diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index cb857b4fe..69bf8b718 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -8456,12 +8456,17 @@ components: type: integer minimum: 0 maximum: 100 + taken: + type: integer + minimum: 0 + maximum: 9007199254740991 streak: type: integer minimum: 0 maximum: 9007199254740991 required: - rate + - taken - streak additionalProperties: false long: @@ -8471,8 +8476,13 @@ components: type: integer minimum: 0 maximum: 100 + taken: + type: integer + minimum: 0 + maximum: 9007199254740991 required: - rate + - taken additionalProperties: false currentCycle: type: object diff --git a/src/components/medications/__tests__/card-parts.test.tsx b/src/components/medications/__tests__/card-parts.test.tsx index 4fbea4ab1..215dd08e2 100644 --- a/src/components/medications/__tests__/card-parts.test.tsx +++ b/src/components/medications/__tests__/card-parts.test.tsx @@ -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( + , + ); + 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).not.toContain("·"); + expect(html).not.toContain("doses"); + }); + it("status pill stamps the success token + take-now glyph in window", () => { const html = render( ["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; @@ -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 (
{shortLabel} - {shortPct}% + + {shortPct}% + {shortCount && ( + + · {shortCount} + + )} +
{/* aria-label so the bar has an accessible name. */} @@ -68,7 +125,14 @@ export function MedicationComplianceBars({
{longLabel} - {longPct}% + + {longPct}% + {longCount && ( + + · {longCount} + + )} +
diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 3f3a85607..157231c45 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -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} /> ) : ( diff --git a/src/lib/analytics/__tests__/compliance.test.ts b/src/lib/analytics/__tests__/compliance.test.ts index 7e190da28..35b22ef4b 100644 --- a/src/lib/analytics/__tests__/compliance.test.ts +++ b/src/lib/analytics/__tests__/compliance.test.ts @@ -1653,6 +1653,118 @@ describe("buildComplianceDisplay — two rows, cadence-scaled windows", () => { expect(display.shortDays).toBe(90); expect(display.longDays).toBe(365); }); + + // v1.15.8 — the card renders the taken-of-expected count next to each rate + // so two identical percentages stay distinguishable. These pin the count + // numerators onto the display block and guard against a future regression + // where every window collapses to the same rate AND the same count (which + // would read as a stuck display rather than a real, trustworthy number). + it("carries a taken-dose count on each window row", () => { + const schedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + rrule: "FREQ=DAILY", + timesOfDay: ["08:00"], + }, + ]; + // Daily med, perfect adherence over the trailing 30 days. + const events = []; + for (let d = 1; d <= 30; d++) { + const at = new Date(NOW.getTime() - d * DAY_MS); + at.setUTCHours(8, 10, 0, 0); + events.push({ scheduledFor: at, takenAt: at, skipped: false }); + } + const display = buildComplianceDisplay(events, schedules, ctx(), { + now: NOW, + }); + // Both rows ~100% — but the COUNTS differ (7-day window has fewer taken + // doses than the 30-day window), so the two rows are distinguishable even + // when the percentages are identical. + expect(display.short.taken).toBeGreaterThan(0); + expect(display.long.taken).toBeGreaterThan(0); + expect(display.long.taken).toBeGreaterThan(display.short.taken); + // The taken count never exceeds its window's expected denominator. + expect(display.short.taken).toBeLessThanOrEqual(display.expectedShort); + expect(display.long.taken).toBeLessThanOrEqual(display.expectedLong); + }); + + it("a daily med with misses concentrated outside the 7-day window → 7d ≠ 30d rate", () => { + // Guards the "looks broken" report from the other direction: the windows + // must NOT collapse to one value. A daily med taken every day inside the + // last 7 days but with a cluster of misses in the 8–30-day range yields a + // perfect short window and a degraded long window, so the two rows + // genuinely differ. + const schedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + rrule: "FREQ=DAILY", + timesOfDay: ["08:00"], + }, + ]; + const events = []; + for (let d = 1; d <= 30; d++) { + // Days 1–7: every dose taken. Days 10–20: missed (no event row). + if (d >= 10 && d <= 20) continue; + const at = new Date(NOW.getTime() - d * DAY_MS); + at.setUTCHours(8, 10, 0, 0); + events.push({ scheduledFor: at, takenAt: at, skipped: false }); + } + const display = buildComplianceDisplay(events, schedules, ctx(), { + now: NOW, + }); + // Daily med stays on the 7 / 30-day rung. + expect(display.shortDays).toBe(7); + expect(display.longDays).toBe(30); + // The recent week is clean; the month carries the 10–20-day miss cluster. + // The windows MUST diverge — the short window outscores the long one, + // which is the whole point of showing two rows rather than one number. + expect(display.short.rate).toBeGreaterThan(display.long.rate); + expect(display.long.rate).toBeLessThan(100); + expect(display.short.rate).not.toBe(display.long.rate); + }); + + it("a rolling weekly med with a partial-adherence history → short and long rates CAN differ", () => { + // The operator's "every window is 100%" suspicion is real ONLY for a + // faithfully-on-cadence med; once doses are missed the windows MUST be + // able to diverge. A weekly rolling injection with recent perfect weeks + // but missed shots further back drives the short rate above the long rate + // — proving the metric does not structurally collapse to one value. + const schedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + rollingIntervalDays: 7, + timesOfDay: ["08:00"], + }, + ]; + const lastShot = new Date(NOW.getTime() - 3 * DAY_MS); + // 16 weekly slots back; drop a cluster of older shots (weeks 8–12) so the + // long window scores a real miss while the recent weeks stay perfect. + const events = Array.from({ length: 16 }, (_, i) => i) + .filter((i) => i < 6 || i > 11) + .map((i) => { + const at = new Date(lastShot.getTime() - i * 7 * DAY_MS); + at.setUTCHours(20, 0, 0, 0); + return { scheduledFor: at, takenAt: at, skipped: false }; + }); + const display = buildComplianceDisplay( + events, + schedules, + ctx({ + startsOn: new Date(lastShot.getTime() - 15 * 7 * DAY_MS), + lastIntakeAt: lastShot, + }), + { now: NOW }, + ); + // The windows are NOT forced equal: the recent short window outscores the + // longer one that carries the dropped cluster. + expect(display.short.rate).toBeGreaterThan(display.long.rate); + }); }); describe("expectedSlotsBetween", () => { diff --git a/src/lib/analytics/compliance.ts b/src/lib/analytics/compliance.ts index 97aad3372..b49779f2d 100644 --- a/src/lib/analytics/compliance.ts +++ b/src/lib/analytics/compliance.ts @@ -636,10 +636,15 @@ export interface ComplianceDisplay { expectedLong: number; /** Echo of the density floor so a client can re-derive the rung. */ minStableDoses: number; - /** Compliance percentage + day-streak over the short window. */ - short: { rate: number; streak: number }; - /** Compliance percentage over the long window. */ - long: { rate: number }; + /** + * Compliance percentage + taken-dose count + day-streak over the short + * window. `taken` is the count of doses taken in the window (the numerator + * the card renders next to the rate so two identical percentages stay + * distinguishable and trustworthy). + */ + short: { rate: number; taken: number; streak: number }; + /** Compliance percentage + taken-dose count over the long window. */ + long: { rate: number; taken: number }; /** * v1.13.x Fix 4 — the open-cycle state, decoupled from the percentage * rows so a between-doses sparse med never renders a scary red number. @@ -785,8 +790,8 @@ export function buildComplianceDisplay( expectedShort, expectedLong, minStableDoses: MIN_STABLE_DOSES, - short: { rate: short.rate, streak: short.streak }, - long: { rate: long.rate }, + short: { rate: short.rate, taken: short.taken, streak: short.streak }, + long: { rate: long.rate, taken: long.taken }, currentCycle, }; } diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index fae3fca3b..f33c09e93 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -1273,9 +1273,13 @@ const complianceDisplay = z minStableDoses: z.number().int().nonnegative(), short: z.object({ rate: z.number().int().min(0).max(100), + taken: z.number().int().nonnegative(), streak: z.number().int().nonnegative(), }), - long: z.object({ rate: z.number().int().min(0).max(100) }), + long: z.object({ + rate: z.number().int().min(0).max(100), + taken: z.number().int().nonnegative(), + }), currentCycle: z .object({ state: z