mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
895c497f5d
commit
2ee488d541
13 files changed
+1594
-7
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user