Repository navigation
Description
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
.activedevice" 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
FusionSourcefrom a brand enum to a per-deviceIdsource (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
deviceIdand cleaned up in the sameremove/archivepath, 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
deviceIdkeys).
Enabling changes / prerequisites
- Generalize
FusionSourceto per-deviceId(blocker for compare). TodayFusionSourceis 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.whoopImportand overwrite each other inbuildTodayFusedRecord(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-deviceIdsource model. Value-for-value Kotlin twin. - 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 inSourceCoordinator.swift:368). So the same strap can appear as two rows across a re-pair, and there's no WHOOPadoptSerialIdentity. 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
- Tiers 1 and 2 are independently shippable. Tier 1 (switcher) is low-risk and mostly-existing. Tier 2 (compare) needs only the
FusionSourcegeneralization + a read-only card. - Keep everything opt-in and display-only; never mix scores across sources.
- Multi-strap stresses the identity/dedup paths (Duplicate device entries for one physical strap — removal doesn't stick (iOS 4.0 + Android 5.0) #1193/Duplicate device entries for one physical strap — removal doesn't stick #740; the Workouts: read HR under the recording strap's id, not a hardcoded default (multi-WHOOP) #512 workout-HR-blank class of bug) — lean on the proven per-row read-key resolver (
Repository.workoutHrDeviceIds) and union patterns rather than hardcoded ids.
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
ryanbr commentedon Aug 13, 2026
Tier 2 UI — the "Compare straps" card (ready-to-build spec)
Wires the
StrapComparisonengine (#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
Repository.dailyMetrics(...)for each strap'sdeviceId(per-deviceId, not the hardcodedmy-whoop— this card is exactly the kind of path Multi-WHOOP: sweep the hardcoded "my-whoop" assumptions (the #512 class) #1304 protects).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.StrapComparison.compare(a, b)→ rows → render.UI (both platforms, model on the existing Devices cards)
agree= calm/positive,minorDelta= neutral,conflict= attention but not alarm-red — reuse the calm token family, consistent with the rest of Devices).Non-goals (stay out)
FusionSource-per-deviceIdrefactor (not needed for a read-only compare).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).analytics: two-strap comparison engine (#1300 tier 2 — correlate, not…
devices: strap switcher to flip the active strap (#1300 — switch, don…
devices: attach the compare .task to a child view, not the body chain (…
devices: broaden the compare card to Oura + WHOOP, not WHOOP-only (#1300
devices: read the two straps' dailies concurrently in the compare card (
devices: compare-straps card, wiring the #1302 engine (#1300 tier 2) (#…
analytics: two-strap comparison engine (ryanbr#1300 tier 2 — correlat…
devices: strap switcher to flip the active strap (ryanbr#1300 — switc…
devices: compare-straps card, wiring the ryanbr#1302 engine (ryanbr#1300
fix(timeline): name the active device on the Deep Timeline source row (…