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 (
+
+ );
+}
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}.