diff --git a/src/lib/analytics/__tests__/compliance.test.ts b/src/lib/analytics/__tests__/compliance.test.ts index bb9120341..de02b92bc 100644 --- a/src/lib/analytics/__tests__/compliance.test.ts +++ b/src/lib/analytics/__tests__/compliance.test.ts @@ -23,6 +23,7 @@ import { type ComplianceSchedule, } from "../compliance"; import type { IntakeTimingClass } from "../compliance"; +import { buildCadenceTimeline } from "@/lib/medications/scheduling/cadence"; import { getUserTodayBounds } from "@/lib/timezone"; import { userDayKey } from "@/lib/tz/format"; @@ -557,6 +558,39 @@ describe("calculateCompliance — engine-routed (medicationContext)", () => { expect(result.rate).toBe(100); expect(result.missed).toBe(0); }); + + it("B15: legacy daysOfWeek row with two timesOfDay — 2×/day taken reads 100%, not 50%", () => { + // Regression for the compliance divergence: a plain `daysOfWeek` + // schedule (no rrule / rolling, SCHEDULED, not one-shot) carrying two + // `timesOfDay` must expand its numerator through the same engine as + // the denominator. Before the fix the numerator's local legacy walker + // emitted one slot/day from `windowStart`, while the denominator's + // engine counted both — collapsing a fully-adherent 2×/day med to + // 1/2 = 50%. + const schedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "19:30", + daysOfWeek: null, // every day + timesOfDay: ["08:00", "19:00"], + // no rrule, no rollingIntervalDays, scheduleType defaults SCHEDULED + }, + ]; + // Pin a single-day window so exactly two slots are expected. NOW is + // late evening UTC so both of today's slots are in the past. + vi.setSystemTime(new Date("2025-06-09T23:30:00Z")); + const events = [ + eventAt(new Date("2025-06-09T08:00:00Z"), true), + eventAt(new Date("2025-06-09T19:00:00Z"), true), + ]; + const result = calculateCompliance(events, schedules, 1, undefined, { + medicationContext: ctx({ startsOn: new Date("2025-05-01T00:00:00Z") }), + }); + // Both daily slots expand and both pair with a taken intake → 2/2. + expect(result.totalExpected).toBe(2); + expect(result.taken).toBe(2); + expect(result.rate).toBe(100); + }); }); // ──────────────────────────────────────────────────────────────────── @@ -645,6 +679,62 @@ describe("expectedSlotCountForDay + lastNonSkippedTakenAt", () => { expect(count).toBe(2); }); + it("B15 invariant: legacy multi-time row — denominator and numerator expand identically", () => { + // The B15 bug lived in the numerator's expander, not here — this + // helper always ran the engine. Pin the convergence directly: for a + // plain `daysOfWeek` row carrying two `timesOfDay` (no rrule), the + // per-day denominator and the cadence numerator + // (`buildCadenceTimeline`) must report the same slot count. + const dayStart = new Date("2025-06-09T00:00:00Z"); + const dayEnd = new Date("2025-06-10T00:00:00Z"); + const schedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "19:30", + daysOfWeek: null, + timesOfDay: ["08:00", "19:00"], + }, + ]; + const medCtx = ctx({ + startsOn: new Date("2025-05-01T00:00:00Z"), + timeZone: "Europe/Berlin", + }); + + const denominator = expectedSlotCountForDay( + schedules, + dayStart, + dayEnd, + medCtx, + ); + + const numerator = buildCadenceTimeline( + [ + { + windowStart: "08:00", + windowEnd: "19:30", + daysOfWeek: null, + timesOfDay: ["08:00", "19:00"], + }, + ], + [], + new Date("2025-06-09T23:59:00Z"), + 1, + dayStart, + medCtx.timeZone, + { + startsOn: medCtx.startsOn, + endsOn: medCtx.endsOn, + oneShot: medCtx.oneShot, + createdAt: medCtx.createdAt, + lastIntakeAt: medCtx.lastIntakeAt, + timeZone: medCtx.timeZone, + }, + ).length; + + expect(numerator).toBe(denominator); + expect(numerator).toBe(2); + }); + it("lastNonSkippedTakenAt picks the newest non-skipped takenAt", () => { const events = [ { takenAt: new Date("2025-01-10T08:00:00Z"), skipped: false }, diff --git a/src/lib/medications/scheduling/__tests__/compliance.test.ts b/src/lib/medications/scheduling/__tests__/compliance.test.ts index a3cf44737..cc8de1db1 100644 --- a/src/lib/medications/scheduling/__tests__/compliance.test.ts +++ b/src/lib/medications/scheduling/__tests__/compliance.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from "vitest"; import { complianceChips } from "../compliance"; +import { buildCadenceTimeline } from "../cadence"; import type { CadenceEngineContext, IntakeEventLike, @@ -318,4 +319,41 @@ describe("complianceChips — canonical-engine delegation (v1.7.0 SB-SCHED-2)", // than the equivalent every-day SCHEDULED expansion. expect(engine.missedLast30).toBeLessThan(everyDay.missedLast30); }); + + it("B15: a legacy daysOfWeek row with multiple timesOfDay emits one slot per time", () => { + // Repro of the compliance divergence: a plain `daysOfWeek` schedule + // (no rrule / rolling, SCHEDULED, not one-shot) carrying two + // `timesOfDay` must expand to two slots per qualifying day through the + // engine. Before the fix the cadence numerator routed such a row to + // the local legacy walker, which emitted a single slot/day from + // `windowStart` — so a 2×/day medication reported 1/2 = 50%. + const twiceDaily: ScheduleLike = { + windowStart: "08:00", + windowEnd: "19:30", + daysOfWeek: null, // every day + timesOfDay: ["08:00", "19:00"], + // no rrule, no rollingIntervalDays, scheduleType defaults to SCHEDULED + }; + // A single 24-hour window starting at local midnight Berlin. + const dayStart = d("2025-06-09T00:00:00Z"); + const NOW = d("2025-06-09T23:59:00Z"); + const ctx = engineCtx({ + startsOn: d("2025-05-01T00:00:00Z"), + timeZone: "Europe/Berlin", + }); + + const timeline = buildCadenceTimeline( + [twiceDaily], + [], + NOW, + 1, + dayStart, + ctx.timeZone, + ctx, + ); + + // Both configured times land inside the one-day window → two slots. + expect(timeline.length).toBe(2); + }); + }); diff --git a/src/lib/medications/scheduling/cadence.ts b/src/lib/medications/scheduling/cadence.ts index f1fbf2da9..2f62c6641 100644 --- a/src/lib/medications/scheduling/cadence.ts +++ b/src/lib/medications/scheduling/cadence.ts @@ -44,16 +44,20 @@ export interface ScheduleLike { /** Encoded recurrence string per `serializeScheduleRecurrence`. */ daysOfWeek: string | null; /** - * v1.7.0 SB-SCHED-2 — canonical-engine fields. When any of these is - * present AND the caller supplies a `CadenceEngineContext`, + * v1.7.0 SB-SCHED-2 — canonical-engine fields. As of v1.7.3 (B15), * `expandScheduleSlots` delegates the expected-slot grid to the - * canonical recurrence engine (`occurrencesBetween`) instead of the - * legacy `daysOfWeek` walker. This routes compliance through the same - * engine the projector + reminder worker use, fixing the long-standing - * bug where an `rrule = "FREQ=WEEKLY;BYDAY=MO"` schedule expanded to + * canonical recurrence engine (`occurrencesBetween`) for EVERY schedule + * shape whenever the caller supplies a `CadenceEngineContext` — these + * fields carry the recurrence detail the engine reads (rrule / rolling + * / cyclic), and a plain legacy `daysOfWeek` row routes through the + * engine's `expandLegacy` branch all the same. This keeps compliance on + * the same engine the projector + reminder worker use, fixing the bug + * where an `rrule = "FREQ=WEEKLY;BYDAY=MO"` schedule expanded to * daily-every-day in compliance (because `daysOfWeek = null` reads as - * "every day"). Absent fields keep the legacy weekday path so existing - * fixtures / pre-v1.7 callers are byte-stable. + * "every day") AND the B15 bug where a multi-`timesOfDay` legacy row + * collapsed to one slot/day in the numerator. Only callers that omit a + * context fall to the legacy weekday walker (byte-stable for pure-math + * / pre-v1.7 fixtures). */ rrule?: string | null; rollingIntervalDays?: number | null; @@ -84,16 +88,6 @@ export interface CadenceEngineContext { timeZone: string; } -/** A schedule routes through the canonical engine iff it carries a - * non-legacy recurrence shape OR a non-SCHEDULED schedule type. */ -function usesCanonicalEngine(schedule: ScheduleLike): boolean { - return ( - (schedule.rrule ?? null) !== null || - (schedule.rollingIntervalDays ?? null) !== null || - (schedule.scheduleType ?? "SCHEDULED") !== "SCHEDULED" - ); -} - /** Build a `CanonicalSchedule` from a `ScheduleLike` + its index. */ function toCanonical( schedule: ScheduleLike, @@ -230,6 +224,15 @@ function applyTime(day: Date, hhmm: string, tz: string | undefined): Date { return instantInTz(parts.year, parts.month, parts.day, h, m, tz); } +/** Parse "HH:mm" to minutes-since-midnight, or null when malformed. */ +function hhmmToMinutes(hhmm: string): number | null { + const [hStr, mStr] = hhmm.split(":"); + const h = Number(hStr); + const m = Number(mStr); + if (!Number.isFinite(h) || !Number.isFinite(m)) return null; + return h * 60 + m; +} + /** Snap a Date down to the user-local midnight. */ function startOfLocalDay(d: Date, tz: string | undefined): Date { const parts = wallClockInTz(d, tz); @@ -267,14 +270,25 @@ export function expandScheduleSlots( ): ExpectedDose[] { if (to <= from) return []; - // v1.7.0 SB-SCHED-2 — Option B delegation. When the caller supplies a - // medication context AND the schedule carries a canonical recurrence - // (rrule / rolling) or a non-SCHEDULED type, expand the expected-slot - // grid through the canonical engine so compliance, the cadence chart, - // and the projector all read the same source of truth. The legacy - // weekday walker below stays the path for plain `daysOfWeek` schedules - // and for every caller that doesn't thread a context (byte-stable). - if (engineCtx && (engineCtx.oneShot || usesCanonicalEngine(schedule))) { + // v1.7.3 B15 — canonical-engine delegation. When the caller supplies a + // medication context, expand the expected-slot grid through the + // canonical engine for EVERY schedule shape (rrule / rolling / one-shot + // / PRN / cyclic AND plain legacy `daysOfWeek`). The engine's + // `expandLegacy` branch iterates `effectiveTimesOfDay` correctly, so a + // single legacy row carrying multiple `timesOfDay` emits one slot per + // time — matching exactly what `expectedSlotCountForDay` already counts. + // + // The earlier gate (`oneShot || usesCanonicalEngine`) diverged the + // compliance numerator from the denominator: a 2×/day legacy schedule + // (`daysOfWeek` set, two `timesOfDay`, no rrule) fell through to the + // local legacy walker below, which only ever emits ONE slot/day from + // `windowStart`, while `expectedSlotCountForDay` always ran the engine + // and counted both slots → 1/2 = 50%. Routing both sides through the + // single engine converges them for every schedule form. + // + // The local legacy walker below stays the path only when no context is + // threaded (pure-math callers / pre-v1.7 fixtures) — byte-stable. + if (engineCtx) { const canonical = toCanonical(schedule, scheduleIndex); const recurrenceCtx = toRecurrenceContext(engineCtx); // v1.7.0 code-correctness M5 — `occurrencesBetween` is inclusive of @@ -333,22 +347,65 @@ export function expandScheduleSlots( } } - const wStart = applyTime(day, schedule.windowStart, timeZone); - let wEnd = applyTime(day, schedule.windowEnd, timeZone); - // Overnight window: windowEnd <= windowStart means next day. - if (wEnd <= wStart) { - wEnd = new Date(wEnd.getTime() + DAY_MS); + // v1.7.3 B15 — defence-in-depth for the context-less legacy path. A + // schedule carrying multiple `timesOfDay` must emit one slot per time + // per qualifying day, mirroring the engine's `expandLegacy`. Pre-fix + // this walker only ever emitted a single `windowStart..windowEnd` + // window, so a 2×/day legacy row read as one slot/day here — the + // numerator half of the B15 divergence. When `timesOfDay` is empty we + // keep the historical single-window behaviour (byte-stable for every + // pre-v1.7 fixture that relies on it). + const times = + schedule.timesOfDay && schedule.timesOfDay.length > 0 + ? schedule.timesOfDay + : null; + + if (times === null) { + const wStart = applyTime(day, schedule.windowStart, timeZone); + let wEnd = applyTime(day, schedule.windowEnd, timeZone); + // Overnight window: windowEnd <= windowStart means next day. + if (wEnd <= wStart) { + wEnd = new Date(wEnd.getTime() + DAY_MS); + } + + if (wStart >= to) continue; + if (wEnd <= from) continue; + + slots.push({ + day, + windowStart: wStart, + windowEnd: wEnd, + scheduleIndex, + }); + continue; } - if (wStart >= to) continue; - if (wEnd <= from) continue; + for (const time of times) { + const wStart = applyTime(day, time, timeZone); + // Each time-of-day slot spans the schedule's window length. Reuse + // the windowStart..windowEnd span so the pairing radius and the + // chart cell keep their existing shape; an overnight window pushes + // the end to the next day. + const startMin = hhmmToMinutes(schedule.windowStart); + const endMin = hhmmToMinutes(schedule.windowEnd); + let spanMs = DAY_MS; + if (startMin !== null && endMin !== null) { + let span = endMin - startMin; + if (span <= 0) span += 24 * 60; // overnight window + spanMs = span * 60_000; + } + const wEnd = new Date(wStart.getTime() + spanMs); - slots.push({ - day, - windowStart: wStart, - windowEnd: wEnd, - scheduleIndex, - }); + if (wStart >= to) continue; + if (wEnd <= from) continue; + + slots.push({ + day, + windowStart: wStart, + windowEnd: wEnd, + scheduleIndex, + }); + } } return slots;