const BERLIN_TZ = "Europe/Berlin"; export type CalendarDate = { year: number; month: number; day: number }; /** Strict calendar input, interpreted in Berlin regardless of server TZ. */ export function parseBerlinDate(value: string): Date | null { if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null; const [year, month, day] = value.split("-").map(Number); if (year < 1000 || month < 1 || month > 12 || day < 1 || day > 31) return null; const date = berlinWallTimeToUTC({ year, month, day }, 0, 0); return berlinDateKey(date) === value ? date : null; } export function berlinDateKey(date: Date): string { const { year, month, day } = berlinParts(date); return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; } export function formatBerlinDate(date: Date, options: Intl.DateTimeFormatOptions = {}): string { return new Intl.DateTimeFormat("de-DE", { ...options, timeZone: BERLIN_TZ }).format(date); } export function formatBerlinTime(date: Date, options: Intl.DateTimeFormatOptions = {}): string { return new Intl.DateTimeFormat("de-DE", { ...options, timeZone: BERLIN_TZ }).format(date); } export function berlinParts(date: Date): CalendarDate & { hour: number; minute: number } { const parts = new Intl.DateTimeFormat("en-US", { timeZone: BERLIN_TZ, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23", }).formatToParts(date); const get = (type: string) => Number(parts.find((part) => part.type === type)?.value ?? "0"); return { year: get("year"), month: get("month"), day: get("day"), hour: get("hour"), minute: get("minute") }; } export function berlinOffsetMinutes(date: Date): number { const tzName = new Intl.DateTimeFormat("en-US", { timeZone: BERLIN_TZ, timeZoneName: "shortOffset" }) .formatToParts(date) .find((part) => part.type === "timeZoneName")?.value ?? "GMT+1"; const match = tzName.match(/GMT([+-]\d+)(?::(\d+))?/); const hours = match ? Number(match[1]) : 1; const minutes = match?.[2] ? Number(match[2]) : 0; return hours * 60 + (hours < 0 ? -minutes : minutes); } /** Converts a Berlin wall-clock date/time into the UTC instant it represents. */ export function berlinWallTimeToUTC(date: CalendarDate, hour: number, minute: number): Date { const guess = Date.UTC(date.year, date.month - 1, date.day, hour, minute); return new Date(guess - berlinOffsetMinutes(new Date(guess)) * 60_000); } export function berlinDayBounds(date: Date): { key: string; start: Date; end: Date } { const current = berlinParts(date); const nextCalendarDay = new Date(Date.UTC(current.year, current.month - 1, current.day + 1)); const next = berlinParts(nextCalendarDay); const start = berlinWallTimeToUTC(current, 0, 0); const nextStart = berlinWallTimeToUTC(next, 0, 0); return { key: berlinDateKey(date), start, end: new Date(nextStart.getTime() - 1) }; } /** * Converts a JS Date (a UTC instant) into its Europe/Berlin wall-clock * representation for iCalendar's local `TZID` date-time form * (`DTSTART;TZID=Europe/Berlin:YYYYMMDDTHHMMSS`). Uses the ICU timezone * database via Intl rather than manual UTC+1/+2 arithmetic, so daylight * saving transitions are always handled correctly - no naive UTC offset * math that would drift by an hour half the year. */ export function toBerlinICSDateTime(date: Date): string { const parts = new Intl.DateTimeFormat("en-US", { timeZone: BERLIN_TZ, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23", }).formatToParts(date); const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "00"; return `${get("year")}${get("month")}${get("day")}T${get("hour")}${get("minute")}${get("second")}`; } /** UTC timestamp form (`YYYYMMDDTHHMMSSZ`) - used only for DTSTAMP, which marks generation time, not a local event time. */ export function toUTCICSStamp(date: Date): string { return date.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"; } /** * Standard IANA Europe/Berlin VTIMEZONE block (the same one commonly * embedded by calendar exporters): CEST from the last Sunday in March, * CET from the last Sunday in October - the actual EU DST rule, not an * approximation. */ export const BERLIN_VTIMEZONE_LINES = [ "BEGIN:VTIMEZONE", "TZID:Europe/Berlin", "X-LIC-LOCATION:Europe/Berlin", "BEGIN:DAYLIGHT", "TZOFFSETFROM:+0100", "TZOFFSETTO:+0200", "TZNAME:CEST", "DTSTART:19700329T020000", "RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU", "END:DAYLIGHT", "BEGIN:STANDARD", "TZOFFSETFROM:+0200", "TZOFFSETTO:+0100", "TZNAME:CET", "DTSTART:19701025T030000", "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU", "END:STANDARD", "END:VTIMEZONE", ];