+
{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 };
+}