const BERLIN_TIME_ZONE = 'Europe/Berlin'; const MS_PER_DAY = 86_400_000; const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/; const TIME_PATTERN = /^([01]\d|2[0-3]):([0-5]\d)$/; // en-CA formats a date as YYYY-MM-DD. const berlinDayFormat = new Intl.DateTimeFormat('en-CA', { timeZone: BERLIN_TIME_ZONE, year: 'numeric', month: '2-digit', day: '2-digit', }); const berlinPartsFormat = new Intl.DateTimeFormat('en-GB', { timeZone: BERLIN_TIME_ZONE, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', }); /** The calendar day (YYYY-MM-DD) of an instant in Europe/Berlin. */ export function berlinDayOf(instant: Date): string { return berlinDayFormat.format(instant); } /** Today's calendar day (YYYY-MM-DD) in Europe/Berlin. */ export function berlinToday(now: Date = new Date()): string { return berlinDayOf(now); } /** A stored day (YYYY-MM-DD) as the DateTime used for dates: midnight UTC. */ export function dayToDate(day: string): Date { if (!DAY_PATTERN.test(day)) { throw new Error(`Invalid day: ${day}`); } const date = new Date(`${day}T00:00:00.000Z`); // 2026-02-30 parses to March 2nd: only a day that round-trips exists. if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== day) { throw new Error(`Invalid day: ${day}`); } return date; } function dayNumber(day: string): number { return Math.round(dayToDate(day).getTime() / MS_PER_DAY); } /** Calendar arithmetic on YYYY-MM-DD strings; no time zone involved. */ export function addDays(day: string, days: number): string { const result = new Date(dayToDate(day).getTime() + days * MS_PER_DAY); return result.toISOString().slice(0, 10); } /** * Calendar months on a YYYY-MM-DD string. A day the target month does not * have is cut to its last day: 2027-01-31 + 1 month is 2027-02-28. */ export function addMonthsClamped(day: string, months: number): string { const start = dayToDate(day); const monthIndex = start.getUTCMonth() + months; const lastDay = new Date( Date.UTC(start.getUTCFullYear(), monthIndex + 1, 0), ).getUTCDate(); return new Date( Date.UTC( start.getUTCFullYear(), monthIndex, Math.min(start.getUTCDate(), lastDay), ), ) .toISOString() .slice(0, 10); } /** Whole calendar days from `from` to `to` (negative when `to` is earlier). */ export function daysBetween(from: string, to: string): number { return dayNumber(to) - dayNumber(from); } /** Offset of Europe/Berlin to UTC at an instant, in milliseconds. */ function berlinOffsetMs(instant: Date): number { const parts: Record = {}; for (const part of berlinPartsFormat.formatToParts(instant)) { if (part.type !== 'literal') { parts[part.type] = Number(part.value); } } const asUtc = Date.UTC( parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second, ); // The instant without its milliseconds: the formatter works in seconds. return asUtc - Math.floor(instant.getTime() / 1000) * 1000; } /** * The instant at which the Berlin wall clock shows `day` `hhmm`. The offset * is taken at the target day itself (applied twice, so the day a clock change * happens comes out right): 06:30 is 04:30Z in summer and 05:30Z in winter. */ export function berlinDateTime(day: string, hhmm: string): Date { const time = TIME_PATTERN.exec(hhmm); if (!time) { throw new Error(`Invalid time: ${hhmm}`); } const wallClockAsUtc = dayToDate(day).getTime() + (Number(time[1]) * 60 + Number(time[2])) * 60_000; let instant = new Date( wallClockAsUtc - berlinOffsetMs(new Date(wallClockAsUtc)), ); instant = new Date(wallClockAsUtc - berlinOffsetMs(instant)); return instant; }