The database moved to a container of our own; the platform is gone. This takes out what was left of it — and, where the leftovers were load bearing, moves rather than deletes. Moved, not deleted: supabase/migrations/ -> db/migrations/ the schema's source of truth supabase/build-org.ts -> scripts/build-org.ts lib/supabase/types.ts -> lib/types.ts 52 import sites repointed The bookkeeping needed care. It lived in `supabase_migrations.schema_migrations`, and simply renaming the schema would have left the runner facing an empty table: it would have called all 67 migrations pending and replayed them against a database that is long since current. So the runner now creates `migrationen.schema_migrations` and, once, copies the old rows across — guarded so a second run does nothing and a fresh database skips it entirely. Only then does migration 20260907100000 drop the old schema. Deleted: the CLI config, the seed, the historical schema/function dumps (nothing read them), scripts/umzug-von-supabase.sh (the move is done), and both Supabase packages plus the CLI. Nothing in the application imported them — the build now succeeds with no environment variables at all, which is the proof. Integration tests: six of them signed in through Supabase Auth and asserted against the anon key and the service role. That model is gone, so the tests were not portable — they are deleted. session-context and employee-status-filter already ran on pg and are untouched; om-reporting is ported to a direct connection because it guards a real risk (the reporting line rule exists twice, once in SQL and once in TypeScript). CI: the integration job started a Supabase stack. It now runs a postgres service, applies deploy/db-init and every migration to an empty database — that was the valuable part, and it still holds — then checks that a second run is a no-op, which is what proves the bookkeeping works. Docs: security-review.md audited a service-role key, a cookie adapter and auth.users, none of which exist. Restating findings about removed components would suggest today's system had been reviewed; it has not. It now records what was removed and says a fresh review is due. data-model.md was already marked obsolete and described the pre-OM schema; azure-migration.md was a plan for a route not taken. Both deleted. Verified: npm ci, typecheck, lint, 445 tests, build — all clean without the packages. Integration tests skip cleanly with no database. Migration SQL and the runner are reviewed but NOT executed: no Docker here, and the old instance no longer resolves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
500 lines
20 KiB
TypeScript
500 lines
20 KiB
TypeScript
import { fmtName, todayIso, yearsBetweenIso } from "./format";
|
||
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<Measure, string> = {
|
||
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<GroupDimension, string> = {
|
||
division: "Bereich",
|
||
department: "Abteilung",
|
||
team: "Team",
|
||
location: "Standort",
|
||
status: "Status",
|
||
employment_type: "Beschäftigung",
|
||
contract_type: "Vertragsart",
|
||
entry_year: "Eintrittsjahr",
|
||
source: "Intern/Extern",
|
||
paygrade: "Paygrade",
|
||
worker_type: "Angestellte:r / Arbeiter:in",
|
||
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<string, string>;
|
||
departmentName: Map<string, string>;
|
||
teamName: Map<string, string>;
|
||
locationName: Map<string, string>;
|
||
};
|
||
|
||
// 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.
|
||
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.entry_date > asOf) return "Geplant";
|
||
if (e.exit_date && e.exit_date <= asOf) return "Ausgetreten";
|
||
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";
|
||
}
|
||
}
|
||
|
||
const WEEKDAY_ORDER: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||
|
||
function weekdayRank(key: string): number {
|
||
const i = WEEKDAY_ORDER.indexOf(key as Weekday);
|
||
return i === -1 ? WEEKDAY_ORDER.length : i;
|
||
}
|
||
|
||
function sortByWeekday<T extends { key: string }>(items: T[]): T[] {
|
||
return [...items].sort((a, b) => weekdayRank(a.key) - weekdayRank(b.key));
|
||
}
|
||
|
||
// Reused by ReportsPageClient (split legend) and the report export route
|
||
// (split columns) to render a `weekday` split chronologically rather than
|
||
// in first-encountered order; a no-op for every other dimension.
|
||
export function sortKeysForDimension(keys: string[], dim: GroupDimension): string[] {
|
||
return dim === "weekday" ? [...keys].sort((a, b) => weekdayRank(a) - weekdayRank(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<string, ReportEmployee[]>();
|
||
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<string, ReportEmployee[]>();
|
||
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 = split === "weekday" ? sortByWeekday(splitRows) : splitRows;
|
||
}
|
||
rows.push(row);
|
||
}
|
||
return group === "weekday" ? sortByWeekday(rows) : 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 Paygrade", 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<EventGroupDimension, string> = {
|
||
event_type: "Ereignistyp",
|
||
division: "Bereich",
|
||
department: "Abteilung",
|
||
team: "Team",
|
||
location: "Standort",
|
||
event_year: "Jahr",
|
||
};
|
||
|
||
export const EVENT_TYPE_LABELS: Record<HistoryEventType, string> = {
|
||
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",
|
||
};
|
||
|
||
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;
|
||
};
|
||
|
||
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<string, ReportEvent[]>();
|
||
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<string, ReportEvent[]>();
|
||
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<T extends string>(labels: Record<T, string>, 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;
|
||
}
|
||
|
||
// 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);
|
||
}
|