diff --git a/CHANGELOG.md b/CHANGELOG.md index e9ee59e7e..08947ecb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ ## [Unreleased] +## [1.16.9] — 2026-06-12 — the day opens with a verdict, and every dose path tells the truth + +### Added + +- **The dashboard opens with a daily band.** Greeting, the one item that matters right now — a fresh crisis-level blood-pressure reading, an overdue or upcoming dose, weight drifting from its range, a run of short nights, a quiet stretch, a score drop, or the briefing's key finding — with a single action, today's dose tally beside the next due time, and the health score as a ring. The verdict resolves from a fixed ladder with crisis floors that ignore personal target ranges, it never invites a dose the server has not called due, and the band can be turned off under dashboard settings, where the plain greeting then returns to the header. + +### Fixed + +- **An intake recorded through the external API now credits the due slot.** The ingest endpoint was the one intake path that stored a bare row beside the pending slot — the card showed the intake as the last dose while still demanding it, and the ledger later counted a miss. It attributes to the dose band like every other path now, the Telegram confirm and skip buttons converge onto the reminder's own slot, and a nightly pass heals the pairs this has already produced — both days' history and compliance recompute, no manual repair needed. +- **A cached page can no longer contradict an action you just took.** Invalidation now fences in-flight cache builds, so a read can neither join a build that started before your write nor have a background refresh re-store the pre-write state as fresh. +- **Dose prompts err on the safe side everywhere.** A take shortly before the window credits the slot instead of floating loose, an off-schedule intake can no longer silently resolve a different slot and hide a genuinely due dose, a long-interval dose taken on an earlier day downgrades the slot-day prompt to "last dose n days ago" instead of inviting a double dose, and the overdue badge no longer disappears because the day's pending entries were counted as actions. +- Intake times entered retroactively carry the same plausibility bounds as edits, moving a dose across a slot boundary keeps its recorded injection site and dose, both affected days recompute, and schedule corrections, data wipes and backup restores refresh the caches they feed. +- The medication cards, the quick-entry preselection and the dose pills compute their clock in the profile timezone instead of a fixed one. +- WHOOP credentials are trimmed on save — a stray space from the portal's copy button read as an unknown client — and an empty redirect override falls back to the derived callback URL. + ## [1.16.8] — 2026-06-12 — texts that follow your data, charts that never strand ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 5190ca81e..541c54915 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.16.8 + version: 1.16.9 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -2118,7 +2118,7 @@ paths: summary: List medications for the calling user description: Returns every medication owned by the caller (active + paused), ordered by `createdAt DESC`. Each row carries its nested `schedules`, the joined clinical `category`, the latest non-skipped `lastTakenAt`, and the - count of intake events scheduled for today (`todayEventCount`). The response is cached server-side for 60 s per + count of today's actioned intake events (`todayEventCount`). The response is cached server-side for 60 s per user; writes flush the cache. responses: "200": @@ -4890,6 +4890,7 @@ components: pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ takenAt: description: When the dose was actually taken. NULL when `skipped` is true; defaults to `now()` for non-skipped intakes. + Must not lie in the future (5-minute clock-skew allowance) nor more than 5 years in the past. type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ @@ -8362,7 +8363,9 @@ components: type: integer minimum: 0 maximum: 9007199254740991 - description: Number of intake events scheduled for today (user-local day window). + description: "Number of ACTIONED intake events for today (user-local day window): rows with a recorded `takenAt` or an + explicit skip. Pending projector-minted rows do not count — the card overdue-pill suppression compares this + against the passed-dose count, and a pending mint must not read as covered." required: - id - name @@ -11713,6 +11716,76 @@ components: - glucoseByContext additionalProperties: false - type: "null" + medsToday: + type: object + properties: + activeCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + scheduledToday: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + takenToday: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + skippedToday: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + nextDueAt: + anyOf: + - type: string + - type: "null" + nextDueOverdue: + type: boolean + nextDueMedicationName: + anyOf: + - type: string + - type: "null" + required: + - activeCount + - scheduledToday + - takenToday + - skippedToday + - nextDueAt + - nextDueOverdue + - nextDueMedicationName + 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." + healthScore: + anyOf: + - type: object + properties: + score: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + band: + type: string + enum: + - green + - yellow + - red + delta: + anyOf: + - type: number + - type: "null" + required: + - score + - band + - delta + additionalProperties: false + - type: "null" + description: Personal health score summary (0..100 score, traffic-light band, week-over-week delta). Null on a + rollup-coverage miss (it rides the thick phase alongside `extras`) and when no pillar is computable. + Component breakdown is deliberately not serialised here. briefing: anyOf: - type: object @@ -11745,6 +11818,8 @@ components: - metricStates - tiles - extras + - medsToday + - healthScore - briefing - briefingState - briefingUpdatedAt diff --git a/e2e/utils/mock-dashboard-snapshot.ts b/e2e/utils/mock-dashboard-snapshot.ts index 6bfdd2954..cdc207dd8 100644 --- a/e2e/utils/mock-dashboard-snapshot.ts +++ b/e2e/utils/mock-dashboard-snapshot.ts @@ -144,6 +144,16 @@ export function buildMockSnapshot( mood: { summary: null, entries: [] }, }, extras: null, + medsToday: { + activeCount: 0, + scheduledToday: 0, + takenToday: 0, + skippedToday: 0, + nextDueAt: null, + nextDueOverdue: false, + nextDueMedicationName: null, + }, + healthScore: null, briefing: null, briefingState: "preparing", briefingUpdatedAt: null, diff --git a/messages/de.json b/messages/de.json index 7b5c36cd2..008dd5f5f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -274,6 +274,36 @@ }, "welcomeBack": "{greeting}, willkommen zurück.", "welcomeBackWithName": "{greeting} {name}, willkommen zurück.", + "hero": { + "scoreLabel": "Health Score", + "scoreProvisional": "Noch nicht genug Daten", + "doses": { + "summary": "{taken} von {scheduled} Dosen genommen", + "nextAt": "Nächste um {time}", + "allDone": "Alle Dosen für heute erledigt", + "none": "Heute keine Dosen geplant" + }, + "verdict": { + "doseOverdue": "Eine Dosis {name} ist überfällig.", + "doseOverdueNoName": "Eine Dosis ist überfällig.", + "bpHigh": "Dein letzter Blutdruck war sehr hoch ({sys}/{dia}). Miss in Ruhe noch einmal nach.", + "doseUpcoming": "Um {time} steht {name} an.", + "doseUpcomingNoName": "Um {time} steht eine Dosis an.", + "weightDrift": "Dein Gewicht bewegt sich diese Woche vom Zielbereich weg.", + "shortNights": "Deine Nächte waren zuletzt kurz — im Schnitt {hours} h.", + "silence": "Seit {days} Tagen keine Messung. Ein kurzer Eintrag hält dein Bild aktuell.", + "scoreDrop": "Dein Score liegt {points} Punkte unter der Vorwoche.", + "allQuiet": "Alles ruhig — heute gibt es nichts, das deine Aufmerksamkeit braucht." + }, + "action": { + "takeDose": "Jetzt eintragen", + "viewBp": "Blutdruck ansehen", + "viewWeight": "Gewicht ansehen", + "viewSleep": "Schlaf ansehen", + "logMeasurement": "Messung erfassen", + "viewInsights": "Insights öffnen" + } + }, "greetingSalutation": "Hallo, {name}", "streakLabel": "Tage in Folge", "weight": "Gewicht", @@ -323,6 +353,8 @@ "compliance7d": "Therapietreue (7 Tage)", "mood": "Stimmung", "customizeTitle": "Dashboard-Layout", + "heroToggleLabel": "Tagesüberblick", + "heroToggleDescription": "Zeigt Begrüßung, Score, Tagesfokus und Dosen-Status ganz oben auf dem Dashboard.", "layoutReset": "Auf Defaults zurücksetzen", "layoutSaveSuccess": "Dashboard-Layout gespeichert", "layoutSaveError": "Layout konnte nicht gespeichert werden", @@ -730,6 +762,8 @@ "withoutNotification": "Ohne Benachrichtigung", "pausedSince": "Pausiert seit", "takeNow": "Jetzt einnehmen", + "lastDoseYesterday": "Letzte Dosis gestern", + "lastDoseDaysAgo": "Letzte Dosis vor {count} Tagen", "overdue": "Überfällig", "veryOverdue": "Stark überfällig", "compliance7d": "Therapietreue (7 Tage)", diff --git a/messages/en.json b/messages/en.json index d50aa6490..891a06406 100644 --- a/messages/en.json +++ b/messages/en.json @@ -274,6 +274,36 @@ }, "welcomeBack": "{greeting}, welcome back.", "welcomeBackWithName": "{greeting} {name}, welcome back.", + "hero": { + "scoreLabel": "Health Score", + "scoreProvisional": "Not enough data yet", + "doses": { + "summary": "{taken} of {scheduled} doses taken", + "nextAt": "Next at {time}", + "allDone": "All doses done for today", + "none": "No doses scheduled today" + }, + "verdict": { + "doseOverdue": "A dose of {name} is overdue.", + "doseOverdueNoName": "A dose is overdue.", + "bpHigh": "Your last blood pressure was very high ({sys}/{dia}). Take another reading at rest.", + "doseUpcoming": "{name} is due at {time}.", + "doseUpcomingNoName": "A dose is due at {time}.", + "weightDrift": "Your weight is moving away from the target range this week.", + "shortNights": "Your nights have been short lately — {hours} h on average.", + "silence": "No measurement for {days} days. A quick entry keeps your picture current.", + "scoreDrop": "Your score sits {points} points below last week.", + "allQuiet": "All quiet — nothing needs your attention today." + }, + "action": { + "takeDose": "Log it now", + "viewBp": "View blood pressure", + "viewWeight": "View weight", + "viewSleep": "View sleep", + "logMeasurement": "Log measurement", + "viewInsights": "Open Insights" + } + }, "greetingSalutation": "Hi, {name}", "streakLabel": "days in a row", "weight": "Weight", @@ -323,6 +353,8 @@ "compliance7d": "Adherence (7 days)", "mood": "Mood", "customizeTitle": "Dashboard layout", + "heroToggleLabel": "Daily overview", + "heroToggleDescription": "Shows your greeting, score, daily focus and dose status at the top of the dashboard.", "layoutReset": "Reset to defaults", "layoutSaveSuccess": "Dashboard layout saved", "layoutSaveError": "Could not save dashboard layout", @@ -730,6 +762,8 @@ "withoutNotification": "Without notification", "pausedSince": "Paused since", "takeNow": "Take now", + "lastDoseYesterday": "Last dose yesterday", + "lastDoseDaysAgo": "Last dose {count} days ago", "overdue": "Overdue", "veryOverdue": "Very overdue", "compliance7d": "Adherence (7 days)", diff --git a/messages/es.json b/messages/es.json index 593d43f7b..06537b0b8 100644 --- a/messages/es.json +++ b/messages/es.json @@ -274,6 +274,36 @@ }, "welcomeBack": "{greeting}, te damos la bienvenida de nuevo.", "welcomeBackWithName": "{greeting} {name}, te damos la bienvenida de nuevo.", + "hero": { + "scoreLabel": "Health Score", + "scoreProvisional": "Aún no hay datos suficientes", + "doses": { + "summary": "{taken} de {scheduled} dosis tomadas", + "nextAt": "Próxima a las {time}", + "allDone": "Todas las dosis de hoy completadas", + "none": "Hoy no hay dosis programadas" + }, + "verdict": { + "doseOverdue": "Una dosis de {name} está atrasada.", + "doseOverdueNoName": "Una dosis está atrasada.", + "bpHigh": "Tu última tensión arterial fue muy alta ({sys}/{dia}). Vuelve a medirla con calma.", + "doseUpcoming": "A las {time} toca {name}.", + "doseUpcomingNoName": "A las {time} toca una dosis.", + "weightDrift": "Tu peso se está alejando del rango objetivo esta semana.", + "shortNights": "Tus noches han sido cortas últimamente — {hours} h de media.", + "silence": "Sin mediciones desde hace {days} días. Un registro rápido mantiene tu panorama al día.", + "scoreDrop": "Tu puntuación está {points} puntos por debajo de la semana pasada.", + "allQuiet": "Todo tranquilo — hoy no hay nada que requiera tu atención." + }, + "action": { + "takeDose": "Registrar ahora", + "viewBp": "Ver tensión arterial", + "viewWeight": "Ver peso", + "viewSleep": "Ver sueño", + "logMeasurement": "Registrar medición", + "viewInsights": "Abrir Insights" + } + }, "greetingSalutation": "Hola, {name}", "streakLabel": "días seguidos", "weight": "Peso", @@ -323,6 +353,8 @@ "compliance7d": "Cumplimiento (7 días)", "mood": "Estado de ánimo", "customizeTitle": "Diseño del panel", + "heroToggleLabel": "Resumen del día", + "heroToggleDescription": "Muestra el saludo, la puntuación, el foco del día y el estado de las dosis en la parte superior del panel.", "layoutReset": "Restablecer valores por defecto", "layoutSaveSuccess": "Diseño del panel guardado", "layoutSaveError": "No se ha podido guardar el diseño", @@ -730,6 +762,8 @@ "withoutNotification": "Sin notificación", "pausedSince": "En pausa desde", "takeNow": "Tomar ahora", + "lastDoseYesterday": "Última dosis ayer", + "lastDoseDaysAgo": "Última dosis hace {count} días", "overdue": "Vencida", "veryOverdue": "Muy vencida", "compliance7d": "Cumplimiento 7 días", diff --git a/messages/fr.json b/messages/fr.json index ea85d0e79..445d9e1f3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -274,6 +274,36 @@ }, "welcomeBack": "{greeting}, content de te revoir.", "welcomeBackWithName": "{greeting} {name}, content de te revoir.", + "hero": { + "scoreLabel": "Health Score", + "scoreProvisional": "Pas encore assez de données", + "doses": { + "summary": "{taken} dose(s) prise(s) sur {scheduled}", + "nextAt": "Prochaine à {time}", + "allDone": "Toutes les doses du jour sont prises", + "none": "Aucune dose prévue aujourd’hui" + }, + "verdict": { + "doseOverdue": "Une dose de {name} est en retard.", + "doseOverdueNoName": "Une dose est en retard.", + "bpHigh": "Votre dernière tension était très élevée ({sys}/{dia}). Reprenez une mesure au calme.", + "doseUpcoming": "{name} est prévu à {time}.", + "doseUpcomingNoName": "Une dose est prévue à {time}.", + "weightDrift": "Votre poids s’éloigne de la plage cible cette semaine.", + "shortNights": "Vos nuits ont été courtes récemment — {hours} h en moyenne.", + "silence": "Aucune mesure depuis {days} jours. Une saisie rapide garde votre tableau à jour.", + "scoreDrop": "Votre score est {points} points sous la semaine dernière.", + "allQuiet": "Tout est calme — rien ne demande votre attention aujourd’hui." + }, + "action": { + "takeDose": "Enregistrer maintenant", + "viewBp": "Voir la tension", + "viewWeight": "Voir le poids", + "viewSleep": "Voir le sommeil", + "logMeasurement": "Saisir une mesure", + "viewInsights": "Ouvrir Insights" + } + }, "greetingSalutation": "Bonjour, {name}", "streakLabel": "jours d’affilée", "weight": "Poids", @@ -323,6 +353,8 @@ "compliance7d": "Observance (7 jours)", "mood": "Humeur", "customizeTitle": "Mise en page du tableau", + "heroToggleLabel": "Aperçu du jour", + "heroToggleDescription": "Affiche le message d’accueil, le score, le focus du jour et l’état des doses tout en haut du tableau de bord.", "layoutReset": "Rétablir les valeurs par défaut", "layoutSaveSuccess": "Mise en page enregistrée", "layoutSaveError": "Impossible d’enregistrer la mise en page", @@ -730,6 +762,8 @@ "withoutNotification": "Sans notification", "pausedSince": "En pause depuis", "takeNow": "Prendre maintenant", + "lastDoseYesterday": "Dernière dose hier", + "lastDoseDaysAgo": "Dernière dose il y a {count} jours", "overdue": "En retard", "veryOverdue": "Très en retard", "compliance7d": "Observance 7 jours", diff --git a/messages/it.json b/messages/it.json index ef90af625..a71112349 100644 --- a/messages/it.json +++ b/messages/it.json @@ -274,6 +274,36 @@ }, "welcomeBack": "{greeting}, bentornato.", "welcomeBackWithName": "{greeting} {name}, bentornato.", + "hero": { + "scoreLabel": "Health Score", + "scoreProvisional": "Dati ancora insufficienti", + "doses": { + "summary": "{taken} dosi assunte su {scheduled}", + "nextAt": "Prossima alle {time}", + "allDone": "Tutte le dosi di oggi completate", + "none": "Nessuna dose in programma oggi" + }, + "verdict": { + "doseOverdue": "Una dose di {name} è in ritardo.", + "doseOverdueNoName": "Una dose è in ritardo.", + "bpHigh": "L’ultima pressione era molto alta ({sys}/{dia}). Misurala di nuovo con calma.", + "doseUpcoming": "Alle {time} è prevista {name}.", + "doseUpcomingNoName": "Alle {time} è prevista una dose.", + "weightDrift": "Questa settimana il tuo peso si sta allontanando dall’intervallo obiettivo.", + "shortNights": "Le tue notti sono state corte di recente — in media {hours} h.", + "silence": "Nessuna misurazione da {days} giorni. Una voce rapida tiene aggiornato il quadro.", + "scoreDrop": "Il tuo punteggio è {points} punti sotto la settimana scorsa.", + "allQuiet": "Tutto tranquillo — oggi non c’è nulla che richieda la tua attenzione." + }, + "action": { + "takeDose": "Registra ora", + "viewBp": "Vedi pressione", + "viewWeight": "Vedi peso", + "viewSleep": "Vedi sonno", + "logMeasurement": "Registra misurazione", + "viewInsights": "Apri Insights" + } + }, "greetingSalutation": "Ciao, {name}", "streakLabel": "giorni di fila", "weight": "Peso", @@ -323,6 +353,8 @@ "compliance7d": "Aderenza (7 giorni)", "mood": "Umore", "customizeTitle": "Layout della dashboard", + "heroToggleLabel": "Panoramica del giorno", + "heroToggleDescription": "Mostra saluto, punteggio, focus del giorno e stato delle dosi in cima alla dashboard.", "layoutReset": "Ripristina i valori predefiniti", "layoutSaveSuccess": "Layout della dashboard salvato", "layoutSaveError": "Impossibile salvare il layout", @@ -730,6 +762,8 @@ "withoutNotification": "Senza notifica", "pausedSince": "In pausa dal", "takeNow": "Assumi ora", + "lastDoseYesterday": "Ultima dose ieri", + "lastDoseDaysAgo": "Ultima dose {count} giorni fa", "overdue": "In ritardo", "veryOverdue": "Molto in ritardo", "compliance7d": "Aderenza 7 giorni", diff --git a/messages/pl.json b/messages/pl.json index 84f135b09..18ac910b5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -274,6 +274,36 @@ }, "welcomeBack": "{greeting}, witaj ponownie.", "welcomeBackWithName": "{greeting} {name}, witaj ponownie.", + "hero": { + "scoreLabel": "Health Score", + "scoreProvisional": "Za mało danych", + "doses": { + "summary": "Przyjęto {taken} z {scheduled} dawek", + "nextAt": "Następna o {time}", + "allDone": "Wszystkie dzisiejsze dawki przyjęte", + "none": "Brak zaplanowanych dawek na dziś" + }, + "verdict": { + "doseOverdue": "Dawka {name} jest zaległa.", + "doseOverdueNoName": "Dawka jest zaległa.", + "bpHigh": "Twoje ostatnie ciśnienie było bardzo wysokie ({sys}/{dia}). Zmierz je ponownie w spokoju.", + "doseUpcoming": "O {time} pora na {name}.", + "doseUpcomingNoName": "O {time} pora na dawkę.", + "weightDrift": "Twoja waga oddala się w tym tygodniu od zakresu docelowego.", + "shortNights": "Twoje noce były ostatnio krótkie — średnio {hours} h.", + "silence": "Brak pomiarów od {days} dni. Szybki wpis utrzyma aktualny obraz.", + "scoreDrop": "Twój wynik jest o {points} punktów niższy niż w zeszłym tygodniu.", + "allQuiet": "Spokojnie — dziś nic nie wymaga Twojej uwagi." + }, + "action": { + "takeDose": "Zapisz teraz", + "viewBp": "Zobacz ciśnienie", + "viewWeight": "Zobacz wagę", + "viewSleep": "Zobacz sen", + "logMeasurement": "Zapisz pomiar", + "viewInsights": "Otwórz Insights" + } + }, "greetingSalutation": "Cześć, {name}", "streakLabel": "dni z rzędu", "weight": "Waga", @@ -323,6 +353,8 @@ "compliance7d": "Przestrzeganie (7 dni)", "mood": "Nastrój", "customizeTitle": "Układ panelu", + "heroToggleLabel": "Przegląd dnia", + "heroToggleDescription": "Pokazuje powitanie, wynik, fokus dnia i status dawek na górze pulpitu.", "layoutReset": "Przywróć wartości domyślne", "layoutSaveSuccess": "Układ panelu zapisany", "layoutSaveError": "Nie udało się zapisać układu", @@ -730,6 +762,8 @@ "withoutNotification": "Bez powiadomienia", "pausedSince": "Wstrzymane od", "takeNow": "Przyjmij teraz", + "lastDoseYesterday": "Ostatnia dawka wczoraj", + "lastDoseDaysAgo": "Ostatnia dawka {count} dni temu", "overdue": "Po terminie", "veryOverdue": "Mocno po terminie", "compliance7d": "Przestrzeganie 7 dni", diff --git a/package.json b/package.json index 0e81d3c37..cbbc776ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.16.8", + "version": "1.16.9", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/src/app/__tests__/dashboard-hero-wiring.test.ts b/src/app/__tests__/dashboard-hero-wiring.test.ts new file mode 100644 index 000000000..9e26a1939 --- /dev/null +++ b/src/app/__tests__/dashboard-hero-wiring.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * Dashboard hero — page + header wiring pins. + * + * The hero band mounts between the page header and the tile strip: + * + * - it gates on the layout's `heroVisible` flag (anything but the + * literal `false` renders — the resolver's clamp) AND on snapshot + * mode (the verdict needs the snapshot payload); + * - while `primaryLoading` is true (same `!mounted ||` pin the tile + * silhouettes use) a footprint-identical skeleton holds the band, + * so the hero and the tiles swap to content in the SAME render + * pass; + * - the greeting paragraph lives in the hero when that renders; when + * the hero does NOT render (snapshot flag off, or hidden via the + * layout toggle) the header takes it back behind `showGreeting`, + * fed from the page's hero gate — the greeting never disappears. + */ +const PAGE_PATH = join(process.cwd(), "src/app/page.tsx"); +const HEADER_PATH = join( + process.cwd(), + "src/components/dashboard/dashboard-header.tsx", +); + +describe("dashboard hero — page wiring", () => { + const src = readFileSync(PAGE_PATH, "utf8"); + + it("gates the hero on snapshot mode + the layout's heroVisible flag", () => { + expect(src).toMatch( + /const\s+heroVisible\s*=\s*snapshotEnabled\s*&&\s*layout\.heroVisible\s*!==\s*false;/, + ); + }); + + it("holds the skeleton while primaryLoading and swaps the hero in the same pass", () => { + const block = src.match( + /\{heroVisible\s*&&[\s\S]*?/, + ); + expect(block![0]).toContain("onQuickEntry={setQuickEntryDialog}"); + }); + + it("derives primaryLoading once in the component body (shared with the tile gate)", () => { + const matches = src.match(/const\s+primaryLoading\s*=\s*!mounted/g) ?? []; + expect(matches).toHaveLength(1); + }); + + it("the hero sits between the header and the strip", () => { + const headerIdx = src.indexOf(" { + expect(src).not.toContain("welcomeText"); + expect(src).not.toContain("welcomeBackWithName"); + expect(src).not.toContain("getHourForTimeZone"); + }); +}); + +describe("dashboard hero — header handoff", () => { + const header = readFileSync(HEADER_PATH, "utf8"); + + it("the header keeps the greeting behind showGreeting (hero-hidden fallback)", () => { + // The greeting renders here ONLY when the hero band does not — the + // prop gates the paragraph, and the `min-h-5` line-box reservation + // keeps the header stable through the post-hydration name swap. + expect(header).toContain("showGreeting"); + expect(header).toMatch(/\{showGreeting\s*\?/); + expect(header).toContain("min-h-5"); + expect(header).toContain('data-slot="dashboard-header-greeting"'); + }); + + it("the page feeds showGreeting from the inverted hero gate", () => { + const src = readFileSync(PAGE_PATH, "utf8"); + expect(src).toMatch(/showGreeting=\{!heroVisible\}/); + }); + + it("the header keeps the title + customize + add actions", () => { + expect(header).toContain('t("dashboard.title")'); + expect(header).toContain("dashboard-customize-shortcut"); + expect(header).toContain("dashboard-quick-add"); + }); +}); diff --git a/src/app/__tests__/dashboard-suspense-boundaries.test.ts b/src/app/__tests__/dashboard-suspense-boundaries.test.ts index 9db552cbe..30c4dee8d 100644 --- a/src/app/__tests__/dashboard-suspense-boundaries.test.ts +++ b/src/app/__tests__/dashboard-suspense-boundaries.test.ts @@ -74,15 +74,14 @@ describe("v1.4.40 — dashboard per-cell Suspense boundaries", () => { expect(moduleSlice).toMatch(/const\s+DASHBOARD_QUERY_OPTS\s*=\s*\{/); }); - it("memoises the hour-of-day derivation against user.timezone (audit-H4)", () => { + it("keeps the per-render Intl hour walk off the page (audit-H4 successor)", () => { const src = load(PAGE_PATH); - // The greeting hour only changes when the user's timezone changes - // — a `useMemo` keyed on the lifted `userTimezone` local keeps the - // `Intl.DateTimeFormat` instantiation off the per-render hot path. - // Post-W-INFRA Thread 2: `user?.timezone` is lifted to a `userTimezone` - // local one line above the `useMemo` so the dep array stays stable. - expect(src).toMatch( - /const\s+hour\s*=\s*useMemo\([\s\S]*?\[\s*userTimezone\s*\][\s\S]*?\);/, - ); + // The audit-H4 `useMemo(getHourForTimeZone, [userTimezone])` pin + // retired with the greeting itself: the hero band derives the + // daypart from the snapshot's server-computed `greetingHour` + // (`dashboard-hero.tsx`), so the page no longer instantiates any + // `Intl.DateTimeFormat` for the greeting — per render or otherwise. + expect(src).not.toContain("getHourForTimeZone"); + expect(src).not.toContain("welcomeText"); }); }); diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index e8171168f..5299cc133 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -50,6 +50,7 @@ import { } from "@/lib/rollups/medication-compliance-rollups"; import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups"; import { restoreCycleData } from "@/lib/cycle/backup"; +import { invalidateUserData } from "@/lib/cache/invalidate"; export const dynamic = "force-dynamic"; @@ -536,6 +537,10 @@ const handler = apiHandler( }, }); + // v1.16.9 — the restore replaced every row the owner's cached + // payloads were built on; hard-evict all of that user's buckets. + invalidateUserData(ownerId); + const response: RestoreResponse = { restored: true, summary, diff --git a/src/app/api/admin/data/route.ts b/src/app/api/admin/data/route.ts index 444965d80..06e90123d 100644 --- a/src/app/api/admin/data/route.ts +++ b/src/app/api/admin/data/route.ts @@ -5,6 +5,7 @@ import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { NextRequest, NextResponse } from "next/server"; +import { invalidateAllCaches } from "@/lib/cache/invalidate"; export const dynamic = "force-dynamic"; @@ -105,5 +106,9 @@ export const DELETE = apiHandler(async (request: NextRequest) => { details: result, }); + // v1.16.9 — the wipe touched every user's rows; clear every cache + // bucket so no per-user payload survives the reset. + invalidateAllCaches(); + return apiSuccess({ cleared: true, ...result }); }); diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts index 11a8c39db..36f1e9506 100644 --- a/src/app/api/dashboard/summary/__tests__/route.test.ts +++ b/src/app/api/dashboard/summary/__tests__/route.test.ts @@ -14,12 +14,19 @@ vi.mock("@/lib/db", () => ({ // Dashboard compliance tile leaves "Heute nichts geplant" the // moment a daily med is configured. createMany: vi.fn(), + // Shared meds-today builder — latest non-skipped intake per + // medication (next-due re-anchor for rolling cadences). + groupBy: vi.fn(), }, medication: { // v1.4.39 W-SERVER-FIX-2 — projection source for the today's- // intakes backfill. findMany: vi.fn(), }, + // Shared meds-today builder — current-era floor per medication. + medicationScheduleRevision: { + groupBy: vi.fn(), + }, // v1.4.38 W-F — the route now uses `$queryRaw` for the per-type // latest reading, the 7-day rollup-bucket sparkline, and the // 365-day distinct-day streak set. Three raw calls per request, @@ -105,6 +112,14 @@ beforeEach(() => { vi.mocked(prisma.medicationIntakeEvent.createMany).mockResolvedValue({ count: 0, } as never); + // Shared meds-today builder feeder reads — default to empty so the + // legacy tests keep their "no medications" branch. + vi.mocked(prisma.medicationIntakeEvent.groupBy).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationScheduleRevision.groupBy).mockResolvedValue( + [] as never, + ); }); // v1.4.48 L13 — restore real timers no matter how a test exits. A failed @@ -620,25 +635,25 @@ describe("GET /api/dashboard/summary", () => { ], }, ] as never); - // The dashboard route now issues three findMany calls in the - // today path: - // 1. existence probe (no `OR`) → empty (no rows yet) - // 2. final read after backfill (no `OR`) → one minted row - // 3. streak fetch (with `OR`) → empty + // The shared meds-today builder issues four findMany shapes: + // 1. projector existence probe (no `OR`, selects medicationId + + // scheduledFor) → empty (no rows yet) + // 2. today tally read after backfill (no `OR`, selects takenAt + + // skipped) → one minted row + // 3. resolved-slot read + streak fetch (with `OR`) → empty vi.mocked(prisma.medicationIntakeEvent.findMany).mockImplementation((( args: unknown, ) => { const a = args as { where: { OR?: unknown }; - select?: { id?: boolean }; + select?: { medicationId?: boolean }; }; if (a.where.OR) return Promise.resolve([]) as never; - // No `id` field on the existence-probe `select` → the route - // only asked for `(medicationId, scheduledFor)`. - if (!a.select?.id) return Promise.resolve([]) as never; + // The existence probe asks for `(medicationId, scheduledFor)`; + // the tally read asks for `(takenAt, skipped)`. + if (a.select?.medicationId) return Promise.resolve([]) as never; return Promise.resolve([ { - id: "e-new", takenAt: null, skipped: false, }, @@ -712,14 +727,9 @@ describe("GET /api/dashboard/summary", () => { ], }, ] as never); - vi.mocked(prisma.medicationIntakeEvent.findMany).mockImplementation((( - args: unknown, - ) => { - const a = args as { where: { OR?: unknown }; select?: { id?: boolean } }; - if (a.where.OR) return Promise.resolve([]) as never; - if (!a.select?.id) return Promise.resolve([]) as never; - return Promise.resolve([]) as never; - }) as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); vi.mocked(prisma.medicationIntakeEvent.createMany).mockResolvedValue({ count: 2, } as never); @@ -783,18 +793,17 @@ describe("GET /api/dashboard/summary", () => { ) => { const a = args as { where: { OR?: unknown }; - select?: { id?: boolean }; + select?: { medicationId?: boolean }; }; if (a.where.OR) return Promise.resolve([]) as never; - if (!a.select?.id) { + if (a.select?.medicationId) { // Existence probe — the projected slot already exists. return Promise.resolve([ { medicationId: "med-ramipril", scheduledFor }, ]) as never; } - return Promise.resolve([ - { id: "e-existing", takenAt: null, skipped: false }, - ]) as never; + // Today tally read — the pre-existing pending row. + return Promise.resolve([{ takenAt: null, skipped: false }]) as never; }) as never); const res = await callGet(makeReq()); @@ -809,14 +818,16 @@ describe("GET /api/dashboard/summary", () => { it("computes intake compliance for today", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // No active medications (default) → the projector early-outs, so + // the only no-`OR` read is the meds-today tally over the window. vi.mocked(prisma.medicationIntakeEvent.findMany).mockImplementation((( args: unknown, ) => { const a = args as { where: { OR?: unknown } }; if (!a.where.OR) { return Promise.resolve([ - { id: "e1", takenAt: new Date(), skipped: false }, - { id: "e2", takenAt: null, skipped: false }, + { takenAt: new Date(), skipped: false }, + { takenAt: null, skipped: false }, ]) as never; } return Promise.resolve([]) as never; diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index a2bd8b53f..9761a8ba3 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -56,7 +56,7 @@ import { getServerTranslator } from "@/lib/i18n/server-translator"; import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; -import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes"; +import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today"; import { summarizeSleepNights, reconstructSleepNights, @@ -211,20 +211,6 @@ function trendOf(values: number[]): MetricCard["trend"] { return delta > 0 ? "up" : "down"; } -function startOfDayInTz(date: Date, tz: string): Date { - // Compute midnight in the user's tz → UTC ms. - const parts = new Intl.DateTimeFormat("en-US", { - timeZone: tz, - year: "numeric", - month: "2-digit", - day: "2-digit", - }).formatToParts(date); - const y = parts.find((p) => p.type === "year")?.value ?? "1970"; - const m = parts.find((p) => p.type === "month")?.value ?? "01"; - const d = parts.find((p) => p.type === "day")?.value ?? "01"; - return new Date(`${y}-${m}-${d}T00:00:00.000Z`); -} - interface StreakInfo { currentDays: number; longest: number; @@ -373,41 +359,6 @@ function buildContext( return { greetingName, locale }; } -/** - * v1.4.39 W-SERVER-FIX-2 — project today's active schedules, idempotently - * backfill any missing `MedicationIntakeEvent` rows via the shared - * helper, then re-read the today-window so the caller sees both - * pre-existing rows + the freshly minted ones. - * - * Mirrors the behaviour of `/api/medications/intake?scope=today` (same - * helper) so the iOS Dashboard tile (fed by this route) and the iOS - * Erfassen sheet (fed by the intake route) converge on the same row - * set the moment a daily med becomes active. - */ -async function projectAndReadTodaysIntakes( - userId: string, - userTz: string, - todayStart: Date, - todayEnd: Date, -): Promise> { - await projectTodayIntakesAndRecompute({ - userId, - userTz, - todayStart, - todayEnd, - }); - - return prisma.medicationIntakeEvent.findMany({ - where: { - userId, - // v1.7.0 sync — exclude tombstoned rows from the today tile. - deletedAt: null, - scheduledFor: { gte: todayStart, lt: todayEnd }, - }, - select: { id: true, takenAt: true, skipped: true }, - }); -} - async function buildDashboardSummary( userId: string, userTz: string, @@ -421,8 +372,6 @@ async function buildDashboardSummary( const streakWindowStart = new Date( now.getTime() - STREAK_WINDOW_DAYS * 86_400_000, ); - const todayStart = startOfDayInTz(now, userTz); - const todayEnd = new Date(todayStart.getTime() + 86_400_000); // Derived from canonical enum so a new measurement type is auto-included // (V3 audit: enum drift cousins). Per-kind display blocks below decide @@ -453,14 +402,15 @@ async function buildDashboardSummary( // REG-11: taken via ROW_NUMBER window, no calendar // filter, so a 60-day-old metric still paints a chart) // - allTimeAggregate: ≤ N metric types (unchanged) - // - todaysIntakes: ≤ daily intake schedule count (unchanged) + // - medsToday: today-window tally via the shared projector + // (≤ daily intake schedule count, unchanged semantics) // - streakActivity: ≤ daily intake count × 365 (unchanged) // - measurementStreakDays: ≤ 365 (was: every raw row in 365d) const [ latestEver, sparkBuckets, allTimeAggregate, - todaysIntakes, + medsToday, streakActivity, measurementStreakDays, sleepStageRows, @@ -513,13 +463,12 @@ async function buildDashboardSummary( // reminder worker entered the RED phase at the end of the dose // window, so the iOS Dashboard tile fell to "Heute nichts geplant" // even when the intake route (post-`expandTodayIntakes` fix) was - // already projecting + backfilling correctly. Re-uses the same - // helper + idempotent `createMany` (with `skipDuplicates: true` - // so a concurrent intake-route hit can't race a duplicate row in - // before the existence probe converges). - time("todaysIntakes", () => - projectAndReadTodaysIntakes(userId, userTz, todayStart, todayEnd), - ), + // already projecting + backfilling correctly. The shared + // `buildMedsTodayBlock` runs the same idempotent projection + // (`skipDuplicates: true` so a concurrent intake-route hit can't + // race a duplicate row in) before its today-window tally, so this + // route and the dashboard snapshot count the identical row set. + time("medsToday", () => buildMedsTodayBlock(prisma, userId, userTz, now)), time("streakIntakes", () => prisma.medicationIntakeEvent.findMany({ where: { @@ -887,10 +836,11 @@ async function buildDashboardSummary( }); } - const scheduledToday = todaysIntakes.length; - const takenToday = todaysIntakes.filter( - (e) => e.takenAt !== null && !e.skipped, - ).length; + // Shared builder carries the exact tally semantics this tile always + // had (scheduled = every today-window row; taken = `takenAt` set and + // not skipped). The wire shape of `compliance` is locked for iOS. + const scheduledToday = medsToday.scheduledToday; + const takenToday = medsToday.takenToday; const t = getServerTranslator(ctx.locale).t; diff --git a/src/app/api/dashboard/widgets/__tests__/route.test.ts b/src/app/api/dashboard/widgets/__tests__/route.test.ts index b46247982..66458f54e 100644 --- a/src/app/api/dashboard/widgets/__tests__/route.test.ts +++ b/src/app/api/dashboard/widgets/__tests__/route.test.ts @@ -399,6 +399,132 @@ describe("PUT /api/dashboard/widgets — accept-and-ignore unknown ids (v1.7.0 # const callGet = GET as unknown as () => Promise; +describe("PUT /api/dashboard/widgets — heroVisible thread-through", () => { + const knownId = DASHBOARD_WIDGET_IDS[0]; + + it("persists an explicit heroVisible: false WITHOUT dropping stored chartOverlayPrefs", async () => { + // The regression class this route already guards for + // `chartOverlayPrefs`: a layout PUT that omits a sibling field must + // not wipe it. heroVisible sent + prefs omitted → prefs merge from + // the stored blob, heroVisible persists from the body. + const storedPrefs = { + bp: { + showTrendIndicator: true, + showTrendArrow: false, + showTargetRange: true, + comparisonBaseline: "lastMonth" as const, + }, + }; + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dashboardWidgetsJson: serializeDashboardLayout({ + version: 1, + widgets: [{ id: knownId, visible: true, order: 0 }], + chartOverlayPrefs: storedPrefs, + }), + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut( + makeReq({ + version: 1, + widgets: [{ id: knownId, visible: true, order: 0 }], + heroVisible: false, + }), + ); + expect(res.status).toBe(200); + + const updateArg = vi.mocked(prisma.user.update).mock + .calls[0]?.[0] as unknown as { + data: { + dashboardWidgetsJson: { + heroVisible: boolean; + chartOverlayPrefs: Record; + }; + }; + }; + expect(updateArg.data.dashboardWidgetsJson.heroVisible).toBe(false); + expect(updateArg.data.dashboardWidgetsJson.chartOverlayPrefs).toEqual( + storedPrefs, + ); + + // The response echoes the persisted value. + const body = (await res.json()) as { data: { heroVisible: boolean } }; + expect(body.data.heroVisible).toBe(false); + }); + + it("preserves a stored heroVisible: false when the PUT omits the field", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dashboardWidgetsJson: serializeDashboardLayout({ + version: 1, + widgets: [{ id: knownId, visible: true, order: 0 }], + heroVisible: false, + }), + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut( + makeReq({ + version: 1, + widgets: [{ id: knownId, visible: true, order: 0 }], + }), + ); + expect(res.status).toBe(200); + + const updateArg = vi.mocked(prisma.user.update).mock + .calls[0]?.[0] as unknown as { + data: { dashboardWidgetsJson: { heroVisible: boolean } }; + }; + expect(updateArg.data.dashboardWidgetsJson.heroVisible).toBe(false); + }); + + it("defaults heroVisible to true for a first save with no stored layout", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dashboardWidgetsJson: null, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut( + makeReq({ + version: 1, + widgets: [{ id: knownId, visible: true, order: 0 }], + }), + ); + expect(res.status).toBe(200); + + const updateArg = vi.mocked(prisma.user.update).mock + .calls[0]?.[0] as unknown as { + data: { dashboardWidgetsJson: { heroVisible: boolean } }; + }; + expect(updateArg.data.dashboardWidgetsJson.heroVisible).toBe(true); + }); + + it("422s on a non-boolean heroVisible", async () => { + const res = await callPut( + makeReq({ + version: 1, + widgets: [{ id: knownId, visible: true, order: 0 }], + heroVisible: "yes", + }), + ); + expect(res.status).toBe(422); + }); + + it("GET resolves heroVisible alongside the widget layout", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dashboardWidgetsJson: serializeDashboardLayout({ + version: 1, + widgets: [{ id: knownId, visible: true, order: 0 }], + heroVisible: false, + }), + } as never); + + const res = await callGet(); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { heroVisible: boolean } }; + expect(body.data.heroVisible).toBe(false); + }); +}); + describe("dashboard widgets — 27-id catalogue round-trip (v1.7.0 W1)", () => { it("PUT of a full 27-id layout persists every id (iOS-only round-trip)", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 692b7ed70..5896c6322 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -115,6 +115,12 @@ const layoutSchema = z.object({ }), ) .optional(), + // Dashboard hero (daily verdict) visibility. Optional so clients + // that predate the field can still PUT; when omitted the handler + // preserves the stored value (same preserve-when-absent contract as + // `chartOverlayPrefs` — a Settings save that doesn't know the field + // must not silently reset the user's choice). + heroVisible: z.boolean().optional(), }); async function buildDashboardLayout(userId: string): Promise { @@ -258,21 +264,32 @@ export const PUT = apiHandler(async (request: NextRequest) => { // didn't send. The dashboard-layout PUT typically saves widget // visibility / order; chart prefs are PUT through their own route // (`/api/dashboard/chart-overlay-prefs`) and would otherwise be - // wiped here on a subsequent layout save. + // wiped here on a subsequent layout save. `heroVisible` rides the + // same preserve-when-absent contract — an older client's layout + // save must not reset the hero toggle. One stored-layout read + // covers both fallbacks. let mergedChartOverlayPrefs: ChartOverlayPrefsMap | undefined = parsed.data .chartOverlayPrefs as ChartOverlayPrefsMap | undefined; - if (mergedChartOverlayPrefs === undefined) { + let mergedHeroVisible: boolean | undefined = parsed.data.heroVisible; + if (mergedChartOverlayPrefs === undefined || mergedHeroVisible === undefined) { const existing = await prisma.user.findUnique({ where: { id: user.id }, select: { dashboardWidgetsJson: true }, }); - mergedChartOverlayPrefs = - resolveDashboardLayout(existing?.dashboardWidgetsJson) - .chartOverlayPrefs ?? {}; + const existingLayout = resolveDashboardLayout( + existing?.dashboardWidgetsJson, + ); + if (mergedChartOverlayPrefs === undefined) { + mergedChartOverlayPrefs = existingLayout.chartOverlayPrefs ?? {}; + } + if (mergedHeroVisible === undefined) { + mergedHeroVisible = existingLayout.heroVisible !== false; + } } const normalized = serializeDashboardLayout({ ...parsed.data, chartOverlayPrefs: mergedChartOverlayPrefs, + heroVisible: mergedHeroVisible, } as DashboardLayout); await prisma.user.update({ diff --git a/src/app/api/ingest/medication/__tests__/route.test.ts b/src/app/api/ingest/medication/__tests__/route.test.ts index cc2deb55d..629b8c247 100644 --- a/src/app/api/ingest/medication/__tests__/route.test.ts +++ b/src/app/api/ingest/medication/__tests__/route.test.ts @@ -12,7 +12,9 @@ vi.mock("@/lib/db", () => ({ }, medicationIntakeEvent: { findFirst: vi.fn(), + findMany: vi.fn(), create: vi.fn(), + update: vi.fn(), }, medication: { findFirst: vi.fn(), @@ -38,6 +40,9 @@ vi.mock("@/lib/app-settings", () => ({ vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), })); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserMedications: vi.fn(), +})); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), @@ -55,6 +60,7 @@ import { POST } from "../route"; import { prisma } from "@/lib/db"; import { checkRateLimit } from "@/lib/rate-limit"; import { isApiGloballyEnabled } from "@/lib/app-settings"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; function postReq(body: unknown): NextRequest { return new NextRequest("http://localhost/api/ingest/medication", { @@ -145,3 +151,156 @@ describe("POST /api/ingest/medication — 422 multi-issue (v1.4.43 W6)", () => { expect(res.status).toBe(422); }); }); + +describe("POST /api/ingest/medication — slot attribution + convergence (v1.16.9)", () => { + // The maintainer's live bug: a daily 09:00 med, the worker pre-minted the + // pending REMINDER row at 09:00 Berlin, the user ingested the take at + // 08:42 Berlin via the external API. The bare create anchored a SECOND + // row at 08:42 and left the 09:00 pending row open — reminder kept + // firing, ledger showed ad-hoc + later missed, today feed said "due". + const SLOT_0900_BERLIN = new Date("2026-06-10T07:00:00.000Z"); // 09:00 CEST + const TAKEN_0842_BERLIN = new Date("2026-06-10T06:42:00.000Z"); // 08:42 CEST + + const scheduleRow = { + id: "sched-1", + windowStart: "09:00", + windowEnd: "09:00", + daysOfWeek: null, + timesOfDay: ["09:00"], + reminderGraceMinutes: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, + }; + + const pendingReminderRow = { + id: "evt-pending-0900", + takenAt: null, + skipped: false, + idempotencyKey: null, + scheduledFor: SLOT_0900_BERLIN, + source: "REMINDER", + createdAt: new Date("2026-06-10T05:00:00.000Z"), + }; + + function wireHappyPath() { + vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); + vi.mocked(isApiGloballyEnabled).mockResolvedValue(true); + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "tok-1", + userId: "user-1", + permissions: ["*"], + revoked: false, + expiresAt: null, + lastUsedAt: null, + } as never); + vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + timezone: "Europe/Berlin", + } as never); + // First findFirst (by name) returns the bare medication; the slot + // resolver's findFirst (selects schedules) returns the projection. + vi.mocked(prisma.medication.findFirst).mockImplementation(((args: { + where: Record; + }) => { + if (args.where && "name" in args.where) { + return Promise.resolve({ id: "med-1", name: "Metformin" }); + } + return Promise.resolve({ + id: "med-1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + schedules: [scheduleRow], + scheduleRevisions: [], + }); + }) as never); + // Idempotency probe → no replay. + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(null); + // The slot's live rows: the worker-minted pending REMINDER row. + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + pendingReminderRow, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.update).mockImplementation(((args: { + where: { id: string }; + data: Record; + }) => + Promise.resolve({ + ...pendingReminderRow, + ...args.data, + syncVersion: 2, + })) as never); + } + + it("converges an 08:42 take onto the pending 09:00 REMINDER row (no second row)", async () => { + wireHappyPath(); + const res = await POST( + postReq({ + medicationName: "Metformin", + takenAt: TAKEN_0842_BERLIN.toISOString(), + idempotencyKey: "ha-ingest-1", + }), + ); + expect(res.status).toBe(201); + const body = (await res.json()) as { data: { id: string } }; + // The pending REMINDER row was updated in place — never a bare create. + expect(body.data.id).toBe("evt-pending-0900"); + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1); + const update = vi.mocked(prisma.medicationIntakeEvent.update).mock + .calls[0][0] as { + where: { id: string }; + data: { takenAt: Date; skipped: boolean; autoMissed?: boolean }; + }; + expect(update.where.id).toBe("evt-pending-0900"); + expect(update.data.takenAt?.getTime()).toBe(TAKEN_0842_BERLIN.getTime()); + expect(update.data.skipped).toBe(false); + // A recorded take clears a prior cron auto-miss. + expect(update.data.autoMissed).toBe(false); + }); + + it("hard-evicts the user's medication caches after the write", async () => { + wireHappyPath(); + await POST( + postReq({ + medicationName: "Metformin", + takenAt: TAKEN_0842_BERLIN.toISOString(), + idempotencyKey: "ha-ingest-2", + }), + ); + expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", { + evict: true, + }); + }); + + it("keeps the standalone ad-hoc contract for an off-window take", async () => { + wireHappyPath(); + // 14:30 Berlin — outside the 09:00 band and its late tail. + const offWindow = new Date("2026-06-10T12:30:00.000Z"); + vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({ + id: "evt-adhoc", + scheduledFor: offWindow, + takenAt: offWindow, + skipped: false, + source: "API", + } as never); + const res = await POST( + postReq({ + medicationName: "Metformin", + takenAt: offWindow.toISOString(), + idempotencyKey: "ha-ingest-3", + }), + ); + expect(res.status).toBe(201); + expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled(); + const create = vi.mocked(prisma.medicationIntakeEvent.create).mock + .calls[0][0] as { data: { scheduledFor: Date; takenAt: Date } }; + // Ad-hoc rows anchor on the intake instant (`scheduledFor = takenAt`). + expect(create.data.scheduledFor.getTime()).toBe(offWindow.getTime()); + expect(create.data.takenAt.getTime()).toBe(offWindow.getTime()); + }); +}); diff --git a/src/app/api/ingest/medication/route.ts b/src/app/api/ingest/medication/route.ts index fe75ffda1..dfeafa5f4 100644 --- a/src/app/api/ingest/medication/route.ts +++ b/src/app/api/ingest/medication/route.ts @@ -15,6 +15,12 @@ import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { externalIntakeSchema } from "@/lib/validations/medication"; import { isApiGloballyEnabled } from "@/lib/app-settings"; import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; +import { + applyCanonicalSlotWrite, + resolveSlotForWriteByBand, +} from "@/lib/medications/scheduling/slot-upsert"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { NextRequest, NextResponse } from "next/server"; /** @@ -159,17 +165,64 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("API endpoint for this medication is disabled", 403); } - const event = await prisma.medicationIntakeEvent.create({ - data: { + // The external ingest path runs without a User record in scope; + // resolve the timezone once — band attribution and the compliance + // recompute below both anchor on the user's local day. + const ingestUser = await prisma.user.findUnique({ + where: { id: apiToken.userId }, + select: { timezone: true }, + }); + const userTz = ingestUser?.timezone || DEFAULT_TIMEZONE; + + const effectiveTakenAt = takenAt ?? new Date(); + + // v1.16.9 — attribute the take to its scheduled slot by window-band + // membership, the SAME engine the per-medication intake route and the + // read ledger consume. The bare create this replaces anchored every + // ingest at `scheduledFor = takenAt`, leaving the slot's pending + // REMINDER row open: the reminder kept firing, the ledger showed an + // ad-hoc take PLUS a missed slot, and the today feed still said "due". + const attribution = await resolveSlotForWriteByBand({ + userId: apiToken.userId, + medicationId: medication.id, + userTz, + takenAt: effectiveTakenAt, + }); + + let event; + if (attribution.slotInstant) { + // Scheduled dose — converge onto the one canonical slot row (the + // pending REMINDER row when one exists) through the shared upsert: + // H1 deterministic selection, C2 no-downgrade, P2002-safe create. + const applied = await applyCanonicalSlotWrite({ + client: prisma, userId: apiToken.userId, medicationId: medication.id, - scheduledFor: takenAt ?? new Date(), - takenAt: takenAt ?? new Date(), + canonicalSlot: attribution.slotInstant, + takenAt: effectiveTakenAt, skipped: false, - source: "API", + isExplicitTaken: true, + isExplicitSkip: false, idempotencyKey, - }, - }); + createSource: "API", + attributionSource: "AUTO", + }); + event = applied.row; + } else { + // Ad-hoc / PRN / off-window — standalone row under the documented + // contract (`scheduledFor = takenAt`). + event = await prisma.medicationIntakeEvent.create({ + data: { + userId: apiToken.userId, + medicationId: medication.id, + scheduledFor: effectiveTakenAt, + takenAt: effectiveTakenAt, + skipped: false, + source: "API", + idempotencyKey, + }, + }); + } await auditLog("medication.ingest.external", { userId: apiToken.userId, @@ -181,15 +234,21 @@ export const POST = apiHandler(async (request: NextRequest) => { }, }); - annotate({ meta: { medication_id: medication.id, event_id: event.id } }); + annotate({ + meta: { + medication_id: medication.id, + event_id: event.id, + slot_resolved: attribution.slotInstant !== null, + }, + }); + + // v1.16.9 — the ingest is an interactive dose record; the next read + // (today feed, card pill, compliance heatmap) must reflect it rather + // than wait out the TTL. + invalidateUserMedications(apiToken.userId, { evict: true }); // v1.4.39 W-MED — refresh the compliance rollup for the ingested - // event's user-day. The external ingest path runs without a User - // record in scope; resolve the tz once for the recompute. - const ingestUser = await prisma.user.findUnique({ - where: { id: apiToken.userId }, - select: { timezone: true }, - }); + // event's user-day. await recomputeMedicationComplianceForEvent({ userId: apiToken.userId, medicationId: medication.id, diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index 47b93df37..997db221a 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -264,6 +264,11 @@ export const PUT = apiHandler( idempotencyKey: null, createSource: "WEB", attributionSource, + // v1.16.9 — a slot move is a re-binding, not a new dose: the + // original row's recorded injection site and dose override ride + // along onto the converged row instead of being tombstoned away. + injectionSite: event.injectionSite, + doseTaken: event.doseTaken, }); updated = applied.row; } diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index faa5beb26..0fe4beafb 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -179,14 +179,29 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { // Skips carry no binding decision (undefined → column untouched/default). let attributionSource: "AUTO" | "USER_PIN" | undefined; if (skipped) { - canonicalSlot = await resolveSlotInstantForWrite({ - userId: user.id, - medicationId: id, - userTz: user.timezone, - incoming: incomingScheduledFor, - instantIsExplicit: scheduledFor !== undefined, - isTakenWrite: false, - }); + if (scheduledFor === undefined) { + // v1.16.9 — a slot-less deliberate skip resolves by band membership + // on the skip moment, like a take does. The defaulted-now snap only + // reached the tight reminder-grace window, so a skip tapped past it + // landed as an orphan ad-hoc row while the slot's pending REMINDER + // row stayed open to auto-miss. + const attribution = await resolveSlotForWriteByBand({ + userId: user.id, + medicationId: id, + userTz: user.timezone, + takenAt: incomingScheduledFor, + }); + canonicalSlot = attribution.slotInstant; + } else { + canonicalSlot = await resolveSlotInstantForWrite({ + userId: user.id, + medicationId: id, + userTz: user.timezone, + incoming: incomingScheduledFor, + instantIsExplicit: true, + isTakenWrite: false, + }); + } } else if (forceSlotInstant !== undefined) { canonicalSlot = await resolveForcedSlotForWrite({ userId: user.id, diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 919c01e26..b7c729399 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -25,6 +25,7 @@ import type { Prisma } from "@/generated/prisma/client"; import { computeDisplayDue, OVERDUE_LOOKBACK_MS, + toResolvedSlotMark, } from "@/lib/medications/scheduling/next-due"; import { dayKeyForScheduledFor, @@ -137,7 +138,9 @@ export const GET = apiHandler( { autoMissed: true }, ], }, - select: { scheduledFor: true }, + // v1.16.9 — `takenAt` rides along so an ad-hoc row (`scheduledFor + // === takenAt`) cannot ±6h-resolve a different slot. + select: { scheduledFor: true, takenAt: true }, }); // v1.16.4 — current-era floor: the open-overdue search mints from // the LIVE schedule rows, so it must not reach past the newest @@ -161,7 +164,7 @@ export const GET = apiHandler( now, userTz: user.timezone || "Europe/Berlin", lastIntakeAt: lastIntake?.takenAt ?? null, - resolvedSlots: resolvedRows.map((r) => r.scheduledFor), + resolvedSlots: resolvedRows.map(toResolvedSlotMark), eraStart: latestRevision?.validUntil ?? null, }); diff --git a/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts b/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts index 10ead9ff6..bc9011a47 100644 --- a/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts +++ b/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts @@ -39,6 +39,7 @@ import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { scheduleRevisionUpdateSchema } from "@/lib/validations/schedule-revision"; import { toRevisionPayloadEntry } from "@/lib/medications/scheduling/schedule-eras"; import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; import type { Prisma } from "@/generated/prisma/client"; type RouteParams = { params: Promise<{ id: string; revisionId: string }> }; @@ -249,6 +250,10 @@ export const PATCH = apiHandler( // The corrected era re-segments history; refresh the pre-aggregated // compliance rollups asynchronously (best-effort). + // v1.16.9 — an era write re-segments the bands every cached payload + // (list next-due, compliance cells, dashboard tally) was built on; + // hard-evict so the next read reflects the new history immediately. + invalidateUserMedications(user.id, { evict: true }); await enqueueUserMedicationComplianceBackfill(user.id); return apiSuccess({ @@ -321,6 +326,10 @@ export const DELETE = apiHandler( // History re-segments without the era; refresh the pre-aggregated // compliance rollups asynchronously (best-effort). + // v1.16.9 — an era write re-segments the bands every cached payload + // (list next-due, compliance cells, dashboard tally) was built on; + // hard-evict so the next read reflects the new history immediately. + invalidateUserMedications(user.id, { evict: true }); await enqueueUserMedicationComplianceBackfill(user.id); return apiSuccess({ deleted: true }); diff --git a/src/app/api/medications/[id]/schedule-revisions/route.ts b/src/app/api/medications/[id]/schedule-revisions/route.ts index 9eda687b5..c78500192 100644 --- a/src/app/api/medications/[id]/schedule-revisions/route.ts +++ b/src/app/api/medications/[id]/schedule-revisions/route.ts @@ -40,6 +40,7 @@ import { type ScheduleRevisionEntry, } from "@/lib/medications/scheduling/schedule-eras"; import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; import type { Prisma } from "@/generated/prisma/client"; type RouteParams = { params: Promise<{ id: string }> }; @@ -280,6 +281,10 @@ export const POST = apiHandler( // The new era re-segments history the compliance rollups already // pre-aggregated; refresh them asynchronously. Best-effort like // every other rollup write-hook. + // v1.16.9 — an era write re-segments the bands every cached payload + // (list next-due, compliance cells, dashboard tally) was built on; + // hard-evict so the next read reflects the new history immediately. + invalidateUserMedications(user.id, { evict: true }); await enqueueUserMedicationComplianceBackfill(user.id); return apiSuccess( diff --git a/src/app/api/medications/__tests__/route.test.ts b/src/app/api/medications/__tests__/route.test.ts index 69c198e46..c00b3f3b0 100644 --- a/src/app/api/medications/__tests__/route.test.ts +++ b/src/app/api/medications/__tests__/route.test.ts @@ -10,6 +10,13 @@ vi.mock("@/lib/db", () => ({ findMany: vi.fn().mockResolvedValue([]), create: vi.fn(), }, + medicationIntakeEvent: { + groupBy: vi.fn().mockResolvedValue([]), + findMany: vi.fn().mockResolvedValue([]), + }, + medicationScheduleRevision: { + groupBy: vi.fn().mockResolvedValue([]), + }, medicationCategoryAssignment: { findMany: vi.fn().mockResolvedValue([]), }, @@ -59,9 +66,11 @@ vi.mock("next/headers", () => ({ })), })); -import { POST } from "../route"; +import { GET, POST } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { cachedSwr } from "@/lib/cache/server-cache"; +import { getUserTodayBounds } from "@/lib/tz/local-day"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -353,3 +362,52 @@ describe("POST /api/medications — v1.5.4 clinical category extension", () => { expect(res.status).toBe(422); }); }); + +describe("GET /api/medications — todayEventCount counts only actioned rows (v1.16.9)", () => { + it("filters the today-count groupBy to taken-or-skipped rows", async () => { + // The dashboard projector mints a pending row per slot of the day, so + // an unfiltered count covered every passed dose after any dashboard + // visit and the cards' overdue pill went dark. The count read must + // exclude pending rows. + // (`vi.resetAllMocks()` in beforeEach clears the factory defaults, so + // re-wire the pass-through + empty result sets here.) + vi.mocked(cachedSwr).mockImplementation((async ( + _c: unknown, + _k: string, + f: () => Promise, + ) => f()) as never); + vi.mocked(getUserTodayBounds).mockReturnValue({ + start: new Date("2026-06-10T00:00:00Z"), + end: new Date("2026-06-10T23:59:59Z"), + } as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationIntakeEvent.groupBy).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationScheduleRevision.groupBy).mockResolvedValue( + [] as never, + ); + // The handler body takes no parameters; `apiHandler` still receives + // the request through the wrapper's rest args. + const res = await ( + GET as unknown as (req: NextRequest) => Promise + )(new NextRequest("http://localhost/api/medications")); + expect(res.status).toBe(200); + + const groupByCalls = vi.mocked(prisma.medicationIntakeEvent.groupBy).mock + .calls; + const countCall = groupByCalls.find( + (c) => (c[0] as { _count?: unknown })._count !== undefined, + ); + expect(countCall).toBeDefined(); + const where = ( + countCall![0] as { + where: { OR?: unknown }; + } + ).where; + expect(where.OR).toEqual([{ takenAt: { not: null } }, { skipped: true }]); + }); +}); diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 8bc18e95b..5a1440f62 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -59,6 +59,7 @@ import { } from "@/lib/medications/scheduling/slot-upsert"; import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write"; import { + boundedTakenAtSchema, injectionSiteEnum, type InjectionSiteValue, } from "@/lib/validations/medication"; @@ -74,10 +75,9 @@ const bulkEntrySchema = z.object({ .datetime({ offset: true }) .transform((s) => new Date(s)) .optional(), - takenAt: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .optional(), + // v1.16.9 — shared plausibility bounds (no future beyond clock skew, + // 5-year floor), matching the single-intake create + edit paths. + takenAt: boundedTakenAtSchema.optional(), skipped: z.boolean().optional().default(false), idempotencyKey: z.string().min(1).max(128).optional(), // v1.8.5 — optional per-entry injection site. Validated against the @@ -313,6 +313,21 @@ async function postBulk(request: NextRequest): Promise { }); canonicalSlot = attribution.slotInstant; attributionSource = "AUTO"; + } else if (isExplicitSkip && entry.scheduledFor === undefined) { + // v1.16.9 — a slot-less deliberate skip resolves by band + // membership on the skip moment, exactly like a take does. The + // defaulted-now snap below only reaches the tight reminder-grace + // window, so a skip tapped 20 minutes past the dose time inserted + // an orphan ad-hoc skip row while the slot's pending REMINDER row + // stayed open and later auto-missed — the user's deliberate skip + // recorded as a miss. + const attribution = await resolveSlotForWriteByBand({ + userId: user.id, + medicationId: entry.medicationId, + userTz: user.timezone, + takenAt: incomingScheduledFor, + }); + canonicalSlot = attribution.slotInstant; } else { // v1.8.2 — source-agnostic anchor snap for pending echoes + skips. // iOS posts the reminder actions here (source API); without this diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 50891c00e..cbc3461a5 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -560,7 +560,14 @@ export const POST = apiHandler(async (request: NextRequest) => { isExplicitSkip: false, idempotencyKey: null, createSource: "WEB", - injectionSite: resolvedInjectionSite, + // v1.16.9 — carry the recorded site / dose override across the + // tombstone + re-insert: a slot move is a re-binding, not a new + // dose, so the original row's documentation must survive when the + // toggle write itself carries none. + injectionSite: + resolvedInjectionSite ?? + (existing.injectionSite as InjectionSiteValue | null), + doseTaken: existing.doseTaken, }); updated = applied.row; await prisma.medication.update({ @@ -611,6 +618,18 @@ export const POST = apiHandler(async (request: NextRequest) => { scheduledFor: existing.scheduledFor, tz: userTzForHook, }); + // v1.16.9 — a slot move re-binds the dose onto a DIFFERENT instant, + // possibly a different local day. Recompute the target day too, or the + // source day self-corrects while the destination keeps its stale tuple. + const movedTo = updated?.scheduledFor; + if (movedTo && movedTo.getTime() !== existing.scheduledFor.getTime()) { + await recomputeMedicationComplianceForEvent({ + userId: user.id, + medicationId: existing.medicationId, + scheduledFor: movedTo, + tz: userTzForHook, + }); + } return apiSuccess(updated); }); diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 34f8c1418..2da559eaa 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -18,6 +18,8 @@ import { serializeScheduleRecurrence } from "@/lib/medication-schedule"; import { computeDisplayDue, OVERDUE_LOOKBACK_MS, + toResolvedSlotMark, + type ResolvedSlotMark, } from "@/lib/medications/scheduling/next-due"; import { getUserTodayBounds } from "@/lib/tz/local-day"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; @@ -73,10 +75,17 @@ async function buildMedicationsList( prisma.medicationIntakeEvent.groupBy({ by: ["medicationId"], // v1.7.0 sync — exclude tombstoned rows from the today-count map. + // v1.16.9 — count only ACTIONED rows (taken or skipped). The + // dashboard projector mints pending rows for every slot of the + // day, so counting all rows made `todayEventCount` cover every + // passed dose after any dashboard visit — and the cards' + // overdue-pill suppression (`todayEventCount < passedDoseCount`) + // went dark nondeterministically for genuinely overdue doses. where: { userId, deletedAt: null, scheduledFor: { gte: todayStartUtc, lte: todayEndUtc }, + OR: [{ takenAt: { not: null } }, { skipped: true }], }, _count: { id: true }, }), @@ -91,7 +100,11 @@ async function buildMedicationsList( { autoMissed: true }, ], }, - select: { medicationId: true, scheduledFor: true }, + // v1.16.9 — `takenAt` rides along so the ad-hoc shape + // (`scheduledFor === takenAt`) is detectable: such a row must not + // ±6h-resolve a DIFFERENT slot (a 14:30 ad-hoc take hid tonight's + // genuinely-due 20:00 dose). + select: { medicationId: true, scheduledFor: true, takenAt: true }, }), // v1.16.4 — current-era floor per medication: the newest revision's // `validUntil` is where the LIVE schedule rows became valid. The @@ -106,11 +119,12 @@ async function buildMedicationsList( }), ]); - const resolvedSlotsByMedId = new Map(); + const resolvedSlotsByMedId = new Map(); for (const e of resolvedEvents) { + const mark = toResolvedSlotMark(e); const list = resolvedSlotsByMedId.get(e.medicationId); - if (list) list.push(e.scheduledFor); - else resolvedSlotsByMedId.set(e.medicationId, [e.scheduledFor]); + if (list) list.push(mark); + else resolvedSlotsByMedId.set(e.medicationId, [mark]); } const eraStartByMedId = new Map(); diff --git a/src/app/api/settings/data/route.ts b/src/app/api/settings/data/route.ts index 71235937e..b537b765e 100644 --- a/src/app/api/settings/data/route.ts +++ b/src/app/api/settings/data/route.ts @@ -4,6 +4,7 @@ import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { invalidateUserData } from "@/lib/cache/invalidate"; export const dynamic = "force-dynamic"; @@ -119,5 +120,9 @@ export const DELETE = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "settings.data.clear" }, meta: result }); + // v1.16.9 — every cached payload was built on the rows just deleted; + // hard-evict the user's buckets so no surface serves pre-wipe data. + invalidateUserData(userId); + return apiSuccess({ cleared: true, ...result }); }); diff --git a/src/app/api/telegram/webhook/__tests__/route.test.ts b/src/app/api/telegram/webhook/__tests__/route.test.ts index e33c3622c..ce8c8f72d 100644 --- a/src/app/api/telegram/webhook/__tests__/route.test.ts +++ b/src/app/api/telegram/webhook/__tests__/route.test.ts @@ -18,9 +18,12 @@ vi.mock("@/lib/db", () => ({ medicationIntakeEvent: { create: vi.fn(), findFirst: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), }, telegramReminderMessage: { deleteMany: vi.fn(), + findFirst: vi.fn(), }, telegramScheduledDeletion: { createMany: vi.fn(), @@ -52,8 +55,17 @@ vi.mock("@/lib/logging/context", () => ({ })), })); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserMedications: vi.fn(), +})); + +vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ + recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +})); + import { POST, GET } from "../route"; import { prisma } from "@/lib/db"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { answerTelegramCallbackQuery, deleteMessage, @@ -67,10 +79,40 @@ const TG_USER = { id: "user-1", telegramBotToken: "ENC(token-blob)", locale: "en", + timezone: "Europe/Berlin", }; const MEDICATION = { id: "med-1", name: "Ramipril" }; +/** + * The slot-attribution load (`loadAttributeMedication`) selects the full + * schedule projection (distinguishable by `select.schedules`); every other + * `medication.findFirst` in the webhook wants the bare id/name shape. The + * default projection carries no schedules, so the generic dispatch tests + * exercise the ad-hoc (standalone create) path unchanged. + */ +function wireMedicationFindFirst( + schedules: unknown[] = [], + scheduleRevisions: unknown[] = [], +) { + vi.mocked(prisma.medication.findFirst).mockImplementation(((args: { + select?: Record; + }) => { + if (args?.select && "schedules" in args.select) { + return Promise.resolve({ + id: "med-1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + schedules, + scheduleRevisions, + }); + } + return Promise.resolve(MEDICATION); + }) as never); +} + beforeEach(() => { vi.resetAllMocks(); process.env.TELEGRAM_WEBHOOK_SECRET = "sekret"; @@ -80,18 +122,28 @@ beforeEach(() => { resetAt: Date.now() + 60_000, } as never); vi.mocked(prisma.user.findFirst).mockResolvedValue(TG_USER as never); - vi.mocked(prisma.medication.findFirst).mockResolvedValue(MEDICATION as never); + wireMedicationFindFirst(); vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); vi.mocked(prisma.medication.update).mockResolvedValue({} as never); vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(null); vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({} as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); vi.mocked(prisma.telegramReminderMessage.deleteMany).mockResolvedValue({ count: 0, } as never); + vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue(null); vi.mocked(prisma.telegramScheduledDeletion.createMany).mockResolvedValue({ count: 0, } as never); - vi.mocked(prisma.$transaction).mockResolvedValue([] as never); + // Interactive transactions run their callback against the same mock + // client (the route converges the intake write + snooze update inside + // one transaction); batch arrays resolve like Promise.all. + vi.mocked(prisma.$transaction).mockImplementation(((arg: unknown) => + typeof arg === "function" + ? (arg as (tx: unknown) => unknown)(prisma) + : Promise.all(arg as Promise[])) as never); vi.mocked(answerTelegramCallbackQuery).mockResolvedValue(undefined as never); vi.mocked(deleteMessage).mockResolvedValue(undefined as never); vi.mocked(sendTelegramMessage).mockResolvedValue({ @@ -185,15 +237,7 @@ describe("Telegram webhook — callback dispatch", () => { const res = await POST(tgRequest(callbackUpdate("taken:med-1"))); expect(res.status).toBe(200); - // $transaction is invoked with [create-intake, update-medication]. Inspect - // its argument list for both writes. - expect(prisma.$transaction).toHaveBeenCalledTimes(1); - const txOps = vi.mocked(prisma.$transaction).mock.calls[0][0] as unknown; - expect(Array.isArray(txOps)).toBe(true); - - // The route calls prisma.medicationIntakeEvent.create + medication.update - // synchronously while building the transaction array, so these mocks - // record the arguments the way it built them. + // No schedules wired → the take is ad-hoc and inserts standalone. expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1); const intakeArgs = vi.mocked(prisma.medicationIntakeEvent.create).mock .calls[0][0]; @@ -211,6 +255,8 @@ describe("Telegram webhook — callback dispatch", () => { where: { id: "med-1" }, data: { snoozedUntil: null }, }); + // Intake write + snooze reset commit atomically. + expect(prisma.$transaction).toHaveBeenCalledTimes(1); expect(answerTelegramCallbackQuery).toHaveBeenCalledTimes(1); expect(deleteMessage).toHaveBeenCalledWith( @@ -226,7 +272,7 @@ describe("Telegram webhook — callback dispatch", () => { null, ); await POST(tgRequest(callbackUpdate("taken:med-1"))); - expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1); // Second call: existing-row lookup hits → no second write. vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce({ @@ -234,7 +280,7 @@ describe("Telegram webhook — callback dispatch", () => { } as never); const res2 = await POST(tgRequest(callbackUpdate("taken:med-1"))); expect(res2.status).toBe(200); - expect(prisma.$transaction).toHaveBeenCalledTimes(1); // unchanged + expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1); // unchanged }); it("'snooze::60' updates the medication's snoozedUntil and answers with the locale-translated message", async () => { @@ -281,6 +327,8 @@ describe("Telegram webhook — callback dispatch", () => { .snoozedUntil; expect(snoozedUntil.getHours()).toBe(23); expect(snoozedUntil.getMinutes()).toBe(59); + // Skip row + rest-of-day snooze commit atomically. + expect(prisma.$transaction).toHaveBeenCalledTimes(1); }); it("'ack:' answers the callback without creating an intake event", async () => { @@ -599,6 +647,159 @@ describe("Telegram webhook — text-message dispatch", () => { }); }); +describe("Telegram webhook — slot convergence (v1.16.9)", () => { + // Daily 09:00 Berlin med; the worker pre-minted the pending REMINDER row + // at the canonical slot instant. A Telegram take/skip must converge onto + // that row instead of inserting a second row anchored at `now` — the old + // shape left the pending row open to auto-miss, punishing compliance for + // a dose the user explicitly confirmed. + const SLOT_0900_BERLIN = new Date("2026-06-10T07:00:00.000Z"); // 09:00 CEST + const NOW_0903_BERLIN = new Date("2026-06-10T07:03:00.000Z"); // 09:03 CEST + + const scheduleRow = { + id: "sched-1", + windowStart: "09:00", + windowEnd: "09:00", + daysOfWeek: null, + timesOfDay: ["09:00"], + reminderGraceMinutes: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, + }; + + const pendingReminderRow = { + id: "evt-pending-0900", + takenAt: null, + skipped: false, + idempotencyKey: null, + scheduledFor: SLOT_0900_BERLIN, + source: "REMINDER", + createdAt: new Date("2026-06-10T05:00:00.000Z"), + }; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW_0903_BERLIN); + wireMedicationFindFirst([scheduleRow]); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + pendingReminderRow, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.update).mockImplementation(((args: { + data: Record; + }) => + Promise.resolve({ + ...pendingReminderRow, + ...args.data, + syncVersion: 2, + })) as never); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("a take converges onto the pending REMINDER row via the reminder's own slot", async () => { + vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue({ + date: "2026-06-10", + timeOfDay: "09:00", + } as never); + + const res = await POST(tgRequest(callbackUpdate("taken:med-1"))); + expect(res.status).toBe(200); + + // The pending row was updated in place — never a duplicate insert. + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1); + const update = vi.mocked(prisma.medicationIntakeEvent.update).mock + .calls[0][0] as { + where: { id: string }; + data: { takenAt: Date; skipped: boolean; autoMissed?: boolean }; + }; + expect(update.where.id).toBe("evt-pending-0900"); + expect(update.data.takenAt?.getTime()).toBe(NOW_0903_BERLIN.getTime()); + expect(update.data.skipped).toBe(false); + expect(update.data.autoMissed).toBe(false); + expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", { + evict: true, + }); + }); + + it("a take converges via band attribution when no reminder row matches", async () => { + vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue(null); + const res = await POST(tgRequest(callbackUpdate("taken:med-1"))); + expect(res.status).toBe(200); + // 09:03 sits inside the 09:00 band → same convergence, no second row. + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1); + expect( + ( + vi.mocked(prisma.medicationIntakeEvent.update).mock.calls[0][0] as { + where: { id: string }; + } + ).where.id, + ).toBe("evt-pending-0900"); + }); + + it("a skip converges onto the pending REMINDER row as skipped (no orphan + no miss)", async () => { + vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue({ + date: "2026-06-10", + timeOfDay: "09:00", + } as never); + + const res = await POST(tgRequest(callbackUpdate("skip:med-1"))); + expect(res.status).toBe(200); + + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1); + const update = vi.mocked(prisma.medicationIntakeEvent.update).mock + .calls[0][0] as { + where: { id: string }; + data: { takenAt: Date | null; skipped: boolean }; + }; + expect(update.where.id).toBe("evt-pending-0900"); + expect(update.data.takenAt).toBeNull(); + expect(update.data.skipped).toBe(true); + expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", { + evict: true, + }); + }); + + it("an exotic-zone reminder slot reconstructs on the correct local day", async () => { + // Pacific/Kiritimati (UTC+14): 09:00 on 2026-06-10 local is + // 2026-06-09T19:00Z — a naive UTC-noon reference would land a day off. + const slotKiritimati = new Date("2026-06-09T19:00:00.000Z"); + const nowKiritimati = new Date("2026-06-09T19:05:00.000Z"); + vi.setSystemTime(nowKiritimati); + vi.mocked(prisma.user.findFirst).mockResolvedValue({ + ...TG_USER, + timezone: "Pacific/Kiritimati", + } as never); + const pendingRow = { + ...pendingReminderRow, + scheduledFor: slotKiritimati, + }; + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + pendingRow, + ] as never); + vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue({ + date: "2026-06-10", + timeOfDay: "09:00", + } as never); + + const res = await POST(tgRequest(callbackUpdate("taken:med-1"))); + expect(res.status).toBe(200); + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1); + const update = vi.mocked(prisma.medicationIntakeEvent.update).mock + .calls[0][0] as { where: { id: string } }; + expect(update.where.id).toBe("evt-pending-0900"); + }); +}); + describe("Telegram webhook — GET verification", () => { it("returns 200 with a valid header secret", async () => { const req = new NextRequest("http://localhost/api/telegram/webhook", { diff --git a/src/app/api/telegram/webhook/route.ts b/src/app/api/telegram/webhook/route.ts index b492fa489..6a95195a6 100644 --- a/src/app/api/telegram/webhook/route.ts +++ b/src/app/api/telegram/webhook/route.ts @@ -14,6 +14,15 @@ import { annotate, getEvent } from "@/lib/logging/context"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { locales, type Locale } from "@/lib/i18n/config"; import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; +import { + applyCanonicalSlotWrite, + resolveForcedSlotForWrite, + resolveSlotForWriteByBand, +} from "@/lib/medications/scheduling/slot-upsert"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { localHmAsUtc } from "@/lib/tz/local-day"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; interface TelegramUpdate { update_id: number; @@ -123,12 +132,88 @@ async function findTelegramUser(chatId: string) { }); } +/** + * v1.16.9 — the slot a Telegram action should converge onto. + * + * The reminder context knows its own slot: the dispatcher records every + * reminder message as a `TelegramReminderMessage` row carrying the + * local `date` + `timeOfDay` of the dose it fired for. When the action + * callback carries the chat/message ids, that row names the EXACT slot + * — preferred over band attribution, which would orphan a confirmation + * tapped hours after the reminder (band closed → ad-hoc) even though + * the user is answering a specific dose prompt. + * + * Returns the canonical band anchor (validated against the schedule's + * real slots), or `null` when no reminder row matches / the instant is + * not a real slot — the caller then falls back to band attribution. + */ +async function resolveReminderSlot(input: { + userId: string; + medicationId: string; + userTz: string; + chatId: string; + messageId: number; +}): Promise { + const reminder = await prisma.telegramReminderMessage.findFirst({ + where: { + medicationId: input.medicationId, + chatId: input.chatId, + messageId: input.messageId, + }, + select: { date: true, timeOfDay: true }, + }); + if (!reminder) return null; + const instant = reminderLocalInstant( + reminder.date, + reminder.timeOfDay, + input.userTz, + ); + if (!instant) return null; + // Validate against the medication's real band anchors so a stale or + // malformed reminder row can never park a dose on a non-slot instant. + return resolveForcedSlotForWrite({ + userId: input.userId, + medicationId: input.medicationId, + userTz: input.userTz, + slotInstant: instant, + }); +} + +/** + * Mint the UTC instant of local `timeOfDay` on the local calendar day + * `date` (YYYY-MM-DD) in `tz`. Probes a reference instant a day either + * side of UTC noon so the result is correct in every zone offset, then + * derives the DST-correct instant via `localHmAsUtc`. + */ +function reminderLocalInstant( + date: string, + timeOfDay: string, + tz: string, +): Date | null { + if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !/^\d{2}:\d{2}$/.test(timeOfDay)) { + return null; + } + const [hour, minute] = timeOfDay.split(":").map(Number); + const base = new Date(`${date}T12:00:00.000Z`); + if (Number.isNaN(base.getTime())) return null; + for (const shiftDays of [0, -1, 1]) { + const ref = new Date(base.getTime() + shiftDays * 24 * 60 * 60 * 1000); + const parts = wallClockInTz(ref, tz); + const localDay = `${parts.year}-${String(parts.month).padStart(2, "0")}-${String( + parts.day, + ).padStart(2, "0")}`; + if (localDay === date) return localHmAsUtc(ref, tz, hour, minute); + } + return null; +} + async function markMedicationTaken( userId: string, medicationId: string, idempotencyKey: string, locale: Locale, userTz: string | null, + reminderRef?: { chatId: string; messageId: number }, ): Promise<{ ok: boolean; message: string; medicationName?: string }> { const { t } = getServerTranslator(locale); const medication = await prisma.medication.findFirst({ @@ -146,29 +231,90 @@ async function markMedicationTaken( let scheduledFor: Date | null = null; if (!existing) { - scheduledFor = new Date(); - await prisma.$transaction([ - prisma.medicationIntakeEvent.create({ - data: { + const takenAt = new Date(); + const tz = userTz || DEFAULT_TIMEZONE; + + // v1.16.9 — converge onto the slot's canonical row instead of bare- + // creating a second row anchored at `now`. The old shape left the + // worker-minted pending REMINDER row open: the slot later auto-missed + // and compliance punished a dose the user explicitly confirmed. + // Prefer the reminder's own slot (the message names the dose it fired + // for); fall back to band attribution on the tap instant. + let canonicalSlot: Date | null = reminderRef + ? await resolveReminderSlot({ userId, medicationId: medication.id, - scheduledFor, - takenAt: scheduledFor, + userTz: tz, + chatId: reminderRef.chatId, + messageId: reminderRef.messageId, + }) + : null; + if (canonicalSlot === null) { + const attribution = await resolveSlotForWriteByBand({ + userId, + medicationId: medication.id, + userTz: tz, + takenAt, + }); + canonicalSlot = attribution.slotInstant; + } + + // The intake write and the snooze reset commit atomically — a + // recorded dose must never leave the medication snoozed-out of its + // own reminders, and a snooze reset must never land without the dose + // (the pre-band shape used a batch transaction here; the slot + // convergence dropped it). The canonical slot write accepts the + // transaction client; its P2002 race-recovery cannot continue inside + // an aborted Postgres transaction, so the rare create race rolls the + // whole write back instead of converging in place — Telegram + // redelivers the non-200 update and the retry finds the raced row. + scheduledFor = await prisma.$transaction(async (tx) => { + let slotScheduledFor: Date; + if (canonicalSlot) { + const applied = await applyCanonicalSlotWrite({ + client: tx, + userId, + medicationId: medication.id, + canonicalSlot, + takenAt, skipped: false, - source: "REMINDER", + isExplicitTaken: true, + isExplicitSkip: false, idempotencyKey, - }, - }), - prisma.medication.update({ + createSource: "REMINDER", + attributionSource: "AUTO", + }); + slotScheduledFor = applied.row.scheduledFor; + } else { + // Ad-hoc / PRN — standalone row under the documented contract + // (`scheduledFor = takenAt`). + const created = await tx.medicationIntakeEvent.create({ + data: { + userId, + medicationId: medication.id, + scheduledFor: takenAt, + takenAt, + skipped: false, + source: "REMINDER", + idempotencyKey, + }, + }); + slotScheduledFor = created.scheduledFor; + } + await tx.medication.update({ where: { id: medication.id }, data: { snoozedUntil: null }, - }), - ]); + }); + return slotScheduledFor; + }); + // The confirmation must reflect on the next read (today feed, card + // pill, compliance) rather than wait out the cache TTL. + invalidateUserMedications(userId, { evict: true }); } // v1.4.39 W-MED — refresh the compliance rollup for the // affected day so the next read after the cache miss reflects the - // new dose. Only fires when the create-path landed a new row. + // new dose. Only fires when the write landed a row. if (scheduledFor) { await recomputeMedicationComplianceForEvent({ userId, @@ -225,6 +371,7 @@ async function handleCallback(update: TelegramUpdate) { idempotencyKey, locale, user.timezone, + messageId !== undefined ? { chatId, messageId } : undefined, ); await answerTelegramCallbackQuery(botToken, callback.id, result.message); if (messageId) { @@ -313,25 +460,77 @@ async function handleCallback(update: TelegramUpdate) { if (!existing) { const endOfDay = new Date(); endOfDay.setHours(23, 59, 59, 999); - skippedScheduledFor = new Date(); + const skipMoment = new Date(); + const tz = user.timezone || DEFAULT_TIMEZONE; - await prisma.$transaction([ - prisma.medicationIntakeEvent.create({ - data: { + // v1.16.9 — a Telegram skip answers a specific dose prompt, so it + // must converge onto the slot's pending row like a take does. The + // old shape inserted a second row anchored at `now` and left the + // pending REMINDER row open to auto-miss — the user's deliberate + // skip recorded as a miss AND an orphan. Prefer the reminder's own + // slot; fall back to the band the skip moment falls in. + let canonicalSlot: Date | null = + messageId !== undefined + ? await resolveReminderSlot({ + userId: user.id, + medicationId: medication.id, + userTz: tz, + chatId, + messageId, + }) + : null; + if (canonicalSlot === null) { + const attribution = await resolveSlotForWriteByBand({ + userId: user.id, + medicationId: medication.id, + userTz: tz, + takenAt: skipMoment, + }); + canonicalSlot = attribution.slotInstant; + } + + // Atomic like the take path: the skip row and the rest-of-day + // snooze commit together, so a failure can never leave the user + // re-reminded for a recorded skip or silenced without one. Same + // P2002 trade-off as the take path — the rare create race rolls + // back and Telegram's redelivery converges on the raced row. + skippedScheduledFor = await prisma.$transaction(async (tx) => { + let slotScheduledFor: Date; + if (canonicalSlot) { + const applied = await applyCanonicalSlotWrite({ + client: tx, userId: user.id, medicationId: medication.id, - scheduledFor: skippedScheduledFor, + canonicalSlot, takenAt: null, skipped: true, - source: "REMINDER", + isExplicitTaken: false, + isExplicitSkip: true, idempotencyKey, - }, - }), - prisma.medication.update({ + createSource: "REMINDER", + }); + slotScheduledFor = applied.row.scheduledFor; + } else { + const created = await tx.medicationIntakeEvent.create({ + data: { + userId: user.id, + medicationId: medication.id, + scheduledFor: skipMoment, + takenAt: null, + skipped: true, + source: "REMINDER", + idempotencyKey, + }, + }); + slotScheduledFor = created.scheduledFor; + } + await tx.medication.update({ where: { id: medication.id }, data: { snoozedUntil: endOfDay }, - }), - ]); + }); + return slotScheduledFor; + }); + invalidateUserMedications(user.id, { evict: true }); } // v1.4.39 W-MED — refresh the compliance rollup for the skipped diff --git a/src/app/page.tsx b/src/app/page.tsx index 0e665bbd1..48e4dce3d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -36,12 +36,13 @@ import { useDashboardChartReveal, } from "@/components/dashboard/chart-reveal"; import { DashboardHeader } from "@/components/dashboard/dashboard-header"; +import { DashboardHero } from "@/components/dashboard/hero/dashboard-hero"; +import { DashboardHeroSkeleton } from "@/components/dashboard/hero/dashboard-hero-skeleton"; import { QuickEntrySheets, type QuickEntryDialog, } from "@/components/dashboard/quick-entry-sheets"; import { - getHourForTimeZone, getRangeColorClass, getRangeHint, toHoursSummary, @@ -684,43 +685,56 @@ export default function DashboardPage() { upperBound: 55, }, ); - // v1.4.40 W-RSC — memoise the per-render `Intl.DateTimeFormat` - // instantiation (audit-H4). The greeting derived from the hour only - // changes when the user's timezone changes; recomputing it every - // render churned the parts-array and walked the format ICU twice per - // render across a 1 400-line component body. - // Gated on `mounted`: `user` comes from the auth query, which can - // resolve before this boundary hydrates — the greeting text must - // match the name-less SSR output during hydration (React #418) and - // may only personalise from the first client re-render. - const userTimezone = mounted ? user?.timezone : undefined; - const hour = useMemo( - () => (userTimezone ? getHourForTimeZone(userTimezone) : null), - [userTimezone], - ); - const timeGreeting = - hour == null - ? t("dashboard.greeting.day") - : hour >= 5 && hour < 12 - ? t("dashboard.greeting.morning") - : hour >= 12 && hour < 18 - ? t("dashboard.greeting.day") - : t("dashboard.greeting.evening"); - const welcomeText = - mounted && user?.username && user.username.trim().length > 0 - ? t("dashboard.welcomeBackWithName", { - greeting: timeGreeting, - name: user.username, - }) - : t("dashboard.welcomeBack", { greeting: timeGreeting }); + // v1.7.0 — the primary data source differs by flag. In snapshot + // mode `analyticsSlimQuery` is `enabled: false`, and a disabled + // TanStack query reports `fetchStatus: "idle"` → `isLoading` is + // always `false`. Gating the skeleton on the slim query then + // never fires under snapshot mode, so the empty-state branch + // below flashes for the whole snapshot fetch. Key the loading + // flag off whichever query is actually driving the tiles. + // v1.16.4 — `!mounted` pins the SSR pass AND the hydration + // render to the skeleton branch. Server-side the data queries + // are `enabled: false` (no auth) → `isLoading: false`, which + // used to select the empty state; a late-hydrating boundary + // then saw `isAuthenticated` already resolved and rendered the + // skeleton instead — React #418. See `useMounted`. + // v1.16.9 — hoisted out of the strip IIFE: the hero band below + // shares the same gate so its skeleton and the tile silhouettes + // swap to content in the same render pass. + const primaryLoading = + !mounted || + (snapshotEnabled ? snapshotQuery.isLoading : analyticsSlimQuery.isLoading); + + // v1.16.9 — the greeting paragraph moved off the header into the + // hero band (it derives from the snapshot's server-computed + // `greetingHour`, so the old client-side Intl hour walk retired + // with it). The hero gates on the layout's `heroVisible` flag + // (default on; anything but the literal `false` renders) and holds + // a footprint-identical skeleton while the snapshot is in flight, + // so the band swaps in place — no growth, no greeting flash. When + // the hero does NOT render (toggle off, or snapshot flag off) the + // header takes the greeting back — it must never disappear with the + // band. + const heroVisible = snapshotEnabled && layout.heroVisible !== false; + const heroSnapshot = snapshotQuery.data; return (
+ {heroVisible && + (primaryLoading || !heroSnapshot ? ( + + ) : ( + + ))} + {/* v1.4.15 Phase B5 — spotlight tour for first-time users. * Self-gates on `user.onboardingTourCompleted` (DB flag) plus * a session-storage dismiss guard. We pass `ready=true` only @@ -1403,24 +1417,9 @@ export default function DashboardPage() { // strip tile) and undercounted `bp` (one widget id, two tiles), // so the silhouettes reshuffled when the data landed. const configuredTileCount = resolveConfiguredTileCount(layout); - // v1.7.0 — the primary data source differs by flag. In snapshot - // mode `analyticsSlimQuery` is `enabled: false`, and a disabled - // TanStack query reports `fetchStatus: "idle"` → `isLoading` is - // always `false`. Gating the skeleton on the slim query then - // never fires under snapshot mode, so the empty-state branch - // below flashes for the whole snapshot fetch. Key the loading - // flag off whichever query is actually driving the tiles. - // v1.16.4 — `!mounted` pins the SSR pass AND the hydration - // render to the skeleton branch. Server-side the data queries - // are `enabled: false` (no auth) → `isLoading: false`, which - // used to select the empty state; a late-hydrating boundary - // then saw `isAuthenticated` already resolved and rendered the - // skeleton instead — React #418. See `useMounted`. - const primaryLoading = - !mounted || - (snapshotEnabled - ? snapshotQuery.isLoading - : analyticsSlimQuery.isLoading); + // `primaryLoading` is derived once in the component body (above + // the return) — the hero band and this strip share the same + // gate so both surfaces swap in the same render pass. // v1.16.8 — chart-row reservation while the snapshot is in // flight. The chart visibility gates above need snapshot data // (`count > 0`), so the cold chart row only carried the diff --git a/src/components/dashboard/__tests__/dashboard-header.test.tsx b/src/components/dashboard/__tests__/dashboard-header.test.tsx new file mode 100644 index 000000000..da873f652 --- /dev/null +++ b/src/components/dashboard/__tests__/dashboard-header.test.tsx @@ -0,0 +1,57 @@ +/** + * `` greeting contract. + * + * The greeting line lives in the hero band when that renders; the hero + * is optional (snapshot flag off, or hidden via the dashboard-layout + * toggle), so the page feeds `showGreeting` from its hero gate and the + * header restores the pre-hero greeting paragraph for exactly those + * mounts. Pinned here: + * + * 1. `showGreeting` renders the greeting line (with the `min-h-5` + * line-box reservation that keeps the header height stable through + * the post-hydration name personalisation); + * 2. without the prop (the hero owns the greeting) the line is absent + * — no duplicate greeting above the hero band; + * 3. the SSR pass renders the name-less fallback (hydration-safe). + */ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { username: "tester", timezone: "Europe/Berlin" }, + }), +})); + +import { DashboardHeader } from "../dashboard-header"; + +function renderSSR(node: React.ReactElement, locale: "en" | "de" = "de") { + return renderToStaticMarkup( + {node}, + ); +} + +describe(" — greeting", () => { + it("renders the greeting line when the hero is hidden (showGreeting)", () => { + const html = renderSSR( + undefined} showGreeting />, + ); + expect(html).toContain('data-slot="dashboard-header-greeting"'); + // SSR pass: name-less fallback (personalises post-hydration only). + expect(html).toContain("willkommen zurück."); + // Reserved line box keeps the header height stable through the + // post-hydration personalisation swap. + expect(html).toMatch(/data-slot="dashboard-header-greeting"[^>]*>/); + expect(html).toContain("min-h-5"); + }); + + it("renders NO greeting line when the hero band owns it (default)", () => { + const html = renderSSR( undefined} />); + expect(html).not.toContain('data-slot="dashboard-header-greeting"'); + expect(html).not.toContain("willkommen zurück."); + // The title itself stays. + expect(html).toContain("Dashboard"); + }); +}); diff --git a/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx b/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx index df9042c79..54593d1fa 100644 --- a/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx +++ b/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx @@ -41,6 +41,13 @@ vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() }, })); +// v1.16.9 — the component reads the profile timezone for the auto-pick. +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { username: "tester", timezone: "Europe/Berlin" }, + }), +})); + import { MedicationIntakeQuickAdd, pickDefaultMedicationId, @@ -116,6 +123,95 @@ describe("pickDefaultMedicationId — auto-select heuristic", () => { expect(result).toBe("b"); }); + it("does not pre-select a medication whose server next-due is in the future (v1.16.9)", () => { + // The band alone reads "in window" at 10:00 Berlin, but the server + // says the next unresolved dose is tomorrow (a rolling cadence whose + // dose was taken) — the gate must keep the heuristic off it. + const now = new Date("2026-05-12T08:00:00Z"); // 10:00 Berlin (UTC+2) + const result = pickDefaultMedicationId( + [ + makeMed("a", { name: "Aaa" }), + makeMed("b", { + name: "Bbb", + nextDueAt: "2026-05-13T06:00:00.000Z", // tomorrow + nextDueOverdue: false, + schedules: [ + { + id: "s1", + windowStart: "08:00", + windowEnd: "11:00", + daysOfWeek: null, + label: null, + dose: null, + }, + ], + }), + ], + now, + ); + // Nothing genuinely due → alphabetical fallback ("Aaa"). + expect(result).toBe("a"); + }); + + it("does not pre-select a day-scale medication already taken early in its period (v1.16.9)", () => { + // Weekly Tuesday med (2026-05-12 is a Tuesday), taken two days ago. + const now = new Date("2026-05-12T08:00:00Z"); // 10:00 Berlin + const result = pickDefaultMedicationId( + [ + makeMed("a", { name: "Aaa" }), + makeMed("b", { + name: "Bbb", + lastTakenAt: "2026-05-10T07:00:00.000Z", // Sunday — early take + nextDueAt: "2026-05-12T07:00:00.000Z", // today's slot (unresolved) + nextDueOverdue: true, + schedules: [ + { + id: "s1", + windowStart: "09:00", + windowEnd: "09:00", + daysOfWeek: "2", + timesOfDay: ["09:00"], + label: null, + dose: null, + }, + ], + }), + ], + now, + ); + expect(result).toBe("a"); + }); + + it("reasons in the supplied profile timezone, not Berlin (v1.16.9)", () => { + // 08:00 UTC = 10:00 Berlin (inside the 08:00–11:00 window) but only + // 04:00 in New York (hours before it). The same instant must flip + // the pick with the timezone — pinning that the tz threads through + // both the wall-clock conversion and the window reduction. + const now = new Date("2026-05-12T08:00:00Z"); + const meds = [ + makeMed("a", { name: "Aaa" }), + makeMed("b", { + name: "Bbb", + schedules: [ + { + id: "s1", + windowStart: "08:00", + windowEnd: "11:00", + daysOfWeek: null, + label: null, + dose: null, + }, + ], + }), + ]; + expect(pickDefaultMedicationId(meds, now, undefined, "Europe/Berlin")).toBe( + "b", + ); + expect( + pickDefaultMedicationId(meds, now, undefined, "America/New_York"), + ).toBe("a"); + }); + it("falls back to the alphabetical-first active medication when nothing is due", () => { // 03:00 Berlin time: no schedules overlap. The helper should // alphabetically sort and pick the leading id, "ramipril". diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx index a8e7cb5c1..51c48967f 100644 --- a/src/components/dashboard/dashboard-header.tsx +++ b/src/components/dashboard/dashboard-header.tsx @@ -1,12 +1,18 @@ "use client"; /** - * Dashboard page header: title + greeting line, the customize shortcut, - * and the quick-add dropdown that opens the quick-entry sheets. + * Dashboard page header: title, the customize shortcut, and the + * quick-add dropdown that opens the quick-entry sheets. The greeting + * line lives in the hero band (``) when that renders — + * but the hero is optional (snapshot flag off, or hidden via the + * dashboard-layout toggle), and the greeting must never disappear with + * it. `showGreeting` (fed from the page's hero gate) restores the + * pre-hero greeting paragraph under the title for exactly those mounts. * * Extracted from the dashboard page; the page owns the open-state the * dropdown items set via `onQuickEntry`. */ +import { useMemo } from "react"; import Link from "next/link"; import { Activity, Pill, Plus, Settings2, Waves } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -17,37 +23,77 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useTranslations } from "@/lib/i18n/context"; +import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; +import { getHourForTimeZone } from "@/components/dashboard/range-display"; +import { cn } from "@/lib/utils"; import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets"; export function DashboardHeader({ - welcomeText, onQuickEntry, + showGreeting = false, }: { - welcomeText: string; onQuickEntry: (dialog: NonNullable) => void; + /** Render the greeting line here because the hero band does not. */ + showGreeting?: boolean; }) { const { t } = useTranslations(); + const { user } = useAuth(); + const mounted = useMounted(); + + // The pre-hero greeting derivation, kept hydration-safe: `user` comes + // from the auth query, which can resolve before this boundary + // hydrates — the text must match the name-less SSR output during + // hydration (React #418) and may only personalise from the first + // client re-render. + const userTimezone = mounted ? user?.timezone : undefined; + const hour = useMemo( + () => (userTimezone ? getHourForTimeZone(userTimezone) : null), + [userTimezone], + ); + const timeGreeting = + hour == null + ? t("dashboard.greeting.day") + : hour >= 5 && hour < 12 + ? t("dashboard.greeting.morning") + : hour >= 12 && hour < 18 + ? t("dashboard.greeting.day") + : t("dashboard.greeting.evening"); + const welcomeText = + mounted && user?.username && user.username.trim().length > 0 + ? t("dashboard.welcomeBackWithName", { + greeting: timeGreeting, + name: user.username, + }) + : t("dashboard.welcomeBack", { greeting: timeGreeting }); + return ( - /* v1.4.37 W4a item 7 — centre-align the Hinzufügen button - against the 2-line title block on mobile (< sm). The - `welcomeText` line wraps under the title at < 380 px so the - button used to float at the top of the row without a - baseline anchor; `items-center sm:items-start` keeps the - mobile vertical centre while preserving the original - top-aligned posture on sm+ (where the title is one line). */ -
+ /* With the greeting the title block is two lines on narrow + viewports: centre the buttons against it on mobile, top-align on + sm+ (the pre-hero posture). Without the greeting the single-line + centre alignment stands. */ +

{t("dashboard.title")}

- {/* v1.16.8 — `min-h-5` reserves the greeting's line box from the - SSR pass on: the text personalises (name appended) on the - first client re-render after hydration, and the reserved - line keeps the header from collapsing/growing around that - swap. */} -

- {welcomeText} -

+ {showGreeting ? ( + /* `min-h-5` reserves the greeting's line box from the SSR + pass on: the text personalises (name appended) on the first + client re-render after hydration, and the reserved line + keeps the header from collapsing/growing around that swap. */ +

+ {welcomeText} +

+ ) : null}
{/* Customize shortcut to the dashboard-customization settings diff --git a/src/components/dashboard/hero/__tests__/dashboard-hero-skeleton.test.tsx b/src/components/dashboard/hero/__tests__/dashboard-hero-skeleton.test.tsx new file mode 100644 index 000000000..0084eb4c6 --- /dev/null +++ b/src/components/dashboard/hero/__tests__/dashboard-hero-skeleton.test.tsx @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { DashboardHeroSkeleton } from "../dashboard-hero-skeleton"; + +/** + * Structural pin for the hero loading silhouette: the skeleton must + * occupy the EXACT final footprint of `` (same min-h + * floors, same md two-column split, same fixed 120 px ring circle) so + * the loaded band swaps in place with zero layout shift — and it must + * stay invisible to assistive tech with no focusable content. + */ +describe("", () => { + const html = renderToStaticMarkup(); + + it("carries the data-slot and aria-hidden contract", () => { + expect(html).toContain('data-slot="dashboard-hero-skeleton"'); + const root = html.match( + /]*data-slot="dashboard-hero-skeleton"[^>]*>/, + ); + expect(root).not.toBeNull(); + expect(root![0]).toContain('aria-hidden="true"'); + }); + + it("reserves the hero's exact footprint classes", () => { + for (const cls of [ + "min-h-[8.75rem]", + "md:min-h-[9.5rem]", + "hero-gradient", + "rounded-xl", + ]) { + expect(html).toContain(cls); + } + }); + + it("mirrors the two-column md:flex-row split with the fixed ring circle", () => { + expect(html).toContain("md:flex-row"); + expect(html).toContain("size-[120px]"); + expect(html).toContain("rounded-full"); + }); + + it("contains no focusable elements", () => { + expect(html).not.toContain("` contract: + * + * - every verdict variant renders its sentence + the single CTA the + * ladder assigns (link variants carry an href, quick-entry variants + * a button; allQuiet carries none); + * - the briefing rung renders the model headline VERBATIM as plain + * text (no i18n key); + * - the dose row prints the day tally with next-at / all-done / none + * detail, and the documented stale-cache state (past `nextDueAt`, + * `nextDueOverdue: false`) renders as the plain summary — never as + * overdue; + * - the greeting derives from the snapshot's server-computed + * `greetingHour` and personalises from the snapshot username; + * - the right column always paints the `sm` ScoreRing — provisional + * (never "0") when the score is null. + * + * The component reads the wall clock (`new Date()`) to age the cached + * snapshot honestly, so the suite pins the system time; the snapshot's + * own timezone (`Europe/Berlin`) keeps every assertion independent of + * the host zone. + */ + +const NOW = new Date("2026-06-10T10:00:00.000Z"); // 12:00 Europe/Berlin + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +function summary(partial: Partial): DataSummary { + return { + count: 0, + latest: null, + min: null, + max: null, + mean: null, + median: null, + avg7: null, + avg30: null, + slope7: null, + slope30: null, + slope90: null, + anomalyCount: 0, + ...partial, + }; +} + +function medsToday(partial: Partial = {}): MedsTodayBlock { + return { + activeCount: 0, + scheduledToday: 0, + takenToday: 0, + skippedToday: 0, + nextDueAt: null, + nextDueOverdue: false, + nextDueMedicationName: null, + ...partial, + }; +} + +function baseSnapshot( + overrides: Partial = {}, +): DashboardSnapshot { + return { + user: { + username: "tester", + timezone: "Europe/Berlin", + heightCm: null, + dateOfBirth: null, + gender: null, + glucoseUnit: null, + onboardingTourCompleted: true, + greetingHour: 12, + }, + layout: { version: 1, widgets: [] }, + layoutCatalogue: [], + metricStates: {}, + tiles: { + summaries: {}, + lastSeenByType: {}, + mood: { summary: null, entries: [] }, + }, + extras: null, + medsToday: medsToday(), + healthScore: null, + briefing: null, + briefingState: "preparing", + briefingUpdatedAt: null, + briefingStale: false, + generatedAt: NOW.toISOString(), + ...overrides, + }; +} + +function isoHoursFromNow(hours: number): string { + return new Date(NOW.getTime() + hours * 60 * 60 * 1000).toISOString(); +} + +function render( + snapshot: DashboardSnapshot, + opts: { locale?: "en" | "de"; onQuickEntry?: () => void } = {}, +) { + return renderToStaticMarkup( + + undefined)} + /> + , + ); +} + +describe(" — verdict variants", () => { + it("doseOverdue: sentence with the medication name + quick-entry CTA button", () => { + const html = render( + baseSnapshot({ + medsToday: medsToday({ + scheduledToday: 2, + nextDueAt: isoHoursFromNow(-1), + nextDueOverdue: true, + nextDueMedicationName: "Metformin", + }), + }), + ); + expect(html).toContain('data-verdict-variant="doseOverdue"'); + expect(html).toContain("Eine Dosis Metformin ist überfällig."); + expect(html).toContain('data-slot="dashboard-hero-cta"'); + expect(html).toContain("Jetzt eintragen"); + // Quick-entry CTA is a real button, not a link. + expect(html).toMatch(/]*data-slot="dashboard-hero-cta"/); + }); + + it("doseOverdue: a null medication name renders the name-less sentence (no hole)", () => { + const html = render( + baseSnapshot({ + medsToday: medsToday({ + scheduledToday: 2, + nextDueAt: isoHoursFromNow(-1), + nextDueOverdue: true, + nextDueMedicationName: null, + }), + }), + ); + expect(html).toContain('data-verdict-variant="doseOverdue"'); + expect(html).toContain("Eine Dosis ist überfällig."); + // The named template with an empty interpolation leaves a double + // space — it must never render. + expect(html).not.toContain("Eine Dosis ist überfällig."); + }); + + it("doseUpcoming: a null medication name renders the name-less sentence (no hole)", () => { + const nextDueAt = isoHoursFromNow(1.5); // 13:30 Berlin + const html = render( + baseSnapshot({ + medsToday: medsToday({ + scheduledToday: 2, + takenToday: 1, + nextDueAt, + nextDueMedicationName: null, + }), + }), + ); + const expectedTime = makeFormatters("de", "Europe/Berlin", "AUTO").time( + nextDueAt, + ); + expect(html).toContain('data-verdict-variant="doseUpcoming"'); + expect(html).toContain(`Um ${expectedTime} steht eine Dosis an.`); + expect(html).not.toContain(`steht an`); + }); + + it("bpCritical: fixed-floor sentence with the reading + link to the BP insight", () => { + const html = render( + baseSnapshot({ + tiles: { + summaries: { + BLOOD_PRESSURE_SYS: summary({ latest: 184, count: 5 }), + BLOOD_PRESSURE_DIA: summary({ latest: 96, count: 5 }), + }, + lastSeenByType: { + BLOOD_PRESSURE_SYS: { + lastSeenAt: isoHoursFromNow(-2), + daysAgo: 0, + }, + }, + mood: { summary: null, entries: [] }, + }, + }), + ); + expect(html).toContain('data-verdict-variant="bpCritical"'); + expect(html).toContain("sehr hoch (184/96)"); + expect(html).toContain('href="/insights/blood-pressure"'); + expect(html).toContain("Blutdruck ansehen"); + }); + + it("doseUpcoming: preference-aware wall-clock time + name + quick-entry CTA", () => { + const nextDueAt = isoHoursFromNow(1.5); // 13:30 Berlin + const html = render( + baseSnapshot({ + medsToday: medsToday({ + scheduledToday: 2, + takenToday: 1, + nextDueAt, + nextDueMedicationName: "Jardiance", + }), + }), + ); + const expectedTime = makeFormatters("de", "Europe/Berlin", "AUTO").time( + nextDueAt, + ); + expect(html).toContain('data-verdict-variant="doseUpcoming"'); + expect(html).toContain(`Um ${expectedTime} steht Jardiance an.`); + expect(html).toContain("Jetzt eintragen"); + // The dose row repeats the same instant as "Nächste um …". + expect(html).toContain(`Nächste um ${expectedTime}`); + }); + + it("weightDrift: sentence + link to the weight insight", () => { + const html = render( + baseSnapshot({ + user: { ...baseSnapshot().user, heightCm: 180 }, + tiles: { + summaries: { + // Green range for 180 cm ≈ 59.9–80.7 kg; 7d mean sits 1 kg + // further outside than the 30d mean. + WEIGHT: summary({ avg7: 86, avg30: 85, count: 10 }), + }, + lastSeenByType: { + WEIGHT: { lastSeenAt: isoHoursFromNow(-3), daysAgo: 0 }, + }, + mood: { summary: null, entries: [] }, + }, + }), + ); + expect(html).toContain('data-verdict-variant="weightDrift"'); + expect(html).toContain( + "Dein Gewicht bewegt sich diese Woche vom Zielbereich weg.", + ); + expect(html).toContain('href="/insights/weight"'); + expect(html).toContain("Gewicht ansehen"); + }); + + it("shortNights: locale-formatted hours + link to the sleep insight", () => { + const html = render( + baseSnapshot({ + tiles: { + summaries: { + // 372 min = 6.2 h — clearly under the 6.5 h fire line. + SLEEP_DURATION: summary({ avg7: 372, count: 7 }), + }, + lastSeenByType: { + SLEEP_DURATION: { lastSeenAt: isoHoursFromNow(-5), daysAgo: 0 }, + }, + mood: { summary: null, entries: [] }, + }, + }), + ); + expect(html).toContain('data-verdict-variant="shortNights"'); + // German decimal comma via the locale number formatter. + expect(html).toContain("im Schnitt 6,2 h."); + expect(html).toContain('href="/insights/sleep"'); + expect(html).toContain("Schlaf ansehen"); + }); + + it("silence: day count + quick-entry measurement CTA", () => { + const html = render( + baseSnapshot({ + tiles: { + summaries: {}, + lastSeenByType: { + WEIGHT: { lastSeenAt: isoHoursFromNow(-9 * 24), daysAgo: 9 }, + }, + mood: { summary: null, entries: [] }, + }, + }), + ); + expect(html).toContain('data-verdict-variant="silence"'); + expect(html).toContain("Seit 9 Tagen keine Messung."); + expect(html).toContain("Messung erfassen"); + expect(html).toMatch(/]*data-slot="dashboard-hero-cta"/); + }); + + it("scoreDrop: point delta + link to /insights", () => { + const html = render( + baseSnapshot({ + healthScore: { score: 58, band: "yellow", delta: -14 }, + }), + ); + expect(html).toContain('data-verdict-variant="scoreDrop"'); + expect(html).toContain("liegt 14 Punkte unter der Vorwoche."); + expect(html).toContain('href="/insights"'); + expect(html).toContain("Insights öffnen"); + }); + + it("briefing: renders the model headline verbatim as plain text + Insights link", () => { + const headline = "Systolic crept up over seven days."; + const html = render( + baseSnapshot({ + briefingState: "ready", + briefingStale: false, + briefingUpdatedAt: NOW.toISOString(), + briefing: { + paragraph: "p", + keyFindings: [ + { + tone: "watch", + headline, + detail: "detail", + delta: null, + sourceWindow: "7d", + sourceMetric: "bp", + }, + ], + } as DashboardSnapshot["briefing"], + }), + ); + expect(html).toContain('data-verdict-variant="briefing"'); + expect(html).toContain(headline); + expect(html).toContain('href="/insights"'); + expect(html).toContain("Insights öffnen"); + }); + + it("allQuiet: sentence with NO CTA in the band", () => { + const html = render(baseSnapshot()); + expect(html).toContain('data-verdict-variant="allQuiet"'); + expect(html).toContain("Alles ruhig"); + expect(html).not.toContain('data-slot="dashboard-hero-cta"'); + }); + + it("exactly one CTA renders for an actionable verdict", () => { + const html = render( + baseSnapshot({ + medsToday: medsToday({ + scheduledToday: 1, + nextDueOverdue: true, + nextDueAt: isoHoursFromNow(-1), + nextDueMedicationName: "Metformin", + }), + healthScore: { score: 50, band: "yellow", delta: -20 }, + }), + ); + const ctas = html.match(/data-slot="dashboard-hero-cta"/g) ?? []; + expect(ctas).toHaveLength(1); + }); +}); + +describe(" — dose row", () => { + it("prints the day tally with the v1.16.6 card chrome and the Pill icon", () => { + const html = render( + baseSnapshot({ + medsToday: medsToday({ scheduledToday: 3, takenToday: 1 }), + }), + ); + expect(html).toContain("1 von 3 Dosen genommen"); + const doseRow = html.match( + /]*data-slot="dashboard-hero-doses"[^>]*>/, + ); + expect(doseRow).not.toBeNull(); + for (const cls of [ + "bg-card/65", + "border-border/60", + "rounded-xl", + "shadow-sm", + "backdrop-blur-sm", + ]) { + expect(doseRow![0]).toContain(cls); + } + }); + + it("all doses resolved (taken + skipped) → 'Alle Dosen für heute erledigt'", () => { + const html = render( + baseSnapshot({ + medsToday: medsToday({ + scheduledToday: 2, + takenToday: 1, + skippedToday: 1, + }), + }), + ); + expect(html).toContain("1 von 2 Dosen genommen"); + expect(html).toContain("Alle Dosen für heute erledigt"); + expect(html).not.toContain("Nächste um"); + }); + + it("no doses scheduled → 'Heute keine Dosen geplant'", () => { + const html = render(baseSnapshot()); + expect(html).toContain("Heute keine Dosen geplant"); + expect(html).not.toContain("Dosen genommen"); + }); + + it("DEFENSIVE: a stale past nextDueAt with nextDueOverdue false renders the plain summary, never overdue", () => { + const html = render( + baseSnapshot({ + medsToday: medsToday({ + scheduledToday: 2, + takenToday: 1, + nextDueAt: isoHoursFromNow(-0.5), // anchor passed AFTER build + nextDueOverdue: false, + nextDueMedicationName: "Metformin", + }), + }), + ); + expect(html).toContain("1 von 2 Dosen genommen"); + expect(html).not.toContain("Nächste um"); + expect(html).not.toContain("überfällig"); + expect(html).toContain('data-verdict-variant="allQuiet"'); + }); + + it("a next-due anchor on TOMORROW's local day stays off the dose row", () => { + const html = render( + baseSnapshot({ + medsToday: medsToday({ + scheduledToday: 2, + takenToday: 1, + nextDueAt: isoHoursFromNow(20), // 08:00 Berlin tomorrow + nextDueMedicationName: "Metformin", + }), + }), + ); + expect(html).toContain("1 von 2 Dosen genommen"); + expect(html).not.toContain("Nächste um"); + }); +}); + +describe(" — greeting", () => { + it("personalises from the snapshot username with the server greeting hour", () => { + const html = render( + baseSnapshot({ + user: { ...baseSnapshot().user, username: "Sam", greetingHour: 8 }, + }), + ); + expect(html).toContain("Guten Morgen Sam, willkommen zurück."); + }); + + it("falls back to the name-less line when the username is blank", () => { + const html = render( + baseSnapshot({ + user: { ...baseSnapshot().user, username: " ", greetingHour: 20 }, + }), + ); + expect(html).toContain("Guten Abend, willkommen zurück."); + }); + + it("derives the daypart from greetingHour without any client Intl walk", () => { + const day = render( + baseSnapshot({ + user: { ...baseSnapshot().user, greetingHour: 13 }, + }), + ); + expect(day).toContain("Hallo tester, willkommen zurück."); + }); +}); + +describe(" — score column", () => { + it("renders the sm ScoreRing with the score + band", () => { + const html = render( + baseSnapshot({ + healthScore: { score: 82, band: "green", delta: 2 }, + }), + ); + expect(html).toContain('data-slot="score-ring"'); + expect(html).toContain('data-band="green"'); + expect(html).toContain("Health Score"); + }); + + it("null score renders the provisional ring (not 0) at identical geometry", () => { + const html = render(baseSnapshot({ healthScore: null })); + expect(html).toContain('data-slot="score-ring"'); + expect(html).toContain('data-provisional="true"'); + expect(html).toContain("Noch nicht genug Daten"); + // The ring announces "not enough data", never a zero value. + const aria = html.match( + /]*data-slot="score-ring"[^>]*aria-label="([^"]+)"/, + ); + expect(aria).not.toBeNull(); + expect(aria![1]).not.toMatch(/\b0\b/); + // Same fixed footprint as the scored state — the column never + // collapses (the 120 px sm geometry rides an inline style). + expect(html).toContain("width:120px"); + }); +}); + +describe(" — source pins", () => { + const src = readFileSync( + join(process.cwd(), "src/components/dashboard/hero/dashboard-hero.tsx"), + "utf8", + ); + + it("quick-entry CTA wires the verdict's target into onQuickEntry", () => { + expect(src).toMatch(/onClick=\{\(\) => onQuickEntry\(cta\.target\)\}/); + }); + + it("re-derives the verdict with a fresh now per snapshot", () => { + expect(src).toMatch(/resolveDashboardVerdict\(snapshot,\s*new Date\(\)\)/); + }); + + it("uses the hero-gradient chrome WITHOUT glow-purple", () => { + expect(src).toContain("hero-gradient"); + expect(src).not.toContain("glow-purple"); + }); +}); diff --git a/src/components/dashboard/hero/dashboard-hero-skeleton.tsx b/src/components/dashboard/hero/dashboard-hero-skeleton.tsx new file mode 100644 index 000000000..3b85873cc --- /dev/null +++ b/src/components/dashboard/hero/dashboard-hero-skeleton.tsx @@ -0,0 +1,45 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +/** + * Loading silhouette for the dashboard hero band. + * + * Mirrors the real `` footprint exactly — same + * `.hero-gradient` chrome, same `min-h-[8.75rem] md:min-h-[9.5rem]` + * floor, same two-column `md:flex-row` split with the fixed 120 px ring + * circle on the right — so the swap to the loaded hero happens in place + * with zero layout shift. The left column reserves the greeting line, + * the verdict sentence + CTA row, and the dose-row pill. + * + * Always `aria-hidden` with no focusable content: the tile-strip + * skeleton alongside carries the page's loading semantics, and a second + * announcement here would double up for screen readers. Reduced motion + * is honoured inside the `` primitive + * (`motion-reduce:animate-none`). + */ +export function DashboardHeroSkeleton() { + return ( + + ); +} diff --git a/src/components/dashboard/hero/dashboard-hero.tsx b/src/components/dashboard/hero/dashboard-hero.tsx new file mode 100644 index 000000000..3d917740c --- /dev/null +++ b/src/components/dashboard/hero/dashboard-hero.tsx @@ -0,0 +1,286 @@ +"use client"; + +/** + * Dashboard hero — the daily-verdict band between the page header and + * the tile strip. + * + * Purely presentational: every input rides in on the already-resolved + * `DashboardSnapshot`, the verdict is re-derived on render with a fresh + * `now` (so a cached snapshot ages honestly), and the only callback is + * the quick-entry opener the page owns. Three blocks: + * + * - left column: greeting line (absorbed from the old header + * paragraph — the snapshot's server-computed `greetingHour` keeps + * the daypart free of any client `Intl` work), the verdict sentence + * with its single CTA button, and the dose row; + * - right column: the shared `` at `sm` geometry. A null + * score renders the ring's provisional state at the identical + * 120 px footprint, so the column never collapses. + * + * Defensive contract (cached snapshot): `medsToday.nextDueAt` in the + * past with `nextDueOverdue: false` means the slot's anchor passed + * after the snapshot was built. The verdict resolver already falls + * through (rung 2 keys on the flag, rung 3 requires a future anchor), + * and the dose row below only prints "next at" for a FUTURE anchor on + * the user's local today — the stale state renders as the plain day + * summary, never as overdue. + * + * Rung 8 (`briefing`) renders the model-authored headline VERBATIM as a + * plain React text child — no i18n key, no HTML, no markdown — per the + * repo-wide no-markdown-renderer rule. + * + * The page h1 stays in ``; this band is supporting + * content, so it renders non-heading text only (matching the Insights + * hero strip, which also carries no heading element). + */ +import { useMemo } from "react"; +import Link from "next/link"; +import { Pill } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { ScoreRing } from "@/components/insights/derived/score-ring"; +import { + resolveDashboardVerdict, + type DashboardVerdictVariant, +} from "@/lib/dashboard/verdict"; +import type { DashboardSnapshot } from "@/lib/dashboard/snapshot"; +import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets"; +import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context"; +import { makeFormatters } from "@/lib/format-locale"; +import { cn } from "@/lib/utils"; + +/** + * Variant → message key. `briefing` is deliberately absent — its + * sentence is the model headline rendered verbatim, not a translation. + * The bpCritical rung surfaces under the `bpHigh` copy key. + */ +const VERDICT_MESSAGE_KEY: Record< + Exclude, + string +> = { + doseOverdue: "dashboard.hero.verdict.doseOverdue", + bpCritical: "dashboard.hero.verdict.bpHigh", + doseUpcoming: "dashboard.hero.verdict.doseUpcoming", + weightDrift: "dashboard.hero.verdict.weightDrift", + shortNights: "dashboard.hero.verdict.shortNights", + silence: "dashboard.hero.verdict.silence", + scoreDrop: "dashboard.hero.verdict.scoreDrop", + allQuiet: "dashboard.hero.verdict.allQuiet", +}; + +/** Variant → CTA label key. `allQuiet` carries no CTA (cta: null). */ +const CTA_LABEL_KEY: Partial> = { + doseOverdue: "dashboard.hero.action.takeDose", + doseUpcoming: "dashboard.hero.action.takeDose", + bpCritical: "dashboard.hero.action.viewBp", + weightDrift: "dashboard.hero.action.viewWeight", + shortNights: "dashboard.hero.action.viewSleep", + silence: "dashboard.hero.action.logMeasurement", + scoreDrop: "dashboard.hero.action.viewInsights", + briefing: "dashboard.hero.action.viewInsights", +}; + +/** YYYY-MM-DD key of `d`'s calendar day in `tz` (mirrors the resolver). */ +function dayKeyInTz(d: Date, tz: string): string { + return new Intl.DateTimeFormat("en-CA", { + timeZone: tz, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(d); +} + +export function DashboardHero({ + snapshot, + onQuickEntry, +}: { + snapshot: DashboardSnapshot; + onQuickEntry: (dialog: NonNullable) => void; +}) { + const { t, locale } = useTranslations(); + const timeFormat = useTimeFormatPreference(); + // Times format in the USER's timezone from the snapshot (the shared + // `useFormatters()` hook falls back to the display default), honouring + // the per-user hour-cycle preference like the medication cards do. + // Only ever invoked post-snapshot, so no pre-paint Intl work. + const fmt = useMemo( + () => makeFormatters(locale, snapshot.user.timezone, timeFormat), + [locale, snapshot.user.timezone, timeFormat], + ); + + // Fresh `now` per snapshot evaluation: the resolver re-derives every + // freshness window against the current instant, so a snapshot served + // from cache ages honestly between refetches. + const verdict = useMemo( + () => resolveDashboardVerdict(snapshot, new Date()), + [snapshot], + ); + + // ── Greeting — absorbed from the old header paragraph ────────────── + // The server-computed wall-clock hour keeps the daypart stable and + // Intl-free; the hero only mounts once the snapshot resolved (the + // skeleton carries the footprint before that), so the personalised + // line can never disagree with SSR output. + const hour = snapshot.user.greetingHour; + const timeGreeting = + hour >= 5 && hour < 12 + ? t("dashboard.greeting.morning") + : hour >= 12 && hour < 18 + ? t("dashboard.greeting.day") + : t("dashboard.greeting.evening"); + const name = snapshot.user.username.trim(); + const welcomeText = + name.length > 0 + ? t("dashboard.welcomeBackWithName", { greeting: timeGreeting, name }) + : t("dashboard.welcomeBack", { greeting: timeGreeting }); + + // ── Verdict sentence + CTA ────────────────────────────────────────── + // The dose variants interpolate a medication name; a snapshot can carry + // none (the resolver emits `""`), and the named sentence would render + // with a hole ("Eine Dosis ist überfällig."). Swap to the name-less + // sibling key instead of patching a generic word into the gap. + const verdictName = + typeof verdict.values.name === "string" ? verdict.values.name.trim() : ""; + const messageKey = (variant: Exclude) => + variant === "doseOverdue" && verdictName.length === 0 + ? "dashboard.hero.verdict.doseOverdueNoName" + : variant === "doseUpcoming" && verdictName.length === 0 + ? "dashboard.hero.verdict.doseUpcomingNoName" + : VERDICT_MESSAGE_KEY[variant]; + const sentence = + verdict.variant === "briefing" + ? // Model headline VERBATIM as a plain text child — no key. + String(verdict.values.headline ?? "") + : t(messageKey(verdict.variant), { + ...verdict.values, + // The resolver emits a fixed 24 h wall-clock string; re-format + // the underlying instant through the user's hour-cycle + // preference when we still hold it. + ...(verdict.variant === "doseUpcoming" && + snapshot.medsToday.nextDueAt !== null + ? { time: fmt.time(snapshot.medsToday.nextDueAt) } + : {}), + ...(verdict.variant === "shortNights" && + typeof verdict.values.hours === "number" + ? { hours: fmt.number(verdict.values.hours, 1) } + : {}), + }); + const cta = verdict.cta; + const ctaLabelKey = CTA_LABEL_KEY[verdict.variant]; + + // ── Dose row ──────────────────────────────────────────────────────── + const meds = snapshot.medsToday; + const now = new Date(); + const tz = snapshot.user.timezone; + const unresolvedRemain = + meds.scheduledToday > meds.takenToday + meds.skippedToday; + const nextDueMs = meds.nextDueAt !== null ? Date.parse(meds.nextDueAt) : NaN; + // "Next at" only for a FUTURE anchor on the user's local today. A past + // anchor with `nextDueOverdue: false` is the documented stale-cache + // state and must render as the plain summary — never as overdue. + const showNextAt = + unresolvedRemain && + Number.isFinite(nextDueMs) && + nextDueMs >= now.getTime() && + dayKeyInTz(new Date(nextDueMs), tz) === dayKeyInTz(now, tz); + const doseDetail = + meds.scheduledToday > 0 + ? !unresolvedRemain + ? t("dashboard.hero.doses.allDone") + : showNextAt + ? t("dashboard.hero.doses.nextAt", { + time: fmt.time(new Date(nextDueMs)), + }) + : null + : null; + + return ( +
+
+
+

+ {welcomeText} +

+
+

+ {sentence} +

+ {cta !== null && ctaLabelKey ? ( + cta.kind === "link" ? ( + + ) : ( + + ) + ) : null} +
+
+
+
+ {/* Right column — fixed 120 px ring footprint. A null score + renders the ring's provisional state (aria announces "not + enough data", never 0) at identical geometry, so the column + NEVER collapses. */} +
+ +
+
+
+ ); +} diff --git a/src/components/dashboard/medication-intake-quick-add.tsx b/src/components/dashboard/medication-intake-quick-add.tsx index 353156978..b85d45fa2 100644 --- a/src/components/dashboard/medication-intake-quick-add.tsx +++ b/src/components/dashboard/medication-intake-quick-add.tsx @@ -25,9 +25,10 @@ import { } from "@/lib/query-keys"; import { reduceCurrentWindowStatus, - toBerlinDate, + toZonedDate, type ScheduleWindowInput, } from "@/lib/medications/window-status"; +import { useAuth } from "@/hooks/use-auth"; import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch"; /** @@ -80,6 +81,15 @@ export interface MedicationOption { schedules: Schedule[]; lastTakenAt: string | null; todayEventCount: number | null; + /** + * v1.16.9 — the server display-due verdict from `GET /api/medications` + * (`nextDueAt` + `nextDueOverdue`). Threaded into the default pick so + * the quick-add gates its "due now" heuristic exactly like the cards: + * a rolling cadence whose next dose is days away must not pre-select. + * Optional for older mocks; absent keeps the legacy band-only pick. + */ + nextDueAt?: string | null; + nextDueOverdue?: boolean; } interface MedicationIntakeQuickAddProps { @@ -120,23 +130,40 @@ export function pickDefaultMedicationId( lateMinutes: 120, missedMinutes: 240, }, + /** + * v1.16.9 — the profile timezone the "due now" heuristic reasons in + * (the same source the cards use). Berlin stays the last-resort + * fallback for logged-out mounts and legacy fixtures. + */ + tz: string = "Europe/Berlin", ): string | null { const actives = options.filter((m) => m.active); if (actives.length === 0) return null; if (actives.length === 1) return actives[0].id; - const nowBerlin = toBerlinDate(now); + const nowLocal = toZonedDate(now, tz); const due = actives.find((m) => { + // v1.16.9 — the same server display-due gate the cards apply: a + // future (non-overdue) next-due suppresses the overdue tiers and a + // day-scale dose taken early in its period must not pre-select. + const nextDueMs = m.nextDueAt ? new Date(m.nextDueAt).getTime() : NaN; const status = reduceCurrentWindowStatus({ schedules: m.schedules, - nowBerlin, + nowBerlin: nowLocal, lateMinutes: thresholds.lateMinutes, missedMinutes: thresholds.missedMinutes, active: true, lastTakenAt: m.lastTakenAt, todayEventCount: m.todayEventCount ?? 0, + tz, + nextDue: + m.nextDueAt === undefined + ? undefined + : Number.isFinite(nextDueMs) + ? { at: new Date(nextDueMs), overdue: m.nextDueOverdue === true } + : null, }); - return status.status !== null; + return status.status !== null && status.takenEarlyDaysAgo === null; }); if (due) return due.id; @@ -154,6 +181,11 @@ export function MedicationIntakeQuickAdd({ }: MedicationIntakeQuickAddProps) { const { t } = useTranslations(); const queryClient = useQueryClient(); + // v1.16.9 — the auto-pick reasons in the PROFILE timezone (the same + // source the cards use); Berlin stays the last-resort fallback so + // logged-out mounts behave unchanged. + const { user } = useAuth(); + const userTz = user?.timezone || "Europe/Berlin"; const { data: medicationsRaw, isLoading: medicationsLoading } = useQuery({ queryKey: queryKeys.medications(), @@ -210,8 +242,8 @@ export function MedicationIntakeQuickAdd({ const formId = useId(); const defaultMedicationId = useMemo( - () => pickDefaultMedicationId(medications), - [medications], + () => pickDefaultMedicationId(medications, new Date(), undefined, userTz), + [medications, userTz], ); const medicationId = medicationOverride ?? defaultMedicationId ?? ""; const selectedMedication = medications.find((m) => m.id === medicationId); diff --git a/src/components/medications/card-parts/medication-card-body.tsx b/src/components/medications/card-parts/medication-card-body.tsx index bc695e3b7..5a27eeadb 100644 --- a/src/components/medications/card-parts/medication-card-body.tsx +++ b/src/components/medications/card-parts/medication-card-body.tsx @@ -71,6 +71,8 @@ export interface MedicationCardBodyProps { status: Exclude; windowStart: string; windowEnd: string; + /** v1.16.9 — day-scale last-dose context in whole days (see the pill). */ + takenEarlyDaysAgo?: number | null; } | null; /** @@ -178,7 +180,17 @@ export function MedicationCardBody({ overdue tier arrives with the compliance query, and a line popping in late used to shift every row below it (CLS). */}
- {overdueLabel ? ( + {windowStatus?.takenEarlyDaysAgo != null ? ( + // v1.16.9 — last-dose context outranks the overdue line: a + // day-scale dose already taken earlier in its period must never + // escalate into a take prompt (double-dose risk). + + ) : overdueLabel ? (

+ + + + {" "} + — {formatTimeWindowRange(windowStart, windowEnd, locale)} + +

+ ); + } + return (

x + 1, 0); @@ -288,7 +293,7 @@ export function Glp1MedicationCard({ a.windowStart.localeCompare(b.windowStart) || a.windowEnd.localeCompare(b.windowEnd), ); - const nowBerlin = toBerlinDate(now); + const nowBerlin = toZonedDate(now, userTz); const lateMinutes = thresholds?.lateMinutes ?? 120; const missedMinutes = thresholds?.missedMinutes ?? 240; const currentWindowStatus = reduceCurrentWindowStatus({ @@ -299,6 +304,7 @@ export function Glp1MedicationCard({ active: medication.active, lastTakenAt: medication.lastTakenAt, todayEventCount: medication.todayEventCount ?? 0, + tz: userTz, // v1.16.6 — gate the pill on the server display-due so a rolling // cadence whose next injection is days away can never paint an overdue // pill today. `undefined` (older payloads / fixtures) keeps legacy @@ -319,6 +325,7 @@ export function Glp1MedicationCard({ currentWindowStatus, nextDueAt: medication.nextDueAt, now, + timeZone: userTz, }); // v1.15.9 — the recent-injection list now feeds ONLY the post-dose @@ -439,6 +446,7 @@ export function Glp1MedicationCard({ // legacy schedule window (which may be stale / degenerate). windowStart: currentWindowStatus.window!.start, windowEnd: currentWindowStatus.window!.end, + takenEarlyDaysAgo: currentWindowStatus.takenEarlyDaysAgo, } : null } diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index af72a7006..30a7a8ec2 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -13,7 +13,7 @@ import { formatDateTime, formatTime } from "@/lib/format"; import { getMedicationCategoryLabel } from "@/lib/medications/category-label"; import { reduceCurrentWindowStatus, - toBerlinDate, + toZonedDate, } from "@/lib/medications/window-status"; import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; @@ -26,6 +26,7 @@ import { apiGet, apiPost } from "@/lib/api/api-fetch"; import { useMedicationComplianceSummary } from "@/lib/queries/use-medication-compliance-summary"; import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog"; import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs"; +import { useAuth } from "@/hooks/use-auth"; import type { InjectionSiteKey } from "@/lib/medications/injection-sites"; interface Schedule { @@ -104,6 +105,11 @@ export function MedicationCard({ }: MedicationCardProps) { const queryClient = useQueryClient(); const { t, locale } = useTranslations(); + // v1.16.9 — the card reasons in the PROFILE timezone (the snapshot the + // session carries); Berlin stays the last-resort fallback so logged-out + // mounts and legacy fixtures behave unchanged. + const { user } = useAuth(); + const userTz = user?.timezone || "Europe/Berlin"; const fmt = useFormatters(); const weekdayLabel = useWeekdayLabel(); // v1.8.5 — post-dose injection-site prompt state. Holds the intake @@ -199,7 +205,7 @@ export function MedicationCard({ a.windowStart.localeCompare(b.windowStart) || a.windowEnd.localeCompare(b.windowEnd), ); - const nowBerlin = toBerlinDate(new Date()); + const nowBerlin = toZonedDate(new Date(), userTz); // v1.8.4 — the next-due instant comes from the server (`nextDueAt`, // computed by the canonical recurrence engine anchored on the last // intake). The day label below derives from it; the window-range / @@ -224,6 +230,7 @@ export function MedicationCard({ active: medication.active, lastTakenAt: medication.lastTakenAt, todayEventCount: medication.todayEventCount ?? 0, + tz: userTz, // v1.16.6 — gate the pill on the server display-due so a rolling // cadence whose next dose is tomorrow can never paint an overdue pill // today. `undefined` (older payloads / fixtures) keeps legacy behaviour. @@ -231,7 +238,10 @@ export function MedicationCard({ medication.nextDueAt === undefined ? undefined : nextAt !== undefined - ? { at: new Date(nextAt), overdue: medication.nextDueOverdue === true } + ? { + at: new Date(nextAt), + overdue: medication.nextDueOverdue === true, + } : null, }); @@ -244,6 +254,7 @@ export function MedicationCard({ currentWindowStatus, nextDueAt: medication.nextDueAt, now: new Date(), + timeZone: userTz, }); function formatLastTakenAt(value: string): string { @@ -251,7 +262,7 @@ export function MedicationCard({ // string-comparable for the today / yesterday / older bucketing below. // The actual user-facing display goes through formatTime / formatDateTime. const dayFormatter = new Intl.DateTimeFormat("en-CA", { - timeZone: "Europe/Berlin", + timeZone: userTz, year: "numeric", month: "2-digit", day: "2-digit", @@ -314,7 +325,7 @@ export function MedicationCard({ // Format day label relative to today let dayLabel = ""; if (nextAt) { - const nextDate = toBerlinDate(new Date(nextAt)); + const nextDate = toZonedDate(new Date(nextAt), userTz); const todayStr = `${nowBerlin.getFullYear()}-${nowBerlin.getMonth()}-${nowBerlin.getDate()}`; const nextStr = `${nextDate.getFullYear()}-${nextDate.getMonth()}-${nextDate.getDate()}`; const tomorrow = new Date(nowBerlin); @@ -381,13 +392,16 @@ export function MedicationCard({ // legacy schedule window (which may be stale / degenerate). windowStart: currentWindowStatus.window!.start, windowEnd: currentWindowStatus.window!.end, + takenEarlyDaysAgo: currentWindowStatus.takenEarlyDaysAgo, } : null } doseStatus={doseStatus} nextLine={nextLine} lastLine={ - medication.lastTakenAt ? formatLastTakenAt(medication.lastTakenAt) : null + medication.lastTakenAt + ? formatLastTakenAt(medication.lastTakenAt) + : null } compliance={ compliance ? { rate7, rate30, streak, shortDays, longDays } : null diff --git a/src/components/settings/__tests__/dashboard-layout-section.test.tsx b/src/components/settings/__tests__/dashboard-layout-section.test.tsx index d6d740bde..73ab9f58f 100644 --- a/src/components/settings/__tests__/dashboard-layout-section.test.tsx +++ b/src/components/settings/__tests__/dashboard-layout-section.test.tsx @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { DEFAULT_DASHBOARD_LAYOUT, @@ -269,6 +271,76 @@ describe(" — iOS-only id skip (v1.7.0)", () => { }); }); +/** + * Dashboard hero (Tagesüberblick) visibility switch — one toggle above + * the widget list, persisted as `heroVisible` on the layout blob through + * the SAME PUT mutation the widget rows use (the Save button flushes the + * draft). SSR smoke assertions + source pins, matching the rest of this + * suite (no DOM runtime for state mutation). + */ +describe(" — hero visibility switch", () => { + it("renders one hero switch above the widget list", () => { + const html = render(); + const switches = html.match(/data-slot="hero-visible-switch"/g) ?? []; + expect(switches).toHaveLength(1); + // Above the list: the hero switch markup precedes the first widget row. + expect(html.indexOf('data-slot="hero-visible-switch"')).toBeLessThan( + html.indexOf('data-slot="widget-row"'), + ); + }); + + it("labels the switch with the localised copy + description", () => { + const de = render(, "de"); + expect(de).toContain("Tagesüberblick"); + expect(de).toContain( + "Zeigt Begrüßung, Score, Tagesfokus und Dosen-Status ganz oben auf dem Dashboard.", + ); + const en = render(); + expect(en).toContain("Daily overview"); + }); + + it("reflects heroVisible: default layout → checked, false → unchecked", () => { + const checkedHtml = render( + , + ); + const checked = checkedHtml.match( + /]*data-slot="hero-visible-switch"[^>]*>/, + ); + expect(checked).not.toBeNull(); + expect(checked![0]).toContain('data-state="checked"'); + + queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: false }; + const uncheckedHtml = render( + , + ); + const unchecked = uncheckedHtml.match( + /]*data-slot="hero-visible-switch"[^>]*>/, + ); + expect(unchecked).not.toBeNull(); + expect(unchecked![0]).toContain('data-state="unchecked"'); + }); + + it("persists through the existing PUT draft flow (source pins)", () => { + const src = readFileSync( + join( + process.cwd(), + "src/components/settings/dashboard-layout-section.tsx", + ), + "utf8", + ); + // The toggle writes the flag onto the layout draft… + expect(src).toMatch( + /setDraft\(\{\s*\.\.\.layout,\s*heroVisible:\s*value\s*\}\)/, + ); + // …which the Save button flushes via the one save mutation + // (`apiPut("/api/dashboard/widgets", …)`) every other control uses. + expect(src).toMatch(/onCheckedChange=\{\(v\) => setHeroVisible\(v\)\}/); + expect(src).toMatch( + /apiPut\("\/api\/dashboard\/widgets", next\)/, + ); + }); +}); + /** * v1.11.2 HIGH-1 — the 8 B5 ids are WRITABLE (in DASHBOARD_WIDGET_IDS so the * iOS pin PUT validates them) but have NO web render path, so the web diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 304cbcfe0..0699eb892 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -153,6 +153,8 @@ export function DashboardLayoutSection({ id }: { id: string }) { // tooltip. One hint paragraph is rendered once at the bottom of the list // and referenced by every drag handle in this section. const dragHintId = useId(); + // Label ↔ switch hookup for the hero (Tagesüberblick) toggle below. + const heroToggleId = useId(); // v1.4.47 W4 — sensors: pointer for mouse/touch, keyboard for Tab + Space // + arrow-key reordering. The KeyboardSensor still works for users who @@ -243,6 +245,16 @@ export function DashboardLayoutSection({ id }: { id: string }) { setDraft({ ...layout, comparisonBaseline: value }); } + /** + * Dashboard hero (daily verdict) visibility. Rides the layout blob + * (`heroVisible`, default on) and persists through the same PUT the + * widget toggles use — the Save button flushes it. + */ + function setHeroVisible(value: boolean) { + if (!layout) return; + setDraft({ ...layout, heroVisible: value }); + } + /** * v1.4.48 M6a — the arrow buttons now delegate to `reorderWidgets()` * so the swap-and-renumber logic lives in exactly one place. The @@ -377,6 +389,33 @@ export function DashboardLayoutSection({ id }: { id: string }) {

)} + {/* Hero (Tagesüberblick) visibility — one switch above the widget + list because the band sits above every widget on the dashboard. + Persists as `heroVisible` on the layout blob through the same + PUT mutation the rows below use (Save flushes the draft). */} + {layout && ( +
+
+ +

+ {t("dashboard.heroToggleDescription")} +

+
+ setHeroVisible(v)} + disabled={saveMutation.isPending} + data-slot="hero-visible-switch" + /> +
+ )} + {isLoading || !layout ? (
diff --git a/src/lib/__tests__/dashboard-layout.test.ts b/src/lib/__tests__/dashboard-layout.test.ts index 0a8fe9a33..964ede10a 100644 --- a/src/lib/__tests__/dashboard-layout.test.ts +++ b/src/lib/__tests__/dashboard-layout.test.ts @@ -377,6 +377,65 @@ describe("resolveDashboardLayout() — chartOverlayPrefs (v1.4.18)", () => { }); }); +/** + * Dashboard hero (daily verdict) visibility — `heroVisible` piggy-backs + * on the layout blob like the B8 comparison baseline. Resolver contract: + * anything that is not the literal `false` clamps back to `true` + * (default-on for legacy blobs; a stale client cannot poison the field + * with a non-boolean). Serializer persists the resolved boolean + * explicitly so a re-read never has to guess the default. + */ +describe("resolveDashboardLayout() — heroVisible", () => { + it("defaults to true for legacy layouts (no field saved)", () => { + const legacy = { + version: 1, + widgets: [{ id: "weight", visible: true, order: 0 }], + }; + expect(resolveDashboardLayout(legacy).heroVisible).toBe(true); + }); + + it("respects an explicit heroVisible: false", () => { + const saved = { + version: 1, + widgets: [{ id: "weight", visible: true, order: 0 }], + heroVisible: false, + }; + expect(resolveDashboardLayout(saved).heroVisible).toBe(false); + }); + + it("clamps non-boolean values back to true", () => { + for (const poisoned of ["false", 0, null, 1, {}]) { + const saved = { + version: 1, + widgets: [{ id: "weight", visible: true, order: 0 }], + heroVisible: poisoned, + }; + expect(resolveDashboardLayout(saved).heroVisible).toBe(true); + } + }); + + it("defaults heroVisible to true in DEFAULT_DASHBOARD_LAYOUT", () => { + expect(DEFAULT_DASHBOARD_LAYOUT.heroVisible).toBe(true); + }); + + it("preserves heroVisible: false through serialize → resolve round-trip", () => { + const layout: DashboardLayout = { + ...DEFAULT_DASHBOARD_LAYOUT, + heroVisible: false, + }; + const resolved = resolveDashboardLayout(serializeDashboardLayout(layout)); + expect(resolved.heroVisible).toBe(false); + }); + + it("serializer derives true when the field is missing on input", () => { + const layout: DashboardLayout = { + version: 1, + widgets: [{ id: "weight", visible: true, order: 0 }], + }; + expect(serializeDashboardLayout(layout).heroVisible).toBe(true); + }); +}); + /** * v1.7.0 — full 27-id widget catalogue for the iOS cold-launch seed. * The catalogue is a pure superset of the server-known ids; the diff --git a/src/lib/ai/coach/__tests__/snapshot-budget.test.ts b/src/lib/ai/coach/__tests__/snapshot-budget.test.ts index 063506be0..97ba155be 100644 --- a/src/lib/ai/coach/__tests__/snapshot-budget.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot-budget.test.ts @@ -12,6 +12,7 @@ vi.mock("@/lib/db", () => ({ measurement: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, + medication: { findMany: vi.fn() }, workout: { findMany: vi.fn() }, user: { findUnique: vi.fn() }, }, @@ -67,6 +68,7 @@ const prismaMock = prisma as unknown as { measurement: { findMany: ReturnType }; moodEntry: { findMany: ReturnType }; medicationIntakeEvent: { findMany: ReturnType }; + medication: { findMany: ReturnType }; workout: { findMany: ReturnType }; user: { findUnique: ReturnType }; }; @@ -170,12 +172,51 @@ describe("buildCoachSnapshot — budgeting + progressive degradation", () => { score: 3 + (i % 3), })), ); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue( - Array.from({ length: 360 }, (_, i) => ({ - scheduledFor: new Date(Date.now() - i * 24 * 60 * 60 * 1000), - takenAt: new Date(Date.now() - i * 24 * 60 * 60 * 1000), - skipped: false, - })), + prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); + // v1.16.9 — the compliance timeline derives from the band-engine + // ledger now; the snapshot loads medications (schedules + window + // events) instead of raw intake rows. + // Branch on the caller: the GLP-1 block filters on + // `treatmentClass: "GLP1"` (no GLP-1 therapy in this fixture); the + // compliance loader reads every medication. + const complianceMedRows = [ + { + id: "med-1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date(Date.now() - 400 * 24 * 60 * 60 * 1000), + schedules: [ + { + id: "sched-1", + windowStart: "09:00", + windowEnd: "09:00", + daysOfWeek: null, + timesOfDay: ["09:00"], + reminderGraceMinutes: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, + }, + ], + scheduleRevisions: [], + intakeEvents: Array.from({ length: 360 }, (_, i) => ({ + scheduledFor: new Date(Date.now() - i * 24 * 60 * 60 * 1000), + takenAt: new Date(Date.now() - i * 24 * 60 * 60 * 1000), + skipped: false, + autoMissed: false, + attributionSource: "AUTO", + })), + }, + ]; + prismaMock.medication.findMany.mockImplementation( + (args: { where?: { treatmentClass?: string } }) => + Promise.resolve( + args?.where?.treatmentClass === "GLP1" ? [] : complianceMedRows, + ), ); prismaMock.workout.findMany.mockResolvedValue( Array.from({ length: 80 }, (_, i) => ({ @@ -295,6 +336,7 @@ describe("buildCoachSnapshot — budgeting + progressive degradation", () => { prismaMock.measurement.findMany.mockResolvedValue([]); prismaMock.moodEntry.findMany.mockResolvedValue([]); prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); + prismaMock.medication.findMany.mockResolvedValue([]); prismaMock.workout.findMany.mockResolvedValue([]); prismaMock.user.findUnique.mockResolvedValue({ coachPrefsJson: null, diff --git a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts index f3d565677..df2c12a89 100644 --- a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts @@ -10,6 +10,7 @@ vi.mock("@/lib/db", () => ({ measurement: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, + medication: { findMany: vi.fn() }, workout: { findMany: vi.fn() }, user: { findUnique: vi.fn() }, }, @@ -26,6 +27,7 @@ const prismaMock = prisma as unknown as { measurement: { findMany: ReturnType }; moodEntry: { findMany: ReturnType }; medicationIntakeEvent: { findMany: ReturnType }; + medication: { findMany: ReturnType }; workout: { findMany: ReturnType }; user: { findUnique: ReturnType }; }; @@ -60,6 +62,7 @@ describe("buildCoachSnapshot — Apple Health additive metrics (v1.4.23)", () => __resetCoachSnapshotCacheForTests(); prismaMock.moodEntry.findMany.mockResolvedValue([]); prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); + prismaMock.medication.findMany.mockResolvedValue([]); prismaMock.workout.findMany.mockResolvedValue([]); // v1.4.23 H4 — snapshot now reads `User.coachPrefsJson`. Default // null = legacy defaults so the existing test fixtures stay diff --git a/src/lib/ai/coach/__tests__/snapshot.test.ts b/src/lib/ai/coach/__tests__/snapshot.test.ts index a8bfa305a..f7725f414 100644 --- a/src/lib/ai/coach/__tests__/snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot.test.ts @@ -13,6 +13,7 @@ vi.mock("@/lib/db", () => ({ measurement: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, + medication: { findMany: vi.fn() }, user: { findUnique: vi.fn() }, }, })); @@ -36,6 +37,7 @@ const prismaMock = prisma as unknown as { measurement: { findMany: ReturnType }; moodEntry: { findMany: ReturnType }; medicationIntakeEvent: { findMany: ReturnType }; + medication: { findMany: ReturnType }; user: { findUnique: ReturnType }; }; const featuresMock = extractFeatures as unknown as ReturnType; @@ -68,6 +70,7 @@ describe("buildCoachSnapshot", () => { prismaMock.measurement.findMany.mockResolvedValue([]); prismaMock.moodEntry.findMany.mockResolvedValue([]); prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); + prismaMock.medication.findMany.mockResolvedValue([]); // v1.4.23 H4 — snapshot now reads `User.coachPrefsJson` to apply // per-user excludeMetrics. Default null = use legacy defaults. prismaMock.user.findUnique.mockResolvedValue({ coachPrefsJson: null }); diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index a144309cc..651488c0f 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -35,6 +35,13 @@ import { buildCoachMemoryBlock } from "./memory-snapshot"; import { buildTrajectorySnapshotBlock } from "./trajectory-snapshot"; import { buildCycleSnapshotBlock } from "./cycle-snapshot"; import { isCycleEnabled } from "@/lib/cycle/gate"; +import { + buildComplianceLedgerRows, + buildComplianceMedicationContext, + lastNonSkippedTakenAt, + SCHEDULE_COMPLIANCE_SELECT, +} from "@/lib/analytics/compliance"; +import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history"; import type { BaselineProfile } from "@/lib/insights/derived"; import { CLUSTER_PRIORITY, @@ -753,12 +760,46 @@ async function buildCoachSnapshotImpl( select: { moodLoggedAt: true, score: true }, }) : null; - const intakeRowsPromise = wantsCompliance - ? prisma.medicationIntakeEvent.findMany({ - // Tombstoned intake rows must never reach the Coach snapshot. - where: { userId, deletedAt: null, scheduledFor: { gte: cutoff } }, - orderBy: { scheduledFor: "asc" }, - select: { scheduledFor: true, takenAt: true, skipped: true }, + // v1.16.9 — the adherence timeline derives from the LEDGER tally (the + // same band engine the compliance % + dose history consume), not from a + // raw intake-row count. The raw count read a worker-minted pending row + // as "not taken" (today's later doses dragged the day's rate down all + // morning) and double-counted cross-source duplicate rows on one slot. + // Load each medication's schedules + eras + window events so the ledger + // can be reconstructed per medication. + const complianceMedsPromise = wantsCompliance + ? prisma.medication.findMany({ + where: { userId }, + select: { + id: true, + startsOn: true, + endsOn: true, + oneShot: true, + createdAt: true, + schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, + scheduleRevisions: { + orderBy: { validFrom: "asc" }, + select: { + id: true, + validFrom: true, + validUntil: true, + payload: true, + supersededByRevisionId: true, + }, + }, + intakeEvents: { + // Tombstoned intake rows must never reach the Coach snapshot. + where: { deletedAt: null, scheduledFor: { gte: cutoff } }, + orderBy: { scheduledFor: "asc" }, + select: { + scheduledFor: true, + takenAt: true, + skipped: true, + autoMissed: true, + attributionSource: true, + }, + }, + }, }) : null; const sleepRowsPromise = sources.has("sleep") @@ -826,7 +867,7 @@ async function buildCoachSnapshotImpl( const [ moodRows, - intakeRows, + complianceMeds, sleepRows, workoutRows, glp1Block, @@ -836,7 +877,7 @@ async function buildCoachSnapshotImpl( cycleBlock, ] = await Promise.all([ moodRowsPromise, - intakeRowsPromise, + complianceMedsPromise, sleepRowsPromise, workoutRowsPromise, glp1BlockPromise, @@ -939,31 +980,60 @@ async function buildCoachSnapshotImpl( registerBlock("mood", "mood"); } - if (wantsCompliance && intakeRows) { - // Medication compliance lives outside the structured features - // today — the legacy Coach surface labelled it as "general" - // provenance only. v1.4.20.1 ships a per-day adherence row built - // from the intake-event log so the Coach can answer "did I miss - // my dose on Tuesday?" without inventing the schedule. The read is - // issued in parallel above. - if (intakeRows.length > 0) { - // Per-day adherence rate within the recent window. Older days - // collapse into a single weekly bucket. - const recent = intakeRows.filter((r) => r.scheduledFor >= recentCutoff); - const olderRows = intakeRows.filter((r) => r.scheduledFor < recentCutoff); + if (wantsCompliance && complianceMeds) { + // Medication compliance lives outside the structured features today — + // the legacy Coach surface labelled it as "general" provenance only. + // v1.4.20.1 shipped a per-day adherence row; v1.16.9 derives it from + // the band-engine LEDGER (the same expansion the compliance % and the + // dose-history view consume): a slot counts against the rate only once + // it is genuinely missed, a pending/upcoming slot never reads as "not + // taken", deliberate skips and ad-hoc takes stay out of the + // denominator, and cross-source duplicate rows collapse onto one slot. + const ledgerRows: DoseHistoryRow[] = []; + for (const med of complianceMeds) { + if (med.schedules.length === 0) continue; + const ctx = buildComplianceMedicationContext( + med, + lastNonSkippedTakenAt(med.intakeEvents), + userTz, + ); + ledgerRows.push( + ...buildComplianceLedgerRows( + med.intakeEvents, + med.schedules, + ctx, + cutoff, + now, + now, + ), + ); + } + // Countable rows: taken (on-time or late) or genuinely missed. The + // pending / upcoming / skipped / ad-hoc rows carry no adherence signal. + const countable = ledgerRows.filter( + (r) => + r.status === "taken_on_time" || + r.status === "taken_late" || + r.status === "missed", + ); + if (countable.length > 0) { + const recent = countable.filter((r) => r.at >= recentCutoff); + const olderRows = countable.filter((r) => r.at < recentCutoff); + const isTaken = (r: DoseHistoryRow) => + r.status === "taken_on_time" || r.status === "taken_late"; const recentByDay = new Map< string, { date: Date; total: number; taken: number } >(); for (const r of recent) { - const key = tzDayKey(r.scheduledFor, userTz); + const key = tzDayKey(r.at, userTz); const e = recentByDay.get(key) ?? { - date: r.scheduledFor, + date: r.at, total: 0, taken: 0, }; e.total += 1; - if (r.takenAt && !r.skipped) e.taken += 1; + if (isTaken(r)) e.taken += 1; recentByDay.set(key, e); } const recentRows = Array.from(recentByDay.entries()) @@ -977,10 +1047,10 @@ async function buildCoachSnapshotImpl( .sort((a, b) => a.date.localeCompare(b.date)); const olderByWeek = new Map(); for (const r of olderRows) { - const key = isoWeekKey(r.scheduledFor, userTz); + const key = isoWeekKey(r.at, userTz); const e = olderByWeek.get(key) ?? { taken: 0, total: 0 }; e.total += 1; - if (r.takenAt && !r.skipped) e.taken += 1; + if (isTaken(r)) e.taken += 1; olderByWeek.set(key, e); } const weeklyRows = Array.from(olderByWeek.entries()) @@ -995,7 +1065,7 @@ async function buildCoachSnapshotImpl( timeline: { recent: recentRows, weekly: weeklyRows }, }; metrics.add("compliance"); - counts.compliance = intakeRows.length; + counts.compliance = countable.length; registerBlock("compliance", "compliance"); } else { // v1.7.0 — toggled-on cluster with no rows. Annotate so the diff --git a/src/lib/auth/profile-update.ts b/src/lib/auth/profile-update.ts index 7b531e2ce..e38410022 100644 --- a/src/lib/auth/profile-update.ts +++ b/src/lib/auth/profile-update.ts @@ -8,7 +8,10 @@ import { auditLog } from "@/lib/auth/audit"; import { profileSchema } from "@/lib/validations/auth"; import { isValidTimezone } from "@/lib/tz/format"; import { encrypt } from "@/lib/crypto"; -import { invalidateUserProfile } from "@/lib/cache/invalidate"; +import { + invalidateUserMedications, + invalidateUserProfile, +} from "@/lib/cache/invalidate"; import { z } from "zod/v4"; const extendedProfileSchema = profileSchema.extend({ @@ -168,6 +171,14 @@ export async function applyProfileUpdate( invalidateUserProfile(userId); } + // v1.16.9 — the medication payloads (list next-due, today counts, the + // compliance day buckets) are computed in the user's timezone, but the + // list cache key carries only the userId. A timezone change must evict + // the bucket or the next read serves the previous zone's day grid. + if (data.timezone !== undefined) { + invalidateUserMedications(userId, { evict: true }); + } + await auditLog("profile.update", { userId: updatedUser.id, ipAddress: ipAddress ?? null, diff --git a/src/lib/cache/__tests__/server-cache.test.ts b/src/lib/cache/__tests__/server-cache.test.ts index 12664fbb2..6a0ea86b6 100644 --- a/src/lib/cache/__tests__/server-cache.test.ts +++ b/src/lib/cache/__tests__/server-cache.test.ts @@ -451,6 +451,165 @@ describe("ServerCache stale-while-revalidate (wrapSwr / cachedSwr)", () => { }); }); +describe("ServerCache invalidation vs in-flight builders", () => { + it("a read right after an evict does NOT join the pre-write builder", async () => { + const cache = new ServerCache({ maxEntries: 4, ttlMs: 60_000 }); + let releaseFirst: ((v: string) => void) | null = null; + let calls = 0; + const builder = vi.fn(() => { + calls += 1; + if (calls === 1) { + return new Promise((r) => { + releaseFirst = r; + }); + } + return Promise.resolve("post-write"); + }); + + // Builder 1 starts (reads pre-write state) and is still in flight… + const first = cache.wrap("u1|list", builder); + // …when a write evicts the key. + cache.delete("u1|list"); + + // The next read must start a FRESH build, not join the stale one. + const second = await cache.wrap("u1|list", builder); + expect(second.outcome).toBe("miss"); + expect(second.value).toBe("post-write"); + expect(builder).toHaveBeenCalledTimes(2); + + // The detached pre-write builder resolves AFTER the fresh one — its + // commit must be discarded, never overwriting the post-write entry. + releaseFirst!("pre-write"); + const firstResult = await first; + expect(firstResult.value).toBe("pre-write"); + expect(cache.get("u1|list")).toBe("post-write"); + }); + + it("deleteByPrefix fences an in-flight builder the same way", async () => { + const cache = new ServerCache({ maxEntries: 4, ttlMs: 60_000 }); + let release: ((v: string) => void) | null = null; + const builder = vi.fn( + () => + new Promise((r) => { + release = r; + }), + ); + const inFlight = cache.wrap("u1|compliance", builder); + cache.deleteByPrefix("u1|"); + release!("pre-write"); + await inFlight; + // The pre-write result never entered the cache. + expect(cache.get("u1|compliance")).toBeNull(); + }); + + it("an SWR refresh that completes after an evict does not re-cache the pre-write body", async () => { + vi.useFakeTimers(); + const cache = new ServerCache({ + maxEntries: 4, + ttlMs: 1000, + staleTtlMs: 600_000, + }); + let releaseRefresh: ((v: string) => void) | null = null; + let calls = 0; + const builder = vi.fn(() => { + calls += 1; + if (calls === 1) return Promise.resolve("v1"); + if (calls === 2) { + return new Promise((r) => { + releaseRefresh = r; + }); + } + return Promise.resolve("post-write"); + }); + + await cache.wrapSwr("u1|list", builder); + vi.advanceTimersByTime(1500); + + // Stale read kicks off the detached background refresh (pre-write). + const stale = await cache.wrapSwr("u1|list", builder); + expect(stale.outcome).toBe("stale"); + + // A write evicts the bucket while the refresh is still in flight… + cache.deleteByPrefix("u1|"); + // …then the pre-write refresh completes. Without the fence it would + // re-insert the pre-write body as FRESH for the full TTL. + releaseRefresh!("pre-write"); + await vi.runAllTimersAsync(); + + expect(cache.get("u1|list")).toBeNull(); + const next = await cache.wrapSwr("u1|list", builder); + expect(next.outcome).toBe("miss"); + expect(next.value).toBe("post-write"); + }); + + it("markStaleByPrefix discards an in-flight refresh and keeps SWR semantics", async () => { + vi.useFakeTimers(); + const cache = new ServerCache({ + maxEntries: 4, + ttlMs: 1000, + staleTtlMs: 600_000, + }); + let releaseRefresh: ((v: string) => void) | null = null; + let calls = 0; + const builder = vi.fn(() => { + calls += 1; + if (calls === 1) return Promise.resolve("v1"); + if (calls === 2) { + return new Promise((r) => { + releaseRefresh = r; + }); + } + return Promise.resolve("post-write"); + }); + + await cache.wrapSwr("u1|k", builder); + vi.advanceTimersByTime(1500); + const stale = await cache.wrapSwr("u1|k", builder); + expect(stale.outcome).toBe("stale"); + + // Background write marks the bucket stale mid-refresh; the pre-write + // refresh result must not land as a fresh entry. + cache.markStaleByPrefix("u1|"); + releaseRefresh!("pre-write"); + await vi.runAllTimersAsync(); + + // SWR semantics hold: the prior value still serves stale and a NEW + // (post-write) recompute is kicked off. + const next = await cache.wrapSwr("u1|k", builder); + expect(next.outcome).toBe("stale"); + expect(next.value).toBe("v1"); + await vi.runAllTimersAsync(); + expect(builder).toHaveBeenCalledTimes(3); + const warm = await cache.wrapSwr("u1|k", builder); + expect(warm.outcome).toBe("hit"); + expect(warm.value).toBe("post-write"); + }); + + it("invalidation between two stampeding readers gives the later reader fresh data", async () => { + const cache = new ServerCache({ maxEntries: 4, ttlMs: 60_000 }); + let release: ((v: string) => void) | null = null; + let calls = 0; + const builder = vi.fn(() => { + calls += 1; + if (calls === 1) { + return new Promise((r) => { + release = r; + }); + } + return Promise.resolve("fresh"); + }); + + const a = cache.wrap("k", builder); // pre-write build in flight + cache.delete("k"); // write + const b = cache.wrap("k", builder); // post-write read → fresh build + release!("stale"); + const [ra, rb] = await Promise.all([a, b]); + expect(ra.value).toBe("stale"); // joined its own (pre-write) build + expect(rb.value).toBe("fresh"); + expect(cache.get("k")).toBe("fresh"); + }); +}); + describe("global cache registry", () => { it("exposes the eight blueprint caches as module-scope singletons", () => { expect(caches.analytics).toBeDefined(); diff --git a/src/lib/cache/invalidate.ts b/src/lib/cache/invalidate.ts index c099b2e09..140769403 100644 --- a/src/lib/cache/invalidate.ts +++ b/src/lib/cache/invalidate.ts @@ -246,6 +246,38 @@ export function invalidateUserInsightsLayout(userId: string): void { caches.insightsLayout.deleteByPrefix(userId); } +/** + * v1.16.9 — full per-user cache eviction for the wipe / restore paths. + * A data wipe or a backup restore replaces (or removes) every row the + * cached payloads were built on, so every per-user bucket hard-evicts — + * a marked-stale SWR cell would keep serving the pre-wipe body. + */ +export function invalidateUserData(userId: string): void { + caches.analytics.deleteByPrefix(`${userId}|`); + caches.medications.deleteByPrefix(userId); + caches.medicationCompliance.deleteByPrefix(`${userId}|`); + caches.medicationsIntake.deleteByPrefix(`${userId}|`); + caches.achievements.deleteByPrefix(userId); + caches.insightsTargets.deleteByPrefix(userId); + caches.insightsDerived.deleteByPrefix(`${userId}|`); + caches.moodAnalytics.deleteByPrefix(userId); + caches.moodInsights.deleteByPrefix(userId); + caches.workouts.deleteByPrefix(`${userId}|`); + caches.dashboardWidgets.deleteByPrefix(userId); + caches.insightsLayout.deleteByPrefix(userId); +} + +/** + * v1.16.9 — bucket-wide clear for the admin all-user wipe. Every cache + * empties; the next read per user is a cold build over the (now empty / + * restored) tables. Acceptable for an operation this rare. + */ +export function invalidateAllCaches(): void { + for (const cache of Object.values(caches)) { + cache.deleteByPrefix(""); + } +} + /** * Global eviction of the bug-report-status cache. * diff --git a/src/lib/cache/server-cache.ts b/src/lib/cache/server-cache.ts index 3afdd06fa..c19601b2a 100644 --- a/src/lib/cache/server-cache.ts +++ b/src/lib/cache/server-cache.ts @@ -67,12 +67,30 @@ export interface ServerCacheStats { readonly stampedes: number; } +/** + * v1.16.9 — single-flight slot with invalidation fencing. A builder that + * was in flight when an invalidation (`delete` / `deleteByPrefix` / + * `markStaleByPrefix`) hit its key must NOT commit its result: the build + * read pre-write data, and committing it after the eviction would + * re-cache the pre-write body as fresh for the full TTL. The slot is the + * fence — invalidation flips `invalidated` and detaches the slot from + * `pending`, so (a) the builder's `.then` skips the `set()` and (b) the + * next read starts a fresh build instead of joining the stale one. + * Callers already awaiting the detached promise still receive its value + * (equivalent to having read a moment before the write); it just never + * enters the cache. + */ +interface PendingBuild { + promise: Promise; + invalidated: boolean; +} + export class ServerCache { private readonly map = new Map< string, { expiresAt: number; staleUntil: number; value: T } >(); - private readonly pending = new Map>(); + private readonly pending = new Map>(); private hits = 0; private misses = 0; private evictions = 0; @@ -80,6 +98,59 @@ export class ServerCache { constructor(private readonly opts: ServerCacheOptions) {} + /** + * Fence + detach the in-flight builder for one key (no-op when none). + * Part of every invalidation op — see `PendingBuild`. + */ + private detachPending(key: string): void { + const slot = this.pending.get(key); + if (slot) { + slot.invalidated = true; + this.pending.delete(key); + } + } + + /** Fence + detach every in-flight builder under `prefix`. */ + private detachPendingByPrefix(prefix: string): void { + for (const [key, slot] of this.pending) { + if (key.startsWith(prefix)) { + slot.invalidated = true; + this.pending.delete(key); + } + } + } + + /** + * Register a fenced single-flight build for `key`. The commit only + * lands when no invalidation hit the key while the builder ran; the + * `finally` only clears the slot it owns (an invalidation may already + * have detached it and a newer build may occupy the key). + */ + private startBuild( + key: string, + builder: () => Promise, + ttlMsOverride?: number, + ): PendingBuild { + const slot: PendingBuild = { + promise: undefined as unknown as Promise, + invalidated: false, + }; + slot.promise = builder() + .then((value) => { + if (!slot.invalidated) { + this.set(key, value, ttlMsOverride); + } + return value; + }) + .finally(() => { + if (this.pending.get(key) === slot) { + this.pending.delete(key); + } + }); + this.pending.set(key, slot); + return slot; + } + /** * Read a key. Returns null on miss, expired entry, or absent entry. * Side effect: an expired entry is removed; a live entry is @@ -178,6 +249,12 @@ export class ServerCache { * behaviour). */ markStaleByPrefix(prefix: string): number { + // v1.16.9 — fence in-flight builders too: a refresh that started + // before the write would otherwise complete after this mark and + // re-cache the pre-write body as FRESH for the full TTL. Detaching + // keeps the SWR contract — the marked entry still serves stale, and + // the next read kicks off a fresh (post-write) recompute. + this.detachPendingByPrefix(prefix); let marked = 0; const now = Date.now(); for (const [key, entry] of this.map) { @@ -191,6 +268,9 @@ export class ServerCache { /** Single-key eviction. Returns true when the key was present. */ delete(key: string): boolean { + // v1.16.9 — fence the in-flight builder so a pre-write build can + // neither be joined by post-evict reads nor commit its result. + this.detachPending(key); return this.map.delete(key); } @@ -203,6 +283,8 @@ export class ServerCache { * possible cache-key variant the user might have warmed. */ deleteByPrefix(prefix: string): number { + // v1.16.9 — fence in-flight builders under the prefix (see `delete`). + this.detachPendingByPrefix(prefix); let removed = 0; for (const key of this.map.keys()) { if (key.startsWith(prefix)) { @@ -238,21 +320,13 @@ export class ServerCache { const inFlight = this.pending.get(key); if (inFlight) { this.stampedes += 1; - const value = await inFlight; + const value = await inFlight.promise; return { value, outcome: "stampede" }; } this.misses += 1; - const promise = builder() - .then((value) => { - this.set(key, value, ttlMsOverride); - return value; - }) - .finally(() => { - this.pending.delete(key); - }); - this.pending.set(key, promise); - const value = await promise; + const slot = this.startBuild(key, builder, ttlMsOverride); + const value = await slot.promise; return { value, outcome: "miss" }; } @@ -288,20 +362,11 @@ export class ServerCache { // single-flight slot so concurrent stale reads share one rebuild. this.hits += 1; if (!this.pending.has(key)) { - const refresh = builder() - .then((value) => { - this.set(key, value, ttlMsOverride); - return value; - }) - .catch((err) => { - // The stale value already went out; don't crash the worker. - // Re-throw so the join path of any awaiting caller sees it. - throw err; - }) - .finally(() => { - this.pending.delete(key); - }); - this.pending.set(key, refresh); + // v1.16.9 — the background refresh runs through the same fenced + // single-flight slot as `wrap`, so an invalidation that lands + // while it is in flight discards its (pre-write) result instead + // of re-caching it as fresh. + const refresh = this.startBuild(key, builder, ttlMsOverride).promise; // Detach: the foreground caller does not await the rebuild. // Handle the rejection on the detached handle so an // unhandled-rejection warning never fires — but never silently: @@ -344,6 +409,11 @@ export class ServerCache { /** Reset every counter + entry. Test-only escape hatch. */ __resetForTests(): void { this.map.clear(); + // Fence any in-flight builder so a build crossing a test boundary + // cannot commit into the next test's clean cache. + for (const slot of this.pending.values()) { + slot.invalidated = true; + } this.pending.clear(); this.hits = 0; this.misses = 0; diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 50bcc52cd..06c7f3317 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -321,6 +321,16 @@ export interface DashboardLayout { comparisonBaseline?: ComparisonBaseline; /** v1.4.18 — per-chart overlay-prefs (3 toggles per chart card). */ chartOverlayPrefs?: ChartOverlayPrefsMap; + /** + * Dashboard hero (daily verdict) visibility. Piggy-backs on the + * layout blob like the B8 comparison baseline — a UI affordance, not + * an analytical attribute, so no Prisma migration. The resolver + * clamps anything that is not the literal `false` back to `true` + * (legacy blobs without the field render the hero by default; a + * stale client cannot poison it with a non-boolean), and the + * serializer persists the resolved boolean explicitly. + */ + heroVisible?: boolean; } const DASHBOARD_LAYOUT_VERSION = 1; @@ -332,6 +342,9 @@ const DASHBOARD_LAYOUT_VERSION = 1; */ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = { version: DASHBOARD_LAYOUT_VERSION, + // Hero (daily verdict) is on by default; users opt out via + // Settings → Dashboard. + heroVisible: true, widgets: [ { id: "weight", visible: true, tileVisible: true, order: 0 }, { id: "bp", visible: true, tileVisible: true, order: 1 }, @@ -449,6 +462,10 @@ export function resolveDashboardLayout(raw: unknown): DashboardLayout { // client cannot poison the dashboard with values the renderer // doesn't know how to draw. chartOverlayPrefs: coerceChartOverlayPrefsMap(candidate.chartOverlayPrefs), + // Hero visibility — anything that is not the literal `false` + // clamps to `true` (default-on for legacy blobs and malformed + // values alike). + heroVisible: candidate.heroVisible !== false, }; } @@ -476,5 +493,8 @@ export function serializeDashboardLayout( // v1.4.18 — persist per-chart overlay prefs verbatim through the // same coercion the resolver runs so the wire shape is stable. chartOverlayPrefs: coerceChartOverlayPrefsMap(layout.chartOverlayPrefs), + // Persist hero visibility explicitly (same clamp as the resolver) + // so a re-read never has to guess the default. + heroVisible: layout.heroVisible !== false, }; } diff --git a/src/lib/dashboard/__tests__/meds-today.test.ts b/src/lib/dashboard/__tests__/meds-today.test.ts new file mode 100644 index 000000000..8d738d11a --- /dev/null +++ b/src/lib/dashboard/__tests__/meds-today.test.ts @@ -0,0 +1,333 @@ +/** + * `buildMedsTodayBlock` — integration-style unit tests with a mocked + * Prisma client and a mocked projector. + * + * Pinned contracts: + * - the idempotent projection runs BEFORE the today-window read (so + * the tally always sees the freshly minted pending rows) with the + * user's local-day bounds; + * - the tally semantics match the summary route's compliance tile + * exactly (taken = `takenAt` set and not skipped); + * - the next-due pick is the EARLIEST display-due across active + * medications (real recurrence engine, no engine mocks); + * - an open overdue slot surfaces with `nextDueOverdue: true` and a + * resolved slot at the same anchor advances to the next future one; + * - the today window is computed in the USER's timezone (asserted on + * the where-clause bounds for a non-European zone). + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const projectTodayIntakesAndRecompute = vi.fn(); + +vi.mock("@/lib/medications/scheduling/project-today-intakes", () => ({ + projectTodayIntakesAndRecompute: (...a: unknown[]) => + projectTodayIntakesAndRecompute(...a), +})); + +import { buildMedsTodayBlock } from "../meds-today"; + +/** Noon Berlin (CEST) on a fixed summer day. */ +const NOW = new Date("2026-06-10T10:00:00.000Z"); +const BERLIN = "Europe/Berlin"; + +interface FakeIntakeRow { + medicationId?: string; + scheduledFor?: Date; + takenAt: Date | null; + skipped: boolean; +} + +function dailySchedule( + overrides: Record = {}, +): Record { + return { + id: "sched-1", + medicationId: "med-1", + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + timesOfDay: ["08:00"], + reminderGraceMinutes: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, + ...overrides, + }; +} + +function medication( + overrides: Record = {}, +): Record { + return { + id: "med-1", + name: "Testmittel", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + schedules: [dailySchedule()], + ...overrides, + }; +} + +/** Call-order ledger so the projection-before-read contract is assertable. */ +let callOrder: string[]; +let medications: Array>; +let todayRows: FakeIntakeRow[]; +let resolvedRows: Array<{ + medicationId: string; + scheduledFor: Date; + takenAt: Date | null; +}>; +let latestIntakeGroups: Array<{ + medicationId: string; + _max: { takenAt: Date | null }; +}>; +let eraFloorGroups: Array<{ + medicationId: string; + _max: { validUntil: Date | null }; +}>; +let capturedTodayWhere: Record | null; + +const fakePrisma = { + medication: { + findMany: vi.fn(async () => { + callOrder.push("medication.findMany"); + return medications; + }), + }, + medicationIntakeEvent: { + findMany: vi.fn( + async (args: { where: { OR?: unknown; scheduledFor?: unknown } }) => { + if (args.where.OR) { + callOrder.push("resolved.findMany"); + return resolvedRows; + } + callOrder.push("today.findMany"); + capturedTodayWhere = args.where as Record; + return todayRows; + }, + ), + groupBy: vi.fn(async () => { + callOrder.push("latest.groupBy"); + return latestIntakeGroups; + }), + }, + medicationScheduleRevision: { + groupBy: vi.fn(async () => { + callOrder.push("era.groupBy"); + return eraFloorGroups; + }), + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +} as any; + +beforeEach(() => { + vi.clearAllMocks(); + callOrder = []; + medications = []; + todayRows = []; + resolvedRows = []; + latestIntakeGroups = []; + eraFloorGroups = []; + capturedTodayWhere = null; + projectTodayIntakesAndRecompute.mockImplementation(async () => { + callOrder.push("project"); + return { projected: 0, backfilled: 0 }; + }); +}); + +describe("buildMedsTodayBlock — projection contract", () => { + it("projects the user's local today BEFORE every read (idempotency seam)", async () => { + await buildMedsTodayBlock(fakePrisma, "user-1", BERLIN, NOW); + + expect(projectTodayIntakesAndRecompute).toHaveBeenCalledTimes(1); + const arg = projectTodayIntakesAndRecompute.mock.calls[0][0] as { + userId: string; + userTz: string; + todayStart: Date; + todayEnd: Date; + }; + expect(arg.userId).toBe("user-1"); + expect(arg.userTz).toBe(BERLIN); + // Berlin local midnight (CEST = UTC+2) → 22:00Z the prior evening. + expect(arg.todayStart.toISOString()).toBe("2026-06-09T22:00:00.000Z"); + expect(arg.todayEnd.toISOString()).toBe("2026-06-10T22:00:00.000Z"); + + // Projection strictly first — the tally must see the minted rows. + expect(callOrder[0]).toBe("project"); + expect(callOrder.slice(1)).toContain("today.findMany"); + }); + + it("computes the today window in the user's zone, not the server's", async () => { + // Pacific/Auckland (UTC+12, no DST in June): 10:00Z = 22:00 local, + // so local-today spans [2026-06-09T12:00Z, 2026-06-10T12:00Z). + await buildMedsTodayBlock(fakePrisma, "user-1", "Pacific/Auckland", NOW); + + const where = capturedTodayWhere as { + scheduledFor: { gte: Date; lt: Date }; + deletedAt: null; + }; + expect(where.scheduledFor.gte.toISOString()).toBe( + "2026-06-09T12:00:00.000Z", + ); + expect(where.scheduledFor.lt.toISOString()).toBe( + "2026-06-10T12:00:00.000Z", + ); + expect(where.deletedAt).toBeNull(); + }); +}); + +describe("buildMedsTodayBlock — tally", () => { + it("counts scheduled / taken / skipped with the summary-tile semantics", async () => { + todayRows = [ + { takenAt: new Date("2026-06-10T06:05:00.000Z"), skipped: false }, + { takenAt: new Date("2026-06-10T08:00:00.000Z"), skipped: false }, + { takenAt: null, skipped: true }, // deliberate skip + { takenAt: null, skipped: false }, // still-open pending + ]; + + const block = await buildMedsTodayBlock(fakePrisma, "user-1", BERLIN, NOW); + + expect(block.scheduledToday).toBe(4); + expect(block.takenToday).toBe(2); + expect(block.skippedToday).toBe(1); + }); + + it("returns a zeroed block for an account with no medications", async () => { + const block = await buildMedsTodayBlock(fakePrisma, "user-1", BERLIN, NOW); + expect(block).toEqual({ + activeCount: 0, + scheduledToday: 0, + takenToday: 0, + skippedToday: 0, + nextDueAt: null, + nextDueOverdue: false, + nextDueMedicationName: null, + }); + }); + + it("reads only ACTIVE medications", async () => { + await buildMedsTodayBlock(fakePrisma, "user-1", BERLIN, NOW); + const arg = fakePrisma.medication.findMany.mock.calls[0][0] as { + where: { userId: string; active: boolean }; + }; + expect(arg.where).toEqual({ userId: "user-1", active: true }); + }); +}); + +describe("buildMedsTodayBlock — next due (real engine)", () => { + it("picks the EARLIEST future slot across medications, with its name", async () => { + // 18:00 Berlin = 16:00Z; 20:00 Berlin = 18:00Z. Yesterday's slots + // are past their catch-up bands (daily tail 4 h), so the engine + // lands on today's anchors. + medications = [ + medication({ + id: "med-late", + name: "Spätmittel", + schedules: [ + dailySchedule({ + id: "sched-late", + medicationId: "med-late", + windowStart: "20:00", + windowEnd: "21:00", + timesOfDay: ["20:00"], + }), + ], + }), + medication({ + id: "med-evening", + name: "Abendmittel", + schedules: [ + dailySchedule({ + id: "sched-evening", + medicationId: "med-evening", + windowStart: "18:00", + windowEnd: "19:00", + timesOfDay: ["18:00"], + }), + ], + }), + ]; + + const block = await buildMedsTodayBlock(fakePrisma, "user-1", BERLIN, NOW); + + expect(block.activeCount).toBe(2); + expect(block.nextDueAt).toBe("2026-06-10T16:00:00.000Z"); + expect(block.nextDueOverdue).toBe(false); + expect(block.nextDueMedicationName).toBe("Abendmittel"); + }); + + it("surfaces an open overdue slot (anchor passed, band still open)", async () => { + // 11:00 Berlin anchor = 09:00Z; now is 12:00 Berlin — one hour past + // the anchor, well inside the daily 4 h catch-up band. + medications = [ + medication({ + name: "Mittagsmittel", + schedules: [ + dailySchedule({ + windowStart: "11:00", + windowEnd: "12:00", + timesOfDay: ["11:00"], + }), + ], + }), + ]; + + const block = await buildMedsTodayBlock(fakePrisma, "user-1", BERLIN, NOW); + + expect(block.nextDueAt).toBe("2026-06-10T09:00:00.000Z"); + expect(block.nextDueOverdue).toBe(true); + expect(block.nextDueMedicationName).toBe("Mittagsmittel"); + }); + + it("advances past a RESOLVED slot to the next future anchor", async () => { + medications = [ + medication({ + name: "Mittagsmittel", + schedules: [ + dailySchedule({ + windowStart: "11:00", + windowEnd: "12:00", + timesOfDay: ["11:00"], + }), + ], + }), + ]; + // The 11:00-Berlin slot was acted on — taken row at the anchor. + resolvedRows = [ + { + medicationId: "med-1", + scheduledFor: new Date("2026-06-10T09:00:00.000Z"), + // Slot-anchored take (attributed): takenAt differs from the anchor. + takenAt: new Date("2026-06-10T09:05:00.000Z"), + }, + ]; + + const block = await buildMedsTodayBlock(fakePrisma, "user-1", BERLIN, NOW); + + // Tomorrow's 11:00 Berlin anchor. + expect(block.nextDueAt).toBe("2026-06-11T09:00:00.000Z"); + expect(block.nextDueOverdue).toBe(false); + }); + + it("returns null next-due when no schedule has an upcoming slot", async () => { + // Course ended last month — the engine yields nothing. + medications = [ + medication({ + name: "Altmittel", + endsOn: new Date("2026-05-01T00:00:00.000Z"), + }), + ]; + + const block = await buildMedsTodayBlock(fakePrisma, "user-1", BERLIN, NOW); + + expect(block.activeCount).toBe(1); + expect(block.nextDueAt).toBeNull(); + expect(block.nextDueOverdue).toBe(false); + expect(block.nextDueMedicationName).toBeNull(); + }); +}); diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 991e3dcc8..ef9da94e1 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -21,6 +21,8 @@ const ensureUserMoodRollupsFresh = vi.fn(); const computeBpInTargetFastPath = vi.fn(); const getAssistantFlags = vi.fn(); const hasAnyConfiguredProvider = vi.fn(); +const buildMedsTodayBlock = vi.fn(); +const computeUserHealthScoreFastPath = vi.fn(); vi.mock("@/lib/analytics/summaries-slice", () => ({ computeSummariesSlice: (...a: unknown[]) => computeSummariesSlice(...a), @@ -46,6 +48,15 @@ vi.mock("@/lib/feature-flags", () => ({ vi.mock("@/lib/ai/provider", () => ({ hasAnyConfiguredProvider: (...a: unknown[]) => hasAnyConfiguredProvider(...a), })); +// Meds-today + health-score builders are tested in their own suites; +// here only the assembly contract (fast vs warm phase) is pinned. +vi.mock("@/lib/dashboard/meds-today", () => ({ + buildMedsTodayBlock: (...a: unknown[]) => buildMedsTodayBlock(...a), +})); +vi.mock("@/lib/analytics/health-score-fast-path", () => ({ + computeUserHealthScoreFastPath: (...a: unknown[]) => + computeUserHealthScoreFastPath(...a), +})); import { buildDashboardSnapshot, @@ -112,6 +123,16 @@ beforeEach(() => { fakePrisma.measurement.findMany.mockResolvedValue([]); fakePrisma.moodEntry.findMany.mockResolvedValue([]); hasAnyConfiguredProvider.mockResolvedValue(true); + buildMedsTodayBlock.mockResolvedValue({ + activeCount: 1, + scheduledToday: 2, + takenToday: 1, + skippedToday: 0, + nextDueAt: null, + nextDueOverdue: false, + nextDueMedicationName: null, + }); + computeUserHealthScoreFastPath.mockResolvedValue(null); }); describe("buildDashboardSnapshot — envelope shape", () => { @@ -195,6 +216,104 @@ describe("buildDashboardSnapshot — two-phase null extras", () => { }); }); +describe("buildDashboardSnapshot — medsToday (fast phase)", () => { + it("rides the fast phase and is present even on a rollup-coverage miss", async () => { + probeRollupCoverage.mockResolvedValue(new Map([["WEIGHT", false]])); + isFullyCovered.mockReturnValue(false); + + const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); + + expect(snap.medsToday).toEqual({ + activeCount: 1, + scheduledToday: 2, + takenToday: 1, + skippedToday: 0, + nextDueAt: null, + nextDueOverdue: false, + nextDueMedicationName: null, + }); + // Built from the user's id + timezone, not from any body field. + expect(buildMedsTodayBlock).toHaveBeenCalledWith( + fakePrisma, + "user-1", + "Europe/Berlin", + expect.any(Date), + ); + }); +}); + +describe("buildDashboardSnapshot — healthScore (warm phase only)", () => { + it("is null on a rollup-coverage miss and the fast path never runs", async () => { + probeRollupCoverage.mockResolvedValue(new Map([["WEIGHT", false]])); + isFullyCovered.mockReturnValue(false); + + const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); + + expect(snap.healthScore).toBeNull(); + expect(computeUserHealthScoreFastPath).not.toHaveBeenCalled(); + }); + + it("serialises score + band + delta — and ONLY those — when warm", async () => { + const coverageMap = new Map([["WEIGHT", true]]); + probeRollupCoverage.mockResolvedValue(coverageMap); + isFullyCovered.mockReturnValue(true); + computeBpInTargetFastPath.mockResolvedValue({ + last7Days: { pct: 70 }, + last30Days: { pct: 80 }, + allTime: { pct: 75 }, + priorMonth: { pct: 60 }, + priorYear: { pct: 50 }, + gradedScore: 88, + }); + computeUserHealthScoreFastPath.mockResolvedValue({ + score: 72, + band: "yellow", + delta: -12, + components: { bp: {}, weight: {}, mood: {}, compliance: {} }, + }); + + const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); + + // Score + band + delta only — components never reach the wire. + expect(snap.healthScore).toEqual({ score: 72, band: "yellow", delta: -12 }); + + // The fast path reuses the BP windows + coverage map already + // computed for `extras` (no re-probe, graded score threaded). + expect(computeUserHealthScoreFastPath).toHaveBeenCalledTimes(1); + const arg = computeUserHealthScoreFastPath.mock.calls[0][0] as { + userId: string; + bpInTargetPct: number | null; + bpGradedScore: number | null; + heightCm: number | null; + coverage: unknown; + }; + expect(arg.userId).toBe("user-1"); + expect(arg.bpInTargetPct).toBe(80); + expect(arg.bpGradedScore).toBe(88); + expect(arg.heightCm).toBe(180); + expect(arg.coverage).toBe(coverageMap); + }); + + it("is null when warm but no pillar is computable (fast path returns null)", async () => { + probeRollupCoverage.mockResolvedValue(new Map([["WEIGHT", true]])); + isFullyCovered.mockReturnValue(true); + computeBpInTargetFastPath.mockResolvedValue({ + last7Days: null, + last30Days: null, + allTime: null, + priorMonth: null, + priorYear: null, + gradedScore: null, + }); + computeUserHealthScoreFastPath.mockResolvedValue(null); + + const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); + + expect(snap.extras).not.toBeNull(); + expect(snap.healthScore).toBeNull(); + }); +}); + describe("buildDashboardSnapshot — briefingState matrix", () => { beforeEach(() => { probeRollupCoverage.mockResolvedValue(new Map()); @@ -507,6 +626,8 @@ describe("buildDashboardSnapshot — additive proof", () => { expect(snap).toHaveProperty("metricStates"); expect(snap).toHaveProperty("layoutCatalogue"); expect(snap).toHaveProperty("briefingStale"); + expect(snap).toHaveProperty("medsToday"); + expect(snap).toHaveProperty("healthScore"); // The web-consumed `layout` block is the resolved per-user layout — // NOT replaced by the 35-id catalogue. diff --git a/src/lib/dashboard/__tests__/verdict-client-safety.test.ts b/src/lib/dashboard/__tests__/verdict-client-safety.test.ts new file mode 100644 index 000000000..199f80bb8 --- /dev/null +++ b/src/lib/dashboard/__tests__/verdict-client-safety.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync, existsSync, statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; + +/** + * Client-safety pin for the verdict resolver's import graph. + * + * `resolveDashboardVerdict` runs inside the `"use client"` dashboard + * hero, so every VALUE import reachable from `verdict.ts` must stay off + * the server graph: no prisma, no `node:` builtins, no pg-boss, no + * `@/lib/jobs/coach-nudge` (whose dispatcher/feature-flag imports pull + * `node:async_hooks` — the exact regression class the dose-window + * editor hit when it imported `DOSE_WINDOW_DEFAULTS` from + * `@/lib/analytics/compliance`; see + * `src/lib/medications/scheduling/dose-window-defaults.ts`). The shared + * nudge thresholds therefore live in the dependency-free + * `@/lib/jobs/coach-nudge-thresholds` leaf. + * + * `import type` statements are erased by the compiler AND the bundler, + * so the walker skips them — the `DashboardSnapshot` type import from + * `@/lib/dashboard/snapshot` (a heavy server module) is legal exactly + * as long as it stays type-only. If someone drops the `type` keyword, + * the walker traverses into the snapshot builder and this suite fails. + */ + +const SRC = join(process.cwd(), "src"); +const ENTRY = join(SRC, "lib/dashboard/verdict.ts"); + +/** Specifier patterns that mark a server-only dependency. */ +const BANNED_SPECIFIERS: RegExp[] = [ + /^node:/, + /^@\/generated\//, + /^@\/lib\/db$/, + /^@prisma\//, + /^pg-boss$/, + /^server-only$/, + /^@\/lib\/jobs\/coach-nudge$/, +]; + +/** + * Extract VALUE-import specifiers from a module. Type-only statements + * (`import type … from`, `export type … from`) are erased at build + * time and skipped. Side-effect imports (`import "x"`) are included. + */ +function valueImportSpecifiers(source: string): string[] { + const out: string[] = []; + // Statement-anchored (`^import` / `^export`) and semicolon-bounded + // (`[^;]*?`) so the lazy scan can never swallow across statements or + // latch onto a `from "…"` inside an unrelated string / comment. + const stmt = + /^(import|export)(\s+type)?\s+[^;"']*?from\s*["']([^"']+)["']|^import\s*["']([^"']+)["']/gm; + let m: RegExpExecArray | null; + while ((m = stmt.exec(source)) !== null) { + if (m[4] !== undefined) { + out.push(m[4]); // bare side-effect import + continue; + } + if (m[2] !== undefined) continue; // `import type` / `export type` — erased + out.push(m[3]); + } + return out; +} + +/** Resolve `@/…` and relative specifiers to a file inside `src/`. */ +function resolveLocal(spec: string, fromFile: string): string | null { + let base: string; + if (spec.startsWith("@/")) base = join(SRC, spec.slice(2)); + else if (spec.startsWith(".")) base = resolve(dirname(fromFile), spec); + else return null; // external package + for (const candidate of [ + base, + `${base}.ts`, + `${base}.tsx`, + join(base, "index.ts"), + join(base, "index.tsx"), + ]) { + if (existsSync(candidate) && statSync(candidate).isFile()) { + return candidate; + } + } + throw new Error(`unresolvable local import "${spec}" from ${fromFile}`); +} + +function walkGraph(entry: string): { + visited: Set; + specifiers: Set; +} { + const visited = new Set(); + const specifiers = new Set(); + const queue = [entry]; + while (queue.length > 0) { + const file = queue.pop()!; + if (visited.has(file)) continue; + visited.add(file); + for (const spec of valueImportSpecifiers(readFileSync(file, "utf8"))) { + specifiers.add(spec); + const local = resolveLocal(spec, file); + if (local !== null) queue.push(local); + } + } + return { visited, specifiers }; +} + +describe("verdict.ts — client-safe import graph", () => { + const { visited, specifiers } = walkGraph(ENTRY); + + it("carries no server-only specifier anywhere in the value-import graph", () => { + const offenders = [...specifiers].filter((spec) => + BANNED_SPECIFIERS.some((re) => re.test(spec)), + ); + expect(offenders).toEqual([]); + }); + + it("never traverses into the snapshot builder (type-only boundary holds)", () => { + const snapshotModule = join(SRC, "lib/dashboard/snapshot.ts"); + expect(visited.has(snapshotModule)).toBe(false); + }); + + it("reads the nudge thresholds from the dependency-free leaf", () => { + expect(specifiers.has("@/lib/jobs/coach-nudge-thresholds")).toBe(true); + const leaf = readFileSync( + join(SRC, "lib/jobs/coach-nudge-thresholds.ts"), + "utf8", + ); + expect(valueImportSpecifiers(leaf)).toEqual([]); + }); + + it("the whole graph stays local — no external runtime package rides in", () => { + const external = [...specifiers].filter( + (spec) => !spec.startsWith("@/") && !spec.startsWith("."), + ); + expect(external).toEqual([]); + }); +}); diff --git a/src/lib/dashboard/__tests__/verdict.test.ts b/src/lib/dashboard/__tests__/verdict.test.ts new file mode 100644 index 000000000..c5f704c19 --- /dev/null +++ b/src/lib/dashboard/__tests__/verdict.test.ts @@ -0,0 +1,705 @@ +/** + * Dashboard hero — verdict-ladder unit tests. + * + * `resolveDashboardVerdict` is pure (snapshot in, verdict out, `now` + * injected), so the suite pins every rung, the first-hit-wins + * precedence, and the exact boundaries the spec locked: + * - bpCritical fires at sys 180, not 179 (fixed floors, never user + * thresholds), only on a fresh reading, and outranks an overdue + * dose (a crisis-level reading is rung 1); + * - weightDrift fires at a 0.50 kg distance delta, not 0.49; + * - silence fires at 7 days, not 6; + * - scoreDrop fires at −10, not −9; + * - doseUpcoming is timezone-correct (next anchor must be today in + * the USER's zone) and never fires on a stale past `nextDueAt` + * with `nextDueOverdue: false` (the cached-snapshot defensive + * contract). + */ +import { describe, it, expect } from "vitest"; + +import { resolveDashboardVerdict } from "../verdict"; +import type { DashboardSnapshot } from "../snapshot"; +import type { MedsTodayBlock } from "../meds-today"; +import type { DataSummary } from "@/lib/analytics/trends"; + +/** Noon Berlin (CEST) on a fixed summer day. */ +const NOW = new Date("2026-06-10T10:00:00.000Z"); + +function summary(partial: Partial): DataSummary { + return { + count: 0, + latest: null, + min: null, + max: null, + mean: null, + median: null, + avg7: null, + avg30: null, + slope7: null, + slope30: null, + slope90: null, + anomalyCount: 0, + ...partial, + }; +} + +function medsToday(partial: Partial = {}): MedsTodayBlock { + return { + activeCount: 0, + scheduledToday: 0, + takenToday: 0, + skippedToday: 0, + nextDueAt: null, + nextDueOverdue: false, + nextDueMedicationName: null, + ...partial, + }; +} + +function baseSnapshot( + overrides: Partial = {}, +): DashboardSnapshot { + return { + user: { + username: "tester", + timezone: "Europe/Berlin", + heightCm: null, + dateOfBirth: null, + gender: null, + glucoseUnit: null, + onboardingTourCompleted: true, + greetingHour: 12, + }, + layout: { version: 1, widgets: [] }, + layoutCatalogue: [], + metricStates: {}, + tiles: { + summaries: {}, + lastSeenByType: {}, + mood: { summary: null, entries: [] }, + }, + extras: null, + medsToday: medsToday(), + healthScore: null, + briefing: null, + briefingState: "preparing", + briefingUpdatedAt: null, + briefingStale: false, + generatedAt: NOW.toISOString(), + ...overrides, + }; +} + +function isoHoursAgo(hours: number, now: Date = NOW): string { + return new Date(now.getTime() - hours * 60 * 60 * 1000).toISOString(); +} + +const FRESH_FINDING = { + tone: "info" as const, + headline: "Steps held steady this week.", + detail: "Daily totals stayed within the usual range.", + delta: null, + sourceWindow: "7d" as const, + sourceMetric: "steps" as const, +}; + +const WATCH_FINDING = { + tone: "watch" as const, + headline: "Systolic crept up over seven days.", + detail: "The weekly mean sits above the prior week.", + delta: "+4 mmHg", + sourceWindow: "7d" as const, + sourceMetric: "bp" as const, +}; + +describe("rung 2 — doseOverdue", () => { + it("fires on nextDueOverdue with the medication name and a quick-entry CTA", () => { + const verdict = resolveDashboardVerdict( + baseSnapshot({ + medsToday: medsToday({ + scheduledToday: 2, + nextDueAt: isoHoursAgo(1), + nextDueOverdue: true, + nextDueMedicationName: "Metformin", + }), + }), + NOW, + ); + expect(verdict.variant).toBe("doseOverdue"); + expect(verdict.values).toEqual({ name: "Metformin" }); + expect(verdict.cta).toEqual({ + kind: "quickEntry", + target: "medicationIntake", + }); + }); + + it("yields to a simultaneously critical fresh BP reading (precedence)", () => { + // A crisis-level reading (≥ 180 / ≥ 110, ≤ 1 day old) is rung 1 — + // it must not hide behind a routine medication prompt. + const verdict = resolveDashboardVerdict( + baseSnapshot({ + medsToday: medsToday({ + nextDueOverdue: true, + nextDueMedicationName: "Metformin", + }), + tiles: { + summaries: { + BLOOD_PRESSURE_SYS: summary({ latest: 200, count: 5 }), + BLOOD_PRESSURE_DIA: summary({ latest: 120, count: 5 }), + }, + lastSeenByType: { + BLOOD_PRESSURE_SYS: { lastSeenAt: isoHoursAgo(1), daysAgo: 0 }, + }, + mood: { summary: null, entries: [] }, + }, + }), + NOW, + ); + expect(verdict.variant).toBe("bpCritical"); + }); + + it("wins over the BP rung once the critical reading has aged past a day", () => { + const verdict = resolveDashboardVerdict( + baseSnapshot({ + medsToday: medsToday({ + nextDueOverdue: true, + nextDueMedicationName: "Metformin", + }), + tiles: { + summaries: { + BLOOD_PRESSURE_SYS: summary({ latest: 200, count: 5 }), + BLOOD_PRESSURE_DIA: summary({ latest: 120, count: 5 }), + }, + lastSeenByType: { + BLOOD_PRESSURE_SYS: { lastSeenAt: isoHoursAgo(3 * 24), daysAgo: 3 }, + }, + mood: { summary: null, entries: [] }, + }, + }), + NOW, + ); + expect(verdict.variant).toBe("doseOverdue"); + }); + + it("never renders overdue from a stale past nextDueAt with the flag false", () => { + // Cached-snapshot defensive contract: the anchor passed AFTER the + // snapshot was built, so the flag is still false — plain summary. + const verdict = resolveDashboardVerdict( + baseSnapshot({ + medsToday: medsToday({ + scheduledToday: 1, + nextDueAt: isoHoursAgo(1), + nextDueOverdue: false, + nextDueMedicationName: "Metformin", + }), + }), + NOW, + ); + expect(verdict.variant).toBe("allQuiet"); + }); +}); + +describe("rung 1 — bpCritical", () => { + function bpSnapshot( + sys: number | null, + dia: number | null, + lastSeenAt: string, + ): DashboardSnapshot { + return baseSnapshot({ + tiles: { + summaries: { + ...(sys !== null && { + BLOOD_PRESSURE_SYS: summary({ latest: sys, count: 5 }), + }), + ...(dia !== null && { + BLOOD_PRESSURE_DIA: summary({ latest: dia, count: 5 }), + }), + }, + lastSeenByType: { + BLOOD_PRESSURE_SYS: { lastSeenAt, daysAgo: 0 }, + }, + mood: { summary: null, entries: [] }, + }, + }); + } + + it("fires at the fixed systolic floor 180 with a fresh reading", () => { + const verdict = resolveDashboardVerdict( + bpSnapshot(180, 95, isoHoursAgo(2)), + NOW, + ); + expect(verdict.variant).toBe("bpCritical"); + expect(verdict.values).toEqual({ sys: 180, dia: 95 }); + expect(verdict.cta).toEqual({ + kind: "link", + href: "/insights/blood-pressure", + }); + }); + + it("does NOT fire at 179 (boundary)", () => { + const verdict = resolveDashboardVerdict( + bpSnapshot(179, 95, isoHoursAgo(2)), + NOW, + ); + expect(verdict.variant).toBe("allQuiet"); + }); + + it("fires on the diastolic floor 110 alone", () => { + const verdict = resolveDashboardVerdict( + bpSnapshot(150, 110, isoHoursAgo(2)), + NOW, + ); + expect(verdict.variant).toBe("bpCritical"); + expect(verdict.values).toEqual({ sys: 150, dia: 110 }); + }); + + it("does NOT fire at dia 109 (boundary)", () => { + const verdict = resolveDashboardVerdict( + bpSnapshot(150, 109, isoHoursAgo(2)), + NOW, + ); + expect(verdict.variant).toBe("allQuiet"); + }); + + it("skips a critical-valued reading older than one day", () => { + const verdict = resolveDashboardVerdict( + bpSnapshot(190, 120, isoHoursAgo(3 * 24)), + NOW, + ); + expect(verdict.variant).toBe("allQuiet"); + }); + + it("still fires inside the one-day freshness window (daysAgo floor 1)", () => { + const verdict = resolveDashboardVerdict( + bpSnapshot(190, 120, isoHoursAgo(36)), + NOW, + ); + expect(verdict.variant).toBe("bpCritical"); + }); + + it("re-derives freshness from the injected now, not the snapshot daysAgo", () => { + // The snapshot claims daysAgo: 0 (computed at build time), but the + // injected `now` sits three days later — the rung must skip. + const snap = bpSnapshot(190, 120, isoHoursAgo(1)); + const later = new Date(NOW.getTime() + 3 * 24 * 60 * 60 * 1000); + expect(resolveDashboardVerdict(snap, later).variant).toBe("allQuiet"); + // Same snapshot evaluated at its build instant fires. + expect(resolveDashboardVerdict(snap, NOW).variant).toBe("bpCritical"); + }); +}); + +describe("rung 3 — doseUpcoming", () => { + function upcomingSnapshot( + nextDueAt: string, + tz = "Europe/Berlin", + ): DashboardSnapshot { + const snap = baseSnapshot({ + medsToday: medsToday({ + scheduledToday: 2, + takenToday: 1, + skippedToday: 0, + nextDueAt, + nextDueOverdue: false, + nextDueMedicationName: "Ramipril", + }), + }); + snap.user.timezone = tz; + return snap; + } + + it("fires for a dose due within two hours today, with local HH:mm", () => { + // 11:30 UTC = 13:30 Berlin (CEST). + const verdict = resolveDashboardVerdict( + upcomingSnapshot("2026-06-10T11:30:00.000Z"), + NOW, + ); + expect(verdict.variant).toBe("doseUpcoming"); + expect(verdict.values).toEqual({ time: "13:30", name: "Ramipril" }); + expect(verdict.cta).toEqual({ + kind: "quickEntry", + target: "medicationIntake", + }); + }); + + it("does NOT fire when the dose is more than two hours away", () => { + const verdict = resolveDashboardVerdict( + upcomingSnapshot("2026-06-10T12:30:00.000Z"), + NOW, + ); + expect(verdict.variant).toBe("allQuiet"); + }); + + it("fires exactly at the two-hour boundary", () => { + const verdict = resolveDashboardVerdict( + upcomingSnapshot("2026-06-10T12:00:00.000Z"), + NOW, + ); + expect(verdict.variant).toBe("doseUpcoming"); + }); + + it("does NOT fire when the next anchor falls on tomorrow in the user's zone", () => { + // 23:30 Berlin: a dose 1 h away sits at 00:30 LOCAL tomorrow even + // though it is still 2026-06-10 in UTC terms of distance. + const lateNow = new Date("2026-06-10T21:30:00.000Z"); // 23:30 Berlin + const verdict = resolveDashboardVerdict( + upcomingSnapshot("2026-06-10T22:30:00.000Z"), + lateNow, + ); + expect(verdict.variant).toBe("allQuiet"); + }); + + it("respects a non-European zone for the same instants", () => { + // Pacific/Auckland (UTC+12, no DST in June): 10:00 UTC = 22:00 + // local; a dose at 11:30 UTC = 23:30 local is still TODAY there. + const verdict = resolveDashboardVerdict( + upcomingSnapshot("2026-06-10T11:30:00.000Z", "Pacific/Auckland"), + NOW, + ); + expect(verdict.variant).toBe("doseUpcoming"); + expect(verdict.values).toEqual({ time: "23:30", name: "Ramipril" }); + }); + + it("does NOT fire when every scheduled dose is already resolved", () => { + const snap = upcomingSnapshot("2026-06-10T11:30:00.000Z"); + snap.medsToday.takenToday = 1; + snap.medsToday.skippedToday = 1; // 2 scheduled, 2 resolved + expect(resolveDashboardVerdict(snap, NOW).variant).toBe("allQuiet"); + }); +}); + +describe("rung 4 — weightDrift", () => { + // height 180 cm → green band [59.94, 80.676] kg. + const GREEN_MAX_180 = 24.9 * 1.8 * 1.8; + + function weightSnapshot( + avg7: number | null, + avg30: number | null, + heightCm: number | null = 180, + ): DashboardSnapshot { + const snap = baseSnapshot({ + tiles: { + summaries: { + WEIGHT: summary({ latest: avg7, avg7, avg30, count: 20 }), + }, + lastSeenByType: { + WEIGHT: { lastSeenAt: isoHoursAgo(2), daysAgo: 0 }, + }, + mood: { summary: null, entries: [] }, + }, + }); + snap.user.heightCm = heightCm; + return snap; + } + + it("fires at exactly the 0.50 kg drift threshold", () => { + // avg30 inside the band (distance 0), avg7 exactly 0.5 kg outside. + const verdict = resolveDashboardVerdict( + weightSnapshot(GREEN_MAX_180 + 0.5, GREEN_MAX_180 - 1), + NOW, + ); + expect(verdict.variant).toBe("weightDrift"); + expect(verdict.cta).toEqual({ kind: "link", href: "/insights/weight" }); + }); + + it("does NOT fire at 0.49 kg drift (boundary)", () => { + const verdict = resolveDashboardVerdict( + weightSnapshot(GREEN_MAX_180 + 0.49, GREEN_MAX_180 - 1), + NOW, + ); + expect(verdict.variant).toBe("allQuiet"); + }); + + it("skips entirely when no height is stored (no derivable range)", () => { + const verdict = resolveDashboardVerdict( + weightSnapshot(GREEN_MAX_180 + 5, GREEN_MAX_180 - 1, null), + NOW, + ); + expect(verdict.variant).toBe("allQuiet"); + }); + + it("skips when avg7 or avg30 is null", () => { + expect(resolveDashboardVerdict(weightSnapshot(null, 75), NOW).variant).toBe( + "allQuiet", + ); + expect( + resolveDashboardVerdict(weightSnapshot(GREEN_MAX_180 + 5, null), NOW) + .variant, + ).toBe("allQuiet"); + }); + + it("stays silent while converging back toward the range", () => { + // avg7 closer to the band than avg30 — drift is negative. + const verdict = resolveDashboardVerdict( + weightSnapshot(GREEN_MAX_180 + 1, GREEN_MAX_180 + 3), + NOW, + ); + expect(verdict.variant).toBe("allQuiet"); + }); +}); + +describe("rung 5 — shortNights", () => { + function sleepSnapshot( + avg7: number | null, + count: number, + ): DashboardSnapshot { + return baseSnapshot({ + tiles: { + summaries: { + SLEEP_DURATION: summary({ avg7, count, latest: avg7 }), + }, + lastSeenByType: { + SLEEP_DURATION: { lastSeenAt: isoHoursAgo(8), daysAgo: 0 }, + }, + mood: { summary: null, entries: [] }, + }, + }); + } + + it("fires under the clear-margin floor (6.5 h) with enough readings", () => { + const verdict = resolveDashboardVerdict(sleepSnapshot(360, 7), NOW); + expect(verdict.variant).toBe("shortNights"); + expect(verdict.values).toEqual({ hours: 6 }); + expect(verdict.cta).toEqual({ kind: "link", href: "/insights/sleep" }); + }); + + it("fires just under the 390-minute floor, not at it (boundary)", () => { + expect(resolveDashboardVerdict(sleepSnapshot(389, 7), NOW).variant).toBe( + "shortNights", + ); + expect(resolveDashboardVerdict(sleepSnapshot(390, 7), NOW).variant).toBe( + "allQuiet", + ); + }); + + it("stays silent on a sparse week (fewer than 5 readings)", () => { + expect(resolveDashboardVerdict(sleepSnapshot(300, 4), NOW).variant).toBe( + "allQuiet", + ); + }); +}); + +describe("rung 6 — silence", () => { + function silenceSnapshot( + seen: Record, + ): DashboardSnapshot { + return baseSnapshot({ + tiles: { + summaries: {}, + lastSeenByType: seen, + mood: { summary: null, entries: [] }, + }, + }); + } + + it("fires when every logged-ever core vital is silent for ≥ 7 days", () => { + const verdict = resolveDashboardVerdict( + silenceSnapshot({ + WEIGHT: { lastSeenAt: isoHoursAgo(8 * 24), daysAgo: 8 }, + BLOOD_PRESSURE_SYS: { lastSeenAt: isoHoursAgo(9 * 24), daysAgo: 9 }, + // glucose never logged — excluded from the min. + }), + NOW, + ); + expect(verdict.variant).toBe("silence"); + expect(verdict.values).toEqual({ days: 8 }); + expect(verdict.cta).toEqual({ kind: "quickEntry", target: "measurement" }); + }); + + it("does NOT fire at 6 days (boundary), fires at exactly 7", () => { + expect( + resolveDashboardVerdict( + silenceSnapshot({ + WEIGHT: { lastSeenAt: isoHoursAgo(6 * 24), daysAgo: 6 }, + }), + NOW, + ).variant, + ).toBe("allQuiet"); + expect( + resolveDashboardVerdict( + silenceSnapshot({ + WEIGHT: { lastSeenAt: isoHoursAgo(7 * 24), daysAgo: 7 }, + }), + NOW, + ).variant, + ).toBe("silence"); + }); + + it("a single fresh vital silences the rung (min, not max)", () => { + const verdict = resolveDashboardVerdict( + silenceSnapshot({ + WEIGHT: { lastSeenAt: isoHoursAgo(30 * 24), daysAgo: 30 }, + BLOOD_GLUCOSE: { lastSeenAt: isoHoursAgo(2), daysAgo: 0 }, + }), + NOW, + ); + expect(verdict.variant).toBe("allQuiet"); + }); + + it("skips when none of the three vitals was ever logged", () => { + expect(resolveDashboardVerdict(silenceSnapshot({}), NOW).variant).toBe( + "allQuiet", + ); + }); +}); + +describe("rung 7 — scoreDrop", () => { + function scoreSnapshot(delta: number | null): DashboardSnapshot { + return baseSnapshot({ + healthScore: { score: 60, band: "yellow", delta }, + }); + } + + it("fires at a −10 delta (boundary)", () => { + const verdict = resolveDashboardVerdict(scoreSnapshot(-10), NOW); + expect(verdict.variant).toBe("scoreDrop"); + expect(verdict.values).toEqual({ points: 10 }); + expect(verdict.cta).toEqual({ kind: "link", href: "/insights" }); + }); + + it("does NOT fire at −9 (boundary)", () => { + expect(resolveDashboardVerdict(scoreSnapshot(-9), NOW).variant).toBe( + "allQuiet", + ); + }); + + it("skips a null delta and a null score block", () => { + expect(resolveDashboardVerdict(scoreSnapshot(null), NOW).variant).toBe( + "allQuiet", + ); + expect( + resolveDashboardVerdict(baseSnapshot({ healthScore: null }), NOW).variant, + ).toBe("allQuiet"); + }); +}); + +describe("rung 8 — briefing", () => { + it("renders the first watch-tone headline verbatim from a fresh briefing", () => { + const verdict = resolveDashboardVerdict( + baseSnapshot({ + briefing: { + paragraph: "Calm day overall.", + keyFindings: [FRESH_FINDING, WATCH_FINDING], + }, + briefingState: "ready", + briefingStale: false, + briefingUpdatedAt: NOW.toISOString(), + }), + NOW, + ); + expect(verdict.variant).toBe("briefing"); + expect(verdict.values).toEqual({ headline: WATCH_FINDING.headline }); + expect(verdict.cta).toEqual({ kind: "link", href: "/insights" }); + }); + + it("falls back to the first finding when no watch tone exists", () => { + const verdict = resolveDashboardVerdict( + baseSnapshot({ + briefing: { + paragraph: "Calm day overall.", + keyFindings: [FRESH_FINDING], + }, + briefingState: "ready", + briefingStale: false, + }), + NOW, + ); + expect(verdict.variant).toBe("briefing"); + expect(verdict.values).toEqual({ headline: FRESH_FINDING.headline }); + }); + + it("skips a STALE briefing even in ready-shaped delivery", () => { + const verdict = resolveDashboardVerdict( + baseSnapshot({ + briefing: { + paragraph: "Yesterday's text.", + keyFindings: [WATCH_FINDING], + }, + briefingState: "preparing", + briefingStale: true, + }), + NOW, + ); + expect(verdict.variant).toBe("allQuiet"); + }); + + it("skips ready + stale (belt and braces on the two flags)", () => { + const verdict = resolveDashboardVerdict( + baseSnapshot({ + briefing: { + paragraph: "Old text.", + keyFindings: [WATCH_FINDING], + }, + briefingState: "ready", + briefingStale: true, + }), + NOW, + ); + expect(verdict.variant).toBe("allQuiet"); + }); + + it("skips an empty keyFindings array", () => { + const verdict = resolveDashboardVerdict( + baseSnapshot({ + briefing: { paragraph: "Flat data day.", keyFindings: [] }, + briefingState: "ready", + briefingStale: false, + }), + NOW, + ); + expect(verdict.variant).toBe("allQuiet"); + }); +}); + +describe("rung 9 — allQuiet + full-ladder precedence", () => { + it("returns allQuiet with no CTA on an empty snapshot", () => { + const verdict = resolveDashboardVerdict(baseSnapshot(), NOW); + expect(verdict).toEqual({ variant: "allQuiet", values: {}, cta: null }); + }); + + it("bpCritical outranks every lower rung when stacked", () => { + // BP critical + short nights + score drop + fresh briefing — the + // ladder must stop at rung 2. + const verdict = resolveDashboardVerdict( + baseSnapshot({ + tiles: { + summaries: { + BLOOD_PRESSURE_SYS: summary({ latest: 185, count: 5 }), + BLOOD_PRESSURE_DIA: summary({ latest: 100, count: 5 }), + SLEEP_DURATION: summary({ avg7: 300, count: 7 }), + }, + lastSeenByType: { + BLOOD_PRESSURE_SYS: { lastSeenAt: isoHoursAgo(1), daysAgo: 0 }, + SLEEP_DURATION: { lastSeenAt: isoHoursAgo(8), daysAgo: 0 }, + }, + mood: { summary: null, entries: [] }, + }, + healthScore: { score: 40, band: "red", delta: -20 }, + briefing: { + paragraph: "Busy day.", + keyFindings: [WATCH_FINDING], + }, + briefingState: "ready", + briefingStale: false, + }), + NOW, + ); + expect(verdict.variant).toBe("bpCritical"); + }); + + it("scoreDrop outranks the briefing teaser", () => { + const verdict = resolveDashboardVerdict( + baseSnapshot({ + healthScore: { score: 50, band: "yellow", delta: -15 }, + briefing: { + paragraph: "Busy day.", + keyFindings: [WATCH_FINDING], + }, + briefingState: "ready", + briefingStale: false, + }), + NOW, + ); + expect(verdict.variant).toBe("scoreDrop"); + expect(verdict.values).toEqual({ points: 15 }); + }); +}); diff --git a/src/lib/dashboard/meds-today.ts b/src/lib/dashboard/meds-today.ts new file mode 100644 index 000000000..1d7745c3c --- /dev/null +++ b/src/lib/dashboard/meds-today.ts @@ -0,0 +1,215 @@ +/** + * Today's medication block for the dashboard hero + summary tally. + * + * `buildMedsTodayBlock` is the one shared builder behind every surface + * that answers "how is the medication day going?": + * + * 1. **Projection** — `projectTodayIntakesAndRecompute` idempotently + * mints the day's pending `MedicationIntakeEvent` rows for active + * schedules (same helper the intake route + dashboard summary use), + * so the tally below never under-counts a daily med whose rows the + * reminder worker has not minted yet. + * 2. **Tally** — one today-window event read produces + * `scheduledToday` / `takenToday` / `skippedToday` with the exact + * semantics the summary route's compliance tile always had + * (taken = `takenAt !== null && !skipped`; tombstones excluded). + * 3. **Next due** — the same four feeder reads + + * `computeDisplayDue` loop the medications list route runs + * (`src/app/api/medications/route.ts`), restricted to ACTIVE + * medications: latest non-skipped intake per med (rolling cadences + * re-anchor on it), resolved slots over the overdue-lookback + * horizon, and the current-era floor per med. The block surfaces + * the EARLIEST display-due across medications — an open overdue + * slot sits in the past, so it naturally wins over any future slot. + * + * The feeder reads are not lifted out of the medications list route + * because that route reuses the same rows for its own response fields + * (`lastTakenAt`, `todayEventCount`, per-card next-due); the clean + * shared seam is `computeDisplayDue` itself, which both call sites go + * through. + * + * Consumer contract (snapshot caching): `nextDueAt` / `nextDueOverdue` + * are computed at build time and may be served from a cache. A + * `nextDueAt` in the past with `nextDueOverdue: false` means the slot's + * anchor passed AFTER the block was built — render the plain day + * summary, never an overdue state, until a fresh block arrives. + */ +import type { PrismaClient } from "@/generated/prisma/client"; +import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes"; +import { + computeDisplayDue, + OVERDUE_LOOKBACK_MS, + toResolvedSlotMark, + type ResolvedSlotMark, +} from "@/lib/medications/scheduling/next-due"; +import { getUserTodayBounds } from "@/lib/tz/local-day"; + +export interface MedsTodayBlock { + /** Active medications (paused courses excluded). */ + activeCount: number; + /** Intake-event rows scheduled in the user's local today (all states). */ + scheduledToday: number; + /** Rows with `takenAt` set and not skipped. */ + takenToday: number; + /** Rows the user deliberately skipped. */ + skippedToday: number; + /** + * Earliest display-due instant across active medications (ISO8601), + * or null when nothing is due (no schedules, course ended, all PRN). + * May lie outside today — the consumer gates on the local day. + */ + nextDueAt: string | null; + /** + * True when `nextDueAt` is an OPEN overdue slot (anchor passed, still + * inside its catch-up band, unresolved). A cached `nextDueAt` in the + * past with `nextDueOverdue: false` must render as the plain summary, + * never as overdue — see the module doc. + */ + nextDueOverdue: boolean; + /** Name of the medication carrying `nextDueAt`; null when none due. */ + nextDueMedicationName: string | null; +} + +export async function buildMedsTodayBlock( + prisma: PrismaClient, + userId: string, + userTz: string, + now: Date, +): Promise { + const { start: todayStart, end: todayEnd } = getUserTodayBounds(now, userTz); + // The projector + tally read use an exclusive upper bound (`lt`), + // matching the summary route's window convention. + const todayEndExclusive = new Date(todayStart.getTime() + 24 * 60 * 60 * 1000); + + // Projection FIRST so the tally read below sees the freshly minted + // pending rows. Idempotent — `skipDuplicates` + the + // `(userId, medicationId, scheduledFor, source)` unique index make a + // concurrent intake-route hit safe. + await projectTodayIntakesAndRecompute({ + userId, + userTz, + todayStart, + todayEnd: todayEndExclusive, + }); + + // Resolved-slot window — same horizon the medications list route uses: + // back as far as the widest band tail (so a long-resolved past slot + // cannot resurface as overdue), forward two days for the lookahead. + const resolvedWindowStart = new Date( + todayStart.getTime() - OVERDUE_LOOKBACK_MS, + ); + const resolvedWindowEnd = new Date( + todayEnd.getTime() + 2 * 24 * 60 * 60 * 1000, + ); + + const [medications, todayEvents, latestIntakes, resolvedEvents, eraFloors] = + await Promise.all([ + prisma.medication.findMany({ + where: { userId, active: true }, + include: { schedules: true }, + orderBy: { createdAt: "desc" }, + }), + prisma.medicationIntakeEvent.findMany({ + where: { + userId, + deletedAt: null, + scheduledFor: { gte: todayStart, lt: todayEndExclusive }, + }, + select: { takenAt: true, skipped: true }, + }), + prisma.medicationIntakeEvent.groupBy({ + by: ["medicationId"], + where: { + userId, + deletedAt: null, + skipped: false, + takenAt: { not: null }, + }, + _max: { takenAt: true }, + }), + prisma.medicationIntakeEvent.findMany({ + where: { + userId, + deletedAt: null, + scheduledFor: { gte: resolvedWindowStart, lte: resolvedWindowEnd }, + OR: [ + { takenAt: { not: null } }, + { skipped: true }, + { autoMissed: true }, + ], + }, + // v1.16.9 — `takenAt` rides along so an ad-hoc row (`scheduledFor + // === takenAt`) cannot ±6h-resolve a different slot. + select: { medicationId: true, scheduledFor: true, takenAt: true }, + }), + // Current-era floor per medication: the newest revision's + // `validUntil` is where the LIVE schedule rows became valid; the + // open-overdue search must not reach past it into a previous + // era's cadence. + prisma.medicationScheduleRevision.groupBy({ + by: ["medicationId"], + where: { medication: { userId }, supersededByRevisionId: null }, + _max: { validUntil: true }, + }), + ]); + + const takenToday = todayEvents.filter( + (e) => e.takenAt !== null && !e.skipped, + ).length; + const skippedToday = todayEvents.filter((e) => e.skipped).length; + + const lastTakenAtByMedId = new Map( + latestIntakes.map((entry) => [entry.medicationId, entry._max.takenAt]), + ); + const resolvedSlotsByMedId = new Map(); + for (const e of resolvedEvents) { + const mark = toResolvedSlotMark(e); + const list = resolvedSlotsByMedId.get(e.medicationId); + if (list) list.push(mark); + else resolvedSlotsByMedId.set(e.medicationId, [mark]); + } + const eraStartByMedId = new Map(); + for (const f of eraFloors) { + if (f._max.validUntil) eraStartByMedId.set(f.medicationId, f._max.validUntil); + } + + // Earliest display-due across active medications. An open overdue + // slot's anchor is in the past, so the minimum naturally prefers it + // over any future slot. + let nextDueAt: Date | null = null; + let nextDueOverdue = false; + let nextDueMedicationName: string | null = null; + for (const m of medications) { + const display = computeDisplayDue({ + medication: { + id: m.id, + startsOn: m.startsOn, + endsOn: m.endsOn, + oneShot: m.oneShot, + createdAt: m.createdAt, + }, + schedules: m.schedules, + now, + userTz, + lastIntakeAt: lastTakenAtByMedId.get(m.id) ?? null, + resolvedSlots: resolvedSlotsByMedId.get(m.id) ?? [], + eraStart: eraStartByMedId.get(m.id) ?? null, + }); + if (!display) continue; + if (nextDueAt === null || display.at.getTime() < nextDueAt.getTime()) { + nextDueAt = display.at; + nextDueOverdue = display.overdue; + nextDueMedicationName = m.name; + } + } + + return { + activeCount: medications.length, + scheduledToday: todayEvents.length, + takenToday, + skippedToday, + nextDueAt: nextDueAt ? nextDueAt.toISOString() : null, + nextDueOverdue, + nextDueMedicationName, + }; +} diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 945ee4f14..bb633d37d 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -66,6 +66,12 @@ import { dailyBriefingSchema, type DailyBriefing } from "@/lib/ai/schema"; import { getAssistantFlags } from "@/lib/feature-flags"; import { hasAnyConfiguredProvider } from "@/lib/ai/provider"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; +import { + buildMedsTodayBlock, + type MedsTodayBlock, +} from "@/lib/dashboard/meds-today"; +import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path"; +import type { HealthScoreBand } from "@/lib/analytics/health-score"; /** Briefing freshness window — mirrors the 24 h TTL on the advisor cache. */ const BRIEFING_TTL_MS = 24 * 60 * 60 * 1000; @@ -213,6 +219,18 @@ export interface DashboardLayoutCatalogueEntry { order: number; } +/** + * Health-score summary for the hero — score + band + week-over-week + * delta ONLY. The per-pillar component breakdown is deliberately NOT + * serialised here; the analytics route remains the one surface that + * exposes components. + */ +export interface DashboardSnapshotHealthScore { + score: number; + band: HealthScoreBand; + delta: number | null; +} + export interface DashboardSnapshot { user: DashboardSnapshotUser; layout: DashboardLayout; @@ -241,6 +259,24 @@ export interface DashboardSnapshot { }; /** Thick phase — null on a rollup-coverage miss. */ extras: DashboardSnapshotExtras | null; + /** + * Fast phase — today's medication block (projection-backed tally + + * earliest next-due across active medications). + * + * Caching contract: the snapshot is served read-through a cache, so + * `medsToday.nextDueAt` can sit in the past with + * `medsToday.nextDueOverdue: false` when the slot's anchor passed + * AFTER the snapshot was built. Consumers MUST render that state as + * the plain day summary — never as overdue — until a fresh snapshot + * carries `nextDueOverdue: true` from the band model itself. + */ + medsToday: MedsTodayBlock; + /** + * Warm-phase health score (score + band + delta only — no + * components). `null` on a rollup-coverage miss (the score rides the + * thick phase alongside `extras`) and when no pillar is computable. + */ + healthScore: DashboardSnapshotHealthScore | null; briefing: DailyBriefing | null; briefingState: BriefingState; briefingUpdatedAt: string | null; @@ -377,29 +413,40 @@ async function buildMoodBlock( } /** - * Thick slice — BD-Zielbereich + per-context glucose. Only called by - * the builder when the rollup tier is warm (`isFullyCovered`); the BP - * fast-path then stays on the sub-second rollup branch and never drops - * into the multi-second live fallback that would make the whole strip - * wait. On a coverage miss the builder skips this entirely and emits - * `extras: null` (per-tile shimmer until the boot backfill converges). + * Thick slice — BD-Zielbereich + per-context glucose + health score. + * Only called by the builder when the rollup tier is warm + * (`isFullyCovered`); the BP fast-path then stays on the sub-second + * rollup branch and never drops into the multi-second live fallback + * that would make the whole strip wait. On a coverage miss the builder + * skips this entirely and emits `extras: null` + `healthScore: null` + * (per-tile shimmer until the boot backfill converges). + * + * The health score rides this warm phase because it reuses the BP + * windows already computed here (`gradedScore` + `last30Days.pct`) and + * the same coverage map, so the score's weight pillar also stays on + * the rollup branch. */ async function buildExtras( prisma: PrismaClient, user: SnapshotUserInput, userTz: string, coverage: RollupCoverageMap, -): Promise { + now: Date, + time: (label: string, fn: () => Promise) => Promise, +): Promise<{ + extras: DashboardSnapshotExtras; + healthScore: DashboardSnapshotHealthScore | null; +}> { let bpInTargetPct: number | null = null; let bpInTargetPct7d: number | null = null; let bpInTargetPct30d: number | null = null; let bpInTargetPctAllTime: number | null = null; let bpInTargetPctPriorMonth: number | null = null; let bpInTargetPctPriorYear: number | null = null; + let bpGradedScore: number | null = null; const bpTargets = getBpTargets(user.dateOfBirth); if (bpTargets) { - const now = new Date(); const windows = await computeBpInTargetFastPath({ userId: user.id, targets: bpTargets, @@ -413,8 +460,30 @@ async function buildExtras( bpInTargetPctAllTime = windows.allTime?.pct ?? null; bpInTargetPctPriorMonth = windows.priorMonth?.pct ?? null; bpInTargetPctPriorYear = windows.priorYear?.pct ?? null; + bpGradedScore = windows.gradedScore ?? null; } + // Health score — reuses the BP windows captured above plus the + // already-probed coverage map (no second probe). Score + band + + // delta only; the component breakdown stays off this wire. + const scoreResult = await time("healthScore", () => + computeUserHealthScoreFastPath({ + userId: user.id, + bpInTargetPct, + bpGradedScore, + heightCm: user.heightCm, + now, + coverage, + }), + ); + const healthScore: DashboardSnapshotHealthScore | null = scoreResult + ? { + score: scoreResult.score, + band: scoreResult.band, + delta: scoreResult.delta, + } + : null; + const glucoseSince = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); const glucoseRows = await prisma.measurement.findMany({ where: { @@ -440,13 +509,16 @@ async function buildExtras( } return { - bpInTargetPct, - bpInTargetPct7d, - bpInTargetPct30d, - bpInTargetPctAllTime, - bpInTargetPctPriorMonth, - bpInTargetPctPriorYear, - glucoseByContext, + extras: { + bpInTargetPct, + bpInTargetPct7d, + bpInTargetPct30d, + bpInTargetPctAllTime, + bpInTargetPctPriorMonth, + bpInTargetPctPriorYear, + glucoseByContext, + }, + healthScore, }; } @@ -646,15 +718,19 @@ export async function buildDashboardSnapshot( const coverage = await time("coverage", () => probeRollupCoverage(user.id)); const warm = isFullyCovered(coverage); - const [slim, mood, extras, flags] = await Promise.all([ + const [slim, mood, extrasResult, flags, medsToday] = await Promise.all([ // A5 — reuse the coverage map already probed above so the slice // doesn't re-run the identical `probeRollupCoverage` query. time("summaries", () => computeSummariesSlice(user.id, coverage)), time("mood", () => buildMoodBlock(prisma, user.id)), warm - ? time("extras", () => buildExtras(prisma, user, userTz, coverage)) + ? time("extras", () => + buildExtras(prisma, user, userTz, coverage, now, time), + ) : Promise.resolve(null), time("flags", () => getAssistantFlags()), + // Fast phase — projection-backed today tally + earliest next-due. + time("medsToday", () => buildMedsTodayBlock(prisma, user.id, userTz, now)), ]); const layout = resolveDashboardLayout(user.dashboardWidgetsJson); @@ -689,7 +765,9 @@ export async function buildDashboardSnapshot( entries: mood.entries, }, }, - extras, + extras: extrasResult?.extras ?? null, + medsToday, + healthScore: extrasResult?.healthScore ?? null, briefing: briefing.briefing, briefingState: briefing.briefingState, briefingUpdatedAt: briefing.briefingUpdatedAt, diff --git a/src/lib/dashboard/verdict.ts b/src/lib/dashboard/verdict.ts new file mode 100644 index 000000000..59c62e330 --- /dev/null +++ b/src/lib/dashboard/verdict.ts @@ -0,0 +1,286 @@ +/** + * Dashboard hero — daily-verdict resolver. + * + * Pure function over an already-built `DashboardSnapshot`: no DB, no + * network, no clock read (`now` is injected so the resolver is + * deterministic under test and a cached snapshot can be re-evaluated + * against the current instant). + * + * Nine rungs, first hit wins. The ordering is a severity ladder: + * the fixed-floor BP emergency first (a fresh crisis-level reading + * outranks an overdue dose), then the actionable medication states, + * then trend nudges, then the briefing teaser, then the all-quiet + * fallback. Threshold constants are imported from the client-safe + * coach-nudge-thresholds leaf where one exists so the hero and the + * nudge cron can never disagree on what "drift" or "deficit" means — + * and so this module never pulls the cron's server graph into a + * client bundle (this resolver runs inside `"use client"` code). + * + * Freshness is re-derived from the snapshot's ISO timestamps against + * the injected `now` (not the snapshot's own `daysAgo` fields) so a + * snapshot served from cache ages honestly. + * + * Defensive contract (cache staleness): a `medsToday.nextDueAt` in the + * past with `nextDueOverdue: false` means the slot's anchor passed + * after the snapshot was built. Rung 2 keys ONLY on the server-computed + * `nextDueOverdue` flag and rung 3 requires `nextDueAt >= now`, so that + * state falls through to the plain summary — never renders as overdue. + */ +import { + COACH_NUDGE_WEIGHT_DRIFT_KG, + COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H, +} from "@/lib/jobs/coach-nudge-thresholds"; +import { buildWeightRangeFromHeight } from "@/lib/analytics/value-bands"; +import type { DashboardSnapshot } from "@/lib/dashboard/snapshot"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * Fixed clinical floors for the BP-critical rung (ESC/ESH grade-3 / + * hypertensive-crisis territory). Deliberately NOT the user's + * personal targets — a user-relaxed threshold must never silence a + * 180/110 reading on the hero. + */ +const BP_CRITICAL_SYS_FLOOR = 180; +const BP_CRITICAL_DIA_FLOOR = 110; +/** A critical reading older than this many days is history, not an alert. */ +const BP_CRITICAL_MAX_DAYS_AGO = 1; + +/** Rung 3 — surface a dose at most this far before its anchor. */ +const DOSE_UPCOMING_WINDOW_MS = 2 * 60 * 60 * 1000; + +/** Rung 5 — nightly floor (hours) the short-nights rung measures against. */ +const SHORT_NIGHTS_FLOOR_H = 7; +/** Rung 5 — minimum readings in the summary window before the rung fires. */ +const SHORT_NIGHTS_MIN_COUNT = 5; + +/** Rung 6 — every logged-ever core vital silent for at least this long. */ +const SILENCE_MIN_DAYS = 7; +/** Rung 6 — the core vitals the silence rung watches. */ +const SILENCE_TYPES = [ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "BLOOD_GLUCOSE", +] as const; + +/** Rung 7 — week-over-week score drop (points) that fires. */ +const SCORE_DROP_MIN_POINTS = 10; + +export type DashboardVerdictVariant = + | "doseOverdue" + | "bpCritical" + | "doseUpcoming" + | "weightDrift" + | "shortNights" + | "silence" + | "scoreDrop" + | "briefing" + | "allQuiet"; + +export type DashboardVerdictCta = + | { kind: "quickEntry"; target: "medicationIntake" | "measurement" } + | { kind: "link"; href: string }; + +export interface DashboardVerdict { + variant: DashboardVerdictVariant; + /** + * Interpolation values for the variant's i18n message. `briefing` + * carries the model-authored headline VERBATIM under `headline`; + * renderers must emit it as plain text children (no HTML, no + * markdown) per the repo-wide no-markdown-renderer rule. + */ + values: Record; + cta: DashboardVerdictCta | null; +} + +/** YYYY-MM-DD key of `d`'s calendar day in `tz`. */ +function dayKeyInTz(d: Date, tz: string): string { + return new Intl.DateTimeFormat("en-CA", { + timeZone: tz, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(d); +} + +/** HH:mm wall-clock of `d` in `tz`. */ +function hmInTz(d: Date, tz: string): string { + const parts = new Intl.DateTimeFormat("en-GB", { + timeZone: tz, + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(d); + // Some ICU builds emit "24:xx" for midnight under hour12: false. + return parts.startsWith("24") ? `00${parts.slice(2)}` : parts; +} + +/** Whole days between an ISO instant and `now` (floor; negative-safe). */ +function daysSince(iso: string, now: Date): number { + return Math.floor((now.getTime() - Date.parse(iso)) / DAY_MS); +} + +export function resolveDashboardVerdict( + snapshot: DashboardSnapshot, + now: Date, +): DashboardVerdict { + const meds = snapshot.medsToday; + const tz = snapshot.user.timezone; + const summaries = snapshot.tiles.summaries; + const lastSeen = snapshot.tiles.lastSeenByType; + + // ── 1 · bpCritical ───────────────────────────────────────────────── + // Fixed floors (sys ≥ 180 OR dia ≥ 110), never user thresholds, and + // only while the reading is fresh (≤ 1 day, re-derived against the + // injected `now`). Sits ABOVE the overdue dose: a fresh crisis-level + // reading is the one item that must not hide behind a routine + // medication prompt. + const sysLatest = summaries.BLOOD_PRESSURE_SYS?.latest ?? null; + const diaLatest = summaries.BLOOD_PRESSURE_DIA?.latest ?? null; + const sysSeen = lastSeen.BLOOD_PRESSURE_SYS; + const bpCriticalValue = + (sysLatest !== null && sysLatest >= BP_CRITICAL_SYS_FLOOR) || + (diaLatest !== null && diaLatest >= BP_CRITICAL_DIA_FLOOR); + if ( + bpCriticalValue && + sysSeen && + daysSince(sysSeen.lastSeenAt, now) <= BP_CRITICAL_MAX_DAYS_AGO + ) { + return { + variant: "bpCritical", + values: { sys: sysLatest ?? 0, dia: diaLatest ?? 0 }, + cta: { kind: "link", href: "/insights/blood-pressure" }, + }; + } + + // ── 2 · doseOverdue ──────────────────────────────────────────────── + // Keys ONLY on the server-computed band-model flag. A stale cached + // `nextDueAt` in the past with `nextDueOverdue: false` falls through + // (defensive contract — see module doc). + if (meds.nextDueOverdue === true) { + return { + variant: "doseOverdue", + values: { name: meds.nextDueMedicationName ?? "" }, + cta: { kind: "quickEntry", target: "medicationIntake" }, + }; + } + + // ── 3 · doseUpcoming ─────────────────────────────────────────────── + // Unresolved doses remain today AND the next anchor sits within the + // next two hours on the user's local calendar day. + const unresolvedToday = + meds.scheduledToday > meds.takenToday + meds.skippedToday; + if (unresolvedToday && meds.nextDueAt !== null) { + const dueMs = Date.parse(meds.nextDueAt); + const untilDue = dueMs - now.getTime(); + if ( + untilDue >= 0 && + untilDue <= DOSE_UPCOMING_WINDOW_MS && + dayKeyInTz(new Date(dueMs), tz) === dayKeyInTz(now, tz) + ) { + return { + variant: "doseUpcoming", + values: { + time: hmInTz(new Date(dueMs), tz), + name: meds.nextDueMedicationName ?? "", + }, + cta: { kind: "quickEntry", target: "medicationIntake" }, + }; + } + } + + // ── 4 · weightDrift ──────────────────────────────────────────────── + // Distance-to-green-range drift: the 7-day mean sits further from + // the BMI-derived green band than the 30-day mean by at least the + // coach-nudge drift threshold. No stored height → no range → skip. + const weight = summaries.WEIGHT; + if ( + snapshot.user.heightCm !== null && + weight && + weight.avg7 !== null && + weight.avg30 !== null + ) { + const range = buildWeightRangeFromHeight(snapshot.user.heightCm); + const dist = (x: number): number => + Math.max(0, range.greenMin - x, x - range.greenMax); + if (dist(weight.avg7) - dist(weight.avg30) >= COACH_NUDGE_WEIGHT_DRIFT_KG) { + return { + variant: "weightDrift", + values: {}, + cta: { kind: "link", href: "/insights/weight" }, + }; + } + } + + // ── 5 · shortNights ──────────────────────────────────────────────── + // 7-day mean sleep (stored in minutes) clearly under the floor, with + // enough readings that a sparse week stays silent. The clear-margin + // is the same constant the coach-nudge sleep trigger uses. + const sleep = summaries.SLEEP_DURATION; + if (sleep && sleep.avg7 !== null && sleep.count >= SHORT_NIGHTS_MIN_COUNT) { + const floorMinutes = + (SHORT_NIGHTS_FLOOR_H - COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H) * 60; + if (sleep.avg7 < floorMinutes) { + return { + variant: "shortNights", + values: { hours: Math.round((sleep.avg7 / 60) * 10) / 10 }, + cta: { kind: "link", href: "/insights/sleep" }, + }; + } + } + + // ── 6 · silence ──────────────────────────────────────────────────── + // EVERY logged-ever core vital has been silent for ≥ 7 days (min over + // the per-type ages). Types the user never logged don't count — a + // BP-only account is judged on BP alone. + let minDaysAgo: number | null = null; + for (const type of SILENCE_TYPES) { + const seen = lastSeen[type]; + if (!seen) continue; + const days = daysSince(seen.lastSeenAt, now); + if (minDaysAgo === null || days < minDaysAgo) minDaysAgo = days; + } + if (minDaysAgo !== null && minDaysAgo >= SILENCE_MIN_DAYS) { + return { + variant: "silence", + values: { days: minDaysAgo }, + cta: { kind: "quickEntry", target: "measurement" }, + }; + } + + // ── 7 · scoreDrop ────────────────────────────────────────────────── + const score = snapshot.healthScore; + if ( + score !== null && + score.delta !== null && + score.delta <= -SCORE_DROP_MIN_POINTS + ) { + return { + variant: "scoreDrop", + values: { points: Math.round(Math.abs(score.delta)) }, + cta: { kind: "link", href: "/insights" }, + }; + } + + // ── 8 · briefing ─────────────────────────────────────────────────── + // Fresh briefing only (`ready` + not stale) with at least one key + // finding. Prefer a "watch"-tone finding; the headline rides verbatim + // as plain text — see the `values` doc. + if ( + snapshot.briefingState === "ready" && + !snapshot.briefingStale && + snapshot.briefing !== null && + snapshot.briefing.keyFindings.length > 0 + ) { + const findings = snapshot.briefing.keyFindings; + const picked = findings.find((f) => f.tone === "watch") ?? findings[0]; + return { + variant: "briefing", + values: { headline: picked.headline }, + cta: { kind: "link", href: "/insights" }, + }; + } + + // ── 9 · allQuiet ─────────────────────────────────────────────────── + return { variant: "allQuiet", values: {}, cta: null }; +} diff --git a/src/lib/insights/__tests__/prompt-soft-delete-filter.test.ts b/src/lib/insights/__tests__/prompt-soft-delete-filter.test.ts index 8e2778214..f37ced16d 100644 --- a/src/lib/insights/__tests__/prompt-soft-delete-filter.test.ts +++ b/src/lib/insights/__tests__/prompt-soft-delete-filter.test.ts @@ -40,9 +40,12 @@ describe("prompt reads exclude tombstoned rows", () => { }); it("the Coach snapshot filters its intake read", () => { + // v1.16.9 — the compliance timeline reads intake events through the + // medication relation (`intakeEvents`) so the ledger can be rebuilt + // per medication; the tombstone filter must ride on that nested read. const src = read("../../ai/coach/snapshot.ts"); const intakeRead = src.match( - /medicationIntakeEvent\.findMany\(\{[\s\S]{0,200}?where:\s*\{[^}]*\}/, + /intakeEvents:\s*\{[\s\S]{0,300}?where:\s*\{[^}]*\}/, ); expect(intakeRead).not.toBeNull(); expect(intakeRead![0]).toContain("deletedAt: null"); diff --git a/src/lib/jobs/coach-nudge-thresholds.ts b/src/lib/jobs/coach-nudge-thresholds.ts new file mode 100644 index 000000000..8cf107bb9 --- /dev/null +++ b/src/lib/jobs/coach-nudge-thresholds.ts @@ -0,0 +1,19 @@ +/** + * Coach-nudge trend thresholds shared with client-reachable code. + * + * Kept in a leaf module — free of any server-only import — because the + * dashboard hero's verdict resolver (`@/lib/dashboard/verdict`) reads + * these to stay numerically identical to the nudge cron, and that + * resolver runs inside a `"use client"` component. Importing them from + * `@/lib/jobs/coach-nudge` would drag the prisma/dispatcher server + * graph into the browser bundle (the same regression class the + * dose-window defaults hit — see + * `src/lib/medications/scheduling/dose-window-defaults.ts`). + * `@/lib/jobs/coach-nudge` re-exports both so server-side imports keep + * their path. + */ + +/** Weight trigger: minimum kg the weekly mean must drift AWAY from the range. */ +export const COACH_NUDGE_WEIGHT_DRIFT_KG = 0.5; +/** Sleep trigger: a night must undershoot the floor by this margin (h). */ +export const COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H = 0.5; diff --git a/src/lib/jobs/coach-nudge.ts b/src/lib/jobs/coach-nudge.ts index 5b4c4281a..d931e1733 100644 --- a/src/lib/jobs/coach-nudge.ts +++ b/src/lib/jobs/coach-nudge.ts @@ -77,6 +77,18 @@ import { getServerTranslator } from "@/lib/i18n/server-translator"; import type { Locale } from "@/lib/i18n/config"; import { defaultLocale, locales } from "@/lib/i18n/config"; import { getEvent } from "@/lib/logging/context"; +// Trend thresholds shared with the dashboard hero's verdict resolver +// live in a client-safe leaf module; re-exported below so server-side +// imports keep their path. +import { + COACH_NUDGE_WEIGHT_DRIFT_KG, + COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H, +} from "@/lib/jobs/coach-nudge-thresholds"; + +export { + COACH_NUDGE_WEIGHT_DRIFT_KG, + COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H, +} from "@/lib/jobs/coach-nudge-thresholds"; const MS_PER_DAY = 24 * 60 * 60 * 1000; @@ -109,14 +121,10 @@ export const COACH_NUDGE_SELF_CONTEXT_STALE_DAYS = 60; export const COACH_NUDGE_COACH_ACTIVE_DAYS = 14; /** Weight trigger: minimum readings per weekly window. */ export const COACH_NUDGE_WEIGHT_MIN_READINGS = 3; -/** Weight trigger: minimum kg the weekly mean must drift AWAY from the range. */ -export const COACH_NUDGE_WEIGHT_DRIFT_KG = 0.5; /** Sleep trigger: minimum recorded nights in the 7-day window. */ export const COACH_NUDGE_SLEEP_MIN_NIGHTS = 5; /** Sleep trigger: nights under the floor required to fire. */ export const COACH_NUDGE_SLEEP_DEFICIT_NIGHTS = 4; -/** Sleep trigger: a night must undershoot the floor by this margin (h). */ -export const COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H = 0.5; /** Gap trigger: distinct active days required in the prior 3 weeks. */ export const COACH_NUDGE_GAP_MIN_ACTIVE_DAYS = 10; /** Gap trigger: length of the silent window that fires (days). */ diff --git a/src/lib/medications/__tests__/intake-slot-dedup-converge.test.ts b/src/lib/medications/__tests__/intake-slot-dedup-converge.test.ts new file mode 100644 index 000000000..e9b0ba44b --- /dev/null +++ b/src/lib/medications/__tests__/intake-slot-dedup-converge.test.ts @@ -0,0 +1,261 @@ +/** + * v1.16.9 — ad-hoc→pending band convergence in the nightly dedup. + * + * The production damage this heals: the external-API and Telegram write + * paths historically bare-created `scheduledFor = takenAt`, leaving the + * worker-minted pending REMINDER row open. The legacy snap pass only + * reaches a ± half-window tolerance and the discovery query only matched + * 2-minute drift, so an 08:42 API take beside its pending 09:00 row + * never collapsed — the ledger showed an ad-hoc take PLUS a missed slot + * for one real dose. + * + * Covered here: + * 1. The 08:42 / 09:00 pair heals: the pending row tombstones, the + * take normalises onto the canonical anchor. + * 2. A take outside the legacy snap tolerance but inside the band's + * late tail still heals (the case the snap pass can never reach). + * 3. An ambiguous double-slot (two live rows on the anchor) is left + * alone by the convergence pass. + * 4. A WEB ad-hoc row (deliberate user shape) is never converged. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + medication: { findMany: vi.fn() }, + medicationIntakeEvent: { + findFirst: vi.fn(), + findMany: vi.fn(), + updateMany: vi.fn(), + update: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() })); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ + recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +})); + +import { dedupeUserIntakeSlots } from "../intake-slot-dedup"; +import { prisma } from "@/lib/db"; + +const TZ = "Europe/Berlin"; + +// Daily 09:00 Berlin med. 09:00 CEST on 2026-06-10 = 07:00:00Z. +const SLOT_0900 = new Date("2026-06-10T07:00:00.000Z"); +const TAKEN_0842 = new Date("2026-06-10T06:42:00.000Z"); +// 12:30 Berlin — outside the ±2h legacy snap tolerance of the 09:00 +// anchor, but inside the band's late tail (on-time end 10:00 + 180 min). +const TAKEN_1230 = new Date("2026-06-10T10:30:00.000Z"); + +const SCHEDULE = { + id: "s1", + windowStart: "09:00", + windowEnd: "09:00", + daysOfWeek: null, + timesOfDay: ["09:00"], + reminderGraceMinutes: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, +}; + +const MED = { + id: "med-1", + userId: "u1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00Z"), + schedules: [SCHEDULE], + scheduleRevisions: [], +}; + +function pendingRow(over: Record = {}) { + return { + id: "r-pending", + medicationId: "med-1", + scheduledFor: SLOT_0900, + takenAt: null, + skipped: false, + syncVersion: 0, + createdAt: new Date("2026-06-10T05:00:00Z"), + attributionSource: "AUTO", + source: "REMINDER", + ...over, + }; +} + +function adhocRow(at: Date, over: Record = {}) { + return { + id: "r-adhoc", + medicationId: "med-1", + scheduledFor: at, + takenAt: at, + skipped: false, + syncVersion: 1, + createdAt: at, + attributionSource: "AUTO", + source: "API", + ...over, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + timezone: TZ, + } as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue([MED] as never); + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue( + null as never, + ); + vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({ + count: 1, + } as never); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue({} as never); +}); + +describe("dedupeUserIntakeSlots — ad-hoc→pending convergence (v1.16.9)", () => { + it("heals the 08:42 API take / pending 09:00 REMINDER pair", async () => { + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + adhocRow(TAKEN_0842), + pendingRow(), + ] as never); + + const summary = await dedupeUserIntakeSlots("u1"); + + // The pending row tombstones; the take is the dose of record. + expect(prisma.medicationIntakeEvent.updateMany).toHaveBeenCalledTimes(1); + const tombstone = vi.mocked(prisma.medicationIntakeEvent.updateMany).mock + .calls[0][0] as { + where: { id: string }; + data: { deletedAt: Date }; + }; + expect(tombstone.where.id).toBe("r-pending"); + expect(tombstone.data.deletedAt).toBeInstanceOf(Date); + + // The take normalises onto the canonical 09:00 anchor. + expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1); + const normalise = vi.mocked(prisma.medicationIntakeEvent.update).mock + .calls[0][0] as { + where: { id: string }; + data: { scheduledFor: Date }; + }; + expect(normalise.where.id).toBe("r-adhoc"); + expect(normalise.data.scheduledFor.getTime()).toBe(SLOT_0900.getTime()); + + expect(summary.rowsSoftDeleted).toBe(1); + expect(summary.rowsNormalised).toBe(1); + expect(summary.slotsCollapsed).toBe(1); + expect(summary.daysRecomputed).toBeGreaterThanOrEqual(1); + }); + + it("heals an early take ahead of an explicit window that starts AT the dose time", async () => { + // Explicit 09:00–10:00 dose window: the on-time band starts AT the + // dose time, so an 08:42 take sits only inside the bounded early + // grace below `onTimeStart`. The convergence must accept it exactly + // like the write-path attribution does — otherwise the actioned + // ad-hoc row stays beside the unactioned 09:00 REMINDER row and the + // slot later auto-misses a dose the user took. + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + { + ...MED, + schedules: [ + { + ...SCHEDULE, + windowStart: "09:00", + windowEnd: "10:00", + doseWindows: [{ timeOfDay: "09:00", start: "09:00", end: "10:00" }], + }, + ], + }, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + adhocRow(TAKEN_0842), + pendingRow(), + ] as never); + + const summary = await dedupeUserIntakeSlots("u1"); + + const tombstone = vi.mocked(prisma.medicationIntakeEvent.updateMany).mock + .calls[0][0] as { where: { id: string } }; + expect(tombstone.where.id).toBe("r-pending"); + const normalise = vi.mocked(prisma.medicationIntakeEvent.update).mock + .calls[0][0] as { where: { id: string }; data: { scheduledFor: Date } }; + expect(normalise.where.id).toBe("r-adhoc"); + expect(normalise.data.scheduledFor.getTime()).toBe(SLOT_0900.getTime()); + expect(summary.slotsCollapsed).toBe(1); + }); + + it("heals a take outside the legacy snap tolerance but inside the band's late tail", async () => { + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + adhocRow(TAKEN_1230), + pendingRow(), + ] as never); + + const summary = await dedupeUserIntakeSlots("u1"); + + expect(summary.rowsSoftDeleted).toBe(1); + expect(summary.rowsNormalised).toBe(1); + const tombstone = vi.mocked(prisma.medicationIntakeEvent.updateMany).mock + .calls[0][0] as { where: { id: string } }; + expect(tombstone.where.id).toBe("r-pending"); + const normalise = vi.mocked(prisma.medicationIntakeEvent.update).mock + .calls[0][0] as { where: { id: string }; data: { scheduledFor: Date } }; + expect(normalise.where.id).toBe("r-adhoc"); + expect(normalise.data.scheduledFor.getTime()).toBe(SLOT_0900.getTime()); + }); + + it("leaves an ambiguous double-slot (two live rows on the anchor) alone", async () => { + // Two live rows already sit on the 09:00 anchor — converging the take + // would have to pick one blindly. The convergence pass declines; the + // take stays where it is. (The legacy exact-instant collapse of the + // two anchor rows is a separate, pre-existing pass.) + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + adhocRow(TAKEN_1230), + pendingRow(), + pendingRow({ id: "r-pending-2", source: "API" }), + ] as never); + + await dedupeUserIntakeSlots("u1"); + + // The ad-hoc take was never normalised onto the anchor. + const normaliseCalls = vi + .mocked(prisma.medicationIntakeEvent.update) + .mock.calls.map((c) => (c[0] as { where: { id: string } }).where.id); + expect(normaliseCalls).not.toContain("r-adhoc"); + }); + + it("never converges a WEB ad-hoc row (deliberate user shape)", async () => { + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + adhocRow(TAKEN_1230, { source: "WEB" }), + pendingRow(), + ] as never); + + const summary = await dedupeUserIntakeSlots("u1"); + + expect(summary.rowsSoftDeleted).toBe(0); + expect(summary.rowsNormalised).toBe(0); + expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled(); + }); + + it("never converges a USER_PIN released row", async () => { + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + adhocRow(TAKEN_1230, { attributionSource: "USER_PIN" }), + pendingRow(), + ] as never); + + const summary = await dedupeUserIntakeSlots("u1"); + + expect(summary.rowsSoftDeleted).toBe(0); + expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/medications/__tests__/window-status.test.ts b/src/lib/medications/__tests__/window-status.test.ts index 23dcb806f..ce6328f62 100644 --- a/src/lib/medications/__tests__/window-status.test.ts +++ b/src/lib/medications/__tests__/window-status.test.ts @@ -365,3 +365,95 @@ describe("reduceCurrentWindowStatus — display-due gate (v1.16.6)", () => { expect(res.status).toBeNull(); }); }); + +/** + * v1.16.9 — day-scale early-take downgrade. A weekly injectable taken two + * days before its slot day is already on board; on the slot day the + * in-window pill must carry taken-early context instead of prompting a + * full take (a "take now" there is a double-dose prompt). + */ +describe("reduceCurrentWindowStatus — day-scale taken-early context", () => { + // Weekly Wednesday 09:00 (2026-06-10 is a Wednesday). daysOfWeek "3" = + // Wednesday in the legacy CSV encoding. + const weeklyWedSchedule = { + windowStart: "09:00", + windowEnd: "09:00", + daysOfWeek: "3", + timesOfDay: ["09:00"], + }; + // Monday 2026-06-08, 08:00 Berlin (CEST) — two days before the slot day. + const MONDAY_TAKE = "2026-06-08T06:00:00.000Z"; + + it("flags takenEarly on the slot day after a Monday take (Mon-take/Wed-slot)", () => { + const res = reduceCurrentWindowStatus({ + ...BASE, + schedules: [weeklyWedSchedule], + nowBerlin: localClock(9, 15), + lastTakenAt: MONDAY_TAKE, + todayEventCount: 0, + }); + expect(res.status).toBe("in_window"); + expect(res.takenEarlyDaysAgo).toBe(2); + }); + + it("does not flag takenEarly when the last take was a full period ago", () => { + // Previous Wednesday — the prior cycle's regular dose. + const res = reduceCurrentWindowStatus({ + ...BASE, + schedules: [weeklyWedSchedule], + nowBerlin: localClock(9, 15), + lastTakenAt: "2026-06-03T07:05:00.000Z", + todayEventCount: 0, + }); + expect(res.status).toBe("in_window"); + expect(res.takenEarlyDaysAgo).toBeNull(); + }); + + it("does not flag a daily cadence (minute-scale periods stay untouched)", () => { + const res = reduceCurrentWindowStatus({ + ...BASE, + schedules: [ + { + windowStart: "09:00", + windowEnd: "09:00", + daysOfWeek: null, + timesOfDay: ["09:00"], + }, + ], + nowBerlin: localClock(9, 15), + lastTakenAt: "2026-06-09T07:05:00.000Z", // yesterday — normal daily take + todayEventCount: 0, + }); + expect(res.status).toBe("in_window"); + expect(res.takenEarlyDaysAgo).toBeNull(); + }); + + it("rides on the late tier too — an early take must not escalate to overdue", () => { + const res = reduceCurrentWindowStatus({ + ...BASE, + schedules: [weeklyWedSchedule], + nowBerlin: localClock(11, 30), // past the band + inside lateMinutes + lastTakenAt: MONDAY_TAKE, + todayEventCount: 0, + }); + expect(res.status).toBe("late"); + expect(res.takenEarlyDaysAgo).toBe(2); + }); + + it("evaluates the early-take day in the supplied timezone", () => { + // Pacific/Auckland (UTC+12): the same instant lands on different local + // days in Berlin vs Auckland near midnight. 2026-06-08T13:00:00Z is + // 2026-06-09 01:00 in Auckland (one day before the Wed slot day) — + // still inside the weekly period → takenEarly. + const res = reduceCurrentWindowStatus({ + ...BASE, + schedules: [weeklyWedSchedule], + nowBerlin: localClock(9, 15), // interpreted as Auckland wall clock + lastTakenAt: "2026-06-08T13:00:00.000Z", + todayEventCount: 0, + tz: "Pacific/Auckland", + }); + expect(res.status).toBe("in_window"); + expect(res.takenEarlyDaysAgo).toBe(1); + }); +}); diff --git a/src/lib/medications/intake-slot-dedup.ts b/src/lib/medications/intake-slot-dedup.ts index b5e06713d..1e475ad9f 100644 --- a/src/lib/medications/intake-slot-dedup.ts +++ b/src/lib/medications/intake-slot-dedup.ts @@ -49,6 +49,11 @@ import { annotate } from "@/lib/logging/context"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { resolveCanonicalSlotInstant } from "@/lib/medications/scheduling/resolve-slot-instant"; +import { + buildMedicationDayBands, + type AttributeIntakeMedication, +} from "@/lib/medications/scheduling/attribute-intake"; +import type { ScheduleRevisionLike } from "@/lib/medications/scheduling/schedule-eras"; import type { WorkerMedicationRow, WorkerScheduleRow, @@ -89,6 +94,8 @@ type DedupMedication = WorkerMedicationRow & { userId: string; timezone: string; schedules: WorkerScheduleRow[]; + /** v1.16.9 — archived eras so the band convergence honours old times. */ + scheduleRevisions: ScheduleRevisionLike[]; }; interface DedupIntakeRow { @@ -105,6 +112,13 @@ interface DedupIntakeRow { * ad-hoc). Such a row never joins a snap cluster. */ attributionSource: string; + /** + * v1.16.9 — write origin. The ad-hoc→pending band convergence only + * touches API / REMINDER rows (external ingest + the Telegram webhook, + * the two paths that historically bare-created `scheduledFor = + * takenAt`); a WEB or IMPORT ad-hoc row is a deliberate user shape. + */ + source: string; } /** @@ -197,6 +211,21 @@ export async function dedupeUserIntakeSlots( scheduleType: true, cyclicOnWeeks: true, cyclicOffWeeks: true, + // v1.16.9 — the band convergence honours the persisted per-dose + // windows (the same lever the write path + read ledger consume). + doseWindows: true, + }, + }, + // v1.16.9 — archived eras: an old ad-hoc row converges against the + // schedule that was live at its takenAt, not today's times. + scheduleRevisions: { + orderBy: { validFrom: "asc" }, + select: { + id: true, + validFrom: true, + validUntil: true, + payload: true, + supersededByRevisionId: true, }, }, }, @@ -235,12 +264,33 @@ export async function dedupeUserIntakeSlots( createdAt: true, // v1.16.0 — pin-awareness: a USER_PIN row never joins a cluster. attributionSource: true, + // v1.16.9 — write origin for the ad-hoc→pending convergence gate. + source: true, }, orderBy: { scheduledFor: "asc" }, })) as DedupIntakeRow[]; if (rows.length < 2) continue; + // v1.16.9 — heal pass FIRST: converge ad-hoc actioned API/REMINDER + // rows onto the pending row of the slot whose minted band contains + // the takenAt. The legacy snap pass below only reaches a ±half-window + // tolerance, so an 18-minute-early API ingest beside its pending + // 09:00 REMINDER row never clustered — the production damage this + // backfill exists to repair. The pass mutates `rows` in place + // (tombstoned pending rows removed, the winner's `scheduledFor` + // normalised) so the snap pass below operates on the healed state. + await convergeAdhocRowsOntoPendingSlots({ + med, + rows, + userTz, + lastIntakeAt, + summary, + daysToRecompute, + }); + + if (rows.length < 2) continue; + // Group rows by the canonical slot instant they snap to. Rows that // resolve to `null` are PRN / off-slot — keyed individually so they // never collapse together. @@ -402,6 +452,168 @@ export async function dedupeUserIntakeSlots( return summary; } +/** + * v1.16.9 — converge ad-hoc actioned rows onto their slot's pending row. + * + * The healed shape: an external-API or Telegram take historically + * bare-created `scheduledFor = takenAt`, leaving the worker-minted + * pending REMINDER row open. The pending row later auto-missed, so the + * ledger showed an ad-hoc take PLUS a missed slot for one real dose. + * The write paths converge since v1.16.9; this pass repairs the rows + * already written. + * + * Deliberately conservative — a pair converges ONLY when: + * - the actioned row is ad-hoc shaped (`scheduledFor === takenAt`, + * taken, not skipped), source API or REMINDER, and not USER_PIN; + * - exactly ONE minted band contains the takenAt (the bands are + * disjoint by construction, but a zero- or ambiguous-match take is + * left alone); + * - the band's anchor carries exactly ONE live row and that row is + * unactioned (no takenAt, not skipped — a cron auto-miss flag does + * not count as an action: the take proves the miss wrong). + * + * The actioned row is the dose of record: its `scheduledFor` normalises + * to the band anchor and the pending row is tombstoned (soft-deleted, + * syncVersion bumped) per the canonical pattern. `rows` is mutated in + * place so the caller's snap pass sees the healed state. + */ +async function convergeAdhocRowsOntoPendingSlots(input: { + med: DedupMedication; + rows: DedupIntakeRow[]; + userTz: string; + lastIntakeAt: Date | null; + summary: IntakeSlotDedupSummary; + daysToRecompute: Set; +}): Promise { + const { med, rows, userTz, lastIntakeAt, summary, daysToRecompute } = input; + + const isRolling = med.schedules.some((s) => s.rollingIntervalDays !== null); + + const adhocCandidates = rows.filter( + (r) => + r.takenAt !== null && + !r.skipped && + r.scheduledFor.getTime() === r.takenAt.getTime() && + r.attributionSource !== "USER_PIN" && + (r.source === "API" || r.source === "REMINDER"), + ); + if (adhocCandidates.length === 0) return; + + const medication: AttributeIntakeMedication = { + id: med.id, + startsOn: med.startsOn, + endsOn: med.endsOn, + oneShot: med.oneShot, + createdAt: med.createdAt, + schedules: med.schedules, + scheduleRevisions: med.scheduleRevisions ?? [], + }; + + for (const adhoc of adhocCandidates) { + try { + const takenAt = adhoc.takenAt as Date; + // Rolling cadences anchor their retrospective grid AT each intake; + // build the instants from the rows already in memory, EXCLUDING the + // candidate itself (its own ad-hoc anchor must not mint the band it + // is judged against). + const intakeInstants = isRolling + ? rows + .filter( + (r) => + r.id !== adhoc.id && + r.takenAt !== null && + !r.skipped && + r.takenAt.getTime() <= takenAt.getTime(), + ) + .map((r) => r.takenAt as Date) + .sort((a, b) => a.getTime() - b.getTime()) + : undefined; + + const { bands, hasExpectedSlots } = buildMedicationDayBands({ + medication, + userTz, + around: takenAt, + lastIntakeAt, + intakeInstants, + now: takenAt, + }); + if (!hasExpectedSlots) continue; + + const t = takenAt.getTime(); + // The lower bound honours the bounded early grace (`earlyStart`): + // an explicit window that starts AT the dose time ("09:00–10:00") + // must still converge an 08:42 take — the same reach the write-path + // attribution credits. Bands without the field keep the strict + // on-time start. + const matching = bands.filter( + (b) => + t >= (b.earlyStart ?? b.onTimeStart).getTime() && + t <= b.overdueEnd.getTime(), + ); + if (matching.length !== 1) continue; // zero or ambiguous — leave alone. + const anchor = matching[0].at; + if (anchor.getTime() === adhoc.scheduledFor.getTime()) continue; // same instant — the snap pass owns it. + + const anchorRows = rows.filter( + (r) => + r.id !== adhoc.id && r.scheduledFor.getTime() === anchor.getTime(), + ); + if (anchorRows.length !== 1) continue; // empty or double-slot — leave alone. + const pending = anchorRows[0]; + if (pending.takenAt !== null || pending.skipped) continue; // already served. + + // Tombstone the pending row; the take is the dose of record. + await prisma.medicationIntakeEvent.updateMany({ + where: { id: pending.id, deletedAt: null }, + data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, + }); + summary.rowsSoftDeleted += 1; + + const adhocDayInstant = adhoc.scheduledFor; + // Normalise the take onto the canonical anchor. Same P2002 guard as + // the snap pass: a tombstoned row may already occupy + // `(anchor, source)`; when it does the take keeps its own anchor — + // the pending row is already gone, so the slot no longer double-counts. + try { + await prisma.medicationIntakeEvent.update({ + where: { id: adhoc.id }, + data: { scheduledFor: anchor, syncVersion: { increment: 1 } }, + }); + summary.rowsNormalised += 1; + adhoc.scheduledFor = anchor; + } catch (normErr) { + if (!isP2002(normErr)) throw normErr; + annotate({ + meta: { + intake_slot_dedup_converge_collision: true, + intake_slot_dedup_medication: med.id, + intake_slot_dedup_slot: anchor.toISOString(), + }, + }); + } + + summary.slotsCollapsed += 1; + + // Both local days can differ for day-scale bands — recompute each. + daysToRecompute.add(`${med.id}|${anchor.toISOString()}`); + daysToRecompute.add(`${med.id}|${adhocDayInstant.toISOString()}`); + + // Mutate the in-memory rows so the snap pass sees the healed state. + const pendingIdx = rows.findIndex((r) => r.id === pending.id); + if (pendingIdx >= 0) rows.splice(pendingIdx, 1); + } catch (pairErr) { + annotate({ + meta: { + intake_slot_dedup_converge_failed: true, + intake_slot_dedup_medication: med.id, + intake_slot_dedup_error: + pairErr instanceof Error ? pairErr.message : String(pairErr), + }, + }); + } + } +} + /** * Discovery pass — runs at worker boot and on the daily cron tick (the * cron payload omits `userId`; the worker handler dispatches here). Finds @@ -444,6 +656,15 @@ export async function enqueueBootTimeIntakeSlotDedup(): Promise<{ // `(user_id, medication_id, scheduled_for)`. The window absorbs the // sub-minute iOS-vs-server drift; the per-user pass re-checks // precisely so a wider-than-needed window only costs idle jobs. + // + // v1.16.9 — second arm: the ad-hoc→pending heal pattern. An actioned + // ad-hoc row (`scheduled_for = taken_at`) beside an unactioned row on + // the same medication can sit up to the widest band reach apart + // (weekly on-time 1d + overdue 4d ≈ 5 days; 475200 s adds half a day + // of slack) — far outside the 2-minute drift arm, which is why the + // 18-minute API/REMINDER pairs were never enqueued. The per-user pass + // re-checks by exact band membership, so the wide arm only costs idle + // jobs. const users = await prisma.$queryRaw>` SELECT DISTINCT a."user_id" AS id FROM "medication_intake_events" a @@ -453,7 +674,18 @@ export async function enqueueBootTimeIntakeSlotDedup(): Promise<{ AND a."id" <> b."id" AND a."deleted_at" IS NULL AND b."deleted_at" IS NULL - AND abs(extract(epoch FROM (a."scheduled_for" - b."scheduled_for"))) <= 120 + AND ( + abs(extract(epoch FROM (a."scheduled_for" - b."scheduled_for"))) <= 120 + OR ( + a."taken_at" IS NOT NULL + AND a."scheduled_for" = a."taken_at" + AND a."skipped" = false + AND a."source" IN ('API', 'REMINDER') + AND b."taken_at" IS NULL + AND b."skipped" = false + AND abs(extract(epoch FROM (a."scheduled_for" - b."scheduled_for"))) <= 475200 + ) + ) `; if (users.length === 0) { diff --git a/src/lib/medications/scheduling/__tests__/attribution.test.ts b/src/lib/medications/scheduling/__tests__/attribution.test.ts index 9d2b40a0f..227f30e3f 100644 --- a/src/lib/medications/scheduling/__tests__/attribution.test.ts +++ b/src/lib/medications/scheduling/__tests__/attribution.test.ts @@ -96,8 +96,17 @@ describe("attributeIntakeToSlot — summer (CEST)", () => { expect(r?.status).toBe("on_time"); }); - it("a take at 17:30 is ad-hoc (past the 07:00 tail, before the 19:00 window)", () => { - expect(attributeIntakeToSlot(at(day, 17, 30), bands)).toBeNull(); + it("a take at 17:30 credits the 19:00 slot through the bounded early grace (v1.16.9)", () => { + // 30 min before the 18:00 on-time start — inside the 60-min early + // grace, so a slightly-early take credits its slot instead of + // orphaning ad-hoc while the slot later reads missed. + const r = attributeIntakeToSlot(at(day, 17, 30), bands); + expect(r?.band.timeOfDay).toBe("19:00"); + expect(r?.status).toBe("on_time"); + }); + + it("a take at 16:30 stays ad-hoc (beyond the 60-min early grace)", () => { + expect(attributeIntakeToSlot(at(day, 16, 30), bands)).toBeNull(); }); }); @@ -166,3 +175,73 @@ describe("attributeIntakeToSlot — configurable window widens on-time", () => { expect(r?.status).toBe("on_time"); }); }); + +/** + * v1.16.9 — bounded early grace ahead of the on-time band. + * + * A window configured to start AT the dose time ("09:00–10:00") refused + * an 08:42 take: it orphaned ad-hoc and the slot read missed. Takes up to + * 60 min before `onTimeStart` now credit the slot; the reach is capped at + * the previous slot's `overdueEnd` so a take inside the prior dose's late + * tail is never re-claimed. + */ +describe("attributeIntakeToSlot — bounded early grace (v1.16.9)", () => { + const day = new Date("2026-06-08T12:00:00Z"); + + function nineToTenWindow(): SlotWindowInput { + return { + at: localHmAsUtc(day, TZ, 9, 0), + timeOfDay: "09:00", + onTimeStart: localHmAsUtc(day, TZ, 9, 0), + onTimeEnd: localHmAsUtc(day, TZ, 10, 0), + lateGraceMs: 180 * MIN, + }; + } + + it("an 08:42 take credits the 09:00 slot whose window starts AT 09:00", () => { + const bands = buildSlotBands([nineToTenWindow()]); + const r = attributeIntakeToSlot(at(day, 8, 42), bands); + expect(r?.band.timeOfDay).toBe("09:00"); + expect(r?.status).toBe("on_time"); + }); + + it("a take more than 60 min before the window start stays ad-hoc", () => { + const bands = buildSlotBands([nineToTenWindow()]); + expect(attributeIntakeToSlot(at(day, 7, 55), bands)).toBeNull(); + }); + + it("the early grace never crosses into the previous slot's band", () => { + // 06:00 slot with a long late tail reaching to 09:00 (capped exactly + // at the 09:00 window start) + the 09:00–10:00 window. A take at + // 08:42 sits inside the 06:00 slot's tail — it must stay attributed + // LATE to the 06:00 slot, never early-claimed by the 09:00 slot. + const slots: SlotWindowInput[] = [ + { + at: localHmAsUtc(day, TZ, 6, 0), + timeOfDay: "06:00", + onTimeStart: localHmAsUtc(day, TZ, 5, 30), + onTimeEnd: localHmAsUtc(day, TZ, 6, 30), + lateGraceMs: 600 * MIN, // capped at the next window's start + }, + nineToTenWindow(), + ]; + const bands = buildSlotBands(slots); + // The cap binds: the 09:00 band's early reach starts where the 06:00 + // tail ends. + expect(bands[1].earlyStart.toISOString()).toBe( + bands[0].overdueEnd.toISOString(), + ); + const r = attributeIntakeToSlot(at(day, 8, 42), bands); + expect(r?.band.timeOfDay).toBe("06:00"); + expect(r?.status).toBe("late"); + }); + + it("isLastIntakeInBand suppression mirrors the grace (no take-now after an 08:42 take)", () => { + // Covered at the card level: see window-status — this pins the band + // shape the suppression relies on (earlyStart <= onTimeStart always). + const bands = buildSlotBands([nineToTenWindow()]); + expect(bands[0].earlyStart.getTime()).toBeLessThanOrEqual( + bands[0].onTimeStart.getTime(), + ); + }); +}); diff --git a/src/lib/medications/scheduling/__tests__/next-due.test.ts b/src/lib/medications/scheduling/__tests__/next-due.test.ts index 495fecd35..3a9dec442 100644 --- a/src/lib/medications/scheduling/__tests__/next-due.test.ts +++ b/src/lib/medications/scheduling/__tests__/next-due.test.ts @@ -11,12 +11,22 @@ */ import { describe, expect, it } from "vitest"; -import { computeDisplayDue, computeNextDueAt } from "../next-due"; +import { + computeDisplayDue, + computeNextDueAt, + toResolvedSlotMark, + type ResolvedSlotMark, +} from "../next-due"; import type { WorkerMedicationRow, WorkerScheduleRow, } from "../worker-helpers"; +/** A slot-anchored resolved mark (the write paths' canonical shape). */ +function mark(at: Date): ResolvedSlotMark { + return { at, slotAnchored: true }; +} + function d(iso: string): Date { return new Date(iso); } @@ -155,8 +165,8 @@ describe("computeNextDueAt — skip resolved slots (twice-daily)", () => { userTz: BERLIN, lastIntakeAt: d("2026-06-10T15:30:00Z"), resolvedSlots: [ - d("2026-06-10T05:00:00Z"), // today 07:00 Berlin - d("2026-06-10T17:00:00Z"), // today 19:00 Berlin + mark(d("2026-06-10T05:00:00Z")), // today 07:00 Berlin + mark(d("2026-06-10T17:00:00Z")), // today 19:00 Berlin ], }); expect(next).not.toBeNull(); @@ -173,7 +183,7 @@ describe("computeNextDueAt — skip resolved slots (twice-daily)", () => { now, userTz: BERLIN, lastIntakeAt: d("2026-06-10T07:13:00Z"), - resolvedSlots: [d("2026-06-10T05:00:00Z")], // only today 07:00 + resolvedSlots: [mark(d("2026-06-10T05:00:00Z"))], // only today 07:00 }); expect(next).not.toBeNull(); expect(next!.toISOString()).toBe("2026-06-10T17:00:00.000Z"); // today 19:00 @@ -315,7 +325,7 @@ describe("computeDisplayDue — open overdue slot", () => { now, userTz: BERLIN, lastIntakeAt: null, - resolvedSlots: [d("2026-06-10T19:00:00Z")], + resolvedSlots: [mark(d("2026-06-10T19:00:00Z"))], }); expect(due).not.toBeNull(); expect(due!.overdue).toBe(false); @@ -336,3 +346,137 @@ describe("computeDisplayDue — open overdue slot", () => { expect(due!.overdue).toBe(false); }); }); + +/** + * v1.16.9 — an AD-HOC row (`scheduledFor === takenAt`) must never + * ±6h-resolve a DIFFERENT slot. The proven failure: twice-daily 08:00 / + * 20:00 Berlin, an ad-hoc take at 14:30 sat within 6h of tonight's 20:00 + * anchor and resolved it — the genuinely-due dose disappeared from the + * card while the ledger still counted the slot missed. + */ +describe("resolved-slot marks — ad-hoc rows only match their own anchor", () => { + const medication = makeMedication(); + // 08:00 / 20:00 Berlin (CEST): 06:00Z / 18:00Z. + function schedule(): WorkerScheduleRow { + return { + id: "sched-2x", + windowStart: "08:00", + windowEnd: "20:00", + daysOfWeek: null, + timesOfDay: ["08:00", "20:00"], + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }; + } + const ADHOC_1430 = d("2026-06-10T12:30:00Z"); // 14:30 Berlin + + it("a 14:30 ad-hoc take leaves tonight's 20:00 slot due", () => { + const now = d("2026-06-10T15:00:00Z"); // 17:00 Berlin + const next = computeNextDueAt({ + medication, + schedules: [schedule()], + now, + userTz: BERLIN, + lastIntakeAt: ADHOC_1430, + resolvedSlots: [ + // The morning slot was genuinely taken (slot-anchored row)… + mark(d("2026-06-10T06:00:00Z")), + // …and the 14:30 take recorded ad-hoc (`scheduledFor === takenAt`). + toResolvedSlotMark({ scheduledFor: ADHOC_1430, takenAt: ADHOC_1430 }), + ], + }); + expect(next).not.toBeNull(); + // Tonight's 20:00 Berlin (18:00Z) stays due — the ad-hoc row must not + // resolve it across the radius. + expect(next!.toISOString()).toBe("2026-06-10T18:00:00.000Z"); + }); + + it("a 14:30 ad-hoc take does not suppress the 20:00 overdue pill either", () => { + const now = d("2026-06-10T19:00:00Z"); // 21:00 Berlin — inside the late tail + const due = computeDisplayDue({ + medication, + schedules: [schedule()], + now, + userTz: BERLIN, + lastIntakeAt: ADHOC_1430, + resolvedSlots: [ + mark(d("2026-06-10T06:00:00Z")), + toResolvedSlotMark({ scheduledFor: ADHOC_1430, takenAt: ADHOC_1430 }), + ], + }); + expect(due).not.toBeNull(); + expect(due!.overdue).toBe(true); + expect(due!.at.toISOString()).toBe("2026-06-10T18:00:00.000Z"); + }); + + it("a slot-anchored attributed row still resolves its slot across drift", () => { + const now = d("2026-06-10T19:00:00Z"); // 21:00 Berlin + const due = computeDisplayDue({ + medication, + schedules: [schedule()], + now, + userTz: BERLIN, + lastIntakeAt: d("2026-06-10T18:05:00Z"), + resolvedSlots: [ + mark(d("2026-06-10T06:00:00Z")), + // Attributed take: scheduledFor on the anchor, takenAt 20:05. + toResolvedSlotMark({ + scheduledFor: d("2026-06-10T18:00:00Z"), + takenAt: d("2026-06-10T18:05:00Z"), + }), + ], + }); + expect(due).not.toBeNull(); + expect(due!.overdue).toBe(false); + // Both today's slots resolved → tomorrow's 08:00 Berlin (06:00Z). + expect(due!.at.toISOString()).toBe("2026-06-11T06:00:00.000Z"); + }); + + it("an ad-hoc row sitting exactly on the anchor still resolves that slot", () => { + const now = d("2026-06-10T19:00:00Z"); + const due = computeDisplayDue({ + medication, + schedules: [schedule()], + now, + userTz: BERLIN, + lastIntakeAt: d("2026-06-10T18:00:00Z"), + resolvedSlots: [ + mark(d("2026-06-10T06:00:00Z")), + // Take recorded at the exact anchor instant — ad-hoc shaped but ON + // the slot; the near-exact epsilon keeps it resolving. + toResolvedSlotMark({ + scheduledFor: d("2026-06-10T18:00:00Z"), + takenAt: d("2026-06-10T18:00:00Z"), + }), + ], + }); + expect(due).not.toBeNull(); + expect(due!.overdue).toBe(false); + expect(due!.at.toISOString()).toBe("2026-06-11T06:00:00.000Z"); + }); + + it("behaves identically in a non-European zone (Pacific/Auckland)", () => { + const AUCKLAND = "Pacific/Auckland"; // UTC+12 in June (NZST) + // 08:00 / 20:00 Auckland on 2026-06-10 = 2026-06-09T20:00Z / 2026-06-10T08:00Z. + const adhoc = d("2026-06-10T02:30:00Z"); // 14:30 Auckland + const now = d("2026-06-10T09:00:00Z"); // 21:00 Auckland + const due = computeDisplayDue({ + medication, + schedules: [schedule()], + now, + userTz: AUCKLAND, + lastIntakeAt: adhoc, + resolvedSlots: [ + mark(d("2026-06-09T20:00:00Z")), + toResolvedSlotMark({ scheduledFor: adhoc, takenAt: adhoc }), + ], + }); + expect(due).not.toBeNull(); + expect(due!.overdue).toBe(true); + expect(due!.at.toISOString()).toBe("2026-06-10T08:00:00.000Z"); + }); +}); diff --git a/src/lib/medications/scheduling/attribution.ts b/src/lib/medications/scheduling/attribution.ts index 7d8b705bb..3c69488c9 100644 --- a/src/lib/medications/scheduling/attribution.ts +++ b/src/lib/medications/scheduling/attribution.ts @@ -25,6 +25,8 @@ * this module timezone-agnostic and trivially testable. */ +import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults"; + /** Per-slot window the caller supplies (bounds already minted as instants). */ export interface SlotWindowInput { /** Canonical anchor instant for the slot (labelling / next-due / pairing). */ @@ -39,10 +41,29 @@ export interface SlotWindowInput { lateGraceMs: number; } +/** + * v1.16.9 — bounded early grace ahead of the on-time band. A user who + * configures a window to start AT the dose time ("09:00–10:00") would + * otherwise have an 08:42 take refused and orphaned ad-hoc while the + * slot read missed. A take up to this far before `onTimeStart` still + * credits the slot (as on-time); the reach is capped at the PREVIOUS + * slot's `overdueEnd` so it can never claim a take that belongs to the + * prior dose's late tail. Sourced from the shared dose-window-defaults + * leaf (client-safe) so the card pill suppression reads the same width. + */ +export const EARLY_GRACE_MS = + DOSE_WINDOW_DEFAULTS.earlyGraceMinutes * 60 * 1000; + /** A slot window with its (capped) late-tail cutoff resolved. */ export interface SlotBand extends SlotWindowInput { /** Cutoff past which a take no longer pairs to this slot (capped). */ overdueEnd: Date; + /** + * Earliest instant a take still credits this slot (the bounded early + * grace before `onTimeStart`, capped at the previous slot's + * `overdueEnd`). + */ + earlyStart: Date; } export type AttributionStatus = "on_time" | "late"; @@ -59,7 +80,7 @@ export interface AttributionResult { */ export function buildSlotBands(slots: SlotWindowInput[]): SlotBand[] { const sorted = [...slots].sort((a, b) => a.at.getTime() - b.at.getTime()); - return sorted.map((slot, i) => { + const withTails = sorted.map((slot, i) => { let overdueEnd = slot.onTimeEnd.getTime() + slot.lateGraceMs; const next = sorted[i + 1]; if (next) { @@ -71,6 +92,19 @@ export function buildSlotBands(slots: SlotWindowInput[]): SlotBand[] { overdueEnd = Math.max(overdueEnd, slot.onTimeEnd.getTime()); return { ...slot, overdueEnd: new Date(overdueEnd) }; }); + // Second pass — the early reach is bounded by the PREVIOUS slot's + // resolved tail, so it never claims a take inside the prior dose's + // band. The previous `overdueEnd` is already capped at this slot's + // `onTimeStart`, so `earlyStart <= onTimeStart` holds by construction. + return withTails.map((band, i) => { + let earlyStart = band.onTimeStart.getTime() - EARLY_GRACE_MS; + const prev = withTails[i - 1]; + if (prev) { + earlyStart = Math.max(earlyStart, prev.overdueEnd.getTime()); + } + earlyStart = Math.min(earlyStart, band.onTimeStart.getTime()); + return { ...band, earlyStart: new Date(earlyStart) }; + }); } /** @@ -90,7 +124,10 @@ export function attributeIntakeToSlot( null; for (const band of bands) { - const onStart = band.onTimeStart.getTime(); + // v1.16.9 — the bounded early grace extends the on-time capture below + // `onTimeStart` (never into the previous slot's band — `buildSlotBands` + // caps it). Hand-built bands without the field keep the strict start. + const onStart = (band.earlyStart ?? band.onTimeStart).getTime(); const onEnd = band.onTimeEnd.getTime(); const overdueEnd = band.overdueEnd.getTime(); diff --git a/src/lib/medications/scheduling/dose-window-defaults.ts b/src/lib/medications/scheduling/dose-window-defaults.ts index 3831bf840..668501f68 100644 --- a/src/lib/medications/scheduling/dose-window-defaults.ts +++ b/src/lib/medications/scheduling/dose-window-defaults.ts @@ -20,6 +20,15 @@ export const DOSE_WINDOW_DEFAULTS = { /** Daily / intraday on-time half-width around the target instant (min). */ dailyOnTimeMinutes: 60, + /** + * Bounded early grace ahead of a dose's on-time window (min). A take up + * to this far before the window start still credits the slot (the + * attribution caps the reach at the previous slot's overdue end). + * Single source for the write-path attribution AND the card pill's + * last-intake suppression, so the two can never disagree on how early + * a take may land. + */ + earlyGraceMinutes: 60, /** * Daily / intraday overdue tail past the on-time window (min). A dose * taken inside `[onTime, onTime + overdue]` still counts; beyond it the diff --git a/src/lib/medications/scheduling/next-due.ts b/src/lib/medications/scheduling/next-due.ts index 4f646770a..21cb71e94 100644 --- a/src/lib/medications/scheduling/next-due.ts +++ b/src/lib/medications/scheduling/next-due.ts @@ -29,6 +29,63 @@ import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-d */ const RESOLVE_RADIUS_MS = 6 * 60 * 60 * 1000; +/** + * v1.16.9 — exact-match slop for an AD-HOC row. An ad-hoc take anchors + * `scheduledFor = takenAt` on its own instant, so it can only resolve a + * slot it actually sits on (sub-minute drift absorbed); letting it use + * the ±6h radius hid genuinely-due slots — a 14:30 ad-hoc take resolved + * tonight's 20:00 dose while the ledger still counted that slot missed. + */ +const ADHOC_RESOLVE_EPSILON_MS = 60 * 1000; + +/** + * A row that resolves a slot (taken / deliberately skipped / + * cron-auto-missed), with its anchoring shape preserved. + * + * `slotAnchored: false` marks the ad-hoc shape (`scheduledFor === + * takenAt`): such a row resolves a slot only on a near-exact anchor + * match, never across the ±6h radius. Slot-anchored rows (the write + * paths snap their `scheduledFor` to the canonical slot instant) keep + * the defensive radius. + */ +export interface ResolvedSlotMark { + at: Date; + slotAnchored: boolean; +} + +/** + * Map a resolved intake row to its `ResolvedSlotMark`. The ad-hoc shape + * is detectable only for taken rows (`scheduledFor === takenAt` to the + * millisecond — the documented standalone-insert contract); skips and + * auto-misses anchor on their slot by construction. + */ +export function toResolvedSlotMark(row: { + scheduledFor: Date; + takenAt: Date | null; +}): ResolvedSlotMark { + return { + at: row.scheduledFor, + slotAnchored: + row.takenAt === null || + row.takenAt.getTime() !== row.scheduledFor.getTime(), + }; +} + +function buildIsResolved( + resolved: ResolvedSlotMark[], +): (slotAt: Date) => boolean { + return (slotAt: Date): boolean => { + const slot = slotAt.getTime(); + for (const r of resolved) { + const radius = r.slotAnchored + ? RESOLVE_RADIUS_MS + : ADHOC_RESOLVE_EPSILON_MS; + if (Math.abs(r.at.getTime() - slot) <= radius) return true; + } + return false; + }; +} + /** * v1.16.4 — how far back the open-overdue search mints bands. The widest * possible band reach is a weekly slot's on-time half-width plus its @@ -57,21 +114,15 @@ export function computeNextDueAt(input: { * these so a twice-daily med whose remaining slots today are all logged * advances to the next genuinely-open slot (tomorrow's first dose) instead * of re-surfacing a resolved present/past slot. Omit for the legacy - * purely-time-anchored next-due. + * purely-time-anchored next-due. v1.16.9 — each mark carries its + * anchoring shape; ad-hoc rows only resolve on a near-exact match. */ - resolvedSlots?: Date[]; + resolvedSlots?: ResolvedSlotMark[]; }): Date | null { const { medication, schedules, now, userTz, lastIntakeAt } = input; if (schedules.length === 0) return null; - const resolved = input.resolvedSlots ?? []; - const isResolved = (slotAt: Date): boolean => { - const slot = slotAt.getTime(); - for (const r of resolved) { - if (Math.abs(r.getTime() - slot) <= RESOLVE_RADIUS_MS) return true; - } - return false; - }; + const isResolved = buildIsResolved(input.resolvedSlots ?? []); const ctx = buildRecurrenceContext({ medication, userTz, lastIntakeAt }); let earliest: Date | null = null; @@ -113,7 +164,7 @@ export interface ComputeDisplayDueInput { now: Date; userTz: string; lastIntakeAt: Date | null; - resolvedSlots?: Date[]; + resolvedSlots?: ResolvedSlotMark[]; /** * Floor of the CURRENT schedule era (the newest revision's `validUntil`), * when the medication has archived revisions. The overdue search mints @@ -148,14 +199,7 @@ function findOpenOverdueSlot(input: ComputeDisplayDueInput): Date | null { const { medication, schedules, now, userTz, lastIntakeAt } = input; if (schedules.length === 0) return null; - const resolved = input.resolvedSlots ?? []; - const isResolved = (slotAt: Date): boolean => { - const slot = slotAt.getTime(); - for (const r of resolved) { - if (Math.abs(r.getTime() - slot) <= RESOLVE_RADIUS_MS) return true; - } - return false; - }; + const isResolved = buildIsResolved(input.resolvedSlots ?? []); let floor = new Date(now.getTime() - OVERDUE_LOOKBACK_MS); if (input.eraStart && input.eraStart.getTime() > floor.getTime()) { diff --git a/src/lib/medications/scheduling/recurrence.ts b/src/lib/medications/scheduling/recurrence.ts index 527026b53..2fba3b485 100644 --- a/src/lib/medications/scheduling/recurrence.ts +++ b/src/lib/medications/scheduling/recurrence.ts @@ -117,6 +117,18 @@ export interface RecurrenceContext { * Latest `MedicationIntakeEvent.takenAt` for this medication. * Used only by rolling schedules. Null when the user has never * logged an intake (rolling then anchors on `startsOn ?? createdAt`). + * + * Skip semantics (documented decision, v1.16.9): a deliberate SKIP + * does NOT advance the rolling anchor. A skipped row carries + * `takenAt: null`, so every `lastIntakeAt` feeder query + * (`takenAt: { not: null }`) excludes it by construction, and the + * next due stays `previous take + N` — the skipped dose keeps + * surfacing as due until a real take re-anchors the grid. Advancing + * the anchor from the skipped instant instead would silently shift + * the whole future grid off the user's established rhythm; if that + * trade-off is ever revisited, every `lastIntakeAt` feeder (the + * projector, the reminder worker, the list route, the write-path + * resolvers, the dedup pass) must change together. */ lastIntakeAt: Date | null; } diff --git a/src/lib/medications/scheduling/slot-upsert.ts b/src/lib/medications/scheduling/slot-upsert.ts index a0df2d2c8..f50e55ac0 100644 --- a/src/lib/medications/scheduling/slot-upsert.ts +++ b/src/lib/medications/scheduling/slot-upsert.ts @@ -54,8 +54,13 @@ export interface ApplyCanonicalSlotWriteInput { isExplicitTaken: boolean; isExplicitSkip: boolean; idempotencyKey: string | null; - /** Source to stamp on a freshly-created slot row. */ - createSource: "WEB" | "API"; + /** + * Source to stamp on a freshly-created slot row. `REMINDER` is the + * Telegram-webhook path — its take / skip confirmations converge onto + * the worker-minted pending row like every other write, and a fresh + * create keeps the historical reminder provenance. + */ + createSource: "WEB" | "API" | "REMINDER"; /** * v1.8.5 — resolved + server-validated injection site to persist on a * taken write. `null` = no site (the column stays / is set NULL). Only diff --git a/src/lib/medications/window-status.ts b/src/lib/medications/window-status.ts index d97e2f2f8..0c8ad5f18 100644 --- a/src/lib/medications/window-status.ts +++ b/src/lib/medications/window-status.ts @@ -87,6 +87,18 @@ export interface CurrentWindowStatus { schedule: Schedule | null; /** Non-null exactly when `status` is non-null. */ window: MatchedDoseWindow | null; + /** + * v1.16.9 — non-null when the matched schedule is a DAY-SCALE cadence + * (weekly / N-weekly injectable) and a recent actioned take exists + * inside the current cadence period but on an earlier day. The dose is + * already on board: the card must render last-dose context instead of + * the "take now" / overdue prompt — a full take prompt on the slot day + * is a double-dose prompt. The value is the whole local days since + * that take, so the pill copy stays factual (the take may have been an + * early shot OR the previous slot served late — the day distance does + * not distinguish). Always null when `status` is null. + */ + takenEarlyDaysAgo: number | null; } /** @@ -303,11 +315,28 @@ function getScheduleStatus( return best; } +/** + * v1.16.9 — the attribution module's bounded early grace: a take + * slightly before the on-time band still credits the slot, so the pill + * suppression must accept it too (an 08:42 take for a 09:00-window slot + * must not leave "take now" burning at 09:05). Read from the shared + * dose-window-defaults leaf — the same constant the write-path + * attribution derives `EARLY_GRACE_MS` from — so the two surfaces can + * never disagree. + */ +const EARLY_GRACE_MINUTES = DOSE_WINDOW_DEFAULTS.earlyGraceMinutes; + function isLastIntakeInBand( lastTakenAt: string | null, band: DoseWindowBounds, nowLocal: Date, tz: string, + /** + * Lower bound (minutes-of-day) the early grace may not cross — the end + * of the schedule's previous dose band, so a take belonging to the + * prior dose never suppresses this one. Defaults to 0 (no earlier band). + */ + earlyFloorMins = 0, ): boolean { if (!lastTakenAt) return false; @@ -330,7 +359,81 @@ function isLastIntakeInBand( ? intakeMins + 24 * 60 : intakeMins; - return adjustedIntake >= band.startMins && adjustedIntake <= band.endMins; + const startWithGrace = Math.max( + band.startMins - EARLY_GRACE_MINUTES, + earlyFloorMins, + 0, + ); + return adjustedIntake >= startWithGrace && adjustedIntake <= band.endMins; +} + +/** + * v1.16.9 — day-scale cadence detection for the taken-early downgrade. + * + * A schedule whose dose days sit ≥ 2 days apart (a single weekday, a + * sparse Mo/Th pattern, an N-weekly interval) is day-scale: one dose + * covers a multi-day period, so a take earlier in the period must inform + * the slot-day pill. Daily / near-daily patterns (minute-scale) return + * `dayScale: false` — their period is a day and the same-day band checks + * already cover them. `periodDays` is the smallest gap between + * consecutive dose days (the span one dose covers). + */ +function dayScaleCadence(daysOfWeek: string | null): { + dayScale: boolean; + periodDays: number; +} { + const recurrence = parseScheduleRecurrence(daysOfWeek); + const days = [...new Set(recurrence.daysOfWeek)].sort((a, b) => a - b); + if (days.length === 0) return { dayScale: false, periodDays: 0 }; + const intervalWeeks = Math.max(1, recurrence.intervalWeeks ?? 1); + if (days.length === 1) { + return { dayScale: true, periodDays: 7 * intervalWeeks }; + } + let minGap = Infinity; + for (let i = 1; i < days.length; i++) { + minGap = Math.min(minGap, days[i] - days[i - 1]); + } + // Wrap from the week's last dose day to the next cycle's first. + const wrapGap = days[0] + 7 * intervalWeeks - days[days.length - 1]; + minGap = Math.min(minGap, wrapGap); + return { dayScale: minGap >= 2, periodDays: minGap }; +} + +/** + * Whole local days since the last actioned take when it lies on an + * EARLIER local day inside the current cadence period — the shape the + * taken-early downgrade fires on — else `null`. A same-day take is the + * in-band suppression's job; a take a full period (or more) ago is the + * previous cycle's dose. The day count rides into the pill copy, which + * stays neutral ("last dose {n} days ago"): the period may have opened + * with an early shot OR the previous slot served late, and the distance + * alone cannot tell the two apart. + */ +function earlyTakeDaysAgo( + lastTakenAt: string | null, + nowLocal: Date, + tz: string, + periodDays: number, +): number | null { + if (!lastTakenAt || periodDays < 2) return null; + const intake = toZonedDate(new Date(lastTakenAt), tz); + const dayMs = 24 * 60 * 60 * 1000; + const intakeDay = Math.floor( + new Date( + intake.getFullYear(), + intake.getMonth(), + intake.getDate(), + ).getTime() / dayMs, + ); + const nowDay = Math.floor( + new Date( + nowLocal.getFullYear(), + nowLocal.getMonth(), + nowLocal.getDate(), + ).getTime() / dayMs, + ); + const diff = nowDay - intakeDay; + return diff >= 1 && diff < periodDays ? diff : null; } /** @@ -388,32 +491,39 @@ function countPassedDoses( * the default are the legacy Berlin contract — callers that serve other * timezones pass their own. */ -export function reduceCurrentWindowStatus( - options: { - schedules: Schedule[]; - nowBerlin: Date; - lateMinutes: number; - missedMinutes: number; - active: boolean; - lastTakenAt: string | null; - todayEventCount: number; - /** IANA timezone matching `nowBerlin`'s conversion; defaults to Berlin. */ - tz?: string; - /** - * v1.16.6 — the server display-due gate. `undefined` keeps the legacy - * purely band-derived behaviour (callers without the list payload); - * `null` means the server found NO upcoming slot (paused / ended / - * one-shot past) so no pill renders at all. With a gate present: - * - `overdue: false` (next unresolved slot in the future) suppresses - * late / very_late outright and allows in_window only when the due - * instant falls on the current local day — a rolling cadence whose - * next dose is tomorrow stays calm today; - * - `overdue: true` keeps the band-derived escalation (the slot is - * genuinely in its catch-up tail). - */ - nextDue?: NextDueGate | null; - }, -): CurrentWindowStatus { +export function reduceCurrentWindowStatus< + Schedule extends ScheduleWindowInput, +>(options: { + schedules: Schedule[]; + nowBerlin: Date; + lateMinutes: number; + missedMinutes: number; + active: boolean; + lastTakenAt: string | null; + /** + * v1.16.9 — number of ACTIONED intake events today (taken or + * explicitly skipped). The server list feeder counts only actioned + * rows: the dashboard projector mints a pending row for every slot of + * the day, so an all-rows count covered every passed dose after any + * dashboard visit and the overdue pill went dark nondeterministically. + */ + todayEventCount: number; + /** IANA timezone matching `nowBerlin`'s conversion; defaults to Berlin. */ + tz?: string; + /** + * v1.16.6 — the server display-due gate. `undefined` keeps the legacy + * purely band-derived behaviour (callers without the list payload); + * `null` means the server found NO upcoming slot (paused / ended / + * one-shot past) so no pill renders at all. With a gate present: + * - `overdue: false` (next unresolved slot in the future) suppresses + * late / very_late outright and allows in_window only when the due + * instant falls on the current local day — a rolling cadence whose + * next dose is tomorrow stays calm today; + * - `overdue: true` keeps the band-derived escalation (the slot is + * genuinely in its catch-up tail). + */ + nextDue?: NextDueGate | null; +}): CurrentWindowStatus { const { schedules, nowBerlin, @@ -430,6 +540,7 @@ export function reduceCurrentWindowStatus( status: null, schedule: null, window: null, + takenEarlyDaysAgo: null, }; if (!active) return none; if (nextDue === null) return none; @@ -465,14 +576,36 @@ export function reduceCurrentWindowStatus( // intake events for the day. if (hit.status !== "in_window" && !hasUncoveredOverdue) return best; // Don't show in_window if last intake is already within this band - // today. + // today (including the bounded early grace, floored at the end of + // the schedule's previous dose band so a prior dose's take never + // suppresses this one). + const earlyFloorMins = resolveDoseWindows(s).reduce( + (floor, b) => + b.endMins <= hit.band.startMins && b.endMins > floor + ? b.endMins + : floor, + 0, + ); if ( hit.status === "in_window" && - isLastIntakeInBand(lastTakenAt, hit.band, nowBerlin, tz) + isLastIntakeInBand(lastTakenAt, hit.band, nowBerlin, tz, earlyFloorMins) ) { return best; } - if (!best.status || STATUS_PRIORITY[hit.status] > STATUS_PRIORITY[best.status]) { + if ( + !best.status || + STATUS_PRIORITY[hit.status] > STATUS_PRIORITY[best.status] + ) { + // v1.16.9 — day-scale early-take downgrade: a weekly injectable + // taken days before its slot day is already on board, so the pill + // must carry last-dose context instead of prompting a full take + // (a "Jetzt einnehmen" / overdue prompt here is a double-dose + // prompt). The server next-due stays canonical — only the pill's + // framing changes; the day count rides along for the copy. + const cadence = dayScaleCadence(s.daysOfWeek); + const takenEarlyDaysAgo = cadence.dayScale + ? earlyTakeDaysAgo(lastTakenAt, nowBerlin, tz, cadence.periodDays) + : null; return { status: hit.status, schedule: s, @@ -481,6 +614,7 @@ export function reduceCurrentWindowStatus( start: hit.band.startHm, end: hit.band.endHm, }, + takenEarlyDaysAgo, }; } return best; diff --git a/src/lib/openapi/routes/insights.ts b/src/lib/openapi/routes/insights.ts index 83d68356d..edc57407a 100644 --- a/src/lib/openapi/routes/insights.ts +++ b/src/lib/openapi/routes/insights.ts @@ -606,6 +606,35 @@ const dashboardSnapshotResponse = z glucoseByContext: dataSummaryRecord, }) .nullable(), + // Dashboard hero — today's medication block (fast phase, always + // present). Projection-backed tally + earliest next-due across + // active medications. + medsToday: z + .object({ + activeCount: z.number().int(), + scheduledToday: z.number().int(), + takenToday: z.number().int(), + skippedToday: z.number().int(), + nextDueAt: z.string().nullable(), + nextDueOverdue: z.boolean(), + nextDueMedicationName: 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.", + ), + // Dashboard hero — health score (warm phase, nullable on a + // rollup-coverage miss). Score + band + delta only; the per-pillar + // component breakdown stays on the analytics route. + healthScore: z + .object({ + score: z.number().int(), + band: z.enum(["green", "yellow", "red"]), + delta: z.number().nullable(), + }) + .nullable() + .describe( + "Personal health score summary (0..100 score, traffic-light band, week-over-week delta). Null on a rollup-coverage miss (it rides the thick phase alongside `extras`) and when no pillar is computable. Component breakdown is deliberately not serialised here.", + ), briefing: z.record(z.string(), z.unknown()).nullable(), briefingState: z.enum(["ready", "preparing", "disabled", "no-provider"]), briefingUpdatedAt: z.string().nullable(), diff --git a/src/lib/openapi/routes/medications.ts b/src/lib/openapi/routes/medications.ts index fbbde4171..2bec317a1 100644 --- a/src/lib/openapi/routes/medications.ts +++ b/src/lib/openapi/routes/medications.ts @@ -217,7 +217,7 @@ const medicationListEntry = medicationResource .int() .nonnegative() .describe( - "Number of intake events scheduled for today (user-local day window).", + "Number of ACTIONED intake events for today (user-local day window): rows with a recorded `takenAt` or an explicit skip. Pending projector-minted rows do not count — the card overdue-pill suppression compares this against the passed-dose count, and a pending mint must not read as covered.", ), }) .meta({ @@ -619,7 +619,7 @@ export const medicationPaths: NonNullable = { tags: ["Medications"], summary: "List medications for the calling user", description: - "Returns every medication owned by the caller (active + paused), ordered by `createdAt DESC`. Each row carries its nested `schedules`, the joined clinical `category`, the latest non-skipped `lastTakenAt`, and the count of intake events scheduled for today (`todayEventCount`). The response is cached server-side for 60 s per user; writes flush the cache.", + "Returns every medication owned by the caller (active + paused), ordered by `createdAt DESC`. Each row carries its nested `schedules`, the joined clinical `category`, the latest non-skipped `lastTakenAt`, and the count of today's actioned intake events (`todayEventCount`). The response is cached server-side for 60 s per user; writes flush the cache.", responses: { "200": { description: "Medication list.", diff --git a/src/lib/validations/__tests__/medication-intake-event.test.ts b/src/lib/validations/__tests__/medication-intake-event.test.ts index 402fe7110..25930c729 100644 --- a/src/lib/validations/__tests__/medication-intake-event.test.ts +++ b/src/lib/validations/__tests__/medication-intake-event.test.ts @@ -11,7 +11,11 @@ */ import { describe, expect, it } from "vitest"; -import { updateIntakeEventSchema } from "@/lib/validations/medication"; +import { + externalIntakeSchema, + intakeSchema, + updateIntakeEventSchema, +} from "@/lib/validations/medication"; const HOUR_MS = 60 * 60 * 1000; const YEAR_MS = 365 * 24 * HOUR_MS; @@ -74,3 +78,70 @@ describe("updateIntakeEventSchema — takenAt bounds (P0-4)", () => { expect(result.success).toBe(true); }); }); + +/** + * v1.16.9 — the same bounds on every CREATE path. The retro-add dialog + * posts through `intakeSchema`, the iOS sync through the bulk entry + * shape, external integrations through `externalIntakeSchema`; none of + * them may insert a row the edit path would refuse. + */ +describe("intakeSchema — takenAt bounds on create (v1.16.9)", () => { + it("rejects a future takenAt beyond the skew allowance", () => { + const result = intakeSchema.safeParse({ + medicationId: "med-1", + takenAt: new Date(Date.now() + HOUR_MS).toISOString(), + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toContain("future"); + } + }); + + it("rejects a takenAt more than 5 years in the past", () => { + const result = intakeSchema.safeParse({ + medicationId: "med-1", + takenAt: new Date(Date.now() - 6 * YEAR_MS).toISOString(), + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toContain("5 years"); + } + }); + + it("accepts a recent takenAt and a body that omits it", () => { + expect( + intakeSchema.safeParse({ + medicationId: "med-1", + takenAt: new Date(Date.now() - 2 * HOUR_MS).toISOString(), + }).success, + ).toBe(true); + expect(intakeSchema.safeParse({ medicationId: "med-1" }).success).toBe( + true, + ); + }); +}); + +describe("externalIntakeSchema — takenAt bounds on ingest (v1.16.9)", () => { + it("rejects future and implausibly-old instants, accepts recent ones", () => { + const future = externalIntakeSchema.safeParse({ + medicationName: "Metformin", + idempotencyKey: "k-1", + takenAt: new Date(Date.now() + HOUR_MS).toISOString(), + }); + expect(future.success).toBe(false); + + const ancient = externalIntakeSchema.safeParse({ + medicationName: "Metformin", + idempotencyKey: "k-2", + takenAt: new Date(Date.now() - 6 * YEAR_MS).toISOString(), + }); + expect(ancient.success).toBe(false); + + const ok = externalIntakeSchema.safeParse({ + medicationName: "Metformin", + idempotencyKey: "k-3", + takenAt: new Date(Date.now() - HOUR_MS).toISOString(), + }); + expect(ok.success).toBe(true); + }); +}); diff --git a/src/lib/validations/medication.ts b/src/lib/validations/medication.ts index 03bda0bc9..1c9213675 100644 --- a/src/lib/validations/medication.ts +++ b/src/lib/validations/medication.ts @@ -163,6 +163,30 @@ function hhmmToMinutes(hhmm: string): number { return Number(h) * 60 + Number(m); } +/** + * v1.15.19 introduced `takenAt` plausibility bounds on the EDIT path + * (audit P0-4); v1.16.9 lifts them onto every CREATE path too (the + * per-medication intake POST, the bulk endpoint, the external ingest) so + * the retro-add dialog and a hand-rolled API call can't insert a row a + * decade in the past or hours in the future. Future allowance covers + * client clock skew; the 5-year floor matches the GLP-1 dose-change + * validator's window. Slot-distance / medication-start checks stay in + * the routes — the schema cannot see the medication. + */ +export const TAKEN_AT_CLOCK_SKEW_MS = 5 * 60 * 1000; +export const TAKEN_AT_MAX_AGE_MS = 5 * 365 * 24 * 60 * 60 * 1000; + +/** ISO `takenAt` → `Date` with the shared plausibility bounds applied. */ +export const boundedTakenAtSchema = z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .refine((d) => d.getTime() <= Date.now() + TAKEN_AT_CLOCK_SKEW_MS, { + message: "takenAt must not be in the future", + }) + .refine((d) => d.getTime() >= Date.now() - TAKEN_AT_MAX_AGE_MS, { + message: "takenAt must be within the last 5 years", + }); + export const scheduleSchema = z .object({ windowStart: z @@ -598,12 +622,10 @@ export const intakeSchema = z .describe( "Slot the dose belongs to. Defaults to `takenAt` (or `now()` when both are absent) so the compliance pairing logic can pin the dose to a schedule slot.", ), - takenAt: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) + takenAt: boundedTakenAtSchema .optional() .describe( - "When the dose was actually taken. NULL when `skipped` is true; defaults to `now()` for non-skipped intakes.", + "When the dose was actually taken. NULL when `skipped` is true; defaults to `now()` for non-skipped intakes. Must not lie in the future (5-minute clock-skew allowance) nor more than 5 years in the past.", ), skipped: z .boolean() @@ -673,10 +695,8 @@ export const intakeSchema = z export const externalIntakeSchema = z.object({ medicationName: z.string().min(1).max(200), - takenAt: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .optional(), + // v1.16.9 — same plausibility bounds as the interactive create paths. + takenAt: boundedTakenAtSchema.optional(), idempotencyKey: z.string().max(128), }); @@ -718,22 +738,9 @@ export const listIntakeEventsSchema = z.object({ * checks stay out of the schema — it cannot see the medication — and live * in the route (start-date guard) + the edit dialog (non-blocking hint). */ -const TAKEN_AT_CLOCK_SKEW_MS = 5 * 60 * 1000; -const TAKEN_AT_MAX_AGE_MS = 5 * 365 * 24 * 60 * 60 * 1000; - export const updateIntakeEventSchema = z .object({ - takenAt: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .refine((d) => d.getTime() <= Date.now() + TAKEN_AT_CLOCK_SKEW_MS, { - message: "takenAt must not be in the future", - }) - .refine((d) => d.getTime() >= Date.now() - TAKEN_AT_MAX_AGE_MS, { - message: "takenAt must be within the last 5 years", - }) - .nullable() - .optional(), + takenAt: boundedTakenAtSchema.nullable().optional(), skipped: z.boolean().optional(), scheduledFor: z.iso .datetime({ offset: true }) diff --git a/src/lib/validations/whoop.ts b/src/lib/validations/whoop.ts index ea33fe786..f547569d7 100644 --- a/src/lib/validations/whoop.ts +++ b/src/lib/validations/whoop.ts @@ -7,8 +7,10 @@ import { z } from "zod/v4"; * multi-operator product). Stored encrypted on `User`. */ export const whoopCredentialsSchema = z.object({ - clientId: z.string().min(1).max(200), - clientSecret: z.string().min(1).max(200), + // Trimmed: a trailing space or newline from the portal's copy button + // reaches WHOOP verbatim and answers as "unknown client". + clientId: z.string().trim().min(1).max(200), + clientSecret: z.string().trim().min(1).max(200), }); export type WhoopCredentialsInput = z.infer; diff --git a/src/lib/whoop/client.ts b/src/lib/whoop/client.ts index 7eb91358e..90cf76fd2 100644 --- a/src/lib/whoop/client.ts +++ b/src/lib/whoop/client.ts @@ -34,8 +34,10 @@ export interface WhoopCredentials { } function getRedirectUri(): string { + // `||`, not `??`: the compose whitelist materialises the var as an empty + // string when unset, which must still fall through to the derived URI. return ( - process.env.WHOOP_REDIRECT_URI ?? + process.env.WHOOP_REDIRECT_URI || `${process.env.NEXT_PUBLIC_APP_URL}/api/whoop/callback` ); } diff --git a/tests/integration/medications-intake-bulk.test.ts b/tests/integration/medications-intake-bulk.test.ts index df18caf3f..2e42a6324 100644 --- a/tests/integration/medications-intake-bulk.test.ts +++ b/tests/integration/medications-intake-bulk.test.ts @@ -377,8 +377,11 @@ describe("POST /api/medications/intake/bulk (real Postgres)", () => { }); const { POST } = await import("@/app/api/medications/intake/bulk/route"); - const t1 = new Date("2026-06-15T09:00:00.000Z"); - const t2 = new Date("2026-06-15T15:00:00.000Z"); + // Relative past instants: takenAt now carries a no-future plausibility + // bound, so fixed calendar dates would rot into rejections. + const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000); + const t1 = new Date(yesterday.setUTCHours(9, 0, 0, 0)); + const t2 = new Date(yesterday.setUTCHours(15, 0, 0, 0)); await POST( makeRequest({ entries: [ @@ -408,7 +411,14 @@ describe("POST /api/medications/intake/bulk (real Postgres)", () => { it("creates exactly one row for a fresh scheduled dose with no pre-existing slot row", async () => { const prisma = getPrismaClient(); const medId = await makeScheduledMed(["19:00"]); - const slot = localHmAsUtc(new Date(), TZ, 19, 0); + // Yesterday's slot: today's 19:00 is in the future for any run before + // the evening, and a future takenAt is now rejected by design. + const slot = localHmAsUtc( + new Date(Date.now() - 24 * 60 * 60 * 1000), + TZ, + 19, + 0, + ); const { POST } = await import("@/app/api/medications/intake/bulk/route"); const res = await POST(