/** * Medication compliance calculations. * * v1.5.0 — `calculateCompliance` is now a cadence-aware adapter on top * of `complianceChips` / `buildCadenceTimeline`. Prior to this release * the helper computed `totalExpected = schedules.length * days`, which * silently ignored `MedicationSchedule.daysOfWeek` and `intervalWeeks`. * A weekly Ozempic schedule (Mondays only) reported ~13% adherence * instead of 100%; a weekday-only 3×/day metformin reported 73% * instead of 100%. The wire shape (`{ totalExpected, taken, skipped, * missed, rate, streak }`) is unchanged so every consumer (Health * Score, AI Coach prompt context, /api/medications/[id]/compliance, * BP-status compliance gate, insight targets, the per-medication * tile) keeps reading the same fields. Only the math underneath the * fields was wrong — that's what got fixed. Closes #214. * * `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 { buildBandsForSchedules, type BandMinterMedication, type DoseWindowConfig, } from "@/lib/medications/scheduling/band-minter"; 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"; 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; } 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 a Date for a given "HH:mm" on a specific date. */ function toDateOnDay(time: string, day: Date): Date { const { hours, minutes } = parseHHmm(time); 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`. */ export function classifyIntakeTiming( takenAt: Date | null, windowStart: string, // "HH:mm" windowEnd: string, // "HH:mm" scheduledDate: Date, // the date this was scheduled options?: { lateMinutes?: number }, ): IntakeTimingClass { if (takenAt === null) return "missed"; const start = toDateOnDay(windowStart, scheduledDate); let end = toDateOnDay(windowEnd, scheduledDate); // 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.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.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; }, lastIntakeAt: Date | null, timeZone: string, ): ComplianceMedicationContext { return { startsOn: med.startsOn, endsOn: med.endsOn, oneShot: med.oneShot, createdAt: med.createdAt, lastIntakeAt, timeZone, }; } /** * 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 { let count = 0; 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; for (let i = 0; i < schedules.length; i++) { count += expandComplianceOccurrences( toCanonicalSchedule(schedules[i], `compliance-daily-${i}`), recurrenceCtx, dayStart, // occurrencesBetween is inclusive of both ends; subtract 1 ms so a // slot exactly at the next day's midnight doesn't double-count. new Date(dayEnd.getTime() - 1), retro, ).length; } return count; } /** * v1.8.5 — the sibling of {@link expectedSlotCountForDay} that returns the * expected-dose occurrences *themselves* (ascending by instant) over an * arbitrary window, rather than just the per-day count. Same loop, same * canonical-engine delegation; we keep the slots so the dose-adherence * timeline can pair each expected slot to its intake. * * Used by {@link buildComplianceDisplay} to decide the card's render mode * (percent bars vs an uptime-style per-dose strip) and to build the strip. * `occurrencesBetween` is inclusive of both ends; the caller passes a * `[from, to]` window and we sort the union of every schedule's slots so * a multi-schedule medication interleaves its slots in time order. * * 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; const all: Occurrence[] = []; for (let i = 0; i < schedules.length; i++) { all.push( ...expandComplianceOccurrences( toCanonicalSchedule(schedules[i], `compliance-slots-${i}`), recurrenceCtx, effectiveFrom, to, retro, ), ); } return all.sort((a, b) => a.at.getTime() - b.at.getTime()); } /** * 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 = [ [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, ); const groups = buildBandsForSchedules({ medication, schedules: canonicalSchedules, 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, })); return reconstructDoseHistory(bands, intakes, now); } /** * 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, } : 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 }; } /** * 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, }; 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, ); }