From b4d2d51d4663ed681f3f9730cb3739f7a4a96322 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 12 Jun 2026 11:22:21 +0200 Subject: [PATCH] fix(dashboard,medications): review findings before the cut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh crisis-level blood-pressure reading outranks an overdue dose in the daily verdict, and once it ages past a day the dose leads again. The greeting returns to the page header whenever the daily band is hidden, and the settings toggle says it rides along. The nightly convergence pass honours the early grace, so a take recorded shortly before a window that starts at the dose time heals like the write path credits it. The early-take context line states a fact — last dose yesterday, last dose n days ago — instead of claiming an early intake after a late one, the quick-add default pick runs in the profile timezone, a dose line without a medication name reads cleanly, the early-grace constant has one home, and the Telegram confirm writes intake and snooze atomically again. --- messages/de.json | 7 +- messages/en.json | 7 +- messages/es.json | 9 +- messages/fr.json | 7 +- messages/it.json | 7 +- messages/pl.json | 7 +- .../__tests__/dashboard-hero-wiring.test.ts | 24 +++- .../telegram/webhook/__tests__/route.test.ts | 12 +- src/app/api/telegram/webhook/route.ts | 128 ++++++++++------- src/app/page.tsx | 10 +- .../__tests__/dashboard-header.test.tsx | 57 ++++++++ .../medication-intake-quick-add.test.tsx | 37 +++++ src/components/dashboard/dashboard-header.tsx | 75 +++++++++- .../hero/__tests__/dashboard-hero.test.tsx | 38 +++++ .../dashboard/hero/dashboard-hero.tsx | 16 ++- .../dashboard/medication-intake-quick-add.tsx | 25 +++- .../card-parts/medication-card-body.tsx | 10 +- .../card-parts/medication-status-pill.tsx | 20 +-- .../medications/glp1-medication-card.tsx | 2 +- .../medications/medication-card.tsx | 11 +- .../dashboard-layout-section.test.tsx | 2 +- src/lib/dashboard/__tests__/verdict.test.ts | 54 +++++-- src/lib/dashboard/verdict.ts | 35 ++--- .../intake-slot-dedup-converge.test.ts | 41 +++++- .../__tests__/window-status.test.ts | 10 +- src/lib/medications/intake-slot-dedup.ts | 9 +- src/lib/medications/scheduling/attribution.ts | 8 +- .../scheduling/dose-window-defaults.ts | 9 ++ src/lib/medications/window-status.ts | 134 ++++++++++-------- 29 files changed, 601 insertions(+), 210 deletions(-) create mode 100644 src/components/dashboard/__tests__/dashboard-header.test.tsx diff --git a/messages/de.json b/messages/de.json index bea30b511..008dd5f5f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -285,8 +285,10 @@ }, "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.", @@ -352,7 +354,7 @@ "mood": "Stimmung", "customizeTitle": "Dashboard-Layout", "heroToggleLabel": "Tagesüberblick", - "heroToggleDescription": "Zeigt Score, Tagesfokus und Dosen-Status ganz oben auf dem Dashboard.", + "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", @@ -760,7 +762,8 @@ "withoutNotification": "Ohne Benachrichtigung", "pausedSince": "Pausiert seit", "takeNow": "Jetzt einnehmen", - "takenEarly": "Bereits vorzeitig eingenommen", + "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 2a398b384..891a06406 100644 --- a/messages/en.json +++ b/messages/en.json @@ -285,8 +285,10 @@ }, "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.", @@ -352,7 +354,7 @@ "mood": "Mood", "customizeTitle": "Dashboard layout", "heroToggleLabel": "Daily overview", - "heroToggleDescription": "Shows your score, daily focus and dose status at the top of the dashboard.", + "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", @@ -760,7 +762,8 @@ "withoutNotification": "Without notification", "pausedSince": "Paused since", "takeNow": "Take now", - "takenEarly": "Already taken early", + "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 3d0217e3a..06537b0b8 100644 --- a/messages/es.json +++ b/messages/es.json @@ -284,9 +284,11 @@ "none": "Hoy no hay dosis programadas" }, "verdict": { - "doseOverdue": "Una dosis de {name} está pendiente desde hace rato.", + "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.", @@ -352,7 +354,7 @@ "mood": "Estado de ánimo", "customizeTitle": "Diseño del panel", "heroToggleLabel": "Resumen del día", - "heroToggleDescription": "Muestra la puntuación, el foco del día y el estado de las dosis en la parte superior del panel.", + "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", @@ -760,7 +762,8 @@ "withoutNotification": "Sin notificación", "pausedSince": "En pausa desde", "takeNow": "Tomar ahora", - "takenEarly": "Ya tomado con antelación", + "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 a4bdb7f90..445d9e1f3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -285,8 +285,10 @@ }, "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.", @@ -352,7 +354,7 @@ "mood": "Humeur", "customizeTitle": "Mise en page du tableau", "heroToggleLabel": "Aperçu du jour", - "heroToggleDescription": "Affiche le score, le focus du jour et l’état des doses tout en haut du tableau de bord.", + "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", @@ -760,7 +762,8 @@ "withoutNotification": "Sans notification", "pausedSince": "En pause depuis", "takeNow": "Prendre maintenant", - "takenEarly": "Déjà pris en avance", + "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 5358627c9..a71112349 100644 --- a/messages/it.json +++ b/messages/it.json @@ -285,8 +285,10 @@ }, "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.", @@ -352,7 +354,7 @@ "mood": "Umore", "customizeTitle": "Layout della dashboard", "heroToggleLabel": "Panoramica del giorno", - "heroToggleDescription": "Mostra punteggio, focus del giorno e stato delle dosi in cima alla dashboard.", + "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", @@ -760,7 +762,8 @@ "withoutNotification": "Senza notifica", "pausedSince": "In pausa dal", "takeNow": "Assumi ora", - "takenEarly": "Già assunto in anticipo", + "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 72127650e..18ac910b5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -285,8 +285,10 @@ }, "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.", @@ -352,7 +354,7 @@ "mood": "Nastrój", "customizeTitle": "Układ panelu", "heroToggleLabel": "Przegląd dnia", - "heroToggleDescription": "Pokazuje wynik, fokus dnia i status dawek na górze pulpitu.", + "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", @@ -760,7 +762,8 @@ "withoutNotification": "Bez powiadomienia", "pausedSince": "Wstrzymane od", "takeNow": "Przyjmij teraz", - "takenEarly": "Już przyjęto wcześniej", + "lastDoseYesterday": "Ostatnia dawka wczoraj", + "lastDoseDaysAgo": "Ostatnia dawka {count} dni temu", "overdue": "Po terminie", "veryOverdue": "Mocno po terminie", "compliance7d": "Przestrzeganie 7 dni", diff --git a/src/app/__tests__/dashboard-hero-wiring.test.ts b/src/app/__tests__/dashboard-hero-wiring.test.ts index 9b2cf45d5..9e26a1939 100644 --- a/src/app/__tests__/dashboard-hero-wiring.test.ts +++ b/src/app/__tests__/dashboard-hero-wiring.test.ts @@ -14,10 +14,10 @@ import { join } from "node:path"; * 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 left the header for the hero. The header - * renders no name-bearing text at all any more, so the SSR pass - * stays name-free by construction (the hero itself only mounts - * once the snapshot resolved — post-hydration). + * - 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( @@ -69,9 +69,19 @@ describe("dashboard hero — page wiring", () => { describe("dashboard hero — header handoff", () => { const header = readFileSync(HEADER_PATH, "utf8"); - it("the header dropped the greeting paragraph and its reservation", () => { - expect(header).not.toContain("welcomeText"); - expect(header).not.toContain("min-h-5"); + 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", () => { diff --git a/src/app/api/telegram/webhook/__tests__/route.test.ts b/src/app/api/telegram/webhook/__tests__/route.test.ts index 036acbb34..ce8c8f72d 100644 --- a/src/app/api/telegram/webhook/__tests__/route.test.ts +++ b/src/app/api/telegram/webhook/__tests__/route.test.ts @@ -137,7 +137,13 @@ beforeEach(() => { 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({ @@ -249,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( @@ -319,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 () => { diff --git a/src/app/api/telegram/webhook/route.ts b/src/app/api/telegram/webhook/route.ts index 742c2c269..6a95195a6 100644 --- a/src/app/api/telegram/webhook/route.ts +++ b/src/app/api/telegram/webhook/route.ts @@ -259,40 +259,53 @@ async function markMedicationTaken( canonicalSlot = attribution.slotInstant; } - if (canonicalSlot) { - const applied = await applyCanonicalSlotWrite({ - client: prisma, - userId, - medicationId: medication.id, - canonicalSlot, - takenAt, - skipped: false, - isExplicitTaken: true, - isExplicitSkip: false, - idempotencyKey, - createSource: "REMINDER", - attributionSource: "AUTO", - }); - scheduledFor = applied.row.scheduledFor; - } else { - // Ad-hoc / PRN — standalone row under the documented contract - // (`scheduledFor = takenAt`). - const created = await prisma.medicationIntakeEvent.create({ - data: { + // 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, - scheduledFor: takenAt, + canonicalSlot, takenAt, skipped: false, - source: "REMINDER", + isExplicitTaken: true, + isExplicitSkip: false, idempotencyKey, - }, + 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 }, }); - scheduledFor = created.scheduledFor; - } - await prisma.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. @@ -476,37 +489,46 @@ async function handleCallback(update: TelegramUpdate) { canonicalSlot = attribution.slotInstant; } - if (canonicalSlot) { - const applied = await applyCanonicalSlotWrite({ - client: prisma, - userId: user.id, - medicationId: medication.id, - canonicalSlot, - takenAt: null, - skipped: true, - isExplicitTaken: false, - isExplicitSkip: true, - idempotencyKey, - createSource: "REMINDER", - }); - skippedScheduledFor = applied.row.scheduledFor; - } else { - const created = await prisma.medicationIntakeEvent.create({ - data: { + // 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: skipMoment, + canonicalSlot, takenAt: null, skipped: true, - source: "REMINDER", + isExplicitTaken: false, + isExplicitSkip: true, idempotencyKey, - }, + 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 }, }); - skippedScheduledFor = created.scheduledFor; - } - await prisma.medication.update({ - where: { id: medication.id }, - data: { snoozedUntil: endOfDay }, + return slotScheduledFor; }); invalidateUserMedications(user.id, { evict: true }); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 16c68c38b..48e4dce3d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -711,13 +711,19 @@ export default function DashboardPage() { // 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. + // 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 ? ( 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 91d882fea..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, @@ -175,6 +182,36 @@ describe("pickDefaultMedicationId — auto-select heuristic", () => { 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 79b0a9589..51c48967f 100644 --- a/src/components/dashboard/dashboard-header.tsx +++ b/src/components/dashboard/dashboard-header.tsx @@ -3,12 +3,16 @@ /** * Dashboard page header: title, the customize shortcut, and the * quick-add dropdown that opens the quick-entry sheets. The greeting - * line moved into the hero band (``), which derives it - * from the snapshot's server-computed `greetingHour`. + * 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"; @@ -19,19 +23,78 @@ 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({ onQuickEntry, + showGreeting = false, }: { 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 ( -
-

- {t("dashboard.title")} -

+ /* 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")} +

+ {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 (the tile/layout editor at /settings/dashboard). Sits to diff --git a/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx b/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx index 16b9ac94b..0c386bbf8 100644 --- a/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx +++ b/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx @@ -147,6 +147,44 @@ describe(" — verdict variants", () => { 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({ diff --git a/src/components/dashboard/hero/dashboard-hero.tsx b/src/components/dashboard/hero/dashboard-hero.tsx index a746951d2..3d917740c 100644 --- a/src/components/dashboard/hero/dashboard-hero.tsx +++ b/src/components/dashboard/hero/dashboard-hero.tsx @@ -20,7 +20,7 @@ * 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 1 keys on the flag, rung 3 requires a future anchor), + * 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. @@ -134,11 +134,23 @@ export function DashboardHero({ : 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(VERDICT_MESSAGE_KEY[verdict.variant], { + : 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 diff --git a/src/components/dashboard/medication-intake-quick-add.tsx b/src/components/dashboard/medication-intake-quick-add.tsx index d617055af..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"; /** @@ -129,12 +130,18 @@ 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 @@ -142,12 +149,13 @@ export function pickDefaultMedicationId( 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 @@ -155,7 +163,7 @@ export function pickDefaultMedicationId( ? { at: new Date(nextDueMs), overdue: m.nextDueOverdue === true } : null, }); - return status.status !== null && !status.takenEarly; + return status.status !== null && status.takenEarlyDaysAgo === null; }); if (due) return due.id; @@ -173,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(), @@ -229,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 7161bd33d..5a27eeadb 100644 --- a/src/components/medications/card-parts/medication-card-body.tsx +++ b/src/components/medications/card-parts/medication-card-body.tsx @@ -71,8 +71,8 @@ export interface MedicationCardBodyProps { status: Exclude; windowStart: string; windowEnd: string; - /** v1.16.9 — day-scale early-take context (see the pill). */ - takenEarly?: boolean; + /** v1.16.9 — day-scale last-dose context in whole days (see the pill). */ + takenEarlyDaysAgo?: number | null; } | null; /** @@ -180,15 +180,15 @@ export function MedicationCardBody({ overdue tier arrives with the compliance query, and a line popping in late used to shift every row below it (CLS). */}
- {windowStatus?.takenEarly ? ( - // v1.16.9 — taken-early context outranks the overdue line: a + {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 ? (

diff --git a/src/components/medications/card-parts/medication-status-pill.tsx b/src/components/medications/card-parts/medication-status-pill.tsx index 221cc3930..7f9e41be9 100644 --- a/src/components/medications/card-parts/medication-status-pill.tsx +++ b/src/components/medications/card-parts/medication-status-pill.tsx @@ -10,13 +10,15 @@ interface MedicationStatusPillProps { windowStart: string; windowEnd: string; /** - * v1.16.9 — day-scale early-take context. When true the dose was + * v1.16.9 — day-scale last-dose context. Non-null when a dose was * already taken earlier in the cadence period (a weekly shot days - * before its slot day), so the pill renders a calm "taken early" note - * instead of the take-now / overdue prompt — prompting a full take on - * the slot day would be a double-dose prompt. + * before its slot day, or the previous slot served late); the pill + * then renders a calm factual "last dose {n} days ago" note instead + * of the take-now / overdue prompt — prompting a full take on the + * slot day would be a double-dose prompt. The value is the whole + * local days since that take. */ - takenEarly?: boolean; + takenEarlyDaysAgo?: number | null; } /** @@ -31,16 +33,18 @@ export function MedicationStatusPill({ status, windowStart, windowEnd, - takenEarly = false, + takenEarlyDaysAgo = null, }: MedicationStatusPillProps) { const { t, locale } = useTranslations(); - if (takenEarly) { + if (takenEarlyDaysAgo != null) { return (

{" "} diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index c87ec1fad..a54846022 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -446,7 +446,7 @@ export function Glp1MedicationCard({ // legacy schedule window (which may be stale / degenerate). windowStart: currentWindowStatus.window!.start, windowEnd: currentWindowStatus.window!.end, - takenEarly: currentWindowStatus.takenEarly, + takenEarlyDaysAgo: currentWindowStatus.takenEarlyDaysAgo, } : null } diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 3edb02c54..30a7a8ec2 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -238,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, }); @@ -389,14 +392,16 @@ export function MedicationCard({ // legacy schedule window (which may be stale / degenerate). windowStart: currentWindowStatus.window!.start, windowEnd: currentWindowStatus.window!.end, - takenEarly: currentWindowStatus.takenEarly, + 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 71121b0b2..73ab9f58f 100644 --- a/src/components/settings/__tests__/dashboard-layout-section.test.tsx +++ b/src/components/settings/__tests__/dashboard-layout-section.test.tsx @@ -293,7 +293,7 @@ describe(" — hero visibility switch", () => { const de = render(, "de"); expect(de).toContain("Tagesüberblick"); expect(de).toContain( - "Zeigt Score, Tagesfokus und Dosen-Status ganz oben auf dem Dashboard.", + "Zeigt Begrüßung, Score, Tagesfokus und Dosen-Status ganz oben auf dem Dashboard.", ); const en = render(); expect(en).toContain("Daily overview"); diff --git a/src/lib/dashboard/__tests__/verdict.test.ts b/src/lib/dashboard/__tests__/verdict.test.ts index 61b32560f..c5f704c19 100644 --- a/src/lib/dashboard/__tests__/verdict.test.ts +++ b/src/lib/dashboard/__tests__/verdict.test.ts @@ -5,7 +5,8 @@ * 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) and only on a fresh reading; + * 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; @@ -111,7 +112,7 @@ const WATCH_FINDING = { sourceMetric: "bp" as const, }; -describe("rung 1 — doseOverdue", () => { +describe("rung 2 — doseOverdue", () => { it("fires on nextDueOverdue with the medication name and a quick-entry CTA", () => { const verdict = resolveDashboardVerdict( baseSnapshot({ @@ -132,7 +133,9 @@ describe("rung 1 — doseOverdue", () => { }); }); - it("wins over a simultaneously critical BP reading (precedence)", () => { + 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({ @@ -152,6 +155,29 @@ describe("rung 1 — doseOverdue", () => { }), 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"); }); @@ -173,7 +199,7 @@ describe("rung 1 — doseOverdue", () => { }); }); -describe("rung 2 — bpCritical", () => { +describe("rung 1 — bpCritical", () => { function bpSnapshot( sys: number | null, dia: number | null, @@ -392,14 +418,12 @@ describe("rung 4 — weightDrift", () => { }); it("skips when avg7 or avg30 is null", () => { + expect(resolveDashboardVerdict(weightSnapshot(null, 75), NOW).variant).toBe( + "allQuiet", + ); expect( - resolveDashboardVerdict(weightSnapshot(null, 75), NOW).variant, - ).toBe("allQuiet"); - expect( - resolveDashboardVerdict( - weightSnapshot(GREEN_MAX_180 + 5, null), - NOW, - ).variant, + resolveDashboardVerdict(weightSnapshot(GREEN_MAX_180 + 5, null), NOW) + .variant, ).toBe("allQuiet"); }); @@ -414,7 +438,10 @@ describe("rung 4 — weightDrift", () => { }); describe("rung 5 — shortNights", () => { - function sleepSnapshot(avg7: number | null, count: number): DashboardSnapshot { + function sleepSnapshot( + avg7: number | null, + count: number, + ): DashboardSnapshot { return baseSnapshot({ tiles: { summaries: { @@ -540,8 +567,7 @@ describe("rung 7 — scoreDrop", () => { "allQuiet", ); expect( - resolveDashboardVerdict(baseSnapshot({ healthScore: null }), NOW) - .variant, + resolveDashboardVerdict(baseSnapshot({ healthScore: null }), NOW).variant, ).toBe("allQuiet"); }); }); diff --git a/src/lib/dashboard/verdict.ts b/src/lib/dashboard/verdict.ts index ae6c56cef..59c62e330 100644 --- a/src/lib/dashboard/verdict.ts +++ b/src/lib/dashboard/verdict.ts @@ -7,7 +7,8 @@ * against the current instant). * * Nine rungs, first hit wins. The ordering is a severity ladder: - * actionable medication states first, then a fixed-floor BP emergency, + * 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 @@ -21,7 +22,7 @@ * * 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 1 keys ONLY on the server-computed + * 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. */ @@ -128,22 +129,12 @@ export function resolveDashboardVerdict( const summaries = snapshot.tiles.summaries; const lastSeen = snapshot.tiles.lastSeenByType; - // ── 1 · 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" }, - }; - } - - // ── 2 · bpCritical ───────────────────────────────────────────────── + // ── 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`). + // 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; @@ -162,6 +153,18 @@ export function resolveDashboardVerdict( }; } + // ── 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. diff --git a/src/lib/medications/__tests__/intake-slot-dedup-converge.test.ts b/src/lib/medications/__tests__/intake-slot-dedup-converge.test.ts index ef78d293c..e9b0ba44b 100644 --- a/src/lib/medications/__tests__/intake-slot-dedup-converge.test.ts +++ b/src/lib/medications/__tests__/intake-slot-dedup-converge.test.ts @@ -119,9 +119,7 @@ beforeEach(() => { vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({ count: 1, } as never); - vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue( - {} as never, - ); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue({} as never); }); describe("dedupeUserIntakeSlots — ad-hoc→pending convergence (v1.16.9)", () => { @@ -159,6 +157,43 @@ describe("dedupeUserIntakeSlots — ad-hoc→pending convergence (v1.16.9)", () 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), diff --git a/src/lib/medications/__tests__/window-status.test.ts b/src/lib/medications/__tests__/window-status.test.ts index 4d2dd09d4..ce6328f62 100644 --- a/src/lib/medications/__tests__/window-status.test.ts +++ b/src/lib/medications/__tests__/window-status.test.ts @@ -393,7 +393,7 @@ describe("reduceCurrentWindowStatus — day-scale taken-early context", () => { todayEventCount: 0, }); expect(res.status).toBe("in_window"); - expect(res.takenEarly).toBe(true); + expect(res.takenEarlyDaysAgo).toBe(2); }); it("does not flag takenEarly when the last take was a full period ago", () => { @@ -406,7 +406,7 @@ describe("reduceCurrentWindowStatus — day-scale taken-early context", () => { todayEventCount: 0, }); expect(res.status).toBe("in_window"); - expect(res.takenEarly).toBe(false); + expect(res.takenEarlyDaysAgo).toBeNull(); }); it("does not flag a daily cadence (minute-scale periods stay untouched)", () => { @@ -425,7 +425,7 @@ describe("reduceCurrentWindowStatus — day-scale taken-early context", () => { todayEventCount: 0, }); expect(res.status).toBe("in_window"); - expect(res.takenEarly).toBe(false); + expect(res.takenEarlyDaysAgo).toBeNull(); }); it("rides on the late tier too — an early take must not escalate to overdue", () => { @@ -437,7 +437,7 @@ describe("reduceCurrentWindowStatus — day-scale taken-early context", () => { todayEventCount: 0, }); expect(res.status).toBe("late"); - expect(res.takenEarly).toBe(true); + expect(res.takenEarlyDaysAgo).toBe(2); }); it("evaluates the early-take day in the supplied timezone", () => { @@ -454,6 +454,6 @@ describe("reduceCurrentWindowStatus — day-scale taken-early context", () => { tz: "Pacific/Auckland", }); expect(res.status).toBe("in_window"); - expect(res.takenEarly).toBe(true); + expect(res.takenEarlyDaysAgo).toBe(1); }); }); diff --git a/src/lib/medications/intake-slot-dedup.ts b/src/lib/medications/intake-slot-dedup.ts index 8f2004853..1e475ad9f 100644 --- a/src/lib/medications/intake-slot-dedup.ts +++ b/src/lib/medications/intake-slot-dedup.ts @@ -540,8 +540,15 @@ async function convergeAdhocRowsOntoPendingSlots(input: { 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.onTimeStart.getTime() && t <= b.overdueEnd.getTime(), + (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; diff --git a/src/lib/medications/scheduling/attribution.ts b/src/lib/medications/scheduling/attribution.ts index 91e428d35..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). */ @@ -46,9 +48,11 @@ export interface SlotWindowInput { * 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. + * 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 = 60 * 60 * 1000; +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 { 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/window-status.ts b/src/lib/medications/window-status.ts index 22a3f0230..0c8ad5f18 100644 --- a/src/lib/medications/window-status.ts +++ b/src/lib/medications/window-status.ts @@ -88,15 +88,17 @@ export interface CurrentWindowStatus { /** Non-null exactly when `status` is non-null. */ window: MatchedDoseWindow | null; /** - * v1.16.9 — true when the matched schedule is a DAY-SCALE cadence + * 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 (an early - * shot the band attribution recorded ad-hoc). The dose is already on - * board: the card must render taken-early context instead of the - * "take now" / overdue prompt — a full take prompt on the slot day is - * a double-dose prompt. Always false when `status` is null. + * 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. */ - takenEarly: boolean; + takenEarlyDaysAgo: number | null; } /** @@ -314,12 +316,15 @@ function getScheduleStatus( } /** - * v1.16.9 — mirror of 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). + * 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 = 60; +const EARLY_GRACE_MINUTES = DOSE_WINDOW_DEFAULTS.earlyGraceMinutes; function isLastIntakeInBand( lastTakenAt: string | null, @@ -395,18 +400,22 @@ function dayScaleCadence(daysOfWeek: string | null): { } /** - * True when the last actioned take lies on an EARLIER local day inside - * the current cadence period — the early-shot shape the taken-early - * downgrade fires on. 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. + * 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 hasEarlyTakeInPeriod( +function earlyTakeDaysAgo( lastTakenAt: string | null, nowLocal: Date, tz: string, periodDays: number, -): boolean { - if (!lastTakenAt || periodDays < 2) return false; +): 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( @@ -424,7 +433,7 @@ function hasEarlyTakeInPeriod( ).getTime() / dayMs, ); const diff = nowDay - intakeDay; - return diff >= 1 && diff < periodDays; + return diff >= 1 && diff < periodDays ? diff : null; } /** @@ -482,39 +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; - /** - * 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 { +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, @@ -531,7 +540,7 @@ export function reduceCurrentWindowStatus( status: null, schedule: null, window: null, - takenEarly: false, + takenEarlyDaysAgo: null, }; if (!active) return none; if (nextDue === null) return none; @@ -583,17 +592,20 @@ export function reduceCurrentWindowStatus( ) { 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 taken-early context instead of prompting a full take + // 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. + // framing changes; the day count rides along for the copy. const cadence = dayScaleCadence(s.daysOfWeek); - const takenEarly = - cadence.dayScale && - hasEarlyTakeInPeriod(lastTakenAt, nowBerlin, tz, cadence.periodDays); + const takenEarlyDaysAgo = cadence.dayScale + ? earlyTakeDaysAgo(lastTakenAt, nowBerlin, tz, cadence.periodDays) + : null; return { status: hit.status, schedule: s, @@ -602,7 +614,7 @@ export function reduceCurrentWindowStatus( start: hit.band.startHm, end: hit.band.endHm, }, - takenEarly, + takenEarlyDaysAgo, }; } return best;