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 && (
+
setTakeAllOpen(true)}
+ className="min-h-11 sm:min-h-9"
+ >
+
+ {t("medications.takeAllDue.button")}
+
+ )}
{/* 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}
+
+ ))}
+
+
+ onOpenChange(false)}
+ disabled={submitting}
+ className="min-h-11 sm:min-h-9"
+ >
+ {t("common.cancel")}
+
+ void handleConfirm()}
+ disabled={submitting || dueMedications.length === 0}
+ className="min-h-11 sm:min-h-9"
+ data-testid="take-all-due-confirm"
+ >
+ {submitting ? (
+
+ ) : (
+
+ )}
+ {t("medications.takeAllDue.confirm", {
+ count: dueMedications.length,
+ })}
+
+
+
+
+ );
+}
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 };
+}