From 576505973769ed8bae0f4f48ed035d10e0e66b6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 26 Oct 2026 00:05:21 +0100 Subject: [PATCH] fix(medications): end today's intake list at the next local midnight The list ended 24 hours after local midnight. On the 25-hour day of an autumn DST change that left the last hour out, so a dose due then could not be offered from it; on the 23-hour spring day it reached into the next day. Take the day's bounds from getUserTodayBounds, as the dashboard tile does. Anchor the health-status test's history to local noon: now minus whole days is still today on the long day. --- CHANGELOG.md | 5 ++ src/lib/medications/intake-today.ts | 9 +- .../health-status-implausible-values.test.ts | 8 +- .../medication-intake-today-dst.test.ts | 89 +++++++++++++++++++ 4 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 tests/integration/medication-intake-today-dst.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6265aa2ce..1096efb40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -278,6 +278,11 @@ way of creating a folder with its parents loops forever there on Linux, so a `DB_BACKUP_DIR` or `GEOLITE2_DIR` pointing below it stopped the process. Such a directory now reads as not writable. +- **The last hour of a long DST day keeps its doses.** On the day the + clocks go back, the list of today's doses that the apps offer from ended + an hour early, so a late evening dose was missing from it; on the day + they go forward it reached an hour into tomorrow. It now spans the real + local day, as the dashboard already did. ### Security diff --git a/src/lib/medications/intake-today.ts b/src/lib/medications/intake-today.ts index fe242840e..f14bef72b 100644 --- a/src/lib/medications/intake-today.ts +++ b/src/lib/medications/intake-today.ts @@ -7,7 +7,7 @@ */ import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; -import { startOfLocalDayInTz } from "@/lib/tz/local-day"; +import { getUserTodayBounds } from "@/lib/tz/local-day"; import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes"; import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking"; @@ -16,8 +16,11 @@ export async function readTodayIntakes(input: { userTz: string; }) { const { userId, userTz } = input; - const todayStart = startOfLocalDayInTz(new Date(), userTz); - const todayEnd = new Date(todayStart.getTime() + 86_400_000); + // The real local day, 23 or 25 hours on a DST day: a fixed 24 h left the + // last hour of the 25-hour day off the list, so a dose at 23:30 that night + // could not be offered. Same window as the dashboard's meds tile. + const { start: todayStart, end } = getUserTodayBounds(new Date(), userTz); + const todayEnd = new Date(end.getTime() + 1); // Project pending intake rows for every active schedule whose window opens // today, then idempotently backfill any missing rows. Without it the diff --git a/tests/integration/health-status-implausible-values.test.ts b/tests/integration/health-status-implausible-values.test.ts index fdf4d45e6..282ed03c2 100644 --- a/tests/integration/health-status-implausible-values.test.ts +++ b/tests/integration/health-status-implausible-values.test.ts @@ -12,6 +12,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; +import { startOfLocalDayInTz } from "@/lib/tz/local-day"; + import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; @@ -73,6 +75,10 @@ async function seedDaily( type: "PULSE" | "WEIGHT", valueFor: (dayIndex: number) => number, ) { + // Noon of each earlier local day: `now - d days` is still today on the + // 25-hour day of an autumn DST change, and the card then reads yesterday's + // value as one of today's. + const todayStart = startOfLocalDayInTz(new Date(), "Europe/Berlin").getTime(); const rows = []; for (let d = 14; d >= 1; d -= 1) { rows.push({ @@ -80,7 +86,7 @@ async function seedDaily( type, value: valueFor(d), unit: type === "PULSE" ? "bpm" : "kg", - measuredAt: new Date(Date.now() - d * DAY_MS), + measuredAt: new Date(todayStart - d * DAY_MS + DAY_MS / 2), source: "MANUAL" as const, }); } diff --git a/tests/integration/medication-intake-today-dst.test.ts b/tests/integration/medication-intake-today-dst.test.ts new file mode 100644 index 000000000..f6d833b1f --- /dev/null +++ b/tests/integration/medication-intake-today-dst.test.ts @@ -0,0 +1,89 @@ +/** + * Today's intake list spans the real local day on a DST change. + * + * Europe/Berlin's autumn change day lasts 25 hours, the spring one 23. The + * list used to end 24 hours after local midnight, so on the long day a dose + * at 23:30 was missing from it (nothing could be offered from the list in + * that last hour), and on the short day it reached an hour into tomorrow. + */ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; + +import { GET as listIntakes } from "@/app/api/medications/intake/route"; + +import { callRoute } from "../kit/call-route"; +import { makeSession, makeUser } from "../kit/factories"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const prisma = getPrismaClient(); + +beforeAll(async () => { + await truncateAllTables(prisma); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +afterAll(async () => { + await truncateAllTables(prisma); +}); + +async function todayIds( + now: string, + doses: Record, +): Promise { + const user = await makeUser({ timezone: "Europe/Berlin" }); + const auth = await makeSession(user); + for (const [id, scheduledFor] of Object.entries(doses)) { + await prisma.medication.create({ + data: { id: `${id}-med`, userId: user.id, name: id, dose: "1" }, + }); + await prisma.medicationIntakeEvent.create({ + data: { + id, + userId: user.id, + medicationId: `${id}-med`, + scheduledFor: new Date(scheduledFor), + }, + }); + } + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date(now)); + const res = await callRoute>(listIntakes, { + method: "GET", + route: "/api/medications/intake", + query: { scope: "today" }, + auth, + }); + expect(res.status).toBe(200); + return res.data.map((e) => e.id).sort(); +} + +describe("GET /api/medications/intake?scope=today across a DST change", () => { + it("keeps the last hour of the 25-hour autumn day", async () => { + // 2026-10-25 in Berlin runs from 22:00 UTC on the 24th to 23:00 UTC. + const ids = await todayIds("2026-10-25T22:40:00.000Z", { + "dst-autumn-morning": "2026-10-25T07:00:00.000Z", // 08:00 CET + "dst-autumn-late": "2026-10-25T22:30:00.000Z", // 23:30 CET + "dst-autumn-tomorrow": "2026-10-25T23:30:00.000Z", // 00:30 on the 26th + }); + expect(ids).toEqual(["dst-autumn-late", "dst-autumn-morning"]); + }); + + it("stops at midnight on the 23-hour spring day", async () => { + // 2026-03-29 in Berlin runs from 23:00 UTC on the 28th to 22:00 UTC. + const ids = await todayIds("2026-03-29T21:40:00.000Z", { + "dst-spring-late": "2026-03-29T21:30:00.000Z", // 23:30 CEST + "dst-spring-tomorrow": "2026-03-29T22:30:00.000Z", // 00:30 on the 30th + }); + expect(ids).toEqual(["dst-spring-late"]); + }); +});