Files
anouma/lib/calendar/timezone.ts
T

112 lines
4.3 KiB
TypeScript

const BERLIN_TZ = "Europe/Berlin";
export type CalendarDate = { year: number; month: number; day: number };
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",
];