From 0919d8b962717b52bcdd80d91714a0f639a446a6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 01:44:41 +0200
Subject: [PATCH 01/52] fix(settings): make Delete All Data delete what it says
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The wipe deleted thirteen tables against a schema that carries more than
eighty user-scoped ones. Mental-health assessments, lab results, the
menstrual record, allergies, family history, the document vault, every AI
conversation and live clinician share links all survived a confirmation
that promised to permanently delete all health data. So did
MeasurementRollup, the per-day reconstruction of the measurements the same
transaction had just removed.
The list now lives in src/lib/data-wipe/wipe-plan.ts next to its own
exemption list, and a structural guard enumerates schema.prisma and fails
when a model belongs to neither. Adding a model can no longer widen the set
of things that survive a wipe by default. The same guard covers the User
row's own columns, which had drifted the same way.
The boundary is explicit: everything the account owns goes, except what is
needed to sign in again. The confirmation and the toast say so before the
person types the confirmation rather than after.
Checkup reminders and allergies promised permanence over a soft delete with
no restore path; both now hard-delete, so the dialog is true. The lab
biomarker dialog was checked and is already accurate — it maps to the
hard-deleting /api/biomarkers/[id] route, not to the soft-deleting lab
result route.
---
messages/de.json | 6 +-
messages/en.json | 6 +-
messages/es.json | 6 +-
messages/fr.json | 6 +-
messages/it.json | 6 +-
messages/pl.json | 6 +-
.../copy-matches-behaviour-guard.test.ts | 9 +-
src/__tests__/data-wipe-completeness.test.ts | 286 +++++++++++++
src/app/api/allergies/[id]/route.ts | 26 +-
.../api/measurement-reminders/[id]/route.ts | 13 +-
src/app/api/settings/data/route.ts | 136 +++---
src/lib/data-wipe/wipe-plan.ts | 403 ++++++++++++++++++
12 files changed, 796 insertions(+), 113 deletions(-)
create mode 100644 src/__tests__/data-wipe-completeness.test.ts
create mode 100644 src/lib/data-wipe/wipe-plan.ts
diff --git a/messages/de.json b/messages/de.json
index 4728f85d6..ee218f830 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -5217,10 +5217,10 @@
"apiEndpointAuth": "Authentifizierung",
"apiEndpointExample": "Body-Beispiel",
"dangerZone": "Alle Daten löschen",
- "dangerZoneDescription": "Löscht alle deine Gesundheitsdaten und Integrationen. Dein Nutzerkonto bleibt erhalten.",
+ "dangerZoneDescription": "Löscht deine gesamte Gesundheitsakte und jede Integrationsverbindung. Dein Konto und deine Anmeldedaten bleiben erhalten.",
"dangerZoneConfirm": "Alle Daten löschen?",
- "dangerZoneConfirmDescription": "Dieser Vorgang löscht alle deine Gesundheitsdaten und Integrationen unwiderruflich. Dein Nutzerkonto bleibt erhalten.",
- "dangerZoneSuccess": "Alle persönlichen Daten wurden gelöscht",
+ "dangerZoneConfirmDescription": "Dieser Vorgang löscht unwiderruflich alles in deiner Akte: Messwerte, Medikamente, Stimmungseinträge, Laborwerte, Zykluseinträge, Dokumente, KI-Gespräche und jede Integrationsverbindung, dazu alles daraus Abgeleitete und alles, was du früher gelöscht hast. Freigabe-Links, die du an eine Praxis gegeben hast, funktionieren sofort nicht mehr. Dein Konto bleibt bestehen, ebenso deine Anmeldedaten und Anzeigeeinstellungen wie Sprache, Zeitzone und Einheiten. Das lässt sich nicht rückgängig machen.",
+ "dangerZoneSuccess": "Deine Gesundheitsakte wurde gelöscht. Dein Konto und deine Anmeldung bleiben unverändert.",
"dangerZoneDeleteFailed": "Löschen fehlgeschlagen",
"finalDelete": "Endgültig löschen",
"deleteAccountCardTitle": "Konto vollständig löschen",
diff --git a/messages/en.json b/messages/en.json
index 4191253be..4c498d987 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -5217,10 +5217,10 @@
"apiEndpointAuth": "Authentication",
"apiEndpointExample": "Body example",
"dangerZone": "Delete All Data",
- "dangerZoneDescription": "Deletes all your health data and integrations. Your user account will be preserved.",
+ "dangerZoneDescription": "Erases your whole health record and every integration connection. Your account and the credentials you sign in with are kept.",
"dangerZoneConfirm": "Delete all data?",
- "dangerZoneConfirmDescription": "This will permanently delete all your health data and integrations. Your user account will be preserved.",
- "dangerZoneSuccess": "All personal data has been deleted",
+ "dangerZoneConfirmDescription": "This permanently deletes everything in your record: measurements, medications, mood entries, lab results, cycle entries, documents, AI conversations and every integration connection, together with everything derived from them and anything you deleted earlier. Share links you gave to a doctor stop working immediately. Your account stays, along with the credentials you sign in with and display settings such as language, timezone and units. This cannot be undone.",
+ "dangerZoneSuccess": "Your health record has been deleted. Your account and sign-in are unchanged.",
"dangerZoneDeleteFailed": "Deletion failed",
"finalDelete": "Delete permanently",
"deleteAccountCardTitle": "Delete account entirely",
diff --git a/messages/es.json b/messages/es.json
index d046fbd97..8c609dfcd 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -5217,10 +5217,10 @@
"apiEndpointAuth": "Autenticación",
"apiEndpointExample": "Ejemplo de cuerpo",
"dangerZone": "Eliminar todos los datos",
- "dangerZoneDescription": "Elimina todos tus datos de salud e integraciones. Tu cuenta de usuario se conservará.",
+ "dangerZoneDescription": "Borra todo tu historial de salud y todas las conexiones de integración. Tu cuenta y las credenciales con las que inicias sesión se conservan.",
"dangerZoneConfirm": "¿Eliminar todos los datos?",
- "dangerZoneConfirmDescription": "Esto eliminará de forma permanente todos tus datos de salud e integraciones. Tu cuenta de usuario se conservará.",
- "dangerZoneSuccess": "Se han eliminado todos los datos personales",
+ "dangerZoneConfirmDescription": "Esto elimina de forma permanente todo lo que hay en tu historial: mediciones, medicamentos, entradas de ánimo, resultados de laboratorio, entradas de ciclo, documentos, conversaciones con la IA y todas las conexiones de integración, junto con todo lo derivado de ello y todo lo que eliminaste antes. Los enlaces compartidos que diste a una consulta dejan de funcionar de inmediato. Tu cuenta se mantiene, junto con las credenciales con las que inicias sesión y los ajustes de presentación como el idioma, la zona horaria y las unidades. Esto no se puede deshacer.",
+ "dangerZoneSuccess": "Se ha eliminado tu historial de salud. Tu cuenta y tu inicio de sesión no han cambiado.",
"dangerZoneDeleteFailed": "Falló la eliminación",
"finalDelete": "Eliminar permanentemente",
"deleteAccountCardTitle": "Eliminar la cuenta por completo",
diff --git a/messages/fr.json b/messages/fr.json
index 2eda57ea9..678b46765 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -5217,10 +5217,10 @@
"apiEndpointAuth": "Authentification",
"apiEndpointExample": "Exemple de corps",
"dangerZone": "Supprimer toutes les données",
- "dangerZoneDescription": "Supprime toutes vos données de santé et intégrations. Votre compte utilisateur sera conservé.",
+ "dangerZoneDescription": "Efface l’intégralité de votre dossier de santé et toutes les connexions d’intégration. Votre compte et vos identifiants de connexion sont conservés.",
"dangerZoneConfirm": "Supprimer toutes les données ?",
- "dangerZoneConfirmDescription": "Cette action supprimera définitivement toutes vos données de santé et intégrations. Votre compte utilisateur sera conservé.",
- "dangerZoneSuccess": "Toutes les données personnelles ont été supprimées",
+ "dangerZoneConfirmDescription": "Cette action supprime définitivement tout ce que contient votre dossier : mesures, médicaments, entrées d’humeur, résultats de laboratoire, entrées de cycle, documents, conversations avec l’IA et toutes les connexions d’intégration, ainsi que tout ce qui en découle et tout ce que vous aviez supprimé auparavant. Les liens de partage remis à un cabinet cessent aussitôt de fonctionner. Votre compte reste en place, de même que vos identifiants de connexion et vos réglages d’affichage comme la langue, le fuseau horaire et les unités. Cette action est irréversible.",
+ "dangerZoneSuccess": "Votre dossier de santé a été supprimé. Votre compte et votre connexion sont inchangés.",
"dangerZoneDeleteFailed": "Échec de la suppression",
"finalDelete": "Supprimer définitivement",
"deleteAccountCardTitle": "Supprimer entièrement le compte",
diff --git a/messages/it.json b/messages/it.json
index f772e54ed..5a791cdc9 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -5217,10 +5217,10 @@
"apiEndpointAuth": "Autenticazione",
"apiEndpointExample": "Esempio di body",
"dangerZone": "Elimina tutti i dati",
- "dangerZoneDescription": "Elimina tutti i tuoi dati sanitari e le integrazioni. Il tuo account utente verrà conservato.",
+ "dangerZoneDescription": "Cancella l’intera cartella sanitaria e ogni connessione di integrazione. Il tuo account e le credenziali con cui accedi restano.",
"dangerZoneConfirm": "Eliminare tutti i dati?",
- "dangerZoneConfirmDescription": "Questa azione eliminerà definitivamente tutti i tuoi dati sanitari e le integrazioni. Il tuo account utente verrà conservato.",
- "dangerZoneSuccess": "Tutti i dati personali sono stati eliminati",
+ "dangerZoneConfirmDescription": "Questa azione elimina definitivamente tutto ciò che contiene la tua cartella: misurazioni, farmaci, voci dell’umore, risultati di laboratorio, voci del ciclo, documenti, conversazioni con l’IA e ogni connessione di integrazione, insieme a tutto ciò che ne deriva e a tutto quello che avevi eliminato prima. I link di condivisione consegnati a uno studio medico smettono subito di funzionare. Il tuo account resta, insieme alle credenziali con cui accedi e alle impostazioni di visualizzazione come lingua, fuso orario e unità. Non può essere annullato.",
+ "dangerZoneSuccess": "La tua cartella sanitaria è stata eliminata. Account e accesso restano invariati.",
"dangerZoneDeleteFailed": "Eliminazione non riuscita",
"finalDelete": "Elimina definitivamente",
"deleteAccountCardTitle": "Elimina completamente l’account",
diff --git a/messages/pl.json b/messages/pl.json
index 45354f0dc..92b747092 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -5217,10 +5217,10 @@
"apiEndpointAuth": "Uwierzytelnianie",
"apiEndpointExample": "Przykład treści",
"dangerZone": "Usuń wszystkie dane",
- "dangerZoneDescription": "Usuwa wszystkie Twoje dane zdrowotne i integracje. Twoje konto użytkownika zostanie zachowane.",
+ "dangerZoneDescription": "Kasuje całą Twoją dokumentację zdrowotną i wszystkie połączenia integracji. Konto i dane logowania zostają zachowane.",
"dangerZoneConfirm": "Usunąć wszystkie dane?",
- "dangerZoneConfirmDescription": "Spowoduje to trwałe usunięcie wszystkich Twoich danych zdrowotnych i integracji. Twoje konto użytkownika zostanie zachowane.",
- "dangerZoneSuccess": "Wszystkie dane osobowe zostały usunięte",
+ "dangerZoneConfirmDescription": "Ta czynność trwale usuwa wszystko z Twojej dokumentacji: pomiary, leki, wpisy nastroju, wyniki badań, wpisy cyklu, dokumenty, rozmowy z SI oraz wszystkie połączenia integracji, a wraz z nimi wszystko, co z nich wyliczono, i wszystko, co usunięto wcześniej. Linki udostępnione poradni natychmiast przestają działać. Konto zostaje, razem z danymi logowania i ustawieniami wyświetlania, takimi jak język, strefa czasowa i jednostki. Nie można tego cofnąć.",
+ "dangerZoneSuccess": "Twoja dokumentacja zdrowotna została usunięta. Konto i logowanie pozostają bez zmian.",
"dangerZoneDeleteFailed": "Usuwanie nie powiodło się",
"finalDelete": "Usuń trwale",
"deleteAccountCardTitle": "Usuń całe konto",
diff --git a/src/__tests__/copy-matches-behaviour-guard.test.ts b/src/__tests__/copy-matches-behaviour-guard.test.ts
index 5b26a9c1c..345f18467 100644
--- a/src/__tests__/copy-matches-behaviour-guard.test.ts
+++ b/src/__tests__/copy-matches-behaviour-guard.test.ts
@@ -69,11 +69,12 @@ const FINALITY_PHRASES: Record = {
],
it: [
"non può essere annullata",
+ "non può essere annullato",
"definitivamente",
"definitiva",
"permanente",
],
- pl: ["nie można cofnąć", "trwale", "nieodwracaln"],
+ pl: ["nie można cofnąć", "nie można tego cofnąć", "trwale", "nieodwracaln"],
};
/** Per-locale phrase that promises the short undo window. */
@@ -108,6 +109,12 @@ const PERMANENT_CONFIRM_KEYS = [
"mood.manage.purgeBody",
// GDPR Art. 17 account purge
"settings.deleteAccountConfirmDescription",
+ // hard delete of every user-scoped row except the sign-in credentials
+ "settings.dangerZoneConfirmDescription",
+ // hard delete since v1.32.40 — the tombstone served nothing and the
+ // dialog already promised permanence
+ "measurementReminders.deleteConfirmDescription",
+ "records.allergies.deleteConfirmDescription",
] as const;
describe("delete confirmations describe what the surface actually does", () => {
diff --git a/src/__tests__/data-wipe-completeness.test.ts b/src/__tests__/data-wipe-completeness.test.ts
new file mode 100644
index 000000000..98315046d
--- /dev/null
+++ b/src/__tests__/data-wipe-completeness.test.ts
@@ -0,0 +1,286 @@
+/**
+ * Structural guard binding `prisma/schema.prisma` to the "Delete All Data"
+ * wipe plan.
+ *
+ * The failure it exists to catch: a model is added to the schema, it is
+ * user-scoped from birth, and the wipe list is "the follow-up". Nothing fails
+ * — the route keeps deleting what it always deleted, the toast keeps saying
+ * "all personal data has been deleted", and the new table quietly joins the
+ * set of things that survive a confirmation the person typed by hand. That is
+ * how a 13-table wipe ended up facing an 80-table schema.
+ *
+ * Both ends are literals — Prisma model blocks on one side, exported arrays
+ * and objects on the other — so the matcher is exact. Nothing here greps for
+ * intent.
+ *
+ * What it proves:
+ * 1. every model carrying a `userId` column is either wiped or exempt with a
+ * reason;
+ * 2. every model without one either reaches a wiped model through a chain of
+ * required `onDelete: Cascade` relations (so the database removes it) or
+ * is declared instance-scoped;
+ * 3. every `User` column is either reset or kept with a reason;
+ * 4. the delete order lists children before parents, so the per-model counts
+ * in the audit row are truthful;
+ * 5. no exemption has gone stale.
+ *
+ * Its honest limit: it proves classification, not judgement. It cannot tell
+ * that a model was put on the exempt list for a bad reason — only that
+ * somebody had to write a reason down. And it reads relations, so a table
+ * linked to an account by a bare `userId` string with no foreign key (there
+ * are two: `TelegramScheduledDeletion`, `TelegramPromptContext`) is caught by
+ * rule 1 only because the column name matches the convention.
+ *
+ * Mutation check: remove any entry from `WIPE_MODELS` and this goes red naming
+ * that model; add a column to `model User` without classifying it and it goes
+ * red naming the column.
+ */
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { describe, it, expect } from "vitest";
+
+import {
+ INSTANCE_SCOPED,
+ USER_KEPT_FIELDS,
+ USER_RESET,
+ WIPE_EXEMPT,
+ WIPE_MODELS,
+ wipeDelegateKey,
+} from "@/lib/data-wipe/wipe-plan";
+
+const SCHEMA = join(process.cwd(), "prisma", "schema.prisma");
+
+interface Relation {
+ /** Target model name. */
+ target: string;
+ cascade: boolean;
+ /** `true` when the relation field is optional (`Model?`). */
+ optional: boolean;
+}
+
+interface ModelShape {
+ scalars: string[];
+ relations: Relation[];
+}
+
+function parseSchema(schema: string): Map {
+ const blocks = [...schema.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm)].map(
+ (m) => ({ name: m[1], body: m[2] }),
+ );
+ const modelNames = new Set(blocks.map((b) => b.name));
+
+ const out = new Map();
+ for (const block of blocks) {
+ const scalars: string[] = [];
+ const relations: Relation[] = [];
+ for (const rawLine of block.body.split("\n")) {
+ const line = rawLine.trim();
+ if (!line || line.startsWith("//") || line.startsWith("@@")) continue;
+ const match = /^(\w+)\s+(\w+)(\[\])?(\?)?/.exec(line);
+ if (!match) continue;
+ const [, field, type, isList, isOptional] = match;
+ if (modelNames.has(type)) {
+ // A list side of a relation carries no column and cannot cascade.
+ if (isList) continue;
+ relations.push({
+ target: type,
+ cascade: line.includes("onDelete: Cascade"),
+ optional: Boolean(isOptional),
+ });
+ continue;
+ }
+ if (isList && type === type.toUpperCase()) {
+ // Scalar / enum list column — still a column.
+ scalars.push(field);
+ continue;
+ }
+ scalars.push(field);
+ }
+ out.set(block.name, { scalars, relations });
+ }
+ return out;
+}
+
+const schemaText = readFileSync(SCHEMA, "utf8");
+const models = parseSchema(schemaText);
+const wipeSet = new Set(WIPE_MODELS);
+
+describe("data-wipe completeness", () => {
+ it("parses the schema into a plausible shape", () => {
+ expect(models.size).toBeGreaterThan(100);
+ expect(models.get("Measurement")?.scalars).toContain("userId");
+ expect(models.get("Measurement")?.scalars).not.toContain("user");
+ expect(models.get("User")?.scalars).toContain("heightCm");
+ expect(models.get("User")?.scalars).not.toContain("measurements");
+ expect(
+ models.get("CoachMessage")?.relations.map((r) => r.target),
+ ).toContain("CoachConversation");
+ });
+
+ it("every model with a userId column is wiped or exempt with a reason", () => {
+ const userScoped = [...models]
+ .filter(([, shape]) => shape.scalars.includes("userId"))
+ .map(([name]) => name);
+
+ expect(userScoped.length).toBeGreaterThan(80);
+
+ const unclassified = userScoped.filter(
+ (name) =>
+ !wipeSet.has(name) &&
+ !(name in WIPE_EXEMPT) &&
+ !(name in INSTANCE_SCOPED),
+ );
+
+ if (unclassified.length > 0) {
+ throw new Error(
+ `${unclassified.length} user-scoped model(s) are in neither the wipe list nor the exemption list:\n` +
+ unclassified.map((n) => ` ❌ ${n}`).join("\n") +
+ "\n\nA person who types the confirmation on Settings → Delete All Data " +
+ "expects these rows gone. Add each to WIPE_MODELS, or to WIPE_EXEMPT " +
+ "with the reason keeping it is not account deletion — and say so in " +
+ "the confirmation copy.",
+ );
+ }
+
+ const bothWays = userScoped.filter(
+ (name) => wipeSet.has(name) && name in WIPE_EXEMPT,
+ );
+ expect(bothWays, "a model cannot be both wiped and exempt").toEqual([]);
+ });
+
+ it("every model without a userId column is reached by cascade or declared instance-scoped", () => {
+ // Fixpoint over required cascade relations: a model is covered when the
+ // database removes it as a consequence of a row the wipe deletes.
+ const covered = new Set(wipeSet);
+ for (;;) {
+ let grew = false;
+ for (const [name, shape] of models) {
+ if (covered.has(name)) continue;
+ const reached = shape.relations.some(
+ (r) => r.cascade && !r.optional && covered.has(r.target),
+ );
+ if (reached) {
+ covered.add(name);
+ grew = true;
+ }
+ }
+ if (!grew) break;
+ }
+
+ const orphans = [...models]
+ .filter(([name, shape]) => !shape.scalars.includes("userId"))
+ .map(([name]) => name)
+ .filter(
+ (name) =>
+ !covered.has(name) &&
+ !(name in INSTANCE_SCOPED) &&
+ !(name in WIPE_EXEMPT),
+ );
+
+ if (orphans.length > 0) {
+ throw new Error(
+ `${orphans.length} model(s) carry no userId, are not removed by any cascade from a wiped row, and are not declared instance-scoped:\n` +
+ orphans.map((n) => ` ❌ ${n}`).join("\n") +
+ "\n\nEither give the parent relation onDelete: Cascade, add the model " +
+ "to WIPE_MODELS, or add it to INSTANCE_SCOPED with the reason it " +
+ "belongs to the instance rather than to an account.",
+ );
+ }
+ });
+
+ it("every User column is either reset or kept with a reason", () => {
+ const columns = models.get("User")?.scalars ?? [];
+ expect(columns.length).toBeGreaterThan(80);
+
+ const reset = new Set(Object.keys(USER_RESET));
+ const kept = new Set(Object.keys(USER_KEPT_FIELDS));
+
+ const unclassified = columns.filter((c) => !reset.has(c) && !kept.has(c));
+ if (unclassified.length > 0) {
+ throw new Error(
+ `${unclassified.length} User column(s) are in neither USER_RESET nor USER_KEPT_FIELDS:\n` +
+ unclassified.map((c) => ` ❌ ${c}`).join("\n") +
+ "\n\nA column on the account row survives the wipe by default. Reset " +
+ "it, or record why keeping it is not keeping the person's record.",
+ );
+ }
+
+ const bothWays = columns.filter((c) => reset.has(c) && kept.has(c));
+ expect(bothWays, "a column cannot be both reset and kept").toEqual([]);
+ });
+
+ it("carries no stale classification", () => {
+ for (const name of WIPE_MODELS) {
+ expect(
+ models.has(name),
+ `WIPE_MODELS names "${name}", which is not a model`,
+ ).toBe(true);
+ }
+ for (const [name, reason] of Object.entries(WIPE_EXEMPT)) {
+ expect(
+ models.has(name),
+ `WIPE_EXEMPT names "${name}", which is not a model`,
+ ).toBe(true);
+ expect(
+ reason.length,
+ `WIPE_EXEMPT.${name} needs a real reason`,
+ ).toBeGreaterThan(30);
+ }
+ for (const [name, reason] of Object.entries(INSTANCE_SCOPED)) {
+ expect(
+ models.has(name),
+ `INSTANCE_SCOPED names "${name}", which is not a model`,
+ ).toBe(true);
+ expect(
+ reason.length,
+ `INSTANCE_SCOPED.${name} needs a real reason`,
+ ).toBeGreaterThan(20);
+ }
+ const columns = new Set(models.get("User")?.scalars ?? []);
+ for (const [column, reason] of Object.entries(USER_KEPT_FIELDS)) {
+ expect(
+ columns.has(column),
+ `USER_KEPT_FIELDS names "${column}", which is no longer a User column`,
+ ).toBe(true);
+ expect(
+ reason.length,
+ `USER_KEPT_FIELDS.${column} needs a real reason`,
+ ).toBeGreaterThan(20);
+ }
+ expect(new Set(WIPE_MODELS).size, "WIPE_MODELS repeats a model").toBe(
+ WIPE_MODELS.length,
+ );
+ });
+
+ it("deletes children before their parents so the counts are truthful", () => {
+ const position = new Map(WIPE_MODELS.map((m, i) => [m as string, i]));
+ const violations: string[] = [];
+ for (const child of WIPE_MODELS) {
+ const shape = models.get(child);
+ if (!shape) continue;
+ for (const relation of shape.relations) {
+ if (!relation.cascade) continue;
+ if (relation.target === child) continue;
+ if (!position.has(relation.target)) continue;
+ if (position.get(child)! > position.get(relation.target)!) {
+ violations.push(
+ `${child} (#${position.get(child)}) cascades from ${relation.target} (#${position.get(relation.target)})`,
+ );
+ }
+ }
+ }
+ expect(
+ violations,
+ `WIPE_MODELS deletes a parent before its child, so the child's count reports 0 for rows that existed:\n${violations.join("\n")}`,
+ ).toEqual([]);
+ });
+
+ it("maps every wiped model to a plausible delegate key", () => {
+ for (const model of WIPE_MODELS) {
+ const key = wipeDelegateKey(model);
+ expect(key[0]).toBe(model[0].toLowerCase());
+ expect(key.slice(1)).toBe(model.slice(1));
+ }
+ });
+});
diff --git a/src/app/api/allergies/[id]/route.ts b/src/app/api/allergies/[id]/route.ts
index af618247e..10ee3ccb2 100644
--- a/src/app/api/allergies/[id]/route.ts
+++ b/src/app/api/allergies/[id]/route.ts
@@ -117,23 +117,25 @@ export const DELETE = apiHandler(
const { id } = await params;
const existing = await prisma.allergy.findUnique({
where: { id },
- select: { id: true, userId: true, deletedAt: true },
+ select: { id: true, userId: true },
});
if (!existing || existing.userId !== user.id) {
return apiError("Allergy not found", 404);
}
- if (existing.deletedAt === null) {
- await prisma.allergy.update({
- where: { id },
- data: { deletedAt: new Date() },
- });
- await auditLog("allergy.delete", {
- userId: user.id,
- ipAddress: getClientIp(request),
- details: { allergyId: id },
- });
- }
+ // Hard delete. The confirmation says it can't be undone, and nothing here
+ // needs a tombstone: no restore route reaches an allergy, no other table
+ // references one, it is absent from the `/api/sync/changes` delta feed,
+ // and every read already filters `deletedAt: null`. A row already carrying
+ // a tombstone from before this changed is removed too, so the record
+ // converges on what the dialog promised.
+ await prisma.allergy.delete({ where: { id } });
+
+ await auditLog("allergy.delete", {
+ userId: user.id,
+ ipAddress: getClientIp(request),
+ details: { allergyId: id },
+ });
annotate({
action: { name: "allergy.delete", entity_type: "allergy", entity_id: id },
diff --git a/src/app/api/measurement-reminders/[id]/route.ts b/src/app/api/measurement-reminders/[id]/route.ts
index aad5e46e0..6f37d2fd6 100644
--- a/src/app/api/measurement-reminders/[id]/route.ts
+++ b/src/app/api/measurement-reminders/[id]/route.ts
@@ -206,12 +206,13 @@ export const DELETE = apiHandler(
return apiError("Measurement reminder not found", 404);
}
- // Soft-delete (tombstone) parity with the rest of the tree. A
- // re-delete of an already-tombstoned row is idempotent.
- await prisma.measurementReminder.update({
- where: { id },
- data: { deletedAt: new Date() },
- });
+ // Hard delete. The confirmation says "permanently deleted", and this row
+ // has nothing that a tombstone would serve: it is not in the
+ // `/api/sync/changes` delta feed, no restore route reaches it, no other
+ // table references it, and every read already filters `deletedAt: null`.
+ // The tombstone bought parity with tables that need one and cost the
+ // dialog its accuracy, so the row goes.
+ await prisma.measurementReminder.delete({ where: { id } });
await auditLog("measurementReminder.delete", {
userId: user.id,
diff --git a/src/app/api/settings/data/route.ts b/src/app/api/settings/data/route.ts
index beb67d1fc..38a744be4 100644
--- a/src/app/api/settings/data/route.ts
+++ b/src/app/api/settings/data/route.ts
@@ -9,11 +9,37 @@ import {
} from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { invalidateUserData } from "@/lib/cache/invalidate";
+import {
+ resolveWipeDelegate,
+ USER_RESET,
+ wipeDelegateKey,
+ WIPE_MODELS,
+} from "@/lib/data-wipe/wipe-plan";
export const dynamic = "force-dynamic";
/**
- * Delete all user-owned health/integration data while keeping the account.
+ * Budget for the wipe transaction.
+ *
+ * Prisma's default interactive-transaction timeout is 5 s, which was already
+ * optimistic for thirteen `deleteMany` statements and is not survivable for
+ * the full user-scoped set on an account with a large import behind it. The
+ * whole action is one transaction on purpose — a half-applied wipe is worse
+ * than none, because the person is told it succeeded either way.
+ */
+const WIPE_TRANSACTION_TIMEOUT_MS = 120_000;
+const WIPE_TRANSACTION_MAX_WAIT_MS = 15_000;
+
+/**
+ * Delete every row this account owns, keeping the account and the credentials
+ * that sign into it.
+ *
+ * The set of tables is not written here. It is declared once in
+ * `@/lib/data-wipe/wipe-plan` and held against `prisma/schema.prisma` by
+ * `src/__tests__/data-wipe-completeness.test.ts`, so a model added to the
+ * schema cannot quietly land outside the wipe — which is exactly how this
+ * route came to delete thirteen tables out of eighty-odd while telling people
+ * it had deleted all of them.
*
* v1.23 — gated by a fresh step-up for MFA-enrolled accounts (see the account
* deletion route); single-factor accounts keep the typed-confirmation-only
@@ -40,92 +66,50 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
const userId = user.id;
- const result = await prisma.$transaction(async (tx) => {
- const measurements = await tx.measurement.deleteMany({
- where: { userId },
- });
- const intakeEvents = await tx.medicationIntakeEvent.deleteMany({
- where: { userId },
- });
- const medications = await tx.medication.deleteMany({
- where: { userId },
- });
- const moodEntries = await tx.moodEntry.deleteMany({
- where: { userId },
- });
- // v1.4.39 W-MOOD — wipe the persisted rollup partition for this
- // user so the next analytics read returns an empty envelope.
- await tx.moodEntryRollup.deleteMany({ where: { userId } });
- const apiTokens = await tx.apiToken.deleteMany({
- where: { userId },
- });
- const withingsConnections = await tx.withingsConnection.deleteMany({
- where: { userId },
- });
- const notificationChannels = await tx.notificationChannel.deleteMany({
- where: { userId },
- });
- const pushSubscriptions = await tx.pushSubscription.deleteMany({
- where: { userId },
- });
- const dataBackups = await tx.dataBackup.deleteMany({
- where: { userId },
- });
- const achievements = await tx.userAchievement.deleteMany({
- where: { userId },
- });
- const telegramDeletions = await tx.telegramScheduledDeletion.deleteMany({
- where: { userId },
- });
- const auditLogs = await tx.auditLog.deleteMany({
- where: { userId },
- });
+ const counts = await prisma.$transaction(
+ async (tx) => {
+ const perModel: Record = {};
+ for (const model of WIPE_MODELS) {
+ const { count } = await resolveWipeDelegate(tx, model).deleteMany({
+ where: { userId },
+ });
+ if (count > 0) perModel[wipeDelegateKey(model)] = count;
+ }
- await tx.user.update({
- where: { id: userId },
- data: {
- heightCm: null,
- dateOfBirth: null,
- gender: null,
- insightsPrivacyMode: "aggregated",
- insightsCachedAt: null,
- insightsCachedText: null,
- telegramBotToken: null,
- telegramChatId: null,
- telegramEnabled: false,
- withingsClientIdEncrypted: null,
- withingsClientSecretEncrypted: null,
- onboardingCompletedAt: null,
- },
- });
+ // The account row survives; the personal data carried on its own columns
+ // does not. Same classification contract as the model list.
+ await tx.user.update({ where: { id: userId }, data: USER_RESET });
- return {
- measurements: measurements.count,
- intakeEvents: intakeEvents.count,
- medications: medications.count,
- moodEntries: moodEntries.count,
- apiTokens: apiTokens.count,
- withingsConnections: withingsConnections.count,
- notificationChannels: notificationChannels.count,
- pushSubscriptions: pushSubscriptions.count,
- dataBackups: dataBackups.count,
- achievements: achievements.count,
- telegramDeletions: telegramDeletions.count,
- auditLogs: auditLogs.count,
- };
- });
+ return perModel;
+ },
+ {
+ timeout: WIPE_TRANSACTION_TIMEOUT_MS,
+ maxWait: WIPE_TRANSACTION_MAX_WAIT_MS,
+ },
+ );
+ const deletedRows = Object.values(counts).reduce((sum, n) => sum + n, 0);
+
+ // Written after the transaction commits, so the row cannot be removed by the
+ // AuditLog delete inside it.
await auditLog("user.data.clear", {
userId,
ipAddress: getClientIp(request),
- details: result,
+ details: { deletedRows, models: counts },
});
- annotate({ action: { name: "settings.data.clear" }, meta: result });
+ annotate({
+ action: { name: "settings.data.clear" },
+ meta: {
+ deleted_rows: deletedRows,
+ models_wiped: WIPE_MODELS.length,
+ models_with_rows: Object.keys(counts).length,
+ },
+ });
// v1.16.9 — every cached payload was built on the rows just deleted;
// hard-evict the user's buckets so no surface serves pre-wipe data.
invalidateUserData(userId);
- return apiSuccess({ cleared: true, ...result });
+ return apiSuccess({ cleared: true, deletedRows, models: counts });
});
diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts
new file mode 100644
index 000000000..f14986763
--- /dev/null
+++ b/src/lib/data-wipe/wipe-plan.ts
@@ -0,0 +1,403 @@
+/**
+ * The one declaration of what "Delete All Data" means.
+ *
+ * The route this feeds used to carry its delete calls inline. That list was
+ * correct on the day it was written and then stopped being correct, silently,
+ * every time a model was added to `prisma/schema.prisma` — a new table is
+ * user-scoped from birth and outside the wipe by default, and nothing anywhere
+ * noticed. Sixty-odd tables accumulated on the wrong side of a confirmation
+ * dialog that says "permanently delete all your health data".
+ *
+ * So the list lives here, next to its own exemption list, and
+ * `src/__tests__/data-wipe-completeness.test.ts` enumerates the schema and
+ * fails when a model belongs to neither. Adding a model to the schema now
+ * forces a decision instead of granting a default.
+ *
+ * ── The boundary ───────────────────────────────────────────────────────────
+ *
+ * This action erases everything the account owns EXCEPT what is needed to sign
+ * in again. Removing the sign-in credentials too would not be data deletion,
+ * it would be account deletion — and that has its own route
+ * (`src/app/api/settings/account/route.ts`, which deletes the `User` row and
+ * lets the schema's `onDelete: Cascade` do the rest).
+ *
+ * Everything in {@link WIPE_MODELS} is hard-deleted, including rows that were
+ * only soft-deleted before: `deleteMany({ where: { userId } })` carries no
+ * `deletedAt` filter, so a tombstone goes with the record.
+ *
+ * ── Why the list is ordered ────────────────────────────────────────────────
+ *
+ * Correctness does not depend on the order — every child of a wiped model
+ * cascades at the database level. The order exists so the per-model counts in
+ * the audit row are truthful: delete a parent first and its children are gone
+ * before their own `deleteMany` runs, which would report 0 for rows that did
+ * exist. Children therefore come before their parents, and the completeness
+ * test asserts that property rather than trusting the author.
+ */
+import { Prisma } from "@/generated/prisma/client";
+
+/**
+ * Every user-scoped model this action deletes, children before parents.
+ *
+ * Names are Prisma model names; {@link wipeDelegateKey} maps each to its
+ * client delegate. Keeping model names (not delegate keys) is what lets the
+ * completeness test compare this list against `schema.prisma` literally.
+ */
+export const WIPE_MODELS = [
+ // ── Measurements and their derived tiers ────────────────────────────────
+ "Measurement",
+ // The rollup tier is a per-day, per-metric reconstruction of the same
+ // history. A wipe that leaves it behind is not a wipe.
+ "MeasurementRollup",
+ "MeasurementReminder",
+
+ // ── Medication ──────────────────────────────────────────────────────────
+ "MedicationIntakeEvent",
+ "MedicationPauseEra",
+ "MedicationSideEffect",
+ "MedicationInventoryItem",
+ "MedicationIntakeImportJob",
+ "MedicationComplianceRollup",
+
+ // ── Mood ────────────────────────────────────────────────────────────────
+ "MoodEntry",
+ "MoodEntryRollup",
+ "MoodReminderDispatch",
+ "MoodTagHidden",
+
+ // ── Clinical record ─────────────────────────────────────────────────────
+ "MentalHealthAssessment",
+ "LabResult",
+ "Biomarker",
+ "CustomMetricEntry",
+ "MenstrualCycle",
+ "CycleDayLog",
+ "CycleSymptom",
+ "CycleProfile",
+ "CyclePrediction",
+ "Allergy",
+ "FamilyHistoryEntry",
+ "IllnessDayLog",
+ "EcgRecording",
+ "NutrientIntakeDay",
+
+ // ── Activity ────────────────────────────────────────────────────────────
+ "WorkoutInsight",
+ "WorkoutInsightGenerationClaim",
+ "PersonalRecord",
+ "StrainTrimpCache",
+
+ // ── Context and engagement ──────────────────────────────────────────────
+ "EnvironmentContext",
+ "EnvironmentTravelLocation",
+ "UserHealthProfile",
+ "UserAchievement",
+ "ArrivalReaction",
+ "DismissedPriorityItem",
+ "RecommendationFeedback",
+
+ // ── Documents ───────────────────────────────────────────────────────────
+ "DocumentContentIndex",
+ "DocumentThumbnail",
+ "DocumentConditionLink",
+ "ExtractedFact",
+ "ImportJob",
+
+ // ── AI ──────────────────────────────────────────────────────────────────
+ "CoachConversation",
+ "CoachFact",
+ "CoachPlan",
+ "CoachReminder",
+ "CoachUsage",
+ "InsightNarrative",
+ "ProviderHealth",
+
+ // ── Sharing ─────────────────────────────────────────────────────────────
+ // Deleting the row is the revocation: `resolveShareToken` /
+ // `resolveShareGateState` both resolve an unknown `tokenHash` to `null`, and
+ // null is the same blunt 404 a revoked link gets.
+ "ClinicianShareLink",
+
+ // ── Integrations ────────────────────────────────────────────────────────
+ "WithingsConnection",
+ "WithingsOAuthState",
+ "WhoopConnection",
+ "WhoopOAuthState",
+ "WhoopConnectTicket",
+ "FitbitConnection",
+ "FitbitOAuthState",
+ "GoogleHealthConnection",
+ "GoogleHealthOAuthState",
+ "McpOAuthConnection",
+ "IntegrationStatus",
+
+ // ── Notification transports ─────────────────────────────────────────────
+ "NotificationChannel",
+ "PushSubscription",
+ "PushAttempt",
+ "Device",
+ "TelegramScheduledDeletion",
+ "TelegramPromptContext",
+
+ // ── API surface and operational trails ──────────────────────────────────
+ "StepUpElevation",
+ // Cached response envelopes — the bodies are the health payloads the API
+ // just returned.
+ "IdempotencyKey",
+ "DataBackup",
+ "AuditLog",
+ "ConsentReceipt",
+ "UserKnownDevice",
+
+ // ── Parents, last ───────────────────────────────────────────────────────
+ "Medication",
+ "MoodTag",
+ "MoodTagCategory",
+ "CustomMetric",
+ "IllnessEpisode",
+ "Workout",
+ "InboundDocument",
+ "ApiToken",
+] as const;
+
+export type WipeModel = (typeof WIPE_MODELS)[number];
+
+/**
+ * User-scoped models this action deliberately keeps, each with the reason.
+ *
+ * The rule is narrow and stated in the confirmation copy: what the account
+ * needs to sign in again survives. Anything added here that is not a sign-in
+ * credential needs a reason that survives being read aloud to the person who
+ * typed the confirmation.
+ */
+export const WIPE_EXEMPT: Readonly> = {
+ Session:
+ "the signed-in session performing the wipe; deleting it signs the person out mid-action and the account is preserved",
+ RefreshToken:
+ "per-device sign-in continuity; deleting it signs every paired device out and is account deletion by another name",
+ Passkey: "a sign-in credential — without it the account may be unreachable",
+ WebauthnMfaCredential:
+ "a second-factor credential; removing it weakens the account rather than clearing data",
+ MfaRecoveryCode:
+ "the second-factor recovery path; removing it can lock the account out permanently",
+ MfaChallenge:
+ "an in-flight authentication challenge, seconds-lived and swept by its own expiry",
+ AuthChallenge:
+ "an in-flight WebAuthn challenge, seconds-lived and swept by its own expiry",
+ TrustedDevice:
+ "the remember-this-device grant that suppresses a second-factor prompt; a sign-in credential",
+ OidcNativeHandoff:
+ "an in-flight one-time sign-in code, minutes-lived and swept by its own expiry",
+ InviteRedemption:
+ "the instance's registration ledger, not the person's record; even account deletion only detaches it (onDelete: SetNull) rather than removing it",
+};
+
+/**
+ * Models that carry no `userId` and belong to the instance rather than to any
+ * account, so no wipe of a single account may touch them.
+ *
+ * Everything else without a `userId` reaches a wiped or exempt model through a
+ * chain of `onDelete: Cascade` relations and needs no entry here; the
+ * completeness test proves that reachability rather than assuming it.
+ */
+export const INSTANCE_SCOPED: Readonly> = {
+ AppSettings: "instance-wide configuration singleton",
+ RateLimit: "instance-wide rate-limit buckets, keyed by string, self-expiring",
+ HostMetric: "instance-wide host telemetry, not attributable to an account",
+ CycleSymptomCategory:
+ "seeded catalogue shared by every account; a user's custom symptoms live on CycleSymptom",
+ IllnessSymptom: "seeded symptom catalogue shared by every account",
+ InviteToken:
+ "the instance's invite ledger; the creator relation cascades on account deletion, and a data wipe keeps the account",
+ User: "the account itself — preserved by definition; its columns are handled by USER_RESET / USER_KEPT_FIELDS",
+};
+
+/**
+ * The `User` columns this action clears, and the value each is reset to.
+ *
+ * The same rot applies here: a column added to `User` is personal data from
+ * birth and outside the reset by default. The completeness test compares these
+ * keys against the model's scalar columns, so a new column has to be
+ * classified.
+ *
+ * `Prisma.DbNull` rather than `null` for the JSON columns — on a nullable JSON
+ * field `null` is the JSON value `null`, not SQL NULL.
+ */
+export const USER_RESET = {
+ // Body and identity-of-the-record
+ heightCm: null,
+ dateOfBirth: null,
+ gender: null,
+ hasDiabetes: false,
+ fullName: null,
+ displayName: null,
+ insurerName: null,
+ insuranceNumberEncrypted: null,
+ insurerIkNumber: null,
+ lastReportPracticeName: null,
+ avatarBytes: null,
+ avatarContentType: null,
+ avatarUpdatedAt: null,
+
+ // Where the person lives — an environment-context input, and a location
+ homeLat: null,
+ homeLon: null,
+ homeLabel: null,
+ homeTimezone: null,
+ homeSince: null,
+
+ // Derived AI output cached on the row
+ insightsPrivacyMode: "aggregated",
+ insightsCachedAt: null,
+ insightsCachedText: null,
+ insightsSnapshotHash: null,
+ insightsWarmFailedAt: null,
+ insightsBriefingRerollDate: null,
+ morningDigestRefreshedOn: null,
+ insightsExcludeMetrics: [],
+ insightsLayoutJson: Prisma.DbNull,
+
+ // AI provider configuration and keys
+ aiProvider: null,
+ aiModel: null,
+ aiBaseUrl: null,
+ aiAnthropicKeyEncrypted: null,
+ aiLocalKeyEncrypted: null,
+ aiOpenaiKeyEncrypted: null,
+ aiProviderChain: Prisma.DbNull,
+ aiResponseTimeoutSeconds: null,
+ disableCoach: false,
+ coachPrefsJson: Prisma.DbNull,
+ coachLastSeenAt: null,
+ documentsAutoAiRead: false,
+ labsLocalOcrEnabled: false,
+ codexAccessTokenEncrypted: null,
+ codexRefreshTokenEncrypted: null,
+ codexTokenExpiresAt: null,
+ codexConnectedAt: null,
+ codexConnectionStatus: "disconnected",
+ useCentralCodex: false,
+
+ // Integration credentials and per-integration state
+ telegramBotToken: null,
+ telegramChatId: null,
+ telegramEnabled: false,
+ withingsClientIdEncrypted: null,
+ withingsClientSecretEncrypted: null,
+ whoopClientIdEncrypted: null,
+ whoopClientSecretEncrypted: null,
+ fitbitClientIdEncrypted: null,
+ fitbitClientSecretEncrypted: null,
+ googleHealthClientIdEncrypted: null,
+ googleHealthClientSecretEncrypted: null,
+ nightscoutUrlEncrypted: null,
+ nightscoutTokenEncrypted: null,
+ nightscoutAllowPrivateHost: false,
+ polarAccessTokenEncrypted: null,
+ polarUserIdEncrypted: null,
+ polarClientIdEncrypted: null,
+ polarClientSecretEncrypted: null,
+ ouraAccessTokenEncrypted: null,
+ ouraRefreshTokenEncrypted: null,
+ ouraClientIdEncrypted: null,
+ ouraClientSecretEncrypted: null,
+ stravaClientIdEncrypted: null,
+ stravaClientSecretEncrypted: null,
+ stravaAccessTokenEncrypted: null,
+ stravaRefreshTokenEncrypted: null,
+ stravaAthleteId: null,
+ stravaLastActivityAt: null,
+ stravaBackfillCompletedAt: null,
+ healthKitConfigJson: Prisma.DbNull,
+ healthKitLastSyncedAt: null,
+ lastSyncedAt: null,
+ sourcePriorityJson: Prisma.DbNull,
+
+ // Choices that describe the record rather than the interface
+ thresholdsJson: Prisma.DbNull,
+ dashboardWidgetsJson: Prisma.DbNull,
+ medicationListLayoutJson: Prisma.DbNull,
+ moodTagLayoutJson: Prisma.DbNull,
+ reportSelectionJson: Prisma.DbNull,
+ modulePreferencesJson: Prisma.DbNull,
+ globalExcludedInjectionSites: [],
+ moodReminderEnabled: false,
+ notificationPrefs: Prisma.DbNull,
+ researchModeEnabled: false,
+ researchModeAcknowledgedAt: null,
+ researchModeAcknowledgedVersion: null,
+
+ // Back to the start of the tour — the record it was built around is gone
+ onboardingCompletedAt: null,
+ onboardingStep: 0,
+ onboardingGoals: [],
+ onboardingTourCompleted: false,
+ onboardingTourProgressJson: Prisma.DbNull,
+} satisfies Prisma.UserUpdateInput;
+
+/**
+ * `User` columns the wipe leaves alone, each with the reason. Same contract as
+ * {@link WIPE_EXEMPT}: sign-in, identity, and how the interface is rendered.
+ */
+export const USER_KEPT_FIELDS: Readonly> = {
+ id: "the account's primary key",
+ username: "the account's sign-in identity",
+ email: "the account's sign-in identity and recovery address",
+ passwordHash: "a sign-in credential — the account password",
+ role: "the account's authorisation level, set by the operator",
+ oidcIssuer: "a sign-in credential — the external identity binding",
+ oidcSub: "a sign-in credential — the external identity binding",
+ createdAt: "when the account was created; the account is preserved",
+ updatedAt: "row bookkeeping maintained by Prisma",
+ totpSecretEncrypted: "a second-factor credential",
+ totpConfirmedAt: "second-factor enrolment state",
+ totpLastStep:
+ "second-factor replay guard; clearing it re-opens a replay window",
+ mfaEnforced: "the operator's per-account second-factor policy",
+ passkeyUpgradeNudgeDismissed:
+ "an interface nudge the person already dismissed",
+ timezone: "an interface preference — how dates and times are rendered",
+ locale: "an interface preference — which language the interface speaks",
+ unitPreference: "an interface preference — metric or imperial rendering",
+ timeFormat: "an interface preference — 12- or 24-hour rendering",
+ dateFormat: "an interface preference — date-order rendering",
+ glucoseUnit: "an interface preference — mg/dL or mmol/L rendering",
+ disclaimerAcknowledgedAt:
+ "the account's acknowledgement of the medical disclaimer, which is about the app and not about the record",
+ disclaimerAcknowledgedVersion:
+ "the version of the medical disclaimer the account acknowledged",
+ documentQuotaBytes:
+ "a per-account storage quota set by the operator, not by the person",
+};
+
+/** Prisma client delegate key for a model name (`MoodEntry` → `moodEntry`). */
+export function wipeDelegateKey(model: string): string {
+ return model.charAt(0).toLowerCase() + model.slice(1);
+}
+
+/** The narrow slice of a Prisma delegate the wipe loop uses. */
+export interface UserScopedDeleteDelegate {
+ deleteMany(args: { where: { userId: string } }): Promise<{ count: number }>;
+}
+
+/**
+ * Resolve a model name to its delegate on a client or transaction handle.
+ *
+ * One documented cast: `WIPE_MODELS` is a literal list of model names checked
+ * against `schema.prisma` by the completeness test, and every entry indexes a
+ * delegate that exposes `deleteMany` over a `userId`. The test also asserts
+ * every entry resolves, so an unresolvable name fails the build rather than
+ * the request.
+ */
+export function resolveWipeDelegate(
+ client: object,
+ model: string,
+): UserScopedDeleteDelegate {
+ const delegate = (client as Record)[
+ wipeDelegateKey(model)
+ ];
+ if (!delegate || typeof delegate.deleteMany !== "function") {
+ throw new Error(`No Prisma delegate for wipe model "${model}"`);
+ }
+ return delegate;
+}
From 5e66ebaa54fe4bbe7603a34585972b5bb4b71063 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 01:52:27 +0200
Subject: [PATCH 02/52] fix(jobs): let the retention purges finish, and let
them fail loudly
The nightly tombstone purge asked for `deleted_at IS NOT NULL AND deleted_at
< cutoff` and the audit-log purge asked for `created_at < cutoff`. Neither
predicate had an index behind it: every deleted_at index in the schema is
partial on IS NULL, the live-rows predicate, and every audit_logs index leads
with user_id or action. Both purges planned a sequential scan and then paid
index maintenance per removed row, in one unbounded statement, on a
connection carrying statement_timeout=60000. Past a certain backlog the
statement aborts, the transaction rolls back, nothing is purged, and the next
night runs the identical statement against the same rows. The mechanism that
gives deletion a horizon could not close on the instances that needed it.
Migration 0277 adds the indexes the predicates ask for; the three tombstone
ones are partial on IS NOT NULL, so on a healthy instance they cover only the
tombstones and shrink as the purge drains. The purges now walk the backlog in
batches of 5 000 by primary key, capped at 200 000 rows a run.
Both handlers used to swallow every error into a wide-event warning and
return, so pg-boss recorded a successful job either way: a purge failing
every night looked exactly like a purge with nothing to do. They now rethrow,
and a run that stops at the cap records that a backlog remains.
---
.../migration.sql | 46 +++++++
prisma/schema.prisma | 26 ++++
.../jobs/__tests__/audit-log-cleanup.test.ts | 49 ++++++-
.../measurement-tombstone-cleanup.test.ts | 46 ++++++-
src/lib/jobs/audit-log-cleanup.ts | 29 +++-
src/lib/jobs/measurement-tombstone-cleanup.ts | 84 ++++++++---
src/lib/jobs/purge-batch.ts | 81 +++++++++++
.../cleanup-handlers-visibility.test.ts | 130 ++++++++++++++++++
src/lib/jobs/reminder/cleanup-handlers.ts | 49 +++++--
9 files changed, 493 insertions(+), 47 deletions(-)
create mode 100644 prisma/migrations/0277_retention_purge_indexes/migration.sql
create mode 100644 src/lib/jobs/purge-batch.ts
create mode 100644 src/lib/jobs/reminder/__tests__/cleanup-handlers-visibility.test.ts
diff --git a/prisma/migrations/0277_retention_purge_indexes/migration.sql b/prisma/migrations/0277_retention_purge_indexes/migration.sql
new file mode 100644
index 000000000..acff53d8d
--- /dev/null
+++ b/prisma/migrations/0277_retention_purge_indexes/migration.sql
@@ -0,0 +1,46 @@
+-- v1.32.40 — indexes for the retention purges.
+--
+-- The nightly tombstone purge asks for `deleted_at IS NOT NULL AND deleted_at
+-- < cutoff`, and the audit-log purge asks for `created_at < cutoff`. Neither
+-- predicate had an index: every `deleted_at` index in the schema is PARTIAL on
+-- `deleted_at IS NULL` (the live-rows predicate, the exact opposite of what a
+-- purge needs), and every `audit_logs` index leads with `user_id` or `action`.
+-- Both purges therefore planned a sequential scan and paid index maintenance
+-- on every removed row. Each connection carries `statement_timeout=60000`
+-- (`src/lib/db.ts`), so past a certain backlog the statement aborts, the
+-- transaction rolls back, nothing is purged, and the next night runs the same
+-- statement against the same rows. The purge that was meant to give deletion a
+-- horizon could not close on the instances that needed it most.
+--
+-- The purges are batched as of the same release, so no single statement is on
+-- the hook for a whole backlog; these indexes are what make each batch's
+-- lookup cheap instead of a fresh sequential scan per batch.
+--
+-- The three tombstone indexes are PARTIAL on `deleted_at IS NOT NULL`, which
+-- is the shape that matters here: on a healthy instance the index covers only
+-- the tombstones, so it stays tiny and shrinks as the purge drains. Prisma
+-- cannot express a partial predicate, so these stay raw-SQL-only — the same
+-- convention as migrations 0087 / 0108 / 0243, noted at the model in
+-- `schema.prisma`. `audit_logs(created_at)` is expressible and is declared on
+-- the model.
+--
+-- Additive-only: every statement is `CREATE INDEX IF NOT EXISTS`, no table
+-- rewrite, no data change. NON-CONCURRENT on purpose — Prisma Migrate wraps a
+-- migration file in a transaction and `CREATE INDEX CONCURRENTLY` cannot run
+-- in one; these build at boot, where docker-entrypoint runs `migrate deploy`
+-- before the app serves traffic, so the build lock lands pre-traffic.
+
+CREATE INDEX IF NOT EXISTS "measurements_tombstone_purge_idx"
+ ON "measurements" ("deleted_at")
+ WHERE "deleted_at" IS NOT NULL;
+
+CREATE INDEX IF NOT EXISTS "mood_entries_tombstone_purge_idx"
+ ON "mood_entries" ("deleted_at")
+ WHERE "deleted_at" IS NOT NULL;
+
+CREATE INDEX IF NOT EXISTS "medication_intake_events_tombstone_purge_idx"
+ ON "medication_intake_events" ("deleted_at")
+ WHERE "deleted_at" IS NOT NULL;
+
+CREATE INDEX IF NOT EXISTS "audit_logs_created_at_idx"
+ ON "audit_logs" ("created_at");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 90ca68d2b..b625607b2 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -1763,6 +1763,13 @@ model Measurement {
// (v1.28.46 H4). It makes the note-encryption boot-discovery `SELECT DISTINCT
// user_id` an index-only scan that converges to empty. Same partial-predicate
// reason it is omitted here.
+ // `measurements_tombstone_purge_idx` is a partial index over `(deleted_at)
+ // WHERE deleted_at IS NOT NULL` — see migration 0277 (v1.32.40). Every
+ // other deleted_at index here is partial on IS NULL, which is the live-rows
+ // predicate and the opposite of what the nightly tombstone purge asks for;
+ // without this one the purge planned a sequential scan and could not finish
+ // inside statement_timeout. Raw SQL only (partial predicate not expressible
+ // in Prisma).
@@map("measurements")
}
@@ -2854,6 +2861,13 @@ model MedicationIntakeEvent {
// scanned the whole intake history. These two let both index-seek instead.
@@index([userId, scheduledFor])
@@index([userId, medicationId, takenAt])
+ // `medication_intake_events_tombstone_purge_idx` is a partial index over `(deleted_at)
+ // WHERE deleted_at IS NOT NULL` — see migration 0277 (v1.32.40). Every
+ // other deleted_at index here is partial on IS NULL, which is the live-rows
+ // predicate and the opposite of what the nightly tombstone purge asks for;
+ // without this one the purge planned a sequential scan and could not finish
+ // inside statement_timeout. Raw SQL only (partial predicate not expressible
+ // in Prisma).
@@map("medication_intake_events")
}
@@ -3852,6 +3866,13 @@ model MoodEntry {
// WHERE note IS NOT NULL AND note_encrypted IS NULL` — see migration 0243
// (v1.28.46 H4), serving the note-encryption boot-discovery scan. Raw SQL
// only (partial predicate not expressible in Prisma).
+ // `mood_entries_tombstone_purge_idx` is a partial index over `(deleted_at)
+ // WHERE deleted_at IS NOT NULL` — see migration 0277 (v1.32.40). Every
+ // other deleted_at index here is partial on IS NULL, which is the live-rows
+ // predicate and the opposite of what the nightly tombstone purge asks for;
+ // without this one the purge planned a sequential scan and could not finish
+ // inside statement_timeout. Raw SQL only (partial predicate not expressible
+ // in Prisma).
@@map("mood_entries")
}
@@ -4277,6 +4298,11 @@ model AuditLog {
// this three-column index serves both the findFirst and the take:5 as
// an index range scan over the highest-churn table. See migration 0123.
@@index([userId, action, createdAt(sort: Desc)])
+ // v1.32.40 — the retention purge filters on `createdAt` alone. Every index
+ // above leads with `userId` or `action`, so the purge planned a sequential
+ // scan and could not finish inside statement_timeout on an instance with
+ // enough history. See migration 0277.
+ @@index([createdAt])
@@map("audit_logs")
}
diff --git a/src/lib/jobs/__tests__/audit-log-cleanup.test.ts b/src/lib/jobs/__tests__/audit-log-cleanup.test.ts
index c7b431543..25e2eb1a3 100644
--- a/src/lib/jobs/__tests__/audit-log-cleanup.test.ts
+++ b/src/lib/jobs/__tests__/audit-log-cleanup.test.ts
@@ -1,14 +1,28 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import {
+ describe,
+ it,
+ expect,
+ vi,
+ beforeEach,
+ afterEach,
+ type Mock,
+} from "vitest";
import type { PrismaClient } from "@/generated/prisma/client";
import {
cleanupOldAuditLogs,
getAuditLogRetentionDays,
DEFAULT_AUDIT_LOG_RETENTION_DAYS,
} from "../audit-log-cleanup";
+import { PURGE_BATCH_SIZE, PURGE_MAX_BATCHES } from "../purge-batch";
function makePrismaMock(deletedCount: number) {
return {
auditLog: {
+ findMany: vi
+ .fn()
+ .mockResolvedValue(
+ Array.from({ length: deletedCount }, (_, i) => ({ id: `row-${i}` })),
+ ),
deleteMany: vi.fn().mockResolvedValue({ count: deletedCount }),
},
} as unknown as PrismaClient;
@@ -56,14 +70,23 @@ describe("cleanupOldAuditLogs", () => {
it("deletes audit log rows older than 365 days by default", async () => {
const prisma = makePrismaMock(42);
const now = new Date("2026-05-04T00:00:00Z");
- const deleted = await cleanupOldAuditLogs(prisma, now);
+ const outcome = await cleanupOldAuditLogs(prisma, now);
- expect(deleted).toBe(42);
- expect(prisma.auditLog.deleteMany).toHaveBeenCalledTimes(1);
+ expect(outcome).toEqual({ deleted: 42, drained: true });
+ // The predicate is applied on the bounded lookup; the delete itself is by
+ // primary key so no statement is on the hook for the whole backlog.
+ expect(prisma.auditLog.findMany).toHaveBeenCalledTimes(1);
const cutoff = new Date(now.getTime() - 365 * 86_400_000);
- expect(prisma.auditLog.deleteMany).toHaveBeenCalledWith({
+ expect(prisma.auditLog.findMany).toHaveBeenCalledWith({
where: { createdAt: { lt: cutoff } },
+ select: { id: true },
+ take: PURGE_BATCH_SIZE,
});
+ expect(prisma.auditLog.deleteMany).toHaveBeenCalledTimes(1);
+ expect(
+ (prisma.auditLog.deleteMany as unknown as Mock).mock.calls[0][0].where.id
+ .in,
+ ).toHaveLength(42);
});
it("uses the configured retention", async () => {
@@ -73,8 +96,22 @@ describe("cleanupOldAuditLogs", () => {
await cleanupOldAuditLogs(prisma, now);
const cutoff = new Date(now.getTime() - 90 * 86_400_000);
- expect(prisma.auditLog.deleteMany).toHaveBeenCalledWith({
+ expect(prisma.auditLog.findMany).toHaveBeenCalledWith({
where: { createdAt: { lt: cutoff } },
+ select: { id: true },
+ take: PURGE_BATCH_SIZE,
});
});
+
+ it("stops at the batch cap and says the backlog is not drained", async () => {
+ const prisma = makePrismaMock(PURGE_BATCH_SIZE);
+ const outcome = await cleanupOldAuditLogs(
+ prisma,
+ new Date("2026-05-04T00:00:00Z"),
+ );
+
+ expect(prisma.auditLog.findMany).toHaveBeenCalledTimes(PURGE_MAX_BATCHES);
+ expect(outcome.drained).toBe(false);
+ expect(outcome.deleted).toBe(PURGE_BATCH_SIZE * PURGE_MAX_BATCHES);
+ });
});
diff --git a/src/lib/jobs/__tests__/measurement-tombstone-cleanup.test.ts b/src/lib/jobs/__tests__/measurement-tombstone-cleanup.test.ts
index 5733f252c..ed96efe98 100644
--- a/src/lib/jobs/__tests__/measurement-tombstone-cleanup.test.ts
+++ b/src/lib/jobs/__tests__/measurement-tombstone-cleanup.test.ts
@@ -16,6 +16,7 @@ import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { cleanupExpiredMeasurementTombstones } from "../measurement-tombstone-cleanup";
+import { PURGE_BATCH_SIZE, PURGE_MAX_BATCHES } from "../purge-batch";
import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client";
const DAY_MS = 86_400_000;
@@ -23,16 +24,19 @@ const DAY_MS = 86_400_000;
describe("cleanupExpiredMeasurementTombstones", () => {
it("prunes only tombstones older than the retention horizon", async () => {
const now = new Date("2026-06-01T00:00:00.000Z");
+ const findMany = vi
+ .fn()
+ .mockResolvedValueOnce([{ id: "a" }, { id: "b" }, { id: "c" }]);
const deleteMany = vi.fn().mockResolvedValue({ count: 3 });
const prisma = {
- measurement: { deleteMany },
+ measurement: { findMany, deleteMany },
} as unknown as Parameters[0];
- const pruned = await cleanupExpiredMeasurementTombstones(prisma, now);
- expect(pruned).toBe(3);
+ const outcome = await cleanupExpiredMeasurementTombstones(prisma, now);
+ expect(outcome).toEqual({ deleted: 3, drained: true });
- expect(deleteMany).toHaveBeenCalledTimes(1);
- const where = deleteMany.mock.calls[0][0].where as {
+ expect(findMany).toHaveBeenCalledTimes(1);
+ const where = findMany.mock.calls[0][0].where as {
deletedAt: { not: null; lt: Date };
};
// Only soft-deleted rows are eligible …
@@ -42,6 +46,38 @@ describe("cleanupExpiredMeasurementTombstones", () => {
now.getTime() - TOMBSTONE_RETENTION_DAYS * DAY_MS,
);
expect(where.deletedAt.lt.getTime()).toBe(expectedCutoff.getTime());
+
+ // The delete is by primary key, so no single statement is on the hook for
+ // the whole backlog.
+ expect(deleteMany).toHaveBeenCalledTimes(1);
+ expect(deleteMany.mock.calls[0][0].where).toEqual({
+ id: { in: ["a", "b", "c"] },
+ });
+ });
+
+ it("keeps walking a backlog that exceeds one batch, and reports when it stops short", async () => {
+ const now = new Date("2026-06-01T00:00:00.000Z");
+ // Always a full batch back — the predicate never exhausts, so the run has
+ // to stop at the cap rather than loop forever.
+ const fullBatch = Array.from({ length: PURGE_BATCH_SIZE }, (_, i) => ({
+ id: `row-${i}`,
+ }));
+ const findMany = vi.fn().mockResolvedValue(fullBatch);
+ const deleteMany = vi.fn().mockResolvedValue({ count: PURGE_BATCH_SIZE });
+ const prisma = {
+ measurement: { findMany, deleteMany },
+ } as unknown as Parameters[0];
+
+ const outcome = await cleanupExpiredMeasurementTombstones(prisma, now);
+
+ expect(findMany).toHaveBeenCalledTimes(PURGE_MAX_BATCHES);
+ expect(outcome.deleted).toBe(PURGE_BATCH_SIZE * PURGE_MAX_BATCHES);
+ expect(outcome.drained).toBe(false);
+ // Every lookup is bounded — an unbounded one is the shape that could not
+ // finish inside statement_timeout.
+ for (const call of findMany.mock.calls) {
+ expect(call[0].take).toBe(PURGE_BATCH_SIZE);
+ }
});
});
diff --git a/src/lib/jobs/audit-log-cleanup.ts b/src/lib/jobs/audit-log-cleanup.ts
index 5fb51179c..ddb890b24 100644
--- a/src/lib/jobs/audit-log-cleanup.ts
+++ b/src/lib/jobs/audit-log-cleanup.ts
@@ -11,6 +11,7 @@
* `deleteMany`; runs daily via pg-boss.
*/
import type { PrismaClient } from "@/generated/prisma/client";
+import { purgeInBatches, type PurgeOutcome } from "@/lib/jobs/purge-batch";
export const DEFAULT_AUDIT_LOG_RETENTION_DAYS = 365;
@@ -27,14 +28,34 @@ export function getAuditLogRetentionDays(): number {
return parsed;
}
+/**
+ * Delete audit rows past the retention horizon, in bounded batches.
+ *
+ * Until v1.32.40 this was one unbounded `deleteMany` on `created_at < cutoff`.
+ * Every index on the table leads with `user_id` or `action`, so the predicate
+ * had none and the statement planned a sequential scan plus index maintenance
+ * on every removed row. Past the 60-second `statement_timeout` that aborts and
+ * rolls back, and the next night repeats it against the same rows — a
+ * retention window that can never close on an instance that has accumulated
+ * enough of them. Migration 0277 adds the `created_at` index; the batching
+ * keeps any single statement bounded regardless.
+ */
export async function cleanupOldAuditLogs(
prisma: PrismaClient,
now: Date = new Date(),
-): Promise {
+): Promise {
const days = getAuditLogRetentionDays();
const cutoff = new Date(now.getTime() - days * 86_400_000);
- const { count } = await prisma.auditLog.deleteMany({
- where: { createdAt: { lt: cutoff } },
+ return purgeInBatches({
+ findIds: async (take) =>
+ (
+ await prisma.auditLog.findMany({
+ where: { createdAt: { lt: cutoff } },
+ select: { id: true },
+ take,
+ })
+ ).map((row) => row.id),
+ deleteIds: async (ids) =>
+ (await prisma.auditLog.deleteMany({ where: { id: { in: ids } } })).count,
});
- return count;
}
diff --git a/src/lib/jobs/measurement-tombstone-cleanup.ts b/src/lib/jobs/measurement-tombstone-cleanup.ts
index 681b3e2f6..d627059d9 100644
--- a/src/lib/jobs/measurement-tombstone-cleanup.ts
+++ b/src/lib/jobs/measurement-tombstone-cleanup.ts
@@ -20,50 +20,90 @@
*/
import type { PrismaClient } from "@/generated/prisma/client";
import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client";
+import { purgeInBatches, type PurgeOutcome } from "@/lib/jobs/purge-batch";
const DAY_MS = 86_400_000;
+function tombstoneCutoff(now: Date): Date {
+ return new Date(now.getTime() - TOMBSTONE_RETENTION_DAYS * DAY_MS);
+}
+
/**
- * Hard-delete soft-deleted measurement rows whose `deletedAt` is older
- * than the retention horizon. Returns the number of rows pruned.
+ * Hard-delete soft-deleted measurement rows whose `deletedAt` is older than
+ * the retention horizon.
+ *
+ * Batched. Until v1.32.40 this was one unbounded `deleteMany` over a predicate
+ * that no index supported, which meant a backlog large enough to exceed the
+ * 60-second `statement_timeout` could never be drained: the statement aborted,
+ * the transaction rolled back, and the next night ran the identical statement
+ * against the same rows. `measurements` is the densest table in the schema and
+ * a bulk source delete can tombstone a six-figure row count in one action, so
+ * this is the one most likely to have been stuck. Migration 0277 adds the
+ * partial index the predicate needs.
*/
export async function cleanupExpiredMeasurementTombstones(
prisma: PrismaClient,
now: Date = new Date(),
-): Promise {
- const cutoff = new Date(now.getTime() - TOMBSTONE_RETENTION_DAYS * DAY_MS);
- const { count } = await prisma.measurement.deleteMany({
- where: { deletedAt: { not: null, lt: cutoff } },
+): Promise {
+ const cutoff = tombstoneCutoff(now);
+ return purgeInBatches({
+ findIds: async (take) =>
+ (
+ await prisma.measurement.findMany({
+ where: { deletedAt: { not: null, lt: cutoff } },
+ select: { id: true },
+ take,
+ })
+ ).map((row) => row.id),
+ deleteIds: async (ids) =>
+ (await prisma.measurement.deleteMany({ where: { id: { in: ids } } }))
+ .count,
});
- return count;
}
-/**
- * Hard-delete soft-deleted mood-entry rows past the retention horizon.
- * Returns the number of rows pruned.
- */
+/** Hard-delete soft-deleted mood-entry rows past the retention horizon. */
export async function cleanupExpiredMoodTombstones(
prisma: PrismaClient,
now: Date = new Date(),
-): Promise {
- const cutoff = new Date(now.getTime() - TOMBSTONE_RETENTION_DAYS * DAY_MS);
- const { count } = await prisma.moodEntry.deleteMany({
- where: { deletedAt: { not: null, lt: cutoff } },
+): Promise {
+ const cutoff = tombstoneCutoff(now);
+ return purgeInBatches({
+ findIds: async (take) =>
+ (
+ await prisma.moodEntry.findMany({
+ where: { deletedAt: { not: null, lt: cutoff } },
+ select: { id: true },
+ take,
+ })
+ ).map((row) => row.id),
+ deleteIds: async (ids) =>
+ (await prisma.moodEntry.deleteMany({ where: { id: { in: ids } } })).count,
});
- return count;
}
/**
* Hard-delete soft-deleted medication-intake-event rows past the retention
- * horizon. Returns the number of rows pruned.
+ * horizon.
*/
export async function cleanupExpiredIntakeTombstones(
prisma: PrismaClient,
now: Date = new Date(),
-): Promise {
- const cutoff = new Date(now.getTime() - TOMBSTONE_RETENTION_DAYS * DAY_MS);
- const { count } = await prisma.medicationIntakeEvent.deleteMany({
- where: { deletedAt: { not: null, lt: cutoff } },
+): Promise {
+ const cutoff = tombstoneCutoff(now);
+ return purgeInBatches({
+ findIds: async (take) =>
+ (
+ await prisma.medicationIntakeEvent.findMany({
+ where: { deletedAt: { not: null, lt: cutoff } },
+ select: { id: true },
+ take,
+ })
+ ).map((row) => row.id),
+ deleteIds: async (ids) =>
+ (
+ await prisma.medicationIntakeEvent.deleteMany({
+ where: { id: { in: ids } },
+ })
+ ).count,
});
- return count;
}
diff --git a/src/lib/jobs/purge-batch.ts b/src/lib/jobs/purge-batch.ts
new file mode 100644
index 000000000..8db2b5fac
--- /dev/null
+++ b/src/lib/jobs/purge-batch.ts
@@ -0,0 +1,81 @@
+/**
+ * Batched retention purges.
+ *
+ * The retention jobs used to issue one unbounded `deleteMany` over a predicate
+ * no index supported. Every `deleted_at` index in the schema is PARTIAL on
+ * `IS NULL` — the live-rows predicate, the exact opposite of what a purge asks
+ * for — so the purge planned a sequential scan and then paid index maintenance
+ * on every row it removed. Below the 60-second `statement_timeout` the
+ * connection carries (`src/lib/db.ts`) that is invisible; above it the
+ * statement aborts, the transaction rolls back, nothing is purged, and the job
+ * runs the identical statement again the next night. A backlog that grows past
+ * the timeout can therefore never be drained by the mechanism meant to drain
+ * it, and the job that was supposed to make deletion real never completes.
+ *
+ * The fix has three parts and this file is one of them:
+ * - the predicates get indexes that match them (migration 0277);
+ * - the delete runs in bounded batches, so no single statement is on the
+ * hook for the whole backlog;
+ * - a run that stops at the cap says so, so "still draining" and "nothing
+ * left to do" stop looking identical from the outside.
+ */
+
+/** Rows removed per statement. */
+export const PURGE_BATCH_SIZE = 5_000;
+
+/**
+ * Batches per run. 40 × 5 000 = 200 000 rows a night, which drains a large
+ * backlog over a handful of nights instead of asking one statement to survive
+ * it. The cap exists so a nightly job cannot hold connections and bloat WAL
+ * for an unbounded stretch on a instance that has accumulated years of rows.
+ */
+export const PURGE_MAX_BATCHES = 40;
+
+export interface PurgeOutcome {
+ /** Rows removed this run. */
+ deleted: number;
+ /**
+ * `false` when the run stopped at {@link PURGE_MAX_BATCHES} with rows still
+ * matching. The next run continues; an operator watching this stay `false`
+ * for many nights is watching a backlog that needs a wider window.
+ */
+ drained: boolean;
+}
+
+export interface PurgeBatchOptions {
+ /** Ids of up to `take` rows matching the retention predicate. */
+ findIds: (take: number) => Promise;
+ /** Remove exactly those ids; returns the row count actually removed. */
+ deleteIds: (ids: string[]) => Promise;
+ batchSize?: number;
+ maxBatches?: number;
+}
+
+/**
+ * Delete rows matching a retention predicate in bounded batches.
+ *
+ * Selecting ids first and deleting by primary key keeps each statement small
+ * and predictable regardless of how selective the predicate is, and keeps the
+ * scan on the index that supports the predicate rather than on the delete.
+ */
+export async function purgeInBatches({
+ findIds,
+ deleteIds,
+ batchSize = PURGE_BATCH_SIZE,
+ maxBatches = PURGE_MAX_BATCHES,
+}: PurgeBatchOptions): Promise {
+ let deleted = 0;
+
+ for (let batch = 0; batch < maxBatches; batch++) {
+ const ids = await findIds(batchSize);
+ if (ids.length === 0) return { deleted, drained: true };
+
+ deleted += await deleteIds(ids);
+
+ // A short final batch means the predicate is exhausted; asking again would
+ // cost a round-trip to learn nothing.
+ if (ids.length < batchSize) return { deleted, drained: true };
+ }
+
+ return { deleted, drained: false };
+}
diff --git a/src/lib/jobs/reminder/__tests__/cleanup-handlers-visibility.test.ts b/src/lib/jobs/reminder/__tests__/cleanup-handlers-visibility.test.ts
new file mode 100644
index 000000000..766971dab
--- /dev/null
+++ b/src/lib/jobs/reminder/__tests__/cleanup-handlers-visibility.test.ts
@@ -0,0 +1,130 @@
+/**
+ * v1.32.40 — the retention purges have to be able to fail out loud.
+ *
+ * Both handlers used to catch every error into a wide-event warning and return
+ * normally. pg-boss then recorded a successful job, so a purge that aborted on
+ * `statement_timeout` every night for a year looked exactly like a purge with
+ * nothing to do. That is the failure this whole change exists to remove, so
+ * the propagation is pinned rather than left to convention.
+ *
+ * The second half pins the other half of the same problem: a run that stopped
+ * at the batch cap still has a backlog, and without the flag that is also
+ * indistinguishable from a clean run.
+ *
+ * Mutation check: drop the `throw err` from either handler and the matching
+ * "surfaces the failure" test goes red; drop the `drained` meta and the
+ * matching backlog test goes red.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const h = vi.hoisted(() => ({
+ cleanupOldAuditLogs: vi.fn(),
+ cleanupExpiredMeasurementTombstones: vi.fn(),
+ cleanupExpiredMoodTombstones: vi.fn(),
+ cleanupExpiredIntakeTombstones: vi.fn(),
+ meta: {} as Record,
+ warnings: [] as string[],
+}));
+
+vi.mock("@/lib/jobs/audit-log-cleanup", () => ({
+ cleanupOldAuditLogs: h.cleanupOldAuditLogs,
+}));
+vi.mock("@/lib/jobs/measurement-tombstone-cleanup", () => ({
+ cleanupExpiredMeasurementTombstones: h.cleanupExpiredMeasurementTombstones,
+ cleanupExpiredMoodTombstones: h.cleanupExpiredMoodTombstones,
+ cleanupExpiredIntakeTombstones: h.cleanupExpiredIntakeTombstones,
+}));
+vi.mock("../shared", async (importOriginal) => ({
+ ...(await importOriginal>()),
+ getWorkerPrisma: () => ({}),
+}));
+vi.mock("@/lib/logging/background", () => ({
+ withBackgroundEvent: async (
+ _name: string,
+ fn: (evt: {
+ addMeta: (k: string, v: unknown) => void;
+ addWarning: (w: string) => void;
+ }) => Promise,
+ ) =>
+ fn({
+ addMeta: (k, v) => {
+ h.meta[k] = v;
+ },
+ addWarning: (w) => {
+ h.warnings.push(w);
+ },
+ }),
+}));
+
+import {
+ handleAuditLogCleanup,
+ handleMeasurementTombstoneCleanup,
+} from "../cleanup-handlers";
+
+const DRAINED = { deleted: 1, drained: true };
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ for (const key of Object.keys(h.meta)) delete h.meta[key];
+ h.warnings.length = 0;
+ h.cleanupOldAuditLogs.mockResolvedValue(DRAINED);
+ h.cleanupExpiredMeasurementTombstones.mockResolvedValue(DRAINED);
+ h.cleanupExpiredMoodTombstones.mockResolvedValue(DRAINED);
+ h.cleanupExpiredIntakeTombstones.mockResolvedValue(DRAINED);
+});
+
+describe("audit-log cleanup handler", () => {
+ it("surfaces the failure instead of recording a successful job", async () => {
+ h.cleanupOldAuditLogs.mockRejectedValue(new Error("statement timeout"));
+ await expect(handleAuditLogCleanup([])).rejects.toThrow(
+ "statement timeout",
+ );
+ expect(h.warnings.join(" ")).toContain("audit-log-cleanup failed");
+ });
+
+ it("reports a backlog left behind by the batch cap", async () => {
+ h.cleanupOldAuditLogs.mockResolvedValue({
+ deleted: 200_000,
+ drained: false,
+ });
+ await handleAuditLogCleanup([]);
+ expect(h.meta.audit_log_cleanup_deleted).toBe(200_000);
+ expect(h.meta.audit_log_cleanup_drained).toBe(false);
+ expect(h.warnings.join(" ")).toContain("backlog remains");
+ });
+
+ it("stays quiet when the backlog is drained", async () => {
+ await handleAuditLogCleanup([]);
+ expect(h.meta.audit_log_cleanup_drained).toBe(true);
+ expect(h.warnings).toEqual([]);
+ });
+});
+
+describe("tombstone cleanup handler", () => {
+ it("surfaces the failure instead of recording a successful job", async () => {
+ h.cleanupExpiredMeasurementTombstones.mockRejectedValue(
+ new Error("statement timeout"),
+ );
+ await expect(handleMeasurementTombstoneCleanup([])).rejects.toThrow(
+ "statement timeout",
+ );
+ expect(h.warnings.join(" ")).toContain("tombstone-cleanup failed");
+ });
+
+ it("reports a backlog when any of the three legs stopped short", async () => {
+ h.cleanupExpiredMoodTombstones.mockResolvedValue({
+ deleted: 200_000,
+ drained: false,
+ });
+ await handleMeasurementTombstoneCleanup([]);
+ expect(h.meta.tombstone_cleanup_drained).toBe(false);
+ expect(h.meta.mood_tombstone_cleanup_pruned).toBe(200_000);
+ expect(h.warnings.join(" ")).toContain("backlog remains");
+ });
+
+ it("stays quiet when all three legs drained", async () => {
+ await handleMeasurementTombstoneCleanup([]);
+ expect(h.meta.tombstone_cleanup_drained).toBe(true);
+ expect(h.warnings).toEqual([]);
+ });
+});
diff --git a/src/lib/jobs/reminder/cleanup-handlers.ts b/src/lib/jobs/reminder/cleanup-handlers.ts
index 4513975c1..a3b1e6f43 100644
--- a/src/lib/jobs/reminder/cleanup-handlers.ts
+++ b/src/lib/jobs/reminder/cleanup-handlers.ts
@@ -184,16 +184,36 @@ export async function handleMeasurementTombstoneCleanup(
try {
// v1.7.0 sync — prune tombstones across all three sync domains on
// the same retention horizon.
- const [measurements, mood, intakes] = await Promise.all([
- cleanupExpiredMeasurementTombstones(p),
- cleanupExpiredMoodTombstones(p),
- cleanupExpiredIntakeTombstones(p),
- ]);
- evt.addMeta("measurement_tombstone_cleanup_pruned", measurements);
- evt.addMeta("mood_tombstone_cleanup_pruned", mood);
- evt.addMeta("intake_tombstone_cleanup_pruned", intakes);
+ //
+ // v1.32.40 — sequential rather than parallel. Each leg now walks its
+ // backlog in batches, and three concurrent walks against the three
+ // densest tables competed for the worker pool for no gain: the job has
+ // the whole night.
+ const measurements = await cleanupExpiredMeasurementTombstones(p);
+ const mood = await cleanupExpiredMoodTombstones(p);
+ const intakes = await cleanupExpiredIntakeTombstones(p);
+ evt.addMeta(
+ "measurement_tombstone_cleanup_pruned",
+ measurements.deleted,
+ );
+ evt.addMeta("mood_tombstone_cleanup_pruned", mood.deleted);
+ evt.addMeta("intake_tombstone_cleanup_pruned", intakes.deleted);
+ // A run that stopped at the batch cap has a backlog left. Without this
+ // flag a partial run and a complete one are indistinguishable from
+ // outside, which is how a purge that never finished stayed invisible.
+ const drained = measurements.drained && mood.drained && intakes.drained;
+ evt.addMeta("tombstone_cleanup_drained", drained);
+ if (!drained) {
+ evt.addWarning(
+ "tombstone-cleanup stopped at the batch cap; a backlog remains for the next run",
+ );
+ }
} catch (err) {
+ // Rethrow. A retention purge that fails silently every night looks
+ // exactly like one that has nothing to do; pg-boss recording a failed
+ // job is the only signal an operator can act on.
evt.addWarning(`tombstone-cleanup failed: ${err}`);
+ throw err;
}
},
);
@@ -268,10 +288,19 @@ export async function handleAuditLogCleanup(
await withBackgroundEvent("job.audit_log_cleanup", async (evt) => {
const p = getWorkerPrisma();
try {
- const deleted = await cleanupOldAuditLogs(p);
- evt.addMeta("audit_log_cleanup_deleted", deleted);
+ const outcome = await cleanupOldAuditLogs(p);
+ evt.addMeta("audit_log_cleanup_deleted", outcome.deleted);
+ evt.addMeta("audit_log_cleanup_drained", outcome.drained);
+ if (!outcome.drained) {
+ evt.addWarning(
+ "audit-log-cleanup stopped at the batch cap; a backlog remains for the next run",
+ );
+ }
} catch (err) {
+ // Rethrow — see the tombstone handler. Storage-limitation retention that
+ // fails quietly is retention that is not happening.
evt.addWarning(`audit-log-cleanup failed: ${err}`);
+ throw err;
}
});
}
From dccd2f320baf090e46be3b8b4c8a77eb0a3e1a2b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 02:14:07 +0200
Subject: [PATCH 03/52] test(settings): prove the wipe against real Postgres,
and state what deletion means
The defect is about rows that survive, so a mocked client proves nothing. The
new integration test seeds a row into every table in the schema, driven by
information_schema and pg_constraint rather than a fixture, runs the wipe and
asks the database what is left. A table added later is seeded and asserted
without anyone remembering to.
It holds all four halves: nothing in the wipe list survives, everything in the
exemption list does, everything reached only by a database cascade is gone,
and the account row comes back reset but able to sign in. A second file pins
the allergy and checkup-reminder deletes actually removing their row.
The reproductive-health note gains a Deletion section. It taught people to
reason carefully about a host being imaged and never said what pressing
delete does; now it says which of the three deletions removes the row and
which only hides it.
---
docs/self-hosting/reproductive-health-data.md | 25 +
src/__tests__/data-wipe-completeness.test.ts | 2 +-
src/app/api/allergies/[id]/route.ts | 4 +-
.../api/measurement-reminders/[id]/route.ts | 5 +-
src/app/api/settings/data/route.ts | 5 +-
src/lib/data-wipe/wipe-plan.ts | 2 -
tests/integration/settings-data-wipe.test.ts | 522 ++++++++++++++++++
.../soft-delete-copy-parity.test.ts | 120 ++++
8 files changed, 678 insertions(+), 7 deletions(-)
create mode 100644 tests/integration/settings-data-wipe.test.ts
create mode 100644 tests/integration/soft-delete-copy-parity.test.ts
diff --git a/docs/self-hosting/reproductive-health-data.md b/docs/self-hosting/reproductive-health-data.md
index 051c5bdfd..557f96633 100644
--- a/docs/self-hosting/reproductive-health-data.md
+++ b/docs/self-hosting/reproductive-health-data.md
@@ -81,6 +81,31 @@ These are cited as factual public record to explain _why_ the
"who holds the data" question is the one that matters for this category.
HealthLog makes no claim about any other product's current practices.
+## What deleting actually does
+
+Worth knowing before you rely on it, because "deleted" means two
+different things depending on which button you press.
+
+- **Deleting one cycle or day-log entry** hides it. Every read filters
+ it out and it is gone from the app, from exports, and from the doctor
+ report. The row itself stays in PostgreSQL as a tombstone, which is
+ what lets a phone that was offline at delete time learn that the entry
+ went away. No job removes cycle tombstones on a schedule, so the row
+ stays until one of the two actions below.
+- **Settings → Delete All Data** removes the rows outright, tombstones
+ included. Everything the account owns goes except what you sign in
+ with; the confirmation dialog lists it.
+- **Deleting the account** removes the account row, and the database
+ cascades every record with it.
+
+Two consequences follow, and they matter for the seizure threat model
+above. A host imaged while a per-entry deletion is the only deletion you
+have performed still holds that entry. And a backup taken before any of
+these actions still holds whatever it captured, because a backup is a
+copy of a moment. If your reason for deleting is that the data must not
+be recoverable from the machine, use one of the two whole-record actions
+and rotate your backups.
+
## Practical hardening
If reproductive-health privacy is the reason you self-host, the
diff --git a/src/__tests__/data-wipe-completeness.test.ts b/src/__tests__/data-wipe-completeness.test.ts
index 98315046d..58f9cf752 100644
--- a/src/__tests__/data-wipe-completeness.test.ts
+++ b/src/__tests__/data-wipe-completeness.test.ts
@@ -169,7 +169,7 @@ describe("data-wipe completeness", () => {
}
const orphans = [...models]
- .filter(([name, shape]) => !shape.scalars.includes("userId"))
+ .filter(([, shape]) => !shape.scalars.includes("userId"))
.map(([name]) => name)
.filter(
(name) =>
diff --git a/src/app/api/allergies/[id]/route.ts b/src/app/api/allergies/[id]/route.ts
index 10ee3ccb2..a2591bf40 100644
--- a/src/app/api/allergies/[id]/route.ts
+++ b/src/app/api/allergies/[id]/route.ts
@@ -1,7 +1,9 @@
/**
* `GET /api/allergies/{id}` — one owned, live allergy record.
* `PATCH /api/allergies/{id}` — edit it (partial; field-by-field).
- * `DELETE /api/allergies/{id}` — soft-delete it (idempotent; `{ deleted: true }`).
+ * `DELETE /api/allergies/{id}` — delete it outright (`{ deleted: true }`; 404
+ * once it is gone). The confirmation dialog promises it cannot be undone, and
+ * nothing here needs a tombstone to keep that promise honest.
*
* Owner-scoped. `userId` is narrowed from auth and fed to the Prisma `where`;
* the body never carries it. The free-text `reaction` + `note` are encrypted
diff --git a/src/app/api/measurement-reminders/[id]/route.ts b/src/app/api/measurement-reminders/[id]/route.ts
index 6f37d2fd6..e591c7481 100644
--- a/src/app/api/measurement-reminders/[id]/route.ts
+++ b/src/app/api/measurement-reminders/[id]/route.ts
@@ -2,8 +2,9 @@
* v1.17.1 — Vorsorge (measurement) reminder by id: get + patch + delete.
*
* PATCH re-derives the server-authoritative `nextDueAt` after applying
- * the (mutually-exclusive cadence) edit. DELETE soft-deletes (tombstone)
- * for parity with the rest of the tree.
+ * the (mutually-exclusive cadence) edit. DELETE removes the row; the
+ * confirmation dialog says the reminder is permanently deleted, and nothing
+ * here needs a tombstone to keep that promise honest.
*/
import { NextRequest } from "next/server";
diff --git a/src/app/api/settings/data/route.ts b/src/app/api/settings/data/route.ts
index 38a744be4..482fefb4a 100644
--- a/src/app/api/settings/data/route.ts
+++ b/src/app/api/settings/data/route.ts
@@ -91,7 +91,10 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
const deletedRows = Object.values(counts).reduce((sum, n) => sum + n, 0);
// Written after the transaction commits, so the row cannot be removed by the
- // AuditLog delete inside it.
+ // AuditLog delete inside it. The person's whole audit history goes; this one
+ // row stays as the receipt for the erasure they asked for. Account deletion
+ // takes the other branch and purges even this, because there is no longer an
+ // account for it to belong to.
await auditLog("user.data.clear", {
userId,
ipAddress: getClientIp(request),
diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts
index f14986763..7ad5d753b 100644
--- a/src/lib/data-wipe/wipe-plan.ts
+++ b/src/lib/data-wipe/wipe-plan.ts
@@ -160,8 +160,6 @@ export const WIPE_MODELS = [
"ApiToken",
] as const;
-export type WipeModel = (typeof WIPE_MODELS)[number];
-
/**
* User-scoped models this action deliberately keeps, each with the reason.
*
diff --git a/tests/integration/settings-data-wipe.test.ts b/tests/integration/settings-data-wipe.test.ts
new file mode 100644
index 000000000..1edbeef11
--- /dev/null
+++ b/tests/integration/settings-data-wipe.test.ts
@@ -0,0 +1,522 @@
+/**
+ * Integration guard for `DELETE /api/settings/data` — "Delete All Data".
+ *
+ * The whole defect is about rows that survive, so a mocked Prisma proves
+ * nothing: it can only show that the calls the route makes were made. This
+ * runs against real Postgres, seeds a row into every table in the schema, runs
+ * the wipe, and then asks the database what is left.
+ *
+ * The seeding is driven by `information_schema` and `pg_constraint` rather
+ * than a hand-written fixture, for the same reason the wipe list is derived
+ * rather than hand-written: a fixture written today covers the schema of
+ * today. A table added next year is seeded automatically and therefore
+ * asserted automatically.
+ *
+ * What it asserts:
+ * - every model in `WIPE_MODELS` has zero rows for the account afterwards;
+ * - every model in `WIPE_EXEMPT` still has its row, so the "kept" half of
+ * the confirmation copy is true too;
+ * - every table the wipe reaches only through a database cascade is empty;
+ * - the instance-scoped tables are untouched;
+ * - the `User` row survives with `USER_RESET` applied and
+ * `USER_KEPT_FIELDS` intact.
+ *
+ * Its honest limit: it proves the wipe removes what the plan declares. The
+ * plan being right is what `src/__tests__/data-wipe-completeness.test.ts`
+ * holds against `schema.prisma`.
+ */
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { cookieJar, headerJar } from "./mock-next-headers";
+import { getPrismaClient, truncateAllTables } from "./setup";
+import {
+ INSTANCE_SCOPED,
+ USER_KEPT_FIELDS,
+ USER_RESET,
+ WIPE_EXEMPT,
+ WIPE_MODELS,
+} from "@/lib/data-wipe/wipe-plan";
+import type { PrismaClient } from "@/generated/prisma/client";
+
+vi.mock("next/headers", async () => {
+ const { cookieJar, headerJar } = await import("./mock-next-headers");
+ return {
+ headers: vi.fn(async () => ({
+ get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
+ })),
+ cookies: vi.fn(async () => ({
+ get: (name: string) => {
+ const value = cookieJar.get(name);
+ return value ? { name, value } : undefined;
+ },
+ set: (name: string, value: string) => {
+ cookieJar.set(name, value);
+ },
+ delete: (name: string) => {
+ cookieJar.delete(name);
+ },
+ })),
+ };
+});
+
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+
+// ── schema.prisma model → table name ────────────────────────────────────────
+
+function modelTableMap(): Map {
+ const schema = readFileSync(
+ join(process.cwd(), "prisma", "schema.prisma"),
+ "utf8",
+ );
+ const map = new Map();
+ for (const block of schema.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm)) {
+ const mapped = /@@map\("([^"]+)"\)/.exec(block[2]);
+ map.set(block[1], mapped ? mapped[1] : block[1]);
+ }
+ return map;
+}
+
+// ── generic seeder ──────────────────────────────────────────────────────────
+
+interface ColumnRow {
+ table_name: string;
+ column_name: string;
+ is_nullable: string;
+ column_default: string | null;
+ udt_name: string;
+}
+
+interface ForeignKeyRow {
+ table_name: string;
+ column_name: string;
+ ref_table: string;
+ ref_column: string;
+ cascades: boolean;
+}
+
+/** Tables the seeder never writes: Prisma's own ledger, and the account row. */
+const SEED_SKIP = new Set(["_prisma_migrations", "users"]);
+
+function synthesise(udt: string, enums: Map, tag: string) {
+ if (udt.startsWith("_")) return `'{}'::${udt.slice(1)}[]`;
+ switch (udt) {
+ case "text":
+ case "varchar":
+ case "bpchar":
+ return `'${tag}'`;
+ case "int2":
+ case "int4":
+ case "int8":
+ case "numeric":
+ case "float4":
+ case "float8":
+ return "1";
+ case "bool":
+ return "false";
+ case "timestamp":
+ case "timestamptz":
+ case "date":
+ return "now()";
+ case "bytea":
+ return "'\\x00'::bytea";
+ case "json":
+ case "jsonb":
+ return `'{}'::${udt}`;
+ case "uuid":
+ return "gen_random_uuid()";
+ default: {
+ const label = enums.get(udt);
+ if (label) return `'${label}'::"${udt}"`;
+ throw new Error(`seeder has no value for column type "${udt}"`);
+ }
+ }
+}
+
+/**
+ * Insert one row into every table in `public`, parents first, wiring every
+ * foreign key to the row already created for its target and every `user_id` to
+ * the account under test.
+ */
+async function seedEveryTable(
+ prisma: PrismaClient,
+ userId: string,
+): Promise {
+ const columns = await prisma.$queryRaw`
+ SELECT table_name, column_name, is_nullable, column_default, udt_name
+ FROM information_schema.columns
+ WHERE table_schema = 'public'`;
+
+ const foreignKeys = await prisma.$queryRaw`
+ SELECT con.conrelid::regclass::text AS table_name,
+ att.attname AS column_name,
+ con.confrelid::regclass::text AS ref_table,
+ ratt.attname AS ref_column,
+ con.confdeltype = 'c' AS cascades
+ FROM pg_constraint con
+ JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS ck(attnum, ord) ON true
+ JOIN LATERAL unnest(con.confkey) WITH ORDINALITY AS rk(attnum, ord)
+ ON rk.ord = ck.ord
+ JOIN pg_attribute att ON att.attrelid = con.conrelid AND att.attnum = ck.attnum
+ JOIN pg_attribute ratt ON ratt.attrelid = con.confrelid AND ratt.attnum = rk.attnum
+ WHERE con.contype = 'f' AND con.connamespace = 'public'::regnamespace`;
+
+ const enumRows = await prisma.$queryRaw<
+ Array<{ typname: string; enumlabel: string }>
+ >`
+ SELECT t.typname, e.enumlabel
+ FROM pg_type t
+ JOIN pg_enum e ON e.enumtypid = t.oid
+ ORDER BY t.typname, e.enumsortorder`;
+ const enums = new Map();
+ for (const row of enumRows) {
+ if (!enums.has(row.typname)) enums.set(row.typname, row.enumlabel);
+ }
+
+ const byTable = new Map();
+ for (const column of columns) {
+ if (SEED_SKIP.has(column.table_name)) continue;
+ const bucket = byTable.get(column.table_name) ?? [];
+ bucket.push(column);
+ byTable.set(column.table_name, bucket);
+ }
+
+ const fkByTable = new Map();
+ for (const fk of foreignKeys) {
+ const bucket = fkByTable.get(fk.table_name) ?? [];
+ bucket.push(fk);
+ fkByTable.set(fk.table_name, bucket);
+ }
+
+ // Parents before children. A self-reference or an optional cycle is skipped
+ // by only ordering on the columns the insert actually fills.
+ const pending = new Set(byTable.keys());
+ const ordered: string[] = [];
+ while (pending.size > 0) {
+ let progressed = false;
+ for (const table of [...pending]) {
+ const blocked = (fkByTable.get(table) ?? []).some(
+ (fk) => fk.ref_table !== table && pending.has(fk.ref_table),
+ );
+ if (blocked) continue;
+ ordered.push(table);
+ pending.delete(table);
+ progressed = true;
+ }
+ if (!progressed) {
+ throw new Error(
+ `foreign-key cycle among: ${[...pending].sort().join(", ")}`,
+ );
+ }
+ }
+
+ const insertedId = new Map();
+ let tag = 0;
+
+ for (const table of ordered) {
+ const tableColumns = byTable.get(table)!;
+ const fks = fkByTable.get(table) ?? [];
+ const names: string[] = [];
+ const values: string[] = [];
+
+ for (const column of tableColumns) {
+ const fk = fks.find((f) => f.column_name === column.column_name);
+ const required =
+ column.is_nullable === "NO" && column.column_default === null;
+ const isUserColumn = column.column_name === "user_id";
+
+ if (fk?.ref_table === "users") {
+ // Always bind to the account under test, nullable or not — a NULL
+ // owner would put the row outside the wipe and make the assertion
+ // vacuous.
+ names.push(column.column_name);
+ values.push(`'${userId}'`);
+ continue;
+ }
+ if (!required && !isUserColumn) continue;
+ if (fk) {
+ const parent = insertedId.get(fk.ref_table);
+ if (!parent) {
+ throw new Error(
+ `${table}.${column.column_name} references ${fk.ref_table}, which was not seeded`,
+ );
+ }
+ names.push(column.column_name);
+ values.push(`'${parent}'`);
+ continue;
+ }
+ names.push(column.column_name);
+ values.push(synthesise(column.udt_name, enums, `seed-${tag++}`));
+ }
+
+ const sql =
+ names.length === 0
+ ? `INSERT INTO "${table}" DEFAULT VALUES ON CONFLICT DO NOTHING`
+ : `INSERT INTO "${table}" (${names.map((n) => `"${n}"`).join(", ")}) VALUES (${values.join(", ")}) ON CONFLICT DO NOTHING`;
+ await prisma.$executeRawUnsafe(sql);
+
+ const hasId = tableColumns.some((c) => c.column_name === "id");
+ if (hasId) {
+ const rows = await prisma.$queryRawUnsafe>(
+ `SELECT id FROM "${table}" LIMIT 1`,
+ );
+ if (rows[0]) insertedId.set(table, rows[0].id);
+ }
+ }
+
+ return ordered;
+}
+
+async function countRows(
+ prisma: PrismaClient,
+ table: string,
+ userId?: string,
+): Promise {
+ const rows = userId
+ ? await prisma.$queryRawUnsafe>(
+ `SELECT count(*)::bigint AS n FROM "${table}" WHERE user_id = $1`,
+ userId,
+ )
+ : await prisma.$queryRawUnsafe>(
+ `SELECT count(*)::bigint AS n FROM "${table}"`,
+ );
+ return Number(rows[0].n);
+}
+
+describe("DELETE /api/settings/data leaves nothing it promised to delete", () => {
+ beforeEach(async () => {
+ await truncateAllTables(getPrismaClient());
+ cookieJar.clear();
+ headerJar.clear();
+ });
+
+ it("wipes every in-scope table and keeps exactly the declared set", async () => {
+ const prisma = getPrismaClient();
+ const tables = modelTableMap();
+
+ const user = await prisma.user.create({
+ data: {
+ username: "wipe-subject",
+ email: "wipe-subject@example.test",
+ heightCm: 180,
+ dateOfBirth: new Date("1990-01-01T00:00:00Z"),
+ gender: "OTHER",
+ fullName: "Seeded Name",
+ insurerName: "Seeded Insurer",
+ stravaAthleteId: "12345",
+ thresholdsJson: { sys: 130 },
+ onboardingCompletedAt: new Date(),
+ onboardingStep: 4,
+ passwordHash: "argon2-placeholder",
+ locale: "en",
+ unitPreference: "metric",
+ },
+ });
+
+ const seeded = await seedEveryTable(prisma, user.id);
+ expect(
+ seeded.length,
+ "the seeder should reach essentially the whole schema",
+ ).toBeGreaterThan(100);
+
+ // The session has to be created after the generic seed so its id is the
+ // one the cookie carries. `mfaVerifiedAt` is set because the seeder also
+ // planted a WebAuthn MFA credential, which makes the account MFA-enrolled
+ // and puts the route behind a fresh step-up.
+ const session = await prisma.session.create({
+ data: {
+ userId: user.id,
+ expiresAt: new Date(Date.now() + 60_000),
+ mfaVerifiedAt: new Date(),
+ },
+ });
+ cookieJar.set("healthlog_session", session.id);
+
+ // ── act ──
+ const { DELETE } = await import("@/app/api/settings/data/route");
+ const response = await DELETE(
+ new Request("http://localhost/api/settings/data", {
+ method: "DELETE",
+ body: JSON.stringify({ confirm: "DELETE" }),
+ }) as never,
+ );
+ expect(response.status).toBe(200);
+
+ // ── assert: nothing the plan claims to delete is left ──
+ const survivors: string[] = [];
+ for (const model of WIPE_MODELS) {
+ const table = tables.get(model)!;
+ // `AuditLog` is wiped inside the transaction and then receives exactly
+ // one new row after it commits: the receipt for the wipe itself. The
+ // history is gone; the record that the person asked for it is not.
+ const expected = model === "AuditLog" ? 1 : 0;
+ if ((await countRows(prisma, table, user.id)) > expected) {
+ survivors.push(`${model} (${table})`);
+ }
+ }
+ expect(
+ survivors,
+ `rows survived a wipe that told the person everything was deleted:\n${survivors.join("\n")}`,
+ ).toEqual([]);
+
+ // The one surviving audit row is the receipt, not leftover history.
+ const auditRows = await prisma.auditLog.findMany({
+ where: { userId: user.id },
+ select: { action: true },
+ });
+ expect(auditRows.map((r) => r.action)).toEqual(["user.data.clear"]);
+
+ // ── assert: the kept set is exactly what the copy promises ──
+ const missing: string[] = [];
+ for (const model of Object.keys(WIPE_EXEMPT)) {
+ const table = tables.get(model)!;
+ if ((await countRows(prisma, table, user.id)) === 0) {
+ missing.push(`${model} (${table})`);
+ }
+ }
+ expect(
+ missing,
+ `the copy says these are kept, and they are gone:\n${missing.join("\n")}`,
+ ).toEqual([]);
+
+ // ── assert: the instance's own tables are untouched ──
+ for (const model of Object.keys(INSTANCE_SCOPED)) {
+ if (model === "User") continue;
+ const table = tables.get(model)!;
+ expect(
+ await countRows(prisma, table),
+ `${model} belongs to the instance and must survive one account's wipe`,
+ ).toBeGreaterThan(0);
+ }
+
+ // ── assert: everything reached only by cascade is gone too ──
+ const wipedTables = new Set(WIPE_MODELS.map((m) => tables.get(m)!));
+ const exemptTables = new Set(
+ Object.keys(WIPE_EXEMPT).map((m) => tables.get(m)!),
+ );
+ const instanceTables = new Set(
+ Object.keys(INSTANCE_SCOPED).map((m) => tables.get(m)!),
+ );
+ const leftovers: string[] = [];
+ for (const table of seeded) {
+ if (wipedTables.has(table)) continue;
+ if (exemptTables.has(table)) continue;
+ if (instanceTables.has(table)) continue;
+ if ((await countRows(prisma, table)) > 0) leftovers.push(table);
+ }
+ expect(
+ leftovers,
+ `tables the wipe reaches only through a database cascade still hold rows:\n${leftovers.join("\n")}`,
+ ).toEqual([]);
+
+ // ── assert: the account survives, reset but signed-in-able ──
+ const after = await prisma.user.findUniqueOrThrow({
+ where: { id: user.id },
+ });
+ const record = after as unknown as Record;
+
+ expect(record.username).toBe("wipe-subject");
+ expect(record.passwordHash).toBe("argon2-placeholder");
+ for (const column of Object.keys(USER_KEPT_FIELDS)) {
+ expect(record).toHaveProperty(column);
+ }
+ expect(record.locale).toBe("en");
+ expect(record.unitPreference).toBe("metric");
+
+ const stillSet: string[] = [];
+ for (const column of Object.keys(USER_RESET)) {
+ const value = record[column];
+ const cleared =
+ value === null ||
+ value === false ||
+ value === 0 ||
+ (Array.isArray(value) && value.length === 0) ||
+ value === "aggregated" ||
+ value === "disconnected";
+ if (!cleared) stillSet.push(`${column} = ${JSON.stringify(value)}`);
+ }
+ expect(
+ stillSet,
+ `User columns the plan resets still carry a value:\n${stillSet.join("\n")}`,
+ ).toEqual([]);
+ });
+
+ it("refuses without the typed confirmation and leaves the record alone", async () => {
+ const prisma = getPrismaClient();
+
+ const user = await prisma.user.create({
+ data: { username: "wipe-refused", email: "wipe-refused@example.test" },
+ });
+ const session = await prisma.session.create({
+ data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) },
+ });
+ cookieJar.set("healthlog_session", session.id);
+ await prisma.measurement.create({
+ data: {
+ userId: user.id,
+ type: "WEIGHT",
+ value: 80,
+ unit: "kg",
+ measuredAt: new Date(),
+ },
+ });
+
+ const { DELETE } = await import("@/app/api/settings/data/route");
+ const response = await DELETE(
+ new Request("http://localhost/api/settings/data", {
+ method: "DELETE",
+ body: JSON.stringify({ confirm: "nope" }),
+ }) as never,
+ );
+
+ expect(response.status).toBe(422);
+ expect(await prisma.measurement.count({ where: { userId: user.id } })).toBe(
+ 1,
+ );
+ });
+
+ it("revokes a clinician share link so the token resolves to nothing", async () => {
+ const prisma = getPrismaClient();
+
+ const user = await prisma.user.create({
+ data: { username: "wipe-sharer", email: "wipe-sharer@example.test" },
+ });
+ const session = await prisma.session.create({
+ data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) },
+ });
+ cookieJar.set("healthlog_session", session.id);
+
+ const { hashToken } = await import("@/lib/auth/hmac");
+ const rawToken = `hls_${"a".repeat(48)}`;
+ await prisma.clinicianShareLink.create({
+ data: {
+ userId: user.id,
+ tokenHash: hashToken(rawToken),
+ label: "Practice",
+ rangeStart: new Date("2026-01-01T00:00:00Z"),
+ sectionsJson: {},
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
+ },
+ });
+
+ const { resolveShareToken } =
+ await import("@/lib/clinician-share/resolve-share-token");
+ expect(await resolveShareToken(rawToken)).not.toBeNull();
+
+ const { DELETE } = await import("@/app/api/settings/data/route");
+ const response = await DELETE(
+ new Request("http://localhost/api/settings/data", {
+ method: "DELETE",
+ body: JSON.stringify({ confirm: "DELETE" }),
+ }) as never,
+ );
+ expect(response.status).toBe(200);
+
+ // A clinician holding the link now reads nothing, not a stale record.
+ expect(await resolveShareToken(rawToken)).toBeNull();
+ });
+});
diff --git a/tests/integration/soft-delete-copy-parity.test.ts b/tests/integration/soft-delete-copy-parity.test.ts
new file mode 100644
index 000000000..052cc1c63
--- /dev/null
+++ b/tests/integration/soft-delete-copy-parity.test.ts
@@ -0,0 +1,120 @@
+/**
+ * Two confirmation dialogs promised permanence over a soft delete.
+ *
+ * `records.allergies.deleteConfirmDescription` says it can't be undone and
+ * `measurementReminders.deleteConfirmDescription` says the reminder is
+ * permanently deleted, while both handlers stamped `deletedAt` and kept the
+ * row — with no restore route, no undo affordance, and no purge job to give
+ * the tombstone a horizon. The row then rode the nightly disaster-recovery
+ * backup off the host, indefinitely.
+ *
+ * Neither tombstone bought anything: neither model is in the
+ * `/api/sync/changes` delta feed, nothing references either table, and every
+ * read already filtered `deletedAt: null`. So the behaviour moved to match
+ * the copy rather than the copy retreating.
+ *
+ * Asserted against the real database because the whole question is whether a
+ * row is still there. `src/__tests__/copy-matches-behaviour-guard.test.ts`
+ * holds the other end — that the copy keeps claiming permanence.
+ *
+ * Mutation check: put either handler back on `update({ data: { deletedAt } })`
+ * and the matching test goes red on the surviving row.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { cookieJar, headerJar } from "./mock-next-headers";
+import { getPrismaClient, truncateAllTables } from "./setup";
+
+vi.mock("next/headers", async () => {
+ const { cookieJar, headerJar } = await import("./mock-next-headers");
+ return {
+ headers: vi.fn(async () => ({
+ get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
+ })),
+ cookies: vi.fn(async () => ({
+ get: (name: string) => {
+ const value = cookieJar.get(name);
+ return value ? { name, value } : undefined;
+ },
+ set: (name: string, value: string) => {
+ cookieJar.set(name, value);
+ },
+ delete: (name: string) => {
+ cookieJar.delete(name);
+ },
+ })),
+ };
+});
+
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+
+async function signIn(username: string): Promise {
+ const prisma = getPrismaClient();
+ const user = await prisma.user.create({
+ data: { username, email: `${username}@example.test` },
+ });
+ const session = await prisma.session.create({
+ data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) },
+ });
+ cookieJar.set("healthlog_session", session.id);
+ return user.id;
+}
+
+beforeEach(async () => {
+ await truncateAllTables(getPrismaClient());
+ cookieJar.clear();
+ headerJar.clear();
+});
+
+describe("deletes that promise permanence remove the row", () => {
+ it("an allergy is gone from the database, not hidden", async () => {
+ const prisma = getPrismaClient();
+ const userId = await signIn("allergy-deleter");
+
+ const allergy = await prisma.allergy.create({
+ data: { userId, substance: "Penicillin" },
+ });
+
+ const { DELETE } = await import("@/app/api/allergies/[id]/route");
+ const response = await DELETE(
+ new Request(`http://localhost/api/allergies/${allergy.id}`, {
+ method: "DELETE",
+ }) as never,
+ { params: Promise.resolve({ id: allergy.id }) } as never,
+ );
+ expect(response.status).toBe(200);
+
+ expect(await prisma.allergy.count({ where: { id: allergy.id } })).toBe(0);
+ });
+
+ it("a checkup reminder is gone from the database, not hidden", async () => {
+ const prisma = getPrismaClient();
+ const userId = await signIn("reminder-deleter");
+
+ const reminder = await prisma.measurementReminder.create({
+ data: {
+ userId,
+ label: "Dental checkup",
+ intervalDays: 180,
+ notifyHour: 9,
+ nextDueAt: new Date(Date.now() + 86_400_000),
+ },
+ });
+
+ const { DELETE } =
+ await import("@/app/api/measurement-reminders/[id]/route");
+ const response = await DELETE(
+ new Request(`http://localhost/api/measurement-reminders/${reminder.id}`, {
+ method: "DELETE",
+ }) as never,
+ { params: Promise.resolve({ id: reminder.id }) } as never,
+ );
+ expect(response.status).toBe(200);
+
+ expect(
+ await prisma.measurementReminder.count({ where: { id: reminder.id } }),
+ ).toBe(0);
+ });
+});
From fb9a4ee616bad9230d4a1507aba202f27d431ac2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 02:28:14 +0200
Subject: [PATCH 04/52] test(sync): follow the tombstone purge's batched
outcome shape
---
tests/integration/sync-changes.test.ts | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/tests/integration/sync-changes.test.ts b/tests/integration/sync-changes.test.ts
index ef29461fe..64185964c 100644
--- a/tests/integration/sync-changes.test.ts
+++ b/tests/integration/sync-changes.test.ts
@@ -377,8 +377,10 @@ describe("GET /api/sync/changes (real Postgres)", () => {
const pruned = await cleanupExpiredMeasurementTombstones(prisma);
// Only the ancient row (deletedAt past the retention horizon) is
- // pruned; the recent reachable tombstone survives.
- expect(pruned).toBe(1);
+ // pruned; the recent reachable tombstone survives. `drained` says the
+ // batched walk reached the end of the backlog rather than stopping at
+ // its per-run cap.
+ expect(pruned).toEqual({ deleted: 1, drained: true });
const survivors = await prisma.measurement.findMany({
where: { userId: TEST_USER_ID },
select: { externalId: true },
From 84e38ab11023ba79f431fa5b158497c4cc23781f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 14:04:56 +0200
Subject: [PATCH 05/52] chore(db): number the retention indexes for the slot
they actually take
---
.../migration.sql | 0
1 file changed, 0 insertions(+), 0 deletions(-)
rename prisma/migrations/{0277_retention_purge_indexes => 0276_retention_purge_indexes}/migration.sql (100%)
diff --git a/prisma/migrations/0277_retention_purge_indexes/migration.sql b/prisma/migrations/0276_retention_purge_indexes/migration.sql
similarity index 100%
rename from prisma/migrations/0277_retention_purge_indexes/migration.sql
rename to prisma/migrations/0276_retention_purge_indexes/migration.sql
From 527dd6c1fa58724367e2e0a292ff68f6af0b2c96 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 01:02:35 +0200
Subject: [PATCH 06/52] fix(integrations): escalate a long transient failure
streak, and scope a success to its own leg
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A sync failure the classifier bucketed as `transient` had no exit. The park
test read `kind === "persistent"` and the persistent streak anchor was only
ever stamped by a persistent failure, so a streak could run for a thousand
attempts with the persistent counter at zero, the state frozen at
`error_transient`, and no ladder it could climb. `transient` is a hypothesis
about retryability, and the classifier defaults every timeout, 429, 5xx and
unrecognised response to it precisely because the wire cannot tell a reboot
from a permanent break. That default is right for one attempt and wrong for a
thousand: an unbroken streak retried hourly for a full day has refuted it by
experiment. Record it as `persistent` from there, which feeds the park ladder
that already exists — 48 h of unbroken failure end to end before an
integration stops attempting.
The counters could not accumulate anyway. `IntegrationStatus` is keyed
(user, integration) while Withings runs four legs on four crons and WHOOP four
resources on four more, and a success cleared the error, every bucket, the
streak anchor and the state whatever leg had recorded them. The ECG tick at
:41 wiped what the sleep tick recorded at :15 — including a reauth park — so
the strike ladder was reset several times an hour and the card painted a green
"connected · 12 minutes ago" over a data class that had stopped arriving. A
success now clears only what it is entitled to clear.
Two columns carry it: `failing_since_at` dates the streak, `failing_leg` names
its owner. The migration backfills the anchor for rows already in an error
state so a connection broken for weeks escalates on its next attempt rather
than restarting its clock.
---
messages/de.json | 2 +-
messages/en.json | 2 +-
messages/es.json | 2 +-
messages/fr.json | 2 +-
messages/it.json | 2 +-
messages/pl.json | 2 +-
.../migration.sql | 30 ++
prisma/schema.prisma | 10 +
src/app/api/integrations/status/route.ts | 8 +
.../settings/integration-status-pill.tsx | 11 +-
.../settings/integrations/shared.tsx | 11 +-
src/lib/integrations/__tests__/status.test.ts | 8 +
src/lib/integrations/status.ts | 215 ++++++++++-
src/lib/integrations/sync-verdict.ts | 23 +-
src/lib/jobs/reminder/whoop-sync.ts | 16 +-
src/lib/mcp/__tests__/tools.test.ts | 2 +
src/lib/mcp/tools.ts | 1 +
src/lib/polar/__tests__/sync.test.ts | 8 +-
src/lib/polar/sync-workouts.ts | 5 +
src/lib/polar/sync.ts | 6 +-
.../whoop/__tests__/sync-dead-token.test.ts | 22 +-
.../whoop/__tests__/sync-user-whoop.test.ts | 28 +-
src/lib/whoop/sync-core.ts | 10 +-
.../withings/__tests__/sync-activity.test.ts | 4 +-
src/lib/withings/__tests__/sync-ecg.test.ts | 15 +-
.../__tests__/sync-measure-watermark.test.ts | 8 +-
src/lib/withings/__tests__/sync-sleep.test.ts | 8 +-
src/lib/withings/sync-activity.ts | 15 +-
src/lib/withings/sync-ecg.ts | 17 +-
src/lib/withings/sync-sleep.ts | 13 +-
src/lib/withings/sync.ts | 24 +-
.../integration-sync-escalation.test.ts | 358 ++++++++++++++++++
32 files changed, 823 insertions(+), 65 deletions(-)
create mode 100644 prisma/migrations/0275_integration_failure_streak_and_leg/migration.sql
create mode 100644 tests/integration/integration-sync-escalation.test.ts
diff --git a/messages/de.json b/messages/de.json
index ee218f830..861e0a940 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -5155,7 +5155,7 @@
"integrationPill": {
"connected": "Verbunden",
"errorReconnect": "Fehler — neu verbinden",
- "warningServerError": "Verbunden, Sync-Problem",
+ "warningServerError": "Sync fehlgeschlagen",
"parkedReconnect": "Pausiert — manuell wieder verbinden",
"notConnected": "Nicht verbunden",
"justNow": "gerade eben",
diff --git a/messages/en.json b/messages/en.json
index 4c498d987..7edc56190 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -5155,7 +5155,7 @@
"integrationPill": {
"connected": "Connected",
"errorReconnect": "Error — reconnect",
- "warningServerError": "Connected, sync issue",
+ "warningServerError": "Sync failing",
"parkedReconnect": "Paused — reconnect manually",
"notConnected": "Not connected",
"justNow": "just now",
diff --git a/messages/es.json b/messages/es.json
index 8c609dfcd..a4c955d17 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -5155,7 +5155,7 @@
"integrationPill": {
"connected": "Conectado",
"errorReconnect": "Error — reconectar",
- "warningServerError": "Conectado, problema de sincronización",
+ "warningServerError": "Sincronización con errores",
"parkedReconnect": "Pausado — reconectar manualmente",
"notConnected": "No conectado",
"justNow": "ahora mismo",
diff --git a/messages/fr.json b/messages/fr.json
index 678b46765..cd3cc3217 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -5155,7 +5155,7 @@
"integrationPill": {
"connected": "Connecté",
"errorReconnect": "Erreur — reconnecter",
- "warningServerError": "Connecté, problème de synchronisation",
+ "warningServerError": "Échec de synchronisation",
"parkedReconnect": "En pause — reconnecter manuellement",
"notConnected": "Non connecté",
"justNow": "à l’instant",
diff --git a/messages/it.json b/messages/it.json
index 5a791cdc9..858cab7f8 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -5155,7 +5155,7 @@
"integrationPill": {
"connected": "Connesso",
"errorReconnect": "Errore — riconnetti",
- "warningServerError": "Connesso, problema di sincronizzazione",
+ "warningServerError": "Sincronizzazione in errore",
"parkedReconnect": "In pausa — riconnettere manualmente",
"notConnected": "Non connesso",
"justNow": "proprio ora",
diff --git a/messages/pl.json b/messages/pl.json
index 92b747092..4a8f61594 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -5155,7 +5155,7 @@
"integrationPill": {
"connected": "Połączono",
"errorReconnect": "Błąd — połącz ponownie",
- "warningServerError": "Połączono, problem z synchronizacją",
+ "warningServerError": "Synchronizacja nie działa",
"parkedReconnect": "Wstrzymane — połącz ręcznie",
"notConnected": "Nie połączono",
"justNow": "przed chwilą",
diff --git a/prisma/migrations/0275_integration_failure_streak_and_leg/migration.sql b/prisma/migrations/0275_integration_failure_streak_and_leg/migration.sql
new file mode 100644
index 000000000..d473ff3a2
--- /dev/null
+++ b/prisma/migrations/0275_integration_failure_streak_and_leg/migration.sql
@@ -0,0 +1,30 @@
+-- Integration sync ledger: date the failure streak, and name the leg that owns it.
+--
+-- `failing_since_at` is the start of the current unbroken failure streak,
+-- whatever kind the classifier assigned. The per-kind buckets counted attempts
+-- but nothing dated them, so a streak the classifier had called `transient`
+-- could run for a thousand attempts without ever becoming anything: the park
+-- test read `kind = 'persistent'` and the persistent anchor was only ever
+-- stamped by a persistent failure.
+--
+-- `failing_leg` records which sync leg the error came from. The table is keyed
+-- (user_id, integration) while Withings runs four legs on four crons and WHOOP
+-- runs four resources on four more, so a sibling leg's success was clearing an
+-- error it had not caused — several times an hour, which is why the strike
+-- ladder never climbed.
+ALTER TABLE "integration_statuses"
+ ADD COLUMN "failing_since_at" TIMESTAMP(3),
+ ADD COLUMN "failing_leg" TEXT;
+
+-- Backfill for rows that are already failing. Without this a connection that
+-- has been broken for weeks would restart its clock at the next attempt and
+-- need another two days before it escalated. `last_success_at` is the honest
+-- anchor where it exists (the streak began at most one cron tick after it);
+-- rows that never succeeded fall back to the last attempt, then to creation.
+-- `failing_leg` is left NULL: which leg broke is not recoverable from the row,
+-- and NULL means "unattributed", under which any success still clears — the
+-- pre-existing behaviour, applied only to the backfilled rows.
+UPDATE "integration_statuses"
+SET "failing_since_at" = COALESCE("last_success_at", "last_attempt_at", "created_at")
+WHERE "state" IN ('error_transient', 'error_reauth', 'parked')
+ AND "failing_since_at" IS NULL;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index b625607b2..b54bdac41 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -4137,6 +4137,16 @@ model IntegrationStatus {
// sole source of truth for the alert ladder.
consecutiveFailuresByKind Json? @map("consecutive_failures_by_kind") // v1.4.43 W14 — { transient, reauth_required, persistent }
persistentFailureStartedAt DateTime? @map("persistent_failure_started_at") // v1.4.43 W14 — start of the current persistent streak (drives >24h park)
+ // Start of the current unbroken failure streak, whatever the kind. The
+ // per-kind buckets count attempts; this dates them, which is what tells a
+ // blip apart from a dead pipe. Cleared by a success that is entitled to
+ // clear it, by a reconnect, a disconnect, and a resume.
+ failingSinceAt DateTime? @map("failing_since_at")
+ // Which sync leg owns the error currently on the row. `null` = the provider
+ // has a single leg, or the failure predates leg attribution. A success from
+ // a DIFFERENT leg may not clear an error this one did not cause — see
+ // `recordSyncSuccess`.
+ failingLeg String? @map("failing_leg")
alertedAt DateTime? @map("alerted_at") // last time admin Telegram alert fired (idempotency guard)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts
index e74ad777f..9611e5c57 100644
--- a/src/app/api/integrations/status/route.ts
+++ b/src/app/api/integrations/status/route.ts
@@ -168,6 +168,7 @@ export const GET = apiHandler(async () => {
state: string;
lastSuccessAt: string | null;
lastAttemptAt: string | null;
+ failingSince: string | null;
},
opts: {
connected?: boolean;
@@ -181,6 +182,7 @@ export const GET = apiHandler(async () => {
state: snapshot.state,
lastSuccessAt: snapshot.lastSuccessAt,
lastAttemptAt: snapshot.lastAttemptAt,
+ failingSinceAt: snapshot.failingSince,
legacyLastSyncedAt: opts.legacyLastSyncedAt ?? null,
cadence: INTEGRATION_CADENCE[integration],
now,
@@ -381,6 +383,12 @@ interface IntegrationViewModel {
lastSuccessAt: string | null;
lastAttemptAt: string | null;
lastError: string | null;
+ /**
+ * Start of the current unbroken failure streak, or null when the provider is
+ * not failing. `syncHealth.since` is derived from it; the raw instant rides
+ * along so a client can render the age without re-deriving the verdict.
+ */
+ failingSince: string | null;
/**
* The server-resolved liveness verdict. Present on EVERY entry, whether or
* not a card renders it — the unit of coverage is the envelope entry, not
diff --git a/src/components/settings/integration-status-pill.tsx b/src/components/settings/integration-status-pill.tsx
index 35d8ef14d..0850617cc 100644
--- a/src/components/settings/integration-status-pill.tsx
+++ b/src/components/settings/integration-status-pill.tsx
@@ -110,11 +110,20 @@ const WARNING_STATES = new Set([
"parked",
]);
-/** The states whose label carries an inline relative timestamp. */
+/**
+ * The states whose label carries an inline relative timestamp.
+ *
+ * `warning` is in the set because leaving it out was the whole lie: the pill
+ * said "sync issue" identically on hour one and on day seventeen, and the age
+ * — which the mapper computes and passes in — was dropped on the floor. How
+ * long it has been broken is the only part of that sentence a person can act
+ * on.
+ */
const RELATIVE_STATES = new Set([
"connected",
"stale",
"stalled",
+ "warning",
]);
export function IntegrationStatusPill({
diff --git a/src/components/settings/integrations/shared.tsx b/src/components/settings/integrations/shared.tsx
index 24583bec8..a7fb3a2d6 100644
--- a/src/components/settings/integrations/shared.tsx
+++ b/src/components/settings/integrations/shared.tsx
@@ -161,14 +161,19 @@ export function pillStateFor(
/**
* The timestamp the pill shows inline. `connected` reads the last success (a
* connection row's own `lastSyncedAt` wins when it is newer, which is what
- * repaints a fresh "just now" straight after a manual sync); the two aging
- * states read the instant that triggered the verdict.
+ * repaints a fresh "just now" straight after a manual sync); the aging and
+ * failing states read the instant that triggered the verdict.
+ *
+ * `failing` must NOT fall through to the `legacyLastSyncedAt` arm below. That
+ * field is a connection row's own clock, kept fresh by the very sync that is
+ * erroring, so a provider that had delivered nothing for a fortnight painted a
+ * "just now" beside its warning.
*/
export function pillTimestampFor(
status: IntegrationStatusViewModel | undefined,
): string | null {
const verdict = status?.syncHealth?.verdict;
- if (verdict === "stale" || verdict === "stalled") {
+ if (verdict === "stale" || verdict === "stalled" || verdict === "failing") {
return status?.syncHealth?.since ?? null;
}
return status?.legacyLastSyncedAt ?? status?.lastSuccessAt ?? null;
diff --git a/src/lib/integrations/__tests__/status.test.ts b/src/lib/integrations/__tests__/status.test.ts
index 4eea74911..e8a95ac1b 100644
--- a/src/lib/integrations/__tests__/status.test.ts
+++ b/src/lib/integrations/__tests__/status.test.ts
@@ -108,6 +108,8 @@ describe("recordSyncSuccess", () => {
persistent: 0,
},
persistentFailureStartedAt: null,
+ failingSinceAt: null,
+ failingLeg: null,
alertedAt: null,
});
});
@@ -493,6 +495,8 @@ describe("markReauthRequired / markDisconnected / markReconnected", () => {
persistent: 0,
},
persistentFailureStartedAt: null,
+ failingSinceAt: null,
+ failingLeg: null,
alertedAt: null,
});
});
@@ -516,6 +520,8 @@ describe("markReauthRequired / markDisconnected / markReconnected", () => {
persistent: 0,
},
persistentFailureStartedAt: null,
+ failingSinceAt: null,
+ failingLeg: null,
alertedAt: null,
});
});
@@ -833,6 +839,8 @@ describe("resumeIntegrationFromPark", () => {
persistent: 0,
},
persistentFailureStartedAt: null,
+ failingSinceAt: null,
+ failingLeg: null,
alertedAt: null,
});
diff --git a/src/lib/integrations/status.ts b/src/lib/integrations/status.ts
index b844cb4ed..8edc70e9f 100644
--- a/src/lib/integrations/status.ts
+++ b/src/lib/integrations/status.ts
@@ -34,7 +34,7 @@
import { prisma } from "@/lib/db";
import { encrypt, decrypt } from "@/lib/crypto";
import { auditLog } from "@/lib/auth/audit";
-import { getEvent } from "@/lib/logging/context";
+import { annotate, getEvent } from "@/lib/logging/context";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
import type { IntegrationClassification } from "@/lib/integrations/http-status-classifier";
@@ -194,6 +194,41 @@ const ALERT_REPEAT_WINDOW_MS = 24 * 60 * 60 * 1000;
*/
const PARK_PERSISTENT_FAILURE_AFTER_MS = 24 * 60 * 60 * 1000;
+/**
+ * How long an unbroken failure streak may keep claiming to be `transient`
+ * before the claim is treated as refuted and the failure is recorded as
+ * `persistent` instead.
+ *
+ * `transient` is a hypothesis, not an observation: the classifier defaults a
+ * timeout, a 429, a 5xx and any unrecognised response to it precisely because
+ * the wire cannot tell "the upstream is rebooting" from "this will never work
+ * again". That default is right for one attempt and wrong for a thousand — a
+ * streak that has been retried hourly for a full day without a single success
+ * has falsified the hypothesis by experiment, and continuing to call it
+ * transient is the ledger asserting a certainty the wire never gave it.
+ *
+ * 24 h is the same window the persistent park already uses. An upstream
+ * maintenance night, a rate-limit cool-off, a redeploy and a DNS wobble all
+ * fit comfortably inside it, so a genuine blip recovers and resets the streak
+ * long before this trips — nothing that heals on its own takes a day. The
+ * escalation is additionally gated on `getPersistentFailureThreshold()`
+ * consecutive failures, so a provider whose cron ticks rarely cannot escalate
+ * on its second-ever attempt just because wall-clock time passed.
+ *
+ * Escalating feeds the EXISTING ladder rather than a second one: the
+ * `persistent` bucket starts counting, `persistentFailureStartedAt` anchors,
+ * and `PARK_PERSISTENT_FAILURE_AFTER_MS` later the row parks and stops
+ * retrying — 48 h of unbroken failure end to end before anything is disabled.
+ */
+const TRANSIENT_ESCALATION_AFTER_MS = 24 * 60 * 60 * 1000;
+
+/** Row states that mean "an error is currently held on this row". */
+const ERROR_STATES: ReadonlySet = new Set([
+ "error_transient",
+ "error_reauth",
+ "parked",
+]);
+
export interface IntegrationStatusSnapshot {
integration: IntegrationKey;
state: IntegrationState;
@@ -205,6 +240,13 @@ export interface IntegrationStatusSnapshot {
* of truth now that the legacy `consecutiveFailures` column is
* gone. */
consecutiveFailuresByKind: ConsecutiveFailuresByKind | null;
+ /**
+ * Start of the current unbroken failure streak, whatever the kind. The
+ * buckets count attempts; this dates them, which is what tells a blip apart
+ * from a pipe that has been dead for weeks. `null` whenever the row is not
+ * currently failing.
+ */
+ failingSince: string | null;
}
/**
@@ -271,6 +313,7 @@ export async function getIntegrationStatus(
lastAttemptAt: null,
lastError: null,
consecutiveFailuresByKind: null,
+ failingSince: null,
};
}
return {
@@ -280,6 +323,7 @@ export async function getIntegrationStatus(
lastAttemptAt: row.lastAttemptAt?.toISOString() ?? null,
lastError: row.lastError ? safeDecryptError(row.lastError) : null,
consecutiveFailuresByKind: readBucketColumn(row.consecutiveFailuresByKind),
+ failingSince: row.failingSinceAt?.toISOString() ?? null,
};
}
@@ -305,17 +349,79 @@ export async function isReauthRequired(
return row?.state === "error_reauth" || row?.state === "parked";
}
+export interface RecordSyncSuccessOptions {
+ /**
+ * The sync leg that succeeded, for a provider whose legs run on separate
+ * schedules (Withings measures / activity / sleep / ECG, the four WHOOP
+ * resources, the two Polar legs). Omit it for a single-leg provider or for a
+ * full pass that covered every leg — an omitted leg clears the row whole,
+ * which is the pre-existing behaviour and the correct one for those callers.
+ */
+ leg?: string;
+}
+
/**
* Record a successful sync. Resets the failure counter, clears any
* prior error message, and (importantly) flips state back to
* `connected` even from `error_reauth` — the Withings flow re-enters
* after the OAuth callback writes a new refresh token.
+ *
+ * A success only clears what it is entitled to clear. `IntegrationStatus` is
+ * keyed `(userId, integration)`, but Withings runs four legs on four crons and
+ * WHOOP runs four resources on four more — so an unconditional clear meant the
+ * ECG leg succeeding at :41 wiped the error, the buckets, the streak anchor and
+ * the state that the sleep leg had recorded at :15. The failing leg's strike
+ * ladder was reset several times an hour, so it could never reach the alert
+ * threshold or the park window, and the card painted a green "connected · 12
+ * minutes ago" over a data class that had stopped arriving. When the row holds
+ * an error owned by a DIFFERENT named leg, this success records its attempt and
+ * leaves the error standing.
+ *
+ * The partial path deliberately does NOT advance `lastSuccessAt`. That field is
+ * what the freshness arms and the card's "connected · X ago" read, and moving
+ * it forward while another leg is dead is exactly the sentence this change
+ * exists to stop the ledger from saying. `lastAttemptAt` does advance, so the
+ * row still reads as "being tried" rather than ageing into `stalled`.
*/
export async function recordSyncSuccess(
userId: string,
integration: IntegrationKey,
+ options?: RecordSyncSuccessOptions,
): Promise {
const now = new Date();
+ const leg = options?.leg ?? null;
+
+ if (leg !== null) {
+ const existing = await prisma.integrationStatus.findUnique({
+ where: { userId_integration: { userId, integration } },
+ select: { state: true, failingLeg: true },
+ });
+ const heldByAnotherLeg =
+ existing != null &&
+ existing.failingLeg != null &&
+ existing.failingLeg !== leg &&
+ ERROR_STATES.has(existing.state);
+
+ if (heldByAnotherLeg) {
+ await prisma.integrationStatus.update({
+ where: { userId_integration: { userId, integration } },
+ data: { lastAttemptAt: now },
+ });
+ // Meta only — the enclosing wide event owns its own action name (the
+ // cron task), and overwriting it here would rename the job.
+ annotate({
+ meta: {
+ integration_partial_success: {
+ integration,
+ succeeded_leg: leg,
+ failing_leg: existing.failingLeg,
+ },
+ },
+ });
+ return;
+ }
+ }
+
// v1.4.43 W14 — a success resets ALL per-kind buckets back to zero
// and clears the persistent-streak start timestamp.
// v1.4.47 W1 — the legacy `consecutiveFailures` column was dropped
@@ -337,6 +443,8 @@ export async function recordSyncSuccess(
lastError: null,
consecutiveFailuresByKind: zeroBuckets(),
persistentFailureStartedAt: null,
+ failingSinceAt: null,
+ failingLeg: null,
alertedAt: null,
},
});
@@ -349,6 +457,12 @@ export interface RecordSyncFailureInput {
message: string;
/** Optional structured error code (e.g. "invalid_grant", "401"). */
errorCode?: string;
+ /**
+ * The sync leg that failed, for a provider whose legs run on separate
+ * schedules. Recorded on the row so a sibling leg's success cannot clear an
+ * error it did not cause. Omit it for a single-leg provider.
+ */
+ leg?: string;
}
/**
@@ -375,23 +489,35 @@ export interface RecordSyncFailureInput {
* v1.4.47 W1 — the legacy single-column `consecutiveFailures` integer
* was dropped (migration 0077); the bucket is now the sole counter
* and the back-fill branch is gone.
+ *
+ * A `transient` failure whose streak has run unbroken for longer than
+ * `TRANSIENT_ESCALATION_AFTER_MS` is recorded as `persistent` instead. Before
+ * that, a `transient` streak had no exit at all: the park test read
+ * `kind === "persistent"`, `persistentFailureStartedAt` was only stamped on a
+ * persistent failure, and so a streak the classifier had bucketed as transient
+ * could run for a thousand attempts with the persistent counter at zero, the
+ * state frozen at `error_transient`, and no ladder it could ever climb. See
+ * the constant for why time, rather than count, is the honest signal.
*/
export async function recordSyncFailure(
input: RecordSyncFailureInput,
): Promise {
const { userId, integration, kind, message, errorCode } = input;
+ const leg = input.leg ?? null;
const now = new Date();
const encryptedError = safeEncryptError(message);
// Read the current row first so we can:
// (a) compute the new per-kind bucket value
- // (b) decide whether the persistent streak has exceeded the
+ // (b) decide whether the streak has outlived the `transient` claim
+ // (c) decide whether the persistent streak has exceeded the
// park threshold
const existing = await prisma.integrationStatus.findUnique({
where: { userId_integration: { userId, integration } },
select: {
consecutiveFailuresByKind: true,
persistentFailureStartedAt: true,
+ failingSinceAt: true,
alertedAt: true,
},
});
@@ -402,6 +528,30 @@ export async function recordSyncFailure(
// or for the first-ever write of this (user, integration) pair
const startingBuckets = readBucketColumn(existing?.consecutiveFailuresByKind);
+ // Streak anchor: the first failure of the current unbroken run. Every path
+ // that resolves a failure (success entitled to clear, reconnect, disconnect,
+ // resume) nulls it, so a non-null value always dates a live streak.
+ const failingSinceAt = existing?.failingSinceAt ?? now;
+ const streakAgeMs = now.getTime() - failingSinceAt.getTime();
+
+ // The `transient` hypothesis, tested against the clock. Both conditions are
+ // required: the wall-clock window rules out a blip, and the consecutive-count
+ // gate keeps a rarely-polled provider from escalating on its second attempt
+ // just because a day of real time went by. `+ 1` because this failure has not
+ // been written into the bucket yet.
+ const transientStreakAfterThis = (startingBuckets?.transient ?? 0) + 1;
+ const escalatedFromTransient =
+ kind === "transient" &&
+ streakAgeMs >= TRANSIENT_ESCALATION_AFTER_MS &&
+ transientStreakAfterThis >= getPersistentFailureThreshold();
+
+ // Everything downstream — bucket, state mapping, park test, audit detail,
+ // alert copy — reads the effective kind, so the escalation feeds the ladder
+ // that already exists instead of growing a parallel one.
+ const effectiveKind: FailureKind = escalatedFromTransient
+ ? "persistent"
+ : kind;
+
// Snapshot the persistent-bucket count BEFORE we increment so the
// streak-anchor decision below can see the pre-increment value.
// Otherwise we'd inspect the freshly-incremented bucket and never
@@ -419,14 +569,21 @@ export async function recordSyncFailure(
// Increment only the bucket matching this failure's kind.
// The other two buckets stay at their current value so a persistent
// streak isn't reset by an intervening transient hiccup.
- buckets[kind] = (buckets[kind] ?? 0) + 1;
+ buckets[effectiveKind] = (buckets[effectiveKind] ?? 0) + 1;
// Track the persistent-streak start so the >24h park check has a
// wall-clock anchor. Only stamped on the FIRST persistent failure of
// a streak; cleared on success or when the persistent bucket goes
// back to zero (which today only happens via success, but the logic
// is symmetric for future "transient drained the streak" rules).
- const isPersistent = kind === "persistent";
+ //
+ // An escalated transient anchors here too, at the moment of escalation
+ // rather than at the streak start. That is deliberate: it dates when the
+ // failure started being treated as unrecoverable, which is what the park
+ // window measures, and it puts a full second day between "this is no longer
+ // transient" and "stop retrying" — 48 h of unbroken failure before an
+ // integration disables itself over a verdict the wire never actually gave.
+ const isPersistent = effectiveKind === "persistent";
let persistentFailureStartedAt: Date | null =
existing?.persistentFailureStartedAt ?? null;
if (isPersistent && persistentStreakBefore === 0) {
@@ -455,7 +612,7 @@ export async function recordSyncFailure(
// grep for contract-bug bursts)
const newState: IntegrationState = shouldPark
? "parked"
- : kind === "reauth_required"
+ : effectiveKind === "reauth_required"
? "error_reauth"
: "error_transient";
@@ -469,6 +626,8 @@ export async function recordSyncFailure(
lastError: encryptedError,
consecutiveFailuresByKind: buckets,
persistentFailureStartedAt: isPersistent ? now : null,
+ failingSinceAt,
+ failingLeg: leg,
},
update: {
state: newState,
@@ -476,6 +635,8 @@ export async function recordSyncFailure(
lastError: encryptedError,
consecutiveFailuresByKind: buckets,
persistentFailureStartedAt,
+ failingSinceAt,
+ failingLeg: leg,
},
});
@@ -496,11 +657,18 @@ export async function recordSyncFailure(
userId,
details: {
integration,
- kind,
+ kind: effectiveKind,
+ // Non-null only when the clock overrode the classifier, so an operator
+ // reading the trail can tell "the upstream said this was permanent" from
+ // "we concluded it was after a day of retrying".
+ escalatedFrom: escalatedFromTransient ? kind : null,
+ failingLeg: leg,
+ failingSinceAt: failingSinceAt.toISOString(),
+ streakAgeMs,
errorCode: errorCode ?? null,
message,
attemptNumber: bucketTotal,
- bucketCount: buckets[kind],
+ bucketCount: buckets[effectiveKind],
state: newState,
},
});
@@ -528,7 +696,7 @@ export async function recordSyncFailure(
await maybeAlertAdmins({
userId,
integration,
- kind,
+ kind: effectiveKind,
message,
errorCode,
consecutiveFailures: alertSignal,
@@ -560,6 +728,10 @@ export async function recordSyncFailure(
persistentFailureStartedAt:
existing.persistentFailureStartedAt.toISOString(),
persistentStreakAgeMs,
+ // The whole streak, which for an escalated transient is a full day
+ // longer than the persistent window above.
+ failingSinceAt: failingSinceAt.toISOString(),
+ streakAgeMs,
errorCode: errorCode ?? null,
message,
},
@@ -603,6 +775,8 @@ export async function resumeIntegrationFromPark(
lastError: null,
consecutiveFailuresByKind: zeroBuckets(),
persistentFailureStartedAt: null,
+ failingSinceAt: null,
+ failingLeg: null,
alertedAt: null,
},
});
@@ -646,8 +820,11 @@ export async function parkIntegrationAtReauth(opts: {
integration: IntegrationKey;
message: string;
errorCode: string;
+ /** The leg whose scope gap forced the park, for multi-leg providers. */
+ leg?: string;
}): Promise {
const { userId, integration, message, errorCode } = opts;
+ const leg = opts.leg ?? null;
const now = new Date();
const encryptedError = safeEncryptError(message);
@@ -656,7 +833,7 @@ export async function parkIntegrationAtReauth(opts: {
// audit log when the state or error changes.
const existing = await prisma.integrationStatus.findUnique({
where: { userId_integration: { userId, integration } },
- select: { state: true, lastError: true },
+ select: { state: true, lastError: true, failingSinceAt: true },
});
const isFreshPark =
existing?.state !== "error_reauth" || existing.lastError !== encryptedError;
@@ -673,6 +850,8 @@ export async function parkIntegrationAtReauth(opts: {
// zero so a later genuine transient burst still has the full
// 3-strike runway before paging.
consecutiveFailuresByKind: zeroBuckets(),
+ failingSinceAt: now,
+ failingLeg: leg,
},
update: {
state: "error_reauth",
@@ -681,6 +860,8 @@ export async function parkIntegrationAtReauth(opts: {
// Deliberately omit `consecutiveFailuresByKind` — the existing
// bucket values are preserved exactly. This is the whole point
// of the helper.
+ failingSinceAt: existing?.failingSinceAt ?? now,
+ failingLeg: leg,
},
});
@@ -703,6 +884,12 @@ export async function markReauthRequired(
integration: IntegrationKey,
message: string,
): Promise {
+ const now = new Date();
+ const existing = await prisma.integrationStatus.findUnique({
+ where: { userId_integration: { userId, integration } },
+ select: { failingSinceAt: true },
+ });
+
// v1.4.47 W1 — the legacy `consecutiveFailures` column was dropped
// (migration 0077). Out-of-band reauth detection (proactive token
// refresh) seeds the `reauth_required` bucket at 1 on a first-ever
@@ -720,11 +907,15 @@ export async function markReauthRequired(
reauth_required: 1,
persistent: 0,
},
- lastAttemptAt: new Date(),
+ lastAttemptAt: now,
+ failingSinceAt: now,
},
update: {
state: "error_reauth",
lastError: safeEncryptError(message),
+ // Never re-stamp a streak already running — the anchor dates the
+ // failure, not the detection.
+ failingSinceAt: existing?.failingSinceAt ?? now,
},
});
@@ -756,6 +947,8 @@ export async function markDisconnected(
lastError: null,
consecutiveFailuresByKind: zeroBuckets(),
persistentFailureStartedAt: null,
+ failingSinceAt: null,
+ failingLeg: null,
alertedAt: null,
},
});
@@ -783,6 +976,8 @@ export async function markReconnected(
lastError: null,
consecutiveFailuresByKind: zeroBuckets(),
persistentFailureStartedAt: null,
+ failingSinceAt: null,
+ failingLeg: null,
alertedAt: null,
},
});
diff --git a/src/lib/integrations/sync-verdict.ts b/src/lib/integrations/sync-verdict.ts
index f4ab30bce..d0313902b 100644
--- a/src/lib/integrations/sync-verdict.ts
+++ b/src/lib/integrations/sync-verdict.ts
@@ -132,6 +132,14 @@ export interface SyncVerdictInput {
lastAttemptAt?: string | Date | null;
/** A connection row's own `lastSyncedAt`, for rows predating the ledger. */
legacyLastSyncedAt?: string | Date | null;
+ /**
+ * Start of the current unbroken failure streak, from the ledger. This is what
+ * the error verdicts date themselves by. `lastAttemptAt` is the wrong anchor
+ * — it is refreshed every hour by the retry itself, so a connection dead for
+ * a fortnight reported "failing, just now". `lastSuccessAt` is nearly right
+ * but a multi-leg provider can advance it while one leg stays broken.
+ */
+ failingSinceAt?: string | Date | null;
/** Newest observed data instant, when the caller knows it (Apple Health). */
lastDataAt?: string | Date | null;
cadence: SyncCadence;
@@ -183,6 +191,7 @@ export function resolveSyncVerdict(input: SyncVerdictInput): SyncHealth {
const lastAttempt = toMillis(input.lastAttemptAt);
const legacy = toMillis(input.legacyLastSyncedAt);
const lastData = toMillis(input.lastDataAt);
+ const failingSince = toMillis(input.failingSinceAt);
// 1. No live connection, or one the user explicitly ended.
if (input.connected === false)
@@ -196,10 +205,13 @@ export function resolveSyncVerdict(input: SyncVerdictInput): SyncHealth {
// 2. States the user has to act on, in order of specificity.
if (input.state === "parked") {
- return { verdict: "parked", since: iso(lastAttempt) };
+ return { verdict: "parked", since: iso(failingSince ?? lastAttempt) };
}
if (input.state === "error_reauth") {
- return { verdict: "reauth_required", since: iso(lastAttempt) };
+ return {
+ verdict: "reauth_required",
+ since: iso(failingSince ?? lastAttempt),
+ };
}
// 3. Nothing has ever run and nothing has ever arrived — the honest reading
@@ -218,9 +230,12 @@ export function resolveSyncVerdict(input: SyncVerdictInput): SyncHealth {
}
}
- // 5. Trying and erroring.
+ // 5. Trying and erroring. `since` dates the streak, not the last retry: this
+ // is the field the card renders, and "failing since a minute ago" on a
+ // connection that last delivered a fortnight back is the sentence this
+ // whole path exists to stop.
if (input.state === "error_transient") {
- return { verdict: "failing", since: iso(lastSuccess) };
+ return { verdict: "failing", since: iso(failingSince ?? lastSuccess) };
}
// 6. Running, but nothing new is arriving. The primary arm for push sources.
diff --git a/src/lib/jobs/reminder/whoop-sync.ts b/src/lib/jobs/reminder/whoop-sync.ts
index b8df364f5..10f07df56 100644
--- a/src/lib/jobs/reminder/whoop-sync.ts
+++ b/src/lib/jobs/reminder/whoop-sync.ts
@@ -24,6 +24,7 @@ import {
import {
recordWhoopSyncFailure,
syncWhoopResourceWithStatus,
+ type WhoopSyncLeg,
} from "@/lib/whoop/sync-core";
import { isSyncFailureRecorded } from "@/lib/integrations/status";
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
@@ -68,6 +69,7 @@ type ByIdSync = (userId: string, resourceId: string) => Promise;
*/
export async function runWhoopResourceSync(
taskName: string,
+ leg: WhoopSyncLeg,
jobs: Job[],
syncFn: CollectionSync,
byIdFn?: ByIdSync,
@@ -101,7 +103,7 @@ export async function runWhoopResourceSync(
// Stamp `IntegrationStatus.lastSuccessAt` on a genuine per-resource
// import — a webhook-driven refresh advanced the cursor but used to
// leave the sync-status ledger frozen until the next full poll.
- const imported = await syncWhoopResourceWithStatus(userId, () =>
+ const imported = await syncWhoopResourceWithStatus(userId, leg, () =>
resourceId && byIdFn ? byIdFn(userId, resourceId) : syncFn(userId),
);
measurementsImported += imported;
@@ -119,7 +121,7 @@ export async function runWhoopResourceSync(
// marked in `handleCollectionFetchError`, so record only the UNMARKED
// escapes here.
if (!isSyncFailureRecorded(err)) {
- await recordWhoopSyncFailure(userId, err);
+ await recordWhoopSyncFailure(userId, err, leg);
}
evt.addWarning(`${taskName} failed for user ${userId}: ${err}`);
}
@@ -144,6 +146,7 @@ export async function runWhoopResourceSync(
export function handleWhoopRecoverySync(jobs: Job[]) {
return runWhoopResourceSync(
"job.whoop_recovery_sync",
+ "recovery",
jobs,
syncUserRecovery,
syncWhoopRecoveryById,
@@ -153,6 +156,7 @@ export function handleWhoopRecoverySync(jobs: Job[]) {
export function handleWhoopSleepSync(jobs: Job[]) {
return runWhoopResourceSync(
"job.whoop_sleep_sync",
+ "sleep",
jobs,
syncWhoopSleep,
syncWhoopSleepById,
@@ -162,6 +166,7 @@ export function handleWhoopSleepSync(jobs: Job[]) {
export function handleWhoopWorkoutSync(jobs: Job[]) {
return runWhoopResourceSync(
"job.whoop_workout_sync",
+ "workout",
jobs,
syncUserWorkout,
syncWhoopWorkoutById,
@@ -170,5 +175,10 @@ export function handleWhoopWorkoutSync(jobs: Job[]) {
export function handleWhoopCycleSync(jobs: Job[]) {
// Cycle has no webhook (poll-only), so no fetch-by-id path.
- return runWhoopResourceSync("job.whoop_cycle_sync", jobs, syncUserCycle);
+ return runWhoopResourceSync(
+ "job.whoop_cycle_sync",
+ "cycle",
+ jobs,
+ syncUserCycle,
+ );
}
diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts
index e5c7526c5..ddf9c85f8 100644
--- a/src/lib/mcp/__tests__/tools.test.ts
+++ b/src/lib/mcp/__tests__/tools.test.ts
@@ -572,6 +572,7 @@ describe("get_integration_status", () => {
lastAttemptAt: "2026-06-27T00:00:00.000Z",
lastError: "token revoked",
consecutiveFailuresByKind: null,
+ failingSince: null,
});
const result = (await tool("get_integration_status").run(CTX, {})) as {
@@ -611,6 +612,7 @@ describe("get_integration_status", () => {
lastAttemptAt,
lastError: null,
consecutiveFailuresByKind: null,
+ failingSince: null,
});
return tool("get_integration_status")
diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts
index 46be574db..13abed92e 100644
--- a/src/lib/mcp/tools.ts
+++ b/src/lib/mcp/tools.ts
@@ -1518,6 +1518,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
state: s.state,
lastSuccessAt: s.lastSuccessAt,
lastAttemptAt: s.lastAttemptAt,
+ failingSinceAt: s.failingSince,
cadence: INTEGRATION_CADENCE[s.integration],
});
return {
diff --git a/src/lib/polar/__tests__/sync.test.ts b/src/lib/polar/__tests__/sync.test.ts
index 56e33a343..f5d12ff9c 100644
--- a/src/lib/polar/__tests__/sync.test.ts
+++ b/src/lib/polar/__tests__/sync.test.ts
@@ -196,7 +196,9 @@ describe("syncUserPolar", () => {
externalId: "recharge:2026-06-10:recovery",
value: 100,
});
- expect(recordSuccessMock).toHaveBeenCalledWith("u1", "polar");
+ expect(recordSuccessMock).toHaveBeenCalledWith("u1", "polar", {
+ leg: "vitals",
+ });
});
it("maps a cardio-load row with source POLAR + stable externalId", async () => {
@@ -482,7 +484,9 @@ describe("syncUserPolar", () => {
expect(recordFailureMock).not.toHaveBeenCalled();
expect(recordSuccessMock).toHaveBeenCalledTimes(1);
- expect(recordSuccessMock).toHaveBeenCalledWith("u1", "polar");
+ expect(recordSuccessMock).toHaveBeenCalledWith("u1", "polar", {
+ leg: "vitals",
+ });
});
it.each([
diff --git a/src/lib/polar/sync-workouts.ts b/src/lib/polar/sync-workouts.ts
index 0f7e942c0..7084ae511 100644
--- a/src/lib/polar/sync-workouts.ts
+++ b/src/lib/polar/sync-workouts.ts
@@ -40,6 +40,8 @@ import { fetchExercises, mapExercise, type PolarWorkoutRow } from "./client";
import { getPolarConnection } from "./credentials";
import { PolarApiError, classifyPolarError } from "./response-classifier";
+const POLAR_LEG_WORKOUTS = "workouts";
+
async function recordPolarWorkoutFailure(
userId: string,
err: unknown,
@@ -47,6 +49,9 @@ async function recordPolarWorkoutFailure(
await recordSyncFailure({
userId,
integration: "polar",
+ // The workouts leg rides the same hourly tick as vitals but owns its own
+ // errors: the vitals success stamp must not clear a failure from here.
+ leg: POLAR_LEG_WORKOUTS,
// Same classification the vitals leg uses (`classifyPolarFailure` in
// `./sync`); resolved directly off the shared classifier so this leg does
// not have to import the heavier vitals-sync module.
diff --git a/src/lib/polar/sync.ts b/src/lib/polar/sync.ts
index 048445a4f..e6f62a920 100644
--- a/src/lib/polar/sync.ts
+++ b/src/lib/polar/sync.ts
@@ -91,6 +91,8 @@ export function classifyPolarFailure(err: unknown): FailureKind {
* record a future escape with the SAME provider-owned classification the inline
* catch already applies.
*/
+export const POLAR_LEG_VITALS = "vitals";
+
export async function recordPolarSyncFailure(
userId: string,
err: unknown,
@@ -98,6 +100,7 @@ export async function recordPolarSyncFailure(
await recordSyncFailure({
userId,
integration: "polar",
+ leg: POLAR_LEG_VITALS,
kind: classifyPolarFailure(err),
message: err instanceof Error ? err.message : String(err),
errorCode:
@@ -288,6 +291,7 @@ export async function syncUserPolar(userId: string): Promise {
await recordSyncFailure({
userId,
integration: "polar",
+ leg: POLAR_LEG_VITALS,
kind: classifyPolarFailure(firstErr),
message: `partial sync failure (${names}): ${
firstErr instanceof Error ? firstErr.message : String(firstErr)
@@ -300,7 +304,7 @@ export async function syncUserPolar(userId: string): Promise {
return imported;
}
- await recordSyncSuccess(userId, "polar");
+ await recordSyncSuccess(userId, "polar", { leg: POLAR_LEG_VITALS });
return imported;
}
diff --git a/src/lib/whoop/__tests__/sync-dead-token.test.ts b/src/lib/whoop/__tests__/sync-dead-token.test.ts
index 3065f74a2..cea0a9b4e 100644
--- a/src/lib/whoop/__tests__/sync-dead-token.test.ts
+++ b/src/lib/whoop/__tests__/sync-dead-token.test.ts
@@ -259,19 +259,29 @@ describe("syncWhoopResourceWithStatus — dominant per-resource cron path", () =
// The hourly-cron arm Fitbit has no twin for: a dead token noted by
// `getValidToken` inside `run` must block the per-resource success stamp,
// or the release is inert on the path that runs most often.
- const imported = await syncWhoopResourceWithStatus("user-1", async () => {
- noteHardFailure(WHOOP_TOKEN_HARD_FAIL);
- return 0;
- });
+ const imported = await syncWhoopResourceWithStatus(
+ "user-1",
+ "recovery",
+ async () => {
+ noteHardFailure(WHOOP_TOKEN_HARD_FAIL);
+ return 0;
+ },
+ );
expect(imported).toBe(0);
expect(recordSyncSuccessMock).not.toHaveBeenCalled();
});
it("stamps success on a clean per-resource run (no hard failure)", async () => {
- const imported = await syncWhoopResourceWithStatus("user-1", async () => 3);
+ const imported = await syncWhoopResourceWithStatus(
+ "user-1",
+ "recovery",
+ async () => 3,
+ );
expect(imported).toBe(3);
- expect(recordSyncSuccessMock).toHaveBeenCalledWith("user-1", "whoop");
+ expect(recordSyncSuccessMock).toHaveBeenCalledWith("user-1", "whoop", {
+ leg: "recovery",
+ });
});
});
diff --git a/src/lib/whoop/__tests__/sync-user-whoop.test.ts b/src/lib/whoop/__tests__/sync-user-whoop.test.ts
index 2c5fcfbdc..00110e072 100644
--- a/src/lib/whoop/__tests__/sync-user-whoop.test.ts
+++ b/src/lib/whoop/__tests__/sync-user-whoop.test.ts
@@ -177,24 +177,38 @@ describe("syncUserWhoop — all-403 looks-healthy guard", () => {
describe("syncWhoopResourceWithStatus — per-resource lastSuccessAt stamp", () => {
it("stamps success when the resource imported rows", async () => {
- const imported = await syncWhoopResourceWithStatus("user1", async () => 5);
+ const imported = await syncWhoopResourceWithStatus(
+ "user1",
+ "recovery",
+ async () => 5,
+ );
expect(imported).toBe(5);
- expect(recordSyncSuccess).toHaveBeenCalledWith("user1", "whoop");
+ expect(recordSyncSuccess).toHaveBeenCalledWith("user1", "whoop", {
+ leg: "recovery",
+ });
});
it("stamps success on a clean fetch that imported nothing new", async () => {
// No 403 soft-skip → a genuine "nothing changed" webhook/cron tick still
// proves the connection is alive, so `lastSuccessAt` advances.
- const imported = await syncWhoopResourceWithStatus("user1", async () => 0);
+ const imported = await syncWhoopResourceWithStatus(
+ "user1",
+ "recovery",
+ async () => 0,
+ );
expect(imported).toBe(0);
- expect(recordSyncSuccess).toHaveBeenCalledWith("user1", "whoop");
+ expect(recordSyncSuccess).toHaveBeenCalledWith("user1", "whoop", {
+ leg: "recovery",
+ });
});
it("does NOT stamp success when the resource soft-skipped a 403 and imported nothing", async () => {
- const imported = await syncWhoopResourceWithStatus("user1", () =>
- softSkip403("user1"),
+ const imported = await syncWhoopResourceWithStatus(
+ "user1",
+ "recovery",
+ () => softSkip403("user1"),
);
expect(imported).toBe(0);
@@ -204,7 +218,7 @@ describe("syncWhoopResourceWithStatus — per-resource lastSuccessAt stamp", ()
it("propagates a hard failure without stamping success", async () => {
await expect(
- syncWhoopResourceWithStatus("user1", async () => {
+ syncWhoopResourceWithStatus("user1", "recovery", async () => {
throw new Error("boom");
}),
).rejects.toThrow("boom");
diff --git a/src/lib/whoop/sync-core.ts b/src/lib/whoop/sync-core.ts
index dc54db379..2e2e41212 100644
--- a/src/lib/whoop/sync-core.ts
+++ b/src/lib/whoop/sync-core.ts
@@ -591,8 +591,11 @@ export async function markResourceSynced(
* a genuine import clears the error state; a 403 soft-skip leaves the
* "looks-healthy" window closed.
*/
+export type WhoopSyncLeg = "recovery" | "sleep" | "workout" | "cycle";
+
export async function syncWhoopResourceWithStatus(
userId: string,
+ leg: WhoopSyncLeg,
run: () => Promise,
): Promise {
// Run under BOTH trackers: soft-skip (a 403 scope gate) and hard-fail (a dead
@@ -607,7 +610,10 @@ export async function syncWhoopResourceWithStatus(
);
const softSkippedOnly = softSkipCount > 0 && imported === 0;
if (!softSkippedOnly && hardFailTracker.failures.length === 0) {
- await recordSyncSuccess(userId, "whoop");
+ // Leg-scoped: WHOOP runs four resource crons against one ledger row, so a
+ // healthy recovery tick at :05 must not clear the error the workout tick
+ // recorded at :35 the hour before.
+ await recordSyncSuccess(userId, "whoop", { leg });
}
return imported;
}
@@ -619,6 +625,7 @@ export async function syncWhoopResourceWithStatus(
export async function recordWhoopSyncFailure(
userId: string,
err: unknown,
+ leg?: WhoopSyncLeg,
): Promise {
const message = err instanceof Error ? err.message : String(err);
await recordSyncFailure({
@@ -626,6 +633,7 @@ export async function recordWhoopSyncFailure(
integration: "whoop",
kind: classificationToFailureKind(classifyWhoopError(err)),
message,
+ leg,
errorCode:
err instanceof WhoopApiError ? err.httpStatus?.toString() : undefined,
});
diff --git a/src/lib/withings/__tests__/sync-activity.test.ts b/src/lib/withings/__tests__/sync-activity.test.ts
index 0f9c2a80d..8c1ba17fe 100644
--- a/src/lib/withings/__tests__/sync-activity.test.ts
+++ b/src/lib/withings/__tests__/sync-activity.test.ts
@@ -370,7 +370,9 @@ describe("syncUserActivity — field mapping + idempotency", () => {
} as never);
await syncUserActivity("user-1");
- expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings");
+ expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings", {
+ leg: "activity",
+ });
});
it("W-2 — records a transient failure and refuses the success stamp when the batch write throws", async () => {
diff --git a/src/lib/withings/__tests__/sync-ecg.test.ts b/src/lib/withings/__tests__/sync-ecg.test.ts
index 92317bd21..5baca7a92 100644
--- a/src/lib/withings/__tests__/sync-ecg.test.ts
+++ b/src/lib/withings/__tests__/sync-ecg.test.ts
@@ -216,7 +216,9 @@ describe("syncUserEcg", () => {
rhythmClassification: "IRREGULAR",
externalId: "withings:ecg:user-1:111",
});
- expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings");
+ expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings", {
+ leg: "ecg",
+ });
});
it("never narrows the broad catch-up to an ordinary webhook window", async () => {
@@ -384,6 +386,7 @@ describe("syncUserEcg", () => {
expect(recordWithingsSyncFailure).toHaveBeenCalledWith(
"user-1",
expect.objectContaining({ message: "source write failed" }),
+ "ecg",
);
});
@@ -405,7 +408,11 @@ describe("syncUserEcg", () => {
"IRREGULAR_RHYTHM_NOTIFICATION",
new Date("2024-05-06T00:00:00.000Z"),
);
- expect(recordWithingsSyncFailure).toHaveBeenCalledWith("user-1", poison);
+ expect(recordWithingsSyncFailure).toHaveBeenCalledWith(
+ "user-1",
+ poison,
+ "ecg",
+ );
expect(recordSyncSuccess).not.toHaveBeenCalled();
});
@@ -435,6 +442,8 @@ describe("syncUserEcg", () => {
expect(imported).toBe(1);
expect(prisma.measurement.upsert).toHaveBeenCalledTimes(1);
expect(prisma.ecgRecording.upsert).not.toHaveBeenCalled();
- expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings");
+ expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings", {
+ leg: "ecg",
+ });
});
});
diff --git a/src/lib/withings/__tests__/sync-measure-watermark.test.ts b/src/lib/withings/__tests__/sync-measure-watermark.test.ts
index c81a9d4a6..c597f80b4 100644
--- a/src/lib/withings/__tests__/sync-measure-watermark.test.ts
+++ b/src/lib/withings/__tests__/sync-measure-watermark.test.ts
@@ -201,7 +201,9 @@ describe("syncUserMeasurements — watermark hold on hard row failure (F4)", ()
// still healthy: stamp the watermark + success, record no failure.
expect(imported).toBe(0);
expect(prisma.withingsConnection.update).toHaveBeenCalledTimes(1);
- expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings");
+ expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings", {
+ leg: "measures",
+ });
expect(recordSyncFailure).not.toHaveBeenCalled();
});
@@ -218,7 +220,9 @@ describe("syncUserMeasurements — watermark hold on hard row failure (F4)", ()
expect(imported).toBe(1);
expect(prisma.withingsConnection.update).toHaveBeenCalledTimes(1);
- expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings");
+ expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings", {
+ leg: "measures",
+ });
expect(recordSyncFailure).not.toHaveBeenCalled();
});
});
diff --git a/src/lib/withings/__tests__/sync-sleep.test.ts b/src/lib/withings/__tests__/sync-sleep.test.ts
index 98fcff22d..bf414bfe0 100644
--- a/src/lib/withings/__tests__/sync-sleep.test.ts
+++ b/src/lib/withings/__tests__/sync-sleep.test.ts
@@ -470,7 +470,9 @@ describe("syncUserSleep — segment writes + idempotency", () => {
it("calls recordSyncSuccess after a clean round-trip", async () => {
installFetchMock([]);
await syncUserSleep("user-1");
- expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings");
+ expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings", {
+ leg: "sleep",
+ });
});
it("W-3 — records a transient failure and rethrows when a sleep write throws", async () => {
@@ -580,7 +582,9 @@ describe("syncUserSleep — segment writes + idempotency", () => {
// The one stage segment is still written; the summary failure is swallowed.
expect(imported).toBe(1);
expect(reconcileMock).toHaveBeenCalledTimes(1);
- expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings");
+ expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings", {
+ leg: "sleep",
+ });
});
it("tolerates a ScanWatch night with no REM segment (no all-zeros row)", async () => {
diff --git a/src/lib/withings/sync-activity.ts b/src/lib/withings/sync-activity.ts
index 05431f7ef..869fb1d18 100644
--- a/src/lib/withings/sync-activity.ts
+++ b/src/lib/withings/sync-activity.ts
@@ -50,7 +50,11 @@ import {
WithingsApiError,
classifyWithingsResponse,
} from "./response-classifier";
-import { getValidToken, recordWithingsSyncFailure } from "./sync";
+import {
+ WITHINGS_LEG_ACTIVITY,
+ getValidToken,
+ recordWithingsSyncFailure,
+} from "./sync";
import {
isReauthRequired,
parkIntegrationAtReauth,
@@ -253,6 +257,7 @@ export async function syncUserActivity(
message:
"Withings connection is missing the user.activity scope. Reconnect Withings in Settings to enable activity sync.",
errorCode: "scope_missing",
+ leg: WITHINGS_LEG_ACTIVITY,
});
return 0;
}
@@ -303,10 +308,11 @@ export async function syncUserActivity(
kind: "reauth_required",
message: err instanceof Error ? err.message : String(err),
errorCode: "403",
+ leg: WITHINGS_LEG_ACTIVITY,
});
throw err;
}
- await recordWithingsSyncFailure(userId, err);
+ await recordWithingsSyncFailure(userId, err, WITHINGS_LEG_ACTIVITY);
throw err;
}
@@ -468,6 +474,7 @@ export async function syncUserActivity(
message: `activity batch write failed: ${
err instanceof Error ? err.message : String(err)
}`,
+ leg: WITHINGS_LEG_ACTIVITY,
});
}
}
@@ -500,7 +507,9 @@ export async function syncUserActivity(
// W-2 — only a clean write stamps success. A swallowed batch-write left the
// ledger honest via the transient failure recorded above.
if (!writeFailed) {
- await recordSyncSuccess(userId, "withings");
+ await recordSyncSuccess(userId, "withings", {
+ leg: WITHINGS_LEG_ACTIVITY,
+ });
}
return imported;
}
diff --git a/src/lib/withings/sync-ecg.ts b/src/lib/withings/sync-ecg.ts
index 04c99bb1a..7f226913f 100644
--- a/src/lib/withings/sync-ecg.ts
+++ b/src/lib/withings/sync-ecg.ts
@@ -64,7 +64,11 @@ import {
WithingsApiError,
classifyWithingsResponse,
} from "./response-classifier";
-import { getValidToken, recordWithingsSyncFailure } from "./sync";
+import {
+ WITHINGS_LEG_ECG,
+ getValidToken,
+ recordWithingsSyncFailure,
+} from "./sync";
import {
isReauthRequired,
recordSyncFailure,
@@ -408,10 +412,11 @@ export async function syncUserEcg(
kind: "reauth_required",
message: err instanceof Error ? err.message : String(err),
errorCode: "403",
+ leg: WITHINGS_LEG_ECG,
});
throw err;
}
- await recordWithingsSyncFailure(userId, err);
+ await recordWithingsSyncFailure(userId, err, WITHINGS_LEG_ECG);
throw err;
}
@@ -526,10 +531,14 @@ export async function syncUserEcg(
}
if (sourceWriteError) {
- await recordWithingsSyncFailure(userId, sourceWriteError.cause);
+ await recordWithingsSyncFailure(
+ userId,
+ sourceWriteError.cause,
+ WITHINGS_LEG_ECG,
+ );
throw sourceWriteError.cause;
}
- await recordSyncSuccess(userId, "withings");
+ await recordSyncSuccess(userId, "withings", { leg: WITHINGS_LEG_ECG });
return imported;
}
diff --git a/src/lib/withings/sync-sleep.ts b/src/lib/withings/sync-sleep.ts
index 37ba27262..840b5d8d3 100644
--- a/src/lib/withings/sync-sleep.ts
+++ b/src/lib/withings/sync-sleep.ts
@@ -64,7 +64,11 @@ import {
WithingsApiError,
classifyWithingsResponse,
} from "./response-classifier";
-import { getValidToken, recordWithingsSyncFailure } from "./sync";
+import {
+ WITHINGS_LEG_SLEEP,
+ getValidToken,
+ recordWithingsSyncFailure,
+} from "./sync";
import {
isReauthRequired,
parkIntegrationAtReauth,
@@ -355,6 +359,7 @@ export async function syncUserSleep(
message:
"Withings connection is missing the user.activity scope. Reconnect Withings in Settings to enable sleep sync.",
errorCode: "scope_missing",
+ leg: WITHINGS_LEG_SLEEP,
});
return 0;
}
@@ -394,10 +399,11 @@ export async function syncUserSleep(
kind: "reauth_required",
message: err instanceof Error ? err.message : String(err),
errorCode: "403",
+ leg: WITHINGS_LEG_SLEEP,
});
throw err;
}
- await recordWithingsSyncFailure(userId, err);
+ await recordWithingsSyncFailure(userId, err, WITHINGS_LEG_SLEEP);
throw err;
}
@@ -429,6 +435,7 @@ export async function syncUserSleep(
message: `sleep write failed: ${
err instanceof Error ? err.message : String(err)
}`,
+ leg: WITHINGS_LEG_SLEEP,
});
throw err;
};
@@ -581,6 +588,6 @@ export async function syncUserSleep(
() => {},
);
- await recordSyncSuccess(userId, "withings");
+ await recordSyncSuccess(userId, "withings", { leg: WITHINGS_LEG_SLEEP });
return imported;
}
diff --git a/src/lib/withings/sync.ts b/src/lib/withings/sync.ts
index 5594b39f9..3da585814 100644
--- a/src/lib/withings/sync.ts
+++ b/src/lib/withings/sync.ts
@@ -156,7 +156,7 @@ export async function getValidToken(
}, PROVIDER_REFRESH_TRANSACTION_OPTIONS);
} catch (err) {
getEvent()?.addWarning(`Token refresh failed for user ${userId}`);
- await recordWithingsSyncFailure(userId, err);
+ await recordWithingsSyncFailure(userId, err, WITHINGS_LEG_MEASURES);
if (options.throwOnRefreshFailure) throw err;
return null;
}
@@ -226,7 +226,7 @@ export async function syncUserMeasurements(
try {
measures = await fetchMeasurements(tokenInfo.accessToken, startDate);
} catch (err) {
- await recordWithingsSyncFailure(userId, err);
+ await recordWithingsSyncFailure(userId, err, WITHINGS_LEG_MEASURES);
throw err;
}
@@ -388,6 +388,7 @@ export async function syncUserMeasurements(
await recordWithingsSyncFailure(
userId,
firstRowError ?? new Error("Withings measure row write failed"),
+ WITHINGS_LEG_MEASURES,
);
return imported;
}
@@ -402,7 +403,9 @@ export async function syncUserMeasurements(
// round-trip is the signal "the connection is healthy". A user who
// doesn't weigh themselves for a week shouldn't see "error" on
// their settings page.
- await recordSyncSuccess(userId, "withings");
+ await recordSyncSuccess(userId, "withings", {
+ leg: WITHINGS_LEG_MEASURES,
+ });
return imported;
}
@@ -423,9 +426,23 @@ export async function syncUserMeasurements(
* `Error` instances (e.g. a pg-boss job retry that lost the prototype
* during the JSON round-trip).
*/
+/**
+ * The four Withings sync legs. Each runs on its own cron at its own minute and
+ * writes the SAME `(user, withings)` ledger row, so a failure has to say which
+ * leg it came from — otherwise the next sibling to succeed clears an error it
+ * did not cause, which is what kept a broken leg invisible.
+ */
+export type WithingsSyncLeg = "measures" | "activity" | "sleep" | "ecg";
+
+export const WITHINGS_LEG_MEASURES: WithingsSyncLeg = "measures";
+export const WITHINGS_LEG_ACTIVITY: WithingsSyncLeg = "activity";
+export const WITHINGS_LEG_SLEEP: WithingsSyncLeg = "sleep";
+export const WITHINGS_LEG_ECG: WithingsSyncLeg = "ecg";
+
export async function recordWithingsSyncFailure(
userId: string,
err: unknown,
+ leg: WithingsSyncLeg,
): Promise {
const message = err instanceof Error ? err.message : String(err);
await recordSyncFailure({
@@ -433,6 +450,7 @@ export async function recordWithingsSyncFailure(
integration: "withings",
kind: classificationToFailureKind(classifyError(err)),
message,
+ leg,
errorCode:
err instanceof WithingsApiError
? err.withingsStatus?.toString()
diff --git a/tests/integration/integration-sync-escalation.test.ts b/tests/integration/integration-sync-escalation.test.ts
new file mode 100644
index 000000000..fee900389
--- /dev/null
+++ b/tests/integration/integration-sync-escalation.test.ts
@@ -0,0 +1,358 @@
+/**
+ * Failure escalation and leg-scoped clearing on the integration ledger (real
+ * Postgres).
+ *
+ * Two defects are pinned here, and both are about state that only exists across
+ * several writes — a mocked Prisma cannot show either of them.
+ *
+ * 1. A `transient` streak had no exit. The park test read
+ * `kind === "persistent"` and the persistent anchor was only ever stamped
+ * by a persistent failure, so a streak the classifier had bucketed as
+ * transient could run indefinitely with the persistent counter at zero and
+ * the state frozen at `error_transient`.
+ * 2. `IntegrationStatus` is keyed `(userId, integration)` while Withings runs
+ * four legs on four crons and WHOOP four resources on four more. An
+ * unconditional success cleared the error, the buckets, the streak anchor
+ * and the state that a DIFFERENT leg had recorded, several times an hour,
+ * so the failing leg's strike ladder could never climb.
+ *
+ * The dispatcher is mocked at module level so the alert path resolves without
+ * dialling Telegram; the assertions are on ledger state, not the network.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+process.env.ENCRYPTION_KEY ??=
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+
+import { getPrismaClient, truncateAllTables } from "./setup";
+
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+
+vi.mock("@/lib/notifications/dispatcher", () => ({
+ dispatchNotification: vi.fn().mockResolvedValue(undefined),
+}));
+
+import {
+ getIntegrationStatus,
+ isReauthRequired,
+ recordSyncFailure,
+ recordSyncSuccess,
+} from "@/lib/integrations/status";
+
+const TEST_USER_ID = "user-sync-escalation";
+const HOUR_MS = 60 * 60 * 1000;
+
+function row(integration: string) {
+ return getPrismaClient().integrationStatus.findUnique({
+ where: { userId_integration: { userId: TEST_USER_ID, integration } },
+ });
+}
+
+/**
+ * Age the streak anchor backwards so a multi-day window can be exercised
+ * without a multi-day test. Only the anchor moves — every counter, the state
+ * and the error stay exactly as the production writes left them.
+ */
+async function ageStreakBy(integration: string, ms: number) {
+ const current = await row(integration);
+ const anchor = current!.failingSinceAt!;
+ await getPrismaClient().integrationStatus.update({
+ where: { userId_integration: { userId: TEST_USER_ID, integration } },
+ data: { failingSinceAt: new Date(anchor.getTime() - ms) },
+ });
+}
+
+async function agePersistentAnchorBy(integration: string, ms: number) {
+ const current = await row(integration);
+ const anchor = current!.persistentFailureStartedAt!;
+ await getPrismaClient().integrationStatus.update({
+ where: { userId_integration: { userId: TEST_USER_ID, integration } },
+ data: { persistentFailureStartedAt: new Date(anchor.getTime() - ms) },
+ });
+}
+
+async function failTransient(integration: string, leg?: string) {
+ await recordSyncFailure({
+ userId: TEST_USER_ID,
+ integration: integration as "withings",
+ kind: "transient",
+ message: `${integration} sync error: 503 - upstream unavailable`,
+ errorCode: "503",
+ leg,
+ });
+}
+
+beforeEach(async () => {
+ await truncateAllTables(getPrismaClient());
+ await getPrismaClient().user.create({
+ data: {
+ id: TEST_USER_ID,
+ username: "sync-escalation",
+ email: "sync-escalation@example.test",
+ },
+ });
+});
+
+describe("failure escalation", () => {
+ it("escalates an unbroken transient streak to persistent once it outlives the transient window", async () => {
+ // Three hourly failures — a real burst, still inside the window.
+ await failTransient("withings");
+ await failTransient("withings");
+ await failTransient("withings");
+
+ let current = await row("withings");
+ expect(current!.state).toBe("error_transient");
+ expect(current!.consecutiveFailuresByKind).toMatchObject({
+ transient: 3,
+ persistent: 0,
+ });
+ expect(current!.persistentFailureStartedAt).toBeNull();
+ expect(current!.failingSinceAt).toBeInstanceOf(Date);
+
+ // The upstream stays broken for a full day. Nothing else changes.
+ await ageStreakBy("withings", 25 * HOUR_MS);
+ await failTransient("withings");
+
+ current = await row("withings");
+ // The transient bucket is frozen where the window closed; the failure now
+ // counts against the persistent bucket the park ladder reads.
+ expect(current!.consecutiveFailuresByKind).toMatchObject({
+ transient: 3,
+ persistent: 1,
+ });
+ expect(current!.persistentFailureStartedAt).toBeInstanceOf(Date);
+
+ const audits = await getPrismaClient().auditLog.findMany({
+ where: { action: "integrations.sync.failed", userId: TEST_USER_ID },
+ orderBy: { createdAt: "asc" },
+ });
+ const last = JSON.parse(audits[audits.length - 1]!.details ?? "{}");
+ expect(last.kind).toBe("persistent");
+ // The trail keeps the classifier's own verdict, so an operator can tell
+ // "the upstream said this was permanent" from "we concluded it was".
+ expect(last.escalatedFrom).toBe("transient");
+ });
+
+ it("parks the escalated streak a further window later and stops attempting", async () => {
+ await failTransient("withings");
+ await failTransient("withings");
+ await failTransient("withings");
+ await ageStreakBy("withings", 25 * HOUR_MS);
+ await failTransient("withings");
+
+ // Escalated, still retrying — parking on the same tick would turn one long
+ // upstream outage into a manual reconnect for every account.
+ expect((await row("withings"))!.state).toBe("error_transient");
+ expect(await isReauthRequired(TEST_USER_ID, "withings")).toBe(false);
+
+ // A second day of the same.
+ await agePersistentAnchorBy("withings", 25 * HOUR_MS);
+ await failTransient("withings");
+
+ const current = await row("withings");
+ expect(current!.state).toBe("parked");
+ expect(await isReauthRequired(TEST_USER_ID, "withings")).toBe(true);
+
+ const parked = await getPrismaClient().auditLog.findMany({
+ where: { action: "integrations.parked", userId: TEST_USER_ID },
+ });
+ expect(parked).toHaveLength(1);
+ });
+
+ it("does not escalate a short outage that recovers", async () => {
+ // Well past the wall-clock window, but the streak keeps being broken by a
+ // success — which is exactly what a flapping upstream looks like.
+ for (let cycle = 0; cycle < 4; cycle++) {
+ await failTransient("withings");
+ await failTransient("withings");
+ await ageStreakBy("withings", 48 * HOUR_MS);
+ await recordSyncSuccess(TEST_USER_ID, "withings");
+ }
+
+ const current = await row("withings");
+ expect(current!.state).toBe("connected");
+ expect(current!.consecutiveFailuresByKind).toMatchObject({
+ transient: 0,
+ reauth_required: 0,
+ persistent: 0,
+ });
+ expect(current!.persistentFailureStartedAt).toBeNull();
+ expect(current!.failingSinceAt).toBeNull();
+ expect(current!.lastError).toBeNull();
+ });
+
+ it("does not escalate on wall-clock alone below the consecutive-failure threshold", async () => {
+ // A rarely-polled provider: one failure, then a day passes, then a second.
+ // Time is necessary but not sufficient — otherwise a provider that ticks
+ // once a day would escalate on its second-ever attempt.
+ await failTransient("nightscout");
+ await ageStreakBy("nightscout", 25 * HOUR_MS);
+ await failTransient("nightscout");
+
+ const current = await row("nightscout");
+ expect(current!.state).toBe("error_transient");
+ expect(current!.consecutiveFailuresByKind).toMatchObject({
+ transient: 2,
+ persistent: 0,
+ });
+ expect(current!.persistentFailureStartedAt).toBeNull();
+ });
+
+ it("parks a rejected credential at error_reauth on the first failure instead of retrying it", async () => {
+ await recordSyncFailure({
+ userId: TEST_USER_ID,
+ integration: "whoop",
+ kind: "reauth_required",
+ message: "WHOOP refresh error: 400 - invalid_grant",
+ errorCode: "invalid_grant",
+ leg: "recovery",
+ });
+
+ const current = await row("whoop");
+ expect(current!.state).toBe("error_reauth");
+ expect(current!.failingSinceAt).toBeInstanceOf(Date);
+ // The sync entry-points short-circuit — a revoked grant is not retried a
+ // thousand times.
+ expect(await isReauthRequired(TEST_USER_ID, "whoop")).toBe(true);
+ });
+
+ it("dates the streak from its first failure, not from the last retry", async () => {
+ await failTransient("withings");
+ const firstAttempt = (await row("withings"))!.failingSinceAt!;
+
+ await failTransient("withings");
+ await failTransient("withings");
+
+ const current = await row("withings");
+ expect(current!.failingSinceAt!.getTime()).toBe(firstAttempt.getTime());
+ // `lastAttemptAt` keeps moving — that is the field that made a fortnight of
+ // silence read as "failing, just now".
+ expect(current!.lastAttemptAt!.getTime()).toBeGreaterThanOrEqual(
+ firstAttempt.getTime(),
+ );
+
+ const snapshot = await getIntegrationStatus(TEST_USER_ID, "withings");
+ expect(snapshot.failingSince).toBe(firstAttempt.toISOString());
+ });
+});
+
+describe("leg-scoped success", () => {
+ it("does not let a sibling leg's success clear another leg's error", async () => {
+ await recordSyncFailure({
+ userId: TEST_USER_ID,
+ integration: "withings",
+ kind: "transient",
+ message: "sleep write failed: reconciliation conflict",
+ leg: "sleep",
+ });
+ const afterFailure = await row("withings");
+ const streakStart = afterFailure!.failingSinceAt!;
+
+ // The ECG leg ticks 26 minutes later and is perfectly healthy.
+ await recordSyncSuccess(TEST_USER_ID, "withings", { leg: "ecg" });
+
+ const current = await row("withings");
+ expect(current!.state).toBe("error_transient");
+ expect(current!.lastError).toBe(afterFailure!.lastError);
+ expect(current!.consecutiveFailuresByKind).toMatchObject({ transient: 1 });
+ expect(current!.failingLeg).toBe("sleep");
+ expect(current!.failingSinceAt!.getTime()).toBe(streakStart.getTime());
+ // Not advanced: it is what the card's "connected · X ago" reads, and the
+ // sleep pipe has delivered nothing.
+ expect(current!.lastSuccessAt).toBeNull();
+ // The attempt did happen, so the row must not age into `stalled`.
+ expect(current!.lastAttemptAt).toBeInstanceOf(Date);
+ });
+
+ it("lets the owning leg's own success clear the row whole", async () => {
+ await recordSyncFailure({
+ userId: TEST_USER_ID,
+ integration: "withings",
+ kind: "transient",
+ message: "sleep write failed: reconciliation conflict",
+ leg: "sleep",
+ });
+ await recordSyncSuccess(TEST_USER_ID, "withings", { leg: "ecg" });
+ await recordSyncSuccess(TEST_USER_ID, "withings", { leg: "sleep" });
+
+ const current = await row("withings");
+ expect(current!.state).toBe("connected");
+ expect(current!.lastError).toBeNull();
+ expect(current!.failingLeg).toBeNull();
+ expect(current!.failingSinceAt).toBeNull();
+ expect(current!.lastSuccessAt).toBeInstanceOf(Date);
+ expect(current!.consecutiveFailuresByKind).toMatchObject({
+ transient: 0,
+ reauth_required: 0,
+ persistent: 0,
+ });
+ });
+
+ it("keeps a leg's reauth park standing against a sibling leg's success", async () => {
+ await recordSyncFailure({
+ userId: TEST_USER_ID,
+ integration: "withings",
+ kind: "reauth_required",
+ message: "Withings sleep error: 403",
+ errorCode: "403",
+ leg: "sleep",
+ });
+ expect(await isReauthRequired(TEST_USER_ID, "withings")).toBe(true);
+
+ await recordSyncSuccess(TEST_USER_ID, "withings", { leg: "ecg" });
+
+ // Un-parking here is the worst shape of the bug: the sleep leg's grant is
+ // gone, and the row went back to claiming a healthy connection.
+ expect(await isReauthRequired(TEST_USER_ID, "withings")).toBe(true);
+ expect((await row("withings"))!.state).toBe("error_reauth");
+ });
+
+ it("lets a sibling success be counted while the strike ladder still climbs", async () => {
+ // The regression, end to end: the failing leg fails hourly, a healthy leg
+ // succeeds in between, and the streak must still reach the escalation.
+ for (let hour = 0; hour < 3; hour++) {
+ await failTransient("withings", "sleep");
+ await recordSyncSuccess(TEST_USER_ID, "withings", { leg: "ecg" });
+ }
+ await ageStreakBy("withings", 25 * HOUR_MS);
+ await failTransient("withings", "sleep");
+
+ const current = await row("withings");
+ expect(current!.consecutiveFailuresByKind).toMatchObject({
+ transient: 3,
+ persistent: 1,
+ });
+ expect(current!.persistentFailureStartedAt).toBeInstanceOf(Date);
+ });
+
+ it("clears the row whole when a full pass names no leg", async () => {
+ // A run that covered every resource is entitled to clear everything, and a
+ // single-leg provider that never names a leg keeps its old behaviour.
+ await recordSyncFailure({
+ userId: TEST_USER_ID,
+ integration: "whoop",
+ kind: "transient",
+ message: "WHOOP workout error: 503",
+ leg: "workout",
+ });
+ await recordSyncSuccess(TEST_USER_ID, "whoop");
+
+ const current = await row("whoop");
+ expect(current!.state).toBe("connected");
+ expect(current!.lastError).toBeNull();
+ expect(current!.failingLeg).toBeNull();
+ expect(current!.failingSinceAt).toBeNull();
+ });
+
+ it("lets any success clear an unattributed failure", async () => {
+ await failTransient("oura");
+ await recordSyncSuccess(TEST_USER_ID, "oura", { leg: "vitals" });
+
+ const current = await row("oura");
+ expect(current!.state).toBe("connected");
+ expect(current!.lastError).toBeNull();
+ expect(current!.failingSinceAt).toBeNull();
+ });
+});
From 4e3b029c38ed1d04e2415c136a7d7509f816ad5b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 01:10:44 +0200
Subject: [PATCH 07/52] test(integrations): pin the escalation ladder,
leg-scoped clearing, and what the card says
Mutation-checked against the production paths they cover: the escalation
verdict, the leg guard in `recordSyncSuccess`, the streak anchor the verdict
dates itself by, the status envelope threading it, and the pill's relative
timestamp.
Also give the four legacy per-provider status routes the verdict the
consolidated envelope already carries. They answered `connected: true` plus a
`state` string and nothing else, so a client reading one of them saw a live
connection over a pipe that had delivered nothing for weeks.
---
.../status/__tests__/route.test.ts | 38 ++++
src/app/api/integrations/status/route.ts | 42 ++--
src/app/api/nightscout/status/route.ts | 17 ++
src/app/api/oura/status/route.ts | 17 ++
src/app/api/polar/status/route.ts | 17 ++
src/app/api/strava/status/route.ts | 17 ++
.../integration-status-pill.test.tsx | 34 +++
src/lib/integrations/__tests__/status.test.ts | 209 ++++++++++++++++++
.../__tests__/sync-verdict.test.ts | 67 +++++-
9 files changed, 443 insertions(+), 15 deletions(-)
diff --git a/src/app/api/integrations/status/__tests__/route.test.ts b/src/app/api/integrations/status/__tests__/route.test.ts
index edba6a7c8..ec5d59e2e 100644
--- a/src/app/api/integrations/status/__tests__/route.test.ts
+++ b/src/app/api/integrations/status/__tests__/route.test.ts
@@ -29,6 +29,7 @@ const ledger: Record = {
lastSuccessAt: null,
lastAttemptAt: null,
lastError: null,
+ failingSince: null,
};
vi.mock("@/lib/integrations/status", () => ({
getIntegrationStatus: vi.fn(async (_u: string, integration: string) => ({
@@ -239,3 +240,40 @@ describe("/api/integrations/status — sync-health verdict on every entry", () =
expect(nightscout!.syncHealth).toBeDefined();
});
});
+
+describe("/api/integrations/status — a provider that has delivered nothing for weeks", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ for (const key of Object.keys(perIntegrationLedger)) {
+ delete perIntegrationLedger[key];
+ }
+ userFind.mockResolvedValue({});
+ whoopFind.mockResolvedValue(null);
+ polarAvailable.mockResolvedValue(true);
+ ouraAvailable.mockResolvedValue(false);
+ });
+
+ it("reports it as failing and dates the verdict from the streak, not the retry", async () => {
+ const failingSince = new Date(
+ Date.now() - 17 * 24 * 60 * 60 * 1000,
+ ).toISOString();
+ perIntegrationLedger.nightscout = {
+ state: "error_transient",
+ // The hourly cron is still running and a sibling leg can still stamp a
+ // success, so neither of these tells the user anything.
+ lastSuccessAt: new Date(Date.now() - 20 * 60_000).toISOString(),
+ lastAttemptAt: new Date(Date.now() - 4 * 60_000).toISOString(),
+ failingSince,
+ lastError: "Nightscout sync HTTP 502",
+ };
+ userFind.mockResolvedValue({ nightscoutUrlEncrypted: "enc" });
+
+ const nightscout = (await fetchEntries()).find(
+ (entry) => entry.integration === "nightscout",
+ )!;
+ expect(nightscout.syncHealth).toEqual({
+ verdict: "failing",
+ since: failingSince,
+ });
+ });
+});
diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts
index 9611e5c57..fc6b657fc 100644
--- a/src/app/api/integrations/status/route.ts
+++ b/src/app/api/integrations/status/route.ts
@@ -22,6 +22,7 @@ import {
getIntegrationStatus,
getPersistentFailureThreshold,
type IntegrationKey,
+ type IntegrationStatusSnapshot,
} from "@/lib/integrations/status";
import {
getSourceMetricFreshness,
@@ -155,6 +156,25 @@ export const GET = apiHandler(async () => {
const now = Date.now();
const whoopConnected = !!whoopConn?.whoopUserId;
+ /**
+ * The ledger fields the envelope publishes, named one by one.
+ *
+ * `failingSince` is deliberately not among them. It is the input to
+ * `syncHealth.since`, which every consumer already reads, and putting both on
+ * the wire would be two names for one fact — the second of which nothing
+ * renders.
+ */
+ function publish(snapshot: IntegrationStatusSnapshot) {
+ return {
+ integration: snapshot.integration,
+ state: snapshot.state,
+ lastSuccessAt: snapshot.lastSuccessAt,
+ lastAttemptAt: snapshot.lastAttemptAt,
+ lastError: snapshot.lastError,
+ consecutiveFailuresByKind: snapshot.consecutiveFailuresByKind,
+ };
+ }
+
/**
* The sync-health verdict + the per-metric staleness flags, resolved from
* data already in hand — no additional query. Every entry carries it,
@@ -227,7 +247,7 @@ export const GET = apiHandler(async () => {
threshold: getPersistentFailureThreshold(),
integrations: [
{
- ...withingsStatus,
+ ...publish(withingsStatus),
configured: withingsConfigured,
connected: !!withingsConn,
connectedAt: withingsConn?.createdAt?.toISOString() ?? null,
@@ -251,7 +271,7 @@ export const GET = apiHandler(async () => {
}),
} satisfies IntegrationViewModel & WithingsExtras,
{
- ...whoopStatus,
+ ...publish(whoopStatus),
configured: whoopConfigured,
connected: whoopConnected,
connectedAt: whoopConnected
@@ -274,7 +294,7 @@ export const GET = apiHandler(async () => {
}),
} satisfies IntegrationViewModel & WhoopExtras,
{
- ...fitbitStatus,
+ ...publish(fitbitStatus),
configured: fitbitConfigured,
connected: fitbitConnected,
connectedAt: fitbitConn?.createdAt?.toISOString() ?? null,
@@ -291,7 +311,7 @@ export const GET = apiHandler(async () => {
}),
} satisfies IntegrationViewModel & FitbitExtras,
{
- ...googleHealthStatus,
+ ...publish(googleHealthStatus),
configured: googleHealthConfigured,
connected: googleHealthConnected,
connectedAt: googleHealthConn?.createdAt?.toISOString() ?? null,
@@ -314,7 +334,7 @@ export const GET = apiHandler(async () => {
}),
} satisfies IntegrationViewModel & GoogleHealthExtras,
{
- ...polarStatus,
+ ...publish(polarStatus),
// `connected` = a stored access token; `configured` mirrors it (the
// OAuth card has no separate "credentials saved but disconnected" view
// beyond `hasOwnCredentials`). The card greys out the connect button
@@ -331,7 +351,7 @@ export const GET = apiHandler(async () => {
}),
} satisfies IntegrationViewModel & OAuthProviderExtras,
{
- ...ouraStatus,
+ ...publish(ouraStatus),
connected: ouraConnected,
configured: ouraConnected,
available: ouraAvailable,
@@ -344,7 +364,7 @@ export const GET = apiHandler(async () => {
}),
} satisfies IntegrationViewModel & OAuthProviderExtras,
{
- ...stravaStatus,
+ ...publish(stravaStatus),
connected: stravaConnected,
configured: stravaConnected,
available: stravaAvailable,
@@ -359,7 +379,7 @@ export const GET = apiHandler(async () => {
}),
} satisfies IntegrationViewModel & OAuthProviderExtras,
{
- ...nightscoutStatus,
+ ...publish(nightscoutStatus),
// Nightscout is a URL the self-hoster pastes once; `configured` (a
// stored URL) is the connect marker, and a fully-public instance has
// no token. The card reads these off the envelope now instead of its
@@ -383,12 +403,6 @@ interface IntegrationViewModel {
lastSuccessAt: string | null;
lastAttemptAt: string | null;
lastError: string | null;
- /**
- * Start of the current unbroken failure streak, or null when the provider is
- * not failing. `syncHealth.since` is derived from it; the raw instant rides
- * along so a client can render the age without re-deriving the verdict.
- */
- failingSince: string | null;
/**
* The server-resolved liveness verdict. Present on EVERY entry, whether or
* not a card renders it — the unit of coverage is the envelope entry, not
diff --git a/src/app/api/nightscout/status/route.ts b/src/app/api/nightscout/status/route.ts
index 38096cd59..f2cda6aa0 100644
--- a/src/app/api/nightscout/status/route.ts
+++ b/src/app/api/nightscout/status/route.ts
@@ -3,6 +3,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { getIntegrationStatus } from "@/lib/integrations/status";
+import {
+ INTEGRATION_CADENCE,
+ resolveSyncVerdict,
+} from "@/lib/integrations/sync-verdict";
/**
* Nightscout connection status for the current user (v1.17.0).
@@ -45,5 +49,18 @@ export const GET = apiHandler(async () => {
lastSuccessAt: status.lastSuccessAt,
lastAttemptAt: status.lastAttemptAt,
lastError: status.lastError,
+ // `connected: true` here means "a connection exists", not "it is working"
+ // — a pipe dead for a fortnight still answers true. The verdict is the
+ // liveness truth, resolved by the same rule the Settings envelope uses so
+ // this route cannot drift into a second opinion.
+ syncHealth: resolveSyncVerdict({
+ connected: true,
+ configured: true,
+ state: status.state,
+ lastSuccessAt: status.lastSuccessAt,
+ lastAttemptAt: status.lastAttemptAt,
+ failingSinceAt: status.failingSince,
+ cadence: INTEGRATION_CADENCE["nightscout"],
+ }),
});
});
diff --git a/src/app/api/oura/status/route.ts b/src/app/api/oura/status/route.ts
index f3d4d17f5..ba3abf574 100644
--- a/src/app/api/oura/status/route.ts
+++ b/src/app/api/oura/status/route.ts
@@ -4,6 +4,10 @@ import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { getOuraClientCredentials } from "@/lib/oura/credentials";
import { getIntegrationStatus } from "@/lib/integrations/status";
+import {
+ INTEGRATION_CADENCE,
+ resolveSyncVerdict,
+} from "@/lib/integrations/sync-verdict";
/**
* v1.17.0 (F4) — Oura connection status for the current user.
@@ -53,5 +57,18 @@ export const GET = apiHandler(async () => {
lastSuccessAt: status.lastSuccessAt,
lastAttemptAt: status.lastAttemptAt,
lastError: status.lastError,
+ // `connected: true` here means "a connection exists", not "it is working"
+ // — a pipe dead for a fortnight still answers true. The verdict is the
+ // liveness truth, resolved by the same rule the Settings envelope uses so
+ // this route cannot drift into a second opinion.
+ syncHealth: resolveSyncVerdict({
+ connected: true,
+ configured: true,
+ state: status.state,
+ lastSuccessAt: status.lastSuccessAt,
+ lastAttemptAt: status.lastAttemptAt,
+ failingSinceAt: status.failingSince,
+ cadence: INTEGRATION_CADENCE["oura"],
+ }),
});
});
diff --git a/src/app/api/polar/status/route.ts b/src/app/api/polar/status/route.ts
index 87d43742b..92cd26d79 100644
--- a/src/app/api/polar/status/route.ts
+++ b/src/app/api/polar/status/route.ts
@@ -4,6 +4,10 @@ import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { getPolarClientCredentials } from "@/lib/polar/credentials";
import { getIntegrationStatus } from "@/lib/integrations/status";
+import {
+ INTEGRATION_CADENCE,
+ resolveSyncVerdict,
+} from "@/lib/integrations/sync-verdict";
/**
* v1.17.0 (F4) — Polar connection status for the current user.
@@ -54,5 +58,18 @@ export const GET = apiHandler(async () => {
lastSuccessAt: status.lastSuccessAt,
lastAttemptAt: status.lastAttemptAt,
lastError: status.lastError,
+ // `connected: true` here means "a connection exists", not "it is working"
+ // — a pipe dead for a fortnight still answers true. The verdict is the
+ // liveness truth, resolved by the same rule the Settings envelope uses so
+ // this route cannot drift into a second opinion.
+ syncHealth: resolveSyncVerdict({
+ connected: true,
+ configured: true,
+ state: status.state,
+ lastSuccessAt: status.lastSuccessAt,
+ lastAttemptAt: status.lastAttemptAt,
+ failingSinceAt: status.failingSince,
+ cadence: INTEGRATION_CADENCE["polar"],
+ }),
});
});
diff --git a/src/app/api/strava/status/route.ts b/src/app/api/strava/status/route.ts
index 64df49fe4..c80c14a71 100644
--- a/src/app/api/strava/status/route.ts
+++ b/src/app/api/strava/status/route.ts
@@ -4,6 +4,10 @@ import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { getStravaClientCredentials } from "@/lib/strava/credentials";
import { getIntegrationStatus } from "@/lib/integrations/status";
+import {
+ INTEGRATION_CADENCE,
+ resolveSyncVerdict,
+} from "@/lib/integrations/sync-verdict";
/**
* v1.28.x — Strava connection status for the current user.
@@ -53,5 +57,18 @@ export const GET = apiHandler(async () => {
lastSuccessAt: status.lastSuccessAt,
lastAttemptAt: status.lastAttemptAt,
lastError: status.lastError,
+ // `connected: true` here means "a connection exists", not "it is working"
+ // — a pipe dead for a fortnight still answers true. The verdict is the
+ // liveness truth, resolved by the same rule the Settings envelope uses so
+ // this route cannot drift into a second opinion.
+ syncHealth: resolveSyncVerdict({
+ connected: true,
+ configured: true,
+ state: status.state,
+ lastSuccessAt: status.lastSuccessAt,
+ lastAttemptAt: status.lastAttemptAt,
+ failingSinceAt: status.failingSince,
+ cadence: INTEGRATION_CADENCE["strava"],
+ }),
});
});
diff --git a/src/components/settings/__tests__/integration-status-pill.test.tsx b/src/components/settings/__tests__/integration-status-pill.test.tsx
index 390bee332..466834869 100644
--- a/src/components/settings/__tests__/integration-status-pill.test.tsx
+++ b/src/components/settings/__tests__/integration-status-pill.test.tsx
@@ -196,3 +196,37 @@ describe("IntegrationStatusPill", () => {
expect(html).toContain('data-testid="apple-health-status"');
});
});
+
+describe("IntegrationStatusPill — a failing provider states its age", () => {
+ it("renders the failing state with how long it has been failing", () => {
+ const seventeenDaysAgo = new Date(Date.now() - 17 * 24 * 60 * 60 * 1000);
+ const html = render(
+ ,
+ );
+ // The label must not open with a claim of connection: the pipe has
+ // delivered nothing for a fortnight.
+ expect(html).not.toContain("Connected");
+ expect(html).toContain("Sync failing");
+ // And the age has to be on the pill. Without it, hour one and day
+ // seventeen render identically, which is how a dead sync stayed quiet.
+ expect(html).toMatch(/17\s*d\s+ago/);
+ expect(html).toContain('data-state="warning"');
+ });
+
+ it("keeps the German label free of a connection claim too", () => {
+ const html = render(
+ ,
+ "de",
+ );
+ expect(html).not.toContain("Verbunden");
+ expect(html).toContain("Sync fehlgeschlagen");
+ });
+});
diff --git a/src/lib/integrations/__tests__/status.test.ts b/src/lib/integrations/__tests__/status.test.ts
index e8a95ac1b..9be47a336 100644
--- a/src/lib/integrations/__tests__/status.test.ts
+++ b/src/lib/integrations/__tests__/status.test.ts
@@ -899,3 +899,212 @@ describe("sync-failure-recorded marker (§5)", () => {
expect(isSyncFailureRecorded(frozen)).toBe(false);
});
});
+
+describe("transient-streak escalation", () => {
+ const DAY_MS = 24 * 60 * 60 * 1000;
+
+ /** Seed the row the escalation reads: a streak of `transient` of a given age. */
+ function seedStreak(opts: { transient: number; ageMs: number }) {
+ vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
+ consecutiveFailuresByKind: {
+ transient: opts.transient,
+ reauth_required: 0,
+ persistent: 0,
+ },
+ persistentFailureStartedAt: null,
+ failingSinceAt: new Date(Date.now() - opts.ageMs),
+ alertedAt: null,
+ } as never);
+ vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
+ alertedAt: new Date(),
+ } as never);
+ vi.mocked(prisma.user.findMany).mockResolvedValue([] as never);
+ }
+
+ function upsertUpdate() {
+ return vi.mocked(prisma.integrationStatus.upsert).mock.calls[0]![0]!
+ .update as Record;
+ }
+
+ it("records a transient failure as persistent once the streak outlives the window", async () => {
+ seedStreak({ transient: 3, ageMs: DAY_MS + 60_000 });
+
+ await recordSyncFailure({
+ userId: "u1",
+ integration: "withings",
+ kind: "transient",
+ message: "Withings measure error: 503 - upstream",
+ });
+
+ // The transient bucket is frozen where the window closed; the persistent
+ // bucket — the only one the park ladder reads — starts counting.
+ expect(upsertUpdate().consecutiveFailuresByKind).toEqual({
+ transient: 3,
+ reauth_required: 0,
+ persistent: 1,
+ });
+ expect(upsertUpdate().persistentFailureStartedAt).toBeInstanceOf(Date);
+ expect(auditLog).toHaveBeenCalledWith(
+ "integrations.sync.failed",
+ expect.objectContaining({
+ details: expect.objectContaining({
+ kind: "persistent",
+ escalatedFrom: "transient",
+ }),
+ }),
+ );
+ });
+
+ it("leaves a streak inside the window alone", async () => {
+ seedStreak({ transient: 9, ageMs: DAY_MS - 60_000 });
+
+ await recordSyncFailure({
+ userId: "u1",
+ integration: "withings",
+ kind: "transient",
+ message: "Withings measure error: 503 - upstream",
+ });
+
+ expect(upsertUpdate().consecutiveFailuresByKind).toEqual({
+ transient: 10,
+ reauth_required: 0,
+ persistent: 0,
+ });
+ expect(upsertUpdate().persistentFailureStartedAt).toBeNull();
+ expect(auditLog).toHaveBeenCalledWith(
+ "integrations.sync.failed",
+ expect.objectContaining({
+ details: expect.objectContaining({
+ kind: "transient",
+ escalatedFrom: null,
+ }),
+ }),
+ );
+ });
+
+ it("needs the consecutive-failure threshold as well as the clock", async () => {
+ // A provider polled once a day: old streak, but only two attempts in it.
+ seedStreak({ transient: 1, ageMs: 5 * DAY_MS });
+
+ await recordSyncFailure({
+ userId: "u1",
+ integration: "nightscout",
+ kind: "transient",
+ message: "Nightscout sync HTTP 502",
+ });
+
+ expect(upsertUpdate().consecutiveFailuresByKind).toEqual({
+ transient: 2,
+ reauth_required: 0,
+ persistent: 0,
+ });
+ expect(upsertUpdate().persistentFailureStartedAt).toBeNull();
+ });
+
+ it("dates a fresh streak from this failure and never re-dates a running one", async () => {
+ const anchor = new Date(Date.now() - 3 * DAY_MS);
+ vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
+ consecutiveFailuresByKind: {
+ transient: 1,
+ reauth_required: 0,
+ persistent: 0,
+ },
+ persistentFailureStartedAt: null,
+ failingSinceAt: anchor,
+ alertedAt: new Date(),
+ } as never);
+ vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
+ alertedAt: new Date(),
+ } as never);
+
+ await recordSyncFailure({
+ userId: "u1",
+ integration: "withings",
+ kind: "transient",
+ message: "Withings measure error: 503 - upstream",
+ });
+
+ expect(upsertUpdate().failingSinceAt).toEqual(anchor);
+ });
+});
+
+describe("recordSyncSuccess — leg scoping", () => {
+ it("leaves a sibling leg's error standing and only records the attempt", async () => {
+ vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
+ state: "error_transient",
+ failingLeg: "sleep",
+ } as never);
+ vi.mocked(prisma.integrationStatus.update).mockResolvedValueOnce(
+ {} as never,
+ );
+
+ await recordSyncSuccess("u1", "withings", { leg: "ecg" });
+
+ expect(prisma.integrationStatus.upsert).not.toHaveBeenCalled();
+ const args = vi.mocked(prisma.integrationStatus.update).mock.calls[0]![0]!;
+ // `lastSuccessAt` deliberately absent: it is what the card's
+ // "connected · X ago" reads, and the sleep pipe has delivered nothing.
+ expect(args.data).toEqual({ lastAttemptAt: expect.any(Date) });
+ });
+
+ it("clears the row whole when the failing leg is the one that succeeded", async () => {
+ vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
+ state: "error_transient",
+ failingLeg: "sleep",
+ } as never);
+ vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce(
+ {} as never,
+ );
+
+ await recordSyncSuccess("u1", "withings", { leg: "sleep" });
+
+ expect(prisma.integrationStatus.update).not.toHaveBeenCalled();
+ const args = vi.mocked(prisma.integrationStatus.upsert).mock.calls[0]![0]!;
+ expect(args.update).toMatchObject({
+ state: "connected",
+ lastError: null,
+ failingSinceAt: null,
+ failingLeg: null,
+ });
+ });
+
+ it("clears the row whole when the failure was never attributed to a leg", async () => {
+ vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
+ state: "error_transient",
+ failingLeg: null,
+ } as never);
+ vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce(
+ {} as never,
+ );
+
+ await recordSyncSuccess("u1", "oura", { leg: "vitals" });
+
+ expect(prisma.integrationStatus.upsert).toHaveBeenCalledOnce();
+ });
+
+ it("clears the row whole when the caller names no leg (a full pass)", async () => {
+ vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce(
+ {} as never,
+ );
+
+ await recordSyncSuccess("u1", "whoop");
+
+ // No probe read at all — an unnamed success is entitled to clear.
+ expect(prisma.integrationStatus.findUnique).not.toHaveBeenCalled();
+ expect(prisma.integrationStatus.upsert).toHaveBeenCalledOnce();
+ });
+
+ it("does not treat a healthy row's stale leg marker as a held error", async () => {
+ vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
+ state: "connected",
+ failingLeg: "sleep",
+ } as never);
+ vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce(
+ {} as never,
+ );
+
+ await recordSyncSuccess("u1", "withings", { leg: "ecg" });
+
+ expect(prisma.integrationStatus.upsert).toHaveBeenCalledOnce();
+ });
+});
diff --git a/src/lib/integrations/__tests__/sync-verdict.test.ts b/src/lib/integrations/__tests__/sync-verdict.test.ts
index c119ec25a..ef891110e 100644
--- a/src/lib/integrations/__tests__/sync-verdict.test.ts
+++ b/src/lib/integrations/__tests__/sync-verdict.test.ts
@@ -22,7 +22,8 @@ import {
const NOW = new Date("2026-07-25T12:00:00.000Z");
const nowMs = NOW.getTime();
const ago = (ms: number) => new Date(nowMs - ms).toISOString();
-const HOUR = 60 * 60 * 1000;
+const MINUTE = 60 * 1000;
+const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
describe("resolveSyncVerdict — the ladder", () => {
@@ -205,3 +206,67 @@ describe("resolveSyncVerdict — the ladder", () => {
}
});
});
+
+describe("resolveSyncVerdict — the error verdicts date the streak", () => {
+ it("dates a failing provider from the streak start, not the last retry", () => {
+ // The shape that hid a fortnight of silence: the hourly retry keeps
+ // `lastAttemptAt` fresh, and a multi-leg provider can keep advancing
+ // `lastSuccessAt` from a healthy leg while another one is dead.
+ const failingSinceAt = ago(17 * DAY);
+ expect(
+ resolveSyncVerdict({
+ connected: true,
+ state: "error_transient",
+ lastSuccessAt: ago(30 * MINUTE),
+ lastAttemptAt: ago(4 * MINUTE),
+ failingSinceAt,
+ cadence: POLLED_CADENCE,
+ now: NOW,
+ }),
+ ).toEqual({ verdict: "failing", since: failingSinceAt });
+ });
+
+ it("falls back to the last success when the ledger has no streak anchor", () => {
+ // Rows written before the anchor existed, and never re-failed since.
+ const lastSuccessAt = ago(3 * DAY);
+ expect(
+ resolveSyncVerdict({
+ connected: true,
+ state: "error_transient",
+ lastSuccessAt,
+ lastAttemptAt: ago(2 * HOUR),
+ failingSinceAt: null,
+ cadence: POLLED_CADENCE,
+ now: NOW,
+ }),
+ ).toEqual({ verdict: "failing", since: lastSuccessAt });
+ });
+
+ it("dates a parked provider from the streak start", () => {
+ const failingSinceAt = ago(6 * DAY);
+ expect(
+ resolveSyncVerdict({
+ connected: true,
+ state: "parked",
+ lastAttemptAt: ago(10 * MINUTE),
+ failingSinceAt,
+ cadence: POLLED_CADENCE,
+ now: NOW,
+ }),
+ ).toEqual({ verdict: "parked", since: failingSinceAt });
+ });
+
+ it("dates a reauth-required provider from the streak start", () => {
+ const failingSinceAt = ago(9 * DAY);
+ expect(
+ resolveSyncVerdict({
+ connected: true,
+ state: "error_reauth",
+ lastAttemptAt: ago(10 * MINUTE),
+ failingSinceAt,
+ cadence: POLLED_CADENCE,
+ now: NOW,
+ }),
+ ).toEqual({ verdict: "reauth_required", since: failingSinceAt });
+ });
+});
From e0ae866f320562c9b50eb0b9bf414f3c96a658a6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 01:14:20 +0200
Subject: [PATCH 08/52] test(whoop): assert the per-resource failure carries
its leg
---
src/lib/jobs/__tests__/whoop-resource-sync.test.ts | 3 +++
1 file changed, 3 insertions(+)
diff --git a/src/lib/jobs/__tests__/whoop-resource-sync.test.ts b/src/lib/jobs/__tests__/whoop-resource-sync.test.ts
index 295ae98e6..6359d2ecb 100644
--- a/src/lib/jobs/__tests__/whoop-resource-sync.test.ts
+++ b/src/lib/jobs/__tests__/whoop-resource-sync.test.ts
@@ -147,6 +147,9 @@ describe("runWhoopResourceSync — WH-4 write-path ledger honesty", () => {
expect(recordWhoopSyncFailureMock).toHaveBeenCalledWith(
"u1",
expect.objectContaining({ message: "write failed" }),
+ // Attributed to the leg it came from, so the recovery leg's success at
+ // :05 cannot clear it.
+ "workout",
);
});
From 054eb51e96e46d2b14cff4515a43415b3b62e112 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 14:19:26 +0200
Subject: [PATCH 09/52] chore(db): number the failure-streak migration behind
the ones already released
---
.../migration.sql | 0
1 file changed, 0 insertions(+), 0 deletions(-)
rename prisma/migrations/{0275_integration_failure_streak_and_leg => 0277_integration_failure_streak_and_leg}/migration.sql (100%)
diff --git a/prisma/migrations/0275_integration_failure_streak_and_leg/migration.sql b/prisma/migrations/0277_integration_failure_streak_and_leg/migration.sql
similarity index 100%
rename from prisma/migrations/0275_integration_failure_streak_and_leg/migration.sql
rename to prisma/migrations/0277_integration_failure_streak_and_leg/migration.sql
From a8d798442ff968bfd740b1b05f1be491f296b28b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 07:20:53 +0200
Subject: [PATCH 10/52] fix(environment): name the enum type the database
actually has
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Migration 0217 created the type as `environment_location_source`; the
datamodel declared `EnvironmentLocationSource` with no `@@map`, so the client
compiled every write against `public."EnvironmentLocationSource"`. Postgres
answered 42704, the upsert rolled the transaction back, and the module sat at
"0 days recorded" from the day it shipped — one enum out of forty-nine, and the
only one without a map.
No migration follows. `prisma migrate diff` against a database built from the
committed migrations reports the enum rename and the `environment_contexts.source`
column rebuild before the map and nothing after it: the database was never the
side that drifted, and renaming a live type to match a datamodel typo would be
the wrong direction.
Two guards, because the two questions are different. The unit guard replays
CREATE TYPE / ALTER TYPE RENAME / DROP TYPE across the migration set and holds
every enum to a type that survives it. The integration guard compares all three
name classes Prisma emits — enum type, table, column — against a real migrated
Postgres, which is the only place a name mismatch is observable at all: it
survives generation, typecheck, and every mocked test untouched.
Refs #646
---
prisma/schema.prisma | 10 +
src/__tests__/helpers/prisma-schema-names.ts | 121 ++++++++++
.../prisma-enum-mapping-guard.test.ts | 145 ++++++++++++
.../prisma-schema-name-drift.test.ts | 222 ++++++++++++++++++
4 files changed, 498 insertions(+)
create mode 100644 src/__tests__/helpers/prisma-schema-names.ts
create mode 100644 src/__tests__/prisma-enum-mapping-guard.test.ts
create mode 100644 tests/integration/prisma-schema-name-drift.test.ts
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index b54bdac41..1139d4d90 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -919,6 +919,16 @@ enum EnvironmentLocationSource {
HOME
TRAVEL
DEVICE
+
+ /// Migration 0217 created the physical type as `environment_location_source`,
+ /// like every other enum in this schema. This declaration carried no `@@map`,
+ /// so the client compiled every write against `public."EnvironmentLocationSource"`
+ /// — a type no database has ever had — and each insert into `environment_contexts`
+ /// died on `42704` before a row could commit. The map names the type that
+ /// exists; no migration follows, because the database was never the side that
+ /// drifted. `prisma-enum-mapping-guard.test.ts` now holds the whole schema to
+ /// this rule.
+ @@map("environment_location_source")
}
/// A per-day environmental observation (daily weather / daylight) for the
diff --git a/src/__tests__/helpers/prisma-schema-names.ts b/src/__tests__/helpers/prisma-schema-names.ts
new file mode 100644
index 000000000..310ecaaf3
--- /dev/null
+++ b/src/__tests__/helpers/prisma-schema-names.ts
@@ -0,0 +1,121 @@
+/**
+ * A minimal reader for the physical names `prisma/schema.prisma` declares.
+ *
+ * Prisma emits three kinds of identifier straight into the SQL it sends: the
+ * enum type name, the table name, and the column name. Each one is the `@@map`
+ * / `@map` value when present and the declared name verbatim when absent. If
+ * any of the three names something the database does not have, the statement
+ * dies at the driver with `42704` / `42P01` / `42703` — and nothing earlier in
+ * the pipeline can see it, because generation, typecheck, and every mocked
+ * unit test read the logical name only.
+ *
+ * Consumers: `prisma-enum-mapping-guard.test.ts` (the convention, no database)
+ * and `tests/integration/prisma-schema-name-drift.test.ts` (the physical
+ * comparison against a migrated Postgres).
+ */
+import { readFileSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+export const SCHEMA_PATH = resolve(
+ dirname(fileURLToPath(import.meta.url)),
+ "../../../prisma/schema.prisma",
+);
+
+export interface DeclaredEnum {
+ /** The enum name in the datamodel. */
+ name: string;
+ /** The `@@map` value, or null when the declaration carries none. */
+ mappedName: string | null;
+ /** The name Prisma puts in the SQL — `@@map` when present, else `name`. */
+ physicalName: string;
+}
+
+export interface DeclaredField {
+ name: string;
+ /** The `@map` value, or null when the field carries none. */
+ mappedName: string | null;
+ /** The column name Prisma puts in the SQL. */
+ physicalName: string;
+}
+
+export interface DeclaredModel {
+ name: string;
+ mappedName: string | null;
+ /** The table name Prisma puts in the SQL. */
+ physicalName: string;
+ /** Scalar + enum fields only — relation fields carry no column. */
+ fields: DeclaredField[];
+}
+
+export interface DeclaredSchema {
+ enums: DeclaredEnum[];
+ models: DeclaredModel[];
+}
+
+const BLOCK_MAP = /^\s*@@map\("([^"]+)"\)/m;
+const FIELD_MAP = /@map\("([^"]+)"\)/;
+/** `name Type[]? @attr…` — the two leading tokens are what matters. */
+const FIELD_LINE = /^\s*(\w+)\s+(\w+)(?:\[\])?\??\s*(.*)$/;
+
+/** Drop `//` and `///` lines so prose can never read as a declaration. */
+function stripComments(block: string): string {
+ return block
+ .split("\n")
+ .filter((line) => !/^\s*\/\//.test(line))
+ .join("\n");
+}
+
+/**
+ * Read the datamodel. Deliberately regex-shaped rather than a real parser: the
+ * guard has to run inside `pnpm test` with no database and no Prisma engine,
+ * and the three declarations it reads (`enum`, `model`, field lines) are the
+ * most stable syntax in the file. Both consumers assert a floor on how much
+ * they found, so a parser that silently stops matching fails loudly instead of
+ * passing vacuously.
+ */
+export function readDeclaredSchema(
+ schemaPath: string = SCHEMA_PATH,
+): DeclaredSchema {
+ const source = readFileSync(schemaPath, "utf8");
+ const enums: DeclaredEnum[] = [];
+ const rawModels: Array<{ name: string; body: string }> = [];
+
+ const block = /^(model|enum)\s+(\w+)\s*\{([\s\S]*?)^\}/gm;
+ for (let m = block.exec(source); m !== null; m = block.exec(source)) {
+ const [, kind, name, body] = m;
+ const clean = stripComments(body);
+ const mappedName = BLOCK_MAP.exec(clean)?.[1] ?? null;
+ if (kind === "enum") {
+ enums.push({ name, mappedName, physicalName: mappedName ?? name });
+ } else {
+ rawModels.push({ name, body: clean });
+ }
+ }
+
+ const modelNames = new Set(rawModels.map((m) => m.name));
+
+ const models = rawModels.map(({ name, body }) => {
+ const mappedName = BLOCK_MAP.exec(body)?.[1] ?? null;
+ const fields: DeclaredField[] = [];
+ for (const line of body.split("\n")) {
+ if (/^\s*@@/.test(line)) continue;
+ const match = FIELD_LINE.exec(line);
+ if (!match) continue;
+ const [, fieldName, baseType, rest] = match;
+ // A field whose type is another model is a relation, not a column. The
+ // back-reference side carries no `@relation` attribute at all, so the
+ // type is what decides it — keying on `@relation` would miss half.
+ if (modelNames.has(baseType)) continue;
+ const fieldMapped = FIELD_MAP.exec(rest)?.[1] ?? null;
+ fields.push({
+ name: fieldName,
+ mappedName: fieldMapped,
+ physicalName: fieldMapped ?? fieldName,
+ });
+ }
+ return { name, mappedName, physicalName: mappedName ?? name, fields };
+ });
+
+ return { enums, models };
+}
diff --git a/src/__tests__/prisma-enum-mapping-guard.test.ts b/src/__tests__/prisma-enum-mapping-guard.test.ts
new file mode 100644
index 000000000..94a866bac
--- /dev/null
+++ b/src/__tests__/prisma-enum-mapping-guard.test.ts
@@ -0,0 +1,145 @@
+/**
+ * Structural guard — a Prisma enum cannot name a type the migrations never
+ * created.
+ *
+ * `EnvironmentLocationSource` was declared without an `@@map` while migration
+ * 0217 created the type as `environment_location_source`. Prisma writes the
+ * enum's physical name straight into the SQL, so every insert into
+ * `environment_contexts` asked Postgres for `public."EnvironmentLocationSource"`
+ * and got `42704`. The module read "0 days recorded" from the day it shipped.
+ *
+ * Nothing upstream could see it: the type is right in the generated client, the
+ * datamodel is internally consistent, and every mocked unit test only ever
+ * exercises the logical name. One enum out of forty-nine drifted, and it took a
+ * self-hoster reading Postgres error codes to find it.
+ *
+ * So the physical name is checked against the only other place that states it —
+ * the migration SQL. `CREATE TYPE`, `ALTER TYPE … RENAME TO`, and `DROP TYPE`
+ * are replayed in migration order, and every declared enum has to land on a
+ * type that survives to the end. The `@@map`-present rule is asserted
+ * separately, because an unmapped PascalCase enum is the specific mistake that
+ * happened and the message should say so rather than "type not found".
+ *
+ * Limits, stated plainly. This reads SQL text, not a database: it proves the
+ * migrations create the type, not that any particular deployment's database
+ * matches its migration ledger. It covers enum types only. The comprehensive
+ * check — every enum, table, and column name compared against a real migrated
+ * Postgres — is `tests/integration/prisma-schema-name-drift.test.ts`, which
+ * this guard exists to front-run at unit speed.
+ */
+import { readdirSync, readFileSync } from "node:fs";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { describe, it, expect } from "vitest";
+
+import { readDeclaredSchema } from "./helpers/prisma-schema-names";
+
+const MIGRATIONS_DIR = resolve(
+ dirname(fileURLToPath(import.meta.url)),
+ "../../prisma/migrations",
+);
+
+const CREATE_TYPE = /CREATE\s+TYPE\s+(?:IF\s+NOT\s+EXISTS\s+)?"?([\w.]+)"?/gi;
+const RENAME_TYPE =
+ /ALTER\s+TYPE\s+"?([\w.]+)"?\s+RENAME\s+TO\s+"?([\w.]+)"?/gi;
+const DROP_TYPE = /DROP\s+TYPE\s+(?:IF\s+EXISTS\s+)?"?([\w.]+)"?/gi;
+
+/** `public.foo` and `foo` are the same type; the guard compares bare names. */
+function bare(name: string): string {
+ return name.includes(".") ? name.slice(name.lastIndexOf(".") + 1) : name;
+}
+
+/**
+ * Statements that are commented out (the rollback recipes several migrations
+ * carry in a `--` block) must not count as executed SQL.
+ */
+function executableSql(sql: string): string {
+ return sql
+ .split("\n")
+ .filter((line) => !/^\s*--/.test(line))
+ .join("\n");
+}
+
+/** Replay every migration in order and return the types left standing. */
+function liveEnumTypes(): Set {
+ const live = new Set();
+ const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
+ .filter((entry) => entry.isDirectory())
+ .map((entry) => entry.name)
+ .sort();
+
+ for (const dir of dirs) {
+ let sql: string;
+ try {
+ sql = readFileSync(join(MIGRATIONS_DIR, dir, "migration.sql"), "utf8");
+ } catch {
+ continue;
+ }
+ const statements = executableSql(sql);
+
+ // Order within one file matters: a migration that drops and recreates a
+ // type under a new name has to be replayed as written, so each statement
+ // kind is applied at its own offset rather than kind-by-kind.
+ const events: Array<{ at: number; apply: () => void }> = [];
+ for (const m of statements.matchAll(CREATE_TYPE)) {
+ const name = bare(m[1]);
+ events.push({ at: m.index, apply: () => void live.add(name) });
+ }
+ for (const m of statements.matchAll(RENAME_TYPE)) {
+ const from = bare(m[1]);
+ const to = bare(m[2]);
+ events.push({
+ at: m.index,
+ apply: () => {
+ live.delete(from);
+ live.add(to);
+ },
+ });
+ }
+ for (const m of statements.matchAll(DROP_TYPE)) {
+ const name = bare(m[1]);
+ events.push({ at: m.index, apply: () => void live.delete(name) });
+ }
+ for (const event of events.sort((a, b) => a.at - b.at)) event.apply();
+ }
+ return live;
+}
+
+describe("prisma enums — the declared type is the migrated type", () => {
+ const { enums } = readDeclaredSchema();
+
+ it("reads the schema (a parser that stopped matching cannot pass vacuously)", () => {
+ expect(enums.length).toBeGreaterThan(40);
+ });
+
+ it("declares an @@map on every enum", () => {
+ const unmapped = enums
+ .filter((declared) => declared.mappedName === null)
+ .map((declared) => declared.name);
+ expect(
+ unmapped,
+ unmapped.length > 0
+ ? `Enum(s) without @@map — Prisma will ask Postgres for the PascalCase ` +
+ `type name, which no migration creates: ${unmapped.join(", ")}`
+ : undefined,
+ ).toEqual([]);
+ });
+
+ it("finds the migrated types (a broken replay cannot pass vacuously)", () => {
+ expect(liveEnumTypes().size).toBeGreaterThan(40);
+ });
+
+ it("points every enum at a type the migrations leave standing", () => {
+ const live = liveEnumTypes();
+ const missing = enums
+ .filter((declared) => !live.has(declared.physicalName))
+ .map((declared) => `${declared.name} → "${declared.physicalName}"`);
+ expect(
+ missing,
+ missing.length > 0
+ ? `Enum(s) naming a type no migration creates:\n ${missing.join("\n ")}`
+ : undefined,
+ ).toEqual([]);
+ });
+});
diff --git a/tests/integration/prisma-schema-name-drift.test.ts b/tests/integration/prisma-schema-name-drift.test.ts
new file mode 100644
index 000000000..9865cb93a
--- /dev/null
+++ b/tests/integration/prisma-schema-name-drift.test.ts
@@ -0,0 +1,222 @@
+/**
+ * Every name `prisma/schema.prisma` will put into SQL exists in the migrated
+ * database.
+ *
+ * `EnvironmentLocationSource` shipped with no `@@map` while migration 0217 had
+ * created the type as `environment_location_source`. Prisma writes the enum's
+ * physical name into the statement, so every insert into `environment_contexts`
+ * asked for `public."EnvironmentLocationSource"` and got `42704`. The nightly
+ * job failed on every run from the day the module shipped and the screen read
+ * "0 days recorded" the whole time.
+ *
+ * The reason it survived that long is the reason this file is an integration
+ * test and not a unit test: generation succeeds, typecheck succeeds, and a
+ * mocked Prisma client never emits a type name at all. Only a real migrated
+ * Postgres can answer whether the datamodel's physical names exist.
+ *
+ * So it compares all three name classes Prisma emits — enum type, table,
+ * column — against `pg_type` and `information_schema`. One direction only:
+ * every DECLARED name must exist physically. The reverse is deliberately not
+ * asserted, because a column dropped from the datamodel but left in the
+ * database breaks nothing, while a name the client sends and the database
+ * lacks breaks every statement that touches it.
+ *
+ * Also verified here: the environment write the drift actually blocked, once
+ * per enum value, against the live type.
+ */
+import { beforeEach, describe, expect, it } from "vitest";
+
+import {
+ readDeclaredSchema,
+ type DeclaredModel,
+} from "@/__tests__/helpers/prisma-schema-names";
+
+import { getPrismaClient, truncateAllTables } from "./setup";
+
+const prisma = getPrismaClient();
+
+async function physicalEnumTypes(): Promise> {
+ const rows = await prisma.$queryRaw>`
+ SELECT t.typname
+ FROM pg_type t
+ JOIN pg_namespace n ON n.oid = t.typnamespace
+ WHERE t.typtype = 'e' AND n.nspname = 'public'
+ `;
+ return new Set(rows.map((row) => row.typname));
+}
+
+async function physicalColumns(): Promise
+ )}
{/* Attribution (CC BY 4.0, required) */}
diff --git a/src/components/settings/import-panel/__tests__/import-result-guard.test.ts b/src/components/settings/import-panel/__tests__/import-result-guard.test.ts
index ee5f100df..defcda458 100644
--- a/src/components/settings/import-panel/__tests__/import-result-guard.test.ts
+++ b/src/components/settings/import-panel/__tests__/import-result-guard.test.ts
@@ -7,6 +7,13 @@
* exactly one key of the outcome table, and no card reaches for them
* directly. A future card that hand-rolls a green tick fails here rather than
* shipping and lying to a self-hoster about their sensor history.
+ *
+ * #650 widened it. The medication intake import is a third import surface, in
+ * another directory, and it was not covered when the rule landed — it kept its
+ * own success/error tone and reported a run that wrote one row out of
+ * twenty-eight as a success. "Every import surface" now means every one of
+ * them, listed by path, so a fourth surface has to be added here deliberately
+ * rather than inheriting the old behaviour by being somewhere else.
*/
import { describe, it, expect } from "vitest";
import { readFileSync, readdirSync } from "node:fs";
@@ -14,6 +21,34 @@ import { join } from "node:path";
const PANEL_DIR = join(__dirname, "..");
const PRESENTATION_FILE = "import-result-view.tsx";
+const REPO_ROOT = join(__dirname, "..", "..", "..", "..", "..");
+
+/**
+ * Every surface that reports an import result, and the module each one must
+ * render its outcome through. Paths from the repository root.
+ */
+const IMPORT_SURFACES: ReadonlyArray<{ path: string; through: RegExp }> = [
+ {
+ path: "src/components/settings/import-panel/csv-import-card.tsx",
+ through: /CsvImportResultView|ImportOutcomeLine/,
+ },
+ {
+ path: "src/components/settings/import-panel/json-import-card.tsx",
+ through: /CsvImportResultView|ImportOutcomeLine/,
+ },
+ {
+ path: "src/components/settings/import-panel/apple-health-import-card.tsx",
+ through: /CsvImportResultView|ImportOutcomeLine/,
+ },
+ {
+ path: "src/components/medications/intake-import-result.tsx",
+ through: /ImportOutcomeLine/,
+ },
+ {
+ path: "src/components/medications/intake-import-dialog.tsx",
+ through: /IntakeImportResultView/,
+ },
+];
/** The success affordance: the tick component and the success colour token. */
const SUCCESS_MARKERS = ["CheckCircle2", "text-success"] as const;
@@ -25,10 +60,16 @@ function panelSourceFiles(): string[] {
}
/** Code only — a comment naming the marker is prose, not an affordance. */
+function stripComments(source: string): string {
+ return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
+}
+
function readCode(name: string): string {
- return readFileSync(join(PANEL_DIR, name), "utf8")
- .replace(/\/\*[\s\S]*?\*\//g, "")
- .replace(/^\s*\/\/.*$/gm, "");
+ return stripComments(readFileSync(join(PANEL_DIR, name), "utf8"));
+}
+
+function readRepoCode(relativePath: string): string {
+ return stripComments(readFileSync(join(REPO_ROOT, relativePath), "utf8"));
}
describe("import panel — success affordance is owned by the outcome table", () => {
@@ -63,13 +104,27 @@ describe("import panel — success affordance is owned by the outcome table", ()
}
});
- it("routes every card's result line through the shared outcome line", () => {
- for (const name of [
- "csv-import-card.tsx",
- "json-import-card.tsx",
- "apple-health-import-card.tsx",
- ]) {
- expect(readCode(name)).toMatch(/CsvImportResultView|ImportOutcomeLine/);
+ it("routes every import surface's result line through the shared outcome line", () => {
+ for (const surface of IMPORT_SURFACES) {
+ expect(
+ readRepoCode(surface.path),
+ `${surface.path} must render its outcome through the shared line`,
+ ).toMatch(surface.through);
}
});
+
+ it("keeps the success markers out of every import surface", () => {
+ const offenders = IMPORT_SURFACES.filter((surface) =>
+ SUCCESS_MARKERS.some((marker) =>
+ readRepoCode(surface.path).includes(marker),
+ ),
+ ).map((surface) => surface.path);
+ expect(
+ offenders,
+ offenders.length > 0
+ ? `Import surface(s) hand-rolling the success affordance instead of ` +
+ `classifying the counts: ${offenders.join(", ")}`
+ : undefined,
+ ).toEqual([]);
+ });
});
diff --git a/src/lib/jobs/__tests__/medication-intake-import.test.ts b/src/lib/jobs/__tests__/medication-intake-import.test.ts
index ce3b81c28..205ecd0ba 100644
--- a/src/lib/jobs/__tests__/medication-intake-import.test.ts
+++ b/src/lib/jobs/__tests__/medication-intake-import.test.ts
@@ -5,9 +5,11 @@ import {
MEDICATION_INTAKE_IMPORT_ROLLUP_CHUNK_SIZE,
_setMedicationIntakeImportPrismaForTests,
advanceMedicationImportProgress,
+ groupMedicationImportSkips,
medicationImportChunk,
processMedicationIntakeImportJob,
sanitiseMedicationImportFailure,
+ totalMedicationImportSkips,
type MedicationImportProgress,
} from "@/lib/jobs/medication-intake-import";
@@ -15,7 +17,7 @@ const INITIAL: MedicationImportProgress = {
processed: 0,
total: 1_000,
imported: 0,
- skippedDuplicates: 0,
+ skippedByReason: {},
touchedDays: [],
rollupProcessed: 0,
};
@@ -43,21 +45,21 @@ describe("medication intake import worker bounds", () => {
from: 0,
processed: 100,
imported: 98,
- skippedDuplicates: 2,
+ skippedByReason: { duplicate_in_file: 2 },
touchedDays: ["2026-07-20", "2026-07-21"],
});
const second = advanceMedicationImportProgress(first, {
from: 100,
processed: 100,
imported: 97,
- skippedDuplicates: 3,
+ 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,
- skippedDuplicates: 2,
+ skippedByReason: { duplicate_in_file: 2 },
touchedDays: ["2026-07-20"],
});
@@ -66,7 +68,7 @@ describe("medication intake import worker bounds", () => {
processed: 200,
total: 1_000,
imported: 195,
- skippedDuplicates: 5,
+ skippedByReason: { duplicate_in_file: 3, already_recorded: 2 },
touchedDays: ["2026-07-20", "2026-07-21", "2026-07-22"],
rollupProcessed: 0,
});
@@ -93,7 +95,7 @@ describe("medication intake import worker bounds", () => {
processed: 0,
total: 0,
imported: 0,
- skippedDuplicates: 0,
+ skippedByReason: {},
touchedDays,
};
let status = "running";
@@ -185,3 +187,62 @@ describe("medication intake import worker bounds", () => {
expect(auditCount).toBe(1);
});
});
+
+describe("medication import skip reporting (#650)", () => {
+ it("reports one entry per reason, count descending, and omits zero", () => {
+ expect(
+ groupMedicationImportSkips({
+ duplicate_in_file: 2,
+ already_recorded: 9,
+ }),
+ ).toEqual([
+ { reason: "already_recorded", count: 9 },
+ { reason: "duplicate_in_file", count: 2 },
+ ]);
+ expect(
+ groupMedicationImportSkips({
+ duplicate_in_file: 0,
+ already_recorded: 4,
+ }),
+ ).toEqual([{ reason: "already_recorded", count: 4 }]);
+ expect(groupMedicationImportSkips({})).toEqual([]);
+ });
+
+ it("totals every reason so the headline count matches the groups", () => {
+ const counts = { duplicate_in_file: 2, already_recorded: 9 };
+ expect(totalMedicationImportSkips(counts)).toBe(11);
+ expect(
+ groupMedicationImportSkips(counts).reduce(
+ (sum, group) => sum + group.count,
+ 0,
+ ),
+ ).toBe(totalMedicationImportSkips(counts));
+ expect(totalMedicationImportSkips({})).toBe(0);
+ });
+
+ it("keeps the two reasons apart while accumulating across chunks", () => {
+ const first = advanceMedicationImportProgress(INITIAL, {
+ from: 0,
+ 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({
+ duplicate_in_file: 10,
+ already_recorded: 5,
+ });
+ expect(groupMedicationImportSkips(second.skippedByReason)).toEqual([
+ { reason: "duplicate_in_file", count: 10 },
+ { reason: "already_recorded", count: 5 },
+ ]);
+ });
+});
diff --git a/src/lib/jobs/job-failures.ts b/src/lib/jobs/job-failures.ts
new file mode 100644
index 000000000..5141bbd1b
--- /dev/null
+++ b/src/lib/jobs/job-failures.ts
@@ -0,0 +1,153 @@
+/**
+ * What the queue already knows about its own broken jobs, read back out.
+ *
+ * The `environment-fetch` job threw on every single run for the whole life of
+ * the environmental-context module. It was handled correctly at the call site —
+ * `recordError()`, a `workerLog("error", …)`, a rethrow so pg-boss retries —
+ * and none of that reached a screen. `recordError()` increments one unnamed
+ * counter, so `/api/admin/status` could say "errors: 41" and not which queue.
+ * The worker log only exists where someone is tailing the container. The user
+ * saw "0 days recorded" and had no way to tell an empty module from a broken
+ * one.
+ *
+ * pg-boss has held the answer the whole time: a job that exhausts its retries
+ * lands in `pgboss.job` with `state = 'failed'`, its queue name, the thrown
+ * error in `output`, and the instant it gave up. Reading that back is a
+ * complete signal for every queue at once, and it needs nothing from the ~30
+ * `recordError()` call sites — which is the point, because a visibility fix
+ * that has to be remembered at each call site is the same failure mode one
+ * level up.
+ *
+ * Both readers fail soft to `null`. The `pgboss` schema is created by the
+ * worker, so a web-only deployment legitimately has no such table, and the
+ * admin status page must not 500 because the queue is absent. `null` means "no
+ * queue to ask", which is not the same fact as an empty array ("asked, nothing
+ * broken") — the callers keep the two apart.
+ */
+import { prisma } from "@/lib/db";
+import { redactSecrets } from "@/lib/logging/redact";
+
+/** How far back a failure still counts as news. */
+export const JOB_FAILURE_WINDOW_HOURS = 72;
+
+/** Queues named in one report — enough to see a pattern, bounded for the wire. */
+const MAX_QUEUES = 20;
+
+/** The error text is for a human reading a status page, not for a stack trace. */
+const MAX_ERROR_CHARS = 300;
+
+export interface FailingQueue {
+ /** The pg-boss queue name, e.g. `environment-fetch`. */
+ queue: string;
+ /** Jobs that exhausted their retries inside the window. */
+ failures: number;
+ /** ISO instant of the newest failure. */
+ lastFailedAt: string;
+ /** The newest failure's message, redacted and truncated. */
+ lastError: string;
+}
+
+interface FailingQueueRow {
+ name: string;
+ failures: number;
+ last_failed_at: Date | null;
+ last_error: string | null;
+}
+
+/**
+ * Turn whatever pg-boss stored in `output` into one line. A thrown `Error`
+ * serialises to `{ message, stack, … }`; a thrown non-Error can be any JSON at
+ * all, so the raw text is the fallback rather than an invented placeholder.
+ */
+function presentError(raw: string | null): string {
+ if (raw === null) return "";
+ const trimmed = raw.trim();
+ if (trimmed.length === 0) return "";
+ const redacted = redactSecrets(trimmed);
+ return redacted.length > MAX_ERROR_CHARS
+ ? `${redacted.slice(0, MAX_ERROR_CHARS)}…`
+ : redacted;
+}
+
+/**
+ * Every queue with at least one exhausted-retry failure in the window, newest
+ * first. `null` when the queue schema is not present at all.
+ */
+export async function readFailingQueues(
+ withinHours: number = JOB_FAILURE_WINDOW_HOURS,
+): Promise {
+ try {
+ const rows = await prisma.$queryRaw`
+ SELECT
+ name,
+ count(*)::int AS failures,
+ max(completed_on) AS last_failed_at,
+ (
+ array_agg(
+ COALESCE(output->>'message', output::text)
+ ORDER BY completed_on DESC NULLS LAST
+ )
+ )[1] AS last_error
+ FROM pgboss.job
+ WHERE state = 'failed'
+ AND completed_on > now() - make_interval(hours => ${withinHours}::int)
+ GROUP BY name
+ ORDER BY max(completed_on) DESC
+ LIMIT ${MAX_QUEUES}
+ `;
+ return rows
+ .filter((row) => row.last_failed_at !== null)
+ .map((row) => ({
+ queue: row.name,
+ failures: row.failures,
+ lastFailedAt: row.last_failed_at!.toISOString(),
+ lastError: presentError(row.last_error),
+ }));
+ } catch {
+ // No `pgboss` schema (web-only deployment) or no permission to read it.
+ // Absence of a queue is not a failing queue, and it is not zero either.
+ return null;
+ }
+}
+
+export interface QueueFailureForUser {
+ /** ISO instant the user's newest job on this queue gave up. */
+ lastFailedAt: string;
+ /** Failures for this user on this queue inside the window. */
+ failures: number;
+}
+
+/**
+ * The newest exhausted-retry failure of `queue` for one account, matched on the
+ * `userId` the job payload carries. Used by a user-facing surface to say "the
+ * last background run failed" instead of rendering a zero as if it were a
+ * measurement. Deliberately carries no error text: the message is written for
+ * an operator and can name internals, so a non-admin reader gets the fact and
+ * the time only.
+ */
+export async function readQueueFailureForUser(
+ queue: string,
+ userId: string,
+ withinHours: number = JOB_FAILURE_WINDOW_HOURS,
+): Promise {
+ try {
+ const rows = await prisma.$queryRaw<
+ Array<{ failures: number; last_failed_at: Date | null }>
+ >`
+ SELECT count(*)::int AS failures, max(completed_on) AS last_failed_at
+ FROM pgboss.job
+ WHERE name = ${queue}
+ AND state = 'failed'
+ AND data->>'userId' = ${userId}
+ AND completed_on > now() - make_interval(hours => ${withinHours}::int)
+ `;
+ const row = rows[0];
+ if (!row || row.last_failed_at === null || row.failures === 0) return null;
+ return {
+ lastFailedAt: row.last_failed_at.toISOString(),
+ failures: row.failures,
+ };
+ } catch {
+ return null;
+ }
+}
diff --git a/src/lib/jobs/medication-intake-import.ts b/src/lib/jobs/medication-intake-import.ts
index 80a055333..1cc758146 100644
--- a/src/lib/jobs/medication-intake-import.ts
+++ b/src/lib/jobs/medication-intake-import.ts
@@ -32,19 +32,78 @@ export interface MedicationImportPayload {
entries: MedicationImportEntry[];
}
+/**
+ * Why a submitted entry did not become a row. Machine-readable; the client
+ * turns each into a sentence.
+ *
+ * `duplicate_in_file` — two entries in the same submission resolve to the same
+ * instant, so only one can be written.
+ * `already_recorded` — the dose is already on the ledger for that instant, from
+ * an earlier import or from any other intake surface.
+ *
+ * They used to be one `skippedDuplicates` number, which is how a file whose
+ * every row collapsed onto one key could report "27 duplicates skipped" about
+ * 27 doses that had no duplicate anywhere. Naming the reason is what makes the
+ * count checkable against what the person actually submitted.
+ */
+export const MEDICATION_IMPORT_SKIP_REASONS = [
+ "duplicate_in_file",
+ "already_recorded",
+] as const;
+
+export type MedicationImportSkipReason =
+ (typeof MEDICATION_IMPORT_SKIP_REASONS)[number];
+
+/** Skip counts keyed by reason. A reason with no skips is absent, not zero. */
+export type MedicationImportSkipCounts = Partial<
+ Record
+>;
+
export interface MedicationImportProgress {
processed: number;
total: number;
imported: number;
- skippedDuplicates: number;
+ skippedByReason: MedicationImportSkipCounts;
touchedDays: string[];
rollupProcessed: number;
}
+export interface MedicationImportSkipGroup {
+ reason: MedicationImportSkipReason;
+ count: number;
+}
+
export interface MedicationImportResult {
imported: number;
- skippedDuplicates: number;
- skippedInvalid: number;
+ /** Total entries that did not become a row. */
+ skipped: number;
+ /**
+ * One entry per reason, count descending then reason — never the same
+ * sentence repeated per row. Empty when nothing was skipped.
+ */
+ skipReasons: MedicationImportSkipGroup[];
+}
+
+/** Collapse the running counts into the wire shape, in a stable order. */
+export function groupMedicationImportSkips(
+ counts: MedicationImportSkipCounts,
+): MedicationImportSkipGroup[] {
+ return MEDICATION_IMPORT_SKIP_REASONS.map((reason) => ({
+ reason,
+ count: counts[reason] ?? 0,
+ }))
+ .filter((group) => group.count > 0)
+ .sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason));
+}
+
+/** Total skipped across every reason. */
+export function totalMedicationImportSkips(
+ counts: MedicationImportSkipCounts,
+): number {
+ return MEDICATION_IMPORT_SKIP_REASONS.reduce(
+ (sum, reason) => sum + (counts[reason] ?? 0),
+ 0,
+ );
}
export interface MedicationIntakeImportQueuePayload {
@@ -55,7 +114,7 @@ export interface MedicationImportChunkResult {
from: number;
processed: number;
imported: number;
- skippedDuplicates: number;
+ skippedByReason: MedicationImportSkipCounts;
touchedDays: string[];
}
@@ -98,13 +157,20 @@ export function advanceMedicationImportProgress(
}
}
+ const skippedByReason: MedicationImportSkipCounts = {
+ ...previous.skippedByReason,
+ };
+ for (const reason of MEDICATION_IMPORT_SKIP_REASONS) {
+ const added = Math.max(0, Math.trunc(chunk.skippedByReason[reason] ?? 0));
+ if (added === 0) continue;
+ skippedByReason[reason] = (skippedByReason[reason] ?? 0) + added;
+ }
+
return {
processed,
total: previous.total,
imported: previous.imported + Math.max(0, Math.trunc(chunk.imported)),
- skippedDuplicates:
- previous.skippedDuplicates +
- Math.max(0, Math.trunc(chunk.skippedDuplicates)),
+ skippedByReason,
rollupProcessed: previous.rollupProcessed,
touchedDays,
};
@@ -146,6 +212,32 @@ function parsePayload(value: Prisma.JsonValue): MedicationImportPayload {
return { entries };
}
+/**
+ * Read the persisted skip counts. Strict, like every other field of the
+ * progress blob: an unknown reason key or a non-integer count means the row was
+ * not written by this code, and the job fails rather than reporting a number it
+ * cannot stand behind. Returns null on anything malformed.
+ */
+function parseSkipCounts(
+ value: Prisma.JsonValue | undefined,
+): MedicationImportSkipCounts | null {
+ if (value === undefined || value === null) return null;
+ if (typeof value !== "object" || Array.isArray(value)) return null;
+ const counts: MedicationImportSkipCounts = {};
+ for (const [key, raw] of Object.entries(value)) {
+ if (
+ !(MEDICATION_IMPORT_SKIP_REASONS as readonly string[]).includes(key) ||
+ typeof raw !== "number" ||
+ !Number.isInteger(raw) ||
+ raw < 0
+ ) {
+ return null;
+ }
+ counts[key as MedicationImportSkipReason] = raw;
+ }
+ return counts;
+}
+
function parseProgress(
value: Prisma.JsonValue,
total: number,
@@ -155,7 +247,7 @@ function parseProgress(
}
const processed = Number(value.processed);
const imported = Number(value.imported);
- const skippedDuplicates = Number(value.skippedDuplicates);
+ const skippedByReason = parseSkipCounts(value.skippedByReason);
const rawTouchedDays = value.touchedDays;
const touchedDays =
Array.isArray(rawTouchedDays) &&
@@ -170,8 +262,7 @@ function parseProgress(
processed > total ||
!Number.isInteger(imported) ||
imported < 0 ||
- !Number.isInteger(skippedDuplicates) ||
- skippedDuplicates < 0 ||
+ skippedByReason === null ||
touchedDays === null ||
!Number.isInteger(rollupProcessed) ||
rollupProcessed < 0 ||
@@ -183,7 +274,7 @@ function parseProgress(
processed,
total,
imported,
- skippedDuplicates,
+ skippedByReason,
touchedDays,
rollupProcessed,
};
@@ -289,8 +380,8 @@ async function processNextChunk(
}
const result: MedicationImportResult = {
imported: progress.imported,
- skippedDuplicates: progress.skippedDuplicates,
- skippedInvalid: 0,
+ skipped: totalMedicationImportSkips(progress.skippedByReason),
+ skipReasons: groupMedicationImportSkips(progress.skippedByReason),
};
const completedAt = new Date();
await tx.auditLog.create({
@@ -301,8 +392,8 @@ async function processNextChunk(
jobId: row.id,
medicationId: row.medicationId,
imported: result.imported,
- skippedDuplicates: result.skippedDuplicates,
- skippedInvalid: 0,
+ skipped: result.skipped,
+ skipReasons: result.skipReasons,
total: progress.total,
}),
},
@@ -365,11 +456,20 @@ async function processNextChunk(
const touchedDays = created.map((event) =>
dayKeyForScheduledFor(event.scheduledFor, row.user.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
+ // earlier import or any other intake surface. Reporting both as one
+ // "duplicates" number is what let a whole month of distinct doses read
+ // as duplicates of each other.
const nextProgress = advanceMedicationImportProgress(progress, {
from: progress.processed,
processed: entries.length,
imported: created.length,
- skippedDuplicates: entries.length - created.length,
+ skippedByReason: {
+ duplicate_in_file: entries.length - uniqueEntries.length,
+ already_recorded: uniqueEntries.length - created.length,
+ },
touchedDays,
});
const heartbeatAt = new Date();
diff --git a/src/lib/medications/__tests__/intake-import-poll.test.ts b/src/lib/medications/__tests__/intake-import-poll.test.ts
index 82f412486..4826c29ce 100644
--- a/src/lib/medications/__tests__/intake-import-poll.test.ts
+++ b/src/lib/medications/__tests__/intake-import-poll.test.ts
@@ -12,8 +12,8 @@ describe("waitForMedicationIntakeImport", () => {
status: "done",
result: {
imported: 3,
- skippedDuplicates: 1,
- skippedInvalid: 0,
+ skipped: 1,
+ skipReasons: [{ reason: "already_recorded", count: 1 }],
},
});
const wait = vi.fn().mockResolvedValue(undefined);
@@ -25,8 +25,8 @@ describe("waitForMedicationIntakeImport", () => {
}),
).resolves.toEqual({
imported: 3,
- skippedDuplicates: 1,
- skippedInvalid: 0,
+ skipped: 1,
+ skipReasons: [{ reason: "already_recorded", count: 1 }],
});
expect(readStatus).toHaveBeenCalledTimes(3);
expect(wait).toHaveBeenCalledTimes(2);
diff --git a/src/lib/medications/intake-import-payload.ts b/src/lib/medications/intake-import-payload.ts
new file mode 100644
index 000000000..27d085df5
--- /dev/null
+++ b/src/lib/medications/intake-import-payload.ts
@@ -0,0 +1,42 @@
+/**
+ * The one derivation from a submitted intake-import row to the queued entry.
+ *
+ * It lives here rather than inline in the route so the test that proves a
+ * reported export lands 28 rows can drive the real derivation instead of
+ * restating it. A test that mirrors the production formula proves the mirror.
+ */
+
+/** A validated row of the import body: a local date and a local wall time. */
+export interface MedicationImportRow {
+ datum: string;
+ uhrzeit: string;
+}
+
+export interface MedicationImportQueueEntry {
+ takenAt: string;
+ idempotencyKey: string;
+}
+
+/**
+ * An imported dose is identified by when it was taken, and by nothing else.
+ *
+ * The key used to be built from the row's optional `zaehler` field whenever it
+ * was present. That reads as a per-row counter, but nothing required it to be
+ * one, and an export that put the dose quantity there — 1 tablet, on every row
+ * — collapsed a month of distinct doses onto a single key. Deriving from the
+ * instant makes the key a function of the fact it identifies, and matches the
+ * live-row unique on `(user_id, medication_id, scheduled_for, source)` that the
+ * database enforces regardless.
+ */
+export function buildMedicationImportEntries(
+ medicationId: string,
+ rows: readonly MedicationImportRow[],
+): MedicationImportQueueEntry[] {
+ return rows.map((row) => {
+ const takenAt = new Date(`${row.datum}T${row.uhrzeit}`);
+ return {
+ takenAt: takenAt.toISOString(),
+ idempotencyKey: `import-${medicationId}-${takenAt.getTime()}`,
+ };
+ });
+}
diff --git a/src/lib/medications/intake-import-poll.ts b/src/lib/medications/intake-import-poll.ts
index 8e28fcfa1..6e52f3e67 100644
--- a/src/lib/medications/intake-import-poll.ts
+++ b/src/lib/medications/intake-import-poll.ts
@@ -2,8 +2,9 @@ import { apiGet } from "@/lib/api/api-fetch";
export interface MedicationIntakeImportResult {
imported: number;
- skippedDuplicates: number;
- skippedInvalid: number;
+ skipped: number;
+ /** One entry per reason with its count — never the same line per row. */
+ skipReasons: Array<{ reason: string; count: number }>;
}
interface MedicationIntakeImportStatus {
diff --git a/tests/integration/job-failures.test.ts b/tests/integration/job-failures.test.ts
new file mode 100644
index 000000000..18a6f6812
--- /dev/null
+++ b/tests/integration/job-failures.test.ts
@@ -0,0 +1,151 @@
+/**
+ * #646 — a broken background job says its own name.
+ *
+ * The environment fetch threw on every run for the whole life of the module.
+ * It was logged and counted, and neither reached a screen: `recordError()`
+ * increments one unnamed integer, and the worker log only exists where someone
+ * is tailing the container. The queue held the answer the entire time.
+ *
+ * These readers query `pgboss.job` directly, so the only test worth having is
+ * one against a schema pg-boss itself created. A hand-written `CREATE TABLE`
+ * would prove the query matches the table the test wrote, which is exactly the
+ * mistake being fixed. So pg-boss provisions its own schema here and the jobs
+ * are failed through its own API.
+ */
+import { PgBoss } from "pg-boss";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+
+import {
+ readFailingQueues,
+ readQueueFailureForUser,
+} from "@/lib/jobs/job-failures";
+
+import { getPrismaClient } from "./setup";
+
+const QUEUE = "environment-fetch";
+const OTHER_QUEUE = "reminder-check";
+
+let boss: PgBoss;
+
+/** Send a job, take it, and fail it terminally so it lands in `state='failed'`. */
+async function failOneJob(queue: string, data: object, error: string) {
+ const jobId = await boss.send(queue, data, { retryLimit: 0 });
+ if (!jobId) throw new Error("queue rejected the job");
+ const [job] = await boss.fetch(queue);
+ if (!job) throw new Error("nothing to fetch");
+ await boss.fail(queue, job.id, new Error(error));
+ return jobId;
+}
+
+beforeAll(async () => {
+ boss = new PgBoss({
+ connectionString: process.env.DATABASE_URL,
+ // No maintenance or monitoring timers — the test drives every transition.
+ supervise: false,
+ schedule: false,
+ });
+ await boss.start();
+ await boss.createQueue(QUEUE);
+ await boss.createQueue(OTHER_QUEUE);
+}, 60_000);
+
+afterAll(async () => {
+ await boss?.stop({ graceful: false, close: true });
+ await getPrismaClient().$executeRawUnsafe(
+ `DROP SCHEMA IF EXISTS pgboss CASCADE`,
+ );
+});
+
+describe("readFailingQueues", () => {
+ it("names the queue, counts the failures, and carries the last error", async () => {
+ await failOneJob(
+ QUEUE,
+ { userId: "user-a" },
+ 'type "public.EnvironmentLocationSource" does not exist',
+ );
+ await failOneJob(QUEUE, { userId: "user-b" }, "upstream feed unavailable");
+ await failOneJob(OTHER_QUEUE, {}, "reminder dispatch failed");
+
+ const failing = await readFailingQueues();
+ expect(failing).not.toBeNull();
+
+ const environment = failing?.find((entry) => entry.queue === QUEUE);
+ expect(environment?.failures).toBe(2);
+ expect(environment?.lastError).toBe("upstream feed unavailable");
+ expect(Number.isFinite(Date.parse(environment?.lastFailedAt ?? ""))).toBe(
+ true,
+ );
+
+ const other = failing?.find((entry) => entry.queue === OTHER_QUEUE);
+ expect(other?.failures).toBe(1);
+ expect(other?.lastError).toBe("reminder dispatch failed");
+ });
+
+ it("reports an empty list — not null — when nothing is failing", async () => {
+ await getPrismaClient().$executeRawUnsafe(
+ `DELETE FROM pgboss.job WHERE state = 'failed'`,
+ );
+ await expect(readFailingQueues()).resolves.toEqual([]);
+ });
+
+ it("ignores a failure older than the window", async () => {
+ await failOneJob(QUEUE, { userId: "user-a" }, "old failure");
+ await getPrismaClient().$executeRawUnsafe(
+ `UPDATE pgboss.job SET completed_on = now() - interval '10 days' WHERE state = 'failed'`,
+ );
+ await expect(readFailingQueues(72)).resolves.toEqual([]);
+ });
+});
+
+describe("readQueueFailureForUser", () => {
+ it("matches on the userId the job payload carries", async () => {
+ await getPrismaClient().$executeRawUnsafe(
+ `DELETE FROM pgboss.job WHERE state = 'failed'`,
+ );
+ await failOneJob(QUEUE, { userId: "user-a" }, "first");
+ await failOneJob(QUEUE, { userId: "user-a" }, "second");
+ await failOneJob(QUEUE, { userId: "user-b" }, "someone else");
+
+ const mine = await readQueueFailureForUser(QUEUE, "user-a");
+ expect(mine?.failures).toBe(2);
+ expect(Number.isFinite(Date.parse(mine?.lastFailedAt ?? ""))).toBe(true);
+
+ // No error text on this side — the message is written for an operator.
+ expect(Object.keys(mine ?? {}).sort()).toEqual([
+ "failures",
+ "lastFailedAt",
+ ]);
+ });
+
+ it("answers null for an account whose jobs did not fail", async () => {
+ await expect(
+ readQueueFailureForUser(QUEUE, "user-with-no-failures"),
+ ).resolves.toBeNull();
+ });
+
+ it("does not attribute another queue's failure to this one", async () => {
+ await getPrismaClient().$executeRawUnsafe(
+ `DELETE FROM pgboss.job WHERE state = 'failed'`,
+ );
+ await failOneJob(OTHER_QUEUE, { userId: "user-a" }, "reminder failed");
+ await expect(readQueueFailureForUser(QUEUE, "user-a")).resolves.toBeNull();
+ });
+});
+
+describe("no queue schema at all", () => {
+ it("answers null rather than throwing, so the status page still renders", async () => {
+ const prisma = getPrismaClient();
+ await boss.stop({ graceful: false, close: true });
+ await prisma.$executeRawUnsafe(`ALTER SCHEMA pgboss RENAME TO pgboss_gone`);
+ try {
+ await expect(readFailingQueues()).resolves.toBeNull();
+ await expect(
+ readQueueFailureForUser(QUEUE, "user-a"),
+ ).resolves.toBeNull();
+ } finally {
+ await prisma.$executeRawUnsafe(
+ `ALTER SCHEMA pgboss_gone RENAME TO pgboss`,
+ );
+ }
+ });
+});
diff --git a/tests/integration/medication-intake-import-job.test.ts b/tests/integration/medication-intake-import-job.test.ts
index 0ab134115..645975b07 100644
--- a/tests/integration/medication-intake-import-job.test.ts
+++ b/tests/integration/medication-intake-import-job.test.ts
@@ -82,7 +82,7 @@ describe("medication intake import job — concurrent retry", () => {
processed: 0,
total: payload.entries.length,
imported: 0,
- skippedDuplicates: 0,
+ skippedByReason: {},
touchedDays: [],
rollupProcessed: 0,
};
@@ -150,16 +150,19 @@ describe("medication intake import job — concurrent retry", () => {
{ 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
+ // than as an undifferentiated "duplicate".
expect(refreshedJob.result).toEqual({
imported: 3,
- skippedDuplicates: 1,
- skippedInvalid: 0,
+ skipped: 1,
+ skipReasons: [{ reason: "duplicate_in_file", count: 1 }],
});
expect(refreshedJob.progress).toEqual({
processed: 4,
total: 4,
imported: 3,
- skippedDuplicates: 1,
+ skippedByReason: { duplicate_in_file: 1 },
touchedDays: ["2026-07-20", "2026-07-21"],
rollupProcessed: 2,
});
@@ -198,7 +201,7 @@ describe("medication intake import job — concurrent retry", () => {
processed: 0,
total: 1,
imported: 0,
- skippedDuplicates: 0,
+ skippedByReason: {},
touchedDays: [],
rollupProcessed: 0,
};
diff --git a/tests/integration/medication-intake-import-reported-export.test.ts b/tests/integration/medication-intake-import-reported-export.test.ts
new file mode 100644
index 000000000..67ccb39a6
--- /dev/null
+++ b/tests/integration/medication-intake-import-reported-export.test.ts
@@ -0,0 +1,239 @@
+/**
+ * #650 — the reported export, from the request body to the rows on the ledger.
+ *
+ * A self-hoster attached a 28-dose file: one intake per day for four weeks, all
+ * at 08:30, with `"zaehler": 1.0` on every row. The route accepted it, answered
+ * 202, and one intake landed. The other 27 were reported back as duplicates —
+ * of each other, because the replay key was built from `zaehler` and every row
+ * carried the same value.
+ *
+ * A mocked Prisma cannot show this: the collapse is half in the key derivation
+ * and half in two database uniques (`(user_id, idempotency_key)` and the
+ * live-row slot unique). So the real file goes through the real route into a
+ * real Postgres here, and the assertion is the count of rows that exist
+ * afterwards.
+ */
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { getPrismaClient, truncateAllTables } from "./setup";
+import {
+ _setMedicationIntakeImportPrismaForTests,
+ processMedicationIntakeImportJob,
+} from "@/lib/jobs/medication-intake-import";
+import { buildMedicationImportEntries } from "@/lib/medications/intake-import-payload";
+import { toJson } from "@/lib/db";
+
+vi.mock("@/lib/cache/invalidate", () => ({
+ invalidateUserMedications: vi.fn(),
+}));
+vi.mock("@/lib/notifications/medication-intake-sync", () => ({
+ queueMedicationIntakeSync: vi.fn(),
+}));
+
+/** The file attached to the report, byte for byte. */
+const REPORTED_EXPORT = JSON.parse(
+ readFileSync(
+ join(
+ __dirname,
+ "..",
+ "..",
+ "src/app/api/medications/[id]/intake/import/__tests__/fixtures/reported-intake-export.json",
+ ),
+ "utf8",
+ ),
+) as Array<{ datum: string; uhrzeit: string; zaehler: number }>;
+
+beforeEach(async () => {
+ const prisma = getPrismaClient();
+ await truncateAllTables(prisma);
+ _setMedicationIntakeImportPrismaForTests(prisma);
+ vi.clearAllMocks();
+});
+
+afterAll(() => {
+ _setMedicationIntakeImportPrismaForTests(null);
+});
+
+async function seedMedication() {
+ const prisma = getPrismaClient();
+ const user = await prisma.user.create({
+ data: {
+ username: "reported-export",
+ email: "reported-export@example.test",
+ role: "USER",
+ timezone: "Europe/Berlin",
+ },
+ });
+ const medication = await prisma.medication.create({
+ data: {
+ userId: user.id,
+ name: "Reported medication",
+ dose: "1 tablet",
+ unitsPerDose: 1,
+ },
+ });
+ return { user, medication };
+}
+
+/** The route's own derivation, so the fix is what is under test — not a mirror. */
+function normalise(medicationId: string) {
+ return {
+ entries: buildMedicationImportEntries(medicationId, REPORTED_EXPORT),
+ };
+}
+
+describe("medication intake import — the reported 28-dose export", () => {
+ it("writes one row per dose instead of one row for the whole file", async () => {
+ const prisma = getPrismaClient();
+ const { user, medication } = await seedMedication();
+ const payload = normalise(medication.id);
+
+ const job = await prisma.medicationIntakeImportJob.create({
+ data: {
+ userId: user.id,
+ medicationId: medication.id,
+ payload: toJson(payload),
+ progress: toJson({
+ processed: 0,
+ total: payload.entries.length,
+ imported: 0,
+ skippedByReason: {},
+ touchedDays: [],
+ rollupProcessed: 0,
+ }),
+ },
+ });
+
+ const result = await processMedicationIntakeImportJob(job.id);
+
+ expect(result).toEqual({ imported: 28, skipped: 0, skipReasons: [] });
+
+ const events = await prisma.medicationIntakeEvent.findMany({
+ where: { userId: user.id, medicationId: medication.id },
+ orderBy: { takenAt: "asc" },
+ select: { takenAt: true, source: true, idempotencyKey: true },
+ });
+ expect(events).toHaveLength(28);
+ expect(new Set(events.map((event) => event.idempotencyKey)).size).toBe(28);
+ expect(events.every((event) => event.source === "IMPORT")).toBe(true);
+ // Every submitted day is on the ledger — the point of the whole import.
+ expect(
+ events.map((event) => event.takenAt?.toISOString().slice(0, 10)),
+ ).toEqual(REPORTED_EXPORT.map((row) => row.datum));
+ });
+
+ it("re-importing the same file adds nothing and says why", async () => {
+ const prisma = getPrismaClient();
+ const { user, medication } = await seedMedication();
+ const payload = normalise(medication.id);
+ const progress = {
+ processed: 0,
+ total: payload.entries.length,
+ imported: 0,
+ skippedByReason: {},
+ touchedDays: [],
+ rollupProcessed: 0,
+ };
+
+ const first = await prisma.medicationIntakeImportJob.create({
+ data: {
+ userId: user.id,
+ medicationId: medication.id,
+ payload: toJson(payload),
+ progress: toJson(progress),
+ },
+ });
+ await processMedicationIntakeImportJob(first.id);
+
+ const second = await prisma.medicationIntakeImportJob.create({
+ data: {
+ userId: user.id,
+ medicationId: medication.id,
+ payload: toJson(payload),
+ progress: toJson(progress),
+ },
+ });
+ const result = await processMedicationIntakeImportJob(second.id);
+
+ expect(result).toEqual({
+ imported: 0,
+ skipped: 28,
+ skipReasons: [{ reason: "already_recorded", count: 28 }],
+ });
+ await expect(
+ prisma.medicationIntakeEvent.count({
+ where: { userId: user.id, medicationId: medication.id },
+ }),
+ ).resolves.toBe(28);
+ });
+
+ it("counts a within-file collision apart from an already-recorded dose", async () => {
+ const prisma = getPrismaClient();
+ const { user, medication } = await seedMedication();
+
+ // One dose already on the ledger, from any surface at all.
+ const existing = new Date("2026-05-31T08:30:00");
+ await prisma.medicationIntakeEvent.create({
+ data: {
+ userId: user.id,
+ medicationId: medication.id,
+ scheduledFor: existing,
+ takenAt: existing,
+ source: "IMPORT",
+ idempotencyKey: `import-${medication.id}-${existing.getTime()}`,
+ },
+ });
+
+ // A submission that repeats one of its own rows AND repeats that dose.
+ const duplicated = new Date("2026-06-05T08:30:00");
+ const entries = [
+ {
+ takenAt: existing.toISOString(),
+ idempotencyKey: `import-${medication.id}-${existing.getTime()}`,
+ },
+ {
+ takenAt: duplicated.toISOString(),
+ idempotencyKey: `import-${medication.id}-${duplicated.getTime()}`,
+ },
+ {
+ takenAt: duplicated.toISOString(),
+ idempotencyKey: `import-${medication.id}-${duplicated.getTime()}`,
+ },
+ ];
+
+ const job = await prisma.medicationIntakeImportJob.create({
+ data: {
+ userId: user.id,
+ medicationId: medication.id,
+ payload: toJson({ entries }),
+ progress: toJson({
+ processed: 0,
+ total: entries.length,
+ imported: 0,
+ skippedByReason: {},
+ touchedDays: [],
+ rollupProcessed: 0,
+ }),
+ },
+ });
+
+ const result = await processMedicationIntakeImportJob(job.id);
+
+ expect(result).toEqual({
+ imported: 1,
+ skipped: 2,
+ skipReasons: [
+ { reason: "already_recorded", count: 1 },
+ { reason: "duplicate_in_file", count: 1 },
+ ],
+ });
+ await expect(
+ prisma.medicationIntakeEvent.count({
+ where: { userId: user.id, medicationId: medication.id },
+ }),
+ ).resolves.toBe(2);
+ });
+});
From 11e6402b5ae64b899831669b9cf0bd97c788bd6e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 11:29:00 +0200
Subject: [PATCH 12/52] feat(medications): read a dose-history export directly
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The intake importer took a hand-written list of dates and times for one
medication. A self-hoster with a ten-column export covering sixteen
medications and years of history was left translating it by hand, which is
where the scheduled time and the time a dose was actually taken get
flattened into one — the distinction the record wants most.
The export keeps them as two columns, so read the file's own shape.
`MedicationIntakeEvent` already models that pair as `scheduledFor` +
`takenAt`; an imported dose now populates both instead of anchoring itself
on the take. `Skipped` becomes a deliberate skip with no take time, which is
how every other skip path writes it. A dose whose actual amount differs from
the amount the schedule expected carries the difference as a per-dose
override; an unremarkable amount stays absent, which is what absent means
there.
Timestamps carry their own UTC offset (`+1030` and `+0930` on adjacent rows
of one file) and it is read literally: the epoch is computed from the stated
offset rather than handed to the platform's lenient date parser, and a value
without an offset is refused instead of assumed to be the server's zone.
An export covers a whole regimen, so the job is account-wide and each
payload entry names its own medication — migration 0276 widens
`medication_intake_import_jobs.medication_id` to nullable for it. The
per-medication route is unchanged and keeps naming its medication on the job
row. A medication is never created from an import file; unmatched names come
back verbatim so they can be added properly and the file re-run.
Every column gets a verdict, and `?dryRun=1` returns the whole one before
anything is written.
---
.../migration.sql | 13 +
prisma/schema.prisma | 286 +++++------
.../intake/import/[jobId]/status/route.ts | 24 +-
.../[jobId]/status/route.ts | 30 ++
.../intake/dose-history-import/route.ts | 317 ++++++++++++
src/lib/jobs/medication-intake-import.ts | 230 +++++++--
.../medications/import/auto-export-format.ts | 180 +++++++
.../medications/import/auto-export-instant.ts | 127 +++++
.../medications/import/auto-export-parse.ts | 452 ++++++++++++++++++
.../medications/import/auto-export-plan.ts | 215 +++++++++
.../medications/intake-import-job-status.ts | 55 +++
src/lib/medications/intake-import-payload.ts | 6 +
.../medication-intake-import-job.test.ts | 5 +
13 files changed, 1748 insertions(+), 192 deletions(-)
create mode 100644 prisma/migrations/0276_account_scoped_intake_import/migration.sql
create mode 100644 src/app/api/medications/intake/dose-history-import/[jobId]/status/route.ts
create mode 100644 src/app/api/medications/intake/dose-history-import/route.ts
create mode 100644 src/lib/medications/import/auto-export-format.ts
create mode 100644 src/lib/medications/import/auto-export-instant.ts
create mode 100644 src/lib/medications/import/auto-export-parse.ts
create mode 100644 src/lib/medications/import/auto-export-plan.ts
create mode 100644 src/lib/medications/intake-import-job-status.ts
diff --git a/prisma/migrations/0276_account_scoped_intake_import/migration.sql b/prisma/migrations/0276_account_scoped_intake_import/migration.sql
new file mode 100644
index 000000000..845bdaaef
--- /dev/null
+++ b/prisma/migrations/0276_account_scoped_intake_import/migration.sql
@@ -0,0 +1,13 @@
+-- An intake import can now cover a whole regimen.
+--
+-- The per-medication import route scopes its job to one medication and the
+-- column named it. A dose history exported from another tracker is one file for
+-- every medication a person takes, so that job belongs to the account and each
+-- payload entry names its own medication instead. NULL means "account-wide",
+-- not "unknown"; the worker refuses a payload entry that names no medication
+-- and no job row to fall back on.
+--
+-- Widening only: every existing row keeps its medication and the per-medication
+-- route keeps writing one.
+ALTER TABLE "medication_intake_import_jobs"
+ ALTER COLUMN "medication_id" DROP NOT NULL;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 1139d4d90..3cb06cee8 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -640,104 +640,104 @@ model User {
passkeyUpgradeNudgeDismissed Boolean @default(false) @map("passkey_upgrade_nudge_dismissed")
// Relations
- passkeys Passkey[]
- sessions Session[]
- challenges AuthChallenge[]
+ passkeys Passkey[]
+ sessions Session[]
+ challenges AuthChallenge[]
// v1.23 — second-factor stores (kept separate from `passkeys`, which is the
// passwordless-primary credential set; an MFA credential is meaningless
// without the password).
- mfaChallenges MfaChallenge[]
- mfaRecoveryCodes MfaRecoveryCode[]
- webauthnMfaCredentials WebauthnMfaCredential[]
- knownDevices UserKnownDevice[]
+ mfaChallenges MfaChallenge[]
+ mfaRecoveryCodes MfaRecoveryCode[]
+ webauthnMfaCredentials WebauthnMfaCredential[]
+ knownDevices UserKnownDevice[]
// v1.23 — "remember this device" trusted-device tokens. A trusted device
// skips the second factor (factor 2 only) within the window; the password
// is still required. Revoked when the second factor is removed.
- trustedDevices TrustedDevice[]
+ trustedDevices TrustedDevice[]
// v1.30.34 — short-lived, token-bound step-up elevations. The Bearer-transport
// equivalent of `Session.mfaVerifiedAt`: a token client re-proves a factor and
// receives one single-use elevation good for one MFA-management call.
- stepUpElevations StepUpElevation[]
- measurements Measurement[]
- medications Medication[]
- intakeEvents MedicationIntakeEvent[]
- medicationPauseEras MedicationPauseEra[]
- apiTokens ApiToken[]
+ stepUpElevations StepUpElevation[]
+ measurements Measurement[]
+ medications Medication[]
+ intakeEvents MedicationIntakeEvent[]
+ medicationPauseEras MedicationPauseEra[]
+ apiTokens ApiToken[]
// v1.24 — MCP remote OAuth 2.1 connections (1:N). Each row is a granted
// authorization for an MCP client, audience-bound to health:read/health:write.
- mcpOAuthConnections McpOAuthConnection[]
- refreshTokens RefreshToken[]
+ mcpOAuthConnections McpOAuthConnection[]
+ refreshTokens RefreshToken[]
// v1.30.x — one-time native OIDC SSO handoff codes. Each row bridges the
// browser-context OIDC callback to the cookie-less native token exchange.
- oidcNativeHandoffs OidcNativeHandoff[]
- auditLogs AuditLog[]
- withingsConnection WithingsConnection?
+ oidcNativeHandoffs OidcNativeHandoff[]
+ auditLogs AuditLog[]
+ withingsConnection WithingsConnection?
// v1.4.47 W6 — short-lived `(nonce → userId)` ledger backing the
// Withings OAuth state check. The connect route mints a row with a
// 10-minute TTL; the callback resolves the user via this relation
// instead of parsing the cookie value, closing the L-1 audit gap
// where the legacy `${user.id}:${nonce}` shape leaked the user id
// into request logs / network captures.
- withingsOAuthStates WithingsOAuthState[]
+ withingsOAuthStates WithingsOAuthState[]
// v1.11.0 — WHOOP integration. Mirrors the Withings connection (1:1) +
// OAuth-state ledger (1:N) relations.
- whoopConnection WhoopConnection?
- whoopOAuthStates WhoopOAuthState[]
- whoopConnectTickets WhoopConnectTicket[]
+ whoopConnection WhoopConnection?
+ whoopOAuthStates WhoopOAuthState[]
+ whoopConnectTickets WhoopConnectTicket[]
// v1.12.0 — Fitbit/Pixel integration. Mirrors the WHOOP connection (1:1) +
// OAuth-state ledger (1:N) relations.
- fitbitConnection FitbitConnection?
- fitbitOAuthStates FitbitOAuthState[]
+ fitbitConnection FitbitConnection?
+ fitbitOAuthStates FitbitOAuthState[]
// v1.27.0 — Google Health API integration (separate provider from the classic
// Fitbit Web API above). Mirrors the connection (1:1) + OAuth-state ledger
// (1:N) relations.
- googleHealthConnection GoogleHealthConnection?
- googleHealthOAuthStates GoogleHealthOAuthState[]
- moodEntries MoodEntry[]
- mentalHealthAssessments MentalHealthAssessment[]
- moodTagsCustom MoodTag[] @relation("UserMoodTags")
- moodTagCategoriesCustom MoodTagCategory[] @relation("UserMoodTagCategories")
- moodTagsHidden MoodTagHidden[]
- notificationChannels NotificationChannel[]
- pushSubscriptions PushSubscription[]
- dataBackups DataBackup[]
- achievements UserAchievement[]
- recommendationFeedback RecommendationFeedback[]
- idempotencyKeys IdempotencyKey[]
- devices Device[]
- integrationStatuses IntegrationStatus[]
- coachConversations CoachConversation[]
- coachFacts CoachFact[]
- coachPlans CoachPlan[]
- coachReminders CoachReminder[]
- coachUsage CoachUsage[]
+ googleHealthConnection GoogleHealthConnection?
+ googleHealthOAuthStates GoogleHealthOAuthState[]
+ moodEntries MoodEntry[]
+ mentalHealthAssessments MentalHealthAssessment[]
+ moodTagsCustom MoodTag[] @relation("UserMoodTags")
+ moodTagCategoriesCustom MoodTagCategory[] @relation("UserMoodTagCategories")
+ moodTagsHidden MoodTagHidden[]
+ notificationChannels NotificationChannel[]
+ pushSubscriptions PushSubscription[]
+ dataBackups DataBackup[]
+ achievements UserAchievement[]
+ recommendationFeedback RecommendationFeedback[]
+ idempotencyKeys IdempotencyKey[]
+ devices Device[]
+ integrationStatuses IntegrationStatus[]
+ coachConversations CoachConversation[]
+ coachFacts CoachFact[]
+ coachPlans CoachPlan[]
+ coachReminders CoachReminder[]
+ coachUsage CoachUsage[]
// v1.4.25 W8d — workout passthrough (Apple HKWorkout / Withings
// getworkouts / future Strava). Schema lands now so the v1.5 iOS
// session can build against a stable DTO. Routes hang off Workout
// (1:1), not User — the User → WorkoutRoute path is two hops by
// design.
- workouts Workout[]
+ workouts Workout[]
// v1.4.25 W8d — personal records (schema only; detection worker
// lands in a later release). The GET /api/personal-records route
// ships with v1.4.25 so the iOS app can build its query path
// against a stable contract.
- personalRecords PersonalRecord[]
+ personalRecords PersonalRecord[]
// Today rail dismiss ledger — observational PriorityItem kinds only.
- dismissedPriorityItems DismissedPriorityItem[]
+ dismissedPriorityItems DismissedPriorityItem[]
// v1.4.25 W19b — per-pen / per-vial inventory entities, the
// companion to the running-sum MedicationInventoryEvent ledger.
// Carries the EMA §6.3 30-day in-use clock per opened pen.
- inventoryItems MedicationInventoryItem[]
+ inventoryItems MedicationInventoryItem[]
// v1.4.25 W19d — GLP-1 side-effect logbook. 21-entry × 5-category
// EMA EPAR §4.8-derived taxonomy with 1-5 Likert severity. Daily
// capture (not per-shot) so delayed-onset symptoms map to their
// actual occurrence time.
- medicationSideEffects MedicationSideEffect[]
+ medicationSideEffects MedicationSideEffect[]
// v1.4.34 — Apple Health `export.zip` import jobs. Mirrors the
// pg-boss job state so the polling endpoint can read live per-
// phase progress without round-tripping through pg-boss internals.
- importJobs ImportJob[]
- medicationIntakeImportJobs MedicationIntakeImportJob[]
+ importJobs ImportJob[]
+ medicationIntakeImportJobs MedicationIntakeImportJob[]
// v1.5.0 — persistent measurement rollup table. Carries the
// count/min/max/mean/sd/slope/r2 stats per (type, granularity,
// bucketStart) so the analytics + comprehensive read paths bypass
@@ -745,112 +745,112 @@ model User {
// multi-instance fan-out. Populated synchronously on the DAY
// granularity from the measurement write hooks; WEEK/MONTH/YEAR
// are enqueued through pg-boss for the worker to fold.
- measurementRollups MeasurementRollup[]
+ measurementRollups MeasurementRollup[]
// v0.5.4 ios-coord — per-day dedup ledger for MOOD_REMINDER pushes.
// One row per (user, local date) the worker has nudged. Prevents
// a second push when the cron rechecks the same hour later in the
// 22:00 window (the cron fires every 15 min — without the ledger
// a user could see three pushes back-to-back).
- moodReminderDispatches MoodReminderDispatch[]
+ moodReminderDispatches MoodReminderDispatch[]
// v1.4.39 W-MOOD — per-(user, granularity, bucket) mood rollup. New
// tier behind the mood-analytics read path; per-write hooks fold
// MoodEntry mutations into the DAY granularity inline so the next
// read serves from this table instead of the unbounded
// MoodEntry.findMany walk.
- moodEntryRollups MoodEntryRollup[]
+ 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[]
+ 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
// be verifiable") plus the operator's GDPR Art. 7 documentation
// burden. Append-only — a user revoking + re-granting consent mints
// a fresh row so the timeline reads chronologically.
- consentReceipts ConsentReceipt[]
+ consentReceipts ConsentReceipt[]
// v1.4.49 — per-attempt push-delivery ledger. Every sender (APNS,
// WEB_PUSH, TELEGRAM, NTFY) writes one fire-and-forget row per
// dispatch so the admin diagnostic endpoint can return the trailing
// 20 attempts ordered by recency. Daily prune at 03:35 Europe/Berlin
// retires rows older than 90 days (see `handlePushAttemptCleanup` in
// `reminder-worker.ts`).
- pushAttempts PushAttempt[]
+ pushAttempts PushAttempt[]
// v1.10.3 — per-(user, day) Strain personal-anchor cache (day-total
// TRIMP + running EWMA reference) so the nightly Strain engine anchors
// the 0–100 map to the user's own training load without re-integrating
// the 42-day chronic window of HR series each night.
- strainTrimpCache StrainTrimpCache[]
+ strainTrimpCache StrainTrimpCache[]
// v1.11.0 — owner-minted clinician share links. Each row grants a
// scoped, time-limited, revocable read view of the health record to a
// clinician holding the raw `hls_` token (stored only as an HMAC hash).
- clinicianShareLinks ClinicianShareLink[]
+ clinicianShareLinks ClinicianShareLink[]
// v1.11.0 W1 — durable per-provider AI-health ledger (one row per
// provider chain entry) so a known-bad credential is negatively cached
// across workers instead of re-burning a 401 every generation.
- providerHealth ProviderHealth[]
+ providerHealth ProviderHealth[]
// v1.11.0 W3 — one durable period-narrative row per (period, locale).
// Delete/regenerate-clean; the generated prose is AES-256-GCM at rest.
- insightNarratives InsightNarrative[]
+ insightNarratives InsightNarrative[]
// v1.31.0 — the data-arrival spine's durable per-day reaction marker. One
// row per (kind, local day); the unique index IS the once-per-kind-per-day
// claim every reaction surface reads. Retained 14 days.
- arrivalReactions ArrivalReaction[]
- workoutInsights WorkoutInsight[]
+ arrivalReactions ArrivalReaction[]
+ workoutInsights WorkoutInsight[]
workoutInsightGenerationClaims WorkoutInsightGenerationClaim[]
// v1.17.1 — preventive-care / measurement (Vorsorge) reminders. One row
// per user-created reminder ("BP every 7 days", "annual blood panel").
// Server computes the canonical `nextDueAt`; the cron fires through the
// existing notification dispatcher. Soft-deleted.
- measurementReminders MeasurementReminder[]
+ measurementReminders MeasurementReminder[]
// v1.17.1 — minimal structured lab store (LDL, HbA1c, ferritin, TSH, …)
// that pairs with the Vorsorge "annual blood panel" reminder so a result
// has somewhere to land. Soft-deleted.
- labResults LabResult[]
+ labResults LabResult[]
// v1.19.0 — user-scoped biomarker catalog (canonical name/unit/range per
// marker); LabResult rows link to a Biomarker for the resolved range/unit.
- biomarkers Biomarker[]
+ biomarkers Biomarker[]
// v1.25.5 — user-defined custom metrics. A separate generic store
// (catalog + values), fully isolated from the closed MeasurementType
// system: not synced, not in FHIR, not in AI insights — log + chart only.
- customMetrics CustomMetric[]
- customMetricEntries CustomMetricEntry[]
+ customMetrics CustomMetric[]
+ customMetricEntries CustomMetricEntry[]
// v1.19.0 — illness / condition journal (born-gated by the module system).
// IllnessEpisode is the soft-deleted episode row; IllnessDayLog is the
// per-day timeline mirroring the CycleDayLog shape.
- illnessEpisodes IllnessEpisode[]
- illnessDayLogs IllnessDayLog[]
+ illnessEpisodes IllnessEpisode[]
+ illnessDayLogs IllnessDayLog[]
// v1.25 (W-RECORDS) — structured health-record entries. Allergies map to
// FHIR AllergyIntolerance; family-history entries map to FHIR
// FamilyMemberHistory. Reference records (not time-series signals); the
// sensitive free-text fields are encrypted at rest.
- allergies Allergy[]
- familyHistory FamilyHistoryEntry[]
+ allergies Allergy[]
+ familyHistory FamilyHistoryEntry[]
// v1.25 (W-ENV) — opt-in environmental-context module. `EnvironmentContext`
// is the per-day weather/daylight observation keyed to the resolved location;
// `EnvironmentTravelLocation` holds the manual date-range travel overrides
// that win over the home location for the days they cover.
- environmentContexts EnvironmentContext[]
- environmentTravelLocations EnvironmentTravelLocation[]
+ environmentContexts EnvironmentContext[]
+ environmentTravelLocations EnvironmentTravelLocation[]
// v1.28 — micronutrient intake daily totals synced from Apple Health
// (vitamins, minerals, water, caffeine). Opt-in `nutrients` module, off
// by default; the ingest route refuses while the module is off.
- nutrientIntakeDays NutrientIntakeDay[]
+ nutrientIntakeDays NutrientIntakeDay[]
// v1.25 (W-DOCS-IN) — inbound clinical documents (doctor reports /
// discharge letters) the user uploads. The raw document is encrypted at
// rest; extracted facts land in a review-then-confirm staging area
// (ExtractedFact) and only reach the structured stores on explicit
// approval. Opt-in module, off by default.
- inboundDocuments InboundDocument[]
- extractedFacts ExtractedFact[]
- documentConditionLinks DocumentConditionLink[]
- documentContentIndexes DocumentContentIndex[]
- documentThumbnails DocumentThumbnail[]
+ inboundDocuments InboundDocument[]
+ extractedFacts ExtractedFact[]
+ documentConditionLinks DocumentConditionLink[]
+ documentContentIndexes DocumentContentIndex[]
+ documentThumbnails DocumentThumbnail[]
// v1.19.0 — full Withings ECG waveform recordings (encrypted at rest).
// The paired AFib verdict still lands as an IRREGULAR_RHYTHM_NOTIFICATION
// Measurement EVENT row (v1.18.11); this holds the raw signal samples.
- ecgRecordings EcgRecording[]
+ ecgRecordings EcgRecording[]
// v1.7.0 profile — optional patient-identity fields surfaced on the
// health-record export cover (PDF) and the FHIR `Patient` resource.
@@ -1612,8 +1612,8 @@ enum MeasurementAggregationProvenance {
}
model Measurement {
- id String @id @default(cuid())
- userId String @map("user_id")
+ id String @id @default(cuid())
+ userId String @map("user_id")
type MeasurementType
value Float
// v1.19.2 (iOS #34 extension) — per-bucket spread for an aggregated row.
@@ -1624,26 +1624,26 @@ model Measurement {
// samples. NULL for every other row (per-sample readings, per-day
// cumulative `stats:` totals, manual entries) — the series reader hands
// them through only for the heart-rate kind.
- valueMin Float? @map("value_min")
- valueMax Float? @map("value_max")
+ valueMin Float? @map("value_min")
+ valueMax Float? @map("value_max")
unit String // kg, mmHg, bpm, %, minutes (sleep), steps, mg/dL, kcal, m, ms, mL/(kg·min), celsius
- source MeasurementSource @default(MANUAL)
- measuredAt DateTime @map("measured_at")
+ source MeasurementSource @default(MANUAL)
+ measuredAt DateTime @map("measured_at")
notes String? // LEGACY plaintext — superseded by `notesEncrypted` (v1.23). New writes go to the encrypted column and null this; the boot-time backfill migrates the remaining rows. Column drop is a follow-up release once the backfill reports zero remaining.
// v1.23 — free-text measurement note, AES-256-GCM at rest (Bytes, matching
// the IllnessDayLog / CycleDayLog / LabResult note convention). Registered
// in ENCRYPTED_COLUMNS + the rotation script.
- notesEncrypted Bytes? @map("notes_encrypted")
+ notesEncrypted Bytes? @map("notes_encrypted")
// External-system dedup key. Withings used `grpid` (numeric); v1.4.23
// adds Apple Health which uses `HKSample.uuid` (string). NULL for
// manual entries — Postgres treats NULLs as distinct in unique
// indexes, so two manual rows with the same (user, type, source) are
// still allowed.
- externalId String? @map("external_id")
+ externalId String? @map("external_id")
// v1.4.23 — opaque marker for the source's schema version (HealthKit
// sample's `metadata.HKWasUserEntered`, Withings model code, …).
// Stored as a string so the ingest path can replay it back unchanged.
- externalSourceVersion String? @map("external_source_version")
+ externalSourceVersion String? @map("external_source_version")
// Authority of an aggregate stored under a `stats:*` identity. NULL for
// ordinary point/sample rows. Native HealthKit statistics outrank the
// source-day estimate derived from export.xml; migrated ambiguous rows are
@@ -1657,11 +1657,11 @@ model Measurement {
// one direction only: a non-glucose row can never carry a context. Every
// per-context reader selects by equality against the four enum members, so
// a NULL context stays absent rather than reading as a classification.
- glucoseContext GlucoseContext? @map("glucose_context")
+ glucoseContext GlucoseContext? @map("glucose_context")
// v1.4.23 — per-stage sleep label. NULL for non-sleep rows; non-NULL
// only for `type = SLEEP_DURATION`. Mirrors the glucoseContext
// pattern. A single night produces ~5 rows (one per stage).
- sleepStage SleepStage? @map("sleep_stage")
+ sleepStage SleepStage? @map("sleep_stage")
// v1.10.0 — categorical events (WX-B). The device's own classification
// verdict / severity for an EVENT row. NULL for every continuous
// measurement; non-NULL only for the categorical EVENT `MeasurementType`s.
@@ -1670,7 +1670,7 @@ model Measurement {
// continuous row can never carry a classification. NOT part of any unique
// index — dedup stays on `(userId, type, source, externalId)` keyed by the
// device's `HKSample.uuid`, so a re-posted event collapses to a duplicate.
- rhythmClassification RhythmClassification? @map("rhythm_classification")
+ rhythmClassification RhythmClassification? @map("rhythm_classification")
// v1.4.25 W8c — device-type tag for the cross-source canonical
// picker. Stored as a free-text string (kept simple — Prisma enum
// would require coordinated client + server bumps every time we
@@ -1681,7 +1681,7 @@ model Measurement {
// NULL as `unknown`. Unique constraints intentionally do not
// include `deviceType` so two ingest paths from the same source
// still dedup on (type, source, externalId).
- deviceType String? @map("device_type")
+ deviceType String? @map("device_type")
// v1.4.30 — iOS SyncMode foundation (R-E C-2). Monotonically
// incremented on every server-side mutation of the row. iOS uses
// it for last-writer-wins reconciliation when two devices write
@@ -1689,16 +1689,16 @@ model Measurement {
// update path is responsible for bumping it. Existing rows
// start at the default after the additive migration so legacy
// ingest keeps round-tripping unchanged.
- syncVersion Int @default(1) @map("sync_version")
+ syncVersion Int @default(1) @map("sync_version")
// v1.4.30 — soft-delete column. The DELETE path on the
// by-external-ids endpoint flips this rather than removing the row
// so paired iOS clients can reconcile deletions without losing the
// audit trail. NULL = live row; non-NULL = tombstoned at the
// recorded instant. The unique-by-externalId index keeps both
// states distinguishable for the bulk-backfill UPSERT path.
- deletedAt DateTime? @map("deleted_at")
- createdAt DateTime @default(now()) @map("created_at")
- updatedAt DateTime @updatedAt @map("updated_at")
+ deletedAt DateTime? @map("deleted_at")
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// v1.19.0 — optional back-relation to the full ECG waveform recording(s)
@@ -1976,10 +1976,10 @@ model Workout {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- route WorkoutRoute?
- samples WorkoutSamples?
- insight WorkoutInsight?
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ route WorkoutRoute?
+ samples WorkoutSamples?
+ insight WorkoutInsight?
insightGenerationClaim WorkoutInsightGenerationClaim?
/// Ingest dedup. NULL externalId is distinct so two manual
@@ -2086,7 +2086,6 @@ model WorkoutSamples {
// ever creates one: a workout with no row renders no card, and that includes
// every workout that predates this model.
model WorkoutInsight {
-
id String @id @default(cuid())
userId String @map("user_id")
workoutId String @unique @map("workout_id")
@@ -2437,7 +2436,7 @@ model Medication {
schedules MedicationSchedule[]
intakeEvents MedicationIntakeEvent[]
- intakeImportJobs MedicationIntakeImportJob[]
+ intakeImportJobs MedicationIntakeImportJob[]
phaseConfig ReminderPhaseConfig?
telegramMessages TelegramReminderMessage[]
doseChanges MedicationDoseChange[]
@@ -3242,8 +3241,8 @@ model ApiToken {
// 60-minute access rows so it does not flood (M2).
mcpConnectionId String? @map("mcp_connection_id")
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- mcpConnection McpOAuthConnection? @relation(fields: [mcpConnectionId], references: [id], onDelete: SetNull)
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ mcpConnection McpOAuthConnection? @relation(fields: [mcpConnectionId], references: [id], onDelete: SetNull)
// v1.30.34 — step-up elevations bound to this token. Cascade-deleted with it.
stepUpElevations StepUpElevation[]
@@ -3479,13 +3478,13 @@ model OidcNativeHandoff {
// ─── Withings ──────────────────────────────────────────────
model WithingsConnection {
- id String @id @default(cuid())
- userId String @unique @map("user_id")
- withingsUserId String @map("withings_user_id")
- accessToken String @map("access_token") // encrypted at app level
- refreshToken String @map("refresh_token") // encrypted at app level
- tokenExpiresAt DateTime @map("token_expires_at")
- lastSyncedAt DateTime? @map("last_synced_at")
+ id String @id @default(cuid())
+ userId String @unique @map("user_id")
+ withingsUserId String @map("withings_user_id")
+ accessToken String @map("access_token") // encrypted at app level
+ refreshToken String @map("refresh_token") // encrypted at app level
+ tokenExpiresAt DateTime @map("token_expires_at")
+ lastSyncedAt DateTime? @map("last_synced_at")
/// v1.4.25 W5d — comma-separated OAuth scope string returned by the
/// Withings authorisation flow. Legacy v1.4.24- connections sit on
/// `user.metrics` only; v1.4.25 starts requesting
@@ -3493,21 +3492,21 @@ model WithingsConnection {
/// activity / sleep endpoints unlock. Null = legacy connection that
/// has never re-authed since v1.4.25 — the UI surfaces a banner
/// inviting them to reconnect.
- scope String? @map("scope")
+ scope String? @map("scope")
/// v1.17.1 — one-shot marker for the sleep-stamp backfill. Pre-fix Withings
/// sleep rows were stamped with the segment START; the reader treats
/// `measuredAt` as the END, shifting every night one segment-length earlier.
/// The boot-time backfill deletes the affected WITHINGS / SLEEP_DURATION rows
/// and re-syncs with the corrected END stamp, then sets this so the discovery
/// query drops the connection. Null = backfill not yet run.
- sleepTimelineBackfillAt DateTime? @map("sleep_timeline_backfill_at")
+ sleepTimelineBackfillAt DateTime? @map("sleep_timeline_backfill_at")
/// v1.32 — durable per-category webhook subscription outcomes. The JSON
/// payload is versioned and validated before use; the timestamp mirrors the
/// earliest transient/pending category retry for indexed hourly discovery.
webhookSubscriptionState Json? @map("webhook_subscription_state")
webhookSubscriptionRetryAt DateTime? @map("webhook_subscription_retry_at")
- createdAt DateTime @default(now()) @map("created_at")
- updatedAt DateTime @updatedAt @map("updated_at")
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -4476,7 +4475,14 @@ model ImportJob {
model MedicationIntakeImportJob {
id String @id @default(cuid())
userId String @map("user_id")
- medicationId String @map("medication_id")
+ /// v1.32.40 (migration 0276) — nullable. The per-medication import route
+ /// scopes its job to one medication and names it here. A dose history
+ /// exported from another tracker covers a whole regimen in one file — the
+ /// reported export named sixteen medications across 3,395 rows — so that job
+ /// belongs to the account and each payload entry names its own medication
+ /// instead. NULL therefore means "account-wide", not "unknown": the worker
+ /// reads `entry.medicationId` and refuses a payload that names neither.
+ medicationId String? @map("medication_id")
pgBossJobId String? @unique @map("pg_boss_job_id")
status String @default("queued")
payload Json
@@ -4489,8 +4495,8 @@ model MedicationIntakeImportJob {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ medication Medication? @relation(fields: [medicationId], references: [id], onDelete: Cascade)
@@index([userId, createdAt])
@@index([medicationId, createdAt])
@@ -5424,13 +5430,13 @@ model ClinicianShareLink {
// ── Frozen scope (write-once at creation; never widened) ──
/// Reporting-window start (absolute, not "last N days"). Frozen so a
/// rolling share can never reach data older than the owner chose.
- rangeStart DateTime @map("range_start")
+ rangeStart DateTime @map("range_start")
/// Window end. Null = rolling ("up to now"); set = frozen snapshot window.
- rangeEnd DateTime? @map("range_end")
+ rangeEnd DateTime? @map("range_end")
/// The frozen report scope: `{ "v": 2, "leaves": [...] }`. Write-once at
/// mint, never widened. A blob this build cannot read resolves to the empty
/// selection, so a drifted row serves nothing rather than defaulting open.
- sectionsJson Json @map("sections_json")
+ sectionsJson Json @map("sections_json")
/// v1.28.16 — the authoritative "documents-only" flag, frozen write-once at
/// create. When true the public view serves ONLY the attached documents and
/// the aggregator is never called, INDEPENDENT of `sectionsJson`. Decoupling
@@ -5439,7 +5445,7 @@ model ClinicianShareLink {
/// documents-only link (the previous derived check would have leaked it once
/// the new key defaulted on). Legacy links (pre-column) keep the derived
/// all-off fallback in the view loader.
- documentOnly Boolean @default(false) @map("document_only")
+ documentOnly Boolean @default(false) @map("document_only")
/// REQUIRED — no never-expiring share (capped at `SHARE_LINK_MAX_DAYS`).
expiresAt DateTime @map("expires_at")
@@ -5543,38 +5549,38 @@ model InsightNarrative {
// revision owns generation. A strictly newer arrival atomically invalidates
// stale generation state; equal or older deliveries are deduplicated.
model ArrivalReaction {
- id String @id @default(cuid())
- userId String @map("user_id")
+ id String @id @default(cuid())
+ userId String @map("user_id")
/// One of `ARRIVAL_KINDS` (`@/lib/arrivals/types`).
- kind String
+ kind String
/// The user's PROFILE-timezone YYYY-MM-DD this reaction is filed under.
- localDate String @map("local_date")
+ localDate String @map("local_date")
/// Timestamp of the newest sample that triggered it.
- occurredAt DateTime @map("occurred_at")
+ occurredAt DateTime @map("occurred_at")
/// When the newest sample actually reached HealthLog. Unlike `createdAt`,
/// this advances when a later same-kind arrival refreshes the daily marker.
- arrivedAt DateTime @default(now()) @map("arrived_at")
+ arrivedAt DateTime @default(now()) @map("arrived_at")
/// Kind-scoped referent: the workout id for `workout`, the panel day for labs.
- refId String? @map("ref_id")
+ refId String? @map("ref_id")
/// AES-256-GCM ciphertext of the generated reaction line. Null until — and
/// unless — a line is generated: the marker is the feature, the sentence is
/// garnish, and a provider-less install never fills this in. Follows the
/// `InsightNarrative.encryptedContent` precedent, NOT the plaintext pattern
/// the status cards use. Registered in `scripts/rotate-encryption-key.ts`.
- lineEncrypted Bytes? @map("line_encrypted")
- generatedAt DateTime? @map("generated_at")
+ lineEncrypted Bytes? @map("line_encrypted")
+ generatedAt DateTime? @map("generated_at")
/// Opaque owner token and lease for pre-provider setup. A stale worker may
/// reclaim only while `generationProviderInvokedAt` is null.
- generationClaimId String? @map("generation_claim_id")
- generationClaimedAt DateTime? @map("generation_claimed_at")
+ generationClaimId String? @map("generation_claim_id")
+ generationClaimedAt DateTime? @map("generation_claimed_at")
/// The token reservation is written in the same transaction as the
/// `coach_usage` increment. A reclaimed pre-provider attempt reuses it.
- generationReservedTokens Int? @map("generation_reserved_tokens")
- generationBudgetDateKey String? @map("generation_budget_date_key")
+ generationReservedTokens Int? @map("generation_reserved_tokens")
+ generationBudgetDateKey String? @map("generation_budget_date_key")
/// Durable provider boundary. Once set, this marker is terminal unless the
/// encrypted line is committed; no retry may invoke a provider again.
- generationProviderInvokedAt DateTime? @map("generation_provider_invoked_at")
- createdAt DateTime @default(now()) @map("created_at")
+ generationProviderInvokedAt DateTime? @map("generation_provider_invoked_at")
+ createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
diff --git a/src/app/api/medications/[id]/intake/import/[jobId]/status/route.ts b/src/app/api/medications/[id]/intake/import/[jobId]/status/route.ts
index 0f8e0a4a0..cddd97cf4 100644
--- a/src/app/api/medications/[id]/intake/import/[jobId]/status/route.ts
+++ b/src/app/api/medications/[id]/intake/import/[jobId]/status/route.ts
@@ -2,8 +2,8 @@ import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
-import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
+import { readMedicationIntakeImportJob } from "@/lib/medications/intake-import-job-status";
interface RouteContext {
params: Promise<{ id: string; jobId: string }>;
@@ -20,20 +20,12 @@ export const GET = apiHandler(
meta: { job_id: jobId, medication_id: medicationId },
});
- const row = await prisma.medicationIntakeImportJob.findFirst({
- where: { id: jobId, medicationId, userId: user.id },
- });
- if (!row) return apiError("Import job not found", 404);
-
- return apiSuccess({
- jobId: row.id,
- status: row.status,
- progress: row.progress,
- result: row.result,
- failureReason: row.failureReason,
- createdAt: row.createdAt.toISOString(),
- startedAt: row.startedAt?.toISOString() ?? null,
- completedAt: row.completedAt?.toISOString() ?? null,
- });
+ const job = await readMedicationIntakeImportJob(
+ user.id,
+ jobId,
+ medicationId,
+ );
+ if (!job) return apiError("Import job not found", 404);
+ return apiSuccess(job);
},
);
diff --git a/src/app/api/medications/intake/dose-history-import/[jobId]/status/route.ts b/src/app/api/medications/intake/dose-history-import/[jobId]/status/route.ts
new file mode 100644
index 000000000..35236eb3a
--- /dev/null
+++ b/src/app/api/medications/intake/dose-history-import/[jobId]/status/route.ts
@@ -0,0 +1,30 @@
+import type { NextRequest } from "next/server";
+
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { apiError, apiSuccess } from "@/lib/api-response";
+import { annotate } from "@/lib/logging/context";
+import { readMedicationIntakeImportJob } from "@/lib/medications/intake-import-job-status";
+
+interface RouteContext {
+ params: Promise<{ jobId: string }>;
+}
+
+export const dynamic = "force-dynamic";
+
+export const GET = apiHandler(
+ async (_request: NextRequest, { params }: RouteContext) => {
+ const { user } = await requireAuth();
+ const { jobId } = await params;
+ annotate({
+ action: { name: "medication.intake.history.import.status" },
+ meta: { job_id: jobId },
+ });
+
+ // No medication argument: this reads the account-wide job, and the helper
+ // narrows on `medicationId: null` so a per-medication job id cannot be read
+ // through here (or the other way round).
+ const job = await readMedicationIntakeImportJob(user.id, jobId);
+ if (!job) return apiError("Import job not found", 404);
+ return apiSuccess(job);
+ },
+);
diff --git a/src/app/api/medications/intake/dose-history-import/route.ts b/src/app/api/medications/intake/dose-history-import/route.ts
new file mode 100644
index 000000000..df4150bd6
--- /dev/null
+++ b/src/app/api/medications/intake/dose-history-import/route.ts
@@ -0,0 +1,317 @@
+/**
+ * Import a dose history exported from another medication tracker.
+ *
+ * The existing per-medication import takes a hand-written list of dates and
+ * times for one medication. That is what a self-hoster with a ten-column,
+ * 3,395-row export covering sixteen medications was left translating by hand,
+ * guessing at what each field meant. This route reads that file's own shape, so
+ * nobody has to.
+ *
+ * What the shape earns it: the export keeps the scheduled time and the time the
+ * dose was actually taken in two separate columns, which is exactly the pair
+ * `MedicationIntakeEvent` already models as `scheduledFor` + `takenAt`. That
+ * distinction is the first casualty of translating a file by hand, and it is the
+ * one that decides whether a dose reads as on time or late for the rest of its
+ * life.
+ *
+ * Body is the file's raw text — `text/csv` for the ten-column export,
+ * `application/json` for the documented JSON shape. Capped at 16 MB, the same
+ * ceiling the measurement CSV import carries.
+ *
+ * `?dryRun=1` parses, matches every row against the record, and returns the
+ * whole verdict WITHOUT writing: how many rows land, how many are refused and
+ * for which reason, which medication names match nothing. A bulk import of years
+ * of history should be inspectable before it happens.
+ *
+ * The write itself runs on the same background job the per-medication import
+ * uses — chunked, heartbeated, resumable, with the rollup recompute chunked
+ * behind it. A path that works on 28 rows and dies on 30,000 is not an import.
+ */
+import { NextRequest } from "next/server";
+
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
+import { auditLog } from "@/lib/auth/audit";
+import { prisma, toJson } from "@/lib/db";
+import { getGlobalBoss } from "@/lib/jobs/boss-instance";
+import {
+ MEDICATION_INTAKE_IMPORT_QUEUE,
+ MEDICATION_INTAKE_IMPORT_SEND_OPTIONS,
+ MEDICATION_INTAKE_IMPORT_STALE_AFTER_MS,
+ groupMedicationImportSkips,
+ totalMedicationImportSkips,
+ type MedicationImportPayload,
+ type MedicationImportProgress,
+} from "@/lib/jobs/medication-intake-import";
+import { annotate } from "@/lib/logging/context";
+import {
+ parseAutoExportCsv,
+ parseAutoExportJson,
+} from "@/lib/medications/import/auto-export-parse";
+import {
+ planAutoExportImport,
+ type AutoExportPlan,
+} from "@/lib/medications/import/auto-export-plan";
+import { checkRateLimit } from "@/lib/rate-limit";
+
+/** Same ceiling as the measurement CSV import. */
+const MAX_BYTES = 16 * 1024 * 1024;
+
+/**
+ * Rows the importer will queue in one submission.
+ *
+ * The write loop reads the whole payload out of the job row on every chunk, so
+ * the cost of a submission grows with rows × chunks. At 30,000 rows that is a
+ * few hundred megabytes of read across a couple of hundred short transactions —
+ * slow but bounded, heartbeated, and resumable. Beyond it the shape stops being
+ * the right one, and saying so is better than accepting a file that then crawls.
+ */
+const MAX_QUEUED_ROWS = 30_000;
+
+/**
+ * Names echoed back so the person can act on them. Bounded: an unmatched name is
+ * a hint, and a file naming five hundred unknown medications needs a different
+ * conversation than a longer list.
+ */
+const MAX_NAMES_REPORTED = 50;
+
+/** Blast-radius bucket shared with every other bulk import surface. */
+const RATE_LIMIT_MAX = 5;
+const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
+
+function cap(names: readonly string[]): string[] {
+ return names.slice(0, MAX_NAMES_REPORTED);
+}
+
+/** The file-level verdict, identical for a dry run and a real submission. */
+function describePlan(plan: AutoExportPlan) {
+ return {
+ rowsRead: plan.rowsRead,
+ queued: plan.entries.length,
+ refused: totalMedicationImportSkips(plan.skippedByReason),
+ refusedByReason: groupMedicationImportSkips(plan.skippedByReason),
+ unmatchedMedications: cap(plan.unmatchedMedications),
+ ambiguousMedications: cap(plan.ambiguousMedications),
+ mirroredMedications: cap(plan.mirroredMedications),
+ unknownColumns: cap(plan.unknownColumns),
+ codingsNotRead: plan.csvCodingsIgnored,
+ fromArchivedMedications: plan.fromArchivedMedications,
+ };
+}
+
+export const POST = apiHandler(async (request: NextRequest) => {
+ const { user } = await requireAuth();
+ const dryRunParam = new URL(request.url).searchParams.get("dryRun");
+ const dryRun = dryRunParam === "1" || dryRunParam === "true";
+ annotate({
+ action: { name: "medication.intake.history.import" },
+ meta: { dry_run: dryRun },
+ });
+
+ const rl = await checkRateLimit(
+ `import:${user.id}`,
+ RATE_LIMIT_MAX,
+ RATE_LIMIT_WINDOW_MS,
+ );
+ if (!rl.allowed) {
+ return apiError(`Maximum ${RATE_LIMIT_MAX} imports per hour`, 429);
+ }
+
+ const contentType = (request.headers.get("content-type") ?? "").toLowerCase();
+ const isJson = contentType.includes("application/json");
+ const isText =
+ contentType.includes("text/csv") || contentType.includes("text/plain");
+ if (!isJson && !isText) {
+ return apiError("Content-Type must be text/csv or application/json", 415);
+ }
+
+ const declaredLength = Number(request.headers.get("content-length") ?? "0");
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_BYTES) {
+ return apiError("File exceeds the 16 MB limit", 413);
+ }
+ let text: string;
+ try {
+ text = await request.text();
+ } catch {
+ return apiError("Could not read the request body", 400);
+ }
+ if (Buffer.byteLength(text, "utf8") > MAX_BYTES) {
+ return apiError("File exceeds the 16 MB limit", 413);
+ }
+
+ const parsed = isJson ? parseAutoExportJson(text) : parseAutoExportCsv(text);
+ if (parsed.fatal) {
+ annotate({
+ action: { name: "medication.intake.history.import.refused" },
+ meta: { reason: parsed.fatal.reason },
+ });
+ // The reason travels as a machine-readable code so the client can render the
+ // sentence that names the actual problem — a JSON export that carries no
+ // intake time needs a different answer than a truncated file.
+ return apiError("The file could not be read", 422, {
+ errorCode: parsed.fatal.reason,
+ ...(parsed.fatal.detail === undefined
+ ? {}
+ : { detail: parsed.fatal.detail }),
+ });
+ }
+
+ const medications = await prisma.medication.findMany({
+ where: { userId: user.id },
+ // Every medication, active or not: a dose taken in 2023 belongs to the
+ // medication whether or not the person has since stopped taking it.
+ select: { id: true, name: true, externalSource: true },
+ });
+ const plan = planAutoExportImport(parsed, medications);
+
+ if (plan.entries.length > MAX_QUEUED_ROWS) {
+ return apiError(
+ `The file holds ${plan.entries.length} importable rows — the limit is ${MAX_QUEUED_ROWS} per import`,
+ 422,
+ { errorCode: "too_many_rows" },
+ );
+ }
+
+ const file = describePlan(plan);
+ if (dryRun) {
+ return apiSuccess({ dryRun: true, jobId: null, statusUrl: null, file });
+ }
+
+ const payload: MedicationImportPayload = { entries: plan.entries };
+ // Rows refused before the queue are seeded into the progress the worker
+ // advances, so the count the person reads at the end covers the whole file and
+ // not just the part that reached the database.
+ const progress: MedicationImportProgress = {
+ processed: 0,
+ total: plan.entries.length,
+ imported: 0,
+ skippedByReason: plan.skippedByReason,
+ touchedDays: [],
+ rollupProcessed: 0,
+ };
+
+ const admission = await prisma.$transaction(async (tx) => {
+ await tx.$queryRaw`
+ SELECT "id"
+ FROM "users"
+ WHERE "id" = ${user.id}
+ FOR UPDATE
+ `;
+ const now = new Date();
+ const staleBefore = new Date(
+ now.getTime() - MEDICATION_INTAKE_IMPORT_STALE_AFTER_MS,
+ );
+ await tx.medicationIntakeImportJob.updateMany({
+ where: {
+ userId: user.id,
+ medicationId: null,
+ status: { in: ["queued", "running"] },
+ OR: [
+ { heartbeatAt: { lt: staleBefore } },
+ { heartbeatAt: null, startedAt: { lt: staleBefore } },
+ {
+ heartbeatAt: null,
+ startedAt: null,
+ createdAt: { lt: staleBefore },
+ },
+ ],
+ },
+ data: {
+ status: "failed",
+ failureReason: "Medication intake import abandoned",
+ heartbeatAt: now,
+ completedAt: now,
+ },
+ });
+ const activeJob = await tx.medicationIntakeImportJob.findFirst({
+ where: {
+ userId: user.id,
+ medicationId: null,
+ status: { in: ["queued", "running"] },
+ },
+ select: { id: true },
+ });
+ if (activeJob) return { active: true as const };
+
+ const importJob = await tx.medicationIntakeImportJob.create({
+ data: {
+ userId: user.id,
+ medicationId: null,
+ status: "queued",
+ payload: toJson(payload),
+ progress: toJson(progress),
+ },
+ });
+ return { active: false as const, importJob };
+ });
+ if (admission.active) {
+ return apiError("A dose-history import is already in progress", 409);
+ }
+ const { importJob } = admission;
+
+ const statusUrl = `/api/medications/intake/dose-history-import/${importJob.id}/status`;
+ let bossJobId: string | null = null;
+ try {
+ const boss = getGlobalBoss();
+ if (!boss) throw new Error("worker unavailable");
+ bossJobId = await boss.send(
+ MEDICATION_INTAKE_IMPORT_QUEUE,
+ { jobId: importJob.id },
+ MEDICATION_INTAKE_IMPORT_SEND_OPTIONS,
+ );
+ if (!bossJobId) throw new Error("queue rejected job");
+ await prisma.medicationIntakeImportJob.update({
+ where: { id: importJob.id },
+ data: { pgBossJobId: bossJobId },
+ });
+ } catch {
+ const completedAt = new Date();
+ await prisma.medicationIntakeImportJob.update({
+ where: { id: importJob.id },
+ data: {
+ status: "failed",
+ failureReason: "Background worker enqueue failed",
+ heartbeatAt: completedAt,
+ completedAt,
+ },
+ });
+ await auditLog("medication.intake.import.kickoff.denied", {
+ userId: user.id,
+ ipAddress: getClientIp(request),
+ details: { jobId: importJob.id, reason: "enqueue_failed" },
+ });
+ return apiError("Background worker is not available", 503);
+ }
+
+ await auditLog("medication.intake.history.import.kickoff", {
+ userId: user.id,
+ ipAddress: getClientIp(request),
+ details: {
+ jobId: importJob.id,
+ bossJobId,
+ rowsRead: file.rowsRead,
+ queued: file.queued,
+ refused: file.refused,
+ },
+ });
+ annotate({
+ action: { name: "medication.intake.history.import.kickoff" },
+ meta: {
+ job_id: importJob.id,
+ rows_read: file.rowsRead,
+ queued: file.queued,
+ refused: file.refused,
+ },
+ });
+
+ return apiSuccess(
+ {
+ dryRun: false,
+ jobId: importJob.id,
+ status: "queued" as const,
+ statusUrl,
+ file,
+ },
+ 202,
+ );
+});
diff --git a/src/lib/jobs/medication-intake-import.ts b/src/lib/jobs/medication-intake-import.ts
index 1cc758146..5866b1f65 100644
--- a/src/lib/jobs/medication-intake-import.ts
+++ b/src/lib/jobs/medication-intake-import.ts
@@ -24,8 +24,28 @@ export const MEDICATION_INTAKE_IMPORT_SEND_OPTIONS = {
} as const;
export interface MedicationImportEntry {
- takenAt: string;
+ /**
+ * The slot instant this dose belongs to, and the fact it is identified by.
+ * Equals `takenAt` for an ad-hoc dose that belongs to no slot.
+ */
+ scheduledFor: string;
+ /** When the dose was actually taken. `null` for a deliberate skip. */
+ takenAt: string | null;
idempotencyKey: string;
+ /**
+ * The medication this dose attaches to. Carried per entry because a dose
+ * history exported from another app covers a whole regimen in one file, so the
+ * job cannot be scoped to a single medication the way the per-medication
+ * import route's job is. Absent on that route's jobs, where the job row names
+ * the medication instead.
+ */
+ medicationId?: string;
+ /**
+ * Per-dose override, free text, mirroring `Medication.dose`. Absent when the
+ * configured schedule dose applies — which is the overwhelming majority, and
+ * absence is how `MedicationIntakeEvent.doseTaken` already says so.
+ */
+ doseTaken?: string;
}
export interface MedicationImportPayload {
@@ -33,22 +53,40 @@ export interface MedicationImportPayload {
}
/**
- * Why a submitted entry did not become a row. Machine-readable; the client
- * turns each into a sentence.
+ * Why a submitted row did not become a row on the ledger. Machine-readable; the
+ * client turns each into a sentence.
*
- * `duplicate_in_file` — two entries in the same submission resolve to the same
- * instant, so only one can be written.
- * `already_recorded` — the dose is already on the ledger for that instant, from
- * an earlier import or from any other intake surface.
+ * `duplicate_in_file` — two rows of the same submission resolve to the same
+ * medication and slot, so only one can be written.
+ * `already_recorded` — the dose is already on the ledger for that slot, from an
+ * earlier import or from any other intake surface.
*
- * They used to be one `skippedDuplicates` number, which is how a file whose
- * every row collapsed onto one key could report "27 duplicates skipped" about
- * 27 doses that had no duplicate anywhere. Naming the reason is what makes the
- * count checkable against what the person actually submitted.
+ * Those two used to be one `skippedDuplicates` number, which is how a file whose
+ * every row collapsed onto one key could report "27 duplicates skipped" about 27
+ * doses that had no duplicate anywhere. Naming the reason is what makes the count
+ * checkable against what the person actually submitted.
+ *
+ * The rest are decided before the queue, when a whole export file is parsed and
+ * matched against the record. They are seeded into the job's progress so the
+ * final count covers the entire file rather than only the part that reached the
+ * database — a row refused at the door is still a row the person needs told
+ * about, by name.
*/
export const MEDICATION_IMPORT_SKIP_REASONS = [
"duplicate_in_file",
"already_recorded",
+ "medication_not_found",
+ "medication_ambiguous",
+ "medication_is_mirrored",
+ "status_not_recorded",
+ "status_unknown",
+ "missing_timestamp",
+ "missing_timezone_offset",
+ "unreadable_timestamp",
+ "implausible_timestamp",
+ "missing_medication",
+ "unreadable_dosage",
+ "unreadable_row",
] as const;
export type MedicationImportSkipReason =
@@ -197,16 +235,49 @@ function parsePayload(value: Prisma.JsonValue): MedicationImportPayload {
typeof entry !== "object" ||
entry === null ||
Array.isArray(entry) ||
- typeof entry.takenAt !== "string" ||
- !Number.isFinite(Date.parse(entry.takenAt)) ||
typeof entry.idempotencyKey !== "string" ||
entry.idempotencyKey.length === 0
) {
throw new Error("Medication intake import entry is malformed");
}
+ const takenAt = entry.takenAt;
+ if (
+ takenAt !== null &&
+ (typeof takenAt !== "string" || !Number.isFinite(Date.parse(takenAt)))
+ ) {
+ throw new Error("Medication intake import entry is malformed");
+ }
+ // `scheduledFor` was added when the importer learnt to read an export that
+ // states the scheduled time and the time taken as two separate facts. A job
+ // row enqueued before that carries only `takenAt`, and such a row is an
+ // ad-hoc dose anchored on itself — which is what the absence means. This is
+ // for rows already in the database, not for a caller.
+ const rawScheduledFor =
+ entry.scheduledFor === undefined ? takenAt : entry.scheduledFor;
+ if (
+ typeof rawScheduledFor !== "string" ||
+ !Number.isFinite(Date.parse(rawScheduledFor))
+ ) {
+ throw new Error("Medication intake import entry is malformed");
+ }
+ if (
+ entry.medicationId !== undefined &&
+ (typeof entry.medicationId !== "string" ||
+ entry.medicationId.length === 0)
+ ) {
+ throw new Error("Medication intake import entry is malformed");
+ }
+ if (entry.doseTaken !== undefined && typeof entry.doseTaken !== "string") {
+ throw new Error("Medication intake import entry is malformed");
+ }
return {
- takenAt: entry.takenAt,
+ scheduledFor: rawScheduledFor,
+ takenAt: takenAt as string | null,
idempotencyKey: entry.idempotencyKey,
+ ...(entry.medicationId === undefined
+ ? {}
+ : { medicationId: entry.medicationId }),
+ ...(entry.doseTaken === undefined ? {} : { doseTaken: entry.doseTaken }),
};
});
return { entries };
@@ -287,23 +358,64 @@ 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 ` ` 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,
- medicationId: string,
+ jobMedicationId: string | null,
timezone: string,
days: readonly string[],
): Promise {
await Promise.all(
- days.map((day) =>
- recomputeMedicationComplianceForDay(
+ days.map((day) => {
+ const decoded = decodeTouchedDay(day, jobMedicationId);
+ if (!decoded) return Promise.resolve();
+ return recomputeMedicationComplianceForDay(
userId,
- medicationId,
- day,
+ decoded.medicationId,
+ decoded.dayKey,
timezone,
client,
- ),
- ),
+ );
+ }),
);
}
@@ -426,35 +538,81 @@ async function processNextChunk(
entries.map((entry) => [entry.idempotencyKey, entry]),
).values(),
];
+ // An entry names its own medication when the job came from an export file
+ // covering a whole regimen; the per-medication route's jobs name it on the
+ // job row instead. One of the two must be there, and a payload with
+ // neither is a malformed job rather than a row to write against a guess.
+ const resolveMedicationId = (entry: MedicationImportEntry): string => {
+ const medicationId = entry.medicationId ?? row.medicationId;
+ if (!medicationId) {
+ throw new Error("Medication intake import entry names no medication");
+ }
+ return medicationId;
+ };
+
const created = await tx.medicationIntakeEvent.createManyAndReturn({
data: uniqueEntries.map((entry) => {
- const takenAt = new Date(entry.takenAt);
+ const scheduledFor = new Date(entry.scheduledFor);
+ const takenAt =
+ entry.takenAt === null ? null : new Date(entry.takenAt);
return {
userId: row.userId,
- medicationId: row.medicationId,
- scheduledFor: takenAt,
+ medicationId: resolveMedicationId(entry),
+ // The scheduled slot and the time taken are two separate facts, and
+ // the export states both. Writing the take into both columns — which
+ // is all the old two-field payload could express — asserts every
+ // dose landed exactly on time, and no later edit recovers what that
+ // overwrote.
+ scheduledFor,
takenAt,
- skipped: false,
+ // A null take on an imported row is a deliberate skip: the source
+ // recorded that the person chose not to take that dose.
+ skipped: takenAt === null,
source: "IMPORT" as const,
idempotencyKey: entry.idempotencyKey,
+ ...(entry.doseTaken === undefined
+ ? {}
+ : { doseTaken: entry.doseTaken }),
};
}),
skipDuplicates: true,
- select: { id: true, takenAt: true, scheduledFor: true },
+ select: {
+ id: true,
+ medicationId: true,
+ takenAt: true,
+ scheduledFor: true,
+ },
});
- await consumeImportedIntakesBatch({
- client: tx,
- userId: row.userId,
- medicationId: row.medicationId,
- events: created.map((event) => ({
- eventId: event.id,
- intakeAt: event.takenAt ?? event.scheduledFor,
- })),
- });
+ // Inventory is per medication and a skip consumes nothing, so the created
+ // rows are grouped and the skips left out. `consumeImportedIntakesBatch`
+ // asserts it claimed every event handed to it, which only holds for rows
+ // that actually carry a take.
+ const consumedByMedication = new Map<
+ string,
+ Array<{ eventId: string; intakeAt: Date }>
+ >();
+ for (const event of created) {
+ if (event.takenAt === null) continue;
+ const bucket = consumedByMedication.get(event.medicationId);
+ const entry = { eventId: event.id, intakeAt: event.takenAt };
+ if (bucket) bucket.push(entry);
+ else consumedByMedication.set(event.medicationId, [entry]);
+ }
+ for (const [medicationId, events] of consumedByMedication) {
+ await consumeImportedIntakesBatch({
+ client: tx,
+ userId: row.userId,
+ medicationId,
+ events,
+ });
+ }
const touchedDays = created.map((event) =>
- dayKeyForScheduledFor(event.scheduledFor, row.user.timezone),
+ encodeTouchedDay(
+ event.medicationId,
+ dayKeyForScheduledFor(event.scheduledFor, row.user.timezone),
+ ),
);
// Two different facts, counted apart. The Map collapse above is entries
// of this submission that resolve to the same instant; `skipDuplicates`
diff --git a/src/lib/medications/import/auto-export-format.ts b/src/lib/medications/import/auto-export-format.ts
new file mode 100644
index 000000000..2cfbf4cff
--- /dev/null
+++ b/src/lib/medications/import/auto-export-format.ts
@@ -0,0 +1,180 @@
+/**
+ * The medication dose-history export format, read directly.
+ *
+ * A self-hoster was hand-mapping this export into the intake importer's
+ * two-field shape and guessing at what belonged where. Hand-mapping is what
+ * destroyed the part that matters: the export keeps the scheduled time and the
+ * time actually taken as two separate columns, which is exactly the pair
+ * `MedicationIntakeEvent` models as `scheduledFor` + `takenAt`. Flattening the
+ * file collapses them into one.
+ *
+ * Two shapes exist. The CSV carries ten columns; the JSON carries the
+ * documented per-dose object. Both are read here, and every column gets a
+ * verdict that the importing person can read before choosing a file — a column
+ * dropped in silence is the failure this format work exists to remove.
+ *
+ * Nothing in this module touches a database. It is the vocabulary and the
+ * verdicts; `auto-export-parse.ts` applies them.
+ */
+
+/** The CSV header, in the order the export writes it. */
+export const AUTO_EXPORT_CSV_COLUMNS = [
+ "Date",
+ "Scheduled Date",
+ "Medication",
+ "Nickname",
+ "Dosage",
+ "Scheduled Dosage",
+ "Unit",
+ "Status",
+ "Archived",
+ "Codings",
+] as const;
+
+export type AutoExportCsvColumn = (typeof AUTO_EXPORT_CSV_COLUMNS)[number];
+
+/**
+ * What the importer does with a column.
+ *
+ * `honoured` — the value reaches the record, or decides whether a row does.
+ * `reported` — read, never stored; it appears in the run summary so the
+ * person can see what the file said.
+ * `ignored` — deliberately not read, with a stated reason.
+ */
+export type AutoExportColumnVerdict = "honoured" | "reported" | "ignored";
+
+export interface AutoExportColumnRuling {
+ column: string;
+ verdict: AutoExportColumnVerdict;
+ /**
+ * Stable key for the sentence explaining the ruling. The UI resolves it with
+ * a literal `t()` call per key so the i18n call-site guard sees every one.
+ */
+ noteKey: string;
+ /** True for a field only the JSON shape carries. */
+ jsonOnly?: boolean;
+}
+
+/**
+ * Every field of both shapes with its ruling. Exhaustive by construction: a
+ * structural test asserts each CSV column and each documented JSON field
+ * appears exactly once, so a column cannot be added to the parser without a
+ * verdict landing here.
+ */
+export const AUTO_EXPORT_COLUMN_RULINGS: readonly AutoExportColumnRuling[] = [
+ { column: "Date", verdict: "honoured", noteKey: "date" },
+ { column: "Scheduled Date", verdict: "honoured", noteKey: "scheduledDate" },
+ { column: "Medication", verdict: "honoured", noteKey: "medication" },
+ { column: "Nickname", verdict: "honoured", noteKey: "nickname" },
+ { column: "Dosage", verdict: "honoured", noteKey: "dosage" },
+ {
+ column: "Scheduled Dosage",
+ verdict: "honoured",
+ noteKey: "scheduledDosage",
+ },
+ { column: "Unit", verdict: "honoured", noteKey: "unit" },
+ { column: "Status", verdict: "honoured", noteKey: "status" },
+ { column: "Archived", verdict: "reported", noteKey: "archived" },
+ { column: "Codings", verdict: "ignored", noteKey: "codings" },
+ { column: "form", verdict: "ignored", noteKey: "form", jsonOnly: true },
+ { column: "start", verdict: "ignored", noteKey: "start", jsonOnly: true },
+ { column: "end", verdict: "ignored", noteKey: "end", jsonOnly: true },
+] as const;
+
+/**
+ * Documented JSON field names. `displayText` is the JSON spelling of the CSV's
+ * `Medication`, `isArchived` of `Archived`, `scheduledDate` of `Scheduled
+ * Date`; the CSV's `Scheduled Dosage` has no JSON counterpart at all.
+ */
+export const AUTO_EXPORT_JSON_FIELDS = [
+ "displayText",
+ "nickname",
+ "form",
+ "start",
+ "end",
+ "scheduledDate",
+ "status",
+ "isArchived",
+ "dosage",
+ "codings",
+] as const;
+
+/**
+ * Every status the format documents.
+ *
+ * `Taken` and `Skipped` each name a decision the person made about a dose, and
+ * HealthLog already models both — a taken row and a deliberate skip. The rest
+ * name the absence of a decision: nobody acted, or the app never asked. There
+ * is no honest row to write for those. Turning "nobody acted" into a missed
+ * dose would be the importer inventing a compliance fact the export never
+ * asserted, and writing them as still-pending would put outstanding doses from
+ * 2023 back in front of the person today.
+ */
+export const AUTO_EXPORT_STATUSES = [
+ "Taken",
+ "Skipped",
+ "Not Interacted",
+ "Notification Not Sent",
+ "Snoozed",
+ "Not Logged",
+ "Unspecified",
+] as const;
+
+export type AutoExportStatus = (typeof AUTO_EXPORT_STATUSES)[number];
+
+/** The two statuses that become a row. */
+export const AUTO_EXPORT_ACTIONABLE_STATUSES = ["Taken", "Skipped"] as const;
+
+export function isAutoExportStatus(value: string): value is AutoExportStatus {
+ return (AUTO_EXPORT_STATUSES as readonly string[]).includes(value);
+}
+
+export function isActionableAutoExportStatus(value: string): boolean {
+ return (AUTO_EXPORT_ACTIONABLE_STATUSES as readonly string[]).includes(value);
+}
+
+/**
+ * Where the export's codings WOULD land, and why they do not land there today.
+ *
+ * The format documents `codings` as `[{ code, system, version }]` with systems
+ * such as RxNorm and NDC. HealthLog already holds two of those: `atcCode`
+ * (WHO ATC) and `rxNormCode` (RxCUI) on `Medication`. A code is a far stronger
+ * identity than a display name someone may have typed differently in each app,
+ * so it is the natural way to match a row to a medication.
+ *
+ * It is not read, for two reasons that are about evidence rather than effort.
+ * The CSV shape — the only shape that carries a dose history at all — gives the
+ * `Codings` column no documented encoding, and the sample export leaves it empty
+ * on every row, so there is nothing to derive a reader from and no way to test
+ * one. The JSON shape does document the structure but carries no intake time, so
+ * it is refused whole. A populated `Codings` cell is therefore counted and
+ * reported rather than guessed at.
+ *
+ * If a shape turns up that carries both, these are the columns waiting for it —
+ * for matching only. `atcCode` and `rxNormCode` are asserted by the person or
+ * their clinician, and an import file is neither.
+ */
+
+/**
+ * Fold a dose amount and its unit into the free-text per-dose override.
+ *
+ * `count` is the export's word for "this many of the configured dose" and
+ * carries nothing about substance amount, so the number stands alone. Any other
+ * unit is echoed exactly as the file wrote it — `0.4 mL`, not a translated or
+ * re-cased guess at which of HealthLog's own unit keys was meant.
+ */
+export function formatAutoExportDose(amount: number, unit: string): string {
+ const trimmed = unit.trim();
+ // `String` already drops a trailing `.0`, which is how the export writes
+ // whole amounts: `1.0` becomes `1`, `0.25` stays `0.25`.
+ const rendered = String(amount);
+ if (trimmed.length === 0 || trimmed.toLowerCase() === "count") {
+ return rendered;
+ }
+ return `${rendered} ${trimmed}`;
+}
+
+/** Normalise a medication name for matching: trimmed, folded, case-blind. */
+export function normaliseMedicationName(value: string): string {
+ return value.normalize("NFKC").trim().replace(/\s+/g, " ").toLowerCase();
+}
diff --git a/src/lib/medications/import/auto-export-instant.ts b/src/lib/medications/import/auto-export-instant.ts
new file mode 100644
index 000000000..767bda7d9
--- /dev/null
+++ b/src/lib/medications/import/auto-export-instant.ts
@@ -0,0 +1,127 @@
+/**
+ * Reading an instant that carries its own UTC offset.
+ *
+ * The medication export writes `2023-02-16 08:38:00 +1030` — a space instead
+ * of `T`, and a compact offset with no colon. Neither form is ISO 8601, so
+ * `new Date(...)` on it is engine-dependent leniency rather than a contract:
+ * V8 happens to accept it, another runtime need not, and a silent `Invalid
+ * Date` or a reinterpretation in the server's zone would move every dose in
+ * the file by hours. A file spanning an Australian summer carries `+1030` and
+ * `+0930` on adjacent rows, so the offset is per-row data, not a file-level
+ * setting to be guessed at once.
+ *
+ * So the offset is read explicitly and the epoch computed arithmetically:
+ * `Date.UTC(wall clock) - offset`. Nothing here parses a date from a string
+ * through the platform. A value without an offset is refused rather than
+ * assumed — the server's zone is not evidence about where the dose was taken.
+ *
+ * `src/lib/tz/` is the project's timezone layer and it stays the authority for
+ * the other direction (an instant plus a zone name to a local day key, which
+ * is what the compliance rollups key on). It carries no parser, by design: it
+ * converts an offset-less wall clock plus a zone NAME to UTC. An offset is not
+ * a zone name, so this is the one thing it cannot do, and this module is
+ * deliberately the narrowest possible complement to it.
+ */
+
+/** Widest real UTC offset in either direction (±14:00, Kiritimati). */
+const MAX_OFFSET_MINUTES = 14 * 60;
+
+/**
+ * `YYYY-MM-DD` `HH:MM:SS` with an optional fractional second, then either `Z`
+ * or a `±HHMM` / `±HH:MM` offset. Both the space and the `T` separator are
+ * accepted: the CSV uses a space, and a hand-edited file may well use `T`.
+ */
+const OFFSET_INSTANT_RE =
+ /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?\s*(?:(Z|z)|([+-])(\d{2}):?(\d{2}))$/;
+
+export type AutoExportInstantFailure =
+ /** The cell was blank. Distinct from unreadable: nothing was stated. */
+ | "absent"
+ /** Shaped like a timestamp but carries no offset, so the instant is unknown. */
+ | "missing_offset"
+ /** Does not match the documented `yyyy-MM-dd HH:mm:ss Z` shape at all. */
+ | "unreadable"
+ /** Matches the shape but names no real instant (month 13, offset ±99:00). */
+ | "out_of_range";
+
+export type AutoExportInstantResult =
+ | { ok: true; instant: Date; offsetMinutes: number }
+ | { ok: false; failure: AutoExportInstantFailure };
+
+/**
+ * True when the value looks like a date-time that simply lost its offset, so
+ * the refusal can say "no offset" rather than the less useful "unreadable".
+ */
+const OFFSETLESS_INSTANT_RE =
+ /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2})?(\.\d{1,9})?$/;
+
+/**
+ * Parse one `Date` / `Scheduled Date` cell.
+ *
+ * Returns the absolute instant and the offset that produced it. The offset is
+ * handed back rather than discarded so a caller can prove, in a test, that the
+ * value the file stated is the value that survived.
+ */
+export function parseAutoExportInstant(
+ raw: string | null | undefined,
+): AutoExportInstantResult {
+ const value = (raw ?? "").trim();
+ if (value.length === 0) return { ok: false, failure: "absent" };
+
+ const match = OFFSET_INSTANT_RE.exec(value);
+ if (!match) {
+ return {
+ ok: false,
+ failure: OFFSETLESS_INSTANT_RE.test(value)
+ ? "missing_offset"
+ : "unreadable",
+ };
+ }
+
+ const [, y, mo, d, h, mi, s, zulu, sign, offH, offM] = match;
+ const year = Number(y);
+ const month = Number(mo);
+ const day = Number(d);
+ const hour = Number(h);
+ const minute = Number(mi);
+ const second = Number(s);
+
+ if (month < 1 || month > 12 || day < 1 || day > 31) {
+ return { ok: false, failure: "out_of_range" };
+ }
+ if (hour > 23 || minute > 59 || second > 59) {
+ return { ok: false, failure: "out_of_range" };
+ }
+
+ let offsetMinutes = 0;
+ if (!zulu) {
+ const offsetHours = Number(offH);
+ const offsetRemainder = Number(offM);
+ if (offsetRemainder > 59) return { ok: false, failure: "out_of_range" };
+ offsetMinutes =
+ (sign === "-" ? -1 : 1) * (offsetHours * 60 + offsetRemainder);
+ if (Math.abs(offsetMinutes) > MAX_OFFSET_MINUTES) {
+ return { ok: false, failure: "out_of_range" };
+ }
+ }
+
+ // `Date.UTC` rolls a nonexistent calendar day over (Feb 30 becomes Mar 2),
+ // so the components are read back and compared. A rolled-over value means
+ // the file named a day that does not exist, which is a refusal, not a
+ // silently shifted dose.
+ const wallClockMs = Date.UTC(year, month - 1, day, hour, minute, second);
+ const probe = new Date(wallClockMs);
+ if (
+ probe.getUTCFullYear() !== year ||
+ probe.getUTCMonth() !== month - 1 ||
+ probe.getUTCDate() !== day
+ ) {
+ return { ok: false, failure: "out_of_range" };
+ }
+
+ const instant = new Date(wallClockMs - offsetMinutes * 60_000);
+ if (Number.isNaN(instant.getTime())) {
+ return { ok: false, failure: "out_of_range" };
+ }
+ return { ok: true, instant, offsetMinutes };
+}
diff --git a/src/lib/medications/import/auto-export-parse.ts b/src/lib/medications/import/auto-export-parse.ts
new file mode 100644
index 000000000..99d55f5e2
--- /dev/null
+++ b/src/lib/medications/import/auto-export-parse.ts
@@ -0,0 +1,452 @@
+/**
+ * The medication dose-history export, from file text to per-row facts.
+ *
+ * Pure and synchronous: no database, no clock beyond an injectable `now`.
+ *
+ * Every row is either a dose or a named refusal. There is no third outcome, and
+ * nothing is dropped between the two: a test asserts that
+ * `doses.length + refusals.length` equals the number of data rows read.
+ *
+ * Only the CSV shape carries a dose history. The documented JSON shape is read
+ * too, and refused, for the reason set out in {@link parseAutoExportJson} — a
+ * refusal that says why is worth more than a lossy import.
+ */
+import {
+ ENTRY_INSTANT_FAR_PAST,
+ isPlausibleEntryInstant,
+} from "@/lib/validations/entry-instant";
+import { splitCsvRows } from "@/lib/import/csv-measurements";
+
+import {
+ AUTO_EXPORT_CSV_COLUMNS,
+ formatAutoExportDose,
+ isActionableAutoExportStatus,
+ isAutoExportStatus,
+} from "./auto-export-format";
+import {
+ parseAutoExportInstant,
+ type AutoExportInstantFailure,
+} from "./auto-export-instant";
+
+/** One dose the file states, in HealthLog's own terms. */
+export interface AutoExportDose {
+ /** 1-based source line, so a refusal can be pointed at. */
+ line: number;
+ /** The name as written, kept verbatim for the unmatched-medication report. */
+ medicationName: string;
+ /** The export's optional second name for the same medication. */
+ nickname: string | null;
+ /**
+ * The slot this dose belongs to. The file's `Scheduled Date` when it states
+ * one; otherwise the actual instant, which is how HealthLog already anchors
+ * an ad-hoc dose that belongs to no slot.
+ */
+ scheduledFor: Date;
+ /** When the dose was actually taken. `null` for a deliberate skip. */
+ takenAt: Date | null;
+ skipped: boolean;
+ /** Per-dose override, set only where the file records a real deviation. */
+ doseTaken: string | null;
+ /** The UTC offset the file stated for the actual instant, in minutes. */
+ offsetMinutes: number;
+ /** True when the row came from a medication archived in the source app. */
+ fromArchivedMedication: boolean;
+ /** True when the file named no slot, so this dose is ad-hoc. */
+ adHoc: boolean;
+}
+
+/**
+ * Why a row did not become a dose. Every one of these is surfaced with a count;
+ * none of them is a silent drop.
+ */
+export const AUTO_EXPORT_REFUSAL_REASONS = [
+ /** Status names no decision about the dose (`Not Interacted`, `Snoozed`, …). */
+ "status_not_recorded",
+ /** Status is not a value the format documents at all. */
+ "status_unknown",
+ /** `Date` is blank — there is no dose without an instant. */
+ "missing_timestamp",
+ /** A timestamp shaped right but carrying no UTC offset. */
+ "missing_timezone_offset",
+ /** A timestamp that does not match the documented shape. */
+ "unreadable_timestamp",
+ /** A timestamp naming no real instant, or one outside plausible capture. */
+ "implausible_timestamp",
+ /** `Medication` and `Nickname` both blank — the row names no medication. */
+ "missing_medication",
+ /** A dosage cell that is not a number. */
+ "unreadable_dosage",
+ /** A CSV line with fewer cells than the header declared. */
+ "unreadable_row",
+] as const;
+
+export type AutoExportRefusalReason =
+ (typeof AUTO_EXPORT_REFUSAL_REASONS)[number];
+
+export interface AutoExportRefusal {
+ line: number;
+ reason: AutoExportRefusalReason;
+}
+
+/** A file-level problem: nothing can be read at all. */
+export const AUTO_EXPORT_FATAL_REASONS = [
+ "empty_file",
+ "missing_required_columns",
+ "unreadable_json",
+ "json_not_an_array",
+ /** The JSON shape records no time a dose was actually taken. */
+ "json_carries_no_intake_time",
+] as const;
+
+export type AutoExportFatalReason = (typeof AUTO_EXPORT_FATAL_REASONS)[number];
+
+export interface AutoExportParseOutcome {
+ fatal?: { reason: AutoExportFatalReason; detail?: string };
+ doses: AutoExportDose[];
+ refusals: AutoExportRefusal[];
+ /** Data rows read, refusals included. `doses + refusals` must equal it. */
+ rowsRead: number;
+ /**
+ * Header cells the importer has no verdict for. Named rather than dropped: a
+ * column nobody mentions is the defect this format work exists to remove.
+ */
+ unknownColumns: string[];
+ /**
+ * Rows whose `Codings` cell held something. Counted and left unread — the CSV
+ * shape gives that column no documented encoding, and guessing one would be
+ * the importer inventing a medication identity.
+ */
+ csvCodingsIgnored: number;
+ /** Rows that came from a medication the source app has archived. */
+ fromArchivedMedications: number;
+}
+
+/**
+ * The past floor for an imported dose.
+ *
+ * HealthLog's own intake surfaces clamp a backdated dose to five years, which
+ * would refuse most of a real export — the reported file starts in 2023 and a
+ * longer history is ordinary. Importing years of history is the entire point of
+ * this route, so the floor is the absolute 1900 bound the shared validator
+ * carries and only the future side stays tight.
+ */
+const IMPORT_PAST_FLOOR_MS = Date.now() - ENTRY_INSTANT_FAR_PAST.getTime();
+
+function refusalForInstantFailure(
+ failure: AutoExportInstantFailure,
+): AutoExportRefusalReason {
+ switch (failure) {
+ case "absent":
+ return "missing_timestamp";
+ case "missing_offset":
+ return "missing_timezone_offset";
+ case "out_of_range":
+ return "implausible_timestamp";
+ case "unreadable":
+ return "unreadable_timestamp";
+ }
+}
+
+/** `Yes` / `true` / `1`; anything else, blank included, reads as not archived. */
+function readArchivedFlag(raw: string | null | undefined): boolean {
+ const value = (raw ?? "").trim().toLowerCase();
+ return value === "yes" || value === "true" || value === "1";
+}
+
+type DosageRead =
+ | { ok: true; amount: number | null }
+ | { ok: false; reason: "unreadable_dosage" };
+
+/** A blank dosage cell states nothing; a non-numeric one is a refusal. */
+function readDosage(raw: string): DosageRead {
+ const value = raw.trim();
+ if (value.length === 0) return { ok: true, amount: null };
+ if (!/^-?\d+(\.\d+)?$/.test(value)) {
+ return { ok: false, reason: "unreadable_dosage" };
+ }
+ const amount = Number(value);
+ return Number.isFinite(amount)
+ ? { ok: true, amount }
+ : { ok: false, reason: "unreadable_dosage" };
+}
+
+/**
+ * The per-dose override, or `null`.
+ *
+ * Written only where the file records an actual amount that DIFFERS from the
+ * amount the schedule expected — the only case where the dose taken is a fact
+ * the schedule does not already carry. `doseTaken` is documented as "NULL = the
+ * configured medication / schedule dose applies", so restating an unremarkable
+ * amount on every one of three thousand rows would be noise dressed as
+ * information. Where the file states no scheduled amount there is nothing to
+ * compare against, and absence stays absence.
+ */
+function resolveDoseOverride(
+ actual: number | null,
+ scheduled: number | null,
+ unit: string,
+): string | null {
+ if (actual === null || scheduled === null) return null;
+ if (actual === scheduled) return null;
+ return formatAutoExportDose(actual, unit);
+}
+
+interface RowInput {
+ line: number;
+ actual: string;
+ scheduled: string;
+ medication: string;
+ nickname: string;
+ dosage: string;
+ scheduledDosage: string;
+ unit: string;
+ status: string;
+ archived: string;
+}
+
+type RowOutcome =
+ | { ok: true; dose: AutoExportDose }
+ | { ok: false; reason: AutoExportRefusalReason };
+
+/** The one place a row becomes a dose. */
+function readRow(input: RowInput, now: number): RowOutcome {
+ const status = input.status.trim();
+ if (!isAutoExportStatus(status)) {
+ return { ok: false, reason: "status_unknown" };
+ }
+ if (!isActionableAutoExportStatus(status)) {
+ return { ok: false, reason: "status_not_recorded" };
+ }
+
+ const medicationName = input.medication.trim();
+ const nickname = input.nickname.trim();
+ if (medicationName.length === 0 && nickname.length === 0) {
+ return { ok: false, reason: "missing_medication" };
+ }
+
+ const actual = parseAutoExportInstant(input.actual);
+ if (!actual.ok) {
+ return { ok: false, reason: refusalForInstantFailure(actual.failure) };
+ }
+
+ // A blank `Scheduled Date` is not a defect: the export writes it for a dose
+ // logged outside any slot. HealthLog anchors exactly that case with
+ // `scheduledFor === takenAt`, so the absence maps onto an existing notion
+ // instead of being invented into one.
+ const scheduledRead = parseAutoExportInstant(input.scheduled);
+ let scheduledFor: Date;
+ let adHoc: boolean;
+ if (scheduledRead.ok) {
+ scheduledFor = scheduledRead.instant;
+ adHoc = false;
+ } else if (scheduledRead.failure === "absent") {
+ scheduledFor = actual.instant;
+ adHoc = true;
+ } else {
+ return {
+ ok: false,
+ reason: refusalForInstantFailure(scheduledRead.failure),
+ };
+ }
+
+ for (const instant of [actual.instant, scheduledFor]) {
+ if (
+ !isPlausibleEntryInstant(instant, { now, maxAgeMs: IMPORT_PAST_FLOOR_MS })
+ ) {
+ return { ok: false, reason: "implausible_timestamp" };
+ }
+ }
+
+ const dosage = readDosage(input.dosage);
+ if (!dosage.ok) return { ok: false, reason: dosage.reason };
+ const scheduledDosage = readDosage(input.scheduledDosage);
+ if (!scheduledDosage.ok) return { ok: false, reason: scheduledDosage.reason };
+
+ const skipped = status === "Skipped";
+ return {
+ ok: true,
+ dose: {
+ line: input.line,
+ medicationName: medicationName.length > 0 ? medicationName : nickname,
+ nickname: nickname.length > 0 ? nickname : null,
+ scheduledFor,
+ // A skip is a dose that was deliberately not taken. The file's `Date` on
+ // such a row is when the person pressed skip, which is not a take, so
+ // `takenAt` stays null exactly as every other skip path writes it.
+ takenAt: skipped ? null : actual.instant,
+ skipped,
+ doseTaken: skipped
+ ? null
+ : resolveDoseOverride(
+ dosage.amount,
+ scheduledDosage.amount,
+ input.unit,
+ ),
+ offsetMinutes: actual.offsetMinutes,
+ fromArchivedMedication: readArchivedFlag(input.archived),
+ adHoc,
+ },
+ };
+}
+
+const CSV_HEADER_LOOKUP = new Map(
+ AUTO_EXPORT_CSV_COLUMNS.map((column) => [column.toLowerCase(), column]),
+);
+
+/** `Date`, `Medication` and `Status` are the file's irreducible content. */
+const CSV_REQUIRED_COLUMNS = ["Date", "Medication", "Status"] as const;
+
+export interface AutoExportParseOptions {
+ /** Clock anchor for the plausibility bound. Injectable for tests. */
+ now?: number;
+}
+
+const EMPTY_OUTCOME: Omit = {
+ doses: [],
+ refusals: [],
+ rowsRead: 0,
+ unknownColumns: [],
+ csvCodingsIgnored: 0,
+ fromArchivedMedications: 0,
+};
+
+/** Parse the ten-column CSV export. */
+export function parseAutoExportCsv(
+ text: string,
+ options: AutoExportParseOptions = {},
+): AutoExportParseOutcome {
+ const now = options.now ?? Date.now();
+ const rows = splitCsvRows(text);
+ if (rows.length === 0) {
+ return { ...EMPTY_OUTCOME, fatal: { reason: "empty_file" } };
+ }
+
+ const header = rows[0];
+ const index = new Map();
+ const unknownColumns: string[] = [];
+ header.forEach((cell, position) => {
+ const trimmed = cell.trim();
+ const canonical = CSV_HEADER_LOOKUP.get(trimmed.toLowerCase());
+ if (canonical) {
+ if (!index.has(canonical)) index.set(canonical, position);
+ return;
+ }
+ if (trimmed.length > 0) unknownColumns.push(trimmed);
+ });
+
+ // A slimmer export missing any of the other seven columns still imports, with
+ // the facts those columns carry honestly absent.
+ const missing = CSV_REQUIRED_COLUMNS.filter((column) => !index.has(column));
+ if (missing.length > 0) {
+ return {
+ ...EMPTY_OUTCOME,
+ unknownColumns,
+ fatal: {
+ reason: "missing_required_columns",
+ detail: missing.join(", "),
+ },
+ };
+ }
+
+ const cellAt = (row: string[], column: string): string => {
+ const position = index.get(column);
+ if (position === undefined) return "";
+ return row[position] ?? "";
+ };
+
+ const doses: AutoExportDose[] = [];
+ const refusals: AutoExportRefusal[] = [];
+ let csvCodingsIgnored = 0;
+ let fromArchivedMedications = 0;
+
+ for (let i = 1; i < rows.length; i += 1) {
+ const row = rows[i];
+ const line = i + 1;
+ // A row shorter than the header has lost cells to a malformed quote or a
+ // hand edit, so the alignment is no longer trustworthy and no cell of it
+ // can be read at face value.
+ if (row.length < header.length) {
+ refusals.push({ line, reason: "unreadable_row" });
+ continue;
+ }
+ if (cellAt(row, "Codings").trim().length > 0) csvCodingsIgnored += 1;
+ if (readArchivedFlag(cellAt(row, "Archived"))) fromArchivedMedications += 1;
+
+ const outcome = readRow(
+ {
+ line,
+ actual: cellAt(row, "Date"),
+ scheduled: cellAt(row, "Scheduled Date"),
+ medication: cellAt(row, "Medication"),
+ nickname: cellAt(row, "Nickname"),
+ dosage: cellAt(row, "Dosage"),
+ scheduledDosage: cellAt(row, "Scheduled Dosage"),
+ unit: cellAt(row, "Unit"),
+ status: cellAt(row, "Status"),
+ archived: cellAt(row, "Archived"),
+ },
+ now,
+ );
+ if (outcome.ok) doses.push(outcome.dose);
+ else refusals.push({ line, reason: outcome.reason });
+ }
+
+ return {
+ doses,
+ refusals,
+ rowsRead: rows.length - 1,
+ unknownColumns,
+ csvCodingsIgnored,
+ fromArchivedMedications,
+ };
+}
+
+/**
+ * Read the documented JSON export — and refuse it, deliberately.
+ *
+ * The two shapes are not two encodings of the same thing. The CSV is a dose
+ * log: `Date` is when a dose was actually taken and `Scheduled Date` is the slot
+ * it belonged to, side by side. The documented JSON carries `scheduledDate`,
+ * `status`, `dosage`, and the parent medication's `start` / `end` / `form` — and
+ * no field for the time a dose was actually taken.
+ *
+ * So a `Taken` dose from the JSON shape could only be written by putting the
+ * scheduled time in `takenAt`, which asserts every dose was taken to the minute
+ * it was due. That is the exact flattening this whole path exists to undo: it
+ * manufactures a spotless on-time history out of a file that never claimed one,
+ * and no later correction can recover what was overwritten. A refusal that names
+ * the missing field leaves the person able to export the CSV, which has it.
+ *
+ * The shape is still parsed far enough to tell a JSON medication export apart
+ * from unreadable text, so the answer can be that sentence rather than a
+ * generic failure.
+ */
+export function parseAutoExportJson(text: string): AutoExportParseOutcome {
+ let decoded: unknown;
+ try {
+ decoded = JSON.parse(text);
+ } catch {
+ return { ...EMPTY_OUTCOME, fatal: { reason: "unreadable_json" } };
+ }
+
+ // The export wraps the array under a key in some of its output modes; both
+ // the bare array and a single-key envelope are recognised.
+ let list: unknown = decoded;
+ if (!Array.isArray(list) && typeof list === "object" && list !== null) {
+ const nested = Object.values(list as Record).find(
+ (value) => Array.isArray(value),
+ );
+ if (nested) list = nested;
+ }
+ if (!Array.isArray(list)) {
+ return { ...EMPTY_OUTCOME, fatal: { reason: "json_not_an_array" } };
+ }
+ if (list.length === 0) {
+ return { ...EMPTY_OUTCOME, fatal: { reason: "empty_file" } };
+ }
+ return {
+ ...EMPTY_OUTCOME,
+ rowsRead: list.length,
+ fatal: { reason: "json_carries_no_intake_time" },
+ };
+}
diff --git a/src/lib/medications/import/auto-export-plan.ts b/src/lib/medications/import/auto-export-plan.ts
new file mode 100644
index 000000000..5ca81b81c
--- /dev/null
+++ b/src/lib/medications/import/auto-export-plan.ts
@@ -0,0 +1,215 @@
+/**
+ * From parsed doses to the queued import — medication matching and the payload.
+ *
+ * Pure: the caller passes the medications the account already holds, so the
+ * whole matching decision is testable without a database and the route stays a
+ * thin shell over it.
+ *
+ * The one policy decision that lives here is that a medication is never created
+ * from an import file. See {@link planAutoExportImport}.
+ */
+import type {
+ MedicationImportEntry,
+ MedicationImportSkipCounts,
+ MedicationImportSkipReason,
+} from "@/lib/jobs/medication-intake-import";
+
+import { normaliseMedicationName } from "./auto-export-format";
+import type {
+ AutoExportDose,
+ AutoExportParseOutcome,
+} from "./auto-export-parse";
+
+/** A medication already on the record, as the matcher needs to see it. */
+export interface MedicationMatchCandidate {
+ id: string;
+ name: string;
+ /**
+ * Non-null marks the medication a read-only mirror of an external list (the
+ * Apple Health medications sync). Such a row is source-exclusive by policy —
+ * only dose events from that same source attach to it — so an import cannot
+ * write to it.
+ */
+ externalSource: string | null;
+}
+
+export interface AutoExportPlan {
+ /** What the worker will attempt to write, in file order. */
+ entries: MedicationImportEntry[];
+ /**
+ * Rows refused before the queue, by reason. Seeded into the job's progress so
+ * the final count the person reads covers the whole file and not just the part
+ * that reached the database.
+ */
+ skippedByReason: MedicationImportSkipCounts;
+ /**
+ * Names the file used that match nothing on the record, deduplicated and in
+ * first-seen order. Surfaced verbatim so the person can rename or add the
+ * medication and re-run, rather than being told a number.
+ */
+ unmatchedMedications: string[];
+ /** Names that match more than one medication, so no single one can be meant. */
+ ambiguousMedications: string[];
+ /** Names that match only a medication mirrored from an external list. */
+ mirroredMedications: string[];
+ /** Data rows read from the file, refusals included. */
+ rowsRead: number;
+ /** Rows the file marked as belonging to a medication archived in the source. */
+ fromArchivedMedications: number;
+ /** Rows whose `Codings` cell held something that was left unread. */
+ csvCodingsIgnored: number;
+ /** Header cells the importer has no verdict for. */
+ unknownColumns: string[];
+}
+
+/**
+ * An imported dose is identified by its medication and its slot instant.
+ *
+ * That is the grain the database already enforces through the live-row unique on
+ * `(user_id, medication_id, scheduled_for, source)`, so the replay key is a
+ * function of exactly the same facts. It was previously built from an optional
+ * counter field in the request body, which collapsed a month of distinct doses
+ * onto one key as soon as an export put something non-unique there.
+ */
+export function autoExportIdempotencyKey(
+ medicationId: string,
+ scheduledFor: Date,
+): string {
+ return `import-${medicationId}-${scheduledFor.getTime()}`;
+}
+
+function bump(
+ counts: MedicationImportSkipCounts,
+ reason: MedicationImportSkipReason,
+): void {
+ counts[reason] = (counts[reason] ?? 0) + 1;
+}
+
+/** Why a row's medication could not be resolved to one on the record. */
+type MatchFailure =
+ "medication_not_found" | "medication_ambiguous" | "medication_is_mirrored";
+
+/**
+ * Resolve every parsed dose against the account's medications and build the
+ * queue payload.
+ *
+ * **A medication is never created from an import file.** The argument for
+ * creating them is real: a 3,000-row export names its medications, and refusing
+ * every row of an unknown one makes the person do setup work by hand before the
+ * import is worth anything. The argument against wins anyway. An import file
+ * carries a display name and nothing else — no dose, no unit, no schedule, no
+ * form, no delivery route. A medication conjured from it would be a name with a
+ * blank dose that every schedule-aware surface then has to tolerate: compliance
+ * has no slots to expect, the inventory has no units per dose, reminders have
+ * nothing to fire on. And a name that differs by a word from one already on the
+ * record would silently fork the history in two, which is worse than a refusal,
+ * because a refusal is visible and a fork is not. The unmatched names are
+ * reported verbatim instead, so the person adds the medication properly once and
+ * re-runs; the import is idempotent, so re-running costs nothing.
+ *
+ * A name matching two medications is refused rather than resolved to whichever
+ * the database returned first.
+ */
+export function planAutoExportImport(
+ parsed: AutoExportParseOutcome,
+ medications: readonly MedicationMatchCandidate[],
+): AutoExportPlan {
+ const skippedByReason: MedicationImportSkipCounts = {};
+ for (const refusal of parsed.refusals) bump(skippedByReason, refusal.reason);
+
+ const byName = new Map();
+ for (const medication of medications) {
+ const key = normaliseMedicationName(medication.name);
+ if (key.length === 0) continue;
+ const bucket = byName.get(key);
+ if (bucket) bucket.push(medication);
+ else byName.set(key, [medication]);
+ }
+
+ const named: Record = {
+ medication_not_found: [],
+ medication_ambiguous: [],
+ medication_is_mirrored: [],
+ };
+ const namedSeen: Record> = {
+ medication_not_found: new Set(),
+ medication_ambiguous: new Set(),
+ medication_is_mirrored: new Set(),
+ };
+ const entries: MedicationImportEntry[] = [];
+ const seenKeys = new Set();
+
+ const resolve = (
+ dose: AutoExportDose,
+ ): { ok: true; id: string } | { ok: false; reason: MatchFailure } => {
+ // The export can name the same medication twice: `Medication` is the display
+ // name and `Nickname` is the person's own label for it. Either may be the one
+ // that matches what the record calls it, so both are tried.
+ const candidates = [dose.medicationName, dose.nickname]
+ .filter((value): value is string => !!value && value.trim().length > 0)
+ .map(normaliseMedicationName)
+ .filter((value) => value.length > 0);
+ for (const key of candidates) {
+ const bucket = byName.get(key);
+ if (!bucket) continue;
+ if (bucket.length > 1) {
+ return { ok: false, reason: "medication_ambiguous" };
+ }
+ // A mirrored medication is a read-only copy of another app's list and only
+ // that app's dose events attach to it. Writing an imported dose onto it
+ // would put a row the mirror never asserted inside the mirror.
+ if (bucket[0].externalSource !== null) {
+ return { ok: false, reason: "medication_is_mirrored" };
+ }
+ return { ok: true, id: bucket[0].id };
+ }
+ return { ok: false, reason: "medication_not_found" };
+ };
+
+ for (const dose of parsed.doses) {
+ const match = resolve(dose);
+ if (!match.ok) {
+ bump(skippedByReason, match.reason);
+ const label = dose.medicationName;
+ if (!namedSeen[match.reason].has(label)) {
+ namedSeen[match.reason].add(label);
+ named[match.reason].push(label);
+ }
+ continue;
+ }
+
+ const idempotencyKey = autoExportIdempotencyKey(
+ match.id,
+ dose.scheduledFor,
+ );
+ // Two rows of one file resolving to the same medication and slot: only one
+ // can exist, and saying so here — where the whole file is in hand — counts
+ // it exactly, instead of leaving the second row to surface later as though
+ // the record had already held that dose.
+ if (seenKeys.has(idempotencyKey)) {
+ bump(skippedByReason, "duplicate_in_file");
+ continue;
+ }
+ seenKeys.add(idempotencyKey);
+
+ entries.push({
+ medicationId: match.id,
+ scheduledFor: dose.scheduledFor.toISOString(),
+ takenAt: dose.takenAt === null ? null : dose.takenAt.toISOString(),
+ idempotencyKey,
+ ...(dose.doseTaken === null ? {} : { doseTaken: dose.doseTaken }),
+ });
+ }
+
+ return {
+ entries,
+ skippedByReason,
+ unmatchedMedications: named.medication_not_found,
+ ambiguousMedications: named.medication_ambiguous,
+ mirroredMedications: named.medication_is_mirrored,
+ rowsRead: parsed.rowsRead,
+ fromArchivedMedications: parsed.fromArchivedMedications,
+ csvCodingsIgnored: parsed.csvCodingsIgnored,
+ unknownColumns: parsed.unknownColumns,
+ };
+}
diff --git a/src/lib/medications/intake-import-job-status.ts b/src/lib/medications/intake-import-job-status.ts
new file mode 100644
index 000000000..54e01d738
--- /dev/null
+++ b/src/lib/medications/intake-import-job-status.ts
@@ -0,0 +1,55 @@
+/**
+ * The one projection of an intake-import job onto the polling response.
+ *
+ * Two routes read it — the per-medication import under `/api/medications/[id]/…`
+ * and the account-wide export import — and the client polls both through the
+ * same helper. Restating the field list per route is how one of them ends up
+ * omitting `failureReason` and a failed run reads as a run still going.
+ */
+import { prisma } from "@/lib/db";
+import type { Prisma } from "@/generated/prisma/client";
+
+export interface MedicationIntakeImportJobStatus {
+ jobId: string;
+ status: string;
+ progress: Prisma.JsonValue;
+ result: Prisma.JsonValue | null;
+ failureReason: string | null;
+ createdAt: string;
+ startedAt: string | null;
+ completedAt: string | null;
+}
+
+/**
+ * Read one job the caller owns, or `null`.
+ *
+ * `medicationId` narrows to a job scoped to that medication; omit it for the
+ * account-wide import. `userId` is always in the `where`, so a job id belonging
+ * to somebody else is a 404 and not a leak.
+ */
+export async function readMedicationIntakeImportJob(
+ userId: string,
+ jobId: string,
+ medicationId?: string,
+): Promise {
+ const row = await prisma.medicationIntakeImportJob.findFirst({
+ where: {
+ id: jobId,
+ userId,
+ // An account-wide job carries no medication, and asking for one by id must
+ // not resolve to the other kind. `null` is the explicit narrowing.
+ medicationId: medicationId ?? null,
+ },
+ });
+ if (!row) return null;
+ return {
+ jobId: row.id,
+ status: row.status,
+ progress: row.progress,
+ result: row.result,
+ failureReason: row.failureReason,
+ createdAt: row.createdAt.toISOString(),
+ startedAt: row.startedAt?.toISOString() ?? null,
+ completedAt: row.completedAt?.toISOString() ?? null,
+ };
+}
diff --git a/src/lib/medications/intake-import-payload.ts b/src/lib/medications/intake-import-payload.ts
index 27d085df5..ded1f0720 100644
--- a/src/lib/medications/intake-import-payload.ts
+++ b/src/lib/medications/intake-import-payload.ts
@@ -13,6 +13,7 @@ export interface MedicationImportRow {
}
export interface MedicationImportQueueEntry {
+ scheduledFor: string;
takenAt: string;
idempotencyKey: string;
}
@@ -35,6 +36,11 @@ export function buildMedicationImportEntries(
return rows.map((row) => {
const takenAt = new Date(`${row.datum}T${row.uhrzeit}`);
return {
+ // This body carries one time per row and nothing to say which slot it
+ // belonged to, so every dose it describes is ad-hoc: anchored on itself,
+ // which is how HealthLog already records a dose that belongs to no slot.
+ // The export-reading route is the one that has both facts to give.
+ scheduledFor: takenAt.toISOString(),
takenAt: takenAt.toISOString(),
idempotencyKey: `import-${medicationId}-${takenAt.getTime()}`,
};
diff --git a/tests/integration/medication-intake-import-job.test.ts b/tests/integration/medication-intake-import-job.test.ts
index 645975b07..f5847ffde 100644
--- a/tests/integration/medication-intake-import-job.test.ts
+++ b/tests/integration/medication-intake-import-job.test.ts
@@ -61,18 +61,22 @@ describe("medication intake import job — concurrent retry", () => {
const payload: MedicationImportPayload = {
entries: [
{
+ scheduledFor: "2026-07-20T05:00:00.000Z",
takenAt: "2026-07-20T05:00:00.000Z",
idempotencyKey: `import-${medication.id}-1`,
},
{
+ scheduledFor: "2026-07-20T05:00:00.000Z",
takenAt: "2026-07-20T05:00:00.000Z",
idempotencyKey: `import-${medication.id}-1`,
},
{
+ scheduledFor: "2026-07-20T17:00:00.000Z",
takenAt: "2026-07-20T17:00:00.000Z",
idempotencyKey: `import-${medication.id}-2`,
},
{
+ scheduledFor: "2026-07-21T05:00:00.000Z",
takenAt: "2026-07-21T05:00:00.000Z",
idempotencyKey: `import-${medication.id}-3`,
},
@@ -192,6 +196,7 @@ describe("medication intake import job — concurrent retry", () => {
const payload: MedicationImportPayload = {
entries: [
{
+ scheduledFor: "2026-07-20T05:00:00.000Z",
takenAt: "2026-07-20T05:00:00.000Z",
idempotencyKey: `import-${medication.id}-final`,
},
From 34a220421c3f870b0b202fb35bf6273bc123e66e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 11:46:44 +0200
Subject: [PATCH 13/52] test(medications): prove the reported export lands, row
for row
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The fixture is the export a self-hoster attached, de-identified as it
arrived: 3,395 rows, sixteen medications, three and a half years, two UTC
offsets, three statuses, two units, blank cells in four columns. A
hand-built fixture would have carried one status and one offset and proved
nothing about the file that prompted the work.
The integration pass runs it through the real derivation into a real
Postgres, because the claim is about rows: 3,387 land, 8 are refused because
the file records no decision about them, and imported plus refused equals
every row read. 2,799 of the doses were taken after they were due, which is
the fact that survives only because the scheduled slot and the take are two
columns.
Structural cover so the verdicts cannot rot: every column of both shapes has
exactly one ruling, and every parse-time refusal is spelled the same as a job
skip reason — a reason that exists in only one vocabulary renders as "reason
not recorded", which is a refusal nobody can act on.
---
messages/de.json | 68 +-
messages/en.json | 68 +-
messages/es.json | 68 +-
messages/fr.json | 68 +-
messages/it.json | 68 +-
messages/pl.json | 68 +-
.../module-route-gate-inventory.test.ts | 4 +
.../intake/import/__tests__/route.test.ts | 6 +
.../medications/intake-import-result.tsx | 66 +-
.../__tests__/import-result-guard.test.ts | 4 +
.../import-panel/dose-history-columns.tsx | 94 +
.../import-panel/dose-history-import-card.tsx | 398 ++
.../settings/import-panel/index.tsx | 2 +
src/lib/jobs/medication-intake-import.ts | 4 +-
.../auto-export-format-coverage.test.ts | 89 +
.../__tests__/auto-export-instant.test.ts | 98 +
.../__tests__/auto-export-parse.test.ts | 286 ++
.../import/__tests__/auto-export-plan.test.ts | 218 ++
.../fixtures/dose-history-export.csv | 3396 +++++++++++++++++
.../medication-dose-history-import.test.ts | 274 ++
20 files changed, 5322 insertions(+), 25 deletions(-)
create mode 100644 src/components/settings/import-panel/dose-history-columns.tsx
create mode 100644 src/components/settings/import-panel/dose-history-import-card.tsx
create mode 100644 src/lib/medications/import/__tests__/auto-export-format-coverage.test.ts
create mode 100644 src/lib/medications/import/__tests__/auto-export-instant.test.ts
create mode 100644 src/lib/medications/import/__tests__/auto-export-parse.test.ts
create mode 100644 src/lib/medications/import/__tests__/auto-export-plan.test.ts
create mode 100644 src/lib/medications/import/__tests__/fixtures/dose-history-export.csv
create mode 100644 tests/integration/medication-dose-history-import.test.ts
diff --git a/messages/de.json b/messages/de.json
index 206b9d8f2..f1cfb2c16 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -1917,7 +1917,19 @@
"skipGroupOther": "{count} Einträge übersprungen: {reason}",
"reasonDuplicateInFile": "ein anderer Eintrag derselben Datei hat dasselbe Datum und dieselbe Uhrzeit",
"reasonAlreadyRecorded": "für dieses Datum und diese Uhrzeit ist bereits eine Einnahme erfasst",
- "reasonUnknown": "Grund nicht erfasst"
+ "reasonUnknown": "Grund nicht erfasst",
+ "reasonMedicationNotFound": "kein Medikament im Bestand passt zum Namen in der Datei",
+ "reasonMedicationAmbiguous": "der Name in der Datei passt auf mehr als ein Medikament",
+ "reasonMedicationIsMirrored": "das Medikament ist eine schreibgeschützte Kopie aus einer anderen App",
+ "reasonStatusNotRecorded": "die Datei hält zu dieser Dosis keine Entscheidung fest",
+ "reasonStatusUnknown": "der Status in der Datei gehört nicht zum dokumentierten Format",
+ "reasonMissingTimestamp": "die Zeile nennt keine Zeit",
+ "reasonMissingOffset": "die Zeit trägt keinen UTC-Offset, der Zeitpunkt ist damit unbekannt",
+ "reasonUnreadableTimestamp": "die Zeit entspricht nicht dem dokumentierten Format",
+ "reasonImplausibleTimestamp": "die Zeit nennt keinen echten Zeitpunkt oder liegt in der Zukunft",
+ "reasonMissingMedication": "die Zeile nennt kein Medikament",
+ "reasonUnreadableDosage": "die Dosis ist keine Zahl",
+ "reasonUnreadableRow": "die Zeile hat weniger Spalten als die Kopfzeile"
}
},
"charts": {
@@ -4547,6 +4559,60 @@
"reasonUnstableExternalId": "Externe ID ist über Exporte hinweg nicht stabil",
"reasonDuplicate": "bereits importiert",
"reasonUnknown": "übersprungen"
+ },
+ "doseHistory": {
+ "title": "Einnahmeverlauf",
+ "description": "Ein Einnahmeverlauf, wie ihn ein anderer Medikamenten-Tracker exportiert, direkt gelesen. Eine Datei deckt alle Medikamente darin ab.",
+ "pasteLabel": "Export einfügen",
+ "fileInputLabel": "Datei mit Einnahmeverlauf wählen",
+ "uploadFile": "Datei wählen",
+ "preview": "Vorschau",
+ "import": "Importieren",
+ "empty": "Erst den Export einfügen oder eine Datei wählen.",
+ "readFailed": "Diese Datei konnte nicht gelesen werden.",
+ "rateLimited": "Höchstens 5 Importe pro Stunde. Später erneut versuchen.",
+ "tooLarge": "Diese Datei überschreitet das Limit von 16 MB.",
+ "inProgress": "Ein Import des Einnahmeverlaufs läuft bereits. Auf sein Ende warten.",
+ "workerUnavailable": "Der Hintergrunddienst ist nicht verfügbar, es wurde nichts importiert.",
+ "failed": "Der Import ist fehlgeschlagen. Es wurde nichts geschrieben.",
+ "columnsHeading": "Was mit jeder Spalte passiert",
+ "jsonOnly": "nur JSON",
+ "previewLine": "Zeilen gelesen: {rows} · würde importieren: {queued} · abgewiesen: {refused}",
+ "submittedLine": "Zeilen gelesen: {rows} · zum Import eingereiht: {queued}",
+ "unmatched": "Kein Medikament im Bestand passt zu: {names}",
+ "ambiguous": "Passt auf mehr als ein Medikament: {names}",
+ "mirrored": "Aus einer anderen App gespiegelt, daher keine Dosis ergänzbar: {names}",
+ "unknownColumns": "Spalten ohne Regel im Importer, ungelesen: {names}",
+ "codingsNotRead": "Zeilen mit einem nicht gelesenen Codings-Wert: {count}",
+ "archivedRows": "Zeilen von einem in der Quell-App archivierten Medikament, trotzdem importiert: {count}",
+ "verdict": {
+ "honoured": "verwendet",
+ "reported": "gemeldet",
+ "ignored": "nicht gelesen"
+ },
+ "column": {
+ "date": "Der Zeitpunkt der tatsächlichen Einnahme. Sein UTC-Offset wird genau so gelesen, wie er dasteht.",
+ "scheduledDate": "Der Slot, zu dem die Dosis gehörte, getrennt vom Zeitpunkt der Einnahme. Leer heißt: die Dosis gehörte zu keinem Slot.",
+ "medication": "Wird einem Medikament zugeordnet, das es im Bestand schon gibt. Aus der Datei wird nichts angelegt.",
+ "nickname": "Wird als zweiter Name versucht, wenn der erste zu nichts passt.",
+ "dosage": "Wird nur dann an der Dosis festgehalten, wenn sie von der geplanten Menge abweicht.",
+ "scheduledDosage": "Wird mit der tatsächlichen Menge verglichen, um eine Abweichung zu erkennen. Nie eigenständig gespeichert.",
+ "unit": "Wird mit der Menge geschrieben, geschrieben wie in der Datei. „count“ heißt Vielfache der konfigurierten Dosis.",
+ "status": "„Taken“ wird eine Dosis, „Skipped“ ein bewusstes Auslassen. Jeder andere Wert hält keine Entscheidung fest und die Zeile wird abgewiesen.",
+ "archived": "Wird gezählt und zurückgemeldet. Eine erfolgte Einnahme wird nicht dadurch ungültig, dass das Medikament später archiviert wurde.",
+ "codings": "Die CSV dokumentiert für diese Spalte keine Kodierung, also bleibt sie unberührt statt geraten. Gefüllte Zellen werden gezählt.",
+ "form": "Beschreibt das Medikament, nicht die Dosis.",
+ "start": "Das Datum des Behandlungsbeginns, nicht der Zeitpunkt dieser Dosis.",
+ "end": "Das Datum des Behandlungsendes, nicht der Zeitpunkt dieser Dosis."
+ },
+ "fatal": {
+ "emptyFile": "Diese Datei enthält keine Zeilen.",
+ "missingColumns": "Dieser Datei fehlt eine Spalte, die der Importer braucht: Date, Medication oder Status.",
+ "unreadableJson": "Diese Datei ist kein gültiges JSON.",
+ "jsonNotAnArray": "Dieses JSON enthält keine Liste von Dosen.",
+ "jsonNoIntakeTime": "Der JSON-Export hält fest, wann eine Dosis fällig war, nicht wann sie genommen wurde, und eine geplante Zeit wird nicht als Einnahmezeit geschrieben. Stattdessen die CSV exportieren, die beides trägt.",
+ "tooManyRows": "Diese Datei enthält mehr Zeilen, als ein Import aufnehmen kann. Teilen und in Teilen importieren."
+ }
}
},
"subtitle": "Exportiere deine Daten oder importiere aus einer Sicherung."
diff --git a/messages/en.json b/messages/en.json
index 48bfe097b..d7da7aa75 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1917,7 +1917,19 @@
"skipGroupOther": "{count} entries skipped: {reason}",
"reasonDuplicateInFile": "another entry in the same file has the same date and time",
"reasonAlreadyRecorded": "an intake is already recorded for that date and time",
- "reasonUnknown": "reason not recorded"
+ "reasonUnknown": "reason not recorded",
+ "reasonMedicationNotFound": "no medication on record matches the name in the file",
+ "reasonMedicationAmbiguous": "the name in the file matches more than one medication on record",
+ "reasonMedicationIsMirrored": "the medication is a read-only copy of another app's list",
+ "reasonStatusNotRecorded": "the file records no decision about that dose",
+ "reasonStatusUnknown": "the status in the file is not one the format defines",
+ "reasonMissingTimestamp": "the row states no time",
+ "reasonMissingOffset": "the time carries no UTC offset, so the instant is unknown",
+ "reasonUnreadableTimestamp": "the time is not in the format the export documents",
+ "reasonImplausibleTimestamp": "the time names no real instant, or lies in the future",
+ "reasonMissingMedication": "the row names no medication",
+ "reasonUnreadableDosage": "the dosage is not a number",
+ "reasonUnreadableRow": "the row has fewer columns than the header"
}
},
"charts": {
@@ -4547,6 +4559,60 @@
"reasonUnstableExternalId": "external ID is not stable across exports",
"reasonDuplicate": "already imported",
"reasonUnknown": "skipped"
+ },
+ "doseHistory": {
+ "title": "Medication dose history",
+ "description": "A dose history exported from another medication tracker, read as it comes. One file covers every medication in it.",
+ "pasteLabel": "Paste the export",
+ "fileInputLabel": "Choose a dose-history export file",
+ "uploadFile": "Choose file",
+ "preview": "Preview",
+ "import": "Import",
+ "empty": "Paste the export or choose a file first.",
+ "readFailed": "That file could not be read.",
+ "rateLimited": "Maximum 5 imports per hour. Try again later.",
+ "tooLarge": "That file is over the 16 MB limit.",
+ "inProgress": "A dose-history import is already running. Wait for it to finish.",
+ "workerUnavailable": "The background worker is not available, so nothing was imported.",
+ "failed": "The import failed. Nothing was written.",
+ "columnsHeading": "What each column does",
+ "jsonOnly": "JSON only",
+ "previewLine": "Rows read: {rows} · would import: {queued} · refused: {refused}",
+ "submittedLine": "Rows read: {rows} · queued for import: {queued}",
+ "unmatched": "No medication on record matches: {names}",
+ "ambiguous": "Matches more than one medication: {names}",
+ "mirrored": "Mirrored from another app, so no dose can be added: {names}",
+ "unknownColumns": "Columns the importer has no rule for, left unread: {names}",
+ "codingsNotRead": "Rows carrying a Codings value that was not read: {count}",
+ "archivedRows": "Rows from a medication archived in the source app, imported anyway: {count}",
+ "verdict": {
+ "honoured": "used",
+ "reported": "reported",
+ "ignored": "not read"
+ },
+ "column": {
+ "date": "The time the dose was actually taken. Its UTC offset is read exactly as written.",
+ "scheduledDate": "The slot the dose belonged to, kept apart from the time it was taken. Empty means the dose belonged to no slot.",
+ "medication": "Matched against a medication already on your record. Nothing is created from the file.",
+ "nickname": "Tried as a second name when the first one matches nothing.",
+ "dosage": "Recorded on the dose only where it differs from the scheduled amount.",
+ "scheduledDosage": "Compared against the actual amount to decide whether that dose deviated. Never stored on its own.",
+ "unit": "Written with the amount, spelled as the file spells it. \"count\" means multiples of the configured dose.",
+ "status": "\"Taken\" becomes a dose and \"Skipped\" a deliberate skip. Any other value records no decision and the row is refused.",
+ "archived": "Counted and reported back. A dose that happened is not undone by archiving the medication afterwards.",
+ "codings": "The CSV gives this column no documented encoding, so it is left alone rather than guessed at. Populated cells are counted.",
+ "form": "Describes the medication, not the dose.",
+ "start": "The date you started the medication, not the time of this dose.",
+ "end": "The date you stopped the medication, not the time of this dose."
+ },
+ "fatal": {
+ "emptyFile": "That file holds no rows.",
+ "missingColumns": "That file is missing a column the importer needs: Date, Medication or Status.",
+ "unreadableJson": "That file is not valid JSON.",
+ "jsonNotAnArray": "That JSON holds no list of doses.",
+ "jsonNoIntakeTime": "The JSON export records when a dose was due but not when it was taken, and a scheduled time will not be written as if it were the take. Export the CSV instead, which carries both.",
+ "tooManyRows": "That file holds more rows than one import can take. Split it and import the parts."
+ }
}
},
"subtitle": "Export your data or import from a backup."
diff --git a/messages/es.json b/messages/es.json
index fcad5dafa..f3e2c7dbc 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -1917,7 +1917,19 @@
"skipGroupOther": "{count} entradas omitidas: {reason}",
"reasonDuplicateInFile": "otra entrada del mismo archivo tiene la misma fecha y hora",
"reasonAlreadyRecorded": "ya hay una toma registrada para esa fecha y hora",
- "reasonUnknown": "motivo no registrado"
+ "reasonUnknown": "motivo no registrado",
+ "reasonMedicationNotFound": "ningún medicamento registrado coincide con el nombre del archivo",
+ "reasonMedicationAmbiguous": "el nombre del archivo coincide con más de un medicamento",
+ "reasonMedicationIsMirrored": "el medicamento es una copia de solo lectura de otra aplicación",
+ "reasonStatusNotRecorded": "el archivo no registra ninguna decisión sobre esa dosis",
+ "reasonStatusUnknown": "el estado del archivo no es uno de los que define el formato",
+ "reasonMissingTimestamp": "la fila no indica ninguna hora",
+ "reasonMissingOffset": "la hora no lleva desplazamiento UTC, así que el instante es desconocido",
+ "reasonUnreadableTimestamp": "la hora no tiene el formato que documenta la exportación",
+ "reasonImplausibleTimestamp": "la hora no nombra un instante real, o está en el futuro",
+ "reasonMissingMedication": "la fila no nombra ningún medicamento",
+ "reasonUnreadableDosage": "la dosis no es un número",
+ "reasonUnreadableRow": "la fila tiene menos columnas que la cabecera"
}
},
"charts": {
@@ -4547,6 +4559,60 @@
"reasonUnstableExternalId": "el ID externo no es estable entre exportaciones",
"reasonDuplicate": "ya importada",
"reasonUnknown": "omitida"
+ },
+ "doseHistory": {
+ "title": "Historial de dosis",
+ "description": "Un historial de dosis exportado desde otra aplicación de seguimiento, leído tal cual. Un archivo abarca todos los medicamentos que contiene.",
+ "pasteLabel": "Pega la exportación",
+ "fileInputLabel": "Elegir un archivo de historial de dosis",
+ "uploadFile": "Elegir archivo",
+ "preview": "Previsualizar",
+ "import": "Importar",
+ "empty": "Pega la exportación o elige un archivo primero.",
+ "readFailed": "No se pudo leer ese archivo.",
+ "rateLimited": "Máximo 5 importaciones por hora. Inténtalo más tarde.",
+ "tooLarge": "Ese archivo supera el límite de 16 MB.",
+ "inProgress": "Ya hay una importación de historial en curso. Espera a que termine.",
+ "workerUnavailable": "El proceso en segundo plano no está disponible, así que no se importó nada.",
+ "failed": "La importación falló. No se escribió nada.",
+ "columnsHeading": "Qué hace cada columna",
+ "jsonOnly": "solo JSON",
+ "previewLine": "Filas leídas: {rows} · se importarían: {queued} · rechazadas: {refused}",
+ "submittedLine": "Filas leídas: {rows} · en cola para importar: {queued}",
+ "unmatched": "Ningún medicamento registrado coincide con: {names}",
+ "ambiguous": "Coincide con más de un medicamento: {names}",
+ "mirrored": "Copiado de otra aplicación, así que no se puede añadir ninguna dosis: {names}",
+ "unknownColumns": "Columnas sin regla en el importador, no leídas: {names}",
+ "codingsNotRead": "Filas con un valor de Codings no leído: {count}",
+ "archivedRows": "Filas de un medicamento archivado en la aplicación de origen, importadas igualmente: {count}",
+ "verdict": {
+ "honoured": "usada",
+ "reported": "informada",
+ "ignored": "no leída"
+ },
+ "column": {
+ "date": "La hora en que se tomó realmente la dosis. Su desplazamiento UTC se lee tal como está escrito.",
+ "scheduledDate": "La franja a la que pertenecía la dosis, separada de la hora en que se tomó. Vacío significa que no pertenecía a ninguna franja.",
+ "medication": "Se empareja con un medicamento que ya está en tu registro. Nada se crea a partir del archivo.",
+ "nickname": "Se prueba como segundo nombre cuando el primero no coincide con nada.",
+ "dosage": "Se registra en la dosis solo cuando difiere de la cantidad prevista.",
+ "scheduledDosage": "Se compara con la cantidad real para decidir si esa dosis se desvió. Nunca se guarda por sí sola.",
+ "unit": "Se escribe con la cantidad, tal como la escribe el archivo. \"count\" significa múltiplos de la dosis configurada.",
+ "status": "\"Taken\" se convierte en una dosis y \"Skipped\" en una omisión deliberada. Cualquier otro valor no registra decisión alguna y la fila se rechaza.",
+ "archived": "Se cuenta y se informa. Una dosis que ocurrió no se deshace al archivar el medicamento después.",
+ "codings": "El CSV no documenta ninguna codificación para esta columna, así que se deja intacta en vez de adivinarla. Las celdas con contenido se cuentan.",
+ "form": "Describe el medicamento, no la dosis.",
+ "start": "La fecha en que empezaste el medicamento, no la hora de esta dosis.",
+ "end": "La fecha en que dejaste el medicamento, no la hora de esta dosis."
+ },
+ "fatal": {
+ "emptyFile": "Ese archivo no contiene filas.",
+ "missingColumns": "A ese archivo le falta una columna que el importador necesita: Date, Medication o Status.",
+ "unreadableJson": "Ese archivo no es JSON válido.",
+ "jsonNotAnArray": "Ese JSON no contiene ninguna lista de dosis.",
+ "jsonNoIntakeTime": "La exportación JSON registra cuándo tocaba una dosis pero no cuándo se tomó, y una hora prevista no se escribirá como si fuera la toma. Exporta el CSV, que lleva ambas.",
+ "tooManyRows": "Ese archivo tiene más filas de las que admite una importación. Divídelo e importa las partes."
+ }
}
},
"subtitle": "Exporta tus datos o importa desde una copia de seguridad."
diff --git a/messages/fr.json b/messages/fr.json
index 5d2e86ce7..57333483f 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -1917,7 +1917,19 @@
"skipGroupOther": "{count} entrées ignorées : {reason}",
"reasonDuplicateInFile": "une autre entrée du même fichier porte les mêmes date et heure",
"reasonAlreadyRecorded": "une prise est déjà enregistrée pour cette date et cette heure",
- "reasonUnknown": "motif non enregistré"
+ "reasonUnknown": "motif non enregistré",
+ "reasonMedicationNotFound": "aucun médicament enregistré ne correspond au nom du fichier",
+ "reasonMedicationAmbiguous": "le nom du fichier correspond à plusieurs médicaments",
+ "reasonMedicationIsMirrored": "le médicament est une copie en lecture seule provenant d'une autre application",
+ "reasonStatusNotRecorded": "le fichier ne consigne aucune décision sur cette dose",
+ "reasonStatusUnknown": "le statut du fichier ne fait pas partie de ceux que définit le format",
+ "reasonMissingTimestamp": "la ligne n'indique aucune heure",
+ "reasonMissingOffset": "l'heure ne porte pas de décalage UTC, l'instant est donc inconnu",
+ "reasonUnreadableTimestamp": "l'heure n'est pas au format documenté par l'export",
+ "reasonImplausibleTimestamp": "l'heure ne désigne aucun instant réel, ou se situe dans le futur",
+ "reasonMissingMedication": "la ligne ne nomme aucun médicament",
+ "reasonUnreadableDosage": "la dose n'est pas un nombre",
+ "reasonUnreadableRow": "la ligne a moins de colonnes que l'en-tête"
}
},
"charts": {
@@ -4547,6 +4559,60 @@
"reasonUnstableExternalId": "l'identifiant externe n'est pas stable d'un export à l'autre",
"reasonDuplicate": "déjà importée",
"reasonUnknown": "ignorée"
+ },
+ "doseHistory": {
+ "title": "Historique des prises",
+ "description": "Un historique de prises exporté depuis une autre application de suivi, lu tel quel. Un fichier couvre tous les médicaments qu'il contient.",
+ "pasteLabel": "Coller l'export",
+ "fileInputLabel": "Choisir un fichier d'historique des prises",
+ "uploadFile": "Choisir un fichier",
+ "preview": "Aperçu",
+ "import": "Importer",
+ "empty": "Collez d'abord l'export ou choisissez un fichier.",
+ "readFailed": "Ce fichier n'a pas pu être lu.",
+ "rateLimited": "Maximum 5 imports par heure. Réessayez plus tard.",
+ "tooLarge": "Ce fichier dépasse la limite de 16 Mo.",
+ "inProgress": "Un import d'historique est déjà en cours. Attendez qu'il se termine.",
+ "workerUnavailable": "Le service d'arrière-plan est indisponible, rien n'a été importé.",
+ "failed": "L'import a échoué. Rien n'a été écrit.",
+ "columnsHeading": "Ce que devient chaque colonne",
+ "jsonOnly": "JSON uniquement",
+ "previewLine": "Lignes lues : {rows} · seraient importées : {queued} · refusées : {refused}",
+ "submittedLine": "Lignes lues : {rows} · en file d'import : {queued}",
+ "unmatched": "Aucun médicament enregistré ne correspond à : {names}",
+ "ambiguous": "Correspond à plusieurs médicaments : {names}",
+ "mirrored": "Copié depuis une autre application, aucune prise ne peut y être ajoutée : {names}",
+ "unknownColumns": "Colonnes sans règle dans l'importeur, non lues : {names}",
+ "codingsNotRead": "Lignes portant une valeur Codings non lue : {count}",
+ "archivedRows": "Lignes d'un médicament archivé dans l'application source, importées quand même : {count}",
+ "verdict": {
+ "honoured": "utilisée",
+ "reported": "signalée",
+ "ignored": "non lue"
+ },
+ "column": {
+ "date": "L'heure à laquelle la dose a réellement été prise. Son décalage UTC est lu exactement tel qu'il est écrit.",
+ "scheduledDate": "Le créneau auquel la dose appartenait, distinct de l'heure de prise. Vide signifie qu'elle n'appartenait à aucun créneau.",
+ "medication": "Rapproché d'un médicament déjà présent dans votre dossier. Rien n'est créé à partir du fichier.",
+ "nickname": "Essayé comme second nom lorsque le premier ne correspond à rien.",
+ "dosage": "Consigné sur la dose uniquement lorsqu'il diffère de la quantité prévue.",
+ "scheduledDosage": "Comparé à la quantité réelle pour décider s'il y a eu écart. Jamais conservé seul.",
+ "unit": "Écrit avec la quantité, orthographié comme dans le fichier. « count » signifie des multiples de la dose configurée.",
+ "status": "« Taken » devient une prise et « Skipped » une omission délibérée. Toute autre valeur ne consigne aucune décision et la ligne est refusée.",
+ "archived": "Compté et signalé. Une prise qui a eu lieu n'est pas annulée parce que le médicament a été archivé ensuite.",
+ "codings": "Le CSV ne documente aucun encodage pour cette colonne, elle est donc laissée telle quelle plutôt que devinée. Les cellules remplies sont comptées.",
+ "form": "Décrit le médicament, pas la prise.",
+ "start": "La date de début du traitement, pas l'heure de cette prise.",
+ "end": "La date d'arrêt du traitement, pas l'heure de cette prise."
+ },
+ "fatal": {
+ "emptyFile": "Ce fichier ne contient aucune ligne.",
+ "missingColumns": "Il manque à ce fichier une colonne dont l'importeur a besoin : Date, Medication ou Status.",
+ "unreadableJson": "Ce fichier n'est pas du JSON valide.",
+ "jsonNotAnArray": "Ce JSON ne contient aucune liste de prises.",
+ "jsonNoIntakeTime": "L'export JSON consigne le moment où une dose était due, pas celui où elle a été prise, et une heure prévue ne sera pas écrite comme si c'était la prise. Exportez le CSV, qui porte les deux.",
+ "tooManyRows": "Ce fichier contient plus de lignes qu'un import peut en prendre. Découpez-le et importez les parties."
+ }
}
},
"subtitle": "Exportez vos données ou importez depuis une sauvegarde."
diff --git a/messages/it.json b/messages/it.json
index d838baa38..16e645775 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -1917,7 +1917,19 @@
"skipGroupOther": "{count} voci ignorate: {reason}",
"reasonDuplicateInFile": "un’altra voce dello stesso file ha la stessa data e la stessa ora",
"reasonAlreadyRecorded": "per quella data e ora è già registrata un’assunzione",
- "reasonUnknown": "motivo non registrato"
+ "reasonUnknown": "motivo non registrato",
+ "reasonMedicationNotFound": "nessun farmaco registrato corrisponde al nome nel file",
+ "reasonMedicationAmbiguous": "il nome nel file corrisponde a più di un farmaco",
+ "reasonMedicationIsMirrored": "il farmaco è una copia in sola lettura da un'altra app",
+ "reasonStatusNotRecorded": "il file non registra alcuna decisione su quella dose",
+ "reasonStatusUnknown": "lo stato nel file non è uno di quelli definiti dal formato",
+ "reasonMissingTimestamp": "la riga non indica alcun orario",
+ "reasonMissingOffset": "l'orario non porta un offset UTC, quindi l'istante è ignoto",
+ "reasonUnreadableTimestamp": "l'orario non è nel formato documentato dall'esportazione",
+ "reasonImplausibleTimestamp": "l'orario non indica un istante reale, o è nel futuro",
+ "reasonMissingMedication": "la riga non nomina alcun farmaco",
+ "reasonUnreadableDosage": "il dosaggio non è un numero",
+ "reasonUnreadableRow": "la riga ha meno colonne dell'intestazione"
}
},
"charts": {
@@ -4547,6 +4559,60 @@
"reasonUnstableExternalId": "l'ID esterno non è stabile tra un export e l'altro",
"reasonDuplicate": "già importata",
"reasonUnknown": "saltata"
+ },
+ "doseHistory": {
+ "title": "Storico delle assunzioni",
+ "description": "Uno storico di assunzioni esportato da un'altra app di monitoraggio, letto così com'è. Un file copre tutti i farmaci che contiene.",
+ "pasteLabel": "Incolla l'esportazione",
+ "fileInputLabel": "Scegli un file con lo storico delle assunzioni",
+ "uploadFile": "Scegli file",
+ "preview": "Anteprima",
+ "import": "Importa",
+ "empty": "Prima incolla l'esportazione o scegli un file.",
+ "readFailed": "Non è stato possibile leggere quel file.",
+ "rateLimited": "Massimo 5 importazioni all'ora. Riprova più tardi.",
+ "tooLarge": "Quel file supera il limite di 16 MB.",
+ "inProgress": "Un'importazione dello storico è già in corso. Attendi che finisca.",
+ "workerUnavailable": "Il processo in background non è disponibile, quindi non è stato importato nulla.",
+ "failed": "L'importazione è fallita. Non è stato scritto nulla.",
+ "columnsHeading": "Cosa fa ogni colonna",
+ "jsonOnly": "solo JSON",
+ "previewLine": "Righe lette: {rows} · da importare: {queued} · rifiutate: {refused}",
+ "submittedLine": "Righe lette: {rows} · in coda per l'importazione: {queued}",
+ "unmatched": "Nessun farmaco registrato corrisponde a: {names}",
+ "ambiguous": "Corrisponde a più di un farmaco: {names}",
+ "mirrored": "Copiato da un'altra app, quindi non si può aggiungere alcuna dose: {names}",
+ "unknownColumns": "Colonne senza regola nell'importatore, non lette: {names}",
+ "codingsNotRead": "Righe con un valore Codings non letto: {count}",
+ "archivedRows": "Righe di un farmaco archiviato nell'app di origine, importate comunque: {count}",
+ "verdict": {
+ "honoured": "usata",
+ "reported": "segnalata",
+ "ignored": "non letta"
+ },
+ "column": {
+ "date": "L'ora in cui la dose è stata effettivamente assunta. Il suo offset UTC viene letto esattamente come è scritto.",
+ "scheduledDate": "La fascia a cui la dose apparteneva, tenuta distinta dall'ora di assunzione. Vuoto significa che non apparteneva a nessuna fascia.",
+ "medication": "Abbinato a un farmaco già presente nella tua scheda. Dal file non viene creato nulla.",
+ "nickname": "Provato come secondo nome quando il primo non corrisponde a niente.",
+ "dosage": "Registrato sulla dose solo quando differisce dalla quantità prevista.",
+ "scheduledDosage": "Confrontato con la quantità effettiva per stabilire se quella dose si è discostata. Mai salvato da solo.",
+ "unit": "Scritto con la quantità, come lo scrive il file. \"count\" indica multipli della dose configurata.",
+ "status": "\"Taken\" diventa una dose e \"Skipped\" un'omissione deliberata. Qualsiasi altro valore non registra alcuna decisione e la riga viene rifiutata.",
+ "archived": "Contato e segnalato. Una dose che è avvenuta non si annulla archiviando il farmaco dopo.",
+ "codings": "Il CSV non documenta alcuna codifica per questa colonna, quindi viene lasciata stare invece di essere indovinata. Le celle piene vengono contate.",
+ "form": "Descrive il farmaco, non la dose.",
+ "start": "La data in cui hai iniziato il farmaco, non l'ora di questa dose.",
+ "end": "La data in cui hai smesso il farmaco, non l'ora di questa dose."
+ },
+ "fatal": {
+ "emptyFile": "Quel file non contiene righe.",
+ "missingColumns": "A quel file manca una colonna che serve all'importatore: Date, Medication o Status.",
+ "unreadableJson": "Quel file non è JSON valido.",
+ "jsonNotAnArray": "Quel JSON non contiene alcun elenco di dosi.",
+ "jsonNoIntakeTime": "L'esportazione JSON registra quando una dose era prevista, non quando è stata assunta, e un orario previsto non verrà scritto come se fosse l'assunzione. Esporta il CSV, che porta entrambi.",
+ "tooManyRows": "Quel file contiene più righe di quante un'importazione possa accoglierne. Dividilo e importa le parti."
+ }
}
},
"subtitle": "Esporta i tuoi dati o importa da un backup."
diff --git a/messages/pl.json b/messages/pl.json
index 2fd455604..4a795f816 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -1917,7 +1917,19 @@
"skipGroupOther": "Pominięto {count} wpisów: {reason}",
"reasonDuplicateInFile": "inny wpis w tym samym pliku ma tę samą datę i godzinę",
"reasonAlreadyRecorded": "przyjęcie o tej dacie i godzinie jest już zapisane",
- "reasonUnknown": "powód nieodnotowany"
+ "reasonUnknown": "powód nieodnotowany",
+ "reasonMedicationNotFound": "żaden zapisany lek nie odpowiada nazwie z pliku",
+ "reasonMedicationAmbiguous": "nazwa z pliku odpowiada więcej niż jednemu lekowi",
+ "reasonMedicationIsMirrored": "lek jest kopią tylko do odczytu z innej aplikacji",
+ "reasonStatusNotRecorded": "plik nie zapisuje żadnej decyzji o tej dawce",
+ "reasonStatusUnknown": "status w pliku nie należy do tych, które definiuje format",
+ "reasonMissingTimestamp": "wiersz nie podaje godziny",
+ "reasonMissingOffset": "godzina nie ma przesunięcia UTC, więc moment jest nieznany",
+ "reasonUnreadableTimestamp": "godzina nie ma formatu opisanego w dokumentacji eksportu",
+ "reasonImplausibleTimestamp": "godzina nie wskazuje rzeczywistego momentu albo leży w przyszłości",
+ "reasonMissingMedication": "wiersz nie nazywa żadnego leku",
+ "reasonUnreadableDosage": "dawka nie jest liczbą",
+ "reasonUnreadableRow": "wiersz ma mniej kolumn niż nagłówek"
}
},
"charts": {
@@ -4547,6 +4559,60 @@
"reasonUnstableExternalId": "identyfikator zewnętrzny nie jest stabilny między eksportami",
"reasonDuplicate": "już zaimportowano",
"reasonUnknown": "pominięto"
+ },
+ "doseHistory": {
+ "title": "Historia dawek",
+ "description": "Historia dawek wyeksportowana z innej aplikacji, czytana wprost. Jeden plik obejmuje wszystkie leki, które zawiera.",
+ "pasteLabel": "Wklej eksport",
+ "fileInputLabel": "Wybierz plik z historią dawek",
+ "uploadFile": "Wybierz plik",
+ "preview": "Podgląd",
+ "import": "Importuj",
+ "empty": "Najpierw wklej eksport albo wybierz plik.",
+ "readFailed": "Nie udało się odczytać tego pliku.",
+ "rateLimited": "Maksymalnie 5 importów na godzinę. Spróbuj później.",
+ "tooLarge": "Ten plik przekracza limit 16 MB.",
+ "inProgress": "Import historii dawek już trwa. Poczekaj, aż się zakończy.",
+ "workerUnavailable": "Proces w tle jest niedostępny, więc nic nie zostało zaimportowane.",
+ "failed": "Import nie udał się. Nic nie zostało zapisane.",
+ "columnsHeading": "Co się dzieje z każdą kolumną",
+ "jsonOnly": "tylko JSON",
+ "previewLine": "Odczytane wiersze: {rows} · do zaimportowania: {queued} · odrzucone: {refused}",
+ "submittedLine": "Odczytane wiersze: {rows} · w kolejce do importu: {queued}",
+ "unmatched": "Żaden zapisany lek nie odpowiada: {names}",
+ "ambiguous": "Odpowiada więcej niż jednemu lekowi: {names}",
+ "mirrored": "Skopiowany z innej aplikacji, więc nie można dodać dawki: {names}",
+ "unknownColumns": "Kolumny bez reguły w importerze, nieodczytane: {names}",
+ "codingsNotRead": "Wiersze z nieodczytaną wartością Codings: {count}",
+ "archivedRows": "Wiersze leku zarchiwizowanego w aplikacji źródłowej, zaimportowane mimo to: {count}",
+ "verdict": {
+ "honoured": "użyta",
+ "reported": "zgłoszona",
+ "ignored": "nieodczytana"
+ },
+ "column": {
+ "date": "Godzina rzeczywistego przyjęcia dawki. Jej przesunięcie UTC czytane jest dokładnie tak, jak zapisane.",
+ "scheduledDate": "Okno, do którego należała dawka, trzymane osobno od godziny przyjęcia. Puste znaczy, że dawka nie należała do żadnego okna.",
+ "medication": "Dopasowane do leku, który już jest w Twojej kartotece. Z pliku nic nie jest tworzone.",
+ "nickname": "Próbowane jako druga nazwa, gdy pierwsza do niczego nie pasuje.",
+ "dosage": "Zapisywane przy dawce tylko wtedy, gdy różni się od zaplanowanej ilości.",
+ "scheduledDosage": "Porównywane z rzeczywistą ilością, by rozstrzygnąć, czy dawka odbiegła od planu. Nigdy nie zapisywane samo.",
+ "unit": "Zapisywana razem z ilością, dokładnie tak, jak w pliku. „count” oznacza wielokrotności skonfigurowanej dawki.",
+ "status": "„Taken” staje się dawką, a „Skipped” świadomym pominięciem. Każda inna wartość nie zapisuje żadnej decyzji i wiersz zostaje odrzucony.",
+ "archived": "Liczone i zgłaszane. Dawka, która się odbyła, nie znika przez późniejsze zarchiwizowanie leku.",
+ "codings": "CSV nie dokumentuje dla tej kolumny żadnego kodowania, więc zostaje nietknięta zamiast zgadywana. Wypełnione komórki są liczone.",
+ "form": "Opisuje lek, nie dawkę.",
+ "start": "Data rozpoczęcia leku, nie godzina tej dawki.",
+ "end": "Data zakończenia leku, nie godzina tej dawki."
+ },
+ "fatal": {
+ "emptyFile": "Ten plik nie zawiera wierszy.",
+ "missingColumns": "W tym pliku brakuje kolumny, której potrzebuje importer: Date, Medication albo Status.",
+ "unreadableJson": "Ten plik nie jest poprawnym JSON-em.",
+ "jsonNotAnArray": "Ten JSON nie zawiera listy dawek.",
+ "jsonNoIntakeTime": "Eksport JSON zapisuje, kiedy dawka była zaplanowana, a nie kiedy została przyjęta, i zaplanowana godzina nie zostanie zapisana jako godzina przyjęcia. Wyeksportuj CSV, który zawiera obie.",
+ "tooManyRows": "Ten plik ma więcej wierszy, niż może przyjąć jeden import. Podziel go i zaimportuj części."
+ }
}
},
"subtitle": "Wyeksportuj dane lub zaimportuj z kopii zapasowej."
diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts
index 87a44dfed..b60133b32 100644
--- a/src/app/api/__tests__/module-route-gate-inventory.test.ts
+++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts
@@ -212,6 +212,10 @@ const EXEMPT_ROUTES: ReadonlyArray = [
"src/app/api/medications/compliance/route.ts",
"src/app/api/medications/intake/route.ts",
"src/app/api/medications/intake/bulk/route.ts",
+ // The account-wide dose-history import is the same class as the
+ // per-medication importer below: it writes intake rows and reads its own job.
+ "src/app/api/medications/intake/dose-history-import/route.ts",
+ "src/app/api/medications/intake/dose-history-import/[jobId]/status/route.ts",
// NB: `medications/extract` is NOT exempt — it gates on
// `requireAssistantSurface("coach")` (the NL-extraction is an assistant
// surface), so the inventory already counts it as a delegated gate.
diff --git a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts
index a11c60a78..7a7d1bf03 100644
--- a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts
+++ b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts
@@ -239,14 +239,20 @@ describe("POST /api/medications/[id]/intake/import — durable kickoff", () => {
// A legacy `zaehler` on the first row rides along and decides nothing:
// both keys come from the instant, so two different times can never
// collapse onto one key again.
+ // This body carries one time per row and nothing to say which slot it
+ // belonged to, so `scheduledFor` mirrors the take: every dose it
+ // describes is ad-hoc, anchored on itself. The export-reading route is
+ // the one with two facts to write.
payload: {
entries: [
{
idempotencyKey: `import-m1-${Date.parse("2026-01-01T07:00:00")}`,
+ scheduledFor: expect.any(String),
takenAt: expect.any(String),
},
{
idempotencyKey: `import-m1-${Date.parse("2026-01-01T19:00:00")}`,
+ scheduledFor: expect.any(String),
takenAt: expect.any(String),
},
],
diff --git a/src/components/medications/intake-import-result.tsx b/src/components/medications/intake-import-result.tsx
index 69bbf5754..c87c600ba 100644
--- a/src/components/medications/intake-import-result.tsx
+++ b/src/components/medications/intake-import-result.tsx
@@ -55,18 +55,64 @@ function useSkipReasonLabel(): (reason: string) => string {
const labels: Record = {
duplicate_in_file: t("medications.import.reasonDuplicateInFile"),
already_recorded: t("medications.import.reasonAlreadyRecorded"),
+ medication_not_found: t("medications.import.reasonMedicationNotFound"),
+ medication_ambiguous: t("medications.import.reasonMedicationAmbiguous"),
+ medication_is_mirrored: t("medications.import.reasonMedicationIsMirrored"),
+ status_not_recorded: t("medications.import.reasonStatusNotRecorded"),
+ status_unknown: t("medications.import.reasonStatusUnknown"),
+ missing_timestamp: t("medications.import.reasonMissingTimestamp"),
+ missing_timezone_offset: t("medications.import.reasonMissingOffset"),
+ unreadable_timestamp: t("medications.import.reasonUnreadableTimestamp"),
+ implausible_timestamp: t("medications.import.reasonImplausibleTimestamp"),
+ missing_medication: t("medications.import.reasonMissingMedication"),
+ unreadable_dosage: t("medications.import.reasonUnreadableDosage"),
+ unreadable_row: t("medications.import.reasonUnreadableRow"),
};
const fallback = t("medications.import.reasonUnknown");
return (reason: string) => labels[reason] ?? fallback;
}
+/**
+ * The grouped skip lines on their own, without the outcome sentence above them.
+ *
+ * Split out because a dry-run preview needs exactly these lines and must NOT
+ * borrow the outcome sentence: a preview has written nothing, so classifying it
+ * by the counts it WOULD produce would put a success tick on a run that never
+ * happened. The reason vocabulary is shared; the verdict is not.
+ */
+export function IntakeImportSkipGroups({
+ groups,
+ testId = "intake-import-skip-groups",
+}: {
+ groups: readonly IntakeImportSkipGroup[];
+ testId?: string;
+}) {
+ const { tCount } = useTranslations();
+ const labelFor = useSkipReasonLabel();
+ if (groups.length === 0) return null;
+ return (
+
+ {/* The reason lines, never the outcome sentence: a preview has
+ written nothing, and classifying it by the counts it would
+ produce would put a success tick on a run that never ran. */}
+
+ {verdict.unmatchedMedications.length > 0 && (
+
").length - 1;
+ expect(lines).toBe(MEDICATION_IMPORT_SKIP_REASONS.length);
+ // The fallback exists for a reason a future server sends that this client
+ // does not know. It must not be reachable for one this client ships with.
+ expect(html).not.toContain("reason not recorded");
+ });
+});
diff --git a/src/lib/medications/import/auto-export-format.ts b/src/lib/medications/import/auto-export-format.ts
index 2cfbf4cff..407eb4a4d 100644
--- a/src/lib/medications/import/auto-export-format.ts
+++ b/src/lib/medications/import/auto-export-format.ts
@@ -31,8 +31,6 @@ export const AUTO_EXPORT_CSV_COLUMNS = [
"Codings",
] as const;
-export type AutoExportCsvColumn = (typeof AUTO_EXPORT_CSV_COLUMNS)[number];
-
/**
* What the importer does with a column.
*
From 36d589e11be1b8deeda3d2613032a8fc3c75273a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 12:48:42 +0200
Subject: [PATCH 15/52] test(medications): close the gaps the mutation pass
found
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three things the tests said were covered and were not.
A column flipped from honoured to ignored moved both sides of its own
assertion, because the check read the verdict off the table it was checking.
The verdicts are now written out as literals, so changing one is a decision
somebody states twice.
The preview block was unreachable from an SSR-only test, so nothing would
have noticed it rendering through the outcome view — which would classify
what a preview *would* write as a success and put a tick on a run that never
ran. It moves into its own component that takes the verdict as a prop, the way
the CSV result view already does, and the preview and the archived-row report
are asserted directly.
The account-wide import narrows its in-progress check to account-wide jobs, so
an import running on one medication's page does not refuse it. Nothing pinned
that; now something does.
Also correct two comments about the earlier replay-key fault. The repeated
value in the reported file was not a dose quantity — it was copied from the
example this dialog itself displays. The field never carried identity, and the
submission was never at fault.
---
.../medications/[id]/intake/import/route.ts | 17 ++-
.../__tests__/route.test.ts | 24 ++++
.../dose-history-import-card.test.tsx | 70 +++++++++++
.../__tests__/import-result-guard.test.ts | 4 +
.../import-panel/dose-history-import-card.tsx | 100 +---------------
.../import-panel/dose-history-verdict.tsx | 113 ++++++++++++++++++
.../auto-export-format-coverage.test.ts | 37 ++++++
src/lib/medications/intake-import-payload.ts | 16 ++-
8 files changed, 274 insertions(+), 107 deletions(-)
create mode 100644 src/components/settings/import-panel/dose-history-verdict.tsx
diff --git a/src/app/api/medications/[id]/intake/import/route.ts b/src/app/api/medications/[id]/intake/import/route.ts
index 12020d103..fb18c1866 100644
--- a/src/app/api/medications/[id]/intake/import/route.ts
+++ b/src/app/api/medications/[id]/intake/import/route.ts
@@ -28,12 +28,17 @@ import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant";
// The entry shape carried a third field, `zaehler`, and the replay key was
// built from it whenever it was present. That reads as a per-row counter,
-// which is what the documented example showed — but the field was optional,
-// its uniqueness was never stated and never checked, and an export that puts
-// the dose quantity there (1 tablet, on every row) collapsed a month of
-// history onto one key: the first row imported, every later one lost the
-// `(userId, idempotencyKey)` unique and came back reported as a duplicate of
-// a dose it had nothing to do with.
+// which is what the example beside this dialog showed — but the field was
+// optional, its uniqueness was never stated and never checked, and nothing
+// told anyone it carried identity. A submission that repeated one value down
+// the column collapsed a month of history onto one key: the first row
+// imported, every later one lost the `(userId, idempotencyKey)` unique and
+// came back reported as a duplicate of a dose it had nothing to do with.
+//
+// The reported file repeated `1.0` because it was modelled on the example this
+// dialog itself displayed, which counted upward and explained nothing. So the
+// fault was never in the submission; it was in a field that decided identity
+// while presenting itself as a quantity.
//
// `zaehler` is gone from the contract rather than accepted-and-ignored. A
// payload that still carries it imports unchanged — `z.object` drops
diff --git a/src/app/api/medications/intake/dose-history-import/__tests__/route.test.ts b/src/app/api/medications/intake/dose-history-import/__tests__/route.test.ts
index 99acd93b7..dbdbe059d 100644
--- a/src/app/api/medications/intake/dose-history-import/__tests__/route.test.ts
+++ b/src/app/api/medications/intake/dose-history-import/__tests__/route.test.ts
@@ -241,6 +241,30 @@ describe("POST /api/medications/intake/dose-history-import", () => {
expect(prisma.medicationIntakeImportJob.create).not.toHaveBeenCalled();
});
+ it("looks only at account-wide work when deciding whether one is already running", async () => {
+ await POST(postReq(CSV));
+ // The in-progress check and the stale sweep both narrow on
+ // `medicationId: null`. Without that narrowing, an import running on a
+ // single medication's own page would refuse this one — and the sweep would
+ // reach across and fail it.
+ expect(prisma.medicationIntakeImportJob.findFirst).toHaveBeenCalledWith({
+ where: {
+ userId: "user-1",
+ medicationId: null,
+ status: { in: ["queued", "running"] },
+ },
+ select: { id: true },
+ });
+ expect(prisma.medicationIntakeImportJob.updateMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({
+ userId: "user-1",
+ medicationId: null,
+ }),
+ }),
+ );
+ });
+
it("fails the job and answers 503 when the worker cannot take it", async () => {
vi.mocked(getGlobalBoss).mockReturnValue(null as never);
const res = await POST(postReq(CSV));
diff --git a/src/components/settings/import-panel/__tests__/dose-history-import-card.test.tsx b/src/components/settings/import-panel/__tests__/dose-history-import-card.test.tsx
index 012464d37..9f54adce0 100644
--- a/src/components/settings/import-panel/__tests__/dose-history-import-card.test.tsx
+++ b/src/components/settings/import-panel/__tests__/dose-history-import-card.test.tsx
@@ -27,6 +27,10 @@ import { IntakeImportSkipGroups } from "@/components/medications/intake-import-r
import { DoseHistoryColumnRulings } from "../dose-history-columns";
import { DoseHistoryImportCard } from "../dose-history-import-card";
+import {
+ DoseHistoryVerdictView,
+ type DoseHistoryFileVerdict,
+} from "../dose-history-verdict";
function render(node: React.ReactElement) {
return renderToStaticMarkup(
@@ -82,6 +86,72 @@ describe("", () => {
});
});
+describe("", () => {
+ const verdict: DoseHistoryFileVerdict = {
+ rowsRead: 3395,
+ queued: 3387,
+ refused: 8,
+ refusedByReason: [{ reason: "status_not_recorded", count: 8 }],
+ unmatchedMedications: [],
+ ambiguousMedications: [],
+ mirroredMedications: [],
+ unknownColumns: [],
+ codingsNotRead: 0,
+ fromArchivedMedications: 1462,
+ };
+
+ it("shows a preview's refusals without claiming anything was written", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toContain('data-preview="true"');
+ expect(html).toContain('data-testid="dose-history-refusal-groups"');
+ expect(html).toContain("8 entries skipped");
+ // A preview has written nothing. Rendering it through the outcome view
+ // would classify 3,387-would-be-written as a success and put a tick on a
+ // run that never ran.
+ expect(html).not.toContain("data-outcome");
+ expect(html).not.toContain("text-success");
+ expect(html).not.toContain("intake-import-outcome");
+ });
+
+ it("reports the archived rows as imported rather than as a reason to drop them", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toContain('data-testid="dose-history-archived"');
+ expect(html).toContain("1462");
+ expect(html).toContain("imported anyway");
+ // Archived rows are reported, never refused.
+ expect(html).not.toContain("dose-history-unmatched");
+ });
+
+ it("names each medication it could not use, and why it could not", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toContain("Ramipril");
+ expect(html).toContain("Metformin");
+ expect(html).toContain("Vitamin D");
+ expect(html).toContain("Mood");
+ expect(html).toContain("No medication on record matches");
+ expect(html).toContain("Matches more than one medication");
+ expect(html).toContain("Mirrored from another app");
+ expect(html).toContain("no rule for");
+ expect(html).toContain("Codings value that was not read");
+ });
+});
+
describe("refusal reason sentences", () => {
it("has plain language for every reason the importer can report", () => {
const html = render(
diff --git a/src/components/settings/import-panel/__tests__/import-result-guard.test.ts b/src/components/settings/import-panel/__tests__/import-result-guard.test.ts
index c090e1694..dcf939e18 100644
--- a/src/components/settings/import-panel/__tests__/import-result-guard.test.ts
+++ b/src/components/settings/import-panel/__tests__/import-result-guard.test.ts
@@ -52,6 +52,10 @@ const IMPORT_SURFACES: ReadonlyArray<{ path: string; through: RegExp }> = [
path: "src/components/settings/import-panel/dose-history-import-card.tsx",
through: /IntakeImportResultView/,
},
+ {
+ path: "src/components/settings/import-panel/dose-history-verdict.tsx",
+ through: /IntakeImportSkipGroups/,
+ },
];
/** The success affordance: the tick component and the success colour token. */
diff --git a/src/components/settings/import-panel/dose-history-import-card.tsx b/src/components/settings/import-panel/dose-history-import-card.tsx
index e4690ce92..058d16467 100644
--- a/src/components/settings/import-panel/dose-history-import-card.tsx
+++ b/src/components/settings/import-panel/dose-history-import-card.tsx
@@ -23,7 +23,6 @@ import { AlertCircle, Loader2, PillBottle, Upload } from "lucide-react";
import {
IntakeImportResultView,
- IntakeImportSkipGroups,
type IntakeImportResultState,
} from "@/components/medications/intake-import-result";
import { Button } from "@/components/ui/button";
@@ -38,23 +37,13 @@ import {
import { invalidateMedicationReads } from "@/lib/query-keys";
import { DoseHistoryColumnRulings } from "./dose-history-columns";
+import {
+ DoseHistoryVerdictView,
+ type DoseHistoryFileVerdict,
+} from "./dose-history-verdict";
import { ImportCardShell } from "./import-card-shell";
import { MAX_PASTE_CHARS } from "./constants";
-/** The file-level verdict, exactly as the route returns it. */
-interface DoseHistoryFileVerdict {
- rowsRead: number;
- queued: number;
- refused: number;
- refusedByReason: Array<{ reason: string; count: number }>;
- unmatchedMedications: string[];
- ambiguousMedications: string[];
- mirroredMedications: string[];
- unknownColumns: string[];
- codingsNotRead: number;
- fromArchivedMedications: number;
-}
-
interface DoseHistoryKickoff {
dryRun: boolean;
jobId: string | null;
@@ -255,86 +244,7 @@ export function DoseHistoryImportCard() {
- {/* The reason lines, never the outcome sentence: a preview has
- written nothing, and classifying it by the counts it would
- produce would put a success tick on a run that never ran. */}
-
- {verdict.unmatchedMedications.length > 0 && (
-
+
)}
{outcome && }
{error && (
diff --git a/src/components/settings/import-panel/dose-history-verdict.tsx b/src/components/settings/import-panel/dose-history-verdict.tsx
new file mode 100644
index 000000000..bab72db84
--- /dev/null
+++ b/src/components/settings/import-panel/dose-history-verdict.tsx
@@ -0,0 +1,113 @@
+"use client";
+
+/**
+ * What the importer found in the file, before or alongside what it wrote.
+ *
+ * Split out of the card so each state can be rendered from a prop and asserted
+ * directly — the card itself owns a file input, a query client and a poll loop,
+ * and the thing that has to be right is what this block claims.
+ *
+ * It deliberately does NOT render the outcome sentence. A preview has written
+ * nothing, so classifying it by the counts it *would* produce would put a success
+ * tick on a run that never ran — the same defect, pointed one step earlier, as
+ * the one that made an import of nothing look finished. The reason lines are
+ * shared with the real outcome; the verdict is not.
+ */
+import { IntakeImportSkipGroups } from "@/components/medications/intake-import-result";
+import { useTranslations } from "@/lib/i18n/context";
+
+/** The file-level verdict, exactly as the route returns it. */
+export interface DoseHistoryFileVerdict {
+ rowsRead: number;
+ queued: number;
+ refused: number;
+ refusedByReason: Array<{ reason: string; count: number }>;
+ unmatchedMedications: string[];
+ ambiguousMedications: string[];
+ mirroredMedications: string[];
+ unknownColumns: string[];
+ codingsNotRead: number;
+ fromArchivedMedications: number;
+}
+
+export function DoseHistoryVerdictView({
+ verdict,
+ previewOnly,
+}: {
+ verdict: DoseHistoryFileVerdict;
+ previewOnly: boolean;
+}) {
+ const { t } = useTranslations();
+ return (
+
+ );
+}
diff --git a/src/lib/medications/import/__tests__/auto-export-format-coverage.test.ts b/src/lib/medications/import/__tests__/auto-export-format-coverage.test.ts
index c0c17b9ca..9998cb3f2 100644
--- a/src/lib/medications/import/__tests__/auto-export-format-coverage.test.ts
+++ b/src/lib/medications/import/__tests__/auto-export-format-coverage.test.ts
@@ -26,6 +26,43 @@ import {
import { AUTO_EXPORT_REFUSAL_REASONS } from "../auto-export-parse";
describe("export column rulings", () => {
+ /**
+ * The verdicts, spelled out.
+ *
+ * Every other assertion here reads the verdict off the same table it is
+ * checking, so a column flipped from `honoured` to `ignored` would move both
+ * sides of the comparison and pass — the importer would quietly stop using a
+ * column and the card would quietly say so, with nothing failing in between.
+ * Restating them as literals is what makes such a flip a decision somebody has
+ * to write down twice.
+ */
+ const EXPECTED_VERDICTS: Record = {
+ Date: "honoured",
+ "Scheduled Date": "honoured",
+ Medication: "honoured",
+ Nickname: "honoured",
+ Dosage: "honoured",
+ "Scheduled Dosage": "honoured",
+ Unit: "honoured",
+ Status: "honoured",
+ Archived: "reported",
+ Codings: "ignored",
+ form: "ignored",
+ start: "ignored",
+ end: "ignored",
+ };
+
+ it("holds the verdict each column was decided to have", () => {
+ expect(
+ Object.fromEntries(
+ AUTO_EXPORT_COLUMN_RULINGS.map((ruling) => [
+ ruling.column,
+ ruling.verdict,
+ ]),
+ ),
+ ).toEqual(EXPECTED_VERDICTS);
+ });
+
it("rules on every CSV column exactly once", () => {
const ruled = AUTO_EXPORT_COLUMN_RULINGS.filter(
(ruling) => !ruling.jsonOnly,
diff --git a/src/lib/medications/intake-import-payload.ts b/src/lib/medications/intake-import-payload.ts
index ded1f0720..da60d6944 100644
--- a/src/lib/medications/intake-import-payload.ts
+++ b/src/lib/medications/intake-import-payload.ts
@@ -19,15 +19,19 @@ export interface MedicationImportQueueEntry {
}
/**
- * An imported dose is identified by when it was taken, and by nothing else.
+ * An imported dose is identified by its medication and its instant, and by
+ * nothing else.
*
* The key used to be built from the row's optional `zaehler` field whenever it
* was present. That reads as a per-row counter, but nothing required it to be
- * one, and an export that put the dose quantity there — 1 tablet, on every row
- * — collapsed a month of distinct doses onto a single key. Deriving from the
- * instant makes the key a function of the fact it identifies, and matches the
- * live-row unique on `(user_id, medication_id, scheduled_for, source)` that the
- * database enforces regardless.
+ * one and nothing checked. The file that surfaced this repeated `1.0` down the
+ * column because it was modelled on the example the import dialog itself
+ * displayed — so the value came from us, and a field we presented as a quantity
+ * silently decided identity. A month of distinct doses collapsed onto one key.
+ *
+ * Deriving from the instant makes the key a function of the fact it identifies,
+ * and matches the live-row unique on `(user_id, medication_id, scheduled_for,
+ * source)` that the database enforces regardless.
*/
export function buildMedicationImportEntries(
medicationId: string,
From b627b92809bc7cc247da5d7d6ae22e035b15c05d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 13:14:38 +0200
Subject: [PATCH 16/52] feat(medications): rule on each export status for what
it actually is
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The seven documented statuses are four kinds of thing, so they get four
rulings instead of one.
Taken and Skipped state a decision about a dose and each maps onto something
already recorded here — a row with a take time, and a row marked skipped. That
second mapping was checked rather than assumed: skipped is documented as a
deliberate skip and held apart from autoMissed, which is the marker for a dose
nobody ever acted on, and the compliance re-fold counts a skipped slot in its
own column and never as taken. So a deliberate skip arrives as one.
Not Interacted, Not Logged and Unspecified are absences rather than
statements. Written as taken they would invent a dose; written as skipped they
would invent a decision, because not answering a notification is not choosing
not to take something; written as pending they would put outstanding doses
from years ago in front of someone today. Snoozed describes what happened to a
reminder. Notification Not Sent is about the software. None of the five
becomes a row.
All five are refused, but each under its own name, because "the file says
nothing about this dose" and "this row is about a reminder" are different
things and a person acts on them differently. Three reasons where there was
one, each with its own sentence in six locales.
An inactive medication's history imports and the medication stays inactive.
Preserving what happened and putting a medication back on the active list are
two acts, and only the first belongs to an import.
---
messages/de.json | 6 +-
messages/en.json | 6 +-
messages/es.json | 6 +-
messages/fr.json | 6 +-
messages/it.json | 6 +-
messages/pl.json | 6 +-
.../__tests__/route.test.ts | 4 +-
.../medications/intake-import-result.tsx | 8 +-
.../dose-history-import-card.test.tsx | 2 +-
src/lib/jobs/medication-intake-import.ts | 4 +-
.../auto-export-format-coverage.test.ts | 77 ++++++++++++++++---
.../__tests__/auto-export-parse.test.ts | 4 +-
.../import/__tests__/auto-export-plan.test.ts | 2 +-
.../medications/import/auto-export-format.ts | 69 +++++++++++++----
.../medications/import/auto-export-parse.ts | 27 +++++--
.../medication-dose-history-import.test.ts | 27 ++++++-
.../medication-intake-import-job.test.ts | 8 +-
17 files changed, 216 insertions(+), 52 deletions(-)
diff --git a/messages/de.json b/messages/de.json
index f1cfb2c16..55d67f5f4 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -1921,7 +1921,6 @@
"reasonMedicationNotFound": "kein Medikament im Bestand passt zum Namen in der Datei",
"reasonMedicationAmbiguous": "der Name in der Datei passt auf mehr als ein Medikament",
"reasonMedicationIsMirrored": "das Medikament ist eine schreibgeschützte Kopie aus einer anderen App",
- "reasonStatusNotRecorded": "die Datei hält zu dieser Dosis keine Entscheidung fest",
"reasonStatusUnknown": "der Status in der Datei gehört nicht zum dokumentierten Format",
"reasonMissingTimestamp": "die Zeile nennt keine Zeit",
"reasonMissingOffset": "die Zeit trägt keinen UTC-Offset, der Zeitpunkt ist damit unbekannt",
@@ -1929,7 +1928,10 @@
"reasonImplausibleTimestamp": "die Zeit nennt keinen echten Zeitpunkt oder liegt in der Zukunft",
"reasonMissingMedication": "die Zeile nennt kein Medikament",
"reasonUnreadableDosage": "die Dosis ist keine Zahl",
- "reasonUnreadableRow": "die Zeile hat weniger Spalten als die Kopfzeile"
+ "reasonUnreadableRow": "die Zeile hat weniger Spalten als die Kopfzeile",
+ "reasonStatusNoDoseInformation": "die Datei hält zu dieser Dosis nichts fest — niemand hat reagiert und nichts wurde eingetragen",
+ "reasonStatusReminderEvent": "die Zeile betrifft eine verschobene Erinnerung und sagt nichts über die Dosis",
+ "reasonStatusNotificationNotSent": "die Zeile hält fest, dass keine Benachrichtigung gesendet wurde — das betrifft die App, nicht die Dosis"
}
},
"charts": {
diff --git a/messages/en.json b/messages/en.json
index d7da7aa75..bfd7535dd 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1921,7 +1921,6 @@
"reasonMedicationNotFound": "no medication on record matches the name in the file",
"reasonMedicationAmbiguous": "the name in the file matches more than one medication on record",
"reasonMedicationIsMirrored": "the medication is a read-only copy of another app's list",
- "reasonStatusNotRecorded": "the file records no decision about that dose",
"reasonStatusUnknown": "the status in the file is not one the format defines",
"reasonMissingTimestamp": "the row states no time",
"reasonMissingOffset": "the time carries no UTC offset, so the instant is unknown",
@@ -1929,7 +1928,10 @@
"reasonImplausibleTimestamp": "the time names no real instant, or lies in the future",
"reasonMissingMedication": "the row names no medication",
"reasonUnreadableDosage": "the dosage is not a number",
- "reasonUnreadableRow": "the row has fewer columns than the header"
+ "reasonUnreadableRow": "the row has fewer columns than the header",
+ "reasonStatusNoDoseInformation": "the file records nothing about that dose — nobody answered and nothing was logged",
+ "reasonStatusReminderEvent": "the row is about a snoozed reminder, which says nothing about the dose",
+ "reasonStatusNotificationNotSent": "the row records that no notification was sent, which is about the app rather than the dose"
}
},
"charts": {
diff --git a/messages/es.json b/messages/es.json
index f3e2c7dbc..f150bd1d9 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -1921,7 +1921,6 @@
"reasonMedicationNotFound": "ningún medicamento registrado coincide con el nombre del archivo",
"reasonMedicationAmbiguous": "el nombre del archivo coincide con más de un medicamento",
"reasonMedicationIsMirrored": "el medicamento es una copia de solo lectura de otra aplicación",
- "reasonStatusNotRecorded": "el archivo no registra ninguna decisión sobre esa dosis",
"reasonStatusUnknown": "el estado del archivo no es uno de los que define el formato",
"reasonMissingTimestamp": "la fila no indica ninguna hora",
"reasonMissingOffset": "la hora no lleva desplazamiento UTC, así que el instante es desconocido",
@@ -1929,7 +1928,10 @@
"reasonImplausibleTimestamp": "la hora no nombra un instante real, o está en el futuro",
"reasonMissingMedication": "la fila no nombra ningún medicamento",
"reasonUnreadableDosage": "la dosis no es un número",
- "reasonUnreadableRow": "la fila tiene menos columnas que la cabecera"
+ "reasonUnreadableRow": "la fila tiene menos columnas que la cabecera",
+ "reasonStatusNoDoseInformation": "el archivo no registra nada sobre esa dosis: nadie respondió y nada se anotó",
+ "reasonStatusReminderEvent": "la fila trata de un recordatorio postergado, que no dice nada sobre la dosis",
+ "reasonStatusNotificationNotSent": "la fila registra que no se envió ninguna notificación, lo cual habla de la aplicación y no de la dosis"
}
},
"charts": {
diff --git a/messages/fr.json b/messages/fr.json
index 57333483f..50fdd82da 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -1921,7 +1921,6 @@
"reasonMedicationNotFound": "aucun médicament enregistré ne correspond au nom du fichier",
"reasonMedicationAmbiguous": "le nom du fichier correspond à plusieurs médicaments",
"reasonMedicationIsMirrored": "le médicament est une copie en lecture seule provenant d'une autre application",
- "reasonStatusNotRecorded": "le fichier ne consigne aucune décision sur cette dose",
"reasonStatusUnknown": "le statut du fichier ne fait pas partie de ceux que définit le format",
"reasonMissingTimestamp": "la ligne n'indique aucune heure",
"reasonMissingOffset": "l'heure ne porte pas de décalage UTC, l'instant est donc inconnu",
@@ -1929,7 +1928,10 @@
"reasonImplausibleTimestamp": "l'heure ne désigne aucun instant réel, ou se situe dans le futur",
"reasonMissingMedication": "la ligne ne nomme aucun médicament",
"reasonUnreadableDosage": "la dose n'est pas un nombre",
- "reasonUnreadableRow": "la ligne a moins de colonnes que l'en-tête"
+ "reasonUnreadableRow": "la ligne a moins de colonnes que l'en-tête",
+ "reasonStatusNoDoseInformation": "le fichier ne consigne rien sur cette dose : personne n'a répondu et rien n'a été noté",
+ "reasonStatusReminderEvent": "la ligne concerne un rappel reporté, ce qui ne dit rien de la dose",
+ "reasonStatusNotificationNotSent": "la ligne consigne qu'aucune notification n'a été envoyée, ce qui parle de l'application et non de la dose"
}
},
"charts": {
diff --git a/messages/it.json b/messages/it.json
index 16e645775..aed593603 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -1921,7 +1921,6 @@
"reasonMedicationNotFound": "nessun farmaco registrato corrisponde al nome nel file",
"reasonMedicationAmbiguous": "il nome nel file corrisponde a più di un farmaco",
"reasonMedicationIsMirrored": "il farmaco è una copia in sola lettura da un'altra app",
- "reasonStatusNotRecorded": "il file non registra alcuna decisione su quella dose",
"reasonStatusUnknown": "lo stato nel file non è uno di quelli definiti dal formato",
"reasonMissingTimestamp": "la riga non indica alcun orario",
"reasonMissingOffset": "l'orario non porta un offset UTC, quindi l'istante è ignoto",
@@ -1929,7 +1928,10 @@
"reasonImplausibleTimestamp": "l'orario non indica un istante reale, o è nel futuro",
"reasonMissingMedication": "la riga non nomina alcun farmaco",
"reasonUnreadableDosage": "il dosaggio non è un numero",
- "reasonUnreadableRow": "la riga ha meno colonne dell'intestazione"
+ "reasonUnreadableRow": "la riga ha meno colonne dell'intestazione",
+ "reasonStatusNoDoseInformation": "il file non registra nulla su quella dose: nessuno ha risposto e niente è stato annotato",
+ "reasonStatusReminderEvent": "la riga riguarda un promemoria postposto, che non dice nulla sulla dose",
+ "reasonStatusNotificationNotSent": "la riga registra che non è stata inviata alcuna notifica, il che riguarda l'app e non la dose"
}
},
"charts": {
diff --git a/messages/pl.json b/messages/pl.json
index 4a795f816..d959fa615 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -1921,7 +1921,6 @@
"reasonMedicationNotFound": "żaden zapisany lek nie odpowiada nazwie z pliku",
"reasonMedicationAmbiguous": "nazwa z pliku odpowiada więcej niż jednemu lekowi",
"reasonMedicationIsMirrored": "lek jest kopią tylko do odczytu z innej aplikacji",
- "reasonStatusNotRecorded": "plik nie zapisuje żadnej decyzji o tej dawce",
"reasonStatusUnknown": "status w pliku nie należy do tych, które definiuje format",
"reasonMissingTimestamp": "wiersz nie podaje godziny",
"reasonMissingOffset": "godzina nie ma przesunięcia UTC, więc moment jest nieznany",
@@ -1929,7 +1928,10 @@
"reasonImplausibleTimestamp": "godzina nie wskazuje rzeczywistego momentu albo leży w przyszłości",
"reasonMissingMedication": "wiersz nie nazywa żadnego leku",
"reasonUnreadableDosage": "dawka nie jest liczbą",
- "reasonUnreadableRow": "wiersz ma mniej kolumn niż nagłówek"
+ "reasonUnreadableRow": "wiersz ma mniej kolumn niż nagłówek",
+ "reasonStatusNoDoseInformation": "plik nie zapisuje nic o tej dawce — nikt nie odpowiedział i nic nie zostało zanotowane",
+ "reasonStatusReminderEvent": "wiersz dotyczy odłożonego przypomnienia, co nic nie mówi o dawce",
+ "reasonStatusNotificationNotSent": "wiersz zapisuje, że nie wysłano powiadomienia, co dotyczy aplikacji, a nie dawki"
}
},
"charts": {
diff --git a/src/app/api/medications/intake/dose-history-import/__tests__/route.test.ts b/src/app/api/medications/intake/dose-history-import/__tests__/route.test.ts
index dbdbe059d..a5ef639a6 100644
--- a/src/app/api/medications/intake/dose-history-import/__tests__/route.test.ts
+++ b/src/app/api/medications/intake/dose-history-import/__tests__/route.test.ts
@@ -127,7 +127,7 @@ describe("POST /api/medications/intake/dose-history-import", () => {
rowsRead: 3,
queued: 2,
refused: 1,
- refusedByReason: [{ reason: "status_not_recorded", count: 1 }],
+ refusedByReason: [{ reason: "status_no_dose_information", count: 1 }],
unmatchedMedications: [],
ambiguousMedications: [],
mirroredMedications: [],
@@ -170,7 +170,7 @@ describe("POST /api/medications/intake/dose-history-import", () => {
total: 2,
imported: 0,
// Seeded, so the count at the end of the run covers the whole file.
- skippedByReason: { status_not_recorded: 1 },
+ skippedByReason: { status_no_dose_information: 1 },
touchedDays: [],
rollupProcessed: 0,
},
diff --git a/src/components/medications/intake-import-result.tsx b/src/components/medications/intake-import-result.tsx
index c87c600ba..4493fc219 100644
--- a/src/components/medications/intake-import-result.tsx
+++ b/src/components/medications/intake-import-result.tsx
@@ -58,7 +58,13 @@ function useSkipReasonLabel(): (reason: string) => string {
medication_not_found: t("medications.import.reasonMedicationNotFound"),
medication_ambiguous: t("medications.import.reasonMedicationAmbiguous"),
medication_is_mirrored: t("medications.import.reasonMedicationIsMirrored"),
- status_not_recorded: t("medications.import.reasonStatusNotRecorded"),
+ status_no_dose_information: t(
+ "medications.import.reasonStatusNoDoseInformation",
+ ),
+ status_reminder_event: t("medications.import.reasonStatusReminderEvent"),
+ status_notification_not_sent: t(
+ "medications.import.reasonStatusNotificationNotSent",
+ ),
status_unknown: t("medications.import.reasonStatusUnknown"),
missing_timestamp: t("medications.import.reasonMissingTimestamp"),
missing_timezone_offset: t("medications.import.reasonMissingOffset"),
diff --git a/src/components/settings/import-panel/__tests__/dose-history-import-card.test.tsx b/src/components/settings/import-panel/__tests__/dose-history-import-card.test.tsx
index 9f54adce0..b35ca4fd8 100644
--- a/src/components/settings/import-panel/__tests__/dose-history-import-card.test.tsx
+++ b/src/components/settings/import-panel/__tests__/dose-history-import-card.test.tsx
@@ -91,7 +91,7 @@ describe("", () => {
rowsRead: 3395,
queued: 3387,
refused: 8,
- refusedByReason: [{ reason: "status_not_recorded", count: 8 }],
+ refusedByReason: [{ reason: "status_no_dose_information", count: 8 }],
unmatchedMedications: [],
ambiguousMedications: [],
mirroredMedications: [],
diff --git a/src/lib/jobs/medication-intake-import.ts b/src/lib/jobs/medication-intake-import.ts
index 6a11880a3..2fa7f0228 100644
--- a/src/lib/jobs/medication-intake-import.ts
+++ b/src/lib/jobs/medication-intake-import.ts
@@ -78,7 +78,9 @@ export const MEDICATION_IMPORT_SKIP_REASONS = [
"medication_not_found",
"medication_ambiguous",
"medication_is_mirrored",
- "status_not_recorded",
+ "status_no_dose_information",
+ "status_reminder_event",
+ "status_notification_not_sent",
"status_unknown",
"missing_timestamp",
"missing_timezone_offset",
diff --git a/src/lib/medications/import/__tests__/auto-export-format-coverage.test.ts b/src/lib/medications/import/__tests__/auto-export-format-coverage.test.ts
index 9998cb3f2..01ba2bb4d 100644
--- a/src/lib/medications/import/__tests__/auto-export-format-coverage.test.ts
+++ b/src/lib/medications/import/__tests__/auto-export-format-coverage.test.ts
@@ -21,9 +21,12 @@ import {
AUTO_EXPORT_CSV_COLUMNS,
AUTO_EXPORT_JSON_FIELDS,
AUTO_EXPORT_STATUSES,
- AUTO_EXPORT_ACTIONABLE_STATUSES,
+ AUTO_EXPORT_STATUS_DISPOSITIONS,
} from "../auto-export-format";
-import { AUTO_EXPORT_REFUSAL_REASONS } from "../auto-export-parse";
+import {
+ AUTO_EXPORT_REFUSAL_REASONS,
+ parseAutoExportCsv,
+} from "../auto-export-parse";
describe("export column rulings", () => {
/**
@@ -106,14 +109,70 @@ describe("export column rulings", () => {
});
describe("status vocabulary", () => {
- it("treats only a recorded decision as actionable", () => {
- for (const status of AUTO_EXPORT_ACTIONABLE_STATUSES) {
- expect(AUTO_EXPORT_STATUSES).toContain(status);
+ /**
+ * The ruling per documented status, written out.
+ *
+ * The file that prompted this work contains three of the seven, so the other
+ * four are documented-but-unobserved — and an unobserved status is exactly the
+ * one a later edit can quietly promote into a dose, because no fixture would
+ * notice. Restating all seven means promoting any of them has to be typed
+ * twice.
+ */
+ const EXPECTED: Record = {
+ Taken: "dose_taken",
+ Skipped: "dose_skipped",
+ "Not Interacted": "no_dose_information",
+ "Not Logged": "no_dose_information",
+ Unspecified: "no_dose_information",
+ Snoozed: "reminder_event",
+ "Notification Not Sent": "app_event",
+ };
+
+ it("holds the ruling each documented status was decided to have", () => {
+ expect({ ...AUTO_EXPORT_STATUS_DISPOSITIONS }).toEqual(EXPECTED);
+ });
+
+ it("rules on every documented status, and on nothing else", () => {
+ expect(Object.keys(AUTO_EXPORT_STATUS_DISPOSITIONS).sort()).toEqual(
+ [...AUTO_EXPORT_STATUSES].sort(),
+ );
+ });
+
+ it("writes a row for exactly the two statuses that state a decision", () => {
+ const header =
+ "Date,Scheduled Date,Medication,Nickname,Dosage,Scheduled Dosage,Unit,Status,Archived,Codings\n";
+ const now = Date.parse("2026-07-01T00:00:00.000Z");
+ // One row per documented status, so every status is exercised — including
+ // the four the reported file never contained.
+ for (const status of AUTO_EXPORT_STATUSES) {
+ const outcome = parseAutoExportCsv(
+ `${header}2025-01-01 08:00:00 +1030,2025-01-01 08:00:00 +1030,Med,Med,1.0,1.0,count,${status},No,\n`,
+ { now },
+ );
+ const expectsDose =
+ EXPECTED[status] === "dose_taken" ||
+ EXPECTED[status] === "dose_skipped";
+ expect(outcome.doses.length, `${status} → dose`).toBe(
+ expectsDose ? 1 : 0,
+ );
+ if (expectsDose) {
+ // A skip is a dose not taken; only `Taken` carries a take time. Neither
+ // is inferred from the other.
+ expect(outcome.doses[0].takenAt === null).toBe(
+ EXPECTED[status] === "dose_skipped",
+ );
+ } else {
+ // Refused, and under a reason of its own rather than a shared total.
+ expect(outcome.refusals, `${status} → refusal`).toHaveLength(1);
+ expect(outcome.refusals[0].reason).toBe(
+ {
+ no_dose_information: "status_no_dose_information",
+ reminder_event: "status_reminder_event",
+ app_event: "status_notification_not_sent",
+ }[EXPECTED[status]],
+ );
+ }
}
- // The other five documented values name the absence of a decision. Widening
- // this set means deciding what row to write for "nobody acted", which is a
- // compliance judgement the export never made.
- expect(AUTO_EXPORT_ACTIONABLE_STATUSES).toEqual(["Taken", "Skipped"]);
});
});
diff --git a/src/lib/medications/import/__tests__/auto-export-parse.test.ts b/src/lib/medications/import/__tests__/auto-export-parse.test.ts
index 569d65cb0..0dbd8f5be 100644
--- a/src/lib/medications/import/__tests__/auto-export-parse.test.ts
+++ b/src/lib/medications/import/__tests__/auto-export-parse.test.ts
@@ -58,7 +58,9 @@ describe("parseAutoExportCsv — the reported export", () => {
// The file holds `Taken`, `Skipped` and `Not Interacted`. The first two are
// decisions the person made; the eight `Not Interacted` rows are the absence
// of one, and there is no honest dose to write for them.
- expect(reasonCounts(parseFixture())).toEqual({ status_not_recorded: 8 });
+ expect(reasonCounts(parseFixture())).toEqual({
+ status_no_dose_information: 8,
+ });
});
it("keeps the scheduled slot and the time taken apart", () => {
diff --git a/src/lib/medications/import/__tests__/auto-export-plan.test.ts b/src/lib/medications/import/__tests__/auto-export-plan.test.ts
index 9a14a3a8f..15db73415 100644
--- a/src/lib/medications/import/__tests__/auto-export-plan.test.ts
+++ b/src/lib/medications/import/__tests__/auto-export-plan.test.ts
@@ -72,7 +72,7 @@ describe("planAutoExportImport — the reported export", () => {
expect(
plan.entries.length + totalMedicationImportSkips(plan.skippedByReason),
).toBe(plan.rowsRead);
- expect(plan.skippedByReason).toEqual({ status_not_recorded: 8 });
+ expect(plan.skippedByReason).toEqual({ status_no_dose_information: 8 });
});
it("names the medications it could not match instead of reporting a number", () => {
diff --git a/src/lib/medications/import/auto-export-format.ts b/src/lib/medications/import/auto-export-format.ts
index 407eb4a4d..c5dca331e 100644
--- a/src/lib/medications/import/auto-export-format.ts
+++ b/src/lib/medications/import/auto-export-format.ts
@@ -98,37 +98,76 @@ export const AUTO_EXPORT_JSON_FIELDS = [
] as const;
/**
- * Every status the format documents.
+ * What each documented status is, and therefore what the importer does with it.
*
- * `Taken` and `Skipped` each name a decision the person made about a dose, and
- * HealthLog already models both — a taken row and a deliberate skip. The rest
- * name the absence of a decision: nobody acted, or the app never asked. There
- * is no honest row to write for those. Turning "nobody acted" into a missed
- * dose would be the importer inventing a compliance fact the export never
- * asserted, and writing them as still-pending would put outstanding doses from
- * 2023 back in front of the person today.
+ * Seven values, and they are not seven shades of one thing — they divide into
+ * four kinds, so they get four rulings rather than one blanket rule.
+ *
+ * `dose_taken` / `dose_skipped` — the two that state a decision the person made
+ * about a dose. Both map one-to-one onto something HealthLog already records: a
+ * row with a `takenAt`, and a row with `skipped = true`. That second mapping was
+ * checked rather than assumed — `skipped` is documented as a deliberate user
+ * skip, held apart from `autoMissed`, which is the marker for a dose nobody ever
+ * acted on. So a deliberate skip in the export lands as a deliberate skip here,
+ * and not as a miss.
+ *
+ * `no_dose_information` — `Not Interacted`, `Not Logged`, `Unspecified`. These
+ * are absences, not statements. Nobody answered, or nothing was written down.
+ * Writing them as taken would invent a dose; writing them as skipped would
+ * invent a decision, because "did not answer the notification" is not "chose not
+ * to take it"; writing them as still-pending would put outstanding doses from
+ * years ago back in front of the person today. So they are counted, reported,
+ * and not written. Absence reads as absence.
+ *
+ * `reminder_event` — `Snoozed` describes what happened to a reminder. A snoozed
+ * reminder says nothing at all about whether the medication was taken.
+ *
+ * `app_event` — `Notification Not Sent` is a fact about the software, not about
+ * the person. It belongs to no dose.
+ *
+ * The three non-dose kinds are all refused, but each under its own name, because
+ * "the file says nothing about this dose" and "this row is about a reminder" are
+ * different things and a person reading the result acts on them differently.
*/
+export const AUTO_EXPORT_STATUS_DISPOSITIONS = {
+ Taken: "dose_taken",
+ Skipped: "dose_skipped",
+ "Not Interacted": "no_dose_information",
+ "Not Logged": "no_dose_information",
+ Unspecified: "no_dose_information",
+ Snoozed: "reminder_event",
+ "Notification Not Sent": "app_event",
+} as const satisfies Record;
+
+export type AutoExportStatusDisposition =
+ | "dose_taken"
+ | "dose_skipped"
+ | "no_dose_information"
+ | "reminder_event"
+ | "app_event";
+
+/** Every status the format documents, in the order the documentation lists it. */
export const AUTO_EXPORT_STATUSES = [
- "Taken",
- "Skipped",
"Not Interacted",
"Notification Not Sent",
"Snoozed",
+ "Taken",
+ "Skipped",
"Not Logged",
"Unspecified",
] as const;
export type AutoExportStatus = (typeof AUTO_EXPORT_STATUSES)[number];
-/** The two statuses that become a row. */
-export const AUTO_EXPORT_ACTIONABLE_STATUSES = ["Taken", "Skipped"] as const;
-
export function isAutoExportStatus(value: string): value is AutoExportStatus {
return (AUTO_EXPORT_STATUSES as readonly string[]).includes(value);
}
-export function isActionableAutoExportStatus(value: string): boolean {
- return (AUTO_EXPORT_ACTIONABLE_STATUSES as readonly string[]).includes(value);
+/** The ruling for a documented status. */
+export function autoExportStatusDisposition(
+ status: AutoExportStatus,
+): AutoExportStatusDisposition {
+ return AUTO_EXPORT_STATUS_DISPOSITIONS[status];
}
/**
diff --git a/src/lib/medications/import/auto-export-parse.ts b/src/lib/medications/import/auto-export-parse.ts
index 99d55f5e2..357d9ac76 100644
--- a/src/lib/medications/import/auto-export-parse.ts
+++ b/src/lib/medications/import/auto-export-parse.ts
@@ -19,8 +19,8 @@ import { splitCsvRows } from "@/lib/import/csv-measurements";
import {
AUTO_EXPORT_CSV_COLUMNS,
+ autoExportStatusDisposition,
formatAutoExportDose,
- isActionableAutoExportStatus,
isAutoExportStatus,
} from "./auto-export-format";
import {
@@ -60,8 +60,12 @@ export interface AutoExportDose {
* none of them is a silent drop.
*/
export const AUTO_EXPORT_REFUSAL_REASONS = [
- /** Status names no decision about the dose (`Not Interacted`, `Snoozed`, …). */
- "status_not_recorded",
+ /** `Not Interacted` / `Not Logged` / `Unspecified` — an absence, not a dose. */
+ "status_no_dose_information",
+ /** `Snoozed` — something that happened to a reminder, not to a dose. */
+ "status_reminder_event",
+ /** `Notification Not Sent` — a fact about the app, not about the person. */
+ "status_notification_not_sent",
/** Status is not a value the format documents at all. */
"status_unknown",
/** `Date` is blank — there is no dose without an instant. */
@@ -214,8 +218,19 @@ function readRow(input: RowInput, now: number): RowOutcome {
if (!isAutoExportStatus(status)) {
return { ok: false, reason: "status_unknown" };
}
- if (!isActionableAutoExportStatus(status)) {
- return { ok: false, reason: "status_not_recorded" };
+ // Four kinds, four answers. Only the two that state a decision about a dose
+ // become a row; the others are refused under the name of what they actually
+ // are, so the person is told "this row is about a reminder" rather than being
+ // left to read a total.
+ const disposition = autoExportStatusDisposition(status);
+ if (disposition === "no_dose_information") {
+ return { ok: false, reason: "status_no_dose_information" };
+ }
+ if (disposition === "reminder_event") {
+ return { ok: false, reason: "status_reminder_event" };
+ }
+ if (disposition === "app_event") {
+ return { ok: false, reason: "status_notification_not_sent" };
}
const medicationName = input.medication.trim();
@@ -262,7 +277,7 @@ function readRow(input: RowInput, now: number): RowOutcome {
const scheduledDosage = readDosage(input.scheduledDosage);
if (!scheduledDosage.ok) return { ok: false, reason: scheduledDosage.reason };
- const skipped = status === "Skipped";
+ const skipped = disposition === "dose_skipped";
return {
ok: true,
dose: {
diff --git a/tests/integration/medication-dose-history-import.test.ts b/tests/integration/medication-dose-history-import.test.ts
index ee85ce2cf..740c82890 100644
--- a/tests/integration/medication-dose-history-import.test.ts
+++ b/tests/integration/medication-dose-history-import.test.ts
@@ -129,7 +129,7 @@ describe("dose-history import — the reported export end to end", () => {
skipped: 8,
// The eight `Not Interacted` rows: the file states no decision about those
// doses, so there is nothing honest to write, and the count says which.
- skipReasons: [{ reason: "status_not_recorded", count: 8 }],
+ skipReasons: [{ reason: "status_no_dose_information", count: 8 }],
});
// Every row of the file is accounted for by one of the two numbers.
expect(result!.imported + result!.skipped).toBe(3395);
@@ -223,7 +223,7 @@ describe("dose-history import — the reported export end to end", () => {
skipped: 3395,
skipReasons: [
{ reason: "already_recorded", count: 3387 },
- { reason: "status_not_recorded", count: 8 },
+ { reason: "status_no_dose_information", count: 8 },
],
});
await expect(
@@ -252,6 +252,29 @@ describe("dose-history import — the reported export end to end", () => {
).resolves.toBe(2);
}, 240_000);
+ it("imports an inactive medication's history without putting it back on the active list", async () => {
+ const prisma = getPrismaClient();
+ const user = await seedRegimen();
+ // Standing in for a medication archived in the source app: the person has
+ // stopped taking it here too. Importing the history it names is one act;
+ // making the medication current again is a different one, and only the first
+ // was asked for. Someone re-adding it later should find the history waiting,
+ // not find it already re-added on their behalf.
+ await prisma.medication.updateMany({
+ where: { userId: user.id },
+ data: { active: false },
+ });
+
+ const { jobId } = await enqueue(user.id);
+ const result = await processMedicationIntakeImportJob(jobId);
+
+ expect(result!.imported).toBe(3387);
+ const stillInactive = await prisma.medication.count({
+ where: { userId: user.id, active: false },
+ });
+ 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();
diff --git a/tests/integration/medication-intake-import-job.test.ts b/tests/integration/medication-intake-import-job.test.ts
index f5847ffde..85c031380 100644
--- a/tests/integration/medication-intake-import-job.test.ts
+++ b/tests/integration/medication-intake-import-job.test.ts
@@ -167,7 +167,13 @@ describe("medication intake import job — concurrent retry", () => {
total: 4,
imported: 3,
skippedByReason: { duplicate_in_file: 1 },
- touchedDays: ["2026-07-20", "2026-07-21"],
+ // 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,
});
expect(audits).toHaveLength(1);
From 81ba71194675e7eb1e8b31111ba32984d7406af0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 16:37:21 +0200
Subject: [PATCH 17/52] chore(db): number the account-scoped import migration
after the ones ahead of it
---
.../migration.sql | 0
1 file changed, 0 insertions(+), 0 deletions(-)
rename prisma/migrations/{0276_account_scoped_intake_import => 0278_account_scoped_intake_import}/migration.sql (100%)
diff --git a/prisma/migrations/0276_account_scoped_intake_import/migration.sql b/prisma/migrations/0278_account_scoped_intake_import/migration.sql
similarity index 100%
rename from prisma/migrations/0276_account_scoped_intake_import/migration.sql
rename to prisma/migrations/0278_account_scoped_intake_import/migration.sql
From a3b0af577f0de5cbdf90907594c52b88663a16d0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 05:24:02 +0200
Subject: [PATCH 18/52] fix(sleep): count a re-exported sleep period once
A device can write the same period twice: an original envelope and a
corrected one, same start, later end, distinct external ids. Both rows
belong to the same writer, so the writer collapse keeps both, and the
per-stage totals added them. One night served a 10 h 13 min in-bed
headline next to a 20 h 17 min in-bed legend on the same screen, and
the hypnogram drew the night twice over.
Every per-stage total is now the measure of the union of that stage's
spans rather than the sum of the rows, and the same merged spans feed
the hypnogram segments and the time-asleep headline. Stages merge
independently: two stages describing one minute is a contest, and the
writer collapse is this file's mechanism for that - arbitrating again
would collapse the summary-shaped writers, which stamp every stage
total on the sleep end instant and nest by design.
stages.IN_BED is now the in-bed envelope itself, the same union
inBedMinutes reports, so the legend cannot contradict the headline and
a night whose envelope only a losing writer recorded still reports it.
---
.../api/sleep/night/__tests__/route.test.ts | 27 ++
.../analytics/__tests__/sleep-night.test.ts | 349 ++++++++++++++++++
src/lib/analytics/sleep-night.ts | 205 ++++++++--
3 files changed, 554 insertions(+), 27 deletions(-)
diff --git a/src/app/api/sleep/night/__tests__/route.test.ts b/src/app/api/sleep/night/__tests__/route.test.ts
index c8d43feab..1e28ffd85 100644
--- a/src/app/api/sleep/night/__tests__/route.test.ts
+++ b/src/app/api/sleep/night/__tests__/route.test.ts
@@ -201,4 +201,31 @@ describe("GET /api/sleep/night", () => {
const res = await GET(req("date=2026-06-04"));
expect(res.status).toBe(200); // never 500 on the reconstruction edge
});
+ it("cannot serve an in-bed headline that contradicts its stage legend", async () => {
+ // A device wrote the same night twice: an envelope 16:06 -> 02:09:59 and
+ // a corrected one 16:06 -> 02:18:59. Raw-summing them served a 613-minute
+ // headline next to a 1217-minute legend on the same screen.
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(prisma.measurement.findMany).mockResolvedValue([
+ stage("2026-07-25T02:09:59.000Z", "IN_BED", 603 + 59 / 60),
+ stage("2026-07-25T02:18:59.000Z", "IN_BED", 612 + 59 / 60),
+ stage("2026-07-24T21:55:42.000Z", "CORE", 319.7),
+ stage("2026-07-24T23:30:36.000Z", "DEEP", 94.9),
+ stage("2026-07-24T23:37:31.200Z", "AWAKE", 6.92),
+ stage("2026-07-25T01:53:25.200Z", "REM", 135.9),
+ ] as never);
+ const res = await GET(req("date=2026-07-25"));
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.main.inBedMinutes).toBe(613);
+ expect(body.data.main.stages.IN_BED).toBe(613);
+ // One in-bed bar on the hypnogram, not the night drawn twice over.
+ expect(
+ body.data.main.segments.filter(
+ (seg: { stage: string }) => seg.stage === "IN_BED",
+ ),
+ ).toHaveLength(1);
+ expect(body.data.main.stages.CORE).toBe(320);
+ expect(body.data.main.asleepMinutes).toBe(551);
+ });
});
diff --git a/src/lib/analytics/__tests__/sleep-night.test.ts b/src/lib/analytics/__tests__/sleep-night.test.ts
index 7cb83ecb9..4f4bce2c3 100644
--- a/src/lib/analytics/__tests__/sleep-night.test.ts
+++ b/src/lib/analytics/__tests__/sleep-night.test.ts
@@ -1252,3 +1252,352 @@ describe("night-level source discrepancy (dashboard path)", () => {
expect(latestNight?.sourceDiscrepancy).toBeNull();
});
});
+
+/**
+ * Overlapping spans within ONE writer. A device can write the SAME period
+ * twice — an original envelope and a corrected one, same start, later end,
+ * distinct external ids. Both rows belong to the same writer, so the
+ * writer collapse keeps both, and adding their minute counts reports the
+ * period twice.
+ */
+describe("overlapping spans within one writer", () => {
+ /** 16:06:00Z → 02:09:59Z — the superseded envelope. */
+ const IN_BED_ORIGINAL = 603 + 59 / 60;
+ /** 16:06:00Z → 02:18:59Z — the corrected envelope, same start. */
+ const IN_BED_REVISED = 612 + 59 / 60;
+
+ /**
+ * One reported night: two IN_BED rows sharing a start, four staged
+ * segments inside the envelope, one AWAKE bout mid-sleep. Raw-summing the
+ * two envelopes yields 1217 min (20 h 17 min) against a 613 min (10 h 13
+ * min) union.
+ */
+ function revisedEnvelopeNight(): SleepStageRow[] {
+ return [
+ srcRow(
+ "2026-07-25T02:09:59.000Z",
+ "IN_BED",
+ IN_BED_ORIGINAL,
+ "APPLE_HEALTH",
+ ),
+ srcRow(
+ "2026-07-25T02:18:59.000Z",
+ "IN_BED",
+ IN_BED_REVISED,
+ "APPLE_HEALTH",
+ ),
+ srcRow("2026-07-24T21:55:42.000Z", "CORE", 319.7, "APPLE_HEALTH"),
+ srcRow("2026-07-24T23:30:36.000Z", "DEEP", 94.9, "APPLE_HEALTH"),
+ srcRow("2026-07-24T23:37:31.200Z", "AWAKE", 6.92, "APPLE_HEALTH"),
+ srcRow("2026-07-25T01:53:25.200Z", "REM", 135.9, "APPLE_HEALTH"),
+ ];
+ }
+
+ it("reports the union, not the sum, when a revised in-bed row supersedes the original", () => {
+ const sessions = reconstructSleepSessions(revisedEnvelopeNight(), "UTC");
+ expect(sessions).toHaveLength(1);
+ const s = sessions[0];
+ // Headline and legend are ONE number: 613, never 1217.
+ expect(Math.round(s.inBedMinutes!)).toBe(613);
+ expect(Math.round(s.stages.IN_BED!)).toBe(613);
+ expect(s.stages.IN_BED).toBe(s.inBedMinutes);
+ // The staged totals of that night were never the defect.
+ expect(Math.round(s.stages.CORE!)).toBe(320);
+ expect(Math.round(s.stages.DEEP!)).toBe(95);
+ expect(Math.round(s.stages.REM!)).toBe(136);
+ expect(Math.round(s.stages.AWAKE!)).toBe(7);
+ expect(Math.round(s.asleepMinutes)).toBe(551);
+ expect(s.awakenings).toBe(1);
+ });
+
+ it("draws the revised in-bed envelope as ONE hypnogram segment", () => {
+ const sessions = reconstructSleepSessions(revisedEnvelopeNight(), "UTC");
+ const inBed = sessions[0].segments.filter((seg) => seg.stage === "IN_BED");
+ expect(inBed).toHaveLength(1);
+ expect(inBed[0].start.toISOString()).toBe("2026-07-24T16:06:00.000Z");
+ expect(inBed[0].end.toISOString()).toBe("2026-07-25T02:18:59.000Z");
+ // Five bars, not six: the superseded envelope is gone, not stacked.
+ expect(sessions[0].segments).toHaveLength(5);
+ });
+
+ it("carries the same union onto the night-level headline and legend", () => {
+ const nights = reconstructSleepNights(revisedEnvelopeNight(), "UTC");
+ expect(nights).toHaveLength(1);
+ expect(Math.round(nights[0].inBedMinutes!)).toBe(613);
+ expect(nights[0].stages.IN_BED).toBe(nights[0].inBedMinutes);
+ expect(Math.round(nights[0].stages.CORE!)).toBe(320);
+ expect(Math.round(nights[0].asleepMinutes)).toBe(551);
+ });
+
+ it("merges a re-exported CORE span instead of counting it twice", () => {
+ // The same correction shape on a sleep stage rather than the envelope:
+ // 22:00 → 00:00 superseded by 22:00 → 00:30.
+ const rows: SleepStageRow[] = [
+ srcRow("2026-06-04T00:00:00.000Z", "CORE", 120, "APPLE_HEALTH"),
+ srcRow("2026-06-04T00:30:00.000Z", "CORE", 150, "APPLE_HEALTH"),
+ srcRow("2026-06-04T02:00:00.000Z", "DEEP", 90, "APPLE_HEALTH"),
+ ];
+ const sessions = reconstructSleepSessions(rows, "UTC");
+ expect(sessions[0].stages.CORE).toBe(150);
+ expect(sessions[0].asleepMinutes).toBe(240);
+ expect(sessions[0].segments.filter((s) => s.stage === "CORE")).toHaveLength(
+ 1,
+ );
+ });
+
+ it("keeps two TOUCHING awake bouts separate — touching is not overlap", () => {
+ // Consecutive AWAKE spans (one ends exactly where the next starts)
+ // describe two bouts, not one doubled export. Coalescing them would
+ // silently drop a mid-sleep awakening.
+ const rows: SleepStageRow[] = [
+ srcRow("2026-06-04T00:00:00.000Z", "CORE", 120, "APPLE_HEALTH"),
+ srcRow("2026-06-04T00:10:00.000Z", "AWAKE", 10, "APPLE_HEALTH"),
+ srcRow("2026-06-04T00:20:00.000Z", "AWAKE", 10, "APPLE_HEALTH"),
+ srcRow("2026-06-04T02:00:00.000Z", "DEEP", 90, "APPLE_HEALTH"),
+ ];
+ const sessions = reconstructSleepSessions(rows, "UTC");
+ expect(sessions[0].stages.AWAKE).toBe(20);
+ expect(
+ sessions[0].segments.filter((s) => s.stage === "AWAKE"),
+ ).toHaveLength(2);
+ expect(sessions[0].awakenings).toBe(2);
+ });
+
+ it("leaves a summary-shaped writer's nested stage spans alone", () => {
+ // WHOOP stamps every stage total on the sleep END instant, so its spans
+ // nest inside one another by design. Arbitrating across stages would
+ // collapse the night to its longest stage — the totals must survive.
+ const rows: SleepStageRow[] = [
+ srcRow("2026-06-04T06:00:00.000Z", "CORE", 200, "WHOOP"),
+ srcRow("2026-06-04T06:00:00.000Z", "DEEP", 60, "WHOOP"),
+ srcRow("2026-06-04T06:00:00.000Z", "REM", 80, "WHOOP"),
+ srcRow("2026-06-04T06:00:00.000Z", "AWAKE", 15, "WHOOP"),
+ ];
+ const sessions = reconstructSleepSessions(rows, "UTC");
+ expect(sessions[0].stages.CORE).toBe(200);
+ expect(sessions[0].stages.DEEP).toBe(60);
+ expect(sessions[0].stages.REM).toBe(80);
+ expect(sessions[0].asleepMinutes).toBe(340);
+ });
+});
+
+describe("in-bed envelope across writers", () => {
+ it("unions two partially overlapping writer envelopes into headline AND legend", () => {
+ const rows: SleepStageRow[] = [
+ writerRow(
+ "2026-06-04T02:00:00.000Z",
+ "CORE",
+ 200,
+ "APPLE_HEALTH",
+ "watch",
+ ),
+ // Watch 22:00 → 04:00, phone 00:00 → 06:00: union 22:00 → 06:00.
+ writerRow(
+ "2026-06-04T04:00:00.000Z",
+ "IN_BED",
+ 360,
+ "APPLE_HEALTH",
+ "watch",
+ ),
+ writerRow(
+ "2026-06-04T06:00:00.000Z",
+ "IN_BED",
+ 360,
+ "APPLE_HEALTH",
+ "phone",
+ ),
+ ];
+ const sessions = reconstructSleepSessions(rows, "UTC");
+ expect(sessions[0].inBedMinutes).toBe(480);
+ expect(sessions[0].stages.IN_BED).toBe(480);
+ const nights = reconstructSleepNights(rows, "UTC");
+ expect(nights[0].stages.IN_BED).toBe(nights[0].inBedMinutes);
+ });
+
+ it("keeps the legend on the headline when only a LOSING writer knows the envelope", () => {
+ // The watch wins the night on stage richness and writes no IN_BED row;
+ // the phone's bedtime detection holds the night's only envelope. The
+ // legend must report the envelope the headline reports, not nothing.
+ const rows: SleepStageRow[] = [
+ writerRow(
+ "2026-06-04T00:30:00.000Z",
+ "CORE",
+ 120,
+ "APPLE_HEALTH",
+ "watch",
+ ),
+ writerRow(
+ "2026-06-04T02:30:00.000Z",
+ "DEEP",
+ 60,
+ "APPLE_HEALTH",
+ "watch",
+ ),
+ writerRow("2026-06-04T04:00:00.000Z", "REM", 80, "APPLE_HEALTH", "watch"),
+ writerRow(
+ "2026-06-04T04:30:00.000Z",
+ "IN_BED",
+ 460,
+ "APPLE_HEALTH",
+ "phone",
+ ),
+ ];
+ const sessions = reconstructSleepSessions(rows, "UTC");
+ expect(sessions[0].inBedMinutes).toBe(460);
+ expect(sessions[0].stages.IN_BED).toBe(460);
+ });
+
+ it("sums two DISJOINT in-bed intervals of one session without merging them", () => {
+ // Two separate in-bed windows less than the session gap apart: the
+ // envelope is the sum of the two unions, not the span between them.
+ const rows: SleepStageRow[] = [
+ srcRow("2026-06-04T00:00:00.000Z", "IN_BED", 60, "APPLE_HEALTH"),
+ srcRow("2026-06-04T04:00:00.000Z", "CORE", 120, "APPLE_HEALTH"),
+ srcRow("2026-06-04T05:00:00.000Z", "IN_BED", 60, "APPLE_HEALTH"),
+ ];
+ const sessions = reconstructSleepSessions(rows, "UTC");
+ expect(sessions).toHaveLength(1);
+ expect(sessions[0].inBedMinutes).toBe(120);
+ expect(sessions[0].stages.IN_BED).toBe(120);
+ expect(
+ sessions[0].segments.filter((s) => s.stage === "IN_BED"),
+ ).toHaveLength(2);
+ });
+
+ it("reports no in-bed signal at all when no IN_BED row exists", () => {
+ const rows: SleepStageRow[] = [
+ srcRow("2026-06-04T02:00:00.000Z", "CORE", 120, "APPLE_HEALTH"),
+ srcRow("2026-06-04T03:00:00.000Z", "DEEP", 60, "APPLE_HEALTH"),
+ ];
+ const sessions = reconstructSleepSessions(rows, "UTC");
+ expect(sessions[0].inBedMinutes).toBeNull();
+ expect(sessions[0].stages.IN_BED).toBeUndefined();
+ const nights = reconstructSleepNights(rows, "UTC");
+ expect(nights[0].inBedMinutes).toBeNull();
+ expect(nights[0].stages.IN_BED).toBeUndefined();
+ });
+});
+
+/**
+ * The invariant, checked over generated shapes rather than hand-picked ones:
+ * every stage total is the MEASURE OF THE UNION of that stage's spans, so no
+ * stage total can exceed the session's own envelope, and `stages.IN_BED` is
+ * the same number as `inBedMinutes` by construction.
+ */
+describe("stage-total invariant over generated sessions", () => {
+ /** Deterministic 32-bit PRNG — a failing seed is reproducible. */
+ function prng(seed: number): () => number {
+ let state = seed >>> 0;
+ return () => {
+ state = (state * 1_664_525 + 1_013_904_223) >>> 0;
+ return state / 4_294_967_296;
+ };
+ }
+
+ /** Total minutes covered by the union of a set of [start, end) spans. */
+ function unionMinutes(spans: Array<{ start: number; end: number }>): number {
+ if (spans.length === 0) return 0;
+ const sorted = [...spans].sort((a, b) => a.start - b.start);
+ let total = 0;
+ let curStart = sorted[0].start;
+ let curEnd = sorted[0].end;
+ for (const span of sorted.slice(1)) {
+ if (span.start <= curEnd) {
+ if (span.end > curEnd) curEnd = span.end;
+ } else {
+ total += curEnd - curStart;
+ curStart = span.start;
+ curEnd = span.end;
+ }
+ }
+ return (total + (curEnd - curStart)) / 60_000;
+ }
+
+ const GENERATED_STAGES: SleepStage[] = ["CORE", "DEEP", "REM", "AWAKE"];
+ const BASE = Date.parse("2026-06-03T20:00:00.000Z");
+
+ it("holds for 500 randomly overlapping single-writer sessions", () => {
+ const rand = prng(20_260_724);
+ for (let round = 0; round < 500; round++) {
+ // One IN_BED row spans the whole window so every generated span lands
+ // in ONE session regardless of the gaps between the staged rows.
+ const rows: SleepStageRow[] = [
+ srcRow(
+ new Date(BASE + 720 * 60_000).toISOString(),
+ "IN_BED",
+ 720,
+ "APPLE_HEALTH",
+ ),
+ ];
+ // A second, deliberately overlapping in-bed envelope on most rounds —
+ // the corrected-export shape.
+ if (rand() < 0.7) {
+ const extra = 60 + Math.floor(rand() * 660);
+ rows.push(
+ srcRow(
+ new Date(BASE + (extra + 30) * 60_000).toISOString(),
+ "IN_BED",
+ extra,
+ "APPLE_HEALTH",
+ ),
+ );
+ }
+ const spanCount = 2 + Math.floor(rand() * 8);
+ for (let i = 0; i < spanCount; i++) {
+ const stage = GENERATED_STAGES[Math.floor(rand() * 4)];
+ const start = Math.floor(rand() * 600);
+ const minutes = 5 + Math.floor(rand() * 175);
+ rows.push(
+ srcRow(
+ new Date(BASE + (start + minutes) * 60_000).toISOString(),
+ stage,
+ minutes,
+ "APPLE_HEALTH",
+ ),
+ );
+ }
+
+ const sessions = reconstructSleepSessions(rows, "UTC");
+ expect(sessions).toHaveLength(1);
+ const s = sessions[0];
+
+ // Independently recompute the spans of the raw rows.
+ const spansOf = (predicate: (r: SleepStageRow) => boolean) =>
+ rows.filter(predicate).map((r) => ({
+ start: r.measuredAt.getTime() - r.value * 60_000,
+ end: r.measuredAt.getTime(),
+ }));
+ const envelope = unionMinutes(spansOf(() => true));
+
+ for (const [stage, total] of Object.entries(s.stages)) {
+ // No stage total exceeds the session's union envelope…
+ expect(total).toBeLessThanOrEqual(envelope);
+ // …and each one IS the union measure of that stage's own spans.
+ expect(total).toBe(
+ stage === "IN_BED"
+ ? Math.round(
+ unionMinutes(spansOf((r) => r.sleepStage === "IN_BED")),
+ )
+ : unionMinutes(spansOf((r) => r.sleepStage === stage)),
+ );
+ }
+ // The legend never contradicts the headline.
+ expect(s.stages.IN_BED).toBe(s.inBedMinutes);
+ expect(s.awakeMinutes).toBe(s.stages.AWAKE ?? null);
+ // Time asleep is the same merged arithmetic as the legend it sits on.
+ const granular = ["CORE", "DEEP", "REM"].reduce(
+ (sum, stage) => sum + (s.stages[stage as SleepStage] ?? 0),
+ 0,
+ );
+ expect(s.asleepMinutes).toBe(granular);
+ // Deliberately NOT asserted: that the stage totals SUM to no more than
+ // the envelope. Two stages describing the same minute is a contest, not
+ // a doubled export, and the writer collapse is the mechanism that
+ // settles it — arbitrating again here would collapse every
+ // summary-shaped writer (WHOOP / Polar stamp all stage totals on the
+ // sleep END instant, so their spans nest by design) to its longest
+ // stage. The per-stage bound above is what the merge can honestly
+ // guarantee, and it is what the contradictory legend needed.
+ }
+ });
+});
diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts
index d3a2d58ea..138e6fc9e 100644
--- a/src/lib/analytics/sleep-night.ts
+++ b/src/lib/analytics/sleep-night.ts
@@ -93,10 +93,28 @@
* the long IN_BED window — the watch wins the night on richness, and a
* winner-only in-bed figure would shrink to nothing exactly when the
* stage data is best. Overlapping spans from two writers are merged
- * interval-union style so a doubled export never double-counts. The
- * asleep total, the `stages` map, and `awakeMinutes` stay winner-only —
- * blending the phone's coarse AWAKE blocks into the watch's night was
- * the original inflated-awake bug this collapse exists to prevent.
+ * interval-union style so a doubled export never double-counts.
+ * `stages.IN_BED` IS that same number — one quantity, one computation —
+ * so the stage legend can never contradict the headline. The asleep
+ * total, `awakeMinutes` and every OTHER entry of the `stages` map stay
+ * winner-only — blending the phone's coarse AWAKE blocks into the
+ * watch's night was the original inflated-awake bug this collapse
+ * exists to prevent.
+ *
+ * Overlap within one writer
+ * -------------------------
+ * A device can write the SAME period twice: an original envelope and a
+ * corrected one, same start, later end, distinct external ids. Both rows
+ * survive the writer collapse (they ARE the same writer), and adding both
+ * minute counts reports the night twice — the shape that made one night's
+ * in-bed legend read 20 h against a 10 h headline. Every per-stage total
+ * is therefore the measure of the UNION of that stage's spans, not the sum
+ * of the rows (`mergeOverlappingSegments`). Stages merge independently: a
+ * CORE span overlapping a DEEP span is a contest over what the minute WAS,
+ * and the writer collapse above is this file's one mechanism for that
+ * contest. The invariant that falls out and holds for every session: no
+ * stage total can exceed the union envelope of that stage's own spans, so
+ * none can exceed the session's envelope either.
*
* Unit
* ----
@@ -166,21 +184,25 @@ function isRedundantBareAsleep(
* bare `ASLEEP` aggregate against the granular CORE/DEEP/REM rows it overlaps.
* IN_BED + AWAKE are never asleep; a stage-less (`null`) row is the night's
* total only when no granular stage is present.
+ *
+ * Each stage contributes the measure of the UNION of its own spans
+ * (`mergeOverlappingSegments`), so a period a device re-exported under a
+ * corrected row is counted once — the headline cannot drift away from the
+ * per-stage legend that sums the same merged spans.
*/
function asleepMinutesOf(rows: readonly SleepStageRow[]): number {
let granular = 0;
let fallback = 0;
let sawGranular = false;
- for (const r of rows) {
- const minutes = Number.isFinite(r.value) ? r.value : 0;
- const stage = r.sleepStage;
+ for (const seg of mergeOverlappingSegments(rows.map(segmentOf))) {
+ const stage = seg.stage;
if (stage != null && GRANULAR_ASLEEP_STAGES.has(stage)) {
- granular += minutes;
+ granular += seg.minutes;
sawGranular = true;
} else if (stage === "ASLEEP" || stage == null) {
// Bare unspecified aggregate (`ASLEEP`) or a stage-less legacy/manual
// row — only used when no granular stage carries the night.
- fallback += minutes;
+ fallback += seg.minutes;
}
// IN_BED / AWAKE never count toward asleep.
}
@@ -274,7 +296,13 @@ export interface SleepNight {
inBedMinutes: number | null;
/** Awake-in-bed minutes when an AWAKE row exists, else null. */
awakeMinutes: number | null;
- /** Per-stage minutes for the night (only stages the winning writer reported). */
+ /**
+ * Per-stage minutes for the night. Every entry is the measure of the
+ * UNION of that stage's spans, never the raw row sum, so a re-exported
+ * period is counted once. Stages other than IN_BED come from the winning
+ * writer only; `stages.IN_BED` is the all-writer envelope and therefore
+ * always equals `inBedMinutes`.
+ */
stages: Partial>;
/**
* Non-null when two writer buckets reported clearly different asleep
@@ -633,6 +661,10 @@ export function reconstructSleepNights(
if (envelope !== null) {
inBed += envelope;
sawInBed = true;
+ // ONE quantity, ONE computation: `stages.IN_BED` IS the in-bed
+ // envelope, so the legend can never contradict the headline. The
+ // per-stage loop below skips IN_BED entirely for that reason.
+ stages.IN_BED = (stages.IN_BED ?? 0) + envelope;
}
// v1.11.5 — decide the granular-over-bare gate PER SESSION. A granular
// overnight does not strip a bare-only nap's minutes, and the per-stage
@@ -645,20 +677,28 @@ export function reconstructSleepNights(
asleep += sessionAsleep;
let sessionEnd = Number.NEGATIVE_INFINITY;
for (const r of sessionRows) {
- const minutes = Number.isFinite(r.value) ? r.value : 0;
if (r.measuredAt.getTime() > latest.getTime()) latest = r.measuredAt;
if (r.measuredAt.getTime() > sessionEnd)
sessionEnd = r.measuredAt.getTime();
- const stage = r.sleepStage;
- // Drop the redundant bare ASLEEP aggregate (and stage-less rows) from
- // the per-stage breakdown when this session has the granular partition,
- // so `stages` is never the granular set PLUS its ~equal bare twin.
- if (isRedundantBareAsleep(stage, sawGranular)) continue;
- if (stage) {
- stages[stage] = (stages[stage] ?? 0) + minutes;
- }
+ }
+ // Per-stage totals come from the OVERLAP-MERGED spans, never from the
+ // raw row sum: a device that re-exports a period it already wrote (a
+ // corrected envelope — same start, later end, fresh external id) would
+ // otherwise have both rows added and report the period twice. The
+ // granular-over-bare filter runs first, so `stages` is never the
+ // granular set PLUS its ~equal bare twin. IN_BED is skipped — it is the
+ // envelope, already carried above from the same union as `inBedMinutes`.
+ const merged = mergeOverlappingSegments(
+ sessionRows
+ .filter((r) => !isRedundantBareAsleep(r.sleepStage, sawGranular))
+ .map(segmentOf),
+ );
+ for (const seg of merged) {
+ const stage = seg.stage;
+ if (stage == null || stage === "IN_BED") continue;
+ stages[stage] = (stages[stage] ?? 0) + seg.minutes;
if (stage === "AWAKE") {
- awake += minutes;
+ awake += seg.minutes;
sawAwake = true;
}
}
@@ -730,7 +770,13 @@ export interface SleepSession {
inBedMinutes: number | null;
/** Awake-in-bed minutes when an AWAKE segment exists, else null. */
awakeMinutes: number | null;
- /** Per-stage minutes for the session (only stages the device reported). */
+ /**
+ * Per-stage minutes for the session. Every entry is the measure of the
+ * UNION of that stage's spans, never the raw row sum, so a corrected
+ * re-export of a period is counted once. Stages other than IN_BED come
+ * from the canonical writer only; `stages.IN_BED` is the all-writer
+ * envelope and therefore always equals `inBedMinutes`.
+ */
stages: Partial>;
/**
* Count of AWAKE segments between the first and last asleep segment —
@@ -739,7 +785,11 @@ export interface SleepSession {
* canonical source's segments only.
*/
awakenings: number;
- /** The canonical source's segments, sorted by start, for the hypnogram. */
+ /**
+ * The canonical source's segments, sorted by start, for the hypnogram.
+ * Overlapping spans of the same stage are merged, so a corrected
+ * re-export never draws a second bar across the night it revises.
+ */
segments: SleepSegment[];
/**
* `true` when the winning writer's stage ORDER is synthesised rather than
@@ -770,6 +820,96 @@ function segmentOf(r: SleepStageRow): SleepSegment {
return { stage: r.sleepStage, start, end, minutes };
}
+/** Map key for stage-less (`null`) spans — they group among themselves. */
+const NULL_STAGE_KEY = "__null_stage__";
+
+/**
+ * Merge the spans of ONE stage that genuinely OVERLAP into their interval
+ * union. A device re-exporting a period it already wrote (a corrected
+ * envelope: same start, later end, fresh external id) lands as a SECOND row
+ * describing the SAME minutes, and adding both minute counts reports the
+ * night twice. The union is the only defensible total for two spans that
+ * describe one period, and it needs no arbitration — both rows say the same
+ * thing about the contested minute.
+ *
+ * Overlap is STRICT (`start < runningEnd`): two spans that merely TOUCH
+ * (`start === runningEnd`) describe consecutive periods, so they stay two
+ * segments — their union already equals their sum, and coalescing them would
+ * silently drop one mid-sleep awakening from `countAwakenings`.
+ *
+ * A group of exactly one span is returned VERBATIM, so a well-formed night
+ * (no overlap anywhere) keeps the stored per-row minutes bit-for-bit and only
+ * a genuinely doubled export is rewritten.
+ */
+function mergeStageSpans(spans: readonly SleepSegment[]): SleepSegment[] {
+ if (spans.length <= 1) return [...spans];
+ const sorted = [...spans].sort(
+ (a, b) =>
+ a.start.getTime() - b.start.getTime() ||
+ a.end.getTime() - b.end.getTime(),
+ );
+ const out: SleepSegment[] = [];
+ let group: SleepSegment[] = [sorted[0]];
+ let groupEnd = sorted[0].end.getTime();
+ const flush = (): void => {
+ if (group.length === 1) {
+ out.push(group[0]);
+ return;
+ }
+ const start = group[0].start;
+ out.push({
+ stage: group[0].stage,
+ start,
+ end: new Date(groupEnd),
+ minutes: (groupEnd - start.getTime()) / 60_000,
+ });
+ };
+ for (let i = 1; i < sorted.length; i++) {
+ const span = sorted[i];
+ if (span.start.getTime() < groupEnd) {
+ group.push(span);
+ if (span.end.getTime() > groupEnd) groupEnd = span.end.getTime();
+ } else {
+ flush();
+ group = [span];
+ groupEnd = span.end.getTime();
+ }
+ }
+ flush();
+ return out;
+}
+
+/**
+ * Collapse a segment list so no two segments of the SAME stage overlap,
+ * returning the result sorted by start. Stages are merged INDEPENDENTLY —
+ * a CORE span overlapping a DEEP span is left alone, because that is not a
+ * doubled export but a contest over what the minute WAS, and the file already
+ * owns one mechanism for that contest: `pickSessionWriter` collapses the
+ * session to a single writer before anything is summed. Arbitrating across
+ * stages here would also destroy the summary-shaped writers on purpose:
+ * WHOOP / Polar stamp every stage total on the sleep END instant, so their
+ * stage spans nest inside one another by design and their union is one
+ * stage's length, not the night.
+ *
+ * The consequence is the invariant this function exists to guarantee: every
+ * stage total is the MEASURE OF THE UNION of that stage's spans, so no stage
+ * total can exceed the session's own envelope.
+ */
+function mergeOverlappingSegments(
+ segments: readonly SleepSegment[],
+): SleepSegment[] {
+ const byStage = new Map();
+ for (const seg of segments) {
+ const key = seg.stage ?? NULL_STAGE_KEY;
+ const list = byStage.get(key) ?? [];
+ list.push(seg);
+ byStage.set(key, list);
+ }
+ const out: SleepSegment[] = [];
+ for (const list of byStage.values()) out.push(...mergeStageSpans(list));
+ return out.sort((a, b) => a.start.getTime() - b.start.getTime());
+}
+
/**
* Count mid-sleep awakenings from a session's segments. An AWAKE segment
* counts only when it sits BETWEEN the first and last asleep segment — a
@@ -854,10 +994,16 @@ export function reconstructSleepSessions(
// granular stages and the `stages` map is the granular set OR the bare
// aggregate, never both.
const sawGranular = sawGranularStage(pool);
- const segments = pool
- .filter((r) => !isRedundantBareAsleep(r.sleepStage, sawGranular))
- .map(segmentOf)
- .sort((a, b) => a.start.getTime() - b.start.getTime());
+ // Overlapping spans of ONE stage merge into their union before anything
+ // is drawn or summed: a corrected re-export (same start, later end, fresh
+ // external id) must not paint a second bar over the night it revises, nor
+ // add its minutes on top of the row it supersedes. Returned sorted by
+ // start, as the hypnogram expects.
+ const segments = mergeOverlappingSegments(
+ pool
+ .filter((r) => !isRedundantBareAsleep(r.sleepStage, sawGranular))
+ .map(segmentOf),
+ );
// The granular-over-bare filter can empty a non-empty pool (a session whose
// only rows are the redundant bare ASLEEP aggregate / stage-less twins of a
@@ -877,7 +1023,11 @@ export function reconstructSleepSessions(
if (seg.start.getTime() < earliest.getTime()) earliest = seg.start;
if (seg.end.getTime() > latest.getTime()) latest = seg.end;
const stage = seg.stage;
- if (stage) stages[stage] = (stages[stage] ?? 0) + seg.minutes;
+ // IN_BED is skipped here — it is the session ENVELOPE, filled in below
+ // from the very computation `inBedMinutes` reports, so the two can never
+ // disagree.
+ if (stage == null || stage === "IN_BED") continue;
+ stages[stage] = (stages[stage] ?? 0) + seg.minutes;
if (stage === "AWAKE") {
awake += seg.minutes;
sawAwake = true;
@@ -889,6 +1039,7 @@ export function reconstructSleepSessions(
// only in-bed window (the phone's bedtime detection). The hypnogram
// `segments` + `stages` stay winner-only.
const inBedEnvelope = inBedEnvelopeMinutes(session);
+ if (inBedEnvelope !== null) stages.IN_BED = inBedEnvelope;
const asleep = asleepMinutesOf(pool);
const canonicalSource = sourceOfWriterKey(canonical);
sessions.push({
From 0ce510fa6890df460b71f894c5945f95d3909e89 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 05:26:03 +0200
Subject: [PATCH 19/52] refactor(sleep): pin the in-bed legend before the
per-stage loop reads it
The envelope was assigned after the loop, so the loop's IN_BED skip had
no observable effect - removing it changed nothing, which makes it a
dead branch rather than a guarantee. Assign the envelope first and let
the skip protect it.
---
src/lib/analytics/sleep-night.ts | 17 ++++++++++-------
1 file changed, 10 insertions(+), 7 deletions(-)
diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts
index 138e6fc9e..794d3a8a0 100644
--- a/src/lib/analytics/sleep-night.ts
+++ b/src/lib/analytics/sleep-night.ts
@@ -1017,6 +1017,16 @@ export function reconstructSleepSessions(
let awake = 0;
let sawAwake = false;
const stages: Partial> = {};
+ // In-bed: union envelope across ALL writers in the RAW session — the
+ // canonical (winning) writer often carries no IN_BED row at all (a
+ // watch's stage export) while the losing writer holds the night's
+ // only in-bed window (the phone's bedtime detection). ONE quantity,
+ // ONE computation: `stages.IN_BED` IS that envelope, so the legend can
+ // never contradict the headline — which is why the per-stage loop
+ // below must skip IN_BED instead of adding the winner's own spans on
+ // top of it. The hypnogram `segments` stay winner-only.
+ const inBedEnvelope = inBedEnvelopeMinutes(session);
+ if (inBedEnvelope !== null) stages.IN_BED = inBedEnvelope;
let earliest = segments[0].start;
let latest = segments[0].end;
for (const seg of segments) {
@@ -1033,13 +1043,6 @@ export function reconstructSleepSessions(
sawAwake = true;
}
}
- // In-bed: union envelope across ALL writers in the RAW session — the
- // canonical (winning) writer often carries no IN_BED row at all (a
- // watch's stage export) while the losing writer holds the night's
- // only in-bed window (the phone's bedtime detection). The hypnogram
- // `segments` + `stages` stay winner-only.
- const inBedEnvelope = inBedEnvelopeMinutes(session);
- if (inBedEnvelope !== null) stages.IN_BED = inBedEnvelope;
const asleep = asleepMinutesOf(pool);
const canonicalSource = sourceOfWriterKey(canonical);
sessions.push({
From f74efc44aa06e19461c9a3fa4532f92b03c6d7c2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 05:41:40 +0200
Subject: [PATCH 20/52] fix(profile): let the third gender value through every
surface that handles it
The profile validator has accepted MALE, FEMALE and OTHER for a long
time, but the published contract documented two, and a client written
against that contract had no way to know the third existed. The profile
PATCH is one write, so a rejected gender took every sibling field with
it - a time-format change lost to a field the person never touched.
Gender now clears on an empty string, the same contract every other
profile field documents, and an unsupported value is refused with a
message that names the field and its accepted values instead of the
validator's own prose. Onboarding validated against a narrower enum
than the profile schema and 422'd the whole submission; its form could
not record the third value at all.
The rest is the same defect in other clothes. The Strain model scored
an unanswered or OTHER account on the male Banister coefficients; it
now uses the midpoint of both published models, matching every other
sex-split formula here. The dashboard snapshot emitted null for an
account that answered OTHER, which is indistinguishable from no answer.
Three casts asserted a two-value type over a three-value column. All of
them now carry the stored value and narrow once, at the boundary of a
reference table that genuinely has no third row, through one named
helper in src/lib/profile/sex.ts.
Refs #645
---
docs/api/openapi.yaml | 5 +
messages/de.json | 1 +
messages/en.json | 1 +
messages/es.json | 1 +
messages/fr.json | 1 +
messages/it.json | 1 +
messages/pl.json | 1 +
.../complete/__tests__/route.test.ts | 93 +++++++++++++++++++
src/app/api/onboarding/complete/route.ts | 6 +-
.../api/user/profile/__tests__/route.test.ts | 80 ++++++++++++++++
src/app/page-client.tsx | 3 +-
src/app/privacy/page.tsx | 5 +-
src/components/onboarding/baseline-form.tsx | 9 +-
.../dashboard/__tests__/target-bands.test.ts | 23 +++++
src/lib/dashboard/bands.ts | 10 +-
src/lib/dashboard/snapshot.ts | 11 ++-
.../header-profile-section.ts | 11 ++-
.../insights/__tests__/strain-score.test.ts | 19 ++++
src/lib/insights/derived/baseline.ts | 14 ++-
src/lib/insights/derived/norms.ts | 5 +-
src/lib/insights/pulse-status.ts | 3 +-
src/lib/insights/strain-score.ts | 30 ++++--
src/lib/openapi/routes/insights/schemas.ts | 2 +-
src/lib/openapi/routes/profile.ts | 12 ++-
src/lib/profile/sex.ts | 39 ++++++++
src/lib/targets/build-response.ts | 3 +-
src/lib/targets/vitals-builder.ts | 20 +++-
src/lib/validations/auth.ts | 18 +++-
28 files changed, 388 insertions(+), 39 deletions(-)
create mode 100644 src/app/api/onboarding/complete/__tests__/route.test.ts
create mode 100644 src/lib/profile/sex.ts
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index f0aa76bef..75d1d22bc 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -13272,11 +13272,13 @@ components:
- type: string
- type: "null"
gender:
+ description: Stored gender. Null or an empty string clears it; an omitted field is left untouched.
anyOf:
- type: string
enum:
- MALE
- FEMALE
+ - OTHER
- type: "null"
displayName:
anyOf:
@@ -24028,6 +24030,7 @@ components:
enum:
- MALE
- FEMALE
+ - OTHER
- type: "null"
heightCm:
anyOf:
@@ -24139,6 +24142,7 @@ components:
enum:
- MALE
- FEMALE
+ - OTHER
- type: "null"
heightCm:
anyOf:
@@ -26037,6 +26041,7 @@ components:
enum:
- MALE
- FEMALE
+ - OTHER
- type: "null"
glucoseUnit:
anyOf:
diff --git a/messages/de.json b/messages/de.json
index 55d67f5f4..31f362748 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4145,6 +4145,7 @@
"genderNone": "Keine Angabe",
"genderMale": "Männlich",
"genderFemale": "Weiblich",
+ "genderOther": "Divers",
"dateOfBirthLabel": "Geburtsdatum",
"dateOfBirthHint": "Wird ausschließlich für altersbezogene Auswertungen genutzt.",
"saveCta": "Speichern und weiter"
diff --git a/messages/en.json b/messages/en.json
index bfd7535dd..3fcc3b6cb 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4145,6 +4145,7 @@
"genderNone": "Prefer not to say",
"genderMale": "Male",
"genderFemale": "Female",
+ "genderOther": "Other",
"dateOfBirthLabel": "Date of birth",
"dateOfBirthHint": "Used only for age-aware analytics.",
"saveCta": "Save and continue"
diff --git a/messages/es.json b/messages/es.json
index f150bd1d9..ce0c60e5e 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4145,6 +4145,7 @@
"genderNone": "Prefiero no decirlo",
"genderMale": "Hombre",
"genderFemale": "Mujer",
+ "genderOther": "Otro",
"dateOfBirthLabel": "Fecha de nacimiento",
"dateOfBirthHint": "Se utiliza solo para análisis sensibles a la edad.",
"saveCta": "Guardar y continuar"
diff --git a/messages/fr.json b/messages/fr.json
index 50fdd82da..cea4d2355 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4145,6 +4145,7 @@
"genderNone": "Préfère ne pas le préciser",
"genderMale": "Homme",
"genderFemale": "Femme",
+ "genderOther": "Autre",
"dateOfBirthLabel": "Date de naissance",
"dateOfBirthHint": "Utilisée uniquement pour les analyses qui tiennent compte de l'âge.",
"saveCta": "Enregistrer et continuer"
diff --git a/messages/it.json b/messages/it.json
index aed593603..3edd4d0cc 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4145,6 +4145,7 @@
"genderNone": "Preferisco non specificare",
"genderMale": "Maschile",
"genderFemale": "Femminile",
+ "genderOther": "Altro",
"dateOfBirthLabel": "Data di nascita",
"dateOfBirthHint": "Usata solo per le analisi che tengono conto dell'età.",
"saveCta": "Salva e continua"
diff --git a/messages/pl.json b/messages/pl.json
index d959fa615..fbb86f532 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4145,6 +4145,7 @@
"genderNone": "Wolę nie podawać",
"genderMale": "Mężczyzna",
"genderFemale": "Kobieta",
+ "genderOther": "Inna",
"dateOfBirthLabel": "Data urodzenia",
"dateOfBirthHint": "Używana wyłącznie do analiz zależnych od wieku.",
"saveCta": "Zapisz i kontynuuj"
diff --git a/src/app/api/onboarding/complete/__tests__/route.test.ts b/src/app/api/onboarding/complete/__tests__/route.test.ts
new file mode 100644
index 000000000..667d16d7e
--- /dev/null
+++ b/src/app/api/onboarding/complete/__tests__/route.test.ts
@@ -0,0 +1,93 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { NextRequest } from "next/server";
+
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ user: { update: vi.fn() },
+ auditLog: { create: vi.fn() },
+ },
+}));
+
+vi.mock("@/lib/auth/session", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ getSession: vi.fn(),
+ setOnboardingPendingCookie: vi.fn(async () => {}),
+ };
+});
+
+vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
+
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+
+vi.mock("@/lib/logging/context", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, annotate: vi.fn() };
+});
+
+vi.mock("next/headers", () => ({
+ headers: vi.fn(async () => ({ get: () => null })),
+ cookies: vi.fn(async () => ({
+ get: () => undefined,
+ set: () => {},
+ delete: () => {},
+ })),
+}));
+
+import { POST } from "../route";
+import { prisma } from "@/lib/db";
+import { getSession } from "@/lib/auth/session";
+
+const SESSION_OK = {
+ session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
+ user: { id: "user-1", username: "testuser", role: "USER" as const },
+};
+
+function req(body: unknown): NextRequest {
+ return new NextRequest("http://localhost/api/onboarding/complete", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+}
+
+beforeEach(() => {
+ vi.resetAllMocks();
+ vi.mocked(prisma.user.update).mockResolvedValue({} as never);
+ vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
+});
+
+describe("POST /api/onboarding/complete", () => {
+ it("returns 401 when unauthenticated", async () => {
+ vi.mocked(getSession).mockResolvedValue(null);
+ const res = await POST(req({}));
+ expect(res.status).toBe(401);
+ });
+
+ /**
+ * Onboarding validated gender against a narrower enum than the profile
+ * schema, so a value the account is allowed to hold rejected the whole
+ * submission — height, date of birth and display name included.
+ */
+ it("accepts every gender the profile stores, including OTHER", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ const res = await POST(req({ gender: "OTHER", heightCm: 180 }));
+ expect(res.status).toBe(200);
+ expect(prisma.user.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { id: "user-1" },
+ data: expect.objectContaining({ gender: "OTHER", heightCm: 180 }),
+ }),
+ );
+ });
+
+ it("still refuses an unsupported gender", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ const res = await POST(req({ gender: "diverse" }));
+ expect(res.status).toBe(422);
+ expect(prisma.user.update).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/api/onboarding/complete/route.ts b/src/app/api/onboarding/complete/route.ts
index 9c7230491..cd7eebdc7 100644
--- a/src/app/api/onboarding/complete/route.ts
+++ b/src/app/api/onboarding/complete/route.ts
@@ -10,7 +10,11 @@ const onboardingSchema = z.object({
displayName: z.string().trim().min(1).max(50).optional(),
heightCm: z.number().min(50).max(300).optional(),
dateOfBirth: z.string().optional(),
- gender: z.enum(["MALE", "FEMALE"]).optional(),
+ // Same three values the profile schema stores. A narrower enum here
+ // rejected the whole onboarding submission over a field the account
+ // is allowed to hold, and left the value unrecordable until the person
+ // found the setting again afterwards.
+ gender: z.enum(["MALE", "FEMALE", "OTHER"]).optional(),
});
/**
diff --git a/src/app/api/user/profile/__tests__/route.test.ts b/src/app/api/user/profile/__tests__/route.test.ts
index 8a8654e13..b628b0550 100644
--- a/src/app/api/user/profile/__tests__/route.test.ts
+++ b/src/app/api/user/profile/__tests__/route.test.ts
@@ -297,4 +297,84 @@ describe("PATCH /api/user/profile", () => {
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
+ /**
+ * The whole profile PATCH is one write, so a rejected gender took every
+ * sibling change down with it — someone switching their time format lost
+ * the save over a field they never touched.
+ */
+ it("clears gender on an empty string and still persists the sibling change", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
+ vi.mocked(prisma.user.update).mockResolvedValue({
+ id: "user-1",
+ username: "testuser",
+ displayName: null,
+ email: null,
+ role: "USER",
+ heightCm: null,
+ dateOfBirth: null,
+ gender: null,
+ timezone: "Europe/Berlin",
+ locale: null,
+ timeFormat: "H24",
+ dateFormat: "AUTO",
+ moodReminderEnabled: false,
+ fullName: null,
+ insurerName: null,
+ insurerIkNumber: null,
+ insuranceNumberEncrypted: null,
+ } as never);
+
+ const res = await PATCH(req({ timeFormat: "H24", gender: "" }));
+ expect(res.status).toBe(200);
+ expect(prisma.user.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({ timeFormat: "H24", gender: null }),
+ }),
+ );
+ });
+
+ it("accepts OTHER and writes it through", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
+ vi.mocked(prisma.user.update).mockResolvedValue({
+ id: "user-1",
+ username: "testuser",
+ displayName: null,
+ email: null,
+ role: "USER",
+ heightCm: null,
+ dateOfBirth: null,
+ gender: "OTHER",
+ timezone: "Europe/Berlin",
+ locale: null,
+ timeFormat: "AUTO",
+ dateFormat: "AUTO",
+ moodReminderEnabled: false,
+ fullName: null,
+ insurerName: null,
+ insurerIkNumber: null,
+ insuranceNumberEncrypted: null,
+ } as never);
+
+ const res = await PATCH(req({ gender: "OTHER" }));
+ expect(res.status).toBe(200);
+ expect(prisma.user.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({ gender: "OTHER" }),
+ }),
+ );
+ const body = (await res.json()) as { data: { gender: string | null } };
+ expect(body.data.gender).toBe("OTHER");
+ });
+
+ it("still refuses an unsupported gender, with a message that names the field", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ const res = await PATCH(req({ timeFormat: "H24", gender: "diverse" }));
+ expect(res.status).toBe(422);
+ expect(prisma.user.update).not.toHaveBeenCalled();
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("Gender must be MALE, FEMALE or OTHER");
+ expect(body.error).not.toContain("Invalid option");
+ });
});
diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx
index f4c67f5b3..584ada4d2 100644
--- a/src/app/page-client.tsx
+++ b/src/app/page-client.tsx
@@ -154,6 +154,7 @@ import type { DataSummary } from "@/lib/analytics/trends";
import { mergeSlimAndThickAnalytics } from "@/lib/analytics/merge-slim-thick";
import { isWindowSufficient } from "@/lib/analytics/window-confidence";
import { buildDashboardBands } from "@/lib/dashboard/bands";
+import { toProfileSex } from "@/lib/profile/sex";
import { apiGet } from "@/lib/api/api-fetch";
// v1.18.6 — the pure first-paint / skeleton-reservation gates moved to
@@ -718,7 +719,7 @@ export default function DashboardPageClient({
() =>
buildDashboardBands({
dateOfBirth: user?.dateOfBirth ? new Date(user.dateOfBirth) : null,
- gender: (user?.gender as "MALE" | "FEMALE" | null | undefined) ?? null,
+ gender: toProfileSex(user?.gender),
heightCm: user?.heightCm ?? null,
}),
[user],
diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx
index d15b07946..2db962bff 100644
--- a/src/app/privacy/page.tsx
+++ b/src/app/privacy/page.tsx
@@ -754,8 +754,9 @@ export default function PrivacyPage() {
Profile metadata: locale, timezone, date of birth (optional),
- biological sex (optional), height (optional) — used to compute
- age-adjusted target ranges and BMI.
+ gender (optional, one of male, female or other), height
+ (optional) — used to compute age-adjusted target ranges and
+ BMI.
diff --git a/src/components/onboarding/baseline-form.tsx b/src/components/onboarding/baseline-form.tsx
index 86916172e..b1ba7f94d 100644
--- a/src/components/onboarding/baseline-form.tsx
+++ b/src/components/onboarding/baseline-form.tsx
@@ -139,7 +139,11 @@ export function BaselineForm() {
const heightCm = heightAdapter.toCanonicalCm(form.height);
if (heightCm !== null) profileBody.heightCm = heightCm;
if (form.dateOfBirth) profileBody.dateOfBirth = form.dateOfBirth;
- if (form.gender === "MALE" || form.gender === "FEMALE") {
+ if (
+ form.gender === "MALE" ||
+ form.gender === "FEMALE" ||
+ form.gender === "OTHER"
+ ) {
profileBody.gender = form.gender;
}
if (Object.keys(profileBody).length > 0) {
@@ -241,6 +245,9 @@ export function BaselineForm() {
{t("onboarding.baseline.genderFemale")}
+
+ {t("onboarding.baseline.genderOther")}
+
diff --git a/src/lib/dashboard/__tests__/target-bands.test.ts b/src/lib/dashboard/__tests__/target-bands.test.ts
index 7f76f903a..5e798d75b 100644
--- a/src/lib/dashboard/__tests__/target-bands.test.ts
+++ b/src/lib/dashboard/__tests__/target-bands.test.ts
@@ -134,6 +134,29 @@ describe("buildTargetBands — server/client parity", () => {
});
}
+ it("gives OTHER the neutral bands, identical to an unanswered profile", () => {
+ // OTHER has no row in the percentile / body-fat tables. It must land on
+ // the same neutral fallback an unanswered profile gets, never on one of
+ // the two sides — and the type must be able to carry it here at all.
+ const other = buildTargetBands({
+ dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
+ gender: "OTHER",
+ heightCm: 180,
+ });
+ const unanswered = buildTargetBands({
+ dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
+ gender: null,
+ heightCm: 180,
+ });
+ expect(other).toEqual(unanswered);
+ const male = buildTargetBands({
+ dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
+ gender: "MALE",
+ heightCm: 180,
+ });
+ expect(other.bodyFatBands).not.toEqual(male.bodyFatBands);
+ });
+
it("nulls the profile-derived bands when no DOB / height", () => {
const bands = buildTargetBands({
dateOfBirth: null,
diff --git a/src/lib/dashboard/bands.ts b/src/lib/dashboard/bands.ts
index 381ed55aa..d27dacd53 100644
--- a/src/lib/dashboard/bands.ts
+++ b/src/lib/dashboard/bands.ts
@@ -29,6 +29,7 @@ import {
getAgeFromDateOfBirth,
getPersonalizedPulseTarget,
} from "@/lib/analytics/pulse-targets";
+import { binaryReferenceSex, type ProfileSex } from "@/lib/profile/sex";
export interface DashboardTargetBands {
/** Personalised BP target numbers (null when no DOB). */
@@ -63,15 +64,18 @@ export interface DashboardTargetBands {
*/
export function buildDashboardBands(profile: {
dateOfBirth: Date | null;
- gender: "MALE" | "FEMALE" | null;
+ gender: ProfileSex;
heightCm: number | null;
}): DashboardTargetBands {
const bpTargets = profile.dateOfBirth
? getBpTargets(profile.dateOfBirth)
: null;
const pulseAge = getAgeFromDateOfBirth(profile.dateOfBirth);
- const pulseTarget = getPersonalizedPulseTarget(pulseAge, profile.gender);
- const bodyFatRange = getBodyFatTargetRange(profile.gender);
+ // The percentile and body-fat tables carry two sexes and no third row;
+ // both helpers answer a null with a neutral midpoint rather than a side.
+ const referenceSex = binaryReferenceSex(profile.gender);
+ const pulseTarget = getPersonalizedPulseTarget(pulseAge, referenceSex);
+ const bodyFatRange = getBodyFatTargetRange(referenceSex);
const weightRange = profile.heightCm
? buildWeightRangeFromHeight(profile.heightCm)
: null;
diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts
index 82b63d4c2..159ba501b 100644
--- a/src/lib/dashboard/snapshot.ts
+++ b/src/lib/dashboard/snapshot.ts
@@ -101,6 +101,7 @@ import {
import { getServerTranslator } from "@/lib/i18n/server-translator";
import type { Locale } from "@/lib/i18n/config";
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
+import { toProfileSex, type ProfileSex } from "@/lib/profile/sex";
/** Briefing freshness window — mirrors the 24 h TTL on the advisor cache. */
const BRIEFING_TTL_MS = 24 * 60 * 60 * 1000;
@@ -203,7 +204,12 @@ export interface DashboardSnapshotUser {
timezone: string;
heightCm: number | null;
dateOfBirth: string | null;
- gender: "MALE" | "FEMALE" | null;
+ /**
+ * Sex as stored, `OTHER` included. Emitting `null` for an account that
+ * answered `OTHER` made the value indistinguishable from no answer at
+ * all on every client reading this payload.
+ */
+ gender: ProfileSex;
glucoseUnit: string | null;
onboardingTourCompleted: boolean;
/**
@@ -1277,8 +1283,7 @@ export async function buildDashboardSnapshot(
]);
const briefingMemory = mapBriefingMemory(coachMemoryBlock, locale);
- const gender =
- user.gender === "MALE" || user.gender === "FEMALE" ? user.gender : null;
+ const gender = toProfileSex(user.gender);
return {
user: {
diff --git a/src/lib/doctor-report-pdf/header-profile-section.ts b/src/lib/doctor-report-pdf/header-profile-section.ts
index c425240ed..ac9683a83 100644
--- a/src/lib/doctor-report-pdf/header-profile-section.ts
+++ b/src/lib/doctor-report-pdf/header-profile-section.ts
@@ -74,13 +74,18 @@ export function buildHeaderProfileSection(
);
}
if (data.patient.gender) {
+ // Only the three stored values get a label. A row on a clinical
+ // document is a positive claim, so an unrecognised string leaves the
+ // line out rather than asserting a third value the account never chose.
const genderKeys: Record = {
MALE: "doctorReport.genderMale",
FEMALE: "doctorReport.genderFemale",
+ OTHER: "doctorReport.genderOther",
};
- const genderKey =
- genderKeys[data.patient.gender] ?? "doctorReport.genderOther";
- patientInfo.push(`${t("doctorReport.gender")}: ${t(genderKey)}`);
+ const genderKey = genderKeys[data.patient.gender];
+ if (genderKey) {
+ patientInfo.push(`${t("doctorReport.gender")}: ${t(genderKey)}`);
+ }
}
if (data.patient.heightCm) {
patientInfo.push(
diff --git a/src/lib/insights/__tests__/strain-score.test.ts b/src/lib/insights/__tests__/strain-score.test.ts
index c6a896f42..1e47ca9f2 100644
--- a/src/lib/insights/__tests__/strain-score.test.ts
+++ b/src/lib/insights/__tests__/strain-score.test.ts
@@ -66,6 +66,25 @@ describe("banisterTrimp", () => {
).toBe(0);
});
+ it("scores an unanswered or OTHER sex on the midpoint of both models, not on the male one", () => {
+ // The published Banister weights cover two groups. Defaulting the rest
+ // to the male pair scored people on an assumption they never made; the
+ // neutral answer is the mean of the two models, matching every other
+ // sex-split formula in the repo.
+ const samples = [
+ { t: "2026-06-02T08:00:00.000Z", hr: 150 },
+ { t: "2026-06-02T09:00:00.000Z", hr: 150 },
+ ];
+ const male = banisterTrimp(samples, hrRest, hrMax, "MALE");
+ const female = banisterTrimp(samples, hrRest, hrMax, "FEMALE");
+ const other = banisterTrimp(samples, hrRest, hrMax, "OTHER");
+ const unanswered = banisterTrimp(samples, hrRest, hrMax, null);
+ expect(male).not.toBeCloseTo(female, 6);
+ expect(other).toBeCloseTo((male + female) / 2, 10);
+ expect(unanswered).toBeCloseTo(other, 10);
+ expect(other).not.toBeCloseTo(male, 6);
+ });
+
it("accumulates a positive TRIMP for a sustained elevated series", () => {
const samples = [
{ t: "2026-06-02T08:00:00.000Z", hr: 150 },
diff --git a/src/lib/insights/derived/baseline.ts b/src/lib/insights/derived/baseline.ts
index 456a83718..83c090e8f 100644
--- a/src/lib/insights/derived/baseline.ts
+++ b/src/lib/insights/derived/baseline.ts
@@ -31,6 +31,8 @@
import type { MeasurementType, PrismaClient } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
+import { toProfileSex } from "@/lib/profile/sex";
+import type { ProfileSex } from "@/lib/profile/sex";
import {
probeRollupCoverage,
type RollupCoverageMap,
@@ -82,7 +84,14 @@ export interface VitalsBaselineValue {
/** Caller-supplied profile (read once per request, never re-fetched here). */
export interface BaselineProfile {
ageYears: number | null;
- sex: "MALE" | "FEMALE" | null;
+ /**
+ * Sex as stored, `OTHER` included. The sex-split reference tables have
+ * no `OTHER` row, so `resolveSexRows` falls through to the sex-agnostic
+ * band or reports none — an honest omission. Narrowing the value away
+ * HERE instead would hand every consumer a `null` it cannot tell apart
+ * from "no answer".
+ */
+ sex: ProfileSex;
/**
* Height in cm from `User.heightCm`, when set. Consumed by the BMI
* metric (weight ÷ height²); `null` when the profile has no height.
@@ -108,8 +117,7 @@ export async function loadBaselineProfile(
where: { id: userId },
select: { dateOfBirth: true, gender: true, heightCm: true },
});
- const sex =
- user?.gender === "MALE" || user?.gender === "FEMALE" ? user.gender : null;
+ const sex = toProfileSex(user?.gender);
return {
ageYears: getAgeFromDateOfBirth(user?.dateOfBirth ?? null),
sex,
diff --git a/src/lib/insights/derived/norms.ts b/src/lib/insights/derived/norms.ts
index 5e5c0f0d2..a82ce39eb 100644
--- a/src/lib/insights/derived/norms.ts
+++ b/src/lib/insights/derived/norms.ts
@@ -49,9 +49,10 @@
* Client-safe — pure data + a pure lookup, no server imports.
*/
import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry";
+import type { ProfileSex } from "@/lib/profile/sex";
-/** Profile sex as stored on `User.gender` ("MALE" | "FEMALE" | null). */
-export type NormSex = "MALE" | "FEMALE" | null | undefined;
+/** Profile sex as a reference-table lookup key; `undefined` = unknown. */
+export type NormSex = ProfileSex | undefined;
/** A reference band — same shape as `MetricNormalRange`. */
export interface NormRange {
diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts
index 0c17a12f8..66965d70a 100644
--- a/src/lib/insights/pulse-status.ts
+++ b/src/lib/insights/pulse-status.ts
@@ -50,6 +50,7 @@ import {
type StatusCardResult,
} from "@/lib/insights/status-card-generation";
import { annotate } from "@/lib/logging/context";
+import { binaryReferenceSex } from "@/lib/profile/sex";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
/**
@@ -263,7 +264,7 @@ export async function preparePulseStatusForUser(
const pulseAge = getAgeFromDateOfBirth(user?.dateOfBirth ?? null);
const pulseTarget = getPersonalizedPulseTarget(
pulseAge,
- (user?.gender as "MALE" | "FEMALE" | null | undefined) ?? null,
+ binaryReferenceSex(user?.gender),
);
// v1.15.12 A2 — resting-target in-target % over the RESTING series
diff --git a/src/lib/insights/strain-score.ts b/src/lib/insights/strain-score.ts
index 4334fd9d3..00c2bc400 100644
--- a/src/lib/insights/strain-score.ts
+++ b/src/lib/insights/strain-score.ts
@@ -64,6 +64,7 @@
*/
import type { MeasurementType, PrismaClient } from "@/generated/prisma/client";
import { loadBaselineProfile } from "@/lib/insights/derived/baseline";
+import type { ProfileSex } from "@/lib/profile/sex";
import {
scoreDayKey,
scoreExternalId,
@@ -187,14 +188,24 @@ export function banisterTrimp(
samples: readonly HrSample[],
hrRest: number,
hrMax: number,
- sex: "MALE" | "FEMALE" | null,
+ sex: ProfileSex,
): number {
if (hrMax <= hrRest) return 0;
- // Morton/Fitz-Clarke/Banister gender weights; the unisex default uses the
- // male coefficients (the more common watch-cohort assumption) when sex is
- // unknown.
- const a = sex === "FEMALE" ? 0.86 : 0.64;
- const b = sex === "FEMALE" ? 1.67 : 1.92;
+ // Morton/Fitz-Clarke/Banister weights. The published pair covers two
+ // groups and nothing else, so an account that is neither (OTHER, or no
+ // answer at all) is scored on the MIDPOINT of the two models rather than
+ // silently on the male one — the same neutral fallback every other
+ // sex-split formula in the repo uses (`value-bands`, `classifications`,
+ // `pulse-targets`). Picking a side for someone who did not pick one is a
+ // guess dressed as a measurement.
+ const MALE_WEIGHTS = { a: 0.64, b: 1.92 } as const;
+ const FEMALE_WEIGHTS = { a: 0.86, b: 1.67 } as const;
+ const weights =
+ sex === "MALE"
+ ? [MALE_WEIGHTS]
+ : sex === "FEMALE"
+ ? [FEMALE_WEIGHTS]
+ : [MALE_WEIGHTS, FEMALE_WEIGHTS];
const usable = samples
.filter((s) => typeof s.hr === "number" && Number.isFinite(s.hr))
@@ -213,7 +224,10 @@ export function banisterTrimp(
if (!Number.isFinite(hrr)) continue;
if (hrr <= 0) continue; // below resting → no training impulse.
if (hrr > 1) hrr = 1; // clamp a spurious over-max sample.
- trimp += dtMin * hrr * a * Math.exp(b * hrr);
+ // One model for a known sex; the mean of both when there is none.
+ let impulse = 0;
+ for (const { a, b } of weights) impulse += a * Math.exp(b * hrr);
+ trimp += (dtMin * hrr * impulse) / weights.length;
}
return trimp;
}
@@ -359,7 +373,7 @@ export interface StrainComputeResult {
export interface StrainProfile {
ageYears: number | null;
- sex: "MALE" | "FEMALE" | null;
+ sex: ProfileSex;
}
/**
diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts
index f9e1ce047..08cf58d45 100644
--- a/src/lib/openapi/routes/insights/schemas.ts
+++ b/src/lib/openapi/routes/insights/schemas.ts
@@ -800,7 +800,7 @@ export const dashboardSnapshotResponse = z
timezone: z.string(),
heightCm: z.number().nullable(),
dateOfBirth: z.string().nullable(),
- gender: z.enum(["MALE", "FEMALE"]).nullable(),
+ gender: z.enum(["MALE", "FEMALE", "OTHER"]).nullable(),
glucoseUnit: z.string().nullable(),
onboardingTourCompleted: z.boolean(),
greetingHour: z.number().int(),
diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts
index 1dba5d131..0b9c2b671 100644
--- a/src/lib/openapi/routes/profile.ts
+++ b/src/lib/openapi/routes/profile.ts
@@ -211,7 +211,13 @@ const profileUpdateRequest = z
email: z.email().nullable().optional(),
heightCm: z.number().min(50).max(300).nullable().optional(),
dateOfBirth: z.string().nullable().optional(),
- gender: z.enum(["MALE", "FEMALE"]).nullable().optional(),
+ gender: z
+ .enum(["MALE", "FEMALE", "OTHER"])
+ .nullable()
+ .optional()
+ .describe(
+ "Stored gender. Null or an empty string clears it; an omitted field is left untouched.",
+ ),
displayName: z.string().min(1).max(80).nullable().optional(),
locale: z.enum(["de", "en"]).nullable().optional(),
timezone: z.string().min(1).max(64).optional(),
@@ -255,7 +261,7 @@ const profileResponse = z
displayName: z.string().nullable().optional(),
email: z.string().nullable(),
dateOfBirth: z.iso.datetime({ offset: true }).nullable(),
- gender: z.enum(["MALE", "FEMALE"]).nullable(),
+ gender: z.enum(["MALE", "FEMALE", "OTHER"]).nullable(),
heightCm: z.number().nullable(),
locale: z.string().nullable(),
timezone: z.string(),
@@ -299,7 +305,7 @@ const profileUpdateResponse = z
displayName: z.string().nullable().optional(),
email: z.string().nullable(),
dateOfBirth: z.iso.datetime({ offset: true }).nullable(),
- gender: z.enum(["MALE", "FEMALE"]).nullable(),
+ gender: z.enum(["MALE", "FEMALE", "OTHER"]).nullable(),
heightCm: z.number().nullable(),
locale: z.string().nullable(),
timezone: z.string(),
diff --git a/src/lib/profile/sex.ts b/src/lib/profile/sex.ts
new file mode 100644
index 000000000..c9c9e96b0
--- /dev/null
+++ b/src/lib/profile/sex.ts
@@ -0,0 +1,39 @@
+/**
+ * The one owner of the profile sex value: what the column holds, and the
+ * single place where it is allowed to shrink.
+ *
+ * `User.gender` stores three values plus "no answer". Several surfaces used
+ * to type it as two, either by narrowing early or by an `as` cast, and the
+ * third value then travelled through code that could not represent it. That
+ * is how it ends up defaulting into whichever branch happens to be last:
+ * the FHIR export mapped it to `female`, and the Strain model scored it on
+ * the male coefficients. Both were the same defect wearing different clothes.
+ *
+ * So: carry the stored value as far as it can honestly go, and narrow only
+ * at the boundary of a reference table that genuinely has no third row —
+ * through `binaryReferenceSex`, which is named so the shrink is visible in
+ * a diff instead of hiding inside a ternary.
+ *
+ * Client-safe — a type and a pure function, no imports.
+ */
+
+/** Profile sex exactly as stored; `null` = the account gave no answer. */
+export type ProfileSex = "MALE" | "FEMALE" | "OTHER" | null;
+
+/**
+ * Narrow a stored sex to the two values a sex-split reference table has
+ * rows for. `OTHER` and "no answer" both resolve to `null`, and every
+ * consumer of that `null` answers with a neutral value or with nothing —
+ * never with one of the two sides. Accepts a loose `string` because a few
+ * callers read the column straight off Prisma, where it is `String?`.
+ */
+export function binaryReferenceSex(
+ sex: string | null | undefined,
+): "MALE" | "FEMALE" | null {
+ return sex === "MALE" || sex === "FEMALE" ? sex : null;
+}
+
+/** Narrow an arbitrary stored string to the three values the column holds. */
+export function toProfileSex(sex: string | null | undefined): ProfileSex {
+ return sex === "MALE" || sex === "FEMALE" || sex === "OTHER" ? sex : null;
+}
diff --git a/src/lib/targets/build-response.ts b/src/lib/targets/build-response.ts
index a669e8d89..43efa954d 100644
--- a/src/lib/targets/build-response.ts
+++ b/src/lib/targets/build-response.ts
@@ -18,6 +18,7 @@ import pLimit from "p-limit";
import type { MeasurementType } from "@/generated/prisma/client";
import type { ThresholdOverridesJson } from "@/lib/analytics/effective-range";
import { resolveGlucoseUnit } from "@/lib/glucose";
+import { toProfileSex } from "@/lib/profile/sex";
import {
ensureUserMoodRollupsFresh,
readMoodDayRollups,
@@ -234,7 +235,7 @@ export async function buildTargetsResponse(user: AuthedUser) {
const now = new Date();
const age = dbUser?.dateOfBirth ? getAge(new Date(dbUser.dateOfBirth)) : null;
- const gender = (dbUser?.gender as "MALE" | "FEMALE" | null) ?? null;
+ const gender = toProfileSex(dbUser?.gender);
const heightCm = dbUser?.heightCm ?? null;
const latestByType: TargetValueByType = {};
const average30ByType: TargetValueByType = {};
diff --git a/src/lib/targets/vitals-builder.ts b/src/lib/targets/vitals-builder.ts
index 9517aa01f..8dc1feb06 100644
--- a/src/lib/targets/vitals-builder.ts
+++ b/src/lib/targets/vitals-builder.ts
@@ -15,6 +15,7 @@ import {
} from "@/lib/analytics/pulse-targets";
import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse";
import { getBodyFatTargetRange } from "@/lib/analytics/value-bands";
+import { binaryReferenceSex, type ProfileSex } from "@/lib/profile/sex";
import { userDayKey } from "@/lib/tz/resolver";
import {
makeRangeClassifier,
@@ -42,7 +43,8 @@ interface VitalTargetsInput {
average30ByType: TargetValueByType;
heightCm: number | null;
age: number | null;
- gender: "MALE" | "FEMALE" | null;
+ /** Sex as stored, `OTHER` included; narrowed per formula below. */
+ gender: ProfileSex;
timezone: string;
now: Date;
}
@@ -141,6 +143,10 @@ export function buildVitalTargets({
}: VitalTargetsInput): VitalTargetsResult {
const targets: TargetItem[] = [];
const consistencyClock = { timezone, now };
+ // The BP / pulse / body-fat tables are published for two groups and have
+ // no third row. Narrow once, here, where that is true — each helper
+ // answers a null with a neutral value, never with one of the two sides.
+ const referenceSex = binaryReferenceSex(gender);
const weightRange = heightCm ? getWeightRange(heightCm) : null;
let weightClassification: TargetItem["classification"] = null;
@@ -173,7 +179,7 @@ export function buildVitalTargets({
}),
});
- const bpRange = age != null ? getBpTargetsByAge(age, gender) : null;
+ const bpRange = age != null ? getBpTargetsByAge(age, referenceSex) : null;
const bpPairsByDay = buildBpPairsByDay(recentMeasurements, bpRange, timezone);
let bpClassification: TargetItem["classification"] = null;
if (
@@ -254,7 +260,7 @@ export function buildVitalTargets({
});
}
- const pulseTarget = getPersonalizedPulseTarget(age, gender);
+ const pulseTarget = getPersonalizedPulseTarget(age, referenceSex);
const restingResolved = resolveRestingPulseSeries({
restingSamples: recentMeasurements
.filter((measurement) => measurement.type === "RESTING_HEART_RATE")
@@ -385,13 +391,17 @@ export function buildVitalTargets({
let bodyFatClassification: TargetItem["classification"] = null;
if (latestByType.BODY_FAT != null && age != null) {
- const classification = classifyBodyFat(latestByType.BODY_FAT, gender, age);
+ const classification = classifyBodyFat(
+ latestByType.BODY_FAT,
+ referenceSex,
+ age,
+ );
bodyFatClassification = {
category: classification.category,
color: classification.color,
};
}
- const bodyFatRange = age != null ? getBodyFatTargetRange(gender) : null;
+ const bodyFatRange = age != null ? getBodyFatTargetRange(referenceSex) : null;
targets.push({
type: "BODY_FAT",
label: "Body fat",
diff --git a/src/lib/validations/auth.ts b/src/lib/validations/auth.ts
index 329cd2878..1d4021c7f 100644
--- a/src/lib/validations/auth.ts
+++ b/src/lib/validations/auth.ts
@@ -45,7 +45,23 @@ export const profileSchema = z.object({
email: z.email("Invalid email address").nullable().optional(),
heightCm: z.number().min(50).max(300).nullable().optional(),
dateOfBirth: z.string().nullable().optional(), // ISO date string
- gender: z.enum(["MALE", "FEMALE", "OTHER"]).nullable().optional(),
+ // Three stored values, and the same clearing contract every other
+ // profile field honours: null OR an empty string clears it. A client
+ // that renders "no answer" as "" was otherwise 422'd on a field the
+ // person never touched, and because the whole profile PATCH is one
+ // transaction that rejection took every sibling change with it. The
+ // message names the field and the accepted values — a person should
+ // never read the validator's own prose off a form.
+ gender: z.preprocess(
+ (v) => (v === "" ? null : v),
+ z
+ .enum(["MALE", "FEMALE", "OTHER"], {
+ error:
+ "Gender must be MALE, FEMALE or OTHER. Send null or an empty string to clear it.",
+ })
+ .nullable()
+ .optional(),
+ ),
// v1.7.0 — optional patient-identity fields for the health-record
// export cover + FHIR Patient resource. All optional, never required.
fullName: z.string().trim().max(120).nullable().optional(),
From 349dac3a07a76c5bd48151a7d5d5b12aa4e80c3b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 06:06:23 +0200
Subject: [PATCH 21/52] test(profile): cover the third gender value where it
used to be lost
Adds the cases that were missing everywhere except the FHIR conformance
suite: an empty string clears without taking its siblings down, OTHER
round-trips through the profile and onboarding writes, an unsupported
value is still refused and says which field it is about, the Strain
model scores an unanswered account on neither side, the dashboard bands
give OTHER the neutral fallback, the snapshot payload carries the value
instead of flattening it, and the doctor-report cover omits the row
rather than claiming a value the account never chose.
Refs #645
---
src/app/api/insights/generate/route.ts | 6 +-
src/components/onboarding/baseline-form.tsx | 13 ++-
src/lib/ai/coach/snapshot.ts | 6 +-
src/lib/dashboard/__tests__/snapshot.test.ts | 29 ++++++
.../__tests__/header-profile-gender.test.ts | 93 +++++++++++++++++++
src/lib/profile/__tests__/sex.test.ts | 38 ++++++++
6 files changed, 170 insertions(+), 15 deletions(-)
create mode 100644 src/lib/doctor-report-pdf/__tests__/header-profile-gender.test.ts
create mode 100644 src/lib/profile/__tests__/sex.test.ts
diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts
index c17109db5..271389d2b 100644
--- a/src/app/api/insights/generate/route.ts
+++ b/src/app/api/insights/generate/route.ts
@@ -68,6 +68,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { invalidateUserInsights } from "@/lib/cache/invalidate";
import { annotate } from "@/lib/logging/context";
+import { toProfileSex } from "@/lib/profile/sex";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { normalizeLocale } from "@/lib/insights/status-shared";
import { hasUsableStatusProvider } from "@/lib/insights/status-provider";
@@ -597,10 +598,7 @@ export const POST = apiHandler((request: NextRequest) =>
// v1.10.0 — fold a notable derived wellness signal (readiness / recovery
// shift, confidence-gated) into the briefing as a SYSTEM CONTEXT block.
// Reads the one derived contract; null (the common case) costs nothing.
- const derivedSex =
- dbUser?.gender === "MALE" || dbUser?.gender === "FEMALE"
- ? (dbUser.gender as "MALE" | "FEMALE")
- : null;
+ const derivedSex = toProfileSex(dbUser?.gender);
const derivedBriefing = await detectDerivedBriefingSignals(userId, {
ageYears: getAgeFromDateOfBirth(dbUser?.dateOfBirth ?? null),
sex: derivedSex,
diff --git a/src/components/onboarding/baseline-form.tsx b/src/components/onboarding/baseline-form.tsx
index b1ba7f94d..f905c1ca1 100644
--- a/src/components/onboarding/baseline-form.tsx
+++ b/src/components/onboarding/baseline-form.tsx
@@ -29,6 +29,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { queryKeys } from "@/lib/query-keys";
+import { toProfileSex } from "@/lib/profile/sex";
import {
AnamnesisCard,
buildAnamnesisAboutMeBody,
@@ -139,13 +140,11 @@ export function BaselineForm() {
const heightCm = heightAdapter.toCanonicalCm(form.height);
if (heightCm !== null) profileBody.heightCm = heightCm;
if (form.dateOfBirth) profileBody.dateOfBirth = form.dateOfBirth;
- if (
- form.gender === "MALE" ||
- form.gender === "FEMALE" ||
- form.gender === "OTHER"
- ) {
- profileBody.gender = form.gender;
- }
+ // Every value the profile stores travels; the guard drops only the
+ // "prefer not to say" placeholder. It used to list two of the three
+ // by hand, so a third-value answer vanished on submit without a word.
+ const gender = toProfileSex(form.gender);
+ if (gender) profileBody.gender = gender;
if (Object.keys(profileBody).length > 0) {
await apiPut("/api/auth/profile", profileBody);
}
diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts
index ae160c7f2..f6cbe5fcc 100644
--- a/src/lib/ai/coach/snapshot.ts
+++ b/src/lib/ai/coach/snapshot.ts
@@ -53,6 +53,7 @@ import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate";
import { MODULE_SCOPED_SOURCES } from "@/lib/modules/measurement-scope";
import { SCHEDULE_COMPLIANCE_SELECT } from "@/lib/analytics/compliance";
import type { BaselineProfile } from "@/lib/insights/derived";
+import { toProfileSex } from "@/lib/profile/sex";
import {
CLUSTER_PRIORITY,
clusterSourcesFromPrefs,
@@ -691,10 +692,7 @@ async function buildCoachSnapshotImpl(
const derivedCtx = features.context;
const derivedProfile: BaselineProfile = {
ageYears: derivedCtx?.ageYears ?? null,
- sex:
- derivedCtx?.gender === "MALE" || derivedCtx?.gender === "FEMALE"
- ? (derivedCtx.gender as "MALE" | "FEMALE")
- : null,
+ sex: toProfileSex(derivedCtx?.gender),
heightCm: derivedCtx?.heightCm ?? null,
};
// v1.18.0 — the derived block + WHOOP-native dayStrain + trajectory are
diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts
index 19ef63e60..288e2c39f 100644
--- a/src/lib/dashboard/__tests__/snapshot.test.ts
+++ b/src/lib/dashboard/__tests__/snapshot.test.ts
@@ -1276,3 +1276,32 @@ describe("buildDashboardSnapshot — scoreRings wire", () => {
expect(snap.tiles.summaries.WEIGHT).toBeDefined();
});
});
+
+describe("buildDashboardSnapshot — the wire carries the stored gender", () => {
+ beforeEach(() => {
+ // Prime only what the user block needs; this suite must not inherit
+ // another test's mock state.
+ probeRollupCoverage.mockResolvedValue(new Map());
+ isFullyCovered.mockReturnValue(false);
+ computeBpInTargetFastPath.mockResolvedValue(null);
+ });
+
+ it("emits OTHER rather than flattening it to no answer", async () => {
+ // Narrowing to two values on the wire made an account that answered
+ // OTHER indistinguishable from one that answered nothing, on every
+ // client reading this payload.
+ const snap = await buildDashboardSnapshot(
+ fakePrisma,
+ baseUser({ gender: "OTHER" }),
+ );
+ expect(snap.user.gender).toBe("OTHER");
+ });
+
+ it("still reports no answer as null", async () => {
+ const snap = await buildDashboardSnapshot(
+ fakePrisma,
+ baseUser({ gender: null }),
+ );
+ expect(snap.user.gender).toBeNull();
+ });
+});
diff --git a/src/lib/doctor-report-pdf/__tests__/header-profile-gender.test.ts b/src/lib/doctor-report-pdf/__tests__/header-profile-gender.test.ts
new file mode 100644
index 000000000..ce126d2b5
--- /dev/null
+++ b/src/lib/doctor-report-pdf/__tests__/header-profile-gender.test.ts
@@ -0,0 +1,93 @@
+import { describe, it, expect, vi } from "vitest";
+import { buildHeaderProfileSection } from "../header-profile-section";
+import type {
+ DoctorReportPdfRenderContext,
+ DoctorReportPdfCursorState,
+} from "../render-context";
+
+/**
+ * A line on a clinical cover sheet is a positive claim about the patient.
+ * The gender row used to label ANY unrecognised stored string as the third
+ * value, so a legacy or malformed value asserted something the account
+ * never chose. Absence has to read as absence.
+ */
+function renderPatientLines(gender: string | null): string[] {
+ const lines: string[] = [];
+ const doc = {
+ setFontSize: vi.fn(),
+ setFont: vi.fn(),
+ setTextColor: vi.fn(),
+ setDrawColor: vi.fn(),
+ setLineWidth: vi.fn(),
+ line: vi.fn(),
+ addPage: vi.fn(),
+ getNumberOfPages: () => 1,
+ splitTextToSize: (s: string) => [s],
+ text: (line: string) => {
+ lines.push(line);
+ },
+ internal: { pageSize: { getWidth: () => 210, getHeight: () => 297 } },
+ };
+ const context = {
+ doc,
+ data: {
+ patient: {
+ username: "testuser",
+ fullName: null,
+ dateOfBirth: null,
+ gender,
+ heightCm: null,
+ insurerName: null,
+ insurerIkNumber: null,
+ },
+ period: {
+ since: "2026-06-01T00:00:00.000Z",
+ start: null,
+ end: null,
+ days: 30,
+ },
+ practiceName: null,
+ measurements: {},
+ stats: {},
+ compliance: {},
+ },
+ // The translator echoes the key so an assertion can name the label it
+ // expects instead of a locale string.
+ t: (key: string) => key,
+ num: (value: number) => String(value),
+ fmtDate: (iso: string) => iso.slice(0, 10),
+ now: new Date("2026-07-26T00:00:00.000Z"),
+ insuranceNumber: null,
+ margin: 15,
+ pageWidth: 210,
+ contentMaxY: 280,
+ ensureSpace: (current: number) => current,
+ } as unknown as DoctorReportPdfRenderContext;
+ buildHeaderProfileSection(context, {
+ y: 20,
+ } as unknown as DoctorReportPdfCursorState);
+ return lines;
+}
+
+describe("doctor-report cover — gender row", () => {
+ it("labels each of the three stored values", () => {
+ expect(renderPatientLines("MALE")).toContain(
+ "doctorReport.gender: doctorReport.genderMale",
+ );
+ expect(renderPatientLines("FEMALE")).toContain(
+ "doctorReport.gender: doctorReport.genderFemale",
+ );
+ expect(renderPatientLines("OTHER")).toContain(
+ "doctorReport.gender: doctorReport.genderOther",
+ );
+ });
+
+ it("omits the row entirely for no answer or an unrecognised value", () => {
+ for (const stored of [null, "", "diverse", "male"]) {
+ const lines = renderPatientLines(stored);
+ expect(
+ lines.some((line) => line.startsWith("doctorReport.gender:")),
+ ).toBe(false);
+ }
+ });
+});
diff --git a/src/lib/profile/__tests__/sex.test.ts b/src/lib/profile/__tests__/sex.test.ts
new file mode 100644
index 000000000..bd0d8dad9
--- /dev/null
+++ b/src/lib/profile/__tests__/sex.test.ts
@@ -0,0 +1,38 @@
+import { describe, it, expect } from "vitest";
+import { toProfileSex, binaryReferenceSex } from "@/lib/profile/sex";
+
+/**
+ * The column holds three values. Two separate defects shipped from code
+ * that could only hold two: the FHIR export mapped the third to `female`,
+ * and the Strain model scored it on the male coefficients. These two
+ * functions are the only sanctioned way to move between the widths.
+ */
+describe("toProfileSex", () => {
+ it("carries every stored value through", () => {
+ expect(toProfileSex("MALE")).toBe("MALE");
+ expect(toProfileSex("FEMALE")).toBe("FEMALE");
+ expect(toProfileSex("OTHER")).toBe("OTHER");
+ });
+
+ it("reports no answer as no answer, and never guesses one", () => {
+ expect(toProfileSex(null)).toBeNull();
+ expect(toProfileSex(undefined)).toBeNull();
+ expect(toProfileSex("")).toBeNull();
+ expect(toProfileSex("male")).toBeNull();
+ expect(toProfileSex("diverse")).toBeNull();
+ });
+});
+
+describe("binaryReferenceSex", () => {
+ it("keeps the two values a sex-split reference table has rows for", () => {
+ expect(binaryReferenceSex("MALE")).toBe("MALE");
+ expect(binaryReferenceSex("FEMALE")).toBe("FEMALE");
+ });
+
+ it("resolves the third value to null rather than to one of the two", () => {
+ expect(binaryReferenceSex("OTHER")).toBeNull();
+ expect(binaryReferenceSex(null)).toBeNull();
+ expect(binaryReferenceSex(undefined)).toBeNull();
+ expect(binaryReferenceSex("")).toBeNull();
+ });
+});
From 0afd217f9fe2b528eebea92298a2dc3a73a2553e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 06:11:43 +0200
Subject: [PATCH 22/52] docs(api): say which profile fields an empty string
actually clears
The description promised that null or an empty string clears any field.
An empty string clears the free-text ones and now gender, but email is
validated as an address and 422s on it, and the display preferences are
not clearable at all. Name them rather than let a client find out.
---
docs/api/openapi.yaml | 7 +++++--
src/lib/openapi/routes/profile.ts | 2 +-
2 files changed, 6 insertions(+), 3 deletions(-)
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 75d1d22bc..b87fd95c0 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -13336,8 +13336,11 @@ components:
anyOf:
- type: string
- type: "null"
- description: Partial profile update. Every field is optional; an omitted field is left untouched, an explicit null (or
- empty string) clears it. `userId` is never accepted — it is narrowed from the session/token.
+ description: Partial profile update. Every field is optional and an omitted field is left untouched. An explicit null
+ clears any nullable field; an empty string also clears `displayName`, `dateOfBirth`, `gender`, `fullName`,
+ `insurerName`, `insuranceNumber` and `insurerIkNumber`. `email` is not among them — it is validated as an
+ address, so send null to clear it. `locale`, `timezone`, `timeFormat` and `dateFormat` are not clearable.
+ `userId` is never accepted — it is narrowed from the session/token.
NotificationPrefsPatchRequest:
type: object
properties:
diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts
index 0b9c2b671..a75fe9a11 100644
--- a/src/lib/openapi/routes/profile.ts
+++ b/src/lib/openapi/routes/profile.ts
@@ -252,7 +252,7 @@ const profileUpdateRequest = z
.meta({
id: "ProfileUpdateRequest",
description:
- "Partial profile update. Every field is optional; an omitted field is left untouched, an explicit null (or empty string) clears it. `userId` is never accepted — it is narrowed from the session/token.",
+ "Partial profile update. Every field is optional and an omitted field is left untouched. An explicit null clears any nullable field; an empty string also clears `displayName`, `dateOfBirth`, `gender`, `fullName`, `insurerName`, `insuranceNumber` and `insurerIkNumber`. `email` is not among them — it is validated as an address, so send null to clear it. `locale`, `timezone`, `timeFormat` and `dateFormat` are not clearable. `userId` is never accepted — it is narrowed from the session/token.",
});
const profileResponse = z
From 48ac8f7e2d8bf83ea56a4145219ba9f9032ae623 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 00:02:04 +0200
Subject: [PATCH 23/52] fix(ui): a mis-tap must not be able to destroy an
allergy list
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Settings → AI "About me" had one Clear control wired to
`save.mutate({ aboutMe: "", allergies: "" })`. One tap, no dialog, and
the allergy line went with the note — while the toast it raised said
"About-me note cleared", which is what the control looks like it does.
The allergy line is the first thing a clinician reads and the column is
overwritten in place, so there is nothing to restore afterwards.
Scope the control rather than only gating it: it now writes `aboutMe`
alone and omits `allergies`, which the PUT leaves untouched by contract,
so the payload cannot carry the allergy line away even if the dialog is
ever dropped. Clearing allergies stays possible in the allergies field
itself, which is a deliberate act on the thing being emptied. The
confirmation on top guards the note.
The same one-tap shape ran through thirteen other controls that
overwrite a column or drop a credential with nothing to restore from.
They route through the shared confirmation now: both Codex disconnects
(personal and the instance-wide shared account), the AI server key, the
profile photo, the threshold reset — all, per metric, and the override
switch, whose off position performed the same irreversible delete from a
control that reads as a display toggle — the source-priority order, the
dashboard layout, the Insights overview, a single target range, a
remembered Coach fact, a past Coach plan, a logged cycle day and a custom
cycle symptom.
`ConfirmButton` moves to `components/ui`, since it is no longer a
settings-local helper, and gains `ConfirmDialog`: the same dialog without
a trigger, for destructive controls that are not buttons. A switch has to
stay on when the dialog its off position raised is cancelled, and a
control that could not reach for the primitive reached for nothing —
which is a fair part of why those were the ones that shipped unguarded.
Copy lands in all six locales.
---
messages/de.json | 46 +++++++
messages/en.json | 46 +++++++
messages/es.json | 46 +++++++
messages/fr.json | 46 +++++++
messages/it.json | 46 +++++++
messages/pl.json | 46 +++++++
src/app/coach/plans/page.tsx | 21 ++--
.../admin/ai-server-key-section.tsx | 17 +--
.../admin/central-codex-section.tsx | 23 ++--
src/components/cycle/log-day-sheet.tsx | 40 ++++--
.../insights/insights-edit-mode.tsx | 24 ++--
.../__tests__/coach-memory-section.test.tsx | 6 +-
src/components/settings/about-me-section.tsx | 43 +++++--
.../account-section/avatar-section.tsx | 23 ++--
.../settings/ai/codex-provider-form.tsx | 22 ++--
.../settings/coach-memory-section.tsx | 34 +++---
.../settings/coach-reminders-section.tsx | 2 +-
.../settings/dashboard-layout-section.tsx | 18 +--
.../settings/environment-section.tsx | 2 +-
.../settings/security-sessions-card.tsx | 2 +-
src/components/settings/sources-section.tsx | 19 +--
.../settings/thresholds-editor-section.tsx | 56 ++++++---
.../settings/trusted-devices-card.tsx | 2 +-
src/components/targets/target-edit-sheet.tsx | 24 ++--
.../{settings => ui}/confirm-button.tsx | 115 +++++++++++++-----
25 files changed, 587 insertions(+), 182 deletions(-)
rename src/components/{settings => ui}/confirm-button.tsx (54%)
diff --git a/messages/de.json b/messages/de.json
index 31f362748..e6e20165d 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -455,6 +455,9 @@
"scoreRingsTitle": "Score-Ringe",
"scoreRingsDescription": "Bis zu drei zusätzliche Ringe neben dem Health Score – zum Beispiel Bereitschaft, Erholung, Schlaf oder Therapietreue. Zum Umsortieren ziehen; der Health Score steht standardmäßig vorne.",
"layoutReset": "Auf Defaults zurücksetzen",
+ "layoutResetTitle": "Dashboard-Layout zurücksetzen?",
+ "layoutResetBody": "Deine Kachel-Reihenfolge, die ausgeblendeten Kacheln und die gewählten Ringe kehren zu den Standardwerten zurück. Die Anordnung wird nirgends sonst gespeichert.",
+ "layoutResetConfirm": "Layout zurücksetzen",
"layoutSaveSuccess": "Dashboard-Layout gespeichert",
"layoutSaveError": "Layout konnte nicht gespeichert werden",
"layoutConflictReloaded": "Layout wurde vom Server neu geladen. Bitte nimm deine Änderung erneut vor.",
@@ -2141,6 +2144,9 @@
"editMode": {
"done": "Fertig",
"reset": "Zurücksetzen",
+ "resetTitle": "Übersicht zurücksetzen?",
+ "resetBody": "Abschnittsreihenfolge und ausgeblendete Abschnitte kehren zu den Standardwerten zurück. Deine Anordnung wird nirgends sonst gespeichert.",
+ "resetConfirm": "Übersicht zurücksetzen",
"title": "Übersicht anpassen",
"description": "Zum Sortieren ziehen, zum Ein- oder Ausblenden auf das Auge tippen.",
"dragHandle": "Zum Sortieren ziehen",
@@ -3988,6 +3994,12 @@
"overrideToggleLabel": "Eigene Werte",
"resetAction": "Auf Default zurücksetzen",
"resetAllAction": "Alle auf Default zurücksetzen",
+ "resetAllTitle": "Alle eigenen Bereiche zurücksetzen?",
+ "resetAllBody": "Jede Messgröße kehrt zu ihrem Standardbereich zurück. Die von dir gesetzten Werte werden nirgends sonst gespeichert und lassen sich nicht wiederherstellen.",
+ "resetAllConfirm": "Alle zurücksetzen",
+ "resetTitle": "Diesen Bereich zurücksetzen?",
+ "resetBody": "Die Messgröße kehrt zu ihrem Standardbereich zurück. Die von dir gesetzten Werte werden nirgends sonst gespeichert und lassen sich nicht wiederherstellen.",
+ "resetConfirm": "Bereich zurücksetzen",
"minLabel": "Min",
"maxLabel": "Max",
"unitSuffix": "({unit})",
@@ -4356,6 +4368,9 @@
"cardTitle": "Quellen-Priorität pro Metrik",
"help": "Liefern mehrere Quellen denselben Wert am selben Tag, zählt die oberste Quelle als kanonisch. Die übrigen bleiben für die Audit-Spur gespeichert.",
"resetDefaults": "Auf Standard zurücksetzen",
+ "resetDefaultsTitle": "Quellen-Reihenfolge zurücksetzen?",
+ "resetDefaultsBody": "Jede von dir angeordnete Rangfolge kehrt zur Standardreihenfolge zurück, für alle Messgrößen und Gerätetypen auf einmal. Das wird sofort gespeichert und lässt sich nicht wiederherstellen.",
+ "resetDefaultsConfirm": "Reihenfolge zurücksetzen",
"moveUp": "Nach oben",
"moveDown": "Nach unten",
"saveSuccess": "Quellen-Priorität gespeichert.",
@@ -4787,6 +4802,9 @@
"upload": "Foto hochladen",
"replace": "Foto ersetzen",
"remove": "Entfernen",
+ "removeTitle": "Profilbild entfernen?",
+ "removeBody": "Das Bild wird von deinem Server gelöscht, an seine Stelle treten deine Initialen. Zum erneuten Hochladen brauchst du die Originaldatei.",
+ "removeConfirm": "Bild entfernen",
"hint": "JPEG, PNG oder WebP. Bis zu 2 MB und 2048×2048 Pixel.",
"uploaded": "Profilfoto aktualisiert",
"removed": "Profilfoto entfernt",
@@ -4946,6 +4964,9 @@
"statusExpired": "Verbindung abgelaufen",
"connectButton": "Mit ChatGPT verbinden",
"disconnectButton": "Trennen",
+ "disconnectTitle": "ChatGPT trennen?",
+ "disconnectBody": "Die gespeicherte Anmeldung wird zusammen mit deinem zwischengespeicherten Insight-Text verworfen. Für eine neue Verbindung musst du dich erneut über den Gerätecode-Ablauf anmelden.",
+ "disconnectConfirm": "Trennen",
"modelSlugLabel": "Modell-Slug",
"modelSlugBody": "Codex nutzt das Modell, auf das dein ChatGPT-Abo routet. Die Umgebungsvariable CODEX_MODEL kann das instanzweit überschreiben.",
"lastInsight": "Letzte Analyse: {when}"
@@ -4976,6 +4997,9 @@
"categoryContext": "Kontext",
"forget": "Vergessen",
"forgetAria": "Diese Angabe vergessen",
+ "forgetTitle": "Diese Notiz vergessen?",
+ "forgetBody": "Der Coach nutzt sie ab der nächsten Nachricht nicht mehr. Eine vergessene Notiz lässt sich nicht wiederherstellen.",
+ "forgetConfirm": "Vergessen",
"forgetAll": "Alles vergessen",
"forgetAllConfirmTitle": "Alles vergessen, woran sich der Coach erinnert?",
"forgetAllConfirmBody": "Dadurch werden alle gespeicherten Angaben entfernt. Der Coach beginnt beim nächsten Gespräch von vorn. Das lässt sich nicht rückgängig machen.",
@@ -5019,6 +5043,9 @@
"placeholder": "Z. B. Schichtarbeit, vegetarische Ernährung, Knieverletzung, Training für einen Halbmarathon…",
"charCount": "{used} / {max} Zeichen",
"clear": "Leeren",
+ "clearTitle": "Über-mich-Notiz leeren?",
+ "clearBody": "Entfernt nur den Freitext. Allergien und Unverträglichkeiten bleiben unverändert.",
+ "clearConfirm": "Notiz leeren",
"save": "Speichern",
"savedToast": "Über-mich-Notiz gespeichert.",
"clearedToast": "Über-mich-Notiz gelöscht.",
@@ -5844,6 +5871,9 @@
"baseUrlLabel": "Basis-URL",
"save": "Speichern",
"remove": "Schlüssel entfernen",
+ "removeTitle": "Server-Schlüssel entfernen?",
+ "removeBody": "Konten ohne eigenen Schlüssel verlieren die KI-Funktionen sofort. Der Schlüssel wird nach dem Speichern nirgends mehr angezeigt — zum Wiederherstellen brauchst du das Original.",
+ "removeConfirm": "Schlüssel entfernen",
"saved": "KI-Server-Schlüssel gespeichert",
"error": "Speichern des KI-Server-Schlüssels fehlgeschlagen"
},
@@ -6366,6 +6396,9 @@
"statusExpired": "Abgelaufen",
"connectButton": "ChatGPT-Konto verbinden",
"disconnectButton": "Trennen",
+ "disconnectTitle": "Gemeinsames KI-Konto trennen?",
+ "disconnectBody": "Alle Nutzer auf diesem Server, die die gemeinsame Verbindung nutzen, verlieren die KI-Funktionen, bis wieder ein Konto verknüpft wird. Die gespeicherte Anmeldung lässt sich nicht wiederherstellen.",
+ "disconnectConfirm": "Trennen",
"connectedSince": "Verbunden seit {when}",
"connected": "Gemeinsame KI-Verbindung verknüpft",
"disconnected": "Gemeinsame KI-Verbindung entfernt",
@@ -6842,6 +6875,10 @@
"diastolicMax": "Diastolisch max",
"boundsHint": "Erlaubter Bereich: {min}–{max} {unit}",
"resetToDefault": "Auf Standard zurücksetzen",
+ "resetTitle": "Dieses Ziel zurücksetzen?",
+ "resetBody": "Der Bereich kehrt zum Standard zurück. Die von dir gesetzten Werte werden nirgends sonst gespeichert und lassen sich nicht wiederherstellen.",
+ "resetBodyBp": "Sowohl der systolische als auch der diastolische Bereich kehren zum Standard zurück. Die von dir gesetzten Werte werden nirgends sonst gespeichert und lassen sich nicht wiederherstellen.",
+ "resetConfirm": "Ziel zurücksetzen",
"saveSuccess": "Zielbereich aktualisiert",
"saveError": "Zielbereich konnte nicht gespeichert werden",
"resetSuccess": "Zielbereich auf Standard zurückgesetzt",
@@ -7074,6 +7111,9 @@
"icon": "Symbol",
"limitReached": "Höchstzahl an eigenen Symptomen erreicht.",
"remove": "{label} entfernen",
+ "removeTitle": "Dieses Symptom entfernen?",
+ "removeBody": "„{label}“ verschwindet aus der Symptomliste. Bereits damit markierte Tage behalten es, du kannst es aber nicht mehr auswählen.",
+ "removeConfirm": "Entfernen",
"fallbackLabel": "Eigenes Symptom"
}
},
@@ -7306,6 +7346,9 @@
"severity": "Schweregrad",
"severityLevel": "Schweregrad {level} von 4",
"delete": "Löschen",
+ "deleteTitle": "Diesen Tag löschen?",
+ "deleteBody": "Alles, was für diesen Tag erfasst ist, verschwindet: Blutung, Temperatur, Symptome und deine Notiz. Es lässt sich nicht wiederherstellen.",
+ "deleteConfirm": "Tag löschen",
"temperatureSection": "Temperatur & Eisprung",
"intimacySection": "Intimität & Verhütung",
"testsSection": "Tests",
@@ -8099,6 +8142,9 @@
"markMet": "Ziel erreicht",
"abandon": "Plan beenden",
"remove": "Entfernen",
+ "removeTitle": "Diesen Plan entfernen?",
+ "removeBody": "Der Plan und sein Verlauf verschwinden aus deiner Historie. Er lässt sich nicht wiederherstellen.",
+ "removeConfirm": "Plan entfernen",
"empty": "Noch keine Pläne. Wenn du dich im Gespräch zu einer konkreten Wenn-dann-Absicht bekennst, schlägt der Coach sie hier zur Bestätigung vor.",
"groupProposed": "Vorschläge",
"groupActive": "Aktiv",
diff --git a/messages/en.json b/messages/en.json
index 3fcc3b6cb..b1b50f6fc 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -455,6 +455,9 @@
"scoreRingsTitle": "Score rings",
"scoreRingsDescription": "Up to three additional rings next to the health score — for example readiness, recovery, sleep or adherence. Drag to reorder; the health score leads by default.",
"layoutReset": "Reset to defaults",
+ "layoutResetTitle": "Reset the dashboard layout?",
+ "layoutResetBody": "Your tile order, the tiles you hid and the rings you chose all go back to defaults. The arrangement is not stored anywhere else.",
+ "layoutResetConfirm": "Reset layout",
"layoutSaveSuccess": "Dashboard layout saved",
"layoutSaveError": "Could not save dashboard layout",
"layoutConflictReloaded": "Layout reloaded from the server. Please make your change again.",
@@ -2143,6 +2146,9 @@
"editMode": {
"done": "Done",
"reset": "Reset",
+ "resetTitle": "Reset the overview?",
+ "resetBody": "Section order and the sections you hid go back to defaults. Your arrangement is not stored anywhere else.",
+ "resetConfirm": "Reset overview",
"title": "Customize overview",
"description": "Drag to reorder, tap the eye to show or hide.",
"dragHandle": "Drag to reorder",
@@ -3988,6 +3994,12 @@
"overrideToggleLabel": "Custom range",
"resetAction": "Reset to default",
"resetAllAction": "Reset all to default",
+ "resetAllTitle": "Reset every custom range?",
+ "resetAllBody": "Every metric goes back to its default range. The values you set are not stored anywhere else and cannot be brought back.",
+ "resetAllConfirm": "Reset all",
+ "resetTitle": "Reset this range?",
+ "resetBody": "The metric goes back to its default range. The values you set are not stored anywhere else and cannot be brought back.",
+ "resetConfirm": "Reset range",
"minLabel": "Min",
"maxLabel": "Max",
"unitSuffix": "({unit})",
@@ -4356,6 +4368,9 @@
"cardTitle": "Source priority by metric",
"help": "When several sources deliver the same value on the same day, the top source counts as canonical. The others stay stored for the audit trail.",
"resetDefaults": "Reset to defaults",
+ "resetDefaultsTitle": "Reset the source order?",
+ "resetDefaultsBody": "Every ladder you arranged goes back to the default order, for all metrics and device types at once. This is saved immediately and cannot be brought back.",
+ "resetDefaultsConfirm": "Reset order",
"moveUp": "Move up",
"moveDown": "Move down",
"saveSuccess": "Source priority saved.",
@@ -4787,6 +4802,9 @@
"upload": "Upload photo",
"replace": "Replace photo",
"remove": "Remove",
+ "removeTitle": "Remove the profile photo?",
+ "removeBody": "The image is deleted from your server and your initials take its place. You would need the original file to upload it again.",
+ "removeConfirm": "Remove photo",
"hint": "JPEG, PNG, or WebP. Up to 2 MB and 2048×2048 pixels.",
"uploaded": "Profile photo updated",
"removed": "Profile photo removed",
@@ -4946,6 +4964,9 @@
"statusExpired": "Connection expired",
"connectButton": "Connect with ChatGPT",
"disconnectButton": "Disconnect",
+ "disconnectTitle": "Disconnect ChatGPT?",
+ "disconnectBody": "The stored sign-in is dropped along with your cached insight text. Reconnecting means signing in through the device-code flow again.",
+ "disconnectConfirm": "Disconnect",
"modelSlugLabel": "Model slug",
"modelSlugBody": "Codex uses the model your ChatGPT subscription routes to. The CODEX_MODEL environment variable lets ops override this on the instance.",
"lastInsight": "Last insight: {when}"
@@ -4976,6 +4997,9 @@
"categoryContext": "Context",
"forget": "Forget",
"forgetAria": "Forget this fact",
+ "forgetTitle": "Forget this fact?",
+ "forgetBody": "The Coach stops using it from the next message on. There is no way to bring a forgotten fact back.",
+ "forgetConfirm": "Forget",
"forgetAll": "Forget everything",
"forgetAllConfirmTitle": "Forget everything the Coach remembers?",
"forgetAllConfirmBody": "This removes every stored fact. The Coach will start fresh the next time you chat. This cannot be undone.",
@@ -5019,6 +5043,9 @@
"placeholder": "E.g. shift work, vegetarian diet, knee injury, training for a half marathon…",
"charCount": "{used} / {max} characters",
"clear": "Clear",
+ "clearTitle": "Clear the about-me note?",
+ "clearBody": "This removes the free-text note only. Your allergies and intolerances stay exactly as they are.",
+ "clearConfirm": "Clear note",
"save": "Save",
"savedToast": "About-me note saved.",
"clearedToast": "About-me note cleared.",
@@ -5844,6 +5871,9 @@
"baseUrlLabel": "Base URL",
"save": "Save",
"remove": "Remove key",
+ "removeTitle": "Remove the server key?",
+ "removeBody": "Accounts without their own key lose AI features straight away. The key is not shown anywhere after it is stored, so you will need the original to put it back.",
+ "removeConfirm": "Remove key",
"saved": "AI server key settings saved",
"error": "Saving the AI server key failed"
},
@@ -6366,6 +6396,9 @@
"statusExpired": "Expired",
"connectButton": "Connect a ChatGPT account",
"disconnectButton": "Disconnect",
+ "disconnectTitle": "Disconnect the shared AI account?",
+ "disconnectBody": "Every user on this server who relies on the shared connection loses AI features until someone links an account again. The stored sign-in cannot be recovered.",
+ "disconnectConfirm": "Disconnect",
"connectedSince": "Connected since {when}",
"connected": "Shared AI connection linked",
"disconnected": "Shared AI connection removed",
@@ -6842,6 +6875,10 @@
"diastolicMax": "Diastolic max",
"boundsHint": "Allowed range: {min}–{max} {unit}",
"resetToDefault": "Reset to default",
+ "resetTitle": "Reset this target?",
+ "resetBody": "The range goes back to the default. The values you set are not stored anywhere else and cannot be brought back.",
+ "resetBodyBp": "Both the systolic and the diastolic range go back to the default. The values you set are not stored anywhere else and cannot be brought back.",
+ "resetConfirm": "Reset target",
"saveSuccess": "Target range updated",
"saveError": "Could not save the target range",
"resetSuccess": "Target range reset to default",
@@ -7074,6 +7111,9 @@
"icon": "Icon",
"limitReached": "Custom symptom limit reached.",
"remove": "Remove {label}",
+ "removeTitle": "Remove this symptom?",
+ "removeBody": "“{label}” disappears from the symptom list. Days you already tagged with it keep it, but you cannot pick it again.",
+ "removeConfirm": "Remove",
"fallbackLabel": "Custom symptom"
}
},
@@ -7300,6 +7340,9 @@
"severity": "Severity",
"severityLevel": "Severity {level} of 4",
"delete": "Delete",
+ "deleteTitle": "Delete this day?",
+ "deleteBody": "Everything logged for this day goes: flow, temperature, symptoms and your note. There is no way to bring it back.",
+ "deleteConfirm": "Delete day",
"note": "Note",
"notePlaceholder": "Anything you want to remember about today…",
"save": "Save",
@@ -8099,6 +8142,9 @@
"markMet": "Goal met",
"abandon": "End plan",
"remove": "Remove",
+ "removeTitle": "Remove this plan?",
+ "removeBody": "The plan and how it went disappear from your history. There is no way to bring it back.",
+ "removeConfirm": "Remove plan",
"empty": "No plans yet. When you commit to a concrete if-then intention in a conversation, the Coach proposes it here for confirmation.",
"groupProposed": "Suggestions",
"groupActive": "Active",
diff --git a/messages/es.json b/messages/es.json
index ce0c60e5e..9f56ae1f6 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -455,6 +455,9 @@
"scoreRingsTitle": "Anillos de puntuación",
"scoreRingsDescription": "Hasta tres anillos adicionales junto a la puntuación de salud, por ejemplo preparación, recuperación, sueño o cumplimiento. Arrastra para reordenar; la puntuación de salud va primero de forma predeterminada.",
"layoutReset": "Restablecer valores por defecto",
+ "layoutResetTitle": "¿Restablecer el diseño del panel?",
+ "layoutResetBody": "El orden de tus tarjetas, las que ocultaste y los anillos que elegiste vuelven a los valores predeterminados. La disposición no se guarda en ningún otro sitio.",
+ "layoutResetConfirm": "Restablecer diseño",
"layoutSaveSuccess": "Diseño del panel guardado",
"layoutSaveError": "No se ha podido guardar el diseño",
"layoutConflictReloaded": "Se ha recargado el diseño desde el servidor. Vuelve a aplicar tu cambio.",
@@ -2143,6 +2146,9 @@
"editMode": {
"done": "Listo",
"reset": "Restablecer",
+ "resetTitle": "¿Restablecer la vista general?",
+ "resetBody": "El orden de las secciones y las que ocultaste vuelven a los valores predeterminados. Tu disposición no se guarda en ningún otro sitio.",
+ "resetConfirm": "Restablecer vista",
"title": "Personalizar resumen",
"description": "Arrastra para reordenar, toca el ojo para mostrar u ocultar.",
"dragHandle": "Arrastra para reordenar",
@@ -3988,6 +3994,12 @@
"overrideToggleLabel": "Valores propios",
"resetAction": "Restablecer valor por defecto",
"resetAllAction": "Restablecer todos los valores",
+ "resetAllTitle": "¿Restablecer todos los rangos personalizados?",
+ "resetAllBody": "Cada métrica vuelve a su rango predeterminado. Los valores que definiste no se guardan en ningún otro sitio y no se pueden recuperar.",
+ "resetAllConfirm": "Restablecer todo",
+ "resetTitle": "¿Restablecer este rango?",
+ "resetBody": "La métrica vuelve a su rango predeterminado. Los valores que definiste no se guardan en ningún otro sitio y no se pueden recuperar.",
+ "resetConfirm": "Restablecer rango",
"minLabel": "Mín",
"maxLabel": "Máx",
"unitSuffix": "({unit})",
@@ -4356,6 +4368,9 @@
"cardTitle": "Prioridad de fuentes por métrica",
"help": "Si varias fuentes entregan el mismo valor el mismo día, la fuente superior cuenta como canónica. Las demás se conservan para la traza de auditoría.",
"resetDefaults": "Restablecer valores predeterminados",
+ "resetDefaultsTitle": "¿Restablecer el orden de fuentes?",
+ "resetDefaultsBody": "Cada orden que configuraste vuelve al predeterminado, para todas las métricas y tipos de dispositivo a la vez. Se guarda de inmediato y no se puede recuperar.",
+ "resetDefaultsConfirm": "Restablecer orden",
"moveUp": "Subir",
"moveDown": "Bajar",
"saveSuccess": "Prioridad de fuentes guardada.",
@@ -4787,6 +4802,9 @@
"upload": "Subir foto",
"replace": "Reemplazar foto",
"remove": "Quitar",
+ "removeTitle": "¿Eliminar la foto de perfil?",
+ "removeBody": "La imagen se elimina de tu servidor y tus iniciales ocupan su lugar. Necesitarás el archivo original para volver a subirla.",
+ "removeConfirm": "Eliminar foto",
"hint": "JPEG, PNG o WebP. Hasta 2 MB y 2048×2048 píxeles.",
"uploaded": "Foto de perfil actualizada",
"removed": "Foto de perfil eliminada",
@@ -4946,6 +4964,9 @@
"statusExpired": "Conexión caducada",
"connectButton": "Conectar con ChatGPT",
"disconnectButton": "Desconectar",
+ "disconnectTitle": "¿Desconectar ChatGPT?",
+ "disconnectBody": "Se descarta el inicio de sesión guardado junto con el texto de tus análisis en caché. Volver a conectar exige repetir el flujo de código de dispositivo.",
+ "disconnectConfirm": "Desconectar",
"modelSlugLabel": "Slug del modelo",
"modelSlugBody": "Codex usa el modelo al que enruta tu suscripción de ChatGPT. La variable de entorno CODEX_MODEL permite al operador sobrescribirlo en la instancia.",
"lastInsight": "Último análisis: {when}"
@@ -4976,6 +4997,9 @@
"categoryContext": "Contexto",
"forget": "Olvidar",
"forgetAria": "Olvidar este dato",
+ "forgetTitle": "¿Olvidar este dato?",
+ "forgetBody": "El Coach dejará de usarlo a partir del próximo mensaje. Un dato olvidado no se puede recuperar.",
+ "forgetConfirm": "Olvidar",
"forgetAll": "Olvidar todo",
"forgetAllConfirmTitle": "¿Olvidar todo lo que recuerda el Coach?",
"forgetAllConfirmBody": "Esto elimina todos los datos guardados. El Coach empezará de cero la próxima vez que converses. No se puede deshacer.",
@@ -5019,6 +5043,9 @@
"placeholder": "P. ej. trabajo por turnos, dieta vegetariana, lesión de rodilla, entrenamiento para una media maratón…",
"charCount": "{used} / {max} caracteres",
"clear": "Vaciar",
+ "clearTitle": "¿Vaciar la nota «Sobre mí»?",
+ "clearBody": "Solo se elimina el texto libre. Tus alergias e intolerancias permanecen tal cual.",
+ "clearConfirm": "Vaciar nota",
"save": "Guardar",
"savedToast": "Nota «Sobre mí» guardada.",
"clearedToast": "Nota «Sobre mí» eliminada.",
@@ -5844,6 +5871,9 @@
"baseUrlLabel": "URL base",
"save": "Guardar",
"remove": "Eliminar clave",
+ "removeTitle": "¿Eliminar la clave del servidor?",
+ "removeBody": "Las cuentas sin clave propia pierden las funciones de IA de inmediato. La clave no se muestra en ningún sitio una vez guardada, así que necesitarás el original para volver a ponerla.",
+ "removeConfirm": "Eliminar clave",
"saved": "Ajustes de la clave de IA del servidor guardados",
"error": "No se pudo guardar la clave de IA del servidor"
},
@@ -6366,6 +6396,9 @@
"statusExpired": "Caducado",
"connectButton": "Conectar una cuenta de ChatGPT",
"disconnectButton": "Desconectar",
+ "disconnectTitle": "¿Desconectar la cuenta de IA compartida?",
+ "disconnectBody": "Todos los usuarios de este servidor que dependan de la conexión compartida pierden las funciones de IA hasta que alguien vuelva a vincular una cuenta. El inicio de sesión guardado no se puede recuperar.",
+ "disconnectConfirm": "Desconectar",
"connectedSince": "Conectado desde {when}",
"connected": "Conexión de IA compartida vinculada",
"disconnected": "Conexión de IA compartida eliminada",
@@ -6842,6 +6875,10 @@
"diastolicMax": "Diastólica máx",
"boundsHint": "Rango permitido: {min}–{max} {unit}",
"resetToDefault": "Restablecer por defecto",
+ "resetTitle": "¿Restablecer este objetivo?",
+ "resetBody": "El rango vuelve al predeterminado. Los valores que definiste no se guardan en ningún otro sitio y no se pueden recuperar.",
+ "resetBodyBp": "Tanto el rango sistólico como el diastólico vuelven al predeterminado. Los valores que definiste no se guardan en ningún otro sitio y no se pueden recuperar.",
+ "resetConfirm": "Restablecer objetivo",
"saveSuccess": "Rango objetivo actualizado",
"saveError": "No se ha podido guardar el rango objetivo",
"resetSuccess": "Rango objetivo restablecido por defecto",
@@ -7074,6 +7111,9 @@
"icon": "Icono",
"limitReached": "Se alcanzó el límite de síntomas personalizados.",
"remove": "Eliminar {label}",
+ "removeTitle": "¿Eliminar este síntoma?",
+ "removeBody": "«{label}» desaparece de la lista de síntomas. Los días que ya lo tienen lo conservan, pero no podrás volver a elegirlo.",
+ "removeConfirm": "Eliminar",
"fallbackLabel": "Síntoma personalizado"
}
},
@@ -7306,6 +7346,9 @@
"severity": "Intensidad",
"severityLevel": "Intensidad {level} de 4",
"delete": "Eliminar",
+ "deleteTitle": "¿Eliminar este día?",
+ "deleteBody": "Se pierde todo lo registrado ese día: flujo, temperatura, síntomas y tu nota. No se puede recuperar.",
+ "deleteConfirm": "Eliminar día",
"temperatureSection": "Temperatura y ovulación",
"intimacySection": "Intimidad y anticoncepción",
"testsSection": "Pruebas",
@@ -8099,6 +8142,9 @@
"markMet": "Objetivo cumplido",
"abandon": "Finalizar plan",
"remove": "Eliminar",
+ "removeTitle": "¿Eliminar este plan?",
+ "removeBody": "El plan y cómo fue desaparecen de tu historial. No se puede recuperar.",
+ "removeConfirm": "Eliminar plan",
"empty": "Aún no hay planes. Cuando te comprometas con una intención concreta de tipo «si… entonces» en una conversación, el Coach la propondrá aquí para confirmarla.",
"groupProposed": "Propuestas",
"groupActive": "Activos",
diff --git a/messages/fr.json b/messages/fr.json
index cea4d2355..f5348e6d8 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -455,6 +455,9 @@
"scoreRingsTitle": "Anneaux de score",
"scoreRingsDescription": "Jusqu’à trois anneaux supplémentaires à côté du score de santé — par exemple préparation, récupération, sommeil ou observance. Glissez pour réordonner ; le score de santé est placé en premier par défaut.",
"layoutReset": "Rétablir les valeurs par défaut",
+ "layoutResetTitle": "Réinitialiser la disposition du tableau de bord ?",
+ "layoutResetBody": "L’ordre de vos tuiles, celles que vous avez masquées et les anneaux choisis reviennent aux valeurs par défaut. Cette disposition n’est conservée nulle part ailleurs.",
+ "layoutResetConfirm": "Réinitialiser la disposition",
"layoutSaveSuccess": "Mise en page enregistrée",
"layoutSaveError": "Impossible d’enregistrer la mise en page",
"layoutConflictReloaded": "Mise en page rechargée depuis le serveur. Veuillez refaire votre modification.",
@@ -2143,6 +2146,9 @@
"editMode": {
"done": "Terminé",
"reset": "Réinitialiser",
+ "resetTitle": "Réinitialiser la vue d’ensemble ?",
+ "resetBody": "L’ordre des sections et celles que vous avez masquées reviennent aux valeurs par défaut. Votre disposition n’est conservée nulle part ailleurs.",
+ "resetConfirm": "Réinitialiser la vue",
"title": "Personnaliser l’aperçu",
"description": "Fais glisser pour réorganiser, touche l’œil pour afficher ou masquer.",
"dragHandle": "Fais glisser pour réorganiser",
@@ -3988,6 +3994,12 @@
"overrideToggleLabel": "Valeurs personnelles",
"resetAction": "Restaurer la valeur par défaut",
"resetAllAction": "Tout restaurer aux valeurs par défaut",
+ "resetAllTitle": "Réinitialiser toutes les plages personnalisées ?",
+ "resetAllBody": "Chaque mesure revient à sa plage par défaut. Les valeurs que vous avez définies ne sont conservées nulle part ailleurs et ne peuvent pas être récupérées.",
+ "resetAllConfirm": "Tout réinitialiser",
+ "resetTitle": "Réinitialiser cette plage ?",
+ "resetBody": "La mesure revient à sa plage par défaut. Les valeurs que vous avez définies ne sont conservées nulle part ailleurs et ne peuvent pas être récupérées.",
+ "resetConfirm": "Réinitialiser la plage",
"minLabel": "Min",
"maxLabel": "Max",
"unitSuffix": "({unit})",
@@ -4356,6 +4368,9 @@
"cardTitle": "Priorité des sources par métrique",
"help": "Si plusieurs sources fournissent la même valeur le même jour, la source en tête compte comme canonique. Les autres restent enregistrées pour la piste d’audit.",
"resetDefaults": "Rétablir les valeurs par défaut",
+ "resetDefaultsTitle": "Réinitialiser l’ordre des sources ?",
+ "resetDefaultsBody": "Chaque classement que vous avez défini revient à l’ordre par défaut, pour toutes les mesures et tous les types d’appareil à la fois. L’enregistrement est immédiat et irréversible.",
+ "resetDefaultsConfirm": "Réinitialiser l’ordre",
"moveUp": "Monter",
"moveDown": "Descendre",
"saveSuccess": "Priorité des sources enregistrée.",
@@ -4787,6 +4802,9 @@
"upload": "Téléverser une photo",
"replace": "Remplacer la photo",
"remove": "Supprimer",
+ "removeTitle": "Supprimer la photo de profil ?",
+ "removeBody": "L’image est supprimée de votre serveur et vos initiales prennent sa place. Il vous faudra le fichier d’origine pour la téléverser à nouveau.",
+ "removeConfirm": "Supprimer la photo",
"hint": "JPEG, PNG ou WebP. Jusqu'à 2 Mo et 2048×2048 pixels.",
"uploaded": "Photo de profil mise à jour",
"removed": "Photo de profil supprimée",
@@ -4946,6 +4964,9 @@
"statusExpired": "Connexion expirée",
"connectButton": "Se connecter avec ChatGPT",
"disconnectButton": "Déconnecter",
+ "disconnectTitle": "Déconnecter ChatGPT ?",
+ "disconnectBody": "La connexion enregistrée est supprimée, ainsi que le texte de vos analyses en cache. Se reconnecter suppose de refaire l’authentification par code d’appareil.",
+ "disconnectConfirm": "Déconnecter",
"modelSlugLabel": "Slug du modèle",
"modelSlugBody": "Codex utilise le modèle vers lequel votre abonnement ChatGPT route. La variable d’environnement CODEX_MODEL permet à l’opérateur de le remplacer sur l’instance.",
"lastInsight": "Dernière analyse : {when}"
@@ -4976,6 +4997,9 @@
"categoryContext": "Contexte",
"forget": "Oublier",
"forgetAria": "Oublier cette information",
+ "forgetTitle": "Oublier ce fait ?",
+ "forgetBody": "Le Coach cesse de l’utiliser dès le message suivant. Un fait oublié ne peut pas être récupéré.",
+ "forgetConfirm": "Oublier",
"forgetAll": "Tout oublier",
"forgetAllConfirmTitle": "Oublier tout ce dont le Coach se souvient ?",
"forgetAllConfirmBody": "Cela supprime toutes les informations enregistrées. Le Coach repartira de zéro lors de votre prochaine conversation. Cette action est irréversible.",
@@ -5019,6 +5043,9 @@
"placeholder": "Ex. travail posté, régime végétarien, blessure au genou, entraînement pour un semi-marathon…",
"charCount": "{used} / {max} caractères",
"clear": "Vider",
+ "clearTitle": "Vider la note « À propos de moi » ?",
+ "clearBody": "Seul le texte libre est supprimé. Vos allergies et intolérances restent inchangées.",
+ "clearConfirm": "Vider la note",
"save": "Enregistrer",
"savedToast": "Note « À propos de moi » enregistrée.",
"clearedToast": "Note « À propos de moi » supprimée.",
@@ -5844,6 +5871,9 @@
"baseUrlLabel": "URL de base",
"save": "Enregistrer",
"remove": "Supprimer la clé",
+ "removeTitle": "Supprimer la clé du serveur ?",
+ "removeBody": "Les comptes sans clé personnelle perdent aussitôt les fonctions IA. La clé n’est affichée nulle part une fois enregistrée : il vous faudra l’original pour la remettre.",
+ "removeConfirm": "Supprimer la clé",
"saved": "Paramètres de la clé IA du serveur enregistrés",
"error": "Échec de l'enregistrement de la clé IA du serveur"
},
@@ -6366,6 +6396,9 @@
"statusExpired": "Expiré",
"connectButton": "Relier un compte ChatGPT",
"disconnectButton": "Déconnecter",
+ "disconnectTitle": "Déconnecter le compte IA partagé ?",
+ "disconnectBody": "Tous les utilisateurs de ce serveur qui s’appuient sur la connexion partagée perdent les fonctions IA jusqu’à ce qu’un compte soit relié à nouveau. La connexion enregistrée est irrécupérable.",
+ "disconnectConfirm": "Déconnecter",
"connectedSince": "Connecté depuis le {when}",
"connected": "Connexion IA partagée reliée",
"disconnected": "Connexion IA partagée supprimée",
@@ -6842,6 +6875,10 @@
"diastolicMax": "Diastolique max",
"boundsHint": "Plage autorisée : {min}–{max} {unit}",
"resetToDefault": "Restaurer la valeur par défaut",
+ "resetTitle": "Réinitialiser cet objectif ?",
+ "resetBody": "La plage revient à la valeur par défaut. Les valeurs que vous avez définies ne sont conservées nulle part ailleurs et ne peuvent pas être récupérées.",
+ "resetBodyBp": "La plage systolique et la plage diastolique reviennent toutes deux à la valeur par défaut. Les valeurs que vous avez définies ne sont conservées nulle part ailleurs et ne peuvent pas être récupérées.",
+ "resetConfirm": "Réinitialiser l’objectif",
"saveSuccess": "Plage cible mise à jour",
"saveError": "Impossible d’enregistrer la plage cible",
"resetSuccess": "Plage cible réinitialisée",
@@ -7074,6 +7111,9 @@
"icon": "Icône",
"limitReached": "Limite de symptômes personnalisés atteinte.",
"remove": "Supprimer {label}",
+ "removeTitle": "Supprimer ce symptôme ?",
+ "removeBody": "« {label} » disparaît de la liste des symptômes. Les journées déjà marquées le conservent, mais vous ne pourrez plus le sélectionner.",
+ "removeConfirm": "Supprimer",
"fallbackLabel": "Symptôme personnalisé"
}
},
@@ -7306,6 +7346,9 @@
"severity": "Intensité",
"severityLevel": "Intensité {level} sur 4",
"delete": "Supprimer",
+ "deleteTitle": "Supprimer cette journée ?",
+ "deleteBody": "Tout ce qui est enregistré pour cette journée disparaît : flux, température, symptômes et votre note. Impossible de le récupérer.",
+ "deleteConfirm": "Supprimer la journée",
"temperatureSection": "Température et ovulation",
"intimacySection": "Intimité et contraception",
"testsSection": "Tests",
@@ -8099,6 +8142,9 @@
"markMet": "Objectif atteint",
"abandon": "Terminer le plan",
"remove": "Supprimer",
+ "removeTitle": "Supprimer ce plan ?",
+ "removeBody": "Le plan et son déroulement disparaissent de votre historique. Il est impossible de le récupérer.",
+ "removeConfirm": "Supprimer le plan",
"empty": "Aucun plan pour l'instant. Lorsque vous vous engagez sur une intention concrète « si… alors » dans une conversation, le Coach la propose ici pour confirmation.",
"groupProposed": "Propositions",
"groupActive": "Actifs",
diff --git a/messages/it.json b/messages/it.json
index 3edd4d0cc..03c3462ee 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -455,6 +455,9 @@
"scoreRingsTitle": "Anelli dei punteggi",
"scoreRingsDescription": "Fino a tre anelli aggiuntivi accanto al punteggio di salute, ad esempio prontezza, recupero, sonno o aderenza. Trascina per riordinare; il punteggio di salute è primo per impostazione predefinita.",
"layoutReset": "Ripristina i valori predefiniti",
+ "layoutResetTitle": "Reimpostare il layout della dashboard?",
+ "layoutResetBody": "L’ordine dei riquadri, quelli che hai nascosto e gli anelli scelti tornano ai valori predefiniti. La disposizione non è salvata altrove.",
+ "layoutResetConfirm": "Reimposta layout",
"layoutSaveSuccess": "Layout della dashboard salvato",
"layoutSaveError": "Impossibile salvare il layout",
"layoutConflictReloaded": "Layout ricaricato dal server. Applica di nuovo la tua modifica.",
@@ -2143,6 +2146,9 @@
"editMode": {
"done": "Fine",
"reset": "Reimposta",
+ "resetTitle": "Reimpostare la panoramica?",
+ "resetBody": "L’ordine delle sezioni e quelle che hai nascosto tornano ai valori predefiniti. La tua disposizione non è salvata altrove.",
+ "resetConfirm": "Reimposta panoramica",
"title": "Personalizza panoramica",
"description": "Trascina per riordinare, tocca l’occhio per mostrare o nascondere.",
"dragHandle": "Trascina per riordinare",
@@ -3988,6 +3994,12 @@
"overrideToggleLabel": "Valori personali",
"resetAction": "Ripristina predefinito",
"resetAllAction": "Ripristina tutti ai predefiniti",
+ "resetAllTitle": "Reimpostare tutti gli intervalli personalizzati?",
+ "resetAllBody": "Ogni metrica torna al suo intervallo predefinito. I valori impostati non sono salvati altrove e non possono essere recuperati.",
+ "resetAllConfirm": "Reimposta tutto",
+ "resetTitle": "Reimpostare questo intervallo?",
+ "resetBody": "La metrica torna al suo intervallo predefinito. I valori impostati non sono salvati altrove e non possono essere recuperati.",
+ "resetConfirm": "Reimposta intervallo",
"minLabel": "Min",
"maxLabel": "Max",
"unitSuffix": "({unit})",
@@ -4356,6 +4368,9 @@
"cardTitle": "Priorità delle fonti per metrica",
"help": "Se più fonti forniscono lo stesso valore nello stesso giorno, la fonte in cima conta come canonica. Le altre restano memorizzate per la traccia di controllo.",
"resetDefaults": "Ripristina i valori predefiniti",
+ "resetDefaultsTitle": "Reimpostare l’ordine delle fonti?",
+ "resetDefaultsBody": "Ogni gerarchia che hai impostato torna all’ordine predefinito, per tutte le metriche e tutti i tipi di dispositivo insieme. Viene salvato subito e non può essere recuperato.",
+ "resetDefaultsConfirm": "Reimposta ordine",
"moveUp": "Sposta su",
"moveDown": "Sposta giù",
"saveSuccess": "Priorità delle fonti salvata.",
@@ -4787,6 +4802,9 @@
"upload": "Carica foto",
"replace": "Sostituisci foto",
"remove": "Rimuovi",
+ "removeTitle": "Rimuovere la foto del profilo?",
+ "removeBody": "L’immagine viene eliminata dal tuo server e al suo posto compaiono le tue iniziali. Per caricarla di nuovo ti servirà il file originale.",
+ "removeConfirm": "Rimuovi foto",
"hint": "JPEG, PNG o WebP. Fino a 2 MB e 2048×2048 pixel.",
"uploaded": "Foto del profilo aggiornata",
"removed": "Foto del profilo rimossa",
@@ -4946,6 +4964,9 @@
"statusExpired": "Connessione scaduta",
"connectButton": "Connetti con ChatGPT",
"disconnectButton": "Disconnetti",
+ "disconnectTitle": "Disconnettere ChatGPT?",
+ "disconnectBody": "L’accesso salvato viene eliminato insieme al testo degli approfondimenti in cache. Per riconnetterti dovrai ripetere il flusso con codice dispositivo.",
+ "disconnectConfirm": "Disconnetti",
"modelSlugLabel": "Slug del modello",
"modelSlugBody": "Codex usa il modello a cui instrada il tuo abbonamento ChatGPT. La variabile d’ambiente CODEX_MODEL consente all’operatore di sovrascriverlo sull’istanza.",
"lastInsight": "Ultima analisi: {when}"
@@ -4976,6 +4997,9 @@
"categoryContext": "Contesto",
"forget": "Dimentica",
"forgetAria": "Dimentica questa informazione",
+ "forgetTitle": "Dimenticare questa informazione?",
+ "forgetBody": "Il Coach smette di usarla dal messaggio successivo. Un’informazione dimenticata non può essere recuperata.",
+ "forgetConfirm": "Dimentica",
"forgetAll": "Dimentica tutto",
"forgetAllConfirmTitle": "Dimenticare tutto ciò che il Coach ricorda?",
"forgetAllConfirmBody": "Questo rimuove tutte le informazioni memorizzate. Il Coach ripartirà da zero alla prossima chat. L'operazione non può essere annullata.",
@@ -5019,6 +5043,9 @@
"placeholder": "Es. lavoro a turni, dieta vegetariana, infortunio al ginocchio, allenamento per una mezza maratona…",
"charCount": "{used} / {max} caratteri",
"clear": "Svuota",
+ "clearTitle": "Svuotare la nota «Su di me»?",
+ "clearBody": "Rimuove solo il testo libero. Allergie e intolleranze restano invariate.",
+ "clearConfirm": "Svuota nota",
"save": "Salva",
"savedToast": "Nota «Su di me» salvata.",
"clearedToast": "Nota «Su di me» eliminata.",
@@ -5844,6 +5871,9 @@
"baseUrlLabel": "URL di base",
"save": "Salva",
"remove": "Rimuovi chiave",
+ "removeTitle": "Rimuovere la chiave del server?",
+ "removeBody": "Gli account senza una chiave propria perdono subito le funzioni IA. Dopo il salvataggio la chiave non è più visibile da nessuna parte: per rimetterla ti servirà l’originale.",
+ "removeConfirm": "Rimuovi chiave",
"saved": "Impostazioni della chiave IA del server salvate",
"error": "Salvataggio della chiave IA del server non riuscito"
},
@@ -6366,6 +6396,9 @@
"statusExpired": "Scaduto",
"connectButton": "Collega un account ChatGPT",
"disconnectButton": "Disconnetti",
+ "disconnectTitle": "Disconnettere l’account IA condiviso?",
+ "disconnectBody": "Tutti gli utenti di questo server che usano la connessione condivisa perdono le funzioni IA finché qualcuno non collega di nuovo un account. L’accesso salvato non è recuperabile.",
+ "disconnectConfirm": "Disconnetti",
"connectedSince": "Connesso dal {when}",
"connected": "Connessione IA condivisa collegata",
"disconnected": "Connessione IA condivisa rimossa",
@@ -6842,6 +6875,10 @@
"diastolicMax": "Diastolica max",
"boundsHint": "Range consentito: {min}–{max} {unit}",
"resetToDefault": "Ripristina predefinito",
+ "resetTitle": "Reimpostare questo obiettivo?",
+ "resetBody": "L’intervallo torna al valore predefinito. I valori impostati non sono salvati altrove e non possono essere recuperati.",
+ "resetBodyBp": "Sia l’intervallo sistolico sia quello diastolico tornano al valore predefinito. I valori impostati non sono salvati altrove e non possono essere recuperati.",
+ "resetConfirm": "Reimposta obiettivo",
"saveSuccess": "Range obiettivo aggiornato",
"saveError": "Impossibile salvare il range obiettivo",
"resetSuccess": "Range obiettivo ripristinato",
@@ -7074,6 +7111,9 @@
"icon": "Icona",
"limitReached": "Limite di sintomi personalizzati raggiunto.",
"remove": "Rimuovi {label}",
+ "removeTitle": "Rimuovere questo sintomo?",
+ "removeBody": "«{label}» sparisce dall’elenco dei sintomi. I giorni già contrassegnati lo mantengono, ma non potrai più selezionarlo.",
+ "removeConfirm": "Rimuovi",
"fallbackLabel": "Sintomo personalizzato"
}
},
@@ -7306,6 +7346,9 @@
"severity": "Intensità",
"severityLevel": "Intensità {level} su 4",
"delete": "Elimina",
+ "deleteTitle": "Eliminare questo giorno?",
+ "deleteBody": "Sparisce tutto ciò che hai registrato per questo giorno: flusso, temperatura, sintomi e la tua nota. Non può essere recuperato.",
+ "deleteConfirm": "Elimina giorno",
"temperatureSection": "Temperatura e ovulazione",
"intimacySection": "Intimità e contraccezione",
"testsSection": "Test",
@@ -8099,6 +8142,9 @@
"markMet": "Obiettivo raggiunto",
"abandon": "Termina piano",
"remove": "Rimuovi",
+ "removeTitle": "Rimuovere questo piano?",
+ "removeBody": "Il piano e il suo andamento spariscono dalla cronologia. Non può essere recuperato.",
+ "removeConfirm": "Rimuovi piano",
"empty": "Ancora nessun piano. Quando in una conversazione ti impegni in un'intenzione concreta «se… allora», il Coach la propone qui per la conferma.",
"groupProposed": "Proposte",
"groupActive": "Attivi",
diff --git a/messages/pl.json b/messages/pl.json
index fbb86f532..f1d9fb61a 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -455,6 +455,9 @@
"scoreRingsTitle": "Pierścienie wyników",
"scoreRingsDescription": "Maksymalnie trzy dodatkowe pierścienie obok wskaźnika zdrowia — np. gotowość, regeneracja, sen lub przestrzeganie zaleceń. Przeciągnij, aby zmienić kolejność; wskaźnik zdrowia jest domyślnie na początku.",
"layoutReset": "Przywróć wartości domyślne",
+ "layoutResetTitle": "Zresetować układ pulpitu?",
+ "layoutResetBody": "Kolejność kafelków, ukryte kafelki i wybrane pierścienie wracają do ustawień domyślnych. Ten układ nie jest nigdzie indziej zapisany.",
+ "layoutResetConfirm": "Zresetuj układ",
"layoutSaveSuccess": "Układ panelu zapisany",
"layoutSaveError": "Nie udało się zapisać układu",
"layoutConflictReloaded": "Układ został ponownie wczytany z serwera. Wprowadź zmianę jeszcze raz.",
@@ -2143,6 +2146,9 @@
"editMode": {
"done": "Gotowe",
"reset": "Przywróć",
+ "resetTitle": "Zresetować przegląd?",
+ "resetBody": "Kolejność sekcji i ukryte sekcje wracają do ustawień domyślnych. Twój układ nie jest nigdzie indziej zapisany.",
+ "resetConfirm": "Zresetuj przegląd",
"title": "Dostosuj przegląd",
"description": "Przeciągnij, aby zmienić kolejność, dotknij oka, aby pokazać lub ukryć.",
"dragHandle": "Przeciągnij, aby zmienić kolejność",
@@ -3988,6 +3994,12 @@
"overrideToggleLabel": "Własne wartości",
"resetAction": "Przywróć wartość domyślną",
"resetAllAction": "Przywróć wszystkie wartości domyślne",
+ "resetAllTitle": "Zresetować wszystkie własne zakresy?",
+ "resetAllBody": "Każda metryka wraca do zakresu domyślnego. Ustawione wartości nie są nigdzie indziej zapisane i nie da się ich odzyskać.",
+ "resetAllConfirm": "Zresetuj wszystkie",
+ "resetTitle": "Zresetować ten zakres?",
+ "resetBody": "Metryka wraca do zakresu domyślnego. Ustawione wartości nie są nigdzie indziej zapisane i nie da się ich odzyskać.",
+ "resetConfirm": "Zresetuj zakres",
"minLabel": "Min",
"maxLabel": "Maks",
"unitSuffix": "({unit})",
@@ -4356,6 +4368,9 @@
"cardTitle": "Priorytet źródeł według metryki",
"help": "Gdy kilka źródeł dostarcza tę samą wartość tego samego dnia, źródło na górze liczy się jako kanoniczne. Pozostałe pozostają zapisane na potrzeby audytu.",
"resetDefaults": "Przywróć wartości domyślne",
+ "resetDefaultsTitle": "Zresetować kolejność źródeł?",
+ "resetDefaultsBody": "Każda ustawiona przez ciebie kolejność wraca do domyślnej, dla wszystkich metryk i typów urządzeń naraz. Zapis następuje natychmiast i nie da się go cofnąć.",
+ "resetDefaultsConfirm": "Zresetuj kolejność",
"moveUp": "W górę",
"moveDown": "W dół",
"saveSuccess": "Zapisano priorytet źródeł.",
@@ -4787,6 +4802,9 @@
"upload": "Prześlij zdjęcie",
"replace": "Zmień zdjęcie",
"remove": "Usuń",
+ "removeTitle": "Usunąć zdjęcie profilowe?",
+ "removeBody": "Obraz zostanie usunięty z twojego serwera, a jego miejsce zajmą inicjały. Do ponownego wgrania potrzebny będzie oryginalny plik.",
+ "removeConfirm": "Usuń zdjęcie",
"hint": "JPEG, PNG lub WebP. Do 2 MB i 2048×2048 pikseli.",
"uploaded": "Zaktualizowano zdjęcie profilowe",
"removed": "Usunięto zdjęcie profilowe",
@@ -4946,6 +4964,9 @@
"statusExpired": "Połączenie wygasło",
"connectButton": "Połącz z ChatGPT",
"disconnectButton": "Rozłącz",
+ "disconnectTitle": "Odłączyć ChatGPT?",
+ "disconnectBody": "Zapisane logowanie zostanie usunięte razem z zapisanym tekstem analiz. Ponowne połączenie wymaga przejścia przez kod urządzenia jeszcze raz.",
+ "disconnectConfirm": "Odłącz",
"modelSlugLabel": "Slug modelu",
"modelSlugBody": "Codex używa modelu, do którego kieruje Twoja subskrypcja ChatGPT. Zmienna środowiskowa CODEX_MODEL pozwala operatorowi nadpisać to w instancji.",
"lastInsight": "Ostatnia analiza: {when}"
@@ -4976,6 +4997,9 @@
"categoryContext": "Kontekst",
"forget": "Zapomnij",
"forgetAria": "Zapomnij tę informację",
+ "forgetTitle": "Zapomnieć tę informację?",
+ "forgetBody": "Coach przestanie z niej korzystać od następnej wiadomości. Zapomnianej informacji nie da się odzyskać.",
+ "forgetConfirm": "Zapomnij",
"forgetAll": "Zapomnij wszystko",
"forgetAllConfirmTitle": "Zapomnieć wszystko, co Coach pamięta?",
"forgetAllConfirmBody": "Spowoduje to usunięcie wszystkich zapisanych informacji. Coach zacznie od nowa przy następnej rozmowie. Tej operacji nie można cofnąć.",
@@ -5019,6 +5043,9 @@
"placeholder": "Np. praca zmianowa, dieta wegetariańska, kontuzja kolana, trening do półmaratonu…",
"charCount": "{used} / {max} znaków",
"clear": "Wyczyść",
+ "clearTitle": "Wyczyścić notatkę „O mnie”?",
+ "clearBody": "Usuwa tylko tekst notatki. Alergie i nietolerancje pozostają bez zmian.",
+ "clearConfirm": "Wyczyść notatkę",
"save": "Zapisz",
"savedToast": "Notatka „O mnie” zapisana.",
"clearedToast": "Notatka „O mnie” usunięta.",
@@ -5844,6 +5871,9 @@
"baseUrlLabel": "Adres bazowy URL",
"save": "Zapisz",
"remove": "Usuń klucz",
+ "removeTitle": "Usunąć klucz serwera?",
+ "removeBody": "Konta bez własnego klucza natychmiast tracą funkcje AI. Po zapisaniu klucz nie jest nigdzie pokazywany, więc do przywrócenia potrzebny będzie oryginał.",
+ "removeConfirm": "Usuń klucz",
"saved": "Zapisano ustawienia serwerowego klucza AI",
"error": "Nie udało się zapisać serwerowego klucza AI"
},
@@ -6366,6 +6396,9 @@
"statusExpired": "Wygasło",
"connectButton": "Podłącz konto ChatGPT",
"disconnectButton": "Rozłącz",
+ "disconnectTitle": "Odłączyć wspólne konto AI?",
+ "disconnectBody": "Wszyscy użytkownicy tego serwera korzystający ze wspólnego połączenia stracą funkcje AI, dopóki ktoś nie podłączy konta ponownie. Zapisanego logowania nie da się odzyskać.",
+ "disconnectConfirm": "Odłącz",
"connectedSince": "Połączono od {when}",
"connected": "Wspólne połączenie AI podłączone",
"disconnected": "Wspólne połączenie AI usunięte",
@@ -6842,6 +6875,10 @@
"diastolicMax": "Rozkurczowe maks",
"boundsHint": "Dozwolony zakres: {min}–{max} {unit}",
"resetToDefault": "Przywróć wartość domyślną",
+ "resetTitle": "Zresetować ten cel?",
+ "resetBody": "Zakres wraca do wartości domyślnej. Ustawione wartości nie są nigdzie indziej zapisane i nie da się ich odzyskać.",
+ "resetBodyBp": "Zarówno zakres skurczowy, jak i rozkurczowy wracają do wartości domyślnej. Ustawione wartości nie są nigdzie indziej zapisane i nie da się ich odzyskać.",
+ "resetConfirm": "Zresetuj cel",
"saveSuccess": "Zakres docelowy zaktualizowany",
"saveError": "Nie udało się zapisać zakresu docelowego",
"resetSuccess": "Zakres docelowy przywrócono do domyślnego",
@@ -7074,6 +7111,9 @@
"icon": "Ikona",
"limitReached": "Osiągnięto limit własnych objawów.",
"remove": "Usuń {label}",
+ "removeTitle": "Usunąć ten objaw?",
+ "removeBody": "„{label}” zniknie z listy objawów. Dni już nim oznaczone zachowają go, ale nie wybierzesz go ponownie.",
+ "removeConfirm": "Usuń",
"fallbackLabel": "Objaw własny"
}
},
@@ -7306,6 +7346,9 @@
"severity": "Nasilenie",
"severityLevel": "Nasilenie {level} z 4",
"delete": "Usuń",
+ "deleteTitle": "Usunąć ten dzień?",
+ "deleteBody": "Zniknie wszystko, co zapisano tego dnia: krwawienie, temperatura, objawy i twoja notatka. Nie da się tego przywrócić.",
+ "deleteConfirm": "Usuń dzień",
"temperatureSection": "Temperatura i owulacja",
"intimacySection": "Intymność i antykoncepcja",
"testsSection": "Testy",
@@ -8099,6 +8142,9 @@
"markMet": "Cel osiągnięty",
"abandon": "Zakończ plan",
"remove": "Usuń",
+ "removeTitle": "Usunąć ten plan?",
+ "removeBody": "Plan i jego przebieg znikną z historii. Nie da się go przywrócić.",
+ "removeConfirm": "Usuń plan",
"empty": "Nie ma jeszcze planów. Gdy w rozmowie zobowiążesz się do konkretnej intencji „jeśli… to”, Coach zaproponuje ją tutaj do potwierdzenia.",
"groupProposed": "Propozycje",
"groupActive": "Aktywne",
diff --git a/src/app/coach/plans/page.tsx b/src/app/coach/plans/page.tsx
index 7549adc1e..daa23c41d 100644
--- a/src/app/coach/plans/page.tsx
+++ b/src/app/coach/plans/page.tsx
@@ -13,6 +13,7 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
+import { ConfirmButton } from "@/components/ui/confirm-button";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { EmptyState } from "@/components/ui/empty-state";
@@ -189,18 +190,20 @@ function CoachPlansBody() {
>
)}
{group === "past" && (
-
+ icon={}
+ label={t("coach.plans.remove")}
+ title={t("coach.plans.removeTitle")}
+ body={t("coach.plans.removeBody")}
+ confirmLabel={t("coach.plans.removeConfirm")}
+ disabled={busy && !remove.isPending}
+ pending={remove.isPending}
+ onConfirm={() => remove.mutate(plan.id)}
+ />
)}
diff --git a/src/components/admin/ai-server-key-section.tsx b/src/components/admin/ai-server-key-section.tsx
index 16767c2a3..d21db61b6 100644
--- a/src/components/admin/ai-server-key-section.tsx
+++ b/src/components/admin/ai-server-key-section.tsx
@@ -7,6 +7,7 @@ import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
+import { ConfirmButton } from "@/components/ui/confirm-button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { SettingsCard } from "@/components/settings/settings-card";
@@ -170,14 +171,16 @@ export function AiServerKeySection() {
{t("admin.aiServerKey.save")}
{configured && (
-
+ label={t("admin.aiServerKey.remove")}
+ title={t("admin.aiServerKey.removeTitle")}
+ body={t("admin.aiServerKey.removeBody")}
+ confirmLabel={t("admin.aiServerKey.removeConfirm")}
+ pending={save.isPending}
+ onConfirm={handleRemove}
+ />
)}
diff --git a/src/components/admin/central-codex-section.tsx b/src/components/admin/central-codex-section.tsx
index 6e357267a..258b6eb45 100644
--- a/src/components/admin/central-codex-section.tsx
+++ b/src/components/admin/central-codex-section.tsx
@@ -22,6 +22,7 @@ import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
+import { ConfirmButton } from "@/components/ui/confirm-button";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
@@ -180,21 +181,19 @@ export function CentralCodexSection() {
)}
{isConnected ? (
-
+ icon={}
+ label={t("admin.centralCodex.disconnectButton")}
+ title={t("admin.centralCodex.disconnectTitle")}
+ body={t("admin.centralCodex.disconnectBody")}
+ confirmLabel={t("admin.centralCodex.disconnectConfirm")}
+ pending={disconnecting}
+ onConfirm={handleDisconnect}
+ />
) : deviceCode ? (
diff --git a/src/components/settings/coach-memory-section.tsx b/src/components/settings/coach-memory-section.tsx
index 14667c53e..5b4a40e34 100644
--- a/src/components/settings/coach-memory-section.tsx
+++ b/src/components/settings/coach-memory-section.tsx
@@ -34,6 +34,7 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
+import { ConfirmButton } from "@/components/ui/confirm-button";
import { EmptyState } from "@/components/ui/empty-state";
import { formatDateOrRelative } from "@/lib/format";
import { SettingsCard } from "@/components/settings/settings-card";
@@ -243,35 +244,28 @@ export function CoachMemorySection({
})}
- }
+ label={t("settings.ai.coachMemory.forget")}
+ title={t("settings.ai.coachMemory.forgetTitle")}
+ body={t("settings.ai.coachMemory.forgetBody")}
+ confirmLabel={t("settings.ai.coachMemory.forgetConfirm")}
+ disabled={!isAuthenticated}
// v1.11.2 — per-id pending: only the row being
// deleted disables/spins. `forgetOne.variables` holds
// the id passed to the in-flight `mutate()`, so a
// single shared mutation no longer greys out every
// other row's forget button during one delete.
- disabled={
- !isAuthenticated ||
- (forgetOne.isPending && forgetOne.variables === fact.id)
+ pending={
+ forgetOne.isPending && forgetOne.variables === fact.id
}
- onClick={() => forgetOne.mutate(fact.id)}
- >
- {forgetOne.isPending &&
- forgetOne.variables === fact.id ? (
-
- ) : (
-
- )}
- {t("settings.ai.coachMemory.forget")}
-
+ onConfirm={() => forgetOne.mutate(fact.id)}
+ />
))}
diff --git a/src/components/settings/coach-reminders-section.tsx b/src/components/settings/coach-reminders-section.tsx
index c8eab020b..245af87d0 100644
--- a/src/components/settings/coach-reminders-section.tsx
+++ b/src/components/settings/coach-reminders-section.tsx
@@ -25,7 +25,7 @@ import { EmptyState } from "@/components/ui/empty-state";
import { formatDateOrRelative } from "@/lib/format";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
-import { ConfirmButton } from "@/components/settings/confirm-button";
+import { ConfirmButton } from "@/components/ui/confirm-button";
import { useTranslations } from "@/lib/i18n/context";
import {
useCoachReminders,
diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx
index a1a80d49a..ff131dfd5 100644
--- a/src/components/settings/dashboard-layout-section.tsx
+++ b/src/components/settings/dashboard-layout-section.tsx
@@ -28,6 +28,7 @@ import {
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Button } from "@/components/ui/button";
+import { ConfirmButton } from "@/components/ui/confirm-button";
import { Switch } from "@/components/ui/switch";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { useTranslations } from "@/lib/i18n/context";
@@ -607,16 +608,19 @@ export function DashboardLayoutSection({ id }: { id: string }) {
icon={LayoutDashboard}
title={t("dashboard.customizeTitle")}
status={
- resetMutation.mutate()}
- disabled={resetMutation.isPending}
className="min-h-11 self-end sm:min-h-9 sm:self-auto"
- >
-
- {t("dashboard.layoutReset")}
-
+ icon={}
+ label={t("dashboard.layoutReset")}
+ title={t("dashboard.layoutResetTitle")}
+ body={t("dashboard.layoutResetBody")}
+ confirmLabel={t("dashboard.layoutResetConfirm")}
+ pending={resetMutation.isPending}
+ onConfirm={() => resetMutation.mutate()}
+ />
}
/>
{/*
diff --git a/src/components/settings/environment-section.tsx b/src/components/settings/environment-section.tsx
index 60752fcfc..ad8dc7e54 100644
--- a/src/components/settings/environment-section.tsx
+++ b/src/components/settings/environment-section.tsx
@@ -27,7 +27,7 @@ import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useAuth } from "@/hooks/use-auth";
-import { ConfirmButton } from "@/components/settings/confirm-button";
+import { ConfirmButton } from "@/components/ui/confirm-button";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
diff --git a/src/components/settings/security-sessions-card.tsx b/src/components/settings/security-sessions-card.tsx
index e17ef4d2d..32219c7f7 100644
--- a/src/components/settings/security-sessions-card.tsx
+++ b/src/components/settings/security-sessions-card.tsx
@@ -14,7 +14,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { ChevronDown, Loader2, MonitorSmartphone } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
-import { ConfirmButton } from "@/components/settings/confirm-button";
+import { ConfirmButton } from "@/components/ui/confirm-button";
import { EmptyState } from "@/components/ui/empty-state";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx
index 3eb73f3c4..f9a435987 100644
--- a/src/components/settings/sources-section.tsx
+++ b/src/components/settings/sources-section.tsx
@@ -41,6 +41,7 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
+import { ConfirmButton } from "@/components/ui/confirm-button";
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/ui/empty-state";
import { SettingsCard } from "@/components/settings/settings-card";
@@ -297,15 +298,19 @@ export function SourcesSection() {
title={t("settings.sections.sources.cardTitle")}
description={t("settings.sections.sources.help")}
status={
- resetMutation.mutate()}
- disabled={resetMutation.isPending || saveMutation.isPending}
- >
-
- {t("settings.sections.sources.resetDefaults")}
-
+ icon={}
+ label={t("settings.sections.sources.resetDefaults")}
+ title={t("settings.sections.sources.resetDefaultsTitle")}
+ body={t("settings.sections.sources.resetDefaultsBody")}
+ confirmLabel={t("settings.sections.sources.resetDefaultsConfirm")}
+ disabled={saveMutation.isPending}
+ pending={resetMutation.isPending}
+ onConfirm={() => resetMutation.mutate()}
+ />
}
/>
diff --git a/src/components/settings/thresholds-editor-section.tsx b/src/components/settings/thresholds-editor-section.tsx
index a0cdde8be..94bf2b9db 100644
--- a/src/components/settings/thresholds-editor-section.tsx
+++ b/src/components/settings/thresholds-editor-section.tsx
@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { SlidersHorizontal, RotateCcw, AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
+import { ConfirmButton, ConfirmDialog } from "@/components/ui/confirm-button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
@@ -130,15 +131,18 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
aria-hidden="true"
/>
{data && Object.keys(data.overrides).length > 0 && (
- resetMutation.mutate(null)}
- disabled={resetMutation.isPending}
- >
-
- {t("thresholds.resetAllAction")}
-
+ icon={}
+ label={t("thresholds.resetAllAction")}
+ title={t("thresholds.resetAllTitle")}
+ body={t("thresholds.resetAllBody")}
+ confirmLabel={t("thresholds.resetAllConfirm")}
+ pending={resetMutation.isPending}
+ onConfirm={() => resetMutation.mutate(null)}
+ />
)}
@@ -241,6 +245,7 @@ function MetricRow({
const hasOverride = override !== null;
const [overrideMode, setOverrideMode] = useState(hasOverride);
+ const [confirmSwitchOff, setConfirmSwitchOff] = useState(false);
const [minStr, setMinStr] = useState(
seedString(override?.min ?? effective?.default?.greenMin, bounds.min),
);
@@ -310,15 +315,35 @@ function MetricRow({
+ {/* Flipping this off DELETES the stored override — the same
+ irreversible write the Reset control performs, reached by a
+ control that reads as a display toggle. It gets the same
+ confirmation, and a dismissal leaves the switch on. */}
{
+ if (!next && hasOverride) {
+ setConfirmSwitchOff(true);
+ return;
+ }
setOverrideMode(next);
- if (!next && hasOverride) onReset();
}}
disabled={busy}
/>
+ {
+ setOverrideMode(false);
+ onReset();
+ }}
+ />
{hasOverride && (
{t("thresholds.sourceOverride")}
@@ -381,15 +406,18 @@ function MetricRow({
{t("common.save")}
{hasOverride && (
-
- {t("thresholds.resetAction")}
-
+ label={t("thresholds.resetAction")}
+ title={t("thresholds.resetTitle")}
+ body={t("thresholds.resetBody")}
+ confirmLabel={t("thresholds.resetConfirm")}
+ pending={busy}
+ onConfirm={onReset}
+ />
)}
diff --git a/src/components/settings/trusted-devices-card.tsx b/src/components/settings/trusted-devices-card.tsx
index 104176f91..1a3da2fea 100644
--- a/src/components/settings/trusted-devices-card.tsx
+++ b/src/components/settings/trusted-devices-card.tsx
@@ -13,7 +13,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, ShieldCheck } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
-import { ConfirmButton } from "@/components/settings/confirm-button";
+import { ConfirmButton } from "@/components/ui/confirm-button";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
diff --git a/src/components/targets/target-edit-sheet.tsx b/src/components/targets/target-edit-sheet.tsx
index ad0342bec..0774aad98 100644
--- a/src/components/targets/target-edit-sheet.tsx
+++ b/src/components/targets/target-edit-sheet.tsx
@@ -7,6 +7,7 @@ import { RotateCcw } from "lucide-react";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Button } from "@/components/ui/button";
+import { ConfirmButton } from "@/components/ui/confirm-button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
@@ -422,16 +423,23 @@ function TargetEditSheetBody({
footer={
{hasOverride ? (
- resetMutation.mutate()}
- disabled={busy}
- data-slot="target-edit-reset"
- >
-
- {t("targets.edit.resetToDefault")}
-
+ icon={}
+ label={t("targets.edit.resetToDefault")}
+ title={t("targets.edit.resetTitle")}
+ body={
+ isBp
+ ? t("targets.edit.resetBodyBp")
+ : t("targets.edit.resetBody")
+ }
+ confirmLabel={t("targets.edit.resetConfirm")}
+ disabled={busy && !resetMutation.isPending}
+ pending={resetMutation.isPending}
+ onConfirm={() => resetMutation.mutate()}
+ />
) : (
)}
diff --git a/src/components/settings/confirm-button.tsx b/src/components/ui/confirm-button.tsx
similarity index 54%
rename from src/components/settings/confirm-button.tsx
rename to src/components/ui/confirm-button.tsx
index a6673f8bd..98bf329ee 100644
--- a/src/components/settings/confirm-button.tsx
+++ b/src/components/ui/confirm-button.tsx
@@ -1,7 +1,7 @@
"use client";
/**
- * A destructive settings control that asks first.
+ * The destructive-control confirmation, in the two shapes the tree needs.
*
* The security card's "sign out everywhere" and its per-row revoke, and the
* same pair on trusted devices, fired on a single tap with no confirmation —
@@ -13,6 +13,13 @@
* next destructive control gets the confirmation by using this, instead of by
* someone remembering to add one.
*
+ * `ConfirmButton` covers the common case — a button that opens its own dialog.
+ * `ConfirmDialog` is the same dialog without the trigger, for the destructive
+ * controls that are not buttons: a switch whose off position deletes what it
+ * was holding, a menu item, a key binding. Those had no shared path at all,
+ * which is a fair part of why they were the ones that shipped unguarded — a
+ * control that cannot reach for the primitive reaches for nothing.
+ *
* The copy contract matches the rest of the app after the delete-confirmation
* sweep — say what happens, and claim no more finality than is true. Callers
* pass their own body text so each control can be specific about its own
@@ -36,6 +43,51 @@ import {
} from "@/components/ui/alert-dialog";
import { useTranslations } from "@/lib/i18n/context";
+interface ConfirmCopy {
+ title: string;
+ body: string;
+ /** The dialog's action label. Names the act, not "OK". */
+ confirmLabel: string;
+ onConfirm: () => void;
+ pending?: boolean;
+ /** `data-slot` for tests and the design guards. */
+ slot?: string;
+}
+
+function ConfirmBody({
+ title,
+ body,
+ confirmLabel,
+ onConfirm,
+ pending = false,
+ slot,
+}: ConfirmCopy) {
+ const { t } = useTranslations();
+
+ return (
+
+
+ {title}
+ {body}
+
+
+ {t("common.cancel")}
+
+ {pending && (
+
+ )}
+ {confirmLabel}
+
+
+
+ );
+}
+
export function ConfirmButton({
label,
title,
@@ -50,15 +102,9 @@ export function ConfirmButton({
icon,
ariaLabel,
slot,
-}: {
+}: ConfirmCopy & {
/** The control's own label, before the dialog opens. */
label: string;
- title: string;
- body: string;
- /** The dialog's action label. Names the act, not "OK". */
- confirmLabel: string;
- onConfirm: () => void;
- pending?: boolean;
disabled?: boolean;
size?: "sm" | "icon";
variant?: "outline" | "ghost";
@@ -66,11 +112,7 @@ export function ConfirmButton({
/** For an icon-only control, where `label` renders as an icon. */
ariaLabel?: string;
icon?: ReactNode;
- /** `data-slot` for tests and the design guards. */
- slot?: string;
}) {
- const { t } = useTranslations();
-
return (
@@ -94,26 +136,35 @@ export function ConfirmButton({
{label}
-
-
- {title}
- {body}
-
-
- {t("common.cancel")}
-
- {pending && (
-
- )}
- {confirmLabel}
-
-
-
+
+
+ );
+}
+
+/**
+ * The trigger-less half. The caller owns `open` and decides what a dismissal
+ * means for its own control — a switch, for instance, has to stay ON when the
+ * dialog its OFF position raised is cancelled, which is a state a
+ * self-triggering dialog cannot express.
+ */
+export function ConfirmDialog({
+ open,
+ onOpenChange,
+ ...copy
+}: ConfirmCopy & {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}) {
+ return (
+
+
);
}
From f5f7d7d9d8d94e6b0b085234985725157430fb14 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 00:16:08 +0200
Subject: [PATCH 24/52] test(ui): a destructive control cannot ship without a
confirmation path
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three legs, none of which stands alone.
`destructive-control-guard.test.ts` is the structural one. A registry
names every destructive control, what it takes, and how recoverable that
is; three detectors then make FORGETTING to register the failure mode.
The obvious guard — every trash icon must sit near a dialog — fails both
ways and fails quietly: it misses the control that destroyed the most
here, a PUT carrying two empty strings with no trash icon involved, and
it cries wolf on the trash icon that drops a row from an unsaved draft.
Both classes are live in this tree, and a guard suppressed on half its
hits teaches people to suppress it. So the judgement lives in the
registry, where it is written down and reviewable, and the mechanism only
guarantees that a new control cannot arrive unnoticed and a removed one
cannot leave a lie behind. What it cannot do is on the record in the
docblock: it cannot prove the registry complete, and it cannot prove an
entry true. Skipping a confirmation is possible, but it costs a written
argument, and the length floor is enforced.
`confirm-button.test.tsx` proves the dismissal contract once, where it
belongs: the trigger carries no click handler, the caller's callback
reaches exactly one node, the dialog action, and cancel never sees it.
An earlier draft also asserted that a closed dialog renders no confirm
control; that passed with `open` hard-coded true, because Radix portals
do not render server-side at all. It was measuring the renderer, so it
is gone rather than left green.
`about-me-clear-scope.test.tsx` covers the finding itself: the request
carries no allergies key at all, the clearing call reaches the control
only as `onConfirm`, and the allergy field stays editable so the targeted
way to clear it survives.
The existing settings suite grows to name every newly guarded control by
slot, plus the threshold override switch, whose off position performed
the same irreversible delete as the Reset button beside it.
Every assertion was mutation-checked.
---
.../destructive-control-guard.test.ts | 815 ++++++++++++++++++
.../__tests__/about-me-clear-scope.test.tsx | 166 ++++
.../destructive-controls-confirm.test.tsx | 200 ++++-
.../ui/__tests__/confirm-button.test.tsx | 190 ++++
4 files changed, 1333 insertions(+), 38 deletions(-)
create mode 100644 src/__tests__/destructive-control-guard.test.ts
create mode 100644 src/components/settings/__tests__/about-me-clear-scope.test.tsx
create mode 100644 src/components/ui/__tests__/confirm-button.test.tsx
diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts
new file mode 100644
index 000000000..053e3211a
--- /dev/null
+++ b/src/__tests__/destructive-control-guard.test.ts
@@ -0,0 +1,815 @@
+/**
+ * Structural guard on destructive controls: nothing that can take a person's
+ * data away ships without someone having written down what it takes and what
+ * gates it.
+ *
+ * WHY A REGISTRY RATHER THAN A PATTERN MATCH
+ *
+ * The obvious guard — "every `Trash2` trigger must sit next to an
+ * `AlertDialog`" — fails in both directions, and it fails quietly. It misses
+ * the control that destroys the most: Settings → AI cleared a person's
+ * free-text note AND their allergy line from one untargeted tap, through a
+ * `PUT` carrying two empty strings, with no trash icon involved in the write
+ * and nothing in the payload spelling "delete". It cries wolf on the reverse:
+ * a `Trash2` that removes a row from an unsaved draft, a "reset" that only
+ * touches local state until a separate Save, an undo affordance inside a
+ * success toast. Both classes are live in this tree. A guard that has to be
+ * suppressed on half its hits teaches people to suppress it.
+ *
+ * So the honest shape is: the REGISTRY below is the source of truth for what
+ * is destructive, because that judgement is not mechanical. Three detectors
+ * then make FORGETTING TO REGISTER the failure mode — each is complete for
+ * its own syntactic shape, and together they cover every way this codebase
+ * currently issues a destroying request:
+ *
+ * D1 a DELETE goes out `apiDelete(…)` / `method: "DELETE"`
+ * D2 a route whose own name is the act `/api/…/{disconnect,revoke,reset,
+ * purge,clear,wipe,bulk-delete}`
+ * D3 a write blanks a stored field a payload literal assigning `""`
+ * or `null` to a named key
+ *
+ * WHAT THIS CANNOT DO — stated plainly, because a guard that oversells itself
+ * is worse than none:
+ *
+ * - It cannot prove the registry is COMPLETE. A destructive control that
+ * issues no DELETE, hits no act-named route, and blanks no literal field —
+ * a PUT that overwrites a list with a shorter one, say — is invisible to
+ * all three detectors and will not be caught by anything here.
+ * - It cannot prove a registered claim is TRUE. `confirm: ["ConfirmButton"]`
+ * is checked only to the extent that the named mechanism is present in one
+ * of the entry's files. A reviewer who waves through a wrong entry defeats
+ * it, and no test substitutes for that review.
+ * - `unconfirmed` entries are the escape hatch, and they are deliberately
+ * expensive: each needs a written reason long enough to be an argument
+ * rather than a shrug. There is no silent skip.
+ *
+ * What it does hold: a new destructive control cannot land unnoticed, a
+ * removed one cannot leave a lie behind, and the specific regression that
+ * started this — a destroying payload fired straight from an `onClick` — goes
+ * red at the call site.
+ */
+import { describe, it, expect } from "vitest";
+import { readFileSync, globSync } from "node:fs";
+import { join, sep } from "node:path";
+
+const SRC = join(process.cwd(), "src");
+
+type ConfirmKind =
+ | "ConfirmButton"
+ | "ConfirmDialog"
+ | "DeleteButton"
+ | "SelectionActionBar"
+ | "AlertDialog"
+ | "inline-two-step"
+ | "typed-phrase";
+
+/**
+ * How bounded the loss is. This is the axis that decides whether a control
+ * wants a confirmation, an undo, or both, so the registry makes the next
+ * person answer it rather than leaving it implied.
+ *
+ * permanent a column overwritten, or a row hard-deleted.
+ * Nothing in the product brings it back.
+ * tombstoned-no-restore soft-deleted in the database, but no restore route
+ * and no surface a person could reach it from. Gone,
+ * as far as the person is concerned.
+ * restorable a restore route exists AND a control reaches it.
+ */
+type Recovery = "permanent" | "tombstoned-no-restore" | "restorable";
+
+interface DestructiveEntry {
+ /** Path under `src/`, forward slashes. Where the destroying request lives. */
+ file: string;
+ /** Plain words: what a person loses. */
+ destroys: string;
+ /** Files carrying the trigger, when the mutation lives in a hook. */
+ triggers?: string[];
+ recovery: Recovery;
+ /** Mechanisms gating the triggers. Empty only alongside `unconfirmed`. */
+ confirm: ConfirmKind[];
+ /**
+ * Controls in this entry that deliberately do NOT confirm. Every one needs
+ * an argument, not a note — the length floor is enforced below.
+ */
+ unconfirmed?: { control: string; reason: string }[];
+ /**
+ * Set when the entry is deliberately kept although no detector sees it —
+ * the concrete form of "this guard cannot prove the registry is complete".
+ * The value says which shape slips past, so the blind spot is on the record
+ * instead of being inferred from an absent row.
+ */
+ undetected?: string;
+}
+
+const REGISTRY: DestructiveEntry[] = [
+ // ── Account, credentials, sessions ──────────────────────────────────────
+ {
+ file: "components/settings/advanced-section.tsx",
+ destroys:
+ "every health record on the account, or the whole account including passkeys and audit history",
+ recovery: "permanent",
+ confirm: ["AlertDialog"],
+ unconfirmed: [
+ {
+ control: "research-mode switch, off position",
+ reason:
+ "Turning research mode off drops the stored acknowledgement receipt, which is regenerated by turning it back on and re-acknowledging. Nothing a person authored or measured is involved, so a dialog on a labelled switch would gate a preference rather than a loss.",
+ },
+ ],
+ },
+ {
+ file: "components/settings/security-sessions-card.tsx",
+ destroys: "one login session, or every session other than this one",
+ recovery: "permanent",
+ confirm: ["ConfirmButton"],
+ },
+ {
+ file: "components/settings/trusted-devices-card.tsx",
+ destroys: "the second-factor memory for one device, or for all of them",
+ recovery: "permanent",
+ confirm: ["ConfirmButton"],
+ },
+ {
+ file: "components/settings/security-section/passkey-list-section.tsx",
+ destroys: "a passkey — hard-deleted, the credential cannot be re-derived",
+ recovery: "permanent",
+ confirm: ["AlertDialog"],
+ },
+ {
+ file: "components/settings/security-section/security-keys-card.tsx",
+ destroys: "a WebAuthn second factor",
+ recovery: "permanent",
+ confirm: ["AlertDialog"],
+ },
+ {
+ file: "components/settings/api-section.tsx",
+ destroys: "an API token — revoked, and the secret is never shown again",
+ recovery: "permanent",
+ confirm: ["AlertDialog"],
+ },
+ {
+ file: "components/settings/mcp-section.tsx",
+ destroys: "an MCP client grant or an MCP token",
+ recovery: "permanent",
+ confirm: ["AlertDialog"],
+ },
+ {
+ file: "components/settings/sharing-section.tsx",
+ destroys: "a clinician share link — the URL stays dead once revoked",
+ recovery: "permanent",
+ confirm: ["AlertDialog"],
+ },
+ {
+ file: "components/settings/account-section/avatar-section.tsx",
+ destroys: "the uploaded profile photo, deleted from the server",
+ recovery: "permanent",
+ confirm: ["ConfirmButton"],
+ },
+
+ // ── AI credentials ──────────────────────────────────────────────────────
+ {
+ file: "components/settings/ai/codex-provider-form.tsx",
+ destroys: "the stored Codex sign-in and the cached insight text with it",
+ recovery: "permanent",
+ confirm: ["ConfirmButton"],
+ },
+ {
+ file: "components/admin/central-codex-section.tsx",
+ destroys:
+ "the instance-wide shared AI sign-in — every user relying on it loses AI features",
+ recovery: "permanent",
+ confirm: ["ConfirmButton"],
+ },
+ {
+ file: "components/admin/ai-server-key-section.tsx",
+ destroys:
+ "the operator-wide AI provider key, which is never displayed again once stored",
+ recovery: "permanent",
+ confirm: ["ConfirmButton"],
+ },
+ {
+ file: "components/settings/ai/ai-consent-card.tsx",
+ destroys: "the AI consent receipt that gates every AI surface",
+ recovery: "restorable",
+ confirm: ["inline-two-step"],
+ },
+ {
+ file: "components/settings/about-me-section.tsx",
+ destroys: "the free-text self-context note the Coach and briefing read",
+ recovery: "permanent",
+ confirm: ["ConfirmButton"],
+ },
+ {
+ file: "components/settings/coach-memory-section.tsx",
+ destroys: "one remembered Coach fact, or every one of them",
+ recovery: "tombstoned-no-restore",
+ confirm: ["ConfirmButton", "AlertDialog"],
+ },
+ {
+ file: "hooks/use-coach-reminders.ts",
+ destroys: "a Coach reminder",
+ triggers: ["components/settings/coach-reminders-section.tsx"],
+ recovery: "tombstoned-no-restore",
+ confirm: ["ConfirmButton"],
+ },
+ {
+ file: "hooks/use-coach-plans.ts",
+ destroys: "a Coach plan and the record of how it went",
+ triggers: [
+ "app/coach/plans/page.tsx",
+ "components/insights/coach-panel/plan-proposal-card.tsx",
+ ],
+ recovery: "tombstoned-no-restore",
+ confirm: ["ConfirmButton"],
+ unconfirmed: [
+ {
+ control:
+ "decline a PROPOSED plan — app/coach/plans/page.tsx and plan-proposal-card.tsx",
+ reason:
+ "Declining is the negative half of an accept/decline pair the assistant just offered; the person has invested nothing in a proposal they never adopted, and the same suggestion re-derives on a later turn. Confirming a refusal would make saying no more expensive than saying yes. The delete of a plan the person actually RAN is confirmed, and that is the one carrying history.",
+ },
+ ],
+ },
+ {
+ file: "components/insights/coach-panel/use-coach.ts",
+ destroys: "a whole Coach conversation and every message in it",
+ triggers: [
+ "components/insights/coach-panel/history-rail.tsx",
+ "app/coach/conversations/page.tsx",
+ ],
+ recovery: "permanent",
+ confirm: [],
+ unconfirmed: [
+ {
+ control:
+ "delete conversation — history-rail.tsx and conversations page",
+ reason:
+ "This surface deliberately chose undo over confirmation: the delete is held for a few seconds behind an undo toast, and the earlier arm-then-confirm double tap was removed on purpose. Adding a dialog back would give the same act two gates. The pending-delete flush on unmount is a real hole in that promise and is tracked separately; it is a defect in the undo, not an argument for a confirmation.",
+ },
+ ],
+ },
+ {
+ file: "components/insights/coach-panel/coach-conversation.tsx",
+ destroys:
+ "the stored list of pending clarifying questions, and document attachments on a conversation",
+ recovery: "permanent",
+ confirm: [],
+ unconfirmed: [
+ {
+ control: "dismiss questions; detach a document",
+ reason:
+ "Neither touches something the person wrote. The questions are assistant-generated suggestions that the extractor regenerates on later turns, and dismissing them IS the affordance. Detaching removes a join row only — the document itself stays in the vault and is two taps from being re-attached.",
+ },
+ ],
+ },
+
+ // ── Preferences and layout a person arranged by hand ─────────────────────
+ {
+ file: "components/settings/thresholds-editor-section.tsx",
+ destroys: "the custom range set for one metric, or for every metric",
+ recovery: "permanent",
+ confirm: ["ConfirmButton", "ConfirmDialog"],
+ },
+ {
+ file: "components/targets/target-edit-sheet.tsx",
+ destroys:
+ "the custom target range for one metric — both halves at once for blood pressure",
+ recovery: "permanent",
+ confirm: ["ConfirmButton"],
+ },
+ {
+ file: "components/settings/dashboard-layout-section.tsx",
+ destroys:
+ "the dashboard arrangement: tile order, hidden tiles, ring choice",
+ recovery: "permanent",
+ confirm: ["ConfirmButton"],
+ },
+ {
+ file: "components/insights/insights-edit-mode.tsx",
+ destroys: "the Insights overview arrangement: section order and visibility",
+ recovery: "permanent",
+ confirm: ["ConfirmButton"],
+ },
+ {
+ file: "components/settings/environment-section.tsx",
+ destroys: "a saved travel location",
+ recovery: "permanent",
+ confirm: ["ConfirmButton"],
+ },
+ {
+ file: "components/settings/web-push-card.tsx",
+ destroys: "the browser push subscription row",
+ recovery: "restorable",
+ confirm: [],
+ unconfirmed: [
+ {
+ control: "unsubscribe from push notifications",
+ reason:
+ "This is the off half of a notification channel toggle. Re-subscribing from the same control recreates the row, nothing the person authored is stored in it, and the browser permission survives. Gating it would put a dialog on a switch.",
+ },
+ ],
+ },
+
+ // ── Measurements, mood, labs, illness — the undo-and-restore surfaces ────
+ {
+ file: "components/measurements/measurement-list.tsx",
+ destroys: "one measurement, or every selected measurement",
+ recovery: "restorable",
+ confirm: ["DeleteButton", "SelectionActionBar", "AlertDialog"],
+ },
+ {
+ file: "components/mood/mood-list.tsx",
+ destroys: "one mood entry, or every selected mood entry",
+ recovery: "restorable",
+ confirm: ["DeleteButton", "SelectionActionBar", "AlertDialog"],
+ },
+ {
+ file: "components/labs/lab-history-list.tsx",
+ destroys: "one lab reading",
+ recovery: "restorable",
+ confirm: ["DeleteButton"],
+ },
+ {
+ file: "components/illness/use-illness.ts",
+ destroys: "an illness episode with its day logs and notes",
+ triggers: ["components/illness/episode-menu.tsx"],
+ recovery: "restorable",
+ confirm: ["AlertDialog"],
+ },
+ {
+ file: "components/labs/biomarker-manager.tsx",
+ destroys: "a biomarker AND every lab reading recorded against it",
+ recovery: "permanent",
+ confirm: ["DeleteButton"],
+ },
+ {
+ file: "components/labs/lab-biomarker-detail.tsx",
+ destroys: "a biomarker AND every lab reading recorded against it",
+ recovery: "permanent",
+ confirm: ["DeleteButton"],
+ },
+ {
+ file: "components/custom-metrics/custom-metric-detail.tsx",
+ destroys: "a custom metric definition",
+ recovery: "tombstoned-no-restore",
+ confirm: ["DeleteButton"],
+ },
+ {
+ file: "components/custom-metrics/custom-metric-history-list.tsx",
+ destroys: "one custom-metric reading",
+ recovery: "permanent",
+ confirm: ["DeleteButton"],
+ },
+ {
+ file: "components/records/allergy-manager.tsx",
+ destroys: "one allergy record",
+ recovery: "tombstoned-no-restore",
+ confirm: ["DeleteButton"],
+ },
+ {
+ file: "components/records/family-history-manager.tsx",
+ destroys: "one family-history entry",
+ recovery: "tombstoned-no-restore",
+ confirm: ["DeleteButton"],
+ },
+ {
+ file: "hooks/use-measurement-reminders.ts",
+ destroys: "a checkup or measurement reminder with its schedule",
+ triggers: ["components/measurement-reminders/vorsorge-section.tsx"],
+ recovery: "tombstoned-no-restore",
+ confirm: ["AlertDialog"],
+ },
+
+ // ── Cycle ───────────────────────────────────────────────────────────────
+ {
+ file: "components/cycle/use-cycle.ts",
+ destroys:
+ "a whole logged cycle day — flow, temperature, symptoms, note — or a custom symptom from the catalogue",
+ triggers: ["components/cycle/log-day-sheet.tsx"],
+ recovery: "tombstoned-no-restore",
+ confirm: ["ConfirmButton", "ConfirmDialog"],
+ },
+
+ // ── Medications ─────────────────────────────────────────────────────────
+ {
+ file: "components/medications/sections/destructive-zone-section.tsx",
+ destroys:
+ "every intake event for a medication, or the medication with its schedules and inventory",
+ recovery: "permanent",
+ confirm: ["AlertDialog"],
+ },
+ {
+ file: "components/medications/intake-history-editable.tsx",
+ destroys: "one intake event, or every selected intake event",
+ triggers: ["components/medications/intake-history-list-v2.tsx"],
+ recovery: "tombstoned-no-restore",
+ confirm: ["AlertDialog"],
+ },
+ {
+ file: "components/medications/dose-history-ledger.tsx",
+ destroys: "one intake event",
+ recovery: "tombstoned-no-restore",
+ confirm: ["AlertDialog"],
+ },
+ {
+ file: "components/medications/side-effects-section.tsx",
+ destroys: "one side-effect log entry",
+ recovery: "permanent",
+ confirm: ["AlertDialog"],
+ },
+ {
+ file: "components/medications/sections/inventory-section.tsx",
+ destroys: "one inventory pack record",
+ recovery: "permanent",
+ confirm: ["DeleteButton"],
+ },
+ {
+ file: "components/medications/scheduling/schedule-history-timeline.tsx",
+ destroys: "one schedule revision, re-segmenting the dosing history",
+ recovery: "permanent",
+ confirm: ["AlertDialog"],
+ },
+ {
+ file: "components/medications/use-medication-intake.ts",
+ destroys: "an intake event recorded seconds ago",
+ recovery: "tombstoned-no-restore",
+ confirm: [],
+ unconfirmed: [
+ {
+ control: "Undo inside the intake success toast",
+ reason:
+ "This IS the undo for a create the person made a moment earlier, reached only from that create's own toast. Confirming an undo would ask someone to confirm a correction, which inverts what the affordance is for.",
+ },
+ ],
+ },
+
+ // ── Mood tags ───────────────────────────────────────────────────────────
+ {
+ file: "components/mood/manage/archived-tags-card.tsx",
+ destroys:
+ "an archived custom mood tag AND every mood entry's link to it, by cascade",
+ recovery: "permanent",
+ confirm: ["AlertDialog"],
+ },
+ {
+ file: "components/mood/manage/tag-groups-card.tsx",
+ destroys: "a custom mood tag group (its member tags are rehomed, not lost)",
+ recovery: "permanent",
+ confirm: ["AlertDialog"],
+ },
+
+ // ── Documents ───────────────────────────────────────────────────────────
+ {
+ file: "components/documents/document-detail-sheet.tsx",
+ destroys: "one document in the vault",
+ recovery: "restorable",
+ confirm: [],
+ unconfirmed: [
+ {
+ control: "Delete in the document detail sheet",
+ reason:
+ "Documents chose undo over confirmation across every one of its delete paths, and it is the one domain where that choice is fully backed: the row is soft-deleted, a restore route exists, and the success toast carries the undo that calls it. Layering a dialog on top would gate a reversible act on the surface that already handles reversibility best.",
+ },
+ ],
+ },
+ {
+ file: "components/documents/documents-view.tsx",
+ destroys: "every selected document, or one document from a focused card",
+ triggers: [
+ "components/documents/document-bulk-bar.tsx",
+ "components/documents/document-card.tsx",
+ ],
+ recovery: "restorable",
+ confirm: [],
+ undetected:
+ 'The bulk route is POST /api/documents/inbound/bulk with the verb in the body ({ action: "delete" }), so no detector sees it: no DELETE goes out, the path names no act, and nothing is blanked. Registered by hand.',
+ unconfirmed: [
+ {
+ control: "bulk delete, and the Delete/Backspace key on a focused card",
+ reason:
+ "Same undo-not-confirmation choice as the detail sheet, and the bulk path raises one aggregate undo for the whole batch. The keyboard path is the weakest link in that argument, since a keystroke needs no aim and the undo lives only in a toast with no trash view behind it; it is recorded here rather than silently accepted, and closing it means giving documents a recovery surface, not a dialog.",
+ },
+ ],
+ },
+
+ // ── Admin ───────────────────────────────────────────────────────────────
+ {
+ file: "components/admin/danger-zone-section.tsx",
+ destroys: "every user's records on the instance",
+ recovery: "permanent",
+ confirm: ["typed-phrase"],
+ },
+ {
+ file: "components/admin/invite-tokens-section.tsx",
+ destroys: "an unredeemed invite token — the link stays dead",
+ recovery: "permanent",
+ confirm: ["AlertDialog"],
+ },
+ {
+ file: "components/admin/user-management-section.tsx",
+ destroys: "another user's sessions, or their stored password hash",
+ recovery: "permanent",
+ confirm: ["AlertDialog"],
+ },
+
+ // ── Integrations ────────────────────────────────────────────────────────
+ ...(
+ [
+ ["withings-card.tsx", "Withings"],
+ ["fitbit-card.tsx", "Fitbit"],
+ ["whoop-card.tsx", "WHOOP"],
+ ["google-health-card.tsx", "Google Health"],
+ ["nightscout-card.tsx", "Nightscout"],
+ ["oauth-provider-card.tsx", "the generic OAuth providers"],
+ ] as const
+ ).map(([f, name]): DestructiveEntry => ({
+ file: `components/settings/integrations/${f}`,
+ destroys: `the stored ${name} credentials — reconnecting means authorising again`,
+ recovery: "permanent",
+ confirm: ["AlertDialog"],
+ })),
+];
+
+// ── Detectors ─────────────────────────────────────────────────────────────
+
+/** D1 — a DELETE request goes out. */
+const D1 = /apiDelete[<(]|method:\s*"DELETE"/;
+/** D2 — a route path whose own name is the destructive act. */
+const D2 =
+ /["`]\/api\/[^"`?\s]*(?:disconnect|revoke|reset|purge|clear|wipe|bulk-delete)[^"`?\s]*/;
+/** D3 — a write payload that blanks a named field. */
+const D3 =
+ /(?:\.mutate|\.mutateAsync|apiPut|apiPatch|apiPost)[<(]?\(\s*(?:"[^"]*",\s*)?\{[^{}]*?\b\w+:\s*(?:""|null)/s;
+
+/**
+ * Where a destructive CONTROL can live. Route handlers are the other end of
+ * the wire, not a control, and `src/lib` holds no JSX — its three matches are
+ * the `apiDelete` helper itself, an audit-path string constant and an OpenAPI
+ * path literal. Both exclusions are asserted below so neither can be widened
+ * into a hiding place without this test noticing.
+ */
+const CONTROL_ROOTS = ["app/", "components/", "hooks/"];
+const EXCLUDED_PREFIXES = ["app/api/", "generated/", "lib/"];
+
+function sourceFiles(): string[] {
+ return globSync("**/*.{ts,tsx}", { cwd: SRC })
+ .map((p) => p.split(sep).join("/"))
+ .filter((p) => !p.includes("__tests__"))
+ .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
+ .sort();
+}
+
+function read(rel: string): string {
+ return readFileSync(join(SRC, rel), "utf8");
+}
+
+function isControlFile(rel: string): boolean {
+ if (EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) return false;
+ return CONTROL_ROOTS.some((p) => rel.startsWith(p));
+}
+
+function detect(rel: string): string[] {
+ const src = read(rel);
+ const tags: string[] = [];
+ if (D1.test(src)) tags.push("D1");
+ if (D2.test(src)) tags.push("D2");
+ if (D3.test(src)) tags.push("D3");
+ return tags;
+}
+
+let detectedCache: string[] | null = null;
+function detectedFiles(): string[] {
+ detectedCache ??= sourceFiles().filter(
+ (rel) => isControlFile(rel) && detect(rel).length,
+ );
+ return detectedCache;
+}
+
+const CONFIRM_MARKERS: Record = {
+ ConfirmButton: / [e.file, e]));
+
+describe("T1 — every destructive control is registered", () => {
+ it("the registry names every file the detectors flag", () => {
+ const missing = detectedFiles().filter((rel) => !byFile.has(rel));
+ expect(
+ missing,
+ "A destructive request is issued from these files and nothing in the " +
+ "registry accounts for it. Add an entry naming what it destroys, how " +
+ "recoverable that is, and what gates it — or, if it genuinely does " +
+ "not destroy anything, say so under `unconfirmed` with the argument.",
+ ).toEqual([]);
+ });
+
+ it("carries no stale entry", () => {
+ const stale = REGISTRY.filter(
+ (e) => !e.undetected && !detectedFiles().includes(e.file),
+ ).map((e) => e.file);
+ expect(
+ stale,
+ "These entries no longer match any detector. If the control was removed, " +
+ "delete the entry; if it was rewritten into a shape the detectors " +
+ "cannot see, that is worth knowing before the entry is dropped.",
+ ).toEqual([]);
+ });
+
+ it("declares each file once", () => {
+ expect(byFile.size).toBe(REGISTRY.length);
+ });
+
+ it("pins the detector exclusions so they cannot quietly widen", () => {
+ // `src/lib` and the route handlers are excluded because a control cannot
+ // live there. If that stops being true the exclusion is a hiding place.
+ expect(EXCLUDED_PREFIXES).toEqual(["app/api/", "generated/", "lib/"]);
+ expect(CONTROL_ROOTS).toEqual(["app/", "components/", "hooks/"]);
+ });
+});
+
+describe("T2 — every registered control names a real gate", () => {
+ for (const entry of REGISTRY) {
+ describe(entry.file, () => {
+ const files = [entry.file, ...(entry.triggers ?? [])];
+
+ it("has a confirmation mechanism, or an argument for having none", () => {
+ expect(
+ entry.confirm.length > 0 || (entry.unconfirmed?.length ?? 0) > 0,
+ `${entry.file}: neither a confirmation nor a reason for going without one`,
+ ).toBe(true);
+ });
+
+ for (const kind of entry.confirm) {
+ it(`the claimed ${kind} is actually present`, () => {
+ const found = files.some((f) => CONFIRM_MARKERS[kind].test(read(f)));
+ expect(
+ found,
+ `${entry.file} claims ${kind}, but no such control renders in ${files.join(" / ")}`,
+ ).toBe(true);
+ });
+ }
+
+ for (const skip of entry.unconfirmed ?? []) {
+ it(`the case for leaving "${skip.control}" unconfirmed is written out`, () => {
+ // A one-liner is a shrug. The floor is set where a sentence that
+ // actually argues something has to be written.
+ expect(
+ skip.reason.trim().length,
+ `${entry.file} → ${skip.control}: give the reason, not a note`,
+ ).toBeGreaterThanOrEqual(120);
+ });
+ }
+
+ it("every named trigger file exists", () => {
+ for (const f of files) expect(() => read(f)).not.toThrow();
+ });
+ });
+ }
+});
+
+describe("T3 — a destroying payload never fires straight from a handler", () => {
+ /**
+ * The regression that started this: `onClick={() => save.mutate({ aboutMe:
+ * "", allergies: "" })}`. One tap, two fields blanked, no gate. This walks
+ * every event-handler attribute in every registered file and fails if a
+ * destroying request sits inline inside one — which is the shape that skips
+ * the dialog by construction rather than by oversight.
+ *
+ * Indirection through a named handler (`onClick={handleDisconnect}`) is out
+ * of reach here and is covered instead by T2's requirement that the file
+ * render a confirmation at all. Named, so nobody reads more into this.
+ */
+ /**
+ * Elements whose handlers ARE the confirmed path — the dialog's own action
+ * button, and the primitives that own their dialog. A destroying call inside
+ * one of these is the point, not the bug. Getting this list wrong in the
+ * other direction is how a guard starts crying wolf, so it is short and
+ * every member is a confirmation surface by construction.
+ */
+ const CONFIRMED_ELEMENTS = new Set([
+ "AlertDialogAction",
+ "ConfirmButton",
+ "ConfirmDialog",
+ "DeleteButton",
+ "SelectionActionBar",
+ ]);
+ /** `onConfirm` is a confirmation callback wherever it appears. */
+ const CONFIRMED_ATTRS = /^onConfirm$/;
+
+ /**
+ * Walk the JSX opening tags and hand back `[element, handlerAttr, body]` for
+ * every event handler, so the check can tell a raw `onClick` on a `Button`
+ * from the `onClick` the dialog's action button carries.
+ */
+ function handlers(src: string): { el: string; attr: string; body: string }[] {
+ const out: { el: string; attr: string; body: string }[] = [];
+ const TAG = /<([A-Z][\w.]*|[a-z][\w-]*)(?=[\s/>])/g;
+ for (const tag of src.matchAll(TAG)) {
+ const el = tag[1];
+ // Scan to the end of THIS opening tag, tracking brace depth and strings
+ // so an attribute expression containing `>` does not end it early.
+ let i = tag.index + tag[0].length;
+ let depth = 0;
+ let quote: string | null = null;
+ const attrs: { attr: string; body: string }[] = [];
+ let pendingAttr: string | null = null;
+ let bodyStart = 0;
+ for (; i < src.length; i++) {
+ const c = src[i];
+ if (quote) {
+ if (c === quote) quote = null;
+ continue;
+ }
+ if (depth === 0 && (c === '"' || c === "'" || c === "`")) {
+ quote = c;
+ continue;
+ }
+ if (c === "{") {
+ if (depth === 0) {
+ const before = src.slice(Math.max(0, i - 60), i);
+ const m = /\b(on[A-Z]\w*)=$/.exec(before);
+ pendingAttr = m ? m[1] : null;
+ bodyStart = i;
+ }
+ depth++;
+ continue;
+ }
+ if (c === "}") {
+ depth--;
+ if (depth === 0 && pendingAttr) {
+ attrs.push({
+ attr: pendingAttr,
+ body: src.slice(bodyStart, i + 1),
+ });
+ pendingAttr = null;
+ }
+ continue;
+ }
+ if (depth === 0 && c === ">") break;
+ }
+ for (const a of attrs) out.push({ el, ...a });
+ }
+ return out;
+ }
+
+ // Unconditional, including the entries that argue for having no dialog. The
+ // whole registry is clean of this shape today, so there is no exemption to
+ // carve out and no reason to leave a door open for one. A control that
+ // genuinely should fire without a gate still has somewhere to go: the
+ // confirmed elements above, or a named handler the file can be read at.
+ for (const entry of REGISTRY) {
+ const files = [entry.file, ...(entry.triggers ?? [])].filter((f) =>
+ f.endsWith(".tsx"),
+ );
+
+ for (const file of files) {
+ it(`${file} — no inline destroying handler`, () => {
+ const offenders = handlers(read(file))
+ .filter(
+ (h) =>
+ !CONFIRMED_ELEMENTS.has(h.el) && !CONFIRMED_ATTRS.test(h.attr),
+ )
+ .filter((h) => D1.test(h.body) || D3.test(h.body))
+ .map((h) => `<${h.el} ${h.attr}=${h.body.slice(0, 80)}`);
+ expect(
+ offenders,
+ `${file}: a destroying request fires directly from an event handler. ` +
+ `Route it through ConfirmButton / ConfirmDialog / DeleteButton, or ` +
+ `move it behind a named handler the reviewer can read.`,
+ ).toEqual([]);
+ });
+ }
+ }
+});
+
+describe("T4 — the registry answers the recoverability question", () => {
+ it("every entry classifies what a person can get back", () => {
+ for (const e of REGISTRY) {
+ expect(
+ ["permanent", "tombstoned-no-restore", "restorable"],
+ e.file,
+ ).toContain(e.recovery);
+ expect(e.destroys.trim().length, e.file).toBeGreaterThan(10);
+ }
+ });
+
+ it("nothing permanent is left unconfirmed without an argument", () => {
+ const bare = REGISTRY.filter(
+ (e) =>
+ e.recovery === "permanent" &&
+ e.confirm.length === 0 &&
+ (e.unconfirmed?.length ?? 0) === 0,
+ ).map((e) => e.file);
+ expect(bare).toEqual([]);
+ });
+});
diff --git a/src/components/settings/__tests__/about-me-clear-scope.test.tsx b/src/components/settings/__tests__/about-me-clear-scope.test.tsx
new file mode 100644
index 000000000..99088eed4
--- /dev/null
+++ b/src/components/settings/__tests__/about-me-clear-scope.test.tsx
@@ -0,0 +1,166 @@
+/**
+ * Settings → AI "About me": the Clear control cannot take the allergy line.
+ *
+ * The control used to fire `save.mutate({ aboutMe: "", allergies: "" })` from
+ * a bare `onClick`. One tap wiped a person's free-text note AND their
+ * allergies — the entry a clinician reads first, stored in a column that is
+ * overwritten in place, with no tombstone and no restore route. The toast it
+ * raised said "About-me note cleared", so the copy had been describing the
+ * intended behaviour all along; the payload was the thing that was wrong.
+ *
+ * Two properties are pinned here, and the first matters more than the second:
+ *
+ * 1. SCOPE. The request carries `aboutMe` and no `allergies` key at all.
+ * The PUT leaves an omitted field untouched by contract, so the payload
+ * cannot carry the allergy line away even if the dialog were removed
+ * again. A confirmation someone can delete is not a guarantee.
+ * 2. GATE. Nothing goes out while the dialog is merely on screen. The
+ * destructive call reaches the control only as `onConfirm`, so a
+ * dismissed dialog leaves the request unmade — asserted here by
+ * rendering, checking no request was issued, and only then invoking the
+ * captured callback.
+ *
+ * `@testing-library/react` is not a dependency (SSR-only component tests), so
+ * the dialog is not literally clicked. `ConfirmButton` is stubbed to capture
+ * the props the panel hands it; that the real primitive routes `onConfirm`
+ * through the dialog action alone is proved in
+ * `src/components/ui/__tests__/confirm-button.test.tsx`.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import { queryKeys } from "@/lib/query-keys";
+
+const apiPut = vi.fn(async () => ({
+ aboutMe: null,
+ conditions: null,
+ allergies: "penicillin",
+ coachFocus: null,
+ pendingQuestions: [],
+ updatedAt: "2026-07-26T00:00:00.000Z",
+ maxChars: 4000,
+ fieldMaxChars: 500,
+}));
+
+vi.mock("@/lib/api/api-fetch", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, apiPut: (...args: unknown[]) => apiPut(...args) };
+});
+
+/** Captured props of every `` the panel rendered. */
+const confirmButtons: Record[] = [];
+
+vi.mock("@/components/ui/confirm-button", () => ({
+ ConfirmButton: (props: Record) => {
+ confirmButtons.push(props);
+ return (
+ {String(props.label)}
+ );
+ },
+ ConfirmDialog: () => null,
+}));
+
+const { AboutMeSection } = await import("../about-me-section");
+
+const STORED = {
+ aboutMe: "Shift work, training for a half marathon.",
+ conditions: null,
+ allergies: "penicillin",
+ coachFocus: null,
+ pendingQuestions: [],
+ updatedAt: "2026-07-26T00:00:00.000Z",
+ maxChars: 4000,
+ fieldMaxChars: 500,
+};
+
+function render(seed: Partial = {}) {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false, staleTime: Infinity } },
+ });
+ client.setQueryData(queryKeys.coachAboutMe(), { ...STORED, ...seed });
+ return renderToStaticMarkup(
+
+
+
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ confirmButtons.length = 0;
+ apiPut.mockClear();
+});
+
+describe("the Clear control is scoped to the note", () => {
+ it("routes through the shared confirmation, not a bare button", () => {
+ const html = render();
+ expect(html).toContain('data-slot="settings-about-me-clear"');
+ expect(confirmButtons).toHaveLength(1);
+ expect(typeof confirmButtons[0].onConfirm).toBe("function");
+ });
+
+ it("hands the destroying call to the dialog, not to the control", () => {
+ render();
+ const props = confirmButtons[0];
+ // The callback arrives ONLY as `onConfirm`. Anything that would let the
+ // trigger act — an `onClick`, an `onSelect` — puts the request one tap
+ // away again, which is the shape this whole change removed.
+ expect(typeof props.onConfirm).toBe("function");
+ for (const key of Object.keys(props)) {
+ if (key === "onConfirm") continue;
+ expect(key, "the trigger must carry no handler of its own").not.toMatch(
+ /^on[A-Z]/,
+ );
+ }
+ // And rendering it, on its own, asks the server for nothing.
+ expect(apiPut).not.toHaveBeenCalled();
+ });
+
+ it("omits the allergies field entirely once confirmed", async () => {
+ render();
+ (confirmButtons[0].onConfirm as () => void)();
+ await vi.waitFor(() => expect(apiPut).toHaveBeenCalledTimes(1));
+
+ const [url, payload] = apiPut.mock.calls[0] as [
+ string,
+ Record,
+ ];
+ expect(url).toBe("/api/coach/about-me");
+ expect(payload.aboutMe).toBe("");
+ // Not "sent as the stored value" — absent. The route leaves an omitted
+ // field alone, so absence is what protects the allergy line.
+ expect(Object.keys(payload)).not.toContain("allergies");
+ expect(payload.allergies).toBeUndefined();
+ });
+
+ it("says in its dialog that allergies are not touched", () => {
+ render();
+ expect(confirmButtons[0].body).toBe(
+ "This removes the free-text note only. Your allergies and intolerances stay exactly as they are.",
+ );
+ });
+
+ it("hides the control when there is no note, even with allergies stored", () => {
+ const html = render({ aboutMe: null });
+ expect(html).not.toContain('data-slot="settings-about-me-clear"');
+ expect(confirmButtons).toHaveLength(0);
+ });
+});
+
+describe("clearing allergies stays possible, in the field itself", () => {
+ it("renders the allergy textarea, editable, carrying the stored value", () => {
+ // Removing the field from the Clear payload only removes the untargeted
+ // route. The targeted one has to still be there, or scoping the control
+ // would have taken a capability away rather than a footgun.
+ const html = render();
+ const field = html.slice(
+ html.indexOf('data-testid="settings-about-me-allergies"'),
+ );
+ const openTag = field.slice(0, field.indexOf(">"));
+ expect(openTag).not.toContain("disabled");
+ expect(html).toContain("penicillin");
+ });
+});
diff --git a/src/components/settings/__tests__/destructive-controls-confirm.test.tsx b/src/components/settings/__tests__/destructive-controls-confirm.test.tsx
index 6d5b0d96c..c3c67147f 100644
--- a/src/components/settings/__tests__/destructive-controls-confirm.test.tsx
+++ b/src/components/settings/__tests__/destructive-controls-confirm.test.tsx
@@ -3,65 +3,189 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
/**
- * Destructive settings controls ask first.
+ * Destructive controls ask first.
*
* Signing every other device out, and revoking a trusted device, used to fire
* on a single tap. Neither is undoable — the other devices simply have to log
* in again, and a revoked trusted device asks for a second factor next time —
- * while every other destructive surface in the app confirms. On a shared
+ * while every other destructive surface in the app confirmed. On a shared
* screen a mis-tap is a support call.
*
- * This reads the sources rather than rendering, because what it pins is
- * structural: that these particular controls route through `ConfirmButton`
- * and never call a revoke mutation straight from an `onClick`. A render test
- * would prove the dialog exists in one arrangement; this proves no control on
- * these cards can go back to firing directly.
+ * The sweep that followed found fourteen more of the same shape, and this file
+ * grew to hold them. It reads the sources rather than rendering, because what
+ * it pins is structural: that these particular controls route through
+ * `ConfirmButton` / `ConfirmDialog` and never call a destroying mutation
+ * straight from an `onClick`. A render test would prove a dialog exists in one
+ * arrangement; this proves no control on these surfaces can go back to firing
+ * directly.
+ *
+ * It is one leg of three, and none of them stands alone:
+ *
+ * - here: each named control routes through the primitive;
+ * - `src/components/ui/__tests__/confirm-button.test.tsx`: the primitive
+ * hands the caller's callback to the dialog action and nowhere else, so a
+ * dismissal cannot act;
+ * - `src/__tests__/destructive-control-guard.test.ts`: no NEW destructive
+ * control can arrive without being written down.
+ *
+ * Together those give "each guarded control refuses to act on a dismissed
+ * dialog" without a click-through the SSR-only test setup cannot perform.
*/
-const CARDS = [
+function source(rel: string) {
+ return readFileSync(join(process.cwd(), "src", rel), "utf8");
+}
+
+/**
+ * Every control that must open a confirmation before it destroys anything,
+ * keyed by the `slot` it hands the primitive. A slot that disappears from its
+ * file means the control was renamed, moved, or unwired — all three are worth
+ * a red test.
+ */
+const CONTROLS: { file: string; slots: string[] }[] = [
{
- file: "security-sessions-card.tsx",
+ file: "components/settings/security-sessions-card.tsx",
slots: ["revoke-session", "sign-out-everywhere"],
},
{
- file: "trusted-devices-card.tsx",
+ file: "components/settings/trusted-devices-card.tsx",
slots: ["revoke-trusted-device", "revoke-all-trusted-devices"],
},
+ // The allergy wipe. Scoped to the note as well as gated — see
+ // `about-me-clear-scope.test.tsx` for the payload half.
+ {
+ file: "components/settings/about-me-section.tsx",
+ slots: ["settings-about-me-clear"],
+ },
+ {
+ file: "components/settings/ai/codex-provider-form.tsx",
+ slots: ["settings-codex-disconnect"],
+ },
+ {
+ file: "components/admin/central-codex-section.tsx",
+ slots: ["admin-central-codex-disconnect"],
+ },
+ {
+ file: "components/admin/ai-server-key-section.tsx",
+ slots: ["admin-ai-server-key-remove"],
+ },
+ {
+ file: "components/settings/account-section/avatar-section.tsx",
+ slots: ["settings-avatar-remove"],
+ },
+ {
+ file: "components/settings/coach-memory-section.tsx",
+ slots: ["settings-coach-memory-forget"],
+ },
+ {
+ file: "components/settings/sources-section.tsx",
+ slots: ["settings-sources-reset"],
+ },
+ {
+ file: "components/settings/dashboard-layout-section.tsx",
+ slots: ["settings-dashboard-layout-reset"],
+ },
+ {
+ file: "components/insights/insights-edit-mode.tsx",
+ slots: ["insights-edit-reset"],
+ },
+ {
+ file: "components/targets/target-edit-sheet.tsx",
+ slots: ["target-edit-reset"],
+ },
+ { file: "app/coach/plans/page.tsx", slots: ["coach-plan-delete"] },
+ {
+ file: "components/cycle/log-day-sheet.tsx",
+ slots: ["cycle-day-delete", "cycle-custom-symptom-remove"],
+ },
+ // Two controls, one interpolated slot each: the per-metric reset and the
+ // override switch, which delete the same stored range.
+ {
+ file: "components/settings/thresholds-editor-section.tsx",
+ slots: [
+ "settings-thresholds-reset-all",
+ "settings-thresholds-reset-${metric}",
+ "settings-thresholds-override-off-${metric}",
+ ],
+ },
];
-function source(file: string) {
- return readFileSync(
- join(process.cwd(), "src", "components", "settings", file),
- "utf8",
- );
-}
+describe("destructive controls confirm before firing", () => {
+ for (const control of CONTROLS) {
+ describe(control.file, () => {
+ const src = source(control.file);
-describe("destructive session controls confirm before firing", () => {
- for (const card of CARDS) {
- describe(card.file, () => {
- const src = source(card.file);
-
- it("routes every destructive control through ConfirmButton", () => {
- expect(src).toContain("ConfirmButton");
- for (const slot of card.slots) {
- expect(src).toContain(`slot="${slot}"`);
+ it("routes every named control through the shared confirmation", () => {
+ expect(src).toMatch(/ {
- // `onConfirm={() => revoke…}` is the confirmed path and is fine;
- // `onClick={() => revoke…}` is the unguarded one this forbids.
- const unguarded = src.match(/onClick=\{\(\) =>\s*revoke/g) ?? [];
- expect(unguarded).toEqual([]);
- });
-
- it("gives each control its own confirmation body", () => {
- // A shared generic body would let one control's dialog describe
- // another's consequence — the reason the copy is per-control.
- const bodies = src.match(/body=\{t\("([^"]+)"/g) ?? [];
- expect(bodies.length).toBe(card.slots.length);
- expect(new Set(bodies).size).toBe(card.slots.length);
+ it("names the consequence in surface-specific copy", () => {
+ // A dialog that says "Are you sure?" makes the person guess what they
+ // are agreeing to, so every body is a key belonging to this surface.
+ // Two controls MAY share one — the per-metric threshold reset and the
+ // override switch do, because they perform the identical delete — so
+ // what is pinned is the provenance of the copy, not its cardinality.
+ const bodies = [...src.matchAll(/\bbody=\{([\s\S]{0,200}?)\}\n/g)].map(
+ (m) => m[1],
+ );
+ expect(
+ bodies.length,
+ `${control.file}: no confirmation body found`,
+ ).toBeGreaterThan(0);
+ const keys = bodies.flatMap((b) =>
+ [...b.matchAll(/t\("([^"]+)"/g)].map((m) => m[1]),
+ );
+ expect(keys.length).toBeGreaterThanOrEqual(bodies.length);
+ for (const key of keys) {
+ expect(key, `${control.file}: generic dialog copy`).not.toMatch(
+ /^common\./,
+ );
+ }
});
});
}
});
+
+describe("the threshold override switch deletes behind the same dialog", () => {
+ /**
+ * The switch labelled "Custom range" performs the same irreversible delete
+ * as the Reset button beside it — flipping it off drops the stored range —
+ * from a control that reads as a display toggle. It was the one destructive
+ * path in this sweep that no earlier pass named, precisely because it is not
+ * shaped like a delete. `ConfirmButton` cannot wrap a switch, which is why
+ * `ConfirmDialog` exists.
+ */
+ const src = source("components/settings/thresholds-editor-section.tsx");
+ const handler = src.slice(
+ src.indexOf("onCheckedChange={(next)"),
+ src.indexOf("disabled={busy}", src.indexOf("onCheckedChange={(next)")),
+ );
+
+ it("does not reset straight from the switch handler", () => {
+ expect(handler).toContain("setConfirmSwitchOff(true)");
+ expect(
+ handler,
+ "flipping the switch must open the dialog, not perform the delete",
+ ).not.toMatch(/onReset\(\)/);
+ });
+
+ it("leaves the switch on when the dialog is dismissed", () => {
+ // `setOverrideMode(false)` lives inside `onConfirm`, not in the handler,
+ // so a cancelled dialog leaves both the switch and the stored range alone.
+ const confirmArm = src.slice(
+ src.indexOf("", src.indexOf("` / `` — the dismissal contract.
+ *
+ * Every destructive control this repo guards routes through one of these two,
+ * so the property "a dismissed dialog does not act" is worth proving ONCE,
+ * here, rather than re-proving it per consumer. What makes that compositional
+ * argument sound is that the callers really do route through the primitive,
+ * which `src/__tests__/destructive-control-guard.test.ts` asserts separately
+ * (T2: the claimed mechanism renders; T3: no destroying request fires from a
+ * bare handler). Neither half means much alone.
+ *
+ * The proof is structural because it can be. Both components are hook-free,
+ * so the test calls them as plain functions and walks the element tree they
+ * return, which is stronger than asserting on markup: it shows WHERE the
+ * caller's `onConfirm` is attached, not merely that some button exists.
+ *
+ * - the trigger carries no `onClick` at all, so tapping it cannot act;
+ * - `onConfirm` is reachable from exactly one node, the dialog's action;
+ * - the cancel node never receives it;
+ * - and the action lives inside `AlertDialogContent`, which Radix mounts
+ * only while open — so dismissing unmounts the only path to the callback.
+ *
+ * WHAT IS NOT PROVEN HERE. The project runs SSR-only component tests
+ * (`@testing-library/react` is not a dependency), so nothing below clicks
+ * Cancel. An earlier draft of this file asserted that a closed dialog renders
+ * no confirm control in the SSR markup; that passed with `open` hard-coded to
+ * true, because Radix portals do not render server-side at all — it was
+ * measuring the renderer, not the component, so it is gone rather than left
+ * green. That the content mounts only while open is Radix's own contract, and
+ * the rendered dialog is driven by Playwright (`e2e/share-documents.spec.ts`
+ * exercises one by role). What IS proven here is the wiring: where the
+ * caller's callback goes, and where it does not.
+ */
+import { describe, it, expect, vi } from "vitest";
+import { isValidElement, type ReactElement, type ReactNode } from "react";
+
+// `ConfirmBody` is invoked as a plain function below so its element tree can
+// be walked; that bypasses React's context machinery, so its one hook is
+// stubbed. The provider-backed SSR assertions further down use the real one.
+vi.mock("@/lib/i18n/context", () => ({
+ useTranslations: () => ({ t: (k: string) => k }),
+}));
+
+const { ConfirmButton, ConfirmDialog } = await import("../confirm-button");
+
+/** Display name of an element's type, for readable assertions. */
+function nameOf(el: ReactElement): string {
+ const t = el.type as unknown;
+ if (typeof t === "string") return t;
+ if (typeof t === "function") return (t as { name?: string }).name || "anon";
+ const obj = t as { displayName?: string; name?: string };
+ return obj?.displayName ?? obj?.name ?? "unknown";
+}
+
+/** Every element in the tree, paired with its resolved name. */
+function walk(node: ReactNode, out: ReactElement[] = []): ReactElement[] {
+ if (Array.isArray(node)) {
+ for (const n of node) walk(n, out);
+ return out;
+ }
+ if (!isValidElement(node)) return out;
+ out.push(node);
+ const props = node.props as { children?: ReactNode };
+ if (props?.children) walk(props.children, out);
+ return out;
+}
+
+const COPY = {
+ title: "Clear the note?",
+ body: "The note goes; your allergies stay.",
+ confirmLabel: "Clear note",
+};
+
+describe(" — the trigger cannot act on its own", () => {
+ const onConfirm = vi.fn();
+ const tree = walk(
+ ConfirmButton({
+ label: "Clear",
+ slot: "demo-clear",
+ onConfirm,
+ ...COPY,
+ }) as ReactElement,
+ );
+
+ function find(name: string) {
+ return tree.filter((el) => nameOf(el) === name);
+ }
+
+ it("renders exactly one trigger and one dialog body", () => {
+ expect(find("AlertDialogTrigger")).toHaveLength(1);
+ expect(find("ConfirmBody")).toHaveLength(1);
+ });
+
+ it("the trigger's button has no click handler", () => {
+ const [trigger] = find("AlertDialogTrigger");
+ const [button] = walk(
+ (trigger.props as { children?: ReactNode }).children,
+ ).filter((el) => nameOf(el) === "Button");
+ expect(button).toBeDefined();
+ const props = button.props as Record;
+ expect(
+ props.onClick,
+ "a trigger that can act is a trigger that fires on a mis-tap",
+ ).toBeUndefined();
+ expect(props["data-slot"]).toBe("demo-clear");
+ });
+
+ it("passes onConfirm to the dialog body and nowhere else", () => {
+ const carriers = tree.filter(
+ (el) => (el.props as Record).onConfirm === onConfirm,
+ );
+ expect(carriers.map(nameOf)).toEqual(["ConfirmBody"]);
+ });
+
+ it("never invokes the callback during render", () => {
+ expect(onConfirm).not.toHaveBeenCalled();
+ });
+});
+
+describe("ConfirmBody — the action is the only path to the callback", () => {
+ const onConfirm = vi.fn();
+ // `ConfirmBody` is internal; reach it through the public component's tree.
+ const body = walk(
+ ConfirmButton({
+ label: "Clear",
+ slot: "demo-clear",
+ onConfirm,
+ ...COPY,
+ }) as ReactElement,
+ ).find((el) => nameOf(el) === "ConfirmBody")!;
+
+ const rendered = walk(
+ (body.type as (p: unknown) => ReactElement)(body.props),
+ );
+
+ it("wires the callback to the action node only", () => {
+ const carriers = rendered.filter(
+ (el) => (el.props as Record).onClick === onConfirm,
+ );
+ expect(carriers.map(nameOf)).toEqual(["AlertDialogAction"]);
+ });
+
+ it("leaves the cancel node without it", () => {
+ const cancel = rendered.find((el) => nameOf(el) === "AlertDialogCancel")!;
+ const props = cancel.props as Record;
+ expect(props.onClick).toBeUndefined();
+ expect(props.onConfirm).toBeUndefined();
+ });
+
+ it("keeps the action inside the dialog content Radix only mounts when open", () => {
+ const content = rendered.find((el) => nameOf(el) === "AlertDialogContent")!;
+ const inside = walk((content.props as { children?: ReactNode }).children);
+ expect(inside.map(nameOf)).toContain("AlertDialogAction");
+ });
+
+ it("labels the action with the caller's verb rather than a bare OK", () => {
+ const action = rendered.find((el) => nameOf(el) === "AlertDialogAction")!;
+ const children = (action.props as { children: unknown }).children;
+ expect(JSON.stringify(children)).toContain(COPY.confirmLabel);
+ });
+});
+
+describe(" — the trigger-less half", () => {
+ const onConfirm = vi.fn();
+ const tree = walk(
+ ConfirmDialog({
+ open: false,
+ onOpenChange: vi.fn(),
+ onConfirm,
+ slot: "demo-switch",
+ ...COPY,
+ }) as ReactElement,
+ );
+
+ it("renders no trigger, so only the caller's own control can open it", () => {
+ expect(tree.map(nameOf)).not.toContain("AlertDialogTrigger");
+ });
+
+ it("hands the dialog its open state rather than owning it", () => {
+ const root = tree.find((el) => nameOf(el) === "AlertDialog")!;
+ expect((root.props as Record).open).toBe(false);
+ });
+
+ it("passes onConfirm to the dialog body and nowhere else", () => {
+ const carriers = tree.filter(
+ (el) => (el.props as Record).onConfirm === onConfirm,
+ );
+ expect(carriers.map(nameOf)).toEqual(["ConfirmBody"]);
+ });
+});
From 78644ab77e239e8bbc86d7fb54e96bc6f8fab494 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 00:27:09 +0200
Subject: [PATCH 25/52] test(ui): type the about-me mock and drop the
es2018-only regex flag
---
.../destructive-control-guard.test.ts | 2 +-
.../__tests__/about-me-clear-scope.test.tsx | 47 +++++++++++++------
2 files changed, 33 insertions(+), 16 deletions(-)
diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts
index 053e3211a..16f6ca168 100644
--- a/src/__tests__/destructive-control-guard.test.ts
+++ b/src/__tests__/destructive-control-guard.test.ts
@@ -539,7 +539,7 @@ const D2 =
/["`]\/api\/[^"`?\s]*(?:disconnect|revoke|reset|purge|clear|wipe|bulk-delete)[^"`?\s]*/;
/** D3 — a write payload that blanks a named field. */
const D3 =
- /(?:\.mutate|\.mutateAsync|apiPut|apiPatch|apiPost)[<(]?\(\s*(?:"[^"]*",\s*)?\{[^{}]*?\b\w+:\s*(?:""|null)/s;
+ /(?:\.mutate|\.mutateAsync|apiPut|apiPatch|apiPost)[<(]?\(\s*(?:"[^"]*",\s*)?\{[^{}]*?\b\w+:\s*(?:""|null)/;
/**
* Where a destructive CONTROL can live. Route handlers are the other end of
diff --git a/src/components/settings/__tests__/about-me-clear-scope.test.tsx b/src/components/settings/__tests__/about-me-clear-scope.test.tsx
index 99088eed4..60bfef5cf 100644
--- a/src/components/settings/__tests__/about-me-clear-scope.test.tsx
+++ b/src/components/settings/__tests__/about-me-clear-scope.test.tsx
@@ -33,20 +33,39 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
-const apiPut = vi.fn(async () => ({
- aboutMe: null,
- conditions: null,
- allergies: "penicillin",
- coachFocus: null,
- pendingQuestions: [],
- updatedAt: "2026-07-26T00:00:00.000Z",
- maxChars: 4000,
- fieldMaxChars: 500,
+type AboutMePayload = {
+ aboutMe: string | null;
+ conditions: string | null;
+ allergies: string | null;
+ coachFocus: string | null;
+ pendingQuestions: string[];
+ updatedAt: string;
+ maxChars: number;
+ fieldMaxChars: number;
+};
+
+// Hoisted, because `vi.mock`'s factory is lifted above the imports and may not
+// close over a normal top-level binding. Typed with the real two-argument
+// signature so `mock.calls[0]` is a tuple and the payload assertion below
+// reads the second argument rather than `unknown[]`.
+const { apiPut } = vi.hoisted(() => ({
+ apiPut: vi.fn<(url: string, payload: unknown) => Promise>(
+ async () => ({
+ aboutMe: null,
+ conditions: null,
+ allergies: "penicillin",
+ coachFocus: null,
+ pendingQuestions: [],
+ updatedAt: "2026-07-26T00:00:00.000Z",
+ maxChars: 4000,
+ fieldMaxChars: 500,
+ }),
+ ),
}));
vi.mock("@/lib/api/api-fetch", async (importOriginal) => {
const actual = await importOriginal();
- return { ...actual, apiPut: (...args: unknown[]) => apiPut(...args) };
+ return { ...actual, apiPut };
});
/** Captured props of every `` the panel rendered. */
@@ -64,7 +83,7 @@ vi.mock("@/components/ui/confirm-button", () => ({
const { AboutMeSection } = await import("../about-me-section");
-const STORED = {
+const STORED: AboutMePayload = {
aboutMe: "Shift work, training for a half marathon.",
conditions: null,
allergies: "penicillin",
@@ -124,10 +143,8 @@ describe("the Clear control is scoped to the note", () => {
(confirmButtons[0].onConfirm as () => void)();
await vi.waitFor(() => expect(apiPut).toHaveBeenCalledTimes(1));
- const [url, payload] = apiPut.mock.calls[0] as [
- string,
- Record,
- ];
+ const [url, raw] = apiPut.mock.calls[0]!;
+ const payload = raw as Record;
expect(url).toBe("/api/coach/about-me");
expect(payload.aboutMe).toBe("");
// Not "sent as the stored value" — absent. The route leaves an omitted
From 29188b2262be7179d6a7118c521806c6a863cefe Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 00:22:31 +0200
Subject: [PATCH 26/52] fix(insights): let a section heading's trailing caption
wrap instead of widening the page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Insights overview panned sideways at every phone width. SectionHeading
pinned its trailing action slot with shrink-0, which sizes a flex item to its
max-content width. That slot was designed for a compact control, but every
caller passes prose today — the provenance method caption, the device-score
subtitle, the sleep-quality subtitle. A sentence at max-content measures
1229 px in English and 1464 px in German, and since the row sits inside the
AuthShell — whose overflow-y:auto makes overflow-x compute to auto —
that width turned the page into a horizontal scroller. min-w-0 lifts the flex
min-width:auto floor so the caption wraps to the row instead; the parent's
flex-wrap still moves the slot to its own line before anything is squeezed.
The existing width guards could not see this. Both measured
documentElement.scrollWidth, which never budges when absorbs the
overflow into its own scroll area, and the insights case ran against an empty
mocked payload that fits any viewport by construction.
- Add e2e/utils/horizontal-overflow.ts: asserts the document AND the
scroll container, and names the widest offending element on failure.
- Add e2e/mobile-horizontal-overflow.spec.ts: sweeps /insights, /, /measurements
and /medications at 360/390/393/402/430 px against populated fixtures, plus a
canary proving the measurement can see through , and a check that the
insights fixture really populates the page.
- Rewrite the cycle spec's empty payload as a populated one and sweep every
phone width; its /insights case moves to the new spec.
- Point mobile-density.spec.ts at the shared assertion — it carried the same
document-only blind spot.
---
e2e/cycle-insights-mobile.spec.ts | 101 +++++---
e2e/mobile-density.spec.ts | 38 ++-
e2e/mobile-horizontal-overflow.spec.ts | 202 +++++++++++++++
e2e/utils/horizontal-overflow.ts | 205 +++++++++++++++
e2e/utils/mock-populated-insights.ts | 268 ++++++++++++++++++++
src/components/insights/section-heading.tsx | 15 +-
6 files changed, 782 insertions(+), 47 deletions(-)
create mode 100644 e2e/mobile-horizontal-overflow.spec.ts
create mode 100644 e2e/utils/horizontal-overflow.ts
create mode 100644 e2e/utils/mock-populated-insights.ts
diff --git a/e2e/cycle-insights-mobile.spec.ts b/e2e/cycle-insights-mobile.spec.ts
index d45dfbbf2..a33ea0ad4 100644
--- a/e2e/cycle-insights-mobile.spec.ts
+++ b/e2e/cycle-insights-mobile.spec.ts
@@ -1,20 +1,29 @@
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+import {
+ expectNoHorizontalOverflow,
+ PHONE_WIDTHS,
+ settleForOverflowMeasurement,
+} from "./utils/horizontal-overflow";
/**
- * Pixel-5 mobile smoke for the Insights overview + the cycle vertical
- * (v1.15.10). Mirrors `mobile-viewport.spec.ts`. Asserts:
+ * Mobile smoke for the cycle vertical (v1.15.10). Asserts:
*
- * 1. `/insights` has no horizontal page scroll on the Pixel-5 width — the
- * core guarantee behind the cycle-ring-width fix (the 120 px ring tile
- * used to overflow narrow grid cells).
- * 2. `/cycle` has no horizontal page scroll on the Pixel-5 width — covers
+ * 1. `/cycle` has no horizontal page scroll at any phone width — covers
* the tab-strip-overflow fix (the four long German labels used to force
* a horizontal scroll at 375 px) and the responsive cycle ring.
- * 3. The cycle-calendar day cells clear the WCAG 2.5.5 mobile tap-target
+ * 2. The cycle-calendar day cells clear the WCAG 2.5.5 mobile tap-target
* height floor (44 CSS px) — the `min-h-10` → `min-h-11` bump.
*
+ * The `/insights` width case that used to live here has moved to
+ * `mobile-horizontal-overflow.spec.ts`. It asserted against an EMPTY insights
+ * payload and against `documentElement.scrollWidth`, so it was green through a
+ * populated page that really did pan sideways inside the `AuthShell` ``
+ * scroll container. The replacement runs populated fixtures at several phone
+ * widths and measures the scroll container the app actually uses. The cycle
+ * fixture below is populated for the same reason.
+ *
* The cycle feature gate is enabled by rewriting the real `/api/auth/me`
* response with `cycleTrackingEnabled: true` (every other field stays
* authentic), and the `/api/cycle/*` reads are mocked so the wheel, tab strip,
@@ -108,22 +117,48 @@ const HISTORY_BODY = {
};
const INSIGHTS_BODY = {
- data: { rows: [], headline: null, symptomPatterns: [] },
+ data: {
+ rows: buildInsightRows(),
+ headline: {
+ symptom: "HEADACHE",
+ phase: "LUTEAL",
+ rate: 0.3,
+ confidence: "high",
+ },
+ symptomPatterns: buildInsightRows(),
+ },
error: null,
};
-async function assertNoHorizontalScroll(page: import("@playwright/test").Page) {
- const dims = await page.evaluate(() => ({
- scrollWidth: document.documentElement.scrollWidth,
- innerWidth: window.innerWidth,
+/**
+ * A populated cycle-insights payload. The previous fixture was
+ * `{ rows: [], headline: null, symptomPatterns: [] }` — an empty page, which
+ * fits any viewport by construction and proved nothing about the width
+ * guarantee it was written for. Long labels are deliberate: the tab strip and
+ * the summary rows are where narrow-width text pressure shows up.
+ */
+function buildInsightRows(): Array> {
+ const symptoms = [
+ "HEADACHE",
+ "CRAMPS",
+ "BLOATING",
+ "FATIGUE",
+ "MOOD_SWINGS",
+ "BREAST_TENDERNESS",
+ "BACK_PAIN",
+ "ACNE",
+ ];
+ return symptoms.map((symptom, i) => ({
+ symptom,
+ phase: ["MENSTRUAL", "FOLLICULAR", "OVULATORY", "LUTEAL"][i % 4],
+ occurrences: 12 - i,
+ totalDays: 40,
+ rate: (12 - i) / 40,
+ confidence: i < 4 ? "high" : "low",
}));
- expect(
- dims.scrollWidth,
- `scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`,
- ).toBeLessThanOrEqual(dims.innerWidth + 1);
}
-test.describe("cycle + insights mobile smoke", () => {
+test.describe("cycle mobile smoke", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({ page }, testInfo) => {
@@ -175,24 +210,26 @@ test.describe("cycle + insights mobile smoke", () => {
);
});
- test("/insights has no horizontal scroll on Pixel-5", async ({ page }) => {
- await page.goto("/insights", { waitUntil: "domcontentloaded" });
- await page.waitForLoadState("networkidle");
- await assertNoHorizontalScroll(page);
- });
-
- test("/cycle has no horizontal scroll and the calendar cells clear the 44 px tap floor", async ({
+ test("/cycle has no horizontal scroll at any phone width and the calendar cells clear the 44 px tap floor", async ({
page,
}) => {
+ for (const width of PHONE_WIDTHS) {
+ await page.setViewportSize({ width, height: 851 });
+ await page.goto("/cycle", { waitUntil: "domcontentloaded" });
+
+ // The cycle vertical mounted (the page redirects home if the gate is off).
+ const wheel = page.locator('[data-slot="cycle-wheel-tile"]');
+ await expect(wheel).toBeVisible();
+
+ await settleForOverflowMeasurement(page);
+ await expectNoHorizontalOverflow(page, `/cycle @${width}px`);
+ }
+
+ // Back to the Pixel-5 width for the tap-target measurement.
+ await page.setViewportSize({ width: 393, height: 851 });
await page.goto("/cycle", { waitUntil: "domcontentloaded" });
- await page.waitForLoadState("networkidle");
-
- // The cycle vertical mounted (the page redirects home if the gate is off).
- const wheel = page.locator('[data-slot="cycle-wheel-tile"]');
- await expect(wheel).toBeVisible();
-
- // 1) No horizontal page scroll at the Pixel-5 width.
- await assertNoHorizontalScroll(page);
+ await expect(page.locator('[data-slot="cycle-wheel-tile"]')).toBeVisible();
+ await page.waitForLoadState("networkidle").catch(() => {});
// 2) Calendar day cells clear the 44 px tap-target height floor.
const cells = await page.locator('[role="gridcell"]').all();
diff --git a/e2e/mobile-density.spec.ts b/e2e/mobile-density.spec.ts
index ebe54672a..af0e0836d 100644
--- a/e2e/mobile-density.spec.ts
+++ b/e2e/mobile-density.spec.ts
@@ -8,6 +8,7 @@ import {
POPULATED_SUMMARIES,
} from "./utils/mock-dashboard-snapshot";
import { mockMoodInsights } from "./utils/mock-mood-insights";
+import { expectNoHorizontalOverflow } from "./utils/horizontal-overflow";
/**
* Phone-width density guard (390×844 iPhone class, 360×780 small
@@ -17,8 +18,12 @@ import { mockMoodInsights } from "./utils/mock-mood-insights";
* squeezed into a one-word-per-line column beside a wide delta, the
* delta spilling past the tile edge at 360 px):
*
- * 1. No horizontal page overflow — `scrollWidth` never exceeds the
- * viewport width (1 px sub-pixel tolerance).
+ * 1. No horizontal page overflow — neither the document nor the `AuthShell`
+ * `` scroll container exceeds the viewport width (1 px sub-pixel
+ * tolerance). The `` half is load-bearing: this guard measured only
+ * the document until a report of sideways scroll on a populated Insights
+ * page showed the document never widens, because `` absorbs the
+ * overflow into its own scrollable area.
* 2. Nothing escapes its tile — for every card / Today hero / list
* row, no descendant's visible box crosses the tile's left or
* right edge (clip-aware: content genuinely hidden by an inner
@@ -181,19 +186,24 @@ test.describe("phone-width density guard", () => {
).toBeVisible();
}
- const dims = await page.evaluate(() => ({
- scrollWidth: document.documentElement.scrollWidth,
- bodyScrollWidth: document.body.scrollWidth,
- innerWidth: window.innerWidth,
- }));
+ // The document/body pair alone cannot see the app's sideways scroll:
+ // `AuthShell` scrolls inside ``, whose
+ // `overflow-y:auto` makes `overflow-x` compute to `auto`, so an
+ // over-wide child grows `main.scrollWidth` while the document stays
+ // exactly viewport-wide. The shared helper asserts both areas and
+ // names the offending element.
+ await expectNoHorizontalOverflow(
+ page,
+ `${route} @${viewport.width}x${viewport.height}`,
+ );
+ const bodyScrollWidth = await page.evaluate(
+ () => document.body.scrollWidth,
+ );
+ const innerWidth = await page.evaluate(() => window.innerWidth);
expect(
- dims.scrollWidth,
- `page scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`,
- ).toBeLessThanOrEqual(dims.innerWidth + 1);
- expect(
- dims.bodyScrollWidth,
- `body scrollWidth=${dims.bodyScrollWidth}`,
- ).toBeLessThanOrEqual(dims.innerWidth + 1);
+ bodyScrollWidth,
+ `body scrollWidth=${bodyScrollWidth}`,
+ ).toBeLessThanOrEqual(innerWidth + 1);
const escapes = await page.evaluate(collectTileEscapes);
expect(
diff --git a/e2e/mobile-horizontal-overflow.spec.ts b/e2e/mobile-horizontal-overflow.spec.ts
new file mode 100644
index 000000000..857209a20
--- /dev/null
+++ b/e2e/mobile-horizontal-overflow.spec.ts
@@ -0,0 +1,202 @@
+import { expect, test } from "./setup/test";
+
+import { STORAGE_STATE_PATH } from "./setup/global-setup";
+import { mockDashboardSnapshot } from "./utils/mock-dashboard-snapshot";
+import { mockPopulatedInsights } from "./utils/mock-populated-insights";
+import {
+ expectNoHorizontalOverflow,
+ measureHorizontalOverflow,
+ PHONE_WIDTHS,
+ settleForOverflowMeasurement,
+} from "./utils/horizontal-overflow";
+
+/**
+ * The content-heavy routes must not scroll sideways at any phone width.
+ *
+ * This replaces a per-surface habit of asserting
+ * `documentElement.scrollWidth <= innerWidth` on whatever happened to render.
+ * That assertion had two holes, and a real report walked through both:
+ *
+ * 1. It measured the DOCUMENT. `AuthShell` scrolls inside
+ * ``, whose `overflow-y:auto` makes `overflow-x`
+ * compute to `auto` — so an over-wide child grows `main.scrollWidth`, the
+ * user pans the page sideways, and the document never widens by a pixel.
+ * 2. It ran against an EMPTY page. A surface with no content fits any
+ * viewport, so the guard proved nothing about the surface people use.
+ *
+ * `expectNoHorizontalOverflow` closes (1) by asserting both scroll areas and
+ * naming the widest offending element on failure. The fixtures below close (2)
+ * by giving each route a body before it is measured.
+ *
+ * Widths cover real iPhone (390 / 393 / 402 / 430) and common Android (360)
+ * CSS widths — a single width hides anything whose overflow is width-dependent.
+ * Runs on `chromium-mobile` only; the desktop project skips the whole file.
+ */
+
+interface RouteCase {
+ path: string;
+ /** Something that must be on-screen before measuring — proves it is populated. */
+ ready: string;
+ setup?: (page: import("@playwright/test").Page) => Promise;
+}
+
+const ROUTE_CASES: RouteCase[] = [
+ {
+ path: "/insights",
+ // The "signals of the day" section is the one whose heading carries the
+ // long provenance sentence, so it is also the readiness signal.
+ ready: '[data-slot="coincident-deviation-section"]',
+ setup: mockPopulatedInsights,
+ },
+ {
+ path: "/",
+ ready: "main#main-content",
+ setup: (page) => mockDashboardSnapshot(page),
+ },
+ { path: "/measurements", ready: "main#main-content" },
+ { path: "/medications", ready: "main#main-content" },
+];
+
+test.describe("content-heavy routes have no horizontal page scroll at phone widths", () => {
+ test.use({ storageState: STORAGE_STATE_PATH });
+
+ test.beforeEach(async ({}, testInfo) => {
+ test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
+ });
+
+ for (const routeCase of ROUTE_CASES) {
+ test(`${routeCase.path} does not scroll sideways on any phone width`, async ({
+ page,
+ }) => {
+ await routeCase.setup?.(page);
+
+ for (const width of PHONE_WIDTHS) {
+ await page.setViewportSize({ width, height: 851 });
+ await page.goto(routeCase.path, { waitUntil: "domcontentloaded" });
+ await expect(page.locator(routeCase.ready).first()).toBeVisible();
+ await settleForOverflowMeasurement(page);
+ await expectNoHorizontalOverflow(page, `${routeCase.path} @${width}px`);
+ }
+ });
+ }
+
+ /**
+ * The fixture itself has to stay honest. If `mockPopulatedInsights` ever
+ * stops populating the overview — a renamed route, a changed envelope — the
+ * width assertions above would keep passing against an empty page and the
+ * guard would quietly go back to proving nothing. Assert the page is
+ * actually carrying content, so fixture rot fails loudly instead.
+ */
+ test("the /insights fixture really populates the overview", async ({
+ page,
+ }) => {
+ await mockPopulatedInsights(page);
+ await page.setViewportSize({ width: 390, height: 851 });
+ await page.goto("/insights", { waitUntil: "domcontentloaded" });
+
+ const signals = page.locator('[data-slot="coincident-deviation-section"]');
+ await expect(signals).toBeVisible();
+
+ // The prose in the heading's trailing action slot — the shape that widened
+ // the page. It must be present AND long enough to be able to.
+ const method = signals.locator('[data-slot="provenance-explainer-method"]');
+ await expect(method).toBeVisible();
+ const text = (await method.textContent()) ?? "";
+ expect(
+ text.length,
+ `provenance caption too short to exercise the overflow class: "${text}"`,
+ ).toBeGreaterThan(60);
+
+ // And the surface as a whole is not the empty state.
+ await expect(
+ page.locator('[data-slot="section-heading"]').first(),
+ ).toBeVisible();
+ const headingCount = await page
+ .locator('[data-slot="section-heading"]')
+ .count();
+ expect(headingCount, "overview rendered no sections").toBeGreaterThan(1);
+ });
+
+ /**
+ * A long trailing caption must WRAP inside the row, not extend it. Asserting
+ * the wrap directly (rather than only the page-level width) pins the fix at
+ * the element that broke: a `shrink-0` action slot sized itself to the
+ * caption's max-content width, ~1.2-1.5k px depending on locale.
+ */
+ test("a long heading action caption wraps instead of widening the row", async ({
+ page,
+ }) => {
+ await mockPopulatedInsights(page);
+ await page.setViewportSize({ width: 390, height: 851 });
+ await page.goto("/insights", { waitUntil: "domcontentloaded" });
+
+ const heading = page
+ .locator(
+ '[data-slot="coincident-deviation-section"] [data-slot="section-heading"]',
+ )
+ .first();
+ await expect(heading).toBeVisible();
+
+ const box = await heading.boundingBox();
+ const method = heading.locator('[data-slot="provenance-explainer-method"]');
+ const methodBox = await method.boundingBox();
+ expect(box, "heading has no box").not.toBeNull();
+ expect(methodBox, "provenance caption has no box").not.toBeNull();
+
+ expect(
+ Math.round(methodBox!.width),
+ `caption width ${methodBox!.width} exceeds its heading row ${box!.width} — the action slot is sizing to max-content instead of wrapping`,
+ ).toBeLessThanOrEqual(Math.round(box!.width) + 1);
+ expect(
+ Math.round(box!.width),
+ "heading row is wider than the viewport",
+ ).toBeLessThanOrEqual(390);
+ });
+
+ /**
+ * The helper must be able to SEE overflow through the `` scroll
+ * container — the exact blind spot that let the previous guard pass. Inject
+ * a deliberately over-wide, un-contained block and assert the measurement
+ * reports it. Without this, a helper that silently measured the wrong
+ * element would make every assertion above vacuous.
+ */
+ test("the overflow measurement detects an over-wide block inside ", async ({
+ page,
+ }) => {
+ await mockPopulatedInsights(page);
+ await page.setViewportSize({ width: 390, height: 851 });
+ await page.goto("/insights", { waitUntil: "domcontentloaded" });
+ await expect(page.locator("main#main-content")).toBeVisible();
+
+ const clean = await measureHorizontalOverflow(page);
+ expect(clean.mainScrollWidth).toBeLessThanOrEqual(
+ (clean.mainClientWidth ?? 0) + 1,
+ );
+
+ await page.evaluate(() => {
+ const probe = document.createElement("div");
+ probe.id = "overflow-canary";
+ probe.style.width = "1500px";
+ probe.style.height = "10px";
+ document.querySelector("main#main-content")!.appendChild(probe);
+ });
+
+ const dirty = await measureHorizontalOverflow(page);
+ expect(
+ dirty.mainScrollWidth,
+ "an intentionally 1500px-wide block did not widen main's scroll area — the helper is measuring the wrong element",
+ ).toBeGreaterThan((dirty.mainClientWidth ?? 0) + 1);
+ expect(
+ dirty.offenders.some((o) => o.selector.includes("overflow-canary")),
+ `the canary was not reported as an offender; got: ${dirty.offenders
+ .map((o) => o.selector)
+ .join(" | ")}`,
+ ).toBe(true);
+ // The document stayed exactly as wide as the viewport while `` grew
+ // by 1500px — this is the blind spot, asserted rather than described.
+ expect(
+ dirty.docScrollWidth,
+ "document width changed, so the document-only assertion would have caught this after all",
+ ).toBeLessThanOrEqual(dirty.innerWidth + 1);
+ });
+});
diff --git a/e2e/utils/horizontal-overflow.ts b/e2e/utils/horizontal-overflow.ts
new file mode 100644
index 000000000..f957d8c96
--- /dev/null
+++ b/e2e/utils/horizontal-overflow.ts
@@ -0,0 +1,205 @@
+import { expect, type Page } from "@playwright/test";
+
+/**
+ * Shared horizontal-overflow guard for phone widths.
+ *
+ * WHY THIS MEASURES `` AND NOT JUST THE DOCUMENT
+ * ----------------------------------------------------
+ * The obvious assertion — `documentElement.scrollWidth <= innerWidth` — cannot
+ * observe the app's real sideways scroll. `AuthShell` puts every route inside
+ *
+ *
+ *
+ * and CSS says that when one of `overflow-x` / `overflow-y` is not `visible`,
+ * the other computes to `auto`. So `` is itself a horizontal scroll
+ * container: an over-wide child grows `main.scrollWidth`, the user pans the
+ * page sideways, and `documentElement.scrollWidth` never budges because
+ * `` absorbed the overflow. A document-only assertion is green through
+ * exactly the defect a person reports as "I can scroll sideways off the page".
+ *
+ * Both measurements are therefore asserted. On failure the helper names the
+ * widest element that is actually extending the scroll area (skipping anything
+ * an ancestor already scrolls or clips — a strip that is *meant* to pan inside
+ * its own `overflow-x-auto` container is not a defect), with its measured
+ * width and the viewport width, so a future failure is actionable without a
+ * screenshot.
+ */
+
+/** Real iPhone + common Android CSS widths. */
+export const PHONE_WIDTHS = [360, 390, 393, 402, 430] as const;
+
+export interface OverflowOffender {
+ selector: string;
+ rectWidth: number;
+ rectRight: number;
+ scrollWidth: number;
+ path: string;
+}
+
+export interface OverflowReport {
+ innerWidth: number;
+ docScrollWidth: number;
+ /** null when the route renders outside `AuthShell` (no ``). */
+ mainScrollWidth: number | null;
+ mainClientWidth: number | null;
+ offenders: OverflowOffender[];
+}
+
+/**
+ * Measure horizontal overflow of the document AND of the `AuthShell` scroll
+ * container, plus the elements responsible.
+ */
+export async function measureHorizontalOverflow(
+ page: Page,
+): Promise {
+ return page.evaluate(() => {
+ const vw = window.innerWidth;
+ const de = document.documentElement;
+ const main = document.querySelector("main#main-content");
+
+ const report = {
+ innerWidth: vw,
+ docScrollWidth: de.scrollWidth,
+ mainScrollWidth: main ? main.scrollWidth : null,
+ mainClientWidth: main ? main.clientWidth : null,
+ offenders: [] as Array<{
+ selector: string;
+ rectWidth: number;
+ rectRight: number;
+ scrollWidth: number;
+ path: string;
+ }>,
+ };
+
+ const overflows =
+ de.scrollWidth > vw + 1 ||
+ (main !== null && main.scrollWidth > main.clientWidth + 1);
+ if (!overflows) return report;
+
+ const describe = (el: Element): string => {
+ const parts = [el.tagName.toLowerCase()];
+ if (el.id) parts.push(`#${el.id}`);
+ const slot = el.getAttribute("data-slot");
+ if (slot) parts.push(`[data-slot="${slot}"]`);
+ const cls = (el.getAttribute("class") ?? "").trim();
+ if (cls) parts.push(`.${cls.split(/\s+/).slice(0, 10).join(".")}`);
+ return parts.join("").slice(0, 180);
+ };
+
+ const root = main ?? document.body;
+ const limit = main
+ ? main.getBoundingClientRect().left + main.clientWidth
+ : vw;
+ const scrollLeft = main ? main.scrollLeft : window.scrollX;
+
+ // An element whose ancestor (below `root`) scrolls or clips is contained by
+ // design — the UI standards' "wide content scrolls in its own container".
+ const absorbedByAncestor = (el: Element): boolean => {
+ let n = el.parentElement;
+ while (n && n !== root) {
+ const ox = getComputedStyle(n).overflowX;
+ if (
+ ox === "auto" ||
+ ox === "scroll" ||
+ ox === "hidden" ||
+ ox === "clip"
+ )
+ return true;
+ n = n.parentElement;
+ }
+ return false;
+ };
+
+ for (const el of Array.from(root.querySelectorAll("*"))) {
+ const r = el.getBoundingClientRect();
+ if (r.width === 0 && r.height === 0) continue;
+ // Fixed/sticky chrome is positioned against the viewport, not the
+ // scroll area, so it cannot widen it.
+ const position = getComputedStyle(el).position;
+ if (position === "fixed" || position === "sticky") continue;
+ const right = r.right + scrollLeft;
+ if (right <= limit + 1) continue;
+ if (absorbedByAncestor(el)) continue;
+
+ const chain: string[] = [];
+ let n: Element | null = el;
+ while (n && n !== de) {
+ chain.unshift(describe(n).slice(0, 70));
+ n = n.parentElement;
+ }
+ report.offenders.push({
+ selector: describe(el),
+ rectWidth: Math.round(r.width),
+ rectRight: Math.round(right),
+ scrollWidth: el.scrollWidth,
+ path: chain.slice(-5).join(" > "),
+ });
+ }
+
+ report.offenders.sort((a, b) => b.rectWidth - a.rectWidth);
+ report.offenders = report.offenders.slice(0, 5);
+ return report;
+ });
+}
+
+function formatReport(label: string, report: OverflowReport): string {
+ const lines = [
+ `${label}: horizontal overflow at viewport ${report.innerWidth}px`,
+ ` document.scrollWidth = ${report.docScrollWidth} (viewport ${report.innerWidth})`,
+ ` main.scrollWidth = ${report.mainScrollWidth} (client ${report.mainClientWidth})`,
+ ];
+ if (report.offenders.length === 0) {
+ lines.push(
+ " no un-contained offender found — the overflow may come from a scroll container's own content",
+ );
+ }
+ for (const o of report.offenders) {
+ lines.push(
+ ` • width ${o.rectWidth}px, right edge ${o.rectRight}px — ${o.selector}`,
+ );
+ lines.push(` path: ${o.path}`);
+ }
+ return lines.join("\n");
+}
+
+/**
+ * Assert the page does not scroll sideways — neither the document nor the
+ * `AuthShell` `` scroll container.
+ */
+export async function expectNoHorizontalOverflow(
+ page: Page,
+ label: string,
+): Promise {
+ const report = await measureHorizontalOverflow(page);
+ const message = formatReport(label, report);
+
+ expect(report.docScrollWidth, message).toBeLessThanOrEqual(
+ report.innerWidth + 1,
+ );
+ if (report.mainScrollWidth !== null && report.mainClientWidth !== null) {
+ expect(report.mainScrollWidth, message).toBeLessThanOrEqual(
+ report.mainClientWidth + 1,
+ );
+ }
+}
+
+/**
+ * Settle a route before measuring: let the network go idle, pan the whole
+ * scroll container so every `next/dynamic` below-the-fold block mounts, then
+ * return to the top. A block that only mounts on scroll can only overflow once
+ * it has mounted.
+ */
+export async function settleForOverflowMeasurement(page: Page): Promise {
+ await page.waitForLoadState("networkidle").catch(() => {});
+ await page.evaluate(async () => {
+ const main = document.querySelector("main#main-content");
+ const el: HTMLElement = main ?? document.documentElement;
+ const step = el.clientHeight || 800;
+ for (let y = 0; y < el.scrollHeight; y += step) {
+ el.scrollTop = y;
+ await new Promise((r) => setTimeout(r, 100));
+ }
+ el.scrollTop = 0;
+ });
+ await page.waitForLoadState("networkidle").catch(() => {});
+}
diff --git a/e2e/utils/mock-populated-insights.ts b/e2e/utils/mock-populated-insights.ts
new file mode 100644
index 000000000..0968f2e9b
--- /dev/null
+++ b/e2e/utils/mock-populated-insights.ts
@@ -0,0 +1,268 @@
+import type { Page } from "@playwright/test";
+
+import {
+ SECTION_MOBILITY,
+ SECTION_VITALS,
+} from "@/components/insights/derived/use-dashboard-derived";
+
+/**
+ * A POPULATED Insights overview fixture.
+ *
+ * The mobile-overflow guard exists to prove that a page carrying real content
+ * does not scroll sideways. Against the shared e2e account — which owns no
+ * measurements — `/insights` resolves to its empty state, and an empty page
+ * trivially fits any viewport. Every section below therefore gets a body:
+ * populated wellness scores, a full vitals grid, and the "signals of the day"
+ * card whose heading carries the long plain-language provenance caption.
+ *
+ * The strings are deliberately long. Prose in a heading's trailing `action`
+ * slot is exactly what widened the page in the first place, and a fixture with
+ * short labels would not reproduce it.
+ */
+
+const NOW = "2026-01-15T08:30:00.000Z";
+
+function provenance(inputs: string[]) {
+ return {
+ inputs,
+ source: "DAY" as const,
+ windowDays: 30,
+ computedAt: NOW,
+ };
+}
+
+function coverage() {
+ return {
+ requiredInputs: 4,
+ presentInputs: 4,
+ historyDays: 90,
+ missing: [] as string[],
+ };
+}
+
+function confidence() {
+ return { score: 88, band: "high" as const };
+}
+
+function deviation(
+ type: string,
+ value: number,
+ low: number,
+ high: number,
+ direction: "above" | "below" | "in",
+) {
+ return {
+ type,
+ value,
+ center: (low + high) / 2,
+ low,
+ high,
+ outside: direction !== "in",
+ direction,
+ };
+}
+
+/**
+ * The "signals of the day" payload in its `fired` state — the branch that
+ * renders `SectionHeading action={}`, i.e. a full
+ * sentence pinned into the heading row.
+ */
+const COINCIDENT_FIRED = {
+ metric: "COINCIDENT_DEVIATION",
+ status: "ok" as const,
+ value: {
+ fired: true,
+ day: "2026-01-15",
+ illnessExplained: false,
+ vitals: [
+ deviation("RESTING_HEART_RATE", 71, 52, 62, "above"),
+ deviation("HEART_RATE_VARIABILITY", 38, 45, 78, "below"),
+ deviation("RESPIRATORY_RATE", 17.4, 12.5, 16.2, "above"),
+ deviation("OXYGEN_SATURATION", 97, 96, 99, "in"),
+ deviation("BODY_TEMPERATURE", 36.9, 36.2, 37.1, "in"),
+ ],
+ contributing: [
+ deviation("RESTING_HEART_RATE", 71, 52, 62, "above"),
+ deviation("HEART_RATE_VARIABILITY", 38, 45, 78, "below"),
+ deviation("RESPIRATORY_RATE", 17.4, 12.5, 16.2, "above"),
+ ],
+ },
+ coverage: coverage(),
+ confidence: confidence(),
+ provenance: provenance([
+ "RESTING_HEART_RATE",
+ "HEART_RATE_VARIABILITY",
+ "RESPIRATORY_RATE",
+ ]),
+ reason: null,
+};
+
+/** A deterministic sparkline series around `center`. */
+function series(center: number, spread: number): number[] {
+ return Array.from({ length: 30 }, (_, i) =>
+ Number((center + Math.sin(i / 2.5) * spread).toFixed(2)),
+ );
+}
+
+/**
+ * A populated `WellnessScoreValue` entry — the exact wire shape the three
+ * persisted score types share (`RECOVERY_SCORE` / `STRESS_SCORE` /
+ * `STRAIN_SCORE`).
+ */
+function wellnessScore(
+ metric: string,
+ score: number,
+ band: "green" | "yellow" | "red",
+) {
+ return {
+ metric,
+ status: "ok" as const,
+ value: {
+ score,
+ band,
+ trendDelta: 3.4,
+ daysInWindow: 28,
+ asOf: NOW,
+ series: series(score, 6),
+ },
+ coverage: coverage(),
+ confidence: confidence(),
+ provenance: provenance(["SLEEP_DURATION", "RESTING_HEART_RATE"]),
+ reason: null,
+ assessment: null,
+ };
+}
+
+/** A populated `VitalsBaselineValue` tile — exact wire shape. */
+function vitalsBaseline(type: string, center: number, spread: number) {
+ return {
+ metric: "VITALS_BASELINE",
+ status: "ok" as const,
+ value: {
+ type,
+ center,
+ low: Number((center - spread).toFixed(2)),
+ high: Number((center + spread).toFixed(2)),
+ spread,
+ sampleDays: 30,
+ k: 2,
+ series: series(center, spread / 2),
+ },
+ coverage: coverage(),
+ confidence: confidence(),
+ provenance: provenance([type]),
+ reason: null,
+ };
+}
+
+/**
+ * The honest "not enough history" arm. Metrics whose value shapes this fixture
+ * does not reproduce exactly are served as `insufficient` rather than as an
+ * invented payload — that is a real state those tiles handle (they un-mount),
+ * and a wrong-shaped `ok` payload would throw in the tile instead of testing
+ * anything.
+ */
+function insufficient(metric: string) {
+ return {
+ metric,
+ status: "insufficient" as const,
+ value: null,
+ coverage: {
+ requiredInputs: 4,
+ presentInputs: 1,
+ historyDays: 3,
+ missing: ["SLEEP_DURATION"],
+ },
+ confidence: null,
+ provenance: provenance([]),
+ reason: "not_enough_history",
+ };
+}
+
+/** Center + spread per vital, in each metric's own units. */
+const VITAL_BANDS: Record = {
+ RESTING_HEART_RATE: [57, 5],
+ RESPIRATORY_RATE: [14.1, 1.8],
+ OXYGEN_SATURATION: [97, 1.5],
+ BODY_TEMPERATURE: [36.6, 0.45],
+ BLOOD_GLUCOSE: [92, 13],
+ WEIGHT: [79.4, 3],
+};
+
+function buildBatchMetrics(): Record {
+ const metrics: Record = {
+ RECOVERY_SCORE: wellnessScore("RECOVERY_SCORE", 66, "yellow"),
+ STRESS_SCORE: wellnessScore("STRESS_SCORE", 41, "green"),
+ STRAIN_SCORE: wellnessScore("STRAIN_SCORE", 72, "yellow"),
+ READINESS: insufficient("READINESS"),
+ SLEEP_SCORE: insufficient("SLEEP_SCORE"),
+ FITNESS_AGE: insufficient("FITNESS_AGE"),
+ VASCULAR_AGE_DELTA: insufficient("VASCULAR_AGE_DELTA"),
+ HRV_BALANCE: insufficient("HRV_BALANCE"),
+ BMI: insufficient("BMI"),
+ SIX_MINUTE_WALK_BAND: insufficient("SIX_MINUTE_WALK_BAND"),
+ };
+ for (const type of SECTION_VITALS) {
+ if (type === "HEART_RATE_VARIABILITY") continue;
+ const [center, spread] = VITAL_BANDS[type] ?? [50, 5];
+ metrics[`VITALS_BASELINE:${type}`] = vitalsBaseline(type, center, spread);
+ }
+ for (const { metric } of SECTION_MOBILITY) {
+ metrics[metric] = insufficient(metric);
+ }
+ return metrics;
+}
+
+/**
+ * Route-mock a populated Insights overview onto `page`.
+ *
+ * Only the reads that decide whether a section renders AT ALL are stubbed;
+ * everything else (the i18n bundle, every layout decision, the real component
+ * tree) stays authentic, so the geometry under test is the shipped geometry.
+ */
+export async function mockPopulatedInsights(page: Page): Promise {
+ // Without a non-empty comprehensive payload the page short-circuits to its
+ // "no data yet" empty state and no section mounts at all.
+ // RegExp matchers throughout, not globs: Playwright's URL glob treats `?` as
+ // a single-character wildcard, so `**/api/insights/derived?*` also matches
+ // `/api/insights/derived/batch?…` and the two handlers shadow each other.
+ await page.route(/\/api\/insights\/comprehensive(\?|$)/, (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ data: {
+ totalMeasurements: 4821,
+ moodSummary: { count: 96 },
+ medications: [{ id: "med-1" }, { id: "med-2" }, { id: "med-3" }],
+ },
+ error: null,
+ }),
+ }),
+ );
+
+ // The batched derived read behind the wellness strip + the vitals grid.
+ await page.route(/\/api\/insights\/derived\/batch(\?|$)/, (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ data: { metrics: buildBatchMetrics() },
+ error: null,
+ }),
+ }),
+ );
+
+ // The single-metric derived route. Only COINCIDENT_DEVIATION is stubbed —
+ // it is the card whose heading carries the provenance sentence. Any other
+ // metric falls through to the real handler.
+ await page.route(/\/api\/insights\/derived\?/, async (route) => {
+ const metric = new URL(route.request().url()).searchParams.get("metric");
+ if (metric !== "COINCIDENT_DEVIATION") return route.fallback();
+ return route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ data: COINCIDENT_FIRED, error: null }),
+ });
+ });
+}
diff --git a/src/components/insights/section-heading.tsx b/src/components/insights/section-heading.tsx
index 67a4260e3..441efbd54 100644
--- a/src/components/insights/section-heading.tsx
+++ b/src/components/insights/section-heading.tsx
@@ -82,7 +82,20 @@ export function SectionHeading({
{action ? (
-
{action}
+ // The action slot must be able to SHRINK. It was `shrink-0`, which
+ // pins a flex item at its max-content width — fine for the compact
+ // control the slot was designed for, wrong for the prose every real
+ // caller passes today (the provenance method caption, the
+ // device-score subtitle, the sleep-quality subtitle). A sentence at
+ // max-content is ~1.2-1.5k px, and because the row sits inside the
+ // AuthShell `` — whose `overflow-y:auto` makes `overflow-x`
+ // compute to `auto` — that width became a horizontally scrollable
+ // page at every phone width, growing with the locale's string length.
+ // `min-w-0` lifts the flex `min-width:auto` floor so the text can wrap
+ // to the row width instead. The parent's `flex-wrap` still moves the
+ // slot onto its own line before anything is squeezed, so a compact
+ // control keeps its size.
+
{action}
) : null}
);
From c941e7e5cb68e9210aaea67933058e0cb1d5d1a3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 00:38:49 +0200
Subject: [PATCH 27/52] test(e2e): measure the heading caption atomically
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The wrap assertion gated on the section heading, but the signals card paints a
skeleton heading first and swaps it for the resolved one — so the element could
detach between the visibility check and the measurement, and the skeleton
heading carries no action slot to measure in the first place. Gate on the
caption itself and read both rects in a single evaluate.
---
e2e/mobile-horizontal-overflow.spec.ts | 39 +++++++++++++++++---------
1 file changed, 26 insertions(+), 13 deletions(-)
diff --git a/e2e/mobile-horizontal-overflow.spec.ts b/e2e/mobile-horizontal-overflow.spec.ts
index 857209a20..e4f0758a4 100644
--- a/e2e/mobile-horizontal-overflow.spec.ts
+++ b/e2e/mobile-horizontal-overflow.spec.ts
@@ -130,27 +130,40 @@ test.describe("content-heavy routes have no horizontal page scroll at phone widt
await page.setViewportSize({ width: 390, height: 851 });
await page.goto("/insights", { waitUntil: "domcontentloaded" });
- const heading = page
+ // Wait on the CAPTION, not the heading: the card first paints a skeleton
+ // whose heading carries no action slot, then swaps to the resolved shell.
+ // Both render `[data-slot="section-heading"]`, so gating on the heading
+ // alone can measure the skeleton — or measure nothing at all, because the
+ // element detaches during the swap.
+ const method = page
.locator(
- '[data-slot="coincident-deviation-section"] [data-slot="section-heading"]',
+ '[data-slot="coincident-deviation-section"] [data-slot="provenance-explainer-method"]',
)
.first();
- await expect(heading).toBeVisible();
+ await expect(method).toBeVisible();
- const box = await heading.boundingBox();
- const method = heading.locator('[data-slot="provenance-explainer-method"]');
- const methodBox = await method.boundingBox();
- expect(box, "heading has no box").not.toBeNull();
- expect(methodBox, "provenance caption has no box").not.toBeNull();
+ // Read both rects in ONE evaluate so the swap cannot land between them.
+ const box = await method.evaluate((el) => {
+ const headingEl = el.closest('[data-slot="section-heading"]');
+ if (!headingEl) return null;
+ const caption = el.getBoundingClientRect();
+ const heading = headingEl.getBoundingClientRect();
+ return {
+ captionWidth: caption.width,
+ headingWidth: heading.width,
+ viewportWidth: window.innerWidth,
+ };
+ });
+ expect(box, "caption is not inside a section heading").not.toBeNull();
expect(
- Math.round(methodBox!.width),
- `caption width ${methodBox!.width} exceeds its heading row ${box!.width} — the action slot is sizing to max-content instead of wrapping`,
- ).toBeLessThanOrEqual(Math.round(box!.width) + 1);
+ Math.round(box!.captionWidth),
+ `caption width ${box!.captionWidth} exceeds its heading row ${box!.headingWidth} — the action slot is sizing to max-content instead of wrapping`,
+ ).toBeLessThanOrEqual(Math.round(box!.headingWidth) + 1);
expect(
- Math.round(box!.width),
+ Math.round(box!.headingWidth),
"heading row is wider than the viewport",
- ).toBeLessThanOrEqual(390);
+ ).toBeLessThanOrEqual(box!.viewportWidth);
});
/**
From a1dbd9c8312521d38031ee9379b40b1d55855e58 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 10:31:45 +0200
Subject: [PATCH 28/52] fix(medications): apply the post-opening clock only
where a container has one
A supply container went EXPIRED 30 days after its first use no matter what
kind of container it was. That window exists because puncturing an injection
pen exposes its contents and the product information caps how long the rest
stays usable; a blister of tablets has no such limit, every tablet sits in
its own sealed cavity, and pressing the first one out says nothing about the
rest. The state machine never read `containerType`, so a tablet pack opened
five weeks ago had its remaining stock written off.
The clock now keys off the container kind. PEN and AMPOULE carry it: both are
a single sealed reservoir that the first dose breaches. BLISTER, INHALER,
BOTTLE and OTHER do not: their units stay individually sealed, or the
arrangement is simply unknown, and a fixed 30 days would be a number nobody
entered. The printed expiry still applies to every kind, so an unknown
container is not an unbounded one.
`computeExpiresAt` takes the kind too, so a clock-less container with no
printed date now persists no `expiresAt` at all and the daily expire scan
never sees it.
The per-item PATCH gains a way back out: `markAsFirstUseAt` accepts null and
clears the opening date, which is the correction a container needs after the
intake hook auto-opened it from a back-dated dose.
Also drops `daysRemainingInUse`. It had no caller anywhere in the tree and
its docblock described a chip that does not exist.
---
docs/api/openapi.yaml | 36 ++--
prisma/schema.prisma | 22 ++-
.../[id]/inventory/[itemId]/route.ts | 24 ++-
.../[id]/inventory/__tests__/route.test.ts | 74 +++++++
.../inventory/__tests__/consumption.test.ts | 27 +++
.../inventory/__tests__/state-machine.test.ts | 166 +++++++++-------
src/lib/medications/inventory/consumption.ts | 10 +-
src/lib/medications/inventory/service.ts | 15 +-
.../medications/inventory/state-machine.ts | 181 ++++++++++--------
src/lib/openapi/routes/medications/schemas.ts | 8 +-
src/lib/validations/medication/inventory.ts | 10 +-
11 files changed, 394 insertions(+), 179 deletions(-)
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index b87fd95c0..7be123662 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -12943,8 +12943,9 @@ components:
description: Units the container ships with (tablets / ampoules / puffs; 1–1000, fractional allowed for split-pill
packs). Dose-derived readouts divide by the medication's `unitsPerDose`.
containerType:
- description: Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level only;
- defaults to OTHER.
+ description: Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Defaults to OTHER. PEN and
+ AMPOULE are the sealed reservoirs the first dose breaches, so they carry a 30-day post-opening window; every
+ other kind expires only on its printed date.
type: string
enum:
- PEN
@@ -12991,11 +12992,15 @@ components:
type: object
properties:
markAsFirstUseAt:
- description: Manually start the 30-day in-use clock (the user opened the container without logging an intake). ACTIVE
- flips to IN_USE; a backdated instant whose window already lapsed lands EXPIRED.
- type: string
- format: date-time
- pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
+ description: Opening date of the container (the user opened it without logging an intake). ACTIVE flips to IN_USE.
+ `null` clears it — the container reads as unopened again, which is how a wrongly auto-opened row is
+ corrected. For a PEN or an AMPOULE this also starts / stops the 30-day in-use clock; other container kinds
+ carry no such clock and expire only on their printed date.
+ anyOf:
+ - type: string
+ format: date-time
+ pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
+ - type: "null"
markAsUsedUp:
description: "Terminal override: zero the remaining units and mark the container USED_UP (physically discarded)."
type: boolean
@@ -20474,8 +20479,9 @@ components:
- IN_USE
- EXPIRED
- USED_UP
- description: Container lifecycle state. ACTIVE = unopened; IN_USE = opened (30-day in-use clock running); EXPIRED =
- printed expiry or in-use window lapsed with units left; USED_UP = drained (terminal).
+ description: Container lifecycle state. ACTIVE = unopened; IN_USE = opened (on a PEN / AMPOULE the 30-day in-use clock
+ is running); EXPIRED = printed expiry lapsed, or the in-use window lapsed on a clock-bearing container, with
+ units left; USED_UP = drained (terminal).
containerType:
type: string
enum:
@@ -20485,8 +20491,10 @@ components:
- INHALER
- BOTTLE
- OTHER
- description: Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level
- classification; defaults to OTHER.
+ description: Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER); defaults to OTHER. PEN and
+ AMPOULE are the sealed reservoirs the first dose breaches, so opening one starts a 30-day post-opening
+ window; BLISTER / INHALER / BOTTLE / OTHER hold individually sealed units and expire only on their printed
+ date.
unitsTotal:
anyOf:
- type: number
@@ -20509,14 +20517,16 @@ components:
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
- description: Instant the container was first used. NULL until opened; starts the 30-day in-use clock.
+ description: Instant the container was first used. NULL until opened. On a PEN / AMPOULE it starts the 30-day in-use
+ clock; on the other kinds it only records that the pack is open.
expiresAt:
anyOf:
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
- description: Persisted MIN(firstUseAt + 30 days, printedExpiry). NULL when neither clock has started.
+ description: Persisted MIN(firstUseAt + 30 days, printedExpiry) for a clock-bearing container (PEN / AMPOULE), else
+ printedExpiry alone. NULL when no deadline applies — an opened blister with no printed date has none.
printedExpiry:
anyOf:
- type: string
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 3cb06cee8..d7eb6d929 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -2955,9 +2955,17 @@ enum MedicationInventoryState {
}
/// v1.16.10 — what kind of physical container an inventory item is.
-/// Display-level only (no state-machine behaviour hangs off it); the
-/// register dialog defaults it from the medication's delivery form
+/// The register dialog defaults it from the medication's delivery form
/// (INJECTION → PEN, ORAL → BLISTER, else OTHER).
+///
+/// The kind decides whether opening the container starts a
+/// post-opening degradation clock: PEN and AMPOULE are one sealed
+/// reservoir that the first dose breaches, so they carry the 30-day
+/// in-use window; BLISTER / INHALER / BOTTLE / OTHER hold individually
+/// sealed units (or an unidentified arrangement) and expire only on
+/// their printed date. See `IN_USE_CLOCK_CONTAINER_TYPES` in
+/// `src/lib/medications/inventory/state-machine.ts` for the per-member
+/// reasoning.
enum MedicationContainerType {
PEN
AMPOULE
@@ -2976,6 +2984,8 @@ model MedicationInventoryItem {
state MedicationInventoryState @default(ACTIVE)
/// v1.16.10 — container kind (pen / ampoule / blister pack / …).
/// Backfilled PEN for items on INJECTION medications, OTHER otherwise.
+ /// Read by the state machine: PEN / AMPOULE carry the post-opening
+ /// clock, the rest expire only on `printedExpiry`.
containerType MedicationContainerType @default(OTHER) @map("container_type")
/// Units the container shipped with (v1.16.10 — renamed from
/// `dosesTotal`; the DB column stays `doses_total`). A unit is one
@@ -2989,10 +2999,12 @@ model MedicationInventoryItem {
/// (v1.16.10 — renamed from `dosesRemaining`; column stays
/// `doses_remaining`). v1.16.12 (#316) — Decimal for fractional dosing.
unitsRemaining Decimal @map("doses_remaining") @db.Decimal(12, 4)
- /// NULL until the pen is first opened. Sets the 30-day in-use
- /// clock per EMA EPAR §6.3.
+ /// NULL until the container is first opened. On a PEN / AMPOULE it
+ /// also sets the 30-day in-use clock per EMA EPAR §6.3; on the other
+ /// kinds it records that the pack is open and nothing more.
firstUseAt DateTime? @map("first_use_at")
- /// Persisted = MIN(firstUseAt + 30 days, printedExpiry). Persisting
+ /// Persisted = MIN(firstUseAt + 30 days, printedExpiry) for a
+ /// clock-bearing container, else printedExpiry alone. Persisting
/// rather than deriving keeps the daily "expire-stale" cron a
/// single indexed scan.
expiresAt DateTime? @map("expires_at")
diff --git a/src/app/api/medications/[id]/inventory/[itemId]/route.ts b/src/app/api/medications/[id]/inventory/[itemId]/route.ts
index a31eae9ec..e371b526e 100644
--- a/src/app/api/medications/[id]/inventory/[itemId]/route.ts
+++ b/src/app/api/medications/[id]/inventory/[itemId]/route.ts
@@ -2,7 +2,8 @@
* v1.4.25 W19b — per-pen inventory item operations.
*
* PATCH /api/medications/[id]/inventory/[itemId]
- * - mark-as-first-use (starts the 30-day clock manually)
+ * - set or clear the opening date (`markAsFirstUseAt`); for a pen
+ * or an ampoule it also starts / stops the in-use clock
* - mark-as-used-up (terminal — operator override when a pen is
* physically discarded but the dose ledger hasn't reached zero)
* - update printed expiry (e.g. carton label correction)
@@ -117,11 +118,17 @@ export const PATCH = apiHandler(
let nextUnitsRemaining: number = Number(existing.unitsRemaining);
let nextPrintedExpiry = existing.printedExpiry;
- if (markAsFirstUseAt) {
- // The clock can be set manually if the user opened the pen
- // without logging an intake event.
+ if (markAsFirstUseAt !== undefined) {
+ // The opening date can be set manually if the user opened the
+ // container without logging an intake event — and cleared
+ // (null) when it was never opened at all. An auto-open the
+ // consumption hook wrote from a back-dated intake is the one
+ // the user most often has to take back, and until now nothing
+ // could unset it.
nextFirstUseAt = markAsFirstUseAt;
- if (nextState === "ACTIVE") nextState = "IN_USE";
+ if (nextState === "ACTIVE" && markAsFirstUseAt !== null) {
+ nextState = "IN_USE";
+ }
}
if (printedExpiry !== undefined) {
@@ -145,7 +152,11 @@ export const PATCH = apiHandler(
nextState = "USED_UP";
}
- const nextExpiresAt = computeExpiresAt(nextFirstUseAt, nextPrintedExpiry);
+ const nextExpiresAt = computeExpiresAt(
+ nextFirstUseAt,
+ nextPrintedExpiry,
+ existing.containerType,
+ );
// Re-run the canonical state machine over the composed next-row
// view. The clause-by-clause updates above set the state ad-hoc
@@ -160,6 +171,7 @@ export const PATCH = apiHandler(
nextState = computeInventoryState(
{
state: nextState,
+ containerType: existing.containerType,
unitsTotal: Number(existing.unitsTotal),
unitsRemaining: nextUnitsRemaining,
firstUseAt: nextFirstUseAt,
diff --git a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts
index bb4926240..33c877cd6 100644
--- a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts
+++ b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts
@@ -269,6 +269,8 @@ describe("PATCH /api/medications/[id]/inventory/[itemId]", () => {
userId: "user-1",
medicationId: "med-1",
state: "ACTIVE" as const,
+ // A pen — the container kind that carries the post-opening clock.
+ containerType: "PEN" as const,
unitsTotal: 4,
unitsRemaining: 4,
firstUseAt: null,
@@ -384,6 +386,78 @@ describe("PATCH /api/medications/[id]/inventory/[itemId]", () => {
});
});
+ it("leaves a back-dated first use on a blister IN_USE with no expiresAt", async () => {
+ // The same back-dated stamp on a tablet pack must not write the
+ // remaining tablets off: a blister carries no post-opening clock,
+ // so the only deadline it could have is a printed one, and this row
+ // has none.
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
+ ...existingActive,
+ containerType: "BLISTER",
+ unitsTotal: 60,
+ unitsRemaining: 54,
+ } as never);
+ vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
+ ...existingActive,
+ state: "IN_USE",
+ } as never);
+
+ const backdated = new Date(
+ Date.now() - 45 * 24 * 60 * 60 * 1000,
+ ).toISOString();
+ const res = await PATCH(
+ jsonReq(
+ "http://localhost/api/medications/med-1/inventory/inv-1",
+ { markAsFirstUseAt: backdated },
+ "PATCH",
+ ),
+ { params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
+ );
+ expect(res.status).toBe(200);
+ const updateCall = vi.mocked(prisma.medicationInventoryItem.update).mock
+ .calls[0][0];
+ expect(updateCall.data).toMatchObject({
+ state: "IN_USE",
+ firstUseAt: new Date(backdated),
+ expiresAt: null,
+ });
+ });
+
+ it("clears the opening date when markAsFirstUseAt is null", async () => {
+ // The correction path for a container the consumption hook
+ // auto-opened by mistake: unsetting the date returns the row to
+ // ACTIVE and drops any in-use deadline with it.
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
+ ...existingActive,
+ state: "IN_USE",
+ firstUseAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000),
+ expiresAt: new Date(Date.now() + 25 * 24 * 60 * 60 * 1000),
+ } as never);
+ vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
+ ...existingActive,
+ state: "ACTIVE",
+ } as never);
+
+ const res = await PATCH(
+ jsonReq(
+ "http://localhost/api/medications/med-1/inventory/inv-1",
+ { markAsFirstUseAt: null },
+ "PATCH",
+ ),
+ { params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
+ );
+ expect(res.status).toBe(200);
+ const updateCall = vi.mocked(prisma.medicationInventoryItem.update).mock
+ .calls[0][0];
+ expect(updateCall.data).toMatchObject({
+ state: "ACTIVE",
+ firstUseAt: null,
+ expiresAt: null,
+ });
+ });
+
it("rejects 404 when the item belongs to another user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
diff --git a/src/lib/medications/inventory/__tests__/consumption.test.ts b/src/lib/medications/inventory/__tests__/consumption.test.ts
index 7ce6f7992..850103efa 100644
--- a/src/lib/medications/inventory/__tests__/consumption.test.ts
+++ b/src/lib/medications/inventory/__tests__/consumption.test.ts
@@ -39,6 +39,7 @@ interface FakeItem {
userId: string;
medicationId: string;
state: "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
+ containerType: "PEN" | "AMPOULE" | "BLISTER" | "INHALER" | "BOTTLE" | "OTHER";
unitsTotal: number;
unitsRemaining: number;
firstUseAt: Date | null;
@@ -217,6 +218,9 @@ function item(overrides: Partial = {}): FakeItem {
userId: "user-1",
medicationId: "med-1",
state: "ACTIVE",
+ // These fixtures model the pen case — the kind that carries the
+ // post-opening clock. The clock-less kinds get their own scenarios.
+ containerType: "PEN",
unitsTotal: 10,
unitsRemaining: 10,
firstUseAt: null,
@@ -419,6 +423,29 @@ describe("consumeForIntake", () => {
]);
});
+ it("leaves a backdated auto-open of a blister IN_USE with no expiresAt", async () => {
+ // Same backdated take against a tablet pack: every remaining
+ // tablet is still in its own sealed cavity, so opening the pack
+ // starts no clock and the cron gets no deadline to scan for.
+ const backdated = new Date(NOW.getTime() - 40 * MS_PER_DAY);
+ const state: FakeState = {
+ unitsPerDose: 1,
+ events: [takenEvent({ takenAt: backdated })],
+ items: [item({ id: "sealed", containerType: "BLISTER" })],
+ };
+ const client = makeClient(state);
+ await consumeForIntake({
+ ...consumeArgs(client),
+ intakeAt: backdated,
+ });
+
+ const opened = state.items[0];
+ expect(opened.firstUseAt).toEqual(backdated);
+ expect(opened.state).toBe("IN_USE");
+ expect(opened.expiresAt).toBeNull();
+ expect(opened.unitsRemaining).toBe(9);
+ });
+
it("spills across containers when the open one runs dry", async () => {
const state: FakeState = {
unitsPerDose: 3,
diff --git a/src/lib/medications/inventory/__tests__/state-machine.test.ts b/src/lib/medications/inventory/__tests__/state-machine.test.ts
index 928ee9869..d4d5ea9ad 100644
--- a/src/lib/medications/inventory/__tests__/state-machine.test.ts
+++ b/src/lib/medications/inventory/__tests__/state-machine.test.ts
@@ -1,11 +1,13 @@
import { describe, expect, it } from "vitest";
import {
+ IN_USE_CLOCK_CONTAINER_TYPES,
computeExpiresAt,
computeInventoryState,
- daysRemainingInUse,
+ hasInUseClock,
type InventoryItemView,
} from "../state-machine";
+import { MEDICATION_CONTAINER_TYPE_VALUES } from "@/lib/validations/medication/inventory";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const NOW = new Date("2026-05-14T12:00:00Z");
@@ -16,6 +18,7 @@ function makeItem(
): InventoryItemView {
return {
state: "ACTIVE",
+ containerType: "PEN",
unitsTotal: 4,
unitsRemaining: 4,
firstUseAt: null,
@@ -24,6 +27,20 @@ function makeItem(
};
}
+describe("hasInUseClock", () => {
+ it("covers every container kind the enum declares", () => {
+ // A new enum member must be classified deliberately, not inherit a
+ // default. This pins the full partition.
+ const withClock = MEDICATION_CONTAINER_TYPE_VALUES.filter(hasInUseClock);
+ const withoutClock = MEDICATION_CONTAINER_TYPE_VALUES.filter(
+ (ct) => !hasInUseClock(ct),
+ );
+ expect(withClock).toEqual(["PEN", "AMPOULE"]);
+ expect(withoutClock).toEqual(["BLISTER", "INHALER", "BOTTLE", "OTHER"]);
+ expect([...IN_USE_CLOCK_CONTAINER_TYPES]).toEqual(withClock);
+ });
+});
+
describe("computeInventoryState", () => {
it("returns ACTIVE for a fresh refrigerated pen", () => {
expect(computeInventoryState(makeItem(), NOW_MS)).toBe("ACTIVE");
@@ -36,13 +53,21 @@ describe("computeInventoryState", () => {
expect(computeInventoryState(item, NOW_MS)).toBe("IN_USE");
});
- it("returns EXPIRED when the in-use clock blew past 30 days", () => {
+ it("returns EXPIRED when the in-use clock blew past 30 days on a pen", () => {
const item = makeItem({
firstUseAt: new Date(NOW_MS - 31 * MS_PER_DAY),
});
expect(computeInventoryState(item, NOW_MS)).toBe("EXPIRED");
});
+ it("returns EXPIRED when the in-use clock blew past 30 days on an ampoule", () => {
+ const item = makeItem({
+ containerType: "AMPOULE",
+ firstUseAt: new Date(NOW_MS - 31 * MS_PER_DAY),
+ });
+ expect(computeInventoryState(item, NOW_MS)).toBe("EXPIRED");
+ });
+
it("returns EXPIRED when printed expiry has lapsed (unopened pen)", () => {
const item = makeItem({
printedExpiry: new Date(NOW_MS - 1 * MS_PER_DAY),
@@ -89,99 +114,104 @@ describe("computeInventoryState", () => {
});
expect(computeInventoryState(item, NOW_MS)).toBe("IN_USE");
});
+
+ describe("containers without a post-opening clock", () => {
+ // The reported defect: a blister of 54 tablets, no printed expiry,
+ // opened well over 30 days ago. Pressing out the first tablet must
+ // not write the other 53 off.
+ it("keeps a long-opened blister with no printed expiry IN_USE", () => {
+ const item = makeItem({
+ containerType: "BLISTER",
+ unitsTotal: 60,
+ unitsRemaining: 54,
+ firstUseAt: new Date(NOW_MS - 45 * MS_PER_DAY),
+ printedExpiry: null,
+ });
+ expect(computeInventoryState(item, NOW_MS)).toBe("IN_USE");
+ });
+
+ // The counterpart the same fix must not weaken.
+ it("still expires an opened pen past its window", () => {
+ const item = makeItem({
+ containerType: "PEN",
+ unitsTotal: 60,
+ unitsRemaining: 54,
+ firstUseAt: new Date(NOW_MS - 45 * MS_PER_DAY),
+ printedExpiry: null,
+ });
+ expect(computeInventoryState(item, NOW_MS)).toBe("EXPIRED");
+ });
+
+ it.each(["BLISTER", "INHALER", "BOTTLE", "OTHER"] as const)(
+ "keeps a long-opened %s IN_USE",
+ (containerType) => {
+ const item = makeItem({
+ containerType,
+ firstUseAt: new Date(NOW_MS - 400 * MS_PER_DAY),
+ });
+ expect(computeInventoryState(item, NOW_MS)).toBe("IN_USE");
+ },
+ );
+
+ it("still honours the printed expiry on a clock-less container", () => {
+ const item = makeItem({
+ containerType: "BLISTER",
+ firstUseAt: new Date(NOW_MS - 45 * MS_PER_DAY),
+ printedExpiry: new Date(NOW_MS - 1 * MS_PER_DAY),
+ });
+ expect(computeInventoryState(item, NOW_MS)).toBe("EXPIRED");
+ });
+
+ it("keeps a clock-less container ACTIVE while its printed expiry is ahead", () => {
+ const item = makeItem({
+ containerType: "BOTTLE",
+ printedExpiry: new Date(NOW_MS + 200 * MS_PER_DAY),
+ });
+ expect(computeInventoryState(item, NOW_MS)).toBe("ACTIVE");
+ });
+ });
});
describe("computeExpiresAt", () => {
it("returns null when neither firstUseAt nor printedExpiry is set", () => {
- expect(computeExpiresAt(null, null)).toBeNull();
+ expect(computeExpiresAt(null, null, "PEN")).toBeNull();
});
it("returns the printed expiry when only printedExpiry is set", () => {
const printed = new Date("2027-01-15T00:00:00Z");
- expect(computeExpiresAt(null, printed)).toEqual(printed);
+ expect(computeExpiresAt(null, printed, "PEN")).toEqual(printed);
});
- it("returns firstUseAt + 30 days when only firstUseAt is set", () => {
+ it("returns firstUseAt + 30 days when only firstUseAt is set on a pen", () => {
const firstUse = new Date("2026-05-01T00:00:00Z");
- const result = computeExpiresAt(firstUse, null);
+ const result = computeExpiresAt(firstUse, null, "PEN");
expect(result?.getTime()).toBe(firstUse.getTime() + 30 * MS_PER_DAY);
});
it("returns the in-use deadline when it lands before printed expiry", () => {
const firstUse = new Date("2026-05-01T00:00:00Z");
const printed = new Date("2027-01-15T00:00:00Z");
- const result = computeExpiresAt(firstUse, printed);
+ const result = computeExpiresAt(firstUse, printed, "PEN");
expect(result?.getTime()).toBe(firstUse.getTime() + 30 * MS_PER_DAY);
});
it("returns the printed expiry when it lands before the in-use deadline", () => {
const firstUse = new Date("2026-05-01T00:00:00Z");
const printed = new Date("2026-05-10T00:00:00Z");
- const result = computeExpiresAt(firstUse, printed);
+ const result = computeExpiresAt(firstUse, printed, "PEN");
expect(result).toEqual(printed);
});
-});
-describe("daysRemainingInUse", () => {
- it("returns null for an ACTIVE (unopened) pen", () => {
- expect(daysRemainingInUse(makeItem(), NOW_MS)).toBeNull();
+ it("returns null for an opened blister with no printed expiry", () => {
+ // Nothing for the daily expire-cron's indexed scan to find — which
+ // is the point: there is no deadline to scan for.
+ const firstUse = new Date("2026-05-01T00:00:00Z");
+ expect(computeExpiresAt(firstUse, null, "BLISTER")).toBeNull();
});
- it("returns null for an EXPIRED pen (clock blew)", () => {
- const item = makeItem({
- firstUseAt: new Date(NOW_MS - 31 * MS_PER_DAY),
- });
- expect(daysRemainingInUse(item, NOW_MS)).toBeNull();
- });
-
- it("returns null for a USED_UP pen", () => {
- const item = makeItem({
- unitsRemaining: 0,
- firstUseAt: new Date(NOW_MS - 5 * MS_PER_DAY),
- });
- expect(daysRemainingInUse(item, NOW_MS)).toBeNull();
- });
-
- it("returns 25 days when the pen was opened 5 days ago", () => {
- const item = makeItem({
- firstUseAt: new Date(NOW_MS - 5 * MS_PER_DAY),
- });
- expect(daysRemainingInUse(item, NOW_MS)).toBe(25);
- });
-
- it("returns 0 on the last day of the window (still IN_USE)", () => {
- // 29 days + 23h59m elapsed — under one day left, floor → 0.
- const item = makeItem({
- firstUseAt: new Date(NOW_MS - 30 * MS_PER_DAY + 60_000),
- });
- expect(daysRemainingInUse(item, NOW_MS)).toBe(0);
- });
-
- describe("thin-shape overload (v1.4.25 W21 Fix-N)", () => {
- // The medication-detail card calls this with just `{ firstUseAt }`
- // because it has already filtered the list to state === "IN_USE"
- // before reaching the helper. The thin overload skips the
- // state-machine gate.
- it("accepts a thin `{ firstUseAt: Date }` shape", () => {
- const firstUseAt = new Date(NOW_MS - 5 * MS_PER_DAY);
- expect(daysRemainingInUse({ firstUseAt }, NOW_MS)).toBe(25);
- });
-
- it("accepts a thin `{ firstUseAt: string }` ISO shape", () => {
- const firstUseAt = new Date(NOW_MS - 10 * MS_PER_DAY).toISOString();
- expect(daysRemainingInUse({ firstUseAt }, NOW_MS)).toBe(20);
- });
-
- it("returns null when the thin firstUseAt is null", () => {
- expect(daysRemainingInUse({ firstUseAt: null }, NOW_MS)).toBeNull();
- });
-
- it("skips the state-machine gate for the thin form", () => {
- // 31 days past the window — the full-view path would return
- // null here (state ≠ IN_USE). The thin form returns 0 because
- // the caller has already gated on IN_USE.
- const firstUseAt = new Date(NOW_MS - 31 * MS_PER_DAY);
- expect(daysRemainingInUse({ firstUseAt }, NOW_MS)).toBe(0);
- });
+ it("returns the printed expiry alone for an opened blister that has one", () => {
+ const firstUse = new Date("2026-05-01T00:00:00Z");
+ const printed = new Date("2027-01-15T00:00:00Z");
+ expect(computeExpiresAt(firstUse, printed, "BLISTER")).toEqual(printed);
});
});
diff --git a/src/lib/medications/inventory/consumption.ts b/src/lib/medications/inventory/consumption.ts
index 913b02b1c..2d83e0082 100644
--- a/src/lib/medications/inventory/consumption.ts
+++ b/src/lib/medications/inventory/consumption.ts
@@ -250,10 +250,12 @@ export async function consumeForIntake(input: {
const nextExpiresAt = computeExpiresAt(
nextFirstUseAt,
item.printedExpiry,
+ item.containerType,
);
const nextState = computeInventoryState(
{
state: item.state,
+ containerType: item.containerType,
unitsTotal: Number(item.unitsTotal),
unitsRemaining: nextUnitsRemaining,
firstUseAt: nextFirstUseAt,
@@ -418,10 +420,15 @@ export async function consumeImportedIntakesBatch(input: {
const nextFirstUseAt = item.firstUseAt ?? event.intakeAt;
item.unitsRemaining -= take;
item.firstUseAt = nextFirstUseAt;
- item.expiresAt = computeExpiresAt(nextFirstUseAt, item.printedExpiry);
+ item.expiresAt = computeExpiresAt(
+ nextFirstUseAt,
+ item.printedExpiry,
+ item.containerType,
+ );
item.state = computeInventoryState(
{
state: item.state,
+ containerType: item.containerType,
unitsTotal: Number(item.unitsTotal),
unitsRemaining: item.unitsRemaining,
firstUseAt: nextFirstUseAt,
@@ -545,6 +552,7 @@ export async function restoreForIntake(input: {
const nextState = computeInventoryState(
{
state: item.state,
+ containerType: item.containerType,
unitsTotal: Number(item.unitsTotal),
unitsRemaining: nextUnitsRemaining,
firstUseAt: item.firstUseAt,
diff --git a/src/lib/medications/inventory/service.ts b/src/lib/medications/inventory/service.ts
index 2b77e2e34..0747283a9 100644
--- a/src/lib/medications/inventory/service.ts
+++ b/src/lib/medications/inventory/service.ts
@@ -22,8 +22,14 @@ import {
} from "./summary";
/**
- * Daily cron pass — flip any IN_USE rows whose 30-day window has
- * elapsed into EXPIRED. Returns the count of transitions for logging.
+ * Daily cron pass — flip any IN_USE row whose persisted `expiresAt` has
+ * lapsed into EXPIRED. Returns the count of transitions for logging.
+ *
+ * `expiresAt` is written by `computeExpiresAt`, so the deadline is the
+ * printed expiry, the post-opening window, or the earlier of the two —
+ * and a container kind with no post-opening clock and no printed expiry
+ * carries no `expiresAt` at all, which is exactly how it stays out of
+ * this scan.
*
* Optionally scoped to a single user — useful for the per-user
* trigger path. Cron with `userId: null` sweeps every user.
@@ -158,7 +164,10 @@ export function buildCreateInventoryInput(input: {
firstUseAt: null,
printedExpiry: input.printedExpiry,
purchasedAt: input.purchasedAt,
- expiresAt: computeExpiresAt(null, input.printedExpiry),
+ // A freshly registered container is unopened, so the only deadline
+ // it can carry is the printed one — but pass the kind anyway so the
+ // helper stays the single place that decides.
+ expiresAt: computeExpiresAt(null, input.printedExpiry, input.containerType),
manufacturer: input.manufacturer,
doseStrength: input.doseStrength,
// Encrypt the free-text note at rest; the plaintext column stays null.
diff --git a/src/lib/medications/inventory/state-machine.ts b/src/lib/medications/inventory/state-machine.ts
index 83d50980d..ed6fd92f5 100644
--- a/src/lib/medications/inventory/state-machine.ts
+++ b/src/lib/medications/inventory/state-machine.ts
@@ -1,5 +1,5 @@
/**
- * v1.4.25 W19b — pen / vial inventory state machine.
+ * v1.4.25 W19b — container inventory state machine.
*
* Pure functions over a minimal `InventoryItemView` shape — no DB
* access, no Prisma imports. The route handlers, the consumption hook
@@ -9,7 +9,8 @@
* Decision tree (in evaluation order):
* 1. unitsRemaining === 0 → USED_UP (terminal)
* 2. printedExpiry && now > expiry → EXPIRED (printed label trumps clock)
- * 3. firstUseAt && now > firstUseAt + IN_USE_WINDOW_MS → EXPIRED
+ * 3. firstUseAt && the container carries a post-opening clock &&
+ * now > firstUseAt + IN_USE_WINDOW_MS → EXPIRED
* 4. firstUseAt is set → IN_USE
* 5. otherwise → ACTIVE
*
@@ -18,22 +19,85 @@
* machine is unit-agnostic — it only cares whether the count reached
* zero.
*
- * The window length is fixed at 30 days per EMA EPAR §6.3 for
- * Mounjaro KwikPen / Saxenda / Trulicity. Ozempic ships with a 56-day
- * window per its own EPAR — that drug-specific override is *not*
- * encoded here yet. The W19b scope deliberately uses the
- * conservative 30-day default; a follow-up can read the
- * drug-knowledge layer (`glp1-knowledge.ts`) and parametrise the
- * window per Glp1DrugId. The state machine itself remains
- * window-agnostic — it accepts the window length as input.
+ * ## Which containers carry a post-opening clock
+ *
+ * Clause 3 exists because breaching a sealed multi-dose reservoir
+ * exposes its contents: an injection pen's septum is punctured on the
+ * first dose and the product information caps how long the remainder
+ * stays usable (EMA EPAR §6.3). That fact is a property of the
+ * CONTAINER, not of every supply row, and applying it blindly wrote off
+ * good stock: pressing the first tablet out of a blister started a
+ * 30-day clock and a month later the untouched remainder was EXPIRED.
+ *
+ * So the clock now keys off `containerType`
+ * ({@link IN_USE_CLOCK_CONTAINER_TYPES}):
+ *
+ * - PEN — one sealed multi-dose reservoir, punctured on first use.
+ * The clock is the reason clause 3 exists.
+ * - AMPOULE — the member a punctured injectable reservoir (ampoule or
+ * multi-dose vial) lands on; there is no separate VIAL member. Same
+ * physical fact as the pen: the barrier is broken and the contents
+ * are exposed.
+ * - BLISTER — every tablet sits in its own sealed foil cavity.
+ * Pressing out tablet 1 does nothing to tablets 2..n. The carton
+ * expiry is the only expiry.
+ * - INHALER — a pressurised canister has no in-use limit at all, and
+ * the dry-powder devices that do state months, not 30 days. Any
+ * fixed default here would be a fabricated number.
+ * - BOTTLE — a bottle of tablets has no in-use limit. A bottle of
+ * suspension or drops does, but it is product-specific and the enum
+ * cannot tell the two apart.
+ * - OTHER — the backfill default for every row that never chose a
+ * kind. Nothing about it says a limit exists, so none is invented.
+ *
+ * For every clock-less kind the printed expiry (clause 2) still applies
+ * in full — an unknown container is not an unbounded one. What it does
+ * not get is a deadline nobody entered, because writing stock off on a
+ * guess hides supply the user physically holds and mutes the low-stock
+ * signal that would have told them to reorder.
+ *
+ * ## Window length
+ *
+ * The window is fixed at 30 days per EMA EPAR §6.3 for Mounjaro
+ * KwikPen / Saxenda. The per-product window is NOT read from the
+ * drug-knowledge layer (`glp1-knowledge.ts`) even though it carries one
+ * (`inUse.maxDays`: 14 for Trulicity, 56 for Ozempic): the only bridge
+ * from an inventory row to a `Glp1DrugId` is a fuzzy match on the
+ * free-text `Medication.name` (`findDrugIdByBrand`), and a mismatch
+ * there would shorten a window and write stock off — the exact failure
+ * this module just removed. The state machine stays window-agnostic:
+ * it accepts the window length as input, so a caller that can name the
+ * product with certainty can pass its own.
*/
-import type { MedicationInventoryState } from "@/generated/prisma/client";
+import type {
+ MedicationContainerType,
+ MedicationInventoryState,
+} from "@/generated/prisma/client";
/** Default in-use window per EMA EPAR §6.3 for the strictest GLP-1
- * agonists (Mounjaro, Saxenda, Trulicity). 30 days. */
+ * agonists (Mounjaro, Saxenda). 30 days. */
export const DEFAULT_IN_USE_WINDOW_DAYS = 30;
+/**
+ * Container kinds whose first opening starts a degradation clock — the
+ * ones that are a single sealed reservoir breached by the first dose.
+ * Every other kind holds individually sealed units (or an unknown
+ * arrangement) and expires only on its printed date. See the module
+ * docblock for the per-member reasoning.
+ */
+export const IN_USE_CLOCK_CONTAINER_TYPES = [
+ "PEN",
+ "AMPOULE",
+] as const satisfies readonly MedicationContainerType[];
+
+/** Does opening this container start the in-use window? */
+export function hasInUseClock(containerType: MedicationContainerType): boolean {
+ return (IN_USE_CLOCK_CONTAINER_TYPES as readonly string[]).includes(
+ containerType,
+ );
+}
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/**
@@ -44,6 +108,7 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000;
*/
export interface InventoryItemView {
state: MedicationInventoryState;
+ containerType: MedicationContainerType;
unitsTotal: number;
unitsRemaining: number;
firstUseAt: Date | null;
@@ -51,7 +116,7 @@ export interface InventoryItemView {
}
/**
- * Resolve the canonical state for a pen / vial given the wall clock.
+ * Resolve the canonical state for a container given the wall clock.
*
* The function is pure — given the same input it always returns the
* same state — so the caller can re-evaluate as often as it wants
@@ -75,40 +140,52 @@ export function computeInventoryState(
// (2) Printed expiry trumps the in-use clock. A container whose
// carton expiry has lapsed is EXPIRED even if it was never opened.
+ // This clause applies to EVERY container kind.
if (item.printedExpiry && nowMs > item.printedExpiry.getTime()) {
return "EXPIRED";
}
- // (3) In-use clock blew. firstUseAt + window < now ⇒ EXPIRED.
+ // (3) In-use clock blew — only for the kinds that have one.
+ // firstUseAt + window < now ⇒ EXPIRED.
if (item.firstUseAt) {
- const inUseDeadlineMs =
- item.firstUseAt.getTime() + inUseWindowDays * MS_PER_DAY;
- if (nowMs > inUseDeadlineMs) {
- return "EXPIRED";
+ if (hasInUseClock(item.containerType)) {
+ const inUseDeadlineMs =
+ item.firstUseAt.getTime() + inUseWindowDays * MS_PER_DAY;
+ if (nowMs > inUseDeadlineMs) {
+ return "EXPIRED";
+ }
}
- // (4) Has been opened but still inside the window.
+ // (4) Has been opened. A clock-less container stays IN_USE for as
+ // long as it holds units — "opened" is a true statement about it,
+ // it just carries no deadline of its own.
return "IN_USE";
}
- // (5) Refrigerated, unopened.
+ // (5) Sealed, unopened.
return "ACTIVE";
}
/**
- * Compute the `expiresAt` column. It's the min of the in-use
- * deadline (firstUseAt + window) and the printed expiry — whichever
- * lands first. If the container has never been opened we fall back to
- * the printed expiry alone (in-use clock hasn't started). If neither
- * is set we return null so the daily expire-cron has nothing to scan.
+ * Compute the `expiresAt` column — the instant the daily expire-cron
+ * scans for.
+ *
+ * For a container with a post-opening clock it is the min of the in-use
+ * deadline (firstUseAt + window) and the printed expiry, whichever
+ * lands first. For every other kind the printed expiry is the only
+ * deadline there is, so an opened blister with no printed date carries
+ * no `expiresAt` at all and the cron never sees it. If neither applies
+ * we return null.
*/
export function computeExpiresAt(
firstUseAt: Date | null,
printedExpiry: Date | null,
+ containerType: MedicationContainerType,
inUseWindowDays: number = DEFAULT_IN_USE_WINDOW_DAYS,
): Date | null {
- const inUseDeadline = firstUseAt
- ? new Date(firstUseAt.getTime() + inUseWindowDays * MS_PER_DAY)
- : null;
+ const inUseDeadline =
+ firstUseAt && hasInUseClock(containerType)
+ ? new Date(firstUseAt.getTime() + inUseWindowDays * MS_PER_DAY)
+ : null;
if (inUseDeadline && printedExpiry) {
return inUseDeadline.getTime() < printedExpiry.getTime()
@@ -117,51 +194,3 @@ export function computeExpiresAt(
}
return inUseDeadline ?? printedExpiry ?? null;
}
-
-/**
- * Whole-days remaining in the 30-day in-use window, rounded toward
- * zero. Returns null when the container is not currently IN_USE (the
- * UI surface for ACTIVE / EXPIRED / USED_UP shows a different label).
- *
- * The day count is computed against the in-use deadline only —
- * the printed expiry is its own separate countdown surfaced as the
- * "Carton expires {date}" label. This split avoids the UI lying to
- * the user when the printed expiry is more imminent than the
- * in-use deadline (the EXPIRED transition is still correct via
- * `computeInventoryState`, but the day-count chip shows the
- * 30-day-clock number).
- *
- * v1.4.25 W21 Fix-N — widened to accept either the full
- * `InventoryItemView` (server-side path: state-machine-gated, returns
- * null for non-IN_USE) OR a thin `{ firstUseAt }` shape (caller
- * already knows state is IN_USE so the gate is a no-op). The widening
- * collapsed an earlier reimplementation in the now-retired web
- * inventory surface; the helper stays because the GLP-1 details
- * endpoint still computes weeksOfSupply for iOS.
- */
-export function daysRemainingInUse(
- item: InventoryItemView | { firstUseAt: Date | string | null },
- nowMs: number,
- inUseWindowDays: number = DEFAULT_IN_USE_WINDOW_DAYS,
-): number | null {
- const firstUseRaw = item.firstUseAt;
- if (!firstUseRaw) return null;
- const firstUseAt =
- firstUseRaw instanceof Date ? firstUseRaw : new Date(firstUseRaw);
- // Only the full view triggers the state-machine gate; thin callers
- // (the inventory disclosure UI on the medication card) supply just
- // `firstUseAt` and have already filtered on state === "IN_USE" before
- // reaching here. The gate is silently skipped for the thin form.
- if ("state" in item && "unitsRemaining" in item) {
- const state = computeInventoryState(
- { ...item, firstUseAt },
- nowMs,
- inUseWindowDays,
- );
- if (state !== "IN_USE") return null;
- }
- const deadlineMs = firstUseAt.getTime() + inUseWindowDays * MS_PER_DAY;
- const remainingMs = deadlineMs - nowMs;
- if (remainingMs <= 0) return 0;
- return Math.floor(remainingMs / MS_PER_DAY);
-}
diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts
index ec2d35972..1e3425278 100644
--- a/src/lib/openapi/routes/medications/schemas.ts
+++ b/src/lib/openapi/routes/medications/schemas.ts
@@ -290,12 +290,12 @@ export const medicationInventoryItemResource = z
state: z
.enum(["ACTIVE", "IN_USE", "EXPIRED", "USED_UP"])
.describe(
- "Container lifecycle state. ACTIVE = unopened; IN_USE = opened (30-day in-use clock running); EXPIRED = printed expiry or in-use window lapsed with units left; USED_UP = drained (terminal).",
+ "Container lifecycle state. ACTIVE = unopened; IN_USE = opened (on a PEN / AMPOULE the 30-day in-use clock is running); EXPIRED = printed expiry lapsed, or the in-use window lapsed on a clock-bearing container, with units left; USED_UP = drained (terminal).",
),
containerType: z
.enum(MEDICATION_CONTAINER_TYPE_VALUES)
.describe(
- "Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level classification; defaults to OTHER.",
+ "Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER); defaults to OTHER. PEN and AMPOULE are the sealed reservoirs the first dose breaches, so opening one starts a 30-day post-opening window; BLISTER / INHALER / BOTTLE / OTHER hold individually sealed units and expire only on their printed date.",
),
unitsTotal: z
.number()
@@ -313,13 +313,13 @@ export const medicationInventoryItemResource = z
.datetime({ offset: true })
.nullable()
.describe(
- "Instant the container was first used. NULL until opened; starts the 30-day in-use clock.",
+ "Instant the container was first used. NULL until opened. On a PEN / AMPOULE it starts the 30-day in-use clock; on the other kinds it only records that the pack is open.",
),
expiresAt: z.iso
.datetime({ offset: true })
.nullable()
.describe(
- "Persisted MIN(firstUseAt + 30 days, printedExpiry). NULL when neither clock has started.",
+ "Persisted MIN(firstUseAt + 30 days, printedExpiry) for a clock-bearing container (PEN / AMPOULE), else printedExpiry alone. NULL when no deadline applies — an opened blister with no printed date has none.",
),
printedExpiry: z.iso.datetime({ offset: true }).nullable(),
purchasedAt: z.iso.datetime({ offset: true }).nullable(),
diff --git a/src/lib/validations/medication/inventory.ts b/src/lib/validations/medication/inventory.ts
index 1c33a83d0..acf0ff406 100644
--- a/src/lib/validations/medication/inventory.ts
+++ b/src/lib/validations/medication/inventory.ts
@@ -11,7 +11,10 @@ import { z } from "zod/v4";
*/
/**
* v1.16.10 — container kinds, mirrored from the Prisma
- * `MedicationContainerType` enum. Display-level classification only.
+ * `MedicationContainerType` enum. Beyond labelling the row, the kind
+ * decides whether opening the container starts a post-opening clock —
+ * see `IN_USE_CLOCK_CONTAINER_TYPES` in
+ * `src/lib/medications/inventory/state-machine.ts`.
*/
export const MEDICATION_CONTAINER_TYPE_VALUES = [
"PEN",
@@ -42,7 +45,7 @@ export const createInventoryItemSchema = z
.enum(MEDICATION_CONTAINER_TYPE_VALUES)
.optional()
.describe(
- "Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level only; defaults to OTHER.",
+ "Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Defaults to OTHER. PEN and AMPOULE are the sealed reservoirs the first dose breaches, so they carry a 30-day post-opening window; every other kind expires only on its printed date.",
),
printedExpiry: z.iso
.datetime({ offset: true })
@@ -85,9 +88,10 @@ export const updateInventoryItemSchema = z
markAsFirstUseAt: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
+ .nullable()
.optional()
.describe(
- "Manually start the 30-day in-use clock (the user opened the container without logging an intake). ACTIVE flips to IN_USE; a backdated instant whose window already lapsed lands EXPIRED.",
+ "Opening date of the container (the user opened it without logging an intake). ACTIVE flips to IN_USE. `null` clears it — the container reads as unopened again, which is how a wrongly auto-opened row is corrected. For a PEN or an AMPOULE this also starts / stops the 30-day in-use clock; other container kinds carry no such clock and expire only on their printed date.",
),
markAsUsedUp: z
.boolean()
From bb1e01e4567a1801d958fdec69cb09acbf10f00e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 10:31:56 +0200
Subject: [PATCH 29/52] fix(medications): restore the containers the in-use
clock wrongly wrote off
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Migration 0276 reverses the rows that reached EXPIRED only through the
post-opening clock on a container that never had one. It moves a row back to
IN_USE when an opening date is recorded and ACTIVE when none is, and sets
`expires_at` from the printed expiry — which for these rows is usually
nothing at all, because they recorded none.
The predicate is exact rather than approximate: nothing but the printed
expiry and the in-use clock can write EXPIRED, so a clock-less container
whose printed expiry is absent or still ahead can only have got there through
the clock. Pens and ampoules keep their EXPIRED, so does anything whose
printed date genuinely lapsed, and so does a drained container. No date is
invented and no printed expiry is fabricated.
The integration test seeds the before-state, applies the migration SQL from
disk, and asserts both what moves and what stays — the testcontainer starts
empty, so the migration running at boot proves nothing on its own.
---
.../migration.sql | 44 +++
...ntory-expiry-container-type-repair.test.ts | 261 ++++++++++++++++++
2 files changed, 305 insertions(+)
create mode 100644 prisma/migrations/0276_inventory_expiry_container_type_repair/migration.sql
create mode 100644 tests/integration/inventory-expiry-container-type-repair.test.ts
diff --git a/prisma/migrations/0276_inventory_expiry_container_type_repair/migration.sql b/prisma/migrations/0276_inventory_expiry_container_type_repair/migration.sql
new file mode 100644
index 000000000..9424627b3
--- /dev/null
+++ b/prisma/migrations/0276_inventory_expiry_container_type_repair/migration.sql
@@ -0,0 +1,44 @@
+-- Restore supply containers that the in-use clock wrote off although they
+-- never carried one.
+--
+-- Until this release the state machine started a 30-day post-opening clock
+-- for every container as soon as `first_use_at` was set, including the
+-- containers whose units stay individually sealed. Pressing the first tablet
+-- out of a blister therefore condemned the rest of the pack a month later:
+-- the row went EXPIRED, its units dropped out of the available pool, and the
+-- supply card read "0 of 0 doses" beside a count of expired units.
+--
+-- The rows to restore are the ones that can ONLY have reached EXPIRED through
+-- that clock:
+-- * `container_type` is one of the kinds that has no post-opening clock
+-- (everything except PEN and AMPOULE — see IN_USE_CLOCK_CONTAINER_TYPES
+-- in src/lib/medications/inventory/state-machine.ts),
+-- * the printed expiry is absent or still ahead of now, so the printed
+-- label cannot be what expired the row,
+-- * units are left, so USED_UP is not the answer either.
+--
+-- Nothing else can have set EXPIRED: the API cannot write that state
+-- directly, the daily cron only flips rows whose `expires_at` has lapsed,
+-- and the consumption hook derives the state from the same two clocks.
+--
+-- Deliberately untouched:
+-- * PEN and AMPOULE rows — their clock is real and their EXPIRED is earned;
+-- * any row whose printed expiry has genuinely lapsed, whatever its kind;
+-- * rows with no units left (their state is a USED_UP question, not this
+-- one);
+-- * every other column. No date is invented and no printed expiry is
+-- fabricated: a row that recorded no expiry keeps recording none, which
+-- is why `expires_at` is set from `printed_expiry` rather than computed.
+UPDATE "medication_inventory_items"
+SET
+ "state" = CASE
+ WHEN "first_use_at" IS NOT NULL THEN 'IN_USE'::"medication_inventory_state"
+ ELSE 'ACTIVE'::"medication_inventory_state"
+ END,
+ -- The only deadline a clock-less container can carry is its printed one.
+ "expires_at" = "printed_expiry",
+ "updated_at" = NOW()
+WHERE "state" = 'EXPIRED'
+ AND "container_type" NOT IN ('PEN', 'AMPOULE')
+ AND ("printed_expiry" IS NULL OR "printed_expiry" > NOW())
+ AND "doses_remaining" > 0;
diff --git a/tests/integration/inventory-expiry-container-type-repair.test.ts b/tests/integration/inventory-expiry-container-type-repair.test.ts
new file mode 100644
index 000000000..216703aee
--- /dev/null
+++ b/tests/integration/inventory-expiry-container-type-repair.test.ts
@@ -0,0 +1,261 @@
+/**
+ * Integration coverage for `0276_inventory_expiry_container_type_repair`.
+ *
+ * The repair reverses the rows the in-use clock wrote off although their
+ * container never carried one. A unit test cannot prove it: the predicate is
+ * SQL, it reads an enum column and compares a timestamp against `NOW()`, and
+ * the testcontainer starts empty so the migration itself has nothing to touch
+ * when it runs at boot. This test seeds the before-state, applies the EXACT
+ * migration SQL from disk, and asserts what moved, what did not, and that a
+ * second application changes nothing further.
+ *
+ * Requires Docker / OrbStack; driven by CI's integration job (and
+ * `pnpm test:integration` locally).
+ */
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { beforeEach, describe, expect, it } from "vitest";
+
+import { getPrismaClient, truncateAllTables } from "./setup";
+import type {
+ MedicationContainerType,
+ MedicationInventoryState,
+ PrismaClient,
+} from "@/generated/prisma/client";
+
+const REPAIR_SQL = readFileSync(
+ resolve(
+ process.cwd(),
+ "prisma/migrations/0276_inventory_expiry_container_type_repair/migration.sql",
+ ),
+ "utf8",
+);
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+const USER_ID = "inventory-repair-user";
+
+interface SeedInput {
+ containerType: MedicationContainerType;
+ state: MedicationInventoryState;
+ unitsRemaining?: number;
+ firstUseAt?: Date | null;
+ printedExpiry?: Date | null;
+ expiresAt?: Date | null;
+}
+
+async function seedItem(
+ prisma: PrismaClient,
+ medicationId: string,
+ input: SeedInput,
+): Promise {
+ const row = await prisma.medicationInventoryItem.create({
+ data: {
+ userId: USER_ID,
+ medicationId,
+ state: input.state,
+ containerType: input.containerType,
+ unitsTotal: 60,
+ unitsRemaining: input.unitsRemaining ?? 54,
+ firstUseAt: input.firstUseAt ?? null,
+ printedExpiry: input.printedExpiry ?? null,
+ expiresAt: input.expiresAt ?? null,
+ },
+ select: { id: true },
+ });
+ return row.id;
+}
+
+async function readItem(prisma: PrismaClient, id: string) {
+ const row = await prisma.medicationInventoryItem.findUniqueOrThrow({
+ where: { id },
+ select: {
+ state: true,
+ expiresAt: true,
+ printedExpiry: true,
+ firstUseAt: true,
+ unitsRemaining: true,
+ },
+ });
+ return row;
+}
+
+describe("0276 inventory expiry container-type repair — integration", () => {
+ let medicationId: string;
+
+ beforeEach(async () => {
+ const prisma = getPrismaClient();
+ await truncateAllTables(prisma);
+ await prisma.user.create({
+ data: {
+ id: USER_ID,
+ username: "inventory-repair",
+ email: "inventory-repair@example.test",
+ },
+ });
+ const med = await prisma.medication.create({
+ data: {
+ userId: USER_ID,
+ name: "Tablets",
+ dose: "10mg",
+ deliveryForm: "ORAL",
+ },
+ select: { id: true },
+ });
+ medicationId = med.id;
+ });
+
+ it("restores a wrongly expired blister to IN_USE and clears its deadline", async () => {
+ const prisma = getPrismaClient();
+ const openedAt = new Date(Date.now() - 45 * DAY_MS);
+ const id = await seedItem(prisma, medicationId, {
+ containerType: "BLISTER",
+ state: "EXPIRED",
+ firstUseAt: openedAt,
+ printedExpiry: null,
+ // What the old `computeExpiresAt` persisted: firstUseAt + 30 days.
+ expiresAt: new Date(openedAt.getTime() + 30 * DAY_MS),
+ });
+
+ await prisma.$executeRawUnsafe(REPAIR_SQL);
+
+ const row = await readItem(prisma, id);
+ expect(row.state).toBe("IN_USE");
+ // No printed expiry was recorded, so none is invented.
+ expect(row.expiresAt).toBeNull();
+ expect(row.printedExpiry).toBeNull();
+ expect(row.firstUseAt).toEqual(openedAt);
+ expect(Number(row.unitsRemaining)).toBe(54);
+ });
+
+ it("restores an unopened clock-less container to ACTIVE", async () => {
+ const prisma = getPrismaClient();
+ const id = await seedItem(prisma, medicationId, {
+ containerType: "OTHER",
+ state: "EXPIRED",
+ firstUseAt: null,
+ printedExpiry: null,
+ });
+
+ await prisma.$executeRawUnsafe(REPAIR_SQL);
+
+ expect((await readItem(prisma, id)).state).toBe("ACTIVE");
+ });
+
+ it("keeps the printed expiry as the row's deadline when one is recorded", async () => {
+ const prisma = getPrismaClient();
+ const printed = new Date(Date.now() + 200 * DAY_MS);
+ const openedAt = new Date(Date.now() - 45 * DAY_MS);
+ const id = await seedItem(prisma, medicationId, {
+ containerType: "BOTTLE",
+ state: "EXPIRED",
+ firstUseAt: openedAt,
+ printedExpiry: printed,
+ expiresAt: new Date(openedAt.getTime() + 30 * DAY_MS),
+ });
+
+ await prisma.$executeRawUnsafe(REPAIR_SQL);
+
+ const row = await readItem(prisma, id);
+ expect(row.state).toBe("IN_USE");
+ expect(row.expiresAt).toEqual(printed);
+ });
+
+ it("leaves an expired pen alone — its clock is real", async () => {
+ const prisma = getPrismaClient();
+ const openedAt = new Date(Date.now() - 45 * DAY_MS);
+ const expiresAt = new Date(openedAt.getTime() + 30 * DAY_MS);
+ const id = await seedItem(prisma, medicationId, {
+ containerType: "PEN",
+ state: "EXPIRED",
+ firstUseAt: openedAt,
+ expiresAt,
+ });
+
+ await prisma.$executeRawUnsafe(REPAIR_SQL);
+
+ const row = await readItem(prisma, id);
+ expect(row.state).toBe("EXPIRED");
+ expect(row.expiresAt).toEqual(expiresAt);
+ });
+
+ it("leaves an expired ampoule alone — its clock is real", async () => {
+ const prisma = getPrismaClient();
+ const id = await seedItem(prisma, medicationId, {
+ containerType: "AMPOULE",
+ state: "EXPIRED",
+ firstUseAt: new Date(Date.now() - 45 * DAY_MS),
+ });
+
+ await prisma.$executeRawUnsafe(REPAIR_SQL);
+
+ expect((await readItem(prisma, id)).state).toBe("EXPIRED");
+ });
+
+ it("leaves a container whose printed expiry genuinely lapsed alone", async () => {
+ const prisma = getPrismaClient();
+ const printed = new Date(Date.now() - 3 * DAY_MS);
+ const id = await seedItem(prisma, medicationId, {
+ containerType: "BLISTER",
+ state: "EXPIRED",
+ firstUseAt: new Date(Date.now() - 45 * DAY_MS),
+ printedExpiry: printed,
+ expiresAt: printed,
+ });
+
+ await prisma.$executeRawUnsafe(REPAIR_SQL);
+
+ const row = await readItem(prisma, id);
+ expect(row.state).toBe("EXPIRED");
+ expect(row.expiresAt).toEqual(printed);
+ });
+
+ it("leaves a drained container alone — nothing to give back", async () => {
+ const prisma = getPrismaClient();
+ const id = await seedItem(prisma, medicationId, {
+ containerType: "BLISTER",
+ state: "EXPIRED",
+ unitsRemaining: 0,
+ firstUseAt: new Date(Date.now() - 45 * DAY_MS),
+ });
+
+ await prisma.$executeRawUnsafe(REPAIR_SQL);
+
+ expect((await readItem(prisma, id)).state).toBe("EXPIRED");
+ });
+
+ it.each(["ACTIVE", "IN_USE", "USED_UP"] as const)(
+ "leaves a %s row untouched",
+ async (state) => {
+ const prisma = getPrismaClient();
+ const expiresAt = new Date(Date.now() + 10 * DAY_MS);
+ const id = await seedItem(prisma, medicationId, {
+ containerType: "BLISTER",
+ state,
+ unitsRemaining: state === "USED_UP" ? 0 : 54,
+ expiresAt,
+ });
+
+ await prisma.$executeRawUnsafe(REPAIR_SQL);
+
+ const row = await readItem(prisma, id);
+ expect(row.state).toBe(state);
+ expect(row.expiresAt).toEqual(expiresAt);
+ },
+ );
+
+ it("is idempotent — a second application changes nothing", async () => {
+ const prisma = getPrismaClient();
+ const id = await seedItem(prisma, medicationId, {
+ containerType: "BLISTER",
+ state: "EXPIRED",
+ firstUseAt: new Date(Date.now() - 45 * DAY_MS),
+ });
+
+ await prisma.$executeRawUnsafe(REPAIR_SQL);
+ const first = await readItem(prisma, id);
+ await prisma.$executeRawUnsafe(REPAIR_SQL);
+ const second = await readItem(prisma, id);
+
+ expect(second).toEqual(first);
+ });
+});
From a4ff11f10d86fc51b3ce8202683927b7f1e38676 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 10:32:07 +0200
Subject: [PATCH 30/52] fix(medications): surface and correct the dates that
decide a container's state
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The supply tab rendered a container's kind, its unit count and a state badge,
and nothing else. A row could read "Expired" with nothing on screen to say
which date caused it, and the correction dialog only ever offered the
remaining-unit count — so the opening date, which the intake consumption hook
writes without the user ever typing it, could not be taken back.
Each row now carries the two dates it has, and the correction dialog edits
both alongside the unit count. Each field rides along only when it actually
changed, so saving a stock correction never rewrites a date left untouched,
and clearing the opening date sends null, which the route reads as "this was
never opened" rather than "leave it alone". Beside the opening date the
helper text says whether the kind carries a 30-day window at all.
The headline stops contradicting itself. "0 of 0 doses remaining" next to
"+ 54 units expired" read as "nothing was ever recorded" beside "stock was
written off"; when the available pool is empty and only expired stock is
left, one line says so instead. The low-stock push follows: no usable supply
with written-off units on hand is a different errand from an empty pack, and
`expiredUnits` now reaches the payload, the notification metadata and the
wide event.
---
messages/de.json | 17 +-
messages/en.json | 17 +-
messages/es.json | 17 +-
messages/fr.json | 17 +-
messages/it.json | 17 +-
messages/pl.json | 17 +-
.../inventory-section-dates.test.tsx | 208 ++++++++++++++++++
.../inventory-section-summary.test.tsx | 32 ++-
.../sections/inventory-section.tsx | 181 +++++++++++++--
.../__tests__/medication-low-stock.test.ts | 26 +++
src/lib/jobs/medication-low-stock.ts | 30 +++
11 files changed, 536 insertions(+), 43 deletions(-)
create mode 100644 src/components/medications/__tests__/inventory-section-dates.test.tsx
diff --git a/messages/de.json b/messages/de.json
index e6e20165d..b10ea57da 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -1716,8 +1716,8 @@
"addSuccess": "Bestand erfasst.",
"addFailed": "Bestand konnte nicht erfasst werden.",
"adjustButton": "Anpassen",
- "adjustTitle": "Bestand korrigieren",
- "adjustDescription": "Setze die verbleibenden Einheiten neu — für Korrekturen oder eine Entnahme. 0 markiert den Vorrat als aufgebraucht.",
+ "adjustTitle": "Behälter korrigieren",
+ "adjustDescription": "Verbleibende Einheiten setzen und die Daten dieses Behälters korrigieren. 0 Einheiten markiert ihn als aufgebraucht.",
"adjustQuantityLabel": "Verbleibende Einheiten",
"adjustHelper": "Kapazität: {total} Einheiten.",
"adjustSubmit": "Speichern",
@@ -1757,7 +1757,15 @@
"packagingFailed": "Speichern fehlgeschlagen",
"addManufacturerLabel": "Hersteller",
"addDoseStrengthLabel": "Wirkstärke",
- "addDoseStrengthHint": "Wie auf dem Behälter aufgedruckt, z. B. 5 mg/0,5 ml."
+ "addDoseStrengthHint": "Wie auf dem Behälter aufgedruckt, z. B. 5 mg/0,5 ml.",
+ "openedOn": "Geöffnet {date}",
+ "expiresOn": "Haltbar bis {date}",
+ "summaryAllExpired": "Kein nutzbarer Bestand: {units} Einheiten als abgelaufen markiert",
+ "editExpiryLabel": "Aufgedrucktes Haltbarkeitsdatum",
+ "editExpiryHelper": "Das Datum auf der Packung. Leer lassen, wenn keines erfasst ist.",
+ "editOpenedLabel": "Geöffnet am",
+ "editOpenedHelper": "Leer lassen, wenn der Behälter noch verschlossen ist.",
+ "editOpenedHelperClock": "Ein Pen oder eine Ampulle läuft 30 Tage nach dem Öffnen ab. Leer lassen, wenn noch verschlossen."
},
"erweitert": {
"group": {
@@ -7008,7 +7016,8 @@
"title": "Vorrat niedrig: {medName}",
"bodyToday": "Dein Vorrat an {medName} reicht keinen ganzen Tag mehr ({units} Einheiten übrig).",
"bodyReorder": "Dein Vorrat an {medName} ({units} Einheiten) ist um den {runsOutOn} aufgebraucht — bestelle bis zum {reorderBy} nach.",
- "bodyLastDose": "Dein Vorrat an {medName} ({units} Einheiten) reicht noch etwa für die letzte Dosis. Aufgebraucht um den {runsOutOn}."
+ "bodyLastDose": "Dein Vorrat an {medName} ({units} Einheiten) reicht noch etwa für die letzte Dosis. Aufgebraucht um den {runsOutOn}.",
+ "bodyExpired": "{medName}: kein nutzbarer Bestand mehr. {units} Einheiten sind als abgelaufen markiert."
},
"moodReminders": {
"dailyTitle": "Stimmung erfassen",
diff --git a/messages/en.json b/messages/en.json
index b1b50f6fc..ee47dc886 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1716,8 +1716,8 @@
"addSuccess": "Supply registered.",
"addFailed": "The supply could not be registered.",
"adjustButton": "Adjust",
- "adjustTitle": "Correct stock",
- "adjustDescription": "Set the remaining units — for corrections or a withdrawal. 0 marks the item as used up.",
+ "adjustTitle": "Correct container",
+ "adjustDescription": "Set the remaining units and correct the dates printed on or recorded for this container. 0 units marks it as used up.",
"adjustQuantityLabel": "Remaining units",
"adjustHelper": "Capacity: {total} units.",
"adjustSubmit": "Save",
@@ -1757,7 +1757,15 @@
"packagingFailed": "Saving failed",
"addManufacturerLabel": "Manufacturer",
"addDoseStrengthLabel": "Strength",
- "addDoseStrengthHint": "As printed on the container, e.g. 5 mg/0.5 ml."
+ "addDoseStrengthHint": "As printed on the container, e.g. 5 mg/0.5 ml.",
+ "openedOn": "Opened {date}",
+ "expiresOn": "Expires {date}",
+ "summaryAllExpired": "No usable supply: {units} units marked expired",
+ "editExpiryLabel": "Printed expiry date",
+ "editExpiryHelper": "The date printed on the carton. Leave empty if none is recorded.",
+ "editOpenedLabel": "Opened on",
+ "editOpenedHelper": "Leave empty if the container is still sealed.",
+ "editOpenedHelperClock": "A pen or ampoule expires 30 days after it is opened. Leave empty if it is still sealed."
},
"erweitert": {
"group": {
@@ -7008,7 +7016,8 @@
"title": "Low supply: {medName}",
"bodyToday": "{medName}: {units} units left — less than one day of supply.",
"bodyReorder": "{medName}: {units} units left. Supply runs out around {runsOutOn} — reorder by {reorderBy}.",
- "bodyLastDose": "{medName}: {units} units left — about your final dose. Supply runs out around {runsOutOn}."
+ "bodyLastDose": "{medName}: {units} units left — about your final dose. Supply runs out around {runsOutOn}.",
+ "bodyExpired": "{medName}: no usable supply left. {units} units are marked expired."
},
"moodReminders": {
"dailyTitle": "Log your mood",
diff --git a/messages/es.json b/messages/es.json
index 9f56ae1f6..50a18d9f4 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -1716,8 +1716,8 @@
"addSuccess": "Existencias registradas.",
"addFailed": "No se pudieron registrar las existencias.",
"adjustButton": "Ajustar",
- "adjustTitle": "Corregir existencias",
- "adjustDescription": "Establece las unidades restantes — para correcciones o una retirada. 0 marca el envase como agotado.",
+ "adjustTitle": "Corregir envase",
+ "adjustDescription": "Ajusta las unidades restantes y corrige las fechas de este envase. 0 unidades lo marca como agotado.",
"adjustQuantityLabel": "Unidades restantes",
"adjustHelper": "Capacidad: {total} unidades.",
"adjustSubmit": "Guardar",
@@ -1757,7 +1757,15 @@
"packagingFailed": "Error al guardar",
"addManufacturerLabel": "Fabricante",
"addDoseStrengthLabel": "Concentración",
- "addDoseStrengthHint": "Tal como aparece impreso en el envase, p. ej. 5 mg/0,5 ml."
+ "addDoseStrengthHint": "Tal como aparece impreso en el envase, p. ej. 5 mg/0,5 ml.",
+ "openedOn": "Abierto el {date}",
+ "expiresOn": "Caduca el {date}",
+ "summaryAllExpired": "Sin existencias utilizables: {units} unidades marcadas como caducadas",
+ "editExpiryLabel": "Fecha de caducidad impresa",
+ "editExpiryHelper": "La fecha impresa en el envase. Déjalo vacío si no hay ninguna registrada.",
+ "editOpenedLabel": "Abierto el",
+ "editOpenedHelper": "Déjalo vacío si el envase sigue cerrado.",
+ "editOpenedHelperClock": "Una pluma o una ampolla caduca 30 días después de abrirse. Déjalo vacío si sigue cerrada."
},
"erweitert": {
"group": {
@@ -7008,7 +7016,8 @@
"title": "Quedan pocas existencias: {medName}",
"bodyToday": "{medName}: quedan {units} unidades — menos de un día de suministro.",
"bodyReorder": "{medName}: quedan {units} unidades. Las existencias se agotan hacia el {runsOutOn} — vuelve a pedir antes del {reorderBy}.",
- "bodyLastDose": "{medName}: quedan {units} unidades — alrededor de tu última dosis. Las existencias se agotan hacia el {runsOutOn}."
+ "bodyLastDose": "{medName}: quedan {units} unidades — alrededor de tu última dosis. Las existencias se agotan hacia el {runsOutOn}.",
+ "bodyExpired": "{medName}: no quedan existencias utilizables. {units} unidades están marcadas como caducadas."
},
"moodReminders": {
"dailyTitle": "Registrar el ánimo",
diff --git a/messages/fr.json b/messages/fr.json
index f5348e6d8..713e46e24 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -1716,8 +1716,8 @@
"addSuccess": "Stock enregistré.",
"addFailed": "Le stock n'a pas pu être enregistré.",
"adjustButton": "Ajuster",
- "adjustTitle": "Corriger le stock",
- "adjustDescription": "Définissez les unités restantes — pour une correction ou un prélèvement. 0 marque l'article comme épuisé.",
+ "adjustTitle": "Corriger le contenant",
+ "adjustDescription": "Ajustez les unités restantes et corrigez les dates de ce contenant. 0 unité le marque comme épuisé.",
"adjustQuantityLabel": "Unités restantes",
"adjustHelper": "Capacité : {total} unités.",
"adjustSubmit": "Enregistrer",
@@ -1757,7 +1757,15 @@
"packagingFailed": "Échec de l’enregistrement",
"addManufacturerLabel": "Fabricant",
"addDoseStrengthLabel": "Dosage",
- "addDoseStrengthHint": "Tel qu'imprimé sur le contenant, par ex. 5 mg/0,5 ml."
+ "addDoseStrengthHint": "Tel qu'imprimé sur le contenant, par ex. 5 mg/0,5 ml.",
+ "openedOn": "Ouvert le {date}",
+ "expiresOn": "Périme le {date}",
+ "summaryAllExpired": "Aucun stock utilisable : {units} unités marquées périmées",
+ "editExpiryLabel": "Date de péremption imprimée",
+ "editExpiryHelper": "La date imprimée sur la boîte. Laissez vide si aucune n'est enregistrée.",
+ "editOpenedLabel": "Ouvert le",
+ "editOpenedHelper": "Laissez vide si le contenant est encore scellé.",
+ "editOpenedHelperClock": "Un stylo ou une ampoule périme 30 jours après ouverture. Laissez vide s'il est encore scellé."
},
"erweitert": {
"group": {
@@ -7008,7 +7016,8 @@
"title": "Stock faible : {medName}",
"bodyToday": "{medName} : {units} unités restantes — moins d’un jour de réserve.",
"bodyReorder": "{medName} : {units} unités restantes. Le stock sera épuisé vers le {runsOutOn} — recommandez avant le {reorderBy}.",
- "bodyLastDose": "{medName} : {units} unités restantes — environ votre dernière dose. Le stock sera épuisé vers le {runsOutOn}."
+ "bodyLastDose": "{medName} : {units} unités restantes — environ votre dernière dose. Le stock sera épuisé vers le {runsOutOn}.",
+ "bodyExpired": "{medName} : plus de stock utilisable. {units} unités sont marquées périmées."
},
"moodReminders": {
"dailyTitle": "Enregistrer votre humeur",
diff --git a/messages/it.json b/messages/it.json
index 03c3462ee..6bc653e8c 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -1716,8 +1716,8 @@
"addSuccess": "Scorta registrata.",
"addFailed": "Impossibile registrare la scorta.",
"adjustButton": "Correggi",
- "adjustTitle": "Correggi la scorta",
- "adjustDescription": "Imposta le unità rimanenti — per correzioni o un prelievo. 0 segna la scorta come esaurita.",
+ "adjustTitle": "Correggi il contenitore",
+ "adjustDescription": "Imposta le unità rimanenti e correggi le date di questo contenitore. 0 unità lo segna come esaurito.",
"adjustQuantityLabel": "Unità rimanenti",
"adjustHelper": "Capacità: {total} unità.",
"adjustSubmit": "Salva",
@@ -1757,7 +1757,15 @@
"packagingFailed": "Salvataggio non riuscito",
"addManufacturerLabel": "Produttore",
"addDoseStrengthLabel": "Dosaggio",
- "addDoseStrengthHint": "Come stampato sul contenitore, ad es. 5 mg/0,5 ml."
+ "addDoseStrengthHint": "Come stampato sul contenitore, ad es. 5 mg/0,5 ml.",
+ "openedOn": "Aperto il {date}",
+ "expiresOn": "Scade il {date}",
+ "summaryAllExpired": "Nessuna scorta utilizzabile: {units} unità contrassegnate come scadute",
+ "editExpiryLabel": "Data di scadenza stampata",
+ "editExpiryHelper": "La data stampata sulla confezione. Lascia vuoto se non ne è registrata nessuna.",
+ "editOpenedLabel": "Aperto il",
+ "editOpenedHelper": "Lascia vuoto se il contenitore è ancora sigillato.",
+ "editOpenedHelperClock": "Una penna o una fiala scade 30 giorni dopo l'apertura. Lascia vuoto se è ancora sigillata."
},
"erweitert": {
"group": {
@@ -7008,7 +7016,8 @@
"title": "Scorte in esaurimento: {medName}",
"bodyToday": "{medName}: {units} unità rimaste — meno di un giorno di scorta.",
"bodyReorder": "{medName}: {units} unità rimaste. Le scorte finiscono intorno al {runsOutOn} — riordina entro il {reorderBy}.",
- "bodyLastDose": "{medName}: {units} unità rimaste — circa la tua ultima dose. Le scorte finiscono intorno al {runsOutOn}."
+ "bodyLastDose": "{medName}: {units} unità rimaste — circa la tua ultima dose. Le scorte finiscono intorno al {runsOutOn}.",
+ "bodyExpired": "{medName}: nessuna scorta utilizzabile. {units} unità sono contrassegnate come scadute."
},
"moodReminders": {
"dailyTitle": "Registra il tuo umore",
diff --git a/messages/pl.json b/messages/pl.json
index f1d9fb61a..b05dc3814 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -1716,8 +1716,8 @@
"addSuccess": "Zapas zarejestrowany.",
"addFailed": "Nie udało się zarejestrować zapasu.",
"adjustButton": "Skoryguj",
- "adjustTitle": "Skoryguj zapas",
- "adjustDescription": "Ustaw pozostałe jednostki — przy korekcie lub pobraniu. 0 oznacza zapas jako zużyty.",
+ "adjustTitle": "Popraw opakowanie",
+ "adjustDescription": "Ustaw pozostałe jednostki i popraw daty tego opakowania. 0 jednostek oznacza je jako zużyte.",
"adjustQuantityLabel": "Pozostałe jednostki",
"adjustHelper": "Pojemność: {total} jednostek.",
"adjustSubmit": "Zapisz",
@@ -1757,7 +1757,15 @@
"packagingFailed": "Zapis nie powiódł się",
"addManufacturerLabel": "Producent",
"addDoseStrengthLabel": "Moc",
- "addDoseStrengthHint": "Zgodnie z nadrukiem na opakowaniu, np. 5 mg/0,5 ml."
+ "addDoseStrengthHint": "Zgodnie z nadrukiem na opakowaniu, np. 5 mg/0,5 ml.",
+ "openedOn": "Otwarto {date}",
+ "expiresOn": "Ważne do {date}",
+ "summaryAllExpired": "Brak zapasu do użycia: {units} jednostek oznaczono jako przeterminowane",
+ "editExpiryLabel": "Data ważności z opakowania",
+ "editExpiryHelper": "Data nadrukowana na opakowaniu. Zostaw puste, jeśli nie została zapisana.",
+ "editOpenedLabel": "Data otwarcia",
+ "editOpenedHelper": "Zostaw puste, jeśli opakowanie jest jeszcze zamknięte.",
+ "editOpenedHelperClock": "Pen lub ampułka traci ważność 30 dni po otwarciu. Zostaw puste, jeśli jest jeszcze zamknięta."
},
"erweitert": {
"group": {
@@ -7008,7 +7016,8 @@
"title": "Niski zapas: {medName}",
"bodyToday": "{medName}: pozostało {units} jednostek — mniej niż jeden dzień zapasu.",
"bodyReorder": "{medName}: pozostało {units} jednostek. Zapas skończy się około {runsOutOn} — zamów ponownie do {reorderBy}.",
- "bodyLastDose": "{medName}: pozostało {units} jednostek — to mniej więcej Twoja ostatnia dawka. Zapas skończy się około {runsOutOn}."
+ "bodyLastDose": "{medName}: pozostało {units} jednostek — to mniej więcej Twoja ostatnia dawka. Zapas skończy się około {runsOutOn}.",
+ "bodyExpired": "{medName}: brak zapasu do użycia. {units} jednostek oznaczono jako przeterminowane."
},
"moodReminders": {
"dailyTitle": "Zapisz nastrój",
diff --git a/src/components/medications/__tests__/inventory-section-dates.test.tsx b/src/components/medications/__tests__/inventory-section-dates.test.tsx
new file mode 100644
index 000000000..b52cbcff2
--- /dev/null
+++ b/src/components/medications/__tests__/inventory-section-dates.test.tsx
@@ -0,0 +1,208 @@
+/**
+ * Bestand item-row dates: visible on the row, editable in the correction
+ * dialog.
+ *
+ * Both dates decide the container's state — the printed expiry directly,
+ * the opening date through the post-opening window on a pen or an
+ * ampoule — and the opening date is written by the intake consumption
+ * hook without the user ever typing it. Until now neither was rendered
+ * anywhere and neither could be corrected, so a container could read
+ * EXPIRED with nothing on screen to explain why.
+ *
+ * Project convention is SSR-only component tests (`renderToStaticMarkup`).
+ */
+import { describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+
+// The Radix Dialog / Select portal at runtime, so their bodies never
+// materialise in static markup. Collapse the primitives to plain
+// wrappers (same trick as the AddInventoryDialog suite).
+vi.mock("@/components/ui/dialog", () => {
+ const Pass = ({ children }: { children?: React.ReactNode }) => (
+
+ ),
+ SelectTrigger: Pass,
+ SelectValue: () => null,
+ };
+});
+
+const useQueryMock = vi.fn();
+
+vi.mock("@tanstack/react-query", () => ({
+ useQuery: (...args: unknown[]) => useQueryMock(...args),
+ useQueryClient: () => ({
+ invalidateQueries: vi.fn().mockResolvedValue(undefined),
+ }),
+}));
+
+vi.mock("sonner", () => ({
+ toast: { success: vi.fn(), error: vi.fn() },
+}));
+
+const { InventorySection, AddInventoryDialog, AdjustInventoryDialog } =
+ await import("../sections/inventory-section");
+
+function render(node: React.ReactNode): string {
+ return renderToStaticMarkup(
+ {node},
+ );
+}
+
+function seedItems(items: unknown[]) {
+ useQueryMock.mockReturnValue({
+ data: { items, meta: { total: items.length } },
+ isLoading: false,
+ });
+}
+
+const BASE = {
+ id: "i1",
+ state: "IN_USE" as const,
+ containerType: "BLISTER" as const,
+ unitsTotal: 60,
+ unitsRemaining: 54,
+};
+
+describe(" — item-row dates", () => {
+ it("renders the opening date on the row", () => {
+ seedItems([
+ {
+ ...BASE,
+ firstUseAt: "2026-06-12T00:00:00.000Z",
+ printedExpiry: null,
+ },
+ ]);
+ const html = render(
+ ,
+ );
+ expect(html).toContain('data-slot="inventory-item-dates"');
+ expect(html).toContain("Geöffnet 12.06.2026");
+ });
+
+ it("renders the printed expiry on the row", () => {
+ seedItems([
+ {
+ ...BASE,
+ state: "ACTIVE",
+ firstUseAt: null,
+ printedExpiry: "2027-06-01T00:00:00.000Z",
+ },
+ ]);
+ const html = render(
+ ,
+ );
+ expect(html).toContain("Haltbar bis 01.06.2027");
+ });
+
+ it("shows no date line when neither date is recorded", () => {
+ // Absence reads as absence — no placeholder, no invented date.
+ seedItems([{ ...BASE, firstUseAt: null, printedExpiry: null }]);
+ const html = render(
+ ,
+ );
+ expect(html).not.toContain('data-slot="inventory-item-dates"');
+ });
+});
+
+describe(" — editable dates", () => {
+ const item = {
+ ...BASE,
+ firstUseAt: "2026-06-12T00:00:00.000Z",
+ printedExpiry: "2027-06-01T00:00:00.000Z",
+ };
+
+ function renderDialog(over: Partial = {}): string {
+ return render(
+ {}}
+ />,
+ );
+ }
+
+ it("offers a printed-expiry field prefilled from the item", () => {
+ const html = renderDialog();
+ expect(html).toContain('id="inventory-edit-expiry"');
+ expect(html).toContain("Aufgedrucktes Haltbarkeitsdatum");
+ expect(html).toContain('value="2027-06-01"');
+ });
+
+ it("offers an opening-date field prefilled from the item", () => {
+ const html = renderDialog();
+ expect(html).toContain('id="inventory-edit-opened"');
+ expect(html).toContain("Geöffnet am");
+ expect(html).toContain('value="2026-06-12"');
+ });
+
+ it("leaves both date fields empty when the item records neither", () => {
+ const html = renderDialog({ firstUseAt: null, printedExpiry: null });
+ expect(html).toContain('id="inventory-edit-expiry"');
+ expect(html).toContain('id="inventory-edit-opened"');
+ expect(html).not.toContain("2027-06-01");
+ expect(html).not.toContain("2026-06-12");
+ });
+
+ it("explains the 30-day window beside the opening date on a pen", () => {
+ const html = renderDialog({ containerType: "PEN" });
+ expect(html).toContain("30 Tage nach dem Öffnen");
+ });
+
+ it("omits the window explanation for a container that has none", () => {
+ const html = renderDialog({ containerType: "BLISTER" });
+ expect(html).not.toContain("30 Tage nach dem Öffnen");
+ expect(html).toContain("noch verschlossen");
+ });
+
+ it("keeps the remaining-units correction alongside the dates", () => {
+ const html = renderDialog();
+ expect(html).toContain('id="inventory-adjust-remaining"');
+ });
+});
+
+describe(" — printed expiry on create", () => {
+ it("still offers the expiry field", () => {
+ // Regression guard: the create path always offered the printed
+ // expiry; the correction path is the addition.
+ const html = render(
+ {}}
+ />,
+ );
+ expect(html).toContain('id="inventory-add-expiry"');
+ });
+});
diff --git a/src/components/medications/__tests__/inventory-section-summary.test.tsx b/src/components/medications/__tests__/inventory-section-summary.test.tsx
index 599244947..9c9fa416b 100644
--- a/src/components/medications/__tests__/inventory-section-summary.test.tsx
+++ b/src/components/medications/__tests__/inventory-section-summary.test.tsx
@@ -68,7 +68,11 @@ function seedItems(
}
describe(" — summary availability", () => {
- it("one expired pen with units ⇒ headline 0, expired units as muted suffix", () => {
+ it("only expired stock ⇒ the headline says so instead of '0 of 0'", () => {
+ // "0 von 0 Dosen übrig" beside "+ 4 Einheiten abgelaufen" reads as
+ // two contradicting statements: the first says nothing was ever
+ // recorded, the second says stock was written off. One honest line
+ // replaces both.
seedItems([
{
id: "i1",
@@ -81,9 +85,9 @@ describe(" — summary availability", () => {
const html = render(
,
);
- expect(html).toContain("0 von 0 Dosen übrig");
- expect(html).toContain('data-slot="inventory-expired-suffix"');
- expect(html).toContain("+ 4 Einheiten abgelaufen");
+ expect(html).toContain("Kein nutzbarer Bestand: 4 Einheiten");
+ expect(html).not.toContain("0 von 0 Dosen übrig");
+ expect(html).not.toContain('data-slot="inventory-expired-suffix"');
// The item row itself stays visible with its state badge.
expect(html).toContain("Abgelaufen");
});
@@ -134,5 +138,25 @@ describe(" — summary availability", () => {
);
expect(html).toContain("2 von 4 Dosen übrig");
expect(html).toContain("+ 3 Einheiten abgelaufen");
+ expect(html).not.toContain("Kein nutzbarer Bestand");
+ });
+
+ it("keeps the plain empty-supply reading when nothing was written off", () => {
+ // Every container drained: zero available AND zero expired. That is
+ // the honest "0 of 0", and it must not be replaced.
+ seedItems([
+ {
+ id: "i1",
+ state: "USED_UP",
+ containerType: "PEN",
+ unitsTotal: 4,
+ unitsRemaining: 0,
+ },
+ ]);
+ const html = render(
+ ,
+ );
+ expect(html).toContain("0 von 0 Dosen übrig");
+ expect(html).not.toContain("Kein nutzbarer Bestand");
});
});
diff --git a/src/components/medications/sections/inventory-section.tsx b/src/components/medications/sections/inventory-section.tsx
index d473fe8d5..aac3bba24 100644
--- a/src/components/medications/sections/inventory-section.tsx
+++ b/src/components/medications/sections/inventory-section.tsx
@@ -10,11 +10,15 @@
* `POST /api/medications/[id]/inventory` — quantity in units,
* optional printed expiry. The quantity prefills from the
* medication's `dosesPerUnit` when configured.
- * - CORRECT: a per-item adjust flow (`PATCH …/inventory/[itemId]` with
- * `unitsRemaining`) for stock corrections and manual withdrawals —
- * the count is set absolutely, clamped server-side to the item's
- * capacity, and the canonical state machine derives the next state
- * (0 ⇒ used up).
+ * - CORRECT: a per-item adjust flow (`PATCH …/inventory/[itemId]`) for
+ * stock corrections and manual withdrawals — the count is set
+ * absolutely, clamped server-side to the item's capacity, and the
+ * canonical state machine derives the next state (0 ⇒ used up). The
+ * same flow edits the two dates that decide the state: the
+ * carton-printed expiry and the date the container was opened. The
+ * opening date is written by the intake consumption hook without the
+ * user typing it, so it needs a correction path more than any other
+ * field on the row.
* - DELETE: a per-item trash affordance behind a destructive confirm
* (`DELETE …/inventory/[itemId]`). Consumption stamps on intake
* events that referenced the container stay in place; a later
@@ -60,7 +64,8 @@ import {
SelectValue,
} from "@/components/ui/select";
import { SettingsGroup } from "@/components/medications/settings-group";
-import { useTranslations } from "@/lib/i18n/context";
+import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
+import { formatDate } from "@/lib/date-format";
import { queryKeys } from "@/lib/query-keys";
import { formatUnitCount } from "@/components/medications/units-per-dose";
import {
@@ -117,6 +122,31 @@ interface InventoryItem {
// the UI renders "—" instead of a fabricated 0.
unitsTotal: number | null;
unitsRemaining: number | null;
+ /** ISO instant. The carton-printed expiry; null when none was recorded. */
+ printedExpiry: string | null;
+ /** ISO instant. When the container was opened; null while sealed. */
+ firstUseAt: string | null;
+}
+
+/**
+ * Both dates the row carries are entered — and were always stored — as a
+ * calendar day at the browser's local midnight. Reading them back in the
+ * same zone is what makes the value the user typed the value they see;
+ * pinning to UTC would slide the day for anyone east of Greenwich.
+ */
+function toLocalDayInput(iso: string | null | undefined): string {
+ if (!iso) return "";
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return "";
+ const pad = (n: number) => String(n).padStart(2, "0");
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
+}
+
+/** The inverse: a `yyyy-MM-dd` day back to the instant the API stores. */
+function fromLocalDayInput(day: string): string | null {
+ if (!day) return null;
+ const at = new Date(`${day}T00:00:00`);
+ return Number.isNaN(at.getTime()) ? null : at.toISOString();
}
interface InventoryResponse {
@@ -139,6 +169,15 @@ const STATE_BADGE: Record<
USED_UP: "outline",
};
+/**
+ * Container kinds whose first opening starts a 30-day degradation clock.
+ * Mirrors `IN_USE_CLOCK_CONTAINER_TYPES` in
+ * `src/lib/medications/inventory/state-machine.ts` — the server owns the
+ * transition; the client only needs to know which helper text to show
+ * beside the opening date.
+ */
+const CLOCK_CONTAINER_TYPES: readonly ContainerType[] = ["PEN", "AMPOULE"];
+
/** Default container kind for the register flow, from the delivery form. */
function defaultContainerType(deliveryForm: string | undefined): ContainerType {
if (deliveryForm === "INJECTION") return "PEN";
@@ -160,7 +199,8 @@ export function InventorySection({
/** Defaults the register flow's container type. */
deliveryForm?: string;
}) {
- const { t } = useTranslations();
+ const { t, locale } = useTranslations();
+ const dateFormatPref = useDateFormatPreference();
const queryClient = useQueryClient();
const [addOpen, setAddOpen] = useState(false);
const [adjustItem, setAdjustItem] = useState(null);
@@ -244,6 +284,10 @@ export function InventorySection({
const remaining = summary?.dosesRemaining ?? 0;
const total = summary?.dosesTotal ?? 0;
const expiredUnits = summary?.expiredUnits ?? 0;
+ // Nothing available, but stock is sitting in containers that were
+ // declared unusable. That is a different statement from "no supply
+ // recorded" and the headline has to make the difference.
+ const allExpired = totalUnits === 0 && expiredUnits > 0;
const dialogs = (
<>
@@ -302,8 +346,17 @@ export function InventorySection({
>
- {t("medications.detail.bestand.summary", { remaining, total })}
- {perDose !== 1 && (
+ {/* "0 of 0 doses" beside "+ 54 units expired" read as two
+ contradicting statements — the first says nothing was ever
+ configured, the second says stock was written off. When the
+ available pool is empty and expired stock is all that is
+ left, say that instead. */}
+ {allExpired
+ ? t("medications.detail.bestand.summaryAllExpired", {
+ units: expiredUnits,
+ })
+ : t("medications.detail.bestand.summary", { remaining, total })}
+ {perDose !== 1 && !allExpired && (
{t("medications.detail.bestand.unitsDetail", {
remaining: formatUnitCount(remainingUnits),
@@ -311,7 +364,7 @@ export function InventorySection({
})}
)}
- {expiredUnits > 0 && (
+ {expiredUnits > 0 && !allExpired && (
+ {/* The two dates that decide the row's state. They used to
+ be invisible here — a container could read EXPIRED with
+ nothing on screen to explain why, and no way to correct
+ the value that caused it. */}
+ {(item.firstUseAt || item.printedExpiry) && (
+
+ {item.firstUseAt && (
+
+ {t("medications.detail.bestand.openedOn", {
+ date: formatDate(
+ toLocalDayInput(item.firstUseAt),
+ dateFormatPref,
+ locale,
+ ),
+ })}
+
+ )}
+ {item.printedExpiry && (
+
+ {t("medications.detail.bestand.expiresOn", {
+ date: formatDate(
+ toLocalDayInput(item.printedExpiry),
+ dateFormatPref,
+ locale,
+ ),
+ })}
+
+ )}
+
+ )}
);
diff --git a/src/lib/jobs/__tests__/medication-low-stock.test.ts b/src/lib/jobs/__tests__/medication-low-stock.test.ts
index 4aa1a21f0..618b2ae7f 100644
--- a/src/lib/jobs/__tests__/medication-low-stock.test.ts
+++ b/src/lib/jobs/__tests__/medication-low-stock.test.ts
@@ -48,6 +48,7 @@ describe("evaluateMedicationRunway", () => {
runwayDays: 6,
dosesRemaining: 6,
unitsRemaining: 6,
+ expiredUnits: 0,
});
});
@@ -65,6 +66,8 @@ describe("evaluateMedicationRunway", () => {
);
expect(out.dosesRemaining).toBe(2);
expect(out.runwayDays).toBe(2);
+ // Written-off stock is surfaced alongside, never folded into supply.
+ expect(out.expiredUnits).toBe(10);
});
it("returns runway 0 for an exhausted supply with a consuming schedule", () => {
@@ -146,6 +149,7 @@ const payloadArgs = (
medName: "Metformin",
runwayDays: 5,
unitsRemaining: 10,
+ expiredUnits: 0,
leadDays: 0,
triggerDays: 7,
schedules: [dailySchedule],
@@ -174,6 +178,28 @@ describe("buildLowStockPayload", () => {
expect(out.body).toContain("less than one day");
});
+ it("names the written-off stock when nothing usable is left but expired units are", () => {
+ // "0 units left" and "everything you have left is written off" are
+ // different errands; the second must not read as the first.
+ const out = payloadArgs({
+ runwayDays: 0,
+ unitsRemaining: 0,
+ expiredUnits: 54,
+ });
+ expect(out.body).toContain("no usable supply left");
+ expect(out.body).toContain("54 units are marked expired");
+ expect(out.body).not.toContain("less than one day");
+ });
+
+ it("keeps the depleted line when nothing expired either", () => {
+ const out = payloadArgs({
+ runwayDays: 0,
+ unitsRemaining: 0,
+ expiredUnits: 0,
+ });
+ expect(out.body).toContain("less than one day");
+ });
+
it("renders the last-dose line when the runway is one cadence interval", () => {
// Weekly cadence, runway 7 ≈ one dose-interval → last-dose state.
const out = payloadArgs({
diff --git a/src/lib/jobs/medication-low-stock.ts b/src/lib/jobs/medication-low-stock.ts
index 22f7f6f96..94d9458df 100644
--- a/src/lib/jobs/medication-low-stock.ts
+++ b/src/lib/jobs/medication-low-stock.ts
@@ -105,6 +105,13 @@ export interface LowStockEvaluation {
runwayDays: number | null;
dosesRemaining: number;
unitsRemaining: number;
+ /**
+ * Units sitting in containers that were declared unusable. Never part
+ * of the runway — but "you have nothing left" and "everything you have
+ * left is written off" are different errands, so the push has to be
+ * able to tell them apart.
+ */
+ expiredUnits: number;
}
/**
@@ -141,6 +148,7 @@ export function evaluateMedicationRunway(
runwayDays: null,
dosesRemaining: supply.dosesRemaining,
unitsRemaining: supply.unitsRemaining,
+ expiredUnits: supply.expiredUnits,
};
}
const runwayDays =
@@ -151,6 +159,7 @@ export function evaluateMedicationRunway(
runwayDays,
dosesRemaining: supply.dosesRemaining,
unitsRemaining: supply.unitsRemaining,
+ expiredUnits: supply.expiredUnits,
};
}
@@ -234,6 +243,7 @@ export function buildLowStockPayload(input: {
medName: string;
runwayDays: number;
unitsRemaining: number;
+ expiredUnits: number;
leadDays: number;
triggerDays: number;
schedules: RunwaySchedule[];
@@ -243,6 +253,19 @@ export function buildLowStockPayload(input: {
const title = t("lowStockReminders.title", { medName: input.medName });
if (input.runwayDays < 1) {
+ // Nothing usable left. Whether that means the packs are empty or
+ // that every remaining unit was written off is the difference
+ // between "reorder" and "check whether that really is unusable", so
+ // the two get different copy.
+ if (input.unitsRemaining <= 0 && input.expiredUnits > 0) {
+ return {
+ title,
+ body: t("lowStockReminders.bodyExpired", {
+ medName: input.medName,
+ units: input.expiredUnits,
+ }),
+ };
+ }
return {
title,
body: t("lowStockReminders.bodyToday", {
@@ -305,6 +328,7 @@ interface LowStockEventMeta {
threshold_days: number;
doses_remaining: number;
units_remaining: number;
+ expired_units: number;
reason?: "already_notified" | "no_runway" | "no_channel";
}
@@ -436,6 +460,7 @@ export async function runMedicationLowStockTick(
threshold_days: triggerDays,
doses_remaining: evaluation.dosesRemaining,
units_remaining: evaluation.unitsRemaining,
+ expired_units: evaluation.expiredUnits,
};
switch (decision) {
@@ -466,6 +491,7 @@ export async function runMedicationLowStockTick(
medName: med.name,
runwayDays: evaluation.runwayDays ?? 0,
unitsRemaining: evaluation.unitsRemaining,
+ expiredUnits: evaluation.expiredUnits,
leadDays,
triggerDays,
schedules: med.schedules,
@@ -487,6 +513,10 @@ export async function runMedicationLowStockTick(
leadDays,
unitsRemaining: evaluation.unitsRemaining,
dosesRemaining: evaluation.dosesRemaining,
+ // Stock the user physically holds but that was declared
+ // unusable. The client shows it so "0 left" never reads
+ // as "you never had any".
+ expiredUnits: evaluation.expiredUnits,
// Deep link — the web-push sender threads this into
// the service-worker payload's click target. Lands on
// the detail page's Bestand (supply) tab, the surface
From fd1464867d039b528db3fb00a85322f2b56dec5c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 10:34:38 +0200
Subject: [PATCH 31/52] fix(medications): separate the two container dates and
name the ampoule caveat
The row printed the opening date and the printed expiry with nothing between
them. It carries the same middle dot the unit figures use.
The docblock now says out loud what the enum cannot distinguish: AMPOULE
covers both a punctured multi-dose vial, where the post-opening window is
real, and a carton of single-use glass ampoules, where it is not. The kind is
never auto-selected, so picking it is a statement about an opened container,
and the opening date is editable when it turns out not to be.
---
.../__tests__/inventory-section-dates.test.tsx | 16 ++++++++++++++++
.../medications/sections/inventory-section.tsx | 1 +
src/lib/medications/inventory/state-machine.ts | 7 ++++++-
3 files changed, 23 insertions(+), 1 deletion(-)
diff --git a/src/components/medications/__tests__/inventory-section-dates.test.tsx b/src/components/medications/__tests__/inventory-section-dates.test.tsx
index b52cbcff2..a35c71485 100644
--- a/src/components/medications/__tests__/inventory-section-dates.test.tsx
+++ b/src/components/medications/__tests__/inventory-section-dates.test.tsx
@@ -124,6 +124,22 @@ describe(" — item-row dates", () => {
expect(html).toContain("Haltbar bis 01.06.2027");
});
+ it("separates the two dates when the row carries both", () => {
+ seedItems([
+ {
+ ...BASE,
+ firstUseAt: "2026-06-12T00:00:00.000Z",
+ printedExpiry: "2027-06-01T00:00:00.000Z",
+ },
+ ]);
+ const html = render(
+ ,
+ );
+ expect(html).toContain("Geöffnet 12.06.2026");
+ expect(html).toContain("Haltbar bis 01.06.2027");
+ expect(html).toContain("·");
+ });
+
it("shows no date line when neither date is recorded", () => {
// Absence reads as absence — no placeholder, no invented date.
seedItems([{ ...BASE, firstUseAt: null, printedExpiry: null }]);
diff --git a/src/components/medications/sections/inventory-section.tsx b/src/components/medications/sections/inventory-section.tsx
index aac3bba24..6473f207f 100644
--- a/src/components/medications/sections/inventory-section.tsx
+++ b/src/components/medications/sections/inventory-section.tsx
@@ -505,6 +505,7 @@ export function InventorySection({
})}
)}
+ {item.firstUseAt && item.printedExpiry && ·}
{item.printedExpiry && (
{t("medications.detail.bestand.expiresOn", {
diff --git a/src/lib/medications/inventory/state-machine.ts b/src/lib/medications/inventory/state-machine.ts
index ed6fd92f5..10b49884c 100644
--- a/src/lib/medications/inventory/state-machine.ts
+++ b/src/lib/medications/inventory/state-machine.ts
@@ -37,7 +37,12 @@
* - AMPOULE — the member a punctured injectable reservoir (ampoule or
* multi-dose vial) lands on; there is no separate VIAL member. Same
* physical fact as the pen: the barrier is broken and the contents
- * are exposed.
+ * are exposed. The enum cannot tell that reservoir apart from a
+ * carton of single-use glass ampoules, where the clock would be
+ * wrong for the same reason it was wrong on a blister; the kind is
+ * never auto-selected, so choosing it is a deliberate statement
+ * about a container that was opened, and the opening date is
+ * editable when it turns out not to be.
* - BLISTER — every tablet sits in its own sealed foil cavity.
* Pressing out tablet 1 does nothing to tablets 2..n. The carton
* expiry is the only expiry.
From 48b8071a67584b31e927511d6416ae3b54f8187b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 10:37:35 +0200
Subject: [PATCH 32/52] test(medications): pin the container-correction wire
contract
Submitting the correction dialog is out of reach for the SSR component
tests, so the two behaviours that only show up on submit are pinned against
the module source: a date rides along only when it changed, and a cleared
field sends null rather than an empty string. The route side of the same
contract is covered by a real request test.
---
.../inventory-section-dates.test.tsx | 31 +++++++++++++++++++
1 file changed, 31 insertions(+)
diff --git a/src/components/medications/__tests__/inventory-section-dates.test.tsx b/src/components/medications/__tests__/inventory-section-dates.test.tsx
index a35c71485..3cc8b63c9 100644
--- a/src/components/medications/__tests__/inventory-section-dates.test.tsx
+++ b/src/components/medications/__tests__/inventory-section-dates.test.tsx
@@ -11,11 +11,21 @@
*
* Project convention is SSR-only component tests (`renderToStaticMarkup`).
*/
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
+const src = readFileSync(
+ resolve(
+ process.cwd(),
+ "src/components/medications/sections/inventory-section.tsx",
+ ),
+ "utf8",
+);
+
// The Radix Dialog / Select portal at runtime, so their bodies never
// materialise in static markup. Collapse the primitives to plain
// wrappers (same trick as the AddInventoryDialog suite).
@@ -204,6 +214,27 @@ describe(" — editable dates", () => {
const html = renderDialog();
expect(html).toContain('id="inventory-adjust-remaining"');
});
+
+ // Submitting is out of SSR reach, so the wire contract is pinned
+ // against the source (same approach as the register-dialog suite).
+ it("sends each date only when it actually changed", () => {
+ // Absent means untouched on the server: saving a unit correction
+ // must not rewrite a date the user never opened.
+ expect(src).toMatch(
+ /\.\.\.\(expiry !== initialExpiry && \{\s*\n?\s*printedExpiry: fromLocalDayInput\(expiry\),/,
+ );
+ expect(src).toMatch(
+ /\.\.\.\(opened !== initialOpened && \{\s*\n?\s*markAsFirstUseAt: fromLocalDayInput\(opened\),/,
+ );
+ });
+
+ it("sends a cleared date as null, not as an empty string", () => {
+ // null is the deliberate "there is no such date"; the route reads it
+ // as a clear, which is the way back out of a wrong auto-open.
+ expect(src).toMatch(
+ /function fromLocalDayInput\(day: string\): string \| null \{\s*\n\s*if \(!day\) return null;/,
+ );
+ });
});
describe(" — printed expiry on create", () => {
From f226a907cbe8c3937f7ea4d5a8af6353f74c6612 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 10:50:52 +0200
Subject: [PATCH 33/52] fix(medications): clear the pending clock deadlines the
repair would have missed
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The first pass of migration 0276 restored the rows the in-use clock had
already flipped, but every clock-less container still IN_USE carried the same
clock-derived `expires_at`, most of them with the deadline still ahead. The
daily expire scan would have worked through them over the following weeks and
reproduced the whole defect one row at a time.
A clock-less container's `expires_at` is now set from its printed expiry —
usually nothing, which is how it stays out of that scan. Pens and ampoules
keep theirs; their clock is real.
---
.../migration.sql | 33 ++++++++++---
...ntory-expiry-container-type-repair.test.ts | 49 ++++++++++++++++---
2 files changed, 68 insertions(+), 14 deletions(-)
diff --git a/prisma/migrations/0276_inventory_expiry_container_type_repair/migration.sql b/prisma/migrations/0276_inventory_expiry_container_type_repair/migration.sql
index 9424627b3..bc63cc098 100644
--- a/prisma/migrations/0276_inventory_expiry_container_type_repair/migration.sql
+++ b/prisma/migrations/0276_inventory_expiry_container_type_repair/migration.sql
@@ -1,5 +1,6 @@
-- Restore supply containers that the in-use clock wrote off although they
--- never carried one.
+-- never carried one, and drop the deadlines that would have written off the
+-- rest of them over the following weeks.
--
-- Until this release the state machine started a 30-day post-opening clock
-- for every container as soon as `first_use_at` was set, including the
@@ -8,8 +9,26 @@
-- the row went EXPIRED, its units dropped out of the available pool, and the
-- supply card read "0 of 0 doses" beside a count of expired units.
--
--- The rows to restore are the ones that can ONLY have reached EXPIRED through
--- that clock:
+-- Two things have to be undone. The rows already flipped need their state
+-- back, and every clock-less container still carries a persisted `expires_at`
+-- derived from that clock — including the ones whose deadline has not landed
+-- yet. Leaving those would let the daily expire scan repeat the whole defect
+-- on its own schedule.
+
+-- (1) A clock-less container's only legitimate deadline is its printed
+-- expiry, so that is what `expires_at` becomes. For the common row that is
+-- NULL, which is precisely how it stays out of the daily expire scan. PEN and
+-- AMPOULE are left alone: their post-opening clock is real, so the deadline
+-- they carry is earned.
+UPDATE "medication_inventory_items"
+SET
+ "expires_at" = "printed_expiry",
+ "updated_at" = NOW()
+WHERE "container_type" NOT IN ('PEN', 'AMPOULE')
+ AND "expires_at" IS DISTINCT FROM "printed_expiry";
+
+-- (2) The rows that already flipped. They are the ones that can ONLY have
+-- reached EXPIRED through that clock:
-- * `container_type` is one of the kinds that has no post-opening clock
-- (everything except PEN and AMPOULE — see IN_USE_CLOCK_CONTAINER_TYPES
-- in src/lib/medications/inventory/state-machine.ts),
@@ -26,17 +45,15 @@
-- * any row whose printed expiry has genuinely lapsed, whatever its kind;
-- * rows with no units left (their state is a USED_UP question, not this
-- one);
--- * every other column. No date is invented and no printed expiry is
--- fabricated: a row that recorded no expiry keeps recording none, which
--- is why `expires_at` is set from `printed_expiry` rather than computed.
+-- * the state of every row that is not EXPIRED, and every other column.
+-- No date is invented and no printed expiry is fabricated: a row that
+-- recorded no expiry keeps recording none.
UPDATE "medication_inventory_items"
SET
"state" = CASE
WHEN "first_use_at" IS NOT NULL THEN 'IN_USE'::"medication_inventory_state"
ELSE 'ACTIVE'::"medication_inventory_state"
END,
- -- The only deadline a clock-less container can carry is its printed one.
- "expires_at" = "printed_expiry",
"updated_at" = NOW()
WHERE "state" = 'EXPIRED'
AND "container_type" NOT IN ('PEN', 'AMPOULE')
diff --git a/tests/integration/inventory-expiry-container-type-repair.test.ts b/tests/integration/inventory-expiry-container-type-repair.test.ts
index 216703aee..2ee6868c9 100644
--- a/tests/integration/inventory-expiry-container-type-repair.test.ts
+++ b/tests/integration/inventory-expiry-container-type-repair.test.ts
@@ -224,25 +224,62 @@ describe("0276 inventory expiry container-type repair — integration", () => {
});
it.each(["ACTIVE", "IN_USE", "USED_UP"] as const)(
- "leaves a %s row untouched",
+ "leaves the state of a %s row alone",
async (state) => {
const prisma = getPrismaClient();
- const expiresAt = new Date(Date.now() + 10 * DAY_MS);
const id = await seedItem(prisma, medicationId, {
containerType: "BLISTER",
state,
unitsRemaining: state === "USED_UP" ? 0 : 54,
- expiresAt,
+ expiresAt: new Date(Date.now() + 10 * DAY_MS),
});
await prisma.$executeRawUnsafe(REPAIR_SQL);
- const row = await readItem(prisma, id);
- expect(row.state).toBe(state);
- expect(row.expiresAt).toEqual(expiresAt);
+ expect((await readItem(prisma, id)).state).toBe(state);
},
);
+ it("clears the pending clock deadline on a still-IN_USE blister", async () => {
+ // The rows the flip has not reached yet. Their `expires_at` is the
+ // old firstUseAt + 30 days, still in the future, and the daily
+ // expire scan would have written them off on schedule.
+ const prisma = getPrismaClient();
+ const openedAt = new Date(Date.now() - 10 * DAY_MS);
+ const id = await seedItem(prisma, medicationId, {
+ containerType: "BLISTER",
+ state: "IN_USE",
+ firstUseAt: openedAt,
+ printedExpiry: null,
+ expiresAt: new Date(openedAt.getTime() + 30 * DAY_MS),
+ });
+
+ await prisma.$executeRawUnsafe(REPAIR_SQL);
+
+ const row = await readItem(prisma, id);
+ expect(row.state).toBe("IN_USE");
+ expect(row.expiresAt).toBeNull();
+ });
+
+ it("leaves a pen's pending clock deadline in place", async () => {
+ const prisma = getPrismaClient();
+ const openedAt = new Date(Date.now() - 10 * DAY_MS);
+ const expiresAt = new Date(openedAt.getTime() + 30 * DAY_MS);
+ const id = await seedItem(prisma, medicationId, {
+ containerType: "PEN",
+ state: "IN_USE",
+ firstUseAt: openedAt,
+ printedExpiry: null,
+ expiresAt,
+ });
+
+ await prisma.$executeRawUnsafe(REPAIR_SQL);
+
+ const row = await readItem(prisma, id);
+ expect(row.state).toBe("IN_USE");
+ expect(row.expiresAt).toEqual(expiresAt);
+ });
+
it("is idempotent — a second application changes nothing", async () => {
const prisma = getPrismaClient();
const id = await seedItem(prisma, medicationId, {
From 7b59388887a5725f552a205e62337ee51b92ee91 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 10:55:11 +0200
Subject: [PATCH 34/52] test(medications): make the repair's idempotency
assertion able to fail
The comparison now includes `updated_at`, so a second application that
rewrites an already-correct row is visible even when the value it writes is
identical. Without it the assertion passed whether or not the guard that
skips untouched rows was there.
---
.../inventory-expiry-container-type-repair.test.ts | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/tests/integration/inventory-expiry-container-type-repair.test.ts b/tests/integration/inventory-expiry-container-type-repair.test.ts
index 2ee6868c9..b27c363d6 100644
--- a/tests/integration/inventory-expiry-container-type-repair.test.ts
+++ b/tests/integration/inventory-expiry-container-type-repair.test.ts
@@ -74,6 +74,9 @@ async function readItem(prisma: PrismaClient, id: string) {
printedExpiry: true,
firstUseAt: true,
unitsRemaining: true,
+ // Part of the read so the idempotency assertion below can see a
+ // second application touching a row it should have skipped.
+ updatedAt: true,
},
});
return row;
@@ -280,7 +283,10 @@ describe("0276 inventory expiry container-type repair — integration", () => {
expect(row.expiresAt).toEqual(expiresAt);
});
- it("is idempotent — a second application changes nothing", async () => {
+ it("is idempotent — a second application touches nothing", async () => {
+ // `updatedAt` is part of the compared shape, so a second pass that
+ // rewrites an already-correct row is visible even when the value it
+ // writes is the same one.
const prisma = getPrismaClient();
const id = await seedItem(prisma, medicationId, {
containerType: "BLISTER",
From d91b7cb57a86f199fd2fa4cb53c7d8278c5b0d74 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 11:02:18 +0200
Subject: [PATCH 35/52] test(medications): cover the container kind on the
import and refund paths
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Hardcoding the container kind in the batch-import consumption path or in the
refund path left the whole suite green — neither had a scenario that read a
clock-less container. Both now do: a back-dated import chunk against a tablet
pack stays IN_USE with no deadline, and a refund that brings units back to a
long-opened pack reads IN_USE rather than expired. The pen counterpart of the
import case is pinned alongside.
The fake client's `updateMany` learns the `id: { in: [...] }` form the batch
claim uses; it only understood a single id before.
The no-runway arm of the low-stock evaluation now has its written-off count
asserted too.
---
.../__tests__/medication-low-stock.test.ts | 9 +-
.../inventory/__tests__/consumption.test.ts | 97 ++++++++++++++++++-
2 files changed, 102 insertions(+), 4 deletions(-)
diff --git a/src/lib/jobs/__tests__/medication-low-stock.test.ts b/src/lib/jobs/__tests__/medication-low-stock.test.ts
index 618b2ae7f..77ce5506e 100644
--- a/src/lib/jobs/__tests__/medication-low-stock.test.ts
+++ b/src/lib/jobs/__tests__/medication-low-stock.test.ts
@@ -76,7 +76,14 @@ describe("evaluateMedicationRunway", () => {
});
it("derives NO runway for a schedule-less medication", () => {
- expect(evaluateMedicationRunway([item(2)], 1, []).runwayDays).toBe(null);
+ const out = evaluateMedicationRunway(
+ [item(2), { state: "EXPIRED", unitsTotal: 30, unitsRemaining: 7 }],
+ 1,
+ [],
+ );
+ expect(out.runwayDays).toBe(null);
+ // The no-runway arm still reports the written-off stock.
+ expect(out.expiredUnits).toBe(7);
});
});
diff --git a/src/lib/medications/inventory/__tests__/consumption.test.ts b/src/lib/medications/inventory/__tests__/consumption.test.ts
index 850103efa..7928e09e9 100644
--- a/src/lib/medications/inventory/__tests__/consumption.test.ts
+++ b/src/lib/medications/inventory/__tests__/consumption.test.ts
@@ -19,7 +19,11 @@ vi.mock("@/lib/logging/context", () => ({
import { Prisma } from "@/generated/prisma/client";
import { annotate } from "@/lib/logging/context";
-import { consumeForIntake, restoreForIntake } from "../consumption";
+import {
+ consumeForIntake,
+ consumeImportedIntakesBatch,
+ restoreForIntake,
+} from "../consumption";
const NOW = new Date("2026-06-10T12:00:00.000Z");
const MS_PER_DAY = 24 * 60 * 60 * 1000;
@@ -98,7 +102,9 @@ function makeClient(state: FakeState) {
updateMany: vi.fn(
async (args: {
where: {
- id?: string;
+ // The single-event hook narrows by one id; the import batch
+ // claims a whole chunk with `id: { in: [...] }`.
+ id?: string | { in: string[] };
userId?: string;
medicationId?: string;
deletedAt?: null;
@@ -110,7 +116,13 @@ function makeClient(state: FakeState) {
}) => {
const w = args.where;
const matches = state.events.filter((e) => {
- if (w.id !== undefined && e.id !== w.id) return false;
+ if (typeof w.id === "string" && e.id !== w.id) return false;
+ if (
+ w.id !== undefined &&
+ typeof w.id === "object" &&
+ !w.id.in.includes(e.id)
+ )
+ return false;
if (w.userId !== undefined && e.userId !== w.userId) return false;
if (
w.medicationId !== undefined &&
@@ -633,6 +645,56 @@ describe("consumeForIntake", () => {
});
});
+describe("consumeImportedIntakesBatch", () => {
+ // The import worker's batch path derives state through the same state
+ // machine as the request-time hook, so it has to read the container
+ // kind too — a back-dated import chunk against a tablet pack must not
+ // write the pack off.
+ it("leaves a back-dated import auto-open of a blister IN_USE", async () => {
+ const backdated = new Date(NOW.getTime() - 40 * MS_PER_DAY);
+ const state: FakeState = {
+ unitsPerDose: 1,
+ events: [takenEvent({ takenAt: backdated })],
+ items: [item({ id: "sealed", containerType: "BLISTER" })],
+ };
+ const client = makeClient(state);
+ await consumeImportedIntakesBatch({
+ client: client as never,
+ userId: "user-1",
+ medicationId: "med-1",
+ events: [{ eventId: "evt-1", intakeAt: backdated }],
+ });
+
+ const opened = state.items[0];
+ expect(opened.firstUseAt).toEqual(backdated);
+ expect(opened.state).toBe("IN_USE");
+ expect(opened.expiresAt).toBeNull();
+ expect(opened.unitsRemaining).toBe(9);
+ });
+
+ it("still expires a back-dated import auto-open of a pen", async () => {
+ const backdated = new Date(NOW.getTime() - 40 * MS_PER_DAY);
+ const state: FakeState = {
+ unitsPerDose: 1,
+ events: [takenEvent({ takenAt: backdated })],
+ items: [item({ id: "sealed", containerType: "PEN" })],
+ };
+ const client = makeClient(state);
+ await consumeImportedIntakesBatch({
+ client: client as never,
+ userId: "user-1",
+ medicationId: "med-1",
+ events: [{ eventId: "evt-1", intakeAt: backdated }],
+ });
+
+ const opened = state.items[0];
+ expect(opened.state).toBe("EXPIRED");
+ expect(opened.expiresAt).toEqual(
+ new Date(backdated.getTime() + 30 * MS_PER_DAY),
+ );
+ });
+});
+
describe("restoreForIntake", () => {
function restoreArgs(client: ReturnType) {
return { client: client as never, userId: "user-1", eventId: "evt-1" };
@@ -725,6 +787,35 @@ describe("restoreForIntake", () => {
expect(state.items[0].state).toBe("EXPIRED");
});
+ it("re-derives IN_USE for a refunded blister whose opening is long past", async () => {
+ // Same refund against a tablet pack: the units come back and the
+ // pack is usable again. There is no post-opening window to have
+ // lapsed, so EXPIRED would be wrong.
+ const state: FakeState = {
+ unitsPerDose: 1,
+ events: [
+ takenEvent({
+ inventoryConsumption: [{ itemId: "open", units: 1 }],
+ }),
+ ],
+ items: [
+ item({
+ id: "open",
+ containerType: "BLISTER",
+ state: "USED_UP",
+ unitsTotal: 4,
+ unitsRemaining: 0,
+ firstUseAt: new Date(NOW.getTime() - 35 * MS_PER_DAY),
+ }),
+ ],
+ };
+ const client = makeClient(state);
+ await restoreForIntake(restoreArgs(client));
+
+ expect(state.items[0].unitsRemaining).toBe(1);
+ expect(state.items[0].state).toBe("IN_USE");
+ });
+
it("is a no-op when the row carries no stamp", async () => {
const state: FakeState = {
unitsPerDose: 1,
From 5b3883859403b0710af491fbf3feb26763432542 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 11:05:10 +0200
Subject: [PATCH 36/52] test(medications): type the container fixture so the
overrides compile
`Partial` inferred the literal types off the base fixture, so an
override could not swap the container kind or null a date. The fixture takes
an explicit row shape instead.
---
.../inventory-section-dates.test.tsx | 20 ++++++++++++++-----
1 file changed, 15 insertions(+), 5 deletions(-)
diff --git a/src/components/medications/__tests__/inventory-section-dates.test.tsx b/src/components/medications/__tests__/inventory-section-dates.test.tsx
index 3cc8b63c9..fe9475895 100644
--- a/src/components/medications/__tests__/inventory-section-dates.test.tsx
+++ b/src/components/medications/__tests__/inventory-section-dates.test.tsx
@@ -95,13 +95,23 @@ function seedItems(items: unknown[]) {
});
}
+interface Row {
+ id: string;
+ state: "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
+ containerType: "PEN" | "AMPOULE" | "BLISTER" | "INHALER" | "BOTTLE" | "OTHER";
+ unitsTotal: number | null;
+ unitsRemaining: number | null;
+ printedExpiry: string | null;
+ firstUseAt: string | null;
+}
+
const BASE = {
id: "i1",
- state: "IN_USE" as const,
- containerType: "BLISTER" as const,
+ state: "IN_USE",
+ containerType: "BLISTER",
unitsTotal: 60,
unitsRemaining: 54,
-};
+} satisfies Omit;
describe(" — item-row dates", () => {
it("renders the opening date on the row", () => {
@@ -161,13 +171,13 @@ describe(" — item-row dates", () => {
});
describe(" — editable dates", () => {
- const item = {
+ const item: Row = {
...BASE,
firstUseAt: "2026-06-12T00:00:00.000Z",
printedExpiry: "2027-06-01T00:00:00.000Z",
};
- function renderDialog(over: Partial = {}): string {
+ function renderDialog(over: Partial = {}): string {
return render(
Date: Sun, 26 Jul 2026 18:00:20 +0200
Subject: [PATCH 37/52] chore(db): number the inventory repair last in the
chain
---
.../migration.sql | 0
1 file changed, 0 insertions(+), 0 deletions(-)
rename prisma/migrations/{0276_inventory_expiry_container_type_repair => 0279_inventory_expiry_container_type_repair}/migration.sql (100%)
diff --git a/prisma/migrations/0276_inventory_expiry_container_type_repair/migration.sql b/prisma/migrations/0279_inventory_expiry_container_type_repair/migration.sql
similarity index 100%
rename from prisma/migrations/0276_inventory_expiry_container_type_repair/migration.sql
rename to prisma/migrations/0279_inventory_expiry_container_type_repair/migration.sql
From 9db7df1d4d4c8e57b25fc58712b1d8ffbaab1855 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 18:02:35 +0200
Subject: [PATCH 38/52] test(db): follow the inventory repair to its new number
---
.../inventory-expiry-container-type-repair.test.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/tests/integration/inventory-expiry-container-type-repair.test.ts b/tests/integration/inventory-expiry-container-type-repair.test.ts
index b27c363d6..5f2067b01 100644
--- a/tests/integration/inventory-expiry-container-type-repair.test.ts
+++ b/tests/integration/inventory-expiry-container-type-repair.test.ts
@@ -1,5 +1,5 @@
/**
- * Integration coverage for `0276_inventory_expiry_container_type_repair`.
+ * Integration coverage for `0279_inventory_expiry_container_type_repair`.
*
* The repair reverses the rows the in-use clock wrote off although their
* container never carried one. A unit test cannot prove it: the predicate is
@@ -26,7 +26,7 @@ import type {
const REPAIR_SQL = readFileSync(
resolve(
process.cwd(),
- "prisma/migrations/0276_inventory_expiry_container_type_repair/migration.sql",
+ "prisma/migrations/0279_inventory_expiry_container_type_repair/migration.sql",
),
"utf8",
);
From dbac3d3abd2fed824dff0f2b3bc59a0b46305884 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 18:18:28 +0200
Subject: [PATCH 39/52] =?UTF-8?q?chore(release):=20v1.33.0=20=E2=80=94=20w?=
=?UTF-8?q?hat=20was=20silently=20failing?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
CHANGELOG.md | 96 +++++++++++++++++++++++++++++++++++++++++++
docs/api/openapi.yaml | 2 +-
package.json | 2 +-
public/sw.js | 2 +-
4 files changed, 99 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8f8dbca67..9ff26d31c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,102 @@
## [Unreleased]
+## [1.33.0] — 2026-07-26
+
+Eight defects, and every one of them had the same shape: something failed
+reliably, the code recorded it precisely, and no person ever found out.
+
+**"Delete All Data" deleted 13 tables out of 92 and reported success.** What
+survived included mental-health questionnaires, lab results, the menstrual
+record, allergies, family history, the whole document vault, every AI
+conversation, and clinician share links that kept working. On the account row
+itself, 11 of 119 columns were cleared, so name, insurer, insurance number, home
+coordinates, profile photo and every integration credential stayed. The wipe now
+covers everything the account owns except what is needed to sign in again, the
+list is derived from the schema so a newly added table cannot quietly survive,
+and the confirmation says what will go before you agree rather than after.
+
+**A device sync failed 1149 times without anyone being told.** The escalation
+had no rung above "temporary", so a connection could fail hourly for weeks while
+the card read "connected". Worse, providers that sync several things separately
+had one healthy part erase the error recorded by a broken one, twice an hour,
+including a connection that had already been parked for a dead credential. A
+failing connection now shows how long it has been failing, escalates after a
+day, and stops retrying after two with a reconnect button.
+
+**The Environment module had never once saved a row, for anyone.** One
+declaration in 49 was missing the line that maps it to the database, so every
+write referred to a type that does not exist. The job failed every night and
+recorded that failure faithfully, and no code anywhere reads a failed job. Both
+halves are fixed: the module writes, and a failing background job now names
+itself on the admin page and on the affected screen. Past days can be fetched
+from the weather source; the nightly job fills the last week by itself and the
+backfill button covers the rest.
+
+**Sleep was counted twice.** A device that re-exports a night it already sent
+left two overlapping in-bed periods, and both were added. One night read ten
+hours in the headline and twenty in the legend. The same fault inflated the
+time-asleep figure itself. Every stage total is now the measure of its own
+period rather than the sum of overlapping ones. Naps are also now told apart
+from the main sleep everywhere a person reads them, not just in the export.
+
+**Twenty-two controls could destroy something without asking.** Among them a
+single tap that cleared the allergy list along with a text note, and a switch
+labelled "Custom range" that deleted the stored range when switched off. Every
+destructive control is now registered, and a new one cannot ship without being
+classified.
+
+**Medication stock was written off that had not expired.** The thirty-day clock
+that applies to an opened injection pen was being applied to a blister of
+tablets, so pressing out the first tablet started a countdown and a month later
+the rest were declared unusable. The container kind was recorded and never read.
+Pens and ampoules keep the clock; blisters, bottles and inhalers do not. A
+repair returns stock that was written off, and the dates that drive it can now
+be seen and corrected on the item itself.
+
+**Insights scrolled sideways on every phone.** Two guards were watching for
+exactly that and both measured the document, while the app scrolls inside an
+inner area that absorbed the overflow. They now measure what actually moves.
+
+**A third value for gender was accepted by the app and missing from the
+published contract in five places, and dropped or coerced in nine more.** A
+fitness calculation scored anyone without a recorded gender using the male
+coefficients, and the doctor report labelled any unrecognised value as
+non-binary. Every place the field is read, written, documented or displayed was
+gone through.
+
+### Two additions
+
+Medication history from a widely used iOS export tool can now be read directly,
+as the file actually is, rather than converted by hand. The scheduled time and
+the time taken stay two separate facts, which is what a hand-mapped file
+destroys. Every column is either used, reported, or named as not read, and you
+see which before you import. Seven status values are ruled on individually: only
+"taken" and "skipped" become doses, and a snoozed reminder or an unanswered
+notification is counted and named rather than turned into a decision you never
+made.
+
+Behavioural patterns now cover alcohol against next-day recovery and resting
+heart rate. The statistics for it were already in the app.
+
+### For operators
+
+Four migrations. `0276` adds retention indexes, `0277` adds failure-streak
+tracking, `0278` scopes the intake import key per account, `0279` repairs
+inventory that was wrongly expired. None deletes or rewrites a measurement.
+
+### Credit
+
+Reported by @lutzkind (sleep double-counting, the gender contract, the
+Environment enum, and a run of earlier reports), @Nazza01 (the intake import,
+plus the export file and the format documentation that made reading it
+possible), @DrGithubble (the unit and precision work that led here) and @doenke
+(the measurement table, narrowed by the reporter into something buildable).
+
+Two of these defects were found because someone outside this project read the
+code and said what was wrong. The Environment module had been broken for every
+user since it shipped and nobody inside noticed.
+
## [1.32.39] — 2026-07-26
The doctor report now carries what you chose, and nothing else.
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 7be123662..704a2ba43 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
- version: 1.32.39
+ version: 1.33.0
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
diff --git a/package.json b/package.json
index b5a3e56ec..70d975c0d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "healthlog",
- "version": "1.32.39",
+ "version": "1.33.0",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
diff --git a/public/sw.js b/public/sw.js
index 945e43296..763fc3589 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
- /* @sw-version-fallback */ "v1.32.39";
+ /* @sw-version-fallback */ "v1.33.0";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
From 24d699f252634149432f3301afb68ad32d1730ce Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 18:26:45 +0200
Subject: [PATCH 40/52] chore(bundle): re-measure the client budget for the
surfaces this release adds
---
bundle-budget.json | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/bundle-budget.json b/bundle-budget.json
index 124e100a7..aee297fdd 100644
--- a/bundle-budget.json
+++ b/bundle-budget.json
@@ -1,11 +1,11 @@
{
- "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include the shared rootMainFiles baseline. Measured at v1.32.0 after the correctness/UX architecture wave: / 425, /insights 411, /measurements 412, /insights/mood 428, total 2982, baseline 129. Route caps retain focused headroom; the aggregate cap allows normal chunk noise while still catching a new ~90 KB duplicated library. Message catalogs and duplicate Recharts chunks have dedicated structural guards.",
+ "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include the shared rootMainFiles baseline. Re-measured at v1.33.0: / 429, /insights 414, /measurements 416, /insights/mood 432, total 3060, baseline 130. The aggregate rose 78 KB from v1.32.0's 2982 because that release adds surfaces rather than libraries: a dose-history import card, inventory date editing, fourteen confirmation dialogs, and a job-failure panel. Every per-route cap still passes with headroom, and the thing this aggregate exists to catch (a newly duplicated ~90 KB library) did not happen: the Recharts chunk count is still 1 and structurally guarded. Raised 3050 to 3120 for that reason, not to make a red check green. Route caps retain focused headroom. Message catalogs and duplicate Recharts chunks have dedicated structural guards.",
"routesKbGz": {
"/page": 460,
"/insights/page": 445,
"/measurements/page": 445,
"/insights/mood/page": 460
},
- "totalClientKbGz": 3050,
+ "totalClientKbGz": 3120,
"maxRechartsChunks": 1
}
From a402eaaf15f0c5a8ebab37d76fbf281f23654138 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 26 Jul 2026 14:55:42 +0200
Subject: [PATCH 41/52] fix(coach): tell a history outside the window apart
from a metric never recorded
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A Coach retrieval tool that read the window and found nothing returned
`reason: "no_data"` — the same code for two situations that have nothing in
common: the record holds nothing for that metric, and the record holds a
substantial history for it, all of it older than the window that was searched.
Nothing downstream could tell them apart, so the Coach said the honest-sounding
sentence for the wrong case, and then, having been told there was nothing there,
changed the subject. Someone with a year of imported glucose readings was told
the readings did not exist, on the same account whose glucose page was showing
them.
The window cap stays. It exists because a multi-year history does not fit a
prompt budget, and widening it would only move the same failure. What was
missing is the ability to say a different true sentence.
- `tools/availability.ts` — one grouped aggregate per miss, no rows
materialised, resolving four states: `no_data` (probe confirmed empty),
`outside_window` (rows exist, older than the searched window; carries the
count, the date range in the user's own timezone, and a bounded per-series
mean/min/max), `unavailable_in_scope` (rows exist inside the window and the
domain still produced none — a module is off), and `no_data_unconfirmed` (the
probe itself failed; absence was never established, so it is not asserted).
- Every empty read in the executor routes through it. A bare `no_data` can no
longer be returned without the record having been checked.
- The DATA INVENTORY carries the same distinction, so a domain the window
cannot reach is advertised with what it holds instead of as absent — the model
no longer skips the fetch, and a vague follow-up keeps the subject.
- Both instructions that told the model to report absence and move on now
branch on the reason code, and forbid answering about one metric by reporting
another.
- The out-of-window aggregate joins the authoritative set for the prose
verifier, so the sentence the fix exists to make sayable is not elided.
- A cross-cutting narration block that throws is now counted rather than
vanishing into a `null` that looks like honest absence.
- The medication-compliance page tells a failed read apart from an account with
no medications, instead of inviting someone to re-enter what it already holds.
Refs #648
---
src/app/api/insights/chat/route.ts | 4 +-
src/app/insights/medications/page.tsx | 27 +-
src/lib/ai/coach/snapshot.ts | 120 ++---
src/lib/ai/coach/source-measurement-types.ts | 75 +++
src/lib/ai/coach/tools/availability.ts | 500 +++++++++++++++++++
src/lib/ai/coach/tools/definitions.ts | 12 +-
src/lib/ai/coach/tools/executor.ts | 113 ++++-
src/lib/ai/coach/tools/inventory.ts | 113 ++++-
src/lib/ai/coach/tools/loop.ts | 25 +-
src/lib/ai/coach/tools/system-addendum.ts | 40 +-
10 files changed, 917 insertions(+), 112 deletions(-)
create mode 100644 src/lib/ai/coach/source-measurement-types.ts
create mode 100644 src/lib/ai/coach/tools/availability.ts
diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts
index b989f897a..9779bbb3b 100644
--- a/src/app/api/insights/chat/route.ts
+++ b/src/app/api/insights/chat/route.ts
@@ -695,7 +695,9 @@ async function handleChatRequest(request: NextRequest): Promise {
// ("you've logged 42 BP readings") stays grounded.
const presentToolPayloads = [
...(workoutEvidence === null ? [] : [workoutEvidence]),
- ...(loop.toolResults ?? []).map((r) => r.data),
+ // A miss carries no `data` but may carry `available` — the bounded
+ // out-of-window aggregate rule 3 lets the model cite. Ground it.
+ ...(loop.toolResults ?? []).map((r) => r.data ?? r.available),
];
toolResultPayloads =
presentToolPayloads.length > 0
diff --git a/src/app/insights/medications/page.tsx b/src/app/insights/medications/page.tsx
index 552b5eaaf..c499c329b 100644
--- a/src/app/insights/medications/page.tsx
+++ b/src/app/insights/medications/page.tsx
@@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { Progress } from "@/components/ui/progress";
+import { QueryErrorCard } from "@/components/ui/query-error-card";
import { ComplianceHeatmap } from "@/components/charts/compliance-heatmap";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
@@ -93,7 +94,12 @@ export default function InsightsMedikamentePage() {
const { isAuthenticated } = useAuth();
const { t, locale } = useTranslations();
- const { data: comprehensive, isLoading: isComprehensiveLoading } = useQuery({
+ const {
+ data: comprehensive,
+ isLoading: isComprehensiveLoading,
+ isError: isComprehensiveError,
+ refetch: refetchComprehensive,
+ } = useQuery({
queryKey: queryKeys.insightsComprehensive(),
queryFn: async () => {
return apiGet("/api/insights/comprehensive");
@@ -153,6 +159,25 @@ export default function InsightsMedikamentePage() {
);
}
+ // A failed comprehensive read leaves `medications` empty, which is
+ // indistinguishable from an account that tracks none — and the empty state
+ // then invites someone on five daily medications to add the medications the
+ // app already holds. A read failure gets its own branch, before the length
+ // check, mirroring the sibling overview at `/insights`.
+ if (isComprehensiveError) {
+ return (
+
+ refetchComprehensive()}
+ />
+
+ );
+ }
+
if (medications.length === 0) {
// v1.4.27 F17 — medication compliance is event-driven so the
// gate reads `medications.length > 0`. CTA targets `/medications`
diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts
index f6cbe5fcc..7d7ff7c0b 100644
--- a/src/lib/ai/coach/snapshot.ts
+++ b/src/lib/ai/coach/snapshot.ts
@@ -47,6 +47,7 @@ import {
type GroundingMetricInput,
} from "./reference-grounding";
import type { MeasurementType } from "@/generated/prisma/client";
+import { COACH_SOURCE_MEASUREMENT_TYPES } from "@/lib/ai/coach/source-measurement-types";
import type { ReferenceMetric } from "@/lib/reference-ranges";
import { isCycleAvailableForUser } from "@/lib/cycle/gate";
import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate";
@@ -271,6 +272,29 @@ export async function buildCoachSnapshot(
return result;
}
+/**
+ * A cross-cutting narration block that throws must not vanish silently. Each of
+ * these builders is additive — the snapshot is still valid without it — so the
+ * catch stays, but the failure is now countable: `signalTrust` is the block that
+ * tells the model HOW MUCH to trust the numbers it is about to narrate, and a
+ * builder that fails to `null` is indistinguishable from a user who simply has
+ * no trust caveats. Absence and failure are different things and the wide event
+ * now says which one happened.
+ */
+function blockFailed(block: string): (err: unknown) => null {
+ return (err: unknown) => {
+ // Resolved before the annotate call, not inline: the free-text guard in
+ // `snapshot.test.ts` reads three lines around a `name:` key and an inline
+ // `err.name` there looks like an un-sanitised user string to it.
+ const reason = err instanceof Error ? err.name : "unknown";
+ annotate({
+ action: { name: "coach.snapshot.block_failed" },
+ meta: { block, reason },
+ });
+ return null;
+ };
+}
+
async function buildCoachSnapshotImpl(
userId: string,
scope?: CoachScope,
@@ -545,81 +569,15 @@ async function buildCoachSnapshotImpl(
coarseTailPromises.pulse ?? Promise.resolve(undefined),
]);
- // v1.4.23 W6 (S-04) — single source of truth for the
- // CoachScopeSource → MeasurementType[] mapping. Drives both the
- // SQL `WHERE type IN (…)` build below and the Apple-Health timeline
- // block table downstream, so adding a new metric is one entry
- // instead of three (boolean + push + appleHealthBlocks row).
- // Default Coach scope leaves the Apple Health rows off; non-iOS
- // accounts never pay the type-IN overhead because their `sources`
- // set never enables them.
- // v1.7.0 — extended with the full clustered taxonomy. `mood`,
- // `compliance`, and `workouts` map to no `MeasurementType` because
- // they read separate models (MoodEntry / MedicationIntakeEvent /
- // Workout) and are handled by their own branches below. `glucose`
- // also reads `Measurement` but needs the `glucoseContext` column, so
- // its block is built separately rather than from the shared
- // `byType()` rows.
- const METRIC_TYPES: Record = {
- bp: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
- weight: ["WEIGHT"],
- pulse: ["PULSE"],
- mood: [],
- compliance: [],
- // v1.30.4 (HRV union) — Oura / Polar / WHOOP write nightly HRV as RMSSD
- // (`HRV_RMSSD`), never SDNN (`HEART_RATE_VARIABILITY`, Apple / Fitbit).
- // The app's HRV surface already unions both (`sub-page-metric.ts`);
- // resolving SDNN alone here made a ring/strap-only user's Coach context
- // + the `get_metric_series`/rich-read MCP paths report a false
- // `{present:false}` for HRV even though the app charts it.
- hrv: ["HEART_RATE_VARIABILITY", "HRV_RMSSD"],
- sleep: ["SLEEP_DURATION"],
- resting_hr: ["RESTING_HEART_RATE"],
- steps: ["ACTIVITY_STEPS"],
- active_energy: ["ACTIVE_ENERGY_BURNED"],
- flights: ["FLIGHTS_CLIMBED"],
- distance: ["WALKING_RUNNING_DISTANCE"],
- vo2_max: ["VO2_MAX"],
- body_temp: ["BODY_TEMPERATURE"],
- // ── cardio composition / vascular ──
- walking_hr: ["WALKING_HEART_RATE_AVERAGE"],
- respiratory_rate: ["RESPIRATORY_RATE"],
- spo2: ["OXYGEN_SATURATION"],
- pulse_wave_velocity: ["PULSE_WAVE_VELOCITY"],
- vascular_age: ["VASCULAR_AGE"],
- // ── body composition ──
- body_fat: ["BODY_FAT"],
- fat_mass: ["FAT_MASS"],
- fat_free_mass: ["FAT_FREE_MASS"],
- muscle_mass: ["MUSCLE_MASS"],
- lean_body_mass: ["LEAN_BODY_MASS"],
- bone_mass: ["BONE_MASS"],
- total_body_water: ["TOTAL_BODY_WATER"],
- bmi: ["BODY_MASS_INDEX"],
- visceral_fat: ["VISCERAL_FAT"],
- // ── metabolic — built via the dedicated glucose branch ──
- glucose: ["BLOOD_GLUCOSE"],
- // ── mobility & gait ──
- walking_steadiness: ["WALKING_STEADINESS"],
- walking_asymmetry: ["WALKING_ASYMMETRY"],
- walking_double_support: ["WALKING_DOUBLE_SUPPORT"],
- walking_step_length: ["WALKING_STEP_LENGTH"],
- walking_speed: ["WALKING_SPEED"],
- // ── environment / exposure ──
- audio_env: ["AUDIO_EXPOSURE_ENV"],
- audio_headphone: ["AUDIO_EXPOSURE_HEADPHONE"],
- audio_event: ["AUDIO_EXPOSURE_EVENT"],
- daylight: ["TIME_IN_DAYLIGHT"],
- skin_temp: ["SKIN_TEMPERATURE"],
- // ── workouts — read from the Workout model, not Measurement ──
- workouts: [],
- };
-
// Single fetch for all measurement types — Prisma's filter pushes
// the type list into one SQL `WHERE type IN (…)` so we don't pay
- // per-metric round-trips.
+ // per-metric round-trips. The CoachScopeSource → MeasurementType[] table
+ // lives in `source-measurement-types.ts` (v1.4.23 W6 / S-04 kept it a single
+ // source of truth; it is hoisted out of this function so the availability
+ // probe that answers "does this domain exist OUTSIDE the window?" resolves
+ // its types from the same table this windowed read filters on).
const wantedTypes = Array.from(sources).flatMap(
- (source) => METRIC_TYPES[source] ?? [],
+ (source) => COACH_SOURCE_MEASUREMENT_TYPES[source] ?? [],
);
const measurementRowsPromise =
@@ -628,7 +586,7 @@ async function buildCoachSnapshotImpl(
.findMany({
where: {
userId,
- type: { in: wantedTypes as never[] },
+ type: { in: wantedTypes },
measuredAt: { gte: cutoff },
deletedAt: null,
},
@@ -1206,11 +1164,19 @@ async function buildCoachSnapshotImpl(
: await Promise.all([
excludesMedications
? Promise.resolve(null)
- : buildAdherenceStoryline(userId, userTz, now).catch(() => null),
- buildChangepointSignals(userId, now).catch(() => null),
- buildSignalTrust(userId, userTz, now).catch(() => null),
+ : buildAdherenceStoryline(userId, userTz, now).catch(
+ blockFailed("adherenceStoryline"),
+ ),
+ buildChangepointSignals(userId, now).catch(
+ blockFailed("changepoints"),
+ ),
+ buildSignalTrust(userId, userTz, now).catch(
+ blockFailed("signalTrust"),
+ ),
experimentVerdictEnabled()
- ? buildExperimentOutcomeBlock(userId, { now }).catch(() => null)
+ ? buildExperimentOutcomeBlock(userId, { now }).catch(
+ blockFailed("experimentOutcomes"),
+ )
: Promise.resolve(null),
]);
if (adherenceStoryline) snapshot.adherenceStoryline = adherenceStoryline;
diff --git a/src/lib/ai/coach/source-measurement-types.ts b/src/lib/ai/coach/source-measurement-types.ts
new file mode 100644
index 000000000..186032b01
--- /dev/null
+++ b/src/lib/ai/coach/source-measurement-types.ts
@@ -0,0 +1,75 @@
+/**
+ * CoachScopeSource → MeasurementType[] mapping.
+ *
+ * Extracted out of `snapshot.ts` (where it lived as a function-local literal)
+ * so the availability probe reads the SAME table the windowed snapshot read
+ * builds its `WHERE type IN (…)` from. The probe answers "does this domain
+ * exist at all, outside the window?" — if it resolved its types from a second
+ * copy of this table, a new metric could be windowed by one map and probed by
+ * the other, and the two would disagree silently. One table, two readers.
+ *
+ * `mood`, `compliance`, and `workouts` map to no `MeasurementType` because they
+ * read separate models (MoodEntry / MedicationIntakeEvent / Workout); the
+ * availability probe carries its own subject kinds for those. `glucose` also
+ * reads `Measurement` but needs the `glucoseContext` column, so its snapshot
+ * block is built separately rather than from the shared rows.
+ */
+import type { MeasurementType } from "@/generated/prisma/client";
+import type { CoachScopeSource } from "@/lib/ai/coach/types";
+
+export const COACH_SOURCE_MEASUREMENT_TYPES: Readonly<
+ Record
+> = {
+ bp: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
+ weight: ["WEIGHT"],
+ pulse: ["PULSE"],
+ mood: [],
+ compliance: [],
+ // v1.30.4 (HRV union) — Oura / Polar / WHOOP write nightly HRV as RMSSD
+ // (`HRV_RMSSD`), never SDNN (`HEART_RATE_VARIABILITY`, Apple / Fitbit).
+ // The app's HRV surface already unions both (`sub-page-metric.ts`);
+ // resolving SDNN alone here made a ring/strap-only user's Coach context
+ // + the `get_metric_series`/rich-read MCP paths report a false
+ // `{present:false}` for HRV even though the app charts it.
+ hrv: ["HEART_RATE_VARIABILITY", "HRV_RMSSD"],
+ sleep: ["SLEEP_DURATION"],
+ resting_hr: ["RESTING_HEART_RATE"],
+ steps: ["ACTIVITY_STEPS"],
+ active_energy: ["ACTIVE_ENERGY_BURNED"],
+ flights: ["FLIGHTS_CLIMBED"],
+ distance: ["WALKING_RUNNING_DISTANCE"],
+ vo2_max: ["VO2_MAX"],
+ body_temp: ["BODY_TEMPERATURE"],
+ // ── cardio composition / vascular ──
+ walking_hr: ["WALKING_HEART_RATE_AVERAGE"],
+ respiratory_rate: ["RESPIRATORY_RATE"],
+ spo2: ["OXYGEN_SATURATION"],
+ pulse_wave_velocity: ["PULSE_WAVE_VELOCITY"],
+ vascular_age: ["VASCULAR_AGE"],
+ // ── body composition ──
+ body_fat: ["BODY_FAT"],
+ fat_mass: ["FAT_MASS"],
+ fat_free_mass: ["FAT_FREE_MASS"],
+ muscle_mass: ["MUSCLE_MASS"],
+ lean_body_mass: ["LEAN_BODY_MASS"],
+ bone_mass: ["BONE_MASS"],
+ total_body_water: ["TOTAL_BODY_WATER"],
+ bmi: ["BODY_MASS_INDEX"],
+ visceral_fat: ["VISCERAL_FAT"],
+ // ── metabolic — built via the dedicated glucose branch ──
+ glucose: ["BLOOD_GLUCOSE"],
+ // ── mobility & gait ──
+ walking_steadiness: ["WALKING_STEADINESS"],
+ walking_asymmetry: ["WALKING_ASYMMETRY"],
+ walking_double_support: ["WALKING_DOUBLE_SUPPORT"],
+ walking_step_length: ["WALKING_STEP_LENGTH"],
+ walking_speed: ["WALKING_SPEED"],
+ // ── environment / exposure ──
+ audio_env: ["AUDIO_EXPOSURE_ENV"],
+ audio_headphone: ["AUDIO_EXPOSURE_HEADPHONE"],
+ audio_event: ["AUDIO_EXPOSURE_EVENT"],
+ daylight: ["TIME_IN_DAYLIGHT"],
+ skin_temp: ["SKIN_TEMPERATURE"],
+ // ── workouts — read from the Workout model, not Measurement ──
+ workouts: [],
+};
diff --git a/src/lib/ai/coach/tools/availability.ts b/src/lib/ai/coach/tools/availability.ts
new file mode 100644
index 000000000..36f767010
--- /dev/null
+++ b/src/lib/ai/coach/tools/availability.ts
@@ -0,0 +1,500 @@
+/**
+ * Coach availability probe — the discriminant behind "no data".
+ *
+ * A Coach retrieval tool reads the snapshot for a WINDOW. When the window read
+ * comes back empty there are two entirely different situations, and until this
+ * module existed both collapsed into one `reason: "no_data"`:
+ *
+ * 1. the record holds nothing for this domain, ever;
+ * 2. the record holds a substantial history for this domain, all of it older
+ * than the window that was searched.
+ *
+ * The second one is not absence. Reported as absence, the Coach tells someone
+ * their readings do not exist and then — having been told there is nothing
+ * there — moves on to a metric it can talk about. So every `present: false`
+ * that comes from an empty window read is checked here first, and the two
+ * cases reach the model as different reason codes:
+ *
+ * `no_data` — the probe confirmed the record is empty for this domain.
+ * `outside_window` — rows exist; every one of them is older than the window
+ * that was searched. `available` carries how many, over
+ * what range, and (for measurement-backed domains) a
+ * bounded aggregate of them.
+ * `unavailable_in_scope` — rows exist INSIDE the searched window and the
+ * domain still produced no block: the owning module is off
+ * for this account, or the block's own floor was not met.
+ * Widening the window cannot help, and telling the person
+ * the data does not exist would be false twice over.
+ * `no_data_unconfirmed` — the probe itself failed. The window read found
+ * nothing and we could not establish whether older rows
+ * exist, so the model is told exactly that rather than
+ * being handed a confident absence it cannot verify.
+ *
+ * Cost. One grouped aggregate over `Measurement` for every measurement-backed
+ * subject in the batch (indexed by `(userId, type, measuredAt)`, no rows
+ * materialised in JS), plus at most one `aggregate()` per non-measurement
+ * subject kind actually asked for. It runs ONLY on the miss path, and only for
+ * the domains that missed.
+ *
+ * Why an aggregate and not a wider window. The window cap exists because a
+ * multi-year raw history does not fit a prompt budget — 1,597 CGM points cannot
+ * go to the model. So the out-of-window range comes back as the six scalars SQL
+ * can compute without materialising a row (count, first, last, mean, min, max
+ * per series). That is bounded by construction, needs no rollup coverage, and
+ * is enough for the Coach to answer the question instead of only naming it.
+ */
+import { prisma } from "@/lib/db";
+import type { MeasurementType } from "@/generated/prisma/client";
+import { userDayKey } from "@/lib/tz/format";
+import { resolveUserTimezone } from "@/lib/tz/resolver";
+import { annotate } from "@/lib/logging/context";
+import type { CoachScopeSource, CoachScopeWindow } from "@/lib/ai/coach/types";
+import type { CoachToolName } from "./definitions";
+import { COACH_SOURCE_MEASUREMENT_TYPES } from "@/lib/ai/coach/source-measurement-types";
+import { windowToDays } from "@/lib/ai/coach/snapshot-series";
+import { DEFAULT_WINDOW } from "@/lib/ai/coach/snapshot-cache";
+
+/** Which table a domain's existence has to be checked against. */
+export type CoachAvailabilitySubject =
+ | { kind: "measurement"; types: readonly MeasurementType[] }
+ | { kind: "mood" }
+ | { kind: "workout" }
+ | { kind: "intake" }
+ | { kind: "lab" };
+
+/** Per-series aggregate over the whole recorded history of one series. */
+export interface CoachAvailabilitySeries {
+ /** The measurement type, verbatim — unambiguous for the model. */
+ series: string;
+ /** The unit every row of this series carries, when they all agree. */
+ unit: string | null;
+ count: number;
+ mean: number;
+ min: number;
+ max: number;
+}
+
+/**
+ * What the record holds for a domain, over its whole history. Model-facing
+ * only: it is serialised into the tool-result turn and the DATA INVENTORY, and
+ * never rendered to a person, so it carries no i18n strings.
+ */
+export interface CoachDomainAvailability {
+ /** Rows the record holds for this domain, over its whole history. */
+ count: number;
+ /** `YYYY-MM-DD` of the earliest row, in the user's display timezone. */
+ firstDate: string;
+ /** `YYYY-MM-DD` of the latest row, in the user's display timezone. */
+ lastDate: string;
+ /**
+ * Bounded aggregate per series. Present for measurement-backed domains whose
+ * rows all share one unit; omitted when the history mixes units (a mean
+ * across mg/dL and mmol/L rows would be a fabricated number) and for the
+ * domains that carry no single value column (workouts, intakes, labs, mood).
+ */
+ series?: CoachAvailabilitySeries[];
+ /**
+ * The widest window that would reach `lastDate`, when one exists — the model
+ * can re-call the tool with it and get the real series. `null` when the
+ * history lies beyond every window's reach, which is when the honest sentence
+ * is the whole answer.
+ */
+ reachableWithWindow: CoachScopeWindow | null;
+}
+
+/** The windows a tool call may ask for, widest last. */
+const WINDOWS_WIDEST_LAST: readonly CoachScopeWindow[] = [
+ "last7days",
+ "last30days",
+ "last90days",
+ "lastYear",
+ "allTime",
+];
+
+/**
+ * The narrowest window whose cutoff still contains `lastDate`, or `null` when
+ * even the widest one does not reach it. `allTime` is capped at a year like
+ * `lastYear` (see `windowToDays`), so a history older than that is genuinely
+ * unreachable by re-calling with a wider window — and saying so is the point.
+ */
+function reachableWindow(lastAt: Date, now: Date): CoachScopeWindow | null {
+ const ageDays = (now.getTime() - lastAt.getTime()) / 86_400_000;
+ for (const window of WINDOWS_WIDEST_LAST) {
+ if (ageDays <= windowToDays(window)) return window;
+ }
+ return null;
+}
+
+interface RawBounds {
+ count: number;
+ firstAt: Date;
+ lastAt: Date;
+ series?: Array<{
+ series: string;
+ unit: string | null;
+ count: number;
+ mean: number;
+ min: number;
+ max: number;
+ }>;
+}
+
+/**
+ * One grouped aggregate over `Measurement` for every type in `types`, split by
+ * unit so a mixed-unit history is detectable rather than averaged.
+ */
+async function readMeasurementBounds(
+ userId: string,
+ types: readonly MeasurementType[],
+): Promise