Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
a5c2b8e
Weight trend: include scale weighings alongside the journal's weight
Riedel-David Nov 3, 2026
e8a7afa
.coderabbit.yaml aktualisieren
BucciMobile Nov 4, 2026
16a4d62
Weight trend: local-midnight cutoff, fixed test clock, running max fo…
Riedel-David Nov 4, 2026
67f1f10
Weight trend: seed the weighing chain from the full history, plot onl…
Riedel-David Nov 4, 2026
3644e69
fix(notify): land detected-activity (nap/workout) notification taps o…
mistral-vibe Nov 5, 2026
1b1f613
fix(shell): keep tab state on notification-driven tab switches; fix r…
mistral-vibe Nov 5, 2026
9226987
fix(shell): sync _selection notifier on manual tab changes and Back
mistral-vibe Nov 5, 2026
6ef6c3f
fix(shell): handle selection notifier swap in didUpdateWidget
mistral-vibe Nov 5, 2026
56e8176
fix(shell): null-safe, post-frame reconciliation on notifier swap
mistral-vibe Nov 5, 2026
7f9be37
fix(shell): discard stale deferred onSelect after notifier swap
mistral-vibe Nov 5, 2026
4c06603
docs: add WHOOP SpO2 evidence-review proposal (staged, self-critical)
mistral-vibe Nov 5, 2026
3796d7e
feat: carry gen5 v18 SpO2 candidate byte as raw diagnostic
mistral-vibe Nov 5, 2026
f9f368d
docs: address review — 'app agreement' wording, analytics owns aggreg…
mistral-vibe Nov 6, 2026
29b73bf
fix: include record ts/counter in the gen5 SpO2 candidate diagnostic log
mistral-vibe Nov 6, 2026
feeb71a
docs: gate any user-facing SpO2 display on the validation workstream …
mistral-vibe Nov 6, 2026
fb23c04
fix: drop the gen5 SpO2 candidate log line entirely
mistral-vibe Nov 6, 2026
7c20915
docs: RAM-only diagnostics (CWE-532) + explicit accuracy/bias gate th…
mistral-vibe Nov 6, 2026
e85d816
docs: qualify the SpO2 candidate comment — raw_archive may hold the b…
mistral-vibe Nov 6, 2026
69feaa9
docs: gate must validate the nightly aggregate, not only burst-level …
mistral-vibe Nov 6, 2026
6150e49
docs: gen4 live display gate uses time-aligned live reference, not th…
mistral-vibe Nov 6, 2026
752671d
docs: address upstream review — saturation-range coverage gate, corre…
mistral-vibe Nov 7, 2026
9a7fb41
docs: fix line wrap in gen4 gate sentence
mistral-vibe Nov 7, 2026
f5bbfbb
test: pin spo2CandidateRaw preservation through Sample.copyWith (nonz…
mistral-vibe Nov 7, 2026
41d79af
docs: require per-burst time-aligned reference coverage; validate any…
mistral-vibe Nov 8, 2026
60754bf
docs: address upstream review round 2 — independent window/weights/co…
mistral-vibe Nov 8, 2026
f72b4d7
test+docs: pin the #472 seam — captured candidate stays unread (Spo2D…
mistral-vibe Nov 9, 2026
4d8a5e1
docs: relate to merged #472 — spo2 MetricSpec/provenance owned by imp…
mistral-vibe Nov 9, 2026
2c75585
docs: explicit post-gate publication path — validation-only read, Spo…
mistral-vibe Nov 9, 2026
97ea164
docs: point to the validation-gate proposal (PR #17 / upstream #675) …
mistral-vibe Nov 9, 2026
f1c5110
fix: copyWith tests use the real signature (tsEpoch only) — Sample ha…
mistral-vibe Nov 10, 2026
85ecde1
l10n: add report contact strings for es, fr, hi, zh (main's l10n test…
abdulsaheel Nov 10, 2026
01490f3
nap notif tab: dispose the selection notifier, fix the deferred-selec…
abdulsaheel Nov 10, 2026
060cfde
spo2 proposal: no named outside projects, byte 82 stays opaque, weare…
abdulsaheel Nov 10, 2026
fc90e65
gen5 spo2 candidate: neutral docs, say it is live-path only, drop the…
abdulsaheel Nov 10, 2026
aa5b160
main is red since #680: ru/th strings for the firmware warning, galle…
abdulsaheel Nov 10, 2026
802b30f
Merge pull request #683 from OpenStrap/fix/l10n-report-contact
abdulsaheel Nov 10, 2026
022ba92
Merge branch 'main' into feat/scale-weight-in-trend
abdulsaheel Nov 10, 2026
a5b420b
Merge branch 'main' into vibe/nap-notif-activity-tab-7879a4
abdulsaheel Nov 10, 2026
283a9f5
Merge branch 'main' into vibe/whoop-spo2-proposal-7295e2
abdulsaheel Nov 10, 2026
48e6f03
Merge branch 'main' into vibe/whoop-spo2-gen5-candidate-7295e2
abdulsaheel Nov 10, 2026
ac9dced
Merge pull request #666 from Riedel-David/feat/scale-weight-in-trend
abdulsaheel Nov 10, 2026
a75edba
Merge pull request #668 from BucciMobile/vibe/nap-notif-activity-tab-…
abdulsaheel Nov 10, 2026
aeb0387
Merge pull request #675 from BucciMobile/vibe/whoop-spo2-proposal-7295e2
abdulsaheel Nov 10, 2026
69341a6
Merge pull request #676 from BucciMobile/vibe/whoop-spo2-gen5-candida…
abdulsaheel Nov 10, 2026
f7cf721
CI: split the test suite across six parallel jobs
Nov 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/scripts/test_shard.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Run one slice of the test suite: test_shard.sh <index> <total>.
#
# Every *_test.dart under test/ belongs to exactly one slice (its position in
# the sorted list, modulo <total>), so the slices together are the whole suite.
# Inside a slice the files still run one at a time (--concurrency=1): the suite
# uses sqflite_common_ffi against real database files and parallel workers on
# ONE machine race on them. Separate CI jobs are separate machines.
#
# ponytail: slices are balanced by file COUNT, not by duration. If one slice
# becomes the slow one, split by recorded timings instead.
set -euo pipefail

index="$1"
total="$2"

files=()
i=0
while IFS= read -r f; do
if [ $((i % total)) -eq "$index" ]; then files+=("$f"); fi
i=$((i + 1))
done < <(find test -name '*_test.dart' | LC_ALL=C sort)

if [ "${#files[@]}" -eq 0 ]; then
echo "slice $index of $total holds no test files" >&2
exit 1
fi

echo "slice $index of $total: ${#files[@]} of $i test files"
if [ "${TEST_SHARD_LIST_ONLY:-}" = 1 ]; then
printf '%s\n' "${files[@]}"
exit 0
fi
flutter test --concurrency=1 --reporter=expanded "${files[@]}"
61 changes: 50 additions & 11 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ permissions:
contents: read

jobs:
test:
# Analyze, the lock guard and the zone-dependent tests: everything that is
# not the main suite.
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -68,21 +70,58 @@ jobs:
- name: Analyze
run: flutter analyze

# --concurrency=1: the suite uses sqflite_common_ffi against real database
# files, and parallel workers race on them.
#
# The golden capture (whoop_hist.jsonl) is a real band recording kept
# beside the repo rather than committed to it, so the two derivation-replay
# tests SKIP here and run locally.
- name: Test
run: flutter test --concurrency=1 --reporter=expanded

# The WHOOP freeze goldens are per timezone (day labels are local), and
# the run above is UTC, so it only enforces the *_utc files and the other
# the main suite runs in UTC, so it only enforces the *_utc files and the other
# two skip. Re-run that one file under each committed offset. The Mi
# scale clock test reads the process zone and is a no-op under UTC, so it
# rides along.
- name: Zone-dependent tests (+05:30, -04:00)
run: |
TZ=Asia/Kolkata flutter test --concurrency=1 test/whoop_freeze_golden_test.dart test/adapters/miscale_test.dart
TZ=America/New_York flutter test --concurrency=1 test/whoop_freeze_golden_test.dart test/adapters/miscale_test.dart

# The main suite, split across machines. It ran as ONE job for about 75
# minutes, and a newer push to main cancels the run in progress
# (`cancel-in-progress` above), so runs on main were cancelled about as often
# as they finished. Each slice still runs its files one at a time; see
# .github/scripts/test_shard.sh.
#
# The golden capture (whoop_hist.jsonl) is a real band recording kept beside
# the repo rather than committed to it, so the two derivation-replay tests
# SKIP here and run locally.
suite:
runs-on: ubuntu-latest
strategy:
# One failing slice must not hide failures in the others.
fail-fast: false
matrix:
shard: [0, 1, 2, 3, 4, 5]
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false

# Same pin as `checks`; see the note there.
- uses: subosito/flutter-action@v2
with:
flutter-version: 3.41.6
channel: stable
cache: true

- name: Install dependencies
run: flutter pub get

- name: Test (slice ${{ matrix.shard }} of 6)
run: bash .github/scripts/test_shard.sh ${{ matrix.shard }} 6

# One check with the old job's name, so anything that waits for `test`
# (branch protection, a badge) keeps working. Fails unless every job above
# passed.
test:
if: always()
needs: [checks, suite]
runs-on: ubuntu-latest
steps:
- name: All checks and slices passed
run: |
[ "${{ needs.checks.result }}" = success ] && [ "${{ needs.suite.result }}" = success ]
196 changes: 196 additions & 0 deletions docs/proposals/whoop-spo2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
# Proposal: WHOOP SpO2 (blood oxygen) — evidence review and staged plan

Status: proposal only, no code yet. Written so other contributors can build on
the current state of `protocol` and `edge` without re-doing this research.

## Process (what was done, openly)

1. Collected the existing claims about the 96-byte realtime packet on the data
characteristic: SpO2 at byte 5 (range gate 50–100), CRC-32
(poly 0x04C11DB7, xor-out 0xF43F44AC) at bytes 92–95. Single source,
unverified.
2. Checked which claims more than one independent source agrees on, and which
rest on a single source.
3. Compared with what our own repos already know: `protocol` (`live.dart`,
`records.dart`, `gen5_records.dart`), `whoop-rs` (`whoop-protocol/src/live.rs`,
`spo2_from_paired`, v18 `spo2_pct`), `edge` (`ble_engine.dart`,
`adapters/whoop_gen4.dart`, `adapters/signals.dart`).

What was NOT done: no real-device captures, no ground-truth comparison against a
reference oximeter, no CRC verification against our own captures. Everything
below that is not marked "verified in our repos" is an unverified claim with
the confidence level stated.

## Findings by generation

### WHOOP 4.0 — no secured SpO2

- Byte 5 of the 96-byte realtime packet is claimed to be SpO2 %. Single
source, no ground truth, only a 50–100 plausibility gate.
- The same source says blood oxygen on the 4.0 is only measured once a day,
during sleep, so byte 5 is expected to carry a value only inside sleep windows
and 0/invalid otherwise. Unverified.
- Our `protocol` repo verified that `spo2RedRaw`/`spo2IrRaw` (inner 64/66) move
in lockstep with a fixed offset: a red/IR ratio from them tracks channel drift,
not oxygenation (`records.dart` explicitly forbids building SpO2 on them).
whoop-rs therefore only computes a ratio-of-ratios estimate on paired v24
windows and never a calibrated %.
- Open discrepancy: the single source maps the data stream to characteristic
`61080004-…`, while whoop-rs maps `GEN4_DATA` to `61080005-…` and `…0004` to
EVENTS (`whoop-client/src/uuids.rs`). One mapping is off by one; must be
resolved against our own captures before any wiring.
- Constraint from edge AGENTS.md invariant 14: live high-rate streams are
RAM-only, never persisted. That rules out persisting the stream, NOT a
day metric: an in-memory aggregate over a validated sleep window can still
produce a nightly value without persisting anything. Whether Gen4 ever gets
a nightly metric is decided by validation + coverage, not by invariant 14
alone.

### WHOOP 5.0 / MG — sleep-only, agreement with the vendor app unverified

- v18 historical records carry the strap's own computed SpO2 % at frame-abs
byte 82 (= `inner[74]`), which our `protocol` already exposes as
`spo2CandidateRaw` (`gen5_records.dart`) but deliberately does not publish as
a percentage.
- Measurement schedule, reported by more than one independent source: 30 s
measurement bursts every 1,200 s, only while the band declares sleep. 5/MG is
reported to never send red/IR raw channels. Not verified by us.
- Reported comparison with the vendor app: nightly means 93.4–96.7 vs
WHOOP's own 93.1–96.5 over the same weeks (period means 94.4 vs 94.8). No
night had both readings, so this stays an estimate, not a validation.
- No usable offline derivation from raw PPG on 5.0: time multiplexing alone
would NOT make SpO2 impossible (pulse oximeters alternate red/IR), but the
decoded Gen5 data has no identified red/IR channel pair and the optical
channel bursts are widely separated in time — so no derivation path is
available from what we can decode. The byte-82 device value is the only
usable source.
- Encoding unknown. Treat byte 82 as an opaque byte until a validation run
pins its encoding; this is why `spo2Candidate` is deprecated.

## Self-critical assessment

- "Matches the WHOOP app" is agreement, not validation; only a reference
oximeter (or PSG-grade device) validates. We state this explicitly, following
the precedent in `vendor-staged-sleep.md`.
- The gen4 byte-5 claim is single-source; the sleep-only measurement reports
do NOT contradict it (a realtime packet can carry SpO2 only during sleep,
which is consistent with the claim). It stays unverified because it has one
source and no ground-truth validation.
- No project anywhere publishes a live (daytime) SpO2 for any WHOOP
generation. The realistic product surface is a nightly sleep SpO2 for
5/MG, plus at most a raw/diagnostic byte for gen4.
- All sources agree the raw ADC channels cannot yield SpO2; any PR deriving %
from red/IR on 4.0 contradicts our own verified findings and should be
rejected.
- Relation to merged PR #472: its import surface already owns the `spo2`
MetricSpec (suppressed row, vendor-import provenance, not charted). Live
band-derived SpO2 must NOT create a second `spo2` row or chart key —
provenance (vendor import vs band-derived) stays separate. Our raw capture
(`Sample.spo2CandidateRaw`) is deliberately unread until the gate passes;
the single publication path afterwards is `Spo2Data` in
`lib/models/payloads.dart`.

## Staged plan (proposed)

1. **protocol** — raw, gated decode only:
- Keep `spo2CandidateRaw` as-is. Do not document any interpretation of the
byte until the validation workstream pins its encoding; it stays labeled
candidate until validated.
- Add a decoder for the 96-byte realtime packet exposing byte 5 as **raw +
CRC flag only** — no percentage publishing. Resolve the characteristic
UUID discrepancy from real captures first.
- Tests from real frames; extend parity fixtures where applicable.
2. **whoop-rs** — DEFERRED. Current scope is `protocol` + `edge` + `analytics`
(analytics owns the nightly metric, see step 3); only whoop-rs is out of
scope for now. When picked up: mirror the decoders in `whoop-protocol` (live 96-byte packet,
v18 byte 82 interpretation), expose raw values via `whoop-ffi`; keep the
existing `spo2_pct` v18 and `spo2_from_paired` paths unchanged.
3. **edge** — pass through the band-reported `deviceSpo2` samples for
gen5/5.0/MG; the per-burst medians and the nightly mean are computed in
`OpenStrap/analytics` (a metric, not signal wiring — AGENTS.md assigns
metrics there), labeled as a device estimate, same labeling discipline as
`vendor_staged`.
- After the gate passes: feed `Sample.spo2CandidateRaw` into `Spo2Data`
(`lib/models/payloads.dart`) as the single publication path — no separate
display path, no second `spo2` metric row (see #472 note above).
- NO user-facing display on either generation until the validation
workstream lands: gen5 byte 82 stays raw until its encoding (bit-7 flag)
is pinned and validated against a reference device; gen4 byte 5 stays
diagnostic, never user-facing, until ground-truth validated.
- Diagnostics stay RAM-ONLY, never in `_log`/FileLog. FileLog persists to
disk (`lib/sync/file_log.dart`). Check where it lands on each Android
version before claiming who can read it; regardless of readability the
data would still be PERSISTED, which is the reason health diagnostics stay
in RAM.
- Respect invariants: live streams RAM-only (inv. 14), never fabricate a
metric (inv. 3), bump `kAlgoVersion` when any output changes (inv. 4).
4. **Validation workstream** (prerequisite for ANY user-facing SpO2 value,
both generations): paired measurements against a reference oximeter over
at least 10 nights / 30 paired samples across at least 3 distinct wearers (one
wearer's nights can satisfy the counts alone, so the wearer minimum is
separate); the display gate opens only when
accuracy criteria are met — MAE <= 2 pp AND |mean bias| <= 1 pp AND
Bland-Altman 95% limits of agreement within +/-4 pp. Correlation
(Pearson/Spearman) is reported separately and is NEVER treated as proof of
accuracy. Burst-level pairs are NOT sufficient: the gate must also compare
the actual NIGHTLY OUTPUT (our aggregation over the band-declared sleep
window, burst weighting included) against the reference aggregated over the
same night, because burst-level agreement can pass while the nightly
calculation uses the wrong window or weights. COVERAGE REQUIREMENT (else a
constant estimate passes by construction): reference samples must span the
saturation range (e.g. stratified bins across 85–100%) and participants must
be representative (FDA oximeter guidance explicitly calls out skin
pigmentation); accuracy is assessed per range/group, and a degenerate
constant estimate (all reference samples ~97%) must NOT open the gate.
BURST COVERAGE: the reference must provide time-aligned readings for EVERY
burst used in the nightly aggregate — sparse reference samples can match the
nightly device output while missing errors during most of the night — and
MINIMUM SLEEP-WINDOW COVERAGE must be defined with an independent check for
omitted bursts before approving a nightly estimate (data loss leaving only a
few bursts must not pass the gate). WINDOW MEMBERSHIP: included/excluded
bursts are checked independently against the band-declared sleep boundaries,
and the reference aggregate is constructed from the correct window — two
aggregates sharing the same WRONG window can match each other. WEIGHTS: the
expected burst weights are defined independently and the implementation is
checked against them; the reference aggregate must NOT copy the device's
weights (e.g. bursts of 90/90/99 with weights 1/1/8 yield 97.2 instead of
the equal-weight mean 93 — a weighting defect the copied weights would
conceal). Gen4 has NO nightly SpO2 output —
its byte 5 is a live-stream byte only — so any Gen4 display gate must
instead use time-aligned LIVE reference measurements (same accuracy
thresholds: MAE <= 2 pp, |bias| <= 1 pp, 95% LoA within +/-4 pp), never the
nightly gate. If Gen4 LATER gains a nightly metric (in-memory aggregate over
a validated sleep window), that nightly output must be validated exactly
like the Gen5 one — against the reference aggregated over the same window —
because accurate live samples alone cannot detect wrong window selection or
weighting. Plus: byte-82 encoding pin-down, UUID discrepancy resolution.

## Publication path after the gate (explicit)

The gate and the publication path are separate concerns; both are specified
here so the gate is evaluable without shipping a display:

- **Validation-only read is allowed BEFORE the gate passes.** Evaluating the
gate requires the actual nightly output, so the same nightly aggregation
intended for publication is computed and compared against the reference —
internally only. User-facing output stays withheld until the gate passes;
`Sample.spo2CandidateRaw` remains unread by the UI in the meantime.
- **Payload changes (edge `payloads.dart`):** `Spo2Data` currently has no
percentage accessor and `TodayData.spo2` returns null while `odi_per_hour`
is null. Publication therefore adds: a `spo2Pct` field on `Spo2Data`, a
`double? get spo2Pct` accessor, and a getter condition on `TodayData.spo2`
that accepts the payload once validation has passed (flagged by the
validation record, not by the mere presence of a byte).
- **UI integration:** publishing through `Spo2Data` alone does NOT display
anything — the chart reads `metric_series` and the Explore row stays hidden
without stored `spo2` history. The integration step therefore writes the
validated nightly value into the existing `spo2` series with
band-derived provenance, reusing the #472 import row's provenance axis —
NO second `spo2` row, no second chart key.

## Out of scope

- Any medical claim or diagnosis (terms.html: wellness only, not a medical
device).
- Deriving SpO2 from red/IR ADC counts.
- Persisting live-stream data.
35 changes: 21 additions & 14 deletions lib/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -437,14 +437,18 @@ ShellDomain domainForRoute(String route) => switch (routePath(route)) {
// Water is a journal field logged beside food, and food is a section of
// Activity.
kRouteWater => ShellDomain.activity,
// The movement/sedentary nudges, recovery-ready and step-goal notes,
// and the detected-activity reviews: all about what is on Today.
// The movement/sedentary nudges, recovery-ready and step-goal notes:
// all about what is on Today.
kRouteMovement ||
kRouteRecovery ||
kRouteSteps ||
kRouteDetectedActivities ||
kRouteWorkoutSuggestion =>
ShellDomain.today,
// The detected-activity reviews (naps and workouts alike). The
// suggestion cards they are about live on the Activity tab, so the
// pushed review sits over Activity — the tap lands where the content
// is, not on Home (issue #113's promise, extended to naps).
kRouteDetectedActivities => ShellDomain.activity,
// The forgotten-workout nudge. Activity is the destination itself — the
// live session bar with its finish control is pinned to the shell there
// — so screenForRoute stays null for it.
Expand Down Expand Up @@ -519,12 +523,10 @@ class _ShellState extends State<_Shell> {
late ShellDomain _domain = restoredShellTab();

/// AppShell owns its own selection and takes only an `initial`, so a
/// programmatic jump re-keys it.
// ponytail: re-keying rebuilds the tab stack, so a deep link loses the
// scroll position of whatever tab was open. Deep links are rare and arrive
// from outside the app; give AppShell an external controller if that ever
// stops being true.
int _rev = 0;
/// programmatic jump drives it through this notifier instead of re-keying:
/// recreating AppShell would rebuild the tab stack and lose every visited
/// tab's state (scroll position included) on a notification deep link.
late final ValueNotifier<ShellDomain> _selection;

AppState? _app;

Expand All @@ -534,6 +536,7 @@ class _ShellState extends State<_Shell> {
// A tapped notification asks for a tab via AppState.navRequest. Jump there,
// then clear the request so it isn't replayed on rebuild.
_app = context.read<AppState>();
_selection = ValueNotifier(_domain);
_app!.navRequest.addListener(_consumeSoon);
_app!.screenRequest.addListener(_consumeSoon);
// Cold launch from a tapped notification: the route may already be set
Expand Down Expand Up @@ -591,17 +594,17 @@ class _ShellState extends State<_Shell> {

void _go(ShellDomain d) {
if (d == _domain) return;
setState(() {
_domain = d;
_rev++;
});
setState(() => _domain = d);
Prefs.setInt(Prefs.shellTabV2, d.index);
// AppShell follows this without being recreated, so tab state survives.
_selection.value = d;
}

@override
void dispose() {
_app?.navRequest.removeListener(_consumeSoon);
_app?.screenRequest.removeListener(_consumeSoon);
_selection.dispose();
super.dispose();
}

Expand All @@ -611,12 +614,16 @@ class _ShellState extends State<_Shell> {
// AppState tick.
final live = context.select<AppState, bool>((a) => a.activeWorkout != null);
return AppShell(
key: ValueKey(_rev),
initial: _domain,
selection: _selection,
banner: live ? const _LiveSessionBar() : null,
onSelect: (d) {
_domain = d;
Prefs.setInt(Prefs.shellTabV2, d.index);
// Manual tab changes (and Back) must update the notifier too:
// otherwise it goes stale and a later _go to that stale value fires
// no change event, so AppShell would not follow the deep link.
_selection.value = d;
},
builder: (c, d) => switch (d) {
ShellDomain.today => const HomeScreen(),
Expand Down
Loading
Loading