mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
The nearest test enumerated only the schedule under test, and production rows carry no schedule identity. On a medication composed of sibling schedule rows (08:00 daily plus 12:00 daily, the wizard's compose shape) a row snapped exactly to A's 08:00 was also asked about B's 12:00, and B's own occurrences alone never showed that A's 08:00 is nearer: next-due, the display due and the compliance cycle all answered tomorrow's 08:00 at 09:00 local, the same swallow one level up. The neighbour set is now the union of every schedule's occurrences within the radius, memoised per row, since it depends on the row alone and a predicate asks about many slots. The rolling short-circuit still looks at the schedule under test only. The era filter in the next-due predicate compared a field no caller ever populated, so it is gone along with the test that pinned it; the era floor lives in the overdue search's band range and in the forward walk's start at now, as it always did in production. Tests: sibling schedules in both next-due and the compliance cycle, the exact tie between two slots, the 25-hour fall-back day (a pair across the repeated hour, and a row anchored in the old offset), and the ad-hoc epsilon case now stands on the ad-hoc row alone.
3156 lines
118 KiB
TypeScript
3156 lines
118 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, and weekday-only multi-dose. Closes #214.
|
||
*
|
||
* The DST local-day walk is NOT pinned here. A "DST boundary" case used
|
||
* to sit in this file passing no timezone, so the slot builder took the
|
||
* legacy UTC path where the date carries no transition — it asserted
|
||
* `totalExpected === 7`, which holds in every zone and therefore could
|
||
* never see a double-emit. Threading a real zone through it does not
|
||
* help: the assertion stays true in Berlin, UTC, UTC+14 and UTC−11
|
||
* alike. The property it claimed lives in
|
||
* `src/lib/medications/scheduling/__tests__/compliance.test.ts`, which
|
||
* walks `buildCadenceTimeline` with an explicit zone across BOTH the 23 h
|
||
* spring-forward and the 25 h fall-back day.
|
||
*
|
||
* 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,
|
||
tallyComplianceFromLedger,
|
||
type ComplianceMedicationContext,
|
||
type ComplianceSchedule,
|
||
} from "@/lib/analytics/compliance";
|
||
import type { IntakeTimingClass } from "@/lib/analytics/compliance";
|
||
import { buildCadenceTimeline } from "@/lib/medications/scheduling/cadence";
|
||
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
||
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("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("on-time takes: the ledger scores every logged 08:00 dose as taken (100%)", () => {
|
||
// v1.15.18 — the context path now tallies the unified dose-history
|
||
// ledger (band membership) instead of the legacy ±12h proximity timeline.
|
||
// An 08:00 daily dose taken at 08:00 sits inside the ±60min on-time band →
|
||
// taken_on_time. The seven logged days all score taken; the current-day
|
||
// 08:00 slot is still inside its takeable window at NOW (12:00, the late
|
||
// tail ends at 12:00) so it reads upcoming, not missed → a clean 100%.
|
||
// (The off-time / stricter behaviour is pinned in the next test.)
|
||
const schedules: ComplianceSchedule[] = [
|
||
{
|
||
windowStart: "08:00",
|
||
windowEnd: "09:00",
|
||
daysOfWeek: null,
|
||
timesOfDay: ["08:00"],
|
||
},
|
||
];
|
||
const onTime = (d: Date) => ({
|
||
scheduledFor: d,
|
||
takenAt: d,
|
||
skipped: false,
|
||
});
|
||
const events = Array.from({ length: 7 }, (_, i) => {
|
||
const day = new Date(NOW.getTime() - (i + 1) * DAY_MS);
|
||
day.setUTCHours(8, 0, 0, 0);
|
||
return onTime(day);
|
||
});
|
||
const withCtx = calculateCompliance(events, schedules, 7, undefined, {
|
||
medicationContext: ctx(),
|
||
});
|
||
// The window floors at NOW − 7d (Jan-8 12:00), so Jan-8's 08:00 dose falls
|
||
// just before it; the six in-window logged doses (Jan-9..Jan-14) all score
|
||
// taken and the current-day slot is still upcoming → 100%.
|
||
expect(withCtx.taken).toBe(6);
|
||
expect(withCtx.missed).toBe(0);
|
||
expect(withCtx.rate).toBe(100);
|
||
});
|
||
|
||
it("off-time take: the ledger is stricter than the legacy ±12h proximity matcher", () => {
|
||
// v1.15.18 — the keystone behaviour change. A daily 08:00 dose logged at
|
||
// 12:30 (4.5h late) USED to count as taken under the ±12h `pairDoses`
|
||
// proximity matcher. The unified band model attributes it honestly: 12:30
|
||
// is outside the 08:00 slot's on-time band (07:00–09:00) AND its late tail
|
||
// (to ~12:00), so it is an AD-HOC take and the slot reads MISSED. The
|
||
// percentage now agrees with the history view (the dose cannot read
|
||
// "taken" in the % while the ledger calls it ad-hoc).
|
||
const schedules: ComplianceSchedule[] = [
|
||
{
|
||
windowStart: "08:00",
|
||
windowEnd: "09:00",
|
||
daysOfWeek: null,
|
||
timesOfDay: ["08:00"],
|
||
},
|
||
];
|
||
// takenAt = 08:00 slot + 4.5h = 12:30 (eventAt adds 30min onto a 12:00
|
||
// scheduledFor; here we pin the off-time take explicitly).
|
||
const events = Array.from({ length: 7 }, (_, i) => {
|
||
const day = new Date(NOW.getTime() - (i + 1) * DAY_MS);
|
||
day.setUTCHours(8, 0, 0, 0);
|
||
return {
|
||
scheduledFor: day,
|
||
takenAt: new Date(day.getTime() + 4.5 * 60 * 60 * 1000),
|
||
skipped: false,
|
||
};
|
||
});
|
||
const withCtx = calculateCompliance(events, schedules, 7, undefined, {
|
||
medicationContext: ctx(),
|
||
});
|
||
// Every off-time take is ad-hoc → its slot is missed → 0% taken.
|
||
expect(withCtx.taken).toBe(0);
|
||
expect(withCtx.missed).toBeGreaterThanOrEqual(6);
|
||
expect(withCtx.rate).toBe(0);
|
||
});
|
||
|
||
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",
|
||
);
|
||
});
|
||
|
||
// A degenerate POINT window (`windowStart === windowEnd`) is a real shape on
|
||
// legacy rows. It used to take the overnight branch and inflate into a ~30h
|
||
// on-time band, so a dose taken many hours late still counted `on_time` and
|
||
// fed a perfect-compliance streak. It now widens symmetrically by the default
|
||
// on-time half-width, matching `resolveLegacyWindowBounds`.
|
||
describe("degenerate point window", () => {
|
||
it('keeps a dose at the point itself "on_time"', () => {
|
||
const takenAt = new Date("2025-01-15T07:00:00Z");
|
||
expect(
|
||
classifyIntakeTiming(takenAt, "07:00", "07:00", scheduledDate),
|
||
).toBe("on_time");
|
||
});
|
||
|
||
it('does not classify a dose ~16h late as "on_time"', () => {
|
||
const takenAt = new Date("2025-01-15T23:00:00Z");
|
||
expect(
|
||
classifyIntakeTiming(takenAt, "07:00", "07:00", scheduledDate),
|
||
).toBe("very_late");
|
||
});
|
||
|
||
it("does not swallow the whole day into the on-time band", () => {
|
||
// 13:00 is 6h past the point — outside the widened band + graces.
|
||
const takenAt = new Date("2025-01-15T13:00:00Z");
|
||
expect(
|
||
classifyIntakeTiming(takenAt, "07:00", "07:00", scheduledDate),
|
||
).not.toBe("on_time");
|
||
});
|
||
|
||
it("still wraps a genuine overnight window", () => {
|
||
// 00:30 the next day sits inside a 23:00 -> 01:00 window.
|
||
const takenAt = new Date("2025-01-16T00:30:00Z");
|
||
expect(
|
||
classifyIntakeTiming(takenAt, "23:00", "01:00", scheduledDate),
|
||
).toBe("on_time");
|
||
});
|
||
});
|
||
|
||
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",
|
||
);
|
||
});
|
||
|
||
it("interprets the window HH:mm in the supplied tz (non-UTC user)", () => {
|
||
// America/Los_Angeles is UTC-7 in summer. An "08:00" slot means 08:00
|
||
// LOCAL = 15:00 UTC on 2026-06-15; a dose taken 15 min into the window
|
||
// is 15:15 UTC. The slot's canonical instant is the anchor date.
|
||
const slotInstant = new Date("2026-06-15T15:00:00Z"); // 08:00 LA
|
||
const takenAt = new Date("2026-06-15T15:15:00Z"); // 08:15 LA
|
||
|
||
// With the user's zone the dose is on time.
|
||
expect(
|
||
classifyIntakeTiming(takenAt, "08:00", "09:00", slotInstant, {
|
||
tz: "America/Los_Angeles",
|
||
}),
|
||
).toBe("on_time");
|
||
|
||
// Without a tz the window HH:mm is read as UTC (08:00 UTC), so the very
|
||
// same on-time dose reads as 7h15m off — very_late. This is the defect
|
||
// the achievements perfect-day check hit for users far from UTC.
|
||
expect(classifyIntakeTiming(takenAt, "08:00", "09:00", slotInstant)).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 — taking the first of two close doses leaves the second due", () => {
|
||
// 19:49 + 23:59 Berlin (CEST): 17:49Z + 21:59Z. The 19:49 take used to
|
||
// resolve the 23:59 slot too (flat ±6 h radius), so the open cycle
|
||
// pointed at tomorrow's 19:49 while tonight's dose was still due.
|
||
const schedules: ComplianceSchedule[] = [
|
||
{
|
||
windowStart: "19:49",
|
||
windowEnd: "23:59",
|
||
daysOfWeek: null,
|
||
rollingIntervalDays: null,
|
||
timesOfDay: ["19:49", "23:59"],
|
||
},
|
||
];
|
||
const slot = new Date("2025-06-15T17:49:00Z");
|
||
const takenAt = new Date("2025-06-15T17:49:12Z");
|
||
const events = [{ scheduledFor: slot, takenAt, skipped: false }];
|
||
const display = buildComplianceDisplay(
|
||
events,
|
||
schedules,
|
||
ctx({ lastIntakeAt: takenAt, timeZone: "Europe/Berlin" }),
|
||
{ now: new Date("2025-06-15T18:00:00Z") },
|
||
);
|
||
expect(display.currentCycle.state).toBe("on_track");
|
||
expect(display.currentCycle.nextDueAt?.toISOString()).toBe(
|
||
"2025-06-15T21:59:00.000Z",
|
||
);
|
||
});
|
||
|
||
it("currentCycle — a row on sibling schedule A's 08:00 leaves sibling B's 12:00 due", () => {
|
||
// Two schedule rows on one medication (08:00 daily plus 12:00 daily).
|
||
// Berlin (CEST): 06:00Z + 10:00Z. The row carries no schedule identity;
|
||
// B's slot must lose to A's nearer occurrence, not read resolved.
|
||
const schedules: ComplianceSchedule[] = [
|
||
{
|
||
windowStart: "08:00",
|
||
windowEnd: "08:00",
|
||
daysOfWeek: null,
|
||
rollingIntervalDays: null,
|
||
timesOfDay: ["08:00"],
|
||
},
|
||
{
|
||
windowStart: "12:00",
|
||
windowEnd: "12:00",
|
||
daysOfWeek: null,
|
||
rollingIntervalDays: null,
|
||
timesOfDay: ["12:00"],
|
||
},
|
||
];
|
||
const slot = new Date("2025-06-15T06:00:00Z");
|
||
const takenAt = new Date("2025-06-15T06:03:00Z");
|
||
const events = [{ scheduledFor: slot, takenAt, skipped: false }];
|
||
const display = buildComplianceDisplay(
|
||
events,
|
||
schedules,
|
||
ctx({ lastIntakeAt: takenAt, timeZone: "Europe/Berlin" }),
|
||
{ now: new Date("2025-06-15T07:00:00Z") },
|
||
);
|
||
expect(display.currentCycle.state).toBe("on_track");
|
||
expect(display.currentCycle.nextDueAt?.toISOString()).toBe(
|
||
"2025-06-15T10:00:00.000Z",
|
||
);
|
||
});
|
||
|
||
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("attributes two same-day takes to BOTH slots — a near-on-time morning take is taken_late, not orphaned", () => {
|
||
// v1.15.18 — the band model attributes each take to its OWN slot. A
|
||
// morning take at 08:13 (07:00 slot + 1h13m) is inside the 07:00 slot's
|
||
// late tail (07:00 + 60min on-time + 180min tail → to 10:00), so it reads
|
||
// taken_late and does NOT orphan the 07:00 slot onto the 19:00 row. The
|
||
// evening 19:00 take is on-time. The crux of the original BUG-3 fix
|
||
// survives the move off the ±6h snap: a same-day morning + evening take
|
||
// never collapse into one taken + one falsely-missed.
|
||
//
|
||
// Cover EVERY slot the window emits so there is no stray uncovered slot:
|
||
// a 3-day window (NOW − 3d = Jan-12 12:00 .. Jan-15 12:00) holds Jan-12
|
||
// 19:00, Jan-13 07:00+19:00, Jan-14 07:00+19:00, Jan-15 07:00 = 6 slots.
|
||
const onTime = (s: Date) => ({
|
||
scheduledFor: s,
|
||
takenAt: s,
|
||
skipped: false,
|
||
});
|
||
const events = [
|
||
onTime(slot(12, "19:00")),
|
||
onTime(slot(13, "07:00")),
|
||
onTime(slot(13, "19:00")),
|
||
// 07:00 slot, logged at 08:13 (within the late tail → taken_late).
|
||
{
|
||
scheduledFor: slot(14, "07:00"),
|
||
takenAt: new Date(
|
||
slot(14, "07:00").getTime() + (1 * 60 + 13) * 60 * 1000,
|
||
),
|
||
skipped: false,
|
||
},
|
||
onTime(slot(14, "19:00")),
|
||
onTime(slot(15, "07:00")),
|
||
];
|
||
const result = calculateCompliance(events, twiceDaily, 3, undefined, {
|
||
now: NOW,
|
||
medicationContext: ctxVal,
|
||
});
|
||
// Every slot is covered by its own take → no miss, 100%.
|
||
expect(result.missed).toBe(0);
|
||
expect(result.rate).toBe(100);
|
||
expect(result.taken).toBe(6);
|
||
});
|
||
|
||
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");
|
||
});
|
||
});
|
||
|
||
// ────────────────────────────────────────────────────────────────────
|
||
// v1.15.18 — the UNIFIED ledger tally. `tallyComplianceFromLedger` is the
|
||
// ONE authority behind the compliance %: it builds the cadence-aware bands
|
||
// per schedule, reconstructs the dose-history ledger, and tallies it so the
|
||
// % and the history view can never contradict. These tests pin the
|
||
// taking-vs-timing split, the exclusions (skip / ad-hoc / upcoming / PRN),
|
||
// auto-missed=missed, and the cross-consistency invariant that the SAME
|
||
// intake can't be "taken" in the % while the ledger calls it "ad_hoc".
|
||
// ────────────────────────────────────────────────────────────────────
|
||
|
||
import { localHmAsUtc } from "@/lib/tz/local-day";
|
||
import {
|
||
buildBandsForSchedules,
|
||
type BandMinterMedication,
|
||
} from "@/lib/medications/scheduling/band-minter";
|
||
import {
|
||
reconstructDoseHistory,
|
||
type HistoryIntake,
|
||
} from "@/lib/medications/scheduling/dose-history";
|
||
import type {
|
||
CanonicalSchedule,
|
||
RecurrenceContext,
|
||
} from "@/lib/medications/scheduling/recurrence";
|
||
|
||
describe("tallyComplianceFromLedger — the unified % keystone", () => {
|
||
const TZ = "Europe/Berlin";
|
||
const dayRef = new Date("2026-06-08T12:00:00Z");
|
||
function at(h: number, m: number): Date {
|
||
return localHmAsUtc(dayRef, TZ, h, m);
|
||
}
|
||
function ctxFor(
|
||
over: Partial<ComplianceMedicationContext> = {},
|
||
): ComplianceMedicationContext {
|
||
return {
|
||
startsOn: new Date("2026-06-01T00:00:00Z"),
|
||
endsOn: null,
|
||
oneShot: false,
|
||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||
lastIntakeAt: null,
|
||
timeZone: TZ,
|
||
...over,
|
||
};
|
||
}
|
||
// Twice-daily 07:00 / 19:00, single window covering the whole day.
|
||
const twiceDaily: ComplianceSchedule[] = [
|
||
{
|
||
windowStart: "07:00",
|
||
windowEnd: "19:00",
|
||
daysOfWeek: null,
|
||
timesOfDay: ["07:00", "19:00"],
|
||
},
|
||
];
|
||
// Late evening "now" so both of the reference day's slots are past cutoff.
|
||
const nowEvening = at(23, 59);
|
||
// Window covers just the reference day.
|
||
const from = at(0, 0);
|
||
|
||
it("a late take counts as taken (taking adherence), with the timing split exposed", () => {
|
||
// 07:00 on-time + 19:00 logged at 20:30 (inside the 180min late tail).
|
||
const events = [
|
||
{ scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false },
|
||
{ scheduledFor: at(19, 0), takenAt: at(20, 30), skipped: false },
|
||
];
|
||
const tally = tallyComplianceFromLedger(
|
||
events,
|
||
twiceDaily,
|
||
ctxFor(),
|
||
from,
|
||
nowEvening,
|
||
nowEvening,
|
||
);
|
||
expect(tally.taken).toBe(2);
|
||
expect(tally.takenOnTime).toBe(1);
|
||
expect(tally.takenLate).toBe(1);
|
||
expect(tally.missed).toBe(0);
|
||
expect(tally.rate).toBe(100);
|
||
});
|
||
|
||
it("a deliberate user-skip is EXCLUDED from the denominator", () => {
|
||
const events = [
|
||
{ scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false },
|
||
{ scheduledFor: at(19, 0), takenAt: null, skipped: true },
|
||
];
|
||
const tally = tallyComplianceFromLedger(
|
||
events,
|
||
twiceDaily,
|
||
ctxFor(),
|
||
from,
|
||
nowEvening,
|
||
nowEvening,
|
||
);
|
||
expect(tally.taken).toBe(1);
|
||
expect(tally.skipped).toBe(1);
|
||
expect(tally.missed).toBe(0);
|
||
expect(tally.denominator).toBe(1); // taken + missed, skip excluded
|
||
expect(tally.rate).toBe(100);
|
||
});
|
||
|
||
// v1.15.20 — a USER_PIN take binds by its slot anchor, so a deliberate
|
||
// "diesem Slot zuordnen" on an off-window take serves the slot as a LATE
|
||
// dose: it enters the numerator (taken), never the on-time count, and the
|
||
// slot stops reading missed. The same instants WITHOUT the pin read
|
||
// ad-hoc + missed — the pin is the only thing that may move them.
|
||
it("a pinned off-window take serves its slot as late — no on-time gain", () => {
|
||
const pinned = [
|
||
{
|
||
scheduledFor: at(7, 0),
|
||
takenAt: at(13, 0), // far past the 07:00 band's tail
|
||
skipped: false,
|
||
attributionSource: "USER_PIN" as const,
|
||
},
|
||
{ scheduledFor: at(19, 0), takenAt: at(19, 0), skipped: false },
|
||
];
|
||
const tally = tallyComplianceFromLedger(
|
||
pinned,
|
||
twiceDaily,
|
||
ctxFor(),
|
||
from,
|
||
nowEvening,
|
||
nowEvening,
|
||
);
|
||
expect(tally.taken).toBe(2);
|
||
expect(tally.takenOnTime).toBe(1); // only the 19:00 dose
|
||
expect(tally.takenLate).toBe(1); // the pin counts late, never on-time
|
||
expect(tally.missed).toBe(0);
|
||
expect(tally.adHoc).toBe(0);
|
||
expect(tally.rate).toBe(100);
|
||
|
||
// Control: the identical instants without the pin stay ad-hoc and the
|
||
// 07:00 slot reads missed — AUTO attribution is untouched.
|
||
const unpinned = pinned.map((e) => ({
|
||
scheduledFor: e.scheduledFor,
|
||
takenAt: e.takenAt,
|
||
skipped: e.skipped,
|
||
}));
|
||
const auto = tallyComplianceFromLedger(
|
||
unpinned,
|
||
twiceDaily,
|
||
ctxFor(),
|
||
from,
|
||
nowEvening,
|
||
nowEvening,
|
||
);
|
||
expect(auto.taken).toBe(1);
|
||
expect(auto.takenLate).toBe(0);
|
||
expect(auto.missed).toBe(1);
|
||
expect(auto.adHoc).toBe(1);
|
||
expect(auto.rate).toBe(50);
|
||
});
|
||
|
||
// v1.15.19 — intraday pending rows. The today-projector mints pending
|
||
// rows (takenAt = null, not skipped, not auto-missed) for every slot of
|
||
// the day up front; from slot time onward the old ledger read them as
|
||
// missed, so the rate dipped intraday although the dose was still inside
|
||
// its overdue window.
|
||
it("a pending row inside its overdue window reads upcoming — the intraday rate holds", () => {
|
||
// 07:00 taken; the 19:00 pending row was minted up front. At 20:00 the
|
||
// evening dose is still takeable (the late tail runs to 23:00), so it
|
||
// stays out of the denominator and the rate holds at 100, not 50.
|
||
const events = [
|
||
{ scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false },
|
||
{ scheduledFor: at(19, 0), takenAt: null, skipped: false },
|
||
];
|
||
const tally = tallyComplianceFromLedger(
|
||
events,
|
||
twiceDaily,
|
||
ctxFor(),
|
||
from,
|
||
at(23, 59),
|
||
at(20, 0),
|
||
);
|
||
expect(tally.taken).toBe(1);
|
||
expect(tally.missed).toBe(0);
|
||
expect(tally.denominator).toBe(1);
|
||
expect(tally.rate).toBe(100);
|
||
});
|
||
|
||
it("a pending row past its overdueEnd IS a miss", () => {
|
||
// Same shape, but read at 23:59 — the 19:00 dose's tail has closed.
|
||
const events = [
|
||
{ scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false },
|
||
{ scheduledFor: at(19, 0), takenAt: null, skipped: false },
|
||
];
|
||
const tally = tallyComplianceFromLedger(
|
||
events,
|
||
twiceDaily,
|
||
ctxFor(),
|
||
from,
|
||
nowEvening,
|
||
nowEvening,
|
||
);
|
||
expect(tally.taken).toBe(1);
|
||
expect(tally.missed).toBe(1);
|
||
expect(tally.rate).toBe(50);
|
||
});
|
||
|
||
it("a pending row whose slot lies outside `to = now` never becomes a phantom ad-hoc", () => {
|
||
// The history view asks with `to = now`. The 19:00 band is not minted
|
||
// at 14:00, but the projector already wrote the evening pending row —
|
||
// it must be dropped, not surfaced as an ad-hoc row with takenAt=null.
|
||
const events = [
|
||
{ scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false },
|
||
{ scheduledFor: at(19, 0), takenAt: null, skipped: false },
|
||
];
|
||
const nowAfternoon = at(14, 0);
|
||
const tally = tallyComplianceFromLedger(
|
||
events,
|
||
twiceDaily,
|
||
ctxFor(),
|
||
from,
|
||
nowAfternoon,
|
||
nowAfternoon,
|
||
);
|
||
expect(tally.adHoc).toBe(0);
|
||
expect(tally.taken).toBe(1);
|
||
expect(tally.missed).toBe(0);
|
||
expect(tally.rate).toBe(100);
|
||
});
|
||
|
||
it("an auto-missed forgotten dose IS a miss (counts against the rate)", () => {
|
||
const events = [
|
||
{ scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false },
|
||
{
|
||
scheduledFor: at(19, 0),
|
||
takenAt: null,
|
||
skipped: false,
|
||
autoMissed: true,
|
||
},
|
||
];
|
||
const tally = tallyComplianceFromLedger(
|
||
events,
|
||
twiceDaily,
|
||
ctxFor(),
|
||
from,
|
||
nowEvening,
|
||
nowEvening,
|
||
);
|
||
expect(tally.taken).toBe(1);
|
||
expect(tally.missed).toBe(1);
|
||
expect(tally.skipped).toBe(0);
|
||
expect(tally.rate).toBe(50);
|
||
});
|
||
|
||
it("an off-schedule (ad-hoc) take is EXCLUDED + leaves its slot missed", () => {
|
||
// The maintainer's case: 07:00 dose logged at 11:29 → outside the band → ad-hoc;
|
||
// the 07:00 slot is missed; the 19:00 slot is also missed (untouched).
|
||
const events = [
|
||
{ scheduledFor: at(7, 0), takenAt: at(11, 29), skipped: false },
|
||
];
|
||
const tally = tallyComplianceFromLedger(
|
||
events,
|
||
twiceDaily,
|
||
ctxFor(),
|
||
from,
|
||
nowEvening,
|
||
nowEvening,
|
||
);
|
||
expect(tally.adHoc).toBe(1);
|
||
// The ad-hoc take is NOT in taken; both slots are missed.
|
||
expect(tally.taken).toBe(0);
|
||
expect(tally.missed).toBe(2);
|
||
// Denominator = taken + missed; ad-hoc never enters it.
|
||
expect(tally.denominator).toBe(2);
|
||
expect(tally.rate).toBe(0);
|
||
});
|
||
|
||
it("a future slot is upcoming — excluded, never a premature miss", () => {
|
||
// Early morning now: both slots still ahead of their miss cutoff.
|
||
const earlyNow = at(5, 0);
|
||
const tally = tallyComplianceFromLedger(
|
||
[],
|
||
twiceDaily,
|
||
ctxFor(),
|
||
from,
|
||
earlyNow,
|
||
earlyNow,
|
||
);
|
||
expect(tally.taken).toBe(0);
|
||
expect(tally.missed).toBe(0);
|
||
expect(tally.denominator).toBe(0);
|
||
expect(tally.rate).toBe(100); // empty-window contract
|
||
});
|
||
|
||
it("a PRN schedule contributes NO denominator (every intake excluded)", () => {
|
||
const prn: ComplianceSchedule[] = [
|
||
{
|
||
windowStart: "07:00",
|
||
windowEnd: "19:00",
|
||
daysOfWeek: null,
|
||
timesOfDay: ["07:00"],
|
||
scheduleType: "PRN",
|
||
},
|
||
];
|
||
const events = [
|
||
{ scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false },
|
||
{ scheduledFor: at(14, 0), takenAt: at(14, 0), skipped: false },
|
||
];
|
||
const tally = tallyComplianceFromLedger(
|
||
events,
|
||
prn,
|
||
ctxFor(),
|
||
from,
|
||
nowEvening,
|
||
nowEvening,
|
||
);
|
||
// PRN → hasExpectedSlots false → no bands → every take ad-hoc, no slots.
|
||
expect(tally.taken).toBe(0);
|
||
expect(tally.missed).toBe(0);
|
||
expect(tally.adHoc).toBe(2);
|
||
expect(tally.denominator).toBe(0);
|
||
expect(tally.rate).toBe(100);
|
||
});
|
||
|
||
it("the rate is capped at 100% (extra doses never inflate it)", () => {
|
||
// Both slots taken + an ad-hoc extra. Ad-hoc is excluded so the rate is a
|
||
// clean 100, never >100.
|
||
const events = [
|
||
{ scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false },
|
||
{ scheduledFor: at(19, 0), takenAt: at(19, 0), skipped: false },
|
||
{ scheduledFor: at(13, 0), takenAt: at(13, 0), skipped: false }, // extra
|
||
];
|
||
const tally = tallyComplianceFromLedger(
|
||
events,
|
||
twiceDaily,
|
||
ctxFor(),
|
||
from,
|
||
nowEvening,
|
||
nowEvening,
|
||
);
|
||
expect(tally.taken).toBe(2);
|
||
expect(tally.adHoc).toBe(1);
|
||
expect(tally.rate).toBe(100);
|
||
});
|
||
|
||
it("CROSS-CONSISTENCY: the same intake cannot be taken in the % and ad_hoc in the ledger", () => {
|
||
// The keystone invariant. Build the exact bands the tally builds, run the
|
||
// SAME reconstructDoseHistory, and assert the tally's per-status counts
|
||
// equal the ledger's per-status counts — so a dose graded "taken" by the %
|
||
// is a "taken_*" row in the history view, and an "ad_hoc" row never leaks
|
||
// into the taken numerator.
|
||
const events = [
|
||
{ scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false }, // on-time
|
||
{ scheduledFor: at(19, 0), takenAt: at(20, 30), skipped: false }, // late
|
||
{ scheduledFor: at(7, 0), takenAt: at(11, 29), skipped: false }, // ad-hoc
|
||
];
|
||
const tally = tallyComplianceFromLedger(
|
||
events,
|
||
twiceDaily,
|
||
ctxFor(),
|
||
from,
|
||
nowEvening,
|
||
nowEvening,
|
||
);
|
||
|
||
// Re-derive the ledger independently the same way the tally does.
|
||
const medication: BandMinterMedication = {
|
||
id: "x",
|
||
startsOn: ctxFor().startsOn,
|
||
endsOn: null,
|
||
oneShot: false,
|
||
createdAt: ctxFor().createdAt,
|
||
};
|
||
const recurrenceCtx: RecurrenceContext = {
|
||
medication: {
|
||
id: "x",
|
||
startsOn: ctxFor().startsOn,
|
||
endsOn: null,
|
||
oneShot: false,
|
||
createdAt: ctxFor().createdAt,
|
||
},
|
||
timeZone: TZ,
|
||
lastIntakeAt: null,
|
||
};
|
||
const canonical: CanonicalSchedule = {
|
||
id: "s",
|
||
rrule: null,
|
||
rollingIntervalDays: null,
|
||
timesOfDay: ["07:00", "19:00"],
|
||
daysOfWeek: null,
|
||
windowStart: "07:00",
|
||
windowEnd: "19:00",
|
||
reminderGraceMinutes: null,
|
||
scheduleType: "SCHEDULED",
|
||
cyclicOnWeeks: null,
|
||
cyclicOffWeeks: null,
|
||
};
|
||
const groups = buildBandsForSchedules({
|
||
medication,
|
||
schedules: [canonical],
|
||
ctx: recurrenceCtx,
|
||
userTz: TZ,
|
||
range: { from, to: nowEvening },
|
||
now: nowEvening,
|
||
intakeInstants: events
|
||
.filter((e) => !e.skipped && e.takenAt)
|
||
.map((e) => e.takenAt as Date),
|
||
});
|
||
const bands = groups.flatMap((g) => (g.hasExpectedSlots ? g.bands : []));
|
||
const intakes: HistoryIntake[] = events.map((e) => ({
|
||
scheduledFor: e.scheduledFor,
|
||
takenAt: e.takenAt,
|
||
skipped: e.skipped,
|
||
autoMissed: false,
|
||
}));
|
||
const rows = reconstructDoseHistory(bands, intakes, nowEvening);
|
||
|
||
const fromLedger = {
|
||
onTime: rows.filter((r) => r.status === "taken_on_time").length,
|
||
late: rows.filter((r) => r.status === "taken_late").length,
|
||
missed: rows.filter((r) => r.status === "missed").length,
|
||
adHoc: rows.filter((r) => r.status === "ad_hoc").length,
|
||
};
|
||
expect(tally.takenOnTime).toBe(fromLedger.onTime);
|
||
expect(tally.takenLate).toBe(fromLedger.late);
|
||
expect(tally.missed).toBe(fromLedger.missed);
|
||
expect(tally.adHoc).toBe(fromLedger.adHoc);
|
||
// And the load-bearing claim: the ad-hoc take is NOT counted as taken.
|
||
expect(tally.taken).toBe(fromLedger.onTime + fromLedger.late);
|
||
expect(tally.adHoc).toBe(1);
|
||
});
|
||
|
||
it("rolling weekly (GLP-1) — each logged shot is a taken slot (retrospective family)", () => {
|
||
const rolling: ComplianceSchedule[] = [
|
||
{
|
||
windowStart: "08:00",
|
||
windowEnd: "08:00",
|
||
daysOfWeek: null,
|
||
rollingIntervalDays: 7,
|
||
timesOfDay: ["08:00"],
|
||
},
|
||
];
|
||
const shot1 = new Date("2026-05-04T09:12:00Z");
|
||
const shot2 = new Date("2026-05-11T18:40:00Z");
|
||
const shot3 = new Date("2026-05-18T07:55:00Z");
|
||
const now = new Date("2026-05-20T12:00:00Z");
|
||
const events = [shot1, shot2, shot3].map((d) => ({
|
||
scheduledFor: d,
|
||
takenAt: d,
|
||
skipped: false,
|
||
}));
|
||
const tally = tallyComplianceFromLedger(
|
||
events,
|
||
rolling,
|
||
ctxFor({
|
||
startsOn: new Date("2026-05-01T00:00:00Z"),
|
||
createdAt: new Date("2026-05-01T00:00:00Z"),
|
||
lastIntakeAt: shot3,
|
||
}),
|
||
new Date("2026-05-01T00:00:00Z"),
|
||
now,
|
||
now,
|
||
);
|
||
// Each irregular shot anchors its own retrospective band → all taken.
|
||
expect(tally.taken).toBeGreaterThanOrEqual(3);
|
||
expect(tally.rate).toBeGreaterThanOrEqual(90);
|
||
});
|
||
|
||
// v1.15.18 W7 — the persisted per-dose window reaches the % through the
|
||
// SAME minter the history view + write path use. An 11:29 take that the
|
||
// default ±1h leaves ad-hoc (slot missed) is counted on-time once the
|
||
// morning slot carries an explicit 07:00–12:00 window.
|
||
it("the persisted doseWindows widens the on-time band feeding the %", () => {
|
||
const events = [
|
||
{ scheduledFor: at(7, 0), takenAt: at(11, 29), skipped: false },
|
||
{ scheduledFor: at(19, 0), takenAt: at(19, 0), skipped: false },
|
||
];
|
||
// DEFAULT (no doseWindows): 11:29 is past the 11:00 cutoff → ad-hoc,
|
||
// morning slot missed.
|
||
const dflt = tallyComplianceFromLedger(
|
||
events,
|
||
twiceDaily,
|
||
ctxFor(),
|
||
from,
|
||
nowEvening,
|
||
nowEvening,
|
||
);
|
||
expect(dflt.missed).toBe(1);
|
||
expect(dflt.adHoc).toBe(1);
|
||
|
||
// WITH the persisted 07:00–12:00 window: 11:29 lands on-time, nothing
|
||
// missed, nothing orphaned.
|
||
const windowed: ComplianceSchedule[] = [
|
||
{
|
||
windowStart: "07:00",
|
||
windowEnd: "19:00",
|
||
daysOfWeek: null,
|
||
timesOfDay: ["07:00", "19:00"],
|
||
doseWindows: [{ timeOfDay: "07:00", start: "07:00", end: "12:00" }],
|
||
},
|
||
];
|
||
const wt = tallyComplianceFromLedger(
|
||
events,
|
||
windowed,
|
||
ctxFor(),
|
||
from,
|
||
nowEvening,
|
||
nowEvening,
|
||
);
|
||
expect(wt.missed).toBe(0);
|
||
expect(wt.adHoc).toBe(0);
|
||
expect(wt.takenOnTime).toBe(2);
|
||
expect(wt.rate).toBe(100);
|
||
});
|
||
});
|