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}