mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
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;
|
||||
@@ -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
|
||||
|
||||
@@ -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"];
|
||||
|
||||
|
||||
@@ -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")),
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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.")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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")}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user