Skip to content

feat(oura): persist reconstructed hypnogram as a ring-provided sleep session - #446

Closed
pipiche38 wants to merge 27 commits into
ryanbr:mainfrom
pipiche38:oura-sleep-stage-timeline-persist
Closed

pipiche38 wants to merge 27 commits into
ryanbr:mainfrom
pipiche38:oura-sleep-stage-timeline-persist

Conversation

@pipiche38

Copy link
Copy Markdown

Follow-on to #325. Persists the reconstructed Oura hypnogram as a real night with a stage breakdown.

What

Banks the anchored hypnogram as a CachedSleepSession ([{start,end,stage}] stagesJSON) under the ring's
own deviceId — the imported/measured side, not the -noop computed sibling. SleepMerge's existing
imported-over-computed rule then surfaces Oura's SleepNet staging over NOOP's sparse-motion computed night
("richer record wins" — the same arbitration that picks a WHOOP/HC import over a computed night). Reuses the
#988/HC stage-timeline path: no new merge inputs, read-id plumbing, or schema migration.

Sidesteps the #960 Tier-B durable-rows pin: sleep phases are Tier-A provided data (the ring's own
classifier), only reshaped — not a Tier-B derivation like the activity MET estimate.

How

  • Pure byte-identical mappers (Swift OuraSleepSessionMapping + Kotlin twin); stagesJSON hand-built in
    fixed key order. 6 + 6 tests assert the JSON verbatim.
  • OuraLiveSource.persistHypnogramBurst builds the session from the same anchored + 0x49-refined codes and
    upserts via a new persistSleepSession closure → store.upsertSleepSessions, keyed by the ring deviceId
    (idempotent by startTs). A sleep session persisted … strap-log line makes it observable on-device.

Verification

  • App-target Swift compiled: Strand (macOS) + NOOPiOS (iOS). WhoopStore swift test + Android compile green.
  • ⚠️ On-hardware validation pending — this is on the Strand/BLE path, so compile proves nothing about the
    drain → persist → merge → display chain. Multi-night plan (tracks a varying input; cross-checked vs WHOOP +
    wearer-reported) tracked on the branch.

Open question for review

The persisted night feeds the recovery gate via imported-wins. I read this as compliant (ring's own Tier-A
classification; Oura is ExperimentalBrand-gated), but a cross-check showed deep sleep ~2× off WHOOP — open
to making it display-only instead.

pipiche38 and others added 20 commits July 11, 2026 19:10
…, drain to bytes_left==0

The 0x11 GetEvents summary was misread on main (the old ryanbr#91 fix never
merged): body[0] is events_received (a batch COUNT, not a status - the
2026-07-11 18:53 drain stopped on events==0 with 400,873 bytes STILL
LEFT, abandoning the newest data incl. the night's hypnogram), and
bytes[2..5] is bytes_left (a remaining-BYTE count, not a cursor -
persisting it and comparing across sessions as clocks minted a phantom
"ring-time regression" -> reset-to-0 -> full history re-dump on every
connect; both morning/evening logs show it byte-for-byte).

Package:
- parseGetEventsResponse -> (eventsReceived, bytesLeft, moreData) per
  open_oura's EventBatchSummary; moreData = bytesLeft > 0.
- 3 golden tests from the real captured summaries, incl. the exact
  events==0/bytes_left==400,873 case that must continue.

App (OuraLiveSource):
- The drain now runs until bytes_left == 0. The response byte-count is
  NEVER persisted.
- The durable resume point (open_oura nextEventToSync) is the newest
  STORED history sample's ring-time (maxStoredRingTime), advanced only
  by samples that resolved a REAL anchored time (incl. drained
  pending-anchor parks; never wall-clock fallbacks), bounded by a
  plausibility ceiling, and committed only at drain end when it resolves
  under the current anchor.
- Genuine ring reboot detection: a stored sample OLDER than the seek
  cursor (sawPreResumeData) -> full pull next connect.
- Backstop guards only (a healthy drain ends at 0): bytes_left stall
  (3 flat summaries) and a 300s wall deadline; a forced stop keeps
  banked forward progress, never resets to 0.
- A persisted cursor above the ceiling (pre-fix garbage) resets to a
  full pull at load.
- Log marker [cursor-fix v1] so the running build is identifiable.

swift test: 91/91. macOS app builds (BUILD SUCCEEDED). To validate
on-device: connect twice - 1st drain should end "caught up (bytes_left
0)" + "resume cursor advanced", 2nd should fetch only the delta (no
phantom regression, no full re-dump), and the previous night's sleep
data should finally arrive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj38eZ4JMejA813XhYvMk6
…al log

Three fixes so the ring's hypnogram can be validated against WHOOP:

(2) Stage mapping: OuraSleepStage now follows open_oura's VALIDATED
decode_sleep_phases order (0=deep, 1=light, 2=rem, 3=awake). NOOP had
0=awake/2=deep/3=rem from the same unverified doc as the rest of s6.12;
two live captures contradict it - phase records decoded AT WAKE (wearer
demonstrably awake) carry code 3, which is awake under open_oura's
mapping and "REM" under the old one. Raw codes persist unchanged
(stage.rawValue is the wire code); only the labels move. Golden tests
updated (0x4E + 0x4B: codes 1,2,3,0 -> light, rem, awake, deep).

(3) PK-collapse fix (OuraStreamMapping): the event store's PK is
(deviceId, ts, kind) and every code in a phase record shared the
record's ts, so a whole hypnogram record collapsed to ONE surviving
row (the DB held ~1 row/record; nights lost). Each code's ts is now
offset by its index in seconds - an ORDER-preserving synthetic spacing,
documented as provisional until the real per-code epoch is pinned; the
raw code + index persist unchanged.

(4) Arrival visibility (OuraLiveSource, investigation log): every
0x4B/0x4E/0x5A record now logs its anchored time, code count, and
deep/light/rem/awake histogram inline, plus a per-code cadence
self-check (record gap / previous record's code count, median at
drain-end) - the same technique that pinned the 60s activity MET epoch.
This settles when/whether the ring transmits the night's phase timeline
(the drain now provably reaches the newest data, yet the night's
hypnogram has not appeared in the phase rows - the log will show what
actually arrives).

Tests: OuraProtocol 91/91, WhoopStore 243/243, StrandAnalytics 994/994.
macOS app BUILD SUCCEEDED. No Android Oura twin on main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj38eZ4JMejA813XhYvMk6
…rt drain

The 2026-07-12 morning captures delivered the ring's FULL night hypnogram
(23 records x 52 codes; deep 88 / light 331 / REM 79 / awake 100 min at
30s/code - structure and totals consistent with WHOOP's staging of the
same night), but every record read "[no anchor yet]": a short resume
drain carries no organic 0x42 time-sync, so the session never anchored.
Consequences: the hypnogram wall-clock-stamped at connect time, and the
resume cursor could not commit (4 connects re-dumped the same window).

Root cause: this clean stack never asks the ring for its clock - the
SyncTime send (and the corrected 0x12 layout) were old-branch fixes that
never merged to main. Ported both, from the on-device-proven versions:

- Commands.syncTime is now the authoritative open_oura layout
  `12 09 <unix_secs u64 LE> <tz i8 half-hours>` (supersedes the
  reverse-engineered unix/256 + 0xF6-trailer guess, which did not match
  the native client). Tests updated + a tz-byte test added.
