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:
@@ -5,4 +5,7 @@ dist
|
||||
.git
|
||||
.env
|
||||
*.md
|
||||
# quick-260916-dcz: Wurzel-Markdown bleibt draussen, nur diese eine Datei braucht der Web-Bau
|
||||
# (apps/web/next.config.ts liest sie zur Bauzeit, COPY im Web-Dockerfile).
|
||||
!CHANGELOG.md
|
||||
coverage
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Änderungen an Tessera
|
||||
|
||||
Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an Tessera geändert hat. Die neueste Version steht oben. Der Abschnitt „Unveröffentlicht“ nennt Änderungen, die bereits in der Beta enthalten, aber noch nicht als Version freigegeben sind.
|
||||
|
||||
## Unveröffentlicht
|
||||
|
||||
### Neu
|
||||
|
||||
- Seite „Was ist neu“: Ein Klick auf die Versionsnummer unten in der Seitenleiste zeigt diese Liste. Auf dem Live-System sehen Sie nur freigegebene Versionen, auf der Beta zusätzlich den Abschnitt „Noch nicht freigegeben“.
|
||||
- Uhr: Unter Einstellungen > Dashboard lässt sich die Schriftgröße der Uhrzeit fest in Punkt (8 bis 200) vorgeben. Lassen Sie das Feld leer, passt sich die Schrift weiterhin automatisch an.
|
||||
|
||||
### Geändert
|
||||
|
||||
- Dashboard-Raster und Mindestgrößen: Das Raster ist doppelt so fein, Widgets lassen sich in kleineren Schritten verschieben und in der Größe ziehen. Jedes Widget hat jetzt genau die Mindestgröße, bei der es gerade noch bedienbar ist – kleiner geht es nicht, größer jederzeit; das gilt auch für bereits platzierte Widgets. Gespeicherte Anordnungen werden beim ersten Aufruf automatisch übernommen und verrutschen nicht.
|
||||
- Verschieben: Im Bearbeitungsmodus lässt sich jede Kachel an einer beliebigen Stelle anfassen (außer an Eingabefeldern, Knöpfen und Links); ein grauer Griff am oberen Rand zeigt das an. Kacheln überlappen sich beim Ablegen nicht mehr – über einer belegten Stelle springt die Kachel an ihren Ausgangspunkt zurück.
|
||||
- Der Bearbeiten-Schalter des Dashboards sitzt jetzt unten rechts; die Widgets beginnen direkt unter der Kopfzeile.
|
||||
- Uhrzeit, Stoppuhr und Rechner wachsen und schrumpfen mit ihrer Kachel – eine große Uhr-Kachel zeigt eine große Uhrzeit. Die Stoppuhr hat kompaktere Knöpfe und passt so auch in kleine Kacheln.
|
||||
- Die Ränder sind überall enger: Der äußere Seitenrahmen auf allen Seiten, der Abstand zwischen den Widgets und die Innenabstände der Widgets sind halbiert.
|
||||
- Das Anwenderhandbuch beschreibt das feine Raster, die mitwachsende Uhrzeit, die Schriftgrößen-Einstellung, den neuen Schalter, das Ziehen und die Mindestgrößen.
|
||||
|
||||
## 1.0.0 – 2026-09-15
|
||||
|
||||
### Neu
|
||||
|
||||
- Portal mit Kopfleiste und Seitenleiste: Dashboard, Marktplatz und die für Sie freigegebenen Module nach Kategorien mit Suchfeld. Die Seitenleiste lässt sich ein- und ausklappen, das Erscheinungsbild wechselt zwischen Hell, Dunkel und System, die Sprache zwischen Deutsch und Englisch.
|
||||
- Persönliches Dashboard mit frei anordenbaren Kacheln: Uhr, Suchleiste, Kalender, Notizen, Taschenrechner, Favoriten, Link und Stoppuhr. Im Bearbeitungsmodus fügen Sie Kacheln hinzu, verschieben sie und ziehen ihre Größe; die Einstellungen je Kachel (Kalenderquellen, Suchanbieter, Links) finden Sie unter Einstellungen > Dashboard.
|
||||
- Marktplatz mit den Zuständen „Aktiviert“ und „Verfügbar“, Suche, Filtern und einer Detailseite je Modul. Administratoren aktivieren ein Modul für das Unternehmen und geben es je Gruppe oder Benutzer frei (Freigaben-Matrix).
|
||||
- Ausschreibungs-Radar: Trefferliste öffentlicher Ausschreibungen mit Filtern nach Frist, Postleitzahl, Bundesland, Branche und Auftragswert; Suchprofile mit Sofort-Alarm per E-Mail; Sammel-Mail täglich oder wöchentlich; Merken und Gelesen-Markierung; eigene Postfächer und RSS-Feeds als Quellen.
|
||||
- DKV-Rechnung: Automatische Verarbeitung von DKV-Tankkarten-Rechnungen aus einem Postfach, Fahrzeug-Stammdaten mit CSV-Import, Verarbeitungshistorie und Exportdateien zum Herunterladen.
|
||||
- Zertifikat-Manager (Zertifikate analysieren, aufteilen, zusammenführen und konvertieren) und Domaincheck (Verfügbarkeit von Internet-Domains prüfen).
|
||||
- Benutzer-, Gruppen- und Rechteverwaltung: Rollen Benutzer, Admin und Super-Admin; lokale und verzeichnisgeführte Konten; Gruppen mit Standardgruppe; Anbindung an das Active Directory mit Import von Gruppen und Einzelbenutzern, Ausschlussliste und automatischer Synchronisation.
|
||||
- E-Mail-Versand (SMTP) mit Testnachricht; „Passwort vergessen“ und Zurücksetzen per E-Mail; erzwungene Passwortänderung bei neuen Konten.
|
||||
- Persönliche Einstellungen: Profilbild, Akzentfarbe und Passwort ändern für lokale Konten.
|
||||
- Knopf „Fehler melden“ in der Kopfleiste: Bildschirmfoto, Beschreibung und technische Angaben gehen per E-Mail an den Administrator.
|
||||
- Versionsanzeige unten in der Seitenleiste mit Versionsnummer und Kanal (Live oder Beta).
|
||||
@@ -22,6 +22,9 @@ COPY --from=deps /app/ ./
|
||||
COPY apps/web/ ./apps/web/
|
||||
COPY packages/shared/ ./packages/shared/
|
||||
COPY tsconfig.base.json ./
|
||||
# quick-260916-dcz: next.config.ts liest CHANGELOG.md zur Bauzeit (Seite "Was ist neu");
|
||||
# .dockerignore schliesst *.md aus und macht fuer genau diese Datei eine Ausnahme.
|
||||
COPY CHANGELOG.md ./
|
||||
ENV NEXT_PUBLIC_API_URL=/api-proxy
|
||||
# Muss VOR dem Build stehen: Next.js bettet NEXT_PUBLIC_* zur Bauzeit ins Bundle ein.
|
||||
# So spaet wie moeglich, damit die COPY-Schichten darueber im Cache bleiben.
|
||||
|
||||
@@ -1,10 +1,36 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { NextConfig } from 'next';
|
||||
import createNextIntlPlugin from 'next-intl/plugin';
|
||||
|
||||
// Bauzeit-Einbettung der Aenderungsliste (quick-260916-dcz): CHANGELOG.md aus
|
||||
// dem Wurzelverzeichnis wird hier gelesen und als `env.TESSERA_CHANGELOG_MD`
|
||||
// abgelegt. Next.js ersetzt `process.env.TESSERA_CHANGELOG_MD` fuer webpack UND
|
||||
// Turbopack ueber denselben Define-Mechanismus. Der Text bleibt nur im
|
||||
// Server-Bundle, weil ausschliesslich die Server-Seite (changelog/page.tsx)
|
||||
// `@/lib/changelog` importiert — Importdisziplin, kein Client-Chunk.
|
||||
|
||||
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
|
||||
|
||||
function readChangelog(): string {
|
||||
const file = path.resolve(__dirname, '../../CHANGELOG.md');
|
||||
try {
|
||||
return readFileSync(file, 'utf8');
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`CHANGELOG.md konnte nicht gelesen werden (${file}): ${reason}. ` +
|
||||
'Die Datei muss im Wurzelverzeichnis liegen; im Docker-Bau braucht sie die Ausnahme ' +
|
||||
'`!CHANGELOG.md` in .dockerignore und die Zeile `COPY CHANGELOG.md ./` in apps/web/Dockerfile.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone' as const,
|
||||
env: {
|
||||
TESSERA_CHANGELOG_MD: readChangelog(),
|
||||
},
|
||||
async rewrites() {
|
||||
const apiUrl = process.env.API_INTERNAL_URL || 'http://api:3001';
|
||||
return [
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import MDEditor from '@uiw/react-md-editor';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
/**
|
||||
* Gerenderte Aenderungsliste fuer die Seite "Was ist neu" (quick-260916-dcz).
|
||||
*
|
||||
* Nutzt `MDEditor.Markdown` aus dem bereits installierten `@uiw/react-md-editor`
|
||||
* (wie die Vorschau des Notiz-Widgets) mit `rehype-sanitize` — kein neues
|
||||
* Paket, kein dangerouslySetInnerHTML. Der Farbmodus folgt `next-themes`
|
||||
* erst nach dem Mount (vorher 'light'), damit Server- und Client-Markup
|
||||
* uebereinstimmen (mounted-Guard wie in AppShell). Der Markdown-Text kommt
|
||||
* als Prop von der Server-Seite; dieses Modul importiert `@/lib/changelog`
|
||||
* bewusst NICHT, damit der Text nicht in Client-Chunks landet.
|
||||
*/
|
||||
export function ChangelogView({ markdown }: { markdown: string }) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const mode: 'light' | 'dark' = mounted && resolvedTheme === 'dark' ? 'dark' : 'light';
|
||||
|
||||
return (
|
||||
<div data-testid="changelog-markdown" className="rounded-md border border-border bg-card p-4">
|
||||
<MDEditor.Markdown
|
||||
source={markdown}
|
||||
rehypePlugins={[[rehypeSanitize]]}
|
||||
wrapperElement={{ 'data-color-mode': mode }}
|
||||
style={{ background: 'transparent' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,9 +4,33 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
/**
|
||||
* AppVersionBadge.test — die Versionszeile unten in der Seitenleiste
|
||||
* (quick-260914-ku1). Vorlage: sidebar.test.tsx (next-intl-Mock, cleanup,
|
||||
* dynamischer Import nach dem Setzen der Mocks).
|
||||
* dynamischer Import nach dem Setzen der Mocks). Seit quick-260916-dcz ist die
|
||||
* Zeile ein Link zur Seite "Was ist neu" — deshalb der `next/link`-Mock, der
|
||||
* href, className, title, aria-label und data-testid durchreicht (Tests 5/6).
|
||||
*/
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({
|
||||
children,
|
||||
href,
|
||||
className,
|
||||
title,
|
||||
'aria-label': ariaLabel,
|
||||
'data-testid': testId,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
href: string;
|
||||
className?: string;
|
||||
title?: string;
|
||||
'aria-label'?: string;
|
||||
'data-testid'?: string;
|
||||
}) => (
|
||||
<a href={href} className={className} title={title} aria-label={ariaLabel} data-testid={testId}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: (ns: string) => (key: string) => {
|
||||
const map: Record<string, Record<string, string>> = {
|
||||
@@ -14,6 +38,7 @@ vi.mock('next-intl', () => ({
|
||||
'channel.beta': 'Beta',
|
||||
'channel.live': 'Live',
|
||||
'channel.dev': 'Entwicklung',
|
||||
whatsNew: 'Was ist neu',
|
||||
},
|
||||
};
|
||||
return map[ns]?.[key] ?? key;
|
||||
@@ -95,4 +120,22 @@ describe('AppVersionBadge (quick-260914-ku1)', () => {
|
||||
expect(el).toHaveTextContent('dev · Entwicklung');
|
||||
expect(el.hasAttribute('title')).toBe(false);
|
||||
});
|
||||
|
||||
it('Test 5 (Link): die Versionszeile ist ein Link auf /changelog', async () => {
|
||||
mockLoad.mockResolvedValue(null);
|
||||
const AppVersionBadge = await importBadge();
|
||||
render(<AppVersionBadge />);
|
||||
|
||||
const el = screen.getByTestId('app-version');
|
||||
expect(el.tagName).toBe('A');
|
||||
expect(el).toHaveAttribute('href', '/changelog');
|
||||
});
|
||||
|
||||
it('Test 6 (aria-label): der Link traegt den Namen "Was ist neu"', async () => {
|
||||
mockLoad.mockResolvedValue(null);
|
||||
const AppVersionBadge = await importBadge();
|
||||
render(<AppVersionBadge />);
|
||||
|
||||
expect(screen.getByTestId('app-version')).toHaveAttribute('aria-label', 'Was ist neu');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { type ApiVersionInfo, appVersion, loadApiVersion } from '@/lib/app-version';
|
||||
|
||||
@@ -9,6 +10,8 @@ import { type ApiVersionInfo, appVersion, loadApiVersion } from '@/lib/app-versi
|
||||
* `v1.0.0 · Beta` oder lokal `dev · Entwicklung` (quick-260914-ku1).
|
||||
* Der Tooltip nennt den Web-Commit und, sobald geladen, die API-Version
|
||||
* samt Kanal. "Commit" und "API" sind in beiden Sprachen gleich.
|
||||
* Seit quick-260916-dcz ist die Zeile ein Link zur Seite "Was ist neu"
|
||||
* (/changelog); der Tooltip bleibt, der Linkname steht im aria-label.
|
||||
*/
|
||||
export function AppVersionBadge() {
|
||||
const t = useTranslations('sidebar');
|
||||
@@ -30,8 +33,14 @@ export function AppVersionBadge() {
|
||||
const title = parts.length > 0 ? parts.join(' · ') : undefined;
|
||||
|
||||
return (
|
||||
<span data-testid="app-version" className="block truncate text-xs text-muted-foreground" title={title}>
|
||||
<Link
|
||||
href="/changelog"
|
||||
data-testid="app-version"
|
||||
aria-label={t('whatsNew')}
|
||||
title={title}
|
||||
className="block truncate text-xs text-muted-foreground transition-colors hover:text-foreground hover:underline"
|
||||
>
|
||||
{appVersion.version} · {t(`channel.${appVersion.channel}`)}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { filterChangelogForChannel } from './changelog';
|
||||
|
||||
/**
|
||||
* changelog.test — Kanalregel fuer die Seite "Was ist neu" (quick-260916-dcz).
|
||||
*
|
||||
* `filterChangelogForChannel` ist eine reine Funktion ueber dem Markdown-Text
|
||||
* von CHANGELOG.md: auf `live` verschwindet der Abschnitt "Unveröffentlicht",
|
||||
* auf `beta`/`dev` bleibt er mit der Ueberschrift "Noch nicht freigegeben
|
||||
* (Beta)"; H1 und Vorspann entfallen immer. Test 1 ist die Falsifizierung (a)
|
||||
* des Plans: er wird rot, sobald die Funktion den Abschnitt nicht entfernt.
|
||||
* Tests 9/10 pruefen die Bauzeit-Einbettung ueber `TESSERA_CHANGELOG_MD`
|
||||
* (Muster: app-version.test.ts — stubEnv + resetModules + dynamischer Import).
|
||||
*/
|
||||
|
||||
const SAMPLE = [
|
||||
'# Änderungen an Tessera',
|
||||
'',
|
||||
'Vorspann: die neueste Version steht oben.',
|
||||
'',
|
||||
'## Unveröffentlicht',
|
||||
'',
|
||||
'### Geändert',
|
||||
'',
|
||||
'- Punkt U1 (noch nicht freigegeben)',
|
||||
'- Punkt U2 (noch nicht freigegeben)',
|
||||
'',
|
||||
'## 1.0.0 – 2026-09-15',
|
||||
'',
|
||||
'### Neu',
|
||||
'',
|
||||
'- Punkt A der Version 1.0.0',
|
||||
'- Punkt B der Version 1.0.0',
|
||||
'',
|
||||
'## 0.9.0 – 2026-09-01',
|
||||
'',
|
||||
'### Neu',
|
||||
'',
|
||||
'- Punkt C der Version 0.9.0',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('filterChangelogForChannel (quick-260916-dcz)', () => {
|
||||
it('Test 1 (live): entfernt den Abschnitt Unveröffentlicht samt Punkten, behaelt die Versionen in Reihenfolge', () => {
|
||||
const result = filterChangelogForChannel(SAMPLE, 'live');
|
||||
|
||||
expect(result.hasUnreleased).toBe(false);
|
||||
expect(result.markdown).not.toContain('## Unveröffentlicht');
|
||||
expect(result.markdown).not.toContain('Punkt U1');
|
||||
expect(result.markdown).not.toContain('Punkt U2');
|
||||
expect(result.markdown).toContain('## 1.0.0 – 2026-09-15');
|
||||
expect(result.markdown).toContain('## 0.9.0 – 2026-09-01');
|
||||
expect(result.markdown).toContain('- Punkt A der Version 1.0.0');
|
||||
expect(result.markdown).toContain('- Punkt C der Version 0.9.0');
|
||||
expect(result.markdown.indexOf('## 1.0.0')).toBeLessThan(result.markdown.indexOf('## 0.9.0'));
|
||||
});
|
||||
|
||||
it('Test 2 (beta): ersetzt die Ueberschrift durch "Noch nicht freigegeben (Beta)", Punkte bleiben', () => {
|
||||
const result = filterChangelogForChannel(SAMPLE, 'beta');
|
||||
|
||||
expect(result.hasUnreleased).toBe(true);
|
||||
expect(result.markdown).toContain('## Noch nicht freigegeben (Beta)');
|
||||
expect(result.markdown).not.toContain('## Unveröffentlicht');
|
||||
expect(result.markdown).toContain('- Punkt U1');
|
||||
expect(result.markdown).toContain('- Punkt U2');
|
||||
expect(result.markdown).toContain('## 1.0.0 – 2026-09-15');
|
||||
});
|
||||
|
||||
it('Test 3 (dev): verhaelt sich wie beta', () => {
|
||||
const result = filterChangelogForChannel(SAMPLE, 'dev');
|
||||
|
||||
expect(result.hasUnreleased).toBe(true);
|
||||
expect(result.markdown).toContain('## Noch nicht freigegeben (Beta)');
|
||||
expect(result.markdown).not.toContain('## Unveröffentlicht');
|
||||
expect(result.markdown).toContain('- Punkt U1');
|
||||
});
|
||||
|
||||
it('Test 4 (eigenes Label): options.unreleasedHeading bestimmt die Ueberschrift', () => {
|
||||
const result = filterChangelogForChannel(SAMPLE, 'beta', {
|
||||
unreleasedHeading: 'Not yet released (beta)',
|
||||
});
|
||||
|
||||
expect(result.markdown).toContain('## Not yet released (beta)');
|
||||
expect(result.markdown).not.toContain('## Noch nicht freigegeben (Beta)');
|
||||
});
|
||||
|
||||
it('Test 5 (leerer Abschnitt, beta): Unveröffentlicht ohne Listenpunkt wird ausgeblendet', () => {
|
||||
const empty = [
|
||||
'# Titel',
|
||||
'',
|
||||
'## Unveröffentlicht',
|
||||
'',
|
||||
'### Neu',
|
||||
'',
|
||||
'## 1.0.0 – 2026-09-15',
|
||||
'',
|
||||
'- Punkt A der Version 1.0.0',
|
||||
'',
|
||||
].join('\n');
|
||||
const result = filterChangelogForChannel(empty, 'beta');
|
||||
|
||||
expect(result.hasUnreleased).toBe(false);
|
||||
expect(result.markdown).not.toContain('Unveröffentlicht');
|
||||
expect(result.markdown).not.toContain('Noch nicht freigegeben');
|
||||
expect(result.markdown).toContain('## 1.0.0 – 2026-09-15');
|
||||
expect(result.markdown).toContain('- Punkt A der Version 1.0.0');
|
||||
});
|
||||
|
||||
it('Test 6 (kein Abschnitt): ohne Unveröffentlicht bleiben die Versionsabschnitte unveraendert', () => {
|
||||
const noUnreleased = [
|
||||
'# Titel',
|
||||
'',
|
||||
'## 1.0.0 – 2026-09-15',
|
||||
'',
|
||||
'### Neu',
|
||||
'',
|
||||
'- Punkt A der Version 1.0.0',
|
||||
'',
|
||||
'## 0.9.0 – 2026-09-01',
|
||||
'',
|
||||
'- Punkt C der Version 0.9.0',
|
||||
'',
|
||||
].join('\n');
|
||||
const expected = [
|
||||
'## 1.0.0 – 2026-09-15',
|
||||
'',
|
||||
'### Neu',
|
||||
'',
|
||||
'- Punkt A der Version 1.0.0',
|
||||
'',
|
||||
'## 0.9.0 – 2026-09-01',
|
||||
'',
|
||||
'- Punkt C der Version 0.9.0',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const beta = filterChangelogForChannel(noUnreleased, 'beta');
|
||||
const live = filterChangelogForChannel(noUnreleased, 'live');
|
||||
|
||||
expect(beta.hasUnreleased).toBe(false);
|
||||
expect(live.hasUnreleased).toBe(false);
|
||||
expect(beta.markdown).toBe(expected);
|
||||
expect(live.markdown).toBe(expected);
|
||||
});
|
||||
|
||||
it('Test 7 (Hierarchie): H1 und Vorspann fehlen, Ergebnis beginnt mit "## ", "### " bleibt', () => {
|
||||
const result = filterChangelogForChannel(SAMPLE, 'beta');
|
||||
|
||||
expect(result.markdown).not.toContain('# Änderungen an Tessera');
|
||||
expect(result.markdown).not.toContain('Vorspann:');
|
||||
expect(result.markdown.trim().startsWith('## ')).toBe(true);
|
||||
expect(result.markdown).toContain('### Geändert');
|
||||
expect(result.markdown).toContain('### Neu');
|
||||
});
|
||||
|
||||
it('Test 8 (CRLF): Windows-Zeilenenden werden normalisiert, live entfernt den Abschnitt ebenfalls', () => {
|
||||
const crlf = SAMPLE.replace(/\n/g, '\r\n');
|
||||
const result = filterChangelogForChannel(crlf, 'live');
|
||||
|
||||
expect(result.hasUnreleased).toBe(false);
|
||||
expect(result.markdown).not.toContain('\r');
|
||||
expect(result.markdown).not.toContain('Unveröffentlicht');
|
||||
expect(result.markdown).toContain('## 1.0.0 – 2026-09-15');
|
||||
});
|
||||
|
||||
it('Test 9 (Einbettung): changelogMarkdown ist exakt der Wert von TESSERA_CHANGELOG_MD', async () => {
|
||||
const text = '# T\n\n## 1.0.0 – 2026-01-01\n\n- x';
|
||||
vi.stubEnv('TESSERA_CHANGELOG_MD', text);
|
||||
vi.resetModules();
|
||||
const mod = await import('./changelog');
|
||||
|
||||
expect(mod.changelogMarkdown).toBe(text);
|
||||
});
|
||||
|
||||
it('Test 10 (ohne Variable): changelogMarkdown ist ein leerer String', async () => {
|
||||
vi.stubEnv('TESSERA_CHANGELOG_MD', '');
|
||||
vi.resetModules();
|
||||
const empty = await import('./changelog');
|
||||
expect(empty.changelogMarkdown).toBe('');
|
||||
|
||||
vi.stubEnv('TESSERA_CHANGELOG_MD', undefined);
|
||||
vi.resetModules();
|
||||
const unset = await import('./changelog');
|
||||
expect(unset.changelogMarkdown).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { AppChannel } from './app-version';
|
||||
|
||||
/**
|
||||
* Aenderungsliste fuer die Seite "Was ist neu" (quick-260916-dcz).
|
||||
*
|
||||
* Quelle ist CHANGELOG.md im Wurzelverzeichnis; `apps/web/next.config.ts` liest
|
||||
* die Datei zur Bauzeit und legt den Text als `env.TESSERA_CHANGELOG_MD` ab.
|
||||
* Dieses Modul darf NUR von der Server-Seite (`app/(portal)/changelog/page.tsx`)
|
||||
* importiert werden — sonst landet der Text in oeffentlich abrufbaren
|
||||
* Client-Chunks unter /_next/static.
|
||||
*
|
||||
* Kanalregel (`filterChangelogForChannel`): auf `live` fehlt der Abschnitt
|
||||
* "Unveröffentlicht" vollstaendig; auf `beta` und `dev` bleibt er und traegt
|
||||
* die Ueberschrift "Noch nicht freigegeben (Beta)" (uebersetzbar). Ein leerer
|
||||
* Abschnitt (ohne Listenpunkt) wird auf allen Kanaelen ausgeblendet. H1 und
|
||||
* Vorspann vor der ersten `## `-Ueberschrift entfallen — die Seite hat ihren
|
||||
* eigenen Titel. Reine Funktion ohne React/Next, deshalb direkt testbar.
|
||||
*/
|
||||
|
||||
export type ChangelogChannel = AppChannel;
|
||||
|
||||
export interface FilteredChangelog {
|
||||
markdown: string;
|
||||
hasUnreleased: boolean;
|
||||
}
|
||||
|
||||
export const UNRELEASED_HEADING = 'Unveröffentlicht';
|
||||
|
||||
const DEFAULT_UNRELEASED_LABEL = 'Noch nicht freigegeben (Beta)';
|
||||
|
||||
/**
|
||||
* Wie in app-version.ts: Next.js ersetzt `process.env.<NAME>` zur Bauzeit nur,
|
||||
* wenn der Ausdruck woertlich mit vollem Namen im Code steht — kein
|
||||
* Destructuring, kein `process.env[name]`.
|
||||
*/
|
||||
export const changelogMarkdown: string = process.env.TESSERA_CHANGELOG_MD ?? '';
|
||||
|
||||
const SECTION_RE = /^## /;
|
||||
const UNRELEASED_RE = new RegExp(`^## ${UNRELEASED_HEADING}\\s*$`);
|
||||
const LIST_ITEM_RE = /^\s*[-*] /;
|
||||
|
||||
function splitSections(lines: string[]): string[][] {
|
||||
const sections: string[][] = [];
|
||||
let current: string[] | null = null;
|
||||
for (const line of lines) {
|
||||
if (SECTION_RE.test(line)) {
|
||||
current = [line];
|
||||
sections.push(current);
|
||||
} else if (current) {
|
||||
current.push(line);
|
||||
}
|
||||
// Zeilen vor der ersten `## `-Ueberschrift (H1, Vorspann) werden verworfen.
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
export function filterChangelogForChannel(
|
||||
markdown: string,
|
||||
channel: AppChannel,
|
||||
options?: { unreleasedHeading?: string },
|
||||
): FilteredChangelog {
|
||||
const normalized = markdown.replace(/\r\n?/g, '\n');
|
||||
const sections = splitSections(normalized.split('\n'));
|
||||
|
||||
let hasUnreleased = false;
|
||||
const kept: string[][] = [];
|
||||
|
||||
for (const section of sections) {
|
||||
if (!UNRELEASED_RE.test(section[0])) {
|
||||
kept.push(section);
|
||||
continue;
|
||||
}
|
||||
const isEmpty = !section.slice(1).some((line) => LIST_ITEM_RE.test(line));
|
||||
if (isEmpty || channel === 'live') {
|
||||
continue;
|
||||
}
|
||||
hasUnreleased = true;
|
||||
const label = options?.unreleasedHeading ?? DEFAULT_UNRELEASED_LABEL;
|
||||
kept.push([`## ${label}`, ...section.slice(1)]);
|
||||
}
|
||||
|
||||
const joined = kept.map((section) => section.join('\n')).join('\n').trim();
|
||||
return { markdown: joined ? `${joined}\n` : '', hasUnreleased };
|
||||
}
|
||||
@@ -109,7 +109,8 @@
|
||||
"beta": "Beta",
|
||||
"live": "Live",
|
||||
"dev": "Entwicklung"
|
||||
}
|
||||
},
|
||||
"whatsNew": "Was ist neu"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -512,6 +513,13 @@
|
||||
"errorSendFailed": "Die E-Mail konnte nicht gesendet werden. Bitte versuchen Sie es später erneut oder wenden Sie sich an Ihren Administrator.",
|
||||
"errorGeneric": "Die Meldung konnte nicht gesendet werden."
|
||||
},
|
||||
"changelog": {
|
||||
"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."
|
||||
},
|
||||
"locale": {
|
||||
"de": "Deutsch",
|
||||
"en": "English"
|
||||
|
||||
@@ -109,7 +109,8 @@
|
||||
"beta": "Beta",
|
||||
"live": "Live",
|
||||
"dev": "Development"
|
||||
}
|
||||
},
|
||||
"whatsNew": "What's new"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -512,6 +513,13 @@
|
||||
"errorSendFailed": "The e-mail could not be sent. Please try again later or contact your administrator.",
|
||||
"errorGeneric": "The report could not be sent."
|
||||
},
|
||||
"changelog": {
|
||||
"title": "What's new",
|
||||
"intro": "All changes to Tessera, sorted by version – the current version is at the top.",
|
||||
"unreleasedHeading": "Not yet released (beta)",
|
||||
"unreleasedHint": "The items under “Not yet released” are already part of this beta but have not been released as a version yet.",
|
||||
"empty": "No entries yet."
|
||||
},
|
||||
"locale": {
|
||||
"de": "Deutsch",
|
||||
"en": "English"
|
||||
|
||||
Reference in New Issue
Block a user