mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Walk every course in the streak and the cadence timeline
The rate counted misses in earlier courses while the streak and the cadence chart's missed tally minted only the latest course, so a miss in a previous course never broke the streak. The cadence engine context now carries the courses and expands over their span, keeping only course days, the same rule as the compliance ledger.
This commit is contained in:
1 parent
16c16000e7
commit
8aa2e33435
3 files changed
+103
-13
No files matched your search
@@ -116,3 +116,45 @@ describe("compliance across two courses", () => {
|
||||
expect(slots).toHaveLength(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the streak across two courses (v1.40, #1024)", () => {
|
||||
// Course A Jun 1-10 with Jun 5 missed, course B Jun 13 onward, every other
|
||||
// course day taken. The miss sits in the earlier course: a streak that only
|
||||
// saw the latest course would run unbroken through the whole window.
|
||||
const A = {
|
||||
startsOn: dateOfDayKey("2026-06-01"),
|
||||
endsOn: dateOfDayKey("2026-06-10"),
|
||||
};
|
||||
const B = { startsOn: dateOfDayKey("2026-06-13"), endsOn: null };
|
||||
const NOW_B = new Date("2026-06-27T18:00:00.000Z");
|
||||
const taken = [
|
||||
...dayKeys("2026-06-01", "2026-06-10").filter((d) => d !== "2026-06-05"),
|
||||
...dayKeys("2026-06-13", "2026-06-27"),
|
||||
].map((day) => ({
|
||||
scheduledFor: new Date(`${day}T06:00:00.000Z`),
|
||||
takenAt: new Date(`${day}T06:05:00.000Z`),
|
||||
skipped: false,
|
||||
}));
|
||||
|
||||
it("stops at the miss in the earlier course", () => {
|
||||
const ctx = buildComplianceMedicationContext(
|
||||
{
|
||||
startsOn: B.startsOn,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-05-20T10:00:00.000Z"),
|
||||
courses: [A, B],
|
||||
},
|
||||
taken[taken.length - 1].takenAt,
|
||||
TZ,
|
||||
);
|
||||
const result = calculateCompliance(taken, DAILY, 30, ctx.createdAt, {
|
||||
now: NOW_B,
|
||||
medicationContext: ctx,
|
||||
});
|
||||
expect(result.missed).toBe(1);
|
||||
// Jun 6 to Jun 27. The two gap days expect nothing and advance the
|
||||
// streak like any out-of-cadence day; the miss on Jun 5 ends the run.
|
||||
expect(result.streak).toBe(22);
|
||||
});
|
||||
});
|
||||
@@ -384,6 +384,8 @@ export function calculateCompliance(
|
||||
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;
|
||||
|
||||
@@ -747,6 +749,8 @@ function buildTimelineForWindow(
|
||||
lastIntakeAt: ctx.lastIntakeAt,
|
||||
timeZone: ctx.timeZone,
|
||||
scheduleRevisions: ctx.scheduleRevisions,
|
||||
// v1.40 (#1024) — the same course days the ledger counts.
|
||||
courses: ctx.courses,
|
||||
};
|
||||
|
||||
const normalisedEvents: IntakeEventLike[] = events
|
||||
|
||||
@@ -27,6 +27,12 @@
|
||||
* W19e tests pin.
|
||||
*/
|
||||
|
||||
import {
|
||||
isInsideCourses,
|
||||
sortCourses,
|
||||
type CourseSpan,
|
||||
} from "@/lib/medications/course-window";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import type { SlotBand } from "@/lib/medications/scheduling/attribution";
|
||||
import { type BandMinterMedication } from "@/lib/medications/scheduling/band-minter";
|
||||
@@ -134,6 +140,36 @@ export interface CadenceEngineContext {
|
||||
* live THEN. Optional: callers without revisions keep live-only minting.
|
||||
*/
|
||||
scheduleRevisions?: ScheduleRevisionLike[];
|
||||
/**
|
||||
* v1.40 (#1024) — the medication's courses. With two or more, every
|
||||
* timeline here expands over the span of all of them and keeps only the
|
||||
* slots on a course day, exactly as the compliance ledger does; fewer,
|
||||
* and `startsOn`/`endsOn` alone bound it, as before.
|
||||
*/
|
||||
courses?: CourseSpan[];
|
||||
}
|
||||
|
||||
/** v1.40 (#1024) — the span a timeline expands over (see `courses`). */
|
||||
function mintWindow(engineCtx: CadenceEngineContext): {
|
||||
startsOn: Date | null;
|
||||
endsOn: Date | null;
|
||||
} {
|
||||
const courses = engineCtx.courses;
|
||||
if (!courses || courses.length < 2) {
|
||||
return { startsOn: engineCtx.startsOn, endsOn: engineCtx.endsOn };
|
||||
}
|
||||
const sorted = sortCourses(courses);
|
||||
return {
|
||||
startsOn: sorted[0].startsOn,
|
||||
endsOn: sorted[sorted.length - 1].endsOn,
|
||||
};
|
||||
}
|
||||
|
||||
/** v1.40 (#1024) — whether a slot at `at` lies on a course day. */
|
||||
function onCourseDay(engineCtx: CadenceEngineContext, at: Date): boolean {
|
||||
const courses = engineCtx.courses;
|
||||
if (!courses || courses.length < 2) return true;
|
||||
return isInsideCourses(userDayKey(at, engineCtx.timeZone), courses);
|
||||
}
|
||||
|
||||
/** Build a `CanonicalSchedule` from a `ScheduleLike` + its index. */
|
||||
@@ -161,11 +197,12 @@ function toCanonical(
|
||||
function toRecurrenceContext(
|
||||
engineCtx: CadenceEngineContext,
|
||||
): RecurrenceContext {
|
||||
const window = mintWindow(engineCtx);
|
||||
return {
|
||||
medication: {
|
||||
id: "cadence-med",
|
||||
startsOn: engineCtx.startsOn,
|
||||
endsOn: engineCtx.endsOn,
|
||||
startsOn: window.startsOn,
|
||||
endsOn: window.endsOn,
|
||||
oneShot: engineCtx.oneShot,
|
||||
createdAt: engineCtx.createdAt,
|
||||
},
|
||||
@@ -407,12 +444,14 @@ export function expandScheduleSlots(
|
||||
retro.now,
|
||||
)
|
||||
: occurrencesBetween(canonical, from, inclusiveTo, recurrenceCtx);
|
||||
return occurrences.map((occ) => ({
|
||||
day: startOfLocalDay(occ.at, timeZone),
|
||||
windowStart: occ.at,
|
||||
windowEnd: occ.graceUntil,
|
||||
scheduleIndex,
|
||||
}));
|
||||
return occurrences
|
||||
.filter((occ) => onCourseDay(engineCtx, occ.at))
|
||||
.map((occ) => ({
|
||||
day: startOfLocalDay(occ.at, timeZone),
|
||||
windowStart: occ.at,
|
||||
windowEnd: occ.graceUntil,
|
||||
scheduleIndex,
|
||||
}));
|
||||
}
|
||||
|
||||
const recurrence = parseScheduleRecurrence(schedule.daysOfWeek);
|
||||
@@ -864,18 +903,19 @@ function missedFromLedger(
|
||||
if (!engineCtx) return null;
|
||||
const from = new Date(asOf.getTime() - windowDays * DAY_MS);
|
||||
const userTz = engineCtx.timeZone || timeZone || "UTC";
|
||||
const window = mintWindow(engineCtx);
|
||||
const medication: BandMinterMedication = {
|
||||
id: "missed-tally",
|
||||
startsOn: engineCtx.startsOn,
|
||||
endsOn: engineCtx.endsOn,
|
||||
startsOn: window.startsOn,
|
||||
endsOn: window.endsOn,
|
||||
oneShot: engineCtx.oneShot,
|
||||
createdAt: engineCtx.createdAt,
|
||||
};
|
||||
const recurrenceCtx: RecurrenceContext = {
|
||||
medication: {
|
||||
id: "missed-tally",
|
||||
startsOn: engineCtx.startsOn,
|
||||
endsOn: engineCtx.endsOn,
|
||||
startsOn: window.startsOn,
|
||||
endsOn: window.endsOn,
|
||||
oneShot: engineCtx.oneShot,
|
||||
createdAt: engineCtx.createdAt,
|
||||
},
|
||||
@@ -927,5 +967,9 @@ function missedFromLedger(
|
||||
asOf,
|
||||
engineCtx.createdAt,
|
||||
);
|
||||
return rows.filter((r) => r.status === "missed").length;
|
||||
return rows.filter(
|
||||
(r) =>
|
||||
r.status === "missed" &&
|
||||
(r.kind !== "slot" || onCourseDay(engineCtx, r.at)),
|
||||
).length;
|
||||
}
|
||||
Reference in new issue
Block a user