/** * Timezone helpers (client-safe, Intl only). Planned dates are entered as wall-clock time in the * tenant timezone (TenantSettings.timezone) and stored as UTC. */ export const DEFAULT_TIMEZONE = "Europe/Berlin"; export function safeTimeZone(tz: string | null | undefined): string { if (!tz) return DEFAULT_TIMEZONE; try { new Intl.DateTimeFormat("en-US", { timeZone: tz }); return tz; } catch { return DEFAULT_TIMEZONE; } } /** Offset (ms) of `timeZone` relative to UTC at instant `at`. */ export function tzOffsetMs(at: Date, timeZone: string): number { const parts = new Intl.DateTimeFormat("en-US", { timeZone, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", }).formatToParts(at); const n = (t: string) => Number(parts.find((p) => p.type === t)?.value); const asUtc = Date.UTC(n("year"), n("month") - 1, n("day"), n("hour"), n("minute"), n("second")); return asUtc - Math.floor(at.getTime() / 1000) * 1000; } /** "2026-09-14T08:30" (wall time in tz) → UTC Date. Returns undefined for invalid input. */ export function wallTimeToUtc(value: string, timeZone: string): Date | undefined { const m = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}))?$/.exec(value.trim()); if (!m) return undefined; const tz = safeTimeZone(timeZone); const guess = Date.UTC(+m[1], +m[2] - 1, +m[3], m[4] ? +m[4] : 0, m[5] ? +m[5] : 0); const first = guess - tzOffsetMs(new Date(guess), tz); // second pass corrects DST boundaries const result = guess - tzOffsetMs(new Date(first), tz); const d = new Date(result); return Number.isNaN(d.getTime()) ? undefined : d; } /** UTC Date → "YYYY-MM-DDTHH:mm" wall time in tz (for ). */ export function toWallTimeInput(date: Date | null | undefined, timeZone: string): string { if (!date) return ""; const parts = new Intl.DateTimeFormat("en-CA", { timeZone: safeTimeZone(timeZone), hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", }).formatToParts(date); const p = (t: string) => parts.find((x) => x.type === t)?.value ?? "00"; return `${p("year")}-${p("month")}-${p("day")}T${p("hour")}:${p("minute")}`; } /** Start/end of the calendar day containing `now` in the given timezone. */ export function zonedDayBounds(now: Date, timeZone: string): { start: Date; end: Date } { const tz = safeTimeZone(timeZone); const [y, m, d] = new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit" }) .format(now) .split("-") .map(Number); const guessStart = Date.UTC(y, m - 1, d, 0, 0, 0); const start = new Date(guessStart - tzOffsetMs(new Date(guessStart), tz)); const guessEnd = Date.UTC(y, m - 1, d, 23, 59, 59, 999); const end = new Date(guessEnd - tzOffsetMs(new Date(guessEnd), tz)); return { start, end }; } export function formatDateTime(date: Date | null | undefined, locale: string, timeZone: string): string { if (!date) return ""; return new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE", { timeZone: safeTimeZone(timeZone), dateStyle: "medium", timeStyle: "short" }).format(date); } export function formatDate(date: Date | null | undefined, locale: string, timeZone: string): string { if (!date) return ""; return new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE", { timeZone: safeTimeZone(timeZone), dateStyle: "medium" }).format(date); }