feat(quick-260916-dcz): CHANGELOG.md, Seite "Was ist neu" mit Kanalfilter, Versionszeile als Link, Bauzeit-Einbettung

- CHANGELOG.md (Wurzel): Unveroeffentlicht (bwo + dyv + diese Seite, 8 Punkte) und 1.0.0 – 2026-09-15 (11 Punkte aus den Handbuechern), Alltagssprache, echte Umlaute
- next.config.ts liest CHANGELOG.md zur Bauzeit nach env.TESSERA_CHANGELOG_MD; .dockerignore-Ausnahme !CHANGELOG.md und COPY-Zeile im Web-Dockerfile
- lib/changelog.ts: filterChangelogForChannel (live ohne Unveroeffentlicht, beta/dev mit "Noch nicht freigegeben (Beta)", leerer Abschnitt ausgeblendet), 10 Tests
- Server-Seite /changelog (MDEditor.Markdown + rehype-sanitize, Hinweis auf Beta, Leer-Text), 3 Tests; Versionszeile ist Link mit aria-label, 2 Tests
- de/en: sidebar.whatsNew und Namensraum changelog; Web-Suite 49/309, tsc 0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
This commit is contained in:
2026-09-16 11:20:22 +02:00
parent 0db21627f5
commit ba06db98c5
13 changed files with 620 additions and 5 deletions
@@ -0,0 +1,120 @@
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
/**
* changelog-page.test — Seite "Was ist neu" unter /changelog (quick-260916-dcz).
*
* Die Seite ist eine async Server-Komponente (Vorbild module-access-gate.test:
* Funktion awaiten, Ergebnis rendern). `next-intl/server` bekommt eine
* Handtabelle, `@/lib/app-version` einen veraenderbaren Kanal, `@/lib/changelog`
* wird je Test per `vi.doMock` mit `importOriginal` (echte Filterfunktion,
* eigener `changelogMarkdown`) bereitgestellt, `@uiw/react-md-editor` als
* Modul mit `default.Markdown`, das die Quelle roh in ein <pre> schreibt.
*/
vi.mock('next-intl/server', () => ({
getTranslations: async () => (key: string) => {
const translations: Record<string, string> = {
title: 'Was ist neu',
intro: 'Alle Änderungen an Tessera, sortiert nach Version – die aktuelle Version steht oben.',
unreleasedHeading: 'Noch nicht freigegeben (Beta)',
unreleasedHint:
'Die Punkte unter „Noch nicht freigegeben“ sind in dieser Beta bereits enthalten, aber noch nicht als Version freigegeben.',
empty: 'Noch keine Einträge vorhanden.',
};
return translations[key] ?? key;
},
}));
let mockChannel: 'beta' | 'live' | 'dev' = 'live';
vi.mock('@/lib/app-version', () => ({
get appVersion() {
return { version: 'v1.2.3', channel: mockChannel, commit: 'abc1234' };
},
}));
vi.mock('@uiw/react-md-editor', () => ({
default: {
Markdown: ({ source }: { source?: string }) => <pre data-testid="md">{source}</pre>,
},
}));
vi.mock('next-themes', () => ({
useTheme: () => ({ resolvedTheme: 'light' }),
}));
const SAMPLE = [
'# Änderungen an Tessera',
'',
'Vorspann.',
'',
'## Unveröffentlicht',
'',
'### Neu',
'',
'- Punkt U1',
'',
'## 1.0.0 – 2026-09-15',
'',
'### Neu',
'',
'- Punkt A',
'',
].join('\n');
function mockChangelog(markdown: string) {
vi.doMock('@/lib/changelog', async (importOriginal) => {
const original = await importOriginal<typeof import('@/lib/changelog')>();
return { ...original, changelogMarkdown: markdown };
});
}
async function renderPage() {
const { default: ChangelogPage } = await import('./page');
const element = await ChangelogPage();
render(element);
}
afterEach(() => {
cleanup();
vi.resetModules();
vi.doUnmock('@/lib/changelog');
mockChannel = 'live';
});
describe('ChangelogPage /changelog (quick-260916-dcz)', () => {
it('Test 1 (live): Titel sichtbar, kein Hinweis, Markdown ohne Unveröffentlicht', async () => {
mockChannel = 'live';
mockChangelog(SAMPLE);
await renderPage();
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Was ist neu');
expect(screen.queryByTestId('changelog-unreleased-hint')).not.toBeInTheDocument();
const md = screen.getByTestId('md').textContent ?? '';
expect(md).toContain('1.0.0');
expect(md).not.toContain('Unveröffentlicht');
expect(md).not.toContain('Noch nicht freigegeben');
});
it('Test 2 (beta): Hinweis vorhanden, Markdown traegt "Noch nicht freigegeben (Beta)"', async () => {
mockChannel = 'beta';
mockChangelog(SAMPLE);
await renderPage();
expect(screen.getByTestId('changelog-unreleased-hint')).toHaveTextContent(
'Die Punkte unter „Noch nicht freigegeben“ sind in dieser Beta bereits enthalten, aber noch nicht als Version freigegeben.',
);
const md = screen.getByTestId('md').textContent ?? '';
expect(md).toContain('Noch nicht freigegeben (Beta)');
expect(md).toContain('- Punkt U1');
});
it('Test 3 (leer): ohne Text erscheint der Leer-Hinweis und kein Markdown-Element', async () => {
mockChannel = 'beta';
mockChangelog('');
await renderPage();
expect(screen.getByTestId('changelog-empty')).toHaveTextContent('Noch keine Einträge vorhanden.');
expect(screen.queryByTestId('md')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,47 @@
import { getTranslations } from 'next-intl/server';
import { ChangelogView } from '@/components/changelog/changelog-view';
import { appVersion } from '@/lib/app-version';
import { changelogMarkdown, filterChangelogForChannel } from '@/lib/changelog';
/**
* Seite "Was ist neu" unter /changelog (quick-260916-dcz).
*
* Async Server-Komponente ohne 'use client' (Vorbild module-access-gate.tsx):
* der Text stammt aus der Bauzeit-Variable `TESSERA_CHANGELOG_MD`
* (next.config.ts liest CHANGELOG.md) und bleibt so im Server-Bundle. Die
* Kanalregel liegt in `filterChangelogForChannel`: auf Live fehlt der
* Abschnitt "Unveröffentlicht", auf Beta/Entwicklung erscheint er als "Noch
* nicht freigegeben (Beta)" samt Hinweis. Zugriff nur angemeldet — die
* bestehende middleware.ts schuetzt jede Route ausser /login und
* /reset-password; /changelog ist keine oeffentliche Route.
*/
export default async function ChangelogPage() {
const t = await getTranslations('changelog');
const { markdown, hasUnreleased } = filterChangelogForChannel(changelogMarkdown, appVersion.channel, {
unreleasedHeading: t('unreleasedHeading'),
});
return (
<div className="mx-auto max-w-3xl py-8 px-4">
<h1 className="text-2xl font-bold text-foreground mb-2">{t('title')}</h1>
<p className="text-sm text-muted-foreground mb-6">{t('intro')}</p>
{hasUnreleased && (
<div
data-testid="changelog-unreleased-hint"
className="rounded-md bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 px-4 py-3 text-sm text-yellow-800 dark:text-yellow-200 mb-6"
>
{t('unreleasedHint')}
</div>
)}
{markdown.trim() === '' ? (
<p data-testid="changelog-empty" className="text-sm text-muted-foreground">
{t('empty')}
</p>
) : (
<ChangelogView markdown={markdown} />
)}
</div>
);
}