mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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:
1 parent
9501d540d0
commit
5765059737
4 files changed
+107
-4
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user