fix(compliance): carry endedAt into the streak and per-window timelines

calculateCompliance and buildTimelineForWindow built the cadence
engine's context field by field and left out endedAt, so a dose slot
after End course counted as missed in the streak and the timeline while
the rate left it out.

Both now take the context from one builder, endedAt is required on
ComplianceMedicationContext and CadenceEngineContext, and the course
anchor guard holds every builder of either type to naming it.

Refs #1280
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 00:26:55 +02:00
1 parent 8fd1d543a6
commit 8b66f647cb
9 files changed
+217 -35

No files matched your search

@@ -0,0 +1,120 @@
/**
* #1280 — a course ended at an instant expects no dose after it, in the
* streak and the per-window timeline as in the rate.
*
* The person lives in Europe/Berlin and doses at 08:00 and 20:00 local
* (06:00Z and 18:00Z in October before the clock change). Every dose of the
* last twenty days was taken, except the evening one on the day the course
* was ended at noon: that slot came after the end and was never due.
*/
import { describe, expect, it } from "vitest";
import {
buildMedicationComplianceBundle,
calculateCompliance,
} from "@/lib/analytics/compliance/display";
import {
buildComplianceMedicationContext,
type ComplianceSchedule,
} from "@/lib/analytics/compliance/types";
const TZ = "Europe/Berlin";
const DAY = 86_400_000;
const NOW = new Date("2026-10-25T10:00:00.000Z");
const ENDED_AT = new Date("2026-10-24T10:00:00.000Z");
const CREATED_AT = new Date(NOW.getTime() - 20 * DAY);
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
timesOfDay: ["08:00", "20:00"],
daysOfWeek: null,
rrule: null,
rollingIntervalDays: null,
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
},
];
/** Every dose taken on time; the end day's evening only when asked. */
function events(withLastEvening: boolean) {
const out: Array<{
scheduledFor: Date;
takenAt: Date;
skipped: boolean;
autoMissed: boolean;
}> = [];
for (let d = 19; d >= 0; d -= 1) {
const key = new Date(Date.UTC(2026, 9, 24 - d)).toISOString().slice(0, 10);
const morning = new Date(`${key}T06:00:00.000Z`);
out.push({
scheduledFor: morning,
takenAt: morning,
skipped: false,
autoMissed: false,
});
if (d > 0 || withLastEvening) {
const evening = new Date(`${key}T18:00:00.000Z`);
out.push({
scheduledFor: evening,
takenAt: evening,
skipped: false,
autoMissed: false,
});
}
}
return out;
}
function context(endedAt: Date | null) {
return buildComplianceMedicationContext(
{
startsOn: new Date(Date.UTC(2026, 9, 4)),
endsOn: new Date(Date.UTC(2026, 9, 24)),
endedAt,
oneShot: false,
createdAt: CREATED_AT,
},
new Date("2026-10-24T06:00:00.000Z"),
TZ,
);
}
describe("a course ended at an instant", () => {
it("does not break the streak on the slot after the end", () => {
const ended = calculateCompliance(events(false), schedules, 7, CREATED_AT, {
now: NOW,
medicationContext: context(ENDED_AT),
});
// The same course run to the end of its last day with every dose taken.
const whole = calculateCompliance(events(true), schedules, 7, CREATED_AT, {
now: NOW,
medicationContext: context(null),
});
expect(whole.streak).toBeGreaterThan(0);
expect(ended.missed).toBe(0);
expect(ended.streak).toBe(whole.streak);
});
it("does not break the bundle's streaks either", () => {
const ended = buildMedicationComplianceBundle(
events(false),
schedules,
context(ENDED_AT),
NOW,
);
const whole = buildMedicationComplianceBundle(
events(true),
schedules,
context(null),
NOW,
);
expect(whole.compliance7.streak).toBeGreaterThan(0);
expect(ended.compliance7.missed).toBe(0);
expect(ended.compliance7.streak).toBe(whole.compliance7.streak);
expect(ended.compliance30.streak).toBe(whole.compliance30.streak);
});
});
@@ -49,6 +49,7 @@ for (const tz of ["Asia/Kolkata", "America/New_York"]) {
const ctx: ComplianceMedicationContext = {
startsOn: new Date("2026-09-23T00:00:00Z"),
endsOn: new Date("2026-09-28T00:00:00Z"),
endedAt: null,
oneShot: false,
createdAt,
lastIntakeAt: d2(21, 30),
+27 -24
View File
@@ -243,6 +243,31 @@ export function buildComplianceDisplay(
* can be driven deterministically from a caller
* that already pinned its own `now`.
*/
/**
* The cadence engine's view of a compliance context, and the only place it
* is built: the streak timeline, the per-window timeline and the ledger must
* expect the same slots. The two timelines used to copy the context field
* by field and dropped `endedAt` (#1280), so a slot after "End course"
* counted as missed in the streak while the rate left it out. `endedAt` is
* required on both types, so a field missed here is a type error rather
* than a slot.
*/
export function cadenceEngineContextOf(
ctx: ComplianceMedicationContext,
): CadenceEngineContext {
return {
startsOn: ctx.startsOn,
endsOn: ctx.endsOn,
endedAt: ctx.endedAt,
oneShot: ctx.oneShot,
createdAt: ctx.createdAt,
lastIntakeAt: ctx.lastIntakeAt,
timeZone: ctx.timeZone,
scheduleRevisions: ctx.scheduleRevisions,
courses: ctx.courses,
};
}
/**
* Whether a medication expects any dose at all, and so has an adherence rate
* worth showing.
@@ -375,19 +400,7 @@ export function calculateCompliance(
// canonical engine (RRULE / rolling / one-shot / PRN / cyclic);
// otherwise the legacy weekday walker stays in force.
const ctx = options?.medicationContext;
const engineCtx: CadenceEngineContext | undefined = ctx
? {
startsOn: ctx.startsOn,
endsOn: ctx.endsOn,
oneShot: ctx.oneShot,
createdAt: ctx.createdAt,
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;
const engineCtx = ctx ? cadenceEngineContextOf(ctx) : undefined;
// Match events against the same slot grid the cadence chart uses.
// The chart's pairing radius is ±12 h so a late-by-six-hours dose
@@ -755,17 +768,7 @@ function buildTimelineForWindow(
cyclicOffWeeks: s.cyclicOffWeeks ?? null,
}));
const engineCtx: CadenceEngineContext = {
startsOn: ctx.startsOn,
endsOn: ctx.endsOn,
oneShot: ctx.oneShot,
createdAt: ctx.createdAt,
lastIntakeAt: ctx.lastIntakeAt,
timeZone: ctx.timeZone,
scheduleRevisions: ctx.scheduleRevisions,
// v1.40 (#1024) — the same course days the ledger counts.
courses: ctx.courses,
};
const engineCtx = cadenceEngineContextOf(ctx);
const normalisedEvents: IntakeEventLike[] = events
.filter((e) => e.scheduledFor >= effectiveStart && e.scheduledFor <= now)
+3 -3
View File
@@ -161,10 +161,10 @@ export interface ComplianceMedicationContext {
/**
* #1280 — the instant "End course" ended the latest course: no slot at or
* after it is expected. Earlier courses carry their own in `courses`.
* Every production context comes from `buildComplianceMedicationContext`,
* whose input requires it.
* Required, null when the whole `endsOn` day counts: a context built
* without it would expect the dose after the end.
*/
endedAt?: Date | null;
endedAt: Date | null;
oneShot: boolean;
createdAt: Date;
lastIntakeAt: Date | null;
@@ -23,6 +23,7 @@ function ctx(
return {
startsOn: null,
endsOn: null,
endedAt: null,
oneShot: false,
createdAt: d("2025-05-01T00:00:00Z"),
lastIntakeAt: null,
+2 -2
View File
@@ -128,8 +128,8 @@ export interface ScheduleLike {
export interface CadenceEngineContext {
startsOn: Date | null;
endsOn: Date | null;
/** #1280 — when "End course" ended the latest course (see the engine). */
endedAt?: Date | null;
/** #1280 — when "End course" ended the latest course; null: whole day. */
endedAt: Date | null;
oneShot: boolean;
createdAt: Date;
/** Latest non-skipped intake — only read by rolling cadences. */