fix(medications): drop the duplicate next-dose line and the injection-site recommendation from the card

The open-cycle status line repeated the upcoming-dose timing already
shown in the card's next-intake slot; the calm on-track "next dose in N
days" phrasing is now suppressed, leaving only the actionable due /
overdue tiers.

Remove the "recommended next site" nudge from the GLP-1 card — it is
noise on the at-a-glance surface. Injection-site tracking stays: the last
site still reads on the last-injection line and the post-dose picker
still captures the next site.
This commit is contained in:
Marc-André Bombeck committed 2026-06-06 23:25:47 +02:00
1 parent 19af59ebfe
commit 0e184e7f1b
4 files changed
+41 -128

No files matched your search

@@ -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(
<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 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(
<MedicationCycleStatus
cycle={{ ...baseCycle, state: "on_track", nextDueAt: iso }}
/>,
);
expect(html).toContain("Next dose in 4 days");
});
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", () => {
// 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(
<MedicationCycleStatus
cycle={{ ...baseCycle, state: "on_track", nextDueAt: later }}
/>,
);
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(
<MedicationCycleStatus
cycle={{ ...baseCycle, state: "on_track", nextDueAt }}
/>,
);
expect(html).toBe("");
}
});
it("due reads the amber due-today line", () => {
@@ -370,15 +370,11 @@ describe("<Glp1MedicationCard> — 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("<Glp1MedicationCard> — 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");
});
@@ -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 = <AlertTriangle className="size-3.5 shrink-0" aria-hidden="true" />;
label = t("medications.cycleOverdue");
} else if (cycle.state === "due") {
} else {
// 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();
// `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 (
@@ -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}
</span>
)}
{/* 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 && (
<span className="hidden sm:inline">
{" · "}
{t("medications.glp1RotationSuggested", {
site: t(
`medications.site${siteSuffix(recommendedNextSite)}`,
),
})}
</span>
)}
</>
) : null
}