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(); + } + }); +});