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; }