fix(medications): take-now / overdue pill on the GLP-1 card too

The generic medication card painted a coloured take-now / overdue /
very-overdue pill at the top of the detail surface; the GLP-1 card
silently omitted it. Marc reported the asymmetry during the v1.4.37
UX audit (audit item 11): Mounjaro and Ramipril cards felt "different"
even though both should read as the same row shape.

Wire the shared `reduceCurrentWindowStatus` reducer into the GLP-1
card, fetch the same reminder thresholds as the generic card, and
re-render once a minute so the pill tracks wall-clock progress without
a route reload. The pill renders only when a schedule is currently
in/past its window and the user has not yet covered every overdue
schedule with today's intake events — same suppression rules as the
generic card.
This commit is contained in:
Marc-André Bombeck committed 2026-05-17 14:27:46 +02:00
1 parent 6fc9c99009
commit 2e929d0afb
1 file changed
+88 -2
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useReducer, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import {
@@ -21,12 +21,17 @@ import { Progress } from "@/components/ui/progress";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
import { formatDateTime, formatTime } from "@/lib/format";
import { formatTimeWindowRange } from "@/lib/time-window-format";
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
import {
describeInjectionSite,
nextInjectionSite,
type InjectionSiteKey,
} from "@/lib/medications/injection-sites";
import {
reduceCurrentWindowStatus,
toBerlinDate,
} from "@/lib/medications/window-status";
/**
* v1.4.25 W4d — GLP-1 medication card variant.
*
@@ -78,6 +83,7 @@ export interface Glp1Medication {
notificationsEnabled: boolean;
pausedAt: string | null;
lastTakenAt: string | null;
todayEventCount?: number;
schedules: ScheduleLite[];
}
@@ -156,9 +162,10 @@ export function Glp1MedicationCard({
onLogSideEffect,
}: Glp1MedicationCardProps) {
const queryClient = useQueryClient();
const { t } = useTranslations();
const { t, locale } = useTranslations();
const fmt = useFormatters();
const [intakeLoading, setIntakeLoading] = useState<string | null>(null);
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
const { data: compliance } = useQuery({
queryKey: ["medications", medication.id, "compliance"],
@@ -172,6 +179,20 @@ export function Glp1MedicationCard({
enabled: medication.active,
});
// v1.4.37 W4b — same reminder-thresholds source as the generic
// medication card so the take-now / overdue / very-overdue pill
// tiers identically on both surfaces.
const { data: thresholds } = useQuery({
queryKey: ["settings", "reminder-thresholds"],
queryFn: async () => {
const res = await fetch("/api/settings/reminder-thresholds");
if (!res.ok) return null;
const json = await res.json();
return json.data as { lateMinutes: number; missedMinutes: number };
},
staleTime: 5 * 60 * 1000,
});
// Pull GLP-1-specific extras (dose history + recent injection sites +
// pen inventory). Lives behind a dedicated endpoint so the standard
// medications grid loads in parallel without paying for it.
@@ -186,6 +207,14 @@ export function Glp1MedicationCard({
staleTime: 60 * 1000,
});
// Re-render once a minute so the in-window / overdue pill tracks
// wall-clock progress without a route reload — mirrors the generic
// medication card's tick cadence.
useEffect(() => {
const interval = setInterval(forceUpdate, 60_000);
return () => clearInterval(interval);
}, []);
async function recordIntake(skipped: boolean) {
const key = skipped ? "skip" : "take";
setIntakeLoading(key);
@@ -210,6 +239,28 @@ export function Glp1MedicationCard({
const rate30 = compliance?.compliance30?.rate ?? 0;
const streak = compliance?.compliance7?.streak ?? 0;
// v1.4.37 W4b — symmetric take-now / overdue pill with the generic
// card. Sort schedules by `windowStart` so the most-actionable
// earliest window wins when multiple GLP-1 windows overlap (rare
// today; the parity is what matters).
const sortedSchedules = [...medication.schedules].sort(
(a, b) =>
a.windowStart.localeCompare(b.windowStart) ||
a.windowEnd.localeCompare(b.windowEnd),
);
const nowBerlin = toBerlinDate(now);
const lateMinutes = thresholds?.lateMinutes ?? 120;
const missedMinutes = thresholds?.missedMinutes ?? 240;
const currentWindowStatus = reduceCurrentWindowStatus({
schedules: sortedSchedules,
nowBerlin,
lateMinutes,
missedMinutes,
active: medication.active,
lastTakenAt: medication.lastTakenAt,
todayEventCount: medication.todayEventCount ?? 0,
});
const recentInjections = details?.recentIntakes ?? [];
const lastSite =
recentInjections.find((i) => i.injectionSite)?.injectionSite ?? null;
@@ -297,6 +348,41 @@ export function Glp1MedicationCard({
/>
<CardContent className="space-y-3.5">
{/* v1.4.37 W4b — take-now / overdue / very-overdue pill,
byte-equivalent with the generic medication card. The
GLP-1 card historically omitted this row, which made
Mounjaro feel different from Ramipril on the medications
grid even though the underlying schedule contract is
the same shape. */}
{currentWindowStatus.status && (
<p className="text-sm">
<span
className={
currentWindowStatus.status === "in_window"
? "text-success font-medium"
: currentWindowStatus.status === "late"
? "text-dracula-yellow font-medium"
: "text-warning font-medium"
}
>
{currentWindowStatus.status === "in_window"
? t("medications.takeNow")
: currentWindowStatus.status === "late"
? t("medications.overdue")
: t("medications.veryOverdue")}
</span>
<span className="text-muted-foreground hidden sm:inline">
{" "}
—{" "}
{formatTimeWindowRange(
currentWindowStatus.schedule!.windowStart,
currentWindowStatus.schedule!.windowEnd,
locale,
)}
</span>
</p>
)}
{/* Injection state — last + next */}
<div className="space-y-1 text-sm">
{medication.lastTakenAt && (