import { TZDate } from "@date-fns/tz"; /** * Every wall-clock time in this app means Europe/Berlin, whatever time zone the * editor's browser happens to be in. * * The Angular app used the browser's local time, which is only correct while * the editor is in Germany: an editor abroad would enter "19:00" and save an * event hours off. The API has no notion of time zones at all - it stores what * it is sent and writes the iCal feed with the *server's* local `getHours()`, * labelled `TZID=Europe/Berlin` (icalgenerator.service.ts) - so the frontend is * the one place that can get this right, by always sending the UTC instant of a * Berlin wall time. * * Dates travel through the form as "YYYY-MM-DD" and times as "HH:mm", exactly * what produce, so there is no Date object to mutate * in place (the other Angular bug). * * DST, as @date-fns/tz resolves it and the tests pin: * - a time that does not exist (02:30 on the last Sunday of March) moves * forward to 03:30 CEST; * - a time that exists twice (02:30 on the last Sunday of October) is the * second one, CET. */ export const TIME_ZONE = "Europe/Berlin"; const LOCALE = "de-DE"; export interface WallTime { /** "YYYY-MM-DD" */ date: string; /** "HH:mm" */ time: string; } const pad = (n: number): string => n.toString().padStart(2, "0"); const splitDate = (date: string): [number, number, number] => { const [y, m, d] = date.split("-").map(Number); return [y, m, d]; }; /** Berlin wall time → instant. */ export const fromWallTime = (date: string, time: string): Date => { const [y, m, d] = splitDate(date); const [hh, mm] = time.split(":").map(Number); return new Date(new TZDate(y, m - 1, d, hh, mm, TIME_ZONE).getTime()); }; /** Instant → Berlin wall time. */ export const toWallTime = (instant: Date): WallTime => { const t = new TZDate(instant.getTime(), TIME_ZONE); return { date: `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())}`, time: `${pad(t.getHours())}:${pad(t.getMinutes())}`, }; }; /** The Berlin calendar day an instant falls on, as "YYYY-MM-DD" (sorts as a string). */ export const berlinDay = (instant: Date): string => toWallTime(instant).date; /** Calendar arithmetic on "YYYY-MM-DD", independent of any time zone. */ export const addDays = (date: string, days: number): string => { const [y, m, d] = splitDate(date); const u = new Date(Date.UTC(y, m - 1, d + days)); return `${u.getUTCFullYear()}-${pad(u.getUTCMonth() + 1)}-${pad(u.getUTCDate())}`; }; /** Whole days from `a` to `b` ("YYYY-MM-DD"); negative when b is earlier. */ export const daysBetween = (a: string, b: string): number => { const [ay, am, ad] = splitDate(a); const [by, bm, bd] = splitDate(b); return Math.round((Date.UTC(by, bm - 1, bd) - Date.UTC(ay, am - 1, ad)) / 86_400_000); }; /** * A whole-day event's stored range: 00:00 on the first day to 23:59 on the * last, both Berlin. * * The end is the *inclusive* last day because that is what the iCal generator * assumes - it adds one day to a whole-day end (`formatDate(…, true, true)`). * 23:59 rather than 00:00 keeps the event "upcoming" for the whole of its last * day in the list filter. */ export const wholeDayRange = (startDate: string, endDate: string): { start: Date; end: Date } => ({ start: fromWallTime(startDate, "00:00"), end: fromWallTime(endDate, "23:59"), }); /** The next full hour after `now`, as Berlin wall time. */ export const nextFullHour = (now: Date): WallTime => { const { date, time } = toWallTime(now); const hour = Number(time.slice(0, 2)) + 1; return hour === 24 ? { date: addDays(date, 1), time: "00:00" } : { date, time: `${pad(hour)}:00` }; }; /** * The first occurrence of a repeating event that starts today (Berlin) or * later, keeping its wall-clock time. Non-repeating events, and frequencies * the API may hold but this app does not know, return `start` unchanged. * * Only YEARLY is ever written by this app (birthdays), but the API accepts any * `FREQ` string, so older data may hold others. * * A yearly event on 29 February falls on 28 February in other years - the * choice a birthday list wants, though RFC 5545 would skip those years. */ export const nextOccurrence = (start: Date, repeatFrequency: string, now: Date): Date => { const today = berlinDay(now); const { date, time } = toWallTime(start); if (date >= today) { return start; } const [, m, d] = splitDate(date); switch (repeatFrequency) { case "YEARLY": { const year = Number(today.slice(0, 4)); const candidate = (yy: number) => { const lastDay = new Date(Date.UTC(yy, m, 0)).getUTCDate(); return `${yy}-${pad(m)}-${pad(Math.min(d, lastDay))}`; }; const thisYear = candidate(year); return fromWallTime(thisYear >= today ? thisYear : candidate(year + 1), time); } case "MONTHLY": { const [ty, tm] = splitDate(today); const candidate = (yy: number, mm: number) => { const lastDay = new Date(Date.UTC(yy, mm, 0)).getUTCDate(); return `${yy}-${pad(mm)}-${pad(Math.min(d, lastDay))}`; }; const thisMonth = candidate(ty, tm); if (thisMonth >= today) { return fromWallTime(thisMonth, time); } return fromWallTime(tm === 12 ? candidate(ty + 1, 1) : candidate(ty, tm + 1), time); } case "WEEKLY": case "DAILY": { const step = repeatFrequency === "WEEKLY" ? 7 : 1; const steps = Math.ceil(daysBetween(date, today) / step); return fromWallTime(addDays(date, steps * step), time); } default: return start; } }; const dayFormat = new Intl.DateTimeFormat(LOCALE, { timeZone: TIME_ZONE, weekday: "short", day: "numeric", month: "short", year: "numeric", }); const timeFormat = new Intl.DateTimeFormat(LOCALE, { timeZone: TIME_ZONE, hour: "2-digit", minute: "2-digit" }); const dateTimeFormat = new Intl.DateTimeFormat(LOCALE, { timeZone: TIME_ZONE, dateStyle: "medium", timeStyle: "short" }); const monthFormat = new Intl.DateTimeFormat(LOCALE, { timeZone: TIME_ZONE, month: "long", year: "numeric" }); /** "Sa., 3. Okt. 2026" */ export const formatDay = (instant: Date): string => dayFormat.format(instant); /** "19:00" */ export const formatTime = (instant: Date): string => timeFormat.format(instant); /** "03.10.2026, 19:00" */ export const formatDateTime = (instant: Date): string => dateTimeFormat.format(instant); /** "Oktober 2026" */ export const formatMonth = (instant: Date): string => monthFormat.format(instant); /** "2026-10" - a sortable month key. */ export const monthKey = (instant: Date): string => berlinDay(instant).slice(0, 7);