Skip to content

Devices: switch between / compare two straps (4.0 + 5/MG) for one user — not fused scores #1300

Open

Description

@ryanbr

Context

A single user may own two WHOOP straps — e.g. a 4.0 and a 5.0/MG — and wants NOOP to make sense of both without pretending they're one score. Today NOOP treats each strap as an independent row you switch between (the header even counts "YOUR BANDS … N paired", DevicesView.swift:143-145), and the live scores path is deliberately single-owner-per-day (DayOwnerResolver, invariant I2: scores are never mixed across sources). The multi-device fusion engine exists but is experimental and off the live path (the "Your Data, Fused" host; spec docs/superpowers/specs/2026-06-19-v5-local-multi-device-fusion-design.md is still "Design, not approved").

This issue proposes a spectrum of opt-in options in the Devices window, from the simplest (switch) to the useful-and-safe (compare/correlate) — and explicitly does not merge two straps into one score.

Non-goals (the design line we keep)

  • Do NOT fuse two straps into one recovery/strain/sleep score. The live scores path stays single-owner-per-day (DayOwnerResolver, Packages/StrandAnalytics/.../DayOwnerResolver.swift:6-20; invariant I2). Combining into scores fights the architecture on purpose.
  • No change to the "at most one .active device" invariant (I1, DeviceRegistryStore.setActive).

Proposal — three tiers, opt-in

1. Switch between straps (a dropdown) — the "don't combine" option

The simplest answer: let the user flip which strap they're looking at, without any fusion. This reuses the existing single-active + DayOwner machinery — "Make active" already does exactly this per card (DevicesView.swift:256-267), so a dropdown/segmented switcher (in the Devices header, and optionally the Today header) is just a faster, clearer UI over the mechanism that already exists.

  • Lowest risk: no identity work, no fusion, no I2 concerns — it only changes which strap owns the view/day.
  • Good default for "I wear the 4.0 for scores and the 5 for ECG/SpO₂, and I just want to switch."

2. Compare straps (correlate) — read-only, display-only

A "Compare straps" card in the Devices window that shows, per metric, 4.0 value vs 5/MG value + an agreement state (agree / minor delta / conflict), using tolerances that ALREADY exist (MetricArbitrationPolicy.tolerance: RHR ±3/±8 bpm, HRV ±8/±20 ms, SpO₂ ±2/±4 %, skin-temp ±0.5/±1.5 °C, sleep ±20/±60 min, …). The agreement classifier already exists too (FusionResolver.classify → agree/minorDelta/conflict).

  • Read-only, does not touch scores → safe against I2.
  • Answers "how do my two straps compare?" honestly, with provenance ("from 4.0 / from 5.0").
  • The one enabling fix: generalize FusionSource from a brand enum to a per-deviceId source (see below). This also unblocks any future real fusion.

3. Combine into one fused record — NOT recommended here

Extending the experimental fused record (never the scores) to a per-strap model is possible but is a bigger, spec-gated change. Out of scope for this issue; call it out only so we don't drift into it.

De-registration behaviour (forget one strap, keep the other)

"Forget device" hard-purges a strap's rows (DeviceRegistry.forget → DeviceRegistryStore.remove, #1193). The switch/compare UI must degrade gracefully:

  • Switcher: if the forgotten strap was the one being viewed/active, fall back to the remaining strap (the "pick a new active" prompt already exists after removing the active device, DevicesView.swift:373-384). With one strap left, the switcher collapses to a single entry (or hides).
  • Compare card: shown only when ≥ 2 live straps exist; hides automatically when a strap is forgotten, leaving no orphaned "group".
  • No dangling group state: whatever grouping/pairing metadata we add must be keyed by deviceId and cleaned up in the same remove/archive path, so forgetting a strap can never leave a half-group or point compare/fusion at a purged id.
  • Data already written under the forgotten strap's id is purged by forget; the remaining strap's history is untouched (independent deviceId keys).

