mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
2beaec4149
commit
ba17b08a01
10 files changed
+592
-8
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}.
|
||||
|
||||
Reference in new issue
Block a user