mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #899 from Antiheld86/fix/dashboard-hrv-rmssd-fallback
Fix/dashboard hrv rmssd fallback
This commit is contained in:
14 files changed
+1054
-61
No files matched your search
@@ -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
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user