fix(medications): fan out the today-tile projector over every time-of-day

The today-window projector minted a single pending intake row per
schedule, anchored at `windowStart`, dropping a schedule's first-class
`timesOfDay`. A twice-daily medication stored as one schedule row with
`timesOfDay = ["07:00","19:00"]` therefore surfaced only its morning
dose in the today tile and the "Erfassen" sheet, and the event-count
compliance rollup recorded one expected dose per day instead of two — a
perfectly adherent user read 50% the moment the second dose was logged,
and deleting then re-adding one dose left the rate stuck at 50%.

Fan the projection out over every `timesOfDay` entry, mirroring the
reminder worker's per-slot mint and dedup against the
`(userId, medicationId, scheduledFor, source)` unique index. Absent
first-class `timesOfDay` the projector still emits one slot at
`windowStart`, byte-stable against the worker's legacy single-window row.

Adds regression coverage across the projector (dashboard summary), the
legacy compliance walker, and the cadence expansion pinning the
single-row twice-daily case at the correct two-dose-per-day shape.
This commit is contained in:
Marc-André Bombeck committed 2026-06-01 07:26:26 +02:00
1 parent 869f13612a
commit fcb517df87
4 files changed
+206 -6

No files matched your search

@@ -623,6 +623,68 @@ describe("GET /api/dashboard/summary", () => {
]);
});
it("projects one row per timesOfDay for a twice-daily schedule", async () => {
// A single MedicationSchedule row carrying two first-class
// `timesOfDay` is two distinct dose slots per day. Pre-fix the
// projector minted only the `windowStart` slot, so the second daily
// dose never appeared in the today-tile and the event-count
// compliance rollup read half the expected doses (a 2×/day med
// showed 50%). Assert the backfill mints both slots.
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-21T03:00:00.000Z"));
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([
{
id: "med-twice-daily",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00Z"),
schedules: [
{
id: "sched-bid",
medicationId: "med-twice-daily",
windowStart: "07:00",
windowEnd: "07:30",
daysOfWeek: null,
timesOfDay: ["07:00", "19:00"],
reminderGraceMinutes: null,
rrule: null,
rollingIntervalDays: null,
},
],
},
] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockImplementation(((
args: unknown,
) => {
const a = args as { where: { OR?: unknown }; select?: { id?: boolean } };
if (a.where.OR) return Promise.resolve([]) as never;
if (!a.select?.id) return Promise.resolve([]) as never;
return Promise.resolve([]) as never;
}) as never);
vi.mocked(prisma.medicationIntakeEvent.createMany).mockResolvedValue({
count: 2,
} as never);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
expect(prisma.medicationIntakeEvent.createMany).toHaveBeenCalledTimes(1);
const args = vi.mocked(prisma.medicationIntakeEvent.createMany).mock
.calls[0][0] as {
data: Array<{ medicationId: string; scheduledFor: Date }>;
};
expect(args.data).toHaveLength(2);
const hours = args.data
.map((r) => new Date(r.scheduledFor).getUTCHours())
.sort((a, b) => a - b);
// 07:00 and 19:00 in the default zone (Europe/Berlin, UTC+2 in May)
// materialise to 05:00 and 17:00 UTC. The assertion that matters is
// two distinct slots, not one.
expect(hours).toEqual([5, 17]);
vi.useRealTimers();
});
it("is idempotent on a second pass (v1.4.39 W-SERVER-FIX-2)", async () => {
// When the existence probe already sees a row for the projected
// slot (e.g. the intake route or the reminder worker minted it
@@ -125,6 +125,81 @@ describe("calculateCompliance — cadence-aware adapter", () => {
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
@@ -43,6 +43,52 @@ describe("expandScheduleSlots", () => {
expect(slots).toHaveLength(5);
});
it("fans out a twice-daily schedule into one slot per timesOfDay entry", () => {
const sched: ScheduleLike = {
windowStart: "07:00",
windowEnd: "07:30",
daysOfWeek: null,
timesOfDay: ["07:00", "19:00"],
};
// Single UTC day: expect a 07:00 and a 19:00 slot, not just one.
const slots = expandScheduleSlots(
sched,
0,
d("2025-06-02T00:00:00Z"),
d("2025-06-03T00:00:00Z"),
d("2025-06-02T00:00:00Z"),
"UTC",
);
expect(slots).toHaveLength(2);
expect(slots.map((s) => s.windowStart.toISOString())).toEqual([
"2025-06-02T07:00:00.000Z",
"2025-06-02T19:00:00.000Z",
]);
// Each emitted window keeps the legacy windowEnd - windowStart span.
for (const s of slots) {
expect(s.windowEnd.getTime() - s.windowStart.getTime()).toBe(
30 * 60 * 1000,
);
}
});
it("emits one slot at windowStart when no first-class timesOfDay is set", () => {
// Byte-stable legacy path — the absence of timesOfDay must not
// change the pre-fix single-window behaviour the chart relied on.
const sched: ScheduleLike = {
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
};
const slots = expandScheduleSlots(
sched,
0,
d("2025-06-02T00:00:00"),
d("2025-06-03T00:00:00"),
);
expect(slots).toHaveLength(1);
});
it("respects daysOfWeek (Mon + Wed + Fri)", () => {
const sched: ScheduleLike = {
windowStart: "08:00",
@@ -127,12 +127,29 @@ export async function projectTodayIntakesAndRecompute(input: {
if (!scheduleEmitsInWindow(canonical, ctx, todayStart, todayEnd)) {
continue;
}
const [h, m] = schedule.windowStart.split(":").map(Number);
if (!Number.isFinite(h) || !Number.isFinite(m)) continue;
projected.push({
medicationId: schedule.medicationId,
scheduledFor: localHmAsUtc(now, userTz, h, m),
});
// Multi-time-of-day fan-out — mirror the reminder worker's
// per-slot mint. A schedule with `timesOfDay = ["07:00","19:00"]`
// is two distinct dose slots; projecting only `windowStart`
// minted a single pending row, so a twice-daily med's second
// dose never appeared in the today-tile and the event-count
// compliance rollup read half the expected doses (a 2×/day med
// showed 50% even when both doses were logged). Absent
// first-class `timesOfDay` the projector emits one slot at
// `windowStart`, byte-stable against the worker's legacy single-
// window row and the `(userId, medicationId, scheduledFor,
// source)` unique index.
const slotTimes =
schedule.timesOfDay && schedule.timesOfDay.length > 0
? schedule.timesOfDay
: [schedule.windowStart];
for (const slotTime of slotTimes) {
const [h, m] = slotTime.split(":").map(Number);
if (!Number.isFinite(h) || !Number.isFinite(m)) continue;
projected.push({
medicationId: schedule.medicationId,
scheduledFor: localHmAsUtc(now, userTz, h, m),
});
}
}
}