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;