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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-26 00:05:21 +01:00
1 parent 9501d540d0
commit 5765059737
4 files changed
+107 -4

No files matched your search

+5
View File
@@ -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
+6 -3
View File
@@ -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
@@ -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,
});
}
@@ -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<string, string>,
): Promise<string[]> {
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<Array<{ id: string }>>(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"]);
});
});