Enabling changes / prerequisites

  1. Generalize FusionSource to per-deviceId (blocker for compare). Today FusionSource is an enum keyed by brand — case whoopImport = "my-whoop" (Packages/StrandAnalytics/.../FusionTypes.swift:17-32). Two WHOOP straps (my-whoop + whoop-<uuid>) both collapse to .whoopImport and overwrite each other in buildTodayFusedRecord (Strand/App/AppModel.swift:1768-1784), and the agreement logic only compares different sources — so it cannot correlate two straps of the same brand. Compare needs a per-deviceId source model. Value-for-value Kotlin twin.
  2. WHOOP serial identity (robustness prereq, separable). WHOOP identity is anchored to a transient CoreBluetooth UUID (whoop-<CBUUID>), not the strap serial — the serial exists in the DIS but is used only to tell MG from 5.0 (Whoop5Variant.swift:43-78), and serial-based identity adoption is wired for Oura only (DeviceRegistryStore.adoptSerialIdentity, Oura caller in SourceCoordinator.swift:368). So the same strap can appear as two rows across a re-pair, and there's no WHOOP adoptSerialIdentity. The switcher (tier 1) works fine on current ids; compare (tier 2) works while both are paired; but robust long-term identity wants a WHOOP serial-adoption path. Track as its own change.

Scope / risks

Anchors

Devices UI: Strand/Screens/DevicesView.swift, android/.../ui/DevicesScreen.kt. Identity/registry: Packages/WhoopStore/.../PairedDevice.swift, DeviceRegistryStore.swift, Strand/Data/DeviceRegistry.swift. Fusion: MetricArbitrationPolicy.swift, FusionResolver.swift, FusionTypes.swift, AppModel.buildTodayFusedRecord. Scores owner: DayOwnerResolver.swift.

Activity

  1. ryanbr commented on Aug 13, 2026

    @ryanbr
    OwnerAuthor

    Tier 2 UI — the "Compare straps" card (ready-to-build spec)

    Wires the StrapComparison engine (#1302) into the Devices window. Build after #1302 merges (clean base) — it's app-target UI + a data bridge, validated on the app-build gate and a real device.

    Data flow

    1. Pick the day: the most recent local day BOTH straps banked data for (fall back to a short day-picker). If there's no overlapping day → a calm "no shared day yet" empty state (both straps must have been worn to compare).
    2. Read both dailies: Repository.dailyMetrics(...) for each strap's deviceId (per-deviceId, not the hardcoded my-whoop — this card is exactly the kind of path Multi-WHOOP: sweep the hardcoded "my-whoop" assumptions (the #512 class) #1304 protects).
    3. Bridge DailyMetric → [MetricKind: Double] (app-layer glue, byte-parity Swift/Kotlin): restingHR, avg/max HR→heartRate, avgHrv→hrv, spo2Pct→spo2, skinTempDevC→skinTemp, steps, totalSleepMin→sleep, activeKcalEst→calories.
    4. StrapComparison.compare(a, b) → rows → render.

    UI (both platforms, model on the existing Devices cards)

    • A card headed by the two strap names (A = 4.0, B = 5/MG) with provenance.
    • One row per metric: label · value A · value B · an agreement chip (agree = calm/positive, minorDelta = neutral, conflict = attention but not alarm-red — reuse the calm token family, consistent with the rest of Devices).
    • Shown only with 2+ straps (auto-collapses on de-register, like the switcher).
    • Read-only, display-only — never mixes into a score (I2 preserved).

    Non-goals (stay out)

    • No FusionSource-per-deviceId refactor (not needed for a read-only compare).
    • No fused scores. No N-way (2 straps; note the limit for 3).

    Verification

    swift-packages/android already cover the engine; the card is app-target Swift → app-build gate + on-device check (does it read both straps, render agreement, handle no-shared-day).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions