Merge pull request #670 from MBombeck/release/v1.34.4

release/v1.34.4
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-31 08:53:46 +02:00
commit 0e42b7a03e
67 files changed
+2769 -587

No files matched your search

+49
View File
@@ -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.
+1 -1
View File
@@ -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.
+2 -4
View File
@@ -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": {
+2 -4
View File
@@ -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": {
+2 -4
View File
@@ -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": {
+2 -4
View File
@@ -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": {
+2 -4
View File
@@ -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": {
+2 -4
View File
@@ -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": {
+1 -1
View File
@@ -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",
@@ -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";
+7 -8
View File
@@ -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 /
+1 -1
View File
@@ -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/*`
@@ -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([]);
});
});
@@ -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),
@@ -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 () => {
@@ -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));
+17 -40
View File
@@ -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<string, number>;
perNight: Array<{ dayKey: string; stages: Record<string, number> }>;
} | null> {
): Promise<SleepStageComposition | null> {
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<string, number> = {};
let totalMinutes = 0;
const perNight = nights
.map((n) => {
const nightStages: Record<string, number> = {};
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
@@ -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,
});
});
+1 -2
View File
@@ -13,8 +13,7 @@
* "coach": true,
* "briefing": true,
* "insightStatus": true,
* "correlations": true,
* "healthScoreExplainer": true
* "correlations": true
* }
* },
* "error": null
@@ -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
@@ -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);
@@ -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);
@@ -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);
@@ -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);
@@ -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);
@@ -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);
+3 -17
View File
@@ -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}
/>
<SettingsToggle
label={t("admin.assistant.healthScoreExplainer.title")}
description={t("admin.assistant.healthScoreExplainer.description")}
checked={raw?.assistantHealthScoreExplainerEnabled ?? true}
onCheckedChange={(checked) =>
mutation.mutate({
assistantHealthScoreExplainerEnabled: checked,
})
}
disabled={disabledSubs}
/>
</div>
</div>
</SettingsCard>
@@ -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("<InsightStatusCard> — assistant.insightStatus gate", () => {
briefing: true,
insightStatus: true,
correlations: true,
healthScoreExplainer: true,
}),
);
expect(html).toContain("Your pulse is stable");
@@ -67,7 +65,6 @@ describe("<InsightStatusCard> — assistant.insightStatus gate", () => {
briefing: true,
insightStatus: false,
correlations: true,
healthScoreExplainer: true,
}),
);
expect(html).toBe("");
@@ -82,7 +79,6 @@ describe("<InsightStatusCard> — assistant.insightStatus gate", () => {
briefing: false,
insightStatus: false,
correlations: false,
healthScoreExplainer: false,
}),
);
expect(html).toBe("");
@@ -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("<SleepStageStackedBar>", () => {
});
});
});
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,
);
});
});
@@ -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<string, number>;
/**
* 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<string, string> = {
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<string, number | string>;
/**
* 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<string, number | string> = {
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<string, number | string> | 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}
</span>
<span className="text-muted-foreground">
{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}%`}
</span>
</div>
);
@@ -351,6 +460,26 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
<Cell fill={STAGE_COLORS[stage]} />
</Bar>
))}
{hasNap && (
<Bar
key={NAP_KEY}
dataKey={NAP_KEY}
stackId="stages"
fill={STAGE_COLORS[NAP_KEY]}
// The outline is what separates the nap from the stage
// beneath it; the two fills alone measure as low as
// 1.04:1. See NAP_SEPARATOR.
stroke={NAP_SEPARATOR.stroke}
strokeWidth={NAP_SEPARATOR.strokeWidth}
isAnimationActive={false}
>
<Cell
fill={STAGE_COLORS[NAP_KEY]}
stroke={NAP_SEPARATOR.stroke}
strokeWidth={NAP_SEPARATOR.strokeWidth}
/>
</Bar>
)}
</BarChart>
</ResponsiveContainer>
</div>
@@ -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() {
) : (
<ul className="space-y-2">
{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,
@@ -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", {
@@ -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<string, string | number>) => {
const raw = resolveKey(bundle as Record<string, unknown>, 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");
});
});
@@ -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<string, unknown> {
const body: Record<string, unknown> = {};
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<string, unknown>,
): Promise<BaselineProfileResponse> {
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, string | number>) => string,
labelKeys: Record<string, string>,
): 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 | number>) => string,
): string {
if (errorCode) {
const key = `apiErrors.${errorCode}`;
const translated = t(key);
if (translated !== key) return translated;
}
return t("onboarding.errorGeneric");
}
+33 -14
View File
@@ -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<string, unknown> = {};
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
@@ -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", () => ({
@@ -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}`;
@@ -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<string, string> = {
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;
}
@@ -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";
@@ -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("&quot;coach&quot;:false");
expect(html).toContain("&quot;insightStatus&quot;:false");
expect(html).toContain("&quot;healthScoreExplainer&quot;:false");
});
});
+1 -4
View File
@@ -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<FeatureFlagsPayload> {
@@ -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<typeof import("@/lib/tz/resolver")>();
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<typeof import("@/lib/logging/context")>();
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<string, number>; 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);
});
});
+40
View File
@@ -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<string, SleepSession[]>();
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
@@ -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<string, number>;
/**
* 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<string, number>;
/** 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<string, number> = {};
let totalMinutes = 0;
const perNight: SleepStageCompositionNight[] = [];
for (const { night, main, naps } of days) {
const nightStages: Record<string, number> = {};
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,
};
}
+60
View File
@@ -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();
});
});
+272 -230
View File
@@ -463,242 +463,284 @@ export class ServerCache<T> {
}
/**
* 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<unknown>({
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<unknown>({
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<unknown>({
name: "achievements",
maxEntries: 1000,
ttlMs: 60_000,
staleTtlMs: 600_000,
}),
dashboardWidgets: new ServerCache<unknown>({
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<WorkoutsProjection>({
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<unknown>({
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<unknown>({
name: "medicationCompliance",
maxEntries: 2000,
ttlMs: 900_000,
staleTtlMs: 600_000,
}),
moodAnalytics: new ServerCache<unknown>({
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<unknown>({
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<unknown>({
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<unknown>({
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<unknown>({
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<unknown>({
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<unknown>({
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<unknown>({
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<unknown>({
name: "achievements",
maxEntries: 1000,
ttlMs: 60_000,
staleTtlMs: 600_000,
}),
dashboardWidgets: new ServerCache<unknown>({
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<WorkoutsProjection>({
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<unknown>({
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<unknown>({
name: "medicationCompliance",
maxEntries: 2000,
ttlMs: 900_000,
staleTtlMs: 600_000,
}),
moodAnalytics: new ServerCache<unknown>({
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<unknown>({
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<unknown>({
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<unknown>({
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<unknown>({
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<unknown>({
name: "medicationListLayout",
maxEntries: 500,
ttlMs: 300_000,
}),
} as const;
}
type ServerCacheRegistry = ReturnType<typeof createServerCaches>;
/**
* 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
+9 -2
View File
@@ -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 }],
});
});
});
+61 -9
View File
@@ -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<CycleTrackingGoal>([
"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<T extends string>(
value: string | null | undefined,
allow: ReadonlySet<T>,
@@ -326,7 +371,8 @@ function enumOrNull<T extends string>(
* 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<string, number>();
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 } }
: {}),
},
});
@@ -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);
});
});
@@ -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,
+19 -6
View File
@@ -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<DashboardSnapshot>,
`${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,
@@ -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"),
@@ -82,7 +82,6 @@ function buildClient(flags: Partial<AssistantFlagSet>): QueryClient {
briefing: true,
insightStatus: true,
correlations: true,
healthScoreExplainer: true,
...flags,
},
});
@@ -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" };
@@ -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<string, unknown> } };
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);
}
});
});
+5 -16
View File
@@ -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<AssistantFlagSet> {
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantCorrelationsEnabled: true,
assistantHealthScoreExplainerEnabled: true,
},
});
@@ -91,8 +83,6 @@ export async function getAssistantFlags(): Promise<AssistantFlagSet> {
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 };
@@ -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<string, unknown>[] = [];
function setIntakeEvents(events: Record<string, unknown>[]): void {
intakeEvents = events;
}
function readIntakeEvents(args?: {
where?: { scheduledFor?: { gte?: Date; lte?: Date } };
}): Record<string, unknown>[] {
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<typeof readIntakeEvents>[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();
});
});
-2
View File
@@ -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;
@@ -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]);
});
});
+78
View File
@@ -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 };
}
+62
View File
@@ -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<string, string> = {
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<string, string> = 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;
}
@@ -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<string, unknown> = {
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
+18 -8
View File
@@ -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();
-1
View File
@@ -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();
+20 -2
View File
@@ -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+$/),
@@ -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"),
@@ -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<string, number | null>;
}
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<typeof POST>[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<Record<string, unknown>>;
};
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<typeof POST>[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<typeof POST>[0],
{ params: Promise.resolve({ id: backup.id }) },
);
expect(response.status).toBe(200);
expect(await severityByKey(owner.id)).toEqual({
[RATED_KEY]: null,
[UNRATED_KEY]: null,
});
});
});