Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-10-16-kundenansicht-03-website-chat-vertraege.md
T

1570 lines
158 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Kundenansicht neu P2: Website, Chat, Verträge Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Die Bereiche Website, Chat und Verträge sowie die Etagen Projektdetails, Gutschrift, Passwort, Bestellen und Wechsel laufen mit den heutigen APIs in der neuen weißen Oberfläche. Die öffentlichen Seiten (Login, Kündigen, Widerrufen, Auftragsbestätigung, Offline) gibt es im neuen Stil unter `public/neu/oeffentlich/`, noch nicht ausgeliefert.
**Architecture:** Je Ansicht ein ES-Modul `public/neu/ansicht-*.js` mit `zeigeX(wurzel, { route, me, nav })`, gebaut mit `h()` und `textContent` auf den heutigen Endpunkten (Karte woms-vorschau B4). Die acht Stub-Dateien und `ansichten.css`, die P1 anlegt, werden gefüllt. Reine Logik (Formate, Reiterwahl, Status, Zeitleiste) steht in kleinen exportierten Funktionen und wird mit `node --test` geprüft, DOM-Teile laufen gegen `test/support/fakeDom.js`. Die Wortlaut-Seiten (Bestellen, Wechsel, E-Mail-Speicher, Gutschrift, öffentliche Seiten) übernehmen den Text der Fassung 5 unverändert. Ein Wortlaut-Orakel lädt `public/app.js` in eine VM und vergleicht den sichtbaren Text Zeichen für Zeichen mit der neuen Seite.
**Tech Stack:** ES-Module ohne Build (Browser, Node 20/24), `node --test`, `test/support/fakeDom.js`, `window.ChatComposer` (klassisches Skript `public/chat-composer.js`), `public/auftrag-portal.js`, Sichtprüfung nur über das Werkzeug aus P0.
**Spec:** `docs/superpowers/specs/2026-10-15-kundenansicht-neu-design.md` (5.1 Aussehen, 5.3 Gruppierung, 5.7 Sprache, C14 Wortlaut der Bestellseiten)
**Übersicht/Verträge:** /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht/docs/superpowers/plans/2026-10-16-kundenansicht-00-uebersicht.md (gilt wörtlich)
**Worktree und Branch:** `/home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui`, Branch `kn/ui` (Strang UI, nach P1, Stand Tag `kn-p1`). Alle Pfade dieses Plans sind relativ zu diesem Worktree, soweit sie nicht mit `/` beginnen. Tag am Ende: `kn-p2`.
## Global Constraints
Alle Global Constraints der Übersicht gelten (Schalter, Aussehen mit allen Verboten, Anrede, keine Funktionspreise, Hashes, rechtlich Fest, Testbefehle mit Speicherbremse, Commits mit Zeile `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`, Sicherheitsblock, kein Push). Paketeigene Zusätze:
- **Importe relativ** in `public/neu/*.js` (`./dom.js`, `../auftrag-portal.js`), damit `node --test` die Module direkt lädt. `ChatComposer` kommt über `globalThis`.
- **Kein HTML-Einsetzen:** kein `innerHTML`, `insertAdjacentHTML`, `outerHTML`. Kundentext nur über `h()` und `textContent`. URLs aus Daten (`previewUrl`, `portalUrl`, `paymentUrl`) nur über `safeUrl` (http/https) in `href`/`src`.
- **Anrede:** `du` in allen P2-Texten. Ausnahme nur die Wortlaut-Dateien `ansicht-bestellen.js`, `ansicht-wechsel.js`, `ansicht-speicher.js`, `ansicht-gutschrift.js` und die Seiten unter `oeffentlich/`: Dort bleibt der Text der Fassung 5 wörtlich (C14, Sie-Form inklusive). Untertitel unter Überschriften entfallen überall (Spec 5.7).
- **Keine eigene `h1`:** Bereichstitel zeichnet die Hülle (P1 Task 12), Etagentitel kommen aus `titelFuer(route)`. Weicht der Titel ab (Projektname, Produktname), meldet die Ansicht ihn mit `meldeTitel(wurzel, text)` (Ereignis `kn:titel`, P1 Task 6).
- **Links zwischen Ansichten** tragen `href` und `data-view` (Brücke der Hülle, `hashFuerAnsicht`), erzeugt mit `zuAnsicht(name, projektId?)`. Programmatisch navigiert nur `navigiere(hash, { ersetzen })`.
- **Wiederholbar:** Jeder `zeigeX`-Aufruf leert `wurzel`, zeigt sofort den Text `Wird geladen …`, verwirft späte Antworten (Zähler je Aufruf) und räumt Timer ab. Fällt eine Quelle aus, zeigt nur ihr Abschnitt einen Fehlertext.
- **Ansichtsmodus** (`me.viewAs === true`): Jeder schreibende Knopf ist gesperrt und es geht kein schreibender Aufruf raus. Neuer Sperrtext `Im Ansichtsmodus nicht möglich.`, Chat, Passwort und Gutschrift behalten ihre alten Sperrtexte.
- **Schreibende Aufrufe** über `api()` (V2) mit Body als Objekt. Alt-Module, die einen JSON-String senden (`auftrag-portal.js`), nur über `legacyRequest(api)`.
- **Verlaufsstempel (P1 VF7, VF9):** Wer die Adresse aufräumt, ruft `history.replaceState(history.state, …)`, nie mit `null`.
- **Schreibverbot für Altes:** `public/app.js`, `dashboard.html`, `login.html`, `kuendigen.html`, `widerrufen.html`, `offline.html`, `auftrag.html`, `auftrag.css`, `legal-form.*`, `auftrag*.js`, `chat-composer.*` (alle direkt unter `public/`) bleiben unverändert (Prüfung in Task 20). Was die neue Oberfläche anders braucht, liegt als neue Datei unter `public/neu/`.
- **Kein Lila in `public/neu/`:** Die Prüfung `lila` (P0) liest den Text jedes geladenen Stylesheets, auch ungenutzter Regeln. Die neue Oberfläche lädt deshalb weder `/chat-composer.css` noch `/auftrag.css`, `/login.css` oder `/legal-form.css`, sondern bereinigte Dateien unter `public/neu/` (`chat-composer.css` in Task 7, `oeffentlich/oeffentlich.css` in Task 17). Ein Quelltext-Test (Task 1) sucht in jeder Textdatei unter `public/neu/` nach `#9d3dfe` und `rgba(157, 61, 254`.
- **Pin:** `test/adminPanelGone.test.js:55-66` liest `public/` rekursiv. Kein neuer Quelltext enthält `admin.html`, `kalkulation.html` oder `/api/portal-admin`.
- **Testcode in diesem Plan** zeigt Namen und Assertions. Importe (`node:test`, `node:assert/strict`, `fakeDom`, `knDaten`, das Modul) und Hilfsfunktionen schreibt der Implementierer dazu.
## Review Focus
1. **Alt-Modul sendet doppelt kodierten Body:** `auftrag-portal.js` schickt `JSON.stringify(...)`, das neue `api()` kodiert Objekte selbst. Ohne Adapter scheitert „Auftrag bestätigen“ still mit 400. Tests: Task 1 (`legacyRequest`), Task 8 (POST-Body ist ein Objekt).
2. **Reiter-Sprung überschreibt ausdrückliche Wahl:** Mit offener Rechnung springt nur der Bereichs-Einstieg (Route ohne `reiter`) auf Rechnungen. `#abo`, `#wartung`, `#stunden`, `#speicher` und ein Klick auf einen Reiter nie. Allein `route.reiter` entscheidet, die Adressleiste ist keine zweite Wahrheit. Hosting-Kunden dürfen auf `#stunden` nicht ins Leere laufen. Tests: Task 16.
3. **Ansichtsmodus schreibt als Kunde:** Mitarbeiter markieren Chat gelesen, bestellen, ändern Speicher oder Passwort. Tests: Task 7 (Chat), 8 (Aufträge), 9 (Zahlungsart), 11 (Speicher), 12 (Bestellen), 13 (Wechsel), 14 (Gutschrift), 15 (Passwort).
4. **Wortlaut der Fassung 5 driftet:** Ein geänderter Satz auf Bestell-, Wechsel-, Speicher-, Gutschrift- oder öffentlichen Seiten wäre eine unbemerkte Rechtsänderung. Tests: Orakel in Task 11 bis 14 und 17, Hash-Pin der Gutschrift-Bedingungen in Task 14.
5. **Teilausfall und fremde Inhalte:** Fällt Aktivität, Rechnungen, Aufträge, Stundenkonto, Verträge oder Vorschau aus, oder steht `<img onerror>` bzw. `javascript:` in Projektname, Rechnungsprojekt, Chat, Rückfrage oder Vorschau-Adresse, bleibt der Rest der Seite stehen und nichts wird als HTML oder Link ausgeführt. Tests: Task 5, 6, 8, 9, 10, 14, 16.
## Festlegungen dieses Plans
Diese Punkte legen weder Spec noch Übersicht fest.
- **Projektdetails** sind immer Etage 2 (`#projekt?projekt=<id>`), auch bei einer einzigen Website. P1 ruft dafür `zeigeWebsite` mit `route.etage === 'projekt'` (Wurzel `#etage-body`). Die ID liest die Ansicht aus `projekt=`, hilfsweise aus `id=`.
- **Reiter nach Abo:** Ohne laufendes Abo gibt es nur Rechnungen und Abo (dort die Abo-Auswahl). Hosting zeigt zusätzlich E-Mail, Wartung zusätzlich Stunden und E-Mail.
- **„Verträge hier kündigen“** steht unter jedem Reiter, auch ohne Abo. „Vertrag widerrufen · bis TT.MM.“ steht im Reiter Abo nur bei `withdrawalOpen`.
- **„Postfach anfragen“** wandert in den Reiter E-Mail. Der Chat öffnet mit der Vorgabe `Bitte lege ein neues Postfach an: `.
- **Offene Rechnung** heißt wie in V3 `Number(balance) > 0`. Eine offene: `Rechnung offen · RE-… · 50,00 € · Zahlen`. Mehrere: `2 Rechnungen offen · 150,00 € · Zahlen`.
- **Reiterwahl in Verträge:** Allein `route.reiter` entscheidet. Die Ansicht liest `location.hash` nicht. Fehlt `route.reiter` (Bereichs-Einstieg über `#vertraege` oder die Leiste), gilt Rechnungen bei offener Rechnung, sonst Abo. Das setzt voraus, dass P1 `routeAusHash('#vertraege')` ohne `reiter` liefert (VF8). Reiterklicks navigieren mit den ausdrücklichen Hashes `#rechnungen`, `#abo`, `#stunden`, `#speicher`.
- **Wechsel mit gemerkter Funktion (E14, C5):** Hat `ansicht-wechsel.js` eine Funktion gesendet und liefert der Server danach `subscription.wechselFunktion`, geht die Ansicht mit `ersetzen` zu `#funktionen`, nicht zu `#vertraege`. Dort öffnet P3 (Task 6) den Einschalt-Ablauf. Dasselbe gilt für die Rückkehr mit `?abo=success` in Task 16. Ohne gemerkte Funktion bleibt alles wie in Fassung 5.
- **Stunden** erscheinen als `2,5 Std.` (Komma, höchstens zwei Stellen).
- Der Erklärtext unter dem Chat-Composer („Enter = Senden …“) und alle Untertitel unter Überschriften entfallen.
## Dateiübersicht
Quelle `Tabelle` = Zuständigkeitstabelle der Übersicht, `Stub` = Datei, die P1 als Stub anlegt und die P2 füllt (P1 Task 12), `VF1` = Zusatzdatei, die P2 braucht (Vertragsfrage VF1).
| Datei | Quelle | Aufgabe | Task |
|---|---|---|---|
| `public/neu/ansicht-hilfen.js` | VF1 | Formate, Bausteinklassen `K`, `legacyRequest`, `navigiere`, `zuAnsicht`, Ereignisse, Hinweis- und Chat-Vorgabe-Speicher | 1 |
| `test/support/knDaten.js`, `test/support/knWortlautOrakel.js` | VF1 | Testdaten und `mit()`-Rahmen, VM-Orakel für den alten Text | 1, 2 |
| `public/neu/rechnung-offen.js` | VF1, VF3 | Zeile „Rechnung offen“ für Website und Funktionen (P3) | 3 |
| `public/neu/install-hinweis.js` | VF1 | Installationshinweis | 4 |
| `public/neu/website-details.js` | VF1 | Auftrag, Änderungen, Arbeiten, Dateien, Aktivität | 5 |
| `public/neu/ansicht-website.js` | Tabelle, Stub | Projektliste, Etage `projekt`, Aktivität | 6 |
| `public/neu/ansicht-chat.js` | Tabelle, Stub | Chat | 7 |
| `public/neu/dashboard.html` | P1-Datei | zwei Zeilen für den Composer (P1 Task 12 erlaubt sie): Skript und die bereinigte CSS-Kopie | 7 |
| `public/neu/chat-composer.css` | VF1 | Kopie von `public/chat-composer.css` ohne Lila, Gewicht 800 und Glas (Prüfung `lila` liest den Text jedes Stylesheets) | 7 |
| `test/knHuelleSeite.test.js` | P1-Test | eine Zeile: erlaubt `/neu/chat-composer.css` statt `/chat-composer.css` | 7 |
| `public/neu/vertraege-rechnungen.js` | VF1 | Reiter Rechnungen mit Aufträgen | 8 |
| `public/neu/vertraege-abo.js` | VF1 | Reiter Abo, Abo-Auswahl, Einstiege | 9 |
| `public/neu/ansicht-stunden.js` | Tabelle | Stundenkonto mit Vorschuss | 10 |
| `public/neu/ansicht-speicher.js` | Tabelle | E-Mail-Speicher 1:1, Postfach anfragen | 11 |
| `public/neu/ansicht-bestellen.js`, `ansicht-wechsel.js` | Tabelle, Stub | Bestellen und Wechsel 1:1 (Fassung 5); der Wechsel mit gemerkter Funktion springt zu `#funktionen` | 12, 13 |
| `public/neu/ansicht-gutschrift.js`, `ansicht-passwort.js` | Tabelle, Stub | Gutschrift 1:1, Passwort | 14, 15 |
| `public/neu/ansicht-vertraege.js` | Tabelle, Stub | Rahmen mit Reitern | 16 |
| `public/neu/oeffentlich/{login,kuendigen,widerrufen,offline}.html` | Tabelle | Öffentliche Seiten im neuen Stil | 17 |
| `public/neu/oeffentlich/auftrag.html` | Spec 5.1, VF1 | Auftragsbestätigung im neuen Stil (Link aus Mail K31) | 17 |
| `public/neu/oeffentlich/oeffentlich.css` | VF1 | Aussehen dieser fünf Seiten, auch der `ab-*`-Klassen der Auftragsseite | 17 |
| `public/neu/ansichten.css` | Tabelle, Stub | Aussehen aller P2-Ansichten und des Aufträge-Abschnitts | 18 |
| `/root/reports/kundenansicht-neu-2026-10-15/werkzeug/` (eigenes Git): `views.mjs`, `gates.mjs`, `fixtures/endpunkte-p2.mjs`, `fixtures/endpunkte.mjs`, `vertrag-endpunkte.mjs`, `vorschau/seiten.mjs` (ein Eintrag) | Tabelle (P0 legt an, P2 ergänzt) | Ansichten, Gate `p2`, drei Endpunkte, Seite `auftrag.html` | 19 |
| `test/knAnsicht*.test.js` (19 Dateien) | VF1 | Tests dieses Pakets | 1 bis 18 |
**Bestehende Testdateien und Pins:** Genau eine wird geändert, `test/knHuelleSeite.test.js` (P1-eigene Datei im selben Strang, eine Zeile in Task 7). Geprüft: Kein Test liest `public/` rekursiv außer `adminPanelGone.test.js:32-66` (siehe Constraint); die `readdirSync`-Stellen in `termineView.test.js:324,397,418`, `termineFragebogenView.test.js:1023`, `shopWiring.test.js:49`, `zeitenView.test.js:391` lesen `public/` nicht rekursiv. `test/knHuelleSeite.test.js` (P1) erlaubt `/chat-composer.js` und `/chat-composer.css` in `dashboard.html`; Task 7 stellt die Stil-Zeile auf `/neu/chat-composer.css` um (VF10). Nur lesend benutzt: `legalTexts.test.js:18` (Hash `2b7a745c…473e62` des Blocks `TAKEOVER_CONDITIONS`, Task 14) und `auftraegeKundenportalDaten.js` (Testdaten `eintrag`, Task 8).
---
### Task 1: Hilfen, Testdaten und Quelltext-Regeln
**Files:**
- Create: `public/neu/ansicht-hilfen.js`, `test/support/knDaten.js`
- Test: `test/knAnsichtHilfen.test.js`, `test/knAnsichtQuelltext.test.js`
**Interfaces:**
- Consumes: `api(pfad, { method, body })` und `ApiFehler` aus `./api.js`, `geheZu(hash, { ersetzen })` aus `./router.js`, `hashFuerAnsicht(name)` aus `./routen.js` (alle P1), `safeUrl` aus `../fn-util.js`.
- Produces `ansicht-hilfen.js`:
```js
export const K // Bausteinklassen aus P1 basis.css: knopf, primaer, zweit, textknopf, feld, liste, zeile, abschnitt, meldung, fehler, leer, versteckt:'hidden', pille:{grau,blau,orange,violett,gruen,rot}
export const fmtEuro // (euro) → '50,00\u00a0€' (geschütztes Leerzeichen vor dem Euro-Zeichen)
export const fmtDatum // (wert) → 'TT.MM.JJJJ' (Europe/Berlin), 'TT.MM.JJJJ' bleibt, leer → '–'
export const fmtDatumZeit // (wert) → 'TT.MM.JJ, HH:MM' (Europe/Berlin)
export const fmtTagMonat // (wert) → 'TT.MM.' (Europe/Berlin), ungültig → ''
export const fmtStd // (minuten) → '2,5 Std.' | '2 Std.' | '0,75 Std.'
export const fmtMinuten // (minuten) → '' | '45 min' | '2 h' | '2 h 30 min'
export const fmtBytes // (bytes) → '12 B' | '1,5 KB' | '5,0 MB'
export const ABO_NICHT_GELADEN = 'Deine Verträge konnten gerade nicht geladen werden. Bitte versuche es später noch einmal.'
export function legacyRequest(api) // → (pfad, opts) => api(pfad, { ...opts, body: Objekt }); String-Body wird per JSON.parse zum Objekt
export function navigiere(hash, { ersetzen = false } = {}) // geheZu(); bei Ausnahme window.location.hash = hash
export function zuAnsicht(name, projektId = '') // → { href, 'data-view': name, 'data-project'?: projektId }; href = hashFuerAnsicht(name) (+ '?projekt=<id>')
export function meldeTitel(el, text) // el.dispatchEvent(new CustomEvent('kn:titel', { bubbles: true, detail: { text } }))
export function meldeZaehlerNeu(was) // 'chat'|'vertraege'|'alle': document.dispatchEvent(new CustomEvent('kn:zaehler-neu', { bubbles: true, detail: { was } })), no-op ohne document/CustomEvent
export function merkeHinweis(text); export function nimmHinweis() // einmaliger Hinweis für die nächste Verträge-Ansicht, danach ''
export function setzeChatVorgabe(text); export function nimmChatVorgabe() // Nachrichtenvorgabe für den nächsten Chat
export async function ladeAbo() // GET /api/abo → Objekt | null (Fehler → null)
export function wechselFunktionAus(sub) // sub = subscription | undefined | null → { key, projectId } | null (V11: beide Felder nichtleere Strings)
export { safeUrl }
```
- Produces `test/support/knDaten.js` (Formen laut `server/routes/abo.js:88-214`, `routes/customerData.js:18-100` mit `services/customerInvoices.js:61-87`, `routes/projects.js:25-90`, `routes/chat.js:26-35`, `services/takeoverCredits.js:366-384`):
```js
export const mit = (tabelle, fn) => async () => … // installFakeDom mit 80 Antwortfunktionen; Schlüssel 'GET /api/abo' (oder nur Pfad); Wert Objekt (200) oder (aufruf) => ({ status, body }); unbekannt → 404 { error: 'unbekannt' }. Setzt globalThis.CustomEvent auf eine Attrappe mit beschreibbaren Feldern (fakeDom schreibt target und ergebnisse) und stellt sie danach zurück
export const mitDom = (fn) => … // wie mit() ohne Antworten, führt fn(fx) sofort aus (synchrone Tests)
export const neueWurzel = (fx) => Element // div, an document.body gehängt
export const VORSCHAU = { prorationDate: 1760000000, amountDueCents: 3000, creditCents: 1500, monthlyCents: 5000, nextChargeAt: '2026-11-16T00:00:00.000Z', minTermEnd: '2027-10-16T00:00:00.000Z' } // POST /api/abo/switch/preview
export const VORSCHAU_SPEICHER = { direction: 'down', units: 1, totalGb: 10, monthlyCents: 100, deltaMonthlyCents: -100, prorationCents: 0, nextChargeAt: '2026-11-16T00:00:00.000Z', effectiveAt: '2026-11-01T00:00:00.000Z' } // POST /api/abo/storage/preview
export const LIMITS = { maxFiles: 3, maxFileBytes: 8388608, maxTotalBytes: 10485760 } // takeover-credits
export const me = (over = {}), projekt = (over = {}), rechnung = (over = {}), nachricht = (over = {}), angebot = (over = {})
export const abo = ({ produkt = 'wartung', ...over } = {}) // subscription: active, productKey, priceAmount, storage, canChangeStorage, canSwitch, withdrawals …
export const ledger = (over = {}), aboAntwort = (over = {}), anfrageGutschrift = (over = {})
```
- [ ] **Step 1: Voraussetzungen prüfen**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git tag --list 'kn-p*' && ls public/neu`
Expected: `kn-p0` und `kn-p1` stehen in der Liste. `ls` zeigt `dom.js api.js routen.js router.js reiter.js etage.js tokens.css basis.css huelle.css dashboard.html` und die Stubs `ansicht-website.js ansicht-chat.js ansicht-vertraege.js ansicht-passwort.js ansicht-gutschrift.js ansicht-wechsel.js ansicht-bestellen.js ansichten.css`. Fehlt etwas, anhalten und dem Controller melden.
- [ ] **Step 2: Tests schreiben**
`test/knAnsichtHilfen.test.js`:
```js
test('Formate: Euro mit geschütztem Leerzeichen, Daten in Berliner Zeit', () => {
assert.equal(fmtEuro(50), '50,00\u00a0€')
assert.equal(fmtDatum('2026-10-17T10:00:00Z'), '17.10.2026')
assert.equal(fmtDatum('17.10.2026'), '17.10.2026')
assert.equal(fmtDatum(''), '–')
assert.equal(fmtDatumZeit('2026-10-17T10:00:00Z'), '17.10.26, 12:00')
assert.equal(fmtTagMonat('2026-10-17T21:59:59Z'), '17.10.')
assert.equal(fmtTagMonat('2026-10-17T22:00:00Z'), '18.10.')
assert.equal(fmtTagMonat('kaputt'), '')
})
test('Formate: Stunden, Minuten, Bytes', () => {
assert.deepEqual([150, 120, 45, 0].map(fmtStd), ['2,5 Std.', '2 Std.', '0,75 Std.', '0 Std.'])
assert.deepEqual([0, 45, 120, 150].map(fmtMinuten), ['', '45 min', '2 h', '2 h 30 min'])
assert.deepEqual([12, 1536, 5 * 1024 * 1024].map(fmtBytes), ['12 B', '1,5 KB', '5,0 MB'])
})
test('legacyRequest: String-Body wird zum Objekt, Objekt und fehlender Body bleiben', async () => {
const aufrufe = []
const alt = legacyRequest(async (pfad, opts) => { aufrufe.push([pfad, opts]); return {} })
await alt('/a', { method: 'POST', body: '{"x":1}' }); await alt('/b', { method: 'POST', body: { y: 2 } }); await alt('/c')
assert.deepEqual(aufrufe, [['/a', { method: 'POST', body: { x: 1 } }], ['/b', { method: 'POST', body: { y: 2 } }], ['/c', {}]])
})
test('meldeZaehlerNeu ohne Dokument tut nichts, Hinweis und Chat-Vorgabe gelten einmal', () => {
assert.doesNotThrow(() => meldeZaehlerNeu('chat'))
merkeHinweis('Gespeichert.'); assert.equal(nimmHinweis(), 'Gespeichert.'); assert.equal(nimmHinweis(), '')
setzeChatVorgabe('Hallo'); assert.equal(nimmChatVorgabe(), 'Hallo'); assert.equal(nimmChatVorgabe(), '')
})
test('wechselFunktionAus: nur vollständige Werte zählen (V11)', () => {
assert.deepEqual(wechselFunktionAus({ wechselFunktion: { key: 'termine', projectId: 'p1', extra: 1 } }), { key: 'termine', projectId: 'p1' })
for (const sub of [undefined, null, {}, { wechselFunktion: null }, { wechselFunktion: { key: 'termine' } }, { wechselFunktion: { key: '', projectId: 'p1' } }, { wechselFunktion: { key: 1, projectId: 'p1' } }]) assert.equal(wechselFunktionAus(sub), null)
})
test('zuAnsicht ist die Brücke der Hülle, meldeTitel steigt zu den Eltern auf', () => mitDom((fx) => {
assert.deepEqual(zuAnsicht('takeover'), { href: '#gutschrift', 'data-view': 'takeover' })
assert.deepEqual(zuAnsicht('projekt', 'p1'), { href: '#projekt?projekt=p1', 'data-view': 'projekt', 'data-project': 'p1' })
assert.equal(zuAnsicht('invoices').href, '#rechnungen')
const aussen = fx.document.createElement('div'); const innen = fx.document.createElement('p'); aussen.append(innen); const titel = []
aussen.addEventListener('kn:titel', (e) => titel.push(e.detail.text)); meldeTitel(innen, 'Zwei'); assert.deepEqual(titel, ['Zwei'])
}))
```
`test/knAnsichtQuelltext.test.js` prüft die vorhandenen Module aus der Liste `ansicht-hilfen, rechnung-offen, install-hinweis, website-details, ansicht-website, ansicht-chat, ansicht-vertraege, vertraege-rechnungen, vertraege-abo, ansicht-stunden, ansicht-speicher, ansicht-bestellen, ansicht-wechsel, ansicht-gutschrift, ansicht-passwort` (Kommentare vorher entfernt, fehlende Dateien übersprungen, `WORTLAUT = ['ansicht-speicher','ansicht-bestellen','ansicht-wechsel','ansicht-gutschrift']`). Die Stubs von P1 bestehen die Regeln schon:
```js
test('P2-Module: kein HTML-Einsetzen, nur relative Importe, keine Admin-Verweise', () => {
for (const n of vorhanden) {
const q = lies(n)
assert.doesNotMatch(q, /innerHTML|insertAdjacentHTML|outerHTML|document\.write|console\.log/, n)
assert.doesNotMatch(q, /from\s+'\/|import\(\s*'\//, `${n}: absoluter Import`)
assert.doesNotMatch(q, /admin\.html|kalkulation\.html|\/api\/portal-admin/, n)
}
})
test('P2-Module: Du-Form, nur die vier Wortlaut-Dateien behalten die Sie-Form (C14)', () => {
for (const n of vorhanden.filter((x) => !WORTLAUT.includes(x))) assert.doesNotMatch(lies(n), /\b(Sie|Ihr(e[nmrs]?)?|Ihnen)\b/, n)
})
test('public/neu/: kein Lila in irgendeiner Textdatei (die Prüfung lila liest jedes geladene Stylesheet samt ungenutzter Regeln)', () => {
for (const pfad of dateienUnter('public/neu', /\.(css|html|js|mjs|svg|webmanifest)$/)) assert.doesNotMatch(readFileSync(pfad, 'utf8'), /#9d3dfe|rgba?\(\s*157\s*,\s*61\s*,\s*254/i, pfad)
})
```
Der Lila-Test liest den Rohtext einschließlich Kommentaren (so wie die Prüfung den Stylesheet-Text liest); `dateienUnter(ordner, muster)` durchsucht rekursiv und gehört zu den Hilfen des Tests. Er läuft ab Task 1 und deckt damit auch alle späteren Dateien dieses Pakets (Task 7, 17, 18) und die von P3 und P4 ab.
- [ ] **Step 3: Test laufen lassen, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knAnsichtHilfen.test.js test/knAnsichtQuelltext.test.js`
Expected: `knAnsichtHilfen` FAIL mit `ERR_MODULE_NOT_FOUND` für `public/neu/ansicht-hilfen.js`, `knAnsichtQuelltext` grün.
- [ ] **Step 4: Implementieren**
`ansicht-hilfen.js` nach der Signatur oben. Datums- und Zeitformate über `Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', … }).formatToParts` zusammensetzen, nicht auf die Ausgabe von `toLocaleString` verlassen. `K` mit den Klassen, die P1 `basis.css` führt (`kn-knopf`, `kn-knopf kn-knopf-primaer`, `kn-pille kn-pille-<ton>` usw.). `knDaten.js` mit den Bauern; Standardwerte aus den Quellzeilen oben: Abo mit `startedAt`, `currentPeriodEnd`, `priceAmount` in Cent, `storage:{units,pendingSetup,pendingReduction}`; Produkte `hosting` 2000 und `wartung` 5000 Cent mit je einer Feature-Zeile `['Titel','Detail']`; `termsVersion: '2026-09-30-v5'`, `lateStartAt: '2026-10-30T00:00:00.000Z'`, `mailStorage: { includedGb: 5, gbPerUnit: 5, unitPriceCents: 100, maxUnits: 10 }`, `available: true`; `me()` mit Kunde `anna@example.com`.
- [ ] **Step 5: Lauf grün**
Run: derselbe Befehl wie Step 3. Expected: PASS, 9 Tests.
- [ ] **Step 6: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/ansicht-hilfen.js test/support/knDaten.js test/knAnsichtHilfen.test.js test/knAnsichtQuelltext.test.js && git commit -m "Kundenansicht neu P2: Hilfen, Testdaten und Quelltext-Regeln" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 2: Wortlaut-Orakel
**Files:**
- Create: `test/support/knWortlautOrakel.js`
- Test: `test/knAnsichtOrakel.test.js`
**Interfaces:**
- Consumes: `public/app.js` (klassisches Skript, nur gelesen), `test/support/knDaten.js`.
- Produces:
```js
export function sichtbarerText(quelle) // HTML-String oder Element → Text ohne Tags, Kommentare, <script>, <style> und ohne jeden Leerraum (auch U+00A0); &amp; &lt; &gt; &quot; &#39; &nbsp; dekodiert
export function ohneKopf(html) // streicht <header class="kb-head">…</header> samt Überschrift und Untertitel (Titel zeichnet die Hülle, Untertitel entfallen); ohne Kopf bleibt alles
export function ladeAlteApp() // → { bestellen(daten, key): string, wechsel(daten, vorschau): Promise<string>, speicher(daten, vorschau?): Promise<string>, gutschrift(daten, antwort): Promise<string> }
```
`ladeAlteApp` führt `app.js` per `vm.runInContext` in einem Kontext mit Attrappen aus: `window.addEventListener`, `window.matchMedia` → `{ matches: false, addEventListener(){} }`, `document.addEventListener/querySelector/getElementById/querySelectorAll`, `navigator { userAgent: '' }`, `fetch` nach URL-Tabelle, `CustomEvent`. Das sind alle Top-Level-Berührungen (`app.js:1039, 1190, 2135-2136, 2553, 2556`). Dann ruft sie die Funktionsdeklarationen `renderOrderView` (`:921`), `renderSwitchView` (`:1046`), `renderStoragePanel` (`:1249`) und `loadTakeoverCard` (`:1761`) mit Attrappen-Elementen auf und gibt deren `innerHTML` zurück. `takeoverState.customer` wird per `vm.runInContext('takeoverState.customer = { email: "anna@example.com" }', ctx)` gesetzt. `wechsel` und `speicher` beantworten die Vorschau-Anfrage mit dem übergebenen Objekt und warten zwei Takte, damit auch der Fall „Reduzierung geplant“ (`app.js:1405`) im selben Endzustand liegt wie die neue Seite.
- [ ] **Step 1: Test schreiben**
```js
test('sichtbarerText: Tags, Skripte und jeder Leerraum fallen weg, Entitäten werden Text', () => {
assert.equal(sichtbarerText('<p>Hallo&nbsp;<b>Welt</b> &amp; Co<!-- Hinweis für Entwickler --><script>x()</script></p>'), 'HalloWelt&Co')
assert.equal(sichtbarerText('50,00\u00a0€ '), '50,00€')
})
test('ohneKopf streicht nur den Kopf, nicht die Absätze danach', () => {
assert.equal(sichtbarerText(ohneKopf('<header class="kb-head"><h1>A</h1><p>B</p></header><p>C</p>')), 'C')
assert.equal(sichtbarerText(ohneKopf('<header class="kb-head"><h1>A</h1></header><p>C</p>')), 'C')
assert.equal(sichtbarerText(ohneKopf('<p>C</p>')), 'C')
})
test('altes app.js rendert in der VM: Bestellen, Wechsel, Speicher, Gutschrift', async () => {
const alt = ladeAlteApp()
assert.match(sichtbarerText(alt.bestellen(aboAntwort(), 'wartung')), /Zahlungspflichtigbestellen/)
const hosting = aboAntwort({ subscription: abo({ produkt: 'hosting', canSwitch: true }) })
assert.match(sichtbarerText(await alt.wechsel(hosting, VORSCHAU)), /Zahlungspflichtigbestellen/)
assert.match(sichtbarerText(await alt.speicher(aboAntwort({ subscription: abo() }))), /E-Mail-Speicher/)
assert.match(sichtbarerText(await alt.gutschrift(aboAntwort({ subscription: abo() }), { requests: [], limits: LIMITS })), /Antragabsenden/)
})
```
- [ ] **Step 2: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knAnsichtOrakel.test.js`
Expected: FAIL `ERR_MODULE_NOT_FOUND` für `knWortlautOrakel.js`.
- [ ] **Step 3: Orakel implementieren** (Signaturen und Attrappen wie oben).
- [ ] **Step 4: Lauf grün**
Run: derselbe Befehl. Expected: PASS, 3 Tests.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add test/support/knWortlautOrakel.js test/knAnsichtOrakel.test.js && git commit -m "Kundenansicht neu P2: Wortlaut-Orakel für die Texte der Fassung 5" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 3: Zeile „Rechnung offen“ (für Website und Funktionen)
**Files:**
- Create: `public/neu/rechnung-offen.js`
- Test: `test/knAnsichtRechnungOffen.test.js`
**Interfaces:**
- Consumes: `api` (`./api.js`), `h` (`./dom.js`), `fmtEuro`, `safeUrl`, `zuAnsicht` (`./ansicht-hilfen.js`), `GET /api/invoices` → `{ invoices:[{ id, number, balance, portalUrl, … }], incomplete }`.
- Produces (P3 verbraucht es, VF3):
```js
export function offeneRechnungen(antwort) // → Rechnung[] mit Number(balance) > 0, Reihenfolge der Antwort
export async function ladeOffeneRechnungen() // → Rechnung[]; Fehler → []
export function rechnungOffenZeile(offen) // → <p class="kn-rechnung-offen"> | null (leere Liste)
```
- [ ] **Step 1: Test schreiben**
```js
const NB = '\u00a0'
test('eine offene Rechnung: Text und Link auf die Rechnung', () => mitDom((fx) => {
const p = rechnungOffenZeile([rechnung({ number: 'RE-2026-0042', balance: 50, portalUrl: 'https://invoice.stripe.com/i/x' })])
assert.equal(p.textContent, `Rechnung offen · RE-2026-0042 · 50,00${NB}€ · Zahlen`)
const a = fx.finde(p, 'a'); assert.equal(a.getAttribute('href'), 'https://invoice.stripe.com/i/x'); assert.equal(a.getAttribute('rel'), 'noopener noreferrer')
}))
test('mehrere offene: Summe, Link über die Brücke zu #rechnungen; unsicherer Link fällt darauf zurück', () => mitDom((fx) => {
const zwei = [rechnung({ balance: 100 }), rechnung({ balance: 50 })]
assert.equal(rechnungOffenZeile(zwei).textContent, `2 Rechnungen offen · 150,00${NB}€ · Zahlen`)
const a = fx.finde(rechnungOffenZeile(zwei), 'a'); assert.deepEqual([a.getAttribute('href'), a.getAttribute('data-view')], ['#rechnungen', 'invoices'])
assert.equal(fx.finde(rechnungOffenZeile([rechnung({ balance: 5, portalUrl: 'javascript:alert(1)' })]), 'a').getAttribute('href'), '#rechnungen')
assert.equal(rechnungOffenZeile([]), null)
}))
test('offeneRechnungen filtert nach Betrag, ladeOffeneRechnungen schluckt Fehler', mit({ 'GET /api/invoices': () => ({ status: 500, body: { error: 'x' } }) }, async () => {
assert.deepEqual(offeneRechnungen({ invoices: [rechnung({ balance: 0 }), rechnung({ id: 'b', balance: 1 })] }).map((r) => r.id), ['b'])
assert.deepEqual(await ladeOffeneRechnungen(), [])
}))
```
- [ ] **Step 2: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knAnsichtRechnungOffen.test.js`
Expected: FAIL `ERR_MODULE_NOT_FOUND` für `rechnung-offen.js`.
- [ ] **Step 3: Implementieren** (Signaturen oben). Eine Rechnung mit sicherem `portalUrl`: `target="_blank" rel="noopener noreferrer"`. Alle anderen Fälle: `zuAnsicht('invoices')`. Der Betrag ist die Summe der `balance`.
- [ ] **Step 4: Lauf grün.** Run wie Step 2. Expected: PASS, 3 Tests.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/rechnung-offen.js test/knAnsichtRechnungOffen.test.js && git commit -m "Kundenansicht neu P2: Zeile Rechnung offen" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 4: Installationshinweis
**Files:**
- Create: `public/neu/install-hinweis.js`
- Test: `test/knAnsichtInstall.test.js`
**Interfaces:**
- Consumes: `h` (`./dom.js`). Logik aus `app.js:2153-2161` (Standalone, iOS) und `:2267-2293`.
- Produces:
```js
export function installAngebot({ appLayout, standalone, canPrompt, ios, dismissed }) // → { zeigen: boolean, art: 'prompt'|'ios'|null }
export function baueInstallHinweis({ art, onInstall, onDismiss }) // → HTMLElement
export function starteInstallQuelle(win = window) // je win genau eine Quelle (WeakMap) → { ios, standalone(), kannPrompt(), installiere(), beiAenderung(fn) }; hört beforeinstallprompt und appinstalled
export function istAusgeblendet(); export function merkeAusgeblendet() // localStorage 'kb-install-dismissed' = '1', immer in try/catch
```
- [ ] **Step 1: Test schreiben**
```js
const a = (o) => installAngebot({ appLayout: true, standalone: false, canPrompt: false, ios: false, dismissed: false, ...o })
test('installAngebot: nur Handy, nicht installiert, mit Prompt oder iOS, nicht ausgeblendet', () => {
assert.deepEqual(a({ canPrompt: true }), { zeigen: true, art: 'prompt' })
assert.deepEqual(a({ ios: true }), { zeigen: true, art: 'ios' })
assert.deepEqual(a({ ios: true, canPrompt: true }), { zeigen: true, art: 'prompt' })
for (const o of [{}, { canPrompt: true, standalone: true }, { canPrompt: true, appLayout: false }, { canPrompt: true, dismissed: true }]) assert.equal(a(o).zeigen, false)
})
test('Hinweis: Texte, Knöpfe, Sperre bei kaputtem localStorage', () => mitDom((fx) => {
let n = 0; const zaehle = () => { n += 1 }
const ios = baueInstallHinweis({ art: 'ios', onInstall() {}, onDismiss() {} })
assert.match(ios.textContent, /WEBklar als App/); assert.match(ios.textContent, /Tippe auf „Teilen“ und dann auf „Zum Home-Bildschirm“\./)
const prompt = baueInstallHinweis({ art: 'prompt', onInstall: zaehle, onDismiss: zaehle })
assert.match(prompt.textContent, /Mit einem Tipp auf deinem Homescreen – ohne App-Store\./)
fx.klick(fx.finde(prompt, (e) => e.textContent === 'Installieren')); fx.klick(fx.finde(prompt, '[aria-label="Hinweis ausblenden"]')); assert.equal(n, 2)
globalThis.localStorage = { getItem() { throw new Error('x') }, setItem() { throw new Error('x') } }
assert.equal(istAusgeblendet(), false); assert.doesNotThrow(merkeAusgeblendet); delete globalThis.localStorage
}))
test('Installquelle: beforeinstallprompt merken, installieren, appinstalled räumt', async () => {
const handler = {}; const win = { addEventListener: (t, f) => { handler[t] = f }, matchMedia: () => ({ matches: false }), navigator: { userAgent: 'iPhone' } }
const q = starteInstallQuelle(win); assert.equal(q.ios, true); assert.equal(starteInstallQuelle(win), q)
let prompted = 0; handler.beforeinstallprompt({ preventDefault() {}, prompt: async () => { prompted += 1 } })
assert.equal(q.kannPrompt(), true); await q.installiere(); assert.equal(prompted, 1); assert.equal(q.kannPrompt(), false)
})
```
- [ ] **Step 2: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knAnsichtInstall.test.js`
Expected: FAIL `ERR_MODULE_NOT_FOUND`.
- [ ] **Step 3: Implementieren** (Signaturen oben, Texte wörtlich aus dem Test, kein Icon). Das iOS-Erkennen wie `app.js:2157`. Fehlendes `matchMedia` zählt als `false`.
- [ ] **Step 4: Lauf grün.** Run wie Step 2. Expected: PASS, 3 Tests.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/install-hinweis.js test/knAnsichtInstall.test.js && git commit -m "Kundenansicht neu P2: Installationshinweis" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 5: Bausteine der Projektdetails und Aktivität
**Files:**
- Create: `public/neu/website-details.js`
- Test: `test/knAnsichtWebsiteDetails.test.js`
**Interfaces:**
- Consumes: `h`, `K`, `zuAnsicht`, `fmtBytes`, `fmtMinuten`, `fmtDatum`. Antworten: `GET /api/projects/:id/git` → `{ changes:[{ date, title, details }] }`, `/files` → `{ totalFiles, totalSizeBytes, truncated, topLevel:[{ path, type, fileCount, sizeBytes }] }`, `/work` → `{ order:{ number, topic, status:{ label, tone, step } }|null, entries:[{ date, kind, label, person, minutes, changes, text, supports }], totalMinutes }`, `GET /api/activity` → `{ tickets:[order + createdAt + entries] }` (`routes/projects.js:57-100`, `routes/customerData.js:86-104`).
- Produces:
```js
export const AUFTRAG_SCHRITTE = ['Eingegangen', 'In Arbeit', 'Prüfung', 'Erledigt']
export function statusTon(tone) // new→grau active→blau waiting→orange review→violett done→gruen paused→grau stopped→rot, sonst blau
export function tagesUeberschrift(datum, jetzt = new Date()) // 'Heute' | 'Gestern' | 'Mittwoch, 14. Oktober' | 'Mittwoch, 24. Dezember 2025' (Berliner Tag)
export function baueAuftrag(order, gesamtMinuten = 0) // Nummer, Betreff, Status-Pille, Fortschrittsliste, bei tone waiting 'Wir brauchen noch eine Info von dir.' + Link zuAnsicht('chat')
export function baueAenderungen(changes, jetzt) // nach Tagen gruppiert, 5 sichtbar
export function baueArbeiten(entries, leerText) // 4 sichtbar
export function baueDateien(files)
export function baueAktivitaet(tickets, jetzt) // je Auftrag baueAuftrag + baueArbeiten, 3 sichtbar, Knopf 'Alle anzeigen (n)'
export async function ladeProjektDetails(projectId) // → { git, files, work }, jede Quelle einzeln .catch(() => null)
export function baueProjektDetails({ git, files, work }, jetzt) // Abschnitte 'Letzte Änderungen', 'Arbeiten am Projekt', 'Dateien' (je mit Fehlertext, wenn null)
```
Einklappen über `data-einklappbar` (Wrapper, Klasse `is-open`), `data-extra` (versteckte Zeilen), `data-mehr` (Knopf). Fehlertexte und Leertexte wörtlich wie `app.js:301, 345, 362, 399-403`.
- [ ] **Step 1: Test schreiben**
```js
test('Tagesüberschrift im Berliner Tag', () => {
const jetzt = new Date('2026-10-16T09:00:00Z')
assert.deepEqual(['2026-10-15T22:30:00Z', '2026-10-15T20:00:00Z', '2026-10-14T10:00:00Z', '2025-12-24T10:00:00Z'].map((d) => tagesUeberschrift(new Date(d), jetzt)),
['Heute', 'Gestern', 'Mittwoch, 14. Oktober', 'Mittwoch, 24. Dezember 2025'])
})
test('statusTon', () => assert.deepEqual(['new', 'active', 'waiting', 'review', 'done', 'paused', 'stopped', '?'].map(statusTon), ['grau', 'blau', 'orange', 'violett', 'gruen', 'grau', 'rot', 'blau']))
test('Änderungen: nach Tagen, fünf sichtbar, Rest hinter „weitere“, Titel bleibt Text', () => mitDom((fx) => {
const tag = (d, n, p) => Array.from({ length: n }, (_, i) => ({ date: `2026-10-${d}T0${i + 6}:00:00.000Z`, title: `${p} ${i}`, details: [] }))
const el = baueAenderungen([...tag(15, 4, 'Neu'), ...tag(14, 3, 'Alt'), { date: '2026-10-13T06:00:00.000Z', title: '<img src=x onerror=alert(1)>', details: [] }], new Date('2026-10-16T09:00:00Z'))
assert.equal(fx.alle(el, '[data-extra]').length, 3)
assert.equal(fx.finde(el, '[data-mehr]').textContent, '3 weitere Änderungen anzeigen')
assert.equal(fx.finde(el, 'img'), null)
fx.klick(fx.finde(el, '[data-mehr]')); assert.ok(fx.finde(el, '[data-einklappbar]').classList.contains('is-open')); assert.equal(fx.finde(el, '[data-mehr]').textContent, 'Weniger anzeigen')
}))
test('Arbeiten und Dateien: Zähltexte, Leertexte, Ordner mit Schrägstrich', () => mitDom((fx) => {
const e = (i) => ({ date: '2026-10-1' + i, kind: 'work', label: 'Arbeit', minutes: 15, person: 'Kenso' })
assert.equal(fx.finde(baueArbeiten([1, 2, 3, 4, 5].map(e), 'leer'), '[data-mehr]').textContent, '1 weiteren Eintrag anzeigen')
assert.equal(baueArbeiten([], 'Noch keine Arbeiten.').textContent, 'Noch keine Arbeiten.')
const d = baueDateien({ totalFiles: 3, totalSizeBytes: 1536, truncated: true, topLevel: [{ path: 'img', type: 'dir', fileCount: 2, sizeBytes: 1024 }, { path: 'index.html', type: 'file', fileCount: 1, sizeBytes: 512 }] })
assert.match(d.textContent, /3 Dateien · 1,5 KB \(Übersicht unvollständig – sehr großes Projekt\)/); assert.match(d.textContent, /img\//)
assert.equal(baueDateien({ totalFiles: 0 }).textContent, 'Keine Datei-Informationen verfügbar.')
}))
test('Projektdetails: Teilausfall ersetzt nur den betroffenen Abschnitt', () => mitDom(() => {
const el = baueProjektDetails({ git: null, files: { totalFiles: 0 }, work: { order: null, entries: [] } }, new Date())
assert.match(el.textContent, /Die Änderungen können gerade nicht geladen werden\./); assert.match(el.textContent, /Keine Datei-Informationen verfügbar\./)
}))
```
- [ ] **Step 2: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knAnsichtWebsiteDetails.test.js`
Expected: FAIL `ERR_MODULE_NOT_FOUND`.
- [ ] **Step 3: Implementieren** (Signaturen oben). Alles über `h()`. Tag-Vergleich über den Berliner Kalendertag. Zeit je Änderung `HH:MM` in Berliner Zeit. „Arbeiten am Projekt“ erscheint wie `app.js:400`: wenn `order` da ist oder `work` fehlt. Keine Emojis, keine Icons.
- [ ] **Step 4: Lauf grün.** Run wie Step 2. Expected: PASS, 5 Tests.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/website-details.js test/knAnsichtWebsiteDetails.test.js && git commit -m "Kundenansicht neu P2: Bausteine für Projektdetails und Aktivität" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 6: Ansicht Website
**Files:**
- Modify: `public/neu/ansicht-website.js` (Stub von P1 ersetzen)
- Test: `test/knAnsichtWebsite.test.js`
**Interfaces:**
- Consumes: `api`, `h`, `leeren` (V2), `K`, `zuAnsicht`, `meldeTitel`, `fmtDatumZeit`, `safeUrl` (`./ansicht-hilfen.js`), alles aus `./website-details.js`, `ladeOffeneRechnungen`, `rechnungOffenZeile` (`./rechnung-offen.js`), `starteInstallQuelle`, `installAngebot`, `baueInstallHinweis`, `istAusgeblendet`, `merkeAusgeblendet` (`./install-hinweis.js`). `GET /api/projects` → `{ projects:[{ id, projectName, subdomain, previewUrl, liveDomain, status, provisioningStatus, updatedAt }] }` (`routes/projects.js:25-55`).
- Produces:
```js
export async function zeigeWebsite(wurzel, { route, me, nav }) // route.etage: undefined | 'projekt' (query 'projekt=<id>', hilfsweise 'id=<id>'); route.scroll === 'aktivitaet' scrollt zur Aktivität
export function projektStatus(project) // → { ton, text }: 'Vorschau online' gruen | 'Vorschau gestört' rot (provisioningStatus/status enthält 'fail') | 'Vorschau wird vorbereitet' orange (previewUrl, nicht bereit) | 'Noch keine Website' grau
export function vorschauBereit(project) // status ready|deployed UND previewUrl (wie app.js:434)
```
Aufbau Hauptseite: Zeile „Rechnung offen“ (nur wenn offen), Installationshinweis (nur wenn `installAngebot().zeigen`), Projektliste (`[data-projekt=<id>]` mit Frame nur bei `vorschauBereit` und `safeUrl`, Name, Status-Pille, Fakten `Vorschau-Adresse`, `Live-Domain` (`noch nicht verbunden`), `Letzte Änderung`, Links `Website öffnen` und `Projektdetails` = `zuAnsicht('projekt', id)`), `section[data-abschnitt="aktivitaet"]` mit Überschrift `Was wir zuletzt erledigt haben`. Texte ohne Frame: `Vorschau wird vorbereitet …` bzw. `Noch keine Website veröffentlicht`. Leer: `Noch keine Website-Projekte zugewiesen.` Aktivität-Fehler: `Aktivität konnte nicht geladen werden: <Meldung>`, leer: `Es wurden noch keine Arbeiten dokumentiert.` Etage `projekt`: `meldeTitel(wurzel, <Projektname>)` und `baueProjektDetails(await ladeProjektDetails(id))`.
- [ ] **Step 1: Test schreiben**
```js
const HAUPT = { 'GET /api/projects': { projects: [projekt({ id: 'p1', projectName: 'Bäckerei <b>Roth</b>' })] }, 'GET /api/activity': { tickets: [] }, 'GET /api/invoices': { invoices: [] } }
const zeige = async (fx, route = { bereich: 'website', query: '' }, m = me()) => { const w = neueWurzel(fx); await zeigeWebsite(w, { route, me: m, nav: 'tab' }); await fx.ruhe(); return w }
test('projektStatus', () => {
const s = (o) => projektStatus(projekt(o))
assert.deepEqual(s({}), { ton: 'gruen', text: 'Vorschau online' })
assert.deepEqual(s({ status: 'building' }), { ton: 'orange', text: 'Vorschau wird vorbereitet' })
assert.deepEqual(s({ provisioningStatus: 'failed' }), { ton: 'rot', text: 'Vorschau gestört' })
assert.deepEqual(s({ previewUrl: '' }), { ton: 'grau', text: 'Noch keine Website' })
})
test('Website: Ladezustand ist Text und verschwindet', mit(HAUPT, async (fx) => {
const w = neueWurzel(fx); const lauf = zeigeWebsite(w, { route: { bereich: 'website', query: '' }, me: me(), nav: 'tab' })
assert.match(fx.text(w), /Wird geladen …/); await lauf; await fx.ruhe(); assert.doesNotMatch(fx.text(w), /Wird geladen …/)
}))
test('Website: Zeile je Projekt mit Frame, Status, Link über die Brücke; Name bleibt Text', mit(HAUPT, async (fx) => {
const w = await zeige(fx)
assert.equal(fx.finde(w, 'iframe').getAttribute('src'), 'https://baeckerei.project.webklar.com')
assert.match(fx.text(w), /Vorschau online/); assert.match(fx.text(w), /Bäckerei <b>Roth<\/b>/); assert.equal(fx.finde(w, 'b'), null)
const a = fx.finde(w, '[data-projekt="p1"] a[data-view="projekt"]'); assert.deepEqual([a.textContent, a.getAttribute('href'), a.getAttribute('data-project')], ['Projektdetails', '#projekt?projekt=p1', 'p1'])
}))
test('Website: unsichere Vorschau-Adresse ergibt weder Frame noch Link', mit({ ...HAUPT, 'GET /api/projects': { projects: [projekt({ previewUrl: 'javascript:alert(1)' })] } }, async (fx) => {
const w = await zeige(fx)
assert.equal(fx.finde(w, 'iframe'), null); assert.ok(fx.alle(w, 'a').every((a) => !/^javascript/i.test(a.getAttribute('href') || '')))
}))
test('Website: Aktivität fällt aus, die Projekte bleiben', mit({ ...HAUPT, 'GET /api/activity': () => ({ status: 500, body: { error: 'Server kaputt' } }) }, async (fx) => {
const w = await zeige(fx)
assert.match(fx.text(w), /Aktivität konnte nicht geladen werden: Server kaputt/); assert.ok(fx.finde(w, '[data-projekt="p1"]'))
}))
test('Website: leer', mit({ ...HAUPT, 'GET /api/projects': { projects: [] } }, async (fx) => { assert.match(fx.text(await zeige(fx)), /Noch keine Website-Projekte zugewiesen\./) }))
test('Website: offene Rechnung steht als erste Zeile', mit({ ...HAUPT, 'GET /api/invoices': { invoices: [rechnung({ number: 'RE-2026-0042', balance: 50 })] } }, async (fx) => {
const w = await zeige(fx); assert.match(fx.finde(w, '.kn-rechnung-offen').textContent, /^Rechnung offen · RE-2026-0042/)
}))
test('Website: route.scroll scrollt genau einmal zur Aktivität', mit(HAUPT, async (fx) => {
let n = 0; const create = fx.document.createElement.bind(fx.document)
fx.document.createElement = (t) => { const el = create(t); if (t === 'section') el.scrollIntoView = () => { n += 1 }; return el }
await zeige(fx, { bereich: 'website', scroll: 'aktivitaet', query: '' }); assert.equal(n, 1)
}))
test('Etage projekt: Titel, drei Abschnitte, nur das gewählte Projekt (projekt= und id=)', mit({
...HAUPT, 'GET /api/projects': { projects: [projekt({ id: 'p1', projectName: 'Eins' }), projekt({ id: 'p2', projectName: 'Zwei' })] },
'GET /api/projects/p1/git': { changes: [] }, 'GET /api/projects/p1/files': { totalFiles: 0 }, 'GET /api/projects/p1/work': { order: null, entries: [] },
'GET /api/projects/p2/git': { changes: [] }, 'GET /api/projects/p2/files': { totalFiles: 0 }, 'GET /api/projects/p2/work': { order: null, entries: [] },
}, async (fx) => {
const titel = []; const w = neueWurzel(fx); w.addEventListener('kn:titel', (e) => titel.push(e.detail.text))
await zeigeWebsite(w, { route: { bereich: 'website', etage: 'projekt', query: 'projekt=p2' }, me: me(), nav: 'push' }); await fx.ruhe()
assert.deepEqual(titel, ['Zwei']); for (const t of ['Letzte Änderungen', 'Arbeiten am Projekt', 'Dateien']) assert.match(fx.text(w), new RegExp(t))
assert.ok(fx.fetchLog.some((a) => a.url === '/api/projects/p2/git')); assert.ok(!fx.fetchLog.some((a) => a.url === '/api/projects/p1/git'))
await zeigeWebsite(w, { route: { bereich: 'website', etage: 'projekt', query: 'id=p1' }, me: me(), nav: 'push' }); await fx.ruhe(); assert.deepEqual(titel, ['Zwei', 'Eins'])
}))
test('Website: Installationshinweis nur im Handy-Layout und noch nicht installiert', mit(HAUPT, async (fx) => {
fx.window.matchMedia = (q) => ({ matches: q.includes('max-width: 880px') }); fx.window.navigator = { userAgent: 'iPhone', maxTouchPoints: 5 }
assert.match(fx.text(await zeige(fx)), /WEBklar als App/)
}))
```
- [ ] **Step 2: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knAnsichtWebsite.test.js`
Expected: FAIL (der Stub exportiert `projektStatus` nicht, `zeigeWebsite` schreibt nur „Website (folgt)“).
- [ ] **Step 3: Implementieren** (Signaturen und Aufbau oben). Die Frame-Skalierung (Basisbreite 1280 px, `ResizeObserver` nur wenn vorhanden) wie `app.js:98-112` als kleine interne Funktion `skaliereVorschau(rahmen)`. Aktivität lädt parallel zu den Projekten, jede Quelle mit eigenem Fehlerweg. Fehlt `projekt=` und `id=`, gilt das erste Projekt. Späte Antworten über einen Zähler je Aufruf verwerfen. Fehlendes `matchMedia` zählt als „kein Handy-Layout“.
- [ ] **Step 4: Lauf grün.** Run wie Step 2. Expected: PASS, 10 Tests.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/ansicht-website.js test/knAnsichtWebsite.test.js && git commit -m "Kundenansicht neu P2: Ansicht Website mit Projektdetails als Etage" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 7: Ansicht Chat
**Files:**
- Create: `public/neu/chat-composer.css` (bereinigte Kopie von `public/chat-composer.css`, Original bleibt unverändert)
- Modify: `public/neu/ansicht-chat.js` (Stub von P1 ersetzen), `public/neu/dashboard.html` (zwei Zeilen, P1 Task 12 erlaubt sie), `test/knHuelleSeite.test.js` (nur die Stil-Zeile, siehe Step 3)
- Test: `test/knAnsichtChat.test.js`
**Interfaces:**
- Consumes: `api`, `h`, `fmtDatumZeit`, `meldeZaehlerNeu`, `nimmChatVorgabe`; `globalThis.ChatComposer` (`create(opts) → { destroy, focus }`, `renderMessage(msg, { mine, meta, fileUrl })`, `trackScroll(box)`, `public/chat-composer.js:542-1174`). `GET /api/chat/messages[?since=]` → `{ messages:[{ id, sender, senderName, text, attachments, createdAt }], unread }`, `POST /api/chat/messages` `{ text, attachmentIds }` → `{ message }`, `POST /api/chat/read` (`routes/chat.js:107-246`).
- Produces:
```js
export async function zeigeChat(wurzel, { route, me, nav })
export const CHAT_TEXTE = { platzhalter: 'Nachricht an das WEBklar-Team…', kurz: 'Nachricht…', gesperrt: 'Im Ansichtsmodus kann nicht als Kunde geschrieben werden.', leer: 'Noch keine Nachrichten.' }
export function nachrichtMeta(msg) // 'Du · 14.10.26, 10:05' | '<senderName oder Webklar-Team> · …'
```
Composer-Optionen wie `app.js:762-778`: `root`, `dropZone`, `uploadUrl: '/api/chat/attachments'`, `placeholder`, `placeholderShort`, `disabled: me.viewAs === true`, `disabledText`, `send`, `onSent`. Poll alle 10 s mit `?since=<letztes createdAt, URL-kodiert>`, Nachrichten nach `id` entdoppelt, Poll bricht ab bei `document.hidden` oder `!wurzel.isConnected`. `POST /api/chat/read` nach dem Laden und bei neuen Nachrichten, nie im Ansichtsmodus; danach `meldeZaehlerNeu('chat')`. Ein zweiter Aufruf ruft `destroy()` des alten Composers und stoppt den alten Timer. In `dashboard.html` kommen `<link rel="stylesheet" href="/neu/chat-composer.css">` zwischen `/neu/huelle.css` und `/neu/ansichten.css` und `<script src="/chat-composer.js"></script>` zwischen `/kundenansicht.js` und das Modul `/neu/huelle.js`. Das alte `/chat-composer.css` wird **nicht** geladen: Es enthält `#9d3dfe` und `rgba(157, 61, 254, …)` (Zeilen 4 bis 7), die Prüfung `lila` von P0 liest den Text jedes geladenen Stylesheets, und das Gate `p2` und alle späteren (p3, p4, p7) würden in jeder Ansicht rot (P1 VF3). Die Kopie ändert gegenüber dem Original nur: neuer Kopfkommentar (Zweck der Kopie, ohne Farbwerte), der `:root`-Block mit hellen, neutralen Variablen, Gewicht 700 statt 800 in `.chat-file-ext` und kein `backdrop-filter` in `.chat-overlay` (Bildansicht und Kamera bleiben dunkel, sie erscheinen erst auf Antippen).
- [ ] **Step 1: Test schreiben** (Attrappe `stub()` setzt `globalThis.ChatComposer`: `create` merkt `opts`, hängt ein `textarea` in `opts.root` und liefert `{ destroy, focus }`, `renderMessage` baut `div.chat-bubble[data-id]` mit `meta + ' ' + text`; `mock` kommt aus `node:test`).
```js
const NACHRICHTEN = { messages: [nachricht({ id: 'm1', sender: 'admin', senderName: 'Kenso', text: 'Hallo', createdAt: '2026-10-14T08:00:00.000Z' }), nachricht({ id: 'm2', sender: 'customer', text: 'Danke', createdAt: '2026-10-14T08:05:00.000Z' })], unread: 1 }
test('Chat: Blasen, Composer-Optionen, genau eine Gelesen-Markierung', mit({ 'GET /api/chat/messages': NACHRICHTEN, 'POST /api/chat/read': { updated: 1 } }, async (fx) => {
const log = stub(); const events = []; fx.document.dispatchEvent = (e) => events.push(`${e.type}:${e.detail.was}`)
const w = neueWurzel(fx); await zeigeChat(w, { route: { bereich: 'chat', query: '' }, me: me(), nav: 'tab' }); await fx.ruhe()
assert.deepEqual(fx.alle(w, '.chat-bubble').map((e) => e.textContent), ['Kenso · 14.10.26, 10:00 Hallo', 'Du · 14.10.26, 10:05 Danke'])
assert.deepEqual([log.create.uploadUrl, log.create.placeholder, log.create.disabled], ['/api/chat/attachments', 'Nachricht an das WEBklar-Team…', false])
assert.equal(fx.fetchLog.filter((a) => a.method === 'POST' && a.url === '/api/chat/read').length, 1); assert.ok(events.includes('kn:zaehler-neu:chat'))
}))
test('Chat im Ansichtsmodus: Composer gesperrt, kein schreibender Aufruf', mit({ 'GET /api/chat/messages': NACHRICHTEN }, async (fx) => {
const log = stub(); await zeigeChat(neueWurzel(fx), { route: { bereich: 'chat', query: '' }, me: me({ viewAs: true }), nav: 'tab' }); await fx.ruhe()
assert.equal(log.create.disabled, true); assert.equal(log.create.disabledText, 'Im Ansichtsmodus kann nicht als Kunde geschrieben werden.')
assert.deepEqual(fx.fetchLog.filter((a) => a.method !== 'GET'), [])
}))
test('Chat: senden, Poll mit since, Dopplungen, zweiter Aufruf räumt auf', mit({ 'GET /api/chat/messages': NACHRICHTEN, 'POST /api/chat/read': {}, 'POST /api/chat/messages': { message: { id: 'm3' } } }, async (fx) => {
mock.timers.enable({ apis: ['setInterval'] })
try {
const log = stub(); const w = neueWurzel(fx); const args = { route: { bereich: 'chat', query: '' }, me: me(), nav: 'tab' }
await zeigeChat(w, args); await fx.ruhe()
assert.deepEqual(await log.create.send({ text: 'Hi', attachmentIds: ['a1'] }), { id: 'm3' })
assert.deepEqual(fx.fetchLog.at(-1).body, { text: 'Hi', attachmentIds: ['a1'] })
mock.timers.tick(10000); await fx.ruhe()
assert.equal(fx.fetchLog.at(-1).url, '/api/chat/messages?since=2026-10-14T08%3A05%3A00.000Z'); assert.equal(fx.alle(w, '.chat-bubble').length, 2)
await zeigeChat(w, args); await fx.ruhe(); assert.equal(log.destroyed, 1)
const vorher = fx.fetchLog.length; mock.timers.tick(10000); await fx.ruhe(); assert.equal(fx.fetchLog.filter((a, i) => i >= vorher && a.url.includes('since=')).length, 1)
} finally { mock.timers.reset() }
}))
test('Chat: Vorgabe aus „Postfach anfragen“ landet im Eingabefeld, leerer Verlauf sagt es', mit({ 'GET /api/chat/messages': { messages: [], unread: 0 }, 'POST /api/chat/read': {} }, async (fx) => {
stub(); setzeChatVorgabe('Bitte lege ein neues Postfach an: '); const w = neueWurzel(fx); await zeigeChat(w, { route: { bereich: 'chat', query: '' }, me: me(), nav: 'tab' }); await fx.ruhe()
assert.equal(fx.finde(w, 'textarea').value, 'Bitte lege ein neues Postfach an: '); assert.match(fx.text(w), /Noch keine Nachrichten\./)
}))
test('dashboard.html lädt den Composer klassisch und die bereinigte CSS-Kopie zwischen huelle.css und ansichten.css, nie das alte /chat-composer.css', () => {
const html = readFileSync(new URL('../public/neu/dashboard.html', import.meta.url), 'utf8')
assert.match(html, /<script src="\/kundenansicht\.js"><\/script>\s*<script src="\/chat-composer\.js"><\/script>[\s\S]*<script type="module" src="\/neu\/huelle\.js">/)
const i = (s) => html.indexOf(s); assert.ok(i('href="/neu/huelle.css"') < i('href="/neu/chat-composer.css"') && i('href="/neu/chat-composer.css"') < i('href="/neu/ansichten.css"'))
assert.doesNotMatch(html, /href="\/chat-composer\.css"/)
})
const lesePublic = (p) => readFileSync(new URL(`../public/${p}`, import.meta.url), 'utf8')
const geruest = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\{[^{}]*\}/g, '{}').replace(/\s+/g, ' ').trim() // Selektoren und Verschachtelung ohne Deklarationen
test('public/neu/chat-composer.css: kein Lila, kein Gewicht 800, kein Glas, helle Variablen', () => {
const kopie = lesePublic('neu/chat-composer.css')
assert.doesNotMatch(kopie, /#9d3dfe|rgba?\(\s*157\s*,\s*61\s*,\s*254|font-weight:\s*800|backdrop-filter/i)
const wurzel = kopie.match(/:root\s*\{([^}]*)\}/)[1]
for (const v of ['accent', 'accent-hover', 'accent-soft', 'accent-line', 'accent-text', 'on-accent', 'line', 'muted', 'surface']) assert.match(wurzel, new RegExp(`--chat-${v}:`), v)
assert.match(wurzel, /--chat-accent:\s*#111111/); assert.match(wurzel, /--chat-surface:\s*#ffffff/)
})
test('public/neu/chat-composer.css: gleiche Selektoren in gleicher Reihenfolge wie das Original (Kopie driftet nicht)', { skip: !existsSync(new URL('../public/chat-composer.css', import.meta.url)) }, () => {
assert.equal(geruest(lesePublic('neu/chat-composer.css')), geruest(lesePublic('chat-composer.css')))
})
```
- [ ] **Step 2: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knAnsichtChat.test.js`
Expected: FAIL (der Stub exportiert `CHAT_TEXTE` nicht, `dashboard.html` lädt den Composer nicht, `public/neu/chat-composer.css` fehlt).
- [ ] **Step 3: Implementieren** (Signaturen oben). Aufbau: `div.kn-chat` mit Verlauf `#kn-chat-messages` und Composer-Wurzel, ohne Überschrift. `ChatComposer.trackScroll` auf den Verlauf. Die Vorgabe setzt `value`, schickt ein `input`-Ereignis (nur wenn `Event` existiert) und fokussiert; `setSelectionRange` nur, wenn vorhanden. Kein Erklärtext unter dem Composer.
- `dashboard.html`: genau die zwei Zeilen aus dem Test, sonst nichts ändern.
- `public/neu/chat-composer.css`: `cp public/chat-composer.css public/neu/chat-composer.css`, dann in der Kopie genau diese vier Änderungen und sonst nichts:
1. Kopfkommentar ersetzen: bereinigte Kopie für die neue Oberfläche, das Original bleibt für die alte Ansicht, Unterschiede in einem Satz, **ohne Farbwerte** (der Lila-Test liest auch Kommentare).
2. `:root`-Block: `--chat-accent: #111111; --chat-accent-hover: #333333; --chat-accent-soft: rgba(17, 17, 17, 0.08); --chat-accent-line: rgba(17, 17, 17, 0.5); --chat-accent-text: #111111; --chat-on-accent: #ffffff; --chat-line: #d8dde3; --chat-muted: #5e6678; --chat-surface: #ffffff;`. Die Variablen in `.kn-chat` (Task 18) überschreiben sie für den Chat; die Kopie sorgt nur dafür, dass der Stylesheet-Text und die Vorgaben hell und lilafrei sind.
3. `font-weight: 800` in `.chat-file-ext` wird `700`.
4. Die beiden `backdrop-filter`-Deklarationen in `.chat-overlay` entfallen (der Hintergrund ist zu 95 % deckend, der Weichzeichner ändert nichts).
- `test/knHuelleSeite.test.js` (P1, Test „dashboard.html: weiße PWA-Metas, Stile in fester Reihenfolge …“): In der Zeile `css.filter((f) => f !== '/chat-composer.css')` und in der Reihenfolge-Prüfung dahinter steht `/chat-composer.css`. Beide Stellen werden `/neu/chat-composer.css`. Das alte `/chat-composer.css` fällt damit als unbekannter Eintrag durch `deepEqual` und bleibt verboten. Hat P1 den Test schon so geschrieben (VF10), bleibt die Datei unverändert und taucht im Commit nicht auf.
- [ ] **Step 4: Lauf grün.** Run wie Step 2, danach `test/knHuelleSeite.test.js` (P1) und `test/knAnsichtQuelltext.test.js` mit demselben Befehlsmuster. Expected: PASS, 7 Tests in `knAnsichtChat`, und die Seite-Tests von P1 sowie der Lila-Test aus Task 1 bleiben grün.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/ansicht-chat.js public/neu/chat-composer.css public/neu/dashboard.html test/knHuelleSeite.test.js test/knAnsichtChat.test.js && git commit -m "Kundenansicht neu P2: Ansicht Chat mit dem Composer und lilafreier CSS-Kopie" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
(Blieb `test/knHuelleSeite.test.js` unverändert, entfällt sie im `git add`.)
---
### Task 8: Reiter Rechnungen mit Aufträgen
**Files:**
- Create: `public/neu/vertraege-rechnungen.js`
- Test: `test/knAnsichtRechnungen.test.js`
**Interfaces:**
- Consumes: `api`, `h`, `legacyRequest`, `safeUrl`, `fmtEuro`, `fmtDatum`; `initAuftraege({ section, body, invoicesTitle, request, viewAs })` aus `../auftrag-portal.js` (Du-Form, `public/auftrag-portal.js:11-84`); `GET /api/invoices` → `{ invoices:[{ id, number, date, amount, balance, status, paid, cancelled, overdue, portalUrl, hasPdf, projekt }], incomplete }`, `GET /api/auftraege` → `{ aktiv, auftraege }`.
- Produces:
```js
export async function zeigeRechnungen(box, { me, antwort }) // Reiter-Inhalt; antwort optional = schon geladene GET /api/invoices-Antwort (der Rahmen braucht sie für den Sprung), sonst lädt die Funktion selbst
```
Aufbau: `section.ab-portal.hidden#kn-auftraege` mit `h2` `Aufträge` und Rumpf (das Alt-Modul blendet ein, wenn Aufträge aktiv und vorhanden sind), `h2.hidden` `Rechnungen`, Tabelle mit Spalten `Nr.`, `Datum`, `Betrag`, `Offen`, `Status` und `data-label` an den Zellen für das Handy, Nummer mit Projekt darunter (Text), Links `Zahlen` (offene Rechnung, nur mit sicherem `portalUrl`), `Ansehen` (sonst mit sicherem `portalUrl`), `PDF` (`/api/invoices/<id>/pdf`, wenn `hasPdf !== false`). Texte: `Es liegen noch keine Rechnungen vor.`, `Deine Rechnungen konnten gerade nicht vollständig geladen werden. Bitte versuche es später noch einmal.`, `Rechnungen konnten nicht geladen werden: <Meldung>`. Die Aufträge-Abfrage stört die Rechnungen nie (Fehler lassen den Abschnitt weg).
- [ ] **Step 1: Test schreiben**
```js
test('Rechnungen: Zeilen, Links nur mit sicherer Adresse, Projekt bleibt Text', mit({ 'GET /api/invoices': { invoices: [
rechnung({ id: 'in_1', number: 'RE-2026-0042', amount: 50, balance: 50, status: 'Offen', portalUrl: 'https://invoice.stripe.com/i/x', hasPdf: true, projekt: 'Bäckerei <i>Roth</i>' }),
rechnung({ id: 'in_0', number: 'RE-2026-0030', amount: 20, balance: 0, paid: true, status: 'Bezahlt', portalUrl: 'javascript:alert(1)', hasPdf: true })], incomplete: false }, 'GET /api/auftraege': { aktiv: false } }, async (fx) => {
const box = neueWurzel(fx); await zeigeRechnungen(box, { me: me() }); await fx.ruhe()
const zeilen = fx.alle(box, 'tbody tr'); assert.equal(zeilen.length, 2); assert.match(zeilen[0].textContent, /50,00\u00a0€/); assert.equal(fx.finde(box, 'i'), null)
assert.deepEqual(fx.alle(zeilen[0], 'a').map((a) => [a.textContent, a.getAttribute('href')]), [['Zahlen', 'https://invoice.stripe.com/i/x'], ['PDF', '/api/invoices/in_1/pdf']])
assert.deepEqual(fx.alle(zeilen[1], 'a').map((a) => a.textContent), ['PDF'])
}))
let antwort
test('Rechnungen: Ausfall, leere Liste und unvollständige Antwort', mit({ 'GET /api/invoices': () => antwort, 'GET /api/auftraege': { aktiv: false } }, async (fx) => {
const zeige = async (a) => { antwort = a; const b = neueWurzel(fx); await zeigeRechnungen(b, { me: me() }); await fx.ruhe(); return fx.text(b) }
assert.match(await zeige({ status: 500, body: { error: 'Server kaputt' } }), /Rechnungen konnten nicht geladen werden: Server kaputt/)
assert.match(await zeige({ body: { invoices: [] } }), /Es liegen noch keine Rechnungen vor\./)
assert.match(await zeige({ body: { invoices: [rechnung({ balance: 0 })], incomplete: true } }), /Deine Rechnungen konnten gerade nicht vollständig geladen werden\. Bitte versuche es später noch einmal\./)
}))
test('Aufträge bestätigen: der POST-Body ist ein Objekt, kein JSON-String (Review Focus 1)', mit({ 'GET /api/invoices': { invoices: [] }, 'GET /api/auftraege': { aktiv: true, auftraege: [eintrag({ id: 'ab1' })] }, 'POST /api/auftraege/ab1/bestaetigen': () => ({ body: { auftrag: { ...eintrag({ id: 'ab1' }), status: 'bestaetigt' } } }) }, async (fx) => {
const box = neueWurzel(fx); await zeigeRechnungen(box, { me: me() }); await fx.ruhe()
await fx.klick(fx.finde(box, '[data-feld="bestaetigung"]')); await fx.klick(fx.finde(box, '[data-aktion="bestaetigen"]'))
const post = fx.fetchLog.find((a) => a.method === 'POST'); assert.equal(post.url, '/api/auftraege/ab1/bestaetigen'); assert.equal(typeof post.body, 'object')
assert.equal(fx.finde(box, '#kn-auftraege').classList.contains('hidden'), false)
}))
test('Aufträge im Ansichtsmodus: Häkchen gesperrt, Hinweis sichtbar, kein POST', mit({ 'GET /api/invoices': { invoices: [] }, 'GET /api/auftraege': { aktiv: true, auftraege: [eintrag({ id: 'ab1', kannBestaetigen: false })] } }, async (fx) => {
const box = neueWurzel(fx); await zeigeRechnungen(box, { me: me({ viewAs: true }) }); await fx.ruhe()
assert.ok(fx.finde(box, '[data-gesperrt]')); assert.equal(fx.finde(box, '[data-feld="bestaetigung"]').hasAttribute('disabled'), true); assert.deepEqual(fx.fetchLog.filter((a) => a.method === 'POST'), [])
}))
```
(`eintrag` aus `./auftraegeKundenportalDaten.js`.)
- [ ] **Step 2: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knAnsichtRechnungen.test.js`
Expected: FAIL `ERR_MODULE_NOT_FOUND` für `vertraege-rechnungen.js`.
- [ ] **Step 3: Implementieren** (Signatur und Aufbau oben). Nach dem Zeichnen der Liste ruft `zeigeRechnungen` `initAuftraege({ section, body, invoicesTitle, request: legacyRequest(api), viewAs: me.viewAs === true })`.
- [ ] **Step 4: Lauf grün.** Run wie Step 2. Expected: PASS, 4 Tests.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/vertraege-rechnungen.js test/knAnsichtRechnungen.test.js && git commit -m "Kundenansicht neu P2: Reiter Rechnungen mit Aufträgen" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 9: Reiter Abo und Abo-Auswahl
**Files:**
- Create: `public/neu/vertraege-abo.js`
- Test: `test/knAnsichtAbo.test.js`
**Interfaces:**
- Consumes: `api`, `h`, `K`, `zuAnsicht`, Formate, `safeUrl`. `GET /api/abo` (`routes/abo.js:88-214`), `POST /api/abo/portal` → `{ url }` (`:285`), `GET /api/abo/takeover-credits` → `{ requests:[{ status }], limits }`.
- Produces:
```js
export async function zeigeAbo(box, { daten, me }) // daten = GET /api/abo-Antwort; Reiter-Inhalt
export function aboZustand(daten) // 'ohne' | 'beendet' | 'laeuft' | 'gekuendigt'
export function aboZeile(sub) // 'Leistungen bis TT.MM.JJJJ' | 'Beginn und erste Abbuchung am …' | 'nächste Abbuchung …' (app.js:867-873)
export function laufzeitText(sub) // Texte aus app.js:838-846 unverändert (ohne Anrede)
```
Texte (du): `Mein Vertrag`, Pillen `aktiv`/`Zahlung offen`/`gekündigt`, Produktname ohne `WEBklar `, `<Preis> pro Monat`, `Gemäß § 19 UStG ohne Umsatzsteuer.`, `Angebot`, `E-Mail-Speicher: <n> GB (<k> Zusatzpaket[e])`, Leistungsliste, Links `Vertragsbedingungen` (`sub.termsUrl`) und `als PDF` (`sub.termsPdfUrl`), `Vertrag widerrufen · bis TT.MM.` → `/widerrufen.html` nur bei `withdrawalOpen`, Zahlungsproblem `Die letzte Abbuchung ist fehlgeschlagen. Bitte aktualisiere deine Zahlungsart, damit deine Website online bleibt.` mit Knopf `Zahlungsart aktualisieren`, Beginn nach Frist `Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist am TT.MM.JJJJ. Dann wird auch zum ersten Mal abgebucht.`, Wechsel-Einstieg bei Hosting mit `canSwitch`: `Mehr als Hosting? Das Wartungs-Abo`, Preis, `Zusätzlich zum Hosting: Pflege deiner Website, beliebig viele Postfächer und 1 Stunde Arbeitszeit pro Monat.`, Link `Zum Wartungs-Abo wechseln` = `zuAnsicht('wechsel')`; bei `switchPending` `Dein Wechsel wartet auf die Zahlung` mit Link `Jetzt bezahlen` (nur sicheres `paymentUrl`, sonst `Schreib uns gern im Chat, wenn du Hilfe brauchst.`); bei gekündigtem Hosting `Dein Abo ist gekündigt. Bitte schreib uns im Chat, wenn du zum Wartungs-Abo wechseln möchtest.` statt der Wechsel-Karte; Gutschrift-Einstieg `Übernahmegutschrift beantragen` = `zuAnsicht('takeover')` mit Zahl der Rückfragen (`span[data-badge]`). Ohne Abo: je Produkt `[data-produkt=<key>]` mit Name, Preis, `Gemäß § 19 UStG ohne Umsatzsteuer. <n> Monate Mindestlaufzeit, danach monatlich kündbar.`, Leistungsliste, Angebots-Etikett `Dein persönliches Angebot` + Angebotstext, Link `Abo abschließen` = `zuAnsicht('bestellen-<key>')`; bei `available: false` ohne `href`, mit `aria-disabled="true"` und dem Satz `Die Online-Bestellung wird gerade eingerichtet – schreib uns gern im Chat.`; bei beendetem Abo `Dein bisheriges Abo ist beendet. Du kannst jederzeit ein neues abschließen.`; Fußsatz `Alle Preise gemäß § 19 UStG ohne Umsatzsteuer. Es gelten die Vertragsbedingungen.` mit Link auf `/abo-bedingungen.html`.
- [ ] **Step 1: Test schreiben**
```js
const WARTUNG = aboAntwort({ subscription: abo({ priceAmount: 5000, currentPeriodEnd: '2026-11-16T00:00:00.000Z', minTermEnd: '2099-06-01T00:00:00.000Z', termsUrl: '/abo-bedingungen.html', termsPdfUrl: '/WEBklar-Abo-Vertragsunterlagen.pdf' }) })
const zeige = async (fx, daten, m = me()) => { const b = neueWurzel(fx); await zeigeAbo(b, { daten, me: m }); await fx.ruhe(); return b }
const SRV = { 'GET /api/abo/takeover-credits': { requests: [{ status: 'rueckfrage' }, { status: 'offen' }], limits: {} } }
test('aboZustand', () => {
assert.deepEqual([aboAntwort(), aboAntwort({ subscription: abo({ active: false }) }), WARTUNG, aboAntwort({ subscription: abo({ cancelAtPeriodEnd: true }) })].map(aboZustand), ['ohne', 'beendet', 'laeuft', 'gekuendigt'])
})
test('Abo Wartung: Vertrag, Preis, Laufzeit, Bedingungen, Gutschrift-Einstieg mit Rückfragen', mit(SRV, async (fx) => {
const b = await zeige(fx, WARTUNG); const t = fx.text(b)
for (const s of ['Mein Vertrag', 'aktiv', 'Wartungs-Abo', 'Gemäß § 19 UStG ohne Umsatzsteuer.', 'nächste Abbuchung 16.11.2026', 'Mindestlaufzeit bis 01.06.2099. Kündigung frühestens zu diesem Termin, mit einem Monat Frist.']) assert.ok(t.includes(s), s)
assert.equal(fx.finde(b, 'a[href="/WEBklar-Abo-Vertragsunterlagen.pdf"]').textContent, 'als PDF')
assert.equal(fx.finde(b, 'a[href="#gutschrift"][data-view="takeover"] [data-badge]').textContent, '1')
}))
test('Abo Hosting: Wechsel-Einstieg; wartende Zahlung nur mit sicherer Adresse; gekündigt ohne Wechsel-Karte', mit(SRV, async (fx) => {
const hosting = (o) => aboAntwort({ subscription: abo({ produkt: 'hosting', canSwitch: true, ...o }) })
const w = fx.finde(await zeige(fx, hosting({})), 'a[data-view="wechsel"]'); assert.deepEqual([w.textContent, w.getAttribute('href')], ['Zum Wartungs-Abo wechseln', '#wechsel'])
const sicher = await zeige(fx, hosting({ switchPending: { since: 'x', paymentUrl: 'https://pay.stripe.com/x' } })); assert.equal(fx.finde(sicher, 'a[href="https://pay.stripe.com/x"]').textContent, 'Jetzt bezahlen')
const unsicher = await zeige(fx, hosting({ switchPending: { since: 'x', paymentUrl: 'javascript:alert(1)' } })); assert.match(fx.text(unsicher), /Schreib uns gern im Chat, wenn du Hilfe brauchst\./)
const gekuendigt = await zeige(fx, hosting({ cancelAtPeriodEnd: true })); assert.equal(fx.finde(gekuendigt, 'a[data-view="wechsel"]'), null); assert.match(fx.text(gekuendigt), /Dein Abo ist gekündigt\. Bitte schreib uns im Chat, wenn du zum Wartungs-Abo wechseln möchtest\./)
}))
test('Zahlungsproblem: Knopf öffnet das Stripe-Portal; Widerruf nur in der Frist; Ansichtsmodus sperrt', mit({ ...SRV, 'POST /api/abo/portal': { url: 'https://billing.stripe.com/x' } }, async (fx) => {
const daten = aboAntwort({ subscription: abo({ paymentProblem: true, withdrawalOpen: true, withdrawalUntil: '2026-10-17T21:59:00.000Z' }) })
const b = await zeige(fx, daten); assert.match(fx.text(b), /Bitte aktualisiere deine Zahlungsart, damit deine Website online bleibt\./); assert.match(fx.finde(b, 'a[href="/widerrufen.html"]').textContent, /^Vertrag widerrufen · bis 17\.10\./)
await fx.klick(fx.finde(b, (e) => e.textContent === 'Zahlungsart aktualisieren')); assert.equal(fx.window.location.href, 'https://billing.stripe.com/x')
const ansicht = await zeige(fx, daten, me({ viewAs: true })); assert.equal(fx.finde(ansicht, (e) => e.textContent === 'Zahlungsart aktualisieren').hasAttribute('disabled'), true)
assert.equal(fx.finde(await zeige(fx, WARTUNG), 'a[href="/widerrufen.html"]'), null)
}))
test('Ohne Abo: zwei Produkte mit Link zur Bestellung, nicht verfügbar sperrt, beendet sagt es', mit(SRV, async (fx) => {
const b = await zeige(fx, aboAntwort()); assert.deepEqual(['hosting', 'wartung'].map((k) => fx.finde(b, `[data-produkt="${k}"] a`).getAttribute('href')), ['#bestellen-hosting', '#bestellen-wartung'])
assert.match(fx.text(b), /20,00\u00a0€/)
const aus = await zeige(fx, aboAntwort({ available: false })); assert.equal(fx.finde(aus, '[data-produkt="hosting"] a').getAttribute('aria-disabled'), 'true'); assert.match(fx.text(aus), /schreib uns gern im Chat\./)
assert.match(fx.text(await zeige(fx, aboAntwort({ subscription: abo({ active: false }) }))), /Dein bisheriges Abo ist beendet\. Du kannst jederzeit ein neues abschließen\./)
}))
test('Abo: Ausfall der Rückfragen-Abfrage lässt den Einstieg stehen', mit({ 'GET /api/abo/takeover-credits': () => ({ status: 500, body: { error: 'x' } }) }, async (fx) => {
const b = await zeige(fx, WARTUNG); assert.ok(fx.finde(b, 'a[href="#gutschrift"]')); assert.equal(fx.finde(b, '[data-badge]'), null)
}))
```
- [ ] **Step 2: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knAnsichtAbo.test.js`
Expected: FAIL `ERR_MODULE_NOT_FOUND` für `vertraege-abo.js`.
- [ ] **Step 3: Implementieren** (Signaturen und Texte oben). Die Rückfragen-Zahl lädt `zeigeAbo` selbst, nur bei vorhandenem Abo, nach dem Rendern. `Zahlungsart aktualisieren` ruft `POST /api/abo/portal`, setzt bei sicherer Adresse `window.location.href`, sonst zeigt es die Fehlermeldung am Knopf.
- [ ] **Step 4: Lauf grün.** Run wie Step 2. Expected: PASS, 6 Tests.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/vertraege-abo.js test/knAnsichtAbo.test.js && git commit -m "Kundenansicht neu P2: Reiter Abo und Abo-Auswahl" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 10: Stundenkonto mit Vorschuss
**Files:**
- Create: `public/neu/ansicht-stunden.js`
- Test: `test/knAnsichtStunden.test.js`
**Interfaces:**
- Consumes: `h`, `K`, `fmtStd`, `fmtDatum`, `ladeAbo`, `ABO_NICHT_GELADEN`. `ledger` aus `GET /api/abo` (`hourLedger.js:77-129`, ab P6 zusätzlich `vorschussMinutes` und je Eintrag `einrichtung`, `vorschussMinutes`, V8).
- Produces:
```js
export async function zeigeStunden(wurzel, { route, me, nav, daten }) // daten optional; sonst ladeAbo(); Reiter-Inhalt
export function vorschussZeile(minuten, gekuendigt) // '' bei ≤ 0; sonst 'Vorschuss für Einrichtung: 2,5 Std., wird mit den nächsten Monaten verrechnet' | '… , verfällt, wird nicht berechnet'
```
Aufbau: Statistik `verfügbare Zeit` (`balanceMinutes`), `bisher genutzt` (`usedMinutes`), `verfallen am TT.MM.JJJJ` (`nextExpiry`), Vorschuss-Zeile, Abschnitt `Verlauf` (Tabelle Datum, Vorgang mit `#<woid>`, Zeit; Einträge mit `overageMinutes` bekommen die Pille `+<fmtStd> extra`, Einträge mit `vorschussMinutes` die Pille `Vorschuss <fmtStd>` und nie zusätzlich `extra`; Gutschriften als `Monatskontingent`, `+<fmtStd>`, `nutzbar bis TT.MM.JJJJ` bzw. `abgelaufen`), Leer `Noch keine Buchungen.`, Satz `1 Stunde pro bezahltem Monat. Angerechnet wird in 15-Minuten-Schritten, die ältesten Stunden zuerst.`, Link `Leistungen & Bedingungen` (`sub.termsUrl`). Warnungen wie Task 9 (`paymentProblem`, `serviceStartsAt`). Ohne Wartung oder ohne `ledger`: `Ein Stundenkonto gibt es im Wartungs-Abo.`
- [ ] **Step 1: Test schreiben**
```js
const L = ledger({ balanceMinutes: 90, usedMinutes: 120, vorschussMinutes: 150, nextExpiry: { minutes: 60, at: '2027-03-01T00:00:00.000Z' },
credits: [{ grantedAt: '2026-10-01T00:00:00.000Z', expiresAt: '2027-10-01T00:00:00.000Z', minutes: 60, remaining: 60, expired: false }],
usage: [{ date: '2026-10-10T12:00:00.000Z', topic: 'Einrichtung Terminplaner', woid: '100150', minutes: 150, overageMinutes: 0, vorschussMinutes: 150, einrichtung: true },
{ date: '2026-10-05T12:00:00.000Z', topic: 'Textänderung <b>neu</b>', woid: '100140', minutes: 30, overageMinutes: 30, vorschussMinutes: 0, einrichtung: false }] })
test('vorschussZeile', () => {
assert.equal(vorschussZeile(150, false), 'Vorschuss für Einrichtung: 2,5 Std., wird mit den nächsten Monaten verrechnet')
assert.equal(vorschussZeile(150, true), 'Vorschuss für Einrichtung: 2,5 Std., verfällt, wird nicht berechnet'); assert.equal(vorschussZeile(0, false), '')
})
test('Stunden: Statistik, Vorschuss nie als Mehraufwand, Mehraufwand bleibt Mehraufwand', mit({}, async (fx) => {
const w = neueWurzel(fx); await zeigeStunden(w, { route: { bereich: 'vertraege', reiter: 'stunden', query: '' }, me: me(), nav: 'tab', daten: aboAntwort({ subscription: abo(), ledger: L }) })
const t = fx.text(w)
for (const s of ['1,5 Std.', '2 Std.', 'verfallen am 01.03.2027', 'Vorschuss für Einrichtung: 2,5 Std., wird mit den nächsten Monaten verrechnet', 'Monatskontingent', '+1 Std.', 'nutzbar bis 01.10.2027', '#100150']) assert.ok(t.includes(s), s)
const [vorschuss, mehr] = fx.alle(w, 'tbody tr'); assert.match(vorschuss.textContent, /Vorschuss 2,5 Std\./); assert.doesNotMatch(vorschuss.textContent, /extra/); assert.match(mehr.textContent, /\+0,5 Std\. extra/)
assert.equal(fx.finde(w, 'b'), null)
}))
test('Stunden: gekündigtes Abo verfällt den Vorschuss, Hosting und fehlendes Konto sagen es', mit({}, async (fx) => {
const zeige = async (daten) => { const w = neueWurzel(fx); await zeigeStunden(w, { route: { bereich: 'vertraege', query: '' }, me: me(), nav: 'tab', daten }); await fx.ruhe(); return fx.text(w) }
assert.match(await zeige(aboAntwort({ subscription: abo({ cancelAtPeriodEnd: true }), ledger: L })), /verfällt, wird nicht berechnet/)
assert.match(await zeige(aboAntwort({ subscription: abo({ produkt: 'hosting' }) })), /Ein Stundenkonto gibt es im Wartungs-Abo\./)
assert.match(await zeige(aboAntwort({ subscription: abo(), ledger: null })), /Ein Stundenkonto gibt es im Wartungs-Abo\./)
}))
test('Stunden lädt selbst, wenn keine Daten übergeben werden, und überlebt einen Ausfall', mit({ 'GET /api/abo': () => ({ status: 500, body: { error: 'x' } }) }, async (fx) => {
const w = neueWurzel(fx); await zeigeStunden(w, { route: { bereich: 'vertraege', query: '' }, me: me(), nav: 'tab' }); await fx.ruhe(); assert.match(fx.text(w), /Deine Verträge konnten gerade nicht geladen werden\./)
}))
```
- [ ] **Step 2: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knAnsichtStunden.test.js`
Expected: FAIL `ERR_MODULE_NOT_FOUND` für `ansicht-stunden.js`.
- [ ] **Step 3: Implementieren** (Signaturen oben). Fehlende Felder (`vorschussMinutes` fehlt vor P6) zählen als 0. Gekündigt heißt `cancelAtPeriodEnd || cancelAt`.
- [ ] **Step 4: Lauf grün.** Run wie Step 2. Expected: PASS, 4 Tests.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/ansicht-stunden.js test/knAnsichtStunden.test.js && git commit -m "Kundenansicht neu P2: Stundenkonto mit Vorschuss-Anzeige" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 11: E-Mail-Speicher (Wortlaut Fassung 5)
**Files:**
- Create: `public/neu/ansicht-speicher.js`
- Test: `test/knAnsichtSpeicher.test.js`
**Interfaces:**
- Consumes: `api`, `h`, `ladeAbo`, `setzeChatVorgabe`, `navigiere`, Formate, `ABO_NICHT_GELADEN`; im Test `sichtbarerText`, `ladeAlteApp`. `POST /api/abo/storage/preview {units}`, `POST /api/abo/storage {units, acceptTerms?, termsVersion?}` (`routes/abo.js:302-352`). Text und Logik 1:1 aus `app.js:1247-1408` (`renderStoragePanel`), Sie-Form bleibt.
- Produces:
```js
export async function zeigeSpeicher(wurzel, { route, me, nav, daten }) // daten optional; Reiter-Inhalt: Knopf 'Postfach anfragen' (du) und das Panel [data-panel="speicher"]
```
Das Panel behält Überschrift `E-Mail-Speicher`, die Preiszeile, Stepper (`#storage-minus`, `#storage-plus`), Zusammenfassung, Zustimmungshaken `#storage-terms` (nur Plus), Knöpfe `Zahlungspflichtig bestellen` bzw. `Speichern`, alle Hinweise und Fehlertexte wörtlich. Nach Erfolg lädt die Ansicht `GET /api/abo` neu und zeigt den Hinweis aus `app.js:1383-1385` oben im Panel. Im Ansichtsmodus sind Stepper, Haken und Knöpfe gesperrt, daneben steht `Im Ansichtsmodus nicht möglich.` `Postfach anfragen` ruft `setzeChatVorgabe('Bitte lege ein neues Postfach an: ')` und `navigiere('#chat')`.
- [ ] **Step 1: Test schreiben**
```js
const alt = ladeAlteApp()
const speicher = (o = {}, sub = {}) => aboAntwort({ subscription: abo({ storage: { units: 2, pendingSetup: false, pendingReduction: null }, canChangeStorage: true, ...sub }), ...o })
const zeigeNeu = async (fx, daten, m = me()) => { const w = neueWurzel(fx); await zeigeSpeicher(w, { route: { bereich: 'vertraege', reiter: 'email', query: '' }, me: m, nav: 'tab', daten }); await fx.ruhe(); return w }
for (const [name, daten] of [['zwei Pakete', speicher()], ['gesperrt', speicher({}, { canChangeStorage: false })], ['wird eingerichtet', speicher({}, { storage: { units: 1, pendingSetup: true, pendingReduction: null } })], ['Reduzierung geplant', speicher({}, { storage: { units: 3, pendingSetup: false, pendingReduction: { units: 1, effectiveAt: '2026-11-01T00:00:00.000Z' } } })]]) {
test(`E-Mail-Speicher (${name}): Text wie in app.js`, mit({ 'POST /api/abo/storage/preview': VORSCHAU_SPEICHER }, async (fx) => {
const w = await zeigeNeu(fx, daten)
assert.equal(sichtbarerText(fx.finde(w, '[data-panel="speicher"]')), sichtbarerText(await alt.speicher(daten, VORSCHAU_SPEICHER)))
}))
}
test('Plus: Vorschau, Zustimmung Pflicht, Body mit acceptTerms und termsVersion, Hinweis nach Erfolg', mit({
'POST /api/abo/storage/preview': { direction: 'up', units: 3, totalGb: 20, monthlyCents: 300, deltaMonthlyCents: 100, prorationCents: 40, nextChargeAt: '2026-11-16T00:00:00.000Z', effectiveAt: null },
'POST /api/abo/storage': { change: { task: true }, subscription: abo() }, 'GET /api/abo': speicher() }, async (fx) => {
const w = await zeigeNeu(fx, speicher())
await fx.klick(fx.finde(w, '#storage-plus')); await fx.ruhe(); assert.deepEqual(fx.fetchLog.at(-1).body, { units: 3 })
await fx.klick(fx.finde(w, '#storage-submit')); assert.match(fx.text(w), /Bitte stimmen Sie den Regeln zum Zusatzspeicher zu\./)
await fx.klick(fx.finde(w, '#storage-terms')); await fx.klick(fx.finde(w, '#storage-submit')); await fx.ruhe()
assert.deepEqual(fx.fetchLog.find((a) => a.url === '/api/abo/storage').body, { units: 3, acceptTerms: true, termsVersion: '2026-09-30-v5' })
assert.match(fx.text(w), /Danke, Ihre Bestellung ist eingegangen\. Der Speicher wird in der Regel innerhalb eines Werktags eingerichtet\./)
}))
test('Minus ohne Haken; 409 hängt den Hinweis zum Neuladen an; Ansichtsmodus sperrt', mit({
'POST /api/abo/storage/preview': VORSCHAU_SPEICHER, 'POST /api/abo/storage': () => ({ status: 409, body: { error: 'Stand geändert.' } }) }, async (fx) => {
const w = await zeigeNeu(fx, speicher()); await fx.klick(fx.finde(w, '#storage-minus')); await fx.ruhe(); await fx.klick(fx.finde(w, '#storage-submit')); await fx.ruhe()
assert.deepEqual(fx.fetchLog.find((a) => a.url === '/api/abo/storage').body, { units: 1 }); assert.match(fx.text(w), /Stand geändert\. Bitte laden Sie die Seite neu, falls sich der Stand geändert hat\./)
const gesperrt = await zeigeNeu(fx, speicher(), me({ viewAs: true })); assert.ok(['#storage-minus', '#storage-plus'].every((s) => fx.finde(gesperrt, s).hasAttribute('disabled')))
}))
test('Postfach anfragen setzt die Chat-Vorgabe und geht zum Chat', mit({}, async (fx) => {
fx.window.history.pushState = (_s, _t, url) => { fx.window.location.hash = url }
const w = await zeigeNeu(fx, speicher()); await fx.klick(fx.finde(w, (e) => e.textContent === 'Postfach anfragen'))
assert.equal(nimmChatVorgabe(), 'Bitte lege ein neues Postfach an: '); assert.equal(fx.window.location.hash, '#chat')
}))
```
- [ ] **Step 2: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knAnsichtSpeicher.test.js`
Expected: FAIL `ERR_MODULE_NOT_FOUND` für `ansicht-speicher.js`.
- [ ] **Step 3: Implementieren.** Der Text kommt wörtlich aus `app.js:1249-1408`, die Reihenfolge der Elemente bleibt (das Orakel prüft sie). Das Panel trägt `data-panel="speicher"`. Struktur nur über `h()`, `<a>` mit `target="_blank" rel="noopener"` wie im Alten, Sätze mit eingebetteten Links in Teilstücken.
- [ ] **Step 4: Lauf grün.** Run wie Step 2. Expected: PASS, 7 Tests.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/ansicht-speicher.js test/knAnsichtSpeicher.test.js && git commit -m "Kundenansicht neu P2: E-Mail-Speicher im Wortlaut der Fassung 5" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 12: Bestellen (1:1 Fassung 5)
**Files:**
- Modify: `public/neu/ansicht-bestellen.js` (Stub von P1 ersetzen)
- Test: `test/knAnsichtBestellen.test.js`
**Interfaces:**
- Consumes: `api`, `h`, `ladeAbo`, `safeUrl`, `meldeTitel`, Formate; `GET /api/abo`; `POST /api/abo/checkout` mit Body `{ productKey, acceptTerms, startChoice, termsVersion: daten.termsVersion, offerId: offer?.id || '', lateStartAt: daten.lateStartAt || '' }` → `{ url }`. Text und Logik 1:1 aus `app.js:920-1033` (`renderOrderView`).
- Produces:
```js
export async function zeigeBestellen(wurzel, { route, me, nav, daten }) // Produkt aus route.produkt ('hosting'|'wartung'), sonst aus location.hash /bestellen-(hosting|wartung)/
export function produktAusRoute(route, hash) // → 'hosting' | 'wartung' | ''
```
Aufbau: `meldeTitel(wurzel, <Produktname ohne 'WEBklar '>)`, Angebotskasten, Leistungsliste samt Übernahmehinweis, Block „Ihre Bestellung“ `#abo-order` mit Haken `#abo-terms`, Beginn-Auswahl (`input[name="abo-start-choice"]`), Zusammenfassung (Leistung, Preis, Abrechnung, Fällig, Leistungsbeginn, Laufzeit, Zahlung, Anbieter), Fehlerplatz `#abo-error`, Knopf `#abo-buy` `Zahlungspflichtig bestellen` und der Satz darunter. Die Kerninfo liegt unmittelbar vor dem Knopf, ohne `details` oder `dialog` dazwischen. Kopf (Überschrift, Untertitel) entfällt, sonst bleibt alles wörtlich. Ist das Produkt unbekannt oder `available` falsch, steht nur der Satz `Die Online-Bestellung wird gerade eingerichtet – schreiben Sie uns gern im Chat.` Im Ansichtsmodus ist der Knopf gesperrt, daneben steht `Im Ansichtsmodus nicht möglich.`
- [ ] **Step 1: Test schreiben**
```js
const alt = ladeAlteApp()
const route = (produkt) => ({ bereich: 'vertraege', etage: 'bestellen', produkt, query: '' })
for (const key of ['hosting', 'wartung']) for (const [name, daten] of [['ohne Angebot', aboAntwort()], ['mit Angebot', aboAntwort({ offers: { hosting: angebot(), wartung: angebot() } })], ['Testkonto', aboAntwort({ testAccount: true })], ['nicht verfügbar', aboAntwort({ available: false })]]) {
test(`Bestellen ${key} (${name}): sichtbarer Text wie in app.js`, mit({}, async (fx) => {
const w = neueWurzel(fx); await zeigeBestellen(w, { route: route(key), me: me(), nav: 'push', daten }); await fx.ruhe()
assert.equal(sichtbarerText(w), sichtbarerText(ohneKopf(alt.bestellen(daten, key))))
}))
}
test('produktAusRoute', () => { assert.equal(produktAusRoute({ produkt: 'wartung' }, ''), 'wartung'); assert.equal(produktAusRoute({}, '#bestellen-hosting'), 'hosting'); assert.equal(produktAusRoute({}, '#abo'), '') })
test('Bestellen meldet den Produktnamen als Titel', mit({}, async (fx) => {
const titel = []; const w = neueWurzel(fx); w.addEventListener('kn:titel', (e) => titel.push(e.detail.text))
await zeigeBestellen(w, { route: route('wartung'), me: me(), nav: 'push', daten: aboAntwort() }); assert.deepEqual(titel, ['Wartungs-Abo'])
}))
test('Beginn-Auswahl ändert die Zeilen, Prüfungen wörtlich, Body wörtlich', mit({ 'POST /api/abo/checkout': { url: 'https://checkout.stripe.com/x' } }, async (fx) => {
const w = neueWurzel(fx); await zeigeBestellen(w, { route: route('wartung'), me: me(), nav: 'push', daten: aboAntwort() })
await fx.klick(fx.finde(w, '#abo-buy')); assert.match(fx.text(w), /Bitte stimmen Sie den Vertragsbedingungen zu\./)
await fx.klick(fx.finde(w, '#abo-terms')); await fx.klick(fx.finde(w, '#abo-buy')); assert.match(fx.text(w), /Bitte wählen Sie, wann wir mit der Leistung beginnen sollen\./)
await fx.klick(fx.finde(w, 'input[value="sofort"]'))
assert.ok(fx.text(w).includes('Heute fällig: 50,00\u00a0€ (gemäß § 19 UStG ohne Umsatzsteuer)')); assert.ok(fx.text(w).includes('sofort, auf Ihren ausdrücklichen Wunsch vor Ablauf der Widerrufsfrist'))
await fx.klick(fx.finde(w, '#abo-buy')); await fx.ruhe()
assert.deepEqual(fx.fetchLog.at(-1), { method: 'POST', url: '/api/abo/checkout', body: { productKey: 'wartung', acceptTerms: true, startChoice: 'sofort', termsVersion: '2026-09-30-v5', offerId: '', lateStartAt: '2026-10-30T00:00:00.000Z' } })
assert.equal(fx.window.location.href, 'https://checkout.stripe.com/x')
}))
test('Bestellen: unsichere Adresse der Zahlungsseite wird nicht geöffnet, Ansichtsmodus sperrt', mit({ 'POST /api/abo/checkout': () => ({ body: { url: 'javascript:alert(1)' } }) }, async (fx) => {
const w = neueWurzel(fx); await zeigeBestellen(w, { route: route('wartung'), me: me(), nav: 'push', daten: aboAntwort() })
await fx.klick(fx.finde(w, '#abo-terms')); await fx.klick(fx.finde(w, 'input[value="nach_frist"]')); await fx.klick(fx.finde(w, '#abo-buy')); await fx.ruhe()
assert.notEqual(fx.window.location.href, 'javascript:alert(1)')
const v = neueWurzel(fx); await zeigeBestellen(v, { route: route('wartung'), me: me({ viewAs: true }), nav: 'push', daten: aboAntwort() })
assert.equal(fx.finde(v, '#abo-buy').hasAttribute('disabled'), true); assert.match(fx.text(v), /Im Ansichtsmodus nicht möglich\./)
}))
test('Kerninfo und Knopf stehen ohne Klappe nebeneinander', mit({}, async (fx) => {
const w = neueWurzel(fx); await zeigeBestellen(w, { route: route('wartung'), me: me(), nav: 'push', daten: aboAntwort() })
assert.equal(fx.alle(w, 'details, dialog').length, 0); assert.ok(fx.finde(w, '#abo-order').textContent.includes('Zahlungspflichtig bestellen'))
}))
```
(`angebot()` aus `knDaten`, `ohneKopf` aus dem Orakel.)
- [ ] **Step 2: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knAnsichtBestellen.test.js`
Expected: FAIL (der Stub exportiert `produktAusRoute` nicht).
- [ ] **Step 3: Implementieren.** Text aus `app.js:925-1032` Zeichen für Zeichen, Reihenfolge der Elemente unverändert. Die Ids `abo-terms`, `abo-order`, `abo-start-summary`, `abo-due-summary`, `abo-buy`, `abo-error` bleiben. Nach Fehlern wird der Knopf wieder frei und trägt `Zahlungspflichtig bestellen`. Während des Aufrufs `Weiter zur Zahlung…`. Weiterleitung nur mit `safeUrl`.
- [ ] **Step 4: Lauf grün.** Run wie Step 2. Expected: PASS, 13 Tests.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/ansicht-bestellen.js test/knAnsichtBestellen.test.js && git commit -m "Kundenansicht neu P2: Bestellseite im Wortlaut der Fassung 5" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 13: Wechsel (1:1 Fassung 5, mit gemerkter Funktion)
**Files:**
- Modify: `public/neu/ansicht-wechsel.js` (Stub von P1 ersetzen)
- Test: `test/knAnsichtWechsel.test.js`
**Interfaces:**
- Consumes: `api`, `h`, `ladeAbo`, `wechselFunktionAus`, `safeUrl`, `merkeHinweis`, `navigiere`, `meldeTitel`. `POST /api/abo/switch/preview` `{}` → `{ prorationDate, amountDueCents, creditCents, monthlyCents, nextChargeAt, minTermEnd }`, `POST /api/abo/switch` `{ acceptTerms: true, earlyStart: true, termsVersion, prorationDate, amountDueCents, funktion? }` → `{ paymentUrl?, subscription }` (`planSwitch.js:347-388`, `routes/abo.js:355-380`). V11: `sessionStorage['kn-wechsel-funktion']` = `JSON {key, projectId}` (P3 schreibt es). Text und Logik 1:1 aus `app.js:1035-1196`.
- Produces:
```js
export async function zeigeWechsel(wurzel, { route, me, nav, daten }) // meldeTitel 'Wechsel ins Wartungs-Abo'
export function gemerkteFunktion() // → { key, projectId } | null (sessionStorage in try/catch, JSON und beide Felder als nichtleere Strings geprüft)
```
Ist ein Wechsel nicht möglich (`!sub.active || productKey !== 'hosting' || !canSwitch || !wartung`), steht `Ein Wechsel ist derzeit nicht möglich. Details finden Sie unter Verträge.` (Link `zuAnsicht('vertraege')`) und die Ansicht navigiert mit `ersetzen` nach `#vertraege`. Sonst Vorschau laden, Bestellblock wörtlich, Prüfungen, Knopf `Zahlungspflichtig bestellen` (nur frei, wenn die Vorschau da ist). Das Feld `funktion` wird nur gesendet, wenn `gemerkteFunktion()` etwas liefert (`gesendet`), und der Schlüssel danach gelöscht (nach erfolgreichem POST, mit oder ohne `paymentUrl`). Mit `paymentUrl` geht die Seite zur Zahlung, ein Ziel gibt es dann nicht. **Ziel nach Erfolg ohne `paymentUrl` (E14, C5):**
- Ist `gesendet` und liefert `wechselFunktionAus(antwort.subscription)` etwas, geht die Ansicht mit `navigiere('#funktionen', { ersetzen: true })` zu den Funktionen. P3 (Task 6, Test „wechselFunktion“) öffnet dort den Einschalt-Ablauf dieser Funktion. Es gibt kein `merkeHinweis`, sonst erschiene der Satz später zufällig in Verträge.
- Fehlt das Feld in der POST-Antwort (`wechselFunktion` setzt laut P6 sicher nur `GET /api/abo`), lädt die Ansicht genau einmal `ladeAbo()` und liest `wechselFunktionAus(daten?.subscription)`. Ohne gemerkte Funktion (`gesendet` falsch) gibt es keinen zweiten Abruf und keinen Sprung, auch wenn der Server ein Feld meldet.
- Sonst (nicht gesendet, oder der Wechsel ist noch nicht erledigt) wie bisher: `merkeHinweis(<Satz aus app.js:1158-1160>)` und `navigiere('#vertraege')`.
Ein `pageshow` mit hängender Bestellung rendert neu. Im Ansichtsmodus ist der Knopf gesperrt, daneben steht `Im Ansichtsmodus nicht möglich.`
- [ ] **Step 1: Test schreiben**
```js
const alt = ladeAlteApp()
const HOSTING = aboAntwort({ subscription: abo({ produkt: 'hosting', canSwitch: true, storage: { units: 2, pendingSetup: false, pendingReduction: null } }) })
const zeigeNeu = async (fx, daten = HOSTING, m = me(), titel = []) => { const w = neueWurzel(fx); w.addEventListener('kn:titel', (e) => titel.push(e.detail.text)); await zeigeWechsel(w, { route: { bereich: 'vertraege', etage: 'wechsel', query: '' }, me: m, nav: 'push', daten }); await fx.ruhe(); return w }
test('Wechsel: sichtbarer Text wie in app.js, Titel wird gemeldet', mit({ 'POST /api/abo/switch/preview': VORSCHAU }, async (fx) => {
const titel = []; assert.equal(sichtbarerText(await zeigeNeu(fx, HOSTING, me(), titel)), sichtbarerText(ohneKopf(await alt.wechsel(HOSTING, VORSCHAU)))); assert.deepEqual(titel, ['Wechsel ins Wartungs-Abo'])
}))
test('Wechsel nicht möglich: Satz und Rückweg zu Verträgen', mit({}, async (fx) => {
fx.window.history.replaceState = (_s, _t, url) => { fx.window.location.hash = `#${String(url).split('#')[1] ?? ''}` }
const w = await zeigeNeu(fx, aboAntwort({ subscription: abo({ produkt: 'wartung' }) }))
assert.match(fx.text(w), /Ein Wechsel ist derzeit nicht möglich\. Details finden Sie unter Verträge\./); assert.equal(fx.window.location.hash, '#vertraege')
}))
test('Wechsel: Prüfungen wörtlich, Body mit gemerkter Funktion, Schlüssel danach weg', mit({ 'POST /api/abo/switch/preview': VORSCHAU, 'POST /api/abo/switch': () => ({ body: { paymentUrl: 'https://pay.stripe.com/x', subscription: abo() } }) }, async (fx) => {
const speicher = new Map(); globalThis.sessionStorage = { getItem: (k) => speicher.get(k) ?? null, setItem: (k, v) => speicher.set(k, v), removeItem: (k) => speicher.delete(k) }
const w = await zeigeNeu(fx)
await fx.klick(fx.finde(w, '#switch-buy')); assert.match(fx.text(w), /Bitte stimmen Sie den Vertragsbedingungen zu\./)
await fx.klick(fx.finde(w, '#switch-terms')); await fx.klick(fx.finde(w, '#switch-buy')); assert.match(fx.text(w), /Bitte bestätigen Sie den Sofortbeginn, sonst können wir den Wechsel nicht ausführen\./)
await fx.klick(fx.finde(w, '#switch-early')); speicher.set('kn-wechsel-funktion', JSON.stringify({ key: 'termine', projectId: 'p1' }))
await fx.klick(fx.finde(w, '#switch-buy')); await fx.ruhe()
assert.deepEqual(fx.fetchLog.find((a) => a.url === '/api/abo/switch').body, { acceptTerms: true, earlyStart: true, termsVersion: '2026-09-30-v5', prorationDate: 1760000000, amountDueCents: 3000, funktion: { key: 'termine', projectId: 'p1' } })
assert.equal(speicher.has('kn-wechsel-funktion'), false); assert.equal(fx.window.location.href, 'https://pay.stripe.com/x'); assert.match(fx.text(w), /Ihr Wechsel wartet auf die Zahlung\. Wir leiten Sie zur Zahlung weiter\./)
delete globalThis.sessionStorage
}))
test('gemerkteFunktion: kaputte oder fremde Werte ergeben null', () => {
const wert = (v) => { globalThis.sessionStorage = { getItem: () => v }; try { return gemerkteFunktion() } finally { delete globalThis.sessionStorage } }
assert.deepEqual(wert('{"key":"shop","projectId":"p2"}'), { key: 'shop', projectId: 'p2' })
for (const v of ['kaputt', '{"key":"shop"}', '{"key":1,"projectId":"p"}', null]) assert.equal(wert(v), null)
})
const FUNKTION = { key: 'termine', projectId: 'p1' }
const WOHIN = (fx) => { fx.window.history.replaceState = (_s, _t, url) => { fx.window.location.hash = `#${String(url).split('#')[1] ?? ''}` }; fx.window.history.pushState = fx.window.history.replaceState }
for (const [name, gemerkt, antwort, nachgeladen, ziel] of [
['gemerkt, Funktion steht in der Antwort', true, { wechselFunktion: FUNKTION }, undefined, '#funktionen'],
['gemerkt, Funktion erst im GET /api/abo', true, {}, { wechselFunktion: FUNKTION }, '#funktionen'],
['gemerkt, Wechsel noch nicht erledigt', true, {}, { wechselFunktion: null }, '#vertraege'],
['nicht gemerkt, der Server meldet trotzdem eine Funktion', false, { wechselFunktion: FUNKTION }, { wechselFunktion: FUNKTION }, '#vertraege'],
]) {
test(`Wechsel ohne Zahlungsseite (${name}): Ziel ${ziel} (E14, C5)`, mit({
'POST /api/abo/switch/preview': VORSCHAU, 'POST /api/abo/switch': () => ({ body: { subscription: abo(antwort) } }),
'GET /api/abo': aboAntwort({ subscription: abo({ produkt: 'wartung', ...nachgeladen }) }),
}, async (fx) => {
WOHIN(fx); const speicher = new Map(gemerkt ? [['kn-wechsel-funktion', JSON.stringify(FUNKTION)]] : [])
globalThis.sessionStorage = { getItem: (k) => speicher.get(k) ?? null, setItem: (k, v) => speicher.set(k, v), removeItem: (k) => speicher.delete(k) }
try {
const w = await zeigeNeu(fx); await fx.klick(fx.finde(w, '#switch-terms')); await fx.klick(fx.finde(w, '#switch-early')); await fx.klick(fx.finde(w, '#switch-buy')); await fx.ruhe()
assert.equal(fx.window.location.hash, ziel); assert.equal(speicher.has('kn-wechsel-funktion'), false)
assert.equal(nimmHinweis() !== '', ziel === '#vertraege') // bei #funktionen bleibt kein Hinweis für später liegen
assert.equal(fx.fetchLog.filter((a) => a.url === '/api/abo').length, gemerkt && !antwort.wechselFunktion ? 1 : 0) // höchstens ein Nachladen, nur mit gemerkter Funktion
} finally { delete globalThis.sessionStorage }
}))
}
test('Wechsel: 409 lädt die Vorschau neu und behält die Meldung; Ansichtsmodus sperrt', mit({ 'POST /api/abo/switch/preview': VORSCHAU, 'POST /api/abo/switch': () => ({ status: 409, body: { error: 'Betrag hat sich geändert.' } }) }, async (fx) => {
const w = await zeigeNeu(fx); await fx.klick(fx.finde(w, '#switch-terms')); await fx.klick(fx.finde(w, '#switch-early')); await fx.klick(fx.finde(w, '#switch-buy')); await fx.ruhe()
assert.match(fx.text(w), /Betrag hat sich geändert\./); assert.equal(fx.fetchLog.filter((a) => a.url === '/api/abo/switch/preview').length, 2)
assert.equal(fx.finde(await zeigeNeu(fx, HOSTING, me({ viewAs: true })), '#switch-buy').hasAttribute('disabled'), true)
}))
```
- [ ] **Step 2: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knAnsichtWechsel.test.js`
Expected: FAIL (der Stub exportiert `gemerkteFunktion` nicht).
- [ ] **Step 3: Implementieren.** Text aus `app.js:1046-1187` wörtlich, Ids `switch-order`, `switch-terms`, `switch-early`, `switch-buy`, `switch-error` bleiben. Die Vorschau lädt bei jedem Aufruf neu (das ersetzt die 5-Minuten-Regel von `app.js:1195`). Beim Neuzeichnen bleiben angehakte Felder und ein sichtbarer Fehler erhalten. Das Ziel nach Erfolg ohne `paymentUrl` folgt der Beschreibung oben (`#funktionen` mit `ersetzen` nur mit gemerkter und zurückgekommener Funktion, sonst `#vertraege` mit Hinweis).
- [ ] **Step 4: Lauf grün.** Run wie Step 2. Expected: PASS, 9 Tests.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/ansicht-wechsel.js test/knAnsichtWechsel.test.js && git commit -m "Kundenansicht neu P2: Wechsel im Wortlaut der Fassung 5 mit gemerkter Funktion" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 14: Übernahmegutschrift (1:1)
**Files:**
- Modify: `public/neu/ansicht-gutschrift.js` (Stub von P1 ersetzen)
- Test: `test/knAnsichtGutschrift.test.js`
**Interfaces:**
- Consumes: `api`, `h`, `zuAnsicht`, `ladeAbo`, `fmtDatum`, `fmtEuro`, `meldeZaehlerNeu`. `GET /api/abo/takeover-credits` → `{ requests:[{ id, ref, createdAt, services, provider, domain, invoiceAmountCents, billingMonths, paidUntil, status, question, amountCents, decisionNote }], conditionsVersion, limits:{ maxFiles, maxFileBytes, maxTotalBytes } }`, `POST /api/abo/takeover-credits` (Body wie `app.js:1701-1721`), `POST /api/abo/takeover-credits/:id/reply` `{ message, files }`. Text und Logik 1:1 aus `app.js:1564-1815`.
- Produces:
```js
const TAKEOVER_CONDITIONS = [ … ] // Block Zeichen für Zeichen aus app.js:1578-1584 (Deklaration beginnt wörtlich mit `const TAKEOVER_CONDITIONS = [`)
export async function zeigeGutschrift(wurzel, { route, me, nav }) // Karte [data-karte="gutschrift"], Titel liefert die Hülle
export async function sammleDateien(dateien, grenzen, { erforderlich }, lies = IO.lies) // wirft die Texte aus app.js:1686-1692; → [{ name, data }]
export const IO = { lies: leseAlsBase64 } // Tests ersetzen IO.lies (FileReader gibt es im Test nicht)
```
Ohne Abo und ohne Anträge: `Die Übernahmegutschrift können Sie beantragen, sobald Ihr Abo läuft.` mit Link `Zum Abo` = `zuAnsicht('vertraege')`. Im Ansichtsmodus ist `Gutschrift beantragen` gesperrt und daneben steht `Im Ansichtsmodus kann kein Antrag gestellt werden.` (Antwort-Knöpfe ebenso gesperrt). Ids und Feldnamen des Formulars bleiben (`tk-form`, `tk-provider`, `tk-domain`, `tk-amount`, `tk-months`, `tk-paid-until`, `tk-message`, `tk-files`, `tk-email`, `tk-consent`, `tk-submit`, `name="services"`). Kundentext (`question`, `decisionNote`, Anbieter, Domain) nur als Text. Nach Absenden oder Antworten lädt die Ansicht neu und ruft `meldeZaehlerNeu('vertraege')`.
- [ ] **Step 1: Test schreiben** (Importe `readFileSync`, `createHash`).
```js
const alt = ladeAlteApp(); const ABO = aboAntwort({ subscription: abo() })
const ANTRAEGE = { requests: [anfrageGutschrift({ id: 't1', status: 'rueckfrage', question: 'Bitte <b>Kündigungsbestätigung</b> hochladen' }), anfrageGutschrift({ id: 't2', status: 'genehmigt', amountCents: 4000, decisionNote: 'Passt.' }), anfrageGutschrift({ id: 't3', status: 'abgelehnt', decisionNote: 'Nicht erstattbar.' })], conditionsVersion: '2026-09-30', limits: LIMITS }
const SRV = { 'GET /api/abo': ABO, 'GET /api/abo/takeover-credits': ANTRAEGE }
const zeigeNeu = async (fx, m = me()) => { const w = neueWurzel(fx); await zeigeGutschrift(w, { route: { bereich: 'vertraege', etage: 'gutschrift', query: '' }, me: m, nav: 'push' }); await fx.ruhe(); return w }
for (const [name, abo_, antwort] of [['mit Anträgen', ABO, ANTRAEGE], ['ohne Abo', aboAntwort(), { requests: [], limits: LIMITS }]]) {
test(`Gutschrift (${name}): Karte wie in app.js`, mit({ 'GET /api/abo': abo_, 'GET /api/abo/takeover-credits': antwort }, async (fx) => {
const w = await zeigeNeu(fx)
assert.equal(sichtbarerText(fx.finde(w, '[data-karte="gutschrift"]')), sichtbarerText(await alt.gutschrift(abo_, antwort)))
}))
}
test('Bedingungen der Gutschrift: Block gleich dem Pin in legalTexts.test.js (Fassung 2026-09-30)', () => {
const q = readFileSync(new URL('../public/neu/ansicht-gutschrift.js', import.meta.url), 'utf8'); const i = q.indexOf('const TAKEOVER_CONDITIONS'); const block = q.slice(i, q.indexOf(']', i))
assert.equal(createHash('sha256').update(block).digest('hex'), '2b7a745cb599c0ab28f20615a745bd86da6faa8d120391ca56beac35a0473e62')
})
test('sammleDateien: Prüftexte wörtlich', async () => {
const g = { maxFiles: 3, maxFileBytes: 8 * 1048576, maxTotalBytes: 10 * 1048576 }; const f = (name, size) => ({ name, size }); const lies = async () => 'QQ=='
await assert.rejects(sammleDateien([], g, { erforderlich: true }, lies), { message: 'Bitte laden Sie die Rechnung Ihres bisherigen Anbieters hoch.' })
await assert.rejects(sammleDateien([1, 2, 3, 4].map((n) => f(`${n}.pdf`, 1)), g, { erforderlich: false }, lies), { message: 'Bitte höchstens 3 Dateien auf einmal hochladen.' })
await assert.rejects(sammleDateien([f('groß.pdf', 9 * 1048576)], g, { erforderlich: false }, lies), { message: '„groß.pdf“ ist größer als 8 MB.' })
await assert.rejects(sammleDateien([f('a.pdf', 6 * 1048576), f('b.pdf', 6 * 1048576)], g, { erforderlich: false }, lies), { message: 'Die Dateien sind zusammen größer als 10 MB.' })
assert.deepEqual(await sammleDateien([f('a.pdf', 5)], g, { erforderlich: true }, lies), [{ name: 'a.pdf', data: 'QQ==' }])
})
test('Antrag: Prüfungen wörtlich, Body mit denselben Schlüsseln wie früher, Meldung nach Erfolg', mit({ ...SRV, 'POST /api/abo/takeover-credits': {} }, async (fx) => {
IO.lies = async () => 'QQ=='; const w = await zeigeNeu(fx); await fx.klick(fx.finde(w, (e) => e.textContent === 'Gutschrift beantragen'))
await fx.klick(fx.finde(w, '#tk-submit')); assert.match(fx.text(w), /Bitte wählen Sie, welche Leistung Sie bisher woanders bezahlt haben\./)
await fx.klick(fx.finde(w, 'input[value="domain"]')); await fx.setze(fx.finde(w, '#tk-provider'), 'IONOS'); await fx.setze(fx.finde(w, '#tk-amount'), '119.88'); await fx.setze(fx.finde(w, '#tk-months'), '12'); await fx.setze(fx.finde(w, '#tk-paid-until'), '2027-03-31')
await fx.klick(fx.finde(w, '#tk-submit')); assert.match(fx.text(w), /Bitte bestätigen Sie Ihre Angaben und die Bedingungen\./)
fx.finde(w, '#tk-files').files = [{ name: 'r.pdf', size: 10 }]; await fx.klick(fx.finde(w, '#tk-consent')); await fx.klick(fx.finde(w, '#tk-submit')); await fx.ruhe()
assert.deepEqual(fx.fetchLog.find((a) => a.method === 'POST').body, { services: ['domain'], provider: 'IONOS', domain: '', invoiceAmount: '119.88', billingMonths: 12, paidUntil: '2027-03-31', message: '', email: 'anna@example.com', consent: true, files: [{ name: 'r.pdf', data: 'QQ==' }] })
assert.match(fx.text(w), /Vielen Dank! Ihr Antrag ist eingegangen\. Die Eingangsbestätigung haben wir an anna@example\.com geschickt; unsere Antwort kommt ebenfalls per E-Mail\./)
}))
test('Rückfrage beantworten; Kundentext bleibt Text; Ansichtsmodus sperrt alles', mit({ ...SRV, 'POST /api/abo/takeover-credits/t1/reply': {} }, async (fx) => {
const w = await zeigeNeu(fx); assert.equal(fx.finde(w, 'b'), null)
await fx.setze(fx.finde(w, 'form[data-reply-for="t1"] textarea'), 'Anbei.'); await fx.klick(fx.finde(w, 'form[data-reply-for="t1"] button[type="submit"]')); await fx.ruhe()
assert.deepEqual(fx.fetchLog.find((a) => a.url.endsWith('/reply')).body, { message: 'Anbei.', files: [] })
const v = await zeigeNeu(fx, me({ viewAs: true })); assert.match(fx.text(v), /Im Ansichtsmodus kann kein Antrag gestellt werden\./)
assert.ok(fx.alle(v, 'button').filter((b) => ['Gutschrift beantragen', 'Antwort senden'].includes(b.textContent)).every((b) => b.hasAttribute('disabled')))
}))
test('Gutschrift ohne Abo: Satz und Link zum Abo über die Brücke', mit({ 'GET /api/abo': aboAntwort(), 'GET /api/abo/takeover-credits': { requests: [], limits: LIMITS } }, async (fx) => {
const w = await zeigeNeu(fx); assert.match(fx.text(w), /Die Übernahmegutschrift können Sie beantragen, sobald Ihr Abo läuft\./)
const a = fx.finde(w, 'a[href="#vertraege"]'); assert.deepEqual([a.textContent, a.getAttribute('data-view')], ['Zum Abo', 'vertraege'])
}))
```
- [ ] **Step 2: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knAnsichtGutschrift.test.js`
Expected: FAIL (der Stub exportiert `sammleDateien` und `IO` nicht).
- [ ] **Step 3: Implementieren.** Die Karte trägt `data-karte="gutschrift"` und entspricht `#takeover-card` (Reihenfolge: Einleitung, Meldung, Knopf, Formular mit Klasse `hidden`, Liste). Der Block `TAKEOVER_CONDITIONS` wird mit der Kommentarzeile darüber aus `app.js:1577-1584` übernommen. Fehlertexte, Statuspillen und Hinweise wörtlich aus `app.js:1570-1815`.
- [ ] **Step 4: Lauf grün.** Run wie Step 2. Expected: PASS, 7 Tests. Danach `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/legalTexts.test.js`: PASS (unverändert).
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/ansicht-gutschrift.js test/knAnsichtGutschrift.test.js && git commit -m "Kundenansicht neu P2: Übernahmegutschrift im Wortlaut" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 15: Passwort ändern
**Files:**
- Modify: `public/neu/ansicht-passwort.js` (Stub von P1 ersetzen)
- Test: `test/knAnsichtPasswort.test.js`
**Interfaces:**
- Consumes: `api`, `h`, `zuAnsicht`, `navigiere`. `POST /api/auth/password` `{ currentPassword, newPassword }` (`routes/auth.js:236-251`, Fehlercodes `wrong_password`, `current_missing`, `new_invalid`). Regeln und Texte aus `app.js:1820-1925` und `dashboard.html:290-322` (bereits Du-Form).
- Produces:
```js
export async function zeigePasswort(wurzel, { route, me, nav }) // Felder name current-password, new-password, new-password-repeat; verstecktes Feld username = me.customer.email; Titel 'Passwort ändern' liefert die Hülle
export function passwortProblem(aktuell, neu, wiederholung, email) // → [feldId, text] | null, Reihenfolge und Texte wörtlich aus app.js:1820-1832
```
`Fertig` geht zurück: bei `nav === 'push'` `window.history.back()`, sonst `navigiere('#')`. Nach Erfolg sind die Felder leer und wieder `type="password"`. Hinweis `Mindestens 8 Zeichen. Am sichersten ist ein langer Satz, den nur du kennst.`, Fußsatz `Danach bist du auf deinen anderen Geräten abgemeldet. Aktuelles Passwort vergessen?` mit Link `Schreib uns im Chat` = `zuAnsicht('chat')` und `– wir setzen es zurück.`, Erfolgstext `Dein Passwort wurde geändert.` mit `Melde dich ab jetzt mit dem neuen Passwort an. Auf deinen anderen Geräten bist du abgemeldet.`, im Ansichtsmodus alle Felder gesperrt mit `Im Ansichtsmodus kann das Passwort nicht geändert werden.`
- [ ] **Step 1: Test schreiben**
```js
test('passwortProblem: Texte wörtlich, Reihenfolge der Prüfungen', () => {
const p = passwortProblem
assert.deepEqual(p('', 'x', 'x', ''), ['pw-current', 'Bitte gib dein aktuelles Passwort ein.'])
assert.deepEqual(p('a', '', '', ''), ['pw-new', 'Bitte gib ein neues Passwort ein.'])
assert.deepEqual(p('a', 'kurz', 'kurz', ''), ['pw-new', 'Das neue Passwort muss mindestens 8 Zeichen haben.'])
assert.deepEqual(p('a', ' ', ' ', ''), ['pw-new', 'Das neue Passwort darf nicht nur aus Leerzeichen bestehen.'])
assert.deepEqual(p('abcdefgh', 'abcdefgh', 'abcdefgh', ''), ['pw-new', 'Das neue Passwort muss sich vom aktuellen unterscheiden.'])
assert.deepEqual(p('a', 'Anna@Example.com', 'Anna@Example.com', 'anna@example.com'), ['pw-new', 'Bitte nimm nicht deine E-Mail-Adresse als Passwort.'])
assert.deepEqual(p('a', 'neues-passwort', 'anderes-passwort', ''), ['pw-repeat', 'Die beiden neuen Passwörter stimmen nicht überein.'])
assert.equal(p('a', 'neues-passwort', 'neues-passwort', ''), null)
})
const felder = (fx, w) => ({ alt: fx.finde(w, 'input[name="current-password"]'), neu: fx.finde(w, 'input[name="new-password"]'), wdh: fx.finde(w, 'input[name="new-password-repeat"]') })
const zeigeNeu = async (fx, m = me(), nav = 'push') => { const w = neueWurzel(fx); await zeigePasswort(w, { route: { bereich: 'website', etage: 'passwort', query: '' }, me: m, nav }); return w }
test('Passwort ändern: Problem zeigt Text ohne Aufruf, Erfolg sendet und räumt auf, Fertig geht zurück', mit({ 'POST /api/auth/password': { success: true } }, async (fx) => {
let zurueck = 0; fx.window.history.back = () => { zurueck += 1 }
const w = await zeigeNeu(fx); const f = felder(fx, w); assert.equal(fx.finde(w, 'input[name="username"]').value, 'anna@example.com')
assert.equal(fx.finde(w, 'a[data-view="chat"]').textContent, 'Schreib uns im Chat')
await fx.klick(fx.finde(w, 'button[type="submit"]')); assert.match(fx.text(w), /Bitte gib dein aktuelles Passwort ein\./); assert.equal(fx.fetchLog.length, 0)
await fx.setze(f.alt, 'altes-passwort'); await fx.setze(f.neu, 'neues-passwort'); await fx.setze(f.wdh, 'neues-passwort'); await fx.klick(fx.finde(w, 'button[type="submit"]')); await fx.ruhe()
assert.deepEqual(fx.fetchLog.at(-1).body, { currentPassword: 'altes-passwort', newPassword: 'neues-passwort' }); assert.equal(f.neu.value, '')
assert.match(fx.text(w), /Dein Passwort wurde geändert\./); await fx.klick(fx.finde(w, (e) => e.textContent === 'Fertig')); assert.equal(zurueck, 1)
}))
test('Falsches Passwort fokussiert das Feld; Ansichtsmodus sperrt alles', mit({ 'POST /api/auth/password': () => ({ status: 400, body: { error: 'Das aktuelle Passwort stimmt nicht.', code: 'wrong_password' } }) }, async (fx) => {
const w = await zeigeNeu(fx); const f = felder(fx, w)
await fx.setze(f.alt, 'x'); await fx.setze(f.neu, 'neues-passwort'); await fx.setze(f.wdh, 'neues-passwort'); await fx.klick(fx.finde(w, 'button[type="submit"]')); await fx.ruhe()
assert.match(fx.text(w), /Das aktuelle Passwort stimmt nicht\./); assert.equal(fx.document.activeElement, f.alt)
const v = await zeigeNeu(fx, me({ viewAs: true })); assert.ok(fx.alle(v, 'input, button').every((e) => e.hasAttribute('disabled'))); assert.match(fx.text(v), /Im Ansichtsmodus kann das Passwort nicht geändert werden\./)
}))
```
- [ ] **Step 2: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knAnsichtPasswort.test.js`
Expected: FAIL (der Stub exportiert `passwortProblem` nicht).
- [ ] **Step 3: Implementieren** (Signaturen und Texte oben). Fehlerfokus: `wrong_password`, `current_missing` → aktuelles Feld, `new_invalid` → neues Feld.
- [ ] **Step 4: Lauf grün.** Run wie Step 2. Expected: PASS, 3 Tests.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/ansicht-passwort.js test/knAnsichtPasswort.test.js && git commit -m "Kundenansicht neu P2: Passwort ändern als Etage" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 16: Verträge, Rahmen mit Reitern
**Files:**
- Modify: `public/neu/ansicht-vertraege.js` (Stub von P1 ersetzen)
- Test: `test/knAnsichtVertraege.test.js`
**Interfaces:**
- Consumes: `api`, `h`, `baueReiter` aus `./reiter.js` (P1 Task 5: `baueReiter({ eintraege: [{ key, label }], aktiv, art = 'unterstrich', ariaLabel, bei(key) }) → HTMLElement` mit `button[role=tab]`, `aria-selected`, Methode `setzeAktiv(key)`), `ladeAbo`, `wechselFunktionAus`, `navigiere`, `nimmHinweis`, `ABO_NICHT_GELADEN`, `offeneRechnungen` (Task 3); `zeigeRechnungen` (8), `zeigeAbo` (9), `zeigeStunden` (10), `zeigeSpeicher` (11); `routeAusHash` aus `./routen.js` (P1, nur im Test der Vorbedingung). Bestellen, Wechsel und Gutschrift ruft P1 direkt (`ETAGE_ANSICHTEN`).
- Produces:
```js
export async function zeigeVertraege(wurzel, { route, me, nav }) // route.reiter: 'rechnungen'|'abo'|'stunden'|'email' | undefined (Bereichs-Einstieg)
export function sichtbareReiter(daten) // daten = GET /api/abo | null: ohne laufendes Abo ['rechnungen','abo']; Hosting + 'email'; Wartung + 'stunden','email' (Reihenfolge rechnungen, abo, stunden, email)
export function waehleReiter({ routeReiter, sichtbar, offeneRechnungen }) // routeReiter = route.reiter oder undefined; liest weder location.hash noch sonst eine Adresse
export const AUF_ABO_WARTEN = { versuche: 10, pauseMs: 2000 } // nach ?abo=success, in Tests verkürzbar
```
`waehleReiter` (allein `route.reiter` ist die Wahrheit, P1 schreibt `#abo` und `#wartung` kanonisch um, die Adressleiste sagt darum nichts mehr aus): Ist `routeReiter` unter `sichtbar`, gilt er, auch bei offener Rechnung (`#abo`, `#wartung`, `#rechnungen`, `#stunden`, `#speicher` und jeder Klick auf einen Reiter). Ein nicht sichtbarer `routeReiter` fällt auf `abo`. Fehlt `routeReiter` (Bereichs-Einstieg über `#vertraege` oder die Leiste, VF8), gilt `rechnungen`, wenn `offeneRechnungen > 0`, sonst `abo`. Reiter-Klicks (`bei(key)`) navigieren mit den ausdrücklichen Hashes `#rechnungen`, `#abo`, `#stunden`, `#speicher` (`navigiere(…, { ersetzen: true })`); die Ansicht wählt danach wieder nur aus der neuen Route. Aufbau: Hinweis aus `nimmHinweis()`, Reiterzeile (`Rechnungen`, `Abo`, `Stunden`, `E-Mail`), Reiter-Inhalt, darunter immer der Link `Verträge hier kündigen` → `/kuendigen.html`. Der Titel `Verträge` kommt von der Hülle. `GET /api/abo` und `GET /api/invoices` laden parallel, jede Quelle mit eigenem Fehlerweg (`ABO_NICHT_GELADEN`, Rechnungen zeigen ihren Fehler selbst). Für `route.reiter === 'stunden'` ohne Wartung zeigt der Abo-Reiter zusätzlich `Ein Stundenkonto gibt es im Wartungs-Abo.` Rückkehr von Stripe (`location.search` enthält `abo=success`): wartet bis zu `AUF_ABO_WARTEN.versuche` mal auf ein laufendes Abo und zeigt dazu `Vielen Dank! Dein Abo wird aktiviert …` und `Das dauert nur einen Moment.`, danach (auch bei `abo=cancel`) `history.replaceState(history.state, '', location.pathname + location.hash)`. Liefert die zuletzt geladene `GET /api/abo`-Antwort bei `abo=success` eine `wechselFunktionAus(daten.subscription)`, navigiert die Ansicht nach dem Aufräumen mit `navigiere('#funktionen', { ersetzen: true })` (E14, C5; P3 Task 6 öffnet dort den Ablauf), sonst bleibt sie in Verträge. Das Feld kommt nur nach einem Wechsel mit gemerkter Funktion (V11); `?abo=success` entsteht allerdings nur beim Abschluss (`stripeBilling.js:637`), die Zahlungsseite des Wechsels hat keine Rückkehradresse. Der Zweig ist deshalb ein Sicherheitsnetz, den Hauptweg deckt Task 13 ab.
- [ ] **Step 1: Signatur von `baueReiter` lesen**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && sed -n 1,60p public/neu/reiter.js`
Expected: Die Signatur ist sichtbar. Weicht sie von der oben genannten ab, wird nur die interne Funktion `vertraegeReiter()` in `ansicht-vertraege.js` angepasst. Die Tests unten fragen `[role="tab"][aria-selected="true"]` ab.
- [ ] **Step 2: Tests schreiben**
```js
const VIER = ['rechnungen', 'abo', 'stunden', 'email']
test('sichtbareReiter nach Abo', () => {
assert.deepEqual([null, aboAntwort(), aboAntwort({ subscription: abo({ active: false }) })].map((d) => sichtbareReiter(d)), [['rechnungen', 'abo'], ['rechnungen', 'abo'], ['rechnungen', 'abo']])
assert.deepEqual(sichtbareReiter(aboAntwort({ subscription: abo({ produkt: 'hosting' }) })), ['rechnungen', 'abo', 'email'])
assert.deepEqual(sichtbareReiter(aboAntwort({ subscription: abo() })), VIER)
})
test('Vorbedingung (VF8): der Bereichs-Hash hat keinen Reiter, die Reiter-Hashes haben einen', () => {
assert.equal(routeAusHash('#vertraege').reiter, undefined)
for (const [hash, reiter] of [['#abo', 'abo'], ['#wartung', 'abo'], ['#rechnungen', 'rechnungen'], ['#stunden', 'stunden'], ['#speicher', 'email']]) assert.equal(routeAusHash(hash).reiter, reiter, hash)
})
test('waehleReiter: nur der Bereichs-Einstieg springt, jede ausdrückliche Wahl bleibt (Review Focus 2)', () => {
const w = (o) => waehleReiter({ routeReiter: undefined, sichtbar: VIER, offeneRechnungen: 1, ...o })
assert.equal(w({}), 'rechnungen'); assert.equal(w({ offeneRechnungen: 0 }), 'abo')
assert.equal(w({ routeReiter: 'abo' }), 'abo') // #abo, #wartung und der Klick auf „Abo“ trotz offener Rechnung
assert.equal(w({ routeReiter: 'stunden' }), 'stunden'); assert.equal(w({ routeReiter: 'email' }), 'email'); assert.equal(w({ routeReiter: 'rechnungen', offeneRechnungen: 0 }), 'rechnungen')
assert.equal(w({ routeReiter: 'stunden', sichtbar: ['rechnungen', 'abo'] }), 'abo')
})
const SERVER = { 'GET /api/abo': aboAntwort(), 'GET /api/invoices': { invoices: [rechnung({ balance: 50 })], incomplete: false }, 'GET /api/auftraege': { aktiv: false }, 'GET /api/abo/takeover-credits': { requests: [], limits: LIMITS } }
const zeige = async (fx, route = {}) => { const w = neueWurzel(fx); await zeigeVertraege(w, { route: { bereich: 'vertraege', query: '', ...route }, me: me(), nav: 'tab' }); await fx.ruhe(); return w }
const aktiv = (fx, w) => fx.finde(w, '[role="tab"][aria-selected="true"]').textContent
const WOHIN = (fx) => { fx.window.history.replaceState = (_s, _t, url) => { fx.window.location.hash = `#${String(url).split('#')[1] ?? ''}` }; fx.window.history.pushState = fx.window.history.replaceState }
test('Verträge: Reiter in fester Reihenfolge, Kündigen-Link unter jedem Reiter, ausdrückliche Reiterwahl bleibt trotz offener Rechnung', mit(SERVER, async (fx) => {
for (const [route, gewaehlt] of [[{ reiter: 'abo' }, 'Abo'], [{ reiter: 'rechnungen' }, 'Rechnungen'], [{}, 'Rechnungen']]) { // {} = Bereichs-Einstieg
const w = await zeige(fx, route)
assert.deepEqual(fx.alle(w, '[role="tab"]').map((t) => t.textContent), ['Rechnungen', 'Abo'])
assert.equal(aktiv(fx, w), gewaehlt)
assert.equal(fx.finde(w, 'a[href="/kuendigen.html"]').textContent, 'Verträge hier kündigen')
}
}))
test('Verträge: die Route entscheidet, die Adressleiste nicht (keine zweite Wahrheit)', mit(SERVER, async (fx) => {
fx.window.location.hash = '#vertraege'; assert.equal(aktiv(fx, await zeige(fx, { reiter: 'abo' })), 'Abo') // widerspricht der Route absichtlich
fx.window.location.hash = '#abo'; assert.equal(aktiv(fx, await zeige(fx, {})), 'Rechnungen')
}))
test('Reiterklick navigiert ausdrücklich und ersetzt: #rechnungen, #abo, #stunden, #speicher', mit({ ...SERVER, 'GET /api/abo': aboAntwort({ subscription: abo() }) }, async (fx) => {
WOHIN(fx); const w = await zeige(fx, { reiter: 'rechnungen' })
for (const [label, hash] of [['Abo', '#abo'], ['Stunden', '#stunden'], ['E-Mail', '#speicher'], ['Rechnungen', '#rechnungen']]) {
await fx.klick(fx.finde(w, (e) => e.getAttribute?.('role') === 'tab' && e.textContent === label)); assert.equal(fx.window.location.hash, hash, label)
}
}))
test('Verträge: /api/abo fällt aus, Reiter, Rechnungen und Kündigen bleiben', mit({ ...SERVER, 'GET /api/abo': () => ({ status: 500, body: { error: 'x' } }) }, async (fx) => {
const w = await zeige(fx, { reiter: 'rechnungen' })
assert.match(fx.text(w), /Deine Verträge konnten gerade nicht geladen werden\./); assert.ok(fx.finde(w, 'a[href="/kuendigen.html"]')); assert.ok(fx.finde(w, 'tbody tr'))
}))
test('route.reiter stunden ohne Wartung: Abo-Reiter mit Hinweis', mit({ ...SERVER, 'GET /api/abo': aboAntwort({ subscription: abo({ produkt: 'hosting' }) }) }, async (fx) => {
const w = await zeige(fx, { reiter: 'stunden' })
assert.equal(aktiv(fx, w), 'Abo'); assert.match(fx.text(w), /Ein Stundenkonto gibt es im Wartungs-Abo\./)
}))
test('Hinweis aus einer Aktion steht genau einmal über den Reitern', mit(SERVER, async (fx) => {
merkeHinweis('Dein Wechsel ist abgeschlossen.'); assert.match(fx.text(await zeige(fx, { reiter: 'abo' })), /Dein Wechsel ist abgeschlossen\./)
assert.doesNotMatch(fx.text(await zeige(fx, { reiter: 'abo' })), /Dein Wechsel ist abgeschlossen\./)
}))
test('Rückkehr von Stripe wartet auf das Abo und entfernt ?abo= aus der Adresse, der Verlaufsstempel bleibt', mit({ ...SERVER, 'GET /api/abo': (() => { let n = 0; return () => ({ body: n++ < 2 ? aboAntwort() : aboAntwort({ subscription: abo() }) }) })() }, async (fx) => {
Object.assign(AUF_ABO_WARTEN, { versuche: 5, pauseMs: 0 }); fx.window.location.search = '?abo=success'; fx.window.location.hash = '#abo'; fx.window.history.state = { kn: { tiefe: 0 } }
let ersetzt = null; fx.window.history.replaceState = (s, _t, url) => { ersetzt = { s, url } }
const w = await zeige(fx, { reiter: 'abo' })
assert.match(fx.text(w), /Mein Vertrag/); assert.deepEqual(ersetzt, { s: { kn: { tiefe: 0 } }, url: '/dashboard.html#abo' }); assert.ok(fx.fetchLog.filter((a) => a.url === '/api/abo').length >= 3)
}))
let letztes = null
test('Rückkehr von Stripe: mit zurückgekommener wechselFunktion geht es mit ersetzen zu #funktionen, ohne sie bleibt es in Verträge (E14, C5)', mit({ ...SERVER, 'GET /api/abo': () => ({ body: letztes }) }, async (fx) => {
Object.assign(AUF_ABO_WARTEN, { versuche: 5, pauseMs: 0 }); fx.window.location.search = '?abo=success'; WOHIN(fx)
letztes = aboAntwort({ subscription: abo({ wechselFunktion: { key: 'termine', projectId: 'p1' } }) }); fx.window.location.hash = '#vertraege'
await zeige(fx, {}); assert.equal(fx.window.location.hash, '#funktionen')
letztes = aboAntwort({ subscription: abo({ wechselFunktion: null }) }); fx.window.location.hash = '#vertraege'
await zeige(fx, {}); assert.equal(fx.window.location.hash, '#vertraege')
}))
```
- [ ] **Step 3: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knAnsichtVertraege.test.js`
Expected: FAIL (der Stub exportiert `sichtbareReiter` nicht). Der Test „Vorbedingung (VF8)“ gehört nicht zu den erwarteten Fehlern: Er prüft `routen.js` von P1 und muss schon jetzt grün sein. Ist er rot (P1 liefert für `#vertraege` noch `reiter: 'abo'`), anhalten und dem Controller melden. Nicht um `waehleReiter` herumbauen: Ohne Route ohne Reiter kann die Ansicht den Bereichs-Einstieg nicht von der Wahl des Reiters „Abo“ trennen.
- [ ] **Step 4: Implementieren** (Signaturen und Aufbau oben). Die Reiter-Inhalte `rechnungen`, `abo`, `stunden`, `email` rufen `zeigeRechnungen` (mit `antwort`), `zeigeAbo`, `zeigeStunden`, `zeigeSpeicher` (mit `daten`) mit den schon geladenen Antworten. Die Reiterwahl liest `location.hash` nirgends. Erlaubt sind nur `location.search` (`abo=`) und das Zusammensetzen von `location.pathname + location.hash` beim Aufräumen der Adresse (dort bleibt der Hash unverändert).
- [ ] **Step 5: Lauf grün.** Run wie Step 3. Expected: PASS, 11 Tests.
- [ ] **Step 6: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/ansicht-vertraege.js test/knAnsichtVertraege.test.js && git commit -m "Kundenansicht neu P2: Verträge mit Reitern" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 17: Öffentliche Seiten im neuen Stil
**Files:**
- Create: `public/neu/oeffentlich/login.html`, `kuendigen.html`, `widerrufen.html`, `auftrag.html`, `offline.html`, `public/neu/oeffentlich/oeffentlich.css`
- Test: `test/knAnsichtOeffentlich.test.js`
**Interfaces:**
- Consumes: die alten Seiten `public/login.html`, `kuendigen.html`, `widerrufen.html`, `auftrag.html`, `offline.html` (nur gelesen) und ihre Skripte `public/app.js` (`initLoginPage`), `legal-form.js`, `auftrag-widerruf.js`, `auftrag.js` mit `auftrag-dom.js` und `auftrag-ansicht.js` (unverändert wiederverwendet, VF6); `public/neu/tokens.css` (P1); `public/auftrag.css` (nur gelesen, als Vorlage der `ab-*`-Regeln). Das Werkzeug aus P0 liefert `/login.html`, `/kuendigen.html`, `/widerrufen.html` und (Task 19 ergänzt den Eintrag) `/auftrag.html` aus diesen Dateien, sobald sie existieren (`vorschau/seiten.mjs`, `SEITEN`).
- Produces: fünf HTML-Seiten mit gleichem sichtbarem Text, gleichen Feldern und gleichen Skripten wie die alten, aber mit `<link>` auf `/neu/tokens.css` und `/neu/oeffentlich/oeffentlich.css` statt `/login.css`, `/legal-form.css` und `/auftrag.css`. `offline.html` bleibt vollständig inline (Service Worker, `sw.js:5`), mit den Werten der Global Constraints als Literale. Keine neuen `id`s, keine Verläufe, kein Lila, kein Dunkelmodus. Pflicht-Metazeile `<meta name="theme-color" content="#ffffff">` (genau eine), auf der Login-Seite zusätzlich `apple-mobile-web-app-status-bar-style` mit Inhalt `default`. Das Kündigen-Formular bleibt schlicht (keine Halteangebote), die Knöpfe heißen wie bisher.
- **Auftragsseite (Spec 5.1: „wird angeglichen“; Link aus Mail K31, `/auftrag.html#t=<Token>`):** `neu/oeffentlich/auftrag.html` behält `<body class="ab-seite">`, die Ids `ab-wurzel` und `ab-ansage`, den Rückweg `<a class="ab-app-back" href="/dashboard.html">`, `<script type="module" src="/auftrag.js">` und den Sie-Text (die Auftragsbestätigung bleibt wie gebaut, Spec 5.6 Zeile I). Weil der Token in der Adresse steht, bleiben `<meta name="robots" content="noindex, nofollow">` und `<meta name="referrer" content="no-referrer">`. Statt `color-scheme: light dark` und zwei `theme-color`-Zeilen steht `<meta name="color-scheme" content="light">` und die eine weiße `theme-color`. `oeffentlich.css` enthält dafür einen Abschnitt `body.ab-seite`: Er setzt die `--ab-*`-Variablen aus den Tokens (ok = grün, warn = orange, rot, muted = grau der Status-Pillen, Knopf und Fokus Akzent `#111`) und schreibt die Regeln aus `auftrag.css` ab dem Abschnitt „Grundlayout“ bis vor „Portal (Reiter Rechnungen, dunkel)“ sowie `.ab-app-back` auf Tokens um: ohne Karte (Abschnitt mit Haarlinie statt Kasten), Radien 6 und 12, Gewicht höchstens 700, ohne `@media (prefers-color-scheme: dark)`. Die `.ab-portal`-Regeln gehören nicht hierher, sie stehen in `ansichten.css` (Task 18).
- [ ] **Step 1: Test schreiben**
```js
const lese = (p) => readFileSync(new URL(`../public/${p}`, import.meta.url), 'utf8')
const PAARE = ['login', 'kuendigen', 'widerrufen', 'auftrag', 'offline'].map((n) => [`${n}.html`, `neu/oeffentlich/${n}.html`])
const koerper = (html) => html.slice(html.search(/<body/i)) // Metas und Links im Kopf ändern sich bewusst (Stil, theme-color), geprüft werden sie unten
const attribute = (html) => [...koerper(html).matchAll(/\s(name|id|value|type|autocomplete|maxlength|required|min|max|step|inputmode|placeholder|enterkeyhint|data-text|data-label|data-label-for)(?:="([^"]*)")?/g)].map((m) => `${m[1]}=${m[2] ?? ''}`).sort()
const skripte = (html) => [...html.matchAll(/<script[^>]*>[\s\S]*?<\/script>/g)].map((m) => m[0].replace(/\s+/g, ' '))
for (const [alt, neu] of PAARE) {
test(`${neu}: gleicher sichtbarer Text, gleiche Felder, gleiche Skripte wie ${alt}`, () => {
assert.equal(sichtbarerText(lese(neu)), sichtbarerText(lese(alt))); assert.deepEqual(attribute(lese(neu)), attribute(lese(alt))); assert.deepEqual(skripte(lese(neu)), skripte(lese(alt)))
})
test(`${neu}: neuer Stil, hell, ohne Verläufe, Lila und Dunkelmodus`, () => {
const n = lese(neu)
assert.match(n, /<meta name="theme-color" content="#ffffff">/); assert.equal((n.match(/name="theme-color"/g) || []).length, 1)
assert.doesNotMatch(n, /aurora|dot-background|#9d3dfe|gradient|\/login\.css|\/legal-form\.css|\/auftrag\.css|prefers-color-scheme|content="light dark"/)
if (neu.endsWith('offline.html')) assert.doesNotMatch(n, /<link|(?:src|href)="https?:/); else assert.match(n, /href="\/neu\/tokens\.css"[\s\S]*href="\/neu\/oeffentlich\/oeffentlich\.css"/)
})
}
test('Login: Skript lädt app.js und startet initLoginPage, Statusleiste hell', () => {
const n = lese('neu/oeffentlich/login.html'); assert.match(n, /<script src="\/app\.js"><\/script>/); assert.match(n, /initLoginPage\(\)/); assert.match(n, /apple-mobile-web-app-status-bar-style" content="default"/)
})
test('Auftragsseite: Token bleibt geschützt (noindex, kein Referrer), Rückweg, Wurzel und Modul bleiben, hell, jede Klasse hat eine Regel', () => {
const n = lese('neu/oeffentlich/auftrag.html')
assert.match(n, /<meta name="robots" content="noindex, nofollow">/); assert.match(n, /<meta name="referrer" content="no-referrer">/); assert.match(n, /<meta name="color-scheme" content="light">/)
assert.match(n, /<body class="ab-seite">/); assert.match(n, /<a class="ab-app-back" href="\/dashboard\.html">/); assert.match(n, /id="ab-wurzel"/); assert.match(n, /id="ab-ansage"/); assert.match(n, /<script type="module" src="\/auftrag\.js"><\/script>/)
const klassen = new Set([...lese('auftrag.css').replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/\.ab-seite[^{,]*?\.(ab-[a-z0-9-]+)/g)].map((m) => m[1])); klassen.add('ab-app-back') // Alle Klassen, die die alte Seite stylt (ohne Kommentare)
const css = lese('neu/oeffentlich/oeffentlich.css')
for (const k of klassen) assert.match(css, new RegExp(`\\.${k}(?![\\w-])`), `Klasse ohne Regel: ${k}`)
assert.match(css, /body\.ab-seite[^{]*\{[^}]*--ab-knopf:/)
})
```
- [ ] **Step 2: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knAnsichtOeffentlich.test.js`
Expected: FAIL `ENOENT` für `public/neu/oeffentlich/login.html`.
- [ ] **Step 3: Seiten bauen.** Jede alte Seite Element für Element übernehmen, Layout über neue Klassen (`kn-seite`, `kn-formular` usw.). Klassen und Ids, die die alten Skripte brauchen (`hidden`, `decl-form`, `done`, `done-at`, `done-list`, `form-error`, `print-btn`, `packages-wrap`, `reason-wrap`, `when-wrap`, `effective-date`, `ab-nummer-wrap`, `login-form`, `login-error`, `login-btn`, `login-staff-link`, auf der Auftragsseite alle `ab-*`-Klassen und die Ids `ab-wurzel` und `ab-ansage`), bleiben. Dekor (`aurora`, Punktraster) entfällt. `oeffentlich.css`: weiß, Text und Linien nur über Tokens, Felder mit Ring, Knöpfe 44 px hoch (schwarz für Anmelden und Absenden), Druckansicht der Bestätigung wie `legal-form.css:29`, installierte App wie `:30-37` mit weißem statt dunklem Streifen hinter der Statusleiste. Der Abschnitt `body.ab-seite` folgt der Beschreibung der Auftragsseite oben. Der Lauf in Step 4 zeigt jede `ab-*`-Klasse ohne Regel einzeln an.
- [ ] **Step 4: Lauf grün.** Run wie Step 2. Expected: PASS, 12 Tests (fünf Seiten mal zwei, Login und Auftragsseite je einer).
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/oeffentlich/login.html public/neu/oeffentlich/kuendigen.html public/neu/oeffentlich/widerrufen.html public/neu/oeffentlich/auftrag.html public/neu/oeffentlich/offline.html public/neu/oeffentlich/oeffentlich.css test/knAnsichtOeffentlich.test.js && git commit -m "Kundenansicht neu P2: öffentliche Seiten und Auftragsbestätigung im neuen Stil, Text unverändert" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 18: Aussehen der Ansichten (`ansichten.css`)
**Files:**
- Modify: `public/neu/ansichten.css` (Stub von P1 füllen)
- Test: `test/knAnsichtStil.test.js`
**Interfaces:**
- Consumes: Tokens aus `public/neu/tokens.css` und Bausteine aus `basis.css` (P1: `.kn-knopf`, `.kn-knopf-primaer`, `.kn-knopf-zweit`, `.kn-textknopf`, `.kn-feld`, `.kn-liste`, `.kn-zeile`, `.kn-abschnitt`, `.kn-meldung`, `.kn-fehler`, `.kn-leer`, `.kn-pille` mit `-grau|-blau|-orange|-violett|-gruen|-rot`), die Klassen aus `K` und alle `kn-*`-Klassen der P2-Module.
- Produces: eine Datei, die `dashboard.html` nach `basis.css`, `huelle.css` und `/neu/chat-composer.css` (der bereinigten Kopie aus Task 7) lädt. Abschnitte: `Website` (Projektzeile mit Frame 16:10, Fakten, Zeitleiste, Details), `Chat` (`.kn-chat` nach dem Muster `.woms-chat` aus `/home/webklar/apps/ticket.webklar.com/build/src/styles/chat.css:12-110`, Selektoren strikt spezifischer als `neu/chat-composer.css`, kein `!important`), `Verträge` (Rechnungen als Zeilen, auf dem Handy gestapelt), `Abo`, `Stunden`, `Speicher`, Bestell- und Formularblöcke (Zusammenfassungstabelle, Haken, Knöpfe 44 px), `.kn .ab-portal` für den Aufträge-Abschnitt (alle Variablen `--ab-*` selbst gesetzt, hell, ohne `--kb-*`), `.kn .hidden`.
- [ ] **Step 1: Test schreiben**
```js
const css = (f) => readFileSync(new URL(`../public/neu/${f}`, import.meta.url), 'utf8')
const lese = (p) => readFileSync(new URL(`../public/${p}`, import.meta.url), 'utf8')
const A = css('ansichten.css'); const O = css('oeffentlich/oeffentlich.css'); const C = css('chat-composer.css'); const BASIS = css('basis.css'); const TOKENS = css('tokens.css')
const VERBOTEN = /linear-gradient|radial-gradient|conic-gradient|backdrop-filter|prefers-color-scheme:\s*dark|font-weight:\s*800|#9d3dfe|rgba?\(\s*157\s*,\s*61/i
test('Stil: keine verbotenen Mittel (Verläufe, Glas, Dunkelmodus, Lila, Gewicht 800), auch nicht in der Kopie des Composer-CSS', () => { for (const [n, c] of [['ansichten.css', A], ['oeffentlich.css', O], ['chat-composer.css', C]]) assert.doesNotMatch(c, VERBOTEN, n) })
test('Stil: Farbliterale nur in Custom-Property-Deklarationen, jedes benutzte Token ist definiert', () => {
for (const [n, c] of [['ansichten.css', A], ['oeffentlich.css', O]]) for (const z of c.split('\n')) if (!/^\s*--kn-[\w-]+:/.test(z)) assert.doesNotMatch(z, /#[0-9a-f]{3,8}\b|rgba?\(/i, `${n}: ${z.trim()}`)
const definiert = new Set([...(TOKENS + A + O).matchAll(/(--kn-[\w-]+)\s*:/g)].map((m) => m[1]))
for (const m of (A + O).matchAll(/var\((--kn-[\w-]+)/g)) assert.ok(definiert.has(m[1]), m[1])
})
test('Stil: jede kn-Klasse der P2-Module hat eine Regel', () => {
const klassen = new Set(); for (const n of MODULE.filter(existiert)) for (const m of lies(n).matchAll(/class:\s*'([^']+)'/g)) m[1].split(/\s+/).filter((k) => k.startsWith('kn-')).forEach((k) => klassen.add(k))
for (const wert of Object.values(K).flatMap((v) => (typeof v === 'string' ? [v] : Object.values(v)))) wert.split(/\s+/).filter((k) => k.startsWith('kn-')).forEach((k) => klassen.add(k))
for (const k of klassen) assert.match(BASIS + A, new RegExp(`\\.${k}(?![\\w-])`), `Klasse ohne Regel: ${k}`)
})
test('Stil: Aufträge-Abschnitt, Chat-Variablen, hidden und Handy-Abfrage', () => {
const ab = new Set([...(lese('auftrag-dom.js') + lese('auftrag-portal.js')).matchAll(/\bab-[a-z0-9]+(?:-[a-z0-9]+)*/g)].map((m) => m[0]))
for (const k of [...ab, 'ab-pille-warn', 'ab-pille-ok', 'ab-pille-rot'].filter((x) => x !== 'ab-portal')) assert.match(A, new RegExp(`\\.kn \\.ab-portal[^{]*\\.${k}(?![\\w-])`), k)
for (const v of ['accent', 'accent-hover', 'accent-soft', 'accent-line', 'accent-text', 'on-accent', 'line', 'muted', 'surface']) assert.match(A, new RegExp(`\\.kn-chat[^{]*\\{[^}]*--chat-${v}:`), v)
assert.match(A, /\.kn \.hidden\s*\{[^}]*display:\s*none/); assert.ok(A.includes('(max-width: 880px), (pointer: coarse) and (max-height: 500px)'))
})
```
(`MODULE`, `existiert`, `lies` wie in `knAnsichtQuelltext.test.js`, `K` aus `ansicht-hilfen.js`.)
- [ ] **Step 2: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knAnsichtStil.test.js`
Expected: FAIL (der Stub enthält nur einen Kommentar). Der Test listet jede Klasse ohne Regel. Diese Liste ist die Arbeitsliste für Step 3.
- [ ] **Step 3: CSS schreiben.** Abschnitte durch Haarlinien statt Kästen, Listen als Zeilen (`border-top` Haarlinie), Tabellenkopf Zweittext 12 px, Pillen getönt ohne Rand (Radius 4), Knöpfe 36 px (Handy 44 px) mit Radius 6, Felder mit Ring-Schatten, Zahlen `tabular-nums`, Fokus sichtbar, kein Glow. Radien nur 4, 6 und 12 px (Rund 50 %). Das Chat-Aussehen übernimmt die Struktur des WOMS-Musters mit Präfix `.kn-chat`. Farben nur über `var(--kn-…)`, fehlende Werte als `--kn-…`-Deklaration oben in `ansichten.css` (erlaubt vom Test).
- [ ] **Step 4: Lauf grün.** Run wie Step 2. Expected: PASS, 4 Tests.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/ansichten.css test/knAnsichtStil.test.js && git commit -m "Kundenansicht neu P2: Aussehen der Ansichten" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 19: Werkzeug, Ansichten, Gate und Endpunkte
**Files:**
- Modify (Werkzeug-Git `KW` = `/root/reports/kundenansicht-neu-2026-10-15/werkzeug/`): `views.mjs`, `gates.mjs`, `fixtures/endpunkte.mjs` (eine Importzeile), `vertrag-endpunkte.mjs`, `vorschau/seiten.mjs` (ein Eintrag in `SEITEN`: `{ ziel: 'auftrag.html', quellen: ['neu/oeffentlich/auftrag.html', 'auftrag.html'] }`; P0 darf ihn gleich aufnehmen, VF7)
- Create (`KW`): `fixtures/endpunkte-p2.mjs`
- Modify (Portal-Worktree, nur bei Verstößen aus Step 3): Dateien aus der Dateiübersicht.
**Interfaces:**
- Consumes (P0 Task 6, 9, 10, 14 und README): Ansichten `{ id, fall, adresse, geraete, warte, schritte?(seite), bewegung?, tastatur?, ignorieren? }` mit Geräten `handy`, `handy-quer`, `tablet`, `desktop`; `GATES.p<N> = { checks, ansichten }` mit `ALWAYS_ON` und den Prüfungen `dark-surface`, `low-contrast`, `overflow-x`, `lila`; `schritte(seite)` bekommt die Playwright-Seite (`seite.evaluate`, `seite.route`, `seite.reload`, `seite.viewportSize()`) und wirft bei Verstoß (wird zu `step-failed`). Die 14 Fälle von P0 (`betrieb-wartung`, `hosting`, `ohne-abo`, `privat-frist`, `kundenart-leer`, `rechnung-offen`, `chat-ungelesen`, `funktionen-alle`, `vorschuss`, `mehrere-websites`, `mitarbeiter`, `abgemeldet`, `fehler`, `leer`) bleiben unverändert (P0 pinnt `FALL_NAMEN`). Sonderdaten (offene Aufträge, Rückfrage, Zahlungsproblem, Speicher mit Änderung) setzt der Step mit `seite.route` auf die jeweilige `/api/…`-Antwort und lädt dann neu. Unbekannte `/api/*` melden `unknown-api`, deshalb stehen die drei neuen Endpunkte in `fixtures/endpunkte-p2.mjs`: `POST /api/abo/portal` → `{ url: '/vorschau-hinweis.html' }`, `POST|DELETE /api/chat/attachments` (Anhänge des Composers) und der öffentliche `GET /api/auftrag/*` → `{ auftrag }` mit einem offenen Auftrag in der Form von `oeffentlicheAnsicht` (`server/services/auftraege/auftragSicht.js:76-110`: `status: 'versendet'`, `bestaetigtAm: null`, `kundenart: 'unternehmen'`, `art: 'festpreis'`, eine Position, `summeCents`, `zahlungsplanText`, `agbUrl`, `texte { bestaetigung, fruehbeginn: null, kleinunternehmer }`). Alle drei mit Eintrag in `vertrag-endpunkte.mjs` (`NICHT_PRUEFBAR` mit Grund, falls der echte Router nicht über die Testnachbauten läuft; der Auftrags-Router hängt am Schalter `AUFTRAEGE_ENABLED` und braucht Aufträge-Seeds).
- Produces: Gate `p2 = { checks: [...ALWAYS_ON, 'dark-surface', 'low-contrast', 'overflow-x', 'lila'], ansichten: ['kn-p2-*'] }`, grün auf Handy, Tablet und Desktop. Alle Ansichten `geraete: ['handy', 'tablet', 'desktop']`, soweit unten nichts anderes steht.
| id (`kn-p2-…`) | Fall | Adresse, `warte` | Prüfung im Step |
|---|---|---|---|
| `website` | `betrieb-wartung` | `/dashboard.html`, `#website-body [data-projekt]` | Frame, Pille `Vorschau online`, Link `Projektdetails` |
| `website-mehrere` | `mehrere-websites` | wie oben | drei Zeilen |
| `website-rechnung` | `rechnung-offen` | wie oben | erste Zeile `Rechnung offen · RE-2026-0042` |
| `website-leer` | `leer` | `/dashboard.html`, `#website-body .kn-leer` | Leertext |
| `website-teilausfall` | `fehler` | `/dashboard.html`, `#website-body [data-abschnitt="aktivitaet"]` | Fehlertext der Aktivität, Projekte sichtbar |
| `website-projekt` | `betrieb-wartung` | `/dashboard.html#projekt?projekt=<erste ID>`, `#etage-body` | drei Abschnitte, Titel im Blatt ist der Projektname |
| `website-fortschritt` | `betrieb-wartung` | `/dashboard.html#fortschritt`, `[data-abschnitt="aktivitaet"]` | Abschnitt im Blickfeld |
| `chat` | `chat-ungelesen` | `/dashboard.html#chat`, `#chat-body textarea` | Blasen, Composer unten erreichbar, Badge verschwunden |
| `chat-ansicht` | `mitarbeiter` | `/dashboard.html#chat`, `#chat-body textarea` | Composer gesperrt |
| `rechnungen` | `rechnung-offen` | `/dashboard.html#rechnungen`, `#vertraege-body tbody tr` | Link `Zahlen` |
| `auftraege` | `betrieb-wartung` | `/dashboard.html#rechnungen`, `#vertraege-body [role=tab]` | nach `seite.route('**/api/auftraege', …)` und Neuladen: Abschnitt `Aufträge` sichtbar |
| `sprung` | `rechnung-offen` | `/dashboard.html#vertraege`, `#vertraege-body [role=tab]` | Bereichs-Einstieg: Reiter `Rechnungen` aktiv. Klick auf den Reiter `Abo`: `Abo` bleibt aktiv, springt nicht zurück (Review Focus 2). Danach `location.hash = '#rechnungen'` ergibt `Rechnungen`, ein Neuaufruf von `/dashboard.html#abo` ergibt `Abo` trotz offener Rechnung |
| `abo-wartung`, `abo-hosting`, `abo-ohne`, `abo-privat` | `betrieb-wartung`, `hosting`, `ohne-abo`, `privat-frist` | `/dashboard.html#abo`, `#vertraege-body [role=tab]` | Wechsel-Karte nur bei Hosting, zwei Produkte ohne Abo, `Vertrag widerrufen · bis` nur bei Frist, `Verträge hier kündigen` immer |
| `abo-zahlung` | `betrieb-wartung` | wie oben | `/api/abo` mit `paymentProblem: true` überschrieben: Knopf `Zahlungsart aktualisieren` |
| `stunden`, `stunden-vorschuss` | `betrieb-wartung`, `vorschuss` | `/dashboard.html#stunden`, `#vertraege-body [role=tab]` | Statistik; Vorschuss-Zeile, kein `extra` |
| `email`, `email-plus` | `betrieb-wartung` (mit `canChangeStorage: true`) | `/dashboard.html#speicher`, `[data-panel="speicher"]` | Panel; nach Plus Zusammenfassung und Knopf im Blick |
| `bestellen`, `bestellen-hosting` | `ohne-abo` | `/dashboard.html#bestellen-wartung`, `/dashboard.html#bestellen-hosting`, `#abo-order` | Kerninfo und Knopf `Zahlungspflichtig bestellen` im selben Bildschirm, keine Klappe dazwischen |
| `wechsel` | `hosting` | `/dashboard.html#wechsel`, `#switch-order` | Bestellblock, Knopf |
| `gutschrift`, `gutschrift-frage` | `betrieb-wartung` | `/dashboard.html#gutschrift`, `[data-karte="gutschrift"]` | Formular aufklappbar; Rückfrage mit Antwortfeld (`/api/abo/takeover-credits` überschrieben) |
| `passwort` | `betrieb-wartung` | `/dashboard.html#passwort`, `#etage-body input[name="new-password"]` | drei Felder, Knopf |
| `login`, `kuendigen`, `widerrufen` | `abgemeldet`, `betrieb-wartung`, `betrieb-wartung` | `/login.html`, `/kuendigen.html`, `/widerrufen.html`; `warte: 'form'`; `handy`, `desktop` | Seite hell, Knopf erreichbar |
| `auftrag` | `abgemeldet` | `/auftrag.html#t=vorschau`, `warte: '#ab-wurzel[data-zustand="offen"]'`; `handy`, `desktop` | Seite hell (kein Dunkelmodus), Zusammenfassung, Haken und Knopf `Auftrag bestätigen` erreichbar, `lila` grün (die Seite lädt kein `/auftrag.css`) |
| `offline` | `abgemeldet` | `/neu/oeffentlich/offline.html`, `warte: 'main'`; `handy`, `desktop` | Seite hell, Knopf `Erneut versuchen` |
- [ ] **Step 1: Ansichten, Endpunkte und Gate eintragen** (Tabelle oben, Beispiel und Konventionen in `KW/README.md` und in `views.mjs`). Jede `steps`-Prüfung wirft bei Abweichung. Danach `free -m` (über 2000 MB verfügbar).
- [ ] **Step 2: Werkzeug-Tests grün**
Run: `cd /root/reports/kundenansicht-neu-2026-10-15/werkzeug && ./check.sh --alle`
Expected: alle Tests grün (Vertragstest der Fixtures eingeschlossen, `fail 0`).
- [ ] **Step 3: Lauf**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && KN_STRANG_ENV=/root/reports/kundenansicht-neu-2026-10-15/werkzeug/tmp/strang-ui.env /root/reports/kundenansicht-neu-2026-10-15/werkzeug/shots.sh /root/reports/kundenansicht-neu-2026-10-15/werkzeug/tmp/shots-p2 --gate p2`
Expected: Exit 0, Bericht ohne Verstöße. Fehlt `strang-ui.env`, legt man sie wie in P1 Task 13 Step 1 an. Bei Verstößen die Ansicht im Portal-Worktree beheben (eigener Fix-Commit, Dateien einzeln) und neu laufen lassen. `step-failed`, `external-request`, `console-error`, `unknown-api` und `backend-error` zählen immer. Danach `ls -l /dev/null` (`crw-rw-rw-`).
- [ ] **Step 4: Aufnahmen ansehen.** Je Ansicht auf Handy, Tablet und Desktop (Read-Werkzeug auf die PNG unter `tmp/shots-p2/`): keine Karten, keine Kästen, Titel der Hülle oben, nichts läuft waagerecht über, Zahlen und Pillen nicht abgeschnitten.
- [ ] **Step 5: Commit im Werkzeug-Git**
```bash
cd /root/reports/kundenansicht-neu-2026-10-15/werkzeug && git add views.mjs gates.mjs fixtures/endpunkte-p2.mjs fixtures/endpunkte.mjs vertrag-endpunkte.mjs vorschau/seiten.mjs && git commit -m "Kundenansicht neu P2: Ansichten, Gate p2, drei Endpunkte und die Auftragsseite" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 20: Selbstprüfung
**Files:** keine neuen.
- [ ] **Step 1: Speicher prüfen**
Run: `free -m`
Expected: mehr als 2000 MB verfügbar. Sonst warten und dem Controller melden.
- [ ] **Step 2: Alle eigenen Tests grün**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/knAnsichtQuelltext.test.js test/knAnsichtHilfen.test.js test/knAnsichtOrakel.test.js test/knAnsichtRechnungOffen.test.js test/knAnsichtInstall.test.js test/knAnsichtWebsiteDetails.test.js test/knAnsichtWebsite.test.js test/knAnsichtChat.test.js test/knAnsichtRechnungen.test.js test/knAnsichtAbo.test.js test/knAnsichtStunden.test.js test/knAnsichtSpeicher.test.js test/knAnsichtBestellen.test.js test/knAnsichtWechsel.test.js test/knAnsichtGutschrift.test.js test/knAnsichtPasswort.test.js test/knAnsichtVertraege.test.js test/knAnsichtOeffentlich.test.js test/knAnsichtStil.test.js`
Expected: alle PASS, 0 FAIL.
- [ ] **Step 3: Bestehende Pins grün**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/adminPanelGone.test.js test/legalTexts.test.js test/kundenansichtClient.test.js test/auftraegeKundenportalPortal.test.js test/auftraegeKundenportalDashboard.test.js test/knHuelleSeite.test.js test/kundenansichtDashboard.test.js`
Expected: alle PASS.
- [ ] **Step 4: Nur eigene Dateien geändert, Altes unverändert**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git diff --name-only kn-p1 HEAD && git diff --stat kn-p1 HEAD -- public/app.js public/dashboard.html public/login.html public/kuendigen.html public/widerrufen.html public/offline.html public/legal-form.js public/legal-form.css public/auftrag.html public/auftrag.css public/chat-composer.js public/chat-composer.css`
Expected: Die erste Liste enthält nur Pfade aus der Dateiübersicht (`public/neu/…`, `test/support/kn*.js`, `test/knAnsicht*.test.js`) und `test/knHuelleSeite.test.js` (nur Task 7, eine Zeile). Der zweite Befehl gibt nichts aus.
- [ ] **Step 5: Verträge abhaken**
- [ ] V2: Jede Ansicht exportiert `zeigeX(wurzel, { route, me, nav })`, nutzt `h`, `api` und Hash-Namen aus `[a-z]`; alte Hashes werden nicht erzeugt oder verändert.
- [ ] V3: Offene Rechnung ist `Number(balance) > 0` (Zeile, Sprung), „Verträge hier kündigen“ unter jedem Reiter, „Vertrag widerrufen“ nur mit `withdrawalOpen`, Zähler-Meldung über `kn:zaehler-neu`.
- [ ] V8: Vorschuss wird gezeigt, wenn `ledger.vorschussMinutes > 0`, nie als Mehraufwand (Task 10).
- [ ] V11: `ansicht-wechsel.js` sendet `funktion` aus `sessionStorage['kn-wechsel-funktion']` und löscht den Schlüssel nach dem POST (Task 13). Kommt danach `subscription.wechselFunktion` zurück (aus der POST-Antwort, sonst aus einem Nachladen), geht die Ansicht mit `ersetzen` zu `#funktionen`, ohne gemerkte Funktion zu `#vertraege`; dasselbe tut `ansicht-vertraege.js` bei `?abo=success` (Task 13 und 16). Das Öffnen des Einschalt-Ablaufs dort testet P3 (Task 6, „wechselFunktion“), P2 prüft nur das Ziel.
- [ ] Reiterwahl: `waehleReiter` und `zeigeVertraege` lesen `location.hash` nicht, allein `route.reiter` zählt; die Vorbedingung (VF8) ist grün (Task 16).
- [ ] Kein Lila: Die neue Oberfläche lädt weder `/chat-composer.css` noch `/auftrag.css`, `/login.css` oder `/legal-form.css` (Task 7, 17); der Lila-Test aus Task 1 ist grün, das Gate `p2` bleibt in der Prüfung `lila` leer.
- [ ] Auftragsbestätigung: `neu/oeffentlich/auftrag.html` trägt noindex und no-referrer und den Text der alten Seite (Task 17); P10 muss `/auftrag.html` in `UMSCHALT_SEITEN` und in die Nachprüf-Marker aufnehmen (VF6).
- [ ] V12 (nur 1:1): Bestellen, Wechsel, Speicher, Gutschrift tragen den Wortlaut der Fassung 5 und senden `termsVersion` aus `GET /api/abo` (Task 11 bis 14).
- [ ] Zuständigkeitstabelle: nur die Dateien aus der Dateiübersicht angefasst (Step 4).
- [ ] **Step 6: Sicherheit und Sauberkeit**
Run: `ls -l /dev/null && cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git status --short`
Expected: `crw-rw-rw-` für `/dev/null`, `git status` ohne Ausgabe (der Symlink `node_modules` ist ignoriert).
- [ ] **Step 7: Tag setzen**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git tag kn-p2 && git log --oneline kn-p1..kn-p2 | wc -l
```
Expected: Zahl der Commits dieses Pakets (mindestens 18), kein Push.
---
## Vertragsfragen an den Controller
Die Pläne von P0, P1, P3 bis P5 lagen beim Schreiben vor und sind eingearbeitet. Offen bleibt:
- **VF1 Zusatzdateien von P2.** Die Zuständigkeitstabelle nennt nur die Ansichts-Dateien. P2 braucht zusätzlich: `public/neu/ansicht-hilfen.js`, `rechnung-offen.js`, `install-hinweis.js`, `website-details.js`, `vertraege-rechnungen.js`, `vertraege-abo.js`, `public/neu/oeffentlich/oeffentlich.css`, `public/neu/oeffentlich/auftrag.html` (Spec 5.1 verlangt die Angleichung, die Tabelle nennt nur vier öffentliche Seiten), `public/neu/chat-composer.css` (lilafreie Kopie, VF10), `test/support/knDaten.js`, `test/support/knWortlautOrakel.js`, `test/knAnsicht*.test.js`. Außerdem ergänzt P2 zwei Zeilen in `public/neu/dashboard.html` (Composer, Task 7; P1s Seiten-Test erlaubt das Skript, die Stil-Zeile stellt Task 7 um). Vorschlag: Alle in die Zeile von P2 aufnehmen, kein anderes Paket legt gleiche Namen an.
- **VF2 Ziel des Ereignisses `kn:zaehler-neu`.** P1 Task 11 hört am `document` (Test: `b.doc.dispatchEvent`), P1 VF6 nennt `window`. P2 sendet am `document` mit `bubbles: true` (erreicht beide). Werte in `detail.was`: `'chat'`, `'vertraege'`, `'alle'`. Bitte P1 vereinheitlichen.
- **VF3 Rechnungszeile.** P2 liefert `rechnung-offen.js` und zeichnet die Zeile auf der Website. Als Quelle nutzt die Website einen eigenen Abruf von `GET /api/invoices`. P3 (und P1 VF6) erwarten `zaehler.stand().rechnungen`; P1 Task 7 liefert in `stand()` aber nur `chat` und `vertraege`. Vorschlag: P1 ergänzt `rechnungen` in `stand()`, dann reicht P2 es in einem Folgeschritt durch und spart den zweiten Abruf.
- **VF4 Projekt-ID in der Adresse.** P1s Beispiel ist `#projekt?id=p1`, P2 erzeugt `#projekt?projekt=<id>` (wie die Funktionsmodule, `data-project` der Brücke) und liest beide. Bitte P1 das Beispiel angleichen.
- **VF5 Fassung 6 für Speicher und Gutschrift.** Spec 5.6 (C1, D2) will auch dort das schlanke Muster, die Zuständigkeitstabelle nennt für P7 nur Bestellen und Wechsel. Vorschlag: P7 übernimmt zusätzlich `ansicht-speicher.js` (Plus-Block) und `ansicht-gutschrift.js` (Zustimmungsblock), ersetzt dort die Wortlaut-Orakel-Tests durch Pins auf die Texte der Fassung 6 und zieht Hash (`legalTexts.test.js:18`) und `config.takeover.conditionsVersion` gemeinsam nach. Das Orakel (`test/support/knWortlautOrakel.js`) liest `app.js`, P10 muss es vor dem Löschen von `app.js` einfrieren oder entfernen.
- **VF6 Öffentliche Seiten und P10.** Die neue Login-Seite nutzt `initLoginPage()` aus `public/app.js` (unverändert), die Rechtsformulare `legal-form.js` und `auftrag-widerruf.js`. P10 muss `initLoginPage` samt Helfern (`app.js:421-568`) vor dem Löschen von `app.js` nach `public/neu/oeffentlich/` verlegen und beim Ausliefern `sw.js` (`OFFLINE_URL`, heute `/offline.html`) mitziehen. Der Installationshinweis (P2) und „App installieren“ im Konto (P1) hören beide auf `beforeinstallprompt`, das ist unkritisch, P10 darf es zusammenlegen. **Auftragsseite:** `neu/oeffentlich/auftrag.html` (Task 17) lädt `/auftrag.js` mit `auftrag-dom.js` und `auftrag-ansicht.js` unverändert. P10 nimmt `/auftrag.html` in `UMSCHALT_SEITEN` und in die Nachprüf-Marker auf (Marker: `/neu/oeffentlich/oeffentlich.css` im Quelltext, kein `/auftrag.css`). Danach braucht keine ausgelieferte Seite mehr `auftrag.css`, P10 kann sie aus „Bleiben“ nehmen, sobald das alte Dashboard (`.ab-portal`) fällt. Mehrere P2-Tests lesen alte Dateien (`app.js` im Orakel, die alten Seiten in `knAnsichtOeffentlich`, `auftrag.css` und `chat-composer.css` in `knAnsichtOeffentlich` und `knAnsichtChat`); P10 muss sie vor dem Löschen einfrieren oder entfernen. Der Vergleich mit `chat-composer.css` überspringt sich selbst, wenn das Original fehlt.
- **VF7 Werkzeug.** P2 ergänzt `fixtures/endpunkte-p2.mjs` (`POST /api/abo/portal`, `POST|DELETE /api/chat/attachments`, öffentlicher `GET /api/auftrag/*`) und ein Gate `p2`; neue Fälle legt P2 nicht an, weil P0 `FALL_NAMEN` pinnt. Für die Auftragsseite braucht `vorschau/seiten.mjs` (P0) einen Eintrag in `SEITEN` (`auftrag.html` mit Quellen `neu/oeffentlich/auftrag.html`, `auftrag.html`); Task 19 trägt ihn ein, P0 kann ihn gleich aufnehmen. Fehlt `tmp/strang-ui.env`, legt Task 19 sie wie P1 Task 13 Step 1 an.
- **VF8 Bereichs-Einstieg ohne Reiter (P1, Übersicht V2).** Den Sprung auf Rechnungen (Spec 5.3) und die ausdrückliche Reiterwahl kann Task 16 nur über `route.reiter` trennen, die Adressleiste taugt nicht: P1 schreibt `#abo` und `#wartung` kanonisch zu `#vertraege` um. Dafür muss `routeAusHash('#vertraege')` eine Route **ohne** `reiter` liefern, `#abo` und `#wartung` `reiter: 'abo'`, und `hashFuerRoute` darf `reiter: 'abo'` nicht zu `#vertraege` zusammenfallen lassen (`#abo` bleibt `#abo`). Die Übersicht V2 nennt heute `vertraege`, `abo`, `wartung` → `reiter: 'abo'`, und P1 Task 3 pinnt das in `FAELLE`. Beides ist anzupassen (die Standard-Reiter für `ankerKette` und `elternVon` bleiben Sache von P1). Ohne die Änderung zeigt der Test „Vorbedingung (VF8)“ in Task 16 es sofort an, `waehleReiter` springt dann nie (sicher, aber ohne Sprung), und das Gate `sprung` scheitert.
- **VF9 Wechsel mit Funktion (P2, P3, P6).** P2 (Task 13, 16) navigiert mit `ersetzen` zu `#funktionen`, P3 Task 6 öffnet dort den Ablauf. P6 setzt `wechselFunktion` laut Plan nur in `GET /api/abo`, nicht sicher in der Antwort von `POST /api/abo/switch`; P2 lädt deshalb einmal nach, wenn die Antwort das Feld nicht trägt. Vorschlag (optional): P6 liefert das Feld auch in der POST-Antwort, dann entfällt der Abruf. P7 baut `ansicht-wechsel.js` später um und muss das Ziel nach Erfolg (`#funktionen` bei gemerkter und zurückgekommener Funktion, sonst `#vertraege`) samt den vier Tests aus Task 13 übernehmen. Die Zahlungsseite des Wechsels (`hosted_invoice_url`) hat keine Rückkehradresse, `?abo=success` kommt nur vom Abschluss (`stripeBilling.js:637`). Wer nach der Zahlung zurückkehrt, ohne `#funktionen` zu öffnen, wird erst beim nächsten Aufruf der Funktionen weitergeleitet (längstens 14 Tage).
- **VF10 Composer-CSS (P1, P2).** Das alte `chat-composer.css` enthält Lila und kann nicht in die neue Seite (P1 VF3). P2 legt die lilafreie Kopie `public/neu/chat-composer.css` an (Task 7). P1s Seiten-Test (`knHuelleSeite`) erlaubt bisher `/chat-composer.css`; Task 7 stellt die Zeile um. Vorschlag: P1 schreibt sie gleich als `/neu/chat-composer.css`, dann bleibt die Datei in Task 7 unberührt. P10 kann die Kopie nach dem Löschen des Originals zur Hauptdatei machen.