mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
The dose-to-slot pairing matched a logged intake against the slot's window *centre*. For a wide intraday window (a twice-daily 07:00–19:00 med spans 12h, so its 07:00 slot centres at 13:00 and its 19:00 slot at 01:00 the next day) the centre sits between the real dose times, so an off-time take paired to the wrong slot: a morning dose logged late could land on the evening slot, a deliberate user-skip could read as taken, and a late evening take could read as missed — distorting the compliance rate for an irregular twice-daily taker. The intake write path already snaps each dose's scheduledFor to its canonical slot instant, which identifies the slot unambiguously. Add an exact-anchor pass that claims the slot whose windowStart matches the intake's scheduledFor (±1 min) before the proximity fallback. Rows that don't line up on a slot instant (legacy un-snapped data, sparse off-day takes) keep the existing proximity match, so every prior cadence fixture is byte-stable.
2440 lines
95 KiB
TypeScript
2440 lines
95 KiB
TypeScript
/**
|
||
* 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 {
|
||
DOSE_WINDOW_DEFAULTS,
|
||
MIN_STABLE_DOSES,
|
||
buildComplianceDisplay,
|
||
buildComplianceMedicationContext,
|
||
calculateCompliance,
|
||
classifyIntakeTiming,
|
||
deriveDoseStatus,
|
||
doseCadenceFamily,
|
||
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("currentDose — rolling weekly, last shot 3 days ago → upcoming (card stays calm)", () => {
|
||
// v1.15.9 — the open dose's per-dose status drives the card's green /
|
||
// overdue presentation. A weekly shot due in 4 days is well before the
|
||
// ±1-day on-time window opens → `upcoming`, with the target echoed.
|
||
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.currentDose.status).toBe("upcoming");
|
||
expect(display.currentDose.targetAt).not.toBeNull();
|
||
expect(display.currentDose.targetAt!.getTime()).toBe(
|
||
display.currentCycle.nextDueAt!.getTime(),
|
||
);
|
||
});
|
||
|
||
it("currentDose — weekly shot due now is on_time_window (green); long overdue is missed", () => {
|
||
const schedules: ComplianceSchedule[] = [
|
||
{
|
||
windowStart: "08:00",
|
||
windowEnd: "09:00",
|
||
daysOfWeek: null,
|
||
rollingIntervalDays: 7,
|
||
timesOfDay: ["08:00"],
|
||
},
|
||
];
|
||
// Last shot exactly 7 days ago → next due ~now → inside the ±1-day
|
||
// on-time window → green.
|
||
const dueNow = new Date(NOW.getTime() - 7 * DAY_MS);
|
||
const dueNowDisplay = buildComplianceDisplay(
|
||
[{ scheduledFor: dueNow, takenAt: dueNow, skipped: false }],
|
||
schedules,
|
||
ctx({ startsOn: dueNow, lastIntakeAt: dueNow }),
|
||
{ now: NOW },
|
||
);
|
||
expect(dueNowDisplay.currentDose.status).toBe("on_time_window");
|
||
|
||
// Last shot 14 days ago → due ~7 days ago → past the 4-day overdue tail
|
||
// → missed (the card escalates to "Stark überfällig").
|
||
const longAgo = new Date(NOW.getTime() - 14 * DAY_MS);
|
||
const missedDisplay = buildComplianceDisplay(
|
||
[{ scheduledFor: longAgo, takenAt: longAgo, skipped: false }],
|
||
schedules,
|
||
ctx({ startsOn: longAgo, lastIntakeAt: longAgo }),
|
||
{ now: NOW },
|
||
);
|
||
expect(missedDisplay.currentDose.status).toBe("missed");
|
||
});
|
||
|
||
it("currentDose — twice-daily, both today's slots resolved → next open is tomorrow 07:00 (BUG 2)", () => {
|
||
// v1.15.10 BUG 2 — afternoon, with today's 07:00 taken and today's 19:00
|
||
// taken early. The open-dose search must skip both resolved slots and
|
||
// surface tomorrow's 07:00 — the next genuinely-open slot — instead of
|
||
// sticking on today's resolved 19:00.
|
||
const schedules: ComplianceSchedule[] = [
|
||
{
|
||
windowStart: "07:00",
|
||
windowEnd: "19:00",
|
||
daysOfWeek: null,
|
||
timesOfDay: ["07:00", "19:00"],
|
||
},
|
||
];
|
||
// NOW = 2025-06-15T12:00:00Z (UTC ctx). Today's slots: 07:00Z + 19:00Z.
|
||
const slot0700 = new Date("2025-06-15T07:00:00Z");
|
||
const slot1900 = new Date("2025-06-15T19:00:00Z");
|
||
const events = [
|
||
// 07:00 dose logged on time.
|
||
{ scheduledFor: slot0700, takenAt: new Date("2025-06-15T07:05:00Z"), skipped: false },
|
||
// 19:00 dose logged EARLY (before its slot) — snapped onto the 19:00 row.
|
||
{ scheduledFor: slot1900, takenAt: new Date("2025-06-15T11:30:00Z"), skipped: false },
|
||
];
|
||
const display = buildComplianceDisplay(events, schedules, ctx(), { now: NOW });
|
||
expect(display.currentCycle.nextDueAt).not.toBeNull();
|
||
// Next open slot is tomorrow 07:00 UTC, not today's resolved 19:00.
|
||
expect(display.currentCycle.nextDueAt!.toISOString()).toBe(
|
||
"2025-06-16T07:00:00.000Z",
|
||
);
|
||
});
|
||
|
||
it("currentDose — twice-daily, only 07:00 resolved → next open is today 19:00 (BUG 2)", () => {
|
||
const schedules: ComplianceSchedule[] = [
|
||
{
|
||
windowStart: "07:00",
|
||
windowEnd: "19:00",
|
||
daysOfWeek: null,
|
||
timesOfDay: ["07:00", "19:00"],
|
||
},
|
||
];
|
||
const slot0700 = new Date("2025-06-15T07:00:00Z");
|
||
const events = [
|
||
{ scheduledFor: slot0700, takenAt: new Date("2025-06-15T07:05:00Z"), skipped: false },
|
||
];
|
||
const display = buildComplianceDisplay(events, schedules, ctx(), { now: NOW });
|
||
expect(display.currentCycle.nextDueAt).not.toBeNull();
|
||
// Today's 19:00 is still open.
|
||
expect(display.currentCycle.nextDueAt!.toISOString()).toBe(
|
||
"2025-06-15T19:00:00.000Z",
|
||
);
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|
||
|
||
// ────────────────────────────────────────────────────────────────────
|
||
// v1.15.9 — forgotten doses count as MISSED (BUG #2) + user-skip vs
|
||
// auto-miss semantics (BUG #3). A never-acted dose the auto-miss cron
|
||
// flipped carries `autoMissed: true`; the engine must pair it to a
|
||
// `missed` slot (against the rate). A deliberate user skip
|
||
// (`skipped: true`) stays excluded from the denominator.
|
||
// ────────────────────────────────────────────────────────────────────
|
||
|
||
describe("calculateCompliance — autoMissed forgotten doses (BUG #2 / #3)", () => {
|
||
const NOW = new Date("2025-01-15T12:00:00Z");
|
||
|
||
beforeEach(() => {
|
||
vi.useFakeTimers();
|
||
vi.setSystemTime(NOW);
|
||
});
|
||
afterEach(() => {
|
||
vi.useRealTimers();
|
||
});
|
||
|
||
const daily: ComplianceSchedule[] = [
|
||
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null },
|
||
];
|
||
|
||
it("a forgotten daily dose (autoMissed) lowers the rate — not neutralised", () => {
|
||
// Three days: two taken + one forgotten (autoMissed). Before v1.15.9 the
|
||
// auto-skip cron flipped the forgotten dose to skipped, which the engine
|
||
// excluded → 2/2 = 100% (inflated). Now it is a real miss → 2/3 = 67%.
|
||
const events = [
|
||
eventAt(new Date("2025-01-14T08:30:00Z"), true),
|
||
{
|
||
scheduledFor: new Date("2025-01-13T08:30:00Z"),
|
||
takenAt: null,
|
||
skipped: false,
|
||
autoMissed: true,
|
||
},
|
||
eventAt(new Date("2025-01-12T08:30:00Z"), true),
|
||
];
|
||
const result = calculateCompliance(events, daily, 7);
|
||
expect(result.taken).toBe(2);
|
||
expect(result.skipped).toBe(0);
|
||
expect(result.missed).toBeGreaterThanOrEqual(1);
|
||
// The autoMissed day is inside the denominator and drags the rate below
|
||
// the inflated 100% the old skip-neutralisation produced.
|
||
expect(result.rate).toBeLessThan(100);
|
||
});
|
||
|
||
it("a deliberate user-skip is excluded; an auto-miss on the same day-grid counts (BUG #3)", () => {
|
||
// One taken, one user-skip (excluded), one auto-miss (counted). The
|
||
// user-skip must NOT appear in the denominator; the auto-miss must.
|
||
const events = [
|
||
eventAt(new Date("2025-01-14T08:30:00Z"), true),
|
||
{
|
||
scheduledFor: new Date("2025-01-13T08:30:00Z"),
|
||
takenAt: null,
|
||
skipped: true, // deliberate pause
|
||
autoMissed: false,
|
||
},
|
||
{
|
||
scheduledFor: new Date("2025-01-12T08:30:00Z"),
|
||
takenAt: null,
|
||
skipped: false,
|
||
autoMissed: true, // forgotten
|
||
},
|
||
];
|
||
const result = calculateCompliance(events, daily, 7);
|
||
expect(result.skipped).toBe(1);
|
||
expect(result.taken).toBe(1);
|
||
// Denominator = taken + missed (the user-skip is out). At least the
|
||
// auto-miss counts, so the rate is taken/(taken+missed) < 100.
|
||
expect(result.missed).toBeGreaterThanOrEqual(1);
|
||
expect(result.rate).toBeLessThan(100);
|
||
// The user-skip alone (no auto-miss) would have kept 100% — confirm the
|
||
// auto-miss is what pulls it down by re-running with the skip only.
|
||
// Cover every slot the 7-day window emits (today's 08:00 is past NOW =
|
||
// 12:00, so the window holds Jan 9..15). Take all but one user-skip.
|
||
const skipOnlyEvents = [];
|
||
for (let day = 9; day <= 15; day++) {
|
||
const at = new Date(`2025-01-${String(day).padStart(2, "0")}T08:30:00Z`);
|
||
if (day === 13) {
|
||
skipOnlyEvents.push({
|
||
scheduledFor: at,
|
||
takenAt: null,
|
||
skipped: true,
|
||
autoMissed: false,
|
||
});
|
||
} else {
|
||
skipOnlyEvents.push(eventAt(at, true));
|
||
}
|
||
}
|
||
const skipOnly = calculateCompliance(skipOnlyEvents, daily, 7);
|
||
// Every non-skipped day taken → the deliberate skip never lowers the rate.
|
||
expect(skipOnly.rate).toBe(100);
|
||
});
|
||
|
||
it("buildComplianceDisplay surfaces taken / expected / missed counts per row", () => {
|
||
const events = [
|
||
eventAt(new Date("2025-01-14T08:30:00Z"), true),
|
||
{
|
||
scheduledFor: new Date("2025-01-13T08:30:00Z"),
|
||
takenAt: null,
|
||
skipped: false,
|
||
autoMissed: true,
|
||
},
|
||
];
|
||
const ctxVal = buildComplianceMedicationContext(
|
||
{ startsOn: null, endsOn: null, oneShot: false, createdAt: new Date("2025-01-08T00:00:00Z") },
|
||
lastNonSkippedTakenAt(events),
|
||
"UTC",
|
||
);
|
||
const display = buildComplianceDisplay(events, daily, ctxVal, { now: NOW });
|
||
// `expected` is the rate denominator (taken + missed), `missed` counts the
|
||
// forgotten doses, `taken` the numerator — the "taken / expected" triple.
|
||
expect(display.short.expected).toBe(display.short.taken + display.short.missed);
|
||
expect(display.short.missed).toBeGreaterThanOrEqual(1);
|
||
expect(display.long.expected).toBe(display.long.taken + display.long.missed);
|
||
});
|
||
});
|
||
|
||
// ────────────────────────────────────────────────────────────────────
|
||
// v1.15.10 BUG 3 — verify the compliance % for an IRREGULAR twice-daily
|
||
// taker (07:00 + 19:00) who logs at off-times plus the odd ad-hoc dose.
|
||
// Pins the slot-pairing (an off-time morning take snaps to the 07:00 slot,
|
||
// an evening take to the 19:00 slot — ±6h half-gap for a 12h-gap med), the
|
||
// skip/missed/extra semantics, and the resulting rate = taken/(taken+missed).
|
||
// ────────────────────────────────────────────────────────────────────
|
||
|
||
describe("calculateCompliance — irregular twice-daily taker (BUG 3 verify)", () => {
|
||
// Pin `now` so the 7-day window is deterministic.
|
||
const NOW = new Date("2025-01-15T12:00:00Z");
|
||
|
||
beforeEach(() => {
|
||
vi.useFakeTimers();
|
||
vi.setSystemTime(NOW);
|
||
});
|
||
afterEach(() => {
|
||
vi.useRealTimers();
|
||
});
|
||
|
||
// Twice-daily: 07:00 + 19:00, every day. timesOfDay drives the two slots.
|
||
const twiceDaily: ComplianceSchedule[] = [
|
||
{
|
||
windowStart: "07:00",
|
||
windowEnd: "19:00",
|
||
daysOfWeek: null,
|
||
timesOfDay: ["07:00", "19:00"],
|
||
},
|
||
];
|
||
|
||
const ctxVal = buildComplianceMedicationContext(
|
||
{
|
||
startsOn: null,
|
||
endsOn: null,
|
||
oneShot: false,
|
||
// Created well before the window so age never clamps the denominator.
|
||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||
},
|
||
null,
|
||
"UTC",
|
||
);
|
||
|
||
function slot(day: number, hhmm: "07:00" | "19:00"): Date {
|
||
return new Date(`2025-01-${String(day).padStart(2, "0")}T${hhmm}:00Z`);
|
||
}
|
||
|
||
it("snaps two off-time same-day takes to BOTH slots → 2 taken, not 1 taken + 1 missed", () => {
|
||
// The crux of BUG 3: a day with a late-morning take (09:13) and an
|
||
// evening take (19:00) must read as two taken doses. The morning take
|
||
// pairs to the 07:00 slot (2h13m away, well inside the ±6h half-gap) and
|
||
// the evening to the 19:00 slot — NOT the morning take landing on 19:00
|
||
// and the 07:00 slot reading falsely missed.
|
||
//
|
||
// A 2-day window so BOTH of Jan-14's slots fall inside [NOW-2d, NOW]
|
||
// (a 1-day window starts at noon Jan-14, which is after the 07:00 slot).
|
||
const events = [
|
||
// 07:00 slot, logged at 09:13 (off-time, snapped onto the 07:00 row).
|
||
{
|
||
scheduledFor: slot(14, "07:00"),
|
||
takenAt: new Date(slot(14, "07:00").getTime() + (2 * 60 + 13) * 60 * 1000),
|
||
skipped: false,
|
||
},
|
||
// 19:00 slot, logged on time.
|
||
{ scheduledFor: slot(14, "19:00"), takenAt: slot(14, "19:00"), skipped: false },
|
||
// Jan-13's slots are covered too so the window has no stray miss.
|
||
{ scheduledFor: slot(13, "07:00"), takenAt: slot(13, "07:00"), skipped: false },
|
||
{ scheduledFor: slot(13, "19:00"), takenAt: slot(13, "19:00"), skipped: false },
|
||
];
|
||
const result = calculateCompliance(events, twiceDaily, 2, undefined, {
|
||
now: NOW,
|
||
medicationContext: ctxVal,
|
||
});
|
||
// Both of Jan-14's off-time/on-time takes pair to their own slot → the
|
||
// morning slot is NOT falsely missed. No missed slot anywhere → 100%.
|
||
expect(result.missed).toBe(0);
|
||
expect(result.rate).toBe(100);
|
||
// The morning take did not orphan the 07:00 slot: at least Jan-14's two
|
||
// doses both register as taken.
|
||
expect(result.taken).toBeGreaterThanOrEqual(3);
|
||
});
|
||
|
||
it("computes the rate as taken/(taken+missed) over a 7-day irregular pattern", () => {
|
||
// Window: NOW − 7d = Jan 8 12:00 .. Jan 15 12:00. The slots that fall in
|
||
// [start, now] (07:00 + 19:00 each day; Jan-8 19:00 only since 07:00 is
|
||
// before 12:00; Jan-15 07:00 only since 19:00 is after now):
|
||
// Jan 8: 19:00 → 1 slot
|
||
// Jan 9..14: 07:00 + 19:00 → 12 slots
|
||
// Jan 15: 07:00 → 1 slot
|
||
// = 14 expected slots.
|
||
//
|
||
// Pattern (mirrors the operator: irregular times + 2 real skips + 1
|
||
// uncovered morning slot + a couple of ad-hoc extras that pair to no
|
||
// slot):
|
||
// - Jan 8 19:00 : taken (logged 20:10, off-time) → taken
|
||
// - Jan 9 07:00 : taken 09:13 (off-time morning) → taken
|
||
// - Jan 9 19:00 : USER-SKIP → skipped (excluded)
|
||
// - Jan 10 07:00: taken 06:40 → taken
|
||
// - Jan 10 19:00: taken 21:30 (late) → taken
|
||
// - Jan 11 07:00: UNCOVERED (no intake, past cutoff) → missed
|
||
// - Jan 11 19:00: taken 18:50 → taken
|
||
// - Jan 12 07:00: taken 08:05 → taken
|
||
// - Jan 12 19:00: taken 19:20 → taken
|
||
// - Jan 13 07:00: USER-SKIP → skipped (excluded)
|
||
// - Jan 13 19:00: taken 19:00 → taken
|
||
// - Jan 14 07:00: taken 09:13 (off-time morning) → taken
|
||
// - Jan 14 19:00: taken 19:00 → taken
|
||
// - Jan 15 07:00: taken 07:30 → taken
|
||
// Plus two ad-hoc extras (no slot within ±6h) that must NOT touch the
|
||
// denominator:
|
||
// - Jan 10 13:00 (a midday top-up, midpoint between both slots)
|
||
// - Jan 12 03:00 (a small-hours dose, > 6h from either slot)
|
||
//
|
||
// Hand count over the 14 slots:
|
||
// taken = 11
|
||
// skipped = 2 (excluded)
|
||
// missed = 1
|
||
// denom = taken + missed = 12
|
||
// rate = round(11/12 * 100) = round(91.67) = 92
|
||
const mins = (h: number, m: number) => (h * 60 + m) * 60 * 1000;
|
||
const takeAt = (s: Date, h: number, m: number) =>
|
||
new Date(new Date(s).setUTCHours(0, 0, 0, 0) + mins(h, m));
|
||
const taken = (s: Date, h: number, m: number) => ({
|
||
scheduledFor: s,
|
||
takenAt: takeAt(s, h, m),
|
||
skipped: false,
|
||
});
|
||
const userSkip = (s: Date) => ({
|
||
scheduledFor: s,
|
||
takenAt: null,
|
||
skipped: true,
|
||
autoMissed: false,
|
||
});
|
||
const autoMiss = (s: Date) => ({
|
||
scheduledFor: s,
|
||
takenAt: null,
|
||
skipped: false,
|
||
autoMissed: true,
|
||
});
|
||
const extra = (s: Date) => ({ scheduledFor: s, takenAt: s, skipped: false });
|
||
|
||
const events = [
|
||
taken(slot(8, "19:00"), 20, 10),
|
||
taken(slot(9, "07:00"), 9, 13),
|
||
userSkip(slot(9, "19:00")),
|
||
taken(slot(10, "07:00"), 6, 40),
|
||
taken(slot(10, "19:00"), 21, 30),
|
||
autoMiss(slot(11, "07:00")),
|
||
taken(slot(11, "19:00"), 18, 50),
|
||
taken(slot(12, "07:00"), 8, 5),
|
||
taken(slot(12, "19:00"), 19, 20),
|
||
userSkip(slot(13, "07:00")),
|
||
taken(slot(13, "19:00"), 19, 0),
|
||
taken(slot(14, "07:00"), 9, 13),
|
||
taken(slot(14, "19:00"), 19, 0),
|
||
taken(slot(15, "07:00"), 7, 30),
|
||
// ad-hoc extras (must not enter the denominator)
|
||
extra(new Date("2025-01-10T13:00:00Z")),
|
||
extra(new Date("2025-01-12T03:00:00Z")),
|
||
];
|
||
|
||
const result = calculateCompliance(events, twiceDaily, 7, undefined, {
|
||
now: NOW,
|
||
medicationContext: ctxVal,
|
||
});
|
||
|
||
// The denominator counts only taken + missed (the two user-skips + the
|
||
// two ad-hoc extras are excluded). With the v1.15.10 exact-anchor pairing
|
||
// every off-time take lands on its OWN slot — the two user-skips read as
|
||
// skipped (not taken), the late evening take reads as taken (not missed).
|
||
expect(result.taken).toBe(11);
|
||
expect(result.skipped).toBe(2);
|
||
expect(result.missed).toBe(1);
|
||
// rate = taken / (taken + missed) = 11/12 = 92% — the honest arithmetic.
|
||
expect(result.rate).toBe(
|
||
Math.round((result.taken / (result.taken + result.missed)) * 100),
|
||
);
|
||
expect(result.rate).toBe(92);
|
||
});
|
||
});
|
||
|
||
// ────────────────────────────────────────────────────────────────────
|
||
// v1.15.9 — cadence-aware per-dose grace/miss window model. The card
|
||
// reads `deriveDoseStatus` to render on-time / overdue / missed states.
|
||
// ────────────────────────────────────────────────────────────────────
|
||
|
||
describe("deriveDoseStatus — daily / intraday windows", () => {
|
||
const target = new Date("2025-01-15T08:00:00Z");
|
||
|
||
it("on-time within ±60 min of target", () => {
|
||
expect(deriveDoseStatus(target, "daily", new Date("2025-01-15T08:30:00Z"))).toBe(
|
||
"on_time_window",
|
||
);
|
||
expect(deriveDoseStatus(target, "daily", new Date("2025-01-15T07:15:00Z"))).toBe(
|
||
"on_time_window",
|
||
);
|
||
});
|
||
|
||
it("upcoming before the on-time window opens", () => {
|
||
expect(deriveDoseStatus(target, "daily", new Date("2025-01-15T06:30:00Z"))).toBe(
|
||
"upcoming",
|
||
);
|
||
});
|
||
|
||
it("overdue between +60 min and +240 min", () => {
|
||
// +90 min → past on-time (60), before miss cutoff (240).
|
||
expect(deriveDoseStatus(target, "daily", new Date("2025-01-15T09:30:00Z"))).toBe(
|
||
"overdue",
|
||
);
|
||
// +239 min → still overdue (takeable).
|
||
expect(deriveDoseStatus(target, "daily", new Date("2025-01-15T11:59:00Z"))).toBe(
|
||
"overdue",
|
||
);
|
||
});
|
||
|
||
it("missed past +240 min", () => {
|
||
expect(deriveDoseStatus(target, "daily", new Date("2025-01-15T12:30:00Z"))).toBe(
|
||
"missed",
|
||
);
|
||
});
|
||
|
||
it("never overlaps the next dose's on-time window", () => {
|
||
// Next dose at 10:00; its on-time window opens at 09:00 (−60 min). The
|
||
// 08:00 dose's miss cutoff is therefore clamped to 09:00, well before its
|
||
// own +240 min (12:00). At 09:30 the 08:00 dose is already missed.
|
||
const nextDoseAt = new Date("2025-01-15T10:00:00Z");
|
||
expect(
|
||
deriveDoseStatus(target, "daily", new Date("2025-01-15T09:30:00Z"), {
|
||
nextDoseAt,
|
||
}),
|
||
).toBe("missed");
|
||
});
|
||
|
||
it("taken inside the on-time window → taken_on_time, late → taken_late", () => {
|
||
expect(
|
||
deriveDoseStatus(target, "daily", new Date("2025-01-15T20:00:00Z"), {
|
||
takenAt: new Date("2025-01-15T08:30:00Z"),
|
||
}),
|
||
).toBe("taken_on_time");
|
||
expect(
|
||
deriveDoseStatus(target, "daily", new Date("2025-01-15T20:00:00Z"), {
|
||
takenAt: new Date("2025-01-15T10:00:00Z"),
|
||
}),
|
||
).toBe("taken_late");
|
||
});
|
||
|
||
it("a deliberate skip short-circuits to skipped", () => {
|
||
expect(
|
||
deriveDoseStatus(target, "daily", new Date("2025-01-15T20:00:00Z"), {
|
||
skipped: true,
|
||
}),
|
||
).toBe("skipped");
|
||
});
|
||
});
|
||
|
||
describe("deriveDoseStatus — weekly GLP-1 (4-day rule)", () => {
|
||
const target = new Date("2025-01-13T08:00:00Z"); // a Monday shot
|
||
|
||
it("on-time within ±1 day of the target day", () => {
|
||
expect(deriveDoseStatus(target, "weekly", new Date("2025-01-13T20:00:00Z"))).toBe(
|
||
"on_time_window",
|
||
);
|
||
expect(deriveDoseStatus(target, "weekly", new Date("2025-01-14T07:00:00Z"))).toBe(
|
||
"on_time_window",
|
||
);
|
||
});
|
||
|
||
it("overdue up to +4 days (still counts when taken — the clinical rule)", () => {
|
||
// +3 days from target → past the ±1-day on-time window, before +4 days.
|
||
expect(deriveDoseStatus(target, "weekly", new Date("2025-01-16T12:00:00Z"))).toBe(
|
||
"overdue",
|
||
);
|
||
// Taken on day +3 still counts as late, not missed.
|
||
expect(
|
||
deriveDoseStatus(target, "weekly", new Date("2025-01-20T00:00:00Z"), {
|
||
takenAt: new Date("2025-01-16T12:00:00Z"),
|
||
}),
|
||
).toBe("taken_late");
|
||
});
|
||
|
||
it("missed past +4 days", () => {
|
||
expect(deriveDoseStatus(target, "weekly", new Date("2025-01-18T12:00:00Z"))).toBe(
|
||
"missed",
|
||
);
|
||
});
|
||
|
||
it("defaults match the documented 60-min / 240-min / 4-day boundaries", () => {
|
||
expect(DOSE_WINDOW_DEFAULTS.dailyOnTimeMinutes).toBe(60);
|
||
expect(
|
||
DOSE_WINDOW_DEFAULTS.dailyOnTimeMinutes + DOSE_WINDOW_DEFAULTS.dailyOverdueMinutes,
|
||
).toBe(240);
|
||
expect(DOSE_WINDOW_DEFAULTS.weeklyOverdueDays).toBe(4);
|
||
});
|
||
});
|
||
|
||
describe("doseCadenceFamily", () => {
|
||
it("rolling ≥ 2 days → weekly", () => {
|
||
expect(
|
||
doseCadenceFamily({ windowStart: "08:00", windowEnd: "09:00", rollingIntervalDays: 7 }),
|
||
).toBe("weekly");
|
||
});
|
||
it("WEEKLY rrule → weekly", () => {
|
||
expect(
|
||
doseCadenceFamily({
|
||
windowStart: "08:00",
|
||
windowEnd: "09:00",
|
||
rrule: "FREQ=WEEKLY;BYDAY=MO",
|
||
}),
|
||
).toBe("weekly");
|
||
});
|
||
it("daily rrule → daily", () => {
|
||
expect(
|
||
doseCadenceFamily({ windowStart: "08:00", windowEnd: "09:00", rrule: "FREQ=DAILY" }),
|
||
).toBe("daily");
|
||
});
|
||
it("plain legacy daily → daily", () => {
|
||
expect(
|
||
doseCadenceFamily({ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null }),
|
||
).toBe("daily");
|
||
});
|
||
});
|