From b90c82cba3bb99f120f26a88d34d066b749fccfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 12:48:17 +0200 Subject: [PATCH 01/63] fix(migrate): run migrate deploy in a UTC session Prisma ignores PGTZ, so a database whose default zone is not UTC ran every migration in that zone, and a migration mixing timestamp and timestamptz wrote shifted values. The boot wrapper now appends -c TimeZone=UTC to the options parameter of DATABASE_URL for its Prisma calls, honouring DATABASE_SESSION_OPTIONS_DISABLED like the app does. --- docs/ops/deploy.md | 9 ++ scripts/__tests__/migrate-deploy.test.ts | 49 ++++++ scripts/migrate-deploy.mjs | 55 +++++++ tests/integration/migrate-deploy-utc.test.ts | 151 +++++++++++++++++++ 4 files changed, 264 insertions(+) create mode 100644 tests/integration/migrate-deploy-utc.test.ts diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index 89ac3d380..36e78047e 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -40,6 +40,15 @@ not reachable". The entrypoint also runs the migrations on every boot, so step 2 is optional; it is there for when you want to see the migration result before the new container takes traffic. +The entrypoint runs every migration in a UTC session, the zone the app's own +connections use, whatever the database's default `TimeZone` is: it adds +`-c TimeZone=UTC` to the `options` parameter of `DATABASE_URL` for the Prisma +calls (an `options` value you set yourself is kept, with the pin after it). +Behind a connection pooler that refuses `options`, the +`DATABASE_SESSION_OPTIONS_DISABLED` switch turns this off along with the +app's own session settings; set the database or role default to UTC there +(`ALTER DATABASE healthlog SET timezone = 'UTC'`). + ### A migration that timed out on a lock Some migrations bound how long they wait for a table lock, so that a diff --git a/scripts/__tests__/migrate-deploy.test.ts b/scripts/__tests__/migrate-deploy.test.ts index a63edc96b..46b7b7915 100644 --- a/scripts/__tests__/migrate-deploy.test.ts +++ b/scripts/__tests__/migrate-deploy.test.ts @@ -24,7 +24,9 @@ import { afterEach, describe, expect, it } from "vitest"; import { decideLockRetry, lockTimeoutInReport, + prismaEnv, retryDelays, + utcSessionUrl, } from "../migrate-deploy.mjs"; const P3018_LOCK = `Applying migration \`0412_measurement_hot_updates\` @@ -167,6 +169,53 @@ describe("which failure is retried", () => { }); }); +describe("the session time zone Prisma migrates in", () => { + const options = (url: string | undefined) => + new URL(url ?? "").searchParams.get("options"); + + it("pins TimeZone=UTC through the libpq options parameter", () => { + const url = utcSessionUrl( + "postgresql://u:p@db:5432/healthlog?schema=public", + ); + expect(options(url)).toBe("-c TimeZone=UTC"); + expect(new URL(url ?? "").searchParams.get("schema")).toBe("public"); + }); + + it("appends to options the operator set, so a later -c wins", () => { + const url = utcSessionUrl( + "postgresql://u:p@db/h?options=-c%20TimeZone%3DEurope%2FBerlin", + ); + expect(options(url)).toBe("-c TimeZone=Europe/Berlin -c TimeZone=UTC"); + // Running it again adds nothing. + expect(utcSessionUrl(url)).toBe(url); + }); + + it("leaves a URL alone when the startup options are switched off", () => { + const raw = "postgresql://u:p@db/h?schema=public"; + expect(utcSessionUrl(raw, { optionsDisabled: true })).toBe(raw); + expect( + prismaEnv({ DATABASE_URL: raw, DATABASE_SESSION_OPTIONS_DISABLED: "1" }) + .DATABASE_URL, + ).toBe(raw); + }); + + it("passes an unset or unparseable URL through untouched", () => { + expect(utcSessionUrl(undefined)).toBeUndefined(); + expect(utcSessionUrl("")).toBe(""); + expect(utcSessionUrl("not a url")).toBe("not a url"); + expect(prismaEnv({}).DATABASE_URL).toBeUndefined(); + }); + + it("hands Prisma the pinned URL and keeps the rest of the environment", () => { + const env = prismaEnv({ + DATABASE_URL: "postgresql://u:p@db/h", + HEALTHLOG_X: "1", + }); + expect(options(env.DATABASE_URL)).toBe("-c TimeZone=UTC"); + expect(env.HEALTHLOG_X).toBe("1"); + }); +}); + describe("the retry loop", () => { const dirs: string[] = []; afterEach(() => { diff --git a/scripts/migrate-deploy.mjs b/scripts/migrate-deploy.mjs index 23abb5f9a..4f06202c2 100644 --- a/scripts/migrate-deploy.mjs +++ b/scripts/migrate-deploy.mjs @@ -30,6 +30,16 @@ * after a failed one (P3009), from the `logs` Prisma stored on the failed * row in `_prisma_migrations`. * + * Every Prisma call runs with its session pinned to UTC, the way the app's + * own connections are (`src/lib/db-session.ts`). Almost every `DateTime` + * column is `timestamp` without a time zone and holds UTC, and Postgres + * converts between that and `timestamptz` / `now()` through the session time + * zone. A migration that mixes the two (0372 does) therefore wrote hours-off + * values on a database whose default zone is not UTC. Prisma ignores `PGTZ`, + * so the pin rides the connection URL as the libpq `options` parameter, which + * Prisma passes to the server. `DATABASE_SESSION_OPTIONS_DISABLED` (a pooler + * that refuses `options`) turns it off, as it does for the app. + * * Environment: * HEALTHLOG_PRISMA_CLI Prisma's CLI entry (default: the image's copy) * HEALTHLOG_MIGRATIONS_DIR where the migration files are (default: @@ -49,6 +59,7 @@ const DEFAULT_RETRY_DELAYS_S = [15, 30, 60, 120, 240]; export const RETRY_MARKER = /^--\s*@retry-on-lock-timeout\s*$/m; const LOCK_TIMEOUT = "55P03"; +const TIME_ZONE_OPTION = "-c TimeZone=UTC"; const MIGRATION_NAME = /^[A-Za-z0-9_]+$/; /** @@ -116,10 +127,54 @@ export function retryDelays(raw) { : DEFAULT_RETRY_DELAYS_S; } +/** + * `DATABASE_URL` with the session time zone pinned to UTC, for Prisma. + * + * Appends to an `options` value the operator set rather than replacing it; + * a later `-c` wins, so an operator's own `TimeZone` gives way to UTC. A URL + * that does not parse, or a host that disabled the startup options, is + * returned unchanged. + * + * @param {string | undefined} raw + * @param {{ optionsDisabled?: boolean }} [opts] + * @returns {string | undefined} + */ +export function utcSessionUrl(raw, { optionsDisabled = false } = {}) { + if (!raw || optionsDisabled) return raw; + let url; + try { + url = new URL(raw); + } catch { + return raw; + } + const existing = url.searchParams.get("options")?.trim(); + if (existing && existing.endsWith(TIME_ZONE_OPTION)) return raw; + url.searchParams.set( + "options", + existing ? `${existing} ${TIME_ZONE_OPTION}` : TIME_ZONE_OPTION, + ); + return url.toString(); +} + +/** The same switch the app reads in `src/lib/db-session.ts`. */ +function sessionOptionsDisabled(env) { + const raw = env.DATABASE_SESSION_OPTIONS_DISABLED?.trim(); + return raw !== undefined && /^(1|true|yes)$/i.test(raw); +} + +/** The environment every Prisma call runs in. */ +export function prismaEnv(env = process.env) { + const url = utcSessionUrl(env.DATABASE_URL, { + optionsDisabled: sessionOptionsDisabled(env), + }); + return url === undefined ? { ...env } : { ...env, DATABASE_URL: url }; +} + function runPrisma(cli, args) { return new Promise((resolve) => { const child = spawn(process.execPath, [cli, ...args], { stdio: ["ignore", "pipe", "pipe"], + env: prismaEnv(), }); let output = ""; child.stdout.on("data", (chunk) => { diff --git a/tests/integration/migrate-deploy-utc.test.ts b/tests/integration/migrate-deploy-utc.test.ts new file mode 100644 index 000000000..84b0e1b94 --- /dev/null +++ b/tests/integration/migrate-deploy-utc.test.ts @@ -0,0 +1,151 @@ +/** + * The boot's `migrate deploy` (`scripts/migrate-deploy.mjs`) runs every + * migration in a UTC session, whatever the database's own default zone. + * + * A role whose default `TimeZone` is Europe/Berlin stands in for a shared + * Postgres configured that way. One migration records the session zone and + * the instant a `timestamp` → `timestamptz` cast lands on, which is what a + * migration mixing the two types (0372) depends on. Through the wrapper both + * read UTC; through the bare Prisma CLI, the counter-check, they read Berlin, + * which proves the role default is what the wrapper overrode. + */ +import { spawn } from "node:child_process"; +import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Client } from "pg"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const ROLE = "hl_tz_probe"; +const PASSWORD = "tz-probe"; +const ROOT = process.cwd(); +const PRISMA_CLI = join(ROOT, "node_modules", "prisma", "build", "index.js"); + +const dirs: string[] = []; + +function adminUrl(): URL { + const raw = process.env.DATABASE_URL; + if (!raw) throw new Error("DATABASE_URL is not set for the integration run"); + const url = new URL(raw); + url.searchParams.delete("schema"); + return url; +} + +async function admin(): Promise { + const client = new Client({ connectionString: adminUrl().toString() }); + await client.connect(); + return client; +} + +/** A URL that logs in as the Berlin-default role, into its own schema. */ +function probeUrl(schema: string): string { + const url = new URL(process.env.DATABASE_URL ?? ""); + url.username = ROLE; + url.password = PASSWORD; + url.searchParams.set("schema", schema); + return url.toString(); +} + +beforeAll(async () => { + const db = await admin(); + await db.query(`DROP OWNED BY ${ROLE}`).catch(() => {}); + await db.query(`DROP ROLE IF EXISTS ${ROLE}`); + await db.query(`CREATE ROLE ${ROLE} LOGIN SUPERUSER PASSWORD '${PASSWORD}'`); + await db.query(`ALTER ROLE ${ROLE} SET timezone = 'Europe/Berlin'`); + await db.end(); +}); + +afterAll(async () => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true }); + const db = await admin(); + for (const schema of ["tz_probe_wrapper", "tz_probe_bare"]) { + await db.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`); + } + await db.query(`DROP OWNED BY ${ROLE}`).catch(() => {}); + await db.query(`DROP ROLE IF EXISTS ${ROLE}`); + await db.end(); +}); + +function migrationsDir(): string { + const dir = mkdtempSync(join(tmpdir(), "hl-migrate-utc-")); + dirs.push(dir); + writeFileSync( + join(dir, "schema.prisma"), + 'datasource db {\n provider = "postgresql"\n}\n', + ); + mkdirSync(join(dir, "migrations", "0001_zone_probe"), { recursive: true }); + cpSync( + join(ROOT, "prisma", "migrations", "migration_lock.toml"), + join(dir, "migrations", "migration_lock.toml"), + ); + writeFileSync( + join(dir, "migrations", "0001_zone_probe", "migration.sql"), + `CREATE TABLE "zone_probe" AS + SELECT current_setting('TimeZone') AS zone, + ('2026-01-15 12:00:00'::timestamp)::timestamptz + = '2026-01-15 12:00:00+00'::timestamptz AS cast_is_utc;`, + ); + return dir; +} + +function run( + args: string[], + databaseUrl: string, +): Promise<{ code: number; stderr: string }> { + return new Promise((resolve) => { + const child = spawn(process.execPath, args, { + cwd: ROOT, + env: { + ...process.env, + DATABASE_URL: databaseUrl, + DATABASE_SESSION_OPTIONS_DISABLED: "", + HEALTHLOG_PRISMA_CLI: PRISMA_CLI, + }, + }); + let stderr = ""; + child.stdout.on("data", () => {}); + child.stderr.on("data", (chunk) => (stderr += chunk)); + child.on("close", (code) => resolve({ code: code ?? 1, stderr })); + }); +} + +async function probe(schema: string) { + const db = await admin(); + const { rows } = await db.query( + `SELECT zone, cast_is_utc FROM ${schema}.zone_probe`, + ); + await db.end(); + return rows[0] as { zone: string; cast_is_utc: boolean }; +} + +describe("migrate deploy session time zone", () => { + it("runs the migration in UTC through the boot wrapper", async () => { + const dir = migrationsDir(); + const result = await run( + [ + join(ROOT, "scripts", "migrate-deploy.mjs"), + "--schema", + join(dir, "schema.prisma"), + ], + probeUrl("tz_probe_wrapper"), + ); + expect(result.code, result.stderr).toBe(0); + expect(await probe("tz_probe_wrapper")).toEqual({ + zone: "UTC", + cast_is_utc: true, + }); + }, 60_000); + + it("counter-check: the bare CLI inherits the database's own zone", async () => { + const dir = migrationsDir(); + const result = await run( + [PRISMA_CLI, "migrate", "deploy", "--schema", join(dir, "schema.prisma")], + probeUrl("tz_probe_bare"), + ); + expect(result.code, result.stderr).toBe(0); + expect(await probe("tz_probe_bare")).toEqual({ + zone: "Europe/Berlin", + cast_is_utc: false, + }); + }, 60_000); +}); From 56daa573505338b2ade5b6dc7d8d2938b32fc7e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 12:55:06 +0200 Subject: [PATCH 02/63] refactor(format): one byte formatter for every surface The document vault and the admin area each carried a formatBytes; the same backup read 1.25 MB in one and 1.2 MB in the other, and only the vault knew gigabytes. The vault rule (one decimal below 100, up to GB, digits in the reader's locale) now lives in src/lib/format/bytes.ts and takes the locale, so the server-rendered share page uses it too. A test holds the definition to that one module. --- src/components/admin/backups-section.tsx | 6 +-- .../admin/measurement-maintenance-card.tsx | 18 ++----- .../admin/offhost-backups-section.tsx | 4 +- src/components/clinician/documents-list.tsx | 2 +- .../documents/__tests__/vault-utils.test.ts | 10 ---- src/components/documents/document-card.tsx | 7 +-- .../documents/document-detail-sheet.tsx | 2 +- .../sources/document-source-picker.tsx | 2 +- src/components/documents/upload-zone.tsx | 2 +- src/components/documents/vault-utils.ts | 15 ------ src/lib/format/__tests__/bytes.test.ts | 53 +++++++++++++++++++ src/lib/format/bytes.ts | 33 ++++++------ 12 files changed, 84 insertions(+), 70 deletions(-) create mode 100644 src/lib/format/__tests__/bytes.test.ts diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 16b6e5ed4..2f3ab6759 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -843,7 +843,7 @@ async function uploadOnce(file: File) { } export function BackupsSection() { - const { t } = useTranslations(); + const { t, locale } = useTranslations(); const fmt = useFormatters(); const queryClient = useQueryClient(); @@ -1327,7 +1327,7 @@ export function BackupsSection() { - {formatBytes(row.sizeBytes, fmt)} + {formatBytes(row.sizeBytes, locale)} {fmt.dateTime(row.createdAt)} @@ -1394,7 +1394,7 @@ export function BackupsSection() {

{t("admin.section.backups.colSize")}:{" "} - {formatBytes(row.sizeBytes, fmt)} ·{" "} + {formatBytes(row.sizeBytes, locale)} ·{" "} {fmt.dateTime(row.createdAt)}

