drop dose counts and injection site from the medication card

Remove the per-row dose count (the '· 12 / 12' caption) from the
compliance bars — the percentage, bar and window-days label remain. The
auto-miss engine now makes the two windows' percentages genuinely
diverge, so the counts no longer earn the row noise; the engine still
carries the counts on the wire for other consumers.

Remove the injection-site display from the overview card, including the
last-injection-site shown on the last-intake line. Site tracking and
logging are unchanged elsewhere (the post-dose picker, the detail
history); only the card surface drops it.
This commit is contained in:
Marc-André Bombeck committed 2026-06-07 13:38:37 +02:00
1 parent 5b47aae8b1
commit a30b5f0d36
3 files changed
+125 -120

No files matched your search

@@ -13,7 +13,8 @@ import { MedicationStatusPill } from "@/components/medications/card-parts/medica
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";
import { MedicationCardBody } from "@/components/medications/card-parts/medication-card-body";
import type { CurrentCycle, DoseStatus } from "@/lib/analytics/compliance";
/**
* v1.7.2 — the medication-card status pill, compliance bars, intake-action
@@ -84,49 +85,22 @@ describe("medication card-parts — shared presentational components", () => {
expect(html).not.toContain("lucide-flame");
});
it("compliance bars show taken / expected counts next to each rate", () => {
// v1.15.8 — two identical percentages with no context read as a stuck
// display; the count after each rate makes them distinguishable. A
// rolling weekly med reading 100% on both windows now shows
// `100% · 4 / 4` and `100% · 52 / 52`.
it("compliance bars render the percentage only — no dose count beside it", () => {
// v1.15.9 — the operator does not want a count next to the percentage.
// The auto-miss engine now makes the two windows' percentages genuinely
// diverge, so the per-row count (`· 12 / 12`) is gone: percentage + bar
// + window-days label only.
const html = render(
<MedicationComplianceBars
rate7={100}
rate30={100}
streak={3}
takenShort={4}
expectedShort={4}
takenLong={52}
expectedLong={52}
/>,
);
expect(html).toContain("4 / 4");
expect(html).toContain("52 / 52");
// The middle-dot separator joins the percentage and the count.
expect(html).toContain("·");
});
it("compliance bars fall back to the bare expected count when no taken count is on the wire", () => {
const html = render(
<MedicationComplianceBars
rate7={80}
rate30={75}
streak={0}
expectedShort={12}
expectedLong={52}
/>,
);
expect(html).toContain("12 doses");
expect(html).toContain("52 doses");
});
it("compliance bars render no count line when neither count is available", () => {
// Older mocks / the pre-display fallback path pass only the rates.
const html = render(
<MedicationComplianceBars rate7={90} rate30={88} streak={0} />,
<MedicationComplianceBars rate7={100} rate30={92} streak={3} />,
);
expect(html).toContain("100%");
expect(html).toContain("92%");
// No middle-dot separator and no "doses" count caption anywhere.
expect(html).not.toContain("·");
expect(html).not.toContain("doses");
// The bar + the window-days label survive.
expect(html).toContain("7-day compliance");
expect(html).toContain("30-day compliance");
});
it("status pill stamps the success token + take-now glyph in window", () => {
@@ -397,3 +371,89 @@ describe("streak-token parity — generic vs GLP-1 card", () => {
}
});
});
/**
* v1.15.9 — the ONE shared `<MedicationCardBody>` both cards render. These
* tests pin the state-driven presentation (green take-window highlight,
* overdue / heavily-overdue top line) and the structural identity that makes
* the two variants impossible to diverge — they pass content into THIS body.
*/
describe("medication card body — shared shell + dose-state presentation", () => {
function renderBody(doseStatus: DoseStatus, active = true) {
return render(
<MedicationCardBody
name="Ramipril"
dose="5 mg"
categoryLabel="Blood Pressure"
active={active}
href="/medications/m1"
linkLabel="Open"
stateBadges={null}
headerActions={null}
windowStatus={
doseStatus === "on_time_window"
? { status: "in_window", windowStart: "08:00", windowEnd: "20:00" }
: null
}
doseStatus={doseStatus}
nextLine="Tomorrow, 08:00"
lastLine="Today, 07:30"
compliance={{ rate7: 90, rate30: 88, streak: 0, shortDays: 7, longDays: 30 }}
currentCycle={null}
intakeLoading={null}
onRecordIntake={() => {}}
/>,
);
}
it("highlights the card green when the dose is in its take-window", () => {
const html = renderBody("on_time_window");
// Green ring + tinted background on the Card shell.
expect(html).toContain("ring-success/40");
expect(html).toContain("bg-success/5");
});
it("stays calm (no green highlight) for upcoming / taken states", () => {
for (const status of ["upcoming", "taken_on_time", "taken_late", "skipped"] as const) {
const html = renderBody(status);
expect(html).not.toContain("ring-success/40");
}
});
it("shows a calm 'Overdue' top line for an overdue dose", () => {
const html = renderBody("overdue");
expect(html).toContain("Overdue");
expect(html).toContain("text-destructive");
expect(html).not.toContain("Very overdue");
});
it("escalates to 'Very overdue' at / past the miss cutoff", () => {
const html = renderBody("missed");
expect(html).toContain("Very overdue");
expect(html).toContain("text-destructive");
});
it("never highlights green on an inactive medication", () => {
const html = renderBody("on_time_window", false);
expect(html).not.toContain("ring-success/40");
expect(html).toContain("opacity-60");
});
it("renders the decisive next + last lines exactly once each", () => {
const html = renderBody("upcoming");
expect((html.match(/Next intake:/g) ?? []).length).toBe(1);
expect((html.match(/Last intake:/g) ?? []).length).toBe(1);
expect(html).toContain("Tomorrow, 08:00");
expect(html).toContain("Today, 07:30");
});
it("keeps the body shell + spacing tokens that guarantee cross-variant identity", () => {
const html = renderBody("upcoming");
// Single shared CardContent body with the canonical spacing rhythm.
expect(html).toContain("flex h-full flex-col space-y-3.5");
// Bottom-pinned action wrapper.
expect(html).toContain("mt-auto");
// The shared reserved-height next/last slot.
expect(html).toContain("min-h-[2.75rem] space-y-3.5 text-sm");
});
});
@@ -321,11 +321,12 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
);
// v1.15.8 — the GLP-1 card unified onto the generic "Next intake" /
// "Last intake" labels (the appointment-phrased override is gone). The
// last-line renders the "Last intake:" label in its own left column and
// the value (label · site) flush right; both halves show in the SSR.
// "Last intake" labels (the appointment-phrased override is gone).
// v1.15.9 — the injection SITE is no longer shown on the card line; the
// last-line reads the relative-day label only, exactly like the generic
// card. (Tracking / logging is unchanged everywhere else.)
expect(html).toContain("Last intake:");
expect(html).toContain("Abdomen, lower left");
expect(html).not.toContain("Abdomen, lower left");
// The next-line carries the "Next intake:" label in its left column,
// independent of which of the three value variants (today / tomorrow /
// "in N days") the helper produced.
@@ -372,11 +373,13 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
expect(html).not.toContain("Dosis-Historie");
});
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.
it("does not render any injection-site display on the card", () => {
// v1.15.9 — the card surface drops the injection SITE entirely: neither
// the old "recommended next site" nudge NOR the last-site label on the
// last-injection line. The operator: "where I injected doesn't interest
// me." Site TRACKING is unchanged everywhere else (the post-dose picker
// captures the next site; the detail history shows it) — only the card
// surface is clean.
const client = makeClient();
seedCompliance(client, med7p5.id);
seedGlp1Details(client, med7p5.id, {
@@ -399,10 +402,9 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
client,
);
// No recommendation copy anywhere on the card.
// No recommendation copy and no last-site label 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");
expect(html).not.toContain("Abdomen, lower left");
});
it("no longer renders the pen-inventory line (retired v1.4.28)", () => {
@@ -609,9 +611,10 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
expect(html).toContain("Sonstiges");
expect(html).not.toContain("GLP-1-Injektion");
// v1.15.8 — unified onto the generic "Letzte Einnahme:" label (the
// appointment-phrased "Letzter Termin:" override is gone).
// appointment-phrased "Letzter Termin:" override is gone). v1.15.9 — the
// injection site no longer renders on the card.
expect(html).toContain("Letzte Einnahme:");
expect(html).toContain("Bauch, unten links");
expect(html).not.toContain("Bauch, unten links");
// v1.4.28 retired the "Dosis-Historie" disclosure on the GLP-1 card.
expect(html).not.toContain("Dosis-Historie");
});
@@ -3,30 +3,6 @@ import { Flame } from "lucide-react";
import { Progress } from "@/components/ui/progress";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
type Translate = ReturnType<typeof useTranslations>["t"];
type Formatters = ReturnType<typeof useFormatters>;
/**
* v1.15.8 — build the dose-count caption shown after the percentage.
*
* Returns `taken / expected` (`4 / 12`) when both counts are known, the
* pluralised `<expected> doses` string when only the denominator is on the
* wire, and `null` when neither is available (older mocks / pre-display
* fallback) so the caller renders nothing rather than an empty separator.
*/
function doseCount(
t: Translate,
fmt: Formatters,
taken: number | undefined,
expected: number | undefined,
): string | null {
if (typeof expected !== "number") return null;
if (typeof taken === "number") {
return `${fmt.number(taken)} / ${fmt.number(expected)}`;
}
return t("medications.complianceDoses", { count: expected });
}
interface MedicationComplianceBarsProps {
rate7: number;
rate30: number;
@@ -41,20 +17,6 @@ interface MedicationComplianceBarsProps {
shortDays?: number;
/** v1.8.6 — the span of the long row in days. */
longDays?: number;
/**
* v1.15.8 — taken-dose count over the short window (numerator). Rendered
* after the percentage as a `taken / expected` count so two identical
* percentages stay distinguishable: a rolling weekly med reading 100% on
* every window now shows `100% · 4 / 4` vs `100% · 52 / 52` instead of two
* bare identical numbers that read as a stuck display.
*/
takenShort?: number;
/** v1.15.8 — expected-dose count over the short window (denominator). */
expectedShort?: number;
/** v1.15.8 — taken-dose count over the long window (numerator). */
takenLong?: number;
/** v1.15.8 — expected-dose count over the long window (denominator). */
expectedLong?: number;
}
/**
@@ -67,6 +29,12 @@ interface MedicationComplianceBarsProps {
* 365-day long window. The labels are parametrised on the chosen day-counts
* so each row names the window it actually covers.
*
* v1.15.9 — the per-row dose count (`· 12 / 12`) is gone: the operator wants
* the percentage only. The auto-miss engine now makes the two windows'
* percentages genuinely diverge, so the counts no longer earn the row noise.
* The engine still carries `expected` / `taken` / `missed` on the wire for
* iOS + the Health Score; the card simply does not render them.
*
* The streak flame uses the semantic `text-warning` token (an alias over
* Dracula orange in dark mode, AA-safe on the light card). The generic card
* historically drifted onto Tailwind stock `text-orange-400`, and the flame
@@ -79,10 +47,6 @@ export function MedicationComplianceBars({
streak,
shortDays = 7,
longDays = 30,
takenShort,
expectedShort,
takenLong,
expectedLong,
}: MedicationComplianceBarsProps) {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -96,27 +60,12 @@ export function MedicationComplianceBars({
const shortPct = fmt.number(Math.round(rate7));
const longPct = fmt.number(Math.round(rate30));
// v1.15.8 — the dose-count line rendered after each percentage. Shows the
// taken-of-expected count when both are known (`4 / 12`), else the bare
// expected count when only the denominator is on the wire (`12 Dosen`).
// Two windows reading the same percentage stay distinguishable by their
// counts, which is what an operator needs to trust the number.
const shortCount = doseCount(t, fmt, takenShort, expectedShort);
const longCount = doseCount(t, fmt, takenLong, expectedLong);
return (
<div className="space-y-2.5">
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{shortLabel}</span>
<span className="font-medium">
{shortPct}%
{shortCount && (
<span className="text-muted-foreground ml-1.5 font-normal">
· {shortCount}
</span>
)}
</span>
<span className="font-medium">{shortPct}%</span>
</div>
{/* aria-label so the bar has an accessible name. */}
<Progress value={rate7} className="h-2" aria-label={shortLabel} />
@@ -125,14 +74,7 @@ export function MedicationComplianceBars({
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{longLabel}</span>
<span className="font-medium">
{longPct}%
{longCount && (
<span className="text-muted-foreground ml-1.5 font-normal">
· {longCount}
</span>
)}
</span>
<span className="font-medium">{longPct}%</span>
</div>
<Progress value={rate30} className="h-2" aria-label={longLabel} />
</div>