From bb2f63545c7d5826e77b6b11ec1456f744df15fb Mon Sep 17 00:00:00 2001 From: Jonathan Ng Date: Fri, 6 Nov 2026 00:00:12 -0800 Subject: [PATCH 1/2] feat(biometrics): show Apple Watch blood oxygen in its own vitals row --- app/api/biometrics/watch-upload/route.test.ts | 7 +-- app/api/biometrics/watch-upload/route.ts | 4 +- docs/biometrics-reference-imports.md | 11 ++++ scripts/import-watch-health.py | 44 +++++++++------ scripts/tests/test_import_watch_health.py | 47 +++++++++++++--- src/components/Sleep/NightVitals.tsx | 15 +++--- .../Sleep/tests/NightVitals.test.tsx | 32 ++++++++++- src/components/biometrics/BiometricsPage.tsx | 10 ++-- src/components/biometrics/VitalsChart.tsx | 44 +++++++++------ src/components/biometrics/WatchUpload.tsx | 8 +-- src/components/biometrics/biometricsLogic.ts | 8 +++ .../biometrics/tests/BiometricsPage.test.tsx | 28 +++++++++- .../biometrics/tests/VitalsChart.test.tsx | 54 +++++++++++++++++++ .../biometrics/tests/WatchUpload.test.tsx | 4 +- .../biometrics/tests/biometricsLogic.test.ts | 11 +++- src/demo/handlers/biometrics.ts | 7 ++- .../tests/biometrics.referenceVitals.test.ts | 10 +++- 17 files changed, 278 insertions(+), 66 deletions(-) diff --git a/app/api/biometrics/watch-upload/route.test.ts b/app/api/biometrics/watch-upload/route.test.ts index ea68eda9c..c52401e7a 100644 --- a/app/api/biometrics/watch-upload/route.test.ts +++ b/app/api/biometrics/watch-upload/route.test.ts @@ -61,15 +61,16 @@ it('imports Watch breathing rate and HRV, skips records sleepypod wrote to Healt record('RespiratoryRate', 'Apple Watch', 14.5, 'count/min'), record('HeartRateVariabilitySDNN', 'Apple Watch', 48, 'ms'), record('HeartRateVariabilityRMSSD', 'Apple Watch', 0.032, 's'), + record('OxygenSaturation', 'Apple Watch', 0.97, '%'), // The pod's own readings written back by the iOS app, even from a source named like a Watch. record('RespiratoryRate', 'sleepypod', 99, 'count/min'), record('HeartRateVariabilitySDNN', 'sleepypod design', 99, 'ms'), record('HeartRate', 'sleepypod Watch', 99, 'count/min'), ].join('')}` const request = () => new Request('http://pod/api/biometrics/watch-upload?side=left&days=7', { method: 'POST', body: xml }) - expect(await (await POST(request())).json()).toEqual({ stages: 0, vitals: 0, breathing: 1, hrv: 1, rmssd: 1, rejectedStageBatches: 0, - matchedStages: 0, matchedVitals: 0, matchedBreathing: 1, matchedHrv: 1, matchedRmssd: 1 }) - expect(requests.map(r => [r.metric, r.side, r.samples?.map(s => s.value)])).toEqual([['breathing_rate', 'left', [14.5]], ['hrv_sdnn', 'left', [48]], ['hrv_rmssd', 'left', [32]]]) + expect(await (await POST(request())).json()).toEqual({ stages: 0, vitals: 0, breathing: 1, hrv: 1, rmssd: 1, oxygen: 1, rejectedStageBatches: 0, + matchedStages: 0, matchedVitals: 0, matchedBreathing: 1, matchedHrv: 1, matchedRmssd: 1, matchedOxygen: 1 }) + expect(requests.map(r => [r.metric, r.side, r.samples?.map(s => s.value)])).toEqual([['breathing_rate', 'left', [14.5]], ['hrv_sdnn', 'left', [48]], ['hrv_rmssd', 'left', [32]], ['blood_oxygen', 'left', [97]]]) expect(await (await POST(request())).json()).toMatchObject({ breathing: 0, hrv: 0, matchedBreathing: 1, matchedHrv: 1, matchedRmssd: 1 }) }) diff --git a/app/api/biometrics/watch-upload/route.ts b/app/api/biometrics/watch-upload/route.ts index 6bb52dcdb..86db68175 100644 --- a/app/api/biometrics/watch-upload/route.ts +++ b/app/api/biometrics/watch-upload/route.ts @@ -9,8 +9,8 @@ export const runtime = 'nodejs' export const dynamic = 'force-dynamic' const MAX_BYTES = 512 * 1024 * 1024 const options = z.object({ side: z.enum(['left', 'right']), days: z.coerce.number().int().min(1).max(90).default(30) }) -const resultSchema = z.object({ stages: z.number(), vitals: z.number(), breathing: z.number(), hrv: z.number(), rmssd: z.number().default(0), rejectedStageBatches: z.number(), - matchedStages: z.number(), matchedVitals: z.number(), matchedBreathing: z.number(), matchedHrv: z.number(), matchedRmssd: z.number().default(0) }) +const resultSchema = z.object({ stages: z.number(), vitals: z.number(), breathing: z.number(), hrv: z.number(), rmssd: z.number().default(0), oxygen: z.number().default(0), rejectedStageBatches: z.number(), + matchedStages: z.number(), matchedVitals: z.number(), matchedBreathing: z.number(), matchedHrv: z.number(), matchedRmssd: z.number().default(0), matchedOxygen: z.number().default(0) }) const shared = globalThis as typeof globalThis & { watchUploadBusy?: boolean } /** diff --git a/docs/biometrics-reference-imports.md b/docs/biometrics-reference-imports.md index e801b957a..49ead2afa 100644 --- a/docs/biometrics-reference-imports.md +++ b/docs/biometrics-reference-imports.md @@ -239,6 +239,17 @@ the source reports them. Normalized rows accept the same `device` and `algorithm tables across sources. Any other device, such as a ring or an Android watch, uses the preview and commit import above with its own `source`. +### Apple Watch blood oxygen row + +The Health export importer and `/reference-samples` accept `blood_oxygen` as a +percentage. Health exports write saturation as a fraction under unit `%` (`0.97`), +so the importer multiplies by 100 before the 50–100 check. Nights and Biometrics +draw it as a separate row, because the pod has no oxygen sensor to overlay it on. +The row and its legend entry appear only when a reading falls inside a drawn +session, so daytime spot checks alone add nothing. The row spans 80–100%; a lower +reading sits on the bottom edge and its hover label keeps the real value. +`scripts/import-watch-health.py --oxygen-only` imports this metric alone. + ### Optional Apple Watch RMSSD overlay The Health export importer and legacy `reference-samples` endpoints accept diff --git a/scripts/import-watch-health.py b/scripts/import-watch-health.py index 48dc2b5d8..17f805d42 100755 --- a/scripts/import-watch-health.py +++ b/scripts/import-watch-health.py @@ -1,6 +1,6 @@ #!/usr/bin/env -S uv run --quiet --no-project """ -Import Apple Watch sleep stages, heart rate, breathing rate and HRV from a Health export into the pod. +Import Apple Watch sleep stages, heart rate, breathing rate, HRV and blood oxygen from a Health export into the pod. scripts/import-watch-health.py ~/Desktop/apple_health_export --pod 192.168.1.88 --side left @@ -13,21 +13,23 @@ POST /api/biometrics/reference-vitals HeartRate samples POST /api/biometrics/reference-samples RespiratoryRate (breathing_rate, breaths/min) HeartRateVariabilitySDNN (hrv_sdnn, ms) - and HeartRateVariabilityRMSSD (hrv_rmssd, ms) + HeartRateVariabilityRMSSD (hrv_rmssd, ms) + and OxygenSaturation (blood_oxygen, %) -Heart rate, breathing rate and HRV are first-write-wins on side + time + +Heart rate, breathing rate, HRV and blood oxygen are first-write-wins on side + time + source (+ metric), so re-running on a newer export only adds what is new. Stages are stricter: a batch is rejected (HTTP 400) when a segment overlaps a stored one with different boundaries, e.g. a night the iOS app already posted. Such a batch is skipped with a warning, the vitals are still imported, and the run exits 1 at the end. Nothing is written to the pod's own `vitals` table: Watch heart rate lives in `reference_vitals` (the Biometrics page draws it as a second line over the -pod's heart rate), breathing rate and HRV in `reference_samples`. +pod's heart rate), breathing rate, HRV and blood oxygen in `reference_samples`. Options: --days N only samples from the last N days (default 30; 0 = everything) --dry-run parse and count, post nothing --stages-only / --hr-only (heart rate alone, without breathing rate and HRV) + --oxygen-only blood oxygen alone """ import argparse import importlib.util @@ -50,11 +52,15 @@ HK_RR = 'HKQuantityTypeIdentifierRespiratoryRate' HK_HRV = 'HKQuantityTypeIdentifierHeartRateVariabilitySDNN' HK_RMSSD = 'HKQuantityTypeIdentifierHeartRateVariabilityRMSSD' +HK_SPO2 = 'HKQuantityTypeIdentifierOxygenSaturation' # reference_samples metric, HK type, plausible range (the API enforces the same) -SAMPLE_METRICS = (('breathing_rate', HK_RR, 4, 60), ('hrv_sdnn', HK_HRV, 1, 500), ('hrv_rmssd', HK_RMSSD, 1, 500)) -# Factors into the canonical unit (breaths/min, ms). Health exports use -# count/min and ms; anything unlisted is skipped instead of stored unconverted. -SAMPLE_UNITS = {HK_RR: {'count/min': 1, 'count/s': 60, 'Hz': 60}, HK_HRV: {'ms': 1, 's': 1000}, HK_RMSSD: {'ms': 1, 's': 1000}} +SAMPLE_METRICS = (('breathing_rate', HK_RR, 4, 60), ('hrv_sdnn', HK_HRV, 1, 500), ('hrv_rmssd', HK_RMSSD, 1, 500), + ('blood_oxygen', HK_SPO2, 50, 100)) +# Factors into the canonical unit (breaths/min, ms, %). Health exports use +# count/min and ms, and write saturation as a fraction under unit "%" (0.97); +# anything unlisted is skipped instead of stored unconverted. +SAMPLE_UNITS = {HK_RR: {'count/min': 1, 'count/s': 60, 'Hz': 60}, HK_HRV: {'ms': 1, 's': 1000}, HK_RMSSD: {'ms': 1, 's': 1000}, + HK_SPO2: {'%': 100}} # The sleepypod iOS app writes the pod's own HR, breathing, HRV and sleep stages # back into Health (sourceName "sleepypod", "sleepypod design"). Those are never # a reference, whatever --source says. @@ -144,7 +150,7 @@ def export_stream(export): def run(export, pod, side, source_pattern='Watch', days=30, dry_run=False, - stages=True, hr=True, extra=True, post=post_json, now=None, report=False): + stages=True, hr=True, extra=True, oxygen=True, post=post_json, now=None, report=False): now = time.time() if now is None else now cutoff = now - days * 86400 if days > 0 else float('-inf') quantities = {hk: [] for _, hk, _, _ in SAMPLE_METRICS} @@ -154,7 +160,8 @@ def run(export, pod, side, source_pattern='Watch', days=30, dry_run=False, segs = [s for s in dedupe_segments(segments) if s[1] >= cutoff] if stages else [] samples = [(t, bpm) for t, bpm in hr_samples if t >= cutoff and 20 <= bpm <= 250] if hr else [] - extras = {metric: [(t, v) for t, v in quantities[hk] if t >= cutoff and lo <= v <= hi] if extra else [] + extras = {metric: [(t, v) for t, v in quantities[hk] if t >= cutoff and lo <= v <= hi] + if (oxygen if metric == 'blood_oxygen' else extra) else [] for metric, hk, lo, hi in SAMPLE_METRICS} def span(times): @@ -166,13 +173,13 @@ def span(times): print(f'{len(segs)} stage segments ({span([s[0] for s in segs])}), ' f'{len(samples)} heart-rate samples ({span([t for t, _ in samples])}), ' - f"{len(extras['breathing_rate'])} breathing-rate and {len(extras['hrv_sdnn'])} HRV samples and {len(extras['hrv_rmssd'])} RMSSD samples for side {side}", + f"{len(extras['breathing_rate'])} breathing-rate, {len(extras['hrv_sdnn'])} HRV, {len(extras['hrv_rmssd'])} RMSSD and {len(extras['blood_oxygen'])} blood-oxygen samples for side {side}", file=sys.stderr) if dry_run: - return {'stages': 0, 'vitals': 0, 'breathing': 0, 'hrv': 0, 'rmssd': 0} + return {'stages': 0, 'vitals': 0, 'breathing': 0, 'hrv': 0, 'rmssd': 0, 'oxygen': 0} url = base_url(pod) - written = {'stages': 0, 'vitals': 0, 'breathing': 0, 'hrv': 0, 'rmssd': 0} + written = {'stages': 0, 'vitals': 0, 'breathing': 0, 'hrv': 0, 'rmssd': 0, 'oxygen': 0} rejected = 0 for batch in chunks(segs): try: @@ -193,7 +200,7 @@ def span(times): 'samples': [{'timestamp': t, 'heartRate': bpm} for t, bpm in batch], }) written['vitals'] += int(out.get('written', 0)) - for metric, key in (('breathing_rate', 'breathing'), ('hrv_sdnn', 'hrv'), ('hrv_rmssd', 'rmssd')): + for metric, key in (('breathing_rate', 'breathing'), ('hrv_sdnn', 'hrv'), ('hrv_rmssd', 'rmssd'), ('blood_oxygen', 'oxygen')): for batch in chunks(extras[metric]): out = post(f'{url}/api/biometrics/reference-samples', { 'side': side, 'source': 'apple_watch', 'metric': metric, @@ -201,11 +208,12 @@ def span(times): }) written[key] += int(out.get('written', 0)) print(f"wrote {written['stages']} new stage segments, {written['vitals']} new heart-rate, " - f"{written['breathing']} new breathing-rate and {written['hrv']} new HRV and {written['rmssd']} new RMSSD samples to {url}", + f"{written['breathing']} new breathing-rate, {written['hrv']} new HRV, {written['rmssd']} new RMSSD and {written['oxygen']} new blood-oxygen samples to {url}", file=sys.stderr) if report: return {**written, 'rejectedStageBatches': rejected, 'matchedStages': len(segs), 'matchedVitals': len(samples), - 'matchedBreathing': len(extras['breathing_rate']), 'matchedHrv': len(extras['hrv_sdnn']), 'matchedRmssd': len(extras['hrv_rmssd'])} + 'matchedBreathing': len(extras['breathing_rate']), 'matchedHrv': len(extras['hrv_sdnn']), 'matchedRmssd': len(extras['hrv_rmssd']), + 'matchedOxygen': len(extras['blood_oxygen'])} if rejected: raise SystemExit(f'error: {rejected} stage batch(es) rejected by the pod; vitals were still imported') return written @@ -223,13 +231,15 @@ def main(argv=None): group = ap.add_mutually_exclusive_group() group.add_argument('--stages-only', action='store_true') group.add_argument('--hr-only', action='store_true') + group.add_argument('--oxygen-only', action='store_true') args = ap.parse_args(argv) export = os.path.join(args.export, 'export.xml') if os.path.isdir(args.export) else args.export if not os.path.exists(export): ap.error(f'{export} not found') result = run(export, args.pod, args.side, args.source, args.days, args.dry_run, - stages=not args.hr_only, hr=not args.stages_only, extra=not (args.hr_only or args.stages_only), + stages=not (args.hr_only or args.oxygen_only), hr=not (args.stages_only or args.oxygen_only), + extra=not (args.hr_only or args.stages_only or args.oxygen_only), oxygen=not (args.hr_only or args.stages_only), report=args.json) if args.json: print(json.dumps(result)) diff --git a/scripts/tests/test_import_watch_health.py b/scripts/tests/test_import_watch_health.py index 5504050d2..3eed4417f 100644 --- a/scripts/tests/test_import_watch_health.py +++ b/scripts/tests/test_import_watch_health.py @@ -11,6 +11,7 @@ import os import tempfile import unittest +from unittest import mock from datetime import datetime, timedelta, timezone HERE = os.path.dirname(os.path.abspath(__file__)) @@ -115,14 +116,14 @@ def test_posts_stages_and_heart_rate_to_the_pod_endpoints(self): {'timestamp': int((T0 + timedelta(minutes=6)).timestamp()), 'heartRate': 56.5}, {'timestamp': int((T0 + timedelta(minutes=11)).timestamp()), 'heartRate': 61.0}, ]) - self.assertEqual(written, {'stages': 3, 'vitals': 3, 'breathing': 0, 'hrv': 0, 'rmssd': 0}) + self.assertEqual(written, {'stages': 3, 'vitals': 3, 'breathing': 0, 'hrv': 0, 'rmssd': 0, 'oxygen': 0}) self.assertIn('wrote 3 new stage segments, 3 new heart-rate', err) def test_dry_run_posts_nothing_but_reports_counts(self): write_export(self.export, [(0, 20, 'AsleepCore')], [(1, 58)]) post, written, err = self.run_import(dry_run=True) self.assertEqual(post.calls, []) - self.assertEqual(written, {'stages': 0, 'vitals': 0, 'breathing': 0, 'hrv': 0, 'rmssd': 0}) + self.assertEqual(written, {'stages': 0, 'vitals': 0, 'breathing': 0, 'hrv': 0, 'rmssd': 0, 'oxygen': 0}) self.assertIn('1 stage segments', err) self.assertIn('1 heart-rate samples', err) @@ -133,7 +134,7 @@ def test_days_window_drops_old_samples(self): self.assertEqual(len(post.calls[0][1]['segments']), 1) self.assertEqual(len(post.calls[1][1]['samples']), 1) _, written_all, _ = self.run_import(days=0) - self.assertEqual(written_all, {'stages': 2, 'vitals': 2, 'breathing': 0, 'hrv': 0, 'rmssd': 0}) + self.assertEqual(written_all, {'stages': 2, 'vitals': 2, 'breathing': 0, 'hrv': 0, 'rmssd': 0, 'oxygen': 0}) def test_batches_at_the_api_limit(self): write_export(self.export, [], [(m, 60) for m in range(0, 4500)]) @@ -195,8 +196,8 @@ def test_posts_breathing_rate_and_hrv_per_metric(self): 'samples': [{'timestamp': t(2), 'value': 14.5}, {'timestamp': t(7), 'value': 15.0}]}) self.assertEqual(post.calls[2][1], {'side': 'left', 'source': 'apple_watch', 'metric': 'hrv_sdnn', 'samples': [{'timestamp': t(3), 'value': 48.0}]}) - self.assertEqual(written, {'stages': 0, 'vitals': 1, 'breathing': 2, 'hrv': 1, 'rmssd': 0}) - self.assertIn('2 breathing-rate and 1 HRV samples', err) + self.assertEqual(written, {'stages': 0, 'vitals': 1, 'breathing': 2, 'hrv': 1, 'rmssd': 0, 'oxygen': 0}) + self.assertIn('2 breathing-rate, 1 HRV, 0 RMSSD and 0 blood-oxygen samples', err) # --hr-only and --stages-only leave breathing rate and HRV out. post, _, _ = self.run_import(stages=False, extra=False) self.assertEqual([u.rsplit('/', 1)[1] for u, _ in post.calls], ['reference-vitals']) @@ -211,7 +212,7 @@ def test_converts_breathing_and_hrv_units_and_skips_unknown_ones(self): t = lambda m: int((T0 + timedelta(minutes=m)).timestamp()) self.assertEqual(post.calls[1][1]['samples'], [{'timestamp': t(2), 'value': 15.0}]) self.assertEqual(post.calls[2][1]['samples'], [{'timestamp': t(4), 'value': 42.0}]) - self.assertEqual(written, {'stages': 0, 'vitals': 1, 'breathing': 1, 'hrv': 1, 'rmssd': 0}) + self.assertEqual(written, {'stages': 0, 'vitals': 1, 'breathing': 1, 'hrv': 1, 'rmssd': 0, 'oxygen': 0}) def test_never_imports_records_sleepypod_wrote_to_health(self): at = T0 + timedelta(minutes=1) @@ -229,7 +230,7 @@ def test_never_imports_records_sleepypod_wrote_to_health(self): self.assertEqual(values, [58.0, 14.0, 40.0]) self.assertEqual([s['stage'] for s in post.calls[0][1]['segments']], ['light']) _, written_any, _ = self.run_import(source_pattern='.') - self.assertEqual(written_any, {'stages': 1, 'vitals': 1, 'breathing': 1, 'hrv': 1, 'rmssd': 0}) + self.assertEqual(written_any, {'stages': 1, 'vitals': 1, 'breathing': 1, 'hrv': 1, 'rmssd': 0, 'oxygen': 0}) def test_rmssd_remains_distinct_and_preserves_breathing(self): at = T0 + timedelta(minutes=2) @@ -242,6 +243,38 @@ def test_rmssd_remains_distinct_and_preserves_breathing(self): self.assertEqual([p['samples'][0]['value'] for _, p in post.calls], [14, 48, 32]) self.assertEqual(written['rmssd'], 1) + def test_blood_oxygen_fraction_is_stored_as_a_percentage(self): + at = T0 + timedelta(minutes=2) + extra = (quantity_record('OxygenSaturation', at, 0.97, '%') + # A percentage, not a fraction, lands out of range rather than as 9700%. + + quantity_record('OxygenSaturation', at + timedelta(minutes=1), 97, '%') + + quantity_record('OxygenSaturation', at + timedelta(minutes=2), 0.4, '%') + + quantity_record('OxygenSaturation', at + timedelta(minutes=3), 0.96, 'ratio') + + quantity_record('OxygenSaturation', at + timedelta(minutes=4), 0.95, '%', source='sleepypod Watch')) + write_export(self.export, [], [], extra=extra) + post, written, _ = self.run_import() + self.assertEqual([(p['metric'], p['source'], p['samples'][0]['value'], len(p['samples'])) for _, p in post.calls], + [('blood_oxygen', 'apple_watch', 97, 1)]) + self.assertEqual(written['oxygen'], 1) + + def test_oxygen_only_leaves_every_other_metric_alone(self): + at = T0 + timedelta(minutes=2) + write_export(self.export, [(0, 20, 'AsleepCore')], [(2, 58)], rr=[(2, 14)], hrv=[(2, 48)], + extra=quantity_record('OxygenSaturation', at, 0.97, '%')) + post, written, _ = self.run_import(stages=False, hr=False, extra=False) + self.assertEqual([p.get('metric') for _, p in post.calls], ['blood_oxygen']) + self.assertEqual(written, {'stages': 0, 'vitals': 0, 'breathing': 0, 'hrv': 0, 'rmssd': 0, 'oxygen': 1}) + + def test_only_flags_choose_what_is_imported(self): + write_export(self.export, [], []) + cases = {'--oxygen-only': (False, False, False, True), '--stages-only': (True, False, False, False), + '--hr-only': (False, True, False, False)} + for flag, (stages, hr, extra, oxygen) in cases.items(): + with mock.patch.object(iw, 'run', return_value={}) as run: + iw.main([self.export, '--pod', 'x', '--side', 'left', flag]) + self.assertEqual({k: run.call_args.kwargs[k] for k in ('stages', 'hr', 'extra', 'oxygen')}, + {'stages': stages, 'hr': hr, 'extra': extra, 'oxygen': oxygen}, flag) + def test_base_url_forms(self): self.assertEqual(iw.base_url('192.168.1.88'), 'http://192.168.1.88:3000') self.assertEqual(iw.base_url('pod.local:8080'), 'http://pod.local:8080') diff --git a/src/components/Sleep/NightVitals.tsx b/src/components/Sleep/NightVitals.tsx index 8ccde20c3..514379ba7 100644 --- a/src/components/Sleep/NightVitals.tsx +++ b/src/components/Sleep/NightVitals.tsx @@ -4,7 +4,7 @@ import { trpc } from '@/src/utils/trpc' import { Card, InlineError, SectionLabel, Skeleton } from '@/src/components/ds' import { VitalsExplorer } from '@/src/components/biometrics/VitalsExplorer' import { VitalsLegend } from '@/src/components/biometrics/VitalsChart' -import { baselineBand, splitSessions } from '@/src/components/biometrics/biometricsLogic' +import { baselineBand, readingsInSessions, splitSessions } from '@/src/components/biometrics/biometricsLogic' /** Nights and Biometrics use the same recorded data, chart, colors and navigation. */ export function NightVitals({ side, start, end }: { side: 'left' | 'right', start: number, end: number }) { @@ -14,6 +14,7 @@ export function NightVitals({ side, start, end }: { side: 'left' | 'right', star const hrv = trpc.biometrics.getReferenceSamples.useQuery({ ...range, metric: 'hrv_sdnn', limit: 20000 }) const rmssd = trpc.biometrics.getReferenceSamples.useQuery({ ...range, metric: 'hrv_rmssd', limit: 20000 }) const br = trpc.biometrics.getReferenceSamples.useQuery({ ...range, metric: 'breathing_rate', limit: 20000 }) + const oxygen = trpc.biometrics.getReferenceSamples.useQuery({ ...range, metric: 'blood_oxygen', limit: 20000 }) const movement = trpc.biometrics.getMovementBuckets.useQuery({ ...range, bucketSeconds: 300, limit: 10000 }) const points = (vitals.data ?? []).map(r => ({ t: new Date(r.timestamp).getTime(), hr: r.heartRate, hrv: r.hrv, br: r.breathingRate, provenanceKey: r.provenance?.processingRunId ?? r.processingRunId ?? 'legacy_unknown', hrvMetricId: r.provenance?.hrvMetricId ?? null })) @@ -21,21 +22,23 @@ export function NightVitals({ side, start, end }: { side: 'left' | 'right', star const referenceHrv = (hrv.data ?? []).map(r => ({ t: new Date(r.timestamp).getTime(), value: r.value })) const referenceRmssd = (rmssd.data ?? []).map(r => ({ t: new Date(r.timestamp).getTime(), value: r.value })) const referenceBr = (br.data ?? []).map(r => ({ t: new Date(r.timestamp).getTime(), value: r.value })) + const referenceOxygen = (oxygen.data ?? []).map(r => ({ t: new Date(r.timestamp).getTime(), value: r.value })) const sessionPoints = points.length ? points - : [...new Set([...reference, ...referenceHrv, ...referenceRmssd, ...referenceBr].map(r => r.t))] + : [...new Set([...reference, ...referenceHrv, ...referenceRmssd, ...referenceBr, ...referenceOxygen].map(r => r.t))] .sort((a, b) => a - b).map(t => ({ t, hr: null, hrv: null, br: null })) + const sessions = splitSessions(sessionPoints) return ( Vitals through the night {vitals.error && Could not load this night’s recorded vitals.} - {(hr.error || hrv.error || rmssd.error || br.error) && Some Apple Watch readings could not be loaded.} - {vitals.isLoading || hr.isLoading || hrv.isLoading || rmssd.isLoading || br.isLoading + {(hr.error || hrv.error || rmssd.error || br.error || oxygen.error) && Some Apple Watch readings could not be loaded.} + {vitals.isLoading || hr.isLoading || hrv.isLoading || rmssd.isLoading || br.isLoading || oxygen.isLoading ? : sessionPoints.length > 0 - ? + ? :

