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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-03 23:16:16 +02:00
1 parent 16c16000e7
commit 8aa2e33435
3 files changed
+103 -13

No files matched your search

@@ -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);
});
});
+4
View File
@@ -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
+57 -13
View File
@@ -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;
}