From 778c3a53a4ccea186fc2ce99efa3e6e8a829d06b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 12 Jun 2026 22:02:47 +0200 Subject: [PATCH] feat(medications): take every due dose in one confirmed action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When two or more medications are due, the page header offers a single confirm dialog listing them with dose and window. The takes loop the per-medication intake route rather than the sync bulk surface — slot attribution, inventory consumption and idempotency stay byte-identical to individual taps, and a failed medication stays due while the rest land. (#316) --- src/app/medications/page.tsx | 78 ++- .../__tests__/take-all-due.test.tsx | 462 ++++++++++++++++++ .../medications/take-all-due-dialog.tsx | 139 ++++++ src/components/medications/take-all-due.ts | 217 ++++++++ 4 files changed, 894 insertions(+), 2 deletions(-) create mode 100644 src/components/medications/__tests__/take-all-due.test.tsx create mode 100644 src/components/medications/take-all-due-dialog.tsx create mode 100644 src/components/medications/take-all-due.ts diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index edd1de3a8..7a08976f9 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -11,6 +11,8 @@ import { MedicationWizardDialog } from "@/components/medications/wizard/Medicati import { MedicationCard } from "@/components/medications/medication-card"; import { Glp1MedicationCard } from "@/components/medications/glp1-medication-card"; import { LogIntakeDialog } from "@/components/medications/log-intake-dialog"; +import { TakeAllDueDialog } from "@/components/medications/take-all-due-dialog"; +import { deriveDueMedications } from "@/components/medications/take-all-due"; import { MedicationTable, MedicationTableSkeleton, @@ -25,7 +27,14 @@ import { } from "@/components/ui/dropdown-menu"; import { EmptyState } from "@/components/ui/empty-state"; import { Skeleton } from "@/components/ui/skeleton"; -import { CheckCircle2, Loader2, Pill, Plus, Wrench } from "lucide-react"; +import { + CheckCheck, + CheckCircle2, + Loader2, + Pill, + Plus, + Wrench, +} from "lucide-react"; import { apiGet } from "@/lib/api/api-fetch"; import { useMedicationListLayout } from "@/lib/queries/use-medication-list-layout"; import { applyMedicationOrder } from "@/lib/medications/medication-order"; @@ -45,6 +54,8 @@ interface Schedule { rollingIntervalDays?: number | null; /** v1.5 — reminder grace window in minutes. */ reminderGraceMinutes?: number | null; + /** v1.16.1 — explicit per-dose on-time windows (band model). */ + doseWindows?: { timeOfDay: string; start: string; end: string }[] | null; } interface Medication { @@ -66,12 +77,16 @@ interface Medication { notificationsEnabled: boolean; pausedAt: string | null; lastTakenAt: string | null; + /** List-only count of today's ACTIONED intake events (taken/skipped). */ + todayEventCount?: number; /** * v1.8.4 — server-computed next due instant (canonical recurrence * engine). Threaded straight through to both card variants, which render * it instead of re-deriving the timestamp client-side. */ nextDueAt?: string | null; + /** v1.16.4 — true when `nextDueAt` is an OPEN overdue slot. */ + nextDueOverdue?: boolean; /** v1.5 — medication-level course start date (ISO string). */ startsOn?: string | null; /** v1.5 — medication-level course end date (ISO string). */ @@ -132,7 +147,7 @@ function MedicationCardSkeleton() { } export default function MedicationsPage() { - const { isAuthenticated, isLoading: authLoading } = useAuth(); + const { user, isAuthenticated, isLoading: authLoading } = useAuth(); const { t } = useTranslations(); const router = useRouter(); const queryClient = useQueryClient(); @@ -148,6 +163,9 @@ export default function MedicationsPage() { // two paths: log an intake (incl. a backdated one) against an existing // medication, or create a new medication (the existing wizard). const [logIntakeOpen, setLogIntakeOpen] = useState(false); + // v1.16.11 (#316) — "Alle fälligen einnehmen" confirm dialog. The header + // button earns its slot only while ≥ 2 medications are currently due. + const [takeAllOpen, setTakeAllOpen] = useState(false); // v1.16.10 — the persisted list presentation: cards vs table plus the // manual medication order, server-side per user @@ -176,6 +194,25 @@ export default function MedicationsPage() { enabled: isAuthenticated, }); + // v1.16.11 — the same reminder-thresholds read the cards make (shared + // key + cache), so the header's due-set derivation tiers late / + // very_late exactly like the card pills. Null on failure keeps the + // derivation on the 120/240 defaults the cards also fall back to. + const { data: thresholds } = useQuery({ + queryKey: queryKeys.settingsReminderThresholds(), + queryFn: async () => { + try { + return await apiGet<{ lateMinutes: number; missedMinutes: number }>( + "/api/settings/reminder-thresholds", + ); + } catch { + return null; + } + }, + enabled: isAuthenticated, + staleTime: 5 * 60 * 1000, + }); + function openCreate() { setDialogOpen(true); } @@ -248,6 +285,15 @@ export default function MedicationsPage() { ); const tableView = layout.view === "table"; + // v1.16.11 (#316) — the currently-due set, derived from the list payload + // the page already holds via the SAME pipeline a card's pill runs (band + // model + server display-due gate + taken-early downgrade). Page order is + // preserved so the confirm dialog lists medications as rendered. + const dueMeds = deriveDueMedications(activeMeds, { + tz: user?.timezone || "Europe/Berlin", + thresholds: thresholds ?? undefined, + }); + return (
@@ -262,6 +308,22 @@ export default function MedicationsPage() {

+ {/* v1.16.11 (#316) — "Alle fälligen einnehmen". Contextual, not a + permanent fixture: it renders only while at least TWO + medications are currently due (a single due dose is the + card's own one-tap job). Calm outline variant so the primary + Add button keeps the visual lead; same responsive 44-px + mobile tap floor as its neighbours. */} + {dueMeds.length >= 2 && ( + + )} {/* v1.16.11 — the wrench is the one customize entry point: it links to /settings/medications, which owns the view preference (cards ⇄ table) and the manual-order editor. @@ -453,6 +515,18 @@ export default function MedicationsPage() { date+time that supports backdating. Submits to the existing per-medication intake route (same slot upsert as a live "Taken" tap) so the one-row-per-dose-slot invariant holds. */} + {/* v1.16.11 (#316) — confirm-all dialog for the due set. Records + through the per-medication intake route in a loop (slot + attribution + inventory consumption identical to N individual + taps — see take-all-due.ts); failed medications stay due. */} + {takeAllOpen && ( + + )} + {logIntakeOpen && ( ` — SSR list rendering (name + dose + window) per + * the repo's SSR-only component-test convention, plus source-string + * pins for the interactive plumbing static markup can't exercise. + * + * Fixtures use relative instants anchored on the host's current date + * (today at a fixed UTC wall-clock hour) — no hardcoded calendar dates. + */ + +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +// The Radix Dialog portals at runtime, so its body never materialises in +// static markup. Collapse the primitives to plain wrappers (same trick as +// the LogInjectionSiteDialog suite) so the list + footer are reachable. +vi.mock("@/components/ui/dialog", () => { + const Pass = ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ); + return { + Dialog: ({ + open, + children, + }: { + open: boolean; + children: React.ReactNode; + }) => (open ?
{children}
: null), + DialogContent: Pass, + DialogDescription: Pass, + DialogFooter: Pass, + DialogHeader: Pass, + DialogTitle: Pass, + }; +}); + +const { TakeAllDueDialog } = await import("../take-all-due-dialog"); + +beforeEach(() => { + vi.mocked(toast.success).mockClear(); + vi.mocked(toast.error).mockClear(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +/** Today at the given UTC wall-clock time — relative to the host date. */ +function todayAtUtc(hours: number, minutes = 0): Date { + const d = new Date(); + d.setUTCHours(hours, minutes, 0, 0); + return d; +} + +const t = (key: string, params?: Record) => + params ? `${key}:${JSON.stringify(params)}` : key; + +function fakeQueryClient(): QueryClientType { + return { + invalidateQueries: vi.fn().mockResolvedValue(undefined), + } as unknown as QueryClientType; +} + +function makeMed( + overrides: Partial & { id: string }, +): DueDerivationMedication { + return { + name: `Med ${overrides.id}`, + dose: "10 mg", + active: true, + pausedAt: null, + lastTakenAt: null, + todayEventCount: 0, + schedules: [ + { + windowStart: "12:00", + windowEnd: "12:00", + daysOfWeek: null, + timesOfDay: ["12:00"], + dose: null, + }, + ], + ...overrides, + }; +} + +describe("deriveDueMedications — only currently-due, active, unpaused", () => { + // Noon UTC today; the 12:00 dose band (±60 min default half-width) is + // open, the 20:00 band has not opened, the 08:00 band sits in its + // very_late tail (ended 09:00, missedMinutes 240 → until 13:00). + const now = todayAtUtc(12, 0); + const opts = { now, tz: "UTC" }; + + it("includes an in-window medication with its matched band and slot instant", () => { + const due = deriveDueMedications([makeMed({ id: "a" })], opts); + expect(due).toHaveLength(1); + expect(due[0]).toMatchObject({ + id: "a", + name: "Med a", + dose: "10 mg", + window: { start: "11:00", end: "13:00" }, + }); + // The displayed slot instant — today's 12:00 UTC dose, the same value + // the card's own button would thread as `scheduledFor`. + expect(due[0].scheduledFor?.toISOString()).toBe( + todayAtUtc(12, 0).toISOString(), + ); + }); + + it("includes an overdue (very_late) dose still inside its catch-up tail", () => { + const due = deriveDueMedications( + [ + makeMed({ + id: "b", + schedules: [ + { + windowStart: "08:00", + windowEnd: "08:00", + daysOfWeek: null, + timesOfDay: ["08:00"], + dose: "5 mg", + }, + ], + }), + ], + opts, + ); + expect(due).toHaveLength(1); + // The matched schedule's dose wins over the medication-level dose. + expect(due[0].dose).toBe("5 mg"); + expect(due[0].scheduledFor?.toISOString()).toBe( + todayAtUtc(8, 0).toISOString(), + ); + }); + + it("excludes inactive and paused medications", () => { + const pausedAt = new Date(now.getTime() - 60 * 60 * 1000).toISOString(); + const due = deriveDueMedications( + [ + makeMed({ id: "inactive", active: false }), + makeMed({ id: "paused", pausedAt }), + makeMed({ id: "due" }), + ], + opts, + ); + expect(due.map((d) => d.id)).toEqual(["due"]); + }); + + it("excludes a medication whose dose band is not currently open", () => { + const due = deriveDueMedications( + [ + makeMed({ + id: "evening", + schedules: [ + { + windowStart: "20:00", + windowEnd: "20:00", + daysOfWeek: null, + timesOfDay: ["20:00"], + dose: null, + }, + ], + }), + ], + opts, + ); + expect(due).toEqual([]); + }); + + it("excludes a dose already covered by an actioned intake in its band", () => { + // Taken 30 minutes ago — inside the open 12:00 band; the in-window + // suppression (same as the card pill) keeps it out of the due set. + const due = deriveDueMedications( + [ + makeMed({ + id: "covered", + lastTakenAt: new Date(now.getTime() - 30 * 60 * 1000).toISOString(), + todayEventCount: 1, + }), + ], + opts, + ); + expect(due).toEqual([]); + }); + + it("honours the server display-due gate: a future non-overdue next-due suppresses", () => { + const tomorrowNoon = new Date(now.getTime() + 24 * 60 * 60 * 1000); + const due = deriveDueMedications( + [ + makeMed({ + id: "rolling", + nextDueAt: tomorrowNoon.toISOString(), + nextDueOverdue: false, + }), + ], + opts, + ); + expect(due).toEqual([]); + }); + + it("excludes a day-scale dose already taken earlier in its period (taken-early downgrade)", () => { + // Weekly cadence on today's weekday; the shot landed yesterday — the + // dose is on board, a "take all" inclusion would prompt a double dose. + const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); + const due = deriveDueMedications( + [ + makeMed({ + id: "weekly", + lastTakenAt: yesterday.toISOString(), + schedules: [ + { + windowStart: "12:00", + windowEnd: "12:00", + daysOfWeek: String(now.getUTCDay()), + timesOfDay: ["12:00"], + dose: null, + }, + ], + }), + ], + opts, + ); + expect(due).toEqual([]); + }); + + it("preserves the input (page) order", () => { + const due = deriveDueMedications( + [makeMed({ id: "z" }), makeMed({ id: "a" })], + opts, + ); + expect(due.map((d) => d.id)).toEqual(["z", "a"]); + }); +}); + +describe("runTakeAllDue — per-medication loop with failure isolation", () => { + const slotA = new Date(Date.now() - 60 * 60 * 1000); + const slotB = new Date(Date.now() - 30 * 60 * 1000); + const dueTwo: DueMedication[] = [ + { id: "med-a", name: "A", dose: "10 mg", window: null, scheduledFor: slotA }, + { id: "med-b", name: "B", dose: "5 mg", window: null, scheduledFor: slotB }, + ]; + + function okResponse(id: string) { + return { + ok: true, + json: vi.fn().mockResolvedValue({ data: { id } }), + text: vi.fn().mockResolvedValue(JSON.stringify({ data: { id } })), + }; + } + + it("posts each due medication to ITS intake route with the displayed slot, exactly like a card tap", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(okResponse("evt-a")) + .mockResolvedValueOnce(okResponse("evt-b")); + vi.stubGlobal("fetch", fetchMock); + const queryClient = fakeQueryClient(); + + const result = await runTakeAllDue({ medications: dueTwo, t, queryClient }); + + expect(result).toEqual({ taken: 2, failed: 0 }); + expect(fetchMock.mock.calls[0][0]).toBe("/api/medications/med-a/intake"); + expect(fetchMock.mock.calls[1][0]).toBe("/api/medications/med-b/intake"); + const bodyA = JSON.parse( + (fetchMock.mock.calls[0][1] as RequestInit).body as string, + ); + expect(bodyA).toEqual({ + skipped: false, + scheduledFor: slotA.toISOString(), + }); + expect(vi.mocked(toast.success)).toHaveBeenCalledWith( + 'medications.takeAllDue.successToast:{"count":2}', + ); + vi.unstubAllGlobals(); + }); + + it("omits scheduledFor for a PRN due entry (null slot), preserving the server now-snap path", async () => { + const fetchMock = vi.fn().mockResolvedValue(okResponse("evt-prn")); + vi.stubGlobal("fetch", fetchMock); + + await runTakeAllDue({ + medications: [ + { id: "med-p", name: "P", dose: "", window: null, scheduledFor: null }, + ], + t, + queryClient: fakeQueryClient(), + }); + + const body = JSON.parse( + (fetchMock.mock.calls[0][1] as RequestInit).body as string, + ); + expect(body).toEqual({ skipped: false }); + expect(body).not.toHaveProperty("scheduledFor"); + vi.unstubAllGlobals(); + }); + + it("reports a partial failure ('1 recorded, 1 failed') and still invalidates for the landed take", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(okResponse("evt-a")) + .mockResolvedValueOnce({ ok: false, json: vi.fn(), text: vi.fn() }); + vi.stubGlobal("fetch", fetchMock); + const queryClient = fakeQueryClient(); + + const result = await runTakeAllDue({ medications: dueTwo, t, queryClient }); + + expect(result).toEqual({ taken: 1, failed: 1 }); + expect(vi.mocked(toast.error)).toHaveBeenCalledWith( + 'medications.takeAllDue.partialToast:{"taken":1,"failed":1}', + ); + expect(vi.mocked(toast.success)).not.toHaveBeenCalledWith( + expect.stringContaining("successToast"), + ); + // The landed take must reach every dependent consumer. + expect(queryClient.invalidateQueries).toHaveBeenCalledTimes( + medicationDependentKeys.length, + ); + vi.unstubAllGlobals(); + }); + + it("reports total failure without invalidating (nothing changed server-side)", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: false, json: vi.fn(), text: vi.fn() }), + ); + const queryClient = fakeQueryClient(); + + const result = await runTakeAllDue({ medications: dueTwo, t, queryClient }); + + expect(result).toEqual({ taken: 0, failed: 2 }); + expect(vi.mocked(toast.error)).toHaveBeenCalledWith( + "medications.takeAllDue.failedToast", + ); + expect(queryClient.invalidateQueries).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); + }); + + it("invalidates exactly the medicationDependentKeys bundle (source pin)", () => { + // The bundle includes the dashboard snapshot key (v1.16.11), so a + // batch take refreshes the hero dose tally too. Pin the source so a + // refactor to a hand-rolled key list cannot slip through. + const src = readFileSync( + resolve(__dirname, "../take-all-due.ts"), + "utf8", + ); + expect(src).toContain( + "await invalidateKeys(queryClient, medicationDependentKeys)", + ); + }); +}); + +describe(" — confirm dialog lists the due medications", () => { + const dialogSrc = readFileSync( + resolve(__dirname, "../take-all-due-dialog.tsx"), + "utf8", + ); + const pageSrc = readFileSync( + resolve(__dirname, "../../../app/medications/page.tsx"), + "utf8", + ); + + function render(node: React.ReactNode): string { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return renderToStaticMarkup( + + {node} + , + ); + } + + const due: DueMedication[] = [ + { + id: "med-a", + name: "Metoprolol", + dose: "47,5 mg", + window: { start: "07:00", end: "09:00" }, + scheduledFor: new Date(Date.now() - 60 * 60 * 1000), + }, + { + id: "med-b", + name: "Vitamin D", + dose: "20 000 IE", + window: { start: "11:00", end: "13:00" }, + scheduledFor: new Date(Date.now() - 5 * 60 * 1000), + }, + ]; + + it("renders each due medication with name, dose and window plus the confirm/cancel footer", () => { + const html = render( + , + ); + expect(html).toContain("Metoprolol"); + expect(html).toContain("47,5 mg"); + expect(html).toContain("Vitamin D"); + // DE window formatting via the shared formatter. + expect(html).toContain("07:00 bis 09:00 Uhr"); + expect(html).toContain("11:00 bis 13:00 Uhr"); + // Title + confirm carrying the count, German du-Form copy. + expect(html).toContain("Fällige Medikamente einnehmen"); + expect(html).toContain("Alle einnehmen (2)"); + expect(html).toContain("Abbrechen"); + }); + + it("does not render when closed", () => { + const html = render( + , + ); + expect(html).toBe(""); + }); + + it("guards the take loop behind a submitting state and refuses to close mid-flight (source pin)", () => { + expect(dialogSrc).toContain( + "const [submitting, setSubmitting] = useState(false)", + ); + expect(dialogSrc).toMatch( + /async function handleConfirm\(\) \{[\s\S]*setSubmitting\(true\)[\s\S]*await runTakeAllDue\([\s\S]*finally \{[\s\S]*setSubmitting\(false\)/, + ); + expect(dialogSrc).toContain("if (submitting && !next) return;"); + // 44-px mobile tap floor on both footer actions. + expect(dialogSrc).toContain('className="min-h-11 sm:min-h-9"'); + }); + + it("earns its medications-page header slot only when at least two medications are due (source pin)", () => { + expect(pageSrc).toContain("dueMeds.length >= 2 && ("); + expect(pageSrc).toContain("deriveDueMedications(activeMeds"); + expect(pageSrc).toContain('t("medications.takeAllDue.button")'); + }); +}); diff --git a/src/components/medications/take-all-due-dialog.tsx b/src/components/medications/take-all-due-dialog.tsx new file mode 100644 index 000000000..78b5558ae --- /dev/null +++ b/src/components/medications/take-all-due-dialog.tsx @@ -0,0 +1,139 @@ +"use client"; + +/** + * v1.16.11 (#316) — confirm dialog for the medications-page "Alle + * fälligen einnehmen" header action. Lists every currently-due + * medication (name + dose + matched dose window) and records all of them + * in one confirmed action through `runTakeAllDue` (the per-medication + * intake loop — see `take-all-due.ts` for the loop-vs-bulk decision). + * + * The component owns only the dialog shell + the submitting guard; the + * derivation and the recording orchestration live in the pure, + * injection-tested module. While a run is in flight the dialog refuses + * to close (mirroring `LogInjectionSiteDialog`'s controlled-submit + * contract) so the summary toast is never orphaned mid-request. + */ + +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { CheckCheck, Loader2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useTranslations } from "@/lib/i18n/context"; +import { formatTimeWindowRange } from "@/lib/time-window-format"; +import { + runTakeAllDue, + type DueMedication, +} from "@/components/medications/take-all-due"; + +export interface TakeAllDueDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** The currently-due set from `deriveDueMedications`, in page order. */ + dueMedications: DueMedication[]; +} + +export function TakeAllDueDialog({ + open, + onOpenChange, + dueMedications, +}: TakeAllDueDialogProps) { + const { t, locale } = useTranslations(); + const queryClient = useQueryClient(); + const [submitting, setSubmitting] = useState(false); + + async function handleConfirm() { + if (submitting) return; + setSubmitting(true); + try { + await runTakeAllDue({ medications: dueMedications, t, queryClient }); + // Close regardless of partial failures — the summary toast carries + // the counts and failed medications stay due on the page behind it. + onOpenChange(false); + } finally { + setSubmitting(false); + } + } + + return ( + { + // Refuse to close while the take loop is in flight. + if (submitting && !next) return; + onOpenChange(next); + }} + > + + + {t("medications.takeAllDue.dialogTitle")} + + {t("medications.takeAllDue.dialogDescription")} + + +
    + {dueMedications.map((med) => ( +
  • + + {med.name} + {med.dose ? ( + — {med.dose} + ) : null} + + {med.window ? ( + + {formatTimeWindowRange( + med.window.start, + med.window.end, + locale, + )} + + ) : null} +
  • + ))} +
+ + + + +
+
+ ); +} diff --git a/src/components/medications/take-all-due.ts b/src/components/medications/take-all-due.ts new file mode 100644 index 000000000..b8e11945b --- /dev/null +++ b/src/components/medications/take-all-due.ts @@ -0,0 +1,217 @@ +/** + * v1.16.11 (#316) — "take all due at once": the due-set derivation plus + * the confirm-time orchestration behind the medications-page header + * button. A user with several medications due in the current window + * confirms them in ONE action instead of per-card taps. + * + * Due-set derivation reuses the EXACT pipeline a single card runs — + * `reduceCurrentWindowStatus` (band model + server display-due gate + + * taken-early downgrade) and `resolveDisplayedSlotInstant` (the canonical + * slot the card's own "Genommen" button would record) — so the set the + * dialog lists is, by construction, the set of cards currently showing a + * take-now / overdue pill. No new endpoint: every input rides on the + * `GET /api/medications` list payload the page already holds. + * + * Recording loops the per-medication `POST /api/medications/{id}/intake` + * (the same call `runRecordIntake` makes for one card) rather than the + * `POST /api/medications/intake/bulk` endpoint, deliberately: + * + * - The bulk route is the iOS SyncMode surface: its taken path requires + * an explicit `takenAt` and attributes by band membership + * (`resolveSlotForWriteByBand`) or a `forceSlotInstant` user pin. The + * interactive card semantics instead send the DISPLAYED slot as + * `scheduledFor` and let the server stamp `takenAt = now` — for an + * overdue slot whose catch-up tail extends past the band, the two + * attributions can diverge. Looping the single route keeps slot + * attribution, inventory consumption (`consumedTransition` gate) and + * the canonical-slot upsert idempotency byte-identical to N + * individual taps. + * - Per-medication failure isolation falls out naturally: each POST + * succeeds or fails alone, the summary toast reports both counts, and + * the failed medications simply stay due (the invalidation refetch + * re-derives them into the set). + * + * The due set is small (a handful of medications, never the 500-entry + * sync scale), so N sequential round trips are negligible. + */ + +import type { QueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { apiPost } from "@/lib/api/api-fetch"; +import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys"; +import { + reduceCurrentWindowStatus, + toZonedDate, + type ScheduleWindowInput, +} from "@/lib/medications/window-status"; +import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant"; + +type Translator = ( + key: string, + params?: Record, +) => string; + +/** Schedule shape the derivation needs — the list payload is a superset. */ +export interface DueDerivationSchedule extends ScheduleWindowInput { + dose: string | null; +} + +/** Medication shape the derivation needs — the list payload is a superset. */ +export interface DueDerivationMedication { + id: string; + name: string; + dose: string; + active: boolean; + pausedAt: string | null; + lastTakenAt: string | null; + todayEventCount?: number | null; + /** Server display-due verdict from `GET /api/medications`. */ + nextDueAt?: string | null; + nextDueOverdue?: boolean; + schedules: DueDerivationSchedule[]; +} + +/** One medication the confirm dialog lists and the take loop records. */ +export interface DueMedication { + id: string; + name: string; + /** Matched schedule dose, falling back to the medication-level dose. */ + dose: string; + /** HH:mm bounds of the matched dose band, for the dialog's window line. */ + window: { start: string; end: string } | null; + /** + * Canonical slot instant the take records (threaded as `scheduledFor`, + * exactly like the card's own button). Null preserves the PRN / + * unscheduled now-snap path. + */ + scheduledFor: Date | null; +} + +const DEFAULT_THRESHOLDS = { lateMinutes: 120, missedMinutes: 240 }; + +/** + * Derive the medications that are due RIGHT NOW — active, unpaused, with + * a non-null current-window status (in_window / late / very_late) that is + * not the day-scale taken-early downgrade. Input order is preserved so + * the dialog lists medications in the same order the page renders them. + */ +export function deriveDueMedications( + medications: DueDerivationMedication[], + options: { + now?: Date; + /** IANA timezone (profile timezone; Berlin is the legacy fallback). */ + tz?: string; + thresholds?: { lateMinutes: number; missedMinutes: number }; + } = {}, +): DueMedication[] { + const now = options.now ?? new Date(); + const tz = options.tz ?? "Europe/Berlin"; + const { lateMinutes, missedMinutes } = + options.thresholds ?? DEFAULT_THRESHOLDS; + const nowLocal = toZonedDate(now, tz); + + const due: DueMedication[] = []; + for (const m of medications) { + // Paused courses keep their schedules but must never surface here — + // the cards suppress their pills the same way. + if (!m.active || m.pausedAt) continue; + + const nextDueMs = m.nextDueAt ? new Date(m.nextDueAt).getTime() : NaN; + const status = reduceCurrentWindowStatus({ + schedules: m.schedules, + nowBerlin: nowLocal, + lateMinutes, + missedMinutes, + active: m.active, + lastTakenAt: m.lastTakenAt, + todayEventCount: m.todayEventCount ?? 0, + tz, + // The same server display-due gate the cards apply: a future + // (non-overdue) next-due suppresses the overdue tiers; `undefined` + // (older mocks) keeps the legacy band-only behaviour. + nextDue: + m.nextDueAt === undefined + ? undefined + : Number.isFinite(nextDueMs) + ? { at: new Date(nextDueMs), overdue: m.nextDueOverdue === true } + : null, + }); + // `takenEarlyDaysAgo` non-null = a day-scale dose already on board + // earlier in its period; including it would build a double-dose + // confirm list. + if (!status.status || status.takenEarlyDaysAgo !== null) continue; + + due.push({ + id: m.id, + name: m.name, + dose: status.schedule?.dose ?? m.dose, + window: status.window + ? { start: status.window.start, end: status.window.end } + : null, + scheduledFor: resolveDisplayedSlotInstant({ + currentWindowStatus: status, + nextDueAt: m.nextDueAt ?? null, + now, + timeZone: tz, + }), + }); + } + return due; +} + +/** + * Record every due medication through the per-medication intake route, + * sequentially, with per-medication failure isolation (see the module doc + * for the loop-vs-bulk decision). Dependencies are injected so this is + * unit-testable without a React render, mirroring `runRecordIntake`. + * + * Toast contract: all-success → one success summary; partial → an error + * summary carrying both counts ("3 erfasst, 1 fehlgeschlagen"); all-fail + * → a plain failure prompt. Failed medications stay due — the dependent- + * key invalidation refetches the list and the derivation re-surfaces + * them. The optional per-card injection-site follow-up prompt is + * intentionally NOT part of the batch confirm (it is skippable on the + * single path too); the site stays editable from the intake history. + */ +export async function runTakeAllDue(deps: { + medications: DueMedication[]; + t: Translator; + queryClient: QueryClient; +}): Promise<{ taken: number; failed: number }> { + const { medications, t, queryClient } = deps; + let taken = 0; + let failed = 0; + + for (const med of medications) { + try { + await apiPost( + `/api/medications/${med.id}/intake`, + med.scheduledFor + ? { skipped: false, scheduledFor: med.scheduledFor.toISOString() } + : { skipped: false }, + ); + taken += 1; + } catch { + failed += 1; + } + } + + // One invalidation for the whole batch (the bundle includes the + // dashboard snapshot key, so the hero dose tally refreshes too). A + // zero-success run changed nothing server-side — skip the refetch and + // keep the due set as-is for the retry. + if (taken > 0) { + await invalidateKeys(queryClient, medicationDependentKeys); + } + + if (failed === 0) { + toast.success(t("medications.takeAllDue.successToast", { count: taken })); + } else if (taken > 0) { + toast.error(t("medications.takeAllDue.partialToast", { taken, failed })); + } else { + toast.error(t("medications.takeAllDue.failedToast")); + } + + return { taken, failed }; +}