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(
+
+ + {t("medications.cycleNoClosedCycles")} +
+ ); + } + + let tone: string; + let icon: React.ReactNode; + let label: string; + + if (cycle.state === "missed") { + tone = "text-destructive"; + icon = ; + label = t("medications.cycleOverdue"); + } else if (cycle.state === "due") { + tone = "text-warning"; + icon = ; + label = t("medications.cycleDueToday"); + } else { + // on_track — the next dose simply hasn't come round yet. + tone = "text-success"; + icon = ; + 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 ( ++ {icon} + {label} +
+ ); +} diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index 4f9462279..1f81a4d12 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -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({