diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index dca5a1ead..7cf006a87 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 351, - "total": 113619, + "count": 350, + "total": 113407, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -257,7 +257,6 @@ "lib/ai/local-client.ts :: generateCompletionStream": 294, "lib/ai/openai-client.ts :: generateCompletion": 335, "lib/analytics/bp-in-target-fast-path.ts :: computeFromRollups": 203, - "lib/analytics/compliance/display.ts :: calculateCompliance": 212, "lib/analytics/correlations-fast-path.ts :: computeCorrelationHypothesesFastPath": 256, "lib/analytics/intraday-pulse-io.ts :: loadIntradayPulse": 206, "lib/analytics/score/reader.ts :: computeUserHealthScore": 324, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index e3c126303..9bb4f7a09 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", "count": 2897, - "total": 9272, + "total": 9270, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -1828,7 +1828,7 @@ "lib/analytics/compliance/__tests__/schedule-compliance-select.test.ts": 1, "lib/analytics/compliance/adapters.ts": 6, "lib/analytics/compliance/cycle.ts": 5, - "lib/analytics/compliance/display.ts": 18, + "lib/analytics/compliance/display.ts": 16, "lib/analytics/compliance/dose-status.ts": 4, "lib/analytics/compliance/ledger.ts": 7, "lib/analytics/compliance/occurrences.ts": 9, diff --git a/src/__tests__/recurrence-course-anchor-guard.test.ts b/src/__tests__/recurrence-course-anchor-guard.test.ts index 6ed1d14b8..ef13a425e 100644 --- a/src/__tests__/recurrence-course-anchor-guard.test.ts +++ b/src/__tests__/recurrence-course-anchor-guard.test.ts @@ -21,6 +21,14 @@ * when its block names `endedAt` or spreads a window that carries it * (`...window` from `mintWindow`, `...medicationWindow(row)`). * + * The compliance side builds its context in two more shapes, + * `ComplianceMedicationContext` and the `CadenceEngineContext` the timelines + * hand the engine, and both dropped `endedAt` once: the streak and the + * per-window timeline copied the context field by field and counted the dose + * after an ended course as missed. Both types now require the field, and the + * last leg holds every builder of either to naming it, and the timelines to + * the one builder. + * * Limit, stated: a literal passed straight to the engine without the * `RecurrenceContext` annotation is invisible here. Every medication path * today goes through one of the annotated builders below. @@ -29,7 +37,7 @@ import { readdirSync } from "node:fs"; import { join, relative, resolve } from "node:path"; import { describe, expect, it } from "vitest"; -import { readSourceText } from "./helpers/source-files"; +import { readSource, readSourceText } from "./helpers/source-files"; const SRC = resolve(__dirname, ".."); const ROOT = resolve(SRC, ".."); @@ -142,3 +150,53 @@ describe("the rolling course anchor is opt-in", () => { expect(declaring).toEqual([]); }); }); + +/** A typed literal or a typed return of either compliance context. */ +const COMPLIANCE_CONTEXT_DECLARATION = + /(\)\s*:\s*(?:CadenceEngineContext|ComplianceMedicationContext)\s*\{|:\s*(?:CadenceEngineContext|ComplianceMedicationContext)(?:\s*\|\s*undefined)?\s*=\s*[^;{]*\{)/g; + +function complianceContextBuilders(): Array<{ file: string; block: string }> { + const out: Array<{ file: string; block: string }> = []; + for (const file of sourceFiles(SRC)) { + const text = readSourceText(file); + for (const m of text.matchAll(COMPLIANCE_CONTEXT_DECLARATION)) { + const open = m.index! + m[0].length - 1; + out.push({ file: relative(ROOT, file), block: blockAt(text, open) }); + } + } + return out; +} + +describe("the compliance contexts carry the instant a course was ended", () => { + it("builds the engine's context in one place, which names endedAt", () => { + const found = complianceContextBuilders(); + // The two builders: the medication context and the engine's view of it. + expect(found.map((b) => b.file).sort()).toEqual([ + "src/lib/analytics/compliance/display.ts", + "src/lib/analytics/compliance/types.ts", + ]); + for (const b of found) { + expect(b.block, b.file).toMatch(/\bendedAt\s*:/); + } + // The matcher sees each shape the timelines used to write. + const shapes = [ + "const e: CadenceEngineContext = { a: 1 };", + "const e: CadenceEngineContext | undefined = ctx ? { a: 1 } : undefined;", + "function f(): ComplianceMedicationContext {\n return {};\n}", + ]; + for (const shape of shapes) { + expect([...shape.matchAll(COMPLIANCE_CONTEXT_DECLARATION)]).toHaveLength( + 1, + ); + } + }); + + it("has both timelines take the engine context from that builder", () => { + const display = readSource("src/lib/analytics/compliance/display.ts", [ + "function cadenceEngineContextOf(", + "function buildTimelineForWindow(", + ]); + const uses = display.match(/cadenceEngineContextOf\(ctx\)/g) ?? []; + expect(uses.length).toBe(2); + }); +}); diff --git a/src/lib/analytics/compliance/__tests__/compliance-ended-course.test.ts b/src/lib/analytics/compliance/__tests__/compliance-ended-course.test.ts new file mode 100644 index 000000000..61031ee37 --- /dev/null +++ b/src/lib/analytics/compliance/__tests__/compliance-ended-course.test.ts @@ -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); + }); +}); diff --git a/src/lib/analytics/compliance/__tests__/pre-creation-slots.test.ts b/src/lib/analytics/compliance/__tests__/pre-creation-slots.test.ts index 1d666d92e..0a2871961 100644 --- a/src/lib/analytics/compliance/__tests__/pre-creation-slots.test.ts +++ b/src/lib/analytics/compliance/__tests__/pre-creation-slots.test.ts @@ -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), diff --git a/src/lib/analytics/compliance/display.ts b/src/lib/analytics/compliance/display.ts index 521830ae5..7d7f220d9 100644 --- a/src/lib/analytics/compliance/display.ts +++ b/src/lib/analytics/compliance/display.ts @@ -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) diff --git a/src/lib/analytics/compliance/types.ts b/src/lib/analytics/compliance/types.ts index 622a6996a..47ebe8410 100644 --- a/src/lib/analytics/compliance/types.ts +++ b/src/lib/analytics/compliance/types.ts @@ -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; diff --git a/src/lib/medications/scheduling/__tests__/compliance.test.ts b/src/lib/medications/scheduling/__tests__/compliance.test.ts index 66957b55f..c4f8c462f 100644 --- a/src/lib/medications/scheduling/__tests__/compliance.test.ts +++ b/src/lib/medications/scheduling/__tests__/compliance.test.ts @@ -23,6 +23,7 @@ function ctx( return { startsOn: null, endsOn: null, + endedAt: null, oneShot: false, createdAt: d("2025-05-01T00:00:00Z"), lastIntakeAt: null, diff --git a/src/lib/medications/scheduling/cadence.ts b/src/lib/medications/scheduling/cadence.ts index 44dbcb7f3..2f5fc5475 100644 --- a/src/lib/medications/scheduling/cadence.ts +++ b/src/lib/medications/scheduling/cadence.ts @@ -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. */