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

158 KiB
Raw Blame History

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:
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):
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:

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:

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
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:
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
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
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):
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
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

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:
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
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

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:
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
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

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:
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
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

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:
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).
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

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:
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
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

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:
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
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

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:
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
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

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:
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
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

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:
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
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

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:
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
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

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:
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).
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

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:
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
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

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:
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
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

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 ids, 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

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

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

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

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

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
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.