import { ABSENCE_TYPES } from "./absence"; import { BEENDIGUNGSART_WERTE } from "./beendigung"; import { fmtName, todayIso, yearsBetweenIso } from "./format"; import { HAY_GRADE_WERTE } from "./hay-grade"; import { WOCHENTAGE } from "./wochentage"; import type { EmploymentStatus, HistoryEventType, Weekday } from "./types"; export { todayIso }; // ── Bestand (point-in-time snapshot) ────────────────────────────── export type Measure = "headcount" | "fte" | "parttime_rate" | "avg_age" | "avg_tenure" | "female_share" | "avg_dependents"; export type GroupDimension = | "division" | "department" | "team" | "location" | "status" | "employment_type" | "contract_type" | "entry_year" | "source" | "paygrade" | "worker_type" | "collective_agreement" | "betriebsrat" | "dienstwagen" | "laterale_fuehrung" | "c_level" | "has_dependents" | "teilzeit_art" | "weekday"; export const MEASURE_LABELS: Record = { headcount: "Headcount", fte: "FTE", parttime_rate: "Teilzeitquote", avg_age: "Ø Alter", avg_tenure: "Ø Zugehörigkeit", female_share: "Frauenanteil", avg_dependents: "Ø Angehörige", }; export const GROUP_LABELS: Record = { division: "Bereich", department: "Abteilung", team: "Team", location: "Standort", status: "Status", employment_type: "Beschäftigung", contract_type: "Vertragsart", entry_year: "Eintrittsjahr", source: "Intern/Extern", paygrade: "Hay-Grade", worker_type: "Beschäftigtengruppe", collective_agreement: "Kollektivvertrag", betriebsrat: "Betriebsrat", dienstwagen: "Dienstwagen", laterale_fuehrung: "Laterale Führung", c_level: "C-Level", has_dependents: "Hat Angehörige", teilzeit_art: "Teilzeitvariante", weekday: "Wochentag", }; export const AVERAGE_MEASURES: Measure[] = ["parttime_rate", "avg_age", "avg_tenure", "female_share", "avg_dependents"]; const SUM_MEASURES: Measure[] = ["headcount", "fte"]; export const STATUS_OPTIONS: EmploymentStatus[] = ["Aktiv", "Karenz", "Geplant", "Ausgetreten"]; export const DEFAULT_STATUSES: EmploymentStatus[] = ["Aktiv", "Karenz"]; // `status` filters travel through the URL/exports as a comma-joined list // (e.g. "Aktiv,Karenz"); this is the one place that turns that string back // into a validated status set, defaulting to Aktiv+Karenz when unset — used // by both the Bestand pivot and the full data export so they can never // silently disagree on which statuses "no filter" means. export function parseStatuses(status: string | undefined): EmploymentStatus[] { if (!status) return DEFAULT_STATUSES; const requested = status.split(","); const valid = STATUS_OPTIONS.filter((s) => requested.includes(s)); return valid.length > 0 ? valid : DEFAULT_STATUSES; } export type ReportEmployee = { id: string; first_name: string; last_name: string; job_title: string; /** Die Einheit der Planstelle; null, wenn zum Stichtag keine besetzt war. */ org_unit_id: string | null; location_id: string; status: string; employment_type: string; contract_type: string; entry_date: string; exit_date: string | null; weekly_hours: number; source: string; paygrade: string; birth_date: string; gender: string; worker_type: string; collective_agreement: string; work_days: Weekday[]; is_betriebsrat: boolean; has_dienstwagen: boolean; is_laterale_fuehrung: boolean; is_c_level: boolean; teilzeit_art: string | null; dependents_count: number; }; // Alle drei sind über die *Einheit* der Planstelle geschlüsselt, nicht über // drei verschiedene Fremdschlüssel: welcher Bereich, welche Abteilung und // welches Team zu einer Einheit gehören, ergibt sich aus ihrer Vorfahrenkette // und wird einmal vorberechnet. /** * Eine Organisationseinheit als Filterwert — jede Ebene, nicht nur die * Bereiche. `depth` dient der Einrückung in der Auswahlliste. */ export type UnitOption = { id: string; name: string; depth: number; unitType: string }; export type OrgLookups = { divisionName: Map; departmentName: Map; teamName: Map; locationName: Map; }; // Reconstructs status as of any date from the columns that actually carry a // timeline (entry/exit/Karenz), rather than trusting `employees.status`, // which only ever reflects *today*. // // Die Einordnung in die Organisation wird zum selben Stichtag aufgelöst: seit // dem OM-Modell ist position_assignments zeitabhängig, eine Auswertung // gruppiert also nach der Einheit von damals. Vorher gab es diese Historie // nicht, und ein Stichtagsbericht gruppierte nach der heutigen Zuordnung — // was in der Oberfläche vermerkt werden musste, statt still falsch zu sein. // ── Warum der Austritt zuerst geprüft wird ────────────────────────── // // Umgekehrt herum stand hier „Geplant" vor „Ausgetreten", und wer einen // Eintritt in der Zukunft hatte, galt als geplant — auch dann noch, wenn der // Austritt längst erfasst war. Genau das trifft den No-Show (Migration // 20260814100000): jemand wird eingestellt, erscheint nie, und der Austritt // wird noch vor dem Eintrittstag verbucht. Im Bestand sind das Zeilen mit // Eintritt 01.10.2026 und einem Austritt, die der Filter „Geplant" mitzählte, // während die Liste daneben „Ausgetreten" anzeigte — employees.status, das // die SQL-Funktion beim Austritt gesetzt hat, sagte von Anfang an das // Richtige. // // Ein abgeschlossener Austritt ist der stärkere Befund: er beendet das // Verhältnis, gleichgültig ob der Eintritt schon war oder noch kommt. // „Geplant" heißt danach genau das, was es heißen soll — ein Eintritt, der // noch bevorsteht und nicht zurückgenommen wurde. // // ── Warum ein Austritt am Eintrittstag *jeden* Stichtag schlägt ────── // // Die erste Fassung dieser Regel prüfte nur `exit_date <= asOf`, und das // reichte für den Nichtantritt nicht: Migration 20260814100000 setzt bei // „No Show" das Austrittsdatum **auf den Eintrittstag**. Liegt der noch in // der Zukunft, ist auch der Austritt in der Zukunft — die Regel fiel durch // auf „Geplant", und die Liste zeigte jemanden als anstehenden Eintritt, von // dem längst feststand, dass er nicht kommt. // // Die Migration begründet ihr Vorgehen damit, „nie aktiv" folge aus dem // Datum von selbst. In SQL stimmt das; hier stand der Satz nur als Absicht // und nicht als Klausel. Er steht jetzt da: endet das Verhältnis nicht // später, als es beginnt, gab es keinen einzigen Tag Beschäftigung — zu // keinem Stichtag, auch zu keinem vor dem Eintritt. // // lib/employee-status-filter.ts bildet dieselbe Reihenfolge in SQL ab; die // beiden müssen Klausel für Klausel zusammenpassen. export function deriveStatusAsOf( e: { entry_date: string; exit_date: string | null; karenz_start_date: string | null; karenz_return_date: string | null }, asOf: string ): EmploymentStatus { if (e.exit_date && (e.exit_date <= asOf || e.exit_date <= e.entry_date)) return "Ausgetreten"; if (e.entry_date > asOf) return "Geplant"; if (e.karenz_start_date && e.karenz_start_date <= asOf && (!e.karenz_return_date || asOf < e.karenz_return_date)) return "Karenz"; return "Aktiv"; } function tenureYearsAsOf(entryDate: string, exitDate: string | null, asOf: string): number { const start = Date.parse(`${entryDate}T00:00:00Z`); const end = Date.parse(`${exitDate && exitDate <= asOf ? exitDate : asOf}T00:00:00Z`); return Math.max(0, (end - start) / (1000 * 60 * 60 * 24 * 365.25)); } export function groupKeyFor(e: ReportEmployee, dim: GroupDimension, lookups: OrgLookups): string { switch (dim) { case "division": return e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "Unbekannt") : "–"; case "department": return e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "–") : "–"; case "team": return e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–"; case "location": return lookups.locationName.get(e.location_id) ?? "Unbekannt"; case "status": return e.status; case "employment_type": return e.employment_type; case "contract_type": return e.contract_type; case "entry_year": return e.entry_date.slice(0, 4); case "source": return e.source; case "paygrade": return e.paygrade; case "worker_type": return e.worker_type; case "collective_agreement": return e.collective_agreement; case "betriebsrat": return e.is_betriebsrat ? "Ja" : "Nein"; case "dienstwagen": return e.has_dienstwagen ? "Ja" : "Nein"; case "laterale_fuehrung": return e.is_laterale_fuehrung ? "Ja" : "Nein"; case "c_level": return e.is_c_level ? "Ja" : "Nein"; case "has_dependents": return e.dependents_count > 0 ? "Ja" : "Nein"; case "teilzeit_art": // „Keine" statt „–": die Antwort ist hier eine Aussage, kein Fehlen. return e.teilzeit_art ?? "Keine"; case "weekday": // Not a strict partition — see groupKeysFor, which aggregateReport // actually uses. This single-key fallback only covers a direct // groupKeyFor("weekday", ...) call from outside aggregateReport. return e.work_days[0] ?? "–"; default: return "Unbekannt"; } } /** * Dimensionen, deren Werte eine eigene Reihenfolge haben. * * Alles andere wird nach der Kennzahl sortiert, gross zuerst — bei „Bereich" * oder „Standort" ist das die Antwort auf die Frage, die der Bericht stellt. * Bei einer Leiter ist es keine: Montag vor Dienstag und HG09 vor HG10 sind * die Reihenfolge, in der die Werte *sind*, und eine nach Häufigkeit * umgestellte Leiter liest sich als Zufall. * * HG09 ist die unterste Stufe, HG20 die oberste. Der „Generic Grade" (`-`) * steht vor allen: er ist keine Stufe, sondern ihr Fehlen, und vor der * niedrigsten ist der Platz, an dem das am wenigsten nach einer Aussage * aussieht. */ const EIGENE_REIHENFOLGE: Partial> = { weekday: WOCHENTAGE, paygrade: HAY_GRADE_WERTE, }; function rang(dim: GroupDimension, key: string): number { const liste = EIGENE_REIHENFOLGE[dim]; if (!liste) return 0; const i = liste.indexOf(key); // Unbekanntes hinten, nicht vorn: ein Wert, den die Liste nicht kennt, soll // auffallen und nicht die Leiter anführen. return i === -1 ? liste.length : i; } function sortiereNachReihenfolge(items: T[], dim: GroupDimension): T[] { return [...items].sort((a, b) => rang(dim, a.key) - rang(dim, b.key)); } // Reused by ReportsPageClient (split legend) and the report export route // (split columns) to render a split in the order its values have rather than // in first-encountered order; a no-op for every other dimension. export function sortKeysForDimension(keys: string[], dim: GroupDimension): string[] { return EIGENE_REIHENFOLGE[dim] ? [...keys].sort((a, b) => rang(dim, a) - rang(dim, b)) : keys; } // Every dimension other than `weekday` is a strict single-key partition // (delegates to groupKeyFor); `weekday` returns one key per work day, so an // employee is counted in every day they work — deliberately not a // partition, since that's the whole point of the dimension. export function groupKeysFor(e: ReportEmployee, dim: GroupDimension, lookups: OrgLookups): string[] { if (dim === "weekday") return e.work_days.length > 0 ? e.work_days : ["–"]; return [groupKeyFor(e, dim, lookups)]; } export function measureValue(rows: ReportEmployee[], measure: Measure, asOf: string = todayIso()): number { if (rows.length === 0) return 0; switch (measure) { case "headcount": return rows.length; case "fte": return rows.reduce((s, e) => s + e.weekly_hours / 38.5, 0); case "parttime_rate": return (rows.filter((e) => e.employment_type === "Teilzeit").length / rows.length) * 100; case "avg_age": return rows.reduce((s, e) => s + yearsBetweenIso(e.birth_date, asOf), 0) / rows.length; case "avg_tenure": return rows.reduce((s, e) => s + tenureYearsAsOf(e.entry_date, e.exit_date, asOf), 0) / rows.length; case "female_share": return (rows.filter((e) => e.gender === "w").length / rows.length) * 100; case "avg_dependents": return rows.reduce((s, e) => s + e.dependents_count, 0) / rows.length; default: return 0; } } export type ReportPerson = { id: string; name: string; title: string; team: string; entry_date: string }; export type ReportSplitRow = { key: string; value: number; count: number }; export type ReportRow = { key: string; value: number; count: number; people: ReportPerson[]; split?: ReportSplitRow[] }; export function aggregateReport( employees: ReportEmployee[], measure: Measure, group: GroupDimension, split: GroupDimension | null, lookups: OrgLookups, asOf: string = todayIso() ): ReportRow[] { const byGroup = new Map(); for (const e of employees) { for (const key of groupKeysFor(e, group, lookups)) { if (!byGroup.has(key)) byGroup.set(key, []); byGroup.get(key)!.push(e); } } const rows: ReportRow[] = []; for (const [key, rowsForGroup] of byGroup) { const value = measureValue(rowsForGroup, measure, asOf); const people: ReportPerson[] = rowsForGroup.map((e) => ({ id: e.id, name: fmtName(e.first_name, e.last_name), title: e.job_title, team: e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–", entry_date: e.entry_date, })); const row: ReportRow = { key, value, count: rowsForGroup.length, people }; if (split) { const bySplit = new Map(); for (const e of rowsForGroup) { for (const sKey of groupKeysFor(e, split, lookups)) { if (!bySplit.has(sKey)) bySplit.set(sKey, []); bySplit.get(sKey)!.push(e); } } const splitRows = Array.from(bySplit.entries()).map(([sKey, sRows]) => ({ key: sKey, value: measureValue(sRows, measure, asOf), count: sRows.length, })); row.split = EIGENE_REIHENFOLGE[split] ? sortiereNachReihenfolge(splitRows, split) : splitRows; } rows.push(row); } return EIGENE_REIHENFOLGE[group] ? sortiereNachReihenfolge(rows, group) : rows.sort((a, b) => b.value - a.value); } export function sumValues(rows: { value: number }[]): number { return rows.reduce((s, r) => s + r.value, 0); } // headcount/fte sum across groups; averages/ratios are weighted by each // group's underlying record count for a sensible overall figure. export function totalForRows(rows: { value: number; count: number }[], measure: Measure): number { if (SUM_MEASURES.includes(measure)) return sumValues(rows); const totalCount = rows.reduce((s, r) => s + r.count, 0); if (totalCount === 0) return 0; return rows.reduce((s, r) => s + r.value * r.count, 0) / totalCount; } export const REPORT_PRESETS: { name: string; measure: Measure; group: GroupDimension; split?: GroupDimension }[] = [ { name: "Headcount nach Bereich", measure: "headcount", group: "division" }, { name: "Frauenanteil nach Bereich", measure: "female_share", group: "division" }, { name: "Headcount nach Hay-Grade", measure: "headcount", group: "paygrade" }, { name: "Teilzeitquote nach Standort", measure: "parttime_rate", group: "location" }, { name: "Headcount nach Wochentag", measure: "headcount", group: "weekday" }, { name: "Headcount nach C-Level", measure: "headcount", group: "c_level" }, { name: "Ø Angehörige nach Bereich", measure: "avg_dependents", group: "division" }, ]; // ── Ereignisse (events over a period) ───────────────────────────── // Backed by employee_history, the append-only log — unlike Bestand, this // covers every event type (not just Eintritt/Austritt), survives an // employee's entry_date being overwritten by a later rehire, and each event // keeps its own date/description regardless of the employee's current state. // Sentinel for "von"/"bis" — distinct from "" (unset, falls back to the // current-year default) or a real date. Written to the URL/exports as the // literal string "open". export const EVENT_DATE_OPEN = "open"; export type EventGroupDimension = "event_type" | "division" | "department" | "team" | "location" | "event_year"; export const EVENT_GROUP_LABELS: Record = { event_type: "Ereignistyp", division: "Bereich", department: "Abteilung", team: "Team", location: "Standort", event_year: "Jahr", }; export const EVENT_TYPE_LABELS: Record = { Eintritt: "Eintritt", Beförderung: "Beförderung", Versetzung: "Versetzung", // Stored value stays 'Karenz'; the label follows the renamed concept. Karenz: "Langzeitabwesenheit", Vertragsänderung: "Vertragsänderung", Stammdatenänderung: "Stammdatenänderung", Austritt: "Austritt", Wiedereintritt: "Wiedereintritt", Reorganisation: "Reorganisation", Gehaltsanpassung: "Gehaltsanpassung", Rückkehr: "Rückkehr aus Langzeitabwesenheit", Übernahme: "Übernahme (extern → intern)", }; // ── Was im Berichtemanager zur Auswahl steht ──────────────────────── // // Nicht alle elf. EVENT_TYPE_LABELS bleibt vollständig — die Historie einer // Person zeigt jedes Ereignis, und jedes braucht seine Beschriftung und seine // Farbe. Die *Auswertung* fragt aber nach Bewegungen im Bestand, und zwei // Typen sind dort nur Rauschen: // // * „Stammdatenänderung" entsteht bei jeder geänderten Telefonnummer. // * „Gehaltsanpassung" ist ein totes Ereignis: das Gehalt liegt in Loga, // keine SQL-Funktion schreibt diesen Typ mehr. // // Beide standen in der Liste und lieferten Auswertungen, die niemand wollte. // Aus dem Workshop gestrichen (Anforderung 4). // // „Wiedereintritt" stand dagegen zu Recht schon hier und bekommt in // lib/colors.ts dieselbe grüne Kategorie wie der Eintritt — es ist einer. const NICHT_AUSWERTBAR: readonly HistoryEventType[] = ["Stammdatenänderung", "Gehaltsanpassung"]; export const EREIGNIS_AUSWAHL: readonly HistoryEventType[] = ( Object.keys(EVENT_TYPE_LABELS) as HistoryEventType[] ).filter((t) => !NICHT_AUSWERTBAR.includes(t)); export type ReportEvent = { employee_id: string; first_name: string; last_name: string; job_title: string; /** Die Einheit der Planstelle; null, wenn zum Stichtag keine besetzt war. */ org_unit_id: string | null; location_id: string; event_date: string; event_type: HistoryEventType; description: string; /** * Der Untertyp des Ereignisses — die Beendigungsart bei einem Austritt, die * Art der Abwesenheit bei einer Langzeitabwesenheit. Beides steht auf der * Person und nicht am Ereignis: `employee_history` hält den Verlauf, die * Einzelheiten stehen in `employees`. * * Daraus folgt die Grenze, und sie steht auch in der Oberfläche: nach einer * Wiedereinstellung setzt rehire_employee `exit_reason` auf null zurück, und * der Austritt von damals hat dann keinen Grund mehr. Für die Frage, die * gestellt wird — „wie viele sind dieses Jahr gegangen, und warum" —, ist * das ohne Belang; für eine Auswertung über fünf Jahre wäre es eine Lücke. */ subtype: string | null; }; /** Zu welchen Ereignistypen es überhaupt einen Untertyp gibt. */ export const EREIGNIS_UNTERTYP: Partial> = { Austritt: { label: "Beendigungsart", alle: "Alle Beendigungsarten" }, Karenz: { label: "Art der Langzeitabwesenheit", alle: "Alle Arten" }, }; function eventGroupKeyFor(e: ReportEvent, dim: EventGroupDimension, lookups: OrgLookups): string { switch (dim) { case "event_type": return EVENT_TYPE_LABELS[e.event_type] ?? e.event_type; case "division": return e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "Unbekannt") : "–"; case "department": return e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "–") : "–"; case "team": return e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–"; case "location": return lookups.locationName.get(e.location_id) ?? "Unbekannt"; case "event_year": return e.event_date.slice(0, 4); default: return "Unbekannt"; } } export function aggregateEvents( events: ReportEvent[], group: EventGroupDimension, split: EventGroupDimension | null, lookups: OrgLookups ): ReportRow[] { const byGroup = new Map(); for (const e of events) { const key = eventGroupKeyFor(e, group, lookups); if (!byGroup.has(key)) byGroup.set(key, []); byGroup.get(key)!.push(e); } const rows: ReportRow[] = []; for (const [key, rowsForGroup] of byGroup) { // Repurposes ReportPerson for events: title -> event description, // entry_date -> event_date. Keeps the existing drill-down UI/export // code working unchanged for both report modes. const people: ReportPerson[] = rowsForGroup.map((e) => ({ id: e.employee_id, name: fmtName(e.first_name, e.last_name), title: e.description, team: e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–", entry_date: e.event_date, })); const row: ReportRow = { key, value: rowsForGroup.length, count: rowsForGroup.length, people }; if (split) { const bySplit = new Map(); for (const e of rowsForGroup) { const sKey = eventGroupKeyFor(e, split, lookups); if (!bySplit.has(sKey)) bySplit.set(sKey, []); bySplit.get(sKey)!.push(e); } row.split = Array.from(bySplit.entries()).map(([sKey, sRows]) => ({ key: sKey, value: sRows.length, count: sRows.length })); } rows.push(row); } return rows.sort((a, b) => b.value - a.value); } export const EVENT_REPORT_PRESETS: { name: string; group: EventGroupDimension; split?: EventGroupDimension; eventType?: HistoryEventType }[] = [ { name: "Ereignisse nach Typ", group: "event_type" }, { name: "Eintritte nach Bereich", group: "division", eventType: "Eintritt" }, { name: "Austritte nach Abteilung", group: "department", eventType: "Austritt" }, { name: "Beförderungen nach Bereich", group: "division", eventType: "Beförderung" }, ]; // ── Query-string parsing ────────────────────────────────────────── // The Berichte page and every /api/export/* route read the same handful of // dimension/measure names out of a URL the user fully controls. These used // to be unchecked `as` casts, which let an unknown value through as a real // enum value: it reached GROUP_LABELS[group] as undefined (a literal // "undefined" column header in the export) and was interpolated into the // download filename, i.e. into a Content-Disposition header. Parsing against // the label maps — the same objects that define the legal values — keeps the // two in step by construction. function parseKeyOf(labels: Record, value: string | null | undefined, fallback: T): T { return value && Object.hasOwn(labels, value) ? (value as T) : fallback; } export function parseMode(value: string | null | undefined): "snapshot" | "events" { return value === "events" ? "events" : "snapshot"; } export function parseMeasure(value: string | null | undefined): Measure { return parseKeyOf(MEASURE_LABELS, value, "headcount"); } export function parseGroupDimension(value: string | null | undefined, fallback: GroupDimension = "division"): GroupDimension { return parseKeyOf(GROUP_LABELS, value, fallback); } export function parseEventGroupDimension( value: string | null | undefined, fallback: EventGroupDimension = "event_type" ): EventGroupDimension { return parseKeyOf(EVENT_GROUP_LABELS, value, fallback); } // Unlike the dimensions above, "no split" and "all event types" are legal — // hence null rather than a fallback value for an unrecognized input. export function parseSplitDimension(value: string | null | undefined): GroupDimension | null { return value && Object.hasOwn(GROUP_LABELS, value) ? (value as GroupDimension) : null; } export function parseEventSplitDimension(value: string | null | undefined): EventGroupDimension | null { return value && Object.hasOwn(EVENT_GROUP_LABELS, value) ? (value as EventGroupDimension) : null; } export function parseEventType(value: string | null | undefined): HistoryEventType | null { return value && Object.hasOwn(EVENT_TYPE_LABELS, value) ? (value as HistoryEventType) : null; } /** * Der Untertyp aus der Adresszeile — geprüft gegen die Liste, die zum * gewählten Ereignistyp gehört. * * Ohne den Ereignistyp gibt es keinen gültigen Untertyp: „Wochenhilfe" zu * einem Austritt ist keine Einschränkung, sondern ein leeres Ergebnis mit * unklarer Ursache. */ export function parseEventSubtype(value: string | null | undefined, typ: HistoryEventType | null): string | null { if (!value || !typ || !EREIGNIS_UNTERTYP[typ]) return null; return untertypOptionen(typ).includes(value) ? value : null; } /** Die erlaubten Untertypen eines Ereignistyps. */ export function untertypOptionen(typ: HistoryEventType | null): readonly string[] { if (typ === "Austritt") return BEENDIGUNGSART_WERTE; if (typ === "Karenz") return ABSENCE_TYPES; return []; } // Rejects anything that is not a real calendar date, so a Stichtag from the // URL can never reach a date comparison (or a column header) as free text. export function parseIsoDateParam(value: string | null | undefined): string | undefined { if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined; const d = new Date(`${value}T00:00:00Z`); return Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== value ? undefined : value; } // from/to additionally accept the EVENT_DATE_OPEN sentinel ("this side of // the interval is intentionally unbounded"), which is not a date. export function parseEventDateParam(value: string | null | undefined): string | undefined { return value === EVENT_DATE_OPEN ? EVENT_DATE_OPEN : parseIsoDateParam(value); }