mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
b474ea1489
commit
1b354de68b
5 files changed
+103
-29
No files matched your search
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user