refactor(api,tz): one typed client for every call and one home for wall-clock math

A typed apiFetch wrapper owns the envelope unwrap, the ok-check and the
error read for every client call — two hundred call sites across the
app migrate onto it and a lint rule keeps raw api fetches out for good.
The legacy timezone helpers fold into the tz module and test fixtures
drop personal names.
This commit is contained in:
Marc-André Bombeck committed 2026-06-11 02:19:27 +02:00
1 parent 7e7e17c68c
commit 9445d89b76
90 files changed
+1097 -319

No files matched your search

@@ -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;
+3
View File
@@ -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,
},
};
@@ -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
+2 -2
View File
@@ -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"];
+6
View File
@@ -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")),
});
}
+2 -2
View File
@@ -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.
@@ -33,7 +33,7 @@ function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
end: "2026-05-03T12:00:00.000Z",
},
patient: {
username: "marc",
username: "testuser",
dateOfBirth: "1985-06-15T00:00:00.000Z",
gender: "MALE",
heightCm: 182,
@@ -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 {
@@ -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:
@@ -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
@@ -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.")
*
@@ -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.
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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:
@@ -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");
@@ -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
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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.")
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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:
+2 -2
View File
@@ -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.
@@ -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):
*
@@ -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 },
@@ -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
@@ -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
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+221
View File
@@ -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("<html>upstream</html>", { 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<number[], { total: number }>(
"/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 }),
});
});
});
+192
View File
@@ -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<T>(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>)`), `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<T, M>` 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<string, unknown> | undefined;
constructor(
message: string,
status: number,
meta?: Record<string, unknown>,
) {
super(message);
this.name = "ApiError";
this.status = status;
this.meta = meta;
}
}
interface Envelope<T, M = Record<string, unknown>> {
data: T;
error: string | null;
meta?: M;
}
async function throwApiError(res: Response): Promise<never> {
let meta: Record<string, unknown> | 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<string, unknown>;
}
} catch {
/* non-JSON body — readError supplies the fallback message */
}
throw new ApiError(await readError(res), res.status, meta);
}
async function parseEnvelope<T, M>(
res: Response,
): Promise<Envelope<T, M> | undefined> {
if (res.status === 204) return undefined;
const text = await res.text();
if (text.length === 0) return undefined;
return JSON.parse(text) as Envelope<T, M>;
}
/**
* Same-origin API fetch — resolves with the unwrapped `data` payload,
* throws {@link ApiError} on a non-OK response.
*/
export async function apiFetch<T = unknown>(
path: string,
init?: RequestInit,
): Promise<T> {
const res = await fetch(path, init);
if (!res.ok) await throwApiError(res);
const envelope = await parseEnvelope<T, unknown>(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<string, unknown>,
>(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<T, M>(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<Response> {
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<T = unknown>(
path: string,
init?: RequestInit,
): Promise<T> {
return apiFetch<T>(path, { ...init, method: "GET" });
}
/** POST with optional JSON body — resolves with the unwrapped `data`. */
export function apiPost<T = unknown>(
path: string,
body?: unknown,
init?: RequestInit,
): Promise<T> {
return apiFetch<T>(path, withJsonBody("POST", body, init));
}
/** PUT with optional JSON body — resolves with the unwrapped `data`. */
export function apiPut<T = unknown>(
path: string,
body?: unknown,
init?: RequestInit,
): Promise<T> {
return apiFetch<T>(path, withJsonBody("PUT", body, init));
}
/** PATCH with optional JSON body — resolves with the unwrapped `data`. */
export function apiPatch<T = unknown>(
path: string,
body?: unknown,
init?: RequestInit,
): Promise<T> {
return apiFetch<T>(path, withJsonBody("PATCH", body, init));
}
/** DELETE with optional JSON body — resolves with the unwrapped `data`. */
export function apiDelete<T = unknown>(
path: string,
body?: unknown,
init?: RequestInit,
): Promise<T> {
return apiFetch<T>(path, withJsonBody("DELETE", body, init));
}
@@ -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",
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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<string, keyof DoctorReportPrefs> = {
@@ -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`
@@ -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
+1 -1
View File
@@ -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`.
+20 -7
View File
@@ -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(
<I18nProvider initialLocale="de">
<Read k="dashboard.title" />
<I18nProvider initialLocale="de" initialMessages={deMessages}>
<Read k="common.save" />
</I18nProvider>,
);
// 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\.title</);
expect(html).toContain("Speichern");
expect(html).not.toContain(">Save<");
});
// 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
+105 -23
View File
@@ -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, string | number>) => 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>(() => {
// 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<Locale | null>(null);
// Tracks the most recent switch request so a slow bundle load can't
// clobber a newer one (last click wins).
const requestedLocaleRef = useRef<Locale | null>(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, string | number>): 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 (
<I18nContext.Provider value={{ locale, setLocale, t }}>
{children}
</I18nContext.Provider>
const value = useMemo(
() => ({ locale, setLocale, pendingLocale, t }),
[locale, setLocale, pendingLocale, t],
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useTranslations() {
+58
View File
@@ -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<string, unknown>;
/** Synchronous fallback floor — always available, never awaited. */
export const fallbackMessages: MessageBundle = enMessages;
const loaders: Record<Locale, () => 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<Record<Locale, MessageBundle>> = { 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<MessageBundle> {
const cached = cache[locale];
if (cached) return cached;
const mod = await loaders[locale]();
cache[locale] = mod.default;
return mod.default;
}
+26
View File
@@ -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<string, unknown>,
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<string, unknown>)[part];
} else {
return undefined;
}
}
return typeof current === "string" ? current : undefined;
}
+16 -37
View File
@@ -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<Locale, Record<string, unknown>> = {
de: deMessages,
@@ -35,25 +36,3 @@ export const allMessages: Record<Locale, Record<string, unknown>> = {
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<string, unknown>,
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<string, unknown>)[part];
} else {
return undefined;
}
}
return typeof current === "string" ? current : undefined;
}
@@ -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:<TYPE>` 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.",
+2 -2
View File
@@ -136,7 +136,7 @@ const ALLOWED_SET = new Set<string>(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:<TYPE>`
@@ -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(
+2 -2
View File
@@ -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;
}
+2 -2
View File
@@ -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.
@@ -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")}`;
}
+2 -2
View File
@@ -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<TimeOfDayBucket, { sum: number; count: number }>();
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;
@@ -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")}`;
}
@@ -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")}`;
}
@@ -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)");
});
+3 -2
View File
@@ -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()
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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()
+1 -1
View File
@@ -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<DrainCumulativePayload>(
DRAIN_CUMULATIVE_QUEUE,
@@ -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 {
@@ -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"/>
<Record type="HKQuantityTypeIdentifierStepCount"
@@ -46,7 +46,7 @@ function tinyExportXml(): string {
startDate="2026-05-14 12:00:00 +0200"
endDate="2026-05-14 12:30:00 +0200"
value="3500"
sourceName="Marc's iPhone"
sourceName="Test iPhone"
sourceVersion="17.4.1"/>
<Record type="HKCategoryTypeIdentifierSleepAnalysis"
+1 -1
View File
@@ -877,7 +877,7 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set<string>([
// `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",
+1 -1
View File
@@ -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
@@ -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", () => {
+1 -1
View File
@@ -5,7 +5,7 @@
* `categoryLabel` into the same `<MedicationCardHeader>` 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.
+1 -1
View File
@@ -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.
*/
@@ -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", () => {
@@ -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),
@@ -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", () => {
@@ -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;
@@ -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";
@@ -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.
+1 -1
View File
@@ -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 ×
@@ -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 {
@@ -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 ||
+1 -1
View File
@@ -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
+47
View File
@@ -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<ZodOpenApiObject["paths"]> = {
},
},
},
"/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:<userId>`. 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"],
@@ -16,9 +16,15 @@ import { describe, it, expect, vi, afterEach } from "vitest";
const useQueryMock =
vi.fn<(opts: Record<string, unknown>) => { data: undefined }>();
const getQueryDataMock = vi.fn();
const setQueryDataMock = vi.fn();
vi.mock("@tanstack/react-query", () => ({
useQuery: (opts: Record<string, unknown>) => 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<unknown>)();
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<unknown>)();
expect(setQueryDataMock).not.toHaveBeenCalled();
});
});
+19 -2
View File
@@ -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<DashboardSnapshot> {
}
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,
+1 -1
View File
@@ -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<Array<{ id: string }>>`
@@ -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");
+1 -1
View File
@@ -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)
-139
View File
@@ -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<string, number> = {
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);
}
+91
View File
@@ -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);
}
@@ -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);
});
@@ -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.
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 = {
+1 -1
View File
@@ -199,7 +199,7 @@ export type MetricPriority = z.infer<typeof metricPriorityObjectSchema>;
export type DeviceTypePriority = z.infer<typeof deviceTypePrioritySchema>;
/**
* 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
@@ -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",