mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
20d7187b0d
commit
2beaec4149
10 files changed
+279
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user