diff --git a/src/components/admin/measurement-maintenance-card.tsx b/src/components/admin/measurement-maintenance-card.tsx index 6109f5a89..3569c0e64 100644 --- a/src/components/admin/measurement-maintenance-card.tsx +++ b/src/components/admin/measurement-maintenance-card.tsx @@ -45,20 +45,8 @@ const ENDPOINT = "/api/admin/maintenance/measurements"; /** How often the card asks while a run is waiting or working. */ const POLL_MS = 10_000; -const GIB = 1024 * 1024 * 1024; - -function sizeLabel( - bytes: number, - fmt: ReturnType, -): string { - // The shared formatter stops at MB; this table is routinely gigabytes. - return bytes >= GIB - ? `${fmt.number(bytes / GIB, 2)} GB` - : formatBytes(bytes, fmt); -} - export function MeasurementMaintenanceCard() { - const { t } = useTranslations(); + const { t, locale } = useTranslations(); const fmt = useFormatters(); const queryClient = useQueryClient(); @@ -132,11 +120,11 @@ export function MeasurementMaintenanceCard() {
) : null} diff --git a/src/components/admin/offhost-backups-section.tsx b/src/components/admin/offhost-backups-section.tsx index 99ffb6af2..3e65f6402 100644 --- a/src/components/admin/offhost-backups-section.tsx +++ b/src/components/admin/offhost-backups-section.tsx @@ -84,7 +84,7 @@ function freshnessLabel( } function AccountRow({ row }: { row: OffhostAccountRow }) { - const { t } = useTranslations(); + const { t, locale } = useTranslations(); const fmt = useFormatters(); return ( @@ -128,7 +128,7 @@ function AccountRow({ row }: { row: OffhostAccountRow }) { }) : `${fmt.dateTime(row.lastSuccessAt)} · ${formatBytes( row.sizeBytes, - fmt, + locale, )} · ${t("admin.section.backups.offhost.age", { hours: row.ageHours ?? 0, })}`} diff --git a/src/components/clinician/documents-list.tsx b/src/components/clinician/documents-list.tsx index b5850b2dd..d9bbc1317 100644 --- a/src/components/clinician/documents-list.tsx +++ b/src/components/clinician/documents-list.tsx @@ -11,7 +11,7 @@ import { Download } from "lucide-react"; import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta"; -import { formatBytes } from "@/components/documents/vault-utils"; +import { formatBytes } from "@/lib/format/bytes"; import type { ShareViewDocument } from "@/lib/clinician-share/share-view-data"; import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents"; diff --git a/src/components/documents/__tests__/vault-utils.test.ts b/src/components/documents/__tests__/vault-utils.test.ts index f368012c5..d881f7a5e 100644 --- a/src/components/documents/__tests__/vault-utils.test.ts +++ b/src/components/documents/__tests__/vault-utils.test.ts @@ -11,7 +11,6 @@ import { documentCardKeyAction, documentDateKey, expandRangeSelection, - formatBytes, formatMonthLabel, hasProcessingDocument, isDocumentProcessing, @@ -216,15 +215,6 @@ describe("timeline items", () => { }); }); -describe("byte formatting", () => { - it("scales units and keeps one decimal below 10", () => { - expect(formatBytes(0, "en")).toBe("0 B"); - expect(formatBytes(512, "en")).toBe("512 B"); - expect(formatBytes(26_214_400, "en")).toBe("25 MB"); - expect(formatBytes(1_288_490_189, "en")).toBe("1.2 GB"); - }); -}); - describe("upload response contract (§3.2)", () => { it("HTTP 200 + meta.duplicate returns the EXISTING row as a duplicate", () => { const body = JSON.stringify({ diff --git a/src/components/documents/document-card.tsx b/src/components/documents/document-card.tsx index d40019b92..807e551b0 100644 --- a/src/components/documents/document-card.tsx +++ b/src/components/documents/document-card.tsx @@ -31,11 +31,8 @@ import { cn } from "@/lib/utils"; import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; import { DOCUMENT_KIND_ICONS } from "./document-kind-meta"; import type { UploadQueueItem } from "./use-document-upload"; -import { - documentCardKeyAction, - formatBytes, - isDocumentProcessing, -} from "./vault-utils"; +import { formatBytes } from "@/lib/format/bytes"; +import { documentCardKeyAction, isDocumentProcessing } from "./vault-utils"; /** How long the "Ready" chip stays up after a card's index lands. */ const READY_FLASH_MS = 3000; diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index c5594528f..1426fcb91 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -102,7 +102,7 @@ import { useDocumentSummary, useSuggestDetails, } from "./use-document-assist"; -import { formatBytes } from "./vault-utils"; +import { formatBytes } from "@/lib/format/bytes"; import { QueryErrorRow } from "@/components/ui/query-error-row"; type PatchInput = { diff --git a/src/components/documents/sources/document-source-picker.tsx b/src/components/documents/sources/document-source-picker.tsx index 1ae4fc11d..ba50192d0 100644 --- a/src/components/documents/sources/document-source-picker.tsx +++ b/src/components/documents/sources/document-source-picker.tsx @@ -75,7 +75,7 @@ import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documen import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; import type { WrittenOutcome } from "@/lib/outcome/written-outcome"; -import { formatBytes } from "../vault-utils"; +import { formatBytes } from "@/lib/format/bytes"; import { errorCodeOf, isStoppingError, diff --git a/src/components/documents/upload-zone.tsx b/src/components/documents/upload-zone.tsx index c94863234..40386a4ba 100644 --- a/src/components/documents/upload-zone.tsx +++ b/src/components/documents/upload-zone.tsx @@ -19,7 +19,7 @@ import { type RefObject } from "react"; import { Progress } from "@/components/ui/progress"; import { useTranslations } from "@/lib/i18n/context"; import type { DocumentUsageDto } from "@/lib/validations/inbound-documents"; -import { formatBytes } from "./vault-utils"; +import { formatBytes } from "@/lib/format/bytes"; /** Usage fraction above which the quota bar appears. */ const QUOTA_BAR_THRESHOLD = 0.8; diff --git a/src/components/documents/vault-utils.ts b/src/components/documents/vault-utils.ts index 3e4c12175..e7c0f3653 100644 --- a/src/components/documents/vault-utils.ts +++ b/src/components/documents/vault-utils.ts @@ -302,21 +302,6 @@ export function formatMonthLabel(key: string, locale: string): string { }).format(new Date(`${key}-15T12:00:00.000Z`)); } -/** Locale byte formatting: 0 B / 412 KB / 1.2 MB / 2.4 GB. */ -export function formatBytes(bytes: number, locale: string): string { - const units = ["B", "KB", "MB", "GB"] as const; - let value = Math.max(0, bytes); - let unit = 0; - while (value >= 1024 && unit < units.length - 1) { - value /= 1024; - unit += 1; - } - const digits = unit === 0 || value >= 100 ? 0 : value >= 10 ? 1 : 1; - return `${new Intl.NumberFormat(locale, { - maximumFractionDigits: digits, - }).format(value)} ${units[unit]}`; -} - /** * Shift-click range selection over the timeline's visual order. Selects * every id between the last plainly-toggled anchor and the target diff --git a/src/lib/format/__tests__/bytes.test.ts b/src/lib/format/__tests__/bytes.test.ts new file mode 100644 index 000000000..0413d42c8 --- /dev/null +++ b/src/lib/format/__tests__/bytes.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +import { formatBytes } from "../bytes"; + +describe("formatBytes", () => { + it("scales units and keeps one decimal below 100", () => { + expect(formatBytes(0, "en")).toBe("0 B"); + expect(formatBytes(512, "en")).toBe("512 B"); + expect(formatBytes(1_310_720, "en")).toBe("1.3 MB"); + expect(formatBytes(26_214_400, "en")).toBe("25 MB"); + expect(formatBytes(1_288_490_189, "en")).toBe("1.2 GB"); + expect(formatBytes(150 * 1024 * 1024, "en")).toBe("150 MB"); + }); + + it("prints the digits in the reader's locale", () => { + expect(formatBytes(1_288_490_189, "de")).toBe("1,2 GB"); + expect(formatBytes(1_288_490_189, "de-DE")).toBe("1,2 GB"); + expect(formatBytes(1_310_720, "fr")).toBe("1,3 MB"); + }); + + it("never prints a negative size", () => { + expect(formatBytes(-5, "en")).toBe("0 B"); + }); +}); + +/** + * One byte formatter. A second definition is how the admin area and the + * document vault came to print the same size two ways. + */ +describe("a single byte formatter", () => { + it("is defined only in src/lib/format/bytes.ts", () => { + const root = join(__dirname, "../../.."); + const hits: string[] = []; + const walk = (dir: string) => { + for (const name of readdirSync(dir)) { + if (name === "node_modules" || name === "generated") continue; + const path = join(dir, name); + if (statSync(path).isDirectory()) { + walk(path); + } else if (/\.tsx?$/.test(name) && !/\.test\.tsx?$/.test(name)) { + const source = readFileSync(path, "utf8"); + if (/function\s+formatBytes\b|const\s+formatBytes\s*=/.test(source)) { + hits.push(path.slice(root.length + 1)); + } + } + } + }; + walk(root); + expect(hits).toEqual(["lib/format/bytes.ts"]); + }); +}); diff --git a/src/lib/format/bytes.ts b/src/lib/format/bytes.ts index c59732ce2..7fd753fbe 100644 --- a/src/lib/format/bytes.ts +++ b/src/lib/format/bytes.ts @@ -1,22 +1,23 @@ -import type { useFormatters } from "@/lib/i18n/context"; +import { getNumberFormat } from "@/lib/intl/formatter-cache"; /** - * One spelling of "1.25 MB" for every surface that shows a byte count from - * the admin backups response. + * One spelling of a byte count for every surface: 0 B / 412 KB / 1.2 MB / + * 2.4 GB, digits in the reader's locale ("1,2 MB" for a German reader). * - * It lives here rather than beside the card that happened to need it first: - * the second reader was importing a formatter out of a thousand-line client - * component, which is a dependency on a page's worth of code for four lines of - * arithmetic. Both cards render on the same route today, so it costs nothing - * today — and it becomes a cost the moment either one moves. + * There used to be two: this module printed megabytes with two decimals and + * no gigabyte step, the document vault printed one decimal up to gigabytes, so + * the same backup read "1.25 MB" in the admin area and "1.2 MB" beside a + * document. It takes the locale rather than a formatter object so the share + * page, which renders on the server without the formatter hook, uses it too. */ -export function formatBytes( - bytes: number, - fmt: ReturnType, -): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) { - return `${fmt.integer(Math.round(bytes / 1024))} KB`; +export function formatBytes(bytes: number, locale: string): string { + const units = ["B", "KB", "MB", "GB"] as const; + let value = Math.max(0, bytes); + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; } - return `${fmt.number(bytes / 1024 / 1024, 2)} MB`; + const digits = unit === 0 || value >= 100 ? 0 : 1; + return `${getNumberFormat(locale, { maximumFractionDigits: digits }).format(value)} ${units[unit]}`; } From 537bad4212bcefa0c0294beac651079743e32b42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 12:55:07 +0200 Subject: [PATCH 03/63] fix(ui): print every displayed decimal in the reader's notation toFixed() prints a full stop in every language and a locale-less toLocaleString() follows the browser, so a German reader saw 37.5 next to 70,5 on the same screen. Fever in the illness log, the cycle phase crosstab, custom metric values, nutrient day totals, the mood chart and heatmap tooltips, the mood takeaways, the elevation chart axis, the BP 30-day average, the host metrics clock and the encryption row counts now go through useFormatters(). The new healthlog/no-unlocalised-decimal rule refuses toFixed(), toPrecision(), a locale-less toLocaleString() or Intl constructor, and a quotient rendered straight into text under src/components and src/app. Rounding through Number(...) stays legal; SVG path data and number-input values carry a reasoned next-line directive. --- .../__tests__/no-unlocalised-decimal.test.mjs | 177 +++++++++++ eslint-plugins/healthlog/index.js | 5 + .../healthlog/no-unlocalised-decimal.js | 291 ++++++++++++++++++ eslint.config.mjs | 7 + src/components/admin/encryption-section.tsx | 15 +- .../admin/general-settings-section.tsx | 3 +- src/components/admin/host-metrics-chart.tsx | 14 +- .../admin/user-management-section.tsx | 3 +- src/components/charts/mood-chart.tsx | 17 +- src/components/charts/mood-heatmap.tsx | 13 +- .../charts/workout-elevation-chart.tsx | 7 +- .../custom-metrics/custom-metric-chart.tsx | 16 +- .../custom-metric-history-list.tsx | 5 +- .../custom-metrics/custom-metric-list.tsx | 4 +- src/components/custom-metrics/format-value.ts | 9 +- src/components/cycle/cycle-phase-crosstab.tsx | 25 +- src/components/illness/log-day-sheet.tsx | 10 +- src/components/insights/ecg-waveform.tsx | 1 + .../insights/metric-target-summary.tsx | 5 +- .../insights/mood/mood-narrative-feed.tsx | 13 +- src/components/labs/lab-trend-sparkline.tsx | 1 + .../settings/nutrient-intake-card.tsx | 13 +- src/components/timeline/timeline-geometry.ts | 1 + src/components/ui/calendar.tsx | 1 + 24 files changed, 596 insertions(+), 60 deletions(-) create mode 100644 eslint-plugins/healthlog/__tests__/no-unlocalised-decimal.test.mjs create mode 100644 eslint-plugins/healthlog/no-unlocalised-decimal.js diff --git a/eslint-plugins/healthlog/__tests__/no-unlocalised-decimal.test.mjs b/eslint-plugins/healthlog/__tests__/no-unlocalised-decimal.test.mjs new file mode 100644 index 000000000..1ef5dd6e4 --- /dev/null +++ b/eslint-plugins/healthlog/__tests__/no-unlocalised-decimal.test.mjs @@ -0,0 +1,177 @@ +/** + * Unit tests for the `healthlog/no-unlocalised-decimal` rule — a displayed + * number goes through the app's formatters. Covers the four flagged idioms, + * the rounding round-trip that stays legal, the browser time zone lookup, the + * directive contract, and the scope gating. + */ + +import { describe, expect, it } from "vitest"; +import { Linter, RuleTester } from "eslint"; +import rule from "../no-unlocalised-decimal.js"; + +RuleTester.describe = describe; +RuleTester.it = it; +RuleTester.itOnly = it.only; + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2023, + sourceType: "module", + parserOptions: { ecmaFeatures: { jsx: true } }, + }, +}); + +const APP_FILE = "/repo/src/components/insights/example.tsx"; +const PAGE_FILE = "/repo/src/app/(app)/pulse/page.tsx"; + +ruleTester.run("no-unlocalised-decimal", rule, { + valid: [ + // The formatter is the sanctioned path. + { code: "const s = fmt.number(value, 1);", filename: APP_FILE }, + // Rounding, not display: the result is a number again. + { code: "const n = Number(value.toFixed(2));", filename: APP_FILE }, + { code: "const n = parseFloat(value.toFixed(2));", filename: APP_FILE }, + { + code: "const n = Number.parseFloat(value.toFixed(2));", + filename: APP_FILE, + }, + { code: "const n = +value.toFixed(2);", filename: APP_FILE }, + // A locale is passed. + { code: 'const s = n.toLocaleString("de-DE");', filename: APP_FILE }, + { + code: "const s = d.toLocaleDateString(locale, opts);", + filename: APP_FILE, + }, + { code: "const f = new Intl.NumberFormat(locale);", filename: APP_FILE }, + // The browser time zone lookup formats nothing. + { + code: "const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;", + filename: APP_FILE, + }, + // Integer arithmetic in text is fine. + { code: "const s = `${Math.round(a / b)} h`;", filename: APP_FILE }, + { code: "const s = String(Math.round(a / b));", filename: APP_FILE }, + // A tagged template is not text. + { code: "const q = sql`${a / b}`;", filename: APP_FILE }, + // Wire formats under src/app/api are not display. + { + code: "const s = value.toFixed(2);", + filename: "/repo/src/app/api/export/route.ts", + }, + // Outside the UI roots and in tests the rule is silent. + { code: "const s = value.toFixed(2);", filename: "/repo/src/lib/x.ts" }, + { + code: "const s = value.toFixed(2);", + filename: "/repo/src/components/__tests__/x.test.tsx", + }, + ], + invalid: [ + { + code: "const s = value.toFixed(1);", + filename: APP_FILE, + errors: [{ messageId: "fixed", data: { method: "toFixed" } }], + }, + { + code: "const s = `${value.toFixed(1)} kg`;", + filename: PAGE_FILE, + errors: [{ messageId: "fixed" }], + }, + { + code: "const s = value.toPrecision(3);", + filename: APP_FILE, + errors: [{ messageId: "fixed", data: { method: "toPrecision" } }], + }, + // Number() around something else does not launder the call. + { + code: "const s = String(Number(a) + value.toFixed(1));", + filename: APP_FILE, + errors: [{ messageId: "fixed" }], + }, + { + code: "{total.toLocaleString()}", + filename: APP_FILE, + errors: [{ messageId: "localeless" }], + }, + { + code: "const s = d.toLocaleDateString(undefined, { day: 'numeric' });", + filename: APP_FILE, + errors: [{ messageId: "localeless" }], + }, + { + code: "const s = d.toLocaleTimeString();", + filename: APP_FILE, + errors: [{ messageId: "localeless" }], + }, + { + code: "const f = new Intl.NumberFormat();", + filename: APP_FILE, + errors: [{ messageId: "intlLocaleless" }], + }, + { + code: "const f = new Intl.DateTimeFormat(undefined, { hour: '2-digit' });", + filename: APP_FILE, + errors: [{ messageId: "intlLocaleless" }], + }, + { + code: "const s = String(Math.round(v * 10) / 10);", + filename: APP_FILE, + errors: [{ messageId: "division" }], + }, + { + code: "const s = `${total / count} per day`;", + filename: APP_FILE, + errors: [{ messageId: "division" }], + }, + ], +}); + +/** + * Directives resolve against the rule's registered name, which RuleTester + * does not use, so they run through a Linter with the plugin registered as + * `healthlog` — the way the flat config loads it. + */ +describe("no-unlocalised-decimal disable directives", () => { + const linter = new Linter({ configType: "flat" }); + const config = [ + { + files: ["**/*.tsx"], + plugins: { healthlog: { rules: { "no-unlocalised-decimal": rule } } }, + rules: { "healthlog/no-unlocalised-decimal": "error" }, + languageOptions: { + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + }, + ]; + const lint = (code) => + linter + .verify(code, config, { filename: "src/components/charts/x.tsx" }) + .map((m) => m.messageId ?? m.message); + + it("flags the site without a directive", () => { + expect(lint("const d = `${x.toFixed(1)}`;")).toEqual(["fixed"]); + }); + + it("accepts a next-line directive that gives a reason", () => { + expect( + lint( + "// eslint-disable-next-line healthlog/no-unlocalised-decimal -- SVG coordinates, not text\nconst d = `${x.toFixed(1)}`;", + ), + ).toEqual([]); + }); + + it("refuses a directive without a reason", () => { + expect( + lint( + "// eslint-disable-next-line healthlog/no-unlocalised-decimal\nconst d = x.toFixed(1);", + ), + ).toEqual(["missingReason"]); + }); + + it("refuses a whole-file disable, which would hide the next site too", () => { + expect( + lint( + "const a = 1;\n/* eslint-disable healthlog/no-unlocalised-decimal -- SVG coordinates, not text */\nconst d = x.toFixed(1);", + ), + ).toEqual(["nextLineOnly"]); + }); +}); diff --git a/eslint-plugins/healthlog/index.js b/eslint-plugins/healthlog/index.js index 7205742f1..cf7c16ea5 100644 --- a/eslint-plugins/healthlog/index.js +++ b/eslint-plugins/healthlog/index.js @@ -16,6 +16,9 @@ * slicing an ISO string or parsing UTC midnight. * - `no-planning-tags` — no planning-note identifier at the head of a * comment line. + * - `no-unlocalised-decimal` — a displayed number goes through the app's + * formatters, not toFixed() or a locale-less + * toLocaleString(). */ "use strict"; @@ -29,6 +32,7 @@ const jobHandlerOutcome = require("./job-handler-outcome.js"); const noDefaultZoneLiteral = require("./no-default-zone-literal.js"); const noUtcDayKey = require("./no-utc-day-key.js"); const noPlanningTags = require("./no-planning-tags.js"); +const noUnlocalisedDecimal = require("./no-unlocalised-decimal.js"); module.exports = { rules: { @@ -46,5 +50,6 @@ module.exports = { "no-default-zone-literal": noDefaultZoneLiteral, "no-utc-day-key": noUtcDayKey, "no-planning-tags": noPlanningTags, + "no-unlocalised-decimal": noUnlocalisedDecimal, }, }; diff --git a/eslint-plugins/healthlog/no-unlocalised-decimal.js b/eslint-plugins/healthlog/no-unlocalised-decimal.js new file mode 100644 index 000000000..51bb3f2cf --- /dev/null +++ b/eslint-plugins/healthlog/no-unlocalised-decimal.js @@ -0,0 +1,291 @@ +/** + * @fileoverview ESLint rule — a number shown to a person goes through the + * app's locale, never through the JavaScript default. + * + * `value.toFixed(1)` always prints a full stop. A German, French, Italian, + * Spanish, Dutch or Polish reader expects a comma (`37,5 °C`), and the same + * screen already shows a comma wherever the value went through + * `useFormatters()`. The mixed reading is the defect: one card says `70,5 kg`, + * the card beside it `70.5 kg`. `toLocaleString()` without a locale is the + * mirror image of the same mistake — it follows the browser, not the language + * the person picked in HealthLog. + * + * The legitimate forms: + * + * - in a component: `const fmt = useFormatters(); fmt.number(value, 1)` + * (`fmt.integer`, `fmt.percent` for the other shapes) + * - outside React: `makeFormatters(locale, …)` from `@/lib/format-locale` + * - rounding, not display: `Number(value.toFixed(2))` stays legal — the + * result is a number again and is formatted wherever it is shown + * + * FLAGGED, in `src/components/` and `src/app/` outside `src/app/api/` (wire + * formats are not display) and outside test files: + * + * - `.toFixed(…)` and `.toPrecision(…)`, unless the call is the + * argument of `Number(…)` / `parseFloat(…)` / `Number.parseFloat(…)` or + * the operand of a unary `+` + * - `.toLocaleString()`, `toLocaleDateString()`, `toLocaleTimeString()` + * called with no locale or with `undefined` as the locale + * - `Intl.NumberFormat()` / `Intl.DateTimeFormat()` constructed with no + * locale or with `undefined` (reading `.resolvedOptions()` off it, the + * browser time zone lookup, formats nothing and passes) + * - a division rendered straight into text: `String(a / b)` and + * `` `${a / b}` `` — the quotient is fractional and prints with a stop + * + * A site where the stop is the point (SVG path data, a value written into a + * machine-read attribute) stays, with the reason on the line above: + * + * // eslint-disable-next-line healthlog/no-unlocalised-decimal -- + * + * The rule refuses a directive for itself without a reason, or in any form + * other than `eslint-disable-next-line`. + * + * WHAT THIS RULE DOES NOT CATCH + * + * Without type information it cannot tell a number from a string, so + * `String(weight)` or `` `${weight} kg` `` with a fractional `weight` passes, + * as does a quotient assigned to a variable first and rendered later. It + * removes the idioms that print a stop by construction, not every path to + * one. + */ + +"use strict"; + +const RULE_NAME = "healthlog/no-unlocalised-decimal"; +const ENFORCED_ROOTS = ["src/components/", "src/app/"]; +const EXEMPT_ROOTS = ["src/app/api/"]; +const MIN_REASON_CHARS = 8; + +const FIXED_METHODS = new Set(["toFixed", "toPrecision"]); +const LOCALE_METHODS = new Set([ + "toLocaleString", + "toLocaleDateString", + "toLocaleTimeString", +]); +const INTL_CTORS = new Set(["NumberFormat", "DateTimeFormat"]); + +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_ROOTS.some((root) => posix.includes(root))) return false; + if (isTestFile(posix)) return false; + return true; +} + +function propertyName(member) { + if (!member || member.type !== "MemberExpression") return null; + if (!member.computed && member.property.type === "Identifier") { + return member.property.name; + } + if (member.computed && member.property.type === "Literal") { + return String(member.property.value); + } + return null; +} + +function isUndefined(node) { + return ( + !node || + (node.type === "Identifier" && node.name === "undefined") || + (node.type === "UnaryExpression" && node.operator === "void") + ); +} + +/** Strip TS-only wrappers and parentheses-equivalents the parser keeps. */ +function unwrap(node) { + let current = node; + while ( + current && + (current.type === "TSAsExpression" || + current.type === "TSNonNullExpression" || + current.type === "TSSatisfiesExpression" || + current.type === "ChainExpression") + ) { + current = current.expression; + } + return current; +} + +/** The call's value is turned back into a number: rounding, not display. */ +function isNumericRoundTrip(callNode) { + let child = callNode; + let parent = callNode.parent; + while ( + parent && + (parent.type === "TSAsExpression" || + parent.type === "TSNonNullExpression" || + parent.type === "ChainExpression") + ) { + child = parent; + parent = parent.parent; + } + if (!parent) return false; + if (parent.type === "UnaryExpression" && parent.operator === "+") { + return true; + } + if (parent.type !== "CallExpression" || parent.arguments[0] !== child) { + return false; + } + const callee = parent.callee; + if (callee.type === "Identifier") { + return callee.name === "Number" || callee.name === "parseFloat"; + } + return ( + callee.type === "MemberExpression" && + callee.object.type === "Identifier" && + callee.object.name === "Number" && + propertyName(callee) === "parseFloat" + ); +} + +function isDivision(node) { + const inner = unwrap(node); + return !!inner && inner.type === "BinaryExpression" && inner.operator === "/"; +} + +function isIntlCtor(callee) { + return ( + callee && + callee.type === "MemberExpression" && + callee.object.type === "Identifier" && + callee.object.name === "Intl" && + INTL_CTORS.has(propertyName(callee)) + ); +} + +/** @type {import("eslint").Rule.RuleModule} */ +const noUnlocalisedDecimalRule = { + meta: { + type: "problem", + docs: { + description: + "Format a displayed number through useFormatters() / makeFormatters(), not toFixed() or a locale-less toLocaleString().", + }, + schema: [], + messages: { + fixed: + "`{{method}}()` prints a full stop in every language. Format the value with `useFormatters().number(value, digits)` (or `makeFormatters` outside React). For rounding, wrap it in `Number(…)`. If the stop is meant, say why: `// eslint-disable-next-line healthlog/no-unlocalised-decimal -- `.", + localeless: + "`{{method}}()` without a locale follows the browser, not the language picked in HealthLog. Use `useFormatters()` or pass the active locale.", + intlLocaleless: + "`Intl.{{ctor}}` without a locale follows the browser, not the language picked in HealthLog. Use `useFormatters()` or pass the active locale.", + division: + "A quotient rendered straight into text prints with a full stop. Format it with `useFormatters().number(value, digits)`.", + missingReason: + "A disable directive for healthlog/no-unlocalised-decimal must say why the full stop is right here: `-- ` after the rule name.", + nextLineOnly: + "Disable healthlog/no-unlocalised-decimal only with `eslint-disable-next-line`, one site at a time, so the next occurrence is still caught.", + }, + }, + create(context) { + const filename = context.filename ?? context.getFilename?.(); + if (!filename || !isEnforced(filename)) { + return {}; + } + const sourceCode = context.sourceCode ?? context.getSourceCode(); + + function checkDirectives() { + for (const comment of sourceCode.getAllComments()) { + const text = comment.value.trim(); + const match = /^eslint-disable(-next-line|-line)?\b([\s\S]*)$/.exec( + text, + ); + if (!match || !match[2].includes(RULE_NAME)) continue; + if (match[1] !== "-next-line") { + context.report({ + loc: { line: 1, column: 0 }, + messageId: "nextLineOnly", + }); + continue; + } + const reason = match[2].split(/\s--\s/)[1]; + if (!reason || reason.trim().length < MIN_REASON_CHARS) { + context.report({ loc: comment.loc, messageId: "missingReason" }); + } + } + } + + function checkIntl(node) { + // `Intl.DateTimeFormat().resolvedOptions().timeZone` reads the + // browser's zone; nothing is formatted, so the missing locale is right. + const readsOptionsOnly = + node.parent && + node.parent.type === "MemberExpression" && + node.parent.object === node && + propertyName(node.parent) === "resolvedOptions"; + if ( + !readsOptionsOnly && + isIntlCtor(node.callee) && + isUndefined(node.arguments[0]) + ) { + context.report({ + node, + messageId: "intlLocaleless", + data: { ctor: propertyName(node.callee) }, + }); + } + } + + return { + "Program:exit": checkDirectives, + CallExpression(node) { + const name = propertyName(node.callee); + if (name && FIXED_METHODS.has(name)) { + if (!isNumericRoundTrip(node)) { + context.report({ + node, + messageId: "fixed", + data: { method: name }, + }); + } + return; + } + if (name && LOCALE_METHODS.has(name)) { + if (isUndefined(node.arguments[0])) { + context.report({ + node, + messageId: "localeless", + data: { method: name }, + }); + } + return; + } + if ( + node.callee.type === "Identifier" && + node.callee.name === "String" && + isDivision(node.arguments[0]) + ) { + context.report({ node, messageId: "division" }); + return; + } + checkIntl(node); + }, + NewExpression: checkIntl, + TemplateLiteral(node) { + if (node.parent && node.parent.type === "TaggedTemplateExpression") { + return; + } + for (const expression of node.expressions) { + if (isDivision(expression)) { + context.report({ node: expression, messageId: "division" }); + } + } + }, + }; + }, +}; + +module.exports = noUnlocalisedDecimalRule; diff --git a/eslint.config.mjs b/eslint.config.mjs index 3f53b7b7d..b4e514c25 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -121,6 +121,13 @@ const eslintConfig = defineConfig([ // scripts/strip-planning-tags.mjs; see eslint-plugins/healthlog/ // planning-tags.js for the shape and its limits. "healthlog/no-planning-tags": "error", + // A number shown to a person goes through useFormatters() (or + // makeFormatters outside React): toFixed() prints a full stop in every + // language and a locale-less toLocaleString() follows the browser + // instead of the language picked in the app. SVG path data and other + // machine-read strings carry `eslint-disable-next-line ... -- `; + // the rule refuses a directive without one. See the rule header. + "healthlog/no-unlocalised-decimal": "error", }, }, // v1.28.17 — every recharts-rendering component funnels through the diff --git a/src/components/admin/encryption-section.tsx b/src/components/admin/encryption-section.tsx index 78230e0cf..58d8fe7c9 100644 --- a/src/components/admin/encryption-section.tsx +++ b/src/components/admin/encryption-section.tsx @@ -229,11 +229,11 @@ export function EncryptionSection() { />
{/* "Safe to drop the legacy key" needs a legacy key to drop. With a @@ -414,6 +414,7 @@ export function ColumnCoverage({ initiallyOpen?: boolean; }) { const { t } = useTranslations(); + const fmt = useFormatters(); const [open, setOpen] = useState(initiallyOpen); const [showAll, setShowAll] = useState(false); const withRows = columns.filter((c) => c.total > 0); @@ -484,16 +485,12 @@ export function ColumnCoverage({ {c.model}.{c.field} - - {c.total.toLocaleString()} - - - {active.toLocaleString()} - + {fmt.integer(c.total)} + {fmt.integer(active)} {stale > 0 ? ( - {stale.toLocaleString()} + {fmt.integer(stale)} ) : ( 0 diff --git a/src/components/admin/general-settings-section.tsx b/src/components/admin/general-settings-section.tsx index 4182d6e3b..d98b9f0e1 100644 --- a/src/components/admin/general-settings-section.tsx +++ b/src/components/admin/general-settings-section.tsx @@ -54,7 +54,8 @@ export function GeneralSettingsSection() { : ""; const storedQuotaGb = settings?.documentQuotaBytes != null - ? String(Math.round((settings.documentQuotaBytes / GIB) * 10) / 10) + ? // eslint-disable-next-line healthlog/no-unlocalised-decimal -- value of a type="number" input, which only takes a full stop + String(Math.round((settings.documentQuotaBytes / GIB) * 10) / 10) : ""; const commitMaxFile = () => { diff --git a/src/components/admin/host-metrics-chart.tsx b/src/components/admin/host-metrics-chart.tsx index 20987aa64..20377f6be 100644 --- a/src/components/admin/host-metrics-chart.tsx +++ b/src/components/admin/host-metrics-chart.tsx @@ -47,6 +47,7 @@ import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { apiGet } from "@/lib/api/api-fetch"; import { readStoredTimezone } from "@/lib/timezone-mirror"; +import { resolveIntlLocale } from "@/lib/format-locale"; interface HostMetricsApiSample { capturedAt: string; @@ -89,7 +90,10 @@ const COLOR_DISK = "var(--dracula-purple)"; * + the absolute peak BPS used to scale `diskBusyPercent`. Kept as a * separate export so the test suite doesn't need to render Recharts. */ -export function buildChartRows(samples: HostMetricsApiSample[]): { +export function buildChartRows( + samples: HostMetricsApiSample[], + intlLocale?: string, +): { rows: ChartRow[]; peakDiskBps: number; hasDiskData: boolean; @@ -104,7 +108,7 @@ export function buildChartRows(samples: HostMetricsApiSample[]): { // samples), and an operator reading their own instance should see the same // clock here as on every other timestamp in the admin area — a spike at // "14:20" must not be 14:20 here and 15:20 two screens away. - const formatter = new Intl.DateTimeFormat(undefined, { + const formatter = new Intl.DateTimeFormat(intlLocale ?? "en-US", { hour: "2-digit", minute: "2-digit", hour12: false, @@ -141,7 +145,7 @@ export function buildChartRows(samples: HostMetricsApiSample[]): { } export function HostMetricsChart() { - const { t } = useTranslations(); + const { t, locale } = useTranslations(); const fmt = useFormatters(); const { data, isLoading, isError, error, refetch } = useQuery({ @@ -156,8 +160,8 @@ export function HostMetricsChart() { }); const { rows, peakDiskBps, hasDiskData } = useMemo( - () => buildChartRows(data?.samples ?? []), - [data?.samples], + () => buildChartRows(data?.samples ?? [], resolveIntlLocale(locale)), + [data?.samples, locale], ); // Total physical memory (latest sample) — lets the tooltip pair the diff --git a/src/components/admin/user-management-section.tsx b/src/components/admin/user-management-section.tsx index ca9d2863f..2cc1b5aff 100644 --- a/src/components/admin/user-management-section.tsx +++ b/src/components/admin/user-management-section.tsx @@ -162,7 +162,8 @@ export function UserManagementSection() { setEditQuotaGb( u.documentQuotaBytes === null ? "" - : String(Math.round((u.documentQuotaBytes / 1_073_741_824) * 10) / 10), + : // eslint-disable-next-line healthlog/no-unlocalised-decimal -- value of a type="number" input, which only takes a full stop + String(Math.round((u.documentQuotaBytes / 1_073_741_824) * 10) / 10), ); } diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx index 226d273e6..bbfb9196b 100644 --- a/src/components/charts/mood-chart.tsx +++ b/src/components/charts/mood-chart.tsx @@ -20,7 +20,11 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Card, CardHeader, CardContent } from "@/components/ui/card"; import { TagChip } from "@/components/ui/tag-chip"; import { TileHeader } from "@/components/insights/tile-header"; -import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context"; +import { + useDateFormatPreference, + useFormatters, + useTranslations, +} from "@/lib/i18n/context"; import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label"; import { readStoredTimezone } from "@/lib/timezone-mirror"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; @@ -299,6 +303,7 @@ export function MoodChart({ }: MoodChartProps) { const { isAuthenticated } = useAuth(); const { t, locale } = useTranslations(); + const fmt = useFormatters(); // Issue #490 — x-axis tick + tooltip date labels. Every timestamp this // chart formats encodes a CALENDAR DAY (noon-UTC of a server day key, // or a `bucketTimeSeries` Berlin-day UTC-midnight bucket start), so @@ -618,9 +623,9 @@ export function MoodChart({ }; const formatTooltipValue = (value: number): string => { - const rounded = Math.round(value * 10) / 10; + const rounded = fmt.number(Math.round(value * 10) / 10); const label = moodLabels[Math.round(value)]; - return label ? `${rounded} (${label})` : String(rounded); + return label ? `${rounded} (${label})` : rounded; }; // v1.4.16 — personal mood baseline (median over visible window). @@ -681,7 +686,7 @@ export function MoodChart({ if (scores.length === 0) { return t("charts.a11y.noData", { metric }); } - const fmtScore = (v: number) => String(Math.round(v * 10) / 10); + const fmtScore = (v: number) => fmt.number(Math.round(v * 10) / 10); const minVal = Math.min(...scores); const maxVal = Math.max(...scores); const latestVal = scores[scores.length - 1]; @@ -938,7 +943,7 @@ export function MoodChart({ delta = t("charts.deltaUnchanged"); } else { const sign = diff > 0 ? "+" : "−"; - const formatted = `${sign}${Math.abs(diff).toFixed(1)}`; + const formatted = `${sign}${fmt.number(Math.abs(diff), 1)}`; delta = t( effectiveCompareBaseline === "lastMonth" ? "comparison.deltaVs.lastMonth" @@ -951,7 +956,7 @@ export function MoodChart({ delta = t("charts.deltaUnchanged"); } else { const sign = diff > 0 ? "+" : "−"; - const formatted = `${sign}${Math.abs(diff).toFixed(1)}`; + const formatted = `${sign}${fmt.number(Math.abs(diff), 1)}`; delta = t("charts.deltaVsBaseline").replace( "{delta}", formatted, diff --git a/src/components/charts/mood-heatmap.tsx b/src/components/charts/mood-heatmap.tsx index 06475692a..cb3d86328 100644 --- a/src/components/charts/mood-heatmap.tsx +++ b/src/components/charts/mood-heatmap.tsx @@ -6,7 +6,11 @@ import { useCalendarDate } from "@/hooks/use-calendar-date"; import { dateOnlyKey } from "@/lib/tz/date-only"; import { ChartDataTable } from "./chart-data-table"; import { useState, useMemo, useEffect, useRef } from "react"; -import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context"; +import { + useDisplayTimezone, + useFormatters, + useTranslations, +} from "@/lib/i18n/context"; import { heatmapDays } from "@/lib/charts/heatmap-days"; import { moodLabelKeyForScore } from "@/lib/mood/labels"; @@ -144,6 +148,7 @@ export function MoodHeatmap({ stretch = false, }: MoodHeatmapProps) { const { t } = useTranslations(); + const fmt = useFormatters(); const timeZone = useDisplayTimezone(); // Day keys are calendar dates ("YYYY-MM-DD"), read as such in every zone. // A noon-UTC instant formatted in the reader's zone was a day late from @@ -274,7 +279,7 @@ export function MoodHeatmap({ } const labelKey = moodLabelKeyForScore(Math.round(cell.cell.score)); const moodLabel = labelKey ? t(labelKey) : ""; - return `${formatDay(cell.dateKey)}: ${cell.cell.score.toFixed(1)}${moodLabel ? ` · ${moodLabel}` : ""}`; + return `${formatDay(cell.dateKey)}: ${fmt.number(cell.cell.score, 1)}${moodLabel ? ` · ${moodLabel}` : ""}`; }; // Only the logged days go into the data table; the empty-tinted cells // are already covered by the aggregate summary on the SVG, so a rotor @@ -420,8 +425,8 @@ export function MoodHeatmap({ formatValue={(value) => { const labelKey = moodLabelKeyForScore(Math.round(value)); return labelKey - ? `${value.toFixed(1)} · ${t(labelKey)}` - : value.toFixed(1); + ? `${fmt.number(value, 1)} · ${t(labelKey)}` + : fmt.number(value, 1); }} formatDate={(date) => formatDay(dateOnlyKey(date))} bucket="day" diff --git a/src/components/charts/workout-elevation-chart.tsx b/src/components/charts/workout-elevation-chart.tsx index dbf0382fc..251cd6df5 100644 --- a/src/components/charts/workout-elevation-chart.tsx +++ b/src/components/charts/workout-elevation-chart.tsx @@ -10,7 +10,7 @@ import { ResponsiveContainer, } from "recharts"; -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { useUnitDisplay } from "@/hooks/use-unit-display"; import { applyDisplayTransform, @@ -35,6 +35,7 @@ export function WorkoutElevationChart({ points: WorkoutElevationPoint[]; }) { const { t } = useTranslations(); + const fmt = useFormatters(); const { preference } = useUnitDisplay(); // Distance along the route in km or mi, altitude in m or ft — the same // registry the stat tiles read, so the axis and the tiles agree. @@ -74,7 +75,7 @@ export function WorkoutElevationChart({ type="number" domain={[0, "dataMax"]} tick={{ fontSize: 10, fill: "var(--muted-foreground)" }} - tickFormatter={(v) => Number(v).toFixed(1)} + tickFormatter={(v) => fmt.number(Number(v), 1)} tickLine={false} axisLine={false} /> @@ -94,7 +95,7 @@ export function WorkoutElevationChart({ fontSize: "0.875rem", }} labelFormatter={(d) => - `${Number(d).toFixed(2)} ${distance.displayUnit}` + `${fmt.number(Number(d), 2)} ${distance.displayUnit}` } formatter={(value) => [ `${Math.round(Number(value))} ${elevation.displayUnit}`, diff --git a/src/components/custom-metrics/custom-metric-chart.tsx b/src/components/custom-metrics/custom-metric-chart.tsx index e35a77dc8..0281fbd3c 100644 --- a/src/components/custom-metrics/custom-metric-chart.tsx +++ b/src/components/custom-metrics/custom-metric-chart.tsx @@ -121,7 +121,11 @@ export function CustomMetricChart({ focusFor: (index) => { const point = points[index]; return point - ? { label: name, value: formatMetricValue(point.value, decimals), unit } + ? { + label: name, + value: formatMetricValue(point.value, decimals, fmt.number), + unit, + } : null; }, }); @@ -187,7 +191,9 @@ export function CustomMetricChart({ tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} stroke="var(--border)" width={44} - tickFormatter={(v: number) => formatMetricValue(v, decimals)} + tickFormatter={(v: number) => + formatMetricValue(v, decimals, fmt.number) + } /> {targetLow != null && targetHigh != null ? ( formatMetricValue(value, decimals)} + formatValue={(value) => + formatMetricValue(value, decimals, fmt.number) + } formatDate={(date) => fmt.dateShortSmartCalendar(date)} bucket="day" metricLabel={name} diff --git a/src/components/custom-metrics/custom-metric-history-list.tsx b/src/components/custom-metrics/custom-metric-history-list.tsx index 0c327ef13..5e2b60dfa 100644 --- a/src/components/custom-metrics/custom-metric-history-list.tsx +++ b/src/components/custom-metrics/custom-metric-history-list.tsx @@ -24,7 +24,7 @@ import { Textarea } from "@/components/ui/textarea"; import { useTableSort } from "@/hooks/use-table-sort"; import { ApiError, apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch"; import { formatDateShort } from "@/lib/format"; -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { formatMetricValue } from "./format-value"; @@ -64,6 +64,7 @@ export function CustomMetricHistoryList({ decimals: number | null; }) { const { t } = useTranslations(); + const fmt = useFormatters(); const queryClient = useQueryClient(); const { sortBy, sortDir, toggleSort } = useTableSort({ @@ -242,7 +243,7 @@ export function CustomMetricHistoryList({ {ordered.map((r) => ( - {formatMetricValue(r.value, decimals)} + {formatMetricValue(r.value, decimals, fmt.number)} {unit ? ` ${unit}` : ""} diff --git a/src/components/custom-metrics/custom-metric-list.tsx b/src/components/custom-metrics/custom-metric-list.tsx index 232326b3d..ca71bdf8c 100644 --- a/src/components/custom-metrics/custom-metric-list.tsx +++ b/src/components/custom-metrics/custom-metric-list.tsx @@ -15,7 +15,7 @@ import { SectionHeading } from "@/components/ui/section-heading"; import { Skeleton } from "@/components/ui/skeleton"; import { apiGet } from "@/lib/api/api-fetch"; import { formatDate } from "@/lib/format"; -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { CustomMetricForm } from "./custom-metric-form"; @@ -31,6 +31,7 @@ import type { CustomMetricDto, CustomMetricListResponse } from "./types"; */ export function CustomMetricList() { const { t } = useTranslations(); + const fmt = useFormatters(); // v1.36.x — the section is dropped whole inside somebody else's record. // Defining a metric is not a delegated verb, and every row here links to // `/custom-metrics/{id}`, which sharing does not cover: the shell turns a @@ -133,6 +134,7 @@ export function CustomMetricList() { {formatMetricValue( metric.latest.value, metric.decimals, + fmt.number, )} {metric.unit ? ` ${metric.unit}` : ""} diff --git a/src/components/custom-metrics/format-value.ts b/src/components/custom-metrics/format-value.ts index 6ee795c99..69e7fd68f 100644 --- a/src/components/custom-metrics/format-value.ts +++ b/src/components/custom-metrics/format-value.ts @@ -1,17 +1,20 @@ /** - * v1.25.5 — format a custom-metric value for display. + * Format a custom-metric value for display. * * When the metric defines a fixed number of decimals, honour it; otherwise fall * back to a compact representation that trims trailing zeros (up to 3 places). + * The digits come from the caller's locale formatter (`useFormatters().number`), + * so a German reader sees "70,5" where an English one sees "70.5". */ export function formatMetricValue( value: number, decimals: number | null | undefined, + formatNumber: (value: number, fractionDigits?: number) => string, ): string { if (!Number.isFinite(value)) return "—"; if (decimals != null) { - return value.toFixed(decimals); + return formatNumber(value, decimals); } // Compact default: round to at most 3 places and drop trailing zeros. - return String(Math.round(value * 1000) / 1000); + return formatNumber(Math.round(value * 1000) / 1000); } diff --git a/src/components/cycle/cycle-phase-crosstab.tsx b/src/components/cycle/cycle-phase-crosstab.tsx index 9dd962d90..57433890b 100644 --- a/src/components/cycle/cycle-phase-crosstab.tsx +++ b/src/components/cycle/cycle-phase-crosstab.tsx @@ -1,6 +1,7 @@ "use client"; -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import type { Formatters } from "@/lib/format-locale"; import { useUnitDisplay, type UnitDisplay } from "@/hooks/use-unit-display"; import { applyDisplayTransform, @@ -81,8 +82,12 @@ const CONFIDENCE_CLASS: Record = { }; /** Whole numbers for steps; one decimal otherwise. */ -function fmt(value: number, display: CyclePhaseCrosstabDisplay): string { - return display === "steps" ? Math.round(value).toString() : value.toFixed(1); +function fmt( + value: number, + display: CyclePhaseCrosstabDisplay, + nf: Formatters, +): string { + return display === "steps" ? nf.integer(value) : nf.number(value, 1); } /** @@ -152,6 +157,7 @@ export function CyclePhaseHeadline({ headline: CyclePhaseCrosstabRow | null; }) { const { t } = useTranslations(); + const nf = useFormatters(); const unitDisplay = useUnitDisplay(); if (!headline) { return ( @@ -176,7 +182,7 @@ export function CyclePhaseHeadline({ const params = { metric: metricLabel, // A phase delta → factor-only conversion (no affine offset). - delta: fmt(Math.abs(deltaConv(headline.delta)), headline.display), + delta: fmt(Math.abs(deltaConv(headline.delta)), headline.display, nf), unit, dir, }; @@ -199,6 +205,7 @@ export function CyclePhaseCrosstab({ rows: CyclePhaseCrosstabRow[]; }) { const { t } = useTranslations(); + const nf = useFormatters(); const unitDisplay = useUnitDisplay(); if (rows.length === 0) return null; @@ -223,7 +230,7 @@ export function CyclePhaseCrosstab({ // observational, so colouring it would imply a verdict the data // doesn't support. The sign prefix + `data-direction` carry it. const up = row.delta >= 0; - const deltaText = `${up ? "+" : ""}${fmt(deltaConv(row.delta), row.display)} ${unit}`; + const deltaText = `${up ? "+" : ""}${fmt(deltaConv(row.delta), row.display, nf)} ${unit}`; return (
  • {t("cycle.insights.crosstab.detail", { metric: metricLabel, - lutealAvg: fmt(absConv(row.lutealAvg), row.display), - follicularAvg: fmt(absConv(row.follicularAvg), row.display), + lutealAvg: fmt(absConv(row.lutealAvg), row.display, nf), + follicularAvg: fmt( + absConv(row.follicularAvg), + row.display, + nf, + ), unit, lutealDays: row.lutealDays, follicularDays: row.follicularDays, diff --git a/src/components/illness/log-day-sheet.tsx b/src/components/illness/log-day-sheet.tsx index 85b3e0c86..5284689ab 100644 --- a/src/components/illness/log-day-sheet.tsx +++ b/src/components/illness/log-day-sheet.tsx @@ -24,7 +24,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { useUnitDisplay } from "@/hooks/use-unit-display"; import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog"; @@ -141,6 +141,7 @@ export function LogDaySheet({ onsetDate, }: LogDaySheetProps) { const { t } = useTranslations(); + const fmt = useFormatters(); // The fever is stored in °C and typed in the reader's temperature unit: // seeded through the transform, inverted on save. const { toDisplay, fromDisplay, unitFor } = useUnitDisplay(); @@ -185,7 +186,8 @@ export function LogDaySheet({ setFunctionalImpact(dto?.functionalImpact ?? null); setFever( dto?.feverC != null - ? String(toDisplay("BODY_TEMPERATURE", dto.feverC)) + ? // Seeded in the reader's notation; the parser below takes either mark. + fmt.number(toDisplay("BODY_TEMPERATURE", dto.feverC)) : "", ); setNote(dto?.note ?? ""); @@ -345,7 +347,7 @@ export function LogDaySheet({ summary={ feverValue != null ? ( - {feverValue.toFixed(1)} {feverUnit} + {fmt.number(feverValue, 1)} {feverUnit} ) : null } @@ -357,7 +359,7 @@ export function LogDaySheet({ setFever(e.target.value)} className="max-w-32" diff --git a/src/components/insights/ecg-waveform.tsx b/src/components/insights/ecg-waveform.tsx index 3779c7642..2534d492e 100644 --- a/src/components/insights/ecg-waveform.tsx +++ b/src/components/insights/ecg-waveform.tsx @@ -83,6 +83,7 @@ function buildPath(samples: number[]): string { const x = i * xStep; // Higher voltage → smaller y (up). const y = Y_PAD + ((max - samples[i]) / span) * usableH; + // eslint-disable-next-line healthlog/no-unlocalised-decimal -- SVG coordinates, read by the renderer, not a person d += `${i === 0 ? "M" : "L"}${x.toFixed(2)} ${y.toFixed(2)}`; if (i < n - 1) d += " "; } diff --git a/src/components/insights/metric-target-summary.tsx b/src/components/insights/metric-target-summary.tsx index e0cc9789a..0e7d85126 100644 --- a/src/components/insights/metric-target-summary.tsx +++ b/src/components/insights/metric-target-summary.tsx @@ -7,7 +7,7 @@ import { ExternalLink, Target } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useUnitDisplay } from "@/hooks/use-unit-display"; import { queryKeys } from "@/lib/query-keys"; -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { resolveTargetUnitAdapter } from "@/lib/targets/target-unit-display"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { RangeBar } from "@/components/targets/range-bar"; @@ -231,6 +231,7 @@ function TargetReferencePanel({ glucoseUnit: payloadGlucoseUnit, }: TargetReferencePanelProps) { const { t } = useTranslations(); + const fmt = useFormatters(); const adjust = useTargetAdjust(); const { preference, glucoseUnit: accountGlucoseUnit } = useUnitDisplay(); const glucoseUnit = payloadGlucoseUnit ?? accountGlucoseUnit; @@ -314,7 +315,7 @@ function TargetReferencePanel({ const avgValue = bpDiastolic?.average30 != null ? `${Math.round(average30)}/${Math.round(bpDiastolic.average30)}` - : String(Math.round(average30 * 10) / 10); + : fmt.number(Math.round(average30 * 10) / 10); averageLabel = `${t("targets.average30d")} ${avgValue} ${unit}`; } diff --git a/src/components/insights/mood/mood-narrative-feed.tsx b/src/components/insights/mood/mood-narrative-feed.tsx index a916e5b12..8abccfd65 100644 --- a/src/components/insights/mood/mood-narrative-feed.tsx +++ b/src/components/insights/mood/mood-narrative-feed.tsx @@ -13,7 +13,7 @@ import { } from "lucide-react"; import type { ComponentType } from "react"; -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; /** @@ -59,8 +59,12 @@ function pickIcon(item: MoodNarrativeItem): ComponentType<{ return ICONS[item.kind] ?? Tag; } +/** Vars carrying a one-decimal score figure (`mood-narratives.ts`). */ +const DECIMAL_VARS = ["delta", "value"] as const; + export function MoodNarrativeFeed({ items }: { items: MoodNarrativeItem[] }) { const { t } = useTranslations(); + const fmt = useFormatters(); if (items.length === 0) return null; @@ -89,6 +93,13 @@ export function MoodNarrativeFeed({ items }: { items: MoodNarrativeItem[] }) { if (vars.weekdayKey) vars.weekday = t(vars.weekdayKey); if (vars.tagKey) vars.tag = t(vars.tagKey); if (vars.bucketKey) vars.bucket = t(vars.bucketKey); + // The server spells the score figures with a full stop so they + // survive JSON unchanged; they are printed in the reader's notation. + for (const name of DECIMAL_VARS) { + const raw = vars[name]; + const value = raw === undefined ? Number.NaN : Number(raw); + if (Number.isFinite(value)) vars[name] = fmt.number(value, 1); + } return (
  • `${x.toFixed(1)},${y.toFixed(1)}`) .join(" "); const isValidRange = diff --git a/src/components/settings/nutrient-intake-card.tsx b/src/components/settings/nutrient-intake-card.tsx index 1bc12e5f0..652e07051 100644 --- a/src/components/settings/nutrient-intake-card.tsx +++ b/src/components/settings/nutrient-intake-card.tsx @@ -24,7 +24,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header"; import { EmptyState } from "@/components/ui/empty-state"; import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/hooks/use-auth"; -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { apiGet } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; @@ -46,14 +46,13 @@ interface NutrientIntakeOverview { nutrients: NutrientOverviewRow[]; } -function formatAmount(amount: number): string { - // Day totals arrive as floats; one decimal is plenty for a glance - // list and trailing zeros read as noise. - return String(Math.round(amount * 10) / 10); -} - export function NutrientIntakeCard() { const { t } = useTranslations(); + const fmt = useFormatters(); + // Day totals arrive as floats; one decimal is plenty for a glance list and + // trailing zeros read as noise, so the digit count is a ceiling. + const formatAmount = (amount: number) => + fmt.number(Math.round(amount * 10) / 10); const { user } = useAuth(); const enabled = user?.modules?.nutrients === true; diff --git a/src/components/timeline/timeline-geometry.ts b/src/components/timeline/timeline-geometry.ts index bb95c69c5..2bb2b49f4 100644 --- a/src/components/timeline/timeline-geometry.ts +++ b/src/components/timeline/timeline-geometry.ts @@ -1157,6 +1157,7 @@ export function layoutSeries( thin: p.count < THIN_BUCKET_COUNT, }; }); + // eslint-disable-next-line healthlog/no-unlocalised-decimal -- SVG coordinates, read by the renderer, not a person const xy = (p: SeriesPointLayout) => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`; let path = ""; let bridges = ""; diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx index f5fb159d5..0e79b7a11 100644 --- a/src/components/ui/calendar.tsx +++ b/src/components/ui/calendar.tsx @@ -197,6 +197,7 @@ function CalendarDayButton({ ref={ref} variant="ghost" size="icon" + // eslint-disable-next-line healthlog/no-unlocalised-decimal -- data attribute the day-picker's own styles key on, never shown data-day={day.date.toLocaleDateString()} data-selected-single={ modifiers.selected && From 41de0ca1260d1b5788bdf895ed4b5d5b3756e49c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 13:01:45 +0200 Subject: [PATCH 04/63] refactor(medications): drop the compliance rollup table that nothing reads medication_compliance_rollups lost its last reader when compliance moved to the schedule-anchored computation, yet every dose write still upserted it, a boot backfill refolded it, and the wipe and backup plans carried it. The per-dose hook keeps the one derived row that still hangs off a dose day, the supplement nutrients, under dose-day-refresh. The pending-row mints (today projection, reminder) no longer call it, since an untaken dose adds no nutrients. The import job finishes without its per-day recompute phase; its progress keeps touchedDays and rollupProcessed on the wire, inert and documented as deprecated. The backfill queue is retired on boot and migration 0414 drops the table under a bounded lock wait. --- docs/api/openapi.yaml | 14 +- docs/ops/intake-repair.md | 16 +- .../migration.sql | 32 ++ prisma/schema.prisma | 74 +-- scripts/migrate-demo-edge01.README.md | 4 +- scripts/migrate-demo-edge01.sh | 4 +- scripts/repair-intake-anomalies.ts | 165 +------ scripts/seed-demo-v15.sql | 1 - src/__tests__/sql-now-utc-guard.test.ts | 4 - src/__tests__/tz-day-paths-harness.test.ts | 2 +- .../auth/me/timezone/__tests__/route.test.ts | 20 +- src/app/api/auth/me/timezone/route.ts | 22 +- .../ingest/medication/__tests__/route.test.ts | 4 +- src/app/api/ingest/medication/route.ts | 4 +- .../medications/[id]/__tests__/route.test.ts | 40 +- .../intake/[eventId]/__tests__/route.test.ts | 4 +- .../[id]/intake/[eventId]/route.ts | 8 +- .../[id]/intake/__tests__/route.test.ts | 4 +- .../__tests__/source-provenance.test.ts | 4 +- .../bulk-delete/__tests__/route.test.ts | 12 +- .../[id]/intake/bulk-delete/route.ts | 27 +- .../[id]/intake/purge/__tests__/route.test.ts | 8 +- .../medications/[id]/intake/purge/route.ts | 11 +- src/app/api/medications/[id]/intake/route.ts | 4 +- src/app/api/medications/[id]/route.ts | 40 +- .../schedule-revisions/[revisionId]/route.ts | 7 - .../__tests__/route.test.ts | 20 - .../[id]/schedule-revisions/route.ts | 5 - .../intake/__tests__/route.test.ts | 15 - .../intake/bulk/__tests__/route.test.ts | 4 +- src/app/api/medications/intake/bulk/route.ts | 43 +- src/app/api/medications/intake/route.ts | 6 +- .../telegram/webhook/__tests__/route.test.ts | 4 +- src/lib/data-wipe/wipe-plan.ts | 1 - .../restore-backup-owner-mismatch.test.ts | 2 +- .../restore-backup-round-trip.test.ts | 2 +- src/lib/export/backup-plan.ts | 2 - src/lib/export/restore-backup.ts | 31 -- .../jobs/__tests__/intake-auto-take.test.ts | 8 +- .../medication-intake-import.test.ts | 66 +-- .../medication-reminder-check.test.ts | 3 - .../medication-reminder-dedup.test.ts | 3 - .../__tests__/queue-policy-tables.test.ts | 1 - src/lib/jobs/intake-auto-take.ts | 6 +- src/lib/jobs/medication-intake-import.ts | 127 +---- src/lib/jobs/queue-runtime.ts | 3 - .../reminder/medication-reminder-check.ts | 10 - src/lib/jobs/reminder/register-rollup.ts | 91 +--- src/lib/jobs/subsystem-surface.ts | 1 - .../intake-slot-dedup-converge.test.ts | 4 +- .../intake-slot-dedup-inventory.test.ts | 4 +- .../intake-slot-dedup-isolation.test.ts | 8 +- .../__tests__/intake-slot-dedup-pin.test.ts | 4 +- src/lib/medications/dose-day-refresh.ts | 62 +++ src/lib/medications/intake-slot-dedup.ts | 4 +- .../scheduling/project-today-intakes.ts | 36 -- src/lib/openapi/routes/medications/schemas.ts | 6 +- src/lib/openapi/routes/profile.ts | 2 +- .../medication-compliance-rollups.test.ts | 403 ---------------- .../rollups/medication-compliance-rollups.ts | 438 ------------------ src/lib/telegram-webhook-handlers.ts | 10 +- src/lib/tz/wall-clock.ts | 4 +- tests/integration/intake-slot-dedup.test.ts | 10 - tests/integration/local-day-keys.test.ts | 29 -- .../medication-cadence-worker.test.ts | 8 +- .../medication-compliance-rollup-slot.test.ts | 225 --------- .../medication-dose-history-import.test.ts | 20 - .../medication-intake-import-job.test.ts | 40 +- ...medication-reminder-cross-midnight.test.ts | 3 - .../sharing-delegable-writes.test.ts | 26 -- .../integration/supplement-nutrients.test.ts | 6 +- 71 files changed, 314 insertions(+), 2027 deletions(-) create mode 100644 prisma/migrations/0414_drop_medication_compliance_rollups/migration.sql create mode 100644 src/lib/medications/dose-day-refresh.ts delete mode 100644 src/lib/rollups/__tests__/medication-compliance-rollups.test.ts delete mode 100644 src/lib/rollups/medication-compliance-rollups.ts delete mode 100644 tests/integration/medication-compliance-rollup-slot.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 89c03a66b..19745220b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -9544,10 +9544,9 @@ paths: defend against a poisoned column. Audit-logged with the old and new zone. - A change is not only a display preference. The medication-compliance rollup keys its rows by the LOCAL day a - dose fell on, minted under whatever zone was in force at write time, so moving the zone re-buckets that history - and the stored keys go stale — and the coverage probe compares counts rather than keys, so it cannot notice. The - route therefore enqueues a re-fold of the trailing window whenever the zone actually changes. Best-effort and + A change is not only a display preference. The supplement nutrient rows key on the LOCAL day a dose fell on, + minted under whatever zone was in force at write time, so moving the zone re-buckets them. The route therefore + enqueues a recompute of the account's supplement nutrients whenever the zone actually changes. Best-effort and de-duplicated, so rapid re-picks collapse into one. requestBody: required: true @@ -52993,12 +52992,15 @@ components: type: array items: type: string - description: Local calendar days (YYYY-MM-DD) touched so far, driving the chunked rollup recompute behind the write. + description: "Deprecated and inert: always `[]` on a job admitted from v1.43.8, when the per-day compliance rollup it + drove was removed. A job admitted earlier keeps the days it recorded. Kept for clients that decode it; do + not read it." rollupProcessed: type: integer minimum: 0 maximum: 9007199254740991 - description: Of `touchedDays`, how many have had their rollup recomputed. + description: "Deprecated and inert: always `0` on a job admitted from v1.43.8. Kept for clients that decode it; do not + read it." required: - processed - total diff --git a/docs/ops/intake-repair.md b/docs/ops/intake-repair.md index c1a16cdc8..3c528d5c3 100644 --- a/docs/ops/intake-repair.md +++ b/docs/ops/intake-repair.md @@ -33,9 +33,9 @@ metadata (`medication_schedules`): - After upgrading to v1.15.19+, when a user reports a compliance rate above 100 %, duplicate entries in the medication history, or a dose shown as taken days away from its slot. -- The compliance rollup counts DISTINCT slots since v1.15.19, so the - percentages self-correct on recompute even without running this script. - Running it additionally clears the phantom rows from the history view. +- Compliance counts DISTINCT slots since v1.15.19, so the percentages are + right even without running this script. Running it additionally clears + the phantom rows from the history view. - The `intake-slot-dedup` worker already collapses canonical-slot duplicates at boot and on a daily cron; this script is the manual, immediately-observable fallback and the only path that surfaces the @@ -104,13 +104,9 @@ healthlog-tsx scripts/repair-intake-anomalies.ts --fix as the duplicate losers). A medication without any derivable anchor (PRN-only / unscheduled) is skipped — there is nothing to compare against. -- **Affected compliance rollups are recomputed** for every touched - `(user, medication, day)`. The script executes the same DISTINCT-slot - aggregation SQL the shared helper runs (a verbatim twin of - `recomputeMedicationComplianceForDay` in - `src/lib/rollups/medication-compliance-rollups.ts` — kept inline so - the script needs no app imports), so the scheduled/taken counts and - the rate self-correct immediately. +- **No separate recompute is needed.** Every compliance reader folds the + rows that share one slot, so the scheduled/taken counts and the rate are + right as soon as the duplicates are tombstoned. The script is idempotent: a second `--fix` run finds zero duplicate groups and changes nothing. Exit code is 0 on success (including a clean diff --git a/prisma/migrations/0414_drop_medication_compliance_rollups/migration.sql b/prisma/migrations/0414_drop_medication_compliance_rollups/migration.sql new file mode 100644 index 000000000..e5894b517 --- /dev/null +++ b/prisma/migrations/0414_drop_medication_compliance_rollups/migration.sql @@ -0,0 +1,32 @@ +-- Drop `medication_compliance_rollups`. +-- +-- The per-(user, medication, day) coverage ledger lost its last reader when +-- the dashboard tile and the compliance views moved to the schedule-anchored +-- computation; every dose write still upserted a row into it, a boot backfill +-- refolded it, and it travelled through the data wipe for nothing. The write +-- hook now refreshes only what still derives from a dose day (the supplement +-- nutrients), and the boot retires the backfill queue. +-- +-- Nothing is lost: the table held counts derived from +-- `medication_intake_events`, which stay. +-- +-- Lock wait. Dropping the table removes its two foreign keys, and removing a +-- foreign key's triggers takes ACCESS EXCLUSIVE on the referenced tables, +-- `users` and `medications`, for the instant the catalog changes. Waiting for +-- that lock behind a long reader would queue every login and every +-- medication read behind the migration, so the wait is bounded at five +-- seconds inside one `DO` block (one statement, so it commits or rolls back +-- whole; `migrate deploy` runs a file statement by statement). A lock +-- timeout changes nothing and the file is safe to apply again, which the +-- marker declares (docs/ops/deploy.md, "A migration that timed out on a +-- lock"). +-- +-- @retry-on-lock-timeout + +DO $migration$ +BEGIN + PERFORM set_config('lock_timeout', '5s', true); + + DROP TABLE IF EXISTS "medication_compliance_rollups"; +END +$migration$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 05b4527d1..e1975b2fc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -118,61 +118,61 @@ model User { environmentAqHistoryJson Json? @map("environment_aq_history_json") // Codex OAuth (ChatGPT subscription) - codexAccessTokenEncrypted String? @map("codex_access_token_encrypted") - codexRefreshTokenEncrypted String? @map("codex_refresh_token_encrypted") + codexAccessTokenEncrypted String? @map("codex_access_token_encrypted") + codexRefreshTokenEncrypted String? @map("codex_refresh_token_encrypted") /// @internal: dead mirror of the expiry inside the encrypted Codex creds — /// the refresh path reads the decrypted blob's own `expiresAt` /// (`src/lib/ai/provider.ts`), never this column. Kept (and kept written) /// for wire/backup compatibility rather than dropped. - codexTokenExpiresAt DateTime? @map("codex_token_expires_at") - codexConnectedAt DateTime? @map("codex_connected_at") - codexConnectionStatus String @default("disconnected") @map("codex_connection_status") + codexTokenExpiresAt DateTime? @map("codex_token_expires_at") + codexConnectedAt DateTime? @map("codex_connected_at") + codexConnectionStatus String @default("disconnected") @map("codex_connection_status") // Per-user opt-in to the operator's shared central Codex (ChatGPT // subscription) connection stored on `AppSettings.adminCodex*`. OFF by // default; only takes effect when the operator has actually connected the // central Codex. Routing through it is external egress on a shared, // train-by-default account bound by the operator's rate limits — consent-gated // exactly like any other server-managed egress. - useCentralCodex Boolean @default(false) @map("use_central_codex") - insightsPrivacyMode String @default("aggregated") @map("insights_privacy_mode") // "aggregated" | "raw" - insightsCachedAt DateTime? @map("insights_cached_at") + useCentralCodex Boolean @default(false) @map("use_central_codex") + insightsPrivacyMode String @default("aggregated") @map("insights_privacy_mode") // "aggregated" | "raw" + insightsCachedAt DateTime? @map("insights_cached_at") /// @internal: legacy plaintext cache of the AI insights payload, read only /// as a fallback for rows the free-text encryption backfill has not reached /// yet (v1.43.7). Every write since then goes to `insightsCachedTextEncrypted` /// and leaves this NULL; the backfill seals and nulls it on the old rows. /// DROP this column in a later release, once the backfill reports zero /// remaining rows on every instance. - insightsCachedText String? @map("insights_cached_text") + insightsCachedText String? @map("insights_cached_text") /// v1.43.7 — the cached AI insights payload (briefing, per-domain readings, /// recommendations; JSON), AES-256-GCM binary layout under /// `INSIGHTS_CACHED_TEXT_AAD`. Read and written only through /// `src/lib/insights/cached-text-cipher.ts`. - insightsCachedTextEncrypted Bytes? @map("insights_cached_text_encrypted") + insightsCachedTextEncrypted Bytes? @map("insights_cached_text_encrypted") // Language the cached text was generated in. The cache is one slot per // user, so a reader in another language must not be served it: the // snapshot briefing lift and the advisor read treat a tag that differs // from the reader's locale as an empty cache and re-warm in the reader's. // NULL on rows written before the tag existed (treated as matching; the // next write tags it). - insightsCachedLocale String? @map("insights_cached_locale") + insightsCachedLocale String? @map("insights_cached_locale") // v1.16.8 — SHA-256 fingerprint of the compacted feature snapshot the // cached comprehensive text was generated from. Nightly / forced / lazy // regenerations compare the fresh snapshot's hash against this and skip // the provider call (refreshing only `insightsCachedAt`) when nothing // the prompt sees has changed. - insightsSnapshotHash String? @map("insights_snapshot_hash") + insightsSnapshotHash String? @map("insights_snapshot_hash") // v1.16.8 — last time a forced full warm's comprehensive step failed or // timed out. The forced warm path backs off for an hour after a failure // so a persistently broken provider chain cannot be re-driven into a // full generation attempt by every page-open. - insightsWarmFailedAt DateTime? @map("insights_warm_failed_at") + insightsWarmFailedAt DateTime? @map("insights_warm_failed_at") // v1.18.7 — UTC YYYY-MM-DD of the last daily-briefing phrasing re-roll. // The content-hash gate keeps the FINDINGS byte-stable on unchanged data, // but the daily-briefing PARAGRAPH is re-rolled once per calendar day at a // higher, seedless temperature so the prose reads fresh day-over-day // without re-paying for the findings. This stamps the day a re-roll ran so // the gate stays at most one extra call per day. - insightsBriefingRerollDate String? @map("insights_briefing_reroll_date") + insightsBriefingRerollDate String? @map("insights_briefing_reroll_date") // Morning-freshness marker: the user-local YYYY-MM-DD (their profile // timezone) for which the event-driven morning refresh has run — i.e. last // night's sleep has arrived AND the sleep-dependent insight/score cache was @@ -181,13 +181,13 @@ model User { // `morning-digest-refresh` job the sleep-arrival trigger enqueues; the // string form makes the "same local morning" comparison timezone-honest // without a second timestamp read. - morningDigestRefreshedOn String? @map("morning_digest_refreshed_on") + morningDigestRefreshedOn String? @map("morning_digest_refreshed_on") // v1.4.36 W3 T3 — per-user AI Insights exclude-metrics list, mirrors // `coachPrefsJson.excludeMetrics`. The /api/insights/generate route // filters matching keys off `features` before JSON.stringify so the // model never sees data the user opted out of. Default `[]` = legacy // behaviour for every existing row. - insightsExcludeMetrics String[] @default([]) @map("insights_exclude_metrics") + insightsExcludeMetrics String[] @default([]) @map("insights_exclude_metrics") // v1.8.5 — user-level injection-site deny-list. Sites listed here are // never offered for ANY medication's injection-site picker and the @@ -897,11 +897,6 @@ model User { // read serves from this table instead of the unbounded // MoodEntry.findMany walk. moodEntryRollups MoodEntryRollup[] - // v1.4.39 W-MED — per-(user, medication, day) compliance rollup. - // Per-write hook on MedicationIntakeEvent mutations + the scheduler - // keep the row in sync so `/api/medications/intake?scope=compliance` - // and `computeUserHealthScoreFastPath` skip the per-event findMany. - medicationComplianceRollups MedicationComplianceRollup[] // v1.4.40 SB-10 — AI consent receipts the iOS app POSTs when the user // grants AI processing during onboarding. Persisted audit trail // covers App-Store Guideline 5.1.2(i) ("data-collection consent must @@ -2972,10 +2967,6 @@ model Medication { /// roll-up (GI / METABOLIC / INJECTION_SITE / COGNITIVE / /// GLP1_SPECIFIC) and 1-5 Likert severity per entry. sideEffects MedicationSideEffect[] - /// v1.4.39 W-MED — per-(medication, day) compliance rollup. Mutations - /// on the linked MedicationIntakeEvent rows refresh the row inline; - /// the read paths consume this instead of the per-event findMany. - complianceRollups MedicationComplianceRollup[] /// v1.16.3 — archived schedule eras (effective dating). Superseded /// schedule states land here when a wholesale schedule replace changes /// cadence-relevant fields; the band minter segments historical minting @@ -5038,39 +5029,6 @@ model MoodEntryRollup { @@map("mood_entry_rollups") } -// ─── Medication Compliance Rollup (v1.4.39 W-MED) ───────────── -// -// Per-(userId, medicationId, day) compliance ledger. Replaces the -// unbounded MedicationIntakeEvent findMany on -// `/api/medications/intake?scope=compliance` and the equivalent -// fan-out inside `computeUserHealthScoreFastPath`. -// -// Day-key is anchored to the user's IANA timezone (TEXT, encoded as -// `YYYY-MM-DD`) so the read path can collapse a date range into a -// straight WHERE clause without re-deriving the boundary per row. -// -// Write path: every MedicationIntakeEvent create / update / delete -// (and the scheduler that mints fresh `scheduledFor` rows) fires -// `recomputeMedicationComplianceForDay` which upserts the -// `(user, medication, day)` row. Worker handles weekly / monthly -// re-fold off the hot path. -model MedicationComplianceRollup { - userId String @map("user_id") - medicationId String @map("medication_id") - day String // YYYY-MM-DD anchored to user.timezone - scheduled Int - taken Int - skipped Int - computedAt DateTime @default(now()) @map("computed_at") @db.Timestamptz(3) - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) - - @@id([userId, medicationId, day]) - @@index([userId, day(sort: Desc)]) - @@map("medication_compliance_rollups") -} - // ─── Integration Status (v1.4.15) ───────────────────────── // // Per-(user, integration) row that captures the most recent sync attempt diff --git a/scripts/migrate-demo-edge01.README.md b/scripts/migrate-demo-edge01.README.md index 35dd6cd15..f21a6414d 100644 --- a/scripts/migrate-demo-edge01.README.md +++ b/scripts/migrate-demo-edge01.README.md @@ -107,8 +107,8 @@ acknowledgement). `audit_logs` rows (the text lives in the `details` JSON) instead. - **Transient / regenerable:** idempotency_keys, rate_limits, data_backups, import_jobs, host_metrics, feedback, recommendation_feedback, and all rollup - tables (measurement_rollups, mood_entry_rollups, - medication_compliance_rollups, strain_trimp_cache, cycle_predictions) — these + tables (measurement_rollups, mood_entry_rollups, strain_trimp_cache, + cycle_predictions) — these rebuild on edge01. - **No rows:** workouts / workout_routes / workout_samples (demo has none). - **Instance / global:** app_settings; global catalogues (mood_tags, diff --git a/scripts/migrate-demo-edge01.sh b/scripts/migrate-demo-edge01.sh index af4918879..228e94714 100755 --- a/scripts/migrate-demo-edge01.sh +++ b/scripts/migrate-demo-edge01.sh @@ -65,8 +65,7 @@ # - Ops / transient / regenerable: idempotency_keys, rate_limits, # data_backups, import_jobs, host_metrics, feedback, # recommendation_feedback, all rollup tables (measurement_rollups, -# mood_entry_rollups, medication_compliance_rollups, strain_trimp_cache, -# cycle_predictions) — these REGENERATE on edge01. +# mood_entry_rollups, strain_trimp_cache, cycle_predictions) — these REGENERATE on edge01. # - workouts / workout_routes / workout_samples — demo has none. # - app_settings — instance-level, not user data. # - Global catalogues (mood_tags, cycle_symptoms) — pre-exist on edge01; @@ -299,7 +298,6 @@ DELETE FROM audit_logs WHERE user_id = '$EDGE_DEMO_ID'; -- regenerable / transient tables that may carry FK rows blocking the user delete DELETE FROM measurement_rollups WHERE user_id = '$EDGE_DEMO_ID'; DELETE FROM mood_entry_rollups WHERE user_id = '$EDGE_DEMO_ID'; -DELETE FROM medication_compliance_rollups WHERE user_id = '$EDGE_DEMO_ID'; DELETE FROM strain_trimp_cache WHERE user_id = '$EDGE_DEMO_ID'; DELETE FROM cycle_predictions WHERE user_id = '$EDGE_DEMO_ID'; DELETE FROM measurements WHERE user_id = '$EDGE_DEMO_ID'; diff --git a/scripts/repair-intake-anomalies.ts b/scripts/repair-intake-anomalies.ts index 70ef1c500..bc851be5e 100644 --- a/scripts/repair-intake-anomalies.ts +++ b/scripts/repair-intake-anomalies.ts @@ -50,14 +50,9 @@ * CREATE/UPDATE only with `--backfill-eras`. Medications that * already have a revision are skipped (idempotent). * - * After any mutation the affected `(user, medication, day)` compliance - * rollups are recomputed with the SAME slot-level DISTINCT aggregation the - * shared helper runs — see `recomputeComplianceDay` below, a verbatim - * SQL twin of `recomputeMedicationComplianceForDay` in - * `src/lib/rollups/medication-compliance-rollups.ts`. The rollup - * aggregates DISTINCT slots since v1.15.19, so a recompute alone already - * heals the counts; the tombstones additionally clear the phantom rows - * from the history view. + * The tombstones clear the phantom rows from the history view; every + * compliance reader folds rows sharing a slot, so the counts need no + * separate recompute. * * Default is a DRY-RUN that only reports. Idempotent: a second `--fix` * run finds zero groups and changes nothing. @@ -211,8 +206,8 @@ function pickWinner(rows: IntakeRow[]): IntakeRow { // Timezone / day-key helpers — self-contained twins of the app helpers // the previous revision imported. Reference implementations: // - `userDayKey` / `DEFAULT_TIMEZONE`: `src/lib/tz/format.ts` -// - `tzOffsetMinutes` / `startOfDayUtcInTz` / the DST-safe day-after -// boundary: `src/lib/rollups/medication-compliance-rollups.ts` +// - `tzOffsetMinutes` / `startOfDayUtcInTz`: `startOfLocalDayKey` in +// `src/lib/tz/local-day.ts` // ───────────────────────────────────────────────────────────────────── const DEFAULT_TIMEZONE = "Europe/Berlin"; @@ -287,100 +282,6 @@ function startOfDayUtcInTz(dayKey: string, tz: string): Date { return guess; } -/** - * `[start, windowEnd)` UTC window of the local calendar day — the same - * DST-safe boundary derivation `recomputeMedicationComplianceForDay` - * runs (23 / 25-hour days fold correctly because the end anchors on - * the NEXT day's local midnight, not `start + 24 h`). - */ -function dayWindowUtc(dayKey: string, tz: string): { start: Date; end: Date } { - const start = startOfDayUtcInTz(dayKey, tz); - const fallbackEnd = new Date(start.getTime() + 86_400_000); - const [y, m, d] = dayKey.split("-").map(Number); - const probeNext = new Date(Date.UTC(y, m - 1, d, 12, 0, 0) + 86_400_000); - const nextDayKey = dayKeyForScheduledFor(probeNext, tz); - const dstSafeEnd = startOfDayUtcInTz(nextDayKey, tz); - return { - start, - end: dstSafeEnd.getTime() > start.getTime() ? dstSafeEnd : fallbackEnd, - }; -} - -/** - * Recompute one `(userId, medicationId, dayKey)` compliance-rollup row. - * - * VERBATIM SQL TWIN of `recomputeMedicationComplianceForDay` in - * `src/lib/rollups/medication-compliance-rollups.ts` — keep the two in - * sync when the rollup contract changes. v1.15.19 semantics: the inner - * CTE folds rows sharing one `scheduled_for` instant into one DISTINCT - * slot with taken-beats-skipped priority; the outer aggregate counts - * slots, not rows, so a duplicated slot can never inflate `scheduled`. - * The upsert re-aggregates inside the statement (atomic against - * concurrent app writers); the trailing DELETE clears the row when the - * day no longer holds any live event. - */ -async function recomputeComplianceDay( - client: Client, - userId: string, - medicationId: string, - dayKey: string, - tz: string | null | undefined, -): Promise { - const { start, end } = dayWindowUtc(dayKey, safeTimezone(tz)); - await client.query( - ` - WITH slot AS ( - SELECT - BOOL_OR("taken_at" IS NOT NULL AND NOT "skipped") AS slot_taken, - BOOL_OR("skipped") AS slot_skipped - FROM "medication_intake_events" - WHERE "user_id" = $1 - AND "medication_id" = $2 - AND "deleted_at" IS NULL - AND "scheduled_for" >= $4::timestamp - AND "scheduled_for" < $5::timestamp - GROUP BY "scheduled_for" - ), - aggregate AS ( - SELECT - COUNT(*)::int AS scheduled, - COALESCE(SUM(CASE WHEN "slot_taken" THEN 1 ELSE 0 END), 0)::int AS taken, - COALESCE(SUM(CASE WHEN NOT "slot_taken" AND "slot_skipped" THEN 1 ELSE 0 END), 0)::int AS skipped - FROM slot - ) - INSERT INTO "medication_compliance_rollups" - ("user_id", "medication_id", "day", "scheduled", "taken", "skipped", "computed_at") - SELECT $1, $2, $3, aggregate.scheduled, aggregate.taken, aggregate.skipped, NOW() - FROM aggregate - WHERE aggregate.scheduled > 0 - ON CONFLICT ("user_id", "medication_id", "day") DO UPDATE - SET "scheduled" = EXCLUDED."scheduled", - "taken" = EXCLUDED."taken", - "skipped" = EXCLUDED."skipped", - "computed_at" = EXCLUDED."computed_at" - `, - [userId, medicationId, dayKey, utcParam(start), utcParam(end)], - ); - await client.query( - ` - DELETE FROM "medication_compliance_rollups" - WHERE "user_id" = $1 - AND "medication_id" = $2 - AND "day" = $3 - AND NOT EXISTS ( - SELECT 1 - FROM "medication_intake_events" - WHERE "user_id" = $1 - AND "medication_id" = $2 - AND "deleted_at" IS NULL - AND "scheduled_for" >= $4::timestamp - AND "scheduled_for" < $5::timestamp - ) - `, - [userId, medicationId, dayKey, utcParam(start), utcParam(end)], - ); -} - interface Summary { duplicateGroups: number; rowsTombstoned: number; @@ -394,7 +295,6 @@ interface Summary { eraRevisionsCreated: number; startsOnCandidates: number; startsOnAdjusted: number; - rollupsRecomputed: number; } const HHMM_RE = /^([01]\d|2[0-3]):([0-5]\d)$/; @@ -453,7 +353,6 @@ async function main(): Promise { eraRevisionsCreated: 0, startsOnCandidates: 0, startsOnAdjusted: 0, - rollupsRecomputed: 0, }; // Optional per-user scope. The fragment is a fixed string with a @@ -461,7 +360,7 @@ async function main(): Promise { const userFilter = opts.userId ? `AND e."user_id" = $1` : ""; const userParams = opts.userId ? [opts.userId] : []; - /** `userId -> timezone` cache for the rollup day-key derivation. */ + /** `userId -> timezone` cache for the local-day derivations. */ const tzCache = new Map(); async function userTimezone(userId: string): Promise { if (!tzCache.has(userId)) { @@ -474,10 +373,6 @@ async function main(): Promise { return tzCache.get(userId) ?? null; } - // `(userId, medicationId, dayKey)` tuples whose compliance rollup needs - // a recompute after the tombstones. Deduped so a busy day folds once. - const daysToRecompute = new Set(); - try { // ───────────────────────────────────────────────────────────────── // 1. Slot duplicates — exact (user, medication, scheduled_for) @@ -568,12 +463,6 @@ async function main(): Promise { [losers.map((r) => r.id)], ); summary.rowsTombstoned += res.rowCount ?? 0; - - const tz = await userTimezone(group.user_id); - const dayKey = dayKeyForScheduledFor(group.scheduled_for, tz); - daysToRecompute.add( - `${group.user_id}|${group.medication_id}|${dayKey}`, - ); } } @@ -651,12 +540,6 @@ async function main(): Promise { console.log( `${TAG} tombstoned ${res.rowCount ?? 0} implausible row(s) (operator opt-in)`, ); - - for (const row of implausible) { - const tz = await userTimezone(row.user_id); - const dayKey = dayKeyForScheduledFor(row.scheduled_for, tz); - daysToRecompute.add(`${row.user_id}|${row.medication_id}|${dayKey}`); - } } // ───────────────────────────────────────────────────────────────── @@ -822,12 +705,6 @@ async function main(): Promise { [staleRows.map((r) => r.id)], ); summary.staleAnchorTombstoned = res.rowCount ?? 0; - - for (const row of staleRows) { - const tz = await userTimezone(row.user_id); - const dayKey = dayKeyForScheduledFor(row.scheduled_for, tz); - daysToRecompute.add(`${row.user_id}|${row.medication_id}|${dayKey}`); - } } // ───────────────────────────────────────────────────────────────── @@ -1057,35 +934,6 @@ async function main(): Promise { summary.startsOnAdjusted += res.rowCount ?? 0; } } - - // ───────────────────────────────────────────────────────────────── - // 6. Recompute the affected compliance rollups so scheduled/taken - // counts self-correct. Same DISTINCT-slot SQL the shared helper - // runs (see `recomputeComplianceDay` above). - // ───────────────────────────────────────────────────────────────── - if (opts.fix && daysToRecompute.size > 0) { - console.log( - `\n${TAG} 6) recomputing ${daysToRecompute.size} compliance rollup day(s)`, - ); - for (const key of daysToRecompute) { - const [userId, medicationId, dayKey] = key.split("|"); - try { - await recomputeComplianceDay( - client, - userId, - medicationId, - dayKey, - await userTimezone(userId), - ); - summary.rollupsRecomputed += 1; - } catch (err) { - console.error( - `${TAG} rollup recompute failed for ${key}: ` + - `${err instanceof Error ? err.message : String(err)}`, - ); - } - } - } } finally { await client.end(); } @@ -1112,7 +960,6 @@ async function main(): Promise { console.log(` era revisions created: ${summary.eraRevisionsCreated}`); console.log(` startsOn candidates: ${summary.startsOnCandidates}`); console.log(` startsOn adjusted: ${summary.startsOnAdjusted}`); - console.log(` rollup days recomputed: ${summary.rollupsRecomputed}`); } main().catch((err) => { diff --git a/scripts/seed-demo-v15.sql b/scripts/seed-demo-v15.sql index 3fdfe2f7a..a15ca2411 100644 --- a/scripts/seed-demo-v15.sql +++ b/scripts/seed-demo-v15.sql @@ -37,7 +37,6 @@ END $$; -- ── Wipe per-user state ─────────────────────────────────────────── DELETE FROM medication_intake_events WHERE user_id IN (SELECT user_id FROM _demo); -DELETE FROM medication_compliance_rollups WHERE user_id IN (SELECT user_id FROM _demo); DELETE FROM medication_schedules WHERE medication_id IN ( SELECT id FROM medications WHERE user_id IN (SELECT user_id FROM _demo) ); diff --git a/src/__tests__/sql-now-utc-guard.test.ts b/src/__tests__/sql-now-utc-guard.test.ts index cf7b5baa3..00df946cb 100644 --- a/src/__tests__/sql-now-utc-guard.test.ts +++ b/src/__tests__/sql-now-utc-guard.test.ts @@ -30,10 +30,6 @@ const ALLOWED: Record = { hits: 1, why: "computed_at is timestamptz", }, - "src/lib/rollups/medication-compliance-rollups.ts": { - hits: 1, - why: "computed_at is timestamptz", - }, "src/lib/jobs/job-failures.ts": { hits: 2, why: "pg-boss tables are timestamptz", diff --git a/src/__tests__/tz-day-paths-harness.test.ts b/src/__tests__/tz-day-paths-harness.test.ts index 7adf653da..598ea1108 100644 --- a/src/__tests__/tz-day-paths-harness.test.ts +++ b/src/__tests__/tz-day-paths-harness.test.ts @@ -85,7 +85,7 @@ import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; import { serialiseDocument } from "@/lib/documents/store"; import { resolveIntakeActionability } from "@/lib/medications/intake-actionable"; import { moodDateKey } from "@/lib/mood/date-key"; -import { dayKeyForScheduledFor } from "@/lib/rollups/medication-compliance-rollups"; +import { dayKeyForScheduledFor } from "@/lib/medications/dose-day-refresh"; import { dateOnlyAtNoonUtc, dayKeyAsUtcMidnight, diff --git a/src/app/api/auth/me/timezone/__tests__/route.test.ts b/src/app/api/auth/me/timezone/__tests__/route.test.ts index 183b1fa9b..ca3670583 100644 --- a/src/app/api/auth/me/timezone/__tests__/route.test.ts +++ b/src/app/api/auth/me/timezone/__tests__/route.test.ts @@ -23,14 +23,6 @@ vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); -// v1.37.19 — the zone change enqueues a compliance re-fold. -const refoldMock = vi.hoisted(() => - vi.fn(async () => ({ enqueued: true, error: null })), -); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - enqueueUserMedicationComplianceBackfill: refoldMock, -})); - // The supplement nutrient rows are keyed by the same local day. const nutrientMock = vi.hoisted(() => vi.fn(async () => ({ queued: true }))); vi.mock("@/lib/medications/ingredients/recompute-job", () => ({ @@ -110,11 +102,9 @@ describe("PUT /api/auth/me/timezone", () => { expect(res.status).toBe(422); }); - // Watched red: with the re-fold enqueue removed from the route (the - // pre-v1.37.19 write), the changed-zone case fails — the compliance - // rollup's day keys were minted under the old zone and nothing ever - // re-bucketed them (the coverage probe compares counts, not keys). - it("enqueues a compliance re-fold when the zone actually changes", async () => { + // The supplement nutrient rows' day keys were minted under the old zone; + // without the enqueue nothing re-buckets them. + it("enqueues the nutrient recompute when the zone actually changes", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.user.findUnique).mockResolvedValue({ timezone: "Europe/Berlin", @@ -123,11 +113,10 @@ describe("PUT /api/auth/me/timezone", () => { const res = await PUT(mkReq({ timezone: "America/New_York" })); expect(res.status).toBe(200); - expect(refoldMock).toHaveBeenCalledWith("user-1"); expect(nutrientMock).toHaveBeenCalledWith("user-1"); }); - it("does not re-fold when the zone is unchanged", async () => { + it("does not recompute when the zone is unchanged", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.user.findUnique).mockResolvedValue({ timezone: "Europe/Berlin", @@ -136,7 +125,6 @@ describe("PUT /api/auth/me/timezone", () => { const res = await PUT(mkReq({ timezone: "Europe/Berlin" })); expect(res.status).toBe(200); - expect(refoldMock).not.toHaveBeenCalled(); expect(nutrientMock).not.toHaveBeenCalled(); }); diff --git a/src/app/api/auth/me/timezone/route.ts b/src/app/api/auth/me/timezone/route.ts index 729760646..254a2b4c9 100644 --- a/src/app/api/auth/me/timezone/route.ts +++ b/src/app/api/auth/me/timezone/route.ts @@ -30,7 +30,6 @@ import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { invalidateUserTimezone, isValidTimezone } from "@/lib/tz/resolver"; import { enqueueSupplementNutrientRecompute } from "@/lib/medications/ingredients/recompute-job"; -import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups"; export const PUT = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); @@ -62,24 +61,13 @@ export const PUT = apiHandler(async (request: NextRequest) => { invalidateUserTimezone(user.id); - // v1.37.19 — the medication-compliance rollup keys its rows by - // the LOCAL day the dose fell on, minted under the timezone in force at - // write time. A zone change re-buckets those days, so the stored keys go - // stale (and the coverage probe compares counts, not keys — it cannot - // notice). Re-fold the trailing window through the ordinary backfill - // job; best-effort, the singleton key collapses rapid re-picks. + // The supplement nutrient rows are keyed by the LOCAL day the dose fell + // on, minted under the timezone in force at write time. A zone change + // re-buckets those days, so the account's rows are recomputed through + // the ordinary job; best-effort, the singleton key collapses rapid + // re-picks. if ((before?.timezone ?? null) !== tz) { - const refold = await enqueueUserMedicationComplianceBackfill(user.id); - // The supplement nutrient rows are keyed by the same local day. await enqueueSupplementNutrientRecompute(user.id); - annotate({ - meta: { - timezone_compliance_refold_enqueued: refold.enqueued, - ...(refold.error - ? { timezone_compliance_refold_error: refold.error } - : {}), - }, - }); } await auditLog("user.timezone.update", { diff --git a/src/app/api/ingest/medication/__tests__/route.test.ts b/src/app/api/ingest/medication/__tests__/route.test.ts index 6d441ff39..421dee241 100644 --- a/src/app/api/ingest/medication/__tests__/route.test.ts +++ b/src/app/api/ingest/medication/__tests__/route.test.ts @@ -38,8 +38,8 @@ vi.mock("@/lib/rate-limit", () => ({ vi.mock("@/lib/app-settings", () => ({ isApiGloballyEnabled: vi.fn(), })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +vi.mock("@/lib/medications/dose-day-refresh", () => ({ + refreshDoseSlot: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/lib/medications/inventory/consumption", () => ({ consumeForIntake: vi.fn().mockResolvedValue(undefined), diff --git a/src/app/api/ingest/medication/route.ts b/src/app/api/ingest/medication/route.ts index 33fc0d326..e91694740 100644 --- a/src/app/api/ingest/medication/route.ts +++ b/src/app/api/ingest/medication/route.ts @@ -23,7 +23,7 @@ import { unstableExternalIdShape, } from "@/lib/validations/external-id"; import { isApiGloballyEnabled } from "@/lib/app-settings"; -import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; +import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh"; import { applyCanonicalSlotWrite, resolveSlotForWriteByBand, @@ -337,7 +337,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // v1.4.39 — refresh the compliance rollup for the ingested // event's user-day. - await recomputeMedicationComplianceForEvent({ + await refreshDoseSlot({ userId: apiToken.userId, medicationId: medication.id, scheduledFor: event.scheduledFor, diff --git a/src/app/api/medications/[id]/__tests__/route.test.ts b/src/app/api/medications/[id]/__tests__/route.test.ts index 0713a9094..8cc13470d 100644 --- a/src/app/api/medications/[id]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/__tests__/route.test.ts @@ -51,9 +51,9 @@ vi.mock("@/lib/cache/invalidate", () => ({ // v1.16.1 — the schedule-replace branch recomputes the compliance // rollups of the tombstoned slots; stub the rollup module so the route // test never reaches `$executeRaw`. -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ +vi.mock("@/lib/medications/dose-day-refresh", () => ({ dayKeyForScheduledFor: vi.fn(() => "2026-06-10"), - recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined), + refreshDoseDay: vi.fn().mockResolvedValue(undefined), })); // v1.40 (#1024) — the course writer and reader run against their own // integration suite; here they answer "no course, nothing refused". @@ -99,8 +99,8 @@ import { import { auditLog } from "@/lib/auth/audit"; import { dayKeyForScheduledFor, - recomputeMedicationComplianceForDay, -} from "@/lib/rollups/medication-compliance-rollups"; + refreshDoseDay, +} from "@/lib/medications/dose-day-refresh"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -297,7 +297,7 @@ describe("PUT /api/medications/[id] — schedule replace archives a revision (v1 vi.mocked(getMedicationCategories).mockResolvedValue({}); vi.mocked(auditLog).mockResolvedValue(undefined); vi.mocked(dayKeyForScheduledFor).mockReturnValue("2026-06-10"); - vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(undefined); + vi.mocked(refreshDoseDay).mockResolvedValue(undefined); vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([ PREVIOUS_ROW, ] as never); @@ -447,19 +447,19 @@ describe("PUT /api/medications/[id] — schedule replace migrates open slots", ( ROUTE_CTX, ); expect(res.status).toBe(200); - expect(recomputeMedicationComplianceForDay).toHaveBeenCalledTimes(2); - expect(recomputeMedicationComplianceForDay).toHaveBeenCalledWith( - "user-1", - "m1", - "2026-06-10", - "Europe/Berlin", - ); - expect(recomputeMedicationComplianceForDay).toHaveBeenCalledWith( - "user-1", - "m1", - "2026-06-11", - "Europe/Berlin", - ); + expect(refreshDoseDay).toHaveBeenCalledTimes(2); + expect(refreshDoseDay).toHaveBeenCalledWith({ + userId: "user-1", + medicationId: "m1", + dayKey: "2026-06-10", + tz: "Europe/Berlin", + }); + expect(refreshDoseDay).toHaveBeenCalledWith({ + userId: "user-1", + medicationId: "m1", + dayKey: "2026-06-11", + tz: "Europe/Berlin", + }); }); it("does NOT touch intake rows when the body carries no schedules array", async () => { @@ -865,7 +865,7 @@ describe("PUT /api/medications/[id] — intake tracking switch (#1033)", () => { vi.mocked(getMedicationCategories).mockResolvedValue({}); vi.mocked(auditLog).mockResolvedValue(undefined); vi.mocked(dayKeyForScheduledFor).mockReturnValue("2026-06-10"); - vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(undefined); + vi.mocked(refreshDoseDay).mockResolvedValue(undefined); vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([ LIVE_ROW, ] as never); @@ -1055,7 +1055,7 @@ describe("PUT /api/medications/[id] — shipped client on a record-only medicati vi.mocked(getMedicationCategories).mockResolvedValue({}); vi.mocked(auditLog).mockResolvedValue(undefined); vi.mocked(dayKeyForScheduledFor).mockReturnValue("2026-06-10"); - vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(undefined); + vi.mocked(refreshDoseDay).mockResolvedValue(undefined); vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([ STORED_ROW, ] as never); diff --git a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts index 9e92263a7..cedb34685 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts @@ -37,8 +37,8 @@ vi.mock("@/lib/medications/inventory/consumption", () => ({ consumeForIntake: vi.fn().mockResolvedValue(undefined), restoreForIntake: vi.fn().mockResolvedValue(undefined), })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +vi.mock("@/lib/medications/dose-day-refresh", () => ({ + refreshDoseSlot: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index 615bbac81..4eaafb54d 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -19,7 +19,7 @@ import { import { reconcileOneShotState } from "@/lib/medications/lifecycle"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; -import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; +import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh"; import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz"; import { applyCanonicalSlotWrite, @@ -392,14 +392,14 @@ export const PUT = apiHandler( // scheduledFor day-keys when the dose moved across a day boundary // (band re-attribution can move it as well as an explicit edit); if the // slot is unchanged the second call collapses to a no-op upsert. - await recomputeMedicationComplianceForEvent({ + await refreshDoseSlot({ userId: user.id, medicationId: id, scheduledFor: event.scheduledFor, tz: user.timezone, }); if (slotMoved) { - await recomputeMedicationComplianceForEvent({ + await refreshDoseSlot({ userId: user.id, medicationId: id, scheduledFor: targetScheduledFor, @@ -488,7 +488,7 @@ export const DELETE = apiHandler( // v1.4.39 — refresh the rollup row for the day the deleted // event sat on. When that day now holds zero events the helper // drops the rollup row entirely. - await recomputeMedicationComplianceForEvent({ + await refreshDoseSlot({ userId: user.id, medicationId: id, scheduledFor: event.scheduledFor, diff --git a/src/app/api/medications/[id]/intake/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/__tests__/route.test.ts index 354ab4780..42b3eaf50 100644 --- a/src/app/api/medications/[id]/intake/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/route.test.ts @@ -78,8 +78,8 @@ vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserMedications: vi.fn(), })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +vi.mock("@/lib/medications/dose-day-refresh", () => ({ + refreshDoseSlot: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); diff --git a/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts b/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts index 053deba20..759d6ff64 100644 --- a/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts @@ -86,8 +86,8 @@ vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserMedications: vi.fn(), })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +vi.mock("@/lib/medications/dose-day-refresh", () => ({ + refreshDoseSlot: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/lib/notifications/medication-intake-sync", () => ({ diff --git a/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts index eb99f97bc..a42c9fa5b 100644 --- a/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts @@ -46,12 +46,12 @@ vi.mock("@/lib/rate-limit", () => ({ rateLimitHeaders: vi.fn(() => ({ "X-RateLimit-Remaining": "0" })), })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ +vi.mock("@/lib/medications/dose-day-refresh", () => ({ dayKeyForScheduledFor: vi.fn((d: Date) => { const iso = d.toISOString(); return iso.slice(0, 10); }), - recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined), + refreshDoseDay: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/lib/medications/inventory/consumption", () => ({ @@ -98,8 +98,8 @@ import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { checkRateLimit } from "@/lib/rate-limit"; import { dayKeyForScheduledFor, - recomputeMedicationComplianceForDay, -} from "@/lib/rollups/medication-compliance-rollups"; + refreshDoseDay, +} from "@/lib/medications/dose-day-refresh"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -174,7 +174,7 @@ describe("POST /api/medications/[id]/intake/bulk-delete", () => { const json = await res.json(); expect(json.data).toEqual({ deleted: 0 }); expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled(); - expect(recomputeMedicationComplianceForDay).not.toHaveBeenCalled(); + expect(refreshDoseDay).not.toHaveBeenCalled(); // (#22) — a no-op delete queues no cross-device sync fan-out. expect(queueMedicationIntakeSync).not.toHaveBeenCalled(); }); @@ -201,7 +201,7 @@ describe("POST /api/medications/[id]/intake/bulk-delete", () => { expect(dayKeyForScheduledFor).toHaveBeenCalledTimes(3); // Three rows, two unique dayKeys → two recompute calls. - expect(recomputeMedicationComplianceForDay).toHaveBeenCalledTimes(2); + expect(refreshDoseDay).toHaveBeenCalledTimes(2); // (#22) — the whole bulk delete queues exactly ONE cross-device // sync fan-out, not one per removed row. diff --git a/src/app/api/medications/[id]/intake/bulk-delete/route.ts b/src/app/api/medications/[id]/intake/bulk-delete/route.ts index 948db196b..75b0c5f98 100644 --- a/src/app/api/medications/[id]/intake/bulk-delete/route.ts +++ b/src/app/api/medications/[id]/intake/bulk-delete/route.ts @@ -15,8 +15,8 @@ import { restoreForIntake } from "@/lib/medications/inventory/consumption"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { dayKeyForScheduledFor, - recomputeMedicationComplianceForDay, -} from "@/lib/rollups/medication-compliance-rollups"; + refreshDoseDay, +} from "@/lib/medications/dose-day-refresh"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { NextRequest } from "next/server"; @@ -159,26 +159,11 @@ export const POST = apiHandler( }, }); - // Best-effort rollup refresh — one trip per unique dayKey so a - // 14-row delete spanning two days closes in two SQL trips. The - // helper is idempotent and the catch swallows transient - // populator failures (same contract as the single-event delete). + // Best-effort dose-day refresh, one per unique dayKey, so a 14-row + // delete spanning two days refreshes two days (same contract as the + // single-event delete). for (const dayKey of dayKeysToRefresh) { - try { - await recomputeMedicationComplianceForDay(user.id, id, dayKey, tz); - } catch (err) { - annotate({ - action: { - name: "medications.intake.bulk-delete.recompute-failed", - entity_type: "medication", - entity_id: id, - }, - meta: { - day_key: dayKey, - error: err instanceof Error ? err.message : String(err), - }, - }); - } + await refreshDoseDay({ userId: user.id, medicationId: id, dayKey, tz }); } await auditLog("medication.intake.bulk_delete", { diff --git a/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts index 3af9531b4..06e1dd824 100644 --- a/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts @@ -2,9 +2,9 @@ * v1.5.5 — purge route fires the medication cache bundle. * * Pins the contract the detail-page Verlauf-löschen cascade depends - * on: dropping the rollup rows is necessary but not sufficient. The + * on: tombstoning the rows is necessary but not sufficient. The * analytics + iOS today-tally + dashboard tiles all read off cached - * shapes that survive the rollup delete for their TTL; the bundle + * shapes that survive the tombstoning for their TTL; the bundle * invalidation forces every downstream reader to converge on the * post-purge counts in the same tick the user sees the toast. */ @@ -16,7 +16,6 @@ vi.mock("@/lib/db", () => ({ prisma: { medication: { findUnique: vi.fn() }, medicationIntakeEvent: { updateMany: vi.fn() }, - medicationComplianceRollup: { deleteMany: vi.fn() }, // v1.43 — no composition, so no supplement recompute is queued. medicationIngredient: { findFirst: vi.fn().mockResolvedValue(null) }, }, @@ -87,9 +86,6 @@ beforeEach(() => { vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({ count: 7, } as never); - vi.mocked(prisma.medicationComplianceRollup.deleteMany).mockResolvedValue({ - count: 5, - } as never); }); describe("DELETE /api/medications/[id]/intake/purge", () => { diff --git a/src/app/api/medications/[id]/intake/purge/route.ts b/src/app/api/medications/[id]/intake/purge/route.ts index 2885701d7..1737eac0c 100644 --- a/src/app/api/medications/[id]/intake/purge/route.ts +++ b/src/app/api/medications/[id]/intake/purge/route.ts @@ -17,8 +17,8 @@ type RouteParams = { params: Promise<{ id: string }> }; export const DELETE = apiHandler( async (request: NextRequest, { params }: RouteParams) => { // v1.37.0 — MANAGE. It tombstones rather than deletes (since the - // delegated-write release) and drops only rollups that recompute, so the - // history it clears is reconstructable from the rows themselves. + // delegated-write release), so the history it clears is reconstructable + // from the rows themselves. const { user } = await requireRecordAuth("manage", "medications"); const { id } = await params; @@ -60,13 +60,6 @@ export const DELETE = apiHandler( data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, }); - // v1.4.39 — drop every rollup row for this medication so the - // next compliance read returns zero-filled buckets rather than the - // last-known scheduled / taken totals for now-deleted events. - await prisma.medicationComplianceRollup.deleteMany({ - where: { medicationId: id, userId: user.id }, - }); - // v1.43 — the purged doses no longer count toward the micronutrients. if (await medicationHasComposition(user.id, id)) { await enqueueSupplementNutrientRecompute(user.id); diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index 0ecd6c9d1..5ea078dba 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -36,7 +36,7 @@ import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear"; import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses"; import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake"; -import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; +import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh"; import { applyCanonicalSlotWrite, findPinConflict, @@ -608,7 +608,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { // v1.4.39 — refresh the persistent compliance rollup for the // affected day. The hook is best-effort; failures annotate but never // block the user's POST response. - await recomputeMedicationComplianceForEvent({ + await refreshDoseSlot({ userId: user.id, medicationId: id, scheduledFor: event.scheduledFor, diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index a1821e376..e711a713d 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -48,8 +48,8 @@ import { } from "@/lib/medications/scheduling/next-due"; import { dayKeyForScheduledFor, - recomputeMedicationComplianceForDay, -} from "@/lib/rollups/medication-compliance-rollups"; + refreshDoseDay, +} from "@/lib/medications/dose-day-refresh"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { effectiveUnitsPerDose, @@ -716,9 +716,9 @@ export const PUT = apiHandler( // Tombstone today's and future open pending rows of this medication. // Runs on a schedule replace (the old anchors are stale) and when intake // tracking is switched off (nothing is due any more). - // The rollup days the tombstoned slots sat on, recomputed once the + // The dose days the tombstoned slots sat on, refreshed once the // transaction below has committed the tombstones. - const staleRollupDays = new Set(); + const staleDoseDays = new Set(); const tombstoneOpenSlots = async ( db: Prisma.TransactionClient, ): Promise => { @@ -768,7 +768,7 @@ export const PUT = apiHandler( }); // Deduped via the user-tz day key. for (const row of tombstoned) { - staleRollupDays.add(dayKeyForScheduledFor(row.scheduledFor, userTz)); + staleDoseDays.add(dayKeyForScheduledFor(row.scheduledFor, userTz)); } }; @@ -1085,26 +1085,16 @@ export const PUT = apiHandler( throw err; } - // Recompute each rollup day the tombstoned slots sat on, now that the - // tombstones are committed. Best-effort like every other rollup - // write-hook: a failure is annotated, never fails the edit. - for (const dayKey of staleRollupDays) { - try { - await recomputeMedicationComplianceForDay( - user.id, - id, - dayKey, - user.timezone || DEFAULT_TIMEZONE, - ); - } catch (err) { - annotate({ - meta: { - medication_compliance_rollup_failed: true, - medication_compliance_rollup_error: - err instanceof Error ? err.message : String(err), - }, - }); - } + // Refresh each dose day the tombstoned slots sat on, now that the + // tombstones are committed. Best-effort like every dose write-hook: a + // failure is reported, never fails the edit. + for (const dayKey of staleDoseDays) { + await refreshDoseDay({ + userId: user.id, + medicationId: id, + dayKey, + tz: user.timezone || DEFAULT_TIMEZONE, + }); } // Apply the v1.5.5 primary-schedule grace bridge after the diff --git a/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts b/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts index e5d2109ff..afe537197 100644 --- a/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts +++ b/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts @@ -45,7 +45,6 @@ import { toRevisionPayloadEntry, type ScheduleRevisionEntry, } from "@/lib/medications/scheduling/schedule-eras"; -import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import type { Prisma } from "@/generated/prisma/client"; @@ -286,13 +285,10 @@ export const PATCH = apiHandler( }, }); - // The corrected era re-segments history; refresh the pre-aggregated - // compliance rollups asynchronously (best-effort). // v1.16.9 — an era write re-segments the bands every cached payload // (list next-due, compliance cells, dashboard tally) was built on; // hard-evict so the next read reflects the new history immediately. invalidateUserMedications(user.id, { evict: true }); - await enqueueUserMedicationComplianceBackfill(user.id); return apiSuccess({ id: outcome.revision.id, @@ -404,13 +400,10 @@ export const DELETE = apiHandler( meta: { revision_id: revisionId }, }); - // History re-segments without the era; refresh the pre-aggregated - // compliance rollups asynchronously (best-effort). // v1.16.9 — an era write re-segments the bands every cached payload // (list next-due, compliance cells, dashboard tally) was built on; // hard-evict so the next read reflects the new history immediately. invalidateUserMedications(user.id, { evict: true }); - await enqueueUserMedicationComplianceBackfill(user.id); return apiSuccess({ deleted: true }); }, diff --git a/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts b/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts index 93986e9e1..a54c4b903 100644 --- a/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts @@ -42,11 +42,6 @@ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - enqueueUserMedicationComplianceBackfill: vi - .fn() - .mockResolvedValue({ enqueued: true, error: null }), -})); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), @@ -64,7 +59,6 @@ import { GET, POST } from "../route"; import { DELETE, PATCH } from "../[revisionId]/route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; -import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -349,11 +343,6 @@ describe("POST /api/medications/[id]/schedule-revisions", () => { expect(createArgs.data.payload[0].rrule).toBe("FREQ=DAILY"); expect(createArgs.data.payload[0].scheduleType).toBe("SCHEDULED"); - // History re-segmented — the rollup backfill must be enqueued. - expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith( - "user-1", - ); - // Check-then-insert ran inside the transaction with the // per-medication `FOR UPDATE` lock taken first. expect(prisma.$transaction).toHaveBeenCalled(); @@ -432,9 +421,6 @@ describe("DELETE /api/medications/[id]/schedule-revisions/[revisionId]", () => { where: { medicationId: "m1", supersededByRevisionId: "rev-m" }, data: { supersededByRevisionId: null }, }); - expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith( - "user-1", - ); }); }); @@ -533,9 +519,6 @@ describe("PATCH /api/medications/[id]/schedule-revisions/[revisionId]", () => { expect(updateArgs.data.payload[0].rrule).toBe("FREQ=DAILY"); // In-place — no superseding row is minted. expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled(); - expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith( - "user-1", - ); // Check-then-write ran inside the locked transaction. expect(prisma.$transaction).toHaveBeenCalled(); expect(prisma.$queryRaw).toHaveBeenCalled(); @@ -573,8 +556,5 @@ describe("PATCH /api/medications/[id]/schedule-revisions/[revisionId]", () => { where: { id: "rev-t" }, data: { supersededByRevisionId: "rev-fix" }, }); - expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith( - "user-1", - ); }); }); diff --git a/src/app/api/medications/[id]/schedule-revisions/route.ts b/src/app/api/medications/[id]/schedule-revisions/route.ts index 20994aa84..8f27bb9c6 100644 --- a/src/app/api/medications/[id]/schedule-revisions/route.ts +++ b/src/app/api/medications/[id]/schedule-revisions/route.ts @@ -40,7 +40,6 @@ import { toRevisionPayloadEntry, type ScheduleRevisionEntry, } from "@/lib/medications/scheduling/schedule-eras"; -import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import type { Prisma } from "@/generated/prisma/client"; @@ -282,14 +281,10 @@ export const POST = apiHandler( }, }); - // The new era re-segments history the compliance rollups already - // pre-aggregated; refresh them asynchronously. Best-effort like - // every other rollup write-hook. // v1.16.9 — an era write re-segments the bands every cached payload // (list next-due, compliance cells, dashboard tally) was built on; // hard-evict so the next read reflects the new history immediately. invalidateUserMedications(user.id, { evict: true }); - await enqueueUserMedicationComplianceBackfill(user.id); return apiSuccess( { diff --git a/src/app/api/medications/intake/__tests__/route.test.ts b/src/app/api/medications/intake/__tests__/route.test.ts index c1ff368b0..87d35347f 100644 --- a/src/app/api/medications/intake/__tests__/route.test.ts +++ b/src/app/api/medications/intake/__tests__/route.test.ts @@ -34,12 +34,6 @@ vi.mock("@/lib/db", () => ({ // v1.15.18 — the band resolver loads the medication via findFirst. findFirst: vi.fn(), }, - medicationComplianceRollup: { - findFirst: vi.fn(), - findMany: vi.fn(), - upsert: vi.fn(), - deleteMany: vi.fn(), - }, // v1.4.43 — audit-ledger breadcrumb for validation-failed paths. auditLog: { create: vi.fn() }, $transaction: vi.fn(), @@ -119,15 +113,6 @@ beforeEach(() => { vi.mocked(prisma.medicationIntakeEvent.createMany).mockResolvedValue({ count: 0, } as never); - // v1.4.39 — default the coverage probe to "uncovered" so the - // legacy compliance test still exercises the live-fallback branch - // and finds the legacy mocked intake events. - vi.mocked(prisma.medicationComplianceRollup.findFirst).mockResolvedValue( - null, - ); - vi.mocked(prisma.medicationComplianceRollup.findMany).mockResolvedValue( - [] as never, - ); // v1.4.39 — the coverage probe is now a single `$queryRaw` // aggregate returning `{ rolled_days, event_days }`. Default to // "zero rollups, zero events" (covered/trivial-empty) so tests that diff --git a/src/app/api/medications/intake/bulk/__tests__/route.test.ts b/src/app/api/medications/intake/bulk/__tests__/route.test.ts index f72e787a3..bd530c53c 100644 --- a/src/app/api/medications/intake/bulk/__tests__/route.test.ts +++ b/src/app/api/medications/intake/bulk/__tests__/route.test.ts @@ -51,8 +51,8 @@ vi.mock("@/lib/medications/inventory/consumption", () => ({ consumeForIntake: vi.fn().mockResolvedValue(undefined), restoreForIntake: vi.fn().mockResolvedValue(undefined), })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined), +vi.mock("@/lib/medications/dose-day-refresh", () => ({ + refreshDoseDay: vi.fn().mockResolvedValue(undefined), dayKeyForScheduledFor: vi.fn().mockReturnValue("2026-01-01"), })); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 7b9847940..8e8e244bb 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -65,9 +65,9 @@ import { } from "@/lib/integrations/healthkit-type-sync"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { - recomputeMedicationComplianceForDay, dayKeyForScheduledFor, -} from "@/lib/rollups/medication-compliance-rollups"; + refreshDoseDay, +} from "@/lib/medications/dose-day-refresh"; import { applyCanonicalSlotWrite, findPinConflict, @@ -365,10 +365,9 @@ async function postBulk(request: NextRequest): Promise { let extraDosesUnscheduled = 0; const skipped: Array<{ index: number; reason: string }> = []; const unstableShapes: UnstableExternalIdShape[] = []; - // v1.4.39 — collect distinct `(medicationId, dayKey)` pairs - // touched by the batch so one rollup recompute fires per pair after - // all inserts complete. Per-row recompute would balloon a 500-entry - // batch into 500 sequential rollup hits. + // Distinct `(medicationId, dayKey)` pairs touched by the batch, so one + // dose-day refresh fires per pair after all inserts complete. A per-row + // refresh would balloon a 500-entry batch into 500 sequential refreshes. const touchedDays = new Map< string, { medicationId: string; dayKey: string } @@ -949,30 +948,16 @@ async function postBulk(request: NextRequest): Promise { invalidateUserMedications(user.id, { evict: true }); } - // v1.4.39 — refresh one rollup row per distinct - // `(medicationId, dayKey)` touched by the batch. Best-effort: failures - // are swallowed by the helper's `recomputeMedicationComplianceForEvent` - // wrapper, but bulk callers wrap recompute directly to keep the - // tight ingest loop unaffected by populator issues. + // Refresh the derived rows of each distinct `(medicationId, dayKey)` the + // batch touched. Best-effort: the refresh reports its own failures and + // never fails the ingest. for (const { medicationId, dayKey } of touchedDays.values()) { - try { - await recomputeMedicationComplianceForDay( - user.id, - medicationId, - dayKey, - user.timezone, - ); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - annotate({ - meta: { - medication_compliance_rollup_bulk_failed: true, - medication_compliance_rollup_bulk_error: message, - medication_compliance_rollup_medication: medicationId, - medication_compliance_rollup_day: dayKey, - }, - }); - } + await refreshDoseDay({ + userId: user.id, + medicationId, + dayKey, + tz: user.timezone, + }); } // v1.17.1 (#22) — silent cross-device intake sync. ONE queued fan-out diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 98d92f114..89881c390 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -28,7 +28,7 @@ import { auditLog } from "@/lib/auth/audit"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; -import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; +import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh"; import { buildScheduleAnchoredComplianceBuckets, type ScheduleAnchoredComplianceBucket, @@ -573,7 +573,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // the affected day so the next read after the cache miss returns // the up-to-date `(scheduled, taken, skipped)` tuple. Best-effort: // a populator failure annotates ops without blocking the response. - await recomputeMedicationComplianceForEvent({ + await refreshDoseSlot({ userId: user.id, medicationId: existing.medicationId, scheduledFor: existing.scheduledFor, @@ -584,7 +584,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // source day self-corrects while the destination keeps its stale tuple. const movedTo = updated?.scheduledFor; if (movedTo && movedTo.getTime() !== existing.scheduledFor.getTime()) { - await recomputeMedicationComplianceForEvent({ + await refreshDoseSlot({ userId: user.id, medicationId: existing.medicationId, scheduledFor: movedTo, diff --git a/src/app/api/telegram/webhook/__tests__/route.test.ts b/src/app/api/telegram/webhook/__tests__/route.test.ts index 0516e8480..b9ccdb6ab 100644 --- a/src/app/api/telegram/webhook/__tests__/route.test.ts +++ b/src/app/api/telegram/webhook/__tests__/route.test.ts @@ -93,8 +93,8 @@ vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserMedications: vi.fn(), })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +vi.mock("@/lib/medications/dose-day-refresh", () => ({ + refreshDoseSlot: vi.fn().mockResolvedValue(undefined), })); import { POST, GET } from "../route"; diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 447f8f180..436278879 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -75,7 +75,6 @@ export const WIPE_MODELS = [ "MedicationSideEffect", "MedicationInventoryItem", "MedicationIntakeImportJob", - "MedicationComplianceRollup", // The account's own category names (v1.40, #1041). No child rows; the // medications that name one go with their own entry. "MedicationCategoryLabel", diff --git a/src/lib/export/__tests__/restore-backup-owner-mismatch.test.ts b/src/lib/export/__tests__/restore-backup-owner-mismatch.test.ts index f23c35dea..99735016f 100644 --- a/src/lib/export/__tests__/restore-backup-owner-mismatch.test.ts +++ b/src/lib/export/__tests__/restore-backup-owner-mismatch.test.ts @@ -44,7 +44,7 @@ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); vi.mock("@/lib/rollups/mood-rollups", () => ({ recomputeUserMoodRollups: vi.fn(), })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ +vi.mock("@/lib/medications/dose-day-refresh", () => ({ recomputeUserMedicationCompliance: vi.fn(), MEDICATION_COMPLIANCE_BACKFILL_DAYS: 30, })); diff --git a/src/lib/export/__tests__/restore-backup-round-trip.test.ts b/src/lib/export/__tests__/restore-backup-round-trip.test.ts index bffce80d4..fc61a2cbe 100644 --- a/src/lib/export/__tests__/restore-backup-round-trip.test.ts +++ b/src/lib/export/__tests__/restore-backup-round-trip.test.ts @@ -66,7 +66,7 @@ vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserData: vi.fn() })); vi.mock("@/lib/rollups/mood-rollups", () => ({ recomputeUserMoodRollups: vi.fn(), })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ +vi.mock("@/lib/medications/dose-day-refresh", () => ({ recomputeUserMedicationCompliance: vi.fn(), MEDICATION_COMPLIANCE_BACKFILL_DAYS: 30, })); diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 91e5dc74c..b3c9ddef3 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -655,8 +655,6 @@ export const COVERAGE_PENDING: Readonly> = { export const DERIVED_MODELS: Readonly> = { MeasurementRollup: "A per-day, per-metric reconstruction of `Measurement`. The boot-time rollup backfill rebuilds it for an account that has none.", - MedicationComplianceRollup: - "Recomputed from `MedicationIntakeEvent` against the schedule by the compliance analytics tier.", MoodEntryRollup: "A per-day reconstruction of `MoodEntry`, rebuilt by the same rollup tier that rebuilds the measurement one.", MoodPrediction: diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index 12387f67f..5b6c12e94 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -80,10 +80,6 @@ import { type BackupSummary, } from "@/lib/validations/backup-summary"; import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups"; -import { - recomputeUserMedicationCompliance, - MEDICATION_COMPLIANCE_BACKFILL_DAYS, -} from "@/lib/rollups/medication-compliance-rollups"; import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups"; import { restoreCycleData } from "@/lib/cycle/backup"; import { @@ -3058,33 +3054,6 @@ export async function restoreBackup( } } - // v1.4.39 — re-fold the medication-compliance rollup tier - // from the restored intake events. The medication delete inside the - // transaction already cascaded the existing rollup partition for - // this owner via the FK, so the fold mints fresh rows. Boot-time - // backfill is the safety net if this best-effort call fails. - if (payload.intakeEvents.length > 0) { - try { - const restoreUser = await prisma.user.findUnique({ - where: { id: ownerId }, - select: { timezone: true }, - }); - await recomputeUserMedicationCompliance( - ownerId, - MEDICATION_COMPLIANCE_BACKFILL_DAYS, - restoreUser?.timezone ?? null, - ); - } catch (err) { - annotate({ - meta: { - medication_compliance_rollup_restore_failed: true, - medication_compliance_rollup_restore_error: - err instanceof Error ? err.message : String(err), - }, - }); - } - } - const summary = { ...summarizeBackup(payload), measurements: measurementsRestored, diff --git a/src/lib/jobs/__tests__/intake-auto-take.test.ts b/src/lib/jobs/__tests__/intake-auto-take.test.ts index dbcb3e0a9..db93d7c97 100644 --- a/src/lib/jobs/__tests__/intake-auto-take.test.ts +++ b/src/lib/jobs/__tests__/intake-auto-take.test.ts @@ -20,8 +20,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@/lib/medications/inventory/consumption", () => ({ consumeForIntake: vi.fn(async () => undefined), })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - recomputeMedicationComplianceForEvent: vi.fn(async () => undefined), +vi.mock("@/lib/medications/dose-day-refresh", () => ({ + refreshDoseSlot: vi.fn(async () => undefined), dayKeyForScheduledFor: (at: Date, tz: string) => at.toLocaleDateString("sv-SE", { timeZone: tz }), })); @@ -36,7 +36,7 @@ vi.mock("@/lib/medications/lifecycle", () => ({ })); import { consumeForIntake } from "@/lib/medications/inventory/consumption"; -import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; +import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh"; import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { INTAKE_AUTO_TAKE_CRON, @@ -590,7 +590,7 @@ function row(partial: Partial & { id: string; scheduledFor: Date }) { describe("runIntakeAutoTakePass — over a fake client", () => { beforeEach(() => { vi.mocked(consumeForIntake).mockClear(); - vi.mocked(recomputeMedicationComplianceForEvent).mockClear(); + vi.mocked(refreshDoseSlot).mockClear(); vi.mocked(queueMedicationIntakeSync).mockClear(); }); diff --git a/src/lib/jobs/__tests__/medication-intake-import.test.ts b/src/lib/jobs/__tests__/medication-intake-import.test.ts index fae356cd7..671fcf8d4 100644 --- a/src/lib/jobs/__tests__/medication-intake-import.test.ts +++ b/src/lib/jobs/__tests__/medication-intake-import.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest"; import { MEDICATION_IMPORT_SKIP_DETAIL_LIMIT, MEDICATION_INTAKE_IMPORT_CHUNK_SIZE, - MEDICATION_INTAKE_IMPORT_ROLLUP_CHUNK_SIZE, _setMedicationIntakeImportPrismaForTests, advanceMedicationImportProgress, appendMedicationImportSkipDetails, @@ -48,21 +47,18 @@ describe("medication intake import worker bounds", () => { processed: 100, imported: 98, skippedByReason: { duplicate_in_file: 2 }, - touchedDays: ["2026-07-20", "2026-07-21"], }); const second = advanceMedicationImportProgress(first, { from: 100, processed: 100, imported: 97, skippedByReason: { duplicate_in_file: 1, already_recorded: 2 }, - touchedDays: ["2026-07-21", "2026-07-22"], }); const replay = advanceMedicationImportProgress(second, { from: 0, processed: 100, imported: 98, skippedByReason: { duplicate_in_file: 2 }, - touchedDays: ["2026-07-20"], }); expect(first.processed).toBe(100); @@ -71,7 +67,7 @@ describe("medication intake import worker bounds", () => { total: 1_000, imported: 195, skippedByReason: { duplicate_in_file: 3, already_recorded: 2 }, - touchedDays: ["2026-07-20", "2026-07-21", "2026-07-22"], + touchedDays: [], rollupProcessed: 0, }); expect(replay).toBe(second); @@ -89,7 +85,11 @@ describe("medication intake import worker bounds", () => { expect(failure.length).toBeLessThanOrEqual(1_000); }); - it("checkpoints bounded rollup transactions across many touched days", async () => { + it("finishes a job admitted with pending rollup days without recomputing them", async () => { + // A job admitted before the compliance rollup was dropped carries the + // days it touched and how many of them were recomputed. It now finishes + // on its next pass, writes nothing per day, and keeps its stored + // progress shape. const touchedDays = Array.from({ length: 1_000 }, (_, index) => new Date(Date.UTC(2020, 0, index + 1)).toISOString().slice(0, 10), ); @@ -99,32 +99,27 @@ describe("medication intake import worker bounds", () => { imported: 0, skippedByReason: {}, touchedDays, + rollupProcessed: 40, }; let status = "running"; - const executeRawCounts: number[] = []; + let executeRawCount = 0; + let transactions = 0; let auditCount = 0; - let transactionNumber = 0; - let rejectRollup = true; const client = { $transaction: async ( operation: (tx: Record) => Promise, ) => { - transactionNumber += 1; - let executeRawCount = 0; + transactions += 1; const tx = { - $queryRaw: async () => [{ id: "job-many-days" }], + $queryRaw: async () => [{ id: "job-legacy-days" }], $executeRaw: async () => { executeRawCount += 1; - if (rejectRollup && transactionNumber === 3) { - rejectRollup = false; - throw new Error("rollup transaction failed"); - } return 1; }, medicationIntakeImportJob: { findUnique: async () => ({ - id: "job-many-days", + id: "job-legacy-days", recordUserId: "user-1", actorUserId: "user-1", medicationId: "medication-1", @@ -132,7 +127,6 @@ describe("medication intake import worker bounds", () => { payload: { entries: [] }, progress: persistedProgress, startedAt: new Date(), - recordUser: { timezone: "UTC" }, }), update: async (args: { data: { progress?: unknown; status?: string }; @@ -151,43 +145,25 @@ describe("medication intake import worker bounds", () => { }, }, }; - try { - return await operation(tx); - } finally { - executeRawCounts.push(executeRawCount); - } + return operation(tx); }, }; _setMedicationIntakeImportPrismaForTests(client as never); try { - await expect( - processMedicationIntakeImportJob("job-many-days"), - ).rejects.toThrow("rollup transaction failed"); - expect(persistedProgress).toMatchObject({ - rollupProcessed: MEDICATION_INTAKE_IMPORT_ROLLUP_CHUNK_SIZE * 2, - }); - expect(status).toBe("running"); - expect(auditCount).toBe(0); - - await processMedicationIntakeImportJob("job-many-days"); + await processMedicationIntakeImportJob("job-legacy-days"); } finally { _setMedicationIntakeImportPrismaForTests(null); } - expect(executeRawCounts).toHaveLength( - Math.ceil( - touchedDays.length / MEDICATION_INTAKE_IMPORT_ROLLUP_CHUNK_SIZE, - ) + 1, - ); - expect(Math.max(...executeRawCounts)).toBeLessThanOrEqual( - MEDICATION_INTAKE_IMPORT_ROLLUP_CHUNK_SIZE * 2, - ); expect(status).toBe("done"); - expect(persistedProgress).toMatchObject({ - rollupProcessed: touchedDays.length, - }); + expect(transactions).toBe(1); + expect(executeRawCount).toBe(0); expect(auditCount).toBe(1); + expect(persistedProgress).toMatchObject({ + touchedDays, + rollupProcessed: 40, + }); }); }); @@ -229,14 +205,12 @@ describe("medication import skip reporting (#650)", () => { processed: 100, imported: 90, skippedByReason: { duplicate_in_file: 10 }, - touchedDays: [], }); const second = advanceMedicationImportProgress(first, { from: 100, processed: 100, imported: 95, skippedByReason: { already_recorded: 5 }, - touchedDays: [], }); expect(second.skippedByReason).toEqual({ diff --git a/src/lib/jobs/__tests__/medication-reminder-check.test.ts b/src/lib/jobs/__tests__/medication-reminder-check.test.ts index 0f09aeb47..f4a9fa453 100644 --- a/src/lib/jobs/__tests__/medication-reminder-check.test.ts +++ b/src/lib/jobs/__tests__/medication-reminder-check.test.ts @@ -99,9 +99,6 @@ vi.mock("@/lib/notifications/dispatcher", () => ({ })); vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn(() => "token") })); vi.mock("@/lib/telegram", () => ({ deleteMessage: vi.fn() })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), -})); vi.mock("@/lib/notifications/reminder-dedup", () => ({ claimNotificationEvent: vi.fn().mockResolvedValue(true), medicationReminderDedupKey: vi.fn(() => "dedup-key"), diff --git a/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts b/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts index 914e854f5..75f250ab7 100644 --- a/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts +++ b/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts @@ -124,9 +124,6 @@ vi.mock("@/lib/notifications/dispatcher", () => ({ })); vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn(() => "token") })); vi.mock("@/lib/telegram", () => ({ deleteMessage: vi.fn() })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), -})); import { handleReminderCheck } from "@/lib/jobs/reminder/medication-reminder-check"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; diff --git a/src/lib/jobs/__tests__/queue-policy-tables.test.ts b/src/lib/jobs/__tests__/queue-policy-tables.test.ts index 6e3232928..128c73dad 100644 --- a/src/lib/jobs/__tests__/queue-policy-tables.test.ts +++ b/src/lib/jobs/__tests__/queue-policy-tables.test.ts @@ -60,7 +60,6 @@ describe("rollup registrar queue policies", () => { it.each([ ["ROLLUP_FULL_BACKFILL_QUEUE", "exclusive"], ["MOOD_ROLLUP_FULL_BACKFILL_QUEUE", "exclusive"], - ["MEDICATION_COMPLIANCE_BACKFILL_QUEUE", "exclusive"], ["STEP_CONSOLIDATION_QUEUE", "exclusive"], ["STEP_CONSOLIDATION_REPAIR_QUEUE", "exclusive"], ["CUMULATIVE_PR_REDERIVE_QUEUE", "exclusive"], diff --git a/src/lib/jobs/intake-auto-take.ts b/src/lib/jobs/intake-auto-take.ts index 6915edc2c..e26a42dca 100644 --- a/src/lib/jobs/intake-auto-take.ts +++ b/src/lib/jobs/intake-auto-take.ts @@ -85,8 +85,8 @@ import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake import { isP2002 } from "@/lib/prisma-errors"; import { dayKeyForScheduledFor, - recomputeMedicationComplianceForEvent, -} from "@/lib/rollups/medication-compliance-rollups"; + refreshDoseSlot, +} from "@/lib/medications/dose-day-refresh"; import { DEFAULT_TIMEZONE, validTimezoneOr } from "@/lib/tz/format"; import { getEvent } from "@/lib/logging/context"; @@ -375,7 +375,7 @@ async function autoTakeOne( const dayKey = dayKeyForScheduledFor(slot.at, userTz); if (days.has(dayKey)) continue; days.add(dayKey); - await recomputeMedicationComplianceForEvent({ + await refreshDoseSlot({ userId: medication.userId, medicationId: medication.id, scheduledFor: slot.at, diff --git a/src/lib/jobs/medication-intake-import.ts b/src/lib/jobs/medication-intake-import.ts index 16e1d80fc..3fda3d1b2 100644 --- a/src/lib/jobs/medication-intake-import.ts +++ b/src/lib/jobs/medication-intake-import.ts @@ -9,15 +9,10 @@ import { jobBudget } from "@/lib/jobs/job-budget"; import { enqueueSupplementNutrientRecompute } from "@/lib/medications/ingredients/recompute-job"; import { consumeImportedIntakesBatch } from "@/lib/medications/inventory/consumption"; import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; -import { - dayKeyForScheduledFor, - recomputeMedicationComplianceForDay, -} from "@/lib/rollups/medication-compliance-rollups"; export const MEDICATION_INTAKE_IMPORT_QUEUE = "medication-intake-import"; export const MEDICATION_INTAKE_IMPORT_CONCURRENCY = 2; export const MEDICATION_INTAKE_IMPORT_CHUNK_SIZE = 100; -export const MEDICATION_INTAKE_IMPORT_ROLLUP_CHUNK_SIZE = 20; export const MEDICATION_INTAKE_IMPORT_STALE_AFTER_MS = 15 * 60_000; export const MEDICATION_INTAKE_IMPORT_SEND_OPTIONS = { @@ -136,6 +131,11 @@ export interface MedicationImportProgress { skipDetails?: MedicationImportSkipDetail[]; /** Details beyond the hard cap, counted without retaining their contents. */ skippedDetailsOmitted?: number; + /** + * Wire fields of the dropped compliance rollup's recompute phase. A job + * admitted now carries `[]` and `0` and the worker never advances them; + * they stay on the published progress shape for clients that decode it. + */ touchedDays: string[]; rollupProcessed: number; } @@ -218,7 +218,6 @@ export interface MedicationImportChunkResult { imported: number; skippedByReason: MedicationImportSkipCounts; skipDetails?: MedicationImportSkipDetail[]; - touchedDays: string[]; } let workerPrismaSingleton: PrismaClient | null = null; @@ -251,15 +250,6 @@ export function advanceMedicationImportProgress( previous.total, previous.processed + Math.max(0, Math.trunc(chunk.processed)), ); - const touchedDays = [...previous.touchedDays]; - const seenDays = new Set(touchedDays); - for (const day of chunk.touchedDays) { - if (!seenDays.has(day)) { - seenDays.add(day); - touchedDays.push(day); - } - } - const skippedByReason: MedicationImportSkipCounts = { ...previous.skippedByReason, }; @@ -293,7 +283,7 @@ export function advanceMedicationImportProgress( } : {}), rollupProcessed: previous.rollupProcessed, - touchedDays, + touchedDays: previous.touchedDays, }; } @@ -503,67 +493,6 @@ interface ProcessChunkOutcome { userId: string | null; } -/** - * A day whose compliance rollup an import invalidated, as one string. - * - * A job can now span a whole regimen, so a bare day key no longer identifies - * what to recompute — two medications touched on the same day are two rollup - * rows. The medication is folded in ahead of a single space, which occurs in - * neither a cuid nor a `YYYY-MM-DD` key. Not a NUL byte: this value is persisted - * in the progress blob and Postgres rejects a NUL byte inside a jsonb string. - */ -const TOUCHED_DAY_SEPARATOR = " "; - -function encodeTouchedDay(medicationId: string, dayKey: string): string { - return `${medicationId}${TOUCHED_DAY_SEPARATOR}${dayKey}`; -} - -/** - * Split a stored touched-day back into its parts. - * - * A value with no separator was written before a job could span more than one - * medication, so it is a bare day key belonging to the job's own medication. - * Returns null when neither reading yields a medication — a per-medication job - * that lost its medication cannot be resolved and the day is left alone rather - * than recomputed against a guess. - */ -function decodeTouchedDay( - value: string, - fallbackMedicationId: string | null, -): { medicationId: string; dayKey: string } | null { - const separator = value.indexOf(TOUCHED_DAY_SEPARATOR); - if (separator === -1) { - if (!fallbackMedicationId) return null; - return { medicationId: fallbackMedicationId, dayKey: value }; - } - const medicationId = value.slice(0, separator); - const dayKey = value.slice(separator + 1); - if (medicationId.length === 0 || dayKey.length === 0) return null; - return { medicationId, dayKey }; -} - -async function recomputeTouchedDays( - client: Pick, - userId: string, - jobMedicationId: string | null, - timezone: string, - days: readonly string[], -): Promise { - await Promise.all( - days.map((day) => { - const decoded = decodeTouchedDay(day, jobMedicationId); - if (!decoded) return Promise.resolve(); - return recomputeMedicationComplianceForDay( - userId, - decoded.medicationId, - decoded.dayKey, - timezone, - client, - ); - }), - ); -} - async function processNextChunk( client: PrismaClient, jobId: string, @@ -587,7 +516,6 @@ async function processNextChunk( const row = await tx.medicationIntakeImportJob.findUnique({ where: { id: jobId }, - include: { recordUser: { select: { timezone: true } } }, }); if (!row || row.status === "done" || row.status === "failed") { return { @@ -601,40 +529,6 @@ async function processNextChunk( const payload = parsePayload(row.payload); const progress = parseProgress(row.progress, payload.entries.length); if (progress.processed >= progress.total) { - const rollupDays = progress.touchedDays.slice( - progress.rollupProcessed, - progress.rollupProcessed + MEDICATION_INTAKE_IMPORT_ROLLUP_CHUNK_SIZE, - ); - await recomputeTouchedDays( - tx, - row.recordUserId, - row.medicationId, - row.recordUser.timezone, - rollupDays, - ); - const finalizedProgress: MedicationImportProgress = { - ...progress, - rollupProcessed: progress.rollupProcessed + rollupDays.length, - }; - if (finalizedProgress.rollupProcessed < progress.touchedDays.length) { - const heartbeatAt = new Date(); - await tx.medicationIntakeImportJob.update({ - where: { id: row.id }, - data: { - status: "running", - progress: toJson(finalizedProgress), - heartbeatAt, - startedAt: row.startedAt ?? heartbeatAt, - failureReason: null, - }, - }); - return { - terminal: false, - finalized: false, - result: null, - userId: row.recordUserId, - }; - } const result: MedicationImportResult = { imported: progress.imported, skipped: totalMedicationImportSkips(progress.skippedByReason), @@ -665,7 +559,7 @@ async function processNextChunk( data: { status: "done", result: toJson(result), - progress: toJson(finalizedProgress), + progress: toJson(progress), failureReason: null, heartbeatAt: completedAt, completedAt, @@ -782,12 +676,6 @@ async function processNextChunk( }); } - const touchedDays = created.map((event) => - encodeTouchedDay( - event.medicationId, - dayKeyForScheduledFor(event.scheduledFor, row.recordUser.timezone), - ), - ); // Two different facts, counted apart. The Map collapse above is entries // of this submission that resolve to the same instant; `skipDuplicates` // then drops whatever the ledger already holds for that instant, from an @@ -803,7 +691,6 @@ async function processNextChunk( already_recorded: uniqueEntries.length - created.length, }, skipDetails: [...duplicateDetails, ...alreadyRecordedDetails], - touchedDays, }); const heartbeatAt = new Date(); await tx.medicationIntakeImportJob.update({ diff --git a/src/lib/jobs/queue-runtime.ts b/src/lib/jobs/queue-runtime.ts index a5546c376..14a722a49 100644 --- a/src/lib/jobs/queue-runtime.ts +++ b/src/lib/jobs/queue-runtime.ts @@ -203,9 +203,6 @@ export const QUEUE_RUNTIME: Readonly> = { "mood-rollup-full-backfill": short( "One account's mood entries, a few a day.", ), - "medication-compliance-full-backfill": short( - "One account's 90-day compliance window.", - ), "step-consolidation": short( "One account's legacy step rows, one transaction per day.", ), diff --git a/src/lib/jobs/reminder/medication-reminder-check.ts b/src/lib/jobs/reminder/medication-reminder-check.ts index 8a0fb1eb4..efe367658 100644 --- a/src/lib/jobs/reminder/medication-reminder-check.ts +++ b/src/lib/jobs/reminder/medication-reminder-check.ts @@ -8,7 +8,6 @@ import { type Job } from "pg-boss"; import type { Locale } from "@/lib/i18n/config"; import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { recordError, recordReminderCheck } from "@/lib/jobs/worker-status"; -import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; import { decrypt } from "@/lib/crypto"; import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; import { withBackgroundEvent } from "@/lib/logging/background"; @@ -708,15 +707,6 @@ export async function handleReminderCheck( missedDosesMinted++; evt.addMeta("missed_dose", `${med.name}:${slotTime}`); - - // v1.4.39 — refresh the compliance rollup so the - // per-day `scheduled` count increments before any read. - await recomputeMedicationComplianceForEvent({ - userId: med.user.id, - medicationId: med.id, - scheduledFor: slotScheduledFor, - tz: med.user.timezone, - }); } } diff --git a/src/lib/jobs/reminder/register-rollup.ts b/src/lib/jobs/reminder/register-rollup.ts index 27534267c..f4ed00269 100644 --- a/src/lib/jobs/reminder/register-rollup.ts +++ b/src/lib/jobs/reminder/register-rollup.ts @@ -38,13 +38,6 @@ import { type MoodRollupFullBackfillPayload, type MoodRollupRecomputePayload, } from "@/lib/rollups/mood-rollups"; -import { - MEDICATION_COMPLIANCE_BACKFILL_QUEUE, - MEDICATION_COMPLIANCE_BACKFILL_CONCURRENCY, - recomputeUserMedicationCompliance, - enqueueBootTimeMedicationComplianceBackfill, - type MedicationComplianceBackfillPayload, -} from "@/lib/rollups/medication-compliance-rollups"; import { SUPPLEMENT_NUTRIENT_RECOMPUTE_QUEUE, SUPPLEMENT_NUTRIENT_RECOMPUTE_CONCURRENCY, @@ -114,6 +107,7 @@ import { createAndSchedule, createAndWork, cronIsTheRetry, + retireQueues, type QueuePolicyTable, type ScheduleEntry, } from "./registrar-shared"; @@ -154,10 +148,6 @@ const allQueues = [ MOOD_ROLLUP_RECOMPUTE_QUEUE, // v1.4.39 — boot-time fold queue for the mood rollup tier. MOOD_ROLLUP_FULL_BACKFILL_QUEUE, - // v1.4.39 — boot-time fold for the medication-compliance - // rollup tier. Discovery enqueues one job per user with intake - // events but no rollup coverage; idempotent across reboots. - MEDICATION_COMPLIANCE_BACKFILL_QUEUE, // v1.5.6 — boot-time legacy step consolidation. Discovery enqueues // one job per user still holding live pre-v1.5.0 granular step rows. STEP_CONSOLIDATION_QUEUE, @@ -248,11 +238,6 @@ const queuePolicies: QueuePolicyTable = { reason: "Per-user mood backfill; boot-discovery driven and self-converging on rollup coverage.", }, - [MEDICATION_COMPLIANCE_BACKFILL_QUEUE]: { - policy: "exclusive", - reason: - "Per-user compliance backfill; boot-discovery driven and self-converging on rollup coverage.", - }, [STEP_CONSOLIDATION_QUEUE]: { policy: "exclusive", reason: @@ -289,6 +274,13 @@ const queuePolicies: QueuePolicyTable = { // empty key and buy nothing. }; +/** + * The boot backfill of the per-day medication-compliance rollup, whose table + * had no reader left and was dropped. Its queue, and any job still waiting in + * it, is removed on an instance that still has it. + */ +const RETIRED_ROLLUP_QUEUES = ["medication-compliance-full-backfill"] as const; + /** * Register every rollup / consolidation queue. Returns the queue names created * (for the boot-level aggregate assertion). @@ -297,6 +289,7 @@ export async function registerRollupQueues( boss: PgBoss, ): Promise { await createAndSchedule(boss, allQueues, schedules, queuePolicies); + await retireQueues(boss, RETIRED_ROLLUP_QUEUES); // v1.5.0 — persistent measurement rollup worker. Folds the // WEEK / MONTH / YEAR buckets that the write-path hooks enqueue; @@ -722,44 +715,6 @@ export async function registerRollupQueues( }, ); - // v1.4.39 — medication-compliance boot-backfill worker. The - // discovery helper below sends one job per user with intake events - // but zero rollup coverage; this handler folds the trailing 90-day - // window per account. Concurrency-1 so the populator never crowds - // the request pool. - await createAndWork( - boss, - MEDICATION_COMPLIANCE_BACKFILL_QUEUE, - { localConcurrency: MEDICATION_COMPLIANCE_BACKFILL_CONCURRENCY }, - async (jobs) => { - let rowsUpsertedTotal = 0; - for (const job of jobs) { - const { userId } = job.data; - try { - const { rowsUpserted, durationMs } = - await recomputeUserMedicationCompliance(userId); - rowsUpsertedTotal += rowsUpserted; - workerLog( - "info", - `[medication-compliance-backfill] user=${userId} rows=${rowsUpserted} duration=${durationMs}ms`, - ); - } catch (err) { - recordError(); - workerLog( - "error", - `[medication-compliance-backfill] user=${userId} failed`, - err, - ); - throw err; - } - } - return jobDone({ - users: jobs.length, - rows_upserted: rowsUpsertedTotal, - }); - }, - ); - // v1.4.37 — nightly drain worker. Walks every user × cumulative // type and folds per-sample APPLE_HEALTH rows older than the cutoff // into one `stats:…` row per calendar day. Idempotent — a second run @@ -1138,34 +1093,6 @@ export async function enqueueRollupBootDiscovery(): Promise { ); } - // v1.4.39 — medication compliance rollup tier. Mirrors the v1.4.35.1 - // pattern: one job per user with intake events but no rollup coverage. - // Idempotent across reboots and singleton-keyed inside pg-boss. - try { - // Stage 4. - const { enqueued, skipped, error } = - await enqueueBootTimeMedicationComplianceBackfill( - BOOT_BACKFILL_STAGGER_SECONDS * 4, - ); - if (error) { - workerLog( - "error", - `[medication-compliance-backfill] boot discovery failed: ${error}`, - ); - } else { - workerLog( - "info", - `[medication-compliance-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`, - ); - } - } catch (err) { - workerLog( - "error", - "[medication-compliance-backfill] boot discovery threw an unexpected error", - err, - ); - } - // v1.28.37 — step-consolidation repair. Finds every account still holding // a resurrectable tombstoned GOOGLE_HEALTH / FITBIT daily-total step row // (swept by the pre-fix consolidation) and enqueues one repair job per diff --git a/src/lib/jobs/subsystem-surface.ts b/src/lib/jobs/subsystem-surface.ts index 2e1d10a9e..1aa67653b 100644 --- a/src/lib/jobs/subsystem-surface.ts +++ b/src/lib/jobs/subsystem-surface.ts @@ -72,7 +72,6 @@ const subsystemSurface = { "rollup-full-backfill": { audience: "system" }, "mood-rollup-recompute": { audience: "system" }, "mood-rollup-full-backfill": { audience: "system" }, - "medication-compliance-full-backfill": { audience: "system" }, "step-consolidation": { audience: "system" }, "step-consolidation-repair": { audience: "system" }, "cumulative-pr-rederive": { audience: "system" }, diff --git a/src/lib/medications/__tests__/intake-slot-dedup-converge.test.ts b/src/lib/medications/__tests__/intake-slot-dedup-converge.test.ts index e9b0ba44b..76269a333 100644 --- a/src/lib/medications/__tests__/intake-slot-dedup-converge.test.ts +++ b/src/lib/medications/__tests__/intake-slot-dedup-converge.test.ts @@ -35,8 +35,8 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() })); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +vi.mock("@/lib/medications/dose-day-refresh", () => ({ + refreshDoseSlot: vi.fn().mockResolvedValue(undefined), })); import { dedupeUserIntakeSlots } from "../intake-slot-dedup"; diff --git a/src/lib/medications/__tests__/intake-slot-dedup-inventory.test.ts b/src/lib/medications/__tests__/intake-slot-dedup-inventory.test.ts index e453e804c..a21410b50 100644 --- a/src/lib/medications/__tests__/intake-slot-dedup-inventory.test.ts +++ b/src/lib/medications/__tests__/intake-slot-dedup-inventory.test.ts @@ -27,8 +27,8 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() })); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +vi.mock("@/lib/medications/dose-day-refresh", () => ({ + refreshDoseSlot: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserMedications: vi.fn(), diff --git a/src/lib/medications/__tests__/intake-slot-dedup-isolation.test.ts b/src/lib/medications/__tests__/intake-slot-dedup-isolation.test.ts index 0afdeba11..b8b8ddd54 100644 --- a/src/lib/medications/__tests__/intake-slot-dedup-isolation.test.ts +++ b/src/lib/medications/__tests__/intake-slot-dedup-isolation.test.ts @@ -25,13 +25,13 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() })); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +vi.mock("@/lib/medications/dose-day-refresh", () => ({ + refreshDoseSlot: vi.fn().mockResolvedValue(undefined), })); import { dedupeUserIntakeSlots } from "../intake-slot-dedup"; import { prisma } from "@/lib/db"; -import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; +import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh"; const TZ = "Europe/Berlin"; @@ -156,7 +156,7 @@ describe("dedupeUserIntakeSlots — per-slot error isolation", () => { // succeeded → exactly one normalised row counted. expect(summary.rowsNormalised).toBe(1); // Both canonical days recomputed despite the collision. - expect(recomputeMedicationComplianceForEvent).toHaveBeenCalledTimes(2); + expect(refreshDoseSlot).toHaveBeenCalledTimes(2); }); it("isolates a non-P2002 error per slot without dead-lettering the job", async () => { diff --git a/src/lib/medications/__tests__/intake-slot-dedup-pin.test.ts b/src/lib/medications/__tests__/intake-slot-dedup-pin.test.ts index 701d337a4..03d115bad 100644 --- a/src/lib/medications/__tests__/intake-slot-dedup-pin.test.ts +++ b/src/lib/medications/__tests__/intake-slot-dedup-pin.test.ts @@ -45,8 +45,8 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() })); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +vi.mock("@/lib/medications/dose-day-refresh", () => ({ + refreshDoseSlot: vi.fn().mockResolvedValue(undefined), })); import { dedupeUserIntakeSlots } from "../intake-slot-dedup"; diff --git a/src/lib/medications/dose-day-refresh.ts b/src/lib/medications/dose-day-refresh.ts new file mode 100644 index 000000000..6c75af0b8 --- /dev/null +++ b/src/lib/medications/dose-day-refresh.ts @@ -0,0 +1,62 @@ +/** + * The write-path hook every dose mutation funnels through: after a dose of a + * medication was taken, skipped, corrected, deleted, undone or recorded + * automatically, the rows derived from that dose's local day are recomputed. + * + * Today that is one derived row family, the micronutrients a taken + * supplement dose adds (`refreshSupplementNutrientsForDose`). The hook used to + * upsert a per-day compliance rollup as well; that table had no reader left + * and was dropped, so this is what remains of it, under a name that says what + * it does. + * + * Best-effort, like every derived row: a failure is reported by the refresh + * itself and never fails the dose write. + */ +import { refreshSupplementNutrientsForDose } from "@/lib/medications/ingredients/recompute"; +import { DEFAULT_TIMEZONE, isValidTimezone, userDayKey } from "@/lib/tz/format"; + +function safeTimezone(tz: string | null | undefined): string { + return tz && isValidTimezone(tz) ? tz : DEFAULT_TIMEZONE; +} + +/** + * The account-local day (`YYYY-MM-DD`) a dose slot belongs to: the local day + * of its `scheduledFor` in the account's timezone, falling back to the server + * default for a missing or unknown zone. + */ +export function dayKeyForScheduledFor( + scheduledFor: Date, + tz: string | null | undefined, +): string { + return userDayKey(scheduledFor, safeTimezone(tz)); +} + +/** Refresh the derived rows of one medication's local day. */ +export async function refreshDoseDay(input: { + userId: string; + medicationId: string; + dayKey: string; + tz: string | null | undefined; +}): Promise { + await refreshSupplementNutrientsForDose({ + userId: input.userId, + medicationId: input.medicationId, + dayKey: input.dayKey, + timezone: safeTimezone(input.tz), + }); +} + +/** Refresh the derived rows of the local day a dose slot sits on. */ +export async function refreshDoseSlot(input: { + userId: string; + medicationId: string; + scheduledFor: Date; + tz: string | null | undefined; +}): Promise { + await refreshDoseDay({ + userId: input.userId, + medicationId: input.medicationId, + dayKey: dayKeyForScheduledFor(input.scheduledFor, input.tz), + tz: input.tz, + }); +} diff --git a/src/lib/medications/intake-slot-dedup.ts b/src/lib/medications/intake-slot-dedup.ts index 446d42a1d..2644489f6 100644 --- a/src/lib/medications/intake-slot-dedup.ts +++ b/src/lib/medications/intake-slot-dedup.ts @@ -59,7 +59,7 @@ import type { WorkerMedicationRow, WorkerScheduleRow, } from "@/lib/medications/scheduling/worker-helpers"; -import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; +import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh"; export const INTAKE_SLOT_DEDUP_QUEUE = "intake-slot-dedup"; @@ -462,7 +462,7 @@ export async function dedupeUserIntakeSlots( const sep = key.indexOf("|"); const medicationId = key.slice(0, sep); const instantIso = key.slice(sep + 1); - await recomputeMedicationComplianceForEvent({ + await refreshDoseSlot({ userId, medicationId, scheduledFor: new Date(instantIso), diff --git a/src/lib/medications/scheduling/project-today-intakes.ts b/src/lib/medications/scheduling/project-today-intakes.ts index 1ab61da58..f7c1863f7 100644 --- a/src/lib/medications/scheduling/project-today-intakes.ts +++ b/src/lib/medications/scheduling/project-today-intakes.ts @@ -18,9 +18,6 @@ * 2. Reads existing rows in the today-window. * 3. Inserts the missing rows (`skipDuplicates: true` for the * `(userId, medicationId, scheduledFor, source)` unique index). - * 4. Fires one compliance-rollup recompute per distinct - * `(medicationId, dayKey)` so the rollup matches the new - * `scheduled` count before the next read. * * The caller owns its own typed re-read of `MedicationIntakeEvent` * afterwards — the two routes return different projections of those @@ -35,14 +32,12 @@ import { scheduleEmitsInWindow, } from "@/lib/medications/scheduling/worker-helpers"; import { localHmAsUtc } from "@/lib/tz/local-day"; -import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; import { isBeforeLiveEra, LIVE_ERA_REVISION_ARGS, liveEraStart, } from "@/lib/medications/scheduling/live-era"; -import { dayKeyForScheduledFor } from "@/lib/rollups/medication-compliance-rollups"; export interface ProjectTodayIntakesResult { projected: number; @@ -224,36 +219,5 @@ export async function projectTodayIntakesAndRecompute(input: { skipDuplicates: true, }); - // Close the v1.4.39.4 compliance-rollup hook gap: a bulk projection - // mints fresh `(medicationId, scheduledFor)` rows in PENDING state; - // without this hook the rollup row stays at its previous - // `scheduled` count, which inflates compliance % until the user - // actually logs against the new row. Coalesce by `(medicationId, - // dayKey)` so we fire one recompute per distinct day-tuple instead - // of one per row. - // - // `Promise.allSettled` keeps the best-effort contract explicit at - // the call site: the helper swallows internally today, but a - // future refactor that lets a throw escape would otherwise turn - // the parent POST into a 5xx. - const seenDayKeys = new Set(); - const recomputeJobs: Array> = []; - for (const m of missing) { - // The compliance rollup's own day for the slot: two slots on different - // local days that share a UTC date must each get their recompute. - const key = `${m.medicationId}|${dayKeyForScheduledFor(m.scheduledFor, userTz)}`; - if (seenDayKeys.has(key)) continue; - seenDayKeys.add(key); - recomputeJobs.push( - recomputeMedicationComplianceForEvent({ - userId, - medicationId: m.medicationId, - scheduledFor: m.scheduledFor, - tz: userTz, - }), - ); - } - await Promise.allSettled(recomputeJobs); - return { projected: projected.length, backfilled: missing.length }; } diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index 96705ee7b..a3c4352b7 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -1355,13 +1355,15 @@ export const medicationImportProgress = z touchedDays: z .array(z.string()) .describe( - "Local calendar days (YYYY-MM-DD) touched so far, driving the chunked rollup recompute behind the write.", + "Deprecated and inert: always `[]` on a job admitted from v1.43.8, when the per-day compliance rollup it drove was removed. A job admitted earlier keeps the days it recorded. Kept for clients that decode it; do not read it.", ), rollupProcessed: z .number() .int() .min(0) - .describe("Of `touchedDays`, how many have had their rollup recomputed."), + .describe( + "Deprecated and inert: always `0` on a job admitted from v1.43.8. Kept for clients that decode it; do not read it.", + ), }) .meta({ id: "MedicationImportProgress", diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index 8c55acee5..f912151aa 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -2326,7 +2326,7 @@ export const profilePaths: NonNullable = { summary: "Set the account timezone", description: "Lifted out of the profile patch so the picker can send one field without round-tripping the rest. Accepts any zone the running engine's `Intl` knows; anything else is 422 rather than stored, so the resolver never has to defend against a poisoned column. Audit-logged with the old and new zone.\n\n" + - "A change is not only a display preference. The medication-compliance rollup keys its rows by the LOCAL day a dose fell on, minted under whatever zone was in force at write time, so moving the zone re-buckets that history and the stored keys go stale — and the coverage probe compares counts rather than keys, so it cannot notice. The route therefore enqueues a re-fold of the trailing window whenever the zone actually changes. Best-effort and de-duplicated, so rapid re-picks collapse into one.", + "A change is not only a display preference. The supplement nutrient rows key on the LOCAL day a dose fell on, minted under whatever zone was in force at write time, so moving the zone re-buckets them. The route therefore enqueues a recompute of the account's supplement nutrients whenever the zone actually changes. Best-effort and de-duplicated, so rapid re-picks collapse into one.", requestBody: { required: true, content: { "application/json": { schema: timezonePutRequest } }, diff --git a/src/lib/rollups/__tests__/medication-compliance-rollups.test.ts b/src/lib/rollups/__tests__/medication-compliance-rollups.test.ts deleted file mode 100644 index 4c88585ef..000000000 --- a/src/lib/rollups/__tests__/medication-compliance-rollups.test.ts +++ /dev/null @@ -1,403 +0,0 @@ -/** - * v1.4.39 — unit tests for the medication-compliance rollup tier. - * - * `prisma` is mocked at the module boundary so the test can pin the - * intake-event fan-out + the rollup upsert / read shape without - * standing up a real Postgres. Integration coverage (real Postgres + - * the FK cascade behaviour) lives next to the route test suite when - * the W-MED phase grows them; this file proves the helpers' - * contract. - */ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const mocks = vi.hoisted(() => ({ - intakeFindMany: vi.fn(), - rollupUpsert: vi.fn(), - rollupDeleteMany: vi.fn(), - rollupFindMany: vi.fn(), - rollupFindFirst: vi.fn(), - queryRaw: vi.fn(), - executeRaw: vi.fn(), - bossSend: vi.fn(), - getGlobalBossMock: vi.fn(), -})); - -vi.mock("@/lib/db", () => ({ - prisma: { - medicationIntakeEvent: { - findMany: mocks.intakeFindMany, - }, - medicationComplianceRollup: { - upsert: mocks.rollupUpsert, - deleteMany: mocks.rollupDeleteMany, - findMany: mocks.rollupFindMany, - findFirst: mocks.rollupFindFirst, - }, - $queryRaw: mocks.queryRaw, - $executeRaw: mocks.executeRaw, - }, -})); - -vi.mock("@/lib/jobs/boss-instance", () => ({ - getGlobalBoss: () => mocks.getGlobalBossMock(), -})); - -vi.mock("@/lib/logging/context", () => ({ - annotate: vi.fn(), -})); - -import { - dayKeyForScheduledFor, - recomputeMedicationComplianceForDay, - recomputeMedicationComplianceForEvent, - recomputeUserMedicationCompliance, - enqueueBootTimeMedicationComplianceBackfill, - enqueueUserMedicationComplianceBackfill, - MEDICATION_COMPLIANCE_BACKFILL_QUEUE, -} from "../medication-compliance-rollups"; - -const { - intakeFindMany, - rollupUpsert, - rollupDeleteMany, - rollupFindMany, - rollupFindFirst, - queryRaw, - executeRaw, - bossSend, - getGlobalBossMock, -} = mocks; - -beforeEach(() => { - vi.resetAllMocks(); - intakeFindMany.mockResolvedValue([]); - rollupUpsert.mockResolvedValue({}); - rollupDeleteMany.mockResolvedValue({ count: 0 }); - rollupFindMany.mockResolvedValue([]); - rollupFindFirst.mockResolvedValue(null); - queryRaw.mockResolvedValue([]); - executeRaw.mockResolvedValue(0); -}); - -afterEach(() => { - vi.useRealTimers(); -}); - -describe("dayKeyForScheduledFor", () => { - it("anchors the dayKey on the user's tz wall clock", () => { - // 2026-05-17T23:30:00Z — Berlin local is 2026-05-18 01:30 (CEST). - const ts = new Date("2026-05-17T23:30:00.000Z"); - expect(dayKeyForScheduledFor(ts, "Europe/Berlin")).toBe("2026-05-18"); - // Los Angeles is UTC-7 (PDT) — still 2026-05-17 16:30 local. - expect(dayKeyForScheduledFor(ts, "America/Los_Angeles")).toBe("2026-05-17"); - }); - - it("falls back to the server default when tz is null", () => { - const ts = new Date("2026-05-17T22:00:00.000Z"); - expect(dayKeyForScheduledFor(ts, null)).toBe( - dayKeyForScheduledFor(ts, "Europe/Berlin"), - ); - }); -}); - -describe("recomputeMedicationComplianceForDay", () => { - /** - * Helper: pull the bind-variable values out of a Prisma tagged-template - * call so the test can inspect the user-id / day / window bounds the - * SQL was rendered with. Prisma's `$executeRaw` tag passes the - * template strings as the first arg and the substituted values as the - * remaining positional args. - */ - function bindValues(call: unknown[]): unknown[] { - return call.slice(1); - } - - it("runs an atomic INSERT … ON CONFLICT upsert with the (user, medication, day) tuple", async () => { - // QA F-H-02 (v1.4.39): the recompute is now a single SQL statement - // that re-aggregates inside the upsert; the JS pre-fold from the - // previous tier is gone so the test inspects the bind values - // instead of the intake fold. - await recomputeMedicationComplianceForDay( - "user-1", - "med-1", - "2026-05-18", - "Europe/Berlin", - ); - - // Two statements fire: the INSERT … ON CONFLICT upsert and the - // companion DELETE for the "all events removed" branch. - expect(executeRaw).toHaveBeenCalledTimes(2); - const insertBinds = bindValues(executeRaw.mock.calls[0]); - expect(insertBinds).toContain("user-1"); - expect(insertBinds).toContain("med-1"); - expect(insertBinds).toContain("2026-05-18"); - const deleteBinds = bindValues(executeRaw.mock.calls[1]); - expect(deleteBinds).toContain("user-1"); - expect(deleteBinds).toContain("med-1"); - expect(deleteBinds).toContain("2026-05-18"); - }); - - it("aggregates at SLOT level so cross-source duplicate rows cannot inflate scheduled", async () => { - // v1.15.19 — the partial unique index carries `source`, so a pending - // REMINDER row and a taken API row can share one `scheduled_for` - // instant. A row-level COUNT(*) counted that slot twice. Pin that the - // statement groups by `scheduled_for` and folds each slot with - // taken-beats-skipped priority (BOOL_OR) before counting. Semantics - // (two live rows, one slot, different source → scheduled=1, taken=1) - // are proven on real Postgres in - // `tests/integration/medication-compliance-rollup-slot.test.ts`. - await recomputeMedicationComplianceForDay( - "user-1", - "med-1", - "2026-05-18", - "Europe/Berlin", - ); - - const insertSql = (executeRaw.mock.calls[0][0] as readonly string[]).join( - "?", - ); - expect(insertSql).toMatch(/GROUP BY\s+"scheduled_for"/); - expect(insertSql).toMatch( - /BOOL_OR\("taken_at" IS NOT NULL AND NOT "skipped"\)/, - ); - expect(insertSql).toMatch(/BOOL_OR\("skipped"\)/); - // Slot-level taken beats skipped: a slot is `skipped` only when no row - // in it is taken. - expect(insertSql).toMatch(/NOT "slot_taken" AND "slot_skipped"/); - // Scheduled counts slots (the grouped CTE), not raw event rows. - expect(insertSql).toMatch(/FROM slot/); - }); - - it("emits a paired DELETE so a fully-cleared day removes its row", async () => { - // The DELETE fires unconditionally and gates on NOT EXISTS — when - // the day still has events the predicate matches zero rows. The - // test pins that the DELETE statement was issued so a future - // refactor that drops the second statement breaks here. - await recomputeMedicationComplianceForDay( - "user-1", - "med-1", - "2026-05-18", - "Europe/Berlin", - ); - - expect(executeRaw).toHaveBeenCalledTimes(2); - }); - - it("is idempotent across repeated invocations", async () => { - await recomputeMedicationComplianceForDay( - "user-1", - "med-1", - "2026-05-18", - "Europe/Berlin", - ); - await recomputeMedicationComplianceForDay( - "user-1", - "med-1", - "2026-05-18", - "Europe/Berlin", - ); - - expect(executeRaw).toHaveBeenCalledTimes(4); - expect(bindValues(executeRaw.mock.calls[0])).toEqual( - bindValues(executeRaw.mock.calls[2]), - ); - expect(bindValues(executeRaw.mock.calls[1])).toEqual( - bindValues(executeRaw.mock.calls[3]), - ); - }); - - it("binds a tz-anchored UTC window into the upsert", async () => { - await recomputeMedicationComplianceForDay( - "user-1", - "med-1", - "2026-05-18", - "Europe/Berlin", - ); - - const insertBinds = bindValues(executeRaw.mock.calls[0]); - const insertDates = insertBinds.filter((b): b is Date => b instanceof Date); - // Berlin 2026-05-18 00:00 → UTC 2026-05-17 22:00 (CEST is UTC+2). - expect(insertDates[0].toISOString()).toBe("2026-05-17T22:00:00.000Z"); - // Window closes 24h later — Berlin 2026-05-19 00:00 → UTC 2026-05-18 22:00. - expect(insertDates[1].toISOString()).toBe("2026-05-18T22:00:00.000Z"); - }); - - it("buckets the same UTC instant on different days for Berlin vs LA", async () => { - await recomputeMedicationComplianceForDay( - "user-1", - "med-1", - "2026-05-18", - "Europe/Berlin", - ); - const berlinInsert = bindValues(executeRaw.mock.calls[0]).filter( - (b): b is Date => b instanceof Date, - ); - - executeRaw.mockClear(); - - await recomputeMedicationComplianceForDay( - "user-2", - "med-2", - "2026-05-17", - "America/Los_Angeles", - ); - const laInsert = bindValues(executeRaw.mock.calls[0]).filter( - (b): b is Date => b instanceof Date, - ); - - // Berlin 2026-05-18 starts at UTC 2026-05-17 22:00. - expect(berlinInsert[0].toISOString()).toBe("2026-05-17T22:00:00.000Z"); - // LA 2026-05-17 starts at UTC 2026-05-17 07:00 (PDT is UTC-7). - expect(laInsert[0].toISOString()).toBe("2026-05-17T07:00:00.000Z"); - }); - - it("commits the strictest aggregate under concurrent recompute calls", async () => { - // QA F-H-02 race pin: two concurrent recomputes for the same - // (user, medication, day) must both go through the atomic SQL - // path. The earlier `findMany`-then-`upsert` pattern could - // interleave A-SELECT → B-SELECT → B-UPSERT (correct) → A-UPSERT - // (stale N-1); the atomic upsert serialises on the row lock so - // each statement re-aggregates a snapshot taken after the prior - // commit. The mock can't reproduce real Postgres concurrency, but - // the test pins that BOTH callers issue the atomic statement (not - // the legacy read-aggregate-then-upsert) so a regression to the - // racy pattern would fail this assertion. - await Promise.all([ - recomputeMedicationComplianceForDay( - "user-1", - "med-1", - "2026-05-18", - "Europe/Berlin", - ), - recomputeMedicationComplianceForDay( - "user-1", - "med-1", - "2026-05-18", - "Europe/Berlin", - ), - ]); - // 2 callers × 2 statements (INSERT + DELETE) each. - expect(executeRaw).toHaveBeenCalledTimes(4); - // No legacy intake-findMany was issued — the atomic path replaces it. - expect(intakeFindMany).not.toHaveBeenCalled(); - // No legacy rollupUpsert / rollupDeleteMany either. - expect(rollupUpsert).not.toHaveBeenCalled(); - expect(rollupDeleteMany).not.toHaveBeenCalled(); - }); -}); - -describe("recomputeMedicationComplianceForEvent", () => { - it("derives the dayKey from scheduledFor + tz then dispatches", async () => { - // 2026-05-17T23:30:00Z → Berlin day 2026-05-18. - await recomputeMedicationComplianceForEvent({ - userId: "user-1", - medicationId: "med-1", - scheduledFor: new Date("2026-05-17T23:30:00.000Z"), - tz: "Europe/Berlin", - }); - // The atomic upsert binds the dayKey as one of the positional - // arguments; assert "2026-05-18" reached the SQL layer. - const insertBinds = executeRaw.mock.calls[0].slice(1); - expect(insertBinds).toContain("2026-05-18"); - }); - - it("swallows recompute errors so the parent write never blocks", async () => { - executeRaw.mockRejectedValueOnce(new Error("DB melted")); - - await expect( - recomputeMedicationComplianceForEvent({ - userId: "user-1", - medicationId: "med-1", - scheduledFor: new Date("2026-05-17T12:00:00.000Z"), - tz: "Europe/Berlin", - }), - ).resolves.toBeUndefined(); - }); -}); - -describe("recomputeUserMedicationCompliance", () => { - it("upserts one rollup per (medication, day) pair returned by discovery", async () => { - queryRaw.mockResolvedValue([ - { medication_id: "med-1", day: "2026-05-17" }, - { medication_id: "med-1", day: "2026-05-18" }, - { medication_id: "med-2", day: "2026-05-18" }, - ]); - - const result = await recomputeUserMedicationCompliance( - "user-1", - 30, - "Europe/Berlin", - ); - - expect(result.rowsUpserted).toBe(3); - // Each (medication, day) pair issues two atomic SQL statements: - // the INSERT … ON CONFLICT upsert and the companion DELETE that - // gates on `NOT EXISTS`. - expect(executeRaw).toHaveBeenCalledTimes(6); - }); -}); - -describe("enqueueBootTimeMedicationComplianceBackfill", () => { - it("is a no-op when no boss is attached", async () => { - getGlobalBossMock.mockReturnValue(null); - const result = await enqueueBootTimeMedicationComplianceBackfill(); - expect(result).toEqual({ enqueued: 0, skipped: 0, error: null }); - expect(queryRaw).not.toHaveBeenCalled(); - }); - - it("enqueues one job per uncovered user", async () => { - getGlobalBossMock.mockReturnValue({ send: bossSend }); - queryRaw.mockResolvedValue([{ id: "user-1" }, { id: "user-2" }]); - bossSend.mockResolvedValueOnce("job-1"); - bossSend.mockResolvedValueOnce(null); // coalesced - const result = await enqueueBootTimeMedicationComplianceBackfill(); - expect(result.enqueued).toBe(1); - expect(result.skipped).toBe(1); - expect(result.error).toBeNull(); - expect(bossSend).toHaveBeenCalledWith( - MEDICATION_COMPLIANCE_BACKFILL_QUEUE, - expect.objectContaining({ userId: "user-1" }), - expect.objectContaining({ - singletonKey: "medication-compliance-boot-backfill|user-1", - }), - ); - }); -}); - -describe("enqueueUserMedicationComplianceBackfill", () => { - it("is a no-op when no boss is attached", async () => { - getGlobalBossMock.mockReturnValue(null); - const result = await enqueueUserMedicationComplianceBackfill("user-1"); - expect(result).toEqual({ enqueued: false, error: null }); - // Critically: no `$queryRaw` cluster-wide LEFT JOIN scan. - expect(queryRaw).not.toHaveBeenCalled(); - }); - - it("enqueues exactly one job for the caller's user and never touches the cluster-wide LEFT JOIN", async () => { - // QA F-SEC-M-01 (v1.4.39): pre-fix the request-path coverage-miss - // path called `enqueueBootTimeMedicationComplianceBackfill` which - // issued a cluster-wide `LEFT JOIN medication_intake_events × - // medication_compliance_rollups` on every hit. The user-scoped - // helper sends one targeted job and never runs the discovery scan. - getGlobalBossMock.mockReturnValue({ send: bossSend }); - bossSend.mockResolvedValueOnce("job-1"); - const result = await enqueueUserMedicationComplianceBackfill("user-1"); - expect(result).toEqual({ enqueued: true, error: null }); - expect(queryRaw).not.toHaveBeenCalled(); - expect(bossSend).toHaveBeenCalledTimes(1); - expect(bossSend).toHaveBeenCalledWith( - MEDICATION_COMPLIANCE_BACKFILL_QUEUE, - expect.objectContaining({ userId: "user-1" }), - expect.objectContaining({ - singletonKey: "medication-compliance-boot-backfill|user-1", - }), - ); - }); - - it("reports a coalesced enqueue as `enqueued:false`", async () => { - getGlobalBossMock.mockReturnValue({ send: bossSend }); - bossSend.mockResolvedValueOnce(null); - const result = await enqueueUserMedicationComplianceBackfill("user-2"); - expect(result).toEqual({ enqueued: false, error: null }); - }); -}); diff --git a/src/lib/rollups/medication-compliance-rollups.ts b/src/lib/rollups/medication-compliance-rollups.ts deleted file mode 100644 index 552b709d1..000000000 --- a/src/lib/rollups/medication-compliance-rollups.ts +++ /dev/null @@ -1,438 +0,0 @@ -/** - * v1.4.39 — persistent medication-compliance rollup tier. - * - * Replaces the unbounded `MedicationIntakeEvent.findMany` walk on - * `/api/medications/intake?scope=compliance` with a ledger keyed - * `(userId, medicationId, day)` where `day` is `YYYY-MM-DD` anchored - * to the user's IANA timezone. - * - * Write hooks fire from every `MedicationIntakeEvent` mutation site - * (create / update / delete / bulk import) plus the reminder-worker's - * mint path that creates `takenAt:null` rows on RED-phase reminders. - * The dashboard tile it was built for reads the schedule-anchored - * compliance (`src/lib/analytics/schedule-anchored-compliance.ts`) since - * v1.26, and the per-day recompute below is now what keeps a day's - * supplement nutrients in step with its doses; the table itself has no - * reader left. - * - * v1.15.18 DESIGN DECISION — this tier stays a RAW per-day COVERAGE count, - * NOT the band-attributed compliance %. It aggregates, across ALL of a - * user's medications, "of the intake rows whose `scheduled_for` lands on - * this day, how many carry a non-null `taken_at`" — a coarse daily - * cross-medication coverage strip for the dashboard mini-tiles. The - * unified band-membership attribution (the `tallyComplianceFromLedger` - * keystone) is per-medication and per-cadence: it depends on the slot - * bands of each schedule and the user timezone, which cannot be expressed - * in a single cross-medication SQL `SUM(CASE …)`. Re-attributing every - * intake against its cadence's bands before the tally would mean running - * the band minter per `(medication, day)` inside the rollup, which defeats - * the rollup's whole purpose (bounded SQL, no per-row Node walk). The - * detail page + the medication card already read the band-attributed % via - * `calculateCompliance` / `complianceChips`; this tier deliberately remains - * the cheaper coverage signal and is documented as such so the two are not - * confused. The `scheduled` / `taken` column names reflect this: a day's - * `taken` is "slots with an intake logged taken", not "doses inside their - * on-time band". - * - * v1.15.19 — the count is SLOT-level, not row-level. The partial unique - * index on `(user, medication, scheduled_for, source)` lets two live rows - * share one slot instant when their `source` differs (e.g. a pending - * REMINDER row plus a taken API row for the same dose); a row-level - * `COUNT(*)` double-counted that slot as two scheduled doses. The - * aggregate now groups by `scheduled_for` first: `scheduled` counts - * distinct slot instants, and each slot folds its rows with - * taken-beats-skipped priority (`BOOL_OR` of taken, then skipped only when - * no row in the slot is taken). This neutralises cross-source duplicate - * rows retroactively — any recompute over pre-existing duplicates yields - * the deduplicated counts without a data migration. - * - * Audit anchor: `.planning/round-v1438-perf-analysis.md` §2.5 + §5 P4. - */ -import type { PrismaClient } from "@/generated/prisma/client"; -import { prisma } from "@/lib/db"; -import { getGlobalBoss } from "@/lib/jobs/boss-instance"; -import { refreshSupplementNutrientsForDose } from "@/lib/medications/ingredients/recompute"; -import { annotate } from "@/lib/logging/context"; -import { DEFAULT_TIMEZONE, isValidTimezone, userDayKey } from "@/lib/tz/format"; -import { localDayWindow, startOfLocalDayKey } from "@/lib/tz/local-day"; - -/** - * pg-boss queue name for the boot-time medication compliance backfill. - * Mirrors the `rollup-full-backfill` v1.4.35.1 pattern: discovery is - * idempotent across reboots and the worker upserts every - * `(user, medication, day)` row for the trailing window. - */ -export const MEDICATION_COMPLIANCE_BACKFILL_QUEUE = - "medication-compliance-full-backfill"; - -/** Worker concurrency cap — backfill stays serial to spare the pool. */ -export const MEDICATION_COMPLIANCE_BACKFILL_CONCURRENCY = 1; - -/** Trailing window the boot backfill folds per uncovered user. */ -export const MEDICATION_COMPLIANCE_BACKFILL_DAYS = 90; - -/** Payload `boss.send` carries onto the backfill queue. */ -export interface MedicationComplianceBackfillPayload { - userId: string; - /** Wall-clock kick-off for debugging only. */ - enqueuedAt: string; -} - -/** - * Resolve a safe tz string — fall back to the server default when the - * supplied value is missing or not an IANA zone the host recognises. - */ -function safeTimezone(tz: string | null | undefined): string { - if (tz && isValidTimezone(tz)) return tz; - return DEFAULT_TIMEZONE; -} - -/** - * Convert a `scheduledFor` UTC instant to the user's `YYYY-MM-DD` - * day-key. Re-exposed so the write-hook caller can derive the same - * `dayKey` the rollup row is anchored on. - */ -export function dayKeyForScheduledFor( - scheduledFor: Date, - tz: string | null | undefined, -): string { - return userDayKey(scheduledFor, safeTimezone(tz)); -} - -type MedicationComplianceRollupClient = Pick; - -/** - * Recompute the rollup row for one `(userId, medicationId, dayKey)` - * tuple. Reads every `MedicationIntakeEvent` whose `scheduledFor` lands - * in `[startOfDayInTz, startOfNextDayInTz)` then upserts the - * `medication_compliance_rollups` row. - * - * The hook is idempotent: re-running for the same key produces the - * same row. When the day window holds zero events the row is deleted - * so the read path returns the trailing-window zero-default rather - * than a stale stub. - * - */ -export async function recomputeMedicationComplianceForDay( - userId: string, - medicationId: string, - dayKey: string, - tz: string | null | undefined, - client: MedicationComplianceRollupClient = prisma, -): Promise { - const safeTz = safeTimezone(tz); - // The local day's own bounds: 23, 24 or 25 hours on DST days. - const { dayStart: start, dayEnd: windowEnd } = localDayWindow(dayKey, safeTz); - - // v1.4.39: atomic upsert closes the race window - // between the prior `SELECT … aggregate then UPSERT` pattern. Two - // concurrent writes for the same (user, medication, day) used to - // interleave A-SELECT → B-SELECT → B-UPSERT (correct) → A-UPSERT - // (stale N-1). The INSERT … SELECT re-aggregates inside the upsert - // statement so each write commits with a snapshot taken after the - // prior commit released its row lock; the trailing DELETE handles - // the "all events for the day were removed" case in the same shot. - // - // Both statements key on (user_id, medication_id, day) so they - // serialise behind the row lock the rollup table's primary key - // provides. The DELETE runs second so it cannot clobber a row the - // INSERT just wrote — when the day still has events the - // `WHERE NOT EXISTS` predicate matches zero rows. - // v1.15.19 — slot-level aggregation. The inner CTE folds the rows that - // share one `scheduled_for` instant (cross-source duplicates: pending - // REMINDER + taken API) into one slot with taken-beats-skipped priority; - // the outer aggregate then counts slots, not rows, so a duplicated slot - // can never inflate `scheduled`. Pure SQL — re-running the recompute - // over historic duplicates self-corrects the counts. - await client.$executeRaw` - WITH slot AS ( - SELECT - BOOL_OR("taken_at" IS NOT NULL AND NOT "skipped") AS slot_taken, - BOOL_OR("skipped") AS slot_skipped - FROM "medication_intake_events" - WHERE "user_id" = ${userId} - AND "medication_id" = ${medicationId} - AND "deleted_at" IS NULL - AND "scheduled_for" >= ${start} - AND "scheduled_for" < ${windowEnd} - GROUP BY "scheduled_for" - ), - aggregate AS ( - SELECT - COUNT(*)::int AS scheduled, - COALESCE(SUM(CASE WHEN "slot_taken" THEN 1 ELSE 0 END), 0)::int AS taken, - COALESCE(SUM(CASE WHEN NOT "slot_taken" AND "slot_skipped" THEN 1 ELSE 0 END), 0)::int AS skipped - FROM slot - ) - INSERT INTO "medication_compliance_rollups" - ("user_id", "medication_id", "day", "scheduled", "taken", "skipped", "computed_at") - SELECT - ${userId}, - ${medicationId}, - ${dayKey}, - aggregate.scheduled, - aggregate.taken, - aggregate.skipped, - NOW() - FROM aggregate - WHERE aggregate.scheduled > 0 - ON CONFLICT ("user_id", "medication_id", "day") DO UPDATE - SET "scheduled" = EXCLUDED."scheduled", - "taken" = EXCLUDED."taken", - "skipped" = EXCLUDED."skipped", - "computed_at" = EXCLUDED."computed_at" - `; - await client.$executeRaw` - DELETE FROM "medication_compliance_rollups" - WHERE "user_id" = ${userId} - AND "medication_id" = ${medicationId} - AND "day" = ${dayKey} - AND NOT EXISTS ( - SELECT 1 - FROM "medication_intake_events" - WHERE "user_id" = ${userId} - AND "medication_id" = ${medicationId} - AND "deleted_at" IS NULL - AND "scheduled_for" >= ${start} - AND "scheduled_for" < ${windowEnd} - ) - `; - - // v1.43 — the day's other derived row: the micronutrients its taken - // supplement doses add (`source = SUPPLEMENT`). Every dose write path - // already funnels through this recompute, so this is the one place the - // hook needs to live. Only on the base client: a caller inside its own - // transaction (the dose import) has not committed the doses yet and - // queues the account's full recompute once it has. - if (client === prisma) { - await refreshSupplementNutrientsForDose({ - userId, - medicationId, - dayKey, - timezone: safeTz, - }); - } -} - -/** - * Convenience hook for write-paths that have the `scheduledFor` - * instant in hand. Derives `dayKey` in the user's tz then dispatches - * to `recomputeMedicationComplianceForDay`. Best-effort: surfaces - * failures through `annotate` + `console.error` so a silent populator - * regression shows up in ops without blocking the parent write. - */ -export async function recomputeMedicationComplianceForEvent(input: { - userId: string; - medicationId: string; - scheduledFor: Date; - tz: string | null | undefined; -}): Promise { - const dayKey = dayKeyForScheduledFor(input.scheduledFor, input.tz); - try { - await recomputeMedicationComplianceForDay( - input.userId, - input.medicationId, - dayKey, - input.tz, - ); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - annotate({ - meta: { - medication_compliance_rollup_failed: true, - medication_compliance_rollup_error: message, - }, - }); - console.error("[medication-compliance-rollups] recompute failed:", message); - } -} - -/** - * Full-fold helper. Walks every `(medicationId, day)` pair the user - * has logged across the trailing `days` window and upserts each - * rollup row. The boot-time backfill worker calls this once per - * uncovered user; the route's lazy fallback enqueues it when the - * coverage probe returns false. - * - * Bounded SQL: one `DISTINCT (medicationId, dayKey)` $queryRaw scan - * over the trailing window, then one upsert per row. The dayKey is - * computed in Postgres in the user's zone so we - * don't have to ship millions of rows back to Node. - */ -export async function recomputeUserMedicationCompliance( - userId: string, - days: number = MEDICATION_COMPLIANCE_BACKFILL_DAYS, - tz: string | null | undefined = null, -): Promise<{ rowsUpserted: number; durationMs: number }> { - const startedAt = Date.now(); - const safeTz = safeTimezone(tz); - - const now = new Date(); - const oldestKey = userDayKey( - new Date(now.getTime() - (days - 1) * 86_400_000), - safeTz, - ); - const oldestStart = startOfLocalDayKey(oldestKey, safeTz); - - // Distinct (medication_id, dayKey) pairs the user has any intake - // event for inside the trailing window. `scheduled_for` is a naive UTC - // timestamp: it is read as UTC first and only then shifted into the - // user's zone. A single `AT TIME ZONE $tz` reads it as local time and - // shifts it the wrong way, which keyed every slot within the zone's - // offset of midnight onto the neighbouring day. - const pairs = await prisma.$queryRaw< - Array<{ medication_id: string; day: string }> - >` - SELECT DISTINCT - "medication_id", - to_char( - ("scheduled_for" AT TIME ZONE 'UTC') AT TIME ZONE ${safeTz}, - 'YYYY-MM-DD' - ) AS "day" - FROM "medication_intake_events" - WHERE "user_id" = ${userId} - AND "deleted_at" IS NULL - AND "scheduled_for" >= ${oldestStart} - `; - - let rowsUpserted = 0; - for (const pair of pairs) { - await recomputeMedicationComplianceForDay( - userId, - pair.medication_id, - pair.day, - safeTz, - ); - rowsUpserted += 1; - } - - return { rowsUpserted, durationMs: Date.now() - startedAt }; -} - -/** - * v1.4.39 — user-scoped enqueue helper for the - * request-path coverage-miss fallback. - * - * Mirrors `ensureUserMoodRollupsFresh`: the route fires this when its - * coverage probe returned false so the caller's account picks up a - * targeted boot-backfill job, instead of running a cluster-wide - * `LEFT JOIN` over `medication_intake_events × medication_compliance_rollups` - * on every authenticated coverage-miss request. The cluster-wide scan - * was a soft DoS amplifier — an authenticated user iterating - * coverage-miss requests could drive a multi-tenant table scan on - * every hit. - * - * The boot-time discovery helper below stays cluster-wide (it runs - * once per worker boot, not per request). - */ -export async function enqueueUserMedicationComplianceBackfill( - userId: string, -): Promise<{ enqueued: boolean; error: string | null }> { - const boss = getGlobalBoss(); - if (!boss) { - return { enqueued: false, error: null }; - } - try { - const payload: MedicationComplianceBackfillPayload = { - userId, - enqueuedAt: new Date().toISOString(), - }; - const jobId = await boss.send( - MEDICATION_COMPLIANCE_BACKFILL_QUEUE, - payload, - { - retryLimit: 3, - retryDelay: 60, - retryBackoff: true, - singletonKey: `medication-compliance-boot-backfill|${userId}`, - }, - ); - return { enqueued: jobId !== null && jobId !== undefined, error: null }; - } catch (err) { - return { - enqueued: false, - error: err instanceof Error ? err.message : String(err), - }; - } -} - -/** - * Boot-time enqueue helper — mirrors v1.4.35.1 - * `enqueueBootTimeRollupBackfill`. Finds users with intake events but - * zero compliance-rollup coverage for the trailing window and enqueues - * one full-fold per account onto `MEDICATION_COMPLIANCE_BACKFILL_QUEUE`. - * - * Idempotent across reboots: once an account has at least one rollup - * row inside the trailing window the discovery query drops them. - * pg-boss `singletonKey` coalesces duplicate sends. - */ -export async function enqueueBootTimeMedicationComplianceBackfill( - // Optional boot-storm stagger. When > 0 the per-user sends carry a - // `startAfter` delay (seconds) so this full-history fold does not drain in - // parallel with the other boot backfills onto one heavy tenant. Default 0 - // keeps immediate semantics for any non-boot caller. - startAfterSeconds: number = 0, -): Promise<{ - enqueued: number; - skipped: number; - error: string | null; -}> { - const boss = getGlobalBoss(); - if (!boss) { - return { enqueued: 0, skipped: 0, error: null }; - } - - try { - // Trailing window cutoff for the existence probe — `scheduled_for` - // older than this is not what the read path consults. - const cutoff = new Date( - Date.now() - MEDICATION_COMPLIANCE_BACKFILL_DAYS * 86_400_000, - ); - const users = await prisma.$queryRaw>` - SELECT DISTINCT mie."user_id" AS id - FROM "medication_intake_events" mie - LEFT JOIN "medication_compliance_rollups" r - ON r."user_id" = mie."user_id" - AND r."medication_id" = mie."medication_id" - WHERE mie."scheduled_for" >= ${cutoff} - AND mie."deleted_at" IS NULL - AND r."day" IS NULL - `; - - if (users.length === 0) { - return { enqueued: 0, skipped: 0, error: null }; - } - - let enqueued = 0; - let skipped = 0; - for (const { id } of users) { - const payload: MedicationComplianceBackfillPayload = { - userId: id, - enqueuedAt: new Date().toISOString(), - }; - const jobId = await boss.send( - MEDICATION_COMPLIANCE_BACKFILL_QUEUE, - payload, - { - retryLimit: 3, - retryDelay: 60, - retryBackoff: true, - singletonKey: `medication-compliance-boot-backfill|${id}`, - ...(startAfterSeconds > 0 ? { startAfter: startAfterSeconds } : {}), - }, - ); - if (jobId) { - enqueued += 1; - } else { - skipped += 1; - } - } - return { enqueued, skipped, error: null }; - } catch (err) { - return { - enqueued: 0, - skipped: 0, - error: err instanceof Error ? err.message : String(err), - }; - } -} diff --git a/src/lib/telegram-webhook-handlers.ts b/src/lib/telegram-webhook-handlers.ts index 1cc5bb11d..32e010557 100644 --- a/src/lib/telegram-webhook-handlers.ts +++ b/src/lib/telegram-webhook-handlers.ts @@ -9,7 +9,7 @@ import { import { getServerTranslator } from "@/lib/i18n/server-translator"; import { locales, type Locale } from "@/lib/i18n/config"; import { foldForMatch } from "@/lib/i18n/fold-for-match"; -import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; +import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh"; import { applyCanonicalSlotWrite, resolveForcedSlotForWrite, @@ -212,8 +212,8 @@ async function findTelegramUser(chatId: string) { id: true, telegramBotToken: true, locale: true, - // v1.4.39 — pulled into the select so the post-write - // compliance-rollup hook can anchor the user-day bucket. + // Pulled into the select so the post-write dose-day refresh can + // anchor the user's local day. timezone: true, }, }); @@ -428,7 +428,7 @@ async function markMedicationTaken( // affected day so the next read after the cache miss reflects the // new dose. Only fires when the write landed a row. if (scheduledFor) { - await recomputeMedicationComplianceForEvent({ + await refreshDoseSlot({ userId, medicationId: medication.id, scheduledFor, @@ -690,7 +690,7 @@ export async function handleCallback(update: TelegramUpdate) { // event's user-day so the next read sees the (scheduled, skipped) // counts incremented. if (skippedScheduledFor) { - await recomputeMedicationComplianceForEvent({ + await refreshDoseSlot({ userId: user.id, medicationId: medication.id, scheduledFor: skippedScheduledFor, diff --git a/src/lib/tz/wall-clock.ts b/src/lib/tz/wall-clock.ts index 7ec7f1617..79e4922be 100644 --- a/src/lib/tz/wall-clock.ts +++ b/src/lib/tz/wall-clock.ts @@ -1,6 +1,6 @@ /** * Wall-clock-in-timezone helper shared by the medication scheduling - * + compliance-rollup paths. + * paths. * * Decomposes a `Date` instant into the year/month/day/hour/minute/ * second/weekday tuple an observer in `tz` would read off a wall @@ -12,7 +12,7 @@ * per-user timezone work keep their original shape. * * v1.4.40 W-GHOSTS consolidated two file-local copies - * (`compliance-rollups.ts` returning `{year,month,day}` only + + * (a compliance-rollup copy returning `{year,month,day}` only + * `scheduling/cadence.ts` returning the full tuple) into this single * canonical export. The cross-tz ±3 h guard hinges on these helpers * staying in lock-step; keeping them as separate definitions made diff --git a/tests/integration/intake-slot-dedup.test.ts b/tests/integration/intake-slot-dedup.test.ts index c3f69246a..57da5c2a0 100644 --- a/tests/integration/intake-slot-dedup.test.ts +++ b/tests/integration/intake-slot-dedup.test.ts @@ -164,16 +164,6 @@ describe("intake-slot-dedup — duplicate slot collapse", () => { }); expect(eveningRow.deletedAt).toBeNull(); expect(eveningRow.scheduledFor.toISOString()).toBe(slot1900.toISOString()); - - // The compliance rollup reflects the corrected counts: the 07:00 day - // now has scheduled=2 (07:00 taken + 19:00 pending), taken=1 — not the - // inflated scheduled=3 the duplicate produced. - const rollups = await prisma.medicationComplianceRollup.findMany({ - where: { userId: TEST_USER_ID, medicationId: medId }, - }); - expect(rollups).toHaveLength(1); - expect(rollups[0]?.scheduled).toBe(2); - expect(rollups[0]?.taken).toBe(1); }); it("never collapses a PRN med's multiple rows", async () => { diff --git a/tests/integration/local-day-keys.test.ts b/tests/integration/local-day-keys.test.ts index 04b891ea6..5f9d2e5ef 100644 --- a/tests/integration/local-day-keys.test.ts +++ b/tests/integration/local-day-keys.test.ts @@ -12,7 +12,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { getPrismaClient, truncateAllTables } from "./setup"; import { readActivityDayKeys } from "@/lib/dashboard/summary-reads"; -import { recomputeUserMedicationCompliance } from "@/lib/rollups/medication-compliance-rollups"; import { userDayKey } from "@/lib/tz/format"; vi.mock("@/lib/db-compat", () => ({ @@ -94,31 +93,3 @@ describe("readActivityDayKeys", () => { ); }); }); - -describe("recomputeUserMedicationCompliance", () => { - it("keys a late dose onto the person's own day", async () => { - const med = await prisma.medication.create({ - data: { userId: USER, name: "Ramipril", dose: "5mg", active: true }, - }); - await prisma.medicationIntakeEvent.create({ - data: { - userId: USER, - medicationId: med.id, - scheduledFor: LATE, - takenAt: LATE, - skipped: false, - source: "API", - }, - }); - - await recomputeUserMedicationCompliance(USER, 30, "Europe/Berlin"); - - const rows = await prisma.medicationComplianceRollup.findMany({ - where: { userId: USER }, - select: { day: true, scheduled: true, taken: true }, - }); - expect(rows).toEqual([ - { day: key(D + 86_400_000), scheduled: 1, taken: 1 }, - ]); - }); -}); diff --git a/tests/integration/medication-cadence-worker.test.ts b/tests/integration/medication-cadence-worker.test.ts index 24c6b7a01..f0c6b6600 100644 --- a/tests/integration/medication-cadence-worker.test.ts +++ b/tests/integration/medication-cadence-worker.test.ts @@ -76,13 +76,13 @@ vi.mock("@/lib/db-compat", () => ({ vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserMedications: vi.fn(), })); -vi.mock("@/lib/rollups/medication-compliance-rollups", async () => { +vi.mock("@/lib/medications/dose-day-refresh", async () => { const actual = await vi.importActual< - typeof import("@/lib/rollups/medication-compliance-rollups") - >("@/lib/rollups/medication-compliance-rollups"); + typeof import("@/lib/medications/dose-day-refresh") + >("@/lib/medications/dose-day-refresh"); return { ...actual, - recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), + refreshDoseSlot: vi.fn().mockResolvedValue(undefined), }; }); diff --git a/tests/integration/medication-compliance-rollup-slot.test.ts b/tests/integration/medication-compliance-rollup-slot.test.ts deleted file mode 100644 index f80bbe07c..000000000 --- a/tests/integration/medication-compliance-rollup-slot.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -/** - * v1.15.19 — slot-level medication-compliance rollup aggregation. - * - * The partial unique index on `(user, medication, scheduled_for, source)` - * lets two LIVE intake rows share one slot instant when their `source` - * differs — the production shape: the reminder worker pre-mints a pending - * REMINDER row on the slot, and a client write that the resolver refused - * to snap (future-slot guard) used to land a standalone API row on the - * exact same instant. The rollup previously counted rows (`COUNT(*)`), so - * a two-dose day with one duplicated slot reported `scheduled=3..4`. - * - * These tests pin the corrected semantics on real Postgres: - * 1. Two live rows on one slot, different source, one taken → - * scheduled=1, taken=1. - * 2. Taken beats skipped inside a slot. - * 3. The recompute self-corrects historic duplicates with no data - * migration (pure SQL, retroactive). - */ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import { getPrismaClient, truncateAllTables } from "./setup"; - -import { - dayKeyForScheduledFor, - recomputeMedicationComplianceForDay, -} from "@/lib/rollups/medication-compliance-rollups"; - -const TEST_USER_ID = "user-compliance-rollup-slot"; -const TZ = "Europe/Berlin"; - -vi.mock("@/lib/db-compat", () => ({ - ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), -})); - -let medId = ""; - -beforeEach(async () => { - const prisma = getPrismaClient(); - await truncateAllTables(prisma); - await prisma.user.create({ - data: { - id: TEST_USER_ID, - username: "compliance-rollup-slot", - email: "compliance-rollup-slot@example.test", - timezone: TZ, - }, - }); - const med = await prisma.medication.create({ - data: { - userId: TEST_USER_ID, - name: "Ramipril", - dose: "5mg", - active: true, - }, - }); - medId = med.id; -}); - -// Fixed non-DST-boundary day so the slot instants are deterministic. -// 2026-03-10 07:00 / 19:00 Europe/Berlin (CET, UTC+1). -const SLOT_0700 = new Date("2026-03-10T06:00:00.000Z"); -const SLOT_1900 = new Date("2026-03-10T18:00:00.000Z"); - -async function readRollup() { - const prisma = getPrismaClient(); - const dayKey = dayKeyForScheduledFor(SLOT_0700, TZ); - await recomputeMedicationComplianceForDay(TEST_USER_ID, medId, dayKey, TZ); - return prisma.medicationComplianceRollup.findFirst({ - where: { userId: TEST_USER_ID, medicationId: medId, day: dayKey }, - }); -} - -describe("medication-compliance rollup — slot-level aggregation (real Postgres)", () => { - it("counts a cross-source duplicate slot once: scheduled=1, taken=1", async () => { - const prisma = getPrismaClient(); - // The pending REMINDER row the worker minted on the slot… - await prisma.medicationIntakeEvent.create({ - data: { - userId: TEST_USER_ID, - medicationId: medId, - scheduledFor: SLOT_0700, - takenAt: null, - skipped: false, - source: "REMINDER", - }, - }); - // …and the standalone API row a pre-fix client write parked on the - // exact same instant (the unique index tolerates it: source differs). - await prisma.medicationIntakeEvent.create({ - data: { - userId: TEST_USER_ID, - medicationId: medId, - scheduledFor: SLOT_0700, - takenAt: new Date("2026-03-10T06:02:00.000Z"), - skipped: false, - source: "API", - }, - }); - - const rollup = await readRollup(); - expect(rollup).not.toBeNull(); - expect(rollup?.scheduled).toBe(1); - expect(rollup?.taken).toBe(1); - expect(rollup?.skipped).toBe(0); - }); - - it("keeps a two-dose day at scheduled=2 when one slot is duplicated", async () => { - const prisma = getPrismaClient(); - await prisma.medicationIntakeEvent.createMany({ - data: [ - // Duplicated morning slot: pending REMINDER + taken API. - { - userId: TEST_USER_ID, - medicationId: medId, - scheduledFor: SLOT_0700, - takenAt: null, - skipped: false, - source: "REMINDER", - }, - { - userId: TEST_USER_ID, - medicationId: medId, - scheduledFor: SLOT_0700, - takenAt: new Date("2026-03-10T06:01:00.000Z"), - skipped: false, - source: "API", - }, - // Clean evening slot, still pending. - { - userId: TEST_USER_ID, - medicationId: medId, - scheduledFor: SLOT_1900, - takenAt: null, - skipped: false, - source: "REMINDER", - }, - ], - }); - - const rollup = await readRollup(); - // Pre-fix this day reported scheduled=3 (row count). Slot-level it is 2. - expect(rollup?.scheduled).toBe(2); - expect(rollup?.taken).toBe(1); - expect(rollup?.skipped).toBe(0); - }); - - it("taken beats skipped inside one slot", async () => { - const prisma = getPrismaClient(); - await prisma.medicationIntakeEvent.createMany({ - data: [ - { - userId: TEST_USER_ID, - medicationId: medId, - scheduledFor: SLOT_0700, - takenAt: null, - skipped: true, - source: "REMINDER", - }, - { - userId: TEST_USER_ID, - medicationId: medId, - scheduledFor: SLOT_0700, - takenAt: new Date("2026-03-10T06:05:00.000Z"), - skipped: false, - source: "API", - }, - ], - }); - - const rollup = await readRollup(); - expect(rollup?.scheduled).toBe(1); - expect(rollup?.taken).toBe(1); - expect(rollup?.skipped).toBe(0); - }); - - it("counts a purely skipped slot as skipped, not taken", async () => { - const prisma = getPrismaClient(); - await prisma.medicationIntakeEvent.create({ - data: { - userId: TEST_USER_ID, - medicationId: medId, - scheduledFor: SLOT_0700, - takenAt: null, - skipped: true, - source: "WEB", - }, - }); - - const rollup = await readRollup(); - expect(rollup?.scheduled).toBe(1); - expect(rollup?.taken).toBe(0); - expect(rollup?.skipped).toBe(1); - }); - - it("ignores soft-deleted rows when folding a slot", async () => { - const prisma = getPrismaClient(); - await prisma.medicationIntakeEvent.createMany({ - data: [ - { - userId: TEST_USER_ID, - medicationId: medId, - scheduledFor: SLOT_0700, - takenAt: new Date("2026-03-10T06:02:00.000Z"), - skipped: false, - source: "API", - deletedAt: new Date(), - }, - { - userId: TEST_USER_ID, - medicationId: medId, - scheduledFor: SLOT_0700, - takenAt: null, - skipped: false, - source: "REMINDER", - }, - ], - }); - - const rollup = await readRollup(); - // The tombstoned taken row must not paint the live pending slot taken. - expect(rollup?.scheduled).toBe(1); - expect(rollup?.taken).toBe(0); - expect(rollup?.skipped).toBe(0); - }); -}); diff --git a/tests/integration/medication-dose-history-import.test.ts b/tests/integration/medication-dose-history-import.test.ts index ddcc2ec54..7d438f990 100644 --- a/tests/integration/medication-dose-history-import.test.ts +++ b/tests/integration/medication-dose-history-import.test.ts @@ -286,24 +286,4 @@ describe("dose-history import — the reported export end to end", () => { }); expect(stillInactive).toBe(16); }, 240_000); - - it("re-folds the compliance rollup for every medication and day it touched", async () => { - const prisma = getPrismaClient(); - const user = await seedRegimen(); - const { jobId } = await enqueue(user.id); - await processMedicationIntakeImportJob(jobId); - - const rollups = await prisma.medicationComplianceRollup.findMany({ - where: { userId: user.id }, - select: { medicationId: true, day: true }, - }); - // One rollup row per (medication, local day) the import wrote into. The job - // spans sixteen medications, so a bare day key could not have addressed - // them — two medications on one day are two rows. - expect(rollups.length).toBeGreaterThan(3000); - expect(new Set(rollups.map((row) => row.medicationId)).size).toBe(16); - expect( - new Set(rollups.map((row) => `${row.medicationId} ${row.day}`)).size, - ).toBe(rollups.length); - }, 480_000); }); diff --git a/tests/integration/medication-intake-import-job.test.ts b/tests/integration/medication-intake-import-job.test.ts index d438fbe3b..38f0bb1ab 100644 --- a/tests/integration/medication-intake-import-job.test.ts +++ b/tests/integration/medication-intake-import-job.test.ts @@ -106,7 +106,7 @@ describe("medication intake import job — concurrent retry", () => { ]); await processMedicationIntakeImportJob(job.id); - const [events, refreshedInventory, rollups, refreshedJob, audits] = + const [events, refreshedInventory, refreshedJob, audits] = await Promise.all([ prisma.medicationIntakeEvent.findMany({ where: { userId: user.id, medicationId: medication.id }, @@ -115,10 +115,6 @@ describe("medication intake import job — concurrent retry", () => { prisma.medicationInventoryItem.findUniqueOrThrow({ where: { id: inventory.id }, }), - prisma.medicationComplianceRollup.findMany({ - where: { userId: user.id, medicationId: medication.id }, - orderBy: { day: "asc" }, - }), prisma.medicationIntakeImportJob.findUniqueOrThrow({ where: { id: job.id }, }), @@ -144,16 +140,6 @@ describe("medication intake import job — concurrent retry", () => { ), ).toBe(true); expect(Number(refreshedInventory.unitsRemaining)).toBe(7); - expect( - rollups.map(({ day, scheduled, taken }) => ({ - day, - scheduled, - taken, - })), - ).toEqual([ - { day: "2026-07-20", scheduled: 2, taken: 2 }, - { day: "2026-07-21", scheduled: 1, taken: 1 }, - ]); expect(refreshedJob.status).toBe("done"); // The two entries sharing an instant collapse inside the submission; that // is `duplicate_in_file`, and it is reported under its own name rather @@ -168,14 +154,10 @@ describe("medication intake import job — concurrent retry", () => { total: 4, imported: 3, skippedByReason: { duplicate_in_file: 1 }, - // A job can now cover a whole regimen, so a day whose rollup needs - // re-folding is addressed by medication AND day — two medications touched - // on one day are two rollup rows, which a bare day key cannot name. - touchedDays: [ - `${medication.id} 2026-07-20`, - `${medication.id} 2026-07-21`, - ], - rollupProcessed: 2, + // Inert since the compliance rollup was dropped: written at admission + // and never advanced. + touchedDays: [], + rollupProcessed: 0, }); expect(audits).toHaveLength(1); expect(invalidateUserMedications).toHaveBeenCalledTimes(1); @@ -270,9 +252,6 @@ describe("medication intake import job — concurrent retry", () => { prisma.medicationIntakeEvent.count({ where: { medicationId: medication.id }, }), - prisma.medicationComplianceRollup.count({ - where: { medicationId: medication.id }, - }), prisma.auditLog.count({ where: { userId: user.id, @@ -285,20 +264,16 @@ describe("medication intake import job — concurrent retry", () => { ]); expect(afterFailedFinalization[0]).toBe(1); expect(afterFailedFinalization[1]).toBe(0); - expect(afterFailedFinalization[2]).toBe(0); - expect(afterFailedFinalization[3].status).toBe("running"); + expect(afterFailedFinalization[2].status).toBe("running"); _setMedicationIntakeImportPrismaForTests(prisma); await processMedicationIntakeImportJob(job.id); await processMedicationIntakeImportJob(job.id); - const [events, rollups, audits, completedJob] = await Promise.all([ + const [events, audits, completedJob] = await Promise.all([ prisma.medicationIntakeEvent.findMany({ where: { medicationId: medication.id }, }), - prisma.medicationComplianceRollup.findMany({ - where: { medicationId: medication.id }, - }), prisma.auditLog.findMany({ where: { userId: user.id, @@ -310,7 +285,6 @@ describe("medication intake import job — concurrent retry", () => { }), ]); expect(events).toHaveLength(1); - expect(rollups).toHaveLength(1); expect(audits).toHaveLength(1); expect(completedJob.status).toBe("done"); }); diff --git a/tests/integration/medication-reminder-cross-midnight.test.ts b/tests/integration/medication-reminder-cross-midnight.test.ts index 64a4072d7..b354d179a 100644 --- a/tests/integration/medication-reminder-cross-midnight.test.ts +++ b/tests/integration/medication-reminder-cross-midnight.test.ts @@ -14,9 +14,6 @@ vi.mock("@/lib/notifications/dispatcher", () => ({ channelsSucceeded: 1, }), })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), -})); const { dispatchNotification } = await import("@/lib/notifications/dispatcher"); const { handleReminderCheck } = diff --git a/tests/integration/sharing-delegable-writes.test.ts b/tests/integration/sharing-delegable-writes.test.ts index 735a99f86..5d698aade 100644 --- a/tests/integration/sharing-delegable-writes.test.ts +++ b/tests/integration/sharing-delegable-writes.test.ts @@ -1337,30 +1337,4 @@ describe("marking a dose — what the owner is told, and what the rollup recompu status: "taken", }); }); - - it("recomputes the compliance rollup under the OWNER's id", async () => { - // The dose landed in the owner's record; a rollup row keyed on the - // delegate would leave the owner's compliance reading stale and give the - // delegate a row about a medication they do not have. - const owner = await makeUser("owner"); - const delegate = await makeUser("delegate"); - const { intakeId } = await seedPendingDose(owner.id); - await switchInto(owner.id, delegate.id, "WRITE"); - - const { POST } = await import("@/app/api/medications/intake/route"); - expect( - ( - await post(POST as Handler, "/api/medications/intake", { - intakeId, - status: "taken", - }) - ).status, - ).toBe(200); - - const rollups = await getPrismaClient().medicationComplianceRollup.findMany( - { select: { userId: true } }, - ); - expect(rollups.length).toBeGreaterThan(0); - expect([...new Set(rollups.map((r) => r.userId))]).toEqual([owner.id]); - }); }); diff --git a/tests/integration/supplement-nutrients.test.ts b/tests/integration/supplement-nutrients.test.ts index 2a6f68ad0..75f698b0e 100644 --- a/tests/integration/supplement-nutrients.test.ts +++ b/tests/integration/supplement-nutrients.test.ts @@ -494,9 +494,9 @@ describe("taken doses become the day's SUPPLEMENT micronutrients", () => { takenAt: twoDaysAgo, }, }); - const { recomputeMedicationComplianceForEvent } = - await import("@/lib/rollups/medication-compliance-rollups"); - await recomputeMedicationComplianceForEvent({ + const { refreshDoseSlot } = + await import("@/lib/medications/dose-day-refresh"); + await refreshDoseSlot({ userId, medicationId, scheduledFor: twoDaysAgo, From ce8074ad089c325d6c2d6bff86f16955de07e098 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 13:03:12 +0200 Subject: [PATCH 05/63] fix(ui): keep a wrapped label's colon on its last word The label primitive was an inline flex box, so its ::after colon was a flex item of its own: when a long field name wrapped in a narrow column, the colon floated beside the whole text block instead of following the last word. The label now lays out inline and the colon wraps with the text. Call sites that set flex themselves keep their own layout. --- src/components/ui/__tests__/label.test.tsx | 36 ++++++++++++++++++++++ src/components/ui/label.tsx | 13 +++++++- 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 src/components/ui/__tests__/label.test.tsx diff --git a/src/components/ui/__tests__/label.test.tsx b/src/components/ui/__tests__/label.test.tsx new file mode 100644 index 000000000..b2d12d8e7 --- /dev/null +++ b/src/components/ui/__tests__/label.test.tsx @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { Label } from "../label"; + +describe("
  • {t("settings.ai.deviceCodeStep3")}
  • diff --git a/src/components/admin/module-availability-section.tsx b/src/components/admin/module-availability-section.tsx index df5442811..f57122c64 100644 --- a/src/components/admin/module-availability-section.tsx +++ b/src/components/admin/module-availability-section.tsx @@ -23,6 +23,7 @@ import { import type { ModuleKey } from "@/lib/modules/registry"; import { SettingsToggle } from "./_shared"; import { apiGet, apiPatch } from "@/lib/api/api-fetch"; +import { InlineAction } from "@/components/ui/inline-action"; /** * v1.18.0 — operator-side panel for server-wide module availability, the @@ -127,12 +128,11 @@ export function ModuleAvailabilitySection() { onCheckedChange={() => {}} disabled /> - - {t("admin.section.coach.title")} - + + + {t("admin.section.coach.title")} + + ); } diff --git a/src/components/admin/recent-audit-preview.tsx b/src/components/admin/recent-audit-preview.tsx index cab8589b7..0fb5ac6e4 100644 --- a/src/components/admin/recent-audit-preview.tsx +++ b/src/components/admin/recent-audit-preview.tsx @@ -27,6 +27,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { type AdminAuditEntry, useAuthActionLabels } from "./_shared"; import { apiGet } from "@/lib/api/api-fetch"; +import { InlineAction } from "@/components/ui/inline-action"; const PREVIEW_LIMIT = 10; @@ -61,13 +62,12 @@ export function RecentAuditPreview() { titleId="admin-overview-audit-heading" title={t("admin.overview.auditTitle")} status={ - - {t("admin.overview.auditViewAll")} -
    {sourceLink ? ( - - - {t("targets.sourceLabel", { - source: sourceName(target.source, t), - })} - - + + + + {t("targets.sourceLabel", { + source: sourceName(target.source, t), + })} + + + ) : ( {t("targets.sourceLabel", { diff --git a/src/components/insights/sleep/chronotype-card.tsx b/src/components/insights/sleep/chronotype-card.tsx index 14c0e66be..3220acd6c 100644 --- a/src/components/insights/sleep/chronotype-card.tsx +++ b/src/components/insights/sleep/chronotype-card.tsx @@ -8,6 +8,7 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { LearningGate } from "@/components/ui/learning-gate"; import { TileHeader } from "@/components/insights/tile-header"; import type { ChronotypeDto } from "./use-sleep-rhythm"; +import { InlineAction } from "@/components/ui/inline-action"; /** Minutes-of-day → "HH:MM" wall-clock label (24 h, zero-padded). */ function clockLabel(minutesOfDay: number): string { @@ -90,12 +91,12 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) { )}
    - + {showAdvanced && (
    diff --git a/src/components/insights/trends-row.tsx b/src/components/insights/trends-row.tsx index b62e8e06b..eda495290 100644 --- a/src/components/insights/trends-row.tsx +++ b/src/components/insights/trends-row.tsx @@ -23,6 +23,7 @@ import { type TrendAnnotationStatus, } from "./trend-annotation"; import { useUnitDisplay } from "@/hooks/use-unit-display"; +import { InlineAction } from "@/components/ui/inline-action"; /** * Trends row — a small chart per metric above a one-sentence @@ -339,15 +340,20 @@ export function TrendsRow({ is used but was unreachable from Insights. The link sits below the caption so the chart keeps its own interactions. */} {config.detailHref ? ( - - {t("insights.trendsRow.viewDetail", { metric: title })} -
    ); diff --git a/src/components/insights/workout-detail/stats.tsx b/src/components/insights/workout-detail/stats.tsx index 36002d3bd..4aa93b56c 100644 --- a/src/components/insights/workout-detail/stats.tsx +++ b/src/components/insights/workout-detail/stats.tsx @@ -28,6 +28,7 @@ import { formatPace, formatDurationMinutes, } from "./format"; +import { InlineAction } from "@/components/ui/inline-action"; interface StatTileProps { icon: ReactNode; @@ -199,14 +200,15 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) { )} {workout.previousWorkoutId ? ( - - {t("insights.workouts.detail.previousSessionLink")} - - + ) : null}
      diff --git a/src/components/medications/card-parts/medication-compliance-bars.tsx b/src/components/medications/card-parts/medication-compliance-bars.tsx index 2f70c9964..99bd9fe02 100644 --- a/src/components/medications/card-parts/medication-compliance-bars.tsx +++ b/src/components/medications/card-parts/medication-compliance-bars.tsx @@ -3,6 +3,7 @@ import { Flame, RotateCw } from "lucide-react"; import { ComplianceInfoTip } from "@/components/medications/card-parts/compliance-info-tip"; import { Progress } from "@/components/ui/progress"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { InlineAction } from "@/components/ui/inline-action"; interface MedicationComplianceBarsProps { rate7: number; @@ -286,14 +287,10 @@ export function MedicationComplianceError({
      - +
      diff --git a/src/components/medications/detail/medication-detail-tabs.tsx b/src/components/medications/detail/medication-detail-tabs.tsx index bb34d5411..2f0128495 100644 --- a/src/components/medications/detail/medication-detail-tabs.tsx +++ b/src/components/medications/detail/medication-detail-tabs.tsx @@ -104,6 +104,7 @@ import { AutoMarkTakenBody } from "@/components/medications/sections/auto-mark-t import { IntakeTrackingBody } from "@/components/medications/sections/intake-tracking-section"; import { MedicationRelatedSection } from "@/components/medications/detail/medication-related-section"; import type { RelatedLink } from "@/lib/links/related"; +import { InlineAction } from "@/components/ui/inline-action"; // v1.15.20 — Recharts stays out of the detail page's initial bundle: // both chart bodies load through `next/dynamic` (client-only) with the @@ -1097,14 +1098,13 @@ function StatusRow({

      {label}

      {value}

      - + ); } diff --git a/src/components/medications/sections/inventory-section.tsx b/src/components/medications/sections/inventory-section.tsx index 7af96d638..1bab5744a 100644 --- a/src/components/medications/sections/inventory-section.tsx +++ b/src/components/medications/sections/inventory-section.tsx @@ -79,6 +79,7 @@ import { type InventoryItem, type InventoryState, } from "./inventory-shared"; +import { InlineAction } from "@/components/ui/inline-action"; export { AddInventoryDialog, AdjustInventoryDialog } from "./inventory-dialogs"; export { invalidateSupplyQueries } from "./inventory-shared"; @@ -314,17 +315,18 @@ export function InventorySection({ of what was shared" panel. */} {!inSharedRecord && (
      - - {lowStockDays !== null - ? t("medications.detail.bestand.lowStockLinkOn", { - days: lowStockDays, - }) - : t("medications.detail.bestand.lowStockLinkOff")} - + + + {lowStockDays !== null + ? t("medications.detail.bestand.lowStockLinkOn", { + days: lowStockDays, + }) + : t("medications.detail.bestand.lowStockLinkOff")} + +
      )} diff --git a/src/components/mood/mood-context-fields.tsx b/src/components/mood/mood-context-fields.tsx index 533e04384..35f587b64 100644 --- a/src/components/mood/mood-context-fields.tsx +++ b/src/components/mood/mood-context-fields.tsx @@ -55,6 +55,7 @@ import { contextValueLabelKey, } from "@/lib/mood/context-vocabulary"; import type { LinkedDayContext, LinkedFigure } from "@/lib/mood/linked-context"; +import { InlineAction } from "@/components/ui/inline-action"; const CONTEXT_NOTE_MAX_LENGTH = 500; @@ -683,16 +684,17 @@ export function MoodLinkedDaySection({