refactor(medications): share intake handling across cards and unify status tokens

Lift the take / skip intake flow — the optimistic spinner, the
failure toast, the soft-delete Undo, and the dependent-key
invalidation — into a shared `useMedicationIntake` hook consumed by
both the generic medication card and the GLP-1 card. The GLP-1 card
previously inlined its own copy that never gained the failure toast
or the Undo affordance, so a failed POST was swallowed silently and a
misclicked dose had no quick reversal. Both cards now behave
identically by construction; each keeps only its post-success
injection-site prompt.

Converge the medication status-colour surface onto the semantic
feedback vocabulary. The status pill reads as a success → warning →
destructive urgency ramp (dropping the lone `text-dracula-yellow`
middle tier), the streak flame moves off the raw `text-dracula-orange`
palette token onto `text-warning`, the detail status dot swaps its raw
`hsl(var(--…))` form for `bg-success` / `bg-warning`, and the import
result line drops `text-dracula-green` for `text-success`. Each
semantic token aliases the same colour the raw form resolved to, so
the hue is unchanged in dark mode and lands AA-safe on the light card.

Route the compliance percentages through the locale number formatter
with rounding so a non-integer rate never leaks raw, and give the
medications Add button the same `min-h-11 sm:min-h-9` tap-target floor
the dashboard Add button carries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit a4415d706154b063a8783390e35eded5dbc57910)
This commit is contained in:
Marc-André Bombeck committed 2026-06-05 08:10:12 +02:00
1 parent 496147687d
commit a199fac20e
10 files changed
+470 -138

No files matched your search

+4 -1
View File
@@ -256,7 +256,10 @@ export default function MedicationsPage() {
{t("medications.subtitle")}
</p>
</div>
<Button onClick={openCreate}>
{/* 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>
@@ -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(
<MedicationStatusPill
status="late"
windowStart="08:00"
windowEnd="20:00"
/>,
);
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(
<MedicationStatusPill
status="very_late"
@@ -101,7 +120,7 @@ describe("medication card-parts — shared presentational components", () => {
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");
}
});
});
@@ -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
* `<MedicationCard>` and the `<Glp1MedicationCard>`. 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<string, string | number>) =>
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();
});
});
@@ -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 (
<div className="space-y-2.5">
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{shortLabel}</span>
<span className="font-medium">{rate7}%</span>
<span className="font-medium">{shortPct}%</span>
</div>
{/* aria-label so the bar has an accessible name. */}
<Progress value={rate7} className="h-2" aria-label={shortLabel} />
@@ -59,7 +68,7 @@ export function MedicationComplianceBars({
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{longLabel}</span>
<span className="font-medium">{rate30}%</span>
<span className="font-medium">{longPct}%</span>
</div>
<Progress value={rate30} className="h-2" aria-label={longLabel} />
</div>
@@ -68,7 +77,7 @@ export function MedicationComplianceBars({
row doesn't leave a residual gap below the bars. */}
{streak > 0 && (
<div className="flex items-center gap-4 text-xs">
<span className="text-dracula-orange flex items-center gap-1 font-medium">
<span className="text-warning flex items-center gap-1 font-medium">
<Flame className="h-3.5 w-3.5" />
{streak} {t("medications.dayStreak")}
</span>
@@ -30,12 +30,18 @@ export function MedicationStatusPill({
<p className="text-sm">
<span
className={
// v1.12.2 — converge the three tiers onto the semantic feedback
// vocabulary (success / warning / destructive) so the medication
// status surface stops mixing semantic + Dracula tokens. The pill
// reads as the standard urgency ramp: in-window = success (green),
// late = warning (amber), very-late = destructive (red). This drops
// the lone `text-dracula-yellow` middle-tier stray.
"inline-flex items-center gap-1 font-medium " +
(status === "in_window"
? "text-success"
: status === "late"
? "text-dracula-yellow"
: "text-warning")
? "text-warning"
: "text-destructive")
}
>
{status === "in_window" ? (
@@ -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<string | null>(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<string | null>(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;
@@ -191,7 +191,7 @@ export function IntakeImportDialog({
/>
{result && (
<p
className={`text-sm ${resultType === "success" ? "text-dracula-green" : "text-destructive"}`}
className={`text-sm ${resultType === "success" ? "text-success" : "text-destructive"}`}
role="status"
aria-live="polite"
>
+14 -81
View File
@@ -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<string | null>(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;
@@ -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
@@ -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, string | number>,
) => 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<void>;
/** Reverse a just-recorded intake via the soft-delete route. */
undoIntake: (eventId: string) => Promise<void>;
}
/**
* 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<void>;
onRecorded?: (eventId: string | undefined, skipped: boolean) => void;
}): Promise<void> {
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<void> {
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<string | null>(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 };
}