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

@@ -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,
@@ -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,
@@ -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);
});
});