refactor(medications): split the dose-history ledger into its hook, row and dialogs

The ledger file held two components of 404 and 314 lines. It now reads:

- dose-history/use-dose-history-ledger: the window read, summary, day
  groups and the optimistic mark, plus the owner's edits (toggle, pin,
  release, delete) and one reconcile step after every write
- dose-history/ledger-row-item: the row and, apart from it, the owner's
  row menu
- dose-history/ledger-summary-chart and ledger-confirms

dose-history-ledger.tsx keeps the ledger layout. Markup and behaviour
are unchanged; the guards name the modules that now hold what they
check.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 12:41:46 +02:00
1 parent 3eb5aa9364
commit 58215f3338
14 files changed
+1008 -849

No files matched your search

@@ -1,7 +1,7 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 118,
"total": 136542,
"count": 117,
"total": 135490,
"entries": {
"app/api/dashboard/summary/route.ts": 1032,
"app/api/documents/inbound/route.ts": 811,
@@ -26,7 +26,6 @@
"components/measurement-reminders/vorsorge-section.tsx": 1536,
"components/measurements/measurement-form.tsx": 815,
"components/medications/detail/medication-detail-tabs.tsx": 1160,
"components/medications/dose-history-ledger.tsx": 1052,
"components/medications/medication-table.tsx": 853,
"components/medications/wizard/wizard-payload.ts": 1414,
"components/mood/mood-list.tsx": 1408,
@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 405,
"total": 140305,
"count": 403,
"total": 139587,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314,
@@ -183,8 +183,6 @@
"components/medications/detail/efficacy/efficacy-chart.tsx :: EfficacyChart": 361,
"components/medications/detail/medication-detail-tabs.tsx :: MedicationDetailTabs": 771,
"components/medications/dose-history-add-dialog.tsx :: LedgerAddDialog": 223,
"components/medications/dose-history-ledger.tsx :: DoseHistoryLedger": 404,
"components/medications/dose-history-ledger.tsx :: LedgerRowItem": 314,
"components/medications/dose-strength-curve.tsx :: DoseStrengthCurve": 266,
"components/medications/glp1-medication-card.tsx :: Glp1MedicationCard": 405,
"components/medications/injection-site-picker.tsx :: InjectionSitePicker": 287,
@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
"count": 2950,
"total": 10294,
"count": 2949,
"total": 10287,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -1171,7 +1171,6 @@
"components/medications/detail/medication-detail-tabs.tsx": 28,
"components/medications/dose-history-add-dialog.tsx": 2,
"components/medications/dose-history-ledger-compute.ts": 5,
"components/medications/dose-history-ledger.tsx": 7,
"components/medications/dose-strength-curve.tsx": 2,
"components/medications/drug-level-chart.tsx": 2,
"components/medications/glp1-medication-card.tsx": 36,
@@ -242,7 +242,7 @@ describe("daily-reads refetch guard", () => {
expect(read(rel)).toMatch(/invalidateMedicationReads\(/);
}
const pairInPlace = [
"components/medications/dose-history-ledger.tsx",
"components/medications/dose-history/use-dose-history-ledger.ts",
"components/medications/dose-history-add-dialog.tsx",
];
for (const rel of pairInPlace) {
@@ -252,7 +252,7 @@ const RAW_ALLOWLIST: ReadonlyArray<{ file: string; why: string }> = [
why: "The `yyyy-MM-dd` key the ledger groups doses by. The heading a reader sees is rendered separately, through the preference-aware `formatDateWithWeekdaySmart`.",
},
{
file: "src/components/medications/dose-history-ledger.tsx",
file: "src/components/medications/dose-history/use-dose-history-ledger.ts",
why: "The same key shape for `today`, compared against those buckets. A comparison, not a label.",
},
{
@@ -524,8 +524,9 @@ const REGISTRY: DestructiveEntry[] = [
confirm: ["AlertDialog"],
},
{
file: "components/medications/dose-history-ledger.tsx",
file: "components/medications/dose-history/use-dose-history-ledger.ts",
destroys: "one intake event",
triggers: ["components/medications/dose-history/ledger-confirms.tsx"],
recovery: "tombstoned-no-restore",
confirm: ["AlertDialog"],
},
@@ -151,7 +151,7 @@ describe("the intake confirmation is decided in one place", () => {
expect(posting.length).toBeGreaterThan(2);
for (const rel of [
"components/medications/use-medication-intake.ts",
"components/medications/dose-history-ledger.tsx",
"components/medications/dose-history/use-dose-history-ledger.ts",
"components/dashboard/medication-intake-quick-add.tsx",
"components/medications/dose-history-add-dialog.tsx",
]) {
@@ -248,8 +248,13 @@ const PINNED_AFFORDANCES: Record<
"src/components/medications/dose-history-add-dialog.tsx": {
"toast.success": 2,
},
"src/components/medications/dose-history-ledger.tsx": {
"text-success": 2,
"src/components/medications/dose-history/ledger-row-item.tsx": {
"text-success": 1,
},
"src/components/medications/dose-history/ledger-summary-chart.tsx": {
"text-success": 1,
},
"src/components/medications/dose-history/use-dose-history-ledger.ts": {
"toast.success": 4,
},
"src/components/medications/intake-edit-dialog.tsx": { "toast.success": 1 },
@@ -185,7 +185,7 @@ import { admittedQuickEntry } from "@/components/dashboard/quick-entry-sheets";
import { TodayHero } from "@/components/daily/today-hero";
import { VorsorgeDashboardCard } from "@/components/measurement-reminders/vorsorge-dashboard-card";
import { EpisodeDocumentsCard } from "@/components/documents/episode-documents-card";
import { LedgerRowItem } from "@/components/medications/dose-history-ledger";
import { LedgerRowItem } from "@/components/medications/dose-history/ledger-row-item";
import { VaccinationsView } from "@/components/vaccinations/vaccinations-view";
import { VisitsSection } from "@/components/encounters/visits-section";
import type { EncounterDTO } from "@/lib/encounters/dto";
@@ -1,7 +1,7 @@
"use client";
/**
* v1.15.18 WE — the Verlauf tab's dose-history ledger.
* 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 /
@@ -24,13 +24,13 @@
* (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;
* - v1.15.20: an ad-hoc row shows WHEN the dose would have been due
* - an ad-hoc row shows WHEN the dose would have been due
* (`nearestSlot` subline) and — when that slot is still unserved — a
* kebab "Slot … zuordnen" action that pins the take onto it after a
* confirm that names the consequence ("zählt als verspätet"). A pinned
* slot row carries a quiet "zugeordnet" badge and the inverse
* "Zuordnung lösen" kebab action (`forceSlotInstant: null`). v1.16.0:
* the released row keeps USER_PIN provenance server-side (the dedup
* "Zuordnung lösen" kebab action (`forceSlotInstant: null`). The
* released row keeps USER_PIN provenance server-side (the dedup
* leaves it standalone) and reads as a plain ad-hoc row here — no
* badge, since it is no longer bound to a slot.
*
@@ -39,85 +39,27 @@
* the ledger uses the semantic feedback vocabulary on the row, not the card.
*/
import { DayLink } from "@/components/day/day-link";
import {
useActiveRecordName,
useRecordCapabilities,
} from "@/hooks/use-record-capabilities";
import { useCallback, useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
AlertTriangle,
Check,
CircleCheck,
Link2,
Loader2,
MoreVertical,
Pencil,
Plus,
SkipForward,
Trash2,
Unlink,
} from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useState } from "react";
import { Loader2, Plus } from "lucide-react";
import { DayLink } from "@/components/day/day-link";
import { LedgerAddDialog } from "@/components/medications/dose-history-add-dialog";
import { IntakeEditDialog } from "@/components/medications/intake-edit-dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useAuth } from "@/hooks/use-auth";
import {
useTranslations,
useFormatters,
useDateFormatPreference,
} from "@/lib/i18n/context";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { formatDateWithWeekdaySmart } from "@/lib/date-format";
import {
invalidateKeys,
medicationDependentKeys,
queryKeys,
refetchInactiveDailyReads,
} from "@/lib/query-keys";
import type { DoseHistoryStatus } from "@/lib/medications/scheduling/dose-history";
import {
intakeToastOptions,
runUndoIntake,
} from "@/components/medications/use-medication-intake";
import { IntakeEditDialog } from "@/components/medications/intake-edit-dialog";
import { LedgerAddDialog } from "@/components/medications/dose-history-add-dialog";
import {
applyOptimisticSlotMark,
complianceFromLedger,
formatSlotDelta,
groupLedgerByDay,
isSlotActionable,
slotMarkRequestBody,
type LedgerPayload,
type LedgerRow,
} from "@/components/medications/dose-history-ledger-compute";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { EmptyState } from "@/components/ui/empty-state";
useDateFormatPreference,
useFormatters,
useTranslations,
} from "@/lib/i18n/context";
const DAY_MS = 24 * 60 * 60 * 1000;
import { DeleteRowConfirm, PinConfirm } from "./dose-history/ledger-confirms";
import { LedgerRowItem } from "./dose-history/ledger-row-item";
import { LedgerSummaryChart } from "./dose-history/ledger-summary-chart";
import { useDoseHistoryLedger } from "./dose-history/use-dose-history-ledger";
/** A schedule slot the add dialog offers for pinning. */
export interface LedgerSchedule {
@@ -135,36 +77,6 @@ export interface DoseHistoryLedgerProps {
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,
@@ -174,21 +86,11 @@ export function DoseHistoryLedger({
const { t, locale } = useTranslations();
const fmt = useFormatters();
const dateFormatPref = useDateFormatPreference();
const { user } = useAuth();
// v1.36.x — a delegate may record a dose, which is what "add" and the
// inline "mark taken" do. Editing, deleting, pinning and un-skipping an
// existing event are the owner's, so the row menu is absent for them.
// A delegate may record a dose, which is what "add" and the inline "mark
// taken" do. Editing, deleting, pinning and un-skipping an existing event
// are the owner's, so the row menu is absent for them.
const { canWriteDomain } = useRecordCapabilities();
const canAddIntake = canWriteDomain("medications");
// The record this ledger's writes land in, when it is not the caller's own.
// The two sibling intake paths in `use-medication-intake.ts` already drop
// Undo and name the record; this third one builds its own toast and missed
// both, so a delegate met an Undo the server refuses on every dose they
// marked — the most-encountered of the set.
const recordName = useActiveRecordName();
const queryClient = useQueryClient();
const timeZone = user?.timezone || DEFAULT_TIMEZONE;
const [addOpen, setAddOpen] = useState(false);
const [editingEvent, setEditingEvent] = useState<{
id: string;
@@ -196,236 +98,26 @@ export function DoseHistoryLedger({
skipped: boolean;
scheduledFor: string | null;
} | null>(null);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const [marking, setMarking] = useState<string | null>(null);
// v1.15.20 — the pending "Slot zuordnen" confirm: which intake to pin
// onto which slot. The confirm names the consequence (counts as late).
const [pendingPin, setPendingPin] = useState<{
eventId: string;
slotAt: string;
} | null>(null);
// The window is resolved when the request fires, not when the ledger
// mounts. A window pinned at mount made every refetch (including the one
// after adding an entry) ask for rows up to the moment the tab opened, so a
// dose recorded afterwards stayed invisible until a reload (#1028). Only
// `from` travels: the server's `to` defaults to its own now.
const queryKey = queryKeys.medicationDoseHistory(medicationId, windowDays);
const { data, isLoading, isError, error, refetch } = useQuery<LedgerPayload>({
const {
queryKey,
queryFn: async () => {
const from = new Date(Date.now() - windowDays * DAY_MS).toISOString();
const search = new URLSearchParams({ from });
return apiGet<LedgerPayload>(
`/api/medications/${medicationId}/dose-history?${search.toString()}`,
);
},
staleTime: 15_000,
});
const compliance = useMemo(
() => (data ? complianceFromLedger(data.rows) : null),
[data],
);
const groups = useMemo(
() => (data ? groupLedgerByDay(data.rows, timeZone) : []),
[data, timeZone],
);
// Today's local day key — a missed slot from TODAY is still in active
// play (visible Genommen button); older missed days settle into the
// row kebab so the history reads calm.
const todayKey = useMemo(
() =>
new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date()),
[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; a successful
* one carries the same Undo action the medication cards offer (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 {
// Pin the displayed slot so the server marks THAT dose (the canonical
// slot upsert), not a now-snap to the nearest one — and anchor
// `takenAt` on the same instant, matching the optimistic paint. The
// POST returns the created event; its id drives the Undo affordance
// (the same soft-delete route the cards use).
const created = await apiPost<{ id?: string } | undefined>(
`/api/medications/${medicationId}/intake`,
slotMarkRequestBody(row, action),
);
const eventId: string | undefined = created?.id;
toast.success(
t(
action === "skipped"
? "medications.intakeToastSkipped"
: "medications.intakeToastTaken",
{ name: medicationName },
),
// The shared decision, not a fourth copy of it: name the record it
// landed in, and withhold an Undo the server would refuse there.
intakeToastOptions({
recordName,
eventId,
t,
onUndo: (id) =>
void runUndoIntake({
medication: { id: medicationId, name: medicationName },
eventId: id,
t,
queryClient,
}),
}),
);
await invalidateKeys(queryClient, [
...medicationDependentKeys,
queryKey,
]);
// The ledger also invalidates its own window key above; the daily
// reads (Today hero + dashboard) are unmounted behind the detail
// page, so force their inactive refetch too (v1.32.19).
await refetchInactiveDailyReads(queryClient);
} catch (failure) {
if (prev) queryClient.setQueryData(queryKey, prev);
showErrorToast(
t("medications.intakeToastFailed", { name: medicationName }),
{ cause: failure },
);
} finally {
setMarking(null);
}
},
[
marking,
medicationId,
medicationName,
queryClient,
queryKey,
recordName,
t,
],
);
/**
* Flip an existing intake between Übersprungen and Genommen from the row
* kebab. Same `PUT intake/[eventId]` contract the edit dialog uses; a
* skipped→taken flip anchors `takenAt` on the slot instant (a past slot)
* rather than "now".
*/
const toggleIntakeSkipped = useCallback(
async (intake: {
id: string;
skipped: boolean;
scheduledFor: string | null;
}) => {
try {
const body = intake.skipped
? {
skipped: false,
takenAt: intake.scheduledFor ?? new Date().toISOString(),
}
: { skipped: true, takenAt: null };
await apiPut(
`/api/medications/${medicationId}/intake/${intake.id}`,
body,
);
await invalidateKeys(queryClient, [
...medicationDependentKeys,
queryKey,
]);
// The ledger also invalidates its own window key above; the daily
// reads (Today hero + dashboard) are unmounted behind the detail
// page, so force their inactive refetch too (v1.32.19).
await refetchInactiveDailyReads(queryClient);
toast.success(t("medications.detail.intake.edit.savedToast"));
} catch (failure) {
showErrorToast(t("medications.detail.intake.edit.failed"), {
cause: failure,
});
}
},
[medicationId, queryClient, queryKey, t],
);
/**
* v1.15.20 — pin an ad-hoc take onto a scheduled slot (`forceSlotInstant`)
* or release a pinned binding (`forceSlotInstant: null` → the server
* re-attributes by band, usually back to ad-hoc). Same PUT contract the
* edit dialog uses; the authoritative refetch re-renders the moved row.
*/
const setAttribution = useCallback(
async (eventId: string, slotAtIso: string | null) => {
try {
await apiPut(`/api/medications/${medicationId}/intake/${eventId}`, {
forceSlotInstant: slotAtIso,
});
toast.success(
t(
slotAtIso
? "medications.detail.verlauf.pin.pinnedToast"
: "medications.detail.verlauf.pin.unpinnedToast",
),
);
await invalidateKeys(queryClient, [
...medicationDependentKeys,
queryKey,
]);
// The ledger also invalidates its own window key above; the daily
// reads (Today hero + dashboard) are unmounted behind the detail
// page, so force their inactive refetch too (v1.32.19).
await refetchInactiveDailyReads(queryClient);
} catch (failure) {
showErrorToast(t("medications.detail.intake.edit.failed"), {
cause: failure,
});
}
},
[medicationId, queryClient, queryKey, t],
);
async function confirmRowDelete() {
if (!pendingDeleteId) return;
const id = pendingDeleteId;
setPendingDeleteId(null);
try {
await apiDelete(`/api/medications/${medicationId}/intake/${id}`);
toast.success(t("medications.detail.intake.deleteRow.toast"));
await invalidateKeys(queryClient, [...medicationDependentKeys, queryKey]);
// The ledger also invalidates its own window key above; the daily
// reads (Today hero + dashboard) are unmounted behind the detail
// page, so force their inactive refetch too (v1.32.19).
await refetchInactiveDailyReads(queryClient);
} catch (failure) {
showErrorToast(t("medications.detail.intake.deleteRow.failed"), {
cause: failure,
});
}
}
data,
isLoading,
isError,
error,
refetch,
compliance,
groups,
todayKey,
marking,
markSlot,
toggleIntakeSkipped,
setAttribution,
pendingDeleteId,
setPendingDeleteId,
confirmRowDelete,
pendingPin,
setPendingPin,
} = useDoseHistoryLedger({ medicationId, medicationName, windowDays });
return (
<div className="space-y-4" data-slot="dose-history-ledger">
@@ -499,7 +191,7 @@ export function DoseHistoryLedger({
the DEVICE zone and could shift the heading a day when
browser ≠ profile zone; the day-key label renders
UTC-pinned instead (zone-independent, weekday included). */}
{/* v1.42 — the day heading opens the whole day: did I take
{/* The day heading opens the whole day: did I take
it, and how was that day otherwise. Rows keep their own
actions. A planned future slot gets no link. */}
<DayLink date={group.dayKey}>
@@ -569,484 +261,3 @@ export function DoseHistoryLedger({
</div>
);
}
/**
* One ledger row: time + status (colour + glyph + text) + (when an intake is
* attributed) its real take time, plus the row actions.
*
* Action shape (v1.15.20): an actionable slot that is still in play
* (upcoming, or missed TODAY) carries exactly ONE visible [Genommen] button —
* label visible on every viewport — plus the overflow kebab (Übersprungen).
* Every settled row (taken / skipped / a past missed day / ad-hoc) carries
* ONLY the kebab: Bearbeiten, the Übersprungen↔Genommen toggle, Löschen —
* and, for a past missed slot, the Genommen / Übersprungen backfill marks.
*
* v1.36.x — exported for the delegated-affordance suite. The row's capability
* gating is the interesting part and the ledger around it needs a payload, a
* medication and a query client to paint one row.
*/
export function LedgerRowItem({
row,
marking,
isToday,
onMark,
onToggleSkipped,
onEdit,
onDelete,
onPin,
onUnpin,
}: {
row: LedgerRow;
marking: string | null;
isToday: boolean;
onMark: (row: LedgerRow, action: "taken" | "skipped") => void;
onToggleSkipped: (intake: {
id: string;
skipped: boolean;
scheduledFor: string | null;
}) => void;
onEdit: (event: {
id: string;
takenAt: string | null;
skipped: boolean;
scheduledFor: string | null;
}) => void;
onDelete: (id: string) => void;
/** Open the pin confirm for an ad-hoc take onto the named slot (ISO). */
onPin: (eventId: string, slotAt: string) => void;
/** Release a pinned binding (server re-attributes by band). */
onUnpin: (eventId: string) => void;
}) {
const { t } = useTranslations();
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
const canAddIntake = canWriteDomain("medications");
const canManageMedications = canManageDomain("medications");
const fmt = useFormatters();
const actionable = isSlotActionable(row);
// A missed slot from an earlier day stays markable, but only through the
// kebab — the visible button is reserved for doses still in play.
const showTakenButton = actionable && (row.status === "upcoming" || isToday);
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");
// v1.15.20 — an ad-hoc take with a due-context shows WHEN the dose would
// have been due; the pin action additionally needs the slot unserved and a
// real persisted event to re-anchor.
const dueContext =
row.kind === "ad_hoc" && intake?.takenAt && row.nearestSlot
? row.nearestSlot
: null;
const canPin = Boolean(dueContext && !dueContext.filled && intake?.id);
const canUnpin = Boolean(row.kind === "slot" && row.pinned && intake?.id);
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-2xs">
{t("medications.detail.verlauf.adHocTag")}
</Badge>
)}
{/* v1.16.0 — the "zugeordnet" badge only when the row is actually
bound to a slot. A released ("Zuordnung lösen") USER_PIN take
surfaces as an ad-hoc row that also carries `pinned`; badging
it "zugeordnet" would claim the binding the user just released. */}
{row.kind === "slot" && row.pinned && (
<Badge
variant="outline"
className="text-2xs"
data-slot="ledger-pinned-badge"
>
{t("medications.detail.verlauf.pin.pinnedTag")}
</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>
)}
{/* v1.16.4 — quiet per-intake dose override note. Only rows whose
take recorded a deviating dose carry one; the configured dose
stays implicit, so the ledger's rhythm is undisturbed. */}
{intake?.doseTaken && (
<p
className="text-muted-foreground text-xs"
data-slot="ledger-dose-taken"
>
{t("medications.detail.verlauf.doseTaken", {
dose: intake.doseTaken,
})}
</p>
)}
{dueContext && (
<p
className="text-muted-foreground text-xs"
data-slot="ledger-due-context"
>
{t("medications.detail.verlauf.pin.dueContext", {
time: fmt.time(new Date(dueContext.at)),
delta: formatSlotDelta(intake!.takenAt!, dueContext.at),
})}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{showTakenButton && canAddIntake && (
<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" />
)}
{t("medications.detail.verlauf.markTaken")}
</Button>
)}
{/* v1.36.x — the other half of the same admitted verb. Marking a dose
taken OR skipped is one capability, and the medication card offers
both; this ledger offered a delegate only the first, because skip
lives in the kebab and the kebab is owner-only (it also holds edit,
re-attribute and delete). Rather than mix admitted and withheld
items in one menu, a delegate gets skip as its own control beside
take, which is the shape the card already uses. The owner keeps it
where it was. */}
{showTakenButton && canAddIntake && !canManageMedications && (
<Button
variant="outline"
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" />
)}
{t("medications.detail.verlauf.markSkipped")}
</Button>
)}
{canManageMedications && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
aria-label={t("medications.detail.verlauf.rowActionsLabel", {
time: timeLabel,
})}
data-slot="ledger-row-menu"
>
<MoreVertical aria-hidden="true" className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
data-slot="ledger-row-menu-content"
>
{actionable && !showTakenButton && (
<DropdownMenuItem
onClick={() => onMark(row, "taken")}
data-slot="ledger-menu-taken"
>
<Check aria-hidden="true" className="mr-2 size-4" />
{t("medications.detail.verlauf.markTaken")}
</DropdownMenuItem>
)}
{actionable && (
<DropdownMenuItem
onClick={() => onMark(row, "skipped")}
data-slot="ledger-mark-skipped"
>
{skipBusy ? (
<Loader2
aria-hidden="true"
className="mr-2 size-4 animate-spin motion-reduce:animate-none"
/>
) : (
<SkipForward aria-hidden="true" className="mr-2 size-4" />
)}
{t("medications.detail.verlauf.markSkipped")}
</DropdownMenuItem>
)}
{canPin && (
<DropdownMenuItem
onClick={() => onPin(intake!.id as string, dueContext!.at)}
data-slot="ledger-pin"
>
<Link2 aria-hidden="true" className="mr-2 size-4" />
{t("medications.detail.verlauf.pin.action", {
time: fmt.time(new Date(dueContext!.at)),
})}
</DropdownMenuItem>
)}
{canUnpin && (
<DropdownMenuItem
onClick={() => onUnpin(intake!.id as string)}
data-slot="ledger-unpin"
>
<Unlink aria-hidden="true" className="mr-2 size-4" />
{t("medications.detail.verlauf.pin.release")}
</DropdownMenuItem>
)}
{intake?.id && (
<>
<DropdownMenuItem
onClick={() =>
onEdit({
id: intake.id as string,
takenAt: intake.takenAt,
skipped: intake.skipped,
scheduledFor: intake.scheduledFor,
})
}
data-slot="ledger-edit"
>
<Pencil aria-hidden="true" className="mr-2 size-4" />
{t("medications.detail.intake.rowActions.edit")}
</DropdownMenuItem>
{(row.status === "skipped" ||
row.status === "taken_on_time" ||
row.status === "taken_late") && (
<DropdownMenuItem
onClick={() =>
onToggleSkipped({
id: intake.id as string,
skipped: intake.skipped,
scheduledFor: intake.scheduledFor,
})
}
data-slot="ledger-toggle-skipped"
>
{intake.skipped ? (
<Check aria-hidden="true" className="mr-2 size-4" />
) : (
<SkipForward
aria-hidden="true"
className="mr-2 size-4"
/>
)}
{intake.skipped
? t("medications.detail.verlauf.markAsTaken")
: t("medications.detail.verlauf.markAsSkipped")}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete(intake.id as string)}
data-slot="ledger-delete"
>
<Trash2 aria-hidden="true" className="mr-2 size-4" />
{t("medications.detail.intake.rowActions.delete")}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</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-xs">
<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>
);
}
/**
* v1.15.20 — the "Slot zuordnen" confirm. Pinning is a deliberate, visible
* decision, so the dialog names the consequence before the write: the dose
* will count as taken LATE for that slot (the rate is served, never
* flattered) and the row will carry the "zugeordnet" badge until released.
*/
function PinConfirm({
slotLabel,
onConfirm,
onCancel,
}: {
slotLabel: string;
onConfirm: () => void;
onCancel: () => void;
}) {
const { t } = useTranslations();
return (
<AlertDialog open onOpenChange={(open) => !open && onCancel()}>
<AlertDialogContent data-slot="ledger-pin-confirm">
<AlertDialogHeader>
<AlertDialogTitle>
{t("medications.detail.verlauf.pin.confirmTitle", {
time: slotLabel,
})}
</AlertDialogTitle>
<AlertDialogDescription>
{t("medications.detail.verlauf.pin.confirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("medications.detail.verlauf.pin.confirmCancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
onConfirm();
}}
data-slot="ledger-pin-confirm-action"
>
{t("medications.detail.verlauf.pin.confirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
/** 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 dark:bg-destructive/60 hover:bg-destructive/90 text-white"
>
{t("medications.detail.intake.deleteRow.confirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -0,0 +1,101 @@
"use client";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useTranslations } from "@/lib/i18n/context";
/**
* The "Slot zuordnen" confirm. Pinning is a deliberate, visible
* decision, so the dialog names the consequence before the write: the dose
* will count as taken LATE for that slot (the rate is served, never
* flattered) and the row will carry the "zugeordnet" badge until released.
*/
export function PinConfirm({
slotLabel,
onConfirm,
onCancel,
}: {
slotLabel: string;
onConfirm: () => void;
onCancel: () => void;
}) {
const { t } = useTranslations();
return (
<AlertDialog open onOpenChange={(open) => !open && onCancel()}>
<AlertDialogContent data-slot="ledger-pin-confirm">
<AlertDialogHeader>
<AlertDialogTitle>
{t("medications.detail.verlauf.pin.confirmTitle", {
time: slotLabel,
})}
</AlertDialogTitle>
<AlertDialogDescription>
{t("medications.detail.verlauf.pin.confirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("medications.detail.verlauf.pin.confirmCancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
onConfirm();
}}
data-slot="ledger-pin-confirm-action"
>
{t("medications.detail.verlauf.pin.confirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
/** Lightweight single-row delete confirm — the ledger does not need the full
* bulk-delete state machine, just a guard. */
export 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 dark:bg-destructive/60 hover:bg-destructive/90 text-white"
>
{t("medications.detail.intake.deleteRow.confirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -0,0 +1,451 @@
"use client";
import {
AlertCircle,
AlertTriangle,
Check,
CircleCheck,
Link2,
Loader2,
MoreVertical,
Pencil,
Plus,
SkipForward,
Trash2,
Unlink,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { DoseHistoryStatus } from "@/lib/medications/scheduling/dose-history";
import {
formatSlotDelta,
isSlotActionable,
type LedgerRow,
} from "../dose-history-ledger-compute";
/** 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;
}
}
interface LedgerRowItemProps {
row: LedgerRow;
marking: string | null;
isToday: boolean;
onMark: (row: LedgerRow, action: "taken" | "skipped") => void;
onToggleSkipped: (intake: {
id: string;
skipped: boolean;
scheduledFor: string | null;
}) => void;
onEdit: (event: {
id: string;
takenAt: string | null;
skipped: boolean;
scheduledFor: string | null;
}) => void;
onDelete: (id: string) => void;
/** Open the pin confirm for an ad-hoc take onto the named slot (ISO). */
onPin: (eventId: string, slotAt: string) => void;
/** Release a pinned binding (server re-attributes by band). */
onUnpin: (eventId: string) => void;
}
/**
* One ledger row: time + status (colour + glyph + text) + (when an intake is
* attributed) its real take time, plus the row actions.
*
* Action shape: an actionable slot that is still in play
* (upcoming, or missed TODAY) carries exactly ONE visible [Genommen] button —
* label visible on every viewport — plus the overflow kebab (Übersprungen).
* Every settled row (taken / skipped / a past missed day / ad-hoc) carries
* ONLY the kebab: Bearbeiten, the Übersprungen↔Genommen toggle, Löschen —
* and, for a past missed slot, the Genommen / Übersprungen backfill marks.
*
* .x — exported for the delegated-affordance suite. The row's capability
* gating is the interesting part and the ledger around it needs a payload, a
* medication and a query client to paint one row.
*/
export function LedgerRowItem({
row,
marking,
isToday,
onMark,
onToggleSkipped,
onEdit,
onDelete,
onPin,
onUnpin,
}: LedgerRowItemProps) {
const { t } = useTranslations();
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
const canAddIntake = canWriteDomain("medications");
const canManageMedications = canManageDomain("medications");
const fmt = useFormatters();
const actionable = isSlotActionable(row);
// A missed slot from an earlier day stays markable, but only through the
// kebab — the visible button is reserved for doses still in play.
const showTakenButton = actionable && (row.status === "upcoming" || isToday);
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");
// An ad-hoc take with a due-context shows WHEN the dose would
// have been due; the pin action additionally needs the slot unserved and a
// real persisted event to re-anchor.
const dueContext =
row.kind === "ad_hoc" && intake?.takenAt && row.nearestSlot
? row.nearestSlot
: null;
const canPin = Boolean(dueContext && !dueContext.filled && intake?.id);
const canUnpin = Boolean(row.kind === "slot" && row.pinned && intake?.id);
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-2xs">
{t("medications.detail.verlauf.adHocTag")}
</Badge>
)}
{/* The "zugeordnet" badge only when the row is actually
bound to a slot. A released ("Zuordnung lösen") USER_PIN take
surfaces as an ad-hoc row that also carries `pinned`; badging
it "zugeordnet" would claim the binding the user just released. */}
{row.kind === "slot" && row.pinned && (
<Badge
variant="outline"
className="text-2xs"
data-slot="ledger-pinned-badge"
>
{t("medications.detail.verlauf.pin.pinnedTag")}
</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>
)}
{/* Quiet per-intake dose override note. Only rows whose
take recorded a deviating dose carry one; the configured dose
stays implicit, so the ledger's rhythm is undisturbed. */}
{intake?.doseTaken && (
<p
className="text-muted-foreground text-xs"
data-slot="ledger-dose-taken"
>
{t("medications.detail.verlauf.doseTaken", {
dose: intake.doseTaken,
})}
</p>
)}
{dueContext && (
<p
className="text-muted-foreground text-xs"
data-slot="ledger-due-context"
>
{t("medications.detail.verlauf.pin.dueContext", {
time: fmt.time(new Date(dueContext.at)),
delta: formatSlotDelta(intake!.takenAt!, dueContext.at),
})}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{showTakenButton && canAddIntake && (
<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" />
)}
{t("medications.detail.verlauf.markTaken")}
</Button>
)}
{/* .x — the other half of the same admitted verb. Marking a dose
taken OR skipped is one capability, and the medication card offers
both; this ledger offered a delegate only the first, because skip
lives in the kebab and the kebab is owner-only (it also holds edit,
re-attribute and delete). Rather than mix admitted and withheld
items in one menu, a delegate gets skip as its own control beside
take, which is the shape the card already uses. The owner keeps it
where it was. */}
{showTakenButton && canAddIntake && !canManageMedications && (
<Button
variant="outline"
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" />
)}
{t("medications.detail.verlauf.markSkipped")}
</Button>
)}
{canManageMedications && (
<LedgerRowMenu
row={row}
actionable={actionable}
showTakenButton={showTakenButton}
skipBusy={skipBusy}
canPin={canPin}
canUnpin={canUnpin}
dueContext={dueContext}
timeLabel={timeLabel}
onMark={onMark}
onToggleSkipped={onToggleSkipped}
onEdit={onEdit}
onDelete={onDelete}
onPin={onPin}
onUnpin={onUnpin}
/>
)}
</div>
</li>
);
}
/**
* The owner's row menu: the backfill marks for a slot no longer in play,
* pin and release, edit, the skipped/taken toggle, delete.
*/
function LedgerRowMenu({
row,
actionable,
showTakenButton,
skipBusy,
canPin,
canUnpin,
dueContext,
timeLabel,
onMark,
onToggleSkipped,
onEdit,
onDelete,
onPin,
onUnpin,
}: Pick<
LedgerRowItemProps,
| "row"
| "onMark"
| "onToggleSkipped"
| "onEdit"
| "onDelete"
| "onPin"
| "onUnpin"
> & {
actionable: boolean;
showTakenButton: boolean;
skipBusy: boolean;
canPin: boolean;
canUnpin: boolean;
dueContext: LedgerRow["nearestSlot"] | null;
timeLabel: string;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const intake = row.intake;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
aria-label={t("medications.detail.verlauf.rowActionsLabel", {
time: timeLabel,
})}
data-slot="ledger-row-menu"
>
<MoreVertical aria-hidden="true" className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" data-slot="ledger-row-menu-content">
{actionable && !showTakenButton && (
<DropdownMenuItem
onClick={() => onMark(row, "taken")}
data-slot="ledger-menu-taken"
>
<Check aria-hidden="true" className="mr-2 size-4" />
{t("medications.detail.verlauf.markTaken")}
</DropdownMenuItem>
)}
{actionable && (
<DropdownMenuItem
onClick={() => onMark(row, "skipped")}
data-slot="ledger-mark-skipped"
>
{skipBusy ? (
<Loader2
aria-hidden="true"
className="mr-2 size-4 animate-spin motion-reduce:animate-none"
/>
) : (
<SkipForward aria-hidden="true" className="mr-2 size-4" />
)}
{t("medications.detail.verlauf.markSkipped")}
</DropdownMenuItem>
)}
{canPin && (
<DropdownMenuItem
onClick={() => onPin(intake!.id as string, dueContext!.at)}
data-slot="ledger-pin"
>
<Link2 aria-hidden="true" className="mr-2 size-4" />
{t("medications.detail.verlauf.pin.action", {
time: fmt.time(new Date(dueContext!.at)),
})}
</DropdownMenuItem>
)}
{canUnpin && (
<DropdownMenuItem
onClick={() => onUnpin(intake!.id as string)}
data-slot="ledger-unpin"
>
<Unlink aria-hidden="true" className="mr-2 size-4" />
{t("medications.detail.verlauf.pin.release")}
</DropdownMenuItem>
)}
{intake?.id && (
<>
<DropdownMenuItem
onClick={() =>
onEdit({
id: intake.id as string,
takenAt: intake.takenAt,
skipped: intake.skipped,
scheduledFor: intake.scheduledFor,
})
}
data-slot="ledger-edit"
>
<Pencil aria-hidden="true" className="mr-2 size-4" />
{t("medications.detail.intake.rowActions.edit")}
</DropdownMenuItem>
{(row.status === "skipped" ||
row.status === "taken_on_time" ||
row.status === "taken_late") && (
<DropdownMenuItem
onClick={() =>
onToggleSkipped({
id: intake.id as string,
skipped: intake.skipped,
scheduledFor: intake.scheduledFor,
})
}
data-slot="ledger-toggle-skipped"
>
{intake.skipped ? (
<Check aria-hidden="true" className="mr-2 size-4" />
) : (
<SkipForward aria-hidden="true" className="mr-2 size-4" />
)}
{intake.skipped
? t("medications.detail.verlauf.markAsTaken")
: t("medications.detail.verlauf.markAsSkipped")}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete(intake.id as string)}
data-slot="ledger-delete"
>
<Trash2 aria-hidden="true" className="mr-2 size-4" />
{t("medications.detail.intake.rowActions.delete")}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,68 @@
"use client";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { complianceFromLedger } from "../dose-history-ledger-compute";
/**
* 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.
*/
export 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-xs">
<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>
);
}
@@ -0,0 +1,326 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import {
useQuery,
useQueryClient,
type QueryClient,
} from "@tanstack/react-query";
import { toast } from "sonner";
import {
intakeToastOptions,
runUndoIntake,
} from "@/components/medications/use-medication-intake";
import { useAuth } from "@/hooks/use-auth";
import { useActiveRecordName } from "@/hooks/use-record-capabilities";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import {
invalidateKeys,
medicationDependentKeys,
queryKeys,
refetchInactiveDailyReads,
} from "@/lib/query-keys";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import {
applyOptimisticSlotMark,
complianceFromLedger,
groupLedgerByDay,
isSlotActionable,
slotMarkRequestBody,
type LedgerPayload,
type LedgerRow,
} from "../dose-history-ledger-compute";
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* After a write: the medication keys and this window, then the daily reads
* (Today hero, dashboard), which sit unmounted behind the detail page and
* would otherwise keep their stale answer.
*/
async function reconcileLedger(
queryClient: QueryClient,
queryKey: readonly unknown[],
) {
await invalidateKeys(queryClient, [...medicationDependentKeys, queryKey]);
await refetchInactiveDailyReads(queryClient);
}
/**
* The ledger's read and its writes: the window read, the adherence summary,
* the day groups, and the marks, toggles, pins and deletes, each followed by
* the same invalidation (the medication keys, this window, and the daily
* reads that sit unmounted behind the detail page).
*/
export function useDoseHistoryLedger({
medicationId,
medicationName,
windowDays,
}: {
medicationId: string;
medicationName: string;
windowDays: number;
}) {
const { t } = useTranslations();
const { user } = useAuth();
// The record this ledger's writes land in, when it is not the caller's own.
// The two sibling intake paths in `use-medication-intake.ts` already drop
// Undo and name the record; this third one builds its own toast and missed
// both, so a delegate met an Undo the server refuses on every dose they
// marked — the most-encountered of the set.
const recordName = useActiveRecordName();
const queryClient = useQueryClient();
const timeZone = user?.timezone || DEFAULT_TIMEZONE;
const [marking, setMarking] = useState<string | null>(null);
// The window is resolved when the request fires, not when the ledger
// mounts. A window pinned at mount made every refetch (including the one
// after adding an entry) ask for rows up to the moment the tab opened, so a
// dose recorded afterwards stayed invisible until a reload (#1028). Only
// `from` travels: the server's `to` defaults to its own now.
const queryKey = queryKeys.medicationDoseHistory(medicationId, windowDays);
const { data, isLoading, isError, error, refetch } = useQuery<LedgerPayload>({
queryKey,
queryFn: async () => {
const from = new Date(Date.now() - windowDays * DAY_MS).toISOString();
const search = new URLSearchParams({ from });
return apiGet<LedgerPayload>(
`/api/medications/${medicationId}/dose-history?${search.toString()}`,
);
},
staleTime: 15_000,
});
const compliance = useMemo(
() => (data ? complianceFromLedger(data.rows) : null),
[data],
);
const groups = useMemo(
() => (data ? groupLedgerByDay(data.rows, timeZone) : []),
[data, timeZone],
);
// Today's local day key — a missed slot from TODAY is still in active
// play (visible Genommen button); older missed days settle into the
// row kebab so the history reads calm.
const todayKey = useMemo(
() =>
new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date()),
[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; a successful
* one carries the same Undo action the medication cards offer (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 {
// Pin the displayed slot so the server marks THAT dose (the canonical
// slot upsert), not a now-snap to the nearest one — and anchor
// `takenAt` on the same instant, matching the optimistic paint. The
// POST returns the created event; its id drives the Undo affordance
// (the same soft-delete route the cards use).
const created = await apiPost<{ id?: string } | undefined>(
`/api/medications/${medicationId}/intake`,
slotMarkRequestBody(row, action),
);
const eventId: string | undefined = created?.id;
toast.success(
t(
action === "skipped"
? "medications.intakeToastSkipped"
: "medications.intakeToastTaken",
{ name: medicationName },
),
// The shared decision, not a fourth copy of it: name the record it
// landed in, and withhold an Undo the server would refuse there.
intakeToastOptions({
recordName,
eventId,
t,
onUndo: (id) =>
void runUndoIntake({
medication: { id: medicationId, name: medicationName },
eventId: id,
t,
queryClient,
}),
}),
);
await reconcileLedger(queryClient, queryKey);
} catch (failure) {
if (prev) queryClient.setQueryData(queryKey, prev);
showErrorToast(
t("medications.intakeToastFailed", { name: medicationName }),
{ cause: failure },
);
} finally {
setMarking(null);
}
},
[
marking,
medicationId,
medicationName,
queryClient,
queryKey,
recordName,
t,
],
);
const edits = useDoseHistoryEdits({ medicationId, queryKey });
return {
queryKey,
data,
isLoading,
isError,
error,
refetch,
compliance,
groups,
todayKey,
marking,
markSlot,
...edits,
};
}
/**
* The owner's edits from the row menu: the skipped/taken toggle, pinning and
* releasing a take, and the confirmed row delete.
*/
function useDoseHistoryEdits({
medicationId,
queryKey,
}: {
medicationId: string;
queryKey: readonly unknown[];
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
// The pending "Slot zuordnen" confirm: which intake to pin
// onto which slot. The confirm names the consequence (counts as late).
const [pendingPin, setPendingPin] = useState<{
eventId: string;
slotAt: string;
} | null>(null);
/**
* Flip an existing intake between Übersprungen and Genommen from the row
* kebab. Same `PUT intake/[eventId]` contract the edit dialog uses; a
* skipped→taken flip anchors `takenAt` on the slot instant (a past slot)
* rather than "now".
*/
const toggleIntakeSkipped = useCallback(
async (intake: {
id: string;
skipped: boolean;
scheduledFor: string | null;
}) => {
try {
const body = intake.skipped
? {
skipped: false,
takenAt: intake.scheduledFor ?? new Date().toISOString(),
}
: { skipped: true, takenAt: null };
await apiPut(
`/api/medications/${medicationId}/intake/${intake.id}`,
body,
);
await reconcileLedger(queryClient, queryKey);
toast.success(t("medications.detail.intake.edit.savedToast"));
} catch (failure) {
showErrorToast(t("medications.detail.intake.edit.failed"), {
cause: failure,
});
}
},
[medicationId, queryClient, queryKey, t],
);
/**
* Pin an ad-hoc take onto a scheduled slot (`forceSlotInstant`)
* or release a pinned binding (`forceSlotInstant: null` → the server
* re-attributes by band, usually back to ad-hoc). Same PUT contract the
* edit dialog uses; the authoritative refetch re-renders the moved row.
*/
const setAttribution = useCallback(
async (eventId: string, slotAtIso: string | null) => {
try {
await apiPut(`/api/medications/${medicationId}/intake/${eventId}`, {
forceSlotInstant: slotAtIso,
});
toast.success(
t(
slotAtIso
? "medications.detail.verlauf.pin.pinnedToast"
: "medications.detail.verlauf.pin.unpinnedToast",
),
);
await reconcileLedger(queryClient, queryKey);
} catch (failure) {
showErrorToast(t("medications.detail.intake.edit.failed"), {
cause: failure,
});
}
},
[medicationId, queryClient, queryKey, t],
);
async function confirmRowDelete() {
if (!pendingDeleteId) return;
const id = pendingDeleteId;
setPendingDeleteId(null);
try {
await apiDelete(`/api/medications/${medicationId}/intake/${id}`);
toast.success(t("medications.detail.intake.deleteRow.toast"));
await reconcileLedger(queryClient, queryKey);
} catch (failure) {
showErrorToast(t("medications.detail.intake.deleteRow.failed"), {
cause: failure,
});
}
}
return {
toggleIntakeSkipped,
setAttribution,
pendingDeleteId,
setPendingDeleteId,
confirmRowDelete,
pendingPin,
setPendingPin,
};
}