From 2ac2db7a23d9992b4302e6d97a360a4babfcccbe Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 7 May 2026 07:52:16 -0400 Subject: [PATCH] feat(07.1-04): add useUserTimezone client hook - New lib/hooks/use-user-timezone.ts exporting useUserTimezone() and formatInUserTimezone() - Reads user.timezone from Better Auth useSession() additionalField (Plan 01) - Validates against Intl.supportedValuesOf('timeZone') with safe fallback to NEXT_PUBLIC_DEFAULT_TIMEZONE || 'UTC' - Pure formatInUserTimezone helper safe to call inside loops (not a hook) - Resolves TZ-04 --- lib/hooks/use-user-timezone.ts | 61 ++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 lib/hooks/use-user-timezone.ts diff --git a/lib/hooks/use-user-timezone.ts b/lib/hooks/use-user-timezone.ts new file mode 100644 index 0000000..40fc93c --- /dev/null +++ b/lib/hooks/use-user-timezone.ts @@ -0,0 +1,61 @@ +"use client"; + +import { useSession } from "@/lib/auth-client"; + +// Public-readable default (Next.js exposes NEXT_PUBLIC_* to the browser). +// Operators can set this in .env.local to match the server-side +// DEFAULT_TIMEZONE. If unset, both server and client default to 'UTC'. +function getClientDefaultTimezone(): string { + return process.env.NEXT_PUBLIC_DEFAULT_TIMEZONE || "UTC"; +} + +function isValidIanaTimezone(tz: unknown): tz is string { + if (typeof tz !== "string" || tz.length === 0 || tz.length > 64) return false; + try { + return Intl.supportedValuesOf("timeZone").includes(tz); + } catch { + return false; + } +} + +/** + * useUserTimezone — TZ-04. + * + * Returns the calling user's IANA timezone string, sourced from the + * Better Auth additionalField on `useSession()`. Falls back to + * `process.env.NEXT_PUBLIC_DEFAULT_TIMEZONE || 'UTC'` while the session + * is loading, when the field is missing, or when the stored value is + * not a recognized IANA zone. + * + * This is the ONLY supported way to read the user's tz on the client. + * Do NOT call `Intl.DateTimeFormat()` with a hardcoded zone or rely on + * the browser's local zone — the user may have travelled or set a + * preference that differs from the device. + */ +export function useUserTimezone(): string { + const { data } = useSession(); + // Better Auth additionalField is typed `string` post-Plan-01; defence + // in depth: validate before returning. + const raw = (data?.user as { timezone?: unknown } | undefined)?.timezone; + if (isValidIanaTimezone(raw)) return raw; + return getClientDefaultTimezone(); +} + +/** + * formatInUserTimezone — convenience wrapper for the common case of + * "format an ISO string in the user's tz". Equivalent to + * `new Date(iso).toLocaleString(locale, { ...options, timeZone: tz })`. + * + * Pass `tz` from `useUserTimezone()` and the same options object you'd + * pass to toLocaleString / toLocaleDateString — this helper keeps + * existing format strings working without rewrites. + */ +export function formatInUserTimezone( + input: string | number | Date, + tz: string, + options?: Intl.DateTimeFormatOptions, + locale: string = "en-US", +): string { + const date = input instanceof Date ? input : new Date(input); + return date.toLocaleString(locale, { ...options, timeZone: tz }); +}