# 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 `, 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 `` 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=`), 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=') 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-` 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 " ``` --- ### 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,

'), '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('

A

B

C

')), 'C') assert.equal(sichtbarerText(ohneKopf('

A

C

')), 'C') assert.equal(sichtbarerText(ohneKopf('

C

')), '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 " ``` --- ### 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) // →

| 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 " ``` --- ### 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 " ``` --- ### 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: '', 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 " ``` --- ### 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=', hilfsweise '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=]` 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: `, leer: `Es wurden noch keine Arbeiten dokumentiert.` Etage `projekt`: `meldeTitel(wurzel, )` und `baueProjektDetails(await ladeProjektDetails(id))`. - [ ] **Step 1: Test schreiben** ```js const HAUPT = { 'GET /api/projects': { projects: [projekt({ id: 'p1', projectName: 'Bäckerei Roth' })] }, '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 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 " ``` --- ### 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' | ' · …' ``` 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=`, 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 `` zwischen `/neu/huelle.css` und `/neu/ansichten.css` und `` 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, /