feat(medications): split the Add button into log-intake vs new-medication

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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-05 23:50:08 +02:00
1 parent 2beaec4149
commit ba17b08a01
10 files changed
+592 -8

No files matched your search

+17
View File
@@ -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",
+17
View File
@@ -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",
+17
View File
@@ -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",
+17
View File
@@ -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",
+17
View File
@@ -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",
+17
View File
@@ -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",
+62 -8
View File
@@ -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() {
// `<AdvancedSettingsSheet>` mounted below, keyed to the selected
// medication (reused, not duplicated per card).
const [advancedMed, setAdvancedMed] = useState<Medication | null>(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")}
</p>
</div>
{/* 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. */}
<Button className="min-h-11 sm:min-h-9" onClick={openCreate}>
<Plus className="mr-2 h-4 w-4" />
{t("medications.addMedication")}
</Button>
{/* 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. */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="min-h-11 sm:min-h-9">
<Plus className="mr-2 h-4 w-4" />
{t("medications.addMedication")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => setLogIntakeOpen(true)}
disabled={activeMeds.length === 0}
>
<CheckCircle2 className="mr-2 h-4 w-4" />
{t("medications.addChoice.logIntake")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={openCreate}>
<Pill className="mr-2 h-4 w-4" />
{t("medications.addChoice.newMedication")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{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 && (
<LogIntakeDialog
open={logIntakeOpen}
onOpenChange={setLogIntakeOpen}
medications={activeMeds.map((m) => ({
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
@@ -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 });
@@ -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__";
/**
* `<input type="datetime-local">` 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<string>();
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<string>(
() => medications[0]?.id ?? "",
);
const [slot, setSlot] = useState<string>(NO_SLOT);
const [takenAt, setTakenAt] = useState<string>(() =>
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 (
<Dialog open={open} onOpenChange={(o) => !busy && onOpenChange(o)}>
<DialogContent data-slot="log-intake-dialog">
<DialogHeader>
<DialogTitle>{t("medications.logIntake.dialogTitle")}</DialogTitle>
<DialogDescription>
{t("medications.logIntake.dialogDescription")}
</DialogDescription>
</DialogHeader>
{medications.length === 0 ? (
<p className="text-muted-foreground py-4 text-sm">
{t("medications.logIntake.noMedications")}
</p>
) : (
<div className="space-y-3">
<div className="space-y-1">
<Label htmlFor="log-intake-medication">
{t("medications.logIntake.medicationLabel")}
</Label>
<NativeSelect
id="log-intake-medication"
value={medicationId}
onChange={(e) => handleMedicationChange(e.target.value)}
>
{medications.map((m) => (
<option key={m.id} value={m.id}>
{m.dose ? `${m.name} — ${m.dose}` : m.name}
</option>
))}
</NativeSelect>
</div>
{slotTimes.length > 0 && (
<div className="space-y-1">
<Label htmlFor="log-intake-slot">
{t("medications.logIntake.slotLabel")}
</Label>
<NativeSelect
id="log-intake-slot"
value={slot}
onChange={(e) => setSlot(e.target.value)}
>
<option value={NO_SLOT}>
{t("medications.logIntake.slotNone")}
</option>
{slotTimes.map((time) => (
<option key={time} value={time}>
{time}
</option>
))}
</NativeSelect>
</div>
)}
<div className="space-y-1">
<Label htmlFor="log-intake-taken-at">
{t("medications.logIntake.takenAtLabel")}
</Label>
<Input
id="log-intake-taken-at"
type="datetime-local"
value={takenAt}
max={toDateTimeLocal(new Date())}
onChange={(e) => setTakenAt(e.target.value)}
disabled={skipped}
/>
<p className="text-muted-foreground text-xs">
{t("medications.logIntake.takenAtHint")}
</p>
</div>
<label
htmlFor="log-intake-skipped"
className="flex items-center justify-between gap-3"
>
<span className="text-sm font-medium">
{t("medications.logIntake.skippedLabel")}
</span>
<Switch
id="log-intake-skipped"
checked={skipped}
onCheckedChange={setSkipped}
/>
</label>
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={busy}
>
{t("medications.logIntake.cancel")}
</Button>
<Button
onClick={() => void submit()}
disabled={busy || medications.length === 0 || !medicationId}
aria-busy={busy || undefined}
>
{busy && (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("medications.logIntake.submit")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -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<boolean> {
const { medication, skipped, takenAt, scheduledFor, t, queryClient } = deps;
try {
const body: Record<string, unknown> = { 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}.