diff --git a/CHANGELOG.md b/CHANGELOG.md index 90ca2058e..8f4a86455 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,55 @@ ## [Unreleased] +## [1.34.4] — 2026-07-31 + +A release about two places in the code answering one question differently. + +### Added + +- An inferred nap is now shown as its own band in the sleep stage view instead of + being blended into that night. A nap used to be counted into the night's stages, + so a day with a long afternoon sleep read as a longer and shallower night than + it was. Nights without a nap look exactly as they did, with no empty band and no + legend entry for something that is not there. + +### Fixed + +- A checkup that is due tomorrow says tomorrow. The dashboard card worked its due + line out in rolling 24-hour steps while the checkups page used calendar days, so + the same appointment could read as due tomorrow on one screen and in two days on + the other. Both now read one shared rule. +- The due line and the date printed beside it are worked out on the same clock. + One used the device's timezone and the other the profile's, which disagree by a + day for anyone travelling or on a machine set to another zone. +- Recording something no longer takes minutes to appear on the home page. The page + render and the API were each holding their own copy of the server cache, so a + write emptied one and left the other serving the state from before it. They now + share one. +- Returning to the dashboard no longer answers a refresh with a reading fetched + before your last entry. +- The onboarding profile step says when the server could not accept a field. + Valid answers are kept, rejected ones are named, and if nothing could be saved + the message says which field blocked it. +- A cycle symptom's intensity survives a backup and restore. It was recorded and + shown, and every export quietly dropped it. Older backup files still restore, + with the intensity honestly absent rather than filled in with a zero. + +### Removed + +- The Health-Score explainer switch in the operator settings. It promised a control + that was deliberately removed two releases ago, so it had been switching nothing + for months. + +### Internal + +- The sleep composition and the single-night view now agree about which session is + the night, because they ask the same function rather than each deciding. +- A test that was meant to prove a dose taken before midnight stops reminding could + not fail: the stand-in for the database ignored the time window it was asked for. + It now honours it, and both directions are pinned, so a genuinely missed dose + still escalates. + ## [1.34.3] — 2026-07-31 A release about surfaces that knew something and did not say it. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 25fb37174..a9721d741 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.34.3 + version: 1.34.4 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/messages/de.json b/messages/de.json index 510ee0524..62e7dfdaa 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2968,6 +2968,8 @@ "asleep": "Schlafend", "awake": "Wach", "inBed": "Im Bett", + "nap": "Nickerchen", + "napCount": "{count} Nickerchen", "unavailable": "Noch keine Phasen-Daten – Schlafphasen erscheinen hier, sobald eine verbundene Quelle sie liefert." }, "empty": { @@ -6176,10 +6178,6 @@ "correlations": { "title": "Korrelationen", "description": "Erzähl-Kachel zur Korrelation auf der Insights-Übersicht." - }, - "healthScoreExplainer": { - "title": "Health-Score-Erklärer", - "description": "Das ?-Symbol neben der Health-Score-Differenz. Die Zahl bleibt unabhängig davon sichtbar." } }, "modules": { diff --git a/messages/en.json b/messages/en.json index 8c12b07f3..9c9b3db53 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2968,6 +2968,8 @@ "asleep": "Asleep", "awake": "Awake", "inBed": "In bed", + "nap": "Nap", + "napCount": "{count} naps", "unavailable": "No stage data yet — sleep stages appear here as soon as a connected source delivers them." }, "empty": { @@ -6176,10 +6178,6 @@ "correlations": { "title": "Correlations", "description": "Correlation tile narration on the Insights overview." - }, - "healthScoreExplainer": { - "title": "Health-Score explainer", - "description": "The ? glyph next to the Health-Score delta line. The delta digit stays visible regardless." } }, "modules": { diff --git a/messages/es.json b/messages/es.json index e4d6838cd..b9efc2096 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2968,6 +2968,8 @@ "asleep": "Dormido", "awake": "Despierto", "inBed": "En la cama", + "nap": "Siesta", + "napCount": "{count} siestas", "unavailable": "Aún no hay datos de fases — las fases de sueño aparecerán aquí en cuanto una fuente conectada las proporcione." }, "empty": { @@ -6176,10 +6178,6 @@ "correlations": { "title": "Correlaciones", "description": "Narración de la tarjeta de correlación en la vista general de Insights." - }, - "healthScoreExplainer": { - "title": "Explicador de Health-Score", - "description": "El icono ? junto a la línea de delta del Health-Score. El dígito del delta permanece visible siempre." } }, "modules": { diff --git a/messages/fr.json b/messages/fr.json index 3310a4e0d..69258a2dd 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2968,6 +2968,8 @@ "asleep": "Endormi", "awake": "Éveillé", "inBed": "Au lit", + "nap": "Sieste", + "napCount": "{count} siestes", "unavailable": "Pas encore de données de phases — les phases de sommeil apparaîtront ici dès qu’une source connectée les fournira." }, "empty": { @@ -6176,10 +6178,6 @@ "correlations": { "title": "Corrélations", "description": "Narration de la tuile de corrélation sur la vue d'ensemble Insights." - }, - "healthScoreExplainer": { - "title": "Explicateur de Health-Score", - "description": "L'icône ? près de la ligne de delta du Health-Score. Le chiffre du delta reste visible quoi qu'il arrive." } }, "modules": { diff --git a/messages/it.json b/messages/it.json index 752f960dd..b14a7c282 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2968,6 +2968,8 @@ "asleep": "Addormentato", "awake": "Sveglio", "inBed": "A letto", + "nap": "Pisolino", + "napCount": "{count} pisolini", "unavailable": "Ancora nessun dato sulle fasi — le fasi del sonno appariranno qui non appena una fonte collegata le fornirà." }, "empty": { @@ -6176,10 +6178,6 @@ "correlations": { "title": "Correlazioni", "description": "Narrazione del tile di correlazione sulla panoramica Insights." - }, - "healthScoreExplainer": { - "title": "Spiegatore Health-Score", - "description": "L'icona ? accanto alla riga del delta dell'Health-Score. La cifra del delta resta visibile in ogni caso." } }, "modules": { diff --git a/messages/pl.json b/messages/pl.json index a997b9966..51f7d586f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2968,6 +2968,8 @@ "asleep": "Śpi", "awake": "Czuwa", "inBed": "W łóżku", + "nap": "Drzemka", + "napCount": "{count} drzemki/ek", "unavailable": "Brak danych o fazach — fazy snu pojawią się tutaj, gdy tylko dostarczy je połączone źródło." }, "empty": { @@ -6176,10 +6178,6 @@ "correlations": { "title": "Korelacje", "description": "Narracja kafelka korelacji w przeglądzie Insights." - }, - "healthScoreExplainer": { - "title": "Objaśnienie Health-Score", - "description": "Ikona ? obok linii delty Health-Score. Cyfra delty pozostaje widoczna niezależnie." } }, "modules": { diff --git a/package.json b/package.json index c993e4965..a77845fcf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.34.3", + "version": "1.34.4", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/prisma/migrations/0288_drop_health_score_explainer_flag/migration.sql b/prisma/migrations/0288_drop_health_score_explainer_flag/migration.sql new file mode 100644 index 000000000..bb76ac3f1 --- /dev/null +++ b/prisma/migrations/0288_drop_health_score_explainer_flag/migration.sql @@ -0,0 +1,13 @@ +-- Remove the Health-Score explainer operator switch. +-- +-- The toggle gated a caption beside the Health-Score delta line. That +-- caption's component was deleted when the health score was replaced by +-- the reference composite, and the trailing "?" affordance the admin +-- description still named had already been retired a release earlier. +-- The switch has had nothing to switch since; an operator flipping it +-- changed nothing while being told they had. +-- +-- Destructive by design: the setting is gone, so its stored value goes +-- with it. +ALTER TABLE "app_settings" + DROP COLUMN IF EXISTS "assistant_health_score_explainer_enabled"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5d0562c6f..6cd6fa6a8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4364,10 +4364,10 @@ model AppSettings { defaultUserTimezone String? @map("default_user_timezone") @db.VarChar(64) // v1.4.31 — Assistant-surface operator feature flags. The master - // gates every LLM-driven surface app-wide; five sub-flags carve + // gates every LLM-driven surface app-wide; four sub-flags carve // the visibility cut per surface (Coach drawer + chat, Daily // Briefing + advisor, per-metric status cards, correlation - // narration, Health-Score delta explainer). Defaults preserve the + // narration). Defaults preserve the // v1.4.30 behaviour (every surface visible) so upgrades require // no admin action. The runtime resolver in // `src/lib/feature-flags/index.ts` forces every sub-flag false @@ -4376,12 +4376,11 @@ model AppSettings { // flag matrix gates server-routed AND iOS on-device surfaces, so // operators can opt out of every assistant surface regardless of // where the model runs. - assistantEnabled Boolean @default(true) @map("assistant_enabled") - assistantCoachEnabled Boolean @default(true) @map("assistant_coach_enabled") - assistantBriefingEnabled Boolean @default(true) @map("assistant_briefing_enabled") - assistantInsightStatusEnabled Boolean @default(true) @map("assistant_insight_status_enabled") - assistantCorrelationsEnabled Boolean @default(true) @map("assistant_correlations_enabled") - assistantHealthScoreExplainerEnabled Boolean @default(true) @map("assistant_health_score_explainer_enabled") + assistantEnabled Boolean @default(true) @map("assistant_enabled") + assistantCoachEnabled Boolean @default(true) @map("assistant_coach_enabled") + assistantBriefingEnabled Boolean @default(true) @map("assistant_briefing_enabled") + assistantInsightStatusEnabled Boolean @default(true) @map("assistant_insight_status_enabled") + assistantCorrelationsEnabled Boolean @default(true) @map("assistant_correlations_enabled") // v1.18.0 — operator-level server-wide module availability. A DISABLED // allowlist mirroring the per-user `User.modulePreferencesJson`: absent / diff --git a/public/sw.js b/public/sw.js index 3f1789a63..1fe34fe52 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.34.3"; + /* @sw-version-fallback */ "v1.34.4"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/__tests__/vorsorge-due-bucket-single-home-guard.test.ts b/src/__tests__/vorsorge-due-bucket-single-home-guard.test.ts new file mode 100644 index 000000000..19811de76 --- /dev/null +++ b/src/__tests__/vorsorge-due-bucket-single-home-guard.test.ts @@ -0,0 +1,173 @@ +/** + * Structural guard on the checkup due-day bucket. + * + * Turning a reminder's `nextDueAt` into "today" / "tomorrow" / "in N days" / + * "overdue by N days" is calendar arithmetic, and it is easy to get wrong in a + * way that reads fine most of the time: subtract the two instants, divide by + * twenty-four hours, round. That answer disagrees with the wall calendar + * whenever the two times of day are more than twelve hours apart, which is + * most evenings. + * + * It was written that way once, fixed on the checkups page, and left unfixed + * in the hand-copied second body on the dashboard card. For a year the two + * screens gave different answers about the same appointment. This file exists + * so a third body cannot appear. + * + * These are tripwires, not proofs. Whether the bucketing is CORRECT is settled + * by `src/lib/measurement-reminders/__tests__/due-day.test.ts`. What this file + * settles is that there is only one of it. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync, globSync } from "node:fs"; +import { join, sep } from "node:path"; + +const SRC = join(process.cwd(), "src"); + +/** The one home. Everything else imports from here. */ +const HOME = "lib/measurement-reminders/due-day.ts"; + +/** How a consumer must reach it. */ +const HOME_IMPORT = 'from "@/lib/measurement-reminders/due-day"'; + +/** + * The two screens that render the due line. Named here so this guard can never + * pass by having nothing left to check: if the surfaces stop importing the + * shared body, that is the regression, not a reason to relax the test. + */ +const RENDERING_SURFACES = [ + "components/measurement-reminders/vorsorge-section.tsx", + "components/measurement-reminders/vorsorge-dashboard-card.tsx", +]; + +/** + * The keys only the bucket itself mints. A surface may COMPARE against the + * today / overdue keys to colour its badge, and both do — so those two are + * deliberately not listed. Nothing but the bucket ever needs to name + * "tomorrow", "in N days" or "none", so a file outside the home that contains + * one is a second bucket wearing a different function name. + */ +const MINTED_ONLY_BY_THE_BUCKET = [ + "measurementReminders.nextDue.tomorrow", + "measurementReminders.nextDue.inDays", + "measurementReminders.nextDue.none", +]; + +/** The hook that yields the zone the surrounding dates are printed in. */ +const DISPLAY_ZONE_HOOK = "useDisplayTimezone"; + +const FIX_IT = [ + `The due-day bucket lives in src/${HOME} and nowhere else.`, + `Import { relativeDueKey } ${HOME_IMPORT} instead of writing a second one.`, + "If the behaviour needs to change, change it there — both the checkups page", + "and the dashboard card read that body, and they must agree.", +].join(" "); + +const FIX_THE_ZONE = [ + "The due phrase has to be worked out in the same zone as the date printed", + `beside it. Read ${DISPLAY_ZONE_HOOK}() and pass it as the third argument to`, + "relativeDueKey. Do not reach for the device clock and do not give the", + "parameter a default: a caller that stays silent is exactly how the phrase", + "and the date drifted a day apart in the first place.", +].join(" "); + +function sourceFiles(): string[] { + return globSync("**/*.{ts,tsx}", { cwd: SRC }) + .map((p) => p.split(sep).join("/")) + .filter((p) => !p.startsWith("generated/")) + .filter((p) => !p.includes("__tests__/")) + .sort(); +} + +function read(rel: string): string { + return readFileSync(join(SRC, rel), "utf8"); +} + +/** A line that declares `relativeDueKey`, ignoring comment prose. */ +function declaresTheBucket(source: string): boolean { + return source + .split("\n") + .some( + (line) => + /\b(function|const|let|var)\s+relativeDueKey\b/.test(line) && + !/^\s*(\*|\/\/|\/\*)/.test(line), + ); +} + +describe("the checkup due-day bucket has one home", () => { + it("is declared in exactly one file", () => { + const declaring = sourceFiles().filter((rel) => + declaresTheBucket(read(rel)), + ); + + expect(declaring, FIX_IT).toEqual([HOME]); + }); + + it("is reached by import everywhere it is used", () => { + const localUsers = sourceFiles() + .filter((rel) => rel !== HOME) + .filter((rel) => read(rel).includes("relativeDueKey")) + .filter((rel) => !read(rel).includes(HOME_IMPORT)); + + expect(localUsers, FIX_IT).toEqual([]); + }); + + it("is the only place the tomorrow, in-N-days and none keys are minted", () => { + // Catches a duplicate that was renamed on the way in: whatever it is + // called, a second bucket has to name the same keys to say the same words. + const minters = sourceFiles() + .filter((rel) => rel !== HOME) + .filter((rel) => { + const source = read(rel); + return MINTED_ONLY_BY_THE_BUCKET.some((key) => source.includes(key)); + }); + + expect(minters, FIX_IT).toEqual([]); + }); + + it("is what both checkup screens read their due line from", () => { + for (const rel of RENDERING_SURFACES) { + expect(read(rel), `${rel} no longer reads the shared due line`).toContain( + HOME_IMPORT, + ); + } + }); +}); + +describe("the due-day bucket asks the same clock as the date beside it", () => { + it("never floors a day against the device clock", () => { + // `startOfLocalDayInTz(instant, undefined)` means "whatever zone this + // machine is set to". That is the reading the phrase used to take while + // the date next to it took the profile zone. + // + // The check is the bare word rather than a shape like + // `startOfLocalDayInTz(…, undefined)`, because the first draft of it + // matched only the inline form and sailed straight past a mutation that + // put the same `undefined` in a variable one line up. There is no honest + // use for the word in a file whose whole job is to name a zone; if one + // ever appears, that is worth coming here and arguing for. + const home = read(HOME); + + expect(home, FIX_THE_ZONE).not.toContain("undefined"); + }); + + it("takes the zone as an argument with no default to fall back on", () => { + const home = read(HOME); + const signature = home.slice( + home.indexOf("export function relativeDueKey"), + home.indexOf("): RelativeDue"), + ); + + expect(signature, FIX_THE_ZONE).toContain("timeZone: string"); + // `timeZone: string = …` or `timeZone = …` would let a caller stay silent. + expect(signature, FIX_THE_ZONE).not.toMatch(/timeZone[^,)]*=/); + }); + + it("is called by surfaces that read the printed date's zone", () => { + const silent = sourceFiles() + .filter((rel) => rel !== HOME) + .filter((rel) => read(rel).includes("relativeDueKey(")) + .filter((rel) => !read(rel).includes(DISPLAY_ZONE_HOOK)); + + expect(silent, FIX_THE_ZONE).toEqual([]); + }); +}); diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index e6d28a7e7..03d749045 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -391,8 +391,6 @@ const handler = apiHandler( settings.assistantInsightStatusEnabled, assistantCorrelationsEnabled: settings.assistantCorrelationsEnabled, - assistantHealthScoreExplainerEnabled: - settings.assistantHealthScoreExplainerEnabled, moduleAvailabilityJson: settings.moduleAvailabilityJson as never, documentMaxFileBytes: settings.documentMaxFileBytes, documentQuotaBytes: BigInt(settings.documentQuotaBytes), diff --git a/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts b/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts index 99fd498e0..60fef2dba 100644 --- a/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts +++ b/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts @@ -46,7 +46,6 @@ const ALL_ON = { assistantBriefingEnabled: true, assistantInsightStatusEnabled: true, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, }; beforeEach(() => { @@ -140,7 +139,6 @@ describe("PUT /api/admin/settings/assistant-flags", () => { expect(body.data.resolved.briefing).toBe(false); expect(body.data.resolved.insightStatus).toBe(false); expect(body.data.resolved.correlations).toBe(false); - expect(body.data.resolved.healthScoreExplainer).toBe(false); }); it("rejects an empty body", async () => { diff --git a/src/app/api/admin/settings/assistant-flags/route.ts b/src/app/api/admin/settings/assistant-flags/route.ts index fd4fd451e..6445eca22 100644 --- a/src/app/api/admin/settings/assistant-flags/route.ts +++ b/src/app/api/admin/settings/assistant-flags/route.ts @@ -1,6 +1,6 @@ /** * `PUT /api/admin/settings/assistant-flags` — operator-side flip - * surface for the six assistant feature flags. + * surface for the five assistant feature flags. * * Dedicated endpoint (separate from the generic * `/api/admin/settings` PUT) so the admin panel can wire its @@ -47,7 +47,6 @@ const assistantFlagsSchema = z assistantBriefingEnabled: z.boolean().optional(), assistantInsightStatusEnabled: z.boolean().optional(), assistantCorrelationsEnabled: z.boolean().optional(), - assistantHealthScoreExplainerEnabled: z.boolean().optional(), }) .strict(); @@ -57,7 +56,6 @@ type AssistantFlagsRow = { assistantBriefingEnabled: boolean; assistantInsightStatusEnabled: boolean; assistantCorrelationsEnabled: boolean; - assistantHealthScoreExplainerEnabled: boolean; }; function buildResponseShape(row: AssistantFlagsRow) { @@ -67,7 +65,6 @@ function buildResponseShape(row: AssistantFlagsRow) { briefing: row.assistantBriefingEnabled, insightStatus: row.assistantInsightStatusEnabled, correlations: row.assistantCorrelationsEnabled, - healthScoreExplainer: row.assistantHealthScoreExplainerEnabled, }); return { raw: { @@ -76,8 +73,6 @@ function buildResponseShape(row: AssistantFlagsRow) { assistantBriefingEnabled: row.assistantBriefingEnabled, assistantInsightStatusEnabled: row.assistantInsightStatusEnabled, assistantCorrelationsEnabled: row.assistantCorrelationsEnabled, - assistantHealthScoreExplainerEnabled: - row.assistantHealthScoreExplainerEnabled, }, resolved, }; @@ -97,7 +92,6 @@ export const GET = apiHandler(async () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: true, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, }, }); @@ -109,8 +103,6 @@ export const GET = apiHandler(async () => { settings?.assistantInsightStatusEnabled ?? true, assistantCorrelationsEnabled: settings?.assistantCorrelationsEnabled ?? true, - assistantHealthScoreExplainerEnabled: - settings?.assistantHealthScoreExplainerEnabled ?? true, }; return apiSuccess(buildResponseShape(row)); @@ -162,8 +154,6 @@ export const PUT = apiHandler(async (request: NextRequest) => { assistantBriefingEnabled: settings.assistantBriefingEnabled, assistantInsightStatusEnabled: settings.assistantInsightStatusEnabled, assistantCorrelationsEnabled: settings.assistantCorrelationsEnabled, - assistantHealthScoreExplainerEnabled: - settings.assistantHealthScoreExplainerEnabled, }; return apiSuccess(buildResponseShape(row)); diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index b765ba158..aebd44534 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -9,7 +9,10 @@ import { computeSummariesSlice } from "@/lib/analytics/summaries-slice"; import { resolveEffectiveBpTargets } from "@/lib/analytics/effective-range"; import { bpTargetsEqual, getBpTargets } from "@/lib/analytics/bp-targets"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; -import { reconstructSleepNights } from "@/lib/analytics/sleep-night"; +import { + buildSleepStageComposition, + type SleepStageComposition, +} from "@/lib/analytics/sleep-stage-composition"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { ensureUserRollupsFresh } from "@/lib/rollups/measurement-rollups"; import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; @@ -620,28 +623,30 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) { * Berlin-tz day inside the window, with minutes-per-stage for the * stacked-bar chart. Days with zero stage-tagged rows are omitted * from `perNight` (the chart treats them as gaps). + * + * The per-night stages are the MAIN NIGHT's stages. A nap ends on the + * same wake day as that morning's night, and the per-wake-day totals + * folded it into the night's buckets — an afternoon nap added CORE to + * the night and the column read longer and shallower than the night + * was. The nap's minutes now ride alongside on `napMinutes` / + * `napCount`, both absent on a day without one. */ async function computeSleepStageBreakdown( userId: string, userTz: string, -): Promise<{ - windowDays: number; - nights: number; - totalMinutes: number; - stages: Record; - perNight: Array<{ dayKey: string; stages: Record }>; -} | null> { +): Promise { const DAY_MS = 24 * 60 * 60 * 1000; - const since = new Date(Date.now() - 30 * DAY_MS); + const WINDOW_DAYS = 30; + const since = new Date(Date.now() - WINDOW_DAYS * DAY_MS); // v1.11.5 — read the FULL per-stage row set (not `sleepStage: { not: null }`) - // and route it through the canonical `reconstructSleepNights` so the + // and route it through the canonical session reconstruction so the // stacked-bar agrees with the dashboard tile + chart series. The old path // keyed each stage row by its OWN calendar day (splitting a midnight- // spanning night across two buckets) and never collapsed multi-source // (double-counting a WHOOP + Apple Health night). Routing through the // helper fixes both: it session-clusters, keys by the wake day, and picks // one canonical source per night. The bare-vs-granular de-dup is applied - // per-night to the per-stage breakdown too. The `source` is selected so + // per-session to the per-stage breakdown too. The `source` is selected so // the helper's source-collapse can run. const rows = await prisma.measurement.findMany({ where: { @@ -662,35 +667,7 @@ async function computeSleepStageBreakdown( if (rows.length === 0) return null; const priorityJson = await loadUserSourcePriority(userId); - const nights = reconstructSleepNights(rows, userTz, priorityJson).filter( - (n) => Object.keys(n.stages).length > 0, - ); - if (nights.length === 0) return null; - - // Aggregate the per-night stage breakdowns into the window totals + the - // per-night series the chart slices for its 7 / 14 / 30 toggle. - const stages: Record = {}; - let totalMinutes = 0; - const perNight = nights - .map((n) => { - const nightStages: Record = {}; - for (const [stage, mins] of Object.entries(n.stages)) { - if (mins == null) continue; - nightStages[stage] = mins; - stages[stage] = (stages[stage] ?? 0) + mins; - totalMinutes += mins; - } - return { dayKey: n.night, stages: nightStages }; - }) - .sort((a, b) => a.dayKey.localeCompare(b.dayKey)); - - return { - windowDays: 30, - nights: nights.length, - totalMinutes, - stages, - perNight, - }; + return buildSleepStageComposition(rows, userTz, priorityJson, WINDOW_DAYS); } // v1.4.37 W2 — `computeCorrelationHypotheses` body relocated to diff --git a/src/app/api/feature-flags/__tests__/route.test.ts b/src/app/api/feature-flags/__tests__/route.test.ts index 0bd8c9e44..71a4b06f2 100644 --- a/src/app/api/feature-flags/__tests__/route.test.ts +++ b/src/app/api/feature-flags/__tests__/route.test.ts @@ -72,7 +72,6 @@ describe("GET /api/feature-flags", () => { briefing: boolean; insightStatus: boolean; correlations: boolean; - healthScoreExplainer: boolean; }; }; error: null; @@ -85,7 +84,6 @@ describe("GET /api/feature-flags", () => { briefing: true, insightStatus: true, correlations: true, - healthScoreExplainer: true, }); }); @@ -97,7 +95,6 @@ describe("GET /api/feature-flags", () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: true, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, }); const res = await GET(req()); @@ -113,7 +110,6 @@ describe("GET /api/feature-flags", () => { briefing: false, insightStatus: false, correlations: false, - healthScoreExplainer: false, }); }); @@ -125,7 +121,6 @@ describe("GET /api/feature-flags", () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: false, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: false, }); const res = await GET(req()); @@ -141,7 +136,6 @@ describe("GET /api/feature-flags", () => { briefing: true, insightStatus: false, correlations: true, - healthScoreExplainer: false, }); }); diff --git a/src/app/api/feature-flags/route.ts b/src/app/api/feature-flags/route.ts index 2e8d46d29..e92422307 100644 --- a/src/app/api/feature-flags/route.ts +++ b/src/app/api/feature-flags/route.ts @@ -13,8 +13,7 @@ * "coach": true, * "briefing": true, * "insightStatus": true, - * "correlations": true, - * "healthScoreExplainer": true + * "correlations": true * } * }, * "error": null diff --git a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts index a21ef6e2a..6c0d67024 100644 --- a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts +++ b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts @@ -23,7 +23,7 @@ import { describe, expect, it } from "vitest"; * `requireAssistantSurface("coach")`. * 2. Other-gated — file invokes `requireAssistantSurface()` with a * different surface (`insightStatus`, - * `correlations`, `briefing`, `healthScoreExplainer`). + * `correlations`, `briefing`). * 3. Allowlisted — non-Coach Insights routes that don't gate on * the matrix at all (provider chain, settings, * targets, glp1-timeline, feedback). Allowlist diff --git a/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts b/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts index 317554153..479be460f 100644 --- a/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts +++ b/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts @@ -129,7 +129,6 @@ describe("GET /api/insights/biomarker-assessment", () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: false, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, } as never); const res = await callGet(makeReq("bm-1")); expect(res.status).toBe(403); diff --git a/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts b/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts index 3b26c4286..d0bb9f16f 100644 --- a/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts +++ b/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts @@ -91,7 +91,6 @@ describe("GET /api/insights/blood-pressure-status — assistant-flag gate", () = assistantBriefingEnabled: true, assistantInsightStatusEnabled: false, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, } as never); const res = await callGet(makeReq()); expect(res.status).toBe(403); diff --git a/src/app/api/insights/cards/__tests__/route.test.ts b/src/app/api/insights/cards/__tests__/route.test.ts index e4a162f53..fbd8d1a08 100644 --- a/src/app/api/insights/cards/__tests__/route.test.ts +++ b/src/app/api/insights/cards/__tests__/route.test.ts @@ -95,7 +95,6 @@ describe("GET /api/insights/cards — assistant-flag gate", () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: false, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, } as never); const res = await callGet(makeReq()); expect(res.status).toBe(403); @@ -111,7 +110,6 @@ describe("GET /api/insights/cards — assistant-flag gate", () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: true, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, } as never); const res = await callGet(makeReq()); expect(res.status).toBe(403); diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index b82360359..943931089 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -167,7 +167,6 @@ describe("GET /api/insights/correlations", () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: true, assistantCorrelationsEnabled: false, - assistantHealthScoreExplainerEnabled: true, }); const res = await callGet(makeReq()); expect(res.status).toBe(403); @@ -185,7 +184,6 @@ describe("GET /api/insights/correlations", () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: true, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, }); const res = await callGet(makeReq()); expect(res.status).toBe(403); diff --git a/src/app/api/insights/metric-status/__tests__/route.test.ts b/src/app/api/insights/metric-status/__tests__/route.test.ts index f922dc5de..a98484540 100644 --- a/src/app/api/insights/metric-status/__tests__/route.test.ts +++ b/src/app/api/insights/metric-status/__tests__/route.test.ts @@ -134,7 +134,6 @@ describe("GET /api/insights/metric-status", () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: false, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, } as never); const res = await callGet(makeReq("SLEEP_DURATION")); expect(res.status).toBe(403); diff --git a/src/app/api/insights/pregenerate/__tests__/route.test.ts b/src/app/api/insights/pregenerate/__tests__/route.test.ts index 68f17dac8..cec99b006 100644 --- a/src/app/api/insights/pregenerate/__tests__/route.test.ts +++ b/src/app/api/insights/pregenerate/__tests__/route.test.ts @@ -102,7 +102,6 @@ describe("POST /api/insights/pregenerate", () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: true, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, } as never); const res = await callPost(makeReq()); expect(res.status).toBe(200); @@ -117,7 +116,6 @@ describe("POST /api/insights/pregenerate", () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: false, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, } as never); const res = await callPost(makeReq()); expect(res.status).toBe(403); diff --git a/src/components/admin/assistant-section.tsx b/src/components/admin/assistant-section.tsx index a54adbe21..556938bde 100644 --- a/src/components/admin/assistant-section.tsx +++ b/src/components/admin/assistant-section.tsx @@ -12,11 +12,10 @@ import { SettingsToggle } from "./_shared"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; /** - * v1.4.31 — operator-side panel for the six assistant feature - * flags. The master toggle gates the whole assistant; five + * v1.4.31 — operator-side panel for the five assistant feature + * flags. The master toggle gates the whole assistant; four * sub-toggles carve specific surfaces (Coach, Daily Briefing, - * per-metric status cards, correlation narration, Health-Score - * delta explainer). + * per-metric status cards, correlation narration). * * UX: * - Master toggle at the top. When off, the sub-toggles are @@ -35,7 +34,6 @@ interface AssistantFlagsResponse { assistantBriefingEnabled: boolean; assistantInsightStatusEnabled: boolean; assistantCorrelationsEnabled: boolean; - assistantHealthScoreExplainerEnabled: boolean; }; resolved: { enabled: boolean; @@ -43,7 +41,6 @@ interface AssistantFlagsResponse { briefing: boolean; insightStatus: boolean; correlations: boolean; - healthScoreExplainer: boolean; }; } @@ -154,17 +151,6 @@ export function AssistantSection() { } disabled={disabledSubs} /> - - mutation.mutate({ - assistantHealthScoreExplainerEnabled: checked, - }) - } - disabled={disabledSubs} - /> diff --git a/src/components/insights/__tests__/insight-status-card-flag-gate.test.tsx b/src/components/insights/__tests__/insight-status-card-flag-gate.test.tsx index 5f67f5f96..bc63bb811 100644 --- a/src/components/insights/__tests__/insight-status-card-flag-gate.test.tsx +++ b/src/components/insights/__tests__/insight-status-card-flag-gate.test.tsx @@ -33,7 +33,6 @@ function makeClient(flags: { briefing: boolean; insightStatus: boolean; correlations: boolean; - healthScoreExplainer: boolean; }): QueryClient { const client = new QueryClient({ defaultOptions: { queries: { retry: 0 } }, @@ -52,7 +51,6 @@ describe(" — assistant.insightStatus gate", () => { briefing: true, insightStatus: true, correlations: true, - healthScoreExplainer: true, }), ); expect(html).toContain("Your pulse is stable"); @@ -67,7 +65,6 @@ describe(" — assistant.insightStatus gate", () => { briefing: true, insightStatus: false, correlations: true, - healthScoreExplainer: true, }), ); expect(html).toBe(""); @@ -82,7 +79,6 @@ describe(" — assistant.insightStatus gate", () => { briefing: false, insightStatus: false, correlations: false, - healthScoreExplainer: false, }), ); expect(html).toBe(""); diff --git a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx index b8e48baf1..afe9fd8a1 100644 --- a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx +++ b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx @@ -5,7 +5,10 @@ import { I18nProvider } from "@/lib/i18n/context"; import { SleepStageStackedBar, STAGE_ORDER, + buildCompositionRows, + windowHasNap, type SleepStageBreakdown, + type SleepStageNight, } from "../sleep-stage-stacked-bar"; /** @@ -230,3 +233,61 @@ describe("", () => { }); }); }); + +describe("nap band", () => { + /** + * Recharts draws nothing under `renderToStaticMarkup`, so the band itself + * is pinned through the pure row builder the chart feeds. + */ + const label = (dayKey: string) => dayKey; + + it("carries the nap minutes on the day that has one", () => { + const perNight: SleepStageNight[] = [ + { dayKey: "2026-06-03", stages: { CORE: 250, DEEP: 50 } }, + { + dayKey: "2026-06-04", + stages: { CORE: 250, DEEP: 50 }, + napMinutes: 110, + napCount: 1, + }, + ]; + const { rows } = buildCompositionRows(perNight, 7, label); + expect(rows[1].NAP).toBe(110); + expect(rows[1].napCount).toBe(1); + // The night's own stages are untouched by the nap sitting beside them. + expect(rows[1].CORE).toBe(250); + // And the day before, which had no nap, carries nothing. + expect(rows[0].NAP).toBe(0); + expect(windowHasNap(rows)).toBe(true); + }); + + it("mounts no band at all on a week without a nap", () => { + const perNight: SleepStageNight[] = [ + { dayKey: "2026-06-03", stages: { CORE: 250, DEEP: 50 } }, + { dayKey: "2026-06-04", stages: { CORE: 260, DEEP: 40 } }, + ]; + const { rows } = buildCompositionRows(perNight, 7, label); + expect(windowHasNap(rows)).toBe(false); + }); + + it("drops the band when the nap falls outside the visible window", () => { + // The nap is on the oldest day; a 2-day window scrolls past it, so the + // legend must not keep a nap entry the user cannot see. + const perNight: SleepStageNight[] = [ + { + dayKey: "2026-06-02", + stages: { CORE: 250 }, + napMinutes: 110, + napCount: 1, + }, + { dayKey: "2026-06-03", stages: { CORE: 250 } }, + { dayKey: "2026-06-04", stages: { CORE: 260 } }, + ]; + expect(windowHasNap(buildCompositionRows(perNight, 3, label).rows)).toBe( + true, + ); + expect(windowHasNap(buildCompositionRows(perNight, 2, label).rows)).toBe( + false, + ); + }); +}); diff --git a/src/components/insights/sleep-stage-stacked-bar.tsx b/src/components/insights/sleep-stage-stacked-bar.tsx index 0203dc2d8..15e50bb0b 100644 --- a/src/components/insights/sleep-stage-stacked-bar.tsx +++ b/src/components/insights/sleep-stage-stacked-bar.tsx @@ -44,7 +44,16 @@ import type { Locale } from "@/lib/i18n/config"; export interface SleepStageNight { /** Berlin-tz day key (YYYY-MM-DD). */ dayKey: string; + /** Per-stage minutes of the MAIN night. Naps are not in here. */ stages: Record; + /** + * Time asleep across this day's naps, in minutes. Absent on a day with no + * nap, which is most days — the chart draws nothing for it and the legend + * does not carry a nap entry. + */ + napMinutes?: number; + /** How many naps that is. Absent alongside `napMinutes`. */ + napCount?: number; } export interface SleepStageBreakdown { @@ -87,6 +96,46 @@ type WindowSize = (typeof WINDOW_DAYS)[number]; */ export const STAGE_ORDER = ["DEEP", "REM", "CORE", "ASLEEP", "AWAKE"] as const; +/** + * The nap band. Not a sleep stage — it is the day's daytime sleep, kept out + * of the night's stage buckets and drawn as its own block on top of the + * column so the night below it reads as the night. + * + * The band is only rendered when the visible window actually holds a nap. On + * a week without one there is no bar, no legend entry, and no tooltip row. + */ +const NAP_KEY = "NAP"; + +/** + * The nap band is drawn with a card-coloured outline, and that outline is + * load-bearing rather than decoration. + * + * No flat fill can carry this on its own. To clear the 3:1 that WCAG 1.4.11 + * asks between adjacent graphical objects, a nap colour would have to beat + * all five stage colours AND the card it sits on. In the dark theme the five + * stages span luminance 0.385–0.890, which forces the nap below 0.095, while + * clearing the card (0.014) forces it above 0.143 — no value satisfies both. + * The light theme is the same bind mirrored: the stages sit at 0.088–0.161, + * forcing the nap above 0.583, while the card (0.966) forces it below 0.289. + * Both are empty ranges, so this is arithmetic, not a shortage of tokens. + * + * A separator is the sanctioned way out, and it measures comfortably: + * + * stroke vs DEEP REM CORE ASLEEP AWAKE nap fill + * dark 6.78 6.86 11.82 11.92 14.65 9.60 + * light 6.04 7.37 5.80 5.90 4.81 5.60 + * + * Lowest pair 4.81:1, against a light-theme wake bout — the neighbour the + * nap most often lands on, since AWAKE is the top of the stage stack. + * + * The stage tokens themselves are untouched; they are shared with the + * hypnogram and the charts-visual-identity rule keeps them put. + */ +const NAP_SEPARATOR = { + stroke: "var(--card)", + strokeWidth: 2, +} as const; + /** * Dracula stage palette. Exported so the last-night hypnogram * (`sleep-hypnogram.tsx`) reuses the exact same tokens — per the @@ -99,6 +148,7 @@ export const STAGE_COLORS: Record = { ASLEEP: "var(--success)", // dracula-green — legacy iOS 15- unspecified AWAKE: "var(--dracula-yellow)", // dracula-yellow — wake bouts IN_BED: "var(--chart-inbed)", // muted blue-grey — pre-asleep + [NAP_KEY]: "var(--dracula-orange)", // daytime sleep, not a night stage }; /** @@ -139,6 +189,48 @@ function formatDayTick( }); } +/** One Recharts row: the day's label plus a numeric key per drawn band. */ +export type CompositionRow = Record; + +/** + * Turn the trailing window of nights into the chart's rows. + * + * A day without a nap gets a plain `NAP: 0`, which draws as nothing. Pulled + * out of the component because Recharts renders no markup under SSR, so this + * is the only place the nap decision can actually be pinned by a test. + */ +export function buildCompositionRows( + perNight: readonly SleepStageNight[], + windowDays: number, + formatLabel: (dayKey: string) => string, +): { rows: CompositionRow[] } { + const trailing = perNight.slice(-windowDays); + const rows = trailing.map((night) => { + const row: CompositionRow = { + dayKey: night.dayKey, + label: formatLabel(night.dayKey), + }; + for (const stage of STAGE_ORDER) { + row[stage] = night.stages[stage] ?? 0; + } + row[NAP_KEY] = night.napMinutes ?? 0; + row.napCount = night.napCount ?? 0; + return row; + }); + return { rows }; +} + +/** + * Whether the visible window holds a nap at all. When it does not, the nap + * band is never mounted — so there is no bar, no legend entry, and no tooltip + * row saying a nap did not happen. + */ +export function windowHasNap(rows: readonly CompositionRow[]): boolean { + return rows.some( + (row) => typeof row[NAP_KEY] === "number" && (row[NAP_KEY] as number) > 0, + ); +} + export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { const { t, locale } = useTranslations(); @@ -155,6 +247,7 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { ASLEEP: t("insights.sleep.stages.asleep"), AWAKE: t("insights.sleep.stages.awake"), IN_BED: t("insights.sleep.stages.inBed"), + [NAP_KEY]: t("insights.sleep.stages.nap"), }; // v1.4.25 W3f — per-night dataset. Slice the trailing N nights and @@ -176,19 +269,14 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { } return [fallbackRow]; } - const trailing = perNight.slice(-windowDays); - return trailing.map((night) => { - const row: Record = { - dayKey: night.dayKey, - label: formatDayTick(night.dayKey, windowDays, locale), - }; - for (const stage of STAGE_ORDER) { - row[stage] = night.stages[stage] ?? 0; - } - return row; - }); + return buildCompositionRows(perNight, windowDays, (dayKey) => + formatDayTick(dayKey, windowDays, locale), + ).rows; }, [breakdown, windowDays, locale, t]); + // Mount the nap band only when the VISIBLE window holds one. + const hasNap = useMemo(() => windowHasNap(data), [data]); + // Empty-state guard — no perNight rows AND no aggregate. const hasData = data.length > 0 && @@ -287,10 +375,19 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { content={({ active, payload, label }) => { if (!active || !payload || payload.length === 0) return null; + const row = payload[0]?.payload as + Record | undefined; + const napCount = + typeof row?.napCount === "number" ? row.napCount : 0; + // The footer total is the NIGHT. The nap is listed above + // it as its own line and stays out of the sum, so the + // per-stage percentages keep meaning "of this night". const totalNight = payload.reduce( (sum, entry) => - sum + - (typeof entry.value === "number" ? entry.value : 0), + entry.dataKey === NAP_KEY + ? sum + : sum + + (typeof entry.value === "number" ? entry.value : 0), 0, ); return ( @@ -303,6 +400,14 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { const minutes = typeof entry.value === "number" ? entry.value : 0; if (minutes === 0) return null; + const isNap = stage === NAP_KEY; + const label = isNap + ? napCount > 1 + ? t("insights.sleep.stages.napCount", { + count: String(napCount), + }) + : stageLabels[NAP_KEY] + : (stageLabels[stage] ?? stage); const pct = totalNight > 0 ? Math.round((minutes / totalNight) * 100) @@ -318,10 +423,14 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { className="inline-block h-2 w-2 rounded-sm" style={{ background: STAGE_COLORS[stage] }} /> - {stageLabels[stage] ?? stage} + {label} - {formatDurationMinutes(minutes, t)} · {pct}% + {/* The nap is not part of the night, so it + gets no share-of-night percentage. */} + {isNap + ? formatDurationMinutes(minutes, t) + : `${formatDurationMinutes(minutes, t)} · ${pct}%`} ); @@ -351,6 +460,26 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { ))} + {hasNap && ( + + + + )} diff --git a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx index cd101028d..3db90c565 100644 --- a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx +++ b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx @@ -19,7 +19,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { CalendarClock, CheckCircle2, Plus } from "lucide-react"; -import { useTranslations } from "@/lib/i18n/context"; +import { useTranslations, useDisplayTimezone } from "@/lib/i18n/context"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -28,40 +28,15 @@ import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Skeleton } from "@/components/ui/skeleton"; import { MeasurementForm } from "@/components/measurements/measurement-form"; import { isScreeningReminderType } from "@/lib/validations/measurement-reminders"; +import { relativeDueKey } from "@/lib/measurement-reminders/due-day"; import { useMeasurementReminders, useMeasurementReminderMutations, type MeasurementReminder, } from "@/hooks/use-measurement-reminders"; -const DAY_MS = 24 * 60 * 60 * 1000; const SUMMARY_LIMIT = 3; -/** - * v1.32.36 — returns a FULLY-QUALIFIED i18n key, not a bare tail. A call site - * that interpolates the tail onto the namespace root anchors the - * reverse-coverage guard at that bare namespace, which marks every key under - * it reachable and turns the guard into a no-op for the whole namespace. The - * guard now refuses that shape outright, so the key is built whole here. - */ -function relativeDueKey( - nextDueAt: string | null, - now: number, -): { key: string; days: number } { - if (!nextDueAt) return { key: "measurementReminders.nextDue.none", days: 0 }; - const deltaDays = Math.round((new Date(nextDueAt).getTime() - now) / DAY_MS); - if (deltaDays < 0) - return { - key: "measurementReminders.overdueByDays", - days: Math.abs(deltaDays), - }; - if (deltaDays === 0) - return { key: "measurementReminders.nextDue.today", days: 0 }; - if (deltaDays === 1) - return { key: "measurementReminders.nextDue.tomorrow", days: 1 }; - return { key: "measurementReminders.nextDue.inDays", days: deltaDays }; -} - function resolveLabel( reminder: MeasurementReminder, t: (key: string) => string, @@ -75,6 +50,9 @@ function resolveLabel( export function VorsorgeDashboardCard() { const { t } = useTranslations(); + // Issue #490 — the same profile zone the checkups page derives its due + // phrase in, so the two screens still agree when the device sits elsewhere. + const displayTz = useDisplayTimezone(); const router = useRouter(); const { data: reminders, isLoading } = useMeasurementReminders(); const { satisfy } = useMeasurementReminderMutations(); @@ -137,7 +115,7 @@ export function VorsorgeDashboardCard() { ) : (
    {upcoming.map((reminder) => { - const due = relativeDueKey(reminder.nextDueAt, now); + const due = relativeDueKey(reminder.nextDueAt, now, displayTz); const isLinked = reminder.measurementType != null; const isScreening = isScreeningReminderType( reminder.measurementType, diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index 5a1d697a7..941f66f29 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -46,7 +46,7 @@ import { useDisplayTimezone, } from "@/lib/i18n/context"; import { relativeCalendarDate } from "@/lib/i18n/relative-time"; -import { startOfLocalDayInTz } from "@/lib/tz/local-day"; +import { relativeDueKey } from "@/lib/measurement-reminders/due-day"; import { cn } from "@/lib/utils"; import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs"; import { SettingsCardHeader } from "@/components/settings/_card-header"; @@ -143,42 +143,6 @@ const CADENCE_CUSTOM = "custom"; const CADENCE_RRULE = "rrule"; const DAY_MS = 24 * 60 * 60 * 1000; -/** - * v1.32.36 — returns a FULLY-QUALIFIED i18n key, not a bare tail. A call site - * that interpolates the tail onto the namespace root anchors the - * reverse-coverage guard at that bare namespace, which marks every key under - * it reachable and turns the guard into a no-op for the whole namespace. The - * guard now refuses that shape outright, so the key is built whole here. - */ -function relativeDueKey( - nextDueAt: string | null, - now: number, -): { key: string; days: number } { - if (!nextDueAt) return { key: "measurementReminders.nextDue.none", days: 0 }; - const due = new Date(nextDueAt); - if (Number.isNaN(due.getTime())) - return { key: "measurementReminders.nextDue.none", days: 0 }; - // v1.18.9 (recon) — compare CALENDAR-day deltas in the user's local zone, - // not a rolling-24h delta. A reminder due at 09:00 viewed the same evening - // at 20:00 must still read "heute", and one whose due calendar-day is before - // today must read "überfällig" — a raw `(due - now) / DAY_MS` round would - // mis-bucket both. Floor each instant to its local day start (host-local zone - // = the browser user's zone for this client component) before differencing. - const dueDay = startOfLocalDayInTz(due, undefined).getTime(); - const nowDay = startOfLocalDayInTz(new Date(now), undefined).getTime(); - const deltaDays = Math.round((dueDay - nowDay) / DAY_MS); - if (deltaDays < 0) - return { - key: "measurementReminders.overdueByDays", - days: Math.abs(deltaDays), - }; - if (deltaDays === 0) - return { key: "measurementReminders.nextDue.today", days: 0 }; - if (deltaDays === 1) - return { key: "measurementReminders.nextDue.tomorrow", days: 1 }; - return { key: "measurementReminders.nextDue.inDays", days: deltaDays }; -} - /** * v1.18.1 — a COACH-minted reminder stores an i18n KEY in `label` (the * cadence preset's `labelKey`), not free prose. Resolve it through `t()`; @@ -825,9 +789,12 @@ function VorsorgeCard({ const fmt = useFormatters(); // Issue #490 — day-boundary zone for the relative "today / yesterday" // bucket must match the zone `fmt.date` renders in (mirror → Berlin). + // The next-due phrase reads off the SAME zone as the last-done date below + // it; they used to disagree by a day for anyone whose device sat in another + // zone from their profile. const displayTz = useDisplayTimezone(); const [confirmDelete, setConfirmDelete] = useState(false); - const due = relativeDueKey(reminder.nextDueAt, now); + const due = relativeDueKey(reminder.nextDueAt, now, displayTz); const cadence = reminder.intervalDays != null ? t("measurementReminders.cadence.everyNDays", { diff --git a/src/components/onboarding/__tests__/baseline-form-rejected-fields.test.ts b/src/components/onboarding/__tests__/baseline-form-rejected-fields.test.ts new file mode 100644 index 000000000..198ed664f --- /dev/null +++ b/src/components/onboarding/__tests__/baseline-form-rejected-fields.test.ts @@ -0,0 +1,186 @@ +/** + * The onboarding baseline step used to submit the profile and throw the + * server's answer away. A height the server declined vanished between + * the input and the next screen, and the person walked off believing it + * was stored. + * + * These tests run the real assembly end to end — build the body, PUT it + * over a stubbed `fetch`, read the reply — because the gap was never in + * either end on its own. They use the real English and German bundles, + * so a sentence that reads as a key or leaks validator prose fails here. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { getCachedMessages } from "@/lib/i18n/load-locale"; +import { resolveKey } from "@/lib/i18n/resolve-key"; +import { + baselineFieldLabelKeys, + buildBaselineProfileBody, + describeBaselineSaveOutcome, + putBaselineProfile, +} from "../baseline-form-utils"; + +/** A `t` backed by the shipped bundle — no stand-in strings. */ +function translator(locale: "en" | "de") { + const bundle = getCachedMessages(locale); + if (!bundle) throw new Error(`No cached bundle for ${locale}`); + return (key: string, params?: Record) => { + const raw = resolveKey(bundle as Record, key); + if (raw === undefined) return key; + return Object.entries(params ?? {}).reduce( + (acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)), + raw, + ); + }; +} + +const FILLED_FORM = { + displayName: "Robin", + dateOfBirth: "1988-04-02", + gender: "OTHER", +}; + +/** Stub `fetch` with one canned response and capture what was sent. */ +function stubProfilePut(status: number, body: unknown) { + const calls: Array<{ url: string; init: RequestInit }> = []; + vi.stubGlobal("fetch", (url: string, init: RequestInit) => { + calls.push({ url, init }); + return Promise.resolve( + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }), + ); + }); + return calls; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("onboarding baseline profile save", () => { + it("names the rejected field and still lets the accepted ones stand", async () => { + const calls = stubProfilePut(200, { + data: { + id: "u1", + displayName: "Robin", + rejectedFields: [{ path: "heightCm", code: "too_big" }], + }, + error: null, + }); + + const body = buildBaselineProfileBody(FILLED_FORM, 940); + const outcome = describeBaselineSaveOutcome( + await putBaselineProfile(body), + translator("en"), + baselineFieldLabelKeys(false), + ); + + // Everything the person filled in travelled to the server; nothing + // was withheld because one sibling was going to be refused. + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe("/api/auth/profile"); + expect(JSON.parse(String(calls[0].init.body))).toEqual({ + displayName: "Robin", + heightCm: 940, + dateOfBirth: "1988-04-02", + gender: "OTHER", + }); + + // The accepted fields are saved and the wizard moves on... + expect(outcome.advance).toBe(true); + // ...but the declined one is said out loud, by the name this screen + // put next to the input. + expect(outcome.notice).toEqual({ + tone: "warning", + message: "Profile saved, but Height (cm) could not be updated.", + }); + }); + + it("says nothing when every field was accepted", async () => { + stubProfilePut(200, { data: { id: "u1" }, error: null }); + + const outcome = describeBaselineSaveOutcome( + await putBaselineProfile(buildBaselineProfileBody(FILLED_FORM, 178)), + translator("en"), + baselineFieldLabelKeys(false), + ); + + expect(outcome).toEqual({ advance: true, notice: null }); + }); + + it("names the blocking field and holds the step when nothing was saved", async () => { + stubProfilePut(422, { + data: null, + error: 'Nothing was saved. Fix the "dateOfBirth" field and try again.', + meta: { errorCode: "profile.update.nothingSaved" }, + details: { issues: [{ path: "dateOfBirth", code: "invalid_value" }] }, + }); + + const outcome = describeBaselineSaveOutcome( + await putBaselineProfile(buildBaselineProfileBody(FILLED_FORM, null)), + translator("en"), + baselineFieldLabelKeys(false), + ); + + expect(outcome.advance).toBe(false); + expect(outcome.notice?.tone).toBe("error"); + expect(outcome.notice?.message).toBe( + "Nothing was saved. Fix Date of birth and try again.", + ); + // The server's own sentence carries a schema key in quotes; it must + // not be what the person reads. + expect(outcome.notice?.message).not.toContain("dateOfBirth"); + }); + + it("speaks the person's language, not the validator's", async () => { + stubProfilePut(200, { + data: { rejectedFields: [{ path: "gender", code: "invalid_value" }] }, + error: null, + }); + + const outcome = describeBaselineSaveOutcome( + await putBaselineProfile(buildBaselineProfileBody(FILLED_FORM, 178)), + translator("de"), + baselineFieldLabelKeys(false), + ); + + const de = translator("de"); + expect(outcome.notice?.message).toBe( + de("settings.profilePartiallySaved", { + field: de("onboarding.baseline.genderLabel"), + }), + ); + expect(outcome.notice?.message).not.toContain("settings."); + expect(outcome.notice?.message).not.toContain("invalid_value"); + }); + + it("uses the feet-and-inches label when that is what the person read", () => { + const outcome = describeBaselineSaveOutcome( + { + ok: true, + body: { data: { rejectedFields: [{ path: "heightCm", code: "x" }] } }, + }, + translator("en"), + baselineFieldLabelKeys(true), + ); + expect(outcome.notice?.message).toContain("Height (ft, in)"); + }); + + it("falls back to the step's own sentence for a failure with no field detail", async () => { + stubProfilePut(500, { data: null, error: "boom" }); + + const outcome = describeBaselineSaveOutcome( + await putBaselineProfile(buildBaselineProfileBody(FILLED_FORM, 178)), + translator("en"), + baselineFieldLabelKeys(false), + ); + + expect(outcome.advance).toBe(false); + expect(outcome.notice?.message).toBe( + "Something went wrong while finishing setup. Please try again.", + ); + expect(outcome.notice?.message).not.toContain("boom"); + }); +}); diff --git a/src/components/onboarding/baseline-form-utils.ts b/src/components/onboarding/baseline-form-utils.ts new file mode 100644 index 000000000..634d5ffc9 --- /dev/null +++ b/src/components/onboarding/baseline-form-utils.ts @@ -0,0 +1,173 @@ +import { apiFetchRaw } from "@/lib/api/api-fetch"; +import { + describeRejectedProfileField, + type RejectedProfileField, +} from "@/lib/profile/rejected-fields"; +import { toProfileSex } from "@/lib/profile/sex"; + +/** + * Reading the answer to the onboarding baseline step's profile write. + * + * `PUT /api/auth/profile` is field-independent: it writes everything + * that validated and reports the rest back rather than throwing the + * whole submission away. Until now this step ignored that report, so a + * height the server declined disappeared between the input and the next + * screen with nothing said, and the person walked away believing it was + * stored. + * + * The settings account form already answers this question (see + * `handleSaveProfile` in `src/components/settings/account-section`): + * name the field in the person's own words, keep validator prose off + * the screen, and only claim a clean save when there is one. The two + * screens share the field-naming helper and the two sentences, so the + * same rejection reads the same wherever it happens; only the labels + * differ, because each screen calls the fields what its own inputs + * call them. + */ + +/** The parts of the profile response this step reads. */ +export interface BaselineProfileResponse { + ok: boolean; + /** Parsed envelope, or `null` when the body was absent or unreadable. */ + body: { + data?: { rejectedFields?: RejectedProfileField[] } | null; + meta?: { errorCode?: string } | null; + details?: { issues?: RejectedProfileField[] } | null; + } | null; +} + +export interface BaselineSaveOutcome { + /** Whether the wizard may move on to the next step. */ + advance: boolean; + /** A ready-to-show sentence, already localized. Null on a clean save. */ + notice: { tone: "warning" | "error"; message: string } | null; +} + +/** + * Collect the fields the person actually filled in. An untouched field + * is left out entirely rather than sent as an empty value, so skipping + * the step never clears something already stored. Height arrives + * already converted to canonical centimetres — the wire stays cm + * whatever unit the control was showing. + */ +export function buildBaselineProfileBody( + form: { displayName: string; dateOfBirth: string; gender: string }, + heightCm: number | null, +): Record { + const body: Record = {}; + if (form.displayName.trim()) body.displayName = form.displayName.trim(); + if (heightCm !== null) body.heightCm = heightCm; + if (form.dateOfBirth) body.dateOfBirth = form.dateOfBirth; + // Every value the profile stores travels; the guard drops only the + // "prefer not to say" placeholder. It used to list two of the three + // by hand, so a third-value answer vanished on submit without a word. + const gender = toProfileSex(form.gender); + if (gender) body.gender = gender; + return body; +} + +/** + * Write the collected fields and hand back what the server said. + * + * A raw fetch rather than `apiPut`, because the answer this step needs + * lives in the body on both sides of the `ok` line: `rejectedFields` on + * a partial success, `details.issues` when nothing landed. The throwing + * wrapper would collapse both into one opaque failure. + */ +export async function putBaselineProfile( + body: Record, +): Promise { + const res = await apiFetchRaw("/api/auth/profile", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const parsed = (await res.json().catch(() => null)) as + BaselineProfileResponse["body"] | null; + return { ok: res.ok, body: parsed }; +} + +/** + * The labels the baseline step puts next to its own inputs, keyed by the + * schema field the server rejects. Height carries two labels because the + * control follows the unit preference, so the caller passes the one the + * person actually read. + */ +export function baselineFieldLabelKeys(usesFeetInches: boolean) { + return { + displayName: "onboarding.baseline.displayNameLabel", + heightCm: usesFeetInches + ? "onboarding.baseline.heightLabelFtIn" + : "onboarding.baseline.heightLabel", + dateOfBirth: "onboarding.baseline.dateOfBirthLabel", + gender: "onboarding.baseline.genderLabel", + }; +} + +/** + * Decide what the person is told and whether the wizard continues. + * + * - Some fields declined, the rest written: say which one did not make + * it and carry on. The accepted values are already stored; stopping + * here would only make the person re-enter what already saved. + * - Nothing written: name the field that blocked it and stay put, so + * there is something specific to fix instead of a shrug. + * - Any other failure: fall back to the step's own generic sentence. + * The server's `error` string is never shown — the stable + * `meta.errorCode` is resolved through the locale catalog instead, so + * no validator wording can reach the screen. + */ +export function describeBaselineSaveOutcome( + res: BaselineProfileResponse, + t: (key: string, params?: Record) => string, + labelKeys: Record, +): BaselineSaveOutcome { + if (res.ok) { + const rejected = res.body?.data?.rejectedFields; + const field = describeRejectedProfileField(rejected, t, labelKeys); + if (!field) return { advance: true, notice: null }; + return { + advance: true, + notice: { + tone: "warning", + message: t("settings.profilePartiallySaved", { field }), + }, + }; + } + + const errorCode = res.body?.meta?.errorCode; + const blockingField = describeRejectedProfileField( + res.body?.details?.issues, + t, + labelKeys, + ); + + if (errorCode === "profile.update.nothingSaved" && blockingField) { + return { + advance: false, + notice: { + tone: "error", + message: t("settings.profileNothingSaved", { field: blockingField }), + }, + }; + } + + return { + advance: false, + notice: { tone: "error", message: localizedCode(errorCode, t) }, + }; +} + +/** Resolve a stable error code through the catalog, or fall back to the + * step's own sentence when the code is absent or has no entry. */ +function localizedCode( + errorCode: string | undefined, + t: (key: string, params?: Record) => string, +): string { + if (errorCode) { + const key = `apiErrors.${errorCode}`; + const translated = t(key); + if (translated !== key) return translated; + } + return t("onboarding.errorGeneric"); +} diff --git a/src/components/onboarding/baseline-form.tsx b/src/components/onboarding/baseline-form.tsx index f905c1ca1..6ab2f7a17 100644 --- a/src/components/onboarding/baseline-form.tsx +++ b/src/components/onboarding/baseline-form.tsx @@ -29,12 +29,17 @@ import { useTranslations } from "@/lib/i18n/context"; import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch"; import { localizedApiError } from "@/lib/api/localized-error"; import { queryKeys } from "@/lib/query-keys"; -import { toProfileSex } from "@/lib/profile/sex"; import { AnamnesisCard, buildAnamnesisAboutMeBody, type AnamnesisValue, } from "@/components/onboarding/anamnesis-card"; +import { + baselineFieldLabelKeys, + buildBaselineProfileBody, + describeBaselineSaveOutcome, + putBaselineProfile, +} from "@/components/onboarding/baseline-form-utils"; /** * v1.4.25 W14b-Content — onboarding step 3 (baseline). @@ -49,7 +54,12 @@ import { * path (see `applyProfileUpdate` in `src/lib/auth/profile-update.ts`). * * Submit flow on "Save and continue": - * 1. PUT profile (best-effort — empty fields are skipped). + * 1. PUT profile (empty fields are skipped). The write is + * field-independent, so the answer is read rather than assumed: + * a field the server declined is named on screen and the rest + * still counts as saved; a submission where nothing landed keeps + * the person on this step with the blocking field named. See + * `baseline-form-utils.ts`. * 2. PUT /api/coach/about-me — only when the optional anamnesis card * (v1.17.1) was filled; preserves any existing `aboutMe` and * writes conditions / allergies encrypted at rest. @@ -134,19 +144,28 @@ export function BaselineForm() { setSaving(true); try { if (opts.saveProfile) { - const profileBody: Record = {}; - if (form.displayName.trim()) - profileBody.displayName = form.displayName.trim(); - const heightCm = heightAdapter.toCanonicalCm(form.height); - if (heightCm !== null) profileBody.heightCm = heightCm; - if (form.dateOfBirth) profileBody.dateOfBirth = form.dateOfBirth; - // Every value the profile stores travels; the guard drops only the - // "prefer not to say" placeholder. It used to list two of the three - // by hand, so a third-value answer vanished on submit without a word. - const gender = toProfileSex(form.gender); - if (gender) profileBody.gender = gender; + const profileBody = buildBaselineProfileBody( + form, + heightAdapter.toCanonicalCm(form.height), + ); if (Object.keys(profileBody).length > 0) { - await apiPut("/api/auth/profile", profileBody); + const outcome = describeBaselineSaveOutcome( + await putBaselineProfile(profileBody), + t, + baselineFieldLabelKeys(heightAdapter.usesFeetInches), + ); + if (outcome.notice) { + const show = + outcome.notice.tone === "warning" ? toast.warning : toast.error; + show(outcome.notice.message); + } + if (!outcome.advance) { + // Nothing was written. Staying on the step is the point — + // the person has a named field to fix and the values they + // typed are still in front of them. + setSaving(false); + return; + } } // Anamnesis — only write when the user actually filled a field diff --git a/src/components/settings/__tests__/modules-section.test.tsx b/src/components/settings/__tests__/modules-section.test.tsx index c30bad4bd..4003760d1 100644 --- a/src/components/settings/__tests__/modules-section.test.tsx +++ b/src/components/settings/__tests__/modules-section.test.tsx @@ -74,7 +74,6 @@ const ALL_ON_FLAGS: AssistantFlagSet = { briefing: true, insightStatus: true, correlations: true, - healthScoreExplainer: true, }; const flagsSpy = vi.fn<() => AssistantFlagSet>(() => ALL_ON_FLAGS); vi.mock("@/hooks/use-feature-flags", () => ({ diff --git a/src/components/settings/account-section/__tests__/account-section-utils.test.ts b/src/components/settings/account-section/__tests__/account-section-utils.test.ts index d1ef5b814..304d46848 100644 --- a/src/components/settings/account-section/__tests__/account-section-utils.test.ts +++ b/src/components/settings/account-section/__tests__/account-section-utils.test.ts @@ -1,8 +1,6 @@ import { describe, it, expect } from "vitest"; -import { - describeRejectedProfileField, - resolveInitialTimezone, -} from "../account-section-utils"; +import { describeRejectedProfileField } from "@/lib/profile/rejected-fields"; +import { resolveInitialTimezone } from "../account-section-utils"; describe("describeRejectedProfileField", () => { const t = (key: string) => `translated:${key}`; diff --git a/src/components/settings/account-section/account-section-utils.ts b/src/components/settings/account-section/account-section-utils.ts index a2a455267..d37ab9683 100644 --- a/src/components/settings/account-section/account-section-utils.ts +++ b/src/components/settings/account-section/account-section-utils.ts @@ -61,44 +61,9 @@ export function statusText( return "key" in msg ? t(msg.key, msg.params) : msg.text; } -/** A single rejected-field entry from `applyProfileUpdate`'s wire shape - * (`details.issues` on failure, `data.rejectedFields` on a partial - * success) — `path` is the schema field name, never shown verbatim. */ -export interface RejectedProfileField { - path: string; - code: string; - message?: string; -} - /** - * Maps a rejected field's schema `path` to the same i18n label already - * shown next to that input on this screen. The server only ever knows - * the field by its schema key (`heightCm`, `gender`, ...) — naming it - * in a person-facing sentence is the client's job, using labels that - * already exist and are already localized for this form. + * Naming a rejected profile field is shared with the onboarding + * baseline step — see `src/lib/profile/rejected-fields.ts`. This screen + * uses the default label map, which points at the labels these inputs + * already carry. */ -const PROFILE_FIELD_LABEL_KEYS: Record = { - email: "auth.email", - heightCm: "settings.height", - dateOfBirth: "settings.dateOfBirth", - gender: "settings.gender", - fullName: "settings.identity.fullName", - insurerName: "settings.identity.insurer", - insuranceNumber: "settings.identity.insuranceNumber", -}; - -/** - * Renders the first rejected field's label, falling back to its raw - * schema key for a field this screen has no input for (defensive — - * every field `applyProfileUpdate` accepts from this form is mapped - * above). - */ -export function describeRejectedProfileField( - fields: RejectedProfileField[] | undefined, - t: (key: string) => string, -): string | null { - const first = fields?.[0]; - if (!first) return null; - const labelKey = PROFILE_FIELD_LABEL_KEYS[first.path]; - return labelKey ? t(labelKey) : first.path; -} diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx index 4caf5bbb5..4cb6e4f89 100644 --- a/src/components/settings/account-section/index.tsx +++ b/src/components/settings/account-section/index.tsx @@ -39,9 +39,11 @@ import { detectBrowserTimezone } from "@/lib/tz/format"; import { apiFetchRaw } from "@/lib/api/api-fetch"; import { describeRejectedProfileField, + type RejectedProfileField, +} from "@/lib/profile/rejected-fields"; +import { resolveInitialTimezone, statusText, - type RejectedProfileField, type StatusMessage, } from "./account-section-utils"; import { AvatarSection } from "./avatar-section"; diff --git a/src/hooks/__tests__/use-feature-flags.test.tsx b/src/hooks/__tests__/use-feature-flags.test.tsx index 2c7cae7d3..329e23088 100644 --- a/src/hooks/__tests__/use-feature-flags.test.tsx +++ b/src/hooks/__tests__/use-feature-flags.test.tsx @@ -54,7 +54,6 @@ describe("useFeatureFlags", () => { briefing: true, insightStatus: false, correlations: true, - healthScoreExplainer: false, }, }, }), @@ -75,7 +74,6 @@ describe("useFeatureFlags", () => { briefing: true, insightStatus: false, correlations: true, - healthScoreExplainer: false, }, }); @@ -86,6 +84,5 @@ describe("useFeatureFlags", () => { ); expect(html).toContain(""coach":false"); expect(html).toContain(""insightStatus":false"); - expect(html).toContain(""healthScoreExplainer":false"); }); }); diff --git a/src/hooks/use-feature-flags.ts b/src/hooks/use-feature-flags.ts index 7606c79f0..68fe8451b 100644 --- a/src/hooks/use-feature-flags.ts +++ b/src/hooks/use-feature-flags.ts @@ -13,7 +13,7 @@ import { useQueryClientMounted } from "@/hooks/_internal/use-query-client-safe"; * * The matrix gates every assistant-driven surface on the web — * Coach + chat, Daily Briefing, per-metric status cards, correlation - * narration, Health-Score delta explainer. iOS reads the same + * narration. iOS reads the same * endpoint and uses the same matrix; the contract is locked per * `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5. */ @@ -29,8 +29,6 @@ export interface AssistantFlagSet { insightStatus: boolean; /** Correlation narration tile on the mother page. */ correlations: boolean; - /** `?` glyph that opens the Health-Score delta explainer popover. */ - healthScoreExplainer: boolean; } interface FeatureFlagsPayload { @@ -44,7 +42,6 @@ export const DEFAULT_ASSISTANT_FLAGS: AssistantFlagSet = Object.freeze({ briefing: true, insightStatus: true, correlations: true, - healthScoreExplainer: true, }); async function fetchFeatureFlags(): Promise { diff --git a/src/lib/analytics/__tests__/sleep-stage-composition.test.ts b/src/lib/analytics/__tests__/sleep-stage-composition.test.ts new file mode 100644 index 000000000..bb6db6fb2 --- /dev/null +++ b/src/lib/analytics/__tests__/sleep-stage-composition.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +// The last test in this file drives the REAL `GET /api/sleep/night` handler so +// the two surfaces can be compared over one input rather than over one fixture +// used twice. The mocks below are the minimum that route needs. +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { findMany: vi.fn() }, + auditLog: { create: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/tz/resolver", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, resolveUserTimezone: vi.fn(async () => "UTC") }; +}); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/rollups/measurement-read", () => ({ + loadUserSourcePriority: vi.fn(async () => null), +})); + +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), +})); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/context", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, annotate: vi.fn() }; +}); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET as sleepNightGET } from "@/app/api/sleep/night/route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { buildSleepStageComposition } from "@/lib/analytics/sleep-stage-composition"; +import { + reconstructSleepNights, + type SleepStageRow, +} from "@/lib/analytics/sleep-night"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "testuser", role: "USER" as const }, +}; + +function row(iso: string, stage: string, minutes: number): SleepStageRow { + return { + measuredAt: new Date(iso), + sleepStage: stage as SleepStageRow["sleepStage"], + value: minutes, + source: "APPLE_HEALTH" as SleepStageRow["source"], + }; +} + +/** + * One overnight block: 23:00 (Jun 3) → 07:00 (Jun 4) UTC, written as the + * per-stage rows Apple Health produces. Wake day = 2026-06-04. + * + * CORE 240 23:00 → 03:00 + * DEEP 90 03:00 → 04:30 + * REM 80 04:30 → 05:50 + * AWAKE 20 05:50 → 06:10 + * CORE 50 06:10 → 07:00 + * + * Asleep = 460, CORE = 290, in bed = 480. + */ +const NIGHT_ROWS: SleepStageRow[] = [ + row("2026-06-04T07:00:00.000Z", "IN_BED", 480), + row("2026-06-04T03:00:00.000Z", "CORE", 240), + row("2026-06-04T04:30:00.000Z", "DEEP", 90), + row("2026-06-04T05:50:00.000Z", "REM", 80), + row("2026-06-04T06:10:00.000Z", "AWAKE", 20), + row("2026-06-04T07:00:00.000Z", "CORE", 50), +]; + +/** + * A 110-minute afternoon nap on the SAME wake day: 14:00 → 15:50 UTC. Seven + * hours clear of the night's 07:00 wake, so it clusters as its own session. + * + * Deliberately large and deliberately CORE — folded into the night it moves + * that night's CORE from 290 to 400, which no rounding or tolerance can hide. + */ +const NAP_ROWS: SleepStageRow[] = [ + row("2026-06-04T15:50:00.000Z", "CORE", 110), +]; + +const DAY = "2026-06-04"; + +describe("buildSleepStageComposition", () => { + it("keeps an afternoon nap out of the night's stage composition", () => { + const rows = [...NIGHT_ROWS, ...NAP_ROWS]; + + // What the per-wake-day totals say — the shape this surface used to + // publish. The nap's CORE is inside the night. + const folded = reconstructSleepNights(rows, "UTC", null); + expect(folded).toHaveLength(1); + expect(folded[0].stages.CORE).toBe(400); + + const composition = buildSleepStageComposition(rows, "UTC", null, 30); + expect(composition).not.toBeNull(); + expect(composition!.perNight).toHaveLength(1); + + const night = composition!.perNight[0]; + expect(night.dayKey).toBe(DAY); + // The night's own CORE, not the night plus the nap. + expect(night.stages.CORE).toBe(290); + expect(night.stages.DEEP).toBe(90); + expect(night.stages.REM).toBe(80); + expect(night.stages.AWAKE).toBe(20); + // The nap is reported, separately, at its full length. + expect(night.napMinutes).toBe(110); + expect(night.napCount).toBe(1); + + // The window aggregate follows the nights, so it is not inflated either. + expect(composition!.stages.CORE).toBe(290); + expect(composition!.nights).toBe(1); + }); + + it("leaves a night without a nap exactly as it was", () => { + // `reconstructSleepNights` is the path this surface used before the + // split. On a day with no nap the new path must reproduce it exactly. + const before = reconstructSleepNights(NIGHT_ROWS, "UTC", null); + expect(before).toHaveLength(1); + + const composition = buildSleepStageComposition(NIGHT_ROWS, "UTC", null, 30); + expect(composition).not.toBeNull(); + expect(composition!.perNight).toHaveLength(1); + + const night = composition!.perNight[0]; + expect(night.dayKey).toBe(before[0].night); + expect(night.stages).toEqual(before[0].stages); + expect(composition!.stages).toEqual(before[0].stages); + expect(composition!.totalMinutes).toBe( + Object.values(before[0].stages).reduce((a, b) => a + (b ?? 0), 0), + ); + + // No nap means no nap fields at all — nothing for the chart to draw and + // nothing to caption. + expect(night).not.toHaveProperty("napMinutes"); + expect(night).not.toHaveProperty("napCount"); + }); +}); + +describe("night view and stage composition agree on which session is the night", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + }); + + it("publishes the same night and the same nap total over one input", async () => { + const rows = [...NIGHT_ROWS, ...NAP_ROWS]; + vi.mocked(prisma.measurement.findMany).mockResolvedValue(rows as never); + + // Surface 1 — the real hypnogram route, which has always known the + // difference between a night and a nap. + const res = await sleepNightGET( + new NextRequest(`http://localhost/api/sleep/night?date=${DAY}`), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + night: string; + main: { stages: Record; asleepMinutes: number }; + naps: Array<{ asleepMinutes: number }>; + }; + }; + + // Surface 2 — the stage composition, over the very same rows. + const composition = buildSleepStageComposition(rows, "UTC", null, 30); + expect(composition).not.toBeNull(); + const night = composition!.perNight.find( + (n) => n.dayKey === body.data.night, + ); + expect(night).toBeDefined(); + + // The composition's night IS the route's main session: same wake day, + // same per-stage map, minute for minute. + const routeStages = body.data.main.stages; + const compositionStages = Object.fromEntries( + Object.entries(night!.stages).map(([stage, minutes]) => [ + stage, + Math.round(minutes), + ]), + ); + expect(compositionStages).toEqual(routeStages); + + // And the nap the composition reports is the nap the route reports — + // the same count, and the same minutes the route publishes for it. + expect(body.data.naps).toHaveLength(1); + expect(night!.napCount).toBe(body.data.naps.length); + expect(night!.napMinutes).toBe( + body.data.naps.reduce((sum, nap) => sum + nap.asleepMinutes, 0), + ); + + // Guard against both surfaces agreeing on a wrong answer: the nap is + // real, large, and outside the night on both sides. + expect(body.data.naps[0].asleepMinutes).toBe(110); + expect(compositionStages.CORE).toBe(290); + }); +}); diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts index a1c41d7b4..3d5997189 100644 --- a/src/lib/analytics/sleep-night.ts +++ b/src/lib/analytics/sleep-night.ts @@ -1117,6 +1117,46 @@ export function pickMainNightAndNaps(sessions: readonly SleepSession[]): { return { main, naps }; } +/** One wake day resolved into its main night and that day's naps. */ +export interface SleepDaySplit { + /** Wake-day key (YYYY-MM-DD) in the user's timezone. */ + night: string; + /** The overnight block — the session with the most asleep minutes. */ + main: SleepSession; + /** Every other scorable session on the same wake day, sorted by start. */ + naps: SleepSession[]; +} + +/** + * Run the NAP convention across a whole span of sessions: group them by wake + * day and apply `pickMainNightAndNaps` to each day. + * + * This exists so every surface that needs "which session was the night?" over + * more than one day asks the SAME question the single-day surfaces ask, rather + * than growing its own idea of a night. There is exactly one nap classifier in + * this codebase and it is `pickMainNightAndNaps`; this is its multi-day form. + * + * Days whose sessions carry no asleep minutes are dropped (no main night to + * report). The result is sorted ascending by wake day. + */ +export function separateNightsAndNaps( + sessions: readonly SleepSession[], +): SleepDaySplit[] { + const byDay = new Map(); + for (const session of sessions) { + const list = byDay.get(session.night); + if (list) list.push(session); + else byDay.set(session.night, [session]); + } + const days: SleepDaySplit[] = []; + for (const [night, daySessions] of byDay) { + const { main, naps } = pickMainNightAndNaps(daySessions); + if (!main) continue; + days.push({ night, main, naps }); + } + return days.sort((a, b) => a.night.localeCompare(b.night)); +} + export interface SleepNightSummary { /** * A `DataSummary` whose every value is a per-night TIME-ASLEEP total diff --git a/src/lib/analytics/sleep-stage-composition.ts b/src/lib/analytics/sleep-stage-composition.ts new file mode 100644 index 000000000..bf9489172 --- /dev/null +++ b/src/lib/analytics/sleep-stage-composition.ts @@ -0,0 +1,103 @@ +/** + * Stage composition for the insights sleep chart — per-night stage minutes + * over a trailing window, with the day's naps kept OUT of the night. + * + * A nap ends on the same wake day as that morning's overnight block, so the + * per-wake-day totals that back the headline number fold the two together. + * That is right for "how much did I sleep on this day" and wrong for "what was + * my night made of": an 80-minute afternoon nap lands as CORE on top of the + * night and the column reads longer and shallower than the night actually was. + * + * So the composition is built from SESSIONS, split by the one nap classifier + * this codebase has (`pickMainNightAndNaps`, via `separateNightsAndNaps`). The + * night's stages are the main session's stages. The naps ride along as their + * own minutes on the same day, never inside the stage buckets. + * + * Pure — the caller does the bounded DB read. + */ +import { + reconstructSleepSessions, + separateNightsAndNaps, + type SleepStageRow, +} from "@/lib/analytics/sleep-night"; + +export interface SleepStageCompositionNight { + /** Wake-day key (YYYY-MM-DD) in the user's timezone. */ + dayKey: string; + /** + * Per-stage minutes of the MAIN night only. Keys mirror the Prisma + * `SleepStage` enum; `IN_BED` is the session envelope, carried as it was + * before so the chart's own stage order decides what it stacks. + */ + stages: Record; + /** + * Total time asleep across this day's naps, in minutes. Absent when the day + * has no nap — there is no zero to render and nothing to caption. + */ + napMinutes?: number; + /** How many naps that is. Absent alongside `napMinutes`. */ + napCount?: number; +} + +export interface SleepStageComposition { + windowDays: number; + /** Nights carrying at least one stage entry inside the window. */ + nights: number; + /** Sum of every value in `stages` — nights only, naps excluded. */ + totalMinutes: number; + /** Window totals per stage, naps excluded. */ + stages: Record; + /** One entry per night with stage data, sorted ascending by day. */ + perNight: SleepStageCompositionNight[]; +} + +/** + * Build the trailing-window stage composition from raw per-stage rows. + * + * Returns null when no session in the window carries stage data — the + * analytics consumer renders the plain duration totals without a stage card + * in that case. + */ +export function buildSleepStageComposition( + rows: SleepStageRow[], + tz: string, + priorityJson: unknown, + windowDays: number, +): SleepStageComposition | null { + const sessions = reconstructSleepSessions(rows, tz, priorityJson); + const days = separateNightsAndNaps(sessions); + if (days.length === 0) return null; + + const stages: Record = {}; + let totalMinutes = 0; + const perNight: SleepStageCompositionNight[] = []; + + for (const { night, main, naps } of days) { + const nightStages: Record = {}; + for (const [stage, mins] of Object.entries(main.stages)) { + if (mins == null) continue; + nightStages[stage] = mins; + stages[stage] = (stages[stage] ?? 0) + mins; + totalMinutes += mins; + } + if (Object.keys(nightStages).length === 0) continue; + // The nap total is the very `asleepMinutes` `/api/sleep/night` publishes + // for the same session, so the two surfaces report one number, not two. + const napMinutes = naps.reduce((sum, nap) => sum + nap.asleepMinutes, 0); + perNight.push({ + dayKey: night, + stages: nightStages, + ...(napMinutes > 0 ? { napMinutes, napCount: naps.length } : {}), + }); + } + + if (perNight.length === 0) return null; + + return { + windowDays, + nights: perNight.length, + totalMinutes, + stages, + perNight, + }; +} diff --git a/src/lib/cache/__tests__/server-cache-global-pin.test.ts b/src/lib/cache/__tests__/server-cache-global-pin.test.ts new file mode 100644 index 000000000..2145c07f8 --- /dev/null +++ b/src/lib/cache/__tests__/server-cache-global-pin.test.ts @@ -0,0 +1,60 @@ +/** + * The server-cache registry must be shared across module instances. + * + * Next's App Router bundles a module that is imported from both a Server + * Component and a route handler once PER LAYER. Both copies run in the same + * process, but each gets its own module scope — so a registry held in plain + * module state becomes two registries. Measured on a production build: the + * dashboard RSC read its own copy while an intake / measurement write, which + * runs in a route handler, evicted the other one. The home page then kept + * serving the pre-write snapshot for the rest of the TTL. + * + * `globalThis` is the only scope both layers share, so the registry is parked + * there under a `Symbol.for` slot. These tests pin that down from both sides: + * the exported object IS the one on the global, and a second evaluation of the + * module adopts the existing registry instead of building a fresh one. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { caches } from "../server-cache"; + +const CACHES_GLOBAL_SLOT = Symbol.for("healthlog.serverCaches"); + +type GlobalWithCaches = typeof globalThis & { + [CACHES_GLOBAL_SLOT]?: unknown; +}; + +describe("server cache registry is pinned to globalThis", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("exports the very object parked on the global slot", () => { + const parked = (globalThis as GlobalWithCaches)[CACHES_GLOBAL_SLOT]; + expect(parked).toBeDefined(); + expect(caches).toBe(parked); + }); + + it("hands a second module evaluation the same registry", async () => { + // `vi.resetModules()` makes the next import evaluate the module file + // again, which is what a second bundler layer does in production. + const reimported = await import("../server-cache"); + expect(reimported.caches).toBe(caches); + }); + + it("lets a write through one module instance reach the other", async () => { + const reimported = await import("../server-cache"); + caches.analytics.__resetForTests(); + + caches.analytics.set("pin-guard|probe", { seen: true }); + + expect(reimported.caches.analytics.get("pin-guard|probe")).toEqual({ + seen: true, + }); + + reimported.caches.analytics.deleteByPrefix("pin-guard|"); + + expect(caches.analytics.get("pin-guard|probe")).toBeNull(); + }); +}); diff --git a/src/lib/cache/server-cache.ts b/src/lib/cache/server-cache.ts index 7b8edc7fc..8e4cead77 100644 --- a/src/lib/cache/server-cache.ts +++ b/src/lib/cache/server-cache.ts @@ -463,242 +463,284 @@ export class ServerCache { } /** - * Per-route cache instances. Module-scope so every request lands in the - * same `Map`. Sizes match the blueprint §5 table. + * Per-route cache instances. Sizes match the blueprint §5 table. * * The blueprint's full eight-cache roster is provisioned so future * rounds can wire the remaining routes (`/api/medications`, * `/api/dashboard/widgets`, `/api/workouts`, * `/api/mood/analytics`) without churning the helper module. + * + * Built once and parked on `globalThis` (see `caches` below) — module + * scope alone is NOT enough to make every request land in the same `Map`. */ -export const caches = { - /** - * Slim / thick analytics cells, the iOS summary cell, the dashboard - * summary, AND the unified dashboard first-paint snapshot (per-key TTL - * override). 60 s fresh TTL keeps multiple sub-page mounts inside a - * minute on a warm entry. - * - * v1.12.7 — stale-while-revalidate window. The snapshot read uses - * `cachedSwr` and a measurement write marks the bucket stale (not a - * hard evict) via `invalidateUserMeasurements`, so a high-frequency - * iOS Apple-Health sync no longer busts the snapshot into a cold - * rebuild on every batch (the v1.8.7 "sync evicting the cache all day" - * class, on the dashboard snapshot). Slim / thick / summary cells read - * via plain `cached` are unaffected: a marked-stale entry has - * `expiresAt === now`, so their next `cached` read is a clean miss and - * rebuilds fresh — only the `cachedSwr` snapshot serves stale + warms a - * background recompute. The stale window bounds how old a served - * snapshot can be when no reader triggers a revalidation. - * - * v1.16.12 — stale window widened 10 min → 1 h. A single self-hoster - * visits the dashboard / insights every ~20-30 min; the old 10-minute - * window meant nearly every return landed PAST it (a `miss`, ~1.5 s of - * synchronous rebuild) instead of inside it (a `stale`, instant + one - * background refresh). Each stale-serve re-inserts, so an active session - * stays warm regardless of the exact window; the value only governs how - * long an ABSENCE still gets an instant response. 1 h covers a normal - * break while keeping the dashboard's time-derived `nextDueOverdue` / - * tally drift bounded — and that field already self-corrects within the - * snapshot client's 120 s poll and the immediate background refresh. - */ - analytics: new ServerCache({ - name: "analytics", - maxEntries: 1000, - ttlMs: 60_000, - staleTtlMs: 3_600_000, - }), - /** - * v1.16.8 — stale-while-revalidate on the list bucket. The 60 s fresh - * TTL meant every visit minutes apart paid the full cold list build - * (five parallel reads + the next-due engine per medication). The list - * GET reads via `cachedSwr` now: inside the 10-minute stale window the - * prior list serves immediately while one background recompute warms a - * fresh one. Interactive writes hard-evict the bucket through - * `invalidateUserMedications({ evict: true })`, so the window only - * bounds wall-clock drift (`todayEventCount`, `nextDueAt`), never - * user-action staleness. - */ - medications: new ServerCache({ - name: "medications", - maxEntries: 1000, - ttlMs: 60_000, - staleTtlMs: 600_000, - }), - /** - * v1.18.11 (W5 perf) — stale-while-revalidate window. The - * `AchievementUnlockNotifier` is mounted on every authenticated page and - * polls this endpoint every 2 minutes. Against a 60 s hard-TTL bucket - * that poll always landed on an expired entry, firing a cold ~400 ms - * rebuild on every session every 2 minutes. With a stale window the poll - * (and the inline `/achievements` page mount) serves the prior payload - * instantly while one coalesced background recompute refreshes it. - * - * SWR is safe here: the route persists `pendingUnlocks` OUTSIDE the - * cached factory (idempotent `createMany({ skipDuplicates: true })`), so - * serving a stale body never skips an unlock write. The 10-minute window - * mirrors the `medications` bucket; measurement / mood / medication / - * intake writes invalidate the `${userId}|` prefix so a user's own - * action still surfaces on the next read. - */ - achievements: new ServerCache({ - name: "achievements", - maxEntries: 1000, - ttlMs: 60_000, - staleTtlMs: 600_000, - }), - dashboardWidgets: new ServerCache({ - name: "dashboardWidgets", - maxEntries: 500, - ttlMs: 300_000, - }), - // Retain at most 50k canonical workout rows across all filter projections. - // The selected row shape is fixed and narrow, so row count is a stable - // byte proxy without serializing or walking every field on each cache set. - // A projection larger than the entire budget is returned to its caller but - // immediately evicted rather than monopolizing process memory. - workouts: new ServerCache({ - name: "workouts", - maxEntries: 1000, - // A row-weight budget prevents a handful of full-history projections - // from looking as cheap as tiny lists under the entry-count cap. - maxWeight: 50_000, - ttlMs: 60_000, - weightOf: (projection) => Math.max(1, projection.canonical.length), - }), - medicationsIntake: new ServerCache({ - name: "medicationsIntake", - maxEntries: 1000, - ttlMs: 900_000, - }), - /** - * v1.15.20 — per-medication compliance payload - * (`GET /api/medications/[id]/compliance`). The medications list fans - * the endpoint out once per card, and each cold build runs the full - * band-expansion pass — so the warm path has to live server-side. - * Keyed `${userId}|${medicationId}|compliance|${userTz}` (the payload's - * day buckets derive from the user timezone, so a timezone change must - * miss rather than serve the previous zone's buckets); every intake / - * medication write flushes the `${userId}|` prefix via - * `invalidateUserMedications`, so the 15-minute TTL only bounds - * wall-clock drift (a dose flipping overdue), never user-action - * staleness. Sized for a few hundred users × a handful of meds. - * - * v1.16.8 — stale-while-revalidate. Both compliance routes (the per-id - * detail read and the batched card read) consume via `cachedSwr`: - * inside the 10-minute stale window an expired cell serves the prior - * payload immediately while one coalesced rebuild warms it. Interactive - * intake / medication writes — including the iOS bulk-intake endpoint, - * which carries the phone user's own taken/skipped doses — hard-evict - * (the user must see their own dose on the next read); the genuinely - * background writers (the auto-miss cron, slot dedup) mark stale so a - * high-frequency pass never busts every card into an inline cold - * rebuild. - */ - medicationCompliance: new ServerCache({ - name: "medicationCompliance", - maxEntries: 2000, - ttlMs: 900_000, - staleTtlMs: 600_000, - }), - moodAnalytics: new ServerCache({ - name: "moodAnalytics", - maxEntries: 1000, - ttlMs: 60_000, - }), - /** - * v1.8.5 — pre-computed mood-insights aggregates (heatmap, distribution, - * weekday, tags, cross-metric correlations) for the Mood Insights page. - * Read on every `/insights/mood` mount; bounded 365-day live read behind - * the cache. 60 s fresh TTL matches `moodAnalytics`. - * - * v1.12.1 — stale-while-revalidate. The read route uses `cachedSwr` and - * a mood write marks the bucket stale (not a hard evict) via - * `invalidateUserMood`. An active logger now serves the prior aggregate - * immediately while a single background recompute warms a fresh one, - * instead of re-paying the multi-second cold compute on every entry - * (the v1.8.7 "sync evicting the cache all day" class, on mood). The - * 10-minute stale window bounds how old a served aggregate can be when - * no reader has triggered a revalidation. - */ - moodInsights: new ServerCache({ - name: "moodInsights", - maxEntries: 1000, - ttlMs: 60_000, - staleTtlMs: 600_000, - }), - /** - * v1.4.36 W1 — `/api/insights/targets` is read on every Insights - * mount and runs >1 s cold (it pulls 30-day measurements for every - * type + medications + intakes + mood + glucose). 60 s TTL matches - * the analytics cache so multiple sub-page mounts inside a minute - * all hit a warm cache. Invalidated alongside the analytics bucket - * on measurement / mood / medication writes. - * - * v1.16.8 — stale-while-revalidate. The 60 s fresh TTL meant every - * Insights mount more than a minute after the last one re-paid the - * full multi-query build inline (>1 s cold). The route reads via - * `cachedSwr` now: inside the 10-minute stale window the prior body - * serves immediately while one coalesced background rebuild warms a - * fresh one. Writes keep their hard evict (`deleteByPrefix`) so a - * user's own measurement / mood / medication action is always - * reflected on the very next read — the window only bounds - * wall-clock drift, never user-action staleness. - */ - insightsTargets: new ServerCache({ - name: "insightsTargets", - maxEntries: 1000, - ttlMs: 60_000, - staleTtlMs: 600_000, - }), - /** - * v1.16.8 — batched derived-wellness payload - * (`GET /api/insights/derived/batch`). The Insights overview reads - * ~16 metric computes in one request; each cold build walks the - * rollup tier per metric and lands at 1–2 s wall-clock even under the - * bounded `p-limit(4)` fan-out. Keyed - * `${userId}|batch|${sortedTokens}|${locale}` so the overview's one - * canonical token set always lands on one cell. Measurement writes - * cover it through `invalidateUserMeasurements` (evict on interactive - * writes, mark-stale on background syncs); mood writes mark it stale - * (READINESS folds the mood series in). Stale-while-revalidate keeps - * any repeat read inside the 10-minute window instant while one - * background recompute refreshes the cell. - */ - insightsDerived: new ServerCache({ - name: "insightsDerived", - maxEntries: 2000, - ttlMs: 60_000, - // v1.16.12 — 10 min → 1 h, same rationale as the analytics bucket: the - // derived-insight aggregates are trend data (no time-critical due - // state), so a returning visitor serves an instant stale payload + - // background refresh instead of a ~1.1 s cold rebuild. - staleTtlMs: 3_600_000, - }), - /** - * v1.5.5 — per-user insights tile layout cache. Mirrors - * `dashboardWidgets` (same 5-minute TTL, same per-user bucket size). - * Read on every `/insights` mount; the layout only changes on a - * Settings save, which invalidates this cache via - * `invalidateUserInsightsLayout()`. - */ - insightsLayout: new ServerCache({ - name: "insightsLayout", - maxEntries: 500, - ttlMs: 300_000, - }), - /** - * v1.16.10 — per-user medications list presentation (view choice + - * manual order). Mirrors `insightsLayout` (same 5-minute TTL, same - * per-user bucket size). Read on every /medications mount; changes - * only on a view toggle / order save, which invalidates via - * `invalidateUserMedicationListLayout()`. - */ - medicationListLayout: new ServerCache({ - name: "medicationListLayout", - maxEntries: 500, - ttlMs: 300_000, - }), -} as const; +function createServerCaches() { + return { + /** + * Slim / thick analytics cells, the iOS summary cell, the dashboard + * summary, AND the unified dashboard first-paint snapshot (per-key TTL + * override). 60 s fresh TTL keeps multiple sub-page mounts inside a + * minute on a warm entry. + * + * v1.12.7 — stale-while-revalidate window. The snapshot read uses + * `cachedSwr` and a measurement write marks the bucket stale (not a + * hard evict) via `invalidateUserMeasurements`, so a high-frequency + * iOS Apple-Health sync no longer busts the snapshot into a cold + * rebuild on every batch (the v1.8.7 "sync evicting the cache all day" + * class, on the dashboard snapshot). Slim / thick / summary cells read + * via plain `cached` are unaffected: a marked-stale entry has + * `expiresAt === now`, so their next `cached` read is a clean miss and + * rebuilds fresh — only the `cachedSwr` snapshot serves stale + warms a + * background recompute. The stale window bounds how old a served + * snapshot can be when no reader triggers a revalidation. + * + * v1.16.12 — stale window widened 10 min → 1 h. A single self-hoster + * visits the dashboard / insights every ~20-30 min; the old 10-minute + * window meant nearly every return landed PAST it (a `miss`, ~1.5 s of + * synchronous rebuild) instead of inside it (a `stale`, instant + one + * background refresh). Each stale-serve re-inserts, so an active session + * stays warm regardless of the exact window; the value only governs how + * long an ABSENCE still gets an instant response. 1 h covers a normal + * break while keeping the dashboard's time-derived `nextDueOverdue` / + * tally drift bounded — and that field already self-corrects within the + * snapshot client's 120 s poll and the immediate background refresh. + */ + analytics: new ServerCache({ + name: "analytics", + maxEntries: 1000, + ttlMs: 60_000, + staleTtlMs: 3_600_000, + }), + /** + * v1.16.8 — stale-while-revalidate on the list bucket. The 60 s fresh + * TTL meant every visit minutes apart paid the full cold list build + * (five parallel reads + the next-due engine per medication). The list + * GET reads via `cachedSwr` now: inside the 10-minute stale window the + * prior list serves immediately while one background recompute warms a + * fresh one. Interactive writes hard-evict the bucket through + * `invalidateUserMedications({ evict: true })`, so the window only + * bounds wall-clock drift (`todayEventCount`, `nextDueAt`), never + * user-action staleness. + */ + medications: new ServerCache({ + name: "medications", + maxEntries: 1000, + ttlMs: 60_000, + staleTtlMs: 600_000, + }), + /** + * v1.18.11 (W5 perf) — stale-while-revalidate window. The + * `AchievementUnlockNotifier` is mounted on every authenticated page and + * polls this endpoint every 2 minutes. Against a 60 s hard-TTL bucket + * that poll always landed on an expired entry, firing a cold ~400 ms + * rebuild on every session every 2 minutes. With a stale window the poll + * (and the inline `/achievements` page mount) serves the prior payload + * instantly while one coalesced background recompute refreshes it. + * + * SWR is safe here: the route persists `pendingUnlocks` OUTSIDE the + * cached factory (idempotent `createMany({ skipDuplicates: true })`), so + * serving a stale body never skips an unlock write. The 10-minute window + * mirrors the `medications` bucket; measurement / mood / medication / + * intake writes invalidate the `${userId}|` prefix so a user's own + * action still surfaces on the next read. + */ + achievements: new ServerCache({ + name: "achievements", + maxEntries: 1000, + ttlMs: 60_000, + staleTtlMs: 600_000, + }), + dashboardWidgets: new ServerCache({ + name: "dashboardWidgets", + maxEntries: 500, + ttlMs: 300_000, + }), + // Retain at most 50k canonical workout rows across all filter projections. + // The selected row shape is fixed and narrow, so row count is a stable + // byte proxy without serializing or walking every field on each cache set. + // A projection larger than the entire budget is returned to its caller but + // immediately evicted rather than monopolizing process memory. + workouts: new ServerCache({ + name: "workouts", + maxEntries: 1000, + // A row-weight budget prevents a handful of full-history projections + // from looking as cheap as tiny lists under the entry-count cap. + maxWeight: 50_000, + ttlMs: 60_000, + weightOf: (projection) => Math.max(1, projection.canonical.length), + }), + medicationsIntake: new ServerCache({ + name: "medicationsIntake", + maxEntries: 1000, + ttlMs: 900_000, + }), + /** + * v1.15.20 — per-medication compliance payload + * (`GET /api/medications/[id]/compliance`). The medications list fans + * the endpoint out once per card, and each cold build runs the full + * band-expansion pass — so the warm path has to live server-side. + * Keyed `${userId}|${medicationId}|compliance|${userTz}` (the payload's + * day buckets derive from the user timezone, so a timezone change must + * miss rather than serve the previous zone's buckets); every intake / + * medication write flushes the `${userId}|` prefix via + * `invalidateUserMedications`, so the 15-minute TTL only bounds + * wall-clock drift (a dose flipping overdue), never user-action + * staleness. Sized for a few hundred users × a handful of meds. + * + * v1.16.8 — stale-while-revalidate. Both compliance routes (the per-id + * detail read and the batched card read) consume via `cachedSwr`: + * inside the 10-minute stale window an expired cell serves the prior + * payload immediately while one coalesced rebuild warms it. Interactive + * intake / medication writes — including the iOS bulk-intake endpoint, + * which carries the phone user's own taken/skipped doses — hard-evict + * (the user must see their own dose on the next read); the genuinely + * background writers (the auto-miss cron, slot dedup) mark stale so a + * high-frequency pass never busts every card into an inline cold + * rebuild. + */ + medicationCompliance: new ServerCache({ + name: "medicationCompliance", + maxEntries: 2000, + ttlMs: 900_000, + staleTtlMs: 600_000, + }), + moodAnalytics: new ServerCache({ + name: "moodAnalytics", + maxEntries: 1000, + ttlMs: 60_000, + }), + /** + * v1.8.5 — pre-computed mood-insights aggregates (heatmap, distribution, + * weekday, tags, cross-metric correlations) for the Mood Insights page. + * Read on every `/insights/mood` mount; bounded 365-day live read behind + * the cache. 60 s fresh TTL matches `moodAnalytics`. + * + * v1.12.1 — stale-while-revalidate. The read route uses `cachedSwr` and + * a mood write marks the bucket stale (not a hard evict) via + * `invalidateUserMood`. An active logger now serves the prior aggregate + * immediately while a single background recompute warms a fresh one, + * instead of re-paying the multi-second cold compute on every entry + * (the v1.8.7 "sync evicting the cache all day" class, on mood). The + * 10-minute stale window bounds how old a served aggregate can be when + * no reader has triggered a revalidation. + */ + moodInsights: new ServerCache({ + name: "moodInsights", + maxEntries: 1000, + ttlMs: 60_000, + staleTtlMs: 600_000, + }), + /** + * v1.4.36 W1 — `/api/insights/targets` is read on every Insights + * mount and runs >1 s cold (it pulls 30-day measurements for every + * type + medications + intakes + mood + glucose). 60 s TTL matches + * the analytics cache so multiple sub-page mounts inside a minute + * all hit a warm cache. Invalidated alongside the analytics bucket + * on measurement / mood / medication writes. + * + * v1.16.8 — stale-while-revalidate. The 60 s fresh TTL meant every + * Insights mount more than a minute after the last one re-paid the + * full multi-query build inline (>1 s cold). The route reads via + * `cachedSwr` now: inside the 10-minute stale window the prior body + * serves immediately while one coalesced background rebuild warms a + * fresh one. Writes keep their hard evict (`deleteByPrefix`) so a + * user's own measurement / mood / medication action is always + * reflected on the very next read — the window only bounds + * wall-clock drift, never user-action staleness. + */ + insightsTargets: new ServerCache({ + name: "insightsTargets", + maxEntries: 1000, + ttlMs: 60_000, + staleTtlMs: 600_000, + }), + /** + * v1.16.8 — batched derived-wellness payload + * (`GET /api/insights/derived/batch`). The Insights overview reads + * ~16 metric computes in one request; each cold build walks the + * rollup tier per metric and lands at 1–2 s wall-clock even under the + * bounded `p-limit(4)` fan-out. Keyed + * `${userId}|batch|${sortedTokens}|${locale}` so the overview's one + * canonical token set always lands on one cell. Measurement writes + * cover it through `invalidateUserMeasurements` (evict on interactive + * writes, mark-stale on background syncs); mood writes mark it stale + * (READINESS folds the mood series in). Stale-while-revalidate keeps + * any repeat read inside the 10-minute window instant while one + * background recompute refreshes the cell. + */ + insightsDerived: new ServerCache({ + name: "insightsDerived", + maxEntries: 2000, + ttlMs: 60_000, + // v1.16.12 — 10 min → 1 h, same rationale as the analytics bucket: the + // derived-insight aggregates are trend data (no time-critical due + // state), so a returning visitor serves an instant stale payload + + // background refresh instead of a ~1.1 s cold rebuild. + staleTtlMs: 3_600_000, + }), + /** + * v1.5.5 — per-user insights tile layout cache. Mirrors + * `dashboardWidgets` (same 5-minute TTL, same per-user bucket size). + * Read on every `/insights` mount; the layout only changes on a + * Settings save, which invalidates this cache via + * `invalidateUserInsightsLayout()`. + */ + insightsLayout: new ServerCache({ + name: "insightsLayout", + maxEntries: 500, + ttlMs: 300_000, + }), + /** + * v1.16.10 — per-user medications list presentation (view choice + + * manual order). Mirrors `insightsLayout` (same 5-minute TTL, same + * per-user bucket size). Read on every /medications mount; changes + * only on a view toggle / order save, which invalidates via + * `invalidateUserMedicationListLayout()`. + */ + medicationListLayout: new ServerCache({ + name: "medicationListLayout", + maxEntries: 500, + ttlMs: 300_000, + }), + } as const; +} + +type ServerCacheRegistry = ReturnType; + +/** + * The registry lives on `globalThis`, not on module scope. + * + * A module imported from BOTH a Server Component and a route handler is + * instantiated once per bundler layer, so plain module state gives the + * dashboard RSC its own private `Map` and the API routes another. Measured + * on a production build, single process: the RSC copy held two analytics + * entries while an interactive measurement write — which runs in a route + * handler and calls `deleteByPrefix` — emptied only the route-handler copy. + * The dashboard's server-rendered first paint kept serving the pre-write + * snapshot until the TTL lapsed minutes later, and because that payload is + * dehydrated into TanStack it overwrote the fresh value the client had + * already fetched. + * + * Measured under `node .next/standalone/server.js`, which is what the image + * runs, and not only under `next start`. Both behave the same here: three + * module instantiations either way, one shared registry once pinned. Worth + * keeping in mind for the next cache-shaped bug — a reading taken under + * `next start` alone would not have settled this. + * + * `Symbol.for` is the same cross-layer slot `event-builder.ts` uses for the + * event-loop-lag sample; `globalForCaches` mirrors `db.ts`'s `globalForPrisma` + * alias. The pin is unconditional: `db.ts` guards its assignment with + * `NODE_ENV !== "production"` because it only defends against dev HMR, while + * the layer split this defends against is a PRODUCTION build behaviour. + */ +const CACHES_GLOBAL_SLOT = Symbol.for("healthlog.serverCaches"); + +const globalForCaches = globalThis as unknown as { + [key: symbol]: ServerCacheRegistry | undefined; +}; + +export const caches: ServerCacheRegistry = + globalForCaches[CACHES_GLOBAL_SLOT] ?? + (globalForCaches[CACHES_GLOBAL_SLOT] = createServerCaches()); /** * Non-reversible 32-bit hash of the cache key. Used in wide-event meta diff --git a/src/lib/cycle/__tests__/backup.test.ts b/src/lib/cycle/__tests__/backup.test.ts index 7a0166fd1..2bcc923ec 100644 --- a/src/lib/cycle/__tests__/backup.test.ts +++ b/src/lib/cycle/__tests__/backup.test.ts @@ -87,7 +87,11 @@ describe("buildCycleBackupSection disaster-recovery mode", () => { deletedAt, createdAt, updatedAt, - symptomLinks: [{ symptom: { key: "cramps" } }], + // One symptom the person put a number on, one they did not. + symptomLinks: [ + { severity: 4, symptom: { key: "cramps" } }, + { severity: null, symptom: { key: "fatigue" } }, + ], }, ]), }, @@ -160,7 +164,10 @@ describe("buildCycleBackupSection disaster-recovery mode", () => { deletedAt: deletedAt.toISOString(), createdAt: createdAt.toISOString(), updatedAt: updatedAt.toISOString(), - symptomKeys: ["cramps"], + symptomKeys: ["cramps", "fatigue"], + // Only the rated link is listed. An unrated one is left out rather than + // written as a zero, so the file says "never rated" and not "rated none". + symptomSeverities: [{ key: "cramps", severity: 4 }], }); }); }); diff --git a/src/lib/cycle/backup.ts b/src/lib/cycle/backup.ts index ce511a02c..efbfbd895 100644 --- a/src/lib/cycle/backup.ts +++ b/src/lib/cycle/backup.ts @@ -90,7 +90,30 @@ export interface CycleBackupSection { deletedAt?: string | null; createdAt?: string; updatedAt?: string; + /** + * Which symptoms the day carries. The single presence list — one entry per + * link, nothing else decides whether a symptom was on the day. + */ symptomKeys: string[]; + /** + * How hard each of those symptoms hit, where the person said so. + * + * `CycleSymptomLink.severity` is a 1-4 Likert the log-day sheet writes and + * the day-log DTO reads back, and the backup carried only the keys — so a + * day recorded as "cramps, and they were a 4" came back as "cramps" and the + * 4 was gone. Nothing failed; the number was simply not in the file. + * + * Kept as a SPARSE annotation over `symptomKeys` rather than folded into + * it, for two reasons. A release that only knows `symptomKeys` still reads + * every symptom out of a file written here, instead of finding the key it + * looks for replaced by one it does not know and restoring a day with no + * symptoms at all. And presence stays decided in exactly one place, so the + * two lists cannot disagree about which symptoms a day had. + * + * A link with no severity is simply not listed. No entry means the person + * never rated it — which is not the same as rating it zero. + */ + symptomSeverities: Array<{ key: string; severity: number }>; }>; /** * The account's OWN symptom definitions. @@ -231,6 +254,9 @@ export async function buildCycleBackupSection( externalId: d.externalId, tz: d.tz, symptomKeys: d.symptomLinks.map((l) => l.symptom.key), + symptomSeverities: d.symptomLinks + .filter((l): l is typeof l & { severity: number } => l.severity != null) + .map((l) => ({ key: l.symptom.key, severity: l.severity })), })), customSymptoms: customSymptoms.map((sym) => ({ ...(disasterRecovery ? { id: sym.id } : {}), @@ -313,6 +339,25 @@ const CYCLE_GOALS = new Set([ "OFF", ]); +/** + * A symptom severity is the 1-4 Likert the log-day sheet writes, or nothing. + * + * Anything else — a value from a hand-edited file, a wider scale some later + * release might use, a null — reads as "not rated". It is dropped rather than + * clamped or rounded into range, because a severity nobody recorded is absent, + * and inventing a 1 or a 4 for it would be a guess printed next to the person's + * own numbers. It never fails the restore: refusing the whole file over one + * unfamiliar intensity would cost far more than the intensity does. + */ +function severityOrNull(value: number | null | undefined): number | null { + return typeof value === "number" && + Number.isInteger(value) && + value >= 1 && + value <= 4 + ? value + : null; +} + function enumOrNull( value: string | null | undefined, allow: ReadonlySet, @@ -326,7 +371,8 @@ function enumOrNull( * Restore the cycle tables for `ownerId` from a parsed backup payload, * inside an existing transaction. Delete-then-recreate, matching the * measurement/mood restore contract. Symptom links are re-resolved against - * the seeded catalogue by key (unknown keys dropped). `notesEncrypted` is + * the seeded catalogue by key, and each one gets back the 1-4 intensity the + * file recorded for it, or none if the file recorded none. `notesEncrypted` is * written back as the ciphertext envelope verbatim — never re-encrypted. * * Returns the wiped counts for the audit trail. A pre-v1.15 payload (empty @@ -468,13 +514,23 @@ export async function restoreCycleData( }; for (const d of payload.cycleDayLogs) { + // The day's rated intensities, keyed by symptom. A file written before + // severities were carried has no list at all and every link comes back + // unrated, which is what it was. An entry naming a symptom the day does not + // have is simply never read. + const severityByKey = new Map(); + for (const s of d.symptomSeverities ?? []) { + const severity = severityOrNull(s.severity); + if (severity !== null) severityByKey.set(s.key, severity); + } + // Every key was proven resolvable above, so this maps rather than filters: // a `.filter(Boolean)` here would silently re-open the hole that check // just closed. - const symptomIds = (d.symptomKeys ?? []).map((k) => { + const symptomLinks = (d.symptomKeys ?? []).map((k) => { const id = symptomIdByKey.get(k); if (!id) throw new Error(`Unresolved cycle symptom key: ${k}`); - return id; + return { symptomId: id, severity: severityByKey.get(k) ?? null }; }); const cycleId = d.cycleId !== undefined @@ -516,12 +572,8 @@ export async function restoreCycleData( deletedAt: d.deletedAt ? new Date(d.deletedAt) : null, ...(d.createdAt ? { createdAt: new Date(d.createdAt) } : {}), ...(d.updatedAt ? { updatedAt: new Date(d.updatedAt) } : {}), - ...(symptomIds.length > 0 - ? { - symptomLinks: { - create: symptomIds.map((symptomId) => ({ symptomId })), - }, - } + ...(symptomLinks.length > 0 + ? { symptomLinks: { create: symptomLinks } } : {}), }, }); diff --git a/src/lib/dashboard/__tests__/snapshot-read-cache-key.test.ts b/src/lib/dashboard/__tests__/snapshot-read-cache-key.test.ts new file mode 100644 index 000000000..5184a0b05 --- /dev/null +++ b/src/lib/dashboard/__tests__/snapshot-read-cache-key.test.ts @@ -0,0 +1,87 @@ +/** + * The snapshot read and the snapshot invalidator must agree on one key. + * + * `dashboardSnapshotCacheKey` is the declared source of truth, and the + * invalidators sweep by its prefix. The read used to spell the same string + * out by hand: identical today, silently divergent the moment either side is + * edited, and the failure is invisible — the dashboard just keeps painting + * the pre-write body until the entry ages out. + * + * So this asserts the agreement by effect rather than by text: warm the cell + * through the real read, invalidate through the real invalidator, and require + * that the next read rebuilds. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { User } from "@/generated/prisma/client"; + +const buildDashboardSnapshot = vi.fn(async () => ({ tiles: {} })); +const resolveServerLocale = vi.fn(async () => "de"); + +vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/dashboard/snapshot", () => ({ + buildDashboardSnapshot: (...args: unknown[]) => + buildDashboardSnapshot(...(args as [])), +})); +vi.mock("@/lib/i18n/server-locale", () => ({ + resolveServerLocale: (...args: unknown[]) => + resolveServerLocale(...(args as [])), +})); + +const { readDashboardSnapshotCached } = await import("../snapshot-read"); +const { caches, __resetAllCachesForTests } = + await import("@/lib/cache/server-cache"); +const { + dashboardSnapshotCacheKey, + invalidateUserDashboardSnapshot, + invalidateUserMedications, +} = await import("@/lib/cache/invalidate"); + +const USER = { id: "user-key-guard", locale: "de" } as unknown as User; + +beforeEach(() => { + __resetAllCachesForTests(); + buildDashboardSnapshot.mockClear(); +}); + +afterEach(() => { + __resetAllCachesForTests(); +}); + +describe("dashboard snapshot cache key", () => { + it("writes the cell at the key the builder composes", async () => { + const { body } = await readDashboardSnapshotCached(USER); + + // Exact key, not a prefix: `deleteByPrefix` would forgive a suffix change + // on the read side and leave the drift undetected until a user saw it. + expect( + caches.analytics.get(`${dashboardSnapshotCacheKey(USER.id)}|de`), + ).toBe(body); + }); + + it("serves the second read from the cell the first read wrote", async () => { + await readDashboardSnapshotCached(USER); + await readDashboardSnapshotCached(USER); + + expect(buildDashboardSnapshot).toHaveBeenCalledTimes(1); + }); + + it("rebuilds after the snapshot invalidator runs", async () => { + await readDashboardSnapshotCached(USER); + invalidateUserDashboardSnapshot(USER.id); + await readDashboardSnapshotCached(USER); + + expect(buildDashboardSnapshot).toHaveBeenCalledTimes(2); + }); + + it("rebuilds after an interactive medication write", async () => { + // The path behind the reported symptom: a dose is recorded, the intake + // route evicts, and the very next dashboard read must not be the old body. + await readDashboardSnapshotCached(USER); + invalidateUserMedications(USER.id, { evict: true }); + await readDashboardSnapshotCached(USER); + + expect(buildDashboardSnapshot).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 9aac226eb..644e4d108 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -215,7 +215,6 @@ beforeEach(() => { briefing: true, insightStatus: true, correlations: true, - healthScoreExplainer: true, }); fakePrisma.measurement.findMany.mockResolvedValue([]); fakePrisma.moodEntry.findMany.mockResolvedValue([]); @@ -652,7 +651,6 @@ describe("buildDashboardSnapshot — briefingState matrix", () => { briefing: false, insightStatus: false, correlations: false, - healthScoreExplainer: false, }); const snap = await buildDashboardSnapshot( fakePrisma, diff --git a/src/lib/dashboard/snapshot-read.ts b/src/lib/dashboard/snapshot-read.ts index 44038892a..da2423aaa 100644 --- a/src/lib/dashboard/snapshot-read.ts +++ b/src/lib/dashboard/snapshot-read.ts @@ -6,17 +6,27 @@ * prefetches the same payload into a dehydrated TanStack cache so the * first HTML paints real tiles instead of skeletons-until-JS. * - * Both ride the SAME `caches.analytics` SWR cell (key - * `${userId}|dashboard-snapshot|${locale}`), so an RSC prefetch warms the - * API path and vice versa — the builder never runs twice for one user - * within the TTL, and the write-invalidation semantics (stale-sweep on - * measurement writes, hard evict on widget reorder) cover both readers. + * Both ride the SAME `caches.analytics` SWR cell, keyed by + * `dashboardSnapshotCacheKey(userId)` plus the resolved locale, so an RSC + * prefetch warms the API path and vice versa — the builder never runs twice + * for one user within the TTL, and the write-invalidation semantics + * (stale-sweep on measurement writes, hard evict on widget reorder) cover + * both readers. + * + * "The SAME cell" rests on `caches` being pinned to `globalThis` in + * `src/lib/cache/server-cache.ts`. These two entry points are bundled into + * different layers, and while the registry sat in plain module state they + * held one Map each: a write evicted the route handlers' copy and the home + * page kept rendering the pre-write snapshot until it aged out. Anything + * that moves the registry back off the global breaks this paragraph, not + * just a performance assumption. */ import type { User } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { dashboardSnapshotCacheKey } from "@/lib/cache/invalidate"; import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval"; import { buildDashboardSnapshot, @@ -87,7 +97,10 @@ export async function readDashboardSnapshotCached( // miss + synchronous rebuild here. const body = await cachedSwr( caches.analytics as ServerCache, - `${user.id}|dashboard-snapshot|${locale}`, + // The invalidators sweep by `dashboardSnapshotCacheKey(userId)` prefix, so + // the read has to build its key from that same function. Spelling the + // string out here worked only for as long as nobody edited one side. + `${dashboardSnapshotCacheKey(user.id)}|${locale}`, () => buildDashboardSnapshot(prisma, snapshotUser, { time, locale }), annotate, SNAPSHOT_CACHE_TTL_MS, diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index ca085debd..eb0e5794d 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -74,7 +74,6 @@ const appSettings = { assistantBriefingEnabled: false, assistantInsightStatusEnabled: false, assistantCorrelationsEnabled: false, - assistantHealthScoreExplainerEnabled: false, moduleAvailabilityJson: { nutrients: false, insights: true }, documentMaxFileBytes: 12_345_678, documentQuotaBytes: BigInt("9876543210"), diff --git a/src/lib/feature-flags/__tests__/coach-cascade.test.tsx b/src/lib/feature-flags/__tests__/coach-cascade.test.tsx index 3bfa7f5bf..63bc1c149 100644 --- a/src/lib/feature-flags/__tests__/coach-cascade.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-cascade.test.tsx @@ -82,7 +82,6 @@ function buildClient(flags: Partial): QueryClient { briefing: true, insightStatus: true, correlations: true, - healthScoreExplainer: true, ...flags, }, }); diff --git a/src/lib/feature-flags/__tests__/index.test.ts b/src/lib/feature-flags/__tests__/index.test.ts index a15c1c041..fa39c067b 100644 --- a/src/lib/feature-flags/__tests__/index.test.ts +++ b/src/lib/feature-flags/__tests__/index.test.ts @@ -43,7 +43,6 @@ describe("resolveAssistantFlags", () => { briefing: false, insightStatus: true, correlations: false, - healthScoreExplainer: true, }; expect(resolveAssistantFlags(input)).toEqual(input); }); @@ -55,7 +54,6 @@ describe("resolveAssistantFlags", () => { briefing: true, insightStatus: true, correlations: true, - healthScoreExplainer: true, }; expect(resolveAssistantFlags(input)).toEqual({ enabled: false, @@ -63,7 +61,6 @@ describe("resolveAssistantFlags", () => { briefing: false, insightStatus: false, correlations: false, - healthScoreExplainer: false, }); }); }); @@ -82,7 +79,6 @@ describe("getAssistantFlags", () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: false, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: false, }); const flags = await getAssistantFlags(); expect(flags).toEqual({ @@ -91,7 +87,6 @@ describe("getAssistantFlags", () => { briefing: true, insightStatus: false, correlations: true, - healthScoreExplainer: false, }); }); @@ -102,7 +97,6 @@ describe("getAssistantFlags", () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: true, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, }); const flags = await getAssistantFlags(); expect(flags).toEqual({ @@ -111,7 +105,6 @@ describe("getAssistantFlags", () => { briefing: false, insightStatus: false, correlations: false, - healthScoreExplainer: false, }); }); @@ -130,7 +123,6 @@ describe("requireAssistantSurface", () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: true, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, }); const flags = await requireAssistantSurface("coach"); expect(flags.coach).toBe(true); @@ -143,7 +135,6 @@ describe("requireAssistantSurface", () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: true, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, }); await expect(requireAssistantSurface("coach")).rejects.toThrow( AssistantDisabledError, @@ -157,7 +148,6 @@ describe("requireAssistantSurface", () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: true, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, }); await expect(requireAssistantSurface("briefing")).rejects.toThrow( AssistantDisabledError, @@ -184,7 +174,6 @@ describe("per-request memoisation", () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: true, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, }); // Pin a stable stub object so every `getEvent()` inside this test // returns the same reference — the memo keys on identity, so a @@ -207,7 +196,6 @@ describe("per-request memoisation", () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: true, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, }); mockEvent = { addWarning: vi.fn(), id: "req-1" }; diff --git a/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts b/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts new file mode 100644 index 000000000..e60b0ab56 --- /dev/null +++ b/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts @@ -0,0 +1,137 @@ +/** + * Every assistant switch an operator can flip has to move something. + * + * `healthScoreExplainer` did not. It gated a caption beside the + * Health-Score delta; the caption's component went when the health + * score was replaced by the reference composite, and the "?" control + * the admin description still named had been retired a release before + * that. The toggle stayed on the admin panel, in the flag matrix and in + * the API contract for two releases, so an operator could turn it off, + * be told it saved, and change nothing at all. A switch that lies is + * worse than no switch. + * + * Two guards, one point. The first pins the removal so the flag cannot + * drift back in pieces. The second is the general rule the removal came + * from: no sub-flag is allowed to exist without a surface that reads it. + * Plumbing does not count — the admin panel, the resolver, the hook and + * the two routes carry every flag by construction, so a dead one would + * look alive if they were allowed to vouch for it. + */ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { + ASSISTANT_FLAGS_DEFAULT, + resolveAssistantFlags, +} from "@/lib/feature-flags"; + +const ROOT = path.resolve(__dirname, "../../../.."); +const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const; + +/** Names the retired switch went by, on the wire and in the database. */ +const RETIRED_NAMES = [ + "healthScoreExplainer", + "assistantHealthScoreExplainerEnabled", + "assistant_health_score_explainer_enabled", +]; + +/** + * The files that carry every flag whatever it does: the operator panel + * that offers the switch, the resolver and hook that shape the matrix, + * and the two routes that read and write it. None of them is evidence + * that a flag gates anything. + */ +const PLUMBING = [ + "src/lib/feature-flags/index.ts", + "src/hooks/use-feature-flags.ts", + "src/components/admin/assistant-section.tsx", + "src/app/api/feature-flags/route.ts", + "src/app/api/admin/settings/assistant-flags/route.ts", +]; + +function sourceFiles(dir: string, acc: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + if (entry === "generated" || entry === "__tests__") continue; + const full = path.join(dir, entry); + if (statSync(full).isDirectory()) { + sourceFiles(full, acc); + } else if (/\.tsx?$/.test(entry)) { + acc.push(full); + } + } + return acc; +} + +describe("the Health-Score explainer switch stays removed", () => { + it("is absent from the resolved flag matrix, master on and master off", () => { + const on = resolveAssistantFlags({ ...ASSISTANT_FLAGS_DEFAULT }); + const off = resolveAssistantFlags({ + ...ASSISTANT_FLAGS_DEFAULT, + enabled: false, + }); + for (const shape of [ASSISTANT_FLAGS_DEFAULT, on, off]) { + expect(Object.keys(shape)).not.toContain("healthScoreExplainer"); + } + }); + + it("declares no column on the Prisma schema", () => { + const schema = readFileSync( + path.join(ROOT, "prisma/schema.prisma"), + "utf8", + ); + for (const name of RETIRED_NAMES) { + expect(schema, `${name} is back on the schema`).not.toContain(name); + } + }); + + it("carries no operator-panel wording in any locale", () => { + for (const locale of LOCALES) { + const bundle = JSON.parse( + readFileSync(path.join(ROOT, `messages/${locale}.json`), "utf8"), + ) as { admin: { assistant: Record } }; + expect( + Object.keys(bundle.admin.assistant), + `${locale} still offers the switch`, + ).not.toContain("healthScoreExplainer"); + } + }); + + it("ships the migration that dropped the column", () => { + const sql = readFileSync( + path.join( + ROOT, + "prisma/migrations/0288_drop_health_score_explainer_flag/migration.sql", + ), + "utf8", + ); + expect(sql).toContain("assistant_health_score_explainer_enabled"); + expect(sql).toContain("DROP COLUMN"); + }); +}); + +describe("no assistant sub-flag is a dead switch", () => { + it("finds a surface that reads every flag the operator can turn off", () => { + const plumbing = new Set(PLUMBING.map((rel) => path.join(ROOT, rel))); + const sources = sourceFiles(path.join(ROOT, "src")).filter( + (file) => !plumbing.has(file), + ); + const corpus = sources.map((file) => readFileSync(file, "utf8")).join("\n"); + + const subFlags = Object.keys(ASSISTANT_FLAGS_DEFAULT).filter( + (flag) => flag !== "enabled", + ); + expect(subFlags.length).toBeGreaterThan(0); + + for (const flag of subFlags) { + const gatedOnTheServer = corpus.includes( + `requireAssistantSurface("${flag}")`, + ); + const readOnTheClient = new RegExp(`flags\\.${flag}\\b`).test(corpus); + expect( + gatedOnTheServer || readOnTheClient, + `the "${flag}" switch gates nothing — wire it to a surface or remove it`, + ).toBe(true); + } + }); +}); diff --git a/src/lib/feature-flags/index.ts b/src/lib/feature-flags/index.ts index c9126a803..35aa82242 100644 --- a/src/lib/feature-flags/index.ts +++ b/src/lib/feature-flags/index.ts @@ -5,9 +5,9 @@ import { memoizePerRequest } from "@/lib/request-cache"; /** * v1.4.31 — Assistant-surface operator feature flags. * - * Six boolean toggles on `AppSettings` carve the visibility cut for - * the seven LLM-driven surfaces. The master flag is a single kill- - * switch; the five sub-flags carve specific surfaces. + * Five boolean toggles on `AppSettings` carve the visibility cut for + * the LLM-driven surfaces. The master flag is a single kill-switch; + * the four sub-flags carve specific surfaces. * * `assistant.enabled = false` forces every sub-flag false in the * resolved shape — the master always wins. This means a server-side @@ -20,13 +20,9 @@ import { memoizePerRequest } from "@/lib/request-cache"; * shape so iOS reads the same authoritative set the web reads. */ -/** The five assistant sub-flags the operator can carve. */ +/** The four assistant sub-flags the operator can carve. */ export type AssistantSurface = - | "coach" - | "briefing" - | "insightStatus" - | "correlations" - | "healthScoreExplainer"; + "coach" | "briefing" | "insightStatus" | "correlations"; export interface AssistantFlagSet { /** Master kill-switch — when false, every sub-flag is forced false. */ @@ -39,8 +35,6 @@ export interface AssistantFlagSet { insightStatus: boolean; /** Correlation narration tile on the mother page. */ correlations: boolean; - /** `?` glyph that opens the Health-Score delta explainer popover. */ - healthScoreExplainer: boolean; } /** All-on default; mirrors v1.4.30 behaviour for fresh installs. */ @@ -50,7 +44,6 @@ export const ASSISTANT_FLAGS_DEFAULT: AssistantFlagSet = Object.freeze({ briefing: true, insightStatus: true, correlations: true, - healthScoreExplainer: true, }); /** @@ -80,7 +73,6 @@ export async function getAssistantFlags(): Promise { assistantBriefingEnabled: true, assistantInsightStatusEnabled: true, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, }, }); @@ -91,8 +83,6 @@ export async function getAssistantFlags(): Promise { briefing: settings?.assistantBriefingEnabled ?? true, insightStatus: settings?.assistantInsightStatusEnabled ?? true, correlations: settings?.assistantCorrelationsEnabled ?? true, - healthScoreExplainer: - settings?.assistantHealthScoreExplainerEnabled ?? true, }); } catch { getEvent()?.addWarning( @@ -116,7 +106,6 @@ export function resolveAssistantFlags(raw: AssistantFlagSet): AssistantFlagSet { briefing: false, insightStatus: false, correlations: false, - healthScoreExplainer: false, }; } return { ...raw }; diff --git a/src/lib/jobs/__tests__/medication-reminder-check.test.ts b/src/lib/jobs/__tests__/medication-reminder-check.test.ts index d64e7e1cc..2db333d96 100644 --- a/src/lib/jobs/__tests__/medication-reminder-check.test.ts +++ b/src/lib/jobs/__tests__/medication-reminder-check.test.ts @@ -9,6 +9,37 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +/** + * The intake rows the tick is allowed to see, before its own range filter. + * + * The tick reads intakes with a real `scheduledFor` range and suppresses a + * slot from what comes back. A fake that hands every row over regardless of + * `where` cannot tell a correct range from a broken one — the suppression + * assertion stays green either way, which is exactly how a dose logged at + * 23:50 could keep being reminded after midnight while this file passed. So + * the fake honours the range the tick actually asks for. + */ +let intakeEvents: Record[] = []; + +function setIntakeEvents(events: Record[]): void { + intakeEvents = events; +} + +function readIntakeEvents(args?: { + where?: { scheduledFor?: { gte?: Date; lte?: Date } }; +}): Record[] { + const range = args?.where?.scheduledFor; + return intakeEvents.filter((event) => { + const scheduledFor = event.scheduledFor; + if (!(scheduledFor instanceof Date)) return true; + if (range?.gte && scheduledFor.getTime() < range.gte.getTime()) + return false; + if (range?.lte && scheduledFor.getTime() > range.lte.getTime()) + return false; + return true; + }); +} + const prismaMock = { telegramScheduledDeletion: { findMany: vi.fn().mockResolvedValue([]), @@ -22,7 +53,9 @@ const prismaMock = { findMany: vi.fn().mockResolvedValue([]), }, medicationIntakeEvent: { - findMany: vi.fn().mockResolvedValue([]), + findMany: vi.fn(async (args?: Parameters[0]) => + readIntakeEvents(args), + ), findFirst: vi.fn().mockResolvedValue(null), create: vi.fn(), count: vi.fn().mockResolvedValue(0), @@ -125,7 +158,7 @@ beforeEach(() => { prismaMock.telegramPromptContext.deleteMany.mockResolvedValue({ count: 0 }); prismaMock.medication.updateMany.mockResolvedValue({ count: 0 }); prismaMock.medication.findMany.mockResolvedValue([]); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); + setIntakeEvents([]); prismaMock.medicationIntakeEvent.findFirst.mockResolvedValue(null); prismaMock.medicationIntakeEvent.create.mockResolvedValue(undefined); }); @@ -172,7 +205,7 @@ describe("handleReminderCheck per-slot reminder windows", () => { timesOfDay: ["08:00", "18:00"], }), ] as never); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); + setIntakeEvents([]); await handleReminderCheck([]); @@ -197,7 +230,7 @@ describe("handleReminderCheck per-slot reminder windows", () => { doseWindows: [{ timeOfDay: "08:00", start: "07:30", end: "09:30" }], }), ] as never); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); + setIntakeEvents([]); await handleReminderCheck([]); @@ -218,7 +251,7 @@ describe("handleReminderCheck per-slot reminder windows", () => { prismaMock.medication.findMany.mockResolvedValue([ medicationWithSchedule({}), ] as never); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + setIntakeEvents([ { scheduledFor: new Date("2026-07-28T08:00:00.000Z"), takenAt: new Date("2026-07-28T08:45:00.000Z"), @@ -250,7 +283,7 @@ describe("handleReminderCheck medication-wide slot attribution", () => { }, ]; prismaMock.medication.findMany.mockResolvedValue([medication] as never); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + setIntakeEvents([ { scheduledFor: new Date("2026-07-28T09:00:00.000Z"), takenAt: new Date("2026-07-28T08:50:00.000Z"), @@ -285,7 +318,7 @@ describe("handleReminderCheck medication-wide slot attribution", () => { }, ]; prismaMock.medication.findMany.mockResolvedValue([medication] as never); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + setIntakeEvents([ { scheduledFor: new Date("2026-07-28T08:00:00.000Z"), takenAt: new Date("2026-07-28T08:45:00.000Z"), @@ -316,7 +349,7 @@ describe("handleReminderCheck medication-wide slot attribution", () => { prismaMock.medicationIntakeEvent.findFirst.mockResolvedValue({ takenAt: new Date("2026-07-21T08:00:00.000Z"), }); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + setIntakeEvents([ { scheduledFor: new Date("2026-07-28T08:00:00.000Z"), takenAt: null, @@ -339,7 +372,7 @@ describe("handleReminderCheck medication-wide slot attribution", () => { timesOfDay: [], }), ] as never); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + setIntakeEvents([ { scheduledFor: new Date("2026-07-28T08:00:00.000Z"), takenAt: new Date("2026-07-28T08:10:00.000Z"), @@ -372,7 +405,7 @@ describe("handleReminderCheck medication-wide slot attribution", () => { }, ]; prismaMock.medication.findMany.mockResolvedValue([medication] as never); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + setIntakeEvents([ { scheduledFor: new Date("2026-07-28T08:00:00.000Z"), takenAt: null, @@ -396,7 +429,7 @@ describe("handleReminderCheck medication-wide slot attribution", () => { prismaMock.medication.findMany.mockResolvedValue([ medicationWithSchedule({}), ] as never); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + setIntakeEvents([ { scheduledFor: new Date("2026-07-28T08:00:00.000Z"), takenAt: new Date("2026-07-28T13:00:00.000Z"), @@ -424,7 +457,7 @@ describe("handleReminderCheck medication-wide slot attribution", () => { }, ]; prismaMock.medication.findMany.mockResolvedValue([medication] as never); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + setIntakeEvents([ { scheduledFor: new Date("2026-07-28T08:00:30.000Z"), takenAt: null, @@ -451,7 +484,7 @@ describe("handleReminderCheck medication-wide slot attribution", () => { { validUntil: new Date("2026-07-28T08:30:00.000Z") }, ]; prismaMock.medication.findMany.mockResolvedValue([medication] as never); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + setIntakeEvents([ { scheduledFor: new Date("2026-07-28T08:00:00.000Z"), takenAt: new Date("2026-07-28T08:00:00.000Z"), @@ -479,7 +512,7 @@ describe("handleReminderCheck medication-wide slot attribution", () => { { validUntil: new Date("2026-07-28T08:30:00.000Z") }, ]; prismaMock.medication.findMany.mockResolvedValue([medication] as never); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + setIntakeEvents([ { scheduledFor: new Date("2026-07-28T08:00:00.000Z"), takenAt: new Date("2026-07-28T09:00:00.000Z"), @@ -525,7 +558,7 @@ describe("handleReminderCheck medication-wide slot attribution", () => { { validUntil: new Date("2026-07-28T08:30:00.000Z") }, ]; prismaMock.medication.findMany.mockResolvedValue([medication] as never); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + setIntakeEvents([ { scheduledFor: new Date("2026-07-28T10:00:00.000Z"), takenAt: null, @@ -553,7 +586,7 @@ describe("handleReminderCheck medication-wide slot attribution", () => { { validUntil: new Date("2026-07-28T08:30:00.000Z") }, ]; prismaMock.medication.findMany.mockResolvedValue([medication] as never); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + setIntakeEvents([ { scheduledFor: new Date("2026-07-28T10:00:00.000Z"), takenAt: new Date("2026-07-28T08:00:00.000Z"), @@ -691,7 +724,7 @@ describe("issue #664 — exact occurrence identity survives local midnight", () prismaMock.medication.findMany.mockResolvedValue([ crossMidnightMedication(timezone), ] as never); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); + setIntakeEvents([]); await handleReminderCheck([]); @@ -720,7 +753,7 @@ describe("issue #664 — exact occurrence identity survives local midnight", () prismaMock.medication.findMany.mockResolvedValue([ crossMidnightMedication("Europe/Berlin"), ] as never); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([action]); + setIntakeEvents([action]); vi.setSystemTime(new Date("2026-07-28T22:15:00.000Z")); // 00:15 Jul 29 await handleReminderCheck([]); @@ -748,4 +781,68 @@ describe("issue #664 — exact occurrence identity survives local midnight", () }), ); }); + + it("still escalates a genuinely missed 23:45 dose hours after midnight", async () => { + // The other direction of the same boundary, and the one that costs a + // person a dose rather than a night's sleep: quietening the repeat must + // not quieten the escalation. 04:30 Berlin on Jul 29 is 240 minutes past + // the Jul 28 window's end, so the untaken dose reaches RED — and both the + // missed-intake row and the notification must name the Jul 28 slot, not + // the day the tick happens to run on. + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-29T02:30:00.000Z")); // 04:30 Jul 29 + prismaMock.medication.findMany.mockResolvedValue([ + crossMidnightMedication("Europe/Berlin"), + ] as never); + setIntakeEvents([]); + + await handleReminderCheck([]); + + expect(prismaMock.medicationIntakeEvent.create).toHaveBeenCalledTimes(1); + expect(prismaMock.medicationIntakeEvent.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + scheduledFor: new Date("2026-07-28T21:45:00.000Z"), + takenAt: null, + skipped: false, + source: "REMINDER", + }), + }), + ); + expect(dispatchNotification).toHaveBeenCalledTimes(1); + expect(dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + phase: "RED", + scheduledAt: "2026-07-28T21:45:00.000Z", + date: "2026-07-28", + }), + }), + ); + }); + + it("does not mint a second missed row for a dose taken just before midnight", async () => { + // The RED placeholder is the escalation's permanent trace: mint it for a + // dose the user already logged and the medication reads as missed forever + // after, in compliance and in every history surface. + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-29T02:30:00.000Z")); // 04:30 Jul 29 + prismaMock.medication.findMany.mockResolvedValue([ + crossMidnightMedication("Europe/Berlin"), + ] as never); + setIntakeEvents([ + { + scheduledFor: new Date("2026-07-28T21:45:00.000Z"), + takenAt: new Date("2026-07-28T21:50:00.000Z"), + skipped: false, + attributionSource: "AUTO", + updatedAt: new Date("2026-07-28T21:50:00.000Z"), + }, + ]); + + await handleReminderCheck([]); + + expect(prismaMock.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(dispatchNotification).not.toHaveBeenCalled(); + }); }); diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts index ddf9c85f8..48e013be8 100644 --- a/src/lib/mcp/__tests__/tools.test.ts +++ b/src/lib/mcp/__tests__/tools.test.ts @@ -25,7 +25,6 @@ vi.mock("@/lib/feature-flags", () => ({ briefing: true, insightStatus: true, correlations: true, - healthScoreExplainer: true, })), })); // v1.22.0 — `search` reads the record directly via Prisma; stub it so the @@ -1098,7 +1097,6 @@ describe("get_ecg_recordings — v1.30 coverage review (G3)", () => { briefing: true, insightStatus: false, correlations: true, - healthScoreExplainer: true, } as never); const result = (await tool("get_ecg_recordings").run(CTX, {})) as { present: boolean; diff --git a/src/lib/measurement-reminders/__tests__/due-day.test.ts b/src/lib/measurement-reminders/__tests__/due-day.test.ts new file mode 100644 index 000000000..62fc66ade --- /dev/null +++ b/src/lib/measurement-reminders/__tests__/due-day.test.ts @@ -0,0 +1,204 @@ +/** + * The due line on a checkup is a CALENDAR-day statement. "Tomorrow" means the + * next date on the wall calendar, not roughly twenty-four hours from now, and + * most of the cases below are ones where those two answers differ. + * + * The calendar in question is the one the person's PROFILE is set to — the + * same clock the date printed beside the phrase is rendered in. The last group + * of tests pins that, because it was the second half of this defect: the + * phrase used to follow the device while the date beside it followed the + * profile, so a laptop set to another zone could show a date and a phrase that + * contradicted each other. + * + * Every instant here carries an explicit offset, so the suite says the same + * thing whatever zone the machine running it is set to — the CI box is on UTC + * and a laptop is not. + */ +import { describe, it, expect } from "vitest"; + +import { relativeDueKey } from "../due-day"; + +/** + * The profile zone under test. July is deliberate: no IANA zone shifts its + * clocks mid-July, so a fixed offset stands for the whole month and building + * 00:01 or 23:59 can never land on a skipped or repeated hour. + */ +const ZONE = "Europe/Berlin"; +const ZONE_OFFSET = "+02:00"; + +const TODAY = { key: "measurementReminders.nextDue.today", days: 0 }; +const TOMORROW = { key: "measurementReminders.nextDue.tomorrow", days: 1 }; +const NONE = { key: "measurementReminders.nextDue.none", days: 0 }; + +// Fri 17th, Sat 18th, Sun 19th of July 2026. +const FRIDAY = 17; +const SATURDAY = 18; +const SUNDAY = 19; + +const pad = (n: number) => String(n).padStart(2, "0"); + +/** A wall-clock time in {@link ZONE}, as the ISO string the API hands over. */ +function zoned(day: number, hour: number, minute = 0): string { + return `2026-07-${pad(day)}T${pad(hour)}:${pad(minute)}:00${ZONE_OFFSET}`; +} + +/** The same wall clock as the epoch millis a component reads off `Date.now()`. */ +function at(day: number, hour: number, minute = 0): number { + return Date.parse(zoned(day, hour, minute)); +} + +describe("relativeDueKey", () => { + it("still reads 'today' in the evening for a checkup that was due this morning", () => { + expect(relativeDueKey(zoned(FRIDAY, 9), at(FRIDAY, 20), ZONE)).toEqual( + TODAY, + ); + }); + + it("holds 'today' from first thing to last thing, either side of the due hour", () => { + // Both ends of the same date sit more than twelve hours from the due time, + // which is where a rolling-hours delta tips into the neighbouring day: it + // would call the 07:00 checkup overdue by bedtime, and the 21:00 one + // tomorrow at breakfast. + expect(relativeDueKey(zoned(FRIDAY, 7), at(FRIDAY, 22), ZONE)).toEqual( + TODAY, + ); + expect(relativeDueKey(zoned(FRIDAY, 21), at(FRIDAY, 6), ZONE)).toEqual( + TODAY, + ); + }); + + it("calls a Sunday 01:00 checkup two days out when read late on Friday", () => { + // Twenty-six hours apart, so a rolling-hours delta rounds to one day and + // says "tomorrow". Two dates separate them on the calendar. + expect(relativeDueKey(zoned(SUNDAY, 1), at(FRIDAY, 23), ZONE)).toEqual({ + key: "measurementReminders.nextDue.inDays", + days: 2, + }); + }); + + it("calls a Saturday 23:59 checkup tomorrow when read just after Friday midnight", () => { + // Just under forty-eight hours apart, so a rolling-hours delta rounds to + // two days. It is the very next date on the calendar. + expect( + relativeDueKey(zoned(SATURDAY, 23, 59), at(FRIDAY, 0, 1), ZONE), + ).toEqual(TOMORROW); + }); + + it("counts a checkup from yesterday as one day overdue, however recent", () => { + // Forty-five minutes in the past. It was still yesterday's checkup. + expect( + relativeDueKey(zoned(FRIDAY, 23, 30), at(SATURDAY, 0, 15), ZONE), + ).toEqual({ key: "measurementReminders.overdueByDays", days: 1 }); + }); + + it("counts whole calendar days for a checkup further out", () => { + expect(relativeDueKey(zoned(FRIDAY + 7, 6), at(FRIDAY, 22), ZONE)).toEqual({ + key: "measurementReminders.nextDue.inDays", + days: 7, + }); + }); + + it("keeps a whole clock-change day as one day", () => { + // The two days of the year that are not twenty-four hours long. On the + // October day Berlin gains an hour, 01:00 and 23:00 sit twenty-three hours + // apart; on the March day it loses one, 00:30 and 23:30 sit twenty-two + // apart. Both are one calendar date, and on both an hour count rounds up + // and calls the evening "tomorrow". + expect( + relativeDueKey( + "2026-10-25T22:00:00Z", // 23:00 Berlin, after the clocks go back + Date.parse("2026-10-25T00:00:00Z"), // 02:00 Berlin, before they do + ZONE, + ), + ).toEqual(TODAY); + expect( + relativeDueKey( + "2026-03-29T21:30:00Z", // 23:30 Berlin, after the clocks go forward + Date.parse("2026-03-28T23:30:00Z"), // 00:30 Berlin, before they do + ZONE, + ), + ).toEqual(TODAY); + }); + + it("says nothing is scheduled rather than crashing on a date it cannot read", () => { + expect(relativeDueKey("not-a-date", at(FRIDAY, 12), ZONE)).toEqual(NONE); + }); + + it("says nothing is scheduled when there is no due date at all", () => { + expect(relativeDueKey(null, at(FRIDAY, 12), ZONE)).toEqual(NONE); + }); +}); + +describe("relativeDueKey answers in the zone it is given", () => { + // One instant, read from two profiles on opposite sides of midnight. At + // 23:30 UTC on the 17th the checkup at 02:00 UTC on the 18th is tomorrow's; + // in Auckland it is already the 18th for both, so it is today's. Asserting + // BOTH in one test is what makes this bite: an implementation that ignores + // the argument and reads the machine's own clock returns the same answer + // twice, whatever zone that machine happens to be in. + const NOW = Date.parse("2026-07-17T23:30:00Z"); + const DUE = "2026-07-18T02:00:00Z"; + + it("gives two profiles either side of midnight two different answers", () => { + expect(relativeDueKey(DUE, NOW, "UTC")).toEqual(TOMORROW); + expect(relativeDueKey(DUE, NOW, "Pacific/Auckland")).toEqual(TODAY); + }); + + it("falls back to the printed date's zone, never the machine's, on a zone it cannot read", () => { + // 22:30 UTC is already the 18th in Berlin, so Berlin says today and UTC + // says tomorrow. A zone the profile mirror could not make sense of has to + // land where the date beside it lands, which is Berlin. + const now = Date.parse("2026-07-17T22:30:00Z"); + const due = "2026-07-18T00:30:00Z"; + + expect(relativeDueKey(due, now, "Not/AZone")).toEqual( + relativeDueKey(due, now, "Europe/Berlin"), + ); + expect(relativeDueKey(due, now, "Not/AZone")).toEqual(TODAY); + expect(relativeDueKey(due, now, "UTC")).toEqual(TOMORROW); + }); + + /** + * The machine's own clock, moved under the function's feet. Whatever the + * device is set to, the answer must not move — that is the whole difference + * between reading the profile and reading the laptop. Asserting it this way + * rather than by running the suite in a different zone means the property + * holds on the CI box and on a laptop alike, and it stays proven if someone + * reinstates a device-clock fallback for either the given zone or the + * unreadable one. + */ + const HOST_ZONES = ["UTC", "Pacific/Auckland", "America/Los_Angeles"]; + + function underEachHostZone(fn: () => unknown): unknown[] { + const original = process.env.TZ; + try { + return HOST_ZONES.map((hostZone) => { + process.env.TZ = hostZone; + return fn(); + }); + } finally { + process.env.TZ = original; + } + } + + it("ignores the zone the machine itself is set to", () => { + // 22:30 UTC: the 17th in Los Angeles, the 18th in Auckland and in Berlin. + const now = Date.parse("2026-07-17T22:30:00Z"); + const due = "2026-07-18T00:30:00Z"; + + expect(underEachHostZone(() => relativeDueKey(due, now, ZONE))).toEqual([ + TODAY, + TODAY, + TODAY, + ]); + }); + + it("ignores the machine's zone even when the given one is unreadable", () => { + const now = Date.parse("2026-07-17T22:30:00Z"); + const due = "2026-07-18T00:30:00Z"; + + expect( + underEachHostZone(() => relativeDueKey(due, now, "Not/AZone")), + ).toEqual([TODAY, TODAY, TODAY]); + }); +}); diff --git a/src/lib/measurement-reminders/due-day.ts b/src/lib/measurement-reminders/due-day.ts new file mode 100644 index 000000000..855df0943 --- /dev/null +++ b/src/lib/measurement-reminders/due-day.ts @@ -0,0 +1,78 @@ +/** + * The one place a Vorsorge reminder's next-due instant becomes the words a + * person reads: "heute" / "morgen" / "in N Tagen" / "überfällig seit N Tagen". + * + * It used to live inside the two surfaces that render it — the `/checkups` + * page and the dashboard summary card — as two hand-copied bodies. The page + * copy was fixed to compare CALENDAR days; the dashboard copy was not, and + * kept differencing raw instants. So the same reminder read "morgen" on one + * screen and "in 2 Tagen" on the other, and a reminder due this morning + * dropped out of "heute" over the course of the afternoon. One body here, both + * surfaces importing it, and the two cannot disagree again. + * + * Sits beside `scheduling.ts` (which computes the server-authoritative + * `nextDueAt`) because this is the read side of the same value. Nothing in + * this file touches the database or the network, so a client component can + * import it directly. + */ +import { DEFAULT_TIMEZONE, isValidTimezone } from "@/lib/tz/format"; +import { startOfLocalDayInTz } from "@/lib/tz/local-day"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** A fully-qualified i18n key plus the day count it interpolates. */ +export interface RelativeDue { + key: string; + days: number; +} + +/** + * v1.32.36 — returns a FULLY-QUALIFIED i18n key, not a bare tail. A call site + * that interpolates the tail onto the namespace root anchors the + * reverse-coverage guard at that bare namespace, which marks every key under + * it reachable and turns the guard into a no-op for the whole namespace. The + * guard now refuses that shape outright, so the key is built whole here. + * + * `timeZone` is required on purpose. It used to be implicit — the day floor + * silently took the DEVICE clock while the date printed next to it took the + * profile clock, so on a laptop set to another zone the card could print a + * date and a phrase that contradicted each other. Naming the zone is how a + * caller says which of those two clocks it means, and there is no default + * because the missing answer was the whole bug: a silent caller got something + * plausible-looking rather than an error. Pass what the neighbouring date + * label renders in — `useDisplayTimezone()` on the client. + */ +export function relativeDueKey( + nextDueAt: string | null, + now: number, + timeZone: string, +): RelativeDue { + if (!nextDueAt) return { key: "measurementReminders.nextDue.none", days: 0 }; + const due = new Date(nextDueAt); + if (Number.isNaN(due.getTime())) + return { key: "measurementReminders.nextDue.none", days: 0 }; + // Resolve exactly as `relativeCalendarDate` and `fmt.date` do (issue #490): + // a usable zone stands, anything else lands on Berlin. Note where it does + // NOT land — the device. Falling back to the host clock is what let the two + // halves of this card drift apart, and a zone the profile mirror could not + // make sense of has to end up wherever the printed date ends up. + const zone = isValidTimezone(timeZone) ? timeZone : DEFAULT_TIMEZONE; + // v1.18.9 (recon) — compare CALENDAR-day deltas in the user's zone, not a + // rolling-24h delta. A reminder due at 09:00 viewed the same evening at + // 20:00 must still read "heute", and one whose due calendar-day is before + // today must read "überfällig" — a raw `(due - now) / DAY_MS` round would + // mis-bucket both. Floor each instant to its local day start first. + const dueDay = startOfLocalDayInTz(due, zone).getTime(); + const nowDay = startOfLocalDayInTz(new Date(now), zone).getTime(); + const deltaDays = Math.round((dueDay - nowDay) / DAY_MS); + if (deltaDays < 0) + return { + key: "measurementReminders.overdueByDays", + days: Math.abs(deltaDays), + }; + if (deltaDays === 0) + return { key: "measurementReminders.nextDue.today", days: 0 }; + if (deltaDays === 1) + return { key: "measurementReminders.nextDue.tomorrow", days: 1 }; + return { key: "measurementReminders.nextDue.inDays", days: deltaDays }; +} diff --git a/src/lib/profile/rejected-fields.ts b/src/lib/profile/rejected-fields.ts new file mode 100644 index 000000000..12cd82a20 --- /dev/null +++ b/src/lib/profile/rejected-fields.ts @@ -0,0 +1,62 @@ +/** + * Naming the profile fields a save declined. + * + * `applyProfileUpdate` (`src/lib/auth/profile-update.ts`) writes every + * field that validated and reports the rest back: `data.rejectedFields` + * on a partial success, `details.issues` when nothing could be written + * at all. Both carry the schema key (`heightCm`, `gender`, ...) and a + * machine-readable code — never a sentence a person should read. + * + * Turning that key into words is the client's job, and every screen + * that writes the profile has to do it the same way, or the same + * rejection reads differently depending on where the person happened + * to be standing. This module is that one way: the settings account + * form and the onboarding baseline step both resolve the key through + * it, each passing the labels its own inputs already carry. + */ + +/** A single rejected-field entry from `applyProfileUpdate`'s wire shape + * (`details.issues` on failure, `data.rejectedFields` on a partial + * success) — `path` is the schema field name, never shown verbatim. */ +export interface RejectedProfileField { + path: string; + code: string; + message?: string; +} + +/** + * Maps a rejected field's schema `path` to the same i18n label already + * shown next to that input on the settings account form. The server + * only ever knows the field by its schema key — naming it in a + * person-facing sentence is the client's job, using labels that already + * exist and are already localized for that form. + * + * A screen with different labels for the same fields (the onboarding + * baseline step calls them by its own names) passes its own map to + * `describeRejectedProfileField` instead. + */ +export const PROFILE_FIELD_LABEL_KEYS: Record = { + email: "auth.email", + heightCm: "settings.height", + dateOfBirth: "settings.dateOfBirth", + gender: "settings.gender", + fullName: "settings.identity.fullName", + insurerName: "settings.identity.insurer", + insuranceNumber: "settings.identity.insuranceNumber", +}; + +/** + * Renders the first rejected field's label, falling back to its raw + * schema key for a field the calling screen has no input for + * (defensive — every field a form submits is expected in its map). + */ +export function describeRejectedProfileField( + fields: RejectedProfileField[] | undefined, + t: (key: string) => string, + labelKeys: Record = PROFILE_FIELD_LABEL_KEYS, +): string | null { + const first = fields?.[0]; + if (!first) return null; + const labelKey = labelKeys[first.path]; + return labelKey ? t(labelKey) : first.path; +} diff --git a/src/lib/queries/__tests__/use-dashboard-snapshot.test.tsx b/src/lib/queries/__tests__/use-dashboard-snapshot.test.tsx index e1b85b32b..ba22b54c1 100644 --- a/src/lib/queries/__tests__/use-dashboard-snapshot.test.tsx +++ b/src/lib/queries/__tests__/use-dashboard-snapshot.test.tsx @@ -194,6 +194,53 @@ describe("prefetchDashboardSnapshot — hydration-safe promise handoff", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it("a cell that already carries data never parks a response, so a write's refetch reads the server as it is AFTER the write", async () => { + // The read-after-write rule for the handoff. A parked response is a copy of + // the server from the moment it was requested; handing it to a later + // `queryFn` run serves pre-write data to a refetch whose entire purpose is + // to see the write. The dashboard reaches `queryFn` again only via the + // 120 s poll, a focus refetch, or the invalidation a mutation fires — never + // via a remount (`refetchOnMount: false`) — so every consumer of a parked + // response is one of those three. + let serverBody: Record = { + layout: { widgets: [] }, + tiles: {}, + medsToday: { takenToday: 0 }, + }; + const fetchMock = vi + .fn() + .mockImplementation(() => Promise.resolve(envelopeResponse(serverBody))); + vi.stubGlobal("fetch", fetchMock); + getQueryDataMock.mockReturnValue({ widgets: [{ id: "existing" }] }); + + // The user comes back to the dashboard after a few minutes away. The cell + // still holds the pre-write body, so the page paints from it at once. + prefetchDashboardSnapshot( + fakeQueryClient({ + data: { tiles: {} }, + dataUpdatedAt: Date.now() - 5 * 60_000, + }), + ); + expect(fetchMock).not.toHaveBeenCalled(); + + // They record a dose. The server now reports it taken. + serverBody = { + layout: { widgets: [] }, + tiles: {}, + medsToday: { takenToday: 1 }, + }; + + // The write's invalidation refetches the mounted cell. + useDashboardSnapshot(); + const opts = lastOptsOf(useQueryMock); + const snap = await ( + opts.queryFn as () => Promise<{ medsToday: { takenToday: number } }> + )(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(snap.medsToday.takenToday).toBe(1); + }); + it("falls back to a fresh fetch when the preload failed — the mounted cell owns error surfacing", async () => { const layout = { widgets: [] }; const fetchMock = vi diff --git a/src/lib/queries/use-dashboard-snapshot.ts b/src/lib/queries/use-dashboard-snapshot.ts index 8608171f0..62c99c199 100644 --- a/src/lib/queries/use-dashboard-snapshot.ts +++ b/src/lib/queries/use-dashboard-snapshot.ts @@ -99,15 +99,25 @@ export function prefetchDashboardSnapshot(queryClient: QueryClient) { } // A fresh handoff is already in flight / parked. if (preloadedSnapshot) return; - // Cache already warm (return-to-dashboard within staleTime) — the - // mounted cell serves it without ever calling `queryFn`; fetching here - // would be a wasted request parked in the slot. + // The handoff exists to cut the COLD first-load waterfall, so it is only + // ever warranted when no snapshot cell has produced data yet. Once a cell + // carries data the page paints from it immediately and there is no waterfall + // left to cut — but the parked response would still sit in the slot, because + // `refetchOnMount: false` means a remount never calls `queryFn` to consume + // it. The next `queryFn` run is then whatever comes along: the 120 s poll, a + // focus refetch, or the invalidation that follows the user's own write. All + // three exist to fetch data that is NEWER than what the cell holds, and all + // three would instead be handed a response whose request was issued BEFORE + // the write. That is how a just-recorded dose kept reading as still due + // until the following poll cleared it. + // // v1.21.3 (b) — the live cell is locale-keyed (`["dashboard","snapshot", // locale]`), but the preloader runs before the locale context is in scope. - // Probe ALL `["dashboard","snapshot"]` cells (prefix) and treat the freshest - // as the warm-slot signal: if any locale's cell is fresh, skip the preload; - // otherwise warm the request through the handoff and let the mounted cell - // commit it under whichever locale the user lands on. + // Probe ALL `["dashboard","snapshot"]` cells (prefix): if ANY locale's cell + // has data, skip the preload; otherwise warm the request through the handoff + // and let the mounted cell commit it under whichever locale the user lands + // on. A cell that exists but never resolved (an error slot) reports + // `dataUpdatedAt === 0` and correctly still warms. const states = queryClient .getQueriesData({ queryKey: queryKeys.dashboardSnapshot() }) .map(([key]) => queryClient.getQueryState(key)); @@ -115,7 +125,7 @@ export function prefetchDashboardSnapshot(queryClient: QueryClient) { (max, s) => (s ? Math.max(max, s.dataUpdatedAt) : max), 0, ); - if (freshest > 0 && Date.now() - freshest < PRELOAD_MAX_AGE_MS) { + if (freshest > 0) { return; } const promise = fetchDashboardSnapshot(); diff --git a/src/lib/validations/admin.ts b/src/lib/validations/admin.ts index be2cce810..12725cc4a 100644 --- a/src/lib/validations/admin.ts +++ b/src/lib/validations/admin.ts @@ -101,6 +101,5 @@ export const adminSettingsSchema = z assistantBriefingEnabled: z.boolean().optional(), assistantInsightStatusEnabled: z.boolean().optional(), assistantCorrelationsEnabled: z.boolean().optional(), - assistantHealthScoreExplainerEnabled: z.boolean().optional(), }) .strict(); diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 8af09e801..b7917266e 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -267,8 +267,26 @@ const cycleSpanSchema = z * ciphertext envelope verbatim — the backup never decrypts it, so the * owner's free-text note round-trips encrypted (and a wrong-surface leak is * impossible). `symptomKeys` carries the seeded catalogue keys so the - * restore can re-link without exporting internal join ids. + * restore can re-link without exporting internal join ids, and + * `symptomSeverities` carries how hard each of them hit. */ +/** + * The intensity recorded against one of a day's symptoms. + * + * A sparse annotation over `symptomKeys`, not a replacement for it: the keys + * still say which symptoms the day had, this says how hard the ones that were + * rated hit. Only rated links appear, so a missing entry means the person never + * put a number on it. `severity` is left as a plain integer here on purpose — + * the 1-4 range is checked when the row is written, so a file carrying a value + * outside it loses that one intensity instead of failing to restore at all. + */ +const cycleSymptomSeveritySchema = z + .object({ + key: z.string().min(1), + severity: z.number().int().nullable().optional(), + }) + .passthrough(); + const cycleDayLogSchema = z .object({ id: z.string().min(1).optional(), @@ -298,6 +316,7 @@ const cycleDayLogSchema = z createdAt: isoDateTime.optional(), updatedAt: isoDateTime.optional(), symptomKeys: z.array(z.string()).default([]), + symptomSeverities: z.array(cycleSymptomSeveritySchema).default([]), }) .passthrough(); @@ -595,7 +614,6 @@ const appSettingsBackupSchema = z assistantBriefingEnabled: z.boolean(), assistantInsightStatusEnabled: z.boolean(), assistantCorrelationsEnabled: z.boolean(), - assistantHealthScoreExplainerEnabled: z.boolean(), moduleAvailabilityJson: z.unknown().nullable(), documentMaxFileBytes: z.number().int(), documentQuotaBytes: z.string().regex(/^\d+$/), diff --git a/tests/integration/admin-backups-canonical-roundtrip.test.ts b/tests/integration/admin-backups-canonical-roundtrip.test.ts index cfd86f30e..56d6f9687 100644 --- a/tests/integration/admin-backups-canonical-roundtrip.test.ts +++ b/tests/integration/admin-backups-canonical-roundtrip.test.ts @@ -155,7 +155,6 @@ describe("canonical disaster-recovery backup round-trip", () => { assistantBriefingEnabled: false, assistantInsightStatusEnabled: false, assistantCorrelationsEnabled: false, - assistantHealthScoreExplainerEnabled: false, moduleAvailabilityJson: { insights: true, nutrients: false }, documentMaxFileBytes: 12_345_678, documentQuotaBytes: BigInt("9876543210"), diff --git a/tests/integration/backup-cycle-symptom-severity-restore.test.ts b/tests/integration/backup-cycle-symptom-severity-restore.test.ts new file mode 100644 index 000000000..c55095e2b --- /dev/null +++ b/tests/integration/backup-cycle-symptom-severity-restore.test.ts @@ -0,0 +1,317 @@ +/** + * How hard a symptom hit, through the whole backup pipe. + * + * `CycleSymptomLink.severity` is the 1-4 intensity the log-day sheet writes and + * the day-log DTO reads back. The backup carried only `symptomKeys`, so a day + * recorded as "cramps, and they were a 4" exported as "cramps" and restored as + * "cramps". The symptom was there, the number was gone, and nothing failed — + * which is why it went unnoticed. + * + * These tests assert on ROWS, never on the payload object. A writer test and a + * reader test were both green through the last defect on this exact path while + * the assembly between them dropped the field, so the only thing worth proving + * is what ends up in `cycle_symptom_links` after a real export and a real + * restore: the REAL `buildFullBackupPayload` writes the file, the REAL + * `parseBackupPayload` reads it back, and the REAL admin restore route puts the + * rows down. + * + * The restore wipes day-logs and recreates them, so the links it writes are new + * rows. To make that impossible to mistake, the live severities are overwritten + * with a different number before the restore runs: whatever comes back came out + * of the file. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { encrypt } from "@/lib/crypto"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { parseBackupPayload } from "@/lib/validations/backup"; +import { POST } from "@/app/api/admin/backups/[id]/restore/route"; +import { invalidateUserData } from "@/lib/cache/invalidate"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserData: vi.fn(), +})); + +const RATED_KEY = "custom:severity-rated"; +const UNRATED_KEY = "custom:severity-unrated"; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + vi.mocked(invalidateUserData).mockClear(); +}); + +function makeRequest(id: string) { + return new Request(`http://localhost/api/admin/backups/${id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }); +} + +/** + * One account with a day-log carrying two symptoms: one rated 4, one never + * rated. Returns the ids the assertions read back through. + */ +async function seedAccount(username: string) { + const prisma = getPrismaClient(); + const owner = await prisma.user.create({ + data: { + username, + email: `${username}@example.test`, + role: "ADMIN", + }, + }); + const session = await prisma.session.create({ + data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + + const category = await prisma.cycleSymptomCategory.findFirstOrThrow(); + const rated = await prisma.cycleSymptom.create({ + data: { + userId: owner.id, + categoryId: category.id, + key: RATED_KEY, + labelKey: "cycle.symptom.custom", + labelEncrypted: encrypt("Cramps"), + sortOrder: 1, + }, + }); + const unrated = await prisma.cycleSymptom.create({ + data: { + userId: owner.id, + categoryId: category.id, + key: UNRATED_KEY, + labelKey: "cycle.symptom.custom", + labelEncrypted: encrypt("Tender skin"), + sortOrder: 2, + }, + }); + await prisma.cycleProfile.create({ + data: { + userId: owner.id, + goal: "GENERAL_HEALTH", + cycleTrackingEnabled: true, + typicalCycleLength: 29, + }, + }); + const cycle = await prisma.menstrualCycle.create({ + data: { + userId: owner.id, + startDate: "2026-07-01", + periodEndDate: "2026-07-05", + tz: "Europe/Berlin", + }, + }); + const dayLog = await prisma.cycleDayLog.create({ + data: { + userId: owner.id, + cycleId: cycle.id, + date: "2026-07-03", + flow: "MEDIUM", + source: "MANUAL", + tz: "Europe/Berlin", + symptomLinks: { + create: [ + { symptomId: rated.id, severity: 4 }, + // Never rated. NULL here is the whole point of the second case: it + // has to come back NULL, not 0 and not 1. + { symptomId: unrated.id }, + ], + }, + }, + }); + + return { prisma, owner, rated, unrated, dayLog }; +} + +/** + * Overwrite the live intensities so a severity that survives the restore cannot + * have come from a row that was simply left alone. + */ +async function poisonLiveSeverities(userId: string) { + const prisma = getPrismaClient(); + const links = await prisma.cycleSymptomLink.findMany({ + where: { dayLog: { userId } }, + select: { dayLogId: true, symptomId: true }, + }); + for (const link of links) { + await prisma.cycleSymptomLink.update({ + where: { + dayLogId_symptomId: { + dayLogId: link.dayLogId, + symptomId: link.symptomId, + }, + }, + data: { severity: 1 }, + }); + } +} + +async function storeBackup(userId: string, parsed: unknown, type: string) { + const prisma = getPrismaClient(); + return prisma.dataBackup.create({ + data: { userId, type, data: encrypt(JSON.stringify(parsed)) }, + }); +} + +async function severityByKey(userId: string) { + const prisma = getPrismaClient(); + const links = await prisma.cycleSymptomLink.findMany({ + where: { dayLog: { userId } }, + select: { severity: true, symptom: { select: { key: true } } }, + }); + return Object.fromEntries( + links.map((l) => [l.symptom.key, l.severity]), + ) as Record; +} + +describe("a symptom's recorded intensity across export and restore", () => { + it("comes back on the row it was recorded against", async () => { + const { prisma, owner, dayLog } = await seedAccount("cycle-severity-owner"); + + const { payload: built } = await buildFullBackupPayload(prisma, owner.id, { + purpose: "disaster-recovery", + }); + const parsed = parseBackupPayload(JSON.stringify(built)); + const backup = await storeBackup( + owner.id, + parsed, + "CYCLE_SYMPTOM_SEVERITY_ROUND_TRIP", + ); + + await poisonLiveSeverities(owner.id); + + const response = await POST( + makeRequest(backup.id) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + expect(response.status).toBe(200); + + // Rows, not payload keys. + const restoredDayLog = await prisma.cycleDayLog.findUniqueOrThrow({ + where: { id: dayLog.id }, + }); + expect(restoredDayLog.date).toBe("2026-07-03"); + expect(await severityByKey(owner.id)).toEqual({ + [RATED_KEY]: 4, + [UNRATED_KEY]: null, + }); + }); + + it("stays absent when the file was written before intensities were carried", async () => { + const { prisma, owner } = await seedAccount("cycle-severity-legacy"); + + const { payload: built } = await buildFullBackupPayload(prisma, owner.id, { + purpose: "disaster-recovery", + }); + + // A file from the release before this one: the key is not there at all. + // Not an empty list — absent, the way an older writer left it. + const legacy = JSON.parse(JSON.stringify(built)) as { + cycleDayLogs: Array>; + }; + for (const day of legacy.cycleDayLogs) delete day.symptomSeverities; + expect(legacy.cycleDayLogs.some((d) => "symptomSeverities" in d)).toBe( + false, + ); + expect(legacy.cycleDayLogs[0].symptomKeys).toEqual( + expect.arrayContaining([RATED_KEY, UNRATED_KEY]), + ); + + // The old file has to parse, not be refused for the field it never had. + const parsed = parseBackupPayload(JSON.stringify(legacy)); + const backup = await storeBackup( + owner.id, + parsed, + "CYCLE_SYMPTOM_SEVERITY_LEGACY_FILE", + ); + + await poisonLiveSeverities(owner.id); + + const response = await POST( + makeRequest(backup.id) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + expect(response.status).toBe(200); + + // Both symptoms survive; neither is given a number the file never held. + expect(await severityByKey(owner.id)).toEqual({ + [RATED_KEY]: null, + [UNRATED_KEY]: null, + }); + }); + + it("keeps the symptom and drops an intensity outside the scale", async () => { + const { prisma, owner } = await seedAccount("cycle-severity-offscale"); + + const { payload: built } = await buildFullBackupPayload(prisma, owner.id, { + purpose: "disaster-recovery", + }); + + // A hand-edited file, or one from some later release with a wider scale. + // Refusing the whole restore over it would cost the account everything to + // save one number. + const offScale = JSON.parse(JSON.stringify(built)) as { + cycleDayLogs: Array<{ + symptomSeverities: Array<{ key: string; severity: number }>; + }>; + }; + for (const day of offScale.cycleDayLogs) { + day.symptomSeverities = [ + { key: RATED_KEY, severity: 9 }, + // Names a symptom this day does not carry — nothing to attach it to. + { key: "custom:not-on-this-day", severity: 2 }, + ]; + } + + const parsed = parseBackupPayload(JSON.stringify(offScale)); + const backup = await storeBackup( + owner.id, + parsed, + "CYCLE_SYMPTOM_SEVERITY_OFF_SCALE", + ); + + await poisonLiveSeverities(owner.id); + + const response = await POST( + makeRequest(backup.id) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + expect(response.status).toBe(200); + + expect(await severityByKey(owner.id)).toEqual({ + [RATED_KEY]: null, + [UNRATED_KEY]: null, + }); + }); +});