158 KiB
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), damitnode --testdie Module direkt lädt.ChatComposerkommt überglobalThis. - Kein HTML-Einsetzen: kein
innerHTML,insertAdjacentHTML,outerHTML. Kundentext nur überh()undtextContent. URLs aus Daten (previewUrl,portalUrl,paymentUrl) nur übersafeUrl(http/https) inhref/src. - Anrede:
duin allen P2-Texten. Ausnahme nur die Wortlaut-Dateienansicht-bestellen.js,ansicht-wechsel.js,ansicht-speicher.js,ansicht-gutschrift.jsund die Seiten unteroeffentlich/: 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 austitelFuer(route). Weicht der Titel ab (Projektname, Produktname), meldet die Ansicht ihn mitmeldeTitel(wurzel, text)(Ereigniskn:titel, P1 Task 6). - Links zwischen Ansichten tragen
hrefunddata-view(Brücke der Hülle,hashFuerAnsicht), erzeugt mitzuAnsicht(name, projektId?). Programmatisch navigiert nurnavigiere(hash, { ersetzen }). - Wiederholbar: Jeder
zeigeX-Aufruf leertwurzel, zeigt sofort den TextWird 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 SperrtextIm 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 überlegacyRequest(api). - Verlaufsstempel (P1 VF7, VF9): Wer die Adresse aufräumt, ruft
history.replaceState(history.state, …), nie mitnull. - 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 unterpublic/) bleiben unverändert (Prüfung in Task 20). Was die neue Oberfläche anders braucht, liegt als neue Datei unterpublic/neu/. - Kein Lila in
public/neu/: Die Prüfunglila(P0) liest den Text jedes geladenen Stylesheets, auch ungenutzter Regeln. Die neue Oberfläche lädt deshalb weder/chat-composer.cssnoch/auftrag.css,/login.cssoder/legal-form.css, sondern bereinigte Dateien unterpublic/neu/(chat-composer.cssin Task 7,oeffentlich/oeffentlich.cssin Task 17). Ein Quelltext-Test (Task 1) sucht in jeder Textdatei unterpublic/neu/nach#9d3dfeundrgba(157, 61, 254. - Pin:
test/adminPanelGone.test.js:55-66liestpublic/rekursiv. Kein neuer Quelltext enthältadmin.html,kalkulation.htmloder/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
- Alt-Modul sendet doppelt kodierten Body:
auftrag-portal.jsschicktJSON.stringify(...), das neueapi()kodiert Objekte selbst. Ohne Adapter scheitert „Auftrag bestätigen“ still mit 400. Tests: Task 1 (legacyRequest), Task 8 (POST-Body ist ein Objekt). - Reiter-Sprung überschreibt ausdrückliche Wahl: Mit offener Rechnung springt nur der Bereichs-Einstieg (Route ohne
reiter) auf Rechnungen.#abo,#wartung,#stunden,#speicherund ein Klick auf einen Reiter nie. Alleinroute.reiterentscheidet, die Adressleiste ist keine zweite Wahrheit. Hosting-Kunden dürfen auf#stundennicht ins Leere laufen. Tests: Task 16. - 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).
- 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.
- 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ürzeigeWebsitemitroute.etage === 'projekt'(Wurzel#etage-body). Die ID liest die Ansicht ausprojekt=, hilfsweise ausid=. - 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.reiterentscheidet. Die Ansicht liestlocation.hashnicht. Fehltroute.reiter(Bereichs-Einstieg über#vertraegeoder die Leiste), gilt Rechnungen bei offener Rechnung, sonst Abo. Das setzt voraus, dass P1routeAusHash('#vertraege')ohnereiterliefert (VF8). Reiterklicks navigieren mit den ausdrücklichen Hashes#rechnungen,#abo,#stunden,#speicher. - Wechsel mit gemerkter Funktion (E14, C5): Hat
ansicht-wechsel.jseine Funktion gesendet und liefert der Server danachsubscription.wechselFunktion, geht die Ansicht mitersetzenzu#funktionen, nicht zu#vertraege. Dort öffnet P3 (Task 6) den Einschalt-Ablauf. Dasselbe gilt für die Rückkehr mit?abo=successin 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 })undApiFehleraus./api.js,geheZu(hash, { ersetzen })aus./router.js,hashFuerAnsicht(name)aus./routen.js(alle P1),safeUrlaus../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 lautserver/routes/abo.js:88-214,routes/customerData.js:18-100mitservices/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); & < > " ' 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 <b>Welt</b> & 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 derbalance. -
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 ausapp.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. FehlendesmatchMediazählt alsfalse. -
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 ÄnderungHH:MMin Berliner Zeit. „Arbeiten am Projekt“ erscheint wieapp.js:400: wennorderda ist oderworkfehlt. 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,
ResizeObservernur wenn vorhanden) wieapp.js:98-112als kleine interne FunktionskaliereVorschau(rahmen). Aktivität lädt parallel zu den Projekten, jede Quelle mit eigenem Fehlerweg. Fehltprojekt=undid=, gilt das erste Projekt. Späte Antworten über einen Zähler je Aufruf verwerfen. FehlendesmatchMediazä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 vonpublic/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()setztglobalThis.ChatComposer:createmerktopts, hängt eintextareainopts.rootund liefert{ destroy, focus },renderMessagebautdiv.chat-bubble[data-id]mitmeta + ' ' + text;mockkommt ausnode: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-chatmit Verlauf#kn-chat-messagesund Composer-Wurzel, ohne Überschrift.ChatComposer.trackScrollauf den Verlauf. Die Vorgabe setztvalue, schickt eininput-Ereignis (nur wennEventexistiert) und fokussiert;setSelectionRangenur, 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:- 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).
: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.font-weight: 800in.chat-file-extwird700.- Die beiden
backdrop-filter-Deklarationen in.chat-overlayentfallen (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 Zeilecss.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.cssfällt damit als unbekannter Eintrag durchdeepEqualund 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) undtest/knAnsichtQuelltext.test.jsmit demselben Befehlsmuster. Expected: PASS, 7 Tests inknAnsichtChat, 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
zeigeRechnungeninitAuftraege({ 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
zeigeAboselbst, nur bei vorhandenem Abo, nach dem Rendern.Zahlungsart aktualisierenruftPOST /api/abo/portal, setzt bei sicherer Adressewindow.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.ledgerausGET /api/abo(hourLedger.js:77-129, ab P6 zusätzlichvorschussMinutesund je Eintrageinrichtung,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 (
vorschussMinutesfehlt vor P6) zählen als 0. Gekündigt heißtcancelAtPeriodEnd || 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 TestsichtbarerText,ladeAlteApp.POST /api/abo/storage/preview {units},POST /api/abo/storage {units, acceptTerms?, termsVersion?}(routes/abo.js:302-352). Text und Logik 1:1 ausapp.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ägtdata-panel="speicher". Struktur nur überh(),<a>mittarget="_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/checkoutmit Body{ productKey, acceptTerms, startChoice, termsVersion: daten.termsVersion, offerId: offer?.id || '', lateStartAt: daten.lateStartAt || '' }→{ url }. Text und Logik 1:1 ausapp.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-1032Zeichen für Zeichen, Reihenfolge der Elemente unverändert. Die Idsabo-terms,abo-order,abo-start-summary,abo-due-summary,abo-buy,abo-errorbleiben. Nach Fehlern wird der Knopf wieder frei und trägtZahlungspflichtig bestellen. Während des AufrufsWeiter zur Zahlung…. Weiterleitung nur mitsafeUrl. -
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 ausapp.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
gesendetund liefertwechselFunktionAus(antwort.subscription)etwas, geht die Ansicht mitnavigiere('#funktionen', { ersetzen: true })zu den Funktionen. P3 (Task 6, Test „wechselFunktion“) öffnet dort den Einschalt-Ablauf dieser Funktion. Es gibt keinmerkeHinweis, sonst erschiene der Satz später zufällig in Verträge. - Fehlt das Feld in der POST-Antwort (
wechselFunktionsetzt laut P6 sicher nurGET /api/abo), lädt die Ansicht genau einmalladeAbo()und liestwechselFunktionAus(daten?.subscription). Ohne gemerkte Funktion (gesendetfalsch) 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>)undnavigiere('#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-1187wörtlich, Idsswitch-order,switch-terms,switch-early,switch-buy,switch-errorbleiben. Die Vorschau lädt bei jedem Aufruf neu (das ersetzt die 5-Minuten-Regel vonapp.js:1195). Beim Neuzeichnen bleiben angehakte Felder und ein sichtbarer Fehler erhalten. Das Ziel nach Erfolg ohnepaymentUrlfolgt der Beschreibung oben (#funktionenmitersetzennur mit gemerkter und zurückgekommener Funktion, sonst#vertraegemit 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 wieapp.js:1701-1721),POST /api/abo/takeover-credits/:id/reply{ message, files }. Text und Logik 1:1 ausapp.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 Klassehidden, Liste). Der BlockTAKEOVER_CONDITIONSwird mit der Kommentarzeile darüber ausapp.js:1577-1584übernommen. Fehlertexte, Statuspillen und Hinweise wörtlich ausapp.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, Fehlercodeswrong_password,current_missing,new_invalid). Regeln und Texte ausapp.js:1820-1925unddashboard.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,baueReiteraus./reiter.js(P1 Task 5:baueReiter({ eintraege: [{ key, label }], aktiv, art = 'unterstrich', ariaLabel, bei(key) }) → HTMLElementmitbutton[role=tab],aria-selected, MethodesetzeAktiv(key)),ladeAbo,wechselFunktionAus,navigiere,nimmHinweis,ABO_NICHT_GELADEN,offeneRechnungen(Task 3);zeigeRechnungen(8),zeigeAbo(9),zeigeStunden(10),zeigeSpeicher(11);routeAusHashaus./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
baueReiterlesen
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,emailrufenzeigeRechnungen(mitantwort),zeigeAbo,zeigeStunden,zeigeSpeicher(mitdaten) mit den schon geladenen Antworten. Die Reiterwahl liestlocation.hashnirgends. Erlaubt sind nurlocation.search(abo=) und das Zusammensetzen vonlocation.pathname + location.hashbeim 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 Skriptepublic/app.js(initLoginPage),legal-form.js,auftrag-widerruf.js,auftrag.jsmitauftrag-dom.jsundauftrag-ansicht.js(unverändert wiederverwendet, VF6);public/neu/tokens.css(P1);public/auftrag.css(nur gelesen, als Vorlage derab-*-Regeln). Das Werkzeug aus P0 liefert/login.html,/kuendigen.html,/widerrufen.htmlund (Task 19 ergänzt den Eintrag)/auftrag.htmlaus 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.cssund/neu/oeffentlich/oeffentlich.cssstatt/login.css,/legal-form.cssund/auftrag.css.offline.htmlbleibt vollständig inline (Service Worker,sw.js:5), mit den Werten der Global Constraints als Literale. Keine neuenids, keine Verläufe, kein Lila, kein Dunkelmodus. Pflicht-Metazeile<meta name="theme-color" content="#ffffff">(genau eine), auf der Login-Seite zusätzlichapple-mobile-web-app-status-bar-stylemit Inhaltdefault. 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.htmlbehält<body class="ab-seite">, die Idsab-wurzelundab-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">. Stattcolor-scheme: light darkund zweitheme-color-Zeilen steht<meta name="color-scheme" content="light">und die eine weißetheme-color.oeffentlich.cssenthält dafür einen Abschnittbody.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 ausauftrag.cssab dem Abschnitt „Grundlayout“ bis vor „Portal (Reiter Rechnungen, dunkel)“ sowie.ab-app-backauf 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 inansichten.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-formularusw.). 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 alleab-*-Klassen und die Idsab-wurzelundab-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 wielegal-form.css:29, installierte App wie:30-37mit weißem statt dunklem Streifen hinter der Statusleiste. Der Abschnittbody.ab-seitefolgt der Beschreibung der Auftragsseite oben. Der Lauf in Step 4 zeigt jedeab-*-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.cssund Bausteine ausbasis.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-pillemit-grau|-blau|-orange|-violett|-gruen|-rot), die Klassen ausKund allekn-*-Klassen der P2-Module. -
Produces: eine Datei, die
dashboard.htmlnachbasis.css,huelle.cssund/neu/chat-composer.css(der bereinigten Kopie aus Task 7) lädt. Abschnitte:Website(Projektzeile mit Frame 16:10, Fakten, Zeitleiste, Details),Chat(.kn-chatnach dem Muster.woms-chataus/home/webklar/apps/ticket.webklar.com/build/src/styles/chat.css:12-110, Selektoren strikt spezifischer alsneu/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-portalfü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-topHaarlinie), 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, Zahlentabular-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 übervar(--kn-…), fehlende Werte als--kn-…-Deklaration oben inansichten.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 inSEITEN:{ 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ätenhandy,handy-quer,tablet,desktop;GATES.p<N> = { checks, ansichten }mitALWAYS_ONund den Prüfungendark-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 zustep-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 pinntFALL_NAMEN). Sonderdaten (offene Aufträge, Rückfrage, Zahlungsproblem, Speicher mit Änderung) setzt der Step mitseite.routeauf die jeweilige/api/…-Antwort und lädt dann neu. Unbekannte/api/*meldenunknown-api, deshalb stehen die drei neuen Endpunkte infixtures/endpunkte-p2.mjs:POST /api/abo/portal→{ url: '/vorschau-hinweis.html' },POST|DELETE /api/chat/attachments(Anhänge des Composers) und der öffentlicheGET /api/auftrag/*→{ auftrag }mit einem offenen Auftrag in der Form vonoeffentlicheAnsicht(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 invertrag-endpunkte.mjs(NICHT_PRUEFBARmit Grund, falls der echte Router nicht über die Testnachbauten läuft; der Auftrags-Router hängt am SchalterAUFTRAEGE_ENABLEDund 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 Ansichtengeraete: ['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.mdund inviews.mjs). Jedesteps-Prüfung wirft bei Abweichung. Danachfree -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 }), nutzth,apiund 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 mitwithdrawalOpen, Zähler-Meldung überkn:zaehler-neu. - V8: Vorschuss wird gezeigt, wenn
ledger.vorschussMinutes > 0, nie als Mehraufwand (Task 10). - V11:
ansicht-wechsel.jssendetfunktionaussessionStorage['kn-wechsel-funktion']und löscht den Schlüssel nach dem POST (Task 13). Kommt danachsubscription.wechselFunktionzurück (aus der POST-Antwort, sonst aus einem Nachladen), geht die Ansicht mitersetzenzu#funktionen, ohne gemerkte Funktion zu#vertraege; dasselbe tutansicht-vertraege.jsbei?abo=success(Task 13 und 16). Das Öffnen des Einschalt-Ablaufs dort testet P3 (Task 6, „wechselFunktion“), P2 prüft nur das Ziel. - Reiterwahl:
waehleReiterundzeigeVertraegelesenlocation.hashnicht, alleinroute.reiterzählt; die Vorbedingung (VF8) ist grün (Task 16). - Kein Lila: Die neue Oberfläche lädt weder
/chat-composer.cssnoch/auftrag.css,/login.cssoder/legal-form.css(Task 7, 17); der Lila-Test aus Task 1 ist grün, das Gatep2bleibt in der Prüfunglilaleer. - Auftragsbestätigung:
neu/oeffentlich/auftrag.htmlträgt noindex und no-referrer und den Text der alten Seite (Task 17); P10 muss/auftrag.htmlinUMSCHALT_SEITENund in die Nachprüf-Marker aufnehmen (VF6). - V12 (nur 1:1): Bestellen, Wechsel, Speicher, Gutschrift tragen den Wortlaut der Fassung 5 und senden
termsVersionausGET /api/abo(Task 11 bis 14). - Zuständigkeitstabelle: nur die Dateien aus der Dateiübersicht angefasst (Step 4).
- V2: Jede Ansicht exportiert
-
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 inpublic/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 amdocument(Test:b.doc.dispatchEvent), P1 VF6 nenntwindow. P2 sendet amdocumentmitbubbles: true(erreicht beide). Werte indetail.was:'chat','vertraege','alle'. Bitte P1 vereinheitlichen. - VF3 Rechnungszeile. P2 liefert
rechnung-offen.jsund zeichnet die Zeile auf der Website. Als Quelle nutzt die Website einen eigenen Abruf vonGET /api/invoices. P3 (und P1 VF6) erwartenzaehler.stand().rechnungen; P1 Task 7 liefert instand()aber nurchatundvertraege. Vorschlag: P1 ergänztrechnungeninstand(), 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-projectder 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) undansicht-gutschrift.js(Zustimmungsblock), ersetzt dort die Wortlaut-Orakel-Tests durch Pins auf die Texte der Fassung 6 und zieht Hash (legalTexts.test.js:18) undconfig.takeover.conditionsVersiongemeinsam nach. Das Orakel (test/support/knWortlautOrakel.js) liestapp.js, P10 muss es vor dem Löschen vonapp.jseinfrieren oder entfernen. - VF6 Öffentliche Seiten und P10. Die neue Login-Seite nutzt
initLoginPage()auspublic/app.js(unverändert), die Rechtsformularelegal-form.jsundauftrag-widerruf.js. P10 mussinitLoginPagesamt Helfern (app.js:421-568) vor dem Löschen vonapp.jsnachpublic/neu/oeffentlich/verlegen und beim Ausliefernsw.js(OFFLINE_URL, heute/offline.html) mitziehen. Der Installationshinweis (P2) und „App installieren“ im Konto (P1) hören beide aufbeforeinstallprompt, das ist unkritisch, P10 darf es zusammenlegen. Auftragsseite:neu/oeffentlich/auftrag.html(Task 17) lädt/auftrag.jsmitauftrag-dom.jsundauftrag-ansicht.jsunverändert. P10 nimmt/auftrag.htmlinUMSCHALT_SEITENund in die Nachprüf-Marker auf (Marker:/neu/oeffentlich/oeffentlich.cssim Quelltext, kein/auftrag.css). Danach braucht keine ausgelieferte Seite mehrauftrag.css, P10 kann sie aus „Bleiben“ nehmen, sobald das alte Dashboard (.ab-portal) fällt. Mehrere P2-Tests lesen alte Dateien (app.jsim Orakel, die alten Seiten inknAnsichtOeffentlich,auftrag.cssundchat-composer.cssinknAnsichtOeffentlichundknAnsichtChat); P10 muss sie vor dem Löschen einfrieren oder entfernen. Der Vergleich mitchat-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, öffentlicherGET /api/auftrag/*) und ein Gatep2; neue Fälle legt P2 nicht an, weil P0FALL_NAMENpinnt. Für die Auftragsseite brauchtvorschau/seiten.mjs(P0) einen Eintrag inSEITEN(auftrag.htmlmit Quellenneu/oeffentlich/auftrag.html,auftrag.html); Task 19 trägt ihn ein, P0 kann ihn gleich aufnehmen. Fehlttmp/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.reitertrennen, die Adressleiste taugt nicht: P1 schreibt#abound#wartungkanonisch zu#vertraegeum. Dafür mussrouteAusHash('#vertraege')eine Route ohnereiterliefern,#abound#wartungreiter: 'abo', undhashFuerRoutedarfreiter: 'abo'nicht zu#vertraegezusammenfallen lassen (#abobleibt#abo). Die Übersicht V2 nennt heutevertraege,abo,wartung→reiter: 'abo', und P1 Task 3 pinnt das inFAELLE. Beides ist anzupassen (die Standard-Reiter fürankerKetteundelternVonbleiben Sache von P1). Ohne die Änderung zeigt der Test „Vorbedingung (VF8)“ in Task 16 es sofort an,waehleReiterspringt dann nie (sicher, aber ohne Sprung), und das Gatesprungscheitert. - VF9 Wechsel mit Funktion (P2, P3, P6). P2 (Task 13, 16) navigiert mit
ersetzenzu#funktionen, P3 Task 6 öffnet dort den Ablauf. P6 setztwechselFunktionlaut Plan nur inGET /api/abo, nicht sicher in der Antwort vonPOST /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 bautansicht-wechsel.jsspäter um und muss das Ziel nach Erfolg (#funktionenbei 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=successkommt nur vom Abschluss (stripeBilling.js:637). Wer nach der Zahlung zurückkehrt, ohne#funktionenzu öffnen, wird erst beim nächsten Aufruf der Funktionen weitergeleitet (längstens 14 Tage). - VF10 Composer-CSS (P1, P2). Das alte
chat-composer.cssenthält Lila und kann nicht in die neue Seite (P1 VF3). P2 legt die lilafreie Kopiepublic/neu/chat-composer.cssan (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.