feat(medications): wire the Verlauf tab onto the dose-history ledger

Replace the Verlauf tab's flat intake table with the per-slot dose-history
ledger the shared band engine mints. The tab now renders EVERY expected slot
with its status — on-time, late, skipped, missed, upcoming — even when the
dose was never taken, and tags every off-schedule intake as a standalone
ad-hoc row at its real time. Rows group by day, most-recent first.

Genommen / Übersprungen sit on the dose row and recompute instantly: the
cached ledger and the headline adherence are mutated optimistically (the
engines are pure, so the client mirrors their accounting) before the write
fires, then the dependent-key invalidation reconciles; a failed write rolls
the optimistic snapshot back. A header add dialog records a dose — including a
backdated or off-schedule one — and an off-window take surfaces the "diesem
Slot zuordnen?" nudge, pinning it onto a chosen slot via forceSlotInstant
rather than orphaning it. A compact took/skipped bar sits at the top of the
tab; the card body still never tints by status.

Adherence is derived from the same ledger the user is reading (taken over
taken + missed; skip, ad-hoc, upcoming excluded), so the history view and the
percentage cannot contradict. New status + dialog copy in all six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-06-08 19:23:25 +02:00
1 parent 895c497f5d
commit 2ee488d541
13 files changed
+1594 -7

No files matched your search

