mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user