mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +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
@@ -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