mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user