- OuraLiveSource writes syncTime(now) once per session at .streaming,
  BEFORE the first history fetch, so the ring emits a usable 0x42 and
  every fetched record (incl. the night's hypnogram) gets its real
  anchored time; the resume cursor can then commit and the repeated
  re-dump stops.

swift test: 92/92. macOS app BUILD SUCCEEDED. To validate on-device:
"-> sync_time" then "UTC time anchor acquired" early in the connect,
sleep-phase records with REAL night timestamps + the per-code cadence
self-check line, and "resume cursor advanced" at drain end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj38eZ4JMejA813XhYvMk6
Upstream absorbed the ryanbr#91 cursor fix and refactored it into the pure,
unit-tested OuraHistoryDrain core (ryanbr#291) — all 8 conflicts in
OuraLiveSource resolved in favor of that refactor (functionally identical
to this branch's inline version, better factored). Kept from this branch:
the sleep-phase arrival log + per-code cadence self-check (re-added its
teardown resets, which sat inside a conflict hunk), the SyncTime send,
and the stage-mapping/PK-collapse/estimator work (auto-merged cleanly).

Verified after merge: OuraProtocol 106/106 (incl. upstream's
OuraHistoryDrainTests), WhoopStore 256/256, macOS app BUILD SUCCEEDED.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj38eZ4JMejA813XhYvMk6
…st end

The ring's SleepNet finalizes a night AFTER wake and writes the whole
hypnogram to its event log in ONE burst (observed 2026-07-12: 23 records
x 52 codes, envelope ring-times all ~09:30 = the WRITE moment). The
envelope timestamp marks when the analysis was saved, not when the sleep
happened - anchoring per-record collapsed an entire night onto a few
seconds of PK slots (the "vanished hypnogram").

Reconstruction: a burst's codes are one contiguous sequence at 30 s/code
ENDING at the burst's envelope time. The epoch is triple-confirmed:
open_oura sleepnet.md ("classification at 30-second intervals"), the
observed window math (1,196 codes over a ~23:35-09:00 night), and the
0x49 summary's 600-minute window over those same codes.

- OuraProtocol: new pure OuraHypnogramAssembler - groups consecutive
  phase records into bursts by envelope ring-time gap (60 s), lays codes
  backward from the anchored burst end (codesWithTimes). 6 tests incl.
  the real 23x52 night shape and a two-night split.
- OuraLiveSource: phase records feed the assembler instead of enqueueing
  at envelope time; bursts persist on close/drain-end/teardown with
  reconstructed per-code times (wall-clock end fallback when unanchored,
  honest + logged); banked bursts advance the resume cursor; a
  "hypnogram reconstructed [start -> end] ... deep/light/rem/awake min"
  log makes each capture self-evident.
- OuraStreamMapping: store the given ts verbatim (the provisional
  ts+index offset would double-shift reconstructed codes); 30 s spacing
  now guarantees distinct (deviceId, ts, kind) rows.

Tests: OuraProtocol 112/112, WhoopStore 256/256. macOS app BUILD
SUCCEEDED. On-device validation: reconstructed night rows should appear
across the real 23:3x-09:xx window in the event table, matching the
manual reconstruction (deep 88 / light 331 / REM 79 / awake 100 min).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj38eZ4JMejA813XhYvMk6
…wall-clock axis

Resolves the review remarks on upstream ryanbr#325:

PARITY BLOCKER (ryanbr): the Kotlin twin decodes the same tags with the
OLD stage mapping - merging Swift-only would make byte-identical stored
rows decode as deep on iOS and awake on Android. Fixed:
- android OuraEvents.kt: OuraSleepStage flipped to the open_oura
  VALIDATED order (DEEP=0, LIGHT=1, REM=2, AWAKE=3), doc-commented as
  the byte-identical twin of the Swift enum.
- DecoderGoldenTest: 0x6C golden updated (codes 1,2,3,0 -> light, rem,
  awake, deep; byte-identical to the Swift golden) + a raw-value pin
  test so a regression to the old mapping breaks loudly.
- OuraStreamMappingTest: DEEP.raw==0 / REM.raw==2 expectations.
- docs/OURA_PROTOCOL.md s6.12: the line both platforms inherited the
  bug from now teaches 0=deep,1=light,2=rem,3=awake (open_oura
  decode_sleep_phases), with a CORRECTION note, and includes 0x4B.

REVIEW (tigercraft4):
- Stale old-mapping doc lines removed/corrected (OuraEvents.swift
  leftover line above the corrected block; Decoders.swift twin).
- Assembler order robustness: bursts now surface
  hasNonMonotonicRingTimes (arrival order is deliberately NOT re-sorted
  - envelope rts are near-identical write moments, so an unstable sort
  could scramble the true sequence); OuraLiveSource logs the flag.
  New test pins flagged-but-not-resorted behavior.
- Wall-clock fallback removed: an unanchored burst is now HELD
  (pendingUnanchoredBursts, same discipline as pendingAnchorEvents) and
  reconstructed when the 0x42 anchor lands; if the session ends
  unanchored it is DROPPED with an honest log - safe, the resume cursor
  only advances on an anchored persist, so the ring re-serves the same
  records next drain. A night's time axis is never guessed.
- Stored-rows note: no consumer renders OURA_SLEEP_PHASE rows yet (no
  sleep-session builder exists); rows written under the old labels are
  investigation-only. Called out in the PR thread.

Tests: OuraProtocol 116/116 (swift test); macOS app BUILD SUCCEEDED.
Kotlin: mirror edits verified by inspection - no local Java runtime;
android.yml/upstream CI covers testFullDebugUnitTest.
…ame-window re-serve loop

On-device 2026-07-12: one session re-served the same ~955 KB window five times and never
reached the 409 KB tail (last night's hypnogram). Root cause is ours, not the ring's:
the continuation request re-sent the SESSION-START cursor (historyCursor only commits at
drain end) as an open_ring-style max=0 "ack", and it was fired the moment the 0x11 summary
arrived - which the ring emits BEFORE its batch finishes streaming. Re-requesting mid-stream
at a non-advancing cursor makes the ring restart its serve from that cursor.

Aligned with open_oura's drain_events (crates/oura-link/src/client.rs):
- Continuation = getEvents(maxSeenRingTime + 1, max=255, flags=-1) - same shape as the
  initial request; the max=0 ack-fetch is gone (open_oura has no such request).
- The next request waits for the batch to go QUIET (1.5 s with no records, the Swift
  equivalent of open_oura transact()'s quiet window) instead of firing on summary receipt.
- A batch that yields no records, or no cursor advance, STOPS the drain (open_oura
  !progressed) instead of looping until the 3-flat-summaries stall guard trips.
- New OuraHistoryDrain.maxSeenRingTime tracks EVERY history record's envelope ring-time
  (anchored or not, plausibility-capped) for the in-session continuation; the DURABLE
  cursor still commits only from anchored stored samples (unchanged).
- OuraEvent.envelopeRingTimestamp exposes each record's envelope ring-time; history TLV
  ingest routes through ingestHistory() which feeds the drain + keeps the quiet window open.
- Live-HR re-engage keepalive is skipped while a drain is in flight (the Oura app never
  runs live mode during a sync); it resumes on the next 15 s tick.
- OURA_PROTOCOL.md s5.2/s5.3 corrected: 0x11 = events_received/progress/bytes_left (no
  cursor), summary is NOT an end-of-batch marker, and the open_ring ack-fetch is marked
  SUPERSEDED with the observed re-serve evidence.

Verified: OuraProtocol swift test 121/121 (5 new continuation-cursor tests + driver
continuation shape repinned); macOS app xcodebuild BUILD SUCCEEDED. On-device validation
next: reconnect from cursor 3595428 should stream the tail in advancing batches and
deliver the 2026-07-11 night's burst. Android's Oura driver still carries the pre-ryanbr#91
summary model - upstream's tracked Android follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj38eZ4JMejA813XhYvMk6
…t / deadline backlog)

0x71 green_ibi_amp (ryanbr#287/ryanbr#333): every occurrence now logs with anchored time, envelope rt,
payload length, and full raw hex (was once-per-kind with no timestamp) - a verified decoder
needs several real payloads, and the anchored time is what aligns each record with the
concurrent live-HR R-R ground truth. Capped at 50/session against a night-long green-IBI
wall; a drain-end roll-up reports the session count + distinct payload lengths regardless.

Drain self-resume: a drain that ends with KNOWN remaining work now schedules its own next
pass 5 s later instead of waiting for a manual reconnect or the 15 min periodic fetch:
- ring reboot detected (sawPreResumeData -> cursor honestly reset to 0): the full re-pull
  starts immediately on the same connection;
- deadline-guard stop with backlog: progress banked, next pass continues from the new cursor.
A STALL/no-progress stop never chains (that is the ring looping). Capped at 6 passes per
session; a healthy bytes_left==0 completion re-arms the cap. Timer torn down on stop/
disconnect/connect like the other drain timers.

Verified: OuraProtocol swift test all suites 0 failures; macOS app BUILD SUCCEEDED.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj38eZ4JMejA813XhYvMk6
… solved, 0x60 flagged suspect

Ingested github.com/ringverse/protocol/oura (the [ringverse] source our doc already cited
piecemeal). Three findings actioned:

1. 0x71 green_ibi_and_amp: ringverse carries the COMPLETE firmware-addressed decoder
   (p_green_ibi_and_amp @0x503960) - exact scrambled bit map for 5x 11-bit IBIs + 5
   amplitude mantissas, shift=(s==7?0:s+1) from payload[13] bits [2:0], bit [3] reserved
   (set = firmware mismatch), first emitted entry amplitude-only, timestamps walk backward
   by each IBI. Ported verbatim as OuraDecoders.decodeGreenIBIAmpCandidate (Tier-B ryanbr#287,
   strict len-14 gate, never stored) + round-trip/gate golden tests via an inverse packer.
   The 0x71 fixture-capture log now prints the candidate decode (shift/ibis_ms/amps) side
   by side with the raw hex, so captures are immediately comparable to concurrent live R-R.
   OURA_PROTOCOL.md s6.2 rewritten with the full layout + correction of the old "5 deltas +
   6 amplitudes" summary.

2. 0x49 sleep_summary_1 SOLVED: both uint16 LE fields are minutes-before-event offsets -
   the tracked sleep window is [event-start_off, event-end_off]. VALIDATED against our own
   samples: 600/10 on the 7/11->12 night = 23:30->09:20, matching the reconstructed
   hypnogram and the reported sleep. Every 0x49 now logs the derived window as an
   independent cross-check of the reconstruction axis (Tier-B, log-only). s6.12 updated;
   future refinement noted (anchor burst end at event-end_off instead of the write moment).

3. 0x60 decodeIBIAmplitude flagged SUSPECT (s6.1 WARNING): ringverse shows the 11-bit IBI
   pieces are scrambled across non-adjacent bytes; our sequential BitReader read disagrees.
   Cross-check vs live R-R scheduled with the parked history-IBI work - no behavior change
   tonight.

Verified: OuraProtocol swift test 123/123 (2 new); macOS app BUILD SUCCEEDED.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj38eZ4JMejA813XhYvMk6
…anchorless-session gap

On-device 2026-07-13 morning: a mechanically perfect drain (45 advancing batches,
bytes_left 253839 -> 0) ended with NOTHING anchored - no 0x42 arrived all session, so the
night's 1040-code hypnogram burst parked honestly and the resume cursor could not advance.
Root cause: the 0x42 time_sync_ind record is only LOGGED when the ring actually adjusts
its clock; on an already-synced ring (we SyncTime every connect now) a session may never
see one. The anchor was luck-based.

Fix per ringverse BLE.md: the SyncTime RESPONSE (0x13) carries the ring's CURRENT device
timestamp - `13 05 <device_ts:4 LE> <status:1>`. Paired with the host wall-clock at
receipt this is a deterministic ring-time->UTC anchor available at EVERY connect:
- OuraFraming.parseSyncTimeResponse + syncTimeResponseOp (0x13 peeked non-destructively
  like 0x11/0x0D).
- OuraDriver.syncTimeAnchorCandidate (pure): ringverse labels the field "seconds" but the
  record clock runs 100 ms ticks - try raw-ticks and seconds x10 against
  [historyCursor, cursor + 7 days]; exactly ONE must fit, else nil (ambiguous or fresh
  cursor -> log-only, never a guessed anchor).
- OuraDriver.adoptSyncTimeAnchor: same 2020-2035 plausibility gate as 0x42/0x85.
- OuraLiveSource.handleSyncTimeResponse: on success logs the adopted rt + unit and drains
  everything parked while unanchored; on ambiguity logs the raw value for investigation.
  Since the 0x13 lands right after connect (before the drain serves data), history now
  anchors inline and the drain-end cursor commit resolves.
- OURA_PROTOCOL.md s5.4 rewritten: correct u64+tz request layout (the old open_ring
  token/counter/0xf6 guess was still documented), response layout, anchor policy.

Verified: OuraProtocol swift test 126/126 (3 new); macOS app BUILD SUCCEEDED. On-device
next connect should log "UTC anchor from SyncTime response (0x13)" and immediately
reconstruct the re-served night.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj38eZ4JMejA813XhYvMk6
…e moment trailed by 43 min)

On-device 2026-07-13 (first run with the 0x13 anchor - which worked exactly as designed:
deterministic anchor at connect, unit resolved as ticks, cursor finally advanced): the
night reconstructed at 00:11->08:51 while the wearer slept 23:34->08:03. The burst end was
anchored at the SleepNet WRITE moment, which trailed the true sleep end by 43 minutes and
shifted the whole night. The same drain's 0x49 window (event-559/-43 min = 23:32->08:08)
matched the wearer's report within minutes - so the parked refinement is now implemented:

- The 0x49 sleep_summary_1 offsets are stashed (lastSleepWindow049) and paired with the
  hypnogram burst of the SAME finalization (envelope ring-times within 10 min - observed
  seconds apart, the 0x49 arrives right before the phase records).
- The burst end then anchors at event - end_offset (the true sleep end) instead of the
  write envelope; sanity-gated (true end must precede the write by < 6 h) with the write
  moment as fallback when no 0x49 matched. Refinement is logged
  ("burst end refined by 0x49 - SleepNet write ... -> true sleep end ...").
- OURA_PROTOCOL.md s6.12 updated (refinement implemented, +43 min evidence).

Expected axis for last night after a re-pull: ~23:28->08:08 vs reported 23:34->08:03.
Note: rows persisted under the old write-anchored axis are investigation-only (no consumer
renders OURA_SLEEP_PHASE yet); a bulk clean is already on the TODO with the rrInterval one.

Verified: OuraProtocol swift test all green; macOS app BUILD SUCCEEDED.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj38eZ4JMejA813XhYvMk6
…t, PR ryanbr#325 review)

ryanbr is right that the Android twin exists and must not diverge - it carried every bug
Swift fixed this cycle. This ports the complete chain, file by file, with the same fixes,
the same comments, and JVM twin tests mirroring the Swift suites (same fixtures/values):

- Framing.kt: 0x11 summary corrected to open_oura EventBatchSummary
  (events_received/progress/bytes_left - the old cursor misread WAS the ryanbr#91 full re-dump
  loop, still live here); 0x13 SyncTime response parse (deterministic anchor pair);
  lenient parseRecord + ONE-packet-per-notification reassembler (twin of dae3d7a, the
  phantom-storm fix - the old buffering loop was still live here too).
- Commands.kt: syncTime -> u64 seconds LE + tz half-hours (on-device-proven layout; the
  open_ring token/counter/0xF6 guess never anchored real hardware).
- OuraDriver.kt: continuation fetch at the ADVANCED cursor (max=255, open_oura
  drain_events; the max=0 "ack" caused the same-window re-serve loop);
  syncTimeAnchorCandidate (raw-ticks vs seconds x10 disambiguation against the persisted
  cursor) + adoptSyncTimeAnchor (same 2020-2035 plausibility gate); 0x4B routed to the
  validated phase decode.
- EventTags.kt: SLEEP_SUMMARY_B(0x4B) -> SLEEP_PHASE_B, Tier A (twin of dddbe95 - the
  divergence flagged in the PR ryanbr#325 review).
- OuraEvents.kt: envelopeRingTimestamp (feeds the in-session continuation cursor).
- Decoders.kt: decodeGreenIBIAmpCandidate (ringverse p_green_ibi_and_amp @0x503960,
  Tier-B ryanbr#287, log-only).
- NEW OuraHistoryDrain.kt: seen/stored two-cursor drain core (ryanbr#91/ryanbr#291) - guards,
  progress test, reboot detection, cursor commit rules.
- NEW HypnogramAssembler.kt: burst grouping + 30s/code backward time-axis reconstruction.
- ble/OuraLiveSource.kt: sync_time now sent at streaming (was never sent!); 0x13 anchor
  handler; quiet-window batch continuation + no-progress stop; drain-end cursor commit
  (stored-anchored only) + self-chained passes (reboot/deadline backlog); hypnogram
  burst persistence with park-until-anchor + honest teardown drop + the 0x49 true-sleep-
  end refinement; 0x71 fixture-capture log with candidate decode; per-session resets.

STORED-DATA PARITY: OURA_SLEEP_PHASE rows now get the same reconstructed time axis on
both platforms (per-code envelope-time rows were the old Android behavior - the whole
night collapsed onto the write moment).

Verified: compileFullDebugKotlin BUILD SUCCESSFUL; testFullDebugUnitTest 2647 tests -
all com.noop.oura + OuraStreamMapping suites green incl. 30 new/updated twin tests
(OuraHistoryDrainTest, HypnogramAssemblerTest, 0x71 round-trip/gates, 0x13/candidate,
one-packet reassembler, continuation-shape pins). The 3 remaining failures
(AiCoachContextTest, StandardHrSensorFormatTest x2) fail identically on the untouched
tree - pre-existing locale/env issues on this machine, not this change. BLE session
choreography is inspection-only (no Android device on hand) - same validation ask as
the Swift side had before its on-device passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj38eZ4JMejA813XhYvMk6
…session

Bank the anchored Oura hypnogram as a CachedSleepSession with a
[{start,end,stage}] breakdown, upserted under the ring's own deviceId so
SleepMerge's imported-over-computed rule surfaces Oura's SleepNet staging over
NOOP's sparse-motion computed night ("richer record wins" — the same
arbitration that already picks a WHOOP/HC import over a computed night). Reuses
the existing ryanbr#988/HC stage-timeline path; no new merge inputs, read-id plumbing,
or schema migration. Sidesteps the ryanbr#960 Tier-B durable-rows pin: sleep phases
are Tier-A provided data, not a Tier-B derivation.

- Pure byte-identical mappers (Swift OuraSleepSessionMapping + Kotlin twin);
  stagesJSON hand-built in fixed key order; 6+6 tests assert the JSON verbatim.
- Wired via a new persistSleepSession closure in OuraLiveSource ->
  store.upsertSleepSessions, keyed by the ring deviceId. A confirmation strap-log
  line ("sleep session persisted ...") makes the persist observable on-device.

Follow-on to ryanbr#325 (hypnogram reconstruction). App-target Swift compiled (Strand
macOS + NOOPiOS); WhoopStore swift test + Android compile green. On-hardware
validation pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj38eZ4JMejA813XhYvMk6
@ryanbr

ryanbr commented Jul 15, 2026

Copy link
Copy Markdown
Owner

Reviewed — the engineering here is solid and the parity work is genuinely careful:

  • Byte-parity verified. The Swift OuraSleepSessionMapping and Kotlin twin build stagesJSON identically — same fixed key order (start,end,stage), same deep/light/rem/wake tokens, same adjacent-stage merge, and Swift Int / Kotlin Long render the same decimal. The 6+6 golden tests assert the same verbatim JSON. Reusing the docs: add a validation protocol for scoring changes #988/HC stage-timeline path (no new merge inputs or schema migration) is the right call.
  • The Tier-A framing is correct for the Privacy: add retention for scheduled debug exports #960 pin. These phases are the ring's own SleepNet classification, only reshaped — not a Tier-B derivation like the MET estimate. Sidestepping the durable-rows pin is justified.

On your open question — yes, I'd make it display-only for now. The blocker is the one you flagged yourself: the persisted night wins SleepMerge's imported-over-computed rule and therefore feeds the recovery gate, while your own cross-check shows deep sleep ~2× off WHOOP and the Strand/BLE drain→persist→merge→display chain is not yet hardware-validated. That's exactly the case CONTRIBUTING's derived-signal rule guards: don't feed a downstream gate (recovery) on thin evidence. Tier-A answers "whose data is this" (#960) — it doesn't exempt the number from the accuracy bar for driving Charge. A hypnogram 2× off on deep sleep feeding recovery is the failure mode to avoid, and ExperimentalBrand gating narrows the blast radius but doesn't remove it within that cohort.

Concretely, two asks before this is mergeable:

  1. Gate the recovery-feed off (default) so the Oura session is surfaced/display-only and does not win the recovery-relevant merge — until the multi-night validation you planned confirms the staging tracks a varying ground truth (your feat(oura): reconstruct BLE sleep-stage hypnogram #325-style WHOOP + wearer-reported cross-check), not a single night. persistSleepSession already defaults to a no-op, so keeping it out of the recovery input while leaving the display path live looks like a small seam.
  2. Confirm the on-device chain on a real ring (drain → persist → merge → display) — compile can't prove that path.

One lower-priority note: at ~2.9k lines / 20 commits the PR bundles the OuraHistoryDrain + HypnogramAssembler infrastructure with the persist-to-recovery decision. Not a blocker, but splitting the drain/assembler groundwork from the persist step would shrink the review surface and give cleaner revert granularity if the recovery-feed needs to stay off longer than expected.

Nice work overall — the display-only path is a merge-ready feature on its own; it's specifically the recovery-gate feed that needs to wait for validation.

@pipiche38

Copy link
Copy Markdown
Author

@ryanbr thanks for the detailed review. I will do your input, but please consider that this is one is a draft PR which is on top of #325 proving the display-ony (which from your last word should be ready to merge).

@pipiche38

Copy link
Copy Markdown
Author

@ryanbr Thanks again for the careful review — parity/Tier-A framing all landed as intended.

  • On ask Import v8.2.2 snapshot (upstream catch-up + board wave; ports PRs #1035/#1036 + BLE/readiness fixes) #1: I traced the recovery path and I don't think the persisted session actually reaches it. Charge is scored in AnalyticsEngine.analyzeDay from the engine's own staging of the raw HR — matched = SleepStager.detectSleep(...) (AnalyticsEngine.swift:389), restScore = Rest.composite(...) (:451), fed to RecoveryScorer.recovery(sleepPerf: restScore/100) (:616). It never reads back a CachedSleepSession. The persisted hypnogram only wins the display merge (Repository.mergeSleep → sleeps cache, Repository.swift:724), consumed by SleepView + a wake-hour estimate — not the scorer. So the "deep ~2× off feeding recovery" failure mode is a display-fidelity issue, not a recovery-safety one; NOOP's own staging drives Charge, unchanged by this PR.

    If that matches your reading, I'd rather pin it than gate it: add a regression test asserting Charge is byte-identical with vs without the persisted Oura session present, so "display-only" becomes a contract that can't silently regress. Let me know if you see a path I'm missing (iOS Today, debt ledger, etc.).

  • Ask Fork CI: staging-release APK, versioned artifact names, R8-optimised release #2 stands regardless — I'll post the on-device drain→persist→merge→display validation over multiple nights before marking this ready.

But I still beleive that #325 should be ok for merge

@pipiche38

pipiche38 commented Jul 15, 2026 •

Copy link
Copy Markdown
Author

Good progress

Capture d’écran 2026-07-15 à 10 44 43

versus Whoop App capture

Capture d’écran 2026-07-15 à 10 44 19

@pipiche38

Copy link
Copy Markdown
Author

Cross-ref: #375 (vishk23) fixes the same main-session-selection concern on the Oura import paths — a short nap/fragment claiming a day's sleep rollup. This PR is the BLE live-persist twin of that path, and shares the exposure: it currently persists every hypnogram burst, so a low-efficiency daytime fragment can bank as a day's "Oura sleep" (confirmed on-device — two overlapping ~26/52-min eff-0.2 midday sessions alongside the real 572-min night). The paths don't overlap in code (import parser vs OuraLiveSource), so #375 won't cover this one.

Follow-up here will mirror #375's principle — only the main night drives the day — but keyed off duration/SleepStageTotals.mainNightIndex rather than the API's type == "long_sleep" field, which the BLE stream doesn't carry. We do set restingHr/avgHrv to nil, so the impossible-RHR symptom #375 documents can't transfer here; the residual risk is a fragment shown as the night.

@ryanbr

ryanbr commented Jul 16, 2026

Copy link
Copy Markdown
Owner

This is really nicely built — the byte-identical Swift/Kotlin mappers with verbatim-JSON tests, reusing the existing #988/HC merge path with no new schema, and keeping it ExperimentalBrand-gated so it's inert for WHOOP users. CI's green and it still merges clean despite being a way behind. Thanks for the careful write-up, especially the open question.

On that open question — your instinct is right: make it display-only for now. I confirmed there's no guard today, so the persisted night lands on the imported side and, via imported-wins, feeds sleep_performance → recovery. Combined with your own cross-check showing deep sleep ~2× off WHOOP, that runs straight into the derived-signal rule in CLAUDE.md:

never make it the default or feed it a downstream gate (recovery, illness) on thin evidence… land it as instrumentation or behind a default-off toggle.

Deep sleep is a material recovery input, so a 2×-off value (either direction) makes the recovery score materially wrong — even for experimental Oura users, that's a misleading number rather than instrumentation. So:

Requested change: persist + display the Oura hypnogram (great to have the ring's SleepNet staging on the Sleep tab), but exclude Oura-sourced sleep from the recovery / sleep_performance input until the staging is validated the way the rule asks — multi-night, tracking a varying input, cross-checked vs WHOOP + wearer report. Once it validates against a moving target, we flip it to feed recovery. Your multi-night plan on the branch is exactly the right path to earn that.

Two housekeeping items before it can land:

  • Rebase onto current main — it's ~47 commits behind. The 3-way merge is clean here, but please rebase so it's verified against the current tree (and so the app-target OuraLiveSource side gets a real compile against today's code, not just the package tests CI covers).
  • On-hardware pass — as you noted, this is the Strand/BLE drain→persist→merge→display chain, so compile proves nothing about it; a real Oura-ring multi-night is the confirmation.

Happy to look again as soon as it's display-only + rebased.

@pipiche38

Copy link
Copy Markdown
Author

Kindly check PR #325 , that is the initial PR focussed on only Display

@pipiche38

Copy link
Copy Markdown
Author

That PR is well progression.
We have found a way to get IBI-HR corrected decoded with #511 so we might be in position to get Sleep Summary and Sleep Phase associated with HR, further nights are required to cross-check

The 0x49 sleep-window refinement kept only the LATEST window in a single
slot. When an overnight burst and a daytime nap burst finalize in the same
history drain (2026-07-17 capture), the nap's 0x49 overwrote the overnight's
before the overnight burst persisted. The gap check then failed and the
overnight fell back to its SleepNet WRITE time (10:58, ~4 h after the true
07:00 sleep end) instead of the 0x49-refined end -- persisting the night as
01:52->10:58 local rather than 22:00->07:00.

Keep a bounded collection of recent 0x49 windows (cap 16) and pair each burst
with the CLOSEST window by ring-time within the same 10 min tolerance. The
picker is a pure static twin on both platforms (Swift closestSleepWindow049 /
Kotlin companion closestSleepWindow049), byte-identical selection, so the
cross-platform parity contract holds and the pairing is unit-testable with no
strap.

Verification:
- Swift: xcodebuild -scheme Strand -destination platform=macOS
  -only-testing:StrandTests/OuraSleepWindowPairingTests -> 6/6 pass
  (whole app target compiled).
- Android: ./gradlew testFullDebugUnitTest
  --tests com.noop.ble.OuraSleepWindowPairingTest -> 6/6 pass;
  compileFullDebugKotlin clean.
Both suites include the overnight+nap regression that produced the +4 h
mis-anchor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@pipiche38

Copy link
Copy Markdown
Author

Oura overnight IBI-HR vs WHOOP

Night of 2026-07-16, 22:00 → 06:58

Corrected window changes everything. This is a clean, validated result — and it confirms the staging build does carry the merged #511 decoders.

Metric Oura (banked IBI→HR) WHOOP (ground truth)
Minute coverage 99% (532/537 min) —
Artifacts (naive 30–180 gate) 0% (32,612 beats) —
Median sleeping HR 54 bpm RHR 55
Lowest sustained (hourly) 51 bpm —
Mean sleeping HR 57 bpm —

Nocturnal dip (Malik-filtered hourly): 66 → 63 → 64 → 54 → 54 → 51 → 55 → 52 → 52 bpm. Textbook: elevated at onset, floor in the back half, slight pre-wake rise.

What this means

  • The overnight IBI-HR is now genuinely usable. Dense (99% of minutes have beats), essentially artifact-free under a trivial gate, and the Malik 20%-of-median filter barely moves it (57.2 → 56.8) — the corrected decode is clean at the source.
  • It matches WHOOP within ~1 bpm: Oura median sleeping HR 54 vs WHOOP RHR 55; the sustained floor (~51) sits just under WHOOP's RHR, as expected for a lowest-vs-median comparison.
  • It clears the Widget (iOS): throttle the live-HR republish to 60s (#114 follow-up) #194 bar — it tracks a varying input (a real nocturnal dip) and agrees with an independent ground truth (WHOOP). This is exactly the "decisive overnight test" that memory had flagged as pending; it now passes.

@pipiche38

pipiche38 commented Jul 18, 2026 •

Copy link
Copy Markdown
Author

Oura Baseline vs WHOOP, with BLE Nights Placed On It

Robust Oura baseline (267 of your own Oura-official nights) vs WHOOP, with our BLE nights placed on it:

Source Deep Light REM
Oura-official (n=267) 12.7% [0.1–26.6] 67.5% 19.7%
WHOOP (n=176) 21.6% [5.6–43.7] 59.2% 19.2%
BLE 7/16 14.7% ✓ 70.3% ✓ 14.9% ⚠
BLE 7/18 8.7% ✓ 77.0% ✓ 14.3% ⚠

Two clear takeaways

1. deep/light divergence from WHOOP is method, not decode. Our BLE deep% (8.7%, 14.7%) sits squarely inside Oura's own range and near its 12.7% mean — nowhere near WHOOP's 21.6%. Same for light. So the ring's staging, as we decode it, behaves like Oura, not WHOOP. That question is answered.

2. But the check isolated one real flag: REM. Both BLE nights read ~14% REM, while Oura's own method gives you ~20% (and so does WHOOP). That's a ~5–6-point under-call against Oura itself, consistently across both nights — so it's not method difference and not noise. That points at our REM code-mapping in the hypnogram decode, and it's worth a look. This is exactly the kind of thing the WHOOP comparison alone couldn't tell you, and your import idea surfaced it.

Caveat

oura-import is historical (through June, different nights), so this is distribution/pattern matching, not night-matched — and since the ring is NOOP-only now, a fresh night-matched Oura export may not be available. But 267 nights is a solid personal baseline.

@pipiche38

pipiche38 commented Jul 18, 2026 •

Copy link
Copy Markdown
Author

Last night sleep

Whoop App
Capture d’écran 2026-07-18 à 09 56 12

Noop with Oura capture
Capture d’écran 2026-07-18 à 12 59 02

oura-ibi-hr-history → overnight HR now WHOOP-validated across two nights (99% coverage, 0 artifacts, floor ~48–54 vs WHOOP RHR/curve).

@pipiche38

Copy link
Copy Markdown
Author

oura … 2026-07-18 12:00:08 → 12:26:08 UTC = 14:00 → 14:26 local 26 min eff 0.67

What this log shows

A nap, cleanly anchored — this is the nap you meant. The full chain fired:

  • 0x49 sleep window candidate … 14:05:08 → 14:26:08 (event-40/-19 min)
  • hypnogram burst end refined by 0x49 — SleepNet write 14:45:09 → true sleep end 14:26:08 — the anchor correction worked: without it the nap would've been written at the 14:45 drain time (+19 min
    late).
  • hypnogram reconstructed [14:00:08 → 14:26:08, anchored] deep13/light4/rem0/awake8 min → persisted eff 67%.

REM 0 is expected for a 26-min nap; deep-heavy power nap, sane.

But it still isn't the fix test

Two reasons, both unchanged:

  1. Single-window drain — this sync carried only one 0x49 window (the nap's). There's nothing to clobber, so it works on the old code too. The multi-window fix only kicks in when an overnight and
    a nap drain in the same sync.
  2. Still App 9.0.1 = the confirmed pre-fix build.

One thing worth noting

The nap did not hijack the 7/18 sleep-day — the top-of-log summary still shows day=2026-07-18 source=computed, i.e. the 26-min nap did not win the day as a bogus "night." That's the good outcome,
and a small bit of reassurance for the #446 fragment/main-night follow-up — though I wouldn't read it as settled (the 7/17→18 overnight also isn't surfacing as that day's imported night, which
is a bucketing question for later).

Net

The validation state is exactly where it was: bug reproduced (7/16→17), fix still unexercised. You now have a nap and an overnight sitting in the DB, but they drained in separate syncs. The
remaining step is unchanged — build from test/oura-persist-activity-combined (now carries 9fe87e6), then overnight + nap, one sync.

Capture d’écran 2026-07-18 à 14 48 45

pipiche38 and others added 3 commits July 18, 2026 16:29
The Test Centre report bundle gathered report.txt + raw-capture.jsonl (the
WHOOP frame capture) + meta.json, but never the Oura ring's Tier-B JSONL
sidecars (oura-raw / oura-ibihr / oura-activity), so a maintainer could only
get the redacted log lines, not the raw corpora needed to reframe/validate.

Attach them WHEN PRESENT (mirroring raw-capture.jsonl), not behind a test
domain: they cut across Sleep / HRV / Connection / Sources, and file presence
is the honest gate (no ring used -> no files -> nothing attached, so an
Experimental ring needs no separate brand check). The ring UUID inside each
line is masked to <device> by the existing redactEntries pass; entry names are
normalized (id dropped) since redaction never touches names.

- ouraDiagnosticEntries()/normalizedOuraEntryName(): enumerate
  <App Support>/OpenWhoop/Diagnostics for oura-{raw,ibihr,activity}-*.jsonl,
  map to id-free names, keep the largest file per name (read-only; never
  creates the dir so a bundle build has no side effects).
- capEntries(): generalize the raw-capture-only tail-trim to a trimmableNames
  set and split the budget across trimmable files in proportion to size. With a
  single trimmable file this is byte-identical to the prior behaviour (its share
  is the whole remainder), so the existing cap test still passes.
- whoop.sqlite is deliberately NOT attached (large, binary, unredactable;
  meta.json already reports db_bytes + row counts).

App-target Swift (CI doesn't cover it): built Strand (macOS) + NOOPiOS, and
TestBundleAssemblerTests green 8/8 (5 existing + 3 new).

Verified on test/oura-persist-activity-combined; to be retrofitted onto the
per-feature diagnostics branches (oura-sleep-stage-timeline-persist, the
activity/ibihr branches).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj38eZ4JMejA813XhYvMk6
…t (fixes hang)

The review-before-share sheet renders every text bundle entry as a single
SwiftUI Text. When a large research stream is attached — the ~12 MB Oura
oura-raw.jsonl sidecar, or the WHOOP raw-capture — SwiftUI/CoreText tries to lay
out megabytes of text as one Text, allocating >1 GB of glyph storage and pinning
the main thread indefinitely, so generating a report bundle appears to hang.

Diagnosed from a main-thread sample: 100% of samples in
SheetContentRoot.sizeThatFits -> StyledTextLayoutEngine.sizeThatFits ->
CTLineCreateWithAttributedString -> TGlyphEncoder::EncodeChars, at a 1.1 GB
footprint.

ReportReviewGate.previewText now excludes the large raw streams by name
(screenshot.png + raw-capture.jsonl + the oura-{raw,ibihr,activity}.jsonl
sidecars) AND anything over a 1 MiB per-entry guard, so any oversized stream
(a future one, or a pathologically large report.txt) is named in the "attached"
note rather than laid out. report.txt / meta.json / last-crash.txt still show in
full, so the review still shows exactly the report body the user shares; the
excluded streams stay in the .zip and are named so the review stays honest.

Self-contained (no dependency on the Test Centre assembler), so it applies to
main and every feature branch that attaches diagnostics. App-target Swift (CI
doesn't cover it): built Strand (macOS), ReportReviewGateTests 4/4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj38eZ4JMejA813XhYvMk6
@pipiche38 pipiche38 closed this Jul 21, 2026
pipiche38 added a commit to pipiche38/noop that referenced this pull request Jul 22, 2026
… sleep session

The Oura ring banks its whole-night SleepNet phase codes (2-bit deep/light/rem/awake,
4/byte) in one burst after wake. This decodes that burst, reconstructs its time axis
(30 s/code, laid backward from the burst end), refines the true sleep end with the
0x49 sleep-summary window, and upserts the night as a CachedSleepSession under the
ring's own deviceId — so SleepMerge's imported-over-computed rule surfaces Oura's
staging as the night's stages instead of a computed guess.

Pipeline (both platforms, byte-identical Swift/Kotlin twins):
- OuraProtocol: HypnogramAssembler (time-axis reconstruction), 0x49 sleep_summary
  decode + window pairing in OuraHistoryDrain, OuraEvents/Decoders/Framing/OuraDriver
  additions. Pure, no CoreBluetooth; covered by swift test / JVM tests.
- WhoopStore: OuraSleepSessionMapping (anchored per-code hypnogram -> CachedSleepSession
  stage breakdown), OuraStreamMapping sleepPhase pass-through.
- App: OuraLiveSource assembles the burst, anchors + 0x49-refines the end, and calls
  persistSleepSession; SourceCoordinator wires the persist closure.

Tier discipline: the persisted session is a ring-PROVIDED night (Oura's own SleepNet
output), not a NOOP-computed score. Activity/MET (Tier-B) is untouched by this change.

Validated on a real overnight (2026-07-21): reconstructed [22:22:51 -> 08:46:51],
0x49 trimmed 50 min of post-wake write tail, session persisted eff=80%. Time-asleep
8h21 vs the reference app's 8h20 (near-exact); stage split leans light vs the vendor's
cloud-refined hypnogram, as expected for the ring's raw on-device codes.

Supersedes the earlier DRAFT ryanbr#446 (oura-sleep-stage-timeline-persist); clean single
commit off main, no ibiHrDump/rawDump/activity-corpus carried in.

Tests: OuraProtocol 129, WhoopStore 275, Android com.noop.oura.* all green;
Strand macOS BUILD SUCCEEDED; Android compileFullDebugKotlin clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016DN6cU2ERWZUS9tWYCjPvi
pipiche38 added a commit to pipiche38/noop that referenced this pull request Jul 22, 2026
… sleep session, shown as "Oura"

The Oura ring banks its whole-night SleepNet phase codes (2-bit deep/light/rem/awake,
4/byte) in one burst after wake. This decodes that burst, reconstructs its time axis
(30 s/code, laid backward from the burst end), refines the true sleep end with the
0x49 sleep-summary window, and upserts the night as a CachedSleepSession under the
ring's own deviceId — so SleepMerge's imported-over-computed rule surfaces Oura's
staging as the night's stages instead of a computed guess.

Pipeline (both platforms, byte-identical Swift/Kotlin twins):
- OuraProtocol: HypnogramAssembler (time-axis reconstruction), 0x49 sleep_summary
  decode + window pairing in OuraHistoryDrain, OuraEvents/Decoders/Framing/OuraDriver
  additions. Pure, no CoreBluetooth; covered by swift test / JVM tests.
- WhoopStore: OuraSleepSessionMapping (anchored per-code hypnogram -> CachedSleepSession
  stage breakdown), OuraStreamMapping sleepPhase pass-through.
- App: OuraLiveSource assembles the burst, anchors + 0x49-refines the end, and calls
  persistSleepSession; SourceCoordinator wires the persist closure.

Provenance (both platforms): a persisted Oura night is the ring's OWN SleepNet output,
not a NOOP computation, so the sleep surfaces say so honestly instead of the generic
"On-device":
- DeviceBrandCatalog.isOura(deviceId) — ONE canonical resolver, brand table (never an
  "oura" literal); Swift Repository.activeDeviceIsOura + Kotlin SleepScreen call it.
- SleepView / SleepScreen: hero + main-sleep badges read "Oura"; the stage caption reads
  "raw on-device stages"; and a caveat note states the split is the ring's RAW BLE
  classification (more Awake / less Deep+REM) — NOT the Oura app's cloud-adjusted stages.
  daySourceBadge gains the Oura brand so the ring-id session (and By-Day) label correctly.
- Design tokens only (restColor / textTertiary / caption); WHOOP + Apple imports still
  win the provenance order above Oura.

Tier discipline: the persisted session is a ring-PROVIDED night, not a NOOP-computed
score. Activity/MET (Tier-B) is untouched.

Validated on a real overnight (2026-07-21): reconstructed [22:22:51 -> 08:46:51], 0x49
trimmed 50 min of post-wake write tail, session persisted eff=80%. Time-asleep 8h21 vs
the reference app's 8h20; stage split leans light vs the vendor's cloud-refined
hypnogram, as expected for the ring's raw on-device codes.

Supersedes the earlier DRAFT ryanbr#446 (oura-sleep-stage-timeline-persist); clean single
commit off main, no ibiHrDump/rawDump/activity-corpus carried in.

Verification: rebased onto 9.1.0 main. swift test OuraProtocol + WhoopStore green
(incl. DeviceBrandCatalog.isOura, both platforms). macOS Strand BUILD SUCCEEDED;
Android compileFullDebugKotlin OK. The Android unit-test RUN is blocked by an unrelated
9.1.0 test-fake gap (ryanbr#716 DeviceRegistryDao.setModel) that fails testFullDebug compile;
the added Kotlin isOura test mirrors the passing Swift one and runs once that's fixed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pipiche38 added a commit to pipiche38/noop that referenced this pull request Jul 22, 2026
… sleep session, shown as "Oura"

The Oura ring banks its whole-night SleepNet phase codes (2-bit deep/light/rem/awake,
4/byte) in one burst after wake. This decodes that burst, reconstructs its time axis
(30 s/code, laid backward from the burst end), refines the true sleep end with the
0x49 sleep-summary window, and upserts the night as a CachedSleepSession under the
ring's own deviceId — so SleepMerge's imported-over-computed rule surfaces Oura's
staging as the night's stages instead of a computed guess.

Pipeline (both platforms, byte-identical Swift/Kotlin twins):
- OuraProtocol: HypnogramAssembler (time-axis reconstruction), 0x49 sleep_summary
  decode + window pairing in OuraHistoryDrain, OuraEvents/Decoders/Framing/OuraDriver
  additions. Pure, no CoreBluetooth; covered by swift test / JVM tests.
- WhoopStore: OuraSleepSessionMapping (anchored per-code hypnogram -> CachedSleepSession
  stage breakdown), OuraStreamMapping sleepPhase pass-through.
- App: OuraLiveSource assembles the burst, anchors + 0x49-refines the end, and calls
  persistSleepSession; SourceCoordinator wires the persist closure.

Provenance (both platforms): a persisted Oura night is the ring's OWN SleepNet output,
not a NOOP computation, so the sleep surfaces say so honestly instead of the generic
"On-device":
- DeviceBrandCatalog.isOura(deviceId) — ONE canonical resolver, brand table (never an
  "oura" literal); Swift Repository.activeDeviceIsOura + Kotlin SleepScreen call it.
- SleepView / SleepScreen: hero + main-sleep badges read "Oura"; the stage caption reads
  "raw on-device stages"; and a caveat note states the split is the ring's RAW BLE
  classification (more Awake / less Deep+REM) — NOT the Oura app's cloud-adjusted stages.
  daySourceBadge gains the Oura brand so the ring-id session (and By-Day) label correctly.
- Design tokens only (restColor / textTertiary / caption); WHOOP + Apple imports still
  win the provenance order above Oura.

Tier discipline: the persisted session is a ring-PROVIDED night, not a NOOP-computed
score. Activity/MET (Tier-B) is untouched.

Validated on a real overnight (2026-07-21): reconstructed [22:22:51 -> 08:46:51], 0x49
trimmed 50 min of post-wake write tail, session persisted eff=80%. Time-asleep 8h21 vs
the reference app's 8h20; stage split leans light vs the vendor's cloud-refined
hypnogram, as expected for the ring's raw on-device codes.

Supersedes the earlier DRAFT ryanbr#446 (oura-sleep-stage-timeline-persist); clean single
commit off main, no ibiHrDump/rawDump/activity-corpus carried in.

Verification: rebased onto 9.1.0 main. swift test OuraProtocol + WhoopStore green
(incl. DeviceBrandCatalog.isOura, both platforms). macOS Strand BUILD SUCCEEDED;
Android compileFullDebugKotlin OK. The Android unit-test RUN is blocked by an unrelated
9.1.0 test-fake gap (ryanbr#716 DeviceRegistryDao.setModel) that fails testFullDebug compile;
the added Kotlin isOura test mirrors the passing Swift one and runs once that's fixed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@pipiche38
pipiche38 deleted the oura-sleep-stage-timeline-persist branch July 22, 2026 18:03
pipiche38 added a commit to pipiche38/noop that referenced this pull request Jul 23, 2026
… sleep session, shown as "Oura"

Decode the ring's whole-night SleepNet phase-code burst, reconstruct its 30 s/code time axis,
anchor+refine it with the 0x49 sleep-summary window (BOTH ends), and upsert the night as a
CachedSleepSession under the ring's own deviceId — so SleepMerge's imported-over-computed rule
surfaces Oura's own staging instead of a computed guess. The sleep surfaces then label that night
honestly as "Oura" with a caveat that its stages are the ring's raw on-device classification.

Persist (both platforms, byte-identical Swift/Kotlin twins):
- OuraProtocol: HypnogramAssembler time-axis reconstruction with the 0x49 window applied at BOTH
  ends — the end anchored to the sleep-end, and the START clamped to the sleep-onset so the burst's
  pre-window epochs are clipped (the clip lives in the PURE codesWithTimes(…, sleepStartUnixSeconds:)
  with a never-empty guard, so a mis-paired 0x49 can never erase the night); 0x49 pairing in
  OuraHistoryDrain; OuraEvents/Decoders/Framing/OuraDriver additions.
- WhoopStore: OuraSleepSessionMapping (anchored per-code hypnogram → stage breakdown), OuraStreamMapping
  sleepPhase pass-through.
- App: OuraLiveSource assembles the burst, anchors + 0x49-refines both ends, calls persistSleepSession;
  SourceCoordinator wires the persist closure.

Provenance UI (both platforms): a persisted Oura night is the ring's OWN SleepNet output, not a NOOP
computation, so the surfaces say so instead of the generic "On-device":
- DeviceBrandCatalog.isOura(deviceId) — ONE canonical resolver (brand table, no "oura" literal);
  Swift Repository.activeDeviceIsOura + Kotlin SleepScreen call it.
- SleepView / SleepScreen: hero + main-sleep badges read "Oura"; the stage caption reads "raw
  on-device stages"; a caveat note states the split is the ring's RAW BLE classification (more Awake /
  less Deep+REM) — NOT the Oura app's cloud-adjusted stages. daySourceBadge gains the Oura brand so the
  ring-id session (and By-Day) label correctly. Design tokens only; WHOOP/Apple imports still win the
  provenance order above Oura.

Tier discipline: the persisted session is a ring-PROVIDED night, not a NOOP-computed score.
Supersedes DRAFT ryanbr#446; docs/OURA_PROTOCOL.md updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pipiche38 added a commit to pipiche38/noop that referenced this pull request Jul 23, 2026
… sleep session, shown as "Oura"

Decode the ring's whole-night SleepNet phase-code burst, reconstruct its 30 s/code time axis,
anchor+refine it with the 0x49 sleep-summary window (BOTH ends), and upsert the night as a
CachedSleepSession under the ring's own deviceId — so SleepMerge's imported-over-computed rule
surfaces Oura's own staging instead of a computed guess. The sleep surfaces then label that night
honestly as "Oura" with a caveat that its stages are the ring's raw on-device classification.

Persist (both platforms, byte-identical Swift/Kotlin twins):
- OuraProtocol: HypnogramAssembler time-axis reconstruction with the 0x49 window applied at BOTH
  ends — the end anchored to the sleep-end, and the START clamped to the sleep-onset so the burst's
  pre-window epochs are clipped (the clip lives in the PURE codesWithTimes(…, sleepStartUnixSeconds:)
  with a never-empty guard, so a mis-paired 0x49 can never erase the night); 0x49 pairing in
  OuraHistoryDrain; OuraEvents/Decoders/Framing/OuraDriver additions.
- WhoopStore: OuraSleepSessionMapping (anchored per-code hypnogram → stage breakdown), OuraStreamMapping
  sleepPhase pass-through.
- App: OuraLiveSource assembles the burst, anchors + 0x49-refines both ends, calls persistSleepSession;
  SourceCoordinator wires the persist closure.

Provenance UI (both platforms): a persisted Oura night is the ring's OWN SleepNet output, not a NOOP
computation, so the surfaces say so instead of the generic "On-device":
- DeviceBrandCatalog.isOura(deviceId) — ONE canonical resolver (brand table, no "oura" literal);
  Swift Repository.activeDeviceIsOura + Kotlin SleepScreen call it.
- SleepView / SleepScreen: hero + main-sleep badges read "Oura"; the stage caption reads "raw
  on-device stages"; a caveat note states the split is the ring's RAW BLE classification (more Awake /
  less Deep+REM) — NOT the Oura app's cloud-adjusted stages. daySourceBadge gains the Oura brand so the
  ring-id session (and By-Day) label correctly. Design tokens only; WHOOP/Apple imports still win the
  provenance order above Oura.

Tier discipline: the persisted session is a ring-PROVIDED night, not a NOOP-computed score.
Supersedes DRAFT ryanbr#446; docs/OURA_PROTOCOL.md updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pipiche38 added a commit to pipiche38/noop that referenced this pull request Jul 24, 2026
… sleep session, shown as "Oura"

Decode the ring's whole-night SleepNet phase-code burst, reconstruct its 30 s/code time axis,
anchor+refine it with the 0x49 sleep-summary window (BOTH ends), and upsert the night as a
CachedSleepSession under the ring's own deviceId — so SleepMerge's imported-over-computed rule
surfaces Oura's own staging instead of a computed guess. The sleep surfaces then label that night
honestly as "Oura" with a caveat that its stages are the ring's raw on-device classification.

Persist (both platforms, byte-identical Swift/Kotlin twins):
- OuraProtocol: HypnogramAssembler time-axis reconstruction with the 0x49 window applied at BOTH
  ends — the end anchored to the sleep-end, and the START clamped to the sleep-onset so the burst's
  pre-window epochs are clipped (the clip lives in the PURE codesWithTimes(…, sleepStartUnixSeconds:)
  with a never-empty guard, so a mis-paired 0x49 can never erase the night); 0x49 pairing in
  OuraHistoryDrain; OuraEvents/Decoders/Framing/OuraDriver additions.
- WhoopStore: OuraSleepSessionMapping (anchored per-code hypnogram → stage breakdown), OuraStreamMapping
  sleepPhase pass-through.
- App: OuraLiveSource assembles the burst, anchors + 0x49-refines both ends, calls persistSleepSession;
  SourceCoordinator wires the persist closure.

Provenance UI (both platforms): a persisted Oura night is the ring's OWN SleepNet output, not a NOOP
computation, so the surfaces say so instead of the generic "On-device":
- DeviceBrandCatalog.isOura(deviceId) — ONE canonical resolver (brand table, no "oura" literal);
  Swift Repository.activeDeviceIsOura + Kotlin SleepScreen call it.
- SleepView / SleepScreen: hero + main-sleep badges read "Oura"; the stage caption reads "raw
  on-device stages"; a caveat note states the split is the ring's RAW BLE classification (more Awake /
  less Deep+REM) — NOT the Oura app's cloud-adjusted stages. daySourceBadge gains the Oura brand so the
  ring-id session (and By-Day) label correctly. Design tokens only; WHOOP/Apple imports still win the
  provenance order above Oura.

Tier discipline: the persisted session is a ring-PROVIDED night, not a NOOP-computed score.
Supersedes DRAFT ryanbr#446; docs/OURA_PROTOCOL.md updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ryanbr pushed a commit that referenced this pull request Jul 24, 2026
… labelled "Oura" (#773)

* feat(oura): persist the ring's SleepNet hypnogram as a stage-timeline sleep session, shown as "Oura"

Decode the ring's whole-night SleepNet phase-code burst, reconstruct its 30 s/code time axis,
anchor+refine it with the 0x49 sleep-summary window (BOTH ends), and upsert the night as a
CachedSleepSession under the ring's own deviceId — so SleepMerge's imported-over-computed rule
surfaces Oura's own staging instead of a computed guess. The sleep surfaces then label that night
honestly as "Oura" with a caveat that its stages are the ring's raw on-device classification.

Persist (both platforms, byte-identical Swift/Kotlin twins):
- OuraProtocol: HypnogramAssembler time-axis reconstruction with the 0x49 window applied at BOTH
  ends — the end anchored to the sleep-end, and the START clamped to the sleep-onset so the burst's
  pre-window epochs are clipped (the clip lives in the PURE codesWithTimes(…, sleepStartUnixSeconds:)
  with a never-empty guard, so a mis-paired 0x49 can never erase the night); 0x49 pairing in
  OuraHistoryDrain; OuraEvents/Decoders/Framing/OuraDriver additions.
- WhoopStore: OuraSleepSessionMapping (anchored per-code hypnogram → stage breakdown), OuraStreamMapping
  sleepPhase pass-through.
- App: OuraLiveSource assembles the burst, anchors + 0x49-refines both ends, calls persistSleepSession;
  SourceCoordinator wires the persist closure.

Provenance UI (both platforms): a persisted Oura night is the ring's OWN SleepNet output, not a NOOP
computation, so the surfaces say so instead of the generic "On-device":
- DeviceBrandCatalog.isOura(deviceId) — ONE canonical resolver (brand table, no "oura" literal);
  Swift Repository.activeDeviceIsOura + Kotlin SleepScreen call it.
- SleepView / SleepScreen: hero + main-sleep badges read "Oura"; the stage caption reads "raw
  on-device stages"; a caveat note states the split is the ring's RAW BLE classification (more Awake /
  less Deep+REM) — NOT the Oura app's cloud-adjusted stages. daySourceBadge gains the Oura brand so the
  ring-id session (and By-Day) label correctly. Design tokens only; WHOOP/Apple imports still win the
  provenance order above Oura.

Tier discipline: the persisted session is a ring-PROVIDED night, not a NOOP-computed score.
Supersedes DRAFT #446; docs/OURA_PROTOCOL.md updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(oura): coverage for the SleepNet persist, "Oura" provenance, and 0x49 start-clamp

Unit tests for the feature above, on both platforms (byte-identical Swift/Kotlin fixtures):
- HypnogramAssembler: the 0x49 start-clamp in the pure codesWithTimes — drops codes before the onset,
  inclusive boundary, an onset before the burst keeps all, and the never-empty fallback for a
  mis-paired 0x49; plus the existing backward-layout cases.
- OuraSleepSessionMapping: anchored per-code hypnogram → CachedSleepSession stage breakdown.
- OuraSleepWindowPairing: closest-0x49-window selection (overnight vs nap, tolerance, empties).
- DeviceBrandCatalog.isOura: registry-id → brand resolution (Oura true; WHOOP/computed/import false).
- OuraStreamMapping / Decoder golden / Framing / Driver / HistoryDrain: the twin decode + mapping tests.

swift test OuraProtocol + WhoopStore green; the Kotlin twins mirror them (their run is gated only by
the unrelated #716 test-fake compile gap / PR #725).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* i18n(oura): localize the Oura raw-stages caveat (Apple) + baseline it (Android)

The SleepNet provenance caveat added with the Oura raw-stages note was new UI copy the i18n gate
(`Tools/i18n_audit.py --ci`) flagged: on Apple the two literals were absent from their String Catalog;
on Android they were new hardcoded literals not in the baseline.

- Apple: add both strings — the `Text` caveat and its `accessibilityLabel` — to
  Strand/Resources/Localizable.xcstrings with translations for all 8 focus locales
  (de, es, fr, it, pt-PT, ru, zh-Hans, zh-Hant), state `translated`, mirroring the sibling
  "Low confidence staging" note's terminology (deep/REM, efficiency, stages, "anneau/Ring/anillo/…").
- Android: `SleepScreen.kt` is fully hardcoded (0 stringResource, 6 entries already baselined) and has no
  low-confidence twin, so the two literals join the accepted backlog via `--update-baseline` — the diff is
  exactly the two SleepScreen.kt entries, nothing else. (Localizing the Android sleep screen's copy is a
  separate backlog task; this only closes the NEW-literal regression.)

`python3 Tools/i18n_audit.py --ci origin/main` now exits 0 (Android: no new hardcoded literals + focus
locales complete; Apple: no new un-extracted literals + all catalogs complete).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
ryanbr pushed a commit that referenced this pull request Aug 2, 2026
The Home Screen widget showed a large Charge percentage with sparse footer icons; Effort and Rest were already in WidgetSnapshot from #446 but unused on the compact families. Small and medium now show the three daily scores as rings in Today's order, large keeps its stat grid beneath them, and the Lock Screen rectangular accessory carries the same trio.

Rings are static rather than GlowRing's draw-in animation: WidgetKit does not reliably fire onAppear, so an animated ring can freeze empty until the next timeline rebuild. An unscored day renders as a dash over an empty track, never a fabricated zero.

Effort honours the #313 scale (#1022 review): the display string carries the WHOOP 0-21 value while the arc fraction stays on the stored 0-100 axis, so rings stay comparable between users on different scales. Pre-formatted at publish time because effortScaleKey is a plain @AppStorage outside the App Group. The new WidgetSnapshot fields are optional, so an already-persisted snapshot still decodes.

Merged with --admin: this fork PR's workflows were never triggered, so it carried no checks. Verified out of band instead - app-build green on both legs against current main (the only compile these 241 lines have had), plus doc-comments and i18n run locally on the merge result. iOS-only, so Android CI does not apply.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
… labelled "Oura" (ryanbr#773)

* feat(oura): persist the ring's SleepNet hypnogram as a stage-timeline sleep session, shown as "Oura"

Decode the ring's whole-night SleepNet phase-code burst, reconstruct its 30 s/code time axis,
anchor+refine it with the 0x49 sleep-summary window (BOTH ends), and upsert the night as a
CachedSleepSession under the ring's own deviceId — so SleepMerge's imported-over-computed rule
surfaces Oura's own staging instead of a computed guess. The sleep surfaces then label that night
honestly as "Oura" with a caveat that its stages are the ring's raw on-device classification.

Persist (both platforms, byte-identical Swift/Kotlin twins):
- OuraProtocol: HypnogramAssembler time-axis reconstruction with the 0x49 window applied at BOTH
  ends — the end anchored to the sleep-end, and the START clamped to the sleep-onset so the burst's
  pre-window epochs are clipped (the clip lives in the PURE codesWithTimes(…, sleepStartUnixSeconds:)
  with a never-empty guard, so a mis-paired 0x49 can never erase the night); 0x49 pairing in
  OuraHistoryDrain; OuraEvents/Decoders/Framing/OuraDriver additions.
- WhoopStore: OuraSleepSessionMapping (anchored per-code hypnogram → stage breakdown), OuraStreamMapping
  sleepPhase pass-through.
- App: OuraLiveSource assembles the burst, anchors + 0x49-refines both ends, calls persistSleepSession;
  SourceCoordinator wires the persist closure.

Provenance UI (both platforms): a persisted Oura night is the ring's OWN SleepNet output, not a NOOP
computation, so the surfaces say so instead of the generic "On-device":
- DeviceBrandCatalog.isOura(deviceId) — ONE canonical resolver (brand table, no "oura" literal);
  Swift Repository.activeDeviceIsOura + Kotlin SleepScreen call it.
- SleepView / SleepScreen: hero + main-sleep badges read "Oura"; the stage caption reads "raw
  on-device stages"; a caveat note states the split is the ring's RAW BLE classification (more Awake /
  less Deep+REM) — NOT the Oura app's cloud-adjusted stages. daySourceBadge gains the Oura brand so the
  ring-id session (and By-Day) label correctly. Design tokens only; WHOOP/Apple imports still win the
  provenance order above Oura.

Tier discipline: the persisted session is a ring-PROVIDED night, not a NOOP-computed score.
Supersedes DRAFT ryanbr#446; docs/OURA_PROTOCOL.md updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(oura): coverage for the SleepNet persist, "Oura" provenance, and 0x49 start-clamp

Unit tests for the feature above, on both platforms (byte-identical Swift/Kotlin fixtures):
- HypnogramAssembler: the 0x49 start-clamp in the pure codesWithTimes — drops codes before the onset,
  inclusive boundary, an onset before the burst keeps all, and the never-empty fallback for a
  mis-paired 0x49; plus the existing backward-layout cases.
- OuraSleepSessionMapping: anchored per-code hypnogram → CachedSleepSession stage breakdown.
- OuraSleepWindowPairing: closest-0x49-window selection (overnight vs nap, tolerance, empties).
- DeviceBrandCatalog.isOura: registry-id → brand resolution (Oura true; WHOOP/computed/import false).
- OuraStreamMapping / Decoder golden / Framing / Driver / HistoryDrain: the twin decode + mapping tests.

swift test OuraProtocol + WhoopStore green; the Kotlin twins mirror them (their run is gated only by
the unrelated ryanbr#716 test-fake compile gap / PR ryanbr#725).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* i18n(oura): localize the Oura raw-stages caveat (Apple) + baseline it (Android)

The SleepNet provenance caveat added with the Oura raw-stages note was new UI copy the i18n gate
(`Tools/i18n_audit.py --ci`) flagged: on Apple the two literals were absent from their String Catalog;
on Android they were new hardcoded literals not in the baseline.

- Apple: add both strings — the `Text` caveat and its `accessibilityLabel` — to
  Strand/Resources/Localizable.xcstrings with translations for all 8 focus locales
  (de, es, fr, it, pt-PT, ru, zh-Hans, zh-Hant), state `translated`, mirroring the sibling
  "Low confidence staging" note's terminology (deep/REM, efficiency, stages, "anneau/Ring/anillo/…").
- Android: `SleepScreen.kt` is fully hardcoded (0 stringResource, 6 entries already baselined) and has no
  low-confidence twin, so the two literals join the accepted backlog via `--update-baseline` — the diff is
  exactly the two SleepScreen.kt entries, nothing else. (Localizing the Android sleep screen's copy is a
  separate backlog task; this only closes the NEW-literal regression.)

`python3 Tools/i18n_audit.py --ci origin/main` now exits 0 (Android: no new hardcoded literals + focus
locales complete; Apple: no new un-extracted literals + all catalogs complete).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
The Home Screen widget showed a large Charge percentage with sparse footer icons; Effort and Rest were already in WidgetSnapshot from ryanbr#446 but unused on the compact families. Small and medium now show the three daily scores as rings in Today's order, large keeps its stat grid beneath them, and the Lock Screen rectangular accessory carries the same trio.

Rings are static rather than GlowRing's draw-in animation: WidgetKit does not reliably fire onAppear, so an animated ring can freeze empty until the next timeline rebuild. An unscored day renders as a dash over an empty track, never a fabricated zero.

Effort honours the ryanbr#313 scale (ryanbr#1022 review): the display string carries the WHOOP 0-21 value while the arc fraction stays on the stored 0-100 axis, so rings stay comparable between users on different scales. Pre-formatted at publish time because effortScaleKey is a plain @AppStorage outside the App Group. The new WidgetSnapshot fields are optional, so an already-persisted snapshot still decodes.

Merged with --admin: this fork PR's workflows were never triggered, so it carried no checks. Verified out of band instead - app-build green on both legs against current main (the only compile these 241 lines have had), plus doc-comments and i18n run locally on the merge result. iOS-only, so Android CI does not apply.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants