diff --git a/messages/de.json b/messages/de.json index b6e9eff2d..da8dcf50f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 5caff21fa..14a5ae67e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 16245afac..6b0bd6d81 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 685bee44f..1aa77364e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index e9f5270e6..793cdb860 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 7166f54e0..88d334f24 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/components/medications/__tests__/card-parts.test.tsx b/src/components/medications/__tests__/card-parts.test.tsx index 73744fd5f..b11ef174d 100644 --- a/src/components/medications/__tests__/card-parts.test.tsx +++ b/src/components/medications/__tests__/card-parts.test.tsx @@ -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( + , + ); + expect(html).toBe(""); + }); + + it("renders the neutral no-closed-cycles state with the dashed glyph", () => { + const html = render( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + expect(html).toContain("Next dose today"); + }); + + it("due reads the amber due-today line", () => { + const html = render( + , + ); + 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( + , + ); + 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 diff --git a/src/components/medications/card-parts/medication-cycle-status.tsx b/src/components/medications/card-parts/medication-cycle-status.tsx new file mode 100644 index 000000000..c076d076b --- /dev/null +++ b/src/components/medications/card-parts/medication-cycle-status.tsx @@ -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 ( +

+

+ ); + } + + let tone: string; + let icon: React.ReactNode; + let label: string; + + if (cycle.state === "missed") { + tone = "text-destructive"; + icon =