mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
496147687d
commit
a199fac20e
10 files changed
+470
-138
No files matched your search
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user