From e5b873833af19f56c83537656b2af41c36846458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 1 Jun 2026 22:38:19 +0200 Subject: [PATCH] fix(medications): restore the two-row compliance display, scaled to cadence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.8.5 swapped the long-standing 7-/30-day compliance rows for a per-dose adherence timeline on sparse-cadence medications. The timeline breaks the card layout and reads as restless, so every medication is back to two percentage rows. The two windows are no longer hardcoded — they scale with the dosing cadence. selectComplianceWindows walks a 7/30 -> 30/90 -> 90/365 ladder and returns the densest rung whose both windows clear MIN_STABLE_DOSES realised expected doses, so a daily med stays on 7/30, a weekly med steps up to 30/90, and a rolling 35-day injection lands on the widest 90/365 rung. When even the top rung can't clear the floor (a brand-new or very rare med) the widest rung is returned so the card still shows two honest rows. The row labels follow the chosen windows via a parametrised complianceWindow string. The compliance percentage math is unchanged: each row is the existing cadence-aware calculateCompliance over the selected window. compliance7 / compliance30 stay on the wire verbatim for iOS and the Health Score. - compliance.ts: replace the timeline display with selectComplianceWindows + the window ladder; buildComplianceDisplay now returns the two scaled rows - remove the dose-adherence-timeline component and its now-dead strings - medication-compliance-bars: parametrise the row labels on the day-counts - switch the standard and GLP-1 cards back to the always-two-row bars --- messages/de.json | 8 +- messages/en.json | 8 +- messages/es.json | 8 +- messages/fr.json | 8 +- messages/it.json | 8 +- messages/pl.json | 8 +- .../[id]/compliance/__tests__/route.test.ts | 46 ++- .../api/medications/[id]/compliance/route.ts | 15 +- .../medications/__tests__/card-parts.test.tsx | 17 + .../card-parts/dose-adherence-timeline.tsx | 173 --------- .../card-parts/medication-compliance-bars.tsx | 42 ++- .../medications/glp1-medication-card.tsx | 66 ++-- .../medications/medication-card.tsx | 66 ++-- .../analytics/__tests__/compliance.test.ts | 184 +++++----- src/lib/analytics/compliance.ts | 346 +++++++----------- 15 files changed, 376 insertions(+), 627 deletions(-) delete mode 100644 src/components/medications/card-parts/dose-adherence-timeline.tsx diff --git a/messages/de.json b/messages/de.json index e880d7d36..6c7742275 100644 --- a/messages/de.json +++ b/messages/de.json @@ -586,16 +586,10 @@ "veryOverdue": "Stark überfällig", "compliance7d": "7-Tage-Compliance", "compliance30d": "30-Tage-Compliance", + "complianceWindow": "{days}-Tage-Compliance", "lastIntake": "Letzte Einnahme:", "nextIntake": "Nächste Einnahme:", "dayStreak": "Tage Serie", - "lastNDoses": "Letzte {total} Dosen: {taken} von {total}", - "doseStreak": "{count} in Folge", - "doseTimelineAriaLabel": "Jüngste Einnahmetreue", - "doseTaken": "Genommen", - "doseMissed": "Verpasst", - "doseSkipped": "Übersprungen", - "doseUpcoming": "Anstehend", "taken": "Genommen", "skipped": "Übersprungen", "intakeToastTaken": "{name} als genommen erfasst", diff --git a/messages/en.json b/messages/en.json index b72e5de89..3d4b3a817 100644 --- a/messages/en.json +++ b/messages/en.json @@ -586,16 +586,10 @@ "veryOverdue": "Very overdue", "compliance7d": "7-day compliance", "compliance30d": "30-day compliance", + "complianceWindow": "{days}-day compliance", "lastIntake": "Last intake:", "nextIntake": "Next intake:", "dayStreak": "day streak", - "lastNDoses": "Last {total} doses: {taken} of {total}", - "doseStreak": "{count} in a row", - "doseTimelineAriaLabel": "Recent dose adherence", - "doseTaken": "Taken", - "doseMissed": "Missed", - "doseSkipped": "Skipped", - "doseUpcoming": "Upcoming", "taken": "Taken", "skipped": "Skipped", "intakeToastTaken": "{name} recorded as taken", diff --git a/messages/es.json b/messages/es.json index 4a09a62ae..51b9f4b91 100644 --- a/messages/es.json +++ b/messages/es.json @@ -586,16 +586,10 @@ "veryOverdue": "Muy vencida", "compliance7d": "Cumplimiento 7 días", "compliance30d": "Cumplimiento 30 días", + "complianceWindow": "Cumplimiento {days} días", "lastIntake": "Última toma:", "nextIntake": "Próxima toma:", "dayStreak": "días de racha", - "lastNDoses": "Últimas {total} dosis: {taken} de {total}", - "doseStreak": "{count} seguidas", - "doseTimelineAriaLabel": "Adherencia reciente a las dosis", - "doseTaken": "Tomada", - "doseMissed": "Omitida", - "doseSkipped": "Saltada", - "doseUpcoming": "Próxima", "taken": "Tomada", "skipped": "Omitida", "intakeToastTaken": "{name} registrada como tomada", diff --git a/messages/fr.json b/messages/fr.json index b0536ba98..3cd6503e5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -586,16 +586,10 @@ "veryOverdue": "Très en retard", "compliance7d": "Observance 7 jours", "compliance30d": "Observance 30 jours", + "complianceWindow": "Observance {days} jours", "lastIntake": "Dernière prise :", "nextIntake": "Prochaine prise :", "dayStreak": "jours de série", - "lastNDoses": "{total} dernières doses : {taken} sur {total}", - "doseStreak": "{count} d'affilée", - "doseTimelineAriaLabel": "Observance récente des doses", - "doseTaken": "Pris", - "doseMissed": "Manqué", - "doseSkipped": "Ignoré", - "doseUpcoming": "À venir", "taken": "Pris", "skipped": "Sauté", "intakeToastTaken": "{name} enregistré comme pris", diff --git a/messages/it.json b/messages/it.json index 73de3baa4..62a9078b4 100644 --- a/messages/it.json +++ b/messages/it.json @@ -586,16 +586,10 @@ "veryOverdue": "Molto in ritardo", "compliance7d": "Aderenza 7 giorni", "compliance30d": "Aderenza 30 giorni", + "complianceWindow": "Aderenza {days} giorni", "lastIntake": "Ultima assunzione:", "nextIntake": "Prossima assunzione:", "dayStreak": "giorni di serie", - "lastNDoses": "Ultime {total} dosi: {taken} su {total}", - "doseStreak": "{count} di fila", - "doseTimelineAriaLabel": "Aderenza recente alle dosi", - "doseTaken": "Assunta", - "doseMissed": "Mancata", - "doseSkipped": "Saltata", - "doseUpcoming": "In arrivo", "taken": "Assunta", "skipped": "Saltata", "intakeToastTaken": "{name} registrata come assunta", diff --git a/messages/pl.json b/messages/pl.json index d781ee6f1..abaaa6539 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -586,16 +586,10 @@ "veryOverdue": "Mocno po terminie", "compliance7d": "Przestrzeganie 7 dni", "compliance30d": "Przestrzeganie 30 dni", + "complianceWindow": "Przestrzeganie {days} dni", "lastIntake": "Ostatnie przyjęcie:", "nextIntake": "Następne przyjęcie:", "dayStreak": "dni serii", - "lastNDoses": "Ostatnie {total} dawek: {taken} z {total}", - "doseStreak": "{count} z rzędu", - "doseTimelineAriaLabel": "Ostatnie przestrzeganie dawek", - "doseTaken": "Przyjęte", - "doseMissed": "Pominięte", - "doseSkipped": "Opuszczone", - "doseUpcoming": "Nadchodzące", "taken": "Przyjęte", "skipped": "Pominięte", "intakeToastTaken": "{name} oznaczone jako przyjęte", diff --git a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts index a281f05e5..ae9e21ed9 100644 --- a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts @@ -247,13 +247,12 @@ describe("GET /api/medications/[id]/compliance — per-slot timing", () => { }); }); -// v1.8.5 — the additive `complianceDisplay` block. The server decides the -// render mode from expected-dose density: a daily med keeps the percentage -// bars (`"percent"`), a sparse rolling/long-interval med swaps to the -// per-dose timeline (`"timeline"`). The existing `compliance7` / -// `compliance30` fields stay untouched. +// v1.8.6 — the `complianceDisplay` block is always two percentage rows; the +// server scales the two windows to the dosing cadence. A daily med keeps +// 7 / 30 days; a sparse rolling med steps both windows up to the widest rung. +// The existing `compliance7` / `compliance30` fields stay untouched. describe("GET /api/medications/[id]/compliance — complianceDisplay", () => { - it("daily med → mode: percent", async () => { + it("daily med → 7 / 30-day windows", async () => { vi.mocked(prisma.medication.findUnique).mockResolvedValue( medication([ { @@ -277,24 +276,32 @@ describe("GET /api/medications/[id]/compliance — complianceDisplay", () => { const data = await callRouteData(); const display = data.complianceDisplay as { - mode: string; - expected30: number; + shortDays: number; + longDays: number; + expectedLong: number; minStableDoses: number; + short: { rate: number; streak: number }; + long: { rate: number }; }; - expect(display.mode).toBe("percent"); - expect(display.expected30).toBeGreaterThanOrEqual(display.minStableDoses); + expect(display.shortDays).toBe(7); + expect(display.longDays).toBe(30); + expect(display.expectedLong).toBeGreaterThanOrEqual(display.minStableDoses); + expect(display.short).toBeDefined(); + expect(display.long).toBeDefined(); + // No timeline field — the display is two rows only. + expect(display).not.toHaveProperty("mode"); // The legacy fields stay on the wire. expect(data.compliance7).toBeDefined(); expect(data.compliance30).toBeDefined(); }); - it("35-day-interval med → mode: timeline", async () => { + it("35-day-interval med → steps the windows up past 7 / 30 days", async () => { vi.mocked(prisma.medication.findUnique).mockResolvedValue( { id: "med-1", userId: "user-1", - createdAt: new Date(Date.now() - 200 * 24 * 60 * 60 * 1000), - startsOn: new Date(Date.now() - 25 * 24 * 60 * 60 * 1000), + createdAt: new Date(Date.now() - 400 * 24 * 60 * 60 * 1000), + startsOn: new Date(Date.now() - 380 * 24 * 60 * 60 * 1000), endsOn: null, oneShot: false, schedules: [ @@ -314,8 +321,6 @@ describe("GET /api/medications/[id]/compliance — complianceDisplay", () => { ], } as never, ); - // One past intake re-anchors the rolling cadence ~25 days ago, so the - // next slot lands beyond the 30-day window → <4 expected → timeline. vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ { takenAt: new Date(Date.now() - 25 * 24 * 60 * 60 * 1000), @@ -326,11 +331,12 @@ describe("GET /api/medications/[id]/compliance — complianceDisplay", () => { const data = await callRouteData(); const display = data.complianceDisplay as { - mode: string; - expected30: number; - minStableDoses: number; + shortDays: number; + longDays: number; }; - expect(display.mode).toBe("timeline"); - expect(display.expected30).toBeLessThan(display.minStableDoses); + // A 35-day cadence can't clear four expected doses in a 30-day window, so + // the ladder steps the short window beyond 7 days and the long beyond 30. + expect(display.shortDays).toBeGreaterThan(7); + expect(display.longDays).toBeGreaterThan(30); }); }); diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index 7dce9c68c..779506b34 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -142,12 +142,12 @@ export const GET = apiHandler( { medicationContext }, ); - // v1.8.5 — the additive display block. `mode` is server-decided from - // expected-dose density: dense cadences keep the 7-/30-day percentage - // bars (`"percent"`), sparse cadences (weekly, bi-/tri-weekly, rolling - // 35-day injections) swap to the per-dose uptime strip (`"timeline"`). - // `compliance7` / `compliance30` above are untouched — iOS + the Health - // Score read them verbatim. + // v1.8.6 — the two-row compliance display. The card always shows two + // percentage rows; the server scales the two windows to the dosing + // cadence (dense meds keep 7 / 30 days, sparse meds step both windows + // up) and computes each row's rate over the chosen span. `compliance7` + // / `compliance30` above are untouched — iOS + the Health Score read + // them verbatim. const complianceDisplay = buildComplianceDisplay( mapped, medication.schedules, @@ -289,7 +289,8 @@ export const GET = apiHandler( meta: { compliance7: compliance7.rate, compliance30: compliance30.rate, - displayMode: complianceDisplay.mode, + complianceShortDays: complianceDisplay.shortDays, + complianceLongDays: complianceDisplay.longDays, }, }); diff --git a/src/components/medications/__tests__/card-parts.test.tsx b/src/components/medications/__tests__/card-parts.test.tsx index 77f64fc65..9cb63d44e 100644 --- a/src/components/medications/__tests__/card-parts.test.tsx +++ b/src/components/medications/__tests__/card-parts.test.tsx @@ -56,6 +56,23 @@ describe("medication card-parts — shared presentational components", () => { expect(html).not.toContain("text-orange-400"); }); + it("compliance bars scale the row labels to the chosen windows", () => { + // v1.8.6 — a sparse cadence steps both windows up; the labels follow. + const html = render( + , + ); + expect(html).toContain("90-day compliance"); + expect(html).toContain("365-day compliance"); + expect(html).not.toContain("7-day compliance"); + expect(html).not.toContain("30-day compliance"); + }); + it("compliance bars hide the streak flame when streak is zero", () => { const html = render( , diff --git a/src/components/medications/card-parts/dose-adherence-timeline.tsx b/src/components/medications/card-parts/dose-adherence-timeline.tsx deleted file mode 100644 index fc305b54b..000000000 --- a/src/components/medications/card-parts/dose-adherence-timeline.tsx +++ /dev/null @@ -1,173 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; -import { Flame } from "lucide-react"; - -import { useFormatters, useTranslations } from "@/lib/i18n/context"; - -/** - * v1.8.5 — uptime-style per-dose adherence strip for sparse cadences. - * - * The medication cards keep the 7-/30-day percentage bars for dense - * cadences (daily / multi-daily / weekday meds) where the denominator is - * large enough for the percentage to be stable. For sparse cadences - * (weekly, bi-/tri-weekly, rolling 35-day injections) a rolling percentage - * swings violently off one sample and tells the user nothing; the server - * flips `complianceDisplay.mode` to `"timeline"` and this strip takes over. - * - * Each cell is one EXPECTED dose (not a calendar day), oldest → newest: - * taken=green / missed=red / skipped=grey / upcoming=outline. The color - * tokens and the tap-to-pin tooltip mirror the 90-day compliance heatmap - * (`src/components/charts/compliance-heatmap.tsx`) so the two surfaces read - * the same. Plain flex divs, not Recharts — a fixed-size status-cell row - * needs no chart container, axis, or tooltip plumbing. - */ - -export type DoseStatus = "taken" | "missed" | "skipped" | "upcoming"; -export type DoseTiming = "early" | "on_time" | "late" | "very_late" | "missed"; - -export interface DoseAdherenceCell { - scheduledFor: string; - status: DoseStatus; - takenAt: string | null; - timing: DoseTiming | null; - site: string | null; -} - -interface DoseAdherenceTimelineProps { - doses: DoseAdherenceCell[]; - summary: { taken: number; total: number; doseStreak: number }; -} - -// v1.4.27 MB7 / CF-10 precedent — clear the 14 px touch floor (with the -// gap the row stays tap-friendly on narrow viewports). Mirrors the -// heatmap's `CELL_FLOOR_PX`. -const CELL_PX = 14; - -/** Token mirror of the heatmap's status palette. */ -function cellColor(status: DoseStatus, timing: DoseTiming | null): string { - if (status === "upcoming") return "transparent"; - if (status === "skipped") return "var(--secondary)"; - if (status === "missed") return "var(--dracula-red)"; - // taken — shade by punctuality, same ladder the heatmap uses. - if (timing === "very_late") return "var(--dracula-orange)"; - if (timing === "late") return "var(--dracula-yellow)"; - return "var(--dracula-green)"; -} - -export function DoseAdherenceTimeline({ - doses, - summary, -}: DoseAdherenceTimelineProps) { - const { t } = useTranslations(); - const fmt = useFormatters(); - const containerRef = useRef(null); - const [tooltip, setTooltip] = useState<{ - x: number; - y: number; - text: string; - pinned?: boolean; - } | null>(null); - - // Mirror the heatmap: an outside tap clears a pinned tooltip so a touch - // user can dismiss the per-cell detail. Gated on `pinned` so the hover - // flow never pays the indirection. - useEffect(() => { - if (!tooltip?.pinned) return; - const handlePointer = (event: PointerEvent) => { - const container = containerRef.current; - if (!container) return; - if (!container.contains(event.target as Node)) setTooltip(null); - }; - document.addEventListener("pointerdown", handlePointer, true); - return () => { - document.removeEventListener("pointerdown", handlePointer, true); - }; - }, [tooltip?.pinned]); - - function statusLabel(status: DoseStatus): string { - if (status === "taken") return t("medications.doseTaken"); - if (status === "missed") return t("medications.doseMissed"); - if (status === "skipped") return t("medications.doseSkipped"); - return t("medications.doseUpcoming"); - } - - function cellText(cell: DoseAdherenceCell): string { - const when = fmt.dateTime(cell.scheduledFor); - return `${when} — ${statusLabel(cell.status)}`; - } - - return ( -
-
-
- {doses.map((cell, i) => { - const isUpcoming = cell.status === "upcoming"; - return ( -
{ - if (e.pointerType === "touch") return; - setTooltip({ - x: e.clientX, - y: e.clientY, - text: cellText(cell), - }); - }} - onPointerLeave={(e) => { - if (e.pointerType === "touch") return; - setTooltip((prev) => (prev?.pinned ? prev : null)); - }} - onPointerDown={(e) => { - if (e.pointerType !== "touch") return; - setTooltip({ - x: e.clientX, - y: e.clientY, - text: cellText(cell), - pinned: true, - }); - }} - /> - ); - })} -
- - {tooltip && ( -
- {tooltip.text} -
- )} -
- -
- - {t("medications.lastNDoses", { - total: summary.total, - taken: summary.taken, - })} - - {summary.doseStreak > 0 && ( - - - {t("medications.doseStreak", { count: summary.doseStreak })} - - )} -
-
- ); -} diff --git a/src/components/medications/card-parts/medication-compliance-bars.tsx b/src/components/medications/card-parts/medication-compliance-bars.tsx index 7fac27474..051b3e343 100644 --- a/src/components/medications/card-parts/medication-compliance-bars.tsx +++ b/src/components/medications/card-parts/medication-compliance-bars.tsx @@ -7,13 +7,28 @@ interface MedicationComplianceBarsProps { rate7: number; rate30: number; streak: number; + /** + * v1.8.6 — the span of the short row in days. The server scales the two + * windows to the dosing cadence (7 / 30 for dense meds, stepping up to + * 90 / 365 for sparse ones), so the labels follow the chosen windows + * instead of a hardcoded 7 / 30. Defaults to 7 / 30 so older callers and + * fixtures keep their prior labels. + */ + shortDays?: number; + /** v1.8.6 — the span of the long row in days. */ + longDays?: number; } /** - * Shared 7-day / 30-day compliance bars plus the day-streak flame for the + * Shared two-row compliance bars plus the day-streak flame for the * medication cards. Extracted from the generic and GLP-1 cards so the bars * are structurally identical rather than hand-synced. * + * v1.8.6 — the two windows scale with the dosing cadence. A daily med shows + * 7-day / 30-day; a weekly med 30-day / 90-day; a rare injection up to a + * 365-day long window. The labels are parametrised on the chosen day-counts + * so each row names the window it actually covers. + * * The streak flame uses the canonical `text-dracula-orange` warning/streak * token (globals.css). The generic card historically drifted onto Tailwind * stock `text-orange-400`; unifying here closes that token gap. @@ -22,38 +37,31 @@ export function MedicationComplianceBars({ rate7, rate30, streak, + shortDays = 7, + longDays = 30, }: MedicationComplianceBarsProps) { const { t } = useTranslations(); + const shortLabel = t("medications.complianceWindow", { days: shortDays }); + const longLabel = t("medications.complianceWindow", { days: longDays }); + return (
- - {t("medications.compliance7d")} - + {shortLabel} {rate7}%
{/* aria-label so the bar has an accessible name. */} - +
- - {t("medications.compliance30d")} - + {longLabel} {rate30}%
- +
diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index c1a28bd07..98b5117fc 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -10,10 +10,6 @@ import { MedicationCardMenu } from "@/components/medications/medication-card-men import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges"; import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill"; import { MedicationComplianceBars } from "@/components/medications/card-parts/medication-compliance-bars"; -import { - DoseAdherenceTimeline, - type DoseAdherenceCell, -} from "@/components/medications/card-parts/dose-adherence-timeline"; import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { @@ -104,21 +100,24 @@ export interface Glp1Medication { } interface ComplianceDisplay { - mode: "percent" | "timeline"; - expected7: number; - expected30: number; + shortDays: number; + longDays: number; + expectedShort: number; + expectedLong: number; minStableDoses: number; - doseTimeline: DoseAdherenceCell[]; - recentDoseSummary: { taken: number; total: number; doseStreak: number }; + short: { rate: number; streak: number }; + long: { rate: number }; } interface ComplianceData { compliance7: { rate: number; streak: number }; compliance30: { rate: number }; /** - * v1.8.5 — server-decided render mode. GLP-1 injections are commonly - * weekly, so this card flips to the per-dose timeline more often than - * the generic one. Additive — older mocks omit it. + * v1.8.6 — the two compliance windows scaled to the dosing cadence. GLP-1 + * injections are commonly weekly, so this card's windows step up to the + * 30-/90-day rung where the bars stay meaningful. Additive — older mocks + * omit it, in which case the card falls back to the static 7-/30-day + * fields. */ complianceDisplay?: ComplianceDisplay; } @@ -309,9 +308,15 @@ export function Glp1MedicationCard({ const schedule = medication.schedules[0] ?? null; const now = new Date(); const next = nextInjectionFromServer(medication.nextDueAt, now); - const rate7 = compliance?.compliance7?.rate ?? 0; - const rate30 = compliance?.compliance30?.rate ?? 0; - const streak = compliance?.compliance7?.streak ?? 0; + // v1.8.6 — the two compliance windows scale with the dosing cadence. When + // the server supplies `complianceDisplay` the card reads its cadence-scaled + // rows; otherwise it falls back to the static 7-/30-day fields. + const display = compliance?.complianceDisplay; + const shortDays = display?.shortDays ?? 7; + const longDays = display?.longDays ?? 30; + 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.4.37 W4b — symmetric take-now / overdue pill with the generic // card. Sort schedules by `windowStart` so the most-actionable @@ -480,24 +485,19 @@ export function Glp1MedicationCard({ Glp1InventoryDTO slot on /api/medications/[id]/glp1 stays in the response shape; only the web mounts are gone. */} - {/* Compliance region — shared decision with the generic card. - Dense cadences keep the bars; sparse cadences (most weekly - GLP-1 injections) swap to the per-dose uptime strip. Falls back - to the bars when the older payload omits `complianceDisplay`. */} - {medication.active && - compliance && - (compliance.complianceDisplay?.mode === "timeline" ? ( - - ) : ( - - ))} + {/* Compliance bars — always two rows, shared with the generic + card so the page grid stays harmonious. The server scales the + two windows to the dosing cadence; most weekly GLP-1 injections + land on the 30-/90-day rung where the bars stay meaningful. */} + {medication.active && compliance && ( + + )} {/* Primary actions row — shared with the generic medication card. The GLP-1-specific side-effect quick-log lives in the diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index fbb9247e9..d75735ca1 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -9,10 +9,6 @@ import { MedicationCardMenu } from "@/components/medications/medication-card-men import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges"; import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill"; import { MedicationComplianceBars } from "@/components/medications/card-parts/medication-compliance-bars"; -import { - DoseAdherenceTimeline, - type DoseAdherenceCell, -} from "@/components/medications/card-parts/dose-adherence-timeline"; import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions"; import { formatTimeWindowRange } from "@/lib/time-window-format"; import { formatDateTime, formatTime } from "@/lib/format"; @@ -76,12 +72,13 @@ interface Medication { } interface ComplianceDisplay { - mode: "percent" | "timeline"; - expected7: number; - expected30: number; + shortDays: number; + longDays: number; + expectedShort: number; + expectedLong: number; minStableDoses: number; - doseTimeline: DoseAdherenceCell[]; - recentDoseSummary: { taken: number; total: number; doseStreak: number }; + short: { rate: number; streak: number }; + long: { rate: number }; } interface ComplianceData { @@ -97,9 +94,11 @@ interface ComplianceData { rate: number; }; /** - * v1.8.5 — server-decided render mode for the compliance region. Dense - * cadences keep the 7-/30-day bars (`"percent"`); sparse cadences swap to - * the per-dose uptime strip (`"timeline"`). Additive — older mocks omit it. + * v1.8.6 — the two compliance windows scaled to the dosing cadence. The + * card always shows two percentage rows; `shortDays` / `longDays` name the + * windows and `short` / `long` carry their rates. Additive — older mocks + * omit it, in which case the card falls back to the static 7-/30-day + * `compliance7` / `compliance30` fields. */ complianceDisplay?: ComplianceDisplay; } @@ -222,9 +221,15 @@ export function MedicationCard({ } } - const rate7 = compliance?.compliance7?.rate ?? 0; - const rate30 = compliance?.compliance30?.rate ?? 0; - const streak = compliance?.compliance7?.streak ?? 0; + // v1.8.6 — the two compliance windows scale with the dosing cadence. When + // the server supplies `complianceDisplay` the card reads its cadence-scaled + // rows; otherwise it falls back to the static 7-/30-day fields. + const display = compliance?.complianceDisplay; + const shortDays = display?.shortDays ?? 7; + const longDays = display?.longDays ?? 30; + 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; const categoryLabel = getMedicationCategoryLabel(medication.category, t); const sortedSchedules = [...medication.schedules].sort( (a, b) => @@ -391,24 +396,19 @@ export function MedicationCard({

)} - {/* Compliance region — the server decides whether the percentage - bars stay (dense cadences) or the per-dose uptime strip takes - over (sparse cadences). Falls back to the bars when the older - payload omits `complianceDisplay`. */} - {medication.active && - compliance && - (compliance.complianceDisplay?.mode === "timeline" ? ( - - ) : ( - - ))} + {/* Compliance bars — always two rows. The server scales the two + windows to the dosing cadence (7 / 30 days for dense meds, + stepping up to 90 / 365 for sparse ones); the labels follow the + chosen windows. */} + {medication.active && compliance && ( + + )} {/* Quick actions — primary buttons of the medication card. */} {medication.active && ( diff --git a/src/lib/analytics/__tests__/compliance.test.ts b/src/lib/analytics/__tests__/compliance.test.ts index 6c9b91459..bb5afd5e3 100644 --- a/src/lib/analytics/__tests__/compliance.test.ts +++ b/src/lib/analytics/__tests__/compliance.test.ts @@ -1027,7 +1027,13 @@ describe("classifyIntakeTiming", () => { // `buildComplianceDisplay` decides whether the card keeps the 7-/30-day // percentage bars (dense cadences) or swaps to a per-dose timeline // (sparse cadences). The 30-day window is the density yardstick. -describe("buildComplianceDisplay — density rule + dose timeline", () => { +// v1.8.6 — the compliance display is always two percentage rows; only the +// window day-counts scale with the dosing cadence. `selectComplianceWindows` +// walks a `[7,30] → [30,90] → [90,365]` ladder and returns the densest rung +// whose BOTH windows clear `MIN_STABLE_DOSES` realised expected doses, so a +// daily med stays on 7 / 30, a weekly med steps up, and a rare injection +// lands on the widest rung. The timeline rendering is gone. +describe("buildComplianceDisplay — two rows, cadence-scaled windows", () => { const NOW = new Date("2025-06-15T12:00:00Z"); beforeEach(() => { @@ -1054,17 +1060,25 @@ describe("buildComplianceDisplay — density rule + dose timeline", () => { ); } - it("echoes the threshold so a client can re-derive the mode", () => { + it("echoes the density floor so a client can re-derive the rung", () => { const display = buildComplianceDisplay( [], - [{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null, rrule: "FREQ=DAILY", timesOfDay: ["08:00"] }], + [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + rrule: "FREQ=DAILY", + timesOfDay: ["08:00"], + }, + ], ctx(), { now: NOW }, ); expect(display.minStableDoses).toBe(MIN_STABLE_DOSES); }); - it("daily med → percent mode (30-day window clears the 4-dose floor)", () => { + it("daily med → 7 / 30-day windows (the densest rung clears the floor)", () => { const schedules: ComplianceSchedule[] = [ { windowStart: "08:00", @@ -1075,13 +1089,37 @@ describe("buildComplianceDisplay — density rule + dose timeline", () => { }, ]; const display = buildComplianceDisplay([], schedules, ctx(), { now: NOW }); - expect(display.mode).toBe("percent"); - expect(display.expected30).toBeGreaterThanOrEqual(MIN_STABLE_DOSES); + expect(display.shortDays).toBe(7); + expect(display.longDays).toBe(30); + // Both windows hold ≥ 4 expected doses. + expect(display.expectedShort).toBeGreaterThanOrEqual(MIN_STABLE_DOSES); + expect(display.expectedLong).toBeGreaterThanOrEqual(MIN_STABLE_DOSES); }); - it("35-day-interval med → timeline mode (sparse: <4 doses in 30 days)", () => { - // A monthly injection: one dose every 5 weeks. Anchored on a recent - // start so the engine emits at most one slot inside the 30-day window. + it("weekly med → steps up to 30 / 90-day windows", () => { + // Weekly Mondays-only: the 7-day window holds at most one Monday (< 4), + // so the ladder steps up. The 30-day window holds ~4 Mondays and the + // 90-day window ~13 — the [30, 90] rung is the densest that clears the + // floor on both rows. + const schedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + rrule: "FREQ=WEEKLY;BYDAY=MO", + timesOfDay: ["08:00"], + }, + ]; + const display = buildComplianceDisplay([], schedules, ctx(), { now: NOW }); + expect(display.shortDays).toBe(30); + expect(display.longDays).toBe(90); + expect(display.expectedShort).toBeGreaterThanOrEqual(MIN_STABLE_DOSES); + expect(display.expectedLong).toBeGreaterThanOrEqual(MIN_STABLE_DOSES); + }); + + it("35-day rolling injection → widest 90 / 365-day windows", () => { + // One dose every five weeks: the 30-day window never holds ≥ 4, so the + // ladder climbs to the top rung where a full year holds ~10 doses. const schedules: ComplianceSchedule[] = [ { windowStart: "10:00", @@ -1095,129 +1133,79 @@ describe("buildComplianceDisplay — density rule + dose timeline", () => { [], schedules, ctx({ - startsOn: new Date("2025-05-01T00:00:00Z"), - // Rolling cadences re-anchor on the last non-skipped intake. + startsOn: new Date("2024-06-01T00:00:00Z"), lastIntakeAt: new Date("2025-05-20T10:00:00Z"), }), { now: NOW }, ); - expect(display.mode).toBe("timeline"); - expect(display.expected30).toBeLessThan(MIN_STABLE_DOSES); + expect(display.shortDays).toBe(90); + expect(display.longDays).toBe(365); }); - it("weekly med → timeline mode (≥4 needs ~28 days; 30d holds ~4 but <4 when phased)", () => { - // Weekly Mondays-only: across a 30-day trailing window this yields ~4 - // Mondays. With a start mid-window the realised count drops below 4 → - // timeline. Pin the realised count rather than asserting a fixed mode - // independent of phase. + it("a brand-new sparse med still returns two rows on the widest rung", () => { + // Started two days ago: no rung clears the floor. The selection falls + // back to the widest rung so the card still shows two honest rows. const schedules: ComplianceSchedule[] = [ { windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null, - rrule: "FREQ=WEEKLY;BYDAY=MO", + rollingIntervalDays: 35, timesOfDay: ["08:00"], }, ]; const display = buildComplianceDisplay( [], schedules, - ctx({ startsOn: new Date("2025-05-26T00:00:00Z") }), + ctx({ startsOn: new Date("2025-06-13T00:00:00Z") }), { now: NOW }, ); - // 2025-05-26 .. 2025-06-15 → Mondays 5/26, 6/2, 6/9 → 3 expected < 4. - expect(display.expected30).toBeLessThan(MIN_STABLE_DOSES); - expect(display.mode).toBe("timeline"); + expect(display.shortDays).toBe(90); + expect(display.longDays).toBe(365); }); - it("builds taken / missed / skipped cells from the paired engine timeline", () => { - // Weekly Mondays, three past Mondays inside the window: one taken on - // time, one skipped, one missed (no intake). The strip mirrors that. + it("the row rates match calculateCompliance over the chosen windows", () => { + // Daily med, perfect adherence over the trailing two weeks → 100% both + // rows. Pins that the display rate equals the cadence-aware calculator. const schedules: ComplianceSchedule[] = [ { windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null, - rrule: "FREQ=WEEKLY;BYDAY=MO", + rrule: "FREQ=DAILY", timesOfDay: ["08:00"], }, ]; - const events = [ - // 2025-06-09 Monday — taken on time. - { - scheduledFor: new Date("2025-06-09T08:00:00Z"), - takenAt: new Date("2025-06-09T08:10:00Z"), - skipped: false, - }, - // 2025-06-02 Monday — skipped. - { - scheduledFor: new Date("2025-06-02T08:00:00Z"), - takenAt: null, - skipped: true, - }, - // 2025-05-26 Monday — no event → missed. - ]; - const display = buildComplianceDisplay( + 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 context = ctx(); + const display = buildComplianceDisplay(events, schedules, context, { + now: NOW, + }); + const short = calculateCompliance( events, schedules, - ctx({ startsOn: new Date("2025-05-26T00:00:00Z") }), - { now: NOW }, + display.shortDays, + context.createdAt, + { now: NOW, medicationContext: context }, ); - const past = display.doseTimeline.filter((c) => c.status !== "upcoming"); - const statuses = past.map((c) => c.status); - expect(statuses).toContain("taken"); - expect(statuses).toContain("skipped"); - expect(statuses).toContain("missed"); - - const takenCell = past.find((c) => c.status === "taken")!; - expect(takenCell.takenAt).not.toBeNull(); - expect(takenCell.timing).toBe("on_time"); - - const skippedCell = past.find((c) => c.status === "skipped")!; - expect(skippedCell.takenAt).toBeNull(); - expect(skippedCell.timing).toBeNull(); - - // Summary: taken counts in numerator + denominator; skipped excluded - // from both; missed in denominator only. - expect(display.recentDoseSummary.taken).toBe(1); - expect(display.recentDoseSummary.total).toBe(2); // taken + missed - }); - - it("dose-streak: counts consecutive taken-or-skipped back from the newest, breaks on a miss", () => { - // Weekly Mondays: 5/26 missed, 6/2 taken, 6/9 taken → newest-back - // streak = 2 (stops at the 5/26 miss). - const schedules: ComplianceSchedule[] = [ - { - windowStart: "08:00", - windowEnd: "09:00", - daysOfWeek: null, - rrule: "FREQ=WEEKLY;BYDAY=MO", - timesOfDay: ["08:00"], - }, - ]; - const events = [ - { - scheduledFor: new Date("2025-06-09T08:00:00Z"), - takenAt: new Date("2025-06-09T08:05:00Z"), - skipped: false, - }, - { - scheduledFor: new Date("2025-06-02T08:00:00Z"), - takenAt: new Date("2025-06-02T08:05:00Z"), - skipped: false, - }, - // 5/26 missed. - ]; - const display = buildComplianceDisplay( + const long = calculateCompliance( events, schedules, - ctx({ startsOn: new Date("2025-05-26T00:00:00Z") }), - { now: NOW }, + display.longDays, + context.createdAt, + { now: NOW, medicationContext: context }, ); - expect(display.recentDoseSummary.doseStreak).toBe(2); + expect(display.short.rate).toBe(short.rate); + expect(display.long.rate).toBe(long.rate); + expect(display.short.streak).toBe(short.streak); }); - it("caps the past strip at 12 cells for a dense daily med", () => { + it("never reports a timeline mode field — the display is two rows only", () => { const schedules: ComplianceSchedule[] = [ { windowStart: "08:00", @@ -1228,8 +1216,10 @@ describe("buildComplianceDisplay — density rule + dose timeline", () => { }, ]; const display = buildComplianceDisplay([], schedules, ctx(), { now: NOW }); - const past = display.doseTimeline.filter((c) => c.status !== "upcoming"); - expect(past.length).toBeLessThanOrEqual(12); + expect(display).not.toHaveProperty("mode"); + expect(display).not.toHaveProperty("doseTimeline"); + expect(display.short).toBeDefined(); + expect(display.long).toBeDefined(); }); }); diff --git a/src/lib/analytics/compliance.ts b/src/lib/analytics/compliance.ts index 8bcc7a20c..be57e63eb 100644 --- a/src/lib/analytics/compliance.ts +++ b/src/lib/analytics/compliance.ts @@ -23,7 +23,6 @@ import { buildCadenceTimeline, type CadenceEngineContext, type IntakeEventLike, - type PairedDose, type ScheduleLike, } from "@/lib/medications/scheduling/cadence"; import { @@ -33,7 +32,6 @@ import { type RecurrenceContext, type ScheduleType, } from "@/lib/medications/scheduling/recurrence"; -import type { InjectionSiteKey } from "@/lib/medications/injection-sites"; interface IntakeEvent { takenAt: Date | null; @@ -365,122 +363,142 @@ export function expectedSlotsBetween( } /** - * v1.8.5 — render mode for the per-medication compliance display. A - * window's percentage is only meaningful once the denominator is large - * enough that a single miss moves the rate by a small amount; below that - * the bar swings violently and tells the user nothing. So: - * - * - `"percent"`: keep the 7-/30-day percentage bars (dense cadences: - * daily, multi-daily, weekday meds). The window holds ≥ - * `MIN_STABLE_DOSES` expected doses. - * - `"timeline"`: swap to an uptime-style per-dose strip (sparse cadences: - * weekly, bi-/tri-weekly, rolling 35-day injections) where each cell is - * one expected dose, taken=green / missed=red / skipped=grey. - */ -export type ComplianceDisplayMode = "percent" | "timeline"; - -/** - * v1.8.5 — one cell of the dose-adherence timeline. A projection of the - * canonical engine's expected-dose slots paired with the recorded intake, - * oldest → newest. `site` is reserved for injection meds so the strip can - * dual-encode rotation; it is `null` until the per-dose injection-site - * write path lands. - */ -export interface DoseTimelineCell { - /** ISO instant of the expected dose slot. */ - scheduledFor: string; - status: "taken" | "missed" | "skipped" | "upcoming"; - /** ISO instant the dose was logged, or null when not taken. */ - takenAt: string | null; - /** Punctuality bucket for a taken dose; null when not taken. */ - timing: IntakeTimingClass | null; - /** Injection zone for injection meds; null otherwise / when unrecorded. */ - site: InjectionSiteKey | null; -} - -/** - * v1.8.5 — the additive compliance-display block returned alongside the - * existing `compliance7` / `compliance30` fields (which iOS + the Health - * Score still read verbatim). The server decides `mode` from the density - * rule so the client stays dumb. - */ -export interface ComplianceDisplay { - mode: ComplianceDisplayMode; - /** Realised expected dose count over the 7-day window. */ - expected7: number; - /** Realised expected dose count over the 30-day window. */ - expected30: number; - /** Echo of the density threshold so a client can re-derive the mode. */ - minStableDoses: number; - /** Uptime strip — last N expected dose slots, oldest → newest. */ - doseTimeline: DoseTimelineCell[]; - /** Compact summary for the "last N doses" line. */ - recentDoseSummary: { taken: number; total: number; doseStreak: number }; -} - -/** - * v1.8.5 — the floor at which a window's percentage is stable. Four + * v1.8.6 — the floor at which a window's percentage is stable. Four * expected doses is the point where a single miss moves the rate by ≤25% - * rather than ±50–100%. A daily med clears it in 4 days; a weekly med - * needs ~28 days; a tri-weekly / 35-day-rolling med never clears it in a - * 30-day window — which is exactly the signal to show the timeline. + * rather than ±50–100%. A daily med clears it in a 7-day window; a weekly + * med needs ~30 days; a tri-weekly / 35-day-rolling med needs a quarter or + * more. The window ladder below steps up until both rows clear this floor. */ export const MIN_STABLE_DOSES = 4; -/** Number of past dose cells the card strip carries. */ -const TIMELINE_PAST_DOSES = 12; -/** Number of upcoming dose cells appended so the strip shows what's next. */ -const TIMELINE_UPCOMING_DOSES = 2; +/** + * v1.8.6 — the rung ladder for the two compliance windows. Each rung is a + * `[short, long]` pair of day-counts. The card always shows two percentage + * rows; the only thing that scales with cadence is which rung the windows + * sit on. Dense meds (daily / weekday) sit on `[7, 30]`; as the expected + * dose frequency drops, both windows step up so each row still spans enough + * expected doses to mean something, up to a 12-month long window for very + * rare meds. + */ +export const COMPLIANCE_WINDOW_LADDER: ReadonlyArray = + [ + [7, 30], + [30, 90], + [90, 365], + ]; -/** A single paired dose, distilled into a timeline cell. */ -function toTimelineCell(dose: PairedDose): DoseTimelineCell { - const takenAt = dose.match?.takenAt ?? null; - return { - scheduledFor: dose.windowStart.toISOString(), - status: dose.status, - takenAt: takenAt ? takenAt.toISOString() : null, - // Punctuality is computed against the slot window the engine already - // emitted. `windowStart` / `windowEnd` are full instants here; reuse - // the HH:mm classifier by formatting them back to wall-clock on the - // slot's own day so an early / late dose reads correctly. - timing: - dose.status === "taken" && takenAt - ? classifyIntakeTiming( - takenAt, - hhmmOf(dose.windowStart), - hhmmOf(dose.windowEnd), - dose.windowStart, - ) - : null, - // Injection-site dual-encoding is reserved for the per-dose write - // path; the compliance read does not yet carry it. - site: null, - }; -} - -/** Format a Date's UTC wall-clock as "HH:mm" for the punctuality classifier. */ -function hhmmOf(d: Date): string { - const h = String(d.getUTCHours()).padStart(2, "0"); - const m = String(d.getUTCMinutes()).padStart(2, "0"); - return `${h}:${m}`; +/** + * v1.8.6 — the day-counts of the two compliance windows a medication's + * cadence resolves to, plus the realised expected-dose count each window + * holds. `shortDays` / `longDays` drive the row labels; the expected counts + * are surfaced so a client can show the denominator or re-derive the rung. + */ +export interface ComplianceWindowSelection { + shortDays: number; + longDays: number; + expectedShort: number; + expectedLong: number; } /** - * v1.8.5 — compute the additive {@link ComplianceDisplay} block. + * v1.8.6 — pick the two compliance windows for a medication from its + * dosing cadence. * - * The mode is decided purely by density: when the 30-day window holds at - * least {@link MIN_STABLE_DOSES} expected doses the percentage bars stay - * (`"percent"`); otherwise the per-dose timeline takes over (`"timeline"`). - * The 30-day window is the honest denominator — a weekly med clears the - * floor there, a sparse injection never does. + * Walks {@link COMPLIANCE_WINDOW_LADDER} from densest to sparsest and + * returns the first rung whose BOTH windows clear {@link MIN_STABLE_DOSES} + * realised expected doses. A daily med clears `[7, 30]` immediately; a + * weekly med fails the 7-day row (one dose) and lands on `[30, 90]`; a + * 35-day-rolling injection needs the top rung `[90, 365]`. When even the + * top rung can't clear the floor (a brand-new prescription, a very rare + * med) the top rung is returned anyway so the card still shows two honest + * percentage rows over the widest windows available. * - * The timeline itself is a projection of {@link buildCadenceTimeline}: the - * engine already pairs each expected slot to its closest intake, so we - * take the last {@link TIMELINE_PAST_DOSES} past slots plus a couple of - * upcoming ones and distil each into a cell. `doseStreak` counts - * consecutive taken-or-skipped doses back from the newest non-upcoming - * dose — dose-indexed, unlike the day-indexed `streak` on - * {@link ComplianceResult} which inflates on off-cadence days. + * The expected count routes through {@link expectedSlotsBetween} (the + * canonical recurrence engine), so PRN / off-cadence / pre-creation days + * never inflate the denominator. + */ +export function selectComplianceWindows( + schedules: ComplianceSchedule[], + ctx: ComplianceMedicationContext, + options?: { now?: Date }, +): ComplianceWindowSelection { + const now = options?.now ?? new Date(); + const DAY_MS = 24 * 60 * 60 * 1000; + + const expectedOver = (days: number): number => + expectedSlotsBetween( + schedules, + new Date(now.getTime() - days * DAY_MS), + now, + ctx, + ).length; + + // Memoise per distinct window so a shared rung boundary (e.g. 30 / 90) + // isn't re-walked across rungs. + const cache = new Map(); + const expected = (days: number): number => { + const hit = cache.get(days); + if (hit !== undefined) return hit; + const v = expectedOver(days); + cache.set(days, v); + return v; + }; + + for (const [shortDays, longDays] of COMPLIANCE_WINDOW_LADDER) { + const expectedShort = expected(shortDays); + const expectedLong = expected(longDays); + if ( + expectedShort >= MIN_STABLE_DOSES && + expectedLong >= MIN_STABLE_DOSES + ) { + return { shortDays, longDays, expectedShort, expectedLong }; + } + } + + // No rung cleared the floor — fall back to the widest rung so both rows + // still render over the most data the cadence affords. + const [shortDays, longDays] = + COMPLIANCE_WINDOW_LADDER[COMPLIANCE_WINDOW_LADDER.length - 1]; + return { + shortDays, + longDays, + expectedShort: expected(shortDays), + expectedLong: expected(longDays), + }; +} + +/** + * v1.8.6 — the compliance-display block returned alongside the existing + * `compliance7` / `compliance30` fields (which iOS + the Health Score read + * verbatim). The card always renders two percentage rows; the server picks + * the two windows from the medication's cadence and computes each row's + * rate over the chosen span. A dense med keeps `7` / `30`; a sparse med + * steps both windows up so each row covers enough expected doses to be + * meaningful. + */ +export interface ComplianceDisplay { + shortDays: number; + longDays: number; + /** Realised expected dose count over the short window. */ + expectedShort: number; + /** Realised expected dose count over the long window. */ + 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 }; +} + +/** + * v1.8.6 — compute the two-row {@link ComplianceDisplay} block. + * + * The card always shows two percentage rows. {@link selectComplianceWindows} + * decides which windows they span from the medication's cadence, then each + * row's rate is the cadence-aware {@link calculateCompliance} over that + * window. The short row also carries the day-streak. The compliance math is + * unchanged from the legacy 7-/30-day path — only the window day-counts move. */ export function buildComplianceDisplay( events: IntakeEvent[], @@ -489,108 +507,26 @@ export function buildComplianceDisplay( options?: { now?: Date }, ): ComplianceDisplay { const now = options?.now ?? new Date(); - const DAY_MS = 24 * 60 * 60 * 1000; + const { shortDays, longDays, expectedShort, expectedLong } = + selectComplianceWindows(schedules, ctx, { now }); - // Realised expected counts over each window. `expectedSlotsBetween` - // already excludes PRN / off-cadence days through the canonical engine. - const from7 = new Date(now.getTime() - 7 * DAY_MS); - const from30 = new Date(now.getTime() - 30 * DAY_MS); - const expected7 = expectedSlotsBetween(schedules, from7, now, ctx).length; - const expected30 = expectedSlotsBetween(schedules, from30, now, ctx).length; - - const mode: ComplianceDisplayMode = - expected30 >= MIN_STABLE_DOSES ? "percent" : "timeline"; - - // Build the per-dose strip from the same engine the cadence chart uses. - // Pull a generous past window (90 days) so even a 35-day-rolling med - // surfaces a few cells, then keep the last N plus a short look-ahead. - const engineCtx: CadenceEngineContext = { - startsOn: ctx.startsOn, - endsOn: ctx.endsOn, - oneShot: ctx.oneShot, - createdAt: ctx.createdAt, - lastIntakeAt: ctx.lastIntakeAt, - timeZone: ctx.timeZone, - }; - const normalisedSchedules: ScheduleLike[] = schedules.map((s, i) => ({ - id: `display-${i}`, - windowStart: s.windowStart, - windowEnd: s.windowEnd, - daysOfWeek: s.daysOfWeek ?? null, - rrule: s.rrule ?? null, - rollingIntervalDays: s.rollingIntervalDays ?? null, - timesOfDay: s.timesOfDay, - reminderGraceMinutes: s.reminderGraceMinutes ?? null, - scheduleType: s.scheduleType ?? null, - cyclicOnWeeks: s.cyclicOnWeeks ?? null, - cyclicOffWeeks: s.cyclicOffWeeks ?? null, - })); - const normalisedEvents: IntakeEventLike[] = events.map((e) => ({ - scheduledFor: e.scheduledFor, - takenAt: e.takenAt, - skipped: e.skipped, - })); - - // Past window for the strip — bounded so a daily med doesn't return - // hundreds of cells. `buildCadenceTimeline` anchors on `now - windowDays` - // and projects forward; a small look-ahead is added below. - const STRIP_PAST_DAYS = 90; - const past = buildCadenceTimeline( - normalisedSchedules, - normalisedEvents, + const short = calculateCompliance(events, schedules, shortDays, ctx.createdAt, { now, - STRIP_PAST_DAYS, - ctx.createdAt, - engineCtx.timeZone, - engineCtx, - ) - .filter((d) => d.status !== "upcoming") - .sort((a, b) => a.windowStart.getTime() - b.windowStart.getTime()); - - // Upcoming slots — expand a short forward window and keep the soonest. - const lookahead = new Date(now.getTime() + 60 * DAY_MS); - const upcoming = buildCadenceTimeline( - normalisedSchedules, - normalisedEvents, - lookahead, - 60, - ctx.createdAt, - engineCtx.timeZone, - engineCtx, - ) - .filter((d) => d.status === "upcoming" && d.windowStart.getTime() > now.getTime()) - .sort((a, b) => a.windowStart.getTime() - b.windowStart.getTime()) - .slice(0, TIMELINE_UPCOMING_DOSES); - - const recentPast = past.slice(-TIMELINE_PAST_DOSES); - const doseTimeline = [...recentPast, ...upcoming].map(toTimelineCell); - - // Summary over the past cells the strip shows. - const summaryTaken = recentPast.filter((d) => d.status === "taken").length; - const summaryTotal = recentPast.filter( - (d) => d.status === "taken" || d.status === "missed", - ).length; - - // Dose-streak: consecutive taken-or-skipped doses, newest → oldest, from - // the newest non-upcoming dose. A miss breaks it; a skip does not. - let doseStreak = 0; - for (let i = recentPast.length - 1; i >= 0; i--) { - const s = recentPast[i].status; - if (s === "missed") break; - if (s === "taken" || s === "skipped") doseStreak++; - } + medicationContext: ctx, + }); + const long = calculateCompliance(events, schedules, longDays, ctx.createdAt, { + now, + medicationContext: ctx, + }); return { - mode, - expected7, - expected30, + shortDays, + longDays, + expectedShort, + expectedLong, minStableDoses: MIN_STABLE_DOSES, - doseTimeline, - recentDoseSummary: { - taken: summaryTaken, - total: summaryTotal, - doseStreak, - }, + short: { rate: short.rate, streak: short.streak }, + long: { rate: long.rate }, }; }