From ba17b08a0191cc6268a57cd0e34a883a416075bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 23:49:25 +0200 Subject: [PATCH] feat(medications): split the Add button into log-intake vs new-medication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The top medications-page Add button now opens a choice menu with two paths: log an intake against an existing medication — including a backdated one — or create a new medication (the existing wizard, unchanged). The log-intake dialog lists the active medications, optionally pins a schedule slot, and picks a date and time that defaults to now but accepts any past instant. It submits to the existing per-medication intake route: takenAt carries the (possibly backdated) instant, and a chosen slot supplies scheduledFor so the write routes through the server's canonical slot upsert — the same snap path a live Taken tap uses — keeping the one-row-per-dose-slot invariant intact. No new endpoint. Submit orchestration lives in a pure, dependency-injected runLogIntake helper alongside the shared take/skip logic, invalidating the medication dependent query-key bundle so the dashboard and compliance views refresh. New strings land in all six locales. Co-Authored-By: Claude Opus 4.8 (1M context) --- messages/de.json | 17 ++ messages/en.json | 17 ++ messages/es.json | 17 ++ messages/fr.json | 17 ++ messages/it.json | 17 ++ messages/pl.json | 17 ++ src/app/medications/page.tsx | 70 ++++- .../__tests__/use-medication-intake.test.ts | 94 ++++++ .../medications/log-intake-dialog.tsx | 275 ++++++++++++++++++ .../medications/use-medication-intake.ts | 59 ++++ 10 files changed, 592 insertions(+), 8 deletions(-) create mode 100644 src/components/medications/log-intake-dialog.tsx diff --git a/messages/de.json b/messages/de.json index da8dcf50f..fffbd59f3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -590,6 +590,23 @@ "editMedication": "Medikament bearbeiten", "newMedication": "Neues Medikament", "firstMedication": "Erstes Medikament anlegen", + "addChoice": { + "logIntake": "Einnahme erfassen", + "newMedication": "Neues Medikament" + }, + "logIntake": { + "dialogTitle": "Einnahme erfassen", + "dialogDescription": "Erfasse eine Dosis für ein vorhandenes Medikament. Du kannst sie auf ein vergangenes Datum und eine vergangene Uhrzeit zurückdatieren.", + "medicationLabel": "Medikament", + "slotLabel": "Einnahmeslot", + "slotNone": "Kein bestimmter Slot", + "takenAtLabel": "Datum und Uhrzeit", + "takenAtHint": "Standard ist jetzt; wähle ein früheres Datum und eine frühere Uhrzeit, um eine vergangene Dosis zu erfassen.", + "skippedLabel": "Als ausgelassen erfassen", + "noMedications": "Lege zuerst ein Medikament an, dann kannst du Einnahmen dafür erfassen.", + "cancel": "Abbrechen", + "submit": "Einnahme erfassen" + }, "emptyTitle": "Noch keine Medikamente", "emptyDescription": "Lege Medikamente, Dosierungen und Einnahmefenster an, um Compliance zu erfassen.", "loadFailed": "Medikamente konnten nicht geladen werden", diff --git a/messages/en.json b/messages/en.json index 14a5ae67e..ae5cfcb9a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -590,6 +590,23 @@ "editMedication": "Edit medication", "newMedication": "New medication", "firstMedication": "Add first medication", + "addChoice": { + "logIntake": "Log an intake", + "newMedication": "New medication" + }, + "logIntake": { + "dialogTitle": "Log an intake", + "dialogDescription": "Record a dose against an existing medication. You can backdate it to a past date and time.", + "medicationLabel": "Medication", + "slotLabel": "Schedule slot", + "slotNone": "No specific slot", + "takenAtLabel": "Date and time", + "takenAtHint": "Defaults to now; pick an earlier date and time to log a past dose.", + "skippedLabel": "Logged as skipped", + "noMedications": "Add a medication first, then you can log intakes against it.", + "cancel": "Cancel", + "submit": "Log intake" + }, "emptyTitle": "No medications yet", "emptyDescription": "Add medications, dosages, and reminder windows to track compliance.", "loadFailed": "Could not load medications", diff --git a/messages/es.json b/messages/es.json index 6b0bd6d81..8ba11e396 100644 --- a/messages/es.json +++ b/messages/es.json @@ -590,6 +590,23 @@ "editMedication": "Editar medicamento", "newMedication": "Nuevo medicamento", "firstMedication": "Añadir el primer medicamento", + "addChoice": { + "logIntake": "Registrar una toma", + "newMedication": "Nuevo medicamento" + }, + "logIntake": { + "dialogTitle": "Registrar una toma", + "dialogDescription": "Registra una dosis de un medicamento existente. Puedes posfecharla a una fecha y hora pasadas.", + "medicationLabel": "Medicamento", + "slotLabel": "Franja del horario", + "slotNone": "Sin franja concreta", + "takenAtLabel": "Fecha y hora", + "takenAtHint": "Por defecto es ahora; elige una fecha y hora anteriores para registrar una dosis pasada.", + "skippedLabel": "Registrar como omitida", + "noMedications": "Añade primero un medicamento y luego podrás registrar tomas para él.", + "cancel": "Cancelar", + "submit": "Registrar toma" + }, "emptyTitle": "Aún no hay medicamentos", "emptyDescription": "Crea medicamentos, dosis y franjas horarias para registrar el cumplimiento.", "loadFailed": "No se han podido cargar los medicamentos", diff --git a/messages/fr.json b/messages/fr.json index 1aa77364e..260ca6483 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -590,6 +590,23 @@ "editMedication": "Modifier le médicament", "newMedication": "Nouveau médicament", "firstMedication": "Ajouter le premier médicament", + "addChoice": { + "logIntake": "Enregistrer une prise", + "newMedication": "Nouveau médicament" + }, + "logIntake": { + "dialogTitle": "Enregistrer une prise", + "dialogDescription": "Enregistrez une dose pour un médicament existant. Vous pouvez l’antidater à une date et une heure passées.", + "medicationLabel": "Médicament", + "slotLabel": "Créneau du planning", + "slotNone": "Aucun créneau précis", + "takenAtLabel": "Date et heure", + "takenAtHint": "Par défaut maintenant ; choisissez une date et une heure antérieures pour enregistrer une dose passée.", + "skippedLabel": "Enregistrer comme ignorée", + "noMedications": "Ajoutez d’abord un médicament, puis vous pourrez enregistrer des prises pour celui-ci.", + "cancel": "Annuler", + "submit": "Enregistrer la prise" + }, "emptyTitle": "Aucun médicament pour l’instant", "emptyDescription": "Crée des médicaments, des doses et des fenêtres horaires pour suivre l’observance.", "loadFailed": "Impossible de charger les médicaments", diff --git a/messages/it.json b/messages/it.json index 793cdb860..0e8e97df1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -590,6 +590,23 @@ "editMedication": "Modifica farmaco", "newMedication": "Nuovo farmaco", "firstMedication": "Aggiungi il primo farmaco", + "addChoice": { + "logIntake": "Registra un'assunzione", + "newMedication": "Nuovo farmaco" + }, + "logIntake": { + "dialogTitle": "Registra un'assunzione", + "dialogDescription": "Registra una dose per un farmaco esistente. Puoi retrodatarla a una data e un'ora passate.", + "medicationLabel": "Farmaco", + "slotLabel": "Fascia della pianificazione", + "slotNone": "Nessuna fascia specifica", + "takenAtLabel": "Data e ora", + "takenAtHint": "Per impostazione predefinita è adesso; scegli una data e un'ora precedenti per registrare una dose passata.", + "skippedLabel": "Registra come saltata", + "noMedications": "Aggiungi prima un farmaco, poi potrai registrarne le assunzioni.", + "cancel": "Annulla", + "submit": "Registra assunzione" + }, "emptyTitle": "Ancora nessun farmaco", "emptyDescription": "Crea farmaci, dosi e fasce orarie per registrare l’aderenza.", "loadFailed": "Impossibile caricare i farmaci", diff --git a/messages/pl.json b/messages/pl.json index 88d334f24..23b7da3e8 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -590,6 +590,23 @@ "editMedication": "Edytuj lek", "newMedication": "Nowy lek", "firstMedication": "Dodaj pierwszy lek", + "addChoice": { + "logIntake": "Zarejestruj przyjęcie", + "newMedication": "Nowy lek" + }, + "logIntake": { + "dialogTitle": "Zarejestruj przyjęcie", + "dialogDescription": "Zarejestruj dawkę istniejącego leku. Możesz wstawić ją wstecznie na przeszłą datę i godzinę.", + "medicationLabel": "Lek", + "slotLabel": "Przedział harmonogramu", + "slotNone": "Bez konkretnego przedziału", + "takenAtLabel": "Data i godzina", + "takenAtHint": "Domyślnie teraz; wybierz wcześniejszą datę i godzinę, aby zarejestrować przeszłą dawkę.", + "skippedLabel": "Zarejestruj jako pominięte", + "noMedications": "Najpierw dodaj lek, a potem będziesz mógł rejestrować jego przyjęcia.", + "cancel": "Anuluj", + "submit": "Zarejestruj przyjęcie" + }, "emptyTitle": "Brak leków", "emptyDescription": "Utwórz leki, dawki i okna czasowe, aby śledzić przestrzeganie.", "loadFailed": "Nie udało się załadować leków", diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index 251020397..23c4c756a 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -12,11 +12,18 @@ import { MedicationWizardDialog } from "@/components/medications/wizard/Medicati import type { MedicationPayload } from "@/components/medications/wizard/wizard-payload"; import { MedicationCard } from "@/components/medications/medication-card"; import { Glp1MedicationCard } from "@/components/medications/glp1-medication-card"; +import { LogIntakeDialog } from "@/components/medications/log-intake-dialog"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { EmptyState } from "@/components/ui/empty-state"; import { Skeleton } from "@/components/ui/skeleton"; -import { Loader2, Pill, Plus } from "lucide-react"; +import { CheckCircle2, Loader2, Pill, Plus } from "lucide-react"; interface Schedule { id: string; @@ -129,6 +136,10 @@ export default function MedicationsPage() { // `` mounted below, keyed to the selected // medication (reused, not duplicated per card). const [advancedMed, setAdvancedMed] = useState(null); + // v1.14.0 — the medications-page "Add" choice. The top button now offers + // 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); useEffect(() => { if (shouldOpenFromUrl) { @@ -256,13 +267,33 @@ export default function MedicationsPage() { {t("medications.subtitle")}

