mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(tz): split pure helpers out of resolver to keep client bundle clean
`@/lib/tz/resolver` imported `prisma` at module top so any client component that pulled `detectBrowserTimezone`, `listSupportedTimezones`, `formatInUserTz`, `userDayKey`, or `DEFAULT_TIMEZONE` dragged `node:module` into the browser bundle and broke the Turbopack build (register page, timezone picker, admin general/login sections, `@/lib/export`). Pure helpers now live in `@/lib/tz/format` (no Prisma). The resolver re-exports them so every server-side caller keeps its `@/lib/tz/resolver` import path; the four client modules + the export helper now import from `./format` directly.
This commit is contained in:
1 parent
ee560a435c
commit
7bede83d22
7 files changed
+251
-226
No files matched your search
@@ -11,7 +11,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PasswordStrength } from "@/components/ui/password-strength";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { detectBrowserTimezone } from "@/lib/tz/resolver";
|
||||
import { detectBrowserTimezone } from "@/lib/tz/format";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMemo } from "react";
|
||||
import { Settings } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared";
|
||||
import { listSupportedTimezones } from "@/lib/tz/resolver";
|
||||
import { listSupportedTimezones } from "@/lib/tz/format";
|
||||
|
||||
const NATIVE_SELECT_CLASS =
|
||||
"border-input bg-background text-foreground ring-offset-background focus-visible:ring-ring flex h-9 rounded-md border px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-none";
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { toCSV } from "@/lib/export";
|
||||
import { formatInUserTz, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import { formatInUserTz, DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import {
|
||||
type AdminAuditEntry,
|
||||
|
||||
@@ -24,7 +24,7 @@ import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
detectBrowserTimezone,
|
||||
listSupportedTimezones,
|
||||
} from "@/lib/tz/resolver";
|
||||
} from "@/lib/tz/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
const NATIVE_SELECT_CLASS =
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
* thread a user context (admin tools, the backup-on-disk shape) keep
|
||||
* the old contract. Production routes always pass `userTz`.
|
||||
*/
|
||||
import { formatInUserTz } from "./tz/resolver";
|
||||
import { formatInUserTz } from "./tz/format";
|
||||
|
||||
export interface ExportableRecord {
|
||||
[key: string]: unknown;
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* v1.4.25 W7 — pure timezone helpers (no Prisma, no Node-only modules).
|
||||
*
|
||||
* Split out from `./resolver` in v1.4.25 Fix-G so client components can
|
||||
* import constants and formatters without dragging `node:module` (via
|
||||
* Prisma) into the browser bundle. The Prisma-backed resolvers
|
||||
* (`resolveUserTimezone`, `resolveServerDefaultTimezone`) stay in
|
||||
* `./resolver`; the resolver re-exports every helper from this file so
|
||||
* existing server-side callers keep their `@/lib/tz/resolver` import
|
||||
* path. New code should prefer `@/lib/tz/format` for the pure helpers
|
||||
* and `@/lib/tz/resolver` only when it needs the cached User/Settings
|
||||
* lookup.
|
||||
*/
|
||||
|
||||
export const DEFAULT_TIMEZONE = "Europe/Berlin";
|
||||
|
||||
/**
|
||||
* Validate a timezone string by asking `Intl.DateTimeFormat` to use it.
|
||||
* Returns `true` if the runtime accepts the zone, `false` otherwise.
|
||||
* Cheap (microseconds) — call freely at write paths.
|
||||
*/
|
||||
export function isValidTimezone(tz: string): boolean {
|
||||
if (!tz || typeof tz !== "string" || tz.length === 0 || tz.length > 64) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: tz });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The list of every IANA zone the current Node runtime accepts.
|
||||
* Returns an empty array on engines older than Node 22 / V8 12.5
|
||||
* where `Intl.supportedValuesOf` is unavailable — callers should
|
||||
* fall back to free-text input plus `isValidTimezone()` validation.
|
||||
*/
|
||||
export function listSupportedTimezones(): string[] {
|
||||
const supportedValuesOf = (
|
||||
Intl as unknown as {
|
||||
supportedValuesOf?: (key: string) => string[];
|
||||
}
|
||||
).supportedValuesOf;
|
||||
if (typeof supportedValuesOf !== "function") return [];
|
||||
try {
|
||||
return supportedValuesOf("timeZone");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-only helper. Reads the browser's resolved timezone via the
|
||||
* `Intl` API. Returns the string the browser hands us — no
|
||||
* validation, no fallback — because the consumer (signup form,
|
||||
* profile picker hint) is going to round-trip the value through the
|
||||
* server's `isValidTimezone()` check anyway. Safe on every modern
|
||||
* browser back to Edge 18 / Safari 11 / Chrome 24.
|
||||
*/
|
||||
export function detectBrowserTimezone(): string {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
} catch {
|
||||
return DEFAULT_TIMEZONE;
|
||||
}
|
||||
}
|
||||
|
||||
export type FormatInUserTzShape =
|
||||
| "iso-with-offset"
|
||||
| "wall-clock"
|
||||
| "datetime"
|
||||
| "date";
|
||||
|
||||
/**
|
||||
* Format an instant in the user's timezone.
|
||||
*
|
||||
* - "iso-with-offset" — `2026-05-11T11:05:00+02:00` (no
|
||||
* milliseconds, sortable, machine-parseable). Issue #167 fix:
|
||||
* replaces `.toISOString()` in CSV/JSON exports.
|
||||
*
|
||||
* - "wall-clock" — `11:05` (24h, no seconds). Used by chart axis
|
||||
* labels and notification preambles.
|
||||
*
|
||||
* - "datetime" — `2026-05-11 11:05` (24h, locale-independent for
|
||||
* audit/PDF tables). Equivalent to the `formatters.dateTime`
|
||||
* helper but with a per-call timezone instead of the global
|
||||
* `DISPLAY_TIMEZONE`.
|
||||
*
|
||||
* - "date" — `2026-05-11` (ISO date in the user's zone). Used as a
|
||||
* day-bucket key parallel to the legacy `berlinDayKey()`.
|
||||
*/
|
||||
export function formatInUserTz(
|
||||
date: Date,
|
||||
tz: string,
|
||||
format: FormatInUserTzShape = "iso-with-offset",
|
||||
): string {
|
||||
const safeTz = isValidTimezone(tz) ? tz : DEFAULT_TIMEZONE;
|
||||
switch (format) {
|
||||
case "iso-with-offset":
|
||||
return formatIsoWithOffset(date, safeTz);
|
||||
case "wall-clock":
|
||||
return new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: safeTz,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
case "datetime": {
|
||||
const parts = wallClockParts(date, safeTz);
|
||||
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`;
|
||||
}
|
||||
case "date": {
|
||||
const parts = wallClockParts(date, safeTz);
|
||||
return `${parts.year}-${parts.month}-${parts.day}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Day-bucket key in the user's timezone. Mirrors `berlinDayKey()` but
|
||||
* parameterised. Used as a stable Map key for daily aggregation so a
|
||||
* 23:30-local reading lands on today's bucket regardless of UTC
|
||||
* offset. The legacy `berlinDayKey()` stays for surfaces that are
|
||||
* not yet user-scoped (admin tables, audit log viewer).
|
||||
*/
|
||||
export function userDayKey(date: Date, tz: string): string {
|
||||
return formatInUserTz(date, tz, "date");
|
||||
}
|
||||
|
||||
interface WallClockParts {
|
||||
year: string;
|
||||
month: string;
|
||||
day: string;
|
||||
hour: string;
|
||||
minute: string;
|
||||
second: string;
|
||||
}
|
||||
|
||||
function wallClockParts(date: Date, tz: string): WallClockParts {
|
||||
const fmt = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
const parts = fmt.formatToParts(date);
|
||||
const get = (type: Intl.DateTimeFormatPartTypes): string =>
|
||||
parts.find((p) => p.type === type)?.value ?? "00";
|
||||
// Some engines render midnight as "24:00".
|
||||
let hour = get("hour");
|
||||
if (hour === "24") hour = "00";
|
||||
return {
|
||||
year: get("year"),
|
||||
month: get("month"),
|
||||
day: get("day"),
|
||||
hour,
|
||||
minute: get("minute"),
|
||||
second: get("second"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Date as `YYYY-MM-DDTHH:MM:SS±HH:MM` for the requested
|
||||
* timezone. Sortable, lossless w.r.t. the UTC instant, and reads
|
||||
* correctly in Excel / LibreOffice (the CSV-export bug in issue #167
|
||||
* was triggered by `Z` getting stripped by Excel before display, so
|
||||
* UTC-with-offset values are misread as local). Seconds are
|
||||
* preserved (not milliseconds — the export only carries timestamps
|
||||
* at second resolution anyway).
|
||||
*/
|
||||
function formatIsoWithOffset(date: Date, tz: string): string {
|
||||
const parts = wallClockParts(date, tz);
|
||||
const offsetMinutes = tzOffsetMinutes(date, tz);
|
||||
const sign = offsetMinutes >= 0 ? "+" : "-";
|
||||
const abs = Math.abs(offsetMinutes);
|
||||
const offH = String(Math.floor(abs / 60)).padStart(2, "0");
|
||||
const offM = String(abs % 60).padStart(2, "0");
|
||||
return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}${sign}${offH}:${offM}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the UTC offset of a timezone at a specific instant, in
|
||||
* minutes. Positive east of UTC, negative west. Honours DST because
|
||||
* `Intl.DateTimeFormat` does — we reconstruct the offset from the
|
||||
* difference between the wall-clock parts and the UTC parts.
|
||||
*/
|
||||
function tzOffsetMinutes(date: Date, tz: string): number {
|
||||
const fmt = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
const parts = fmt.formatToParts(date);
|
||||
const get = (type: Intl.DateTimeFormatPartTypes): number =>
|
||||
Number(parts.find((p) => p.type === type)?.value ?? "0");
|
||||
const hour = get("hour") === 24 ? 0 : get("hour");
|
||||
const asIfUtc = Date.UTC(
|
||||
get("year"),
|
||||
get("month") - 1,
|
||||
get("day"),
|
||||
hour,
|
||||
get("minute"),
|
||||
get("second"),
|
||||
);
|
||||
return Math.round((asIfUtc - date.getTime()) / 60000);
|
||||
}
|
||||
+29
-221
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* v1.4.25 W7 — per-user timezone resolver.
|
||||
* v1.4.25 W7 — per-user timezone resolver (server-only).
|
||||
*
|
||||
* HealthLog historically pinned every display surface to the
|
||||
* `DISPLAY_TIMEZONE` constant ("Europe/Berlin"). The per-user
|
||||
@@ -8,7 +8,7 @@
|
||||
* a date or buckets by day — exports, charts, AI Coach snapshot,
|
||||
* doctor-report PDF, reminders.
|
||||
*
|
||||
* The resolver is intentionally narrow:
|
||||
* Two entry points live here:
|
||||
*
|
||||
* - `resolveUserTimezone(userId)` reads `User.timezone` and falls
|
||||
* back to the server-wide default. The User column has a NOT
|
||||
@@ -22,27 +22,37 @@
|
||||
* the registration handler hands a new account when the client
|
||||
* doesn't send a browser-detected zone.
|
||||
*
|
||||
* - `detectBrowserTimezone()` is the client-only helper that
|
||||
* returns `Intl.DateTimeFormat().resolvedOptions().timeZone`.
|
||||
* Used by the signup form and the profile picker's "use my
|
||||
* browser zone" button.
|
||||
* Both share a 60-second TTL cache keyed by `userId` / `"__server__"`.
|
||||
* The hot path (Coach snapshot, export with thousands of rows) would
|
||||
* otherwise hammer Prisma. Bust the cache on the rare writes via
|
||||
* `invalidateUserTimezone(userId)` (called from the profile PUT and
|
||||
* the admin PUT). The cache is module-level intentionally — every
|
||||
* Next.js route handler shares the same process, and the per-user
|
||||
* write path already invalidates.
|
||||
*
|
||||
* - `formatInUserTz(date, tz, format?)` is the server-side
|
||||
* formatter. Three named formats keep the call sites tight:
|
||||
* "iso-with-offset" for exports, "wall-clock" for chart labels,
|
||||
* "datetime" for PDFs / notifications.
|
||||
*
|
||||
* Both resolver entry points share a 60-second TTL cache keyed by
|
||||
* `userId` / `"__server__"`. The hot path (Coach snapshot, export
|
||||
* with thousands of rows) would otherwise hammer Prisma. Bust the
|
||||
* cache on the rare writes via `invalidateUserTimezone(userId)`
|
||||
* (called from the profile PUT and the admin PUT). The cache is
|
||||
* module-level intentionally — every Next.js route handler shares
|
||||
* the same process, and the per-user write path already invalidates.
|
||||
* The pure helpers (`isValidTimezone`, `listSupportedTimezones`,
|
||||
* `detectBrowserTimezone`, `formatInUserTz`, `userDayKey`,
|
||||
* `DEFAULT_TIMEZONE`) live in `./format` so client components can
|
||||
* import them without dragging Prisma + `node:module` into the browser
|
||||
* bundle. They are re-exported here so existing server-side callers
|
||||
* keep their `@/lib/tz/resolver` import path.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export const DEFAULT_TIMEZONE = "Europe/Berlin";
|
||||
import {
|
||||
DEFAULT_TIMEZONE,
|
||||
isValidTimezone,
|
||||
} from "./format";
|
||||
|
||||
export {
|
||||
DEFAULT_TIMEZONE,
|
||||
isValidTimezone,
|
||||
listSupportedTimezones,
|
||||
detectBrowserTimezone,
|
||||
formatInUserTz,
|
||||
userDayKey,
|
||||
type FormatInUserTzShape,
|
||||
} from "./format";
|
||||
|
||||
const TTL_MS = 60_000;
|
||||
|
||||
@@ -54,43 +64,6 @@ interface CacheEntry {
|
||||
const userTzCache = new Map<string, CacheEntry>();
|
||||
let serverTzCache: CacheEntry | null = null;
|
||||
|
||||
/**
|
||||
* Validate a timezone string by asking `Intl.DateTimeFormat` to use it.
|
||||
* Returns `true` if the runtime accepts the zone, `false` otherwise.
|
||||
* Cheap (microseconds) — call freely at write paths.
|
||||
*/
|
||||
export function isValidTimezone(tz: string): boolean {
|
||||
if (!tz || typeof tz !== "string" || tz.length === 0 || tz.length > 64) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: tz });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The list of every IANA zone the current Node runtime accepts.
|
||||
* Returns an empty array on engines older than Node 22 / V8 12.5
|
||||
* where `Intl.supportedValuesOf` is unavailable — callers should
|
||||
* fall back to free-text input plus `isValidTimezone()` validation.
|
||||
*/
|
||||
export function listSupportedTimezones(): string[] {
|
||||
const supportedValuesOf = (
|
||||
Intl as unknown as {
|
||||
supportedValuesOf?: (key: string) => string[];
|
||||
}
|
||||
).supportedValuesOf;
|
||||
if (typeof supportedValuesOf !== "function") return [];
|
||||
try {
|
||||
return supportedValuesOf("timeZone");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the server-wide default timezone — the value handed to new
|
||||
* accounts on signup when the client did not send a browser-detected
|
||||
@@ -174,168 +147,3 @@ export function invalidateUserTimezone(userId: string): void {
|
||||
export function invalidateServerDefaultTimezone(): void {
|
||||
serverTzCache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-only helper. Reads the browser's resolved timezone via the
|
||||
* `Intl` API. Returns the string the browser hands us — no
|
||||
* validation, no fallback — because the consumer (signup form,
|
||||
* profile picker hint) is going to round-trip the value through the
|
||||
* server's `isValidTimezone()` check anyway. Safe on every modern
|
||||
* browser back to Edge 18 / Safari 11 / Chrome 24.
|
||||
*/
|
||||
export function detectBrowserTimezone(): string {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
} catch {
|
||||
return DEFAULT_TIMEZONE;
|
||||
}
|
||||
}
|
||||
|
||||
export type FormatInUserTzShape =
|
||||
| "iso-with-offset"
|
||||
| "wall-clock"
|
||||
| "datetime"
|
||||
| "date";
|
||||
|
||||
/**
|
||||
* Format an instant in the user's timezone.
|
||||
*
|
||||
* - "iso-with-offset" — `2026-05-11T11:05:00+02:00` (no
|
||||
* milliseconds, sortable, machine-parseable). Issue #167 fix:
|
||||
* replaces `.toISOString()` in CSV/JSON exports.
|
||||
*
|
||||
* - "wall-clock" — `11:05` (24h, no seconds). Used by chart axis
|
||||
* labels and notification preambles.
|
||||
*
|
||||
* - "datetime" — `2026-05-11 11:05` (24h, locale-independent for
|
||||
* audit/PDF tables). Equivalent to the `formatters.dateTime`
|
||||
* helper but with a per-call timezone instead of the global
|
||||
* `DISPLAY_TIMEZONE`.
|
||||
*
|
||||
* - "date" — `2026-05-11` (ISO date in the user's zone). Used as a
|
||||
* day-bucket key parallel to the legacy `berlinDayKey()`.
|
||||
*/
|
||||
export function formatInUserTz(
|
||||
date: Date,
|
||||
tz: string,
|
||||
format: FormatInUserTzShape = "iso-with-offset",
|
||||
): string {
|
||||
const safeTz = isValidTimezone(tz) ? tz : DEFAULT_TIMEZONE;
|
||||
switch (format) {
|
||||
case "iso-with-offset":
|
||||
return formatIsoWithOffset(date, safeTz);
|
||||
case "wall-clock":
|
||||
return new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: safeTz,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
case "datetime": {
|
||||
const parts = wallClockParts(date, safeTz);
|
||||
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`;
|
||||
}
|
||||
case "date": {
|
||||
const parts = wallClockParts(date, safeTz);
|
||||
return `${parts.year}-${parts.month}-${parts.day}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Day-bucket key in the user's timezone. Mirrors `berlinDayKey()` but
|
||||
* parameterised. Used as a stable Map key for daily aggregation so a
|
||||
* 23:30-local reading lands on today's bucket regardless of UTC
|
||||
* offset. The legacy `berlinDayKey()` stays for surfaces that are
|
||||
* not yet user-scoped (admin tables, audit log viewer).
|
||||
*/
|
||||
export function userDayKey(date: Date, tz: string): string {
|
||||
return formatInUserTz(date, tz, "date");
|
||||
}
|
||||
|
||||
interface WallClockParts {
|
||||
year: string;
|
||||
month: string;
|
||||
day: string;
|
||||
hour: string;
|
||||
minute: string;
|
||||
second: string;
|
||||
}
|
||||
|
||||
function wallClockParts(date: Date, tz: string): WallClockParts {
|
||||
const fmt = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
const parts = fmt.formatToParts(date);
|
||||
const get = (type: Intl.DateTimeFormatPartTypes): string =>
|
||||
parts.find((p) => p.type === type)?.value ?? "00";
|
||||
// Some engines render midnight as "24:00".
|
||||
let hour = get("hour");
|
||||
if (hour === "24") hour = "00";
|
||||
return {
|
||||
year: get("year"),
|
||||
month: get("month"),
|
||||
day: get("day"),
|
||||
hour,
|
||||
minute: get("minute"),
|
||||
second: get("second"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Date as `YYYY-MM-DDTHH:MM:SS±HH:MM` for the requested
|
||||
* timezone. Sortable, lossless w.r.t. the UTC instant, and reads
|
||||
* correctly in Excel / LibreOffice (the CSV-export bug in issue #167
|
||||
* was triggered by `Z` getting stripped by Excel before display, so
|
||||
* UTC-with-offset values are misread as local). Seconds are
|
||||
* preserved (not milliseconds — the export only carries timestamps
|
||||
* at second resolution anyway).
|
||||
*/
|
||||
function formatIsoWithOffset(date: Date, tz: string): string {
|
||||
const parts = wallClockParts(date, tz);
|
||||
const offsetMinutes = tzOffsetMinutes(date, tz);
|
||||
const sign = offsetMinutes >= 0 ? "+" : "-";
|
||||
const abs = Math.abs(offsetMinutes);
|
||||
const offH = String(Math.floor(abs / 60)).padStart(2, "0");
|
||||
const offM = String(abs % 60).padStart(2, "0");
|
||||
return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}${sign}${offH}:${offM}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the UTC offset of a timezone at a specific instant, in
|
||||
* minutes. Positive east of UTC, negative west. Honours DST because
|
||||
* `Intl.DateTimeFormat` does — we reconstruct the offset from the
|
||||
* difference between the wall-clock parts and the UTC parts.
|
||||
*/
|
||||
function tzOffsetMinutes(date: Date, tz: string): number {
|
||||
const fmt = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
const parts = fmt.formatToParts(date);
|
||||
const get = (type: Intl.DateTimeFormatPartTypes): number =>
|
||||
Number(parts.find((p) => p.type === type)?.value ?? "0");
|
||||
const hour = get("hour") === 24 ? 0 : get("hour");
|
||||
const asIfUtc = Date.UTC(
|
||||
get("year"),
|
||||
get("month") - 1,
|
||||
get("day"),
|
||||
hour,
|
||||
get("minute"),
|
||||
get("second"),
|
||||
);
|
||||
return Math.round((asIfUtc - date.getTime()) / 60000);
|
||||
}
|
||||
Reference in new issue
Block a user