From a55816bf58351283d0537d68c8859ff209d9cfed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 22:08:59 +0200 Subject: [PATCH] fix(medications): retrospective rolling-cadence compliance + currentCycle descriptor --- docs/api/openapi.yaml | 41 +++ .../api/medications/[id]/compliance/route.ts | 5 + .../analytics/__tests__/compliance.test.ts | 257 +++++++++++++++- src/lib/analytics/compliance.ts | 282 +++++++++++++++++- src/lib/medications/scheduling/cadence.ts | 49 ++- src/lib/medications/scheduling/recurrence.ts | 132 ++++++++ src/lib/openapi/routes.ts | 26 ++ 7 files changed, 777 insertions(+), 15 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 174c490b3..fd805f6a9 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -6761,6 +6761,46 @@ components: required: - rate additionalProperties: false + currentCycle: + type: object + properties: + state: + type: string + enum: + - on_track + - due + - missed + - none + description: "Open-cycle state, decoupled from the percentage rows: `on_track` = next dose not yet due; `due` = due now + / in grace; `missed` = past grace with no logged intake (the only red state); `none` = no projected next + dose (PRN / paused / ended)." + nextDueAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + description: The open cycle's due instant. Null when `state` is `none`. + graceUntil: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + description: End of the due slot's grace window. Null when `state` is `none`. + hasClosedCycles: + type: boolean + description: False for a brand-new sparse med with zero closed dose cycles — the percentage rows are vacuous and the + card should show a neutral 'not enough data yet' state. + required: + - state + - nextDueAt + - graceUntil + - hasClosedCycles + additionalProperties: false + description: v1.13.x — the current (open) dose cycle, surfaced so a between-doses sparse med renders a neutral 'next + dose in N days' line instead of a scary red 0%. The percentage rows above already exclude the open forward + cycle from their denominator. required: - shortDays - longDays @@ -6769,6 +6809,7 @@ components: - minStableDoses - short - long + - currentCycle additionalProperties: false description: The two-row card block whose windows scale with dosing cadence (dense meds keep 7 / 30 days, sparse meds step both windows up). NOT the 30-day denominator — read `compliance30.totalExpected` for that. diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index 779506b34..1afbdcccb 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -265,6 +265,11 @@ export const GET = apiHandler( dayStart, dayEnd, medicationContext, + // v1.13.x — pass the intake history so a ROLLING schedule's per-day + // `due` flags come from the retrospective grid (each logged dose is a + // due day) instead of the engine's single forward slot — keeping the + // heatmap, the denominator, and the displayed rate in agreement. + mapped, ); dailyCompliance[dateKey] = { diff --git a/src/lib/analytics/__tests__/compliance.test.ts b/src/lib/analytics/__tests__/compliance.test.ts index aa76b6b22..7e190da28 100644 --- a/src/lib/analytics/__tests__/compliance.test.ts +++ b/src/lib/analytics/__tests__/compliance.test.ts @@ -510,7 +510,7 @@ describe("calculateCompliance — engine-routed (medicationContext)", () => { expect(result.totalExpected).toBeLessThanOrEqual(3); }); - it("rolling rollingIntervalDays=7 — only the next-due slot counts", () => { + it("rolling rollingIntervalDays=7 — the open forward cycle is upcoming, not a slot (no events → empty)", () => { const schedules: ComplianceSchedule[] = [ { windowStart: "08:00", @@ -520,8 +520,14 @@ describe("calculateCompliance — engine-routed (medicationContext)", () => { timesOfDay: ["08:00"], }, ]; - // Last intake 5 days ago → next due in 2 days (future) → no past - // expected slot inside the window → empty-window contract → 100. + // v1.13.x — this test previously asserted the OLD forward-only behaviour + // ("only the next-due slot counts"). With the retrospective rolling + // expansion the grid is built from the LOGGED intakes — here there are + // none, and `lastIntakeAt` is 5 days ago so the forward next-due slot is + // 2 days out (future, > now) → excluded as upcoming. With no logged + // intakes in the window and no past-due slot the window is empty → + // empty-window contract → 100 / totalExpected 0. (A faithful history now + // reads its true rate — see the dedicated 12-shot test below.) const result = calculateCompliance([], schedules, 30, undefined, { medicationContext: ctx({ lastIntakeAt: new Date(NOW.getTime() - 5 * DAY_MS), @@ -531,6 +537,127 @@ describe("calculateCompliance — engine-routed (medicationContext)", () => { expect(result.rate).toBe(100); }); + // ────────────────────────────────────────────────────────────────── + // v1.13.x — ROLLING retrospective expansion. The canonical engine's + // `expandRolling` is forward-only: it emits at most the single + // immediately-next slot, so a historical compliance window over a + // rolling weekly injection (the GLP-1 default) saw either zero expected + // slots (vacuous 100%) or one overdue slot (hard 0%) — never the true + // multi-dose adherence. The compliance layer now reconstructs the + // historical grid from the logged intakes (each dose is one satisfied + // expected slot, with synthesized misses for skipped whole cycles + a + // past-due forward slot). These tests pin that the live "0% despite many + // recorded intakes" defect is fixed. + // ────────────────────────────────────────────────────────────────── + + it("rolling rollingIntervalDays=7 — 12 weekly shots logged → high rate, not 0%/100%-vacuous", () => { + const schedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + rollingIntervalDays: 7, + timesOfDay: ["08:00"], + }, + ]; + // 12 consecutive weekly shots, each ~7 days apart, taken at varying + // times of day (the real-world off-HH:mm pattern). Most recent 3 days ago. + const lastShot = new Date(NOW.getTime() - 3 * DAY_MS); + const intakes = Array.from({ length: 12 }, (_, i) => { + const at = new Date(lastShot.getTime() - i * 7 * DAY_MS); + at.setUTCHours(19 + (i % 3), 0, 0, 0); // 19:00/20:00/21:00 — off the 08:00 slot + return { scheduledFor: at, takenAt: at, skipped: false }; + }); + const lastIntakeAt = intakes[0].takenAt; + for (const days of [30, 90]) { + const result = calculateCompliance(intakes, schedules, days, undefined, { + medicationContext: ctx({ lastIntakeAt, startsOn: intakes[11].takenAt }), + }); + // Each logged shot is a satisfied expected slot → taken tracks the + // number of shots in the window; rate is high; not the broken 0%. + expect(result.taken).toBeGreaterThanOrEqual(days === 30 ? 4 : 12); + expect(result.rate).toBeGreaterThanOrEqual(90); + expect(result.missed).toBe(0); + } + }); + + it("rolling weekly — a shot logged a day late from its cycle still pairs (2 of 2, no phantom miss)", () => { + const schedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + rollingIntervalDays: 7, + timesOfDay: ["08:00"], + }, + ]; + // Two shots: cycle anchor + a second shot logged ~8 days later (1 day + // late, inside the 1.5·N tolerance), then nothing — both count taken, + // none missed (the late dose must NOT synthesize a phantom missed cycle). + const first = new Date(NOW.getTime() - 16 * DAY_MS); + const second = new Date(first.getTime() + 8 * DAY_MS); // 1 day late + const intakes = [ + { scheduledFor: second, takenAt: second, skipped: false }, + { scheduledFor: first, takenAt: first, skipped: false }, + ]; + const result = calculateCompliance(intakes, schedules, 30, undefined, { + medicationContext: ctx({ lastIntakeAt: second, startsOn: first }), + }); + expect(result.taken).toBe(2); + expect(result.missed).toBe(0); + expect(result.rate).toBe(100); + }); + + it("rolling weekly — a 3-week gap between shots synthesizes the skipped cycles as missed", () => { + const schedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + rollingIntervalDays: 7, + timesOfDay: ["08:00"], + }, + ]; + // Shot A, then a 21-day gap (2 missed cycles), then shot B. + const a = new Date(NOW.getTime() - 28 * DAY_MS); + const b = new Date(a.getTime() + 21 * DAY_MS); + const intakes = [ + { scheduledFor: b, takenAt: b, skipped: false }, + { scheduledFor: a, takenAt: a, skipped: false }, + ]; + const result = calculateCompliance(intakes, schedules, 30, undefined, { + medicationContext: ctx({ lastIntakeAt: b, startsOn: a }), + }); + // 2 taken (A, B) + ~2 synthesized missed cycles in the gap → ~50%. + expect(result.taken).toBe(2); + expect(result.missed).toBeGreaterThanOrEqual(1); + expect(result.rate).toBeLessThan(100); + }); + + it("rolling weekly — last shot 3 days ago, next due in 4 days → upcoming, excluded from denominator", () => { + const schedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + rollingIntervalDays: 7, + timesOfDay: ["08:00"], + }, + ]; + const lastShot = new Date(NOW.getTime() - 3 * DAY_MS); + const intakes = [ + { scheduledFor: lastShot, takenAt: lastShot, skipped: false }, + ]; + const result = calculateCompliance(intakes, schedules, 30, undefined, { + medicationContext: ctx({ lastIntakeAt: lastShot, startsOn: lastShot }), + }); + // The one closed cycle (the logged shot) is taken; the open forward + // cycle is upcoming and must NOT count as missed → 100%, missed 0. + expect(result.missed).toBe(0); + expect(result.taken).toBe(1); + expect(result.rate).toBe(100); + }); + // ────────────────────────────────────────────────────────────────── // v1.12.0 — weekly-injectable matching radius. A weekly med (Mounjaro) // is dosed once a week, but a real intake is almost never logged within @@ -1376,6 +1503,130 @@ describe("buildComplianceDisplay — two rows, cadence-scaled windows", () => { expect(display.long).toBeDefined(); }); + it("7-day rolling injection with history → [30, 90] windows (not the widest fallback)", () => { + // v1.13.x Fix 3B — after the retrospective rolling expansion, + // `expectedSlotsBetween` returns the real per-cycle grid for rolling, so + // a 7-day rolling injection with a faithful history clears the floor on + // the [30, 90] rung instead of falling through to the widest [90, 365] + // fallback (the pre-fix behaviour, where rolling emitted ≤ 1 slot). + const schedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + rollingIntervalDays: 7, + timesOfDay: ["08:00"], + }, + ]; + // ~16 weekly shots back from a most-recent shot 3 days ago. + const lastShot = new Date(NOW.getTime() - 3 * DAY_MS); + const events = Array.from({ length: 16 }, (_, i) => { + const at = new Date(lastShot.getTime() - i * 7 * DAY_MS); + at.setUTCHours(20, 0, 0, 0); + return { scheduledFor: at, takenAt: at, skipped: false }; + }); + const display = buildComplianceDisplay( + events, + schedules, + ctx({ + startsOn: events[events.length - 1].takenAt, + lastIntakeAt: events[0].takenAt, + }), + { now: NOW }, + ); + expect(display.shortDays).toBe(30); + expect(display.longDays).toBe(90); + expect(display.expectedShort).toBeGreaterThanOrEqual(MIN_STABLE_DOSES); + expect(display.expectedLong).toBeGreaterThanOrEqual(MIN_STABLE_DOSES); + }); + + it("currentCycle — rolling weekly, last shot 3 days ago → on_track (not a red miss)", () => { + // v1.13.x Fix 4 — a between-doses sparse med must NOT render a scary + // state. Last shot 3 days ago → next due in 4 days → the open cycle is + // on_track with a future nextDueAt. + const schedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + rollingIntervalDays: 7, + timesOfDay: ["08:00"], + }, + ]; + const lastShot = new Date(NOW.getTime() - 3 * DAY_MS); + const events = [ + { scheduledFor: lastShot, takenAt: lastShot, skipped: false }, + ]; + const display = buildComplianceDisplay( + events, + schedules, + ctx({ startsOn: lastShot, lastIntakeAt: lastShot }), + { now: NOW }, + ); + expect(display.currentCycle.state).toBe("on_track"); + expect(display.currentCycle.nextDueAt).not.toBeNull(); + expect(display.currentCycle.nextDueAt!.getTime()).toBeGreaterThan( + NOW.getTime(), + ); + expect(display.currentCycle.hasClosedCycles).toBe(true); + }); + + it("currentCycle — rolling weekly overdue past grace → missed", () => { + // v1.13.x Fix 4 — last shot ~10 days ago (cadence 7) → next due was 3 + // days ago, well past grace → the open cycle is `missed` (the only red + // state). nextDueAt is in the past. + const schedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + rollingIntervalDays: 7, + timesOfDay: ["08:00"], + }, + ]; + const lastShot = new Date(NOW.getTime() - 10 * DAY_MS); + const events = [ + { scheduledFor: lastShot, takenAt: lastShot, skipped: false }, + ]; + const display = buildComplianceDisplay( + events, + schedules, + ctx({ startsOn: lastShot, lastIntakeAt: lastShot }), + { now: NOW }, + ); + expect(display.currentCycle.state).toBe("missed"); + expect(display.currentCycle.nextDueAt).not.toBeNull(); + expect(display.currentCycle.nextDueAt!.getTime()).toBeLessThan( + NOW.getTime(), + ); + }); + + it("currentCycle — brand-new sparse med with zero closed cycles → hasClosedCycles false", () => { + // v1.13.x Fix 4 — a med created today with no logged intake has no + // closed dose cycle; the percentage rows are vacuous and the card should + // show a neutral state rather than a misleading 100% / 0%. + const schedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + rollingIntervalDays: 7, + timesOfDay: ["08:00"], + }, + ]; + const display = buildComplianceDisplay( + [], + schedules, + ctx({ + startsOn: new Date(NOW.getTime() + 4 * DAY_MS), // first dose in the future + createdAt: NOW, + }), + { now: NOW }, + ); + expect(display.currentCycle.hasClosedCycles).toBe(false); + expect(display.currentCycle.state).toBe("on_track"); + }); + it("a 2-day-old daily med's expected counts reflect its real age", () => { // Created two days ago: the expected-dose counts must reflect ~2 days // of daily doses, not the 7 / 30 a full-window walk would report. No diff --git a/src/lib/analytics/compliance.ts b/src/lib/analytics/compliance.ts index ad530ee25..97aad3372 100644 --- a/src/lib/analytics/compliance.ts +++ b/src/lib/analytics/compliance.ts @@ -27,6 +27,8 @@ import { } from "@/lib/medications/scheduling/cadence"; import { streaksFromTimeline } from "@/lib/medications/scheduling/compliance"; import { + expandRollingRetrospective, + nextOccurrenceAfter, occurrencesBetween, type CanonicalSchedule, type Occurrence, @@ -253,6 +255,28 @@ export function lastNonSkippedTakenAt( }, 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 @@ -300,6 +324,59 @@ function toCanonicalSchedule( }; } +/** + * 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; +} + +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. @@ -307,23 +384,36 @@ function toCanonicalSchedule( * 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 += occurrencesBetween( + 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), - recurrenceCtx, + retro, ).length; } return count; @@ -356,18 +446,29 @@ export function expectedSlotsBetween( 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( - ...occurrencesBetween( + ...expandComplianceOccurrences( toCanonicalSchedule(schedules[i], `compliance-slots-${i}`), + recurrenceCtx, effectiveFrom, to, - recurrenceCtx, + retro, ), ); } @@ -432,7 +533,7 @@ export interface ComplianceWindowSelection { export function selectComplianceWindows( schedules: ComplianceSchedule[], ctx: ComplianceMedicationContext, - options?: { now?: Date }, + options?: { now?: Date; intakes?: ComplianceIntakeInstant[] }, ): ComplianceWindowSelection { const now = options?.now ?? new Date(); const DAY_MS = 24 * 60 * 60 * 1000; @@ -443,6 +544,7 @@ export function selectComplianceWindows( 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) @@ -488,6 +590,43 @@ export function selectComplianceWindows( * 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; @@ -501,6 +640,102 @@ export interface ComplianceDisplay { short: { rate: number; streak: number }; /** Compliance percentage over the long window. */ long: { rate: 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.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. + */ +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 lastIntakeAt = + lastNonSkippedTakenAt(intakes ?? []) ?? 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; + } + const occ = nextOccurrenceAfter( + canonical, + new Date(now.getTime() - 1), + recurrenceCtx, + ); + if (occ) consider(occ.at, occ.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 }; } /** @@ -519,8 +754,12 @@ export function buildComplianceDisplay( 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 }); + selectComplianceWindows(schedules, ctx, { now, intakes: events }); const short = calculateCompliance(events, schedules, shortDays, ctx.createdAt, { now, @@ -531,6 +770,15 @@ export function buildComplianceDisplay( medicationContext: ctx, }); + // v1.13.x Fix 4 — the open-cycle descriptor, separable from the rates. + const currentCycle = buildCurrentCycle( + schedules, + ctx, + now, + expectedShort, + events, + ); + return { shortDays, longDays, @@ -539,6 +787,7 @@ export function buildComplianceDisplay( minStableDoses: MIN_STABLE_DOSES, short: { rate: short.rate, streak: short.streak }, long: { rate: long.rate }, + currentCycle, }; } @@ -665,6 +914,26 @@ export function calculateCompliance( skipped: e.skipped, })); + // 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, @@ -673,6 +942,7 @@ export function calculateCompliance( medicationCreatedAt ?? effectiveStart, engineCtx?.timeZone, engineCtx, + retro, ); let taken = 0; diff --git a/src/lib/medications/scheduling/cadence.ts b/src/lib/medications/scheduling/cadence.ts index 1e5cb7ab3..6dab236a7 100644 --- a/src/lib/medications/scheduling/cadence.ts +++ b/src/lib/medications/scheduling/cadence.ts @@ -32,10 +32,31 @@ import { type CanonicalSchedule, type RecurrenceContext, type ScheduleType, + expandRollingRetrospective, occurrencesBetween, } from "@/lib/medications/scheduling/recurrence"; import { wallClockInTz } from "@/lib/tz/wall-clock"; +/** + * v1.13.x compliance — opt-in retrospective-rolling expansion. + * + * The canonical engine's `expandRolling` is forward-only (correct for the + * projector / next-due surfaces). For the compliance + detail-page + * timeline a ROLLING cadence must instead reconstruct its historical + * expected-dose grid from the logged intakes. When a caller threads this + * option, rolling schedules route through `expandRollingRetrospective` + * (each logged intake is one satisfied slot, plus synthesized misses for + * skipped whole cycles + a past-due forward slot). Every non-rolling + * shape and every caller that omits the option keeps the forward-only + * engine path byte-for-byte. + */ +export interface RetrospectiveRollingOptions { + /** Non-skipped intake instants for the medication (the slot anchors). */ + intakeInstants: Date[]; + /** Wall-clock "now" — decides whether the forward next-due slot is past-due. */ + now: Date; +} + export interface ScheduleLike { /** "HH:mm" 24h, user-tz reference (per existing schema). */ windowStart: string; @@ -307,6 +328,7 @@ export function expandScheduleSlots( anchor: Date = from, timeZone?: string, engineCtx?: CadenceEngineContext, + retro?: RetrospectiveRollingOptions, ): ExpectedDose[] { if (to <= from) return []; @@ -337,12 +359,22 @@ export function expandScheduleSlots( // landing exactly at the window boundary isn't counted by the engine // path but dropped by the legacy path — the two branches stay // denominator-equivalent at the edge. - const occurrences = occurrencesBetween( - canonical, - from, - new Date(to.getTime() - 1), - recurrenceCtx, - ); + const inclusiveTo = new Date(to.getTime() - 1); + // v1.13.x — ROLLING + retrospective opt-in: reconstruct the historical + // expected-dose grid from the logged intakes rather than the engine's + // single forward slot. Only rolling schedules take this branch; every + // other shape still routes through `occurrencesBetween`. + const occurrences = + retro && canonical.rollingIntervalDays !== null + ? expandRollingRetrospective( + canonical, + recurrenceCtx, + from, + inclusiveTo, + retro.intakeInstants, + retro.now, + ) + : occurrencesBetween(canonical, from, inclusiveTo, recurrenceCtx); return occurrences.map((occ) => ({ day: startOfLocalDay(occ.at, timeZone), windowStart: occ.at, @@ -594,6 +626,7 @@ export function buildCadenceTimeline( anchor?: Date, timeZone?: string, engineCtx?: CadenceEngineContext, + retro?: RetrospectiveRollingOptions, ): PairedDose[] { const from = new Date(asOf.getTime() - windowDays * DAY_MS); const slots: ExpectedDose[] = []; @@ -607,6 +640,7 @@ export function buildCadenceTimeline( anchor ?? from, timeZone, engineCtx, + retro, ), ); } @@ -622,6 +656,7 @@ export function buildCadenceTimeline( anchor, timeZone, engineCtx, + retro, ); return pairDoses(slots, events, asOf, { radiusFloorMs }); } @@ -649,6 +684,7 @@ function cadenceRadiusFloor( anchor: Date | undefined, timeZone: string | undefined, engineCtx: CadenceEngineContext | undefined, + retro?: RetrospectiveRollingOptions, ): number { const probeDays = Math.max(windowDays, 16 * 7); const probeFrom = new Date(asOf.getTime() - probeDays * DAY_MS); @@ -662,6 +698,7 @@ function cadenceRadiusFloor( anchor ?? probeFrom, timeZone, engineCtx, + retro, ); for (const slot of probeSlots) { centres.push((slot.windowStart.getTime() + slot.windowEnd.getTime()) / 2); diff --git a/src/lib/medications/scheduling/recurrence.ts b/src/lib/medications/scheduling/recurrence.ts index c0db43330..600835ee5 100644 --- a/src/lib/medications/scheduling/recurrence.ts +++ b/src/lib/medications/scheduling/recurrence.ts @@ -423,6 +423,138 @@ function expandRolling( return [buildOccurrence(at, time, schedule)]; } +/** + * v1.13.x compliance — RETROSPECTIVE rolling expansion. + * + * `expandRolling` is forward-only by contract (the projector + reminder + * worker depend on it emitting ONLY the immediately-next slot). That is + * correct for "what's next" surfaces but wrong for the compliance grid, + * which must reconstruct the *historical* expected-dose slots a rolling + * cadence implies over a trailing window so a faithfully-adherent weekly + * injection reads its true rate instead of the vacuous 100%-or-0% flip + * the forward-only path produced. + * + * Medically-correct reading: a rolling "every N days from last dose" + * cadence re-anchors on each intake, so the retrospective expected grid + * *is* the observed intake history (each logged dose is exactly one + * satisfied expected slot) PLUS: + * + * - back-filled `missed` slots for genuinely skipped whole cycles: when + * two consecutive intakes are more than `1.5 · N` apart the user + * skipped one or more cycles → emit a slot at `prevIntake + k·N` for + * each whole cycle the gap spans (the `1.5·N` tolerance gate means a + * dose logged a day late never synthesizes a phantom miss); and + * - the single forward next-due slot, emitted ONLY when it is past-due + * (`nextDue ≤ now`). A not-yet-due forward slot is `upcoming` and is + * excluded so the open current cycle never counts against the + * denominator. + * + * Because the back-filled / forward slots carry no nearby intake, they + * pair to nothing in `pairDoses` and read `missed`; the per-intake slots + * are anchored AT the intake instant so they pair (distance 0) and read + * `taken`. The same grid feeds the count-only callers + * (`expectedSlotsBetween` / `expectedSlotCountForDay`) so numerator, + * denominator, and the heatmap `due` flags route through ONE expansion + * (the v1.7.3 B15 numerator/denominator-convergence rule). + * + * Kept here (not folded into `expandRolling`) so the engine's forward + * contract is untouched; compliance opts in explicitly via + * `expandRollingRetrospective`. + * + * @param intakeInstants Non-skipped intake instants for THIS medication + * (ascending or unsorted — sorted internally). The compliance layer + * supplies these; the engine has no DB access. + * @param now The wall-clock anchor (the compliance window's `to`-side + * "now"), used to decide whether the forward next-due slot is past-due. + */ +export function expandRollingRetrospective( + schedule: CanonicalSchedule, + ctx: RecurrenceContext, + from: Date, + to: Date, + intakeInstants: Date[], + now: Date, +): Occurrence[] { + const n = schedule.rollingIntervalDays; + if (n === null || n <= 0) return []; + + const time = schedule.timesOfDay[0] ?? schedule.windowStart; + const cycleMs = n * DAY_MS; + // Tolerance: a gap up to 1.5·N is "on time" (a dose logged a little + // late). Only a gap strictly beyond this synthesizes skipped cycles. + const gapToleranceMs = 1.5 * cycleMs; + const endsCap = ctx.medication.endsOn + ? endOfUtcDay(ctx.medication.endsOn).getTime() + : Infinity; + + const inWindow = (at: Date): boolean => + at.getTime() >= from.getTime() && + at.getTime() <= to.getTime() && + at.getTime() <= endsCap; + + const slots: Occurrence[] = []; + + // Each logged intake is a satisfied expected slot at its own instant. + // Sort ascending so the gap walk between consecutive intakes is correct. + const sorted = [...intakeInstants].sort((a, b) => a.getTime() - b.getTime()); + for (const instant of sorted) { + if (inWindow(instant)) { + slots.push(buildOccurrence(instant, time, schedule)); + } + } + + // Back-fill missed slots for genuinely skipped whole cycles between + // consecutive intakes (gap > 1.5·N). The synthesized slots land at the + // schedule's time-of-day on each skipped cycle's day so they read as a + // real expected dose in the user's timezone. + for (let i = 1; i < sorted.length; i++) { + const prev = sorted[i - 1]; + const next = sorted[i]; + const gap = next.getTime() - prev.getTime(); + if (gap <= gapToleranceMs) continue; + // Number of whole cycles the user skipped: floor(gap/N) − 1 missed + // cycles sit strictly between the two intakes. + const cyclesInGap = Math.floor(gap / cycleMs); + for (let k = 1; k < cyclesInGap; k++) { + const anchor = new Date(prev.getTime() + k * cycleMs); + const at = applyTimeOfDayToDate(anchor, time, ctx.timeZone); + if (inWindow(at)) { + slots.push(buildOccurrence(at, time, schedule)); + } + } + } + + // The single forward next-due slot — emitted as a missed slot ONLY when + // the open cycle is GENUINELY overdue, i.e. `now` is more than half a + // cycle past `nextDue`. A dose merely a little late (within `0.5·N`) is + // the current open cycle still in its window — it must NOT synthesize a + // phantom miss (the N-tolerance gate). A not-yet-due forward slot + // (`nextDue > now`) is excluded entirely (upcoming → out of the + // denominator). This keeps the open current cycle off the percentage rows + // until it is unambiguously missed; the `currentCycle` descriptor carries + // the softer on_track / due / missed state for the card. + const forwardToleranceMs = cycleMs / 2; + const lastInstant = + sorted.length > 0 ? sorted[sorted.length - 1] : ctx.lastIntakeAt; + const nextDue = + lastInstant !== null + ? new Date(lastInstant.getTime() + cycleMs) + : ctx.medication.startsOn ?? ctx.medication.createdAt; + if (now.getTime() - nextDue.getTime() > forwardToleranceMs) { + const at = applyTimeOfDayToDate(nextDue, time, ctx.timeZone); + // Dedupe: a forward slot already represented by a logged intake (the + // intake re-anchored exactly N days out) must not double-count. + const alreadyPresent = slots.some( + (s) => Math.abs(s.at.getTime() - at.getTime()) < cycleMs / 2, + ); + if (inWindow(at) && !alreadyPresent) { + slots.push(buildOccurrence(at, time, schedule)); + } + } + + return slots.sort((a, b) => a.at.getTime() - b.at.getTime()); +} + // ──────────────────────────────────────────────────────────────────── // Dispatch — RRULE // ──────────────────────────────────────────────────────────────────── diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index 6d08d06d5..276e31402 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -1243,6 +1243,32 @@ const complianceDisplay = z streak: z.number().int().nonnegative(), }), long: z.object({ rate: z.number().int().min(0).max(100) }), + currentCycle: z + .object({ + state: z + .enum(["on_track", "due", "missed", "none"]) + .describe( + "Open-cycle state, decoupled from the percentage rows: `on_track` = next dose not yet due; `due` = due now / in grace; `missed` = past grace with no logged intake (the only red state); `none` = no projected next dose (PRN / paused / ended).", + ), + nextDueAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe("The open cycle's due instant. Null when `state` is `none`."), + graceUntil: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "End of the due slot's grace window. Null when `state` is `none`.", + ), + hasClosedCycles: z + .boolean() + .describe( + "False for a brand-new sparse med with zero closed dose cycles — the percentage rows are vacuous and the card should show a neutral 'not enough data yet' state.", + ), + }) + .describe( + "v1.13.x — the current (open) dose cycle, surfaced so a between-doses sparse med renders a neutral 'next dose in N days' line instead of a scary red 0%. The percentage rows above already exclude the open forward cycle from their denominator.", + ), }) .meta({ id: "ComplianceDisplay",