mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(medications): correct weekly-cadence compliance returning 0% despite recorded intakes
A weekly injectable (e.g. a GLP-1 dose) with real recorded intakes reported 0% compliance on the medication card across every window (7 / 30 / 90 days). The web reads the server-computed rate verbatim, so the server value itself was wrong. The intake-to-slot matcher used a fixed +/-12h pairing radius. That is correct for a daily cadence (24h inter-slot gap) but far too tight for a once-weekly dose: a real intake is rarely logged within 12h of the schedule's configured time-of-day, because the user takes the shot on whichever day and time of the dosing week suits them. Those intakes fell outside the radius, every weekly slot read "missed", and the rate collapsed to 0% while the matching intakes were orphaned. Scale the pairing radius with the cadence gap. `pairDoses` now derives a per-slot radius from half the distance to its nearer neighbouring slot (so two adjacent slots never double-claim one intake), floored at the 12h base so daily and multi-dose-daily cadences keep their exact prior behaviour. `buildCadenceTimeline` additionally probes the schedule's intrinsic cadence over a padded window and passes half the minimum inter-slot gap as a radius floor, so a window holding a single expected slot (a weekly med over a 7-day window) widens correctly too. A weekly cadence now pairs an intake logged anywhere in the dosing week to that week's slot, reporting the true rate. Daily-cadence matching is unchanged. Adds unit coverage for the widened radius and a real-Postgres integration test mirroring the weekly-injectable row + off-time intakes.
This commit is contained in:
1 parent
e9eb7dc704
commit
e0f60d82cc
4 files changed
+570
-6
No files matched your search
@@ -531,6 +531,122 @@ describe("calculateCompliance — engine-routed (medicationContext)", () => {
|
||||
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[] = [
|
||||
|
||||
@@ -267,6 +267,66 @@ describe("pairDoses", () => {
|
||||
expect(paired[0].status).toBe("missed");
|
||||
});
|
||||
|
||||
it("widens the match radius for a sparse (weekly) cadence via the half-gap rule", () => {
|
||||
// Two weekly slots seven days apart. An intake landing 2 days after
|
||||
// the first slot (48h — far outside the legacy ±12h radius) must pair
|
||||
// to that first slot, because half the 7-day gap is ~3.5 days.
|
||||
const NOW = d("2025-06-20T12:00:00");
|
||||
const slots = [
|
||||
{
|
||||
day: d("2025-06-02T00:00:00"),
|
||||
windowStart: d("2025-06-02T08:00:00"),
|
||||
windowEnd: d("2025-06-02T09:00:00"),
|
||||
scheduleIndex: 0,
|
||||
},
|
||||
{
|
||||
day: d("2025-06-09T00:00:00"),
|
||||
windowStart: d("2025-06-09T08:00:00"),
|
||||
windowEnd: d("2025-06-09T09:00:00"),
|
||||
scheduleIndex: 0,
|
||||
},
|
||||
];
|
||||
const events = [
|
||||
{
|
||||
scheduledFor: d("2025-06-04T19:00:00"), // Wed, ~2.5 days after slot 1
|
||||
takenAt: d("2025-06-04T19:00:00"),
|
||||
skipped: false,
|
||||
},
|
||||
];
|
||||
const paired = pairDoses(slots, events, NOW);
|
||||
expect(paired[0].status).toBe("taken");
|
||||
expect(paired[1].status).toBe("missed");
|
||||
});
|
||||
|
||||
it("honours a caller-supplied radius floor when the window holds a single slot", () => {
|
||||
// A single weekly slot — no neighbour to derive a half-gap from. The
|
||||
// caller (buildCadenceTimeline) passes the schedule's cadence-derived
|
||||
// floor so the off-time intake still pairs.
|
||||
const NOW = d("2025-06-20T12:00:00");
|
||||
const slots = [
|
||||
{
|
||||
day: d("2025-06-16T00:00:00"),
|
||||
windowStart: d("2025-06-16T08:00:00"),
|
||||
windowEnd: d("2025-06-16T09:00:00"),
|
||||
scheduleIndex: 0,
|
||||
},
|
||||
];
|
||||
const events = [
|
||||
{
|
||||
scheduledFor: d("2025-06-16T23:00:00"), // 15h after the 08:00 slot
|
||||
takenAt: d("2025-06-16T23:00:00"),
|
||||
skipped: false,
|
||||
},
|
||||
];
|
||||
// Without a floor the legacy 12h radius drops it.
|
||||
expect(pairDoses(slots, events, NOW)[0].status).toBe("missed");
|
||||
// With a 3.5-day floor (half a weekly gap) it pairs.
|
||||
const withFloor = pairDoses(slots, events, NOW, {
|
||||
radiusFloorMs: 3.5 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
expect(withFloor[0].status).toBe("taken");
|
||||
});
|
||||
|
||||
it("never double-claims a single event across two close slots", () => {
|
||||
const dailySched = {
|
||||
windowStart: "08:00",
|
||||
@@ -317,6 +377,32 @@ describe("buildCadenceTimeline", () => {
|
||||
const NOW = d("2025-06-10T12:00:00");
|
||||
expect(buildCadenceTimeline([], [], NOW, 30)).toEqual([]);
|
||||
});
|
||||
|
||||
it("pairs off-time weekly intakes via the schedule-derived radius floor (Mounjaro 0% repro)", () => {
|
||||
// A weekly Monday schedule with intakes logged the same Monday but in
|
||||
// the evening (22:00 — outside the legacy ±12h radius). Pre-fix every
|
||||
// slot read `missed`; the timeline now pairs each one.
|
||||
const weekly: ScheduleLike = {
|
||||
windowStart: "07:30",
|
||||
windowEnd: "08:30",
|
||||
daysOfWeek: "1",
|
||||
};
|
||||
const NOW = d("2025-06-11T12:00:00"); // Wednesday
|
||||
const events = Array.from({ length: 4 }, (_, i) => {
|
||||
const at = d(
|
||||
new Date(
|
||||
new Date("2025-06-09T22:00:00").getTime() -
|
||||
i * 7 * 24 * 60 * 60 * 1000,
|
||||
).toISOString(),
|
||||
);
|
||||
return { scheduledFor: at, takenAt: at, skipped: false };
|
||||
});
|
||||
const timeline = buildCadenceTimeline([weekly], events, NOW, 30);
|
||||
const taken = timeline.filter((t) => t.status === "taken").length;
|
||||
const missed = timeline.filter((t) => t.status === "missed").length;
|
||||
expect(taken).toBeGreaterThan(0);
|
||||
expect(missed).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeNextDose", () => {
|
||||
|
||||
@@ -154,8 +154,11 @@ export interface PairedDose extends ExpectedDose {
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const WEEK_MS = 7 * DAY_MS;
|
||||
/**
|
||||
* Match window for pairing an actual intake event to an expected
|
||||
* dose: +/- 12 hours around the slot's center.
|
||||
* Base match radius for pairing an actual intake event to an expected
|
||||
* dose: +/- 12 hours around the slot's centre. This is the floor — a
|
||||
* daily (24h-gap) cadence matches inside +/- 12h, and a denser
|
||||
* multi-dose-per-day cadence keeps the same 12h floor so its
|
||||
* historically-pinned matching contract is byte-stable.
|
||||
*
|
||||
* Rationale: the existing classifyIntakeTiming uses a 3-hour grace
|
||||
* around the window + a configurable late tolerance after. For cadence
|
||||
@@ -165,6 +168,43 @@ const WEEK_MS = 7 * DAY_MS;
|
||||
*/
|
||||
const PAIR_RADIUS_MS = 12 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* v1.12.0 — derive the per-slot match radius from the gap to its
|
||||
* neighbouring expected slots.
|
||||
*
|
||||
* The fixed +/-12h radius was correct only for a daily cadence (the
|
||||
* inter-slot gap is 24h, so a half-gap of 12h cleanly partitions the
|
||||
* timeline into one Voronoi cell per slot). For a SPARSE cadence — a
|
||||
* weekly injectable (Mounjaro / Ozempic), a bi-weekly or monthly dose —
|
||||
* the gap between expected slots is 7+ days, but a real intake is rarely
|
||||
* logged within 12h of the configured slot instant: the user takes the
|
||||
* shot on whichever day of the dosing week suits them, often at a
|
||||
* different time of day than the schedule's HH:mm. Those intakes then
|
||||
* fell OUTSIDE the 12h radius and every weekly slot read `missed` while
|
||||
* the matching intake was orphaned — the live "0% despite recorded
|
||||
* intakes" defect.
|
||||
*
|
||||
* The radius is half the distance to the nearer neighbour (so two
|
||||
* adjacent slots never both reach the same midpoint and double-claim),
|
||||
* floored at the 12h base so daily / multi-dose-daily cadences keep
|
||||
* their exact pre-fix behaviour. A weekly cadence (168h gap) widens to a
|
||||
* ~3.5-day radius, so an intake logged anywhere in the dosing week pairs
|
||||
* to that week's slot.
|
||||
*/
|
||||
function slotMatchRadius(
|
||||
centre: number,
|
||||
prevCentre: number | null,
|
||||
nextCentre: number | null,
|
||||
): number {
|
||||
const halfToPrev =
|
||||
prevCentre === null ? Infinity : (centre - prevCentre) / 2;
|
||||
const halfToNext =
|
||||
nextCentre === null ? Infinity : (nextCentre - centre) / 2;
|
||||
const halfGap = Math.min(halfToPrev, halfToNext);
|
||||
if (!Number.isFinite(halfGap)) return PAIR_RADIUS_MS;
|
||||
return Math.max(PAIR_RADIUS_MS, halfGap);
|
||||
}
|
||||
|
||||
// WallClockParts + wallClockInTz live in `@/lib/tz/wall-clock` as the
|
||||
// canonical helper — see the file-level comment there for the v1.4.40
|
||||
// consolidation rationale.
|
||||
@@ -427,8 +467,18 @@ export function pairDoses(
|
||||
slots: ExpectedDose[],
|
||||
events: IntakeEventLike[],
|
||||
now: Date,
|
||||
options?: { radiusFloorMs?: number },
|
||||
): PairedDose[] {
|
||||
const claimed = new Set<number>();
|
||||
// v1.12.0 — caller-supplied radius floor. `buildCadenceTimeline` derives
|
||||
// the schedule's intrinsic cadence gap and passes half of it here so a
|
||||
// window that holds a SINGLE expected slot (e.g. a weekly med over a
|
||||
// 7-day window) still widens its match radius — the per-slot
|
||||
// neighbour-gap logic below can only widen when two slots are present.
|
||||
const radiusFloor = Math.max(
|
||||
PAIR_RADIUS_MS,
|
||||
options?.radiusFloorMs ?? PAIR_RADIUS_MS,
|
||||
);
|
||||
|
||||
// v1.4.27 B7 / simp-M6 — sort once by `windowStart` for the match
|
||||
// pass and return the result in the same order. Slots that touch
|
||||
@@ -440,10 +490,29 @@ export function pairDoses(
|
||||
const sorted = [...slots].sort(
|
||||
(a, b) => a.windowStart.getTime() - b.windowStart.getTime(),
|
||||
);
|
||||
// Pre-compute each slot's centre once so the per-slot match radius can
|
||||
// read the gap to its neighbours (the centres are monotonic in `sorted`
|
||||
// order, so neighbour gaps are simply the adjacent entries).
|
||||
const centres = sorted.map(
|
||||
(s) => (s.windowStart.getTime() + s.windowEnd.getTime()) / 2,
|
||||
);
|
||||
const result: PairedDose[] = [];
|
||||
|
||||
for (const slot of sorted) {
|
||||
const centre = (slot.windowStart.getTime() + slot.windowEnd.getTime()) / 2;
|
||||
for (let s = 0; s < sorted.length; s++) {
|
||||
const slot = sorted[s];
|
||||
const centre = centres[s];
|
||||
// v1.12.0 — cadence-aware radius: half the gap to the nearer
|
||||
// neighbouring slot, floored at the 12h base. Daily / multi-dose
|
||||
// cadences keep the 12h floor; a weekly+ cadence widens so an intake
|
||||
// logged anywhere inside the dosing week pairs to that week's slot.
|
||||
const radius = Math.max(
|
||||
radiusFloor,
|
||||
slotMatchRadius(
|
||||
centre,
|
||||
s > 0 ? centres[s - 1] : null,
|
||||
s < centres.length - 1 ? centres[s + 1] : null,
|
||||
),
|
||||
);
|
||||
let bestIdx = -1;
|
||||
let bestDist = Infinity;
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
@@ -451,7 +520,7 @@ export function pairDoses(
|
||||
const evt = events[i];
|
||||
const t = (evt.takenAt ?? evt.scheduledFor).getTime();
|
||||
const dist = Math.abs(t - centre);
|
||||
if (dist <= PAIR_RADIUS_MS && dist < bestDist) {
|
||||
if (dist <= radius && dist < bestDist) {
|
||||
bestDist = dist;
|
||||
bestIdx = i;
|
||||
}
|
||||
@@ -541,7 +610,72 @@ export function buildCadenceTimeline(
|
||||
),
|
||||
);
|
||||
}
|
||||
return pairDoses(slots, events, asOf);
|
||||
// v1.12.0 — derive the schedule's intrinsic cadence gap so the pairing
|
||||
// radius widens for sparse cadences even when the requested window holds
|
||||
// a single expected slot (a weekly med over a 7-day window). The per-slot
|
||||
// neighbour-gap logic inside `pairDoses` can only widen when two slots
|
||||
// sit in the window; a single-slot window needs the floor below.
|
||||
const radiusFloorMs = cadenceRadiusFloor(
|
||||
schedules,
|
||||
asOf,
|
||||
windowDays,
|
||||
anchor,
|
||||
timeZone,
|
||||
engineCtx,
|
||||
);
|
||||
return pairDoses(slots, events, asOf, { radiusFloorMs });
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.12.0 — half the schedule's intrinsic cadence gap, used as the floor
|
||||
* for the intake-match radius.
|
||||
*
|
||||
* Expands every schedule over a window padded to at least ~16 weeks so a
|
||||
* sparse cadence (weekly / bi-weekly / monthly) yields ≥ 2 slots and a
|
||||
* real inter-slot gap is observable even when the caller's compliance
|
||||
* window only holds one. Takes the MINIMUM consecutive gap across the
|
||||
* union of all schedules (the densest part of a multi-schedule med) and
|
||||
* halves it, so the floor never over-widens past the point where two
|
||||
* adjacent slots would double-claim one intake. Returns the 12h base when
|
||||
* fewer than two slots exist anywhere in the probe window (rolling cadence
|
||||
* emits only the next slot; a brand-new med; a one-shot) — those cases
|
||||
* either have an empty compliance window or a single slot the base radius
|
||||
* already covers.
|
||||
*/
|
||||
function cadenceRadiusFloor(
|
||||
schedules: ScheduleLike[],
|
||||
asOf: Date,
|
||||
windowDays: number,
|
||||
anchor: Date | undefined,
|
||||
timeZone: string | undefined,
|
||||
engineCtx: CadenceEngineContext | undefined,
|
||||
): number {
|
||||
const probeDays = Math.max(windowDays, 16 * 7);
|
||||
const probeFrom = new Date(asOf.getTime() - probeDays * DAY_MS);
|
||||
const centres: number[] = [];
|
||||
for (let i = 0; i < schedules.length; i++) {
|
||||
const probeSlots = expandScheduleSlots(
|
||||
schedules[i],
|
||||
i,
|
||||
probeFrom,
|
||||
asOf,
|
||||
anchor ?? probeFrom,
|
||||
timeZone,
|
||||
engineCtx,
|
||||
);
|
||||
for (const slot of probeSlots) {
|
||||
centres.push((slot.windowStart.getTime() + slot.windowEnd.getTime()) / 2);
|
||||
}
|
||||
}
|
||||
if (centres.length < 2) return PAIR_RADIUS_MS;
|
||||
centres.sort((a, b) => a - b);
|
||||
let minGap = Infinity;
|
||||
for (let i = 1; i < centres.length; i++) {
|
||||
const gap = centres[i] - centres[i - 1];
|
||||
if (gap > 0 && gap < minGap) minGap = gap;
|
||||
}
|
||||
if (!Number.isFinite(minGap)) return PAIR_RADIUS_MS;
|
||||
return Math.max(PAIR_RADIUS_MS, minGap / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* v1.12.0 — weekly-injectable compliance regression.
|
||||
*
|
||||
* Reproduces, against a real Postgres row, the live defect where a
|
||||
* WEEKLY injectable (Mounjaro / GLP-1) with recorded intakes reported
|
||||
* 0% compliance across EVERY window (7 / 30 / 90 days) on the medication
|
||||
* card. The web reads the server-computed `compliance7` / `compliance30`
|
||||
* / `complianceDisplay` values verbatim, so the server value itself was
|
||||
* 0% — not a relabel.
|
||||
*
|
||||
* Root cause: the intake-to-slot matcher (`pairDoses`) used a fixed ±12h
|
||||
* pairing radius. That radius is correct for a daily cadence (24h gap)
|
||||
* but far too tight for a once-weekly dose, where a real intake is rarely
|
||||
* 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. Those
|
||||
* intakes fell outside the radius, every weekly slot read `missed`, and
|
||||
* the rate collapsed to 0%. The radius now scales with the cadence gap.
|
||||
*
|
||||
* The test writes a Mounjaro-shaped medication + schedule + intake rows,
|
||||
* reads them back through the application Prisma singleton (so the
|
||||
* column shapes — `timesOfDay[]`, `rrule`, `daysOfWeek`,
|
||||
* `rollingIntervalDays` — are byte-identical to what the route reads),
|
||||
* and asserts the cadence-aware compliance helpers return the true
|
||||
* non-zero rate.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
import {
|
||||
buildComplianceDisplay,
|
||||
buildComplianceMedicationContext,
|
||||
calculateCompliance,
|
||||
lastNonSkippedTakenAt,
|
||||
type ComplianceSchedule,
|
||||
} from "@/lib/analytics/compliance";
|
||||
|
||||
const TEST_USER_ID = "user-weekly-compliance";
|
||||
const USER_TZ = "Europe/Berlin";
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Pin "now" to a Wednesday so a Monday weekly cadence has a clean count
|
||||
// of Mondays in each trailing window.
|
||||
const NOW = new Date("2025-06-04T12:00:00Z");
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: TEST_USER_ID,
|
||||
username: "weekly-compliance",
|
||||
email: "weekly-compliance@example.test",
|
||||
timezone: USER_TZ,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a weekly Mounjaro-shaped medication. The schedule is encoded the
|
||||
* way the create route writes a once-weekly injectable, parameterised so
|
||||
* the test can cover both the RRULE-weekly and rolling-interval forms.
|
||||
*/
|
||||
async function createWeeklyMounjaro(opts: {
|
||||
rrule?: string;
|
||||
rollingIntervalDays?: number;
|
||||
daysOfWeek?: string | null;
|
||||
windowStart: string;
|
||||
}): Promise<string> {
|
||||
const prisma = getPrismaClient();
|
||||
const med = await prisma.medication.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
name: "Mounjaro",
|
||||
dose: "5mg",
|
||||
treatmentClass: "GLP1",
|
||||
deliveryForm: "INJECTION",
|
||||
// Created ~100 days ago so the 90-day window is fully covered.
|
||||
createdAt: new Date(NOW.getTime() - 100 * DAY_MS),
|
||||
startsOn: new Date("2025-02-24T00:00:00Z"), // a Monday
|
||||
schedules: {
|
||||
create: {
|
||||
windowStart: opts.windowStart,
|
||||
windowEnd: opts.windowStart,
|
||||
timesOfDay: [opts.windowStart],
|
||||
daysOfWeek: opts.daysOfWeek ?? null,
|
||||
...(opts.rrule !== undefined && { rrule: opts.rrule }),
|
||||
...(opts.rollingIntervalDays !== undefined && {
|
||||
rollingIntervalDays: opts.rollingIntervalDays,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return med.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log `count` weekly intakes on consecutive Mondays back from
|
||||
* `lastMonday`, each taken at the given local time-of-day (deliberately
|
||||
* NOT the schedule's configured HH:mm, mirroring real usage where the
|
||||
* shot is taken whenever).
|
||||
*/
|
||||
async function logWeeklyIntakes(
|
||||
medicationId: string,
|
||||
lastMonday: Date,
|
||||
count: number,
|
||||
): Promise<void> {
|
||||
const prisma = getPrismaClient();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const at = new Date(lastMonday.getTime() - i * 7 * DAY_MS);
|
||||
await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
medicationId,
|
||||
scheduledFor: at,
|
||||
takenAt: at,
|
||||
skipped: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror the compliance route: read the medication + schedules + intakes,
|
||||
* build the context, and compute the three windows + the display block.
|
||||
*/
|
||||
async function readCompliance(medicationId: string) {
|
||||
const prisma = getPrismaClient();
|
||||
const medication = await prisma.medication.findUniqueOrThrow({
|
||||
where: { id: medicationId },
|
||||
include: { schedules: true },
|
||||
});
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
where: { medicationId, userId: TEST_USER_ID, deletedAt: null },
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
});
|
||||
const mapped = events.map((e) => ({
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
scheduledFor: e.scheduledFor,
|
||||
}));
|
||||
const lastIntakeAt = lastNonSkippedTakenAt(mapped);
|
||||
const ctx = buildComplianceMedicationContext(medication, lastIntakeAt, USER_TZ);
|
||||
const schedules = medication.schedules as ComplianceSchedule[];
|
||||
return {
|
||||
compliance7: calculateCompliance(mapped, schedules, 7, medication.createdAt, {
|
||||
medicationContext: ctx,
|
||||
}),
|
||||
compliance30: calculateCompliance(mapped, schedules, 30, medication.createdAt, {
|
||||
medicationContext: ctx,
|
||||
}),
|
||||
compliance90: calculateCompliance(mapped, schedules, 90, medication.createdAt, {
|
||||
medicationContext: ctx,
|
||||
}),
|
||||
display: buildComplianceDisplay(mapped, schedules, ctx),
|
||||
};
|
||||
}
|
||||
|
||||
describe("weekly injectable compliance (Mounjaro 0% regression)", () => {
|
||||
// Mondays back from the pinned Wednesday, taken at 20:00 local (well
|
||||
// outside the schedule's 07:30 HH:mm and the legacy ±12h radius).
|
||||
const lastMonday = new Date("2025-06-02T18:00:00Z");
|
||||
|
||||
it("RRULE FREQ=WEEKLY;BYDAY=MO — off-time Monday intakes report the true rate, not 0%", async () => {
|
||||
const id = await createWeeklyMounjaro({
|
||||
rrule: "FREQ=WEEKLY;BYDAY=MO",
|
||||
windowStart: "07:30",
|
||||
});
|
||||
await logWeeklyIntakes(id, lastMonday, 13);
|
||||
|
||||
const { compliance7, compliance30, compliance90, display } =
|
||||
await readCompliance(id);
|
||||
|
||||
// Every recorded weekly dose pairs to its week's slot → 100%.
|
||||
expect(compliance7.rate).toBe(100);
|
||||
expect(compliance30.rate).toBe(100);
|
||||
expect(compliance90.rate).toBe(100);
|
||||
// And the denominator is the count of Mondays, not days.
|
||||
expect(compliance30.taken).toBeGreaterThan(0);
|
||||
expect(compliance90.taken).toBeGreaterThanOrEqual(12);
|
||||
expect(compliance90.missed).toBe(0);
|
||||
// The two-row card display agrees.
|
||||
expect(display.short.rate).toBe(100);
|
||||
expect(display.long.rate).toBe(100);
|
||||
});
|
||||
|
||||
it("legacy daysOfWeek='1' (Monday-only) — off-time Monday intakes report the true rate, not 0%", async () => {
|
||||
const id = await createWeeklyMounjaro({
|
||||
daysOfWeek: "1",
|
||||
windowStart: "07:30",
|
||||
});
|
||||
await logWeeklyIntakes(id, lastMonday, 13);
|
||||
|
||||
const { compliance7, compliance30, compliance90 } =
|
||||
await readCompliance(id);
|
||||
|
||||
expect(compliance7.rate).toBe(100);
|
||||
expect(compliance30.rate).toBe(100);
|
||||
expect(compliance90.rate).toBe(100);
|
||||
expect(compliance90.missed).toBe(0);
|
||||
});
|
||||
|
||||
it("RRULE weekly — one missed week reports a high-but-not-100 rate (matcher still discriminates a real gap)", async () => {
|
||||
const id = await createWeeklyMounjaro({
|
||||
rrule: "FREQ=WEEKLY;BYDAY=MO",
|
||||
windowStart: "07:30",
|
||||
});
|
||||
// Log only 12 of the trailing 13 Mondays (skip the most recent).
|
||||
await logWeeklyIntakes(
|
||||
id,
|
||||
new Date(lastMonday.getTime() - 7 * DAY_MS),
|
||||
12,
|
||||
);
|
||||
|
||||
const { compliance90 } = await readCompliance(id);
|
||||
// 12 of 13 expected → ~92%, definitively non-zero and below 100.
|
||||
expect(compliance90.rate).toBeGreaterThan(80);
|
||||
expect(compliance90.rate).toBeLessThan(100);
|
||||
expect(compliance90.missed).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user