fix(tz): one start-of-day for every zone, including skipped midnights

Local day bounds had three implementations. The day-key one read the zone
offset at UTC midnight, which on a transition day in zones far from UTC
(Pacific/Auckland, Australia/Sydney) is the offset after the change, so the
day started an hour off. The instant one converged on local 00:00, which in
zones that skip midnight (America/Santiago, America/Havana) does not exist,
and returned 23:00 of the previous day.

startOfLocalDayKey and localDayWindow in lib/tz/local-day are now the only
implementation: the first instant that reads the day, tried under every
offset in force around it. A skipped wall clock resolves to the moment the
clocks jump, the day-keyed copies in the measurement drains, the compliance
rollups and the dashboard batch window are gone, and the noon and mid-hour
anchors use the same converge.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 15:41:16 +02:00
1 parent e1e664be46
commit 73e0653b2c
14 files changed
+237 -231

No files matched your search

+1 -1
View File
@@ -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";
@@ -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 {
+1 -1
View File
@@ -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,
+8 -20
View File
@@ -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);
}
/**
@@ -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", () => {
+1 -1
View File
@@ -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
+11 -97
View File
@@ -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);
}
@@ -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,
@@ -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. */
@@ -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;
@@ -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<void> {
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
+86
View File
@@ -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"),
);
});
});
+89 -31
View File
@@ -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<number>();
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);
}
/**
+27 -1
View File
@@ -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);
}
/**