diff --git a/src/lib/analytics/__tests__/compliance.test.ts b/src/lib/analytics/__tests__/compliance.test.ts index 33e0652d7..e94b7af4f 100644 --- a/src/lib/analytics/__tests__/compliance.test.ts +++ b/src/lib/analytics/__tests__/compliance.test.ts @@ -531,6 +531,122 @@ describe("calculateCompliance — engine-routed (medicationContext)", () => { 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 + // 12h of the schedule's configured HH:mm: the user takes the shot on + // whichever day / time of the dosing week suits them. The pre-fix ±12h + // pairing radius orphaned those intakes — every weekly slot read + // `missed` and the rate collapsed to 0% across EVERY window despite + // recorded intakes. The radius now scales with the cadence gap so an + // intake anywhere in the dosing week pairs to that week's slot. + // ────────────────────────────────────────────────────────────────── + + it("FREQ=WEEKLY;BYDAY=MO — Mondays taken late in the evening (22:00) still pair → 100%, not 0%", () => { + const schedules: ComplianceSchedule[] = [ + { + windowStart: "07:30", + windowEnd: "08:30", + daysOfWeek: null, + rrule: "FREQ=WEEKLY;BYDAY=MO", + timesOfDay: ["07:30"], + }, + ]; + // The configured slot is 07:30; the user logs the shot the same + // Monday but at 22:00 — 14.5h from the slot, outside the legacy ±12h + // radius. Thirteen consecutive Mondays back from the pinned Wednesday. + const lastMonday = new Date("2025-01-13T22:00:00Z"); + const intakes = Array.from({ length: 13 }, (_, i) => { + const at = new Date(lastMonday.getTime() - i * 7 * DAY_MS); + return { scheduledFor: at, takenAt: at, skipped: false }; + }); + const lastIntakeAt = intakes[0].takenAt; + for (const days of [7, 30, 90]) { + const result = calculateCompliance(intakes, schedules, days, undefined, { + medicationContext: ctx({ lastIntakeAt }), + }); + expect(result.rate).toBe(100); + expect(result.taken).toBeGreaterThan(0); + expect(result.missed).toBe(0); + } + }); + + it("FREQ=WEEKLY;BYDAY=MO — single-slot 7-day window: an off-time Monday still pairs (1/1 = 100%)", () => { + const schedules: ComplianceSchedule[] = [ + { + windowStart: "07:30", + windowEnd: "08:30", + daysOfWeek: null, + rrule: "FREQ=WEEKLY;BYDAY=MO", + timesOfDay: ["07:30"], + }, + ]; + // The 7-day window holds exactly one Monday slot — the per-slot + // neighbour-gap widening can't fire (no second slot), so this pins the + // schedule-derived radius floor that `buildCadenceTimeline` supplies. + const monday = new Date("2025-01-13T20:00:00Z"); // 12.5h after 07:30 + const intakes = [{ scheduledFor: monday, takenAt: monday, skipped: false }]; + const result = calculateCompliance(intakes, schedules, 7, undefined, { + medicationContext: ctx({ lastIntakeAt: monday }), + }); + expect(result.totalExpected).toBe(1); + expect(result.taken).toBe(1); + expect(result.rate).toBe(100); + }); + + it("legacy daysOfWeek='1' (Monday-only) — off-time Mondays still pair → non-zero across all windows", () => { + const schedules: ComplianceSchedule[] = [ + { + windowStart: "07:30", + windowEnd: "08:30", + daysOfWeek: "1", + timesOfDay: ["07:30"], + }, + ]; + const lastMonday = new Date("2025-01-13T21:00:00Z"); + const intakes = Array.from({ length: 13 }, (_, i) => { + const at = new Date(lastMonday.getTime() - i * 7 * DAY_MS); + return { scheduledFor: at, takenAt: at, skipped: false }; + }); + const lastIntakeAt = intakes[0].takenAt; + for (const days of [7, 30, 90]) { + const result = calculateCompliance(intakes, schedules, days, undefined, { + medicationContext: ctx({ lastIntakeAt }), + }); + expect(result.rate).toBe(100); + expect(result.missed).toBe(0); + } + }); + + it("DAILY cadence keeps the 12h floor — an intake equidistant-far from every slot stays unmatched", () => { + // Guard the widened radius against over-reach on a DENSE cadence: a + // daily schedule's gap is 24h, so its match radius stays at the 12h + // floor. An intake at 20:00 sits exactly 12h from the same day's 08:00 + // slot and 12h from the next day's 08:00 slot — at the boundary it + // claims the nearer (same-day) slot, but a single off-day intake can + // never pair to a slot two days away the way the weekly widening + // allows. Pin the floor by confirming one intake claims at most one + // daily slot. + const schedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + rrule: "FREQ=DAILY", + timesOfDay: ["08:00"], + }, + ]; + // One intake far from every slot's 08:00 centre — 02:00, which is 6h + // before that day's slot but also 18h before the prior day's: only the + // same-day slot is within 12h. A single intake → at most one taken. + const at = new Date("2025-01-13T02:00:00Z"); + const intakes = [{ scheduledFor: at, takenAt: at, skipped: false }]; + const result = calculateCompliance(intakes, schedules, 3, undefined, { + medicationContext: ctx({ lastIntakeAt: at }), + }); + expect(result.taken).toBeLessThanOrEqual(1); + }); + it("one-shot — exactly one expected slot on startsOn", () => { const startsOn = new Date("2025-01-10T00:00:00Z"); const schedules: ComplianceSchedule[] = [ diff --git a/src/lib/medications/scheduling/__tests__/cadence.test.ts b/src/lib/medications/scheduling/__tests__/cadence.test.ts index f3a3eed3e..4e15b1dc4 100644 --- a/src/lib/medications/scheduling/__tests__/cadence.test.ts +++ b/src/lib/medications/scheduling/__tests__/cadence.test.ts @@ -267,6 +267,66 @@ describe("pairDoses", () => { expect(paired[0].status).toBe("missed"); }); + it("widens the match radius for a sparse (weekly) cadence via the half-gap rule", () => { + // Two weekly slots seven days apart. An intake landing 2 days after + // the first slot (48h — far outside the legacy ±12h radius) must pair + // to that first slot, because half the 7-day gap is ~3.5 days. + const NOW = d("2025-06-20T12:00:00"); + const slots = [ + { + day: d("2025-06-02T00:00:00"), + windowStart: d("2025-06-02T08:00:00"), + windowEnd: d("2025-06-02T09:00:00"), + scheduleIndex: 0, + }, + { + day: d("2025-06-09T00:00:00"), + windowStart: d("2025-06-09T08:00:00"), + windowEnd: d("2025-06-09T09:00:00"), + scheduleIndex: 0, + }, + ]; + const events = [ + { + scheduledFor: d("2025-06-04T19:00:00"), // Wed, ~2.5 days after slot 1 + takenAt: d("2025-06-04T19:00:00"), + skipped: false, + }, + ]; + const paired = pairDoses(slots, events, NOW); + expect(paired[0].status).toBe("taken"); + expect(paired[1].status).toBe("missed"); + }); + + it("honours a caller-supplied radius floor when the window holds a single slot", () => { + // A single weekly slot — no neighbour to derive a half-gap from. The + // caller (buildCadenceTimeline) passes the schedule's cadence-derived + // floor so the off-time intake still pairs. + const NOW = d("2025-06-20T12:00:00"); + const slots = [ + { + day: d("2025-06-16T00:00:00"), + windowStart: d("2025-06-16T08:00:00"), + windowEnd: d("2025-06-16T09:00:00"), + scheduleIndex: 0, + }, + ]; + const events = [ + { + scheduledFor: d("2025-06-16T23:00:00"), // 15h after the 08:00 slot + takenAt: d("2025-06-16T23:00:00"), + skipped: false, + }, + ]; + // Without a floor the legacy 12h radius drops it. + expect(pairDoses(slots, events, NOW)[0].status).toBe("missed"); + // With a 3.5-day floor (half a weekly gap) it pairs. + const withFloor = pairDoses(slots, events, NOW, { + radiusFloorMs: 3.5 * 24 * 60 * 60 * 1000, + }); + expect(withFloor[0].status).toBe("taken"); + }); + it("never double-claims a single event across two close slots", () => { const dailySched = { windowStart: "08:00", @@ -317,6 +377,32 @@ describe("buildCadenceTimeline", () => { const NOW = d("2025-06-10T12:00:00"); expect(buildCadenceTimeline([], [], NOW, 30)).toEqual([]); }); + + it("pairs off-time weekly intakes via the schedule-derived radius floor (Mounjaro 0% repro)", () => { + // A weekly Monday schedule with intakes logged the same Monday but in + // the evening (22:00 — outside the legacy ±12h radius). Pre-fix every + // slot read `missed`; the timeline now pairs each one. + const weekly: ScheduleLike = { + windowStart: "07:30", + windowEnd: "08:30", + daysOfWeek: "1", + }; + const NOW = d("2025-06-11T12:00:00"); // Wednesday + const events = Array.from({ length: 4 }, (_, i) => { + const at = d( + new Date( + new Date("2025-06-09T22:00:00").getTime() - + i * 7 * 24 * 60 * 60 * 1000, + ).toISOString(), + ); + return { scheduledFor: at, takenAt: at, skipped: false }; + }); + const timeline = buildCadenceTimeline([weekly], events, NOW, 30); + const taken = timeline.filter((t) => t.status === "taken").length; + const missed = timeline.filter((t) => t.status === "missed").length; + expect(taken).toBeGreaterThan(0); + expect(missed).toBe(0); + }); }); describe("computeNextDose", () => { diff --git a/src/lib/medications/scheduling/cadence.ts b/src/lib/medications/scheduling/cadence.ts index 2f62c6641..39dc07989 100644 --- a/src/lib/medications/scheduling/cadence.ts +++ b/src/lib/medications/scheduling/cadence.ts @@ -154,8 +154,11 @@ export interface PairedDose extends ExpectedDose { const DAY_MS = 24 * 60 * 60 * 1000; const WEEK_MS = 7 * DAY_MS; /** - * Match window for pairing an actual intake event to an expected - * dose: +/- 12 hours around the slot's center. + * Base match radius for pairing an actual intake event to an expected + * dose: +/- 12 hours around the slot's centre. This is the floor — a + * daily (24h-gap) cadence matches inside +/- 12h, and a denser + * multi-dose-per-day cadence keeps the same 12h floor so its + * historically-pinned matching contract is byte-stable. * * Rationale: the existing classifyIntakeTiming uses a 3-hour grace * around the window + a configurable late tolerance after. For cadence @@ -165,6 +168,43 @@ const WEEK_MS = 7 * DAY_MS; */ const PAIR_RADIUS_MS = 12 * 60 * 60 * 1000; +/** + * v1.12.0 — derive the per-slot match radius from the gap to its + * neighbouring expected slots. + * + * The fixed +/-12h radius was correct only for a daily cadence (the + * inter-slot gap is 24h, so a half-gap of 12h cleanly partitions the + * timeline into one Voronoi cell per slot). For a SPARSE cadence — a + * weekly injectable (Mounjaro / Ozempic), a bi-weekly or monthly dose — + * the gap between expected slots is 7+ days, but a real intake is rarely + * logged within 12h of the configured slot instant: the user takes the + * shot on whichever day of the dosing week suits them, often at a + * different time of day than the schedule's HH:mm. Those intakes then + * fell OUTSIDE the 12h radius and every weekly slot read `missed` while + * the matching intake was orphaned — the live "0% despite recorded + * intakes" defect. + * + * The radius is half the distance to the nearer neighbour (so two + * adjacent slots never both reach the same midpoint and double-claim), + * floored at the 12h base so daily / multi-dose-daily cadences keep + * their exact pre-fix behaviour. A weekly cadence (168h gap) widens to a + * ~3.5-day radius, so an intake logged anywhere in the dosing week pairs + * to that week's slot. + */ +function slotMatchRadius( + centre: number, + prevCentre: number | null, + nextCentre: number | null, +): number { + const halfToPrev = + prevCentre === null ? Infinity : (centre - prevCentre) / 2; + const halfToNext = + nextCentre === null ? Infinity : (nextCentre - centre) / 2; + const halfGap = Math.min(halfToPrev, halfToNext); + if (!Number.isFinite(halfGap)) return PAIR_RADIUS_MS; + return Math.max(PAIR_RADIUS_MS, halfGap); +} + // WallClockParts + wallClockInTz live in `@/lib/tz/wall-clock` as the // canonical helper — see the file-level comment there for the v1.4.40 // consolidation rationale. @@ -427,8 +467,18 @@ export function pairDoses( slots: ExpectedDose[], events: IntakeEventLike[], now: Date, + options?: { radiusFloorMs?: number }, ): PairedDose[] { const claimed = new Set(); + // v1.12.0 — caller-supplied radius floor. `buildCadenceTimeline` derives + // the schedule's intrinsic cadence gap and passes half of it here so a + // window that holds a SINGLE expected slot (e.g. a weekly med over a + // 7-day window) still widens its match radius — the per-slot + // neighbour-gap logic below can only widen when two slots are present. + const radiusFloor = Math.max( + PAIR_RADIUS_MS, + options?.radiusFloorMs ?? PAIR_RADIUS_MS, + ); // v1.4.27 B7 / simp-M6 — sort once by `windowStart` for the match // pass and return the result in the same order. Slots that touch @@ -440,10 +490,29 @@ export function pairDoses( const sorted = [...slots].sort( (a, b) => a.windowStart.getTime() - b.windowStart.getTime(), ); + // Pre-compute each slot's centre once so the per-slot match radius can + // read the gap to its neighbours (the centres are monotonic in `sorted` + // order, so neighbour gaps are simply the adjacent entries). + const centres = sorted.map( + (s) => (s.windowStart.getTime() + s.windowEnd.getTime()) / 2, + ); const result: PairedDose[] = []; - for (const slot of sorted) { - const centre = (slot.windowStart.getTime() + slot.windowEnd.getTime()) / 2; + for (let s = 0; s < sorted.length; s++) { + const slot = sorted[s]; + const centre = centres[s]; + // v1.12.0 — cadence-aware radius: half the gap to the nearer + // neighbouring slot, floored at the 12h base. Daily / multi-dose + // cadences keep the 12h floor; a weekly+ cadence widens so an intake + // logged anywhere inside the dosing week pairs to that week's slot. + const radius = Math.max( + radiusFloor, + slotMatchRadius( + centre, + s > 0 ? centres[s - 1] : null, + s < centres.length - 1 ? centres[s + 1] : null, + ), + ); let bestIdx = -1; let bestDist = Infinity; for (let i = 0; i < events.length; i++) { @@ -451,7 +520,7 @@ export function pairDoses( const evt = events[i]; const t = (evt.takenAt ?? evt.scheduledFor).getTime(); const dist = Math.abs(t - centre); - if (dist <= PAIR_RADIUS_MS && dist < bestDist) { + if (dist <= radius && dist < bestDist) { bestDist = dist; bestIdx = i; } @@ -541,7 +610,72 @@ export function buildCadenceTimeline( ), ); } - return pairDoses(slots, events, asOf); + // v1.12.0 — derive the schedule's intrinsic cadence gap so the pairing + // radius widens for sparse cadences even when the requested window holds + // a single expected slot (a weekly med over a 7-day window). The per-slot + // neighbour-gap logic inside `pairDoses` can only widen when two slots + // sit in the window; a single-slot window needs the floor below. + const radiusFloorMs = cadenceRadiusFloor( + schedules, + asOf, + windowDays, + anchor, + timeZone, + engineCtx, + ); + return pairDoses(slots, events, asOf, { radiusFloorMs }); +} + +/** + * v1.12.0 — half the schedule's intrinsic cadence gap, used as the floor + * for the intake-match radius. + * + * Expands every schedule over a window padded to at least ~16 weeks so a + * sparse cadence (weekly / bi-weekly / monthly) yields ≥ 2 slots and a + * real inter-slot gap is observable even when the caller's compliance + * window only holds one. Takes the MINIMUM consecutive gap across the + * union of all schedules (the densest part of a multi-schedule med) and + * halves it, so the floor never over-widens past the point where two + * adjacent slots would double-claim one intake. Returns the 12h base when + * fewer than two slots exist anywhere in the probe window (rolling cadence + * emits only the next slot; a brand-new med; a one-shot) — those cases + * either have an empty compliance window or a single slot the base radius + * already covers. + */ +function cadenceRadiusFloor( + schedules: ScheduleLike[], + asOf: Date, + windowDays: number, + anchor: Date | undefined, + timeZone: string | undefined, + engineCtx: CadenceEngineContext | undefined, +): number { + const probeDays = Math.max(windowDays, 16 * 7); + const probeFrom = new Date(asOf.getTime() - probeDays * DAY_MS); + const centres: number[] = []; + for (let i = 0; i < schedules.length; i++) { + const probeSlots = expandScheduleSlots( + schedules[i], + i, + probeFrom, + asOf, + anchor ?? probeFrom, + timeZone, + engineCtx, + ); + for (const slot of probeSlots) { + centres.push((slot.windowStart.getTime() + slot.windowEnd.getTime()) / 2); + } + } + if (centres.length < 2) return PAIR_RADIUS_MS; + centres.sort((a, b) => a - b); + let minGap = Infinity; + for (let i = 1; i < centres.length; i++) { + const gap = centres[i] - centres[i - 1]; + if (gap > 0 && gap < minGap) minGap = gap; + } + if (!Number.isFinite(minGap)) return PAIR_RADIUS_MS; + return Math.max(PAIR_RADIUS_MS, minGap / 2); } /** diff --git a/tests/integration/medication-weekly-compliance.test.ts b/tests/integration/medication-weekly-compliance.test.ts new file mode 100644 index 000000000..6ba684e23 --- /dev/null +++ b/tests/integration/medication-weekly-compliance.test.ts @@ -0,0 +1,228 @@ +/** + * v1.12.0 — weekly-injectable compliance regression. + * + * Reproduces, against a real Postgres row, the live defect where a + * WEEKLY injectable (Mounjaro / GLP-1) with recorded intakes reported + * 0% compliance across EVERY window (7 / 30 / 90 days) on the medication + * card. The web reads the server-computed `compliance7` / `compliance30` + * / `complianceDisplay` values verbatim, so the server value itself was + * 0% — not a relabel. + * + * Root cause: the intake-to-slot matcher (`pairDoses`) used a fixed ±12h + * pairing radius. That radius is correct for a daily cadence (24h gap) + * but far too tight for a once-weekly dose, where a real intake is rarely + * logged within 12h of the schedule's configured HH:mm — the user takes + * the shot on whichever day / time of the dosing week suits them. Those + * intakes fell outside the radius, every weekly slot read `missed`, and + * the rate collapsed to 0%. The radius now scales with the cadence gap. + * + * The test writes a Mounjaro-shaped medication + schedule + intake rows, + * reads them back through the application Prisma singleton (so the + * column shapes — `timesOfDay[]`, `rrule`, `daysOfWeek`, + * `rollingIntervalDays` — are byte-identical to what the route reads), + * and asserts the cadence-aware compliance helpers return the true + * non-zero rate. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +import { + buildComplianceDisplay, + buildComplianceMedicationContext, + calculateCompliance, + lastNonSkippedTakenAt, + type ComplianceSchedule, +} from "@/lib/analytics/compliance"; + +const TEST_USER_ID = "user-weekly-compliance"; +const USER_TZ = "Europe/Berlin"; +const DAY_MS = 24 * 60 * 60 * 1000; + +// Pin "now" to a Wednesday so a Monday weekly cadence has a clean count +// of Mondays in each trailing window. +const NOW = new Date("2025-06-04T12:00:00Z"); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + vi.useFakeTimers(); + vi.setSystemTime(NOW); + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { + id: TEST_USER_ID, + username: "weekly-compliance", + email: "weekly-compliance@example.test", + timezone: USER_TZ, + }, + }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +/** + * Create a weekly Mounjaro-shaped medication. The schedule is encoded the + * way the create route writes a once-weekly injectable, parameterised so + * the test can cover both the RRULE-weekly and rolling-interval forms. + */ +async function createWeeklyMounjaro(opts: { + rrule?: string; + rollingIntervalDays?: number; + daysOfWeek?: string | null; + windowStart: string; +}): Promise { + const prisma = getPrismaClient(); + const med = await prisma.medication.create({ + data: { + userId: TEST_USER_ID, + name: "Mounjaro", + dose: "5mg", + treatmentClass: "GLP1", + deliveryForm: "INJECTION", + // Created ~100 days ago so the 90-day window is fully covered. + createdAt: new Date(NOW.getTime() - 100 * DAY_MS), + startsOn: new Date("2025-02-24T00:00:00Z"), // a Monday + schedules: { + create: { + windowStart: opts.windowStart, + windowEnd: opts.windowStart, + timesOfDay: [opts.windowStart], + daysOfWeek: opts.daysOfWeek ?? null, + ...(opts.rrule !== undefined && { rrule: opts.rrule }), + ...(opts.rollingIntervalDays !== undefined && { + rollingIntervalDays: opts.rollingIntervalDays, + }), + }, + }, + }, + }); + return med.id; +} + +/** + * Log `count` weekly intakes on consecutive Mondays back from + * `lastMonday`, each taken at the given local time-of-day (deliberately + * NOT the schedule's configured HH:mm, mirroring real usage where the + * shot is taken whenever). + */ +async function logWeeklyIntakes( + medicationId: string, + lastMonday: Date, + count: number, +): Promise { + const prisma = getPrismaClient(); + for (let i = 0; i < count; i++) { + const at = new Date(lastMonday.getTime() - i * 7 * DAY_MS); + await prisma.medicationIntakeEvent.create({ + data: { + userId: TEST_USER_ID, + medicationId, + scheduledFor: at, + takenAt: at, + skipped: false, + }, + }); + } +} + +/** + * Mirror the compliance route: read the medication + schedules + intakes, + * build the context, and compute the three windows + the display block. + */ +async function readCompliance(medicationId: string) { + const prisma = getPrismaClient(); + const medication = await prisma.medication.findUniqueOrThrow({ + where: { id: medicationId }, + include: { schedules: true }, + }); + const events = await prisma.medicationIntakeEvent.findMany({ + where: { medicationId, userId: TEST_USER_ID, deletedAt: null }, + orderBy: { scheduledFor: "desc" }, + }); + const mapped = events.map((e) => ({ + takenAt: e.takenAt, + skipped: e.skipped, + scheduledFor: e.scheduledFor, + })); + const lastIntakeAt = lastNonSkippedTakenAt(mapped); + const ctx = buildComplianceMedicationContext(medication, lastIntakeAt, USER_TZ); + const schedules = medication.schedules as ComplianceSchedule[]; + return { + compliance7: calculateCompliance(mapped, schedules, 7, medication.createdAt, { + medicationContext: ctx, + }), + compliance30: calculateCompliance(mapped, schedules, 30, medication.createdAt, { + medicationContext: ctx, + }), + compliance90: calculateCompliance(mapped, schedules, 90, medication.createdAt, { + medicationContext: ctx, + }), + display: buildComplianceDisplay(mapped, schedules, ctx), + }; +} + +describe("weekly injectable compliance (Mounjaro 0% regression)", () => { + // Mondays back from the pinned Wednesday, taken at 20:00 local (well + // outside the schedule's 07:30 HH:mm and the legacy ±12h radius). + const lastMonday = new Date("2025-06-02T18:00:00Z"); + + it("RRULE FREQ=WEEKLY;BYDAY=MO — off-time Monday intakes report the true rate, not 0%", async () => { + const id = await createWeeklyMounjaro({ + rrule: "FREQ=WEEKLY;BYDAY=MO", + windowStart: "07:30", + }); + await logWeeklyIntakes(id, lastMonday, 13); + + const { compliance7, compliance30, compliance90, display } = + await readCompliance(id); + + // Every recorded weekly dose pairs to its week's slot → 100%. + expect(compliance7.rate).toBe(100); + expect(compliance30.rate).toBe(100); + expect(compliance90.rate).toBe(100); + // And the denominator is the count of Mondays, not days. + expect(compliance30.taken).toBeGreaterThan(0); + expect(compliance90.taken).toBeGreaterThanOrEqual(12); + expect(compliance90.missed).toBe(0); + // The two-row card display agrees. + expect(display.short.rate).toBe(100); + expect(display.long.rate).toBe(100); + }); + + it("legacy daysOfWeek='1' (Monday-only) — off-time Monday intakes report the true rate, not 0%", async () => { + const id = await createWeeklyMounjaro({ + daysOfWeek: "1", + windowStart: "07:30", + }); + await logWeeklyIntakes(id, lastMonday, 13); + + const { compliance7, compliance30, compliance90 } = + await readCompliance(id); + + expect(compliance7.rate).toBe(100); + expect(compliance30.rate).toBe(100); + expect(compliance90.rate).toBe(100); + expect(compliance90.missed).toBe(0); + }); + + it("RRULE weekly — one missed week reports a high-but-not-100 rate (matcher still discriminates a real gap)", async () => { + const id = await createWeeklyMounjaro({ + rrule: "FREQ=WEEKLY;BYDAY=MO", + windowStart: "07:30", + }); + // Log only 12 of the trailing 13 Mondays (skip the most recent). + await logWeeklyIntakes( + id, + new Date(lastMonday.getTime() - 7 * DAY_MS), + 12, + ); + + const { compliance90 } = await readCompliance(id); + // 12 of 13 expected → ~92%, definitively non-zero and below 100. + expect(compliance90.rate).toBeGreaterThan(80); + expect(compliance90.rate).toBeLessThan(100); + expect(compliance90.missed).toBeGreaterThanOrEqual(1); + }); +});