import { berlinDay, formatMonth, monthKey, nextOccurrence } from "@/lib/dates"; import type { CalendarEvent } from "@/lib/types"; /** * Filtering, sorting and grouping for the admin list. All client-side: the * Angular app re-fetched from the server every time the filter changed, even * though the data did not. */ export type Range = "future" | "past" | "all"; export type Sort = "start_asc" | "start_desc" | "created_asc" | "created_desc"; export const RANGES: readonly Range[] = ["future", "past", "all"]; export const SORTS: readonly Sort[] = ["start_asc", "start_desc", "created_asc", "created_desc"]; export interface ListFilter { calendarIds: readonly number[]; range: Range; query: string; } export const isRecurring = (event: CalendarEvent): boolean => event.repeatFrequency !== ""; /** * When the event next happens, for sorting and grouping. A repeating event's * original start can be decades back (a birthday is stored with the year of * birth); what matters in the list is the next time it comes round. */ export const effectiveStart = (event: CalendarEvent, now: Date): Date => isRecurring(event) ? nextOccurrence(event.start, event.repeatFrequency, now) : event.start; /** * Repeating events always count as current - under "future" and "all", never * under "past". This replaces the Angular rule "calendar 5 ignores the * filter", which keyed on the calendar rather than on the thing that actually * makes an event timeless. * * "future" compares Berlin days, not instants, so an event that ended earlier * today is still listed until midnight. */ export const matchesRange = (event: CalendarEvent, range: Range, now: Date): boolean => { if (range === "all") { return true; } if (isRecurring(event)) { return range === "future"; } const upcoming = berlinDay(event.end) >= berlinDay(now); return range === "future" ? upcoming : !upcoming; }; const normalise = (s: string): string => s.toLocaleLowerCase("de-DE").normalize("NFC"); /** Case-insensitive substring match on title and location; every word must match. */ export const matchesQuery = (event: CalendarEvent, query: string): boolean => { const words = normalise(query).split(/\s+/).filter(Boolean); if (words.length === 0) { return true; } const haystack = normalise(`${event.name} ${event.location}`); return words.every((w) => haystack.includes(w)); }; export const filterEvents = (events: readonly CalendarEvent[], filter: ListFilter, now: Date): CalendarEvent[] => events.filter( (e) => filter.calendarIds.includes(e.calendarId) && matchesRange(e, filter.range, now) && matchesQuery(e, filter.query), ); const byName = (a: CalendarEvent, b: CalendarEvent): number => a.name.localeCompare(b.name, "de-DE"); /** * A stable, total order - ties fall back to the title and then the id. The * Angular comparator returned only 1 or -1, never 0, which is not a valid * comparator and let equal events swap places between renders. */ export const sortEvents = (events: readonly CalendarEvent[], sort: Sort, now: Date): CalendarEvent[] => { const key = (e: CalendarEvent): number => sort.startsWith("created") ? (e.created?.getTime() ?? 0) : effectiveStart(e, now).getTime(); const direction = sort.endsWith("desc") ? -1 : 1; return [...events].sort((a, b) => (key(a) - key(b)) * direction || byName(a, b) || a.eventId - b.eventId); }; export interface MonthGroup { /** "2026-10" */ key: string; /** "Oktober 2026" */ label: string; events: CalendarEvent[]; } /** * Consecutive runs of events in the same month (by effective start), in the * order given. Meant for a list sorted by start; with another order, a month * can legitimately appear more than once. */ export const groupByMonth = (events: readonly CalendarEvent[], now: Date): MonthGroup[] => { const groups: MonthGroup[] = []; for (const event of events) { const start = effectiveStart(event, now); const key = monthKey(start); const last = groups.at(-1); if (last && last.key === key) { last.events.push(event); } else { groups.push({ key, label: formatMonth(start), events: [event] }); } } return groups; };