From a165ff9bb6db39076f9484665ac62e85c30cd457 Mon Sep 17 00:00:00 2001 From: Antiheld86 <4201585+Antiheld86@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:38:46 +0000 Subject: [PATCH 1/7] Show the HRV tile for an account whose HRV is RMSSD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HRV lives under two measurement 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 bridge pushing a watch that reports it that way — write nightly RMSSD as `HRV_RMSSD`. Every HRV surface unions the two: `/insights/hrv` through `fallbackMeasurementType`, the Coach through `COACH_SOURCE_MEASUREMENT_TYPES.hrv`, the MCP reads through `withHrvFallback`. `sub-page-metric.ts` even says listing both is what keeps "the tab, pill, and dashboard tile lit whichever source the user has". The dashboard tile did not. It read SDNN alone, so an RMSSD-only account got a Settings → Dashboard toggle that turned on and rendered nothing, with no explanation, while the insights page charted the same readings — which is why it reads as a broken layout rather than a missing series. `pickHrvSummary` picks the series with the same rule `withHrvFallback` uses: the primary wins whenever it has any rows, the fallback only when it has none. Never a merge — the two share `ms` but measure different things, so blending them would invent a number no device reported. The caller gets the winning type back, because the freshness caption has to read the series on show and the label has to name the measure when it is not SDNN (`· RMSSD`, as the insights chart title already does). Second, less visible half: `HRV_RMSSD` is recovery-owned in `METRIC_STATUS_MODULE_OWNERS`, but the dashboard snapshot gates off `SUMMARY_TYPE_MODULE`, a different map, and that one did not list it. The omission was inert while the tile read SDNN alone; adding the fallback without it would hand a recovery-off account a tile built from recovery-owned rows — the exact hazard the owner map documents. The entry goes in beside the four sibling recovery types, so the gate stays server side and the client keeps no competing copy. --- CHANGELOG.md | 22 +++ .../dashboard-hrv-rmssd-fallback.test.ts | 167 ++++++++++++++++++ src/app/page-client.tsx | 28 ++- src/components/dashboard/dashboard-gates.ts | 53 ++++++ src/lib/dashboard/widget-modules.ts | 10 ++ 5 files changed, 277 insertions(+), 3 deletions(-) create mode 100644 src/app/__tests__/dashboard-hrv-rmssd-fallback.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index da9672701..62f88c460 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## [Unreleased] + +### Fixed + +- **The heart-rate-variability tile never appeared on the dashboard for + people whose HRV comes from a ring or a strap.** Turning the widget on + under Settings → Dashboard did nothing and said nothing, while the same + readings charted perfectly on the HRV page — which made it look as though + the layout itself was broken. HRV is stored two ways: Apple Health, Fitbit + and Google Health record SDNN, whereas Oura, Polar and WHOOP record nightly + RMSSD, as does anything pushed in through a bridge from a watch that + reports it that way. Every other place HRV appears already accepted both; + the dashboard tile looked only for SDNN, so an account that had only RMSSD + had nothing for the tile to show. It now uses whichever series you actually + have, and says "RMSSD" beside the name when that is the one on screen, so a + reading of 40 is not mistaken for a collapsed SDNN figure. An account with + both keeps seeing SDNN, and the two are never blended — they are different + measures that happen to share a unit. Turning the Recovery module off still + takes the RMSSD reading off the dashboard, which it should have done all + along: that series belongs to Recovery everywhere else in the app, and the + dashboard was the one place not checking. + ## [1.38.6] — 2026-09-03 Blood glucose can be shown in mmol/L, which it never could before, and the diff --git a/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.ts b/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.ts new file mode 100644 index 000000000..f9be40f77 --- /dev/null +++ b/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { pickHrvSummary } from "@/components/dashboard/dashboard-gates"; +import { + SUMMARY_TYPE_MODULE, + disabledSummaryTypes, +} from "@/lib/dashboard/widget-modules"; +import { moduleForMeasurementType } from "@/lib/modules/measurement-scope"; +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 + * `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 + * source the user has". + * + * The tile did not. It read `summaries.HEART_RATE_VARIABILITY` alone, so an + * RMSSD-only account got a Settings → Dashboard toggle that turned on and + * rendered nothing, with no message, while `/insights/hrv` charted the same + * readings. That asymmetry is what this file pins. + * + * Mutation check: return the fallback unconditionally and the "SDNN wins" + * cases go red; drop the fallback arm and the RMSSD cases go red; return the + * fallback's TYPE while returning the primary's summary and the freshness + * case goes red. + */ +function summary(count: number, latest: number): DataSummary { + return { + count, + latest, + min: latest, + max: latest, + mean: latest, + median: latest, + avg7: latest, + avg30: latest, + slope30: 0, + } as unknown as DataSummary; +} + +describe("pickHrvSummary", () => { + it("takes SDNN when the account has it", () => { + const picked = pickHrvSummary({ + HEART_RATE_VARIABILITY: summary(12, 48), + }); + expect(picked.type).toBe("HEART_RATE_VARIABILITY"); + expect(picked.summary?.latest).toBe(48); + }); + + it("falls back to RMSSD for a ring / strap account", () => { + // The regression itself: pre-fix this returned undefined, `hasHrv` was + // false, and the tile never rendered however the widget was toggled. + const picked = pickHrvSummary({ HRV_RMSSD: summary(30, 42) }); + expect(picked.type).toBe("HRV_RMSSD"); + expect(picked.summary?.latest).toBe(42); + }); + + it("prefers SDNN when the account carries both series", () => { + // The case a naive `sdnn ?? rmssd` written the wrong way round breaks. + // Both are stored in ms but they measure different things, so the tile + // shows one series, never a blend, and SDNN is the primary. + const picked = pickHrvSummary({ + HEART_RATE_VARIABILITY: summary(9, 55), + HRV_RMSSD: summary(30, 42), + }); + expect(picked.type).toBe("HEART_RATE_VARIABILITY"); + expect(picked.summary?.latest).toBe(55); + }); + + it("treats a present-but-empty SDNN summary as absent", () => { + // The slice can carry a zero-count summary for a type the account has no + // rows for. Keying off presence rather than `count` would pin the tile to + // an empty SDNN series and re-break the ring user. + const picked = pickHrvSummary({ + HEART_RATE_VARIABILITY: summary(0, 0), + HRV_RMSSD: summary(30, 42), + }); + expect(picked.type).toBe("HRV_RMSSD"); + expect(picked.summary?.count).toBe(30); + }); + + it("reports no series, on the primary type, when the account has neither", () => { + // The tile is hidden either way; reporting the fallback type here would + // make an account with no HRV at all look like a ring user to the label + // and the freshness lookup. + for (const input of [undefined, {}, { HRV_RMSSD: summary(0, 0) }]) { + const picked = pickHrvSummary(input); + expect((picked.summary?.count ?? 0) > 0).toBe(false); + expect(picked.type).toBe("HEART_RATE_VARIABILITY"); + } + }); + + it("does not gate on the recovery module itself", () => { + // The gate belongs server-side (see the sibling test below), so a second + // one here would be a competing source of truth. Scoped to the function + // body: the docblock above it legitimately explains where gating lives. + const src = readFileSync( + join(process.cwd(), "src/components/dashboard/dashboard-gates.ts"), + "utf8", + ); + const body = src.slice(src.indexOf("export function pickHrvSummary(")); + expect(body).not.toMatch(/modules|recovery|isModuleEnabled/i); + }); +}); + +describe("the RMSSD fallback cannot leak recovery-module data", () => { + it("drops HRV_RMSSD from the snapshot summaries when recovery is off", () => { + // 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. + 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. + expect(disabledSummaryTypes({ recovery: false })).not.toContain( + "HEART_RATE_VARIABILITY", + ); + // And nothing is dropped when the module is on. + expect(disabledSummaryTypes({ recovery: true }).size).toBe(0); + }); + + it("keeps the fallback aligned with the metric-status owner map", () => { + // The two maps answer different questions and are allowed to differ, but + // not for THIS type: a fallback that serves data the primary path refuses + // is exactly what `METRIC_STATUS_MODULE_OWNERS` documents as the hazard. + expect(moduleForMeasurementType("HRV_RMSSD")).toBe("recovery"); + expect(SUMMARY_TYPE_MODULE.HRV_RMSSD).toBe("recovery"); + }); +}); + +describe("the tile wiring follows the chosen series", () => { + const PAGE = readFileSync( + join(process.cwd(), "src/app/page-client.tsx"), + "utf8", + ); + + it("sources the HRV summary from the helper, not a bare SDNN lookup", () => { + expect(PAGE).toContain("pickHrvSummary("); + // The exact line the bug lived on. If it comes back, so does the bug. + expect(PAGE).not.toContain( + "const hrvSummary = data?.summaries?.HEART_RATE_VARIABILITY", + ); + }); + + it("reads freshness off the type actually shown", () => { + // A hardcoded SDNN lookup here captions a live RMSSD tile as stale. + expect(PAGE).toContain("staleDays={tileStaleDays(hrvType)}"); + expect(PAGE).not.toContain('tileStaleDays("HEART_RATE_VARIABILITY")'); + }); + + it("names the measure when the RMSSD series is the one on show", () => { + // Mirrors how `/insights/hrv` appends `· RMSSD` to its chart title, so a + // 20-60 ms RMSSD reading is not mistaken for a collapsed SDNN one. + expect(PAGE).toContain('hrvType === "HRV_RMSSD"'); + expect(PAGE).toContain("· RMSSD"); + }); +}); diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 408330c7e..72aecb9d6 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -173,6 +173,7 @@ import { resolveDashboardFirstPaintGate, resolveConfiguredTileCount, resolveChartRowPlaceholderCount, + pickHrvSummary, } from "@/components/dashboard/dashboard-gates"; /** @@ -490,7 +491,16 @@ export default function DashboardPageClient({ // the same summaries slice (`computeSummariesSlice` iterates the full // measurement-type enum), so no backend change was needed; the strip // tiles below self-gate on `count > 0`. - const hrvSummary = data?.summaries?.HEART_RATE_VARIABILITY; + // 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 + // 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 + // the freshness caption and the label below follow the series shown. + const { summary: hrvSummary, type: hrvType } = pickHrvSummary( + data?.summaries, + ); const spo2Summary = data?.summaries?.OXYGEN_SATURATION; const respRateSummary = data?.summaries?.RESPIRATORY_RATE; const wristTempSummary = data?.summaries?.WRIST_TEMPERATURE; @@ -1309,7 +1319,16 @@ export default function DashboardPageClient({ node: ( ), }); diff --git a/src/components/dashboard/dashboard-gates.ts b/src/components/dashboard/dashboard-gates.ts index af861d41d..32633ebbd 100644 --- a/src/components/dashboard/dashboard-gates.ts +++ b/src/components/dashboard/dashboard-gates.ts @@ -6,6 +6,7 @@ * live in one cohesive module. `page.tsx` re-exports them so the existing * `../page` test imports stay valid. */ +import type { DataSummary } from "@/lib/analytics/trends"; import type { DashboardLayout } from "@/lib/dashboard-layout"; /** @@ -129,3 +130,55 @@ export function resolveChartRowPlaceholderCount( } return count; } + +/** The two measurement types the HRV surface reads, primary first. */ +export type HrvSummaryType = "HEART_RATE_VARIABILITY" | "HRV_RMSSD"; + +/** + * 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 + * 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 + * `withHrvFallback` (`lib/mcp/rich-reads.ts`). The catalogue entry in + * `lib/insights/sub-page-metric.ts` even says listing both is what "keeps the + * tab, pill, and dashboard tile lit whichever source the user has" — but the + * tile read SDNN alone, so an RMSSD-only account got a Settings toggle that + * silently rendered nothing while the insights page charted the data. + * + * Same rule as `withHrvFallback`: the primary wins whenever it has ANY rows, + * and the fallback is taken only when it has none. Deliberately not a merge — + * SDNN and RMSSD share `ms` but measure different things, so blending them + * would invent a number no device reported. That is why the caller is handed + * the winning `type` back: the freshness caption has to read the series being + * shown, and the label has to name the measure when it is not SDNN. + * + * Module gating belongs upstream and must NOT be repeated here. `HRV_RMSSD` + * is a `recovery`-owned type, so `SUMMARY_TYPE_MODULE` carries it and + * `gateSummariesByModules()` strips it out of `tiles.summaries` before the + * client sees anything — a `recovery`-off account has no key to fall back to. + * That entry was added WITH this helper: while the tile read SDNN alone the + * omission was inert, and a client-side gate here would be a second source of + * truth that drifts from the server's. + */ +export function pickHrvSummary( + summaries: Record | undefined, +): { summary: DataSummary | null; type: HrvSummaryType } { + const primary = summaries?.HEART_RATE_VARIABILITY; + if ((primary?.count ?? 0) > 0) { + return { summary: primary ?? null, type: "HEART_RATE_VARIABILITY" }; + } + const fallback = summaries?.HRV_RMSSD; + if ((fallback?.count ?? 0) > 0) { + return { summary: fallback ?? null, type: "HRV_RMSSD" }; + } + // Neither has rows. Report the primary type so the caller's stale-days + // lookup and label stay on SDNN for the empty case — the tile is hidden + // either way, and reporting the fallback here would make an account with + // no HRV at all look like a ring user. + return { summary: primary ?? null, type: "HEART_RATE_VARIABILITY" }; +} diff --git a/src/lib/dashboard/widget-modules.ts b/src/lib/dashboard/widget-modules.ts index 793d33c91..375d28db5 100644 --- a/src/lib/dashboard/widget-modules.ts +++ b/src/lib/dashboard/widget-modules.ts @@ -68,6 +68,16 @@ export const SUMMARY_TYPE_MODULE: Partial> = { SIX_MINUTE_WALK_DISTANCE: "recovery", STAIR_ASCENT_SPEED: "recovery", STAIR_DESCENT_SPEED: "recovery", + // The HRV fallback series. `METRIC_STATUS_MODULE_OWNERS` already owns this + // type for the metric-status / MCP reads, for the reason stated there: a + // fallback path must not serve the recovery data the primary type refuses. + // The dashboard snapshot gates off THIS map instead, and the entry was + // missing — harmless only while the dashboard HRV tile read SDNN alone. + // Now that the tile falls back to RMSSD (`pickHrvSummary`), the omission + // would hand a recovery-off account a tile built from recovery-owned rows. + // `HEART_RATE_VARIABILITY` stays deliberately ungated: SDNN is a plain + // vital, and it is what the tile shows whenever the account has it. + HRV_RMSSD: "recovery", // Per-night breathing-disturbance index is a sleep-page signal. BREATHING_DISTURBANCES: "sleep", // v1.29 — fluid-intake dashboard tile summary, derived server-side from From 4594b664e3e23a8426aab93e4a5959524dd0d2a6 Mon Sep 17 00:00:00 2001 From: Antiheld86 <4201585+Antiheld86@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:26:10 +0000 Subject: [PATCH 2/7] Type the empty-input cases so tsc accepts the loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "neither series" test walks three inputs in one array literal. Inferred from the literals alone, the bare `{}` widens to `{ HRV_RMSSD?: undefined }`, and TypeScript rejects that against `pickHrvSummary`'s `Record | undefined` — an optional-undefined property is not assignable to the index signature. Vitest never typechecks, so the suite ran green while `tsc --noEmit` failed. Annotating the array pins the parameter type at the declaration and leaves all three cases exactly as they were. --- src/app/__tests__/dashboard-hrv-rmssd-fallback.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.ts b/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.ts index f9be40f77..99b2d519b 100644 --- a/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.ts +++ b/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.ts @@ -91,7 +91,14 @@ describe("pickHrvSummary", () => { // The tile is hidden either way; reporting the fallback type here would // make an account with no HRV at all look like a ring user to the label // and the freshness lookup. - for (const input of [undefined, {}, { HRV_RMSSD: summary(0, 0) }]) { + // Annotated: inferred from the literals alone, `{}` widens to + // `{ HRV_RMSSD?: undefined }`, which the index signature rejects. + const inputs: (Record | undefined)[] = [ + undefined, + {}, + { HRV_RMSSD: summary(0, 0) }, + ]; + for (const input of inputs) { const picked = pickHrvSummary(input); expect((picked.summary?.count ?? 0) > 0).toBe(false); expect(picked.type).toBe("HEART_RATE_VARIABILITY"); From 6aac2070b478f0194ad4e805b8998f3f8b677fe2 Mon Sep 17 00:00:00 2001 From: Antiheld86 <4201585+Antiheld86@users.noreply.github.com> Date: Thu, 3 Sep 2026 07:25:58 +0000 Subject: [PATCH 3/7] Address review: label key, gate every feed, retire the dead switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five points from the review, in the order they were raised. 1. The RMSSD marker did not survive. The tile label paints truncated, uppercase and on one line, so an appended `· RMSSD` was the first thing dropped in the longer locales — the part a reader needs to tell a 40 ms RMSSD night from a collapsed SDNN one. `measurements.typeHrvRmssd` already exists in all seven bundles, so each series now carries its own key. `pickHrvSummary` returns that key beside the series it chose, which keeps one mapping from series to label rather than a conditional at the call site. 2. The module gate bit on one path only. `gateSummariesByModules` was private to the snapshot builder, and with the snapshot flag off the tiles are fed by `/api/analytics`, which filtered nothing — so recovery-off plus RMSSD-only still painted the tile, on the exact path the helper's docblock promised it could not. The filter moves next to the map it reads, and both analytics slices apply it. Gated after the cache read, so the cached cell stays module-independent and toggling a module takes effect on the next request instead of waiting out the TTL. 3. The same class one step on: recovery off plus RMSSD only left a switch that was on, a tile that could never paint, and no explanation. The repo already calls that a dead switch and hides the row; HRV is only dead sometimes, so the answer needs one fact the Settings screen does not have. The server resolves it — `unavailableWidgetIds` on the widgets payload — and Settings drops those rows. An account with SDNN keeps the toggle: its tile still works with recovery off, because SDNN is a vital no module owns. The SDNN probe is an index-only existence check that runs only when recovery is off, outside the layout cache so the row goes the moment the module does. 4. The brittle test sliced from the helper to the end of the file, so any function added below it would have gone red pointing at HRV. Bounded to the function's own body, with a guard on the bound itself. The three wiring assertions matched formatted source text — they would have passed just as well if the tile never rendered — and are replaced by a test that renders the tile through a real TrendCard and asserts the reading is on screen. One negative source check is kept, naming the expression the bug lived on rather than the formatting of its replacement. 5. Rebased onto v1.38.6; the changelog entry sits under [Unreleased]. Not verified here: `pnpm db:generate` cannot run in this container (the Prisma engine download is refused), so snapshot.test.ts, the widgets route test and the analytics route test could not load, and typecheck is incomplete. Lint, format and the HRV suite (17 tests) pass. --- CHANGELOG.md | 18 +- .../dashboard-hrv-rmssd-fallback.test.ts | 174 ---------- .../dashboard-hrv-rmssd-fallback.test.tsx | 323 ++++++++++++++++++ src/app/api/analytics/route.ts | 41 ++- src/app/api/dashboard/widgets/route.ts | 33 +- src/app/page-client.tsx | 30 +- src/components/dashboard/dashboard-gates.ts | 33 +- .../settings/dashboard-layout-section.tsx | 15 +- src/lib/dashboard-layout.ts | 7 + src/lib/dashboard/snapshot.ts | 46 +-- src/lib/dashboard/widget-modules.ts | 66 ++++ 11 files changed, 551 insertions(+), 235 deletions(-) delete mode 100644 src/app/__tests__/dashboard-hrv-rmssd-fallback.test.ts create mode 100644 src/app/__tests__/dashboard-hrv-rmssd-fallback.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 62f88c460..b3d7d2792 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,13 +14,17 @@ reports it that way. Every other place HRV appears already accepted both; the dashboard tile looked only for SDNN, so an account that had only RMSSD had nothing for the tile to show. It now uses whichever series you actually - have, and says "RMSSD" beside the name when that is the one on screen, so a - reading of 40 is not mistaken for a collapsed SDNN figure. An account with - both keeps seeing SDNN, and the two are never blended — they are different - measures that happen to share a unit. Turning the Recovery module off still - takes the RMSSD reading off the dashboard, which it should have done all - along: that series belongs to Recovery everywhere else in the app, and the - dashboard was the one place not checking. + have, and the tile is titled "HRV (RMSSD)" when that is the one on screen, + so a reading of 40 is not mistaken for a collapsed SDNN figure. An account + with both keeps seeing SDNN, and the two are never blended — they are + different measures that happen to share a unit. +- Turning the Recovery module off now takes RMSSD off the dashboard on every + path, not just the default one. That series belongs to Recovery everywhere + else in the app, and the dashboard snapshot already dropped it — but the + analytics endpoint that feeds the same tiles when the snapshot is switched + off filtered nothing at all, so a disabled module still reached the client + there. Both feeds now strip the same set, from the same map, so the gate no + longer depends on which path served the page. ## [1.38.6] — 2026-09-03 diff --git a/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.ts b/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.ts deleted file mode 100644 index 99b2d519b..000000000 --- a/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - -import { pickHrvSummary } from "@/components/dashboard/dashboard-gates"; -import { - SUMMARY_TYPE_MODULE, - disabledSummaryTypes, -} from "@/lib/dashboard/widget-modules"; -import { moduleForMeasurementType } from "@/lib/modules/measurement-scope"; -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 - * `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 - * source the user has". - * - * The tile did not. It read `summaries.HEART_RATE_VARIABILITY` alone, so an - * RMSSD-only account got a Settings → Dashboard toggle that turned on and - * rendered nothing, with no message, while `/insights/hrv` charted the same - * readings. That asymmetry is what this file pins. - * - * Mutation check: return the fallback unconditionally and the "SDNN wins" - * cases go red; drop the fallback arm and the RMSSD cases go red; return the - * fallback's TYPE while returning the primary's summary and the freshness - * case goes red. - */ -function summary(count: number, latest: number): DataSummary { - return { - count, - latest, - min: latest, - max: latest, - mean: latest, - median: latest, - avg7: latest, - avg30: latest, - slope30: 0, - } as unknown as DataSummary; -} - -describe("pickHrvSummary", () => { - it("takes SDNN when the account has it", () => { - const picked = pickHrvSummary({ - HEART_RATE_VARIABILITY: summary(12, 48), - }); - expect(picked.type).toBe("HEART_RATE_VARIABILITY"); - expect(picked.summary?.latest).toBe(48); - }); - - it("falls back to RMSSD for a ring / strap account", () => { - // The regression itself: pre-fix this returned undefined, `hasHrv` was - // false, and the tile never rendered however the widget was toggled. - const picked = pickHrvSummary({ HRV_RMSSD: summary(30, 42) }); - expect(picked.type).toBe("HRV_RMSSD"); - expect(picked.summary?.latest).toBe(42); - }); - - it("prefers SDNN when the account carries both series", () => { - // The case a naive `sdnn ?? rmssd` written the wrong way round breaks. - // Both are stored in ms but they measure different things, so the tile - // shows one series, never a blend, and SDNN is the primary. - const picked = pickHrvSummary({ - HEART_RATE_VARIABILITY: summary(9, 55), - HRV_RMSSD: summary(30, 42), - }); - expect(picked.type).toBe("HEART_RATE_VARIABILITY"); - expect(picked.summary?.latest).toBe(55); - }); - - it("treats a present-but-empty SDNN summary as absent", () => { - // The slice can carry a zero-count summary for a type the account has no - // rows for. Keying off presence rather than `count` would pin the tile to - // an empty SDNN series and re-break the ring user. - const picked = pickHrvSummary({ - HEART_RATE_VARIABILITY: summary(0, 0), - HRV_RMSSD: summary(30, 42), - }); - expect(picked.type).toBe("HRV_RMSSD"); - expect(picked.summary?.count).toBe(30); - }); - - it("reports no series, on the primary type, when the account has neither", () => { - // The tile is hidden either way; reporting the fallback type here would - // make an account with no HRV at all look like a ring user to the label - // and the freshness lookup. - // Annotated: inferred from the literals alone, `{}` widens to - // `{ HRV_RMSSD?: undefined }`, which the index signature rejects. - const inputs: (Record | undefined)[] = [ - undefined, - {}, - { HRV_RMSSD: summary(0, 0) }, - ]; - for (const input of inputs) { - const picked = pickHrvSummary(input); - expect((picked.summary?.count ?? 0) > 0).toBe(false); - expect(picked.type).toBe("HEART_RATE_VARIABILITY"); - } - }); - - it("does not gate on the recovery module itself", () => { - // The gate belongs server-side (see the sibling test below), so a second - // one here would be a competing source of truth. Scoped to the function - // body: the docblock above it legitimately explains where gating lives. - const src = readFileSync( - join(process.cwd(), "src/components/dashboard/dashboard-gates.ts"), - "utf8", - ); - const body = src.slice(src.indexOf("export function pickHrvSummary(")); - expect(body).not.toMatch(/modules|recovery|isModuleEnabled/i); - }); -}); - -describe("the RMSSD fallback cannot leak recovery-module data", () => { - it("drops HRV_RMSSD from the snapshot summaries when recovery is off", () => { - // 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. - 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. - expect(disabledSummaryTypes({ recovery: false })).not.toContain( - "HEART_RATE_VARIABILITY", - ); - // And nothing is dropped when the module is on. - expect(disabledSummaryTypes({ recovery: true }).size).toBe(0); - }); - - it("keeps the fallback aligned with the metric-status owner map", () => { - // The two maps answer different questions and are allowed to differ, but - // not for THIS type: a fallback that serves data the primary path refuses - // is exactly what `METRIC_STATUS_MODULE_OWNERS` documents as the hazard. - expect(moduleForMeasurementType("HRV_RMSSD")).toBe("recovery"); - expect(SUMMARY_TYPE_MODULE.HRV_RMSSD).toBe("recovery"); - }); -}); - -describe("the tile wiring follows the chosen series", () => { - const PAGE = readFileSync( - join(process.cwd(), "src/app/page-client.tsx"), - "utf8", - ); - - it("sources the HRV summary from the helper, not a bare SDNN lookup", () => { - expect(PAGE).toContain("pickHrvSummary("); - // The exact line the bug lived on. If it comes back, so does the bug. - expect(PAGE).not.toContain( - "const hrvSummary = data?.summaries?.HEART_RATE_VARIABILITY", - ); - }); - - it("reads freshness off the type actually shown", () => { - // A hardcoded SDNN lookup here captions a live RMSSD tile as stale. - expect(PAGE).toContain("staleDays={tileStaleDays(hrvType)}"); - expect(PAGE).not.toContain('tileStaleDays("HEART_RATE_VARIABILITY")'); - }); - - it("names the measure when the RMSSD series is the one on show", () => { - // Mirrors how `/insights/hrv` appends `· RMSSD` to its chart title, so a - // 20-60 ms RMSSD reading is not mistaken for a collapsed SDNN one. - expect(PAGE).toContain('hrvType === "HRV_RMSSD"'); - expect(PAGE).toContain("· RMSSD"); - }); -}); diff --git a/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.tsx b/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.tsx new file mode 100644 index 000000000..0ab58efe5 --- /dev/null +++ b/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.tsx @@ -0,0 +1,323 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { renderToStaticMarkup } from "react-dom/server"; +import { HeartPulse } from "lucide-react"; + +import { + pickHrvSummary, + HRV_LABEL_KEY, +} from "@/components/dashboard/dashboard-gates"; +import { TrendCard } from "@/components/charts/trend-card"; +import { I18nProvider } from "@/lib/i18n/context"; +import { + SUMMARY_TYPE_MODULE, + disabledSummaryTypes, + gateSummariesByModules, + unavailableWidgetIds, +} from "@/lib/dashboard/widget-modules"; +import { moduleForMeasurementType } from "@/lib/modules/measurement-scope"; +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 + * `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 + * source the user has". + * + * The tile did not. It read `summaries.HEART_RATE_VARIABILITY` alone, so an + * RMSSD-only account got a Settings → Dashboard toggle that turned on and + * rendered nothing, with no message, while `/insights/hrv` charted the same + * readings. That asymmetry is what this file pins. + * + * Mutation check: return the fallback unconditionally and the "SDNN wins" + * cases go red; drop the fallback arm and the RMSSD cases go red; return the + * fallback's TYPE while returning the primary's summary and the freshness + * case goes red. + */ +function summary(count: number, latest: number): DataSummary { + return { + count, + latest, + min: latest, + max: latest, + mean: latest, + median: latest, + avg7: latest, + avg30: latest, + slope30: 0, + } as unknown as DataSummary; +} + +describe("pickHrvSummary", () => { + it("takes SDNN when the account has it", () => { + const picked = pickHrvSummary({ + HEART_RATE_VARIABILITY: summary(12, 48), + }); + expect(picked.type).toBe("HEART_RATE_VARIABILITY"); + expect(picked.summary?.latest).toBe(48); + }); + + it("falls back to RMSSD for a ring / strap account", () => { + // The regression itself: pre-fix this returned undefined, `hasHrv` was + // false, and the tile never rendered however the widget was toggled. + const picked = pickHrvSummary({ HRV_RMSSD: summary(30, 42) }); + expect(picked.type).toBe("HRV_RMSSD"); + expect(picked.summary?.latest).toBe(42); + }); + + it("prefers SDNN when the account carries both series", () => { + // The case a naive `sdnn ?? rmssd` written the wrong way round breaks. + // Both are stored in ms but they measure different things, so the tile + // shows one series, never a blend, and SDNN is the primary. + const picked = pickHrvSummary({ + HEART_RATE_VARIABILITY: summary(9, 55), + HRV_RMSSD: summary(30, 42), + }); + expect(picked.type).toBe("HEART_RATE_VARIABILITY"); + expect(picked.summary?.latest).toBe(55); + }); + + it("treats a present-but-empty SDNN summary as absent", () => { + // The slice can carry a zero-count summary for a type the account has no + // rows for. Keying off presence rather than `count` would pin the tile to + // an empty SDNN series and re-break the ring user. + const picked = pickHrvSummary({ + HEART_RATE_VARIABILITY: summary(0, 0), + HRV_RMSSD: summary(30, 42), + }); + expect(picked.type).toBe("HRV_RMSSD"); + expect(picked.summary?.count).toBe(30); + }); + + it("reports no series, on the primary type, when the account has neither", () => { + // The tile is hidden either way; reporting the fallback type here would + // make an account with no HRV at all look like a ring user to the label + // and the freshness lookup. + // Annotated: inferred from the literals alone, `{}` widens to + // `{ HRV_RMSSD?: undefined }`, which the index signature rejects. + const inputs: (Record | undefined)[] = [ + undefined, + {}, + { HRV_RMSSD: summary(0, 0) }, + ]; + for (const input of inputs) { + const picked = pickHrvSummary(input); + expect((picked.summary?.count ?? 0) > 0).toBe(false); + expect(picked.type).toBe("HEART_RATE_VARIABILITY"); + } + }); + + it("does not gate on the recovery module itself", () => { + // The gate belongs server-side (see the sibling describe below), so a + // second one here would be a competing source of truth. + // + // Bounded to the function's OWN body — start of the declaration to its + // closing brace in column 0. Slicing to the end of the file instead, as + // this first did, quietly turns every later addition to the module into + // a failure of this test: the next helper that legitimately mentions a + // module would go red here for no reason, pointing at HRV. + const src = readFileSync( + join(process.cwd(), "src/components/dashboard/dashboard-gates.ts"), + "utf8", + ); + const start = src.indexOf("export function pickHrvSummary("); + expect(start).toBeGreaterThan(-1); + const end = src.indexOf("\n}\n", start); + expect(end).toBeGreaterThan(start); + const body = src.slice(start, end); + // Guard the bound itself: a body that swallowed the rest of the file + // would pass the assertion below for the wrong reason. + expect(body).not.toContain("export function resolve"); + expect(body).not.toMatch(/modules|recovery|isModuleEnabled/i); + }); +}); + +describe("the RMSSD fallback cannot leak recovery-module data", () => { + it("drops HRV_RMSSD from the snapshot summaries when recovery is off", () => { + // 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. + 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. + expect(disabledSummaryTypes({ recovery: false })).not.toContain( + "HEART_RATE_VARIABILITY", + ); + // And nothing is dropped when the module is on. + expect(disabledSummaryTypes({ recovery: true }).size).toBe(0); + }); + + it("keeps the fallback aligned with the metric-status owner map", () => { + // The two maps answer different questions and are allowed to differ, but + // not for THIS type: a fallback that serves data the primary path refuses + // is exactly what `METRIC_STATUS_MODULE_OWNERS` documents as the hazard. + expect(moduleForMeasurementType("HRV_RMSSD")).toBe("recovery"); + expect(SUMMARY_TYPE_MODULE.HRV_RMSSD).toBe("recovery"); + }); +}); + +describe("the tile the user actually sees", () => { + // The point of the fix is a tile that PAINTS for a ring / strap account, + // so the test renders one. The three assertions this replaces matched + // formatted source text in `page-client.tsx` — they passed on a string + // that looked right and would have passed just as well if the tile never + // rendered at all, and any reformatting broke them. + // + // `page-client.tsx` itself cannot be mounted in a unit test (it is the + // whole dashboard: auth, a dozen queries, layout). What CAN be rendered + // is the seam the bug lived on — the summaries slice going in, the tile's + // props coming out — so the helper's real return feeds a real TrendCard. + function renderHrvTile(summaries: Record | undefined) { + const { summary, labelKey } = pickHrvSummary(summaries); + return renderToStaticMarkup( + + + , + ); + } + + it("paints the RMSSD reading for a ring / strap account", () => { + const html = renderHrvTile({ HRV_RMSSD: summary(30, 42) }); + // The regression itself: pre-fix this tile had no series and never + // rendered. The reading has to be ON SCREEN, not merely selected. + expect(html).toContain("42"); + expect(html).toContain("ms"); + }); + + it("names the measure with the RMSSD key, not a concatenated marker", () => { + // `HRV_LABEL_KEY` is the single mapping from series to label key, and + // the tile takes whichever the helper chose. One key per series is what + // survives truncation in the longer locales. + expect(pickHrvSummary({ HRV_RMSSD: summary(30, 42) }).labelKey).toBe( + HRV_LABEL_KEY.HRV_RMSSD, + ); + expect( + pickHrvSummary({ HEART_RATE_VARIABILITY: summary(9, 55) }).labelKey, + ).toBe(HRV_LABEL_KEY.HEART_RATE_VARIABILITY); + // The two series never share a label — that is the whole point of + // naming the measure. + expect(HRV_LABEL_KEY.HRV_RMSSD).not.toBe( + HRV_LABEL_KEY.HEART_RATE_VARIABILITY, + ); + }); + + it("carries the chosen label all the way into the painted markup", () => { + expect(renderHrvTile({ HRV_RMSSD: summary(30, 42) })).toContain( + `__${HRV_LABEL_KEY.HRV_RMSSD}__`, + ); + expect(renderHrvTile({ HEART_RATE_VARIABILITY: summary(9, 55) })).toContain( + `__${HRV_LABEL_KEY.HEART_RATE_VARIABILITY}__`, + ); + }); + + it("names both HRV label keys in every locale bundle", () => { + // A key that resolves in `en` and nowhere else paints the raw key + // string to everyone else. Both series' keys must exist in all seven. + const locales = ["de", "en", "es", "fr", "it", "ko", "pl"]; + for (const locale of locales) { + const bundle = JSON.parse( + readFileSync(join(process.cwd(), `messages/${locale}.json`), "utf8"), + ) as { measurements?: Record }; + for (const key of Object.values(HRV_LABEL_KEY)) { + const leaf = key.replace("measurements.", ""); + expect(bundle.measurements?.[leaf], `${locale}: ${key}`).toBeTruthy(); + } + } + }); + + it("does not read freshness off a hardcoded SDNN type", () => { + // The one source-level check kept, and deliberately a NEGATIVE one: it + // names the exact expression the bug lived on rather than asserting the + // current formatting of the replacement, so reformatting cannot break + // it. Freshness is per-type and captioning a live RMSSD tile from the + // SDNN lookup is the failure it guards. + const page = readFileSync( + join(process.cwd(), "src/app/page-client.tsx"), + "utf8", + ); + expect(page).not.toContain('tileStaleDays("HEART_RATE_VARIABILITY")'); + expect(page).not.toContain( + "const hrvSummary = data?.summaries?.HEART_RATE_VARIABILITY", + ); + }); +}); + +describe("the gate reaches every feed, not just the default one", () => { + it("strips the recovery-owned series from a summaries slice", () => { + // `gateSummariesByModules` moved out of the snapshot builder, where it + // was private, so `/api/analytics` can apply the SAME filter. With + // `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` that route feeds the tiles, and + // it filtered nothing at all — so recovery-off plus RMSSD-only still + // painted the tile, on the exact path the helper's docblock promised it + // could not. A gate that only holds on one feed is only as strong as a + // rollout flag. + const summaries = { + HEART_RATE_VARIABILITY: summary(9, 55), + HRV_RMSSD: summary(30, 42), + }; + const lastSeen = { + HEART_RATE_VARIABILITY: { lastSeenAt: "2026-09-01T00:00:00.000Z" }, + HRV_RMSSD: { lastSeenAt: "2026-09-02T00:00:00.000Z" }, + }; + const off = gateSummariesByModules(summaries, lastSeen, { + recovery: false, + }); + expect(off.summaries.HRV_RMSSD).toBeUndefined(); + expect(off.lastSeenByType.HRV_RMSSD).toBeUndefined(); + // SDNN is nobody's module and must survive. + expect(off.summaries.HEART_RATE_VARIABILITY).toBeDefined(); + // Recovery on: nothing is touched, and the inputs are never mutated. + const on = gateSummariesByModules(summaries, lastSeen, { recovery: true }); + expect(on.summaries.HRV_RMSSD).toBeDefined(); + expect(summaries.HRV_RMSSD).toBeDefined(); + }); +}); + +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", () => { + expect( + unavailableWidgetIds({ recovery: false }, { hasSdnn: 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. + expect( + unavailableWidgetIds({ recovery: false }, { hasSdnn: true }), + ).toEqual([]); + }); + + it("keeps the toggle whenever recovery is on", () => { + expect( + unavailableWidgetIds({ recovery: true }, { hasSdnn: 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([]); + }); +}); diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 4af449575..351181531 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -22,7 +22,8 @@ import { type BpInTargetEnvelope, } from "@/lib/analytics/bp-in-target-fast-path"; import { computeAndRecordUserHealthScore } from "@/lib/analytics/score/record"; -import { isModuleEnabled } from "@/lib/modules/gate"; +import { isModuleEnabled, resolveModuleMap } from "@/lib/modules/gate"; +import { gateSummariesByModules } from "@/lib/dashboard/widget-modules"; import { resolveRestMode } from "@/lib/illness/rest-mode"; import { deriveBpWindow90 } from "@/lib/analytics/window-confidence"; import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path"; @@ -109,10 +110,32 @@ export const GET = apiHandler(async (request?: Request) => { () => computeSummariesSlice(user.id), annotate, ); + // Drop the types whose module the user turned off, from the SAME map + // the snapshot builder gates on (`SUMMARY_TYPE_MODULE`, via + // `gateSummariesByModules`). With `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` + // this slice — not the snapshot — feeds the dashboard tile strip, and + // it used to hand back every type regardless: recovery off plus an + // RMSSD-only account still painted the HRV tile, because nothing on + // this path had ever filtered. + // + // Gated AFTER the cache read on purpose. The cached cell stays + // module-independent, so toggling a module takes effect on the next + // request instead of waiting out the TTL, and two users' module states + // can never share a cached answer. + const slimModules = await resolveModuleMap(user.id); + const slimGated = gateSummariesByModules( + slim.summaries, + slim.lastSeenByType, + slimModules, + ); // v1.4.34 IW-B — bfcache-friendly directive on the slim slice too // so a back-forward navigation that landed on the dashboard tile // strip can restore from memory instead of paying a full reload. - const slimRes = apiSuccess(slim); + const slimRes = apiSuccess({ + ...slim, + summaries: slimGated.summaries, + lastSeenByType: slimGated.lastSeenByType, + }); slimRes.headers.set("Cache-Control", NO_STORE_BUT_BFCACHE); return slimRes; } @@ -269,8 +292,18 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) { // pre-fix behaviour for tenants whose year-ago window happened to // fall outside the 425-day floor. const slim = await computeSummariesSlice(user.id); - const results = slim.summaries; - const lastSeenByType = slim.lastSeenByType; + // Same module gate as the slim slice and the snapshot builder — the + // default slice feeds the dashboard too (it is the thick half of the + // legacy two-cell path), so it cannot be the one feed that leaks a + // disabled module's types. + const thickModules = await resolveModuleMap(user.id); + const thickGated = gateSummariesByModules( + slim.summaries, + slim.lastSeenByType, + thickModules, + ); + const results = thickGated.summaries; + const lastSeenByType = thickGated.lastSeenByType; // `computeSummariesSlice` annotates `action: "analytics.get.slim"` // for telemetry on the slim route. Restore the default-slice action diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 7cd00455e..e1fe467d9 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -14,6 +14,8 @@ import { sanitiseZodIssues, } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; +import { resolveModuleMap } from "@/lib/modules/gate"; +import { unavailableWidgetIds } from "@/lib/dashboard/widget-modules"; import { prisma, toJson } from "@/lib/db"; import { takeBaseToken, @@ -218,7 +220,36 @@ export const GET = apiHandler(async () => { () => buildDashboardLayout(user.id), annotate, ); - return apiSuccess(layout); + + // Which toggles cannot do anything for this account. Resolved here, on + // the server, so the Settings screen never has to fetch dashboard data + // to find out whether a switch it is about to draw is dead. + // + // Deliberately OUTSIDE the cached layout above: the layout cell is keyed + // on the user and holds their arrangement, which does not change when a + // module is toggled. Folding a module-dependent answer into it would + // serve a stale one for the rest of the 5-minute TTL — the switch would + // linger for minutes after Recovery went off, which is the confusion this + // is removing. + // + // The SDNN probe is an index-only existence check + // (`@@index([userId, type, measuredAt])`), not a summaries read; it runs + // only when Recovery is off, so the common account pays nothing. + const modules = await resolveModuleMap(user.id); + let hasSdnn = true; + if (modules.recovery === false) { + const sdnnRow = await prisma.measurement.findFirst({ + where: { userId: user.id, type: "HEART_RATE_VARIABILITY" }, + select: { id: true }, + }); + hasSdnn = sdnnRow !== null; + } + const unavailable = unavailableWidgetIds(modules, { hasSdnn }); + return apiSuccess( + unavailable.length > 0 + ? { ...layout, unavailableWidgetIds: unavailable } + : layout, + ); }); export const PUT = apiHandler(async (request: NextRequest) => { diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 72aecb9d6..182c42526 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -498,9 +498,11 @@ export default function DashboardPageClient({ // rendered nothing while `/insights/hrv` charted the same data. The // helper keeps the union in one place and hands back which type won, so // the freshness caption and the label below follow the series shown. - const { summary: hrvSummary, type: hrvType } = pickHrvSummary( - data?.summaries, - ); + const { + summary: hrvSummary, + type: hrvType, + labelKey: hrvLabelKey, + } = pickHrvSummary(data?.summaries); const spo2Summary = data?.summaries?.OXYGEN_SATURATION; const respRateSummary = data?.summaries?.RESPIRATORY_RATE; const wristTempSummary = data?.summaries?.WRIST_TEMPERATURE; @@ -1319,16 +1321,18 @@ export default function DashboardPageClient({ node: ( = { + HEART_RATE_VARIABILITY: "measurements.typeHeartRateVariability", + HRV_RMSSD: "measurements.typeHrvRmssd", +}; + /** * Which HRV series backs the dashboard tile. * @@ -167,18 +180,30 @@ export type HrvSummaryType = "HEART_RATE_VARIABILITY" | "HRV_RMSSD"; */ export function pickHrvSummary( summaries: Record | undefined, -): { summary: DataSummary | null; type: HrvSummaryType } { +): { summary: DataSummary | null; type: HrvSummaryType; labelKey: string } { + const pick = ( + summary: DataSummary | null, + type: HrvSummaryType, + ): { + summary: DataSummary | null; + type: HrvSummaryType; + labelKey: string; + } => ({ + summary, + type, + labelKey: HRV_LABEL_KEY[type], + }); const primary = summaries?.HEART_RATE_VARIABILITY; if ((primary?.count ?? 0) > 0) { - return { summary: primary ?? null, type: "HEART_RATE_VARIABILITY" }; + return pick(primary ?? null, "HEART_RATE_VARIABILITY"); } const fallback = summaries?.HRV_RMSSD; if ((fallback?.count ?? 0) > 0) { - return { summary: fallback ?? null, type: "HRV_RMSSD" }; + return pick(fallback ?? null, "HRV_RMSSD"); } // Neither has rows. Report the primary type so the caller's stale-days // lookup and label stay on SDNN for the empty case — the tile is hidden // either way, and reporting the fallback here would make an account with // no HRV at all look like a ring user. - return { summary: primary ?? null, type: "HEART_RATE_VARIABILITY" }; + return pick(primary ?? null, "HEART_RATE_VARIABILITY"); } diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 0ee6013a2..ef9549330 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -1,7 +1,7 @@ "use client"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useId, useState } from "react"; +import { useId, useMemo, useState } from "react"; import { toast } from "sonner"; import { LayoutDashboard, @@ -258,6 +258,17 @@ export function DashboardLayoutSection({ id }: { id: string }) { refetchOnMount: "always", }); + // Toggles the SERVER resolved as unavailable for this account — a switch + // that is on but can never paint a tile is exactly the confusion this + // screen must not create. Decided server-side (see `unavailableWidgetIds`) + // so this screen needs no dashboard data of its own. Absent field, or a + // query still in flight, means nothing is dead: the row shows, which is + // the fail-open direction the module gate below also takes. + const unavailableIds = useMemo( + () => new Set(remote?.unavailableWidgetIds ?? []), + [remote?.unavailableWidgetIds], + ); + /** * v1.32.16 (issue #581) — the freshest optimistic-concurrency token the * client knows. Read straight from the widgets query cache at mutate time @@ -602,6 +613,7 @@ export function DashboardLayoutSection({ id }: { id: string }) { const moduleKey = WIDGET_MODULE_BY_ID[w.id]; return !moduleKey || modules?.[moduleKey] !== false; }) + .filter((w) => !unavailableIds.has(w.id)) .sort((a, b) => a.order - b.order); const sortedIds = sortedWidgets.map((w) => w.id); return ( @@ -677,6 +689,7 @@ export function DashboardLayoutSection({ id }: { id: string }) { const moduleKey = WIDGET_MODULE_BY_ID[w.id]; return !moduleKey || modules?.[moduleKey] !== false; }) + .filter((w) => !unavailableIds.has(w.id)) .sort((a, b) => a.order - b.order); if (nativeWidgets.length === 0) return null; return ( diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 9bd51187f..36564944b 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -709,6 +709,13 @@ export function mergePreservedLayoutFields( */ export type DashboardLayoutWithToken = DashboardLayout & { updatedAt?: string; + /** + * Widget ids the server resolved as unavailable for this account — a + * toggle that cannot do anything, which Settings must not offer. See + * `unavailableWidgetIds`. Optional and additive: a client that ignores + * it behaves exactly as before. + */ + unavailableWidgetIds?: string[]; }; const DASHBOARD_LAYOUT_VERSION = 1; diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index ea56aa949..9e403895d 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -130,7 +130,8 @@ export { } from "@/lib/dashboard/widget-modules"; import { WIDGET_MODULE_BY_ID, - disabledSummaryTypes, + gateSummariesByModules, + unavailableWidgetIds, } from "@/lib/dashboard/widget-modules"; const GLUCOSE_CONTEXTS = [ @@ -1078,7 +1079,16 @@ function isThickPhaseWarm(coverage: RollupCoverageMap): boolean { function gateLayoutByModules( layout: DashboardLayout, modules: Record, + summaries: Record, ): DashboardLayout { + // Widgets that cannot paint for THIS account, on the same rule the + // Settings screen hides their toggle with — so the layout the dashboard + // renders and the switches the user is offered never disagree. + const unavailable = new Set( + unavailableWidgetIds(modules, { + hasSdnn: (summaries.HEART_RATE_VARIABILITY?.count ?? 0) > 0, + }), + ); return { ...layout, widgets: layout.widgets.map((w) => { @@ -1086,40 +1096,14 @@ function gateLayoutByModules( if (moduleKey && modules[moduleKey] === false) { return { ...w, visible: false, tileVisible: false }; } + if (unavailable.has(w.id)) { + return { ...w, visible: false, tileVisible: false }; + } return w; }), }; } -/** - * v1.18.0 — strip disabled-module measurement types from the slim slice - * so neither `tiles.summaries` / `tiles.lastSeenByType` nor the derived - * `metricStates` carry data for a module the user turned off. Returns a - * shallow copy; the inputs are not mutated. - */ -function gateSummariesByModules( - summaries: Record, - lastSeenByType: Record, - modules: Record, -): { - summaries: Record; - lastSeenByType: Record; -} { - // The drop decision is shared with `GET /api/dashboard/summary` — see - // `disabledSummaryTypes`. Only the filtering below is snapshot-shaped. - const dropped = disabledSummaryTypes(modules); - if (dropped.size === 0) return { summaries, lastSeenByType }; - const outSummaries: Record = {}; - for (const [type, summary] of Object.entries(summaries)) { - if (!dropped.has(type)) outSummaries[type] = summary; - } - const outLastSeen: Record = {}; - for (const [type, slot] of Object.entries(lastSeenByType)) { - if (!dropped.has(type)) outLastSeen[type] = slot; - } - return { summaries: outSummaries, lastSeenByType: outLastSeen }; -} - /** * v1.21.2 (A4) — map the machine-derived coach memory block onto the briefing * card's `{ recall, forward }` shape, both already-localised. `recall` is the @@ -1372,7 +1356,7 @@ export async function buildDashboardSnapshot( }); } - const layout = gateLayoutByModules(storedLayout, modules); + const layout = gateLayoutByModules(storedLayout, modules, slim.summaries); // v1.18.0 — the Daily Briefing is the dashboard's AI-narrative surface, // so the `insights` module gates it alongside the operator briefing // flag + per-user coach opt-out. Disabling `insights` yields the same diff --git a/src/lib/dashboard/widget-modules.ts b/src/lib/dashboard/widget-modules.ts index 375d28db5..1d2c39b44 100644 --- a/src/lib/dashboard/widget-modules.ts +++ b/src/lib/dashboard/widget-modules.ts @@ -113,6 +113,72 @@ export function disabledSummaryTypes( return dropped; } +/** + * Strip disabled-module types from a summaries slice. Lifted out of + * `@/lib/dashboard/snapshot`, where it was private, once a second + * dashboard feed needed it: with `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` + * the tile strip hydrates from `/api/analytics` instead of the snapshot, + * and that route filtered nothing — so a module the user had turned off + * still reached the client, and the tile it owns still painted. The gate + * has to sit on every feed that carries summaries, not just the default + * one, or it is only as strong as a rollout flag. + * + * Pure over its inputs and browser-safe (string keys + the map above), so + * the snapshot builder, the analytics route and any future feed share one + * implementation rather than each carrying a copy that can drift. + * Returns shallow copies; the inputs are not mutated. + */ +/** + * Widget ids whose toggle cannot do anything for THIS account, and so must + * not be offered at all. `WIDGET_MODULE_BY_ID` above answers the same + * question for widgets a module owns outright; this answers it for the one + * widget whose deadness also depends on the account's data. + * + * `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. + * + * 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 }, +): string[] { + const out: string[] = []; + if (modules.recovery === false && !facts.hasSdnn) out.push("hrv"); + return out; +} + +export function gateSummariesByModules< + S extends Record, + L extends Record, +>( + summaries: S, + lastSeenByType: L, + modules: Partial>, +): { summaries: S; lastSeenByType: L } { + const dropped = disabledSummaryTypes(modules); + if (dropped.size === 0) return { summaries, lastSeenByType }; + const outSummaries = {} as S; + for (const [type, summary] of Object.entries(summaries)) { + if (!dropped.has(type)) + (outSummaries as Record)[type] = summary; + } + const outLastSeen = {} as L; + for (const [type, slot] of Object.entries(lastSeenByType)) { + if (!dropped.has(type)) + (outLastSeen as Record)[type] = slot; + } + return { summaries: outSummaries, lastSeenByType: outLastSeen }; +} + /** * iOS `MetricKind` (the `kind` on a `GET /api/dashboard/summary` metric * card) → the `MeasurementType` it is built from. The summary route emits From 263c61da1654159b2af99e7d32c3ce3c83d96340 Mon Sep 17 00:00:00 2001 From: Antiheld86 <4201585+Antiheld86@users.noreply.github.com> Date: Thu, 3 Sep 2026 07:42:00 +0000 Subject: [PATCH 4/7] Mock the two new route dependencies, and cover the dead switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both routes 500'd in their unit tests because the change gave them dependencies the mocks did not know about: the widgets GET reads a module flag and probes for an SDNN row, and the analytics slim slice resolves the module map. Neither test mocks `@/lib/modules/gate`, and the widgets test's prisma double has no `measurement` model — so both calls threw and the handler answered 500. Real breakage from this branch, not stale expectations. The widgets GET no longer resolves the whole module map for one answer. `hrv` is the only conditionally-dead widget and `recovery` the only module that decides it, so it reads that one flag through `isModuleEnabled`, the way this route's neighbours read a single flag. Both gate mocks spread the real module so only the one call is stubbed; everything else that imports it keeps live behaviour. The widgets `beforeEach` states the modules-on default explicitly, because `resetAllMocks` strips a factory's implementation and every test below assumes it. Added coverage for the behaviour itself rather than only unbreaking the suite: recovery off with no SDNN marks `hrv` unavailable, an account WITH SDNN keeps the toggle, and recovery on does not run the probe at all — the one added read on a hot cached route must not touch the common account. --- src/__tests__/api/analytics/since-cap.test.ts | 13 ++++ .../dashboard/widgets/__tests__/route.test.ts | 72 +++++++++++++++++++ src/app/api/dashboard/widgets/route.ts | 14 ++-- 3 files changed, 95 insertions(+), 4 deletions(-) diff --git a/src/__tests__/api/analytics/since-cap.test.ts b/src/__tests__/api/analytics/since-cap.test.ts index d9f481d70..221f1342e 100644 --- a/src/__tests__/api/analytics/since-cap.test.ts +++ b/src/__tests__/api/analytics/since-cap.test.ts @@ -50,6 +50,19 @@ vi.mock("@/lib/db-compat", () => ({ vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +// The slim slice now strips disabled-module types before answering, from +// the same map the snapshot builder gates on — so it resolves the module +// map. Spread the real module so only that call is stubbed. +vi.mock("@/lib/modules/gate", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveModuleMap: vi.fn(async () => + Object.fromEntries(actual.MODULE_KEYS.map((k) => [k, true])), + ), + }; +}); + vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: () => null })), cookies: vi.fn(async () => ({ diff --git a/src/app/api/dashboard/widgets/__tests__/route.test.ts b/src/app/api/dashboard/widgets/__tests__/route.test.ts index 03fcb3375..177950e2f 100644 --- a/src/app/api/dashboard/widgets/__tests__/route.test.ts +++ b/src/app/api/dashboard/widgets/__tests__/route.test.ts @@ -27,11 +27,24 @@ vi.mock("@/lib/db", () => ({ auditLog: { create: vi.fn(), }, + // The GET probes for an SDNN row to decide whether the `hrv` toggle is + // a dead switch for this account. Only reached when recovery is off. + measurement: { + findFirst: vi.fn(), + }, $transaction: vi.fn(), }, toJson: (v: unknown) => v, })); +// The GET reads one module flag to decide whether a toggle can do anything. +// Spread the real module so only that one call is stubbed — every other +// export stays live for whatever else pulls this in. +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + isModuleEnabled: vi.fn(async () => true), +})); + vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn(), })); @@ -66,6 +79,7 @@ import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { annotate } from "@/lib/logging/context"; import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; +import { isModuleEnabled } from "@/lib/modules/gate"; import { DASHBOARD_WIDGET_IDS, DASHBOARD_IOS_ONLY_WIDGET_IDS, @@ -104,6 +118,10 @@ beforeEach(() => { __resetAuditDedupMemoForTests(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + // `resetAllMocks` strips the factory's implementation, so state the + // default here beside the others: modules on, which is the shape every + // test below the dead-switch block assumes. + vi.mocked(isModuleEnabled).mockResolvedValue(true as never); vi.mocked(prisma.$transaction).mockImplementation((async ( callback: (tx: { user: typeof transactionUser }) => Promise, ) => callback({ user: transactionUser })) as never); @@ -1057,3 +1075,57 @@ describe("dashboard widgets — hero primary content", () => { expect(body.data.hero).toBeUndefined(); }); }); + +describe("a toggle that cannot do anything is not offered", () => { + // A switch that is on over a tile that can never paint, with nothing + // saying why, is the defect the HRV fallback exists to remove — so the + // GET tells the client which toggles are dead rather than leaving the + // Settings screen to draw one. + // + // `hrv` falls back to nightly RMSSD when an account has no SDNN, and + // RMSSD is recovery-owned. Recovery off + no SDNN = a dead switch. + function storeLayout() { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dashboardWidgetsJson: serializeDashboardLayout(DEFAULT_DASHBOARD_LAYOUT), + } as never); + } + + async function getBody() { + const res = await callGet(); + expect(res.status).toBe(200); + return (await res.json()) as { + data: { unavailableWidgetIds?: string[] }; + }; + } + + it("marks hrv unavailable for a ring account with recovery off", async () => { + storeLayout(); + vi.mocked(isModuleEnabled).mockResolvedValue(false); + vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null as never); + + expect((await getBody()).data.unavailableWidgetIds).toEqual(["hrv"]); + }); + + it("leaves hrv available when the account has SDNN", async () => { + // SDNN is a plain vital no module owns, so the tile still works with + // recovery off. Taking this row away would be a new bug. + storeLayout(); + vi.mocked(isModuleEnabled).mockResolvedValue(false); + vi.mocked(prisma.measurement.findFirst).mockResolvedValue({ + id: "m1", + } as never); + + expect((await getBody()).data.unavailableWidgetIds).toBeUndefined(); + }); + + it("does not probe for SDNN at all when recovery is on", async () => { + // The probe is the only added read on this hot, cached route; it must + // not run for the common account. + storeLayout(); + vi.mocked(isModuleEnabled).mockResolvedValue(true); + vi.mocked(prisma.measurement.findFirst).mockClear(); + + expect((await getBody()).data.unavailableWidgetIds).toBeUndefined(); + expect(prisma.measurement.findFirst).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index e1fe467d9..13a1c37a7 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -14,7 +14,7 @@ import { sanitiseZodIssues, } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; -import { resolveModuleMap } from "@/lib/modules/gate"; +import { isModuleEnabled } from "@/lib/modules/gate"; import { unavailableWidgetIds } from "@/lib/dashboard/widget-modules"; import { prisma, toJson } from "@/lib/db"; import { @@ -235,16 +235,22 @@ export const GET = apiHandler(async () => { // The SDNN probe is an index-only existence check // (`@@index([userId, type, measuredAt])`), not a summaries read; it runs // only when Recovery is off, so the common account pays nothing. - const modules = await resolveModuleMap(user.id); + // One flag, read the way this codebase reads one flag — not the whole + // module map. `hrv` is the only conditionally-dead widget and `recovery` + // is the only module that decides it. + const recoveryEnabled = await isModuleEnabled(user.id, "recovery"); let hasSdnn = true; - if (modules.recovery === false) { + if (!recoveryEnabled) { const sdnnRow = await prisma.measurement.findFirst({ where: { userId: user.id, type: "HEART_RATE_VARIABILITY" }, select: { id: true }, }); hasSdnn = sdnnRow !== null; } - const unavailable = unavailableWidgetIds(modules, { hasSdnn }); + const unavailable = unavailableWidgetIds( + { recovery: recoveryEnabled }, + { hasSdnn }, + ); return apiSuccess( unavailable.length > 0 ? { ...layout, unavailableWidgetIds: unavailable } From 45fe97e07b7f879f7e4ef09c9e0097c76cafdac8 Mon Sep 17 00:00:00 2001 From: Antiheld86 <4201585+Antiheld86@users.noreply.github.com> Date: Thu, 3 Sep 2026 07:57:46 +0000 Subject: [PATCH 5/7] Declare unavailableWidgetIds in the wire contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The widgets GET grew a response field and the spec was never told. Nothing would have caught it: `openapi:check` compares the generated yaml against the Zod registry, and an undeclared field changes neither — so CI stays green while the published contract quietly stops describing what the route returns. iOS decodes this payload. `dashboardLayoutResult` is shared by the GET and the PUT echo, but the field is resolved per request from the caller's modules and their data, so it exists only on the read. A separate `dashboardLayoutReadResult` extends the shared schema for the GET rather than widening both verbs and describing a field the PUT never sends. docs/api/openapi.yaml is NOT regenerated in this commit: `pnpm openapi:generate` cannot run here (the Prisma engine download is refused, so `@/generated/prisma/client` is missing and the registry will not load). The tree is therefore in drift on purpose and `pnpm openapi:check` will fail until `pnpm openapi:generate` is run and the yaml committed. --- src/lib/openapi/routes/dashboard.ts | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/lib/openapi/routes/dashboard.ts b/src/lib/openapi/routes/dashboard.ts index e9dc74148..e71ded325 100644 --- a/src/lib/openapi/routes/dashboard.ts +++ b/src/lib/openapi/routes/dashboard.ts @@ -108,6 +108,27 @@ const dashboardLayoutResult = dashboardLayoutSchema "Resolved dashboard layout plus the optimistic-concurrency `updatedAt` token.", }); +/** + * The GET-only shape. `unavailableWidgetIds` is resolved per request from + * the caller's module state and their data, so it belongs to the read and + * never to the PUT echo — hence a separate schema rather than widening + * `dashboardLayoutResult`, which both verbs share. + */ +const dashboardLayoutReadResult = dashboardLayoutResult + .extend({ + unavailableWidgetIds: z + .array(z.string()) + .optional() + .describe( + "Widget ids whose toggle cannot do anything for this account, and which a settings UI should therefore not offer — a switch over a tile that can never paint. Resolved per request from the caller's enabled modules and the data they actually have: `hrv` appears here when the Recovery module is off and the account has no SDNN series, because its only remaining HRV source (nightly RMSSD) is recovery-owned and withheld. Absent when nothing is unavailable. Additive: a client that ignores it behaves exactly as before.", + ), + }) + .meta({ + id: "DashboardLayoutReadResult", + description: + "Resolved dashboard layout, its `updatedAt` token, and any widget toggles that are unavailable for this account.", + }); + // ── The native-client dashboard aggregator ─────────────────────────── // // `GET /api/dashboard/summary` predates the registry and is the shape the iOS @@ -372,7 +393,7 @@ export const dashboardWidgetPaths: NonNullable = { tags: ["Dashboard"], summary: "Read the calling user's dashboard widget layout", description: - "Returns the resolved effective layout (defaults merged in if the user has not customised it) plus the optimistic-concurrency `updatedAt` token.", + "Returns the resolved effective layout (defaults merged in if the user has not customised it), the optimistic-concurrency `updatedAt` token, and `unavailableWidgetIds` — any widget whose toggle cannot do anything for this account, which a settings UI should not offer.", responses: { ...recordRefusal(), "200": { @@ -380,7 +401,10 @@ export const dashboardWidgetPaths: NonNullable = { "The resolved layout (custom or default) plus its token.", content: { "application/json": { - schema: dataEnvelope(dashboardLayoutResult, "DashboardLayout"), + schema: dataEnvelope( + dashboardLayoutReadResult, + "DashboardLayout", + ), }, }, }, From 7080ddea44719df7366269d801a64d1c59cee595 Mon Sep 17 00:00:00 2001 From: Antiheld86 <4201585+Antiheld86@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:30:03 +0200 Subject: [PATCH 6/7] docs(api): regenerate the spec for unavailableWidgetIds --- docs/api/openapi.yaml | 273 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 254 insertions(+), 19 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 511396968..106b98de1 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -18061,8 +18061,9 @@ paths: tags: - Dashboard summary: Read the calling user's dashboard widget layout - description: Returns the resolved effective layout (defaults merged in if the user has not customised it) plus the - optimistic-concurrency `updatedAt` token. + description: Returns the resolved effective layout (defaults merged in if the user has not customised it), the + optimistic-concurrency `updatedAt` token, and `unavailableWidgetIds` — any widget whose toggle cannot do + anything for this account, which a settings UI should not offer. responses: "200": description: The resolved layout (custom or default) plus its token. @@ -65069,6 +65070,257 @@ components: - mood - bmi DashboardLayout: + type: object + properties: + data: + $ref: "#/components/schemas/DashboardLayoutReadResult" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DashboardLayoutReadResult: + type: object + properties: + version: + type: number + const: 1 + widgets: + minItems: 1 + maxItems: 50 + type: array + items: + type: object + properties: + id: + type: string + enum: + - weight + - bp + - pulse + - bodyFat + - mood + - medications + - sleep + - steps + - glucose + - totalBodyWater + - boneMass + - muscleMass + - bpInTarget + - oxygenSaturation + - hrv + - respiratoryRate + - achievements + - vo2Max + - recentWorkouts + - cardioRecovery + - sixMinuteWalk + - stairAscentSpeed + - stairDescentSpeed + - breathingDisturbances + - wristTemperature + - falls + - walkingSteadiness + - vorsorge + - waterIntake + - restingHeartRate + - walkingSpeed + - walkingAsymmetry + - walkingStepLength + - bmi + - bodyTemperature + - walkingDoubleSupport + - audioExposureEnvironment + - audioExposureHeadphone + - gripStrength + - painNRS + - waistCircumference + - waistToHeight + visible: + type: boolean + tileVisible: + type: boolean + order: + type: integer + minimum: 0 + maximum: 99 + required: + - id + - visible + - order + additionalProperties: false + comparisonBaseline: + type: string + enum: + - none + - lastMonth + - lastYear + chartOverlayPrefs: + type: object + propertyNames: + type: string + enum: + - bp + - weight + - bmi + - pulse + - bodyFat + - mood + - medications + - sleep + - steps + - vo2Max + - hrv + - restingHr + - oxygenSaturation + - bodyTemperature + - activeEnergy + - bloodGlucose + - totalBodyWater + - boneMass + - flightsClimbed + - walkingRunningDistance + - fatFreeMass + - fatMass + - muscleMass + - skinTemperature + - pulseWaveVelocity + - vascularAge + - visceralFat + - audioExposureEnv + - audioExposureHeadphone + - timeInDaylight + - walkingSteadiness + - audioExposureEvent + - respiratoryRate + - leanBodyMass + - walkingHeartRateAverage + - walkingAsymmetry + - walkingDoubleSupport + - walkingStepLength + - walkingSpeed + - cardioRecovery + - wristTemperature + - fallCount + - sixMinuteWalkDistance + - stairAscentSpeed + - stairDescentSpeed + - breathingDisturbances + - ansCharge + - dayStrain + - workoutStrain + - cardioLoad + - averageHeartRate + - maxHeartRate + - energyExpenditureKj + - gripStrength + - painNrs + - waistCircumference + - waistToHeight + additionalProperties: + type: object + properties: + showTrendIndicator: + type: boolean + showTrendArrow: + type: boolean + showTargetRange: + type: boolean + comparisonBaseline: + type: string + enum: + - none + - lastMonth + - lastYear + rangePoints: + anyOf: + - type: number + const: 0 + - type: number + const: 7 + - type: number + const: 30 + - type: number + const: 90 + required: + - showTrendIndicator + - showTrendArrow + - showTargetRange + additionalProperties: false + selectedScoreRings: + maxItems: 3 + type: array + items: + type: string + enum: + - READINESS + - RECOVERY_SCORE + - SLEEP_SCORE + - MED_COMPLIANCE + heroRingOrder: + maxItems: 4 + type: array + items: + type: string + enum: + - HEALTH_SCORE + - READINESS + - RECOVERY_SCORE + - SLEEP_SCORE + - MED_COMPLIANCE + enabledHeroItemKinds: + description: Today-highlight item kinds in catalogue order. Missing means every current kind is enabled; an empty array + disables all kinds. + maxItems: 9 + type: array + items: + type: string + enum: + - coach_checkin + - dose_window + - preventive_care + - sync_issue + - milestone + - ecg_new_recording + - tension_window + - same_time_baseline + - upcoming_visit + hero: + description: 'Primary hero-card content on the dashboard: "score" (the health-score read, default) or "reminders" (the + Today-highlight rail promoted into the hero slot). Missing resolves to "score"; the stored default is + omitted from responses.' + type: string + enum: + - score + - reminders + updatedAt: + description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as + `baseUpdatedAt` on the next write. Opaque." + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + unavailableWidgetIds: + description: "Widget ids whose toggle cannot do anything for this account, and which a settings UI should therefore not + offer — a switch over a tile that can never paint. Resolved per request from the caller's enabled modules + and the data they actually have: `hrv` appears here when the Recovery module is off and the account has no + SDNN series, because its only remaining HRV source (nightly RMSSD) is recovery-owned and withheld. Absent + when nothing is unavailable. Additive: a client that ignores it behaves exactly as before." + type: array + items: + type: string + required: + - version + additionalProperties: false + description: Resolved dashboard layout, its `updatedAt` token, and any widget toggles that are unavailable for this account. + DashboardLayoutSaved: type: object properties: data: @@ -65310,23 +65562,6 @@ components: - version additionalProperties: false description: Resolved dashboard layout plus the optimistic-concurrency `updatedAt` token. - DashboardLayoutSaved: - type: object - properties: - data: - $ref: "#/components/schemas/DashboardLayoutResult" - error: - type: "null" - meta: - type: object - properties: - requestId: - type: string - additionalProperties: false - required: - - data - - error - additionalProperties: false DashboardLayoutReset: type: object properties: From fde8f9c80cfe792d2581961e08b9563af77bb572 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 3 Sep 2026 20:38:13 +0200 Subject: [PATCH 7/7] test: pin both HRV label keys across every locale The tile now calls t() with a key looked up from HRV_LABEL_KEY rather than a literal, and the call-site coverage guard only reads literal arguments. So the label left the guard's field of view exactly when it became per-series: dropping either key from a bundle would ship a raw key into the dashboard of whichever locale lost it, with nothing in the gate to say so. The keys are read from the map, so a third HRV series is covered by construction rather than by remembering. The count is anchored, because an empty map would satisfy the loop by matching nothing. A second case asserts the RMSSD label actually names the measure in every locale, which is the whole reason the second key exists. Verified by deleting the key from one bundle: three cases fail and name the locale and the key. --- .../dashboard-hrv-rmssd-fallback.test.tsx | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.tsx b/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.tsx index 0ab58efe5..19b133077 100644 --- a/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.tsx +++ b/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.tsx @@ -17,6 +17,7 @@ import { unavailableWidgetIds, } from "@/lib/dashboard/widget-modules"; import { moduleForMeasurementType } from "@/lib/modules/measurement-scope"; +import { locales } from "@/lib/i18n/config"; import type { DataSummary } from "@/lib/analytics/trends"; /** @@ -321,3 +322,50 @@ describe("a switch that cannot do anything is not offered", () => { expect(unavailableWidgetIds({}, { hasSdnn: false })).toEqual([]); }); }); + +describe("both HRV label keys are translated everywhere", () => { + // The tile now calls `t(hrvLabelKey)` with a value looked up from + // HRV_LABEL_KEY, and `i18n-call-site-coverage.test.ts` only sees literal + // `t("ns.key")` arguments. So the moment the label became per-series it + // left the guard's field of view: dropping either key from a bundle would + // ship a raw `measurements.typeHrvRmssd` into the dashboard of whichever + // locale lost it, and nothing in the gate would say so. + // + // Read from the map rather than from two literals, so a third HRV series + // added later is covered by construction instead of by remembering. + const bundles = locales.map((locale) => ({ + locale, + messages: JSON.parse( + readFileSync(join(process.cwd(), "messages", `${locale}.json`), "utf8"), + ) as Record>, + })); + + it("covers every key the tile can ask for, in all seven locales", () => { + const keys = Object.values(HRV_LABEL_KEY); + // Anchor the count: an empty map would satisfy every assertion below by + // matching nothing, which is the failure mode this project keeps finding. + expect(keys.length).toBeGreaterThanOrEqual(2); + expect(bundles).toHaveLength(7); + + for (const { locale, messages } of bundles) { + for (const key of keys) { + const [namespace, name] = key.split("."); + const value = messages[namespace]?.[name]; + expect( + typeof value === "string" && value.trim().length > 0, + `${locale}.json is missing ${key}`, + ).toBe(true); + } + } + }); + + it("names the measure in the RMSSD label, in every locale", () => { + // The whole point of the second key. A label that reads plain "HRV" in + // some locale puts a 40 ms RMSSD night under the same words as an SDNN + // reading, which is the misreading this change exists to prevent. + for (const { locale, messages } of bundles) { + const [ns, name] = HRV_LABEL_KEY.HRV_RMSSD.split("."); + expect(messages[ns]?.[name], `${locale}.json`).toMatch(/RMSSD/i); + } + }); +});