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:
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user