mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
e1e664be46
commit
73e0653b2c
14 files changed
+237
-231
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user