mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
These tests passed in Europe/Berlin but failed under UTC (or against the shipped engine) because they leant on the host clock: - intake route test: the mocked user had no timezone, so slot resolution fell back to the host TZ and the 07:00 schedule slot only matched in Berlin. Pin the user to Europe/Berlin. - health-score: 30 daily takes seeded at an unaligned now-i*DAY shifted their Berlin hour with the runner TZ, dropping them out of the 08:00-10:00 on-time band. Anchor each take to a fixed 07:30 UTC. - sleep-stages: the night sat on the trailing-30-day window edge for a fixed calendar instant. Anchor it to now-5d. - weekly-compliance: not a timezone issue. The dose-window engine treats a weekly dose inside the 4-day overdue tail as still-takeable, not missed, so skipping the most-recent Monday no longer lowers the rate. Skip an older, settled-miss Monday instead; the gap assertion stands.
234 lines
8.2 KiB
TypeScript
234 lines
8.2 KiB
TypeScript
/**
|
||
* 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",
|
||
});
|
||
// Take the most-recent Monday on time, then skip ONE older Monday
|
||
// (`lastMonday − 7d`, which sits 9 days before the pinned now — past
|
||
// the weekly 4-day overdue tail, so it counts as definitively missed
|
||
// rather than still-takeable) and take the 11 Mondays before it. The
|
||
// gap is a real, settled miss, not a head-of-window overdue slot.
|
||
await logWeeklyIntakes(id, lastMonday, 1);
|
||
await logWeeklyIntakes(
|
||
id,
|
||
new Date(lastMonday.getTime() - 14 * DAY_MS),
|
||
11,
|
||
);
|
||
|
||
const { compliance90 } = await readCompliance(id);
|
||
// 12 taken 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);
|
||
});
|
||
});
|