No recorded vitals for this night.

} - 0} referenceRmssd={referenceRmssd.length > 0} referenceHrv={referenceHrv.length > 0} referenceBr={referenceBr.length > 0} /> + 0} referenceRmssd={referenceRmssd.length > 0} referenceHrv={referenceHrv.length > 0} referenceBr={referenceBr.length > 0} referenceOxygen={readingsInSessions(referenceOxygen, sessions).length > 0} /> {(referenceHrv.length > 0 || referenceRmssd.length > 0) &&

Watch SDNN, Watch RMSSD and sleepypod’s window-based IBI variability index use different calculations. They cannot be directly converted; compare their trends, not their absolute values.

}
) diff --git a/src/components/Sleep/tests/NightVitals.test.tsx b/src/components/Sleep/tests/NightVitals.test.tsx index db21ddc83..ec1f406cd 100644 --- a/src/components/Sleep/tests/NightVitals.test.tsx +++ b/src/components/Sleep/tests/NightVitals.test.tsx @@ -24,6 +24,36 @@ it('uses the same chart and scopes all three Watch metrics to the selected side expect(mock.query).toHaveBeenCalledWith(expect.objectContaining({ side: 'right', metric: 'breathing_rate', startDate: new Date(0), endDate: new Date(10000) })) expect(mock.chart).toHaveBeenCalledWith(expect.objectContaining({ reference: [{ t: 2000, hr: 62 }], referenceHrv: [{ t: 2000, value: 35 }], referenceBr: [{ t: 2000, value: 14 }] })) }) +it('passes blood oxygen to the chart and names it only inside a session', () => { + mock.chart.mockClear() + mock.query.mockImplementation(input => ({ data: input.metric === 'blood_oxygen' + ? [{ timestamp: new Date(1000), value: 96 }, { timestamp: new Date(9000), value: 94 }] + : input.limit === 100000 ? [{ timestamp: new Date(1000), heartRate: 60, hrv: 40, breathingRate: 15 }] : [] })) + render() + expect(mock.chart).toHaveBeenCalledWith(expect.objectContaining({ referenceOxygen: [{ t: 1000, value: 96 }, { t: 9000, value: 94 }] })) + expect(screen.getByText('blood oxygen')).toBeTruthy() + cleanup() + mock.query.mockImplementation(input => ({ data: input.metric === 'blood_oxygen' + ? [{ timestamp: new Date(9000), value: 94 }] + : input.limit === 100000 ? [{ timestamp: new Date(1000), heartRate: 60, hrv: 40, breathingRate: 15 }] : [] })) + render() + expect(screen.queryByText('blood oxygen')).toBeNull() +}) +it('reports a failed blood-oxygen request without hiding the chart', () => { + mock.query.mockImplementation(input => input.metric === 'blood_oxygen' + ? { error: new Error('offline') } + : { data: input.limit === 100000 ? [{ timestamp: new Date(1000), heartRate: 60, hrv: 40, breathingRate: 15 }] : [] }) + render() + expect(screen.getByText('Some Apple Watch readings could not be loaded.')).toBeTruthy() + expect(screen.getByText('Shared Biometrics chart')).toBeTruthy() +}) +it('waits for blood oxygen before drawing the night', () => { + mock.chart.mockClear() + mock.query.mockImplementation(input => input.metric === 'blood_oxygen' ? { isLoading: true } : { data: [] }) + render() + expect(mock.chart).not.toHaveBeenCalled() + expect(screen.queryByText('No recorded vitals for this night.')).toBeNull() +}) it('reports a failed recorded-vitals request', () => { mock.query.mockReturnValue({ error: new Error('offline') }) render() @@ -45,7 +75,7 @@ it('connects adjacent stage transitions but never bridges missing Watch interval expect(paths[2].getAttribute('d')).toBe('M 40 39 V 39 H 50') }) -it.each(['hr', 'hrv_sdnn', 'hrv_rmssd', 'breathing_rate'])('draws reference-only %s sessions with null pod metrics', (metric) => { +it.each(['hr', 'hrv_sdnn', 'hrv_rmssd', 'breathing_rate', 'blood_oxygen'])('draws reference-only %s sessions with null pod metrics', (metric) => { mock.chart.mockClear() mock.query.mockImplementation(input => ({ data: input.metric === metric ? [{ timestamp: new Date(2000), value: 35 }] diff --git a/src/components/biometrics/BiometricsPage.tsx b/src/components/biometrics/BiometricsPage.tsx index bad6f336e..1ee98b16f 100644 --- a/src/components/biometrics/BiometricsPage.tsx +++ b/src/components/biometrics/BiometricsPage.tsx @@ -31,7 +31,7 @@ import { referenceInSession, type ReferencePoint, VitalsLegend } from './VitalsC import { WatchUpload } from './WatchUpload' import { VitalsExplorer } from './VitalsExplorer' import { - baselineBand, clock, dateInputValue, fmtDuration, formatRangeLabel, METRICS, metricStats, nightWindow, rangeWindow, SESSION_GAP_MS, splitSessions, STALE_MS, + baselineBand, clock, dateInputValue, fmtDuration, formatRangeLabel, METRICS, readingsInSessions, metricStats, nightWindow, rangeWindow, SESSION_GAP_MS, splitSessions, STALE_MS, type BiometricsRange, type MetricDef, type MetricKey, type RangeWindow, type Session, type VitalPoint, } from './biometricsLogic' @@ -84,9 +84,11 @@ function BiometricsBody({ sectionSwitch, now }: { sectionSwitch?: ReactNode, now const watchHrvQ = trpc.biometrics.getReferenceSamples.useQuery({ side, startDate, endDate, metric: 'hrv_sdnn', limit: 20000 }, { refetchInterval: 5 * 60_000 }) const watchRmssdQ = trpc.biometrics.getReferenceSamples.useQuery({ side, startDate, endDate, metric: 'hrv_rmssd', limit: 20000 }, { refetchInterval: 5 * 60_000 }) const watchBrQ = trpc.biometrics.getReferenceSamples.useQuery({ side, startDate, endDate, metric: 'breathing_rate', limit: 20000 }, { refetchInterval: 5 * 60_000 }) + const watchOxygenQ = trpc.biometrics.getReferenceSamples.useQuery({ side, startDate, endDate, metric: 'blood_oxygen', limit: 20000 }, { refetchInterval: 5 * 60_000 }) const referenceBr = (watchBrQ.data ?? []).map(r => ({ t: new Date(r.timestamp).getTime(), value: r.value })) const referenceHrv = (watchHrvQ.data ?? []).map(r => ({ t: new Date(r.timestamp).getTime(), value: r.value })) const referenceRmssd = (watchRmssdQ.data ?? []).map(r => ({ t: new Date(r.timestamp).getTime(), value: r.value })) + const referenceOxygen = (watchOxygenQ.data ?? []).map(r => ({ t: new Date(r.timestamp).getTime(), value: r.value })) const rows = (vitalsQ.data ?? []) as VitalRow[] const points: VitalPoint[] = rows.map(r => ({ t: new Date(r.timestamp).getTime(), hr: r.heartRate, hrv: r.hrv, br: r.breathingRate, @@ -96,7 +98,7 @@ function BiometricsBody({ sectionSwitch, now }: { sectionSwitch?: ReactNode, now .map(r => ({ t: new Date(r.timestamp).getTime(), hr: r.heartRate })).sort((a, b) => a.t - b.t) const sessions = splitSessions(points.length ? points - : [...new Set([...reference, ...referenceHrv, ...referenceRmssd, ...referenceBr].map(r => r.t))] + : [...new Set([...reference, ...referenceHrv, ...referenceRmssd, ...referenceBr, ...referenceOxygen].map(r => r.t))] .sort((a, b) => a - b).map(t => ({ t, hr: null, hrv: null, br: null }))) const lastVitalAt = latestQ.data?.[0] ? new Date(latestQ.data[0].timestamp).getTime() : null @@ -280,10 +282,10 @@ function BiometricsBody({ sectionSwitch, now }: { sectionSwitch?: ReactNode, now : sessions.length === 0 && !stall ?
No vitals in this range
: ( - + )} {(referenceHrv.length > 0 || referenceRmssd.length > 0) &&

Watch SDNN, Watch RMSSD and sleepypod’s window-based IBI variability index use different calculations. They cannot be directly converted; compare their trends, not their absolute values.

} - 0} referenceRmssd={referenceRmssd.length > 0} referenceHrv={referenceHrv.length > 0} bandLabel={bandLabel} stalled={!!stall} reference={reference.length > 0} /> + 0} referenceBr={referenceBr.length > 0} referenceRmssd={referenceRmssd.length > 0} referenceHrv={referenceHrv.length > 0} bandLabel={bandLabel} stalled={!!stall} reference={reference.length > 0} /> diff --git a/src/components/biometrics/VitalsChart.tsx b/src/components/biometrics/VitalsChart.tsx index a42849834..ee03b4b30 100644 --- a/src/components/biometrics/VitalsChart.tsx +++ b/src/components/biometrics/VitalsChart.tsx @@ -9,6 +9,8 @@ * shows one crosshair through all rows, snapped to the nearest reading. * Imported Apple Watch heart rate (`reference`) is drawn as a dashed line over * the heart-rate row so the two sensors can be compared night by night. + * Watch blood oxygen has no pod counterpart, so it gets a fourth row of its own, + * drawn only when a reading falls inside a session. */ 'use client' @@ -16,7 +18,7 @@ import { useTimeFormat } from '@/src/providers/TimeFormatProvider' import { useEffect, useId, useMemo, useRef, useState, type PointerEvent, type ReactNode } from 'react' import { - axisTicks, timeAtX, clampTo, clock, fmtDuration, fmtGap, layoutAxis, METRICS, nearestIndex, rollingMean, sessionHeader, + axisTicks, timeAtX, clampTo, clock, fmtDuration, fmtGap, layoutAxis, METRICS, nearestIndex, OXYGEN_ROW, readingsInSessions, rollingMean, sessionHeader, type MetricKey, type RangeWindow, type Session, } from './biometricsLogic' @@ -26,7 +28,8 @@ const ROW_H = 78 const ROW_GAP = 18 const HEAD_H = 24 const AXIS_H = 26 -const VITALS_H = METRICS.length * ROW_H + (METRICS.length - 1) * ROW_GAP +const ROWS = [...METRICS, OXYGEN_ROW] +const OXYGEN_INDEX = METRICS.length type Band = { lo: number, hi: number } | null @@ -61,8 +64,8 @@ function pathOf(xs: number[], ys: Array): string { const rowTop = (i: number) => HEAD_H + i * (ROW_H + ROW_GAP) const yOf = (i: number, v: number) => { - const [lo, hi] = METRICS[i].domain - return rowTop(i) + ROW_H - ((clampTo(v, METRICS[i].domain) - lo) / (hi - lo)) * ROW_H + const [lo, hi] = ROWS[i].domain + return rowTop(i) + ROW_H - ((clampTo(v, ROWS[i].domain) - lo) / (hi - lo)) * ROW_H } export interface ReferencePoint { t: number, hr: number } @@ -98,7 +101,7 @@ export function referenceRuns(points: T[], gapMs = REFE const REFERENCE_TOL_MS = 5 * 60_000 const REFERENCE_STROKE = 'var(--text-1)' -export function VitalsChart({ sessions, stall, bands, reference = [], referenceHrv = [], referenceRmssd = [], referenceBr = [], movement = [], onSelectWindow }: { +export function VitalsChart({ sessions, stall, bands, reference = [], referenceHrv = [], referenceRmssd = [], referenceBr = [], referenceOxygen = [], movement = [], onSelectWindow }: { sessions: Session[] /** In bed with no vitals: from the last reading to now. */ stall: { start: number, end: number } | null @@ -108,10 +111,13 @@ export function VitalsChart({ sessions, stall, bands, reference = [], referenceH referenceHrv?: Array<{ t: number, value: number }> referenceRmssd?: Array<{ t: number, value: number }> referenceBr?: Array<{ t: number, value: number }> + referenceOxygen?: Array<{ t: number, value: number }> movement?: Array<{ bucketStart: Date, eventCount: number }> onSelectWindow?: (window: RangeWindow) => void }) { - const PLOT_H = VITALS_H + 76 + const rows = readingsInSessions(referenceOxygen, sessions).length > 0 ? ROWS : METRICS + const vitalsH = rows.length * ROW_H + (rows.length - 1) * ROW_GAP + const PLOT_H = vitalsH + 76 const timeFormat = useTimeFormat() const [wrapRef, wrapW] = useWidth() const hatchId = useId() @@ -171,6 +177,7 @@ export function VitalsChart({ sessions, stall, bands, reference = [], referenceH { key: 'rmssd', points: referenceRmssd, row: 1, title: (value: number) => `Apple Watch RMSSD · ${Math.round(value)} ms` }, { key: 'hrv', points: referenceHrv, row: 1, title: (value: number) => `Apple Watch SDNN · ${Math.round(value)} ms` }, { key: 'br', points: referenceBr, row: 2, title: (value: number) => `Apple Watch breathing · ${value.toFixed(1)} br/min` }, + { key: 'oxygen', points: referenceOxygen, row: OXYGEN_INDEX, title: (value: number) => `Apple Watch blood oxygen · ${Math.round(value)}%` }, ].map(({ key, points, row, title }) => ( {referenceRuns(points.filter(p => p.t >= session.start && p.t <= session.end)).map((run, r) => ( @@ -222,19 +229,19 @@ export function VitalsChart({ sessions, stall, bands, reference = [], referenceH })()} ) - }), [axis, sessions, bands, smooth, referenceBySession, referenceHrv, referenceRmssd, referenceBr]) + }), [axis, sessions, bands, smooth, referenceBySession, referenceHrv, referenceRmssd, referenceBr, referenceOxygen]) return (
{/* Keep units and scales visible while the time axis pans underneath. */}
- {METRICS.flatMap((m, i) => m.ticks.map(v => ( + {rows.flatMap((m, i) => m.ticks.map(v => ( {v} )))} 0 - {maxMovement} + {maxMovement} {/* Row labels */} - {METRICS.map((m, i) => ( + {rows.map((m, i) => (
@@ -244,13 +251,13 @@ export function VitalsChart({ sessions, stall, bands, reference = [], referenceH
))} -
+
Movement changes / 5 min
{plotW > 0 && ( - + @@ -258,7 +265,7 @@ export function VitalsChart({ sessions, stall, bands, reference = [], referenceH {/* Gridlines + tick labels */} - {METRICS.map((m, i) => m.ticks.map(v => ( + {rows.map((m, i) => m.ticks.map(v => ( @@ -351,6 +358,7 @@ export function VitalsChart({ sessions, stall, bands, reference = [], referenceH { key: 'rmssd', points: referenceRmssd, row: 1, label: (v: number) => `Watch RMSSD ${Math.round(v)} ms` }, { key: 'hrv', points: referenceHrv, row: 1, label: (v: number) => `Watch SDNN ${Math.round(v)} ms` }, { key: 'br', points: referenceBr, row: 2, label: (v: number) => `Watch breathing ${v.toFixed(1)} br/min` }, + { key: 'oxygen', points: referenceOxygen, row: OXYGEN_INDEX, label: (v: number) => `Watch blood oxygen ${Math.round(v)}%` }, ].map(({ key, points, row, label }) => { const sample = nearestReference(points.filter(p => p.t >= hoverSeg.start && p.t <= hoverSeg.end), hovered.t) if (!sample) return null @@ -418,7 +426,7 @@ function LegendGroup({ device, children }: { device: string, children: ReactNode ) } -export function VitalsLegend({ bandLabel, stalled, reference = false, referenceHrv = false, referenceRmssd = false, referenceBr = false }: { bandLabel: string, stalled: boolean, reference?: boolean, referenceHrv?: boolean, referenceRmssd?: boolean, referenceBr?: boolean }) { +export function VitalsLegend({ bandLabel, stalled, reference = false, referenceHrv = false, referenceRmssd = false, referenceBr = false, referenceOxygen = false }: { bandLabel: string, stalled: boolean, reference?: boolean, referenceHrv?: boolean, referenceRmssd?: boolean, referenceBr?: boolean, referenceOxygen?: boolean }) { return (
@@ -441,7 +449,7 @@ export function VitalsLegend({ bandLabel, stalled, reference = false, referenceH )} - {(reference || referenceHrv || referenceRmssd || referenceBr) && ( + {(reference || referenceHrv || referenceRmssd || referenceBr || referenceOxygen) && ( {reference && ( @@ -467,6 +475,12 @@ export function VitalsLegend({ bandLabel, stalled, reference = false, referenceH breathing )} + {referenceOxygen && ( + + + blood oxygen + + )} )}
diff --git a/src/components/biometrics/WatchUpload.tsx b/src/components/biometrics/WatchUpload.tsx index 75dc5c798..236c68fa5 100644 --- a/src/components/biometrics/WatchUpload.tsx +++ b/src/components/biometrics/WatchUpload.tsx @@ -6,7 +6,7 @@ import { Button, Modal } from '@/src/components/ds' import { trpc } from '@/src/utils/trpc' import { WATCH_REQUEST_TIMEOUT_MS } from '@/src/utils/watchUploadLimits' -interface ImportResult { stages: number, vitals: number, breathing: number, hrv: number, rmssd?: number, rejectedStageBatches: number, matchedStages: number, matchedVitals: number, matchedBreathing: number, matchedHrv: number, matchedRmssd?: number } +interface ImportResult { stages: number, vitals: number, breathing: number, hrv: number, rmssd?: number, oxygen?: number, rejectedStageBatches: number, matchedStages: number, matchedVitals: number, matchedBreathing: number, matchedHrv: number, matchedRmssd?: number, matchedOxygen?: number } export function WatchUpload({ side, name }: { side: 'left' | 'right', name: string }) { const [open, setOpen] = useState(false) @@ -122,9 +122,9 @@ export function WatchUpload({ side, name }: { side: 'left' | 'right', name: stri {progress !== null &&

{progress < 100 ? `Uploading… ${progress}%` : 'Importing on the pod… Keep this window open.'}

} {result && (
-

{`Imported ${result.vitals} new heart-rate, ${result.breathing} new breathing-rate and ${result.hrv} new SDNN and ${result.rmssd ?? 0} new RMSSD samples, and ${result.stages} new sleep-stage segments.`}

-

{`Found ${result.matchedVitals} heart-rate, ${result.matchedBreathing} breathing-rate and ${result.matchedHrv} SDNN and ${result.matchedRmssd ?? 0} RMSSD samples and ${result.matchedStages} stages in this period. Previously imported records are skipped.`}

- {result.rejectedStageBatches > 0 &&

{`${result.rejectedStageBatches} stage batches conflicted with stored stages and were skipped. Heart-rate, breathing-rate and HRV import completed.`}

} +

{`Imported ${result.vitals} new heart-rate, ${result.breathing} new breathing-rate, ${result.hrv} new SDNN, ${result.rmssd ?? 0} new RMSSD and ${result.oxygen ?? 0} new blood-oxygen samples, and ${result.stages} new sleep-stage segments.`}

+

{`Found ${result.matchedVitals} heart-rate, ${result.matchedBreathing} breathing-rate, ${result.matchedHrv} SDNN, ${result.matchedRmssd ?? 0} RMSSD and ${result.matchedOxygen ?? 0} blood-oxygen samples and ${result.matchedStages} stages in this period. Previously imported records are skipped.`}

+ {result.rejectedStageBatches > 0 &&

{`${result.rejectedStageBatches} stage batches conflicted with stored stages and were skipped. Heart-rate, breathing-rate, HRV and blood-oxygen import completed.`}

}
)} {error &&

{error}

} diff --git a/src/components/biometrics/biometricsLogic.ts b/src/components/biometrics/biometricsLogic.ts index 2312fb0ec..2bf4a9059 100644 --- a/src/components/biometrics/biometricsLogic.ts +++ b/src/components/biometrics/biometricsLogic.ts @@ -31,6 +31,9 @@ export const METRICS: readonly MetricDef[] = [ { key: 'br', label: 'Breathing', unit: 'br/min', domain: [10, 26], ticks: [12, 18, 24], color: 'var(--chart-vital-br)', digits: 1 }, ] +/** Watch-only: the pod has no oxygen sensor, so this row has no pod line and is drawn only when readings exist. */ +export const OXYGEN_ROW: Omit & { key: 'oxygen' } = { key: 'oxygen', label: 'Blood oxygen', unit: '%', domain: [80, 100], ticks: [80, 90, 100], color: 'var(--text-1)', digits: 0 } + export interface VitalPoint { t: number, hr: number | null, hrv: number | null, br: number | null, provenanceKey?: string, hrvMetricId?: string | null } export interface Session { @@ -106,6 +109,11 @@ export function splitSessions(points: VitalPoint[], gapMs = SESSION_GAP_MS): Ses return out } +/** Readings the chart can draw: only sessions are on the axis, so anything between them has no place. */ +export function readingsInSessions(readings: T[], sessions: Array<{ start: number, end: number }>): T[] { + return readings.filter(r => sessions.some(s => r.t >= s.start && r.t <= s.end)) +} + /** * Centred rolling mean over ±(window/2) minutes by timestamp, ignoring nulls. * A point whose own value is null stays null so gaps aren't painted over. diff --git a/src/components/biometrics/tests/BiometricsPage.test.tsx b/src/components/biometrics/tests/BiometricsPage.test.tsx index 657b100cf..87c0182ca 100644 --- a/src/components/biometrics/tests/BiometricsPage.test.tsx +++ b/src/components/biometrics/tests/BiometricsPage.test.tsx @@ -22,6 +22,7 @@ const state = vi.hoisted(() => ({ reference: [] as Array<{ timestamp: Date, heartRate: number }>, referenceHrv: [] as Array<{ timestamp: Date, value: number }>, referenceRmssd: [] as Array<{ timestamp: Date, value: number }> | undefined, + referenceOxygen: [] as Array<{ timestamp: Date, value: number }> | undefined, referenceOpts: undefined as Record | undefined, })) @@ -57,7 +58,7 @@ vi.mock('@/src/utils/trpc', () => ({ }, getFileCount: { useQuery: () => ({ data: { rawFiles: { left: 4, right: 4 }, totalSizeMB: 8.45 } }) }, getMovementBuckets: { useQuery: () => ({ data: [{ bucketStart: new Date(2026, 8, 27, 5), eventCount: 3 }] }) }, - getReferenceSamples: { useQuery: (input: { metric: string }) => ({ data: input.metric === 'hrv_sdnn' ? state.referenceHrv : input.metric === 'hrv_rmssd' ? state.referenceRmssd : [] }) }, + getReferenceSamples: { useQuery: (input: { metric: string }) => ({ data: input.metric === 'hrv_sdnn' ? state.referenceHrv : input.metric === 'hrv_rmssd' ? state.referenceRmssd : input.metric === 'blood_oxygen' ? state.referenceOxygen : [] }) }, getReferenceVitals: { useQuery: (_input: unknown, opts: Record) => { state.referenceOpts = opts @@ -87,6 +88,7 @@ beforeEach(() => { vi.useFakeTimers({ now: NOW, toFake: ['Date'] }) state.referenceHrv = [] state.referenceRmssd = [] + state.referenceOxygen = [] state.occupied = true state.timeFormat = '12h' state.dataPath = { occupancy: { left: 'occupied', right: 'empty' } } @@ -324,6 +326,30 @@ it('draws a Watch-only night and names RMSSD when the pod recorded nothing', () expect(screen.getByText(/Watch SDNN, Watch RMSSD and sleepypod/)).toBeTruthy() }) +it('draws a Watch-only night from blood oxygen alone', () => { + state.vitals.mockReturnValue({ data: [], isLoading: false }) + state.referenceOxygen = [{ timestamp: new Date(LAST_VITAL), value: 96 }] + render() + expect(screen.queryByText('No vitals in this range')).toBeNull() + expect(screen.getByText('blood oxygen')).toBeTruthy() +}) + +it('names blood oxygen only when a reading falls inside a session', () => { + state.referenceOxygen = [{ timestamp: new Date(LAST_VITAL + 3 * 60 * 60_000), value: 96 }] + const view = render() + expect(screen.queryByText('blood oxygen')).toBeNull() + state.referenceOxygen = [{ timestamp: new Date(LAST_VITAL), value: 96 }] + view.rerender() + expect(screen.getByText('blood oxygen')).toBeTruthy() +}) + +it('keeps the pod chart while Watch blood oxygen has not loaded', () => { + state.referenceOxygen = undefined + render() + expect(screen.queryByText('No vitals in this range')).toBeNull() + expect(screen.queryByText('blood oxygen')).toBeNull() +}) + it('keeps the pod chart while Watch RMSSD has not loaded', () => { state.referenceRmssd = undefined render() diff --git a/src/components/biometrics/tests/VitalsChart.test.tsx b/src/components/biometrics/tests/VitalsChart.test.tsx index f5f8fe352..3676a668a 100644 --- a/src/components/biometrics/tests/VitalsChart.test.tsx +++ b/src/components/biometrics/tests/VitalsChart.test.tsx @@ -332,3 +332,57 @@ it('overlays RMSSD separately while retaining SDNN and breathing and breaking ga expect(screen.getByTestId('vitals-watch-hrv')).toBeTruthy() expect(screen.getByTestId('vitals-watch-br')).toBeTruthy() }) + +describe('blood oxygen row', () => { + const inSession = [{ t: START, value: 96 }, { t: START + MIN, value: 94 }, { t: START + 60 * MIN, value: 91 }] + const between = [{ t: START + 4 * 60 * MIN, value: 97 }] + const height = () => Number.parseInt(screen.getByTestId('vitals-chart').style.height) + + it('adds a row of its own, breaking the line across sampling gaps', () => { + withWidth() + render() + expect(screen.getByText('Blood oxygen')).toBeTruthy() + expect(screen.getAllByTestId('vitals-watch-oxygen')).toHaveLength(3) + expect(screen.getAllByTestId('vitals-watch-oxygen-line')).toHaveLength(1) + expect(screen.getByRole('img').getAttribute('aria-label')).toContain('blood oxygen') + expect(screen.getAllByTestId('vitals-watch-oxygen')[0].textContent).toBe('Apple Watch blood oxygen · 96%') + }) + + it('leaves the chart as it was with no readings, or only readings between sessions', () => { + withWidth() + const { rerender } = render() + const without = height() + expect(screen.queryByText('Blood oxygen')).toBeNull() + rerender() + expect(screen.queryByText('Blood oxygen')).toBeNull() + expect(screen.queryByTestId('vitals-watch-oxygen')).toBeNull() + expect(height()).toBe(without) + rerender() + expect(height()).toBe(without + 96) + }) + + it('moves the movement row below it instead of drawing over it', () => { + withWidth() + const { rerender } = render() + const top = () => Number.parseInt(screen.getByText('Movement').parentElement?.style.top ?? '') + const before = top() + rerender() + expect(top()).toBe(before + 96) + }) + + it('pins a reading below the row to its bottom edge and keeps the real value on hover', () => { + withWidth() + render() + const [low, floor] = screen.getAllByTestId('vitals-watch-oxygen') + expect(low.getAttribute('cy')).toBe(floor.getAttribute('cy')) + fireEvent.pointerMove(screen.getByTestId('vitals-chart').querySelector('rect[fill="transparent"]') as Element, { clientX: 8 }) + expect(screen.getByTestId('vitals-watch-oxygen-hover').textContent).toBe('Watch blood oxygen 72%') + }) + + it('names blood oxygen under the Watch only when the row is drawn', () => { + const { rerender } = render() + expect(screen.queryByText('blood oxygen')).toBeNull() + rerender() + expect(screen.getByText('Apple Watch').nextElementSibling?.textContent).toContain('blood oxygen') + }) +}) diff --git a/src/components/biometrics/tests/WatchUpload.test.tsx b/src/components/biometrics/tests/WatchUpload.test.tsx index 17b54fd31..20d13b89a 100644 --- a/src/components/biometrics/tests/WatchUpload.test.tsx +++ b/src/components/biometrics/tests/WatchUpload.test.tsx @@ -32,8 +32,8 @@ it('uploads for the named sleeper, reports partial conflicts, and refreshes char xhr.onload?.() }) await waitFor(() => expect(invalidate).toHaveBeenCalled()) - expect(screen.getByText(/Imported 12 new heart-rate, 5 new breathing-rate and 2 new SDNN and 0 new RMSSD samples/)).toBeTruthy() - expect(screen.getByText(/Found 14 heart-rate, 6 breathing-rate and 2 SDNN and 0 RMSSD samples/)).toBeTruthy() + expect(screen.getByText(/Imported 12 new heart-rate, 5 new breathing-rate, 2 new SDNN, 0 new RMSSD and 0 new blood-oxygen samples/)).toBeTruthy() + expect(screen.getByText(/Found 14 heart-rate, 6 breathing-rate, 2 SDNN, 0 RMSSD and 0 blood-oxygen samples/)).toBeTruthy() expect(screen.getByText(/stage batches conflicted/)).toBeTruthy() }) diff --git a/src/components/biometrics/tests/biometricsLogic.test.ts b/src/components/biometrics/tests/biometricsLogic.test.ts index bf3af2cab..6e98273e4 100644 --- a/src/components/biometrics/tests/biometricsLogic.test.ts +++ b/src/components/biometrics/tests/biometricsLogic.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { axisTicks, BREAK_PX, baselineBand, clampTo, fmtDuration, fmtGap, formatRangeLabel, layoutAxis, metricStats, nearestIndex, - nightWindow, percentile, rangeWindow, rollingMean, segmentTicks, sessionHeader, splitSessions, STALL_PX, + nightWindow, percentile, rangeWindow, readingsInSessions, rollingMean, segmentTicks, sessionHeader, splitSessions, STALL_PX, type VitalPoint, } from '../biometricsLogic' @@ -67,6 +67,15 @@ describe('sessions', () => { }) }) +describe('readings on the axis', () => { + it('keeps readings inside a session, edges included, and drops those between sessions', () => { + const sessions = [{ start: 100, end: 200 }, { start: 400, end: 500 }] + const readings = [99, 100, 200, 201, 300, 400, 501].map(t => ({ t })) + expect(readingsInSessions(readings, sessions).map(r => r.t)).toEqual([100, 200, 400]) + expect(readingsInSessions(readings, [])).toEqual([]) + }) +}) + describe('formatting', () => { it('formats durations and gaps', () => { expect(fmtDuration(236 * MIN)).toBe('3h 56m') diff --git a/src/demo/handlers/biometrics.ts b/src/demo/handlers/biometrics.ts index 15a678034..f3f7d5b0a 100644 --- a/src/demo/handlers/biometrics.ts +++ b/src/demo/handlers/biometrics.ts @@ -224,9 +224,12 @@ export const biometrics: DemoHandlers<'biometrics'> = { const start = input.startDate.getTime() const end = input.endDate.getTime() if (start > end) throw new TRPCError({ code: 'BAD_REQUEST', message: 'startDate must be before or equal to endDate' }) - if (input.metric !== 'hrv_sdnn' && input.metric !== 'breathing_rate') return [] + if (input.metric !== 'hrv_sdnn' && input.metric !== 'breathing_rate' && input.metric !== 'blood_oxygen') return [] + const reading = (v: ReturnType[number]) => input.metric === 'blood_oxygen' + ? 94 + v.breathingRate % 5 + : input.metric === 'hrv_sdnn' ? v.hrv * 0.7 : v.breathingRate - 0.8 return referenceGrid(input.side, start, end) - .map(v => ({ timestamp: new Date(v.t), endTimestamp: null, device: null, algorithm: null, value: Math.round((input.metric === 'hrv_sdnn' ? v.hrv * 0.7 : v.breathingRate - 0.8) * 10) / 10 })) + .map(v => ({ timestamp: new Date(v.t), endTimestamp: null, device: null, algorithm: null, value: Math.round(reading(v) * 10) / 10 })) .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()) .slice(-(input.limit ?? 5000)) }, diff --git a/src/demo/tests/biometrics.referenceVitals.test.ts b/src/demo/tests/biometrics.referenceVitals.test.ts index 1932f3bd7..21c5659dc 100644 --- a/src/demo/tests/biometrics.referenceVitals.test.ts +++ b/src/demo/tests/biometrics.referenceVitals.test.ts @@ -57,7 +57,7 @@ describe('demo biometrics.getReferenceVitals', () => { }) describe('demo reference samples and stages', () => { - it.each(['hrv_sdnn', 'breathing_rate'] as const)('returns chronological %s samples and keeps the newest when limited', async (metric) => { + it.each(['hrv_sdnn', 'breathing_rate', 'blood_oxygen'] as const)('returns chronological %s samples and keeps the newest when limited', async (metric) => { const night = demoNights('left')[0] const input = { side: 'left' as const, startDate: night.enteredBedAt, endDate: (night.leftBedAt ?? new Date()), metric } const all = await required(biometrics.getReferenceSamples)(input) @@ -66,6 +66,14 @@ describe('demo reference samples and stages', () => { expect(await required(biometrics.getReferenceSamples)({ ...input, limit: 3 })).toEqual(all.slice(-3)) await expect(async () => required(biometrics.getReferenceSamples)({ ...input, startDate: input.endDate, endDate: new Date(0) })).rejects.toThrow(/startDate/) }) + it('keeps demo blood oxygen inside the row', async () => { + const night = demoNights('left')[0] + const rows = await required(biometrics.getReferenceSamples)({ side: 'left', startDate: night.enteredBedAt, endDate: (night.leftBedAt ?? new Date()), metric: 'blood_oxygen' }) + for (const row of rows) { + expect(row.value).toBeGreaterThanOrEqual(90) + expect(row.value).toBeLessThanOrEqual(100) + } + }) it('returns overlapping Watch stage blocks for the chosen side and honors the limit', async () => { const night = demoNights('right')[0] const input = { side: 'right' as const, startDate: night.enteredBedAt, endDate: (night.leftBedAt ?? new Date()) } From ad7fd01d849ab27cd3709b73fd44ce2f4276b67a Mon Sep 17 00:00:00 2001 From: Jonathan Ng Date: Fri, 6 Nov 2026 00:09:12 -0800 Subject: [PATCH 2/2] fix(biometrics): name blood oxygen in the upload dialog's privacy note --- src/components/biometrics/WatchUpload.tsx | 4 ++-- src/demo/tests/biometrics.referenceVitals.test.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/biometrics/WatchUpload.tsx b/src/components/biometrics/WatchUpload.tsx index 236c68fa5..684893e64 100644 --- a/src/components/biometrics/WatchUpload.tsx +++ b/src/components/biometrics/WatchUpload.tsx @@ -75,7 +75,7 @@ export function WatchUpload({ side, name }: { side: 'left' | 'right', name: stri

What this does

-

Imports Apple Watch heart rate, breathing rate, HRV and sleep stages for the selected period. Watch heart rate, breathing rate and HRV appear alongside the pod’s readings in Vitals. SDNN and RMSSD are separate overlays, shown only when available; sleep stages are saved as reference data.

+

Imports Apple Watch heart rate, breathing rate, HRV, blood oxygen and sleep stages for the selected period. Watch heart rate, breathing rate and HRV appear alongside the pod’s readings in Vitals, and blood oxygen gets its own row. SDNN and RMSSD are separate overlays, shown only when available; sleep stages are saved as reference data.

This is a one-time import. Upload a fresh export whenever you want to add recent data. Previously imported records are skipped, and the pod’s own readings are preserved.

@@ -88,7 +88,7 @@ export function WatchUpload({ side, name }: { side: 'left' | 'right', name: stri Apple’s export guide
-

The full export is sent to your pod, which imports only Watch heart rate, breathing rate, HRV and sleep stages and removes the uploaded file after processing. ZIP or extracted export.xml, up to 512 MiB.

+

The full export is sent to your pod, which imports only Watch heart rate, breathing rate, HRV, blood oxygen and sleep stages and removes the uploaded file after processing. ZIP or extracted export.xml, up to 512 MiB.

Health export {/* Hidden native picker; the Choose file button opens it and is the keyboard and screen-reader target. */} diff --git a/src/demo/tests/biometrics.referenceVitals.test.ts b/src/demo/tests/biometrics.referenceVitals.test.ts index 21c5659dc..d843ea5d4 100644 --- a/src/demo/tests/biometrics.referenceVitals.test.ts +++ b/src/demo/tests/biometrics.referenceVitals.test.ts @@ -69,6 +69,7 @@ describe('demo reference samples and stages', () => { it('keeps demo blood oxygen inside the row', async () => { const night = demoNights('left')[0] const rows = await required(biometrics.getReferenceSamples)({ side: 'left', startDate: night.enteredBedAt, endDate: (night.leftBedAt ?? new Date()), metric: 'blood_oxygen' }) + expect(rows.length).toBeGreaterThan(0) for (const row of rows) { expect(row.value).toBeGreaterThanOrEqual(90) expect(row.value).toBeLessThanOrEqual(100)