Files
HealthLog/src/lib/analytics/__tests__/compliance.test.ts
T
Marc-André Bombeck 61c35740fc Show the dose-count denominator on the compliance bars
Two identical compliance percentages with no context read as a stuck
display, even when the math is correct (a rolling weekly med on cadence is
~100% on every window by construction; a daily med with full adherence
rounds to ~100% on both 7d and 30d). Render the taken-of-expected count
after each rate (e.g. 100% with 4 / 4 and 100% with 52 / 52) so the two rows
stay distinguishable and trustworthy. Falls back to the bare expected count
when only the denominator is on the wire, and to nothing when neither count
is available.

Thread a per-window taken count onto ComplianceDisplay (short.taken /
long.taken) alongside the existing expectedShort / expectedLong, wire both
through to the shared bars component, and regen the OpenAPI schema.

Add unit tests asserting the windows do not structurally collapse to one
value: a daily med with misses outside the 7-day window scores 7d above 30d,
and a rolling weekly med with a partial-adherence history yields a short rate
above the long rate. Add card-part tests for the count / fallback / no-count
render branches.
2026-06-07 01:06:17 +02:00

1884 lines
73 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* v1.5.0 — `calculateCompliance` is a cadence-aware adapter over
* `buildCadenceTimeline`. The historical wire shape (`totalExpected`,
* `taken`, `skipped`, `missed`, `rate`, `streak`) is unchanged, but
* the numbers now honour `daysOfWeek` and `intervalWeeks`. These
* tests pin the contract for every cadence the production app
* exercises: daily 1×/day, daily multi-dose, weekly Mondays-only,
* bi-weekly, weekday-only multi-dose, and the DST-boundary day.
* Closes #214.
*
* Each test uses `vi.useFakeTimers()` to pin `now` so the rolling
* window is deterministic. `classifyIntakeTiming` tests below the
* matrix are unchanged from the v1.4 line.
*/
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import {
MIN_STABLE_DOSES,
buildComplianceDisplay,
buildComplianceMedicationContext,
calculateCompliance,
classifyIntakeTiming,
expectedSlotCountForDay,
expectedSlotsBetween,
lastNonSkippedTakenAt,
type ComplianceMedicationContext,
type ComplianceSchedule,
} from "../compliance";
import type { IntakeTimingClass } from "../compliance";
import { buildCadenceTimeline } from "@/lib/medications/scheduling/cadence";
import { getUserTodayBounds } from "@/lib/timezone";
import { userDayKey } from "@/lib/tz/format";
const DAY_MS = 24 * 60 * 60 * 1000;
function eventAt(scheduledFor: Date, taken: boolean, skipped = false) {
return {
scheduledFor,
takenAt: taken ? new Date(scheduledFor.getTime() + 30 * 60_000) : null,
skipped,
};
}
describe("calculateCompliance — cadence-aware adapter", () => {
// Pin `now` to a Wednesday so weekday-only schedules have a clean
// count of Mondays in the trailing 30-day window.
const NOW = new Date("2025-01-15T12:00:00Z");
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
it("returns the empty result when no schedules are configured", () => {
const result = calculateCompliance([], [], 7);
expect(result).toEqual({
totalExpected: 0,
taken: 0,
skipped: 0,
missed: 0,
rate: 100,
streak: 0,
});
});
it("daily 1×/day, 7 of 7 taken in a 7-day window → 100%", () => {
const schedules: ComplianceSchedule[] = [
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null },
];
// Anchor events to 08:30 UTC on today (NOW = 12:00 UTC, so the
// morning slot is already past) and the six prior days. Going one
// day further back would land the event before the rolling-window
// start (NOW − 7 d = 2025-01-08T12:00Z, but the would-be event sits
// at 2025-01-08T08:30Z — below the window start), which the slot
// grid intentionally excludes. The original `NOW − N×DAY + 8h`
// shape also landed events at 20:00 UTC on the prior day, twelve
// hours away from the 08:00 slot — at the edge of the pairing
// radius, which matched in Europe/Berlin and missed in UTC.
const events = Array.from({ length: 7 }, (_, i) => {
const at = new Date(NOW);
at.setUTCDate(at.getUTCDate() - i);
at.setUTCHours(8, 30, 0, 0);
return eventAt(at, true);
});
const result = calculateCompliance(events, schedules, 7);
expect(result.rate).toBe(100);
expect(result.taken).toBe(7);
expect(result.missed).toBe(0);
});
it("daily 1×/day, 0 of 7 taken in a 7-day window → 0%", () => {
const schedules: ComplianceSchedule[] = [
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null },
];
const result = calculateCompliance([], schedules, 7);
expect(result.rate).toBe(0);
expect(result.taken).toBe(0);
expect(result.missed).toBeGreaterThan(0);
});
it("daily 3×/day, 18 of 21 taken in a 7-day window → 86% (no-cadence-restriction happy path)", () => {
// The pre-v1.5.0 path got this case right; pin it so the migration
// doesn't regress users on a daily multi-dose schedule.
const schedules: ComplianceSchedule[] = [
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null },
{ windowStart: "13:00", windowEnd: "14:00", daysOfWeek: null },
{ windowStart: "20:00", windowEnd: "21:00", daysOfWeek: null },
];
// Generate 21 events (3/day × 7 days) and drop 3 so 18 are taken.
const events = [];
for (let d = 1; d <= 7; d++) {
for (const hour of [8, 13, 20]) {
// Drop dose #4, #11, #18 to land on 18/21 → 86%.
const idx = (d - 1) * 3 + [8, 13, 20].indexOf(hour);
const taken = ![3, 10, 17].includes(idx);
const scheduledFor = new Date(
NOW.getTime() - d * DAY_MS + hour * 3600_000,
);
events.push(eventAt(scheduledFor, taken));
}
}
const result = calculateCompliance(events, schedules, 7);
expect(result.rate).toBe(86);
expect(result.taken).toBe(18);
expect(result.missed).toBe(3);
});
it("daily 2×/day on ONE schedule row (timesOfDay), all taken → 100%", () => {
// Production shape: the wizard stores a twice-daily med as a single
// MedicationSchedule row carrying `timesOfDay = ["07:00","19:00"]`,
// NOT two rows. Pre-fix the legacy cadence walker only emitted the
// `windowStart` slot, so the denominator was one dose/day and a
// perfectly-adherent user could read 50% once a second dose was
// logged. Pin the single-row twice-daily case at 100%.
const schedules: ComplianceSchedule[] = [
{
windowStart: "07:00",
windowEnd: "07:30",
daysOfWeek: null,
timesOfDay: ["07:00", "19:00"],
},
];
// Log both doses for every day in (and just past) the 7-day window
// so no emitted slot is left unpaired at the window boundary. NOW is
// 12:00 UTC, so today's 19:00 slot is still `upcoming` (excluded).
const events = [];
for (let d = 0; d <= 8; d++) {
const day = new Date(NOW);
day.setUTCDate(day.getUTCDate() - d);
const morning = new Date(day);
morning.setUTCHours(7, 5, 0, 0);
const evening = new Date(day);
evening.setUTCHours(19, 5, 0, 0);
events.push(eventAt(morning, true));
if (d > 0) events.push(eventAt(evening, true));
}
const result = calculateCompliance(events, schedules, 7);
expect(result.rate).toBe(100);
expect(result.missed).toBe(0);
// The window covers ~7 days × 2 doses, so the denominator is well
// above the 7-or-so a one-slot-per-day walker would produce. The
// load-bearing assertion is that BOTH daily doses are counted (the
// fan-out) and every one pairs to a taken event → 100%.
expect(result.totalExpected).toBeGreaterThanOrEqual(12);
expect(result.taken).toBe(result.totalExpected);
});
it("2×/day, delete the evening dose then re-add it → still 100% / 2 of 2", () => {
// The live regression: a user on a 07:00 + 19:00 schedule deletes
// the evening intake and re-adds it. The compliance must read the
// re-added dose dynamically — 2 expected, 2 taken — not collapse to
// 50%. Modelled here on a single calendar day with both slots in the
// past so neither is `upcoming`.
const lateNow = new Date("2025-01-15T22:00:00Z");
vi.setSystemTime(lateNow);
const schedules: ComplianceSchedule[] = [
{
windowStart: "07:00",
windowEnd: "07:30",
daysOfWeek: null,
timesOfDay: ["07:00", "19:00"],
},
];
// Morning taken once; the evening dose was deleted (its tombstoned
// row is excluded from the events array by the route's
// `deletedAt: null` filter) and re-added as a fresh taken event.
const morning = new Date("2025-01-15T07:05:00Z");
const eveningReAdded = new Date("2025-01-15T19:10:00Z");
const events = [
eventAt(morning, true),
eventAt(eveningReAdded, true),
];
const result = calculateCompliance(events, schedules, 1, undefined, {
now: lateNow,
});
expect(result.taken).toBe(2);
expect(result.totalExpected).toBe(2);
expect(result.missed).toBe(0);
expect(result.rate).toBe(100);
});
it("weekly Mondays, all Mondays taken in a 30-day window → 100% (the #214 bug case)", () => {
// Pre-v1.5.0 this returned ~13% (taken / (schedules × 30) = 4/30).
// The fix is to honour `daysOfWeek` so the denominator is the
// number of Mondays in the window, not 30.
const schedules: ComplianceSchedule[] = [
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: "1" },
];
// NOW = Wed 2025-01-15. Mondays in the trailing 30-day window =
// 2025-01-13, 2025-01-06, 2024-12-30, 2024-12-23, 2024-12-16.
const mondays = [
new Date("2025-01-13T08:30:00Z"),
new Date("2025-01-06T08:30:00Z"),
new Date("2024-12-30T08:30:00Z"),
new Date("2024-12-23T08:30:00Z"),
new Date("2024-12-16T08:30:00Z"),
];
const events = mondays.map((m) => eventAt(m, true));
const result = calculateCompliance(events, schedules, 30);
expect(result.rate).toBe(100);
expect(result.taken).toBeGreaterThanOrEqual(4);
expect(result.missed).toBe(0);
});
it("weekly Mondays, one Monday missed in a 30-day window → 75–80%", () => {
const schedules: ComplianceSchedule[] = [
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: "1" },
];
// Take 3 of the 4 most-recent Mondays, miss one.
const events = [
eventAt(new Date("2025-01-13T08:30:00Z"), true),
// 2025-01-06 missed.
eventAt(new Date("2024-12-30T08:30:00Z"), true),
eventAt(new Date("2024-12-23T08:30:00Z"), true),
];
const result = calculateCompliance(events, schedules, 30);
expect(result.rate).toBeGreaterThanOrEqual(60);
expect(result.rate).toBeLessThan(100);
expect(result.missed).toBeGreaterThanOrEqual(1);
});
it("bi-weekly (intervalWeeks=2), all scheduled doses taken in a 30-day window → 100%", () => {
// Encoded recurrence: 2 weeks interval, Monday-anchored.
const schedules: ComplianceSchedule[] = [
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: "i2;1" },
];
// With NOW = Wed Jan 15 the window covers ~Dec 16–Jan 15. The
// anchor week starts on the medicationCreatedAt week. Take both
// possible weeks-anchored Mondays so the denominator is 2 (or 3,
// depending on phase) and the rate is 100% either way.
const createdAt = new Date("2024-11-04T00:00:00Z"); // Mon
const events = [
eventAt(new Date("2025-01-13T08:30:00Z"), true),
eventAt(new Date("2024-12-30T08:30:00Z"), true),
eventAt(new Date("2024-12-16T08:30:00Z"), true),
];
const result = calculateCompliance(events, schedules, 30, createdAt);
expect(result.rate).toBe(100);
expect(result.missed).toBe(0);
});
it("weekday-only 3×/day, every weekday dose taken in a 30-day window → 100% (the #214 metformin case)", () => {
// Pre-v1.5.0 this returned ~73% (66 weekday doses ÷ 90 = 73%).
// The fix is to honour `daysOfWeek` so the denominator is
// 3 schedules × ~22 weekdays in the window, not 3 × 30.
const schedules: ComplianceSchedule[] = [
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: "1,2,3,4,5" },
{ windowStart: "13:00", windowEnd: "14:00", daysOfWeek: "1,2,3,4,5" },
{ windowStart: "20:00", windowEnd: "21:00", daysOfWeek: "1,2,3,4,5" },
];
// Generate events for every weekday slot inside the window —
// including today's already-past slots (08:00 UTC < NOW = 12:00).
const events = [];
for (let d = 0; d <= 30; d++) {
const day = new Date(NOW.getTime() - d * DAY_MS);
const dow = day.getUTCDay();
if (dow === 0 || dow === 6) continue; // skip weekends
for (const hour of [8, 13, 20]) {
const at = new Date(day);
at.setUTCHours(hour, 30, 0, 0);
// Skip future slots (today's 13:00 and 20:00 are in the future
// relative to NOW = 12:00 UTC); the timeline marks those
// `upcoming` and excludes them from the denominator anyway.
if (at.getTime() > NOW.getTime()) continue;
events.push(eventAt(at, true));
}
}
const result = calculateCompliance(events, schedules, 30);
expect(result.rate).toBe(100);
expect(result.missed).toBe(0);
});
it("skipped doses are excluded from the denominator (not a compliance failure)", () => {
const schedules: ComplianceSchedule[] = [
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null },
];
// 7-day window — every day has a slot, every event lands on its
// own day. Two taken + one skipped + four missed.
const events = [
eventAt(new Date("2025-01-14T08:30:00Z"), true),
{
scheduledFor: new Date("2025-01-13T08:30:00Z"),
takenAt: null,
skipped: true,
},
eventAt(new Date("2025-01-12T08:30:00Z"), true),
];
const result = calculateCompliance(events, schedules, 7);
expect(result.skipped).toBe(1);
expect(result.taken).toBe(2);
// The skipped day is excluded from the rate denominator.
// Slots: Jan-9..Jan-15 = 7 (Jan-8 windowEnd 09:00 falls below the
// window start of 12:00). Taken = 2, skipped = 1, missed = 4
// (Jan-9, 10, 11, 15). Rate = 2 taken / (2 + 4 missed) = 33%.
expect(result.rate).toBe(33);
});
it("excludes days before medicationCreatedAt from the denominator", () => {
const schedules: ComplianceSchedule[] = [
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null },
];
// Medication created 3 days ago — the prior 4 days of the 7-day
// window must not count as missed. Anchor each event at 08:30 UTC
// on today + the two prior days (NOW = 12:00 UTC so today's morning
// slot is already past). Going to "3 days ago" would land the event
// at the createdAt instant itself (08:30 < 12:00 on the cutoff day)
// which slips just below the slot grid's emit-cutoff.
const createdAt = new Date(NOW.getTime() - 3 * DAY_MS);
const events = [0, 1, 2].map((daysAgo) => {
const at = new Date(NOW);
at.setUTCDate(at.getUTCDate() - daysAgo);
at.setUTCHours(8, 30, 0, 0);
return eventAt(at, true);
});
const result = calculateCompliance(events, schedules, 7, createdAt);
expect(result.rate).toBe(100);
expect(result.taken).toBe(3);
expect(result.missed).toBe(0);
});
it("DST boundary (Europe/Berlin spring-forward) does not inflate the expected count", () => {
// Spring-forward in Berlin: 2025-03-30 02:00 → 03:00. A daily
// schedule across this boundary must still emit one slot per
// local day — not two on the short day and zero on the next.
// Pin `now` to Apr-02 noon UTC and run a 7-day window so the
// boundary day is inside it. Generate one event per day
// (working backwards from NOW − 1 day so each event lands inside
// the window) and assert the totalExpected lands at 7 (not 8).
vi.setSystemTime(new Date("2025-04-02T12:00:00Z"));
const schedules: ComplianceSchedule[] = [
{ windowStart: "10:00", windowEnd: "11:00", daysOfWeek: null },
];
// Slots emitted by the timeline: Mar-27..Apr-02 (Mar-26's window
// sits below the rolling-window start of Mar-26 12:00). Generate
// one taken event per slot — today's 10:30 slot is also in the
// past relative to NOW = 12:00 so it counts as taken too.
const events = [];
for (let d = 0; d <= 6; d++) {
const at = new Date("2025-04-02T10:30:00Z");
at.setUTCDate(at.getUTCDate() - d);
events.push(eventAt(at, true));
}
const result = calculateCompliance(events, schedules, 7);
// The hard contract: totalExpected stays at 7 — not 8 (which
// would mean the DST short day double-emitted) and not 6 (which
// would mean the short day silently dropped a slot).
expect(result.totalExpected).toBe(7);
expect(result.taken).toBe(7);
expect(result.missed).toBe(0);
expect(result.rate).toBe(100);
});
it("filters events outside the rolling window", () => {
const schedules: ComplianceSchedule[] = [
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null },
];
const events = [
eventAt(new Date("2025-01-14T08:30:00Z"), true),
eventAt(new Date("2024-12-01T08:30:00Z"), true),
];
const result = calculateCompliance(events, schedules, 7);
expect(result.taken).toBe(1);
});
it("caps rate at 100% when more intakes than expected exist", () => {
const schedules: ComplianceSchedule[] = [
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null },
];
// Same-day duplicate logs — the pair algorithm matches one and
// leaves the duplicates dangling; the rate stays at or below 100.
const events = Array.from({ length: 10 }, (_, i) => ({
scheduledFor: new Date(`2025-01-14T08:${String(i).padStart(2, "0")}:00Z`),
takenAt: new Date(`2025-01-14T08:${String(i).padStart(2, "0")}:00Z`),
skipped: false,
}));
const result = calculateCompliance(events, schedules, 7);
expect(result.rate).toBeLessThanOrEqual(100);
});
});
// ────────────────────────────────────────────────────────────────────
// v1.7.0 SB-SCHED-2 — canonical-engine-routed compliance (Option B)
//
// When a `medicationContext` is supplied, the expected-slot denominator
// runs through the canonical recurrence engine. These golden fixtures
// pin every cadence type's denominator explicitly — this is the
// riskiest change of the release because it moves every adherence number
// for non-daily meds.
// ────────────────────────────────────────────────────────────────────
describe("calculateCompliance — engine-routed (medicationContext)", () => {
// Pin `now` to a Wednesday so a weekly Monday cadence has a clean
// count of Mondays in the trailing 30-day window.
const NOW = new Date("2025-01-15T12:00:00Z");
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
function ctx(overrides: Partial<{
startsOn: Date | null;
endsOn: Date | null;
oneShot: boolean;
createdAt: Date;
lastIntakeAt: Date | null;
timeZone: string;
}> = {}) {
return {
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2024-12-01T00:00:00Z"),
lastIntakeAt: null,
timeZone: "UTC",
...overrides,
};
}
it("parity: a legacy daysOfWeek-only schedule yields identical numbers with no context", () => {
const schedules: ComplianceSchedule[] = [
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null },
];
const events = Array.from({ length: 7 }, (_, i) =>
eventAt(new Date(NOW.getTime() - (i + 1) * DAY_MS), true),
);
const legacy = calculateCompliance(events, schedules, 7);
const withCtx = calculateCompliance(events, schedules, 7, undefined, {
medicationContext: ctx(),
});
// Legacy daysOfWeek-only schedule has no canonical fields → both
// paths run the legacy walker → identical numbers.
expect(withCtx).toEqual(legacy);
});
it("FREQ=WEEKLY;BYDAY=MO — took every Monday → 100%, denominator counts only Mondays", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=WEEKLY;BYDAY=MO",
timesOfDay: ["08:00"],
},
];
// Mondays in the trailing 30 days from Wed 2025-01-15: Jan 13, 6,
// Dec 30, 23, 16. Log a taken intake on each.
const mondays = [
"2025-01-13T08:30:00Z",
"2025-01-06T08:30:00Z",
"2024-12-30T08:30:00Z",
"2024-12-23T08:30:00Z",
"2024-12-16T08:30:00Z",
].map((s) => ({
scheduledFor: new Date(s),
takenAt: new Date(s),
skipped: false,
}));
const result = calculateCompliance(mondays, schedules, 30, undefined, {
medicationContext: ctx(),
});
expect(result.rate).toBe(100);
// Denominator is the count of Mondays, not 30 days.
expect(result.totalExpected).toBeLessThanOrEqual(6);
expect(result.totalExpected).toBeGreaterThanOrEqual(4);
});
it("FREQ=WEEKLY;INTERVAL=2;BYDAY=WE — bi-weekly denominator counts only on-weeks", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=WE",
timesOfDay: ["08:00"],
},
];
const result = calculateCompliance([], schedules, 30, undefined, {
medicationContext: ctx({ startsOn: new Date("2024-12-04T00:00:00Z") }),
});
// Over ~30 days a bi-weekly Wednesday emits roughly 2 slots, never
// the ~4 a weekly Wednesday would. Nothing taken → all missed.
expect(result.totalExpected).toBeLessThanOrEqual(3);
});
it("rolling rollingIntervalDays=7 — the open forward cycle is upcoming, not a slot (no events → empty)", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
},
];
// v1.13.x — this test previously asserted the OLD forward-only behaviour
// ("only the next-due slot counts"). With the retrospective rolling
// expansion the grid is built from the LOGGED intakes — here there are
// none, and `lastIntakeAt` is 5 days ago so the forward next-due slot is
// 2 days out (future, > now) → excluded as upcoming. With no logged
// intakes in the window and no past-due slot the window is empty →
// empty-window contract → 100 / totalExpected 0. (A faithful history now
// reads its true rate — see the dedicated 12-shot test below.)
const result = calculateCompliance([], schedules, 30, undefined, {
medicationContext: ctx({
lastIntakeAt: new Date(NOW.getTime() - 5 * DAY_MS),
}),
});
expect(result.totalExpected).toBe(0);
expect(result.rate).toBe(100);
});
// ──────────────────────────────────────────────────────────────────
// v1.13.x — ROLLING retrospective expansion. The canonical engine's
// `expandRolling` is forward-only: it emits at most the single
// immediately-next slot, so a historical compliance window over a
// rolling weekly injection (the GLP-1 default) saw either zero expected
// slots (vacuous 100%) or one overdue slot (hard 0%) — never the true
// multi-dose adherence. The compliance layer now reconstructs the
// historical grid from the logged intakes (each dose is one satisfied
// expected slot, with synthesized misses for skipped whole cycles + a
// past-due forward slot). These tests pin that the live "0% despite many
// recorded intakes" defect is fixed.
// ──────────────────────────────────────────────────────────────────
it("rolling rollingIntervalDays=7 — 12 weekly shots logged → high rate, not 0%/100%-vacuous", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
},
];
// 12 consecutive weekly shots, each ~7 days apart, taken at varying
// times of day (the real-world off-HH:mm pattern). Most recent 3 days ago.
const lastShot = new Date(NOW.getTime() - 3 * DAY_MS);
const intakes = Array.from({ length: 12 }, (_, i) => {
const at = new Date(lastShot.getTime() - i * 7 * DAY_MS);
at.setUTCHours(19 + (i % 3), 0, 0, 0); // 19:00/20:00/21:00 — off the 08:00 slot
return { scheduledFor: at, takenAt: at, skipped: false };
});
const lastIntakeAt = intakes[0].takenAt;
for (const days of [30, 90]) {
const result = calculateCompliance(intakes, schedules, days, undefined, {
medicationContext: ctx({ lastIntakeAt, startsOn: intakes[11].takenAt }),
});
// Each logged shot is a satisfied expected slot → taken tracks the
// number of shots in the window; rate is high; not the broken 0%.
expect(result.taken).toBeGreaterThanOrEqual(days === 30 ? 4 : 12);
expect(result.rate).toBeGreaterThanOrEqual(90);
expect(result.missed).toBe(0);
}
});
it("rolling weekly — a shot logged a day late from its cycle still pairs (2 of 2, no phantom miss)", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
},
];
// Two shots: cycle anchor + a second shot logged ~8 days later (1 day
// late, inside the 1.5·N tolerance), then nothing — both count taken,
// none missed (the late dose must NOT synthesize a phantom missed cycle).
const first = new Date(NOW.getTime() - 16 * DAY_MS);
const second = new Date(first.getTime() + 8 * DAY_MS); // 1 day late
const intakes = [
{ scheduledFor: second, takenAt: second, skipped: false },
{ scheduledFor: first, takenAt: first, skipped: false },
];
const result = calculateCompliance(intakes, schedules, 30, undefined, {
medicationContext: ctx({ lastIntakeAt: second, startsOn: first }),
});
expect(result.taken).toBe(2);
expect(result.missed).toBe(0);
expect(result.rate).toBe(100);
});
it("rolling weekly — a 3-week gap between shots synthesizes the skipped cycles as missed", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
},
];
// Shot A, then a 21-day gap (2 missed cycles), then shot B.
const a = new Date(NOW.getTime() - 28 * DAY_MS);
const b = new Date(a.getTime() + 21 * DAY_MS);
const intakes = [
{ scheduledFor: b, takenAt: b, skipped: false },
{ scheduledFor: a, takenAt: a, skipped: false },
];
const result = calculateCompliance(intakes, schedules, 30, undefined, {
medicationContext: ctx({ lastIntakeAt: b, startsOn: a }),
});
// 2 taken (A, B) + ~2 synthesized missed cycles in the gap → ~50%.
expect(result.taken).toBe(2);
expect(result.missed).toBeGreaterThanOrEqual(1);
expect(result.rate).toBeLessThan(100);
});
it("rolling weekly — last shot 3 days ago, next due in 4 days → upcoming, excluded from denominator", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
},
];
const lastShot = new Date(NOW.getTime() - 3 * DAY_MS);
const intakes = [
{ scheduledFor: lastShot, takenAt: lastShot, skipped: false },
];
const result = calculateCompliance(intakes, schedules, 30, undefined, {
medicationContext: ctx({ lastIntakeAt: lastShot, startsOn: lastShot }),
});
// The one closed cycle (the logged shot) is taken; the open forward
// cycle is upcoming and must NOT count as missed → 100%, missed 0.
expect(result.missed).toBe(0);
expect(result.taken).toBe(1);
expect(result.rate).toBe(100);
});
// ──────────────────────────────────────────────────────────────────
// v1.12.0 — weekly-injectable matching radius. A weekly med (Mounjaro)
// is dosed once a week, but a real intake is almost never logged within
// 12h of the schedule's configured HH:mm: the user takes the shot on
// whichever day / time of the dosing week suits them. The pre-fix ±12h
// pairing radius orphaned those intakes — every weekly slot read
// `missed` and the rate collapsed to 0% across EVERY window despite
// recorded intakes. The radius now scales with the cadence gap so an
// intake anywhere in the dosing week pairs to that week's slot.
// ──────────────────────────────────────────────────────────────────
it("FREQ=WEEKLY;BYDAY=MO — Mondays taken late in the evening (22:00) still pair → 100%, not 0%", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "07:30",
windowEnd: "08:30",
daysOfWeek: null,
rrule: "FREQ=WEEKLY;BYDAY=MO",
timesOfDay: ["07:30"],
},
];
// The configured slot is 07:30; the user logs the shot the same
// Monday but at 22:00 — 14.5h from the slot, outside the legacy ±12h
// radius. Thirteen consecutive Mondays back from the pinned Wednesday.
const lastMonday = new Date("2025-01-13T22:00:00Z");
const intakes = Array.from({ length: 13 }, (_, i) => {
const at = new Date(lastMonday.getTime() - i * 7 * DAY_MS);
return { scheduledFor: at, takenAt: at, skipped: false };
});
const lastIntakeAt = intakes[0].takenAt;
for (const days of [7, 30, 90]) {
const result = calculateCompliance(intakes, schedules, days, undefined, {
medicationContext: ctx({ lastIntakeAt }),
});
expect(result.rate).toBe(100);
expect(result.taken).toBeGreaterThan(0);
expect(result.missed).toBe(0);
}
});
it("FREQ=WEEKLY;BYDAY=MO — single-slot 7-day window: an off-time Monday still pairs (1/1 = 100%)", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "07:30",
windowEnd: "08:30",
daysOfWeek: null,
rrule: "FREQ=WEEKLY;BYDAY=MO",
timesOfDay: ["07:30"],
},
];
// The 7-day window holds exactly one Monday slot — the per-slot
// neighbour-gap widening can't fire (no second slot), so this pins the
// schedule-derived radius floor that `buildCadenceTimeline` supplies.
const monday = new Date("2025-01-13T20:00:00Z"); // 12.5h after 07:30
const intakes = [{ scheduledFor: monday, takenAt: monday, skipped: false }];
const result = calculateCompliance(intakes, schedules, 7, undefined, {
medicationContext: ctx({ lastIntakeAt: monday }),
});
expect(result.totalExpected).toBe(1);
expect(result.taken).toBe(1);
expect(result.rate).toBe(100);
});
it("legacy daysOfWeek='1' (Monday-only) — off-time Mondays still pair → non-zero across all windows", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "07:30",
windowEnd: "08:30",
daysOfWeek: "1",
timesOfDay: ["07:30"],
},
];
const lastMonday = new Date("2025-01-13T21:00:00Z");
const intakes = Array.from({ length: 13 }, (_, i) => {
const at = new Date(lastMonday.getTime() - i * 7 * DAY_MS);
return { scheduledFor: at, takenAt: at, skipped: false };
});
const lastIntakeAt = intakes[0].takenAt;
for (const days of [7, 30, 90]) {
const result = calculateCompliance(intakes, schedules, days, undefined, {
medicationContext: ctx({ lastIntakeAt }),
});
expect(result.rate).toBe(100);
expect(result.missed).toBe(0);
}
});
it("DAILY cadence keeps the 12h floor — an intake equidistant-far from every slot stays unmatched", () => {
// Guard the widened radius against over-reach on a DENSE cadence: a
// daily schedule's gap is 24h, so its match radius stays at the 12h
// floor. An intake at 20:00 sits exactly 12h from the same day's 08:00
// slot and 12h from the next day's 08:00 slot — at the boundary it
// claims the nearer (same-day) slot, but a single off-day intake can
// never pair to a slot two days away the way the weekly widening
// allows. Pin the floor by confirming one intake claims at most one
// daily slot.
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=DAILY",
timesOfDay: ["08:00"],
},
];
// One intake far from every slot's 08:00 centre — 02:00, which is 6h
// before that day's slot but also 18h before the prior day's: only the
// same-day slot is within 12h. A single intake → at most one taken.
const at = new Date("2025-01-13T02:00:00Z");
const intakes = [{ scheduledFor: at, takenAt: at, skipped: false }];
const result = calculateCompliance(intakes, schedules, 3, undefined, {
medicationContext: ctx({ lastIntakeAt: at }),
});
expect(result.taken).toBeLessThanOrEqual(1);
});
it("one-shot — exactly one expected slot on startsOn", () => {
const startsOn = new Date("2025-01-10T00:00:00Z");
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
scheduleType: "SCHEDULED",
timesOfDay: ["08:00"],
},
];
const result = calculateCompliance([], schedules, 30, undefined, {
medicationContext: ctx({ oneShot: true, startsOn, endsOn: startsOn }),
});
// One-shot anchored on Jan 10 → exactly one expected slot in the
// trailing-30 window; nothing taken → one missed.
expect(result.totalExpected).toBe(1);
expect(result.missed).toBe(1);
});
it("PRN — rate 100, totalExpected 0, even with a daily rrule present", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
scheduleType: "PRN",
rrule: "FREQ=DAILY",
timesOfDay: ["08:00"],
},
];
const result = calculateCompliance([], schedules, 30, undefined, {
medicationContext: ctx(),
});
expect(result.totalExpected).toBe(0);
expect(result.rate).toBe(100);
});
it("cyclic 2-on/1-off — off-week days are not counted in the denominator", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
scheduleType: "CYCLIC",
cyclicOnWeeks: 2,
cyclicOffWeeks: 1,
rrule: "FREQ=DAILY",
timesOfDay: ["08:00"],
},
];
const cyclicResult = calculateCompliance([], schedules, 30, undefined, {
medicationContext: ctx({ startsOn: new Date("2024-12-01T00:00:00Z") }),
});
const dailySchedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=DAILY",
timesOfDay: ["08:00"],
},
];
const dailyResult = calculateCompliance([], dailySchedules, 30, undefined, {
medicationContext: ctx({ startsOn: new Date("2024-12-01T00:00:00Z") }),
});
// Cyclic drops the off-weeks → strictly fewer expected slots than a
// plain daily over the same window.
expect(cyclicResult.totalExpected).toBeLessThan(dailyResult.totalExpected);
});
it("DST spring-forward day (Europe/Berlin) parity — daily rate stays 100", () => {
// Europe/Berlin spring-forward 2025-03-30. Pin now just after it.
vi.setSystemTime(new Date("2025-03-31T12:00:00Z"));
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=DAILY",
timesOfDay: ["08:00"],
},
];
// Log a taken intake around 08:00 Berlin local for each of the past
// 7 days. The ±12 h pairing radius absorbs the DST hour shift, so a
// daily schedule across the spring-forward boundary stays compliant.
// Log a taken intake around 08:00 Berlin local for each day spanning
// the window with a one-day pad on each edge. The ±12 h pairing
// radius absorbs the DST hour shift, so every expected daily slot
// across the spring-forward boundary pairs with a logged intake.
const events = Array.from({ length: 10 }, (_, i) => {
const sched = new Date(
new Date("2025-03-31T12:00:00Z").getTime() - (i - 1) * DAY_MS,
);
sched.setUTCHours(6, 30, 0, 0); // ~08:00 Berlin (CET/CEST)
return { scheduledFor: sched, takenAt: sched, skipped: false };
});
const result = calculateCompliance(events, schedules, 7, undefined, {
medicationContext: ctx({ timeZone: "Europe/Berlin" }),
});
// Daily cadence across the DST boundary stays fully compliant — no
// expected slot is left unpaired.
expect(result.rate).toBe(100);
expect(result.missed).toBe(0);
});
it("B15: legacy daysOfWeek row with two timesOfDay — 2×/day taken reads 100%, not 50%", () => {
// Regression for the compliance divergence: a plain `daysOfWeek`
// schedule (no rrule / rolling, SCHEDULED, not one-shot) carrying two
// `timesOfDay` must expand its numerator through the same engine as
// the denominator. Before the fix the numerator's local legacy walker
// emitted one slot/day from `windowStart`, while the denominator's
// engine counted both — collapsing a fully-adherent 2×/day med to
// 1/2 = 50%.
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "19:30",
daysOfWeek: null, // every day
timesOfDay: ["08:00", "19:00"],
// no rrule, no rollingIntervalDays, scheduleType defaults SCHEDULED
},
];
// Pin a single-day window so exactly two slots are expected. NOW is
// late evening UTC so both of today's slots are in the past.
vi.setSystemTime(new Date("2025-06-09T23:30:00Z"));
const events = [
eventAt(new Date("2025-06-09T08:00:00Z"), true),
eventAt(new Date("2025-06-09T19:00:00Z"), true),
];
const result = calculateCompliance(events, schedules, 1, undefined, {
medicationContext: ctx({ startsOn: new Date("2025-05-01T00:00:00Z") }),
});
// Both daily slots expand and both pair with a taken intake → 2/2.
expect(result.totalExpected).toBe(2);
expect(result.taken).toBe(2);
expect(result.rate).toBe(100);
});
it("streak is computed in the USER's timezone, not the host's", () => {
// The timeline `slot.day` is minted in the user's IANA zone. The
// streak walk must key days in the SAME zone — the prior host-tz
// `getFullYear/getMonth/getDate` walk drifted off by a day whenever
// the server clock's zone differed from the user's. Use a far-east
// user zone (UTC+13) so the user-local day for a near-UTC-midnight
// intake clearly differs from the host/UTC day; a daily-taken stream
// must still yield an unbroken streak.
const tz = "Pacific/Auckland";
vi.setSystemTime(new Date("2025-06-10T11:00:00Z")); // ~23:00 NZST.
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
timesOfDay: ["08:00"],
},
];
// Taken near 08:00 Auckland (= ~20:00 UTC previous day) for each of
// the past 7 user-local days.
const events = Array.from({ length: 7 }, (_, i) => {
const sched = new Date(
new Date("2025-06-09T20:00:00Z").getTime() - i * DAY_MS,
);
return { scheduledFor: sched, takenAt: sched, skipped: false };
});
const result = calculateCompliance(events, schedules, 7, undefined, {
medicationContext: ctx({
timeZone: tz,
createdAt: new Date("2025-05-01T00:00:00Z"),
}),
});
// Every user-local day in the window was taken → a multi-day streak.
// The host-tz walk would have mis-keyed the boundary days and broken
// the streak early; the user-tz walk counts them all.
expect(result.streak).toBeGreaterThanOrEqual(6);
});
});
// ────────────────────────────────────────────────────────────────────
// v1.7.0 item 5 — per-day due / expectedCount helpers
// ────────────────────────────────────────────────────────────────────
describe("expectedSlotCountForDay + lastNonSkippedTakenAt", () => {
function ctx(
overrides: Partial<ComplianceMedicationContext> = {},
): ComplianceMedicationContext {
return buildComplianceMedicationContext(
{
startsOn: overrides.startsOn ?? null,
endsOn: overrides.endsOn ?? null,
oneShot: overrides.oneShot ?? false,
createdAt: overrides.createdAt ?? new Date("2024-12-01T00:00:00Z"),
},
overrides.lastIntakeAt ?? null,
overrides.timeZone ?? "UTC",
);
}
it("weekly BYDAY — due on the matching weekday, not due otherwise", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=WEEKLY;BYDAY=MO",
timesOfDay: ["08:00"],
},
];
// 2025-01-13 is a Monday; 2025-01-14 a Tuesday.
const monday = expectedSlotCountForDay(
schedules,
new Date("2025-01-13T00:00:00Z"),
new Date("2025-01-14T00:00:00Z"),
ctx(),
);
const tuesday = expectedSlotCountForDay(
schedules,
new Date("2025-01-14T00:00:00Z"),
new Date("2025-01-15T00:00:00Z"),
ctx(),
);
expect(monday).toBe(1);
expect(tuesday).toBe(0);
});
it("PRN — never due", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
scheduleType: "PRN",
rrule: "FREQ=DAILY",
timesOfDay: ["08:00"],
},
];
const count = expectedSlotCountForDay(
schedules,
new Date("2025-01-13T00:00:00Z"),
new Date("2025-01-14T00:00:00Z"),
ctx(),
);
expect(count).toBe(0);
});
it("multi time-of-day daily — expectedCount reflects each dose", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=DAILY",
timesOfDay: ["08:00", "20:00"],
},
];
const count = expectedSlotCountForDay(
schedules,
new Date("2025-01-13T00:00:00Z"),
new Date("2025-01-14T00:00:00Z"),
ctx(),
);
expect(count).toBe(2);
});
it("B15 invariant: legacy multi-time row — denominator and numerator expand identically", () => {
// The B15 bug lived in the numerator's expander, not here — this
// helper always ran the engine. Pin the convergence directly: for a
// plain `daysOfWeek` row carrying two `timesOfDay` (no rrule), the
// per-day denominator and the cadence numerator
// (`buildCadenceTimeline`) must report the same slot count.
const dayStart = new Date("2025-06-09T00:00:00Z");
const dayEnd = new Date("2025-06-10T00:00:00Z");
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "19:30",
daysOfWeek: null,
timesOfDay: ["08:00", "19:00"],
},
];
const medCtx = ctx({
startsOn: new Date("2025-05-01T00:00:00Z"),
timeZone: "Europe/Berlin",
});
const denominator = expectedSlotCountForDay(
schedules,
dayStart,
dayEnd,
medCtx,
);
const numerator = buildCadenceTimeline(
[
{
windowStart: "08:00",
windowEnd: "19:30",
daysOfWeek: null,
timesOfDay: ["08:00", "19:00"],
},
],
[],
new Date("2025-06-09T23:59:00Z"),
1,
dayStart,
medCtx.timeZone,
{
startsOn: medCtx.startsOn,
endsOn: medCtx.endsOn,
oneShot: medCtx.oneShot,
createdAt: medCtx.createdAt,
lastIntakeAt: medCtx.lastIntakeAt,
timeZone: medCtx.timeZone,
},
).length;
expect(numerator).toBe(denominator);
expect(numerator).toBe(2);
});
it("lastNonSkippedTakenAt picks the newest non-skipped takenAt", () => {
const events = [
{ takenAt: new Date("2025-01-10T08:00:00Z"), skipped: false },
{ takenAt: new Date("2025-01-12T08:00:00Z"), skipped: false },
{ takenAt: new Date("2025-01-13T08:00:00Z"), skipped: true },
{ takenAt: null, skipped: false },
];
expect(lastNonSkippedTakenAt(events)?.toISOString()).toBe(
"2025-01-12T08:00:00.000Z",
);
expect(lastNonSkippedTakenAt([])).toBe(null);
});
// v1.7.0 code-correctness M1 — the per-med compliance route must
// anchor each daily cell on the user-tz local-day boundary so the
// `due` / `expectedCount` flag attaches to the calendar cell the user
// actually sees. A UTC-midnight slice would shove a tz-distant user's
// dose into the adjacent day. These tests model the route's day
// computation (getUserTodayBounds + userDayKey) and assert the engine
// counts the dose in the cell its dateKey labels.
it("anchors the due slot on the user-tz local day for a UTC+13 user", () => {
const tz = "Pacific/Auckland"; // UTC+12, +13 in DST (January = NZDT +13)
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=DAILY",
timesOfDay: ["08:00"],
},
];
// A representative instant inside 2025-01-14 local (noon NZ).
const representative = new Date("2025-01-14T12:00:00+13:00");
const { start, end } = getUserTodayBounds(representative, tz);
const dayEnd = new Date(end.getTime() + 1); // half-open [start, dayEnd)
// The cell key is the user-tz day, not the UTC slice. 08:00 NZ on
// the 14th is 19:00 UTC on the 13th — a UTC slice would mislabel it.
expect(userDayKey(start, tz)).toBe("2025-01-14");
const count = expectedSlotCountForDay(schedules, start, dayEnd, ctx({ timeZone: tz }));
expect(count).toBe(1);
});
it("does not double-count or drop a dose at the user-tz day boundary", () => {
const tz = "Pacific/Auckland";
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=DAILY",
timesOfDay: ["08:00"],
},
];
// Two adjacent local days: the 14th must hold exactly one dose, the
// 13th exactly one — neither steals the other's slot.
const day14 = getUserTodayBounds(new Date("2025-01-14T12:00:00+13:00"), tz);
const day13 = getUserTodayBounds(new Date("2025-01-13T12:00:00+13:00"), tz);
const count14 = expectedSlotCountForDay(
schedules,
day14.start,
new Date(day14.end.getTime() + 1),
ctx({ timeZone: tz }),
);
const count13 = expectedSlotCountForDay(
schedules,
day13.start,
new Date(day13.end.getTime() + 1),
ctx({ timeZone: tz }),
);
expect(count14).toBe(1);
expect(count13).toBe(1);
expect(userDayKey(day14.start, tz)).toBe("2025-01-14");
expect(userDayKey(day13.start, tz)).toBe("2025-01-13");
});
});
describe("classifyIntakeTiming", () => {
// v1.4.34 IW-C — the classifier now widens the pre-window grace to
// 3h and introduces an `early` bucket so a proactive logger (10 min
// before the window) is no longer flushed to `very_late`. Doses up
// to 3h past `windowEnd` stay `on_time`; `late` spans the next 2h
// tail; anything beyond is `very_late`. Overnight windows are
// exercised in parallel by the parameterised matrix below.
const scheduledDate = new Date("2025-01-15T00:00:00Z");
it('returns "missed" when takenAt is null', () => {
expect(classifyIntakeTiming(null, "08:00", "09:00", scheduledDate)).toBe(
"missed",
);
});
it('returns "on_time" when taken within the window', () => {
const takenAt = new Date("2025-01-15T08:30:00Z");
expect(classifyIntakeTiming(takenAt, "08:00", "09:00", scheduledDate)).toBe(
"on_time",
);
});
it('returns "on_time" when taken exactly at windowStart', () => {
const takenAt = new Date("2025-01-15T08:00:00Z");
expect(classifyIntakeTiming(takenAt, "08:00", "09:00", scheduledDate)).toBe(
"on_time",
);
});
it('returns "on_time" when taken exactly at windowEnd', () => {
const takenAt = new Date("2025-01-15T09:00:00Z");
expect(classifyIntakeTiming(takenAt, "08:00", "09:00", scheduledDate)).toBe(
"on_time",
);
});
// Parameterised offset matrix relative to a `08:00 → 09:00` window.
// Negative offsets are minutes before `windowStart` (08:00); positive
// offsets are minutes after `windowEnd` (09:00). The reference
// boundaries are: `early` from -180 to -1 min before start; `on_time`
// from start through windowEnd + 180 min; `late` for the next 120
// min; `very_late` past that or beyond the 3h pre-window grace.
it.each<[label: string, offsetMin: number, expected: IntakeTimingClass]>([
["3.5h before window → very_late", -210, "very_late"],
["exactly 3h before window → early", -180, "early"],
["1h before window → early", -60, "early"],
["10 min before window → early", -10, "early"],
["exactly at windowStart → on_time", 0, "on_time"],
["10 min into the window → on_time", 10, "on_time"],
["20 min past windowEnd → on_time", 80, "on_time"],
["1h past windowEnd → on_time", 120, "on_time"],
["exactly 3h past windowEnd → on_time", 240, "on_time"],
["3.5h past windowEnd → late", 270, "late"],
["5h past windowEnd → late (tail boundary)", 360, "late"],
["5h 1min past windowEnd → very_late", 361, "very_late"],
])("offset case: %s", (_label, offsetMin, expected) => {
// The matrix expresses offsets relative to windowStart (negative)
// or relative to windowEnd (positive). 0 sits at windowStart, +60
// sits at windowEnd, then positive offsets accumulate past
// windowEnd. With windowStart=08:00 and windowEnd=09:00, the
// base instant is 08:00Z; positive offsets walk forward from there.
const baseMs = new Date("2025-01-15T08:00:00Z").getTime();
const takenAt = new Date(baseMs + offsetMin * 60 * 1000);
expect(classifyIntakeTiming(takenAt, "08:00", "09:00", scheduledDate)).toBe(
expected,
);
});
it('returns "very_late" when taken way before the grace period', () => {
const takenAt = new Date("2025-01-15T03:00:00Z"); // 5h before 08:00
expect(classifyIntakeTiming(takenAt, "08:00", "09:00", scheduledDate)).toBe(
"very_late",
);
});
it('respects the configurable `lateMinutes` tail', () => {
// With lateMinutes=30 the late tail collapses to 30 min after the
// 3h on-time grace. windowEnd is 09:00 so `on_time` extends to
// 12:00 and `late` extends to 12:30. A dose at 12:15 falls in
// `late`; one at 12:45 falls in `very_late`.
const takenLate = new Date("2025-01-15T12:15:00Z");
expect(
classifyIntakeTiming(takenLate, "08:00", "09:00", scheduledDate, {
lateMinutes: 30,
}),
).toBe("late");
const takenVeryLate = new Date("2025-01-15T12:45:00Z");
expect(
classifyIntakeTiming(takenVeryLate, "08:00", "09:00", scheduledDate, {
lateMinutes: 30,
}),
).toBe("very_late");
});
it("handles overnight windows (windowEnd < windowStart)", () => {
// Schedule: 23:00 - 01:00 (overnight)
const takenAt = new Date("2025-01-15T23:30:00Z");
expect(classifyIntakeTiming(takenAt, "23:00", "01:00", scheduledDate)).toBe(
"on_time",
);
});
it("handles overnight window early intake", () => {
// Schedule: 23:00 - 01:00, taken at 22:30 (30 min before windowStart)
const takenAt = new Date("2025-01-15T22:30:00Z");
expect(classifyIntakeTiming(takenAt, "23:00", "01:00", scheduledDate)).toBe(
"early",
);
});
it("handles overnight window late intake", () => {
// Schedule: 23:00 - 01:00, taken at 04:30 (3.5h past 01:00)
const takenAt = new Date("2025-01-16T04:30:00Z");
expect(classifyIntakeTiming(takenAt, "23:00", "01:00", scheduledDate)).toBe(
"late",
);
});
it("handles overnight window very late intake", () => {
// Schedule: 23:00 - 01:00, taken at 07:00 (6h past 01:00)
const takenAt = new Date("2025-01-16T07:00:00Z");
expect(classifyIntakeTiming(takenAt, "23:00", "01:00", scheduledDate)).toBe(
"very_late",
);
});
});
// v1.8.5 — the cadence-density rule + the per-dose uptime strip.
// `buildComplianceDisplay` decides whether the card keeps the 7-/30-day
// percentage bars (dense cadences) or swaps to a per-dose timeline
// (sparse cadences). The 30-day window is the density yardstick.
// v1.8.6 — the compliance display is always two percentage rows; only the
// window day-counts scale with the dosing cadence. `selectComplianceWindows`
// walks a `[7,30] → [30,90] → [90,365]` ladder and returns the densest rung
// whose BOTH windows clear `MIN_STABLE_DOSES` realised expected doses, so a
// daily med stays on 7 / 30, a weekly med steps up, and a rare injection
// lands on the widest rung. The timeline rendering is gone.
describe("buildComplianceDisplay — two rows, cadence-scaled windows", () => {
const NOW = new Date("2025-06-15T12:00:00Z");
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
function ctx(
overrides: Partial<ComplianceMedicationContext> = {},
): ComplianceMedicationContext {
return buildComplianceMedicationContext(
{
startsOn: overrides.startsOn ?? null,
endsOn: overrides.endsOn ?? null,
oneShot: overrides.oneShot ?? false,
// Created well before the windows so no day is excluded.
createdAt: overrides.createdAt ?? new Date("2025-01-01T00:00:00Z"),
},
overrides.lastIntakeAt ?? null,
overrides.timeZone ?? "UTC",
);
}
it("echoes the density floor so a client can re-derive the rung", () => {
const display = buildComplianceDisplay(
[],
[
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=DAILY",
timesOfDay: ["08:00"],
},
],
ctx(),
{ now: NOW },
);
expect(display.minStableDoses).toBe(MIN_STABLE_DOSES);
});
it("daily med → 7 / 30-day windows (the densest rung clears the floor)", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=DAILY",
timesOfDay: ["08:00"],
},
];
const display = buildComplianceDisplay([], schedules, ctx(), { now: NOW });
expect(display.shortDays).toBe(7);
expect(display.longDays).toBe(30);
// Both windows hold ≥ 4 expected doses.
expect(display.expectedShort).toBeGreaterThanOrEqual(MIN_STABLE_DOSES);
expect(display.expectedLong).toBeGreaterThanOrEqual(MIN_STABLE_DOSES);
});
it("weekly med → steps up to 30 / 90-day windows", () => {
// Weekly Mondays-only: the 7-day window holds at most one Monday (< 4),
// so the ladder steps up. The 30-day window holds ~4 Mondays and the
// 90-day window ~13 — the [30, 90] rung is the densest that clears the
// floor on both rows.
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=WEEKLY;BYDAY=MO",
timesOfDay: ["08:00"],
},
];
const display = buildComplianceDisplay([], schedules, ctx(), { now: NOW });
expect(display.shortDays).toBe(30);
expect(display.longDays).toBe(90);
expect(display.expectedShort).toBeGreaterThanOrEqual(MIN_STABLE_DOSES);
expect(display.expectedLong).toBeGreaterThanOrEqual(MIN_STABLE_DOSES);
});
it("35-day rolling injection → widest 90 / 365-day windows", () => {
// One dose every five weeks: the 30-day window never holds ≥ 4, so the
// ladder climbs to the top rung where a full year holds ~10 doses.
const schedules: ComplianceSchedule[] = [
{
windowStart: "10:00",
windowEnd: "11:00",
daysOfWeek: null,
rollingIntervalDays: 35,
timesOfDay: ["10:00"],
},
];
const display = buildComplianceDisplay(
[],
schedules,
ctx({
startsOn: new Date("2024-06-01T00:00:00Z"),
lastIntakeAt: new Date("2025-05-20T10:00:00Z"),
}),
{ now: NOW },
);
expect(display.shortDays).toBe(90);
expect(display.longDays).toBe(365);
});
it("a brand-new sparse med still returns two rows on the widest rung", () => {
// Started two days ago: no rung clears the floor. The selection falls
// back to the widest rung so the card still shows two honest rows.
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rollingIntervalDays: 35,
timesOfDay: ["08:00"],
},
];
const display = buildComplianceDisplay(
[],
schedules,
ctx({ startsOn: new Date("2025-06-13T00:00:00Z") }),
{ now: NOW },
);
expect(display.shortDays).toBe(90);
expect(display.longDays).toBe(365);
});
it("the row rates match calculateCompliance over the chosen windows", () => {
// Daily med, perfect adherence over the trailing two weeks → 100% both
// rows. Pins that the display rate equals the cadence-aware calculator.
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=DAILY",
timesOfDay: ["08:00"],
},
];
const events = [];
for (let d = 1; d <= 30; d++) {
const at = new Date(NOW.getTime() - d * DAY_MS);
at.setUTCHours(8, 10, 0, 0);
events.push({ scheduledFor: at, takenAt: at, skipped: false });
}
const context = ctx();
const display = buildComplianceDisplay(events, schedules, context, {
now: NOW,
});
const short = calculateCompliance(
events,
schedules,
display.shortDays,
context.createdAt,
{ now: NOW, medicationContext: context },
);
const long = calculateCompliance(
events,
schedules,
display.longDays,
context.createdAt,
{ now: NOW, medicationContext: context },
);
expect(display.short.rate).toBe(short.rate);
expect(display.long.rate).toBe(long.rate);
expect(display.short.streak).toBe(short.streak);
});
it("never reports a timeline mode field — the display is two rows only", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=DAILY",
timesOfDay: ["08:00"],
},
];
const display = buildComplianceDisplay([], schedules, ctx(), { now: NOW });
expect(display).not.toHaveProperty("mode");
expect(display).not.toHaveProperty("doseTimeline");
expect(display.short).toBeDefined();
expect(display.long).toBeDefined();
});
it("7-day rolling injection with history → [30, 90] windows (not the widest fallback)", () => {
// v1.13.x Fix 3B — after the retrospective rolling expansion,
// `expectedSlotsBetween` returns the real per-cycle grid for rolling, so
// a 7-day rolling injection with a faithful history clears the floor on
// the [30, 90] rung instead of falling through to the widest [90, 365]
// fallback (the pre-fix behaviour, where rolling emitted ≤ 1 slot).
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
},
];
// ~16 weekly shots back from a most-recent shot 3 days ago.
const lastShot = new Date(NOW.getTime() - 3 * DAY_MS);
const events = Array.from({ length: 16 }, (_, i) => {
const at = new Date(lastShot.getTime() - i * 7 * DAY_MS);
at.setUTCHours(20, 0, 0, 0);
return { scheduledFor: at, takenAt: at, skipped: false };
});
const display = buildComplianceDisplay(
events,
schedules,
ctx({
startsOn: events[events.length - 1].takenAt,
lastIntakeAt: events[0].takenAt,
}),
{ now: NOW },
);
expect(display.shortDays).toBe(30);
expect(display.longDays).toBe(90);
expect(display.expectedShort).toBeGreaterThanOrEqual(MIN_STABLE_DOSES);
expect(display.expectedLong).toBeGreaterThanOrEqual(MIN_STABLE_DOSES);
});
it("currentCycle — rolling weekly, last shot 3 days ago → on_track (not a red miss)", () => {
// v1.13.x Fix 4 — a between-doses sparse med must NOT render a scary
// state. Last shot 3 days ago → next due in 4 days → the open cycle is
// on_track with a future nextDueAt.
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
},
];
const lastShot = new Date(NOW.getTime() - 3 * DAY_MS);
const events = [
{ scheduledFor: lastShot, takenAt: lastShot, skipped: false },
];
const display = buildComplianceDisplay(
events,
schedules,
ctx({ startsOn: lastShot, lastIntakeAt: lastShot }),
{ now: NOW },
);
expect(display.currentCycle.state).toBe("on_track");
expect(display.currentCycle.nextDueAt).not.toBeNull();
expect(display.currentCycle.nextDueAt!.getTime()).toBeGreaterThan(
NOW.getTime(),
);
expect(display.currentCycle.hasClosedCycles).toBe(true);
});
it("currentCycle — rolling weekly overdue past grace → missed", () => {
// v1.13.x Fix 4 — last shot ~10 days ago (cadence 7) → next due was 3
// days ago, well past grace → the open cycle is `missed` (the only red
// state). nextDueAt is in the past.
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
},
];
const lastShot = new Date(NOW.getTime() - 10 * DAY_MS);
const events = [
{ scheduledFor: lastShot, takenAt: lastShot, skipped: false },
];
const display = buildComplianceDisplay(
events,
schedules,
ctx({ startsOn: lastShot, lastIntakeAt: lastShot }),
{ now: NOW },
);
expect(display.currentCycle.state).toBe("missed");
expect(display.currentCycle.nextDueAt).not.toBeNull();
expect(display.currentCycle.nextDueAt!.getTime()).toBeLessThan(
NOW.getTime(),
);
});
it("currentCycle — brand-new sparse med with zero closed cycles → hasClosedCycles false", () => {
// v1.13.x Fix 4 — a med created today with no logged intake has no
// closed dose cycle; the percentage rows are vacuous and the card should
// show a neutral state rather than a misleading 100% / 0%.
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
},
];
const display = buildComplianceDisplay(
[],
schedules,
ctx({
startsOn: new Date(NOW.getTime() + 4 * DAY_MS), // first dose in the future
createdAt: NOW,
}),
{ now: NOW },
);
expect(display.currentCycle.hasClosedCycles).toBe(false);
expect(display.currentCycle.state).toBe("on_track");
});
it("a 2-day-old daily med's expected counts reflect its real age", () => {
// Created two days ago: the expected-dose counts must reflect ~2 days
// of daily doses, not the 7 / 30 a full-window walk would report. No
// rung clears the floor, so the selection falls back to the widest
// rung while keeping the counts age-accurate.
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=DAILY",
timesOfDay: ["08:00"],
},
];
const createdAt = new Date(NOW.getTime() - 2 * DAY_MS);
const display = buildComplianceDisplay([], schedules, ctx({ createdAt }), {
now: NOW,
});
// ~2 days of daily doses — nowhere near the 7 a 7-day window or the 30
// a 30-day window would report before the createdAt clamp.
expect(display.expectedShort).toBeLessThanOrEqual(3);
expect(display.expectedLong).toBeLessThanOrEqual(3);
// No rung clears the floor → widest windows still render two rows.
expect(display.shortDays).toBe(90);
expect(display.longDays).toBe(365);
});
// v1.15.8 — the card renders the taken-of-expected count next to each rate
// so two identical percentages stay distinguishable. These pin the count
// numerators onto the display block and guard against a future regression
// where every window collapses to the same rate AND the same count (which
// would read as a stuck display rather than a real, trustworthy number).
it("carries a taken-dose count on each window row", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=DAILY",
timesOfDay: ["08:00"],
},
];
// Daily med, perfect adherence over the trailing 30 days.
const events = [];
for (let d = 1; d <= 30; d++) {
const at = new Date(NOW.getTime() - d * DAY_MS);
at.setUTCHours(8, 10, 0, 0);
events.push({ scheduledFor: at, takenAt: at, skipped: false });
}
const display = buildComplianceDisplay(events, schedules, ctx(), {
now: NOW,
});
// Both rows ~100% — but the COUNTS differ (7-day window has fewer taken
// doses than the 30-day window), so the two rows are distinguishable even
// when the percentages are identical.
expect(display.short.taken).toBeGreaterThan(0);
expect(display.long.taken).toBeGreaterThan(0);
expect(display.long.taken).toBeGreaterThan(display.short.taken);
// The taken count never exceeds its window's expected denominator.
expect(display.short.taken).toBeLessThanOrEqual(display.expectedShort);
expect(display.long.taken).toBeLessThanOrEqual(display.expectedLong);
});
it("a daily med with misses concentrated outside the 7-day window → 7d ≠ 30d rate", () => {
// Guards the "looks broken" report from the other direction: the windows
// must NOT collapse to one value. A daily med taken every day inside the
// last 7 days but with a cluster of misses in the 8–30-day range yields a
// perfect short window and a degraded long window, so the two rows
// genuinely differ.
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=DAILY",
timesOfDay: ["08:00"],
},
];
const events = [];
for (let d = 1; d <= 30; d++) {
// Days 1–7: every dose taken. Days 10–20: missed (no event row).
if (d >= 10 && d <= 20) continue;
const at = new Date(NOW.getTime() - d * DAY_MS);
at.setUTCHours(8, 10, 0, 0);
events.push({ scheduledFor: at, takenAt: at, skipped: false });
}
const display = buildComplianceDisplay(events, schedules, ctx(), {
now: NOW,
});
// Daily med stays on the 7 / 30-day rung.
expect(display.shortDays).toBe(7);
expect(display.longDays).toBe(30);
// The recent week is clean; the month carries the 10–20-day miss cluster.
// The windows MUST diverge — the short window outscores the long one,
// which is the whole point of showing two rows rather than one number.
expect(display.short.rate).toBeGreaterThan(display.long.rate);
expect(display.long.rate).toBeLessThan(100);
expect(display.short.rate).not.toBe(display.long.rate);
});
it("a rolling weekly med with a partial-adherence history → short and long rates CAN differ", () => {
// The operator's "every window is 100%" suspicion is real ONLY for a
// faithfully-on-cadence med; once doses are missed the windows MUST be
// able to diverge. A weekly rolling injection with recent perfect weeks
// but missed shots further back drives the short rate above the long rate
// — proving the metric does not structurally collapse to one value.
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
},
];
const lastShot = new Date(NOW.getTime() - 3 * DAY_MS);
// 16 weekly slots back; drop a cluster of older shots (weeks 8–12) so the
// long window scores a real miss while the recent weeks stay perfect.
const events = Array.from({ length: 16 }, (_, i) => i)
.filter((i) => i < 6 || i > 11)
.map((i) => {
const at = new Date(lastShot.getTime() - i * 7 * DAY_MS);
at.setUTCHours(20, 0, 0, 0);
return { scheduledFor: at, takenAt: at, skipped: false };
});
const display = buildComplianceDisplay(
events,
schedules,
ctx({
startsOn: new Date(lastShot.getTime() - 15 * 7 * DAY_MS),
lastIntakeAt: lastShot,
}),
{ now: NOW },
);
// The windows are NOT forced equal: the recent short window outscores the
// longer one that carries the dropped cluster.
expect(display.short.rate).toBeGreaterThan(display.long.rate);
});
});
describe("expectedSlotsBetween", () => {
function ctx(
overrides: Partial<ComplianceMedicationContext> = {},
): ComplianceMedicationContext {
return buildComplianceMedicationContext(
{
startsOn: overrides.startsOn ?? null,
endsOn: overrides.endsOn ?? null,
oneShot: overrides.oneShot ?? false,
createdAt: overrides.createdAt ?? new Date("2025-01-01T00:00:00Z"),
},
overrides.lastIntakeAt ?? null,
overrides.timeZone ?? "UTC",
);
}
it("returns the occurrences themselves, ascending by instant", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=DAILY",
timesOfDay: ["08:00", "20:00"],
},
];
const slots = expectedSlotsBetween(
schedules,
new Date("2025-06-09T00:00:00Z"),
new Date("2025-06-10T23:59:59Z"),
ctx(),
);
// Two days × two times = four slots, ascending.
expect(slots.length).toBe(4);
for (let i = 1; i < slots.length; i++) {
expect(slots[i].at.getTime()).toBeGreaterThanOrEqual(
slots[i - 1].at.getTime(),
);
}
});
it("excludes PRN schedules from the expected slots", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
scheduleType: "PRN",
rrule: "FREQ=DAILY",
timesOfDay: ["08:00"],
},
];
const slots = expectedSlotsBetween(
schedules,
new Date("2025-06-09T00:00:00Z"),
new Date("2025-06-12T00:00:00Z"),
ctx(),
);
expect(slots.length).toBe(0);
});
it("clamps the window lower bound to createdAt for a young med", () => {
// A daily med created two days before the window's upper bound: the
// legacy weekday walker floors on `startsOn` (none here) but not on
// `createdAt`, so without the clamp this 30-day window would count
// ~30 days of pre-creation slots. With the clamp only the ~2 days the
// med actually existed emit a slot.
const to = new Date("2025-06-15T00:00:00Z");
const from = new Date(to.getTime() - 30 * DAY_MS);
const createdAt = new Date(to.getTime() - 2 * DAY_MS);
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=DAILY",
timesOfDay: ["08:00"],
},
];
const slots = expectedSlotsBetween(schedules, from, to, ctx({ createdAt }));
// Daily cadence over ~2 days → at most 3 slots, never the ~30 a full
// 30-day window would otherwise emit.
expect(slots.length).toBeLessThanOrEqual(3);
expect(slots.length).toBeGreaterThanOrEqual(1);
// Every emitted slot lands at or after the creation instant.
for (const s of slots) {
expect(s.at.getTime()).toBeGreaterThanOrEqual(createdAt.getTime());
}
});
it("leaves the window untouched when createdAt predates it", () => {
// createdAt well before `from` → the clamp is a no-op and the full
// window's slots survive.
const to = new Date("2025-06-15T00:00:00Z");
const from = new Date(to.getTime() - 7 * DAY_MS);
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=DAILY",
timesOfDay: ["08:00"],
},
];
const slots = expectedSlotsBetween(
schedules,
from,
to,
ctx({ createdAt: new Date("2025-01-01T00:00:00Z") }),
);
// A full week of daily doses → ~7 slots.
expect(slots.length).toBeGreaterThanOrEqual(7);
});
});