Merge branch 'phase/002-design-layout'

This commit is contained in:
2026-10-12 09:21:46 +02:00
61 changed files with 2258 additions and 14 deletions
+89
View File
@@ -0,0 +1,89 @@
/**
* Datums- und Zeitformate der Oberfläche (#002): TT.MM.JJJJ, 24-h-Zeit, deutsch.
*
* Die Zeitzone ist fest Europe/Berlin und nicht die des Prozesses: Server (UTC im
* Container) und Browser sollen dieselbe Zeit anzeigen, sonst springen Zeitstempel
* zwischen Server- und Client-Rendering um Stunden.
*/
export const ZEITZONE = "Europe/Berlin";
type Eingabe = Date | string | number;
const DATUM = new Intl.DateTimeFormat("de-DE", {
timeZone: ZEITZONE,
day: "2-digit",
month: "2-digit",
year: "numeric",
});
const UHRZEIT = new Intl.DateTimeFormat("de-DE", {
timeZone: ZEITZONE,
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
});
function alsDatum(wert: Eingabe): Date {
const d = wert instanceof Date ? wert : new Date(wert);
if (Number.isNaN(d.getTime())) throw new RangeError(`Ungültiges Datum: ${String(wert)}`);
return d;
}
/** „12.10.2026“ */
export function datum(wert: Eingabe): string {
return DATUM.format(alsDatum(wert));
}
/** „08:42“ */
export function uhrzeit(wert: Eingabe): string {
return UHRZEIT.format(alsDatum(wert));
}
/** „12.10.2026, 08:42“ */
export function datumZeit(wert: Eingabe): string {
const d = alsDatum(wert);
return `${DATUM.format(d)}, ${UHRZEIT.format(d)}`;
}
const MINUTE = 60_000;
const STUNDE = 60 * MINUTE;
const TAG = 24 * STUNDE;
/**
* Grobe Relativangabe für Listen: „gerade eben“, „vor 5 Min.“, „vor 3 Std.“,
* „vor 2 Tagen“, „in 12 Tagen“. Ab 30 Tagen Abstand wird das Datum gezeigt, weil
* „vor 47 Tagen“ schlechter einzuordnen ist als ein Datum.
*
* `jetzt` ist ein Parameter, damit Tests und statisch gerenderte Seiten einen
* festen Bezugspunkt haben.
*/
export function relativ(wert: Eingabe, jetzt: Eingabe = new Date()): string {
const abstand = alsDatum(wert).getTime() - alsDatum(jetzt).getTime();
const betrag = Math.abs(abstand);
const zukunft = abstand > 0;
if (betrag < MINUTE) return "gerade eben";
if (betrag >= 30 * TAG) return `am ${datum(wert)}`;
let text: string;
if (betrag < STUNDE) {
text = `${Math.floor(betrag / MINUTE)} Min.`;
} else if (betrag < TAG) {
text = `${Math.floor(betrag / STUNDE)} Std.`;
} else {
const tage = Math.floor(betrag / TAG);
// „vor“ und „in“ verlangen beide den Dativ: „vor 1 Tag“, „in 2 Tagen“.
text = tage === 1 ? "1 Tag" : `${tage} Tagen`;
}
return zukunft ? `in ${text}` : `vor ${text}`;
}
/** Ganze Kalendertage bis `wert` (negativ, wenn vorbei) – Grundlage für Ablaufhinweise. */
export function tageBis(wert: Eingabe, jetzt: Eingabe = new Date()): number {
const tagesnummer = (d: Date) => {
const [t, m, j] = DATUM.format(d).split(".").map(Number);
return Date.UTC(j ?? 0, (m ?? 1) - 1, t ?? 1) / TAG;
};
return Math.round(tagesnummer(alsDatum(wert)) - tagesnummer(alsDatum(jetzt)));
}