121 lines
4.7 KiB
TypeScript
121 lines
4.7 KiB
TypeScript
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",
|
|
];
|