Files
alpenwerk-hr/lib/format.ts
Maximilian Stubhan e958bb5c6b
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m51s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m15s
Stop pretending Vercel is an option
It was never used. The repository lives on a self-hosted Gitea, which
Vercel's git integration cannot connect to at all — so the documented
route amounted to "mirror to GitHub first", and nobody did.

vercel.json is gone, and with it the branch in next.config.ts that
switched off `output: "standalone"` when the VERCEL variable was
present. That branch was the only functional trace; everything else was
documentation and comments describing a second deployment path that did
not exist.

DEPLOYMENT.md loses its "two supported ways" framing and the whole
Vercel section — about fifty lines. Several statements next to it were
stale for a different reason and are corrected in the same pass: the
outbound-firewall table still listed Supabase's pooler (the database is
a container now, nothing leaves the server), the prerequisites still
demanded an existing Supabase project, and the .env table still asked
for a pooler connection string instead of the two new passwords.

The nightly job is described as what it is — a container in
docker-compose.yml — rather than as a replacement for Vercel Cron.

Migrations keep their references: two comments from July mention Vercel
Cron, and they describe what was true when they were written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 11:02:40 +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 (im Container) 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);
}