Show the dose-count denominator on the compliance bars

Two identical compliance percentages with no context read as a stuck
display, even when the math is correct (a rolling weekly med on cadence is
~100% on every window by construction; a daily med with full adherence
rounds to ~100% on both 7d and 30d). Render the taken-of-expected count
after each rate (e.g. 100% with 4 / 4 and 100% with 52 / 52) so the two rows
stay distinguishable and trustworthy. Falls back to the bare expected count
when only the denominator is on the wire, and to nothing when neither count
is available.

Thread a per-window taken count onto ComplianceDisplay (short.taken /
long.taken) alongside the existing expectedShort / expectedLong, wire both
through to the shared bars component, and regen the OpenAPI schema.

Add unit tests asserting the windows do not structurally collapse to one
value: a daily med with misses outside the 7-day window scores 7d above 30d,
and a rolling weekly med with a partial-adherence history yields a short rate
above the long rate. Add card-part tests for the count / fallback / no-count
render branches.
This commit is contained in:
Marc-André Bombeck committed 2026-06-07 01:06:17 +02:00
1 parent a3410efb5c
commit 61c35740fc
7 files changed
+261 -9

No files matched your search

@@ -84,6 +84,51 @@ 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`.
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} />,
);
expect(html).not.toContain("·");
expect(html).not.toContain("doses");
});
it("status pill stamps the success token + take-now glyph in window", () => {
const html = render(
<MedicationStatusPill
@@ -3,6 +3,30 @@ 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;
@@ -17,6 +41,20 @@ 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;
}
/**
@@ -41,6 +79,10 @@ export function MedicationComplianceBars({
streak,
shortDays = 7,
longDays = 30,
takenShort,
expectedShort,
takenLong,
expectedLong,
}: MedicationComplianceBarsProps) {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -54,12 +96,27 @@ 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}%</span>
<span className="font-medium">
{shortPct}%
{shortCount && (
<span className="text-muted-foreground ml-1.5 font-normal">
· {shortCount}
</span>
)}
</span>
</div>
{/* aria-label so the bar has an accessible name. */}
<Progress value={rate7} className="h-2" aria-label={shortLabel} />
@@ -68,7 +125,14 @@ 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}%</span>
<span className="font-medium">
{longPct}%
{longCount && (
<span className="text-muted-foreground ml-1.5 font-normal">
· {longCount}
</span>
)}
</span>
</div>
<Progress value={rate30} className="h-2" aria-label={longLabel} />
</div>
@@ -211,6 +211,14 @@ export function MedicationCard({
const rate7 = display?.short.rate ?? compliance?.compliance7?.rate ?? 0;
const rate30 = display?.long.rate ?? compliance?.compliance30?.rate ?? 0;
const streak = display?.short.streak ?? compliance?.compliance7?.streak ?? 0;
// v1.15.8 — taken-of-expected counts, shown after each percentage so two
// identical rates stay distinguishable. Falls back to the static 7-day
// `taken` when the cadence-scaled display block is absent.
const takenShort = display?.short.taken ?? compliance?.compliance7?.taken;
const expectedShort =
display?.expectedShort ?? compliance?.compliance7?.totalExpected;
const takenLong = display?.long.taken;
const expectedLong = display?.expectedLong;
const categoryLabel = getMedicationCategoryLabel(medication.category, t);
const sortedSchedules = [...medication.schedules].sort(
(a, b) =>
@@ -398,6 +406,10 @@ export function MedicationCard({
streak={streak}
shortDays={shortDays}
longDays={longDays}
takenShort={takenShort}
expectedShort={expectedShort}
takenLong={takenLong}
expectedLong={expectedLong}
/>
) : (
<MedicationComplianceSkeleton />