From bf3830203f5c767835ebeb1223ee453eed8a6946 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 14:32:56 +0200 Subject: [PATCH] fix(hrv): keep RMSSD on the display surfaces for an account with no SDNN Fitbit and Google Health HRV is filed as RMSSD since migration 0420, so an account whose only HRV comes from them lost it wherever a display surface read SDNN alone. The dashboard HRV tile keeps its RMSSD fallback with Recovery off while the account has no SDNN, and its toggle stays offered for either estimator. A briefing HRV signal counts as measured today when either series has a reading. The doctor report already files RMSSD as its own ungated leaf; a guard now holds that. Readiness, HRV balance and stress stay on SDNN; a test pins that an RMSSD-only account renormalises over the other components. --- .../dashboard-hrv-rmssd-fallback.test.tsx | 79 ++++++++++++++----- src/app/api/dashboard/widgets/route.ts | 22 +++--- src/app/insights/hrv/page.tsx | 6 +- src/app/page-client.tsx | 5 +- src/components/dashboard/dashboard-gates.ts | 6 +- .../insights/healthkit-metric-page.tsx | 6 +- src/lib/ai/coach/source-measurement-types.ts | 5 +- .../daily/__tests__/briefing-today.test.ts | 35 ++++++++ src/lib/daily/briefing-today-read.ts | 3 +- src/lib/daily/briefing-today-warm.ts | 4 +- src/lib/daily/briefing-today.ts | 26 +++++- src/lib/dashboard/snapshot.ts | 1 + src/lib/dashboard/widget-modules.ts | 31 +++++--- .../derived/__tests__/readiness.test.ts | 34 ++++++++ src/lib/insights/metric-availability.ts | 10 +-- src/lib/insights/sub-page-metric.ts | 3 +- src/lib/mcp/rich-reads.ts | 6 +- .../__tests__/catalogue-guard.test.ts | 16 ++++ 18 files changed, 232 insertions(+), 66 deletions(-) diff --git a/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.tsx b/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.tsx index 2c8790363..c063d13c1 100644 --- a/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.tsx +++ b/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.tsx @@ -24,9 +24,9 @@ import type { DataSummary } from "@/lib/analytics/trends"; * The dashboard HRV tile must accept either HRV type. * * HRV is stored under two measurement types that are not the same measure: - * SDNN (`HEART_RATE_VARIABILITY`, Apple / Fitbit / Google Health) and nightly - * RMSSD (`HRV_RMSSD`, Oura / Polar / WHOOP, and a bridge pushing a Garmin-style - * feed). Every other HRV surface already unions them — `/insights/hrv` via + * SDNN (`HEART_RATE_VARIABILITY`, Apple Health / Health Connect) and nightly + * RMSSD (`HRV_RMSSD`, Oura / Polar / WHOOP / Fitbit / Google Health, and a + * bridge pushing a Garmin-style feed). Every other HRV surface already unions them — `/insights/hrv` via * `fallbackMeasurementType`, the Coach via `COACH_SOURCE_MEASUREMENT_TYPES.hrv`, * the MCP reads via `withHrvFallback` — and `sub-page-metric.ts` states that * listing both is what keeps "the tab, pill, and dashboard tile lit whichever @@ -141,16 +141,16 @@ describe("pickHrvSummary", () => { }); describe("the RMSSD fallback cannot leak recovery-module data", () => { - it("drops HRV_RMSSD from the snapshot summaries when recovery is off", () => { + it("lists HRV_RMSSD among the recovery-off summary types", () => { // The half of this fix that is easy to miss. `HRV_RMSSD` is recovery-owned // in `METRIC_STATUS_MODULE_OWNERS`, which governs the metric-status and // MCP reads — but the dashboard snapshot gates off `SUMMARY_TYPE_MODULE`, // a DIFFERENT map, and that one did not list it. Inert while the tile read - // SDNN alone; the moment the tile falls back to RMSSD it would show a - // recovery-off account a tile built from recovery-owned rows. + // SDNN alone. The one exception, the tile's fallback for an account with + // no SDNN, is made in `gateSummariesByModules` (tested below). expect(disabledSummaryTypes({ recovery: false })).toContain("HRV_RMSSD"); // SDNN is a plain vital and stays ungated — turning recovery off must not - // take the ordinary HRV tile away from an Apple / Fitbit account. + // take the ordinary HRV tile away from an Apple Health account. expect(disabledSummaryTypes({ recovery: false })).not.toContain( "HEART_RATE_VARIABILITY", ); @@ -290,35 +290,74 @@ describe("the gate reaches every feed, not just the default one", () => { expect(on.summaries.HRV_RMSSD).toBeDefined(); expect(summaries.HRV_RMSSD).toBeDefined(); }); + + it("keeps the RMSSD fallback for an account with no SDNN", () => { + // A Fitbit or Google Health account files its nightly HRV as RMSSD since + // migration 0420. On this tile RMSSD stands in for SDNN, a plain vital + // that paints with Recovery off, so the fallback must not vanish for the + // estimator the device reports. + const summaries = { + HEART_RATE_VARIABILITY: summary(0, 0), + HRV_RMSSD: summary(30, 42), + }; + const lastSeen = { + HRV_RMSSD: { lastSeenAt: "2026-09-02T00:00:00.000Z" }, + }; + const off = gateSummariesByModules(summaries, lastSeen, { + recovery: false, + }); + expect(off.summaries.HRV_RMSSD).toBeDefined(); + expect(off.lastSeenByType.HRV_RMSSD).toBeDefined(); + expect(pickHrvSummary(off.summaries).type).toBe("HRV_RMSSD"); + // Another recovery-owned series is still stripped. + const withResting = gateSummariesByModules( + { ...summaries, RESTING_HEART_RATE: summary(5, 58) }, + lastSeen, + { recovery: false }, + ); + expect( + (withResting.summaries as Record).RESTING_HEART_RATE, + ).toBeUndefined(); + }); }); describe("a switch that cannot do anything is not offered", () => { - // The symptom one step on from the original bug: with recovery off, a - // ring / strap account's only HRV series is withheld, so the tile can - // never paint — and before this, the toggle was still offered, still - // turned on, and still explained nothing. Same experience as the defect - // this change exists to remove, so it does not get to survive here. - it("drops the HRV toggle for a ring account with recovery off", () => { + it("drops the HRV toggle for an account with recovery off and no HRV", () => { expect( - unavailableWidgetIds({ recovery: false }, { hasSdnn: false }), + unavailableWidgetIds( + { recovery: false }, + { hasSdnn: false, hasRmssd: false }, + ), ).toEqual(["hrv"]); }); - it("keeps the toggle for an account that has SDNN", () => { - // SDNN is a plain vital that no module owns, so the tile still works - // with recovery off. Taking this row away would be a new bug. + it("keeps the toggle for an account that has either estimator", () => { expect( - unavailableWidgetIds({ recovery: false }, { hasSdnn: true }), + unavailableWidgetIds( + { recovery: false }, + { hasSdnn: true, hasRmssd: false }, + ), + ).toEqual([]); + expect( + unavailableWidgetIds( + { recovery: false }, + { hasSdnn: false, hasRmssd: true }, + ), ).toEqual([]); }); it("keeps the toggle whenever recovery is on", () => { expect( - unavailableWidgetIds({ recovery: true }, { hasSdnn: false }), + unavailableWidgetIds( + { recovery: true }, + { hasSdnn: false, hasRmssd: false }, + ), ).toEqual([]); // Fail-open on an unresolved map, like the module gate beside it: only // an explicit `false` takes a row away. - expect(unavailableWidgetIds({}, { hasSdnn: false })).toEqual([]); + expect( + unavailableWidgetIds({}, { hasSdnn: false, hasRmssd: false }), + ).toEqual([]); }); }); diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 470c6362a..3c394cebf 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -235,7 +235,7 @@ export const GET = apiHandler(async () => { // linger for minutes after Recovery went off, which is the confusion this // is removing. // - // The SDNN probe is an index-only existence check + // The HRV probes are index-only existence checks // (`@@index([userId, type, measuredAt])`), not a summaries read; it runs // only when Recovery is off, so the common account pays nothing. // One flag, read the way this codebase reads one flag — not the whole @@ -243,20 +243,22 @@ export const GET = apiHandler(async () => { // is the only module that decides it. const recoveryEnabled = await isModuleEnabled(user.id, "recovery"); let hasSdnn = true; + let hasRmssd = true; if (!recoveryEnabled) { - const sdnnRow = await prisma.measurement.findFirst({ - where: { - userId: user.id, - type: "HEART_RATE_VARIABILITY", - deletedAt: null, - }, - select: { id: true }, - }); + const [sdnnRow, rmssdRow] = await Promise.all( + (["HEART_RATE_VARIABILITY", "HRV_RMSSD"] as const).map((type) => + prisma.measurement.findFirst({ + where: { userId: user.id, type, deletedAt: null }, + select: { id: true }, + }), + ), + ); hasSdnn = sdnnRow !== null; + hasRmssd = rmssdRow !== null; } const unavailable = unavailableWidgetIds( { recovery: recoveryEnabled }, - { hasSdnn }, + { hasSdnn, hasRmssd }, ); return apiSuccess( unavailable.length > 0 diff --git a/src/app/insights/hrv/page.tsx b/src/app/insights/hrv/page.tsx index f46fce113..06f3462a4 100644 --- a/src/app/insights/hrv/page.tsx +++ b/src/app/insights/hrv/page.tsx @@ -11,9 +11,9 @@ import { EcgCrossLink } from "@/components/insights/ecg-cross-link"; * Heart-rate variability sub-page. Pairs with the iOS HKQuantityType * `heartRateVariabilitySDNN` ingest path; the user typically logs one * sample per night during sleep, so the chart is sparse but the - * weekly trend is the load-bearing signal. Apple Health / Fitbit store - * SDNN as millisecond `HEART_RATE_VARIABILITY` rows; Oura / Polar / WHOOP - * store nightly RMSSD as `HRV_RMSSD`. The page renders SDNN when present + * weekly trend is the load-bearing signal. Apple Health and Health Connect + * store SDNN as millisecond `HEART_RATE_VARIABILITY` rows; Oura / Polar / + * WHOOP / Fitbit / Google Health store nightly RMSSD as `HRV_RMSSD`. The page renders SDNN when present * and falls back to the RMSSD series (labelled "RMSSD") otherwise, so a * ring / strap user's stored HRV always surfaces. Since iOS 27 an Apple * Watch writes both (#1110); with rows of both the page charts each on its diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index b72534282..02c00fe5d 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -491,8 +491,9 @@ export default function DashboardPageClient({ // measurement-type enum), so no backend change was needed; the strip // tiles below self-gate on `count > 0`. // HRV is stored under two types and the tile has to accept either: SDNN - // (`HEART_RATE_VARIABILITY`, Apple / Fitbit / Google Health) or nightly - // RMSSD (`HRV_RMSSD`, Oura / Polar / WHOOP and bridge pushes). Reading + // (`HEART_RATE_VARIABILITY`, Apple Health / Health Connect) or nightly + // RMSSD (`HRV_RMSSD`, Oura / Polar / WHOOP / Fitbit / Google Health and + // bridge pushes). Reading // SDNN alone left a ring / strap account with a Settings toggle that // rendered nothing while `/insights/hrv` charted the same data. The // helper keeps the union in one place and hands back which type won, so diff --git a/src/components/dashboard/dashboard-gates.ts b/src/components/dashboard/dashboard-gates.ts index d219ffa9a..c26154b33 100644 --- a/src/components/dashboard/dashboard-gates.ts +++ b/src/components/dashboard/dashboard-gates.ts @@ -164,9 +164,9 @@ export const HRV_LABEL_KEY: Record = { * Which HRV series backs the dashboard tile. * * HRV is stored under two types and they are not the same measure: Apple - * Health / Fitbit / Google Health write SDNN as `HEART_RATE_VARIABILITY`, - * while Oura / Polar / WHOOP — and a Garmin-style feed pushed in through a - * bridge — write nightly RMSSD as `HRV_RMSSD`. Every other HRV surface + * Health and Health Connect write SDNN as `HEART_RATE_VARIABILITY`, while + * Oura / Polar / WHOOP / Fitbit / Google Health — and a Garmin-style feed + * pushed in through a bridge — write nightly RMSSD as `HRV_RMSSD`. Every other HRV surface * already unions the two: `/insights/hrv` passes `fallbackMeasurementType` * (`app/insights/hrv/page.tsx`), the Coach resolves both * (`COACH_SOURCE_MEASUREMENT_TYPES.hrv`), and the MCP reads swap through diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index 0a595e3d9..22129a95e 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -59,9 +59,9 @@ export interface HealthKitMetricPageProps { /** * v1.17.0 — optional secondary MeasurementType the page falls back to * when the primary type has no rows but the fallback does. Used by HRV: - * the primary `HEART_RATE_VARIABILITY` (SDNN, Apple / Fitbit) is empty - * for a ring / strap user whose nightly HRV is stored as `HRV_RMSSD` - * (Oura / Polar / WHOOP). When the swap is active the chart, stat strip, + * the primary `HEART_RATE_VARIABILITY` (SDNN, Apple Health / Health + * Connect) is empty for a ring / strap user whose nightly HRV is stored as + * `HRV_RMSSD` (Oura / Polar / WHOOP / Fitbit / Google Health). When the swap is active the chart, stat strip, * "all values" list, and diversity nudge key off the fallback type and * `fallbackMeasureLabel` names the measure so the two are never silently * merged. Omit it and every other page renders byte-identically. diff --git a/src/lib/ai/coach/source-measurement-types.ts b/src/lib/ai/coach/source-measurement-types.ts index 186032b01..632a6f4ab 100644 --- a/src/lib/ai/coach/source-measurement-types.ts +++ b/src/lib/ai/coach/source-measurement-types.ts @@ -25,8 +25,9 @@ export const COACH_SOURCE_MEASUREMENT_TYPES: Readonly< pulse: ["PULSE"], mood: [], compliance: [], - // v1.30.4 (HRV union) — Oura / Polar / WHOOP write nightly HRV as RMSSD - // (`HRV_RMSSD`), never SDNN (`HEART_RATE_VARIABILITY`, Apple / Fitbit). + // v1.30.4 (HRV union) — Oura / Polar / WHOOP / Fitbit / Google Health + // write nightly HRV as RMSSD (`HRV_RMSSD`), never SDNN + // (`HEART_RATE_VARIABILITY`, Apple Health). // The app's HRV surface already unions both (`sub-page-metric.ts`); // resolving SDNN alone here made a ring/strap-only user's Coach context // + the `get_metric_series`/rich-read MCP paths report a false diff --git a/src/lib/daily/__tests__/briefing-today.test.ts b/src/lib/daily/__tests__/briefing-today.test.ts index a3b877539..584525a45 100644 --- a/src/lib/daily/__tests__/briefing-today.test.ts +++ b/src/lib/daily/__tests__/briefing-today.test.ts @@ -159,6 +159,41 @@ describe("briefingForToday", () => { ); }); + it("keeps an HRV signal for an account whose only HRV is RMSSD", () => { + // Fitbit, Google Health and every ring file nightly HRV as `HRV_RMSSD`; + // the Coach reads the `hrv` source over both series, so a signal written + // for such an account is a reading of today when the RMSSD row is. + const hrvBriefing: DailyBriefing = { + ...BRIEFING, + signalsOfDay: [ + { + sourceMetric: "hrv", + tone: "info", + headline: "HRV höher als üblich", + nudge: "Guter Tag für Bewegung.", + delta: "+6,4 ms vs. dein 30-Tage-Mittel", + }, + ], + }; + const out = briefingForToday(hrvBriefing, { + ...BERLIN, + generatedAt: "2026-10-05T04:00:00Z", + lastSeenAt: (type) => + type === "HRV_RMSSD" ? "2026-10-05T04:30:00Z" : null, + }); + expect(out?.signalsOfDay?.map((s) => s.delta)).toEqual([ + "+6 ms vs. dein 30-Tage-Mittel", + ]); + // Neither series measured today: still dropped. + const stale = briefingForToday(hrvBriefing, { + ...BERLIN, + generatedAt: "2026-10-05T04:00:00Z", + lastSeenAt: (type) => + type === "HRV_RMSSD" ? "2026-10-04T04:30:00Z" : null, + }); + expect(stale?.signalsOfDay).toEqual([]); + }); + it("serves no briefing written on an earlier day, just after midnight", () => { // Written 23:30 on the 4th, read 00:10 on the 5th (Berlin). expect( diff --git a/src/lib/daily/briefing-today-read.ts b/src/lib/daily/briefing-today-read.ts index 49ace8d14..77e535585 100644 --- a/src/lib/daily/briefing-today-read.ts +++ b/src/lib/daily/briefing-today-read.ts @@ -16,6 +16,7 @@ import type { Locale } from "@/lib/i18n/config"; import { readBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at"; import { userDayKey } from "@/lib/tz/format"; import { + ALL_SIGNAL_READING_TYPES, SIGNAL_READING_TYPE, briefingForToday, } from "@/lib/daily/briefing-today"; @@ -47,7 +48,7 @@ export async function readSignalLastSeen( const rows = await readLatestEver( prisma, userId, - Object.values(SIGNAL_READING_TYPE) as MeasurementType[], + ALL_SIGNAL_READING_TYPES as MeasurementType[], ); const byType = new Map( rows.map((row) => [ diff --git a/src/lib/daily/briefing-today-warm.ts b/src/lib/daily/briefing-today-warm.ts index 885df5ecf..5d7f31244 100644 --- a/src/lib/daily/briefing-today-warm.ts +++ b/src/lib/daily/briefing-today-warm.ts @@ -25,7 +25,7 @@ import { checkRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; import type { Locale } from "@/lib/i18n/config"; import { userDayKey } from "@/lib/tz/format"; -import { SIGNAL_READING_TYPE } from "@/lib/daily/briefing-today"; +import { ALL_SIGNAL_READING_TYPES } from "@/lib/daily/briefing-today"; import { enqueueTodayBriefingWarm } from "@/lib/jobs/insight-pregenerate-shared"; /** A claim outlives the longest local day (a 25 h DST day) with margin. */ @@ -63,7 +63,7 @@ export function todayWarmReasons(input: TodayWarmInput): string[] { ) { reasons.push("day"); } - for (const type of new Set(Object.values(SIGNAL_READING_TYPE))) { + for (const type of ALL_SIGNAL_READING_TYPES) { const at = input.lastSeenAt(type); if (at === null) continue; const atMs = Date.parse(at); diff --git a/src/lib/daily/briefing-today.ts b/src/lib/daily/briefing-today.ts index 4ca2b7720..da8181518 100644 --- a/src/lib/daily/briefing-today.ts +++ b/src/lib/daily/briefing-today.ts @@ -46,6 +46,28 @@ export const SIGNAL_READING_TYPE: Readonly> = { glucose: "BLOOD_GLUCOSE", }; +/** + * Every type a signal's reading may be filed under. HRV is two series: SDNN + * (`HEART_RATE_VARIABILITY`, Apple Health / Health Connect) and nightly RMSSD + * (`HRV_RMSSD`, rings, straps, Fitbit and Google Health). The Coach reads the + * `hrv` source over both, so a signal written for an RMSSD-only account is a + * reading of today when either series has one; checking SDNN alone dropped + * every such signal. Both read in whole milliseconds, so the precision + * lookup keeps the one type in {@link SIGNAL_READING_TYPE}. + */ +export const SIGNAL_READING_TYPES: Readonly> = + Object.fromEntries( + Object.entries(SIGNAL_READING_TYPE).map(([metric, type]) => [ + metric, + metric === "hrv" ? [type, "HRV_RMSSD"] : [type], + ]), + ); + +/** Every type any signal may be a reading of, once each. */ +export const ALL_SIGNAL_READING_TYPES: readonly string[] = [ + ...new Set(Object.values(SIGNAL_READING_TYPES).flat()), +]; + /** The locale's decimal and grouping separators ("," and "." for de). */ function separators(locale: Locale): { decimal: string; group: string } { const parts = new Intl.NumberFormat(resolveIntlLocale(locale)).formatToParts( @@ -204,8 +226,8 @@ export function briefingForToday( }; const signalsOfDay = briefing.signalsOfDay .filter((signal) => { - const type = SIGNAL_READING_TYPE[signal.sourceMetric]; - return type === undefined || measuredToday(type); + const types = SIGNAL_READING_TYPES[signal.sourceMetric]; + return types === undefined || types.some(measuredToday); }) .map((signal) => signal.delta diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 5008cffda..4293375c7 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -1153,6 +1153,7 @@ function gateLayoutByModules( const unavailable = new Set( unavailableWidgetIds(modules, { hasSdnn: (summaries.HEART_RATE_VARIABILITY?.count ?? 0) > 0, + hasRmssd: (summaries.HRV_RMSSD?.count ?? 0) > 0, }), ); return hideModuleWidgets(layout, modules, unavailable); diff --git a/src/lib/dashboard/widget-modules.ts b/src/lib/dashboard/widget-modules.ts index a8b025d99..4142347e7 100644 --- a/src/lib/dashboard/widget-modules.ts +++ b/src/lib/dashboard/widget-modules.ts @@ -120,26 +120,30 @@ export function disabledSummaryTypes( * * `hrv` is that widget. The tile takes SDNN when the account has it and * falls back to nightly RMSSD otherwise (see `pickHrvSummary`). RMSSD is - * recovery-owned, so with Recovery off it is stripped from every feed — - * which leaves a ring / strap account, whose only HRV is RMSSD, holding a - * switch that is on and a tile that can never paint, with nothing saying - * why. That is precisely the defect the RMSSD fallback exists to remove, so - * it must not survive one step further on. An account WITH SDNN keeps the - * row: its tile still works with Recovery off, because SDNN is a plain - * vital that no module owns. + * recovery-owned everywhere else, but on this tile it stands in for SDNN, a + * plain vital no module owns, so `gateSummariesByModules` keeps it with + * Recovery off for an account that has no SDNN. The toggle is dead only for + * an account with Recovery off and no HRV of either kind. * * Decided from server-resolved facts and handed to the client, so the * Settings screen does not have to fetch dashboard data to find out. */ export function unavailableWidgetIds( modules: Partial>, - facts: { hasSdnn: boolean }, + facts: { hasSdnn: boolean; hasRmssd: boolean }, ): string[] { const out: string[] = []; - if (modules.recovery === false && !facts.hasSdnn) out.push("hrv"); + if (modules.recovery === false && !facts.hasSdnn && !facts.hasRmssd) { + out.push("hrv"); + } return out; } +function hasRows(summary: unknown): boolean { + const count = (summary as { count?: unknown } | undefined)?.count; + return typeof count === "number" && count > 0; +} + export function gateSummariesByModules< S extends Record, L extends Record, @@ -149,6 +153,15 @@ export function gateSummariesByModules< modules: Partial>, ): { summaries: S; lastSeenByType: L } { const dropped = disabledSummaryTypes(modules); + // The HRV tile's RMSSD fallback rides with SDNN, not with recovery. SDNN is + // a plain vital on this surface and paints with Recovery off; an account + // whose only HRV is RMSSD (a ring or strap, and every Fitbit or Google + // Health account since migration 0420 refiled their nightly HRV) would + // otherwise lose the tile for the estimator its device happens to report. + // Kept only while the account has no SDNN, so the series never mixes. + if (dropped.has("HRV_RMSSD") && !hasRows(summaries.HEART_RATE_VARIABILITY)) { + dropped.delete("HRV_RMSSD"); + } if (dropped.size === 0) return { summaries, lastSeenByType }; const outSummaries = {} as S; for (const [type, summary] of Object.entries(summaries)) { diff --git a/src/lib/insights/derived/__tests__/readiness.test.ts b/src/lib/insights/derived/__tests__/readiness.test.ts index 5371acb2f..62b83a5ee 100644 --- a/src/lib/insights/derived/__tests__/readiness.test.ts +++ b/src/lib/insights/derived/__tests__/readiness.test.ts @@ -133,6 +133,40 @@ describe("computeReadiness gating", () => { } }); + it("renormalises over the other components for an account whose only HRV is RMSSD", async () => { + // Fitbit and Google Health file nightly HRV as `HRV_RMSSD` (migration + // 0420). Readiness stays on SDNN by design, so such an account has no + // HRV component: it must drop out with weight 0 and the rest carry the + // score, never an error and never an RMSSD value read as SDNN. + const days = (type: string, base: number) => + Array.from({ length: 10 }, (_, i) => ({ + value: base, + measuredAt: new Date( + `2026-05-${String(15 + i).padStart(2, "0")}T07:00:00Z`, + ), + type, + })); + measurementFindMany.mockImplementation( + async (args: { where: { type: unknown } }) => { + const type = args.where.type; + if (type === "RESTING_HEART_RATE") return days(type, 58); + if (type === "RESPIRATORY_RATE") return days(type, 14); + if (type === "HRV_RMSSD") return days(type, 42); + return []; + }, + ); + const result = await computeReadiness("u1", PROFILE, { now: NOW }); + expect(result.status).toBe("ok"); + if (result.status !== "ok") return; + const hrv = result.value.components.find((c) => c.key === "hrv"); + expect(hrv).toMatchObject({ value: null, weight: 0 }); + const present = result.value.components.filter((c) => c.value !== null); + expect(present.map((c) => c.key).sort()).toEqual(["respiratory", "rhr"]); + const total = present.reduce((sum, c) => sum + c.weight, 0); + expect(total).toBeCloseTo(1, 10); + expect(Number.isFinite(result.value.score)).toBe(true); + }); + it("reports confidence reflecting REAL history depth, not the constant window", async () => { // 10 distinct backing days on a 30-day window. Pre-fix `historyDays` was // pinned to `windowDays` so `historyFraction` was always 1 and confidence diff --git a/src/lib/insights/metric-availability.ts b/src/lib/insights/metric-availability.ts index e5ed659b1..2f761a6bd 100644 --- a/src/lib/insights/metric-availability.ts +++ b/src/lib/insights/metric-availability.ts @@ -62,9 +62,9 @@ export type InsightMetric = | "ACTIVE_ENERGY" // v1.4.32 — wave-A HealthKit metrics promoted to first-class sub-pages. | "HEART_RATE_VARIABILITY" - // v1.17.0 — RMSSD-flavoured HRV. Oura / Polar / WHOOP write nightly HRV - // as `HRV_RMSSD`; Apple Health / Fitbit write SDNN as - // `HEART_RATE_VARIABILITY`. The `hrv` sub-page gates on either so a + // v1.17.0 — RMSSD-flavoured HRV. Oura / Polar / WHOOP / Fitbit / Google + // Health write nightly HRV as `HRV_RMSSD`; Apple Health and Health Connect + // write SDNN as `HEART_RATE_VARIABILITY`. The `hrv` sub-page gates on either so a // ring / strap user's stored HRV lights the tab, pill, and dashboard. | "HRV_RMSSD" | "RESTING_HEART_RATE" @@ -182,8 +182,8 @@ export function hasMetricData( return (inputs.summaries?.WEIGHT?.count ?? 0) > 0; } if (metric === "HEART_RATE_VARIABILITY") { - // v1.17.0 — surface SDNN (`HEART_RATE_VARIABILITY`, Apple / Fitbit) - // OR RMSSD (`HRV_RMSSD`, Oura / Polar / WHOOP). Either stream lights + // v1.17.0 — surface SDNN (`HEART_RATE_VARIABILITY`, Apple Health) + // OR RMSSD (`HRV_RMSSD`, Oura / Polar / WHOOP / Fitbit / Google Health). Either stream lights // the HRV tab, pill, and dashboard tile. return ( (inputs.summaries?.HEART_RATE_VARIABILITY?.count ?? 0) > 0 || diff --git a/src/lib/insights/sub-page-metric.ts b/src/lib/insights/sub-page-metric.ts index cb9eca51c..c088eabd5 100644 --- a/src/lib/insights/sub-page-metric.ts +++ b/src/lib/insights/sub-page-metric.ts @@ -120,7 +120,8 @@ const SUB_PAGE_METRIC = { // ── cardiovascular ── "resting-pulse": ["RESTING_HEART_RATE"], // v1.17.0 — HRV surfaces both flavours: SDNN (`HEART_RATE_VARIABILITY`, - // Apple / Fitbit) and RMSSD (`HRV_RMSSD`, Oura / Polar / WHOOP). A + // Apple Health) and RMSSD (`HRV_RMSSD`, Oura / Polar / WHOOP / Fitbit / + // Google Health). A // ring / strap user stores only RMSSD; listing both keeps the tab, // pill, and dashboard tile lit whichever source the user has. hrv: ["HEART_RATE_VARIABILITY", "HRV_RMSSD"], diff --git a/src/lib/mcp/rich-reads.ts b/src/lib/mcp/rich-reads.ts index aa2160b46..e32f4ba55 100644 --- a/src/lib/mcp/rich-reads.ts +++ b/src/lib/mcp/rich-reads.ts @@ -157,9 +157,9 @@ export interface RichMetric { /** * v1.30.4 (G4/HRV union) — a secondary `MeasurementType` to read instead of * `measurementType` when the user has ZERO rows of the primary type. HRV is - * the only metric that carries one today: Apple Health / Fitbit write SDNN - * (`HEART_RATE_VARIABILITY`); Oura / Polar / WHOOP write nightly RMSSD - * (`HRV_RMSSD`) only. Mirrors the app's HRV sub-page fallback + * the only metric that carries one today: Apple Health writes SDNN + * (`HEART_RATE_VARIABILITY`); Oura / Polar / WHOOP / Fitbit / Google Health + * write nightly RMSSD (`HRV_RMSSD`) only. Mirrors the app's HRV sub-page fallback * (`HealthKitMetricPage`'s `fallbackMeasurementType` prop) so a ring/strap * -only account resolves over MCP the same data it sees in the app instead * of a false `{ present: false }`. diff --git a/src/lib/report-selection/__tests__/catalogue-guard.test.ts b/src/lib/report-selection/__tests__/catalogue-guard.test.ts index 952114f1f..6b270286d 100644 --- a/src/lib/report-selection/__tests__/catalogue-guard.test.ts +++ b/src/lib/report-selection/__tests__/catalogue-guard.test.ts @@ -30,6 +30,8 @@ import { SHARE_GROUPS, STRUCTURED_LEAF_IDS, leafLabelKey, + LEAF_MODULE, + MEASUREMENT_LEAF_GROUP, } from "../catalogue"; import { STANDARD_TEMPLATE_LEAVES, standardTemplateFor } from "../template"; @@ -192,3 +194,17 @@ describe("the standard-report action", () => { ); }); }); + +describe("heart-rate variability in the report", () => { + // Fitbit and Google Health file nightly HRV as RMSSD (migration 0420), so + // for those accounts RMSSD is the only HRV the report can show. It is its + // own cardio leaf beside SDNN, never folded into it, and like SDNN no + // module gates it: a report with Recovery off must not lose the account's + // only HRV series for the estimator its device reports. + it("files SDNN and RMSSD as separate, ungated cardio leaves", () => { + for (const type of ["HEART_RATE_VARIABILITY", "HRV_RMSSD"] as const) { + expect(MEASUREMENT_LEAF_GROUP[type]).toBe("cardio"); + expect(LEAF_MODULE[type]).toBeUndefined(); + } + }); +});