diff --git a/CHANGELOG.md b/CHANGELOG.md index 71f7d466a..b6c1583d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +## [1.15.8] — 2026-06-07 — medication cards + +### Changed + +- **Consistent medication cards.** Both medication card styles now use the same structure and the same labels for the same thing — "Next intake" and "Last intake" everywhere — so the two cards read the same way. The GLP-1 card keeps its relative-day hint ("Saturday 13 Jul (in 7 days)"). +- **Compliance bars show the counts.** Each compliance bar now shows the doses behind the percentage (for example "100% · 12 / 12"), so two windows that genuinely land at the same percentage are legible at a glance instead of looking like a glitch. + ## [1.15.7] — 2026-06-07 — data import in the browser ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index cb857b4fe..513615cee 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.15.7 + version: 1.15.8 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -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/messages/de.json b/messages/de.json index b3cb6f540..5aa7eb089 100644 --- a/messages/de.json +++ b/messages/de.json @@ -700,6 +700,7 @@ "compliance7d": "7-Tage-Compliance", "compliance30d": "30-Tage-Compliance", "complianceWindow": "{days}-Tage-Compliance", + "complianceDoses": "{count} Dosen", "lastIntake": "Letzte Einnahme:", "nextIntake": "Nächste Einnahme:", "dayStreak": "Tage Serie", @@ -745,8 +746,6 @@ "glp1DosesPerUnit": "Dosen pro Pen / Ampulle", "glp1DosesPerUnitPlaceholder": "z. B. 4 für einen Mounjaro KwikPen", "glp1DosesPerUnitHelp": "Optional — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy Einmal-Pen = 1. Leer lassen, wenn keine Dosis-pro-Pen-Verfolgung gewünscht ist.", - "glp1NextLabel": "Nächster Termin:", - "glp1LastLabel": "Letzter Termin:", "glp1LastInjection": "{label}", "glp1LastInjectionWithSite": "{label} · {site}", "glp1NextInjectionDays": "{label} (in {days} Tagen)", diff --git a/messages/en.json b/messages/en.json index 659f3aa2b..bcfa155bf 100644 --- a/messages/en.json +++ b/messages/en.json @@ -700,6 +700,7 @@ "compliance7d": "7-day compliance", "compliance30d": "30-day compliance", "complianceWindow": "{days}-day compliance", + "complianceDoses": "{count} doses", "lastIntake": "Last intake:", "nextIntake": "Next intake:", "dayStreak": "day streak", @@ -745,8 +746,6 @@ "glp1DosesPerUnit": "Doses per pen / vial", "glp1DosesPerUnitPlaceholder": "e.g. 4 for a Mounjaro KwikPen", "glp1DosesPerUnitHelp": "Optional — Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose pen = 1. Leave blank if you don't track per-pen dosing.", - "glp1NextLabel": "Next:", - "glp1LastLabel": "Last:", "glp1LastInjection": "{label}", "glp1LastInjectionWithSite": "{label} · {site}", "glp1NextInjectionDays": "{label} (in {days} days)", diff --git a/messages/es.json b/messages/es.json index b8fb454ac..6e5146b72 100644 --- a/messages/es.json +++ b/messages/es.json @@ -700,6 +700,7 @@ "compliance7d": "Cumplimiento 7 días", "compliance30d": "Cumplimiento 30 días", "complianceWindow": "Cumplimiento {days} días", + "complianceDoses": "{count} dosis", "lastIntake": "Última toma:", "nextIntake": "Próxima toma:", "dayStreak": "días de racha", @@ -745,8 +746,6 @@ "glp1DosesPerUnit": "Dosis por pluma / vial", "glp1DosesPerUnitPlaceholder": "p. ej. 4 para una pluma Mounjaro KwikPen", "glp1DosesPerUnitHelp": "Opcional — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy pluma de un solo uso = 1. Déjalo vacío si no quieres llevar el conteo de dosis por pluma.", - "glp1NextLabel": "Próxima cita:", - "glp1LastLabel": "Última cita:", "glp1LastInjection": "{label}", "glp1LastInjectionWithSite": "{label} · {site}", "glp1NextInjectionDays": "{label} (en {days} días)", diff --git a/messages/fr.json b/messages/fr.json index 5d034153b..c422e5303 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -700,6 +700,7 @@ "compliance7d": "Observance 7 jours", "compliance30d": "Observance 30 jours", "complianceWindow": "Observance {days} jours", + "complianceDoses": "{count} doses", "lastIntake": "Dernière prise :", "nextIntake": "Prochaine prise :", "dayStreak": "jours de série", @@ -745,8 +746,6 @@ "glp1DosesPerUnit": "Doses par stylo / flacon", "glp1DosesPerUnitPlaceholder": "p. ex. 4 pour un stylo Mounjaro KwikPen", "glp1DosesPerUnitHelp": "Optionnel — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy stylo à usage unique = 1. Laisse vide si tu ne veux pas suivre la dose par stylo.", - "glp1NextLabel": "Prochain rendez-vous :", - "glp1LastLabel": "Dernier rendez-vous :", "glp1LastInjection": "{label}", "glp1LastInjectionWithSite": "{label} · {site}", "glp1NextInjectionDays": "{label} (dans {days} jours)", diff --git a/messages/it.json b/messages/it.json index 97b5284fb..112f8c9ba 100644 --- a/messages/it.json +++ b/messages/it.json @@ -700,6 +700,7 @@ "compliance7d": "Aderenza 7 giorni", "compliance30d": "Aderenza 30 giorni", "complianceWindow": "Aderenza {days} giorni", + "complianceDoses": "{count} dosi", "lastIntake": "Ultima assunzione:", "nextIntake": "Prossima assunzione:", "dayStreak": "giorni di serie", @@ -745,8 +746,6 @@ "glp1DosesPerUnit": "Dosi per penna / fiala", "glp1DosesPerUnitPlaceholder": "es. 4 per una penna Mounjaro KwikPen", "glp1DosesPerUnitHelp": "Opzionale — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy penna monouso = 1. Lascia vuoto se non vuoi monitorare le dosi per penna.", - "glp1NextLabel": "Prossimo appuntamento:", - "glp1LastLabel": "Ultimo appuntamento:", "glp1LastInjection": "{label}", "glp1LastInjectionWithSite": "{label} · {site}", "glp1NextInjectionDays": "{label} (tra {days} giorni)", diff --git a/messages/pl.json b/messages/pl.json index d238c5397..fa0e5dead 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -700,6 +700,7 @@ "compliance7d": "Przestrzeganie 7 dni", "compliance30d": "Przestrzeganie 30 dni", "complianceWindow": "Przestrzeganie {days} dni", + "complianceDoses": "{count} dawek", "lastIntake": "Ostatnie przyjęcie:", "nextIntake": "Następne przyjęcie:", "dayStreak": "dni serii", @@ -745,8 +746,6 @@ "glp1DosesPerUnit": "Dawki na pen / fiolkę", "glp1DosesPerUnitPlaceholder": "np. 4 dla pena Mounjaro KwikPen", "glp1DosesPerUnitHelp": "Opcjonalne — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy pen jednorazowy = 1. Zostaw puste, jeśli nie chcesz śledzić dawek na pen.", - "glp1NextLabel": "Następny termin:", - "glp1LastLabel": "Ostatni termin:", "glp1LastInjection": "{label}", "glp1LastInjectionWithSite": "{label} · {site}", "glp1NextInjectionDays": "{label} (za {days} dni)", diff --git a/package.json b/package.json index 2713351c5..e98c2d64a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.15.7", + "version": "1.15.8", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev", 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( — GLP-1 variant rendering", () => { client, ); - // The last-line renders the "Last:" label in its own left column and + // 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. - expect(html).toContain("Last:"); + expect(html).toContain("Last intake:"); expect(html).toContain("Abdomen, lower left"); - // The next-line carries the "Next:" label in its left column, + // 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. - expect(html).toMatch(/Next:/); + expect(html).toMatch(/Next intake:/); }); it("no longer renders the inline dose-history disclosure (retired v1.4.28)", () => { @@ -606,7 +608,9 @@ describe(" — GLP-1 variant rendering", () => { // visually symmetric with the generic card on the medications list. expect(html).toContain("Sonstiges"); expect(html).not.toContain("GLP-1-Injektion"); - expect(html).toContain("Letzter Termin:"); + // v1.15.8 — unified onto the generic "Letzte Einnahme:" label (the + // appointment-phrased "Letzter Termin:" override is gone). + expect(html).toContain("Letzte Einnahme:"); expect(html).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 da65d000f..48e6047bc 100644 --- a/src/components/medications/card-parts/medication-compliance-bars.tsx +++ b/src/components/medications/card-parts/medication-compliance-bars.tsx @@ -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["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/card-parts/medication-next-last-slot.tsx b/src/components/medications/card-parts/medication-next-last-slot.tsx index 9c77b61f2..5232f57b7 100644 --- a/src/components/medications/card-parts/medication-next-last-slot.tsx +++ b/src/components/medications/card-parts/medication-next-last-slot.tsx @@ -22,16 +22,18 @@ import { useTranslations } from "@/lib/i18n/context"; * that renders both lines, so the dose rows line up across the grid. * * Each card still builds the value *content* itself (the generic card emits a - * day-label + window-range; the GLP-1 card emits a weekly-cadence string), - * because the two cadences read differently — but the wrapper, order, colour, - * label/value layout, and gating are now guaranteed identical. The GLP-1 card - * overrides the left-column label with its appointment-phrased wording. + * day-label + window-range; the GLP-1 card emits a relative-day cadence + * string), because the two cadences read differently — but the wrapper, + * order, colour, label/value layout, and gating are guaranteed identical. + * v1.15.8 dropped the GLP-1-specific appointment-phrased label override so + * both cards name the concept identically ("Next intake" / "Last intake"); + * only the value content stays per-card. * - * Layout: when `labelled`, each line is a `flex justify-between` row with the - * bold label pinned left and the value flush right (`text-right`), so the - * label and the time read as two distinct columns rather than crammed onto - * one run of text. The label truncates first on a narrow viewport; the value - * (the time / cadence) never truncates. + * Layout: each line is a `flex justify-between` row with the bold label + * pinned left and the value flush right (`text-right`), so the label and the + * time read as two distinct columns rather than crammed onto one run of text. + * The label truncates first on a narrow viewport; the value (the time / + * cadence) never truncates. */ interface MedicationNextLastSlotProps { /** @@ -42,57 +44,32 @@ interface MedicationNextLastSlotProps { next: React.ReactNode | null; /** The last-intake line content, or null when the med has never been taken. */ last: React.ReactNode | null; - /** - * When true the part renders each line as a two-column row: a bold label - * pinned left and the value flush right. The generic card uses the default - * oral "Next intake" / "Last intake" labels; the GLP-1 card overrides them - * with its appointment-phrased labels (see `nextLabel` / `lastLabel`). - * When false the line renders as a single full-width run of text. - */ - labelled?: boolean; - /** - * Override for the left-column label on the next-line. Defaults to the - * oral `medications.nextIntake` string. The GLP-1 card passes its own - * "next appointment" wording so the value column carries only the time. - */ - nextLabel?: React.ReactNode; - /** Override for the left-column label on the last-line. */ - lastLabel?: React.ReactNode; } export function MedicationNextLastSlot({ next, last, - labelled = true, - nextLabel, - lastLabel, }: MedicationNextLastSlotProps) { const { t } = useTranslations(); return (
- {next && - (labelled ? ( -
- - {nextLabel ?? t("medications.nextIntake")} - - {next} -
- ) : ( -

{next}

- ))} - {last && - (labelled ? ( -
- - {lastLabel ?? t("medications.lastIntake")} - - {last} -
- ) : ( -

{last}

- ))} + {next && ( +
+ + {t("medications.nextIntake")} + + {next} +
+ )} + {last && ( +
+ + {t("medications.lastIntake")} + + {last} +
+ )}
); } diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index df75a98a8..8bfdbae2a 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -285,6 +285,13 @@ export function Glp1MedicationCard({ 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 (a weekly GLP-1 reading 100% on + // both windows shows e.g. `100% · 4 / 4` vs `100% · 13 / 13`). + const takenShort = display?.short.taken; + const expectedShort = display?.expectedShort; + const takenLong = display?.long.taken; + const expectedLong = display?.expectedLong; // v1.4.37 W4b — symmetric take-now / overdue pill with the generic // card. Sort schedules by `windowStart` so the most-actionable @@ -403,14 +410,14 @@ export function Glp1MedicationCard({ {/* Injection state — next + last, rendered through the SAME shared the generic card uses, so the order - (next-then-last), colour, spacing, gating, reserved min-height - and the label-left / value-right two-column layout are identical - across the two card types. The GLP-1 card overrides only the - left-column label with its appointment-phrased wording; the value - column carries the time (plus the optional rotation nudge). */} + (next-then-last), labels ("Next intake" / "Last intake"), colour, + spacing, gating, reserved min-height and the label-left / + value-right two-column layout are identical across the two card + types. v1.15.8 dropped the GLP-1-specific appointment-phrased + label override so both cards name the concept identically; the + relative-day value phrasing ("in N days" / "today") stays the + GLP-1 card's own and is preserved. */} @@ -462,6 +469,10 @@ export function Glp1MedicationCard({ streak={streak} shortDays={shortDays} longDays={longDays} + takenShort={takenShort} + expectedShort={expectedShort} + takenLong={takenLong} + expectedLong={expectedLong} /> ) : ( 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