Files
HealthLog/src/lib/analytics/__tests__/compliance-bundle.test.ts
T
Marc-André Bombeck fb6813bcdb fix(medications): bucket the local day in the user's zone, DST-safe
Five timezone-correctness fixes across the medication / compliance surfaces:

- getUserTodayBounds now derives both bounds through the DST-safe
  startOfLocalDayInTz, so the today window is 23h on spring-forward and
  25h on fall-back instead of a hardcoded 24h sampled at now. A 23:30
  local dose no longer drops off the med cards, dashboard, PWA badge and
  reminder idempotency near midnight on a transition day.
- dailyComplianceRatesFromLedger takes the user's zone and buckets each
  per-day rate with userDayKey, so a non-Berlin user's evening dose is
  filed on the local day it was due rather than the next Berlin day.
- classifyIntakeTiming interprets the window HH:mm in an optional zone;
  the achievements perfect-day check passes the user's zone and reads the
  scheduled minute-of-day in-zone, so a dose far from UTC is graded
  against local wall time.
- The /insights greeting resolves the hour in the user's stored zone via
  the new hourInTz helper, matching the dashboard hero.
- The doctor-report and schedule-anchored compliance queries load
  schedules through SCHEDULE_COMPLIANCE_SELECT instead of a broad
  include, so a future engine column reaches both surfaces.
2026-07-08 23:38:48 +02:00

