fix(tz): anchor medication-intake day floor at local midnight

The intake aggregator built its day floor from en-US calendar parts pinned
to UTC midnight, so today's window opened off by the zone offset for any
non-UTC user — a 23:30-local reading could land in the wrong day's bucket.

Promote the DST-safe start-of-local-day primitive into src/lib/tz/local-day
as startOfLocalDayInTz and route the intake route, the cadence start-of-day,
and the recurrence day floor through it, removing three divergent copies.
This commit is contained in:
Marc-André Bombeck committed 2026-06-18 18:25:03 +02:00
1 parent b474ea1489
commit 1b354de68b
5 files changed
+103 -29

No files matched your search

+2 -15
View File
@@ -34,7 +34,7 @@ import {
expectedSlotCountForDay,
lastNonSkippedTakenAt,
} from "@/lib/analytics/compliance";
import { getUserTodayBounds } from "@/lib/tz/local-day";
import { getUserTodayBounds, startOfLocalDayInTz } from "@/lib/tz/local-day";
import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes";
import {
applyCanonicalSlotWrite,
@@ -84,19 +84,6 @@ const updateSchema = z.object({
.optional(),
});
function startOfDayInTz(date: Date, tz: string): Date {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(date);
const y = parts.find((p) => p.type === "year")?.value ?? "1970";
const m = parts.find((p) => p.type === "month")?.value ?? "01";
const d = parts.find((p) => p.type === "day")?.value ?? "01";
return new Date(`${y}-${m}-${d}T00:00:00.000Z`);
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -138,7 +125,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
if (scope === "today") {
const todayStart = startOfDayInTz(new Date(), userTz);
const todayStart = startOfLocalDayInTz(new Date(), userTz);
const todayEnd = new Date(todayStart.getTime() + 86_400_000);
// v1.4.39 W-SERVER-FIX — project pending intake rows for every
+2 -2
View File
@@ -47,6 +47,7 @@ import {
} from "@/lib/medications/scheduling/recurrence";
import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { startOfLocalDayInTz } from "@/lib/tz/local-day";
/**
* v1.13.x compliance — opt-in retrospective-rolling expansion.
@@ -336,8 +337,7 @@ function hhmmToMinutes(hhmm: string): number | null {
/** Snap a Date down to the user-local midnight. */
export function startOfLocalDay(d: Date, tz: string | undefined): Date {
const parts = wallClockInTz(d, tz);
return instantInTz(parts.year, parts.month, parts.day, 0, 0, tz);
return startOfLocalDayInTz(d, tz);
}
/** Snap a Date down to the Sunday-rooted user-local week. */
+3 -12
View File
@@ -59,6 +59,7 @@ import { RRule } from "rrule";
import { annotate } from "@/lib/logging/context";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { startOfLocalDayInTz } from "@/lib/tz/local-day";
/**
* v1.7.0 — schedule-type discriminator on the canonical schedule.
@@ -303,7 +304,7 @@ export function nextOccurrenceAfter(
// keeps a slot the user has not yet acted on visible through the rest
// of its due day instead of silently rolling it forward by N.
if (schedule.rollingIntervalDays !== null) {
const dayFloor = startOfDayInTz(after, ctx.timeZone);
const dayFloor = startOfLocalDayInTz(after, ctx.timeZone);
// With no intake logged the first dose anchors at `startsOn ?? createdAt`.
// When that anchor is on a PRIOR calendar day the dose is overdue by ≥1
// day and must still surface ("take now"); flooring only to the start of
@@ -434,7 +435,7 @@ function expandOneShot(
*/
function firstRollingDoseDayFloor(ctx: RecurrenceContext): number {
const anchor = ctx.medication.startsOn ?? ctx.medication.createdAt;
return startOfDayInTz(anchor, ctx.timeZone).getTime();
return startOfLocalDayInTz(anchor, ctx.timeZone).getTime();
}
function expandRolling(
@@ -782,16 +783,6 @@ function applyTimeOfDayToDate(day: Date, hhmm: string, tz: string): Date {
return guess;
}
/**
* The UTC instant of midnight (00:00 wall-clock) on `instant`'s day in
* the user's IANA timezone. Used by the rolling-cadence next-due search
* to floor the window to the start of the user's current day so an
* overdue / due-earlier-today rolling dose still surfaces.
*/
function startOfDayInTz(instant: Date, tz: string): Date {
return applyTimeOfDayToDate(instant, "00:00", tz);
}
function tzOffsetMinutes(date: Date, tz: string): number {
const parts = wallClockInTz(date, tz);
const asIfUtc = Date.UTC(
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { startOfLocalDayInTz } from "@/lib/tz/local-day";
import { wallClockInTz } from "@/lib/tz/wall-clock";
describe("startOfLocalDayInTz", () => {
it("anchors at the user's local midnight, not UTC midnight", () => {
// 2026-06-15 22:00 UTC is already 2026-06-16 00:00 in Berlin (UTC+2),
// so the local day floor must land on the Berlin 2026-06-16 midnight
// instant (2026-06-15T22:00:00Z) — never 2026-06-16T00:00:00Z.
const instant = new Date("2026-06-15T23:30:00.000Z");
const floor = startOfLocalDayInTz(instant, "Europe/Berlin");
const parts = wallClockInTz(floor, "Europe/Berlin");
expect(parts.hour).toBe(0);
expect(parts.minute).toBe(0);
expect(parts.second).toBe(0);
expect(floor.toISOString()).toBe("2026-06-15T22:00:00.000Z");
});
it("handles negative offsets (Pacific/Auckland) — late-local reading stays on its day", () => {
// 2026-06-15 23:30 NZST (UTC+12) is 2026-06-15T11:30Z; the local day
// floor is Auckland 2026-06-15 midnight = 2026-06-14T12:00:00Z.
const instant = new Date("2026-06-15T11:30:00.000Z");
const floor = startOfLocalDayInTz(instant, "Pacific/Auckland");
const parts = wallClockInTz(floor, "Pacific/Auckland");
expect(parts.hour).toBe(0);
expect(parts.day).toBe(15);
});
it("is DST-safe across the spring-forward boundary", () => {
// Europe/Berlin springs forward 2026-03-29 02:00→03:00.
const instant = new Date("2026-03-29T10:00:00.000Z");
const floor = startOfLocalDayInTz(instant, "Europe/Berlin");
const parts = wallClockInTz(floor, "Europe/Berlin");
expect(parts.hour).toBe(0);
expect(parts.day).toBe(29);
});
it("falls back to host-local day when tz is undefined", () => {
const instant = new Date(2026, 5, 15, 14, 30, 0);
const floor = startOfLocalDayInTz(instant, undefined);
expect(floor.getHours()).toBe(0);
expect(floor.getDate()).toBe(15);
});
});
+52
View File
@@ -49,6 +49,58 @@ export function getUserTodayBounds(
return { start, end };
}
/**
* UTC instant of local midnight (00:00 wall-clock) on the calendar day
* `instant` falls on in `tz`. DST-safe: derives the zone offset at the
* target local time so the returned instant always represents the user's
* local start-of-day, never UTC midnight of that calendar date.
*
* Canonical start-of-local-day primitive. The medication-scheduling
* `startOfLocalDay` (cadence) and `startOfDayInTz` (recurrence) route
* through this so the day-floor never drifts between surfaces. When `tz`
* is omitted the host's system-local day is used.
*/
export function startOfLocalDayInTz(
instant: Date,
tz: string | undefined,
): Date {
if (!tz) {
return new Date(
instant.getFullYear(),
instant.getMonth(),
instant.getDate(),
0,
0,
0,
0,
);
}
const parts = wallClockInTz(instant, tz);
// Two-pass converge: treat the wall clock as UTC, then correct by the
// zone offset at that approximate instant. The second pass settles the
// offset across a DST transition for every IANA zone.
let guess = new Date(
Date.UTC(parts.year, parts.month - 1, parts.day, 0, 0, 0, 0),
);
for (let i = 0; i < 2; i++) {
const at = wallClockInTz(guess, tz);
const asIfUtc = Date.UTC(
at.year,
at.month - 1,
at.day,
at.hour,
at.minute,
at.second,
);
const offsetMin = Math.round((asIfUtc - guess.getTime()) / 60_000);
guess = new Date(
Date.UTC(parts.year, parts.month - 1, parts.day, 0, 0, 0, 0) -
offsetMin * 60_000,
);
}
return guess;
}
/**
* Get the day of the week (0=Sun..6=Sat) for a given instant in a specific
* timezone.