mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
67 files changed
+2769
-587
No files matched your search
@@ -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,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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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";
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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(""coach":false");
|
||||
expect(html).toContain(""insightStatus":false");
|
||||
expect(html).toContain(""healthScoreExplainer":false");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Vendored
+272
-230
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,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();
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user