From 8aa2e33435d1d12f99f9695f60a0a3e9cc548156 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 23:15:14 +0200 Subject: [PATCH] Walk every course in the streak and the cadence timeline The rate counted misses in earlier courses while the streak and the cadence chart's missed tally minted only the latest course, so a miss in a previous course never broke the streak. The cadence engine context now carries the courses and expands over their span, keeping only course days, the same rule as the compliance ledger. --- .../__tests__/compliance-courses.test.ts | 42 +++++++++++ src/lib/analytics/compliance/display.ts | 4 ++ src/lib/medications/scheduling/cadence.ts | 70 +++++++++++++++---- 3 files changed, 103 insertions(+), 13 deletions(-) diff --git a/src/lib/analytics/__tests__/compliance-courses.test.ts b/src/lib/analytics/__tests__/compliance-courses.test.ts index 5eae5a23d..16602da2f 100644 --- a/src/lib/analytics/__tests__/compliance-courses.test.ts +++ b/src/lib/analytics/__tests__/compliance-courses.test.ts @@ -116,3 +116,45 @@ describe("compliance across two courses", () => { expect(slots).toHaveLength(7); }); }); + +describe("the streak across two courses (v1.40, #1024)", () => { + // Course A Jun 1-10 with Jun 5 missed, course B Jun 13 onward, every other + // course day taken. The miss sits in the earlier course: a streak that only + // saw the latest course would run unbroken through the whole window. + const A = { + startsOn: dateOfDayKey("2026-06-01"), + endsOn: dateOfDayKey("2026-06-10"), + }; + const B = { startsOn: dateOfDayKey("2026-06-13"), endsOn: null }; + const NOW_B = new Date("2026-06-27T18:00:00.000Z"); + const taken = [ + ...dayKeys("2026-06-01", "2026-06-10").filter((d) => d !== "2026-06-05"), + ...dayKeys("2026-06-13", "2026-06-27"), + ].map((day) => ({ + scheduledFor: new Date(`${day}T06:00:00.000Z`), + takenAt: new Date(`${day}T06:05:00.000Z`), + skipped: false, + })); + + it("stops at the miss in the earlier course", () => { + const ctx = buildComplianceMedicationContext( + { + startsOn: B.startsOn, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-05-20T10:00:00.000Z"), + courses: [A, B], + }, + taken[taken.length - 1].takenAt, + TZ, + ); + const result = calculateCompliance(taken, DAILY, 30, ctx.createdAt, { + now: NOW_B, + medicationContext: ctx, + }); + expect(result.missed).toBe(1); + // Jun 6 to Jun 27. The two gap days expect nothing and advance the + // streak like any out-of-cadence day; the miss on Jun 5 ends the run. + expect(result.streak).toBe(22); + }); +}); diff --git a/src/lib/analytics/compliance/display.ts b/src/lib/analytics/compliance/display.ts index 395398c7d..f958c417a 100644 --- a/src/lib/analytics/compliance/display.ts +++ b/src/lib/analytics/compliance/display.ts @@ -384,6 +384,8 @@ export function calculateCompliance( lastIntakeAt: ctx.lastIntakeAt, timeZone: ctx.timeZone, scheduleRevisions: ctx.scheduleRevisions, + // v1.40 (#1024) — the streak walks the same course days the rate does. + courses: ctx.courses, } : undefined; @@ -747,6 +749,8 @@ function buildTimelineForWindow( lastIntakeAt: ctx.lastIntakeAt, timeZone: ctx.timeZone, scheduleRevisions: ctx.scheduleRevisions, + // v1.40 (#1024) — the same course days the ledger counts. + courses: ctx.courses, }; const normalisedEvents: IntakeEventLike[] = events diff --git a/src/lib/medications/scheduling/cadence.ts b/src/lib/medications/scheduling/cadence.ts index 19d6aec93..4ae7301ad 100644 --- a/src/lib/medications/scheduling/cadence.ts +++ b/src/lib/medications/scheduling/cadence.ts @@ -27,6 +27,12 @@ * W19e tests pin. */ +import { + isInsideCourses, + sortCourses, + type CourseSpan, +} from "@/lib/medications/course-window"; +import { userDayKey } from "@/lib/tz/format"; import { parseScheduleRecurrence } from "@/lib/medication-schedule"; import type { SlotBand } from "@/lib/medications/scheduling/attribution"; import { type BandMinterMedication } from "@/lib/medications/scheduling/band-minter"; @@ -134,6 +140,36 @@ export interface CadenceEngineContext { * live THEN. Optional: callers without revisions keep live-only minting. */ scheduleRevisions?: ScheduleRevisionLike[]; + /** + * v1.40 (#1024) — the medication's courses. With two or more, every + * timeline here expands over the span of all of them and keeps only the + * slots on a course day, exactly as the compliance ledger does; fewer, + * and `startsOn`/`endsOn` alone bound it, as before. + */ + courses?: CourseSpan[]; +} + +/** v1.40 (#1024) — the span a timeline expands over (see `courses`). */ +function mintWindow(engineCtx: CadenceEngineContext): { + startsOn: Date | null; + endsOn: Date | null; +} { + const courses = engineCtx.courses; + if (!courses || courses.length < 2) { + return { startsOn: engineCtx.startsOn, endsOn: engineCtx.endsOn }; + } + const sorted = sortCourses(courses); + return { + startsOn: sorted[0].startsOn, + endsOn: sorted[sorted.length - 1].endsOn, + }; +} + +/** v1.40 (#1024) — whether a slot at `at` lies on a course day. */ +function onCourseDay(engineCtx: CadenceEngineContext, at: Date): boolean { + const courses = engineCtx.courses; + if (!courses || courses.length < 2) return true; + return isInsideCourses(userDayKey(at, engineCtx.timeZone), courses); } /** Build a `CanonicalSchedule` from a `ScheduleLike` + its index. */ @@ -161,11 +197,12 @@ function toCanonical( function toRecurrenceContext( engineCtx: CadenceEngineContext, ): RecurrenceContext { + const window = mintWindow(engineCtx); return { medication: { id: "cadence-med", - startsOn: engineCtx.startsOn, - endsOn: engineCtx.endsOn, + startsOn: window.startsOn, + endsOn: window.endsOn, oneShot: engineCtx.oneShot, createdAt: engineCtx.createdAt, }, @@ -407,12 +444,14 @@ export function expandScheduleSlots( retro.now, ) : occurrencesBetween(canonical, from, inclusiveTo, recurrenceCtx); - return occurrences.map((occ) => ({ - day: startOfLocalDay(occ.at, timeZone), - windowStart: occ.at, - windowEnd: occ.graceUntil, - scheduleIndex, - })); + return occurrences + .filter((occ) => onCourseDay(engineCtx, occ.at)) + .map((occ) => ({ + day: startOfLocalDay(occ.at, timeZone), + windowStart: occ.at, + windowEnd: occ.graceUntil, + scheduleIndex, + })); } const recurrence = parseScheduleRecurrence(schedule.daysOfWeek); @@ -864,18 +903,19 @@ function missedFromLedger( if (!engineCtx) return null; const from = new Date(asOf.getTime() - windowDays * DAY_MS); const userTz = engineCtx.timeZone || timeZone || "UTC"; + const window = mintWindow(engineCtx); const medication: BandMinterMedication = { id: "missed-tally", - startsOn: engineCtx.startsOn, - endsOn: engineCtx.endsOn, + startsOn: window.startsOn, + endsOn: window.endsOn, oneShot: engineCtx.oneShot, createdAt: engineCtx.createdAt, }; const recurrenceCtx: RecurrenceContext = { medication: { id: "missed-tally", - startsOn: engineCtx.startsOn, - endsOn: engineCtx.endsOn, + startsOn: window.startsOn, + endsOn: window.endsOn, oneShot: engineCtx.oneShot, createdAt: engineCtx.createdAt, }, @@ -927,5 +967,9 @@ function missedFromLedger( asOf, engineCtx.createdAt, ); - return rows.filter((r) => r.status === "missed").length; + return rows.filter( + (r) => + r.status === "missed" && + (r.kind !== "slot" || onCourseDay(engineCtx, r.at)), + ).length; }