mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +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
@@ -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