397 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* v1.15.20 — single-pass compliance bundle parity.
*
* `buildMedicationComplianceBundle` replaces the per-medication compliance
* route's five-plus expansion passes (calculateCompliance × 4, the window
* ladder probes, the heatmap mint) with ONE band expansion + ONE cadence
* timeline. These tests pin the bundle's blocks against the historical
* per-window composition so the refactor cannot drift the public payload:
* every block must be deep-equal to what `calculateCompliance` /
* `buildComplianceDisplay` produce for the same fixture.
*/
import { describe, it, expect } from "vitest";
import {
buildComplianceMedicationContext,
buildComplianceLedgerRows,
buildMedicationComplianceBundle,
buildComplianceDisplay,
calculateCompliance,
dailyComplianceRatesFromLedger,
lastNonSkippedTakenAt,
tallyComplianceFromLedger,
tallyLedgerRows,
type ComplianceSchedule,
} from "../compliance";
const TZ = "UTC";
const DAY_MS = 24 * 60 * 60 * 1000;
/** Deterministic anchor — keeps every window fully in the fixture's hands. */
const NOW = new Date("2026-06-01T12:00:00Z");
function dailySchedule(): ComplianceSchedule {
return {
windowStart: "08:00",
windowEnd: "09:00",
timesOfDay: ["08:00"],
daysOfWeek: null,
rrule: null,
rollingIntervalDays: null,
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
};
}
function weeklyMondaySchedule(): ComplianceSchedule {
return {
windowStart: "08:00",
windowEnd: "09:00",
timesOfDay: ["08:00"],
daysOfWeek: null,
rrule: "FREQ=WEEKLY;BYDAY=MO",
rollingIntervalDays: null,
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
};
}
function rollingSchedule(intervalDays: number): ComplianceSchedule {
return {
windowStart: "10:00",
windowEnd: "11:00",
timesOfDay: ["10:00"],
daysOfWeek: null,
rrule: null,
rollingIntervalDays: intervalDays,
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
};
}
/** A slot instant `daysAgo` before NOW at the given "HH:mm" (UTC fixture). */
function slotAt(daysAgo: number, hhmm: string): Date {
const day = new Date(NOW.getTime() - daysAgo * DAY_MS);
const [h, m] = hhmm.split(":").map(Number);
const d = new Date(day);
d.setUTCHours(h, m, 0, 0);
return d;
}
interface FixtureEvent {
takenAt: Date | null;
skipped: boolean;
scheduledFor: Date;
autoMissed?: boolean;
}
function dailyFixtureEvents(): FixtureEvent[] {
return [
// Taken on time across several days.
{
takenAt: slotAt(1, "08:05"),
skipped: false,
scheduledFor: slotAt(1, "08:00"),
},
{
takenAt: slotAt(2, "08:10"),
skipped: false,
scheduledFor: slotAt(2, "08:00"),
},
{
takenAt: slotAt(4, "07:55"),
skipped: false,
scheduledFor: slotAt(4, "08:00"),
},
// A deliberate skip (excluded from the denominator).
{ takenAt: null, skipped: true, scheduledFor: slotAt(3, "08:00") },
// A taken-late dose (inside the late tail).
{
takenAt: slotAt(5, "10:30"),
skipped: false,
scheduledFor: slotAt(5, "08:00"),
},
// Day 6 has no event → the unfilled slot reads missed.
// An auto-missed forgotten dose.
{
takenAt: null,
skipped: false,
scheduledFor: slotAt(8, "08:00"),
autoMissed: true,
},
];
}
describe("buildMedicationComplianceBundle — parity with the per-window composition", () => {
it("daily medication: every block equals the legacy multi-pass output", () => {
const createdAt = new Date(NOW.getTime() - 40 * DAY_MS);
const schedules = [dailySchedule()];
const events = dailyFixtureEvents();
const ctx = buildComplianceMedicationContext(
{ startsOn: null, endsOn: null, oneShot: false, createdAt },
lastNonSkippedTakenAt(events),
TZ,
);
const bundle = buildMedicationComplianceBundle(events, schedules, ctx, NOW);
expect(bundle.compliance7).toEqual(
calculateCompliance(events, schedules, 7, createdAt, {
now: NOW,
medicationContext: ctx,
}),
);
expect(bundle.compliance30).toEqual(
calculateCompliance(events, schedules, 30, createdAt, {
now: NOW,
medicationContext: ctx,
}),
);
expect(bundle.complianceDisplay).toEqual(
buildComplianceDisplay(events, schedules, ctx, { now: NOW }),
);
// The ledger window clamps to the medication's creation.
expect(bundle.ledgerFrom).toEqual(createdAt);
// One band per daily slot over the 40-day life → 40 or 41 slot rows
// (the boundary day depends on the creation instant vs slot time).
const slotRows = bundle.ledgerRows.filter((r) => r.kind === "slot");
expect(slotRows.length).toBeGreaterThanOrEqual(39);
expect(slotRows.length).toBeLessThanOrEqual(41);
});
it("rolling 35-day medication: display windows + rates match the legacy path", () => {
const createdAt = new Date(NOW.getTime() - 400 * DAY_MS);
const schedules = [rollingSchedule(35)];
const events: FixtureEvent[] = [
{
takenAt: new Date(NOW.getTime() - 25 * DAY_MS),
skipped: false,
scheduledFor: new Date(NOW.getTime() - 25 * DAY_MS),
},
{
takenAt: new Date(NOW.getTime() - 60 * DAY_MS),
skipped: false,
scheduledFor: new Date(NOW.getTime() - 60 * DAY_MS),
},
];
const ctx = buildComplianceMedicationContext(
{
startsOn: new Date(NOW.getTime() - 380 * DAY_MS),
endsOn: null,
oneShot: false,
createdAt,
},
lastNonSkippedTakenAt(events),
TZ,
);
const bundle = buildMedicationComplianceBundle(events, schedules, ctx, NOW);
expect(bundle.compliance7).toEqual(
calculateCompliance(events, schedules, 7, createdAt, {
now: NOW,
medicationContext: ctx,
}),
);
expect(bundle.compliance30).toEqual(
calculateCompliance(events, schedules, 30, createdAt, {
now: NOW,
medicationContext: ctx,
}),
);
expect(bundle.complianceDisplay).toEqual(
buildComplianceDisplay(events, schedules, ctx, { now: NOW }),
);
// A 35-day cadence cannot clear the stability floor on [7, 30].
expect(bundle.complianceDisplay.shortDays).toBeGreaterThan(7);
expect(bundle.complianceDisplay.longDays).toBeGreaterThan(30);
});
it("empty schedule list: zeros short-circuit matches calculateCompliance", () => {
const createdAt = new Date(NOW.getTime() - 10 * DAY_MS);
const ctx = buildComplianceMedicationContext(
{ startsOn: null, endsOn: null, oneShot: false, createdAt },
null,
TZ,
);
const bundle = buildMedicationComplianceBundle([], [], ctx, NOW);
expect(bundle.compliance7).toEqual(
calculateCompliance([], [], 7, createdAt, {
now: NOW,
medicationContext: ctx,
}),
);
expect(bundle.complianceDisplay).toEqual(
buildComplianceDisplay([], [], ctx, { now: NOW }),
);
expect(bundle.ledgerRows).toEqual([]);
});
});
describe("dailyComplianceRatesFromLedger — cadence-aware per-day series", () => {
it("weekly Monday-only med: per-day series agrees with compliance30 and never marks non-due days", () => {
// NOW (2026-06-01) is a Monday; build five trailing Mondays and take
// every one on time. A weekly-cadence med expects a dose ONLY on Mondays.
const createdAt = new Date(NOW.getTime() - 60 * DAY_MS);
const schedules = [weeklyMondaySchedule()];
const ctx = buildComplianceMedicationContext(
{ startsOn: null, endsOn: null, oneShot: false, createdAt },
null,
TZ,
);
const events: FixtureEvent[] = [];
for (let week = 0; week < 5; week++) {
const daysAgo = week * 7; // every Monday back from NOW
events.push({
takenAt: slotAt(daysAgo, "08:05"),
skipped: false,
scheduledFor: slotAt(daysAgo, "08:00"),
});
}
ctx.lastIntakeAt = lastNonSkippedTakenAt(events);
const bundle = buildMedicationComplianceBundle(events, schedules, ctx, NOW);
// Took every expected Monday → 100% over 30 days.
expect(bundle.compliance30.rate).toBe(100);
// The per-day series carves the same 30-day window out of the shared
// ledger and must agree with compliance30 — no off-cadence weekday is
// counted as a miss.
const from = new Date(NOW.getTime() - 30 * DAY_MS);
const windowRows = bundle.ledgerRows.filter(
(r) =>
r.at.getTime() >= from.getTime() && r.at.getTime() <= NOW.getTime(),
);
const series = dailyComplianceRatesFromLedger(windowRows, TZ);
// Only the Mondays in the window produce a point — Tuesday…Sunday emit
// nothing, so a 7×-denser daily denominator can never drag the rate down.
// NOW is itself a Monday, so the 30-day window holds five Mondays.
expect(series.length).toBe(5);
for (const day of series) {
expect(day.rate).toBe(100);
expect(day.missed).toBe(0);
}
// The series' mean rate equals the canonical compliance30 rate: the two
// are computed from the SAME ledger and cannot contradict each other.
const meanRate = series.reduce((sum, d) => sum + d.rate, 0) / series.length;
expect(Math.round(meanRate)).toBe(bundle.compliance30.rate);
});
it("weekly med with one missed Monday: the missed day reads 0%, due days only", () => {
const createdAt = new Date(NOW.getTime() - 60 * DAY_MS);
const schedules = [weeklyMondaySchedule()];
const ctx = buildComplianceMedicationContext(
{ startsOn: null, endsOn: null, oneShot: false, createdAt },
null,
TZ,
);
// Four Mondays in the 30-day window; skip the take on the 2-weeks-ago one.
const events: FixtureEvent[] = [];
for (let week = 0; week < 5; week++) {
const daysAgo = week * 7;
if (week === 2) continue; // forgotten dose → an expected slot reads missed
events.push({
takenAt: slotAt(daysAgo, "08:05"),
skipped: false,
scheduledFor: slotAt(daysAgo, "08:00"),
});
}
ctx.lastIntakeAt = lastNonSkippedTakenAt(events);
const bundle = buildMedicationComplianceBundle(events, schedules, ctx, NOW);
const from = new Date(NOW.getTime() - 30 * DAY_MS);
const windowRows = bundle.ledgerRows.filter(
(r) =>
r.at.getTime() >= from.getTime() && r.at.getTime() <= NOW.getTime(),
);
const series = dailyComplianceRatesFromLedger(windowRows, TZ);
// Still one point per due Monday — never a point on a non-due weekday.
// Five Mondays in the window; the skipped take leaves one as a miss.
expect(series.length).toBe(5);
const zeroDays = series.filter((d) => d.rate === 0);
expect(zeroDays.length).toBe(1);
expect(zeroDays[0].missed).toBe(1);
// Aggregate taken / (taken + missed) across due days equals compliance30.
const taken = series.reduce((sum, d) => sum + d.taken, 0);
const missed = series.reduce((sum, d) => sum + d.missed, 0);
const aggregateRate = Math.round((taken / (taken + missed)) * 100);
expect(aggregateRate).toBe(bundle.compliance30.rate);
});
});
describe("tallyLedgerRows — windowed tallies over one shared ledger", () => {
it("the unwindowed tally equals tallyComplianceFromLedger byte-for-byte", () => {
const createdAt = new Date(NOW.getTime() - 40 * DAY_MS);
const schedules = [dailySchedule()];
const events = dailyFixtureEvents();
const ctx = buildComplianceMedicationContext(
{ startsOn: null, endsOn: null, oneShot: false, createdAt },
lastNonSkippedTakenAt(events),
TZ,
);
const from = new Date(NOW.getTime() - 30 * DAY_MS);
const rows = buildComplianceLedgerRows(
events,
schedules,
ctx,
from,
NOW,
NOW,
);
expect(tallyLedgerRows(rows)).toEqual(
tallyComplianceFromLedger(events, schedules, ctx, from, NOW, NOW),
);
});
it("a sub-window tally counts only rows inside the window", () => {
const createdAt = new Date(NOW.getTime() - 40 * DAY_MS);
const schedules = [dailySchedule()];
const events = dailyFixtureEvents();
const ctx = buildComplianceMedicationContext(
{ startsOn: null, endsOn: null, oneShot: false, createdAt },
lastNonSkippedTakenAt(events),
TZ,
);
const rows = buildComplianceLedgerRows(
events,
schedules,
ctx,
createdAt,
NOW,
NOW,
);
const wide = tallyLedgerRows(rows);
// 3.5 days reaches back past the day −3 08:00 slot (NOW is 12:00Z, so a
// plain 3-day bound would cut that morning slot off).
const narrow = tallyLedgerRows(rows, {
from: new Date(NOW.getTime() - 3.5 * DAY_MS),
to: NOW,
});
// Days −1 and −2 are taken on time; day −3 is a skip. Everything
// older falls outside the window.
expect(narrow.takenOnTime).toBe(2);
expect(narrow.skipped).toBe(1);
expect(narrow.denominator).toBeLessThan(wide.denominator);
});
});