feat(medications): take every due dose in one confirmed action

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)
This commit is contained in:
Marc-André Bombeck committed 2026-06-12 22:02:47 +02:00
1 parent 19a24a5160
commit 778c3a53a4
4 files changed
+894 -2

No files matched your search

+76 -2
View File
@@ -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 (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
@@ -262,6 +308,22 @@ export default function MedicationsPage() {
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{/* 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 && (
<Button
variant="outline"
onClick={() => setTakeAllOpen(true)}
className="min-h-11 sm:min-h-9"
>
<CheckCheck className="h-4 w-4" aria-hidden="true" />
{t("medications.takeAllDue.button")}
</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 && (
<TakeAllDueDialog
open={takeAllOpen}
onOpenChange={setTakeAllOpen}
dueMedications={dueMeds}
/>
)}
{logIntakeOpen && (
<LogIntakeDialog
open={logIntakeOpen}
@@ -0,0 +1,462 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { QueryClient as QueryClientType } from "@tanstack/react-query";
import { toast } from "sonner";
import { I18nProvider } from "@/lib/i18n/context";
import { medicationDependentKeys } from "@/lib/query-keys";
import {
deriveDueMedications,
runTakeAllDue,
type DueDerivationMedication,
type DueMedication,
} from "@/components/medications/take-all-due";
/**
* v1.16.11 (#316) — "take all due at once" coverage.
*
* 1. `deriveDueMedications` — the due set mirrors the card pills by
* construction: only ACTIVE, UNPAUSED medications with a non-null
* current-window status that is not the day-scale taken-early
* downgrade, gated on the server display-due exactly like the cards.
* 2. `runTakeAllDue` — per-medication POST loop with failure isolation:
* each due medication posts to ITS OWN intake route with the displayed
* slot's `scheduledFor`, the summary toast reports the partial-failure
* counts, and the dependent-key bundle invalidates once when at least
* one take landed.
* 3. `<TakeAllDueDialog>` — 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 }) => (
<div>{children}</div>
);
return {
Dialog: ({
open,
children,
}: {
open: boolean;
children: React.ReactNode;
}) => (open ? <div data-slot="mock-dialog">{children}</div> : 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<string, string | number>) =>
params ? `${key}:${JSON.stringify(params)}` : key;
function fakeQueryClient(): QueryClientType {
return {
invalidateQueries: vi.fn().mockResolvedValue(undefined),
} as unknown as QueryClientType;
}
function makeMed(
overrides: Partial<DueDerivationMedication> & { 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("<TakeAllDueDialog> — 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(
<I18nProvider initialLocale="de">
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nProvider>,
);
}
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(
<TakeAllDueDialog open onOpenChange={vi.fn()} dueMedications={due} />,
);
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(
<TakeAllDueDialog
open={false}
onOpenChange={vi.fn()}
dueMedications={due}
/>,
);
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")');
});
});
@@ -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 (
<Dialog
open={open}
onOpenChange={(next) => {
// Refuse to close while the take loop is in flight.
if (submitting && !next) return;
onOpenChange(next);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("medications.takeAllDue.dialogTitle")}</DialogTitle>
<DialogDescription>
{t("medications.takeAllDue.dialogDescription")}
</DialogDescription>
</DialogHeader>
<ul className="space-y-2" data-testid="take-all-due-list">
{dueMedications.map((med) => (
<li
key={med.id}
className="flex items-baseline justify-between gap-3 text-sm"
>
<span className="min-w-0 truncate">
<span className="font-medium">{med.name}</span>
{med.dose ? (
<span className="text-muted-foreground"> — {med.dose}</span>
) : null}
</span>
{med.window ? (
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
{formatTimeWindowRange(
med.window.start,
med.window.end,
locale,
)}
</span>
) : null}
</li>
))}
</ul>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={submitting}
className="min-h-11 sm:min-h-9"
>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={() => void handleConfirm()}
disabled={submitting || dueMedications.length === 0}
className="min-h-11 sm:min-h-9"
data-testid="take-all-due-confirm"
>
{submitting ? (
<Loader2
className="h-4 w-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<CheckCheck className="h-4 w-4" aria-hidden="true" />
)}
{t("medications.takeAllDue.confirm", {
count: dueMedications.length,
})}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+217
View File
@@ -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, string | number>,
) => 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 };
}