mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
19a24a5160
commit
778c3a53a4
4 files changed
+894
-2
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user