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>
134 lines
5.2 KiB
TypeScript
134 lines
5.2 KiB
TypeScript
// 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);
|
||
}
|