diff --git a/eslint-plugins/healthlog/api-fetch-required.js b/eslint-plugins/healthlog/api-fetch-required.js new file mode 100644 index 000000000..4b6828070 --- /dev/null +++ b/eslint-plugins/healthlog/api-fetch-required.js @@ -0,0 +1,131 @@ +/** + * @fileoverview ESLint rule — client-side `/api/...` calls must route + * through the typed `apiFetch` wrapper. + * + * `src/lib/api/api-fetch.ts` owns the `.ok` check, the + * `{ data, error, meta? }` envelope unwrap, and the `ApiError` shape + * (message via `readError`, `status`, error-side `meta`). A raw + * `fetch("/api/…")` in a component re-implements that contract by hand + * — the exact drift this wrapper retired — so this rule flags it at + * authoring time and in CI. + * + * Complements `healthlog/safe-fetch-required`: that rule covers + * OUTBOUND egress (absolute / variable-sourced URLs) and exempts + * same-origin relative paths; this rule covers exactly those same-origin + * `/api/...` paths inside the client surface. + * + * Allowed: + * - `apiFetch` / verb helpers / `apiFetchEnvelope` / `apiFetchRaw` — + * distinct identifiers, never match. + * - calls inside `src/lib/api/api-fetch.ts` — the wrapper's own `fetch`. + * - test files (`*.test.ts(x)`, `__tests__/`, `__mocks__/`) — they + * mock or assert against `fetch` directly. + * + * The check is a syntactic `CallExpression` match against a bare + * `fetch(` callee (Identifier `fetch`) and `globalThis.fetch(` / + * `window.fetch(` / `self.fetch(` member forms, where the first + * argument is a string literal or template literal whose head starts + * with `/api/`. Variable-sourced or absolute URLs are the + * safe-fetch-required rule's territory and stay out of scope here. + * + * @see src/lib/api/api-fetch.ts + * @see eslint-plugins/healthlog/safe-fetch-required.js + */ + +"use strict"; + +// Files exempt from the rule — the wrapper internals and the test +// surface. Posix-style suffix matches against the absolute filename via +// `String#includes`, mirroring the safe-fetch-required convention. +const EXEMPT_FILES = ["src/lib/api/api-fetch.ts"]; + +// Enforce across the client-facing source roots. `src/lib/` stays out: +// server-side lib code never self-calls `/api/` routes, and the +// safe-fetch rule already covers its outbound calls. +const ENFORCED_ROOTS = ["src/components/", "src/app/"]; + +function toPosix(filename) { + return filename.replace(/\\/g, "/"); +} + +function isTestFile(posix) { + return ( + /\.test\.[cm]?[jt]sx?$/.test(posix) || + /\.spec\.[cm]?[jt]sx?$/.test(posix) || + posix.includes("/__tests__/") || + posix.includes("/__mocks__/") + ); +} + +function isEnforced(filename) { + const posix = toPosix(filename); + if (!ENFORCED_ROOTS.some((root) => posix.includes(root))) return false; + if (EXEMPT_FILES.some((f) => posix.includes(f))) return false; + if (isTestFile(posix)) return false; + return true; +} + +/** @type {import("eslint").Rule.RuleModule} */ +const apiFetchRequiredRule = { + meta: { + type: "problem", + docs: { + description: + "Same-origin /api/ calls must route through the apiFetch wrapper (src/lib/api/api-fetch.ts).", + }, + schema: [], + messages: { + rawApiFetch: + 'Raw fetch("/api/…") re-implements the envelope unwrap + error contract by hand. Import { apiFetch } (or a verb helper / apiFetchRaw) from "@/lib/api/api-fetch" and call it instead.', + }, + }, + create(context) { + const filename = context.filename ?? context.getFilename?.(); + if (!filename || !isEnforced(filename)) { + return {}; + } + + function isFetchCallee(callee) { + // Bare `fetch(...)`. + if (callee.type === "Identifier" && callee.name === "fetch") return true; + // `globalThis.fetch(...)` / `window.fetch(...)` / `self.fetch(...)`. + if ( + callee.type === "MemberExpression" && + !callee.computed && + callee.property.type === "Identifier" && + callee.property.name === "fetch" && + callee.object.type === "Identifier" && + (callee.object.name === "globalThis" || + callee.object.name === "window" || + callee.object.name === "self") + ) { + return true; + } + return false; + } + + function isApiPath(arg) { + if (!arg) return false; + const isApiHead = (head) => head.startsWith("/api/"); + // `fetch("/api/…")` + if (arg.type === "Literal" && typeof arg.value === "string") { + return isApiHead(arg.value); + } + // `fetch(`/api/${id}`)` — inspect the template's first cooked chunk. + if (arg.type === "TemplateLiteral" && arg.quasis.length > 0) { + return isApiHead(arg.quasis[0].value.cooked ?? ""); + } + return false; + } + + return { + CallExpression(node) { + if (!isFetchCallee(node.callee)) return; + if (!isApiPath(node.arguments[0])) return; + context.report({ node, messageId: "rawApiFetch" }); + }, + }; + }, +}; + +module.exports = apiFetchRequiredRule; diff --git a/eslint-plugins/healthlog/index.js b/eslint-plugins/healthlog/index.js index 03d4349aa..d5849ae5f 100644 --- a/eslint-plugins/healthlog/index.js +++ b/eslint-plugins/healthlog/index.js @@ -4,16 +4,19 @@ * * - `queryKey-factory` — bare-array queryKey / mutationKey bypass guard. * - `safe-fetch-required` — outbound fetch must route through safeFetch. + * - `api-fetch-required` — client /api/ calls must route through apiFetch. */ "use strict"; const queryKeyFactory = require("./queryKey-factory.js"); const safeFetchRequired = require("./safe-fetch-required.js"); +const apiFetchRequired = require("./api-fetch-required.js"); module.exports = { rules: { "queryKey-factory": queryKeyFactory.rules["queryKey-factory"], "safe-fetch-required": safeFetchRequired, + "api-fetch-required": apiFetchRequired, }, }; diff --git a/eslint-plugins/healthlog/safe-fetch-required.js b/eslint-plugins/healthlog/safe-fetch-required.js index 7a468efb0..32446780f 100644 --- a/eslint-plugins/healthlog/safe-fetch-required.js +++ b/eslint-plugins/healthlog/safe-fetch-required.js @@ -39,6 +39,10 @@ const EXEMPT_FILES = [ "src/lib/safe-fetch.ts", "src/lib/safe-fetch-dispatcher.ts", + // The same-origin `/api/...` envelope wrapper: its `fetch` calls are + // relative-path by contract (the api-fetch-required rule pins every + // client call site onto it), so the egress defences don't apply. + "src/lib/api/api-fetch.ts", ]; // Only enforce inside the application source roots. Scripts, config, and diff --git a/scripts/test-notifications.ts b/scripts/test-notifications.ts index 110ce3cef..8d77b9d57 100644 --- a/scripts/test-notifications.ts +++ b/scripts/test-notifications.ts @@ -18,7 +18,7 @@ import { PrismaPg } from "@prisma/adapter-pg"; import { decrypt } from "@/lib/crypto"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; import { parseScheduleRecurrence } from "@/lib/medication-schedule"; -import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/timezone"; +import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/tz/local-day"; const DATABASE_URL = process.env.DATABASE_URL!; @@ -33,7 +33,7 @@ function parseTimeToMinutes(value: string): number { return h * 60 + m; } -// getUserTodayBounds and getDayOfWeekInTz imported from @/lib/timezone +// getUserTodayBounds and getDayOfWeekInTz imported from @/lib/tz/local-day const dayLabels = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"]; diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 27a4f8ad7..0ac74cee1 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -2,7 +2,9 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; +import { toast } from "sonner"; import { queryKeys } from "@/lib/query-keys"; +import { useTranslations } from "@/lib/i18n/context"; import type { TimeFormatPreference } from "@/lib/format-locale"; import { isTimeFormatPreference, storeTimeFormat } from "@/lib/time-format"; @@ -137,6 +139,7 @@ export function useAuth() { export function useLogout() { const queryClient = useQueryClient(); const router = useRouter(); + const { t } = useTranslations(); return useMutation({ mutationFn: async () => { @@ -147,5 +150,8 @@ export function useLogout() { queryClient.invalidateQueries({ queryKey: queryKeys.auth() }); router.push("/auth/login"); }, + // v1.16.4 — a network-failed logout used to do nothing at all (the + // menu closed, the session stayed); a toast names the failure. + onError: () => toast.error(t("common.networkError")), }); } diff --git a/src/lib/__tests__/dashboard-layout.test.ts b/src/lib/__tests__/dashboard-layout.test.ts index 29f2fc330..0a8fe9a33 100644 --- a/src/lib/__tests__/dashboard-layout.test.ts +++ b/src/lib/__tests__/dashboard-layout.test.ts @@ -124,7 +124,7 @@ describe("serializeDashboardLayout() — tileVisible persistence", () => { /** * v1.4.16 Fix A5 — top-tile selector real-fix. * - * the maintainer reported the toggles in Settings → Dashboard didn't filter + * The maintainer reported the toggles in Settings → Dashboard didn't filter * the dashboard tile-strip. Root cause: the `widgetIdEnum` Zod schema * in `src/app/api/dashboard/widgets/route.ts` was missing * `achievements` even though the default layout has it. Every PUT @@ -253,7 +253,7 @@ describe("resolveDashboardLayout() — comparisonBaseline (B8)", () => { /** * v1.4.18 — per-chart overlay-prefs persistence. * - * the maintainer reverted v1.4.16's always-on chart overlays (gradient, baseline, + * The maintainer reverted v1.4.16's always-on chart overlays (gradient, baseline, * target-zone shading) and asked for per-chart switches that persist * per user. The prefs piggy-back on `User.dashboardWidgetsJson` so we * stay migration-free, mirroring the B8 comparisonBaseline pattern. diff --git a/src/lib/__tests__/doctor-report-pdf-core.test.ts b/src/lib/__tests__/doctor-report-pdf-core.test.ts index bc1566a9e..cf93d39f0 100644 --- a/src/lib/__tests__/doctor-report-pdf-core.test.ts +++ b/src/lib/__tests__/doctor-report-pdf-core.test.ts @@ -33,7 +33,7 @@ function makeData(overrides?: Partial): DoctorReportData { end: "2026-05-03T12:00:00.000Z", }, patient: { - username: "marc", + username: "testuser", dateOfBirth: "1985-06-15T00:00:00.000Z", gender: "MALE", heightCm: 182, diff --git a/src/lib/__tests__/require-auth-bearer.test.ts b/src/lib/__tests__/require-auth-bearer.test.ts index a4ad1f1a2..a3c248933 100644 --- a/src/lib/__tests__/require-auth-bearer.test.ts +++ b/src/lib/__tests__/require-auth-bearer.test.ts @@ -54,8 +54,8 @@ const RAW_TOKEN = "hlk_" + "a".repeat(64); const FAKE_USER = { id: "user-1", role: "USER" as const, - username: "marc", - email: "marc@example.com", + username: "testuser", + email: "user@example.com", }; function setBearerHeader(value: string | null): void { diff --git a/src/lib/ai/__tests__/citation-enforcement.test.ts b/src/lib/ai/__tests__/citation-enforcement.test.ts index 301d63447..42cc4189c 100644 --- a/src/lib/ai/__tests__/citation-enforcement.test.ts +++ b/src/lib/ai/__tests__/citation-enforcement.test.ts @@ -12,7 +12,7 @@ import { MockAIProvider } from "../mock-client"; /** * Phase C1 — citation-from-data hard requirement. * - * the maintainer, verbatim 2026-05-09: "es muss sich halt irgendwie stützen auf + * The maintainer, verbatim 2026-05-09: "es muss sich halt irgendwie stützen auf * medizinische Dinge." ("It must ground on medical facts / user data.") * * Operational meaning: diff --git a/src/lib/ai/__tests__/coach-prompt-v423.test.ts b/src/lib/ai/__tests__/coach-prompt-v423.test.ts index 706e18e34..16aea67c7 100644 --- a/src/lib/ai/__tests__/coach-prompt-v423.test.ts +++ b/src/lib/ai/__tests__/coach-prompt-v423.test.ts @@ -92,7 +92,7 @@ describe("getStrictInsightsSystemPrompt — DE", () => { /** * v1.4.25 W5b — pin the enum-identifier ban rule (no internal metric * identifiers - * in prose). Marc saw the "Metric Pressure_Sys" leak on the + * in prose). The maintainer saw the "Metric Pressure_Sys" leak on the * Einschätzungen surface 2026-05-14; the stripper covers the leak at * render time, the rule closes the loop at generation time so * the cleaned-up prose was the only version emitted in the first diff --git a/src/lib/ai/__tests__/codex-slug-fallback.test.ts b/src/lib/ai/__tests__/codex-slug-fallback.test.ts index 98c8c2a9d..51d4b05e8 100644 --- a/src/lib/ai/__tests__/codex-slug-fallback.test.ts +++ b/src/lib/ai/__tests__/codex-slug-fallback.test.ts @@ -10,7 +10,7 @@ import { /** * Phase C1 — slug-drift defence. * - * the maintainer, verbatim 2026-05-09: "Die Integration des Slug Drift Risiko + * The maintainer, verbatim 2026-05-09: "Die Integration des Slug Drift Risiko * darf halt immer überhaupt nicht sein." * ("Slug-drift risk must never happen.") * diff --git a/src/lib/ai/__tests__/no-default-positivity-opener.test.ts b/src/lib/ai/__tests__/no-default-positivity-opener.test.ts index e590443b3..eaaad5ed1 100644 --- a/src/lib/ai/__tests__/no-default-positivity-opener.test.ts +++ b/src/lib/ai/__tests__/no-default-positivity-opener.test.ts @@ -7,7 +7,7 @@ import { /** * v1.4.19 phase A4 — strip the default-positivity opener. * - * the maintainer reported every insight summary started with the same + * The maintainer reported every insight summary started with the same * "Datengrundlage ist sehr stark" / "Your data foundation is strong" * filler sentence. The user does not see what fields the snapshot * carries, so this opener reads as awkward filler. diff --git a/src/lib/ai/coach/glp1-snapshot.ts b/src/lib/ai/coach/glp1-snapshot.ts index 0c5510f5a..917995eae 100644 --- a/src/lib/ai/coach/glp1-snapshot.ts +++ b/src/lib/ai/coach/glp1-snapshot.ts @@ -154,7 +154,7 @@ const SIDE_EFFECT_TAGS = new Set([ "headache", "vomiting", "reflux", - // German variants the mood-tag picker exposes — Marc's userbase types + // German variants the mood-tag picker exposes — the maintainer's userbase types // in both languages and we don't want the snapshot to miss "Übelkeit" // because the user picked the German chip. "übelkeit", diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts index c8f7d5a29..32f981633 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -650,7 +650,7 @@ englischen Nachrichten antworte auf Englisch.`; * Both gates landed earlier in this wave; the dispatcher below now * assembles a native body per locale from the matrix. The DE branch * keeps its existing hand-curated body (two years of clause-by-clause - * Marc review) — it is the calibration reference for the native FR / + * the maintainer review) — it is the calibration reference for the native FR / * ES / IT / PL bodies and we do not disturb it in v1.4.25. If the * matrix loader throws for any reason, the dispatcher falls back to * the previous EN body + footer so the surface fails open rather diff --git a/src/lib/ai/confidence.ts b/src/lib/ai/confidence.ts index c0cb158da..d69915fa2 100644 --- a/src/lib/ai/confidence.ts +++ b/src/lib/ai/confidence.ts @@ -2,7 +2,7 @@ * v1.4.16 phase B5d — deterministic confidence-score computation per * recommendation. * - * the maintainer's mandate: the model's self-reported confidence is unreliable + * The maintainer's mandate: the model's self-reported confidence is unreliable * (calibrated probabilities are not a small-LLM strength), so the * wrapper OVERRIDES with a server-computed value derived from three * inputs that the cited data window actually exposes: diff --git a/src/lib/ai/prompts/__tests__/drug-level-refusal.probe.test.ts b/src/lib/ai/prompts/__tests__/drug-level-refusal.probe.test.ts index 6a49ee677..2d344a77f 100644 --- a/src/lib/ai/prompts/__tests__/drug-level-refusal.probe.test.ts +++ b/src/lib/ai/prompts/__tests__/drug-level-refusal.probe.test.ts @@ -169,7 +169,7 @@ describe("drug-level refusal — Coach system prompt embeds the rule body", () = for (const locale of SAFETY_CONTRACT_LOCALES) { it(`${locale} Coach system prompt contains the drug-level refusal pattern + MDR cite`, () => { const prompt = assembleCoachContext(locale); - // The prompt must reference the regulatory ids verbatim — Marc's + // The prompt must reference the regulatory ids verbatim — the maintainer's // dialog copy already exposes these to the user, so the Coach // can cite them without paraphrasing. expect(prompt).toContain("EU 2017/745"); diff --git a/src/lib/ai/prompts/__tests__/refusal-probe.test.ts b/src/lib/ai/prompts/__tests__/refusal-probe.test.ts index eb342f22f..b908f85d3 100644 --- a/src/lib/ai/prompts/__tests__/refusal-probe.test.ts +++ b/src/lib/ai/prompts/__tests__/refusal-probe.test.ts @@ -12,7 +12,7 @@ import type { Locale } from "@/lib/i18n/config"; /** * v1.4.25 W14c — cross-locale refusal-probe matrix. * - * Marc cannot review the FR/ES/IT/PL safety-critical translations + * The maintainer cannot review the FR/ES/IT/PL safety-critical translations * himself. Automated coverage is the only safety net, so this test * runs 20+ adversarial paraphrasings against every (ground-rule, * locale) pair — 14 rules x 6 locales x 20 paraphrasings = 1680 diff --git a/src/lib/ai/prompts/compact-sections.ts b/src/lib/ai/prompts/compact-sections.ts index fa72a274d..6c384fcd4 100644 --- a/src/lib/ai/prompts/compact-sections.ts +++ b/src/lib/ai/prompts/compact-sections.ts @@ -13,7 +13,7 @@ * framing. * 2. Some downstream prose composers render the block label without * checking row count, producing the v1.4.35 "Hier sind die - * Schlafdaten: [keine]" anti-pattern Marc flagged in W3. + * Schlafdaten: [keine]" anti-pattern the maintainer flagged in W3. * * `compactSections` walks the object once and drops any property whose * value is: diff --git a/src/lib/ai/prompts/insight-generator.ts b/src/lib/ai/prompts/insight-generator.ts index 6eb5afb03..6d6d32dd7 100644 --- a/src/lib/ai/prompts/insight-generator.ts +++ b/src/lib/ai/prompts/insight-generator.ts @@ -1,7 +1,7 @@ /** * Scope-hardened system prompt for AI insights — Phase C1 (v1.4.15). * - * the maintainer, verbatim 2026-05-09: + * The maintainer, verbatim 2026-05-09: * "Es darf null Halluzinationen haben und es muss sich halt irgendwie * stützen auf medizinische Dinge." * ("Zero hallucinations. Must ground on medical facts.") diff --git a/src/lib/ai/prompts/native-prompts.ts b/src/lib/ai/prompts/native-prompts.ts index 9216ddeec..23f3b546d 100644 --- a/src/lib/ai/prompts/native-prompts.ts +++ b/src/lib/ai/prompts/native-prompts.ts @@ -15,7 +15,7 @@ * than a silent break. * * The DE locale continues to use its existing hand-curated body — - * Marc has reviewed that text clause-by-clause for two years and it's + * the maintainer has reviewed that text clause-by-clause for two years and it's * the calibration reference for the new native bodies. We do NOT route * DE through the matrix in v1.4.25 to avoid disturbing a known-good * surface; a later release can fold it in once the matrix has lived in diff --git a/src/lib/ai/prompts/safety-contracts.de.yaml b/src/lib/ai/prompts/safety-contracts.de.yaml index 7c917e903..ae35e4568 100644 --- a/src/lib/ai/prompts/safety-contracts.de.yaml +++ b/src/lib/ai/prompts/safety-contracts.de.yaml @@ -2,7 +2,7 @@ # Translations of safety-contracts.en.yaml. DE bodies are derived from # the existing hand-curated COACH_PROMPT_DE / SYSTEM_PROMPT_DE bodies in # `src/lib/ai/coach/system-prompt.ts` and `src/lib/ai/prompts/insight- -# generator.ts` — these have been Marc-reviewed clause-by-clause for +# generator.ts` — these have been maintainer-reviewed clause-by-clause for # two years and are the calibration reference for FR/ES/IT/PL. ground_rules: diff --git a/src/lib/ai/schema.ts b/src/lib/ai/schema.ts index 92cf8bc26..6a6e3f157 100644 --- a/src/lib/ai/schema.ts +++ b/src/lib/ai/schema.ts @@ -4,7 +4,7 @@ import { MEDICAL_REFERENCE_IDS } from "./medical-references"; /** * Strict response schema for v1.4.15 AI hardening (Phase C1). * - * the maintainer, verbatim 2026-05-09: "Es darf null Halluzinationen haben und es + * The maintainer, verbatim 2026-05-09: "Es darf null Halluzinationen haben und es * muss sich halt irgendwie stützen auf medizinische Dinge." ("It must * have zero hallucinations and must ground on medical facts.") * @@ -216,7 +216,7 @@ export const aiWarningSchema = z.object({ * v1.4.19 (which predate the field) still parse cleanly. Fresh * generations after the v1.4.20 PROMPT_VERSION bump emit the block. * - * the maintainer's "zero hallucinations" mandate stays: the prompt instructs the + * The maintainer's "zero hallucinations" mandate stays: the prompt instructs the * model to derive every finding from a number visible in the snapshot, * and `keyFindings.length` is hard-capped at 5 so a runaway model * cannot pad the surface with filler. diff --git a/src/lib/analytics/__tests__/bp-in-target.test.ts b/src/lib/analytics/__tests__/bp-in-target.test.ts index e25906e61..46a60fce8 100644 --- a/src/lib/analytics/__tests__/bp-in-target.test.ts +++ b/src/lib/analytics/__tests__/bp-in-target.test.ts @@ -169,9 +169,9 @@ describe("computeBpInTargetPct", () => { /** * v1.4.16 A2 regression — the live tenant's actual production data. * - * the maintainer reported BD-Zielbereich = 0 % despite multiple BP readings + * The maintainer reported BD-Zielbereich = 0 % despite multiple BP readings * that "are clearly in target". A query against production showed - * his last 30 days of paired readings include 117/79, 122/76, 108/76, + * their last 30 days of paired readings include 117/79, 122/76, 108/76, * 106/73, 127/86, 115/78, 108/75, 124/82, 126/80, 133/95. * * Under v1.4.15 narrow-band semantics (sys >= 120 AND sys <= 129 AND @@ -238,7 +238,7 @@ describe("computeBpInTargetPct", () => { describe("computeBpInTargetWindows — all-time headline (v1.4.19 A1)", () => { /** * v1.4.19 A1 regression — the maintainer reported the BD-Zielbereich tile shows - * **EXACTLY 50 % on 7T, 30T, AND the headline ("total")** for his + * **EXACTLY 50 % on 7T, 30T, AND the headline ("total")** for their * production data. Hand-counted hypothesis-1 from the brief (the * three-way coincidence cannot be data — it's a calculation pin): * diff --git a/src/lib/analytics/__tests__/compliance.test.ts b/src/lib/analytics/__tests__/compliance.test.ts index de1deea9f..4d2de0d44 100644 --- a/src/lib/analytics/__tests__/compliance.test.ts +++ b/src/lib/analytics/__tests__/compliance.test.ts @@ -31,7 +31,7 @@ import { } from "../compliance"; import type { IntakeTimingClass } from "../compliance"; import { buildCadenceTimeline } from "@/lib/medications/scheduling/cadence"; -import { getUserTodayBounds } from "@/lib/timezone"; +import { getUserTodayBounds } from "@/lib/tz/local-day"; import { userDayKey } from "@/lib/tz/format"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -2497,7 +2497,7 @@ describe("doseCadenceFamily", () => { // intake can't be "taken" in the % while the ledger calls it "ad_hoc". // ──────────────────────────────────────────────────────────────────── -import { localHmAsUtc } from "@/lib/timezone"; +import { localHmAsUtc } from "@/lib/tz/local-day"; import { buildBandsForSchedules, type BandMinterMedication, @@ -2728,7 +2728,7 @@ describe("tallyComplianceFromLedger — the unified % keystone", () => { }); it("an off-schedule (ad-hoc) take is EXCLUDED + leaves its slot missed", () => { - // Marc's case: 07:00 dose logged at 11:29 → outside the band → ad-hoc; + // The maintainer's case: 07:00 dose logged at 11:29 → outside the band → ad-hoc; // the 07:00 slot is missed; the 19:00 slot is also missed (untouched). const events = [ { scheduledFor: at(7, 0), takenAt: at(11, 29), skipped: false }, diff --git a/src/lib/analytics/__tests__/merge-slim-thick.test.ts b/src/lib/analytics/__tests__/merge-slim-thick.test.ts index 978a28573..57384a8e5 100644 --- a/src/lib/analytics/__tests__/merge-slim-thick.test.ts +++ b/src/lib/analytics/__tests__/merge-slim-thick.test.ts @@ -11,7 +11,7 @@ import type { DataSummary } from "@/lib/analytics/trends"; * inline merge used `slim?.summaries ?? thick?.summaries` which * short-circuited on a truthy-but-empty `{}` from the slim slice and * blanked the tile strip even when thick carried the full payload — - * the regression Marc's v1.4.39.3 e2e CI flagged across eight + * the regression the maintainer's v1.4.39.3 e2e CI flagged across eight * dashboard / chart specs (chart-overlay-controls, dashboard, * measurement-flow, charts-mobile). The pure helper extracted in * v1.4.39.3 uses object emptiness as the discriminator so a populated diff --git a/src/lib/analytics/__tests__/source-priority.test.ts b/src/lib/analytics/__tests__/source-priority.test.ts index a64f16a62..1daef2a09 100644 --- a/src/lib/analytics/__tests__/source-priority.test.ts +++ b/src/lib/analytics/__tests__/source-priority.test.ts @@ -41,7 +41,7 @@ describe("pickCanonicalSourceRows — single-axis cumulative-metric picker", () }); it("picks APPLE_HEALTH over WITHINGS for cumulative steps (default priority)", () => { - // The Marc-directive default for cumulative metrics puts iOS first + // The maintainer-directive default for cumulative metrics puts iOS first // because HealthKit aggregates ScanWatch + iPhone sensors. const rows = [ // Same day, both sources reported — naïvely summing would diff --git a/src/lib/analytics/health-score-fast-path.ts b/src/lib/analytics/health-score-fast-path.ts index c746367eb..520f31636 100644 --- a/src/lib/analytics/health-score-fast-path.ts +++ b/src/lib/analytics/health-score-fast-path.ts @@ -9,7 +9,7 @@ * mood 37 days, active medications) plus a fifth for intake events. * On a cold connection pool the round-trip latency stacks — the * v1.4.36 perf-verify recorded the bp_in_target + healthScore + - * correlations fan-out at 111 s wall-clock against Marc's + * correlations fan-out at 111 s wall-clock against the maintainer's * 311 779-row tenant. * * Read shape (v1.4.37) diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index fb28f6e5a..1af2e97f8 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -825,7 +825,7 @@ async function computeFromLiveAggregate( * (count / min / max / mean / sum) over much larger windows — 1 y, * 2 y, 3 y. Hitting the live `measurements` table for a 3-year span * walks every row the user has ever logged for that type, which on - * Marc's tenant is tens of thousands of rows per type. + * the maintainer's tenant is tens of thousands of rows per type. * * The WMY rollup tier already carries the per-bucket stats we need: * the writer mints WEEK / MONTH / YEAR buckets via pg-boss on every diff --git a/src/lib/analytics/window-trend.ts b/src/lib/analytics/window-trend.ts index 3e60cb907..5c24f3e1d 100644 --- a/src/lib/analytics/window-trend.ts +++ b/src/lib/analytics/window-trend.ts @@ -13,7 +13,7 @@ * rounds to ±0 in the 1-decimal formatter. * * v1.4.16 Fix A8b — the maintainer reported the chart trend overlay reads - * "+0.0 kg/Woche" on the All filter, even when his weight clearly + * "+0.0 kg/Woche" on the All filter, even when their weight clearly * shifted across years. Root cause: `weeklyDelta = (last − first) / days * × 7` is mathematically correct but visually rounds to zero whenever * the slope-per-day is small enough that 7× still falls below the diff --git a/src/lib/api/__tests__/api-fetch.test.ts b/src/lib/api/__tests__/api-fetch.test.ts new file mode 100644 index 000000000..6cdcf430b --- /dev/null +++ b/src/lib/api/__tests__/api-fetch.test.ts @@ -0,0 +1,221 @@ +/** + * Unit tests for the typed client-side API fetch wrapper. + * + * The wrapper owns the `.ok` check, the `{ data, error, meta? }` + * envelope unwrap, and the `ApiError` shape (message via `readError`, + * `status`, error-side `meta`). These tests pin that contract against a + * mocked global `fetch`. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + ApiError, + apiDelete, + apiFetch, + apiFetchEnvelope, + apiFetchRaw, + apiGet, + apiPatch, + apiPost, + apiPut, +} from "../api-fetch"; + +const fetchMock = vi.fn(); + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + fetchMock.mockReset(); + vi.unstubAllGlobals(); +}); + +describe("apiFetch", () => { + it("unwraps the data payload from the envelope", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ data: { id: "m1" }, error: null }), + ); + + const data = await apiFetch<{ id: string }>("/api/measurements/m1"); + + expect(data).toEqual({ id: "m1" }); + expect(fetchMock).toHaveBeenCalledWith("/api/measurements/m1", undefined); + }); + + it("passes init through to fetch (signal, cache, headers)", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ data: null, error: null })); + const controller = new AbortController(); + + await apiFetch("/api/version", { + cache: "no-store", + signal: controller.signal, + }); + + expect(fetchMock).toHaveBeenCalledWith("/api/version", { + cache: "no-store", + signal: controller.signal, + }); + }); + + it("resolves undefined for a 204 response", async () => { + fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 })); + + await expect(apiFetch("/api/foo")).resolves.toBeUndefined(); + }); + + it("resolves undefined for an empty 200 body", async () => { + fetchMock.mockResolvedValueOnce(new Response("", { status: 200 })); + + await expect(apiFetch("/api/foo")).resolves.toBeUndefined(); + }); + + it("throws ApiError with the envelope error message and status", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ data: null, error: "Not found" }, 404), + ); + + const err = await apiFetch("/api/foo").catch((e: unknown) => e); + + expect(err).toBeInstanceOf(ApiError); + expect((err as ApiError).message).toBe("Not found"); + expect((err as ApiError).status).toBe(404); + expect((err as ApiError).meta).toBeUndefined(); + }); + + it("carries the error envelope meta (e.g. errorCode)", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse( + { data: null, error: "Boom", meta: { errorCode: "rate_limited" } }, + 429, + ), + ); + + const err = await apiFetch("/api/foo").catch((e: unknown) => e); + + expect(err).toBeInstanceOf(ApiError); + expect((err as ApiError).status).toBe(429); + expect((err as ApiError).meta).toEqual({ errorCode: "rate_limited" }); + }); + + it("falls back to a stable message for a non-JSON error body", async () => { + fetchMock.mockResolvedValueOnce( + new Response("upstream", { status: 502 }), + ); + + const err = await apiFetch("/api/foo").catch((e: unknown) => e); + + expect(err).toBeInstanceOf(ApiError); + expect((err as ApiError).message).toBe("Request failed (502)"); + expect((err as ApiError).status).toBe(502); + }); + + it("does not special-case 401/403 — same ApiError, no side effect", async () => { + for (const status of [401, 403]) { + fetchMock.mockResolvedValueOnce( + jsonResponse({ data: null, error: "Unauthorized" }, status), + ); + const err = await apiFetch("/api/foo").catch((e: unknown) => e); + expect(err).toBeInstanceOf(ApiError); + expect((err as ApiError).status).toBe(status); + } + }); + + it("lets network failures reject with the native error", async () => { + const netErr = new TypeError("fetch failed"); + fetchMock.mockRejectedValueOnce(netErr); + + await expect(apiFetch("/api/foo")).rejects.toBe(netErr); + }); +}); + +describe("apiFetchEnvelope", () => { + it("returns data and meta", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ data: [1, 2], error: null, meta: { total: 7 } }), + ); + + const { data, meta } = await apiFetchEnvelope( + "/api/items", + ); + + expect(data).toEqual([1, 2]); + expect(meta).toEqual({ total: 7 }); + }); + + it("throws ApiError on non-OK like apiFetch", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ data: null, error: "Nope" }, 422), + ); + + await expect(apiFetchEnvelope("/api/items")).rejects.toMatchObject({ + name: "ApiError", + message: "Nope", + status: 422, + }); + }); +}); + +describe("apiFetchRaw", () => { + it("returns the raw Response without ok-check or unwrap", async () => { + const res = new Response("plain", { status: 500 }); + fetchMock.mockResolvedValueOnce(res); + + await expect(apiFetchRaw("/api/foo")).resolves.toBe(res); + }); +}); + +describe("verb helpers", () => { + it("apiGet sets the method", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ data: 1, error: null })); + + await apiGet("/api/foo"); + + expect(fetchMock).toHaveBeenCalledWith("/api/foo", { method: "GET" }); + }); + + it.each([ + ["apiPost", apiPost, "POST"], + ["apiPut", apiPut, "PUT"], + ["apiPatch", apiPatch, "PATCH"], + ["apiDelete", apiDelete, "DELETE"], + ] as const)("%s JSON-encodes the body", async (_name, helper, method) => { + fetchMock.mockResolvedValueOnce(jsonResponse({ data: null, error: null })); + + await helper("/api/foo", { a: 1 }); + + expect(fetchMock).toHaveBeenCalledWith("/api/foo", { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ a: 1 }), + }); + }); + + it("omits body and Content-Type when no body is given", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ data: null, error: null })); + + await apiPost("/api/foo"); + + expect(fetchMock).toHaveBeenCalledWith("/api/foo", { method: "POST" }); + }); + + it("merges caller headers with the JSON Content-Type", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ data: null, error: null })); + + await apiPost("/api/foo", { a: 1 }, { headers: { "X-Req": "1" } }); + + expect(fetchMock).toHaveBeenCalledWith("/api/foo", { + method: "POST", + headers: { "Content-Type": "application/json", "X-Req": "1" }, + body: JSON.stringify({ a: 1 }), + }); + }); +}); diff --git a/src/lib/api/api-fetch.ts b/src/lib/api/api-fetch.ts new file mode 100644 index 000000000..87b4f250e --- /dev/null +++ b/src/lib/api/api-fetch.ts @@ -0,0 +1,192 @@ +/** + * Typed client-side fetch wrapper for same-origin `/api/...` routes. + * + * Every API route responds with the `{ data, error, meta? }` envelope + * (see `src/lib/api-response.ts`). Before this wrapper, ~250 client call + * sites hand-rolled the same four lines — `fetch`, `.ok` check, `await + * res.json()`, `.data` unwrap — with subtly divergent error handling. + * This module is the one client-side entry point; the ESLint rule + * `healthlog/api-fetch-required` flags raw `fetch("/api/…")` calls in + * `src/components/` + `src/app/` at authoring time. + * + * Contract: + * - `apiFetch(path, init?)` resolves with the unwrapped `data` + * payload (typed via the generic) on a 2xx response. A 204 / empty + * body resolves with `undefined`. + * - On a non-OK response it throws `ApiError` — `message` extracted + * through `readError` (server envelope `error` string, falling back + * to `Request failed ()`), `status` carrying the HTTP code, + * `meta` carrying the error envelope's `meta` object when present + * (e.g. `meta.errorCode`). + * - Network failures reject with the native `TypeError` from `fetch`, + * exactly as before — only HTTP-level failures become `ApiError`. + * - 401 / 403 / 429 are NOT special-cased: the wrapper throws the same + * `ApiError` and performs no redirect, no token refresh, no retry, + * no toast. Session expiry handling stays where it always was (the + * server redirects document navigations via `src/proxy.ts`; data + * calls surface the error to the caller's existing handler). 429 + * callers can branch on `err.status === 429` for cooldown UX. + * - No toast / no global side effect ever originates here. Surfacing + * errors stays the call site's responsibility. + * + * Verb helpers `apiGet` / `apiPost` / `apiPut` / `apiPatch` / + * `apiDelete` JSON-encode an optional body and set `Content-Type: + * application/json` when a body is present. `init` passes through to + * `fetch` (e.g. `signal`, `cache`, extra `headers`). + * + * Escape hatches (both still satisfy the ESLint rule): + * - `apiFetchEnvelope` resolves with `{ data, meta }` for the + * few callers that read success-side `meta` (pagination totals). + * - `apiFetchRaw` is a plain same-origin `fetch` passthrough for call + * sites that need the `Response` itself — blob downloads, SSE + * streams, manual status branching. No `.ok` check, no unwrap. + */ + +import { readError } from "./read-error"; + +/** HTTP-level failure from an `/api/...` route — non-OK status. */ +export class ApiError extends Error { + /** HTTP status code of the failed response. */ + readonly status: number; + /** `meta` object of the error envelope, when the body carried one. */ + readonly meta: Record | undefined; + + constructor( + message: string, + status: number, + meta?: Record, + ) { + super(message); + this.name = "ApiError"; + this.status = status; + this.meta = meta; + } +} + +interface Envelope> { + data: T; + error: string | null; + meta?: M; +} + +async function throwApiError(res: Response): Promise { + let meta: Record | undefined; + try { + const json = (await res.clone().json()) as { meta?: unknown }; + if (json && typeof json.meta === "object" && json.meta !== null) { + meta = json.meta as Record; + } + } catch { + /* non-JSON body — readError supplies the fallback message */ + } + throw new ApiError(await readError(res), res.status, meta); +} + +async function parseEnvelope( + res: Response, +): Promise | undefined> { + if (res.status === 204) return undefined; + const text = await res.text(); + if (text.length === 0) return undefined; + return JSON.parse(text) as Envelope; +} + +/** + * Same-origin API fetch — resolves with the unwrapped `data` payload, + * throws {@link ApiError} on a non-OK response. + */ +export async function apiFetch( + path: string, + init?: RequestInit, +): Promise { + const res = await fetch(path, init); + if (!res.ok) await throwApiError(res); + const envelope = await parseEnvelope(res); + return (envelope === undefined ? undefined : envelope.data) as T; +} + +/** + * Like {@link apiFetch} but resolves with `{ data, meta }` for callers + * that read success-side `meta` (e.g. pagination totals). + */ +export async function apiFetchEnvelope< + T = unknown, + M = Record, +>(path: string, init?: RequestInit): Promise<{ data: T; meta: M | undefined }> { + const res = await fetch(path, init); + if (!res.ok) await throwApiError(res); + const envelope = await parseEnvelope(res); + return { + data: (envelope === undefined ? undefined : envelope.data) as T, + meta: envelope?.meta, + }; +} + +/** + * Plain same-origin `fetch` passthrough for call sites that need the raw + * `Response` — blob downloads, SSE streams, manual status branching. + * Performs no `.ok` check and no envelope unwrap. + */ +export function apiFetchRaw( + path: string, + init?: RequestInit, +): Promise { + return fetch(path, init); +} + +function withJsonBody( + method: string, + body: unknown, + init?: RequestInit, +): RequestInit { + const merged: RequestInit = { ...init, method }; + if (body !== undefined) { + merged.headers = { "Content-Type": "application/json", ...init?.headers }; + merged.body = JSON.stringify(body); + } + return merged; +} + +/** GET — resolves with the unwrapped `data` payload. */ +export function apiGet( + path: string, + init?: RequestInit, +): Promise { + return apiFetch(path, { ...init, method: "GET" }); +} + +/** POST with optional JSON body — resolves with the unwrapped `data`. */ +export function apiPost( + path: string, + body?: unknown, + init?: RequestInit, +): Promise { + return apiFetch(path, withJsonBody("POST", body, init)); +} + +/** PUT with optional JSON body — resolves with the unwrapped `data`. */ +export function apiPut( + path: string, + body?: unknown, + init?: RequestInit, +): Promise { + return apiFetch(path, withJsonBody("PUT", body, init)); +} + +/** PATCH with optional JSON body — resolves with the unwrapped `data`. */ +export function apiPatch( + path: string, + body?: unknown, + init?: RequestInit, +): Promise { + return apiFetch(path, withJsonBody("PATCH", body, init)); +} + +/** DELETE with optional JSON body — resolves with the unwrapped `data`. */ +export function apiDelete( + path: string, + body?: unknown, + init?: RequestInit, +): Promise { + return apiFetch(path, withJsonBody("DELETE", body, init)); +} diff --git a/src/lib/auth/__tests__/profile-update.test.ts b/src/lib/auth/__tests__/profile-update.test.ts index 6856bb45e..628f7b70b 100644 --- a/src/lib/auth/__tests__/profile-update.test.ts +++ b/src/lib/auth/__tests__/profile-update.test.ts @@ -20,7 +20,7 @@ const USER_ID = "user-1"; const STUB_USER = { id: USER_ID, - username: "marc", + username: "testuser", displayName: null, email: null, role: "USER", diff --git a/src/lib/charts/__tests__/comparison-shift-edge-cases.test.ts b/src/lib/charts/__tests__/comparison-shift-edge-cases.test.ts index b1db7fcdf..42262eb87 100644 --- a/src/lib/charts/__tests__/comparison-shift-edge-cases.test.ts +++ b/src/lib/charts/__tests__/comparison-shift-edge-cases.test.ts @@ -11,7 +11,7 @@ import { * * The basic shift suite (`comparison-shift.test.ts`) already pins the * happy path: integer-day forward shifts, value preservation, empty - * input → empty output. This file pins the corner cases Marc flagged + * input → empty output. This file pins the corner cases the maintainer flagged * worry about, in particular: * * 1. DST transition days — the helper must NOT cross a calendar-day @@ -26,7 +26,7 @@ import { * so a future refactor can't silently change it. * 3. Insufficient prior-period data — the chart's empty-state copy * reads "Comparison unavailable …" only when `hasComparisonData` - * is false. Per Marc's directive 2026-05-14 the placeholder is + * is false. Per the maintainer's directive 2026-05-14 the placeholder is * gone; the renderer just suppresses the overlay cleanly. The * helper supports that by returning empty arrays for empty input. * 4. 60-days-of-data, asks for `vs Vorjahr` — the shift moves every diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 5405acd99..50bcc52cd 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -193,7 +193,7 @@ function isComparisonBaseline( /** * v1.4.18 — per-chart overlay-prefs. * - * the maintainer rolled back B1a's always-on chart overlays (gradient fill, + * The maintainer rolled back B1a's always-on chart overlays (gradient fill, * personal-baseline reference line, target-zone shading) and asked for * a per-chart switch surface so each chart can be tuned independently. * The three toggles (7-day trend / Trend arrow / Target range) live in diff --git a/src/lib/db.ts b/src/lib/db.ts index 588883bc5..7097d0a83 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -12,7 +12,7 @@ const globalForPrisma = globalThis as unknown as { * The v1.4.39 empirical cold-mount trace * (`.planning/round-v1439-empirical-trace.md` § B2) showed thick * `/api/analytics` holding ≥ 8 of the 10 default pool slots for - * 6.5 s on Marc's 347k-row tenant, starving every other Wave-B and + * 6.5 s on the maintainer's 347k-row tenant, starving every other Wave-B and * Wave-C useQuery fan-out for the duration. Coolify's Postgres * container runs the stock `max_connections` 100 ceiling, so a * 20-slot Node-side pool sits well under the server's hard cap with diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index d25d40473..330b1a0cb 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -880,7 +880,7 @@ export async function collectDoctorReportData( * Per-measurement-type → section-toggle mapping. Keys not listed here * (e.g., glucose contexts, body fat, oxygen saturation) bypass the * toggles for now — those sections will get their own flags in a - * future iteration. The current scope per Marc's directive is the + * future iteration. The current scope per the maintainer's directive is the * "big five" plus mood + compliance. */ const MEASUREMENT_TYPE_SECTION: Record = { diff --git a/src/lib/feature-flags/__tests__/coach-cascade.test.tsx b/src/lib/feature-flags/__tests__/coach-cascade.test.tsx index bc9580d50..e2944db1f 100644 --- a/src/lib/feature-flags/__tests__/coach-cascade.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-cascade.test.tsx @@ -47,7 +47,7 @@ vi.mock("next/navigation", () => ({ /** * v1.4.37 W5 — Coach disable cascade invariant. * - * Marc directive: when the operator turns the global Coach flag off, + * The maintainer directive: when the operator turns the global Coach flag off, * every Coach affordance on every surface must vanish — no * grey-out, no error, no inert button, no DOM trace. The web app * gates each surface via `useFeatureFlags()` and a `flags.coach` diff --git a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx index 513916fc5..caedd7d9e 100644 --- a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx @@ -305,7 +305,9 @@ describe("Coach per-user disableCoach invariant", () => { // writes the flag does NOT read `user.disableCoach` (it reads // `body.disableCoach`) and is intentionally not listed here. "src/app/api/auth/me/route.ts", - // - Settings → Insights toggle reads + writes the flag. + // - Settings → KI section: the toggle card reads + writes the + // flag; the section shell reads it to gate the Coach sub-cards. + "src/components/settings/ai/disable-coach-card.tsx", "src/components/settings/ai-section.tsx", // v1.7.0 W6 — the unified dashboard snapshot builder reads // `user.disableCoach` to gate the embedded daily briefing to diff --git a/src/lib/format.ts b/src/lib/format.ts index 021b90af9..aae0f6707 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -119,7 +119,7 @@ export function formatDateOrRelative( // in the v1.4.43 sweep, so the bare `t("insights.relativeMinutesAgo", // { count })` and `t("insights.relativeHoursAgo", { count })` calls // returned the key itself — the translation bundle only carries the - // pluralised `*One` / `*Other` variants. Reported by Marc as raw + // pluralised `*One` / `*Other` variants. Reported by the maintainer as raw // `insights.relativeHoursAgo` leaking onto medication cards, recent- // achievements, admin sections, and every other consumer of // `formatDateOrRelative`. diff --git a/src/lib/i18n/__tests__/fallback-chain.test.tsx b/src/lib/i18n/__tests__/fallback-chain.test.tsx index 7153eb317..bee0eaa02 100644 --- a/src/lib/i18n/__tests__/fallback-chain.test.tsx +++ b/src/lib/i18n/__tests__/fallback-chain.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider, useTranslations } from "@/lib/i18n/context"; +import deMessages from "../../../../messages/de.json"; function Read({ k }: { k: string }) { const { t } = useTranslations(); @@ -8,18 +9,30 @@ function Read({ k }: { k: string }) { } describe("i18n fallback chain", () => { - it("resolves a key that exists in the active locale", () => { + it("renders the active locale synchronously when its bundle is passed (RSC handoff)", () => { + // This is the production path since the bundle split: the root + // layout resolves the locale server-side and threads the bundle in + // as `initialMessages`. The very first render — server HTML and + // first client paint alike — must already be German; an async gap + // here would be the EN→DE hydration flash the handoff exists to + // prevent. renderToStaticMarkup is synchronous, so this test fails + // if the provider ever needs an await before t() resolves DE. const html = renderToStaticMarkup( - - + + , ); - // dashboard.title is shipped in every locale; the German value should - // appear (not the EN value, not the raw key). - expect(html).toContain('data-key="dashboard.title"'); - expect(html).not.toMatch(/>dashboard\.titleSave<"); }); + // A "no bundle passed for a non-EN locale" mount serves the static EN + // floor synchronously and backfills the real bundle async. That path + // is not testable in this suite — vitest.setup.ts primes the cache + // with every locale (deliberately, so hundreds of provider mounts + // keep resolving synchronously) — and it never runs in the app: the + // root layout always passes the active locale's bundle. + // Level-2 of the fallback chain (active locale missing → EN) is // unreachable in production by design: the locale-integrity parity // test (../__tests__/i18n-locale-integrity.test.ts) enforces that diff --git a/src/lib/i18n/context.tsx b/src/lib/i18n/context.tsx index 2158e2405..59076d4db 100644 --- a/src/lib/i18n/context.tsx +++ b/src/lib/i18n/context.tsx @@ -5,6 +5,7 @@ import { useContext, useEffect, useMemo, + useRef, useState, useCallback, useSyncExternalStore, @@ -20,11 +21,25 @@ import { readStoredTimeFormat, subscribeTimeFormat, } from "../time-format"; -import { allMessages, resolveKey } from "./shared-resolve"; +import { resolveKey } from "./resolve-key"; +import { + fallbackMessages, + getCachedMessages, + loadMessages, + primeMessages, + type MessageBundle, +} from "./load-locale"; interface I18nContextValue { locale: Locale; setLocale: (locale: Locale) => void; + /** + * Non-null while a locale switch is waiting on its message bundle + * (dynamic import). The switcher uses it for a short pending state; + * `locale` / `t` only flip once the bundle is in so the UI never + * renders the new locale with the old (or fallback) strings. + */ + pendingLocale: Locale | null; t: (key: string, params?: Record) => string; } @@ -65,33 +80,99 @@ function getSavedLocale(): Locale | null { export function I18nProvider({ children, initialLocale, + initialMessages, }: { children: ReactNode; initialLocale?: Locale; + /** + * The active locale's message bundle, resolved server-side by the + * root layout and passed through the RSC payload. This is what keeps + * the client chunk free of every non-EN bundle WITHOUT reintroducing + * the EN→DE hydration flash: the first client render already holds + * the right strings, no async fetch on first paint. Only EN ships + * statically (the synchronous fallback floor of the t() chain). + */ + initialMessages?: MessageBundle; }) { - const [locale, setLocaleState] = useState(() => { + // Locale + messages live in ONE state cell so a locale switch flips + // both atomically — `locale` never points at a bundle that hasn't + // arrived yet. + const [active, setActive] = useState<{ + locale: Locale; + messages: MessageBundle; + }>(() => { // Prefer the server-resolved initial locale to eliminate the hydration // flash where the server renders EN ("Loading…") and the client then // flips to DE ("Laden…") once localStorage/cookie is read at mount. - if ( - initialLocale && - (locales as readonly string[]).includes(initialLocale) - ) { - return initialLocale; + const locale = + initialLocale && (locales as readonly string[]).includes(initialLocale) + ? initialLocale + : (getSavedLocale() ?? detectSystemLocale()); + if (initialMessages && locale === initialLocale) { + // Seed the switch cache too, so switching away and back is instant. + // Idempotent, so safe under StrictMode's double initializer call. + primeMessages(locale, initialMessages); + return { locale, messages: initialMessages }; } - return getSavedLocale() ?? detectSystemLocale(); + return { locale, messages: getCachedMessages(locale) ?? fallbackMessages }; }); + const { locale, messages } = active; + const [pendingLocale, setPendingLocale] = useState(null); + // Tracks the most recent switch request so a slow bundle load can't + // clobber a newer one (last click wins). + const requestedLocaleRef = useRef(null); + + // Degenerate mount (no server-passed bundle, non-EN locale from + // localStorage / navigator): backfill the bundle asynchronously. In + // the app this path never runs — the root layout always passes the + // active locale's messages — but standalone mounts stay correct. + useEffect(() => { + if (getCachedMessages(locale)) return; + let cancelled = false; + void loadMessages(locale).then((loaded) => { + if (cancelled) return; + setActive((prev) => + prev.locale === locale ? { locale, messages: loaded } : prev, + ); + }); + return () => { + cancelled = true; + }; + }, [locale]); const setLocale = useCallback((newLocale: Locale) => { - if ((locales as readonly string[]).includes(newLocale)) { - setLocaleState(newLocale); - localStorage.setItem("healthlog-locale", newLocale); - // Also mirror to cookie so SSR (layout, metadata) renders in the - // user's language. 1-year expiry, Lax samesite, not HttpOnly so the - // client continues to own it. - document.cookie = `healthlog-locale=${newLocale}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`; - document.documentElement.lang = newLocale; + if (!(locales as readonly string[]).includes(newLocale)) return; + localStorage.setItem("healthlog-locale", newLocale); + // Also mirror to cookie so SSR (layout, metadata) renders in the + // user's language. 1-year expiry, Lax samesite, not HttpOnly so the + // client continues to own it. + document.cookie = `healthlog-locale=${newLocale}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`; + document.documentElement.lang = newLocale; + + const cached = getCachedMessages(newLocale); + if (cached) { + requestedLocaleRef.current = null; + setPendingLocale(null); + setActive({ locale: newLocale, messages: cached }); + return; } + + requestedLocaleRef.current = newLocale; + setPendingLocale(newLocale); + loadMessages(newLocale) + .then((loaded) => { + if (requestedLocaleRef.current !== newLocale) return; + requestedLocaleRef.current = null; + setPendingLocale(null); + setActive({ locale: newLocale, messages: loaded }); + }) + .catch(() => { + // Bundle fetch failed (offline mid-session, …) — stay on the + // current locale rather than rendering the new one in EN. + if (requestedLocaleRef.current !== newLocale) return; + requestedLocaleRef.current = null; + setPendingLocale(null); + }); }, []); // Keep the HTML lang and the cookie in sync with the active locale on @@ -103,11 +184,11 @@ export function I18nProvider({ const t = useCallback( (key: string, params?: Record): string => { - let value = resolveKey(allMessages[locale], key); + let value = resolveKey(messages, key); // Fallback to English if key missing in current locale if (value === undefined && locale !== "en") { - value = resolveKey(allMessages.en, key); + value = resolveKey(fallbackMessages, key); } // Fallback to key itself @@ -124,14 +205,15 @@ export function I18nProvider({ return value; }, - [locale], + [locale, messages], ); - return ( - - {children} - + const value = useMemo( + () => ({ locale, setLocale, pendingLocale, t }), + [locale, setLocale, pendingLocale, t], ); + + return {children}; } export function useTranslations() { diff --git a/src/lib/i18n/load-locale.ts b/src/lib/i18n/load-locale.ts new file mode 100644 index 000000000..359dcea3e --- /dev/null +++ b/src/lib/i18n/load-locale.ts @@ -0,0 +1,58 @@ +/** + * Client-side message-bundle loading. + * + * Only English ships statically in the client chunk — it is the + * synchronous fallback floor of the t() chain (active locale → EN → + * raw key) and the bundle the provider can always resolve against + * without an async gap. Every other locale reaches the client one of + * two ways: + * + * 1. First paint: the root layout (a server component) resolves the + * active locale from the `healthlog-locale` cookie / the + * Accept-Language header and threads that locale's messages into + * `I18nProvider` as a serialized RSC prop — so the first client + * render already holds the right bundle and there is no EN→DE + * hydration flash (the historical reason all six bundles used to + * ship statically). + * 2. Locale switch: `loadMessages()` dynamic-imports the target + * bundle on demand; each locale becomes its own async chunk that + * only the users who switch to it ever download. + * + * The module-level cache makes repeat switches instant and lets the + * provider seed it with the server-passed bundle (`primeMessages`). + */ +import enMessages from "../../../messages/en.json"; +import { type Locale } from "./config"; + +export type MessageBundle = Record; + +/** Synchronous fallback floor — always available, never awaited. */ +export const fallbackMessages: MessageBundle = enMessages; + +const loaders: Record Promise<{ default: MessageBundle }>> = { + de: () => import("../../../messages/de.json"), + en: () => import("../../../messages/en.json"), + fr: () => import("../../../messages/fr.json"), + es: () => import("../../../messages/es.json"), + it: () => import("../../../messages/it.json"), + pl: () => import("../../../messages/pl.json"), +}; + +const cache: Partial> = { en: enMessages }; + +export function getCachedMessages(locale: Locale): MessageBundle | undefined { + return cache[locale]; +} + +/** Seed the cache with a bundle that arrived as a server prop. */ +export function primeMessages(locale: Locale, messages: MessageBundle): void { + cache[locale] = messages; +} + +export async function loadMessages(locale: Locale): Promise { + const cached = cache[locale]; + if (cached) return cached; + const mod = await loaders[locale](); + cache[locale] = mod.default; + return mod.default; +} diff --git a/src/lib/i18n/resolve-key.ts b/src/lib/i18n/resolve-key.ts new file mode 100644 index 000000000..79bbb1ee8 --- /dev/null +++ b/src/lib/i18n/resolve-key.ts @@ -0,0 +1,26 @@ +/** + * Resolve a dotted key (`nav.dashboard`) inside a flat-or-nested + * message object. Returns `undefined` when the key path doesn't land + * on a string leaf so the caller can chain a fallback bundle (English, + * raw key) without ambiguity. + * + * Lives in its own module (no message-bundle imports) so the client + * provider can use it without pulling every locale JSON into the + * client chunk — `shared-resolve.ts` keeps the full bundle map for + * server-side callers and re-exports this helper. + */ +export function resolveKey( + messages: Record, + key: string, +): string | undefined { + const parts = key.split("."); + let current: unknown = messages; + for (const part of parts) { + if (current && typeof current === "object" && part in current) { + current = (current as Record)[part]; + } else { + return undefined; + } + } + return typeof current === "string" ? current : undefined; +} diff --git a/src/lib/i18n/shared-resolve.ts b/src/lib/i18n/shared-resolve.ts index e0d262e24..f08790d05 100644 --- a/src/lib/i18n/shared-resolve.ts +++ b/src/lib/i18n/shared-resolve.ts @@ -1,15 +1,18 @@ /** - * Shared message-bundle map and nested-key resolver used by both the - * client-side `I18nProvider` in `context.tsx` and the server-side - * `getServerTranslator()` in `server-translator.ts`. + * Server-side message-bundle map + re-export of the shared key resolver. * - * v1.4.27 B7 / BL-P4-11-S10 — the two surfaces previously each carried + * v1.4.27 B7 / BL-P4-11-S10 — client and server previously each carried * their own private copy of `allMessages` plus a near-identical - * `resolveKey` helper. The maps drifted twice during v1.4.18 / v1.4.25 - * polishing passes when a new locale was added; pinning both call-sites - * to the same import keeps client and server fallback semantics in - * lockstep and shrinks the surface a future locale addition has to - * touch from two to one. + * `resolveKey` helper; pinning both to one import stopped the maps + * drifting when a locale was added. + * + * Since the i18n bundle split, `allMessages` is SERVER-ONLY: the root + * layout picks the active locale's bundle out of it for the RSC handoff + * and `getServerTranslator()` resolves against it. Do NOT import this + * module from client code — the six static JSON imports would drag + * every locale back into the client chunk (the ~1.4 MiB regression the + * split removed). Client code uses `./load-locale` (static EN fallback + * floor + dynamic per-locale imports) and `./resolve-key` instead. */ import deMessages from "../../../messages/de.json"; import enMessages from "../../../messages/en.json"; @@ -19,13 +22,11 @@ import itMessages from "../../../messages/it.json"; import plMessages from "../../../messages/pl.json"; import { type Locale } from "./config"; +export { resolveKey } from "./resolve-key"; + /** - * Single source of truth for the per-locale message bundle. - * - * Both the client provider and the server translator import this map. - * The bundles ship as static JSON imports so they tree-shake into the - * client chunk that needs them and into the server chunk that needs - * them without duplicating. + * Single source of truth for the per-locale message bundle on the + * server (layout RSC handoff, server translator, jobs). */ export const allMessages: Record> = { de: deMessages, @@ -35,25 +36,3 @@ export const allMessages: Record> = { it: itMessages, pl: plMessages, }; - -/** - * Resolve a dotted key (`nav.dashboard`) inside a flat-or-nested - * message object. Returns `undefined` when the key path doesn't land - * on a string leaf so the caller can chain a fallback bundle (English, - * raw key) without ambiguity. - */ -export function resolveKey( - messages: Record, - key: string, -): string | undefined { - const parts = key.split("."); - let current: unknown = messages; - for (const part of parts) { - if (current && typeof current === "object" && part in current) { - current = (current as Record)[part]; - } else { - return undefined; - } - } - return typeof current === "string" ? current : undefined; -} diff --git a/src/lib/insights/__tests__/chart-tokens.test.ts b/src/lib/insights/__tests__/chart-tokens.test.ts index f84525a4b..dc82d4301 100644 --- a/src/lib/insights/__tests__/chart-tokens.test.ts +++ b/src/lib/insights/__tests__/chart-tokens.test.ts @@ -71,7 +71,7 @@ describe("stripChartTokens", () => { }); }); -// v1.4.25 W5b — Marc reported the raw "Metric Pressure_Sys" leak on +// v1.4.25 W5b — the maintainer reported the raw "Metric Pressure_Sys" leak on // /insights 2026-05-14. The v1.4.22 W1a regex only caught the canonical // `metric:` colon form; the model emitted a capitalised "Metric" // word followed by a Pascal/enum-cased identifier. The fix extends the @@ -79,7 +79,7 @@ describe("stripChartTokens", () => { // phrase, (2) bare upper-snake-case enum identifiers from the // MeasurementType list (including the v1.4.23 Apple-Health additions). describe("stripChartTokens — v1.4.25 W5b capitalised + orphan-enum leaks", () => { - it("strips the capitalised `Metric Pressure_Sys` phrase Marc reported", () => { + it("strips the capitalised `Metric Pressure_Sys` phrase the maintainer reported", () => { expect( stripChartTokens("Your Metric Pressure_Sys is sitting above 140."), ).toBe("Your is sitting above 140."); @@ -171,7 +171,7 @@ describe("stripChartTokens — v1.4.25 W5b capitalised + orphan-enum leaks", () it("does not strip ordinary prose that incidentally contains 'metric' as a word", () => { // Lower-case word "metric" without the colon form must not be // touched — it's a perfectly legitimate English word inside copy - // like "Each metric in the snapshot ...". Marc's directive + // like "Each metric in the snapshot ...". The maintainer's directive // explicitly targets the leak patterns, not legitimate prose. expect(stripChartTokens("Each metric tells part of the story.")).toBe( "Each metric tells part of the story.", diff --git a/src/lib/insights/chart-tokens.ts b/src/lib/insights/chart-tokens.ts index 960d28924..5c1e104cf 100644 --- a/src/lib/insights/chart-tokens.ts +++ b/src/lib/insights/chart-tokens.ts @@ -136,7 +136,7 @@ const ALLOWED_SET = new Set(ALLOWED_CHART_TOKENS); const STRIP_TOKEN_REGEX = /metric:[A-Za-z0-9_]+/g; const PARSE_TOKEN_REGEX = /metric:[A-Z_]+/g; -// v1.4.25 W5b — capitalised-Metric form. Marc reported the leak +// v1.4.25 W5b — capitalised-Metric form. The maintainer reported the leak // pattern `Metric Pressure_Sys` on /insights 2026-05-14: the model // emitted the metric reference as a two-word phrase ("Metric" + space // + enum-cased identifier) instead of the canonical `metric:` @@ -236,7 +236,7 @@ const ORPHAN_ENUMS = [ // (lowercase) or "BMI" (no underscore) never match. The fixed list // also means we never accidentally strip a single capitalised word // like "MOOD" or "WEIGHT" that has legitimate use as an in-prose -// metric label in display copy. Marc's directive (2026-05-14) is to +// metric label in display copy. The maintainer's directive (2026-05-14) is to // strip ONLY upper-snake / suffixed enum identifiers, not the // canonical user-facing labels. const ORPHAN_ENUM_REGEX = new RegExp( diff --git a/src/lib/insights/derived/sleep-score.ts b/src/lib/insights/derived/sleep-score.ts index 9d9c588bd..4c8a24ef4 100644 --- a/src/lib/insights/derived/sleep-score.ts +++ b/src/lib/insights/derived/sleep-score.ts @@ -34,7 +34,7 @@ */ import type { MeasurementType, SleepStage } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; -import { getLocalDateParts } from "@/lib/timezone"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import { buildInsufficient, @@ -397,7 +397,7 @@ export function reconstructNights( function minutesOfDay(d: Date, tz: string): number { if (tz === "UTC") return d.getUTCHours() * 60 + d.getUTCMinutes(); - const { hour, minute } = getLocalDateParts(d, tz); + const { hour, minute } = wallClockInTz(d, tz); return hour * 60 + minute; } diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index 7e03f0d7b..56bfdad29 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -195,7 +195,7 @@ export interface AggregatedFeatures { * * Replaces v1.4.35.x `rawMeasurements` (which appended every * measurement row and blew the 10 MB Codex prompt ceiling on power - * users with multi-year imports — 25.9 MB observed on Marc's account + * users with multi-year imports — 25.9 MB observed on the maintainer's account * with 311 775 rows). Reading from `measurement_rollups` instead caps * the payload at O(types × buckets) regardless of underlying row * count. @@ -249,7 +249,7 @@ export class FeaturesPayloadTooLargeError extends Error { * The 90 / 365 / 1825-day windows are non-overlapping: each row of * `measurement_rollups` lives at exactly one granularity, and the * downstream model reads the union of the three series per type. - * Total volume per type for a Marc-scale power user (5 years of daily + * Total volume per type for a heavy power user (5 years of daily * data): 90 DAY + 39 WEEK + 50 MONTH = 179 buckets × ~40 bytes JSON * each ≈ 7 KB. Eight metric types lands at ~56 KB — well under the * 5 MB cap above, vs 25.9 MB for the v1.4.35 rawMeasurements shape. diff --git a/src/lib/insights/metric-correlation-context.ts b/src/lib/insights/metric-correlation-context.ts index 4eb43ee37..f9b404cc9 100644 --- a/src/lib/insights/metric-correlation-context.ts +++ b/src/lib/insights/metric-correlation-context.ts @@ -22,7 +22,7 @@ */ import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; -import { getLocalDateParts } from "@/lib/timezone"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; import { discoverCorrelations, DISCOVERY_BEHAVIOURS, @@ -56,7 +56,7 @@ function channelKeyForType(type: MeasurementType): string | null { /** Day key (YYYY-MM-DD) for an instant in the user's display timezone. */ function tzDayKey(at: Date, tz: string): string { - const { year, month, day } = getLocalDateParts(at, tz); + const { year, month, day } = wallClockInTz(at, tz); return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; } diff --git a/src/lib/insights/mood-aggregates.ts b/src/lib/insights/mood-aggregates.ts index ec751e441..219238db9 100644 --- a/src/lib/insights/mood-aggregates.ts +++ b/src/lib/insights/mood-aggregates.ts @@ -25,7 +25,7 @@ import { type DailyBucket, } from "@/lib/insights/bucket-series"; import { round, summarizeSeries } from "@/lib/insights/status-shared"; -import { getLocalDateParts } from "@/lib/timezone"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; import { computeMoodNarratives, type MoodNarrative, @@ -1555,7 +1555,7 @@ export function computeTimeOfDayAverages( const sums = new Map(); for (const entry of entries) { const tz = entry.tz ?? "UTC"; - const { hour } = getLocalDateParts(entry.moodLoggedAt, tz); + const { hour } = wallClockInTz(entry.moodLoggedAt, tz); const bucket = bucketForHour(hour); const cur = sums.get(bucket) ?? { sum: 0, count: 0 }; cur.sum += entry.score; diff --git a/src/lib/insights/narrative/period-narrative-generate.ts b/src/lib/insights/narrative/period-narrative-generate.ts index 339eea9d0..d383d4cb7 100644 --- a/src/lib/insights/narrative/period-narrative-generate.ts +++ b/src/lib/insights/narrative/period-narrative-generate.ts @@ -27,7 +27,7 @@ import type { PrismaClient } from "@/generated/prisma/client"; import { prisma as defaultPrisma } from "@/lib/db"; import { encrypt, decrypt } from "@/lib/crypto"; -import { getLocalDateParts } from "@/lib/timezone"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; import { annotate } from "@/lib/logging/context"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { @@ -85,7 +85,7 @@ export interface NarrativeProvenancePayload { /** UTC YYYY-MM-DD boundary key for a row, in the user's tz. */ function dateKeyFor(now: Date, tz: string): string { - const { year, month, day } = getLocalDateParts(now, tz); + const { year, month, day } = wallClockInTz(now, tz); return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; } diff --git a/src/lib/insights/narrative/period-narrative.ts b/src/lib/insights/narrative/period-narrative.ts index e363d4852..87f35f6e3 100644 --- a/src/lib/insights/narrative/period-narrative.ts +++ b/src/lib/insights/narrative/period-narrative.ts @@ -31,7 +31,7 @@ */ import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; -import { getLocalDateParts } from "@/lib/timezone"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; import { discoverCorrelations, DISCOVERY_BEHAVIOURS, @@ -177,7 +177,7 @@ export type PeriodNarrativeResult = /** YYYY-MM-DD day key for an instant in the user's display timezone. */ function tzDayKey(at: Date, tz: string): string { - const { year, month, day } = getLocalDateParts(at, tz); + const { year, month, day } = wallClockInTz(at, tz); return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; } diff --git a/src/lib/integrations/__tests__/admin-alert.test.ts b/src/lib/integrations/__tests__/admin-alert.test.ts index 9f1c9479b..681a6989c 100644 --- a/src/lib/integrations/__tests__/admin-alert.test.ts +++ b/src/lib/integrations/__tests__/admin-alert.test.ts @@ -4,7 +4,7 @@ * The state-machine threshold tests live in `status.test.ts` (when * does the alert fire?). This file proves WHAT the alert looks like * — the message body the maintainer sees on Telegram when an integration - * starts crashing for one of his users. + * starts crashing for one of their users. * * The formatter is a pure function so we can test deterministically * without touching Prisma or the dispatcher. @@ -96,9 +96,9 @@ describe("formatAdminAlertPayload — moodLog transient", () => { message: "moodLog sync HTTP 502", errorCode: "http_502", consecutiveFailures: 4, - subjectLabel: "marc@example.com", + subjectLabel: "user@example.com", }); - expect(out.title).toBe("moodLog sync failing for marc@example.com"); + expect(out.title).toBe("moodLog sync failing for user@example.com"); expect(out.message).toContain("Action: investigate the upstream service."); expect(out.message).toContain("(http_502)"); }); diff --git a/src/lib/jobs/cycle-reminder.ts b/src/lib/jobs/cycle-reminder.ts index 6e4e72478..24bc8c809 100644 --- a/src/lib/jobs/cycle-reminder.ts +++ b/src/lib/jobs/cycle-reminder.ts @@ -40,7 +40,8 @@ import type { PrismaClient } from "@/generated/prisma/client"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import type { Locale } from "@/lib/i18n/config"; import { defaultLocale, locales } from "@/lib/i18n/config"; -import { getLocalDateParts, getUserTodayBounds } from "@/lib/timezone"; +import { getUserTodayBounds } from "@/lib/tz/local-day"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; import { dayDiff } from "@/lib/cycle/day-math"; import { isCycleEnabled } from "@/lib/cycle/gate"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; @@ -143,7 +144,7 @@ export function cycleReminderLocalDate( now: Date, timezone: string, ): string | null { - const parts = getLocalDateParts(now, timezone || "Europe/Berlin"); + const parts = wallClockInTz(now, timezone || "Europe/Berlin"); if (parts.hour !== CYCLE_REMINDER_LOCAL_HOUR) return null; return `${parts.year.toString().padStart(4, "0")}-${parts.month .toString() diff --git a/src/lib/jobs/host-metric-sampler.ts b/src/lib/jobs/host-metric-sampler.ts index b37ed7043..2993b2db7 100644 --- a/src/lib/jobs/host-metric-sampler.ts +++ b/src/lib/jobs/host-metric-sampler.ts @@ -2,8 +2,8 @@ * Per-minute host-load sampler that backs the admin /admin/system-status * "last 2 hours" chart (v1.4.16 phase B3). * - * the maintainer asked for a small at-a-glance graph above the system-status facts - * grid showing CPU + memory + disk-IO so a glance at /admin tells him + * The maintainer asked for a small at-a-glance graph above the system-status facts + * grid showing CPU + memory + disk-IO so a glance at /admin tells them * whether the apps-01 host is healthy. We considered scraping Coolify's * Sentinel collector, but the Coolify HTTP API does not expose a clean * read endpoint for per-server metrics — every approach ended up either diff --git a/src/lib/jobs/medication-inventory-expire.ts b/src/lib/jobs/medication-inventory-expire.ts index dfa94fbb5..010b26ff3 100644 --- a/src/lib/jobs/medication-inventory-expire.ts +++ b/src/lib/jobs/medication-inventory-expire.ts @@ -12,7 +12,7 @@ * user explicitly deletes it or marks it USED_UP. * * Notification surface: opt-in only. The W19b scope deliberately - * leaves the push notification disabled by default — Marc directive + * leaves the push notification disabled by default — the maintainer directive * "notifications saturate the lock screen on first sync" applies * here too. Users opt in via Settings → Notifications (a future * preference toggle); until then the job is silent and the user diff --git a/src/lib/jobs/mood-reminder.ts b/src/lib/jobs/mood-reminder.ts index a54fb1358..20aa58604 100644 --- a/src/lib/jobs/mood-reminder.ts +++ b/src/lib/jobs/mood-reminder.ts @@ -24,7 +24,7 @@ import type { PrismaClient } from "@/generated/prisma/client"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import type { Locale } from "@/lib/i18n/config"; import { defaultLocale, locales } from "@/lib/i18n/config"; -import { getLocalDateParts } from "@/lib/timezone"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; import { getEvent } from "@/lib/logging/context"; import { @@ -92,7 +92,7 @@ export function evaluateMoodReminderWindow( return { fire: false, localDate: null, localHour: -1 }; } const targetHour = user.reminderHour ?? MOOD_REMINDER_LOCAL_HOUR; - const parts = getLocalDateParts(now, user.timezone || "Europe/Berlin"); + const parts = wallClockInTz(now, user.timezone || "Europe/Berlin"); const inWindow = parts.hour === targetHour; const isoDate = `${parts.year.toString().padStart(4, "0")}-${parts.month .toString() diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index 362b8debe..ae562d293 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -1564,7 +1564,7 @@ export async function startReminderWorker() { // into one `stats:…` row per calendar day. Idempotent — a second run // collapses zero buckets once every day is in the `stats:` shape. // Concurrency-1 so the drain never crowds the dashboard request pool - // and a long backfill on Marc's account (300 k+ measurement rows) + // and a long backfill on the maintainer's account (300 k+ measurement rows) // stays a single sequential walk. await boss.work( DRAIN_CUMULATIVE_QUEUE, diff --git a/src/lib/jobs/reminder/medication-reminder-check.ts b/src/lib/jobs/reminder/medication-reminder-check.ts index 3225195ca..323d9cadd 100644 --- a/src/lib/jobs/reminder/medication-reminder-check.ts +++ b/src/lib/jobs/reminder/medication-reminder-check.ts @@ -28,7 +28,7 @@ import { isMedicationReminderClientManaged } from "@/lib/validations/notificatio import { getUserTodayBounds as getUserTodayBoundsUtil, localHmAsUtc, -} from "@/lib/timezone"; +} from "@/lib/tz/local-day"; import { getWorkerPrisma, parseTimeToMinutes } from "./shared"; export interface ReminderCheckPayload { diff --git a/src/lib/measurements/__tests__/import-apple-health-export.test.ts b/src/lib/measurements/__tests__/import-apple-health-export.test.ts index f772795ff..9f8f32aab 100644 --- a/src/lib/measurements/__tests__/import-apple-health-export.test.ts +++ b/src/lib/measurements/__tests__/import-apple-health-export.test.ts @@ -38,7 +38,7 @@ function tinyExportXml(): string { startDate="2026-05-14 08:00:00 +0200" endDate="2026-05-14 08:30:00 +0200" value="1500" - sourceName="Marc's iPhone" + sourceName="Test iPhone" sourceVersion="17.4.1"/> ([ // `AppleSleepingBreathingDisturbances` (the per-night quantity) → // BREATHING_DISTURBANCES, and `HKCategoryTypeIdentifierSleepApneaEvent` // (the fired screening event) → BREATHING_DISTURBANCE_EVENT. - // Nutrition — Marc directive, indefinite hold + // Nutrition — the maintainer directive, indefinite hold "HKQuantityTypeIdentifierDietaryWater", "HKQuantityTypeIdentifierDietaryCaffeine", "HKQuantityTypeIdentifierDietaryEnergyConsumed", diff --git a/src/lib/measurements/cumulative-day-sum.ts b/src/lib/measurements/cumulative-day-sum.ts index c67eea962..e5c41947f 100644 --- a/src/lib/measurements/cumulative-day-sum.ts +++ b/src/lib/measurements/cumulative-day-sum.ts @@ -11,7 +11,7 @@ * time-in-daylight share the same physical shape — every iOS * HealthKit ingest writes minute-level slices that the dashboard * tile was rendering as the *latest slice* instead of the *day's - * total*. Marc reported "Steps tile shows last-measurement-not-day-sum". + * total*. The maintainer reported "Steps tile shows last-measurement-not-day-sum". * * This helper extracts the per-day-sum reduction so the route can * call it for every cumulative metric without copy-pasting the diff --git a/src/lib/medications/__tests__/glp1-knowledge-drift.test.ts b/src/lib/medications/__tests__/glp1-knowledge-drift.test.ts index f5f05b1a9..d21a12d52 100644 --- a/src/lib/medications/__tests__/glp1-knowledge-drift.test.ts +++ b/src/lib/medications/__tests__/glp1-knowledge-drift.test.ts @@ -161,7 +161,7 @@ describe("glp1-knowledge drift guard", () => { }); // Local-only soft pins: the research markdown lives under `.planning/` - // which is untracked, so the block is a no-op on CI. Marc's local + // which is untracked, so the block is a no-op on CI. The maintainer's local // working copy keeps the file and the assertions run there. describe.skipIf(!RESEARCH_AVAILABLE)("research-file citation presence", () => { it("references the EMA Mounjaro EPAR for tirzepatide", () => { diff --git a/src/lib/medications/category-label.ts b/src/lib/medications/category-label.ts index 751213d20..77594d28f 100644 --- a/src/lib/medications/category-label.ts +++ b/src/lib/medications/category-label.ts @@ -5,7 +5,7 @@ * `categoryLabel` into the same `` outline badge. * Historically the generic card kept its own inline lookup table while * the GLP-1 card hard-coded `medications.treatmentClassGlp1` into the - * slot — Marc reported the asymmetry during the v1.4.37 UX audit + * slot — the maintainer reported the asymmetry during the v1.4.37 UX audit * (audit item 11). The GLP-1 nature is already implied by the * rotation hint + injection metadata rows, so both surfaces should * paint the actual medication category instead. diff --git a/src/lib/medications/dose-string.ts b/src/lib/medications/dose-string.ts index 02784264e..4c26db6e6 100644 --- a/src/lib/medications/dose-string.ts +++ b/src/lib/medications/dose-string.ts @@ -9,7 +9,7 @@ * shape is preserved as `parseDoseMgOrNull` so the titration route * keeps its "no current step" semantics. * - * Accepts comma OR dot as decimal separator (Marc-memory: the German + * Accepts comma OR dot as decimal separator (maintainer note: the German * UI lets the user type "0,25 mg"). Trailing unit text after the * number is ignored — the parser only owns the numeric extraction. */ diff --git a/src/lib/medications/scheduling/__tests__/attribute-intake.test.ts b/src/lib/medications/scheduling/__tests__/attribute-intake.test.ts index 9b7319e82..4c35f0cf9 100644 --- a/src/lib/medications/scheduling/__tests__/attribute-intake.test.ts +++ b/src/lib/medications/scheduling/__tests__/attribute-intake.test.ts @@ -5,7 +5,7 @@ * ledger: both run the SAME shared band minter so a take is bound to a slot * identically wherever it is decided. These tests pin the behaviour the audit * called out (HIGH-5: the ±6h snap fully gone) + the late-take force-attribute - * guard (Marc's "diesem Slot zuordnen?" nudge): + * guard (the maintainer's "diesem Slot zuordnen?" nudge): * * - on-time take → its own slot anchor; * - clearly off-window → ad-hoc (null), NOT the nearest snapped slot; @@ -22,7 +22,7 @@ import { type AttributeIntakeMedication, } from "../attribute-intake"; import type { WorkerScheduleRow } from "../worker-helpers"; -import { localHmAsUtc } from "@/lib/timezone"; +import { localHmAsUtc } from "@/lib/tz/local-day"; const TZ = "Europe/Berlin"; @@ -65,7 +65,7 @@ function at(dayRef: Date, h: number, m: number): Date { const DAY = new Date("2026-06-08T12:00:00Z"); // CEST -describe("attributeTakenToSlot — twice-daily 07:00 / 19:00 (Marc's case)", () => { +describe("attributeTakenToSlot — twice-daily 07:00 / 19:00 (the maintainer's case)", () => { const m = med([schedule({ timesOfDay: ["07:00", "19:00"] })]); it("snaps an on-time morning take to the 07:00 slot", () => { diff --git a/src/lib/medications/scheduling/__tests__/attribution.test.ts b/src/lib/medications/scheduling/__tests__/attribution.test.ts index ccc59ce50..9d2b40a0f 100644 --- a/src/lib/medications/scheduling/__tests__/attribution.test.ts +++ b/src/lib/medications/scheduling/__tests__/attribution.test.ts @@ -25,7 +25,7 @@ import { attributeIntakeToSlot, type SlotWindowInput, } from "../attribution"; -import { localHmAsUtc } from "@/lib/timezone"; +import { localHmAsUtc } from "@/lib/tz/local-day"; const TZ = "Europe/Berlin"; const MIN = 60_000; @@ -76,7 +76,7 @@ describe("attributeIntakeToSlot — summer (CEST)", () => { }); it("a take at 11:29 is ad-hoc (past the 07:00 tail, before the 19:00 window)", () => { - // The exact case Marc reported: an 11:29 take must NOT show as the 07:00 slot. + // The exact case the maintainer reported: an 11:29 take must NOT show as the 07:00 slot. expect(attributeIntakeToSlot(at(day, 11, 29), bands)).toBeNull(); }); @@ -148,8 +148,8 @@ describe("attributeIntakeToSlot — configurable window widens on-time", () => { const day = new Date("2026-06-08T12:00:00Z"); it("an explicit 07:00–12:00 window makes an 11:29 take on_time for that dose", () => { - // Marc's lever: a real morning window of 07:00–12:00 (not a ±60 point) - // means his habitual ~11:00 intake counts as on-time, not ad-hoc. + // The maintainer's lever: a real morning window of 07:00–12:00 (not a ±60 point) + // means their habitual ~11:00 intake counts as on-time, not ad-hoc. const slots: SlotWindowInput[] = [ { at: localHmAsUtc(day, TZ, 7, 0), diff --git a/src/lib/medications/scheduling/__tests__/band-minter.test.ts b/src/lib/medications/scheduling/__tests__/band-minter.test.ts index bdbb16447..cfcd55f9a 100644 --- a/src/lib/medications/scheduling/__tests__/band-minter.test.ts +++ b/src/lib/medications/scheduling/__tests__/band-minter.test.ts @@ -28,7 +28,7 @@ import { type RecurrenceContext, } from "../recurrence"; import { attributeIntakeToSlot } from "../attribution"; -import { localHmAsUtc } from "@/lib/timezone"; +import { localHmAsUtc } from "@/lib/tz/local-day"; const TZ = "Europe/Berlin"; const MIN = 60_000; @@ -471,7 +471,7 @@ describe("multi-schedule — bands built PER SCHEDULE (no cross-clip)", () => { }); // ──────────────────────────────────────────────────────────────────── -// 9. Per-dose configurable window (Marc's "07:00–09:00" lever, v1.15.18 W7) +// 9. Per-dose configurable window (the maintainer's "07:00–09:00" lever, v1.15.18 W7) // ──────────────────────────────────────────────────────────────────── describe("explicit per-dose window from schedule.doseWindows", () => { diff --git a/src/lib/medications/scheduling/__tests__/dose-history.test.ts b/src/lib/medications/scheduling/__tests__/dose-history.test.ts index c98a6ea37..2ac4620ea 100644 --- a/src/lib/medications/scheduling/__tests__/dose-history.test.ts +++ b/src/lib/medications/scheduling/__tests__/dose-history.test.ts @@ -21,7 +21,7 @@ import { suggestNearestSlot, type HistoryIntake, } from "../dose-history"; -import { localHmAsUtc } from "@/lib/timezone"; +import { localHmAsUtc } from "@/lib/tz/local-day"; const TZ = "Europe/Berlin"; const MIN = 60_000; diff --git a/src/lib/medications/scheduling/__tests__/resolve-slot-instant.test.ts b/src/lib/medications/scheduling/__tests__/resolve-slot-instant.test.ts index 81852027e..c884b6a04 100644 --- a/src/lib/medications/scheduling/__tests__/resolve-slot-instant.test.ts +++ b/src/lib/medications/scheduling/__tests__/resolve-slot-instant.test.ts @@ -15,7 +15,7 @@ import { describe, expect, it } from "vitest"; import { resolveCanonicalSlotInstant } from "../resolve-slot-instant"; import type { SlotResolverMedication } from "../resolve-slot-instant"; import type { WorkerScheduleRow } from "../worker-helpers"; -import { localHmAsUtc } from "@/lib/timezone"; +import { localHmAsUtc } from "@/lib/tz/local-day"; const TZ = "Europe/Berlin"; diff --git a/src/lib/medications/scheduling/band-minter.ts b/src/lib/medications/scheduling/band-minter.ts index 90326471a..d4ef0ced7 100644 --- a/src/lib/medications/scheduling/band-minter.ts +++ b/src/lib/medications/scheduling/band-minter.ts @@ -51,7 +51,7 @@ import { type Occurrence, type RecurrenceContext, } from "@/lib/medications/scheduling/recurrence"; -import { localHmAsUtc } from "@/lib/timezone"; +import { localHmAsUtc } from "@/lib/tz/local-day"; const MINUTE_MS = 60_000; const HOUR_MS = 60 * MINUTE_MS; @@ -82,7 +82,7 @@ export interface BandMinterMedication { /** * Per-dose window override. When absent the minter derives the on-time width + * late tail from `DOSE_WINDOW_DEFAULTS` keyed on the realised family. A caller - * can widen the on-time band per Marc's configurable-window lever. + * can widen the on-time band per the maintainer's configurable-window lever. */ export interface DoseWindowConfig { /** Daily on-time half-width around the slot instant (minutes). */ @@ -235,7 +235,7 @@ function mintBands( schedule: CanonicalSchedule, ): SlotBand[] { const w = resolveWindows(windowConfig); - // The persisted per-dose windows (Marc's "07:00–09:00" lever). When a slot's + // The persisted per-dose windows (the maintainer's "07:00–09:00" lever). When a slot's // `timeOfDay` matches an entry, the on-time band is the explicit `[start, // end]` range; otherwise the default symmetric ±width applies. The late tail // stays cadence-derived for every slot. diff --git a/src/lib/medications/scheduling/compliance.ts b/src/lib/medications/scheduling/compliance.ts index ff2fff89e..ab450ef98 100644 --- a/src/lib/medications/scheduling/compliance.ts +++ b/src/lib/medications/scheduling/compliance.ts @@ -4,7 +4,7 @@ * Reuses `buildCadenceTimeline` to count taken vs missed slots over a * rolling window. The W19e detail page surfaces four chips: adherence * rate, current streak (days), longest streak (days), missed in last - * 30 days. Each chip is monochrome — Marc-memory: no gamified badges. + * 30 days. Each chip is monochrome — maintainer note: no gamified badges. * * Distinct from `src/lib/analytics/compliance.ts.calculateCompliance`: * that helper computes against expected counts (schedules-per-day × diff --git a/src/lib/medications/scheduling/project-today-intakes.ts b/src/lib/medications/scheduling/project-today-intakes.ts index 6be6f93ae..2663fe6db 100644 --- a/src/lib/medications/scheduling/project-today-intakes.ts +++ b/src/lib/medications/scheduling/project-today-intakes.ts @@ -34,7 +34,7 @@ import { buildRecurrenceContext, scheduleEmitsInWindow, } from "@/lib/medications/scheduling/worker-helpers"; -import { localHmAsUtc } from "@/lib/timezone"; +import { localHmAsUtc } from "@/lib/tz/local-day"; import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; export interface ProjectTodayIntakesResult { diff --git a/src/lib/medications/scheduling/resolve-slot-instant.ts b/src/lib/medications/scheduling/resolve-slot-instant.ts index f411390b8..81d2bb05e 100644 --- a/src/lib/medications/scheduling/resolve-slot-instant.ts +++ b/src/lib/medications/scheduling/resolve-slot-instant.ts @@ -33,7 +33,8 @@ import { type CanonicalSchedule, type RecurrenceContext, } from "@/lib/medications/scheduling/recurrence"; -import { getLocalDateParts, localHmAsUtc } from "@/lib/timezone"; +import { localHmAsUtc } from "@/lib/tz/local-day"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; const ONE_MINUTE_MS = 60_000; const ONE_HOUR_MS = 60 * ONE_MINUTE_MS; @@ -153,7 +154,7 @@ export function resolveCanonicalSlotInstant( // time-of-day near the local-midnight boundary (and any DST shift) is // still captured — the projector + worker apply the time-of-day to the // local day, which can land just outside a naive same-UTC-day window. - const parts = getLocalDateParts(incoming, userTz); + const parts = wallClockInTz(incoming, userTz); const localDayMidnightUtc = localHmAsUtc(incoming, userTz, 0, 0); const windowStart = new Date(localDayMidnightUtc.getTime() - ONE_DAY_MS); const windowEnd = new Date(localDayMidnightUtc.getTime() + 2 * ONE_DAY_MS); @@ -180,7 +181,7 @@ export function resolveCanonicalSlotInstant( // Only consider slots that land on the SAME local calendar day as // the incoming instant — a slot on the padded neighbouring day must // not capture a write meant for a different day. - const occParts = getLocalDateParts(occ.at, userTz); + const occParts = wallClockInTz(occ.at, userTz); if ( occParts.year !== parts.year || occParts.month !== parts.month || diff --git a/src/lib/moodlog/push.ts b/src/lib/moodlog/push.ts index c8ae32555..ff27948e1 100644 --- a/src/lib/moodlog/push.ts +++ b/src/lib/moodlog/push.ts @@ -11,7 +11,7 @@ import { safeFetch } from "@/lib/safe-fetch"; * polls MoodLog every 15 minutes and imports new rows. That's a * one-way bridge: anything the user logs INSIDE HealthLog (the iOS * app's mood reminder, the web form, a Telegram nudge handler) never - * reaches MoodLog. Marc's report: he taps a mood in iOS, the entry + * reaches MoodLog. The maintainer's report: they tap a mood in iOS, the entry * lands in HealthLog, but never appears in MoodLog's timeline. * * This helper closes that gap. Every successful create on diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 6f9f5a830..e452f5a96 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -139,6 +139,25 @@ const bulkDeleteMeasurementsResponse = z }) .meta({ id: "MeasurementsBulkDeleteResult" }); +// v1.16.4 — un-tombstone for the management list's delete-Undo +// affordance. Same id-scoping rules as the bulk delete: forged / +// foreign / not-deleted ids are silent no-ops. +const restoreMeasurementsRequest = z + .object({ + ids: z.array(z.string().min(1)).min(1).max(200), + }) + .meta({ + id: "MeasurementsRestoreRequest", + description: + "1..200 measurement ids, scoped to the caller. Forged / foreign / not-deleted ids are silently skipped (no existence leak).", + }); + +const restoreMeasurementsResponse = z + .object({ + restored: z.number().int().nonnegative(), + }) + .meta({ id: "MeasurementsRestoreResult" }); + export const measurementResource = z .object({ id: z.string(), @@ -329,6 +348,34 @@ export const measurementPaths: NonNullable = { }, }, }, + "/api/measurements/restore": { + post: { + tags: ["Measurements"], + summary: "Restore soft-deleted measurements", + description: + "Un-tombstones up to 200 of the caller's soft-deleted measurement rows in one call — the delete-Undo counterpart to `/api/measurements/bulk-delete`. Idempotent via the `Idempotency-Key` header; rate-limited `measurements:restore:`. Forged / foreign / not-deleted ids are silently skipped — `restored` is the count of rows actually un-tombstoned. The restored rows re-surface in `/api/sync/changes` as upserts.", + requestBody: { + required: true, + content: { + "application/json": { schema: restoreMeasurementsRequest }, + }, + }, + responses: { + "200": { + description: "Restore processed.", + content: { + "application/json": { + schema: dataEnvelope( + restoreMeasurementsResponse, + "MeasurementsRestoreResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/sleep/night": { get: { tags: ["Measurements"], diff --git a/src/lib/queries/__tests__/use-dashboard-snapshot.test.tsx b/src/lib/queries/__tests__/use-dashboard-snapshot.test.tsx index 7df25ddce..01ee7955b 100644 --- a/src/lib/queries/__tests__/use-dashboard-snapshot.test.tsx +++ b/src/lib/queries/__tests__/use-dashboard-snapshot.test.tsx @@ -16,9 +16,15 @@ import { describe, it, expect, vi, afterEach } from "vitest"; const useQueryMock = vi.fn<(opts: Record) => { data: undefined }>(); +const getQueryDataMock = vi.fn(); +const setQueryDataMock = vi.fn(); vi.mock("@tanstack/react-query", () => ({ useQuery: (opts: Record) => useQueryMock(opts), + useQueryClient: () => ({ + getQueryData: getQueryDataMock, + setQueryData: setQueryDataMock, + }), })); import { useDashboardSnapshot } from "../use-dashboard-snapshot"; @@ -26,6 +32,9 @@ import { queryKeys } from "@/lib/query-keys"; afterEach(() => { useQueryMock.mockClear(); + getQueryDataMock.mockReset(); + setQueryDataMock.mockClear(); + vi.unstubAllGlobals(); }); describe("useDashboardSnapshot — auto-refresh on an open page", () => { @@ -54,4 +63,38 @@ describe("useDashboardSnapshot — auto-refresh on an open page", () => { const opts = lastOpts(); expect(opts.queryKey).toEqual(queryKeys.dashboardSnapshot()); }); + + it("seeds the widgets cache from the snapshot layout when the slot is empty", async () => { + const layout = { widgets: [] }; + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: { layout, tiles: {} } }), + }), + ); + getQueryDataMock.mockReturnValue(undefined); + useDashboardSnapshot(); + const opts = lastOpts(); + await (opts.queryFn as () => Promise)(); + expect(setQueryDataMock).toHaveBeenCalledWith( + queryKeys.dashboardWidgets(), + layout, + ); + }); + + it("never clobbers an already-populated widgets cache", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: { layout: { widgets: [] }, tiles: {} } }), + }), + ); + getQueryDataMock.mockReturnValue({ widgets: [{ id: "existing" }] }); + useDashboardSnapshot(); + const opts = lastOpts(); + await (opts.queryFn as () => Promise)(); + expect(setQueryDataMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/lib/queries/use-dashboard-snapshot.ts b/src/lib/queries/use-dashboard-snapshot.ts index 64e6b02b4..071d9482e 100644 --- a/src/lib/queries/use-dashboard-snapshot.ts +++ b/src/lib/queries/use-dashboard-snapshot.ts @@ -28,7 +28,7 @@ * - The queryKey is the centralised factory entry * `queryKeys.dashboardSnapshot()`. */ -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { queryKeys } from "@/lib/query-keys"; import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval"; import type { DashboardSnapshot } from "@/lib/dashboard/snapshot"; @@ -41,9 +41,26 @@ async function fetchDashboardSnapshot(): Promise { } export function useDashboardSnapshot(enabled = true) { + const queryClient = useQueryClient(); return useQuery({ queryKey: queryKeys.dashboardSnapshot(), - queryFn: fetchDashboardSnapshot, + queryFn: async () => { + const snap = await fetchDashboardSnapshot(); + // Cold-start de-waterfall: the snapshot already carries the + // resolved widget layout, and the per-chart overlay-prefs hook + // (`use-chart-overlay-prefs.ts`) fetches the SAME payload from + // `/api/dashboard/widgets` under `queryKeys.dashboardWidgets()` + // before the chart cells issue their measurement queries. Seeding + // that cache here removes one full request stage from the + // dashboard waterfall (snapshot → widgets → measurements becomes + // snapshot → measurements). Seed ONLY when the slot is empty so a + // later interval refetch never clobbers an optimistic overlay / + // compare-toggle mutation that wrote the key in the meantime. + if (!queryClient.getQueryData(queryKeys.dashboardWidgets())) { + queryClient.setQueryData(queryKeys.dashboardWidgets(), snap.layout); + } + return snap; + }, enabled, staleTime: 60_000, refetchOnMount: false, diff --git a/src/lib/rollups/measurement-rollups.ts b/src/lib/rollups/measurement-rollups.ts index 012bada89..b420262f4 100644 --- a/src/lib/rollups/measurement-rollups.ts +++ b/src/lib/rollups/measurement-rollups.ts @@ -776,7 +776,7 @@ export async function enqueueBootTimeRollupBackfill(): Promise<{ // its composite primary key. // // v1.4.41 — the v1.4.39 W-SUM legacy-NULL `sum_value` UNION arm - // has been retired. Marc's tenant converged at v1.4.40, so the + // has been retired. The maintainer's tenant converged at v1.4.40, so the // arm was a permanent no-op seq scan over `measurement_rollups`. // Per-day missing coverage remains the sole discovery anchor. const users = await prisma.$queryRaw>` diff --git a/src/lib/sources/__tests__/source-priority-withings-applehealth.test.ts b/src/lib/sources/__tests__/source-priority-withings-applehealth.test.ts index 335177373..7b09b9edd 100644 --- a/src/lib/sources/__tests__/source-priority-withings-applehealth.test.ts +++ b/src/lib/sources/__tests__/source-priority-withings-applehealth.test.ts @@ -3,7 +3,7 @@ * Withings (activity + sleep v2 ingest) AND Apple Health (iOS * passthrough, v1.5) populate the same metric for the same day. * - * The Marc-directive default priority (W8c — see + * The maintainer-directive default priority (W8c — see * `src/lib/validations/source-priority.ts`) picks APPLE_HEALTH first * for every cumulative metric (steps, active energy, walking-running * distance, flights climbed) because HealthKit aggregates iPhone + @@ -105,7 +105,7 @@ describe("cross-source priority — Withings Activity + Apple Health", () => { }); it("respects a user override that promotes WITHINGS above APPLE_HEALTH", () => { - // Marc could prefer his ScanWatch for steps even after iOS lands. + // The maintainer could prefer their ScanWatch for steps even after iOS lands. // The W5e flat-shape override (back-compat) is exercised here. const rows = [ { @@ -138,7 +138,7 @@ describe("cross-source priority — Withings Activity + Apple Health", () => { describe("cross-source priority — Withings Sleep v2 + Apple Health (per-stage)", () => { it("picks APPLE_HEALTH over WITHINGS for sleep when both sources reported the same night", () => { - // Marc's ScanWatch and iPhone both wrote per-stage rows for the + // The maintainer's ScanWatch and iPhone both wrote per-stage rows for the // same night. Sleep default (APPLE_HEALTH > WITHINGS) keeps only // the iOS rows in the aggregation. const measuredAt = new Date("2026-05-12T22:00:00Z"); diff --git a/src/lib/targets/mood-stability-label.ts b/src/lib/targets/mood-stability-label.ts index 885507a85..35be946f4 100644 --- a/src/lib/targets/mood-stability-label.ts +++ b/src/lib/targets/mood-stability-label.ts @@ -6,7 +6,7 @@ * against translation entries `targets.mood.stability.{stable, * variable,highlyVariable}`. * - * Thresholds (Marc-set, documented in the W3e implementation plan): + * Thresholds (maintainer-set, documented in the W3e implementation plan): * σ < 1.0 → "stable" (stabil / stable) * σ < 2.0 → "variable" (schwankend / variable) * σ ≥ 2.0 → "highly-variable" (sehr schwankend / highly variable) diff --git a/src/lib/timezone.ts b/src/lib/timezone.ts deleted file mode 100644 index c28ebc326..000000000 --- a/src/lib/timezone.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Robust timezone utilities using Intl.DateTimeFormat. - * Replaces the fragile `new Date(date.toLocaleString("en-US", { timeZone }))` pattern - * which can produce incorrect results due to locale-dependent date string parsing. - */ - -interface LocalDateParts { - year: number; - month: number; - day: number; - hour: number; - minute: number; - second: number; - dayOfWeek: number; -} - -/** - * Extract date/time parts for a given instant in a specific timezone. - * Uses Intl.DateTimeFormat.formatToParts for reliable, locale-independent parsing. - */ -export function getLocalDateParts(date: Date, tz: string): LocalDateParts { - const fmt = new Intl.DateTimeFormat("en-US", { - timeZone: tz, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - weekday: "short", - }); - - const parts = fmt.formatToParts(date); - const get = (type: Intl.DateTimeFormatPartTypes): string => - parts.find((p) => p.type === type)?.value ?? "0"; - - // Intl hour12:false can return "24" for midnight in some engines - const rawHour = parseInt(get("hour"), 10); - - const weekdayStr = get("weekday"); - const weekdayMap: Record = { - Sun: 0, - Mon: 1, - Tue: 2, - Wed: 3, - Thu: 4, - Fri: 5, - Sat: 6, - }; - - return { - year: parseInt(get("year"), 10), - month: parseInt(get("month"), 10), - day: parseInt(get("day"), 10), - hour: rawHour === 24 ? 0 : rawHour, - minute: parseInt(get("minute"), 10), - second: parseInt(get("second"), 10), - dayOfWeek: weekdayMap[weekdayStr] ?? 0, - }; -} - -/** - * Get the start (midnight) and end (23:59:59.999) of "today" in the user's timezone, - * returned as UTC Date objects suitable for database queries. - */ -export function getUserTodayBounds( - now: Date, - tz: string, -): { start: Date; end: Date } { - const parts = getLocalDateParts(now, tz); - - // Build a UTC date that represents the user's local midnight - // by computing the offset between the real UTC time and the local representation - const localMidnightAsUtc = new Date( - Date.UTC(parts.year, parts.month - 1, parts.day, 0, 0, 0, 0), - ); - - // Compute offset: how far ahead (positive) or behind (negative) the tz is from UTC - // localMidnightAsUtc represents "midnight in tz as if it were UTC" - // We need to find what UTC instant corresponds to midnight in the given tz - const localNowAsUtc = new Date( - Date.UTC( - parts.year, - parts.month - 1, - parts.day, - parts.hour, - parts.minute, - parts.second, - ), - ); - const offsetMs = - Math.round((localNowAsUtc.getTime() - now.getTime()) / 60000) * 60000; - - const start = new Date(localMidnightAsUtc.getTime() - offsetMs); - const end = new Date(start.getTime() + 24 * 60 * 60 * 1000 - 1); - - return { start, end }; -} - -/** - * Get the day of the week (0=Sun..6=Sat) for a given instant in a specific timezone. - */ -export function getDayOfWeekInTz(now: Date, tz: string): number { - return getLocalDateParts(now, tz).dayOfWeek; -} - -/** - * Compute the UTC instant of local `hh:mm` on the local calendar day - * implied by `now` in `tz`. DST-safe — `todayStart.getTime() + h * 3.6e6` - * drifts by an hour on spring-forward / fall-back days because raw UTC - * arithmetic ignores the DST jump between local midnight and local - * `hh:mm`. This helper re-derives the offset at the target local time - * so the returned instant always represents local `hh:mm`. - */ -export function localHmAsUtc( - now: Date, - tz: string, - hour: number, - minute: number, -): Date { - const parts = getLocalDateParts(now, tz); - const localAtTargetAsUtc = new Date( - Date.UTC(parts.year, parts.month - 1, parts.day, hour, minute, 0, 0), - ); - const localNowAsUtc = new Date( - Date.UTC( - parts.year, - parts.month - 1, - parts.day, - parts.hour, - parts.minute, - parts.second, - ), - ); - const offsetMs = - Math.round((localNowAsUtc.getTime() - now.getTime()) / 60000) * 60000; - return new Date(localAtTargetAsUtc.getTime() - offsetMs); -} diff --git a/src/lib/tz/local-day.ts b/src/lib/tz/local-day.ts new file mode 100644 index 000000000..3c6bc2631 --- /dev/null +++ b/src/lib/tz/local-day.ts @@ -0,0 +1,91 @@ +/** + * Local-calendar-day helpers built on `wallClockInTz`. + * + * Merged in from the former `src/lib/timezone.ts` so every + * Intl-backed timezone primitive lives under `src/lib/tz/`. The + * wall-clock decomposition itself is `wallClockInTz` (see + * `./wall-clock`); the helpers here derive UTC instants from the + * user's local calendar day — today's bounds for database queries, + * the local weekday, and the UTC instant of a local `hh:mm`. + */ +import { wallClockInTz } from "./wall-clock"; + +/** + * Get the start (midnight) and end (23:59:59.999) of "today" in the user's + * timezone, returned as UTC Date objects suitable for database queries. + */ +export function getUserTodayBounds( + now: Date, + tz: string, +): { start: Date; end: Date } { + const parts = wallClockInTz(now, tz); + + // Build a UTC date that represents the user's local midnight + // by computing the offset between the real UTC time and the local + // representation. + const localMidnightAsUtc = new Date( + Date.UTC(parts.year, parts.month - 1, parts.day, 0, 0, 0, 0), + ); + + // Compute offset: how far ahead (positive) or behind (negative) the tz is + // from UTC. localMidnightAsUtc represents "midnight in tz as if it were + // UTC"; we need the UTC instant that corresponds to midnight in the tz. + const localNowAsUtc = new Date( + Date.UTC( + parts.year, + parts.month - 1, + parts.day, + parts.hour, + parts.minute, + parts.second, + ), + ); + const offsetMs = + Math.round((localNowAsUtc.getTime() - now.getTime()) / 60000) * 60000; + + const start = new Date(localMidnightAsUtc.getTime() - offsetMs); + const end = new Date(start.getTime() + 24 * 60 * 60 * 1000 - 1); + + return { start, end }; +} + +/** + * Get the day of the week (0=Sun..6=Sat) for a given instant in a specific + * timezone. + */ +export function getDayOfWeekInTz(now: Date, tz: string): number { + return wallClockInTz(now, tz).weekday; +} + +/** + * Compute the UTC instant of local `hh:mm` on the local calendar day + * implied by `now` in `tz`. DST-safe — `todayStart.getTime() + h * 3.6e6` + * drifts by an hour on spring-forward / fall-back days because raw UTC + * arithmetic ignores the DST jump between local midnight and local + * `hh:mm`. This helper re-derives the offset at the target local time + * so the returned instant always represents local `hh:mm`. + */ +export function localHmAsUtc( + now: Date, + tz: string, + hour: number, + minute: number, +): Date { + const parts = wallClockInTz(now, tz); + const localAtTargetAsUtc = new Date( + Date.UTC(parts.year, parts.month - 1, parts.day, hour, minute, 0, 0), + ); + const localNowAsUtc = new Date( + Date.UTC( + parts.year, + parts.month - 1, + parts.day, + parts.hour, + parts.minute, + parts.second, + ), + ); + const offsetMs = + Math.round((localNowAsUtc.getTime() - now.getTime()) / 60000) * 60000; + return new Date(localAtTargetAsUtc.getTime() - offsetMs); +} diff --git a/src/lib/validations/__tests__/doctor-report-prefs.test.ts b/src/lib/validations/__tests__/doctor-report-prefs.test.ts index faa0c3276..7c6b2f874 100644 --- a/src/lib/validations/__tests__/doctor-report-prefs.test.ts +++ b/src/lib/validations/__tests__/doctor-report-prefs.test.ts @@ -52,7 +52,7 @@ describe("parseDoctorReportPrefs", () => { ); }); - it("defaults mood to OFF (privacy directive per Marc)", () => { + it("defaults mood to OFF (privacy directive per the maintainer)", () => { const out = parseDoctorReportPrefs(null); expect(out.mood).toBe(false); }); diff --git a/src/lib/validations/__tests__/source-priority.test.ts b/src/lib/validations/__tests__/source-priority.test.ts index a8953d8ca..555e60483 100644 --- a/src/lib/validations/__tests__/source-priority.test.ts +++ b/src/lib/validations/__tests__/source-priority.test.ts @@ -171,7 +171,7 @@ describe("DEFAULT_SOURCE_PRIORITY", () => { } }); - it("places WITHINGS first for point measurements (Marc directive)", () => { + it("places WITHINGS first for point measurements (the maintainer directive)", () => { // Withings devices are the primary sensor for point readings; the // canonical row should come from the scale / cuff / ScanWatch / // Thermo rather than HealthKit's second-hand mirror. diff --git a/src/lib/validations/doctor-report-prefs.ts b/src/lib/validations/doctor-report-prefs.ts index 079f39475..d390c6261 100644 --- a/src/lib/validations/doctor-report-prefs.ts +++ b/src/lib/validations/doctor-report-prefs.ts @@ -55,7 +55,7 @@ export interface DoctorReportPrefs { /** * Defaults applied when the user has never opened the dialog. Every - * section is ON by default EXCEPT mood, which is opt-in per Marc's + * section is ON by default EXCEPT mood, which is opt-in per the maintainer's * privacy directive (2026-05-14): mental-health data should never appear * in a clinical PDF the user didn't explicitly check. */ @@ -64,7 +64,7 @@ export const DEFAULT_DOCTOR_REPORT_PREFS: DoctorReportPrefs = { weight: true, pulse: true, bmi: true, - mood: false, // privacy default per Marc + mood: false, // privacy default per the maintainer compliance: true, sleep: true, // Cycle data is opt-in: a user sharing a BP report with a cardiologist diff --git a/src/lib/validations/medication.ts b/src/lib/validations/medication.ts index c35445103..03bda0bc9 100644 --- a/src/lib/validations/medication.ts +++ b/src/lib/validations/medication.ts @@ -289,7 +289,7 @@ export const scheduleSchema = z "Cyclic \"off\" weeks (0..52). Required when `scheduleType` is CYCLIC; ignored otherwise.", ), /** - * v1.15.18 — per-dose configurable on-time intake window (Marc's + * v1.15.18 — per-dose configurable on-time intake window (the maintainer's * "07:00–09:00" lever). One entry per dose time the user wants an explicit * range for; a `timeOfDay` with no entry keeps the symmetric ±1h default. * Each `timeOfDay` MUST match one of the schedule's `timesOfDay` (or the diff --git a/src/lib/validations/notification-prefs.ts b/src/lib/validations/notification-prefs.ts index 0fbc2cf84..6da77ea66 100644 --- a/src/lib/validations/notification-prefs.ts +++ b/src/lib/validations/notification-prefs.ts @@ -149,7 +149,7 @@ export const DEFAULT_MOOD_REMINDER_HOUR = 22; /** * Safe defaults. Every category is off so the server reminders flow * unchanged for any user the iOS app has not explicitly opted in. - * Marc's directive: zero regression for clients without working + * The maintainer's directive: zero regression for clients without working * local reminders. */ export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = { diff --git a/src/lib/validations/source-priority.ts b/src/lib/validations/source-priority.ts index 8011840ff..0ef2c6df0 100644 --- a/src/lib/validations/source-priority.ts +++ b/src/lib/validations/source-priority.ts @@ -199,7 +199,7 @@ export type MetricPriority = z.infer; export type DeviceTypePriority = z.infer; /** - * Marc-directive 2026-05-14 defaults: + * maintainer-directive 2026-05-14 defaults: * - Cumulative metrics (steps, activeEnergy, walkingRunningDistance, * flightsClimbed): APPLE_HEALTH > WITHINGS > MANUAL. iOS HealthKit * aggregates ScanWatch + iPhone sensors into a single canonical diff --git a/src/lib/workouts/__tests__/canonical-rows.test.ts b/src/lib/workouts/__tests__/canonical-rows.test.ts index 685ac66cc..45a72683e 100644 --- a/src/lib/workouts/__tests__/canonical-rows.test.ts +++ b/src/lib/workouts/__tests__/canonical-rows.test.ts @@ -183,7 +183,7 @@ describe("dedupeWorkoutBatch", () => { }); it("falls back to earliest createdAt when source + calories both tie", () => { - // Marc's ScanWatch on the wrist + ScanWatch in a desk drawer: + // The maintainer's ScanWatch on the wrist + ScanWatch in a desk drawer: // unrealistic, but the case isolates the tie-breaker hierarchy. const early = row({ id: "early",