diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index b36458230..251020397 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -256,7 +256,10 @@ export default function MedicationsPage() { {t("medications.subtitle")}

- diff --git a/src/components/medications/__tests__/card-parts.test.tsx b/src/components/medications/__tests__/card-parts.test.tsx index 9cb63d44e..73744fd5f 100644 --- a/src/components/medications/__tests__/card-parts.test.tsx +++ b/src/components/medications/__tests__/card-parts.test.tsx @@ -51,9 +51,11 @@ describe("medication card-parts — shared presentational components", () => { expect(html).toContain("90%"); expect(html).toContain("88%"); expect(html).toContain("lucide-flame"); - // Canonical streak/warning token — NOT the Tailwind-stock drift. - expect(html).toContain("text-dracula-orange"); + // v1.12.2 — semantic warning token, NOT the Tailwind-stock drift nor the + // raw Dracula palette token. + expect(html).toContain("text-warning"); expect(html).not.toContain("text-orange-400"); + expect(html).not.toContain("text-dracula-orange"); }); it("compliance bars scale the row labels to the chosen windows", () => { @@ -93,7 +95,24 @@ describe("medication card-parts — shared presentational components", () => { expect(html).toContain("lucide-circle-check"); }); - it("status pill stamps the warning token + glyph when very late", () => { + it("status pill stamps the warning token + glyph when late (semantic, not Dracula yellow)", () => { + // v1.12.2 — the middle "late" tier converged off the lone + // `text-dracula-yellow` stray onto the semantic warning token. + const html = render( + , + ); + expect(html).toContain("text-warning"); + expect(html).not.toContain("text-dracula-yellow"); + expect(html).toContain("lucide-circle-alert"); + }); + + it("status pill stamps the destructive token + glyph when very late", () => { + // v1.12.2 — very-late is the most-urgent tier; it reads as destructive + // (red) so the pill is a clean success → warning → destructive ramp. const html = render( { windowEnd="20:00" />, ); - expect(html).toContain("text-warning"); + expect(html).toContain("text-destructive"); expect(html).toContain("lucide-triangle-alert"); }); @@ -129,8 +148,9 @@ describe("medication card-parts — shared presentational components", () => { /** * Cross-variant streak-token parity: with a positive streak seeded, the - * flame on BOTH the generic and the GLP-1 card resolves to the canonical - * `text-dracula-orange`, and neither carries the legacy `text-orange-400`. + * flame on BOTH the generic and the GLP-1 card resolves to the semantic + * `text-warning` token (v1.12.2), and neither carries the legacy + * `text-orange-400` nor the raw `text-dracula-orange`. */ describe("streak-token parity — generic vs GLP-1 card", () => { const ramipril = { @@ -218,8 +238,9 @@ describe("streak-token parity — generic vs GLP-1 card", () => { for (const html of [ramiprilHtml, mounjaroHtml]) { expect(html).toContain("lucide-flame"); - expect(html).toContain("text-dracula-orange"); + expect(html).toContain("text-warning"); expect(html).not.toContain("text-orange-400"); + expect(html).not.toContain("text-dracula-orange"); } }); }); diff --git a/src/components/medications/__tests__/use-medication-intake.test.ts b/src/components/medications/__tests__/use-medication-intake.test.ts new file mode 100644 index 000000000..ede88e3a5 --- /dev/null +++ b/src/components/medications/__tests__/use-medication-intake.test.ts @@ -0,0 +1,194 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { QueryClient } from "@tanstack/react-query"; + +import { toast } from "sonner"; +import { + runRecordIntake, + runUndoIntake, +} from "@/components/medications/use-medication-intake"; + +/** + * v1.12.2 — the shared intake orchestration consumed by both the generic + * `` and the ``. The two cards used to + * inline their own `recordIntake`, and the GLP-1 copy never gained the + * v1.11.3 failure-toast (C1) or the Undo action (C2): a failed POST was + * swallowed silently and the success toast had no Undo. + * + * Because both cards now call the same `runRecordIntake`, these tests + * proving C1 + C2 fire on the shared path are the proof that the GLP-1 card + * behaves identically to the generic one — there is no second copy left to + * diverge. + * + * The repo has no `@testing-library/react` / `renderHook`, so the pure + * `run*` helpers take their dependencies (translator, query client) injected + * and `fetch` / `toast` are module-mocked. + */ + +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +const t = (key: string, params?: Record) => + params?.name ? `${key}:${params.name}` : key; + +function fakeQueryClient(): QueryClient { + return { + invalidateQueries: vi.fn().mockResolvedValue(undefined), + } as unknown as QueryClient; +} + +const medication = { id: "med-1", name: "Mounjaro" }; + +beforeEach(() => { + vi.restoreAllMocks(); + vi.mocked(toast.success).mockClear(); + vi.mocked(toast.error).mockClear(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => { + it("surfaces the failure toast and never the success toast on a non-ok POST", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: false, json: vi.fn() }), + ); + const setIntakeLoading = vi.fn(); + const queryClient = fakeQueryClient(); + const onRecorded = vi.fn(); + + await runRecordIntake({ + medication, + skipped: false, + t, + queryClient, + setIntakeLoading, + undoIntake: vi.fn(), + onRecorded, + }); + + // C1 — the failed POST surfaces the failure toast (with the med name), + // and the success path never fires. + expect(toast.error).toHaveBeenCalledWith( + "medications.intakeToastFailed:Mounjaro", + ); + expect(toast.success).not.toHaveBeenCalled(); + // No follow-up (no injection-site prompt) on a failed record. + expect(onRecorded).not.toHaveBeenCalled(); + // The spinner is always cleared. + expect(setIntakeLoading).toHaveBeenLastCalledWith(null); + }); + + it("surfaces the failure toast when the POST throws (network error)", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline"))); + const setIntakeLoading = vi.fn(); + + await runRecordIntake({ + medication, + skipped: true, + t, + queryClient: fakeQueryClient(), + setIntakeLoading, + undoIntake: vi.fn(), + }); + + expect(toast.error).toHaveBeenCalledWith( + "medications.intakeToastFailed:Mounjaro", + ); + expect(toast.success).not.toHaveBeenCalled(); + expect(setIntakeLoading).toHaveBeenLastCalledWith(null); + }); + + it("shows a success toast carrying an Undo action whose onClick reverses the dose", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: { id: "evt-99" } }), + }), + ); + const undoIntake = vi.fn(); + const onRecorded = vi.fn(); + + await runRecordIntake({ + medication, + skipped: false, + t, + queryClient: fakeQueryClient(), + setIntakeLoading: vi.fn(), + undoIntake, + onRecorded, + }); + + // C2 — the success toast carries an Undo action. + expect(toast.success).toHaveBeenCalledTimes(1); + const [message, opts] = vi.mocked(toast.success).mock.calls[0]; + expect(message).toBe("medications.intakeToastTaken:Mounjaro"); + const action = opts?.action as + | { label: string; onClick: (e: never) => void } + | undefined; + expect(action?.label).toBe("medications.intakeUndo"); + + // Firing the action calls undoIntake with the just-created event id. + action?.onClick({} as never); + expect(undoIntake).toHaveBeenCalledWith("evt-99"); + + // The post-success follow-up receives the event id + skipped flag. + expect(onRecorded).toHaveBeenCalledWith("evt-99", false); + }); + + it("omits the Undo action when the POST body carries no event id", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: {} }), + }), + ); + + await runRecordIntake({ + medication, + skipped: false, + t, + queryClient: fakeQueryClient(), + setIntakeLoading: vi.fn(), + undoIntake: vi.fn(), + }); + + const [, opts] = vi.mocked(toast.success).mock.calls[0]; + expect(opts).toBeUndefined(); + }); +}); + +describe("runUndoIntake — shared soft-delete", () => { + it("reverts via DELETE and confirms on success", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + const queryClient = fakeQueryClient(); + + await runUndoIntake({ medication, eventId: "evt-1", t, queryClient }); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/medications/med-1/intake/evt-1", + { method: "DELETE" }, + ); + expect(toast.success).toHaveBeenCalledWith("medications.intakeUndone"); + expect(queryClient.invalidateQueries).toHaveBeenCalled(); + }); + + it("surfaces the undo-failure toast on a non-ok DELETE", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false })); + + await runUndoIntake({ + medication, + eventId: "evt-1", + t, + queryClient: fakeQueryClient(), + }); + + expect(toast.error).toHaveBeenCalledWith("medications.intakeUndoFailed"); + expect(toast.success).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/medications/card-parts/medication-compliance-bars.tsx b/src/components/medications/card-parts/medication-compliance-bars.tsx index 4d6de452a..da65d000f 100644 --- a/src/components/medications/card-parts/medication-compliance-bars.tsx +++ b/src/components/medications/card-parts/medication-compliance-bars.tsx @@ -1,7 +1,7 @@ import { Flame } from "lucide-react"; import { Progress } from "@/components/ui/progress"; -import { useTranslations } from "@/lib/i18n/context"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; interface MedicationComplianceBarsProps { rate7: number; @@ -29,9 +29,11 @@ interface MedicationComplianceBarsProps { * 365-day long window. The labels are parametrised on the chosen day-counts * so each row names the window it actually covers. * - * The streak flame uses the canonical `text-dracula-orange` warning/streak - * token (globals.css). The generic card historically drifted onto Tailwind - * stock `text-orange-400`; unifying here closes that token gap. + * The streak flame uses the semantic `text-warning` token (an alias over + * Dracula orange in dark mode, AA-safe on the light card). The generic card + * historically drifted onto Tailwind stock `text-orange-400`, and the flame + * later carried the raw `text-dracula-orange` palette token; v1.12.2 routes + * it through the semantic vocabulary the rest of the status surface uses. */ export function MedicationComplianceBars({ rate7, @@ -41,16 +43,23 @@ export function MedicationComplianceBars({ longDays = 30, }: MedicationComplianceBarsProps) { const { t } = useTranslations(); + const fmt = useFormatters(); const shortLabel = t("medications.complianceWindow", { days: shortDays }); const longLabel = t("medications.complianceWindow", { days: longDays }); + // v1.12.2 — route the rate through the locale number formatter and round + // so a non-integer rate (e.g. 33.333) never leaks raw into the caption, + // matching how every other percentage renders. + const shortPct = fmt.number(Math.round(rate7)); + const longPct = fmt.number(Math.round(rate30)); + return (
{shortLabel} - {rate7}% + {shortPct}%
{/* aria-label so the bar has an accessible name. */} @@ -59,7 +68,7 @@ export function MedicationComplianceBars({
{longLabel} - {rate30}% + {longPct}%
@@ -68,7 +77,7 @@ export function MedicationComplianceBars({ row doesn't leave a residual gap below the bars. */} {streak > 0 && (
- + {streak} {t("medications.dayStreak")} diff --git a/src/components/medications/card-parts/medication-status-pill.tsx b/src/components/medications/card-parts/medication-status-pill.tsx index 521a05196..db96f15f0 100644 --- a/src/components/medications/card-parts/medication-status-pill.tsx +++ b/src/components/medications/card-parts/medication-status-pill.tsx @@ -30,12 +30,18 @@ export function MedicationStatusPill({

{status === "in_window" ? ( diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index 277362e4f..1d339193a 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -15,6 +15,7 @@ import { MedicationComplianceSkeleton, } from "@/components/medications/card-parts/medication-compliance-bars"; import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions"; +import { useMedicationIntake } from "@/components/medications/use-medication-intake"; import { MedicationNextLastSlot, useWeekdayLabel, @@ -186,7 +187,6 @@ export function Glp1MedicationCard({ const { t } = useTranslations(); const fmt = useFormatters(); const weekdayLabel = useWeekdayLabel(); - const [intakeLoading, setIntakeLoading] = useState(null); const [, forceUpdate] = useReducer((x: number) => x + 1, 0); // v1.8.5 — post-dose injection-site prompt (see medication-card.tsx). const [siteIntakeId, setSiteIntakeId] = useState(null); @@ -195,6 +195,19 @@ export function Glp1MedicationCard({ medication.deliveryForm === "INJECTION" && medication.trackInjectionSites === true; + // v1.12.2 — take / skip + failure-toast (C1) + Undo (C2) come from the + // SAME shared hook the generic card uses, closing the robustness gap + // where a failed GLP-1 POST was swallowed silently and the success toast + // carried no Undo. The card keeps its post-success injection-site prompt. + const { intakeLoading, recordIntake } = useMedicationIntake({ + medication, + onRecorded: (eventId, skipped) => { + if (!skipped && tracksInjection && eventId) { + setSiteIntakeId(eventId); + } + }, + }); + const { data: compliance } = useQuery({ queryKey: queryKeys.medicationCompliance(medication.id), queryFn: async () => { @@ -242,42 +255,6 @@ export function Glp1MedicationCard({ return () => clearInterval(interval); }, []); - async function recordIntake(skipped: boolean) { - const key = skipped ? "skip" : "take"; - setIntakeLoading(key); - try { - const res = await fetch(`/api/medications/${medication.id}/intake`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ skipped }), - }); - if (res.ok) { - toast.success( - t( - skipped - ? "medications.intakeToastSkipped" - : "medications.intakeToastTaken", - { name: medication.name }, - ), - ); - await invalidateKeys(queryClient, medicationDependentKeys); - // v1.8.5 — prompt (skippably) for the injection site on a taken - // dose when tracking is enabled. - if (!skipped && tracksInjection) { - try { - const json = await res.json(); - const eventId = json?.data?.id as string | undefined; - if (eventId) setSiteIntakeId(eventId); - } catch { - /* dose recorded; the site prompt is best-effort */ - } - } - } - } finally { - setIntakeLoading(null); - } - } - async function confirmInjectionSite(site: InjectionSiteKey) { const intakeId = siteIntakeId; if (!intakeId) return; diff --git a/src/components/medications/intake-import-dialog.tsx b/src/components/medications/intake-import-dialog.tsx index c54a237e5..41271a4ef 100644 --- a/src/components/medications/intake-import-dialog.tsx +++ b/src/components/medications/intake-import-dialog.tsx @@ -191,7 +191,7 @@ export function IntakeImportDialog({ /> {result && (

diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index c338661c5..13573dd12 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useReducer } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Card, CardContent } from "@/components/ui/card"; +import { useMedicationIntake } from "@/components/medications/use-medication-intake"; import { cn } from "@/lib/utils"; import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader"; import { MedicationCardMenu } from "@/components/medications/medication-card-menu"; @@ -129,7 +130,6 @@ export function MedicationCard({ const { t, locale } = useTranslations(); const fmt = useFormatters(); const weekdayLabel = useWeekdayLabel(); - const [intakeLoading, setIntakeLoading] = useState(null); // v1.8.5 — post-dose injection-site prompt state. Holds the intake // event id returned by the take POST so the confirm handler can PATCH // the chosen site onto it. Null = dialog closed. @@ -139,6 +139,19 @@ export function MedicationCard({ medication.deliveryForm === "INJECTION" && medication.trackInjectionSites === true; + // v1.12.2 — intake take / skip + failure-toast (C1) + Undo (C2) live in + // a shared hook so the generic and GLP-1 cards can never re-diverge. The + // card keeps only its post-success follow-up: prompting (skippably) for + // the injection site on a taken dose when tracking is enabled. + const { intakeLoading, recordIntake } = useMedicationIntake({ + medication, + onRecorded: (eventId, skipped) => { + if (!skipped && tracksInjection && eventId) { + setSiteIntakeId(eventId); + } + }, + }); + const { data: compliance } = useQuery({ queryKey: queryKeys.medicationCompliance(medication.id), queryFn: async () => { @@ -168,86 +181,6 @@ export function MedicationCard({ return () => clearInterval(interval); }, []); - // v1.11.3 C2 — reverse the just-recorded intake via the soft-delete - // route. Surfaced from the success toast's Undo action so a misclicked - // take / skip no longer needs a history dive to correct. - async function undoIntake(eventId: string) { - try { - const res = await fetch( - `/api/medications/${medication.id}/intake/${eventId}`, - { method: "DELETE" }, - ); - if (!res.ok) { - toast.error(t("medications.intakeUndoFailed")); - return; - } - await invalidateKeys(queryClient, medicationDependentKeys); - toast.success(t("medications.intakeUndone")); - } catch { - toast.error(t("medications.intakeUndoFailed")); - } - } - - async function recordIntake(skipped: boolean) { - const key = skipped ? "skip" : "take"; - setIntakeLoading(key); - try { - const res = await fetch(`/api/medications/${medication.id}/intake`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ skipped }), - }); - // v1.11.3 C1 — a failed POST used to clear the spinner silently, so - // the user believed the dose was logged when it was not. Surface the - // failure and never show the success confirmation in that case. - if (!res.ok) { - toast.error( - t("medications.intakeToastFailed", { name: medication.name }), - ); - return; - } - // The POST returns the created event (`apiSuccess(event, 201)`); its - // id drives both the Undo affordance and the optional injection-site - // prompt below. - let eventId: string | undefined; - try { - const json = await res.json(); - eventId = json?.data?.id as string | undefined; - } catch { - /* dose recorded; the body is best-effort for the id */ - } - toast.success( - t( - skipped - ? "medications.intakeToastSkipped" - : "medications.intakeToastTaken", - { name: medication.name }, - ), - eventId - ? { - action: { - label: t("medications.intakeUndo"), - onClick: () => void undoIntake(eventId), - }, - } - : undefined, - ); - await invalidateKeys(queryClient, medicationDependentKeys); - // v1.8.5 — after a TAKEN dose on a tracking-enabled injection, - // prompt (skippably) for the site. The dialog PATCHes it onto - // the just-created event via the status-toggle route. - if (!skipped && tracksInjection && eventId) { - setSiteIntakeId(eventId); - } - } catch { - toast.error( - t("medications.intakeToastFailed", { name: medication.name }), - ); - } finally { - setIntakeLoading(null); - } - } - async function confirmInjectionSite(site: InjectionSiteKey) { const intakeId = siteIntakeId; if (!intakeId) return; diff --git a/src/components/medications/medication-detail-summary.tsx b/src/components/medications/medication-detail-summary.tsx index 641a235de..e5eae9751 100644 --- a/src/components/medications/medication-detail-summary.tsx +++ b/src/components/medications/medication-detail-summary.tsx @@ -66,11 +66,15 @@ export function MedicationDetailSummary({ ? t("medications.detail.status.paused") : t("medications.detail.status.ended"); + // v1.12.2 — use the semantic `bg-success` / `bg-warning` utilities (each a + // straight alias over the same colour the raw `hsl(var(--…))` resolved to) + // so the detail status dot speaks the same token vocabulary as the card + // status pill and streak instead of the raw HSL var form. const dotClass = status === "active" - ? "bg-[hsl(var(--success))]" + ? "bg-success" : status === "paused" - ? "bg-[hsl(var(--warning))]" + ? "bg-warning" : "bg-muted-foreground"; const cadenceLine = oneShot diff --git a/src/components/medications/use-medication-intake.ts b/src/components/medications/use-medication-intake.ts new file mode 100644 index 000000000..0a9de8a31 --- /dev/null +++ b/src/components/medications/use-medication-intake.ts @@ -0,0 +1,185 @@ +"use client"; + +import { useState } from "react"; +import { useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { useTranslations } from "@/lib/i18n/context"; +import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys"; + +type Translator = ( + key: string, + params?: Record, +) => string; + +interface MedicationIntakeIdentity { + id: string; + name: string; +} + +/** + * v1.12.2 — the take / skip + Undo intake orchestration shared by the + * generic {@link MedicationCard} and the {@link Glp1MedicationCard}. + * + * Both cards used to inline their own `recordIntake`, and the two copies + * drifted: the generic card gained the v1.11.3 failure-toast (C1) and the + * Undo action (C2) while the GLP-1 card silently swallowed a failed POST + * and offered no Undo — same "Taken" button, two behaviours. Lifting the + * logic here makes the two cards behave identically by construction. + * + * The hook owns: the in-flight loading key, the intake POST, the failure + * toast, the success toast carrying an Undo action, the soft-delete undo + * route, and the dependent-key invalidation. Each card keeps its own + * post-success hook (`onRecorded`) for card-specific follow-ups — today + * the optional injection-site prompt. + */ +interface UseMedicationIntakeParams { + medication: MedicationIntakeIdentity; + /** + * Card-specific follow-up after a successful record. Receives the + * created event's id (when the POST body carried one) and whether the + * dose was skipped. Used by the cards to open the injection-site prompt + * on a taken dose. The intake itself is already recorded + invalidated + * by the time this fires. + */ + onRecorded?: (eventId: string | undefined, skipped: boolean) => void; +} + +interface UseMedicationIntakeResult { + /** "take" | "skip" while the matching request is in flight, else null. */ + intakeLoading: string | null; + /** Record a take (skipped=false) or skip (skipped=true). */ + recordIntake: (skipped: boolean) => Promise; + /** Reverse a just-recorded intake via the soft-delete route. */ + undoIntake: (eventId: string) => Promise; +} + +/** + * Pure orchestration for a single intake record, with every dependency + * injected so it is unit-testable without a React render (the repo has no + * `renderHook`; the convention is SSR markup + direct invocation). The + * hook below is a thin wrapper that binds the real `fetch` / `toast` / + * translator / query client. + */ +export async function runRecordIntake(deps: { + medication: MedicationIntakeIdentity; + skipped: boolean; + t: Translator; + queryClient: QueryClient; + setIntakeLoading: (value: string | null) => void; + undoIntake: (eventId: string) => void | Promise; + onRecorded?: (eventId: string | undefined, skipped: boolean) => void; +}): Promise { + const { + medication, + skipped, + t, + queryClient, + setIntakeLoading, + undoIntake, + onRecorded, + } = deps; + + setIntakeLoading(skipped ? "skip" : "take"); + try { + const res = await fetch(`/api/medications/${medication.id}/intake`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ skipped }), + }); + // v1.11.3 C1 — a failed POST used to clear the spinner silently, so the + // user believed the dose was logged when it was not. Surface the failure + // and never show the success confirmation in that case. (The GLP-1 card + // missed this port until v1.12.2 lifted the logic here.) + if (!res.ok) { + toast.error(t("medications.intakeToastFailed", { name: medication.name })); + return; + } + // The POST returns the created event (`apiSuccess(event, 201)`); its id + // drives both the Undo affordance and the card's post-success hook + // (the optional injection-site prompt). + let eventId: string | undefined; + try { + const json = await res.json(); + eventId = json?.data?.id as string | undefined; + } catch { + /* dose recorded; the body is best-effort for the id */ + } + // v1.11.3 C2 — the success toast carries an Undo action so a misclicked + // take / skip no longer needs a history dive to correct. + toast.success( + t( + skipped + ? "medications.intakeToastSkipped" + : "medications.intakeToastTaken", + { name: medication.name }, + ), + eventId + ? { + action: { + label: t("medications.intakeUndo"), + onClick: () => void undoIntake(eventId), + }, + } + : undefined, + ); + await invalidateKeys(queryClient, medicationDependentKeys); + onRecorded?.(eventId, skipped); + } catch { + toast.error(t("medications.intakeToastFailed", { name: medication.name })); + } finally { + setIntakeLoading(null); + } +} + +/** + * Pure soft-delete undo for a just-recorded intake, dependencies injected + * for the same testability reason as {@link runRecordIntake}. + */ +export async function runUndoIntake(deps: { + medication: MedicationIntakeIdentity; + eventId: string; + t: Translator; + queryClient: QueryClient; +}): Promise { + const { medication, eventId, t, queryClient } = deps; + try { + const res = await fetch( + `/api/medications/${medication.id}/intake/${eventId}`, + { method: "DELETE" }, + ); + if (!res.ok) { + toast.error(t("medications.intakeUndoFailed")); + return; + } + await invalidateKeys(queryClient, medicationDependentKeys); + toast.success(t("medications.intakeUndone")); + } catch { + toast.error(t("medications.intakeUndoFailed")); + } +} + +export function useMedicationIntake({ + medication, + onRecorded, +}: UseMedicationIntakeParams): UseMedicationIntakeResult { + const queryClient = useQueryClient(); + const { t } = useTranslations(); + const [intakeLoading, setIntakeLoading] = useState(null); + + const undoIntake = (eventId: string) => + runUndoIntake({ medication, eventId, t, queryClient }); + + const recordIntake = (skipped: boolean) => + runRecordIntake({ + medication, + skipped, + t, + queryClient, + setIntakeLoading, + undoIntake, + onRecorded, + }); + + return { intakeLoading, recordIntake, undoIntake }; +}