feat(260925-bow): Was-ist-neu-Fenster im Portal-Rahmen

- release-notes.ts: reine Auswahl der Versionsabschnitte aus CHANGELOG.md
- release-notice-actions.ts ('use server'): Abruf und Merken ueber die API
- ReleaseNoticeDialog: barrierefreies Fenster, Eintraege ueber ChangelogView (variant plain)
- ReleaseNoticeHost in AppShell: einmal je Seitenladung, merkt erst beim Schliessen
- Texte releaseNotice in de.json und en.json

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-25 08:48:25 +02:00
parent cf7784e409
commit 187fb76c91
8 changed files with 577 additions and 2 deletions
+159
View File
@@ -0,0 +1,159 @@
import { compareReleaseVersions, parseReleaseVersion } from '@tessera/shared';
/**
* Auswahl der Versionsabschnitte fuer das "Was ist neu"-Fenster
* (quick-260925-bow, D-03/D-04).
*
* Reine Funktionen ueber dem Markdown-Text von CHANGELOG.md. Diese Datei
* importiert bewusst NICHT `@/lib/changelog` und keine React-/Next-Module:
* ihre Typen werden auch von Client-Komponenten (Fenster, Host) benutzt, und
* der Text der Aenderungsliste darf nicht in oeffentliche Client-Chunks
* gelangen. Den Text reicht die Server-Aktion `release-notice-actions.ts`
* herein.
*
* Regeln:
* - Nur `## `-Abschnitte, deren erstes Wort eine freigegebene Version ist
* (`parseReleaseVersion`); "Unveröffentlicht" und alles Unparsebare fallen weg.
* - Darin nur die Gruppen "Neu" (new), "Geändert" (changed, im Fenster
* "Verbessert") und "Behoben" (fixed), in dieser festen Reihenfolge; andere
* Gruppen ("Entfernt" ...) und Gruppen ohne Listenpunkt fallen weg, eine
* Version ohne verbleibende Gruppe ebenso.
* - Gezeigt wird jede Version mit gemerkt < Version ≤ laufend, neueste zuerst,
* hoechstens `RELEASE_NOTICE_MAX_VERSIONS`; der Rest zaehlt in
* `omittedCount`. Ohne (gueltigen) gemerkten Stand nur die laufende Version.
* - Fehlt der Abschnitt der laufenden Version ganz (Web-Abbild passt nicht
* zur API), gibt es kein Fenster.
*/
export type ReleaseSectionKind = 'new' | 'changed' | 'fixed';
export interface ReleaseNotesSection {
kind: ReleaseSectionKind;
/** Nur die Zeilen unter der Gruppenueberschrift, Leerzeilen am Rand entfernt. */
markdown: string;
}
export interface ReleaseNotesVersion {
version: string;
sections: ReleaseNotesSection[];
}
export interface ReleaseNotice {
currentRelease: string;
versions: ReleaseNotesVersion[];
omittedCount: number;
}
export const RELEASE_NOTICE_MAX_VERSIONS = 3;
const VERSION_HEADING_RE = /^## /;
const GROUP_HEADING_RE = /^### /;
const LIST_ITEM_RE = /^\s*[-*] /;
const GROUP_KIND: Record<string, ReleaseSectionKind> = {
Neu: 'new',
Geändert: 'changed',
Behoben: 'fixed',
};
const KIND_ORDER: ReleaseSectionKind[] = ['new', 'changed', 'fixed'];
function trimBlankLines(lines: string[]): string[] {
let start = 0;
let end = lines.length;
while (start < end && lines[start].trim() === '') start++;
while (end > start && lines[end - 1].trim() === '') end--;
return lines.slice(start, end);
}
/** Alle freigegebenen Versionen der Liste, auch solche ohne gezeigte Gruppe. */
function parseAllReleases(markdown: string): ReleaseNotesVersion[] {
const lines = markdown.replace(/\r\n?/g, '\n').split('\n');
const releases: ReleaseNotesVersion[] = [];
let version: string | null = null;
let groups = new Map<ReleaseSectionKind, string[]>();
let currentGroup: string[] | null = null;
const flush = () => {
if (version === null) return;
const sections: ReleaseNotesSection[] = [];
for (const kind of KIND_ORDER) {
const body = groups.get(kind);
if (!body?.some((line) => LIST_ITEM_RE.test(line))) continue;
sections.push({ kind, markdown: trimBlankLines(body).join('\n') });
}
releases.push({ version, sections });
};
for (const line of lines) {
if (VERSION_HEADING_RE.test(line)) {
flush();
const firstWord = line.slice(3).trim().split(/\s+/)[0] ?? '';
version = parseReleaseVersion(firstWord);
groups = new Map();
currentGroup = null;
continue;
}
if (version === null) continue;
if (GROUP_HEADING_RE.test(line)) {
const kind = GROUP_KIND[line.slice(4).trim()];
if (kind && !groups.has(kind)) {
currentGroup = [];
groups.set(kind, currentGroup);
} else {
currentGroup = null;
}
continue;
}
currentGroup?.push(line);
}
flush();
return releases;
}
/**
* Freigegebene Versionen mit mindestens einer Gruppe Neu/Geändert/Behoben,
* in Dateireihenfolge.
*/
export function parseChangelogReleases(markdown: string): ReleaseNotesVersion[] {
return parseAllReleases(markdown).filter((release) => release.sections.length > 0);
}
/**
* Inhalt des Fensters oder `null` (kein Fenster). `currentRelease` kommt aus
* `GET /users/me/release-notice` (API, `getRunningRelease()`),
* `lastSeen` ist der gemerkte Stand des Benutzers.
*/
export function selectReleaseNotice(
markdown: string,
currentRelease: string | null,
lastSeen: string | null,
): ReleaseNotice | null {
const current = currentRelease === null ? null : parseReleaseVersion(currentRelease);
if (current === null) return null;
const seen = lastSeen === null ? null : parseReleaseVersion(lastSeen);
if (seen !== null && compareReleaseVersions(seen, current) >= 0) return null;
const all = parseAllReleases(markdown);
if (!all.some((release) => release.version === current)) return null;
const inRange = all
.filter((release) => release.sections.length > 0)
.filter((release) => {
if (compareReleaseVersions(release.version, current) > 0) return false;
if (seen === null) return release.version === current;
return compareReleaseVersions(release.version, seen) > 0;
})
.sort((a, b) => compareReleaseVersions(b.version, a.version));
if (inRange.length === 0) return null;
return {
currentRelease: current,
versions: inRange.slice(0, RELEASE_NOTICE_MAX_VERSIONS),
omittedCount: Math.max(0, inRange.length - RELEASE_NOTICE_MAX_VERSIONS),
};
}
@@ -0,0 +1,72 @@
'use server';
import { cookies } from 'next/headers';
import { changelogMarkdown } from '@/lib/changelog';
import { type ReleaseNotice, selectReleaseNotice } from '@/lib/release-notes';
/**
* Server-Aktionen des "Was ist neu"-Fensters (quick-260925-bow).
*
* Eigene `'use server'`-Datei, damit `auth-actions.ts` die Aenderungsliste
* nicht importiert. Server-Code darf `@/lib/changelog` importieren; Client-
* Komponenten bekommen von hier nur eine Aktions-Referenz, der Text der
* Aenderungsliste bleibt im Server-Bundle (Regel aus quick-260916-dcz).
*
* Die laufende Version kommt aus der API (`currentRelease`, einzige Quelle
* `APP_VERSION` der API), nicht aus `NEXT_PUBLIC_APP_VERSION` des Webs.
* Jeder Fehler endet still: kein Fenster bzw. `{ success: false }`.
*/
const API_URL =
process.env.API_INTERNAL_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
function isStringOrNull(value: unknown): value is string | null {
return value === null || typeof value === 'string';
}
async function sessionCookie(): Promise<string | null> {
const cookieStore = await cookies();
return cookieStore.get('session')?.value ?? null;
}
export async function fetchReleaseNotice(): Promise<ReleaseNotice | null> {
try {
const session = await sessionCookie();
if (!session) return null;
const response = await fetch(`${API_URL}/users/me/release-notice`, {
headers: { Cookie: `session=${session}` },
cache: 'no-store',
});
if (!response.ok) return null;
const body: unknown = await response.json();
if (!body || typeof body !== 'object') return null;
const { currentRelease, lastSeenReleaseVersion } = body as Record<string, unknown>;
if (!isStringOrNull(currentRelease) || !isStringOrNull(lastSeenReleaseVersion)) return null;
return selectReleaseNotice(changelogMarkdown, currentRelease, lastSeenReleaseVersion);
} catch {
return null;
}
}
export async function markReleaseSeenAction(version: string): Promise<{ success: boolean }> {
try {
const session = await sessionCookie();
if (!session) return { success: false };
const response = await fetch(`${API_URL}/users/me/release-seen`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: `session=${session}`,
},
body: JSON.stringify({ version }),
cache: 'no-store',
});
return { success: response.ok };
} catch {
return { success: false };
}
}