From 0518c3905015c564fa62a77f612e27194e2246e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 17 Jul 2026 23:02:45 +0200 Subject: [PATCH 01/33] fix(illness): glucose safety-floor push honours the user's display unit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hypoglycemia/hyperglycemia escalation push hardcoded mg/dL in all six locale strings and interpolated the raw mg/dL reading regardless of the user's glucose display preference. A mmol/L user hitting the confirmed-hypo alarm read "70 mg/dL (65 mg/dL)" — the wrong unit at the single most safety-critical moment. Resolve User.glucoseUnit in the safety-floor write hook and convert both the reading and the floor threshold before handing them to the notification copy. The bundle strings now take {threshold} {unit} / {value} {unit} instead of a baked-in "70 mg/dL" / "54 mg/dL". --- messages/de.json | 12 ++--- messages/en.json | 12 ++--- messages/es.json | 12 ++--- messages/fr.json | 12 ++--- messages/it.json | 12 ++--- messages/pl.json | 12 ++--- .../__tests__/safety-floor-notify.test.ts | 12 ++++- src/lib/illness/safety-floor-check.ts | 13 +++++- src/lib/illness/safety-floor-notify.ts | 44 ++++++++++++++++--- 9 files changed, 97 insertions(+), 44 deletions(-) diff --git a/messages/de.json b/messages/de.json index 32ecf86db..89e2eee88 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7898,14 +7898,14 @@ "bpLowDoctor": "Zwei aktuelle Messungen hatten einen systolischen Wert unter {systolic} mmHg. Wenn dir schwindelig oder schummrig ist, setz oder leg dich hin und kontaktiere deine Ärztin oder deinen Arzt, wenn es anhält.", "bpLowEmergency": "Zwei aktuelle Messungen hatten einen systolischen Wert unter {systolic} mmHg und du hast Beschwerden angegeben. Bei Ohnmachtsgefühl, Verwirrtheit oder kalter, feuchter Haut sofort den Notruf wählen.", "glucoseLowTitle": "Niedriger Blutzucker", - "glucoseLowDoctor": "Zwei aktuelle Messungen lagen unter 70 mg/dL ({value} mg/dL). Nimm schnell wirkende Kohlenhydrate, miss in 15 Minuten erneut und kontaktiere deine Ärztin oder deinen Arzt, wenn die Tiefs anhalten.", - "glucoseLowEmergency": "Zwei aktuelle Messungen lagen unter 70 mg/dL ({value} mg/dL) und du hast Beschwerden angegeben. Behandle jetzt mit schnellem Zucker; wenn das nicht geht oder dir sehr unwohl ist, wähle den Notruf.", + "glucoseLowDoctor": "Zwei aktuelle Messungen lagen unter {threshold} {unit} ({value} {unit}). Nimm schnell wirkende Kohlenhydrate, miss in 15 Minuten erneut und kontaktiere deine Ärztin oder deinen Arzt, wenn die Tiefs anhalten.", + "glucoseLowEmergency": "Zwei aktuelle Messungen lagen unter {threshold} {unit} ({value} {unit}) und du hast Beschwerden angegeben. Behandle jetzt mit schnellem Zucker; wenn das nicht geht oder dir sehr unwohl ist, wähle den Notruf.", "glucoseLowSevereTitle": "Sehr niedriger Blutzucker", - "glucoseLowSevereDoctor": "Zwei aktuelle Messungen lagen unter 54 mg/dL ({value} mg/dL). Behandle sofort mit schnell wirkenden Kohlenhydraten, miss in 15 Minuten erneut und kontaktiere deine Ärztin oder deinen Arzt.", - "glucoseLowSevereEmergency": "Zwei aktuelle Messungen lagen unter 54 mg/dL ({value} mg/dL) und du hast Beschwerden angegeben. Behandle jetzt; wenn du nichts bei dir behalten kannst oder dir sehr unwohl ist, wähle den Notruf.", + "glucoseLowSevereDoctor": "Zwei aktuelle Messungen lagen unter {threshold} {unit} ({value} {unit}). Behandle sofort mit schnell wirkenden Kohlenhydraten, miss in 15 Minuten erneut und kontaktiere deine Ärztin oder deinen Arzt.", + "glucoseLowSevereEmergency": "Zwei aktuelle Messungen lagen unter {threshold} {unit} ({value} {unit}) und du hast Beschwerden angegeben. Behandle jetzt; wenn du nichts bei dir behalten kannst oder dir sehr unwohl ist, wähle den Notruf.", "glucoseHighTitle": "Sehr hoher Blutzucker", - "glucoseHighDoctor": "Zwei aktuelle Messungen lagen bei {value} mg/dL oder höher. Miss erneut, trink ausreichend und kontaktiere möglichst bald deine Ärztin oder deinen Arzt, wenn es so hoch bleibt.", - "glucoseHighEmergency": "Zwei aktuelle Messungen lagen bei {value} mg/dL oder höher und du hast Beschwerden angegeben. Bei Erbrechen, schneller Atmung oder Verwirrtheit sofort den Notruf wählen." + "glucoseHighDoctor": "Zwei aktuelle Messungen lagen bei {value} {unit} oder höher. Miss erneut, trink ausreichend und kontaktiere möglichst bald deine Ärztin oder deinen Arzt, wenn es so hoch bleibt.", + "glucoseHighEmergency": "Zwei aktuelle Messungen lagen bei {value} {unit} oder höher und du hast Beschwerden angegeben. Bei Erbrechen, schneller Atmung oder Verwirrtheit sofort den Notruf wählen." } }, "version": { diff --git a/messages/en.json b/messages/en.json index 33e1b8d63..e56ab4870 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7898,14 +7898,14 @@ "bpLowDoctor": "Two recent readings had a systolic below {systolic} mmHg. If you feel dizzy or faint, sit or lie down, and contact your doctor if it continues.", "bpLowEmergency": "Two recent readings had a systolic below {systolic} mmHg and you noted symptoms. If you feel faint, confused, or have cold clammy skin, call emergency services now.", "glucoseLowTitle": "Low blood sugar", - "glucoseLowDoctor": "Two recent readings were below 70 mg/dL ({value} mg/dL). Take fast-acting carbohydrate, re-check in 15 minutes, and contact your doctor if lows continue.", - "glucoseLowEmergency": "Two recent readings were below 70 mg/dL ({value} mg/dL) and you noted symptoms. Treat now with fast-acting sugar; if you cannot, or you feel very unwell, call emergency services.", + "glucoseLowDoctor": "Two recent readings were below {threshold} {unit} ({value} {unit}). Take fast-acting carbohydrate, re-check in 15 minutes, and contact your doctor if lows continue.", + "glucoseLowEmergency": "Two recent readings were below {threshold} {unit} ({value} {unit}) and you noted symptoms. Treat now with fast-acting sugar; if you cannot, or you feel very unwell, call emergency services.", "glucoseLowSevereTitle": "Very low blood sugar", - "glucoseLowSevereDoctor": "Two recent readings were below 54 mg/dL ({value} mg/dL). Treat immediately with fast-acting carbohydrate, re-check in 15 minutes, and contact your doctor.", - "glucoseLowSevereEmergency": "Two recent readings were below 54 mg/dL ({value} mg/dL) and you noted symptoms. Treat now; if you cannot keep food down or feel very unwell, call emergency services.", + "glucoseLowSevereDoctor": "Two recent readings were below {threshold} {unit} ({value} {unit}). Treat immediately with fast-acting carbohydrate, re-check in 15 minutes, and contact your doctor.", + "glucoseLowSevereEmergency": "Two recent readings were below {threshold} {unit} ({value} {unit}) and you noted symptoms. Treat now; if you cannot keep food down or feel very unwell, call emergency services.", "glucoseHighTitle": "Very high blood sugar", - "glucoseHighDoctor": "Two recent readings were {value} mg/dL or higher. Re-check, hydrate, and contact your doctor as soon as possible if it stays this high.", - "glucoseHighEmergency": "Two recent readings were {value} mg/dL or higher and you noted symptoms. If you have vomiting, fast breathing, or confusion, call emergency services now." + "glucoseHighDoctor": "Two recent readings were {value} {unit} or higher. Re-check, hydrate, and contact your doctor as soon as possible if it stays this high.", + "glucoseHighEmergency": "Two recent readings were {value} {unit} or higher and you noted symptoms. If you have vomiting, fast breathing, or confusion, call emergency services now." } }, "version": { diff --git a/messages/es.json b/messages/es.json index bd5e14013..cfec2263e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7898,14 +7898,14 @@ "bpLowDoctor": "Dos lecturas recientes tuvieron una sistólica por debajo de {systolic} mmHg. Si te mareas o te sientes débil, siéntate o recuéstate y contacta a tu médico si continúa.", "bpLowEmergency": "Dos lecturas recientes tuvieron una sistólica por debajo de {systolic} mmHg e indicaste síntomas. Si te sientes a punto de desmayar, confuso o con piel fría y húmeda, llama a emergencias ya.", "glucoseLowTitle": "Glucosa baja", - "glucoseLowDoctor": "Dos lecturas recientes estuvieron por debajo de 70 mg/dL ({value} mg/dL). Toma carbohidratos de acción rápida, vuelve a medir en 15 minutos y contacta a tu médico si los bajones continúan.", - "glucoseLowEmergency": "Dos lecturas recientes estuvieron por debajo de 70 mg/dL ({value} mg/dL) e indicaste síntomas. Trátalo ahora con azúcar rápida; si no puedes o te sientes muy mal, llama a emergencias.", + "glucoseLowDoctor": "Dos lecturas recientes estuvieron por debajo de {threshold} {unit} ({value} {unit}). Toma carbohidratos de acción rápida, vuelve a medir en 15 minutos y contacta a tu médico si los bajones continúan.", + "glucoseLowEmergency": "Dos lecturas recientes estuvieron por debajo de {threshold} {unit} ({value} {unit}) e indicaste síntomas. Trátalo ahora con azúcar rápida; si no puedes o te sientes muy mal, llama a emergencias.", "glucoseLowSevereTitle": "Glucosa muy baja", - "glucoseLowSevereDoctor": "Dos lecturas recientes estuvieron por debajo de 54 mg/dL ({value} mg/dL). Trátalo de inmediato con carbohidratos de acción rápida, vuelve a medir en 15 minutos y contacta a tu médico.", - "glucoseLowSevereEmergency": "Dos lecturas recientes estuvieron por debajo de 54 mg/dL ({value} mg/dL) e indicaste síntomas. Trátalo ahora; si no retienes alimentos o te sientes muy mal, llama a emergencias.", + "glucoseLowSevereDoctor": "Dos lecturas recientes estuvieron por debajo de {threshold} {unit} ({value} {unit}). Trátalo de inmediato con carbohidratos de acción rápida, vuelve a medir en 15 minutos y contacta a tu médico.", + "glucoseLowSevereEmergency": "Dos lecturas recientes estuvieron por debajo de {threshold} {unit} ({value} {unit}) e indicaste síntomas. Trátalo ahora; si no retienes alimentos o te sientes muy mal, llama a emergencias.", "glucoseHighTitle": "Glucosa muy alta", - "glucoseHighDoctor": "Dos lecturas recientes fueron de {value} mg/dL o más. Vuelve a medir, hidrátate y contacta a tu médico cuanto antes si sigue así de alta.", - "glucoseHighEmergency": "Dos lecturas recientes fueron de {value} mg/dL o más e indicaste síntomas. Si tienes vómitos, respiración rápida o confusión, llama a emergencias ya." + "glucoseHighDoctor": "Dos lecturas recientes fueron de {value} {unit} o más. Vuelve a medir, hidrátate y contacta a tu médico cuanto antes si sigue así de alta.", + "glucoseHighEmergency": "Dos lecturas recientes fueron de {value} {unit} o más e indicaste síntomas. Si tienes vómitos, respiración rápida o confusión, llama a emergencias ya." } }, "version": { diff --git a/messages/fr.json b/messages/fr.json index 3ccd752e1..aed77f4c2 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7898,14 +7898,14 @@ "bpLowDoctor": "Deux mesures récentes avaient une systolique inférieure à {systolic} mmHg. Si tu te sens étourdi ou faible, assieds-toi ou allonge-toi et contacte ton médecin si cela persiste.", "bpLowEmergency": "Deux mesures récentes avaient une systolique inférieure à {systolic} mmHg et tu as signalé des symptômes. Si tu te sens près de t'évanouir, confus ou as la peau froide et moite, appelle les secours immédiatement.", "glucoseLowTitle": "Glycémie basse", - "glucoseLowDoctor": "Deux mesures récentes étaient en dessous de 70 mg/dL ({value} mg/dL). Prends des glucides à action rapide, remesure dans 15 minutes et contacte ton médecin si les baisses persistent.", - "glucoseLowEmergency": "Deux mesures récentes étaient en dessous de 70 mg/dL ({value} mg/dL) et tu as signalé des symptômes. Traite maintenant avec du sucre rapide ; si tu ne peux pas ou te sens très mal, appelle les secours.", + "glucoseLowDoctor": "Deux mesures récentes étaient en dessous de {threshold} {unit} ({value} {unit}). Prends des glucides à action rapide, remesure dans 15 minutes et contacte ton médecin si les baisses persistent.", + "glucoseLowEmergency": "Deux mesures récentes étaient en dessous de {threshold} {unit} ({value} {unit}) et tu as signalé des symptômes. Traite maintenant avec du sucre rapide ; si tu ne peux pas ou te sens très mal, appelle les secours.", "glucoseLowSevereTitle": "Glycémie très basse", - "glucoseLowSevereDoctor": "Deux mesures récentes étaient en dessous de 54 mg/dL ({value} mg/dL). Traite immédiatement avec des glucides à action rapide, remesure dans 15 minutes et contacte ton médecin.", - "glucoseLowSevereEmergency": "Deux mesures récentes étaient en dessous de 54 mg/dL ({value} mg/dL) et tu as signalé des symptômes. Traite maintenant ; si tu ne gardes pas la nourriture ou te sens très mal, appelle les secours.", + "glucoseLowSevereDoctor": "Deux mesures récentes étaient en dessous de {threshold} {unit} ({value} {unit}). Traite immédiatement avec des glucides à action rapide, remesure dans 15 minutes et contacte ton médecin.", + "glucoseLowSevereEmergency": "Deux mesures récentes étaient en dessous de {threshold} {unit} ({value} {unit}) et tu as signalé des symptômes. Traite maintenant ; si tu ne gardes pas la nourriture ou te sens très mal, appelle les secours.", "glucoseHighTitle": "Glycémie très élevée", - "glucoseHighDoctor": "Deux mesures récentes étaient à {value} mg/dL ou plus. Remesure, hydrate-toi et contacte ton médecin dès que possible si cela reste aussi élevé.", - "glucoseHighEmergency": "Deux mesures récentes étaient à {value} mg/dL ou plus et tu as signalé des symptômes. En cas de vomissements, de respiration rapide ou de confusion, appelle les secours immédiatement." + "glucoseHighDoctor": "Deux mesures récentes étaient à {value} {unit} ou plus. Remesure, hydrate-toi et contacte ton médecin dès que possible si cela reste aussi élevé.", + "glucoseHighEmergency": "Deux mesures récentes étaient à {value} {unit} ou plus et tu as signalé des symptômes. En cas de vomissements, de respiration rapide ou de confusion, appelle les secours immédiatement." } }, "version": { diff --git a/messages/it.json b/messages/it.json index c8c05e877..b14c333b4 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7898,14 +7898,14 @@ "bpLowDoctor": "Due misurazioni recenti avevano una sistolica sotto {systolic} mmHg. Se ti senti stordito o debole, siediti o sdraiati e contatta il medico se continua.", "bpLowEmergency": "Due misurazioni recenti avevano una sistolica sotto {systolic} mmHg e hai segnalato sintomi. Se senti che stai per svenire, sei confuso o hai la pelle fredda e sudata, chiama subito i soccorsi.", "glucoseLowTitle": "Glicemia bassa", - "glucoseLowDoctor": "Due misurazioni recenti erano sotto 70 mg/dL ({value} mg/dL). Assumi carboidrati ad azione rapida, rimisura tra 15 minuti e contatta il medico se i cali continuano.", - "glucoseLowEmergency": "Due misurazioni recenti erano sotto 70 mg/dL ({value} mg/dL) e hai segnalato sintomi. Tratta subito con zucchero rapido; se non puoi o ti senti molto male, chiama i soccorsi.", + "glucoseLowDoctor": "Due misurazioni recenti erano sotto {threshold} {unit} ({value} {unit}). Assumi carboidrati ad azione rapida, rimisura tra 15 minuti e contatta il medico se i cali continuano.", + "glucoseLowEmergency": "Due misurazioni recenti erano sotto {threshold} {unit} ({value} {unit}) e hai segnalato sintomi. Tratta subito con zucchero rapido; se non puoi o ti senti molto male, chiama i soccorsi.", "glucoseLowSevereTitle": "Glicemia molto bassa", - "glucoseLowSevereDoctor": "Due misurazioni recenti erano sotto 54 mg/dL ({value} mg/dL). Tratta subito con carboidrati ad azione rapida, rimisura tra 15 minuti e contatta il medico.", - "glucoseLowSevereEmergency": "Due misurazioni recenti erano sotto 54 mg/dL ({value} mg/dL) e hai segnalato sintomi. Tratta subito; se non trattieni il cibo o ti senti molto male, chiama i soccorsi.", + "glucoseLowSevereDoctor": "Due misurazioni recenti erano sotto {threshold} {unit} ({value} {unit}). Tratta subito con carboidrati ad azione rapida, rimisura tra 15 minuti e contatta il medico.", + "glucoseLowSevereEmergency": "Due misurazioni recenti erano sotto {threshold} {unit} ({value} {unit}) e hai segnalato sintomi. Tratta subito; se non trattieni il cibo o ti senti molto male, chiama i soccorsi.", "glucoseHighTitle": "Glicemia molto alta", - "glucoseHighDoctor": "Due misurazioni recenti erano di {value} mg/dL o superiori. Rimisura, idratati e contatta il medico al più presto se resta così alta.", - "glucoseHighEmergency": "Due misurazioni recenti erano di {value} mg/dL o superiori e hai segnalato sintomi. In caso di vomito, respiro affannoso o confusione, chiama subito i soccorsi." + "glucoseHighDoctor": "Due misurazioni recenti erano di {value} {unit} o superiori. Rimisura, idratati e contatta il medico al più presto se resta così alta.", + "glucoseHighEmergency": "Due misurazioni recenti erano di {value} {unit} o superiori e hai segnalato sintomi. In caso di vomito, respiro affannoso o confusione, chiama subito i soccorsi." } }, "version": { diff --git a/messages/pl.json b/messages/pl.json index 5951c89df..a01a2123f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7898,14 +7898,14 @@ "bpLowDoctor": "Dwa ostatnie pomiary miały ciśnienie skurczowe poniżej {systolic} mmHg. Jeśli kręci ci się w głowie lub czujesz osłabienie, usiądź lub połóż się i skontaktuj się z lekarzem, jeśli to się utrzymuje.", "bpLowEmergency": "Dwa ostatnie pomiary miały ciśnienie skurczowe poniżej {systolic} mmHg i zgłosiłeś objawy. Jeśli czujesz, że możesz zemdleć, jesteś zdezorientowany lub masz zimną, lepką skórę, natychmiast zadzwoń po pomoc.", "glucoseLowTitle": "Niski poziom cukru", - "glucoseLowDoctor": "Dwa ostatnie pomiary były poniżej 70 mg/dL ({value} mg/dL). Przyjmij szybko działające węglowodany, zmierz ponownie za 15 minut i skontaktuj się z lekarzem, jeśli spadki się utrzymują.", - "glucoseLowEmergency": "Dwa ostatnie pomiary były poniżej 70 mg/dL ({value} mg/dL) i zgłosiłeś objawy. Lecz to teraz szybkim cukrem; jeśli nie możesz lub czujesz się bardzo źle, zadzwoń po pomoc.", + "glucoseLowDoctor": "Dwa ostatnie pomiary były poniżej {threshold} {unit} ({value} {unit}). Przyjmij szybko działające węglowodany, zmierz ponownie za 15 minut i skontaktuj się z lekarzem, jeśli spadki się utrzymują.", + "glucoseLowEmergency": "Dwa ostatnie pomiary były poniżej {threshold} {unit} ({value} {unit}) i zgłosiłeś objawy. Lecz to teraz szybkim cukrem; jeśli nie możesz lub czujesz się bardzo źle, zadzwoń po pomoc.", "glucoseLowSevereTitle": "Bardzo niski poziom cukru", - "glucoseLowSevereDoctor": "Dwa ostatnie pomiary były poniżej 54 mg/dL ({value} mg/dL). Lecz natychmiast szybko działającymi węglowodanami, zmierz ponownie za 15 minut i skontaktuj się z lekarzem.", - "glucoseLowSevereEmergency": "Dwa ostatnie pomiary były poniżej 54 mg/dL ({value} mg/dL) i zgłosiłeś objawy. Lecz to teraz; jeśli nie utrzymujesz pokarmu lub czujesz się bardzo źle, zadzwoń po pomoc.", + "glucoseLowSevereDoctor": "Dwa ostatnie pomiary były poniżej {threshold} {unit} ({value} {unit}). Lecz natychmiast szybko działającymi węglowodanami, zmierz ponownie za 15 minut i skontaktuj się z lekarzem.", + "glucoseLowSevereEmergency": "Dwa ostatnie pomiary były poniżej {threshold} {unit} ({value} {unit}) i zgłosiłeś objawy. Lecz to teraz; jeśli nie utrzymujesz pokarmu lub czujesz się bardzo źle, zadzwoń po pomoc.", "glucoseHighTitle": "Bardzo wysoki poziom cukru", - "glucoseHighDoctor": "Dwa ostatnie pomiary wyniosły {value} mg/dL lub więcej. Zmierz ponownie, nawodnij się i jak najszybciej skontaktuj się z lekarzem, jeśli pozostaje tak wysoki.", - "glucoseHighEmergency": "Dwa ostatnie pomiary wyniosły {value} mg/dL lub więcej i zgłosiłeś objawy. W razie wymiotów, przyspieszonego oddechu lub dezorientacji natychmiast zadzwoń po pomoc." + "glucoseHighDoctor": "Dwa ostatnie pomiary wyniosły {value} {unit} lub więcej. Zmierz ponownie, nawodnij się i jak najszybciej skontaktuj się z lekarzem, jeśli pozostaje tak wysoki.", + "glucoseHighEmergency": "Dwa ostatnie pomiary wyniosły {value} {unit} lub więcej i zgłosiłeś objawy. W razie wymiotów, przyspieszonego oddechu lub dezorientacji natychmiast zadzwoń po pomoc." } }, "version": { diff --git a/src/lib/illness/__tests__/safety-floor-notify.test.ts b/src/lib/illness/__tests__/safety-floor-notify.test.ts index dc1766edf..cd2a5a6b1 100644 --- a/src/lib/illness/__tests__/safety-floor-notify.test.ts +++ b/src/lib/illness/__tests__/safety-floor-notify.test.ts @@ -81,7 +81,17 @@ describe("notifySafetyFloor", () => { const opts = dispatchMock.mock.calls[0][0]; expect(opts.messageKey).toBe("safety.floor.glucoseLowSevereEmergency"); expect(opts.titleKey).toBe("safety.floor.glucoseLowSevereTitle"); - expect(opts.params).toEqual({ value: 48 }); + expect(opts.params).toEqual({ value: 48, unit: "mg/dL", threshold: 54 }); + }); + + it("converts the glucose value + threshold to the caller's display unit", async () => { + await notifySafetyFloor({ + userId: "u1", + decision: glucoseLowSymptomatic, + glucoseUnit: "mmol/L", + }); + const opts = dispatchMock.mock.calls[0][0]; + expect(opts.params).toEqual({ value: 2.7, unit: "mmol/L", threshold: 3 }); }); it("de-dupes by reason when a recent ledger row exists", async () => { diff --git a/src/lib/illness/safety-floor-check.ts b/src/lib/illness/safety-floor-check.ts index 908c0cd4f..00ada6197 100644 --- a/src/lib/illness/safety-floor-check.ts +++ b/src/lib/illness/safety-floor-check.ts @@ -19,6 +19,7 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { getEvent } from "@/lib/logging/context"; import { isModuleEnabled } from "@/lib/modules/gate"; +import { resolveGlucoseUnit } from "@/lib/glucose"; import { FEVER_RED_FLAG_C, SPO2_RED_FLAG_PCT } from "@/lib/clinical-floors"; import { evaluateBloodPressure, @@ -194,7 +195,17 @@ async function checkGlucose( mgdl: r.value, })); const decision = evaluateGlucose({ candidate, recent, symptomCoupled }); - await notifySafetyFloor({ userId, decision }); + if (!decision) return; + + // Resolve the user's display unit only when there's actually a breach to + // report — the escalation push must speak the user's own unit (mmol/L + // users get "3.9 mmol/L", not a raw mg/dL figure mid-hypo). + const profile = await prisma.user.findUnique({ + where: { id: userId }, + select: { glucoseUnit: true }, + }); + const glucoseUnit = resolveGlucoseUnit(profile?.glucoseUnit ?? null); + await notifySafetyFloor({ userId, decision, glucoseUnit }); } /* ── sustained fever / low-SpO2 (multi-day calendar runs) ─────────────── */ diff --git a/src/lib/illness/safety-floor-notify.ts b/src/lib/illness/safety-floor-notify.ts index 5e206088b..23df53951 100644 --- a/src/lib/illness/safety-floor-notify.ts +++ b/src/lib/illness/safety-floor-notify.ts @@ -20,9 +20,12 @@ import { prisma } from "@/lib/db"; import { getEvent } from "@/lib/logging/context"; import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised"; -import type { - SafetyFloorDecision, - SafetyFloorReason, +import { convertGlucose, type GlucoseUnit } from "@/lib/glucose"; +import { + GLUCOSE_HYPO, + GLUCOSE_HYPO_SEVERE, + type SafetyFloorDecision, + type SafetyFloorReason, } from "@/lib/illness/safety-floors"; /** One escalation per (user, reason) per this window. */ @@ -55,9 +58,20 @@ function copyKeysFor(decision: SafetyFloorDecision): { }; } -/** Body params — the re-confirmed reading value(s) echoed into the copy. */ +/** + * Body params — the re-confirmed reading value(s) echoed into the copy. + * + * Glucose is stored canonically in mg/dL (`decision.value`), but the escalation + * push must speak the user's own display unit — a mmol/L user reading "70 + * mg/dL" mid-hypo is the wrong unit at the single most safety-critical moment. + * `glucoseUnit` is resolved by the caller (`safety-floor-check.ts`) from + * `User.glucoseUnit`; every glucose copy key takes `{value} {unit}` and the + * hypo variants also take `{threshold} {unit}` for the "below X" clause — + * the literal "70 mg/dL" / "54 mg/dL" text no longer lives in the bundle. + */ function paramsFor( decision: SafetyFloorDecision, + glucoseUnit: GlucoseUnit, ): Record { if (decision.kind === "BLOOD_PRESSURE") { return { @@ -65,17 +79,35 @@ function paramsFor( diastolic: decision.diastolic ?? 0, }; } - return { value: decision.value }; + const params: Record = { + value: convertGlucose(decision.value, glucoseUnit), + unit: glucoseUnit, + }; + if ( + decision.reason === "glucose_hypo" || + decision.reason === "glucose_hypo_severe" + ) { + const thresholdMgdl = + decision.reason === "glucose_hypo_severe" + ? GLUCOSE_HYPO_SEVERE + : GLUCOSE_HYPO; + params.threshold = convertGlucose(thresholdMgdl, glucoseUnit); + } + return params; } /** * Emit an urgent escalation push for a confirmed safety-floor breach, at most * once per (user, reason) per `DEDUPE_WINDOW_MS`. No-op when `decision` is * null. Never throws. + * + * `glucoseUnit` is only consulted for `GLUCOSE` decisions; defaults to + * canonical mg/dL when the caller omits it (BP-only callers never need it). */ export async function notifySafetyFloor(input: { userId: string; decision: SafetyFloorDecision | null; + glucoseUnit?: GlucoseUnit; }): Promise { const { userId, decision } = input; if (!decision) return; @@ -107,7 +139,7 @@ export async function notifySafetyFloor(input: { userId, titleKey, messageKey, - params: paramsFor(decision), + params: paramsFor(decision, input.glucoseUnit ?? "mg/dL"), eventType: "SYSTEM_ALERT", urgent: true, }); From 8c0b200a5ba40aea0b74a446f187ffd670946e69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 17 Jul 2026 23:03:01 +0200 Subject: [PATCH 02/33] fix(doctor-report): footer states the user's own timezone, not Berlin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clinician PDF footer read "Timezone: Europe/Berlin" for every user regardless of their actual timezone — a maintainer-anchored default leaking into a document handed to a clinician, who then reads every timestamp on the page against the wrong basis for a non-Berlin operator. `userTz` already carries the resolved per-user zone into the formatters; thread the same resolution (valid IANA name, else the Berlin fallback) into the footer string as {timezone} instead of the literal text. --- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/pl.json | 2 +- src/lib/doctor-report-pdf-core.ts | 15 ++++++++++++++- 7 files changed, 20 insertions(+), 7 deletions(-) diff --git a/messages/de.json b/messages/de.json index 89e2eee88..4346d1d38 100644 --- a/messages/de.json +++ b/messages/de.json @@ -91,7 +91,7 @@ "colShare": "Anteil", "footerDisclaimer1": "Dieser Bericht wurde automatisch aus selbst erfassten Daten generiert und dient ausschließlich zur Unterstützung", "footerDisclaimer2": "des Arzt-Patienten-Gesprächs. Er ersetzt keine ärztliche Diagnose. Korrelationen implizieren keine Kausalität.", - "footerSource": "Quelle: HealthLog | Zeitzone: Europe/Berlin | Erstellt: {timestamp}", + "footerSource": "Quelle: HealthLog | Zeitzone: {timezone} | Erstellt: {timestamp}", "typeWeight": "Körpergewicht", "typeBpSys": "Systolischer Blutdruck", "typeBpDia": "Diastolischer Blutdruck", diff --git a/messages/en.json b/messages/en.json index e56ab4870..b0f6e29ea 100644 --- a/messages/en.json +++ b/messages/en.json @@ -91,7 +91,7 @@ "colShare": "Share", "footerDisclaimer1": "This report was generated automatically from self-tracked data and is intended to support", "footerDisclaimer2": "the doctor–patient conversation. It does not replace a medical diagnosis. Correlations do not imply causation.", - "footerSource": "Source: HealthLog | Timezone: Europe/Berlin | Created: {timestamp}", + "footerSource": "Source: HealthLog | Timezone: {timezone} | Created: {timestamp}", "typeWeight": "Body weight", "typeBpSys": "Systolic blood pressure", "typeBpDia": "Diastolic blood pressure", diff --git a/messages/es.json b/messages/es.json index cfec2263e..80274cf85 100644 --- a/messages/es.json +++ b/messages/es.json @@ -91,7 +91,7 @@ "colShare": "Porcentaje", "footerDisclaimer1": "Este informe se generó automáticamente a partir de datos autorregistrados y está destinado a apoyar", "footerDisclaimer2": "la conversación entre médico y paciente. No sustituye un diagnóstico médico. Las correlaciones no implican causalidad.", - "footerSource": "Fuente: HealthLog | Zona horaria: Europe/Berlin | Creado: {timestamp}", + "footerSource": "Fuente: HealthLog | Zona horaria: {timezone} | Creado: {timestamp}", "typeWeight": "Peso corporal", "typeBpSys": "Tensión arterial sistólica", "typeBpDia": "Tensión arterial diastólica", diff --git a/messages/fr.json b/messages/fr.json index aed77f4c2..efe4d56b1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -91,7 +91,7 @@ "colShare": "Part", "footerDisclaimer1": "Ce rapport a été généré automatiquement à partir de données auto-déclarées et vise à soutenir", "footerDisclaimer2": "la conversation médecin-patient. Il ne remplace pas un diagnostic médical. Les corrélations n'impliquent pas la causalité.", - "footerSource": "Source : HealthLog | Fuseau horaire : Europe/Berlin | Créé : {timestamp}", + "footerSource": "Source : HealthLog | Fuseau horaire : {timezone} | Créé : {timestamp}", "typeWeight": "Poids corporel", "typeBpSys": "Tension artérielle systolique", "typeBpDia": "Tension artérielle diastolique", diff --git a/messages/it.json b/messages/it.json index b14c333b4..2c077a936 100644 --- a/messages/it.json +++ b/messages/it.json @@ -91,7 +91,7 @@ "colShare": "Quota", "footerDisclaimer1": "Questo rapporto è stato generato automaticamente da dati auto-registrati ed è destinato a supportare", "footerDisclaimer2": "il dialogo medico-paziente. Non sostituisce una diagnosi medica. Le correlazioni non implicano causalità.", - "footerSource": "Fonte: HealthLog | Fuso orario: Europe/Berlin | Creato: {timestamp}", + "footerSource": "Fonte: HealthLog | Fuso orario: {timezone} | Creato: {timestamp}", "typeWeight": "Peso corporeo", "typeBpSys": "Pressione arteriosa sistolica", "typeBpDia": "Pressione arteriosa diastolica", diff --git a/messages/pl.json b/messages/pl.json index a01a2123f..b84d2954b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -91,7 +91,7 @@ "colShare": "Udział", "footerDisclaimer1": "Ten raport został wygenerowany automatycznie z danych samodzielnie rejestrowanych i ma na celu wspierać", "footerDisclaimer2": "rozmowę lekarza z pacjentem. Nie zastępuje diagnozy medycznej. Korelacje nie oznaczają związku przyczynowego.", - "footerSource": "Źródło: HealthLog | Strefa czasowa: Europe/Berlin | Utworzono: {timestamp}", + "footerSource": "Źródło: HealthLog | Strefa czasowa: {timezone} | Utworzono: {timestamp}", "typeWeight": "Masa ciała", "typeBpSys": "Ciśnienie skurczowe", "typeBpDia": "Ciśnienie rozkurczowe", diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index a4a06623b..ac72fd8f2 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -12,9 +12,14 @@ import { jsPDF } from "jspdf"; import autoTable from "jspdf-autotable"; import { DOCTOR_REPORT_TYPE_LABEL_KEYS } from "./doctor-report/type-label-keys"; -import { makeFormatters, type TimeFormatPreference } from "./format-locale"; +import { + makeFormatters, + DISPLAY_TIMEZONE, + type TimeFormatPreference, +} from "./format-locale"; import type { Locale } from "./i18n/config"; import { convertGlucose, resolveGlucoseUnit } from "./glucose"; +import { isValidTimezone } from "./tz/format"; import { classifyReferenceRange, formatReferenceRange, @@ -505,6 +510,13 @@ export function buildDoctorReportPdfDocument( const num = (value: number, decimals = 1) => formatters.number(value, decimals); const fmtDate = (iso: string) => formatters.date(iso); + // Same resolution `makeFormatters` applies internally (valid IANA name or + // the Europe/Berlin fallback) — the footer must state the SAME zone the + // timestamps above it were actually rendered in, not a maintainer-anchored + // default. A clinician reading a non-Berlin operator's PDF needs the real + // provenance of every timestamp on the page. + const footerTz = + userTz && isValidTimezone(userTz) ? userTz : DISPLAY_TIMEZONE; const unitFor = (type: string): string => { const staticUnit = DOCTOR_REPORT_TYPE_UNIT_KEYS[type]; @@ -1797,6 +1809,7 @@ export function buildDoctorReportPdfDocument( pageDoc.text(t("doctorReport.footerDisclaimer2"), margin, pageHeight - 10); pageDoc.text( t("doctorReport.footerSource", { + timezone: footerTz, timestamp: formatters.dateTime(now), }), margin, From cffebd78db7df90525acb7b503c39ba5656e1984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 17 Jul 2026 23:03:19 +0200 Subject: [PATCH 03/33] fix(mental-health): crisis card no longer sends AT/CH users DE-only numbers The PHQ-9 item-9 crisis card routed every "de" locale to the Germany-only TelefonSeelsorge freephone lines. Those numbers don't connect from Austria or Switzerland, so a Swiss or Austrian user in crisis was shown dead numbers first on the one surface where that costs the most. Add dedicated Austria (Telefonseelsorge 142) and Switzerland (Die Dargebotene Hand 143) resource sets. `User.locale` only ever stores a bare short code today, so an unqualified "de" now resolves to a combined DE/AT/CH set (112 first, then every country's own line) instead of defaulting to Germany; a region-qualified tag resolves to the exact country when one is available. --- messages/de.json | 6 +++ messages/en.json | 6 +++ messages/es.json | 6 +++ messages/fr.json | 6 +++ messages/it.json | 6 +++ messages/pl.json | 6 +++ src/lib/mental-health/crisis-resources.ts | 49 ++++++++++++++++++++++- 7 files changed, 84 insertions(+), 1 deletion(-) diff --git a/messages/de.json b/messages/de.json index 4346d1d38..9690017a4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8042,6 +8042,12 @@ "nummerGegenKummer": { "name": "Nummer gegen Kummer (für junge Menschen)" }, + "telefonSeelsorgeAt": { + "name": "Telefonseelsorge Österreich (rund um die Uhr, kostenlos, vertraulich)" + }, + "dargeboteneHand": { + "name": "Die Dargebotene Hand (rund um die Uhr, kostenlos, vertraulich)" + }, "krisenchat": { "name": "krisenchat (24/7-Chat, junge Menschen)" }, diff --git a/messages/en.json b/messages/en.json index b0f6e29ea..024f48eb3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8042,6 +8042,12 @@ "nummerGegenKummer": { "name": "Nummer gegen Kummer (for young people)" }, + "telefonSeelsorgeAt": { + "name": "Telefonseelsorge Österreich (24/7, free, confidential)" + }, + "dargeboteneHand": { + "name": "Die Dargebotene Hand (24/7, free, confidential)" + }, "krisenchat": { "name": "krisenchat (24/7 chat, young people)" }, diff --git a/messages/es.json b/messages/es.json index 80274cf85..0c376d378 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8042,6 +8042,12 @@ "nummerGegenKummer": { "name": "Nummer gegen Kummer (para jóvenes)" }, + "telefonSeelsorgeAt": { + "name": "Telefonseelsorge Österreich (24/7, gratuito y confidencial)" + }, + "dargeboteneHand": { + "name": "Die Dargebotene Hand (24/7, gratuito y confidencial)" + }, "krisenchat": { "name": "krisenchat (chat 24/7, para jóvenes)" }, diff --git a/messages/fr.json b/messages/fr.json index efe4d56b1..f2e8009e9 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8042,6 +8042,12 @@ "nummerGegenKummer": { "name": "Nummer gegen Kummer (pour les jeunes)" }, + "telefonSeelsorgeAt": { + "name": "Telefonseelsorge Österreich (24h/24, 7j/7, gratuit et confidentiel)" + }, + "dargeboteneHand": { + "name": "Die Dargebotene Hand (24h/24, 7j/7, gratuit et confidentiel)" + }, "krisenchat": { "name": "krisenchat (chat 24h/24, 7j/7, pour les jeunes)" }, diff --git a/messages/it.json b/messages/it.json index 2c077a936..03d0645d5 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8042,6 +8042,12 @@ "nummerGegenKummer": { "name": "Nummer gegen Kummer (per i giovani)" }, + "telefonSeelsorgeAt": { + "name": "Telefonseelsorge Österreich (24/7, gratuito e riservato)" + }, + "dargeboteneHand": { + "name": "Die Dargebotene Hand (24/7, gratuito e riservato)" + }, "krisenchat": { "name": "krisenchat (chat 24/7, per i giovani)" }, diff --git a/messages/pl.json b/messages/pl.json index b84d2954b..63cefc5e2 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8042,6 +8042,12 @@ "nummerGegenKummer": { "name": "Nummer gegen Kummer (dla młodych ludzi)" }, + "telefonSeelsorgeAt": { + "name": "Telefonseelsorge Österreich (całodobowo, bezpłatnie i poufnie)" + }, + "dargeboteneHand": { + "name": "Die Dargebotene Hand (całodobowo, bezpłatnie i poufnie)" + }, "krisenchat": { "name": "krisenchat (czat całodobowy, dla młodych ludzi)" }, diff --git a/src/lib/mental-health/crisis-resources.ts b/src/lib/mental-health/crisis-resources.ts index 808341979..bd3743977 100644 --- a/src/lib/mental-health/crisis-resources.ts +++ b/src/lib/mental-health/crisis-resources.ts @@ -49,6 +49,45 @@ const DE: CrisisResourceSet = { ], }; +/** Austria (at) — Telefonseelsorge Österreich + acute emergency. */ +const AT: CrisisResourceSet = { + emergencyNumber: "112", + resources: [ + { id: "telefonSeelsorgeAt", contacts: ["142", "telefonseelsorge.at"] }, + { id: "findahelpline", contacts: ["findahelpline.com"] }, + ], +}; + +/** Switzerland (ch) — Die Dargebotene Hand + acute emergency. */ +const CH: CrisisResourceSet = { + emergencyNumber: "112", + resources: [ + { id: "dargeboteneHand", contacts: ["143", "143.ch"] }, + { id: "findahelpline", contacts: ["findahelpline.com"] }, + ], +}; + +/** + * Combined DE/AT/CH set — used when the locale is the bare "de" code with no + * region hint. `User.locale` in this app is a short code ("de"/"en"/…), so a + * de-AT or de-CH self-hoster is otherwise indistinguishable from a de-DE one. + * Serving the Germany-only freephone numbers to them left a Swiss or + * Austrian user in crisis staring at numbers that don't connect from their + * country. Leads with 112 (works across all three), then every country's + * own line grouped together so nothing here is presented as DE-only. + */ +const DE_AT_CH: CrisisResourceSet = { + emergencyNumber: "112", + resources: [ + // Country-specific lines first, each set's own `findahelpline` entry + // dropped so the worldwide directory appears exactly once, at the end. + ...DE.resources.filter((r) => r.id !== "findahelpline"), + ...AT.resources.filter((r) => r.id !== "findahelpline"), + ...CH.resources.filter((r) => r.id !== "findahelpline"), + { id: "findahelpline", contacts: ["findahelpline.com"] }, + ], +}; + /** United States (en-US region hint) — 988 + Crisis Text Line. */ const US: CrisisResourceSet = { emergencyNumber: "911", @@ -63,12 +102,20 @@ const US: CrisisResourceSet = { * Resolve a crisis-resource set from the user's locale. Region detection is * coarse (locale prefix) and defaults to the international set — the goal is to * always surface SOMETHING actionable, never to be exhaustive. + * + * A region-qualified tag ("de-AT", "de_CH", …) resolves to the exact country + * set when the caller has one to give us. `User.locale` today only ever + * stores the bare short code, so in practice every "de" hits the DE_AT_CH + * branch below — that is the safe default for an unqualified "de": never + * assume Germany when the region is unknown. */ export function crisisResourcesForLocale( locale: string | null | undefined, ): CrisisResourceSet { const lc = (locale ?? "").toLowerCase(); - if (lc.startsWith("de")) return DE; + if (lc === "de-at" || lc === "de_at" || lc === "at") return AT; + if (lc === "de-ch" || lc === "de_ch" || lc === "ch") return CH; + if (lc.startsWith("de")) return DE_AT_CH; if (lc === "en-us" || lc.startsWith("en_us") || lc === "us") return US; return INTERNATIONAL; } From 30a0528ad0f5564eb1a9636a04a36508db72e7b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 17 Jul 2026 23:03:43 +0200 Subject: [PATCH 04/33] fix(analytics): resting-pulse pill drops the diagnosis-shaped verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit classifyPulse() labelled resting pulse < 60 "Bradycardia" and > 120 "Tachycardia", the latter painted destructive-red. The interpretation layer already frames the same signal calmly for the same range ("below the standard resting range (common in trained hearts)", neutral valence) — a trained user at 55 bpm resting read a diagnosis-shaped verdict on one surface and a calm contextual band on the other. Rename the categories to "Below typical range" / "Above typical range" (matching the interpretation layer's own wording) and step the high band down from destructive/danger to warning/amber — still flagged, never a verdict noun. Neither category paints the pill red anymore. --- messages/de.json | 4 ++-- messages/en.json | 4 ++-- messages/es.json | 4 ++-- messages/fr.json | 4 ++-- messages/it.json | 4 ++-- messages/pl.json | 4 ++-- .../__tests__/target-status-pill.test.tsx | 9 ++++++- src/components/targets/target-status-pill.tsx | 13 ++++++---- src/lib/analytics/classifications.ts | 24 +++++++++++++++---- 9 files changed, 49 insertions(+), 21 deletions(-) diff --git a/messages/de.json b/messages/de.json index 9690017a4..f0ac137eb 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6649,9 +6649,9 @@ "hypertensionGrade1": "Hypertonie Grad 1", "hypertensionGrade2": "Hypertonie Grad 2", "hypertensionGrade3": "Hypertonie Grad 3", - "bradycardia": "Bradykardie", + "belowTypicalRange": "Unter dem typischen Bereich", "elevated": "Erhöht", - "tachycardia": "Tachykardie", + "aboveTypicalRange": "Über dem typischen Bereich", "significantlyLow": "Deutlich niedrig", "slightlyLow": "Leicht niedrig", "onTarget": "Im Zielbereich", diff --git a/messages/en.json b/messages/en.json index 024f48eb3..506d46265 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6649,9 +6649,9 @@ "hypertensionGrade1": "Hypertension Grade 1", "hypertensionGrade2": "Hypertension Grade 2", "hypertensionGrade3": "Hypertension Grade 3", - "bradycardia": "Bradycardia", + "belowTypicalRange": "Below typical range", "elevated": "Elevated", - "tachycardia": "Tachycardia", + "aboveTypicalRange": "Above typical range", "significantlyLow": "Significantly low", "slightlyLow": "Slightly low", "onTarget": "On target", diff --git a/messages/es.json b/messages/es.json index 0c376d378..edc3a2ed5 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6649,9 +6649,9 @@ "hypertensionGrade1": "Hipertensión grado 1", "hypertensionGrade2": "Hipertensión grado 2", "hypertensionGrade3": "Hipertensión grado 3", - "bradycardia": "Bradicardia", + "belowTypicalRange": "Por debajo del rango típico", "elevated": "Elevado", - "tachycardia": "Taquicardia", + "aboveTypicalRange": "Por encima del rango típico", "significantlyLow": "Significativamente bajo", "slightlyLow": "Ligeramente bajo", "onTarget": "En rango objetivo", diff --git a/messages/fr.json b/messages/fr.json index f2e8009e9..49c4f7b9e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6649,9 +6649,9 @@ "hypertensionGrade1": "Hypertension grade 1", "hypertensionGrade2": "Hypertension grade 2", "hypertensionGrade3": "Hypertension grade 3", - "bradycardia": "Bradycardie", + "belowTypicalRange": "En dessous de la plage habituelle", "elevated": "Élevé", - "tachycardia": "Tachycardie", + "aboveTypicalRange": "Au-dessus de la plage habituelle", "significantlyLow": "Nettement bas", "slightlyLow": "Légèrement bas", "onTarget": "Dans la plage cible", diff --git a/messages/it.json b/messages/it.json index 03d0645d5..5ce77c180 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6649,9 +6649,9 @@ "hypertensionGrade1": "Ipertensione grado 1", "hypertensionGrade2": "Ipertensione grado 2", "hypertensionGrade3": "Ipertensione grado 3", - "bradycardia": "Bradicardia", + "belowTypicalRange": "Sotto il range tipico", "elevated": "Elevato", - "tachycardia": "Tachicardia", + "aboveTypicalRange": "Sopra il range tipico", "significantlyLow": "Significativamente basso", "slightlyLow": "Lievemente basso", "onTarget": "Nel range obiettivo", diff --git a/messages/pl.json b/messages/pl.json index 63cefc5e2..1577380ca 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6649,9 +6649,9 @@ "hypertensionGrade1": "Nadciśnienie 1 stopnia", "hypertensionGrade2": "Nadciśnienie 2 stopnia", "hypertensionGrade3": "Nadciśnienie 3 stopnia", - "bradycardia": "Bradykardia", + "belowTypicalRange": "Poniżej typowego zakresu", "elevated": "Podwyższone", - "tachycardia": "Tachykardia", + "aboveTypicalRange": "Powyżej typowego zakresu", "significantlyLow": "Znacząco niskie", "slightlyLow": "Lekko niskie", "onTarget": "W zakresie docelowym", diff --git a/src/components/targets/__tests__/target-status-pill.test.tsx b/src/components/targets/__tests__/target-status-pill.test.tsx index f1ef31eb4..f2f60a402 100644 --- a/src/components/targets/__tests__/target-status-pill.test.tsx +++ b/src/components/targets/__tests__/target-status-pill.test.tsx @@ -33,7 +33,14 @@ describe("statusGroupForCategory", () => { it("paints far-out categories red", () => { expect(statusGroupForCategory("Hypertension Grade 3")).toBe("out"); expect(statusGroupForCategory("Significantly elevated")).toBe("out"); - expect(statusGroupForCategory("Tachycardia")).toBe("out"); + }); + + it("falls back to amber for the resting-pulse bands (no clinical-verdict red)", () => { + // v1.29.6 — "Below typical range" / "Above typical range" replaced the + // diagnosis-shaped "Bradycardia" / "Tachycardia" categories and no + // longer paint the pill destructive-red. + expect(statusGroupForCategory("Below typical range")).toBe("near"); + expect(statusGroupForCategory("Above typical range")).toBe("near"); }); it("falls back to amber for caution + unmapped categories", () => { diff --git a/src/components/targets/target-status-pill.tsx b/src/components/targets/target-status-pill.tsx index d2272572d..57e05fcc9 100644 --- a/src/components/targets/target-status-pill.tsx +++ b/src/components/targets/target-status-pill.tsx @@ -51,9 +51,9 @@ const STATUS_CATEGORY_KEY: Record = { "Hypertension Grade 1": "hypertensionGrade1", "Hypertension Grade 2": "hypertensionGrade2", "Hypertension Grade 3": "hypertensionGrade3", - Bradycardia: "bradycardia", + "Below typical range": "belowTypicalRange", Elevated: "elevated", - Tachycardia: "tachycardia", + "Above typical range": "aboveTypicalRange", "Significantly low": "significantlyLow", "Slightly low": "slightlyLow", "On target": "onTarget", @@ -128,6 +128,13 @@ export function statusGroupForCategory( "Moderately active", "Essential", ]); + // Note: "Below typical range" / "Above typical range" (resting pulse, + // v1.29.6) are deliberately absent from both sets — they fall through to + // the default "near" (amber) group below, same as "Elevated" already + // does. The prior "Bradycardia" / "Tachycardia" categories painted this + // pill destructive-red, a diagnosis-shaped verdict that contradicted the + // calmer, neutral framing the interpretation layer uses for the identical + // signal (see `insight-interpretation.ts` → `RESTING_HEART_RATE`). const redCategories = new Set([ "Obesity Grade III", "Hypertension Grade 3", @@ -135,8 +142,6 @@ export function statusGroupForCategory( "Significantly low", "Far too short", "Far too long", - "Tachycardia", - "Bradycardia", "Low", "High", "Fluctuating", diff --git a/src/lib/analytics/classifications.ts b/src/lib/analytics/classifications.ts index 293a4d27d..773d20872 100644 --- a/src/lib/analytics/classifications.ts +++ b/src/lib/analytics/classifications.ts @@ -101,9 +101,25 @@ export interface PulseClassification { severity: "info" | "normal" | "warning" | "danger"; } +/** + * v1.29.6 — dropped the bare clinical-diagnosis nouns ("Bradycardia" / + * "Tachycardia"). This is the same resting-pulse signal the interpretation + * layer (`insight-interpretation.ts` → `RESTING_HEART_RATE`) already frames + * neutrally ("below the standard resting range (common in trained hearts)", + * valence *neutral*) — a trained user at 55 bpm resting must not read a + * diagnosis-shaped verdict on one surface and a calm contextual band on the + * other. Category wording now mirrors that framing (and the "typical range" + * phrasing this codebase already uses in `pulse-targets.ts`); the < 60 band + * keeps its existing non-danger "info" tone, and the > 120 band steps down + * from destructive/"danger" to warning/amber — still flagged, never a verdict. + */ export function classifyPulse(bpm: number): PulseClassification { if (bpm < 60) { - return { category: "Bradycardia", color: "var(--info)", severity: "info" }; + return { + category: "Below typical range", + color: "var(--info)", + severity: "info", + }; } if (bpm <= 100) { return { category: "Normal", color: "var(--success)", severity: "normal" }; @@ -116,9 +132,9 @@ export function classifyPulse(bpm: number): PulseClassification { }; } return { - category: "Tachycardia", - color: "var(--destructive)", - severity: "danger", + category: "Above typical range", + color: "var(--warning)", + severity: "warning", }; } From 8902d3985cb3e9f2cec823fbff597fa6d1c9ec66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 17 Jul 2026 23:11:11 +0200 Subject: [PATCH 05/33] fix(measurements,insights): source-collapse + timezone parity across multi-source surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four data-correctness fixes in the same class as the workouts-list ordering bug (05f80ad37): - GET /api/measurements groupBy=day: the raw-row scan ordered oldest first and sliced to `limit` before bucketing, so a long history never surfaced its recent days. Reads newest-first now. The per-day sum also ran over every source unfiltered — an Apple Health + Withings write for the same day doubled the total. Routes through pickCanonicalSourceRows first, matching the rollup tile's collapse. - PR detection's cumulative-day picker summed every row on a calendar day regardless of source (false "record" days) and bucketed on the UTC calendar day instead of the user's timezone. Rewritten to source-collapse before summing and bucket on the user's IANA zone, reading through the worker's injected Prisma client so the existing dependency-injection contract for tests stays intact. - The correlation-discovery matrix (the insights route, the per-metric assessment card, and the Coach's get_correlations tool — three independent copies of the same fetch) averaged cumulative types (steps, daylight) and sleep across mismatched grains: per-sample chunk means blended with drained daily totals, and sleep averaged per-stage segment durations instead of summing a night's total. Hoisted the grain resolution into one shared buildMeasurementDailySeries helper (source-collapsed day-sum for cumulative types, per-night reconstruction for sleep, mean unchanged for spot vitals) so all three surfaces agree. Regression tests cover newest-first ordering, cross-source collapse matching the rollup, PR no-double-count, and the shared grain helper. --- src/app/api/insights/correlations/route.ts | 59 +++--- .../__tests__/group-by-day.test.ts | 82 +++++++++ src/app/api/measurements/route.ts | 41 ++++- src/lib/ai/coach/tools/correlations-read.ts | 111 +++++------ .../correlation-channel-series.test.ts | 132 +++++++++++++ .../insights/correlation-channel-series.ts | 154 ++++++++++++++++ .../insights/metric-correlation-context.ts | 76 ++++---- .../__tests__/pr-detection-worker.test.ts | 173 +++++++++-------- .../personal-records/pr-detection-worker.ts | 174 +++++++++++++----- 9 files changed, 759 insertions(+), 243 deletions(-) create mode 100644 src/lib/insights/__tests__/correlation-channel-series.test.ts diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index 4256e6274..e741f1607 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -38,11 +38,15 @@ import { type NamedSeries, } from "@/lib/insights/correlation-discovery"; import { + buildMeasurementDailySeries, fetchComplianceSeries, fetchEnvironmentSeries, fetchLabDraws, fetchSymptomSeries, + toDailyMeans, + type MeasurementSeriesRow, } from "@/lib/insights/correlation-channel-series"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; export const dynamic = "force-dynamic"; @@ -58,25 +62,6 @@ function tzDayKey(at: Date, tz: string): string { return `${year}-${mm}-${dd}`; } -/** Collapse rows to per-day means keyed in the user's tz. */ -function toDailyMeans( - rows: Array<{ value: number; at: Date }>, - tz: string, -): DailySeriesPoint[] { - const byDay = new Map(); - for (const r of rows) { - if (!Number.isFinite(r.value)) continue; - const day = tzDayKey(r.at, tz); - const acc = byDay.get(day) ?? { sum: 0, count: 0 }; - acc.sum += r.value; - acc.count += 1; - byDay.set(day, acc); - } - return [...byDay.entries()] - .map(([day, acc]) => ({ day, value: acc.sum / acc.count })) - .sort((a, b) => (a.day < b.day ? -1 : 1)); -} - export const GET = apiHandler(async () => { const { user } = await requireAuth(); @@ -112,7 +97,11 @@ export const GET = apiHandler(async () => { DISCOVERY_OUTCOMES, ) as MeasurementType[]; - const [measurements, moodEntries] = await Promise.all([ + // v1.29.6 — `source` + `deviceType` + `sleepStage` feed + // `buildMeasurementDailySeries`'s per-type grain resolution below (source + // collapse for cumulative types, per-night reconstruction for sleep); the + // rest of the channels never look at them. + const [measurements, moodEntries, priorityJson] = await Promise.all([ prisma.measurement.findMany({ where: { userId: user.id, @@ -122,7 +111,14 @@ export const GET = apiHandler(async () => { }, orderBy: { measuredAt: "asc" }, take: 20000, - select: { type: true, value: true, measuredAt: true }, + select: { + type: true, + value: true, + measuredAt: true, + source: true, + deviceType: true, + sleepStage: true, + }, }), prisma.moodEntry.findMany({ where: { userId: user.id, deletedAt: null, moodLoggedAt: { gte: since } }, @@ -130,15 +126,19 @@ export const GET = apiHandler(async () => { take: 5000, select: { score: true, moodLoggedAt: true }, }), + loadUserSourcePriority(user.id), ]); - const measurementsByType = new Map< - string, - Array<{ value: number; at: Date }> - >(); + const measurementsByType = new Map(); for (const m of measurements) { const list = measurementsByType.get(m.type) ?? []; - list.push({ value: m.value, at: m.measuredAt }); + list.push({ + value: m.value, + at: m.measuredAt, + source: m.source, + deviceType: m.deviceType, + sleepStage: m.sleepStage, + }); measurementsByType.set(m.type, list); } @@ -167,7 +167,12 @@ export const GET = apiHandler(async () => { const points = (key: string): DailySeriesPoint[] => key === "MOOD" ? moodDaily - : toDailyMeans(measurementsByType.get(key) ?? [], tz); + : buildMeasurementDailySeries( + key as MeasurementType, + measurementsByType.get(key) ?? [], + tz, + priorityJson, + ); const series: NamedSeries[] = []; for (const key of DISCOVERY_BEHAVIOURS) { diff --git a/src/app/api/measurements/__tests__/group-by-day.test.ts b/src/app/api/measurements/__tests__/group-by-day.test.ts index 3d193d221..d22a10677 100644 --- a/src/app/api/measurements/__tests__/group-by-day.test.ts +++ b/src/app/api/measurements/__tests__/group-by-day.test.ts @@ -27,6 +27,11 @@ vi.mock("@/lib/db", () => ({ measurementRollup: { findMany: vi.fn(), }, + // v1.29.6 — the groupBy=day branch now runs its raw rows through + // `pickCanonicalSourceRows`, which loads the user's source-priority + // blob via `loadUserSourcePriority` (prisma.user.findUnique). `null` + // resolves to the default ladders. + user: { findUnique: vi.fn() }, // v1.4.43 W6 — validation-failed paths write an audit breadcrumb. auditLog: { create: vi.fn().mockResolvedValue({}) }, $queryRaw: vi.fn(), @@ -75,6 +80,9 @@ beforeEach(() => { // write; the route swallows rejections so the test only needs a // resolved mock to keep the catch-block silent. vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + // v1.29.6 — null source-priority blob → default rank ladders for the + // groupBy=day canonical-source collapse. + vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never); }); describe("GET /api/measurements — groupBy=day (W7c collapsed list)", () => { @@ -165,6 +173,80 @@ describe("GET /api/measurements — groupBy=day (W7c collapsed list)", () => { expect(day15.measuredAt).toContain("2026-05-15T10:00:00"); }); + // v1.29.6 — the raw-row scan must read the MOST RECENT window before + // bucketing, mirroring the workouts-list ordering fix (05f80ad37): the + // canonical/day-bucket picker doesn't reorder its input, but the + // underlying `orderBy: asc` + `take: limit` sliced the OLDEST page of + // rows before the day-bucketing loop ran — on a long history the most + // recent days never appeared at all. + it("scans the raw rows newest-first before bucketing", async () => { + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + + await GET(getRequest("type=ACTIVITY_STEPS&groupBy=day")); + + const call = vi.mocked(prisma.measurement.findMany).mock.calls[0]?.[0] as { + orderBy?: { measuredAt?: "asc" | "desc" }; + }; + expect(call.orderBy).toEqual({ measuredAt: "desc" }); + }); + + // v1.29.6 — a day with both an Apple Health AND a Withings write for the + // same cumulative metric must collapse to the ladder-canonical source + // before summing, exactly like the dashboard rollup tile + // (`collapseRollupRowsBySource`) does. Without the collapse, 9000 + // Apple-Health steps + 8800 Withings steps summed to a fabricated 17800 — + // a number the tile never showed. + it("collapses cross-source rows to the ladder-canonical source before summing", async () => { + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { + id: "m-apple", + type: "ACTIVITY_STEPS", + value: 9000, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-15T08:00:00.000Z"), + notes: null, + deviceType: null, + }, + { + id: "m-withings", + type: "ACTIVITY_STEPS", + value: 8800, + unit: "steps", + source: "WITHINGS", + measuredAt: new Date("2026-05-15T09:00:00.000Z"), + notes: null, + deviceType: null, + }, + ] as never); + + const res = await GET( + getRequest( + "type=ACTIVITY_STEPS&groupBy=day&from=2026-05-15T00:00:00Z&to=2026-05-16T00:00:00Z", + ), + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { + measurements: Array<{ + dayKey: string; + value: number; + source: string; + sampleCount: number; + }>; + meta: { droppedDuplicates?: number }; + }; + }; + expect(json.data.measurements.length).toBe(1); + const [day] = json.data.measurements; + // APPLE_HEALTH beats WITHINGS on the default `steps` ladder — the + // Withings row drops out of the sum entirely rather than adding on top. + expect(day.value).toBe(9000); + expect(day.source).toBe("APPLE_HEALTH"); + expect(day.sampleCount).toBe(1); + expect(json.data.meta.droppedDuplicates).toBe(1); + }); + it("falls back to the legacy findMany path when groupBy=day is set on a non-cumulative type", async () => { vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 50ec6ebd8..52fbac848 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -21,6 +21,8 @@ import { type AggregateGrain, } from "@/lib/measurements/range-aggregation"; import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; +import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum"; +import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority"; import { canonicalDailyTimestamp, dayKeyForUserTz, @@ -214,9 +216,17 @@ export const GET = apiHandler(async (request: NextRequest) => { user.timezone && user.timezone.length > 0 ? user.timezone : "Europe/Berlin"; + // v1.29.6 — DESC + take so a long history returns the MOST RECENT + // window of raw samples for bucketing. The previous `orderBy: asc` + // + `take: limit` sliced the OLDEST page of rows before the + // day-bucketing loop ran below — on an account with more than + // `limit` cumulative samples in its history, the buckets were built + // entirely from year-old rows and the recent days silently never + // appeared. Mirrors the workouts-list ordering fix (05f80ad37): + // re-establish "most recent" as the page the caller actually gets. const rows = await prisma.measurement.findMany({ where, - orderBy: { measuredAt: "asc" }, + orderBy: { measuredAt: "desc" }, // Per-sample row scan within the requested window — bounded by // the existing `limit` cap (default 100, max 5000) so a wide // multi-year window can still walk the whole range when the @@ -230,9 +240,34 @@ export const GET = apiHandler(async (request: NextRequest) => { source: true, measuredAt: true, notes: true, + deviceType: true, }, }); + // v1.29.6 — collapse overlapping sources to the ladder-canonical + // reading per day BEFORE summing. Without this, a day with both an + // Apple Health AND a Withings write for the same cumulative metric + // (e.g. steps) summed BOTH sources into one inflated total — a day + // the dashboard tile (which reads through the source-collapsed + // rollup, `collapseRollupRowsBySource` in + // `src/lib/rollups/measurement-read.ts`) showed correctly. Uses the + // same `metricKeyForType` ladder lookup the rollup collapse uses, so + // this list and the tile agree on which source wins. A type with no + // ladder (e.g. TIME_IN_DAYLIGHT, FALL_COUNT) has nothing to collapse + // against — every row passes through unchanged, matching the + // pass-through fallback used at the other `pickCanonicalSourceRows` + // call sites. + const metricKey = metricKeyForType(type as MeasurementType); + const priorityJson = await loadUserSourcePriority(user.id); + const canonicalRows = metricKey + ? pickCanonicalSourceRows( + rows.map((r) => ({ ...r, type: r.type as MeasurementType })), + metricKey, + priorityJson, + (d) => dayKeyForUserTz(d, tz), + ).canonicalRows + : rows; + // Group by the user's calendar day, sum values, keep a representative // unit/source from the bucket's first row so the rendered row carries // a sensible label even when the bucket mixes sources. @@ -245,7 +280,7 @@ export const GET = apiHandler(async (request: NextRequest) => { source: string; }; const buckets = new Map(); - for (const row of rows) { + for (const row of canonicalRows) { const key = dayKeyForUserTz(row.measuredAt, tz); const slot = buckets.get(key); if (!slot) { @@ -293,6 +328,7 @@ export const GET = apiHandler(async (request: NextRequest) => { type, groupBy: "day", rowsScanned: rows.length, + droppedDuplicates: rows.length - canonicalRows.length, }, }); return apiSuccess({ @@ -302,6 +338,7 @@ export const GET = apiHandler(async (request: NextRequest) => { limit, offset: 0, groupBy: "day", + droppedDuplicates: rows.length - canonicalRows.length, }, }); } diff --git a/src/lib/ai/coach/tools/correlations-read.ts b/src/lib/ai/coach/tools/correlations-read.ts index ebeb3e2d5..f1d9c8d85 100644 --- a/src/lib/ai/coach/tools/correlations-read.ts +++ b/src/lib/ai/coach/tools/correlations-read.ts @@ -35,14 +35,17 @@ import { EARLY_WINDOW_DAYS, MEDICATION_COMPLIANCE_CHANNEL_KEY, SYMPTOM_SEVERITY_CHANNEL_KEY, - type DailySeriesPoint, type NamedSeries, } from "@/lib/insights/correlation-discovery"; import { + buildMeasurementDailySeries, fetchComplianceSeries, fetchLabDraws, fetchSymptomSeries, + toDailyMeans, + type MeasurementSeriesRow, } from "@/lib/insights/correlation-channel-series"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { computeCoincidentDeviation, loadBaselineProfile, @@ -127,25 +130,6 @@ function tzDayKey(at: Date, tz: string): string { return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; } -/** Collapse rows to per-day means keyed in the user's tz. */ -function toDailyMeans( - rows: Array<{ value: number; at: Date }>, - tz: string, -): DailySeriesPoint[] { - const byDay = new Map(); - for (const r of rows) { - if (!Number.isFinite(r.value)) continue; - const day = tzDayKey(r.at, tz); - const acc = byDay.get(day) ?? { sum: 0, count: 0 }; - acc.sum += r.value; - acc.count += 1; - byDay.set(day, acc); - } - return [...byDay.entries()] - .map(([day, acc]) => ({ day, value: acc.sum / acc.count })) - .sort((a, b) => (a.day < b.day ? -1 : 1)); -} - /** Natural labels for the non-measurement channel keys (read cleanly in prose). */ const CHANNEL_LABELS: Record = { [MEDICATION_COMPLIANCE_CHANNEL_KEY]: "medication adherence", @@ -186,41 +170,68 @@ export async function readCoachCorrelations( DISCOVERY_OUTCOMES, ) as MeasurementType[]; - const [measurements, moodEntries, coincidentDerived] = await Promise.all([ - prisma.measurement.findMany({ - where: { - userId, - deletedAt: null, - measuredAt: { gte: since }, - type: { in: [...behaviourTypes, ...outcomeTypes] }, - }, - orderBy: { measuredAt: "asc" }, - take: 20000, - select: { type: true, value: true, measuredAt: true }, - }), - prisma.moodEntry.findMany({ - where: { userId, deletedAt: null, moodLoggedAt: { gte: since } }, - orderBy: { moodLoggedAt: "asc" }, - take: 5000, - select: { score: true, moodLoggedAt: true }, - }), - // Coincident-deviation is its own derived metric — fail-soft to null so a - // baseline hiccup never sinks the whole correlations read. D2-8: pass the - // user's tz so the "today" grouping matches the user's calendar day, not - // UTC's, before the fired flag is narrated as "out of band TODAY". - computeCoincidentDeviation(userId, profile, { tz }).catch(() => null), - ]); + // v1.29.6 — `source` + `deviceType` + `sleepStage` feed + // `buildMeasurementDailySeries`'s per-type grain resolution below (see + // the insights route's identical comment for why). + const [measurements, moodEntries, coincidentDerived, priorityJson] = + await Promise.all([ + prisma.measurement.findMany({ + where: { + userId, + deletedAt: null, + measuredAt: { gte: since }, + type: { in: [...behaviourTypes, ...outcomeTypes] }, + }, + orderBy: { measuredAt: "asc" }, + take: 20000, + select: { + type: true, + value: true, + measuredAt: true, + source: true, + deviceType: true, + sleepStage: true, + }, + }), + prisma.moodEntry.findMany({ + where: { userId, deletedAt: null, moodLoggedAt: { gte: since } }, + orderBy: { moodLoggedAt: "asc" }, + take: 5000, + select: { score: true, moodLoggedAt: true }, + }), + // Coincident-deviation is its own derived metric — fail-soft to null so a + // baseline hiccup never sinks the whole correlations read. D2-8: pass the + // user's tz so the "today" grouping matches the user's calendar day, not + // UTC's, before the fired flag is narrated as "out of band TODAY". + computeCoincidentDeviation(userId, profile, { tz }).catch(() => null), + loadUserSourcePriority(userId), + ]); - const byType = new Map>(); + const byType = new Map(); for (const m of measurements) { const list = byType.get(m.type) ?? []; - list.push({ value: m.value, at: m.measuredAt }); + list.push({ + value: m.value, + at: m.measuredAt, + source: m.source, + deviceType: m.deviceType, + sleepStage: m.sleepStage, + }); byType.set(m.type, list); } const moodDaily = toDailyMeans( moodEntries.map((e) => ({ value: e.score, at: e.moodLoggedAt })), tz, ); + const seriesPoints = (key: string) => + key === "MOOD" + ? moodDaily + : buildMeasurementDailySeries( + key as MeasurementType, + byType.get(key) ?? [], + tz, + priorityJson, + ); // The two non-measurement, non-mood channels come from their own sources // (the dose-history ledger + the illness day-log), folded in below exactly @@ -241,18 +252,14 @@ export async function readCoachCorrelations( } else if (key === SYMPTOM_SEVERITY_CHANNEL_KEY) { series.push({ ...symptomSeries, role: "behaviour" }); } else { - const points = - key === "MOOD" ? moodDaily : toDailyMeans(byType.get(key) ?? [], tz); - series.push({ key, role: "behaviour", points }); + series.push({ key, role: "behaviour", points: seriesPoints(key) }); } } for (const key of DISCOVERY_OUTCOMES) { if (key === SYMPTOM_SEVERITY_CHANNEL_KEY) { series.push({ ...symptomSeries, role: "outcome" }); } else { - const points = - key === "MOOD" ? moodDaily : toDailyMeans(byType.get(key) ?? [], tz); - series.push({ key, role: "outcome", points }); + series.push({ key, role: "outcome", points: seriesPoints(key) }); } } diff --git a/src/lib/insights/__tests__/correlation-channel-series.test.ts b/src/lib/insights/__tests__/correlation-channel-series.test.ts new file mode 100644 index 000000000..65b3dc833 --- /dev/null +++ b/src/lib/insights/__tests__/correlation-channel-series.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from "vitest"; +import type { SleepStage } from "@/generated/prisma/client"; +import { + buildMeasurementDailySeries, + toDailyMeans, + type MeasurementSeriesRow, +} from "@/lib/insights/correlation-channel-series"; + +/** Build a raw measurement row for `buildMeasurementDailySeries`. */ +function row( + iso: string, + value: number, + source: MeasurementSeriesRow["source"] = "APPLE_HEALTH", + sleepStage: SleepStage | null = null, + deviceType: string | null = null, +): MeasurementSeriesRow { + return { at: new Date(iso), value, source, deviceType, sleepStage }; +} + +describe("buildMeasurementDailySeries — grain consistency (v1.29.6)", () => { + it("sums (not averages) a cumulative type across mixed per-sample-chunk and drained daily-total rows", () => { + const rows: MeasurementSeriesRow[] = [ + // 2026-06-04: two ~mid-morning / afternoon per-sample chunks — the + // "not-yet-drained" shape. + row("2026-06-04T08:00:00.000Z", 350), + row("2026-06-04T14:00:00.000Z", 300), + // 2026-06-05: a single drained `stats:` daily total. + row("2026-06-05T12:00:00.000Z", 8400), + ]; + + const points = buildMeasurementDailySeries( + "ACTIVITY_STEPS", + rows, + "UTC", + null, + ); + + expect(points).toEqual([ + { day: "2026-06-04", value: 650 }, + { day: "2026-06-05", value: 8400 }, + ]); + + // The old `toDailyMeans` reduction would have produced meaningless + // blended figures — pin that the two grains disagree, so a future + // regression that reverts to `toDailyMeans` for this type is caught. + const meanPoints = toDailyMeans( + rows.map((r) => ({ value: r.value, at: r.at })), + "UTC", + ); + expect(meanPoints.find((p) => p.day === "2026-06-04")?.value).toBe(325); // mean of 350/300 + expect(meanPoints.find((p) => p.day === "2026-06-05")?.value).toBe(8400); // single row, coincides + }); + + it("collapses overlapping sources to the ladder-canonical reading before summing a cumulative type", () => { + const rows: MeasurementSeriesRow[] = [ + row("2026-06-04T08:00:00.000Z", 9000, "APPLE_HEALTH"), + row("2026-06-04T08:05:00.000Z", 8800, "WITHINGS"), + ]; + + const points = buildMeasurementDailySeries( + "ACTIVITY_STEPS", + rows, + "UTC", + null, + ); + + // Default `steps` ladder ranks APPLE_HEALTH above WITHINGS — the + // Withings row must drop out of the sum entirely, not add on top. + expect(points).toEqual([{ day: "2026-06-04", value: 9000 }]); + }); + + it("sums a night's per-stage segments into one total instead of averaging them", () => { + // One night: CORE 240 + DEEP 90 + REM 80 = 410 minutes asleep. + // `measuredAt` is the END of each segment; the reconstructor derives + // the start from `measuredAt - value minutes` (mirrors sleep-night.test.ts). + const rows: MeasurementSeriesRow[] = [ + row("2026-06-04T04:00:00.000Z", 240, "APPLE_HEALTH", "CORE"), + row("2026-06-04T05:30:00.000Z", 90, "APPLE_HEALTH", "DEEP"), + row("2026-06-04T07:00:00.000Z", 80, "APPLE_HEALTH", "REM"), + ]; + + const points = buildMeasurementDailySeries( + "SLEEP_DURATION", + rows, + "UTC", + null, + ); + + expect(points).toHaveLength(1); + // The pre-fix `toDailyMeans` reduction would have averaged the three + // segment durations (240 + 90 + 80) / 3 ≈ 137 — a number with no + // clinical meaning. The correct grain is the night's TOTAL. + expect(points[0].value).toBe(410); + expect(points[0].value).not.toBeCloseTo((240 + 90 + 80) / 3, 0); + }); + + it("collapses an overlapping-source night (WHOOP + Apple Health) to one canonical total, never double-counted", () => { + // Same night, two writers: WHOOP (wins the default `sleep` ladder) + // reports a shorter granular breakdown; Apple Health separately + // reports its own (different) total for the same night. Without the + // writer-dedup a naive per-row collapse could blend or double both. + const rows: MeasurementSeriesRow[] = [ + row("2026-06-04T04:00:00.000Z", 200, "WHOOP", "CORE"), + row("2026-06-04T05:00:00.000Z", 60, "WHOOP", "DEEP"), + row("2026-06-04T04:30:00.000Z", 300, "APPLE_HEALTH", "CORE"), + ]; + + const points = buildMeasurementDailySeries( + "SLEEP_DURATION", + rows, + "UTC", + null, + ); + + expect(points).toHaveLength(1); + // WHOOP wins the default `sleep` ladder — the night's total is the + // WHOOP-only sum (260), not a blend with Apple Health's 300 and not + // the two summed together (560). + expect(points[0].value).toBe(260); + }); + + it("keeps the MEAN grain for spot metrics (unchanged behaviour)", () => { + const rows: MeasurementSeriesRow[] = [ + row("2026-06-04T08:00:00.000Z", 60), + row("2026-06-04T20:00:00.000Z", 64), + ]; + + const points = buildMeasurementDailySeries("PULSE", rows, "UTC", null); + + expect(points).toEqual([{ day: "2026-06-04", value: 62 }]); + }); +}); diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts index fa3d4f90e..378106dae 100644 --- a/src/lib/insights/correlation-channel-series.ts +++ b/src/lib/insights/correlation-channel-series.ts @@ -38,6 +38,18 @@ import { type SymptomEpisodeSpan, } from "@/lib/insights/correlation-series-builders"; import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history"; +import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; +import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum"; +import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority"; +import { + reconstructSleepSessions, + pickMainNightAndNaps, + type SleepStageRow, +} from "@/lib/analytics/sleep-night"; +import type { + MeasurementSource, + MeasurementType, +} from "@/generated/prisma/client"; /** * v1.21.0 (FDREXTEND) — build the user's MEDICATION_COMPLIANCE daily series. @@ -244,6 +256,148 @@ function tzDayKey(at: Date, tz: string): string { return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; } +/** + * v1.29.6 — collapse rows to per-day MEANS keyed in the user's tz. This is + * the correct grain for spot metrics (BP, glucose, HRV, resting HR, weight, + * mood) where a day's reduction is the average of its readings. Cumulative + * metrics and sleep must NOT use this — see `buildMeasurementDailySeries`. + * + * Hoisted here from the two former call sites (`/api/insights/correlations` + * and `metric-correlation-context.ts`) so both surfaces stay byte-identical + * instead of drifting as two independently-maintained copies — the same + * class of drift that let the cumulative/sleep grain bug below slip into + * one file and not the other. + */ +export function toDailyMeans( + rows: Array<{ value: number; at: Date }>, + tz: string, +): DailySeriesPoint[] { + const byDay = new Map(); + for (const r of rows) { + if (!Number.isFinite(r.value)) continue; + const day = tzDayKey(r.at, tz); + const acc = byDay.get(day) ?? { sum: 0, count: 0 }; + acc.sum += r.value; + acc.count += 1; + byDay.set(day, acc); + } + return [...byDay.entries()] + .map(([day, acc]) => ({ day, value: acc.sum / acc.count })) + .sort((a, b) => (a.day < b.day ? -1 : 1)); +} + +/** Minimum row shape `buildMeasurementDailySeries` needs per raw reading. */ +export interface MeasurementSeriesRow { + value: number; + at: Date; + source: MeasurementSource; + deviceType: string | null; + /** Only populated (and only consulted) for SLEEP_DURATION rows. */ + sleepStage: SleepStageRow["sleepStage"] | null; +} + +/** + * v1.29.6 — collapse one MeasurementType's raw rows to a single-grain daily + * series, keyed in the user's tz. Fixes a correlation-discovery distortion: + * `ACTIVITY_STEPS` and other cumulative HK types were being reduced with + * `toDailyMeans`, which blends per-sample chunk averages (~350 steps, from + * the not-yet-nightly-drained window) with drained `stats:` daily totals + * (~8400 steps) into one meaningless per-day figure. `SLEEP_DURATION` was + * averaging per-STAGE segment durations (~45 min) instead of summing a + * night's total time asleep, and without collapsing overlapping sources + * first (a WHOOP + Apple Health night double-counted). + * + * - `SLEEP_DURATION` → per-night TOTAL time asleep for the MAIN session + * (naps excluded, matching the dashboard/list convention), via + * `reconstructSleepSessions` — the same writer-dedup + per-night + * collapse the sleep list route and dashboard tile use. + * - `CUMULATIVE_HK_TYPES` (steps, active energy, distance, flights, + * daylight, falls) → source-collapsed per-day SUM, via + * `pickCanonicalSourceRows` (a type with no ladder passes every row + * through unchanged, matching the picker's documented fallback). + * - everything else → per-day MEAN via `toDailyMeans` (unchanged). + */ +export function buildMeasurementDailySeries( + type: MeasurementType, + rows: MeasurementSeriesRow[], + tz: string, + priorityJson: unknown, +): DailySeriesPoint[] { + if (type === "SLEEP_DURATION") { + return buildSleepDailySeries(rows, tz, priorityJson); + } + if (CUMULATIVE_HK_TYPES.has(type)) { + return buildCumulativeDailySeries(type, rows, tz, priorityJson); + } + return toDailyMeans( + rows.map((r) => ({ value: r.value, at: r.at })), + tz, + ); +} + +function buildSleepDailySeries( + rows: MeasurementSeriesRow[], + tz: string, + priorityJson: unknown, +): DailySeriesPoint[] { + const stageRows: SleepStageRow[] = rows.map((r) => ({ + value: r.value, + measuredAt: r.at, + sleepStage: r.sleepStage, + source: r.source, + deviceType: r.deviceType, + })); + const sessions = reconstructSleepSessions(stageRows, tz, priorityJson); + + const byNight = new Map(); + for (const s of sessions) { + const list = byNight.get(s.night) ?? []; + list.push(s); + byNight.set(s.night, list); + } + + const points: DailySeriesPoint[] = []; + for (const [night, nightSessions] of byNight) { + const { main } = pickMainNightAndNaps(nightSessions); + if (!main) continue; + points.push({ day: night, value: main.asleepMinutes }); + } + return points.sort((a, b) => (a.day < b.day ? -1 : 1)); +} + +function buildCumulativeDailySeries( + type: MeasurementType, + rows: MeasurementSeriesRow[], + tz: string, + priorityJson: unknown, +): DailySeriesPoint[] { + const metricKey = metricKeyForType(type); + const canonicalRows = metricKey + ? pickCanonicalSourceRows( + rows.map((r) => ({ + measuredAt: r.at, + source: r.source, + deviceType: r.deviceType, + type, + value: r.value, + })), + metricKey, + priorityJson, + (d) => tzDayKey(d, tz), + ).canonicalRows + : rows.map((r) => ({ measuredAt: r.at, value: r.value })); + + const byDay = new Map(); + for (const row of canonicalRows) { + if (!Number.isFinite(row.value)) continue; + const key = tzDayKey(row.measuredAt, tz); + byDay.set(key, (byDay.get(key) ?? 0) + row.value); + } + return [...byDay.entries()] + .map(([day, value]) => ({ day, value })) + .sort((a, b) => (a.day < b.day ? -1 : 1)); +} + /** * v1.22 — build the user's lab draws for the labs ↔ outcome correlation pass. * diff --git a/src/lib/insights/metric-correlation-context.ts b/src/lib/insights/metric-correlation-context.ts index 2d18cd396..c7557aec9 100644 --- a/src/lib/insights/metric-correlation-context.ts +++ b/src/lib/insights/metric-correlation-context.ts @@ -22,15 +22,19 @@ */ import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; -import { wallClockInTz } from "@/lib/tz/wall-clock"; import { discoverCorrelations, discoveryMeasurementTypes, DISCOVERY_BEHAVIOURS, DISCOVERY_OUTCOMES, - type DailySeriesPoint, type NamedSeries, } from "@/lib/insights/correlation-discovery"; +import { + buildMeasurementDailySeries, + toDailyMeans, + type MeasurementSeriesRow, +} from "@/lib/insights/correlation-channel-series"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import type { RelevantCorrelation } from "@/lib/insights/assessment-context"; const MS_PER_DAY = 24 * 60 * 60 * 1000; @@ -55,31 +59,6 @@ function channelKeyForType(type: MeasurementType): string | null { return null; } -/** Day key (YYYY-MM-DD) for an instant in the user's display timezone. */ -function tzDayKey(at: Date, tz: string): string { - const { year, month, day } = wallClockInTz(at, tz); - return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; -} - -/** Collapse rows to per-day means keyed in the user's tz. */ -function toDailyMeans( - rows: Array<{ value: number; at: Date }>, - tz: string, -): DailySeriesPoint[] { - const byDay = new Map(); - for (const r of rows) { - if (!Number.isFinite(r.value)) continue; - const day = tzDayKey(r.at, tz); - const acc = byDay.get(day) ?? { sum: 0, count: 0 }; - acc.sum += r.value; - acc.count += 1; - byDay.set(day, acc); - } - return [...byDay.entries()] - .map(([day, acc]) => ({ day, value: acc.sum / acc.count })) - .sort((a, b) => (a.day < b.day ? -1 : 1)); -} - /** * Fetch the FDR-surviving correlations that involve `measurementType`. * @@ -117,7 +96,10 @@ export async function getRelevantCorrelationsForMetric( DISCOVERY_OUTCOMES, ) as MeasurementType[]; - const [measurements, moodEntries] = await Promise.all([ + // v1.29.6 — `source` + `deviceType` + `sleepStage` feed + // `buildMeasurementDailySeries`'s per-type grain resolution below (see + // the route's identical comment for why). + const [measurements, moodEntries, priorityJson] = await Promise.all([ prisma.measurement.findMany({ where: { userId, @@ -127,7 +109,14 @@ export async function getRelevantCorrelationsForMetric( }, orderBy: { measuredAt: "asc" }, take: 20000, - select: { type: true, value: true, measuredAt: true }, + select: { + type: true, + value: true, + measuredAt: true, + source: true, + deviceType: true, + sleepStage: true, + }, }), prisma.moodEntry.findMany({ where: { userId, deletedAt: null, moodLoggedAt: { gte: since } }, @@ -135,12 +124,19 @@ export async function getRelevantCorrelationsForMetric( take: 5000, select: { score: true, moodLoggedAt: true }, }), + loadUserSourcePriority(userId), ]); - const byType = new Map>(); + const byType = new Map(); for (const m of measurements) { const list = byType.get(m.type) ?? []; - list.push({ value: m.value, at: m.measuredAt }); + list.push({ + value: m.value, + at: m.measuredAt, + source: m.source, + deviceType: m.deviceType, + sleepStage: m.sleepStage, + }); byType.set(m.type, list); } const moodDaily = toDailyMeans( @@ -148,16 +144,22 @@ export async function getRelevantCorrelationsForMetric( tz, ); + const points = (key: string) => + key === "MOOD" + ? moodDaily + : buildMeasurementDailySeries( + key as MeasurementType, + byType.get(key) ?? [], + tz, + priorityJson, + ); + const series: NamedSeries[] = []; for (const key of DISCOVERY_BEHAVIOURS) { - const points = - key === "MOOD" ? moodDaily : toDailyMeans(byType.get(key) ?? [], tz); - series.push({ key, role: "behaviour", points }); + series.push({ key, role: "behaviour", points: points(key) }); } for (const key of DISCOVERY_OUTCOMES) { - const points = - key === "MOOD" ? moodDaily : toDailyMeans(byType.get(key) ?? [], tz); - series.push({ key, role: "outcome", points }); + series.push({ key, role: "outcome", points: points(key) }); } const result = discoverCorrelations(series); diff --git a/src/lib/personal-records/__tests__/pr-detection-worker.test.ts b/src/lib/personal-records/__tests__/pr-detection-worker.test.ts index 08561385f..c4377c3d0 100644 --- a/src/lib/personal-records/__tests__/pr-detection-worker.test.ts +++ b/src/lib/personal-records/__tests__/pr-detection-worker.test.ts @@ -36,6 +36,7 @@ interface FakeMeasurementRow { source: string; externalId: string | null; userId: string; + deviceType?: string | null; } interface FakeWorkoutRow { @@ -100,14 +101,6 @@ function makeFakePrisma(state: { return true; } - // REG-9 day bucket key. Mirrors `date_trunc('day', measured_at)` on - // UTC — the production query and this fake share the same bucket - // contract so a day-sum test exercises the same partitioning the - // SQL aggregate performs. - function dayKey(d: Date): string { - return `${d.getUTCFullYear()}-${d.getUTCMonth()}-${d.getUTCDate()}`; - } - function matchesWorkoutWhere(row: FakeWorkoutRow, where: FakeWhere): boolean { if (where.userId !== undefined && row.userId !== where.userId) return false; if (where.sportType !== undefined && row.sportType !== where.sportType) @@ -140,79 +133,33 @@ function makeFakePrisma(state: { return true; } - // REG-9 (v1.4.46): the cumulative-kind path runs a `$queryRaw` - // template that SUMs each `(user_id, type)` partition by - // `date_trunc('day', measured_at)` and `ORDER BY day_total {DESC|ASC}`. - // The fake re-implements the same shape against the in-memory rows - // so the worker-level tests exercise the real day-bucket reducer - // logic, not a stub. - function runCumulativeDaySum( - userId: string, - type: MeasurementType, - direction: "desc" | "asc", - ): Array<{ day_total: number; max_measured_at: Date }> { - const matches = state.measurements.filter( - (r) => r.userId === userId && r.type === type, - ); - if (matches.length === 0) return []; - - const byDay = new Map< - string, - { day_total: number; max_measured_at: Date } - >(); - for (const r of matches) { - const key = dayKey(r.measuredAt); - const slot = byDay.get(key) ?? { - day_total: 0, - max_measured_at: r.measuredAt, - }; - slot.day_total += r.value; - if (r.measuredAt > slot.max_measured_at) { - slot.max_measured_at = r.measuredAt; - } - byDay.set(key, slot); - } - - const sorted = Array.from(byDay.values()).sort((a, b) => - direction === "desc" - ? b.day_total - a.day_total - : a.day_total - b.day_total, - ); - return [sorted[0]]; - } - return { - $queryRaw: vi.fn( - async (sql: { strings: string[]; values: unknown[]; sql?: string }) => { - // Prisma.sql flattens nested fragments at construction time — - // the ASC/DESC fragment is inlined into `strings`, only the - // userId + type land in the bound `values` list. We detect the - // cumulative day-sum probe by the SQL preamble and read the - // direction off the flattened SQL text. - const fullSql = sql.sql ?? sql.strings.join("?"); - if (!fullSql.includes("date_trunc('day', m.\"measured_at\")")) { - throw new Error(`Unhandled $queryRaw in fake Prisma:\n${fullSql}`); - } - const [userIdVal, typeVal] = sql.values as [string, string]; - // The ORDER BY direction was interpolated as a literal — recover - // it from the flattened SQL so the fake exercises the same - // direction the worker asked for. - const dirMatch = /ORDER BY day_total\s+(ASC|DESC)/i.exec(fullSql); - const direction: "asc" | "desc" = - dirMatch?.[1]?.toUpperCase() === "ASC" ? "asc" : "desc"; - return runCumulativeDaySum( - userIdVal, - typeVal as MeasurementType, - direction, - ); - }, - ), + // v1.29.6 — the cumulative-kind path no longer runs a `$queryRaw` + // UTC `date_trunc` aggregate; it reads every matching row via + // `measurement.findMany` and does the source-collapse + tz-aware + // day-bucket SUM in JS (`pickCanonicalSourceRows` + + // `dayKeyForUserTz`), matching every other cumulative-day surface. + user: { + findUnique: vi.fn(async () => ({ + // Every fixture in this file is written in `Date.UTC(...)` — + // "UTC" as the resolved IANA zone keeps the day-bucket keys + // identical to the pre-fix UTC `date_trunc` partitioning, so + // the existing day-sum assertions stay valid. + timezone: "UTC", + sourcePriorityJson: null, + })), + }, measurement: { count: vi.fn(async ({ where }: { where: FakeWhere }) => { return state.measurements.filter((r) => matchesMeasurementWhere(r, where), ).length; }), + findMany: vi.fn(async ({ where }: { where: FakeWhere }) => { + return state.measurements.filter((r) => + matchesMeasurementWhere(r, where), + ); + }), findFirst: vi.fn( async ({ where, @@ -900,6 +847,84 @@ describe("detectPersonalRecordsForUser — REG-9 cumulative day-sum (v1.4.46)", expect(pr?.value, `${type} day-sum`).toBe(expectedValue); } }); + + // v1.29.6 — a day with both an Apple Health AND a Withings write for + // the same cumulative metric must NOT be double-counted into a false + // "record" day. Before the fix, the day-sum probe summed every row on + // the day regardless of source, so 9 000 Apple-Health steps + 8 800 + // Withings steps on one day (17 800) could out-rank a later day's real + // 12 000-step single-source total — a fabricated record that also + // suppressed the later, genuine best. + it("does not double-count an Apple Health + Withings write on the same day into a false record", async () => { + const samples: FakeMeasurementRow[] = []; + for (let i = 0; i < PR_DETECTION_WARMUP_THRESHOLD; i++) { + samples.push({ + id: `m-seed-${i}`, + type: "ACTIVITY_STEPS", + value: 4000 + i * 100, + unit: "x", + measuredAt: new Date(Date.UTC(2026, 4, 4 + i, 12, 0, 0)), + source: "APPLE_HEALTH", + externalId: null, + userId: USER, + }); + } + // Cross-source day: Apple Health (the higher-priority default ladder + // source) and Withings both write the SAME day's steps. Summed + // naively this is 17 800 — bigger than any single-source day below. + // Canonically collapsed (Apple Health wins), it is 9 000. + samples.push({ + id: "m-dual-apple", + type: "ACTIVITY_STEPS", + value: 9000, + unit: "x", + measuredAt: new Date(Date.UTC(2026, 4, 11, 8, 0, 0)), + source: "APPLE_HEALTH", + externalId: null, + userId: USER, + }); + samples.push({ + id: "m-dual-withings", + type: "ACTIVITY_STEPS", + value: 8800, + unit: "x", + measuredAt: new Date(Date.UTC(2026, 4, 11, 8, 5, 0)), + source: "WITHINGS", + externalId: null, + userId: USER, + }); + // A later, genuine single-source day that beats the collapsed + // cross-source day (9 000) but NOT the naive double-counted sum + // (17 800). A pre-fix worker would suppress this as "no improvement". + samples.push({ + id: "m-later-best", + type: "ACTIVITY_STEPS", + value: 12000, + unit: "x", + measuredAt: new Date(Date.UTC(2026, 4, 13, 9, 0, 0)), + source: "APPLE_HEALTH", + externalId: null, + userId: USER, + }); + + const state = { + measurements: samples, + workouts: [], + personalRecords: [] as FakePersonalRecordRow[], + }; + const prisma = makeFakePrisma(state); + + await detectPersonalRecordsForUser(USER, { prisma }); + + const stepsPR = state.personalRecords.find( + (r) => r.metricType === "ACTIVITY_STEPS" && r.metricSlot === null, + ); + expect(stepsPR).toBeDefined(); + expect(stepsPR?.value).toBe(12000); + expect(stepsPR?.achievedAt.getTime()).toBe( + new Date(Date.UTC(2026, 4, 13, 9, 0, 0)).getTime(), + ); + }); }); describe("compareToCurrentBest — pure helper", () => { diff --git a/src/lib/personal-records/pr-detection-worker.ts b/src/lib/personal-records/pr-detection-worker.ts index 31e5165a2..a50fd1767 100644 --- a/src/lib/personal-records/pr-detection-worker.ts +++ b/src/lib/personal-records/pr-detection-worker.ts @@ -39,11 +39,13 @@ */ import { prisma as defaultPrisma } from "@/lib/db"; import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; +import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz"; +import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum"; +import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority"; import { measurementTypeEnum } from "@/lib/validations/measurement"; import { getPRDirection, isPRTrackable } from "./pr-direction"; import { PersonalRecordDirection, - Prisma, type MeasurementType, type PrismaClient, } from "@/generated/prisma/client"; @@ -207,6 +209,26 @@ export async function detectPersonalRecordsForUser( let ties = 0; let scanned = 0; + // v1.29.6 — resolved once per run and threaded into the cumulative-day + // picker: the source-collapse ladder needs the user's priority JSON, and + // the day bucketing needs the user's IANA timezone so a "best day" lines + // up with the calendar day the user actually experienced, matching every + // other cumulative-day surface (the `groupBy=day` list branch, the + // dashboard tile). Read directly via the injected `prisma` client + // (not `resolveUserTimezone` / `loadUserSourcePriority`, which both + // reach for the module-level singleton) so the worker's Prisma + // dependency injection — the whole reason `options.prisma` exists — + // stays intact for tests. + const userRow = await prisma.user.findUnique({ + where: { id: userId }, + select: { timezone: true, sourcePriorityJson: true }, + }); + const tz = + userRow?.timezone && userRow.timezone.length > 0 + ? userRow.timezone + : "Europe/Berlin"; + const priorityJson = userRow?.sourcePriorityJson ?? null; + // ── Measurement-driven PRs (metricSlot = null) ───────────── for (const type of measurementTypeEnum.options) { const metricType = type as MeasurementType; @@ -228,6 +250,8 @@ export async function detectPersonalRecordsForUser( userId, metricType, direction, + tz, + priorityJson, ); if (!best) continue; @@ -404,6 +428,8 @@ async function findBestMeasurement( userId: string, type: MeasurementType, direction: PersonalRecordDirection, + tz: string, + priorityJson: unknown, ): Promise { // REG-9 (v1.4.46): cumulative HealthKit kinds (ACTIVITY_STEPS, // ACTIVE_ENERGY_BURNED, FLIGHTS_CLIMBED, WALKING_RUNNING_DISTANCE, @@ -414,23 +440,30 @@ async function findBestMeasurement( // 4 000 steps plus every other slice on the same calendar day) was // ignored. // - // For cumulative kinds we instead bucket-and-sum per calendar day - // (UTC for now — analytics surfaces still day-bucket in UTC; the - // dashboard tile already runs the same shape via - // `pickCumulativeDaySum`). The picked row's `measuredAt` is the - // latest slice on the winning day so the PR's `achievedAt` - // timestamp still points at a real ingested sample (the unique - // index `(userId, metricType, metricSlot, achievedAt)` needs a - // stable timestamp). For MAX-direction cumulative kinds — which is - // every cumulative type today per `pr-direction.ts` — we pick the - // day with the largest sum. The MIN branch is wired symmetrically - // for future-proofing; if a MIN cumulative kind ever lands the - // worker will pick the day with the smallest sum. + // For cumulative kinds we instead bucket-and-sum per calendar day, + // anchored to the user's IANA timezone (matches every other + // cumulative-day surface — the `groupBy=day` list branch, the + // dashboard tile). The picked row's `measuredAt` is the latest + // slice on the winning day so the PR's `achievedAt` timestamp + // still points at a real ingested sample (the unique index + // `(userId, metricType, metricSlot, achievedAt)` needs a stable + // timestamp). For MAX-direction cumulative kinds — which is every + // cumulative type today per `pr-direction.ts` — we pick the day + // with the largest sum. The MIN branch is wired symmetrically for + // future-proofing; if a MIN cumulative kind ever lands the worker + // will pick the day with the smallest sum. // // Spot kinds (resting HR, VO2 max, HRV, body composition) keep the // existing per-row pick — each row is already the day's measurement. if (CUMULATIVE_HK_TYPES.has(type)) { - return findBestCumulativeDay(prisma, userId, type, direction); + return findBestCumulativeDay( + prisma, + userId, + type, + direction, + tz, + priorityJson, + ); } const row = await prisma.measurement.findFirst({ @@ -460,54 +493,93 @@ async function findBestMeasurement( } /** - * REG-9 (v1.4.46): day-bucket SUM picker for cumulative HK kinds. + * REG-9 (v1.4.46) / v1.29.6 — day-bucket SUM picker for cumulative HK + * kinds. * - * Postgres `date_trunc('day', measured_at)` groups every slice on the - * same UTC calendar day. The outer `ORDER BY day_sum {DESC|ASC} LIMIT 1` - * picks the winning day; the inner `MAX(measured_at)` returns a - * stable timestamp for the PR's `achievedAt`. The latest slice on the - * winning day also carries the row's `unit / source / externalId` — - * we re-read it as a separate `findFirst` so the response shape - * stays identical to the spot-kind path (the caller's downstream - * `personalRecord.create` reaches for the original row's metadata). + * v1.29.6 fix: the original implementation summed EVERY row on a + * calendar day regardless of source. A day with both an Apple Health + * AND a Withings write for the same cumulative metric (e.g. steps) + * summed both sources into one inflated total — a false "record" day + * that could permanently outrank a later day's real single-source + * total, which would never win the comparison again. The fix + * collapses overlapping sources to the ladder-canonical reading per + * day BEFORE summing (`pickCanonicalSourceRows`, the same picker the + * doctor report and mood crosstab use), and buckets on the user's + * IANA timezone instead of the UTC calendar day, matching every other + * cumulative-day surface. * * Order of operations: - * 1. Sum-per-day aggregate via `$queryRaw` (one round-trip). - * 2. Re-read the slice at `MAX(measured_at)` on the winning day - * to recover the row's metadata. + * 1. Read every row for the type (already scoped by the caller's + * warm-up gate to a metric with at least + * `PR_DETECTION_WARMUP_THRESHOLD` samples). + * 2. Collapse to the canonical source per local day. + * 3. Bucket-and-sum the canonical rows per local day; pick the + * winning day by `direction`. + * 4. Re-read the winning day's latest slice so the PR row carries a + * real row's unit/source/externalId (the day-sum overrides + * `value` below). * The unique-index contract is unaffected — a re-run picks the same - * day and the same MAX-slice, so `achievedAt` is stable. + * day and the same latest-slice timestamp, so `achievedAt` is stable. */ async function findBestCumulativeDay( prisma: PrismaClient, userId: string, type: MeasurementType, direction: PersonalRecordDirection, + tz: string, + priorityJson: unknown, ): Promise { - const orderDir = - direction === PersonalRecordDirection.MAX - ? Prisma.sql`DESC` - : Prisma.sql`ASC`; + const rows = await prisma.measurement.findMany({ + where: { userId, type, deletedAt: null }, + orderBy: { measuredAt: "asc" }, + select: { + id: true, + value: true, + unit: true, + measuredAt: true, + source: true, + externalId: true, + deviceType: true, + }, + }); + if (rows.length === 0) return null; - const rows = await prisma.$queryRaw< - Array<{ day_total: number; max_measured_at: Date }> - >( - Prisma.sql` - SELECT - SUM(m."value")::double precision AS day_total, - MAX(m."measured_at") AS max_measured_at - FROM measurements m - WHERE m."user_id" = ${userId} - AND m."type"::text = ${type} - AND m."deleted_at" IS NULL - GROUP BY date_trunc('day', m."measured_at") - ORDER BY day_total ${orderDir} - LIMIT 1 - `, - ); + const metricKey = metricKeyForType(type); + const canonicalRows = metricKey + ? pickCanonicalSourceRows( + rows.map((r) => ({ ...r, type })), + metricKey, + priorityJson, + (d) => dayKeyForUserTz(d, tz), + ).canonicalRows + : rows; - const winner = rows[0]; - if (!winner) return null; + const byDay = new Map(); + for (const row of canonicalRows) { + const key = dayKeyForUserTz(row.measuredAt, tz); + const slot = byDay.get(key); + if (!slot) { + byDay.set(key, { total: row.value, latest: row.measuredAt }); + } else { + slot.total += row.value; + if (row.measuredAt > slot.latest) slot.latest = row.measuredAt; + } + } + + let winnerTotal: number | null = null; + let winnerLatest: Date | null = null; + for (const { total, latest } of byDay.values()) { + const better = + winnerTotal === null || + (direction === PersonalRecordDirection.MAX + ? total > winnerTotal + : total < winnerTotal); + if (better) { + winnerTotal = total; + winnerLatest = latest; + } + } + if (winnerTotal === null || winnerLatest === null) return null; // Re-read the latest slice on the winning day so the PR row carries // a real row's unit/source/externalId. The slice's `value` is the @@ -518,7 +590,7 @@ async function findBestCumulativeDay( userId, type, deletedAt: null, - measuredAt: winner.max_measured_at, + measuredAt: winnerLatest, }, select: { id: true, @@ -532,7 +604,7 @@ async function findBestCumulativeDay( return { id: slice.id, - value: winner.day_total, + value: winnerTotal, unit: slice.unit, measuredAt: slice.measuredAt, source: slice.source, From 4f7420905a4ade3c7a6be4896c4b781212f72934 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 17 Jul 2026 22:56:01 +0200 Subject: [PATCH 06/33] fix(strava): normalise raw sport labels to canonical WorkoutSportType MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strava was the second workout integration, after WHOOP, that wrote its raw vendor sport label ("Run", "Ride", "VirtualRide", ...) straight into Workout.sportType instead of a canonical bucket. Every Strava row read as generic, and the read-time picker treats two generic rows as sport-compatible — a same-source brick session (Strava ride ending ~15:00, Strava run starting 15:03) could cluster into one and silently drop a leg. Add mapStravaSportType() (54 documented Strava sport_type values, mirroring the WHOOP sport-map shape) and apply it at write time in mapActivity(); the raw label stays in metadata.stravaType for provenance. Backfill existing Strava rows via a hand-written migration generated from the same table. Extend the cross-provider sport-map parity test to cover Strava, and add a picker-level test proving a back-to-back Ride+Run pair now survives as two rows instead of one. --- .../migration.sql | 103 +++++++++++ .../__tests__/pick-canonical-workout.test.ts | 39 ++++ src/lib/strava/__tests__/client.test.ts | 32 +++- src/lib/strava/client.ts | 23 ++- src/lib/strava/sport-map.ts | 168 ++++++++++++++++++ .../__tests__/sport-map-parity.test.ts | 107 ++++++++--- 6 files changed, 440 insertions(+), 32 deletions(-) create mode 100644 prisma/migrations/0251_backfill_strava_workout_sport/migration.sql create mode 100644 src/lib/strava/sport-map.ts diff --git a/prisma/migrations/0251_backfill_strava_workout_sport/migration.sql b/prisma/migrations/0251_backfill_strava_workout_sport/migration.sql new file mode 100644 index 000000000..0ccf55cae --- /dev/null +++ b/prisma/migrations/0251_backfill_strava_workout_sport/migration.sql @@ -0,0 +1,103 @@ +-- v1.29.x — backfill non-canonical Strava `Workout.sport_type` values. +-- +-- Root cause (the same class the 0247 WHOOP migration fixed): Strava was the +-- second workout integration that never normalised its sport to HealthLog's +-- canonical `WorkoutSportType` enum (see `workoutSportTypeEnum` in +-- src/lib/validations/workout.ts). `mapActivity()` in +-- src/lib/strava/client.ts wrote Strava's raw PascalCase `sport_type` (or +-- the legacy `type`, e.g. "Run", "Ride", "VirtualRide", "TrailRun") — or the +-- literal fallback "workout" when neither field was present — straight into +-- `Workout.sport_type`. Existing Strava rows therefore carry values the +-- `/insights/workouts` list can't map to an icon/label, AND the read-time +-- canonical picker in src/lib/sources/pick-canonical-workout.ts treats any +-- two non-canonical ("generic") rows as sport-compatible — a same-source +-- brick session (a Strava "Ride" ending 15:00 immediately followed by a +-- Strava "Run" starting 15:03) could cluster into one and silently drop a +-- leg. `mapStravaSportType()` (src/lib/strava/sport-map.ts) fixes the write +-- path going forward; this migration re-derives every EXISTING Strava +-- `Workout` row from the same table so historical data and the live mapper +-- never drift apart. +-- +-- The WHEN arms below were generated FROM `STRAVA_SPORT_TABLE` in +-- src/lib/strava/sport-map.ts (one JS-side script run against that exact +-- table, not hand-transcribed). The match expression lower-cases the stored +-- column and strips every character outside `[a-zA-Z0-9]`, mirroring +-- `normaliseStravaSportKey()` in sport-map.ts exactly, so "TrailRun", +-- "trailrun", and any stray-separator variant all resolve to the same key — +-- and so the pre-fix literal fallback "workout" resolves to the same row as +-- Strava's own "Workout" catch-all. +-- +-- Anything neither arm recognises falls to `ELSE 'other'` — same fallback +-- `mapStravaSportType()` uses, never leaves a row on a raw Strava label. +-- +-- Idempotent: the WHERE clause excludes rows whose `sport_type` is ALREADY +-- one of the 20 canonical values, so a re-run touches zero rows once the +-- first run has completed. Safe to re-run after a partial failure. +UPDATE "workouts" +SET "sport_type" = CASE + lower(regexp_replace(trim("sport_type"), '[^a-zA-Z0-9]+', '', 'g')) + WHEN 'alpineski' THEN 'other' + WHEN 'backcountryski' THEN 'other' + WHEN 'badminton' THEN 'tennis' + WHEN 'basketball' THEN 'basketball' + WHEN 'canoeing' THEN 'rowing' + WHEN 'cricket' THEN 'other' + WHEN 'crossfit' THEN 'crossTraining' + WHEN 'dance' THEN 'dance' + WHEN 'ebikeride' THEN 'cycling' + WHEN 'elliptical' THEN 'elliptical' + WHEN 'emountainbikeride' THEN 'cycling' + WHEN 'golf' THEN 'golf' + WHEN 'gravelride' THEN 'cycling' + WHEN 'handcycle' THEN 'cycling' + WHEN 'highintensityintervaltraining' THEN 'hiit' + WHEN 'hike' THEN 'hiking' + WHEN 'iceskate' THEN 'other' + WHEN 'inlineskate' THEN 'other' + WHEN 'kayaking' THEN 'rowing' + WHEN 'kitesurf' THEN 'other' + WHEN 'mountainbikeride' THEN 'cycling' + WHEN 'nordicski' THEN 'other' + WHEN 'padel' THEN 'tennis' + WHEN 'physicaltherapy' THEN 'mindAndBody' + WHEN 'pickleball' THEN 'tennis' + WHEN 'pilates' THEN 'mindAndBody' + WHEN 'racquetball' THEN 'tennis' + WHEN 'ride' THEN 'cycling' + WHEN 'rockclimbing' THEN 'crossTraining' + WHEN 'rollerski' THEN 'other' + WHEN 'rowing' THEN 'rowing' + WHEN 'run' THEN 'running' + WHEN 'sail' THEN 'other' + WHEN 'skateboard' THEN 'other' + WHEN 'snowboard' THEN 'other' + WHEN 'snowshoe' THEN 'hiking' + WHEN 'soccer' THEN 'soccer' + WHEN 'squash' THEN 'tennis' + WHEN 'stairstepper' THEN 'stairClimber' + WHEN 'standuppaddling' THEN 'rowing' + WHEN 'surfing' THEN 'other' + WHEN 'swim' THEN 'swimming' + WHEN 'tabletennis' THEN 'tennis' + WHEN 'tennis' THEN 'tennis' + WHEN 'trailrun' THEN 'running' + WHEN 'velomobile' THEN 'cycling' + WHEN 'virtualride' THEN 'cycling' + WHEN 'virtualrow' THEN 'rowing' + WHEN 'virtualrun' THEN 'running' + WHEN 'volleyball' THEN 'other' + WHEN 'walk' THEN 'walking' + WHEN 'weighttraining' THEN 'strength' + WHEN 'wheelchair' THEN 'other' + WHEN 'windsurf' THEN 'other' + WHEN 'workout' THEN 'other' + WHEN 'yoga' THEN 'yoga' + ELSE 'other' +END +WHERE "source" = 'STRAVA' + AND "sport_type" NOT IN ( + 'walking', 'running', 'cycling', 'hiking', 'swimming', 'rowing', + 'elliptical', 'stairClimber', 'yoga', 'mindAndBody', 'strength', 'hiit', + 'dance', 'golf', 'tennis', 'basketball', 'soccer', 'crossTraining', + 'mixedCardio', 'other' + ); diff --git a/src/lib/sources/__tests__/pick-canonical-workout.test.ts b/src/lib/sources/__tests__/pick-canonical-workout.test.ts index e42ed8ef6..64e6309e9 100644 --- a/src/lib/sources/__tests__/pick-canonical-workout.test.ts +++ b/src/lib/sources/__tests__/pick-canonical-workout.test.ts @@ -337,3 +337,42 @@ describe("pickCanonicalWorkout — field-merge (v1.29.x, WHOOP HR-loss fix)", () expect(winner.totalEnergyKcal).toBe(512); // backfilled from WHOOP }); }); + +describe("pickCanonicalWorkout — Strava brick-workout collapse (data-integrity audit H4)", () => { + // Before the Strava sport-map fix, `mapActivity()` wrote the raw Strava + // label ("Ride", "Run") straight into `sportType` — neither string is a + // member of `workoutSportTypeEnum`, so `isSpecificSportType()` classified + // BOTH rows as generic. Two generic rows are always sport-compatible + // (`genericPairing` in pick-canonical-workout.ts), so a same-source brick + // session — a Strava ride ending ~15:00 immediately followed by a Strava + // run starting 15:03 — clustered into ONE cluster and the ladder pick + // kept only one leg, silently dropping the other from every list, tile, + // and total. + it("pre-fix: two raw (non-canonical) Strava labels close in time COLLAPSE into one cluster", () => { + const result = pickCanonicalWorkout([ + row("strava-ride", "STRAVA", "2026-06-01T15:00:00.000Z", "Ride"), + row("strava-run", "STRAVA", "2026-06-01T15:03:00.000Z", "Run"), + ]); + // This is the confirmed bug, pinned so a future change can't silently + // "fix" clustering itself and mask a regression in the sport-map layer. + expect(result.clusters).toHaveLength(1); + expect(result.canonical).toHaveLength(1); + }); + + it("post-fix: a canonical Strava Ride + Run pair survives as two distinct rows", () => { + // Same timing as the pre-fix fixture above, but with the sportType + // values `mapActivity()` now writes (via `mapStravaSportType()`): + // "Ride" → "cycling", "Run" → "running". Both are SPECIFIC canonical + // buckets and differ from each other, so `isSpecificSportType()` no + // longer treats them as sport-compatible and the brick session + // survives as two workouts. + const result = pickCanonicalWorkout([ + row("strava-ride", "STRAVA", "2026-06-01T15:00:00.000Z", "cycling"), + row("strava-run", "STRAVA", "2026-06-01T15:03:00.000Z", "running"), + ]); + expect(result.clusters).toHaveLength(2); + expect(result.canonical).toHaveLength(2); + const sports = result.canonical.map((w) => w.sportType).sort(); + expect(sports).toEqual(["cycling", "running"]); + }); +}); diff --git a/src/lib/strava/__tests__/client.test.ts b/src/lib/strava/__tests__/client.test.ts index d94eeeab9..a6a470720 100644 --- a/src/lib/strava/__tests__/client.test.ts +++ b/src/lib/strava/__tests__/client.test.ts @@ -29,7 +29,8 @@ describe("mapActivity", () => { }); expect(row).not.toBeNull(); expect(row!.externalId).toBe("123456"); - expect(row!.sportType).toBe("TrailRun"); + // sport_type "TrailRun" maps to the canonical "running" bucket. + expect(row!.sportType).toBe("running"); expect(row!.startedAt.toISOString()).toBe("2026-07-01T06:30:00.000Z"); // endedAt = start + elapsed_time (clock window), not moving_time. expect(row!.endedAt.toISOString()).toBe("2026-07-01T06:56:40.000Z"); @@ -66,15 +67,38 @@ describe("mapActivity", () => { expect((row as unknown as Record).name).toBeUndefined(); }); - it("falls back to `type` then a generic label for the sport", () => { + it("falls back to `type` then a generic label for the sport, always canonical", () => { const noSport: StravaSummaryActivity = { ...baseSummary }; delete (noSport as unknown as Record).sport_type; - expect(mapActivity(noSport)!.sportType).toBe("Run"); + // Falls back to `type: "Run"` → canonical "running". + expect(mapActivity(noSport)!.sportType).toBe("running"); + const meta = mapActivity(noSport)!.metadata as Record; + expect(meta.stravaType).toBe("Run"); const noneAtAll: StravaSummaryActivity = { ...baseSummary }; delete (noneAtAll as unknown as Record).sport_type; delete (noneAtAll as unknown as Record).type; - expect(mapActivity(noneAtAll)!.sportType).toBe("workout"); + // No sport info at all → generic "Workout" fallback → canonical "other". + expect(mapActivity(noneAtAll)!.sportType).toBe("other"); + const noneMeta = mapActivity(noneAtAll)!.metadata as Record< + string, + unknown + >; + expect(noneMeta.stravaType).toBe("Workout"); + }); + + it("keeps the raw sport_type label in metadata even though sportType is canonical", () => { + const row = mapActivity(baseSummary); // sport_type: "TrailRun" + const meta = row!.metadata as Record; + expect(row!.sportType).toBe("running"); + expect(meta.stravaType).toBe("TrailRun"); + }); + + it("never writes a raw Strava sport label into sportType, even for an unrecognised value", () => { + const row = mapActivity({ ...baseSummary, sport_type: "SomeNewSport" }); + expect(row!.sportType).toBe("other"); + const meta = row!.metadata as Record; + expect(meta.stravaType).toBe("SomeNewSport"); }); it("returns null for an activity with no id or no start instant", () => { diff --git a/src/lib/strava/client.ts b/src/lib/strava/client.ts index 34b236be2..09b95fc66 100644 --- a/src/lib/strava/client.ts +++ b/src/lib/strava/client.ts @@ -25,6 +25,8 @@ import { getEvent } from "@/lib/logging/context"; import { safeFetch } from "@/lib/safe-fetch"; import type { Prisma } from "@/generated/prisma/client"; import { StravaApiError, classifyStravaResponse } from "./response-classifier"; +import { mapStravaSportType } from "./sport-map"; +import type { WorkoutSportType } from "@/lib/validations/workout"; const STRAVA_API_BASE = "https://www.strava.com/api/v3"; const STRAVA_OAUTH_AUTH_URL = "https://www.strava.com/oauth/authorize"; @@ -294,7 +296,7 @@ export function fetchActivityById( * `externalId` and upserts on `(userId, source, externalId)`. */ export interface StravaWorkoutRow { externalId: string; - sportType: string; + sportType: WorkoutSportType; startedAt: Date; endedAt: Date; durationSec: number; @@ -318,9 +320,13 @@ function nonNegFloat(n: number | null | undefined): number | null { /** * Map one Strava activity into a `Workout` row. `sport_type` is preferred over - * the legacy `type`; free-text at the DB layer. Duration uses `moving_time` - * (active duration) with `elapsed_time` kept in `metadata`. HR / calories come - * from the summary when present, else from the optional `DetailedActivity`. + * the legacy `type`, then mapped through `mapStravaSportType()` to a canonical + * `WorkoutSportType` — the raw Strava label is never written to `sportType` + * directly (see `src/lib/strava/sport-map.ts` for why). The raw label is kept + * verbatim in `metadata.stravaType` for provenance/debugging. Duration uses + * `moving_time` (active duration) with `elapsed_time` kept in `metadata`. HR / + * calories come from the summary when present, else from the optional + * `DetailedActivity`. * * The activity `name` / `description` are the user's own free-text DATA — they * are stored in `metadata` only and NEVER interpolated into any prompt as an @@ -355,15 +361,18 @@ export function mapActivity( ); const calories = nonNegFloat(detail?.calories); - const sportType = - summary.sport_type ?? summary.type ?? detail?.sport_type ?? "workout"; + const rawSportType = + summary.sport_type ?? summary.type ?? detail?.sport_type ?? "Workout"; + const sportType = mapStravaSportType(rawSportType); const metadata: Prisma.InputJsonValue = { // Activity name/description are user free-text → DATA, never instructions. ...(summary.name ? { stravaName: summary.name } : {}), ...(detail?.description ? { stravaDescription: detail.description } : {}), ...(elapsedSec != null ? { elapsedTimeSec: elapsedSec } : {}), - ...(summary.type ? { stravaType: summary.type } : {}), + // Raw pre-mapping label, kept for provenance/debugging — never used as + // the canonical `sportType` itself (see `mapStravaSportType()`). + ...(rawSportType ? { stravaType: rawSportType } : {}), ...(typeof summary.average_watts === "number" ? { averageWatts: summary.average_watts } : {}), diff --git a/src/lib/strava/sport-map.ts b/src/lib/strava/sport-map.ts new file mode 100644 index 000000000..c3702e9f8 --- /dev/null +++ b/src/lib/strava/sport-map.ts @@ -0,0 +1,168 @@ +/** + * Strava `sport_type` (and legacy `type`) → HealthLog canonical + * `WorkoutSportType`. + * + * Strava was the second workout integration — after WHOOP — that shipped + * WITHOUT this mapping. `mapActivity()` in `src/lib/strava/client.ts` used + * to write `summary.sport_type ?? summary.type ?? detail?.sport_type ?? + * "workout"` straight into `Workout.sportType`, so a Strava "Run" or + * "VirtualRide" never matched a canonical bucket — `/insights/workouts` + * rendered every Strava row generic, and the read-time picker in + * `src/lib/sources/pick-canonical-workout.ts` treats any two GENERIC rows + * as sport-compatible, so a same-source brick session (a Strava "Ride" + * ending 15:00 immediately followed by a Strava "Run" starting 15:03) + * clustered into one and silently dropped a leg. Fitbit + * (`mapFitbitSportType()` in `src/lib/fitbit/client.ts`), Google Health + * (`mapGoogleHealthSportType()` in `src/lib/google-health/mappers.ts`), and + * WHOOP (`mapWhoopSportType()` in `src/lib/whoop/sport-map.ts`) all + * normalise at ingest time; this module brings Strava to parity. + * + * `STRAVA_SPORT_TABLE` is the single source of truth — `STRAVA_SPORT_MAP` + * derives from it, and the one-shot data migration + * `prisma/migrations/0251_backfill_strava_workout_sport` was generated FROM + * this same table so the historical backfill and the live mapper never + * drift apart. + * + * Source: Strava's public `SportType` model — the documented superset of + * the deprecated `ActivityType` (`type`) enum, i.e. every legacy `type` + * value already appears in this table under the same spelling. Captured + * 2026-07-17 from the actively-maintained `stravalib` Python client's + * generated model (`strava_model.py`), which mirrors Strava's published + * Swagger spec 1:1 — 54 documented values. + * + * Bucket choices for sports with no clean 1:1 canonical match are a + * judgment call, applied consistently with the WHOOP table: + * - paddle sports (Canoeing, Kayaking, StandUpPaddling) → `"rowing"` + * (closest upper-body cardio bucket, same call WHOOP made for + * Kayaking/Paddleboarding). + * - racket / paddle-court sports (Badminton, Padel, Pickleball, + * Racquetball, Squash, TableTennis, Tennis) → `"tennis"`. + * - functional / mixed-modal training (Crossfit, RockClimbing) → + * `"crossTraining"`. + * - recovery / mobility work (Pilates, PhysicalTherapy) → `"mindAndBody"`. + * - human-powered wheeled/pedaled locomotion with no dedicated bucket + * (EBikeRide, EMountainBikeRide, GravelRide, Handcycle, + * MountainBikeRide, Velomobile, VirtualRide) → `"cycling"`. + * - winter-hiking (Snowshoe) → `"hiking"`; snow-sliding sports with no + * bucket (AlpineSki, BackcountrySki, NordicSki, RollerSki, Snowboard) + * → `"other"`, same as WHOOP's Skiing/Snowboarding. + * - `VirtualRow` / `VirtualRun` → the same bucket as their non-virtual + * counterpart (`"rowing"` / `"running"`); `VirtualRide` → `"cycling"`. + * - team / niche sports with no matching bucket (Cricket, IceSkate, + * InlineSkate, Kitesurf, Sail, Skateboard, Surfing, Volleyball, + * Wheelchair, Windsurf) → `"other"`, same as an unmapped Fitbit or + * Google Health activity — the row still persists, just outside a + * canonical sport bucket. + * - the generic `"Workout"` label (Strava's own catch-all, and this + * module's pre-fix fallback) → `"other"`. + */ +import type { WorkoutSportType } from "@/lib/validations/workout"; + +interface StravaSportTableRow { + /** Strava's documented `sport_type` value (PascalCase, no separators). */ + name: string; + canonical: WorkoutSportType; +} + +/** + * Strava `sport_type` display name → canonical bucket. Alphabetical, + * matching the `stravalib` model's declaration order. Keep this list — + * and ONLY this list — in sync when Strava adds a sport; both + * `STRAVA_SPORT_MAP` below and the 0251 migration's generator derive from + * it. + */ +export const STRAVA_SPORT_TABLE: readonly StravaSportTableRow[] = [ + { name: "AlpineSki", canonical: "other" }, + { name: "BackcountrySki", canonical: "other" }, + { name: "Badminton", canonical: "tennis" }, + { name: "Basketball", canonical: "basketball" }, + { name: "Canoeing", canonical: "rowing" }, + { name: "Cricket", canonical: "other" }, + { name: "Crossfit", canonical: "crossTraining" }, + { name: "Dance", canonical: "dance" }, + { name: "EBikeRide", canonical: "cycling" }, + { name: "Elliptical", canonical: "elliptical" }, + { name: "EMountainBikeRide", canonical: "cycling" }, + { name: "Golf", canonical: "golf" }, + { name: "GravelRide", canonical: "cycling" }, + { name: "Handcycle", canonical: "cycling" }, + { name: "HighIntensityIntervalTraining", canonical: "hiit" }, + { name: "Hike", canonical: "hiking" }, + { name: "IceSkate", canonical: "other" }, + { name: "InlineSkate", canonical: "other" }, + { name: "Kayaking", canonical: "rowing" }, + { name: "Kitesurf", canonical: "other" }, + { name: "MountainBikeRide", canonical: "cycling" }, + { name: "NordicSki", canonical: "other" }, + { name: "Padel", canonical: "tennis" }, + { name: "PhysicalTherapy", canonical: "mindAndBody" }, + { name: "Pickleball", canonical: "tennis" }, + { name: "Pilates", canonical: "mindAndBody" }, + { name: "Racquetball", canonical: "tennis" }, + { name: "Ride", canonical: "cycling" }, + { name: "RockClimbing", canonical: "crossTraining" }, + { name: "RollerSki", canonical: "other" }, + { name: "Rowing", canonical: "rowing" }, + { name: "Run", canonical: "running" }, + { name: "Sail", canonical: "other" }, + { name: "Skateboard", canonical: "other" }, + { name: "Snowboard", canonical: "other" }, + { name: "Snowshoe", canonical: "hiking" }, + { name: "Soccer", canonical: "soccer" }, + { name: "Squash", canonical: "tennis" }, + { name: "StairStepper", canonical: "stairClimber" }, + { name: "StandUpPaddling", canonical: "rowing" }, + { name: "Surfing", canonical: "other" }, + { name: "Swim", canonical: "swimming" }, + { name: "TableTennis", canonical: "tennis" }, + { name: "Tennis", canonical: "tennis" }, + { name: "TrailRun", canonical: "running" }, + { name: "Velomobile", canonical: "cycling" }, + { name: "VirtualRide", canonical: "cycling" }, + { name: "VirtualRow", canonical: "rowing" }, + { name: "VirtualRun", canonical: "running" }, + { name: "Volleyball", canonical: "other" }, + { name: "Walk", canonical: "walking" }, + { name: "WeightTraining", canonical: "strength" }, + { name: "Wheelchair", canonical: "other" }, + { name: "Windsurf", canonical: "other" }, + { name: "Workout", canonical: "other" }, + { name: "Yoga", canonical: "yoga" }, +] as const; + +/** + * Lowercase and strip every character outside `[a-z0-9]`. Strava's + * `sport_type` values are PascalCase with no internal separators + * ("VirtualRide"), so this is a plain case-fold in the common case; the + * stripping step keeps the lookup resilient to a stray space/underscore/ + * hyphen variant ("Virtual Ride", "virtual_ride") resolving to the same + * key. Applied to BOTH the documented display name (at table-build time) + * and the incoming raw value (at lookup time). + */ +export function normaliseStravaSportKey(raw: string): string { + return raw + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, ""); +} + +export const STRAVA_SPORT_MAP: ReadonlyMap = new Map( + STRAVA_SPORT_TABLE.map((row) => [ + normaliseStravaSportKey(row.name), + row.canonical, + ]), +); + +/** + * Resolve a Strava activity's sport to a canonical `WorkoutSportType`. + * Accepts whichever raw label the caller has already resolved from the + * `sport_type ?? type` fallback chain. Defaults to `"other"` for anything + * absent, blank, or unrecognised (a Strava-added sport this table hasn't + * caught up with yet); NEVER returns the raw Strava label. + */ +export function mapStravaSportType( + raw: string | null | undefined, +): WorkoutSportType { + if (typeof raw !== "string" || raw.trim() === "") return "other"; + return STRAVA_SPORT_MAP.get(normaliseStravaSportKey(raw)) ?? "other"; +} diff --git a/src/lib/workouts/__tests__/sport-map-parity.test.ts b/src/lib/workouts/__tests__/sport-map-parity.test.ts index da5456906..08fc1e276 100644 --- a/src/lib/workouts/__tests__/sport-map-parity.test.ts +++ b/src/lib/workouts/__tests__/sport-map-parity.test.ts @@ -4,20 +4,21 @@ * Every workout integration normalises its raw sport label to HealthLog's * canonical `WorkoutSportType` at ingest time: Fitbit * (`mapFitbitSportType()`), Google Health (`mapGoogleHealthSportType()`), - * and — as of the WHOOP-cycling fix — WHOOP (`mapWhoopSportType()`). This - * table-driven suite checks all three mappers together so a future - * integration or a re-tuned mapping can't silently drift out of parity: - * every mapper must (a) only ever return a value from - * `workoutSportTypeEnum`, (b) default unmapped/garbage input to `"other"`, - * never throw or return the raw input, and (c) agree on the handful of - * sports every wearable reports (walking, running, cycling, swimming, - * strength, yoga, hiit). + * WHOOP (`mapWhoopSportType()`), and — as of the Strava-brick-collapse fix — + * Strava (`mapStravaSportType()`). This table-driven suite checks all four + * mappers together so a future integration or a re-tuned mapping can't + * silently drift out of parity: every mapper must (a) only ever return a + * value from `workoutSportTypeEnum`, (b) default unmapped/garbage input to + * `"other"`, never throw or return the raw input, and (c) agree on the + * handful of sports every wearable reports (walking, running, cycling, + * swimming, strength, yoga, hiit). */ import { describe, expect, it } from "vitest"; import { mapFitbitSportType } from "@/lib/fitbit/client"; import { mapGoogleHealthSportType } from "@/lib/google-health/mappers"; import { mapWhoopSportType } from "@/lib/whoop/sport-map"; +import { mapStravaSportType } from "@/lib/strava/sport-map"; import { workoutSportTypeEnum } from "@/lib/validations/workout"; type Mapper = (raw: string) => string; @@ -26,8 +27,9 @@ const MAPPERS: Record = { fitbit: (raw) => mapFitbitSportType(raw), googleHealth: (raw) => mapGoogleHealthSportType(raw), // WHOOP's mapper takes (sportId, sportName) — exercise it via sport_name - // to line up with the other two integrations' string-in shape. + // to line up with the other integrations' string-in shape. whoop: (raw) => mapWhoopSportType(undefined, raw), + strava: (raw) => mapStravaSportType(raw), }; describe("sport-map parity — every mapper only emits canonical values", () => { @@ -55,23 +57,48 @@ describe("sport-map parity — every mapper only emits canonical values", () => */ const COMMON_SPORTS: Array<{ expected: string; - labels: { fitbit: string; googleHealth: string; whoop: string }; + labels: { + fitbit: string; + googleHealth: string; + whoop: string; + strava: string; + }; }> = [ { expected: "walking", - labels: { fitbit: "walking", googleHealth: "WALKING", whoop: "Walking" }, + labels: { + fitbit: "walking", + googleHealth: "WALKING", + whoop: "Walking", + strava: "Walk", + }, }, { expected: "running", - labels: { fitbit: "running", googleHealth: "RUNNING", whoop: "Running" }, + labels: { + fitbit: "running", + googleHealth: "RUNNING", + whoop: "Running", + strava: "Run", + }, }, { expected: "cycling", - labels: { fitbit: "biking", googleHealth: "BIKING", whoop: "Cycling" }, + labels: { + fitbit: "biking", + googleHealth: "BIKING", + whoop: "Cycling", + strava: "Ride", + }, }, { expected: "swimming", - labels: { fitbit: "swimming", googleHealth: "SWIMMING", whoop: "Swimming" }, + labels: { + fitbit: "swimming", + googleHealth: "SWIMMING", + whoop: "Swimming", + strava: "Swim", + }, }, { expected: "strength", @@ -79,37 +106,64 @@ const COMMON_SPORTS: Array<{ fitbit: "weights", googleHealth: "STRENGTH_TRAINING", whoop: "Weightlifting", + strava: "WeightTraining", }, }, { expected: "yoga", - labels: { fitbit: "yoga", googleHealth: "YOGA", whoop: "Yoga" }, + labels: { + fitbit: "yoga", + googleHealth: "YOGA", + whoop: "Yoga", + strava: "Yoga", + }, }, { expected: "hiit", - labels: { fitbit: "hiit", googleHealth: "HIIT", whoop: "HIIT" }, + labels: { + fitbit: "hiit", + googleHealth: "HIIT", + whoop: "HIIT", + strava: "HighIntensityIntervalTraining", + }, }, { expected: "golf", - labels: { fitbit: "golf", googleHealth: "GOLF", whoop: "Golf" }, + labels: { + fitbit: "golf", + googleHealth: "GOLF", + whoop: "Golf", + strava: "Golf", + }, }, { expected: "tennis", - labels: { fitbit: "tennis", googleHealth: "TENNIS", whoop: "Tennis" }, + labels: { + fitbit: "tennis", + googleHealth: "TENNIS", + whoop: "Tennis", + strava: "Tennis", + }, }, { expected: "rowing", - labels: { fitbit: "rowing", googleHealth: "ROWING", whoop: "Rowing" }, + labels: { + fitbit: "rowing", + googleHealth: "ROWING", + whoop: "Rowing", + strava: "Rowing", + }, }, ]; -describe("sport-map parity — Fitbit / Google Health / WHOOP agree on common sports", () => { +describe("sport-map parity — Fitbit / Google Health / WHOOP / Strava agree on common sports", () => { it.each(COMMON_SPORTS)( - "$expected: all three integrations map to the same canonical bucket", + "$expected: all four integrations map to the same canonical bucket", ({ expected, labels }) => { expect(mapFitbitSportType(labels.fitbit)).toBe(expected); expect(mapGoogleHealthSportType(labels.googleHealth)).toBe(expected); expect(mapWhoopSportType(undefined, labels.whoop)).toBe(expected); + expect(mapStravaSportType(labels.strava)).toBe(expected); }, ); }); @@ -121,9 +175,20 @@ describe("sport-map parity — the confirmed bug, guarded across integrations", fitbit: "biking", googleHealth: "BIKING", whoop: "Cycling", + strava: "Ride", }; const result = mapper(cyclingLabels[name]!); expect(result).toBe("cycling"); } }); + + it("a Strava workout NEVER lands on the raw PascalCase label (the brick-collapse root cause)", () => { + for (const raw of ["Run", "Ride", "VirtualRide", "TrailRun", "Workout"]) { + const result = mapStravaSportType(raw); + expect(result).not.toBe(raw); + expect( + (workoutSportTypeEnum.options as readonly string[]).includes(result), + ).toBe(true); + } + }); }); From fb73c971252e1db03a39842b063a834dcd575ef0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 17 Jul 2026 23:08:19 +0200 Subject: [PATCH 07/33] fix(onboarding): stop the Withings connect dead-end and disclose BYO setup The onboarding "Connect Withings" tap opened /api/withings/connect in a new tab, which returned a raw JSON 400 for every brand-new account (Withings credentials are per-user BYO with no env fallback, and the wizard never collects them). WHOOP, Fitbit, Polar, Oura, and Strava shared the identical raw-JSON-on-no-creds pattern on their own /connect routes, while every other failure branch already redirected with a toast reason. All six now redirect to /settings/integrations?=error&reason=nocreds instead, and the wizard's Withings card checks the integrations-status envelope first: unless credentials are already configured, the CTA reads "Set up in Settings" and links straight to the credentials form rather than launching a doomed OAuth handshake. Also surfaces the BYO-developer-app requirement, which was invisible until a user was deep inside a Settings card. The source-picker step now says up front that device sources need a free developer app on the user's own account; each of the six provider cards gets a compact 3-step guide with the exact callback URL to register, shown until the user has saved their own credentials. --- messages/de.json | 16 ++- messages/en.json | 16 ++- messages/es.json | 16 ++- messages/fr.json | 16 ++- messages/it.json | 16 ++- messages/pl.json | 16 ++- .../fitbit/connect/__tests__/route.test.ts | 115 ++++++++++++++++++ src/app/api/fitbit/connect/route.ts | 10 +- .../api/oura/connect/__tests__/route.test.ts | 69 +++++++++++ src/app/api/oura/connect/route.ts | 14 ++- .../api/polar/connect/__tests__/route.test.ts | 12 +- src/app/api/polar/connect/route.ts | 14 ++- .../strava/connect/__tests__/route.test.ts | 69 +++++++++++ src/app/api/strava/connect/route.ts | 15 ++- .../api/whoop/connect/__tests__/route.test.ts | 12 ++ src/app/api/whoop/connect/route.ts | 11 +- .../withings/connect/__tests__/route.test.ts | 17 +++ src/app/api/withings/connect/route.ts | 13 +- .../__tests__/source-card-grid.test.tsx | 91 ++++++++++++++ .../onboarding/source-card-grid.tsx | 85 ++++++++++--- .../__tests__/oauth-provider-card.test.tsx | 30 +++++ .../__tests__/whoop-card.test.tsx | 41 +++++++ .../settings/integrations/fitbit-card.tsx | 11 +- .../integrations/oauth-provider-card.tsx | 7 ++ .../integrations/setup-guide-link.tsx | 63 ++++++++++ .../settings/integrations/whoop-card.tsx | 11 +- .../settings/integrations/withings-card.tsx | 11 +- 27 files changed, 744 insertions(+), 73 deletions(-) create mode 100644 src/app/api/fitbit/connect/__tests__/route.test.ts create mode 100644 src/app/api/oura/connect/__tests__/route.test.ts create mode 100644 src/app/api/strava/connect/__tests__/route.test.ts create mode 100644 src/components/onboarding/__tests__/source-card-grid.test.tsx create mode 100644 src/components/settings/integrations/__tests__/whoop-card.test.tsx diff --git a/messages/de.json b/messages/de.json index f0ac137eb..c9d7aef88 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4016,7 +4016,7 @@ }, "source": { "title": "Quelle verbinden", - "body": "Werte automatisch übernehmen oder weiter manuell eintragen. Hier wird nichts für dich eingerichtet – jede Karte zeigt nur, wo du sie verbindest.", + "body": "Werte automatisch übernehmen oder weiter manuell eintragen. Hier wird nichts für dich eingerichtet – jede Karte zeigt nur, wo du sie verbindest. Geräte-Quellen brauchen eine kostenlose Entwickler-App auf deinem eigenen Konto (etwa 5 Minuten); die Anleitung auf jeder Karte führt dich durch.", "recommended": "Empfohlen", "manual": { "title": "Manuelle Eingabe", @@ -4024,12 +4024,14 @@ }, "withings": { "title": "Withings", - "body": "Waagen, Blutdruckmesser und Tracker von Withings synchronisieren automatisch.", - "cta": "Withings verbinden" + "body": "Waagen, Blutdruckmesser und Tracker von Withings synchronisieren automatisch, sobald verbunden.", + "cta": "Withings verbinden", + "setupCta": "In den Einstellungen einrichten", + "setupHint": "Braucht zuerst deine eigene, kostenlose Withings-Entwickler-App – etwa 5 Minuten, einmalig." }, "more": { "title": "Weitere Quellen", - "body": "Verbinde eine davon in den Einstellungen, wann immer du bereit bist.", + "body": "Verbinde eine davon in den Einstellungen, wann immer du bereit bist – die meisten brauchen zuerst eine kostenlose Entwickler-App auf deinem eigenen Konto.", "appleHealth": "Apple Health", "whoop": "WHOOP", "oura": "Oura", @@ -5389,6 +5391,12 @@ "ouraDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der", "nightscoutDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der", "integrationCredentialsHint": "Wo du diese Zugangsdaten findest und wie du deine eigene App registrierst, steht in der Einrichtungsanleitung oben.", + "integrationRedirectGuide": { + "intro": "Die Verbindung läuft über deine eigene {provider}-Entwickler-App – etwa 5 Minuten Einrichtung:", + "step1": "Erstelle eine App im {provider}-Entwicklerportal.", + "step2": "Setze deren Weiterleitungs-/Callback-URL auf:", + "step3": "Trage die dort erhaltene Client-ID und das Client-Secret oben ein." + }, "security": { "sessionsTitle": "Aktive Sitzungen", "sessionsDescription": "Geräte und Browser, die aktuell bei deinem Konto angemeldet sind.", diff --git a/messages/en.json b/messages/en.json index 506d46265..c0ca0352a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4016,7 +4016,7 @@ }, "source": { "title": "Connect a source", - "body": "Bring in measurements automatically, or stay on manual entry. Nothing here is configured for you — each card just shows where to connect it.", + "body": "Bring in measurements automatically, or stay on manual entry. Nothing here is configured for you — each card just shows where to connect it. Device sources need a free developer app registered on your own account (about 5 minutes); the setup guide on each card walks through it.", "recommended": "Recommended", "manual": { "title": "Manual entry", @@ -4024,12 +4024,14 @@ }, "withings": { "title": "Withings", - "body": "Scales, blood-pressure cuffs and trackers from Withings sync automatically.", - "cta": "Connect Withings" + "body": "Scales, blood-pressure cuffs and trackers from Withings sync automatically once connected.", + "cta": "Connect Withings", + "setupCta": "Set up in Settings", + "setupHint": "Needs your own free Withings developer app first — about 5 minutes, one time." }, "more": { "title": "More sources", - "body": "Connect any of these in Settings whenever you're ready.", + "body": "Connect any of these in Settings whenever you're ready — most need a free developer app registered on your own account first.", "appleHealth": "Apple Health", "whoop": "WHOOP", "oura": "Oura", @@ -5389,6 +5391,12 @@ "ouraDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the", "nightscoutDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the", "integrationCredentialsHint": "Where to find these credentials and how to register your own app is covered in the setup guide above.", + "integrationRedirectGuide": { + "intro": "This connects through your own {provider} developer app — about 5 minutes to set up:", + "step1": "Create an app in the {provider} developer portal.", + "step2": "Set its redirect / callback URL to:", + "step3": "Paste the client ID and client secret it gives you above." + }, "security": { "sessionsTitle": "Active sessions", "sessionsDescription": "Devices and browsers currently signed in to your account.", diff --git a/messages/es.json b/messages/es.json index edc3a2ed5..c30794199 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4016,7 +4016,7 @@ }, "source": { "title": "Conectar una fuente", - "body": "Importa las mediciones automáticamente o sigue con la entrada manual. Aquí no se configura nada por ti: cada tarjeta solo muestra dónde conectarla.", + "body": "Importa las mediciones automáticamente o sigue con la entrada manual. Aquí no se configura nada por ti: cada tarjeta solo muestra dónde conectarla. Las fuentes de dispositivo necesitan una app de desarrollador gratuita en tu propia cuenta (unos 5 minutos); la guía de cada tarjeta te lo explica.", "recommended": "Recomendado", "manual": { "title": "Entrada manual", @@ -4024,12 +4024,14 @@ }, "withings": { "title": "Withings", - "body": "Las básculas, los tensiómetros y los trackers de Withings se sincronizan automáticamente.", - "cta": "Conectar Withings" + "body": "Las básculas, los tensiómetros y los trackers de Withings se sincronizan automáticamente una vez conectados.", + "cta": "Conectar Withings", + "setupCta": "Configurar en Ajustes", + "setupHint": "Necesita primero tu propia app de desarrollador de Withings, gratuita — unos 5 minutos, una sola vez." }, "more": { "title": "Más fuentes", - "body": "Conecta cualquiera de ellas en Ajustes cuando quieras.", + "body": "Conecta cualquiera de ellas en Ajustes cuando quieras — la mayoría necesita antes una app de desarrollador gratuita en tu propia cuenta.", "appleHealth": "Apple Health", "whoop": "WHOOP", "oura": "Oura", @@ -5389,6 +5391,12 @@ "ouraDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la", "nightscoutDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la", "integrationCredentialsHint": "Dónde encontrar estas credenciales y cómo registrar tu propia aplicación se explica en la guía de configuración de arriba.", + "integrationRedirectGuide": { + "intro": "Esto se conecta mediante tu propia app de desarrollador de {provider} — unos 5 minutos de configuración:", + "step1": "Crea una app en el portal de desarrolladores de {provider}.", + "step2": "Configura su URL de redirección/callback en:", + "step3": "Pega arriba el ID de cliente y el secreto de cliente que te da." + }, "security": { "sessionsTitle": "Sesiones activas", "sessionsDescription": "Dispositivos y navegadores con sesión iniciada en tu cuenta.", diff --git a/messages/fr.json b/messages/fr.json index 49c4f7b9e..33b44e7a3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4016,7 +4016,7 @@ }, "source": { "title": "Connecter une source", - "body": "Importez les mesures automatiquement ou conservez la saisie manuelle. Rien n'est configuré à votre place : chaque carte indique simplement où la connecter.", + "body": "Importez les mesures automatiquement ou conservez la saisie manuelle. Rien n'est configuré à votre place : chaque carte indique simplement où la connecter. Les sources d'appareil nécessitent une application développeur gratuite sur votre propre compte (environ 5 minutes) ; le guide de chaque carte vous accompagne.", "recommended": "Recommandé", "manual": { "title": "Saisie manuelle", @@ -4024,12 +4024,14 @@ }, "withings": { "title": "Withings", - "body": "Balances, tensiomètres et trackers Withings se synchronisent automatiquement.", - "cta": "Connecter Withings" + "body": "Balances, tensiomètres et trackers Withings se synchronisent automatiquement une fois connectés.", + "cta": "Connecter Withings", + "setupCta": "Configurer dans les Réglages", + "setupHint": "Nécessite d'abord votre propre application développeur Withings, gratuite — environ 5 minutes, une seule fois." }, "more": { "title": "Autres sources", - "body": "Connectez l'une d'elles dans les Réglages quand vous le souhaitez.", + "body": "Connectez l'une d'elles dans les Réglages quand vous le souhaitez — la plupart nécessitent d'abord une application développeur gratuite sur votre propre compte.", "appleHealth": "Apple Health", "whoop": "WHOOP", "oura": "Oura", @@ -5389,6 +5391,12 @@ "ouraDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le", "nightscoutDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le", "integrationCredentialsHint": "Où trouver ces identifiants et comment enregistrer ta propre application est expliqué dans le guide de configuration ci-dessus.", + "integrationRedirectGuide": { + "intro": "Cette connexion passe par votre propre application développeur {provider} — environ 5 minutes de configuration :", + "step1": "Créez une application dans le portail développeur {provider}.", + "step2": "Réglez son URL de redirection/callback sur :", + "step3": "Collez ci-dessus l'ID client et le secret client fournis." + }, "security": { "sessionsTitle": "Sessions actives", "sessionsDescription": "Appareils et navigateurs actuellement connectés à votre compte.", diff --git a/messages/it.json b/messages/it.json index 5ce77c180..79b40580f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4016,7 +4016,7 @@ }, "source": { "title": "Connetti una sorgente", - "body": "Importa le misurazioni in automatico oppure resta sull'inserimento manuale. Qui non viene configurato nulla al posto tuo: ogni scheda mostra solo dove collegarla.", + "body": "Importa le misurazioni in automatico oppure resta sull'inserimento manuale. Qui non viene configurato nulla al posto tuo: ogni scheda mostra solo dove collegarla. Le fonti su dispositivo richiedono un'app sviluppatore gratuita sul tuo account (circa 5 minuti); la guida su ogni scheda ti accompagna.", "recommended": "Consigliato", "manual": { "title": "Inserimento manuale", @@ -4024,12 +4024,14 @@ }, "withings": { "title": "Withings", - "body": "Bilance, misuratori di pressione e tracker Withings si sincronizzano automaticamente.", - "cta": "Connetti Withings" + "body": "Bilance, misuratori di pressione e tracker Withings si sincronizzano automaticamente una volta collegati.", + "cta": "Connetti Withings", + "setupCta": "Configura nelle Impostazioni", + "setupHint": "Richiede prima la tua app sviluppatore Withings gratuita — circa 5 minuti, una tantum." }, "more": { "title": "Altre fonti", - "body": "Collega una di queste nelle Impostazioni quando vuoi.", + "body": "Collega una di queste nelle Impostazioni quando vuoi — la maggior parte richiede prima un'app sviluppatore gratuita sul tuo account.", "appleHealth": "Apple Health", "whoop": "WHOOP", "oura": "Oura", @@ -5389,6 +5391,12 @@ "ouraDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la", "nightscoutDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la", "integrationCredentialsHint": "Dove trovare queste credenziali e come registrare la tua app è spiegato nella guida alla configurazione qui sopra.", + "integrationRedirectGuide": { + "intro": "Questa connessione passa dalla tua app sviluppatore {provider} — circa 5 minuti di configurazione:", + "step1": "Crea un'app nel portale sviluppatori {provider}.", + "step2": "Imposta il suo URL di reindirizzamento/callback su:", + "step3": "Incolla qui sopra l'ID client e il client secret ricevuti." + }, "security": { "sessionsTitle": "Sessioni attive", "sessionsDescription": "Dispositivi e browser attualmente connessi al tuo account.", diff --git a/messages/pl.json b/messages/pl.json index 1577380ca..cc319642e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4016,7 +4016,7 @@ }, "source": { "title": "Połącz źródło", - "body": "Pobieraj pomiary automatycznie lub zostań przy wpisywaniu ręcznym. Nic nie jest tu konfigurowane za Ciebie — każda karta pokazuje tylko, gdzie to połączyć.", + "body": "Pobieraj pomiary automatycznie lub zostań przy wpisywaniu ręcznym. Nic nie jest tu konfigurowane za Ciebie — każda karta pokazuje tylko, gdzie to połączyć. Źródła sprzętowe wymagają darmowej aplikacji deweloperskiej na Twoim własnym koncie (około 5 minut); przewodnik na każdej karcie prowadzi krok po kroku.", "recommended": "Polecane", "manual": { "title": "Wpis ręczny", @@ -4024,12 +4024,14 @@ }, "withings": { "title": "Withings", - "body": "Wagi, ciśnieniomierze i trackery Withings synchronizują się automatycznie.", - "cta": "Połącz Withings" + "body": "Wagi, ciśnieniomierze i trackery Withings synchronizują się automatycznie po połączeniu.", + "cta": "Połącz Withings", + "setupCta": "Skonfiguruj w Ustawieniach", + "setupHint": "Najpierw wymaga własnej, darmowej aplikacji deweloperskiej Withings — około 5 minut, jednorazowo." }, "more": { "title": "Więcej źródeł", - "body": "Połącz dowolne z nich w Ustawieniach, gdy zechcesz.", + "body": "Połącz dowolne z nich w Ustawieniach, gdy zechcesz — większość wymaga najpierw darmowej aplikacji deweloperskiej na Twoim własnym koncie.", "appleHealth": "Apple Health", "whoop": "WHOOP", "oura": "Oura", @@ -5389,6 +5391,12 @@ "ouraDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z", "nightscoutDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z", "integrationCredentialsHint": "Gdzie znaleźć te dane logowania i jak zarejestrować własną aplikację, opisano w przewodniku konfiguracji powyżej.", + "integrationRedirectGuide": { + "intro": "To połączenie działa przez Twoją własną aplikację deweloperską {provider} — około 5 minut konfiguracji:", + "step1": "Utwórz aplikację w portalu deweloperskim {provider}.", + "step2": "Ustaw jej URL przekierowania/callback na:", + "step3": "Wklej powyżej otrzymany identyfikator klienta i sekret klienta." + }, "security": { "sessionsTitle": "Aktywne sesje", "sessionsDescription": "Urządzenia i przeglądarki obecnie zalogowane na Twoje konto.", diff --git a/src/app/api/fitbit/connect/__tests__/route.test.ts b/src/app/api/fitbit/connect/__tests__/route.test.ts new file mode 100644 index 000000000..aa71ee8a4 --- /dev/null +++ b/src/app/api/fitbit/connect/__tests__/route.test.ts @@ -0,0 +1,115 @@ +/** + * GET /api/fitbit/connect + * + * Pins: happy path mints a nonce + PKCE pair and redirects to the Fitbit + * consent screen; rate-limit and no-credentials both redirect back to + * `/settings/integrations?fitbit=error&reason=` rather than returning + * JSON — a browser-navigation entry point should never surface an unstyled + * JSON page. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u-1" } })), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { fitbitOAuthState: { create: vi.fn() } }, +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), +})); + +vi.mock("@/lib/fitbit/credentials", () => ({ + getUserFitbitCredentials: vi.fn(), +})); + +vi.mock("@/lib/fitbit/client", () => ({ + getAuthorizationUrl: vi.fn( + () => "https://www.fitbit.com/oauth2/authorize?state=nonce-stub", + ), + generatePkcePair: vi.fn(() => ({ + verifier: "verifier-stub", + challenge: "challenge-stub", + })), +})); + +vi.mock("@/lib/fitbit/oauth-state", () => ({ + FITBIT_OAUTH_STATE_COOKIE: "fitbit_oauth_state", + FITBIT_OAUTH_STATE_TTL_MS: 10 * 60 * 1000, + mintFitbitOAuthStateNonce: vi.fn(() => "nonce-stub"), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: vi.fn(() => ({ setError: vi.fn() })), +})); + +vi.mock("@/lib/auth/secure-cookie", () => ({ + shouldEmitSecureCookie: () => true, +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { getUserFitbitCredentials } from "@/lib/fitbit/credentials"; + +function connectRequest(): NextRequest { + return new NextRequest("http://localhost/api/fitbit/connect", { + method: "GET", + }); +} + +beforeEach(() => { + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 9, + resetAt: Date.now() + 60_000, + } as never); + vi.mocked(prisma.fitbitOAuthState.create).mockReset(); + vi.mocked(prisma.fitbitOAuthState.create).mockResolvedValue({} as never); + vi.mocked(getUserFitbitCredentials).mockResolvedValue({ + clientId: "client-id", + clientSecret: "client-secret", + } as never); +}); + +describe("GET /api/fitbit/connect", () => { + it("redirects to the Fitbit authorize URL on the happy path", async () => { + const response = await GET(connectRequest()); + expect(response.status).toBe(307); + expect(response.headers.get("location")).toContain( + "www.fitbit.com/oauth2/authorize", + ); + expect(prisma.fitbitOAuthState.create).toHaveBeenCalledTimes(1); + }); + + it("redirects to reason=rate_limited when the bucket is exhausted", async () => { + vi.mocked(checkRateLimit).mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + } as never); + + const response = await GET(connectRequest()); + const location = response.headers.get("location") ?? ""; + expect(location).toContain("/settings/integrations"); + expect(location).toContain("fitbit=error"); + expect(location).toContain("reason=rate_limited"); + expect(prisma.fitbitOAuthState.create).not.toHaveBeenCalled(); + }); + + it("redirects to reason=nocreds when no per-user credentials are stored", async () => { + vi.mocked(getUserFitbitCredentials).mockResolvedValueOnce(null); + + const response = await GET(connectRequest()); + const location = response.headers.get("location") ?? ""; + expect(location).toContain("/settings/integrations"); + expect(location).toContain("fitbit=error"); + expect(location).toContain("reason=nocreds"); + expect(prisma.fitbitOAuthState.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/fitbit/connect/route.ts b/src/app/api/fitbit/connect/route.ts index cf1e33ae6..bae44e08a 100644 --- a/src/app/api/fitbit/connect/route.ts +++ b/src/app/api/fitbit/connect/route.ts @@ -1,5 +1,4 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError } from "@/lib/api-response"; import { prisma } from "@/lib/db"; import { annotate, getEvent } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -32,7 +31,8 @@ import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; * Rate-limited per user (10 calls / 60 s) so a logged-in session can't spam * ledger rows for the full 10-min TTL window. Both the rate-limit and * create-failure paths redirect (not JSON) because the entry point is a browser - * navigation — a 429 envelope would surface as a blank page. + * navigation — a 429 envelope would surface as a blank page. The + * no-credentials branch redirects too (v1.29.x), matching Withings/WHOOP. */ const CONNECT_RATE_LIMIT = 10; const CONNECT_WINDOW_MS = 60_000; @@ -58,9 +58,9 @@ export const GET = apiHandler(async (req: NextRequest) => { const creds = await getUserFitbitCredentials(user.id); if (!creds) { - return apiError( - "Please configure your Fitbit OAuth Client ID and Client Secret in Settings first.", - 400, + annotate({ action: { name: "fitbit.connect.no_credentials" } }); + return NextResponse.redirect( + new URL("/settings/integrations?fitbit=error&reason=nocreds", req.url), ); } diff --git a/src/app/api/oura/connect/__tests__/route.test.ts b/src/app/api/oura/connect/__tests__/route.test.ts new file mode 100644 index 000000000..65cc3a80c --- /dev/null +++ b/src/app/api/oura/connect/__tests__/route.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { rateLimitMock, getCredsMock, mintMock, authUrlMock } = vi.hoisted( + () => ({ + rateLimitMock: vi.fn(), + getCredsMock: vi.fn(), + mintMock: vi.fn(() => "signed-state"), + authUrlMock: vi.fn(() => "https://cloud.ouraring.com/oauth/authorize?x=1"), + }), +); + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/auth/secure-cookie", () => ({ + shouldEmitSecureCookie: () => true, +})); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: rateLimitMock })); +vi.mock("@/lib/oura/client", () => ({ getAuthorizationUrl: authUrlMock })); +vi.mock("@/lib/oura/credentials", () => ({ + getOuraClientCredentials: getCredsMock, +})); +vi.mock("@/lib/oauth/signed-state", () => ({ + OAUTH_STATE_TTL_MS: 600_000, + mintSignedState: mintMock, + oauthStateCookieName: () => "oura_oauth_state", +})); + +import { GET } from "../route"; + +process.env.NEXT_PUBLIC_APP_URL = "https://app.example"; + +const run = GET as unknown as () => Promise<{ + status: number; + headers: { get(k: string): string | null }; + cookies: { get(k: string): { value: string } | undefined }; +}>; + +beforeEach(() => { + vi.clearAllMocks(); + rateLimitMock.mockResolvedValue({ allowed: true }); + getCredsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" }); +}); + +describe("GET /api/oura/connect", () => { + it("redirects to the consent screen and sets the state cookie", async () => { + const res = await run(); + expect(res.headers.get("location")).toContain("cloud.ouraring.com"); + expect(res.cookies.get("oura_oauth_state")?.value).toBe("signed-state"); + }); + + it("redirects to an error when the bucket is exhausted", async () => { + rateLimitMock.mockResolvedValue({ allowed: false }); + const res = await run(); + expect(res.headers.get("location")).toContain("reason=rate_limited"); + expect(getCredsMock).not.toHaveBeenCalled(); + }); + + it("redirects to reason=nocreds when neither BYO nor env credentials resolve", async () => { + getCredsMock.mockResolvedValue(null); + const res = await run(); + const location = res.headers.get("location") ?? ""; + expect(location).toContain("/settings/integrations"); + expect(location).toContain("oura=error"); + expect(location).toContain("reason=nocreds"); + }); +}); diff --git a/src/app/api/oura/connect/route.ts b/src/app/api/oura/connect/route.ts index ce2314afb..ec9fac009 100644 --- a/src/app/api/oura/connect/route.ts +++ b/src/app/api/oura/connect/route.ts @@ -1,5 +1,4 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -19,7 +18,10 @@ import { NextResponse } from "next/server"; * BYO Oura client id/secret wins, falling back to the shared env app * (`OURA_CLIENT_ID` / `OURA_CLIENT_SECRET`) when none is stored. CSRF via the * stateless signed state (`src/lib/oauth/signed-state.ts`): the same token is - * the `state` URL param AND an httpOnly cookie. Rate-limited per user. + * the `state` URL param AND an httpOnly cookie. Rate-limited per user. The + * no-credentials branch redirects too (v1.29.x) rather than returning a raw + * JSON 400 — every other failure path here already redirects, and a + * browser-navigation entry point should never surface an unstyled JSON page. */ const CONNECT_RATE_LIMIT = 10; const CONNECT_WINDOW_MS = 60_000; @@ -45,7 +47,13 @@ export const GET = apiHandler(async () => { const creds = await getOuraClientCredentials(user.id); if (!creds) { - return apiError("Oura integration is not configured on this server.", 400); + annotate({ action: { name: "oura.connect.no_credentials" } }); + return NextResponse.redirect( + new URL( + "/settings/integrations?oura=error&reason=nocreds", + process.env.NEXT_PUBLIC_APP_URL!, + ), + ); } const state = mintSignedState("oura", user.id); diff --git a/src/app/api/polar/connect/__tests__/route.test.ts b/src/app/api/polar/connect/__tests__/route.test.ts index 87511d54e..baad21c01 100644 --- a/src/app/api/polar/connect/__tests__/route.test.ts +++ b/src/app/api/polar/connect/__tests__/route.test.ts @@ -13,9 +13,6 @@ vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), })); -vi.mock("@/lib/api-response", () => ({ - apiError: (error: string, status: number) => ({ data: null, error, status }), -})); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); vi.mock("@/lib/auth/secure-cookie", () => ({ shouldEmitSecureCookie: () => true, @@ -61,9 +58,12 @@ describe("GET /api/polar/connect", () => { expect(getCredsMock).not.toHaveBeenCalled(); }); - it("returns 400 when neither BYO nor env credentials resolve", async () => { + it("redirects to reason=nocreds when neither BYO nor env credentials resolve", async () => { getCredsMock.mockResolvedValue(null); - const res = (await run()) as unknown as { status: number }; - expect(res.status).toBe(400); + const res = await run(); + const location = res.headers.get("location") ?? ""; + expect(location).toContain("/settings/integrations"); + expect(location).toContain("polar=error"); + expect(location).toContain("reason=nocreds"); }); }); diff --git a/src/app/api/polar/connect/route.ts b/src/app/api/polar/connect/route.ts index 3b9d8b5f2..824d4cc47 100644 --- a/src/app/api/polar/connect/route.ts +++ b/src/app/api/polar/connect/route.ts @@ -1,5 +1,4 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -22,7 +21,10 @@ import { NextResponse } from "next/server"; * same token is the `state` URL param AND an httpOnly cookie, and the callback * enforces both signature validity and a byte-exact cookie match. * - * Rate-limited per user so a session can't spam the consent redirect. + * Rate-limited per user so a session can't spam the consent redirect. The + * no-credentials branch redirects too (v1.29.x) rather than returning a raw + * JSON 400 — every other failure path here already redirects, and a + * browser-navigation entry point should never surface an unstyled JSON page. */ const CONNECT_RATE_LIMIT = 10; const CONNECT_WINDOW_MS = 60_000; @@ -48,7 +50,13 @@ export const GET = apiHandler(async () => { const creds = await getPolarClientCredentials(user.id); if (!creds) { - return apiError("Polar integration is not configured on this server.", 400); + annotate({ action: { name: "polar.connect.no_credentials" } }); + return NextResponse.redirect( + new URL( + "/settings/integrations?polar=error&reason=nocreds", + process.env.NEXT_PUBLIC_APP_URL!, + ), + ); } const state = mintSignedState("polar", user.id); diff --git a/src/app/api/strava/connect/__tests__/route.test.ts b/src/app/api/strava/connect/__tests__/route.test.ts new file mode 100644 index 000000000..9bcbb6610 --- /dev/null +++ b/src/app/api/strava/connect/__tests__/route.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { rateLimitMock, getCredsMock, mintMock, authUrlMock } = vi.hoisted( + () => ({ + rateLimitMock: vi.fn(), + getCredsMock: vi.fn(), + mintMock: vi.fn(() => "signed-state"), + authUrlMock: vi.fn(() => "https://www.strava.com/oauth/authorize?x=1"), + }), +); + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/auth/secure-cookie", () => ({ + shouldEmitSecureCookie: () => true, +})); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: rateLimitMock })); +vi.mock("@/lib/strava/client", () => ({ getAuthorizationUrl: authUrlMock })); +vi.mock("@/lib/strava/credentials", () => ({ + getStravaClientCredentials: getCredsMock, +})); +vi.mock("@/lib/oauth/signed-state", () => ({ + OAUTH_STATE_TTL_MS: 600_000, + mintSignedState: mintMock, + oauthStateCookieName: () => "strava_oauth_state", +})); + +import { GET } from "../route"; + +process.env.NEXT_PUBLIC_APP_URL = "https://app.example"; + +const run = GET as unknown as () => Promise<{ + status: number; + headers: { get(k: string): string | null }; + cookies: { get(k: string): { value: string } | undefined }; +}>; + +beforeEach(() => { + vi.clearAllMocks(); + rateLimitMock.mockResolvedValue({ allowed: true }); + getCredsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" }); +}); + +describe("GET /api/strava/connect", () => { + it("redirects to the consent screen and sets the state cookie", async () => { + const res = await run(); + expect(res.headers.get("location")).toContain("www.strava.com"); + expect(res.cookies.get("strava_oauth_state")?.value).toBe("signed-state"); + }); + + it("redirects to an error when the bucket is exhausted", async () => { + rateLimitMock.mockResolvedValue({ allowed: false }); + const res = await run(); + expect(res.headers.get("location")).toContain("reason=rate_limited"); + expect(getCredsMock).not.toHaveBeenCalled(); + }); + + it("redirects to reason=nocreds when neither BYO nor env credentials resolve", async () => { + getCredsMock.mockResolvedValue(null); + const res = await run(); + const location = res.headers.get("location") ?? ""; + expect(location).toContain("/settings/integrations"); + expect(location).toContain("strava=error"); + expect(location).toContain("reason=nocreds"); + }); +}); diff --git a/src/app/api/strava/connect/route.ts b/src/app/api/strava/connect/route.ts index 24e9a62bc..c76a61786 100644 --- a/src/app/api/strava/connect/route.ts +++ b/src/app/api/strava/connect/route.ts @@ -1,5 +1,4 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -19,7 +18,10 @@ import { NextResponse } from "next/server"; * user's BYO Strava client id/secret wins, falling back to the shared env app * (`STRAVA_CLIENT_ID` / `STRAVA_CLIENT_SECRET`) when none is stored. CSRF via * the stateless signed state (`src/lib/oauth/signed-state.ts`): the same token - * is the `state` URL param AND an httpOnly cookie. Rate-limited per user. + * is the `state` URL param AND an httpOnly cookie. Rate-limited per user. The + * no-credentials branch redirects too (v1.29.x) rather than returning a raw + * JSON 400 — every other failure path here already redirects, and a + * browser-navigation entry point should never surface an unstyled JSON page. */ const CONNECT_RATE_LIMIT = 10; const CONNECT_WINDOW_MS = 60_000; @@ -45,9 +47,12 @@ export const GET = apiHandler(async () => { const creds = await getStravaClientCredentials(user.id); if (!creds) { - return apiError( - "Strava integration is not configured on this server.", - 400, + annotate({ action: { name: "strava.connect.no_credentials" } }); + return NextResponse.redirect( + new URL( + "/settings/integrations?strava=error&reason=nocreds", + process.env.NEXT_PUBLIC_APP_URL!, + ), ); } diff --git a/src/app/api/whoop/connect/__tests__/route.test.ts b/src/app/api/whoop/connect/__tests__/route.test.ts index 6e243ee0f..fd93eabfd 100644 --- a/src/app/api/whoop/connect/__tests__/route.test.ts +++ b/src/app/api/whoop/connect/__tests__/route.test.ts @@ -53,6 +53,7 @@ import { prisma } from "@/lib/db"; import { requireAuth } from "@/lib/api-handler"; import { consumeWhoopConnectTicket } from "@/lib/whoop/connect-ticket"; import { checkRateLimit } from "@/lib/rate-limit"; +import { getUserWhoopCredentials } from "@/lib/whoop/credentials"; import type { NextRequest } from "next/server"; const create = prisma.whoopOAuthState.create as ReturnType; @@ -133,6 +134,17 @@ describe("GET /api/whoop/connect", () => { expect(create.mock.calls[0][0].data.returnScheme).toBeNull(); }); + it("redirects to reason=nocreds when no per-user credentials are stored", async () => { + vi.mocked(getUserWhoopCredentials).mockResolvedValueOnce(null); + const res = await GET(makeReq("https://app.example/api/whoop/connect")); + expect(res.status).toBe(307); + const location = res.headers.get("location") ?? ""; + expect(location).toContain("/settings/integrations"); + expect(location).toContain("whoop=error"); + expect(location).toContain("reason=nocreds"); + expect(create).not.toHaveBeenCalled(); + }); + it("ticket + return_scheme combine", async () => { consume.mockResolvedValue({ userId: "ticket-user" }); const res = await GET( diff --git a/src/app/api/whoop/connect/route.ts b/src/app/api/whoop/connect/route.ts index 8f5bf8226..716e13895 100644 --- a/src/app/api/whoop/connect/route.ts +++ b/src/app/api/whoop/connect/route.ts @@ -40,7 +40,10 @@ import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; * Rate-limited per user (10 calls / 60 s) so a logged-in session can't spam * ledger rows for the full 10-min TTL window. Both the rate-limit and * create-failure paths redirect (not JSON) because the cookie entry point is a - * browser navigation — a 429 envelope would surface as a blank page. + * browser navigation — a 429 envelope would surface as a blank page. The + * no-credentials branch redirects too (v1.29.x), matching Withings — only the + * ticket-invalid branch stays a typed 401 JSON response, since that path is + * consumed by a native client, not a browser navigation. */ const CONNECT_RATE_LIMIT = 10; const CONNECT_WINDOW_MS = 60_000; @@ -89,9 +92,9 @@ export const GET = apiHandler(async (req: NextRequest) => { const creds = await getUserWhoopCredentials(userId); if (!creds) { - return apiError( - "Please configure your WHOOP Client ID and Client Secret in Settings first.", - 400, + annotate({ action: { name: "whoop.connect.no_credentials" } }); + return NextResponse.redirect( + new URL("/settings/integrations?whoop=error&reason=nocreds", req.url), ); } diff --git a/src/app/api/withings/connect/__tests__/route.test.ts b/src/app/api/withings/connect/__tests__/route.test.ts index cc3fec2b5..32d1044d7 100644 --- a/src/app/api/withings/connect/__tests__/route.test.ts +++ b/src/app/api/withings/connect/__tests__/route.test.ts @@ -12,6 +12,10 @@ * redirects to * `/settings/integrations?withings=error&reason=connect` * instead of bubbling a 500. + * 4. No credentials: redirects to + * `/settings/integrations?withings=error&reason=nocreds` instead of + * returning a raw JSON 400 (v1.29.x — the flagship onboarding CTA + * dead-ended in an unstyled JSON tab for every brand-new account). */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; @@ -132,4 +136,17 @@ describe("GET /api/withings/connect", () => { expect(location).toContain("withings=error"); expect(location).toContain("reason=connect"); }); + + it("redirects to reason=nocreds when no per-user credentials are stored", async () => { + vi.mocked(getUserWithingsCredentials).mockResolvedValueOnce(null); + + const response = await GET(connectRequest()); + + expect(response.status).toBe(307); + const location = response.headers.get("location") ?? ""; + expect(location).toContain("/settings/integrations"); + expect(location).toContain("withings=error"); + expect(location).toContain("reason=nocreds"); + expect(prisma.withingsOAuthState.create).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/withings/connect/route.ts b/src/app/api/withings/connect/route.ts index c1e0c672f..44773679d 100644 --- a/src/app/api/withings/connect/route.ts +++ b/src/app/api/withings/connect/route.ts @@ -1,5 +1,4 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError } from "@/lib/api-response"; import { prisma } from "@/lib/db"; import { annotate, getEvent } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -32,6 +31,12 @@ import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; * instead of bubbling a 500. Both rate-limit and create-failure paths * redirect rather than returning JSON because the entry point is a * browser navigation — a 429 envelope would surface as a blank page. + * + * v1.29.x — the no-credentials branch redirects too, matching every + * other failure path in this route. It used to return a raw JSON 400, + * which every browser navigation into this route (onboarding's + * "Connect Withings" tap included) surfaced as an unstyled JSON page — + * the first "connect a device" moment for a brand-new self-hoster. */ const CONNECT_RATE_LIMIT = 10; @@ -58,9 +63,9 @@ export const GET = apiHandler(async (req: NextRequest) => { const creds = await getUserWithingsCredentials(user.id); if (!creds) { - return apiError( - "Please configure your Withings Client ID and Client Secret in Settings first.", - 400, + annotate({ action: { name: "withings.connect.no_credentials" } }); + return NextResponse.redirect( + new URL("/settings/integrations?withings=error&reason=nocreds", req.url), ); } diff --git a/src/components/onboarding/__tests__/source-card-grid.test.tsx b/src/components/onboarding/__tests__/source-card-grid.test.tsx new file mode 100644 index 000000000..7721480cd --- /dev/null +++ b/src/components/onboarding/__tests__/source-card-grid.test.tsx @@ -0,0 +1,91 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +/** + * v1.29.x — UX audit H1: the wizard's Withings card must not launch an + * OAuth handshake that can only 400 for a brand-new account (Withings + * credentials are per-user BYO with no env fallback). The card reads the + * consolidated integrations-status envelope and swaps its CTA + href + * depending on whether the account already has its own credentials saved. + */ + +const integrationStatusState = vi.hoisted(() => ({ + data: undefined as + | { + threshold: number; + integrations: Array<{ integration: string; configured?: boolean }>; + } + | undefined, +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: integrationStatusState.data }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true }), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + ...rest + }: { + href: string; + children: React.ReactNode; + }) => ( + + {children} + + ), +})); + +import { SourceCardGrid } from "../source-card-grid"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe(" Withings card", () => { + it("points at Settings instead of launching OAuth when unconfigured", () => { + integrationStatusState.data = { + threshold: 3, + integrations: [{ integration: "withings", configured: false }], + }; + const html = render(); + expect(html).toContain('data-testid="source-card-withings-setup"'); + expect(html).toContain('href="/settings/integrations#withings"'); + expect(html).not.toContain('href="/api/withings/connect"'); + expect(html).toContain("Set up in Settings"); + }); + + it("also redirects to Settings when the status envelope hasn't loaded yet", () => { + integrationStatusState.data = undefined; + const html = render(); + expect(html).toContain('href="/settings/integrations#withings"'); + expect(html).not.toContain('href="/api/withings/connect"'); + }); + + it("opens the real OAuth connect flow once credentials are configured", () => { + integrationStatusState.data = { + threshold: 3, + integrations: [{ integration: "withings", configured: true }], + }; + const html = render(); + expect(html).toContain('href="/api/withings/connect"'); + expect(html).not.toContain('data-testid="source-card-withings-setup"'); + expect(html).toContain("Connect Withings"); + }); +}); diff --git a/src/components/onboarding/source-card-grid.tsx b/src/components/onboarding/source-card-grid.tsx index 6ba4305d9..8c0d64fdb 100644 --- a/src/components/onboarding/source-card-grid.tsx +++ b/src/components/onboarding/source-card-grid.tsx @@ -15,6 +15,11 @@ import { } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { + pickStatus, + useIntegrationStatuses, +} from "@/components/settings/integrations/shared"; +import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { apiPost } from "@/lib/api/api-fetch"; @@ -26,8 +31,12 @@ import { queryKeys } from "@/lib/query-keys"; * Presents the data sources HealthLog ships today. The headline cards * cover the two paths most people start on: * 1. Manual entry (enabled, recommended — works on any device) - * 2. Withings (enabled — opens OAuth `/api/withings/connect` in a new - * tab; the callback flips the connection server-side) + * 2. Withings — opens OAuth `/api/withings/connect` in a new tab once + * the account has its own Withings developer app configured; the + * callback flips the connection server-side. Before that, the card + * is honest about the prerequisite (v1.29.x, UX audit H1/H2): the + * CTA reads "Set up in Settings" and points at the credentials form + * instead of launching an OAuth handshake that can only 400. * * Below them a calm "more sources" row lists the remaining shipped * integrations (Apple Health, WHOOP, Oura, Polar, Nightscout, Fitbit), @@ -97,10 +106,20 @@ export function SourceCardGrid() { const { t } = useTranslations(); const router = useRouter(); const queryClient = useQueryClient(); + const { isAuthenticated } = useAuth(); const [selected, setSelected] = useState("manual"); const [advancing, setAdvancing] = useState(false); + // v1.29.x — the wizard's "Connect Withings" card must not launch an OAuth + // handshake that can only fail: Withings credentials are per-user BYO with + // no env fallback, and a brand-new account never has them yet. Read the + // same consolidated envelope Settings → Integrations uses so the card + // knows whether the handshake can actually succeed. + const { data: integrationStatus } = useIntegrationStatuses(isAuthenticated); + const withingsConfigured = + pickStatus(integrationStatus, "withings")?.configured ?? false; + async function advance() { if (advancing) return; setAdvancing(true); @@ -144,6 +163,9 @@ export function SourceCardGrid() { card.recommendedKey ? t(card.recommendedKey) : null } withingsCtaLabel={t("onboarding.source.withings.cta")} + withingsConfigured={withingsConfigured} + withingsSetupCtaLabel={t("onboarding.source.withings.setupCta")} + withingsSetupHint={t("onboarding.source.withings.setupHint")} /> ))} @@ -234,6 +256,14 @@ interface SourceCardItemProps { bodyLabel: string; recommendedLabel: string | null; withingsCtaLabel: string; + /** Whether the account already has its own Withings developer-app + * credentials saved — i.e. whether `/api/withings/connect` can + * actually succeed right now. */ + withingsConfigured: boolean; + /** CTA label shown instead of "Connect Withings" when unconfigured. */ + withingsSetupCtaLabel: string; + /** One-sentence explanation of the BYO credential prerequisite. */ + withingsSetupHint: string; } function SourceCardItem({ @@ -244,6 +274,9 @@ function SourceCardItem({ bodyLabel, recommendedLabel, withingsCtaLabel, + withingsConfigured, + withingsSetupCtaLabel, + withingsSetupHint, }: SourceCardItemProps) { const Icon = card.Icon; @@ -292,20 +325,40 @@ function SourceCardItem({ > {headerRow} {body} - + {withingsConfigured ? ( + + ) : ( + // No per-user Withings credentials yet — an OAuth handshake here + // can only 400 (Withings has no env fallback). Point at the + // credentials form instead of launching a doomed connect flow. +
+

{withingsSetupHint}

+ +
+ )} ); } diff --git a/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx b/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx index 80672df84..9a0f3739a 100644 --- a/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx +++ b/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx @@ -148,3 +148,33 @@ describe("OAuthProviderCard — per-user BYO credentials form (v1.17.1)", () => expect(html).toContain("Saved — enter new to replace"); }); }); + +describe("OAuthProviderCard — redirect-URI mini-guide (v1.29.x, UX audit H2)", () => { + it("shows the callback URL guide before the user has BYO credentials", () => { + process.env.NEXT_PUBLIC_APP_URL = "https://app.example"; + statusPayload = { + connected: false, + configured: false, + available: true, + hasOwnCredentials: false, + }; + const html = render({ credentials: true }); + expect(html).toContain('data-testid="polar-redirect-guide"'); + expect(html).toContain('data-testid="polar-redirect-uri"'); + expect(html).toContain("https://app.example/api/polar/callback"); + }); + + it("hides the guide once the user has stored their own credentials", () => { + statusPayload = { + connected: true, + configured: true, + available: true, + hasOwnCredentials: true, + state: "connected", + lastSuccessAt: null, + lastError: null, + }; + const html = render({ credentials: true }); + expect(html).not.toContain('data-testid="polar-redirect-guide"'); + }); +}); diff --git a/src/components/settings/integrations/__tests__/whoop-card.test.tsx b/src/components/settings/integrations/__tests__/whoop-card.test.tsx new file mode 100644 index 000000000..3faae333a --- /dev/null +++ b/src/components/settings/integrations/__tests__/whoop-card.test.tsx @@ -0,0 +1,41 @@ +/** + * v1.29.x — UX audit H2: the WHOOP card shows a compact callback-URL guide + * while the account has no BYO credentials saved, and steps out of the way + * once configured. Mirrors the OAuthProviderCard coverage for Polar/Oura. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("@tanstack/react-query", () => ({ + useMutation: () => ({ mutate: vi.fn(), isPending: false }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { WhoopCard } from "../whoop-card"; +import type { IntegrationStatusViewModel } from "../shared"; + +function render(viewModel?: Partial) { + return renderToStaticMarkup( + + + , + ); +} + +describe(" redirect-URI mini-guide", () => { + it("shows the callback URL guide before credentials are configured", () => { + process.env.NEXT_PUBLIC_APP_URL = "https://app.example"; + const html = render({ connected: false, configured: false }); + expect(html).toContain('data-testid="whoop-redirect-guide"'); + expect(html).toContain('data-testid="whoop-redirect-uri"'); + expect(html).toContain("https://app.example/api/whoop/callback"); + }); + + it("hides the guide once credentials are configured", () => { + const html = render({ connected: false, configured: true }); + expect(html).not.toContain('data-testid="whoop-redirect-guide"'); + }); +}); diff --git a/src/components/settings/integrations/fitbit-card.tsx b/src/components/settings/integrations/fitbit-card.tsx index e5c4f73a1..f8ecf653a 100644 --- a/src/components/settings/integrations/fitbit-card.tsx +++ b/src/components/settings/integrations/fitbit-card.tsx @@ -52,7 +52,10 @@ import { pillStateFor, type IntegrationStatusViewModel, } from "./shared"; -import { IntegrationCardDescription } from "./setup-guide-link"; +import { + IntegrationCardDescription, + IntegrationRedirectGuide, +} from "./setup-guide-link"; export function FitbitCard({ viewModel, @@ -283,6 +286,12 @@ export function FitbitCard({

{t("settings.fitbitCredentials")}

+ {!status?.configured && ( + + )}
diff --git a/src/components/settings/integrations/oauth-provider-card.tsx b/src/components/settings/integrations/oauth-provider-card.tsx index bc9f1d58c..fe4a05d0d 100644 --- a/src/components/settings/integrations/oauth-provider-card.tsx +++ b/src/components/settings/integrations/oauth-provider-card.tsx @@ -51,6 +51,7 @@ import { } from "@/lib/query-keys"; import { IntegrationCardDescription, + IntegrationRedirectGuide, type IntegrationDocsProvider, } from "./setup-guide-link"; @@ -277,6 +278,12 @@ export function OAuthProviderCard({

{t(`${i18nPrefix}Credentials`)}

+ {!status?.hasOwnCredentials && ( + + )}
diff --git a/src/components/settings/integrations/setup-guide-link.tsx b/src/components/settings/integrations/setup-guide-link.tsx index 862d2df20..4a1b53ad1 100644 --- a/src/components/settings/integrations/setup-guide-link.tsx +++ b/src/components/settings/integrations/setup-guide-link.tsx @@ -38,6 +38,69 @@ export function integrationDocsHref(provider: IntegrationDocsProvider): string { return `${INTEGRATION_DOCS_BASE}/${provider}`; } +/** + * The OAuth callback path every BYO-key provider registers against. Every + * provider's `getRedirectUri()` (`src/lib/{provider}/client.ts`) derives the + * same shape from `NEXT_PUBLIC_APP_URL` unless an operator overrides it with + * an explicit `*_REDIRECT_URI` env var — the common case this card targets. + */ +export function integrationCallbackUrl( + provider: IntegrationDocsProvider, +): string { + const base = process.env.NEXT_PUBLIC_APP_URL ?? ""; + return `${base}/api/${provider}/callback`; +} + +/** + * v1.29.x (UX audit H2) — compact 3-step BYO-developer-app guide for the + * six providers that require a self-hoster to register their own vendor + * app before an OAuth connect can ever succeed (Withings / WHOOP / Fitbit / + * Polar / Oura / Strava). The external "Setup guide" link explains the + * *why*; this inline callout gives the one value every vendor form asks + * for and the app already knows — the callback URL — so it doesn't have to + * be reconstructed by hand from the docs. Shown only while the user hasn't + * saved their own credentials yet; it steps out of the way once configured. + */ +export function IntegrationRedirectGuide({ + provider, + providerLabel, +}: { + provider: IntegrationDocsProvider; + providerLabel: string; +}) { + const { t } = useTranslations(); + const callbackUrl = integrationCallbackUrl(provider); + return ( +
+

+ {t("settings.integrationRedirectGuide.intro", { + provider: providerLabel, + })} +

+
    +
  1. + {t("settings.integrationRedirectGuide.step1", { + provider: providerLabel, + })} +
  2. +
  3. + {t("settings.integrationRedirectGuide.step2")}{" "} + + {callbackUrl} + +
  4. +
  5. {t("settings.integrationRedirectGuide.step3")}
  6. +
+
+ ); +} + /** * v1.18.7 (Wave F) — the one description skeleton every integration card paints: * a WHITE primary sentence (`${i18nPrefix}Description`), then a GREY secondary diff --git a/src/components/settings/integrations/whoop-card.tsx b/src/components/settings/integrations/whoop-card.tsx index 6c3aefd31..309e54efd 100644 --- a/src/components/settings/integrations/whoop-card.tsx +++ b/src/components/settings/integrations/whoop-card.tsx @@ -44,7 +44,10 @@ import { pillStateFor, type IntegrationStatusViewModel, } from "./shared"; -import { IntegrationCardDescription } from "./setup-guide-link"; +import { + IntegrationCardDescription, + IntegrationRedirectGuide, +} from "./setup-guide-link"; export function WhoopCard({ viewModel, @@ -264,6 +267,12 @@ export function WhoopCard({

{t("settings.whoopCredentials")}

+ {!status?.configured && ( + + )}
diff --git a/src/components/settings/integrations/withings-card.tsx b/src/components/settings/integrations/withings-card.tsx index cf4c8d704..49a83245f 100644 --- a/src/components/settings/integrations/withings-card.tsx +++ b/src/components/settings/integrations/withings-card.tsx @@ -37,7 +37,10 @@ import { pillStateFor, type IntegrationStatusViewModel, } from "./shared"; -import { IntegrationCardDescription } from "./setup-guide-link"; +import { + IntegrationCardDescription, + IntegrationRedirectGuide, +} from "./setup-guide-link"; export function WithingsCard({ viewModel, @@ -299,6 +302,12 @@ export function WithingsCard({

{t("settings.withingsCredentials")}

+ {!status?.configured && ( + + )} {/* v1.4.27 MB7 / CF-53 — the credentials grid drops from a 3-column row (client-id / secret / save) to a From d124a3239d5188f3ee3cd4914538cd4a47de4086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 17 Jul 2026 23:35:28 +0200 Subject: [PATCH 08/33] fix(insights): stop the pulse chart from faking a continuous shape, add honest error states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Intraday pulse rendered sparse spot readings as a smooth interpolated curve across hours-wide gaps, implying continuous monitoring the data never had. The chart now grids one point per bucket, breaks the area over any missing bucket (connectNulls={false}), and discloses coverage ("based on N readings across M hours") whenever that produces a visible break. Four surfaces also collapsed a failed fetch into the honest empty/no- data state instead of surfacing the error: the Today hero, the medication efficacy tab (previously stuck on the loading skeleton forever on error), the intraday pulse chart, and the recent-workouts dashboard tile. All four now branch on isError and render QueryErrorCard with a retry action; the tile's "loading…" text line is replaced with row-shaped skeletons. --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/pl.json | 3 +- src/app/page-client.tsx | 10 +- .../__tests__/recent-workouts-tile.test.tsx | 19 ++- .../dashboard/recent-workouts-tile.tsx | 28 +++-- .../insights/intraday-pulse-chart.tsx | 111 +++++++++++++++--- .../detail/efficacy/efficacy-tab.tsx | 20 +++- src/hooks/use-workouts.ts | 6 + 12 files changed, 176 insertions(+), 36 deletions(-) diff --git a/messages/de.json b/messages/de.json index c9d7aef88..44ba5ee3b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -405,7 +405,6 @@ "recentWorkouts": { "title": "Letzte Workouts", "viewAll": "Alle anzeigen", - "loading": "Workouts werden geladen…", "empty": { "title": "Noch keine Workouts", "cta": "Öffne die Apple-Health-App auf dem iPhone, damit Workouts hier erscheinen." @@ -3879,6 +3878,8 @@ "caption": "Dein Puls über den Tag, gemittelt in 10-Minuten-Schritten.", "hourlyNote": "Stündlicher Durchschnitt für diesen Tag — aktuelle Tage zeigen das volle 10-Minuten-Detail.", "empty": "Für diesen Tag noch nicht genug Pulswerte, um den Tagesverlauf zu zeigen.", + "coverageOne": "Basierend auf 1 Messung über {hours}h.", + "coverageMany": "Basierend auf {count} Messungen über {hours}h.", "baseline": "Ruhe", "today": "Heute", "previousDay": "Vorheriger Tag", diff --git a/messages/en.json b/messages/en.json index c0ca0352a..6346aa9bb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -405,7 +405,6 @@ "recentWorkouts": { "title": "Recent workouts", "viewAll": "View all", - "loading": "Loading workouts…", "empty": { "title": "No workouts yet", "cta": "Open Apple Health on your iPhone to sync workouts here." @@ -3879,6 +3878,8 @@ "caption": "Your heart rate through the day, averaged in 10-minute steps.", "hourlyNote": "Showing this day's hourly average — recent days show the full 10-minute detail.", "empty": "Not enough heart-rate readings for this day to show its shape yet.", + "coverageOne": "Based on 1 reading across {hours}h.", + "coverageMany": "Based on {count} readings across {hours}h.", "baseline": "Resting", "today": "Today", "previousDay": "Previous day", diff --git a/messages/es.json b/messages/es.json index c30794199..36f54cb24 100644 --- a/messages/es.json +++ b/messages/es.json @@ -405,7 +405,6 @@ "recentWorkouts": { "title": "Entrenamientos recientes", "viewAll": "Ver todos", - "loading": "Cargando entrenamientos…", "empty": { "title": "Aún no hay entrenamientos", "cta": "Abre la app Apple Salud en el iPhone para sincronizar los entrenamientos." @@ -3879,6 +3878,8 @@ "caption": "Tu frecuencia cardíaca a lo largo del día, promediada en tramos de 10 minutos.", "hourlyNote": "Mostrando el promedio por hora de este día — los días recientes muestran el detalle completo de 10 minutos.", "empty": "Aún no hay suficientes lecturas para este día para mostrar su forma.", + "coverageOne": "Basado en 1 lectura a lo largo de {hours}h.", + "coverageMany": "Basado en {count} lecturas a lo largo de {hours}h.", "baseline": "Reposo", "today": "Hoy", "previousDay": "Día anterior", diff --git a/messages/fr.json b/messages/fr.json index 33b44e7a3..14420754a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -405,7 +405,6 @@ "recentWorkouts": { "title": "Entraînements récents", "viewAll": "Tout voir", - "loading": "Chargement des entraînements…", "empty": { "title": "Aucun entraînement", "cta": "Ouvre l'app Apple Santé sur ton iPhone pour synchroniser les entraînements." @@ -3879,6 +3878,8 @@ "caption": "Votre fréquence cardiaque au fil de la journée, moyennée par tranches de 10 minutes.", "hourlyNote": "Moyenne horaire affichée pour cette journée — les jours récents affichent le détail complet par tranches de 10 minutes.", "empty": "Pas encore assez de mesures pour cette journée pour en afficher le rythme.", + "coverageOne": "Basé sur 1 mesure sur {hours}h.", + "coverageMany": "Basé sur {count} mesures sur {hours}h.", "baseline": "Repos", "today": "Aujourd'hui", "previousDay": "Jour précédent", diff --git a/messages/it.json b/messages/it.json index 79b40580f..639145a51 100644 --- a/messages/it.json +++ b/messages/it.json @@ -405,7 +405,6 @@ "recentWorkouts": { "title": "Allenamenti recenti", "viewAll": "Vedi tutti", - "loading": "Caricamento allenamenti…", "empty": { "title": "Nessun allenamento", "cta": "Apri l'app Salute di Apple sull'iPhone per sincronizzare gli allenamenti." @@ -3879,6 +3878,8 @@ "caption": "La tua frequenza cardiaca durante il giorno, mediata in intervalli di 10 minuti.", "hourlyNote": "Media oraria per questo giorno — i giorni recenti mostrano il dettaglio completo a 10 minuti.", "empty": "Non ci sono ancora abbastanza misurazioni per questo giorno per mostrarne l'andamento.", + "coverageOne": "Basato su 1 misurazione in {hours}h.", + "coverageMany": "Basato su {count} misurazioni in {hours}h.", "baseline": "Riposo", "today": "Oggi", "previousDay": "Giorno precedente", diff --git a/messages/pl.json b/messages/pl.json index cc319642e..996d46c39 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -405,7 +405,6 @@ "recentWorkouts": { "title": "Ostatnie treningi", "viewAll": "Zobacz wszystkie", - "loading": "Wczytywanie treningów…", "empty": { "title": "Brak treningów", "cta": "Otwórz aplikację Apple Zdrowie na iPhonie, aby zsynchronizować treningi." @@ -3879,6 +3878,8 @@ "caption": "Twoje tętno w ciągu dnia, uśrednione w 10-minutowych odcinkach.", "hourlyNote": "Pokazano średnią godzinową dla tego dnia — ostatnie dni pokazują pełny, 10-minutowy szczegół.", "empty": "Za mało pomiarów z tego dnia, aby pokazać jego przebieg.", + "coverageOne": "Na podstawie 1 pomiaru w ciągu {hours}h.", + "coverageMany": "Na podstawie {count} pomiarów w ciągu {hours}h.", "baseline": "Spoczynek", "today": "Dziś", "previousDay": "Poprzedni dzień", diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 2f99a5eec..a23f4a47a 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -35,6 +35,7 @@ import dynamic from "next/dynamic"; import Link from "next/link"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; import { ChartErrorBoundary } from "@/components/charts/chart-error-state"; import { ChartSkeleton } from "@/components/charts/chart-skeleton"; import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; @@ -790,8 +791,11 @@ export default function DashboardPageClient() { decision 1). Renders the S1 daily digest: the day's read, the honest score/freshness face, and the worth-a-look rail. Shimmer skeleton while the cached digest loads; a genuinely empty account - degrades to nothing (the hero itself returns null). The dense - tile grid + charts below are untouched. */} + degrades to nothing (the hero itself returns null). A FAILED fetch + is a distinct state from that honest empty degrade — it renders + `` with a retry action rather than silently + falling through to nothing. The dense tile grid + charts below + are untouched. */} {insightsEnabled && // `!mounted` pins the SSR pass AND the first hydration render to the // skeleton (v1.16.4 pattern): the digest query rehydrates from the @@ -800,6 +804,8 @@ export default function DashboardPageClient() { // skeleton — a text-content hydration mismatch (React #418). (!mounted || digestQuery.isLoading ? ( + ) : digestQuery.isError ? ( + digestQuery.refetch()} /> ) : digestQuery.data ? ( ) : null)} diff --git a/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx b/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx index 750d97bab..cfea873bc 100644 --- a/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx +++ b/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx @@ -32,11 +32,15 @@ let mockResult: { isLoading: boolean; isEmpty: boolean; error: Error | null; + isError: boolean; + refetch: () => void; } = { data: undefined, isLoading: false, isEmpty: false, error: null, + isError: false, + refetch: () => {}, }; vi.mock("@/hooks/use-workouts", async () => { @@ -66,15 +70,26 @@ beforeEach(() => { isLoading: false, isEmpty: false, error: null, + isError: false, + refetch: () => {}, }; }); describe("", () => { - it("renders the loading placeholder while the query is in flight", () => { + it("renders row-shaped skeletons while the query is in flight", () => { mockResult.isLoading = true; const html = render(); expect(html).toContain('data-slot="recent-workouts-loading"'); - expect(html).toContain("Loading workouts"); + expect(html).toContain('data-slot="skeleton"'); + }); + + it("renders a retry card instead of the empty state on a failed fetch", () => { + mockResult.isError = true; + const html = render(); + expect(html).toContain('data-slot="query-error-card"'); + // A failed fetch must never read as the honest "no workouts yet" + // empty state — that would misreport an outage as an empty account. + expect(html).not.toContain('data-slot="recent-workouts-empty"'); }); it("renders the empty-state with the Apple-Health onboarding cue", () => { diff --git a/src/components/dashboard/recent-workouts-tile.tsx b/src/components/dashboard/recent-workouts-tile.tsx index db30b451e..6caaa66f0 100644 --- a/src/components/dashboard/recent-workouts-tile.tsx +++ b/src/components/dashboard/recent-workouts-tile.tsx @@ -17,6 +17,8 @@ import { useWorkouts, type WorkoutListEntry } from "@/hooks/use-workouts"; import { cn } from "@/lib/utils"; import { TileHeader } from "@/components/insights/tile-header"; import { getDateTimeFormat } from "@/lib/intl/formatter-cache"; +import { Skeleton } from "@/components/ui/skeleton"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; /** * v1.4.32 — ``. @@ -99,7 +101,7 @@ function renderRow(workout: WorkoutListEntry, sportName: string) { export function RecentWorkoutsTile() { const { t, locale } = useTranslations(); - const { data, isLoading } = useWorkouts({ limit: 3 }); + const { data, isLoading, isError, refetch } = useWorkouts({ limit: 3 }); const workouts = data?.workouts ?? []; return ( @@ -131,12 +133,24 @@ export function RecentWorkoutsTile() { // v1.4.43 W11-L6 — reserve roughly the loaded tile height // (3 workouts × ~3 rem rows + spacing) so the surrounding // dashboard layout doesn't reflow once the data lands. -

- {t("dashboard.recentWorkouts.loading")} -

+ // v1.29.x — row-shaped skeletons instead of a "loading…" text + // line, matching the sport-icon + label + day + duration shape + // the loaded rows render. +
+ {[0, 1, 2].map((i) => ( +
+ + + + +
+ ))} +
+ ) : isError ? ( + refetch()} + className="border-0 bg-transparent shadow-none" + /> ) : workouts.length === 0 ? (

{t("dashboard.recentWorkouts.empty.title")}

diff --git a/src/components/insights/intraday-pulse-chart.tsx b/src/components/insights/intraday-pulse-chart.tsx index 563d3edbd..8ecfcca1f 100644 --- a/src/components/insights/intraday-pulse-chart.tsx +++ b/src/components/insights/intraday-pulse-chart.tsx @@ -22,10 +22,12 @@ import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { userDayKey, shiftDateKey } from "@/lib/tz/format"; import { TileHeader } from "@/components/insights/tile-header"; import { Button } from "@/components/ui/button"; -import type { - IntradayHrBucket, - IntradayResolution, - TensionWindow, +import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { + BUCKET_MINUTES, + type IntradayHrBucket, + type IntradayResolution, + type TensionWindow, } from "@/lib/analytics/intraday-pulse"; /** Slim DTO the intraday route returns (type-only — no server code bundled). */ @@ -57,13 +59,23 @@ const AXIS_TICKS = [0, 360, 720, 1080, 1440]; * and non-diagnostic — "possible tension", never a stress score. Empty / no- * baseline days render an honest one-liner rather than an empty grid. * + * The area never interpolates across a gap of missing buckets — `chartData` + * is a full-day grid with `null` for every absent bucket and the `Area` + * renders with `connectNulls={false}`, so a handful of spot readings shows + * as isolated points rather than a fabricated smooth curve. Whenever that + * produces a visible break, a small "based on N readings across M hours" + * line discloses the coverage so the shape doesn't read as more continuous + * than it is. + * * v1.29.x — a day navigator (`TileHeader`'s `right` slot, per UI-STANDARDS * §11) pages the same route backward through prior days; "next" is disabled * at today, so the view can never step into the future. A day outside the * dense retention window (`DENSE_INTRADAY_RETENTION_DAYS`) reads back at the * coarser hourly grain instead of an empty chart — `resolution` on the DTO * tells the caption which, and the tension read is always absent on an - * hourly day (it needs per-sample resolution to stay honest). + * hourly day (it needs per-sample resolution to stay honest). A failed fetch + * renders `` with a retry action instead of falling through + * to the empty-day copy. */ export function IntradayPulseChart({ userTimezone, @@ -91,7 +103,7 @@ export function IntradayPulseChart({ setSelectedDateKey(next === todayKey ? null : next); }; - const { data, isLoading } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.insightsPulseIntraday(dateKey), queryFn: () => apiGet(`/api/insights/pulse/intraday?date=${dateKey}`), @@ -100,15 +112,51 @@ export function IntradayPulseChart({ refetchOnWindowFocus: false, }); - const chartData = useMemo( - () => - (data?.series ?? []).map((b) => ({ - minute: b.startMinute, - label: minuteLabel(b.startMinute), - bpm: b.mean, - })), - [data?.series], - ); + const bucketMinutes = data?.bucketMinutes ?? BUCKET_MINUTES; + + // A handful of spot readings scattered across the day must never read as + // a smooth, continuous curve — that fabricates a shape the data doesn't + // support. Building one grid point per possible bucket (present buckets + // carry their mean, absent ones are `null`) lets `connectNulls={false}` + // below break the area over any missing bucket instead of interpolating + // across it, so only genuinely adjacent readings connect. + const chartData = useMemo(() => { + const series = data?.series; + if (!series || series.length === 0) return []; + const byMinute = new Map(series.map((b) => [b.startMinute, b.mean])); + const points: Array<{ minute: number; label: string; bpm: number | null }> = + []; + for (let minute = 0; minute < 1440; minute += bucketMinutes) { + points.push({ + minute, + label: minuteLabel(minute), + bpm: byMinute.get(minute) ?? null, + }); + } + return points; + }, [data?.series, bucketMinutes]); + + // The coverage disclosure only fires when the chart actually contains a + // break — a fully contiguous day already reads honestly without it. Whole + // hours (not fractional) keep the copy simple across locales. + const coverage = useMemo(() => { + const series = data?.series; + if (!series || series.length < 2) return null; + const sorted = [...series].sort((a, b) => a.startMinute - b.startMinute); + let hasGap = false; + for (let i = 1; i < sorted.length; i++) { + if (sorted[i].startMinute - sorted[i - 1].startMinute > bucketMinutes) { + hasGap = true; + break; + } + } + if (!hasGap) return null; + const readingCount = sorted.reduce((sum, b) => sum + b.count, 0); + const first = sorted[0].startMinute; + const last = sorted[sorted.length - 1].startMinute + bucketMinutes; + const hours = Math.max(1, Math.round((last - first) / 60)); + return { readingCount, hours }; + }, [data?.series, bucketMinutes]); const caption = data?.tension ? t(`insights.intradayPulse.tension.${data.tension.partOfDay}`) @@ -116,6 +164,15 @@ export function IntradayPulseChart({ ? t("insights.intradayPulse.hourlyNote") : t("insights.intradayPulse.caption"); + const coverageNote = coverage + ? coverage.readingCount === 1 + ? t("insights.intradayPulse.coverageOne", { hours: coverage.hours }) + : t("insights.intradayPulse.coverageMany", { + count: coverage.readingCount, + hours: coverage.hours, + }) + : null; + const dayLabel = isToday ? t("insights.intradayPulse.today") : fmt.dateShort(new Date(`${dateKey}T12:00:00.000Z`)); @@ -167,9 +224,22 @@ export function IntradayPulseChart({ } />

{caption}

+ {coverageNote ? ( +

+ {coverageNote} +

+ ) : null} {isLoading ? (
+ ) : isError ? ( + refetch()} + className="border-0 bg-transparent shadow-none" + /> ) : chartData.length === 0 ? (
{t("insights.intradayPulse.empty")} @@ -254,15 +324,22 @@ export function IntradayPulseChart({ }} /> ) : null} + {/* `connectNulls={false}` (the default, spelled out here for + intent) is the honesty fix: `chartData` carries one point + per possible bucket, `null` where no reading exists, so a + gap of missing buckets breaks the line/fill instead of + interpolating a shape across hours of silence. `dot` + keeps an isolated reading visible as a point even where + it has no neighbour to connect to. */} diff --git a/src/components/medications/detail/efficacy/efficacy-tab.tsx b/src/components/medications/detail/efficacy/efficacy-tab.tsx index f3b1bede9..f5ded3a59 100644 --- a/src/components/medications/detail/efficacy/efficacy-tab.tsx +++ b/src/components/medications/detail/efficacy/efficacy-tab.tsx @@ -20,6 +20,7 @@ import { TileHeader } from "@/components/insights/tile-header"; import { Glp1PlateauNote } from "@/components/insights/glp1-plateau-note"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Select, SelectContent, @@ -85,7 +86,7 @@ export function EfficacyTab({ const timezone = user?.timezone || DEFAULT_TIMEZONE; const queryClient = useQueryClient(); - const { data, isLoading } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.medicationEfficacy(medicationId), queryFn: () => apiGet( @@ -95,7 +96,7 @@ export function EfficacyTab({ staleTime: 60_000, }); - if (isLoading || !data) { + if (isLoading) { return ( + refetch()} /> + + ); + } + // The tab is hidden upstream when ineligible; this is a defensive fallback. if (!data.eligible) { return ( diff --git a/src/hooks/use-workouts.ts b/src/hooks/use-workouts.ts index 30cbe4937..82a490a42 100644 --- a/src/hooks/use-workouts.ts +++ b/src/hooks/use-workouts.ts @@ -82,6 +82,10 @@ export interface UseWorkoutsResult { isLoading: boolean; isEmpty: boolean; error: Error | null; + /** True once the query has exhausted retries and settled on a failure. */ + isError: boolean; + /** Re-runs the query — the retry action for a failed fetch. */ + refetch: () => void; } export function useWorkouts(opts: UseWorkoutsOptions = {}): UseWorkoutsResult { @@ -109,6 +113,8 @@ export function useWorkouts(opts: UseWorkoutsOptions = {}): UseWorkoutsResult { isLoading: query.isLoading, isEmpty, error: query.error as Error | null, + isError: query.isError, + refetch: () => void query.refetch(), }; } From 5f253f7d09c360165df3a7e7424efb1b50ad4a63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 17 Jul 2026 23:35:46 +0200 Subject: [PATCH 09/33] fix(insights): make the nutrients pill reachable once the module is on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nutrients pill required BOTH the opt-in module on AND at least one NutrientIntakeDay row — but the in-context enable CTA lives on /insights/nutrients itself, so a user who had just turned the module on (or wanted to) had no way to reach the page that would let them. Drop the row-count floor for this one metric: the pill now gates on the module toggle alone, matching the deliberate opt-in the user already made. The page itself is unchanged and still degrades from "enable" to "no rows yet" to the three-card spine. --- .../insights/insights-layout-shell.tsx | 23 ++++++++----------- .../insights/insights-tab-strip.tsx | 6 +++-- src/lib/insights/metric-availability.ts | 14 +++++++---- src/lib/insights/sub-page-metric.ts | 4 ++-- 4 files changed, 24 insertions(+), 23 deletions(-) diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx index c4e34fa26..c5ad3679d 100644 --- a/src/components/insights/insights-layout-shell.tsx +++ b/src/components/insights/insights-layout-shell.tsx @@ -106,20 +106,15 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { // dashboard tile so navigation between surfaces is a free cache hit. const workoutsProbe = useWorkouts({ limit: 1 }); - // v1.29 — nutrients-presence gate. The nutrients pill gates on at - // least one `NutrientIntakeDay` row in a 1-day probe window (any - // nutrient, any source). Enabled only when the opt-in module is on — - // the route 403s "module.disabled" otherwise, so probing while off - // would just burn a request every mount. + // v1.29 — nutrients gate. `nutrients` is opt-in (default off), unlike + // the always-on metric clusters, so the pill's floor is the module + // toggle alone — not a data-presence probe. Gating on BOTH the toggle + // AND a first row was a catch-22: the in-context enable CTA lives on + // `/insights/nutrients` itself, and that page was only reachable once + // data already existed. A user who just flipped the module on (or + // wants to) can now always reach the page; the page itself still + // degrades from "enable" → "no rows yet" → the three-card spine. const nutrientsEnabled = user?.modules?.nutrients === true; - const nutrientsProbe = useQuery({ - queryKey: queryKeys.nutrientIntake(1), - queryFn: () => - apiGet<{ nutrients: Array<{ nutrient: string }> }>( - "/api/nutrients?days=1", - ), - enabled: isAuthenticated && nutrientsEnabled, - }); // v1.4.31 — memoise the `availability` prop so an unchanged // payload doesn't recreate the object on every cache-write of @@ -132,7 +127,7 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { const hasMood = (comprehensiveQuery.data?.moodSummary?.count ?? 0) > 0; const hasMedication = (comprehensiveQuery.data?.medications?.length ?? 0) > 0; const hasWorkouts = (workoutsProbe.data?.workouts.length ?? 0) > 0; - const hasNutrients = (nutrientsProbe.data?.nutrients.length ?? 0) > 0; + const hasNutrients = nutrientsEnabled; const availability: InsightInputs | undefined = useMemo(() => { if (!isAuthenticated) return undefined; return { summaries, hasMood, hasMedication, hasWorkouts, hasNutrients }; diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index 34339ce0b..d2fd0060b 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -366,8 +366,10 @@ const SUB_PAGE_MODULE: Partial> = { "breathing-disturbances": "sleep", "blood-glucose": "glucose", workouts: "workouts", - // v1.29 — the nutrients pill gates on the opt-in `nutrients` module on - // top of the `hasNutrients` data floor. + // v1.29 — the nutrients pill gates on the opt-in `nutrients` module + // alone; there is no data floor on top (see `hasNutrients` in + // `insights-layout-shell.tsx` — reachability, not row count, is the + // gate once the user has opted in). nutrients: "nutrients", }; diff --git a/src/lib/insights/metric-availability.ts b/src/lib/insights/metric-availability.ts index 434655e91..74e790e44 100644 --- a/src/lib/insights/metric-availability.ts +++ b/src/lib/insights/metric-availability.ts @@ -123,7 +123,8 @@ export type InsightMetric = // v1.29 — nutrient intake (hydration + micronutrients). Event/store // driven (`NutrientIntakeDay`, not a `Measurement` series) like // `MOOD` / `MEDICATION` / `WORKOUTS`, so it reads the boolean - // `hasNutrients` flag rather than a `summaries[…].count`. + // `hasNutrients` flag rather than a `summaries[…].count` — and unlike + // those, the flag tracks the opt-in module toggle, not row presence. | "NUTRIENTS"; /** @@ -147,10 +148,13 @@ export interface InsightInputs { */ hasWorkouts?: boolean; /** - * v1.29 — whether the user has at least one `NutrientIntakeDay` row in - * the probe window (any nutrient, any source). Drives the nutrients - * pill. Optional so legacy mounts keep type-checking; the helper - * treats `undefined` as "not available". + * v1.29 — whether the opt-in `nutrients` module is on. Drives the + * nutrients pill directly (no row-count floor on top): the module is + * a deliberate user choice, and the in-context enable CTA lives on + * `/insights/nutrients` itself, so the pill must stay reachable the + * moment the module is on even with zero rows recorded yet. Optional + * so legacy mounts keep type-checking; the helper treats `undefined` + * as "not available". */ hasNutrients?: boolean; } diff --git a/src/lib/insights/sub-page-metric.ts b/src/lib/insights/sub-page-metric.ts index 16180fd6a..bbe2bc391 100644 --- a/src/lib/insights/sub-page-metric.ts +++ b/src/lib/insights/sub-page-metric.ts @@ -138,8 +138,8 @@ const SUB_PAGE_METRIC = { // v1.29 — nutrient intake (hydration + micronutrients). Backed by // `NutrientIntakeDay`, not a `Measurement` series, so the metric list // stays empty like `medications` / `workouts`; the tab-strip pill gates - // on `hasNutrients` (threaded from a lightweight probe read) instead of - // a `summaries[…].count`. + // on `hasNutrients`, which tracks the opt-in module toggle rather than + // a `summaries[…].count` or a row-presence probe. nutrients: [], } as const satisfies Record; From bf1c052797b8c1605e612aa749fc6c7aecf4e588 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 17 Jul 2026 23:49:09 +0200 Subject: [PATCH 10/33] feat(insights): add a metric catalog for discoverability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Metrics with zero data stay hidden from the dashboard and Insights nav by design — that keeps the daily views uncluttered but leaves no reachable surface telling a sparse-device-kit account which capabilities exist at all. Add /insights/catalog: every metric domain grouped by category, each row showing whether the account has data for it and what would supply it, with a connect CTA into settings/integrations when absent and device-sourceable. Reuses the existing tab-strip gating (hasMetricData over the shared analytics/comprehensive/workouts/nutrients reads) and the per-metric empty-state copy already shown on each sub-page, so names, sources, and presence can't drift from what's shown elsewhere. Adds one quiet, always-visible pill at the tail of the Insights tab strip as the entry point — no new loud nav destination. --- messages/de.json | 17 + messages/en.json | 17 + messages/es.json | 17 + messages/fr.json | 17 + messages/it.json | 17 + messages/pl.json | 17 + src/app/insights/catalog/page.tsx | 297 ++++++++++++++++++ .../insights/insights-tab-strip.tsx | 13 + src/lib/insights/metric-catalog.ts | 244 ++++++++++++++ 9 files changed, 656 insertions(+) create mode 100644 src/app/insights/catalog/page.tsx create mode 100644 src/lib/insights/metric-catalog.ts diff --git a/messages/de.json b/messages/de.json index 44ba5ee3b..918bf3db3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -586,6 +586,22 @@ "typeWho5Score": "WHO-5-Wert", "typeSciScore": "SCI-Wert" }, + "metricCatalog": { + "title": "Was HealthLog erfassen kann", + "description": "Alle Messwert-Bereiche, die HealthLog unterstützt — auch die, für die noch keine Daten vorliegen — und was sie liefert.", + "statusPresent": "Erfasst", + "statusAbsent": "Noch nicht erfasst", + "statusModuleOff": "Aus", + "connectCta": "Quelle verbinden", + "viewCta": "Ansehen", + "ecg": { + "source": "Einkanal-EKG-Streifen von einer kompatiblen Uhr — aktuell Withings ScanWatch — werden automatisch synchronisiert." + }, + "watchdog": { + "title": "Automatische Überwachung", + "description": "HealthLog beobachtet außerdem still im Hintergrund — Benachrichtigungen zu unregelmäßigem Rhythmus und Atemstörungen von einer verbundenen Uhr sowie Warnungen bei Abweichungen von deiner Baseline — und zeigt eine Karte nur, wenn etwas auffällt. Keine Karte ist ein gutes Zeichen, kein fehlendes Feature." + } + }, "mood": { "title": "Stimmung", "subtitle": "Stimmungsverlauf und Einträge", @@ -2486,6 +2502,7 @@ "relativeDaysAgoOne": "vor {count} Tag", "relativeDaysAgoOther": "vor {count} Tagen", "navOverview": "Übersicht", + "navCatalog": "Alle Messwerte", "navBloodPressure": "Blutdruck", "navWeight": "Gewicht", "navPulse": "Puls", diff --git a/messages/en.json b/messages/en.json index 6346aa9bb..fe4d78afc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -586,6 +586,22 @@ "typeWho5Score": "WHO-5 score", "typeSciScore": "SCI score" }, + "metricCatalog": { + "title": "What HealthLog can track", + "description": "Every metric domain HealthLog supports, whether or not you have data for it yet — and what would supply it.", + "statusPresent": "Tracked", + "statusAbsent": "Not tracked yet", + "statusModuleOff": "Off", + "connectCta": "Connect a source", + "viewCta": "View", + "ecg": { + "source": "Single-lead ECG strips from a compatible watch — Withings ScanWatch today — synced automatically." + }, + "watchdog": { + "title": "Automatic screening", + "description": "HealthLog also watches quietly in the background — irregular-rhythm and breathing-disturbance notifications from a connected watch, plus baseline-drift alerts — and surfaces a card only when something stands out. No card showing is a good sign, not a missing feature." + } + }, "mood": { "title": "Mood", "subtitle": "Mood tracker and entries", @@ -2486,6 +2502,7 @@ "relativeDaysAgoOne": "{count} day ago", "relativeDaysAgoOther": "{count} days ago", "navOverview": "Overview", + "navCatalog": "All metrics", "navBloodPressure": "Blood Pressure", "navWeight": "Weight", "navPulse": "Pulse", diff --git a/messages/es.json b/messages/es.json index 36f54cb24..0b958601a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -586,6 +586,22 @@ "typeWho5Score": "Puntuación WHO-5", "typeSciScore": "Puntuación SCI" }, + "metricCatalog": { + "title": "Lo que HealthLog puede registrar", + "description": "Todos los ámbitos de métricas que admite HealthLog, tengas datos o no todavía, y qué los proporciona.", + "statusPresent": "Registrado", + "statusAbsent": "Aún sin registrar", + "statusModuleOff": "Desactivado", + "connectCta": "Conectar una fuente", + "viewCta": "Ver", + "ecg": { + "source": "Tiras de ECG de una derivación desde un reloj compatible — hoy, Withings ScanWatch — sincronizadas automáticamente." + }, + "watchdog": { + "title": "Supervisión automática", + "description": "HealthLog también vigila en segundo plano — notificaciones de ritmo irregular y alteraciones respiratorias de un reloj conectado, además de alertas de desviación de tu valor de referencia — y solo muestra una tarjeta cuando algo destaca. Que no aparezca ninguna tarjeta es una buena señal, no una función ausente." + } + }, "mood": { "title": "Estado de ánimo", "subtitle": "Tendencia y registros de estado de ánimo", @@ -2486,6 +2502,7 @@ "relativeDaysAgoOne": "hace {count} día", "relativeDaysAgoOther": "hace {count} días", "navOverview": "Resumen", + "navCatalog": "Todas las métricas", "navBloodPressure": "Presión arterial", "navWeight": "Peso", "navPulse": "Pulso", diff --git a/messages/fr.json b/messages/fr.json index 14420754a..b0e846e42 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -586,6 +586,22 @@ "typeWho5Score": "Score WHO-5", "typeSciScore": "Score SCI" }, + "metricCatalog": { + "title": "Ce que HealthLog peut suivre", + "description": "Tous les domaines de mesures pris en charge par HealthLog, que tu aies déjà des données ou non — et ce qui les fournirait.", + "statusPresent": "Suivi", + "statusAbsent": "Pas encore suivi", + "statusModuleOff": "Désactivé", + "connectCta": "Connecter une source", + "viewCta": "Voir", + "ecg": { + "source": "Tracés ECG à une dérivation depuis une montre compatible — Withings ScanWatch aujourd'hui — synchronisés automatiquement." + }, + "watchdog": { + "title": "Surveillance automatique", + "description": "HealthLog surveille aussi discrètement en arrière-plan — notifications de rythme irrégulier et de perturbations respiratoires envoyées par une montre connectée, ainsi que des alertes d'écart par rapport à ta valeur de référence — et n'affiche une carte que si quelque chose se démarque. L'absence de carte est bon signe, pas une fonctionnalité manquante." + } + }, "mood": { "title": "Humeur", "subtitle": "Tendance et entrées d’humeur", @@ -2486,6 +2502,7 @@ "relativeDaysAgoOne": "il y a {count} jour", "relativeDaysAgoOther": "il y a {count} jours", "navOverview": "Vue d'ensemble", + "navCatalog": "Toutes les mesures", "navBloodPressure": "Tension artérielle", "navWeight": "Poids", "navPulse": "Pouls", diff --git a/messages/it.json b/messages/it.json index 639145a51..a349f00e3 100644 --- a/messages/it.json +++ b/messages/it.json @@ -586,6 +586,22 @@ "typeWho5Score": "Punteggio WHO-5", "typeSciScore": "Punteggio SCI" }, + "metricCatalog": { + "title": "Cosa può monitorare HealthLog", + "description": "Tutti gli ambiti di metriche supportati da HealthLog, che tu abbia già dei dati oppure no — e cosa li fornirebbe.", + "statusPresent": "Monitorato", + "statusAbsent": "Non ancora monitorato", + "statusModuleOff": "Disattivato", + "connectCta": "Collega una fonte", + "viewCta": "Visualizza", + "ecg": { + "source": "Tracciati ECG a una derivazione da un orologio compatibile — oggi Withings ScanWatch — sincronizzati automaticamente." + }, + "watchdog": { + "title": "Monitoraggio automatico", + "description": "HealthLog osserva anche silenziosamente in background — notifiche di ritmo irregolare e disturbi respiratori da un orologio connesso, oltre ad avvisi di scostamento dalla tua base di riferimento — mostrando una scheda solo quando qualcosa emerge. Nessuna scheda visibile è un buon segno, non una funzione mancante." + } + }, "mood": { "title": "Umore", "subtitle": "Andamento e voci dell’umore", @@ -2486,6 +2502,7 @@ "relativeDaysAgoOne": "{count} giorno fa", "relativeDaysAgoOther": "{count} giorni fa", "navOverview": "Panoramica", + "navCatalog": "Tutte le metriche", "navBloodPressure": "Pressione arteriosa", "navWeight": "Peso", "navPulse": "Polso", diff --git a/messages/pl.json b/messages/pl.json index 996d46c39..e10f195fe 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -586,6 +586,22 @@ "typeWho5Score": "Wynik WHO-5", "typeSciScore": "Wynik SCI" }, + "metricCatalog": { + "title": "Co HealthLog potrafi śledzić", + "description": "Wszystkie obszary metryk obsługiwane przez HealthLog — również te, dla których nie masz jeszcze danych — oraz co je dostarcza.", + "statusPresent": "Śledzone", + "statusAbsent": "Jeszcze nieśledzone", + "statusModuleOff": "Wyłączone", + "connectCta": "Połącz źródło", + "viewCta": "Zobacz", + "ecg": { + "source": "Jednoodprowadzeniowe zapisy EKG z kompatybilnego zegarka — obecnie Withings ScanWatch — synchronizowane automatycznie." + }, + "watchdog": { + "title": "Automatyczny nadzór", + "description": "HealthLog dyskretnie obserwuje też dane w tle — powiadomienia o nieregularnym rytmie i zaburzeniach oddychania z podłączonego zegarka, a także alerty o odchyleniu od Twojej wartości bazowej — i pokazuje kartę tylko wtedy, gdy coś zwraca uwagę. Brak karty to dobry znak, a nie brakująca funkcja." + } + }, "mood": { "title": "Nastrój", "subtitle": "Trend i wpisy nastroju", @@ -2486,6 +2502,7 @@ "relativeDaysAgoOne": "{count} dzień temu", "relativeDaysAgoOther": "{count} dni temu", "navOverview": "Przegląd", + "navCatalog": "Wszystkie metryki", "navBloodPressure": "Ciśnienie krwi", "navWeight": "Waga", "navPulse": "Tętno", diff --git a/src/app/insights/catalog/page.tsx b/src/app/insights/catalog/page.tsx new file mode 100644 index 000000000..aa6b0be7a --- /dev/null +++ b/src/app/insights/catalog/page.tsx @@ -0,0 +1,297 @@ +"use client"; + +import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; +import { + Activity, + ChevronRight, + Droplet, + Ear, + Footprints, + HeartPulse, + Moon, + Pill, + Scale, + Siren, + Sun, + Waves, + type LucideIcon, +} from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; +import { SubPageShell } from "@/components/insights/sub-page-shell"; +import { useAuth } from "@/hooks/use-auth"; +import { useWorkouts } from "@/hooks/use-workouts"; +import { useTranslations } from "@/lib/i18n/context"; +import { apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; +import { + hasMetricData, + type InsightInputs, +} from "@/lib/insights/metric-availability"; +import { + CATALOG_GROUP_HEADER_KEYS, + CATALOG_GROUP_ORDER, + catalogEntriesByGroup, + type CatalogEntry, +} from "@/lib/insights/metric-catalog"; +import type { ManagerGroup } from "@/lib/insights/sub-page-metric"; + +/** + * `/insights/catalog` — "what can HealthLog track" (2026-07-17 + * discoverability audit, recommendation R1). + * + * A single, on-purpose surface: every metric domain HealthLog supports, + * grouped, each row showing its name, whether THIS account has data for + * it yet, what supplies it (device-concrete, vendor-neutral), and — when + * absent and a device could supply it — a quiet CTA into + * `/settings/integrations`. This is deliberately NOT surfaced in the + * daily nav: metrics with no data stay hidden from the dashboard / + * Insights tab strip by design (`metric-availability.ts` — "a metric + * with zero observations doesn't surface a navigation target the user + * can't act on"), so an account with a sparse device kit would otherwise + * have no reachable evidence a capability exists at all. This page is + * that evidence, reached only when the user navigates here on purpose + * (the quiet "All metrics" pill at the tail of the Insights tab strip, + * see `insights-tab-strip.tsx`). + * + * Presence per row reuses the EXACT gating inputs the tab strip / nav + * pills already compute (`hasMetricData` over the shared analytics + + * comprehensive + workouts + nutrients reads) — this page adds no new + * server route and no new derivation of "does the user have this metric". + * The ECG row is the one exception: ECG carries no `MeasurementType`, so + * its presence comes from the same `hasRecordings` probe the overview's + * `` already reads. + */ + +interface ComprehensivePayload { + moodSummary: { count: number } | null; + medications: Array<{ id: string }>; +} + +const GROUP_ICONS: Record = { + vitals: Activity, + body: Scale, + activity: Footprints, + sleep: Moon, + cardiovascular: HeartPulse, + hearing: Ear, + environment: Sun, + metabolic: Droplet, + mood: Waves, + events: Pill, +}; + +function StatusBadge({ present }: { present: boolean }) { + const { t } = useTranslations(); + if (present) { + return ( + + {t("metricCatalog.statusPresent")} + + ); + } + return ( + + {t("metricCatalog.statusAbsent")} + + ); +} + +function CatalogRow({ + entry, + present, +}: { + entry: CatalogEntry; + present: boolean | null; +}) { + const { t } = useTranslations(); + + if (entry.kind === "info") { + return ( +
+ +
+

+ {t(entry.nameKey)} +

+

{t(entry.sourceKey)}

+
+
+ ); + } + + const isPresent = present === true; + + return ( +
+
+
+ + {t(entry.nameKey)} + + +
+

{t(entry.sourceKey)}

+
+
+ {isPresent && entry.href ? ( + + ) : !isPresent && entry.connectable ? ( + + ) : !isPresent && entry.manualHref && entry.manualCtaKey ? ( + + ) : null} +
+
+ ); +} + +export default function MetricCatalogPage() { + const { t } = useTranslations(); + const { isAuthenticated, user } = useAuth(); + + const analyticsQuery = useAnalyticsQuery({ slice: "summaries" }); + + const comprehensiveQuery = useQuery({ + queryKey: queryKeys.insightsComprehensive(), + queryFn: () => apiGet("/api/insights/comprehensive"), + enabled: isAuthenticated, + }); + + const workoutsProbe = useWorkouts({ limit: 1 }); + + const nutrientsModuleEnabled = user?.modules?.nutrients === true; + const nutrientsProbe = useQuery({ + queryKey: queryKeys.nutrientIntake(1), + queryFn: () => + apiGet<{ nutrients: Array<{ nutrient: string }> }>( + "/api/nutrients?days=1", + ), + enabled: isAuthenticated && nutrientsModuleEnabled, + }); + + const ecgQuery = useQuery({ + queryKey: queryKeys.insightsEcgList(), + queryFn: () => apiGet<{ hasRecordings: boolean }>("/api/insights/ecg"), + enabled: isAuthenticated, + }); + + const availability: InsightInputs | undefined = isAuthenticated + ? { + summaries: analyticsQuery.data?.summaries, + hasMood: (comprehensiveQuery.data?.moodSummary?.count ?? 0) > 0, + hasMedication: (comprehensiveQuery.data?.medications?.length ?? 0) > 0, + hasWorkouts: (workoutsProbe.data?.workouts.length ?? 0) > 0, + hasNutrients: (nutrientsProbe.data?.nutrients.length ?? 0) > 0, + } + : undefined; + + const grouped = catalogEntriesByGroup(); + + function isPresent(entry: CatalogEntry): boolean | null { + if (entry.kind === "info") return null; + if (entry.id === "ecg") return ecgQuery.data?.hasRecordings === true; + if (!availability || !entry.metric) return false; + return hasMetricData(entry.metric, availability); + } + + return ( + +
+ {CATALOG_GROUP_ORDER.map((group) => { + const entries = grouped.get(group) ?? []; + if (entries.length === 0) return null; + const Icon = GROUP_ICONS[group]; + // Nutrients rides the metabolic group but needs a module-off + // branch (B1 — the tab-strip pill requires the module ON before + // it ever probes for rows, so a module-off account never saw + // the in-context enable CTA; this row is the fix). + return ( + + + + + +
+ {entries.map((entry) => { + if (entry.id === "nutrients" && !nutrientsModuleEnabled) { + return ( +
+
+
+ + {t(entry.nameKey)} + + + {t("metricCatalog.statusModuleOff")} + +
+

+ {t("nutrients.page.moduleOffDescription")} +

+
+
+ +
+
+ ); + } + return ( + + ); + })} +
+
+
+ ); + })} +
+
+ ); +} diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index d2fd0060b..6299c83f8 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -551,6 +551,19 @@ function buildTabs( labelKey: SUB_PAGE_TABS[slug].labelKey, }); } + + // 2026-07-17 discoverability audit (R1) — a quiet, always-present pill + // trailing every other pill, linking to the "what can HealthLog track" + // catalog. Unlike every metric pill above, it carries no availability + // gate (like Overview / Recovery): the catalog's whole purpose is + // listing the metrics that AREN'T visible elsewhere yet, so gating its + // own entry point on data would defeat it. + entries.push({ + kind: "link", + href: `${INSIGHTS_OVERVIEW_PATH}/catalog`, + labelKey: "insights.navCatalog", + }); + return entries; } diff --git a/src/lib/insights/metric-catalog.ts b/src/lib/insights/metric-catalog.ts new file mode 100644 index 000000000..a88c07ecc --- /dev/null +++ b/src/lib/insights/metric-catalog.ts @@ -0,0 +1,244 @@ +/** + * v1.28.x — the metric catalog data model. + * + * Backs `/insights/catalog`: a single, on-purpose surface answering "what + * can HealthLog track, and what feeds it" for the ~90 metric types the + * primary Insights nav deliberately hides while they carry zero data (see + * `metric-availability.ts` docblock — "a metric with zero observations + * doesn't surface a navigation target the user can't act on"). That floor + * is correct for the daily nav; it also means an account with a sparse + * device kit has NO reachable surface that says a capability exists at + * all (2026-07-17 discoverability audit, "the availability floor" finding). + * This module is the one place that lists every capability regardless of + * whether the account has data for it yet. + * + * Every entry's name + source copy is SOURCED from the existing, already- + * localised per-metric empty-state strings — never hand-duplicated here. + * `insights..emptyState.description` (the `HealthKitMetricPage` + * pattern) or `insights.emptyState..description` (the seven + * specialised metrics) already say honestly, per metric, what supplies it + * ("HRV samples land here once Apple Watch — or a compatible Withings + * device — starts syncing nightly readings."). Reusing them means the + * catalog cannot drift from the copy shown on the metric's own empty + * state, and adding a new sub-page's source line is a one-place change + * (write its `emptyState.description`; the catalog picks it up via the + * same derivation the tab strip already uses for the pill label). + */ +import type { InsightMetric } from "@/lib/insights/metric-availability"; +import { + INSIGHTS_OVERVIEW_PATH, + MANAGER_GROUP_ORDER, + SUB_PAGE_MANAGER_GROUP, + SUB_PAGE_SLUGS, + type ManagerGroup, + type SubPageSlug, +} from "@/lib/insights/sub-page-metric"; +import { SUB_PAGE_TABS } from "@/components/insights/insights-tab-strip"; + +export type CatalogEntryKind = "metric" | "info"; + +export interface CatalogEntry { + /** Stable row id — the sub-page slug for routed metrics, a bespoke id otherwise. */ + id: string; + group: ManagerGroup; + /** i18n key resolving to the display name (reused from the tab-strip pill label). */ + nameKey: string; + /** i18n key resolving to the one-line "what supplies this" copy. */ + sourceKey: string; + kind: CatalogEntryKind; + /** `kind === "metric"` — the gating metric `hasMetricData` reads. Absent for the ECG row, which resolves presence from its own probe. */ + metric?: InsightMetric; + /** Route to the metric's own page, once it has data (or always, for ECG). */ + href?: string; + /** Whether an absent row offers the "connect a source" CTA into `/settings/integrations`. */ + connectable: boolean; + /** Manual-only metrics (no device path) offer a log CTA instead. */ + manualHref?: string; + manualCtaKey?: string; +} + +/** + * Group section heading — the descriptive `.header` copy where the tab + * strip's grouped popover carries one (e.g. "Vital signs" vs the pill's + * short "Vitals"), the plain manager-group label otherwise. Reused + * verbatim from the existing tab-strip / pill-order-manager namespace — + * zero new group-heading strings. + */ +export const CATALOG_GROUP_HEADER_KEYS: Record = { + vitals: "insights.tabStrip.vitalsParent.header", + body: "insights.tabStrip.bodyParent.header", + activity: "insights.tabStrip.activityParent.header", + sleep: "insights.editMode.groupSleep", + cardiovascular: "insights.tabStrip.cardiovascularParent.header", + hearing: "insights.tabStrip.hearingParent.header", + environment: "insights.tabStrip.environmentParent.header", + metabolic: "insights.tabStrip.metabolicParent.header", + mood: "insights.editMode.groupMood", + events: "insights.editMode.groupEvents", +}; + +export const CATALOG_GROUP_ORDER: readonly ManagerGroup[] = MANAGER_GROUP_ORDER; + +/** + * The eight slugs whose source copy does NOT live at + * `insights..emptyState.description` (the `HealthKitMetricPage` + * convention) — the seven specialised metrics carry it at + * `insights.emptyState..description` instead, and nutrients (event- + * driven, module-gated) carries it under its own page namespace. Every + * value below is an EXISTING key already shown on that metric's own + * empty-state branch — see `src/app/insights//page.tsx`. + */ +const SOURCE_KEY_OVERRIDES: Partial> = { + "blood-pressure": "insights.emptyState.bloodPressure.description", + pulse: "insights.emptyState.pulse.description", + weight: "insights.emptyState.weight.description", + bmi: "insights.emptyState.bmi.description", + sleep: "insights.emptyState.sleep.description", + mood: "insights.emptyState.mood.description", + medications: "insights.emptyState.medication.description", + nutrients: "nutrients.page.emptyDescription", + // The nav-key → emptyState-key derivation (below) strips the `nav` + // prefix and lowercases the first letter; these two sub-pages' empty + // state key is shorter than their nav label suffix (`navPainNrs` → + // `pain`, not `painNrs`; `navWaistCircumference` → `waist`, not + // `waistCircumference`), so they need an explicit override. + pain: "insights.pain.emptyState.description", + waist: "insights.waist.emptyState.description", +}; + +/** + * Metrics with no device path at all — manual entry is the only source. + * Mood and medications are inherently self-report (no sensor produces a + * mood score or an adherence event). Pain, grip strength, and waist-to- + * height ratio are ALSO manual-only despite sitting beside device-fed + * siblings in their group: none carries an Apple Health mapping + * (`apple-health-mapping.ts`) or a Withings/wearable source — pain is a + * 0–10 self-rating, grip strength is read off a hand dynamometer, and + * waist-to-height is logged directly (not derived like BMI). Cross- + * checked against every routed sub-page's `emptyStateCtaType` + + * `emptyState.description` copy so this list can't silently drift from + * what the metric's own empty state actually offers. + */ +const MANUAL_ONLY_SLUGS: ReadonlySet = new Set([ + "mood", + "medications", + "pain", + "grip-strength", + "waist-to-height", +]); + +const MANUAL_CTA: Partial< + Record +> = { + mood: { href: "/mood", ctaKey: "insights.emptyState.mood.cta" }, + medications: { + href: "/medications", + ctaKey: "insights.emptyState.medication.cta", + }, + pain: { + href: "/measurements?add=PAIN_NRS", + ctaKey: "insights.pain.emptyState.cta", + }, + "grip-strength": { + href: "/measurements?add=GRIP_STRENGTH", + ctaKey: "insights.gripStrength.emptyState.cta", + }, + "waist-to-height": { + href: "/measurements?add=WAIST_TO_HEIGHT", + ctaKey: "insights.waistToHeight.emptyState.cta", + }, +}; + +/** + * Derive the `insights..emptyState.*` namespace segment from a + * `SUB_PAGE_TABS` nav label key (`insights.navOxygenSaturation` → + * `oxygenSaturation`). Mirrors the pill label exactly, so a slug whose nav + * key and empty-state key already agree (41 of the 49 routed sub-pages) + * needs no entry in `SOURCE_KEY_OVERRIDES` at all. + */ +function camelFromNavLabelKey(labelKey: string): string { + const suffix = labelKey.replace(/^insights\.nav/, ""); + return suffix.charAt(0).toLowerCase() + suffix.slice(1); +} + +function buildMetricEntries(): CatalogEntry[] { + return SUB_PAGE_SLUGS.map((slug) => { + const tab = SUB_PAGE_TABS[slug]; + const group = SUB_PAGE_MANAGER_GROUP[slug]; + const camel = camelFromNavLabelKey(tab.labelKey); + const sourceKey = + SOURCE_KEY_OVERRIDES[slug] ?? `insights.${camel}.emptyState.description`; + const manual = MANUAL_CTA[slug]; + const entry: CatalogEntry = { + id: slug, + group, + nameKey: tab.labelKey, + sourceKey, + kind: "metric", + metric: tab.metric, + href: `${INSIGHTS_OVERVIEW_PATH}/${slug}`, + connectable: !MANUAL_ONLY_SLUGS.has(slug), + }; + if (manual) { + entry.manualHref = manual.href; + entry.manualCtaKey = manual.ctaKey; + } + return entry; + }); +} + +/** + * ECG recordings (v1.28.50) carry no `MeasurementType` — the section reads + * a dedicated `hasRecordings` flag off `GET /api/insights/ecg`, not + * `summaries[…].count` — so it is not a `SUB_PAGE_SLUGS` entry and needs + * its own catalog row. Source is Withings ScanWatch today (see + * `.planning/ios-coord/v1.28.50-server-to-ios-ecg.md`) — Apple Watch ECG + * (`HKElectrocardiogramType`) is explicitly deferred in the HealthKit + * ingest map, so the copy names ScanWatch only rather than overclaiming. + */ +const ECG_ENTRY: CatalogEntry = { + id: "ecg", + group: "cardiovascular", + nameKey: "insights.ecg.sectionTitle", + sourceKey: "metricCatalog.ecg.source", + kind: "metric", + href: INSIGHTS_OVERVIEW_PATH, + connectable: true, +}; + +/** + * Discoverability audit finding A5 — the alert-style watchdogs (rhythm- + * event notifications, breathing-disturbance screening, baseline-drift + * health status) are CORRECT to stay unmounted when there is nothing to + * flag ("no alert" must not render an alarming empty shell), but nothing + * anywhere told the user they were running. This is the "single line" + * fix the audit recommended in place of per-card empty states: an + * always-shown info row, no present/absent gate, no CTA. + */ +const WATCHDOG_ENTRY: CatalogEntry = { + id: "watchdog", + group: "cardiovascular", + nameKey: "metricCatalog.watchdog.title", + sourceKey: "metricCatalog.watchdog.description", + kind: "info", + connectable: false, +}; + +export const METRIC_CATALOG_ENTRIES: readonly CatalogEntry[] = [ + ...buildMetricEntries(), + ECG_ENTRY, + WATCHDOG_ENTRY, +]; + +/** Group the catalog entries in canonical manager-group order. */ +export function catalogEntriesByGroup(): ReadonlyMap< + ManagerGroup, + CatalogEntry[] +> { + const map = new Map(); + for (const group of MANAGER_GROUP_ORDER) map.set(group, []); + for (const entry of METRIC_CATALOG_ENTRIES) { + map.get(entry.group)?.push(entry); + } + return map; +} From 844b24c7bacf83b8bac8bd81e84b8e37d22117d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 00:35:02 +0200 Subject: [PATCH 11/33] fix(daily-loop): invalidate the Today digest on measurement/mood/water writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manual measurement, mood, and water writes never carried queryKeys.dailyDigest() in their invalidation bundles — only medication intake did (v1.29.1). The digest query also runs with refetchOnMount/WindowFocus off, so a write from the values list, the mood list, or the hydration quick-add sheet left the Today hero's score and rail items stale until a hard reload. Adds dailyDigest() to measurementDependentKeys and moodDependentKeys, and adds a shared refetchInactiveDailyReads() helper (mirrors the existing medication seam) that forces the dashboard snapshot and digest to refetch even while unmounted. Every measurement/mood/water write call site now runs it alongside its existing *DependentKeys bundle. --- .../nutrients/water-quick-add-sheet.tsx | 12 ++++- .../measurements/measurement-form.tsx | 7 ++- .../measurements/measurement-list.tsx | 5 +++ src/components/mood/mood-form.tsx | 7 ++- src/components/mood/mood-list.tsx | 11 ++++- src/lib/__tests__/query-keys.test.ts | 42 +++++++++++++++++ src/lib/query-keys/index.ts | 45 +++++++++++++++++++ 7 files changed, 125 insertions(+), 4 deletions(-) diff --git a/src/components/insights/nutrients/water-quick-add-sheet.tsx b/src/components/insights/nutrients/water-quick-add-sheet.tsx index 2b60136cd..78396e516 100644 --- a/src/components/insights/nutrients/water-quick-add-sheet.tsx +++ b/src/components/insights/nutrients/water-quick-add-sheet.tsx @@ -10,7 +10,7 @@ import { Label } from "@/components/ui/label"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { useTranslations } from "@/lib/i18n/context"; import { apiPost } from "@/lib/api/api-fetch"; -import { queryKeys } from "@/lib/query-keys"; +import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys"; /** * v1.29 — manual water quick-add sheet, opened from the hydration card. @@ -45,11 +45,21 @@ export function WaterQuickAddSheet({ const [editMode, setEditMode] = useState(false); const [editAmount, setEditAmount] = useState(() => String(todayTotalMl)); + // v1.29.x — the Today digest joins the invalidation. The write route + // already hard-evicts the server snapshot bucket + // (`invalidateUserDashboardSnapshot`), but the client digest/snapshot + // queries are typically unmounted while this sheet is open (the user is + // on `/insights/nutrients`, not the dashboard), so a default ("active") + // invalidation would mark them stale without refetching them — + // `refetchInactiveDailyReads` forces the inactive refetch, mirroring the + // measurement / mood / medication fix. const invalidate = () => { void queryClient.invalidateQueries({ queryKey: ["nutrients"] }); void queryClient.invalidateQueries({ queryKey: queryKeys.dashboardSnapshot(), }); + void queryClient.invalidateQueries({ queryKey: queryKeys.dailyDigest() }); + void refetchInactiveDailyReads(queryClient); }; const addMutation = useMutation({ diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx index d415d1c8c..972036a3d 100644 --- a/src/components/measurements/measurement-form.tsx +++ b/src/components/measurements/measurement-form.tsx @@ -24,7 +24,11 @@ import { import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react"; import { toast } from "sonner"; import { useTranslations } from "@/lib/i18n/context"; -import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys"; +import { + invalidateKeys, + measurementDependentKeys, + refetchInactiveDailyReads, +} from "@/lib/query-keys"; import { ApiError, apiPost } from "@/lib/api/api-fetch"; import { MEASUREMENT_NOTES_MAX_LENGTH } from "@/lib/validations/measurement"; @@ -326,6 +330,7 @@ export function MeasurementForm({ setPulse(""); setNotes(""); await invalidateKeys(queryClient, measurementDependentKeys); + await refetchInactiveDailyReads(queryClient); toast.success(t("common.saved")); onSuccess?.(); } catch (err) { diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 03cfed717..67ec1c0f6 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -66,6 +66,7 @@ import { invalidateKeys, measurementDependentKeys, queryKeys, + refetchInactiveDailyReads, } from "@/lib/query-keys"; import { MEASUREMENT_NOTES_MAX_LENGTH, @@ -544,6 +545,7 @@ export function MeasurementList({ try { await apiPost("/api/measurements/restore", { ids }); await invalidateKeys(queryClient, measurementDependentKeys); + await refetchInactiveDailyReads(queryClient); toast.success(t("measurements.restoredToast")); } catch { toast.error(t("measurements.restoreError")); @@ -558,6 +560,7 @@ export function MeasurementList({ }, onSuccess: (_data, id) => { void invalidateKeys(queryClient, measurementDependentKeys); + void refetchInactiveDailyReads(queryClient); toast.success(t("measurements.deletedToast"), { action: { label: t("common.undo"), @@ -587,6 +590,7 @@ export function MeasurementList({ }, onSuccess: async (deleted, ids) => { await invalidateKeys(queryClient, measurementDependentKeys); + await refetchInactiveDailyReads(queryClient); clearSelection(); toast.success( t("measurements.bulkDeleteSuccess", { count: String(deleted) }), @@ -649,6 +653,7 @@ export function MeasurementList({ }, onSuccess: async () => { await invalidateKeys(queryClient, measurementDependentKeys); + await refetchInactiveDailyReads(queryClient); setEditing(null); setEditError(null); }, diff --git a/src/components/mood/mood-form.tsx b/src/components/mood/mood-form.tsx index ce8d0e536..0e5443d49 100644 --- a/src/components/mood/mood-form.tsx +++ b/src/components/mood/mood-form.tsx @@ -37,7 +37,11 @@ import { import { toast } from "sonner"; import { useTranslations } from "@/lib/i18n/context"; import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group"; -import { invalidateKeys, moodDependentKeys } from "@/lib/query-keys"; +import { + invalidateKeys, + moodDependentKeys, + refetchInactiveDailyReads, +} from "@/lib/query-keys"; import { ApiError, apiPost } from "@/lib/api/api-fetch"; import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section"; import { MoodTagPicker, type RatedFactor } from "./mood-tag-picker"; @@ -216,6 +220,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { resetForm(); setMoreTagsOpen(false); await invalidateKeys(queryClient, moodDependentKeys); + await refetchInactiveDailyReads(queryClient); toast.success(t("common.saved")); onSuccess?.(); } catch (err) { diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index 9fe2e15af..b8622fcc0 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -62,7 +62,12 @@ import { toast } from "sonner"; import { formatDateTime } from "@/lib/format"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { MOOD_LABEL_KEYS, MOOD_SCORE_BY_ENUM } from "@/lib/mood/labels"; -import { invalidateKeys, moodDependentKeys, queryKeys } from "@/lib/query-keys"; +import { + invalidateKeys, + moodDependentKeys, + queryKeys, + refetchInactiveDailyReads, +} from "@/lib/query-keys"; import { moodSourceEnum } from "@/lib/validations/moodlog"; import { Checkbox } from "@/components/ui/checkbox"; import { @@ -268,6 +273,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { try { await apiPost("/api/mood-entries/restore", { ids }); await invalidateKeys(queryClient, moodDependentKeys); + await refetchInactiveDailyReads(queryClient); toast.success(t("mood.restoredToast")); } catch { toast.error(t("mood.restoreError")); @@ -282,6 +288,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { }, onSuccess: (_data, id) => { void invalidateKeys(queryClient, moodDependentKeys); + void refetchInactiveDailyReads(queryClient); toast.success(t("mood.deletedToast"), { action: { label: t("common.undo"), @@ -309,6 +316,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { }, onSuccess: async (deleted, ids) => { await invalidateKeys(queryClient, moodDependentKeys); + await refetchInactiveDailyReads(queryClient); clearSelection(); toast.success(t("mood.bulkDeleteSuccess", { count: String(deleted) }), { action: { @@ -369,6 +377,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { }, onSuccess: async () => { await invalidateKeys(queryClient, moodDependentKeys); + await refetchInactiveDailyReads(queryClient); setEditing(null); setEditError(null); }, diff --git a/src/lib/__tests__/query-keys.test.ts b/src/lib/__tests__/query-keys.test.ts index 4539ec330..6a78c31df 100644 --- a/src/lib/__tests__/query-keys.test.ts +++ b/src/lib/__tests__/query-keys.test.ts @@ -7,6 +7,7 @@ import { moodDependentKeys, medicationDependentKeys, invalidateKeys, + refetchInactiveDailyReads, } from "../query-keys"; import type { QueryClient, QueryKey } from "@tanstack/react-query"; @@ -262,6 +263,15 @@ describe("dependent-key bundles", () => { expect(keyStrings).toContain(JSON.stringify(["dashboard", "snapshot"])); }); + // v1.29.x — a manual measurement write never invalidated the Today + // digest, so its score / rail items lingered stale after a save from + // the values list or the dashboard quick-entry sheet. Mirror the + // v1.29.1 medication fix. + it("measurementDependentKeys bundles the daily digest", () => { + const keyStrings = measurementDependentKeys.map((k) => JSON.stringify(k)); + expect(keyStrings).toContain(JSON.stringify(["daily", "digest"])); + }); + it("moodDependentKeys bundle covers mood + analytics + targets", () => { const keyStrings = moodDependentKeys.map((k) => JSON.stringify(k)); expect(keyStrings).toContain(JSON.stringify(["mood-entries"])); @@ -279,6 +289,13 @@ describe("dependent-key bundles", () => { expect(keyStrings).toContain(JSON.stringify(["dashboard", "snapshot"])); }); + // v1.29.x — mirrors the measurement fix: a manual mood write never + // invalidated the Today digest. + it("moodDependentKeys bundles the daily digest", () => { + const keyStrings = moodDependentKeys.map((k) => JSON.stringify(k)); + expect(keyStrings).toContain(JSON.stringify(["daily", "digest"])); + }); + it("medicationDependentKeys bundle covers medications + analytics + achievements", () => { const keyStrings = medicationDependentKeys.map((k) => JSON.stringify(k)); expect(keyStrings).toContain(JSON.stringify(["medications"])); @@ -467,3 +484,28 @@ describe("invalidateKeys", () => { expect(results[2].status).toBe("fulfilled"); }); }); + +// v1.29.x — the shared helper behind the measurement / mood / water fix. +// `invalidateKeys` (default `refetchType: "active"`) only refetches MOUNTED +// queries, so the dashboard hero and Today digest — both typically unmounted +// while the user is on the measurement/mood/nutrients surface — are marked +// stale but never refetched. Mirrors the v1.29.1 medication-intake fix +// (`invalidateMedicationReads`); this is the shared version every other +// write seam now calls alongside its own `*DependentKeys` bundle. +describe("refetchInactiveDailyReads", () => { + it("forces an inactive refetch of both the dashboard snapshot and the Today digest", async () => { + const invalidate = vi.fn().mockResolvedValue(undefined); + const client = { invalidateQueries: invalidate } as unknown as QueryClient; + + await refetchInactiveDailyReads(client); + + expect(invalidate).toHaveBeenCalledWith({ + queryKey: queryKeys.dashboardSnapshot(), + refetchType: "inactive", + }); + expect(invalidate).toHaveBeenCalledWith({ + queryKey: queryKeys.dailyDigest(), + refetchType: "inactive", + }); + }); +}); diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index 29b98099f..4912ee132 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -92,6 +92,14 @@ export const measurementDependentKeys = [ queryKeys.insightsTargets(), queryKeys.gamificationAchievements(), queryKeys.dashboardSnapshot(), + // v1.29.x — the Today digest reads the same snapshot ingredients a + // manual measurement write already hard-evicts server-side; joins the + // bundle so the digest's score / rail items refresh in lockstep with + // the tile strip. Call sites also force an inactive refetch (see + // `refetchInactiveDailyReads` below) since a default invalidation only + // refetches MOUNTED queries and the digest is typically unmounted + // while the user is on the measurement surface. + queryKeys.dailyDigest(), ["chart-data"] as const, // v1.8.5 — re-run the diversity-nudge clustering when readings change. ["measurement-diversity"] as const, @@ -127,6 +135,11 @@ export const moodDependentKeys = [ queryKeys.insightsTargets(), queryKeys.gamificationAchievements(), queryKeys.dashboardSnapshot(), + // v1.29.x — mirrors the measurement fix above: the Today digest reads + // the same snapshot mood block a mood write already hard-evicts + // server-side. Call sites force an inactive refetch too (see + // `refetchInactiveDailyReads`). + queryKeys.dailyDigest(), ]; /** @@ -208,3 +221,35 @@ export function invalidateKeys( keys.map((queryKey) => queryClient.invalidateQueries({ queryKey })), ); } + +/** + * Force the dashboard snapshot AND the Today digest to refetch even while + * unmounted. `invalidateKeys` (and every `*DependentKeys` bundle above) marks + * a query stale with the default `refetchType: "active"`, which only + * refetches MOUNTED queries — so a write made from the measurement / mood / + * medication surface leaves the dashboard hero and the Today digest (both + * typically unmounted while the user is elsewhere) stale until a manual + * remount re-fetches under `refetchOnMount: false`. + * + * Originates from the v1.16.11 dashboard-snapshot fix and the v1.29.1 + * medication-intake fix (`invalidateMedicationReads` in + * `components/medications/use-medication-intake.ts`, which predates this + * shared helper and keeps its own inline copy). Call this alongside + * `invalidateKeys(queryClient, xDependentKeys)` from any write whose server + * route already hard-evicts the snapshot bucket — the forced refetch then + * returns post-write data immediately, no server change needed. + */ +export async function refetchInactiveDailyReads( + queryClient: QueryClient, +): Promise { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: queryKeys.dashboardSnapshot(), + refetchType: "inactive", + }), + queryClient.invalidateQueries({ + queryKey: queryKeys.dailyDigest(), + refetchType: "inactive", + }), + ]); +} From 2b3ff0e58f9dc9dd66c996e5c393ee8686b17a4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 00:35:19 +0200 Subject: [PATCH 12/33] fix(daily-loop): thread the overdue medication id into the digest's Log-dose link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Today digest's dose-window rail item linked to the bare /medications list even though the overdue medication's id was already resolved server-side alongside its name — a tap landed the user on a generic list they then had to scan themselves. buildMedsTodayBlock now carries nextDueMedicationId; the digest's "Log dose" action links to /medications?highlight= when the id is known, falling back to the bare list for an older cached block. The medications page reads the highlight param, scrolls the matching card into view, and rings it briefly (mirrors the vault's duplicate-upload highlight) in both the card grid and the table view. Regenerates docs/api/openapi.yaml for the new medsToday field. --- docs/api/openapi.yaml | 12 ++++-- e2e/utils/mock-dashboard-snapshot.ts | 1 + src/app/medications/page.tsx | 28 +++++++++++++ .../medications/__tests__/card-parts.test.tsx | 29 +++++++++++++- .../__tests__/medication-table.test.tsx | 39 +++++++++++++++++++ .../card-parts/medication-card-body.tsx | 28 ++++++++++++- .../medications/glp1-medication-card.tsx | 8 ++++ .../medications/medication-card.tsx | 8 ++++ .../medications/medication-table.tsx | 22 ++++++++++- src/lib/daily/__tests__/digest.test.ts | 20 ++++++++++ src/lib/daily/digest.ts | 8 +++- .../dashboard/__tests__/meds-today.test.ts | 4 ++ src/lib/dashboard/__tests__/verdict.test.ts | 1 + src/lib/dashboard/meds-today.ts | 10 +++++ src/lib/openapi/routes/insights/schemas.ts | 3 +- 15 files changed, 213 insertions(+), 8 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a25fdae25..6458702f8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -23987,6 +23987,10 @@ components: anyOf: - type: string - type: "null" + nextDueMedicationId: + anyOf: + - type: string + - type: "null" required: - activeCount - scheduledToday @@ -23995,12 +23999,14 @@ components: - nextDueAt - nextDueOverdue - nextDueMedicationName + - nextDueMedicationId additionalProperties: false description: "Today's medication block: active-medication count, today-window tally (scheduled / taken / skipped), and the earliest next-due slot across active medications. `nextDueOverdue: true` marks an OPEN overdue slot - (anchor passed, still inside its catch-up band). Staleness contract: the snapshot is cache-served, so a - `nextDueAt` in the past with `nextDueOverdue: false` means the anchor passed after the snapshot was built — - render the plain day summary, never an overdue state." + (anchor passed, still inside its catch-up band). `nextDueMedicationId` deep-links a consumer straight to the + overdue medication's card. Staleness contract: the snapshot is cache-served, so a `nextDueAt` in the past + with `nextDueOverdue: false` means the anchor passed after the snapshot was built — render the plain day + summary, never an overdue state." healthScore: anyOf: - type: object diff --git a/e2e/utils/mock-dashboard-snapshot.ts b/e2e/utils/mock-dashboard-snapshot.ts index 1edbc1e6a..a01771183 100644 --- a/e2e/utils/mock-dashboard-snapshot.ts +++ b/e2e/utils/mock-dashboard-snapshot.ts @@ -279,6 +279,7 @@ export function buildMockSnapshot( nextDueAt: null, nextDueOverdue: false, nextDueMedicationName: null, + nextDueMedicationId: null, }, healthScore: options.briefing ? { score: 84, band: "green", delta: -2, restMode: null } diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index 6838d8f7a..6be2d586e 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -169,6 +169,14 @@ export default function MedicationsPage() { // the param from the URL so a manual close + refresh stays closed. const shouldOpenFromUrl = searchParams?.get("new") === "1"; const [dialogOpen, setDialogOpen] = useState(shouldOpenFromUrl); + // v1.29.x — the Today digest's "Log dose" action deep-links straight to + // the overdue medication (`/medications?highlight=`) instead of the + // bare list. Read once on mount; a scroll effect below (once the list + // resolves) brings the target card into view and rings it briefly, then + // clears the state so the ring doesn't linger indefinitely. + const [highlightId, setHighlightId] = useState( + () => searchParams?.get("highlight") ?? null, + ); // v1.14.0 — the medications-page "Add" choice. The top button now offers // two paths: log an intake (incl. a backdated one) against an existing // medication, or create a new medication (the existing wizard). @@ -213,6 +221,21 @@ export default function MedicationsPage() { refetchOnMount: "always", }); + // v1.29.x — once the highlighted card is in the DOM, scroll it into view + // and clear the highlight state after a few seconds so the ring is a + // one-time "you tapped here" cue, not a permanent tint (mirrors the + // vault's upload-duplicate flash-highlight, `HIGHLIGHT_MS` in + // `use-document-upload.ts`). + useEffect(() => { + if (!highlightId || !medications?.length) return; + const card = document.querySelector( + `[data-medication-id="${highlightId}"]`, + ); + card?.scrollIntoView({ behavior: "smooth", block: "center" }); + const timer = setTimeout(() => setHighlightId(null), 3000); + return () => clearTimeout(timer); + }, [highlightId, medications]); + // v1.16.11 — the same reminder-thresholds read the cards make (shared // key + cache), so the header's due-set derivation tiers late / // very_late exactly like the card pills. Null on failure keeps the @@ -468,6 +491,7 @@ export default function MedicationsPage() { ) : (
@@ -485,6 +509,7 @@ export default function MedicationsPage() { medication={med} onEdit={openEdit} onOpenHistory={openHistory} + highlighted={highlightId === med.id} /> ) : ( ), )} @@ -513,6 +539,7 @@ export default function MedicationsPage() { medication={med} onEdit={openEdit} onOpenHistory={openHistory} + highlighted={highlightId === med.id} /> ) : ( ), )} diff --git a/src/components/medications/__tests__/card-parts.test.tsx b/src/components/medications/__tests__/card-parts.test.tsx index 12e01579f..f5721b897 100644 --- a/src/components/medications/__tests__/card-parts.test.tsx +++ b/src/components/medications/__tests__/card-parts.test.tsx @@ -420,9 +420,15 @@ describe("streak-token parity — generic vs GLP-1 card", () => { * structural identity that makes the two variants impossible to diverge. */ describe("medication card body — shared shell + dose-state presentation", () => { - function renderBody(doseStatus: DoseStatus, active = true) { + function renderBody( + doseStatus: DoseStatus, + active = true, + highlighted = false, + ) { return render( `) rings the target card, mirroring + // ``. Carries the id for the page's + // scroll-into-view lookup regardless of highlight state. + it("carries the medication id for the deep-link target lookup", () => { + const html = renderBody("upcoming"); + expect(html).toContain('data-medication-id="m1"'); + }); + + it("rings the card when it is the deep-link highlight target", () => { + const html = renderBody("upcoming", true, true); + expect(html).toContain("ring-primary"); + expect(html).toContain("ring-2"); + }); + + it("does not ring the card when it is not the highlight target", () => { + const html = renderBody("upcoming", true, false); + expect(html).not.toContain("ring-primary"); + }); + it("renders the decisive next + last lines exactly once each", () => { const html = renderBody("upcoming"); expect((html.match(/Next intake:/g) ?? []).length).toBe(1); @@ -605,6 +631,7 @@ describe("compliance error fallback (v1.16.8)", () => { }) { return render( — structure + shared payloads", () => { expect(html).not.toContain('aria-label="Take – Amoxicillin"'); }); + // v1.29.x — the Today digest's "Log dose" deep-link + // (`/medications?highlight=`) rings the matching row, mirroring the + // card grid's transient highlight. + it("carries the medication id and rings the row matching highlightId", () => { + const client = makeClient(); + seedCompliance(client, "m1", 90, 88); + seedCompliance(client, "m2", 90, 88); + const html = render( + , + client, + ); + + expect(html).toContain('data-medication-id="m1"'); + expect(html).toContain('data-medication-id="m2"'); + expect(html).toContain("ring-primary"); + }); + + it("rings no row when highlightId matches nothing", () => { + const client = makeClient(); + seedCompliance(client, "m1", 90, 88); + const html = render( + , + client, + ); + + expect(html).not.toContain("ring-primary"); + }); + it("renders a GLP-1 medication like any other row", () => { const client = makeClient(); seedCompliance(client, "g1", 100, 100); diff --git a/src/components/medications/card-parts/medication-card-body.tsx b/src/components/medications/card-parts/medication-card-body.tsx index 38e290699..1f0bee3c2 100644 --- a/src/components/medications/card-parts/medication-card-body.tsx +++ b/src/components/medications/card-parts/medication-card-body.tsx @@ -50,6 +50,12 @@ import type { MedicationWindowStatus } from "@/lib/medications/window-status"; * - `upcoming` / `taken_*` / `skipped` → no escalation; the card stays calm. */ export interface MedicationCardBodyProps { + /** + * Medication id. Rendered as `data-medication-id` so a deep-link (e.g. the + * Today digest's "Log dose" action, `/medications?highlight=`) can + * locate + scroll to the right card. + */ + id: string; /** Medication name shown on the header's line 1. */ name: string; /** Dose shown beside the name on line 1. */ @@ -140,9 +146,18 @@ export interface MedicationCardBodyProps { /** GLP-1 variant mounts its post-dose injection-site dialog here. */ children?: ReactNode; + + /** + * v1.29.x — briefly ringed when this card is the Today digest's + * "Log dose" deep-link target (`/medications?highlight=`), mirroring + * the vault's `` treatment. Transient — the + * page clears it a few seconds after scrolling the card into view. + */ + highlighted?: boolean; } export function MedicationCardBody({ + id, name, dose, categoryLabel, @@ -164,6 +179,7 @@ export function MedicationCardBody({ intakeLoading, onRecordIntake, children, + highlighted = false, }: MedicationCardBodyProps) { const { t, locale } = useTranslations(); @@ -227,6 +243,7 @@ export function MedicationCardBody({ return ( `) for the Today digest's deep-link + // target. Never a persistent tint — the neutral-card rule above still + // holds for dose STATUS; this is a one-time "you tapped here" cue. + className={cn( + "h-full gap-3 md:gap-3", + active ? "" : "opacity-60", + highlighted && "ring-primary ring-2", + )} > void; onLogSideEffect?: (med: Glp1Medication) => void; + /** + * v1.29.x — briefly ringed when this card is the Today digest's "Log + * dose" deep-link target (`/medications?highlight=`). + */ + highlighted?: boolean; } function diffDays(target: Date, from: Date): number { @@ -182,6 +187,7 @@ export function Glp1MedicationCard({ onEdit, onOpenHistory, onLogSideEffect, + highlighted = false, }: Glp1MedicationCardProps) { const queryClient = useQueryClient(); const { t } = useTranslations(); @@ -493,6 +499,8 @@ export function Glp1MedicationCard({ return ( void; + /** + * v1.29.x — briefly ringed when this card is the Today digest's "Log + * dose" deep-link target (`/medications?highlight=`). + */ + highlighted?: boolean; } export function MedicationCard({ medication, onEdit, onOpenHistory, + highlighted = false, }: MedicationCardProps) { const queryClient = useQueryClient(); const { t, locale } = useTranslations(); @@ -463,6 +469,8 @@ export function MedicationCard({ return ( `). The matching row rings briefly, + * mirroring the card grid's ``. + */ + highlightId?: string | null; } export function MedicationTable({ activeMedications, inactiveMedications, initialSort = null, + highlightId = null, }: MedicationTableProps) { const { t, locale } = useTranslations(); const { user } = useAuth(); @@ -350,6 +357,7 @@ export function MedicationTable({ lateMinutes={lateMinutes} missedMinutes={missedMinutes} lowStockRunwayDays={lowStockRunwayDays} + highlighted={highlightId === med.id} /> ))} {sortedInactive.map((med) => ( @@ -360,6 +368,7 @@ export function MedicationTable({ lateMinutes={lateMinutes} missedMinutes={missedMinutes} lowStockRunwayDays={lowStockRunwayDays} + highlighted={highlightId === med.id} /> ))} @@ -375,6 +384,8 @@ interface MedicationTableRowItemProps { missedMinutes: number; /** Per-user low-stock runway threshold in days; null = alert off. */ lowStockRunwayDays: number | null; + /** v1.29.x — the deep-link highlight target (see `MedicationTableProps`). */ + highlighted?: boolean; } function MedicationTableRowItem({ @@ -383,6 +394,7 @@ function MedicationTableRowItem({ lateMinutes, missedMinutes, lowStockRunwayDays, + highlighted = false, }: MedicationTableRowItemProps) { const { t, locale } = useTranslations(); const fmt = useFormatters(); @@ -637,7 +649,15 @@ function MedicationTableRowItem({ ); return ( - + {/* Sticky name column — opaque background so scrolled content passes underneath, not through. */} diff --git a/src/lib/daily/__tests__/digest.test.ts b/src/lib/daily/__tests__/digest.test.ts index 1027776c3..124dabc4a 100644 --- a/src/lib/daily/__tests__/digest.test.ts +++ b/src/lib/daily/__tests__/digest.test.ts @@ -35,6 +35,7 @@ function meds(over: Partial = {}): MedsTodayBlock { nextDueAt: null, nextDueOverdue: false, nextDueMedicationName: null, + nextDueMedicationId: null, ...over, }; } @@ -201,6 +202,7 @@ describe("buildDailyDigest — worth-a-look rail item builders", () => { medsToday: meds({ nextDueOverdue: true, nextDueMedicationName: "Ramipril", + nextDueMedicationId: "med-ramipril", }), }), t, @@ -213,6 +215,24 @@ describe("buildDailyDigest — worth-a-look rail item builders", () => { expect(dose?.body).toBe("Ramipril is due today."); expect(dose?.actions).toHaveLength(1); expect(dose?.actions[0].intent).toBe("dose.log"); + // Deep-links straight to the overdue medication's card, not the bare + // list — the tap should land on the right med. + expect(dose?.actions[0].href).toBe("/medications?highlight=med-ramipril"); + }); + + it("falls back to the bare medications list when no id is known (older cached block)", () => { + const d = buildDailyDigest( + input({ + medsToday: meds({ + nextDueOverdue: true, + nextDueMedicationName: "Ramipril", + nextDueMedicationId: null, + }), + }), + t, + ); + const dose = d.worthALook.find((i) => i.kind === "dose_window"); + expect(dose?.actions[0].href).toBe("/medications"); }); it("does NOT emit a dose-window item when the medications module is off", () => { diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index 64d01a904..66362f5cd 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -258,6 +258,7 @@ function buildDoseWindowItem( if (!moduleEnabled(modules, "medications")) return null; if (!meds.nextDueOverdue) return null; const name = meds.nextDueMedicationName; + const id = meds.nextDueMedicationId; return { kind: "dose_window", title: t("daily.item.doseWindow.title"), @@ -269,7 +270,12 @@ function buildDoseWindowItem( { labelKey: "daily.action.logDose", intent: "dose.log", - href: "/medications", + // Deep-link straight to the overdue medication's card so the tap + // lands on the right one instead of a generic list the user then + // has to scan (the id is known server-side whenever the name is). + // The bare list stays the honest fallback for an older cached + // block that predates the id field. + href: id ? `/medications?highlight=${id}` : "/medications", }, ], moduleKey: "medications", diff --git a/src/lib/dashboard/__tests__/meds-today.test.ts b/src/lib/dashboard/__tests__/meds-today.test.ts index 8d738d11a..4c96d84d6 100644 --- a/src/lib/dashboard/__tests__/meds-today.test.ts +++ b/src/lib/dashboard/__tests__/meds-today.test.ts @@ -207,6 +207,7 @@ describe("buildMedsTodayBlock — tally", () => { nextDueAt: null, nextDueOverdue: false, nextDueMedicationName: null, + nextDueMedicationId: null, }); }); @@ -259,6 +260,7 @@ describe("buildMedsTodayBlock — next due (real engine)", () => { expect(block.nextDueAt).toBe("2026-06-10T16:00:00.000Z"); expect(block.nextDueOverdue).toBe(false); expect(block.nextDueMedicationName).toBe("Abendmittel"); + expect(block.nextDueMedicationId).toBe("med-evening"); }); it("surfaces an open overdue slot (anchor passed, band still open)", async () => { @@ -282,6 +284,7 @@ describe("buildMedsTodayBlock — next due (real engine)", () => { expect(block.nextDueAt).toBe("2026-06-10T09:00:00.000Z"); expect(block.nextDueOverdue).toBe(true); expect(block.nextDueMedicationName).toBe("Mittagsmittel"); + expect(block.nextDueMedicationId).toBe("med-1"); }); it("advances past a RESOLVED slot to the next future anchor", async () => { @@ -329,5 +332,6 @@ describe("buildMedsTodayBlock — next due (real engine)", () => { expect(block.nextDueAt).toBeNull(); expect(block.nextDueOverdue).toBe(false); expect(block.nextDueMedicationName).toBeNull(); + expect(block.nextDueMedicationId).toBeNull(); }); }); diff --git a/src/lib/dashboard/__tests__/verdict.test.ts b/src/lib/dashboard/__tests__/verdict.test.ts index 8e12f1d52..49e64199b 100644 --- a/src/lib/dashboard/__tests__/verdict.test.ts +++ b/src/lib/dashboard/__tests__/verdict.test.ts @@ -53,6 +53,7 @@ function medsToday(partial: Partial = {}): MedsTodayBlock { nextDueAt: null, nextDueOverdue: false, nextDueMedicationName: null, + nextDueMedicationId: null, ...partial, }; } diff --git a/src/lib/dashboard/meds-today.ts b/src/lib/dashboard/meds-today.ts index cda9c4b9a..d550a5b35 100644 --- a/src/lib/dashboard/meds-today.ts +++ b/src/lib/dashboard/meds-today.ts @@ -68,6 +68,13 @@ export interface MedsTodayBlock { nextDueOverdue: boolean; /** Name of the medication carrying `nextDueAt`; null when none due. */ nextDueMedicationName: string | null; + /** + * Id of the medication carrying `nextDueAt`; null when none due. Threaded + * through so a consumer (the Today digest's dose-window rail item) can + * deep-link straight to the overdue medication's card instead of the bare + * list. + */ + nextDueMedicationId: string | null; } export async function buildMedsTodayBlock( @@ -182,6 +189,7 @@ export async function buildMedsTodayBlock( let nextDueAt: Date | null = null; let nextDueOverdue = false; let nextDueMedicationName: string | null = null; + let nextDueMedicationId: string | null = null; for (const m of medications) { const display = computeDisplayDue({ medication: { @@ -203,6 +211,7 @@ export async function buildMedsTodayBlock( nextDueAt = display.at; nextDueOverdue = display.overdue; nextDueMedicationName = m.name; + nextDueMedicationId = m.id; } } @@ -214,5 +223,6 @@ export async function buildMedsTodayBlock( nextDueAt: nextDueAt ? nextDueAt.toISOString() : null, nextDueOverdue, nextDueMedicationName, + nextDueMedicationId, }; } diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 98b90427f..28d6ebfc1 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -896,9 +896,10 @@ export const dashboardSnapshotResponse = z nextDueAt: z.string().nullable(), nextDueOverdue: z.boolean(), nextDueMedicationName: z.string().nullable(), + nextDueMedicationId: z.string().nullable(), }) .describe( - "Today's medication block: active-medication count, today-window tally (scheduled / taken / skipped), and the earliest next-due slot across active medications. `nextDueOverdue: true` marks an OPEN overdue slot (anchor passed, still inside its catch-up band). Staleness contract: the snapshot is cache-served, so a `nextDueAt` in the past with `nextDueOverdue: false` means the anchor passed after the snapshot was built — render the plain day summary, never an overdue state.", + "Today's medication block: active-medication count, today-window tally (scheduled / taken / skipped), and the earliest next-due slot across active medications. `nextDueOverdue: true` marks an OPEN overdue slot (anchor passed, still inside its catch-up band). `nextDueMedicationId` deep-links a consumer straight to the overdue medication's card. Staleness contract: the snapshot is cache-served, so a `nextDueAt` in the past with `nextDueOverdue: false` means the anchor passed after the snapshot was built — render the plain day summary, never an overdue state.", ), // Dashboard hero — health score (warm phase, nullable on a // rollup-coverage miss). Score + band + delta only; the per-pillar From 141713716285659204a203a54e78bf5c6eaae419 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 00:35:39 +0200 Subject: [PATCH 13/33] fix(daily-loop): stop bad ?doc= links from trapping the coach, add upload processing feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An invalid ?doc= deep-link (a deleted document, a mistyped id) 404s its detail fetch forever. The fenced send-gate never cleared, so every message stayed blocked behind a phantom "untitled" attachment banner that could never resolve — a bad link was a dead end instead of degrading to a normal chat. Dropping a staged id the moment its detail fetch settles into an error now clears the doc-scope, drops the banner, and lets the coach send normally; a toast explains the link is gone. Also gives the document vault processing feedback: the auto-index (and optional AI summary) job that starts right after upload is fire-and-forget server-side, so a freshly uploaded card sat with no signal while it indexed. The vault list now polls while any recently uploaded document is still unindexed (bounded to a few minutes so an old, permanently-unindexed document never keeps it polling), and the card shows a "Processing…"/"Ready" chip that clears on its own once the index lands. --- messages/de.json | 7 +- messages/en.json | 7 +- messages/es.json | 7 +- messages/fr.json | 7 +- messages/it.json | 7 +- messages/pl.json | 7 +- .../__tests__/document-card.test.tsx | 51 ++++++++++++++ .../documents/__tests__/vault-utils.test.ts | 68 +++++++++++++++++++ src/components/documents/document-card.tsx | 60 ++++++++++++++-- src/components/documents/documents-view.tsx | 13 ++++ src/components/documents/vault-utils.ts | 43 ++++++++++++ .../__tests__/coach-launch-scope.test.ts | 29 +++++++- .../coach-panel/coach-conversation.tsx | 55 +++++++++++++++ 13 files changed, 344 insertions(+), 17 deletions(-) diff --git a/messages/de.json b/messages/de.json index 918bf3db3..18bf94738 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2491,7 +2491,8 @@ "documentsAttachedOne": "1 Dokument angehängt", "documentsAttachedOther": "{count} Dokumente angehängt", "railBadge": "Dokument-Chat (abgeschottet)", - "uploadError": "Upload fehlgeschlagen. Bitte versuche es erneut." + "uploadError": "Upload fehlgeschlagen. Bitte versuche es erneut.", + "notFoundError": "Dieser Dokumentlink ist nicht mehr gültig – es wird ein normaler Chat gestartet." } }, "relativeJustNow": "gerade eben", @@ -8327,7 +8328,9 @@ "openLabel": "{title} öffnen", "attachmentBadge": "Nur Download", "uploading": "Wird hochgeladen…", - "dismissFailed": "Fehlgeschlagenen Upload ausblenden" + "dismissFailed": "Fehlgeschlagenen Upload ausblenden", + "processing": "Wird verarbeitet…", + "ready": "Bereit" }, "error": { "fileTooLarge": "Diese Datei ist größer als das Limit von {maxSize}.", diff --git a/messages/en.json b/messages/en.json index fe4d78afc..0c2dfff82 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2491,7 +2491,8 @@ "documentsAttachedOne": "1 document attached", "documentsAttachedOther": "{count} documents attached", "railBadge": "Fenced document chat", - "uploadError": "Upload failed. Please try again." + "uploadError": "Upload failed. Please try again.", + "notFoundError": "That document link is no longer valid — starting a normal chat instead." } }, "relativeJustNow": "just now", @@ -8327,7 +8328,9 @@ "openLabel": "Open {title}", "attachmentBadge": "Download only", "uploading": "Uploading…", - "dismissFailed": "Dismiss failed upload" + "dismissFailed": "Dismiss failed upload", + "processing": "Processing…", + "ready": "Ready" }, "error": { "fileTooLarge": "This file is larger than the {maxSize} limit.", diff --git a/messages/es.json b/messages/es.json index 0b958601a..0d6573ea1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2491,7 +2491,8 @@ "documentsAttachedOne": "1 documento adjunto", "documentsAttachedOther": "{count} documentos adjuntos", "railBadge": "Chat de documento aislado", - "uploadError": "Error al subir. Inténtalo de nuevo." + "uploadError": "Error al subir. Inténtalo de nuevo.", + "notFoundError": "Ese enlace de documento ya no es válido: se iniciará un chat normal." } }, "relativeJustNow": "ahora mismo", @@ -8327,7 +8328,9 @@ "openLabel": "Abrir {title}", "attachmentBadge": "Solo descarga", "uploading": "Subiendo…", - "dismissFailed": "Descartar subida fallida" + "dismissFailed": "Descartar subida fallida", + "processing": "Procesando…", + "ready": "Listo" }, "error": { "fileTooLarge": "Este archivo supera el límite de {maxSize}.", diff --git a/messages/fr.json b/messages/fr.json index b0e846e42..ae6c28eb8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2491,7 +2491,8 @@ "documentsAttachedOne": "1 document joint", "documentsAttachedOther": "{count} documents joints", "railBadge": "Chat de document cloisonné", - "uploadError": "Échec du téléversement. Veuillez réessayer." + "uploadError": "Échec du téléversement. Veuillez réessayer.", + "notFoundError": "Ce lien de document n'est plus valide : une conversation normale démarre à la place." } }, "relativeJustNow": "à l’instant", @@ -8327,7 +8328,9 @@ "openLabel": "Ouvrir {title}", "attachmentBadge": "Téléchargement uniquement", "uploading": "Téléversement…", - "dismissFailed": "Ignorer le téléversement échoué" + "dismissFailed": "Ignorer le téléversement échoué", + "processing": "Traitement en cours…", + "ready": "Prêt" }, "error": { "fileTooLarge": "Ce fichier dépasse la limite de {maxSize}.", diff --git a/messages/it.json b/messages/it.json index a349f00e3..9feb86234 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2491,7 +2491,8 @@ "documentsAttachedOne": "1 documento allegato", "documentsAttachedOther": "{count} documenti allegati", "railBadge": "Chat documento isolata", - "uploadError": "Caricamento non riuscito. Riprova." + "uploadError": "Caricamento non riuscito. Riprova.", + "notFoundError": "Quel link al documento non è più valido: verrà avviata una chat normale." } }, "relativeJustNow": "proprio ora", @@ -8327,7 +8328,9 @@ "openLabel": "Apri {title}", "attachmentBadge": "Solo download", "uploading": "Caricamento…", - "dismissFailed": "Ignora caricamento non riuscito" + "dismissFailed": "Ignora caricamento non riuscito", + "processing": "Elaborazione…", + "ready": "Pronto" }, "error": { "fileTooLarge": "Questo file supera il limite di {maxSize}.", diff --git a/messages/pl.json b/messages/pl.json index e10f195fe..9f51e4099 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2491,7 +2491,8 @@ "documentsAttachedOne": "1 załączony dokument", "documentsAttachedOther": "Załączone dokumenty: {count}", "railBadge": "Odizolowany czat dokumentu", - "uploadError": "Przesyłanie nie powiodło się. Spróbuj ponownie." + "uploadError": "Przesyłanie nie powiodło się. Spróbuj ponownie.", + "notFoundError": "Ten link do dokumentu jest już nieprawidłowy – zamiast tego rozpoczynamy zwykły czat." } }, "relativeJustNow": "przed chwilą", @@ -8327,7 +8328,9 @@ "openLabel": "Otwórz {title}", "attachmentBadge": "Tylko do pobrania", "uploading": "Przesyłanie…", - "dismissFailed": "Odrzuć nieudane przesyłanie" + "dismissFailed": "Odrzuć nieudane przesyłanie", + "processing": "Przetwarzanie…", + "ready": "Gotowe" }, "error": { "fileTooLarge": "Ten plik przekracza limit {maxSize}.", diff --git a/src/components/documents/__tests__/document-card.test.tsx b/src/components/documents/__tests__/document-card.test.tsx index 8734d2ab0..5cb9a5e0b 100644 --- a/src/components/documents/__tests__/document-card.test.tsx +++ b/src/components/documents/__tests__/document-card.test.tsx @@ -175,6 +175,57 @@ describe("", () => { expect(html).toContain("Untitled document"); expect(html).toContain("ring-2"); }); + + // v1.29.x — the fire-and-forget auto-index job is invisible otherwise; + // the card surfaces its own "Processing…" chip for a freshly uploaded, + // not-yet-indexed document. `isDocumentProcessing` (vault-utils) bounds + // this to the recent-upload window; `doc()`'s default `createdAt` is old, + // so the baseline render above never shows the chip. + it("shows the Processing… chip for a freshly uploaded, not-yet-indexed document", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="document-processing-badge"'); + expect(html).toContain("Processing"); + }); + + it("shows no Processing… chip once the document is indexed", () => { + const html = render( + , + ); + expect(html).not.toContain('data-slot="document-processing-badge"'); + }); + + it("shows no Processing… chip for an old, never-indexed document (outside the window)", () => { + const html = render( + , + ); + expect(html).not.toContain('data-slot="document-processing-badge"'); + }); }); describe("", () => { diff --git a/src/components/documents/__tests__/vault-utils.test.ts b/src/components/documents/__tests__/vault-utils.test.ts index 47f72c1ea..3a3723e91 100644 --- a/src/components/documents/__tests__/vault-utils.test.ts +++ b/src/components/documents/__tests__/vault-utils.test.ts @@ -10,8 +10,12 @@ import { expandRangeSelection, formatBytes, formatMonthLabel, + hasProcessingDocument, + isDocumentProcessing, + isRecentlyUploaded, parseUploadResponse, parseVaultSearchParams, + RECENT_UPLOAD_WINDOW_MS, resolveBulkShareDocuments, SHARE_LINK_MAX_DOCUMENTS, vaultFiltersToSearch, @@ -343,3 +347,67 @@ describe("resolveBulkShareDocuments", () => { expect(result.documents).toHaveLength(SHARE_LINK_MAX_DOCUMENTS); }); }); + +// v1.29.x — the vault's "Processing…" / "Ready" chip + the poll-while- +// indexing refetch both key off the bounded recent-upload window rather than +// a bare `!hasContentIndex`, so an old / permanently-unindexed document never +// shows a stuck spinner and the list query never polls forever. +describe("isRecentlyUploaded / isDocumentProcessing / hasProcessingDocument", () => { + const NOW = new Date("2026-03-11T08:10:00.000Z").getTime(); + + it("is recently-uploaded just inside the window, not right at/after it", () => { + const justInside = new Date( + NOW - RECENT_UPLOAD_WINDOW_MS + 1000, + ).toISOString(); + expect(isRecentlyUploaded(justInside, NOW)).toBe(true); + + const atTheEdge = new Date(NOW - RECENT_UPLOAD_WINDOW_MS).toISOString(); + expect(isRecentlyUploaded(atTheEdge, NOW)).toBe(false); + }); + + it("is never recently-uploaded for a future createdAt (clock skew defensiveness)", () => { + const future = new Date(NOW + 1000).toISOString(); + expect(isRecentlyUploaded(future, NOW)).toBe(false); + }); + + it("is processing only while unindexed AND inside the recent-upload window", () => { + const fresh = new Date(NOW - 1000).toISOString(); + expect( + isDocumentProcessing({ createdAt: fresh, hasContentIndex: false }, NOW), + ).toBe(true); + // Indexed already — done, not processing, regardless of age. + expect( + isDocumentProcessing({ createdAt: fresh, hasContentIndex: true }, NOW), + ).toBe(false); + // Old and never indexed (unsupported format, content index disabled, + // …) — outside the window, so it does NOT read as "still processing". + const old = new Date(NOW - RECENT_UPLOAD_WINDOW_MS - 1000).toISOString(); + expect( + isDocumentProcessing({ createdAt: old, hasContentIndex: false }, NOW), + ).toBe(false); + }); + + it("hasProcessingDocument is true when ANY loaded document is still processing", () => { + const fresh = new Date(NOW - 1000).toISOString(); + const old = new Date(NOW - RECENT_UPLOAD_WINDOW_MS - 1000).toISOString(); + const docs = [ + doc({ id: "d1", createdAt: old, hasContentIndex: false }), + doc({ id: "d2", createdAt: fresh, hasContentIndex: false }), + ]; + expect(hasProcessingDocument(docs, NOW)).toBe(true); + }); + + it("hasProcessingDocument is false when every document is indexed or outside the window", () => { + const fresh = new Date(NOW - 1000).toISOString(); + const old = new Date(NOW - RECENT_UPLOAD_WINDOW_MS - 1000).toISOString(); + const docs = [ + doc({ id: "d1", createdAt: old, hasContentIndex: false }), + doc({ id: "d2", createdAt: fresh, hasContentIndex: true }), + ]; + expect(hasProcessingDocument(docs, NOW)).toBe(false); + }); + + it("hasProcessingDocument is false for an empty list", () => { + expect(hasProcessingDocument([], NOW)).toBe(false); + }); +}); diff --git a/src/components/documents/document-card.tsx b/src/components/documents/document-card.tsx index 462d7816e..ba9f94763 100644 --- a/src/components/documents/document-card.tsx +++ b/src/components/documents/document-card.tsx @@ -16,8 +16,8 @@ * whole card is clickable through an invisible overlay button; the checkbox * floats above it. */ -import { Download, X } from "lucide-react"; -import { useRef, useState } from "react"; +import { Check, Download, Loader2, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -28,7 +28,14 @@ import { cn } from "@/lib/utils"; import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; import { DOCUMENT_KIND_ICONS } from "./document-kind-meta"; import type { UploadQueueItem } from "./use-document-upload"; -import { documentDateKey, formatBytes } from "./vault-utils"; +import { + documentDateKey, + formatBytes, + isDocumentProcessing, +} from "./vault-utils"; + +/** How long the "Ready" chip stays up after a card's index lands. */ +const READY_FLASH_MS = 3000; /** Press-and-hold duration that flips a touch press into "select" mode. */ const LONG_PRESS_MS = 500; @@ -86,6 +93,27 @@ export function DocumentCard({ const showFilename = document.filename !== null && document.filename !== title; + // v1.29.x — processing/ready feedback for the fire-and-forget auto-index + // (+ optional AI summary) job that starts right after upload. `processing` + // is derived straight from the DTO (bounded to the recent-upload window, so + // an old/never-indexed document never shows a stuck spinner). `justIndexed` + // is local: it flashes once when a card that WAS processing on this page + // transitions to indexed (the poll in `documents-view.tsx` is what drives + // the transition), then clears on its own — a one-time "it landed" cue, + // not a permanent tint. + const processing = isDocumentProcessing(document); + const wasProcessingRef = useRef(processing); + const [justIndexed, setJustIndexed] = useState(false); + useEffect(() => { + if (wasProcessingRef.current && !processing) { + setJustIndexed(true); + const timer = setTimeout(() => setJustIndexed(false), READY_FLASH_MS); + wasProcessingRef.current = processing; + return () => clearTimeout(timer); + } + wasProcessingRef.current = processing; + }, [processing]); + return (
{document.conditionLinks.length > 0 || - document.servingClass === "attachment" ? ( + document.servingClass === "attachment" || + processing || + justIndexed ? (
{document.conditionLinks.map((link) => ( ) : null} + {processing ? ( + + + {t("documents.card.processing")} + + ) : justIndexed ? ( + + + {t("documents.card.ready")} + + ) : null}
) : null} diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx index 717ad242e..d9d61b9ef 100644 --- a/src/components/documents/documents-view.tsx +++ b/src/components/documents/documents-view.tsx @@ -65,6 +65,7 @@ import { countActiveFilters, documentDateKey, expandRangeSelection, + hasProcessingDocument, parseVaultSearchParams, resolveBulkShareDocuments, SHARE_LINK_MAX_DOCUMENTS, @@ -213,6 +214,18 @@ export function DocumentsView() { ), initialPageParam: null as string | null, getNextPageParam: (lastPage) => lastPage.nextCursor, + // v1.29.x — the auto-index (+ optional AI summary) job that lands right + // after upload is fire-and-forget server-side; nothing pushes its + // completion to the client. Poll while any loaded document is still + // inside its recent-upload processing window (see `hasProcessingDocument` + // — bounded, so an old/permanently-unindexed document never keeps this + // polling forever) so the card's "Processing…" chip clears on its own + // once the index lands, without a manual refresh. + refetchInterval: (query) => { + const pages = query.state.data?.pages ?? []; + const docs = pages.flatMap((p) => p.documents); + return hasProcessingDocument(docs) ? 4000 : false; + }, }); const documents = useMemo( diff --git a/src/components/documents/vault-utils.ts b/src/components/documents/vault-utils.ts index bb15c532d..68629ff77 100644 --- a/src/components/documents/vault-utils.ts +++ b/src/components/documents/vault-utils.ts @@ -115,6 +115,49 @@ export function documentMonthKey(doc: InboundDocumentDto): string { return documentDateKey(doc).slice(0, 7); } +/** + * v1.29.x — bounded "still processing" window for a freshly uploaded + * document. `hasContentIndex` stays false until the fire-and-forget + * auto-index job (`enqueueDocumentIndex`, `src/app/api/documents/inbound/ + * route.ts`) lands — which for some documents (an unsupported format, + * content indexing disabled entirely) never happens. Gating the vault's + * "Processing…" chip AND the timeline's poll-while-processing refetch on + * this window means an old, permanently-unindexed document never shows a + * stuck spinner and the list never polls forever. + */ +export const RECENT_UPLOAD_WINDOW_MS = 3 * 60 * 1000; + +/** True while `createdAt` is still inside the recent-upload processing window. */ +export function isRecentlyUploaded( + createdAt: string, + now: number = Date.now(), +): boolean { + const ageMs = now - new Date(createdAt).getTime(); + return ageMs >= 0 && ageMs < RECENT_UPLOAD_WINDOW_MS; +} + +/** + * True while a recently-uploaded document's content index has not landed + * yet — the vault card's "Processing…" chip condition. + */ +export function isDocumentProcessing( + doc: Pick, + now: number = Date.now(), +): boolean { + return !doc.hasContentIndex && isRecentlyUploaded(doc.createdAt, now); +} + +/** + * Whether ANY document in the list is still inside its processing window — + * drives the vault list query's poll-while-indexing `refetchInterval`. + */ +export function hasProcessingDocument( + documents: InboundDocumentDto[], + now: number = Date.now(), +): boolean { + return documents.some((doc) => isDocumentProcessing(doc, now)); +} + /** * One flat item in the virtualized timeline: a month section label or one * grid row of up to `columns` documents. The virtualizer windows over this diff --git a/src/components/insights/coach-panel/__tests__/coach-launch-scope.test.ts b/src/components/insights/coach-panel/__tests__/coach-launch-scope.test.ts index 5df370787..90959e2fb 100644 --- a/src/components/insights/coach-panel/__tests__/coach-launch-scope.test.ts +++ b/src/components/insights/coach-panel/__tests__/coach-launch-scope.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { launchScopeToCoachScope } from "../coach-conversation"; +import { + dropInvalidStagedAttachments, + launchScopeToCoachScope, +} from "../coach-conversation"; import { metricScopeFromExplainer, scopeSourceFromMetricKey, @@ -61,6 +64,30 @@ describe("metricScopeFromExplainer", () => { }); }); +describe("dropInvalidStagedAttachments (v1.29.x — bad ?doc= deep-link)", () => { + it("returns an empty list when nothing errored", () => { + expect( + dropInvalidStagedAttachments(["doc-1", "doc-2"], [false, false]), + ).toEqual([]); + }); + + it("returns the id whose detail fetch settled into an error (404)", () => { + expect(dropInvalidStagedAttachments(["bad-doc"], [true])).toEqual([ + "bad-doc", + ]); + }); + + it("only drops the errored id, keeping a still-resolving/valid sibling staged", () => { + expect( + dropInvalidStagedAttachments(["good-doc", "bad-doc"], [false, true]), + ).toEqual(["bad-doc"]); + }); + + it("returns an empty list for an empty staged set", () => { + expect(dropInvalidStagedAttachments([], [])).toEqual([]); + }); +}); + describe("scopeSourceFromMetricKey", () => { it("maps the model's snapshot-key vocabulary to a scope source", () => { expect(scopeSourceFromMetricKey("bloodPressure")).toBe("bp"); diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index 4eddfbb7b..629046bc0 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -102,6 +102,24 @@ export function launchScopeToCoachScope( }; } +/** + * v1.29.x — a stale/invalid `?doc=` deep-link (a deleted document, a + * mistyped id, someone else's document) 404s the staged detail fetch + * forever, which used to trap every send behind a phantom "untitled" + * attachment that could never resolve (the fenced gate never clears). + * Pure so the drop decision is unit-testable without mounting the + * component: given the currently staged ids and which of their detail + * fetches settled into an error (index-aligned with `pendingAttachmentIds`, + * matching `useQueries`' `isError`), returns the ids that should fall out of + * staging. A bad link degrades to a normal chat instead of a dead end. + */ +export function dropInvalidStagedAttachments( + pendingAttachmentIds: string[], + isErrorByIndex: boolean[], +): string[] { + return pendingAttachmentIds.filter((_, i) => isErrorByIndex[i] === true); +} + /** * v1.21.4 (C2) — the localStorage key that records the seeded "worth a look" * opener as dismissed for a given LOCAL calendar day. Date-stamped so the @@ -358,6 +376,43 @@ export function CoachConversation({ return d?.title ?? d?.filename ?? t("documents.card.untitled"); }; + // v1.29.x — a stale/invalid `?doc=` deep-link (a deleted document, a + // mistyped id, someone else's document) 404s the detail fetch above + // forever. `refetchInterval` reads `false` once a query has no data, so the + // fetch never retries on its own — `isStagedIndexed` stayed permanently + // false and the fenced send-gate (`fenced && anyPendingUnindexed` below) + // trapped every message behind a phantom "untitled" attachment that could + // never resolve. Drop a staged id the moment its detail fetch settles into + // an error instead: the doc-scope clears, the banner disappears, and the + // surface degrades to a normal chat rather than a dead end. + // + // The drop itself runs in the render-phase-update pattern (mirrors + // `autoOpenResolved` below) rather than an effect: `react-hooks/set-state- + // in-effect` rejects a setState call inside an effect whose source is a + // query result. It is self-limiting without a ref guard: once an id drops + // out of `pendingAttachmentIds`, React's render-phase-update loop + // re-renders BEFORE committing, and on that re-render the id is no longer + // in `pendingAttachmentIds` at all, so `dropInvalidStagedAttachments` can + // never surface it again. The toast is the one genuine side effect and + // stays in its own effect (no setState inside it), keyed on the ids just + // dropped so it fires exactly once per distinct drop. + const invalidStagedIds = dropInvalidStagedAttachments( + pendingAttachmentIds, + stagedDetailResults.map((q) => q.isError === true), + ); + const [invalidToastKey, setInvalidToastKey] = useState(""); + if (invalidStagedIds.length > 0) { + setPendingAttachmentIds((prev) => + prev.filter((id) => !invalidStagedIds.includes(id)), + ); + const key = invalidStagedIds.join(","); + if (key !== invalidToastKey) setInvalidToastKey(key); + } + useEffect(() => { + if (!invalidToastKey) return; + toast.error(t("insights.coach.attach.notFoundError")); + }, [invalidToastKey, t]); + // The pills row above the composer. On a fresh chat every pill is a staged id; // on an existing conversation the server attachments are the live pills and any // staged uploads (still indexing) render alongside them. From 95ab0514b9dce1f66dcc4b308aa7ea687847ff5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 00:01:05 +0200 Subject: [PATCH 14/33] chore(charts): drop two unused recharts chart components sleep-hypnogram.tsx and compliance-line-chart.tsx are not imported anywhere; a future import would mint a second recharts chunk and blow the bundle budget. Delete both plus the dedicated hypnogram test file, and drop the now-missing filename from the touch-action guard's file list. --- .../__tests__/touch-action-guard.test.ts | 1 - .../charts/compliance-line-chart.tsx | 169 ------- .../__tests__/sleep-hypnogram.test.tsx | 192 -------- src/components/insights/sleep-hypnogram.tsx | 417 ------------------ 4 files changed, 779 deletions(-) delete mode 100644 src/components/charts/compliance-line-chart.tsx delete mode 100644 src/components/insights/__tests__/sleep-hypnogram.test.tsx delete mode 100644 src/components/insights/sleep-hypnogram.tsx diff --git a/src/components/charts/__tests__/touch-action-guard.test.ts b/src/components/charts/__tests__/touch-action-guard.test.ts index 0104bda2b..6e00f2525 100644 --- a/src/components/charts/__tests__/touch-action-guard.test.ts +++ b/src/components/charts/__tests__/touch-action-guard.test.ts @@ -24,7 +24,6 @@ const CHART_FILES = [ "health-chart.tsx", "mood-chart.tsx", "medication-compliance-chart.tsx", - "compliance-line-chart.tsx", "scatter-correlation-chart.tsx", ]; diff --git a/src/components/charts/compliance-line-chart.tsx b/src/components/charts/compliance-line-chart.tsx deleted file mode 100644 index 5a1fc8dbe..000000000 --- a/src/components/charts/compliance-line-chart.tsx +++ /dev/null @@ -1,169 +0,0 @@ -"use client"; - -import { useMemo, useState } from "react"; -import { - ResponsiveContainer, - LineChart, - Line, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ReferenceLine, -} from "recharts"; -import { Button } from "@/components/ui/button"; -import { useViewportWidth } from "@/hooks/use-viewport-width"; -import { chooseTickInterval } from "@/lib/charts/x-axis-density"; -import { useTranslations } from "@/lib/i18n/context"; -import { makeFormatters } from "@/lib/format-locale"; - -interface DailyData { - expected: number; - taken: number; - skipped: number; - onTime?: number; - late?: number; - veryLate?: number; -} - -interface ComplianceLineChartProps { - dailyCompliance: Record; - rangePoints?: 30 | 90 | 0; - onRangePointsChange?: (value: 30 | 90 | 0) => void; - showRangeControls?: boolean; - /** - * v1.4.25 W7b — per-user display timezone for the X-axis labels. - * Defaults to "Europe/Berlin" so older callers keep their previous - * rendering bit-for-bit. - */ - userTimezone?: string; -} - -const TIME_RANGES = [ - { label: "30M", points: 30, title: "Die dreissig letzten Messpunkte" }, - { label: "90M", points: 90, title: "Die neunzig letzten Messpunkte" }, - { label: "Alle", points: 0, title: "Alle verfuegbaren Messpunkte" }, -] as const; - -export function ComplianceLineChart({ - dailyCompliance, - rangePoints, - onRangePointsChange, - showRangeControls = true, - userTimezone = "Europe/Berlin", -}: ComplianceLineChartProps) { - const [internalRangePoints, setInternalRangePoints] = useState<30 | 90 | 0>( - 30, - ); - const activeRangePoints = rangePoints ?? internalRangePoints; - const setActiveRangePoints = onRangePointsChange ?? setInternalRangePoints; - const viewportWidth = useViewportWidth(); - const { locale } = useTranslations(); - // v1.4.25 W7b — tz-aware date label for the X-axis. Falls back to - // legacy Europe/Berlin when the prop is omitted. - const tzFmt = useMemo( - () => makeFormatters(locale, userTimezone), - [locale, userTimezone], - ); - - const chartData = useMemo(() => { - const points = Object.entries(dailyCompliance) - .filter(([, data]) => data.expected > 0) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([dateKey, data]) => { - const date = new Date(`${dateKey}T12:00:00.000Z`); - return { - date: tzFmt.date(date), - rate: Math.min(100, Math.round((data.taken / data.expected) * 100)), - timestamp: date.getTime(), - }; - }); - - return activeRangePoints > 0 ? points.slice(-activeRangePoints) : points; - }, [dailyCompliance, activeRangePoints, tzFmt]); - - return ( -
- {showRangeControls ? ( -
- {TIME_RANGES.map((r) => ( - - ))} -
- ) : null} - - {chartData.length === 0 ? ( -
- Keine Daten im gewählten Zeitraum -
- ) : ( -
- - - - - - [`${value}%`, "Compliance"]} - /> - - - - -
- )} -
- ); -} diff --git a/src/components/insights/__tests__/sleep-hypnogram.test.tsx b/src/components/insights/__tests__/sleep-hypnogram.test.tsx deleted file mode 100644 index 4bd96d7b6..000000000 --- a/src/components/insights/__tests__/sleep-hypnogram.test.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - -import { I18nProvider } from "@/lib/i18n/context"; -import { SleepHypnogram, type SleepHypnogramSession } from "../sleep-hypnogram"; - -/** - * v1.16.8 — hypnogram polish: the night's measuring source rides the - * header as a muted caption, and the depth lanes derive from the stages - * actually present in the night's spans (in `LANE_ORDER` order) instead - * of always painting all six — an Apple night (AWAKE/REM/CORE/DEEP) - * used to carry two permanently empty rows. - * - * Project convention: SSR-only tests. Recharts' `ResponsiveContainer` - * paints nothing without measured dimensions, so the lane derivation is - * pinned structurally against the source. - */ - -const baseSession: SleepHypnogramSession = { - night: "2026-06-09", - source: "Apple Health", - start: "2026-06-08T22:30:00.000Z", - end: "2026-06-09T06:00:00.000Z", - asleepMinutes: 420, - inBedMinutes: 450, - awakeMinutes: 30, - awakenings: 2, - stages: { DEEP: 90, REM: 100, CORE: 230, AWAKE: 30 }, - segments: [ - { - stage: "AWAKE", - start: "2026-06-08T22:30:00.000Z", - end: "2026-06-08T22:45:00.000Z", - minutes: 15, - }, - { - stage: "CORE", - start: "2026-06-08T22:45:00.000Z", - end: "2026-06-09T01:00:00.000Z", - minutes: 135, - }, - { - stage: "DEEP", - start: "2026-06-09T01:00:00.000Z", - end: "2026-06-09T02:30:00.000Z", - minutes: 90, - }, - ], -}; - -function render(session: SleepHypnogramSession, locale: "en" | "de" = "en") { - return renderToStaticMarkup( - - - , - ); -} - -describe(" — source caption", () => { - it("surfaces the night's measuring source as a muted header caption", () => { - const html = render(baseSession); - expect(html).toContain('data-slot="sleep-hypnogram-source"'); - expect(html).toContain("Source: Apple Health"); - }); - - it("localises the caption label", () => { - const html = render(baseSession, "de"); - expect(html).toContain("Quelle: Apple Health"); - }); - - it("omits the caption when the session carries no source", () => { - const html = render({ ...baseSession, source: null }); - expect(html).not.toContain('data-slot="sleep-hypnogram-source"'); - }); -}); - -describe(" — lane derivation", () => { - it("derives the Y lanes from the stages present in the spans", () => { - const src = readFileSync( - join(process.cwd(), "src/components/insights/sleep-hypnogram.tsx"), - "utf8", - ); - // Lanes come from the present stages, preserving LANE_ORDER… - expect(src).toContain("LANE_ORDER.filter((stage) => present.has(stage))"); - // …and the axis reads the derived lane list, not the full constant. - expect(src).toContain("domain={[-0.5, lanes.length - 0.5]}"); - expect(src).toContain("ticks={lanes.map((_, i) => lanes.length - 1 - i)}"); - }); -}); - -describe(" — timeline bar visibility", () => { - const sharedEnd = "2026-06-09T06:00:00.000Z"; - - it("draws the timeline bar for a multi-stage night whose segments share a right edge", () => { - // Distinct STARTS but a shared END instant — the shape a summary-derived - // writer reconstructs to. The earlier end-instant guard hid the bar here; - // gating on the start instant restores it. (Regression: bar vanished, - // leaving only the breakdown numbers.) - const session: SleepHypnogramSession = { - ...baseSession, - segments: [ - { - stage: "CORE", - start: "2026-06-08T22:30:00.000Z", - end: sharedEnd, - minutes: 200, - }, - { - stage: "DEEP", - start: "2026-06-09T01:00:00.000Z", - end: sharedEnd, - minutes: 100, - }, - { - stage: "REM", - start: "2026-06-09T03:00:00.000Z", - end: sharedEnd, - minutes: 120, - }, - ], - }; - expect(render(session)).toContain('role="img"'); - }); - - it("suppresses the timeline bar for a SYNTHESISED (reconstructed) night", () => { - // WHOOP / Polar report per-stage TOTALS and the server lays the segments - // out in a fixed physiological order — the timeline would invent a phase - // progression that was never measured. So even with distinct, well-timed - // spans, a `reconstructed` night drops the bar and keeps the breakdown. - const session: SleepHypnogramSession = { - ...baseSession, - source: "WHOOP", - reconstructed: true, - }; - const html = render(session); - expect(html).not.toContain('role="img"'); - expect(html).toContain('data-slot="sleep-hypnogram-breakdown"'); - expect(html).toContain('data-slot="sleep-hypnogram-estimate-note"'); - }); - - it("keeps the timeline bar for a measured (non-reconstructed) night", () => { - // An Apple Health night carries real per-stage onsets, so the stepped bar - // is genuine and stays. The estimate note never appears. - const html = render({ ...baseSession, reconstructed: false }); - expect(html).toContain('role="img"'); - expect(html).not.toContain('data-slot="sleep-hypnogram-estimate-note"'); - }); - - it("collapses to the breakdown footer for a degenerate single-instant session", () => { - // Every segment stamped on ONE instant — no real timeline, so only the - // breakdown footer, no bar. - const instant = "2026-06-09T06:00:00.000Z"; - const session: SleepHypnogramSession = { - ...baseSession, - segments: [ - { stage: "CORE", start: instant, end: instant, minutes: 200 }, - { stage: "DEEP", start: instant, end: instant, minutes: 100 }, - ], - }; - const html = render(session); - expect(html).not.toContain('role="img"'); - expect(html).toContain('data-slot="sleep-hypnogram-breakdown"'); - }); -}); - -describe(" — source discrepancy marker", () => { - const discrepancy = { - deltaMinutes: 160, - sources: [ - { source: "WITHINGS", deviceType: null, asleepMinutes: 615 }, - { source: "GOOGLE_HEALTH", deviceType: null, asleepMinutes: 455 }, - ], - }; - - it("renders the discreet marker when the server flags disagreeing sources", () => { - const html = render({ ...baseSession, sourceDiscrepancy: discrepancy }); - expect(html).toContain('data-slot="sleep-source-discrepancy"'); - // Muted marker, no colour wash — the trigger stays on the muted tier. - expect(html).toContain("Sources disagree"); - }); - - it("omits the marker when the night carries no discrepancy", () => { - expect(render(baseSession)).not.toContain( - 'data-slot="sleep-source-discrepancy"', - ); - expect(render({ ...baseSession, sourceDiscrepancy: null })).not.toContain( - 'data-slot="sleep-source-discrepancy"', - ); - }); -}); diff --git a/src/components/insights/sleep-hypnogram.tsx b/src/components/insights/sleep-hypnogram.tsx deleted file mode 100644 index ca6f6f353..000000000 --- a/src/components/insights/sleep-hypnogram.tsx +++ /dev/null @@ -1,417 +0,0 @@ -"use client"; - -import { useMemo } from "react"; -import { - CartesianGrid, - ComposedChart, - ReferenceArea, - ResponsiveContainer, - XAxis, - YAxis, -} from "recharts"; - -import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { TileHeader } from "@/components/insights/tile-header"; -import { - SleepSourceDiscrepancyMarker, - formatMinutes, - type SleepSourceDiscrepancyDto, -} from "@/components/insights/sleep-source-discrepancy-marker"; -import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context"; -import { hourCycleOptions } from "@/lib/format-locale"; -import { STAGE_COLORS } from "./sleep-stage-stacked-bar"; - -/** - * v1.11.5 — last-night HYPNOGRAM (the maintainer directive: the phase-progression - * view). Renders one night's stage timeline as a stepped depth track — - * Awake at the top, Deep at the base — over clock time, in the Dracula - * stage palette reused from `sleep-stage-stacked-bar.tsx` (no token - * reshuffle, per the charts-visual-identity rule). Below the chart: the - * per-stage breakdown (time + %), total asleep, time in bed, and the - * mid-sleep awakenings count. - * - * One canonical source per night (the endpoint already collapsed it), so - * exactly one timeline renders — Apple ↔ WHOOP are never overlaid. - * - * Recharts stays (project rule). Each stage segment is drawn as its own - * `ReferenceArea` rectangle spanning the segment's [start, end] clock range - * at the stage's depth lane, filled with the segment's `STAGE_COLORS` token - * (the same palette the breakdown legend below uses) — so the chart reads as - * a coloured "cityscape" rather than one flat single-colour line. Depth still - * increases downward (Awake top, Deep base). - * - * v1.11.5 — the chart renders only when per-segment `segments` exist - * (`hasTrack`); the per-stage breakdown + totals footer render whenever - * `session.stages` carries any positive value, so a legacy stage-summary - * night (no segment spans) still shows its breakdown instead of collapsing - * the whole card to "unavailable". - */ - -export interface SleepHypnogramSegment { - stage: string | null; - start: string; - end: string; - minutes: number; -} - -export interface SleepHypnogramSession { - night: string; - source: string | null; - start: string; - end: string; - asleepMinutes: number; - inBedMinutes: number | null; - awakeMinutes: number | null; - awakenings: number; - stages: Record; - segments: SleepHypnogramSegment[]; - /** - * `true` when the winning source has no measured per-stage onset - * timestamps and the server synthesised a contiguous timeline in a fixed - * physiological order (WHOOP / Polar). The "timeline" of such a night is - * not a real hypnogram — it would read as one long Core block followed by - * one Deep block, never the phase-to-phase progression a measured night - * shows — so we never draw the misleading stepped bar for it and fall back - * to the per-stage breakdown alone. A real-series source (Apple Health, - * Withings, Fitbit) is `false` and keeps the timeline. - */ - reconstructed?: boolean; - /** - * Non-null when two writer buckets reported clearly different asleep - * totals for this night (server-computed, observational only — the - * headline total stays the winning writer's). Renders as a discreet - * marker with a per-source breakdown in a tooltip. - */ - sourceDiscrepancy?: SleepSourceDiscrepancyDto | null; -} - -export interface SleepHypnogramProps { - session: SleepHypnogramSession; -} - -/** - * Lane order top → bottom. Awake highest, Deep lowest — depth increases - * downward, matching Apple Health / Oura's "cityscape" convention. Only - * the stages actually present in the night's spans get a lane (the - * order is preserved) — painting all six lanes left an Apple night - * (AWAKE/REM/CORE/DEEP) with two permanently empty rows. - */ -const LANE_ORDER = [ - "AWAKE", - "IN_BED", - "ASLEEP", - "REM", - "CORE", - "DEEP", -] as const; - -/** Breakdown order — deepest restorative first, matching the stacked bar. */ -const BREAKDOWN_ORDER = [ - "DEEP", - "REM", - "CORE", - "ASLEEP", - "AWAKE", - "IN_BED", -] as const; - -// v1.28.x — `formatMinutes`, the source/device label maps, and the -// discrepancy marker itself moved to `sleep-source-discrepancy-marker.tsx` -// so the dashboard sleep tile renders the identical hint. - -export function SleepHypnogram({ session }: SleepHypnogramProps) { - const { t, locale } = useTranslations(); - const timeFormat = useTimeFormatPreference(); - - const stageLabels: Record = { - DEEP: t("insights.sleep.stages.deep"), - REM: t("insights.sleep.stages.rem"), - CORE: t("insights.sleep.stages.core"), - ASLEEP: t("insights.sleep.stages.asleep"), - AWAKE: t("insights.sleep.stages.awake"), - IN_BED: t("insights.sleep.stages.inBed"), - }; - - // Build one coloured span per stage segment: its [start, end] clock range - // at the stage's depth lane, in the stage's own palette token. Each span - // renders as a `ReferenceArea` so the chart is multi-coloured (a single - // Recharts line can only carry one stroke). Lanes derive from the stages - // PRESENT in the spans (in `LANE_ORDER` order) so a four-stage night - // paints four lanes, not six. - const { spans, lanes } = useMemo(() => { - const known = new Set(LANE_ORDER); - const segments = [...session.segments] - .filter((seg) => seg.stage != null && known.has(seg.stage)) - .sort( - (a, b) => new Date(a.start).getTime() - new Date(b.start).getTime(), - ); - const present = new Set(segments.map((seg) => seg.stage as string)); - const lanes = LANE_ORDER.filter((stage) => present.has(stage)); - const laneOf: Record = Object.fromEntries( - // Reverse-index so the first present stage sits at the highest y. - lanes.map((s, i) => [s, lanes.length - 1 - i]), - ); - const spans = segments.map((seg) => { - const stage = seg.stage as string; - return { - stage, - x1: new Date(seg.start).getTime(), - x2: new Date(seg.end).getTime(), - lane: laneOf[stage], - fill: STAGE_COLORS[stage], - }; - }); - return { spans, lanes }; - }, [session.segments]); - - const domain = useMemo<[number, number]>( - () => [new Date(session.start).getTime(), new Date(session.end).getTime()], - [session.start, session.end], - ); - - // v1.12.0 — explicit clock-aligned X-axis ticks. With `data={[]}` Recharts - // has no points to derive ticks from and would otherwise label the domain - // endpoints with raw bedtime/wake stamps (e.g. 23:14 / 06:02). Snap to whole - // clock hours and pick a 1–3 h step from the night's span so the axis reads - // 23:00 / 01:00 / 03:00 / 05:00 and stays mobile-legible (≈4–6 ticks). - const xTicks = useMemo(() => { - const [from, to] = domain; - const spanHours = (to - from) / 3_600_000; - if (!Number.isFinite(spanHours) || spanHours <= 0) return []; - // Aim for ~5 ticks: 1 h up to 5 h span, 2 h up to 10 h, else 3 h. - const stepHours = spanHours <= 5 ? 1 : spanHours <= 10 ? 2 : 3; - const stepMs = stepHours * 3_600_000; - // First whole hour at or after `from`, aligned to the step grid. - const firstHour = new Date(from); - firstHour.setMinutes(0, 0, 0); - if (firstHour.getTime() < from) - firstHour.setTime(firstHour.getTime() + 3_600_000); - // Align the first tick to a multiple of the step (relative to midnight). - while (firstHour.getHours() % stepHours !== 0) { - firstHour.setTime(firstHour.getTime() + 3_600_000); - } - const ticks: number[] = []; - for (let t = firstHour.getTime(); t <= to; t += stepMs) { - ticks.push(t); - } - return ticks; - }, [domain]); - - const timeFmt = useMemo( - () => - new Intl.DateTimeFormat(locale === "de" ? "de-DE" : "en-US", { - hour: "2-digit", - minute: "2-digit", - ...hourCycleOptions(timeFormat), - }), - [locale, timeFormat], - ); - - // Per-stage breakdown with percentage of the night's total tracked time. - const breakdown = useMemo(() => { - const total = Object.values(session.stages).reduce((s, v) => s + v, 0); - return BREAKDOWN_ORDER.filter( - (stage) => (session.stages[stage] ?? 0) > 0, - ).map((stage) => { - const mins = session.stages[stage] ?? 0; - return { - stage, - minutes: mins, - pct: total > 0 ? Math.round((mins / total) * 100) : 0, - }; - }); - }, [session.stages]); - - const ariaLabel = t("insights.sleep.hypnogram.ariaLabel", { - asleep: formatMinutes(session.asleepMinutes, locale), - awakenings: String(session.awakenings), - }); - - // The CHART needs per-segment spans; the BREAKDOWN + totals footer only - // need any positive per-stage minutes. A legacy stage-summary night (no - // segment spans) still shows its breakdown rather than collapsing the - // whole card. "Unavailable" is reserved for a truly empty session. - // Spans must also carry more than one distinct START instant: a real - // night with timed stages steps through the clock, so its segments begin - // at different instants and earn the timeline bar. Only a degenerate - // session whose every segment starts on a single instant collapses to the - // breakdown footer. (Gating on the END instant instead hid the bar for an - // ordinary multi-stage night whose segments happened to share a right - // edge — the regression this restores.) - // A SYNTHESISED night (WHOOP / Polar) has no measured stage onsets — the - // server laid its segments out in a fixed physiological order, so drawing - // the stepped bar would invent a phase progression that was never recorded - // (the "Core block then Deep block, no real breakdown" the bar reads as). - // For those we keep the per-stage breakdown + totals, which ARE real, and - // drop the misleading timeline entirely. Real-series sources keep the bar. - const hasTrack = - session.reconstructed !== true && - spans.length > 0 && - (spans.length === 1 || new Set(spans.map((s) => s.x1)).size > 1); - const hasBreakdown = breakdown.length > 0; - - return ( - // v1.12.0 — card tightened: drop the default Card `gap-4 md:gap-6` to - // `gap-3` so the header sits closer to the timeline, reclaiming the empty - // band the maintainer flagged. The chart keeps its 200 px footprint. - - -
- - - {t("insights.sleep.hypnogram.subtitle", { - asleep: formatMinutes(session.asleepMinutes, locale), - })} - {/* Discreet source-discrepancy marker: two writers reported - clearly different totals for this night. Observational — the - headline stays the winning writer's number; the tooltip lists - each source's own total. Shared with the dashboard sleep - tile via `sleep-source-discrepancy-marker.tsx`. */} - - - {/* The night's measuring source rides the header as a muted - caption — it is already on the wire, and "which device - tracked this" is the first question a multi-tracker user - asks about a surprising night. */} - {session.source ? ( - - {t("insights.sleep.hypnogram.source", { - source: session.source, - })} - - ) : null} - {/* When the source reports stage TOTALS but no measured onsets - (WHOOP / Polar) the stepped timeline is suppressed — drawing one - would invent a phase order that was never recorded. Surface a calm - note so the breakdown below doesn't read as a missing chart. */} - {session.reconstructed === true && hasBreakdown ? ( - - {t("insights.sleep.hypnogram.estimateNote")} - - ) : null} -
-
- - {!hasTrack && !hasBreakdown ? ( -

- {t("insights.sleep.stages.unavailable")} -

- ) : ( - <> - {hasTrack && ( -
- - - - timeFmt.format(new Date(v))} - /> - lanes.length - 1 - i)} - stroke="var(--muted-foreground)" - fontSize={10} - width={56} - tickFormatter={(v: number) => { - const stage = lanes[lanes.length - 1 - v]; - return stage ? (stageLabels[stage] ?? "") : ""; - }} - /> - {spans.map((span, i) => ( - - ))} - - -
- )} - - {hasBreakdown && ( - <> -
- {breakdown.map(({ stage, minutes, pct }) => ( -
- - - - {formatMinutes(minutes, locale)} · {pct}% - -
- ))} -
- -
- - {t("insights.sleep.hypnogram.asleepTotal", { - value: formatMinutes(session.asleepMinutes, locale), - })} - - {session.inBedMinutes != null && ( - - {t("insights.sleep.hypnogram.inBedTotal", { - value: formatMinutes(session.inBedMinutes, locale), - })} - - )} - - {t("insights.sleep.hypnogram.awakenings", { - count: String(session.awakenings), - })} - -
- - )} - - )} -
-
- ); -} From 2b89259cced75eaadc6d3bc24b012e0696245ef4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 00:05:12 +0200 Subject: [PATCH 15/33] perf(daily): cache the digest's milestone + tension gather Every 120s poll of every open tab re-derived the S11 tension window and the S12 milestone from ~8.5k rows even though both are usually null and change at most once a day. Wrap the two independent reads in one SWR cell under the existing analytics bucket, keyed per user and local day, with a TTL that mirrors the dashboard-snapshot cell so a scheduled poll lands warm. Riding the analytics bucket's `${userId}|` prefix means the same measurement-write invalidation the snapshot cell already relies on covers this cell too, so a fresh sleep landing still refreshes it. Also hoists the milestone gather and the tension read into one Promise.all instead of two sequential awaits. --- src/lib/daily/__tests__/load-digest.test.ts | 154 ++++++++++++++++++++ src/lib/daily/load-digest.ts | 82 +++++++++-- 2 files changed, 226 insertions(+), 10 deletions(-) create mode 100644 src/lib/daily/__tests__/load-digest.test.ts diff --git a/src/lib/daily/__tests__/load-digest.test.ts b/src/lib/daily/__tests__/load-digest.test.ts new file mode 100644 index 000000000..8a6dae2b9 --- /dev/null +++ b/src/lib/daily/__tests__/load-digest.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * `loadDailyDigest` — cache behaviour for the S11/S12 "extras" (the milestone + * gather + the intraday-tension read). Perf finding: both re-derived from + * scratch on every 120 s poll of every open tab, reading ~8.5k rows to + * surface a usually-null marker. Now wrapped in one SWR cell + * (`loadDailyDigestExtrasCached`, internal to `load-digest.ts`) under the + * shared `analytics` bucket, so this file drives the reads its builder + * touches (`probeRollupCoverage`, `loadIntradayPulse`) and asserts they run + * once per cache generation, not once per request. + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + integrationStatus: { findMany: vi.fn().mockResolvedValue([]) }, + measurementReminder: { findMany: vi.fn().mockResolvedValue([]) }, + coachPlan: { findMany: vi.fn().mockResolvedValue([]) }, + ecgRecording: { findFirst: vi.fn().mockResolvedValue(null) }, + dismissedPriorityItem: { findMany: vi.fn().mockResolvedValue([]) }, + personalRecord: { findMany: vi.fn().mockResolvedValue([]) }, + }, +})); + +vi.mock("@/lib/dashboard/snapshot-read", () => ({ + readDashboardSnapshotCached: vi.fn(), +})); + +vi.mock("@/lib/modules/gate", () => ({ + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); + +vi.mock("@/lib/rollups/measurement-coverage", () => ({ + probeRollupCoverage: vi.fn().mockResolvedValue(new Map()), +})); + +vi.mock("@/lib/insights/derived/baseline", () => ({ + readDayMeanSeries: vi + .fn() + .mockResolvedValue({ points: [], source: "none" }), +})); + +vi.mock("@/lib/analytics/intraday-pulse-io", () => ({ + loadIntradayPulse: vi.fn(), +})); + +vi.mock("@/lib/i18n/server-translator", () => ({ + getServerTranslator: vi.fn().mockReturnValue({ t: (key: string) => key }), +})); + +import type { User } from "@/generated/prisma/client"; +import { loadDailyDigest } from "../load-digest"; +import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read"; +import { resolveModuleMap } from "@/lib/modules/gate"; +import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; +import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io"; +import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; + +const SNAPSHOT = { + body: { + tiles: { lastSeenByType: {} }, + medsToday: { + activeCount: 0, + scheduledToday: 0, + takenToday: 0, + skippedToday: 0, + nextDueAt: null, + nextDueOverdue: false, + }, + healthScore: null, + briefing: null, + briefingState: "ready", + briefingUpdatedAt: null, + briefingStale: false, + }, + locale: "en", +}; + +const USER = { + id: "user-1", + timezone: "Europe/Berlin", + morningDigestRefreshedOn: null, +} as unknown as User; + +const NOW = new Date("2026-07-17T09:00:00.000Z"); + +beforeEach(() => { + vi.clearAllMocks(); + __resetAllCachesForTests(); + vi.mocked(readDashboardSnapshotCached).mockResolvedValue(SNAPSHOT as never); + vi.mocked(resolveModuleMap).mockResolvedValue({} as never); + vi.mocked(probeRollupCoverage).mockResolvedValue(new Map()); + vi.mocked(loadIntradayPulse).mockResolvedValue({ tension: null } as never); +}); + +describe("loadDailyDigest — S11/S12 extras cache", () => { + it("gathers the milestone + tension inputs once, then serves the second poll from cache", async () => { + await loadDailyDigest(USER, NOW); + await loadDailyDigest(USER, NOW); + + expect(probeRollupCoverage).toHaveBeenCalledTimes(1); + expect(loadIntradayPulse).toHaveBeenCalledTimes(1); + }); + + it("runs the milestone gather and the tension read in parallel, not sequentially", async () => { + const order: string[] = []; + vi.mocked(probeRollupCoverage).mockImplementation(async () => { + order.push("milestone-start"); + await new Promise((r) => setTimeout(r, 5)); + order.push("milestone-end"); + return new Map(); + }); + vi.mocked(loadIntradayPulse).mockImplementation(async () => { + order.push("tension-start"); + await new Promise((r) => setTimeout(r, 5)); + order.push("tension-end"); + return { tension: null } as never; + }); + + await loadDailyDigest(USER, NOW); + + // Sequential awaits would read as [milestone-start, milestone-end, + // tension-start, tension-end]. Promise.all interleaves the starts + // before either finishes. + expect(order.slice(0, 2).sort()).toEqual([ + "milestone-start", + "tension-start", + ]); + }); + + it("refreshes on the next read after a measurement write invalidates the user's cache", async () => { + await loadDailyDigest(USER, NOW); + expect(probeRollupCoverage).toHaveBeenCalledTimes(1); + + // A fresh sleep / vitals landing (interactive write) hard-evicts the + // `${userId}|` prefix — the same sweep the dashboard-snapshot cell + // already relies on. + invalidateUserMeasurements(USER.id, { evict: true }); + + await loadDailyDigest(USER, NOW); + expect(probeRollupCoverage).toHaveBeenCalledTimes(2); + expect(loadIntradayPulse).toHaveBeenCalledTimes(2); + }); + + it("never gathers the extras when the insights module is disabled", async () => { + vi.mocked(resolveModuleMap).mockResolvedValue({ insights: false } as never); + + await loadDailyDigest(USER, NOW); + + expect(probeRollupCoverage).not.toHaveBeenCalled(); + expect(loadIntradayPulse).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index 2873c7166..9464470e9 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -22,6 +22,8 @@ import { getServerTranslator } from "@/lib/i18n/server-translator"; import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; import { userDayKey } from "@/lib/tz/format"; +import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval"; import { buildDailyDigest, type DailyDigest, @@ -140,6 +142,61 @@ async function gatherFreshMilestone( return selectFreshMilestone(candidates, todayKey); } +/** + * P — the milestone gather (~8.5k rows via the streak/PR reads) and the + * intraday-tension read (a bounded but real per-sample day read) are the two + * heaviest inputs the digest computes on every request, yet both are usually + * null and change at most once per local day. Every open tab's 120 s poll + * (`DASHBOARD_REFETCH_INTERVAL_MS`) was re-deriving them from scratch. + * + * Cached together in ONE cell — keyed `${userId}|daily-digest-extras|${dateKey}` + * under the shared `analytics` bucket so the write-invalidation semantics + * `readDashboardSnapshotCached` already relies on cover this cell too: every + * measurement write sweeps the `${userId}|` prefix (mark-stale for a + * background sync, hard-evict for an interactive write — see + * `invalidateUserMeasurements`), so a fresh sleep/vitals landing still + * refreshes this within the same SWR contract the snapshot cell uses. TTL + * mirrors `SNAPSHOT_CACHE_TTL_MS` — strictly greater than the client poll + * interval so a scheduled refetch lands warm. + */ +const DIGEST_EXTRAS_CACHE_TTL_MS = DASHBOARD_REFETCH_INTERVAL_MS + 60_000; + +interface DailyDigestExtras { + milestone: Milestone | null; + tensionWindow: DailyDigestTensionWindow | null; +} + +function digestExtrasCacheKey(userId: string, dateKey: string): string { + return `${userId}|daily-digest-extras|${dateKey}`; +} + +async function loadDailyDigestExtrasCached( + userId: string, + timezone: string, + todayLocalDate: string, + now: Date, +): Promise { + return cachedSwr( + caches.analytics as ServerCache, + digestExtrasCacheKey(userId, todayLocalDate), + async () => { + // S11 / S12 — independent reads, hoisted into one Promise.all (a prior + // revision ran them sequentially). Each is already fault-isolated + // internally (a milestone read-hiccup or a tension-read failure leaves + // its own field quiet rather than breaking the other). + const [milestone, tensionWindow] = await Promise.all([ + gatherFreshMilestone(userId, now), + loadIntradayPulse(userId, timezone, todayLocalDate) + .then((r) => (r.tension ? { partOfDay: r.tension.partOfDay } : null)) + .catch(() => null), + ]); + return { milestone, tensionWindow }; + }, + annotate, + DIGEST_EXTRAS_CACHE_TTL_MS, + ); +} + /** S10 — how recently an ECG recording counts as "new" for the rail read. */ const ECG_NEW_WINDOW_MS = 24 * 60 * 60 * 1000; @@ -306,20 +363,25 @@ export async function loadDailyDigest( // S12 — the calm reward layer. Only gather when the insights module (the // narrative layer that hosts the milestone card) is on; the builder gates on // it too, so a disabled account never surfaces one either way. - const insightsEnabled = modules.insights !== false; - const milestone = insightsEnabled - ? await gatherFreshMilestone(user.id, now) - : null; - + // // S11 — the day's elevated-at-rest window, computed on demand from raw for // TODAY only (read-swap, one bounded day-read; never persisted). Gated on the // insights module and fault-isolated: a tension-read failure must never break // the digest (a hot, must-not-fail path), it just omits the calm marker. - const tensionWindow: DailyDigestTensionWindow | null = insightsEnabled - ? await loadIntradayPulse(user.id, user.timezone, todayLocalDate) - .then((r) => (r.tension ? { partOfDay: r.tension.partOfDay } : null)) - .catch(() => null) - : null; + // + // Both ride the cached, single-flight `loadDailyDigestExtrasCached` cell so + // a 120 s poll of every open tab doesn't re-run the ~8.5k-row streak + + // per-sample tension reads on every request — see its docblock for the + // cache/invalidation contract. + const insightsEnabled = modules.insights !== false; + const { milestone, tensionWindow } = insightsEnabled + ? await loadDailyDigestExtrasCached( + user.id, + user.timezone, + todayLocalDate, + now, + ) + : { milestone: null, tensionWindow: null }; // Dismiss ledger (P — Today rail dismiss). Only the OBSERVATIONAL kinds // (milestone / ecg_new_recording / tension_window) can ever be dismissed, so From 2bc706a4453dd845f7d370bda88bd3826e3519be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 00:08:30 +0200 Subject: [PATCH 16/33] perf(insights): read PULSE cards through the day-rollup tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/insights/cards pulled every raw PULSE row from the last 90 days into JS to compute an average and an anomaly count — on a dense wearable account that's 100k+ rows the DAY rollups already summarise. WEIGHT and blood pressure stay on the bounded raw read (manual-entry volume, and blood pressure needs exact-timestamp pairing for the in-target percentage); PULSE now reads through the existing rollup read-swap helper, falling back to a bounded per-day-grouped live read on a coverage miss. --- .../insights/cards/__tests__/route.test.ts | 61 ++++++++++++ src/app/api/insights/cards/route.ts | 93 ++++++++++++------- 2 files changed, 120 insertions(+), 34 deletions(-) diff --git a/src/app/api/insights/cards/__tests__/route.test.ts b/src/app/api/insights/cards/__tests__/route.test.ts index 8b25d71d8..56d842b15 100644 --- a/src/app/api/insights/cards/__tests__/route.test.ts +++ b/src/app/api/insights/cards/__tests__/route.test.ts @@ -11,6 +11,12 @@ vi.mock("@/lib/db", () => ({ // back to the all-on default so existing assertions ride // through unchanged. appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + // v1.28 perf — PULSE now reads through the DAY-rollup read-swap + // (`probeRollupCoverage` + `readDayMeanSeries`). An empty coverage + // probe sends every test down the live-fallback branch, which itself + // reads via the already-mocked `measurement.findMany` — no rollup + // rows needed for these tests. + $queryRaw: vi.fn().mockResolvedValue([]), }, })); @@ -69,6 +75,10 @@ beforeEach(() => { aiProvider: null, } as never); vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); + // v1.28 perf — empty coverage probe routes PULSE through the + // live-fallback branch of `readDayMeanSeries`, which reads via the + // already-mocked `measurement.findMany` above. + vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never); }); const callGet = GET as unknown as (req: NextRequest) => Promise; @@ -165,6 +175,57 @@ describe("GET /api/insights/cards", () => { expect(body.data.some((c) => c.severity === "alert")).toBe(true); }); + it("reads PULSE through the day-rollup read-swap, never the manual raw-row query (perf)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + + // 27 quiet days + 3 outlier days, one raw sample per day — a large + // enough quiet cluster that the 3 outliers still clear the z-score > 2 + // threshold once folded into day means by the live-fallback branch of + // `readDayMeanSeries` (a small n dilutes the mean/stddev too much for + // the outliers to stand out). + const pulseRows: Array<{ value: number; measuredAt: Date }> = []; + const now = new Date(); + for (let i = 0; i < 30; i++) { + const at = new Date(now.getTime() - i * 86_400_000); + pulseRows.push({ value: i < 3 ? 300 : 68, measuredAt: at }); + } + + vi.mocked(prisma.measurement.findMany).mockImplementation( + (async (args: { where?: { type?: unknown } }) => { + // `readDayMeanSeries`'s live-fallback probes a single `type` + // equality filter; the manual WEIGHT/BP query below probes an + // `{ in: [...] }` list — the two calls are distinguishable by shape. + if (args?.where?.type === "PULSE") return pulseRows; + return []; + }) as never, + ); + + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: Array<{ title: string; summary: string }>; + }; + expect( + body.data.some((c) => c.summary.includes("unusual pulse readings")), + ).toBe(true); + + // The bounded manual-measurements query (WEIGHT / BP) must never ask + // for PULSE — a dense wearable account's raw PULSE rows only ever + // reach this route through the day-mean rollup read. + const manualCall = vi + .mocked(prisma.measurement.findMany) + .mock.calls.find( + (call) => + typeof (call[0] as { where?: { type?: { in?: unknown[] } } }) + ?.where?.type === "object", + ); + expect(manualCall).toBeDefined(); + const typeIn = ( + manualCall?.[0] as { where: { type: { in: string[] } } } + ).where.type.in; + expect(typeIn).not.toContain("PULSE"); + }); + it("does NOT flag a fully-adherent weekly injectable as low compliance (#214 regression)", async () => { // Pre-fix this path used a naive `schedules.length × 7` denominator, so a // weekly Monday injectable with its one dose taken computed ~14% and fired diff --git a/src/app/api/insights/cards/route.ts b/src/app/api/insights/cards/route.ts index 6c592bf2d..6525c661f 100644 --- a/src/app/api/insights/cards/route.ts +++ b/src/app/api/insights/cards/route.ts @@ -13,6 +13,8 @@ import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import { summarize, type DataPoint } from "@/lib/analytics/trends"; +import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; +import { readDayMeanSeries } from "@/lib/insights/derived/baseline"; import { getBpTargets } from "@/lib/analytics/bp-targets"; import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target"; import { @@ -72,43 +74,66 @@ export const GET = apiHandler(async () => { await requireAssistantSurface("insightStatus"); annotate({ action: { name: "insights.cards" } }); - const ninetyDaysAgo = new Date(Date.now() - 90 * 86_400_000); + const NINETY_DAY_WINDOW = 90; + const ninetyDaysAgo = new Date(Date.now() - NINETY_DAY_WINDOW * 86_400_000); - const [dbUser, allMeasurements, medications] = await Promise.all([ - prisma.user.findUnique({ - where: { id: user.id }, - select: { heightCm: true, dateOfBirth: true, aiProvider: true }, - }), - prisma.measurement.findMany({ - where: { - userId: user.id, - type: { - in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"], + const [dbUser, manualMeasurements, medications, rollupCoverage] = + await Promise.all([ + prisma.user.findUnique({ + where: { id: user.id }, + select: { heightCm: true, dateOfBirth: true, aiProvider: true }, + }), + // WEIGHT + BP are manual entries (low volume, and BP needs exact- + // timestamp pairing below for `bpPctInTarget`), so a bounded raw read + // stays cheap. PULSE is excluded here — a continuously-synced wearable + // account can carry 100k+ raw rows in the window; it reads through the + // rollup tier below instead. + prisma.measurement.findMany({ + where: { + userId: user.id, + type: { in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"] }, + measuredAt: { gte: ninetyDaysAgo }, + deletedAt: null, }, - measuredAt: { gte: ninetyDaysAgo }, - deletedAt: null, - }, - orderBy: { measuredAt: "asc" }, - select: { type: true, value: true, measuredAt: true }, - }), - prisma.medication.findMany({ - // v1.16.11 — as-needed (PRN) medications never surface a compliance - // rate (no expected doses). - where: { userId: user.id, active: true, asNeeded: false }, - include: { - // Schedules through the shared compliance select so the configured - // per-dose windows reach the engine like every other surface. - schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, - // v1.16.3 — archived schedule eras for era-aware expected counts. - scheduleRevisions: { orderBy: { validFrom: "asc" } }, - // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. - pauseEras: { select: { pausedAt: true, resumedAt: true } }, - }, - }), - ]); + orderBy: { measuredAt: "asc" }, + select: { type: true, value: true, measuredAt: true }, + }), + prisma.medication.findMany({ + // v1.16.11 — as-needed (PRN) medications never surface a compliance + // rate (no expected doses). + where: { userId: user.id, active: true, asNeeded: false }, + include: { + // Schedules through the shared compliance select so the configured + // per-dose windows reach the engine like every other surface. + schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, + // v1.16.3 — archived schedule eras for era-aware expected counts. + scheduleRevisions: { orderBy: { validFrom: "asc" } }, + // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. + pauseEras: { select: { pausedAt: true, resumedAt: true } }, + }, + }), + probeRollupCoverage(user.id), + ]); + + // v1.28 perf — PULSE reads the DAY-rollup tier (read-swap; falls back to a + // bounded per-day-grouped live read on a coverage miss) instead of pulling + // every raw sample into JS. `pulseAvg30` / `pulseAnomalyCount` derive from + // the resulting day-mean series, so a dense wearable account's anomaly + // count reflects daily outliers rather than every noisy raw sample. + const { points: pulseDayMeans } = await readDayMeanSeries( + user.id, + "PULSE", + NINETY_DAY_WINDOW, + new Date(), + rollupCoverage, + ); + const pulseData: DataPoint[] = pulseDayMeans.map((p) => ({ + date: new Date(`${p.day}T00:00:00.000Z`), + value: p.mean, + })); const byType = (t: MeasurementType): DataPoint[] => - allMeasurements + manualMeasurements .filter((m) => m.type === t) .map((m) => ({ date: m.measuredAt, value: m.value })); @@ -119,11 +144,11 @@ export const GET = apiHandler(async () => { "WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", - "PULSE", ] as MeasurementType[]) { const data = byType(t); if (data.length > 0) summaries[t] = summarize(data); } + if (pulseData.length > 0) summaries.PULSE = summarize(pulseData); let bmi: number | null = null; if (dbUser?.heightCm && summaries.WEIGHT?.latest) { From 2c6699f682003027777a036eca7fd18883274fad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 00:13:45 +0200 Subject: [PATCH 17/33] perf(batch): replace per-entry existence probes with batched reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three batch-ingest routes ran a per-entry findUnique/upsert loop — up to 1000 sequential round trips on a 500-entry batch. - nutrients/batch: one indexed findMany existence read splits the batch into a bulk createMany (new day/nutrient pairs) and a $transaction of per-row updates (existing pairs), the shape measurements/batch already uses. A write failure now marks its whole group skipped rather than one entry, documented at the call site; validation failures stay isolated per entry as before. - mood-entries/bulk: the per-entry findUnique probe is replaced by one batched findMany keyed exactly like each entry's own dedup contract (externalId when carried, else date+moodLoggedAt). The per-entry upsert, tombstone check, tag-link writes and reverse push are unchanged — only the existence read is batched. --- .../mood-entries/bulk/__tests__/route.test.ts | 115 +++++++--- src/app/api/mood-entries/bulk/route.ts | 62 ++++- .../nutrients/batch/__tests__/route.test.ts | 154 +++++++++---- src/app/api/nutrients/batch/route.ts | 216 ++++++++++++------ 4 files changed, 394 insertions(+), 153 deletions(-) diff --git a/src/app/api/mood-entries/bulk/__tests__/route.test.ts b/src/app/api/mood-entries/bulk/__tests__/route.test.ts index 8742b60d8..7bf24ea65 100644 --- a/src/app/api/mood-entries/bulk/__tests__/route.test.ts +++ b/src/app/api/mood-entries/bulk/__tests__/route.test.ts @@ -1,6 +1,14 @@ /** * v1.4.43 W6 — multi-issue 422 envelope on POST /api/mood-entries/bulk. * Preserves the `mood.bulk.invalid` errorCode meta passthrough. + * + * v1.29 perf fix — the per-entry `findUnique` probe was replaced by one + * batched `findMany` existence read (a single indexed OR query); every + * entry still resolves its own "inserted" / "duplicate" state through the + * exact key its dedup contract uses (externalId when carried, else the + * legacy date+moodLoggedAt key). The per-entry `upsert` + tombstone check + + * tag links + reverse push are unchanged. Tests below drive `findMany` + * instead of `findUnique`. */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; @@ -8,7 +16,7 @@ import { NextRequest } from "next/server"; vi.mock("@/lib/db", () => ({ prisma: { moodEntry: { - findUnique: vi.fn(), + findMany: vi.fn(), upsert: vi.fn(), }, auditLog: { create: vi.fn() }, @@ -84,6 +92,29 @@ function postReq(body: unknown): NextRequest { }); } +/** + * Shape the bulk pre-read's `findMany` select always returns. Every mocked + * "existing row" needs every field the route registers into its two lookup + * maps, even when a test only cares about one of them. + */ +function existingRow(overrides: { + id: string; + deletedAt?: Date | null; + source?: string; + externalId?: string | null; + date?: string; + moodLoggedAt?: Date; +}) { + return { + deletedAt: null, + source: "MANUAL", + externalId: null, + date: "2026-05-16", + moodLoggedAt: new Date("2026-05-16T08:00:00.000Z"), + ...overrides, + }; +} + beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); @@ -190,7 +221,7 @@ describe("POST /api/mood-entries/bulk — 422 multi-issue (v1.4.43 W6)", () => { describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => { beforeEach(() => { - vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue(null as never); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([]); vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({ id: "entry-1", } as never); @@ -272,11 +303,7 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => { ); }); - it("dedups on (userId, source, externalId) when an entry carries externalId", async () => { - vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue(null as never); - vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({ - id: "entry-ext", - } as never); + it("reads existence once via a batched findMany, keyed on (source, externalId) when the entry carries one", async () => { const res = await POST( postReq({ entries: [ @@ -293,11 +320,15 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => { data: { inserted: number; entries: Array<{ externalId?: string }> }; }; expect(json.data.inserted).toBe(1); - // The probe + upsert both key on the externalId compound, not the - // legacy wall-clock tuple. - const probeWhere = vi.mocked(prisma.moodEntry.findUnique).mock.calls[0]?.[0] - ?.where as Record; - expect(probeWhere).toHaveProperty("userId_source_externalId"); + expect(prisma.moodEntry.findMany).toHaveBeenCalledTimes(1); + const findManyArg = vi.mocked(prisma.moodEntry.findMany).mock.calls[0]?.[0] as { + where: { userId: string; OR: Array> }; + }; + expect(findManyArg.where.OR).toContainEqual({ + source: "MANUAL", + externalId: "ios-uuid-1", + }); + // The per-entry upsert still keys on the compound unique. const upsertArg = vi.mocked(prisma.moodEntry.upsert).mock.calls[0]?.[0] as { where: Record; create: { externalId: string | null; source: string }; @@ -308,11 +339,7 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => { expect(json.data.entries[0].externalId).toBe("ios-uuid-1"); }); - it("uses the legacy wall-clock key and omits externalId when none is sent", async () => { - vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue(null as never); - vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({ - id: "entry-legacy", - } as never); + it("keys the batched existence read on (date, moodLoggedAt) and omits externalId when none is sent", async () => { const res = await POST( postReq({ entries: [{ mood: "OKAY", moodLoggedAt: "2026-05-16T08:00:00.000Z" }], @@ -322,18 +349,30 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => { const json = (await res.json()) as { data: { entries: Array<{ externalId?: string }> }; }; - const probeWhere = vi.mocked(prisma.moodEntry.findUnique).mock.calls[0]?.[0] - ?.where as Record; - expect(probeWhere).toHaveProperty("userId_date_moodLoggedAt"); + const findManyArg = vi.mocked(prisma.moodEntry.findMany).mock.calls[0]?.[0] as { + where: { OR: Array> }; + }; + expect(findManyArg.where.OR).toContainEqual({ + date: "2026-05-16", + moodLoggedAt: new Date("2026-05-16T08:00:00.000Z"), + }); + const upsertArg = vi.mocked(prisma.moodEntry.upsert).mock.calls[0]?.[0] as { + where: Record; + }; + expect(upsertArg.where).toHaveProperty("userId_date_moodLoggedAt"); expect(json.data.entries[0].externalId).toBeUndefined(); }); it("reports a re-posted externalId entry as a duplicate (idempotent)", async () => { - // Probe finds the row → the response classifies it duplicate, not - // inserted, and the upsert update branch refreshes it in place. - vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue({ - id: "entry-ext", - } as never); + // The batched pre-read finds the row → the response classifies it + // duplicate, not inserted, and the upsert update branch refreshes it. + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([ + existingRow({ + id: "entry-ext", + source: "MANUAL", + externalId: "ios-uuid-1", + }), + ] as never); vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({ id: "entry-ext", } as never); @@ -416,13 +455,17 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => { describe("POST /api/mood-entries/bulk — tombstone suppression", () => { it("keeps a tombstoned match deleted: true no-op reported as duplicate", async () => { - // A soft-deleted row under the probe key: the sync feed already + // A soft-deleted row under the batched pre-read: the sync feed already // surfaced the deletion to paired clients, so a stale offline re-post // must NOT resurrect it — and must not churn the hidden row either. - vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue({ - id: "entry-tomb", - deletedAt: new Date("2026-05-01T00:00:00.000Z"), - } as never); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([ + existingRow({ + id: "entry-tomb", + deletedAt: new Date("2026-05-01T00:00:00.000Z"), + source: "MANUAL", + externalId: "ios-uuid-tomb", + }), + ] as never); const res = await POST( postReq({ entries: [ @@ -455,10 +498,14 @@ describe("POST /api/mood-entries/bulk — tombstone suppression", () => { }); it("still refreshes a LIVE match in place (duplicate + upsert runs)", async () => { - vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue({ - id: "entry-live", - deletedAt: null, - } as never); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([ + existingRow({ + id: "entry-live", + deletedAt: null, + source: "MANUAL", + externalId: "ios-uuid-live", + }), + ] as never); vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({ id: "entry-live", } as never); diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index 7afe11bd4..c74dc1d9a 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -189,6 +189,51 @@ async function postBulk(request: NextRequest): Promise { let duplicates = 0; const skipped: Array<{ index: number; reason: string }> = []; + // Perf — a single indexed existence read replaces the per-entry + // `findUnique` probe (up to 500 sequential round trips pre-fix). Every + // entry still resolves through exactly the key its own dedup contract + // uses (externalId when carried, else the legacy date+moodLoggedAt key), + // so lookups below are byte-identical to the per-entry probe — only the + // read is now batched. The per-entry `upsert` + tombstone check + tag + // links + reverse push all stay unchanged; batching those further would + // risk the correctness-sensitive per-row side effects for a write path + // that is usually small (a normal sync posts the current + previous + // local day, not a full historical backfill). + interface ExistingMoodRow { + id: string; + deletedAt: Date | null; + } + const orClauses: Array< + | { source: string; externalId: string } + | { date: string; moodLoggedAt: Date } + > = entries.map((entry) => + entry.externalId + ? { source: entry.source, externalId: entry.externalId } + : { date: moodDateKey(entry.moodLoggedAt, tz), moodLoggedAt: entry.moodLoggedAt }, + ); + const existingRows = + orClauses.length > 0 + ? await prisma.moodEntry.findMany({ + where: { userId: user.id, OR: orClauses }, + select: { + id: true, + deletedAt: true, + source: true, + externalId: true, + date: true, + moodLoggedAt: true, + }, + }) + : []; + const existingByExternalKey = new Map(); + const existingByDateKey = new Map(); + for (const row of existingRows) { + if (row.externalId !== null) { + existingByExternalKey.set(`${row.source}::${row.externalId}`, row); + } + existingByDateKey.set(`${row.date}::${row.moodLoggedAt.toISOString()}`, row); + } + for (let i = 0; i < entries.length; i++) { const entry = entries[i]; const date = moodDateKey(entry.moodLoggedAt, tz); @@ -220,15 +265,14 @@ async function postBulk(request: NextRequest): Promise { }, }; - // Probe-then-upsert so the response reliably distinguishes - // "inserted" from "duplicate". Two round-trips per entry is - // acceptable given the 500-entry cap; a more cache-friendly - // shape (batched probe) is a v1.4.31 optimisation if the cap - // grows. - const existing = await prisma.moodEntry.findUnique({ - where: probeWhere, - select: { id: true, deletedAt: true }, - }); + // The response reliably distinguishes "inserted" from "duplicate" + // via the batched pre-read above (keyed exactly like `probeWhere`), + // so the per-entry probe here is now a Map lookup, not a round trip. + const existing = + (entry.externalId + ? existingByExternalKey.get(`${resolvedSource}::${entry.externalId}`) + : existingByDateKey.get(`${date}::${entry.moodLoggedAt.toISOString()}`)) ?? + null; // Tombstone suppression: a soft-deleted match stays deleted. The // user-facing DELETE route flips `deletedAt` so the `/api/sync/changes` diff --git a/src/app/api/nutrients/batch/__tests__/route.test.ts b/src/app/api/nutrients/batch/__tests__/route.test.ts index 5afdb0047..1d53a6597 100644 --- a/src/app/api/nutrients/batch/__tests__/route.test.ts +++ b/src/app/api/nutrients/batch/__tests__/route.test.ts @@ -3,8 +3,15 @@ * * Pins the ingest posture: module gate first (403 `module.disabled`), * per-entry skip guards (unit mismatch / plausibility / day key), the - * insert-vs-update statuses off the composite-PK probe, and the batch - * envelope errors (`too_large`, multi-issue `invalid`). + * insert-vs-update statuses off a single bulk existence probe, and the + * batch envelope errors (`too_large`, multi-issue `invalid`). + * + * v1.29 perf fix — the probe-then-upsert-per-entry pattern (up to 1000 + * sequential round trips) was replaced by one indexed `findMany` existence + * read + a bulk `createMany` for new pairs + a `$transaction` of per-row + * `update` calls for existing pairs. `$transaction` is mocked to actually + * run the array of promises it's handed so the update path executes for + * real in these tests. */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; @@ -12,9 +19,11 @@ import { NextRequest } from "next/server"; vi.mock("@/lib/db", () => ({ prisma: { nutrientIntakeDay: { - findUnique: vi.fn(), - upsert: vi.fn(), + findMany: vi.fn(), + createMany: vi.fn(), + update: vi.fn(), }, + $transaction: vi.fn(), }, })); @@ -93,8 +102,20 @@ beforeEach(() => { vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); vi.mocked(auditLog).mockResolvedValue(undefined as never); - vi.mocked(prisma.nutrientIntakeDay.findUnique).mockResolvedValue(null); - vi.mocked(prisma.nutrientIntakeDay.upsert).mockResolvedValue({} as never); + // No existing (day, nutrient) pairs by default — every valid entry + // takes the insert path unless a test overrides this. + vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([]); + vi.mocked(prisma.nutrientIntakeDay.createMany).mockImplementation( + (async (args: { data: unknown[] }) => ({ + count: args.data.length, + })) as never, + ); + vi.mocked(prisma.nutrientIntakeDay.update).mockResolvedValue({} as never); + // Real Prisma resolves every promise in the `$transaction([...])` array; + // the mock does the same so the update path actually runs in these tests. + vi.mocked(prisma.$transaction).mockImplementation( + (async (ops: unknown[]) => Promise.all(ops)) as never, + ); }); describe("POST /api/nutrients/batch — module gate", () => { @@ -122,7 +143,7 @@ describe("POST /api/nutrients/batch — module gate", () => { expect(body.data).toBeNull(); expect(body.meta?.errorCode).toBe("module.disabled"); expect(body.meta?.module).toBe("nutrients"); - expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled(); + expect(prisma.nutrientIntakeDay.createMany).not.toHaveBeenCalled(); }); }); @@ -190,7 +211,8 @@ describe("POST /api/nutrients/batch — per-entry skip guards", () => { reason: "unit_mismatch", }); expect(body.data.skipped).toEqual([{ index: 0, reason: "unit_mismatch" }]); - expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled(); + expect(prisma.nutrientIntakeDay.findMany).not.toHaveBeenCalled(); + expect(prisma.nutrientIntakeDay.createMany).not.toHaveBeenCalled(); }); it("skips a value above the plausibility cap", async () => { @@ -205,7 +227,7 @@ describe("POST /api/nutrients/batch — per-entry skip guards", () => { const body = (await res.json()) as BatchResponse; expect(body.data.entries[0].status).toBe("skipped"); expect(body.data.entries[0].reason).toBe("value_out_of_range"); - expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled(); + expect(prisma.nutrientIntakeDay.createMany).not.toHaveBeenCalled(); }); it("skips an impossible calendar day and a far-future day as day_invalid", async () => { @@ -224,7 +246,7 @@ describe("POST /api/nutrients/batch — per-entry skip guards", () => { "day_invalid", "day_invalid", ]); - expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled(); + expect(prisma.nutrientIntakeDay.createMany).not.toHaveBeenCalled(); }); it("a skipped entry never fails the batch — siblings still land", async () => { @@ -240,12 +262,13 @@ describe("POST /api/nutrients/batch — per-entry skip guards", () => { expect(body.data.processed).toBe(2); expect(body.data.inserted).toBe(1); expect(body.data.skipped).toHaveLength(1); - expect(prisma.nutrientIntakeDay.upsert).toHaveBeenCalledTimes(1); + // One bulk createMany call carries the single valid entry. + expect(prisma.nutrientIntakeDay.createMany).toHaveBeenCalledTimes(1); }); }); -describe("POST /api/nutrients/batch — upsert semantics", () => { - it("reports inserted for a fresh (day, nutrient) and writes the catalog's canonical unit", async () => { +describe("POST /api/nutrients/batch — write semantics", () => { + it("reports inserted for a fresh (day, nutrient), reads existence once, and writes the catalog's canonical unit", async () => { const day = recentDay(); const res = await POST( postReq({ @@ -265,42 +288,43 @@ describe("POST /api/nutrients/batch — upsert semantics", () => { expect(body.data.updated).toBe(0); expect(body.data.entries[0].status).toBe("inserted"); - expect(prisma.nutrientIntakeDay.upsert).toHaveBeenCalledWith({ + // Single indexed existence read, not a per-entry probe. + expect(prisma.nutrientIntakeDay.findMany).toHaveBeenCalledTimes(1); + expect(prisma.nutrientIntakeDay.findMany).toHaveBeenCalledWith({ where: { - userId_day_nutrient_source: { + userId: "user-1", + source: "APPLE_HEALTH", + OR: [{ day, nutrient: "vitamin_d" }], + }, + select: { day: true, nutrient: true }, + }); + + expect(prisma.nutrientIntakeDay.createMany).toHaveBeenCalledWith({ + data: [ + { userId: "user-1", day, nutrient: "vitamin_d", + amount: 22.5, + unit: "ug", source: "APPLE_HEALTH", + externalSourceVersion: "yazio-9.9", }, - }, - create: { - userId: "user-1", - day, - nutrient: "vitamin_d", - amount: 22.5, - unit: "ug", - source: "APPLE_HEALTH", - externalSourceVersion: "yazio-9.9", - }, - update: { - amount: 22.5, - unit: "ug", - externalSourceVersion: "yazio-9.9", - }, + ], + skipDuplicates: true, }); + expect(prisma.nutrientIntakeDay.update).not.toHaveBeenCalled(); }); it("reports updated (never duplicate) when the composite key already exists", async () => { - vi.mocked(prisma.nutrientIntakeDay.findUnique).mockResolvedValue({ - userId: "user-1", - } as never); + const day = recentDay(); + vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([ + { day, nutrient: "caffeine" }, + ] as never); const res = await POST( postReq({ - entries: [ - { day: recentDay(), nutrient: "caffeine", unit: "mg", amount: 310 }, - ], + entries: [{ day, nutrient: "caffeine", unit: "mg", amount: 310 }], }), ); const body = (await res.json()) as BatchResponse; @@ -310,6 +334,20 @@ describe("POST /api/nutrients/batch — upsert semantics", () => { expect( body.data.entries.some((e) => e.status === ("duplicate" as string)), ).toBe(false); + + expect(prisma.nutrientIntakeDay.createMany).not.toHaveBeenCalled(); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(prisma.nutrientIntakeDay.update).toHaveBeenCalledWith({ + where: { + userId_day_nutrient_source: { + userId: "user-1", + day, + nutrient: "caffeine", + source: "APPLE_HEALTH", + }, + }, + data: { amount: 310, unit: "mg", externalSourceVersion: null }, + }); }); it("writes the audit-ledger breadcrumb only when at least one row landed", async () => { @@ -335,19 +373,24 @@ describe("POST /api/nutrients/batch — upsert semantics", () => { ); }); - it("a thrown upsert marks only that entry skipped upsert_failed without leaking the raw error", async () => { + it("a failed bulk insert marks only the insert group skipped, without touching the update group", async () => { // The exception message must never reach the client — the per-entry - // `reason` is a closed set. A raw-message echo was a v1.28.48 fix. + // `reason` is a closed set. const spy = vi.spyOn(console, "error").mockImplementation(() => {}); - vi.mocked(prisma.nutrientIntakeDay.upsert) - .mockRejectedValueOnce(new Error("boom: sensitive db detail")) - .mockResolvedValueOnce({} as never); + const day = recentDay(); + // `zinc` is new (insert path); `iron` already exists (update path). + vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([ + { day, nutrient: "iron" }, + ] as never); + vi.mocked(prisma.nutrientIntakeDay.createMany).mockRejectedValue( + new Error("boom: sensitive db detail"), + ); const res = await POST( postReq({ entries: [ - { day: recentDay(), nutrient: "zinc", unit: "mg", amount: 10 }, - { day: recentDay(), nutrient: "iron", unit: "mg", amount: 12 }, + { day, nutrient: "zinc", unit: "mg", amount: 10 }, + { day, nutrient: "iron", unit: "mg", amount: 12 }, ], }), ); @@ -358,8 +401,29 @@ describe("POST /api/nutrients/batch — upsert semantics", () => { // The raw exception text is logged server-side only, never echoed. expect(JSON.stringify(body)).not.toContain("sensitive db detail"); expect(spy).toHaveBeenCalled(); - expect(body.data.entries[1].status).toBe("inserted"); - expect(body.data.inserted).toBe(1); + // The independent update group still lands. + expect(body.data.entries[1].status).toBe("updated"); + expect(body.data.updated).toBe(1); spy.mockRestore(); }); + + it("a race that drops one createMany row still counts consistently and is observable", async () => { + vi.mocked(prisma.nutrientIntakeDay.createMany).mockResolvedValue({ + count: 1, + } as never); + + const res = await POST( + postReq({ + entries: [ + { day: recentDay(), nutrient: "zinc", unit: "mg", amount: 10 }, + { day: recentDay(2), nutrient: "iron", unit: "mg", amount: 12 }, + ], + }), + ); + const body = (await res.json()) as BatchResponse; + // Both entries are still labelled inserted (count-only trade-off, + // documented at the call site) — the response never fails. + expect(body.data.inserted).toBe(1); + expect(res.status).toBe(200); + }); }); diff --git a/src/app/api/nutrients/batch/route.ts b/src/app/api/nutrients/batch/route.ts index 73506e330..44ea0c9a3 100644 --- a/src/app/api/nutrients/batch/route.ts +++ b/src/app/api/nutrients/batch/route.ts @@ -10,10 +10,14 @@ * Upsert key = the composite PK (userId, day, nutrient). Re-post * replaces amount / unit / externalSourceVersion — last-writer-wins, * the day-total contract; per-entry status is `updated` then, never - * `duplicate`. Per-entry failures come back `skipped` with a reason - * (`unit_mismatch` | `value_out_of_range` | `day_invalid` | - * `upsert_failed`), never a batch failure — the measurements-batch - * posture. + * `duplicate`. Validation failures (`unit_mismatch` | `value_out_of_range` + * | `day_invalid`) are always isolated per entry. The DB write itself runs + * as two batched groups — a single indexed existence read splits valid + * entries into a bulk `createMany` (new pairs) and a `$transaction` of + * per-row updates (existing pairs), the measurements-batch shape. A write + * failure marks its WHOLE group `skipped upsert_failed` rather than + * isolating the one entry that would have caused it (unavoidable once the + * write is batched) — the request itself never fails. * * Module gate FIRST: the opt-in `nutrients` module (default off) * refuses ingest with the 403 `module.disabled` envelope, so a phone @@ -130,11 +134,21 @@ async function postBatch(request: NextRequest): Promise { const { entries } = parsed.data; const dayCeiling = maxAcceptableDay(new Date()); - const results: EntryResult[] = []; + const results: EntryResult[] = new Array(entries.length); const skipped: Array<{ index: number; reason: string }> = []; let inserted = 0; let updated = 0; + interface ValidEntry { + index: number; + day: string; + nutrient: (typeof entries)[number]["nutrient"]; + amount: number; + unit: string; + externalSourceVersion: string | null; + } + const validEntries: ValidEntry[] = []; + for (let i = 0; i < entries.length; i++) { const entry = entries[i]; const definition = NUTRIENT_CATALOG[entry.nutrient]; @@ -144,86 +158,158 @@ async function postBatch(request: NextRequest): Promise { // unit skips the entry rather than converting. if (entry.unit !== definition.unit) { skipped.push({ index: i, reason: "unit_mismatch" }); - results.push({ index: i, status: "skipped", reason: "unit_mismatch" }); + results[i] = { index: i, status: "skipped", reason: "unit_mismatch" }; continue; } if (entry.amount > definition.plausibleDailyMax) { skipped.push({ index: i, reason: "value_out_of_range" }); - results.push({ + results[i] = { index: i, status: "skipped", reason: "value_out_of_range", - }); + }; continue; } if (!isRealCalendarDay(entry.day) || entry.day > dayCeiling) { skipped.push({ index: i, reason: "day_invalid" }); - results.push({ index: i, status: "skipped", reason: "day_invalid" }); + results[i] = { index: i, status: "skipped", reason: "day_invalid" }; continue; } - try { - // v1.29 — `source` joined the composite PK (migration 0249) so a - // manual water entry (source=MANUAL, written by - // `POST /api/nutrients/water`) coexists with the Apple-synced day - // total instead of one clobbering the other on the next sync. The - // batch route always owns the APPLE_HEALTH row — byte-identical - // upsert behaviour to pre-0249, just against the wider key. - const key = { - userId_day_nutrient_source: { - userId: user.id, - day: entry.day, - nutrient: entry.nutrient, - source: "APPLE_HEALTH", - }, - }; + validEntries.push({ + index: i, + day: entry.day, + nutrient: entry.nutrient, + amount: entry.amount, + unit: definition.unit, + externalSourceVersion: entry.externalSourceVersion ?? null, + }); + } - // Probe-then-upsert so the response reliably distinguishes - // `inserted` from `updated` (the mood-bulk pattern; two round - // trips per entry is fine under the 500-entry cap). - const existing = await prisma.nutrientIntakeDay.findUnique({ - where: key, - select: { userId: true }, - }); + // v1.29 — `source` joined the composite PK (migration 0249) so a manual + // water entry (source=MANUAL, written by `POST /api/nutrients/water`) + // coexists with the Apple-synced day total instead of one clobbering the + // other on the next sync. The batch route always owns the APPLE_HEALTH + // row. + // + // Perf (measurements/batch shape): ONE indexed existence read replaces the + // per-entry probe-then-upsert (up to 1000 sequential round trips pre-fix). + // Genuinely new (day, nutrient) pairs land via a single chunked + // `createMany`; pairs that already exist update via a batched + // `$transaction` of per-row `update` calls (values differ per row, so a + // single bulk statement isn't available without raw SQL — this still + // collapses the per-entry PROBE, and pipelines the writes through one + // transaction instead of N standalone round trips). + if (validEntries.length > 0) { + const existing = await prisma.nutrientIntakeDay.findMany({ + where: { + userId: user.id, + source: "APPLE_HEALTH", + OR: validEntries.map((e) => ({ day: e.day, nutrient: e.nutrient })), + }, + select: { day: true, nutrient: true }, + }); + const existingKeys = new Set( + existing.map((r) => `${r.day}::${r.nutrient}`), + ); - await prisma.nutrientIntakeDay.upsert({ - where: key, - create: { - userId: user.id, - day: entry.day, - nutrient: entry.nutrient, - amount: entry.amount, - unit: definition.unit, - source: "APPLE_HEALTH", - externalSourceVersion: entry.externalSourceVersion ?? null, - }, - update: { - // Last-writer-wins on the day total: iOS re-posts the current - // and previous local day on every sync as the totals grow. - amount: entry.amount, - unit: definition.unit, - externalSourceVersion: entry.externalSourceVersion ?? null, - }, - }); + const toInsert: ValidEntry[] = []; + const toUpdate: ValidEntry[] = []; + for (const e of validEntries) { + (existingKeys.has(`${e.day}::${e.nutrient}`) ? toUpdate : toInsert).push( + e, + ); + } - if (existing) { - updated += 1; - results.push({ index: i, status: "updated" }); - } else { - inserted += 1; - results.push({ index: i, status: "inserted" }); + if (toInsert.length > 0) { + try { + const result = await prisma.nutrientIntakeDay.createMany({ + data: toInsert.map((e) => ({ + userId: user.id, + day: e.day, + nutrient: e.nutrient, + amount: e.amount, + unit: e.unit, + source: "APPLE_HEALTH" as const, + externalSourceVersion: e.externalSourceVersion, + })), + skipDuplicates: true, + }); + inserted += result.count; + for (const e of toInsert) { + results[e.index] = { index: e.index, status: "inserted" }; + } + // Rare race: a concurrent request claimed one of these keys between + // our existence probe and this createMany, so skipDuplicates + // silently dropped that row's write here — measurements/batch + // accepts the same count-only trade-off for its own createMany race. + // The DB itself stays consistent (the racing writer's row is + // stored); only this response's per-row label may be stale for + // that one entry. + if (result.count < toInsert.length) { + annotate({ + action: { name: "nutrient.batch.insert_race" }, + meta: { attempted: toInsert.length, stored: result.count }, + }); + } + } catch (err: unknown) { + console.error("[nutrient-batch] bulk insert failed", err); + for (const e of toInsert) { + skipped.push({ index: e.index, reason: "upsert_failed" }); + results[e.index] = { + index: e.index, + status: "skipped", + reason: "upsert_failed", + }; + } + } + } + + if (toUpdate.length > 0) { + try { + await prisma.$transaction( + toUpdate.map((e) => + prisma.nutrientIntakeDay.update({ + where: { + userId_day_nutrient_source: { + userId: user.id, + day: e.day, + nutrient: e.nutrient, + source: "APPLE_HEALTH", + }, + }, + data: { + // Last-writer-wins on the day total: iOS re-posts the + // current and previous local day on every sync as the + // totals grow. + amount: e.amount, + unit: e.unit, + externalSourceVersion: e.externalSourceVersion, + }, + }), + ), + ); + updated += toUpdate.length; + for (const e of toUpdate) { + results[e.index] = { index: e.index, status: "updated" }; + } + } catch (err: unknown) { + // The client-facing `reason` is a closed set (see the file header) — + // never echo raw exception text into it. Log the real error + // server-side only (SWC keeps `console.error` in prod) and return + // the fixed `upsert_failed` member. + console.error("[nutrient-batch] bulk update failed", err); + for (const e of toUpdate) { + skipped.push({ index: e.index, reason: "upsert_failed" }); + results[e.index] = { + index: e.index, + status: "skipped", + reason: "upsert_failed", + }; + } } - } catch (err: unknown) { - // The client-facing `reason` is a closed set (see the file header) — - // never echo raw exception text into it. Log the real error - // server-side only (SWC keeps `console.error` in prod) and return the - // fixed `upsert_failed` member. - console.error("[nutrient-batch] upsert failed", err); - const reason = "upsert_failed"; - skipped.push({ index: i, reason }); - results.push({ index: i, status: "skipped", reason }); } } From e3dfe9f40041d58a891622d6425e148216ad6ba0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 00:17:33 +0200 Subject: [PATCH 18/33] perf(medications): batch the intake CSV import duplicate probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CSV import loop ran a findUnique duplicate check before every single create — up to 2000 sequential round trips on a 1000-row import. Resolve takenAt + the idempotency key for every row first, then read every key's existence in one indexed query before the write loop; create and the per-row inventory consumption stay unchanged. Also runs prettier over the batch-route test files touched in the prior two commits. --- .../insights/cards/__tests__/route.test.ts | 27 +++++++------ .../intake/import/__tests__/route.test.ts | 35 ++++++++++++----- .../medications/[id]/intake/import/route.ts | 38 +++++++++++++++++-- .../mood-entries/bulk/__tests__/route.test.ts | 6 ++- src/app/api/mood-entries/bulk/route.ts | 15 ++++++-- .../nutrients/batch/__tests__/route.test.ts | 5 +-- src/lib/daily/__tests__/load-digest.test.ts | 4 +- 7 files changed, 91 insertions(+), 39 deletions(-) diff --git a/src/app/api/insights/cards/__tests__/route.test.ts b/src/app/api/insights/cards/__tests__/route.test.ts index 56d842b15..e4a162f53 100644 --- a/src/app/api/insights/cards/__tests__/route.test.ts +++ b/src/app/api/insights/cards/__tests__/route.test.ts @@ -190,15 +190,15 @@ describe("GET /api/insights/cards", () => { pulseRows.push({ value: i < 3 ? 300 : 68, measuredAt: at }); } - vi.mocked(prisma.measurement.findMany).mockImplementation( - (async (args: { where?: { type?: unknown } }) => { - // `readDayMeanSeries`'s live-fallback probes a single `type` - // equality filter; the manual WEIGHT/BP query below probes an - // `{ in: [...] }` list — the two calls are distinguishable by shape. - if (args?.where?.type === "PULSE") return pulseRows; - return []; - }) as never, - ); + vi.mocked(prisma.measurement.findMany).mockImplementation((async (args: { + where?: { type?: unknown }; + }) => { + // `readDayMeanSeries`'s live-fallback probes a single `type` + // equality filter; the manual WEIGHT/BP query below probes an + // `{ in: [...] }` list — the two calls are distinguishable by shape. + if (args?.where?.type === "PULSE") return pulseRows; + return []; + }) as never); const res = await callGet(makeReq()); expect(res.status).toBe(200); @@ -216,13 +216,12 @@ describe("GET /api/insights/cards", () => { .mocked(prisma.measurement.findMany) .mock.calls.find( (call) => - typeof (call[0] as { where?: { type?: { in?: unknown[] } } }) - ?.where?.type === "object", + typeof (call[0] as { where?: { type?: { in?: unknown[] } } })?.where + ?.type === "object", ); expect(manualCall).toBeDefined(); - const typeIn = ( - manualCall?.[0] as { where: { type: { in: string[] } } } - ).where.type.in; + const typeIn = (manualCall?.[0] as { where: { type: { in: string[] } } }) + .where.type.in; expect(typeIn).not.toContain("PULSE"); }); 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 e5e3a00a2..3154ea718 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 @@ -3,6 +3,10 @@ * /api/medications/[id]/intake/import. Preserves the * `medication.intake.import.invalid_format` errorCode meta so the CSV * import client UI can branch on the prefix semantics. + * + * v1.29 perf fix — the per-row `findUnique` duplicate probe was replaced by + * one indexed `findMany` existence read over every row's idempotencyKey + * before the write loop. `create` + `consumeForIntake` stay per-row. */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; @@ -13,7 +17,7 @@ vi.mock("@/lib/db", () => ({ findUnique: vi.fn(), }, medicationIntakeEvent: { - findUnique: vi.fn(), + findMany: vi.fn(), create: vi.fn(), }, auditLog: { create: vi.fn() }, @@ -75,6 +79,12 @@ function postReq(body: unknown): NextRequest { const ROUTE_CTX = { params: Promise.resolve({ id: "m1" }) }; +/** Mirrors the route's own dedup-key derivation for a zaehler-less row. */ +function keyFor(medId: string, datum: string, uhrzeit: string): string { + const takenAt = new Date(`${datum}T${uhrzeit}`); + return `import-${medId}-${takenAt.getTime()}`; +} + beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); @@ -172,10 +182,9 @@ describe("POST /api/medications/[id]/intake/import — 422 multi-issue (v1.4.43 describe("POST /api/medications/[id]/intake/import — inventory consumption", () => { beforeEach(() => { - // Fresh imports: no pre-existing dedup row, and create echoes an id. - vi.mocked(prisma.medicationIntakeEvent.findUnique).mockResolvedValue( - null as never, - ); + // Fresh imports: the batched existence read finds no prior rows, and + // create echoes an id. + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([]); let n = 0; vi.mocked(prisma.medicationIntakeEvent.create).mockImplementation( (async () => ({ id: `evt-${++n}`, takenAt: new Date() })) as never, @@ -200,16 +209,24 @@ describe("POST /api/medications/[id]/intake/import — inventory consumption", ( expect(call?.userId).toBe("user-1"); expect(call?.eventId).toBe("evt-1"); + // The three-row import reads existence in ONE indexed query, not one + // findUnique probe per row. + expect(prisma.medicationIntakeEvent.findMany).toHaveBeenCalledTimes(1); + const findManyArg = vi.mocked(prisma.medicationIntakeEvent.findMany).mock + .calls[0]?.[0] as { where: { idempotencyKey: { in: string[] } } }; + expect(findManyArg.where.idempotencyKey.in).toHaveLength(3); + // (#22) — the whole import queues exactly ONE cross-device sync // fan-out, not one per imported row. expect(queueMedicationIntakeSync).toHaveBeenCalledTimes(1); }); it("does not consume for duplicate (already-imported) rows", async () => { - // First entry is a duplicate (existing dedup row), second is fresh. - vi.mocked(prisma.medicationIntakeEvent.findUnique) - .mockResolvedValueOnce({ id: "existing" } as never) - .mockResolvedValueOnce(null as never); + // First entry's key is already present — the batched existence read + // reports it, the second entry's key is absent (fresh). + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + { idempotencyKey: keyFor("m1", "2026-01-01", "07:00:00") }, + ] as never); const res = await POST( postReq([ { datum: "2026-01-01", uhrzeit: "07:00:00" }, diff --git a/src/app/api/medications/[id]/intake/import/route.ts b/src/app/api/medications/[id]/intake/import/route.ts index 75fa97360..6059f98ff 100644 --- a/src/app/api/medications/[id]/intake/import/route.ts +++ b/src/app/api/medications/[id]/intake/import/route.ts @@ -99,6 +99,20 @@ export const POST = apiHandler( // so the rollup pass fires once per day rather than once per row. const touchedDays = new Set(); + // Perf — a CSV import can carry up to 1000 rows; the pre-fix loop ran a + // `findUnique` duplicate probe before every single `create`, up to 2000 + // sequential round trips. Resolve `takenAt` + the dedup key for every + // row first, then read every key's existence in ONE indexed query + // (`idempotencyKey` carries its own unique index) before the write + // loop. `create` + `consumeForIntake` (inventory decrement) stay + // per-row — each has a real side effect keyed off the freshly-created + // event's id, so they aren't safely batchable without a deeper change + // to the inventory-consumption path. + interface PreparedIntake { + takenAt: Date; + idempotencyKey: string; + } + const prepared: PreparedIntake[] = []; for (const entry of entries) { // Parse as local datetime first; if invalid, fall back to CET offset. let takenAt = new Date(`${entry.datum}T${entry.uhrzeit}`); @@ -115,10 +129,26 @@ export const POST = apiHandler( ? `import-${id}-${String(entry.zaehler)}` : `import-${id}-${takenAt.getTime()}`; - const existing = await prisma.medicationIntakeEvent.findUnique({ - where: { idempotencyKey }, - }); - if (existing) { + prepared.push({ takenAt, idempotencyKey }); + } + + const existingRows = + prepared.length > 0 + ? await prisma.medicationIntakeEvent.findMany({ + where: { + idempotencyKey: { in: prepared.map((p) => p.idempotencyKey) }, + }, + select: { idempotencyKey: true }, + }) + : []; + const existingKeys = new Set( + existingRows + .map((r) => r.idempotencyKey) + .filter((k): k is string => k !== null), + ); + + for (const { takenAt, idempotencyKey } of prepared) { + if (existingKeys.has(idempotencyKey)) { skippedDuplicates++; continue; } diff --git a/src/app/api/mood-entries/bulk/__tests__/route.test.ts b/src/app/api/mood-entries/bulk/__tests__/route.test.ts index 7bf24ea65..00a815a76 100644 --- a/src/app/api/mood-entries/bulk/__tests__/route.test.ts +++ b/src/app/api/mood-entries/bulk/__tests__/route.test.ts @@ -321,7 +321,8 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => { }; expect(json.data.inserted).toBe(1); expect(prisma.moodEntry.findMany).toHaveBeenCalledTimes(1); - const findManyArg = vi.mocked(prisma.moodEntry.findMany).mock.calls[0]?.[0] as { + const findManyArg = vi.mocked(prisma.moodEntry.findMany).mock + .calls[0]?.[0] as { where: { userId: string; OR: Array> }; }; expect(findManyArg.where.OR).toContainEqual({ @@ -349,7 +350,8 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => { const json = (await res.json()) as { data: { entries: Array<{ externalId?: string }> }; }; - const findManyArg = vi.mocked(prisma.moodEntry.findMany).mock.calls[0]?.[0] as { + const findManyArg = vi.mocked(prisma.moodEntry.findMany).mock + .calls[0]?.[0] as { where: { OR: Array> }; }; expect(findManyArg.where.OR).toContainEqual({ diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index c74dc1d9a..95fde2461 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -209,7 +209,10 @@ async function postBulk(request: NextRequest): Promise { > = entries.map((entry) => entry.externalId ? { source: entry.source, externalId: entry.externalId } - : { date: moodDateKey(entry.moodLoggedAt, tz), moodLoggedAt: entry.moodLoggedAt }, + : { + date: moodDateKey(entry.moodLoggedAt, tz), + moodLoggedAt: entry.moodLoggedAt, + }, ); const existingRows = orClauses.length > 0 @@ -231,7 +234,10 @@ async function postBulk(request: NextRequest): Promise { if (row.externalId !== null) { existingByExternalKey.set(`${row.source}::${row.externalId}`, row); } - existingByDateKey.set(`${row.date}::${row.moodLoggedAt.toISOString()}`, row); + existingByDateKey.set( + `${row.date}::${row.moodLoggedAt.toISOString()}`, + row, + ); } for (let i = 0; i < entries.length; i++) { @@ -271,8 +277,9 @@ async function postBulk(request: NextRequest): Promise { const existing = (entry.externalId ? existingByExternalKey.get(`${resolvedSource}::${entry.externalId}`) - : existingByDateKey.get(`${date}::${entry.moodLoggedAt.toISOString()}`)) ?? - null; + : existingByDateKey.get( + `${date}::${entry.moodLoggedAt.toISOString()}`, + )) ?? null; // Tombstone suppression: a soft-deleted match stays deleted. The // user-facing DELETE route flips `deletedAt` so the `/api/sync/changes` diff --git a/src/app/api/nutrients/batch/__tests__/route.test.ts b/src/app/api/nutrients/batch/__tests__/route.test.ts index 1d53a6597..1faac5028 100644 --- a/src/app/api/nutrients/batch/__tests__/route.test.ts +++ b/src/app/api/nutrients/batch/__tests__/route.test.ts @@ -113,9 +113,8 @@ beforeEach(() => { vi.mocked(prisma.nutrientIntakeDay.update).mockResolvedValue({} as never); // Real Prisma resolves every promise in the `$transaction([...])` array; // the mock does the same so the update path actually runs in these tests. - vi.mocked(prisma.$transaction).mockImplementation( - (async (ops: unknown[]) => Promise.all(ops)) as never, - ); + vi.mocked(prisma.$transaction).mockImplementation((async (ops: unknown[]) => + Promise.all(ops)) as never); }); describe("POST /api/nutrients/batch — module gate", () => { diff --git a/src/lib/daily/__tests__/load-digest.test.ts b/src/lib/daily/__tests__/load-digest.test.ts index 8a6dae2b9..5e775572c 100644 --- a/src/lib/daily/__tests__/load-digest.test.ts +++ b/src/lib/daily/__tests__/load-digest.test.ts @@ -35,9 +35,7 @@ vi.mock("@/lib/rollups/measurement-coverage", () => ({ })); vi.mock("@/lib/insights/derived/baseline", () => ({ - readDayMeanSeries: vi - .fn() - .mockResolvedValue({ points: [], source: "none" }), + readDayMeanSeries: vi.fn().mockResolvedValue({ points: [], source: "none" }), })); vi.mock("@/lib/analytics/intraday-pulse-io", () => ({ From e8327109b16ac12d9aa94f91fa3d46b5aa0bc4c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 00:38:33 +0200 Subject: [PATCH 19/33] test(nutrients): use relative day-keys so the daily-window test can't date-bomb --- .../api/nutrients/daily/__tests__/route.test.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/app/api/nutrients/daily/__tests__/route.test.ts b/src/app/api/nutrients/daily/__tests__/route.test.ts index e8a7ed9a3..af942a685 100644 --- a/src/app/api/nutrients/daily/__tests__/route.test.ts +++ b/src/app/api/nutrients/daily/__tests__/route.test.ts @@ -118,17 +118,23 @@ describe("GET /api/nutrients/daily", () => { }); it("sums amounts across sources within the same day before bucketing", async () => { + // Relative day-keys — a fixed literal slides out of the window as the + // calendar advances (a `?days=3` window only ever holds today..today-2). + const utcDay = (offset: number) => + new Date(Date.now() - offset * 86_400_000).toISOString().slice(0, 10); + const today = utcDay(0); + const yesterday = utcDay(1); vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([ - { day: "2026-07-16", amount: 900 }, - { day: "2026-07-16", amount: 600 }, - { day: "2026-07-15", amount: 400 }, + { day: today, amount: 900 }, + { day: today, amount: 600 }, + { day: yesterday, amount: 400 }, ] as never); const res = await GET(getReq("?nutrient=water&days=3")); const body = (await res.json()) as DailyResponse; const byDay = new Map(body.data.days.map((d) => [d.day, d.amount])); - expect(byDay.get("2026-07-16")).toBe(1500); - expect(byDay.get("2026-07-15")).toBe(400); + expect(byDay.get(today)).toBe(1500); + expect(byDay.get(yesterday)).toBe(400); }); it("resolves the sex-split reference against the caller's profile sex", async () => { From 7f44e2fa37c39350cdc104d7b46dc6fdb8327a86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 00:47:35 +0200 Subject: [PATCH 20/33] feat(format): add conditional-year date formatters dateShortSmart / dateWithWeekdaySmart add the year only when a date falls outside the current calendar year, so a chart axis or history row never shows a bare "19.02." for a reading from last year. Also adds monthYear for Dec-to-Jan rollover labels (next-dose captions). Year comparison runs in the formatter's own timezone so UTC-pinned bucket-label formatters agree with what they print. --- src/lib/__tests__/format-locale.test.ts | 67 +++++++++++++++++++++++- src/lib/format-locale.ts | 69 +++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/src/lib/__tests__/format-locale.test.ts b/src/lib/__tests__/format-locale.test.ts index cab583128..dea53e046 100644 --- a/src/lib/__tests__/format-locale.test.ts +++ b/src/lib/__tests__/format-locale.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { hourCycleOptions, makeFormatters, @@ -212,6 +212,71 @@ describe("makeFormatters", () => { expect(fmt.date(new Date("2026-10-25T01:30:00Z"))).toBe("25.10.2026"); }); }); + + // Issue #66 (date-format sweep) — `dateShortSmart` / `dateWithWeekdaySmart` + // add the year only when it differs from "now"'s year, so a chart axis, + // history list, or timeline never reads a bare "19.02." for a reading + // from a prior year and mistakes it for this year's. "Now" is faked so + // the boundary is deterministic regardless of the day this suite runs. + describe("conditional-year formatters (#66)", () => { + const RIGHT_NOW = new Date("2026-07-10T12:00:00Z"); + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(RIGHT_NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("dateShortSmart omits the year for a date in the current year", () => { + const de = makeFormatters("de"); + const en = makeFormatters("en"); + const thisYear = new Date("2026-02-19T10:00:00Z"); + expect(de.dateShortSmart(thisYear)).toBe("19.02."); + expect(en.dateShortSmart(thisYear)).not.toContain("2026"); + }); + + it("dateShortSmart includes the year for a date from a prior year", () => { + const de = makeFormatters("de"); + const en = makeFormatters("en"); + // A December-of-last-year date — the exact regression this guards. + const lastDecember = new Date("2025-12-16T10:00:00Z"); + expect(de.dateShortSmart(lastDecember)).toBe("16.12.2025"); + expect(en.dateShortSmart(lastDecember)).toContain("2025"); + }); + + it("dateWithWeekdaySmart mirrors the same boundary", () => { + const de = makeFormatters("de"); + const thisYear = new Date("2026-02-19T10:00:00Z"); + const lastDecember = new Date("2025-12-16T10:00:00Z"); + expect(de.dateWithWeekdaySmart(thisYear)).not.toContain("2026"); + expect(de.dateWithWeekdaySmart(lastDecember)).toContain("2025"); + }); + + it("compares the year in the formatter's own timezone, not the host's", () => { + // 2026-01-01T00:30Z is still 2025-12-31 in America/New_York (UTC-5) — + // the profile-tz formatter must agree with what it actually prints, + // not with a UTC or host-local read of "now"/the value. + const nyFmt = makeFormatters("en", "America/New_York"); + const newYearUtcEve = new Date("2026-01-01T00:30:00Z"); + // RIGHT_NOW (2026-07-10) is year 2026 in New York too, so a value + // that prints as 2025 in New York must carry the year. + expect(nyFmt.dateShortSmart(newYearUtcEve)).toContain("2025"); + }); + + it("monthYear always carries the year (chart axis / next-dose labels)", () => { + const de = makeFormatters("de"); + const en = makeFormatters("en"); + const dec = new Date("2025-12-30T10:00:00Z"); + const jan = new Date("2026-01-15T10:00:00Z"); + expect(de.monthYear(dec)).toContain("2025"); + expect(de.monthYear(jan)).toContain("2026"); + expect(en.monthYear(dec)).toBe("Dec 2025"); + expect(en.monthYear(jan)).toBe("Jan 2026"); + }); + }); }); // v1.25.3 — the time-format preference must reach every hour/minute renderer, diff --git a/src/lib/format-locale.ts b/src/lib/format-locale.ts index 8628b9ba6..7f5d42529 100644 --- a/src/lib/format-locale.ts +++ b/src/lib/format-locale.ts @@ -120,6 +120,23 @@ function asDate(value: DateInput): Date { return value instanceof Date ? value : new Date(value); } +/** + * True when `date` falls in the same calendar year as "now", both read in + * `tz`. Backs the `*Smart` conditional-year formatters below — a date is + * compared in the SAME timezone the formatter renders it in (rather than + * the runtime's local timezone) so a UTC-pinned bucket-label formatter + * (`makeBucketLabelFormatters`, chart axes) and a profile-timezone + * formatter agree with what they actually print. `getDateTimeFormat` is + * the same process-wide cache the rest of this module uses, so the extra + * lookup is cheap. + */ +function isCurrentYearInTz(date: Date, tz: string, localeTag: string): boolean { + const yearOnly = { timeZone: tz, year: "numeric" as const }; + const target = getDateTimeFormat(localeTag, yearOnly).format(date); + const now = getDateTimeFormat(localeTag, yearOnly).format(new Date()); + return target === now; +} + export interface Formatters { /** Integer or decimal with the active locale's decimal separator. */ number: (value: number, fractionDigits?: number) => string; @@ -131,14 +148,34 @@ export interface Formatters { date: (value: DateInput) => string; /** Short date without year, e.g. "19.02." / "Feb 19". */ dateShort: (value: DateInput) => string; + /** + * Short date that adds the year whenever `value` falls outside the + * current calendar year, e.g. "19.02." this year vs. "19.02.2025" for + * a date from last year. Use this instead of `dateShort` on any + * surface that can render a date older than the current year (chart + * axes/tooltips, history lists, timelines) — see the "generell + * überall" year-disambiguation rule. + */ + dateShortSmart: (value: DateInput) => string; /** Full date + time; hour cycle follows the `timeFormat` preference. */ dateTime: (value: DateInput) => string; /** Time only, e.g. "14:30" or "2:30 PM" per the `timeFormat` preference. */ time: (value: DateInput) => string; /** Short weekday + date, e.g. "Do., 19.02." / "Thu, Feb 19". */ dateWithWeekday: (value: DateInput) => string; + /** + * Short weekday + date that adds the year whenever `value` falls + * outside the current calendar year, e.g. "Do., 19.02." this year vs. + * "Do., 19.02.2025" for a date from last year. + */ + dateWithWeekdaySmart: (value: DateInput) => string; /** For axis labels: 3-letter month abbreviation. */ monthShort: (value: DateInput) => string; + /** + * Month + year, e.g. "Feb. 2026" / "Feb 2026" — for axis labels and + * next-dose captions that need to disambiguate a Dec → Jan rollover. + */ + monthYear: (value: DateInput) => string; } export function makeFormatters( @@ -205,6 +242,18 @@ export function makeFormatters( month: "2-digit", }).format(asDate(value)), + dateShortSmart: (value) => { + const d = asDate(value); + return getDateTimeFormat(dateLocale, { + timeZone: tz, + day: "2-digit", + month: "2-digit", + ...(isCurrentYearInTz(d, tz, dateLocale) + ? {} + : { year: "numeric" as const }), + }).format(d); + }, + dateTime: (value) => getDateTimeFormat(dateLocale, { timeZone: tz, @@ -232,11 +281,31 @@ export function makeFormatters( month: "2-digit", }).format(asDate(value)), + dateWithWeekdaySmart: (value) => { + const d = asDate(value); + return getDateTimeFormat(dateLocale, { + timeZone: tz, + weekday: "short", + day: "2-digit", + month: "2-digit", + ...(isCurrentYearInTz(d, tz, dateLocale) + ? {} + : { year: "numeric" as const }), + }).format(d); + }, + monthShort: (value) => getDateTimeFormat(intlLocale, { timeZone: tz, month: "short", }).format(asDate(value)), + + monthYear: (value) => + getDateTimeFormat(intlLocale, { + timeZone: tz, + month: "short", + year: "numeric", + }).format(asDate(value)), }; } From 1ff179178f9bdfde59184d8bcdc136c295715abc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 01:10:13 +0200 Subject: [PATCH 21/33] fix(dates): show the year whenever a date is not the current one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweeps every surface that renders a date from before this year without a year suffix — illness episodes/flares/day-logs, lab and custom-metric charts, the mood chart family, medication titration/side-effects/dose- history/next-dose captions (incl. the Dec→Jan rollover), cycle predictions and history, the BBT chart, the intraday-pulse day-nav, the date-range filter chip, and the nutrient/compliance charts. Each now routes through dateShortSmart / dateWithWeekdaySmart (format-locale.ts) or the matching date-format.ts helper, which add the year only when it differs from the current one — mirroring the workout list's existing model instead of a bare "19.02." that reads as this year's. Also fixes two sites that bypassed the app's locale entirely: the trusted-devices expiry (bare toLocaleDateString, host locale) and the admin token-name timestamp (hard-coded de-DE + Europe/Berlin) now both render through the user's own locale, hour-cycle, and timezone preference. --- .../admin/api-token-overview-section.tsx | 40 +++++++++---------- src/components/charts/health-chart.tsx | 6 +-- .../charts/medication-compliance-chart.tsx | 2 +- src/components/charts/mood-chart.tsx | 6 +-- .../charts/nutrient-daily-bar-chart.tsx | 7 ++-- .../custom-metrics/custom-metric-chart.tsx | 8 ++-- src/components/cycle/bbt-chart.tsx | 8 +++- src/components/cycle/predictions-panel.tsx | 34 ++++++++++------ .../illness/illness-day-timeline.tsx | 2 +- .../illness/illness-episode-detail.tsx | 4 +- src/components/illness/illness-settings.tsx | 2 +- src/components/illness/illness-view.tsx | 8 ++-- .../insights/intraday-pulse-chart.tsx | 2 +- src/components/labs/lab-biomarker-chart.tsx | 8 ++-- .../medications/dose-history-ledger.tsx | 4 +- .../medications/dose-strength-curve.tsx | 4 +- .../medications/glp1-medication-card.tsx | 4 +- .../medications/medication-card.tsx | 2 +- .../medications/medication-table.tsx | 2 +- .../medications/side-effects-section.tsx | 2 +- .../medications/titration-timeline.tsx | 6 ++- .../assessment-history-chart.tsx | 8 ++-- .../settings/trusted-devices-card.tsx | 5 ++- src/components/ui/filter-bar.tsx | 2 +- src/lib/__tests__/date-format.test.ts | 38 +++++++++++++++++- src/lib/__tests__/format-locale.test.ts | 11 ----- src/lib/date-format.ts | 25 ++++++++++++ src/lib/format-locale.ts | 12 ------ 28 files changed, 158 insertions(+), 104 deletions(-) diff --git a/src/components/admin/api-token-overview-section.tsx b/src/components/admin/api-token-overview-section.tsx index 507a74ad6..93044b04d 100644 --- a/src/components/admin/api-token-overview-section.tsx +++ b/src/components/admin/api-token-overview-section.tsx @@ -11,10 +11,11 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { formatDate, formatDateTime } from "@/lib/format"; +import type { Formatters } from "@/lib/format-locale"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { ListRow } from "@/components/ui/list-row"; -import { useTranslations } from "@/lib/i18n/context"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { type ApiTokenInfo } from "./_shared"; import { apiGet } from "@/lib/api/api-fetch"; @@ -114,35 +115,30 @@ function TokenStatusBadge({ const TOKEN_NAME_ISO_RE = /^(.+?)\s+(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2}))(.*)$/; -// Locale-fixed dd.MM.yyyy HH:mm in the Europe/Berlin display zone — the -// rest of the app shows timestamps in Berlin time (`format-locale.ts`), -// so a token issued at 19:46 UTC must read as 21:46 in summer / 20:46 -// in winter, not the raw UTC clock. -const TOKEN_NAME_DATE_FORMATTER = new Intl.DateTimeFormat("de-DE", { - timeZone: "Europe/Berlin", - day: "2-digit", - month: "2-digit", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - hour12: false, -}); - -function formatTokenName(name: string): string { +/** + * Issue #66 (date-format sweep) — this used to hard-code `de-DE` + + * `Europe/Berlin`, so a non-German admin saw a German-formatted + * timestamp baked into the token name regardless of their own locale + * or profile timezone. Routes through the same `fmt.dateTime` every + * other surface renders through, so the admin's locale, hour-cycle + * preference, and profile timezone all apply here too. + */ +function formatTokenName(name: string, fmt: Formatters): string { const match = TOKEN_NAME_ISO_RE.exec(name); if (!match) return name; const [, prefix, iso, rest] = match; const d = new Date(iso); if (Number.isNaN(d.getTime())) return name; - // Intl emits "05.05.2026, 21:46" — strip the comma so the suffix - // reads as a single date+time chunk. - const stamp = TOKEN_NAME_DATE_FORMATTER.format(d).replace(",", ""); + // `fmt.dateTime` emits "05.05.2026, 21:46" — strip the comma so the + // suffix reads as a single date+time chunk. + const stamp = fmt.dateTime(d).replace(",", ""); const formatted = `${prefix} · ${stamp}`; return rest && rest.trim() ? `${formatted} ${rest.trim()}` : formatted; } export function ApiTokenOverviewSection() { const { t } = useTranslations(); + const fmt = useFormatters(); // v1.4.19 phase A7 — `/admin/api-tokens` is a dedicated single- // section route. The previous pattern carried a "Collapse / Expand" // toggle as an escape hatch from the v1.4 shared admin page where @@ -232,7 +228,9 @@ export function ApiTokenOverviewSection() { - +
@@ -303,7 +301,7 @@ export function ApiTokenOverviewSection() {
diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index adb101917..ee68e1142 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -952,7 +952,7 @@ export function HealthChart({ const allData: ChartDataPoint[] = Array.from(dailyAggregates.values()) .map((bucket) => { const point: ChartDataPoint = { - date: tzFmt.dateShort(new Date(bucket.timestamp)), + date: tzFmt.dateShortSmart(new Date(bucket.timestamp)), timestamp: bucket.timestamp, }; @@ -1079,7 +1079,7 @@ export function HealthChart({ ).points.map((point) => { const date = new Date(point.timestamp); const out: ChartDataPoint = { - date: tzFmt.dateShort(date), + date: tzFmt.dateShortSmart(date), timestamp: point.timestamp, }; for (const [type, value] of Object.entries(point.values)) { @@ -1209,7 +1209,7 @@ export function HealthChart({ // Index shifted rows by the same day-key the chart already uses. const shiftedByDay = new Map>(); for (const row of shifted) { - const dayKey = tzFmt.dateShort(new Date(row.timestamp)); + const dayKey = tzFmt.dateShortSmart(new Date(row.timestamp)); const slot = shiftedByDay.get(dayKey) ?? {}; for (const [type, value] of Object.entries(row.values)) { if (typeof value === "number" && Number.isFinite(value)) { diff --git a/src/components/charts/medication-compliance-chart.tsx b/src/components/charts/medication-compliance-chart.tsx index de819d182..303cd8cde 100644 --- a/src/components/charts/medication-compliance-chart.tsx +++ b/src/components/charts/medication-compliance-chart.tsx @@ -284,7 +284,7 @@ export function MedicationComplianceChart({ const chartData = useMemo( () => data - ? aggregateMedicationCompliance(data, (d) => tzFmt.dateShort(d)) + ? aggregateMedicationCompliance(data, (d) => tzFmt.dateShortSmart(d)) : [], [data, tzFmt], ); diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx index d641e9ce6..33715a22e 100644 --- a/src/components/charts/mood-chart.tsx +++ b/src/components/charts/mood-chart.tsx @@ -400,7 +400,7 @@ export function MoodChart({ const allPoints: ChartDataPoint[] = data.entries .map((entry) => ({ - date: tzFmt.dateShort(new Date(dayKeyToTimestamp(entry.date))), + date: tzFmt.dateShortSmart(new Date(dayKeyToTimestamp(entry.date))), timestamp: dayKeyToTimestamp(entry.date), pointIndex: 0, score: entry.score, @@ -436,7 +436,7 @@ export function MoodChart({ })), { bucket: bucketType }, ).points.map((point) => ({ - date: tzFmt.dateShort(new Date(point.timestamp)), + date: tzFmt.dateShortSmart(new Date(point.timestamp)), timestamp: point.timestamp, pointIndex: 0, score: point.values.score, @@ -513,7 +513,7 @@ export function MoodChart({ const shiftedByDay = new Map(); for (const row of shifted) { - const dayKey = tzFmt.dateShort(new Date(row.timestamp)); + const dayKey = tzFmt.dateShortSmart(new Date(row.timestamp)); if (typeof row.score === "number" && Number.isFinite(row.score)) { shiftedByDay.set(dayKey, row.score); } diff --git a/src/components/charts/nutrient-daily-bar-chart.tsx b/src/components/charts/nutrient-daily-bar-chart.tsx index 84bd26eaa..074418f28 100644 --- a/src/components/charts/nutrient-daily-bar-chart.tsx +++ b/src/components/charts/nutrient-daily-bar-chart.tsx @@ -14,7 +14,7 @@ import { import { CHART_HEIGHT_PX } from "@/lib/charts/constants"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; -import { formatDateShort } from "@/lib/format"; +import { useFormatters } from "@/lib/i18n/context"; import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip"; @@ -49,6 +49,7 @@ export function NutrientDailyBarChart({ valueLabel, referenceValue, }: NutrientDailyBarChartProps) { + const fmt = useFormatters(); const points = useMemo( () => days.map((d) => ({ day: d.day, amount: Math.round(d.amount) })), [days], @@ -77,7 +78,7 @@ export function NutrientDailyBarChart({ /> formatDateShort(dayToDate(day))} + tickFormatter={(day: string) => fmt.dateShortSmart(dayToDate(day))} tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} stroke="var(--border)" minTickGap={24} @@ -116,7 +117,7 @@ export function NutrientDailyBarChart({ return ( ); diff --git a/src/components/custom-metrics/custom-metric-chart.tsx b/src/components/custom-metrics/custom-metric-chart.tsx index 9e98a35ff..9cfadde5e 100644 --- a/src/components/custom-metrics/custom-metric-chart.tsx +++ b/src/components/custom-metrics/custom-metric-chart.tsx @@ -16,8 +16,7 @@ import { import { Button } from "@/components/ui/button"; import { CHART_HEIGHT_PX } from "@/lib/charts/constants"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; -import { formatDateShort } from "@/lib/format"; -import { useTranslations } from "@/lib/i18n/context"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { RichChartTooltip, type RichTooltipRow } from "../charts/chart-tooltip"; import { formatMetricValue } from "./format-value"; @@ -62,6 +61,7 @@ export function CustomMetricChart({ decimals: number | null; }) { const { t } = useTranslations(); + const fmt = useFormatters(); const [range, setRange] = useState("365"); const [nowMs] = useState(() => Date.now()); @@ -136,7 +136,7 @@ export function CustomMetricChart({ type="number" scale="time" domain={["dataMin", "dataMax"]} - tickFormatter={(ts: number) => formatDateShort(new Date(ts))} + tickFormatter={(ts: number) => fmt.dateShortSmart(new Date(ts))} tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} stroke="var(--border)" minTickGap={32} @@ -192,7 +192,7 @@ export function CustomMetricChart({ return ( ); diff --git a/src/components/cycle/bbt-chart.tsx b/src/components/cycle/bbt-chart.tsx index 4f7f81656..d3378192c 100644 --- a/src/components/cycle/bbt-chart.tsx +++ b/src/components/cycle/bbt-chart.tsx @@ -191,7 +191,9 @@ export function BbtChart({ scale="time" domain={["dataMin", "dataMax"]} ticks={ticks} - tickFormatter={(v) => fmt.dateShort(new Date(v as number))} + tickFormatter={(v) => + fmt.dateShortSmart(new Date(v as number)) + } tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} tickLine={false} axisLine={false} @@ -285,7 +287,9 @@ function BbtTooltip(props: { "bg-popover border-border rounded-md border px-2.5 py-2 text-xs shadow-md", )} > -

{fmt.dateShort(new Date(p.t))}

+

+ {fmt.dateShortSmart(new Date(p.t))} +

{t("cycle.bbt.tooltipTemp", { temp: fmt.number(p.temp) })}

diff --git a/src/components/cycle/predictions-panel.tsx b/src/components/cycle/predictions-panel.tsx index f7175c2c8..66e428f0d 100644 --- a/src/components/cycle/predictions-panel.tsx +++ b/src/components/cycle/predictions-panel.tsx @@ -11,7 +11,8 @@ import { } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { TileHeader } from "@/components/insights/tile-header"; -import { useTranslations } from "@/lib/i18n/context"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import type { Formatters } from "@/lib/format-locale"; import { FERTILE_HUE, FLOW_HUE, OVULATION_HUE } from "./phase-tokens"; import type { CyclePrediction, CycleHistoryResponse } from "./types"; @@ -26,12 +27,17 @@ import type { CyclePrediction, CycleHistoryResponse } from "./types"; * cycle-tracking on-ramp, not repeated beneath every prediction surface. */ -function formatDate(d: string): string { +/** + * Issue #66 — was a bespoke `toLocaleDateString(undefined, …)` (browser + * locale, never the app's) with no year. Predictions run 1-2 cycles out, so + * a window straddling New Year's would otherwise print an unadorned "Jan 3" + * in December. Routes through the app-locale conditional-year formatter + * instead; `dateShortSmart` already renders day+month in the locale's field + * order and only adds the year when it differs from the current one. + */ +function formatDate(d: string, fmt: Formatters): string { // Render at noon UTC so the YYYY-MM-DD never rolls a day across tz. - return new Date(`${d}T12:00:00Z`).toLocaleDateString(undefined, { - month: "short", - day: "numeric", - }); + return fmt.dateShortSmart(`${d}T12:00:00Z`); } function confidenceTone(c: number): { @@ -60,6 +66,7 @@ export function PredictionsPanel({ history, }: PredictionsPanelProps) { const { t } = useTranslations(); + const fmt = useFormatters(); return (
@@ -92,8 +99,9 @@ export function PredictionsPanel({ ) : null} @@ -105,7 +113,7 @@ export function PredictionsPanel({ ? t("cycle.predictions.ovulationConfirmed") : t("cycle.predictions.ovulation") } - value={formatDate(prediction.predictedOvulation)} + value={formatDate(prediction.predictedOvulation, fmt)} /> ) : null} @@ -120,6 +128,7 @@ export function PredictionsPanel({ function NextPeriod({ prediction }: { prediction: CyclePrediction }) { const { t } = useTranslations(); + const fmt = useFormatters(); const tone = confidenceTone(prediction.confidence); return (
@@ -128,8 +137,8 @@ function NextPeriod({ prediction }: { prediction: CyclePrediction }) { hue={FLOW_HUE} label={t("cycle.predictions.nextPeriod")} value={t("cycle.predictions.nextPeriodWindow", { - low: formatDate(prediction.nextPeriodStartLow), - high: formatDate(prediction.nextPeriodStartHigh), + low: formatDate(prediction.nextPeriodStartLow, fmt), + high: formatDate(prediction.nextPeriodStartHigh, fmt), })} /> @@ -186,6 +195,7 @@ function HistoryCard({ history: CycleHistoryResponse | undefined; }) { const { t } = useTranslations(); + const fmt = useFormatters(); const stats = history?.stats; // Most-recent observed cycles (the spread the regularity number summarises — // seeing it builds trust; the data was already on the wire). QA H2. @@ -253,10 +263,10 @@ function HistoryCard({ className="flex items-center justify-between gap-3 py-2 text-sm" > - {formatDate(c.startDate)} + {formatDate(c.startDate, fmt)} {" – "} {c.endDate - ? formatDate(c.endDate) + ? formatDate(c.endDate, fmt) : t("cycle.history.ongoing")} diff --git a/src/components/illness/illness-day-timeline.tsx b/src/components/illness/illness-day-timeline.tsx index 674686a3c..54841722e 100644 --- a/src/components/illness/illness-day-timeline.tsx +++ b/src/components/illness/illness-day-timeline.tsx @@ -34,7 +34,7 @@ function DayLogRow({ log }: { log: IllnessDayLogDTO }) { return (

- {fmt.dateShort(`${log.date}T12:00:00`)} + {fmt.dateShortSmart(`${log.date}T12:00:00`)}

{log.symptoms.length > 0 ? ( diff --git a/src/components/illness/illness-episode-detail.tsx b/src/components/illness/illness-episode-detail.tsx index 0f61ba6d3..79e4a8a17 100644 --- a/src/components/illness/illness-episode-detail.tsx +++ b/src/components/illness/illness-episode-detail.tsx @@ -82,11 +82,11 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) { {t("illness.onsetOn", { - date: fmt.dateShort(new Date(episode.onsetAt)), + date: fmt.dateShortSmart(new Date(episode.onsetAt)), })} {episode.resolvedAt ? ` · ${t("illness.recoveredOn", { - date: fmt.dateShort(new Date(episode.resolvedAt)), + date: fmt.dateShortSmart(new Date(episode.resolvedAt)), })}` : ""} diff --git a/src/components/illness/illness-settings.tsx b/src/components/illness/illness-settings.tsx index d6952bc5e..f7d859ca2 100644 --- a/src/components/illness/illness-settings.tsx +++ b/src/components/illness/illness-settings.tsx @@ -43,7 +43,7 @@ export function IllnessSettings() { : e.lifecycle === "CHRONIC_ONGOING" ? t("illness.status.ongoing") : t("illness.status.active") - } · ${fmt.dateShort(new Date(e.onsetAt))}`, + } · ${fmt.dateShortSmart(new Date(e.onsetAt))}`, })); return ( diff --git a/src/components/illness/illness-view.tsx b/src/components/illness/illness-view.tsx index 9b7efe866..da308b211 100644 --- a/src/components/illness/illness-view.tsx +++ b/src/components/illness/illness-view.tsx @@ -103,7 +103,7 @@ function EpisodeCard({ : t("illness.status.recovered")} - {fmt.dateShort(new Date(episode.onsetAt))} + {fmt.dateShortSmart(new Date(episode.onsetAt))}
@@ -167,11 +167,11 @@ function EpisodeCard({

{t("illness.onsetOn", { - date: fmt.dateShort(new Date(episode.onsetAt)), + date: fmt.dateShortSmart(new Date(episode.onsetAt)), })} {episode.resolvedAt ? ` · ${t("illness.recoveredOn", { - date: fmt.dateShort(new Date(episode.resolvedAt)), + date: fmt.dateShortSmart(new Date(episode.resolvedAt)), })}` : ""}

@@ -192,7 +192,7 @@ function EpisodeCard({ > {flare.label} - {fmt.dateShort(new Date(flare.onsetAt))} + {fmt.dateShortSmart(new Date(flare.onsetAt))} diff --git a/src/components/insights/intraday-pulse-chart.tsx b/src/components/insights/intraday-pulse-chart.tsx index 8ecfcca1f..e9a29f0d1 100644 --- a/src/components/insights/intraday-pulse-chart.tsx +++ b/src/components/insights/intraday-pulse-chart.tsx @@ -175,7 +175,7 @@ export function IntradayPulseChart({ const dayLabel = isToday ? t("insights.intradayPulse.today") - : fmt.dateShort(new Date(`${dateKey}T12:00:00.000Z`)); + : fmt.dateShortSmart(new Date(`${dateKey}T12:00:00.000Z`)); return (
("365"); // Capture "now" once at mount so the recency filter stays pure across // re-renders (an inline `Date.now()` in render is impure). @@ -145,7 +145,7 @@ export function LabBiomarkerChart({ type="number" scale="time" domain={["dataMin", "dataMax"]} - tickFormatter={(ts: number) => formatDateShort(new Date(ts))} + tickFormatter={(ts: number) => fmt.dateShortSmart(new Date(ts))} tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} stroke="var(--border)" minTickGap={32} @@ -204,7 +204,7 @@ export function LabBiomarkerChart({ return ( ); diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx index 1be882b5a..c15a22de7 100644 --- a/src/components/medications/dose-history-ledger.tsx +++ b/src/components/medications/dose-history-ledger.tsx @@ -82,7 +82,7 @@ import { useFormatters, useDateFormatPreference, } from "@/lib/i18n/context"; -import { formatDateWithWeekday } from "@/lib/date-format"; +import { formatDateWithWeekdaySmart } from "@/lib/date-format"; import { invalidateKeys, medicationDependentKeys, @@ -448,7 +448,7 @@ export function DoseHistoryLedger({ the DEVICE zone and could shift the heading a day when browser ≠ profile zone; the day-key label renders UTC-pinned instead (zone-independent, weekday included). */} - {formatDateWithWeekday(group.dayKey, dateFormatPref, locale)} + {formatDateWithWeekdaySmart(group.dayKey, dateFormatPref, locale)}
    {group.rows.map((row, i) => ( diff --git a/src/components/medications/dose-strength-curve.tsx b/src/components/medications/dose-strength-curve.tsx index 3c11d42b3..c4f4990db 100644 --- a/src/components/medications/dose-strength-curve.tsx +++ b/src/components/medications/dose-strength-curve.tsx @@ -218,7 +218,7 @@ export function DoseStrengthCurve({ scale="time" domain={["dataMin", "dataMax"]} ticks={ticks} - tickFormatter={(v) => fmt.dateShort(new Date(v as number))} + tickFormatter={(v) => fmt.dateShortSmart(new Date(v as number))} tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} tickLine={false} axisLine={false} @@ -237,7 +237,7 @@ export function DoseStrengthCurve({ strokeOpacity: 0.3, strokeDasharray: "3 3", }} - labelFormatter={(v) => fmt.dateShort(new Date(v as number))} + labelFormatter={(v) => fmt.dateShortSmart(new Date(v as number))} formatter={(value) => [ `${fmt.number(value as number)} ${unit}`, t("medications.doseStrength.tooltipLabel"), diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index ce4c86a3f..a10519cbb 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -420,7 +420,7 @@ export function Glp1MedicationCard({ if (next.daysAway === 0) return t("medications.glp1NextInjectionToday"); if (next.daysAway === 1) return t("medications.glp1NextInjectionTomorrow"); const dayName = weekdayLabel(getDayOfWeekInTz(next.date, displayTz)); - const dateShort = fmt.dateShort(next.date); + const dateShort = fmt.dateShortSmart(next.date); return t("medications.glp1NextInjectionDays", { label: `${dayName}, ${dateShort}`, days: next.daysAway, @@ -469,7 +469,7 @@ export function Glp1MedicationCard({ medication.nextDueOverdue && next ? diffDays(next.date, now) === 0 ? formatTime(next.date.toISOString()) - : `${weekdayLabel(getDayOfWeekInTz(next.date, displayTz))}, ${fmt.dateShort(next.date)}` + : `${weekdayLabel(getDayOfWeekInTz(next.date, displayTz))}, ${fmt.dateShortSmart(next.date)}` : null; const nextLine = diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index a84cedc0b..b6c64fa5c 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -437,7 +437,7 @@ export function MedicationCard({ } else if (diffDays <= 5) { dayLabel = weekdayLabel(nextDate.getDay()); } else { - dayLabel = fmt.dateWithWeekday(nextDate); + dayLabel = fmt.dateWithWeekdaySmart(nextDate); } } diff --git a/src/components/medications/medication-table.tsx b/src/components/medications/medication-table.tsx index 2a8f5c65e..b7f045796 100644 --- a/src/components/medications/medication-table.tsx +++ b/src/components/medications/medication-table.tsx @@ -551,7 +551,7 @@ function MedicationTableRowItem({ ? t("medications.tomorrow") : diffDays <= 5 ? weekdayLabel(nextDate.getDay()) - : fmt.dateWithWeekday(nextDate); + : fmt.dateWithWeekdaySmart(nextDate); nextCell = ( {dayLabel}, {formatTime(new Date(nextAt).toISOString())} diff --git a/src/components/medications/side-effects-section.tsx b/src/components/medications/side-effects-section.tsx index ae6e0338f..9b07cf638 100644 --- a/src/components/medications/side-effects-section.tsx +++ b/src/components/medications/side-effects-section.tsx @@ -324,7 +324,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) { className="text-muted-foreground w-14 shrink-0 text-right text-xs tabular-nums" data-slot="side-effect-row-date" > - {fmt.dateShort(new Date(row.occurredAt))} + {fmt.dateShortSmart(new Date(row.occurredAt))}

{row.notes && ( diff --git a/src/components/medications/titration-timeline.tsx b/src/components/medications/titration-timeline.tsx index 3fafd3024..1bc91e710 100644 --- a/src/components/medications/titration-timeline.tsx +++ b/src/components/medications/titration-timeline.tsx @@ -221,9 +221,11 @@ export function TitrationTimeline({

{step.isPast - ? fmt.dateShort(new Date(step.effectiveFrom)) + ? fmt.dateShortSmart(new Date(step.effectiveFrom)) : t("medications.titration.plannedFor", { - date: fmt.dateShort(new Date(step.effectiveFrom)), + date: fmt.dateShortSmart( + new Date(step.effectiveFrom), + ), })}

diff --git a/src/components/mental-health/assessment-history-chart.tsx b/src/components/mental-health/assessment-history-chart.tsx index 95c4dc8fe..8dd09f896 100644 --- a/src/components/mental-health/assessment-history-chart.tsx +++ b/src/components/mental-health/assessment-history-chart.tsx @@ -34,8 +34,7 @@ import { import { RichChartTooltip, type RichTooltipRow } from "../charts/chart-tooltip"; import { CHART_HEIGHT_PX } from "@/lib/charts/constants"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; -import { formatDateShort } from "@/lib/format"; -import { useTranslations } from "@/lib/i18n/context"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { INSTRUMENTS } from "@/lib/mental-health/instruments"; import type { AssessmentRow, InstrumentId } from "./types"; @@ -55,6 +54,7 @@ export function AssessmentHistoryChart({ rows: AssessmentRow[]; }) { const { t } = useTranslations(); + const fmt = useFormatters(); const def = INSTRUMENTS[instrument]; const points = useMemo(() => { @@ -103,7 +103,7 @@ export function AssessmentHistoryChart({ type="number" scale="time" domain={["dataMin", "dataMax"]} - tickFormatter={(ts: number) => formatDateShort(new Date(ts))} + tickFormatter={(ts: number) => fmt.dateShortSmart(new Date(ts))} tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} stroke="var(--border)" minTickGap={32} @@ -139,7 +139,7 @@ export function AssessmentHistoryChart({ return ( ); diff --git a/src/components/settings/trusted-devices-card.tsx b/src/components/settings/trusted-devices-card.tsx index f8ecf9326..79d2745a7 100644 --- a/src/components/settings/trusted-devices-card.tsx +++ b/src/components/settings/trusted-devices-card.tsx @@ -15,7 +15,7 @@ import { Loader2, ShieldCheck } from "lucide-react"; import { Button } from "@/components/ui/button"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { useTranslations } from "@/lib/i18n/context"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiGet, apiDelete } from "@/lib/api/api-fetch"; @@ -34,6 +34,7 @@ export function TrustedDevicesCard({ isAuthenticated: boolean; }) { const { t } = useTranslations(); + const fmt = useFormatters(); const queryClient = useQueryClient(); const [status, setStatus] = useState(null); @@ -125,7 +126,7 @@ export function TrustedDevicesCard({

{t("settings.security.trustedDevices.expires", { - date: new Date(d.expiresAt).toLocaleDateString(), + date: fmt.date(d.expiresAt), })}

diff --git a/src/components/ui/filter-bar.tsx b/src/components/ui/filter-bar.tsx index 032c0c563..f8fb6d155 100644 --- a/src/components/ui/filter-bar.tsx +++ b/src/components/ui/filter-bar.tsx @@ -229,7 +229,7 @@ export function FilterBarDateRange({ const fmt = useFormatters(); const active = from !== "" || to !== ""; const display = active - ? `${from ? fmt.dateShort(from) : "…"} – ${to ? fmt.dateShort(to) : "…"}` + ? `${from ? fmt.dateShortSmart(from) : "…"} – ${to ? fmt.dateShortSmart(to) : "…"}` : null; const clear = () => { onFromChange(""); diff --git a/src/lib/__tests__/date-format.test.ts b/src/lib/__tests__/date-format.test.ts index e67ef4a96..3e990cb83 100644 --- a/src/lib/__tests__/date-format.test.ts +++ b/src/lib/__tests__/date-format.test.ts @@ -5,11 +5,12 @@ * field order the `` primitive and `useFormatters().date()` render. * AUTO defers to the active locale; DMY / MDY / YMD pin the order regardless. */ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { formatDate, formatDateWithWeekday, + formatDateWithWeekdaySmart, parseIsoDate, resolveDateLocale, isDateFormatPreference, @@ -112,6 +113,41 @@ describe("formatDateWithWeekday (#490)", () => { }); }); +// Issue #66 (date-format sweep) — the dose-history ledger scrolls day +// groups across year boundaries; a bare "Di., 16.12." from last December +// must not read as this year's. "Now" is faked so the boundary is +// deterministic regardless of the day this suite runs. +describe("formatDateWithWeekdaySmart (#66)", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-10T12:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("omits the year for a day key in the current UTC year", () => { + expect(formatDateWithWeekdaySmart("2026-07-14", "AUTO", "en")).toBe( + "Tue, 07/14", + ); + }); + + it("adds the year for a day key from a prior UTC year", () => { + expect(formatDateWithWeekdaySmart("2025-12-16", "AUTO", "en")).toBe( + "Tue, 12/16/2025", + ); + expect(formatDateWithWeekdaySmart("2025-12-16", "AUTO", "de")).toBe( + "Di., 16.12.2025", + ); + }); + + it("returns an empty string for empty / unparseable input", () => { + expect(formatDateWithWeekdaySmart("", "AUTO", "en")).toBe(""); + expect(formatDateWithWeekdaySmart(null, "AUTO", "en")).toBe(""); + }); +}); + describe("parseIsoDate", () => { it("parses a valid ISO calendar date to a UTC midnight Date", () => { const d = parseIsoDate("2026-02-19"); diff --git a/src/lib/__tests__/format-locale.test.ts b/src/lib/__tests__/format-locale.test.ts index dea53e046..505b8af00 100644 --- a/src/lib/__tests__/format-locale.test.ts +++ b/src/lib/__tests__/format-locale.test.ts @@ -265,17 +265,6 @@ describe("makeFormatters", () => { // that prints as 2025 in New York must carry the year. expect(nyFmt.dateShortSmart(newYearUtcEve)).toContain("2025"); }); - - it("monthYear always carries the year (chart axis / next-dose labels)", () => { - const de = makeFormatters("de"); - const en = makeFormatters("en"); - const dec = new Date("2025-12-30T10:00:00Z"); - const jan = new Date("2026-01-15T10:00:00Z"); - expect(de.monthYear(dec)).toContain("2025"); - expect(de.monthYear(jan)).toContain("2026"); - expect(en.monthYear(dec)).toBe("Dec 2025"); - expect(en.monthYear(jan)).toBe("Jan 2026"); - }); }); }); diff --git a/src/lib/date-format.ts b/src/lib/date-format.ts index 085292833..1808b9df9 100644 --- a/src/lib/date-format.ts +++ b/src/lib/date-format.ts @@ -167,6 +167,31 @@ export function formatDateWithWeekday( }); } +/** + * Same field set as {@link formatDateWithWeekday}, but adds the year + * whenever `value`'s UTC calendar year differs from the current UTC year + * — issue #66 (date-format sweep). A day group from a previous year (e.g. + * the dose-history ledger scrolled past a year boundary) must not read as + * this year's "Di., 16.12.". + */ +export function formatDateWithWeekdaySmart( + value: Date | string | null | undefined, + pref: DateFormatPreference, + locale: Locale, +): string { + if (value === null || value === undefined || value === "") return ""; + const date = value instanceof Date ? value : parseIsoDate(value); + if (date === null || Number.isNaN(date.getTime())) return ""; + const showYear = date.getUTCFullYear() !== new Date().getUTCFullYear(); + return date.toLocaleDateString(resolveDateLocale(pref, locale), { + timeZone: "UTC", + weekday: "short", + day: "2-digit", + month: "2-digit", + ...(showYear ? { year: "numeric" as const } : {}), + }); +} + /** * Parse a `yyyy-MM-dd` string to a UTC `Date` at midnight. Returns null on * a value that is not exactly the ISO calendar-date shape so callers can diff --git a/src/lib/format-locale.ts b/src/lib/format-locale.ts index 7f5d42529..725e9b8f0 100644 --- a/src/lib/format-locale.ts +++ b/src/lib/format-locale.ts @@ -171,11 +171,6 @@ export interface Formatters { dateWithWeekdaySmart: (value: DateInput) => string; /** For axis labels: 3-letter month abbreviation. */ monthShort: (value: DateInput) => string; - /** - * Month + year, e.g. "Feb. 2026" / "Feb 2026" — for axis labels and - * next-dose captions that need to disambiguate a Dec → Jan rollover. - */ - monthYear: (value: DateInput) => string; } export function makeFormatters( @@ -299,13 +294,6 @@ export function makeFormatters( timeZone: tz, month: "short", }).format(asDate(value)), - - monthYear: (value) => - getDateTimeFormat(intlLocale, { - timeZone: tz, - month: "short", - year: "numeric", - }).format(asDate(value)), }; } From 247bc4f33c17ca30476e200dcdadcd445cbea1c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 01:02:24 +0200 Subject: [PATCH 22/33] fix(ui): close consistency-audit contrast and duplication findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Today hero's top-signal delta used opacity-on-muted text, landing below AA on the app's most prominent card; five mood charts and the sleep- stage IN_BED fill painted raw --dracula-*/hardcoded-hex hues with no (or an unused) light-theme counterpart. Route all of them through existing semantic/chart tokens instead (destructive/warning/success, chart-1..5, and a newly minted --chart-inbed with its own Alucard override) so every hue is AA-tuned per theme. Also collapses three duplicated primitives the audit flagged: the click-Popover VitalsInfoTip and hover-Tooltip InfoHint merge into one touch-first ui/info-popover; MedicationViewToggle and ModuleViewToggle were verbatim copy-paste and now wrap one shared ui/view-toggle; the triplicated sport-icon map (already drifted — mixedCardio rendered a different glyph in the list vs the detail/dashboard views) moves to lib/workouts/sport-icons.ts with HeartPulse as the settled icon. Adds a no-restricted-imports lint rule banning direct recharts imports outside the chart-runtime barrel, catching the bundle-duplication mistake at local lint instead of PR-CI. --- eslint.config.mjs | 48 ++++++++++ src/app/globals.css | 14 +++ src/components/daily/today-hero.tsx | 2 +- .../dashboard/recent-workouts-tile.tsx | 39 +------- .../insights/derived/vitals-dashboard.tsx | 22 +++-- .../insights/derived/vitals-info-tip.tsx | 90 ----------------- .../glucose/glucose-advanced-disclosure.tsx | 4 +- .../glucose/glucose-clinical-panel.tsx | 8 +- .../__tests__/mood-tag-bars-muted.test.tsx | 7 +- .../insights/mood/mood-correlation-cards.tsx | 14 ++- .../insights/mood/mood-distribution-chart.tsx | 23 +++-- .../insights/mood/mood-tag-breakdown.tsx | 8 +- .../insights/mood/mood-time-of-day-chart.tsx | 8 +- .../insights/mood/mood-weekday-chart.tsx | 14 ++- .../insights/sleep-stage-stacked-bar.tsx | 2 +- .../insights/sleep/sleep-debt-card.tsx | 6 +- src/components/insights/workout-detail.tsx | 33 +------ src/components/insights/workout-list.tsx | 38 +------- .../medications/medication-view-toggle.tsx | 70 ++++---------- .../mental-health/mental-wellbeing.tsx | 2 +- .../module-list/module-view-toggle.tsx | 71 +++++--------- src/components/ui/info-hint.tsx | 60 ------------ src/components/ui/info-popover.tsx | 96 +++++++++++++++++++ src/components/ui/view-toggle.tsx | 77 +++++++++++++++ src/lib/workouts/sport-icons.ts | 45 +++++++++ 25 files changed, 401 insertions(+), 400 deletions(-) delete mode 100644 src/components/insights/derived/vitals-info-tip.tsx delete mode 100644 src/components/ui/info-hint.tsx create mode 100644 src/components/ui/info-popover.tsx create mode 100644 src/components/ui/view-toggle.tsx create mode 100644 src/lib/workouts/sport-icons.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 0f6b64259..9d66c3ee6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -77,6 +77,54 @@ const eslintConfig = defineConfig([ "healthlog/spacing-scale": "error", }, }, + // v1.28.17 — every recharts-rendering component funnels through the + // shared `chart-runtime.ts` barrel (see its file header): pointing N + // `next/dynamic` boundaries at N different modules mints a separate + // ~312 KB recharts chunk per boundary, a regression the project already + // paid for once. Direct `from "recharts"` imports outside the barrel's + // own static import graph previously only tripped the bundle-budget + // gate at PR-CI; this rule catches the same mistake at local lint, + // mirroring how `healthlog/safe-fetch-required` scopes its own + // wrapper-internals exemption by file list. + { + files: ["src/**/*.{ts,tsx}"], + ignores: [ + "src/components/charts/chart-runtime.ts", + "src/components/admin/host-metrics-chart.tsx", + "src/components/charts/health-chart.tsx", + "src/components/charts/medication-compliance-chart.tsx", + "src/components/charts/mood-chart.tsx", + "src/components/charts/nutrient-daily-bar-chart.tsx", + "src/components/charts/scatter-correlation-chart.tsx", + "src/components/custom-metrics/custom-metric-chart.tsx", + "src/components/cycle/bbt-chart.tsx", + "src/components/insights/derived/delta-sparkline.tsx", + "src/components/insights/intraday-pulse-chart.tsx", + "src/components/insights/mood/mood-distribution-chart.tsx", + "src/components/insights/mood/mood-time-of-day-chart.tsx", + "src/components/insights/mood/mood-weekday-chart.tsx", + "src/components/insights/sleep-stage-stacked-bar.tsx", + "src/components/labs/lab-biomarker-chart.tsx", + "src/components/medications/detail/efficacy/efficacy-chart.tsx", + "src/components/medications/dose-strength-curve.tsx", + "src/components/medications/drug-level-chart.tsx", + "src/components/mental-health/assessment-history-chart.tsx", + ], + rules: { + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "recharts", + message: + "Import chart components through @/components/charts/chart-runtime (the shared dynamic-import barrel), not recharts directly — a second static import mints a duplicate recharts chunk. Add the component to chart-runtime.ts's export list if it's a new chart.", + }, + ], + }, + ], + }, + }, ]); export default eslintConfig; diff --git a/src/app/globals.css b/src/app/globals.css index 067640131..441b14be6 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -160,6 +160,17 @@ --dracula-red: #ff5555; --dracula-yellow: #f1fa8c; --dracula-lavender: #d6acff; + /* + * v1.28.17 — the sleep-stage IN_BED fill. Distinct from `--dracula-comment` + * above (an orphaned token — no `@theme` utility was ever generated for + * it, per the `confidence-badge.ts` note — and its `#9aa3b3` value never + * matched the classic Dracula "Comment" blue-grey this chart actually + * paints). Minted here instead of retuning the unrelated token, so this + * default carries the exact hex the stacked bar hardcoded before + * (`#6272a4`) and the light override below (`:root.light`) is this + * token's own AA-tuned counterpart. + */ + --chart-inbed: #6272a4; /* * Semantic feedback tokens (see @theme `--color-success` / `--color-warning` @@ -378,6 +389,9 @@ /* Lavender has no semantic twin; a violet one step lighter than purple, * AA-tuned — 4.97:1 card / 4.63:1 bg. */ --dracula-lavender: #7c50e0; + /* IN_BED sleep-stage fill (see the `:root` default above) — same + * blue-grey hue family, darkened for AA — 6.66:1 card / 6.03:1 bg. */ + --chart-inbed: #4a5b8a; --sidebar: oklch(0.988 0.003 300); --sidebar-foreground: oklch(0.3 0.012 295); --sidebar-primary: #644ac9; diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index 18b8f8a65..c9bebb8cb 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -138,7 +138,7 @@ export function TodayHero({ digest }: { digest: DailyDigest }) { > {topSignal.headline} {topSignal.delta ? ( - + {" "} · {topSignal.delta} diff --git a/src/components/dashboard/recent-workouts-tile.tsx b/src/components/dashboard/recent-workouts-tile.tsx index 6caaa66f0..7fcf86efc 100644 --- a/src/components/dashboard/recent-workouts-tile.tsx +++ b/src/components/dashboard/recent-workouts-tile.tsx @@ -1,16 +1,7 @@ "use client"; import Link from "next/link"; -import { - Activity, - Bike, - Dumbbell, - Footprints, - HeartPulse, - Mountain, - PersonStanding, - type LucideIcon, -} from "lucide-react"; +import { Activity } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { useWorkouts, type WorkoutListEntry } from "@/hooks/use-workouts"; @@ -19,6 +10,7 @@ import { TileHeader } from "@/components/insights/tile-header"; import { getDateTimeFormat } from "@/lib/intl/formatter-cache"; import { Skeleton } from "@/components/ui/skeleton"; import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { iconForSport } from "@/lib/workouts/sport-icons"; /** * v1.4.32 — ``. @@ -38,33 +30,6 @@ import { QueryErrorCard } from "@/components/ui/query-error-card"; * round-trip per session. */ -const SPORT_TYPE_ICON: Record = { - walking: Footprints, - running: PersonStanding, - cycling: Bike, - hiking: Mountain, - swimming: Activity, - rowing: Activity, - elliptical: Activity, - stairClimber: Activity, - yoga: PersonStanding, - mindAndBody: PersonStanding, - strength: Dumbbell, - hiit: Activity, - dance: Activity, - golf: Activity, - tennis: Activity, - basketball: Activity, - soccer: Activity, - crossTraining: Activity, - mixedCardio: HeartPulse, - other: Activity, -}; - -function iconForSport(sportType: string): LucideIcon { - return SPORT_TYPE_ICON[sportType] ?? Activity; -} - function formatDuration(seconds: number): string { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx index 57aa57ddf..ec0e39478 100644 --- a/src/components/insights/derived/vitals-dashboard.tsx +++ b/src/components/insights/derived/vitals-dashboard.tsx @@ -16,7 +16,7 @@ import { SectionHeading } from "@/components/insights/section-heading"; import { SparklineDeltaTile } from "./sparkline-delta-tile"; import { LearnMoreLink } from "@/components/ui/learn-more-link"; import { CoverageMeter } from "./coverage-meter"; -import { VitalsInfoTip } from "./vitals-info-tip"; +import { InfoPopover } from "@/components/ui/info-popover"; import { METRIC_PROVENANCE } from "./standards"; import { type DerivedBatchRead } from "./use-derived-metric"; import { @@ -171,11 +171,11 @@ function tileOrder(layout: InsightsLayout | undefined, tileId: string): number { /** * The (i) info-tip trigger for one derived metric, wired from the map. - * `provenance` (inputs / source / window / asOf) isn't rendered here — - * `VitalsInfoTip` carries only the method/caveat + cited standard, mirroring - * what `ProvenanceExplainer` itself renders today — but the param is kept so - * every call site (which already reads `data.provenance` off the derived - * batch) doesn't need touching if a future pass restores the detail. + * `provenance` (inputs / source / window / asOf) isn't rendered here — the + * shared `InfoPopover` carries only the method/caveat + cited standard, + * mirroring what `ProvenanceExplainer` itself renders today — but the param + * is kept so every call site (which already reads `data.provenance` off the + * derived batch) doesn't need touching if a future pass restores the detail. */ function MetricProvenance({ metric, @@ -195,7 +195,13 @@ function MetricProvenance({ {t(meta.methodKey)} ); - return ; + return ( + + ); } interface TileProps { @@ -291,7 +297,7 @@ function BaselineTile({ // v1.29.1 muted this on VITALS_BASELINE tiles because the always-on // "typical range = median ± MAD" caption repeated across every vital // baseline tile and read as clutter. v1.29.2 restores the context — - // now behind the compact (i) trigger (`VitalsInfoTip`) instead of a + // now behind the compact (i) trigger (`InfoPopover`) instead of a // full-width caption, so every baseline tile carries it again // without the repetition or the header-row squeeze that clipped the // cardio-fitness heading. diff --git a/src/components/insights/derived/vitals-info-tip.tsx b/src/components/insights/derived/vitals-info-tip.tsx deleted file mode 100644 index 753ab3fbc..000000000 --- a/src/components/insights/derived/vitals-info-tip.tsx +++ /dev/null @@ -1,90 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { Info } from "lucide-react"; - -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { useTranslations } from "@/lib/i18n/context"; -import { cn } from "@/lib/utils"; -import type { ProvenanceStandard } from "./provenance-explainer"; - -/** - * v1.29.2 QoL — the vitals-dashboard tile's (i) info affordance. - * - * v1.29.1 dropped the always-on "your personal normal range is the - * median…" method caption from every `VITALS_BASELINE` tile because it - * repeated verbatim across up to six tiles and read as clutter at the top - * of the vitals surface — and, on the narrower tiles, squeezed the heading - * itself (the VO₂max/cardio-fitness clip). Rather than reintroduce the - * caption at full length, the SAME method/caveat/standard content moves - * behind a small (i) trigger: a click-opened `Popover` (not a hover - * tooltip — the vitals grid sees most of its use on touch), so the context - * is one tap away instead of permanently occupying header space. - * - * This is a DELIBERATE, narrowly-scoped exception to the v1.22 "no - * icon-only disclosure" direction (`ProvenanceExplainer`'s own docstring): - * that decision targeted the always-visible-caption pattern on OTHER - * surfaces (coincident-deviation, the score-anatomy pages), which stay - * unchanged. The vitals grid is dense enough, and the caption repetitive - * enough, that the same content is better one tap away here. - */ -export interface VitalsInfoTipProps { - /** Plain-language method (+ optional caveat) description — text children only. */ - method: React.ReactNode; - /** Optional cited standard, rendered as a plain external link. */ - standard?: ProvenanceStandard; - className?: string; -} - -export function VitalsInfoTip({ - method, - standard, - className, -}: VitalsInfoTipProps) { - const { t } = useTranslations(); - const [open, setOpen] = useState(false); - - return ( - - - - - -

{method}

- {standard ? ( - - {standard.name} - - ) : null} -
-
- ); -} diff --git a/src/components/insights/glucose/glucose-advanced-disclosure.tsx b/src/components/insights/glucose/glucose-advanced-disclosure.tsx index 4392173f2..fd725cf9f 100644 --- a/src/components/insights/glucose/glucose-advanced-disclosure.tsx +++ b/src/components/insights/glucose/glucose-advanced-disclosure.tsx @@ -3,7 +3,7 @@ import { useId, useState } from "react"; import { ChevronDown } from "lucide-react"; -import { InfoHint } from "@/components/ui/info-hint"; +import { InfoPopover } from "@/components/ui/info-popover"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; @@ -149,7 +149,7 @@ function AdvancedStat({
{label} - +
{value} diff --git a/src/components/insights/glucose/glucose-clinical-panel.tsx b/src/components/insights/glucose/glucose-clinical-panel.tsx index 6a0214972..fe55b5580 100644 --- a/src/components/insights/glucose/glucose-clinical-panel.tsx +++ b/src/components/insights/glucose/glucose-clinical-panel.tsx @@ -6,7 +6,7 @@ import { Activity, Droplet, FlaskConical, TrendingUp } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { TileHeader } from "@/components/insights/tile-header"; -import { InfoHint } from "@/components/ui/info-hint"; +import { InfoPopover } from "@/components/ui/info-popover"; import { LearningGate } from "@/components/ui/learning-gate"; import { LearnMoreLink } from "@/components/ui/learn-more-link"; import { Skeleton } from "@/components/ui/skeleton"; @@ -198,7 +198,9 @@ export function GlucoseClinicalPanel() {
{t("insights.bloodGlucose.clinical.cv.label")} - +
@@ -285,7 +287,7 @@ function Metric({ {icon} {label} - {help ? : null} + {help ? : null} {value} diff --git a/src/components/insights/mood/__tests__/mood-tag-bars-muted.test.tsx b/src/components/insights/mood/__tests__/mood-tag-bars-muted.test.tsx index 8fef436b1..2b48239b4 100644 --- a/src/components/insights/mood/__tests__/mood-tag-bars-muted.test.tsx +++ b/src/components/insights/mood/__tests__/mood-tag-bars-muted.test.tsx @@ -34,9 +34,10 @@ describe("mood tag bars — full-saturation palette (v1.19.0)", () => { for (const fill of fills ?? []) { expect(fill).not.toContain("opacity-55"); } - // The level hue itself stays a Dracula token. - expect(html).toContain("var(--dracula-red)"); - expect(html).toContain("var(--dracula-green)"); + // The level hue itself stays a token — semantic since v1.28.17 (was a + // raw `--dracula-*` primitive; see mood-tag-breakdown.tsx). + expect(html).toContain("var(--destructive)"); + expect(html).toContain("var(--success)"); }); it("structured tag breakdown bars render at full opacity", () => { diff --git a/src/components/insights/mood/mood-correlation-cards.tsx b/src/components/insights/mood/mood-correlation-cards.tsx index 062f9fa1d..b7add85ca 100644 --- a/src/components/insights/mood/mood-correlation-cards.tsx +++ b/src/components/insights/mood/mood-correlation-cards.tsx @@ -66,12 +66,16 @@ const Y_LABEL_KEY: Record = { bloodPressure: "insights.mood.correlation.bloodPressureAxis", }; +// v1.28.17 — the shared `--chart-1..5` vocabulary (same values as the +// `--dracula-purple/cyan/pink/green/orange` primitives it replaces, per +// theme), not raw palette tokens — matches the `no-raw-palette-color` +// discipline and the app's other chart-series colouring. const FILL_BY_KIND: Record = { - sleep: "var(--dracula-purple)", - steps: "var(--dracula-cyan)", - pulse: "var(--dracula-pink)", - weight: "var(--dracula-green)", - bloodPressure: "var(--dracula-orange)", + sleep: "var(--chart-1)", + steps: "var(--chart-4)", + pulse: "var(--chart-3)", + weight: "var(--chart-2)", + bloodPressure: "var(--chart-5)", }; const STRENGTH_KEY: Record = { diff --git a/src/components/insights/mood/mood-distribution-chart.tsx b/src/components/insights/mood/mood-distribution-chart.tsx index 6074baf80..c710db552 100644 --- a/src/components/insights/mood/mood-distribution-chart.tsx +++ b/src/components/insights/mood/mood-distribution-chart.tsx @@ -28,12 +28,17 @@ export interface MoodDistributionRow { count: number; } +// v1.28.17 — semantic tokens, not raw `--dracula-*` primitives: every one of +// these already carries an AA-tuned Alucard (light-theme) override, but +// routing through the meaning-bearing alias (destructive/warning/yellow/ +// success) keeps this map legible as a severity ladder rather than an +// arbitrary palette pick, matching the `no-raw-palette-color` discipline. const BAR_COLOR_BY_SCORE: Record = { - 1: "var(--dracula-red)", - 2: "var(--dracula-orange)", + 1: "var(--destructive)", + 2: "var(--warning)", 3: "var(--dracula-yellow)", - 4: "var(--dracula-green)", - 5: "var(--dracula-green)", + 4: "var(--success)", + 5: "var(--success)", }; export function MoodDistributionChart({ @@ -68,8 +73,8 @@ export function MoodDistributionChart({ // not overridden in `:root.light` (Alucard white card) → white-on- // white. `--muted-foreground` is the token every other in-app chart // uses for axis ticks and is legible in both themes; dark mode is - // unchanged. The mood-hue bar Cells stay on `--dracula-*` — only the - // axis text legibility changes here. + // unchanged. The mood-hue bar Cells route through semantic tokens + // (see `BAR_COLOR_BY_SCORE`), AA-tuned per theme too. tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} stroke="var(--muted-foreground)" interval={0} @@ -80,8 +85,8 @@ export function MoodDistributionChart({ // not overridden in `:root.light` (Alucard white card) → white-on- // white. `--muted-foreground` is the token every other in-app chart // uses for axis ticks and is legible in both themes; dark mode is - // unchanged. The mood-hue bar Cells stay on `--dracula-*` — only the - // axis text legibility changes here. + // unchanged. The mood-hue bar Cells route through semantic tokens + // (see `BAR_COLOR_BY_SCORE`), AA-tuned per theme too. tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} stroke="var(--muted-foreground)" width={28} @@ -117,7 +122,7 @@ export function MoodDistributionChart({ {data.map((row) => ( ))} diff --git a/src/components/insights/mood/mood-tag-breakdown.tsx b/src/components/insights/mood/mood-tag-breakdown.tsx index 763548820..2e4c3d5bc 100644 --- a/src/components/insights/mood/mood-tag-breakdown.tsx +++ b/src/components/insights/mood/mood-tag-breakdown.tsx @@ -19,11 +19,13 @@ export interface MoodTagRow { avgScore: number; } +// v1.28.17 — semantic tokens, not raw `--dracula-*` primitives (see +// mood-distribution-chart's `BAR_COLOR_BY_SCORE` for the same swap). function colorForScore(score: number): string { - if (score < 2) return "var(--dracula-red)"; - if (score < 3) return "var(--dracula-orange)"; + if (score < 2) return "var(--destructive)"; + if (score < 3) return "var(--warning)"; if (score < 3.5) return "var(--dracula-yellow)"; - return "var(--dracula-green)"; + return "var(--success)"; } export function MoodTagBreakdown({ tags }: { tags: MoodTagRow[] }) { diff --git a/src/components/insights/mood/mood-time-of-day-chart.tsx b/src/components/insights/mood/mood-time-of-day-chart.tsx index 481f25aa4..e1f96541a 100644 --- a/src/components/insights/mood/mood-time-of-day-chart.tsx +++ b/src/components/insights/mood/mood-time-of-day-chart.tsx @@ -47,11 +47,13 @@ const LABEL_KEYS: Record = { night: "insights.mood.timeOfDay.night", }; +// v1.28.17 — semantic tokens, not raw `--dracula-*` primitives (see +// mood-distribution-chart's `BAR_COLOR_BY_SCORE` for the same swap). function colorForScore(score: number): string { - if (score < 2) return "var(--dracula-red)"; - if (score < 3) return "var(--dracula-orange)"; + if (score < 2) return "var(--destructive)"; + if (score < 3) return "var(--warning)"; if (score < 3.5) return "var(--dracula-yellow)"; - return "var(--dracula-green)"; + return "var(--success)"; } export function MoodTimeOfDayChart({ diff --git a/src/components/insights/mood/mood-weekday-chart.tsx b/src/components/insights/mood/mood-weekday-chart.tsx index 3f72af6b4..a0d69225f 100644 --- a/src/components/insights/mood/mood-weekday-chart.tsx +++ b/src/components/insights/mood/mood-weekday-chart.tsx @@ -39,11 +39,13 @@ const WEEKDAY_LABEL_KEYS = [ "charts.weekdaysFull.sun", ] as const; +// v1.28.17 — semantic tokens, not raw `--dracula-*` primitives (see +// mood-distribution-chart's `BAR_COLOR_BY_SCORE` for the same swap). function colorForScore(score: number): string { - if (score < 2) return "var(--dracula-red)"; - if (score < 3) return "var(--dracula-orange)"; + if (score < 2) return "var(--destructive)"; + if (score < 3) return "var(--warning)"; if (score < 3.5) return "var(--dracula-yellow)"; - return "var(--dracula-green)"; + return "var(--success)"; } export function MoodWeekdayChart({ weekday }: { weekday: MoodWeekdayRow[] }) { @@ -88,7 +90,8 @@ export function MoodWeekdayChart({ weekday }: { weekday: MoodWeekdayRow[] }) { // v1.15.14 — theme-aware axis text (see mood-distribution-chart). // `--dracula-fg` was white-on-white on the light-mode card; switch // to the shared `--muted-foreground` axis-tick token. Bar mood - // hues stay `--dracula-*`; dark mode is unchanged. + // hues route through semantic tokens (see `colorForScore`); + // dark mode is unchanged. tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} stroke="var(--muted-foreground)" interval={0} @@ -99,7 +102,8 @@ export function MoodWeekdayChart({ weekday }: { weekday: MoodWeekdayRow[] }) { // v1.15.14 — theme-aware axis text (see mood-distribution-chart). // `--dracula-fg` was white-on-white on the light-mode card; switch // to the shared `--muted-foreground` axis-tick token. Bar mood - // hues stay `--dracula-*`; dark mode is unchanged. + // hues route through semantic tokens (see `colorForScore`); + // dark mode is unchanged. tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} stroke="var(--muted-foreground)" width={24} diff --git a/src/components/insights/sleep-stage-stacked-bar.tsx b/src/components/insights/sleep-stage-stacked-bar.tsx index b413419e0..587721c73 100644 --- a/src/components/insights/sleep-stage-stacked-bar.tsx +++ b/src/components/insights/sleep-stage-stacked-bar.tsx @@ -94,7 +94,7 @@ export const STAGE_COLORS: Record = { CORE: "var(--info)", // dracula-cyan — bulk of sleep ASLEEP: "var(--success)", // dracula-green — legacy iOS 15- unspecified AWAKE: "var(--dracula-yellow)", // dracula-yellow — wake bouts - IN_BED: "#6272a4", // dracula-comment — pre-asleep + IN_BED: "var(--chart-inbed)", // muted blue-grey — pre-asleep }; function formatMinutes(total: number, locale: string): string { diff --git a/src/components/insights/sleep/sleep-debt-card.tsx b/src/components/insights/sleep/sleep-debt-card.tsx index 8799ad0e2..350478c1b 100644 --- a/src/components/insights/sleep/sleep-debt-card.tsx +++ b/src/components/insights/sleep/sleep-debt-card.tsx @@ -4,7 +4,7 @@ import { Moon } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { InfoHint } from "@/components/ui/info-hint"; +import { InfoPopover } from "@/components/ui/info-popover"; import { TileHeader } from "@/components/insights/tile-header"; import { LearningGate } from "@/components/ui/learning-gate"; import type { SleepDebtDto } from "./use-sleep-rhythm"; @@ -43,7 +43,7 @@ export function SleepDebtCard({ debt }: { debt: SleepDebtDto }) { title={t("insights.sleep.debt.title")} right={ debt.source === "COMPUTED" ? ( - + ) : undefined } /> @@ -75,7 +75,7 @@ export function SleepDebtCard({ debt }: { debt: SleepDebtDto }) { title={t("insights.sleep.debt.title")} right={ debt.source === "COMPUTED" ? ( - + ) : undefined } /> diff --git a/src/components/insights/workout-detail.tsx b/src/components/insights/workout-detail.tsx index 6ca8783df..6bc216c64 100644 --- a/src/components/insights/workout-detail.tsx +++ b/src/components/insights/workout-detail.tsx @@ -2,17 +2,12 @@ import type { ReactNode } from "react"; import { - Activity, - Bike, - Dumbbell, Flame, Footprints, HeartPulse, Map, Mountain, - PersonStanding, Timer, - type LucideIcon, } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -23,6 +18,7 @@ import { hourCycleOptions, type TimeFormatPreference, } from "@/lib/format-locale"; +import { iconForSport } from "@/lib/workouts/sport-icons"; import type { WorkoutDetailPayload } from "@/hooks/use-workouts"; /** @@ -35,33 +31,6 @@ import type { WorkoutDetailPayload } from "@/hooks/use-workouts"; * `WorkoutDetailPayload`. */ -const SPORT_TYPE_ICON: Record = { - walking: Footprints, - running: PersonStanding, - cycling: Bike, - hiking: Mountain, - swimming: Activity, - rowing: Activity, - elliptical: Activity, - stairClimber: Activity, - yoga: PersonStanding, - mindAndBody: PersonStanding, - strength: Dumbbell, - hiit: Activity, - dance: Activity, - golf: Activity, - tennis: Activity, - basketball: Activity, - soccer: Activity, - crossTraining: Activity, - mixedCardio: HeartPulse, - other: Activity, -}; - -function iconForSport(sportType: string): LucideIcon { - return SPORT_TYPE_ICON[sportType] ?? Activity; -} - function formatDuration(seconds: number): string { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); diff --git a/src/components/insights/workout-list.tsx b/src/components/insights/workout-list.tsx index 6cdfcf319..18cbaaec8 100644 --- a/src/components/insights/workout-list.tsx +++ b/src/components/insights/workout-list.tsx @@ -1,16 +1,6 @@ "use client"; import Link from "next/link"; -import { - Activity, - Bike, - Dumbbell, - Footprints, - Heart, - Mountain, - PersonStanding, - type LucideIcon, -} from "lucide-react"; import { cn } from "@/lib/utils"; import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache"; @@ -19,6 +9,7 @@ import { hourCycleOptions, type TimeFormatPreference, } from "@/lib/format-locale"; +import { iconForSport } from "@/lib/workouts/sport-icons"; import type { WorkoutListEntry } from "@/hooks/use-workouts"; /** @@ -33,33 +24,6 @@ import type { WorkoutListEntry } from "@/hooks/use-workouts"; * lands here. */ -const SPORT_TYPE_ICON: Record = { - walking: Footprints, - running: PersonStanding, - cycling: Bike, - hiking: Mountain, - swimming: Activity, - rowing: Activity, - elliptical: Activity, - stairClimber: Activity, - yoga: PersonStanding, - mindAndBody: PersonStanding, - strength: Dumbbell, - hiit: Activity, - dance: Activity, - golf: Activity, - tennis: Activity, - basketball: Activity, - soccer: Activity, - crossTraining: Activity, - mixedCardio: Heart, - other: Activity, -}; - -function iconForSport(sportType: string): LucideIcon { - return SPORT_TYPE_ICON[sportType] ?? Activity; -} - function formatDuration(seconds: number): string { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); diff --git a/src/components/medications/medication-view-toggle.tsx b/src/components/medications/medication-view-toggle.tsx index e9e08d109..5f32c0e08 100644 --- a/src/components/medications/medication-view-toggle.tsx +++ b/src/components/medications/medication-view-toggle.tsx @@ -3,16 +3,13 @@ import { LayoutGrid, Table as TableIcon } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; -import { cn } from "@/lib/utils"; +import { ViewToggle } from "@/components/ui/view-toggle"; import type { MedicationListView } from "@/lib/medication-list-layout"; /** * v1.16.10 — icon-only card/table view toggle for the /medications page - * header. A small two-segment control: the active segment carries the - * filled surface, the inactive one stays ghost. `aria-pressed` announces - * the state per segment; the group is named for screen readers. Segments - * keep the 44-px mobile tap floor (`size-11`), shrinking to the standard - * 36-px control height on desktop like the neighbouring Add button. + * header. Thin wrapper over the shared `ViewToggle` primitive; see its + * docstring for the interaction grammar. */ interface MedicationViewToggleProps { view: MedicationListView; @@ -25,49 +22,24 @@ export function MedicationViewToggle({ }: MedicationViewToggleProps) { const { t } = useTranslations(); - const segments = [ - { - value: "cards" as const, - label: t("medications.viewCards"), - icon: LayoutGrid, - }, - { - value: "table" as const, - label: t("medications.viewTable"), - icon: TableIcon, - }, - ]; - return ( -
- {segments.map(({ value, label, icon: Icon }) => { - const active = view === value; - return ( - - ); - })} -
+ ); } diff --git a/src/components/mental-health/mental-wellbeing.tsx b/src/components/mental-health/mental-wellbeing.tsx index 2b0ad486e..af57f203d 100644 --- a/src/components/mental-health/mental-wellbeing.tsx +++ b/src/components/mental-health/mental-wellbeing.tsx @@ -18,7 +18,7 @@ * `assessment-result`, `assessment-history`, `crisis-card`). * The landing follows the card grammar of Vorsorge / Medications: instrument * cards (last-result line + Start) + a history card. The disclaimer renders - * ONLY here (a muted caption) and behind the InfoHint — never while testing. + * ONLY here (a muted caption) and behind the InfoPopover — never while testing. * * v1.27.9 — the landing is intro + instrument cards, nothing else: the * combined history card left the main page entirely. Each card opens a diff --git a/src/components/module-list/module-view-toggle.tsx b/src/components/module-list/module-view-toggle.tsx index 2fc25c0cb..139a55845 100644 --- a/src/components/module-list/module-view-toggle.tsx +++ b/src/components/module-list/module-view-toggle.tsx @@ -3,15 +3,15 @@ import { LayoutGrid, List as ListIcon } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; -import { cn } from "@/lib/utils"; +import { ViewToggle } from "@/components/ui/view-toggle"; import type { ModuleListView } from "@/lib/module-list-prefs"; /** * v1.18.6 (W8 / MOD-03) — icon-only card/list view toggle shared by the - * Vorsorge / Illness / Labs settings pages. Mirrors the medication module's - * `MedicationViewToggle` grammar (two ghost segments, the active one filled, - * `aria-pressed` per segment) but switches between a card grid and a compact - * list rather than cards-vs-table, so the label set is `moduleList.view*`. + * Vorsorge / Illness / Labs settings pages. Thin wrapper over the shared + * `ViewToggle` primitive (mirrors the medication module's + * `MedicationViewToggle`, which is the same wrapper switching between a + * card grid and a compact list rather than cards-vs-table). */ interface ModuleViewToggleProps { view: ModuleListView; @@ -21,49 +21,24 @@ interface ModuleViewToggleProps { export function ModuleViewToggle({ view, onChange }: ModuleViewToggleProps) { const { t } = useTranslations(); - const segments = [ - { - value: "cards" as const, - label: t("moduleList.viewCards"), - icon: LayoutGrid, - }, - { - value: "list" as const, - label: t("moduleList.viewList"), - icon: ListIcon, - }, - ]; - return ( -
- {segments.map(({ value, label, icon: Icon }) => { - const active = view === value; - return ( - - ); - })} -
+ ); } diff --git a/src/components/ui/info-hint.tsx b/src/components/ui/info-hint.tsx deleted file mode 100644 index d3caa2de9..000000000 --- a/src/components/ui/info-hint.tsx +++ /dev/null @@ -1,60 +0,0 @@ -"use client"; - -import { Info } from "lucide-react"; - -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { useTranslations } from "@/lib/i18n/context"; -import { cn } from "@/lib/utils"; - -/** - * A small, tap-reachable help affordance. Replaces the bare `title=` - * attribute, which never surfaces on touch. The trigger is a real button - * with an accessible name, so keyboard and screen-reader users reach the - * same hint a pointer user gets on hover; tapping the button focuses it, - * which opens the tooltip on touch devices. - */ -export function InfoHint({ - label, - className, -}: { - /** The hint text shown inside the tooltip. */ - label: string; - className?: string; -}) { - const { t } = useTranslations(); - return ( - - - - - - - {label} - - - - ); -} diff --git a/src/components/ui/info-popover.tsx b/src/components/ui/info-popover.tsx new file mode 100644 index 000000000..549c8036a --- /dev/null +++ b/src/components/ui/info-popover.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useState } from "react"; +import { Info } from "lucide-react"; + +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; + +/** A plain external citation link rendered under the popover body. */ +export interface InfoPopoverLink { + /** Display name of the cited source (e.g. "WHO BMI classification"). */ + name: string; + /** Absolute URL, rendered as a plain ``, never HTML. */ + url: string; +} + +/** + * The app's one (i) info-affordance. Replaces the bare `title=` attribute + * (which never surfaces on touch) and, since v1.28.17, the earlier + * two-primitive split: a click-opened `Popover` — not a hover `Tooltip` — + * because most of this app's use is on touch, where a hover-only tooltip + * never opens reliably on tap. `v1.29.2`'s `VitalsInfoTip` made exactly + * this argument for the vitals grid; the same reasoning applies to every + * other (i) trigger in the tree, so the two primitives merge into this one. + * + * The trigger is a real button with an accessible name, so keyboard and + * screen-reader users reach the same content a pointer user gets on tap. + */ +export interface InfoPopoverProps { + /** Body content — plain text or a short mix of text nodes. */ + content: React.ReactNode; + /** Optional cited source, rendered as a plain external link. */ + link?: InfoPopoverLink; + /** + * Accessible name of the trigger button. Defaults to the generic + * "More information" — pass a more specific label (e.g. "How is this + * calculated?") where the caller wants that framing. + */ + label?: string; + className?: string; +} + +export function InfoPopover({ + content, + link, + label, + className, +}: InfoPopoverProps) { + const { t } = useTranslations(); + const [open, setOpen] = useState(false); + + return ( + + + + + +

{content}

+ {link ? ( +
+ {link.name} + + ) : null} + + + ); +} diff --git a/src/components/ui/view-toggle.tsx b/src/components/ui/view-toggle.tsx new file mode 100644 index 000000000..88c76eab9 --- /dev/null +++ b/src/components/ui/view-toggle.tsx @@ -0,0 +1,77 @@ +"use client"; + +import type { LucideIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +export interface ViewToggleSegment { + value: T; + label: string; + icon: LucideIcon; +} + +export interface ViewToggleProps { + view: T; + onChange: (view: T) => void; + segments: ViewToggleSegment[]; + /** Accessible name for the segmented-control group. */ + groupLabel: string; + /** `data-slot` prefix per segment, rendered as `${dataSlotPrefix}-${value}`. */ + dataSlotPrefix: string; +} + +/** + * Shared icon-only segmented view toggle (e.g. cards vs table/list). A small + * two-segment control: the active segment carries the filled surface, the + * inactive one stays ghost. `aria-pressed` announces the state per segment; + * the group is named for screen readers. Segments keep the 44-px mobile tap + * floor (`size-11` via `size-10` + the `sm:size-8` desktop step), shrinking + * to the standard 36-px control height on desktop like a neighbouring Add + * button. + * + * Previously forked between the medications module + * (`medications/medication-view-toggle.tsx`) and the Vorsorge/Illness/Labs + * settings pages (`module-list/module-view-toggle.tsx`) — verbatim + * copy-paste that only diverged in icons + i18n keys and had already + * drifted. Both are now thin wrappers over this one primitive. + */ +export function ViewToggle({ + view, + onChange, + segments, + groupLabel, + dataSlotPrefix, +}: ViewToggleProps) { + return ( +
+ {segments.map(({ value, label, icon: Icon }) => { + const active = view === value; + return ( + + ); + })} +
+ ); +} diff --git a/src/lib/workouts/sport-icons.ts b/src/lib/workouts/sport-icons.ts new file mode 100644 index 000000000..f126579a5 --- /dev/null +++ b/src/lib/workouts/sport-icons.ts @@ -0,0 +1,45 @@ +import { + Activity, + Bike, + Dumbbell, + Footprints, + HeartPulse, + Mountain, + PersonStanding, + type LucideIcon, +} from "lucide-react"; + +/** + * Shared sport-type → icon map. Previously three private copies + * (`workout-list.tsx`, `workout-detail.tsx`, `recent-workouts-tile.tsx`) + * that had already drifted — `mixedCardio` rendered `Heart` in the list but + * `HeartPulse` in the detail view and the dashboard tile, so the same + * workout showed two different glyphs between the row and its detail page. + * `HeartPulse` won as the shared value (two of three sites already agreed). + */ +export const SPORT_TYPE_ICON: Record = { + walking: Footprints, + running: PersonStanding, + cycling: Bike, + hiking: Mountain, + swimming: Activity, + rowing: Activity, + elliptical: Activity, + stairClimber: Activity, + yoga: PersonStanding, + mindAndBody: PersonStanding, + strength: Dumbbell, + hiit: Activity, + dance: Activity, + golf: Activity, + tennis: Activity, + basketball: Activity, + soccer: Activity, + crossTraining: Activity, + mixedCardio: HeartPulse, + other: Activity, +}; + +export function iconForSport(sportType: string): LucideIcon { + return SPORT_TYPE_ICON[sportType] ?? Activity; +} From 4c436d7dfc073389b0cf49f8f650a2b97f1c1c19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 01:19:05 +0200 Subject: [PATCH 23/33] fix(a11y): keyboard-reachable admin log rows, range-bar marker, heatmap cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin app-log rows opened their JSON detail dialog through a click-only with no keyboard path; the Action cell now wraps a real + + + {event.duration_ms} ms + + + {event.trace_id.slice(0, 8)}… + + + ); + })}
diff --git a/src/components/charts/__tests__/compliance-heatmap-a11y.test.tsx b/src/components/charts/__tests__/compliance-heatmap-a11y.test.tsx new file mode 100644 index 000000000..5e5de3c15 --- /dev/null +++ b/src/components/charts/__tests__/compliance-heatmap-a11y.test.tsx @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { ComplianceHeatmap } from "../compliance-heatmap"; + +/** + * 2026-07-17 a11y audit (M2) — medication-compliance heatmap day cells + * were pointer-only SVG ``s: no `tabIndex`, no per-day `aria-label`. + * This pins the keyboard-reachability fix on top of the pre-existing + * aggregate `role="img"` summary on the SVG. + */ +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe(" — keyboard-reachable day cells", () => { + it("keeps the aggregate role=img summary on the SVG", () => { + const html = render(); + expect(html).toMatch(/]*role="img"[^>]*aria-label="[^"]+"/); + }); + + it("makes every day cell focusable with its own aria-label", () => { + const html = render(); + const rectMatches = html.match(/]*>/g) ?? []; + expect(rectMatches.length).toBeGreaterThan(0); + for (const rect of rectMatches) { + expect(rect).toContain('tabindex="0"'); + expect(rect).toContain('role="img"'); + expect(rect).toMatch(/aria-label="[^"]+"/); + } + }); +}); diff --git a/src/components/charts/__tests__/mood-heatmap-a11y.test.tsx b/src/components/charts/__tests__/mood-heatmap-a11y.test.tsx new file mode 100644 index 000000000..61db1fc72 --- /dev/null +++ b/src/components/charts/__tests__/mood-heatmap-a11y.test.tsx @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { MoodHeatmap } from "../mood-heatmap"; + +/** + * 2026-07-17 a11y audit (M2) — mood heatmap day cells were pointer-only + * SVG ``s: no `tabIndex`, no per-day `aria-label`. This pins the + * keyboard-reachability fix (each cell focusable + self-describing) on + * top of the pre-existing aggregate `role="img"` summary on the SVG. + */ +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe(" — keyboard-reachable day cells", () => { + it("keeps the aggregate role=img summary on the SVG", () => { + const html = render(); + expect(html).toMatch(/]*role="img"[^>]*aria-label="[^"]+"/); + }); + + it("makes every day cell focusable with its own aria-label", () => { + const html = render(); + const rectMatches = html.match(/]*>/g) ?? []; + expect(rectMatches.length).toBeGreaterThan(0); + for (const rect of rectMatches) { + expect(rect).toContain('tabindex="0"'); + expect(rect).toContain('role="img"'); + expect(rect).toMatch(/aria-label="[^"]+"/); + } + }); +}); diff --git a/src/components/charts/compliance-heatmap.tsx b/src/components/charts/compliance-heatmap.tsx index 06071fb69..b4a506449 100644 --- a/src/components/charts/compliance-heatmap.tsx +++ b/src/components/charts/compliance-heatmap.tsx @@ -119,6 +119,20 @@ export function ComplianceHeatmap({ }; }, [tooltip?.pinned]); + // 2026-07-17 a11y audit (M2) — Escape dismisses any open tooltip + // (pinned touch tooltip or keyboard-focus tooltip), matching 1.4.13's + // "dismissible" requirement. + useEffect(() => { + if (!tooltip) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setTooltip(null); + }; + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [tooltip]); + const WEEKDAY_LABELS = [ t("charts.weekdays.mon"), "", @@ -348,7 +362,14 @@ export function ComplianceHeatmap({ height={cellSize} rx={2} fill={cell.color} - className="cursor-pointer" + className="cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--ring)]" + // 2026-07-17 a11y audit (M2) — the aggregate `role="img"` on + // the whole SVG summarises the window; each cell also gets + // its own per-day label so a screen-reader / keyboard user + // can reach the same value the pointer-only tooltip shows. + tabIndex={0} + role="img" + aria-label={buildText()} onPointerEnter={(e) => { // Touch enter fires synthetically immediately before // `pointerdown`; skip it so the pinned tooltip below @@ -373,6 +394,17 @@ export function ComplianceHeatmap({ pinned: true, }); }} + onFocus={(e) => { + const box = e.currentTarget.getBoundingClientRect(); + setTooltip({ + x: box.left + box.width / 2, + y: box.top, + text: buildText(), + }); + }} + onBlur={() => { + setTooltip((prev) => (prev?.pinned ? prev : null)); + }} /> ); })} diff --git a/src/components/charts/mood-heatmap.tsx b/src/components/charts/mood-heatmap.tsx index 2c4e1ca45..2a06f99c1 100644 --- a/src/components/charts/mood-heatmap.tsx +++ b/src/components/charts/mood-heatmap.tsx @@ -90,6 +90,20 @@ export function MoodHeatmap({ }; }, [tooltip?.pinned]); + // 2026-07-17 a11y audit (M2) — Escape dismisses any open tooltip + // (pinned touch tooltip or keyboard-focus tooltip), matching 1.4.13's + // "dismissible" requirement. + useEffect(() => { + if (!tooltip) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setTooltip(null); + }; + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [tooltip]); + const WEEKDAY_LABELS = [ t("charts.weekdays.mon"), "", @@ -287,7 +301,14 @@ export function MoodHeatmap({ // mood band reads clearly; only the no-entry cells stay the // quiet `--secondary` empty-state tint. fillOpacity={1} - className="cursor-pointer" + className="cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--ring)]" + // 2026-07-17 a11y audit (M2) — the aggregate `role="img"` on + // the whole SVG summarises the window; each cell also gets + // its own per-day label so a screen-reader / keyboard user + // can reach the same value the pointer-only tooltip shows. + tabIndex={0} + role="img" + aria-label={buildText()} onPointerEnter={(e) => { if (e.pointerType === "touch") return; setTooltip({ @@ -309,6 +330,17 @@ export function MoodHeatmap({ pinned: true, }); }} + onFocus={(e) => { + const box = e.currentTarget.getBoundingClientRect(); + setTooltip({ + x: box.left + box.width / 2, + y: box.top, + text: buildText(), + }); + }} + onBlur={() => { + setTooltip((prev) => (prev?.pinned ? prev : null)); + }} /> ); })} diff --git a/src/components/insights/__tests__/metric-target-summary.test.tsx b/src/components/insights/__tests__/metric-target-summary.test.tsx index 18e781bb6..55d3b021b 100644 --- a/src/components/insights/__tests__/metric-target-summary.test.tsx +++ b/src/components/insights/__tests__/metric-target-summary.test.tsx @@ -161,7 +161,13 @@ describe("", () => { // redundant "Target: 60.1–80.9 kg" string above it. expect(html).toContain('data-slot="tile-header"'); expect(html).toContain("Target"); - expect(html).not.toContain("Target: 60.1–80.9 kg"); + // 2026-07-17 a11y audit (M1) — the range-bar marker now carries an + // `aria-label` that reuses this same string as a text alternative + // (screen-reader only, not a rendered heading) — strip attribute + // values before asserting no redundant VISIBLE "Target: …" text node + // exists above the bar. + const visibleText = html.replace(/aria-label="[^"]*"/g, ""); + expect(visibleText).not.toContain("Target: 60.1–80.9 kg"); // The min / max endpoints surface as the range-bar axis labels. expect(html).toContain("60.1 kg"); expect(html).toContain("80.9 kg"); diff --git a/src/components/targets/__tests__/range-bar.test.tsx b/src/components/targets/__tests__/range-bar.test.tsx index 5a5dd962a..bb045a3a4 100644 --- a/src/components/targets/__tests__/range-bar.test.tsx +++ b/src/components/targets/__tests__/range-bar.test.tsx @@ -57,4 +57,16 @@ describe("", () => { }); expect(html).toContain("var(--destructive)"); }); + + it("makes the marker focusable with a text alternative for the value + range (2026-07-17 a11y audit M1)", () => { + const html = render({ + value: 72, + min: 60, + max: 100, + unit: "bpm", + }); + expect(html).toContain('tabindex="0"'); + expect(html).toContain('role="img"'); + expect(html).toContain('aria-label="Current: 72 bpm'); + }); }); diff --git a/src/components/targets/range-bar.tsx b/src/components/targets/range-bar.tsx index 2231adbbb..e1b8a9c78 100644 --- a/src/components/targets/range-bar.tsx +++ b/src/components/targets/range-bar.tsx @@ -92,6 +92,17 @@ export function RangeBar({ : t("targets.aboveTarget", { delta: delta.toFixed(1), unit }) : t("targets.inTarget"); + // 2026-07-17 a11y audit (M1) — the marker's value + range live only in a + // hover-only Radix tooltip on a non-focusable `
`. Reusing the same + // three strings the tooltip already shows as one text alternative closes + // 1.1.1 (no separate copy to drift) — `tabIndex` below makes the trigger + // focusable, which is all Radix needs to also open the tooltip on focus. + const markerAriaLabel = [ + t("targets.currentValue", { value: String(value), unit }), + t("targets.targetRangeValue", { min: String(min), max: String(max), unit }), + deltaText, + ].join(". "); + return (
@@ -129,7 +140,10 @@ export function RangeBar({
Date: Sat, 18 Jul 2026 01:21:59 +0200 Subject: [PATCH 24/33] refactor(insights, settings): unify the Heart group and add settings sidebar groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The insights tab strip's "Cardiovascular" popover held only three rare Withings markers while pulse, resting heart rate, HRV, and cardio recovery sat scattered across a flat pill and the unrelated Vitals group. Cardiovascular is now Heart and gathers all six; bmi and workouts — flat only by historical accident — collapse into their correct Body / Activity groups, and breathing disturbances moves out of Vitals into the Sleep manager bucket instead of vanishing whenever the sleep module is off. Blood pressure, weight, sleep, mood, and medications stay pinned as flat top-level pills — now a documented `PINNED_SUB_PAGE_SLUGS` set instead of an unexplained "predates v1.4.32" carve-out. Active energy moves from Vitals to Activity to match its own tab-strip section comment. All changes are grouping / presentation data only — no slug, route, or redirect changed. The Settings sidebar's 19 entries rendered as one flat list with no visual structure. They now cluster under seven group headers (Account, Your data, Display, Health profile, AI, Connectivity, System), reordered to match; the desktop sidebar gets a non-interactive text header per group, the mobile chip strip a quiet divider. Presentation only — no route changed. Reference: 2026-07-17 UX/IA audit, H2 + M1 + M2 + M3 + M6. --- messages/de.json | 17 +- messages/en.json | 17 +- messages/es.json | 17 +- messages/fr.json | 17 +- messages/it.json | 17 +- messages/pl.json | 17 +- src/app/insights/catalog/page.tsx | 2 +- .../__tests__/insights-tab-strip.test.tsx | 129 +++++-- .../insights/insights-tab-strip.tsx | 30 +- .../settings/insights-pill-order-section.tsx | 2 +- src/components/settings/settings-shell.tsx | 361 +++++++++++------- src/lib/insights/metric-catalog.ts | 6 +- src/lib/insights/sub-page-metric.ts | 105 +++-- 13 files changed, 504 insertions(+), 233 deletions(-) diff --git a/messages/de.json b/messages/de.json index 9252f62a7..b54aa1a08 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2534,9 +2534,9 @@ "label": "Aktivität", "header": "Aktivität & Mobilität" }, - "cardiovascularParent": { - "label": "Herz-Kreislauf", - "header": "Herz-Kreislauf-Marker" + "heartParent": { + "label": "Herz", + "header": "Herzgesundheit" }, "hearingParent": { "label": "Gehör", @@ -4192,7 +4192,16 @@ "disableError": "Forschungsmodus konnte nicht deaktiviert werden. Bitte erneut versuchen." }, "shell": { - "sectionsNav": "Einstellungs-Bereiche" + "sectionsNav": "Einstellungs-Bereiche", + "groups": { + "account": "Konto", + "yourData": "Deine Daten", + "display": "Anzeige", + "healthProfile": "Gesundheitsprofil", + "ai": "KI", + "connectivity": "Konnektivität", + "system": "System" + } }, "sections": { "account": { diff --git a/messages/en.json b/messages/en.json index be96b60ab..d24bcfc2d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2534,9 +2534,9 @@ "label": "Activity", "header": "Activity & mobility" }, - "cardiovascularParent": { - "label": "Cardiovascular", - "header": "Cardiovascular markers" + "heartParent": { + "label": "Heart", + "header": "Heart health" }, "hearingParent": { "label": "Hearing", @@ -4192,7 +4192,16 @@ "disableError": "Could not disable Research Mode. Please try again." }, "shell": { - "sectionsNav": "Settings sections" + "sectionsNav": "Settings sections", + "groups": { + "account": "Account", + "yourData": "Your data", + "display": "Display", + "healthProfile": "Health profile", + "ai": "AI", + "connectivity": "Connectivity", + "system": "System" + } }, "sections": { "account": { diff --git a/messages/es.json b/messages/es.json index df1777603..ce0861be5 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2534,9 +2534,9 @@ "label": "Actividad", "header": "Actividad y movilidad" }, - "cardiovascularParent": { - "label": "Cardiovascular", - "header": "Marcadores cardiovasculares" + "heartParent": { + "label": "Corazón", + "header": "Salud cardíaca" }, "hearingParent": { "label": "Audición", @@ -4192,7 +4192,16 @@ "disableError": "No se pudo desactivar el modo Investigación. Inténtalo de nuevo." }, "shell": { - "sectionsNav": "Secciones de ajustes" + "sectionsNav": "Secciones de ajustes", + "groups": { + "account": "Cuenta", + "yourData": "Tus datos", + "display": "Visualización", + "healthProfile": "Perfil de salud", + "ai": "IA", + "connectivity": "Conectividad", + "system": "Sistema" + } }, "sections": { "account": { diff --git a/messages/fr.json b/messages/fr.json index 945051dc9..4dfd3c433 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2534,9 +2534,9 @@ "label": "Activité", "header": "Activité et mobilité" }, - "cardiovascularParent": { - "label": "Cardiovasculaire", - "header": "Marqueurs cardiovasculaires" + "heartParent": { + "label": "Cœur", + "header": "Santé cardiaque" }, "hearingParent": { "label": "Audition", @@ -4192,7 +4192,16 @@ "disableError": "Impossible de désactiver le mode Recherche. Veuillez réessayer." }, "shell": { - "sectionsNav": "Sections des réglages" + "sectionsNav": "Sections des réglages", + "groups": { + "account": "Compte", + "yourData": "Vos données", + "display": "Affichage", + "healthProfile": "Profil de santé", + "ai": "IA", + "connectivity": "Connectivité", + "system": "Système" + } }, "sections": { "account": { diff --git a/messages/it.json b/messages/it.json index 44e478b3b..3a34e2792 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2534,9 +2534,9 @@ "label": "Attività", "header": "Attività e mobilità" }, - "cardiovascularParent": { - "label": "Cardiovascolare", - "header": "Marcatori cardiovascolari" + "heartParent": { + "label": "Cuore", + "header": "Salute del cuore" }, "hearingParent": { "label": "Udito", @@ -4192,7 +4192,16 @@ "disableError": "Impossibile disattivare la modalità Ricerca. Riprova." }, "shell": { - "sectionsNav": "Sezioni delle impostazioni" + "sectionsNav": "Sezioni delle impostazioni", + "groups": { + "account": "Account", + "yourData": "I tuoi dati", + "display": "Visualizzazione", + "healthProfile": "Profilo sanitario", + "ai": "IA", + "connectivity": "Connettività", + "system": "Sistema" + } }, "sections": { "account": { diff --git a/messages/pl.json b/messages/pl.json index 2a51daa76..51652f638 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2534,9 +2534,9 @@ "label": "Aktywność", "header": "Aktywność i mobilność" }, - "cardiovascularParent": { - "label": "Sercowo-naczyniowe", - "header": "Markery sercowo-naczyniowe" + "heartParent": { + "label": "Serce", + "header": "Zdrowie serca" }, "hearingParent": { "label": "Słuch", @@ -4192,7 +4192,16 @@ "disableError": "Nie udało się wyłączyć trybu Badawczego. Spróbuj ponownie." }, "shell": { - "sectionsNav": "Sekcje ustawień" + "sectionsNav": "Sekcje ustawień", + "groups": { + "account": "Konto", + "yourData": "Twoje dane", + "display": "Wyświetlanie", + "healthProfile": "Profil zdrowotny", + "ai": "AI", + "connectivity": "Łączność", + "system": "System" + } }, "sections": { "account": { diff --git a/src/app/insights/catalog/page.tsx b/src/app/insights/catalog/page.tsx index aa6b0be7a..9cda95039 100644 --- a/src/app/insights/catalog/page.tsx +++ b/src/app/insights/catalog/page.tsx @@ -78,7 +78,7 @@ const GROUP_ICONS: Record = { body: Scale, activity: Footprints, sleep: Moon, - cardiovascular: HeartPulse, + heart: HeartPulse, hearing: Ear, environment: Sun, metabolic: Droplet, diff --git a/src/components/insights/__tests__/insights-tab-strip.test.tsx b/src/components/insights/__tests__/insights-tab-strip.test.tsx index 051a4c8e3..d8e103e8a 100644 --- a/src/components/insights/__tests__/insights-tab-strip.test.tsx +++ b/src/components/insights/__tests__/insights-tab-strip.test.tsx @@ -70,10 +70,11 @@ describe(" — availability gating (v1.4.27 F19)", () => { hasMedication: false, }; const html = render(); - // Pills that have data stay. - expect(html).toContain(">Pulse<"); + // Pills that have data stay — pulse and bmi surface through their + // Heart / Body groups (2026-07-17 UX/IA audit M1). + expect(html).toContain('data-group="heart"'); expect(html).toContain(">Weight<"); - expect(html).toContain(">BMI<"); + expect(html).toContain('data-group="body"'); // Pills without data drop. expect(html).not.toContain(">Blood Pressure<"); expect(html).not.toContain(">Mood<"); @@ -143,7 +144,8 @@ describe(" — availability gating (v1.4.27 F19)", () => { }; const html = render(); expect(html).toContain(">Weight<"); - expect(html).toContain(">BMI<"); + // bmi surfaces through the Body group (2026-07-17 UX/IA audit M1). + expect(html).toContain('data-group="body"'); }); }); @@ -221,7 +223,7 @@ describe(" — vitals group collapse (v1.4.34 IW-D)", () => { }; const html = render(); expect(html).not.toContain(">Vitals<"); - expect(html).not.toContain('data-slot="insights-tab-strip-group"'); + expect(html).not.toContain('data-group="vitals"'); }); it("renders the parent pill as a popover trigger (button, not link)", () => { @@ -230,17 +232,80 @@ describe(" — vitals group collapse (v1.4.34 IW-D)", () => { expect(html).toContain('data-group="vitals"'); }); - it("preserves the non-grouped pills inline", () => { + it("preserves the pinned flat pills inline", () => { const html = render(); - // Flat (non-grouped) pills must still be reachable directly. + // Pinned pills must still be reachable directly. expect(html).toContain(">Blood Pressure<"); - expect(html).toContain(">Pulse<"); expect(html).toContain(">Weight<"); - expect(html).toContain(">BMI<"); expect(html).toContain(">Sleep<"); expect(html).toContain(">Mood<"); expect(html).toContain(">Medication<"); - expect(html).toContain(">Workouts<"); + }); +}); + +describe(" — heart group collapse (2026-07-17 UX/IA audit H2)", () => { + // Pulse, resting HR, HRV and cardio recovery used to be scattered across + // a flat "Pulse" pill and the "Vitals" popover; they now share one + // "Heart" group. Blood pressure stays pinned flat (see the pinned-pills + // test above). + const heartAvailability: InsightInputs = { + summaries: { + PULSE: fakeSummary(5), + HEART_RATE_VARIABILITY: fakeSummary(5), + RESTING_HEART_RATE: fakeSummary(5), + CARDIO_RECOVERY: fakeSummary(5), + }, + hasMood: false, + hasMedication: false, + }; + + it("renders a single 'Heart' parent pill instead of flat Pulse / HRV / Resting HR / Cardio recovery pills", () => { + const html = render(); + const parentMatches = html.match(/>HeartPulse<"); + expect(html).not.toContain(">HRV<"); + expect(html).not.toContain(">Resting HR<"); + expect(html).not.toContain(">Cardio Recovery<"); + }); + + it("hides the Heart parent pill when no heart metric has data", () => { + const availability: InsightInputs = { + summaries: { WEIGHT: fakeSummary(2) }, + hasMood: false, + hasMedication: false, + }; + const html = render(); + expect(html).not.toContain(">Heart<"); + expect(html).not.toContain('data-group="heart"'); + }); +}); + +describe(" — bmi / workouts collapse into their groups (2026-07-17 UX/IA audit M1)", () => { + it("bmi renders inside the 'Body' popover, not as a flat pill", () => { + const availability: InsightInputs = { + summaries: { WEIGHT: fakeSummary(5) }, + hasMood: false, + hasMedication: false, + }; + const html = render(); + expect(html).toContain(">Weight<"); + expect(html).not.toContain(">BMI<"); + expect(html).toContain('data-group="body"'); + }); + + it("workouts renders inside the 'Activity' popover, not as a flat pill", () => { + const availability: InsightInputs = { + summaries: {}, + hasMood: false, + hasMedication: false, + hasWorkouts: true, + }; + const html = render(); + expect(html).not.toContain(">Workouts<"); + expect(html).toContain('data-group="activity"'); }); }); @@ -255,8 +320,10 @@ describe(" — saved-layout visibility gate (v1.15.14 W2)", () }; it("hides a pill whose slug is layout-hidden even though it has data", () => { - // `pulse` has data but is NOT in the visible set ⇒ no pill. `weight` - // has data AND is visible ⇒ pill shows. + // `pulse` has data but is NOT in the visible set ⇒ its Heart group + // never emits. `weight` has data AND is visible ⇒ pill shows. `bmi` + // has data (derived from WEIGHT) AND is visible ⇒ its Body group + // emits (2026-07-17 UX/IA audit M1 — bmi collapsed out of the flat row). const visibleTileIds = new Set(["overview", "weight", "bmi"]); const html = render( — saved-layout visibility gate (v1.15.14 W2)", () />, ); expect(html).toContain(">Weight<"); - expect(html).toContain(">BMI<"); - expect(html).not.toContain(">Pulse<"); + expect(html).toContain('data-group="body"'); + expect(html).not.toContain('data-group="heart"'); }); - it("shows a pill when its slug is layout-visible AND has data", () => { + it("shows a group parent pill when its slug is layout-visible AND has data", () => { const visibleTileIds = new Set(["overview", "pulse", "weight", "bmi"]); const html = render( — saved-layout visibility gate (v1.15.14 W2)", () visibleTileIds={visibleTileIds} />, ); - expect(html).toContain(">Pulse<"); + expect(html).toContain('data-group="heart"'); expect(html).toContain(">Weight<"); }); @@ -302,9 +369,9 @@ describe(" — saved-layout visibility gate (v1.15.14 W2)", () it("falls back to the data-only gate when no layout is loaded (visibleTileIds undefined)", () => { // No visibleTileIds prop ⇒ pre-W2 behaviour: every data-having pill shows. const html = render(); - expect(html).toContain(">Pulse<"); + expect(html).toContain('data-group="heart"'); expect(html).toContain(">Weight<"); - expect(html).toContain(">BMI<"); + expect(html).toContain('data-group="body"'); }); it("always shows the Overview pill regardless of layout visibility", () => { @@ -316,7 +383,7 @@ describe(" — saved-layout visibility gate (v1.15.14 W2)", () />, ); expect(html).toContain(">Overview<"); - expect(html).not.toContain(">Pulse<"); + expect(html).not.toContain('data-group="heart"'); expect(html).not.toContain(">Weight<"); }); @@ -405,7 +472,9 @@ describe(" — module enable/disable gate (v1.18.0)", () => { // A pill belonging to a toggleable module (mood / sleep / glucose / // workouts) is hidden when that module is disabled in the account's // resolved module map — on TOP of the data + layout gates. Core metric - // pills (BP, pulse, weight, BMI …) carry no module key and ignore it. + // pills (BP, pulse, weight, BMI …) carry no module key and ignore it — + // pulse and bmi now surface through their Heart / Body group instead of + // a flat pill, but the module gate still never touches them. const moduleAvailability: InsightInputs = { summaries: { PULSE: fakeSummary(5), @@ -418,10 +487,11 @@ describe(" — module enable/disable gate (v1.18.0)", () => { hasWorkouts: true, }; - it("hides the Mood / Sleep / Workouts pills + the glucose (Metabolic) group when their modules are disabled", () => { + it("hides the Mood / Sleep / Activity(workouts) pills/groups + the glucose (Metabolic) group when their modules are disabled", () => { // `blood-glucose` is the sole metabolic metric with data here, so its // module gate collapses the whole "Metabolic" group parent pill — - // mood / sleep / workouts are flat pills that drop directly. + // mood / sleep are flat pills that drop directly; workouts is the + // sole Activity-group member here, so disabling it collapses Activity. const html = render( — module enable/disable gate (v1.18.0)", () => { ); expect(html).not.toContain(">Mood<"); expect(html).not.toContain(">Sleep<"); - expect(html).not.toContain(">Workouts<"); + expect(html).not.toContain('data-group="activity"'); expect(html).not.toContain('data-group="metabolic"'); - // Core metric pills are unaffected by the module map. - expect(html).toContain(">Pulse<"); + // Core metric pills are unaffected by the module map — pulse and bmi + // carry no module key and still surface through their Heart / Body + // groups (2026-07-17 UX/IA audit — both collapsed out of the flat row). + expect(html).toContain('data-group="heart"'); expect(html).toContain(">Weight<"); - expect(html).toContain(">BMI<"); + expect(html).toContain('data-group="body"'); expect(html).toContain(">Overview<"); }); @@ -458,7 +530,8 @@ describe(" — module enable/disable gate (v1.18.0)", () => { ); expect(html).toContain(">Mood<"); expect(html).toContain(">Sleep<"); - expect(html).toContain(">Workouts<"); + // Workouts collapses into the Activity group (2026-07-17 UX/IA audit M1). + expect(html).toContain('data-group="activity"'); expect(html).toContain('data-group="metabolic"'); }); @@ -486,7 +559,7 @@ describe(" — module enable/disable gate (v1.18.0)", () => { ); expect(html).toContain(">Mood<"); expect(html).toContain(">Sleep<"); - expect(html).toContain(">Workouts<"); + expect(html).toContain('data-group="activity"'); expect(html).toContain('data-group="metabolic"'); expect(html).toContain(">Recovery<"); } diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index 6299c83f8..d7f4b6f61 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -23,6 +23,7 @@ import { cn } from "@/lib/utils"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { INSIGHTS_OVERVIEW_PATH, + PINNED_SUB_PAGE_SLUGS, SUB_PAGE_GROUP, SUB_PAGE_GROUP_ORDER, SUB_PAGE_SLUGS, @@ -395,10 +396,12 @@ function isSlugModuleEnabled( /** * v1.4.34 IW-D — group metadata (label + popover header) keyed by * `SubPageGroup`. The strip renders one parent pill per group. v1.7.0 - * adds the body / activity / cardiovascular / hearing / environment / - * metabolic clusters as the previously-orphan metrics each get a - * sub-page; collapsing them behind a parent pill keeps the strip - * scannable. + * adds the body / activity / hearing / environment / metabolic clusters + * as the previously-orphan metrics each get a sub-page; collapsing them + * behind a parent pill keeps the strip scannable. The 2026-07-17 UX/IA + * audit (H2) replaced the old "Cardiovascular" group — which only ever + * held three rare Withings markers — with a unified "Heart" group that + * also carries pulse, resting HR, HRV and cardio recovery. */ const SUB_PAGE_GROUP_META: Record< SubPageGroup, @@ -416,9 +419,9 @@ const SUB_PAGE_GROUP_META: Record< labelKey: "insights.tabStrip.activityParent.label", headerKey: "insights.tabStrip.activityParent.header", }, - cardiovascular: { - labelKey: "insights.tabStrip.cardiovascularParent.label", - headerKey: "insights.tabStrip.cardiovascularParent.header", + heart: { + labelKey: "insights.tabStrip.heartParent.label", + headerKey: "insights.tabStrip.heartParent.header", }, hearing: { labelKey: "insights.tabStrip.hearingParent.label", @@ -517,7 +520,13 @@ function buildTabs( const emittedGroups = new Set(); for (const slug of orderedSlugs) { - const group = SUB_PAGE_GROUP[slug]; + // 2026-07-17 UX/IA audit (H2/M1) — a pinned slug always renders as + // its own flat pill, even if it carries a `SUB_PAGE_GROUP` entry (for + // manager/catalog purposes). It is also excluded below from its + // group's popover children so it never appears twice. + const group = PINNED_SUB_PAGE_SLUGS.has(slug) + ? undefined + : SUB_PAGE_GROUP[slug]; if (group) { if (emittedGroups.has(group)) continue; emittedGroups.add(group); @@ -529,7 +538,10 @@ function buildTabs( ) : SUB_PAGE_GROUP_ORDER[group]; const children = orderedChildren - .filter((childSlug) => visibleSet.has(childSlug)) + .filter( + (childSlug) => + visibleSet.has(childSlug) && !PINNED_SUB_PAGE_SLUGS.has(childSlug), + ) .map((childSlug) => ({ slug: childSlug, href: `${INSIGHTS_OVERVIEW_PATH}/${childSlug}`, diff --git a/src/components/settings/insights-pill-order-section.tsx b/src/components/settings/insights-pill-order-section.tsx index 59fa7eb51..0e6e82d3d 100644 --- a/src/components/settings/insights-pill-order-section.tsx +++ b/src/components/settings/insights-pill-order-section.tsx @@ -73,7 +73,7 @@ const MANAGER_GROUP_HEADER_KEYS: Record = { body: "insights.tabStrip.bodyParent.label", activity: "insights.tabStrip.activityParent.label", sleep: "insights.editMode.groupSleep", - cardiovascular: "insights.tabStrip.cardiovascularParent.label", + heart: "insights.tabStrip.heartParent.label", hearing: "insights.tabStrip.hearingParent.label", environment: "insights.tabStrip.environmentParent.label", metabolic: "insights.tabStrip.metabolicParent.label", diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index b2f92ded7..a23c4470f 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -66,11 +66,52 @@ export { type SettingsSectionSlug, }; +/** + * 2026-07-17 UX/IA audit (M6) — the sidebar's 19 entries render under one + * of these seven non-interactive group headers instead of as one flat + * list. Presentation only: no slug, route, or redirect changes. Order + * below is the render order of the groups themselves. + */ +export type SettingsSectionGroup = + | "account" + | "yourData" + | "display" + | "healthProfile" + | "ai" + | "connectivity" + | "system"; + +export const SETTINGS_SECTION_GROUP_ORDER: readonly SettingsSectionGroup[] = [ + "account", + "yourData", + "display", + "healthProfile", + "ai", + "connectivity", + "system", +]; + +/** i18n key for each group's header label, under `settings.shell.groups.*`. */ +export const SETTINGS_SECTION_GROUP_LABEL_KEYS: Record< + SettingsSectionGroup, + string +> = { + account: "settings.shell.groups.account", + yourData: "settings.shell.groups.yourData", + display: "settings.shell.groups.display", + healthProfile: "settings.shell.groups.healthProfile", + ai: "settings.shell.groups.ai", + connectivity: "settings.shell.groups.connectivity", + system: "settings.shell.groups.system", +}; + interface SettingsSection { slug: SettingsSectionSlug; /** i18n key under `settings.sections..title`. */ titleKey: string; icon: LucideIcon; + /** 2026-07-17 UX/IA audit (M6) — which sidebar group this entry renders under. */ + group: SettingsSectionGroup; /** * v1.18.0 (S5) — per-submodule entries are listed only when their module * is enabled. When set, the nav entry hides if the resolved @@ -83,104 +124,152 @@ interface SettingsSection { /** * Source of truth for the section list that the settings shell renders - * in its sidebar + mobile chip-strip. Order matches the in-app - * navigation order. Don't reorder without updating tests. + * in its sidebar + mobile chip-strip. * - * "About" sits at the end of the list. v1.4.33 IW7 had folded it into - * the sidebar user-card dropdown only, which left `/settings/about` an - * orphaned route — reachable by URL but discoverable nowhere in the - * settings navigation. It is now both a regular (last) shell entry and - * a user-card dropdown item ("Über HealthLog" / "About HealthLog"). + * 2026-07-17 UX/IA audit (M6) — the list used to render in a single flat + * order with no visual structure (19 entries deep, the AI cluster and the + * programmatic-access cluster interleaved, the health-profile pair split + * across the list). It is now clustered into the seven + * `SettingsSectionGroup`s (`SETTINGS_SECTION_GROUP_ORDER`), each entry + * carrying its `group`; the shell inserts a non-interactive header before + * the first VISIBLE entry of each group. This is a presentation-only + * reorder — no slug, route, or redirect changed, and `SETTINGS_SECTION_SLUGS` + * (`section-slugs.ts`, which also carries the page-only slugs and drives + * `generateStaticParams()`) is untouched. * - * v1.18.0 (S3) — `sources` is no longer a sidebar entry: source priority - * folded into Settings → Integrations as the "Sources" sub-tab. - * `thresholds` (Targets) keeps its own entry, served by the dynamic - * `[section]` route. + * "About" sits at the end of the list (System group). v1.4.33 IW7 had + * folded it into the sidebar user-card dropdown only, which left + * `/settings/about` an orphaned route — reachable by URL but discoverable + * nowhere in the settings navigation. It is now both a regular shell entry + * and a user-card dropdown item ("Über HealthLog" / "About HealthLog"). */ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ - { slug: "account", titleKey: "settings.sections.account.title", icon: User }, + // ── Account ── + { + slug: "account", + titleKey: "settings.sections.account.title", + icon: User, + group: "account", + }, // v1.23 — the account-security home: second factors, recovery codes, - // passkey management. Sits directly under Account. + // passkey management. { slug: "security", titleKey: "settings.sections.security.title", icon: ShieldCheck, + group: "account", }, - // v1.18.0 — the "Was du trackst" hub. Sits right below Account: the one - // place to enable/disable the secondary tracking domains. + // v1.23 — "Data & Privacy" assembles the export / deletion / retention / + // encryption / session / activity pieces into one coherent privacy pane. + { + slug: "privacy", + titleKey: "settings.sections.privacy.title", + icon: Lock, + group: "account", + }, + // ── Your data ── + // v1.18.0 — the "Was du trackst" hub: the front door for enabling / + // disabling the secondary tracking domains. { slug: "modules", titleKey: "settings.sections.modules.title", icon: Blocks, + group: "yourData", }, { slug: "integrations", titleKey: "settings.sections.integrations.title", icon: Link2, + group: "yourData", }, - // v1.25.3 — the standalone Kanäle (delivery channels) entry folded back - // into Notifications as a labelled in-page group; `/settings/channels` - // 301-redirects to `/settings/notifications#channels`. - // v1.18.1 (D4) — Quellen (source weighting) split out into its own entry: - // which connection wins when two report the same metric. + // v1.18.1 (D4) — Quellen (source weighting): which connection wins when + // two report the same metric. v1.25.3 — Kanäle (delivery channels) folded + // back into Notifications as an in-page group; + // `/settings/channels` 301-redirects to `/settings/notifications#channels`. { slug: "sources", titleKey: "settings.sections.sources.title", icon: Layers, + group: "yourData", }, - // v1.18.0 (S4) — "Benachrichtigungen" is now the single module-gated - // reminder-types home. The separate "Erinnerungen" hub (a link-only page) - // is gone; reminder TYPES live here, each row gated on its module. + // v1.18.0 (S5) — the full health record (PDF + FHIR R4 + zip package). + // v1.18.6.1 — NOT module-gated: the health record is a flagship export + // capability; gating its only entry-point on the `doctorReport` opt-out + // meant a stray toggle in the Modules hub made the whole section vanish + // with no in-context way back. The `doctorReport` module still governs + // the data layer server-side (403 `module.disabled` over Bearer). { - slug: "notifications", - titleKey: "settings.sections.notifications.title", - icon: Bell, + slug: "gesundheitsakte", + titleKey: "settings.sections.gesundheitsakte.title", + icon: FileHeart, + group: "yourData", }, + // v1.25.7 — clinician share links fold into the Gesundheitsakte section as + // a labelled "Sharing" group; `/settings/sharing` 301-redirects there. + { + slug: "export", + titleKey: "settings.sections.export.title", + icon: Download, + group: "yourData", + }, + // ── Display ── // v1.17.1 (F-2) — one nav entry replaces the four scattered Dashboard / - // Insights / Medications / Mood "arrange" entries. Those routes still - // resolve (deep links, page-header cogs, and the hub's own links all work); - // they are reached through this hub now, so "how my app looks" reads as one - // home. v1.25.3 — renamed to "Appearance"/"Darstellung" and widened into the - // index for every module's view/sort/order surface. The slug stays `layout` - // so every route + deep link is untouched. - // v1.25.7 — "Darstellung" (Appearance) absorbs the per-module view/sort/ - // order surfaces. The standalone Medikamente / Stimmung / Labor / - // Krankheit / Vorsorge nav entries are gone: each module's full settings - // v1.25.11 (#148) — "Darstellung" is a HUB that lists each module and links - // to its own subpage at `/settings/layout/`. The old per-module - // routes 301-redirect to the matching subpage (next.config.ts). + // Insights / Medications / Mood "arrange" entries; those routes still + // resolve, reached through this hub. v1.25.3 — renamed to "Appearance" / + // "Darstellung" and widened into the index for every module's + // view/sort/order surface (the slug stays `layout`). v1.25.11 (#148) — + // each module lives on its own subpage at `/settings/layout/`; + // the old per-module routes 301-redirect there. { slug: "layout", titleKey: "settings.sections.layout.title", icon: Palette, + group: "display", }, - // v1.25 (W-ENV) — Environmental context: home location, travel overrides, and - // the weather/daylight backfill. Module-gated on the opt-in `environment` - // module, so the entry only appears once the user turns it on. - { - slug: "environment", - titleKey: "settings.sections.environment.title", - icon: CloudSun, - moduleGate: "environment", - }, - // v1.25 (W-RECORDS) — Anamnese (medical history): the structured-record home - // for allergies + family history. NOT module-gated (like Vorsorge) — these - // are foundational health-profile records, always available. Sits between - // the illness journal and the preventive-care reminders. - { - slug: "anamnesis", - titleKey: "settings.sections.anamnesis.title", - icon: ClipboardList, - }, - // v1.25.7 — Vorsorge (preventive-care reminders) folded into "Darstellung" - // alongside the other tracking modules; `/settings/vorsorge` 301-redirects - // to `/settings/layout/vorsorge` (next.config.ts). + // v1.25.7 — Vorsorge (preventive-care reminders) folded into + // "Darstellung"; `/settings/vorsorge` 301-redirects to + // `/settings/layout/vorsorge`. { slug: "thresholds", titleKey: "settings.sections.thresholds.title", icon: SlidersHorizontal, + group: "display", + }, + // v1.18.0 (S4) — "Benachrichtigungen" is the single module-gated + // reminder-types home; the standalone "Erinnerungen" hub is gone. + { + slug: "notifications", + titleKey: "settings.sections.notifications.title", + icon: Bell, + group: "display", + }, + // ── Health profile ── + // v1.25 (W-RECORDS) — Anamnese (medical history): the structured-record + // home for allergies + family history. NOT module-gated (like Vorsorge) — + // these are foundational health-profile records, always available. + { + slug: "anamnesis", + titleKey: "settings.sections.anamnesis.title", + icon: ClipboardList, + group: "healthProfile", + }, + // v1.25 (W-ENV) — Environmental context: home location, travel overrides, + // and the weather/daylight backfill. Module-gated on the opt-in + // `environment` module, so the entry only appears once the user turns it on. + { + slug: "environment", + titleKey: "settings.sections.environment.title", + icon: CloudSun, + group: "healthProfile", + moduleGate: "environment", + }, + // ── AI ── + { + slug: "ai", + titleKey: "settings.sections.ai.title", + icon: Sparkles, + group: "ai", }, - { slug: "ai", titleKey: "settings.sections.ai.title", icon: Sparkles }, // v1.18.0 (S5) — Coach: the Coach preference cards get their own entry, // shown only when the coach module is enabled. The AI entry above keeps // provider / model / BYOK configuration. @@ -188,54 +277,37 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ slug: "coach", titleKey: "settings.sections.coach.title", icon: Bot, + group: "ai", moduleGate: "coach", }, - { slug: "api", titleKey: "settings.sections.api.title", icon: KeyRound }, - // v1.22.0 — remote MCP connector. Not module-gated: the card carries its own - // enable toggle (mirroring `gesundheitsakte`), so the entry-point stays - // reachable even with the opt-in module off. - { slug: "mcp", titleKey: "settings.sections.mcp.title", icon: Plug }, - // v1.18.0 (S5) — the full health record (PDF + FHIR R4 + zip package) - // earns its own home, lifted out of Export & Import. - // - // v1.18.6.1 — the nav entry is NOT module-gated. The health record is a - // flagship export capability; gating its only entry-point on the - // `doctorReport` opt-out meant a stray toggle in the Modules hub made the - // whole section vanish with no in-context way back. The entry now always - // shows. The `doctorReport` module still governs the data layer: the - // server-side `/api/export/health-record` route remains the hard - // enforcement (403 `module.disabled` over Bearer when the account opts - // out), and the Modules hub keeps the toggle for anyone who wants the - // surface gone elsewhere — but the Settings entry-point stays reachable. + // ── Connectivity ── { - slug: "gesundheitsakte", - titleKey: "settings.sections.gesundheitsakte.title", - icon: FileHeart, + slug: "api", + titleKey: "settings.sections.api.title", + icon: KeyRound, + group: "connectivity", }, - // v1.25.7 — clinician share links fold into the Gesundheitsakte section as a - // labelled "Sharing" group: minting a time-boxed read-only link is a sharing - // face of the same data. `/settings/sharing` 301-redirects to - // `/settings/gesundheitsakte#sharing` (next.config.ts). The public - // `/c/[token]` view and the `/api/share-links` lifecycle are unchanged. + // v1.22.0 — remote MCP connector. Not module-gated: the card carries its + // own enable toggle (mirroring `gesundheitsakte`), so the entry-point + // stays reachable even with the opt-in module off. { - slug: "export", - titleKey: "settings.sections.export.title", - icon: Download, + slug: "mcp", + titleKey: "settings.sections.mcp.title", + icon: Plug, + group: "connectivity", }, + // ── System ── { slug: "advanced", titleKey: "settings.sections.advanced.title", icon: Settings2, - }, - { - slug: "privacy", - titleKey: "settings.sections.privacy.title", - icon: Lock, + group: "system", }, { slug: "about", titleKey: "settings.sections.about.title", icon: Info, + group: "system", }, ] as const; @@ -471,29 +543,41 @@ export function SettingsShell({ aria-label={t("settings.shell.sectionsNav")} className="no-scrollbar relative -mx-4 mb-4 snap-x snap-mandatory overflow-x-auto px-4 md:hidden" > -
    - {visibleSections.map((section) => { +
      + {visibleSections.map((section, index) => { const isActive = section.slug === activeSlug; const Icon = section.icon; + // 2026-07-17 UX/IA audit (M6) — a quiet vertical divider marks a + // group boundary in the horizontal strip (the desktop sidebar + // carries the full text header for the same grouping). + const startsNewGroup = + index > 0 && visibleSections[index - 1].group !== section.group; return ( -
    • - -
    • + + {startsNewGroup ? ( + + ) : null} +
    • + +
    • +
      ); })}
    @@ -537,25 +621,44 @@ export function SettingsShell({ className="no-scrollbar hidden max-h-[calc(100dvh-5.5rem)] overflow-y-auto md:sticky md:top-6 md:col-start-1 md:row-start-2 md:block md:self-start" >
      - {visibleSections.map((section) => { + {visibleSections.map((section, index) => { const isActive = section.slug === activeSlug; const Icon = section.icon; + // 2026-07-17 UX/IA audit (M6) — a non-interactive header + // precedes the first VISIBLE entry of each group so a + // module-gated entry never leaves an orphan header above an + // empty cluster. + const startsNewGroup = + index === 0 || + visibleSections[index - 1].group !== section.group; return ( -
    • - -
    • + + {startsNewGroup ? ( +
    • + {t(SETTINGS_SECTION_GROUP_LABEL_KEYS[section.group])} +
    • + ) : null} +
    • + +
    • +
      ); })}
    diff --git a/src/lib/insights/metric-catalog.ts b/src/lib/insights/metric-catalog.ts index a88c07ecc..d2ef75963 100644 --- a/src/lib/insights/metric-catalog.ts +++ b/src/lib/insights/metric-catalog.ts @@ -69,7 +69,7 @@ export const CATALOG_GROUP_HEADER_KEYS: Record = { body: "insights.tabStrip.bodyParent.header", activity: "insights.tabStrip.activityParent.header", sleep: "insights.editMode.groupSleep", - cardiovascular: "insights.tabStrip.cardiovascularParent.header", + heart: "insights.tabStrip.heartParent.header", hearing: "insights.tabStrip.hearingParent.header", environment: "insights.tabStrip.environmentParent.header", metabolic: "insights.tabStrip.metabolicParent.header", @@ -198,7 +198,7 @@ function buildMetricEntries(): CatalogEntry[] { */ const ECG_ENTRY: CatalogEntry = { id: "ecg", - group: "cardiovascular", + group: "heart", nameKey: "insights.ecg.sectionTitle", sourceKey: "metricCatalog.ecg.source", kind: "metric", @@ -217,7 +217,7 @@ const ECG_ENTRY: CatalogEntry = { */ const WATCHDOG_ENTRY: CatalogEntry = { id: "watchdog", - group: "cardiovascular", + group: "heart", nameKey: "metricCatalog.watchdog.title", sourceKey: "metricCatalog.watchdog.description", kind: "info", diff --git a/src/lib/insights/sub-page-metric.ts b/src/lib/insights/sub-page-metric.ts index bbe2bc391..7b2cb26ad 100644 --- a/src/lib/insights/sub-page-metric.ts +++ b/src/lib/insights/sub-page-metric.ts @@ -41,10 +41,16 @@ // so the five pills collapse behind one "Vitalwerte" / "Vitals" parent // pill with a popover sub-list. The `group: "vitals"` field below // drives that grouping; the canonical order inside the popover follows -// the strip order. The earlier inline pills (`pulse`, `blood-pressure`, -// `weight`, `bmi`, `sleep`, `mood`, `medications`, `workouts`) stay -// flat — they predate v1.4.32 and the maintainer's audit kept them as -// direct entries. +// the strip order. +// +// 2026-07-17 UX/IA audit (H2, M1, M2, M3) — the inline pills that used to +// stay flat "because they predate v1.4.32" are now flat by deliberate +// design or not at all: `blood-pressure`, `weight`, `sleep`, `medications` +// and `mood` are PINNED (see `PINNED_SUB_PAGE_SLUGS` below) because they +// are the account's highest-traffic pages, not because grouping them was +// never considered. `pulse`, `bmi` and `workouts` were flat by historical +// accident only — they now collapse into their correct group (`heart`, +// `body`, `activity`) like every sibling metric. const SUB_PAGE_METRIC = { // ── vitals ── "blood-pressure": ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"], @@ -156,27 +162,43 @@ export const SUB_PAGE_SLUGS = Object.keys(SUB_PAGE_METRIC) as SubPageSlug[]; * `insights.tabStrip.Parent.*` in every locale. */ // v1.7.0 — the new metric clusters each collapse behind a parent pill -// so the strip stays scannable as the sub-page count grows. The flat -// pills (pulse, blood-pressure, weight, bmi, sleep, mood, medications, -// workouts) stay direct entries as before. +// so the strip stays scannable as the sub-page count grows. Only the +// `PINNED_SUB_PAGE_SLUGS` (blood-pressure, weight, sleep, mood, +// medications) stay direct entries. export type SubPageGroup = | "vitals" | "body" | "activity" - | "cardiovascular" + | "heart" | "hearing" | "environment" | "metabolic"; +/** + * 2026-07-17 UX/IA audit (H2) — slugs pinned as flat, always-top-level + * pills regardless of any `SUB_PAGE_GROUP` entry they carry. This is the + * account's short list of highest-traffic pages (per the audit's + * "Pinned flat pills" proposal); everything else renders through its + * group. A pinned slug is excluded from its group's popover children in + * the tab strip (it already has its own pill) but still counts toward + * its `SUB_PAGE_MANAGER_GROUP` for the "Detailseiten verwalten" manager + * and the metric catalog. + */ +export const PINNED_SUB_PAGE_SLUGS: ReadonlySet = new Set([ + "blood-pressure", + "weight", + "sleep", + "medications", + "mood", +]); + export const SUB_PAGE_GROUP: Partial> = { // vitals - hrv: "vitals", - "resting-pulse": "vitals", oxygen: "vitals", "body-temperature": "vitals", - "active-energy": "vitals", "respiratory-rate": "vitals", // body composition (v1.7.0) + bmi: "body", "body-water": "body", "bone-mass": "body", "fat-free-mass": "body", @@ -186,6 +208,8 @@ export const SUB_PAGE_GROUP: Partial> = { "lean-body-mass": "body", // activity / mobility (v1.7.0) steps: "activity", + "active-energy": "activity", + workouts: "activity", "flights-climbed": "activity", "walking-distance": "activity", "walking-steadiness": "activity", @@ -194,9 +218,17 @@ export const SUB_PAGE_GROUP: Partial> = { "double-support-time": "activity", "step-length": "activity", "walking-speed": "activity", - // cardiovascular (v1.7.0) - "pulse-wave-velocity": "cardiovascular", - "vascular-age": "cardiovascular", + // heart (2026-07-17 UX/IA audit H2) — pulse, resting HR, HRV, cardio + // recovery and the Withings vascular markers now share one coherent + // "Heart" popover instead of pulse/resting-HR/HRV being scattered + // across Vitals and a "Cardiovascular" group that only ever held the + // three rarest markers. + pulse: "heart", + "resting-pulse": "heart", + hrv: "heart", + "pulse-wave-velocity": "heart", + "vascular-age": "heart", + "cardio-recovery": "heart", // hearing (v1.7.0) "environmental-audio": "hearing", "headphone-audio": "hearing", @@ -213,10 +245,6 @@ export const SUB_PAGE_GROUP: Partial> = { "six-minute-walk": "activity", "stair-ascent-speed": "activity", "stair-descent-speed": "activity", - // vitals cluster (breathing sits with respiratory rate) - "breathing-disturbances": "vitals", - // cardiovascular cluster - "cardio-recovery": "cardiovascular", // metabolic cluster (overnight wrist reading) "wrist-temperature": "metabolic", // ── v1.25 — physical / clinical signals ── @@ -242,9 +270,7 @@ export const SUB_PAGE_GROUP_ORDER: Record< activity: SUB_PAGE_SLUGS.filter( (slug) => SUB_PAGE_GROUP[slug] === "activity", ), - cardiovascular: SUB_PAGE_SLUGS.filter( - (slug) => SUB_PAGE_GROUP[slug] === "cardiovascular", - ), + heart: SUB_PAGE_SLUGS.filter((slug) => SUB_PAGE_GROUP[slug] === "heart"), hearing: SUB_PAGE_SLUGS.filter((slug) => SUB_PAGE_GROUP[slug] === "hearing"), environment: SUB_PAGE_SLUGS.filter( (slug) => SUB_PAGE_GROUP[slug] === "environment", @@ -258,24 +284,25 @@ export const SUB_PAGE_GROUP_ORDER: Record< * v1.15.14 W2 — manager grouping for the "Anpassen" → "Detailseiten * verwalten" surface. * - * The tab strip only assigns a `SUB_PAGE_GROUP` to the long-tail metrics - * that collapse behind a popover parent pill; the eight legacy flat pills - * (blood-pressure, pulse, weight, bmi, sleep, mood, medications, workouts) - * render as direct entries and carry NO `SUB_PAGE_GROUP`. The sub-page - * MANAGER, by contrast, lists EVERY slug grouped under a category header, - * so it needs a total slug → group mapping that covers the flat pills too - * and adds the three categories the tab strip never groups (`sleep`, - * `mood`, `events`). This map is that total assignment; it is a superset - * of `SUB_PAGE_GROUP` (every `SUB_PAGE_GROUP` entry agrees with it) plus a - * fallback for the flat slugs. Single source of truth for the manager — - * the tab-strip nav grouping stays `SUB_PAGE_GROUP` exactly as before. + * The tab strip only assigns a `SUB_PAGE_GROUP` to the metrics that + * collapse behind a popover parent pill; the pinned flat pills + * (blood-pressure, weight, sleep, mood, medications — + * `PINNED_SUB_PAGE_SLUGS`) render as direct entries and carry NO + * `SUB_PAGE_GROUP`. The sub-page MANAGER, by contrast, lists EVERY slug + * grouped under a category header, so it needs a total slug → group + * mapping that covers the pinned slugs too and adds the three categories + * the tab strip never groups (`sleep`, `mood`, `events`). This map is + * that total assignment; it is a superset of `SUB_PAGE_GROUP` (every + * `SUB_PAGE_GROUP` entry agrees with it) plus a fallback for the pinned + * slugs. Single source of truth for the manager — the tab-strip nav + * grouping stays `SUB_PAGE_GROUP` exactly as before. */ export type ManagerGroup = | "vitals" | "body" | "activity" | "sleep" - | "cardiovascular" + | "heart" | "hearing" | "environment" | "metabolic" @@ -285,7 +312,7 @@ export type ManagerGroup = /** * Render order of the manager's category sections — mirrors the * MeasurementCategory overlay the rest of insights follows - * (vitals → body → activity → sleep → cardiovascular → hearing → + * (vitals → body → activity → sleep → heart → hearing → * environment → metabolic → mood → events). */ export const MANAGER_GROUP_ORDER: readonly ManagerGroup[] = [ @@ -293,7 +320,7 @@ export const MANAGER_GROUP_ORDER: readonly ManagerGroup[] = [ "body", "activity", "sleep", - "cardiovascular", + "heart", "hearing", "environment", "metabolic", @@ -304,13 +331,15 @@ export const MANAGER_GROUP_ORDER: readonly ManagerGroup[] = [ /** Explicit group for the flat tab-strip slugs (those `SUB_PAGE_GROUP` omits). */ const FLAT_SLUG_MANAGER_GROUP: Partial> = { "blood-pressure": "vitals", - pulse: "vitals", weight: "body", - bmi: "body", - workouts: "activity", sleep: "sleep", mood: "mood", medications: "events", + // 2026-07-17 UX/IA audit (M3) — breathing disturbances stays a flat, + // ungrouped tab-strip pill (see `SUB_PAGE_GROUP`) but files under the + // Sleep section in the manager, not the foreign Vitals popover it used + // to disappear from whenever the sleep module was off. + "breathing-disturbances": "sleep", // v1.29 — nutrient intake filed with the metabolic cluster (alongside // blood glucose / skin / wrist temperature). nutrients: "metabolic", From d978c2b66252102cc184b45e08566f6e85bc7a3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 01:39:22 +0200 Subject: [PATCH 25/33] refactor(compliance): split the god-module into focused units behind a barrel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The medication-compliance engine lived in one 2,099-line file with 38 exports feeding eight surfaces (Coach prompt, BP-status gate, dashboard pillar, per-medication tile, insight targets, doctor report, …). A single edit intended for one surface could silently shift all of them. Split along the existing internal seams into `analytics/compliance/`: parsing, types, adapters, dose-status, occurrences, windows, cycle, ledger, display. `analytics/compliance.ts` is now a thin barrel that re-exports every public symbol, so no consumer import path changes. Pure move — no logic edits; the tally, window ladder, ledger and cycle math are byte-for-byte the code they were. Tests move alongside under `compliance/__tests__/` and keep exercising the public barrel. --- src/lib/analytics/compliance.ts | 2097 +---------------- .../as-needed-compliance-exclusion.test.ts | 0 .../__tests__/compliance-bundle.test.ts | 2 +- .../__tests__/compliance-pause-era.test.ts | 2 +- .../__tests__/compliance.test.ts | 4 +- .../schedule-compliance-select.test.ts | 2 +- src/lib/analytics/compliance/adapters.ts | 131 + src/lib/analytics/compliance/cycle.ts | 199 ++ src/lib/analytics/compliance/display.ts | 727 ++++++ src/lib/analytics/compliance/dose-status.ts | 153 ++ src/lib/analytics/compliance/ledger.ts | 379 +++ src/lib/analytics/compliance/occurrences.ts | 127 + src/lib/analytics/compliance/parsing.ts | 96 + src/lib/analytics/compliance/types.ts | 252 ++ src/lib/analytics/compliance/windows.ts | 113 + 15 files changed, 2205 insertions(+), 2079 deletions(-) rename src/lib/analytics/{ => compliance}/__tests__/as-needed-compliance-exclusion.test.ts (100%) rename src/lib/analytics/{ => compliance}/__tests__/compliance-bundle.test.ts (99%) rename src/lib/analytics/{ => compliance}/__tests__/compliance-pause-era.test.ts (99%) rename src/lib/analytics/{ => compliance}/__tests__/compliance.test.ts (99%) rename src/lib/analytics/{ => compliance}/__tests__/schedule-compliance-select.test.ts (94%) create mode 100644 src/lib/analytics/compliance/adapters.ts create mode 100644 src/lib/analytics/compliance/cycle.ts create mode 100644 src/lib/analytics/compliance/display.ts create mode 100644 src/lib/analytics/compliance/dose-status.ts create mode 100644 src/lib/analytics/compliance/ledger.ts create mode 100644 src/lib/analytics/compliance/occurrences.ts create mode 100644 src/lib/analytics/compliance/parsing.ts create mode 100644 src/lib/analytics/compliance/types.ts create mode 100644 src/lib/analytics/compliance/windows.ts diff --git a/src/lib/analytics/compliance.ts b/src/lib/analytics/compliance.ts index 28f2a12e2..c684440ce 100644 --- a/src/lib/analytics/compliance.ts +++ b/src/lib/analytics/compliance.ts @@ -17,2083 +17,32 @@ * `classifyIntakeTiming` is unchanged and still owns the early / * on_time / late / very_late punctuality bucket logic used by the * daily compliance heatmap on `/api/medications/[id]/compliance`. - */ - -import type { SlotBand } from "@/lib/medications/scheduling/attribution"; -import { - type BandMinterMedication, - type DoseWindowConfig, -} from "@/lib/medications/scheduling/band-minter"; -import { - buildBandsForSchedulesWithEras, - occurrencesAcrossEras, - type ScheduleRevisionLike, -} from "@/lib/medications/scheduling/schedule-eras"; -import { - buildCadenceTimeline, - type CadenceEngineContext, - type IntakeEventLike, - type ScheduleLike, -} from "@/lib/medications/scheduling/cadence"; -import { streaksFromTimeline } from "@/lib/medications/scheduling/compliance"; -import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults"; -import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers"; -import { - reconstructDoseHistory, - type DoseHistoryRow, - type HistoryIntake, -} from "@/lib/medications/scheduling/dose-history"; -import { - expandRollingRetrospective, - nextOccurrenceAfter, - occurrencesBetween, - type CanonicalSchedule, - type Occurrence, - type RecurrenceContext, - type ScheduleType, -} from "@/lib/medications/scheduling/recurrence"; -import { userDayKey } from "@/lib/tz/resolver"; -import { localHmAsUtc } from "@/lib/tz/local-day"; - -export interface IntakeEvent { - takenAt: Date | null; - skipped: boolean; - scheduledFor: Date; - /** - * v1.15.9 — true when the auto-miss cron marked this never-acted dose as - * a forgotten miss. Threaded into the cadence timeline so it counts as a - * `missed` slot (against the rate) instead of being neutralised. Optional - * so pre-v1.15.9 callers / fixtures default it to a normal pending/taken - * row. - */ - autoMissed?: boolean; - /** - * v1.15.20 — slot-binding provenance. `USER_PIN` = the user deliberately - * pinned this off-window take onto its `scheduledFor` slot; the unified - * ledger then binds it by anchor (taken-late, never on-time-washed) - * instead of degrading it to ad-hoc when the takenAt sits outside the - * band tail. Optional so legacy callers / fixtures default to AUTO. - */ - attributionSource?: "AUTO" | "USER_PIN"; -} - -export type IntakeTimingClass = - "early" | "on_time" | "late" | "very_late" | "missed"; - -export interface ComplianceResult { - totalExpected: number; - taken: number; - skipped: number; - missed: number; - rate: number; // 0-100 - streak: number; // consecutive days with all taken -} - -/** - * Daily compliance data including timing breakdown. * - * `early` is the v1.4.34 bucket for doses taken before the window's - * 3-hour grace start. Consumers that group by compliance status - * should treat `early` as compliant (alongside `onTime`). - */ -export interface DailyComplianceEntry { - expected: number; - /** - * v1.7.0 item 5 — the true engine-computed due-slot count for the day. - * Equals `expected`; carried as an explicit additive field iOS keys - * off so it doesn't have to infer "due-ness" from `expected`. - */ - expectedCount: number; - /** - * v1.7.0 item 5 — `expectedCount > 0`. iOS renders a "missed" mark - * only when `due === true`, so off-weeks / non-matching weekdays / PRN - * days no longer paint a false miss. - */ - due: boolean; - taken: number; - skipped: number; - onTime: number; - late: number; - veryLate: number; - early?: number; -} - -/** - * Parse "HH:mm" into hours and minutes. - */ -function parseHHmm(time: string): { hours: number; minutes: number } { - const [h, m] = time.split(":").map(Number); - return { hours: h, minutes: m }; -} - -/** - * Build the UTC instant of local "HH:mm" on the calendar day `day` falls on. + * --- * - * When `tz` is supplied the window `HH:mm` is interpreted in the user's zone - * (via {@link localHmAsUtc}) on the local day of `day` — so an "08:00" slot is - * 08:00 LOCAL, not 08:00 UTC. Without `tz` it keeps the legacy UTC anchoring - * (`setUTCHours`), which is only correct for a UTC user; a caller far from UTC - * must pass its zone or the punctuality band is offset by the zone's offset. - */ -function toDateOnDay(time: string, day: Date, tz?: string): Date { - const { hours, minutes } = parseHHmm(time); - if (tz) { - return localHmAsUtc(day, tz, hours, minutes); - } - const d = new Date(day); - d.setUTCHours(hours, minutes, 0, 0); - return d; -} - -/** - * Classify how punctual an intake was relative to a schedule window. + * This file is a thin barrel. The implementation lives in focused modules + * under `./compliance/` — split along the original internal seams so the + * most-changed business path is editable one concern at a time: * - * v1.4.34 IW-C — widened pre-window grace from 1h to 3h and introduced - * a dedicated `early` bucket so a proactive logger (e.g. 10 min before - * the window) no longer gets flushed into `very_late`. The post-window - * grace likewise grew to 3h so a "20 minutes late" dose stays `on_time`. - * Only doses beyond a 3-hour late tolerance fall into `late`, and only - * doses beyond a further `lateMinutes` tail fall into `very_late`. + * - `compliance/parsing.ts` HH:mm parsing + punctuality classification + * - `compliance/types.ts` shared shapes, selects, context builders + * - `compliance/adapters.ts` canonical-engine adapters (internal) + * - `compliance/dose-status.ts` per-dose window model + cadence family + * - `compliance/occurrences.ts` expected-slot expansion + counting + * - `compliance/windows.ts` the compliance-window ladder + selection + * - `compliance/cycle.ts` open-cycle (current dose) descriptor + * - `compliance/ledger.ts` dose-history ledger tally + per-day rates + * - `compliance/display.ts` two-row display, calculateCompliance, bundle * - * Buckets: - * - "early": within (windowStart - 3h) .. windowStart (compliant) - * - "on_time": windowStart .. (windowEnd + 3h) (compliant) - * - "late": (windowEnd + 3h) .. (windowEnd + 3h + lateMinutes) (default 2h tail) - * - "very_late": before windowStart - 3h, or after the late tail - * - "missed": takenAt is null - * - * Handles overnight windows (windowEnd < windowStart means next day). - * - * @param options.lateMinutes Width of the `late` band beyond the 3-hour - * on-time tolerance, in minutes. Defaults to 120. Doses past this - * tail land in `very_late`. - * @param options.tz IANA zone the window `HH:mm` is interpreted in. Supply - * the user's zone so an "08:00" slot means 08:00 LOCAL; omitting it keeps - * the legacy UTC interpretation (correct only for a UTC user). + * Every symbol the eight compliance call sites import keeps resolving from + * `@/lib/analytics/compliance` — no consumer import path changed. */ -export function classifyIntakeTiming( - takenAt: Date | null, - windowStart: string, // "HH:mm" - windowEnd: string, // "HH:mm" - scheduledDate: Date, // the date this was scheduled - options?: { lateMinutes?: number; tz?: string }, -): IntakeTimingClass { - if (takenAt === null) return "missed"; - const start = toDateOnDay(windowStart, scheduledDate, options?.tz); - let end = toDateOnDay(windowEnd, scheduledDate, options?.tz); - - // Handle overnight windows (e.g. windowStart="23:00", windowEnd="01:00") - if (end <= start) { - end = new Date(end.getTime() + 24 * 60 * 60 * 1000); - } - - const HOUR_MS = 60 * 60 * 1000; - // 3h pre-window grace: doses inside this window are `early` (still - // compliant). Doses before it are `very_late`. - const earlyStart = new Date(start.getTime() - 3 * HOUR_MS); - // 3h post-window grace: doses up to here are still `on_time`. - const onTimeEnd = new Date(end.getTime() + 3 * HOUR_MS); - // Configurable `late` tail past the on-time grace (default 120 min). - const lateTolerance = (options?.lateMinutes ?? 120) * 60 * 1000; - const lateEnd = new Date(onTimeEnd.getTime() + lateTolerance); - - const t = takenAt.getTime(); - - if (t < earlyStart.getTime()) return "very_late"; - if (t < start.getTime()) return "early"; - if (t <= onTimeEnd.getTime()) return "on_time"; - if (t <= lateEnd.getTime()) return "late"; - return "very_late"; -} - -/** - * v1.15.9 — cadence-aware per-dose grace/miss window model. - * - * A medication's dose either is on-time, is still takeable but late - * (counts when taken), or has slipped past the point where a self-hoster - * could plausibly still take it (a MISS). The cutoffs differ by cadence: - * an intraday/daily dose has tight hour-scale windows; a weekly/rolling - * injectable (the GLP-1 case) follows the clinical "take it within 4 days, - * otherwise skip to the next scheduled dose" rule. - * - * The defaults below are centralised named constants so a future revision - * can expose them per-medication without re-deriving the boundaries. Make - * it correct first; configurability later. - * - * DAILY / INTRADAY (minute-scale windows around the target instant): - * - on-time: target − 60 min … target + 60 min - * - overdue: target + 60 min … target + 240 min (still takeable) - * - missed: > target + 240 min, OR the next dose's on-time window - * starts first (whichever is sooner — adjacent doses never - * overlap). - * - * WEEKLY / ROLLING injectable (day-scale windows; the 4-day clinical rule): - * - on-time: target day ± 1 day - * - overdue: up to target + 4 days (late-but-counts) - * - missed: > target + 4 days - */ -export { DOSE_WINDOW_DEFAULTS }; - -/** - * v1.15.9 — the derived per-dose state the medication card renders. - * - * - `on_time_window` — due now, inside the on-time window (green). - * - `overdue` — past on-time, before the miss cutoff (still - * takeable; the card escalates the tint as it nears - * the cutoff — "stark überfällig"). - * - `missed` — past the miss cutoff, never acted on. - * - `taken_on_time` — taken inside the on-time window. - * - `taken_late` — taken in the overdue window (still counts). - * - `skipped` — a deliberate user skip (excluded from the rate). - * - `upcoming` — the on-time window has not opened yet. - * - * PRN doses are never scheduled and so never produce a status — callers - * exclude them before calling {@link deriveDoseStatus}. - */ -export type DoseStatus = - | "upcoming" - | "on_time_window" - | "overdue" - | "missed" - | "taken_on_time" - | "taken_late" - | "skipped"; - -/** Cadence family the window math keys off. */ -export type DoseCadenceFamily = "daily" | "weekly"; - -const HOUR_MS = 60 * 60 * 1000; -const ONE_DAY_MS = 24 * HOUR_MS; - -/** - * v1.15.9 — derive a single dose's {@link DoseStatus} from its target - * instant, the cadence family, `now`, and (optionally) when it was taken / - * whether the user skipped it. Pure and deterministic. - * - * `nextDoseAt` (when supplied) caps a daily dose's miss cutoff at the next - * dose's on-time window start, so two adjacent intraday doses never claim - * overlapping overdue windows — the earlier dose flips to `missed` the - * moment the later one comes due if that is sooner than its own cutoff. - * - * The defaults come from {@link DOSE_WINDOW_DEFAULTS}; callers may override - * per-medication once that configurability lands. - */ -export function deriveDoseStatus( - targetAt: Date, - cadence: DoseCadenceFamily, - now: Date, - options?: { - takenAt?: Date | null; - skipped?: boolean; - nextDoseAt?: Date | null; - windows?: Partial; - }, -): DoseStatus { - if (options?.skipped) return "skipped"; - - const w = { ...DOSE_WINDOW_DEFAULTS, ...(options?.windows ?? {}) }; - const target = targetAt.getTime(); - - let onTimeStart: number; - let onTimeEnd: number; - let overdueEnd: number; - if (cadence === "weekly") { - onTimeStart = target - w.weeklyOnTimeDays * ONE_DAY_MS; - onTimeEnd = target + w.weeklyOnTimeDays * ONE_DAY_MS; - overdueEnd = target + w.weeklyOverdueDays * ONE_DAY_MS; - } else { - onTimeStart = target - w.dailyOnTimeMinutes * 60_000; - onTimeEnd = target + w.dailyOnTimeMinutes * 60_000; - overdueEnd = onTimeEnd + w.dailyOverdueMinutes * 60_000; - } - - // Never let the miss cutoff bleed into the next dose's on-time window. - if (options?.nextDoseAt) { - const nextOnTimeStart = - cadence === "weekly" - ? options.nextDoseAt.getTime() - w.weeklyOnTimeDays * ONE_DAY_MS - : options.nextDoseAt.getTime() - w.dailyOnTimeMinutes * 60_000; - if (nextOnTimeStart < overdueEnd) overdueEnd = nextOnTimeStart; - } - - const takenAt = options?.takenAt ?? null; - if (takenAt) { - const t = takenAt.getTime(); - return t <= onTimeEnd ? "taken_on_time" : "taken_late"; - } - - const n = now.getTime(); - if (n < onTimeStart) return "upcoming"; - if (n <= onTimeEnd) return "on_time_window"; - if (n <= overdueEnd) return "overdue"; - return "missed"; -} - -/** - * v1.15.9 — classify a schedule's cadence into the {@link DoseCadenceFamily} - * the window model uses. A rolling cadence, or any RRULE / legacy weekly - * cadence that emits less often than daily, is `weekly` (day-scale windows + - * the 4-day rule); everything denser (daily, intraday multi-dose) is - * `daily` (minute-scale windows). - */ -export function doseCadenceFamily( - schedule: ComplianceSchedule, -): DoseCadenceFamily { - if ( - schedule.rollingIntervalDays != null && - schedule.rollingIntervalDays >= 2 - ) { - return "weekly"; - } - const rrule = schedule.rrule ?? ""; - if (/FREQ=(WEEKLY|MONTHLY|YEARLY)/i.test(rrule)) return "weekly"; - // A legacy daysOfWeek restriction to specific weekdays (not every day) - // dosed once a day is still effectively daily-cadence on the days it - // fires; the per-slot window is minute-scale either way. Only the rolling - // / calendar-sparse shapes use the day-scale 4-day rule. - return "daily"; -} - -/** - * v1.5.0 — schedule view consumed by the cadence-aware adapter. - * - * Accepts the historical `ScheduleWindow` shape (`windowStart` + - * `windowEnd` only) as well as the richer `ScheduleLike` shape that - * carries `daysOfWeek`. When `daysOfWeek` is missing the schedule is - * treated as `null` (daily, every day) so legacy fixtures keep - * passing without modification. - */ -export interface ComplianceSchedule { - windowStart: string; // HH:mm - windowEnd: string; // HH:mm - daysOfWeek?: string | null; - /** - * v1.7.0 SB-SCHED-2 — canonical-engine fields. When present (and a - * `medicationContext` is threaded into `calculateCompliance`), the - * expected-slot grid is computed through the canonical recurrence - * engine, so an `rrule = "FREQ=WEEKLY;BYDAY=MO"` schedule counts only - * Mondays in the denominator instead of every day. Absent fields keep - * the legacy `daysOfWeek` path — existing fixtures / callers that pass - * only `{ windowStart, windowEnd }` behave exactly as before. - */ - rrule?: string | null; - rollingIntervalDays?: number | null; - timesOfDay?: string[]; - reminderGraceMinutes?: number | null; - scheduleType?: ScheduleType | null; - cyclicOnWeeks?: number | null; - cyclicOffWeeks?: number | null; - /** - * v1.15.18 — per-dose configurable on-time windows. Threaded onto the - * canonical schedule so `tallyComplianceFromLedger` builds the on-time band - * from the explicit `[start, end]` range — keeping the % exactly aligned with - * the dose-history view and the write/edit attribution (all consume the same - * minter). Accepts the raw persisted JSON (a full Prisma row drops straight - * in); `toCanonicalSchedule` normalises it via `normaliseDoseWindows`. - */ - doseWindows?: unknown; -} - -/** - * v1.15.20 — the ONE Prisma `select` for schedule rows feeding a compliance - * computation. Every call site that loads schedules for - * `calculateCompliance` / the cadence helpers must use this constant instead - * of hand-rolling a field list (or an unbounded `include: { schedules: - * true }`), so a future schedule column that the engine consumes — the way - * `doseWindows` joined in v1.15.18 — reaches every surface the moment it is - * added here. Covers the full {@link ComplianceSchedule} shape plus `id` - * (engine-internal labelling / diagnostics). - */ -export const SCHEDULE_COMPLIANCE_SELECT = { - id: true, - windowStart: true, - windowEnd: true, - daysOfWeek: true, - timesOfDay: true, - reminderGraceMinutes: true, - rrule: true, - rollingIntervalDays: true, - scheduleType: true, - cyclicOnWeeks: true, - cyclicOffWeeks: true, - // The configurable per-dose on-time windows. Selecting them everywhere is - // the point: a user-configured "07:00–09:00" band must shape the rate on - // the dashboard pillar, the insights features, the BP-status gate and the - // report exactly as it shapes the medication detail page. - doseWindows: true, -} as const; - -/** - * v1.7.0 SB-SCHED-2 — medication-level context the canonical engine - * needs to expand expected slots. When supplied, `calculateCompliance` - * routes the denominator through the engine; when omitted, every - * schedule falls back to the legacy weekday walker (the byte-stable - * pre-v1.7 behaviour that the parity fixtures pin). - */ -export interface ComplianceMedicationContext { - startsOn: Date | null; - endsOn: Date | null; - oneShot: boolean; - createdAt: Date; - lastIntakeAt: Date | null; - timeZone: string; - /** - * v1.16.3 — archived schedule eras (`validFrom` ascending). When present, - * every expected-slot expansion + band mint segments its range so a past - * day counts/mints against the schedule that was live THEN. Optional: - * callers without revisions keep the live-only expansion. - */ - scheduleRevisions?: ScheduleRevisionLike[]; - /** - * v1.25 H-MED1 — durable pause intervals (`MedicationPauseEra`). When - * present, expected dose slots whose anchor falls inside any - * `[pausedAt, resumedAt ?? now)` interval are dropped from the ledger so a - * resumed medication never counts the paused days as missed. Optional: - * callers without eras keep counting every expected slot. - */ - pauseEras?: MedicationPauseEraLike[]; -} - -/** - * v1.25 H-MED1 — the pause-interval projection the compliance engine reads. - * A Prisma `MedicationPauseEra` row (or a `{ pausedAt, resumedAt }` literal) - * drops in. An open era (`resumedAt === null`) runs to `now`. - */ -export interface MedicationPauseEraLike { - pausedAt: Date; - resumedAt: Date | null; -} - -/** - * v1.7.0 SB-SCHED-2 — convenience builder so the eight compliance call - * sites don't each re-spell the context shape. Pass the medication row - * (any object carrying the course-window fields), the latest non-skipped - * intake instant, and the user's timezone. - */ -export function buildComplianceMedicationContext( - med: { - startsOn: Date | null; - endsOn: Date | null; - oneShot: boolean; - createdAt: Date; - /** v1.16.3 — thread the archived eras when the bundle carries them. */ - scheduleRevisions?: ScheduleRevisionLike[]; - /** v1.25 H-MED1 — thread the pause eras when the caller carries them. */ - pauseEras?: MedicationPauseEraLike[]; - }, - lastIntakeAt: Date | null, - timeZone: string, -): ComplianceMedicationContext { - return { - startsOn: med.startsOn, - endsOn: med.endsOn, - oneShot: med.oneShot, - createdAt: med.createdAt, - lastIntakeAt, - timeZone, - ...(med.scheduleRevisions && { scheduleRevisions: med.scheduleRevisions }), - ...(med.pauseEras && { pauseEras: med.pauseEras }), - }; -} - -/** - * v1.7.0 SB-SCHED-2 — the latest non-skipped `takenAt` across an event - * list (rolling cadences re-anchor on it). Returns null when the user - * has never logged a non-skipped intake. Order-independent, so the - * caller can pass an events array in any sort order. - */ -export function lastNonSkippedTakenAt( - events: { takenAt: Date | null; skipped: boolean }[], -): Date | null { - return events.reduce((latest, e) => { - if (e.skipped || e.takenAt === null) return latest; - if (latest === null || e.takenAt.getTime() > latest.getTime()) { - return e.takenAt; - } - return latest; - }, null); -} - -/** - * v1.13.x — the non-skipped `takenAt` instants at or before `now`, ascending. - * These anchor the retrospective rolling expected-dose grid (each logged dose - * is one satisfied expected slot). The full history is passed — not just the - * compliance window — so the gap-walk between consecutive intakes (which - * synthesizes skipped-cycle misses) and the forward next-due anchor stay - * correct across the window boundary; `expandRollingRetrospective` clamps the - * emitted slots to its own `[from, to]`. - */ -function rollingIntakeInstants( - events: { takenAt: Date | null; skipped: boolean }[], - now: Date, -): Date[] { - return events - .filter( - (e): e is { takenAt: Date; skipped: boolean } => - !e.skipped && - e.takenAt !== null && - e.takenAt.getTime() <= now.getTime(), - ) - .map((e) => e.takenAt) - .sort((a, b) => a.getTime() - b.getTime()); -} - -/** - * v1.8.5 — adapt a compliance medication context to the canonical engine's - * {@link RecurrenceContext}. The synthetic medication `id` only labels the - * row for the engine's internal logging; the `idTag` keeps the two callers' - * historical prefixes (`compliance-daily` vs `compliance-slots`) distinct. - */ -function toRecurrenceCtx( - ctx: ComplianceMedicationContext, - idTag: string, -): RecurrenceContext { - return { - medication: { - id: idTag, - startsOn: ctx.startsOn, - endsOn: ctx.endsOn, - oneShot: ctx.oneShot, - createdAt: ctx.createdAt, - }, - timeZone: ctx.timeZone, - lastIntakeAt: ctx.lastIntakeAt, - }; -} - -/** - * v1.8.5 — adapt a {@link ComplianceSchedule} to the canonical engine's - * {@link CanonicalSchedule}, defaulting the optional fields the compliance - * payload may omit. `id` is engine-internal labelling only. - */ -function toCanonicalSchedule( - s: ComplianceSchedule, - id: string, -): CanonicalSchedule { - return { - id, - rrule: s.rrule ?? null, - rollingIntervalDays: s.rollingIntervalDays ?? null, - timesOfDay: s.timesOfDay ?? [], - daysOfWeek: s.daysOfWeek ?? null, - windowStart: s.windowStart, - windowEnd: s.windowEnd, - reminderGraceMinutes: s.reminderGraceMinutes ?? null, - scheduleType: s.scheduleType ?? "SCHEDULED", - cyclicOnWeeks: s.cyclicOnWeeks ?? null, - cyclicOffWeeks: s.cyclicOffWeeks ?? null, - doseWindows: normaliseDoseWindows(s.doseWindows), - }; -} - -/** - * v1.13.x — non-skipped intake instants for the retrospective rolling grid, - * mirroring {@link rollingIntakeInstants} but reading the public - * `{ takenAt, skipped }[]` shape the `expectedSlots*` callers pass. The - * `expectedSlots*` helpers accept this so their rolling denominator uses the - * SAME expansion as the displayed-rate numerator in {@link calculateCompliance}. - */ -export interface ComplianceIntakeInstant { - takenAt: Date | null; - skipped: boolean; - /** - * v1.15.10 — the dose slot this intake resolves (the canonical snapped - * instant, byte-identical to the engine occurrence's `.at`). Optional so - * every legacy caller / fixture that omits it keeps working; when present - * the open-dose selection in {@link buildCurrentCycle} uses it to skip a - * slot the user has already acted on (taken / skipped / auto-missed) so the - * card advances to the next genuinely-open slot rather than re-surfacing a - * resolved past dose. - */ - scheduledFor?: Date; - /** - * v1.15.10 — the cron-flagged forgotten miss. A resolved (auto-missed) slot - * is excluded from the open-dose search the same way a take / skip is. - */ - autoMissed?: boolean; -} - -function intakeInstantsAtOrBefore( - intakes: ComplianceIntakeInstant[], - now: Date, -): Date[] { - return intakes - .filter( - (e): e is { takenAt: Date; skipped: boolean } => - !e.skipped && - e.takenAt !== null && - e.takenAt.getTime() <= now.getTime(), - ) - .map((e) => e.takenAt) - .sort((a, b) => a.getTime() - b.getTime()); -} - -/** - * v1.13.x — expand one schedule's expected occurrences over `[from, to]`, - * routing a ROLLING schedule through the retrospective builder when intake - * history is supplied (`intakeInstants` + `now`) and the forward-only engine - * path for every other shape. This is the single expansion the slot-count - * helpers and the displayed-rate timeline both delegate to, so the heatmap - * `due` flags, the window-selection denominator, and the percentage agree. - */ -function expandComplianceOccurrences( - canonical: CanonicalSchedule, - recurrenceCtx: RecurrenceContext, - from: Date, - to: Date, - retro: { intakeInstants: Date[]; now: Date } | undefined, -): Occurrence[] { - if (retro && canonical.rollingIntervalDays !== null) { - return expandRollingRetrospective( - canonical, - recurrenceCtx, - from, - to, - retro.intakeInstants, - retro.now, - ); - } - return occurrencesBetween(canonical, from, to, recurrenceCtx); -} - -/** - * v1.7.0 item 5 — count the expected dose slots a medication's schedules - * emit inside `[dayStart, dayEnd)`, routed through the canonical engine. - * Powers the per-day `due` / `expectedCount` fields on the per-med - * compliance payload so iOS history renders a "missed" mark only on days - * the schedule actually expected a dose (not off-weeks / non-matching - * weekdays / PRN days). - * - * v1.13.x — pass `intakes` so a ROLLING schedule routes through the - * retrospective grid (each logged dose is a `due` day, plus skipped-cycle - * misses + a past-due forward slot) instead of the engine's single forward - * slot. Omitting `intakes` keeps the forward-only behaviour for callers that - * don't have the intake history at hand. - */ -export function expectedSlotCountForDay( - schedules: ComplianceSchedule[], - dayStart: Date, - dayEnd: Date, - ctx: ComplianceMedicationContext, - intakes?: ComplianceIntakeInstant[], -): number { - const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-daily"); - const now = new Date(); - const retro = - intakes && schedules.some((s) => s.rollingIntervalDays != null) - ? { intakeInstants: intakeInstantsAtOrBefore(intakes, now), now } - : undefined; - // v1.16.3 — era-aware: a past day counts the slots of the schedule that - // was live THEN. With no revisions the single live era expands exactly - // the per-schedule loop this used to run. - return occurrencesAcrossEras( - { - from: dayStart, - // occurrencesBetween is inclusive of both ends; subtract 1 ms so a - // slot exactly at the next day's midnight doesn't double-count. - to: new Date(dayEnd.getTime() - 1), - }, - ctx.scheduleRevisions ?? [], - schedules.map((s, i) => toCanonicalSchedule(s, `compliance-daily-${i}`)), - (schedule, eraFrom, eraTo) => - expandComplianceOccurrences( - schedule, - recurrenceCtx, - eraFrom, - eraTo, - retro, - ), - { oneShot: ctx.oneShot }, - ).length; -} - -/** - * v1.8.5 — the sibling of {@link expectedSlotCountForDay} that returns the - * expected-dose occurrences *themselves* (ascending by instant) over an - * arbitrary window, rather than just the per-day count. Same loop, same - * canonical-engine delegation; we keep the slots so the dose-adherence - * timeline can pair each expected slot to its intake. - * - * Used by {@link buildComplianceDisplay} to decide the card's render mode - * (percent bars vs an uptime-style per-dose strip) and to build the strip. - * `occurrencesBetween` is inclusive of both ends; the caller passes a - * `[from, to]` window and we sort the union of every schedule's slots so - * a multi-schedule medication interleaves its slots in time order. - * - * v1.8.6 QA — the window lower bound is clamped to `ctx.createdAt`. The - * legacy weekday walker floors on `startsOn` but not on `createdAt`, so a - * brand-new daily med queried over a 30-day window would otherwise emit - * slots for every day before it existed (7/30 expected on a 2-day-old med). - * Clamping here keeps the expected-dose denominator — and the window - * selection that reads it — honest about the medication's real age. The - * displayed rates clamp independently via `calculateCompliance`'s - * `medicationCreatedAt` argument, so this only fixes the slot counts. - */ -export function expectedSlotsBetween( - schedules: ComplianceSchedule[], - from: Date, - to: Date, - ctx: ComplianceMedicationContext, - intakes?: ComplianceIntakeInstant[], -): Occurrence[] { - const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-slots"); - const effectiveFrom = - ctx.createdAt.getTime() > from.getTime() ? ctx.createdAt : from; - // v1.13.x — for a ROLLING schedule the expected grid is reconstructed from - // the intake history (each logged dose is one satisfied expected slot) so - // the window-selection denominator matches the displayed rate. The forward - // next-due slot counts only when past-due relative to `to` (the window's - // upper bound), so a not-yet-due open cycle never inflates the count. - const retro = - intakes && schedules.some((s) => s.rollingIntervalDays != null) - ? { intakeInstants: intakeInstantsAtOrBefore(intakes, to), now: to } - : undefined; - if (effectiveFrom.getTime() > to.getTime()) return []; - // v1.16.3 — era-aware: each archived era contributes the slots of ITS - // schedules; the live rows cover only the range past the newest revision. - return occurrencesAcrossEras( - { from: effectiveFrom, to }, - ctx.scheduleRevisions ?? [], - schedules.map((s, i) => toCanonicalSchedule(s, `compliance-slots-${i}`)), - (schedule, eraFrom, eraTo) => - expandComplianceOccurrences( - schedule, - recurrenceCtx, - eraFrom, - eraTo, - retro, - ), - { oneShot: ctx.oneShot }, - ); -} - -/** - * v1.8.6 — the floor at which a window's percentage is stable. Four - * expected doses is the point where a single miss moves the rate by ≤25% - * rather than ±50–100%. A daily med clears it in a 7-day window; a weekly - * med needs ~30 days; a tri-weekly / 35-day-rolling med needs a quarter or - * more. The window ladder below steps up until both rows clear this floor. - */ -export const MIN_STABLE_DOSES = 4; - -/** - * v1.8.6 — the rung ladder for the two compliance windows. Each rung is a - * `[short, long]` pair of day-counts. The card always shows two percentage - * rows; the only thing that scales with cadence is which rung the windows - * sit on. Dense meds (daily / weekday) sit on `[7, 30]`; as the expected - * dose frequency drops, both windows step up so each row still spans enough - * expected doses to mean something, up to a 12-month long window for very - * rare meds. - */ -export const COMPLIANCE_WINDOW_LADDER: ReadonlyArray< - readonly [number, number] -> = [ - [7, 30], - [30, 90], - [90, 365], -]; - -/** - * v1.8.6 — the day-counts of the two compliance windows a medication's - * cadence resolves to, plus the realised expected-dose count each window - * holds. `shortDays` / `longDays` drive the row labels; the expected counts - * are surfaced so a client can show the denominator or re-derive the rung. - */ -export interface ComplianceWindowSelection { - shortDays: number; - longDays: number; - expectedShort: number; - expectedLong: number; -} - -/** - * v1.8.6 — pick the two compliance windows for a medication from its - * dosing cadence. - * - * Walks {@link COMPLIANCE_WINDOW_LADDER} from densest to sparsest and - * returns the first rung whose BOTH windows clear {@link MIN_STABLE_DOSES} - * realised expected doses. A daily med clears `[7, 30]` immediately; a - * weekly med fails the 7-day row (one dose) and lands on `[30, 90]`; a - * 35-day-rolling injection needs the top rung `[90, 365]`. When even the - * top rung can't clear the floor (a brand-new prescription, a very rare - * med) the top rung is returned anyway so the card still shows two honest - * percentage rows over the widest windows available. - * - * The expected count routes through {@link expectedSlotsBetween} (the - * canonical recurrence engine), so PRN / off-cadence / pre-creation days - * never inflate the denominator. - */ -export function selectComplianceWindows( - schedules: ComplianceSchedule[], - ctx: ComplianceMedicationContext, - options?: { now?: Date; intakes?: ComplianceIntakeInstant[] }, -): ComplianceWindowSelection { - const now = options?.now ?? new Date(); - const DAY_MS = 24 * 60 * 60 * 1000; - - const expectedOver = (days: number): number => - expectedSlotsBetween( - schedules, - new Date(now.getTime() - days * DAY_MS), - now, - ctx, - options?.intakes, - ).length; - - // Memoise per distinct window so a shared rung boundary (e.g. 30 / 90) - // isn't re-walked across rungs. - const cache = new Map(); - const expected = (days: number): number => { - const hit = cache.get(days); - if (hit !== undefined) return hit; - const v = expectedOver(days); - cache.set(days, v); - return v; - }; - - for (const [shortDays, longDays] of COMPLIANCE_WINDOW_LADDER) { - const expectedShort = expected(shortDays); - const expectedLong = expected(longDays); - if (expectedShort >= MIN_STABLE_DOSES && expectedLong >= MIN_STABLE_DOSES) { - return { shortDays, longDays, expectedShort, expectedLong }; - } - } - - // No rung cleared the floor — fall back to the widest rung so both rows - // still render over the most data the cadence affords. - const [shortDays, longDays] = - COMPLIANCE_WINDOW_LADDER[COMPLIANCE_WINDOW_LADDER.length - 1]; - return { - shortDays, - longDays, - expectedShort: expected(shortDays), - expectedLong: expected(longDays), - }; -} - -/** - * v1.8.6 — the compliance-display block returned alongside the existing - * `compliance7` / `compliance30` fields (which iOS + the Health Score read - * verbatim). The card always renders two percentage rows; the server picks - * the two windows from the medication's cadence and computes each row's - * rate over the chosen span. A dense med keeps `7` / `30`; a sparse med - * steps both windows up so each row covers enough expected doses to be - * meaningful. - */ -/** - * v1.13.x Fix 4 — the current-cycle descriptor. SEPARATE from the - * percentage rows: a sparse med (weekly+ injection) whose current cycle is - * not yet due must NOT render a scary red 0%. The percentage rows reflect - * only CLOSED cycles (the open forward cycle is `upcoming`, excluded from - * the denominator); this descriptor carries the open-cycle state so the - * card can render a neutral "next dose in N days" / "due today" / "overdue" - * line decoupled from the rate. - * - * States: - * - `on_track`: `now < nextDueAt`. The next dose simply hasn't come round. - * - `due`: `nextDueAt ≤ now ≤ graceUntil`. A neutral / amber call-to-action. - * - `missed`: `now > graceUntil` and the cycle has no logged intake. The - * only state that should tint red. - * - `none`: no schedule projects a next dose (PRN, paused, ended). The - * card shows no current-cycle line. - * - * `hasClosedCycles` is false for a brand-new sparse med with zero closed - * dose cycles — the card then surfaces a neutral "no closed dose cycles - * yet" state instead of a misleading 100% / 0%. - */ -export type CurrentCycleState = "on_track" | "due" | "missed" | "none"; - -export interface CurrentCycle { - state: CurrentCycleState; - /** The open cycle's due instant (ISO via JSON). Null when `state === "none"`. */ - nextDueAt: Date | null; - /** End of the due slot's grace window. Null when `state === "none"`. */ - graceUntil: Date | null; - /** - * Whether the trailing window holds at least one CLOSED dose cycle. When - * false the percentage rows are vacuous (no closed cycle to score) and the - * card should show a neutral "not enough data yet" state. - */ - hasClosedCycles: boolean; -} - -export interface ComplianceDisplay { - shortDays: number; - longDays: number; - /** Realised expected dose count over the short window. */ - expectedShort: number; - /** Realised expected dose count over the long window. */ - expectedLong: number; - /** Echo of the density floor so a client can re-derive the rung. */ - minStableDoses: number; - /** - * Compliance percentage + counts + day-streak over the short window. - * `taken` is the numerator the card renders next to the rate so two - * identical percentages stay distinguishable and trustworthy; `expected` - * is the rate denominator (taken + missed, EXCLUDING user skips) and - * `missed` the count counting against the rate. The card can render - * "26 / 30 · 87%" from `taken` / `expected`. - */ - short: { - rate: number; - taken: number; - expected: number; - missed: number; - streak: number; - }; - /** Compliance percentage + counts over the long window. */ - long: { rate: number; taken: number; expected: number; missed: number }; - /** - * v1.13.x Fix 4 — the open-cycle state, decoupled from the percentage - * rows so a between-doses sparse med never renders a scary red number. - */ - currentCycle: CurrentCycle; - /** - * v1.15.9 — the open cycle's per-dose {@link DoseStatus}, server-derived - * so the card renders its state (green take-window, overdue / heavily- - * overdue escalation) from one authority instead of re-deriving the window - * math client-side. `status` is `upcoming` when no dose is open yet and - * there is no projected next dose (PRN / paused / ended). `targetAt` is the - * open dose's target instant (echo of `currentCycle.nextDueAt`). - */ - currentDose: { status: DoseStatus; targetAt: Date | null }; -} - -/** - * v1.13.x Fix 4 — classify the medication's open (current) dose cycle. - * - * Uses the canonical engine's {@link nextOccurrenceAfter} to find the - * earliest projected dose at or after the start of the search (one ms before - * `now` so a slot landing exactly now is captured). The rolling branch of - * `nextOccurrenceAfter` floors to the start of the user's current day, so an - * overdue rolling dose still surfaces as the next slot — that lets us tell a - * not-yet-due (`on_track`) cycle apart from an overdue one (`due` / `missed`). - * - * `hasClosedCycles` reads `expectedShort` from the already-computed window - * selection: a window holding zero expected (closed) doses means the - * percentage rows are vacuous and the card should show a neutral state. - */ -/** - * v1.15.10 — half-window an intake may sit from a slot's canonical instant - * and still count as "resolving" that slot. The intake write paths snap - * `scheduledFor` to the exact engine slot instant, so an exact (or - * sub-minute) match is the common case; the ±6h tolerance also catches an - * off-time take on a dense intraday cadence (e.g. a 07:00 dose logged 09:13) - * whose snapped slot is the 07:00 row but whose raw instant we compare - * defensively. It is the same ±half-gap radius the cadence pairer uses for a - * 12h-gap (twice-daily) med, floored so a single-dose-a-day cadence still - * matches its one slot. - */ -const OPEN_DOSE_RESOLVE_RADIUS_MS = 6 * 60 * 60 * 1000; - -/** - * v1.15.10 — true when an intake event has already resolved the slot at - * `slotAt`. A resolved slot is one the user took, deliberately skipped, or - * the auto-miss cron flagged — any of which means the card must NOT keep - * surfacing that slot as the next dose. Matches on the snapped `scheduledFor` - * first (the canonical, exact path) and falls back to the take/skip instant - * within the resolve radius for rows that predate the snap or drifted. - */ -function slotIsResolved( - slotAt: Date, - intakes: ComplianceIntakeInstant[], -): boolean { - const slot = slotAt.getTime(); - for (const e of intakes) { - const isResolved = e.skipped || e.autoMissed === true || e.takenAt !== null; - if (!isResolved) continue; - const ref = (e.scheduledFor ?? e.takenAt) as Date | undefined; - if (!ref) continue; - if (Math.abs(ref.getTime() - slot) <= OPEN_DOSE_RESOLVE_RADIUS_MS) { - return true; - } - } - return false; -} - -export function buildCurrentCycle( - schedules: ComplianceSchedule[], - ctx: ComplianceMedicationContext, - now: Date, - expectedShort: number, - intakes?: ComplianceIntakeInstant[], -): CurrentCycle { - const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-cycle"); - const DAY_MS = 24 * 60 * 60 * 1000; - const allIntakes = intakes ?? []; - const lastIntakeAt = lastNonSkippedTakenAt(allIntakes) ?? ctx.lastIntakeAt; - - // The open cycle's due instant + grace, picked as the SOONEST across every - // schedule. Two paths: - // - ROLLING: the engine's `nextOccurrenceAfter` deliberately rolls an - // overdue re-anchored dose forward (it floors to the start of the - // user's current day), so it cannot surface an open cycle that is more - // than a day overdue. For the current-cycle descriptor we instead - // compute the due instant directly: `(lastNonSkippedIntake) + N` (or - // `startsOn ?? createdAt` with no intake). This is what lets `due` / - // `missed` be distinguished from `on_track`. - // - NON-ROLLING: `nextOccurrenceAfter` already surfaces the next due - // slot (RRULE / legacy / one-shot / cyclic). - let dueAt: Date | null = null; - let graceUntil: Date | null = null; - const consider = (at: Date, grace: Date): void => { - if (dueAt === null || at.getTime() < dueAt.getTime()) { - dueAt = at; - graceUntil = grace; - } - }; - - for (let i = 0; i < schedules.length; i++) { - const s = schedules[i]; - const canonical = toCanonicalSchedule(s, `compliance-cycle-${i}`); - if (canonical.scheduleType === "PRN") continue; - const n = canonical.rollingIntervalDays; - if (n !== null && n > 0) { - const anchor = - lastIntakeAt !== null - ? new Date(lastIntakeAt.getTime() + n * DAY_MS) - : (ctx.startsOn ?? ctx.createdAt); - if (ctx.endsOn && anchor.getTime() > ctx.endsOn.getTime()) continue; - const graceMs = (canonical.reminderGraceMinutes ?? 60) * 60 * 1000; - consider(anchor, new Date(anchor.getTime() + graceMs)); - continue; - } - // v1.15.10 — advance past slots the user has already resolved. The - // engine's `nextOccurrenceAfter` is purely time-anchored, so for a - // twice-daily med it surfaces today's 19:00 slot in the afternoon even - // after the user logged that dose early — the card then sticks on a - // resolved past/present slot ("träge"). Walk occurrences forward and pick - // the first one no intake has resolved (taken / skipped / auto-missed), so - // a med whose remaining slots today are all logged advances to tomorrow's - // first open slot. Bounded so a fully-logged-ahead history can't spin. - let after = new Date(now.getTime() - 1); - let picked: Occurrence | null = null; - for (let step = 0; step < 64; step++) { - const occ = nextOccurrenceAfter(canonical, after, recurrenceCtx); - if (!occ) break; - if (!slotIsResolved(occ.at, allIntakes)) { - picked = occ; - break; - } - // This slot is resolved — step strictly past it and keep looking. - after = new Date(occ.at.getTime()); - } - if (picked) consider(picked.at, picked.graceUntil); - } - - const hasClosedCycles = expectedShort > 0; - - if (dueAt === null || graceUntil === null) { - return { - state: "none", - nextDueAt: null, - graceUntil: null, - hasClosedCycles, - }; - } - - // Narrow the union-mutated locals for the comparisons below. - const due: Date = dueAt; - const grace: Date = graceUntil; - let state: CurrentCycleState; - if (now.getTime() < due.getTime()) { - state = "on_track"; - } else if (now.getTime() <= grace.getTime()) { - state = "due"; - } else { - state = "missed"; - } - - return { state, nextDueAt: due, graceUntil: grace, hasClosedCycles }; -} - -/** - * v1.8.6 — compute the two-row {@link ComplianceDisplay} block. - * - * The card always shows two percentage rows. {@link selectComplianceWindows} - * decides which windows they span from the medication's cadence, then each - * row's rate is the cadence-aware {@link calculateCompliance} over that - * window. The short row also carries the day-streak. The compliance math is - * unchanged from the legacy 7-/30-day path — only the window day-counts move. - */ -export function buildComplianceDisplay( - events: IntakeEvent[], - schedules: ComplianceSchedule[], - ctx: ComplianceMedicationContext, - options?: { now?: Date }, -): ComplianceDisplay { - const now = options?.now ?? new Date(); - // v1.13.x — thread the intake history so a ROLLING cadence's window - // selection scores the retrospective grid (each logged dose is a closed - // cycle) rather than the engine's single forward slot. Without this a - // weekly rolling med always falls through to the widest `[90, 365]` rung. - const { shortDays, longDays, expectedShort, expectedLong } = - selectComplianceWindows(schedules, ctx, { now, intakes: events }); - - const short = calculateCompliance( - events, - schedules, - shortDays, - ctx.createdAt, - { - now, - medicationContext: ctx, - }, - ); - const long = calculateCompliance(events, schedules, longDays, ctx.createdAt, { - now, - medicationContext: ctx, - }); - - // v1.13.x Fix 4 — the open-cycle descriptor, separable from the rates. - const currentCycle = buildCurrentCycle( - schedules, - ctx, - now, - expectedShort, - events, - ); - - // v1.15.9 — derive the open dose's per-dose status from one server - // authority so the card renders the take-window (green) / overdue / - // heavily-overdue escalation without re-spelling the window math. The - // cadence family comes from the SOONEST non-PRN schedule (the one the - // open cycle anchors on). `none` cycles (PRN / paused / ended) carry an - // `upcoming` status with a null target so the card stays calm. - const currentDose: { status: DoseStatus; targetAt: Date | null } = - currentCycle.nextDueAt - ? { - status: deriveDoseStatus( - currentCycle.nextDueAt, - soonestCadenceFamily(schedules), - now, - ), - targetAt: currentCycle.nextDueAt, - } - : { status: "upcoming", targetAt: null }; - - return { - shortDays, - longDays, - expectedShort, - expectedLong, - minStableDoses: MIN_STABLE_DOSES, - // `expected` is the rate denominator (taken + missed) so the card can - // render the trustworthy "taken / expected · rate%" triple; user skips - // are excluded from it by construction (they never enter `missed`). - short: { - rate: short.rate, - taken: short.taken, - expected: short.taken + short.missed, - missed: short.missed, - streak: short.streak, - }, - long: { - rate: long.rate, - taken: long.taken, - expected: long.taken + long.missed, - missed: long.missed, - }, - currentCycle, - currentDose, - }; -} - -/** - * v1.15.9 — the {@link DoseCadenceFamily} of the soonest non-PRN schedule, - * used to pick the window model for the open dose's status. A multi-schedule - * med whose cycle anchors on its earliest window keeps that window's cadence; - * when every schedule is PRN (no projected dose) the family is irrelevant - * because the caller only reads `currentDose` when a cycle is open. - */ -function soonestCadenceFamily( - schedules: ComplianceSchedule[], -): DoseCadenceFamily { - for (const s of schedules) { - if (s.scheduleType === "PRN") continue; - return doseCadenceFamily(s); - } - return "daily"; -} - -/** - * v1.15.18 — the unified compliance tally over the dose-history ledger. - * - * THE single source of the medication compliance %. It builds the - * cadence-aware `SlotBand[]` per schedule (the shared band minter — every - * cadence: daily, fixed-weekday, rolling-retrospective, one-shot, cyclic, - * PRN), reconstructs the ONE dose-history ledger - * (`reconstructDoseHistory`), and tallies it so the percentage and the - * history view are mathematically incapable of contradicting each other - * (audit CRITICAL-2). It replaces the ±12h `pairDoses` proximity matcher - * that `calculateCompliance` used for the engine-routed path. - * - * The tally follows the adherence literature's TAKING-vs-TIMING split: - * - numerator (taken) = `taken_on_time` + `taken_late` — a late dose is - * still a taken dose; "late" is NOT collapsed into "missed"; - * - denominator = taken + `missed`; - * - EXCLUDED from the denominator: `skipped` (deliberate user decision), - * `ad_hoc` (off-schedule top-up — no defensible slot), `upcoming` (the - * window hasn't opened), and ENTIRE PRN groups (`hasExpectedSlots:false` - * — PRN has no defensible denominator per the literature); - * - the rate is capped at 100% (extra doses never inflate it). - * - * The on-time / late split is surfaced separately so a caller can show both - * the TAKING rate (the headline) and the TIMING quality. - * - * Pure / synchronous: the bands are minted from pre-fetched schedules + - * intake instants; no DB access. - */ -export interface LedgerComplianceTally { - /** Doses taken (on-time + late). The TAKING-adherence numerator. */ - taken: number; - /** Of `taken`, the count inside the on-time band. */ - takenOnTime: number; - /** Of `taken`, the count in the late tail (still counts as taken). */ - takenLate: number; - /** Expected doses never acted on past their miss cutoff. */ - missed: number; - /** Deliberate user skips — excluded from the denominator. */ - skipped: number; - /** Off-schedule intakes (PRN groups + ad-hoc rows) — excluded. */ - adHoc: number; - /** taken + missed (the rate denominator). */ - denominator: number; - /** round(100 · taken / denominator), capped at 100. 100 on empty. */ - rate: number; -} - -export function tallyComplianceFromLedger( - events: IntakeEvent[], - schedules: ComplianceSchedule[], - ctx: ComplianceMedicationContext, - from: Date, - to: Date, - now: Date, - windowConfig?: DoseWindowConfig, -): LedgerComplianceTally { - const rows = buildComplianceLedgerRows( - events, - schedules, - ctx, - from, - to, - now, - windowConfig, - ); - return tallyLedgerRows(rows); -} - -/** - * Mint the cadence-aware bands over `[from, to]` and reconstruct the ONE - * unified dose-history ledger for them. This is the expansion pass behind - * {@link tallyComplianceFromLedger}, extracted so a caller that needs - * several trailing sub-windows (7-day / 30-day / display / heatmap, all - * ending at `now`) can mint the bands ONCE over the widest window and tally - * each sub-window from the same rows via {@link tallyLedgerRows} instead of - * re-expanding per window. - * - * Pure / synchronous: bands come from pre-fetched schedules + intake - * instants; no DB access. - */ -export function buildComplianceLedgerRows( - events: IntakeEvent[], - schedules: ComplianceSchedule[], - ctx: ComplianceMedicationContext, - from: Date, - to: Date, - now: Date, - windowConfig?: DoseWindowConfig, -): DoseHistoryRow[] { - const medication: BandMinterMedication = { - id: "compliance-tally", - startsOn: ctx.startsOn, - endsOn: ctx.endsOn, - oneShot: ctx.oneShot, - createdAt: ctx.createdAt, - }; - const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-tally"); - const canonicalSchedules = schedules.map((s, i) => { - const canonical = toCanonicalSchedule(s, `compliance-tally-${i}`); - // A legacy daily schedule carries only `windowStart` (no `timesOfDay`, - // no rrule, no rolling, no `daysOfWeek`). The engine's `expandLegacy` - // reads that as "every day at windowStart", but the band minter's - // cadence-detection gate needs an explicit time signal — surface - // `windowStart` as the single time-of-day so the daily band is minted. - if ( - canonical.timesOfDay.length === 0 && - canonical.rrule === null && - canonical.rollingIntervalDays === null && - canonical.scheduleType !== "PRN" && - !ctx.oneShot - ) { - return { ...canonical, timesOfDay: [canonical.windowStart] }; - } - return canonical; - }); - // Rolling cadences anchor their retrospective grid AT each logged intake; - // the bands need every non-skipped take in (or before) the window. Reuse - // the same instant-extraction the legacy rolling path uses so the - // numerator and denominator are built from one expansion. - const intakeInstants = intakeInstantsAtOrBefore( - events.map((e) => ({ takenAt: e.takenAt, skipped: e.skipped })), - to, - ); - - // v1.16.3 — era-aware mint: archived eras band with THEIR schedules. - const groups = buildBandsForSchedulesWithEras({ - medication, - schedules: canonicalSchedules, - revisions: ctx.scheduleRevisions ?? [], - ctx: recurrenceCtx, - userTz: ctx.timeZone, - range: { from, to }, - now, - windowConfig, - intakeInstants, - }); - - // The ledger reads `scheduledFor` + `takenAt` + skip/auto-miss flags. The - // bands already partition the slot space, so the union of every - // non-PRN schedule's bands is fed to ONE reconstruction — `reconstruct - // DoseHistory` claims each slot by at most one intake, so pooling the - // (already correctly-minted) bands is safe. PRN groups (no expected - // slots) contribute no bands; their intakes surface as ad-hoc and are - // excluded from the denominator, exactly as the literature requires. - const bands: SlotBand[] = []; - for (const g of groups) { - if (g.hasExpectedSlots) bands.push(...g.bands); - } - - const intakes: HistoryIntake[] = events - .filter((e) => e.scheduledFor >= from && e.scheduledFor <= to) - .map((e) => ({ - scheduledFor: e.scheduledFor, - takenAt: e.takenAt, - skipped: e.skipped, - autoMissed: e.autoMissed ?? false, - // v1.15.20 — a pinned take binds by anchor and tallies as taken-late - // (slot served, no on-time gain) instead of ad_hoc + missed. - pinned: e.attributionSource === "USER_PIN", - })); - - const rows = reconstructDoseHistory(bands, intakes, now); - - // v1.25 H-MED1 — drop expected dose slots whose anchor falls inside a - // pause interval. While a medication is paused no dose is expected, so a - // slot minted across the paused window must never count as "missed" (the - // denominator-inflating status). Only `slot` rows are dropped, and only - // those whose status feeds the tally (taken / missed) — skip / ad-hoc / - // upcoming rows are already excluded from the denominator, so dropping - // them here would be a redundant double-exclusion. An open era - // (`resumedAt === null`) runs to `now`. - const pauseEras = ctx.pauseEras; - if (pauseEras && pauseEras.length > 0) { - const isInPause = (at: Date): boolean => { - const t = at.getTime(); - for (const era of pauseEras) { - const start = era.pausedAt.getTime(); - const end = (era.resumedAt ?? now).getTime(); - if (t >= start && t < end) return true; - } - return false; - }; - return rows.filter((row) => { - if (row.kind !== "slot") return true; - if ( - row.status !== "taken_on_time" && - row.status !== "taken_late" && - row.status !== "missed" - ) { - return true; - } - return !isInPause(row.at); - }); - } - - return rows; -} - -/** - * Tally pre-built ledger rows into the {@link LedgerComplianceTally} - * counters. When `window` is supplied only rows whose instant (`row.at`) - * falls inside `[window.from, window.to]` (inclusive) are counted — that is - * how a sub-window tally is carved out of a wider single-pass ledger. The - * window-less call tallies every row, byte-identical to the historical - * {@link tallyComplianceFromLedger} behaviour. - */ -export function tallyLedgerRows( - rows: DoseHistoryRow[], - window?: { from: Date; to: Date }, -): LedgerComplianceTally { - let takenOnTime = 0; - let takenLate = 0; - let missed = 0; - let skipped = 0; - let adHoc = 0; - for (const row of rows) { - if (window) { - const t = row.at.getTime(); - if (t < window.from.getTime() || t > window.to.getTime()) continue; - } - switch (row.status) { - case "taken_on_time": - takenOnTime++; - break; - case "taken_late": - takenLate++; - break; - case "missed": - missed++; - break; - case "skipped": - skipped++; - break; - case "ad_hoc": - adHoc++; - break; - // `upcoming` slots are future / still-takeable → excluded from every - // counter so a partial head-of-window day never pollutes the rate. - } - } - - const taken = takenOnTime + takenLate; - const denominator = taken + missed; - const rate = - denominator > 0 - ? Math.min(100, Math.round((taken / denominator) * 100)) - : 100; - - return { - taken, - takenOnTime, - takenLate, - missed, - skipped, - adHoc, - denominator, - rate, - }; -} - -/** - * Calculate compliance for a medication over a given period. - * - * Honours `daysOfWeek` (e.g. `"1"` for Mondays only) and - * `intervalWeeks` (bi-weekly, tri-weekly, …) by delegating to - * `buildCadenceTimeline`. The denominator is the number of dose slots - * the schedule actually emits inside the window — not - * `schedules.length * days`. A user on a weekly Monday-only schedule - * who takes every Monday for 30 days reports 100% adherence (4 of 4 - * Mondays) instead of the pre-v1.5.0 ~13% (4 of 30). - * - * Skipped doses are excluded from the denominator (deliberate user - * decision, not a compliance failure). When the window contains no - * expected doses (paused med, brand-new prescription, schedule that - * never fires in the window) the helper returns `rate: 100` so the - * empty state doesn't trip a "0% compliance" alarm downstream. - * - * Streak counts consecutive days, ending at `now`, where every - * expected dose for the day was taken (or skipped). Days with no - * expected dose (out-of-cadence weekdays, off-weeks on a bi-weekly - * schedule) advance the streak — the user gets credit for not - * breaking on non-scheduled days. - * - * @param events Recorded intake events from - * `MedicationIntakeEvent`. Only `scheduledFor`, - * `takenAt`, `skipped` are read. - * @param schedules Schedule rows for the medication. `daysOfWeek` - * is read when present; missing field is treated - * as daily. - * @param days Rolling-window size in days (typically 7 / 30 - * / 90). - * @param medicationCreatedAt - * When provided, days before the medication - * existed are excluded so they don't count as - * "missed". - * @param options.now Override for the rolling-window anchor. The - * fast-path Health-Score helper passes a fixed - * `now` so cached medication-compliance rates - * agree with the score's other pillars; default - * is `new Date()` (current wall-clock instant). - * v1.5.0 — added so the cadence-aware adapter - * can be driven deterministically from a caller - * that already pinned its own `now`. - */ -export function calculateCompliance( - events: IntakeEvent[], - schedules: ComplianceSchedule[], - days: number, - medicationCreatedAt?: Date, - options?: { now?: Date; medicationContext?: ComplianceMedicationContext }, -): ComplianceResult { - if (schedules.length === 0) { - return { - totalExpected: 0, - taken: 0, - skipped: 0, - missed: 0, - rate: 100, - streak: 0, - }; - } - - const now = options?.now ?? new Date(); - const DAY_MS = 24 * 60 * 60 * 1000; - const periodStart = new Date(now.getTime() - days * DAY_MS); - const effectiveStart = - medicationCreatedAt && medicationCreatedAt > periodStart - ? medicationCreatedAt - : periodStart; - const effectiveDays = Math.max( - 1, - Math.ceil((now.getTime() - effectiveStart.getTime()) / DAY_MS), - ); - - // v1.15.18 — when a medication context is supplied, the count fields - // (taken / skipped / missed / rate) come from the UNIFIED dose-history - // ledger tally, NOT the ±12h `pairDoses` proximity matcher. This is the - // keystone unification: the percentage is a tally over the exact same - // ledger the history view renders, so the two can never contradict (a - // dose can't read "taken late" in the % while the ledger calls it - // "ad-hoc"). The numerator is on-time + late takes (a late dose still - // counts as taken); user skips + ad-hoc top-ups + PRN groups are excluded - // from the denominator. The streak still walks the cadence timeline below - // (its day-grain "every dose taken or skipped" rule is unchanged). - // - // Context-less callers (pure-math fixtures, pre-v1.7 surfaces) keep the - // legacy timeline tally byte-stable — they have no engine context to mint - // bands from. - const ledgerCtx = options?.medicationContext; - let ledgerCounts: { - taken: number; - skipped: number; - missed: number; - rate: number; - } | null = null; - if (ledgerCtx) { - const tally = tallyComplianceFromLedger( - events, - schedules, - ledgerCtx, - effectiveStart, - now, - now, - ); - ledgerCounts = { - taken: tally.taken, - skipped: tally.skipped, - missed: tally.missed, - rate: tally.rate, - }; - } - - // Normalise the schedule shape so legacy callers that pass only - // `{ windowStart, windowEnd }` still produce a usable `daysOfWeek` - // field (treated as daily by the cadence parser). - const normalisedSchedules: ScheduleLike[] = schedules.map((s, i) => ({ - id: `compliance-${i}`, - windowStart: s.windowStart, - windowEnd: s.windowEnd, - daysOfWeek: s.daysOfWeek ?? null, - // v1.7.0 SB-SCHED-2 — thread the canonical-engine fields so the - // cadence expander can delegate to `occurrencesBetween` when a - // medication context is supplied. Undefined fields collapse to the - // legacy weekday path inside `expandScheduleSlots`. - rrule: s.rrule ?? null, - rollingIntervalDays: s.rollingIntervalDays ?? null, - timesOfDay: s.timesOfDay, - reminderGraceMinutes: s.reminderGraceMinutes ?? null, - scheduleType: s.scheduleType ?? null, - cyclicOnWeeks: s.cyclicOnWeeks ?? null, - cyclicOffWeeks: s.cyclicOffWeeks ?? null, - })); - - // v1.7.0 SB-SCHED-2 — build the engine context once per medication. - // When the caller supplies it, the timeline routes through the - // canonical engine (RRULE / rolling / one-shot / PRN / cyclic); - // otherwise the legacy weekday walker stays in force. - const ctx = options?.medicationContext; - const engineCtx: CadenceEngineContext | undefined = ctx - ? { - startsOn: ctx.startsOn, - endsOn: ctx.endsOn, - oneShot: ctx.oneShot, - createdAt: ctx.createdAt, - lastIntakeAt: ctx.lastIntakeAt, - timeZone: ctx.timeZone, - scheduleRevisions: ctx.scheduleRevisions, - } - : undefined; - - // Match events against the same slot grid the cadence chart uses. - // The chart's pairing radius is ±12 h so a late-by-six-hours dose - // still attaches to the right slot instead of double-counting as - // "missed + extra". - const normalisedEvents: IntakeEventLike[] = events - .filter((e) => e.scheduledFor >= effectiveStart && e.scheduledFor <= now) - .map((e) => ({ - scheduledFor: e.scheduledFor, - takenAt: e.takenAt, - skipped: e.skipped, - // v1.15.9 — carry the forgotten-dose flag into the timeline so an - // auto-missed slot pairs to a `missed` status (counts against the - // rate) rather than a neutral `skipped`. - autoMissed: e.autoMissed ?? false, - })); - - // v1.13.x — ROLLING retrospective expansion. A rolling cadence - // (`rollingIntervalDays`, the canonical GLP-1 "every N days" shape) is - // forward-only in the engine: `expandRolling` emits at most the single - // immediately-next slot, so a historical compliance window saw either - // zero expected slots (vacuous 100%) or one overdue slot (hard 0%) — - // never the true multi-dose adherence over the trailing window. When a - // medication context is threaded AND any schedule is rolling, route the - // rolling schedules through the retrospective builder so each logged dose - // is one satisfied expected slot (plus synthesized misses for skipped - // whole cycles + a past-due forward slot). Non-rolling schedules keep the - // forward-only engine path; both share `buildCadenceTimeline` so the - // numerator and denominator agree (the v1.7.3 B15 convergence rule). - const hasRolling = schedules.some( - (s) => s.rollingIntervalDays != null && s.rollingIntervalDays > 0, - ); - const retro = - engineCtx && hasRolling - ? { intakeInstants: rollingIntakeInstants(events, now), now } - : undefined; - - const timeline = buildCadenceTimeline( - normalisedSchedules, - normalisedEvents, - now, - effectiveDays, - medicationCreatedAt ?? effectiveStart, - engineCtx?.timeZone, - engineCtx, - retro, - ); - - // v1.15.18 — the count fields come from the unified ledger tally when a - // medication context was supplied (the keystone unification), and from the - // legacy timeline tally otherwise (context-less pure-math callers). The - // streak below always walks the timeline — its day-grain rule is orthogonal - // to the per-dose attribution and stays byte-stable for every caller. - let taken: number; - let skipped: number; - let missed: number; - let rate: number; - if (ledgerCounts) { - ({ taken, skipped, missed, rate } = ledgerCounts); - } else { - taken = 0; - skipped = 0; - missed = 0; - for (const slot of timeline) { - if (slot.status === "taken") taken++; - else if (slot.status === "skipped") skipped++; - else if (slot.status === "missed") missed++; - // `upcoming` slots (future window) are excluded from every counter - // so a partial day at the head of the window doesn't pollute the rate. - } - // Skipped doses are excluded from the denominator — they represent a - // deliberate user decision rather than a missed dose. - const denom = taken + missed; - rate = denom > 0 ? Math.min(100, Math.round((taken / denom) * 100)) : 100; - } - - const totalExpected = taken + skipped + missed; - - // Streak: consecutive days, ending today, where every expected dose - // was taken or skipped. Days with no expected dose advance the - // streak — out-of-cadence days are not failures. Delegated to the - // shared `streaksFromTimeline` so the analytics streak and the - // detail-page chip streak agree on every dose AND so the day keys are - // computed in the USER's IANA timezone, not the host's. The prior - // host-tz `getFullYear/getMonth/getDate` walk drifted off by a day - // whenever the server clock's zone differed from the user's — the - // timeline `slot.day` is already minted in the user zone, so the - // walk has to match it. The window is `effectiveDays` ending at - // `now`, which starts no earlier than `effectiveStart` (= the later - // of the period start and the medication's creation) — so days - // before the medication existed are never iterated, preserving the - // old `cursor <= medicationCreatedAt` break by construction. - const { current: streak } = streaksFromTimeline( - timeline, - now, - effectiveDays, - engineCtx?.timeZone, - ); - - return { totalExpected, taken, skipped, missed, rate, streak }; -} - -/** - * Widest trailing window the single-pass compliance ledger has to cover: - * the top rung of {@link COMPLIANCE_WINDOW_LADDER} (365 days). Every - * sub-window the per-medication compliance endpoint serves (7 / 30 / - * cadence-scaled display rows / 90-day heatmap) is a suffix of it. - */ -export const COMPLIANCE_LEDGER_WINDOW_DAYS = 365; - -/** - * The per-medication compliance payload computed from ONE shared expansion - * pass. `ledgerRows` is the unified dose-history ledger over - * `[ledgerFrom, now]`; the caller carves the 90-day heatmap out of it by - * filtering on `row.at`. - */ -export interface MedicationComplianceBundle { - compliance7: ComplianceResult; - compliance30: ComplianceResult; - complianceDisplay: ComplianceDisplay; - /** Unified ledger rows over `[ledgerFrom, now]`, chronological. */ - ledgerRows: DoseHistoryRow[]; - /** Lower bound of the mint window (clamped to the medication's creation). */ - ledgerFrom: Date; -} - -/** - * Build every block of the per-medication compliance response from a single - * band-expansion pass. - * - * The historical composition called {@link calculateCompliance} four times - * (7 / 30 / short / long), {@link selectComplianceWindows} (up to four more - * occurrence expansions for the rung probes) and a separate 90-day heatmap - * mint — five-plus full band expansions per request. This builder instead: - * - * 1. mints the bands + reconstructs the ledger ONCE over - * `[max(createdAt, now − 365 d), now]` (every served window is a - * suffix of that range and ends at `now`, so a sub-window tally is a - * filter over `row.at`, not a re-expansion); - * 2. builds ONE cadence timeline over the same range for the per-window - * streaks ({@link streaksFromTimeline} bounds its day-walk by the - * requested window, so the wider timeline serves every window); - * 3. walks the {@link COMPLIANCE_WINDOW_LADDER} on the ledger's slot-row - * counts (one band per engine occurrence, so the counts match the - * historical `expectedSlotsBetween` probes). - * - * The returned blocks carry the exact public shapes the route has always - * served — only the number of expansion passes changed. - */ -export function buildMedicationComplianceBundle( - events: IntakeEvent[], - schedules: ComplianceSchedule[], - ctx: ComplianceMedicationContext, - now: Date, -): MedicationComplianceBundle { - const ledgerPeriodStart = new Date( - now.getTime() - COMPLIANCE_LEDGER_WINDOW_DAYS * ONE_DAY_MS, - ); - const ledgerFrom = - ctx.createdAt.getTime() > ledgerPeriodStart.getTime() - ? ctx.createdAt - : ledgerPeriodStart; - - const hasSchedules = schedules.length > 0; - const ledgerRows = hasSchedules - ? buildComplianceLedgerRows(events, schedules, ctx, ledgerFrom, now, now) - : []; - - // ONE cadence timeline over the full ledger window. Each per-window - // streak below walks only its own trailing `effectiveDays`, so sharing - // the wide timeline reproduces the per-window builds. - const fullDays = Math.max( - 1, - Math.ceil((now.getTime() - ledgerFrom.getTime()) / ONE_DAY_MS), - ); - const timeline = hasSchedules - ? buildTimelineForWindow(events, schedules, ctx, now, ledgerFrom, fullDays) - : []; - - const resultForWindow = (days: number): ComplianceResult => { - if (!hasSchedules) { - // Mirrors `calculateCompliance`'s empty-schedule short-circuit. - return { - totalExpected: 0, - taken: 0, - skipped: 0, - missed: 0, - rate: 100, - streak: 0, - }; - } - const periodStart = new Date(now.getTime() - days * ONE_DAY_MS); - const effectiveStart = - ctx.createdAt.getTime() > periodStart.getTime() - ? ctx.createdAt - : periodStart; - const effectiveDays = Math.max( - 1, - Math.ceil((now.getTime() - effectiveStart.getTime()) / ONE_DAY_MS), - ); - const tally = tallyLedgerRows(ledgerRows, { - from: effectiveStart, - to: now, - }); - const { current: streak } = streaksFromTimeline( - timeline, - now, - effectiveDays, - ctx.timeZone, - ); - return { - totalExpected: tally.taken + tally.skipped + tally.missed, - taken: tally.taken, - skipped: tally.skipped, - missed: tally.missed, - rate: tally.rate, - streak, - }; - }; - - // Window-ladder selection from the ledger's slot rows. A slot row is one - // minted band, and the minter emits one band per engine occurrence, so - // counting slot rows over a trailing window equals the historical - // `expectedSlotsBetween(...).length` probe for that window. - const expectedCache = new Map(); - const expectedOver = (days: number): number => { - const hit = expectedCache.get(days); - if (hit !== undefined) return hit; - const from = Math.max( - ctx.createdAt.getTime(), - now.getTime() - days * ONE_DAY_MS, - ); - let count = 0; - for (const row of ledgerRows) { - if (row.kind !== "slot") continue; - const t = row.at.getTime(); - if (t >= from && t <= now.getTime()) count++; - } - expectedCache.set(days, count); - return count; - }; - - let selection: ComplianceWindowSelection | null = null; - for (const [shortDays, longDays] of COMPLIANCE_WINDOW_LADDER) { - const expectedShort = expectedOver(shortDays); - const expectedLong = expectedOver(longDays); - if (expectedShort >= MIN_STABLE_DOSES && expectedLong >= MIN_STABLE_DOSES) { - selection = { shortDays, longDays, expectedShort, expectedLong }; - break; - } - } - if (!selection) { - // No rung cleared the floor — fall back to the widest rung, exactly - // like `selectComplianceWindows`. - const [shortDays, longDays] = - COMPLIANCE_WINDOW_LADDER[COMPLIANCE_WINDOW_LADDER.length - 1]; - selection = { - shortDays, - longDays, - expectedShort: expectedOver(shortDays), - expectedLong: expectedOver(longDays), - }; - } - - const compliance7 = resultForWindow(7); - const compliance30 = resultForWindow(30); - const short = resultForWindow(selection.shortDays); - const long = resultForWindow(selection.longDays); - - const currentCycle = buildCurrentCycle( - schedules, - ctx, - now, - selection.expectedShort, - events, - ); - const currentDose: { status: DoseStatus; targetAt: Date | null } = - currentCycle.nextDueAt - ? { - status: deriveDoseStatus( - currentCycle.nextDueAt, - soonestCadenceFamily(schedules), - now, - ), - targetAt: currentCycle.nextDueAt, - } - : { status: "upcoming", targetAt: null }; - - const complianceDisplay: ComplianceDisplay = { - shortDays: selection.shortDays, - longDays: selection.longDays, - expectedShort: selection.expectedShort, - expectedLong: selection.expectedLong, - minStableDoses: MIN_STABLE_DOSES, - short: { - rate: short.rate, - taken: short.taken, - expected: short.taken + short.missed, - missed: short.missed, - streak: short.streak, - }, - long: { - rate: long.rate, - taken: long.taken, - expected: long.taken + long.missed, - missed: long.missed, - }, - currentCycle, - currentDose, - }; - - return { - compliance7, - compliance30, - complianceDisplay, - ledgerRows, - ledgerFrom, - }; -} - -/** - * One day of the cadence-aware compliance series: the day's compliance % - * over the doses the schedule actually expected that day, plus the raw - * taken / missed counts behind it. - */ -export interface DailyComplianceRate { - /** The day's anchor instant (the first expected slot of the day). */ - date: Date; - /** round(100 · taken / (taken + missed)), capped at 100. */ - rate: number; - /** Doses taken (on-time + late) on the day. */ - taken: number; - /** Expected doses never acted on past their miss cutoff, on the day. */ - missed: number; -} - -/** - * v1.18.0 — collapse unified dose-history ledger rows into a cadence-aware - * per-day compliance series. - * - * Only days the schedule's cadence actually expected a dose produce a point: - * the series is grouped over the ledger's scheduled `slot` rows, so an - * off-cadence weekday (a weekly Monday-only med on a Tuesday) or an off-week - * (the off-week of a bi-weekly schedule) emits no point at all and therefore - * can never be read as a 0% "miss". Each day's rate uses the SAME - * taken / (taken + missed) tally — and the SAME on-time-plus-late numerator, - * skip / ad-hoc / upcoming exclusions — that {@link tallyLedgerRows} applies - * to `compliance7` / `compliance30`, so the per-day series and the window - * rates are computed from one ledger and cannot contradict each other. - * - * `ad_hoc` rows (off-schedule top-ups, no defensible slot) and `upcoming` - * rows (the window hasn't opened) are dropped before grouping, exactly as the - * window tally excludes them. A day whose only ledger rows are skips yields no - * point (zero denominator) rather than a misleading 0%. - * - * Days are bucketed in the USER's timezone (`tz`). Passing the caller's real - * zone keeps an evening dose on the local day it was actually due: a fixed - * Berlin key filed a `20:00 America/Los_Angeles` dose (04:00 UTC next day) - * into the following Berlin day, splitting one local day's two doses across - * two buckets and skewing every per-day rate for non-Berlin users. - */ -export function dailyComplianceRatesFromLedger( - ledgerRows: DoseHistoryRow[], - tz: string, -): DailyComplianceRate[] { - const byDay = new Map< - string, - { taken: number; missed: number; date: Date } - >(); - - for (const row of ledgerRows) { - // Only scheduled slots have a defensible denominator; ad-hoc top-ups are - // excluded from the rate exactly as `tallyLedgerRows` excludes them. - if (row.kind !== "slot") continue; - const isTaken = - row.status === "taken_on_time" || row.status === "taken_late"; - const isMissed = row.status === "missed"; - // `skipped` (deliberate) and `upcoming` (window not open) advance neither - // counter — they never enter the day's denominator. - if (!isTaken && !isMissed) continue; - - const dayKey = userDayKey(row.at, tz); - const bucket = byDay.get(dayKey) ?? { taken: 0, missed: 0, date: row.at }; - if (isTaken) bucket.taken += 1; - else bucket.missed += 1; - // Keep the earliest slot instant of the day as the point's anchor. - if (row.at.getTime() < bucket.date.getTime()) bucket.date = row.at; - byDay.set(dayKey, bucket); - } - - return Array.from(byDay.values()) - .sort((a, b) => a.date.getTime() - b.date.getTime()) - .map((day) => { - const denom = day.taken + day.missed; - return { - date: day.date, - rate: - denom > 0 - ? Math.min(100, Math.round((day.taken / denom) * 100)) - : 100, - taken: day.taken, - missed: day.missed, - }; - }); -} - -/** - * The cadence-timeline construction from {@link calculateCompliance}, - * extracted so {@link buildMedicationComplianceBundle} can build it once - * over the full ledger window instead of once per served sub-window. Same - * normalisation, same engine context, same rolling-retrospective routing. - */ -function buildTimelineForWindow( - events: IntakeEvent[], - schedules: ComplianceSchedule[], - ctx: ComplianceMedicationContext, - now: Date, - effectiveStart: Date, - effectiveDays: number, -): ReturnType { - const normalisedSchedules: ScheduleLike[] = schedules.map((s, i) => ({ - id: `compliance-${i}`, - windowStart: s.windowStart, - windowEnd: s.windowEnd, - daysOfWeek: s.daysOfWeek ?? null, - rrule: s.rrule ?? null, - rollingIntervalDays: s.rollingIntervalDays ?? null, - timesOfDay: s.timesOfDay, - reminderGraceMinutes: s.reminderGraceMinutes ?? null, - scheduleType: s.scheduleType ?? null, - cyclicOnWeeks: s.cyclicOnWeeks ?? null, - cyclicOffWeeks: s.cyclicOffWeeks ?? null, - })); - - const engineCtx: CadenceEngineContext = { - startsOn: ctx.startsOn, - endsOn: ctx.endsOn, - oneShot: ctx.oneShot, - createdAt: ctx.createdAt, - lastIntakeAt: ctx.lastIntakeAt, - timeZone: ctx.timeZone, - scheduleRevisions: ctx.scheduleRevisions, - }; - - const normalisedEvents: IntakeEventLike[] = events - .filter((e) => e.scheduledFor >= effectiveStart && e.scheduledFor <= now) - .map((e) => ({ - scheduledFor: e.scheduledFor, - takenAt: e.takenAt, - skipped: e.skipped, - autoMissed: e.autoMissed ?? false, - })); - - const hasRolling = schedules.some( - (s) => s.rollingIntervalDays != null && s.rollingIntervalDays > 0, - ); - const retro = hasRolling - ? { intakeInstants: rollingIntakeInstants(events, now), now } - : undefined; - - return buildCadenceTimeline( - normalisedSchedules, - normalisedEvents, - now, - effectiveDays, - ctx.createdAt, - ctx.timeZone, - engineCtx, - retro, - ); -} +export * from "./compliance/parsing"; +export * from "./compliance/types"; +export * from "./compliance/dose-status"; +export * from "./compliance/occurrences"; +export * from "./compliance/windows"; +export * from "./compliance/cycle"; +export * from "./compliance/ledger"; +export * from "./compliance/display"; diff --git a/src/lib/analytics/__tests__/as-needed-compliance-exclusion.test.ts b/src/lib/analytics/compliance/__tests__/as-needed-compliance-exclusion.test.ts similarity index 100% rename from src/lib/analytics/__tests__/as-needed-compliance-exclusion.test.ts rename to src/lib/analytics/compliance/__tests__/as-needed-compliance-exclusion.test.ts diff --git a/src/lib/analytics/__tests__/compliance-bundle.test.ts b/src/lib/analytics/compliance/__tests__/compliance-bundle.test.ts similarity index 99% rename from src/lib/analytics/__tests__/compliance-bundle.test.ts rename to src/lib/analytics/compliance/__tests__/compliance-bundle.test.ts index 3a042a24d..df88d73e3 100644 --- a/src/lib/analytics/__tests__/compliance-bundle.test.ts +++ b/src/lib/analytics/compliance/__tests__/compliance-bundle.test.ts @@ -22,7 +22,7 @@ import { tallyComplianceFromLedger, tallyLedgerRows, type ComplianceSchedule, -} from "../compliance"; +} from "@/lib/analytics/compliance"; import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history"; import { zonedWallClockToUtc } from "@/lib/tz/wall-clock"; diff --git a/src/lib/analytics/__tests__/compliance-pause-era.test.ts b/src/lib/analytics/compliance/__tests__/compliance-pause-era.test.ts similarity index 99% rename from src/lib/analytics/__tests__/compliance-pause-era.test.ts rename to src/lib/analytics/compliance/__tests__/compliance-pause-era.test.ts index a1a81f30c..6cc5cff8c 100644 --- a/src/lib/analytics/__tests__/compliance-pause-era.test.ts +++ b/src/lib/analytics/compliance/__tests__/compliance-pause-era.test.ts @@ -22,7 +22,7 @@ import { tallyComplianceFromLedger, type ComplianceSchedule, type MedicationPauseEraLike, -} from "../compliance"; +} from "@/lib/analytics/compliance"; import type { ScheduleRevisionLike } from "@/lib/medications/scheduling/schedule-eras"; const TZ = "UTC"; diff --git a/src/lib/analytics/__tests__/compliance.test.ts b/src/lib/analytics/compliance/__tests__/compliance.test.ts similarity index 99% rename from src/lib/analytics/__tests__/compliance.test.ts rename to src/lib/analytics/compliance/__tests__/compliance.test.ts index fa844f670..f14be4e05 100644 --- a/src/lib/analytics/__tests__/compliance.test.ts +++ b/src/lib/analytics/compliance/__tests__/compliance.test.ts @@ -28,8 +28,8 @@ import { tallyComplianceFromLedger, type ComplianceMedicationContext, type ComplianceSchedule, -} from "../compliance"; -import type { IntakeTimingClass } from "../compliance"; +} from "@/lib/analytics/compliance"; +import type { IntakeTimingClass } from "@/lib/analytics/compliance"; import { buildCadenceTimeline } from "@/lib/medications/scheduling/cadence"; import { getUserTodayBounds } from "@/lib/tz/local-day"; import { userDayKey } from "@/lib/tz/format"; diff --git a/src/lib/analytics/__tests__/schedule-compliance-select.test.ts b/src/lib/analytics/compliance/__tests__/schedule-compliance-select.test.ts similarity index 94% rename from src/lib/analytics/__tests__/schedule-compliance-select.test.ts rename to src/lib/analytics/compliance/__tests__/schedule-compliance-select.test.ts index 40f603ba2..d6fcbd66d 100644 --- a/src/lib/analytics/__tests__/schedule-compliance-select.test.ts +++ b/src/lib/analytics/compliance/__tests__/schedule-compliance-select.test.ts @@ -10,7 +10,7 @@ */ import { describe, expect, it } from "vitest"; -import { SCHEDULE_COMPLIANCE_SELECT } from "../compliance"; +import { SCHEDULE_COMPLIANCE_SELECT } from "@/lib/analytics/compliance"; describe("SCHEDULE_COMPLIANCE_SELECT", () => { it("selects every schedule field the compliance engine consumes", () => { diff --git a/src/lib/analytics/compliance/adapters.ts b/src/lib/analytics/compliance/adapters.ts new file mode 100644 index 000000000..3de29fb32 --- /dev/null +++ b/src/lib/analytics/compliance/adapters.ts @@ -0,0 +1,131 @@ +// Extracted from the former single-file `compliance.ts`. See `../compliance.ts` +// (the barrel) for the module map. Pure move — no logic changes. + +import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers"; +import { + expandRollingRetrospective, + occurrencesBetween, + type CanonicalSchedule, + type Occurrence, + type RecurrenceContext, +} from "@/lib/medications/scheduling/recurrence"; +import type { + ComplianceIntakeInstant, + ComplianceMedicationContext, + ComplianceSchedule, +} from "./types"; + +/** + * v1.13.x — the non-skipped `takenAt` instants at or before `now`, ascending. + * These anchor the retrospective rolling expected-dose grid (each logged dose + * is one satisfied expected slot). The full history is passed — not just the + * compliance window — so the gap-walk between consecutive intakes (which + * synthesizes skipped-cycle misses) and the forward next-due anchor stay + * correct across the window boundary; `expandRollingRetrospective` clamps the + * emitted slots to its own `[from, to]`. + */ +export function rollingIntakeInstants( + events: { takenAt: Date | null; skipped: boolean }[], + now: Date, +): Date[] { + return events + .filter( + (e): e is { takenAt: Date; skipped: boolean } => + !e.skipped && + e.takenAt !== null && + e.takenAt.getTime() <= now.getTime(), + ) + .map((e) => e.takenAt) + .sort((a, b) => a.getTime() - b.getTime()); +} + +/** + * v1.8.5 — adapt a compliance medication context to the canonical engine's + * {@link RecurrenceContext}. The synthetic medication `id` only labels the + * row for the engine's internal logging; the `idTag` keeps the two callers' + * historical prefixes (`compliance-daily` vs `compliance-slots`) distinct. + */ +export function toRecurrenceCtx( + ctx: ComplianceMedicationContext, + idTag: string, +): RecurrenceContext { + return { + medication: { + id: idTag, + startsOn: ctx.startsOn, + endsOn: ctx.endsOn, + oneShot: ctx.oneShot, + createdAt: ctx.createdAt, + }, + timeZone: ctx.timeZone, + lastIntakeAt: ctx.lastIntakeAt, + }; +} + +/** + * v1.8.5 — adapt a {@link ComplianceSchedule} to the canonical engine's + * {@link CanonicalSchedule}, defaulting the optional fields the compliance + * payload may omit. `id` is engine-internal labelling only. + */ +export function toCanonicalSchedule( + s: ComplianceSchedule, + id: string, +): CanonicalSchedule { + return { + id, + rrule: s.rrule ?? null, + rollingIntervalDays: s.rollingIntervalDays ?? null, + timesOfDay: s.timesOfDay ?? [], + daysOfWeek: s.daysOfWeek ?? null, + windowStart: s.windowStart, + windowEnd: s.windowEnd, + reminderGraceMinutes: s.reminderGraceMinutes ?? null, + scheduleType: s.scheduleType ?? "SCHEDULED", + cyclicOnWeeks: s.cyclicOnWeeks ?? null, + cyclicOffWeeks: s.cyclicOffWeeks ?? null, + doseWindows: normaliseDoseWindows(s.doseWindows), + }; +} + +export function intakeInstantsAtOrBefore( + intakes: ComplianceIntakeInstant[], + now: Date, +): Date[] { + return intakes + .filter( + (e): e is { takenAt: Date; skipped: boolean } => + !e.skipped && + e.takenAt !== null && + e.takenAt.getTime() <= now.getTime(), + ) + .map((e) => e.takenAt) + .sort((a, b) => a.getTime() - b.getTime()); +} + +/** + * v1.13.x — expand one schedule's expected occurrences over `[from, to]`, + * routing a ROLLING schedule through the retrospective builder when intake + * history is supplied (`intakeInstants` + `now`) and the forward-only engine + * path for every other shape. This is the single expansion the slot-count + * helpers and the displayed-rate timeline both delegate to, so the heatmap + * `due` flags, the window-selection denominator, and the percentage agree. + */ +export function expandComplianceOccurrences( + canonical: CanonicalSchedule, + recurrenceCtx: RecurrenceContext, + from: Date, + to: Date, + retro: { intakeInstants: Date[]; now: Date } | undefined, +): Occurrence[] { + if (retro && canonical.rollingIntervalDays !== null) { + return expandRollingRetrospective( + canonical, + recurrenceCtx, + from, + to, + retro.intakeInstants, + retro.now, + ); + } + return occurrencesBetween(canonical, from, to, recurrenceCtx); +} diff --git a/src/lib/analytics/compliance/cycle.ts b/src/lib/analytics/compliance/cycle.ts new file mode 100644 index 000000000..f80a9f2c2 --- /dev/null +++ b/src/lib/analytics/compliance/cycle.ts @@ -0,0 +1,199 @@ +// Extracted from the former single-file `compliance.ts`. See `../compliance.ts` +// (the barrel) for the module map. Pure move — no logic changes. + +import { + nextOccurrenceAfter, + type Occurrence, +} from "@/lib/medications/scheduling/recurrence"; +import { + lastNonSkippedTakenAt, + type ComplianceIntakeInstant, + type ComplianceMedicationContext, + type ComplianceSchedule, +} from "./types"; +import { toCanonicalSchedule, toRecurrenceCtx } from "./adapters"; + +/** + * v1.13.x Fix 4 — the current-cycle descriptor. SEPARATE from the + * percentage rows: a sparse med (weekly+ injection) whose current cycle is + * not yet due must NOT render a scary red 0%. The percentage rows reflect + * only CLOSED cycles (the open forward cycle is `upcoming`, excluded from + * the denominator); this descriptor carries the open-cycle state so the + * card can render a neutral "next dose in N days" / "due today" / "overdue" + * line decoupled from the rate. + * + * States: + * - `on_track`: `now < nextDueAt`. The next dose simply hasn't come round. + * - `due`: `nextDueAt ≤ now ≤ graceUntil`. A neutral / amber call-to-action. + * - `missed`: `now > graceUntil` and the cycle has no logged intake. The + * only state that should tint red. + * - `none`: no schedule projects a next dose (PRN, paused, ended). The + * card shows no current-cycle line. + * + * `hasClosedCycles` is false for a brand-new sparse med with zero closed + * dose cycles — the card then surfaces a neutral "no closed dose cycles + * yet" state instead of a misleading 100% / 0%. + */ +export type CurrentCycleState = "on_track" | "due" | "missed" | "none"; + +export interface CurrentCycle { + state: CurrentCycleState; + /** The open cycle's due instant (ISO via JSON). Null when `state === "none"`. */ + nextDueAt: Date | null; + /** End of the due slot's grace window. Null when `state === "none"`. */ + graceUntil: Date | null; + /** + * Whether the trailing window holds at least one CLOSED dose cycle. When + * false the percentage rows are vacuous (no closed cycle to score) and the + * card should show a neutral "not enough data yet" state. + */ + hasClosedCycles: boolean; +} + +/** + * v1.13.x Fix 4 — classify the medication's open (current) dose cycle. + * + * Uses the canonical engine's {@link nextOccurrenceAfter} to find the + * earliest projected dose at or after the start of the search (one ms before + * `now` so a slot landing exactly now is captured). The rolling branch of + * `nextOccurrenceAfter` floors to the start of the user's current day, so an + * overdue rolling dose still surfaces as the next slot — that lets us tell a + * not-yet-due (`on_track`) cycle apart from an overdue one (`due` / `missed`). + * + * `hasClosedCycles` reads `expectedShort` from the already-computed window + * selection: a window holding zero expected (closed) doses means the + * percentage rows are vacuous and the card should show a neutral state. + */ +/** + * v1.15.10 — half-window an intake may sit from a slot's canonical instant + * and still count as "resolving" that slot. The intake write paths snap + * `scheduledFor` to the exact engine slot instant, so an exact (or + * sub-minute) match is the common case; the ±6h tolerance also catches an + * off-time take on a dense intraday cadence (e.g. a 07:00 dose logged 09:13) + * whose snapped slot is the 07:00 row but whose raw instant we compare + * defensively. It is the same ±half-gap radius the cadence pairer uses for a + * 12h-gap (twice-daily) med, floored so a single-dose-a-day cadence still + * matches its one slot. + */ +const OPEN_DOSE_RESOLVE_RADIUS_MS = 6 * 60 * 60 * 1000; + +/** + * v1.15.10 — true when an intake event has already resolved the slot at + * `slotAt`. A resolved slot is one the user took, deliberately skipped, or + * the auto-miss cron flagged — any of which means the card must NOT keep + * surfacing that slot as the next dose. Matches on the snapped `scheduledFor` + * first (the canonical, exact path) and falls back to the take/skip instant + * within the resolve radius for rows that predate the snap or drifted. + */ +function slotIsResolved( + slotAt: Date, + intakes: ComplianceIntakeInstant[], +): boolean { + const slot = slotAt.getTime(); + for (const e of intakes) { + const isResolved = e.skipped || e.autoMissed === true || e.takenAt !== null; + if (!isResolved) continue; + const ref = (e.scheduledFor ?? e.takenAt) as Date | undefined; + if (!ref) continue; + if (Math.abs(ref.getTime() - slot) <= OPEN_DOSE_RESOLVE_RADIUS_MS) { + return true; + } + } + return false; +} + +export function buildCurrentCycle( + schedules: ComplianceSchedule[], + ctx: ComplianceMedicationContext, + now: Date, + expectedShort: number, + intakes?: ComplianceIntakeInstant[], +): CurrentCycle { + const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-cycle"); + const DAY_MS = 24 * 60 * 60 * 1000; + const allIntakes = intakes ?? []; + const lastIntakeAt = lastNonSkippedTakenAt(allIntakes) ?? ctx.lastIntakeAt; + + // The open cycle's due instant + grace, picked as the SOONEST across every + // schedule. Two paths: + // - ROLLING: the engine's `nextOccurrenceAfter` deliberately rolls an + // overdue re-anchored dose forward (it floors to the start of the + // user's current day), so it cannot surface an open cycle that is more + // than a day overdue. For the current-cycle descriptor we instead + // compute the due instant directly: `(lastNonSkippedIntake) + N` (or + // `startsOn ?? createdAt` with no intake). This is what lets `due` / + // `missed` be distinguished from `on_track`. + // - NON-ROLLING: `nextOccurrenceAfter` already surfaces the next due + // slot (RRULE / legacy / one-shot / cyclic). + let dueAt: Date | null = null; + let graceUntil: Date | null = null; + const consider = (at: Date, grace: Date): void => { + if (dueAt === null || at.getTime() < dueAt.getTime()) { + dueAt = at; + graceUntil = grace; + } + }; + + for (let i = 0; i < schedules.length; i++) { + const s = schedules[i]; + const canonical = toCanonicalSchedule(s, `compliance-cycle-${i}`); + if (canonical.scheduleType === "PRN") continue; + const n = canonical.rollingIntervalDays; + if (n !== null && n > 0) { + const anchor = + lastIntakeAt !== null + ? new Date(lastIntakeAt.getTime() + n * DAY_MS) + : (ctx.startsOn ?? ctx.createdAt); + if (ctx.endsOn && anchor.getTime() > ctx.endsOn.getTime()) continue; + const graceMs = (canonical.reminderGraceMinutes ?? 60) * 60 * 1000; + consider(anchor, new Date(anchor.getTime() + graceMs)); + continue; + } + // v1.15.10 — advance past slots the user has already resolved. The + // engine's `nextOccurrenceAfter` is purely time-anchored, so for a + // twice-daily med it surfaces today's 19:00 slot in the afternoon even + // after the user logged that dose early — the card then sticks on a + // resolved past/present slot ("träge"). Walk occurrences forward and pick + // the first one no intake has resolved (taken / skipped / auto-missed), so + // a med whose remaining slots today are all logged advances to tomorrow's + // first open slot. Bounded so a fully-logged-ahead history can't spin. + let after = new Date(now.getTime() - 1); + let picked: Occurrence | null = null; + for (let step = 0; step < 64; step++) { + const occ = nextOccurrenceAfter(canonical, after, recurrenceCtx); + if (!occ) break; + if (!slotIsResolved(occ.at, allIntakes)) { + picked = occ; + break; + } + // This slot is resolved — step strictly past it and keep looking. + after = new Date(occ.at.getTime()); + } + if (picked) consider(picked.at, picked.graceUntil); + } + + const hasClosedCycles = expectedShort > 0; + + if (dueAt === null || graceUntil === null) { + return { + state: "none", + nextDueAt: null, + graceUntil: null, + hasClosedCycles, + }; + } + + // Narrow the union-mutated locals for the comparisons below. + const due: Date = dueAt; + const grace: Date = graceUntil; + let state: CurrentCycleState; + if (now.getTime() < due.getTime()) { + state = "on_track"; + } else if (now.getTime() <= grace.getTime()) { + state = "due"; + } else { + state = "missed"; + } + + return { state, nextDueAt: due, graceUntil: grace, hasClosedCycles }; +} diff --git a/src/lib/analytics/compliance/display.ts b/src/lib/analytics/compliance/display.ts new file mode 100644 index 000000000..fa379412f --- /dev/null +++ b/src/lib/analytics/compliance/display.ts @@ -0,0 +1,727 @@ +// Extracted from the former single-file `compliance.ts`. See `../compliance.ts` +// (the barrel) for the module map. Pure move — no logic changes. + +import { + buildCadenceTimeline, + type CadenceEngineContext, + type IntakeEventLike, + type ScheduleLike, +} from "@/lib/medications/scheduling/cadence"; +import { streaksFromTimeline } from "@/lib/medications/scheduling/compliance"; +import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history"; +import type { + ComplianceMedicationContext, + ComplianceResult, + ComplianceSchedule, + IntakeEvent, +} from "./types"; +import { + deriveDoseStatus, + doseCadenceFamily, + type DoseCadenceFamily, + type DoseStatus, +} from "./dose-status"; +import { + COMPLIANCE_WINDOW_LADDER, + MIN_STABLE_DOSES, + selectComplianceWindows, + type ComplianceWindowSelection, +} from "./windows"; +import { + COMPLIANCE_LEDGER_WINDOW_DAYS, + buildComplianceLedgerRows, + tallyComplianceFromLedger, + tallyLedgerRows, +} from "./ledger"; +import { buildCurrentCycle, type CurrentCycle } from "./cycle"; +import { rollingIntakeInstants } from "./adapters"; + +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +/** + * v1.15.9 — the {@link DoseCadenceFamily} of the soonest non-PRN schedule, + * used to pick the window model for the open dose's status. A multi-schedule + * med whose cycle anchors on its earliest window keeps that window's cadence; + * when every schedule is PRN (no projected dose) the family is irrelevant + * because the caller only reads `currentDose` when a cycle is open. + */ +function soonestCadenceFamily( + schedules: ComplianceSchedule[], +): DoseCadenceFamily { + for (const s of schedules) { + if (s.scheduleType === "PRN") continue; + return doseCadenceFamily(s); + } + return "daily"; +} + +/** + * v1.8.6 — the compliance-display block returned alongside the existing + * `compliance7` / `compliance30` fields (which iOS + the Health Score read + * verbatim). The card always renders two percentage rows; the server picks + * the two windows from the medication's cadence and computes each row's + * rate over the chosen span. A dense med keeps `7` / `30`; a sparse med + * steps both windows up so each row covers enough expected doses to be + * meaningful. + */ + +export interface ComplianceDisplay { + shortDays: number; + longDays: number; + /** Realised expected dose count over the short window. */ + expectedShort: number; + /** Realised expected dose count over the long window. */ + expectedLong: number; + /** Echo of the density floor so a client can re-derive the rung. */ + minStableDoses: number; + /** + * Compliance percentage + counts + day-streak over the short window. + * `taken` is the numerator the card renders next to the rate so two + * identical percentages stay distinguishable and trustworthy; `expected` + * is the rate denominator (taken + missed, EXCLUDING user skips) and + * `missed` the count counting against the rate. The card can render + * "26 / 30 · 87%" from `taken` / `expected`. + */ + short: { + rate: number; + taken: number; + expected: number; + missed: number; + streak: number; + }; + /** Compliance percentage + counts over the long window. */ + long: { rate: number; taken: number; expected: number; missed: number }; + /** + * v1.13.x Fix 4 — the open-cycle state, decoupled from the percentage + * rows so a between-doses sparse med never renders a scary red number. + */ + currentCycle: CurrentCycle; + /** + * v1.15.9 — the open cycle's per-dose {@link DoseStatus}, server-derived + * so the card renders its state (green take-window, overdue / heavily- + * overdue escalation) from one authority instead of re-deriving the window + * math client-side. `status` is `upcoming` when no dose is open yet and + * there is no projected next dose (PRN / paused / ended). `targetAt` is the + * open dose's target instant (echo of `currentCycle.nextDueAt`). + */ + currentDose: { status: DoseStatus; targetAt: Date | null }; +} + +/** + * v1.8.6 — compute the two-row {@link ComplianceDisplay} block. + * + * The card always shows two percentage rows. {@link selectComplianceWindows} + * decides which windows they span from the medication's cadence, then each + * row's rate is the cadence-aware {@link calculateCompliance} over that + * window. The short row also carries the day-streak. The compliance math is + * unchanged from the legacy 7-/30-day path — only the window day-counts move. + */ +export function buildComplianceDisplay( + events: IntakeEvent[], + schedules: ComplianceSchedule[], + ctx: ComplianceMedicationContext, + options?: { now?: Date }, +): ComplianceDisplay { + const now = options?.now ?? new Date(); + // v1.13.x — thread the intake history so a ROLLING cadence's window + // selection scores the retrospective grid (each logged dose is a closed + // cycle) rather than the engine's single forward slot. Without this a + // weekly rolling med always falls through to the widest `[90, 365]` rung. + const { shortDays, longDays, expectedShort, expectedLong } = + selectComplianceWindows(schedules, ctx, { now, intakes: events }); + + const short = calculateCompliance( + events, + schedules, + shortDays, + ctx.createdAt, + { + now, + medicationContext: ctx, + }, + ); + const long = calculateCompliance(events, schedules, longDays, ctx.createdAt, { + now, + medicationContext: ctx, + }); + + // v1.13.x Fix 4 — the open-cycle descriptor, separable from the rates. + const currentCycle = buildCurrentCycle( + schedules, + ctx, + now, + expectedShort, + events, + ); + + // v1.15.9 — derive the open dose's per-dose status from one server + // authority so the card renders the take-window (green) / overdue / + // heavily-overdue escalation without re-spelling the window math. The + // cadence family comes from the SOONEST non-PRN schedule (the one the + // open cycle anchors on). `none` cycles (PRN / paused / ended) carry an + // `upcoming` status with a null target so the card stays calm. + const currentDose: { status: DoseStatus; targetAt: Date | null } = + currentCycle.nextDueAt + ? { + status: deriveDoseStatus( + currentCycle.nextDueAt, + soonestCadenceFamily(schedules), + now, + ), + targetAt: currentCycle.nextDueAt, + } + : { status: "upcoming", targetAt: null }; + + return { + shortDays, + longDays, + expectedShort, + expectedLong, + minStableDoses: MIN_STABLE_DOSES, + // `expected` is the rate denominator (taken + missed) so the card can + // render the trustworthy "taken / expected · rate%" triple; user skips + // are excluded from it by construction (they never enter `missed`). + short: { + rate: short.rate, + taken: short.taken, + expected: short.taken + short.missed, + missed: short.missed, + streak: short.streak, + }, + long: { + rate: long.rate, + taken: long.taken, + expected: long.taken + long.missed, + missed: long.missed, + }, + currentCycle, + currentDose, + }; +} + +/** + * Calculate compliance for a medication over a given period. + * + * Honours `daysOfWeek` (e.g. `"1"` for Mondays only) and + * `intervalWeeks` (bi-weekly, tri-weekly, …) by delegating to + * `buildCadenceTimeline`. The denominator is the number of dose slots + * the schedule actually emits inside the window — not + * `schedules.length * days`. A user on a weekly Monday-only schedule + * who takes every Monday for 30 days reports 100% adherence (4 of 4 + * Mondays) instead of the pre-v1.5.0 ~13% (4 of 30). + * + * Skipped doses are excluded from the denominator (deliberate user + * decision, not a compliance failure). When the window contains no + * expected doses (paused med, brand-new prescription, schedule that + * never fires in the window) the helper returns `rate: 100` so the + * empty state doesn't trip a "0% compliance" alarm downstream. + * + * Streak counts consecutive days, ending at `now`, where every + * expected dose for the day was taken (or skipped). Days with no + * expected dose (out-of-cadence weekdays, off-weeks on a bi-weekly + * schedule) advance the streak — the user gets credit for not + * breaking on non-scheduled days. + * + * @param events Recorded intake events from + * `MedicationIntakeEvent`. Only `scheduledFor`, + * `takenAt`, `skipped` are read. + * @param schedules Schedule rows for the medication. `daysOfWeek` + * is read when present; missing field is treated + * as daily. + * @param days Rolling-window size in days (typically 7 / 30 + * / 90). + * @param medicationCreatedAt + * When provided, days before the medication + * existed are excluded so they don't count as + * "missed". + * @param options.now Override for the rolling-window anchor. The + * fast-path Health-Score helper passes a fixed + * `now` so cached medication-compliance rates + * agree with the score's other pillars; default + * is `new Date()` (current wall-clock instant). + * v1.5.0 — added so the cadence-aware adapter + * can be driven deterministically from a caller + * that already pinned its own `now`. + */ +export function calculateCompliance( + events: IntakeEvent[], + schedules: ComplianceSchedule[], + days: number, + medicationCreatedAt?: Date, + options?: { now?: Date; medicationContext?: ComplianceMedicationContext }, +): ComplianceResult { + if (schedules.length === 0) { + return { + totalExpected: 0, + taken: 0, + skipped: 0, + missed: 0, + rate: 100, + streak: 0, + }; + } + + const now = options?.now ?? new Date(); + const DAY_MS = 24 * 60 * 60 * 1000; + const periodStart = new Date(now.getTime() - days * DAY_MS); + const effectiveStart = + medicationCreatedAt && medicationCreatedAt > periodStart + ? medicationCreatedAt + : periodStart; + const effectiveDays = Math.max( + 1, + Math.ceil((now.getTime() - effectiveStart.getTime()) / DAY_MS), + ); + + // v1.15.18 — when a medication context is supplied, the count fields + // (taken / skipped / missed / rate) come from the UNIFIED dose-history + // ledger tally, NOT the ±12h `pairDoses` proximity matcher. This is the + // keystone unification: the percentage is a tally over the exact same + // ledger the history view renders, so the two can never contradict (a + // dose can't read "taken late" in the % while the ledger calls it + // "ad-hoc"). The numerator is on-time + late takes (a late dose still + // counts as taken); user skips + ad-hoc top-ups + PRN groups are excluded + // from the denominator. The streak still walks the cadence timeline below + // (its day-grain "every dose taken or skipped" rule is unchanged). + // + // Context-less callers (pure-math fixtures, pre-v1.7 surfaces) keep the + // legacy timeline tally byte-stable — they have no engine context to mint + // bands from. + const ledgerCtx = options?.medicationContext; + let ledgerCounts: { + taken: number; + skipped: number; + missed: number; + rate: number; + } | null = null; + if (ledgerCtx) { + const tally = tallyComplianceFromLedger( + events, + schedules, + ledgerCtx, + effectiveStart, + now, + now, + ); + ledgerCounts = { + taken: tally.taken, + skipped: tally.skipped, + missed: tally.missed, + rate: tally.rate, + }; + } + + // Normalise the schedule shape so legacy callers that pass only + // `{ windowStart, windowEnd }` still produce a usable `daysOfWeek` + // field (treated as daily by the cadence parser). + const normalisedSchedules: ScheduleLike[] = schedules.map((s, i) => ({ + id: `compliance-${i}`, + windowStart: s.windowStart, + windowEnd: s.windowEnd, + daysOfWeek: s.daysOfWeek ?? null, + // v1.7.0 SB-SCHED-2 — thread the canonical-engine fields so the + // cadence expander can delegate to `occurrencesBetween` when a + // medication context is supplied. Undefined fields collapse to the + // legacy weekday path inside `expandScheduleSlots`. + rrule: s.rrule ?? null, + rollingIntervalDays: s.rollingIntervalDays ?? null, + timesOfDay: s.timesOfDay, + reminderGraceMinutes: s.reminderGraceMinutes ?? null, + scheduleType: s.scheduleType ?? null, + cyclicOnWeeks: s.cyclicOnWeeks ?? null, + cyclicOffWeeks: s.cyclicOffWeeks ?? null, + })); + + // v1.7.0 SB-SCHED-2 — build the engine context once per medication. + // When the caller supplies it, the timeline routes through the + // canonical engine (RRULE / rolling / one-shot / PRN / cyclic); + // otherwise the legacy weekday walker stays in force. + const ctx = options?.medicationContext; + const engineCtx: CadenceEngineContext | undefined = ctx + ? { + startsOn: ctx.startsOn, + endsOn: ctx.endsOn, + oneShot: ctx.oneShot, + createdAt: ctx.createdAt, + lastIntakeAt: ctx.lastIntakeAt, + timeZone: ctx.timeZone, + scheduleRevisions: ctx.scheduleRevisions, + } + : undefined; + + // Match events against the same slot grid the cadence chart uses. + // The chart's pairing radius is ±12 h so a late-by-six-hours dose + // still attaches to the right slot instead of double-counting as + // "missed + extra". + const normalisedEvents: IntakeEventLike[] = events + .filter((e) => e.scheduledFor >= effectiveStart && e.scheduledFor <= now) + .map((e) => ({ + scheduledFor: e.scheduledFor, + takenAt: e.takenAt, + skipped: e.skipped, + // v1.15.9 — carry the forgotten-dose flag into the timeline so an + // auto-missed slot pairs to a `missed` status (counts against the + // rate) rather than a neutral `skipped`. + autoMissed: e.autoMissed ?? false, + })); + + // v1.13.x — ROLLING retrospective expansion. A rolling cadence + // (`rollingIntervalDays`, the canonical GLP-1 "every N days" shape) is + // forward-only in the engine: `expandRolling` emits at most the single + // immediately-next slot, so a historical compliance window saw either + // zero expected slots (vacuous 100%) or one overdue slot (hard 0%) — + // never the true multi-dose adherence over the trailing window. When a + // medication context is threaded AND any schedule is rolling, route the + // rolling schedules through the retrospective builder so each logged dose + // is one satisfied expected slot (plus synthesized misses for skipped + // whole cycles + a past-due forward slot). Non-rolling schedules keep the + // forward-only engine path; both share `buildCadenceTimeline` so the + // numerator and denominator agree (the v1.7.3 B15 convergence rule). + const hasRolling = schedules.some( + (s) => s.rollingIntervalDays != null && s.rollingIntervalDays > 0, + ); + const retro = + engineCtx && hasRolling + ? { intakeInstants: rollingIntakeInstants(events, now), now } + : undefined; + + const timeline = buildCadenceTimeline( + normalisedSchedules, + normalisedEvents, + now, + effectiveDays, + medicationCreatedAt ?? effectiveStart, + engineCtx?.timeZone, + engineCtx, + retro, + ); + + // v1.15.18 — the count fields come from the unified ledger tally when a + // medication context was supplied (the keystone unification), and from the + // legacy timeline tally otherwise (context-less pure-math callers). The + // streak below always walks the timeline — its day-grain rule is orthogonal + // to the per-dose attribution and stays byte-stable for every caller. + let taken: number; + let skipped: number; + let missed: number; + let rate: number; + if (ledgerCounts) { + ({ taken, skipped, missed, rate } = ledgerCounts); + } else { + taken = 0; + skipped = 0; + missed = 0; + for (const slot of timeline) { + if (slot.status === "taken") taken++; + else if (slot.status === "skipped") skipped++; + else if (slot.status === "missed") missed++; + // `upcoming` slots (future window) are excluded from every counter + // so a partial day at the head of the window doesn't pollute the rate. + } + // Skipped doses are excluded from the denominator — they represent a + // deliberate user decision rather than a missed dose. + const denom = taken + missed; + rate = denom > 0 ? Math.min(100, Math.round((taken / denom) * 100)) : 100; + } + + const totalExpected = taken + skipped + missed; + + // Streak: consecutive days, ending today, where every expected dose + // was taken or skipped. Days with no expected dose advance the + // streak — out-of-cadence days are not failures. Delegated to the + // shared `streaksFromTimeline` so the analytics streak and the + // detail-page chip streak agree on every dose AND so the day keys are + // computed in the USER's IANA timezone, not the host's. The prior + // host-tz `getFullYear/getMonth/getDate` walk drifted off by a day + // whenever the server clock's zone differed from the user's — the + // timeline `slot.day` is already minted in the user zone, so the + // walk has to match it. The window is `effectiveDays` ending at + // `now`, which starts no earlier than `effectiveStart` (= the later + // of the period start and the medication's creation) — so days + // before the medication existed are never iterated, preserving the + // old `cursor <= medicationCreatedAt` break by construction. + const { current: streak } = streaksFromTimeline( + timeline, + now, + effectiveDays, + engineCtx?.timeZone, + ); + + return { totalExpected, taken, skipped, missed, rate, streak }; +} + +/** + * The per-medication compliance payload computed from ONE shared expansion + * pass. `ledgerRows` is the unified dose-history ledger over + * `[ledgerFrom, now]`; the caller carves the 90-day heatmap out of it by + * filtering on `row.at`. + */ +export interface MedicationComplianceBundle { + compliance7: ComplianceResult; + compliance30: ComplianceResult; + complianceDisplay: ComplianceDisplay; + /** Unified ledger rows over `[ledgerFrom, now]`, chronological. */ + ledgerRows: DoseHistoryRow[]; + /** Lower bound of the mint window (clamped to the medication's creation). */ + ledgerFrom: Date; +} + +/** + * Build every block of the per-medication compliance response from a single + * band-expansion pass. + * + * The historical composition called {@link calculateCompliance} four times + * (7 / 30 / short / long), {@link selectComplianceWindows} (up to four more + * occurrence expansions for the rung probes) and a separate 90-day heatmap + * mint — five-plus full band expansions per request. This builder instead: + * + * 1. mints the bands + reconstructs the ledger ONCE over + * `[max(createdAt, now − 365 d), now]` (every served window is a + * suffix of that range and ends at `now`, so a sub-window tally is a + * filter over `row.at`, not a re-expansion); + * 2. builds ONE cadence timeline over the same range for the per-window + * streaks ({@link streaksFromTimeline} bounds its day-walk by the + * requested window, so the wider timeline serves every window); + * 3. walks the {@link COMPLIANCE_WINDOW_LADDER} on the ledger's slot-row + * counts (one band per engine occurrence, so the counts match the + * historical `expectedSlotsBetween` probes). + * + * The returned blocks carry the exact public shapes the route has always + * served — only the number of expansion passes changed. + */ +export function buildMedicationComplianceBundle( + events: IntakeEvent[], + schedules: ComplianceSchedule[], + ctx: ComplianceMedicationContext, + now: Date, +): MedicationComplianceBundle { + const ledgerPeriodStart = new Date( + now.getTime() - COMPLIANCE_LEDGER_WINDOW_DAYS * ONE_DAY_MS, + ); + const ledgerFrom = + ctx.createdAt.getTime() > ledgerPeriodStart.getTime() + ? ctx.createdAt + : ledgerPeriodStart; + + const hasSchedules = schedules.length > 0; + const ledgerRows = hasSchedules + ? buildComplianceLedgerRows(events, schedules, ctx, ledgerFrom, now, now) + : []; + + // ONE cadence timeline over the full ledger window. Each per-window + // streak below walks only its own trailing `effectiveDays`, so sharing + // the wide timeline reproduces the per-window builds. + const fullDays = Math.max( + 1, + Math.ceil((now.getTime() - ledgerFrom.getTime()) / ONE_DAY_MS), + ); + const timeline = hasSchedules + ? buildTimelineForWindow(events, schedules, ctx, now, ledgerFrom, fullDays) + : []; + + const resultForWindow = (days: number): ComplianceResult => { + if (!hasSchedules) { + // Mirrors `calculateCompliance`'s empty-schedule short-circuit. + return { + totalExpected: 0, + taken: 0, + skipped: 0, + missed: 0, + rate: 100, + streak: 0, + }; + } + const periodStart = new Date(now.getTime() - days * ONE_DAY_MS); + const effectiveStart = + ctx.createdAt.getTime() > periodStart.getTime() + ? ctx.createdAt + : periodStart; + const effectiveDays = Math.max( + 1, + Math.ceil((now.getTime() - effectiveStart.getTime()) / ONE_DAY_MS), + ); + const tally = tallyLedgerRows(ledgerRows, { + from: effectiveStart, + to: now, + }); + const { current: streak } = streaksFromTimeline( + timeline, + now, + effectiveDays, + ctx.timeZone, + ); + return { + totalExpected: tally.taken + tally.skipped + tally.missed, + taken: tally.taken, + skipped: tally.skipped, + missed: tally.missed, + rate: tally.rate, + streak, + }; + }; + + // Window-ladder selection from the ledger's slot rows. A slot row is one + // minted band, and the minter emits one band per engine occurrence, so + // counting slot rows over a trailing window equals the historical + // `expectedSlotsBetween(...).length` probe for that window. + const expectedCache = new Map(); + const expectedOver = (days: number): number => { + const hit = expectedCache.get(days); + if (hit !== undefined) return hit; + const from = Math.max( + ctx.createdAt.getTime(), + now.getTime() - days * ONE_DAY_MS, + ); + let count = 0; + for (const row of ledgerRows) { + if (row.kind !== "slot") continue; + const t = row.at.getTime(); + if (t >= from && t <= now.getTime()) count++; + } + expectedCache.set(days, count); + return count; + }; + + let selection: ComplianceWindowSelection | null = null; + for (const [shortDays, longDays] of COMPLIANCE_WINDOW_LADDER) { + const expectedShort = expectedOver(shortDays); + const expectedLong = expectedOver(longDays); + if (expectedShort >= MIN_STABLE_DOSES && expectedLong >= MIN_STABLE_DOSES) { + selection = { shortDays, longDays, expectedShort, expectedLong }; + break; + } + } + if (!selection) { + // No rung cleared the floor — fall back to the widest rung, exactly + // like `selectComplianceWindows`. + const [shortDays, longDays] = + COMPLIANCE_WINDOW_LADDER[COMPLIANCE_WINDOW_LADDER.length - 1]; + selection = { + shortDays, + longDays, + expectedShort: expectedOver(shortDays), + expectedLong: expectedOver(longDays), + }; + } + + const compliance7 = resultForWindow(7); + const compliance30 = resultForWindow(30); + const short = resultForWindow(selection.shortDays); + const long = resultForWindow(selection.longDays); + + const currentCycle = buildCurrentCycle( + schedules, + ctx, + now, + selection.expectedShort, + events, + ); + const currentDose: { status: DoseStatus; targetAt: Date | null } = + currentCycle.nextDueAt + ? { + status: deriveDoseStatus( + currentCycle.nextDueAt, + soonestCadenceFamily(schedules), + now, + ), + targetAt: currentCycle.nextDueAt, + } + : { status: "upcoming", targetAt: null }; + + const complianceDisplay: ComplianceDisplay = { + shortDays: selection.shortDays, + longDays: selection.longDays, + expectedShort: selection.expectedShort, + expectedLong: selection.expectedLong, + minStableDoses: MIN_STABLE_DOSES, + short: { + rate: short.rate, + taken: short.taken, + expected: short.taken + short.missed, + missed: short.missed, + streak: short.streak, + }, + long: { + rate: long.rate, + taken: long.taken, + expected: long.taken + long.missed, + missed: long.missed, + }, + currentCycle, + currentDose, + }; + + return { + compliance7, + compliance30, + complianceDisplay, + ledgerRows, + ledgerFrom, + }; +} + +/** + * The cadence-timeline construction from {@link calculateCompliance}, + * extracted so {@link buildMedicationComplianceBundle} can build it once + * over the full ledger window instead of once per served sub-window. Same + * normalisation, same engine context, same rolling-retrospective routing. + */ +function buildTimelineForWindow( + events: IntakeEvent[], + schedules: ComplianceSchedule[], + ctx: ComplianceMedicationContext, + now: Date, + effectiveStart: Date, + effectiveDays: number, +): ReturnType { + const normalisedSchedules: ScheduleLike[] = schedules.map((s, i) => ({ + id: `compliance-${i}`, + windowStart: s.windowStart, + windowEnd: s.windowEnd, + daysOfWeek: s.daysOfWeek ?? null, + rrule: s.rrule ?? null, + rollingIntervalDays: s.rollingIntervalDays ?? null, + timesOfDay: s.timesOfDay, + reminderGraceMinutes: s.reminderGraceMinutes ?? null, + scheduleType: s.scheduleType ?? null, + cyclicOnWeeks: s.cyclicOnWeeks ?? null, + cyclicOffWeeks: s.cyclicOffWeeks ?? null, + })); + + const engineCtx: CadenceEngineContext = { + startsOn: ctx.startsOn, + endsOn: ctx.endsOn, + oneShot: ctx.oneShot, + createdAt: ctx.createdAt, + lastIntakeAt: ctx.lastIntakeAt, + timeZone: ctx.timeZone, + scheduleRevisions: ctx.scheduleRevisions, + }; + + const normalisedEvents: IntakeEventLike[] = events + .filter((e) => e.scheduledFor >= effectiveStart && e.scheduledFor <= now) + .map((e) => ({ + scheduledFor: e.scheduledFor, + takenAt: e.takenAt, + skipped: e.skipped, + autoMissed: e.autoMissed ?? false, + })); + + const hasRolling = schedules.some( + (s) => s.rollingIntervalDays != null && s.rollingIntervalDays > 0, + ); + const retro = hasRolling + ? { intakeInstants: rollingIntakeInstants(events, now), now } + : undefined; + + return buildCadenceTimeline( + normalisedSchedules, + normalisedEvents, + now, + effectiveDays, + ctx.createdAt, + ctx.timeZone, + engineCtx, + retro, + ); +} diff --git a/src/lib/analytics/compliance/dose-status.ts b/src/lib/analytics/compliance/dose-status.ts new file mode 100644 index 000000000..3b4052a1e --- /dev/null +++ b/src/lib/analytics/compliance/dose-status.ts @@ -0,0 +1,153 @@ +// Extracted from the former single-file `compliance.ts`. See `../compliance.ts` +// (the barrel) for the module map. Pure move — no logic changes. + +import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults"; +import type { ComplianceSchedule } from "./types"; + +/** + * v1.15.9 — cadence-aware per-dose grace/miss window model. + * + * A medication's dose either is on-time, is still takeable but late + * (counts when taken), or has slipped past the point where a self-hoster + * could plausibly still take it (a MISS). The cutoffs differ by cadence: + * an intraday/daily dose has tight hour-scale windows; a weekly/rolling + * injectable (the GLP-1 case) follows the clinical "take it within 4 days, + * otherwise skip to the next scheduled dose" rule. + * + * The defaults below are centralised named constants so a future revision + * can expose them per-medication without re-deriving the boundaries. Make + * it correct first; configurability later. + * + * DAILY / INTRADAY (minute-scale windows around the target instant): + * - on-time: target − 60 min … target + 60 min + * - overdue: target + 60 min … target + 240 min (still takeable) + * - missed: > target + 240 min, OR the next dose's on-time window + * starts first (whichever is sooner — adjacent doses never + * overlap). + * + * WEEKLY / ROLLING injectable (day-scale windows; the 4-day clinical rule): + * - on-time: target day ± 1 day + * - overdue: up to target + 4 days (late-but-counts) + * - missed: > target + 4 days + */ +export { DOSE_WINDOW_DEFAULTS }; + +/** + * v1.15.9 — the derived per-dose state the medication card renders. + * + * - `on_time_window` — due now, inside the on-time window (green). + * - `overdue` — past on-time, before the miss cutoff (still + * takeable; the card escalates the tint as it nears + * the cutoff — "stark überfällig"). + * - `missed` — past the miss cutoff, never acted on. + * - `taken_on_time` — taken inside the on-time window. + * - `taken_late` — taken in the overdue window (still counts). + * - `skipped` — a deliberate user skip (excluded from the rate). + * - `upcoming` — the on-time window has not opened yet. + * + * PRN doses are never scheduled and so never produce a status — callers + * exclude them before calling {@link deriveDoseStatus}. + */ +export type DoseStatus = + | "upcoming" + | "on_time_window" + | "overdue" + | "missed" + | "taken_on_time" + | "taken_late" + | "skipped"; + +/** Cadence family the window math keys off. */ +export type DoseCadenceFamily = "daily" | "weekly"; + +const HOUR_MS = 60 * 60 * 1000; +const ONE_DAY_MS = 24 * HOUR_MS; + +/** + * v1.15.9 — derive a single dose's {@link DoseStatus} from its target + * instant, the cadence family, `now`, and (optionally) when it was taken / + * whether the user skipped it. Pure and deterministic. + * + * `nextDoseAt` (when supplied) caps a daily dose's miss cutoff at the next + * dose's on-time window start, so two adjacent intraday doses never claim + * overlapping overdue windows — the earlier dose flips to `missed` the + * moment the later one comes due if that is sooner than its own cutoff. + * + * The defaults come from {@link DOSE_WINDOW_DEFAULTS}; callers may override + * per-medication once that configurability lands. + */ +export function deriveDoseStatus( + targetAt: Date, + cadence: DoseCadenceFamily, + now: Date, + options?: { + takenAt?: Date | null; + skipped?: boolean; + nextDoseAt?: Date | null; + windows?: Partial; + }, +): DoseStatus { + if (options?.skipped) return "skipped"; + + const w = { ...DOSE_WINDOW_DEFAULTS, ...(options?.windows ?? {}) }; + const target = targetAt.getTime(); + + let onTimeStart: number; + let onTimeEnd: number; + let overdueEnd: number; + if (cadence === "weekly") { + onTimeStart = target - w.weeklyOnTimeDays * ONE_DAY_MS; + onTimeEnd = target + w.weeklyOnTimeDays * ONE_DAY_MS; + overdueEnd = target + w.weeklyOverdueDays * ONE_DAY_MS; + } else { + onTimeStart = target - w.dailyOnTimeMinutes * 60_000; + onTimeEnd = target + w.dailyOnTimeMinutes * 60_000; + overdueEnd = onTimeEnd + w.dailyOverdueMinutes * 60_000; + } + + // Never let the miss cutoff bleed into the next dose's on-time window. + if (options?.nextDoseAt) { + const nextOnTimeStart = + cadence === "weekly" + ? options.nextDoseAt.getTime() - w.weeklyOnTimeDays * ONE_DAY_MS + : options.nextDoseAt.getTime() - w.dailyOnTimeMinutes * 60_000; + if (nextOnTimeStart < overdueEnd) overdueEnd = nextOnTimeStart; + } + + const takenAt = options?.takenAt ?? null; + if (takenAt) { + const t = takenAt.getTime(); + return t <= onTimeEnd ? "taken_on_time" : "taken_late"; + } + + const n = now.getTime(); + if (n < onTimeStart) return "upcoming"; + if (n <= onTimeEnd) return "on_time_window"; + if (n <= overdueEnd) return "overdue"; + return "missed"; +} + +/** + * v1.15.9 — classify a schedule's cadence into the {@link DoseCadenceFamily} + * the window model uses. A rolling cadence, or any RRULE / legacy weekly + * cadence that emits less often than daily, is `weekly` (day-scale windows + + * the 4-day rule); everything denser (daily, intraday multi-dose) is + * `daily` (minute-scale windows). + */ +export function doseCadenceFamily( + schedule: ComplianceSchedule, +): DoseCadenceFamily { + if ( + schedule.rollingIntervalDays != null && + schedule.rollingIntervalDays >= 2 + ) { + return "weekly"; + } + const rrule = schedule.rrule ?? ""; + if (/FREQ=(WEEKLY|MONTHLY|YEARLY)/i.test(rrule)) return "weekly"; + // A legacy daysOfWeek restriction to specific weekdays (not every day) + // dosed once a day is still effectively daily-cadence on the days it + // fires; the per-slot window is minute-scale either way. Only the rolling + // / calendar-sparse shapes use the day-scale 4-day rule. + return "daily"; +} diff --git a/src/lib/analytics/compliance/ledger.ts b/src/lib/analytics/compliance/ledger.ts new file mode 100644 index 000000000..308f6b2de --- /dev/null +++ b/src/lib/analytics/compliance/ledger.ts @@ -0,0 +1,379 @@ +// Extracted from the former single-file `compliance.ts`. See `../compliance.ts` +// (the barrel) for the module map. Pure move — no logic changes. + +import type { SlotBand } from "@/lib/medications/scheduling/attribution"; +import type { + BandMinterMedication, + DoseWindowConfig, +} from "@/lib/medications/scheduling/band-minter"; +import { buildBandsForSchedulesWithEras } from "@/lib/medications/scheduling/schedule-eras"; +import { + reconstructDoseHistory, + type DoseHistoryRow, + type HistoryIntake, +} from "@/lib/medications/scheduling/dose-history"; +import { userDayKey } from "@/lib/tz/resolver"; +import type { + ComplianceMedicationContext, + ComplianceSchedule, + IntakeEvent, +} from "./types"; +import { + intakeInstantsAtOrBefore, + toCanonicalSchedule, + toRecurrenceCtx, +} from "./adapters"; + +/** + * v1.15.18 — the unified compliance tally over the dose-history ledger. + * + * THE single source of the medication compliance %. It builds the + * cadence-aware `SlotBand[]` per schedule (the shared band minter — every + * cadence: daily, fixed-weekday, rolling-retrospective, one-shot, cyclic, + * PRN), reconstructs the ONE dose-history ledger + * (`reconstructDoseHistory`), and tallies it so the percentage and the + * history view are mathematically incapable of contradicting each other + * (audit CRITICAL-2). It replaces the ±12h `pairDoses` proximity matcher + * that `calculateCompliance` used for the engine-routed path. + * + * The tally follows the adherence literature's TAKING-vs-TIMING split: + * - numerator (taken) = `taken_on_time` + `taken_late` — a late dose is + * still a taken dose; "late" is NOT collapsed into "missed"; + * - denominator = taken + `missed`; + * - EXCLUDED from the denominator: `skipped` (deliberate user decision), + * `ad_hoc` (off-schedule top-up — no defensible slot), `upcoming` (the + * window hasn't opened), and ENTIRE PRN groups (`hasExpectedSlots:false` + * — PRN has no defensible denominator per the literature); + * - the rate is capped at 100% (extra doses never inflate it). + * + * The on-time / late split is surfaced separately so a caller can show both + * the TAKING rate (the headline) and the TIMING quality. + * + * Pure / synchronous: the bands are minted from pre-fetched schedules + + * intake instants; no DB access. + */ +export interface LedgerComplianceTally { + /** Doses taken (on-time + late). The TAKING-adherence numerator. */ + taken: number; + /** Of `taken`, the count inside the on-time band. */ + takenOnTime: number; + /** Of `taken`, the count in the late tail (still counts as taken). */ + takenLate: number; + /** Expected doses never acted on past their miss cutoff. */ + missed: number; + /** Deliberate user skips — excluded from the denominator. */ + skipped: number; + /** Off-schedule intakes (PRN groups + ad-hoc rows) — excluded. */ + adHoc: number; + /** taken + missed (the rate denominator). */ + denominator: number; + /** round(100 · taken / denominator), capped at 100. 100 on empty. */ + rate: number; +} + +export function tallyComplianceFromLedger( + events: IntakeEvent[], + schedules: ComplianceSchedule[], + ctx: ComplianceMedicationContext, + from: Date, + to: Date, + now: Date, + windowConfig?: DoseWindowConfig, +): LedgerComplianceTally { + const rows = buildComplianceLedgerRows( + events, + schedules, + ctx, + from, + to, + now, + windowConfig, + ); + return tallyLedgerRows(rows); +} + +/** + * Mint the cadence-aware bands over `[from, to]` and reconstruct the ONE + * unified dose-history ledger for them. This is the expansion pass behind + * {@link tallyComplianceFromLedger}, extracted so a caller that needs + * several trailing sub-windows (7-day / 30-day / display / heatmap, all + * ending at `now`) can mint the bands ONCE over the widest window and tally + * each sub-window from the same rows via {@link tallyLedgerRows} instead of + * re-expanding per window. + * + * Pure / synchronous: bands come from pre-fetched schedules + intake + * instants; no DB access. + */ +export function buildComplianceLedgerRows( + events: IntakeEvent[], + schedules: ComplianceSchedule[], + ctx: ComplianceMedicationContext, + from: Date, + to: Date, + now: Date, + windowConfig?: DoseWindowConfig, +): DoseHistoryRow[] { + const medication: BandMinterMedication = { + id: "compliance-tally", + startsOn: ctx.startsOn, + endsOn: ctx.endsOn, + oneShot: ctx.oneShot, + createdAt: ctx.createdAt, + }; + const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-tally"); + const canonicalSchedules = schedules.map((s, i) => { + const canonical = toCanonicalSchedule(s, `compliance-tally-${i}`); + // A legacy daily schedule carries only `windowStart` (no `timesOfDay`, + // no rrule, no rolling, no `daysOfWeek`). The engine's `expandLegacy` + // reads that as "every day at windowStart", but the band minter's + // cadence-detection gate needs an explicit time signal — surface + // `windowStart` as the single time-of-day so the daily band is minted. + if ( + canonical.timesOfDay.length === 0 && + canonical.rrule === null && + canonical.rollingIntervalDays === null && + canonical.scheduleType !== "PRN" && + !ctx.oneShot + ) { + return { ...canonical, timesOfDay: [canonical.windowStart] }; + } + return canonical; + }); + // Rolling cadences anchor their retrospective grid AT each logged intake; + // the bands need every non-skipped take in (or before) the window. Reuse + // the same instant-extraction the legacy rolling path uses so the + // numerator and denominator are built from one expansion. + const intakeInstants = intakeInstantsAtOrBefore( + events.map((e) => ({ takenAt: e.takenAt, skipped: e.skipped })), + to, + ); + + // v1.16.3 — era-aware mint: archived eras band with THEIR schedules. + const groups = buildBandsForSchedulesWithEras({ + medication, + schedules: canonicalSchedules, + revisions: ctx.scheduleRevisions ?? [], + ctx: recurrenceCtx, + userTz: ctx.timeZone, + range: { from, to }, + now, + windowConfig, + intakeInstants, + }); + + // The ledger reads `scheduledFor` + `takenAt` + skip/auto-miss flags. The + // bands already partition the slot space, so the union of every + // non-PRN schedule's bands is fed to ONE reconstruction — `reconstruct + // DoseHistory` claims each slot by at most one intake, so pooling the + // (already correctly-minted) bands is safe. PRN groups (no expected + // slots) contribute no bands; their intakes surface as ad-hoc and are + // excluded from the denominator, exactly as the literature requires. + const bands: SlotBand[] = []; + for (const g of groups) { + if (g.hasExpectedSlots) bands.push(...g.bands); + } + + const intakes: HistoryIntake[] = events + .filter((e) => e.scheduledFor >= from && e.scheduledFor <= to) + .map((e) => ({ + scheduledFor: e.scheduledFor, + takenAt: e.takenAt, + skipped: e.skipped, + autoMissed: e.autoMissed ?? false, + // v1.15.20 — a pinned take binds by anchor and tallies as taken-late + // (slot served, no on-time gain) instead of ad_hoc + missed. + pinned: e.attributionSource === "USER_PIN", + })); + + const rows = reconstructDoseHistory(bands, intakes, now); + + // v1.25 H-MED1 — drop expected dose slots whose anchor falls inside a + // pause interval. While a medication is paused no dose is expected, so a + // slot minted across the paused window must never count as "missed" (the + // denominator-inflating status). Only `slot` rows are dropped, and only + // those whose status feeds the tally (taken / missed) — skip / ad-hoc / + // upcoming rows are already excluded from the denominator, so dropping + // them here would be a redundant double-exclusion. An open era + // (`resumedAt === null`) runs to `now`. + const pauseEras = ctx.pauseEras; + if (pauseEras && pauseEras.length > 0) { + const isInPause = (at: Date): boolean => { + const t = at.getTime(); + for (const era of pauseEras) { + const start = era.pausedAt.getTime(); + const end = (era.resumedAt ?? now).getTime(); + if (t >= start && t < end) return true; + } + return false; + }; + return rows.filter((row) => { + if (row.kind !== "slot") return true; + if ( + row.status !== "taken_on_time" && + row.status !== "taken_late" && + row.status !== "missed" + ) { + return true; + } + return !isInPause(row.at); + }); + } + + return rows; +} + +/** + * Tally pre-built ledger rows into the {@link LedgerComplianceTally} + * counters. When `window` is supplied only rows whose instant (`row.at`) + * falls inside `[window.from, window.to]` (inclusive) are counted — that is + * how a sub-window tally is carved out of a wider single-pass ledger. The + * window-less call tallies every row, byte-identical to the historical + * {@link tallyComplianceFromLedger} behaviour. + */ +export function tallyLedgerRows( + rows: DoseHistoryRow[], + window?: { from: Date; to: Date }, +): LedgerComplianceTally { + let takenOnTime = 0; + let takenLate = 0; + let missed = 0; + let skipped = 0; + let adHoc = 0; + for (const row of rows) { + if (window) { + const t = row.at.getTime(); + if (t < window.from.getTime() || t > window.to.getTime()) continue; + } + switch (row.status) { + case "taken_on_time": + takenOnTime++; + break; + case "taken_late": + takenLate++; + break; + case "missed": + missed++; + break; + case "skipped": + skipped++; + break; + case "ad_hoc": + adHoc++; + break; + // `upcoming` slots are future / still-takeable → excluded from every + // counter so a partial head-of-window day never pollutes the rate. + } + } + + const taken = takenOnTime + takenLate; + const denominator = taken + missed; + const rate = + denominator > 0 + ? Math.min(100, Math.round((taken / denominator) * 100)) + : 100; + + return { + taken, + takenOnTime, + takenLate, + missed, + skipped, + adHoc, + denominator, + rate, + }; +} + +/** + * Widest trailing window the single-pass compliance ledger has to cover: + * the top rung of {@link COMPLIANCE_WINDOW_LADDER} (365 days). Every + * sub-window the per-medication compliance endpoint serves (7 / 30 / + * cadence-scaled display rows / 90-day heatmap) is a suffix of it. + */ +export const COMPLIANCE_LEDGER_WINDOW_DAYS = 365; + +/** + * One day of the cadence-aware compliance series: the day's compliance % + * over the doses the schedule actually expected that day, plus the raw + * taken / missed counts behind it. + */ +export interface DailyComplianceRate { + /** The day's anchor instant (the first expected slot of the day). */ + date: Date; + /** round(100 · taken / (taken + missed)), capped at 100. */ + rate: number; + /** Doses taken (on-time + late) on the day. */ + taken: number; + /** Expected doses never acted on past their miss cutoff, on the day. */ + missed: number; +} + +/** + * v1.18.0 — collapse unified dose-history ledger rows into a cadence-aware + * per-day compliance series. + * + * Only days the schedule's cadence actually expected a dose produce a point: + * the series is grouped over the ledger's scheduled `slot` rows, so an + * off-cadence weekday (a weekly Monday-only med on a Tuesday) or an off-week + * (the off-week of a bi-weekly schedule) emits no point at all and therefore + * can never be read as a 0% "miss". Each day's rate uses the SAME + * taken / (taken + missed) tally — and the SAME on-time-plus-late numerator, + * skip / ad-hoc / upcoming exclusions — that {@link tallyLedgerRows} applies + * to `compliance7` / `compliance30`, so the per-day series and the window + * rates are computed from one ledger and cannot contradict each other. + * + * `ad_hoc` rows (off-schedule top-ups, no defensible slot) and `upcoming` + * rows (the window hasn't opened) are dropped before grouping, exactly as the + * window tally excludes them. A day whose only ledger rows are skips yields no + * point (zero denominator) rather than a misleading 0%. + * + * Days are bucketed in the USER's timezone (`tz`). Passing the caller's real + * zone keeps an evening dose on the local day it was actually due: a fixed + * Berlin key filed a `20:00 America/Los_Angeles` dose (04:00 UTC next day) + * into the following Berlin day, splitting one local day's two doses across + * two buckets and skewing every per-day rate for non-Berlin users. + */ +export function dailyComplianceRatesFromLedger( + ledgerRows: DoseHistoryRow[], + tz: string, +): DailyComplianceRate[] { + const byDay = new Map< + string, + { taken: number; missed: number; date: Date } + >(); + + for (const row of ledgerRows) { + // Only scheduled slots have a defensible denominator; ad-hoc top-ups are + // excluded from the rate exactly as `tallyLedgerRows` excludes them. + if (row.kind !== "slot") continue; + const isTaken = + row.status === "taken_on_time" || row.status === "taken_late"; + const isMissed = row.status === "missed"; + // `skipped` (deliberate) and `upcoming` (window not open) advance neither + // counter — they never enter the day's denominator. + if (!isTaken && !isMissed) continue; + + const dayKey = userDayKey(row.at, tz); + const bucket = byDay.get(dayKey) ?? { taken: 0, missed: 0, date: row.at }; + if (isTaken) bucket.taken += 1; + else bucket.missed += 1; + // Keep the earliest slot instant of the day as the point's anchor. + if (row.at.getTime() < bucket.date.getTime()) bucket.date = row.at; + byDay.set(dayKey, bucket); + } + + return Array.from(byDay.values()) + .sort((a, b) => a.date.getTime() - b.date.getTime()) + .map((day) => { + const denom = day.taken + day.missed; + return { + date: day.date, + rate: + denom > 0 + ? Math.min(100, Math.round((day.taken / denom) * 100)) + : 100, + taken: day.taken, + missed: day.missed, + }; + }); +} diff --git a/src/lib/analytics/compliance/occurrences.ts b/src/lib/analytics/compliance/occurrences.ts new file mode 100644 index 000000000..4b572e5c1 --- /dev/null +++ b/src/lib/analytics/compliance/occurrences.ts @@ -0,0 +1,127 @@ +// Extracted from the former single-file `compliance.ts`. See `../compliance.ts` +// (the barrel) for the module map. Pure move — no logic changes. + +import { occurrencesAcrossEras } from "@/lib/medications/scheduling/schedule-eras"; +import type { Occurrence } from "@/lib/medications/scheduling/recurrence"; +import type { + ComplianceIntakeInstant, + ComplianceMedicationContext, + ComplianceSchedule, +} from "./types"; +import { + expandComplianceOccurrences, + intakeInstantsAtOrBefore, + toCanonicalSchedule, + toRecurrenceCtx, +} from "./adapters"; + +/** + * v1.7.0 item 5 — count the expected dose slots a medication's schedules + * emit inside `[dayStart, dayEnd)`, routed through the canonical engine. + * Powers the per-day `due` / `expectedCount` fields on the per-med + * compliance payload so iOS history renders a "missed" mark only on days + * the schedule actually expected a dose (not off-weeks / non-matching + * weekdays / PRN days). + * + * v1.13.x — pass `intakes` so a ROLLING schedule routes through the + * retrospective grid (each logged dose is a `due` day, plus skipped-cycle + * misses + a past-due forward slot) instead of the engine's single forward + * slot. Omitting `intakes` keeps the forward-only behaviour for callers that + * don't have the intake history at hand. + */ +export function expectedSlotCountForDay( + schedules: ComplianceSchedule[], + dayStart: Date, + dayEnd: Date, + ctx: ComplianceMedicationContext, + intakes?: ComplianceIntakeInstant[], +): number { + const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-daily"); + const now = new Date(); + const retro = + intakes && schedules.some((s) => s.rollingIntervalDays != null) + ? { intakeInstants: intakeInstantsAtOrBefore(intakes, now), now } + : undefined; + // v1.16.3 — era-aware: a past day counts the slots of the schedule that + // was live THEN. With no revisions the single live era expands exactly + // the per-schedule loop this used to run. + return occurrencesAcrossEras( + { + from: dayStart, + // occurrencesBetween is inclusive of both ends; subtract 1 ms so a + // slot exactly at the next day's midnight doesn't double-count. + to: new Date(dayEnd.getTime() - 1), + }, + ctx.scheduleRevisions ?? [], + schedules.map((s, i) => toCanonicalSchedule(s, `compliance-daily-${i}`)), + (schedule, eraFrom, eraTo) => + expandComplianceOccurrences( + schedule, + recurrenceCtx, + eraFrom, + eraTo, + retro, + ), + { oneShot: ctx.oneShot }, + ).length; +} + +/** + * v1.8.5 — the sibling of {@link expectedSlotCountForDay} that returns the + * expected-dose occurrences *themselves* (ascending by instant) over an + * arbitrary window, rather than just the per-day count. Same loop, same + * canonical-engine delegation; we keep the slots so the dose-adherence + * timeline can pair each expected slot to its intake. + * + * Used by {@link buildComplianceDisplay} to decide the card's render mode + * (percent bars vs an uptime-style per-dose strip) and to build the strip. + * `occurrencesBetween` is inclusive of both ends; the caller passes a + * `[from, to]` window and we sort the union of every schedule's slots so + * a multi-schedule medication interleaves its slots in time order. + * + * v1.8.6 QA — the window lower bound is clamped to `ctx.createdAt`. The + * legacy weekday walker floors on `startsOn` but not on `createdAt`, so a + * brand-new daily med queried over a 30-day window would otherwise emit + * slots for every day before it existed (7/30 expected on a 2-day-old med). + * Clamping here keeps the expected-dose denominator — and the window + * selection that reads it — honest about the medication's real age. The + * displayed rates clamp independently via `calculateCompliance`'s + * `medicationCreatedAt` argument, so this only fixes the slot counts. + */ +export function expectedSlotsBetween( + schedules: ComplianceSchedule[], + from: Date, + to: Date, + ctx: ComplianceMedicationContext, + intakes?: ComplianceIntakeInstant[], +): Occurrence[] { + const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-slots"); + const effectiveFrom = + ctx.createdAt.getTime() > from.getTime() ? ctx.createdAt : from; + // v1.13.x — for a ROLLING schedule the expected grid is reconstructed from + // the intake history (each logged dose is one satisfied expected slot) so + // the window-selection denominator matches the displayed rate. The forward + // next-due slot counts only when past-due relative to `to` (the window's + // upper bound), so a not-yet-due open cycle never inflates the count. + const retro = + intakes && schedules.some((s) => s.rollingIntervalDays != null) + ? { intakeInstants: intakeInstantsAtOrBefore(intakes, to), now: to } + : undefined; + if (effectiveFrom.getTime() > to.getTime()) return []; + // v1.16.3 — era-aware: each archived era contributes the slots of ITS + // schedules; the live rows cover only the range past the newest revision. + return occurrencesAcrossEras( + { from: effectiveFrom, to }, + ctx.scheduleRevisions ?? [], + schedules.map((s, i) => toCanonicalSchedule(s, `compliance-slots-${i}`)), + (schedule, eraFrom, eraTo) => + expandComplianceOccurrences( + schedule, + recurrenceCtx, + eraFrom, + eraTo, + retro, + ), + { oneShot: ctx.oneShot }, + ); +} diff --git a/src/lib/analytics/compliance/parsing.ts b/src/lib/analytics/compliance/parsing.ts new file mode 100644 index 000000000..258b7b968 --- /dev/null +++ b/src/lib/analytics/compliance/parsing.ts @@ -0,0 +1,96 @@ +// Extracted from the former single-file `compliance.ts`. See `../compliance.ts` +// (the barrel) for the module map. Pure move — no logic changes. + +import { localHmAsUtc } from "@/lib/tz/local-day"; + +export type IntakeTimingClass = + "early" | "on_time" | "late" | "very_late" | "missed"; + +/** + * Parse "HH:mm" into hours and minutes. + */ +function parseHHmm(time: string): { hours: number; minutes: number } { + const [h, m] = time.split(":").map(Number); + return { hours: h, minutes: m }; +} + +/** + * Build the UTC instant of local "HH:mm" on the calendar day `day` falls on. + * + * When `tz` is supplied the window `HH:mm` is interpreted in the user's zone + * (via {@link localHmAsUtc}) on the local day of `day` — so an "08:00" slot is + * 08:00 LOCAL, not 08:00 UTC. Without `tz` it keeps the legacy UTC anchoring + * (`setUTCHours`), which is only correct for a UTC user; a caller far from UTC + * must pass its zone or the punctuality band is offset by the zone's offset. + */ +function toDateOnDay(time: string, day: Date, tz?: string): Date { + const { hours, minutes } = parseHHmm(time); + if (tz) { + return localHmAsUtc(day, tz, hours, minutes); + } + const d = new Date(day); + d.setUTCHours(hours, minutes, 0, 0); + return d; +} + +/** + * Classify how punctual an intake was relative to a schedule window. + * + * v1.4.34 IW-C — widened pre-window grace from 1h to 3h and introduced + * a dedicated `early` bucket so a proactive logger (e.g. 10 min before + * the window) no longer gets flushed into `very_late`. The post-window + * grace likewise grew to 3h so a "20 minutes late" dose stays `on_time`. + * Only doses beyond a 3-hour late tolerance fall into `late`, and only + * doses beyond a further `lateMinutes` tail fall into `very_late`. + * + * Buckets: + * - "early": within (windowStart - 3h) .. windowStart (compliant) + * - "on_time": windowStart .. (windowEnd + 3h) (compliant) + * - "late": (windowEnd + 3h) .. (windowEnd + 3h + lateMinutes) (default 2h tail) + * - "very_late": before windowStart - 3h, or after the late tail + * - "missed": takenAt is null + * + * Handles overnight windows (windowEnd < windowStart means next day). + * + * @param options.lateMinutes Width of the `late` band beyond the 3-hour + * on-time tolerance, in minutes. Defaults to 120. Doses past this + * tail land in `very_late`. + * @param options.tz IANA zone the window `HH:mm` is interpreted in. Supply + * the user's zone so an "08:00" slot means 08:00 LOCAL; omitting it keeps + * the legacy UTC interpretation (correct only for a UTC user). + */ +export function classifyIntakeTiming( + takenAt: Date | null, + windowStart: string, // "HH:mm" + windowEnd: string, // "HH:mm" + scheduledDate: Date, // the date this was scheduled + options?: { lateMinutes?: number; tz?: string }, +): IntakeTimingClass { + if (takenAt === null) return "missed"; + + const start = toDateOnDay(windowStart, scheduledDate, options?.tz); + let end = toDateOnDay(windowEnd, scheduledDate, options?.tz); + + // Handle overnight windows (e.g. windowStart="23:00", windowEnd="01:00") + if (end <= start) { + end = new Date(end.getTime() + 24 * 60 * 60 * 1000); + } + + const HOUR_MS = 60 * 60 * 1000; + // 3h pre-window grace: doses inside this window are `early` (still + // compliant). Doses before it are `very_late`. + const earlyStart = new Date(start.getTime() - 3 * HOUR_MS); + // 3h post-window grace: doses up to here are still `on_time`. + const onTimeEnd = new Date(end.getTime() + 3 * HOUR_MS); + // Configurable `late` tail past the on-time grace (default 120 min). + const lateTolerance = (options?.lateMinutes ?? 120) * 60 * 1000; + const lateEnd = new Date(onTimeEnd.getTime() + lateTolerance); + + const t = takenAt.getTime(); + + if (t < earlyStart.getTime()) return "very_late"; + if (t < start.getTime()) return "early"; + if (t <= onTimeEnd.getTime()) return "on_time"; + if (t <= lateEnd.getTime()) return "late"; + return "very_late"; +} diff --git a/src/lib/analytics/compliance/types.ts b/src/lib/analytics/compliance/types.ts new file mode 100644 index 000000000..4e0f7ac61 --- /dev/null +++ b/src/lib/analytics/compliance/types.ts @@ -0,0 +1,252 @@ +// Extracted from the former single-file `compliance.ts`. See `../compliance.ts` +// (the barrel) for the module map. Pure move — no logic changes. + +import type { ScheduleRevisionLike } from "@/lib/medications/scheduling/schedule-eras"; +import type { ScheduleType } from "@/lib/medications/scheduling/recurrence"; + +export interface IntakeEvent { + takenAt: Date | null; + skipped: boolean; + scheduledFor: Date; + /** + * v1.15.9 — true when the auto-miss cron marked this never-acted dose as + * a forgotten miss. Threaded into the cadence timeline so it counts as a + * `missed` slot (against the rate) instead of being neutralised. Optional + * so pre-v1.15.9 callers / fixtures default it to a normal pending/taken + * row. + */ + autoMissed?: boolean; + /** + * v1.15.20 — slot-binding provenance. `USER_PIN` = the user deliberately + * pinned this off-window take onto its `scheduledFor` slot; the unified + * ledger then binds it by anchor (taken-late, never on-time-washed) + * instead of degrading it to ad-hoc when the takenAt sits outside the + * band tail. Optional so legacy callers / fixtures default to AUTO. + */ + attributionSource?: "AUTO" | "USER_PIN"; +} + +export interface ComplianceResult { + totalExpected: number; + taken: number; + skipped: number; + missed: number; + rate: number; // 0-100 + streak: number; // consecutive days with all taken +} + +/** + * Daily compliance data including timing breakdown. + * + * `early` is the v1.4.34 bucket for doses taken before the window's + * 3-hour grace start. Consumers that group by compliance status + * should treat `early` as compliant (alongside `onTime`). + */ +export interface DailyComplianceEntry { + expected: number; + /** + * v1.7.0 item 5 — the true engine-computed due-slot count for the day. + * Equals `expected`; carried as an explicit additive field iOS keys + * off so it doesn't have to infer "due-ness" from `expected`. + */ + expectedCount: number; + /** + * v1.7.0 item 5 — `expectedCount > 0`. iOS renders a "missed" mark + * only when `due === true`, so off-weeks / non-matching weekdays / PRN + * days no longer paint a false miss. + */ + due: boolean; + taken: number; + skipped: number; + onTime: number; + late: number; + veryLate: number; + early?: number; +} + +/** + * v1.5.0 — schedule view consumed by the cadence-aware adapter. + * + * Accepts the historical `ScheduleWindow` shape (`windowStart` + + * `windowEnd` only) as well as the richer `ScheduleLike` shape that + * carries `daysOfWeek`. When `daysOfWeek` is missing the schedule is + * treated as `null` (daily, every day) so legacy fixtures keep + * passing without modification. + */ +export interface ComplianceSchedule { + windowStart: string; // HH:mm + windowEnd: string; // HH:mm + daysOfWeek?: string | null; + /** + * v1.7.0 SB-SCHED-2 — canonical-engine fields. When present (and a + * `medicationContext` is threaded into `calculateCompliance`), the + * expected-slot grid is computed through the canonical recurrence + * engine, so an `rrule = "FREQ=WEEKLY;BYDAY=MO"` schedule counts only + * Mondays in the denominator instead of every day. Absent fields keep + * the legacy `daysOfWeek` path — existing fixtures / callers that pass + * only `{ windowStart, windowEnd }` behave exactly as before. + */ + rrule?: string | null; + rollingIntervalDays?: number | null; + timesOfDay?: string[]; + reminderGraceMinutes?: number | null; + scheduleType?: ScheduleType | null; + cyclicOnWeeks?: number | null; + cyclicOffWeeks?: number | null; + /** + * v1.15.18 — per-dose configurable on-time windows. Threaded onto the + * canonical schedule so `tallyComplianceFromLedger` builds the on-time band + * from the explicit `[start, end]` range — keeping the % exactly aligned with + * the dose-history view and the write/edit attribution (all consume the same + * minter). Accepts the raw persisted JSON (a full Prisma row drops straight + * in); `toCanonicalSchedule` normalises it via `normaliseDoseWindows`. + */ + doseWindows?: unknown; +} + +/** + * v1.15.20 — the ONE Prisma `select` for schedule rows feeding a compliance + * computation. Every call site that loads schedules for + * `calculateCompliance` / the cadence helpers must use this constant instead + * of hand-rolling a field list (or an unbounded `include: { schedules: + * true }`), so a future schedule column that the engine consumes — the way + * `doseWindows` joined in v1.15.18 — reaches every surface the moment it is + * added here. Covers the full {@link ComplianceSchedule} shape plus `id` + * (engine-internal labelling / diagnostics). + */ +export const SCHEDULE_COMPLIANCE_SELECT = { + id: true, + windowStart: true, + windowEnd: true, + daysOfWeek: true, + timesOfDay: true, + reminderGraceMinutes: true, + rrule: true, + rollingIntervalDays: true, + scheduleType: true, + cyclicOnWeeks: true, + cyclicOffWeeks: true, + // The configurable per-dose on-time windows. Selecting them everywhere is + // the point: a user-configured "07:00–09:00" band must shape the rate on + // the dashboard pillar, the insights features, the BP-status gate and the + // report exactly as it shapes the medication detail page. + doseWindows: true, +} as const; + +/** + * v1.7.0 SB-SCHED-2 — medication-level context the canonical engine + * needs to expand expected slots. When supplied, `calculateCompliance` + * routes the denominator through the engine; when omitted, every + * schedule falls back to the legacy weekday walker (the byte-stable + * pre-v1.7 behaviour that the parity fixtures pin). + */ +export interface ComplianceMedicationContext { + startsOn: Date | null; + endsOn: Date | null; + oneShot: boolean; + createdAt: Date; + lastIntakeAt: Date | null; + timeZone: string; + /** + * v1.16.3 — archived schedule eras (`validFrom` ascending). When present, + * every expected-slot expansion + band mint segments its range so a past + * day counts/mints against the schedule that was live THEN. Optional: + * callers without revisions keep the live-only expansion. + */ + scheduleRevisions?: ScheduleRevisionLike[]; + /** + * v1.25 H-MED1 — durable pause intervals (`MedicationPauseEra`). When + * present, expected dose slots whose anchor falls inside any + * `[pausedAt, resumedAt ?? now)` interval are dropped from the ledger so a + * resumed medication never counts the paused days as missed. Optional: + * callers without eras keep counting every expected slot. + */ + pauseEras?: MedicationPauseEraLike[]; +} + +/** + * v1.25 H-MED1 — the pause-interval projection the compliance engine reads. + * A Prisma `MedicationPauseEra` row (or a `{ pausedAt, resumedAt }` literal) + * drops in. An open era (`resumedAt === null`) runs to `now`. + */ +export interface MedicationPauseEraLike { + pausedAt: Date; + resumedAt: Date | null; +} + +/** + * v1.7.0 SB-SCHED-2 — convenience builder so the eight compliance call + * sites don't each re-spell the context shape. Pass the medication row + * (any object carrying the course-window fields), the latest non-skipped + * intake instant, and the user's timezone. + */ +export function buildComplianceMedicationContext( + med: { + startsOn: Date | null; + endsOn: Date | null; + oneShot: boolean; + createdAt: Date; + /** v1.16.3 — thread the archived eras when the bundle carries them. */ + scheduleRevisions?: ScheduleRevisionLike[]; + /** v1.25 H-MED1 — thread the pause eras when the caller carries them. */ + pauseEras?: MedicationPauseEraLike[]; + }, + lastIntakeAt: Date | null, + timeZone: string, +): ComplianceMedicationContext { + return { + startsOn: med.startsOn, + endsOn: med.endsOn, + oneShot: med.oneShot, + createdAt: med.createdAt, + lastIntakeAt, + timeZone, + ...(med.scheduleRevisions && { scheduleRevisions: med.scheduleRevisions }), + ...(med.pauseEras && { pauseEras: med.pauseEras }), + }; +} + +/** + * v1.7.0 SB-SCHED-2 — the latest non-skipped `takenAt` across an event + * list (rolling cadences re-anchor on it). Returns null when the user + * has never logged a non-skipped intake. Order-independent, so the + * caller can pass an events array in any sort order. + */ +export function lastNonSkippedTakenAt( + events: { takenAt: Date | null; skipped: boolean }[], +): Date | null { + return events.reduce((latest, e) => { + if (e.skipped || e.takenAt === null) return latest; + if (latest === null || e.takenAt.getTime() > latest.getTime()) { + return e.takenAt; + } + return latest; + }, null); +} + +/** + * v1.13.x — non-skipped intake instants for the retrospective rolling grid, + * mirroring {@link rollingIntakeInstants} but reading the public + * `{ takenAt, skipped }[]` shape the `expectedSlots*` callers pass. The + * `expectedSlots*` helpers accept this so their rolling denominator uses the + * SAME expansion as the displayed-rate numerator in {@link calculateCompliance}. + */ +export interface ComplianceIntakeInstant { + takenAt: Date | null; + skipped: boolean; + /** + * v1.15.10 — the dose slot this intake resolves (the canonical snapped + * instant, byte-identical to the engine occurrence's `.at`). Optional so + * every legacy caller / fixture that omits it keeps working; when present + * the open-dose selection in {@link buildCurrentCycle} uses it to skip a + * slot the user has already acted on (taken / skipped / auto-missed) so the + * card advances to the next genuinely-open slot rather than re-surfacing a + * resolved past dose. + */ + scheduledFor?: Date; + /** + * v1.15.10 — the cron-flagged forgotten miss. A resolved (auto-missed) slot + * is excluded from the open-dose search the same way a take / skip is. + */ + autoMissed?: boolean; +} diff --git a/src/lib/analytics/compliance/windows.ts b/src/lib/analytics/compliance/windows.ts new file mode 100644 index 000000000..1edc2dd71 --- /dev/null +++ b/src/lib/analytics/compliance/windows.ts @@ -0,0 +1,113 @@ +// Extracted from the former single-file `compliance.ts`. See `../compliance.ts` +// (the barrel) for the module map. Pure move — no logic changes. + +import type { + ComplianceIntakeInstant, + ComplianceMedicationContext, + ComplianceSchedule, +} from "./types"; +import { expectedSlotsBetween } from "./occurrences"; + +/** + * v1.8.6 — the floor at which a window's percentage is stable. Four + * expected doses is the point where a single miss moves the rate by ≤25% + * rather than ±50–100%. A daily med clears it in a 7-day window; a weekly + * med needs ~30 days; a tri-weekly / 35-day-rolling med needs a quarter or + * more. The window ladder below steps up until both rows clear this floor. + */ +export const MIN_STABLE_DOSES = 4; + +/** + * v1.8.6 — the rung ladder for the two compliance windows. Each rung is a + * `[short, long]` pair of day-counts. The card always shows two percentage + * rows; the only thing that scales with cadence is which rung the windows + * sit on. Dense meds (daily / weekday) sit on `[7, 30]`; as the expected + * dose frequency drops, both windows step up so each row still spans enough + * expected doses to mean something, up to a 12-month long window for very + * rare meds. + */ +export const COMPLIANCE_WINDOW_LADDER: ReadonlyArray< + readonly [number, number] +> = [ + [7, 30], + [30, 90], + [90, 365], +]; + +/** + * v1.8.6 — the day-counts of the two compliance windows a medication's + * cadence resolves to, plus the realised expected-dose count each window + * holds. `shortDays` / `longDays` drive the row labels; the expected counts + * are surfaced so a client can show the denominator or re-derive the rung. + */ +export interface ComplianceWindowSelection { + shortDays: number; + longDays: number; + expectedShort: number; + expectedLong: number; +} + +/** + * v1.8.6 — pick the two compliance windows for a medication from its + * dosing cadence. + * + * Walks {@link COMPLIANCE_WINDOW_LADDER} from densest to sparsest and + * returns the first rung whose BOTH windows clear {@link MIN_STABLE_DOSES} + * realised expected doses. A daily med clears `[7, 30]` immediately; a + * weekly med fails the 7-day row (one dose) and lands on `[30, 90]`; a + * 35-day-rolling injection needs the top rung `[90, 365]`. When even the + * top rung can't clear the floor (a brand-new prescription, a very rare + * med) the top rung is returned anyway so the card still shows two honest + * percentage rows over the widest windows available. + * + * The expected count routes through {@link expectedSlotsBetween} (the + * canonical recurrence engine), so PRN / off-cadence / pre-creation days + * never inflate the denominator. + */ +export function selectComplianceWindows( + schedules: ComplianceSchedule[], + ctx: ComplianceMedicationContext, + options?: { now?: Date; intakes?: ComplianceIntakeInstant[] }, +): ComplianceWindowSelection { + const now = options?.now ?? new Date(); + const DAY_MS = 24 * 60 * 60 * 1000; + + const expectedOver = (days: number): number => + expectedSlotsBetween( + schedules, + new Date(now.getTime() - days * DAY_MS), + now, + ctx, + options?.intakes, + ).length; + + // Memoise per distinct window so a shared rung boundary (e.g. 30 / 90) + // isn't re-walked across rungs. + const cache = new Map(); + const expected = (days: number): number => { + const hit = cache.get(days); + if (hit !== undefined) return hit; + const v = expectedOver(days); + cache.set(days, v); + return v; + }; + + for (const [shortDays, longDays] of COMPLIANCE_WINDOW_LADDER) { + const expectedShort = expected(shortDays); + const expectedLong = expected(longDays); + if (expectedShort >= MIN_STABLE_DOSES && expectedLong >= MIN_STABLE_DOSES) { + return { shortDays, longDays, expectedShort, expectedLong }; + } + } + + // No rung cleared the floor — fall back to the widest rung so both rows + // still render over the most data the cadence affords. + const [shortDays, longDays] = + COMPLIANCE_WINDOW_LADDER[COMPLIANCE_WINDOW_LADDER.length - 1]; + return { + shortDays, + longDays, + expectedShort: expected(shortDays), + expectedLong: expected(longDays), + }; +} From a7c82eb5828ef0e6ddccc2c8372bd8a7a51a47e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 01:49:27 +0200 Subject: [PATCH 26/33] refactor(types): restore Prisma's type-checker on the cycle + PR write paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cycle write path and the PR worker cast Prisma inputs to `never`, which switches off the compiler exactly where a schema/enum drift would otherwise be caught. - day-log-write: `ExistingRow` and `SensitiveFields` widened every enum column to `string`, forcing 21 `as never` writes. Type them to the generated enums (FlowLevel, OvulationTest, CervicalMucus, Cervix*, HomeTestResult, ContraceptiveKind) — the Zod input enums already match member-for-member — and build the create row from the typed locals instead of re-casting the update-input shape. - cycle/backup: thread the enum element type through `enumOrNull` and pin each allow-set to its enum, so the 12 restore writes type-check against the generated create input rather than `never`. - pr-detection-worker: type the candidate `source` as MeasurementSource (both Workout and PersonalRecord use it) and give `findBestWorkout` a real `Prisma.WorkoutWhereInput` + `WorkoutOrderByWithRelationInput`. No behaviour change; the writes are now compiler-checked. No latent drift surfaced — every enum set lines up with the schema. --- src/lib/cycle/backup.ts | 88 ++++++++++++------- src/lib/cycle/day-log-write.ts | 78 +++++++++------- .../personal-records/pr-detection-worker.ts | 25 +++--- 3 files changed, 117 insertions(+), 74 deletions(-) diff --git a/src/lib/cycle/backup.ts b/src/lib/cycle/backup.ts index a5a76ff50..bad576278 100644 --- a/src/lib/cycle/backup.ts +++ b/src/lib/cycle/backup.ts @@ -10,7 +10,21 @@ * the day-log note, so a wrong-surface plaintext leak is impossible and the * owner's note round-trips encrypted at rest. */ -import type { PrismaClient, Prisma } from "@/generated/prisma/client"; +import type { + CervicalMucus, + CervixFirmness, + CervixOpening, + CervixPosition, + ContraceptiveKind, + CycleTrackingGoal, + FlowLevel, + HomeTestResult, + MeasurementSource, + OvulationTest, + Prisma, + PrismaClient, + SecondarySymptom, +} from "@/generated/prisma/client"; import type { BackupPayload } from "@/lib/validations/backup"; /** The cycle slice of a backup payload. */ @@ -150,26 +164,36 @@ export interface CycleRestoreCleared { // few enum columns up-front (mirrors the measurement-type guard in the // restore route). Unknown values are coerced to null rather than failing // the whole restore — a single drifted field shouldn't strand the rest. -const FLOW_LEVELS = new Set(["NONE", "SPOTTING", "LIGHT", "MEDIUM", "HEAVY"]); -const OVULATION_TESTS = new Set([ +const FLOW_LEVELS = new Set([ + "NONE", + "SPOTTING", + "LIGHT", + "MEDIUM", + "HEAVY", +]); +const OVULATION_TESTS = new Set([ "NEGATIVE", "POSITIVE_LH_SURGE", "ESTROGEN_SURGE", "INDETERMINATE", ]); -const CERVICAL_MUCUS = new Set([ +const CERVICAL_MUCUS = new Set([ "DRY", "STICKY", "CREAMY", "WATERY", "EGG_WHITE", ]); -const SECONDARY_SYMPTOMS = new Set(["MUCUS", "CERVIX"]); -const CERVIX_POSITIONS = new Set(["LOW", "HIGH"]); -const CERVIX_FIRMNESSES = new Set(["FIRM", "SOFT"]); -const CERVIX_OPENINGS = new Set(["CLOSED", "OPEN"]); -const HOME_TESTS = new Set(["NEGATIVE", "POSITIVE", "INDETERMINATE"]); -const CONTRACEPTIVES = new Set([ +const SECONDARY_SYMPTOMS = new Set(["MUCUS", "CERVIX"]); +const CERVIX_POSITIONS = new Set(["LOW", "HIGH"]); +const CERVIX_FIRMNESSES = new Set(["FIRM", "SOFT"]); +const CERVIX_OPENINGS = new Set(["CLOSED", "OPEN"]); +const HOME_TESTS = new Set([ + "NEGATIVE", + "POSITIVE", + "INDETERMINATE", +]); +const CONTRACEPTIVES = new Set([ "NONE", "UNSPECIFIED", "IMPLANT", @@ -180,8 +204,13 @@ const CONTRACEPTIVES = new Set([ "PATCH", "EMERGENCY", ]); -const CYCLE_SOURCES = new Set(["MANUAL", "WITHINGS", "IMPORT", "APPLE_HEALTH"]); -const CYCLE_GOALS = new Set([ +const CYCLE_SOURCES = new Set([ + "MANUAL", + "WITHINGS", + "IMPORT", + "APPLE_HEALTH", +]); +const CYCLE_GOALS = new Set([ "GENERAL_HEALTH", "AVOID_PREGNANCY", "TRYING_TO_CONCEIVE", @@ -191,9 +220,11 @@ const CYCLE_GOALS = new Set([ function enumOrNull( value: string | null | undefined, - allow: Set, + allow: ReadonlySet, ): T | null { - return value != null && allow.has(value) ? (value as T) : null; + return value != null && (allow as ReadonlySet).has(value) + ? (value as T) + : null; } /** @@ -228,13 +259,13 @@ export async function restoreCycleData( await tx.cycleProfile.create({ data: { userId: ownerId, - goal: (enumOrNull(p.goal, CYCLE_GOALS) ?? "GENERAL_HEALTH") as never, + goal: enumOrNull(p.goal, CYCLE_GOALS) ?? "GENERAL_HEALTH", cycleTrackingEnabled: p.cycleTrackingEnabled ?? null, typicalCycleLength: p.typicalCycleLength ?? null, typicalPeriodLength: p.typicalPeriodLength ?? null, lutealPhaseLength: p.lutealPhaseLength ?? null, - secondarySymptom: (enumOrNull(p.secondarySymptom, SECONDARY_SYMPTOMS) ?? - "MUCUS") as never, + secondarySymptom: + enumOrNull(p.secondarySymptom, SECONDARY_SYMPTOMS) ?? "MUCUS", predictionEnabled: p.predictionEnabled ?? true, rawChartMode: p.rawChartMode ?? false, discreetNotifications: p.discreetNotifications ?? false, @@ -302,27 +333,24 @@ export async function restoreCycleData( userId: ownerId, date: d.date, cycleId: owningCycleId(d.date), - flow: enumOrNull(d.flow, FLOW_LEVELS) as never, + flow: enumOrNull(d.flow, FLOW_LEVELS), intermenstrualBleeding: d.intermenstrualBleeding ?? false, basalBodyTempC: d.basalBodyTempC ?? null, temperatureExcluded: d.temperatureExcluded ?? false, - ovulationTest: enumOrNull(d.ovulationTest, OVULATION_TESTS) as never, - cervicalMucus: enumOrNull(d.cervicalMucus, CERVICAL_MUCUS) as never, - cervixPosition: enumOrNull(d.cervixPosition, CERVIX_POSITIONS) as never, - cervixFirmness: enumOrNull( - d.cervixFirmness, - CERVIX_FIRMNESSES, - ) as never, - cervixOpening: enumOrNull(d.cervixOpening, CERVIX_OPENINGS) as never, + ovulationTest: enumOrNull(d.ovulationTest, OVULATION_TESTS), + cervicalMucus: enumOrNull(d.cervicalMucus, CERVICAL_MUCUS), + cervixPosition: enumOrNull(d.cervixPosition, CERVIX_POSITIONS), + cervixFirmness: enumOrNull(d.cervixFirmness, CERVIX_FIRMNESSES), + cervixOpening: enumOrNull(d.cervixOpening, CERVIX_OPENINGS), sexualActivity: d.sexualActivity ?? false, protectedSex: d.protectedSex ?? null, - pregnancyTest: enumOrNull(d.pregnancyTest, HOME_TESTS) as never, - progesteroneTest: enumOrNull(d.progesteroneTest, HOME_TESTS) as never, - contraceptive: enumOrNull(d.contraceptive, CONTRACEPTIVES) as never, + pregnancyTest: enumOrNull(d.pregnancyTest, HOME_TESTS), + progesteroneTest: enumOrNull(d.progesteroneTest, HOME_TESTS), + contraceptive: enumOrNull(d.contraceptive, CONTRACEPTIVES), // Both ciphertext envelopes written back verbatim — never re-encrypted. sensitiveEncrypted: d.sensitiveEncrypted ?? null, notesEncrypted: d.notesEncrypted ?? null, - source: (enumOrNull(d.source, CYCLE_SOURCES) ?? "MANUAL") as never, + source: enumOrNull(d.source, CYCLE_SOURCES) ?? "MANUAL", externalId: d.externalId ?? null, tz: d.tz ?? null, ...(symptomIds.length > 0 diff --git a/src/lib/cycle/day-log-write.ts b/src/lib/cycle/day-log-write.ts index 10f7bc324..8d447daf9 100644 --- a/src/lib/cycle/day-log-write.ts +++ b/src/lib/cycle/day-log-write.ts @@ -32,7 +32,17 @@ import { prisma } from "@/lib/db"; import { encrypt, decrypt } from "@/lib/crypto"; import { getOrCreateCycleProfile } from "@/lib/cycle/profile"; import type { CycleDayLogInput } from "@/lib/validations/cycle"; -import type { Prisma } from "@/generated/prisma/client"; +import type { + CervicalMucus, + CervixFirmness, + CervixOpening, + CervixPosition, + ContraceptiveKind, + FlowLevel, + HomeTestResult, + OvulationTest, + Prisma, +} from "@/generated/prisma/client"; export interface DayLogWriteResult { id: string; @@ -45,9 +55,9 @@ export interface DayLogWriteResult { interface SensitiveFields { sexualActivity: boolean; protectedSex: boolean | null; - pregnancyTest: string | null; - progesteroneTest: string | null; - contraceptive: string | null; + pregnancyTest: HomeTestResult | null; + progesteroneTest: HomeTestResult | null; + contraceptive: ContraceptiveKind | null; } /** A symptom selection carrying its catalog key + optional 1-4 severity. */ @@ -146,20 +156,20 @@ function decryptNoteSoft(notesEncrypted: string | null): string | null { type ExistingRow = { id: string; date: string; - flow: string | null; + flow: FlowLevel | null; intermenstrualBleeding: boolean; basalBodyTempC: number | null; temperatureExcluded: boolean; - ovulationTest: string | null; - cervicalMucus: string | null; - cervixPosition: string | null; - cervixFirmness: string | null; - cervixOpening: string | null; + ovulationTest: OvulationTest | null; + cervicalMucus: CervicalMucus | null; + cervixPosition: CervixPosition | null; + cervixFirmness: CervixFirmness | null; + cervixOpening: CervixOpening | null; sexualActivity: boolean; protectedSex: boolean | null; - pregnancyTest: string | null; - progesteroneTest: string | null; - contraceptive: string | null; + pregnancyTest: HomeTestResult | null; + progesteroneTest: HomeTestResult | null; + contraceptive: ContraceptiveKind | null; sensitiveEncrypted: string | null; notesEncrypted: string | null; deletedAt: Date | null; @@ -418,20 +428,20 @@ async function writeDayLog(args: WriteArgs): Promise { : { ...resolvedSensitive }; const baseData: Prisma.CycleDayLogUncheckedUpdateInput = { - flow: flow as never, + flow, intermenstrualBleeding, basalBodyTempC, temperatureExcluded, - ovulationTest: ovulationTest as never, - cervicalMucus: cervicalMucus as never, - cervixPosition: cervixPosition as never, - cervixFirmness: cervixFirmness as never, - cervixOpening: cervixOpening as never, + ovulationTest, + cervicalMucus, + cervixPosition, + cervixFirmness, + cervixOpening, sexualActivity: sensitivePlaintext.sexualActivity, protectedSex: sensitivePlaintext.protectedSex, - pregnancyTest: sensitivePlaintext.pregnancyTest as never, - progesteroneTest: sensitivePlaintext.progesteroneTest as never, - contraceptive: sensitivePlaintext.contraceptive as never, + pregnancyTest: sensitivePlaintext.pregnancyTest, + progesteroneTest: sensitivePlaintext.progesteroneTest, + contraceptive: sensitivePlaintext.contraceptive, sensitiveEncrypted, notesEncrypted, }; @@ -489,20 +499,20 @@ async function writeDayLog(args: WriteArgs): Promise { source, externalId: entry.externalId ?? null, cycleId, - flow: baseData.flow as never, - intermenstrualBleeding: baseData.intermenstrualBleeding as never, - basalBodyTempC: baseData.basalBodyTempC as never, - temperatureExcluded: baseData.temperatureExcluded as never, - ovulationTest: baseData.ovulationTest as never, - cervicalMucus: baseData.cervicalMucus as never, - cervixPosition: baseData.cervixPosition as never, - cervixFirmness: baseData.cervixFirmness as never, - cervixOpening: baseData.cervixOpening as never, + flow, + intermenstrualBleeding, + basalBodyTempC, + temperatureExcluded, + ovulationTest, + cervicalMucus, + cervixPosition, + cervixFirmness, + cervixOpening, sexualActivity: sensitivePlaintext.sexualActivity, protectedSex: sensitivePlaintext.protectedSex, - pregnancyTest: sensitivePlaintext.pregnancyTest as never, - progesteroneTest: sensitivePlaintext.progesteroneTest as never, - contraceptive: sensitivePlaintext.contraceptive as never, + pregnancyTest: sensitivePlaintext.pregnancyTest, + progesteroneTest: sensitivePlaintext.progesteroneTest, + contraceptive: sensitivePlaintext.contraceptive, sensitiveEncrypted, notesEncrypted, }, diff --git a/src/lib/personal-records/pr-detection-worker.ts b/src/lib/personal-records/pr-detection-worker.ts index a50fd1767..644215d0c 100644 --- a/src/lib/personal-records/pr-detection-worker.ts +++ b/src/lib/personal-records/pr-detection-worker.ts @@ -45,7 +45,9 @@ import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority"; import { measurementTypeEnum } from "@/lib/validations/measurement"; import { getPRDirection, isPRTrackable } from "./pr-direction"; import { + Prisma, PersonalRecordDirection, + type MeasurementSource, type MeasurementType, type PrismaClient, } from "@/generated/prisma/client"; @@ -93,7 +95,7 @@ interface MeasurementCandidate { value: number; unit: string; measuredAt: Date; - source: string; + source: MeasurementSource; externalId: string | null; } @@ -103,7 +105,7 @@ interface WorkoutCandidate { startedAt: Date; durationSec: number; totalDistanceM: number | null; - source: string; + source: MeasurementSource; externalId: string | null; } @@ -298,7 +300,7 @@ export async function detectPersonalRecordsForUser( unit: best.unit, achievedAt: best.measuredAt, sourceMeasurementId: best.id, - source: best.source as never, + source: best.source, externalId: best.externalId, }, ], @@ -384,7 +386,7 @@ export async function detectPersonalRecordsForUser( unit: slot.unit, achievedAt: best.startedAt, sourceMeasurementId: null, - source: best.source as never, + source: best.source, externalId: best.externalId, }, ], @@ -614,15 +616,18 @@ async function findBestCumulativeDay( async function findBestWorkout( prisma: PrismaClient, - where: Record, + where: Prisma.WorkoutWhereInput, slot: WorkoutSlotDefinition, ): Promise { + const dir: Prisma.SortOrder = + slot.direction === PersonalRecordDirection.MAX ? "desc" : "asc"; + const orderBy: Prisma.WorkoutOrderByWithRelationInput = + slot.field === "durationSec" + ? { durationSec: dir } + : { totalDistanceM: dir }; const row = await prisma.workout.findFirst({ - where: where as never, - orderBy: - slot.direction === PersonalRecordDirection.MAX - ? ({ [slot.field]: "desc" } as never) - : ({ [slot.field]: "asc" } as never), + where, + orderBy, select: { id: true, sportType: true, From 55186ccd3e5c0f93ada1019f73166482cb99dba0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 01:56:54 +0200 Subject: [PATCH 27/33] feat(observability): fireAndForget breadcrumb for background side effects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Best-effort background work was swallowed with `.catch(() => {})` — a persistent failure (a broken reminder enqueue, a lost re-auth transition) left no annotate() or logger trail, against the project's own 'annotate() on every interesting path' rule. Add `fireAndForget(promise, { action })`: it annotates the rejection into the current wide event under a per-action meta key (so it rides the central redaction at emit time — the raw message never hits the console) and warns the action label only for failures that settle after the event flushed. Control flow is unchanged; the promise stays fire-and-forget. Adopt it at the meaningful sites: reminder-satisfy enqueues (measurement + labs writes and the five integration sync workers), the post-write safety-floor dispatch, and the consent audit-log write. The Withings re-auth mark stays awaited (its persisted state feeds the same response) but now breadcrumbs its failure instead of swallowing it. --- src/app/api/consent/ai/route.ts | 12 ++- src/app/api/labs/ocr/commit/route.ts | 5 +- src/app/api/labs/route.ts | 5 +- src/app/api/measurements/batch/route.ts | 5 +- src/app/api/measurements/route.ts | 67 +++++++++------- src/app/api/withings/status/route.ts | 17 ++++- src/lib/jobs/reminder/fitbit-sync.ts | 5 +- src/lib/jobs/reminder/google-health-sync.ts | 5 +- src/lib/jobs/reminder/poll-cohort.ts | 5 +- src/lib/jobs/reminder/whoop-sync.ts | 5 +- src/lib/jobs/reminder/withings-sync.ts | 13 +++- .../logging/__tests__/fire-and-forget.test.ts | 76 +++++++++++++++++++ src/lib/logging/fire-and-forget.ts | 51 +++++++++++++ 13 files changed, 228 insertions(+), 43 deletions(-) create mode 100644 src/lib/logging/__tests__/fire-and-forget.test.ts create mode 100644 src/lib/logging/fire-and-forget.ts diff --git a/src/app/api/consent/ai/route.ts b/src/app/api/consent/ai/route.ts index 8679be132..630a7aa86 100644 --- a/src/app/api/consent/ai/route.ts +++ b/src/app/api/consent/ai/route.ts @@ -9,6 +9,7 @@ */ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { apiError, apiSuccess, @@ -56,10 +57,13 @@ export const POST = apiHandler(async (request: NextRequest) => { // reconstruct "who minted this and when" even if the receipts table // is rebuilt from a backup. Fire-and-forget; audit failures must // never bubble into a user-facing 500. - auditLog("consent.ai.grant", { - userId: user.id, - details: { kind, receiptId: receipt.id }, - }).catch(() => {}); + fireAndForget( + auditLog("consent.ai.grant", { + userId: user.id, + details: { kind, receiptId: receipt.id }, + }), + { action: "consent.ai.audit" }, + ); annotate({ action: { name: "consent.ai.grant" }, diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts index 50a3dae50..4f7decad4 100644 --- a/src/app/api/labs/ocr/commit/route.ts +++ b/src/app/api/labs/ocr/commit/route.ts @@ -11,6 +11,7 @@ * `data` object is built field-by-field (no mass assignment). */ import { NextRequest } from "next/server"; +import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { @@ -210,7 +211,9 @@ async function commitOcrRows(request: NextRequest) { // A lab panel just landed — resolve any "annual blood panel" reminders now // rather than waiting on the cron. Fire-and-forget. if (inserted.length > 0) { - void enqueueReminderSatisfy(user.id).catch(() => {}); + fireAndForget(enqueueReminderSatisfy(user.id), { + action: "reminder.satisfy.enqueue", + }); } return apiSuccess({ inserted, skipped }); diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index 0d2ea94c7..1bab6a5fb 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -1,4 +1,5 @@ import { NextRequest } from "next/server"; +import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { @@ -271,7 +272,9 @@ async function postLabResult(request: NextRequest) { // v1.18.1 (D2) — eventful Lab↔Vorsorge satisfaction. A lab panel just // landed; resolve the user's free-text "annual blood panel" reminders // now rather than waiting on the 15-min cron. Fire-and-forget. - void enqueueReminderSatisfy(user.id).catch(() => {}); + fireAndForget(enqueueReminderSatisfy(user.id), { + action: "reminder.satisfy.enqueue", + }); return apiSuccess(serialiseLabResult(created, biomarker), 201); } diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 24e4e9131..0a49e997f 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -34,6 +34,7 @@ * contract. */ import { NextRequest } from "next/server"; +import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { z } from "zod/v4"; import { prisma } from "@/lib/db"; @@ -682,7 +683,9 @@ async function postBatch(request: NextRequest): Promise { // v1.18.1 — eventful Vorsorge satisfaction. A matching reading just // landed; resolve the user's reminders now rather than waiting on the // 15-min cron. Fire-and-forget — the cron reconciles on enqueue miss. - void enqueueReminderSatisfy(user.id).catch(() => {}); + fireAndForget(enqueueReminderSatisfy(user.id), { + action: "reminder.satisfy.enqueue", + }); try { await enqueuePrDetection(user.id, { silent }); await auditLog("personal_records.detection_enqueued", { diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 52fbac848..6aaf88a23 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -1,6 +1,7 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, @@ -820,7 +821,9 @@ async function postMeasurement(request: NextRequest) { // v1.18.1 — eventful Vorsorge satisfaction. Resolve the user's // reminders against the just-landed readings now. Fire-and-forget. - void enqueueReminderSatisfy(user.id).catch(() => {}); + fireAndForget(enqueueReminderSatisfy(user.id), { + action: "reminder.satisfy.enqueue", + }); // v1.18.6 — absolute clinical safety-floor check on the just-written // readings (confirm-gated, module-gated, never diagnoses). The combined @@ -828,19 +831,22 @@ async function postMeasurement(request: NextRequest) { // The transient per-entry `symptomsPresent` flag (ORed across the batch) // lifts a confirmed breach to the symptom-coupled emergency copy. // Fire-and-forget — a notification failure never fails the write. - void runSafetyFloorCheck({ - userId: user.id, - written: results.map((r) => ({ - type: r.type, - value: r.value, - measuredAt: r.measuredAt, - glucoseContext: r.glucoseContext, - })), - symptomsPresent: parsed.data.measurements.some( - (m) => m.symptomsPresent === true, - ), - timezone: user.timezone ?? undefined, - }).catch(() => {}); + fireAndForget( + runSafetyFloorCheck({ + userId: user.id, + written: results.map((r) => ({ + type: r.type, + value: r.value, + measuredAt: r.measuredAt, + glucoseContext: r.glucoseContext, + })), + symptomsPresent: parsed.data.measurements.some( + (m) => m.symptomsPresent === true, + ), + timezone: user.timezone ?? undefined, + }), + { action: "measurement.safety_floor.check" }, + ); // v1.5.0 — refresh the persistent rollup table for every distinct // (type, day) the batch touched so the next analytics / coach read @@ -967,7 +973,9 @@ async function postMeasurement(request: NextRequest) { // v1.18.1 — eventful Vorsorge satisfaction. Resolve the user's reminders // against the just-landed reading now. Fire-and-forget. - void enqueueReminderSatisfy(user.id).catch(() => {}); + fireAndForget(enqueueReminderSatisfy(user.id), { + action: "reminder.satisfy.enqueue", + }); // v1.18.6 — absolute clinical safety-floor check (confirm-gated, // module-gated, never diagnoses). A single-entry POST carries only one arm @@ -975,19 +983,22 @@ async function postMeasurement(request: NextRequest) { // batch path; a lone glucose reading is evaluated here. The transient // `symptomsPresent` flag lifts a confirmed breach to the emergency copy. // Fire-and-forget — a notification failure never fails the write. - void runSafetyFloorCheck({ - userId: user.id, - written: [ - { - type: measurement.type, - value: measurement.value, - measuredAt: measurement.measuredAt, - glucoseContext: measurement.glucoseContext, - }, - ], - symptomsPresent, - timezone: user.timezone ?? undefined, - }).catch(() => {}); + fireAndForget( + runSafetyFloorCheck({ + userId: user.id, + written: [ + { + type: measurement.type, + value: measurement.value, + measuredAt: measurement.measuredAt, + glucoseContext: measurement.glucoseContext, + }, + ], + symptomsPresent, + timezone: user.timezone ?? undefined, + }), + { action: "measurement.safety_floor.check" }, + ); // v1.5.0 — refresh the persistent rollup row for the affected // (type, day) tuple. Runs inline so the next read of the diff --git a/src/app/api/withings/status/route.ts b/src/app/api/withings/status/route.ts index 68c306513..7e4311481 100644 --- a/src/app/api/withings/status/route.ts +++ b/src/app/api/withings/status/route.ts @@ -83,7 +83,22 @@ export const GET = apiHandler(async () => { // the sync path does. const message = error instanceof Error ? error.message : String(error); if (isWithingsRefreshReauthFailure(message)) { - await markReauthRequired(user.id, "withings", message).catch(() => {}); + // Awaited so the persisted re-auth state is reflected in this same + // status response — but a persistence failure must not fail the read. + // Breadcrumb it instead of swallowing silently (the message rides the + // central annotate/redact path, never the raw console). + await markReauthRequired(user.id, "withings", message).catch( + (error: unknown) => { + annotate({ + meta: { + "fire_and_forget.integration.reauth.mark": { + error: error instanceof Error ? error.message : String(error), + provider: "withings", + }, + }, + }); + }, + ); } } } diff --git a/src/lib/jobs/reminder/fitbit-sync.ts b/src/lib/jobs/reminder/fitbit-sync.ts index b591bfcd9..d9a45b895 100644 --- a/src/lib/jobs/reminder/fitbit-sync.ts +++ b/src/lib/jobs/reminder/fitbit-sync.ts @@ -5,6 +5,7 @@ * schedules, and boss.work registrations. */ import { type Job } from "pg-boss"; +import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { recordError } from "@/lib/jobs/worker-status"; import { withBackgroundEvent } from "@/lib/logging/background"; import { runFitbitPollCohort } from "@/lib/fitbit/sync"; @@ -50,7 +51,9 @@ export async function handleFitbitSync(jobs: Job[]) { // reminders eventfully. Fire-and-forget; the cron is the net. onUserSynced: (userId, imported) => { if (imported > 0) { - void enqueueReminderSatisfy(userId).catch(() => {}); + fireAndForget(enqueueReminderSatisfy(userId), { + action: "reminder.satisfy.enqueue", + }); } }, }, diff --git a/src/lib/jobs/reminder/google-health-sync.ts b/src/lib/jobs/reminder/google-health-sync.ts index 66b921332..0006f5ca8 100644 --- a/src/lib/jobs/reminder/google-health-sync.ts +++ b/src/lib/jobs/reminder/google-health-sync.ts @@ -5,6 +5,7 @@ * queue names, cron schedules, and boss.work registrations. */ import { type Job } from "pg-boss"; +import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { recordError } from "@/lib/jobs/worker-status"; import { withBackgroundEvent } from "@/lib/logging/background"; import { runGoogleHealthPollCohort } from "@/lib/google-health/sync"; @@ -56,7 +57,9 @@ export async function handleGoogleHealthSync( // eventfully. Fire-and-forget; the cron is the net. onUserSynced: (userId, imported) => { if (imported > 0) { - void enqueueReminderSatisfy(userId).catch(() => {}); + fireAndForget(enqueueReminderSatisfy(userId), { + action: "reminder.satisfy.enqueue", + }); } }, }, diff --git a/src/lib/jobs/reminder/poll-cohort.ts b/src/lib/jobs/reminder/poll-cohort.ts index a0e086aca..2e716552b 100644 --- a/src/lib/jobs/reminder/poll-cohort.ts +++ b/src/lib/jobs/reminder/poll-cohort.ts @@ -18,6 +18,7 @@ * from this sequential pass. */ import { type Job } from "pg-boss"; +import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { recordError } from "@/lib/jobs/worker-status"; import { withBackgroundEvent } from "@/lib/logging/background"; @@ -76,7 +77,9 @@ export function makePollCohortHandler({ // v1.18.1 — a fresh reading landed; resolve this user's Vorsorge // reminders eventfully. Fire-and-forget; the cron is the net. if (imported > 0) { - void enqueueReminderSatisfy(userId).catch(() => {}); + fireAndForget(enqueueReminderSatisfy(userId), { + action: "reminder.satisfy.enqueue", + }); } } catch (err) { evt.addWarning(`${taskName} failed for user ${userId}: ${err}`); diff --git a/src/lib/jobs/reminder/whoop-sync.ts b/src/lib/jobs/reminder/whoop-sync.ts index 92a1e2ec3..130740e65 100644 --- a/src/lib/jobs/reminder/whoop-sync.ts +++ b/src/lib/jobs/reminder/whoop-sync.ts @@ -5,6 +5,7 @@ * schedules, and boss.work registrations. */ import { type Job } from "pg-boss"; +import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { recordError } from "@/lib/jobs/worker-status"; import { withBackgroundEvent } from "@/lib/logging/background"; import { @@ -101,7 +102,9 @@ export async function runWhoopResourceSync( // v1.18.1 — a fresh reading landed; resolve this user's Vorsorge // reminders eventfully. Fire-and-forget; the cron is the net. if (imported > 0) { - void enqueueReminderSatisfy(userId).catch(() => {}); + fireAndForget(enqueueReminderSatisfy(userId), { + action: "reminder.satisfy.enqueue", + }); } } catch (err) { evt.addWarning(`${taskName} failed for user ${userId}: ${err}`); diff --git a/src/lib/jobs/reminder/withings-sync.ts b/src/lib/jobs/reminder/withings-sync.ts index 0ebf2d182..24320b06d 100644 --- a/src/lib/jobs/reminder/withings-sync.ts +++ b/src/lib/jobs/reminder/withings-sync.ts @@ -5,6 +5,7 @@ * schedules, and boss.work registrations. */ import { type Job } from "pg-boss"; +import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { recordError, recordWithingsSync } from "@/lib/jobs/worker-status"; import { withBackgroundEvent } from "@/lib/logging/background"; import { syncUserMeasurements } from "@/lib/withings/sync"; @@ -68,7 +69,9 @@ export async function handleWithingsFallbackSync( // v1.18.1 — a fresh reading landed; resolve this user's Vorsorge // reminders eventfully. Fire-and-forget; the cron is the net. if (imported > 0) { - void enqueueReminderSatisfy(connection.userId).catch(() => {}); + fireAndForget(enqueueReminderSatisfy(connection.userId), { + action: "reminder.satisfy.enqueue", + }); } } catch (err) { evt.addWarning( @@ -138,7 +141,9 @@ export async function handleWithingsActivitySync( usersSynced++; measurementsImported += imported; if (imported > 0) { - void enqueueReminderSatisfy(userId).catch(() => {}); + fireAndForget(enqueueReminderSatisfy(userId), { + action: "reminder.satisfy.enqueue", + }); } } catch (err) { evt.addWarning( @@ -196,7 +201,9 @@ export async function handleWithingsSleepSync( usersSynced++; measurementsImported += imported; if (imported > 0) { - void enqueueReminderSatisfy(userId).catch(() => {}); + fireAndForget(enqueueReminderSatisfy(userId), { + action: "reminder.satisfy.enqueue", + }); } } catch (err) { evt.addWarning( diff --git a/src/lib/logging/__tests__/fire-and-forget.test.ts b/src/lib/logging/__tests__/fire-and-forget.test.ts new file mode 100644 index 000000000..a0fbb3e23 --- /dev/null +++ b/src/lib/logging/__tests__/fire-and-forget.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fireAndForget } from "../fire-and-forget"; +import { eventStorage } from "../context"; +import { WideEventBuilder } from "../event-builder"; + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("fireAndForget", () => { + it("does not throw and does not reject when the promise resolves", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + fireAndForget(Promise.resolve("ok"), { action: "test.noop.run" }); + await flush(); + expect(warn).not.toHaveBeenCalled(); + }); + + it("annotates the current wide event with the redacted error breadcrumb", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + const builder = new WideEventBuilder(); + + await eventStorage.run(builder, async () => { + fireAndForget(Promise.reject(new Error("boom")), { + action: "reminder.satisfy.enqueue", + meta: { userScope: "u_1" }, + }); + await flush(); + }); + + const event = builder.toJSON(); + const crumb = event.meta?.["fire_and_forget.reminder.satisfy.enqueue"] as + { error: string; userScope?: string } | undefined; + expect(crumb).toBeDefined(); + expect(crumb?.error).toBe("boom"); + expect(crumb?.userScope).toBe("u_1"); + }); + + it("warns with the action label only — never the error payload", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + fireAndForget(Promise.reject(new Error("secret-token-xyz")), { + action: "integration.reauth.mark", + }); + await flush(); + expect(warn).toHaveBeenCalledTimes(1); + const logged = warn.mock.calls[0].join(" "); + expect(logged).toContain("integration.reauth.mark"); + expect(logged).not.toContain("secret-token-xyz"); + }); + + it("is a safe no-op outside a request context", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(() => { + fireAndForget(Promise.reject(new Error("no context")), { + action: "test.orphan.run", + }); + }).not.toThrow(); + await flush(); + }); + + it("coerces a non-Error rejection to a string message", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + const builder = new WideEventBuilder(); + await eventStorage.run(builder, async () => { + fireAndForget(Promise.reject("plain-string"), { + action: "test.reject.string", + }); + await flush(); + }); + const crumb = builder.toJSON().meta?.[ + "fire_and_forget.test.reject.string" + ] as { error: string } | undefined; + expect(crumb?.error).toBe("plain-string"); + }); +}); diff --git a/src/lib/logging/fire-and-forget.ts b/src/lib/logging/fire-and-forget.ts new file mode 100644 index 000000000..93fdc0fb7 --- /dev/null +++ b/src/lib/logging/fire-and-forget.ts @@ -0,0 +1,51 @@ +import { annotate } from "./context"; + +/** + * Run a best-effort background promise and leave an observable breadcrumb + * when it rejects — the disciplined replacement for `.catch(() => {})`, which + * swallows a background failure with no `annotate()` or logger call. + * + * Use it for work whose failure must not fail the request but SHOULD be + * visible: background enqueues (reminder satisfaction, rollup refresh), + * re-auth state transitions, and post-write sync side effects. + * + * Observability without a control-flow change: + * - The rejection reason is annotated into the current wide event under a + * per-action `meta` key, so it flows through the SAME central redaction + * the event builder applies at emit time — the raw message is never + * logged unredacted. When the work settles after the event has already + * flushed (or runs outside a request context, e.g. a pg-boss worker), + * `annotate()` is a safe no-op. + * - A payload-free `console.warn` (SWC keeps `warn` in prod) records the + * action name so a late-settling failure still surfaces in the logs. + * Only the caller-supplied action label — never the error text — reaches + * the raw console sink, keeping it redaction-safe by construction. + * + * The promise stays fire-and-forget: the caller does not await it and its + * resolution is discarded exactly as before. + * + * @param promise The background work. May already be running. + * @param context.action A stable `..` label (never user + * input) identifying which background task failed. + * @param context.meta Optional extra fields to attach to the breadcrumb; they + * ride the annotate/redact path with the error message. + */ +export function fireAndForget( + promise: Promise, + context: { action: string; meta?: Record }, +): void { + void Promise.resolve(promise).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + annotate({ + meta: { + [`fire_and_forget.${context.action}`]: { + error: message, + ...context.meta, + }, + }, + }); + // Action label only — the error payload rides the redacted annotate path + // above, so nothing sensitive reaches the raw console sink. + console.warn(`[fire-and-forget] background task failed: ${context.action}`); + }); +} From 3dc6c7436ffbbbd020b3671df6a4757acf75974f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 02:01:37 +0200 Subject: [PATCH 28/33] fix(analytics): replace NUL-byte map-key separators with a visible one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two in-memory key builders joined their parts with a literal NUL (`${a}\x00${b}`): the sleep writer-bucket key and the correlation pair key. The embedded NUL made grep classify both files as binary, so a plain `grep symbol src/**` skipped them silently. Swap the separator for U+241F (␟, the unit separator). It cannot appear in a `MeasurementSource` enum literal, a device-type tag, or a correlation-channel name, so the key semantics are identical — the sleep key still slices its source prefix unambiguously and the correlation Set still excludes double-counts. Both keys are ephemeral (one pass, never persisted), so the byte change is invisible to any stored state. --- src/lib/analytics/sleep-night.ts | 7 ++++--- src/lib/insights/correlation-discovery.ts | 10 +++++++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts index d5b530aea..d3a2d58ea 100644 --- a/src/lib/analytics/sleep-night.ts +++ b/src/lib/analytics/sleep-night.ts @@ -309,10 +309,11 @@ const RECONSTRUCTED_TIMELINE_SOURCES: ReadonlySet = new Set([ /** * Separator between the source and device-type parts of a writer key. - * `` cannot appear in a `MeasurementSource` enum literal or a - * device-type tag, so the split-back is unambiguous. + * The unit-separator character (U+241F) cannot appear in a + * `MeasurementSource` enum literal or a device-type tag, so the + * split-back is unambiguous. */ -const WRITER_KEY_SEP = ""; +const WRITER_KEY_SEP = "\u241F"; /** * Writer-bucket key of a stage row: the source, refined by the device-type diff --git a/src/lib/insights/correlation-discovery.ts b/src/lib/insights/correlation-discovery.ts index 279944fd7..b7bd338b4 100644 --- a/src/lib/insights/correlation-discovery.ts +++ b/src/lib/insights/correlation-discovery.ts @@ -596,9 +596,17 @@ export function filterSeriesToWindow( })); } +/** + * Separator joining the two channel names into a stable pair key. The + * unit-separator character (U+241F) cannot appear in a correlation-channel + * name, so two distinct pairs never collide. The key is used only for Set + * membership (never split back), and lives for one discovery pass. + */ +const PAIR_KEY_SEP = "\u241F"; + /** Stable pair identity for double-count exclusion. */ function pairKey(behaviour: string, outcome: string): string { - return `${behaviour}${outcome}`; + return `${behaviour}${PAIR_KEY_SEP}${outcome}`; } export interface EmergingCorrelationResult { From 837c44e4c2960aae49a68aedb8c3501b02ae810f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 02:05:25 +0200 Subject: [PATCH 29/33] =?UTF-8?q?test(migrations):=20seed=E2=86=92apply?= =?UTF-8?q?=E2=86=92assert=20harness=20for=20the=20workout-sport=20backfil?= =?UTF-8?q?ls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The raw-SQL data backfills 0247 (WHOOP) and 0251 (Strava) rewrite historical Workout.sport_type rows with no test — the thinnest-covered class in the tree (audits L2). Add an integration test that seeds pre-backfill rows, applies the EXACT migration SQL from disk against the testcontainer Postgres, and asserts: - canonical relabel across both value shapes (id placeholder + raw label), case/normalisation, unknown→other fallback; - the source guard (a WHOOP pass leaves Strava/MANUAL rows verbatim and vice-versa); - idempotency (a second apply reports zero affected rows). Runs in CI's integration job; locally needs Docker/OrbStack via pnpm test:integration. --- .../backfill-workout-sport.test.ts | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 tests/integration/backfill-workout-sport.test.ts diff --git a/tests/integration/backfill-workout-sport.test.ts b/tests/integration/backfill-workout-sport.test.ts new file mode 100644 index 000000000..68cb3ead4 --- /dev/null +++ b/tests/integration/backfill-workout-sport.test.ts @@ -0,0 +1,214 @@ +/** + * Integration coverage for the raw-SQL workout-sport backfills: + * - `0247_backfill_whoop_workout_sport` + * - `0251_backfill_strava_workout_sport` + * + * These two data migrations rewrite historical `Workout.sport_type` rows + * that predate the write-time canonical mappers (`mapWhoopSportType` / + * `mapStravaSportType`). They are the thinnest-tested class in the tree — + * one-shot SQL that rewrites real rows with no seed→apply→assert harness + * (data-integrity audit L2 / senior-dev audit L2). This test seeds + * pre-backfill rows, applies the EXACT migration SQL from disk, and asserts + * the canonical relabel, the source guard, and idempotency. + * + * Runs against the testcontainer Postgres so the real `regexp_replace` / + * `lower` / `trim` normalisation the migrations rely on is exercised + * end-to-end. 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 { + MeasurementSource, + PrismaClient, +} from "@/generated/prisma/client"; + +const migrationSql = (name: string): string => + readFileSync( + resolve(process.cwd(), "prisma", "migrations", name, "migration.sql"), + "utf8", + ); + +const WHOOP_BACKFILL_SQL = migrationSql("0247_backfill_whoop_workout_sport"); +const STRAVA_BACKFILL_SQL = migrationSql("0251_backfill_strava_workout_sport"); + +let seedCounter = 0; + +async function seedWorkout( + prisma: PrismaClient, + userId: string, + source: MeasurementSource, + sportType: string, +): Promise { + seedCounter += 1; + const startedAt = new Date( + Date.UTC(2026, 0, 1, 6, 0, 0) + seedCounter * 60_000, + ); + const row = await prisma.workout.create({ + data: { + userId, + source, + sportType, + externalId: `seed-${source}-${seedCounter}`, + startedAt, + endedAt: new Date(startedAt.getTime() + 30 * 60_000), + durationSec: 30 * 60, + }, + select: { id: true }, + }); + return row.id; +} + +async function sportOf(prisma: PrismaClient, id: string): Promise { + const row = await prisma.workout.findUniqueOrThrow({ + where: { id }, + select: { sportType: true }, + }); + return row.sportType; +} + +describe("workout-sport backfill migrations — integration", () => { + beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + seedCounter = 0; + }); + + async function makeUser(prisma: PrismaClient, tag: string): Promise { + const user = await prisma.user.create({ + data: { + username: `backfill-${tag}`, + email: `backfill-${tag}@example.test`, + role: "USER", + }, + }); + return user.id; + } + + it("0247 relabels WHOOP rows to canonical sports and leaves other sources alone", async () => { + const prisma = getPrismaClient(); + const userId = await makeUser(prisma, "whoop"); + + // Pre-backfill WHOOP rows in both shapes the fix targets. + const placeholderCycling = await seedWorkout( + prisma, + userId, + "WHOOP", + "whoop_sport_1", // numeric-id placeholder → cycling + ); + const rawNameSpin = await seedWorkout( + prisma, + userId, + "WHOOP", + "spin", // raw sport_name → cycling + ); + const mixedCaseRun = await seedWorkout( + prisma, + userId, + "WHOOP", + "Running", // case/normalisation → running + ); + const unknownArm = await seedWorkout( + prisma, + userId, + "WHOOP", + "quidditch", // unrecognised → ELSE 'other' + ); + const alreadyCanonical = await seedWorkout( + prisma, + userId, + "WHOOP", + "cycling", // already canonical — excluded by the WHERE guard + ); + // A Strava row with a raw label must NOT be touched by the WHOOP pass. + const stravaUntouched = await seedWorkout(prisma, userId, "STRAVA", "Ride"); + + await prisma.$executeRawUnsafe(WHOOP_BACKFILL_SQL); + + expect(await sportOf(prisma, placeholderCycling)).toBe("cycling"); + expect(await sportOf(prisma, rawNameSpin)).toBe("cycling"); + expect(await sportOf(prisma, mixedCaseRun)).toBe("running"); + expect(await sportOf(prisma, unknownArm)).toBe("other"); + expect(await sportOf(prisma, alreadyCanonical)).toBe("cycling"); + // Source guard: the Strava row is left verbatim for its own migration. + expect(await sportOf(prisma, stravaUntouched)).toBe("Ride"); + }); + + it("0247 is idempotent — a second apply touches zero rows", async () => { + const prisma = getPrismaClient(); + const userId = await makeUser(prisma, "whoop-idem"); + await seedWorkout(prisma, userId, "WHOOP", "whoop_sport_1"); + await seedWorkout(prisma, userId, "WHOOP", "spin"); + await seedWorkout(prisma, userId, "WHOOP", "quidditch"); + + const firstAffected = await prisma.$executeRawUnsafe(WHOOP_BACKFILL_SQL); + expect(firstAffected).toBe(3); + + const secondAffected = await prisma.$executeRawUnsafe(WHOOP_BACKFILL_SQL); + expect(secondAffected).toBe(0); + }); + + it("0251 relabels STRAVA rows to canonical sports and leaves other sources alone", async () => { + const prisma = getPrismaClient(); + const userId = await makeUser(prisma, "strava"); + + const ride = await seedWorkout(prisma, userId, "STRAVA", "Ride"); + const virtualRide = await seedWorkout( + prisma, + userId, + "STRAVA", + "VirtualRide", // non-alnum stripped → virtualride → cycling + ); + const trailRun = await seedWorkout(prisma, userId, "STRAVA", "TrailRun"); + const literalWorkout = await seedWorkout( + prisma, + userId, + "STRAVA", + "workout", // pre-fix literal fallback → other + ); + const unknownArm = await seedWorkout(prisma, userId, "STRAVA", "Quidditch"); + const alreadyCanonical = await seedWorkout( + prisma, + userId, + "STRAVA", + "running", + ); + // A WHOOP placeholder must NOT be touched by the Strava pass. + const whoopUntouched = await seedWorkout( + prisma, + userId, + "WHOOP", + "whoop_sport_1", + ); + // A MANUAL row with a raw-looking label must be left alone entirely. + const manualUntouched = await seedWorkout(prisma, userId, "MANUAL", "Ride"); + + await prisma.$executeRawUnsafe(STRAVA_BACKFILL_SQL); + + expect(await sportOf(prisma, ride)).toBe("cycling"); + expect(await sportOf(prisma, virtualRide)).toBe("cycling"); + expect(await sportOf(prisma, trailRun)).toBe("running"); + expect(await sportOf(prisma, literalWorkout)).toBe("other"); + expect(await sportOf(prisma, unknownArm)).toBe("other"); + expect(await sportOf(prisma, alreadyCanonical)).toBe("running"); + // Source guards. + expect(await sportOf(prisma, whoopUntouched)).toBe("whoop_sport_1"); + expect(await sportOf(prisma, manualUntouched)).toBe("Ride"); + }); + + it("0251 is idempotent — a second apply touches zero rows", async () => { + const prisma = getPrismaClient(); + const userId = await makeUser(prisma, "strava-idem"); + await seedWorkout(prisma, userId, "STRAVA", "Ride"); + await seedWorkout(prisma, userId, "STRAVA", "VirtualRide"); + await seedWorkout(prisma, userId, "STRAVA", "Quidditch"); + + const firstAffected = await prisma.$executeRawUnsafe(STRAVA_BACKFILL_SQL); + expect(firstAffected).toBe(3); + + const secondAffected = await prisma.$executeRawUnsafe(STRAVA_BACKFILL_SQL); + expect(secondAffected).toBe(0); + }); +}); From 3dd5dbeb42ed9c4c1cd70010cd8147a08f987067 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 01:58:19 +0200 Subject: [PATCH 30/33] feat(export): make the full backup actually full MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full-backup export (plaintext + passphrase-encrypted, and the weekly auto-backup) only ever covered measurements, medications, mood, and cycle data. Labs, illness episodes, allergies, family history, and workouts were silently missing, and the export UI implied "everything" was included. Adds a shared records-backup section (src/lib/export/records-backup.ts) that both the on-demand export routes and the weekly data-backup worker read from: lab results + the biomarker catalog, illness episodes (including flares via parentConditionId) with their day-logs, allergies, family history, and workout summaries. Every read is owner-scoped and soft-delete-filtered; encrypted free-text columns decrypt fail-soft so one bad row never aborts the whole backup. Document original files and workout GPS/per-sample time series stay deliberately out of the JSON payload (size). The payload now carries an explicit manifest disclosing exactly that, and the export UI states it in plain text instead of implying completeness. These new domains are export-only for now — admin restore still only recreates the original measurements/medications/mood/cycle domains; the payload validates and reports counts for the rest, but restoring a snapshot does not yet recreate them. Also fixes the admin backup-upload total to count every domain, not just the original four. --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/pl.json | 3 +- .../api/admin/backups/[id]/restore/route.ts | 6 + src/app/api/admin/backups/upload/route.ts | 8 + .../export/__tests__/per-type-routes.test.ts | 118 ++++++- .../__tests__/soft-delete-filter.test.ts | 66 ++++ .../export/encrypted/__tests__/route.test.ts | 21 ++ src/app/api/export/encrypted/route.ts | 15 +- src/app/api/export/full-backup/route.ts | 22 +- src/components/admin/backups-section.tsx | 20 +- src/components/settings/export-section.tsx | 11 + .../export/__tests__/records-backup.test.ts | 316 +++++++++++++++++ src/lib/export/full-backup-payload.ts | 23 +- src/lib/export/records-backup.ts | 334 ++++++++++++++++++ src/lib/jobs/reminder/backup-handlers.ts | 128 ++++--- src/lib/labs/store.ts | 24 ++ src/lib/validations/__tests__/backup.test.ts | 237 ++++++++++++- src/lib/validations/backup.ts | 192 ++++++++++ 22 files changed, 1491 insertions(+), 68 deletions(-) create mode 100644 src/lib/export/__tests__/records-backup.test.ts create mode 100644 src/lib/export/records-backup.ts diff --git a/messages/de.json b/messages/de.json index b54aa1a08..6a3bb382f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4386,7 +4386,8 @@ }, "fullBackup": { "title": "Voll-Backup", - "description": "Eine einzige JSON-Datei mit allen Daten — gleiches Format wie das wöchentliche Auto-Backup. Praktisch zum Selbst-Wiederherstellen via Admin-Upload.", + "description": "Alle deine Daten in einer JSON-Datei — Messwerte, Medikamente, Stimmung, Zyklus, Laborwerte, Krankheitsepisoden, Allergien, Familienanamnese und Workouts. Gleiches Format wie das wöchentliche Auto-Backup.", + "scopeNote": "Die Originaldateien der Dokumente sowie GPS-Routen und Einzelmesswerte von Workouts sind nicht enthalten — lade diese separat aus dem Dokumententresor bzw. dem Workout-Export herunter.", "encryptToggle": "Mit einer Passphrase verschlüsseln", "passphrasePlaceholder": "Passphrase (mind. 12 Zeichen)", "passphraseHelp": "Leitet den Archivschlüssel per Argon2id ab und versiegelt ihn mit AES-256-GCM. Speichere die Datei mit der Endung .hlx.", diff --git a/messages/en.json b/messages/en.json index d24bcfc2d..2edd6c69f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4386,7 +4386,8 @@ }, "fullBackup": { "title": "Full backup", - "description": "Single JSON file with everything — same shape as the weekly auto-backup. Useful for self-restore via admin upload.", + "description": "Every record you own in one JSON file — measurements, medications, mood, cycle, labs, illness episodes, allergies, family history, and workouts. Same shape as the weekly auto-backup.", + "scopeNote": "Original document files and workout GPS/sample data are not included — download those separately from the vault or the workout export.", "encryptToggle": "Encrypt with a passphrase", "passphrasePlaceholder": "Passphrase (min. 12 characters)", "passphraseHelp": "Derives the archive key with Argon2id and seals it with AES-256-GCM. Save the file with a .hlx extension.", diff --git a/messages/es.json b/messages/es.json index ce0861be5..fdc46ff71 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4386,7 +4386,8 @@ }, "fullBackup": { "title": "Copia completa", - "description": "Un único archivo JSON con todo — el mismo formato que la copia automática semanal. Útil para restaurar tú mismo mediante la carga de administrador.", + "description": "Todos tus datos en un único archivo JSON: mediciones, medicamentos, estado de ánimo, ciclo, analíticas, episodios de enfermedad, alergias, antecedentes familiares y entrenamientos. Mismo formato que la copia automática semanal.", + "scopeNote": "Los archivos originales de los documentos y los datos de rutas GPS/muestras de los entrenamientos no están incluidos — descárgalos por separado desde el archivo de documentos o la exportación de entrenamientos.", "encryptToggle": "Cifrar con una contraseña", "passphrasePlaceholder": "Contraseña (mín. 12 caracteres)", "passphraseHelp": "Deriva la clave del archivo con Argon2id y lo sella con AES-256-GCM. Guarda el archivo con la extensión .hlx.", diff --git a/messages/fr.json b/messages/fr.json index 4dfd3c433..a476e572d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4386,7 +4386,8 @@ }, "fullBackup": { "title": "Sauvegarde complète", - "description": "Un seul fichier JSON contenant tout — même format que la sauvegarde automatique hebdomadaire. Utile pour une restauration personnelle via le téléversement administrateur.", + "description": "Toutes vos données dans un seul fichier JSON : mesures, médicaments, humeur, cycle, analyses de laboratoire, épisodes de maladie, allergies, antécédents familiaux et entraînements. Même format que la sauvegarde automatique hebdomadaire.", + "scopeNote": "Les fichiers originaux des documents ainsi que les tracés GPS/données d'échantillons des entraînements ne sont pas inclus — téléchargez-les séparément depuis le coffre de documents ou l'export des entraînements.", "encryptToggle": "Chiffrer avec une phrase secrète", "passphrasePlaceholder": "Phrase secrète (12 caractères min.)", "passphraseHelp": "Dérive la clé de l'archive avec Argon2id et la scelle avec AES-256-GCM. Enregistrez le fichier avec l'extension .hlx.", diff --git a/messages/it.json b/messages/it.json index 3a34e2792..074ac3ad0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4386,7 +4386,8 @@ }, "fullBackup": { "title": "Backup completo", - "description": "Un unico file JSON con tutto — stesso formato del backup automatico settimanale. Utile per il ripristino autonomo tramite il caricamento da amministratore.", + "description": "Tutti i tuoi dati in un unico file JSON: misurazioni, farmaci, umore, ciclo, esami di laboratorio, episodi di malattia, allergie, anamnesi familiare e allenamenti. Stesso formato del backup automatico settimanale.", + "scopeNote": "I file originali dei documenti e i dati di percorso GPS/campionamento degli allenamenti non sono inclusi — scaricali separatamente dall'archivio documenti o dall'esportazione degli allenamenti.", "encryptToggle": "Cifra con una passphrase", "passphrasePlaceholder": "Passphrase (min. 12 caratteri)", "passphraseHelp": "Deriva la chiave dell'archivio con Argon2id e lo sigilla con AES-256-GCM. Salva il file con estensione .hlx.", diff --git a/messages/pl.json b/messages/pl.json index 51652f638..07a0c8c46 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4386,7 +4386,8 @@ }, "fullBackup": { "title": "Pełna kopia zapasowa", - "description": "Pojedynczy plik JSON ze wszystkim — taki sam format jak cotygodniowa automatyczna kopia. Przydatny do samodzielnego przywracania przez przesłanie w panelu administratora.", + "description": "Wszystkie Twoje dane w jednym pliku JSON — pomiary, leki, nastrój, cykl, wyniki badań laboratoryjnych, epizody chorobowe, alergie, wywiad rodzinny i treningi. Taki sam format jak cotygodniowa automatyczna kopia.", + "scopeNote": "Oryginalne pliki dokumentów oraz dane tras GPS/próbek treningów nie są uwzględnione — pobierz je osobno z archiwum dokumentów lub eksportu treningów.", "encryptToggle": "Zaszyfruj hasłem-frazą", "passphrasePlaceholder": "Hasło-fraza (min. 12 znaków)", "passphraseHelp": "Wyprowadza klucz archiwum za pomocą Argon2id i pieczętuje je AES-256-GCM. Zapisz plik z rozszerzeniem .hlx.", diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 3b6d6cfb1..81c99dd63 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -57,6 +57,12 @@ export const dynamic = "force-dynamic"; interface RestoreResponse { restored: true; + // v1.28 — `summary` reports every domain PRESENT in the uploaded file + // (informational — it now includes labs/illness/allergies/family-history/ + // workouts/documents counts too). `cleared` below is the narrower, + // authoritative list of what this restore actually wiped + recreated — + // those newer domains are export-only for now and are NOT touched by a + // restore, so they are deliberately absent from `cleared`. summary: BackupSummary; cleared: { measurements: number; diff --git a/src/app/api/admin/backups/upload/route.ts b/src/app/api/admin/backups/upload/route.ts index 9457cd99d..28012e04b 100644 --- a/src/app/api/admin/backups/upload/route.ts +++ b/src/app/api/admin/backups/upload/route.ts @@ -218,6 +218,14 @@ export const POST = apiHandler(async (request: NextRequest) => { moodEntries: summary.moodEntries, cycles: summary.cycles, cycleDayLogs: summary.cycleDayLogs, + labResults: summary.labResults, + biomarkers: summary.biomarkers, + illnessEpisodes: summary.illnessEpisodes, + illnessDayLogs: summary.illnessDayLogs, + allergies: summary.allergies, + familyHistory: summary.familyHistory, + workouts: summary.workouts, + documents: summary.documents, }, }, }); diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index 956f61e50..f442c3e15 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -7,7 +7,7 @@ * action name, and rate-limit wiring. The full DB round-trip lives in * the integration suite (`tests/integration/export-per-type.test.ts`). */ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { NextRequest } from "next/server"; vi.mock("@/lib/db", () => ({ @@ -20,6 +20,15 @@ vi.mock("@/lib/db", () => ({ cycleProfile: { findUnique: vi.fn() }, menstrualCycle: { findMany: vi.fn() }, cycleDayLog: { findMany: vi.fn() }, + // v1.28 backup-completeness — the records section the full-backup + // helper now also reads (`buildRecordsBackupSection`). + labResult: { findMany: vi.fn() }, + biomarker: { findMany: vi.fn() }, + illnessEpisode: { findMany: vi.fn() }, + allergy: { findMany: vi.fn() }, + familyHistoryEntry: { findMany: vi.fn() }, + workout: { findMany: vi.fn() }, + inboundDocument: { findMany: vi.fn() }, user: { findUnique: vi.fn() }, auditLog: { create: vi.fn() }, }, @@ -63,6 +72,13 @@ beforeEach(() => { vi.resetAllMocks(); }); +afterEach(() => { + // The v1.28 records-domain end-to-end test stubs ENCRYPTION_KEY to + // exercise the real crypto path; unstub unconditionally (harmless no-op + // for every other test) so a stub never leaks past its own test. + vi.unstubAllEnvs(); +}); + describe("GET /api/export/measurements", () => { it("rejects unauthenticated requests with 401", async () => { vi.mocked(getSession).mockResolvedValue(null); @@ -385,6 +401,15 @@ describe("GET /api/export/full-backup", () => { vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never); vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never); vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.biomarker.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([] as never); const { GET } = await import("../full-backup/route"); const res = await GET(mkReq("http://localhost/api/export/full-backup")); @@ -402,6 +427,20 @@ describe("GET /api/export/full-backup", () => { medications: [], intakeEvents: [], moodEntries: [], + // v1.28 backup-completeness — the newer domains round-trip to empty + // arrays (not undefined/missing) when the account has no records, + // and the manifest still discloses the two deliberate exclusions. + labResults: [], + biomarkers: [], + illnessEpisodes: [], + allergies: [], + familyHistory: [], + workouts: [], + documents: [], + manifest: { + documents: { included: "metadata-only" }, + workouts: { included: "summary-only" }, + }, }); expect(typeof json.exportedAt).toBe("string"); expect(auditLog).toHaveBeenCalledWith( @@ -409,4 +448,81 @@ describe("GET /api/export/full-backup", () => { expect.objectContaining({ userId: "user-1" }), ); }); + + it("includes decrypted v1.28 records-domain data end-to-end", async () => { + vi.stubEnv("ENCRYPTION_KEYS", ""); + vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", ""); + vi.stubEnv("ENCRYPTION_KEY", "a".repeat(64)); + + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 9, + resetAt: Date.now() + 3600_000, + }); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never); + vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never); + + const { encryptToBytes } = await import("@/lib/ai/coach/bytes-codec"); + vi.mocked(prisma.labResult.findMany).mockResolvedValue([ + { + panel: null, + analyte: "HbA1c", + value: 5.4, + valueText: null, + unit: "%", + referenceLow: null, + referenceHigh: null, + takenAt: new Date("2026-04-01T09:00:00.000Z"), + source: "MANUAL", + biomarker: null, + noteEncrypted: null, + }, + ] as never); + vi.mocked(prisma.biomarker.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([ + { + id: "ep-1", + label: "Migraine flare", + type: "CHRONIC", + lifecycle: "FLARE", + onsetAt: new Date("2026-04-10T00:00:00.000Z"), + resolvedAt: null, + parentConditionId: "ep-parent", + noteEncrypted: encryptToBytes("Triggered by travel."), + createdAt: new Date("2026-04-10T00:00:00.000Z"), + updatedAt: new Date("2026-04-10T00:00:00.000Z"), + dayLogs: [], + }, + ] as never); + vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([] as never); + + const { GET } = await import("../full-backup/route"); + const res = await GET(mkReq("http://localhost/api/export/full-backup")); + expect(res.status).toBe(200); + const json = await res.json(); + + expect(json.labResults).toEqual([ + expect.objectContaining({ analyte: "HbA1c", value: 5.4 }), + ]); + // The flare's parent link + decrypted note round-trip in the JSON — + // never the ciphertext. + expect(json.illnessEpisodes[0].parentConditionId).toBe("ep-parent"); + expect(json.illnessEpisodes[0].note).toBe("Triggered by travel."); + // The raw ciphertext column never leaves the server — only the + // decrypted `note` field is present on the DTO. + expect(json.illnessEpisodes[0]).not.toHaveProperty("noteEncrypted"); + }); }); diff --git a/src/app/api/export/__tests__/soft-delete-filter.test.ts b/src/app/api/export/__tests__/soft-delete-filter.test.ts index ae3d95f6d..05b05118e 100644 --- a/src/app/api/export/__tests__/soft-delete-filter.test.ts +++ b/src/app/api/export/__tests__/soft-delete-filter.test.ts @@ -42,6 +42,18 @@ vi.mock("@/lib/db", () => ({ auditLog: { findMany: vi.fn() }, userHealthProfile: { findUnique: vi.fn() }, illnessEpisode: { findMany: vi.fn() }, + // v1.28 backup-completeness — the cycle + records sections the + // full-backup builder now also reads (`buildCycleBackupSection` / + // `buildRecordsBackupSection`). + cycleProfile: { findUnique: vi.fn() }, + menstrualCycle: { findMany: vi.fn() }, + cycleDayLog: { findMany: vi.fn() }, + labResult: { findMany: vi.fn() }, + biomarker: { findMany: vi.fn() }, + allergy: { findMany: vi.fn() }, + familyHistoryEntry: { findMany: vi.fn() }, + workout: { findMany: vi.fn() }, + inboundDocument: { findMany: vi.fn() }, }, })); @@ -125,6 +137,18 @@ beforeEach(() => { null as never, ); vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never); + // v1.28 backup-completeness — the full-backup route now also reads the + // cycle + records sections; let them resolve to empty so the route + // completes instead of rejecting on an unmocked delegate. + vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never); + vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.biomarker.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([] as never); }); describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => { @@ -148,6 +172,48 @@ describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => { ); }); + it("/api/export/full-backup scopes every v1.28 records domain to userId + deletedAt: null", async () => { + const { GET } = await import("../full-backup/route"); + await GET(mkReq("http://localhost/api/export/full-backup")); + + expect(prisma.labResult.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: "user-1", deletedAt: null }, + }), + ); + expect(prisma.illnessEpisode.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: "user-1", deletedAt: null }, + }), + ); + expect(prisma.allergy.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: "user-1", deletedAt: null }, + }), + ); + expect(prisma.familyHistoryEntry.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: "user-1", deletedAt: null }, + }), + ); + expect(prisma.inboundDocument.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: "user-1", deletedAt: null }, + }), + ); + // Biomarker + Workout carry no soft-delete tombstone — userId-only scope. + expect(prisma.biomarker.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { userId: "user-1" } }), + ); + expect(prisma.workout.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { userId: "user-1" } }), + ); + // The document read must never select the encrypted content blob. + const documentCall = vi.mocked(prisma.inboundDocument.findMany).mock + .calls[0][0] as { select?: Record }; + expect(documentCall.select).not.toHaveProperty("contentEncrypted"); + }); + it("/api/export (legacy) scopes the measurement read to deletedAt: null", async () => { const { GET } = await import("../route"); await GET(mkReq("http://localhost/api/export?type=measurements")); diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index ca3e57174..075776e11 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -17,6 +17,15 @@ vi.mock("@/lib/db", () => ({ cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, + // v1.28 backup-completeness — the records section the shared payload + // builder now also reads (`buildRecordsBackupSection`). + labResult: { findMany: vi.fn().mockResolvedValue([]) }, + biomarker: { findMany: vi.fn().mockResolvedValue([]) }, + illnessEpisode: { findMany: vi.fn().mockResolvedValue([]) }, + allergy: { findMany: vi.fn().mockResolvedValue([]) }, + familyHistoryEntry: { findMany: vi.fn().mockResolvedValue([]) }, + workout: { findMany: vi.fn().mockResolvedValue([]) }, + inboundDocument: { findMany: vi.fn().mockResolvedValue([]) }, session: { findUnique: vi.fn() }, webauthnMfaCredential: { count: vi.fn() }, }, @@ -77,6 +86,18 @@ describe("POST /api/export/encrypted", () => { const payload = JSON.parse(plaintext); expect(payload).toHaveProperty("schemaVersion"); expect(payload).toHaveProperty("measurements"); + // v1.28 backup-completeness — the encrypted archive carries the same + // records domains as the plaintext export. + expect(payload).toHaveProperty("labResults"); + expect(payload).toHaveProperty("illnessEpisodes"); + expect(payload).toHaveProperty("allergies"); + expect(payload).toHaveProperty("familyHistory"); + expect(payload).toHaveProperty("workouts"); + expect(payload).toHaveProperty("documents"); + expect(payload.manifest).toMatchObject({ + documents: { included: "metadata-only" }, + workouts: { included: "summary-only" }, + }); }); it("blocks an MFA account without a fresh second factor (401 step-up)", async () => { diff --git a/src/app/api/export/encrypted/route.ts b/src/app/api/export/encrypted/route.ts index 99193bf76..69c8b5806 100644 --- a/src/app/api/export/encrypted/route.ts +++ b/src/app/api/export/encrypted/route.ts @@ -2,9 +2,10 @@ * POST /api/export/encrypted * * v1.23 — passphrase-encrypted variant of the full-backup export. Returns the - * same restore-compatible payload as `GET /api/export/full-backup`, but sealed - * into an `HLX1` archive (Argon2id-derived key + AES-256-GCM) under a passphrase - * the caller supplies in the request body. The binary archive is returned as + * same payload as `GET /api/export/full-backup` (see that route's doc comment + * for which domains restore recreates vs. export-only), sealed into an + * `HLX1` archive (Argon2id-derived key + AES-256-GCM) under a passphrase the + * caller supplies in the request body. The binary archive is returned as * `application/octet-stream`. * * SECURITY: @@ -107,6 +108,14 @@ export const POST = apiHandler(async (request: NextRequest) => { export_mood_count: counts.moodEntries, export_cycle_count: counts.cycles, export_cycle_day_log_count: counts.cycleDayLogs, + export_lab_result_count: counts.labResults, + export_biomarker_count: counts.biomarkers, + export_illness_episode_count: counts.illnessEpisodes, + export_illness_day_log_count: counts.illnessDayLogs, + export_allergy_count: counts.allergies, + export_family_history_count: counts.familyHistory, + export_workout_count: counts.workouts, + export_document_count: counts.documents, export_archive_bytes: archive.byteLength, }, }); diff --git a/src/app/api/export/full-backup/route.ts b/src/app/api/export/full-backup/route.ts index aad5fd565..f5fe21918 100644 --- a/src/app/api/export/full-backup/route.ts +++ b/src/app/api/export/full-backup/route.ts @@ -11,6 +11,16 @@ * `.json` so the browser writes a `.json` file even though the route * segment doesn't carry the extension. * + * Backup-completeness — the payload also carries lab results, the + * biomarker catalog, illness episodes (incl. flares/exacerbations) with + * their day-logs, allergies, family history, and workout summaries. These + * domains are EXPORT-ONLY today: `POST /api/admin/backups/[id]/restore` + * only re-creates the original measurements/medications/mood/cycle + * domains, so restoring a snapshot does not (yet) recreate these sections + * — see `src/lib/export/records-backup.ts`. Document original files and + * workout GPS/sample time series are never included; the payload's + * `manifest` field discloses both, and the export UI states this too. + * * Auth: cookie session OR Bearer token (`requireAuth`). * Rate-limit: shared `export:` bucket (10/h). * Audit: `user.export.full-backup` with the row counts. @@ -34,7 +44,9 @@ export const GET = apiHandler(async (request: NextRequest) => { } // v1.23 — the payload builder is shared with the passphrase-encrypted - // export route so both emit the byte-for-byte same restore-compatible shape. + // export route so both emit the byte-for-byte same shape (the measurement/ + // medication/mood/cycle domains are restore-compatible; the newer records + // domains are export-only for now — see the module doc comment above). const { payload, counts } = await buildFullBackupPayload(prisma, user.id); await auditLog("user.export.full-backup", { @@ -51,6 +63,14 @@ export const GET = apiHandler(async (request: NextRequest) => { export_mood_count: counts.moodEntries, export_cycle_count: counts.cycles, export_cycle_day_log_count: counts.cycleDayLogs, + export_lab_result_count: counts.labResults, + export_biomarker_count: counts.biomarkers, + export_illness_episode_count: counts.illnessEpisodes, + export_illness_day_log_count: counts.illnessDayLogs, + export_allergy_count: counts.allergies, + export_family_history_count: counts.familyHistory, + export_workout_count: counts.workouts, + export_document_count: counts.documents, }, }); diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index bc01e77e5..5b73bbbdf 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -229,6 +229,16 @@ export function BackupsSection() { moodEntries: number; cycles?: number; cycleDayLogs?: number; + // v1.28 backup-completeness — optional so an older-schema upload + // (pre-v1.28) still totals correctly without these keys. + labResults?: number; + biomarkers?: number; + illnessEpisodes?: number; + illnessDayLogs?: number; + allergies?: number; + familyHistory?: number; + workouts?: number; + documents?: number; }; }>("/api/admin/backups/upload", { method: "POST", body: fd }); }, @@ -239,7 +249,15 @@ export function BackupsSection() { data.summary.intakeEvents + data.summary.moodEntries + (data.summary.cycles ?? 0) + - (data.summary.cycleDayLogs ?? 0); + (data.summary.cycleDayLogs ?? 0) + + (data.summary.labResults ?? 0) + + (data.summary.biomarkers ?? 0) + + (data.summary.illnessEpisodes ?? 0) + + (data.summary.illnessDayLogs ?? 0) + + (data.summary.allergies ?? 0) + + (data.summary.familyHistory ?? 0) + + (data.summary.workouts ?? 0) + + (data.summary.documents ?? 0); toast.success( t("admin.section.backups.uploadSuccess", { count: String(total) }), ); diff --git a/src/components/settings/export-section.tsx b/src/components/settings/export-section.tsx index 6fc50159d..4140f0dca 100644 --- a/src/components/settings/export-section.tsx +++ b/src/components/settings/export-section.tsx @@ -494,6 +494,17 @@ function FullBackupCard() { } > + {/* v1.28 backup-completeness — the description above lists what's + included; this line honestly discloses what isn't (document + binaries, workout GPS/sample series) rather than implying + "everything" is in the file. Always visible, not gated on the + encrypt toggle. */} +

    + {t("settings.sections.export.cards.fullBackup.scopeNote")} +