From fcb517df87299427aad3e50e1b7b1d2f810d8226 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 1 Jun 2026 07:26:26 +0200 Subject: [PATCH] fix(medications): fan out the today-tile projector over every time-of-day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../dashboard/summary/__tests__/route.test.ts | 62 +++++++++++++++ .../analytics/__tests__/compliance.test.ts | 75 +++++++++++++++++++ .../scheduling/__tests__/cadence.test.ts | 46 ++++++++++++ .../scheduling/project-today-intakes.ts | 29 +++++-- 4 files changed, 206 insertions(+), 6 deletions(-) diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts index e6918e1df..ab2f3e0b4 100644 --- a/src/app/api/dashboard/summary/__tests__/route.test.ts +++ b/src/app/api/dashboard/summary/__tests__/route.test.ts @@ -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 diff --git a/src/lib/analytics/__tests__/compliance.test.ts b/src/lib/analytics/__tests__/compliance.test.ts index de02b92bc..9843df47a 100644 --- a/src/lib/analytics/__tests__/compliance.test.ts +++ b/src/lib/analytics/__tests__/compliance.test.ts @@ -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 diff --git a/src/lib/medications/scheduling/__tests__/cadence.test.ts b/src/lib/medications/scheduling/__tests__/cadence.test.ts index 425b3431a..f3a3eed3e 100644 --- a/src/lib/medications/scheduling/__tests__/cadence.test.ts +++ b/src/lib/medications/scheduling/__tests__/cadence.test.ts @@ -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", diff --git a/src/lib/medications/scheduling/project-today-intakes.ts b/src/lib/medications/scheduling/project-today-intakes.ts index d58d5cf23..6be6f93ae 100644 --- a/src/lib/medications/scheduling/project-today-intakes.ts +++ b/src/lib/medications/scheduling/project-today-intakes.ts @@ -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), + }); + } } }