diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index b62d4319f..ccc8742d6 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -34,8 +34,8 @@ import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority"; import { canonicalDailyTimestamp, dayKeyForUserTz, - localDayWindow, } from "@/lib/measurements/drain-per-sample-cumulative"; +import { localDayWindow } from "@/lib/tz/local-day"; import { withIdempotency } from "@/lib/idempotency"; import { isP2002 } from "@/lib/prisma-errors"; import { encryptNote, shapeMeasurementNotes } from "@/lib/crypto/note-cipher"; diff --git a/src/lib/analytics/__tests__/intraday-pulse-io.test.ts b/src/lib/analytics/__tests__/intraday-pulse-io.test.ts index 57450a5b4..b1994537f 100644 --- a/src/lib/analytics/__tests__/intraday-pulse-io.test.ts +++ b/src/lib/analytics/__tests__/intraday-pulse-io.test.ts @@ -32,7 +32,7 @@ import { import { toBerlinDayKey } from "@/lib/tz/resolver"; import { userDayKey } from "@/lib/tz/format"; import { percentile } from "@/lib/insights/strain-score"; -import { localDayWindow } from "@/lib/measurements/consolidation-tz"; +import { localDayWindow } from "@/lib/tz/local-day"; /** Mirrors the private `median()` helper in `intraday-pulse-io.ts`. */ function median(values: number[]): number | null { diff --git a/src/lib/analytics/intraday-pulse-io.ts b/src/lib/analytics/intraday-pulse-io.ts index ec7987b34..d271aeebc 100644 --- a/src/lib/analytics/intraday-pulse-io.ts +++ b/src/lib/analytics/intraday-pulse-io.ts @@ -19,7 +19,7 @@ import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { percentile } from "@/lib/insights/strain-score"; import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse"; -import { localDayWindow } from "@/lib/measurements/consolidation-tz"; +import { localDayWindow } from "@/lib/tz/local-day"; import { userDayKey } from "@/lib/tz/format"; import { BUCKET_MINUTES, diff --git a/src/lib/dashboard/batch-chart-types.ts b/src/lib/dashboard/batch-chart-types.ts index 2267a51db..102d94240 100644 --- a/src/lib/dashboard/batch-chart-types.ts +++ b/src/lib/dashboard/batch-chart-types.ts @@ -25,16 +25,12 @@ * prefetch-off / legacy / fail-soft paths where no prop arrives. * * Import-safe from the server RSC and the client component alike: pure, no - * Prisma / db / `node:*` imports (only the client-safe `@/lib/tz/format` - * Intl helpers and the layout type). + * Prisma / db / `node:*` imports (only the client-safe `@/lib/tz` Intl + * helpers and the layout type). */ import type { DashboardLayout } from "@/lib/dashboard-layout"; -import { - DEFAULT_TIMEZONE, - isValidTimezone, - tzOffsetMinutes, - userDayKey, -} from "@/lib/tz/format"; +import { DEFAULT_TIMEZONE, isValidTimezone, userDayKey } from "@/lib/tz/format"; +import { localDayWindow } from "@/lib/tz/local-day"; /** * Day-span the batched dashboard series (`series-batch`) fetches. Threaded to @@ -97,21 +93,13 @@ export interface BatchWindow { } /** - * End-of-day of `now` in `timezone`, as a UTC instant. Falls back to the - * project default zone for an unusable IANA id. Two-step offset refine so the - * rare DST edge (a transition between the as-if-UTC guess and the true local - * instant) still resolves to the right offset. + * Last millisecond of the local day `now` falls on in `timezone`, as a UTC + * instant. Falls back to the project default zone for an unusable IANA id. */ function endOfLocalDayUtc(now: Date, timezone: string): Date { const safeTz = isValidTimezone(timezone) ? timezone : DEFAULT_TIMEZONE; - const dayKey = userDayKey(now, safeTz); // YYYY-MM-DD in the zone - const [year, month, day] = dayKey.split("-").map(Number); - // Wall-clock 23:59:59.999 treated as-if-UTC, then shifted by the zone offset. - const asIfUtc = Date.UTC(year, month - 1, day, 23, 59, 59, 999); - const offset1 = tzOffsetMinutes(new Date(asIfUtc), safeTz); - const corrected = new Date(asIfUtc - offset1 * 60_000); - const offset2 = tzOffsetMinutes(corrected, safeTz); - return new Date(asIfUtc - offset2 * 60_000); + const { dayEnd } = localDayWindow(userDayKey(now, safeTz), safeTz); + return new Date(dayEnd.getTime() - 1); } /** diff --git a/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts b/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts index 39da25dc6..2d8962534 100644 --- a/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts +++ b/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts @@ -14,10 +14,12 @@ import { canonicalDailyTimestamp, dayKeyForUserTz, drainPerSampleCumulative, - localDayWindow, - localStartOfDay, sumBucketValues, } from "../drain-per-sample-cumulative"; +import { + localDayWindow, + startOfLocalDayKey as localStartOfDay, +} from "@/lib/tz/local-day"; import type { PrismaClient } from "@/generated/prisma/client"; describe("dayKeyForUserTz", () => { diff --git a/src/lib/measurements/consolidation-base.ts b/src/lib/measurements/consolidation-base.ts index 382f2335e..43f9d576d 100644 --- a/src/lib/measurements/consolidation-base.ts +++ b/src/lib/measurements/consolidation-base.ts @@ -19,7 +19,7 @@ * supplies its reducer/types and keeps its own public summary shape. * * The timezone day-math helpers (`dayKeyForUserTz`, - * `canonicalDailyTimestamp`, `localStartOfDay`, `localDayWindow`), the + * `canonicalDailyTimestamp`), the * `PerSampleRow` shape, and `CONSOLIDATION_GRACE_CUTOFF_HOURS` all live * in the dependency-free leaf `consolidation-tz.ts`. Both this module * and `drain-per-sample-cumulative.ts` import them from there, so there diff --git a/src/lib/measurements/consolidation-tz.ts b/src/lib/measurements/consolidation-tz.ts index b387505bd..de34efc92 100644 --- a/src/lib/measurements/consolidation-tz.ts +++ b/src/lib/measurements/consolidation-tz.ts @@ -8,8 +8,11 @@ * either of those — it is a dependency-free leaf so the two can both * reach the helpers without forming an import cycle. * - * History: `dayKeyForUserTz`, `canonicalDailyTimestamp`, `localStartOfDay`, - * `localDayWindow`, the grace constant, and the `PerSampleRow` shape used + * Local day bounds (`localDayWindow`, `startOfLocalDayKey`) live in + * `@/lib/tz/local-day`, the one start-of-day implementation. + * + * History: `dayKeyForUserTz`, `canonicalDailyTimestamp`, the grace + * constant, and the `PerSampleRow` shape used * to live in `drain-per-sample-cumulative.ts`. `consolidation-base.ts` * imported them from there while `drain` imported the runner back — a * value-level cycle. Bundled for production (Turbopack merges tightly @@ -25,6 +28,7 @@ import type { MeasurementType } from "@/generated/prisma/client"; // `userDayKey` lives in the pure `@/lib/tz/format` leaf (no Prisma / // node:module pull) so importing it keeps this dependency-free module clean. import { userDayKey } from "@/lib/tz/format"; +import { zonedWallClockToUtc } from "@/lib/tz/wall-clock"; /** * Canonical grace window shared by the cumulative + mean drains. Rows @@ -63,30 +67,6 @@ export function dayKeyForUserTz(date: Date, tz: string): string { return userDayKey(date, tz); } -/** - * Read the IANA-zone offset (in minutes east of UTC) at a given - * instant. Returns 0 for UTC and any zone the shortOffset formatter - * can't resolve (defensive — Node 22's full-icu build covers every - * zone we care about). - */ -function tzOffsetMinutesAt(instant: Date, tz: string): number { - const fmt = new Intl.DateTimeFormat("en-US", { - timeZone: tz, - hour12: false, - hour: "2-digit", - minute: "2-digit", - timeZoneName: "shortOffset", - }); - const parts = fmt.formatToParts(instant); - const tzPart = parts.find((p) => p.type === "timeZoneName")?.value ?? "GMT"; - const match = tzPart.match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/); - if (!match) return 0; - const sign = match[1] === "+" ? 1 : -1; - const hours = Number.parseInt(match[2], 10); - const minutes = match[3] ? Number.parseInt(match[3], 10) : 0; - return sign * (hours * 60 + minutes); -} - /** * Compute the canonical timestamp for a calendar-day key. With a `tz`, * returns the JS-Date instant at the user's local 12:00 noon; without @@ -106,19 +86,11 @@ function tzOffsetMinutesAt(instant: Date, tz: string): number { * correct for every zone within ±12 h. */ export function canonicalDailyTimestamp(dateKey: string, tz?: string): Date { - const utcNoon = new Date(`${dateKey}T12:00:00.000Z`); // No timezone in scope: 12:00 UTC is a safe day-stable anchor for // every zone within ±12 h. - if (!tz) return utcNoon; - // Compute the UTC offset for noon-local of the given day. We don't - // have a lightweight TZ-math library on the server, so the trick is: - // build "12:00 UTC of the day", read what wall-clock that shows in - // the target zone, then shift by the resulting offset. - const offsetMinutes = tzOffsetMinutesAt(utcNoon, tz); - // utcNoon represents 12:00 UTC. The user's local clock reads - // 12:00 + offsetMinutes at that instant. To anchor at local 12:00, - // subtract the offset. - return new Date(utcNoon.getTime() - offsetMinutes * 60 * 1000); + if (!tz) return new Date(`${dateKey}T12:00:00.000Z`); + const [year, month, day] = dateKey.split("-").map(Number); + return zonedWallClockToUtc({ year, month, day, hour: 12, minute: 0 }, tz); } /** @@ -178,64 +150,6 @@ export function canonicalHourlyTimestamp( hour: number, tz: string, ): Date { - const hh = String(hour).padStart(2, "0"); - const guess = new Date(`${dateKey}T${hh}:30:00.000Z`); - const offsetFirst = tzOffsetMinutesAt(guess, tz); - const candidate = new Date(guess.getTime() - offsetFirst * 60 * 1000); - const offsetAtCandidate = tzOffsetMinutesAt(candidate, tz); - if (offsetAtCandidate === offsetFirst) return candidate; - return new Date(guess.getTime() - offsetAtCandidate * 60 * 1000); -} - -/** - * v1.4.37 W10 — Compute the JS-Date instant at the user's local 00:00 - * for a calendar-day key. Robust on DST transitions because the offset - * is read at the UTC-midnight instant of the day, and EU/US DST - * transitions happen at local 02:00 / 03:00 — so the offset at UTC - * midnight is unambiguous on every day of the year. - * - * Used by the W7c drill-down branch to resolve [dayStart, dayEnd) on - * a 23-h spring-forward or 25-h fall-back day; the previous shape - * (`canonicalDailyTimestamp ± 12h`) silently leaked or hid an hour - * of samples on two days per year. - * - * Pair with `localStartOfDay(nextDayKey, tz)` for the right bound so - * the returned window covers the true local-day span — 23 h on - * spring-forward, 24 h on a regular day, 25 h on fall-back. - */ -export function localStartOfDay(dateKey: string, tz: string): Date { - // Anchor at 00:00 UTC of the day; the offset read at that instant - // is the same offset the local clock uses at midnight (DST - // transitions in EU/US happen at 02:00 / 03:00 local, not at the - // 00:00 boundary). For sub-half-hour zones (Asia/Kathmandu UTC+5:45, - // Pacific/Chatham UTC+12:45) the minute component is preserved. - const utcMidnight = new Date(`${dateKey}T00:00:00.000Z`); - const offsetMinutes = tzOffsetMinutesAt(utcMidnight, tz); - // Local 00:00 at this date = UTC midnight - offset. - return new Date(utcMidnight.getTime() - offsetMinutes * 60 * 1000); -} - -/** - * v1.4.37 W10 — Resolve the [dayStart, dayEnd) UTC window for a - * calendar-day key in the user's IANA timezone. The window honours - * DST so the drill-down branch returns the correct 23 / 24 / 25-hour - * span for transition days. - * - * Returns a tuple where `dayEnd` is the local 00:00 of the FOLLOWING - * calendar day — `< dayEnd` is the canonical half-open bound used by - * the route's `measuredAt: { gte: dayStart, lt: dayEnd }` predicate. - */ -export function localDayWindow( - dateKey: string, - tz: string, -): { dayStart: Date; dayEnd: Date } { - const dayStart = localStartOfDay(dateKey, tz); - // Add ONE day to dateKey using UTC arithmetic on a noon anchor (noon - // sidesteps every DST edge case for the calendar increment). Then - // re-extract the ISO date slice — guaranteed to be the next-day key. - const nextUtcNoon = new Date(`${dateKey}T12:00:00.000Z`); - nextUtcNoon.setUTCDate(nextUtcNoon.getUTCDate() + 1); - const nextDateKey = nextUtcNoon.toISOString().slice(0, 10); - const dayEnd = localStartOfDay(nextDateKey, tz); - return { dayStart, dayEnd }; + const [year, month, day] = dateKey.split("-").map(Number); + return zonedWallClockToUtc({ year, month, day, hour, minute: 30 }, tz); } diff --git a/src/lib/measurements/dense-intraday-hourly-rebuild.ts b/src/lib/measurements/dense-intraday-hourly-rebuild.ts index a5cbb7d78..0c49f4c61 100644 --- a/src/lib/measurements/dense-intraday-hourly-rebuild.ts +++ b/src/lib/measurements/dense-intraday-hourly-rebuild.ts @@ -66,9 +66,9 @@ import { import { canonicalDailyTimestamp, canonicalHourlyTimestamp, - localDayWindow, type PerSampleRow, } from "./consolidation-tz"; +import { localDayWindow } from "@/lib/tz/local-day"; import { meanBucketValue } from "./consolidate-daily-mean"; import { DENSE_INTRADAY_RETENTION_DAYS, diff --git a/src/lib/measurements/drain-per-sample-cumulative.ts b/src/lib/measurements/drain-per-sample-cumulative.ts index e0cf58135..e22fb5f5a 100644 --- a/src/lib/measurements/drain-per-sample-cumulative.ts +++ b/src/lib/measurements/drain-per-sample-cumulative.ts @@ -57,8 +57,6 @@ import { CONSOLIDATION_GRACE_CUTOFF_HOURS, canonicalDailyTimestamp, dayKeyForUserTz, - localDayWindow, - localStartOfDay, type PerSampleRow, } from "./consolidation-tz"; import { @@ -73,12 +71,7 @@ import { // keeps resolving them from here. The definitions moved to the // dependency-free leaf `consolidation-tz.ts` to break the init-order // cycle with `consolidation-base.ts`. -export { - canonicalDailyTimestamp, - dayKeyForUserTz, - localDayWindow, - localStartOfDay, -}; +export { canonicalDailyTimestamp, dayKeyForUserTz }; export type { PerSampleRow }; /** Prefix marking an already-collapsed daily-stats row. */ diff --git a/src/lib/measurements/intraday-cumulative-profile.ts b/src/lib/measurements/intraday-cumulative-profile.ts index bb0891bea..6d527017a 100644 --- a/src/lib/measurements/intraday-cumulative-profile.ts +++ b/src/lib/measurements/intraday-cumulative-profile.ts @@ -42,9 +42,9 @@ import { shiftDateKey, userDayKey } from "@/lib/tz/format"; import { dayKeyForUserTz, hourOfDayForUserTz, - localDayWindow, type PerSampleRow, } from "./consolidation-tz"; +import { localDayWindow } from "@/lib/tz/local-day"; /** Hours in a stored profile. Always 24, DST days included — see below. */ export const PROFILE_HOURS = 24; diff --git a/src/lib/rollups/medication-compliance-rollups.ts b/src/lib/rollups/medication-compliance-rollups.ts index 77cbb0822..3fd256272 100644 --- a/src/lib/rollups/medication-compliance-rollups.ts +++ b/src/lib/rollups/medication-compliance-rollups.ts @@ -52,7 +52,7 @@ import { prisma } from "@/lib/db"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { annotate } from "@/lib/logging/context"; import { DEFAULT_TIMEZONE, isValidTimezone, userDayKey } from "@/lib/tz/format"; -import { wallClockInTz } from "@/lib/tz/wall-clock"; +import { localDayWindow, startOfLocalDayKey } from "@/lib/tz/local-day"; /** * pg-boss queue name for the boot-time medication compliance backfill. @@ -84,55 +84,6 @@ export interface ComplianceBucket { taken: number; } -/** - * UTC offset (in minutes) of `tz` at the given instant. Positive east - * of UTC. Honours DST via `Intl.DateTimeFormat`. - */ -function tzOffsetMinutes(date: Date, tz: string): number { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone: tz, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - }).formatToParts(date); - const get = (type: Intl.DateTimeFormatPartTypes): string => - parts.find((p) => p.type === type)?.value ?? "0"; - let hour = Number(get("hour")); - if (hour === 24) hour = 0; - const asIfUtc = Date.UTC( - Number(get("year")), - Number(get("month")) - 1, - Number(get("day")), - hour, - Number(get("minute")), - Number(get("second")), - ); - return Math.round((asIfUtc - date.getTime()) / 60000); -} - -/** - * The UTC instant corresponding to local-midnight on `dayKey` in `tz`. - * Two-pass convergence handles DST transitions correctly. - */ -function startOfDayUtcInTz(dayKey: string, tz: string): Date { - const [yearStr, monthStr, dayStr] = dayKey.split("-"); - const year = Number(yearStr); - const month = Number(monthStr); - const day = Number(dayStr); - let guess = new Date(Date.UTC(year, month - 1, day, 0, 0, 0, 0)); - for (let i = 0; i < 2; i++) { - const offsetMin = tzOffsetMinutes(guess, tz); - guess = new Date( - Date.UTC(year, month - 1, day, 0, 0, 0, 0) - offsetMin * 60_000, - ); - } - return guess; -} - /** * Resolve a safe tz string — fall back to the server default when the * supplied value is missing or not an IANA zone the host recognises. @@ -176,20 +127,8 @@ export async function recomputeMedicationComplianceForDay( client: MedicationComplianceRollupClient = prisma, ): Promise { const safeTz = safeTimezone(tz); - const start = startOfDayUtcInTz(dayKey, safeTz); - const end = new Date(start.getTime() + 86_400_000); - // Re-derive end via the day-after boundary to honour DST 23 / 25-hour - // days. `start + 86_400_000` is correct for non-transition days; on - // a fall-back day we add one calendar day in the local zone. - const nextDayKey = (() => { - const [y, m, d] = dayKey.split("-").map(Number); - const probe = new Date(Date.UTC(y, m - 1, d, 12, 0, 0)); - const probeNext = new Date(probe.getTime() + 86_400_000); - const parts = wallClockInTz(probeNext, safeTz); - return `${String(parts.year).padStart(4, "0")}-${String(parts.month).padStart(2, "0")}-${String(parts.day).padStart(2, "0")}`; - })(); - const dstSafeEnd = startOfDayUtcInTz(nextDayKey, safeTz); - const windowEnd = dstSafeEnd.getTime() > start.getTime() ? dstSafeEnd : end; + // The local day's own bounds: 23, 24 or 25 hours on DST days. + const { dayStart: start, dayEnd: windowEnd } = localDayWindow(dayKey, safeTz); // v1.4.39 hotfix (QA F-H-02): atomic upsert closes the race window // between the prior `SELECT … aggregate then UPSERT` pattern. Two @@ -397,7 +336,7 @@ export async function hasMedicationComplianceCoverage( new Date(now.getTime() - (days - 1) * 86_400_000), safeTz, ); - const oldestStart = startOfDayUtcInTz(oldestKey, safeTz); + const oldestStart = startOfLocalDayKey(oldestKey, safeTz); // Single SQL aggregate: count DISTINCT rolled days vs DISTINCT // event-days in window. The event side is anchored on the same @@ -456,7 +395,7 @@ export async function recomputeUserMedicationCompliance( new Date(now.getTime() - (days - 1) * 86_400_000), safeTz, ); - const oldestStart = startOfDayUtcInTz(oldestKey, safeTz); + const oldestStart = startOfLocalDayKey(oldestKey, safeTz); // Distinct (medication_id, dayKey) pairs the user has any intake // event for inside the trailing window. `to_char(... AT TIME ZONE diff --git a/src/lib/tz/__tests__/local-day.test.ts b/src/lib/tz/__tests__/local-day.test.ts index 3041677c7..49f28e7c0 100644 --- a/src/lib/tz/__tests__/local-day.test.ts +++ b/src/lib/tz/__tests__/local-day.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it } from "vitest"; import { getUserTodayBounds, + localDayWindow, localHmAsUtc, startOfLocalDayInTz, + startOfLocalDayKey, } from "@/lib/tz/local-day"; +import { shiftDateKey, userDayKey } from "@/lib/tz/format"; import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock"; const HOUR_MS = 60 * 60 * 1000; @@ -246,3 +249,86 @@ describe("localHmAsUtc — DST transition days", () => { expect(localHm(a, sydney)).toBe("20:00"); }); }); + +describe("startOfLocalDayKey / localDayWindow — every day of the year", () => { + const ZONES = [ + "America/New_York", + "America/Santiago", + "America/Havana", + "Pacific/Auckland", + "Pacific/Tongatapu", + "Australia/Sydney", + "Europe/Berlin", + "Asia/Kathmandu", + "UTC", + ]; + + // The first instant of the day reads the day's key, and the millisecond + // before it reads the previous key: it is exactly where the day begins. + for (const tz of ZONES) { + it(`starts each 2026 day at its first local instant in ${tz}`, () => { + let key = "2026-01-01"; + for (let i = 0; i < 365; i++) { + const { dayStart, dayEnd } = localDayWindow(key, tz); + expect(userDayKey(dayStart, tz), `${tz} ${key}`).toBe(key); + expect( + userDayKey(new Date(dayStart.getTime() - 1), tz), + `${tz} ${key}`, + ).toBe(shiftDateKey(key, -1)); + const hours = (dayEnd.getTime() - dayStart.getTime()) / 3_600_000; + expect(hours).toBeGreaterThanOrEqual(23); + expect(hours).toBeLessThanOrEqual(25); + expect(startOfLocalDayKey(key, tz).getTime()).toBe(dayStart.getTime()); + key = shiftDateKey(key, 1); + } + }); + } + + it.each([ + // Midnight is skipped: the day begins at 01:00 local. + ["America/Santiago", "2026-09-06", "2026-09-06T04:00:00.000Z", 23], + ["America/Havana", "2026-03-08", "2026-03-08T05:00:00.000Z", 23], + // Far from UTC, the offset at UTC midnight is not the offset at local + // midnight on a transition day. + ["Pacific/Auckland", "2026-04-05", "2026-04-04T11:00:00.000Z", 25], + ["Pacific/Auckland", "2026-09-27", "2026-09-26T12:00:00.000Z", 23], + ["Australia/Sydney", "2026-04-05", "2026-04-04T13:00:00.000Z", 25], + ["America/New_York", "2026-11-01", "2026-11-01T04:00:00.000Z", 25], + ["Europe/Berlin", "2026-03-29", "2026-03-28T23:00:00.000Z", 23], + ["Pacific/Tongatapu", "2026-07-01", "2026-06-30T11:00:00.000Z", 24], + ])("%s %s starts at %s and lasts %i h", (tz, key, start, hours) => { + const { dayStart, dayEnd } = localDayWindow(key, tz); + expect(dayStart.toISOString()).toBe(start); + expect((dayEnd.getTime() - dayStart.getTime()) / 3_600_000).toBe(hours); + expect( + startOfLocalDayInTz(new Date(dayStart.getTime() + 3_600_000), tz), + ).toEqual(dayStart); + }); +}); + +describe("zonedWallClockToUtc — skipped wall clocks", () => { + it("reads a skipped midnight as the moment the clocks jump", () => { + expect( + zonedWallClockToUtc( + { year: 2026, month: 9, day: 6, hour: 0, minute: 0 }, + "America/Santiago", + ).toISOString(), + ).toBe("2026-09-06T04:00:00.000Z"); + }); + + it("reads 02:30 on the Berlin spring-forward day as 03:30 summer time", () => { + expect( + zonedWallClockToUtc( + { year: 2026, month: 3, day: 29, hour: 2, minute: 30 }, + "Europe/Berlin", + ).toISOString(), + ).toBe("2026-03-29T01:30:00.000Z"); + }); + + it("agrees with the day start at 00:00 where midnight is skipped", () => { + const ref = new Date("2026-09-06T15:00:00.000Z"); + expect(localHmAsUtc(ref, "America/Santiago", 0, 0)).toEqual( + startOfLocalDayInTz(ref, "America/Santiago"), + ); + }); +}); diff --git a/src/lib/tz/local-day.ts b/src/lib/tz/local-day.ts index 09e778842..ff8dbc79a 100644 --- a/src/lib/tz/local-day.ts +++ b/src/lib/tz/local-day.ts @@ -8,6 +8,7 @@ * user's local calendar day — today's bounds for database queries, * the local weekday, and the UTC instant of a local `hh:mm`. */ +import { shiftDateKey } from "./format"; import { wallClockInTz, zonedWallClockToUtc } from "./wall-clock"; /** @@ -42,15 +43,94 @@ export function getUserTodayBounds( } /** - * 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. + * The first instant of the local calendar day `year-month-day` in `tz`. * - * 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. + * Usually local 00:00. Where midnight itself is skipped by a DST change + * (America/Santiago, America/Havana, Asia/Beirut move their clocks at + * 00:00), the day starts at the transition instant, 01:00 local. Where + * midnight repeats (a change at 01:00 back to 00:00), it is the first of the + * two midnights. + * + * Candidates are local midnight under every offset the zone observes within + * 14 hours of that date's UTC midnight (any offset in use lies within + * ±14 h); the earliest candidate that already reads the target date is the + * day's start. Reading one offset at UTC midnight instead picked the wrong + * one on transition days in zones far from UTC (Pacific/Auckland) and landed + * a day early where midnight is skipped. + */ +function firstInstantOfLocalDay( + year: number, + month: number, + day: number, + tz: string, +): Date { + const utcMidnight = Date.UTC(year, month - 1, day); + const offsets = new Set(); + for (const delta of [-14, 0, 14]) { + offsets.add(offsetMinutesAt(new Date(utcMidnight + delta * 3_600_000), tz)); + } + let best: number | null = null; + for (const offset of offsets) { + const candidate = utcMidnight - offset * 60_000; + const at = wallClockInTz(new Date(candidate), tz); + if (at.year !== year || at.month !== month || at.day !== day) continue; + if (best === null || candidate < best) best = candidate; + } + if (best !== null) return new Date(best); + // No sampled offset lands on the date (a zone that skipped the whole day, + // such as Pacific/Apia on 2011-12-30): fall back to the converged local + // 00:00, which is the next instant that exists. + return zonedWallClockToUtc({ year, month, day, hour: 0, minute: 0 }, tz); +} + +/** UTC offset of `tz` at `instant`, in minutes east of UTC. */ +function offsetMinutesAt(instant: Date, tz: string): number { + const at = wallClockInTz(instant, tz); + const asIfUtc = Date.UTC( + at.year, + at.month - 1, + at.day, + at.hour, + at.minute, + at.second, + ); + return Math.round((asIfUtc - instant.getTime()) / 60_000); +} + +/** + * The first instant of the local calendar day `dateKey` (`YYYY-MM-DD`) in + * `tz`. The one start-of-day primitive for day keys; see + * {@link firstInstantOfLocalDay} for the DST cases. + */ +export function startOfLocalDayKey(dateKey: string, tz: string): Date { + const [year, month, day] = dateKey.split("-").map(Number); + return firstInstantOfLocalDay(year, month, day, tz); +} + +/** + * The `[dayStart, dayEnd)` UTC window of the local calendar day `dateKey` + * in `tz`: 23, 24 or 25 hours long, whatever the day actually lasts. `dayEnd` + * is the first instant of the following local day, so `measuredAt >= + * dayStart AND measuredAt < dayEnd` selects exactly that day. + */ +export function localDayWindow( + dateKey: string, + tz: string, +): { dayStart: Date; dayEnd: Date } { + return { + dayStart: startOfLocalDayKey(dateKey, tz), + dayEnd: startOfLocalDayKey(shiftDateKey(dateKey, 1), tz), + }; +} + +/** + * The first instant of the local calendar day `instant` falls on in `tz` + * (usually local midnight; see {@link firstInstantOfLocalDay}). + * + * Canonical start-of-local-day primitive for instants. 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, @@ -68,29 +148,7 @@ export function startOfLocalDayInTz( ); } 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; + return firstInstantOfLocalDay(parts.year, parts.month, parts.day, tz); } /** diff --git a/src/lib/tz/wall-clock.ts b/src/lib/tz/wall-clock.ts index 27db95150..917e8f595 100644 --- a/src/lib/tz/wall-clock.ts +++ b/src/lib/tz/wall-clock.ts @@ -130,7 +130,33 @@ export function zonedWallClockToUtc( const offsetMin = Math.round((guessAsIfUtc - guess.getTime()) / 60_000); guess = new Date(asIfUtc - offsetMin * 60_000); } - return guess; + const settled = wallClockInTz(guess, tz); + if ( + settled.year === parts.year && + settled.month === parts.month && + settled.day === parts.day && + settled.hour === parts.hour && + settled.minute === parts.minute + ) { + return guess; + } + // The wall clock does not exist: a DST change skips it (00:00 on the + // spring-forward day in America/Santiago, 02:30 in Europe/Berlin). Read it + // with the offset in force BEFORE the gap, which lands the same distance + // past the change (00:00 becomes 01:00), the way a wall clock reads when + // it is set forward. The converge alone could settle on the previous day. + let before = Infinity; + for (const delta of [-14, 0, 14]) { + const probe = new Date(asIfUtc + delta * 3_600_000); + const at = wallClockInTz(probe, tz); + const offset = Math.round( + (Date.UTC(at.year, at.month - 1, at.day, at.hour, at.minute, at.second) - + probe.getTime()) / + 60_000, + ); + if (offset < before) before = offset; + } + return new Date(asIfUtc - before * 60_000); } /**