diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7c7de80 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,74 @@ +# Änderungen + +Was in welcher Fassung dazugekommen ist. Dieselbe Liste steht in der +Anwendung unter **Version und Änderungen**, erreichbar über die +Versionsnummer in der Fußzeile. + +> Diese Datei wird erzeugt: `npm run changelog`. +> Gepflegt wird der Verlauf in `src/lib/changelog.ts`. + +## 1.6.0 — 21.08.2026 + +Sichtbare Version mit Änderungsverlauf. + +- Die laufende Version steht in der Fußzeile und führt auf eine Seite mit dem vollständigen Änderungsverlauf. +- Der Verlauf wird an einer Stelle gepflegt; die CHANGELOG.md im Projekt entsteht daraus. +- Die Roadmap verweist für Vergangenes auf den Changelog und behält den Blick nach vorn. + +## 1.5.0 — 20.08.2026 + +Statische Codes lassen sich benennen und ablegen. + +- Das Feld „Name“ gilt in beiden Betriebsarten, nicht mehr nur bei dynamischen Codes. +- Der Name bestimmt den Dateinamen beim Herunterladen. +- Ein statischer Code lässt sich in „Meine Codes“ ablegen — als Schnappschuss ohne Kurz-Link und ohne Scan-Zähler. + +## 1.4.0 — 17.08.2026 + +Sprechende Kurz-Links und eine früh erkannte Fehlkonfiguration. + +- Kurz-Links selbst vergeben und nachträglich ändern, mit „/“ als Trenner. +- Die vorherige Adresse bleibt gültig und lässt sich einzeln freigeben — gedruckte Codes bleiben gültig. +- Vergeben und Ändern ist Administratoren vorbehalten und in der Verwaltung für Bearbeiter freigebbar. +- Weicht BASE_URL von der aufgerufenen Adresse ab, meldet die Anmeldung das sofort statt erst am Ende. +- Auf der Anmeldeseite steht nicht mehr, wie die Anmeldung konfiguriert ist. + +## 1.3.0 — 16.08.2026 + +Eigenes Erscheinungsbild und ein Dark Mode. + +- Name, Logo, Favicon und Akzentfarbe sind im Admin-Bereich einstellbar. +- Hell und dunkel folgen dem Betriebssystem, mit Umschalter für eine eigene Wahl. +- Das Update-Skript erklärt, warum ein Vorspulen scheitert, statt nur abzubrechen. +- Die Authentik-Anleitung sagt deutlicher, wie die Redirect URI zu bilden ist. + +## 1.2.0 — 15.08.2026 + +Single Sign-on, Dokumentation und eine Sicherheitsdurchsicht. + +- Anmeldung über Authentik und andere OIDC-Anbieter, mit PKCE und geprüftem ID-Token. +- Bedienungsanleitung mit Bildern, Funktionsübersicht und Roadmap. +- Anmeldung und Sitzungen nach OWASP durchgesehen und gehärtet: stärkere Passwort-Parameter, Längengrenze, Herkunftsprüfung, Schutzheader, absolute Sitzungsdauer. +- Auf der Startseite steht „nur dynamisch“ statt eines nichtssagenden Abzeichens an jeder Kachel. + +## 1.1.0 — 14.08.2026 + +Alle fünfzehn QR-Typen, Anmeldung, Gruppen und mehrere Domains. + +- Vierzehn weitere QR-Typen — von vCard und WLAN bis PDF, Bild und Feedback. +- Landingpages als eigene Gattung für Typen ohne fremdes Ziel. +- Datei-Upload mit Inhaltsprüfung, Größengrenze und Aufräumen. +- Anmeldung, Benutzer, Gruppen und die Rollen Administrator, Bearbeiter und Betrachter. +- Mehrere Domains für Kurz-URLs, den Gruppen zugeteilt. +- „Meine Codes“ als Liste mit Suche, Sortierung und Gruppenfilter. +- Einzelne QR-Typen lassen sich in der Verwaltung ausblenden. + +## 1.0.0 — 13.08.2026 + +Erste lauffähige Fassung. + +- Next.js-Anwendung und PostgreSQL als Docker-Compose-Stack, Migrationen beim Start. +- QR-Typ Website, statisch und dynamisch, mit Kurz-URL, Weiterleitung und Scan-Zähler. +- Gestaltung mit Farben, Kontrastwarnung, Größe, Fehlerkorrektur und Rand. +- Download als PNG und SVG, serverseitig gerendert. +- Update-Skript für Linux, macOS und Windows. diff --git a/README.md b/README.md index 4d1d7d5..86826ca 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden. eigene Wahl. - **Sprechende Kurz-Links:** `qr.firma.de/r/sommeraktion` statt Zufallszeichen, nachträglich änderbar — die vorherige Adresse bleibt gültig. +- **Sichtbare Version:** Die Fußzeile nennt die laufende Fassung und führt + zum Änderungsverlauf — nach einem Update sofort erkennbar, ob es ankam. - **Zwei Container, kein Reverse Proxy nötig.** Migrationen laufen beim Start von selbst; alles Nötige steht in einer `.env`. @@ -48,7 +50,8 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden. - **[Anmeldung über Authentik (OIDC)](docs/authentik.md)** — Einrichtung, Rollen und der Weg zurück - **[Alle Funktionen im Detail](docs/funktionen.md)** — die lange Fassung -- **[Roadmap](docs/roadmap.md)** — was gebaut ist und was kommen könnte +- **[Änderungen](CHANGELOG.md)** — was in welcher Version dazugekommen ist +- **[Roadmap](docs/roadmap.md)** — was als Nächstes kommen könnte - **[Anmeldung und Sitzungen nach OWASP](docs/sicherheit.md)** — Durchsicht, Härtungen und was bewusst offen bleibt diff --git a/docs/funktionen.md b/docs/funktionen.md index 1f7977d..1662174 100644 --- a/docs/funktionen.md +++ b/docs/funktionen.md @@ -500,6 +500,21 @@ alles zeigt und Typen aus späteren Versionen automatisch erscheinen. --- +## Version und Änderungen + +Die Fußzeile nennt die laufende Version und führt auf eine Seite mit dem +vollständigen Änderungsverlauf. Nach einem Update ist das die schnellste +Antwort auf die Frage, ob der Container wirklich mit dem neuen Abbild läuft: +Steht dort noch die alte Nummer, ist er es nicht. + +Der Verlauf wird an **einer** Stelle gepflegt (`src/lib/changelog.ts`). +Daraus entstehen sowohl die Seite in der Anwendung als auch die +[CHANGELOG.md](../CHANGELOG.md) im Projekt (`npm run changelog`); eine +Prüfung wacht darüber, dass beides zusammenpasst. Zu jeder Version gibt es +einen Git-Tag. + +--- + ## Schnittstelle Alles, was die Oberfläche tut, geht auch über HTTP: diff --git a/docs/roadmap.md b/docs/roadmap.md index eacefc1..85d1092 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -7,94 +7,16 @@ Aufwände sind grobe Einschätzungen, keine Zusagen. ## Gebaut -### Version 1 — Grundgerüst (13.08.2026) +Der vollständige Verlauf mit Versionsnummern steht im +[Changelog](../CHANGELOG.md) — und in der Anwendung selbst unter +**Version und Änderungen**, erreichbar über die Versionsnummer in der +Fußzeile. -- Next.js-Anwendung und PostgreSQL als Docker-Compose-Stack, ohne Reverse - Proxy, Migrationen beim Start -- QR-Typ-Registry als Dispatch-Tabelle — ein neuer Typ ist ein Modul plus ein - Formular, ohne Eingriff in den übrigen Code -- Typ **Website**, statisch und dynamisch, mit Kurz-URL, 302-Weiterleitung und - Scan-Zähler -- Gestaltung: Farben mit Kontrastwarnung, Größe, Fehlerkorrektur, Rand -- Download als PNG und SVG, serverseitig gerendert -- „Meine Codes“ mit Ziel ändern und Löschen -- Deutsche Oberfläche, deutsche Fehlermeldungen, Validierung mit Zod -- Update-Skript für Linux/macOS und Windows - -### Typen einzeln freigeben (14.08.2026) - -Ausblenden nicht gebrauchter Typen in der Verwaltung. Bereits erstellte Codes -eines abgeschalteten Typs funktionieren weiter. - -### Phase A — acht weitere Typen (14.08.2026) - -vCard, App-Link, WhatsApp, E-Mail, SMS, Standort, Zahlung (GiroCode und -Zahlungslink), WLAN. - -### Phase B1 — Anmeldung, Gruppen, Rollen (14.08.2026) - -Abschaltbare Anmeldung, Benutzerverwaltung, Gruppen mit den Rollen -Administrator, Bearbeiter und Betrachter, Sitzungen in der Datenbank, -scrypt-Passwörter, Sperre nach Fehlversuchen. - -### Phase B2 — mehrere Domains (14.08.2026) - -Domains für Kurz-URLs, den Gruppen zugeteilt. Slug je Domain eindeutig, -Host-Header gegen die registrierten Domains geprüft. - -### Listenansicht (14.08.2026) - -„Meine Codes“ als Liste mit Suche, Sortierung und Gruppenfilter — gedacht für -viele Projekte nebeneinander. Karten bleiben als zweite Ansicht. - -### Phase C — die letzten sechs Typen (14.08.2026) - -Mehrere Links, Social Media, Video, PDF, Bild, Feedback. Dazu: - -- Landingpages als eigene Gattung (`/s/{slug}`), ohne App-Navigation -- Datei-Upload mit Inhaltsprüfung, Größengrenze und Aufräumen -- Feedback mit anonymer Erhebung und Auswertung - -Damit sind **alle 15 vorgesehenen Typen** gebaut. - -### Single Sign-on (15.08.2026) - -Anmeldung über Authentik und andere OIDC-Anbieter, mit PKCE und geprüftem -ID-Token. - -### Erscheinungsbild (16.08.2026) - -Name, Logo, Favicon und Akzentfarbe im Admin-Bereich einstellbar. Die Farbe -wirkt über die Farbvariablen, die Tailwind ohnehin benutzt — deshalb ohne -Umbau der rund 60 eingefärbten Stellen. - -### Dark Mode (16.08.2026) - -Folgt dem Betriebssystem, mit Umschalter für Hell, Dunkel und System. -Umgesetzt über `light-dark()` statt über `dark:`-Klassen — deshalb ohne -Änderung an den rund 440 Farbklassen und ohne JavaScript für die Systemwahl. - -### Sprechende Kurz-Links (17.08.2026) - -Kurz-Links selbst vergeben und nachträglich ändern, mit `/` als Trenner. Die -vorherige Adresse bleibt gültig und lässt sich einzeln freigeben — sonst würde -jedes Umbenennen gedruckte Codes entwerten. Standardmäßig Administratoren -vorbehalten, per Einstellung für Bearbeiter freigebbar. - -Schließt [Issue #1](https://git.wilde-server.de/florian.wilde/qrcode-generator/issues/1). - -### Statische Codes benennen und ablegen (20.08.2026) - -Das Namensfeld gilt in beiden Betriebsarten. Es bestimmt den Dateinamen beim -Herunterladen und erlaubt, einen statischen Code in „Meine Codes" abzulegen — -als Schnappschuss, dessen Inhalt sich nicht mehr ändert. - -Schließt [Issue #2](https://git.wilde-server.de/florian.wilde/qrcode-generator/issues/2). - -### Dokumentation (15.08.2026) - -Bedienungsanleitung mit Bildern, Authentik-Anleitung, Funktionsübersicht. -Die Bilder erzeugt ein Skript, damit sie nicht veralten. +Kurz gefasst: Version 1.0.0 (13.08.2026) brachte das Grundgerüst mit dem Typ +Website, 1.1.0 alle fünfzehn Typen samt Anmeldung, Gruppen und Domains, +1.2.0 Single Sign-on und die Sicherheitsdurchsicht, 1.3.0 Erscheinungsbild +und Dark Mode, 1.4.0 sprechende Kurz-Links, 1.5.0 benennbare statische Codes +und 1.6.0 diesen Versionshinweis. --- diff --git a/package.json b/package.json index 8a222e2..7423cb9 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "qrcode-generator", - "version": "1.1.0", + "version": "1.6.0", "private": true, "scripts": { "dev": "next dev", "build": "next build", "start": "next start", + "changelog": "node scripts/changelog.mjs", "lint": "eslint", "typecheck": "tsc --noEmit", "db:generate": "prisma generate", diff --git a/scripts/changelog.mjs b/scripts/changelog.mjs new file mode 100644 index 0000000..16ff5cc --- /dev/null +++ b/scripts/changelog.mjs @@ -0,0 +1,84 @@ +/** + * Schreibt CHANGELOG.md aus src/lib/changelog.ts. + * + * node scripts/changelog.mjs erzeugen + * node scripts/changelog.mjs --check nur prüfen, ob die Datei aktuell ist + * + * Warum erzeugt und nicht von Hand gepflegt: Die Anwendung zeigt denselben + * Verlauf unter /version an. Zwei gepflegte Listen liefen unweigerlich + * auseinander — und ein Changelog, der nicht stimmt, ist schlimmer als keiner. + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const wurzel = join(dirname(fileURLToPath(import.meta.url)), ".."); +const ziel = join(wurzel, "CHANGELOG.md"); + +/** + * Die Quelle ist TypeScript, dieses Skript aber schlichtes Node. Statt einen + * Übersetzer dazuzunehmen, wird der Datenteil gelesen und ausgewertet — er + * besteht ausschließlich aus Zeichenketten und Listen. + */ +function ladeVerlauf() { + const quelle = readFileSync(join(wurzel, "src", "lib", "changelog.ts"), "utf8"); + const anfang = quelle.indexOf("export const CHANGELOG"); + if (anfang === -1) throw new Error("CHANGELOG-Liste nicht gefunden."); + + const start = quelle.indexOf("[", anfang); + const ende = quelle.indexOf("\n];", start); + if (start === -1 || ende === -1) throw new Error("Liste nicht abgrenzbar."); + + const literal = quelle.slice(start, ende + 2); + return Function(`"use strict"; return (${literal});`)(); +} + +function formatiereDatum(iso) { + const [jahr, monat, tag] = iso.split("-"); + return `${tag}.${monat}.${jahr}`; +} + +function baue(verlauf) { + const zeilen = [ + "# Änderungen", + "", + "Was in welcher Fassung dazugekommen ist. Dieselbe Liste steht in der", + "Anwendung unter **Version und Änderungen**, erreichbar über die", + "Versionsnummer in der Fußzeile.", + "", + "> Diese Datei wird erzeugt: `npm run changelog`.", + "> Gepflegt wird der Verlauf in `src/lib/changelog.ts`.", + "", + ]; + + for (const eintrag of verlauf) { + zeilen.push(`## ${eintrag.version} — ${formatiereDatum(eintrag.date)}`, ""); + zeilen.push(eintrag.summary, ""); + for (const änderung of eintrag.changes) zeilen.push(`- ${änderung}`); + zeilen.push(""); + } + + return zeilen.join("\n"); +} + +const inhalt = baue(ladeVerlauf()); + +if (process.argv.includes("--check")) { + let vorhanden = ""; + try { + vorhanden = readFileSync(ziel, "utf8"); + } catch { + console.error("CHANGELOG.md fehlt. Erzeugen mit: npm run changelog"); + process.exit(1); + } + if (vorhanden !== inhalt) { + console.error( + "CHANGELOG.md passt nicht mehr zu src/lib/changelog.ts. Neu erzeugen mit: npm run changelog", + ); + process.exit(1); + } + console.log("CHANGELOG.md ist aktuell."); +} else { + writeFileSync(ziel, inhalt, "utf8"); + console.log("CHANGELOG.md geschrieben."); +} diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 91f362c..c2cd6ee 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -6,6 +6,7 @@ import { ThemeToggle } from "@/components/theme-toggle"; import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor"; import { can } from "@/lib/auth/policy"; import { getBranding } from "@/lib/branding"; +import { APP_VERSION } from "@/lib/changelog"; // Der Kopfbereich hängt von der Anmeldung ab und darf nicht vorgerendert // werden. @@ -128,9 +129,20 @@ export default async function AppLayout({ children }: LayoutProps<"/">) {
{children}
diff --git a/src/app/(app)/version/page.tsx b/src/app/(app)/version/page.tsx new file mode 100644 index 0000000..26f6c23 --- /dev/null +++ b/src/app/(app)/version/page.tsx @@ -0,0 +1,83 @@ +import type { Metadata } from "next"; +import Link from "next/link"; + +import { Icon } from "@/components/icons"; +import { Badge, Card } from "@/components/ui"; +import { requirePageActor } from "@/lib/auth/guard"; +import { + APP_VERSION, + CHANGELOG, + formatChangelogDate, +} from "@/lib/changelog"; + +export const metadata: Metadata = { title: "Version und Änderungen" }; + +export const dynamic = "force-dynamic"; + +export default async function VersionPage() { + await requirePageActor(); + + return ( +
+ + + Zur Typ-Auswahl + + +
+

+ Version und Änderungen +

+

+ Hier läuft Version{" "} + {APP_VERSION}. + Darunter steht, was in welcher Fassung dazugekommen ist. +

+

+ Nach einem Update lohnt ein Blick hierher: Steht oben noch die alte + Nummer, läuft der Container noch mit dem alten Abbild. +

+
+ +
    + {CHANGELOG.map((eintrag) => { + const laeuft = eintrag.version === APP_VERSION; + return ( +
  1. + +
    +

    + {eintrag.version} +

    + + {formatChangelogDate(eintrag.date)} + + {laeuft ? ( + läuft hier + ) : null} +
    + +

    {eintrag.summary}

    + +
      + {eintrag.changes.map((zeile) => ( +
    • +
    • + ))} +
    +
    +
  2. + ); + })} +
+
+ ); +} diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts new file mode 100644 index 0000000..d6d8c5e --- /dev/null +++ b/src/lib/changelog.ts @@ -0,0 +1,126 @@ +import pkg from "../../package.json"; + +/** + * Änderungsverlauf der Anwendung — die einzige Quelle dafür. + * + * Hieraus entsteht beides: die Seite unter `/version` und die `CHANGELOG.md` + * im Wurzelverzeichnis (`npm run changelog`). Zwei gepflegte Listen liefen + * unweigerlich auseinander; eine Prüfung wacht darüber, dass die erzeugte + * Datei zu dieser Quelle passt. + * + * Die Einträge sind aus dem Abschnitt „Gebaut“ der Roadmap und der + * Git-Historie zusammengetragen. Versionsnummern gab es dabei nie — es + * existieren keine Tags —, deshalb ist je **Erscheinungstag** eine Version + * gebildet. Das ist nachträglich vergeben, aber die Daten selbst stimmen. + */ +export type ChangelogEntry = { + version: string; + /** ISO-Datum, an dem der Stand entstanden ist. */ + date: string; + /** Eine Zeile zur Einordnung — was diese Version ausmacht. */ + summary: string; + changes: string[]; +}; + +export const CHANGELOG: readonly ChangelogEntry[] = [ + { + version: "1.6.0", + date: "2026-08-21", + summary: "Sichtbare Version mit Änderungsverlauf.", + changes: [ + "Die laufende Version steht in der Fußzeile und führt auf eine Seite mit dem vollständigen Änderungsverlauf.", + "Der Verlauf wird an einer Stelle gepflegt; die CHANGELOG.md im Projekt entsteht daraus.", + "Die Roadmap verweist für Vergangenes auf den Changelog und behält den Blick nach vorn.", + ], + }, + { + version: "1.5.0", + date: "2026-08-20", + summary: "Statische Codes lassen sich benennen und ablegen.", + changes: [ + "Das Feld „Name“ gilt in beiden Betriebsarten, nicht mehr nur bei dynamischen Codes.", + "Der Name bestimmt den Dateinamen beim Herunterladen.", + "Ein statischer Code lässt sich in „Meine Codes“ ablegen — als Schnappschuss ohne Kurz-Link und ohne Scan-Zähler.", + ], + }, + { + version: "1.4.0", + date: "2026-08-17", + summary: "Sprechende Kurz-Links und eine früh erkannte Fehlkonfiguration.", + changes: [ + "Kurz-Links selbst vergeben und nachträglich ändern, mit „/“ als Trenner.", + "Die vorherige Adresse bleibt gültig und lässt sich einzeln freigeben — gedruckte Codes bleiben gültig.", + "Vergeben und Ändern ist Administratoren vorbehalten und in der Verwaltung für Bearbeiter freigebbar.", + "Weicht BASE_URL von der aufgerufenen Adresse ab, meldet die Anmeldung das sofort statt erst am Ende.", + "Auf der Anmeldeseite steht nicht mehr, wie die Anmeldung konfiguriert ist.", + ], + }, + { + version: "1.3.0", + date: "2026-08-16", + summary: "Eigenes Erscheinungsbild und ein Dark Mode.", + changes: [ + "Name, Logo, Favicon und Akzentfarbe sind im Admin-Bereich einstellbar.", + "Hell und dunkel folgen dem Betriebssystem, mit Umschalter für eine eigene Wahl.", + "Das Update-Skript erklärt, warum ein Vorspulen scheitert, statt nur abzubrechen.", + "Die Authentik-Anleitung sagt deutlicher, wie die Redirect URI zu bilden ist.", + ], + }, + { + version: "1.2.0", + date: "2026-08-15", + summary: "Single Sign-on, Dokumentation und eine Sicherheitsdurchsicht.", + changes: [ + "Anmeldung über Authentik und andere OIDC-Anbieter, mit PKCE und geprüftem ID-Token.", + "Bedienungsanleitung mit Bildern, Funktionsübersicht und Roadmap.", + "Anmeldung und Sitzungen nach OWASP durchgesehen und gehärtet: stärkere Passwort-Parameter, Längengrenze, Herkunftsprüfung, Schutzheader, absolute Sitzungsdauer.", + "Auf der Startseite steht „nur dynamisch“ statt eines nichtssagenden Abzeichens an jeder Kachel.", + ], + }, + { + version: "1.1.0", + date: "2026-08-14", + summary: "Alle fünfzehn QR-Typen, Anmeldung, Gruppen und mehrere Domains.", + changes: [ + "Vierzehn weitere QR-Typen — von vCard und WLAN bis PDF, Bild und Feedback.", + "Landingpages als eigene Gattung für Typen ohne fremdes Ziel.", + "Datei-Upload mit Inhaltsprüfung, Größengrenze und Aufräumen.", + "Anmeldung, Benutzer, Gruppen und die Rollen Administrator, Bearbeiter und Betrachter.", + "Mehrere Domains für Kurz-URLs, den Gruppen zugeteilt.", + "„Meine Codes“ als Liste mit Suche, Sortierung und Gruppenfilter.", + "Einzelne QR-Typen lassen sich in der Verwaltung ausblenden.", + ], + }, + { + version: "1.0.0", + date: "2026-08-13", + summary: "Erste lauffähige Fassung.", + changes: [ + "Next.js-Anwendung und PostgreSQL als Docker-Compose-Stack, Migrationen beim Start.", + "QR-Typ Website, statisch und dynamisch, mit Kurz-URL, Weiterleitung und Scan-Zähler.", + "Gestaltung mit Farben, Kontrastwarnung, Größe, Fehlerkorrektur und Rand.", + "Download als PNG und SVG, serverseitig gerendert.", + "Update-Skript für Linux, macOS und Windows.", + ], + }, +]; + +/** + * Die laufende Version. + * + * Aus der `package.json`, damit es genau eine Stelle gibt, an der sie steht. + * Der Wert wird beim Bauen eingesetzt — im Container liegt kein Git, aus dem + * sich sonst etwas ableiten ließe. + */ +export const APP_VERSION: string = pkg.version; + +/** Der Eintrag zur laufenden Version, falls er im Verlauf steht. */ +export function currentEntry(): ChangelogEntry | undefined { + return CHANGELOG.find((eintrag) => eintrag.version === APP_VERSION); +} + +/** Datum als „21.08.2026“ — ohne Abhängigkeit von der Zeitzone des Servers. */ +export function formatChangelogDate(iso: string): string { + const [jahr, monat, tag] = iso.split("-"); + return `${tag}.${monat}.${jahr}`; +}