From 7bede83d22374bf0b5f6f2975b80b979bf0197be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 18:57:52 +0200 Subject: [PATCH] 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. --- src/app/auth/register/page.tsx | 2 +- .../admin/general-settings-section.tsx | 2 +- .../admin/login-overview-section.tsx | 2 +- src/components/settings/timezone-picker.tsx | 2 +- src/lib/export.ts | 2 +- src/lib/tz/format.ts | 217 +++++++++++++++ src/lib/tz/resolver.ts | 250 ++---------------- 7 files changed, 251 insertions(+), 226 deletions(-) create mode 100644 src/lib/tz/format.ts diff --git a/src/app/auth/register/page.tsx b/src/app/auth/register/page.tsx index d1355582e..b4b300252 100644 --- a/src/app/auth/register/page.tsx +++ b/src/app/auth/register/page.tsx @@ -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(); diff --git a/src/components/admin/general-settings-section.tsx b/src/components/admin/general-settings-section.tsx index 75a3c34c2..686e961c9 100644 --- a/src/components/admin/general-settings-section.tsx +++ b/src/components/admin/general-settings-section.tsx @@ -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"; diff --git a/src/components/admin/login-overview-section.tsx b/src/components/admin/login-overview-section.tsx index 72c2464bd..d55e089ca 100644 --- a/src/components/admin/login-overview-section.tsx +++ b/src/components/admin/login-overview-section.tsx @@ -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, diff --git a/src/components/settings/timezone-picker.tsx b/src/components/settings/timezone-picker.tsx index 3a122701e..6486d63bf 100644 --- a/src/components/settings/timezone-picker.tsx +++ b/src/components/settings/timezone-picker.tsx @@ -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 = diff --git a/src/lib/export.ts b/src/lib/export.ts index 24784fa2b..35e8f0aaf 100644 --- a/src/lib/export.ts +++ b/src/lib/export.ts @@ -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; diff --git a/src/lib/tz/format.ts b/src/lib/tz/format.ts new file mode 100644 index 000000000..b922cedb8 --- /dev/null +++ b/src/lib/tz/format.ts @@ -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); +} diff --git a/src/lib/tz/resolver.ts b/src/lib/tz/resolver.ts index b8def446a..df45b6260 100644 --- a/src/lib/tz/resolver.ts +++ b/src/lib/tz/resolver.ts @@ -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(); 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); -}