Merge pull request #899 from Antiheld86/fix/dashboard-hrv-rmssd-fallback

Fix/dashboard hrv rmssd fallback
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-09-03 21:01:25 +02:00
commit c0cbe10033
14 files changed
+1054 -61

No files matched your search

+24
View File
@@ -14,6 +14,30 @@
and a couple of scripts sits at three or four, so the limit is there to
catch a runaway rather than to ration.
### 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 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.7] — 2026-09-03
The weekly backup actually produces a backup again. v1.38.6 stopped it
+254 -19
View File
@@ -18072,8 +18072,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.
@@ -65080,6 +65081,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:
@@ -65321,23 +65573,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:
@@ -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<typeof import("@/lib/modules/gate")>();
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 () => ({
@@ -0,0 +1,371 @@
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 { locales } from "@/lib/i18n/config";
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<string, DataSummary> | 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<string, DataSummary> | undefined) {
const { summary, labelKey } = pickHrvSummary(summaries);
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<TrendCard
label={`__${labelKey}__`}
latest={summary?.latest ?? null}
unit="ms"
avg7={summary?.avg7 ?? null}
avg30={summary?.avg30 ?? null}
slope30={summary?.slope30 ?? null}
icon={HeartPulse}
directionSentiment="up-good"
/>
</I18nProvider>,
);
}
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<string, string> };
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([]);
});
});
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<string, Record<string, string>>,
}));
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);
}
});
});
+37 -4
View File
@@ -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
@@ -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<typeof import("@/lib/modules/gate")>()),
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<unknown>,
) => 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();
});
});
+38 -1
View File
@@ -14,6 +14,8 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { isModuleEnabled } from "@/lib/modules/gate";
import { unavailableWidgetIds } from "@/lib/dashboard/widget-modules";
import { prisma, toJson } from "@/lib/db";
import {
takeBaseToken,
@@ -218,7 +220,42 @@ 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.
// 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 (!recoveryEnabled) {
const sdnnRow = await prisma.measurement.findFirst({
where: { userId: user.id, type: "HEART_RATE_VARIABILITY" },
select: { id: true },
});
hasSdnn = sdnnRow !== null;
}
const unavailable = unavailableWidgetIds(
{ recovery: recoveryEnabled },
{ hasSdnn },
);
return apiSuccess(
unavailable.length > 0
? { ...layout, unavailableWidgetIds: unavailable }
: layout,
);
});
export const PUT = apiHandler(async (request: NextRequest) => {
+29 -3
View File
@@ -173,6 +173,7 @@ import {
resolveDashboardFirstPaintGate,
resolveConfiguredTileCount,
resolveChartRowPlaceholderCount,
pickHrvSummary,
} from "@/components/dashboard/dashboard-gates";
/**
@@ -490,7 +491,18 @@ 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,
labelKey: hrvLabelKey,
} = pickHrvSummary(data?.summaries);
const spo2Summary = data?.summaries?.OXYGEN_SATURATION;
const respRateSummary = data?.summaries?.RESPIRATORY_RATE;
const wristTempSummary = data?.summaries?.WRIST_TEMPERATURE;
@@ -1309,7 +1321,18 @@ export default function DashboardPageClient({
node: (
<TrendCard
key="hrv"
label={t("measurements.typeHeartRateVariability")}
// Name the measure when the RMSSD series is the one on show.
// Without it a ring / strap reading (typically 20-60 ms) sits
// under the same label as an SDNN one and reads as a collapse
// rather than a different measure.
//
// Each series gets its OWN key, never a base label with the
// measure concatenated on. The tile label renders truncated,
// uppercase and on one line, so an appended marker is the
// first thing dropped in the longer locales — exactly the
// part a reader needs. `typeHrvRmssd` is translated in all
// seven locales and carries the measure inside the label.
label={t(hrvLabelKey)}
latest={hrvSummary?.latest ?? null}
unit="ms"
avg7={hrvSummary?.avg7 ?? null}
@@ -1320,7 +1343,10 @@ export default function DashboardPageClient({
directionSentiment="up-good"
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(hrvSummary)}
staleDays={tileStaleDays("HEART_RATE_VARIABILITY")}
// The type that actually backs the tile — reading freshness
// off SDNN while showing RMSSD would caption a live tile as
// permanently stale.
staleDays={tileStaleDays(hrvType)}
/>
),
});
@@ -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,80 @@ export function resolveChartRowPlaceholderCount(
}
return count;
}
/** The two measurement types the HRV surface reads, primary first. */
export type HrvSummaryType = "HEART_RATE_VARIABILITY" | "HRV_RMSSD";
/**
* The i18n key naming each HRV series. One key per series, never a base
* label with the measure concatenated on: the tile label paints truncated,
* uppercase and on a single line, so an appended marker is the first thing
* dropped in the longer locales — and the marker is the part a reader needs
* to tell a 40 ms RMSSD night from a collapsed SDNN one. Both keys are
* translated in all seven locales.
*/
export const HRV_LABEL_KEY: Record<HrvSummaryType, string> = {
HEART_RATE_VARIABILITY: "measurements.typeHeartRateVariability",
HRV_RMSSD: "measurements.typeHrvRmssd",
};
/**
* 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<string, DataSummary> | undefined,
): { 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 pick(primary ?? null, "HEART_RATE_VARIABILITY");
}
const fallback = summaries?.HRV_RMSSD;
if ((fallback?.count ?? 0) > 0) {
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 pick(primary ?? null, "HEART_RATE_VARIABILITY");
}
@@ -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 (
+7
View File
@@ -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;
+15 -31
View File
@@ -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<ModuleKey, boolean>,
summaries: Record<string, DataSummary>,
): 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<string, DataSummary>,
lastSeenByType: Record<string, { lastSeenAt: string } | null>,
modules: Record<ModuleKey, boolean>,
): {
summaries: Record<string, DataSummary>;
lastSeenByType: Record<string, { lastSeenAt: string } | null>;
} {
// 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<string, DataSummary> = {};
for (const [type, summary] of Object.entries(summaries)) {
if (!dropped.has(type)) outSummaries[type] = summary;
}
const outLastSeen: Record<string, { lastSeenAt: string } | null> = {};
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
+76
View File
@@ -68,6 +68,16 @@ export const SUMMARY_TYPE_MODULE: Partial<Record<string, ModuleKey>> = {
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
@@ -103,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<Record<ModuleKey, boolean>>,
facts: { hasSdnn: boolean },
): string[] {
const out: string[] = [];
if (modules.recovery === false && !facts.hasSdnn) out.push("hrv");
return out;
}
export function gateSummariesByModules<
S extends Record<string, unknown>,
L extends Record<string, unknown>,
>(
summaries: S,
lastSeenByType: L,
modules: Partial<Record<ModuleKey, boolean>>,
): { 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<string, unknown>)[type] = summary;
}
const outLastSeen = {} as L;
for (const [type, slot] of Object.entries(lastSeenByType)) {
if (!dropped.has(type))
(outLastSeen as Record<string, unknown>)[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
+26 -2
View File
@@ -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<ZodOpenApiObject["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.",
"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<ZodOpenApiObject["paths"]> = {
"The resolved layout (custom or default) plus its token.",
content: {
"application/json": {
schema: dataEnvelope(dashboardLayoutResult, "DashboardLayout"),
schema: dataEnvelope(
dashboardLayoutReadResult,
"DashboardLayout",
),
},
},
},