feat(medications): surface the open-cycle status on the medication cards

Wire the per-med `complianceDisplay.currentCycle` descriptor into a calm,
rate-decoupled status line on both the generic and GLP-1 medication cards.

A sparse weekly or rolling med (a GLP-1 injection) that is simply between
doses has no closed cycle in flight, so the compliance percentage alone reads
as a scary 0% that says nothing about whether the user is on schedule. The new
`MedicationCycleStatus` card-part reports the open cycle instead: "next dose in
N days" / "due today" / "overdue", or a neutral "no closed dose cycles yet"
when there is not enough history to score. It complements the percentage rows;
it does not replace them.

Driven by `currentCycle.state` (on_track / due / missed / none). Colour follows
the existing success / warning / destructive ramp and pairs each tier with a
Lucide glyph so colour-blind users can disambiguate (WCAG 1.4.1). The relative
day phrasing buckets on the user's calendar day, matching the next/last slot.
Strings land in all six locale bundles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-05 23:49:29 +02:00
1 parent 20d7187b0d
commit 2beaec4149
10 files changed
+279

No files matched your search

+6
View File
@@ -667,6 +667,12 @@
"lastIntake": "Letzte Einnahme:",
"nextIntake": "Nächste Einnahme:",
"dayStreak": "Tage Serie",
"cycleNextDoseToday": "Nächste Dosis heute",
"cycleNextDoseTomorrow": "Nächste Dosis morgen",
"cycleNextDoseInDays": "Nächste Dosis in {days} Tagen",
"cycleDueToday": "Heute fällig",
"cycleOverdue": "Überfällig",
"cycleNoClosedCycles": "Noch keine abgeschlossenen Dosiszyklen",
"taken": "Genommen",
"skipped": "Übersprungen",
"intakeToastTaken": "{name} als genommen erfasst",
+6
View File
@@ -667,6 +667,12 @@
"lastIntake": "Last intake:",
"nextIntake": "Next intake:",
"dayStreak": "day streak",
"cycleNextDoseToday": "Next dose today",
"cycleNextDoseTomorrow": "Next dose tomorrow",
"cycleNextDoseInDays": "Next dose in {days} days",
"cycleDueToday": "Due today",
"cycleOverdue": "Overdue",
"cycleNoClosedCycles": "No closed dose cycles yet",
"taken": "Taken",
"skipped": "Skipped",
"intakeToastTaken": "{name} recorded as taken",
+6
View File
@@ -667,6 +667,12 @@
"lastIntake": "Última toma:",
"nextIntake": "Próxima toma:",
"dayStreak": "días de racha",
"cycleNextDoseToday": "Próxima dosis hoy",
"cycleNextDoseTomorrow": "Próxima dosis mañana",
"cycleNextDoseInDays": "Próxima dosis en {days} días",
"cycleDueToday": "Pendiente hoy",
"cycleOverdue": "Atrasada",
"cycleNoClosedCycles": "Aún no hay ciclos de dosis completados",
"taken": "Tomada",
"skipped": "Omitida",
"intakeToastTaken": "{name} registrada como tomada",
+6
View File
@@ -667,6 +667,12 @@
"lastIntake": "Dernière prise :",
"nextIntake": "Prochaine prise :",
"dayStreak": "jours de série",
"cycleNextDoseToday": "Prochaine dose aujourd'hui",
"cycleNextDoseTomorrow": "Prochaine dose demain",
"cycleNextDoseInDays": "Prochaine dose dans {days} jours",
"cycleDueToday": "À prendre aujourd'hui",
"cycleOverdue": "En retard",
"cycleNoClosedCycles": "Aucun cycle de dose terminé pour l'instant",
"taken": "Pris",
"skipped": "Sauté",
"intakeToastTaken": "{name} enregistré comme pris",
+6
View File
@@ -667,6 +667,12 @@
"lastIntake": "Ultima assunzione:",
"nextIntake": "Prossima assunzione:",
"dayStreak": "giorni di serie",
"cycleNextDoseToday": "Prossima dose oggi",
"cycleNextDoseTomorrow": "Prossima dose domani",
"cycleNextDoseInDays": "Prossima dose tra {days} giorni",
"cycleDueToday": "In scadenza oggi",
"cycleOverdue": "In ritardo",
"cycleNoClosedCycles": "Ancora nessun ciclo di dose completato",
"taken": "Assunta",
"skipped": "Saltata",
"intakeToastTaken": "{name} registrata come assunta",
+6
View File
@@ -667,6 +667,12 @@
"lastIntake": "Ostatnie przyjęcie:",
"nextIntake": "Następne przyjęcie:",
"dayStreak": "dni serii",
"cycleNextDoseToday": "Następna dawka dzisiaj",
"cycleNextDoseTomorrow": "Następna dawka jutro",
"cycleNextDoseInDays": "Następna dawka za {days} dni",
"cycleDueToday": "Termin dzisiaj",
"cycleOverdue": "Zaległa",
"cycleNoClosedCycles": "Brak zakończonych cykli dawkowania",
"taken": "Przyjęte",
"skipped": "Pominięte",
"intakeToastTaken": "{name} oznaczone jako przyjęte",
@@ -12,6 +12,8 @@ import { MedicationComplianceBars } from "@/components/medications/card-parts/me
import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill";
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges";
import { MedicationCycleStatus } from "@/components/medications/card-parts/medication-cycle-status";
import type { CurrentCycle } from "@/lib/analytics/compliance";
/**
* v1.7.2 — the medication-card status pill, compliance bars, intake-action
@@ -146,6 +148,125 @@ describe("medication card-parts — shared presentational components", () => {
});
});
/**
* v1.14.0 — the open-cycle status line. A calm, rate-decoupled status driven
* by `currentCycle.state` so a sparse weekly / rolling med between doses
* surfaces "next dose in N days" / "due today" / "overdue" rather than leaning
* on a percentage that misreads an on-schedule med as a scary 0%.
*/
describe("medication cycle status — open-cycle line", () => {
const baseCycle: CurrentCycle = {
state: "on_track",
nextDueAt: null,
graceUntil: null,
hasClosedCycles: true,
};
// A future instant N whole calendar days from now (noon-anchored so the
// Berlin-day rounding is stable regardless of the test clock's time of day).
function inDays(n: number): Date {
const now = new Date();
const d = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate() + n,
12,
0,
0,
);
return d;
}
it("renders nothing when state is none (PRN / paused / ended)", () => {
const html = render(
<MedicationCycleStatus cycle={{ ...baseCycle, state: "none" }} />,
);
expect(html).toBe("");
});
it("renders the neutral no-closed-cycles state with the dashed glyph", () => {
const html = render(
<MedicationCycleStatus
cycle={{
...baseCycle,
state: "on_track",
nextDueAt: inDays(3),
hasClosedCycles: false,
}}
/>,
);
expect(html).toContain("No closed dose cycles yet");
expect(html).toContain("lucide-circle-dashed");
expect(html).toContain("text-muted-foreground");
// never leaks a relative count when there's no closed cycle to anchor.
expect(html).not.toContain("Next dose in");
});
it("on_track several days out reads the relative-day phrasing in success tone", () => {
const html = render(
<MedicationCycleStatus
cycle={{ ...baseCycle, state: "on_track", nextDueAt: inDays(4) }}
/>,
);
expect(html).toContain("Next dose in 4 days");
expect(html).toContain("text-success");
expect(html).toContain("lucide-circle-check");
});
it("on_track one day out reads tomorrow", () => {
const html = render(
<MedicationCycleStatus
cycle={{ ...baseCycle, state: "on_track", nextDueAt: inDays(1) }}
/>,
);
expect(html).toContain("Next dose tomorrow");
expect(html).not.toContain("in 1 days");
});
it("on_track later today reads today", () => {
const later = new Date();
later.setHours(23, 59, 0, 0);
const html = render(
<MedicationCycleStatus
cycle={{ ...baseCycle, state: "on_track", nextDueAt: later }}
/>,
);
expect(html).toContain("Next dose today");
});
it("due reads the amber due-today line", () => {
const html = render(
<MedicationCycleStatus
cycle={{
...baseCycle,
state: "due",
nextDueAt: new Date(),
graceUntil: inDays(0),
}}
/>,
);
expect(html).toContain("Due today");
expect(html).toContain("text-warning");
expect(html).toContain("lucide-calendar-clock");
});
it("missed reads the destructive overdue line", () => {
const html = render(
<MedicationCycleStatus
cycle={{
...baseCycle,
state: "missed",
nextDueAt: inDays(-3),
graceUntil: inDays(-3),
}}
/>,
);
expect(html).toContain("Overdue");
expect(html).toContain("text-destructive");
expect(html).toContain("lucide-triangle-alert");
});
});
/**
* Cross-variant streak-token parity: with a positive streak seeded, the
* flame on BOTH the generic and the GLP-1 card resolves to the semantic
@@ -0,0 +1,100 @@
import { AlertTriangle, CalendarClock, CircleCheck, CircleDashed } from "lucide-react";
import type { CurrentCycle } from "@/lib/analytics/compliance";
import { useTranslations } from "@/lib/i18n/context";
import { toBerlinDate } from "@/lib/medications/window-status";
interface MedicationCycleStatusProps {
/** The open-cycle descriptor from `complianceDisplay.currentCycle`. */
cycle: CurrentCycle;
}
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Whole-day delta between two instants, measured on the user's calendar
* (Europe/Berlin) so "in 2 days" lines up with the day a sparse dose lands on
* rather than a raw 48-hour count. Positive = future, 0 = today, negative =
* past. Mirrors the day-bucketing the card already does for its next/last slot.
*/
function calendarDayDelta(target: Date, now: Date): number {
const a = toBerlinDate(target);
const b = toBerlinDate(now);
const targetMidnight = new Date(a.getFullYear(), a.getMonth(), a.getDate());
const nowMidnight = new Date(b.getFullYear(), b.getMonth(), b.getDate());
return Math.round((targetMidnight.getTime() - nowMidnight.getTime()) / DAY_MS);
}
/**
* v1.14.0 — the open-cycle status line for the medication cards.
*
* The compliance percentage rows score CLOSED cycles. A sparse weekly /
* rolling med (a GLP-1 injection) that is simply between doses has no scored
* cycle in flight, so the percentage alone reads as a scary 0% / 100% that
* says nothing about whether the user is actually on schedule. This line is
* decoupled from the rate: it reports the open cycle's calm status — when the
* next dose lands, whether it's due or overdue, or that there's not enough
* history to score yet.
*
* Driven by `currentCycle.state` (`on_track` / `due` / `missed` / `none`) plus
* `hasClosedCycles`. Colour follows the same semantic ramp the status pill
* uses (success / warning / destructive) and pairs every tier with a Lucide
* glyph so colour-blind users can disambiguate — WCAG 1.4.1 (Use of Color).
* It complements the compliance number; it does not replace it.
*/
export function MedicationCycleStatus({ cycle }: MedicationCycleStatusProps) {
const { t } = useTranslations();
// No projected next dose (PRN, paused, ended) → render nothing; the card
// keeps its existing inventory.
if (cycle.state === "none") return null;
// Neutral "not enough history yet" wins over the relative phrasing: a
// brand-new sparse med has a next dose but no closed cycle to anchor the
// percentage rows, so we say so plainly rather than implying a score.
if (!cycle.hasClosedCycles) {
return (
<p className="text-muted-foreground flex items-center gap-1.5 text-xs">
<CircleDashed className="size-3.5 shrink-0" aria-hidden="true" />
{t("medications.cycleNoClosedCycles")}
</p>
);
}
let tone: string;
let icon: React.ReactNode;
let label: string;
if (cycle.state === "missed") {
tone = "text-destructive";
icon = <AlertTriangle className="size-3.5 shrink-0" aria-hidden="true" />;
label = t("medications.cycleOverdue");
} else if (cycle.state === "due") {
tone = "text-warning";
icon = <CalendarClock className="size-3.5 shrink-0" aria-hidden="true" />;
label = t("medications.cycleDueToday");
} else {
// on_track — the next dose simply hasn't come round yet.
tone = "text-success";
icon = <CircleCheck className="size-3.5 shrink-0" aria-hidden="true" />;
const now = new Date();
const days = cycle.nextDueAt
? calendarDayDelta(cycle.nextDueAt, now)
: null;
if (days === null || days <= 0) {
// on_track with the slot today (now < dueAt but same calendar day).
label = t("medications.cycleNextDoseToday");
} else if (days === 1) {
label = t("medications.cycleNextDoseTomorrow");
} else {
label = t("medications.cycleNextDoseInDays", { days });
}
}
return (
<p className={`flex items-center gap-1.5 text-xs font-medium ${tone}`}>
{icon}
{label}
</p>
);
}
@@ -14,6 +14,7 @@ import {
MedicationComplianceBars,
MedicationComplianceSkeleton,
} from "@/components/medications/card-parts/medication-compliance-bars";
import { MedicationCycleStatus } from "@/components/medications/card-parts/medication-cycle-status";
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
import { useMedicationIntake } from "@/components/medications/use-medication-intake";
import {
@@ -491,6 +492,16 @@ export function Glp1MedicationCard({
<MedicationComplianceSkeleton />
))}
{/* Open-cycle status line — the real win for a weekly GLP-1: between
injections the percentage rows are vacuous, so this calm,
rate-decoupled line reports "next dose in N days" / "due today" /
"overdue" (or a neutral "no closed cycles yet") instead of a 0%
that misreads a perfectly on-schedule med. `state: "none"`
collapses to nothing inside the part. */}
{medication.active && display?.currentCycle && (
<MedicationCycleStatus cycle={display.currentCycle} />
)}
{/* Primary actions row — shared with the generic medication card.
The GLP-1-specific side-effect quick-log lives in the
header-actions overflow (kebab), not this row, so Mounjaro and
@@ -14,6 +14,7 @@ import {
MedicationComplianceBars,
MedicationComplianceSkeleton,
} from "@/components/medications/card-parts/medication-compliance-bars";
import { MedicationCycleStatus } from "@/components/medications/card-parts/medication-cycle-status";
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
import {
MedicationNextLastSlot,
@@ -402,6 +403,16 @@ export function MedicationCard({
<MedicationComplianceSkeleton />
))}
{/* Open-cycle status line — calm, rate-decoupled. A sparse weekly /
rolling med between doses reports "next dose in N days" / "due
today" / "overdue" (or a neutral "no closed cycles yet") instead of
leaning on a percentage that reads as a scary 0% for a med that is
actually on schedule. Rendered only when the server supplies the
descriptor; `state: "none"` collapses to nothing inside the part. */}
{medication.active && display?.currentCycle && (
<MedicationCycleStatus cycle={display.currentCycle} />
)}
{/* Quick actions — primary buttons of the medication card. The
content above reserves constant-height slots, so the card bodies
in a grid row are equal height and `mt-auto` pins the action row