diff --git a/src/components/medications/__tests__/card-parts.test.tsx b/src/components/medications/__tests__/card-parts.test.tsx index 215dd08e2..f39baaf64 100644 --- a/src/components/medications/__tests__/card-parts.test.tsx +++ b/src/components/medications/__tests__/card-parts.test.tsx @@ -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( - , - ); - 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( - , - ); - 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( - , + , ); + 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 `` 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( + {}} + />, + ); + } + + 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"); + }); +}); diff --git a/src/components/medications/__tests__/medication-card-glp1.test.tsx b/src/components/medications/__tests__/medication-card-glp1.test.tsx index 242391403..d07c69d14 100644 --- a/src/components/medications/__tests__/medication-card-glp1.test.tsx +++ b/src/components/medications/__tests__/medication-card-glp1.test.tsx @@ -321,11 +321,12 @@ describe(" — 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(" — 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(" — 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(" — 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"); }); diff --git a/src/components/medications/card-parts/medication-compliance-bars.tsx b/src/components/medications/card-parts/medication-compliance-bars.tsx index 48e6047bc..df38c8288 100644 --- a/src/components/medications/card-parts/medication-compliance-bars.tsx +++ b/src/components/medications/card-parts/medication-compliance-bars.tsx @@ -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["t"]; -type Formatters = ReturnType; - -/** - * v1.15.8 — build the dose-count caption shown after the percentage. - * - * Returns `taken / expected` (`4 / 12`) when both counts are known, the - * pluralised ` 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 (
{shortLabel} - - {shortPct}% - {shortCount && ( - - · {shortCount} - - )} - + {shortPct}%
{/* aria-label so the bar has an accessible name. */} @@ -125,14 +74,7 @@ export function MedicationComplianceBars({
{longLabel} - - {longPct}% - {longCount && ( - - · {longCount} - - )} - + {longPct}%