From 1fcc5ba96bab52c5a069d4634ac763c5c827510d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 1 Jun 2026 19:51:08 +0200 Subject: [PATCH] feat(medications): adapt the compliance display to cadence density MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rolling 7-/30-day percentage is meaningful for daily and weekday medications but noise for sparse cadences: a med dosed every few weeks has at most one expected dose in a 7-day window, so the bar swings between 0 and 100 off a single sample. Decide the display server-side from expected-dose density. When the 30-day window holds at least four expected doses the percentage bars stay; below that the card swaps to an uptime-style per-dose timeline where each cell is one expected dose — taken, missed, skipped or upcoming — with a "last N doses" summary and a dose-indexed streak. The compliance route returns an additive complianceDisplay block (mode, expected7/30, the dose timeline, the summary) alongside the existing compliance7/30 fields, which the iOS client and the Health Score read unchanged. Read-path only; no schema change. - compliance.ts: add expectedSlotsBetween and buildComplianceDisplay over the canonical recurrence engine - dose-adherence-timeline: plain flex cells reusing the heatmap colour tokens and tap-to-pin tooltip - switch the standard and GLP-1 cards on the server-decided mode --- messages/de.json | 7 + messages/en.json | 7 + messages/es.json | 7 + messages/fr.json | 7 + messages/it.json | 7 + messages/pl.json | 7 + .../[id]/compliance/__tests__/route.test.ts | 95 ++++++ .../api/medications/[id]/compliance/route.ts | 26 +- .../card-parts/dose-adherence-timeline.tsx | 173 +++++++++++ .../medications/glp1-medication-card.tsx | 46 ++- .../medications/medication-card.tsx | 45 ++- .../analytics/__tests__/compliance.test.ts | 275 +++++++++++++++++ src/lib/analytics/compliance.ts | 284 ++++++++++++++++++ 13 files changed, 967 insertions(+), 19 deletions(-) create mode 100644 src/components/medications/card-parts/dose-adherence-timeline.tsx diff --git a/messages/de.json b/messages/de.json index c55754cd6..5c21154dd 100644 --- a/messages/de.json +++ b/messages/de.json @@ -547,6 +547,13 @@ "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 2a3a1109a..6af21ada1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -547,6 +547,13 @@ "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 646426611..59eed95f0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -547,6 +547,13 @@ "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 27f3d555d..029d00f7e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -547,6 +547,13 @@ "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 4b4417156..f28a7b9ac 100644 --- a/messages/it.json +++ b/messages/it.json @@ -547,6 +547,13 @@ "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 87e160988..8e8098fe5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -547,6 +547,13 @@ "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 935f1b1f7..a281f05e5 100644 --- a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts @@ -115,6 +115,13 @@ async function callRoute(): Promise> { return body.data.dailyCompliance as Record; } +async function callRouteData(): Promise> { + const res = await GET(new Request("http://localhost"), ROUTE_PARAMS); + expect(res.status).toBe(200); + const body = await res.json(); + return body.data as Record; +} + describe("GET /api/medications/[id]/compliance — per-slot timing", () => { it("classifies both doses of a single-row twice-daily schedule as on_time (green cell)", async () => { const { start: dayStart } = dayBoundsForOffset(DAY_OFFSET); @@ -239,3 +246,91 @@ describe("GET /api/medications/[id]/compliance — per-slot timing", () => { expect(entry!.onTime).toBe(1); }); }); + +// 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. +describe("GET /api/medications/[id]/compliance — complianceDisplay", () => { + it("daily med → mode: percent", async () => { + vi.mocked(prisma.medication.findUnique).mockResolvedValue( + medication([ + { + id: "sched-1", + windowStart: "08:00", + windowEnd: "09:00", + timesOfDay: ["08:00"], + daysOfWeek: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + reminderGraceMinutes: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }, + ]) as never, + ); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + + const data = await callRouteData(); + const display = data.complianceDisplay as { + mode: string; + expected30: number; + minStableDoses: number; + }; + expect(display.mode).toBe("percent"); + expect(display.expected30).toBeGreaterThanOrEqual(display.minStableDoses); + // The legacy fields stay on the wire. + expect(data.compliance7).toBeDefined(); + expect(data.compliance30).toBeDefined(); + }); + + it("35-day-interval med → mode: timeline", 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), + endsOn: null, + oneShot: false, + schedules: [ + { + id: "sched-1", + windowStart: "10:00", + windowEnd: "11:00", + timesOfDay: ["10:00"], + daysOfWeek: null, + rrule: null, + rollingIntervalDays: 35, + reminderGraceMinutes: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }, + ], + } 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), + skipped: false, + scheduledFor: new Date(Date.now() - 25 * 24 * 60 * 60 * 1000), + }, + ] as never); + + const data = await callRouteData(); + const display = data.complianceDisplay as { + mode: string; + expected30: number; + minStableDoses: number; + }; + expect(display.mode).toBe("timeline"); + expect(display.expected30).toBeLessThan(display.minStableDoses); + }); +}); diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index b46d41a35..7dce9c68c 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -3,6 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess, apiError } from "@/lib/api-response"; import { + buildComplianceDisplay, buildComplianceMedicationContext, calculateCompliance, classifyIntakeTiming, @@ -141,6 +142,18 @@ 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. + const complianceDisplay = buildComplianceDisplay( + mapped, + medication.schedules, + medicationContext, + ); + // Build daily compliance map for heatmap/line chart (90 days) const now = new Date(); const dailyCompliance: Record = {}; @@ -273,9 +286,18 @@ export const GET = apiHandler( entity_type: "medication", entity_id: id, }, - meta: { compliance7: compliance7.rate, compliance30: compliance30.rate }, + meta: { + compliance7: compliance7.rate, + compliance30: compliance30.rate, + displayMode: complianceDisplay.mode, + }, }); - return apiSuccess({ compliance7, compliance30, dailyCompliance }); + return apiSuccess({ + compliance7, + compliance30, + dailyCompliance, + complianceDisplay, + }); }, ); diff --git a/src/components/medications/card-parts/dose-adherence-timeline.tsx b/src/components/medications/card-parts/dose-adherence-timeline.tsx new file mode 100644 index 000000000..fc305b54b --- /dev/null +++ b/src/components/medications/card-parts/dose-adherence-timeline.tsx @@ -0,0 +1,173 @@ +"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/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index 0b8956ac0..f9db3485a 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -10,6 +10,10 @@ 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 { @@ -91,9 +95,24 @@ export interface Glp1Medication { schedules: ScheduleLite[]; } +interface ComplianceDisplay { + mode: "percent" | "timeline"; + expected7: number; + expected30: number; + minStableDoses: number; + doseTimeline: DoseAdherenceCell[]; + recentDoseSummary: { taken: number; total: number; doseStreak: 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. + */ + complianceDisplay?: ComplianceDisplay; } interface DetailsResponse { @@ -420,15 +439,24 @@ export function Glp1MedicationCard({ Glp1InventoryDTO slot on /api/medications/[id]/glp1 stays in the response shape; only the web mounts are gone. */} - {/* Compliance bars — shared with the generic card so the page - grid stays harmonious. */} - {medication.active && compliance && ( - - )} + {/* 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" ? ( + + ) : ( + + ))} {/* 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 47ebb329b..7d619eb22 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -9,6 +9,10 @@ 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"; @@ -62,6 +66,15 @@ interface Medication { schedules: Schedule[]; } +interface ComplianceDisplay { + mode: "percent" | "timeline"; + expected7: number; + expected30: number; + minStableDoses: number; + doseTimeline: DoseAdherenceCell[]; + recentDoseSummary: { taken: number; total: number; doseStreak: number }; +} + interface ComplianceData { compliance7: { totalExpected: number; @@ -74,6 +87,12 @@ interface ComplianceData { compliance30: { 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. + */ + complianceDisplay?: ComplianceDisplay; } interface MedicationCardProps { @@ -327,14 +346,24 @@ export function MedicationCard({

)} - {/* Compliance bar */} - {medication.active && compliance && ( - - )} + {/* 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" ? ( + + ) : ( + + ))} {/* 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 9843df47a..6c9b91459 100644 --- a/src/lib/analytics/__tests__/compliance.test.ts +++ b/src/lib/analytics/__tests__/compliance.test.ts @@ -14,10 +14,13 @@ */ import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; import { + MIN_STABLE_DOSES, + buildComplianceDisplay, buildComplianceMedicationContext, calculateCompliance, classifyIntakeTiming, expectedSlotCountForDay, + expectedSlotsBetween, lastNonSkippedTakenAt, type ComplianceMedicationContext, type ComplianceSchedule, @@ -1019,3 +1022,275 @@ describe("classifyIntakeTiming", () => { ); }); }); + +// v1.8.5 — the cadence-density rule + the per-dose uptime strip. +// `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", () => { + const NOW = new Date("2025-06-15T12:00:00Z"); + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + function ctx( + overrides: Partial = {}, + ): ComplianceMedicationContext { + return buildComplianceMedicationContext( + { + startsOn: overrides.startsOn ?? null, + endsOn: overrides.endsOn ?? null, + oneShot: overrides.oneShot ?? false, + // Created well before the windows so no day is excluded. + createdAt: overrides.createdAt ?? new Date("2025-01-01T00:00:00Z"), + }, + overrides.lastIntakeAt ?? null, + overrides.timeZone ?? "UTC", + ); + } + + it("echoes the threshold so a client can re-derive the mode", () => { + const display = buildComplianceDisplay( + [], + [{ 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)", () => { + const schedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + rrule: "FREQ=DAILY", + timesOfDay: ["08:00"], + }, + ]; + const display = buildComplianceDisplay([], schedules, ctx(), { now: NOW }); + expect(display.mode).toBe("percent"); + expect(display.expected30).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. + const schedules: ComplianceSchedule[] = [ + { + windowStart: "10:00", + windowEnd: "11:00", + daysOfWeek: null, + rollingIntervalDays: 35, + timesOfDay: ["10:00"], + }, + ]; + const display = buildComplianceDisplay( + [], + schedules, + ctx({ + startsOn: new Date("2025-05-01T00:00:00Z"), + // Rolling cadences re-anchor on the last non-skipped intake. + lastIntakeAt: new Date("2025-05-20T10:00:00Z"), + }), + { now: NOW }, + ); + expect(display.mode).toBe("timeline"); + expect(display.expected30).toBeLessThan(MIN_STABLE_DOSES); + }); + + 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. + const schedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + rrule: "FREQ=WEEKLY;BYDAY=MO", + timesOfDay: ["08:00"], + }, + ]; + const display = buildComplianceDisplay( + [], + schedules, + ctx({ startsOn: new Date("2025-05-26T00: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"); + }); + + 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. + const schedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + rrule: "FREQ=WEEKLY;BYDAY=MO", + 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( + events, + schedules, + ctx({ startsOn: new Date("2025-05-26T00:00:00Z") }), + { now: NOW }, + ); + 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( + events, + schedules, + ctx({ startsOn: new Date("2025-05-26T00:00:00Z") }), + { now: NOW }, + ); + expect(display.recentDoseSummary.doseStreak).toBe(2); + }); + + it("caps the past strip at 12 cells for a dense daily med", () => { + const schedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + rrule: "FREQ=DAILY", + timesOfDay: ["08:00"], + }, + ]; + const display = buildComplianceDisplay([], schedules, ctx(), { now: NOW }); + const past = display.doseTimeline.filter((c) => c.status !== "upcoming"); + expect(past.length).toBeLessThanOrEqual(12); + }); +}); + +describe("expectedSlotsBetween", () => { + function ctx( + overrides: Partial = {}, + ): ComplianceMedicationContext { + return buildComplianceMedicationContext( + { + startsOn: overrides.startsOn ?? null, + endsOn: overrides.endsOn ?? null, + oneShot: overrides.oneShot ?? false, + createdAt: overrides.createdAt ?? new Date("2025-01-01T00:00:00Z"), + }, + overrides.lastIntakeAt ?? null, + overrides.timeZone ?? "UTC", + ); + } + + it("returns the occurrences themselves, ascending by instant", () => { + const schedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + rrule: "FREQ=DAILY", + timesOfDay: ["08:00", "20:00"], + }, + ]; + const slots = expectedSlotsBetween( + schedules, + new Date("2025-06-09T00:00:00Z"), + new Date("2025-06-10T23:59:59Z"), + ctx(), + ); + // Two days × two times = four slots, ascending. + expect(slots.length).toBe(4); + for (let i = 1; i < slots.length; i++) { + expect(slots[i].at.getTime()).toBeGreaterThanOrEqual( + slots[i - 1].at.getTime(), + ); + } + }); + + it("excludes PRN schedules from the expected slots", () => { + const schedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + scheduleType: "PRN", + rrule: "FREQ=DAILY", + timesOfDay: ["08:00"], + }, + ]; + const slots = expectedSlotsBetween( + schedules, + new Date("2025-06-09T00:00:00Z"), + new Date("2025-06-12T00:00:00Z"), + ctx(), + ); + expect(slots.length).toBe(0); + }); +}); diff --git a/src/lib/analytics/compliance.ts b/src/lib/analytics/compliance.ts index c2a34f004..072089900 100644 --- a/src/lib/analytics/compliance.ts +++ b/src/lib/analytics/compliance.ts @@ -23,12 +23,15 @@ import { buildCadenceTimeline, type CadenceEngineContext, type IntakeEventLike, + type PairedDose, type ScheduleLike, } from "@/lib/medications/scheduling/cadence"; import { occurrencesBetween, + type Occurrence, type ScheduleType, } from "@/lib/medications/scheduling/recurrence"; +import type { InjectionSiteKey } from "@/lib/medications/injection-sites"; interface IntakeEvent { takenAt: Date | null; @@ -302,6 +305,287 @@ export function expectedSlotCountForDay( return count; } +/** + * v1.8.5 — the sibling of {@link expectedSlotCountForDay} that returns the + * expected-dose occurrences *themselves* (ascending by instant) over an + * arbitrary window, rather than just the per-day count. Same loop, same + * canonical-engine delegation; we keep the slots so the dose-adherence + * timeline can pair each expected slot to its intake. + * + * Used by {@link buildComplianceDisplay} to decide the card's render mode + * (percent bars vs an uptime-style per-dose strip) and to build the strip. + * `occurrencesBetween` is inclusive of both ends; the caller passes a + * `[from, to]` window and we sort the union of every schedule's slots so + * a multi-schedule medication interleaves its slots in time order. + */ +export function expectedSlotsBetween( + schedules: ComplianceSchedule[], + from: Date, + to: Date, + ctx: ComplianceMedicationContext, +): Occurrence[] { + const recurrenceCtx = { + medication: { + id: "compliance-slots", + startsOn: ctx.startsOn, + endsOn: ctx.endsOn, + oneShot: ctx.oneShot, + createdAt: ctx.createdAt, + }, + timeZone: ctx.timeZone, + lastIntakeAt: ctx.lastIntakeAt, + }; + const all: Occurrence[] = []; + for (let i = 0; i < schedules.length; i++) { + const s = schedules[i]; + const canonical = { + id: `compliance-slots-${i}`, + rrule: s.rrule ?? null, + rollingIntervalDays: s.rollingIntervalDays ?? null, + timesOfDay: s.timesOfDay ?? [], + daysOfWeek: s.daysOfWeek ?? null, + windowStart: s.windowStart, + windowEnd: s.windowEnd, + reminderGraceMinutes: s.reminderGraceMinutes ?? null, + scheduleType: s.scheduleType ?? ("SCHEDULED" as const), + cyclicOnWeeks: s.cyclicOnWeeks ?? null, + cyclicOffWeeks: s.cyclicOffWeeks ?? null, + }; + all.push(...occurrencesBetween(canonical, from, to, recurrenceCtx)); + } + return all.sort((a, b) => a.at.getTime() - b.at.getTime()); +} + +/** + * 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 + * 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. + */ +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; + +/** 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.5 — compute the additive {@link ComplianceDisplay} block. + * + * 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. + * + * 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. + */ +export function buildComplianceDisplay( + events: IntakeEvent[], + schedules: ComplianceSchedule[], + ctx: ComplianceMedicationContext, + options?: { now?: Date }, +): ComplianceDisplay { + const now = options?.now ?? new Date(); + const DAY_MS = 24 * 60 * 60 * 1000; + + // 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, + 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++; + } + + return { + mode, + expected7, + expected30, + minStableDoses: MIN_STABLE_DOSES, + doseTimeline, + recentDoseSummary: { + taken: summaryTaken, + total: summaryTotal, + doseStreak, + }, + }; +} + /** * Calculate compliance for a medication over a given period. *