diff --git a/messages/de.json b/messages/de.json index da95f3686..a1f2634f6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1338,6 +1338,42 @@ "failed": "Einnahme konnte nicht gelöscht werden." } }, + "verlauf": { + "loading": "Verlauf wird geladen …", + "loadError": "Verlauf konnte nicht geladen werden.", + "empty": "Noch keine Einnahmen in diesem Zeitraum.", + "summaryEmpty": "Noch keine auswertbaren Dosen.", + "adherenceLabel": "Therapietreue", + "adherenceBarLabel": "{onTime} pünktlich, {late} verspätet, {missed} verpasst", + "add": "Eintrag", + "markTaken": "Genommen", + "markSkipped": "Übersprungen", + "adHocTag": "außerplanmäßig", + "takenDetail": "geplant {planned} · genommen {taken}", + "status": { + "taken_on_time": "pünktlich", + "taken_late": "verspätet", + "skipped": "übersprungen", + "missed": "verpasst", + "upcoming": "ausstehend", + "ad_hoc": "außerplanmäßig" + }, + "addDialog": { + "title": "Einnahme hinzufügen", + "description": "Dosis nachtragen – auch rückwirkend oder außerplanmäßig.", + "takenAtLabel": "Genommen um", + "skippedLabel": "Übersprungen", + "nudgeHint": "Liegt nah am Slot {slot} – beim Speichern fragen wir, ob zugeordnet werden soll.", + "cancel": "Abbrechen", + "submit": "Speichern" + }, + "nudge": { + "title": "Diesem Slot zuordnen?", + "body": "Die Einnahme liegt knapp außerhalb des Fensters von {slot}. Diesem Slot zuordnen oder als außerplanmäßigen Eintrag speichern?", + "keepAdHoc": "Außerplanmäßig", + "pin": "Zuordnen" + } + }, "notifications": { "title": "Erinnerungen", "switchLabel": "Erinnern, wenn die Dosis fällig ist", diff --git a/messages/en.json b/messages/en.json index bce49e826..9b8665c32 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1338,6 +1338,42 @@ "failed": "Could not delete the intake." } }, + "verlauf": { + "loading": "Loading history …", + "loadError": "Could not load the history.", + "empty": "No intakes in this period yet.", + "summaryEmpty": "No doses to score yet.", + "adherenceLabel": "Adherence", + "adherenceBarLabel": "{onTime} on time, {late} late, {missed} missed", + "add": "Entry", + "markTaken": "Taken", + "markSkipped": "Skipped", + "adHocTag": "off-schedule", + "takenDetail": "planned {planned} · taken {taken}", + "status": { + "taken_on_time": "on time", + "taken_late": "late", + "skipped": "skipped", + "missed": "missed", + "upcoming": "upcoming", + "ad_hoc": "off-schedule" + }, + "addDialog": { + "title": "Add intake", + "description": "Record a dose — including a backdated or off-schedule one.", + "takenAtLabel": "Taken at", + "skippedLabel": "Skipped", + "nudgeHint": "Close to the {slot} slot — on save we ask whether to attribute it.", + "cancel": "Cancel", + "submit": "Save" + }, + "nudge": { + "title": "Attribute to this slot?", + "body": "This intake falls just outside the {slot} window. Attribute it to that slot, or keep it as an off-schedule entry?", + "keepAdHoc": "Off-schedule", + "pin": "Attribute" + } + }, "notifications": { "title": "Notifications", "switchLabel": "Send a reminder when this dose is due", diff --git a/messages/es.json b/messages/es.json index c91262b0b..0416210ad 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1338,6 +1338,42 @@ "failed": "No se pudo eliminar la toma." } }, + "verlauf": { + "loading": "Cargando historial …", + "loadError": "No se pudo cargar el historial.", + "empty": "Aún no hay tomas en este periodo.", + "summaryEmpty": "Aún no hay dosis que evaluar.", + "adherenceLabel": "Adherencia", + "adherenceBarLabel": "{onTime} a tiempo, {late} con retraso, {missed} omitidas", + "add": "Entrada", + "markTaken": "Tomada", + "markSkipped": "Saltada", + "adHocTag": "fuera de horario", + "takenDetail": "prevista {planned} · tomada {taken}", + "status": { + "taken_on_time": "a tiempo", + "taken_late": "con retraso", + "skipped": "saltada", + "missed": "perdida", + "upcoming": "pendiente", + "ad_hoc": "fuera de horario" + }, + "addDialog": { + "title": "Añadir toma", + "description": "Registra una dosis, también retroactiva o fuera de horario.", + "takenAtLabel": "Tomada a las", + "skippedLabel": "Saltada", + "nudgeHint": "Cerca del horario {slot}: al guardar te preguntamos si asignarla.", + "cancel": "Cancelar", + "submit": "Guardar" + }, + "nudge": { + "title": "¿Asignar a este horario?", + "body": "Esta toma queda justo fuera de la ventana de {slot}. ¿Asignarla a ese horario o guardarla como entrada fuera de horario?", + "keepAdHoc": "Fuera de horario", + "pin": "Asignar" + } + }, "notifications": { "title": "Recordatorios", "switchLabel": "Enviar un recordatorio cuando toque esta dosis", diff --git a/messages/fr.json b/messages/fr.json index d7a71f79f..5058e0816 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1338,6 +1338,42 @@ "failed": "Impossible de supprimer la prise." } }, + "verlauf": { + "loading": "Chargement de l'historique …", + "loadError": "Impossible de charger l'historique.", + "empty": "Aucune prise sur cette période pour l'instant.", + "summaryEmpty": "Aucune dose à évaluer pour l'instant.", + "adherenceLabel": "Observance", + "adherenceBarLabel": "{onTime} à l'heure, {late} en retard, {missed} manquées", + "add": "Entrée", + "markTaken": "Prise", + "markSkipped": "Sautée", + "adHocTag": "hors planning", + "takenDetail": "prévue {planned} · prise {taken}", + "status": { + "taken_on_time": "à l'heure", + "taken_late": "en retard", + "skipped": "sautée", + "missed": "manquée", + "upcoming": "à venir", + "ad_hoc": "hors planning" + }, + "addDialog": { + "title": "Ajouter une prise", + "description": "Enregistrez une dose, même rétroactive ou hors planning.", + "takenAtLabel": "Prise à", + "skippedLabel": "Sautée", + "nudgeHint": "Proche du créneau {slot} — à l'enregistrement, nous demandons s'il faut l'y rattacher.", + "cancel": "Annuler", + "submit": "Enregistrer" + }, + "nudge": { + "title": "Rattacher à ce créneau ?", + "body": "Cette prise se situe juste en dehors de la fenêtre de {slot}. La rattacher à ce créneau ou la conserver comme entrée hors planning ?", + "keepAdHoc": "Hors planning", + "pin": "Rattacher" + } + }, "notifications": { "title": "Rappels", "switchLabel": "Envoyer un rappel lorsque cette dose est due", diff --git a/messages/it.json b/messages/it.json index e703f84cb..766548cc4 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1338,6 +1338,42 @@ "failed": "Impossibile eliminare l’assunzione." } }, + "verlauf": { + "loading": "Caricamento cronologia …", + "loadError": "Impossibile caricare la cronologia.", + "empty": "Ancora nessuna assunzione in questo periodo.", + "summaryEmpty": "Ancora nessuna dose da valutare.", + "adherenceLabel": "Aderenza", + "adherenceBarLabel": "{onTime} puntuali, {late} in ritardo, {missed} mancate", + "add": "Voce", + "markTaken": "Assunta", + "markSkipped": "Saltata", + "adHocTag": "fuori programma", + "takenDetail": "prevista {planned} · assunta {taken}", + "status": { + "taken_on_time": "puntuale", + "taken_late": "in ritardo", + "skipped": "saltata", + "missed": "mancata", + "upcoming": "in attesa", + "ad_hoc": "fuori programma" + }, + "addDialog": { + "title": "Aggiungi assunzione", + "description": "Registra una dose, anche retroattiva o fuori programma.", + "takenAtLabel": "Assunta alle", + "skippedLabel": "Saltata", + "nudgeHint": "Vicina allo slot {slot}: al salvataggio chiediamo se attribuirla.", + "cancel": "Annulla", + "submit": "Salva" + }, + "nudge": { + "title": "Attribuire a questo slot?", + "body": "Questa assunzione cade appena fuori dalla finestra di {slot}. Attribuirla a quello slot o tenerla come voce fuori programma?", + "keepAdHoc": "Fuori programma", + "pin": "Attribuisci" + } + }, "notifications": { "title": "Promemoria", "switchLabel": "Invia un promemoria quando questa dose è dovuta", diff --git a/messages/pl.json b/messages/pl.json index 74c8e68f3..b51a72471 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1338,6 +1338,42 @@ "failed": "Nie udało się usunąć przyjęcia." } }, + "verlauf": { + "loading": "Wczytywanie historii …", + "loadError": "Nie udało się wczytać historii.", + "empty": "Brak przyjęć w tym okresie.", + "summaryEmpty": "Brak dawek do oceny.", + "adherenceLabel": "Przestrzeganie", + "adherenceBarLabel": "{onTime} na czas, {late} z opóźnieniem, {missed} pominięte", + "add": "Wpis", + "markTaken": "Przyjęte", + "markSkipped": "Pominięte", + "adHocTag": "poza planem", + "takenDetail": "planowo {planned} · przyjęto {taken}", + "status": { + "taken_on_time": "na czas", + "taken_late": "z opóźnieniem", + "skipped": "pominięte", + "missed": "nieprzyjęte", + "upcoming": "oczekujące", + "ad_hoc": "poza planem" + }, + "addDialog": { + "title": "Dodaj przyjęcie", + "description": "Zarejestruj dawkę — także wstecznie lub poza planem.", + "takenAtLabel": "Przyjęto o", + "skippedLabel": "Pominięte", + "nudgeHint": "Blisko slotu {slot} — przy zapisie zapytamy, czy przypisać.", + "cancel": "Anuluj", + "submit": "Zapisz" + }, + "nudge": { + "title": "Przypisać do tego slotu?", + "body": "To przyjęcie wypada tuż poza oknem {slot}. Przypisać je do tego slotu czy zachować jako wpis poza planem?", + "keepAdHoc": "Poza planem", + "pin": "Przypisz" + } + }, "notifications": { "title": "Przypomnienia", "switchLabel": "Wyślij przypomnienie, gdy nadejdzie czas tej dawki", diff --git a/src/components/medications/__tests__/dose-history-add-dialog.test.ts b/src/components/medications/__tests__/dose-history-add-dialog.test.ts new file mode 100644 index 000000000..899951c3a --- /dev/null +++ b/src/components/medications/__tests__/dose-history-add-dialog.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; + +import { + classifyTake, + nearestSlotForTake, +} from "@/components/medications/dose-history-add-dialog"; + +/** + * v1.15.18 WE — the late-take nudge classifier behind the Verlauf add dialog. + * A take inside the ±1h on-time window pins to its slot directly; a near-miss + * (≤4h gap) prompts "diesem Slot zuordnen?"; anything further is ad-hoc; a PRN + * med (no times) is always ad-hoc. + */ + +describe("nearestSlotForTake", () => { + it("finds the closest slot on the take's own day with the gap in minutes", () => { + const near = nearestSlotForTake("2026-06-01T08:30", ["07:00", "19:00"]); + expect(near?.slotHm).toBe("07:00"); + expect(near?.gapMinutes).toBe(90); + }); + + it("returns null for a PRN medication with no time-anchored slots", () => { + expect(nearestSlotForTake("2026-06-01T08:30", [])).toBeNull(); + }); +}); + +describe("classifyTake", () => { + it("treats an in-window take (≤60 min) as an exact slot match", () => { + expect( + classifyTake(nearestSlotForTake("2026-06-01T07:30", ["07:00"])), + ).toBe("exact"); + }); + + it("prompts the nudge for a near-miss take (61–240 min off the slot)", () => { + expect( + classifyTake(nearestSlotForTake("2026-06-01T09:00", ["07:00"])), + ).toBe("nudge"); + }); + + it("records ad-hoc when the take is far from every slot", () => { + expect( + classifyTake(nearestSlotForTake("2026-06-01T14:00", ["07:00"])), + ).toBe("ad_hoc"); + }); + + it("records ad-hoc for a PRN medication (no slots)", () => { + expect(classifyTake(null)).toBe("ad_hoc"); + }); +}); diff --git a/src/components/medications/__tests__/dose-history-ledger-compute.test.ts b/src/components/medications/__tests__/dose-history-ledger-compute.test.ts new file mode 100644 index 000000000..a58de133c --- /dev/null +++ b/src/components/medications/__tests__/dose-history-ledger-compute.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from "vitest"; + +import { + applyOptimisticSlotMark, + complianceFromLedger, + groupLedgerByDay, + isSlotActionable, + type LedgerPayload, + type LedgerRow, +} from "@/components/medications/dose-history-ledger-compute"; + +/** + * v1.15.18 WE — the pure helpers behind the Verlauf ledger. These cover the + * instant-recompute contract (the optimistic % must track the ledger the user + * sees), the day grouping, the actionability guard, and the optimistic-mark + * mutation that flips a slot before the server round-trip. + */ + +function slotRow( + at: string, + status: LedgerRow["status"], + intake?: LedgerRow["intake"], +): LedgerRow { + return { kind: "slot", at, timeOfDay: "07:00", status, intake: intake ?? null }; +} + +describe("complianceFromLedger", () => { + it("counts taken (on-time + late) over taken + missed, excluding skip/ad-hoc/upcoming", () => { + const rows: LedgerRow[] = [ + slotRow("2026-06-01T05:00:00.000Z", "taken_on_time"), + slotRow("2026-06-02T05:00:00.000Z", "taken_late"), + slotRow("2026-06-03T05:00:00.000Z", "missed"), + slotRow("2026-06-04T05:00:00.000Z", "skipped"), + slotRow("2026-06-05T05:00:00.000Z", "upcoming"), + { + kind: "ad_hoc", + at: "2026-06-06T12:00:00.000Z", + timeOfDay: null, + status: "ad_hoc", + intake: null, + }, + ]; + const c = complianceFromLedger(rows); + expect(c.takenOnTime).toBe(1); + expect(c.takenLate).toBe(1); + expect(c.missed).toBe(1); + expect(c.denominator).toBe(3); // 2 taken + 1 missed + expect(c.rate).toBe(67); // round(2/3 * 100) + }); + + it("returns a null rate when nothing counts yet (only upcoming / skipped)", () => { + const rows: LedgerRow[] = [ + slotRow("2026-06-05T05:00:00.000Z", "upcoming"), + slotRow("2026-06-04T05:00:00.000Z", "skipped"), + ]; + expect(complianceFromLedger(rows).rate).toBeNull(); + }); + + it("caps the rate at 100", () => { + const rows: LedgerRow[] = [ + slotRow("2026-06-01T05:00:00.000Z", "taken_on_time"), + slotRow("2026-06-02T05:00:00.000Z", "taken_on_time"), + ]; + expect(complianceFromLedger(rows).rate).toBe(100); + }); +}); + +describe("groupLedgerByDay", () => { + it("groups by local day, most-recent day first, chronological within a day", () => { + const rows: LedgerRow[] = [ + slotRow("2026-06-02T17:00:00.000Z", "taken_on_time"), // 19:00 Berlin + slotRow("2026-06-01T05:00:00.000Z", "taken_on_time"), // 07:00 Berlin + slotRow("2026-06-02T05:00:00.000Z", "missed"), // 07:00 Berlin + ]; + const groups = groupLedgerByDay(rows, "Europe/Berlin"); + expect(groups.map((g) => g.dayKey)).toEqual(["2026-06-02", "2026-06-01"]); + // Within 2026-06-02 the 07:00 row precedes the 19:00 row. + expect(groups[0].rows.map((r) => r.at)).toEqual([ + "2026-06-02T05:00:00.000Z", + "2026-06-02T17:00:00.000Z", + ]); + }); + + it("keeps a late-evening dose on its own local day, not the next UTC day", () => { + // 23:30 Berlin on 2026-06-01 is 21:30 UTC the same day. + const rows: LedgerRow[] = [ + slotRow("2026-06-01T21:30:00.000Z", "taken_late"), + ]; + const groups = groupLedgerByDay(rows, "Europe/Berlin"); + expect(groups[0].dayKey).toBe("2026-06-01"); + }); +}); + +describe("isSlotActionable", () => { + it("is true only for unfilled slot rows (upcoming / missed)", () => { + expect(isSlotActionable(slotRow("x", "upcoming"))).toBe(true); + expect(isSlotActionable(slotRow("x", "missed"))).toBe(true); + expect(isSlotActionable(slotRow("x", "taken_on_time"))).toBe(false); + expect(isSlotActionable(slotRow("x", "skipped"))).toBe(false); + expect( + isSlotActionable({ + kind: "ad_hoc", + at: "x", + timeOfDay: null, + status: "ad_hoc", + intake: null, + }), + ).toBe(false); + }); +}); + +describe("applyOptimisticSlotMark", () => { + const payload: LedgerPayload = { + from: "2026-06-01T00:00:00.000Z", + to: "2026-06-02T00:00:00.000Z", + family: "daily", + hasExpectedSlots: true, + rows: [ + slotRow("2026-06-01T05:00:00.000Z", "missed"), + slotRow("2026-06-01T17:00:00.000Z", "upcoming"), + ], + }; + + it("flips the matched missed slot to taken_on_time with a synthetic intake, bumping the rate instantly", () => { + expect(complianceFromLedger(payload.rows).rate).toBe(0); // 0 taken / 1 missed + const next = applyOptimisticSlotMark( + payload, + "2026-06-01T05:00:00.000Z", + "taken", + ); + const marked = next.rows.find( + (r) => r.at === "2026-06-01T05:00:00.000Z", + )!; + expect(marked.status).toBe("taken_on_time"); + expect(marked.intake?.takenAt).toBe("2026-06-01T05:00:00.000Z"); + expect(marked.intake?.skipped).toBe(false); + expect(complianceFromLedger(next.rows).rate).toBe(100); + }); + + it("flips a slot to skipped without entering the denominator", () => { + const next = applyOptimisticSlotMark( + payload, + "2026-06-01T05:00:00.000Z", + "skipped", + ); + const marked = next.rows.find( + (r) => r.at === "2026-06-01T05:00:00.000Z", + )!; + expect(marked.status).toBe("skipped"); + expect(marked.intake?.skipped).toBe(true); + // skip excluded → denominator drops to 0 → null rate. + expect(complianceFromLedger(next.rows).rate).toBeNull(); + }); + + it("leaves an already-filled (non-actionable) slot untouched", () => { + const filled: LedgerPayload = { + ...payload, + rows: [slotRow("2026-06-01T05:00:00.000Z", "taken_late")], + }; + const next = applyOptimisticSlotMark( + filled, + "2026-06-01T05:00:00.000Z", + "skipped", + ); + expect(next.rows[0].status).toBe("taken_late"); + }); +}); diff --git a/src/components/medications/detail/MedicationDetailTabs.tsx b/src/components/medications/detail/MedicationDetailTabs.tsx index a1809c337..ac1f23485 100644 --- a/src/components/medications/detail/MedicationDetailTabs.tsx +++ b/src/components/medications/detail/MedicationDetailTabs.tsx @@ -37,7 +37,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { MedicationDetailSummary } from "@/components/medications/medication-detail-summary"; import { MedicationDetailSection } from "@/components/medications/medication-detail-section"; import { MedicationComplianceBars } from "@/components/medications/card-parts/medication-compliance-bars"; -import { IntakeHistoryEditable } from "@/components/medications/intake-history-editable"; +import { DoseHistoryLedger } from "@/components/medications/dose-history-ledger"; import { IntakeImportDialog } from "@/components/medications/intake-import-dialog"; import { InventorySection } from "@/components/medications/sections/inventory-section"; import { NotificationsBody } from "@/components/medications/sections/notifications-section"; @@ -342,9 +342,10 @@ export function MedicationDetailTabs({ - {/* VERLAUF — the existing editable intake history (the dose-history - ledger replaces it in the next wave). CSV import rides the - header ghost. */} + {/* VERLAUF — the dose-history ledger: every expected slot with its + status + ad-hoc takes tagged, inline Genommen/Übersprungen with + instant optimistic recompute, edit/add (incl. the late-take "diesem + Slot zuordnen?" nudge). CSV import rides the header ghost. */} } > - ({ + windowStart: s.windowStart, + label: s.label, + dose: s.dose, + timesOfDay: s.timesOfDay, + }))} /> diff --git a/src/components/medications/dose-history-add-dialog.tsx b/src/components/medications/dose-history-add-dialog.tsx new file mode 100644 index 000000000..a5390dd6e --- /dev/null +++ b/src/components/medications/dose-history-add-dialog.tsx @@ -0,0 +1,331 @@ +"use client"; + +/** + * v1.15.18 WE — add-intake dialog scoped to one medication, reached from the + * Verlauf tab header. A trimmed sibling of `` (which spans + * every active medication from the list page): this one is pre-bound to the + * medication whose ledger the user is editing, so it drops the medication + * picker and adds the late-take nudge. + * + * The nudge (Marc decision): when the picked `takenAt` falls JUST OUTSIDE a + * slot's on-time window — close enough that it is plausibly that dose, but + * past the ±1h default band — the dialog offers "diesem Slot zuordnen?". On + * accept the write carries `forceSlotInstant` (the nearest slot's instant on + * the chosen day), pinning the take onto that scheduled slot instead of + * orphaning it to an ad-hoc row. The server validates the instant is a real + * slot (422 otherwise, surfaced as a toast). Decline → the take records + * ad-hoc (no `scheduledFor`); an exact slot match pins directly with no nudge. + */ + +import { useMemo, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { useTranslations } from "@/lib/i18n/context"; +import { + invalidateKeys, + medicationDependentKeys, +} from "@/lib/query-keys"; +import type { QueryKey } from "@tanstack/react-query"; +import type { LedgerSchedule } from "@/components/medications/dose-history-ledger"; + +export interface LedgerAddDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + medicationId: string; + medicationName: string; + schedules: LedgerSchedule[]; + /** The Verlauf tab's ledger key so the add invalidates it alongside the + * shared medication bundle. */ + ledgerKey: QueryKey; +} + +/** The default on-time half-window in minutes (±1h, `DOSE_WINDOW_DEFAULTS`). A + * take within this of a slot is an exact match (no nudge); past it but inside + * the nudge ceiling triggers "diesem Slot zuordnen?". */ +const ON_TIME_MINUTES = 60; +/** Past this gap a take is clearly its own ad-hoc event — no nudge. */ +const NUDGE_CEILING_MINUTES = 240; + +/** `` shape for a Date in local time. */ +function toDateTimeLocal(d: Date): string { + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad( + d.getHours(), + )}:${pad(d.getMinutes())}`; +} + +/** Distinct HH:mm slot times across the medication's schedules. */ +function slotTimes(schedules: LedgerSchedule[]): string[] { + const times = new Set(); + for (const s of schedules) { + if (s.timesOfDay && s.timesOfDay.length > 0) { + for (const tod of s.timesOfDay) times.add(tod); + } else if (s.windowStart) { + times.add(s.windowStart); + } + } + return Array.from(times).sort((a, b) => a.localeCompare(b)); +} + +/** The slot instant on the take's local day for an "HH:mm" slot. */ +function slotInstantOnDay(takenAtLocal: string, slotHm: string): Date | null { + const datePart = takenAtLocal.slice(0, 10); + if (datePart.length !== 10) return null; + const d = new Date(`${datePart}T${slotHm}`); + return Number.isNaN(d.getTime()) ? null : d; +} + +interface NearestSlot { + slotHm: string; + instant: Date; + gapMinutes: number; +} + +/** The nearest schedule slot to the picked `takenAt` on its own day, with the + * absolute gap in minutes. `null` when the medication has no time-anchored + * slots (PRN) or the input is unparseable. */ +export function nearestSlotForTake( + takenAtLocal: string, + times: string[], +): NearestSlot | null { + const taken = new Date(takenAtLocal); + if (Number.isNaN(taken.getTime())) return null; + let best: NearestSlot | null = null; + for (const slotHm of times) { + const instant = slotInstantOnDay(takenAtLocal, slotHm); + if (!instant) continue; + const gapMinutes = Math.abs(instant.getTime() - taken.getTime()) / 60_000; + if (!best || gapMinutes < best.gapMinutes) { + best = { slotHm, instant, gapMinutes }; + } + } + return best; +} + +/** Classify the take against its nearest slot: an exact in-window match pins + * directly; a near-miss prompts the nudge; anything else records ad-hoc. */ +export function classifyTake( + nearest: NearestSlot | null, +): "exact" | "nudge" | "ad_hoc" { + if (!nearest) return "ad_hoc"; + if (nearest.gapMinutes <= ON_TIME_MINUTES) return "exact"; + if (nearest.gapMinutes <= NUDGE_CEILING_MINUTES) return "nudge"; + return "ad_hoc"; +} + +export function LedgerAddDialog({ + open, + onOpenChange, + medicationId, + medicationName, + schedules, + ledgerKey, +}: LedgerAddDialogProps) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + + const [takenAt, setTakenAt] = useState(() => toDateTimeLocal(new Date())); + const [skipped, setSkipped] = useState(false); + const [busy, setBusy] = useState(false); + const [nudgeOpen, setNudgeOpen] = useState(false); + + const times = useMemo(() => slotTimes(schedules), [schedules]); + const nearest = useMemo( + () => (skipped ? null : nearestSlotForTake(takenAt, times)), + [skipped, takenAt, times], + ); + const classification = useMemo(() => classifyTake(nearest), [nearest]); + + /** Fire the write. `pin` true sends `forceSlotInstant` to bind the take onto + * the nearest slot; false/exact uses `scheduledFor` for an in-window match, + * else records ad-hoc. */ + async function write(pin: boolean) { + if (busy) return; + setBusy(true); + try { + const body: Record = { skipped }; + if (!skipped) body.takenAt = new Date(takenAt).toISOString(); + if (!skipped && nearest) { + if (classification === "exact") { + body.scheduledFor = nearest.instant.toISOString(); + } else if (pin) { + body.forceSlotInstant = nearest.instant.toISOString(); + } + } + const res = await fetch(`/api/medications/${medicationId}/intake`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + // A 422 here is the force-slot guard rejecting a non-slot instant; + // surface it rather than silently dropping the dose. + toast.error( + t("medications.intakeToastFailed", { name: medicationName }), + ); + return; + } + toast.success( + t( + skipped + ? "medications.intakeToastSkipped" + : "medications.intakeToastTaken", + { name: medicationName }, + ), + ); + await invalidateKeys(queryClient, [...medicationDependentKeys, ledgerKey]); + setNudgeOpen(false); + onOpenChange(false); + } catch { + toast.error(t("medications.intakeToastFailed", { name: medicationName })); + } finally { + setBusy(false); + } + } + + function submit() { + // A near-miss take prompts the slot-attribution nudge before writing; + // everything else writes straight through. + if (!skipped && classification === "nudge") { + setNudgeOpen(true); + return; + } + void write(false); + } + + return ( + !busy && onOpenChange(o)}> + + + + {t("medications.detail.verlauf.addDialog.title")} + + + {t("medications.detail.verlauf.addDialog.description")} + + + +
+
+ + setTakenAt(e.target.value)} + disabled={skipped} + /> +
+ + + + {/* A near-miss take previews where it will land before the user + commits — calm, not alarming. */} + {!skipped && classification === "nudge" && nearest && ( +

+ {t("medications.detail.verlauf.addDialog.nudgeHint", { + slot: nearest.slotHm, + })} +

+ )} +
+ + + + + + + {/* The "diesem Slot zuordnen?" nudge — pin onto the slot, or keep the + take as a standalone ad-hoc entry. */} + {nudgeOpen && nearest && ( + !busy && setNudgeOpen(o)} + > + + + + {t("medications.detail.verlauf.nudge.title")} + + + {t("medications.detail.verlauf.nudge.body", { + slot: nearest.slotHm, + })} + + + + + + + + + )} +
+
+ ); +} diff --git a/src/components/medications/dose-history-ledger-compute.ts b/src/components/medications/dose-history-ledger-compute.ts new file mode 100644 index 000000000..ce0009456 --- /dev/null +++ b/src/components/medications/dose-history-ledger-compute.ts @@ -0,0 +1,176 @@ +/** + * v1.15.18 WE — pure helpers behind the Verlauf (dose-history) tab. + * + * The Verlauf tab renders the server-minted ledger + * (`GET /api/medications/[id]/dose-history`) and lets the user flip a slot + * Genommen / Übersprungen with INSTANT feedback. The instant feel comes from + * an optimistic local rebuild: on a tap the cached `rows` array is mutated + * synchronously and the headline compliance % is recomputed from those same + * rows in the SAME paint — only then does the server write fire and the + * authoritative refetch reconcile. The engines that mint the ledger are pure, + * so the client can mirror their status accounting without re-running the band + * minter it does not hold. + * + * Everything here is pure / synchronous / instant-free of React so it is unit- + * testable without a render (the repo convention — no `renderHook`). + */ + +import type { DoseHistoryStatus } from "@/lib/medications/scheduling/dose-history"; + +/** The serialised row shape the `dose-history` endpoint returns. */ +export interface LedgerRow { + kind: "slot" | "ad_hoc"; + /** Slot anchor (slot rows) or the ad-hoc take's own time, ISO. */ + at: string; + /** The slot's "HH:mm" label, or null for an ad-hoc row. */ + timeOfDay: string | null; + status: DoseHistoryStatus; + intake: { + id: string | null; + scheduledFor: string; + takenAt: string | null; + skipped: boolean; + autoMissed: boolean; + } | null; +} + +/** The full `data` envelope the endpoint returns. */ +export interface LedgerPayload { + from: string; + to: string; + family: "daily" | "weekly" | "one_shot" | "none"; + hasExpectedSlots: boolean; + rows: LedgerRow[]; +} + +/** + * Headline compliance derived from the ledger — the SAME accounting the + * server-side unification uses (audit CRITICAL-2): taken = on-time + late; + * denominator = taken + missed; skipped + ad-hoc + upcoming are excluded. + * Surfacing one rate from the ledger the user is looking at means the history + * view and the percentage can never contradict. + */ +export interface LedgerCompliance { + /** Whole-percent adherence (0–100), capped. `null` when nothing counts yet. */ + rate: number | null; + takenOnTime: number; + takenLate: number; + missed: number; + /** taken + missed — the doses that count toward the rate. */ + denominator: number; +} + +/** + * Compute the headline adherence from the ledger rows. Ad-hoc rows, skips, + * and still-takeable upcoming slots never enter the denominator, so a forward- + * looking window does not depress the rate. + */ +export function complianceFromLedger(rows: LedgerRow[]): LedgerCompliance { + let takenOnTime = 0; + let takenLate = 0; + let missed = 0; + for (const row of rows) { + if (row.status === "taken_on_time") takenOnTime += 1; + else if (row.status === "taken_late") takenLate += 1; + else if (row.status === "missed") missed += 1; + } + const taken = takenOnTime + takenLate; + const denominator = taken + missed; + const rate = + denominator > 0 ? Math.min(100, Math.round((taken / denominator) * 100)) : null; + return { rate, takenOnTime, takenLate, missed, denominator }; +} + +/** A day-grouped view of the ledger, most-recent day first. */ +export interface LedgerDayGroup { + /** `YYYY-MM-DD` in the user's locale day, for the heading + key. */ + dayKey: string; + /** Rows for that day, chronological (morning first). */ + rows: LedgerRow[]; +} + +/** + * Group rows by their local calendar day (most-recent day first; rows inside a + * day stay chronological so the morning slot leads). The day key is derived in + * the user's timezone via `Intl` so a 23:30 dose does not bleed into the next + * UTC day. + */ +export function groupLedgerByDay( + rows: LedgerRow[], + timeZone: string, +): LedgerDayGroup[] { + const dayKeyFmt = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + const byDay = new Map(); + for (const row of rows) { + const dayKey = dayKeyFmt.format(new Date(row.at)); + const bucket = byDay.get(dayKey); + if (bucket) bucket.push(row); + else byDay.set(dayKey, [row]); + } + const groups: LedgerDayGroup[] = Array.from(byDay.entries()).map( + ([dayKey, dayRows]) => ({ + dayKey, + rows: dayRows + .slice() + .sort((a, b) => new Date(a.at).getTime() - new Date(b.at).getTime()), + }), + ); + // Most-recent day first; chronological within the day above. + groups.sort((a, b) => (a.dayKey < b.dayKey ? 1 : a.dayKey > b.dayKey ? -1 : 0)); + return groups; +} + +/** + * Whether a slot row exposes a Genommen / Übersprungen quick action. A slot is + * actionable when it has no confirmed intake yet (`upcoming` / `missed`) — the + * user is filling in a pending or forgotten dose. A row that already carries a + * take / skip is edited via the row's Bearbeiten dialog, not the quick toggle. + */ +export function isSlotActionable(row: LedgerRow): boolean { + return ( + row.kind === "slot" && + (row.status === "upcoming" || row.status === "missed") + ); +} + +/** + * Optimistically apply a Genommen / Übersprungen tap to the cached payload so + * the row status + the headline % flip before the server round-trip. The + * matched slot (by `at`) gets a synthetic intake and the matching status; an + * already-claimed or non-slot row is returned untouched. The server write + + * the authoritative refetch reconcile afterwards, so any divergence (e.g. the + * take landing late rather than on-time) self-heals on the next read. + * + * `nowIso` is the optimistic `takenAt` for a Genommen tap (the slot's own + * anchor stands in for a clean on-time mark until the server re-attributes). + */ +export function applyOptimisticSlotMark( + payload: LedgerPayload, + slotAt: string, + action: "taken" | "skipped", +): LedgerPayload { + const rows = payload.rows.map((row) => { + if (row.kind !== "slot" || row.at !== slotAt) return row; + if (!isSlotActionable(row)) return row; + const status: DoseHistoryStatus = + action === "taken" ? "taken_on_time" : "skipped"; + return { + ...row, + status, + intake: { + // Optimistic placeholder id; replaced by the server row on refetch. + id: row.intake?.id ?? null, + scheduledFor: row.at, + takenAt: action === "taken" ? row.at : null, + skipped: action === "skipped", + autoMissed: false, + }, + }; + }); + return { ...payload, rows }; +} diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx new file mode 100644 index 000000000..d13ddd53e --- /dev/null +++ b/src/components/medications/dose-history-ledger.tsx @@ -0,0 +1,626 @@ +"use client"; + +/** + * v1.15.18 WE — the Verlauf tab's dose-history ledger. + * + * Renders the server-minted ledger (`GET /api/medications/[id]/dose-history`): + * EVERY expected slot with its status (pünktlich / verspätet / übersprungen / + * verpasst / ausstehend) even when never taken, plus every off-schedule intake + * as a tagged ad-hoc row at its real time. Rows are grouped by day, most-recent + * day first, chronological inside the day. + * + * Three interactions land here: + * - a pending / missed slot exposes Genommen / Übersprungen ON the row + * (~2 taps, not buried), which mark the dose with INSTANT feedback — the + * cached ledger + the headline % are mutated optimistically (the engines + * are pure, so the client mirrors their accounting) before the server + * write fires and the authoritative refetch reconciles; + * - any row with an intake can be edited (time / skipped) or deleted via the + * shared `` + delete confirm; + * - a header "+ Eintrag" opens an add dialog scoped to this medication + * (incl. backdated / off-schedule); an off-window take surfaces the + * "diesem Slot zuordnen?" nudge, pinning it onto a chosen slot via + * `forceSlotInstant` rather than orphaning it to an ad-hoc row. + * + * A compact took / skipped history chart sits at the top — the ledger already + * carries the counts. The card body never tints by status (removed earlier); + * the ledger uses the semantic feedback vocabulary on the row, not the card. + */ + +import { useCallback, useMemo, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + AlertCircle, + AlertTriangle, + Check, + CircleCheck, + Loader2, + Plus, + SkipForward, +} from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { useAuth } from "@/hooks/use-auth"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { + invalidateKeys, + medicationDependentKeys, + queryKeys, +} from "@/lib/query-keys"; +import type { DoseHistoryStatus } from "@/lib/medications/scheduling/dose-history"; +import { IntakeEditDialog } from "@/components/medications/intake-edit-dialog"; +import { LedgerAddDialog } from "@/components/medications/dose-history-add-dialog"; +import { + applyOptimisticSlotMark, + complianceFromLedger, + groupLedgerByDay, + isSlotActionable, + type LedgerPayload, + type LedgerRow, +} from "@/components/medications/dose-history-ledger-compute"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** A schedule slot the add dialog offers for pinning. */ +export interface LedgerSchedule { + windowStart: string; + label: string | null; + dose: string | null; + timesOfDay?: string[]; +} + +export interface DoseHistoryLedgerProps { + medicationId: string; + medicationName: string; + schedules: LedgerSchedule[]; + /** Trailing window in days the ledger renders. Defaults to 90. */ + windowDays?: number; +} + +/** Status → semantic colour token + glyph. Mirrors the card status pill's + * success / warning / destructive ramp so the surface reads as one vocabulary; + * skipped + upcoming are calm muted, ad-hoc is the neutral-positive accent. */ +const STATUS_TONE: Record = { + taken_on_time: "text-success", + taken_late: "text-warning", + skipped: "text-muted-foreground", + missed: "text-destructive", + upcoming: "text-muted-foreground", + ad_hoc: "text-primary", +}; + +function StatusGlyph({ status }: { status: DoseHistoryStatus }) { + const cls = "size-3.5 shrink-0"; + switch (status) { + case "taken_on_time": + return