mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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.
397 lines
13 KiB
TypeScript
397 lines
13 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
});
|