+36
View File
@@ -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",
+36
View File
@@ -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",
+36
View File
@@ -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",
+36
View File
@@ -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",
+36
View File
@@ -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",
+36
View File
@@ -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",
@@ -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");
});
});
@@ -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");
});
});
@@ -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({
<InventorySection medicationId={id} />
</TabsContent>
{/* 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. */}
<TabsContent value="verlauf" className="space-y-6 pt-2">
<MedicationDetailSection
titleId="medication-verlauf-heading"
@@ -362,10 +363,15 @@ export function MedicationDetailTabs({
</Button>
}
>
<IntakeHistoryEditable
<DoseHistoryLedger
medicationId={id}
pageSize={25}
defaultSortBy="scheduledFor"
medicationName={medication.name}
schedules={medication.schedules.map((s) => ({
windowStart: s.windowStart,
label: s.label,
dose: s.dose,
timesOfDay: s.timesOfDay,
}))}
/>
</MedicationDetailSection>
@@ -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 `<LogIntakeDialog>` (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;
/** `<input type="datetime-local">` 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<string>();
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<string, unknown> = { 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 (
<Dialog open={open} onOpenChange={(o) => !busy && onOpenChange(o)}>
<DialogContent data-slot="ledger-add-dialog">
<DialogHeader>
<DialogTitle>
{t("medications.detail.verlauf.addDialog.title")}
</DialogTitle>
<DialogDescription>
{t("medications.detail.verlauf.addDialog.description")}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1">
<Label htmlFor="ledger-add-taken-at">
{t("medications.detail.verlauf.addDialog.takenAtLabel")}
</Label>
<Input
id="ledger-add-taken-at"
type="datetime-local"
value={takenAt}
max={toDateTimeLocal(new Date())}
onChange={(e) => setTakenAt(e.target.value)}
disabled={skipped}
/>
</div>
<label
htmlFor="ledger-add-skipped"
className="flex items-center justify-between gap-3"
>
<span className="text-sm font-medium">
{t("medications.detail.verlauf.addDialog.skippedLabel")}
</span>
<Switch
id="ledger-add-skipped"
checked={skipped}
onCheckedChange={setSkipped}
/>
</label>
{/* A near-miss take previews where it will land before the user
commits — calm, not alarming. */}
{!skipped && classification === "nudge" && nearest && (
<p
className="text-muted-foreground text-xs"
data-slot="ledger-add-nudge-hint"
>
{t("medications.detail.verlauf.addDialog.nudgeHint", {
slot: nearest.slotHm,
})}
</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={busy}
>
{t("medications.detail.verlauf.addDialog.cancel")}
</Button>
<Button
onClick={submit}
disabled={busy}
aria-busy={busy || undefined}
data-slot="ledger-add-submit"
>
{busy && (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("medications.detail.verlauf.addDialog.submit")}
</Button>
</DialogFooter>
{/* The "diesem Slot zuordnen?" nudge — pin onto the slot, or keep the
take as a standalone ad-hoc entry. */}
{nudgeOpen && nearest && (
<Dialog
open={nudgeOpen}
onOpenChange={(o) => !busy && setNudgeOpen(o)}
>
<DialogContent data-slot="ledger-add-nudge-dialog">
<DialogHeader>
<DialogTitle>
{t("medications.detail.verlauf.nudge.title")}
</DialogTitle>
<DialogDescription>
{t("medications.detail.verlauf.nudge.body", {
slot: nearest.slotHm,
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => void write(false)}
disabled={busy}
data-slot="ledger-add-nudge-adhoc"
>
{t("medications.detail.verlauf.nudge.keepAdHoc")}
</Button>
<Button
onClick={() => void write(true)}
disabled={busy}
aria-busy={busy || undefined}
data-slot="ledger-add-nudge-pin"
>
{busy && (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("medications.detail.verlauf.nudge.pin")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</DialogContent>
</Dialog>
);
}
@@ -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<string, LedgerRow[]>();
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 };
}
@@ -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 `<IntakeEditDialog>` + 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<DoseHistoryStatus, string> = {
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 <CircleCheck aria-hidden="true" className={cls} />;
case "taken_late":
return <AlertCircle aria-hidden="true" className={cls} />;
case "missed":
return <AlertTriangle aria-hidden="true" className={cls} />;
case "ad_hoc":
return <Plus aria-hidden="true" className={cls} />;
case "skipped":
return <SkipForward aria-hidden="true" className={cls} />;
default:
return null;
}
}
export function DoseHistoryLedger({
medicationId,
medicationName,
schedules,
windowDays = 90,
}: DoseHistoryLedgerProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const { user } = useAuth();
const queryClient = useQueryClient();
const timeZone = user?.timezone || "Europe/Berlin";
const [addOpen, setAddOpen] = useState(false);
const [editingEvent, setEditingEvent] = useState<{
id: string;
takenAt: string | null;
skipped: boolean;
} | null>(null);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const [marking, setMarking] = useState<string | null>(null);
// Stable window captured once at mount (a lazy state initializer keeps the
// impure `Date.now()` out of render) so the query key + the `from`/`to`
// request stay byte-equal across renders rather than re-keying every paint.
const [{ fromIso, toIso }] = useState(() => {
const now = Date.now();
return {
toIso: new Date(now).toISOString(),
fromIso: new Date(now - windowDays * DAY_MS).toISOString(),
};
});
const queryKey = queryKeys.medicationDoseHistory(medicationId, fromIso, toIso);
const { data, isLoading, isError } = useQuery<LedgerPayload>({
queryKey,
queryFn: async () => {
const search = new URLSearchParams({ from: fromIso, to: toIso });
const res = await fetch(
`/api/medications/${medicationId}/dose-history?${search.toString()}`,
);
if (!res.ok) throw new Error("dose_history_failed");
return (await res.json()).data as LedgerPayload;
},
staleTime: 15_000,
});
const compliance = useMemo(
() => (data ? complianceFromLedger(data.rows) : null),
[data],
);
const groups = useMemo(
() => (data ? groupLedgerByDay(data.rows, timeZone) : []),
[data, timeZone],
);
/**
* Genommen / Übersprungen on a pending / missed slot. The cached ledger is
* mutated optimistically (row status + the headline % flip in this paint),
* then the server write fires and the dependent-key invalidation reconciles.
* A failed write rolls the optimistic snapshot back + toasts (mirrors the
* C1/C2 pattern in `use-medication-intake.ts`).
*/
const markSlot = useCallback(
async (row: LedgerRow, action: "taken" | "skipped") => {
if (marking || !isSlotActionable(row)) return;
setMarking(`${row.at}:${action}`);
const prev = queryClient.getQueryData<LedgerPayload>(queryKey);
if (prev) {
queryClient.setQueryData<LedgerPayload>(
queryKey,
applyOptimisticSlotMark(prev, row.at, action),
);
}
try {
const res = await fetch(`/api/medications/${medicationId}/intake`, {
method: "POST",
headers: { "Content-Type": "application/json" },
// Pin the displayed slot so the server marks THAT dose (the canonical
// slot upsert), not a now-snap to the nearest one.
body: JSON.stringify(
action === "skipped"
? { skipped: true, scheduledFor: row.at }
: {
skipped: false,
scheduledFor: row.at,
takenAt: new Date().toISOString(),
},
),
});
if (!res.ok) {
if (prev) queryClient.setQueryData(queryKey, prev);
toast.error(
t("medications.intakeToastFailed", { name: medicationName }),
);
return;
}
toast.success(
t(
action === "skipped"
? "medications.intakeToastSkipped"
: "medications.intakeToastTaken",
{ name: medicationName },
),
);
await invalidateKeys(queryClient, [
...medicationDependentKeys,
queryKey,
]);
} catch {
if (prev) queryClient.setQueryData(queryKey, prev);
toast.error(
t("medications.intakeToastFailed", { name: medicationName }),
);
} finally {
setMarking(null);
}
},
[marking, medicationId, medicationName, queryClient, queryKey, t],
);
async function confirmRowDelete() {
if (!pendingDeleteId) return;
const id = pendingDeleteId;
setPendingDeleteId(null);
try {
const res = await fetch(
`/api/medications/${medicationId}/intake/${id}`,
{ method: "DELETE" },
);
if (!res.ok) {
toast.error(t("medications.detail.intake.deleteRow.failed"));
return;
}
toast.success(t("medications.detail.intake.deleteRow.toast"));
await invalidateKeys(queryClient, [...medicationDependentKeys, queryKey]);
} catch {
toast.error(t("medications.detail.intake.deleteRow.failed"));
}
}
return (
<div className="space-y-4" data-slot="dose-history-ledger">
{/* Header: the took/skipped summary chart + the add affordance. */}
<div className="flex items-start justify-between gap-3">
{compliance && compliance.denominator > 0 ? (
<LedgerSummaryChart compliance={compliance} />
) : (
<p className="text-muted-foreground text-sm">
{t("medications.detail.verlauf.summaryEmpty")}
</p>
)}
<Button
variant="outline"
size="sm"
onClick={() => setAddOpen(true)}
className="min-h-11 shrink-0 sm:min-h-9"
data-slot="ledger-add"
>
<Plus aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.verlauf.add")}
</Button>
</div>
{isLoading && (
<div
className="text-muted-foreground flex items-center gap-2 py-6 text-sm"
role="status"
aria-busy="true"
>
<Loader2
aria-hidden="true"
className="size-4 animate-spin motion-reduce:animate-none"
/>
{t("medications.detail.verlauf.loading")}
</div>
)}
{isError && (
<p className="text-destructive py-6 text-sm">
{t("medications.detail.verlauf.loadError")}
</p>
)}
{data && !isLoading && groups.length === 0 && (
<p className="text-muted-foreground py-6 text-sm">
{t("medications.detail.verlauf.empty")}
</p>
)}
{data &&
groups.map((group) => (
<section
key={group.dayKey}
className="space-y-1"
data-slot="ledger-day"
data-day={group.dayKey}
>
<h3 className="text-muted-foreground px-1 text-xs font-medium">
{fmt.dateWithWeekday(new Date(`${group.dayKey}T00:00:00`))}
</h3>
<ul className="border-border/60 divide-border/60 divide-y rounded-md border">
{group.rows.map((row, i) => (
<LedgerRowItem
key={`${row.kind}:${row.at}:${row.intake?.id ?? i}`}
row={row}
marking={marking}
onMark={markSlot}
onEdit={(ev) => setEditingEvent(ev)}
onDelete={(id) => setPendingDeleteId(id)}
/>
))}
</ul>
</section>
))}
<IntakeEditDialog
medicationId={medicationId}
event={editingEvent}
onClose={() => setEditingEvent(null)}
/>
{addOpen && (
<LedgerAddDialog
open={addOpen}
onOpenChange={setAddOpen}
medicationId={medicationId}
medicationName={medicationName}
schedules={schedules}
ledgerKey={queryKey}
/>
)}
{pendingDeleteId && (
<DeleteRowConfirm
onConfirm={() => void confirmRowDelete()}
onCancel={() => setPendingDeleteId(null)}
/>
)}
</div>
);
}
/**
* One ledger row: time + status pill + (when an intake is attributed) its real
* take time, plus the inline actions. A pending/missed slot offers Genommen /
* Übersprungen; a row with an intake offers Bearbeiten / Löschen.
*/
function LedgerRowItem({
row,
marking,
onMark,
onEdit,
onDelete,
}: {
row: LedgerRow;
marking: string | null;
onMark: (row: LedgerRow, action: "taken" | "skipped") => void;
onEdit: (event: {
id: string;
takenAt: string | null;
skipped: boolean;
}) => void;
onDelete: (id: string) => void;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const actionable = isSlotActionable(row);
const intake = row.intake;
const takeBusy = marking === `${row.at}:taken`;
const skipBusy = marking === `${row.at}:skipped`;
// The leading time label: the slot's HH:mm for a slot row, else the ad-hoc
// take's real clock time.
const timeLabel =
row.timeOfDay ?? fmt.time(new Date(intake?.takenAt ?? row.at));
// When a take lands away from its slot, show both anchors so the row reads
// "geplant 07:00 · genommen 11:29".
const showTakeDetail =
row.kind === "slot" &&
intake?.takenAt &&
(row.status === "taken_on_time" || row.status === "taken_late");
return (
<li
className="flex items-center justify-between gap-2 px-3 py-2"
data-slot="ledger-row"
data-status={row.status}
data-kind={row.kind}
>
<div className="min-w-0 space-y-0.5">
<div className="flex items-center gap-2">
<span className="text-foreground text-sm font-medium tabular-nums">
{timeLabel}
</span>
<span
className={`inline-flex items-center gap-1 text-xs font-medium ${STATUS_TONE[row.status]}`}
data-slot="ledger-status"
>
<StatusGlyph status={row.status} />
{t(`medications.detail.verlauf.status.${row.status}`)}
</span>
{row.kind === "ad_hoc" && (
<Badge variant="outline" className="text-[0.65rem]">
{t("medications.detail.verlauf.adHocTag")}
</Badge>
)}
</div>
{showTakeDetail && (
<p className="text-muted-foreground text-xs">
{t("medications.detail.verlauf.takenDetail", {
planned: row.timeOfDay ?? fmt.time(new Date(row.at)),
taken: fmt.time(new Date(intake!.takenAt!)),
})}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{actionable && (
<>
<Button
variant="outline"
size="sm"
onClick={() => onMark(row, "taken")}
disabled={takeBusy || skipBusy}
aria-busy={takeBusy || undefined}
className="min-h-9"
data-slot="ledger-mark-taken"
>
{takeBusy ? (
<Loader2
aria-hidden="true"
className="size-4 animate-spin motion-reduce:animate-none"
/>
) : (
<Check aria-hidden="true" className="size-4" />
)}
<span className="hidden sm:inline">
{t("medications.detail.verlauf.markTaken")}
</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onMark(row, "skipped")}
disabled={takeBusy || skipBusy}
aria-busy={skipBusy || undefined}
className="min-h-9"
data-slot="ledger-mark-skipped"
>
{skipBusy ? (
<Loader2
aria-hidden="true"
className="size-4 animate-spin motion-reduce:animate-none"
/>
) : (
<SkipForward aria-hidden="true" className="size-4" />
)}
<span className="hidden sm:inline">
{t("medications.detail.verlauf.markSkipped")}
</span>
</Button>
</>
)}
{intake?.id && (
<>
<Button
variant="ghost"
size="sm"
onClick={() =>
onEdit({
id: intake.id as string,
takenAt: intake.takenAt,
skipped: intake.skipped,
})
}
className="min-h-9"
data-slot="ledger-edit"
>
{t("medications.detail.intake.rowActions.edit")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(intake.id as string)}
className="text-destructive min-h-9"
data-slot="ledger-delete"
>
{t("medications.detail.intake.rowActions.delete")}
</Button>
</>
)}
</div>
</li>
);
}
/**
* Compact took / skipped history chart — a single proportional bar split into
* on-time / late / missed, with the headline rate beside it. No Recharts: a
* three-segment CSS bar is calmer + lighter for a summary this small, and it
* tracks the ledger the user is already reading.
*/
function LedgerSummaryChart({
compliance,
}: {
compliance: NonNullable<ReturnType<typeof complianceFromLedger>>;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const { takenOnTime, takenLate, missed, denominator, rate } = compliance;
const pct = (n: number) => (denominator > 0 ? (n / denominator) * 100 : 0);
return (
<div className="min-w-0 flex-1 space-y-1.5" data-slot="ledger-summary">
<div className="flex items-baseline justify-between gap-2">
<span className="text-muted-foreground text-xs font-medium">
{t("medications.detail.verlauf.adherenceLabel")}
</span>
<span className="text-foreground text-sm font-semibold tabular-nums">
{rate !== null ? `${fmt.number(rate)}%` : "—"}
</span>
</div>
<div
className="bg-muted flex h-2 overflow-hidden rounded-full"
role="img"
aria-label={t("medications.detail.verlauf.adherenceBarLabel", {
onTime: takenOnTime,
late: takenLate,
missed,
})}
>
<div
className="bg-success h-full"
style={{ width: `${pct(takenOnTime)}%` }}
/>
<div
className="bg-warning h-full"
style={{ width: `${pct(takenLate)}%` }}
/>
<div
className="bg-destructive h-full"
style={{ width: `${pct(missed)}%` }}
/>
</div>
<div className="text-muted-foreground flex flex-wrap gap-x-3 gap-y-0.5 text-[0.7rem]">
<span className="text-success">
{t("medications.detail.verlauf.status.taken_on_time")}: {takenOnTime}
</span>
<span className="text-warning">
{t("medications.detail.verlauf.status.taken_late")}: {takenLate}
</span>
<span className="text-destructive">
{t("medications.detail.verlauf.status.missed")}: {missed}
</span>
</div>
</div>
);
}
/** Lightweight single-row delete confirm — the ledger does not need the full
* bulk-delete state machine, just a guard. */
function DeleteRowConfirm({
onConfirm,
onCancel,
}: {
onConfirm: () => void;
onCancel: () => void;
}) {
const { t } = useTranslations();
return (
<AlertDialog open onOpenChange={(open) => !open && onCancel()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("medications.detail.intake.deleteRow.confirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("medications.detail.intake.deleteRow.confirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("medications.detail.intake.bulkDelete.cancelButton")}
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
onConfirm();
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t("medications.detail.intake.deleteRow.confirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
+16
View File
@@ -272,6 +272,22 @@ export const queryKeys = {
*/
medicationInventory: (medicationId: string) =>
["medications", medicationId, "inventory"] as const,
/**
* v1.15.18 — per-medication dose-history ledger
* (`GET /api/medications/[id]/dose-history?from=&to=`), consumed by the
* detail page's Verlauf tab. Keyed by the `(from, to)` window so paging the
* range caches each span independently; rides under the `["medications"]`
* prefix so an intake mutation (take / skip / edit / delete) reaches it
* through `medicationDependentKeys`. The Verlauf tab also mutates this slot
* optimistically on Genommen / Übersprungen so the row status + the
* Übersicht headline % flip in the same paint, then reconciles on the
* authoritative refetch.
*/
medicationDoseHistory: (
medicationId: string,
from: string,
to: string,
) => ["medications", medicationId, "dose-history", from, to] as const,
medicationIntakeDrugLevelChart: (medicationId: string) =>
["medications", medicationId, "intake", "drug-level-chart"] as const,
/**