diff --git a/src/components/medications/__tests__/card-parts.test.tsx b/src/components/medications/__tests__/card-parts.test.tsx
index 708362399..4fbea4ab1 100644
--- a/src/components/medications/__tests__/card-parts.test.tsx
+++ b/src/components/medications/__tests__/card-parts.test.tsx
@@ -207,49 +207,18 @@ describe("medication cycle status — open-cycle line", () => {
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 reads the same day count when nextDueAt arrives as an ISO string", () => {
- // Over the wire `nextDueAt` is JSON, so it reaches the card as a string
- // even though the type says `Date`. The component must re-hydrate it for
- // the Berlin day-bucketing — a raw string would silently drift the count.
- const iso = inDays(4).toISOString() as unknown as Date;
- const html = render(
- ,
- );
- expect(html).toContain("Next dose in 4 days");
- });
-
- 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", () => {
- // Six hours past the pinned noon — still the same Berlin calendar day.
- const later = new Date(Date.now() + 6 * 60 * 60 * 1000);
- const html = render(
- ,
- );
- expect(html).toContain("Next dose today");
+ it("on_track renders nothing — the next-dose timing already reads on the card's top slot", () => {
+ // The calm on_track "next dose in N days" phrasing duplicates the
+ // next-intake slot at the top of the card, so the open-cycle line stays
+ // silent for an on-track med; only due / overdue earn a line here.
+ for (const nextDueAt of [inDays(4), inDays(1), new Date(Date.now() + 6 * 60 * 60 * 1000)]) {
+ const html = render(
+ ,
+ );
+ expect(html).toBe("");
+ }
});
it("due reads the amber due-today line", () => {
diff --git a/src/components/medications/__tests__/medication-card-glp1.test.tsx b/src/components/medications/__tests__/medication-card-glp1.test.tsx
index a7130b2f0..c77f983ea 100644
--- a/src/components/medications/__tests__/medication-card-glp1.test.tsx
+++ b/src/components/medications/__tests__/medication-card-glp1.test.tsx
@@ -370,15 +370,11 @@ describe(" — GLP-1 variant rendering", () => {
expect(html).not.toContain("Dosis-Historie");
});
- it("renders the injection-site rotation nudge folded into the upcoming line", () => {
- // The rotation nudge surfaces only when `lastSite` and the recommender
- // disagree. We feed a left-cluster history → the recommender lands on a
- // right-side or thigh/arm site. The nudge is no longer a separate
- // bordered block (which made the GLP-1 card taller than the generic card
- // in a grid row); it is folded into the upcoming-injection line as a
- // "· Recommended next: …" suffix, so the card keeps the same row
- // inventory as the generic card. The last site stays visible on the
- // last-injection line.
+ it("does not render an injection-site recommendation on the card", () => {
+ // The card no longer carries a "recommended next site" nudge — the
+ // recommendation is noise on the at-a-glance surface. Site TRACKING stays
+ // (the last site reads on the last-injection line; the post-dose picker
+ // still captures the next site), only the recommendation copy is gone.
const client = makeClient();
seedCompliance(client, med7p5.id);
seedGlp1Details(client, med7p5.id, {
@@ -401,11 +397,9 @@ describe(" — GLP-1 variant rendering", () => {
client,
);
- // The recommended-next nudge renders in the upcoming line.
- expect(html).toContain("Recommended next:");
- // The standalone bordered rotation block is gone (height-neutral fold).
- expect(html).not.toContain("bg-muted/40 rounded-md border");
- // Last site stays visible on the last-injection line.
+ // No recommendation copy anywhere on the card.
+ expect(html).not.toContain("Recommended next:");
+ // Last site stays visible on the last-injection line (tracking preserved).
expect(html).toContain("Abdomen, lower left");
});
diff --git a/src/components/medications/card-parts/medication-cycle-status.tsx b/src/components/medications/card-parts/medication-cycle-status.tsx
index 8ff46e633..c364b379f 100644
--- a/src/components/medications/card-parts/medication-cycle-status.tsx
+++ b/src/components/medications/card-parts/medication-cycle-status.tsx
@@ -1,30 +1,13 @@
-import { AlertTriangle, CalendarClock, CircleCheck, CircleDashed } from "lucide-react";
+import { AlertTriangle, CalendarClock, 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.
*
@@ -32,15 +15,20 @@ function calendarDayDelta(target: Date, now: Date): number {
* 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.
+ * decoupled from the rate: it reports the open cycle's calm status — whether
+ * the dose is due or overdue, or that there's not enough history to score yet.
+ *
+ * The calm `on_track` "next dose in N days" phrasing is intentionally NOT
+ * rendered here: the upcoming-dose timing already reads on the card's
+ * next-intake slot at the top, so repeating it would be a duplicate. Only the
+ * actionable due / overdue tiers (and the neutral no-closed-cycles note) earn
+ * this line.
*
* 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.
+ * uses (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();
@@ -61,6 +49,12 @@ export function MedicationCycleStatus({ cycle }: MedicationCycleStatusProps) {
);
}
+ // on_track — the next dose simply hasn't come round yet. The upcoming-dose
+ // timing already reads on the card's next-intake slot at the top, so a second
+ // "next dose in N days" line here is a duplicate. Render nothing for the
+ // on-track case; only the actionable due / overdue tiers earn this line.
+ if (cycle.state !== "missed" && cycle.state !== "due") return null;
+
let tone: string;
let icon: React.ReactNode;
let label: string;
@@ -69,30 +63,11 @@ export function MedicationCycleStatus({ cycle }: MedicationCycleStatusProps) {
tone = "text-destructive";
icon = ;
label = t("medications.cycleOverdue");
- } else if (cycle.state === "due") {
+ } else {
+ // 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();
- // `nextDueAt` is typed `Date` but arrives as an ISO string over the wire;
- // re-hydrate it so the Berlin day-bucketing (toLocaleString with a
- // timeZone) actually applies — a raw string would silently skip it and
- // drift the count by a day near midnight / off Berlin.
- const days = cycle.nextDueAt
- ? calendarDayDelta(new Date(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 (
diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx
index 1f81a4d12..df75a98a8 100644
--- a/src/components/medications/glp1-medication-card.tsx
+++ b/src/components/medications/glp1-medication-card.tsx
@@ -29,10 +29,7 @@ import {
} from "@/lib/query-keys";
import { formatDateTime, formatTime } from "@/lib/format";
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
-import {
- nextInjectionSite,
- type InjectionSiteKey,
-} from "@/lib/medications/injection-sites";
+import { type InjectionSiteKey } from "@/lib/medications/injection-sites";
import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog";
import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs";
import {
@@ -324,11 +321,6 @@ export function Glp1MedicationCard({
const recentInjections = details?.recentIntakes ?? [];
const lastSite =
recentInjections.find((i) => i.injectionSite)?.injectionSite ?? null;
- const recommendedNextSite = nextInjectionSite(
- recentInjections
- .map((r) => r.injectionSite)
- .filter(Boolean) as InjectionSiteKey[],
- );
function lastInjectionLabel(): string | null {
if (!medication.lastTakenAt) return null;
@@ -433,23 +425,6 @@ export function Glp1MedicationCard({
— {schedule.dose}
)}
- {/* Rotation nudge — folded into the upcoming-injection line
- instead of a separate bordered block, so the GLP-1 card
- keeps the same row inventory (and height) as the generic
- card. Only when we have a last + recommended site that
- differs from it. */}
- {lastSite &&
- recommendedNextSite &&
- recommendedNextSite !== lastSite && (
-
- {" · "}
- {t("medications.glp1RotationSuggested", {
- site: t(
- `medications.site${siteSuffix(recommendedNextSite)}`,
- ),
- })}
-
- )}
>
) : null
}