- {/* v1.12.2 — match the dashboard Add button's responsive tap-target - floor (`min-h-11 sm:min-h-9`) so both primary "add" entry points - clear the WCAG 2.5.5 44px mobile minimum identically. */} - + {/* v1.14.0 — the "Add" button is now a choice: log an intake + (incl. a backdated one) against an existing medication, or + create a new medication. v1.12.2 — match the dashboard Add + button's responsive tap-target floor (`min-h-11 sm:min-h-9`) so + both primary "add" entry points clear the WCAG 2.5.5 44px mobile + minimum identically. */} + + + + + + setLogIntakeOpen(true)} + disabled={activeMeds.length === 0} + > + + {t("medications.addChoice.logIntake")} + + + + {t("medications.addChoice.newMedication")} + + + {isLoading ? ( @@ -393,6 +424,29 @@ export default function MedicationsPage() { onSuccess={closeDialog} /> + {/* v1.14.0 — log-intake path of the "Add" choice. Lists the active + medications, lets the user pin a schedule slot, and picks a + 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. */} + {logIntakeOpen && ( + ({ + id: m.id, + name: m.name, + dose: m.dose, + schedules: m.schedules.map((s) => ({ + windowStart: s.windowStart, + label: s.label, + dose: s.dose, + timesOfDay: s.timesOfDay, + })), + }))} + /> + )} + {/* v1.7.1 — one shared advanced-settings sheet for every card. Reuses the exact component the detail page mounts (Data / Reminders / Lifecycle / Danger zone); the card's sliders button diff --git a/src/components/medications/__tests__/use-medication-intake.test.ts b/src/components/medications/__tests__/use-medication-intake.test.ts index 45be74e1a..48274afe0 100644 --- a/src/components/medications/__tests__/use-medication-intake.test.ts +++ b/src/components/medications/__tests__/use-medication-intake.test.ts @@ -3,6 +3,7 @@ import type { QueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { + runLogIntake, runRecordIntake, runUndoIntake, } from "@/components/medications/use-medication-intake"; @@ -255,6 +256,99 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => { }); }); +describe("runLogIntake — manual backdated intake from the Add choice", () => { + it("posts a backdated takenAt against the medication's intake route and returns true", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn() }); + vi.stubGlobal("fetch", fetchMock); + const queryClient = fakeQueryClient(); + + const ok = await runLogIntake({ + medication, + skipped: false, + takenAt: "2026-06-01T08:30:00.000Z", + t, + queryClient, + }); + + expect(ok).toBe(true); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("/api/medications/med-1/intake"); + const body = JSON.parse((init as RequestInit).body as string); + // Backdated instant is carried verbatim; no slot → no scheduledFor. + expect(body).toEqual({ skipped: false, takenAt: "2026-06-01T08:30:00.000Z" }); + expect(body).not.toHaveProperty("scheduledFor"); + expect(queryClient.invalidateQueries).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith( + "medications.intakeToastTaken:Mounjaro", + ); + }); + + it("threads scheduledFor (the chosen slot) so the write routes through the canonical slot upsert", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn() }); + vi.stubGlobal("fetch", fetchMock); + + await runLogIntake({ + medication, + skipped: false, + takenAt: "2026-06-01T05:10:00.000Z", + scheduledFor: "2026-06-01T05:00:00.000Z", + t, + queryClient: fakeQueryClient(), + }); + + const body = JSON.parse( + (fetchMock.mock.calls[0][1] as RequestInit).body as string, + ); + expect(body).toEqual({ + skipped: false, + takenAt: "2026-06-01T05:10:00.000Z", + scheduledFor: "2026-06-01T05:00:00.000Z", + }); + }); + + it("omits takenAt on a skipped log and shows the skipped toast", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn() }); + vi.stubGlobal("fetch", fetchMock); + + await runLogIntake({ + medication, + skipped: true, + takenAt: "2026-06-01T08:30:00.000Z", + t, + queryClient: fakeQueryClient(), + }); + + const body = JSON.parse( + (fetchMock.mock.calls[0][1] as RequestInit).body as string, + ); + expect(body).toEqual({ skipped: true }); + expect(body).not.toHaveProperty("takenAt"); + expect(toast.success).toHaveBeenCalledWith( + "medications.intakeToastSkipped:Mounjaro", + ); + }); + + it("surfaces the failure toast and returns false on a non-ok POST", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, json: vi.fn() })); + const queryClient = fakeQueryClient(); + + const ok = await runLogIntake({ + medication, + skipped: false, + takenAt: "2026-06-01T08:30:00.000Z", + t, + queryClient, + }); + + expect(ok).toBe(false); + expect(toast.error).toHaveBeenCalledWith( + "medications.intakeToastFailed:Mounjaro", + ); + expect(toast.success).not.toHaveBeenCalled(); + expect(queryClient.invalidateQueries).not.toHaveBeenCalled(); + }); +}); + describe("runUndoIntake — shared soft-delete", () => { it("reverts via DELETE and confirms on success", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true }); diff --git a/src/components/medications/log-intake-dialog.tsx b/src/components/medications/log-intake-dialog.tsx new file mode 100644 index 000000000..b1abc918a --- /dev/null +++ b/src/components/medications/log-intake-dialog.tsx @@ -0,0 +1,275 @@ +"use client"; + +/** + * v1.14.0 — manual intake logger reachable from the medications-page + * "Add" choice. The top-of-page Add button now offers two paths: create a + * new medication (the existing wizard) or LOG AN INTAKE against an existing + * medication — including a backdated one. + * + * The form picks a medication, optionally pins a schedule slot, and a + * date+time that defaults to "now" but accepts any past instant. It submits + * to the existing `POST /api/medications/{id}/intake` route: `takenAt` + * carries the (possibly backdated) instant, and a chosen slot supplies + * `scheduledFor` so the write routes through the server's canonical slot + * upsert — the same snap/upsert path a normal "Taken" tap uses — keeping + * the one-row-per-dose-slot invariant intact. No new endpoint is minted. + * + * The submit orchestration lives in the pure, injection-tested + * `runLogIntake` (see `use-medication-intake.ts`); this component owns only + * the form state + the medication/slot pickers. + */ + +import { useMemo, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { NativeSelect } from "@/components/ui/native-select"; +import { Switch } from "@/components/ui/switch"; +import { useTranslations } from "@/lib/i18n/context"; +import { runLogIntake } from "@/components/medications/use-medication-intake"; + +interface LogIntakeSchedule { + windowStart: string; + label: string | null; + dose: string | null; + timesOfDay?: string[]; +} + +export interface LogIntakeMedication { + id: string; + name: string; + dose: string; + schedules: LogIntakeSchedule[]; +} + +export interface LogIntakeDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Active medications the user can log against. */ + medications: LogIntakeMedication[]; +} + +/** Sentinel option value meaning "no specific schedule slot" (PRN). */ +const NO_SLOT = "__none__"; + +/** + * `` shape `YYYY-MM-DDTHH:mm` for the given + * Date rendered in the user's local time. Mirrors the intake-edit dialog so + * both date pickers read the same way. + */ +function toDateTimeLocal(d: Date): string { + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad( + d.getHours(), + )}:${pad(d.getMinutes())}`; +} + +/** + * Distinct HH:mm slot times for a medication, derived from each schedule's + * first-class `timesOfDay` (falling back to `windowStart`). Sorted so the + * morning slot leads. Empty for a medication with no time-anchored slots + * (the PRN-only case), which simply hides the slot picker. + */ +function slotTimesFor(med: LogIntakeMedication | undefined): string[] { + if (!med) return []; + const times = new Set(); + for (const s of med.schedules) { + const fromTimes = s.timesOfDay && s.timesOfDay.length > 0; + if (fromTimes) { + for (const tod of s.timesOfDay!) times.add(tod); + } else if (s.windowStart) { + times.add(s.windowStart); + } + } + return Array.from(times).sort((a, b) => a.localeCompare(b)); +} + +/** + * Combine a `YYYY-MM-DD` date part (read off the picked `takenAt`) with an + * `HH:mm` slot time into an ISO instant in the user's local timezone. This + * is the slot instant on the chosen day; the server snaps it to the + * canonical slot via the same upsert a live "Taken" tap uses. + */ +function slotInstantIso(takenAtLocal: string, slotHm: string): string | null { + const datePart = takenAtLocal.slice(0, 10); + if (datePart.length !== 10) return null; + const d = new Date(`${datePart}T${slotHm}`); + if (Number.isNaN(d.getTime())) return null; + return d.toISOString(); +} + +export function LogIntakeDialog({ + open, + onOpenChange, + medications, +}: LogIntakeDialogProps) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + + const [medicationId, setMedicationId] = useState( + () => medications[0]?.id ?? "", + ); + const [slot, setSlot] = useState(NO_SLOT); + const [takenAt, setTakenAt] = useState(() => + toDateTimeLocal(new Date()), + ); + const [skipped, setSkipped] = useState(false); + const [busy, setBusy] = useState(false); + + const selected = useMemo( + () => medications.find((m) => m.id === medicationId), + [medications, medicationId], + ); + const slotTimes = useMemo(() => slotTimesFor(selected), [selected]); + + function handleMedicationChange(id: string) { + setMedicationId(id); + // Reset the slot when switching medications — the prior slot may not + // exist on the new schedule. + setSlot(NO_SLOT); + } + + async function submit() { + if (!medicationId || busy) return; + const med = medications.find((m) => m.id === medicationId); + if (!med) return; + setBusy(true); + try { + const scheduledFor = + slot !== NO_SLOT ? (slotInstantIso(takenAt, slot) ?? undefined) : undefined; + const ok = await runLogIntake({ + medication: { id: med.id, name: med.name }, + skipped, + takenAt: new Date(takenAt).toISOString(), + scheduledFor, + t, + queryClient, + }); + if (ok) onOpenChange(false); + } finally { + setBusy(false); + } + } + + return ( + !busy && onOpenChange(o)}> + + + {t("medications.logIntake.dialogTitle")} + + {t("medications.logIntake.dialogDescription")} + + + + {medications.length === 0 ? ( +

+ {t("medications.logIntake.noMedications")} +

+ ) : ( +
+
+ + handleMedicationChange(e.target.value)} + > + {medications.map((m) => ( + + ))} + +
+ + {slotTimes.length > 0 && ( +
+ + setSlot(e.target.value)} + > + + {slotTimes.map((time) => ( + + ))} + +
+ )} + +
+ + setTakenAt(e.target.value)} + disabled={skipped} + /> +

+ {t("medications.logIntake.takenAtHint")} +

+
+ + +
+ )} + + + + + +
+
+ ); +} diff --git a/src/components/medications/use-medication-intake.ts b/src/components/medications/use-medication-intake.ts index acafdbec3..08cb2b21d 100644 --- a/src/components/medications/use-medication-intake.ts +++ b/src/components/medications/use-medication-intake.ts @@ -155,6 +155,65 @@ export async function runRecordIntake(deps: { } } +/** + * v1.14.0 — pure orchestration for a manual, possibly BACKDATED intake + * logged against an existing medication from the medications-page "Add" + * choice (the "log an intake" branch). Mirrors {@link runRecordIntake}'s + * dependency-injected shape so it is unit-testable without a React render. + * + * Backdating: the dialog carries a user-picked `takenAt` (local time + * converted to ISO) which the per-medication intake route accepts with no + * future/past restriction. When the user also names a schedule slot the + * dialog supplies `scheduledFor` (the slot instant on the chosen day), so + * the write routes through the server's canonical slot upsert — the same + * snap/upsert path a normal "Taken" tap uses — preserving the + * one-row-per-dose-slot medical invariant. With no slot the route follows + * its unscheduled/PRN insert path. + */ +export async function runLogIntake(deps: { + medication: MedicationIntakeIdentity; + /** True logs a skipped slot; false records a taken dose. */ + skipped: boolean; + /** ISO instant the dose was actually taken; ignored when skipped. */ + takenAt: string; + /** + * ISO slot instant on the chosen day when the user pinned a schedule + * slot. Omit/undefined to route through the unscheduled/PRN path. + */ + scheduledFor?: string; + t: Translator; + queryClient: QueryClient; +}): Promise { + const { medication, skipped, takenAt, scheduledFor, t, queryClient } = deps; + try { + const body: Record = { skipped }; + if (!skipped) body.takenAt = takenAt; + if (scheduledFor) body.scheduledFor = scheduledFor; + const res = await fetch(`/api/medications/${medication.id}/intake`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + toast.error(t("medications.intakeToastFailed", { name: medication.name })); + return false; + } + await invalidateKeys(queryClient, medicationDependentKeys); + toast.success( + t( + skipped + ? "medications.intakeToastSkipped" + : "medications.intakeToastTaken", + { name: medication.name }, + ), + ); + return true; + } catch { + toast.error(t("medications.intakeToastFailed", { name: medication.name })); + return false; + } +} + /** * Pure soft-delete undo for a just-recorded intake, dependencies injected * for the same testability reason as {@link runRecordIntake}.