From d978c2b66252102cc184b45e08566f6e85bc7a3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 01:39:22 +0200 Subject: [PATCH] refactor(compliance): split the god-module into focused units behind a barrel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The medication-compliance engine lived in one 2,099-line file with 38 exports feeding eight surfaces (Coach prompt, BP-status gate, dashboard pillar, per-medication tile, insight targets, doctor report, …). A single edit intended for one surface could silently shift all of them. Split along the existing internal seams into `analytics/compliance/`: parsing, types, adapters, dose-status, occurrences, windows, cycle, ledger, display. `analytics/compliance.ts` is now a thin barrel that re-exports every public symbol, so no consumer import path changes. Pure move — no logic edits; the tally, window ladder, ledger and cycle math are byte-for-byte the code they were. Tests move alongside under `compliance/__tests__/` and keep exercising the public barrel. --- src/lib/analytics/compliance.ts | 2097 +---------------- .../as-needed-compliance-exclusion.test.ts | 0 .../__tests__/compliance-bundle.test.ts | 2 +- .../__tests__/compliance-pause-era.test.ts | 2 +- .../__tests__/compliance.test.ts | 4 +- .../schedule-compliance-select.test.ts | 2 +- src/lib/analytics/compliance/adapters.ts | 131 + src/lib/analytics/compliance/cycle.ts | 199 ++ src/lib/analytics/compliance/display.ts | 727 ++++++ src/lib/analytics/compliance/dose-status.ts | 153 ++ src/lib/analytics/compliance/ledger.ts | 379 +++ src/lib/analytics/compliance/occurrences.ts | 127 + src/lib/analytics/compliance/parsing.ts | 96 + src/lib/analytics/compliance/types.ts | 252 ++ src/lib/analytics/compliance/windows.ts | 113 + 15 files changed, 2205 insertions(+), 2079 deletions(-) rename src/lib/analytics/{ => compliance}/__tests__/as-needed-compliance-exclusion.test.ts (100%) rename src/lib/analytics/{ => compliance}/__tests__/compliance-bundle.test.ts (99%) rename src/lib/analytics/{ => compliance}/__tests__/compliance-pause-era.test.ts (99%) rename src/lib/analytics/{ => compliance}/__tests__/compliance.test.ts (99%) rename src/lib/analytics/{ => compliance}/__tests__/schedule-compliance-select.test.ts (94%) create mode 100644 src/lib/analytics/compliance/adapters.ts create mode 100644 src/lib/analytics/compliance/cycle.ts create mode 100644 src/lib/analytics/compliance/display.ts create mode 100644 src/lib/analytics/compliance/dose-status.ts create mode 100644 src/lib/analytics/compliance/ledger.ts create mode 100644 src/lib/analytics/compliance/occurrences.ts create mode 100644 src/lib/analytics/compliance/parsing.ts create mode 100644 src/lib/analytics/compliance/types.ts create mode 100644 src/lib/analytics/compliance/windows.ts diff --git a/src/lib/analytics/compliance.ts b/src/lib/analytics/compliance.ts index 28f2a12e2..c684440ce 100644 --- a/src/lib/analytics/compliance.ts +++ b/src/lib/analytics/compliance.ts @@ -17,2083 +17,32 @@ * `classifyIntakeTiming` is unchanged and still owns the early / * on_time / late / very_late punctuality bucket logic used by the * daily compliance heatmap on `/api/medications/[id]/compliance`. - */ - -import type { SlotBand } from "@/lib/medications/scheduling/attribution"; -import { - type BandMinterMedication, - type DoseWindowConfig, -} from "@/lib/medications/scheduling/band-minter"; -import { - buildBandsForSchedulesWithEras, - occurrencesAcrossEras, - type ScheduleRevisionLike, -} from "@/lib/medications/scheduling/schedule-eras"; -import { - buildCadenceTimeline, - type CadenceEngineContext, - type IntakeEventLike, - type ScheduleLike, -} from "@/lib/medications/scheduling/cadence"; -import { streaksFromTimeline } from "@/lib/medications/scheduling/compliance"; -import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults"; -import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers"; -import { - reconstructDoseHistory, - type DoseHistoryRow, - type HistoryIntake, -} from "@/lib/medications/scheduling/dose-history"; -import { - expandRollingRetrospective, - nextOccurrenceAfter, - occurrencesBetween, - type CanonicalSchedule, - type Occurrence, - type RecurrenceContext, - type ScheduleType, -} from "@/lib/medications/scheduling/recurrence"; -import { userDayKey } from "@/lib/tz/resolver"; -import { localHmAsUtc } from "@/lib/tz/local-day"; - -export interface IntakeEvent { - takenAt: Date | null; - skipped: boolean; - scheduledFor: Date; - /** - * v1.15.9 — true when the auto-miss cron marked this never-acted dose as - * a forgotten miss. Threaded into the cadence timeline so it counts as a - * `missed` slot (against the rate) instead of being neutralised. Optional - * so pre-v1.15.9 callers / fixtures default it to a normal pending/taken - * row. - */ - autoMissed?: boolean; - /** - * v1.15.20 — slot-binding provenance. `USER_PIN` = the user deliberately - * pinned this off-window take onto its `scheduledFor` slot; the unified - * ledger then binds it by anchor (taken-late, never on-time-washed) - * instead of degrading it to ad-hoc when the takenAt sits outside the - * band tail. Optional so legacy callers / fixtures default to AUTO. - */ - attributionSource?: "AUTO" | "USER_PIN"; -} - -export type IntakeTimingClass = - "early" | "on_time" | "late" | "very_late" | "missed"; - -export interface ComplianceResult { - totalExpected: number; - taken: number; - skipped: number; - missed: number; - rate: number; // 0-100 - streak: number; // consecutive days with all taken -} - -/** - * Daily compliance data including timing breakdown. * - * `early` is the v1.4.34 bucket for doses taken before the window's - * 3-hour grace start. Consumers that group by compliance status - * should treat `early` as compliant (alongside `onTime`). - */ -export interface DailyComplianceEntry { - expected: number; - /** - * v1.7.0 item 5 — the true engine-computed due-slot count for the day. - * Equals `expected`; carried as an explicit additive field iOS keys - * off so it doesn't have to infer "due-ness" from `expected`. - */ - expectedCount: number; - /** - * v1.7.0 item 5 — `expectedCount > 0`. iOS renders a "missed" mark - * only when `due === true`, so off-weeks / non-matching weekdays / PRN - * days no longer paint a false miss. - */ - due: boolean; - taken: number; - skipped: number; - onTime: number; - late: number; - veryLate: number; - early?: number; -} - -/** - * Parse "HH:mm" into hours and minutes. - */ -function parseHHmm(time: string): { hours: number; minutes: number } { - const [h, m] = time.split(":").map(Number); - return { hours: h, minutes: m }; -} - -/** - * Build the UTC instant of local "HH:mm" on the calendar day `day` falls on. + * --- * - * When `tz` is supplied the window `HH:mm` is interpreted in the user's zone - * (via {@link localHmAsUtc}) on the local day of `day` — so an "08:00" slot is - * 08:00 LOCAL, not 08:00 UTC. Without `tz` it keeps the legacy UTC anchoring - * (`setUTCHours`), which is only correct for a UTC user; a caller far from UTC - * must pass its zone or the punctuality band is offset by the zone's offset. - */ -function toDateOnDay(time: string, day: Date, tz?: string): Date { - const { hours, minutes } = parseHHmm(time); - if (tz) { - return localHmAsUtc(day, tz, hours, minutes); - } - const d = new Date(day); - d.setUTCHours(hours, minutes, 0, 0); - return d; -} - -/** - * Classify how punctual an intake was relative to a schedule window. + * This file is a thin barrel. The implementation lives in focused modules + * under `./compliance/` — split along the original internal seams so the + * most-changed business path is editable one concern at a time: * - * v1.4.34 IW-C — widened pre-window grace from 1h to 3h and introduced - * a dedicated `early` bucket so a proactive logger (e.g. 10 min before - * the window) no longer gets flushed into `very_late`. The post-window - * grace likewise grew to 3h so a "20 minutes late" dose stays `on_time`. - * Only doses beyond a 3-hour late tolerance fall into `late`, and only - * doses beyond a further `lateMinutes` tail fall into `very_late`. + * - `compliance/parsing.ts` HH:mm parsing + punctuality classification + * - `compliance/types.ts` shared shapes, selects, context builders + * - `compliance/adapters.ts` canonical-engine adapters (internal) + * - `compliance/dose-status.ts` per-dose window model + cadence family + * - `compliance/occurrences.ts` expected-slot expansion + counting + * - `compliance/windows.ts` the compliance-window ladder + selection + * - `compliance/cycle.ts` open-cycle (current dose) descriptor + * - `compliance/ledger.ts` dose-history ledger tally + per-day rates + * - `compliance/display.ts` two-row display, calculateCompliance, bundle * - * Buckets: - * - "early": within (windowStart - 3h) .. windowStart (compliant) - * - "on_time": windowStart .. (windowEnd + 3h) (compliant) - * - "late": (windowEnd + 3h) .. (windowEnd + 3h + lateMinutes) (default 2h tail) - * - "very_late": before windowStart - 3h, or after the late tail - * - "missed": takenAt is null - * - * Handles overnight windows (windowEnd < windowStart means next day). - * - * @param options.lateMinutes Width of the `late` band beyond the 3-hour - * on-time tolerance, in minutes. Defaults to 120. Doses past this - * tail land in `very_late`. - * @param options.tz IANA zone the window `HH:mm` is interpreted in. Supply - * the user's zone so an "08:00" slot means 08:00 LOCAL; omitting it keeps - * the legacy UTC interpretation (correct only for a UTC user). + * Every symbol the eight compliance call sites import keeps resolving from + * `@/lib/analytics/compliance` — no consumer import path changed. */ -export function classifyIntakeTiming( - takenAt: Date | null, - windowStart: string, // "HH:mm" - windowEnd: string, // "HH:mm" - scheduledDate: Date, // the date this was scheduled - options?: { lateMinutes?: number; tz?: string }, -): IntakeTimingClass { - if (takenAt === null) return "missed"; - const start = toDateOnDay(windowStart, scheduledDate, options?.tz); - let end = toDateOnDay(windowEnd, scheduledDate, options?.tz); - - // Handle overnight windows (e.g. windowStart="23:00", windowEnd="01:00") - if (end <= start) { - end = new Date(end.getTime() + 24 * 60 * 60 * 1000); - } - - const HOUR_MS = 60 * 60 * 1000; - // 3h pre-window grace: doses inside this window are `early` (still - // compliant). Doses before it are `very_late`. - const earlyStart = new Date(start.getTime() - 3 * HOUR_MS); - // 3h post-window grace: doses up to here are still `on_time`. - const onTimeEnd = new Date(end.getTime() + 3 * HOUR_MS); - // Configurable `late` tail past the on-time grace (default 120 min). - const lateTolerance = (options?.lateMinutes ?? 120) * 60 * 1000; - const lateEnd = new Date(onTimeEnd.getTime() + lateTolerance); - - const t = takenAt.getTime(); - - if (t < earlyStart.getTime()) return "very_late"; - if (t < start.getTime()) return "early"; - if (t <= onTimeEnd.getTime()) return "on_time"; - if (t <= lateEnd.getTime()) return "late"; - return "very_late"; -} - -/** - * v1.15.9 — cadence-aware per-dose grace/miss window model. - * - * A medication's dose either is on-time, is still takeable but late - * (counts when taken), or has slipped past the point where a self-hoster - * could plausibly still take it (a MISS). The cutoffs differ by cadence: - * an intraday/daily dose has tight hour-scale windows; a weekly/rolling - * injectable (the GLP-1 case) follows the clinical "take it within 4 days, - * otherwise skip to the next scheduled dose" rule. - * - * The defaults below are centralised named constants so a future revision - * can expose them per-medication without re-deriving the boundaries. Make - * it correct first; configurability later. - * - * DAILY / INTRADAY (minute-scale windows around the target instant): - * - on-time: target − 60 min … target + 60 min - * - overdue: target + 60 min … target + 240 min (still takeable) - * - missed: > target + 240 min, OR the next dose's on-time window - * starts first (whichever is sooner — adjacent doses never - * overlap). - * - * WEEKLY / ROLLING injectable (day-scale windows; the 4-day clinical rule): - * - on-time: target day ± 1 day - * - overdue: up to target + 4 days (late-but-counts) - * - missed: > target + 4 days - */ -export { DOSE_WINDOW_DEFAULTS }; - -/** - * v1.15.9 — the derived per-dose state the medication card renders. - * - * - `on_time_window` — due now, inside the on-time window (green). - * - `overdue` — past on-time, before the miss cutoff (still - * takeable; the card escalates the tint as it nears - * the cutoff — "stark überfällig"). - * - `missed` — past the miss cutoff, never acted on. - * - `taken_on_time` — taken inside the on-time window. - * - `taken_late` — taken in the overdue window (still counts). - * - `skipped` — a deliberate user skip (excluded from the rate). - * - `upcoming` — the on-time window has not opened yet. - * - * PRN doses are never scheduled and so never produce a status — callers - * exclude them before calling {@link deriveDoseStatus}. - */ -export type DoseStatus = - | "upcoming" - | "on_time_window" - | "overdue" - | "missed" - | "taken_on_time" - | "taken_late" - | "skipped"; - -/** Cadence family the window math keys off. */ -export type DoseCadenceFamily = "daily" | "weekly"; - -const HOUR_MS = 60 * 60 * 1000; -const ONE_DAY_MS = 24 * HOUR_MS; - -/** - * v1.15.9 — derive a single dose's {@link DoseStatus} from its target - * instant, the cadence family, `now`, and (optionally) when it was taken / - * whether the user skipped it. Pure and deterministic. - * - * `nextDoseAt` (when supplied) caps a daily dose's miss cutoff at the next - * dose's on-time window start, so two adjacent intraday doses never claim - * overlapping overdue windows — the earlier dose flips to `missed` the - * moment the later one comes due if that is sooner than its own cutoff. - * - * The defaults come from {@link DOSE_WINDOW_DEFAULTS}; callers may override - * per-medication once that configurability lands. - */ -export function deriveDoseStatus( - targetAt: Date, - cadence: DoseCadenceFamily, - now: Date, - options?: { - takenAt?: Date | null; - skipped?: boolean; - nextDoseAt?: Date | null; - windows?: Partial; - }, -): DoseStatus { - if (options?.skipped) return "skipped"; - - const w = { ...DOSE_WINDOW_DEFAULTS, ...(options?.windows ?? {}) }; - const target = targetAt.getTime(); - - let onTimeStart: number; - let onTimeEnd: number; - let overdueEnd: number; - if (cadence === "weekly") { - onTimeStart = target - w.weeklyOnTimeDays * ONE_DAY_MS; - onTimeEnd = target + w.weeklyOnTimeDays * ONE_DAY_MS; - overdueEnd = target + w.weeklyOverdueDays * ONE_DAY_MS; - } else { - onTimeStart = target - w.dailyOnTimeMinutes * 60_000; - onTimeEnd = target + w.dailyOnTimeMinutes * 60_000; - overdueEnd = onTimeEnd + w.dailyOverdueMinutes * 60_000; - } - - // Never let the miss cutoff bleed into the next dose's on-time window. - if (options?.nextDoseAt) { - const nextOnTimeStart = - cadence === "weekly" - ? options.nextDoseAt.getTime() - w.weeklyOnTimeDays * ONE_DAY_MS - : options.nextDoseAt.getTime() - w.dailyOnTimeMinutes * 60_000; - if (nextOnTimeStart < overdueEnd) overdueEnd = nextOnTimeStart; - } - - const takenAt = options?.takenAt ?? null; - if (takenAt) { - const t = takenAt.getTime(); - return t <= onTimeEnd ? "taken_on_time" : "taken_late"; - } - - const n = now.getTime(); - if (n < onTimeStart) return "upcoming"; - if (n <= onTimeEnd) return "on_time_window"; - if (n <= overdueEnd) return "overdue"; - return "missed"; -} - -/** - * v1.15.9 — classify a schedule's cadence into the {@link DoseCadenceFamily} - * the window model uses. A rolling cadence, or any RRULE / legacy weekly - * cadence that emits less often than daily, is `weekly` (day-scale windows + - * the 4-day rule); everything denser (daily, intraday multi-dose) is - * `daily` (minute-scale windows). - */ -export function doseCadenceFamily( - schedule: ComplianceSchedule, -): DoseCadenceFamily { - if ( - schedule.rollingIntervalDays != null && - schedule.rollingIntervalDays >= 2 - ) { - return "weekly"; - } - const rrule = schedule.rrule ?? ""; - if (/FREQ=(WEEKLY|MONTHLY|YEARLY)/i.test(rrule)) return "weekly"; - // A legacy daysOfWeek restriction to specific weekdays (not every day) - // dosed once a day is still effectively daily-cadence on the days it - // fires; the per-slot window is minute-scale either way. Only the rolling - // / calendar-sparse shapes use the day-scale 4-day rule. - return "daily"; -} - -/** - * v1.5.0 — schedule view consumed by the cadence-aware adapter. - * - * Accepts the historical `ScheduleWindow` shape (`windowStart` + - * `windowEnd` only) as well as the richer `ScheduleLike` shape that - * carries `daysOfWeek`. When `daysOfWeek` is missing the schedule is - * treated as `null` (daily, every day) so legacy fixtures keep - * passing without modification. - */ -export interface ComplianceSchedule { - windowStart: string; // HH:mm - windowEnd: string; // HH:mm - daysOfWeek?: string | null; - /** - * v1.7.0 SB-SCHED-2 — canonical-engine fields. When present (and a - * `medicationContext` is threaded into `calculateCompliance`), the - * expected-slot grid is computed through the canonical recurrence - * engine, so an `rrule = "FREQ=WEEKLY;BYDAY=MO"` schedule counts only - * Mondays in the denominator instead of every day. Absent fields keep - * the legacy `daysOfWeek` path — existing fixtures / callers that pass - * only `{ windowStart, windowEnd }` behave exactly as before. - */ - rrule?: string | null; - rollingIntervalDays?: number | null; - timesOfDay?: string[]; - reminderGraceMinutes?: number | null; - scheduleType?: ScheduleType | null; - cyclicOnWeeks?: number | null; - cyclicOffWeeks?: number | null; - /** - * v1.15.18 — per-dose configurable on-time windows. Threaded onto the - * canonical schedule so `tallyComplianceFromLedger` builds the on-time band - * from the explicit `[start, end]` range — keeping the % exactly aligned with - * the dose-history view and the write/edit attribution (all consume the same - * minter). Accepts the raw persisted JSON (a full Prisma row drops straight - * in); `toCanonicalSchedule` normalises it via `normaliseDoseWindows`. - */ - doseWindows?: unknown; -} - -/** - * v1.15.20 — the ONE Prisma `select` for schedule rows feeding a compliance - * computation. Every call site that loads schedules for - * `calculateCompliance` / the cadence helpers must use this constant instead - * of hand-rolling a field list (or an unbounded `include: { schedules: - * true }`), so a future schedule column that the engine consumes — the way - * `doseWindows` joined in v1.15.18 — reaches every surface the moment it is - * added here. Covers the full {@link ComplianceSchedule} shape plus `id` - * (engine-internal labelling / diagnostics). - */ -export const SCHEDULE_COMPLIANCE_SELECT = { - id: true, - windowStart: true, - windowEnd: true, - daysOfWeek: true, - timesOfDay: true, - reminderGraceMinutes: true, - rrule: true, - rollingIntervalDays: true, - scheduleType: true, - cyclicOnWeeks: true, - cyclicOffWeeks: true, - // The configurable per-dose on-time windows. Selecting them everywhere is - // the point: a user-configured "07:00–09:00" band must shape the rate on - // the dashboard pillar, the insights features, the BP-status gate and the - // report exactly as it shapes the medication detail page. - doseWindows: true, -} as const; - -/** - * v1.7.0 SB-SCHED-2 — medication-level context the canonical engine - * needs to expand expected slots. When supplied, `calculateCompliance` - * routes the denominator through the engine; when omitted, every - * schedule falls back to the legacy weekday walker (the byte-stable - * pre-v1.7 behaviour that the parity fixtures pin). - */ -export interface ComplianceMedicationContext { - startsOn: Date | null; - endsOn: Date | null; - oneShot: boolean; - createdAt: Date; - lastIntakeAt: Date | null; - timeZone: string; - /** - * v1.16.3 — archived schedule eras (`validFrom` ascending). When present, - * every expected-slot expansion + band mint segments its range so a past - * day counts/mints against the schedule that was live THEN. Optional: - * callers without revisions keep the live-only expansion. - */ - scheduleRevisions?: ScheduleRevisionLike[]; - /** - * v1.25 H-MED1 — durable pause intervals (`MedicationPauseEra`). When - * present, expected dose slots whose anchor falls inside any - * `[pausedAt, resumedAt ?? now)` interval are dropped from the ledger so a - * resumed medication never counts the paused days as missed. Optional: - * callers without eras keep counting every expected slot. - */ - pauseEras?: MedicationPauseEraLike[]; -} - -/** - * v1.25 H-MED1 — the pause-interval projection the compliance engine reads. - * A Prisma `MedicationPauseEra` row (or a `{ pausedAt, resumedAt }` literal) - * drops in. An open era (`resumedAt === null`) runs to `now`. - */ -export interface MedicationPauseEraLike { - pausedAt: Date; - resumedAt: Date | null; -} - -/** - * v1.7.0 SB-SCHED-2 — convenience builder so the eight compliance call - * sites don't each re-spell the context shape. Pass the medication row - * (any object carrying the course-window fields), the latest non-skipped - * intake instant, and the user's timezone. - */ -export function buildComplianceMedicationContext( - med: { - startsOn: Date | null; - endsOn: Date | null; - oneShot: boolean; - createdAt: Date; - /** v1.16.3 — thread the archived eras when the bundle carries them. */ - scheduleRevisions?: ScheduleRevisionLike[]; - /** v1.25 H-MED1 — thread the pause eras when the caller carries them. */ - pauseEras?: MedicationPauseEraLike[]; - }, - lastIntakeAt: Date | null, - timeZone: string, -): ComplianceMedicationContext { - return { - startsOn: med.startsOn, - endsOn: med.endsOn, - oneShot: med.oneShot, - createdAt: med.createdAt, - lastIntakeAt, - timeZone, - ...(med.scheduleRevisions && { scheduleRevisions: med.scheduleRevisions }), - ...(med.pauseEras && { pauseEras: med.pauseEras }), - }; -} - -/** - * v1.7.0 SB-SCHED-2 — the latest non-skipped `takenAt` across an event - * list (rolling cadences re-anchor on it). Returns null when the user - * has never logged a non-skipped intake. Order-independent, so the - * caller can pass an events array in any sort order. - */ -export function lastNonSkippedTakenAt( - events: { takenAt: Date | null; skipped: boolean }[], -): Date | null { - return events.reduce((latest, e) => { - if (e.skipped || e.takenAt === null) return latest; - if (latest === null || e.takenAt.getTime() > latest.getTime()) { - return e.takenAt; - } - return latest; - }, null); -} - -/** - * v1.13.x — the non-skipped `takenAt` instants at or before `now`, ascending. - * These anchor the retrospective rolling expected-dose grid (each logged dose - * is one satisfied expected slot). The full history is passed — not just the - * compliance window — so the gap-walk between consecutive intakes (which - * synthesizes skipped-cycle misses) and the forward next-due anchor stay - * correct across the window boundary; `expandRollingRetrospective` clamps the - * emitted slots to its own `[from, to]`. - */ -function rollingIntakeInstants( - events: { takenAt: Date | null; skipped: boolean }[], - now: Date, -): Date[] { - return events - .filter( - (e): e is { takenAt: Date; skipped: boolean } => - !e.skipped && - e.takenAt !== null && - e.takenAt.getTime() <= now.getTime(), - ) - .map((e) => e.takenAt) - .sort((a, b) => a.getTime() - b.getTime()); -} - -/** - * v1.8.5 — adapt a compliance medication context to the canonical engine's - * {@link RecurrenceContext}. The synthetic medication `id` only labels the - * row for the engine's internal logging; the `idTag` keeps the two callers' - * historical prefixes (`compliance-daily` vs `compliance-slots`) distinct. - */ -function toRecurrenceCtx( - ctx: ComplianceMedicationContext, - idTag: string, -): RecurrenceContext { - return { - medication: { - id: idTag, - startsOn: ctx.startsOn, - endsOn: ctx.endsOn, - oneShot: ctx.oneShot, - createdAt: ctx.createdAt, - }, - timeZone: ctx.timeZone, - lastIntakeAt: ctx.lastIntakeAt, - }; -} - -/** - * v1.8.5 — adapt a {@link ComplianceSchedule} to the canonical engine's - * {@link CanonicalSchedule}, defaulting the optional fields the compliance - * payload may omit. `id` is engine-internal labelling only. - */ -function toCanonicalSchedule( - s: ComplianceSchedule, - id: string, -): CanonicalSchedule { - return { - id, - 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", - cyclicOnWeeks: s.cyclicOnWeeks ?? null, - cyclicOffWeeks: s.cyclicOffWeeks ?? null, - doseWindows: normaliseDoseWindows(s.doseWindows), - }; -} - -/** - * v1.13.x — non-skipped intake instants for the retrospective rolling grid, - * mirroring {@link rollingIntakeInstants} but reading the public - * `{ takenAt, skipped }[]` shape the `expectedSlots*` callers pass. The - * `expectedSlots*` helpers accept this so their rolling denominator uses the - * SAME expansion as the displayed-rate numerator in {@link calculateCompliance}. - */ -export interface ComplianceIntakeInstant { - takenAt: Date | null; - skipped: boolean; - /** - * v1.15.10 — the dose slot this intake resolves (the canonical snapped - * instant, byte-identical to the engine occurrence's `.at`). Optional so - * every legacy caller / fixture that omits it keeps working; when present - * the open-dose selection in {@link buildCurrentCycle} uses it to skip a - * slot the user has already acted on (taken / skipped / auto-missed) so the - * card advances to the next genuinely-open slot rather than re-surfacing a - * resolved past dose. - */ - scheduledFor?: Date; - /** - * v1.15.10 — the cron-flagged forgotten miss. A resolved (auto-missed) slot - * is excluded from the open-dose search the same way a take / skip is. - */ - autoMissed?: boolean; -} - -function intakeInstantsAtOrBefore( - intakes: ComplianceIntakeInstant[], - now: Date, -): Date[] { - return intakes - .filter( - (e): e is { takenAt: Date; skipped: boolean } => - !e.skipped && - e.takenAt !== null && - e.takenAt.getTime() <= now.getTime(), - ) - .map((e) => e.takenAt) - .sort((a, b) => a.getTime() - b.getTime()); -} - -/** - * v1.13.x — expand one schedule's expected occurrences over `[from, to]`, - * routing a ROLLING schedule through the retrospective builder when intake - * history is supplied (`intakeInstants` + `now`) and the forward-only engine - * path for every other shape. This is the single expansion the slot-count - * helpers and the displayed-rate timeline both delegate to, so the heatmap - * `due` flags, the window-selection denominator, and the percentage agree. - */ -function expandComplianceOccurrences( - canonical: CanonicalSchedule, - recurrenceCtx: RecurrenceContext, - from: Date, - to: Date, - retro: { intakeInstants: Date[]; now: Date } | undefined, -): Occurrence[] { - if (retro && canonical.rollingIntervalDays !== null) { - return expandRollingRetrospective( - canonical, - recurrenceCtx, - from, - to, - retro.intakeInstants, - retro.now, - ); - } - return occurrencesBetween(canonical, from, to, recurrenceCtx); -} - -/** - * v1.7.0 item 5 — count the expected dose slots a medication's schedules - * emit inside `[dayStart, dayEnd)`, routed through the canonical engine. - * Powers the per-day `due` / `expectedCount` fields on the per-med - * compliance payload so iOS history renders a "missed" mark only on days - * the schedule actually expected a dose (not off-weeks / non-matching - * weekdays / PRN days). - * - * v1.13.x — pass `intakes` so a ROLLING schedule routes through the - * retrospective grid (each logged dose is a `due` day, plus skipped-cycle - * misses + a past-due forward slot) instead of the engine's single forward - * slot. Omitting `intakes` keeps the forward-only behaviour for callers that - * don't have the intake history at hand. - */ -export function expectedSlotCountForDay( - schedules: ComplianceSchedule[], - dayStart: Date, - dayEnd: Date, - ctx: ComplianceMedicationContext, - intakes?: ComplianceIntakeInstant[], -): number { - const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-daily"); - const now = new Date(); - const retro = - intakes && schedules.some((s) => s.rollingIntervalDays != null) - ? { intakeInstants: intakeInstantsAtOrBefore(intakes, now), now } - : undefined; - // v1.16.3 — era-aware: a past day counts the slots of the schedule that - // was live THEN. With no revisions the single live era expands exactly - // the per-schedule loop this used to run. - return occurrencesAcrossEras( - { - from: dayStart, - // occurrencesBetween is inclusive of both ends; subtract 1 ms so a - // slot exactly at the next day's midnight doesn't double-count. - to: new Date(dayEnd.getTime() - 1), - }, - ctx.scheduleRevisions ?? [], - schedules.map((s, i) => toCanonicalSchedule(s, `compliance-daily-${i}`)), - (schedule, eraFrom, eraTo) => - expandComplianceOccurrences( - schedule, - recurrenceCtx, - eraFrom, - eraTo, - retro, - ), - { oneShot: ctx.oneShot }, - ).length; -} - -/** - * 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. - * - * v1.8.6 QA — the window lower bound is clamped to `ctx.createdAt`. The - * legacy weekday walker floors on `startsOn` but not on `createdAt`, so a - * brand-new daily med queried over a 30-day window would otherwise emit - * slots for every day before it existed (7/30 expected on a 2-day-old med). - * Clamping here keeps the expected-dose denominator — and the window - * selection that reads it — honest about the medication's real age. The - * displayed rates clamp independently via `calculateCompliance`'s - * `medicationCreatedAt` argument, so this only fixes the slot counts. - */ -export function expectedSlotsBetween( - schedules: ComplianceSchedule[], - from: Date, - to: Date, - ctx: ComplianceMedicationContext, - intakes?: ComplianceIntakeInstant[], -): Occurrence[] { - const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-slots"); - const effectiveFrom = - ctx.createdAt.getTime() > from.getTime() ? ctx.createdAt : from; - // v1.13.x — for a ROLLING schedule the expected grid is reconstructed from - // the intake history (each logged dose is one satisfied expected slot) so - // the window-selection denominator matches the displayed rate. The forward - // next-due slot counts only when past-due relative to `to` (the window's - // upper bound), so a not-yet-due open cycle never inflates the count. - const retro = - intakes && schedules.some((s) => s.rollingIntervalDays != null) - ? { intakeInstants: intakeInstantsAtOrBefore(intakes, to), now: to } - : undefined; - if (effectiveFrom.getTime() > to.getTime()) return []; - // v1.16.3 — era-aware: each archived era contributes the slots of ITS - // schedules; the live rows cover only the range past the newest revision. - return occurrencesAcrossEras( - { from: effectiveFrom, to }, - ctx.scheduleRevisions ?? [], - schedules.map((s, i) => toCanonicalSchedule(s, `compliance-slots-${i}`)), - (schedule, eraFrom, eraTo) => - expandComplianceOccurrences( - schedule, - recurrenceCtx, - eraFrom, - eraTo, - retro, - ), - { oneShot: ctx.oneShot }, - ); -} - -/** - * 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 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; - -/** - * 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< - readonly [number, number] -> = [ - [7, 30], - [30, 90], - [90, 365], -]; - -/** - * 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.6 — pick the two compliance windows for a medication from its - * dosing cadence. - * - * 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 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; intakes?: ComplianceIntakeInstant[] }, -): 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, - options?.intakes, - ).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. - */ -/** - * v1.13.x Fix 4 — the current-cycle descriptor. SEPARATE from the - * percentage rows: a sparse med (weekly+ injection) whose current cycle is - * not yet due must NOT render a scary red 0%. The percentage rows reflect - * only CLOSED cycles (the open forward cycle is `upcoming`, excluded from - * the denominator); this descriptor carries the open-cycle state so the - * card can render a neutral "next dose in N days" / "due today" / "overdue" - * line decoupled from the rate. - * - * States: - * - `on_track`: `now < nextDueAt`. The next dose simply hasn't come round. - * - `due`: `nextDueAt ≤ now ≤ graceUntil`. A neutral / amber call-to-action. - * - `missed`: `now > graceUntil` and the cycle has no logged intake. The - * only state that should tint red. - * - `none`: no schedule projects a next dose (PRN, paused, ended). The - * card shows no current-cycle line. - * - * `hasClosedCycles` is false for a brand-new sparse med with zero closed - * dose cycles — the card then surfaces a neutral "no closed dose cycles - * yet" state instead of a misleading 100% / 0%. - */ -export type CurrentCycleState = "on_track" | "due" | "missed" | "none"; - -export interface CurrentCycle { - state: CurrentCycleState; - /** The open cycle's due instant (ISO via JSON). Null when `state === "none"`. */ - nextDueAt: Date | null; - /** End of the due slot's grace window. Null when `state === "none"`. */ - graceUntil: Date | null; - /** - * Whether the trailing window holds at least one CLOSED dose cycle. When - * false the percentage rows are vacuous (no closed cycle to score) and the - * card should show a neutral "not enough data yet" state. - */ - hasClosedCycles: boolean; -} - -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 + counts + day-streak over the short window. - * `taken` is the numerator the card renders next to the rate so two - * identical percentages stay distinguishable and trustworthy; `expected` - * is the rate denominator (taken + missed, EXCLUDING user skips) and - * `missed` the count counting against the rate. The card can render - * "26 / 30 · 87%" from `taken` / `expected`. - */ - short: { - rate: number; - taken: number; - expected: number; - missed: number; - streak: number; - }; - /** Compliance percentage + counts over the long window. */ - long: { rate: number; taken: number; expected: number; missed: number }; - /** - * v1.13.x Fix 4 — the open-cycle state, decoupled from the percentage - * rows so a between-doses sparse med never renders a scary red number. - */ - currentCycle: CurrentCycle; - /** - * v1.15.9 — the open cycle's per-dose {@link DoseStatus}, server-derived - * so the card renders its state (green take-window, overdue / heavily- - * overdue escalation) from one authority instead of re-deriving the window - * math client-side. `status` is `upcoming` when no dose is open yet and - * there is no projected next dose (PRN / paused / ended). `targetAt` is the - * open dose's target instant (echo of `currentCycle.nextDueAt`). - */ - currentDose: { status: DoseStatus; targetAt: Date | null }; -} - -/** - * v1.13.x Fix 4 — classify the medication's open (current) dose cycle. - * - * Uses the canonical engine's {@link nextOccurrenceAfter} to find the - * earliest projected dose at or after the start of the search (one ms before - * `now` so a slot landing exactly now is captured). The rolling branch of - * `nextOccurrenceAfter` floors to the start of the user's current day, so an - * overdue rolling dose still surfaces as the next slot — that lets us tell a - * not-yet-due (`on_track`) cycle apart from an overdue one (`due` / `missed`). - * - * `hasClosedCycles` reads `expectedShort` from the already-computed window - * selection: a window holding zero expected (closed) doses means the - * percentage rows are vacuous and the card should show a neutral state. - */ -/** - * v1.15.10 — half-window an intake may sit from a slot's canonical instant - * and still count as "resolving" that slot. The intake write paths snap - * `scheduledFor` to the exact engine slot instant, so an exact (or - * sub-minute) match is the common case; the ±6h tolerance also catches an - * off-time take on a dense intraday cadence (e.g. a 07:00 dose logged 09:13) - * whose snapped slot is the 07:00 row but whose raw instant we compare - * defensively. It is the same ±half-gap radius the cadence pairer uses for a - * 12h-gap (twice-daily) med, floored so a single-dose-a-day cadence still - * matches its one slot. - */ -const OPEN_DOSE_RESOLVE_RADIUS_MS = 6 * 60 * 60 * 1000; - -/** - * v1.15.10 — true when an intake event has already resolved the slot at - * `slotAt`. A resolved slot is one the user took, deliberately skipped, or - * the auto-miss cron flagged — any of which means the card must NOT keep - * surfacing that slot as the next dose. Matches on the snapped `scheduledFor` - * first (the canonical, exact path) and falls back to the take/skip instant - * within the resolve radius for rows that predate the snap or drifted. - */ -function slotIsResolved( - slotAt: Date, - intakes: ComplianceIntakeInstant[], -): boolean { - const slot = slotAt.getTime(); - for (const e of intakes) { - const isResolved = e.skipped || e.autoMissed === true || e.takenAt !== null; - if (!isResolved) continue; - const ref = (e.scheduledFor ?? e.takenAt) as Date | undefined; - if (!ref) continue; - if (Math.abs(ref.getTime() - slot) <= OPEN_DOSE_RESOLVE_RADIUS_MS) { - return true; - } - } - return false; -} - -export function buildCurrentCycle( - schedules: ComplianceSchedule[], - ctx: ComplianceMedicationContext, - now: Date, - expectedShort: number, - intakes?: ComplianceIntakeInstant[], -): CurrentCycle { - const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-cycle"); - const DAY_MS = 24 * 60 * 60 * 1000; - const allIntakes = intakes ?? []; - const lastIntakeAt = lastNonSkippedTakenAt(allIntakes) ?? ctx.lastIntakeAt; - - // The open cycle's due instant + grace, picked as the SOONEST across every - // schedule. Two paths: - // - ROLLING: the engine's `nextOccurrenceAfter` deliberately rolls an - // overdue re-anchored dose forward (it floors to the start of the - // user's current day), so it cannot surface an open cycle that is more - // than a day overdue. For the current-cycle descriptor we instead - // compute the due instant directly: `(lastNonSkippedIntake) + N` (or - // `startsOn ?? createdAt` with no intake). This is what lets `due` / - // `missed` be distinguished from `on_track`. - // - NON-ROLLING: `nextOccurrenceAfter` already surfaces the next due - // slot (RRULE / legacy / one-shot / cyclic). - let dueAt: Date | null = null; - let graceUntil: Date | null = null; - const consider = (at: Date, grace: Date): void => { - if (dueAt === null || at.getTime() < dueAt.getTime()) { - dueAt = at; - graceUntil = grace; - } - }; - - for (let i = 0; i < schedules.length; i++) { - const s = schedules[i]; - const canonical = toCanonicalSchedule(s, `compliance-cycle-${i}`); - if (canonical.scheduleType === "PRN") continue; - const n = canonical.rollingIntervalDays; - if (n !== null && n > 0) { - const anchor = - lastIntakeAt !== null - ? new Date(lastIntakeAt.getTime() + n * DAY_MS) - : (ctx.startsOn ?? ctx.createdAt); - if (ctx.endsOn && anchor.getTime() > ctx.endsOn.getTime()) continue; - const graceMs = (canonical.reminderGraceMinutes ?? 60) * 60 * 1000; - consider(anchor, new Date(anchor.getTime() + graceMs)); - continue; - } - // v1.15.10 — advance past slots the user has already resolved. The - // engine's `nextOccurrenceAfter` is purely time-anchored, so for a - // twice-daily med it surfaces today's 19:00 slot in the afternoon even - // after the user logged that dose early — the card then sticks on a - // resolved past/present slot ("träge"). Walk occurrences forward and pick - // the first one no intake has resolved (taken / skipped / auto-missed), so - // a med whose remaining slots today are all logged advances to tomorrow's - // first open slot. Bounded so a fully-logged-ahead history can't spin. - let after = new Date(now.getTime() - 1); - let picked: Occurrence | null = null; - for (let step = 0; step < 64; step++) { - const occ = nextOccurrenceAfter(canonical, after, recurrenceCtx); - if (!occ) break; - if (!slotIsResolved(occ.at, allIntakes)) { - picked = occ; - break; - } - // This slot is resolved — step strictly past it and keep looking. - after = new Date(occ.at.getTime()); - } - if (picked) consider(picked.at, picked.graceUntil); - } - - const hasClosedCycles = expectedShort > 0; - - if (dueAt === null || graceUntil === null) { - return { - state: "none", - nextDueAt: null, - graceUntil: null, - hasClosedCycles, - }; - } - - // Narrow the union-mutated locals for the comparisons below. - const due: Date = dueAt; - const grace: Date = graceUntil; - let state: CurrentCycleState; - if (now.getTime() < due.getTime()) { - state = "on_track"; - } else if (now.getTime() <= grace.getTime()) { - state = "due"; - } else { - state = "missed"; - } - - return { state, nextDueAt: due, graceUntil: grace, hasClosedCycles }; -} - -/** - * 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[], - schedules: ComplianceSchedule[], - ctx: ComplianceMedicationContext, - options?: { now?: Date }, -): ComplianceDisplay { - const now = options?.now ?? new Date(); - // v1.13.x — thread the intake history so a ROLLING cadence's window - // selection scores the retrospective grid (each logged dose is a closed - // cycle) rather than the engine's single forward slot. Without this a - // weekly rolling med always falls through to the widest `[90, 365]` rung. - const { shortDays, longDays, expectedShort, expectedLong } = - selectComplianceWindows(schedules, ctx, { now, intakes: events }); - - const short = calculateCompliance( - events, - schedules, - shortDays, - ctx.createdAt, - { - now, - medicationContext: ctx, - }, - ); - const long = calculateCompliance(events, schedules, longDays, ctx.createdAt, { - now, - medicationContext: ctx, - }); - - // v1.13.x Fix 4 — the open-cycle descriptor, separable from the rates. - const currentCycle = buildCurrentCycle( - schedules, - ctx, - now, - expectedShort, - events, - ); - - // v1.15.9 — derive the open dose's per-dose status from one server - // authority so the card renders the take-window (green) / overdue / - // heavily-overdue escalation without re-spelling the window math. The - // cadence family comes from the SOONEST non-PRN schedule (the one the - // open cycle anchors on). `none` cycles (PRN / paused / ended) carry an - // `upcoming` status with a null target so the card stays calm. - const currentDose: { status: DoseStatus; targetAt: Date | null } = - currentCycle.nextDueAt - ? { - status: deriveDoseStatus( - currentCycle.nextDueAt, - soonestCadenceFamily(schedules), - now, - ), - targetAt: currentCycle.nextDueAt, - } - : { status: "upcoming", targetAt: null }; - - return { - shortDays, - longDays, - expectedShort, - expectedLong, - minStableDoses: MIN_STABLE_DOSES, - // `expected` is the rate denominator (taken + missed) so the card can - // render the trustworthy "taken / expected · rate%" triple; user skips - // are excluded from it by construction (they never enter `missed`). - short: { - rate: short.rate, - taken: short.taken, - expected: short.taken + short.missed, - missed: short.missed, - streak: short.streak, - }, - long: { - rate: long.rate, - taken: long.taken, - expected: long.taken + long.missed, - missed: long.missed, - }, - currentCycle, - currentDose, - }; -} - -/** - * v1.15.9 — the {@link DoseCadenceFamily} of the soonest non-PRN schedule, - * used to pick the window model for the open dose's status. A multi-schedule - * med whose cycle anchors on its earliest window keeps that window's cadence; - * when every schedule is PRN (no projected dose) the family is irrelevant - * because the caller only reads `currentDose` when a cycle is open. - */ -function soonestCadenceFamily( - schedules: ComplianceSchedule[], -): DoseCadenceFamily { - for (const s of schedules) { - if (s.scheduleType === "PRN") continue; - return doseCadenceFamily(s); - } - return "daily"; -} - -/** - * v1.15.18 — the unified compliance tally over the dose-history ledger. - * - * THE single source of the medication compliance %. It builds the - * cadence-aware `SlotBand[]` per schedule (the shared band minter — every - * cadence: daily, fixed-weekday, rolling-retrospective, one-shot, cyclic, - * PRN), reconstructs the ONE dose-history ledger - * (`reconstructDoseHistory`), and tallies it so the percentage and the - * history view are mathematically incapable of contradicting each other - * (audit CRITICAL-2). It replaces the ±12h `pairDoses` proximity matcher - * that `calculateCompliance` used for the engine-routed path. - * - * The tally follows the adherence literature's TAKING-vs-TIMING split: - * - numerator (taken) = `taken_on_time` + `taken_late` — a late dose is - * still a taken dose; "late" is NOT collapsed into "missed"; - * - denominator = taken + `missed`; - * - EXCLUDED from the denominator: `skipped` (deliberate user decision), - * `ad_hoc` (off-schedule top-up — no defensible slot), `upcoming` (the - * window hasn't opened), and ENTIRE PRN groups (`hasExpectedSlots:false` - * — PRN has no defensible denominator per the literature); - * - the rate is capped at 100% (extra doses never inflate it). - * - * The on-time / late split is surfaced separately so a caller can show both - * the TAKING rate (the headline) and the TIMING quality. - * - * Pure / synchronous: the bands are minted from pre-fetched schedules + - * intake instants; no DB access. - */ -export interface LedgerComplianceTally { - /** Doses taken (on-time + late). The TAKING-adherence numerator. */ - taken: number; - /** Of `taken`, the count inside the on-time band. */ - takenOnTime: number; - /** Of `taken`, the count in the late tail (still counts as taken). */ - takenLate: number; - /** Expected doses never acted on past their miss cutoff. */ - missed: number; - /** Deliberate user skips — excluded from the denominator. */ - skipped: number; - /** Off-schedule intakes (PRN groups + ad-hoc rows) — excluded. */ - adHoc: number; - /** taken + missed (the rate denominator). */ - denominator: number; - /** round(100 · taken / denominator), capped at 100. 100 on empty. */ - rate: number; -} - -export function tallyComplianceFromLedger( - events: IntakeEvent[], - schedules: ComplianceSchedule[], - ctx: ComplianceMedicationContext, - from: Date, - to: Date, - now: Date, - windowConfig?: DoseWindowConfig, -): LedgerComplianceTally { - const rows = buildComplianceLedgerRows( - events, - schedules, - ctx, - from, - to, - now, - windowConfig, - ); - return tallyLedgerRows(rows); -} - -/** - * Mint the cadence-aware bands over `[from, to]` and reconstruct the ONE - * unified dose-history ledger for them. This is the expansion pass behind - * {@link tallyComplianceFromLedger}, extracted so a caller that needs - * several trailing sub-windows (7-day / 30-day / display / heatmap, all - * ending at `now`) can mint the bands ONCE over the widest window and tally - * each sub-window from the same rows via {@link tallyLedgerRows} instead of - * re-expanding per window. - * - * Pure / synchronous: bands come from pre-fetched schedules + intake - * instants; no DB access. - */ -export function buildComplianceLedgerRows( - events: IntakeEvent[], - schedules: ComplianceSchedule[], - ctx: ComplianceMedicationContext, - from: Date, - to: Date, - now: Date, - windowConfig?: DoseWindowConfig, -): DoseHistoryRow[] { - const medication: BandMinterMedication = { - id: "compliance-tally", - startsOn: ctx.startsOn, - endsOn: ctx.endsOn, - oneShot: ctx.oneShot, - createdAt: ctx.createdAt, - }; - const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-tally"); - const canonicalSchedules = schedules.map((s, i) => { - const canonical = toCanonicalSchedule(s, `compliance-tally-${i}`); - // A legacy daily schedule carries only `windowStart` (no `timesOfDay`, - // no rrule, no rolling, no `daysOfWeek`). The engine's `expandLegacy` - // reads that as "every day at windowStart", but the band minter's - // cadence-detection gate needs an explicit time signal — surface - // `windowStart` as the single time-of-day so the daily band is minted. - if ( - canonical.timesOfDay.length === 0 && - canonical.rrule === null && - canonical.rollingIntervalDays === null && - canonical.scheduleType !== "PRN" && - !ctx.oneShot - ) { - return { ...canonical, timesOfDay: [canonical.windowStart] }; - } - return canonical; - }); - // Rolling cadences anchor their retrospective grid AT each logged intake; - // the bands need every non-skipped take in (or before) the window. Reuse - // the same instant-extraction the legacy rolling path uses so the - // numerator and denominator are built from one expansion. - const intakeInstants = intakeInstantsAtOrBefore( - events.map((e) => ({ takenAt: e.takenAt, skipped: e.skipped })), - to, - ); - - // v1.16.3 — era-aware mint: archived eras band with THEIR schedules. - const groups = buildBandsForSchedulesWithEras({ - medication, - schedules: canonicalSchedules, - revisions: ctx.scheduleRevisions ?? [], - ctx: recurrenceCtx, - userTz: ctx.timeZone, - range: { from, to }, - now, - windowConfig, - intakeInstants, - }); - - // The ledger reads `scheduledFor` + `takenAt` + skip/auto-miss flags. The - // bands already partition the slot space, so the union of every - // non-PRN schedule's bands is fed to ONE reconstruction — `reconstruct - // DoseHistory` claims each slot by at most one intake, so pooling the - // (already correctly-minted) bands is safe. PRN groups (no expected - // slots) contribute no bands; their intakes surface as ad-hoc and are - // excluded from the denominator, exactly as the literature requires. - const bands: SlotBand[] = []; - for (const g of groups) { - if (g.hasExpectedSlots) bands.push(...g.bands); - } - - const intakes: HistoryIntake[] = events - .filter((e) => e.scheduledFor >= from && e.scheduledFor <= to) - .map((e) => ({ - scheduledFor: e.scheduledFor, - takenAt: e.takenAt, - skipped: e.skipped, - autoMissed: e.autoMissed ?? false, - // v1.15.20 — a pinned take binds by anchor and tallies as taken-late - // (slot served, no on-time gain) instead of ad_hoc + missed. - pinned: e.attributionSource === "USER_PIN", - })); - - const rows = reconstructDoseHistory(bands, intakes, now); - - // v1.25 H-MED1 — drop expected dose slots whose anchor falls inside a - // pause interval. While a medication is paused no dose is expected, so a - // slot minted across the paused window must never count as "missed" (the - // denominator-inflating status). Only `slot` rows are dropped, and only - // those whose status feeds the tally (taken / missed) — skip / ad-hoc / - // upcoming rows are already excluded from the denominator, so dropping - // them here would be a redundant double-exclusion. An open era - // (`resumedAt === null`) runs to `now`. - const pauseEras = ctx.pauseEras; - if (pauseEras && pauseEras.length > 0) { - const isInPause = (at: Date): boolean => { - const t = at.getTime(); - for (const era of pauseEras) { - const start = era.pausedAt.getTime(); - const end = (era.resumedAt ?? now).getTime(); - if (t >= start && t < end) return true; - } - return false; - }; - return rows.filter((row) => { - if (row.kind !== "slot") return true; - if ( - row.status !== "taken_on_time" && - row.status !== "taken_late" && - row.status !== "missed" - ) { - return true; - } - return !isInPause(row.at); - }); - } - - return rows; -} - -/** - * Tally pre-built ledger rows into the {@link LedgerComplianceTally} - * counters. When `window` is supplied only rows whose instant (`row.at`) - * falls inside `[window.from, window.to]` (inclusive) are counted — that is - * how a sub-window tally is carved out of a wider single-pass ledger. The - * window-less call tallies every row, byte-identical to the historical - * {@link tallyComplianceFromLedger} behaviour. - */ -export function tallyLedgerRows( - rows: DoseHistoryRow[], - window?: { from: Date; to: Date }, -): LedgerComplianceTally { - let takenOnTime = 0; - let takenLate = 0; - let missed = 0; - let skipped = 0; - let adHoc = 0; - for (const row of rows) { - if (window) { - const t = row.at.getTime(); - if (t < window.from.getTime() || t > window.to.getTime()) continue; - } - switch (row.status) { - case "taken_on_time": - takenOnTime++; - break; - case "taken_late": - takenLate++; - break; - case "missed": - missed++; - break; - case "skipped": - skipped++; - break; - case "ad_hoc": - adHoc++; - break; - // `upcoming` slots are future / still-takeable → excluded from every - // counter so a partial head-of-window day never pollutes the rate. - } - } - - const taken = takenOnTime + takenLate; - const denominator = taken + missed; - const rate = - denominator > 0 - ? Math.min(100, Math.round((taken / denominator) * 100)) - : 100; - - return { - taken, - takenOnTime, - takenLate, - missed, - skipped, - adHoc, - denominator, - rate, - }; -} - -/** - * Calculate compliance for a medication over a given period. - * - * Honours `daysOfWeek` (e.g. `"1"` for Mondays only) and - * `intervalWeeks` (bi-weekly, tri-weekly, …) by delegating to - * `buildCadenceTimeline`. The denominator is the number of dose slots - * the schedule actually emits inside the window — not - * `schedules.length * days`. A user on a weekly Monday-only schedule - * who takes every Monday for 30 days reports 100% adherence (4 of 4 - * Mondays) instead of the pre-v1.5.0 ~13% (4 of 30). - * - * Skipped doses are excluded from the denominator (deliberate user - * decision, not a compliance failure). When the window contains no - * expected doses (paused med, brand-new prescription, schedule that - * never fires in the window) the helper returns `rate: 100` so the - * empty state doesn't trip a "0% compliance" alarm downstream. - * - * Streak counts consecutive days, ending at `now`, where every - * expected dose for the day was taken (or skipped). Days with no - * expected dose (out-of-cadence weekdays, off-weeks on a bi-weekly - * schedule) advance the streak — the user gets credit for not - * breaking on non-scheduled days. - * - * @param events Recorded intake events from - * `MedicationIntakeEvent`. Only `scheduledFor`, - * `takenAt`, `skipped` are read. - * @param schedules Schedule rows for the medication. `daysOfWeek` - * is read when present; missing field is treated - * as daily. - * @param days Rolling-window size in days (typically 7 / 30 - * / 90). - * @param medicationCreatedAt - * When provided, days before the medication - * existed are excluded so they don't count as - * "missed". - * @param options.now Override for the rolling-window anchor. The - * fast-path Health-Score helper passes a fixed - * `now` so cached medication-compliance rates - * agree with the score's other pillars; default - * is `new Date()` (current wall-clock instant). - * v1.5.0 — added so the cadence-aware adapter - * can be driven deterministically from a caller - * that already pinned its own `now`. - */ -export function calculateCompliance( - events: IntakeEvent[], - schedules: ComplianceSchedule[], - days: number, - medicationCreatedAt?: Date, - options?: { now?: Date; medicationContext?: ComplianceMedicationContext }, -): ComplianceResult { - if (schedules.length === 0) { - return { - totalExpected: 0, - taken: 0, - skipped: 0, - missed: 0, - rate: 100, - streak: 0, - }; - } - - const now = options?.now ?? new Date(); - const DAY_MS = 24 * 60 * 60 * 1000; - const periodStart = new Date(now.getTime() - days * DAY_MS); - const effectiveStart = - medicationCreatedAt && medicationCreatedAt > periodStart - ? medicationCreatedAt - : periodStart; - const effectiveDays = Math.max( - 1, - Math.ceil((now.getTime() - effectiveStart.getTime()) / DAY_MS), - ); - - // v1.15.18 — when a medication context is supplied, the count fields - // (taken / skipped / missed / rate) come from the UNIFIED dose-history - // ledger tally, NOT the ±12h `pairDoses` proximity matcher. This is the - // keystone unification: the percentage is a tally over the exact same - // ledger the history view renders, so the two can never contradict (a - // dose can't read "taken late" in the % while the ledger calls it - // "ad-hoc"). The numerator is on-time + late takes (a late dose still - // counts as taken); user skips + ad-hoc top-ups + PRN groups are excluded - // from the denominator. The streak still walks the cadence timeline below - // (its day-grain "every dose taken or skipped" rule is unchanged). - // - // Context-less callers (pure-math fixtures, pre-v1.7 surfaces) keep the - // legacy timeline tally byte-stable — they have no engine context to mint - // bands from. - const ledgerCtx = options?.medicationContext; - let ledgerCounts: { - taken: number; - skipped: number; - missed: number; - rate: number; - } | null = null; - if (ledgerCtx) { - const tally = tallyComplianceFromLedger( - events, - schedules, - ledgerCtx, - effectiveStart, - now, - now, - ); - ledgerCounts = { - taken: tally.taken, - skipped: tally.skipped, - missed: tally.missed, - rate: tally.rate, - }; - } - - // Normalise the schedule shape so legacy callers that pass only - // `{ windowStart, windowEnd }` still produce a usable `daysOfWeek` - // field (treated as daily by the cadence parser). - const normalisedSchedules: ScheduleLike[] = schedules.map((s, i) => ({ - id: `compliance-${i}`, - windowStart: s.windowStart, - windowEnd: s.windowEnd, - daysOfWeek: s.daysOfWeek ?? null, - // v1.7.0 SB-SCHED-2 — thread the canonical-engine fields so the - // cadence expander can delegate to `occurrencesBetween` when a - // medication context is supplied. Undefined fields collapse to the - // legacy weekday path inside `expandScheduleSlots`. - 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, - })); - - // v1.7.0 SB-SCHED-2 — build the engine context once per medication. - // When the caller supplies it, the timeline routes through the - // canonical engine (RRULE / rolling / one-shot / PRN / cyclic); - // otherwise the legacy weekday walker stays in force. - const ctx = options?.medicationContext; - const engineCtx: CadenceEngineContext | undefined = ctx - ? { - startsOn: ctx.startsOn, - endsOn: ctx.endsOn, - oneShot: ctx.oneShot, - createdAt: ctx.createdAt, - lastIntakeAt: ctx.lastIntakeAt, - timeZone: ctx.timeZone, - scheduleRevisions: ctx.scheduleRevisions, - } - : undefined; - - // Match events against the same slot grid the cadence chart uses. - // The chart's pairing radius is ±12 h so a late-by-six-hours dose - // still attaches to the right slot instead of double-counting as - // "missed + extra". - const normalisedEvents: IntakeEventLike[] = events - .filter((e) => e.scheduledFor >= effectiveStart && e.scheduledFor <= now) - .map((e) => ({ - scheduledFor: e.scheduledFor, - takenAt: e.takenAt, - skipped: e.skipped, - // v1.15.9 — carry the forgotten-dose flag into the timeline so an - // auto-missed slot pairs to a `missed` status (counts against the - // rate) rather than a neutral `skipped`. - autoMissed: e.autoMissed ?? false, - })); - - // v1.13.x — ROLLING retrospective expansion. A rolling cadence - // (`rollingIntervalDays`, the canonical GLP-1 "every N days" shape) is - // forward-only in the engine: `expandRolling` emits at most the single - // immediately-next slot, so a historical compliance window saw either - // zero expected slots (vacuous 100%) or one overdue slot (hard 0%) — - // never the true multi-dose adherence over the trailing window. When a - // medication context is threaded AND any schedule is rolling, route the - // rolling schedules through the retrospective builder so each logged dose - // is one satisfied expected slot (plus synthesized misses for skipped - // whole cycles + a past-due forward slot). Non-rolling schedules keep the - // forward-only engine path; both share `buildCadenceTimeline` so the - // numerator and denominator agree (the v1.7.3 B15 convergence rule). - const hasRolling = schedules.some( - (s) => s.rollingIntervalDays != null && s.rollingIntervalDays > 0, - ); - const retro = - engineCtx && hasRolling - ? { intakeInstants: rollingIntakeInstants(events, now), now } - : undefined; - - const timeline = buildCadenceTimeline( - normalisedSchedules, - normalisedEvents, - now, - effectiveDays, - medicationCreatedAt ?? effectiveStart, - engineCtx?.timeZone, - engineCtx, - retro, - ); - - // v1.15.18 — the count fields come from the unified ledger tally when a - // medication context was supplied (the keystone unification), and from the - // legacy timeline tally otherwise (context-less pure-math callers). The - // streak below always walks the timeline — its day-grain rule is orthogonal - // to the per-dose attribution and stays byte-stable for every caller. - let taken: number; - let skipped: number; - let missed: number; - let rate: number; - if (ledgerCounts) { - ({ taken, skipped, missed, rate } = ledgerCounts); - } else { - taken = 0; - skipped = 0; - missed = 0; - for (const slot of timeline) { - if (slot.status === "taken") taken++; - else if (slot.status === "skipped") skipped++; - else if (slot.status === "missed") missed++; - // `upcoming` slots (future window) are excluded from every counter - // so a partial day at the head of the window doesn't pollute the rate. - } - // Skipped doses are excluded from the denominator — they represent a - // deliberate user decision rather than a missed dose. - const denom = taken + missed; - rate = denom > 0 ? Math.min(100, Math.round((taken / denom) * 100)) : 100; - } - - const totalExpected = taken + skipped + missed; - - // Streak: consecutive days, ending today, where every expected dose - // was taken or skipped. Days with no expected dose advance the - // streak — out-of-cadence days are not failures. Delegated to the - // shared `streaksFromTimeline` so the analytics streak and the - // detail-page chip streak agree on every dose AND so the day keys are - // computed in the USER's IANA timezone, not the host's. The prior - // host-tz `getFullYear/getMonth/getDate` walk drifted off by a day - // whenever the server clock's zone differed from the user's — the - // timeline `slot.day` is already minted in the user zone, so the - // walk has to match it. The window is `effectiveDays` ending at - // `now`, which starts no earlier than `effectiveStart` (= the later - // of the period start and the medication's creation) — so days - // before the medication existed are never iterated, preserving the - // old `cursor <= medicationCreatedAt` break by construction. - const { current: streak } = streaksFromTimeline( - timeline, - now, - effectiveDays, - engineCtx?.timeZone, - ); - - return { totalExpected, taken, skipped, missed, rate, streak }; -} - -/** - * Widest trailing window the single-pass compliance ledger has to cover: - * the top rung of {@link COMPLIANCE_WINDOW_LADDER} (365 days). Every - * sub-window the per-medication compliance endpoint serves (7 / 30 / - * cadence-scaled display rows / 90-day heatmap) is a suffix of it. - */ -export const COMPLIANCE_LEDGER_WINDOW_DAYS = 365; - -/** - * The per-medication compliance payload computed from ONE shared expansion - * pass. `ledgerRows` is the unified dose-history ledger over - * `[ledgerFrom, now]`; the caller carves the 90-day heatmap out of it by - * filtering on `row.at`. - */ -export interface MedicationComplianceBundle { - compliance7: ComplianceResult; - compliance30: ComplianceResult; - complianceDisplay: ComplianceDisplay; - /** Unified ledger rows over `[ledgerFrom, now]`, chronological. */ - ledgerRows: DoseHistoryRow[]; - /** Lower bound of the mint window (clamped to the medication's creation). */ - ledgerFrom: Date; -} - -/** - * Build every block of the per-medication compliance response from a single - * band-expansion pass. - * - * The historical composition called {@link calculateCompliance} four times - * (7 / 30 / short / long), {@link selectComplianceWindows} (up to four more - * occurrence expansions for the rung probes) and a separate 90-day heatmap - * mint — five-plus full band expansions per request. This builder instead: - * - * 1. mints the bands + reconstructs the ledger ONCE over - * `[max(createdAt, now − 365 d), now]` (every served window is a - * suffix of that range and ends at `now`, so a sub-window tally is a - * filter over `row.at`, not a re-expansion); - * 2. builds ONE cadence timeline over the same range for the per-window - * streaks ({@link streaksFromTimeline} bounds its day-walk by the - * requested window, so the wider timeline serves every window); - * 3. walks the {@link COMPLIANCE_WINDOW_LADDER} on the ledger's slot-row - * counts (one band per engine occurrence, so the counts match the - * historical `expectedSlotsBetween` probes). - * - * The returned blocks carry the exact public shapes the route has always - * served — only the number of expansion passes changed. - */ -export function buildMedicationComplianceBundle( - events: IntakeEvent[], - schedules: ComplianceSchedule[], - ctx: ComplianceMedicationContext, - now: Date, -): MedicationComplianceBundle { - const ledgerPeriodStart = new Date( - now.getTime() - COMPLIANCE_LEDGER_WINDOW_DAYS * ONE_DAY_MS, - ); - const ledgerFrom = - ctx.createdAt.getTime() > ledgerPeriodStart.getTime() - ? ctx.createdAt - : ledgerPeriodStart; - - const hasSchedules = schedules.length > 0; - const ledgerRows = hasSchedules - ? buildComplianceLedgerRows(events, schedules, ctx, ledgerFrom, now, now) - : []; - - // ONE cadence timeline over the full ledger window. Each per-window - // streak below walks only its own trailing `effectiveDays`, so sharing - // the wide timeline reproduces the per-window builds. - const fullDays = Math.max( - 1, - Math.ceil((now.getTime() - ledgerFrom.getTime()) / ONE_DAY_MS), - ); - const timeline = hasSchedules - ? buildTimelineForWindow(events, schedules, ctx, now, ledgerFrom, fullDays) - : []; - - const resultForWindow = (days: number): ComplianceResult => { - if (!hasSchedules) { - // Mirrors `calculateCompliance`'s empty-schedule short-circuit. - return { - totalExpected: 0, - taken: 0, - skipped: 0, - missed: 0, - rate: 100, - streak: 0, - }; - } - const periodStart = new Date(now.getTime() - days * ONE_DAY_MS); - const effectiveStart = - ctx.createdAt.getTime() > periodStart.getTime() - ? ctx.createdAt - : periodStart; - const effectiveDays = Math.max( - 1, - Math.ceil((now.getTime() - effectiveStart.getTime()) / ONE_DAY_MS), - ); - const tally = tallyLedgerRows(ledgerRows, { - from: effectiveStart, - to: now, - }); - const { current: streak } = streaksFromTimeline( - timeline, - now, - effectiveDays, - ctx.timeZone, - ); - return { - totalExpected: tally.taken + tally.skipped + tally.missed, - taken: tally.taken, - skipped: tally.skipped, - missed: tally.missed, - rate: tally.rate, - streak, - }; - }; - - // Window-ladder selection from the ledger's slot rows. A slot row is one - // minted band, and the minter emits one band per engine occurrence, so - // counting slot rows over a trailing window equals the historical - // `expectedSlotsBetween(...).length` probe for that window. - const expectedCache = new Map(); - const expectedOver = (days: number): number => { - const hit = expectedCache.get(days); - if (hit !== undefined) return hit; - const from = Math.max( - ctx.createdAt.getTime(), - now.getTime() - days * ONE_DAY_MS, - ); - let count = 0; - for (const row of ledgerRows) { - if (row.kind !== "slot") continue; - const t = row.at.getTime(); - if (t >= from && t <= now.getTime()) count++; - } - expectedCache.set(days, count); - return count; - }; - - let selection: ComplianceWindowSelection | null = null; - for (const [shortDays, longDays] of COMPLIANCE_WINDOW_LADDER) { - const expectedShort = expectedOver(shortDays); - const expectedLong = expectedOver(longDays); - if (expectedShort >= MIN_STABLE_DOSES && expectedLong >= MIN_STABLE_DOSES) { - selection = { shortDays, longDays, expectedShort, expectedLong }; - break; - } - } - if (!selection) { - // No rung cleared the floor — fall back to the widest rung, exactly - // like `selectComplianceWindows`. - const [shortDays, longDays] = - COMPLIANCE_WINDOW_LADDER[COMPLIANCE_WINDOW_LADDER.length - 1]; - selection = { - shortDays, - longDays, - expectedShort: expectedOver(shortDays), - expectedLong: expectedOver(longDays), - }; - } - - const compliance7 = resultForWindow(7); - const compliance30 = resultForWindow(30); - const short = resultForWindow(selection.shortDays); - const long = resultForWindow(selection.longDays); - - const currentCycle = buildCurrentCycle( - schedules, - ctx, - now, - selection.expectedShort, - events, - ); - const currentDose: { status: DoseStatus; targetAt: Date | null } = - currentCycle.nextDueAt - ? { - status: deriveDoseStatus( - currentCycle.nextDueAt, - soonestCadenceFamily(schedules), - now, - ), - targetAt: currentCycle.nextDueAt, - } - : { status: "upcoming", targetAt: null }; - - const complianceDisplay: ComplianceDisplay = { - shortDays: selection.shortDays, - longDays: selection.longDays, - expectedShort: selection.expectedShort, - expectedLong: selection.expectedLong, - minStableDoses: MIN_STABLE_DOSES, - short: { - rate: short.rate, - taken: short.taken, - expected: short.taken + short.missed, - missed: short.missed, - streak: short.streak, - }, - long: { - rate: long.rate, - taken: long.taken, - expected: long.taken + long.missed, - missed: long.missed, - }, - currentCycle, - currentDose, - }; - - return { - compliance7, - compliance30, - complianceDisplay, - ledgerRows, - ledgerFrom, - }; -} - -/** - * One day of the cadence-aware compliance series: the day's compliance % - * over the doses the schedule actually expected that day, plus the raw - * taken / missed counts behind it. - */ -export interface DailyComplianceRate { - /** The day's anchor instant (the first expected slot of the day). */ - date: Date; - /** round(100 · taken / (taken + missed)), capped at 100. */ - rate: number; - /** Doses taken (on-time + late) on the day. */ - taken: number; - /** Expected doses never acted on past their miss cutoff, on the day. */ - missed: number; -} - -/** - * v1.18.0 — collapse unified dose-history ledger rows into a cadence-aware - * per-day compliance series. - * - * Only days the schedule's cadence actually expected a dose produce a point: - * the series is grouped over the ledger's scheduled `slot` rows, so an - * off-cadence weekday (a weekly Monday-only med on a Tuesday) or an off-week - * (the off-week of a bi-weekly schedule) emits no point at all and therefore - * can never be read as a 0% "miss". Each day's rate uses the SAME - * taken / (taken + missed) tally — and the SAME on-time-plus-late numerator, - * skip / ad-hoc / upcoming exclusions — that {@link tallyLedgerRows} applies - * to `compliance7` / `compliance30`, so the per-day series and the window - * rates are computed from one ledger and cannot contradict each other. - * - * `ad_hoc` rows (off-schedule top-ups, no defensible slot) and `upcoming` - * rows (the window hasn't opened) are dropped before grouping, exactly as the - * window tally excludes them. A day whose only ledger rows are skips yields no - * point (zero denominator) rather than a misleading 0%. - * - * Days are bucketed in the USER's timezone (`tz`). Passing the caller's real - * zone keeps an evening dose on the local day it was actually due: a fixed - * Berlin key filed a `20:00 America/Los_Angeles` dose (04:00 UTC next day) - * into the following Berlin day, splitting one local day's two doses across - * two buckets and skewing every per-day rate for non-Berlin users. - */ -export function dailyComplianceRatesFromLedger( - ledgerRows: DoseHistoryRow[], - tz: string, -): DailyComplianceRate[] { - const byDay = new Map< - string, - { taken: number; missed: number; date: Date } - >(); - - for (const row of ledgerRows) { - // Only scheduled slots have a defensible denominator; ad-hoc top-ups are - // excluded from the rate exactly as `tallyLedgerRows` excludes them. - if (row.kind !== "slot") continue; - const isTaken = - row.status === "taken_on_time" || row.status === "taken_late"; - const isMissed = row.status === "missed"; - // `skipped` (deliberate) and `upcoming` (window not open) advance neither - // counter — they never enter the day's denominator. - if (!isTaken && !isMissed) continue; - - const dayKey = userDayKey(row.at, tz); - const bucket = byDay.get(dayKey) ?? { taken: 0, missed: 0, date: row.at }; - if (isTaken) bucket.taken += 1; - else bucket.missed += 1; - // Keep the earliest slot instant of the day as the point's anchor. - if (row.at.getTime() < bucket.date.getTime()) bucket.date = row.at; - byDay.set(dayKey, bucket); - } - - return Array.from(byDay.values()) - .sort((a, b) => a.date.getTime() - b.date.getTime()) - .map((day) => { - const denom = day.taken + day.missed; - return { - date: day.date, - rate: - denom > 0 - ? Math.min(100, Math.round((day.taken / denom) * 100)) - : 100, - taken: day.taken, - missed: day.missed, - }; - }); -} - -/** - * The cadence-timeline construction from {@link calculateCompliance}, - * extracted so {@link buildMedicationComplianceBundle} can build it once - * over the full ledger window instead of once per served sub-window. Same - * normalisation, same engine context, same rolling-retrospective routing. - */ -function buildTimelineForWindow( - events: IntakeEvent[], - schedules: ComplianceSchedule[], - ctx: ComplianceMedicationContext, - now: Date, - effectiveStart: Date, - effectiveDays: number, -): ReturnType { - const normalisedSchedules: ScheduleLike[] = schedules.map((s, i) => ({ - id: `compliance-${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 engineCtx: CadenceEngineContext = { - startsOn: ctx.startsOn, - endsOn: ctx.endsOn, - oneShot: ctx.oneShot, - createdAt: ctx.createdAt, - lastIntakeAt: ctx.lastIntakeAt, - timeZone: ctx.timeZone, - scheduleRevisions: ctx.scheduleRevisions, - }; - - const normalisedEvents: IntakeEventLike[] = events - .filter((e) => e.scheduledFor >= effectiveStart && e.scheduledFor <= now) - .map((e) => ({ - scheduledFor: e.scheduledFor, - takenAt: e.takenAt, - skipped: e.skipped, - autoMissed: e.autoMissed ?? false, - })); - - const hasRolling = schedules.some( - (s) => s.rollingIntervalDays != null && s.rollingIntervalDays > 0, - ); - const retro = hasRolling - ? { intakeInstants: rollingIntakeInstants(events, now), now } - : undefined; - - return buildCadenceTimeline( - normalisedSchedules, - normalisedEvents, - now, - effectiveDays, - ctx.createdAt, - ctx.timeZone, - engineCtx, - retro, - ); -} +export * from "./compliance/parsing"; +export * from "./compliance/types"; +export * from "./compliance/dose-status"; +export * from "./compliance/occurrences"; +export * from "./compliance/windows"; +export * from "./compliance/cycle"; +export * from "./compliance/ledger"; +export * from "./compliance/display"; diff --git a/src/lib/analytics/__tests__/as-needed-compliance-exclusion.test.ts b/src/lib/analytics/compliance/__tests__/as-needed-compliance-exclusion.test.ts similarity index 100% rename from src/lib/analytics/__tests__/as-needed-compliance-exclusion.test.ts rename to src/lib/analytics/compliance/__tests__/as-needed-compliance-exclusion.test.ts diff --git a/src/lib/analytics/__tests__/compliance-bundle.test.ts b/src/lib/analytics/compliance/__tests__/compliance-bundle.test.ts similarity index 99% rename from src/lib/analytics/__tests__/compliance-bundle.test.ts rename to src/lib/analytics/compliance/__tests__/compliance-bundle.test.ts index 3a042a24d..df88d73e3 100644 --- a/src/lib/analytics/__tests__/compliance-bundle.test.ts +++ b/src/lib/analytics/compliance/__tests__/compliance-bundle.test.ts @@ -22,7 +22,7 @@ import { tallyComplianceFromLedger, tallyLedgerRows, type ComplianceSchedule, -} from "../compliance"; +} from "@/lib/analytics/compliance"; import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history"; import { zonedWallClockToUtc } from "@/lib/tz/wall-clock"; diff --git a/src/lib/analytics/__tests__/compliance-pause-era.test.ts b/src/lib/analytics/compliance/__tests__/compliance-pause-era.test.ts similarity index 99% rename from src/lib/analytics/__tests__/compliance-pause-era.test.ts rename to src/lib/analytics/compliance/__tests__/compliance-pause-era.test.ts index a1a81f30c..6cc5cff8c 100644 --- a/src/lib/analytics/__tests__/compliance-pause-era.test.ts +++ b/src/lib/analytics/compliance/__tests__/compliance-pause-era.test.ts @@ -22,7 +22,7 @@ import { tallyComplianceFromLedger, type ComplianceSchedule, type MedicationPauseEraLike, -} from "../compliance"; +} from "@/lib/analytics/compliance"; import type { ScheduleRevisionLike } from "@/lib/medications/scheduling/schedule-eras"; const TZ = "UTC"; diff --git a/src/lib/analytics/__tests__/compliance.test.ts b/src/lib/analytics/compliance/__tests__/compliance.test.ts similarity index 99% rename from src/lib/analytics/__tests__/compliance.test.ts rename to src/lib/analytics/compliance/__tests__/compliance.test.ts index fa844f670..f14be4e05 100644 --- a/src/lib/analytics/__tests__/compliance.test.ts +++ b/src/lib/analytics/compliance/__tests__/compliance.test.ts @@ -28,8 +28,8 @@ import { tallyComplianceFromLedger, type ComplianceMedicationContext, type ComplianceSchedule, -} from "../compliance"; -import type { IntakeTimingClass } from "../compliance"; +} from "@/lib/analytics/compliance"; +import type { IntakeTimingClass } from "@/lib/analytics/compliance"; import { buildCadenceTimeline } from "@/lib/medications/scheduling/cadence"; import { getUserTodayBounds } from "@/lib/tz/local-day"; import { userDayKey } from "@/lib/tz/format"; diff --git a/src/lib/analytics/__tests__/schedule-compliance-select.test.ts b/src/lib/analytics/compliance/__tests__/schedule-compliance-select.test.ts similarity index 94% rename from src/lib/analytics/__tests__/schedule-compliance-select.test.ts rename to src/lib/analytics/compliance/__tests__/schedule-compliance-select.test.ts index 40f603ba2..d6fcbd66d 100644 --- a/src/lib/analytics/__tests__/schedule-compliance-select.test.ts +++ b/src/lib/analytics/compliance/__tests__/schedule-compliance-select.test.ts @@ -10,7 +10,7 @@ */ import { describe, expect, it } from "vitest"; -import { SCHEDULE_COMPLIANCE_SELECT } from "../compliance"; +import { SCHEDULE_COMPLIANCE_SELECT } from "@/lib/analytics/compliance"; describe("SCHEDULE_COMPLIANCE_SELECT", () => { it("selects every schedule field the compliance engine consumes", () => { diff --git a/src/lib/analytics/compliance/adapters.ts b/src/lib/analytics/compliance/adapters.ts new file mode 100644 index 000000000..3de29fb32 --- /dev/null +++ b/src/lib/analytics/compliance/adapters.ts @@ -0,0 +1,131 @@ +// Extracted from the former single-file `compliance.ts`. See `../compliance.ts` +// (the barrel) for the module map. Pure move — no logic changes. + +import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers"; +import { + expandRollingRetrospective, + occurrencesBetween, + type CanonicalSchedule, + type Occurrence, + type RecurrenceContext, +} from "@/lib/medications/scheduling/recurrence"; +import type { + ComplianceIntakeInstant, + ComplianceMedicationContext, + ComplianceSchedule, +} from "./types"; + +/** + * v1.13.x — the non-skipped `takenAt` instants at or before `now`, ascending. + * These anchor the retrospective rolling expected-dose grid (each logged dose + * is one satisfied expected slot). The full history is passed — not just the + * compliance window — so the gap-walk between consecutive intakes (which + * synthesizes skipped-cycle misses) and the forward next-due anchor stay + * correct across the window boundary; `expandRollingRetrospective` clamps the + * emitted slots to its own `[from, to]`. + */ +export function rollingIntakeInstants( + events: { takenAt: Date | null; skipped: boolean }[], + now: Date, +): Date[] { + return events + .filter( + (e): e is { takenAt: Date; skipped: boolean } => + !e.skipped && + e.takenAt !== null && + e.takenAt.getTime() <= now.getTime(), + ) + .map((e) => e.takenAt) + .sort((a, b) => a.getTime() - b.getTime()); +} + +/** + * v1.8.5 — adapt a compliance medication context to the canonical engine's + * {@link RecurrenceContext}. The synthetic medication `id` only labels the + * row for the engine's internal logging; the `idTag` keeps the two callers' + * historical prefixes (`compliance-daily` vs `compliance-slots`) distinct. + */ +export function toRecurrenceCtx( + ctx: ComplianceMedicationContext, + idTag: string, +): RecurrenceContext { + return { + medication: { + id: idTag, + startsOn: ctx.startsOn, + endsOn: ctx.endsOn, + oneShot: ctx.oneShot, + createdAt: ctx.createdAt, + }, + timeZone: ctx.timeZone, + lastIntakeAt: ctx.lastIntakeAt, + }; +} + +/** + * v1.8.5 — adapt a {@link ComplianceSchedule} to the canonical engine's + * {@link CanonicalSchedule}, defaulting the optional fields the compliance + * payload may omit. `id` is engine-internal labelling only. + */ +export function toCanonicalSchedule( + s: ComplianceSchedule, + id: string, +): CanonicalSchedule { + return { + id, + 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", + cyclicOnWeeks: s.cyclicOnWeeks ?? null, + cyclicOffWeeks: s.cyclicOffWeeks ?? null, + doseWindows: normaliseDoseWindows(s.doseWindows), + }; +} + +export function intakeInstantsAtOrBefore( + intakes: ComplianceIntakeInstant[], + now: Date, +): Date[] { + return intakes + .filter( + (e): e is { takenAt: Date; skipped: boolean } => + !e.skipped && + e.takenAt !== null && + e.takenAt.getTime() <= now.getTime(), + ) + .map((e) => e.takenAt) + .sort((a, b) => a.getTime() - b.getTime()); +} + +/** + * v1.13.x — expand one schedule's expected occurrences over `[from, to]`, + * routing a ROLLING schedule through the retrospective builder when intake + * history is supplied (`intakeInstants` + `now`) and the forward-only engine + * path for every other shape. This is the single expansion the slot-count + * helpers and the displayed-rate timeline both delegate to, so the heatmap + * `due` flags, the window-selection denominator, and the percentage agree. + */ +export function expandComplianceOccurrences( + canonical: CanonicalSchedule, + recurrenceCtx: RecurrenceContext, + from: Date, + to: Date, + retro: { intakeInstants: Date[]; now: Date } | undefined, +): Occurrence[] { + if (retro && canonical.rollingIntervalDays !== null) { + return expandRollingRetrospective( + canonical, + recurrenceCtx, + from, + to, + retro.intakeInstants, + retro.now, + ); + } + return occurrencesBetween(canonical, from, to, recurrenceCtx); +} diff --git a/src/lib/analytics/compliance/cycle.ts b/src/lib/analytics/compliance/cycle.ts new file mode 100644 index 000000000..f80a9f2c2 --- /dev/null +++ b/src/lib/analytics/compliance/cycle.ts @@ -0,0 +1,199 @@ +// Extracted from the former single-file `compliance.ts`. See `../compliance.ts` +// (the barrel) for the module map. Pure move — no logic changes. + +import { + nextOccurrenceAfter, + type Occurrence, +} from "@/lib/medications/scheduling/recurrence"; +import { + lastNonSkippedTakenAt, + type ComplianceIntakeInstant, + type ComplianceMedicationContext, + type ComplianceSchedule, +} from "./types"; +import { toCanonicalSchedule, toRecurrenceCtx } from "./adapters"; + +/** + * v1.13.x Fix 4 — the current-cycle descriptor. SEPARATE from the + * percentage rows: a sparse med (weekly+ injection) whose current cycle is + * not yet due must NOT render a scary red 0%. The percentage rows reflect + * only CLOSED cycles (the open forward cycle is `upcoming`, excluded from + * the denominator); this descriptor carries the open-cycle state so the + * card can render a neutral "next dose in N days" / "due today" / "overdue" + * line decoupled from the rate. + * + * States: + * - `on_track`: `now < nextDueAt`. The next dose simply hasn't come round. + * - `due`: `nextDueAt ≤ now ≤ graceUntil`. A neutral / amber call-to-action. + * - `missed`: `now > graceUntil` and the cycle has no logged intake. The + * only state that should tint red. + * - `none`: no schedule projects a next dose (PRN, paused, ended). The + * card shows no current-cycle line. + * + * `hasClosedCycles` is false for a brand-new sparse med with zero closed + * dose cycles — the card then surfaces a neutral "no closed dose cycles + * yet" state instead of a misleading 100% / 0%. + */ +export type CurrentCycleState = "on_track" | "due" | "missed" | "none"; + +export interface CurrentCycle { + state: CurrentCycleState; + /** The open cycle's due instant (ISO via JSON). Null when `state === "none"`. */ + nextDueAt: Date | null; + /** End of the due slot's grace window. Null when `state === "none"`. */ + graceUntil: Date | null; + /** + * Whether the trailing window holds at least one CLOSED dose cycle. When + * false the percentage rows are vacuous (no closed cycle to score) and the + * card should show a neutral "not enough data yet" state. + */ + hasClosedCycles: boolean; +} + +/** + * v1.13.x Fix 4 — classify the medication's open (current) dose cycle. + * + * Uses the canonical engine's {@link nextOccurrenceAfter} to find the + * earliest projected dose at or after the start of the search (one ms before + * `now` so a slot landing exactly now is captured). The rolling branch of + * `nextOccurrenceAfter` floors to the start of the user's current day, so an + * overdue rolling dose still surfaces as the next slot — that lets us tell a + * not-yet-due (`on_track`) cycle apart from an overdue one (`due` / `missed`). + * + * `hasClosedCycles` reads `expectedShort` from the already-computed window + * selection: a window holding zero expected (closed) doses means the + * percentage rows are vacuous and the card should show a neutral state. + */ +/** + * v1.15.10 — half-window an intake may sit from a slot's canonical instant + * and still count as "resolving" that slot. The intake write paths snap + * `scheduledFor` to the exact engine slot instant, so an exact (or + * sub-minute) match is the common case; the ±6h tolerance also catches an + * off-time take on a dense intraday cadence (e.g. a 07:00 dose logged 09:13) + * whose snapped slot is the 07:00 row but whose raw instant we compare + * defensively. It is the same ±half-gap radius the cadence pairer uses for a + * 12h-gap (twice-daily) med, floored so a single-dose-a-day cadence still + * matches its one slot. + */ +const OPEN_DOSE_RESOLVE_RADIUS_MS = 6 * 60 * 60 * 1000; + +/** + * v1.15.10 — true when an intake event has already resolved the slot at + * `slotAt`. A resolved slot is one the user took, deliberately skipped, or + * the auto-miss cron flagged — any of which means the card must NOT keep + * surfacing that slot as the next dose. Matches on the snapped `scheduledFor` + * first (the canonical, exact path) and falls back to the take/skip instant + * within the resolve radius for rows that predate the snap or drifted. + */ +function slotIsResolved( + slotAt: Date, + intakes: ComplianceIntakeInstant[], +): boolean { + const slot = slotAt.getTime(); + for (const e of intakes) { + const isResolved = e.skipped || e.autoMissed === true || e.takenAt !== null; + if (!isResolved) continue; + const ref = (e.scheduledFor ?? e.takenAt) as Date | undefined; + if (!ref) continue; + if (Math.abs(ref.getTime() - slot) <= OPEN_DOSE_RESOLVE_RADIUS_MS) { + return true; + } + } + return false; +} + +export function buildCurrentCycle( + schedules: ComplianceSchedule[], + ctx: ComplianceMedicationContext, + now: Date, + expectedShort: number, + intakes?: ComplianceIntakeInstant[], +): CurrentCycle { + const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-cycle"); + const DAY_MS = 24 * 60 * 60 * 1000; + const allIntakes = intakes ?? []; + const lastIntakeAt = lastNonSkippedTakenAt(allIntakes) ?? ctx.lastIntakeAt; + + // The open cycle's due instant + grace, picked as the SOONEST across every + // schedule. Two paths: + // - ROLLING: the engine's `nextOccurrenceAfter` deliberately rolls an + // overdue re-anchored dose forward (it floors to the start of the + // user's current day), so it cannot surface an open cycle that is more + // than a day overdue. For the current-cycle descriptor we instead + // compute the due instant directly: `(lastNonSkippedIntake) + N` (or + // `startsOn ?? createdAt` with no intake). This is what lets `due` / + // `missed` be distinguished from `on_track`. + // - NON-ROLLING: `nextOccurrenceAfter` already surfaces the next due + // slot (RRULE / legacy / one-shot / cyclic). + let dueAt: Date | null = null; + let graceUntil: Date | null = null; + const consider = (at: Date, grace: Date): void => { + if (dueAt === null || at.getTime() < dueAt.getTime()) { + dueAt = at; + graceUntil = grace; + } + }; + + for (let i = 0; i < schedules.length; i++) { + const s = schedules[i]; + const canonical = toCanonicalSchedule(s, `compliance-cycle-${i}`); + if (canonical.scheduleType === "PRN") continue; + const n = canonical.rollingIntervalDays; + if (n !== null && n > 0) { + const anchor = + lastIntakeAt !== null + ? new Date(lastIntakeAt.getTime() + n * DAY_MS) + : (ctx.startsOn ?? ctx.createdAt); + if (ctx.endsOn && anchor.getTime() > ctx.endsOn.getTime()) continue; + const graceMs = (canonical.reminderGraceMinutes ?? 60) * 60 * 1000; + consider(anchor, new Date(anchor.getTime() + graceMs)); + continue; + } + // v1.15.10 — advance past slots the user has already resolved. The + // engine's `nextOccurrenceAfter` is purely time-anchored, so for a + // twice-daily med it surfaces today's 19:00 slot in the afternoon even + // after the user logged that dose early — the card then sticks on a + // resolved past/present slot ("träge"). Walk occurrences forward and pick + // the first one no intake has resolved (taken / skipped / auto-missed), so + // a med whose remaining slots today are all logged advances to tomorrow's + // first open slot. Bounded so a fully-logged-ahead history can't spin. + let after = new Date(now.getTime() - 1); + let picked: Occurrence | null = null; + for (let step = 0; step < 64; step++) { + const occ = nextOccurrenceAfter(canonical, after, recurrenceCtx); + if (!occ) break; + if (!slotIsResolved(occ.at, allIntakes)) { + picked = occ; + break; + } + // This slot is resolved — step strictly past it and keep looking. + after = new Date(occ.at.getTime()); + } + if (picked) consider(picked.at, picked.graceUntil); + } + + const hasClosedCycles = expectedShort > 0; + + if (dueAt === null || graceUntil === null) { + return { + state: "none", + nextDueAt: null, + graceUntil: null, + hasClosedCycles, + }; + } + + // Narrow the union-mutated locals for the comparisons below. + const due: Date = dueAt; + const grace: Date = graceUntil; + let state: CurrentCycleState; + if (now.getTime() < due.getTime()) { + state = "on_track"; + } else if (now.getTime() <= grace.getTime()) { + state = "due"; + } else { + state = "missed"; + } + + return { state, nextDueAt: due, graceUntil: grace, hasClosedCycles }; +} diff --git a/src/lib/analytics/compliance/display.ts b/src/lib/analytics/compliance/display.ts new file mode 100644 index 000000000..fa379412f --- /dev/null +++ b/src/lib/analytics/compliance/display.ts @@ -0,0 +1,727 @@ +// Extracted from the former single-file `compliance.ts`. See `../compliance.ts` +// (the barrel) for the module map. Pure move — no logic changes. + +import { + buildCadenceTimeline, + type CadenceEngineContext, + type IntakeEventLike, + type ScheduleLike, +} from "@/lib/medications/scheduling/cadence"; +import { streaksFromTimeline } from "@/lib/medications/scheduling/compliance"; +import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history"; +import type { + ComplianceMedicationContext, + ComplianceResult, + ComplianceSchedule, + IntakeEvent, +} from "./types"; +import { + deriveDoseStatus, + doseCadenceFamily, + type DoseCadenceFamily, + type DoseStatus, +} from "./dose-status"; +import { + COMPLIANCE_WINDOW_LADDER, + MIN_STABLE_DOSES, + selectComplianceWindows, + type ComplianceWindowSelection, +} from "./windows"; +import { + COMPLIANCE_LEDGER_WINDOW_DAYS, + buildComplianceLedgerRows, + tallyComplianceFromLedger, + tallyLedgerRows, +} from "./ledger"; +import { buildCurrentCycle, type CurrentCycle } from "./cycle"; +import { rollingIntakeInstants } from "./adapters"; + +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +/** + * v1.15.9 — the {@link DoseCadenceFamily} of the soonest non-PRN schedule, + * used to pick the window model for the open dose's status. A multi-schedule + * med whose cycle anchors on its earliest window keeps that window's cadence; + * when every schedule is PRN (no projected dose) the family is irrelevant + * because the caller only reads `currentDose` when a cycle is open. + */ +function soonestCadenceFamily( + schedules: ComplianceSchedule[], +): DoseCadenceFamily { + for (const s of schedules) { + if (s.scheduleType === "PRN") continue; + return doseCadenceFamily(s); + } + return "daily"; +} + +/** + * 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 + counts + day-streak over the short window. + * `taken` is the numerator the card renders next to the rate so two + * identical percentages stay distinguishable and trustworthy; `expected` + * is the rate denominator (taken + missed, EXCLUDING user skips) and + * `missed` the count counting against the rate. The card can render + * "26 / 30 · 87%" from `taken` / `expected`. + */ + short: { + rate: number; + taken: number; + expected: number; + missed: number; + streak: number; + }; + /** Compliance percentage + counts over the long window. */ + long: { rate: number; taken: number; expected: number; missed: number }; + /** + * v1.13.x Fix 4 — the open-cycle state, decoupled from the percentage + * rows so a between-doses sparse med never renders a scary red number. + */ + currentCycle: CurrentCycle; + /** + * v1.15.9 — the open cycle's per-dose {@link DoseStatus}, server-derived + * so the card renders its state (green take-window, overdue / heavily- + * overdue escalation) from one authority instead of re-deriving the window + * math client-side. `status` is `upcoming` when no dose is open yet and + * there is no projected next dose (PRN / paused / ended). `targetAt` is the + * open dose's target instant (echo of `currentCycle.nextDueAt`). + */ + currentDose: { status: DoseStatus; targetAt: Date | null }; +} + +/** + * 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[], + schedules: ComplianceSchedule[], + ctx: ComplianceMedicationContext, + options?: { now?: Date }, +): ComplianceDisplay { + const now = options?.now ?? new Date(); + // v1.13.x — thread the intake history so a ROLLING cadence's window + // selection scores the retrospective grid (each logged dose is a closed + // cycle) rather than the engine's single forward slot. Without this a + // weekly rolling med always falls through to the widest `[90, 365]` rung. + const { shortDays, longDays, expectedShort, expectedLong } = + selectComplianceWindows(schedules, ctx, { now, intakes: events }); + + const short = calculateCompliance( + events, + schedules, + shortDays, + ctx.createdAt, + { + now, + medicationContext: ctx, + }, + ); + const long = calculateCompliance(events, schedules, longDays, ctx.createdAt, { + now, + medicationContext: ctx, + }); + + // v1.13.x Fix 4 — the open-cycle descriptor, separable from the rates. + const currentCycle = buildCurrentCycle( + schedules, + ctx, + now, + expectedShort, + events, + ); + + // v1.15.9 — derive the open dose's per-dose status from one server + // authority so the card renders the take-window (green) / overdue / + // heavily-overdue escalation without re-spelling the window math. The + // cadence family comes from the SOONEST non-PRN schedule (the one the + // open cycle anchors on). `none` cycles (PRN / paused / ended) carry an + // `upcoming` status with a null target so the card stays calm. + const currentDose: { status: DoseStatus; targetAt: Date | null } = + currentCycle.nextDueAt + ? { + status: deriveDoseStatus( + currentCycle.nextDueAt, + soonestCadenceFamily(schedules), + now, + ), + targetAt: currentCycle.nextDueAt, + } + : { status: "upcoming", targetAt: null }; + + return { + shortDays, + longDays, + expectedShort, + expectedLong, + minStableDoses: MIN_STABLE_DOSES, + // `expected` is the rate denominator (taken + missed) so the card can + // render the trustworthy "taken / expected · rate%" triple; user skips + // are excluded from it by construction (they never enter `missed`). + short: { + rate: short.rate, + taken: short.taken, + expected: short.taken + short.missed, + missed: short.missed, + streak: short.streak, + }, + long: { + rate: long.rate, + taken: long.taken, + expected: long.taken + long.missed, + missed: long.missed, + }, + currentCycle, + currentDose, + }; +} + +/** + * Calculate compliance for a medication over a given period. + * + * Honours `daysOfWeek` (e.g. `"1"` for Mondays only) and + * `intervalWeeks` (bi-weekly, tri-weekly, …) by delegating to + * `buildCadenceTimeline`. The denominator is the number of dose slots + * the schedule actually emits inside the window — not + * `schedules.length * days`. A user on a weekly Monday-only schedule + * who takes every Monday for 30 days reports 100% adherence (4 of 4 + * Mondays) instead of the pre-v1.5.0 ~13% (4 of 30). + * + * Skipped doses are excluded from the denominator (deliberate user + * decision, not a compliance failure). When the window contains no + * expected doses (paused med, brand-new prescription, schedule that + * never fires in the window) the helper returns `rate: 100` so the + * empty state doesn't trip a "0% compliance" alarm downstream. + * + * Streak counts consecutive days, ending at `now`, where every + * expected dose for the day was taken (or skipped). Days with no + * expected dose (out-of-cadence weekdays, off-weeks on a bi-weekly + * schedule) advance the streak — the user gets credit for not + * breaking on non-scheduled days. + * + * @param events Recorded intake events from + * `MedicationIntakeEvent`. Only `scheduledFor`, + * `takenAt`, `skipped` are read. + * @param schedules Schedule rows for the medication. `daysOfWeek` + * is read when present; missing field is treated + * as daily. + * @param days Rolling-window size in days (typically 7 / 30 + * / 90). + * @param medicationCreatedAt + * When provided, days before the medication + * existed are excluded so they don't count as + * "missed". + * @param options.now Override for the rolling-window anchor. The + * fast-path Health-Score helper passes a fixed + * `now` so cached medication-compliance rates + * agree with the score's other pillars; default + * is `new Date()` (current wall-clock instant). + * v1.5.0 — added so the cadence-aware adapter + * can be driven deterministically from a caller + * that already pinned its own `now`. + */ +export function calculateCompliance( + events: IntakeEvent[], + schedules: ComplianceSchedule[], + days: number, + medicationCreatedAt?: Date, + options?: { now?: Date; medicationContext?: ComplianceMedicationContext }, +): ComplianceResult { + if (schedules.length === 0) { + return { + totalExpected: 0, + taken: 0, + skipped: 0, + missed: 0, + rate: 100, + streak: 0, + }; + } + + const now = options?.now ?? new Date(); + const DAY_MS = 24 * 60 * 60 * 1000; + const periodStart = new Date(now.getTime() - days * DAY_MS); + const effectiveStart = + medicationCreatedAt && medicationCreatedAt > periodStart + ? medicationCreatedAt + : periodStart; + const effectiveDays = Math.max( + 1, + Math.ceil((now.getTime() - effectiveStart.getTime()) / DAY_MS), + ); + + // v1.15.18 — when a medication context is supplied, the count fields + // (taken / skipped / missed / rate) come from the UNIFIED dose-history + // ledger tally, NOT the ±12h `pairDoses` proximity matcher. This is the + // keystone unification: the percentage is a tally over the exact same + // ledger the history view renders, so the two can never contradict (a + // dose can't read "taken late" in the % while the ledger calls it + // "ad-hoc"). The numerator is on-time + late takes (a late dose still + // counts as taken); user skips + ad-hoc top-ups + PRN groups are excluded + // from the denominator. The streak still walks the cadence timeline below + // (its day-grain "every dose taken or skipped" rule is unchanged). + // + // Context-less callers (pure-math fixtures, pre-v1.7 surfaces) keep the + // legacy timeline tally byte-stable — they have no engine context to mint + // bands from. + const ledgerCtx = options?.medicationContext; + let ledgerCounts: { + taken: number; + skipped: number; + missed: number; + rate: number; + } | null = null; + if (ledgerCtx) { + const tally = tallyComplianceFromLedger( + events, + schedules, + ledgerCtx, + effectiveStart, + now, + now, + ); + ledgerCounts = { + taken: tally.taken, + skipped: tally.skipped, + missed: tally.missed, + rate: tally.rate, + }; + } + + // Normalise the schedule shape so legacy callers that pass only + // `{ windowStart, windowEnd }` still produce a usable `daysOfWeek` + // field (treated as daily by the cadence parser). + const normalisedSchedules: ScheduleLike[] = schedules.map((s, i) => ({ + id: `compliance-${i}`, + windowStart: s.windowStart, + windowEnd: s.windowEnd, + daysOfWeek: s.daysOfWeek ?? null, + // v1.7.0 SB-SCHED-2 — thread the canonical-engine fields so the + // cadence expander can delegate to `occurrencesBetween` when a + // medication context is supplied. Undefined fields collapse to the + // legacy weekday path inside `expandScheduleSlots`. + 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, + })); + + // v1.7.0 SB-SCHED-2 — build the engine context once per medication. + // When the caller supplies it, the timeline routes through the + // canonical engine (RRULE / rolling / one-shot / PRN / cyclic); + // otherwise the legacy weekday walker stays in force. + const ctx = options?.medicationContext; + const engineCtx: CadenceEngineContext | undefined = ctx + ? { + startsOn: ctx.startsOn, + endsOn: ctx.endsOn, + oneShot: ctx.oneShot, + createdAt: ctx.createdAt, + lastIntakeAt: ctx.lastIntakeAt, + timeZone: ctx.timeZone, + scheduleRevisions: ctx.scheduleRevisions, + } + : undefined; + + // Match events against the same slot grid the cadence chart uses. + // The chart's pairing radius is ±12 h so a late-by-six-hours dose + // still attaches to the right slot instead of double-counting as + // "missed + extra". + const normalisedEvents: IntakeEventLike[] = events + .filter((e) => e.scheduledFor >= effectiveStart && e.scheduledFor <= now) + .map((e) => ({ + scheduledFor: e.scheduledFor, + takenAt: e.takenAt, + skipped: e.skipped, + // v1.15.9 — carry the forgotten-dose flag into the timeline so an + // auto-missed slot pairs to a `missed` status (counts against the + // rate) rather than a neutral `skipped`. + autoMissed: e.autoMissed ?? false, + })); + + // v1.13.x — ROLLING retrospective expansion. A rolling cadence + // (`rollingIntervalDays`, the canonical GLP-1 "every N days" shape) is + // forward-only in the engine: `expandRolling` emits at most the single + // immediately-next slot, so a historical compliance window saw either + // zero expected slots (vacuous 100%) or one overdue slot (hard 0%) — + // never the true multi-dose adherence over the trailing window. When a + // medication context is threaded AND any schedule is rolling, route the + // rolling schedules through the retrospective builder so each logged dose + // is one satisfied expected slot (plus synthesized misses for skipped + // whole cycles + a past-due forward slot). Non-rolling schedules keep the + // forward-only engine path; both share `buildCadenceTimeline` so the + // numerator and denominator agree (the v1.7.3 B15 convergence rule). + const hasRolling = schedules.some( + (s) => s.rollingIntervalDays != null && s.rollingIntervalDays > 0, + ); + const retro = + engineCtx && hasRolling + ? { intakeInstants: rollingIntakeInstants(events, now), now } + : undefined; + + const timeline = buildCadenceTimeline( + normalisedSchedules, + normalisedEvents, + now, + effectiveDays, + medicationCreatedAt ?? effectiveStart, + engineCtx?.timeZone, + engineCtx, + retro, + ); + + // v1.15.18 — the count fields come from the unified ledger tally when a + // medication context was supplied (the keystone unification), and from the + // legacy timeline tally otherwise (context-less pure-math callers). The + // streak below always walks the timeline — its day-grain rule is orthogonal + // to the per-dose attribution and stays byte-stable for every caller. + let taken: number; + let skipped: number; + let missed: number; + let rate: number; + if (ledgerCounts) { + ({ taken, skipped, missed, rate } = ledgerCounts); + } else { + taken = 0; + skipped = 0; + missed = 0; + for (const slot of timeline) { + if (slot.status === "taken") taken++; + else if (slot.status === "skipped") skipped++; + else if (slot.status === "missed") missed++; + // `upcoming` slots (future window) are excluded from every counter + // so a partial day at the head of the window doesn't pollute the rate. + } + // Skipped doses are excluded from the denominator — they represent a + // deliberate user decision rather than a missed dose. + const denom = taken + missed; + rate = denom > 0 ? Math.min(100, Math.round((taken / denom) * 100)) : 100; + } + + const totalExpected = taken + skipped + missed; + + // Streak: consecutive days, ending today, where every expected dose + // was taken or skipped. Days with no expected dose advance the + // streak — out-of-cadence days are not failures. Delegated to the + // shared `streaksFromTimeline` so the analytics streak and the + // detail-page chip streak agree on every dose AND so the day keys are + // computed in the USER's IANA timezone, not the host's. The prior + // host-tz `getFullYear/getMonth/getDate` walk drifted off by a day + // whenever the server clock's zone differed from the user's — the + // timeline `slot.day` is already minted in the user zone, so the + // walk has to match it. The window is `effectiveDays` ending at + // `now`, which starts no earlier than `effectiveStart` (= the later + // of the period start and the medication's creation) — so days + // before the medication existed are never iterated, preserving the + // old `cursor <= medicationCreatedAt` break by construction. + const { current: streak } = streaksFromTimeline( + timeline, + now, + effectiveDays, + engineCtx?.timeZone, + ); + + return { totalExpected, taken, skipped, missed, rate, streak }; +} + +/** + * The per-medication compliance payload computed from ONE shared expansion + * pass. `ledgerRows` is the unified dose-history ledger over + * `[ledgerFrom, now]`; the caller carves the 90-day heatmap out of it by + * filtering on `row.at`. + */ +export interface MedicationComplianceBundle { + compliance7: ComplianceResult; + compliance30: ComplianceResult; + complianceDisplay: ComplianceDisplay; + /** Unified ledger rows over `[ledgerFrom, now]`, chronological. */ + ledgerRows: DoseHistoryRow[]; + /** Lower bound of the mint window (clamped to the medication's creation). */ + ledgerFrom: Date; +} + +/** + * Build every block of the per-medication compliance response from a single + * band-expansion pass. + * + * The historical composition called {@link calculateCompliance} four times + * (7 / 30 / short / long), {@link selectComplianceWindows} (up to four more + * occurrence expansions for the rung probes) and a separate 90-day heatmap + * mint — five-plus full band expansions per request. This builder instead: + * + * 1. mints the bands + reconstructs the ledger ONCE over + * `[max(createdAt, now − 365 d), now]` (every served window is a + * suffix of that range and ends at `now`, so a sub-window tally is a + * filter over `row.at`, not a re-expansion); + * 2. builds ONE cadence timeline over the same range for the per-window + * streaks ({@link streaksFromTimeline} bounds its day-walk by the + * requested window, so the wider timeline serves every window); + * 3. walks the {@link COMPLIANCE_WINDOW_LADDER} on the ledger's slot-row + * counts (one band per engine occurrence, so the counts match the + * historical `expectedSlotsBetween` probes). + * + * The returned blocks carry the exact public shapes the route has always + * served — only the number of expansion passes changed. + */ +export function buildMedicationComplianceBundle( + events: IntakeEvent[], + schedules: ComplianceSchedule[], + ctx: ComplianceMedicationContext, + now: Date, +): MedicationComplianceBundle { + const ledgerPeriodStart = new Date( + now.getTime() - COMPLIANCE_LEDGER_WINDOW_DAYS * ONE_DAY_MS, + ); + const ledgerFrom = + ctx.createdAt.getTime() > ledgerPeriodStart.getTime() + ? ctx.createdAt + : ledgerPeriodStart; + + const hasSchedules = schedules.length > 0; + const ledgerRows = hasSchedules + ? buildComplianceLedgerRows(events, schedules, ctx, ledgerFrom, now, now) + : []; + + // ONE cadence timeline over the full ledger window. Each per-window + // streak below walks only its own trailing `effectiveDays`, so sharing + // the wide timeline reproduces the per-window builds. + const fullDays = Math.max( + 1, + Math.ceil((now.getTime() - ledgerFrom.getTime()) / ONE_DAY_MS), + ); + const timeline = hasSchedules + ? buildTimelineForWindow(events, schedules, ctx, now, ledgerFrom, fullDays) + : []; + + const resultForWindow = (days: number): ComplianceResult => { + if (!hasSchedules) { + // Mirrors `calculateCompliance`'s empty-schedule short-circuit. + return { + totalExpected: 0, + taken: 0, + skipped: 0, + missed: 0, + rate: 100, + streak: 0, + }; + } + const periodStart = new Date(now.getTime() - days * ONE_DAY_MS); + const effectiveStart = + ctx.createdAt.getTime() > periodStart.getTime() + ? ctx.createdAt + : periodStart; + const effectiveDays = Math.max( + 1, + Math.ceil((now.getTime() - effectiveStart.getTime()) / ONE_DAY_MS), + ); + const tally = tallyLedgerRows(ledgerRows, { + from: effectiveStart, + to: now, + }); + const { current: streak } = streaksFromTimeline( + timeline, + now, + effectiveDays, + ctx.timeZone, + ); + return { + totalExpected: tally.taken + tally.skipped + tally.missed, + taken: tally.taken, + skipped: tally.skipped, + missed: tally.missed, + rate: tally.rate, + streak, + }; + }; + + // Window-ladder selection from the ledger's slot rows. A slot row is one + // minted band, and the minter emits one band per engine occurrence, so + // counting slot rows over a trailing window equals the historical + // `expectedSlotsBetween(...).length` probe for that window. + const expectedCache = new Map(); + const expectedOver = (days: number): number => { + const hit = expectedCache.get(days); + if (hit !== undefined) return hit; + const from = Math.max( + ctx.createdAt.getTime(), + now.getTime() - days * ONE_DAY_MS, + ); + let count = 0; + for (const row of ledgerRows) { + if (row.kind !== "slot") continue; + const t = row.at.getTime(); + if (t >= from && t <= now.getTime()) count++; + } + expectedCache.set(days, count); + return count; + }; + + let selection: ComplianceWindowSelection | null = null; + for (const [shortDays, longDays] of COMPLIANCE_WINDOW_LADDER) { + const expectedShort = expectedOver(shortDays); + const expectedLong = expectedOver(longDays); + if (expectedShort >= MIN_STABLE_DOSES && expectedLong >= MIN_STABLE_DOSES) { + selection = { shortDays, longDays, expectedShort, expectedLong }; + break; + } + } + if (!selection) { + // No rung cleared the floor — fall back to the widest rung, exactly + // like `selectComplianceWindows`. + const [shortDays, longDays] = + COMPLIANCE_WINDOW_LADDER[COMPLIANCE_WINDOW_LADDER.length - 1]; + selection = { + shortDays, + longDays, + expectedShort: expectedOver(shortDays), + expectedLong: expectedOver(longDays), + }; + } + + const compliance7 = resultForWindow(7); + const compliance30 = resultForWindow(30); + const short = resultForWindow(selection.shortDays); + const long = resultForWindow(selection.longDays); + + const currentCycle = buildCurrentCycle( + schedules, + ctx, + now, + selection.expectedShort, + events, + ); + const currentDose: { status: DoseStatus; targetAt: Date | null } = + currentCycle.nextDueAt + ? { + status: deriveDoseStatus( + currentCycle.nextDueAt, + soonestCadenceFamily(schedules), + now, + ), + targetAt: currentCycle.nextDueAt, + } + : { status: "upcoming", targetAt: null }; + + const complianceDisplay: ComplianceDisplay = { + shortDays: selection.shortDays, + longDays: selection.longDays, + expectedShort: selection.expectedShort, + expectedLong: selection.expectedLong, + minStableDoses: MIN_STABLE_DOSES, + short: { + rate: short.rate, + taken: short.taken, + expected: short.taken + short.missed, + missed: short.missed, + streak: short.streak, + }, + long: { + rate: long.rate, + taken: long.taken, + expected: long.taken + long.missed, + missed: long.missed, + }, + currentCycle, + currentDose, + }; + + return { + compliance7, + compliance30, + complianceDisplay, + ledgerRows, + ledgerFrom, + }; +} + +/** + * The cadence-timeline construction from {@link calculateCompliance}, + * extracted so {@link buildMedicationComplianceBundle} can build it once + * over the full ledger window instead of once per served sub-window. Same + * normalisation, same engine context, same rolling-retrospective routing. + */ +function buildTimelineForWindow( + events: IntakeEvent[], + schedules: ComplianceSchedule[], + ctx: ComplianceMedicationContext, + now: Date, + effectiveStart: Date, + effectiveDays: number, +): ReturnType { + const normalisedSchedules: ScheduleLike[] = schedules.map((s, i) => ({ + id: `compliance-${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 engineCtx: CadenceEngineContext = { + startsOn: ctx.startsOn, + endsOn: ctx.endsOn, + oneShot: ctx.oneShot, + createdAt: ctx.createdAt, + lastIntakeAt: ctx.lastIntakeAt, + timeZone: ctx.timeZone, + scheduleRevisions: ctx.scheduleRevisions, + }; + + const normalisedEvents: IntakeEventLike[] = events + .filter((e) => e.scheduledFor >= effectiveStart && e.scheduledFor <= now) + .map((e) => ({ + scheduledFor: e.scheduledFor, + takenAt: e.takenAt, + skipped: e.skipped, + autoMissed: e.autoMissed ?? false, + })); + + const hasRolling = schedules.some( + (s) => s.rollingIntervalDays != null && s.rollingIntervalDays > 0, + ); + const retro = hasRolling + ? { intakeInstants: rollingIntakeInstants(events, now), now } + : undefined; + + return buildCadenceTimeline( + normalisedSchedules, + normalisedEvents, + now, + effectiveDays, + ctx.createdAt, + ctx.timeZone, + engineCtx, + retro, + ); +} diff --git a/src/lib/analytics/compliance/dose-status.ts b/src/lib/analytics/compliance/dose-status.ts new file mode 100644 index 000000000..3b4052a1e --- /dev/null +++ b/src/lib/analytics/compliance/dose-status.ts @@ -0,0 +1,153 @@ +// Extracted from the former single-file `compliance.ts`. See `../compliance.ts` +// (the barrel) for the module map. Pure move — no logic changes. + +import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults"; +import type { ComplianceSchedule } from "./types"; + +/** + * v1.15.9 — cadence-aware per-dose grace/miss window model. + * + * A medication's dose either is on-time, is still takeable but late + * (counts when taken), or has slipped past the point where a self-hoster + * could plausibly still take it (a MISS). The cutoffs differ by cadence: + * an intraday/daily dose has tight hour-scale windows; a weekly/rolling + * injectable (the GLP-1 case) follows the clinical "take it within 4 days, + * otherwise skip to the next scheduled dose" rule. + * + * The defaults below are centralised named constants so a future revision + * can expose them per-medication without re-deriving the boundaries. Make + * it correct first; configurability later. + * + * DAILY / INTRADAY (minute-scale windows around the target instant): + * - on-time: target − 60 min … target + 60 min + * - overdue: target + 60 min … target + 240 min (still takeable) + * - missed: > target + 240 min, OR the next dose's on-time window + * starts first (whichever is sooner — adjacent doses never + * overlap). + * + * WEEKLY / ROLLING injectable (day-scale windows; the 4-day clinical rule): + * - on-time: target day ± 1 day + * - overdue: up to target + 4 days (late-but-counts) + * - missed: > target + 4 days + */ +export { DOSE_WINDOW_DEFAULTS }; + +/** + * v1.15.9 — the derived per-dose state the medication card renders. + * + * - `on_time_window` — due now, inside the on-time window (green). + * - `overdue` — past on-time, before the miss cutoff (still + * takeable; the card escalates the tint as it nears + * the cutoff — "stark überfällig"). + * - `missed` — past the miss cutoff, never acted on. + * - `taken_on_time` — taken inside the on-time window. + * - `taken_late` — taken in the overdue window (still counts). + * - `skipped` — a deliberate user skip (excluded from the rate). + * - `upcoming` — the on-time window has not opened yet. + * + * PRN doses are never scheduled and so never produce a status — callers + * exclude them before calling {@link deriveDoseStatus}. + */ +export type DoseStatus = + | "upcoming" + | "on_time_window" + | "overdue" + | "missed" + | "taken_on_time" + | "taken_late" + | "skipped"; + +/** Cadence family the window math keys off. */ +export type DoseCadenceFamily = "daily" | "weekly"; + +const HOUR_MS = 60 * 60 * 1000; +const ONE_DAY_MS = 24 * HOUR_MS; + +/** + * v1.15.9 — derive a single dose's {@link DoseStatus} from its target + * instant, the cadence family, `now`, and (optionally) when it was taken / + * whether the user skipped it. Pure and deterministic. + * + * `nextDoseAt` (when supplied) caps a daily dose's miss cutoff at the next + * dose's on-time window start, so two adjacent intraday doses never claim + * overlapping overdue windows — the earlier dose flips to `missed` the + * moment the later one comes due if that is sooner than its own cutoff. + * + * The defaults come from {@link DOSE_WINDOW_DEFAULTS}; callers may override + * per-medication once that configurability lands. + */ +export function deriveDoseStatus( + targetAt: Date, + cadence: DoseCadenceFamily, + now: Date, + options?: { + takenAt?: Date | null; + skipped?: boolean; + nextDoseAt?: Date | null; + windows?: Partial; + }, +): DoseStatus { + if (options?.skipped) return "skipped"; + + const w = { ...DOSE_WINDOW_DEFAULTS, ...(options?.windows ?? {}) }; + const target = targetAt.getTime(); + + let onTimeStart: number; + let onTimeEnd: number; + let overdueEnd: number; + if (cadence === "weekly") { + onTimeStart = target - w.weeklyOnTimeDays * ONE_DAY_MS; + onTimeEnd = target + w.weeklyOnTimeDays * ONE_DAY_MS; + overdueEnd = target + w.weeklyOverdueDays * ONE_DAY_MS; + } else { + onTimeStart = target - w.dailyOnTimeMinutes * 60_000; + onTimeEnd = target + w.dailyOnTimeMinutes * 60_000; + overdueEnd = onTimeEnd + w.dailyOverdueMinutes * 60_000; + } + + // Never let the miss cutoff bleed into the next dose's on-time window. + if (options?.nextDoseAt) { + const nextOnTimeStart = + cadence === "weekly" + ? options.nextDoseAt.getTime() - w.weeklyOnTimeDays * ONE_DAY_MS + : options.nextDoseAt.getTime() - w.dailyOnTimeMinutes * 60_000; + if (nextOnTimeStart < overdueEnd) overdueEnd = nextOnTimeStart; + } + + const takenAt = options?.takenAt ?? null; + if (takenAt) { + const t = takenAt.getTime(); + return t <= onTimeEnd ? "taken_on_time" : "taken_late"; + } + + const n = now.getTime(); + if (n < onTimeStart) return "upcoming"; + if (n <= onTimeEnd) return "on_time_window"; + if (n <= overdueEnd) return "overdue"; + return "missed"; +} + +/** + * v1.15.9 — classify a schedule's cadence into the {@link DoseCadenceFamily} + * the window model uses. A rolling cadence, or any RRULE / legacy weekly + * cadence that emits less often than daily, is `weekly` (day-scale windows + + * the 4-day rule); everything denser (daily, intraday multi-dose) is + * `daily` (minute-scale windows). + */ +export function doseCadenceFamily( + schedule: ComplianceSchedule, +): DoseCadenceFamily { + if ( + schedule.rollingIntervalDays != null && + schedule.rollingIntervalDays >= 2 + ) { + return "weekly"; + } + const rrule = schedule.rrule ?? ""; + if (/FREQ=(WEEKLY|MONTHLY|YEARLY)/i.test(rrule)) return "weekly"; + // A legacy daysOfWeek restriction to specific weekdays (not every day) + // dosed once a day is still effectively daily-cadence on the days it + // fires; the per-slot window is minute-scale either way. Only the rolling + // / calendar-sparse shapes use the day-scale 4-day rule. + return "daily"; +} diff --git a/src/lib/analytics/compliance/ledger.ts b/src/lib/analytics/compliance/ledger.ts new file mode 100644 index 000000000..308f6b2de --- /dev/null +++ b/src/lib/analytics/compliance/ledger.ts @@ -0,0 +1,379 @@ +// Extracted from the former single-file `compliance.ts`. See `../compliance.ts` +// (the barrel) for the module map. Pure move — no logic changes. + +import type { SlotBand } from "@/lib/medications/scheduling/attribution"; +import type { + BandMinterMedication, + DoseWindowConfig, +} from "@/lib/medications/scheduling/band-minter"; +import { buildBandsForSchedulesWithEras } from "@/lib/medications/scheduling/schedule-eras"; +import { + reconstructDoseHistory, + type DoseHistoryRow, + type HistoryIntake, +} from "@/lib/medications/scheduling/dose-history"; +import { userDayKey } from "@/lib/tz/resolver"; +import type { + ComplianceMedicationContext, + ComplianceSchedule, + IntakeEvent, +} from "./types"; +import { + intakeInstantsAtOrBefore, + toCanonicalSchedule, + toRecurrenceCtx, +} from "./adapters"; + +/** + * v1.15.18 — the unified compliance tally over the dose-history ledger. + * + * THE single source of the medication compliance %. It builds the + * cadence-aware `SlotBand[]` per schedule (the shared band minter — every + * cadence: daily, fixed-weekday, rolling-retrospective, one-shot, cyclic, + * PRN), reconstructs the ONE dose-history ledger + * (`reconstructDoseHistory`), and tallies it so the percentage and the + * history view are mathematically incapable of contradicting each other + * (audit CRITICAL-2). It replaces the ±12h `pairDoses` proximity matcher + * that `calculateCompliance` used for the engine-routed path. + * + * The tally follows the adherence literature's TAKING-vs-TIMING split: + * - numerator (taken) = `taken_on_time` + `taken_late` — a late dose is + * still a taken dose; "late" is NOT collapsed into "missed"; + * - denominator = taken + `missed`; + * - EXCLUDED from the denominator: `skipped` (deliberate user decision), + * `ad_hoc` (off-schedule top-up — no defensible slot), `upcoming` (the + * window hasn't opened), and ENTIRE PRN groups (`hasExpectedSlots:false` + * — PRN has no defensible denominator per the literature); + * - the rate is capped at 100% (extra doses never inflate it). + * + * The on-time / late split is surfaced separately so a caller can show both + * the TAKING rate (the headline) and the TIMING quality. + * + * Pure / synchronous: the bands are minted from pre-fetched schedules + + * intake instants; no DB access. + */ +export interface LedgerComplianceTally { + /** Doses taken (on-time + late). The TAKING-adherence numerator. */ + taken: number; + /** Of `taken`, the count inside the on-time band. */ + takenOnTime: number; + /** Of `taken`, the count in the late tail (still counts as taken). */ + takenLate: number; + /** Expected doses never acted on past their miss cutoff. */ + missed: number; + /** Deliberate user skips — excluded from the denominator. */ + skipped: number; + /** Off-schedule intakes (PRN groups + ad-hoc rows) — excluded. */ + adHoc: number; + /** taken + missed (the rate denominator). */ + denominator: number; + /** round(100 · taken / denominator), capped at 100. 100 on empty. */ + rate: number; +} + +export function tallyComplianceFromLedger( + events: IntakeEvent[], + schedules: ComplianceSchedule[], + ctx: ComplianceMedicationContext, + from: Date, + to: Date, + now: Date, + windowConfig?: DoseWindowConfig, +): LedgerComplianceTally { + const rows = buildComplianceLedgerRows( + events, + schedules, + ctx, + from, + to, + now, + windowConfig, + ); + return tallyLedgerRows(rows); +} + +/** + * Mint the cadence-aware bands over `[from, to]` and reconstruct the ONE + * unified dose-history ledger for them. This is the expansion pass behind + * {@link tallyComplianceFromLedger}, extracted so a caller that needs + * several trailing sub-windows (7-day / 30-day / display / heatmap, all + * ending at `now`) can mint the bands ONCE over the widest window and tally + * each sub-window from the same rows via {@link tallyLedgerRows} instead of + * re-expanding per window. + * + * Pure / synchronous: bands come from pre-fetched schedules + intake + * instants; no DB access. + */ +export function buildComplianceLedgerRows( + events: IntakeEvent[], + schedules: ComplianceSchedule[], + ctx: ComplianceMedicationContext, + from: Date, + to: Date, + now: Date, + windowConfig?: DoseWindowConfig, +): DoseHistoryRow[] { + const medication: BandMinterMedication = { + id: "compliance-tally", + startsOn: ctx.startsOn, + endsOn: ctx.endsOn, + oneShot: ctx.oneShot, + createdAt: ctx.createdAt, + }; + const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-tally"); + const canonicalSchedules = schedules.map((s, i) => { + const canonical = toCanonicalSchedule(s, `compliance-tally-${i}`); + // A legacy daily schedule carries only `windowStart` (no `timesOfDay`, + // no rrule, no rolling, no `daysOfWeek`). The engine's `expandLegacy` + // reads that as "every day at windowStart", but the band minter's + // cadence-detection gate needs an explicit time signal — surface + // `windowStart` as the single time-of-day so the daily band is minted. + if ( + canonical.timesOfDay.length === 0 && + canonical.rrule === null && + canonical.rollingIntervalDays === null && + canonical.scheduleType !== "PRN" && + !ctx.oneShot + ) { + return { ...canonical, timesOfDay: [canonical.windowStart] }; + } + return canonical; + }); + // Rolling cadences anchor their retrospective grid AT each logged intake; + // the bands need every non-skipped take in (or before) the window. Reuse + // the same instant-extraction the legacy rolling path uses so the + // numerator and denominator are built from one expansion. + const intakeInstants = intakeInstantsAtOrBefore( + events.map((e) => ({ takenAt: e.takenAt, skipped: e.skipped })), + to, + ); + + // v1.16.3 — era-aware mint: archived eras band with THEIR schedules. + const groups = buildBandsForSchedulesWithEras({ + medication, + schedules: canonicalSchedules, + revisions: ctx.scheduleRevisions ?? [], + ctx: recurrenceCtx, + userTz: ctx.timeZone, + range: { from, to }, + now, + windowConfig, + intakeInstants, + }); + + // The ledger reads `scheduledFor` + `takenAt` + skip/auto-miss flags. The + // bands already partition the slot space, so the union of every + // non-PRN schedule's bands is fed to ONE reconstruction — `reconstruct + // DoseHistory` claims each slot by at most one intake, so pooling the + // (already correctly-minted) bands is safe. PRN groups (no expected + // slots) contribute no bands; their intakes surface as ad-hoc and are + // excluded from the denominator, exactly as the literature requires. + const bands: SlotBand[] = []; + for (const g of groups) { + if (g.hasExpectedSlots) bands.push(...g.bands); + } + + const intakes: HistoryIntake[] = events + .filter((e) => e.scheduledFor >= from && e.scheduledFor <= to) + .map((e) => ({ + scheduledFor: e.scheduledFor, + takenAt: e.takenAt, + skipped: e.skipped, + autoMissed: e.autoMissed ?? false, + // v1.15.20 — a pinned take binds by anchor and tallies as taken-late + // (slot served, no on-time gain) instead of ad_hoc + missed. + pinned: e.attributionSource === "USER_PIN", + })); + + const rows = reconstructDoseHistory(bands, intakes, now); + + // v1.25 H-MED1 — drop expected dose slots whose anchor falls inside a + // pause interval. While a medication is paused no dose is expected, so a + // slot minted across the paused window must never count as "missed" (the + // denominator-inflating status). Only `slot` rows are dropped, and only + // those whose status feeds the tally (taken / missed) — skip / ad-hoc / + // upcoming rows are already excluded from the denominator, so dropping + // them here would be a redundant double-exclusion. An open era + // (`resumedAt === null`) runs to `now`. + const pauseEras = ctx.pauseEras; + if (pauseEras && pauseEras.length > 0) { + const isInPause = (at: Date): boolean => { + const t = at.getTime(); + for (const era of pauseEras) { + const start = era.pausedAt.getTime(); + const end = (era.resumedAt ?? now).getTime(); + if (t >= start && t < end) return true; + } + return false; + }; + return rows.filter((row) => { + if (row.kind !== "slot") return true; + if ( + row.status !== "taken_on_time" && + row.status !== "taken_late" && + row.status !== "missed" + ) { + return true; + } + return !isInPause(row.at); + }); + } + + return rows; +} + +/** + * Tally pre-built ledger rows into the {@link LedgerComplianceTally} + * counters. When `window` is supplied only rows whose instant (`row.at`) + * falls inside `[window.from, window.to]` (inclusive) are counted — that is + * how a sub-window tally is carved out of a wider single-pass ledger. The + * window-less call tallies every row, byte-identical to the historical + * {@link tallyComplianceFromLedger} behaviour. + */ +export function tallyLedgerRows( + rows: DoseHistoryRow[], + window?: { from: Date; to: Date }, +): LedgerComplianceTally { + let takenOnTime = 0; + let takenLate = 0; + let missed = 0; + let skipped = 0; + let adHoc = 0; + for (const row of rows) { + if (window) { + const t = row.at.getTime(); + if (t < window.from.getTime() || t > window.to.getTime()) continue; + } + switch (row.status) { + case "taken_on_time": + takenOnTime++; + break; + case "taken_late": + takenLate++; + break; + case "missed": + missed++; + break; + case "skipped": + skipped++; + break; + case "ad_hoc": + adHoc++; + break; + // `upcoming` slots are future / still-takeable → excluded from every + // counter so a partial head-of-window day never pollutes the rate. + } + } + + const taken = takenOnTime + takenLate; + const denominator = taken + missed; + const rate = + denominator > 0 + ? Math.min(100, Math.round((taken / denominator) * 100)) + : 100; + + return { + taken, + takenOnTime, + takenLate, + missed, + skipped, + adHoc, + denominator, + rate, + }; +} + +/** + * Widest trailing window the single-pass compliance ledger has to cover: + * the top rung of {@link COMPLIANCE_WINDOW_LADDER} (365 days). Every + * sub-window the per-medication compliance endpoint serves (7 / 30 / + * cadence-scaled display rows / 90-day heatmap) is a suffix of it. + */ +export const COMPLIANCE_LEDGER_WINDOW_DAYS = 365; + +/** + * One day of the cadence-aware compliance series: the day's compliance % + * over the doses the schedule actually expected that day, plus the raw + * taken / missed counts behind it. + */ +export interface DailyComplianceRate { + /** The day's anchor instant (the first expected slot of the day). */ + date: Date; + /** round(100 · taken / (taken + missed)), capped at 100. */ + rate: number; + /** Doses taken (on-time + late) on the day. */ + taken: number; + /** Expected doses never acted on past their miss cutoff, on the day. */ + missed: number; +} + +/** + * v1.18.0 — collapse unified dose-history ledger rows into a cadence-aware + * per-day compliance series. + * + * Only days the schedule's cadence actually expected a dose produce a point: + * the series is grouped over the ledger's scheduled `slot` rows, so an + * off-cadence weekday (a weekly Monday-only med on a Tuesday) or an off-week + * (the off-week of a bi-weekly schedule) emits no point at all and therefore + * can never be read as a 0% "miss". Each day's rate uses the SAME + * taken / (taken + missed) tally — and the SAME on-time-plus-late numerator, + * skip / ad-hoc / upcoming exclusions — that {@link tallyLedgerRows} applies + * to `compliance7` / `compliance30`, so the per-day series and the window + * rates are computed from one ledger and cannot contradict each other. + * + * `ad_hoc` rows (off-schedule top-ups, no defensible slot) and `upcoming` + * rows (the window hasn't opened) are dropped before grouping, exactly as the + * window tally excludes them. A day whose only ledger rows are skips yields no + * point (zero denominator) rather than a misleading 0%. + * + * Days are bucketed in the USER's timezone (`tz`). Passing the caller's real + * zone keeps an evening dose on the local day it was actually due: a fixed + * Berlin key filed a `20:00 America/Los_Angeles` dose (04:00 UTC next day) + * into the following Berlin day, splitting one local day's two doses across + * two buckets and skewing every per-day rate for non-Berlin users. + */ +export function dailyComplianceRatesFromLedger( + ledgerRows: DoseHistoryRow[], + tz: string, +): DailyComplianceRate[] { + const byDay = new Map< + string, + { taken: number; missed: number; date: Date } + >(); + + for (const row of ledgerRows) { + // Only scheduled slots have a defensible denominator; ad-hoc top-ups are + // excluded from the rate exactly as `tallyLedgerRows` excludes them. + if (row.kind !== "slot") continue; + const isTaken = + row.status === "taken_on_time" || row.status === "taken_late"; + const isMissed = row.status === "missed"; + // `skipped` (deliberate) and `upcoming` (window not open) advance neither + // counter — they never enter the day's denominator. + if (!isTaken && !isMissed) continue; + + const dayKey = userDayKey(row.at, tz); + const bucket = byDay.get(dayKey) ?? { taken: 0, missed: 0, date: row.at }; + if (isTaken) bucket.taken += 1; + else bucket.missed += 1; + // Keep the earliest slot instant of the day as the point's anchor. + if (row.at.getTime() < bucket.date.getTime()) bucket.date = row.at; + byDay.set(dayKey, bucket); + } + + return Array.from(byDay.values()) + .sort((a, b) => a.date.getTime() - b.date.getTime()) + .map((day) => { + const denom = day.taken + day.missed; + return { + date: day.date, + rate: + denom > 0 + ? Math.min(100, Math.round((day.taken / denom) * 100)) + : 100, + taken: day.taken, + missed: day.missed, + }; + }); +} diff --git a/src/lib/analytics/compliance/occurrences.ts b/src/lib/analytics/compliance/occurrences.ts new file mode 100644 index 000000000..4b572e5c1 --- /dev/null +++ b/src/lib/analytics/compliance/occurrences.ts @@ -0,0 +1,127 @@ +// Extracted from the former single-file `compliance.ts`. See `../compliance.ts` +// (the barrel) for the module map. Pure move — no logic changes. + +import { occurrencesAcrossEras } from "@/lib/medications/scheduling/schedule-eras"; +import type { Occurrence } from "@/lib/medications/scheduling/recurrence"; +import type { + ComplianceIntakeInstant, + ComplianceMedicationContext, + ComplianceSchedule, +} from "./types"; +import { + expandComplianceOccurrences, + intakeInstantsAtOrBefore, + toCanonicalSchedule, + toRecurrenceCtx, +} from "./adapters"; + +/** + * v1.7.0 item 5 — count the expected dose slots a medication's schedules + * emit inside `[dayStart, dayEnd)`, routed through the canonical engine. + * Powers the per-day `due` / `expectedCount` fields on the per-med + * compliance payload so iOS history renders a "missed" mark only on days + * the schedule actually expected a dose (not off-weeks / non-matching + * weekdays / PRN days). + * + * v1.13.x — pass `intakes` so a ROLLING schedule routes through the + * retrospective grid (each logged dose is a `due` day, plus skipped-cycle + * misses + a past-due forward slot) instead of the engine's single forward + * slot. Omitting `intakes` keeps the forward-only behaviour for callers that + * don't have the intake history at hand. + */ +export function expectedSlotCountForDay( + schedules: ComplianceSchedule[], + dayStart: Date, + dayEnd: Date, + ctx: ComplianceMedicationContext, + intakes?: ComplianceIntakeInstant[], +): number { + const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-daily"); + const now = new Date(); + const retro = + intakes && schedules.some((s) => s.rollingIntervalDays != null) + ? { intakeInstants: intakeInstantsAtOrBefore(intakes, now), now } + : undefined; + // v1.16.3 — era-aware: a past day counts the slots of the schedule that + // was live THEN. With no revisions the single live era expands exactly + // the per-schedule loop this used to run. + return occurrencesAcrossEras( + { + from: dayStart, + // occurrencesBetween is inclusive of both ends; subtract 1 ms so a + // slot exactly at the next day's midnight doesn't double-count. + to: new Date(dayEnd.getTime() - 1), + }, + ctx.scheduleRevisions ?? [], + schedules.map((s, i) => toCanonicalSchedule(s, `compliance-daily-${i}`)), + (schedule, eraFrom, eraTo) => + expandComplianceOccurrences( + schedule, + recurrenceCtx, + eraFrom, + eraTo, + retro, + ), + { oneShot: ctx.oneShot }, + ).length; +} + +/** + * 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. + * + * v1.8.6 QA — the window lower bound is clamped to `ctx.createdAt`. The + * legacy weekday walker floors on `startsOn` but not on `createdAt`, so a + * brand-new daily med queried over a 30-day window would otherwise emit + * slots for every day before it existed (7/30 expected on a 2-day-old med). + * Clamping here keeps the expected-dose denominator — and the window + * selection that reads it — honest about the medication's real age. The + * displayed rates clamp independently via `calculateCompliance`'s + * `medicationCreatedAt` argument, so this only fixes the slot counts. + */ +export function expectedSlotsBetween( + schedules: ComplianceSchedule[], + from: Date, + to: Date, + ctx: ComplianceMedicationContext, + intakes?: ComplianceIntakeInstant[], +): Occurrence[] { + const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-slots"); + const effectiveFrom = + ctx.createdAt.getTime() > from.getTime() ? ctx.createdAt : from; + // v1.13.x — for a ROLLING schedule the expected grid is reconstructed from + // the intake history (each logged dose is one satisfied expected slot) so + // the window-selection denominator matches the displayed rate. The forward + // next-due slot counts only when past-due relative to `to` (the window's + // upper bound), so a not-yet-due open cycle never inflates the count. + const retro = + intakes && schedules.some((s) => s.rollingIntervalDays != null) + ? { intakeInstants: intakeInstantsAtOrBefore(intakes, to), now: to } + : undefined; + if (effectiveFrom.getTime() > to.getTime()) return []; + // v1.16.3 — era-aware: each archived era contributes the slots of ITS + // schedules; the live rows cover only the range past the newest revision. + return occurrencesAcrossEras( + { from: effectiveFrom, to }, + ctx.scheduleRevisions ?? [], + schedules.map((s, i) => toCanonicalSchedule(s, `compliance-slots-${i}`)), + (schedule, eraFrom, eraTo) => + expandComplianceOccurrences( + schedule, + recurrenceCtx, + eraFrom, + eraTo, + retro, + ), + { oneShot: ctx.oneShot }, + ); +} diff --git a/src/lib/analytics/compliance/parsing.ts b/src/lib/analytics/compliance/parsing.ts new file mode 100644 index 000000000..258b7b968 --- /dev/null +++ b/src/lib/analytics/compliance/parsing.ts @@ -0,0 +1,96 @@ +// Extracted from the former single-file `compliance.ts`. See `../compliance.ts` +// (the barrel) for the module map. Pure move — no logic changes. + +import { localHmAsUtc } from "@/lib/tz/local-day"; + +export type IntakeTimingClass = + "early" | "on_time" | "late" | "very_late" | "missed"; + +/** + * Parse "HH:mm" into hours and minutes. + */ +function parseHHmm(time: string): { hours: number; minutes: number } { + const [h, m] = time.split(":").map(Number); + return { hours: h, minutes: m }; +} + +/** + * Build the UTC instant of local "HH:mm" on the calendar day `day` falls on. + * + * When `tz` is supplied the window `HH:mm` is interpreted in the user's zone + * (via {@link localHmAsUtc}) on the local day of `day` — so an "08:00" slot is + * 08:00 LOCAL, not 08:00 UTC. Without `tz` it keeps the legacy UTC anchoring + * (`setUTCHours`), which is only correct for a UTC user; a caller far from UTC + * must pass its zone or the punctuality band is offset by the zone's offset. + */ +function toDateOnDay(time: string, day: Date, tz?: string): Date { + const { hours, minutes } = parseHHmm(time); + if (tz) { + return localHmAsUtc(day, tz, hours, minutes); + } + const d = new Date(day); + d.setUTCHours(hours, minutes, 0, 0); + return d; +} + +/** + * Classify how punctual an intake was relative to a schedule window. + * + * v1.4.34 IW-C — widened pre-window grace from 1h to 3h and introduced + * a dedicated `early` bucket so a proactive logger (e.g. 10 min before + * the window) no longer gets flushed into `very_late`. The post-window + * grace likewise grew to 3h so a "20 minutes late" dose stays `on_time`. + * Only doses beyond a 3-hour late tolerance fall into `late`, and only + * doses beyond a further `lateMinutes` tail fall into `very_late`. + * + * Buckets: + * - "early": within (windowStart - 3h) .. windowStart (compliant) + * - "on_time": windowStart .. (windowEnd + 3h) (compliant) + * - "late": (windowEnd + 3h) .. (windowEnd + 3h + lateMinutes) (default 2h tail) + * - "very_late": before windowStart - 3h, or after the late tail + * - "missed": takenAt is null + * + * Handles overnight windows (windowEnd < windowStart means next day). + * + * @param options.lateMinutes Width of the `late` band beyond the 3-hour + * on-time tolerance, in minutes. Defaults to 120. Doses past this + * tail land in `very_late`. + * @param options.tz IANA zone the window `HH:mm` is interpreted in. Supply + * the user's zone so an "08:00" slot means 08:00 LOCAL; omitting it keeps + * the legacy UTC interpretation (correct only for a UTC user). + */ +export function classifyIntakeTiming( + takenAt: Date | null, + windowStart: string, // "HH:mm" + windowEnd: string, // "HH:mm" + scheduledDate: Date, // the date this was scheduled + options?: { lateMinutes?: number; tz?: string }, +): IntakeTimingClass { + if (takenAt === null) return "missed"; + + const start = toDateOnDay(windowStart, scheduledDate, options?.tz); + let end = toDateOnDay(windowEnd, scheduledDate, options?.tz); + + // Handle overnight windows (e.g. windowStart="23:00", windowEnd="01:00") + if (end <= start) { + end = new Date(end.getTime() + 24 * 60 * 60 * 1000); + } + + const HOUR_MS = 60 * 60 * 1000; + // 3h pre-window grace: doses inside this window are `early` (still + // compliant). Doses before it are `very_late`. + const earlyStart = new Date(start.getTime() - 3 * HOUR_MS); + // 3h post-window grace: doses up to here are still `on_time`. + const onTimeEnd = new Date(end.getTime() + 3 * HOUR_MS); + // Configurable `late` tail past the on-time grace (default 120 min). + const lateTolerance = (options?.lateMinutes ?? 120) * 60 * 1000; + const lateEnd = new Date(onTimeEnd.getTime() + lateTolerance); + + const t = takenAt.getTime(); + + if (t < earlyStart.getTime()) return "very_late"; + if (t < start.getTime()) return "early"; + if (t <= onTimeEnd.getTime()) return "on_time"; + if (t <= lateEnd.getTime()) return "late"; + return "very_late"; +} diff --git a/src/lib/analytics/compliance/types.ts b/src/lib/analytics/compliance/types.ts new file mode 100644 index 000000000..4e0f7ac61 --- /dev/null +++ b/src/lib/analytics/compliance/types.ts @@ -0,0 +1,252 @@ +// Extracted from the former single-file `compliance.ts`. See `../compliance.ts` +// (the barrel) for the module map. Pure move — no logic changes. + +import type { ScheduleRevisionLike } from "@/lib/medications/scheduling/schedule-eras"; +import type { ScheduleType } from "@/lib/medications/scheduling/recurrence"; + +export interface IntakeEvent { + takenAt: Date | null; + skipped: boolean; + scheduledFor: Date; + /** + * v1.15.9 — true when the auto-miss cron marked this never-acted dose as + * a forgotten miss. Threaded into the cadence timeline so it counts as a + * `missed` slot (against the rate) instead of being neutralised. Optional + * so pre-v1.15.9 callers / fixtures default it to a normal pending/taken + * row. + */ + autoMissed?: boolean; + /** + * v1.15.20 — slot-binding provenance. `USER_PIN` = the user deliberately + * pinned this off-window take onto its `scheduledFor` slot; the unified + * ledger then binds it by anchor (taken-late, never on-time-washed) + * instead of degrading it to ad-hoc when the takenAt sits outside the + * band tail. Optional so legacy callers / fixtures default to AUTO. + */ + attributionSource?: "AUTO" | "USER_PIN"; +} + +export interface ComplianceResult { + totalExpected: number; + taken: number; + skipped: number; + missed: number; + rate: number; // 0-100 + streak: number; // consecutive days with all taken +} + +/** + * Daily compliance data including timing breakdown. + * + * `early` is the v1.4.34 bucket for doses taken before the window's + * 3-hour grace start. Consumers that group by compliance status + * should treat `early` as compliant (alongside `onTime`). + */ +export interface DailyComplianceEntry { + expected: number; + /** + * v1.7.0 item 5 — the true engine-computed due-slot count for the day. + * Equals `expected`; carried as an explicit additive field iOS keys + * off so it doesn't have to infer "due-ness" from `expected`. + */ + expectedCount: number; + /** + * v1.7.0 item 5 — `expectedCount > 0`. iOS renders a "missed" mark + * only when `due === true`, so off-weeks / non-matching weekdays / PRN + * days no longer paint a false miss. + */ + due: boolean; + taken: number; + skipped: number; + onTime: number; + late: number; + veryLate: number; + early?: number; +} + +/** + * v1.5.0 — schedule view consumed by the cadence-aware adapter. + * + * Accepts the historical `ScheduleWindow` shape (`windowStart` + + * `windowEnd` only) as well as the richer `ScheduleLike` shape that + * carries `daysOfWeek`. When `daysOfWeek` is missing the schedule is + * treated as `null` (daily, every day) so legacy fixtures keep + * passing without modification. + */ +export interface ComplianceSchedule { + windowStart: string; // HH:mm + windowEnd: string; // HH:mm + daysOfWeek?: string | null; + /** + * v1.7.0 SB-SCHED-2 — canonical-engine fields. When present (and a + * `medicationContext` is threaded into `calculateCompliance`), the + * expected-slot grid is computed through the canonical recurrence + * engine, so an `rrule = "FREQ=WEEKLY;BYDAY=MO"` schedule counts only + * Mondays in the denominator instead of every day. Absent fields keep + * the legacy `daysOfWeek` path — existing fixtures / callers that pass + * only `{ windowStart, windowEnd }` behave exactly as before. + */ + rrule?: string | null; + rollingIntervalDays?: number | null; + timesOfDay?: string[]; + reminderGraceMinutes?: number | null; + scheduleType?: ScheduleType | null; + cyclicOnWeeks?: number | null; + cyclicOffWeeks?: number | null; + /** + * v1.15.18 — per-dose configurable on-time windows. Threaded onto the + * canonical schedule so `tallyComplianceFromLedger` builds the on-time band + * from the explicit `[start, end]` range — keeping the % exactly aligned with + * the dose-history view and the write/edit attribution (all consume the same + * minter). Accepts the raw persisted JSON (a full Prisma row drops straight + * in); `toCanonicalSchedule` normalises it via `normaliseDoseWindows`. + */ + doseWindows?: unknown; +} + +/** + * v1.15.20 — the ONE Prisma `select` for schedule rows feeding a compliance + * computation. Every call site that loads schedules for + * `calculateCompliance` / the cadence helpers must use this constant instead + * of hand-rolling a field list (or an unbounded `include: { schedules: + * true }`), so a future schedule column that the engine consumes — the way + * `doseWindows` joined in v1.15.18 — reaches every surface the moment it is + * added here. Covers the full {@link ComplianceSchedule} shape plus `id` + * (engine-internal labelling / diagnostics). + */ +export const SCHEDULE_COMPLIANCE_SELECT = { + id: true, + windowStart: true, + windowEnd: true, + daysOfWeek: true, + timesOfDay: true, + reminderGraceMinutes: true, + rrule: true, + rollingIntervalDays: true, + scheduleType: true, + cyclicOnWeeks: true, + cyclicOffWeeks: true, + // The configurable per-dose on-time windows. Selecting them everywhere is + // the point: a user-configured "07:00–09:00" band must shape the rate on + // the dashboard pillar, the insights features, the BP-status gate and the + // report exactly as it shapes the medication detail page. + doseWindows: true, +} as const; + +/** + * v1.7.0 SB-SCHED-2 — medication-level context the canonical engine + * needs to expand expected slots. When supplied, `calculateCompliance` + * routes the denominator through the engine; when omitted, every + * schedule falls back to the legacy weekday walker (the byte-stable + * pre-v1.7 behaviour that the parity fixtures pin). + */ +export interface ComplianceMedicationContext { + startsOn: Date | null; + endsOn: Date | null; + oneShot: boolean; + createdAt: Date; + lastIntakeAt: Date | null; + timeZone: string; + /** + * v1.16.3 — archived schedule eras (`validFrom` ascending). When present, + * every expected-slot expansion + band mint segments its range so a past + * day counts/mints against the schedule that was live THEN. Optional: + * callers without revisions keep the live-only expansion. + */ + scheduleRevisions?: ScheduleRevisionLike[]; + /** + * v1.25 H-MED1 — durable pause intervals (`MedicationPauseEra`). When + * present, expected dose slots whose anchor falls inside any + * `[pausedAt, resumedAt ?? now)` interval are dropped from the ledger so a + * resumed medication never counts the paused days as missed. Optional: + * callers without eras keep counting every expected slot. + */ + pauseEras?: MedicationPauseEraLike[]; +} + +/** + * v1.25 H-MED1 — the pause-interval projection the compliance engine reads. + * A Prisma `MedicationPauseEra` row (or a `{ pausedAt, resumedAt }` literal) + * drops in. An open era (`resumedAt === null`) runs to `now`. + */ +export interface MedicationPauseEraLike { + pausedAt: Date; + resumedAt: Date | null; +} + +/** + * v1.7.0 SB-SCHED-2 — convenience builder so the eight compliance call + * sites don't each re-spell the context shape. Pass the medication row + * (any object carrying the course-window fields), the latest non-skipped + * intake instant, and the user's timezone. + */ +export function buildComplianceMedicationContext( + med: { + startsOn: Date | null; + endsOn: Date | null; + oneShot: boolean; + createdAt: Date; + /** v1.16.3 — thread the archived eras when the bundle carries them. */ + scheduleRevisions?: ScheduleRevisionLike[]; + /** v1.25 H-MED1 — thread the pause eras when the caller carries them. */ + pauseEras?: MedicationPauseEraLike[]; + }, + lastIntakeAt: Date | null, + timeZone: string, +): ComplianceMedicationContext { + return { + startsOn: med.startsOn, + endsOn: med.endsOn, + oneShot: med.oneShot, + createdAt: med.createdAt, + lastIntakeAt, + timeZone, + ...(med.scheduleRevisions && { scheduleRevisions: med.scheduleRevisions }), + ...(med.pauseEras && { pauseEras: med.pauseEras }), + }; +} + +/** + * v1.7.0 SB-SCHED-2 — the latest non-skipped `takenAt` across an event + * list (rolling cadences re-anchor on it). Returns null when the user + * has never logged a non-skipped intake. Order-independent, so the + * caller can pass an events array in any sort order. + */ +export function lastNonSkippedTakenAt( + events: { takenAt: Date | null; skipped: boolean }[], +): Date | null { + return events.reduce((latest, e) => { + if (e.skipped || e.takenAt === null) return latest; + if (latest === null || e.takenAt.getTime() > latest.getTime()) { + return e.takenAt; + } + return latest; + }, null); +} + +/** + * v1.13.x — non-skipped intake instants for the retrospective rolling grid, + * mirroring {@link rollingIntakeInstants} but reading the public + * `{ takenAt, skipped }[]` shape the `expectedSlots*` callers pass. The + * `expectedSlots*` helpers accept this so their rolling denominator uses the + * SAME expansion as the displayed-rate numerator in {@link calculateCompliance}. + */ +export interface ComplianceIntakeInstant { + takenAt: Date | null; + skipped: boolean; + /** + * v1.15.10 — the dose slot this intake resolves (the canonical snapped + * instant, byte-identical to the engine occurrence's `.at`). Optional so + * every legacy caller / fixture that omits it keeps working; when present + * the open-dose selection in {@link buildCurrentCycle} uses it to skip a + * slot the user has already acted on (taken / skipped / auto-missed) so the + * card advances to the next genuinely-open slot rather than re-surfacing a + * resolved past dose. + */ + scheduledFor?: Date; + /** + * v1.15.10 — the cron-flagged forgotten miss. A resolved (auto-missed) slot + * is excluded from the open-dose search the same way a take / skip is. + */ + autoMissed?: boolean; +} diff --git a/src/lib/analytics/compliance/windows.ts b/src/lib/analytics/compliance/windows.ts new file mode 100644 index 000000000..1edc2dd71 --- /dev/null +++ b/src/lib/analytics/compliance/windows.ts @@ -0,0 +1,113 @@ +// Extracted from the former single-file `compliance.ts`. See `../compliance.ts` +// (the barrel) for the module map. Pure move — no logic changes. + +import type { + ComplianceIntakeInstant, + ComplianceMedicationContext, + ComplianceSchedule, +} from "./types"; +import { expectedSlotsBetween } from "./occurrences"; + +/** + * 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 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; + +/** + * 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< + readonly [number, number] +> = [ + [7, 30], + [30, 90], + [90, 365], +]; + +/** + * 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.6 — pick the two compliance windows for a medication from its + * dosing cadence. + * + * 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 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; intakes?: ComplianceIntakeInstant[] }, +): 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, + options?.intakes, + ).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), + }; +}