From d0b680c27786af6c9c51d70120ec6dc5c413cde6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 07:48:58 +0200 Subject: [PATCH] fix(dashboard): count the last hour of a 25-hour day in today's doses buildMedsTodayBlock ended the day at local midnight plus 24 hours. On the day the clocks go back that stops an hour early, so a dose between 23:00 and midnight was neither projected nor counted in the doses tally. The exclusive end is now the next local day's start, as the digest loader already computes it. --- .../dashboard/__tests__/meds-today.test.ts | 28 +++++++++++++++++++ src/lib/dashboard/meds-today.ts | 9 +++--- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/lib/dashboard/__tests__/meds-today.test.ts b/src/lib/dashboard/__tests__/meds-today.test.ts index 6beccb201..35f2864a0 100644 --- a/src/lib/dashboard/__tests__/meds-today.test.ts +++ b/src/lib/dashboard/__tests__/meds-today.test.ts @@ -181,6 +181,34 @@ describe("buildMedsTodayBlock — projection contract", () => { }); }); +describe("buildMedsTodayBlock — the 25-hour day", () => { + it("covers the last local hour on the day the clocks go back", async () => { + // 2026-10-25 in Berlin runs 25 hours: CEST midnight is 22:00Z the + // evening before, the next CET midnight 23:00Z. A dose at 23:30 local + // (22:30Z) lies inside the day and must be counted. + const dstNow = new Date("2026-10-25T20:00:00.000Z"); + await buildMedsTodayBlock(fakePrisma, "user-1", BERLIN, dstNow); + + const arg = projectTodayIntakesAndRecompute.mock.calls[0][0] as { + todayStart: Date; + todayEnd: Date; + }; + expect(arg.todayStart.toISOString()).toBe("2026-10-24T22:00:00.000Z"); + expect(arg.todayEnd.toISOString()).toBe("2026-10-25T23:00:00.000Z"); + + const where = capturedTodayWhere as { + scheduledFor: { gte: Date; lt: Date }; + }; + expect(where.scheduledFor.lt.toISOString()).toBe( + "2026-10-25T23:00:00.000Z", + ); + expect( + where.scheduledFor.lt.getTime() > + new Date("2026-10-25T22:30:00.000Z").getTime(), + ).toBe(true); + }); +}); + describe("buildMedsTodayBlock — tally", () => { it("counts scheduled / taken / skipped with the summary-tile semantics", async () => { todayRows = [ diff --git a/src/lib/dashboard/meds-today.ts b/src/lib/dashboard/meds-today.ts index 7cefb726d..017008912 100644 --- a/src/lib/dashboard/meds-today.ts +++ b/src/lib/dashboard/meds-today.ts @@ -104,10 +104,11 @@ export async function buildMedsTodayBlock( ): Promise { const { start: todayStart, end: todayEnd } = getUserTodayBounds(now, userTz); // The projector + tally read use an exclusive upper bound (`lt`), - // matching the summary route's window convention. - const todayEndExclusive = new Date( - todayStart.getTime() + 24 * 60 * 60 * 1000, - ); + // matching the summary route's window convention. `todayEnd` is the + // inclusive last millisecond of the real local day, so +1 ms is the next + // day's start: 23 or 25 hours on a DST day, where a fixed 24 h missed the + // last hour of the 25-hour day. + const todayEndExclusive = new Date(todayEnd.getTime() + 1); // Projection FIRST so the tally read below sees the freshly minted // pending rows. Idempotent — `skipDuplicates` + the