Files
alpenwerk-hr/lib/format.ts
Maximilian Stubhan 8a76b3688f Show people surname first
Employee names now read "Winkler, Hannah" wherever a person appears in a
list, a table, a heading or a tree node. That is the order a personnel
list is kept in, it is the order people are looked up in, and it finally
matches the sorting — the employee list has always been ordered by
surname, which made an alphabetical page look unsorted.

The name was being assembled inline in about twenty places. A rename
that catches half of them is worse than none, so it now goes through
fmtName in lib/format.ts and every display site calls it.

Sentences keep the natural order: "Hannah Winkler wurde versetzt" reads
like German, "Winkler, Hannah wurde versetzt" reads like a form. So the
toasts are unchanged and only labels moved.

Two things the change would have quietly broken:

The org chart's own filter matched against "first last". It now matches
either order, with or without the comma, so typing what you see works
and so does typing what you remember.

The print model sorted by the last word of the composed name, which
happened to be the surname and is now the first name — every printed
unit would have come out sorted by first name. It sorts on the surname
field itself now, which is what it meant all along.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 11:44:03 +02:00

134 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// This app stores dates as date-only strings ("YYYY-MM-DD") and timestamps as
// timestamptz, and is used from a single timezone. Every conversion here is
// pinned to Europe/Vienna rather than the runtime's zone: the server renders
// in UTC (Docker/Vercel) while the browser renders in Vienna, so an unpinned
// formatter produces a different day on each side — wrong dates for the user
// near midnight, and a React hydration mismatch.
const TIMEZONE = "Europe/Vienna";
const dateTimeFormatter = new Intl.DateTimeFormat("de-AT", {
day: "2-digit",
month: "2-digit",
year: "numeric",
timeZone: TIMEZONE,
});
// en-CA formats as "YYYY-MM-DD", which is the shape the rest of the app and
// the database speak.
const isoFormatter = new Intl.DateTimeFormat("en-CA", {
day: "2-digit",
month: "2-digit",
year: "numeric",
timeZone: TIMEZONE,
});
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
// Today in Vienna. Deliberately not new Date().toISOString().slice(0, 10):
// that is the *UTC* date, which for part of every day is already tomorrow
// relative to Austria.
export function todayIso(): string {
return isoFormatter.format(new Date());
}
// Normalizes either input to a "YYYY-MM-DD" string. A date-only string is
// returned as-is — parsing it into a Date first would anchor it to UTC
// midnight and shift it in any negative-offset zone.
export function toIsoDate(value: string | Date): string {
if (typeof value === "string") return DATE_ONLY.test(value) ? value : isoFormatter.format(new Date(value));
return isoFormatter.format(value);
}
export function fmtDate(date: string | Date | null | undefined): string {
if (!date) return "–";
if (typeof date === "string" && DATE_ONLY.test(date)) {
const [year, month, day] = date.split("-");
return `${day}.${month}.${year}`;
}
const d = typeof date === "string" ? new Date(date) : date;
if (Number.isNaN(d.getTime())) return "–";
return dateTimeFormatter.format(d);
}
export function initials(firstName: string, lastName: string): string {
const a = firstName.trim().charAt(0).toUpperCase();
const b = lastName.trim().charAt(0).toUpperCase();
return `${a}${b}`;
}
// „Mustermann, Max" — der Nachname zuerst.
//
// So steht es in jeder Personalliste, und danach wird gesucht: wer jemanden
// sucht, hat den Nachnamen im Kopf. Nebenbei stimmt die Anzeige damit auch
// mit der Sortierung überein, die schon immer nach Nachnamen ging — vorher
// las sich eine alphabetische Liste, als wäre sie ungeordnet.
//
// Eine Stelle für alle: der Name wurde an gut zwanzig Orten einzeln
// zusammengesetzt, und eine Umstellung, die die Hälfte davon vergisst, ist
// schlimmer als gar keine.
export function fmtName(firstName: string, lastName: string): string {
const nach = lastName?.trim() ?? "";
const vor = firstName?.trim() ?? "";
if (!nach) return vor;
if (!vor) return nach;
return `${nach}, ${vor}`;
}
// "Mustermann, Dr. Max, MSc MBA" — vorangestellte Titel stehen beim
// Vornamen, nachgestellte am Ende. Der Nachname bleibt vorne, damit auch die
// Überschrift einer Personalakte so beginnt wie ihr Eintrag in der Liste.
export function fmtFullName(
firstName: string,
lastName: string,
titlePrefix: string[] | null | undefined,
titleSuffix: string[] | null | undefined
): string {
const prefix = titlePrefix && titlePrefix.length > 0 ? `${titlePrefix.join(" ")} ` : "";
const suffix = titleSuffix && titleSuffix.length > 0 ? `, ${titleSuffix.join(" ")}` : "";
return `${fmtName(`${prefix}${firstName}`, lastName)}${suffix}`;
}
// Whole years between two ISO dates. Compares the "MM-DD" tails as strings,
// which is exact and needs no Date arithmetic at all.
export function yearsBetweenIso(from: string, to: string): number {
const years = Number(to.slice(0, 4)) - Number(from.slice(0, 4));
return to.slice(5) < from.slice(5) ? years - 1 : years;
}
export function fmtAge(birthDate: string | Date): number {
return yearsBetweenIso(toIsoDate(birthDate), todayIso());
}
export function tenure(entryDate: string | Date, endDate?: string | Date | null): string {
const start = toIsoDate(entryDate);
const end = endDate ? toIsoDate(endDate) : todayIso();
let years = Number(end.slice(0, 4)) - Number(start.slice(0, 4));
let months = Number(end.slice(5, 7)) - Number(start.slice(5, 7));
if (Number(end.slice(8, 10)) < Number(start.slice(8, 10))) months -= 1;
if (months < 0) {
years -= 1;
months += 12;
}
if (years < 0) return "0 Monate";
const yearPart = years > 0 ? `${years} ${years === 1 ? "Jahr" : "Jahre"}` : "";
const monthPart = months > 0 ? `${months} ${months === 1 ? "Monat" : "Monate"}` : "";
if (yearPart && monthPart) return `${yearPart}, ${monthPart}`;
return yearPart || monthPart || "unter 1 Monat";
}
// Anchored at UTC midnight on both sides so the difference is a whole number
// of calendar days regardless of DST transitions in between.
export function daysBetweenIso(from: string, to: string = todayIso()): number {
const start = Date.parse(`${from}T00:00:00Z`);
const end = Date.parse(`${to}T00:00:00Z`);
return Math.round((end - start) / 86_400_000);
}
export function addDaysIso(iso: string, days: number): string {
const d = new Date(`${iso}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() + days);
return d.toISOString().slice(0, 10);
}