diff --git a/docs/superpowers/plans/2026-10-16-kundenansicht-00-uebersicht.md b/docs/superpowers/plans/2026-10-16-kundenansicht-00-uebersicht.md index 1913d1e..a98de0f 100644 --- a/docs/superpowers/plans/2026-10-16-kundenansicht-00-uebersicht.md +++ b/docs/superpowers/plans/2026-10-16-kundenansicht-00-uebersicht.md @@ -122,7 +122,7 @@ Bestehende Testdateien ändert ein Paket nur dort, wo sein Plan sie ausdrücklic - Vor jedem schweren Lauf `free -m`. Bei weniger als 2000 MB verfügbar warten und den Controller melden. - **Tests WOMS:** `cd /home/webklar/apps/ticket.webklar.com/dev-kundenart && timeout 900 nice -n 10 systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0 env NODE_OPTIONS=--max-old-space-size=1024 npm test`. - **Playwright** nur mit dem Werkzeug aus P0 (`run-browser.sh`, eine Instanz, Speicherbremse). `chromium-1234`, `playwright-core` 1.62.1. -- **Commits:** Dateien einzeln hinzufügen. Nachrichten deutsch, mit Zeile `Co-Authored-By: Claude Opus 5.5 `. +- **Commits:** Dateien einzeln hinzufügen. Nachrichten deutsch, mit der Zeile `Co-Authored-By: …` aus dem eigenen Systemhinweis der ausführenden Sitzung (kein fester Modellname, Nachtrag N50). - Kein Push, kein `--force`, kein Merge auf `main` oder `test`. Pushen tut nur der Inhaber. - Keine Änderungen in `build/`, weder im Portal noch im WOMS. - **Sicherheitsblock** in jedem Agent-Auftrag (Memory „feedback-destruktive-skripte“): @@ -143,6 +143,180 @@ Bestehende Testdateien ändert ein Paket nur dort, wo sein Plan sie ausdrücklic --- +## Nachträge nach der Planrunde (verbindlich, 16.10.2026) + +Die Paket-Pläne wurden parallel geschrieben; ihre Vertragsfragen sind hier entschieden. Diese Nachträge gehen V1–V13 und der Zuständigkeitstabelle vor. Wo ein Paket-Plan davon abweicht, gilt dieser Abschnitt. + +Kürzel in den Fundstellen: `Task n` und `VFn` = Task bzw. Vertragsfrage n im genannten Paket-Plan (P0 bis P10), `Fn` = Frage n der Planrunde (`/root/reports/kundenansicht-neu-2026-10-15/planrunde.json`, Feld `vertragsfragen`). + +### Schalter und Sitzung + +- **N1 Gültige Sitzung (V1).** Kunde heißt `customerId` und `appwriteUserId` ohne `role: 'admin'`; Kundenansicht heißt `viewAs === true` mit `viewId`. Alte Mitarbeiter-Sitzungen (`role: 'admin'` ohne `viewId`) sind in jedem Modus `false`, auch bei `*`. Eine `email`, die kein Text ist, zählt als leer. + (P0 VF3; F12) +- **N2 Schalter für Jobs ohne Cookie (V1).** `schalter.js` bekommt den vierten Export `export function neueAnsichtFuerKunde({ customerId, email }) // boolean`. Gleiche Modus- und Listenregeln wie `neueAnsichtFuerSession` (`off` false, `on` true, `pilot` bei `*` oder getrimmter, klein geschriebener `email` in der Liste), aber ohne Sitzungsprüfung und ohne Mitarbeiter-Regel; leere oder nicht-textliche `customerId` und Nicht-Objekte ergeben `false`. P5 `aboModellFuerKunde(customerId)` liest das Kundendokument und ruft sie; `runAboCheck` und P6 (Team-Aktivierung, Widerruf des Wechsels, Tagesjob) nutzen nur sie, nie `neueAnsichtFuerSession`. + (P0 Task 2, VF10; P5 Task 13; F12) +- **N3 Weiche (V1).** `server/services/kundenansicht/dashboardWeiche.js` ist eine Fabrik (`server/index.js` startet beim Import den Server). Der Direktaufruf `/neu/dashboard.html` wird für Sitzungen ohne neue Ansicht per 302 auf `/dashboard.html` geleitet; Module und CSS unter `/neu/` bleiben frei abrufbar. Bei Modus `on` liefert die Weiche von P10 (`UMSCHALT_SEITEN`, Zeile `/dashboard.html`) die neue Seite auch ohne Sitzung; `neueAnsichtFuerSession` bleibt unverändert. + (P0 VF1, VF2; P10 Task 3, VF7; F12) +- **N4 `/api/auth/me`.** `ansicht` steht nur in den beiden `authenticated: true`-Antworten von `GET /me`. P5 ändert in `auth.js` allein `sanitizeCustomer` (`kundenart`, `kundenartWirksam`); `test/kundenansichtMe.test.js` pinnt nur die Schlüssel der obersten Ebene, damit `customer` wachsen darf. + (P0 Task 4, VF4; P5 Task 2; F13) +- **N5 Platzhalter-Marker.** P0 Task 3 setzt im Platzhalter beide Marker (`` und `data-kn-platzhalter`) und pinnt in `istNeu` nur die Meta-Zeile. P1 Task 12 ersetzt die Seite, ohne einen P0-Test zu ändern; die Werkzeug-Ansichten `platzhalter` und `platzhalter-abgemeldet` bleiben, bis P1 sie ersetzt. + (P1 VF1, Task 12/13; P0 Task 3; F11) + +### Hülle, Routen, Reiter, Zähler + +- **N6 Routen (V2, „Pflichtinhalt von ROUTEN“).** Die Zeile „`vertraege`, `abo`, `wartung` → vertraege, reiter `abo`“ entfällt. Neu: `vertraege` → Bereich `vertraege` ohne `reiter` (die Ansicht wählt den Standardreiter, bei offener Rechnung `rechnungen`, sonst `abo`); `abo` und `wartung` → Bereich `vertraege`, reiter `abo`, kanonischer Hash `#abo` (`#wartung` wird `#abo`, `#vertraege` bleibt `#vertraege`); `?abo=success|cancel` → `#abo`. `waehleReiter` (P2) entscheidet nur über `route.reiter`; Reiterklicks navigieren mit `ersetzen: true` zu `#abo`, `#rechnungen`, `#stunden`, `#speicher`, und `ankerKette`/`elternVon` liefern weiter `#vertraege` als Bereichs-Anker. + (P1 Task 3/4, VF4; P2 Task 16, VF8; F17) +- **N7 Routenfelder und Exporte (V2).** Route-Felder zusätzlich `produkt` (`'hosting' | 'wartung'`) und `scroll` (`'aktivitaet'`); `query` ist immer ein String. Zusätzliche Exporte von `routen.js`: `hashFuerAnsicht(name)` (die `data-view`-Brücke der gemeinsamen Module), `startHash`, `ankerKette`, `elternVon`, `titelFuer`, `FUNKTIONS_ETAGEN`. Die Projekt-ID heißt einheitlich `#projekt?projekt=` (Lesen von `id=` bleibt Rückfall); ein `data-project` an einem `[data-view]`-Element wird zu `?projekt=`. + (P1 Task 3, VF4; P2 VF4; F17) +- **N8 Kleinere Festlegungen der Hülle (V2).** `api(…, { roh })` meint einen Rohdaten-Body (`application/octet-stream`) wie `fn-util.js request({ raw })`, nicht eine rohe Antwort; der V2-Kommentar ist so zu lesen. `?abo=` bleibt in `location.search`; P2 räumt mit `history.replaceState(history.state, '', location.pathname + location.hash)` auf, und P4 ändert die `history.replaceState`-Aufrufe der Module nicht (`stempeln()` aus P1 stempelt danach neu). `public/neu/manifest.webmanifest` hat dieselbe `id`, `start_url` und `scope` wie die alte; `sw.js` bleibt gemeinsam und wird in P1 Task 11 registriert. Ansichten rendern keine eigene `h1`; der Titel kommt aus `titelFuer(route)` oder `kn:titel` (P7: `meldeTitel`). + (P1 VF7 bis VF11; P7 Task 10/11/13; F20) +- **N9 Reiter-Baustein und Meldungen (V2, Hülle).** `baueReiter({ eintraege: [{ key, label, kurz? }], aktiv, art, ariaLabel, bei(key) })` in `public/neu/reiter.js` liefert direkt ein `HTMLElement` mit `setzeAktiv`, `setzeAnzeige`, `messen`; P2 und P3 passen sich an. Inhalte in Etage 2 melden Reiter und Titel ohne Import per `CustomEvent` mit `bubbles: true`: `kn:reiter` (`detail { eintraege, aktiv, bei }`, leere Liste = keine Reiter) und `kn:titel` (`detail { text }`). + (P1 Task 5, VF5; P2 Task 16; P3 Task 5; F18) +- **N10 Reiter der Funktionsmodule in Etage 2.** Terminplaner, Webshop, Buchungskalender und Analyse zeichnen im Abo-Modell keine eigene Leiste, sondern melden `kn:reiter` (die Analyse aus `mountCockpit` mit `bei: pickTab`: Übersicht, Heatmap, Einstellungen). Die Ausnahme „außer analyse“ im Gate `p4` entfällt; der Satz „die Analyse behält ihre eigene Leiste“ in F18 ist überholt. Kurzlabels stehen in `KURZLABELS` (`public/fn-reiter.js`: Team, Start, Recht, Preise), die Vorschau prüft sie (C2). + (P4 Task 1/8/15, VF2; P1 VF5; F4, F18) +- **N11 Zähler-Ereignis (V3).** `kn:zaehler-neu` wird am `document` gesendet (`bubbles: true`), `detail.was` ist `'chat' | 'vertraege' | 'alle'`. `zaehler.stand()` liefert nur `{ chat, vertraege }`; die Hülle setzt den Chat-Zähler beim Öffnen des Chats auf 0. + (P1 Task 7/11, VF6; P2 VF2; F19) +- **N12 Zeile „Rechnung offen“.** P1 zeichnet sie nicht und reicht kein `zaehler` an die Ansichten; `zeigeX(wurzel, { route, me, nav })` bleibt wie in V2. P2 liefert `public/neu/rechnung-offen.js` (`offeneRechnungen`, `rechnungOffenZeile`, `ladeOffeneRechnungen`, optional `ladeOffeneRechnungen(apiFn = api)`); P3 `zeigeFunktionen` lädt `GET /api/invoices` selbst über `abh.api` und zeichnet die Zeile in Meine und Entdecken. Fällt der Abruf aus, fehlt nur die Zeile. + (P3 Task 5, VF5; P2 Task 3, VF3; P1 VF6; F1, F19) +- **N13 Option `einschalten` für die Funktionsmodule (V2, Absatz „Funktionsmodule“).** Die Hülle ruft `initNews`, `initTermine`, `initShop`, `initBuchungen`, `initZeiten` als `init*(wurzel, { einschalten })` mit `einschalten(platz, { key, projectId, onFertig, viewAs? }) → Promise`, das lazy `zeigeEinschalten` aus `public/neu/funktion-einschalten.js` (P3) lädt (`MIT_EINSCHALTEN`, `einschaltenOption(lade)` in `huelle.js`); `initAnalyse(wurzel)` bleibt unverändert, die Projektwahl bleibt `wurzel.dataset.wunschProjekt`. `test/knHuelleStart.test.js` („Option einschalten“) pinnt die Verdrahtung; P4 Task 15 ersetzt seine Zeile `command grep -a -n einschalten public/neu/huelle.js` durch den Lauf dieses Tests. + (P1 Task 11, VF12; P3 VF1; P4 VF1; F1, F21) +- **N14 Overlay.** Ansichten dürfen `oeffneOverlay` und `beendeOverlay` aus `router.js` rufen (P3 für die Bestätigungsseite). Es ist nie mehr als ein Overlay offen; P3 und das Konto-Sheet schließen vor dem Öffnen ein offenes Overlay. + (P3 Task 6, VF5; P1 R6; F22) +- **N15 Chat-Composer.** Die neue Seite lädt `/neu/chat-composer.css`, eine lilafreie Kopie von `public/chat-composer.css` (Akzent `#111`/Grau, weder `#9d3dfe` noch Gewicht 800 noch `backdrop-filter`); `/chat-composer.js` bleibt gemeinsam, das alte CSS unverändert. P2 Task 7 legt die Kopie an und ändert dafür genau die zwei Composer-Zeilen in `dashboard.html`; `test/knHuelleSeite.test.js` akzeptiert `/neu/chat-composer.css` statt `/chat-composer.css`. + (P1 VF3; P2 Task 7, VF10; F23) +- **N16 Analyse-Bild und Alt-Klassen.** `public/neu/bilder/analyse.webp` erzeugt P4 über die Werkzeug-Ansicht `kn-modul-analyse` (Fall `m-analyse-aktiv`); bis dahin entfernt der `error`-Handler in „Entdecken“ das fehlende Bild. `public/neu/module.css` Abschnitt 0 (P4) definiert `kb-panel`, `kb-btn*`, `kb-empty`, `kb-link`, `kb-checks`, `abo-actions`, `abo-btn`, `hidden`, `muted`, `error`, außer eine Datei unter `public/neu/` tut es schon; P2 und P3 nutzen eigene `kn-*`-Klassen. + (P3 VF2; P4 Task 9/14, VF3, VF5; F24) + +### Katalog, Einschalten, Analyse + +- **N17 Katalogfelder (V5).** Jeder `katalog[]`-Eintrag trägt `consent: { version, knopf: 'Einschalten', items: [{ id, text, haken, nurPrivatInFrist? }] }` (`null` bei `analyse`) in der Reihenfolge `einschalten-zeile`, `hinweis`, `beginn` und dazu `hinweis: string | null`, den Text des Items `hinweis` (nur `shop`, `unterkunft`, `termine`: die Pflichten der alten Häkchen `verantwortung`, `stripe`, `gesundheit`). Der Server liefert beides, nie der Client; der Nachweis speichert den Hinweis als Text `hinweis` (`haken: false`). P3 `renderEinschalten` zeichnet `p.kn-fn-hinweis` direkt über dem Knopf (nach der Linkzeile, vor `beginn`), die P0-Fixtures und `vertragNeu` tragen beide Felder. + (P5 Task 6/10/11, VF2; P3 Task 3; P0 Task 6/7, VF11; P8 VF3; F3, F27) +- **N18 `anfrageStatus` (V5).** Der Pfad ist `projects[].features..anfrageStatus` (`'' | 'offen' | 'angenommen' | 'abgelehnt' | 'zurueckgezogen'`), gebaut von `anfrageStatusJeProjekt` in `server/services/aboKatalog.js`; P3 liest ihn nur über `anfrageStatusVon(projekt, key)`. + (P0 VF9; P5 Task 11; P3 Task 1; F16) +- **N19 Antwortcodes der Einschalt-Route (V5).** Die Prüfreihenfolge nach P5 Task 10: 400 `zustimmung_fehlt`; Projektprüfung (403, 409 archiviert); dann 409 `nicht_verfuegbar` (danach 503 `unterlagen_fehlen`), `abo_fehlt`, `nur_betrieb`, `bestellung_offen`, `beginn_noetig`, `bereits_eingeschaltet`, `bestellung_laeuft` (Zustand `ordered`), `abgeschaltet` (Zustand `disabled`), `fassung_veraltet`. 503 kommt zusätzlich bei nicht lesbarem Abo oder gescheitertem Ticket; P3 zeigt den Text des Servers. + (P5 Task 10, VF3; P3 VF4; F27) +- **N20 Probezeit (V5).** `aboDeckt` kennt zusätzlich den Grund `beginnt_spaeter` mit `ab` (ISO, = `trialEnd`) für `trialing` mit offener Probezeit (Privatkunde ohne Sofortbeginn-Häkchen). `abo_fehlt` trägt dann `body.ab`; der Text lautet „Dein Abo beginnt am TT.MM.JJJJ. Ab dann kannst du einschalten.“, und P3 (`TEXTE`, `fehlerAktion`) zeigt Text und Datum im Knopf. P6 (`adminActivate`) und P9 kennen den Grund; die Analyse-Eignung meldet in der Probezeit weiter `kein_abo`. + (P5 Task 4/9/10, VF9; P3 VF4; F8) +- **N21 Abrechnung am Funktionsdokument (V7).** Das Attribut `portalFeatures.einrichtungAbrechnung` (string 8, `'abo' | 'projekt' | ''`, angelegt in `scripts/kundenansicht-setup.mjs`) hält die Abrechnung am Dokument, damit `ticketNachlegen` und `holeZeitenNach` den Marker `workorders.einrichtungAbrechnung` erneut setzen. P6 nutzt es für `ticketNachlegen` (Dokumente mit `unlockedBy` `abo` oder `projekt` ohne Stripe-Sitzung heilen über `einschalten`) und für die Ticket-Zeilen in `termine/ticketDetails.js`, `shop/ticketDetails.js`, `unterkunft/ticketDetails.js`; K27 bleibt die Einschalt-Mail der Zeiten. + (P5 Task 1, VF5; P6 VF3, VF4; F27) +- **N22 Analyse im Abo-Modell.** In der neuen Ansicht verlangt `POST /api/funktionen/analyse/activate` `audienceConfirmed === true` (sonst 400 `zielgruppe_fehlt`, du-Text) und schreibt einen Nachweis in `featureConsents`: Linkzeile A1, Zielgruppen-Zeile A2 (`haken: true`), Knopf `Analyse einschalten`, Kundenart und Fassungen; bei einem Fehler wird alles zurückgenommen. P4 sendet `audienceConfirmed: true`, die alte Ansicht bleibt unverändert. + (P5 Task 13, VF8; P4 Task 8, VF4; F29) + +### Kundenart und Push-Reihenfolge + +- **N23 Kundenart-Pflicht beim Anlegen (V4; Entscheidung des Controllers, F2 und F28 widersprachen sich).** `createCustomerWithPortalAccess` verlangt `kundenart` nur bei `neueAnsichtModus() !== 'off'` (sonst 400 `kundenart_fehlt`); bei `off` ändert sich das Anlegen im WOMS und im Portal nicht. `updateCustomerWithPortalAccess` nimmt `kundenart` weiter optional mit gleicher Prüfung. Die unbedingte Pflicht aus F28 gilt nicht. + (P5 Task 3, VF1; P9 VF3; F2, F28) +- **N24 Push-Reihenfolge (Entscheidung des Controllers).** Der Inhaber pusht in dieser Reihenfolge: (1) `fix/akquise-sichtbar`, falls gewünscht; (2) WOMS-Tag `kn-p9-kundenart`; (3) Portal ab P5; (4) WOMS `kn-p9` zusammen mit P6. Vor dem Setzen von `KUNDENANSICHT_NEU=pilot` muss die WOMS-Kundenart (`kn-p9-kundenart`) live sein. Wird `fix/akquise-sichtbar` später gepusht, rebased P9 darauf, nur auf Anweisung des Controllers. Die Übergabe-Notiz nennt die Reihenfolge und vermerkt, dass die Abo-Vorgabe in WOMS zunächst nur für Pilotkunden wirkt (P6 wertet `abrechnung` nur mit `aboModellFuerKunde` aus). + (P5 VF1, VF10; P9 VF3, VF4; F2, F28) + +### Stundenkonto, Team, Mails + +- **N25 Antwortformen des Stundenkontos (V8).** `nextExpiry` ist `{ minutes, at } | null`; bei `produkt: 'hosting'` und ohne Abo ist `ledger` gleich `null`. `workorders.einrichtungFuer` und `einrichtungAbrechnung` sind für Mitarbeiter lesbar (Setup-Skript P5). T24 trägt den Block `vorschuss`, wenn beim Einschalten `balanceMinutes <= 0` gilt oder schon `vorschussMinutes > 0` besteht; eine Schätzung je Funktion gibt es nicht. + (P6 Task 2/4, VF6; P9 VF2; F33) +- **N26 Geltung der Team-Aktivierung (V10).** Das neue Verhalten (`aboDeckt`-Prüfung, `einschalten` mit Ticket) gilt nur, wenn `aboModellFuerKunde(customerId)` wahr ist; sonst wird `abrechnung` im Body ignoriert und alles läuft wie heute, mit `on` (P10) für alle. P9 sendet `abrechnung` immer. Die 409-Antwort bei fehlendem Abo ist flach: `{ error, code: 'abo_fehlt', grund, wechsel }` mit `grund` aus `kein_abo | falsches_abo | gekuendigt | nicht_aktiv | beginnt_spaeter`; WOMS zeigt eigene Texte, nie den Kundentext in „du“-Form. Erfolg wie bisher `projects[0].features[key]` mit `state: 'setup'` und `ticketWoid`. + (P6 Task 7, VF2; P9 VF2; F32) +- **N27 Abo-Modell-Dokument (V10, V11).** Ein Dokument gehört zum Abo-Modell, wenn `unlockedBy` in `abo | projekt` steht und `einrichtungAbrechnung` in `abo | projekt` (`istAboModell` in `server/services/aboModell.js`); die Gratis-Zeiten der alten Ansicht (`unlockedBy: 'abo'` ohne Marker) bleiben unberührt. V11 ändert sich: Der Widerruf des Wechsels beendet alle Abo-Modell-Dokumente, deren Schlüssel nur in `wartung` steckt, also auch `projekt`-Dokumente; die Analyse eines Hosting-Kunden bleibt. + (P6 Task 5/13, VF4; F32) +- **N28 Team-Einschalten „im Projekt“ ohne Abo (E9/E21; Entscheidung des Controllers, dem Inhaber in der Übergabe zur Bestätigung vorlegen).** Es gilt die Aufbauphasen-Regel von P6 (`server/services/aboAufbauphase.js`, `ohneAboBisher(customerId)`, P6 Task 7, 8, 10). `abrechnung: 'abo'` braucht ein deckendes Abo, sonst 409 `abo_fehlt`. `abrechnung: 'projekt'` braucht es auch, **außer in der Aufbauphase**: Hatte der Kunde noch nie ein Abo (abgebrochene Bestellungen zählen nicht), wird eingeschaltet und mit 200 plus `warnung: { code: 'abo_fehlt', grund: 'kein_abo' }` geantwortet, flach neben `projects`. Das boolesche `warnung: true` für ein fehlendes K16 bleibt für die alte Ansicht. WOMS zeigt die Warnung (P9), der Wortlaut sagt dem Team: „Wartungs-Abo vor dem Livegang vereinbaren“. Der Tagesabgleich lässt `projekt`-Dokumente in der Aufbauphase in Ruhe. Ab dem ersten Abo gilt die Abo-Regel wie für `abo`: Deckt das Abo die Funktion nicht (Hosting, beendet), endet sie mit K39. Hat der Kunde schon ein nicht deckendes Abo, bleibt es auch im Projekt bei 409. + (P6 Task 7/8/10, VF8; P9 Task 10/11; F5) +- **N29 Mailkennungen (V13).** Belegt sind K38 „Deine Funktion wird eingerichtet“ (Einschalten im Abo-Modell, nicht für `zeiten`, dort bleibt K27); K39 „Deine Funktion wurde beendet“ (News, Terminplaner mit Block `termine`, Webshop, Unterkunft; Platzhalter `funktion`, `projekt`, `loeschtag`); K40 und K41 (Zeiten: Abo-Ende mit „läuft noch bis {{frist}}“, abgeschaltet; ohne Preis, ohne Behalten); T24 ans Team mit Block `vorschuss`; K42 (Webshop-Zusage) und K43 (Buchungskalender-Zusage) im Abo-Modell ohne Preiszeile, ohne „€“ und ohne „bestell…“ (Builder `anfrageEntschiedenMail`, Weiche in `sendeEntscheidung` über `aboModellFuerKunde`). K24 und K34 bleiben bytegleich. K44 und T25 bleiben frei; eine Rückkehr-Mail gibt es nicht (die Rückkehr geschieht still, K39 kündigt sie an), außer bei Zeiten im bestehenden Modul. K02 und K03 bekommen den Block `funktionen_abo`; K22, K29, K30 und T16 bleiben bytegleich. Alle neuen Vorlagen in „du“-Form und im Layout von K16. + (P6 Task 3/4/5/15, VF1, VF5; F31) +- **N30 Behalten-Weg der Zeiten.** Für Abo-Modell-Dokumente ist `POST /api/funktionen/zeiten/behalten` gesperrt (409, P6). Die Route und die Mailtexte K29/K30 (Builder `k29Mail`, `k30Mail` auf K40/K41 umgestellt, Vorlagen-Dateien bleiben) räumt P10 Task 20/21 auf. Die WOMS-Texte (`zeitenStatusText`, Bestätigungstexte „Aktivieren (ohne Zahlung)“) ändert ein eigener WOMS-Nachzug nach P10 Task 22 (Controller, Push durch den Inhaber, geführt in der Checkliste Task 6). Alt-Zweige hinter `neueAnsicht(` bleiben als toter Code; ihre Entfernung entscheidet der Inhaber. + (P6 VF7; P9 VF5; P10 VF11, VF12; F34) +- **N31 Abo-Ende-Verhalten (Rechtstexte = Server).** Terminplaner, News, Webshop und Buchungskalender schalten spätestens am Folgetag ab (Tagesjob). Öffnungszeiten live läuft nach dem Abo-Ende 30 Tage unverändert weiter (Zeiten-Modul, K40, P4 Text Z2), danach 30 Tage gespeicherte Eingaben; kein Behalten gegen 30 €. Löschfristen: 30 Tage (Terminplaner, News, Öffnungszeiten), 90 Tage (Webshop, Buchungskalender), 60 Tage (Analyse-Messdaten); ein neues passendes Abo in der Frist holt die Funktion zurück, P6 baut den News-Löschjob. P8 passt Texte und Tests an (R8 „mit P4/P6 abgestimmt“). + (P8 Task 4/7, VF5; P6 Task 8/10; P4 Text Z2; F39) + +### Bestellseiten, Fassung 6, Nachweis + +- **N32 Pfade der Fassung 6 (V12).** `config.abo.termsVersionNeu = '2026-10-16-v6'` (Entwurfstag, bei der Freigabe wird das Datum gesetzt); `FUNKTIONEN_FASSUNG_V6 = '2026-10-16'`. Bis zum Umschalten gelten die Abo-Seite `/neu/rechtliches/abo-bedingungen.html`, das PDF `/neu/rechtliches/WEBklar-Abo-Vertragsunterlagen.pdf` und die Funktionsunterlagen `/neu/rechtliches/-2026-10-16.html|pdf`; `termsDocFor` liefert sie, `/abo-bedingungen-v6.html` aus V12 entfällt. P7-Fixtures und -Tests nutzen `termsDocFor(config.abo.termsVersionNeu).url` und `config.abo.termsVersionNeu` statt Literalen wie `'2026-10-30-v6'`. P10 Task 14 verschiebt Seite und PDF nach `public/` (`/abo-bedingungen.html`), stellt in `FASSUNGEN` v5 auf die `archiv`-Adressen (`public/vertragsunterlagen/v5/`) und v6 auf die Wurzel-Adressen, setzt 301-Weiterleitungen und passt `DOKUMENTE_V6`, den Modus `abo6` und die festen Links an. + (P8 Task 1/5, VF1; P10 VF3; P7 Task 16; F35) +- **N33 Fassung 6 verdrahten: ein Task, zwei Pläne (V5, V12).** P5 Task 15 und P7 Task 0 beschreiben dieselbe Arbeit und laufen einmal, als erste Aufgabe von P7 im Hauptzweig nach dem Merge von `kn/server` und `kn/recht`, mit den Dateien beider Tasks. Quellen sind `WORTLAUT_V6`, `FUNKTIONEN_FASSUNG_V6`, `FUNKTIONEN_UNTERLAGEN_V6` und `mitFassung` aus `server/services/products.js` (P8 liefert, verdrahtet nicht). Verdrahtet werden `config.aboFunktionen.fassung` und `texte.beginn` (Literale, `config.js` importiert keine Dienste), `consentAbo` mit dem Item `hinweis`, `aboKatalog.js` (`unterlagen`, `hinweis`), `aboUnterlagen.js` und `sichereAdresse` in `funktion-einschalten.js` (lässt `/neu/rechtliches/` zu). `bestelltexte.js` bleibt importfrei, `test/knBestelltexte.test.js` pinnt Linkzeile, Häkchen und Fassungsangabe gleich zu `WORTLAUT_V6` (eine einheitliche Form der Fassungsangabe), danach läuft `test/aboWortlautV6.test.js` grün. Die Analyse (`config.analyse.avvVersion`, `legalPaths`, K18-Weiche mit den PDFs aus `public/neu/rechtliches/`) folgt dieser Verdrahtung erst nach der Freigabe; wo P5 Task 15 sie sofort auf Fassung 6 stellt, gilt diese Regel. + (P5 Task 15, VF7, VF10; P7 Task 0/1, VF12; P8 VF2; F3, F29, F30) +- **N34 Vertragsnachweis (V12).** `speichereVertragsNachweis({ customerId, art, bezug = '', texte, fassungen, kundenart, req, at })` schreibt selbst mit `createDocument` und `texteHash` (aus `featureConsents.js`), weil `speichereZustimmung` `projectId = ''` ablehnt; `featureKey = art`, `projectId = ''`. `bezug` und `wortlaut` (`WORTLAUT_STATUS`) stehen als Schlüssel in `fassungen` (höchstens 1000 Zeichen), `texte` bleibt die reine Anzeigereihenfolge. `stripeSessionId` bleibt leer, weil der Nachweis vor der Stripe-Sitzung, dem Wechsel, der Speicheränderung und dem Antrag entsteht (kein Zustand ohne Nachweis); die Zuordnung läuft über Zeit, Konto und `bezug`. + (P7 Task 2, VF3 bis VF5; P8 VF4; F36) +- **N35 Fassung in den Bestellrouten.** P7 übernimmt die Stellen, die `config.abo.termsVersion` fest lesen: `stripeBilling.js:606-607` (`createAboCheckout`: Fassung aus `consent.termsVersion`, Vorgabe unverändert), `abo.js:236`, `:328`, `:388` (Prüfung) und `abo.js:201` (`GET /api/abo` liefert `termsVersion` über `aktuelleAboFassung(req)`). `fassungDerAnfrage(req, termsVersion)` prüft die Neu-Fassung zuerst; bei gleichen Werten von `termsVersion` und `termsVersionNeu` gilt `neueAnsicht(req) ? 'neu' : null`, sodass P10 Task 14 hier nichts ändert. `server/routes/takeoverCredits.js` `POST /` schreibt den Nachweis bei Fassung 6. + (P7 VF2; P8 VF4; P10 VF4; F36, F42) +- **N36 Speicher und Gutschrift (Fassung 6).** P7 übernimmt `ansicht-speicher.js` (Plus-Block) und `ansicht-gutschrift.js` (Zustimmungsblock) wie `ansicht-bestellen.js` und `ansicht-wechsel.js`: P2 baut sie 1:1 (Fassung 5), P7 baut sie auf das schlanke Muster um. Die Orakel-Vergleiche gegen `app.js` werden durch Pins auf die Fassung-6-Texte ersetzt; der Hash-Pin des Blocks `TAKEOVER_CONDITIONS` (`conditionsVersion` 2026-09-30) bleibt. Die Gutschrift-Bedingungen werden die eigene Ansicht `#gutschrift?bedingungen=1` mit unverändertem Rechtstext; `test/support/knWortlautOrakel.js` entfernt P10 Task 17 erst mit `app.js`. + (P2 VF5; P7 VF1, VF8; P10 Task 17; F25) +- **N37 Texte der Bestellseiten.** Fehlertexte der Dienste (`planSwitch.js`, `mailStorage.js`, `stripeBilling.js`, Abo-Routen) bleiben im Quelltext in Sie-Form und werden für Sitzungen mit neuer Ansicht über `server/services/duFehler.js` in du übersetzt; ein Quelltext-Test meldet jeden neuen Sie-Satz ohne Übersetzung. Die Zeilen „Anbieter“ und „Abrechnung“ entfallen aus dem Block der Bestellseiten (der Anbieter steht im Impressum im Konto) und gehen als Kanzlei-Frage in die Rechtsrunde (N46). K01 und K12 behaupten für Betriebe kein „ausdrücklich verlangt“ mehr (Block `sofortverlangen`). Bewusst der Mail-/Textrunde (Spec 5.7) überlassen: Validierungssätze in `takeoverCredits.js`, `custom_text` der Stripe-Zahlungsseite, die übrigen Mails. + (P7 Task 6/14, VF6, VF7, VF11; F37) + +### Öffentliche Seiten und Login + +- **N38 Kündigen und Widerrufen in du.** P7 Task 15 stellt `public/neu/oeffentlich/kuendigen.html` und `widerrufen.html` auf du um; `public/legal-form.js` und `public/auftrag-widerruf.js` zeigen du nur im Zweig `data-anrede="du"` (ohne Attribut unverändert). Die Seiten speichern keinen Wortlaut und keine `termsVersion`, es gibt also keine neue Version (C14). Text-Pins in `test/knOeffentlichDu.test.js` ersetzen für diese zwei Seiten den Paarvergleich aus P2 Task 17 (Feldattribute und Skripte werden weiter verglichen); P10 Task 17 stellt die Sie-Pins in `test/publicForms.test.js:1439-1450`, `test/auftraegeKundenportalWiderruf.test.js:9` und `test/auftraegeAblauf.test.js:288` auf diese du-Sätze um. + (P7 Task 15, VF13; P2 Task 17; F6) +- **N39 Neue öffentliche Seiten im Pilot.** Angemeldete Pilot-Sitzungen (`neueAnsicht(req)`) bekommen `/kuendigen.html` und `/widerrufen.html` schon im Pilot aus `public/neu/oeffentlich/`; ausgeloggte Besucher, der Login (`/login.html`) und `/offline.html` bleiben bis `on` alt. P0 setzt das mit zwei weiteren Zeilen in `dashboardWeiche.js` um (sie liefert nur, wenn die Datei existiert, sonst `next()`); P10 Task 3 übernimmt die Zeilen in `UMSCHALT_SEITEN` und passt den Test „off und pilot liefern die Datei von heute“ für diese zwei Seiten an. + (P10 Task 3; P0 Task 3; P2 Task 17; F7) +- **N40 Login, Service Worker, Auftragsseite.** P10 Task 16 (`public/neu/oeffentlich/login.js`) übernimmt `initLoginPage` samt Helfern (`app.js:421-568`) vor dem Löschen von `app.js`; die neue Hülle registriert `/sw.js` selbst (P1 Task 11), P10 zieht in `sw.js` die `OFFLINE_URL` mit. iOS-Fokuszoom: 16-px-Felder in P1/P2-CSS genügen, ein JS-Schutz nur im Login; der doppelte `beforeinstallprompt`-Hörer (Hinweis P2, Konto P1) darf P10 zusammenlegen. P10 nimmt `/auftrag.html` in `UMSCHALT_SEITEN` und in die Nachprüf-Marker auf (Marker: `/neu/oeffentlich/oeffentlich.css` im Quelltext, kein `/auftrag.css`); P2-Tests, die alte Dateien lesen (`app.js`, alte Seiten, `auftrag.css`, `chat-composer.css`), friert P10 vor dem Löschen ein oder entfernt sie. + (P2 VF6; P10 Task 16/17, VF5; F26) + +### Werkzeug und Vorschau + +- **N41 Werkzeug-Erweiterungen durch P1 bis P4 und P7.** Erlaubt sind nur `views.mjs`, `gates.mjs` (Gate `p`), `fixtures/endpunkte-p.mjs` mit je einem Import in `fixtures/endpunkte.mjs`, `fixtures/faelle-p.mjs` (P4: `fixtures/module.mjs`) mit einem Import in `fixtures/faelle.mjs`, `vertrag-endpunkte.mjs` und der Marker in `test/statik.test.mjs`. Neue Fälle hängen nur in eigenen Dateien hinter den vierzehn Kernfällen; P0 lockert den Pin „Fälle in fester Reihenfolge“ auf `FALL_NAMEN.slice(0, 14)` und den `boot.js`-Test auf diese vierzehn (`boot.js` bekommt `FALL_MUSTER = /^[a-z0-9-]{1,40}$/`, die Leiste zeigt nur Kernfälle). P4 (17 Fälle `m-*`) und P7 (`privat-ohne-abo`, `hosting-privat`) ändern Pin und `boot.js` nicht; ändert ein Paket eine Antwortform, ergänzt es den Vertragseintrag im selben Commit. + (P0 Task 6/11, VF5; P1 VF2; P4 VF5; P7 Task 16, VF10; F14) +- **N42 Strang-Umgebung und Ansicht-IDs.** Die Strang-Umgebung kommt nur über `KN_STRANG_ENV=…/tmp/strang-ui.env`, nie über `KW/strang.env`. Ansicht-IDs tragen das Paketpräfix (`kn-huelle-`, `kn-p2-`, `kn-funktionen-`, `kn-modul-`, `b7-`); wer fremde Ansichten aufruft, nennt die echten IDs (P7: `--only kn-p2-bestellen,kn-p2-bestellen-hosting,kn-p2-wechsel,kn-p2-email,kn-p2-email-plus,kn-p2-gutschrift,kn-p2-gutschrift-frage`). Fixtures für V5, V6 und V8 folgen den Nachträgen oben. Die Ansicht für `/auftrag.html` ergänzt P2 Task 19 mit einem Eintrag in `vorschau/seiten.mjs`. + (P3 VF3; P2 VF7; P7 Task 16; F14) +- **N43 Vorschau-Technik.** `dark-surface` erlaubt genau `#111` (Akzent) für Elemente bis 25 % der Viewport-Fläche; andere dunkle Flächen brauchen `ignorieren` je Ansicht. In der Vorschau hat `public/neu/oeffentlich/` Vorrang vor `public/`; `boot.js` feuert nach dem Laden einmal `DOMContentLoaded` und `load`, Code prüft trotzdem `document.readyState`. Der Arbeitsauftrag an den Inhaber (WOMS „Neues Projekt“, Subdomain `kundenansicht-neu`, Status `ready` oder `deployed`) steht in `KW/UEBERGABE-P0.md` und blockiert Phase B nicht; `KN_ERSTLAUF_FREMDES_OK=ja` nur auf ausdrückliche Anweisung des Controllers. + (P0 Task 10/11/14, VF6 bis VF8; F15) +- **N44 WOMS-Werkzeug.** „Playwright nur mit dem Werkzeug aus P0“ gilt für das Portal; für das WOMS gilt der eigene Strang `werkzeug-kundenart` (P9 Task 1). Ein kleiner Auftrag „WOMS-Werkzeug auf `test` heben“ (Aufträge/Projektkonten, Unterkunft, `arbeitsblaetter/abgerechnet`, Reiter „Abgesagt“) läuft vor Phase E. Beide Werkzeuge halten die Speicherbremse (`free -m` über 2000 MB) und starten nie mehr als einen Browser zugleich. + (P9 Task 1, VF1; F41) + +### Rechtsrunde + +- **N45 Rechtsrunden-Technik.** Die drei fehlenden WOMS-Karten (`leistungsbeschreibung-news`, `leistungsbeschreibung-website-analyse`, `avv-website-analyse`) legt `scripts/rechtliches-hochladen.mjs --karten-anlegen` mit OK des Inhabers vor dem ersten Upload an; `--probe` meldet fehlende als `STOPP: Karte fehlt: `, das Hochladen ohne Karte bricht ab. Die Karte der Analyse-Leistungsbeschreibung bekommt eine abgeleitete ID (`kartenId`, ihr Schlüssel hat 37 Zeichen, erlaubt sind 36), WOMS liest Karten über das Attribut `key`, und die drei Karten werden nicht in `setup-legal.mjs` eingetragen. Die Entwurfsseiten unter `/neu/rechtliches/` gehen mit dem normalen Push live (der Pilot braucht die Links), tragen Entwurfsmarke und `` (P8 Task 5 bis 10 ergänzen) und werden bei der Freigabe ersetzt. + (P8 Task 11/13, VF6, VF7; F40) +- **N46 Entscheidungsliste.** `docs/rechtliches/kundenansicht-entscheidungen.md` führt R1 bis R17 (statt R1 bis R15; `test/kundenansichtEntscheidungen.test.js` entsprechend): R16 ist die Du-Anrede der Seiten Kündigen und Widerrufen (N38), R17 sind die Zeilen „Anbieter“ und „Abrechnung“ im Block der Bestellseiten (N37), je mit Vorschlag, Alternative und Kanzlei-Frage. + (P8 Task 11; P7 VF7; F6, F37) + +### Livegang und P10 + +- **N47 Vor dem Pilot (Ablaufhinweis 4 ergänzt).** Zwei Schritte vor dem Push des Inhabers, beide mit Sicherung und OK des Inhabers: (1) Compose- und `.env`-Zeilen `KUNDENANSICHT_NEU` (Modus `pilot`) und `KUNDENANSICHT_NEU_PILOT_EMAILS` (Pilot-Liste) in `/home/webklar/apps/docker-compose.yml` (zwei Zeilen `- KUNDENANSICHT_NEU=${KUNDENANSICHT_NEU:-off}` und `- KUNDENANSICHT_NEU_PILOT_EMAILS=${KUNDENANSICHT_NEU_PILOT_EMAILS:-}` bei den `ZEITEN_*`-Zeilen) und `/home/webklar/apps/.env`, Sicherung `.bak--kundenansicht`, dann `docker compose up -d --no-deps kundenbereich-server`, nie `preview-portal`; (2) `scripts/kundenansicht-setup.mjs --pruefen`, nach OK des Inhabers ausführen (sonst scheitert jeder Nachweis ohne `featureConsents.kundenart` mit 500). `UEBERGABE-P0` und die Übergabe-Notiz tragen beide Punkte, P10 Task 9 prüft sie nur noch. + (P0 UEBERGABE-P0; P7 VF9; P10 VF2, VF13, Task 9; Befund zur Übersicht; F9, F38) +- **N48 Vor `on`.** P10 Task 1 und 9 prüfen: `WORTLAUT_STATUS` steht auf `'freigegeben'` (E20); die Gleichheitstests `test/knBestelltexte.test.js`, `test/knVerdrahtung.test.js` und `test/aboWortlautV6.test.js` sind grün; `scripts/rechtliches-hochladen.mjs --probe` meldet keine fehlende Karte und `--pruefen` endet mit Exit 0 (alle elf `documentKey` aus `DOKUMENTE_V6` haben eine aktuelle Version mit passender `source`, deshalb prüft Task 9 Step 2 mit `--pruefen` und nicht nur mit `--probe`); `scripts/kundenart-bestand.mjs` (P10 Task 8) und `scripts/kundenansicht-setup.mjs` sind gelaufen. + (P7 VF9; P8 VF9; P10 VF9, VF13; F38) +- **N49 Bestätigungen zu P10.** Die Alt-Zweige der P4-Dateien entfernt P10 Task 21, die Playwright-Skripte der alten Seite löscht Task 17. Zwischen Task 11 und Task 14 liegen sieben Tage Beobachtung; Task 14 ist der Punkt ohne Rückweg per Schalter und braucht ein eigenes OK des Inhabers. Der Rückbau (Task 20) beschränkt sich auf die beiden Kaufrouten und ihre Helfer; Alt-Preise in `config.js` und die Antwortfelder von `GET /api/funktionen` bleiben. + (P10 VF4, VF6, VF8, VF10, VF11; F42) +- **N50 Commit-Zeile (Global Constraints).** Jede ausführende Sitzung nutzt die `Co-Authored-By`-Zeile aus ihrem eigenen Systemhinweis. Commit-Beispiele in den Plänen mit `Claude Opus 5.5` und Formulierungen wie „wörtlich laut Übersicht“ stehen dafür als Platzhalter. + (alle Pläne, Global Constraints „Commits“; P8 Schlussvermerk; F43) + +### Zuständigkeit + +- **N51 Zusätzliche Dateien und geteilte Stellen.** Die folgende Untertabelle ergänzt die Zuständigkeitstabelle um alle in Plänen und Vertragsfragen genannten Zusatzdateien und legt für die Hotspots eine feste Reihenfolge fest. Stubs aus P1 gehören nach der Anlage P2, P3 und P4; P7 übernimmt `ansicht-speicher.js` und `ansicht-gutschrift.js`; P0 besitzt `server/services/kundenansicht/` ganz. + (P0 VF1; P1 VF3; P2 VF1; P4 Dateiübersicht; P5 VF4; P6 VF3; P7 VF1, VF2; P10 VF1; Befund zur Übersicht; F10) + +| Datei / Stelle | Besitzer | Reihenfolge, Hinweis | +|---|---|---| +| `server/services/kundenansicht/` ganz (`schalter.js`, `dashboardWeiche.js`), `test/kundenansichtSchalter.test.js`, `kundenansichtDashboard.test.js`, `kundenansichtMe.test.js` | P0 | andere Pakete legen dort nichts ab | +| Werkzeug `views.mjs`, `gates.mjs`, `fixtures/*`, `vertrag-endpunkte.mjs`, `vorschau/seiten.mjs` (eigenes Git) | P0 legt an | P1 bis P4 und P7 ergänzen nur nach N41 | +| `public/neu/manifest.webmanifest`, `test/support/knHuelle.js`, `test/knHuelle*.test.js` | P1 | P2 bis P4 dürfen die Testhilfe nutzen | +| Stubs aus P1: `public/neu/ansicht-{website,chat,vertraege,passwort,gutschrift,wechsel,bestellen}.js`, `ansichten.css`; `ansicht-funktionen.js`, `funktionen.css`; `module.css` | P1 legt an; danach P2, P3, P4 | Besitz geht nach der Anlage an das Paket der Tabelle oben | +| `public/neu/dashboard.html` | P1 | P2 ändert genau zwei Zeilen (Composer-Skript, Stil `/neu/chat-composer.css`) | +| `public/neu/ansicht-hilfen.js`, `rechnung-offen.js`, `install-hinweis.js`, `website-details.js`, `vertraege-rechnungen.js`, `vertraege-abo.js`, `chat-composer.css`, `oeffentlich/oeffentlich.css`, `oeffentlich/auftrag.html`, `test/support/knDaten.js`, `knWortlautOrakel.js`, `test/knAnsicht*.test.js` | P2 | `knWortlautOrakel.js` friert P10 vor dem Löschen von `app.js` ein | +| `public/neu/ansicht-bestellen.js`, `ansicht-wechsel.js`, `ansicht-speicher.js`, `ansicht-gutschrift.js` | P2 baut 1:1, P7 baut um | P7 erst nach dem Merge von UI und Recht | +| `public/neu/funktion-einschalten.js`, `test/support/knFunktionenDaten.js`, `test/knEinschalten*.test.js`, `knFunktionen*.test.js` | P3 | P7 Task 0 ändert nur `sichereAdresse` | +| `public/neu/bilder/*` | P3 | `analyse.webp` erzeugt P4 | +| `public/fn-reiter.js` (neu), `test/support/modulAbo.js`, `test/kundenansichtNeuModul*.test.js`, Pins `test/shopWiring.test.js:31`, `test/unterkunftWiring.test.js:130` | P4 | | +| `server/services/aboUnterlagen.js`, `aboKatalog.js`, `funktionEinschalten.js` (neu), `featureRegistry.js` (auch `consentAbo*`, `abowegAnalyse`), `test/featureSetup.test.js:191-192`, `test/aboFassung6Verdrahtung.test.js` | P5 | Verdrahtung der Fassung 6 siehe N33 | +| P5, minimal in fremden Dateien: `server/routes/admin/customers.js` (`code`), `server/services/ticketAdmin.js` (`abrechnung`), `server/services/zeiten/einschalten.js` (`abrechnung`, `vorpruefung`), `server/services/analyse.js`, `server/services/shop/anfrage.js` (`stelleAnfrage`), `server/routes/shop.js` (Anfrage) | P5 | P6 vermeidet diese Funktionen | +| `server/services/aboModell.js`, `aboFunktionenMails.js`, `aboFunktionenMeldung.js`, `aboAufbauphase.js`, `aboFunktionenAbgleich.js` (alle neu) | P6 | Aufbauphasen-Regel siehe N28 | +| Mailvorlagen K38 bis K43, T24; Block `funktionen_abo` in K02 und K03 | P6 | K44 und T25 frei (N29) | +| P6, minimal in fremden Dateien: `server/routes/admin/funktionenAdmin.js`, `termine/aboAbgleich.js`, `zeiten/aboAbgleich.js`, `zeiten/mails.js`, `zeiten/behalten.js`, `termine/ticketDetails.js`, `termine/einbauanleitung.js`, `shop/ticketDetails.js`, `unterkunft/ticketDetails.js`, `shop/anfrage.js` und `unterkunft/anfrageFunktion.js` (nur `sendeEntscheidung`) | P6 | nur die in Plan 07 benannten Stellen | +| `server/services/bestelltexte.js`, `duFehler.js`, `vertragsNachweis.js`, `public/neu/bestell-bausteine.js`, `test/support/knBestellDaten.js`, `test/kn*.test.js` (Plan 08) | P7 | | +| P7, minimal in fremden Dateien: `server/services/stripeBilling.js` (`createAboCheckout`), `server/routes/takeoverCredits.js`, `server/mail-templates/K12 Wechsel Wartungs-Abo.html` (Block `sofortverlangen`), `public/neu/oeffentlich/kuendigen.html`, `widerrufen.html`, `public/legal-form.js`, `public/auftrag-widerruf.js` (nur `data-anrede="du"`) | P7 | | +| `test/aboWortlautV6.test.js`, `aboBedingungenV6.test.js`, `legalV6Build.test.js`, `funktionenRechtV6.test.js`, `kundenansichtEntscheidungen.test.js`, `rechtlichesHochladen.test.js` | P8 | | +| `server/routes/umschaltSeiten.js`, `scripts/umschalten-nachpruefung.mjs`, `scripts/kauf-bestand-pruefen.mjs`, `docs/umschalten-kundenansicht.md`, `test/support/dateiListe.js`, `modulKlassen.json`, `oeffentlichStand.json`, `public/neu/oeffentlich/login.js`, `public/sw.js:5`, Tests `knUmschalt*`, `knFassung6Fest`, `knLoginModul`, `knAufraeumen`, `knKaufRueckbau`, `knAltZweigeWeg`, `knSchalterWeg`, `knOfflineCache`, `kundenartBestand`, `kaufBestandPruefen` | P10 | Rückbau der Kaufpfade und Alt-Zweige erst in Phase F | +| **geteilt:** `server/routes/abo.js` | P6, dann P7 | P6: K02/K03, `POST /switch`, `GET /`, `withdrawOrder`, Widerruf des Wechsels; P7: nur die Pfade der Fassung 6 und `router.use` für `duFehler` | +| **geteilt:** `server/services/planSwitch.js` | P6, dann P7 | P6: `details.funktion`, `completeSwitch`, `markSwitchFailed`; P7: `switchToWartung` (Z. 347-351, 388, 483), `performSwitch` (`details`, K12-Aufruf) | +| **geteilt:** `server/services/siteFeatures.js` | P5, dann P6 | P5: `featureView`, `legeTicketAn`; P6: `adminActivate`, `ticketNachlegen` | +| **geteilt:** `server/index.js` | je eine eigene Zeile | P0 Weiche vor `express.static`; P5 1 Import, 1 Mount; P6 1 Import, 1 Intervallblock; P10 eine Zeile direkt vor `app.use(express.static(publicDir))` | +| **geteilt:** `server/services/products.js` | P8, dann P10 | P8: Fassungstabelle, `WORTLAUT_V6` und Konstanten; P10: Zeilen v6 und v5 der Tabelle | +| **geteilt:** `server/config.js` | je eigener Abschnitt | P0 `kundenansichtNeu`, P5 `aboFunktionen`, P8 `abo.termsVersionNeu`; Ausnahme: die Literale `aboFunktionen.fassung` und `.texte.beginn` setzt die Verdrahtung (N33) | +| **geteilt:** `server/services/featureRegistry.js`, `funktionEinschalten.js` | P5 | `consentAbo` ändert nur die Verdrahtung (N33); P6 fügt in `funktionEinschalten.js` genau einen Aufruf `meldeEingeschaltet` ein | +| **geteilt:** `public/neu/ansichten.css` | P2, dann P7 | P7 hängt einen Abschnitt an | +| **geteilt:** `test/mailTemplates.test.js` | P6, dann P7 | P6: Z. 9, 11, 718; P7: Z. 379 | + +--- + ## Verträge ### V1 Pilot-Schalter (P0) @@ -496,6 +670,9 @@ export async function speichereVertragsNachweis({ customerId, art: 'abo'|'wechse - Vorschau aktualisieren (nur `cp`) - Abschlussprüfung mit vier Blickwinkeln: Recht und Nachweise, alte Ansicht unverändert, Handy und Barrierefreiheit, Abo-Modell und Stundenkonto - Korrekturen - - Übergabe-Notiz unter `/root/reports/kundenansicht-neu-2026-10-15/uebergabe/` mit den Push-Befehlen für den Inhaber + - **Vor dem Push des Inhabers, beide mit Sicherung und OK des Inhabers (Nachtrag N47):** + - Compose- und `.env`-Zeilen `KUNDENANSICHT_NEU` (Modus `pilot`) und `KUNDENANSICHT_NEU_PILOT_EMAILS` (Pilot-Liste) anlegen, Sicherung `.bak--kundenansicht`, dann `docker compose up -d --no-deps kundenbereich-server`, nie `preview-portal`. `pilot` erst setzen, wenn WOMS `kn-p9-kundenart` live ist (Nachtrag N24). + - `scripts/kundenansicht-setup.mjs --pruefen`, nach OK des Inhabers ausführen (sonst scheitert jeder Nachweis ohne `featureConsents.kundenart` mit 500). + - Übergabe-Notiz unter `/root/reports/kundenansicht-neu-2026-10-15/uebergabe/` mit den Push-Befehlen für den Inhaber, der Push-Reihenfolge (N24) und den beiden Schritten oben 5. **Rechtsrunde:** P8 legt `docs/rechtliches/kundenansicht-entscheidungen.md` an. Der Controller geht die Liste mit dem Inhaber durch (E20). Hochgeladen wird erst nach seiner Freigabe, mit `scripts/rechtliches-hochladen.mjs`, zuerst `--probe`. 6. **P10** startet erst, wenn der Inhaber ausdrücklich „umschalten“ sagt. diff --git a/docs/superpowers/plans/2026-10-16-kundenansicht-01-werkzeug-vorschau.md b/docs/superpowers/plans/2026-10-16-kundenansicht-01-werkzeug-vorschau.md new file mode 100644 index 0000000..f498def --- /dev/null +++ b/docs/superpowers/plans/2026-10-16-kundenansicht-01-werkzeug-vorschau.md @@ -0,0 +1,1416 @@ +# Kundenansicht neu, P0 Werkzeug, Pilot-Schalter, Vorschau: 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:** Der Pilot-Schalter (V1) liefert je Sitzung die neue oder die alte Kundenseite aus. Ein Werkzeug mit Testdaten-Backend, Prüfungen und Vorschau-Bau macht die neue Oberfläche prüfbar, bevor sie live ist. + +**Architecture:** +- Portal: Der Schalter ist ein reines Modul. Die Weiche für `/dashboard.html` ist eine Handler-Fabrik, die `server/index.js` vor `express.static` einhängt und ohne Server-Start testbar ist. +- Werkzeug: außerhalb des Repos mit eigenem Git. Backend und Fälle sind reine ES-Module ohne `node:`-Importe und laufen im Playwright-Lauf (Gates) wie im Service Worker der Vorschau. +- Ausliefern: ein Skript mit festem Ziel, nur `cp`, Entfernen nur über das Manifest des letzten Laufs, Datei für Datei. + +**Tech Stack:** Node 24 (Host) mit `node --test`, Express 4, Playwright-Core 1.62.1 mit `chromium-1234`, esbuild 0.21.5 aus dem WOMS-`build/`, Bash mit `flock` und `systemd-run`. + +**Spec:** `/home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht/docs/superpowers/specs/2026-10-15-kundenansicht-neu-design.md` (5.10, E16, E19; Code-Karten unter `/root/reports/kundenansicht-neu-2026-10-15/karten/`) + +**Ü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-kundenansicht`, Branch `feature/kundenansicht-neu`, Tag `kn-p0`. Das Werkzeug liegt in `/root/reports/kundenansicht-neu-2026-10-15/werkzeug/` (eigenes Git, Branch `main`, kein Remote, ebenfalls Tag `kn-p0`). + +Kürzel: `WT` = der Portal-Worktree, `KW` = der Werkzeug-Ordner. In Befehlen stehen die Pfade ausgeschrieben. + +## Global Constraints + +Alle Global Constraints der Übersicht gelten wörtlich (alte Ansicht unverändert, Schalter `off | pilot | on` mit Vorgabe `off`, Testbefehle, Playwright nur über das Werkzeug, Sicherheitsblock, kein Push, kein Merge, nichts in `build/`). Paketeigene Zusätze: + +- **Commits:** Dateien einzeln mit `git add`, nie `-A`. Trailer wörtlich `Co-Authored-By: Claude Opus 5.5 ` als zweites `-m`. Werkzeug-Commits mit `git -C /root/reports/kundenansicht-neu-2026-10-15/werkzeug`. +- **Bestehenden Code ändert dieses Paket nur** in `server/config.js`, `server/index.js`, `server/routes/auth.js` (siehe Dateiübersicht). In neuen Dateien unter `server/` und `public/` steht nie der Text `admin.html` oder `kalkulation.html` (Pin `test/adminPanelGone.test.js:54-67`). +- **Werkzeug-Skripte:** `rm -rf` nur auf Pfade unter `KW/tmp/`, nur hinter einer `case`-Prüfung auf den genauen Pfad. Keine Zielpfade aus Parametern ohne Guard. +- **Vorschau-Skript:** Der Agent ruft `vorschau-kundenansicht.sh` nur mit `--pruefen` auf. Die Hilfsfunktionen des Guards dürfen mit Testpfaden unter `KW/tmp/` laufen, nie mit Pfaden, die `..` enthalten. +- **Service-Worker-tauglich:** `backend.mjs` und `fixtures/*.mjs` importieren nichts aus `node:` und lesen `process.env` nur als `process.env.FIXTURE_TODAY`. +- **Testdaten:** fester Tag `FIXTURE_TODAY=2026-10-16`, alles erfunden, Adressen enden auf `.invalid`, URLs sind relativ oder enden auf `.invalid`. +- **Browser:** ein Browser, immer über `run-browser.sh`, nie `fullPage`, Zeitlimit 180 s je Operation, `hasTouch: true` für Handy, Standalone nur per `matchMedia`-Patch, Uhr per `setFixedTime`. Vor schweren Läufen `free -m` (die Skripte prüfen selbst, Schwelle 2000 MB). Danach zeigt `ls -l /dev/null` `crw-rw-rw-`. +- **Texte:** Außer den Platzhalter- und Hinweisseiten der Vorschau entstehen keine Kundentexte. Diese sind in du-Form. Einzige weitere Ausnahme: die Testtexte im Katalog der Fixtures (`katalog[].consent`, `katalog[].hinweis`, Task 6). Sie sind kurz, folgen sinngemäß V5 und Spec 5.6 und sind kein Rechtswortlaut (den liefert später der Server aus P5 und P8). +- **Fälle der Fixtures:** Die ersten vierzehn Namen von `FALL_NAMEN` sind der Vertrag von P0 und stehen fest. Spätere Pakete hängen eigene Fälle nur in eigenen Dateien hinten an (Task 6, README). Pin und `boot.js` bleiben dafür unberührt. + +## Review Focus + +1. **Pilot-Schalter an den Rändern:** Groß-/Kleinschreibung und Leerraum der E-Mail, `*`, leere Liste, `off` mit `*`, E-Mail ohne Text, Sitzung ohne Kunde, alte Mitarbeiter-Sitzung (`role: 'admin'` ohne `viewId`). Erwartet: nur Treffer und Mitarbeiter-Kundenansicht sehen neu. Dieselben Listenregeln gelten für Jobs und Dienste ohne Sitzung (`neueAnsichtFuerKunde`). Tests: Task 2, dieselben Fälle über HTTP in Task 3 und 4. +2. **Weiche liefert die falsche Seite oder falsch gecacht:** Query (`?abo=success`), unsigniertes Cookie, Direktaufruf von `/neu/dashboard.html`, Reihenfolge vor `express.static`, `Cache-Control`. Erwartet: Nicht-Pilot bekommt bytegleich die alte Seite. Test: Task 3. +3. **Fixtures weichen von den echten Antworten ab:** Das Werkzeug zeigt Grün, die echte Seite bricht. Erwartet: Schlüsselmengen stimmen mit den echten Routern überein, jede Ausnahme ist begründet, keine Ausnahme verwaist, keine echten Hosts und Adressen. Tests: Task 7 und 6. +4. **Auslieferung zerstört oder überschreibt Fremdes:** falsches Ziel, Elternordner, Symlink, `..` oder absolute Pfade im Manifest. Erwartet: Ablehnung vor dem ersten Kopieren, entfernt wird nur, was im eigenen Manifest steht. Tests: Task 13. +5. **Werkzeuglauf frisst den Speicher oder läuft still leer:** zweiter Browser, `fullPage`, kein Zeitlimit, zu wenig freier Speicher. In der Vorschau ein leeres Dashboard, weil der Service Worker nicht übernimmt oder nginx für eine fehlende Datei HTML mit 200 liefert. Tests: Task 5 (Sperre, Schwelle, Browser nur über das Skript), 10 (Kontextoptionen, kein `fullPage`), 11 und 12 (Dateien-Abschluss, Start der Vorschau). + +## Dateiübersicht + +| Datei | Aufgabe | Task | +|---|---|---| +| `server/config.js:35` und Abschnitt nach `zeiten` (um `:1116`) | `kundenansichtNeu` | 2 | +| `server/services/kundenansicht/schalter.js`, `test/kundenansichtSchalter.test.js` (neu) | V1 Schalter | 2 | +| `server/services/kundenansicht/dashboardWeiche.js`, `public/neu/dashboard.html` (Platzhalter, ab P1 gehört sie P1), `test/kundenansichtDashboard.test.js` (neu) | Weiche (Vertragsfrage 1) | 3 | +| `server/index.js` (Import, zwei Zeilen zwischen `:153` und `:154`) | Weiche vor `express.static` | 3 | +| `server/routes/auth.js` (Import, `GET /me` in den zwei Antworten `authenticated: true` bei `:405` und `:464`), `test/kundenansichtMe.test.js` (neu) | Feld `ansicht` | 4 | +| Pins `test/adminPanelGone.test.js:93-95`, `test/legalDocs.test.js:443-448` (nur Lauf, keine Änderung) | Reihenfolge vor `express.static` | 1, 3 | +| `test/ui/kundenansicht-neu.ui.mjs` (neu) | Einstieg für Sichtprüfungen | 10 | +| `KW/` `.gitignore`, `package.json`, `env.sh`, `check.sh`, `run-browser.sh`, `browser.mjs` | Gerüst | 5 | +| `KW/` `fixtures/{helfer,basis,faelle,endpunkte,index}.mjs`, `backend.mjs` | Fälle, Backend | 6 | +| `KW/` `vertrag-endpunkte.mjs` | Vertragstest | 7 | +| `KW/` `checks.mjs`, `konsole.mjs` | Prüfungen | 8 | +| `KW/` `views.mjs`, `gates.mjs` | Ansichten, Gates | 9 | +| `KW/` `statik-server.mjs`, `shots.mjs`, `shots.sh`, `vorschau/seiten.mjs` | Läufer | 10 | +| `KW/vorschau/` `sw-eintrag.mjs`, `boot.js`, `index-patchen.mjs`, `pruefe-dist.mjs`, `bauen.sh`, `hinweis.html`, `demo-baeckerei.html` | Vorschau-Bau | 11 | +| `KW/vorschau/` `ziel-pruefen.sh`, `ausliefern-lib.sh`, `manifest.mjs`, `vorschau-kundenansicht.sh`, `plan-zeigen.sh`, `test-ziel.sh` | Auslieferung | 13 | +| `KW/test/` `geruest`, `backend`, `vertrag`, `gates`, `statik`, `vorschau-bau`, `manifest` (`.test.mjs`) und `checks`, `shots`, `vorschau` (`.selftest.mjs`) | Tests des Werkzeugs | 5 bis 13 | +| `KW/README.md`, `KW/UEBERGABE-P0.md` | Bedienung, Auftrag an den Inhaber | 14 | + +--- + +### Task 1: Worktree vorbereiten + +**Files:** kein Commit. Es entsteht nur der unversionierte Symlink `WT/node_modules`. + +**Interfaces:** +- Produces: ein Worktree, in dem Portal-Tests laufen, und die Basislinie (`pass`-Zahl) für Task 3. + +- [ ] **Step 1: Zustand prüfen** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && git branch --show-current && git status --short` +Expected: `feature/kundenansicht-neu`, Status leer. Steht nur `?? docs/superpowers/plans/2026-10-16-kundenansicht-01-werkzeug-vorschau.md` da, committe genau diese Datei (`git add` dieser Datei, Nachricht `Plan: Kundenansicht neu P0 Werkzeug, Schalter, Vorschau`). + +- [ ] **Step 2: Symlink setzen** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && ln -s ../build/node_modules node_modules && git status --short && git check-ignore -v node_modules` +Expected: Status bleibt leer, `check-ignore` nennt die Zeile `node_modules` aus `.gitignore`. + +- [ ] **Step 3: Basislinie laufen lassen** + +Run: `free -m` (bei `available` mindestens 2000 MB), dann `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/zeitenSchalter.test.js test/adminPanelGone.test.js test/legalDocs.test.js` +Expected: `fail 0`. Notiere die `pass`-Zahl. Danach `ls -l /dev/null` mit `crw-rw-rw-`. + +--- + +### Task 2: Pilot-Schalter (V1) + +**Files:** +- Create: `server/services/kundenansicht/schalter.js` +- Modify: `server/config.js:35` (neue Zeile `kundenansichtNeuEnabled`), neuer Abschnitt `kundenansichtNeu` im Objekt `config` direkt nach `zeiten` (um `:1116`) +- Test: `test/kundenansichtSchalter.test.js` + +**Interfaces:** +- Consumes: `parsePortalSession(req)` aus `server/middleware/session.js`, `commaList(raw, fallback)` aus `server/config.js`. +- Produces (V1, wörtlich): + - `config.kundenansichtNeu: { mode: 'off'|'pilot'|'on', pilotEmails: string[] }` aus `KUNDENANSICHT_NEU` und `KUNDENANSICHT_NEU_PILOT_EMAILS` + - `neueAnsichtModus(): 'off'|'pilot'|'on'` + - `neueAnsichtFuerSession(session): boolean` + - `neueAnsicht(req): boolean` (= `neueAnsichtFuerSession(req.session ?? parsePortalSession(req))`) + - `neueAnsichtFuerKunde({ customerId, email }): boolean` (Ergänzung zu V1 für Jobs und Dienste ohne Cookie, etwa P5 `aboModellFuerKunde`, P5 `runAboCheck` und P6; die Übersicht V1 bekommt die Zeile, siehe Vertragsfrage 10). Gleiche Modus- und Listenregeln wie `neueAnsichtFuerSession`, aber ohne Sitzungsprüfung und ohne Mitarbeiter-Regel: `off` ergibt false. Ist `customerId` kein nicht-leerer Text (getrimmt) oder die Eingabe kein Objekt, ergibt es false. `on` ergibt true. `pilot` ergibt true bei `'*'` in der Liste oder wenn die getrimmte, klein geschriebene `email` in der Liste steht (eine `email`, die kein Text ist, zählt als leer). `neueAnsichtFuerSession` und `neueAnsichtFuerKunde` teilen sich eine private Hilfsfunktion für den Listenvergleich. + +- [ ] **Step 1: Failing test schreiben** (`test/kundenansichtSchalter.test.js`) + +```js +import { test, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import { config } from '../server/config.js' +import { neueAnsicht, neueAnsichtFuerKunde, neueAnsichtFuerSession, neueAnsichtModus } from '../server/services/kundenansicht/schalter.js' + +let original +beforeEach(() => { original = { ...config.kundenansichtNeu } }) +afterEach(() => { Object.assign(config.kundenansichtNeu, original) }) +const stelle = (mode, pilotEmails = []) => Object.assign(config.kundenansichtNeu, { mode, pilotEmails }) +const MAIL = 'k@example.invalid' +const kunde = (email = MAIL) => ({ customerId: 'c1', appwriteUserId: 'u1', name: 'K', email }) +const ansicht = (extra = {}) => ({ role: 'admin', viewAs: true, viewId: 'a'.repeat(32), customerId: 'c1', appwriteUserId: 'u_staff', email: 'andrej@webklar.com', ...extra }) +const UNGUELTIG = [null, undefined, {}, 'x', 42, [], { role: 'admin', appwriteUserId: 'u', email: 'kenso@webklar.com' }, ansicht({ viewId: undefined }), ansicht({ viewId: '' }), ansicht({ viewAs: false }), { customerId: 'c1' }, { appwriteUserId: 'u1' }] +const ENV = ['KUNDENANSICHT_NEU', 'KUNDENANSICHT_NEU_PILOT_EMAILS'] +let zaehler = 0 +async function frischeConfig(env = {}) { // neue config.js-Instanz mit genau diesen Variablen (Muster zeitenSchalter.test.js) + const alt = Object.fromEntries(ENV.map((k) => [k, process.env[k]])) + for (const k of ENV) delete process.env[k] + Object.assign(process.env, env) + try { zaehler += 1; return (await import(`../server/config.js?kn-schalter=${zaehler}`)).config } finally { + for (const k of ENV) { if (alt[k] === undefined) delete process.env[k]; else process.env[k] = alt[k] } + } +} + +test('Konfiguration: Vorgabe off, Unbekanntes ist off, Werte werden bereinigt', async () => { + assert.deepEqual((await frischeConfig()).kundenansichtNeu, { mode: 'off', pilotEmails: [] }) + for (const wert of ['', ' ', 'banane', 'true', '1']) assert.equal((await frischeConfig({ KUNDENANSICHT_NEU: wert })).kundenansichtNeu.mode, 'off', wert) + assert.equal((await frischeConfig({ KUNDENANSICHT_NEU: ' ON ' })).kundenansichtNeu.mode, 'on') + const f = await frischeConfig({ KUNDENANSICHT_NEU: ' Pilot ', KUNDENANSICHT_NEU_PILOT_EMAILS: ' Kenso.Gri@Gmail.com, ,justin@webklar.com ' }) + assert.deepEqual([f.kundenansichtNeu.mode, f.kundenansichtNeu.pilotEmails], ['pilot', ['kenso.gri@gmail.com', 'justin@webklar.com']]) +}) + +test('Regeln: [Modus, Liste, Sitzung, erwartet]', () => { + const TABELLE = [ + ['off', ['*', MAIL], kunde(), false], ['off', ['*'], ansicht(), false], + ['on', [], kunde(), true], ['on', [], kunde(''), true], ['on', [], ansicht(), true], + ['pilot', [MAIL], kunde(), true], ['pilot', [MAIL], kunde(' K@Example.Invalid '), true], + ['pilot', [MAIL], kunde('k@example.invalid.evil'), false], ['pilot', [MAIL], kunde('anderer@example.invalid'), false], ['pilot', [MAIL], kunde(''), false], + ['pilot', [MAIL], { ...kunde(), email: undefined }, false], ['pilot', [MAIL], { ...kunde(), email: 42 }, false], ['pilot', [MAIL], { ...kunde(), email: [MAIL] }, false], + ['pilot', ['*'], kunde(), true], ['pilot', ['*'], ansicht(), true], + ['pilot', [], kunde(), false], ['pilot', [], ansicht(), true], // leere Liste: nur die Mitarbeiter-Kundenansicht + ] + for (const [modus, liste, sitzung, soll] of TABELLE) { stelle(modus, liste); assert.equal(neueAnsichtFuerSession(sitzung), soll, `${modus} ${liste} ${JSON.stringify(sitzung)}`) } +}) + +test('ungültige Sitzungen sind in jedem Modus und bei jeder Liste false', () => { + for (const modus of ['off', 'on', 'pilot']) for (const liste of [[], ['*'], [MAIL, 'kenso@webklar.com']]) { stelle(modus, liste); for (const s of UNGUELTIG) assert.equal(neueAnsichtFuerSession(s), false, `${modus} ${liste} ${JSON.stringify(s)}`) } +}) + +test('neueAnsicht(req): req.session hat Vorrang, sonst das signierte Cookie; kaputt oder leer ist false', () => { + stelle('pilot', [MAIL]) + const cookie = (s) => ({ signedCookies: { [config.cookieName]: typeof s === 'string' ? s : JSON.stringify(s) } }) + const fremd = kunde('x@example.invalid') + assert.deepEqual([neueAnsicht({ session: kunde() }), neueAnsicht({ session: fremd }), neueAnsicht(cookie(kunde())), neueAnsicht({ session: null, ...cookie(kunde()) })], [true, false, true, true]) + assert.deepEqual([neueAnsicht({ session: fremd, ...cookie(kunde()) }), neueAnsicht(cookie('{kaputt')), neueAnsicht({})], [false, false, false]) +}) + +test('der Modus wird bei jedem Aufruf gelesen; Unbekanntes gilt als off', () => { + stelle('off'); assert.equal(neueAnsichtFuerSession(kunde()), false) + stelle('on'); assert.equal(neueAnsichtFuerSession(kunde()), true) + stelle('banane'); assert.equal(neueAnsichtModus(), 'off') +}) + +test('neueAnsichtFuerKunde (Jobs ohne Cookie): [Modus, Liste, Eingabe, erwartet]', () => { + const k = (email) => ({ customerId: 'c1', email }) + const TABELLE = [ + ['off', ['*', MAIL], k(MAIL), false], ['off', ['*'], k(undefined), false], + ['on', [], k(MAIL), true], ['on', [], k(''), true], ['on', [], { customerId: 'c1' }, true], + ['pilot', [MAIL], k(MAIL), true], ['pilot', [MAIL], k(' K@Example.Invalid '), true], + ['pilot', [MAIL], k('k@example.invalid.evil'), false], ['pilot', [MAIL], k('anderer@example.invalid'), false], + ['pilot', [MAIL], k(''), false], ['pilot', [MAIL], k(undefined), false], ['pilot', [MAIL], k(42), false], ['pilot', [MAIL], k([MAIL]), false], + ['pilot', ['*'], k(MAIL), true], ['pilot', ['*'], k(undefined), true], + ['pilot', [], k(MAIL), false], // leere Liste: niemand, denn ohne Sitzung gibt es keine Mitarbeiter-Kundenansicht + ] + for (const [modus, liste, eingabe, soll] of TABELLE) { stelle(modus, liste); assert.equal(neueAnsichtFuerKunde(eingabe), soll, `${modus} ${liste} ${JSON.stringify(eingabe)}`) } +}) + +test('neueAnsichtFuerKunde: leere customerId und Unbrauchbares sind in jedem Modus false; Listenregeln wie bei Sitzungen', () => { + const UNBRAUCHBAR = [undefined, null, 'x', 42, [], {}, { email: MAIL }, { customerId: '', email: MAIL }, { customerId: ' ', email: MAIL }, { customerId: 42, email: MAIL }, { customerId: null, email: MAIL }, { customerId: ['c1'], email: MAIL }] + for (const modus of ['off', 'on', 'pilot']) for (const liste of [[], ['*'], [MAIL]]) { + stelle(modus, liste) + for (const e of UNBRAUCHBAR) assert.equal(neueAnsichtFuerKunde(e), false, `${modus} ${liste} ${JSON.stringify(e)}`) + for (const mail of [MAIL, ' K@Example.Invalid ', 'x@example.invalid', '', undefined, 42]) // gleiche Antwort wie für eine gültige Kunden-Sitzung mit derselben E-Mail + assert.equal(neueAnsichtFuerKunde({ customerId: 'c1', email: mail }), neueAnsichtFuerSession({ ...kunde(), email: mail }), `${modus} ${liste} ${JSON.stringify(mail)}`) + } +}) +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag prüfen** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/kundenansichtSchalter.test.js` +Expected: FAIL mit `Cannot find module` auf `schalter.js`. + +- [ ] **Step 3: Implementieren** + +- `server/config.js:35`: `const kundenansichtNeuEnabled = (process.env.KUNDENANSICHT_NEU || '').trim().toLowerCase()`. +- Abschnitt nach `zeiten` (Muster `zeiten`, deutscher Kommentar): `kundenansichtNeu: { mode: ['pilot', 'on'].includes(kundenansichtNeuEnabled) ? kundenansichtNeuEnabled : 'off', pilotEmails: commaList(process.env.KUNDENANSICHT_NEU_PILOT_EMAILS, []) }`. +- `schalter.js` mit den vier Exports (Muster `server/services/zeiten/schalter.js`). Ablauf von `neueAnsichtFuerSession`: + 1. Modus `off` ergibt false. + 2. Ungültige Sitzung ergibt false. Gültig ist ein Objekt (kein Array) mit **Kundenansicht** (`viewAs === true` und `viewId` gesetzt) oder **Kunde** (`customerId` und `appwriteUserId` gesetzt, `role !== 'admin'`). + 3. `on` ergibt true. + 4. `pilot` ergibt true bei Kundenansicht, bei `'*'` in der Liste oder wenn die getrimmte, klein geschriebene `session.email` in der Liste steht. Eine `email`, die kein Text ist, zählt als leer. +- `neueAnsichtFuerKunde({ customerId, email })` folgt den Interfaces: dieselben Schritte 1, 3 und 4, an Stelle von Schritt 2 die Prüfung auf eine nicht-leere `customerId`. Den Listenvergleich aus Schritt 4 (`'*'` oder getrimmte, klein geschriebene E-Mail in der Liste) übernimmt eine private Hilfsfunktion, die beide Exports nutzen. + +- [ ] **Step 4: Test grün** + +Run: wie Step 2. Expected: PASS, `fail 0`. + +- [ ] **Step 5: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht +git add server/config.js server/services/kundenansicht/schalter.js test/kundenansichtSchalter.test.js +git commit -m "Kundenansicht neu: Pilot-Schalter (off/pilot/on) mit Randfall-Tests" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: Dashboard-Weiche und Platzhalter + +**Files:** +- Create: `server/services/kundenansicht/dashboardWeiche.js`, `public/neu/dashboard.html` +- Modify: `server/index.js` (Import bei den Service-Imports, zwei Zeilen zwischen `app.use('/kalkulation.html', …)` und `app.use(express.static(publicDir))`) +- Test: `test/kundenansichtDashboard.test.js` + +**Interfaces:** +- Consumes: `neueAnsicht(req)` (Task 2). +- Produces: + - `dashboardWeiche({ publicDir }): (req, res, next) => void`. Gilt die neue Ansicht: `Cache-Control: no-cache` setzen, dann `res.sendFile(publicDir/neu/dashboard.html)`. Sonst `next()`. + - `neuDirektWeiche(): (req, res, next) => void` für `GET /neu/dashboard.html`: neue Ansicht ergibt `next()`, sonst `res.redirect(302, '/dashboard.html')`. + - `public/neu/dashboard.html` (Platzhalter): ``, `main[data-kn-platzhalter]` mit Überschrift „Kundenansicht neu“ und Text „Platzhalter bis P1“, ``, dazu ein klassisches Inline-Skript, das `GET /api/auth/me` liest und per `textContent` `me.ansicht` (bei `authenticated: false` den Text `abgemeldet`, bei Fehler `Fehler`) einträgt. Weißer Hintergrund, Systemschrift. P1 ersetzt die Datei. + +- [ ] **Step 1: Failing test schreiben** (`test/kundenansichtDashboard.test.js`) + +Kopf wie `test/kundenansichtEnde.test.js:1-19` (Mock mit `customerViewLog`, `startFakeStripe`, `useTestEnv`, damit `testApp.js` und `fixtures.js` laden), dann: + +```js +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import express from 'express' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import path from 'node:path' +// … Mock, Stripe, useTestEnv (wie im Muster), dann dynamisch: +const { config } = await import('../server/config.js') +const { startTestApp, rawGet } = await import('./support/testApp.js') +const { customerCookie, kundenansichtCookie, adminCookie, viewAsCookie } = await import('./support/fixtures.js') +const { dashboardWeiche, neuDirektWeiche } = await import('../server/services/kundenansicht/dashboardWeiche.js') +const publicDir = fileURLToPath(new URL('../public/', import.meta.url)) +const router = express.Router() // gleiche Reihenfolge wie server/index.js +router.get('/dashboard.html', dashboardWeiche({ publicDir })) +router.get('/neu/dashboard.html', neuDirektWeiche()) +router.use(express.static(publicDir)) +const app = await startTestApp({ '/': router }) +after(() => Promise.all([app.close(), aw.close(), st.close()])) +const stelle = (mode, pilotEmails = []) => Object.assign(config.kundenansichtNeu, { mode, pilotEmails }) +const ALT = readFileSync(path.join(publicDir, 'dashboard.html'), 'utf8') +const istNeu = (r) => r.status === 200 && String(r.body).includes('data-kn-platzhalter') && String(r.body).includes('') +const istAlt = (r) => r.status === 200 && r.body === ALT +const MAIL = 'k@example.invalid' // E-Mail von c1; c2 hat k2@example.invalid +const UNSIGNIERT = `${config.cookieName}=${encodeURIComponent(JSON.stringify({ customerId: 'c1', appwriteUserId: 'u1', email: MAIL }))}` + +const FAELLE = [ // [Name, Modus, Liste, Cookie, Ergebnis] + ['Pilot, Kunde auf der Liste', 'pilot', [MAIL], customerCookie('c1'), 'neu'], + ['Pilot, Kunde daneben', 'pilot', [MAIL], customerCookie('c2'), 'alt'], + ['Pilot, Mitarbeiter-Kundenansicht', 'pilot', [], kundenansichtCookie(), 'neu'], + ['off mit Stern, Kunde', 'off', ['*'], customerCookie('c1'), 'alt'], + ['off mit Stern, Kundenansicht', 'off', ['*'], kundenansichtCookie(), 'alt'], + ['on, Kunde', 'on', [], customerCookie('c1'), 'neu'], + ['on, alte Mitarbeiter-Sitzung', 'on', [], adminCookie(), 'alt'], + ['on, Kundenansicht ohne viewId', 'on', [], viewAsCookie('c1'), 'alt'], + ['Pilot mit Stern, ohne Sitzung (200 wie heute, kein Redirect)', 'pilot', ['*'], undefined, 'alt'], + ['Pilot mit Stern, unsigniertes Cookie', 'pilot', ['*'], UNSIGNIERT, 'alt'], +] +for (const [name, modus, liste, cookie, soll] of FAELLE) test(name, async () => { + stelle(modus, liste) + const r = await rawGet('/dashboard.html', { cookie }) + assert.ok(soll === 'neu' ? istNeu(r) : istAlt(r), `${r.status} ${String(r.body).slice(0, 80)}`) +}) + +test('neue Seite: HTML und Cache-Control no-cache; Query bleibt erhalten', async () => { + stelle('pilot', [MAIL]) + const r = await rawGet('/dashboard.html?abo=success', { cookie: customerCookie('c1') }) + assert.ok(istNeu(r)); assert.equal(r.headers.get('cache-control'), 'no-cache'); assert.match(r.headers.get('content-type'), /^text\/html/) +}) +test('Direktaufruf /neu/dashboard.html: Pilot ja, sonst 302 auf /dashboard.html', async () => { + stelle('pilot', [MAIL]) + assert.ok(istNeu(await rawGet('/neu/dashboard.html', { cookie: customerCookie('c1') }))) + for (const cookie of [customerCookie('c2'), undefined]) { + const r = await rawGet('/neu/dashboard.html', { cookie }) + assert.deepEqual([r.status, r.headers.get('location')], [302, '/dashboard.html']) + } +}) +test('andere Seiten bleiben unberührt', async () => { + stelle('pilot', ['*']) + for (const p of ['/login.html', '/manifest.webmanifest']) assert.equal((await rawGet(p, { cookie: customerCookie('c1') })).status, 200, p) +}) +test('server/index.js hängt die Weiche zwischen /kalkulation.html und express.static ein', () => { + const index = readFileSync(new URL('../server/index.js', import.meta.url), 'utf8') + const stelle_ = (s) => index.indexOf(s) + const reihe = ["app.use('/kalkulation.html'", "app.get('/dashboard.html', dashboardWeiche({ publicDir }))", "app.get('/neu/dashboard.html', neuDirektWeiche())", 'app.use(express.static(publicDir))'].map(stelle_) + assert.ok(reihe[0] > -1 && reihe.every((n, i) => i === 0 || n > reihe[i - 1]), reihe.join(',')) +}) +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag prüfen** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/kundenansichtDashboard.test.js` +Expected: FAIL mit `Cannot find module` auf `dashboardWeiche.js`. + +- [ ] **Step 3: Implementieren** + +- `dashboardWeiche.js` und `public/neu/dashboard.html` wie in den Interfaces. `res.set('Cache-Control', 'no-cache')` steht vor `sendFile`, damit Express den Header nicht überschreibt. Fehler von `sendFile` gehen an `next(err)`. +- `server/index.js`: Import `{ dashboardWeiche, neuDirektWeiche }` aus `./services/kundenansicht/dashboardWeiche.js` und direkt vor `app.use(express.static(publicDir))`: + ```js + // Kundenansicht neu: je Sitzung die neue oder die alte Seite (Schalter KUNDENANSICHT_NEU), vor den statischen Dateien + app.get('/dashboard.html', dashboardWeiche({ publicDir })) + app.get('/neu/dashboard.html', neuDirektWeiche()) + ``` + Der Kommentar enthält den Text `express.static(publicDir)` nicht (die Pins nutzen `indexOf`). Der tote alte Handler `app.get('/dashboard.html', …)` hinter `static` bleibt unverändert. + +- [ ] **Step 4: Tests grün, dazu die Pins** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/kundenansichtDashboard.test.js test/adminPanelGone.test.js test/legalDocs.test.js test/kundenansichtGuard.test.js` +Expected: PASS, `fail 0`. + +- [ ] **Step 5: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht +git add server/services/kundenansicht/dashboardWeiche.js public/neu/dashboard.html server/index.js test/kundenansichtDashboard.test.js +git commit -m "Kundenansicht neu: Dashboard-Weiche vor den statischen Dateien, Platzhalter-Seite" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: Feld `ansicht` in `/api/auth/me` + +**Files:** +- Modify: `server/routes/auth.js` (Import, `GET /me`: Antwort der Kundenansicht bei `:405`, Antwort des Kunden bei `:464`) +- Test: `test/kundenansichtMe.test.js` + +**Interfaces:** +- Consumes: `neueAnsichtFuerSession(session)` (Task 2). `/me` hat kein `req.session`, nur die lokale Variable `session`. +- Produces: `GET /api/auth/me` liefert in beiden `authenticated: true`-Antworten zusätzlich `ansicht: 'neu' | 'alt'`. Unauthentifizierte Antworten bleiben unverändert. + +- [ ] **Step 1: Failing test schreiben** (`test/kundenansichtMe.test.js`) + +Kopf wie `kundenansichtEnde.test.js:1-19`, `startTestApp({ '/': kundenansichtGuard, '/api/auth': authRoutes })`, Imports `rawGet`, `customers`, `customerCookie`, `kundenansichtCookie`, `adminCookie`, `seedFixtures`. Weil `/me` bei Kunden Status und Portalzugang prüft: + +```js +seedFixtures(aw) +for (const id of ['c1', 'c2']) { + aw.seed('customers', { ...customers[id], customerStatus: 'active' }) + aw.seed('customerPortalAccess', { $id: `pa_${id}`, customerId: id, enabled: true }) +} +const stelle = (mode, pilotEmails = []) => Object.assign(config.kundenansichtNeu, { mode, pilotEmails }) +const me = async (cookie) => (await rawGet('/api/auth/me', { cookie })).body +const ansichtVon = async (cookie) => (await me(cookie)).ansicht + +test('Kunde: Pilot-Liste, off mit Stern, on', async () => { + const f = [['pilot', ['k@example.invalid'], customerCookie('c1'), 'neu'], ['pilot', ['k@example.invalid'], customerCookie('c2'), 'alt'], + ['off', ['*'], customerCookie('c1'), 'alt'], ['on', [], customerCookie('c2'), 'neu']] + for (const [modus, liste, cookie, soll] of f) { stelle(modus, liste); assert.equal(await ansichtVon(cookie), soll, `${modus} ${liste}`) } +}) +test('Mitarbeiter-Kundenansicht: im Pilot neu, mit off alt; viewAs und viewId bleiben', async () => { + stelle('pilot', []); const b = await me(kundenansichtCookie({ viewId: 'c'.repeat(32) })) + assert.deepEqual([b.authenticated, b.viewAs, b.viewId, b.ansicht], [true, true, 'c'.repeat(32), 'neu']) + stelle('off', ['*']); assert.equal(await ansichtVon(kundenansichtCookie()), 'alt') +}) +test('Spitzenschlüssel bleiben; unauthentifizierte Antworten tragen kein ansicht', async () => { + stelle('on') + assert.deepEqual(Object.keys(await me(customerCookie('c1'))).sort(), ['ansicht', 'authenticated', 'customer', 'role']) // customer darf wachsen (P5) + assert.deepEqual(await me(), { authenticated: false }) + const abgelaufen = await me(kundenansichtCookie({ viewExpiresAt: Date.now() - 1000 })) + const alt = await me(adminCookie()) + assert.deepEqual([abgelaufen.reason, 'ansicht' in abgelaufen, alt.reason, 'ansicht' in alt], ['view_expired', false, 'staff', false]) +}) +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag prüfen** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/kundenansichtMe.test.js` +Expected: FAIL (`ansicht` ist `undefined`). + +- [ ] **Step 3: Implementieren** + +Import aus `../services/kundenansicht/schalter.js` und in beiden `res.json({ authenticated: true, … })` der Schlüssel `ansicht: neueAnsichtFuerSession(session) ? 'neu' : 'alt'`. Sonst nichts in `auth.js` ändern (`sanitizeCustomer` gehört P5). + +- [ ] **Step 4: Tests grün, dazu die Nachbarn** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/kundenansichtMe.test.js test/kundenansichtEnde.test.js test/staffLogin.test.js` +Expected: PASS, `fail 0`. + +- [ ] **Step 5: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht +git add server/routes/auth.js test/kundenansichtMe.test.js +git commit -m "Kundenansicht neu: /api/auth/me meldet ansicht neu oder alt" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 5: Werkzeug-Gerüst (Umgebung, Speicherbremse, Browser-Zugang) + +**Files:** +- Create in `KW`: `.gitignore` (`dist/ shots/ tmp/ sperren/ node_modules/ strang.env`), `package.json` (`private`, `type: module`, keine Abhängigkeiten), `env.sh`, `check.sh`, `run-browser.sh`, `browser.mjs` +- Test: `KW/test/geruest.test.mjs` + +**Interfaces:** +- Produces: + - `env.sh` (nur per `source`): exportiert `KW`, `WT` (Vorgabe `/home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht`, überschreibbar durch `KW/strang.env` bzw. die Datei aus `KN_STRANG_ENV`), `TMPDIR=KW/tmp` (wird angelegt), `FIXTURE_TODAY` (Vorgabe `2026-10-16`), `PLAYWRIGHT_CORE=/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs`, `CHROMIUM=/root/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome`. `return 2`, wenn `WT/server/index.js` fehlt. `pipefail` an, kein `cd`. + - `schwer_sperren`: `flock` auf `KW/sperren/schwer.lock` (Deskriptor 8), setzt `KN_SPERRE=1`. Ist `KN_SPERRE` gesetzt, kehrt sie sofort zurück. + - `speicher_pruefen`: liest `MemAvailable` aus `${KN_MEMINFO:-/proc/meminfo}`. Unter `${KN_MIN_FREI_MB:-2000}` MB: stderr „zu wenig freier Speicher, warten und den Controller melden“, Rückgabe 3. + - `dev_null_pruefen`: Rückgabe 4 mit Meldung, wenn `ls -l /dev/null` nicht mit `crw-rw-rw-` beginnt. + - `check.sh … | --alle`: arbeitet in `WT`. Ohne Argument, fehlende Datei oder unbekannte Option: Exit 2. Danach `speicher_pruefen || exit 3`, `schwer_sperren`. + - Dateien: `timeout 900 nice -n 10 systemd-run --scope -q -p MemoryMax=2000M -p MemorySwapMax=0 node --max-old-space-size=1024 --test --test-concurrency=1 `. + - `--alle`: `timeout 2400 nice -n 10 systemd-run --scope -q -p MemoryMax=2500M -p MemorySwapMax=0 env NODE_OPTIONS=--max-old-space-size=1024 node --test --test-concurrency=2 test/*.test.js`. + - Ausgabe: die Zeilen `# tests N`, `# pass N`, `# fail N` (beide Reporter-Formate, `ℹ ` wird zu `# `), Protokoll in `KW/tmp/test.log`. Exit 1 ohne Zählzeile, sonst der Exit des Laufs. Am Ende `dev_null_pruefen`. + - `run-browser.sh [args…]`: Exit 2 ohne oder mit fehlendem Skript (Pfad vor `source env.sh` auflösen). Dann `speicher_pruefen || exit 3`, `schwer_sperren`, `exec systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0 env NODE_OPTIONS=--max-old-space-size=1024 timeout 1800 node "$skript" "$@"`. + - `browser.mjs`: `export async function starteBrowser(extraArgs = []): Promise`. Chromium aus `CHROMIUM` mit `--no-sandbox --disable-dev-shm-usage --disable-gpu --host-resolver-rules=MAP * ~NOTFOUND , EXCLUDE 127.0.0.1`. Ohne `KN_SPERRE=1` Exit 2 mit „nur über run-browser.sh starten“. Fehlt Playwright-Core 1.62.1 oder `CHROMIUM`: Exit 2. + +- [ ] **Step 1: Ordner und Git anlegen, Failing test schreiben** (`KW/test/geruest.test.mjs`) + +Zuerst: `mkdir -p /root/reports/kundenansicht-neu-2026-10-15/werkzeug/test && git -C /root/reports/kundenansicht-neu-2026-10-15/werkzeug init -b main`. + +```js +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { spawn, spawnSync } from 'node:child_process' +import { mkdirSync, writeFileSync } from 'node:fs' +const KW = new URL('..', import.meta.url).pathname.replace(/\/$/, '') +const WT = '/home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht' +mkdirSync(`${KW}/tmp/fake-wt/server`, { recursive: true }) +const sh = (script, env = {}) => spawnSync('bash', ['-c', script], { encoding: 'utf8', env: { ...process.env, ...env }, timeout: 20000 }) + +test('env.sh setzt KW, WT, TMPDIR und den festen Tag', () => { + const r = sh(`source ${KW}/env.sh && printf '%s\\n' "$KW" "$WT" "$TMPDIR" "$FIXTURE_TODAY"`) + assert.deepEqual([r.status, r.stdout.trim().split('\n')], [0, [KW, WT, `${KW}/tmp`, '2026-10-16']]) +}) +test('strang.env überschreibt WT; ein WT ohne server/index.js ist Exit 2', () => { + writeFileSync(`${KW}/tmp/fake-wt/server/index.js`, '') + writeFileSync(`${KW}/tmp/strang-gut.env`, `export WT=${KW}/tmp/fake-wt\n`) + writeFileSync(`${KW}/tmp/strang-schlecht.env`, `export WT=${KW}/tmp\n`) + assert.equal(sh(`source ${KW}/env.sh && echo $WT`, { KN_STRANG_ENV: `${KW}/tmp/strang-gut.env` }).stdout.trim(), `${KW}/tmp/fake-wt`) + assert.equal(sh(`source ${KW}/env.sh`, { KN_STRANG_ENV: `${KW}/tmp/strang-schlecht.env` }).status, 2) +}) +test('speicher_pruefen: unter 2000 MB Exit 3 mit Meldung, genug ist 0', () => { + writeFileSync(`${KW}/tmp/meminfo-knapp`, 'MemAvailable: 1000000 kB\n') + writeFileSync(`${KW}/tmp/meminfo-genug`, 'MemAvailable: 4000000 kB\n') + const knapp = sh(`source ${KW}/env.sh && speicher_pruefen`, { KN_MEMINFO: `${KW}/tmp/meminfo-knapp` }) + assert.deepEqual([knapp.status, /zu wenig freier Speicher/.test(knapp.stderr)], [3, true]) + assert.equal(sh(`source ${KW}/env.sh && speicher_pruefen`, { KN_MEMINFO: `${KW}/tmp/meminfo-genug` }).status, 0) +}) +test('schwer_sperren ist wiedereintrittsfest und hält andere Prozesse draußen', async () => { + assert.match(sh(`source ${KW}/env.sh && schwer_sperren && schwer_sperren && echo ok`, { KN_SPERRE: '' }).stdout, /ok/) + const halter = spawn('bash', ['-c', `source ${KW}/env.sh && schwer_sperren && sleep 3`], { env: { ...process.env, KN_SPERRE: '' } }) + await new Promise((r) => setTimeout(r, 700)) + assert.equal(spawnSync('flock', ['-n', `${KW}/sperren/schwer.lock`, 'true']).status, 1) + await new Promise((r) => halter.on('exit', r)) +}) +test('Fehlbedienung von check.sh und run-browser.sh ist Exit 2', () => { + for (const cmd of [`${KW}/check.sh`, `${KW}/check.sh test/gibt-es-nicht.test.js`, `${KW}/check.sh --unbekannt`, `${KW}/run-browser.sh`, `${KW}/run-browser.sh ${KW}/tmp/gibt-es-nicht.mjs`]) + assert.equal(sh(cmd, { KN_SPERRE: '1' }).status, 2, cmd) +}) +test('starteBrowser ohne Sperre endet mit Exit 2 und startet nichts', () => { + const r = spawnSync('node', ['-e', `import('${KW}/browser.mjs').then((m) => m.starteBrowser())`], { encoding: 'utf8', env: { ...process.env, KN_SPERRE: '' } }) + assert.deepEqual([r.status, /run-browser\.sh/.test(r.stderr)], [2, true]) +}) +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag prüfen** + +Run: `cd /root/reports/kundenansicht-neu-2026-10-15/werkzeug && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/geruest.test.mjs` +Expected: FAIL (`env.sh` fehlt). + +- [ ] **Step 3: Implementieren** + +Dateien nach den Interfaces. Vorbild (nur lesen, nie ausführen): `env.sh`, `check.sh`, `run-browser.sh`, `browser.mjs` unter `/root/reports/ticketsystem-design-2026-10-04/werkzeug/`. Abweichungen stehen in den Interfaces. + +- [ ] **Step 4: Test grün, dann `check.sh` an einer echten Portal-Testdatei** + +Run: `cd /root/reports/kundenansicht-neu-2026-10-15/werkzeug && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/geruest.test.mjs` +Expected: PASS, `fail 0`. +Run: `/root/reports/kundenansicht-neu-2026-10-15/werkzeug/check.sh test/kundenansichtSchalter.test.js` +Expected: Exit 0 mit `# tests 7`, `# pass 7`, `# fail 0`. + +- [ ] **Step 5: Commit (Werkzeug-Git)** + +```bash +KW=/root/reports/kundenansicht-neu-2026-10-15/werkzeug +git -C $KW add .gitignore package.json env.sh check.sh run-browser.sh browser.mjs test/geruest.test.mjs +git -C $KW commit -m "Werkzeug: Umgebung, Speicherbremse, Sperre, Browser nur über run-browser.sh" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 6: Fälle und Testdaten-Backend + +**Files:** +- Create in `KW`: `fixtures/helfer.mjs`, `fixtures/basis.mjs`, `fixtures/faelle.mjs`, `fixtures/endpunkte.mjs`, `fixtures/index.mjs`, `backend.mjs` +- Test: `KW/test/backend.test.mjs` + +**Interfaces:** +- Produces: + - `helfer.mjs`: `heute(): Date` (UTC-Mitternacht aus `process.env.FIXTURE_TODAY`, sonst echter Tag, ungültiger Wert wirft „ungültig“), `jetzt(): Date` (fester Tag 12:00 UTC, sonst `new Date()`), `tag(offset): Date`, `ymd(d): string`, `iso(offset, hhmm = '12:00'): string`. + - `faelle.mjs`: `FALL_NAMEN: string[]`, `ladeFall(name): Fall` (tiefe Kopie, unbekannter Name wirft). `Fall = { name, titel, sitzung: 'kunde'|'kundenansicht'|'keine', schreibsperre: boolean, fehler: string[], daten: { [endpunktname]: json } }`. + - Die ersten vierzehn Einträge von `FALL_NAMEN` sind die Kernfälle der Tabelle unten, in genau dieser Reihenfolge. Das ist der Vertrag von P0. + - Weitere Fälle hängen spätere Pakete hinter die Kernfälle an, jedes Paket in einer eigenen Datei (P4 `fixtures/module.mjs`, P7 `fixtures/faelle-p7.mjs`, sonst `fixtures/faelle-p.mjs`) mit genau einer Importzeile in `faelle.mjs`. Der Pin im Test und `boot.js` bleiben dabei unberührt (siehe Test und Task 11). + - `endpunkte.mjs`: `ENDPUNKTE: Array<{ name, methode, muster, schreibend?: true, antwort?(ctx): { status?, json?, text?, type? } }>`. `muster` kennt `:param`. `ctx = { fall, zustand, params, query, body, methode, pfad }`. Ohne `antwort` liefert ein GET `ctx.zustand[name]`. `index.mjs` re-exportiert `FALL_NAMEN`, `ladeFall`, `ENDPUNKTE`. + - `backend.mjs`: `createBackend({ fixtures, fall }): { handle(anfrage), calls, violations, zustand }` mit `anfrage = { method, url, headers?, body? }` und Antwort `{ status, headers, body: string }`. Pfade außerhalb von `/api/` ergeben `null`. + - Unbekannter `/api/*`-Pfad: 404 `{ error, code: 'vorschau_unbekannt' }` und Verstoß `{ kind: 'unknown-api', method, pfad }`. Ausnahme im Handler: 500 und `{ kind: 'backend-error', … }`. + - Sitzung `keine`: alles außer `auth.me`, `auth.login`, `auth.login-status`, `auth.logout` ergibt 401 `{ error: 'Nicht angemeldet' }`, bis `auth.login` erfolgreich war. + - `schreibsperre`: jedes Nicht-GET außer `auth.ende` und `auth.logout` ergibt 403 `{ code: 'view_blocked', error: 'In der Kundenansicht nicht möglich.' }`. + - `fehler`: die genannten Endpunkte ergeben 500 `{ error: 'Test: absichtlicher Fehler' }`. + - Schreibende Endpunkte ändern nur `zustand` (Kopie je Backend). Checkout- und Wechsel-Antworten tragen `url: '/vorschau-hinweis.html'`. + - Antworten tragen `content-type` (JSON mit `charset=utf-8`) und `cache-control: no-store`. + +Endpunkte (Formen aus dem Code geprüft: `routes/customerData.js`, `projects.js`, `chat.js`, `features.js`, `auth.js`, Karte B4): + +| Name | Methode Muster | Form | +|---|---|---| +| `auth.me` | GET `/api/auth/me` | wie `auth.js` plus `ansicht: 'neu'`, `customer.kundenart`, `customer.kundenartWirksam` (V4) | +| `auth.login`, `auth.login-status`, `auth.logout`, `auth.password`, `auth.ende` | `/api/auth/login`, `/login-status`, `/logout`, `/password`, `/kundenansicht/ende` | `{ success, role, customer }`, `{ blocked, retryAfterSeconds }`, `{ success }`, `{ success }`, `{ redirect }` | +| `contact`, `activity`, `invoices`, `auftraege`, `avatar` | GET `/api/contact`, `/activity`, `/invoices`, `/auftraege`, `/avatar.svg` | `{ name, responseTime }`, `{ tickets }`, `{ invoices, incomplete }` (Beträge in Euro), `{ aktiv, auftraege }`, SVG-Text | +| `projects`, `projects.git`, `projects.files`, `projects.work` | GET `/api/projects[/:id/git\|files\|work]` | `{ projects }`, `{ changes }`, `{ totalFiles, totalSizeBytes, truncated, topLevel }`, `{ order, entries, totalMinutes }` | +| `features`, `chat.unread`, `chat.messages`, `chat.read`, `chat.send` | `/api/features`, `/api/chat/unread-count`, `/messages` (GET, POST), `/read` | `{ features }` (nur `enabled`), `{ unread }`, `{ messages, unread }`, `{ updated }`, `{ message }` | +| `abo`, `abo.takeover`, `abo.checkout`, `abo.switch-preview`, `abo.switch`, `abo.storage-preview`, `abo.storage`, `abo.public-cancel`, `abo.public-withdraw` | `/api/abo/…` | `abo`: Form von `GET /api/abo`; `takeover`: `{ requests, conditionsVersion, limits }`; Schreibende: `{ url }` bzw. `{ ok: true, receivedAt }` | +| `funktionen`, `funktionen.checkout`, `funktionen.zeiten-einschalten` | `/api/funktionen[/…]` | Form von `GET /api/funktionen` plus V5 (`modell`, `kundenart`, `abo`, `katalog` samt `katalog[].consent` und `katalog[].hinweis`, siehe unter den Fällen); `anfrageStatus` steht in `projects[].features.` (Vertragsfrage 9) | +| `termine`, `shop`, `unterkunft`, `zeiten`, `news` | GET `/api/termine/einstellungen`, `/api/shop/status`, `/api/unterkunft/status`, `/api/zeiten/:projectId`, `/api/news` | Formen aus Karte B4 | +| `fristen` | GET `/api/fristen` | V6 `{ fristen, fruehestesEnde, anzeigen }` | + +Fälle, in dieser Reihenfolge als `FALL_NAMEN`; `faelle.mjs` leitet sie aus `basis()` ab. Basis = „Betrieb mit Wartung“: Kundin `Berta Berg`, `Bäckerei Berg`, `berta@example.invalid`, Wartung seit 40 Tagen mit `ledger.balanceMinutes: 120`, ein Projekt `Bäckerei Berg` mit `previewUrl: '/vorschau/demo-baeckerei.html'`, zwei bezahlte Rechnungen, News aktiv, leere `fristen`. + +| Fall | Abweichung von der Basis | +|---|---| +| `betrieb-wartung` | Basis | +| `hosting` | `subscription.productKey: 'hosting'`, `ledger: null` | +| `ohne-abo` | `subscription: null`, `ledger: null`, `funktionen.abo.produkt: null` | +| `privat-frist` | `kundenart: 'privat'`, Abo seit 5 Tagen, `withdrawals: [{ type: 'abschluss', until: heute+9 }]`, `withdrawalOpen: true`, `fristen.anzeigen: true`, `fruehestesEnde: heute+9` | +| `kundenart-leer` | `kundenart: ''`, `kundenartWirksam: 'privat'`, sonst wie `privat-frist` | +| `rechnung-offen` | eine Rechnung `RE-2026-0042` mit `amount: 50`, `balance: 50` | +| `chat-ungelesen` | `unread: 3`, drei Teamnachrichten | +| `funktionen-alle` | zwei Projekte mit allen Zuständen (`off`, `ordered`, `setup`, `active`, `disabled`) und allen `anfrageStatus` (`offen`, `angenommen`, `abgelehnt`, `zurueckgezogen`); Katalog mit Analyse als sechstem Eintrag | +| `vorschuss` | `ledger.balanceMinutes: 0`, `vorschussMinutes: 150`, erste `usage` mit `einrichtung: true` (V8) | +| `mehrere-websites` | drei Projekte | +| `mitarbeiter` | `sitzung: 'kundenansicht'`, `schreibsperre: true`, `auth.me` als Kundenansicht (`viewAs`, `viewId` aus 32 mal `a`) | +| `abgemeldet` | `sitzung: 'keine'` | +| `fehler` | `fehler: ['abo', 'invoices', 'funktionen']` | +| `leer` | keine Projekte, Rechnungen, Features, Nachrichten, kein Abo | + +**Katalog in `funktionen` (`basis()`, gilt für jeden Fall).** Zusätzlich zu den Feldern aus V5 trägt jeder Katalogeintrag das Zustimmungs-Register, das P5 liefert und P3 (Knopf „Einschalten“, Gate-Ansichten `kn-funktionen-einschalten*`) und P4 (Zusammenfassung) verlangen. Fehlt es, bleibt der Knopf gesperrt, und die Gates dieser Pakete können nicht grün werden. +- Jeder Eintrag außer `analyse` trägt `consent: { version: '2026-10-20', knopf: 'Einschalten', items: [ { id: 'einschalten-zeile', text: 'Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung 2026-10-05).', haken: false }, { id: 'beginn', text: 'Bitte beginnt sofort mit der Einrichtung. Mir ist klar: Mit der vollständigen Einrichtung erlischt mein Widerrufsrecht, und wenn ich vorher widerrufe, zahle ich den bis dahin erbrachten Teil.', haken: true, nurPrivatInFrist: true } ] }`. Bei `analyse` steht `consent: null` (die Analyse nutzt `POST /api/funktionen/analyse/activate`). +- `termine`, `shop` und `unterkunft` tragen zusätzlich `hinweis` (ein Satz, du-Form, Testtext, zum Beispiel `Du bist für deinen Webshop und die Zahlungen selbst verantwortlich.`). `news`, `zeiten` und `analyse` tragen kein Feld `hinweis`. +- Alles bleibt Testtext. Der echte Wortlaut kommt von P8 über den Server. + +- [ ] **Step 1: Failing test schreiben** (`KW/test/backend.test.mjs`) + +```js +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync, readdirSync } from 'node:fs' +process.env.FIXTURE_TODAY = '2026-10-16' +const { createBackend } = await import('../backend.mjs') +const fixtures = await import('../fixtures/index.mjs') +const { FALL_NAMEN, ladeFall } = fixtures +const neu = (fall) => createBackend({ fixtures, fall }) +async function ruf(b, method, pfad, body) { + const r = await b.handle({ method, url: `https://vorschau.test${pfad}`, headers: {}, body: body === undefined ? null : JSON.stringify(body) }) + let json = null + try { json = JSON.parse(r.body) } catch { /* kein JSON */ } + return { status: r.status, json, headers: r.headers } +} +const zustaende = (f) => [...new Set(f.projects.flatMap((p) => Object.values(p.features).map((x) => x.state)))].sort() +const anfragen = (f) => [...new Set(f.projects.flatMap((p) => Object.values(p.features).map((x) => x.anfrageStatus).filter(Boolean)))].sort() + +test('die vierzehn Kernfälle stehen vorn in fester Reihenfolge; jeder Name lädt; unbekannter Fall wirft', () => { + // Pin nur auf den Anfang: spätere Pakete hängen eigene Fälle hinten an (README, Regel „Neue Fälle“). Diesen Pin nie anpassen. + assert.deepEqual(FALL_NAMEN.slice(0, 14), ['betrieb-wartung', 'hosting', 'ohne-abo', 'privat-frist', 'kundenart-leer', 'rechnung-offen', 'chat-ungelesen', 'funktionen-alle', 'vorschuss', 'mehrere-websites', 'mitarbeiter', 'abgemeldet', 'fehler', 'leer']) + assert.equal(new Set(FALL_NAMEN).size, FALL_NAMEN.length) + for (const n of FALL_NAMEN) assert.doesNotThrow(() => ladeFall(n), n) + assert.throws(() => ladeFall('gibt-es-nicht')) +}) +test('jeder Fall beantwortet die Startaufrufe mit dem richtigen Status und ohne Verstoß', async () => { + const START = ['/api/auth/me', '/api/contact', '/api/abo', '/api/invoices', '/api/auftraege', '/api/activity', '/api/projects', '/api/features', '/api/chat/unread-count', '/api/fristen', '/api/funktionen'] + for (const fall of FALL_NAMEN) { + const b = neu(fall) + for (const p of START) { + const soll = fall === 'abgemeldet' && p !== '/api/auth/me' ? 401 : fall === 'fehler' && ['/api/abo', '/api/invoices', '/api/funktionen'].includes(p) ? 500 : 200 + assert.equal((await ruf(b, 'GET', p)).status, soll, `${fall} ${p}`) + } + assert.deepEqual(b.violations, [], fall) + } +}) +const WERTE = [ // [Fall, Pfad, Auswahl aus der JSON-Antwort, erwarteter Wert] + ['betrieb-wartung', '/api/auth/me', (j) => [j.ansicht, j.customer.name, j.customer.companyName, j.customer.kundenart, j.customer.kundenartWirksam], ['neu', 'Berta Berg', 'Bäckerei Berg', 'unternehmen', 'unternehmen']], + ['betrieb-wartung', '/api/abo', (j) => [j.subscription.productKey, j.ledger.balanceMinutes], ['wartung', 120]], + ['betrieb-wartung', '/api/fristen', (j) => j, { fristen: [], fruehestesEnde: null, anzeigen: false }], + ['betrieb-wartung', '/api/projects', (j) => j.projects.length, 1], + ['betrieb-wartung', '/api/funktionen', (j) => j.katalog.map((k) => k.key), ['news', 'termine', 'shop', 'unterkunft', 'zeiten']], + ['hosting', '/api/abo', (j) => [j.subscription.productKey, j.ledger], ['hosting', null]], + ['ohne-abo', '/api/abo', (j) => j.subscription, null], + ['privat-frist', '/api/fristen', (j) => [j.anzeigen, j.fruehestesEnde.slice(0, 10), j.fristen[0].quelle], [true, '2026-10-25', 'abschluss']], + ['privat-frist', '/api/abo', (j) => [j.subscription.withdrawalOpen, j.subscription.withdrawals[0].type, j.subscription.withdrawals[0].until.slice(0, 10)], [true, 'abschluss', '2026-10-25']], + ['kundenart-leer', '/api/auth/me', (j) => [j.customer.kundenart, j.customer.kundenartWirksam], ['', 'privat']], + ['rechnung-offen', '/api/invoices', (j) => j.invoices.filter((i) => i.balance > 0).map((i) => [i.number, i.balance]), [['RE-2026-0042', 50]]], + ['chat-ungelesen', '/api/chat/unread-count', (j) => j.unread, 3], + ['vorschuss', '/api/abo', (j) => [j.ledger.balanceMinutes, j.ledger.vorschussMinutes, j.ledger.usage[0].einrichtung], [0, 150, true]], + ['funktionen-alle', '/api/funktionen', (j) => [zustaende(j), anfragen(j), j.katalog.map((k) => k.key).at(-1)], [['active', 'disabled', 'off', 'ordered', 'setup'], ['abgelehnt', 'angenommen', 'offen', 'zurueckgezogen'], 'analyse']], + ['mehrere-websites', '/api/projects', (j) => j.projects.length, 3], + ['mitarbeiter', '/api/auth/me', (j) => [j.viewAs, j.viewId.length], [true, 32]], + ['abgemeldet', '/api/auth/me', (j) => j, { authenticated: false }], + ['fehler', '/api/abo', (j) => j, { error: 'Test: absichtlicher Fehler' }], + ['leer', '/api/projects', (j) => j, { projects: [] }], + ['leer', '/api/invoices', (j) => j, { invoices: [], incomplete: false }], +] +for (const [fall, pfad, wahl, soll] of WERTE) test(`${fall} ${pfad}`, async () => assert.deepEqual(wahl((await ruf(neu(fall), 'GET', pfad)).json), soll)) + +test('Katalog trägt das Zustimmungs-Register (V5): consent außer bei analyse, hinweis nur bei termine, shop, unterkunft', async () => { + for (const fall of ['betrieb-wartung', 'funktionen-alle', 'privat-frist']) { + const { katalog } = (await ruf(neu(fall), 'GET', '/api/funktionen')).json + for (const k of katalog) { + if (k.key === 'analyse') { assert.equal(k.consent, null, `${fall} analyse`); continue } + assert.deepEqual([k.consent.knopf, k.consent.items.map((i) => i.id), k.consent.items.map((i) => i.haken), k.consent.items[1].nurPrivatInFrist], ['Einschalten', ['einschalten-zeile', 'beginn'], [false, true], true], `${fall} ${k.key}`) + assert.ok(typeof k.consent.version === 'string' && k.consent.items.every((i) => i.text.length > 10), `${fall} ${k.key}`) + } + assert.deepEqual(katalog.filter((k) => Object.hasOwn(k, 'hinweis')).map((k) => k.key), ['termine', 'shop', 'unterkunft'], fall) + assert.ok(katalog.filter((k) => Object.hasOwn(k, 'hinweis')).every((k) => k.hinweis.length > 10 && !k.hinweis.includes('\n')), fall) + } +}) + +test('Schreiben ändert nur den Zustand dieses Backends', async () => { + const b = neu('chat-ungelesen') + await ruf(b, 'POST', '/api/chat/read', {}) + assert.equal((await ruf(b, 'GET', '/api/chat/unread-count')).json.unread, 0) + assert.equal((await ruf(neu('chat-ungelesen'), 'GET', '/api/chat/unread-count')).json.unread, 3) +}) +test('mitarbeiter: Schreibsperre mit view_blocked, Beenden erlaubt; abgemeldet: erst Login macht die Sitzung', async () => { + const m = neu('mitarbeiter') + const r = await ruf(m, 'POST', '/api/chat/messages', { text: 'hallo' }) + assert.deepEqual([r.status, r.json.code, (await ruf(m, 'POST', '/api/auth/kundenansicht/ende', {})).status], [403, 'view_blocked', 200]) + const a = neu('abgemeldet') + assert.equal((await ruf(a, 'GET', '/api/abo')).status, 401) + assert.equal((await ruf(a, 'POST', '/api/auth/login', { email: 'x@example.invalid', password: 'egal' })).json.success, true) + assert.equal((await ruf(a, 'GET', '/api/auth/me')).json.authenticated, true) +}) +test('unbekannte API-Pfade: JSON-404 samt Verstoß; Nicht-API ist null; Checkout nie zu Stripe', async () => { + const b = neu('betrieb-wartung') + const r = await ruf(b, 'GET', '/api/gibt-es-nicht') + assert.deepEqual([r.status, r.json.code, b.violations], [404, 'vorschau_unbekannt', [{ kind: 'unknown-api', method: 'GET', pfad: '/api/gibt-es-nicht' }]]) + assert.equal(await b.handle({ method: 'GET', url: 'https://vorschau.test/login.html' }), null) + assert.equal((await ruf(b, 'POST', '/api/abo/checkout', {})).json.url, '/vorschau-hinweis.html') +}) +test('Fixtures enthalten nur Erfundenes: .invalid-Adressen, keine fremden Hosts', () => { + for (const name of FALL_NAMEN) { + const text = JSON.stringify(ladeFall(name)) + for (const m of text.matchAll(/[\w.+-]+@([\w.-]+)/g)) assert.match(m[1], /\.invalid$/, `${name}: ${m[0]}`) + for (const m of text.matchAll(/https?:\/\/[^"\\]+/g)) assert.match(m[0], /\.invalid(\/|$)/, `${name}: ${m[0]}`) + } +}) +test('heute ist fest, ein ungültiger Tag wirft; Backend und Fixtures importieren nichts aus node:', async () => { + const { heute } = await import('../fixtures/helfer.mjs') + assert.equal(heute().toISOString().slice(0, 10), '2026-10-16') + process.env.FIXTURE_TODAY = 'morgen'; assert.throws(() => heute(), /ungültig/); process.env.FIXTURE_TODAY = '2026-10-16' + for (const f of ['backend.mjs', ...readdirSync(new URL('../fixtures/', import.meta.url)).map((n) => `fixtures/${n}`)]) assert.ok(!/from\s+['"]node:|require\(/.test(readFileSync(new URL(`../${f}`, import.meta.url), 'utf8')), f) +}) +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag prüfen** + +Run: `cd /root/reports/kundenansicht-neu-2026-10-15/werkzeug && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/backend.test.mjs` +Expected: FAIL mit `Cannot find module` auf `backend.mjs`. + +- [ ] **Step 3: Implementieren** + +Dateien nach Interfaces und Tabellen. Routing: Muster in einen Regex übersetzen, die erste Übereinstimmung von Methode und Pfad gewinnt. `basis()` baut jede Antwort in der Form der echten Router (Quellen in der Tabelle). Die Endpunkte, die erst V5, V6, V8 einführen, folgen wörtlich der Übersicht, der Katalog zusätzlich dem Absatz „Katalog in `funktionen`“ (V5 kennt `consent` und `hinweis` nicht, P5 liefert sie). Alle Zeitangaben laufen über `helfer.mjs`. + +- [ ] **Step 4: Test grün** + +Run: wie Step 2. Expected: PASS, `fail 0`. + +- [ ] **Step 5: Commit (Werkzeug-Git)** + +```bash +KW=/root/reports/kundenansicht-neu-2026-10-15/werkzeug +git -C $KW add fixtures/helfer.mjs fixtures/basis.mjs fixtures/faelle.mjs fixtures/endpunkte.mjs fixtures/index.mjs backend.mjs test/backend.test.mjs +git -C $KW commit -m "Werkzeug: vierzehn Fälle und Testdaten-Backend für alle Portal-Endpunkte" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 7: Vertragstest der Fixtures gegen die echten Router + +**Files:** +- Create in `KW`: `vertrag-endpunkte.mjs` +- Test: `KW/test/vertrag.test.mjs` + +**Interfaces:** +- Consumes: `ladeFall`, `ENDPUNKTE`, `createBackend` (Task 6); Portal-Module per absolutem Import aus `WT` (`process.env.WT`, sonst der Vorgabewert): `test/support/{appwriteMock,fakeStripe,testEnv,testApp,fixtures,schema}.js`, `server/routes/`. +- Produces: + - `pfade(wert, p = '', aus = new Set()): Set`: Schlüsselpfade (`.a.b`, `.liste[]`, `.liste[].x`). + - `vertrag-endpunkte.mjs`: `VERTRAG: Array<{ name, fall, router /*Datei unter server/routes*/, mount, anfrage: { pfad, cookie: 'kunde'|'kundenansicht' }, schema: string[] /*schemaKeys-Namen*/, seed?(aw, fx), vorher?(config): () => void /*gibt die Rücksetz-Funktion zurück*/, nurImEchten: string[], nurInFixture: string[], vertragNeu: string[] }>` und `NICHT_PRUEFBAR: Array<{ name, grund }>`. In den drei Listen steht `*` für beliebige Zeichen. **Jeder Eintrag trägt einen Kommentar mit dem Grund.** + - Erste Welle: `auth.me` (Kunde, Seeds wie Task 4), `auth.me` als Kundenansicht (Fall `mitarbeiter`), `contact`, `projects` (ein `websiteProjects`-Dokument), `features` (ein `portalFeatures`-Dokument mit `enabled`), `chat.unread`, `chat.messages` (eine Nachricht), `abo` (Fall `betrieb-wartung`, `wartungDoc`), `funktionen` (`vorher` setzt `config.termine.mode`, `config.shop.mode`, `config.unterkunft.mode`, `config.zeiten.mode` auf `'on'`, damit die echte Antwort die Obermenge der Schlüssel zeigt). + - `vertragNeu` enthält, was die Übersicht in V4 bis V8 als neu nennt und der Server noch nicht liefert (`.customer.kundenart*`, `.modell`, `.abo*`, `.katalog*` mit ausdrücklich `.katalog[].consent*` und `.katalog[].hinweis`, die V5 nicht nennt und P5 liefert, `.projects[].features.*.anfrageStatus`, `.projects[].features.*.unlockedBy`, `.ledger.vorschussMinutes`, `.ledger.usage[].einrichtung*`). Sobald P5 und P6 gemergt sind, meldet der Test diese Ausnahmen als überholt, und sie werden gestrichen. Dann prüft er die Schlüssel gegen den echten Server. + - `NICHT_PRUEFBAR` mit Grund: `invoices` (Stripe, InvoiceNinja), `auftraege` (Schalter), `activity` (Elementform braucht Ticket- und Arbeitsblatt-Seeds, P1 ergänzt), `projects.git`, `projects.files` (Gitea), `projects.work`, `abo.takeover`, `termine`, `shop`, `unterkunft`, `zeiten`, `news` (P4 nimmt sie auf), `fristen` (Route entsteht erst mit P5), `avatar` (SVG), alle schreibenden Endpunkte und die `auth.*`-Schreibenden. + +- [ ] **Step 1: Failing test schreiben** (`KW/test/vertrag.test.mjs`) + +Aufbau wie `kundenansichtGuard.test.js:1-30`: Mock mit der Vereinigung aller `schema`-Namen, `startFakeStripe`, `useTestEnv`, Imports per `import(\`${WT}/…\`)`. Eine Test-App mit allen Routern (`startTestApp(Object.fromEntries(VERTRAG.map((e) => [e.mount, router])))`). `fx` ist das Modul `${WT}/test/support/fixtures.js` (`customerCookie`, `kundenansichtCookie`, `seedFixtures`, `wartungDoc`), `config` das Portal-`config`, `rawGet` aus `testApp.js`, `pfade`, `VERTRAG`, `NICHT_PRUEFBAR` aus `vertrag-endpunkte.mjs`, `ENDPUNKTE` und `createBackend` aus dem Werkzeug. + +```js +const passt = (muster, p) => new RegExp(`^${muster.replace(/[.[\]]/g, '\\$&').replace(/\*/g, '.*')}$`).test(p) +const erlaubt = (liste, p) => liste.some((m) => passt(m, p)) +const cookies = { kunde: customerCookie('c1'), kundenansicht: kundenansichtCookie() } +for (const e of VERTRAG) test(`Vertrag ${e.name} (${e.fall})`, async () => { + await e.seed?.(aw, fx) + const zurueck = e.vorher?.(config) + const echtRes = await rawGet(e.anfrage.pfad, { cookie: cookies[e.anfrage.cookie] }) + zurueck?.() + assert.equal(echtRes.status, 200, e.name) + const fixRes = await createBackend({ fixtures, fall: e.fall }).handle({ method: 'GET', url: `https://vorschau.test${e.anfrage.pfad}` }) + const echt = pfade(echtRes.body); const fix = pfade(JSON.parse(fixRes.body)) + const neuErlaubt = [...e.nurInFixture, ...e.vertragNeu] + assert.deepEqual([...echt].filter((p) => !fix.has(p) && !erlaubt(e.nurImEchten, p)), [], 'echte Schlüssel fehlen in der Fixture') + assert.deepEqual([...fix].filter((p) => !echt.has(p) && !erlaubt(neuErlaubt, p)), [], 'Fixture erfindet Schlüssel') + const verwaist = [...e.nurImEchten.filter((m) => ![...echt].some((p) => !fix.has(p) && passt(m, p))), ...neuErlaubt.filter((m) => ![...fix].some((p) => !echt.has(p) && passt(m, p)))] + assert.deepEqual(verwaist, [], 'Ausnahme deckt keine Abweichung (mehr): streichen') +}) +test('jeder Endpunkt der Fixtures ist geprüft oder begründet ausgenommen', () => { + const bekannt = new Set([...VERTRAG.map((e) => e.name), ...NICHT_PRUEFBAR.map((e) => e.name)]) + assert.deepEqual(ENDPUNKTE.filter((e) => !bekannt.has(e.name)).map((e) => e.name), []) + for (const e of NICHT_PRUEFBAR) assert.ok(e.grund.length > 10, e.name) +}) +test('pfade(): Objekte, Arrays, Blätter', () => { + assert.deepEqual([...pfade({ a: { b: 1 }, l: [{ x: 1 }, { y: 2 }], leer: [] })].sort(), ['.a', '.a.b', '.l', '.l[]', '.l[].x', '.l[].y', '.leer', '.leer[]']) +}) +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag prüfen** + +Run: `cd /root/reports/kundenansicht-neu-2026-10-15/werkzeug && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/vertrag.test.mjs` +Expected: FAIL mit `Cannot find module` auf `vertrag-endpunkte.mjs`. + +- [ ] **Step 3: Implementieren** + +`pfade` rekursiv: Arrays fügen `${p}[]` hinzu und vereinigen ihre Elemente, Objekte fügen `${p}.${key}` hinzu. `vertrag-endpunkte.mjs` mit den Einträgen der ersten Welle. Zeigt der Test Abweichungen, korrigiere zuerst die **Fixture**. Ausnahmen nur mit Grund (zum Beispiel „Elementform braucht Seed“). Echte Aufrufe und Seeds richten sich nach `test/aboRoutes.test.js:1-45`, `test/funktionenRoutes.test.js:1-45` und Task 4. + +- [ ] **Step 4: Test grün** + +Run: wie Step 2. Expected: PASS, `fail 0`. Prüfe von Hand: Jede Ausnahmezeile hat einen Kommentar mit Grund. + +- [ ] **Step 5: Commit (Werkzeug-Git)** + +```bash +KW=/root/reports/kundenansicht-neu-2026-10-15/werkzeug +git -C $KW add vertrag-endpunkte.mjs test/vertrag.test.mjs +git -C $KW commit -m "Werkzeug: Vertragstest der Fixtures gegen die echten Portal-Router" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 8: Prüfungen und Beobachter + +**Files:** +- Create in `KW`: `checks.mjs`, `konsole.mjs` +- Test: `KW/test/checks.selftest.mjs` (Browser, läuft mit `run-browser.sh`) + +**Interfaces:** +- Consumes: `starteBrowser()` (Task 5). +- Produces: + - `checks.mjs`: `SEITEN_PRUEFUNGEN = ['dark-surface', 'low-contrast', 'overflow-x', 'lila']`, `runChecks(seite, { ignorieren = {} } = {}): Promise>`. `ignorieren` bildet Prüfname auf CSS-Selektoren ab. Treffer in oder unter einem solchen Element bleiben im Ergebnis (`ignored: true`). + - `dark-surface`: sichtbares Element mit eigenem Hintergrund (Alpha ab 0,5) und relativer Helligkeit unter 0,5. Ausgenommen: Border-Boxen bis 12 mal 12 px und die Farbe `rgb(17, 17, 17)` (`#111`, Akzent) bei Elementen bis 25 % der Viewport-Fläche. + - `low-contrast`: Textkontrast unter 3:1 gegen den nächsten deckenden Hintergrund. Ausgenommen: `disabled`, `aria-disabled`, Platzhalter. + - `overflow-x`: `documentElement.scrollWidth > documentElement.clientWidth + 1`. + - `lila`: sichtbares Element mit berechneter Farbe (Text, Hintergrund, Rand, Umriss, Schatten, `fill`, `stroke`) `rgb(157, 61, 254)` oder `rgba(157, 61, 254, …)`; dazu der Text aller geladenen Stylesheets mit `#9d3dfe` oder `rgba(157,61,254` (Leerzeichen und Groß-/Kleinschreibung egal), auch in ungenutzten Regeln. + - `konsole.mjs`: `beobachte(seite, { fall = '' } = {}): { ende(): { 'console-error': string[], 'external-request': string[] } }`. Sie sammelt `console.error` und `pageerror` (gefiltert durch `erwartetKonsole`) und jede Anfrage mit Schema `http(s)` oder `ws(s)`, deren Host nicht `127.0.0.1` ist (Detail = URL). `erwartetKonsole(text, fall): boolean` ist wahr für Chromiums „Failed to load resource: the server responded with a status of 500“ nur im Fall `fehler`. + +- [ ] **Step 1: Failing test schreiben** (`KW/test/checks.selftest.mjs`; ein Browser in `before`, in `after` geschlossen) + +```js +import { test, before, after } from 'node:test' +import assert from 'node:assert/strict' +import { starteBrowser } from '../browser.mjs' +import { runChecks } from '../checks.mjs' +import { beobachte, erwartetKonsole } from '../konsole.mjs' +let browser, seite +before(async () => { browser = await starteBrowser(); seite = await (await browser.newContext({ viewport: { width: 390, height: 844 } })).newPage(); seite.setDefaultTimeout(180000) }) +after(async () => { await browser?.close() }) +const KOPF = '' +const FAELLE = [ // [Prüfung, Name, HTML, schlägt an] + ['dark-surface', 'dunkle Fläche', '
', true], + ['dark-surface', '#111 als kleiner Knopf', '', false], + ['dark-surface', '#111 über die ganze Seite', '
', true], + ['low-contrast', 'hellgrauer Text', '

Hallo Welt

', true], + ['low-contrast', 'Zweittext #5e6678', '

Hallo Welt

', false], + ['overflow-x', 'zu breit', '
', true], + ['overflow-x', 'passt', '
', false], + ['lila', 'Textfarbe', '

x

', true], + ['lila', 'Schatten in unbenutzter Regel', '

x

', true], + ['lila', 'neutrales Blau', '

x

', false], +] +for (const [check, name, html, schlaegtAn] of FAELLE) test(`${check}: ${name}`, async () => { + await seite.setContent(KOPF + html) + const treffer = (await runChecks(seite)).filter((t) => t.check === check && !t.ignored) + assert.equal(treffer.length > 0, schlaegtAn, JSON.stringify(treffer)) +}) +test('ignorieren markiert Treffer, statt sie zu löschen', async () => { + await seite.setContent(KOPF + '
') + const t = (await runChecks(seite, { ignorieren: { 'dark-surface': ['#x'] } })).filter((r) => r.check === 'dark-surface') + assert.ok(t.length > 0 && t.every((r) => r.ignored === true)) +}) +test('console-error zählt; der absichtliche 500er ist nur im Fall fehler erwartet', async () => { + const text = 'Failed to load resource: the server responded with a status of 500 (Internal Server Error)' + assert.deepEqual([erwartetKonsole(text, 'fehler'), erwartetKonsole(text, 'betrieb-wartung')], [true, false]) + const b = beobachte(seite, { fall: 'betrieb-wartung' }) + await seite.evaluate(() => console.error('boom')) + assert.equal(b.ende()['console-error'].length, 1) +}) +test('external-request meldet fremde Hosts', async () => { + await seite.route('**/*', (r) => r.abort()) + const b = beobachte(seite) + await seite.setContent(KOPF + '') + await seite.waitForTimeout(300) + assert.deepEqual(b.ende()['external-request'], ['https://bild.example.invalid/x.png']) + await seite.unroute('**/*') +}) +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag prüfen** + +Run: `free -m`, dann `/root/reports/kundenansicht-neu-2026-10-15/werkzeug/run-browser.sh /root/reports/kundenansicht-neu-2026-10-15/werkzeug/test/checks.selftest.mjs` +Expected: FAIL mit `Cannot find module` auf `checks.mjs`. + +- [ ] **Step 3: Implementieren** + +Nach den Interfaces. Vorbild (nur lesen): `checks.mjs`, `konsole.mjs` im WOMS-Werkzeug. Die Farbrechnung läuft in `page.evaluate` und benutzt nichts aus dem Modul. `lila` ist neu. + +- [ ] **Step 4: Test grün** + +Run: wie Step 2. Expected: PASS, `fail 0`; danach `ls -l /dev/null` mit `crw-rw-rw-`. + +- [ ] **Step 5: Commit (Werkzeug-Git)** + +```bash +KW=/root/reports/kundenansicht-neu-2026-10-15/werkzeug +git -C $KW add checks.mjs konsole.mjs test/checks.selftest.mjs +git -C $KW commit -m "Werkzeug: Seitenprüfungen (dunkel, Kontrast, Überlauf, Lila), Konsole und fremde Requests" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 9: Ansichten, Geräte und Gates + +**Files:** +- Create in `KW`: `views.mjs`, `gates.mjs` +- Test: `KW/test/gates.test.mjs` + +**Interfaces:** +- Consumes: `SEITEN_PRUEFUNGEN` (Task 8), `FALL_NAMEN` (Task 6). +- Produces: + - `views.mjs`: + - `GERAETE = { handy: { width: 390, height: 844, hasTouch: true, isMobile: true }, 'handy-quer': { width: 844, height: 390, hasTouch: true, isMobile: true }, tablet: { width: 820, height: 1180, hasTouch: true, isMobile: true }, desktop: { width: 1440, height: 900, hasTouch: false, isMobile: false } }`, `ZEITLIMIT_MS = 180000`. + - `ANSICHTEN: Array<{ id, fall, adresse, geraete: string[], warte: string /*CSS*/, schritte?(seite), bewegung?, standalone?, tastatur?: boolean, uhr?: 'fest'|'echt', zeitlimit?: number, ignorieren?: { [pruefung]: string[] } }>`. P0 liefert `platzhalter` (Fall `betrieb-wartung`, `/dashboard.html`, `handy` und `desktop`, `warte: '#kn-platzhalter-ansicht:not(:empty)'`), `platzhalter-abgemeldet` (Fall `abgemeldet`, `handy`) und `login-alt` (Fall `abgemeldet`, `/login.html`, `handy` und `desktop`, `warte: 'form'`; die alte Seite ist dunkel und lila und zählt nur im Gate `p0`). + - `kontextOptionen(geraet, { bewegung = false, standalone = false, sw = 'block' } = {})` liefert `{ viewport, hasTouch, isMobile, deviceScaleFactor: 1, reducedMotion: bewegung ? 'no-preference' : 'reduce', colorScheme: 'light', locale: 'de-DE', timezoneId: 'Europe/Berlin', serviceWorkers: sw }`. Ein unbekanntes Gerät wirft. + - `waehleAnsichten(auswahl: string[], ansichten = ANSICHTEN)`: Einträge sind exakte IDs, Präfix mit `*` am Ende oder Ausschluss mit `!`. Eine Liste nur aus Ausschlüssen gilt als `['*', …]`. Unbekannte ID, toter Eintrag oder leere Auswahl wirft; die Meldung nennt den Eintrag, bei leerer Auswahl das Wort „leer“. + - `gates.mjs`: `ALWAYS_ON = ['step-failed', 'external-request', 'console-error', 'unknown-api', 'backend-error']`; `GATES = { p0: { checks: ALWAYS_ON, ansichten: ['*'] } }` (spätere Pakete ergänzen ihr `p`, Vertragsfrage 5); `pruefeGate(gate, ansichten = ANSICHTEN)` wirft bei unbekannter Prüfung (erlaubt sind `ALWAYS_ON` und `SEITEN_PRUEFUNGEN`) sowie bei toter oder leerer Auswahl; `gateErgebnis(bericht, gate): { ok, verstoesse: Array<{ ansicht, geraet, check }> }`. Bericht: `Array<{ ansicht, geraet, issues: Array<{ check, ignored?: true }> }>`. Gezählt wird, was in `ALWAYS_ON` oder `gate.checks` steht, zur Auswahl gehört (ohne zu werfen) und nicht `ignored` ist. + +- [ ] **Step 1: Failing test schreiben** (`KW/test/gates.test.mjs`) + +```js +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { GERAETE, ANSICHTEN, ZEITLIMIT_MS, kontextOptionen, waehleAnsichten } from '../views.mjs' +import { ALWAYS_ON, GATES, gateErgebnis, pruefeGate } from '../gates.mjs' +import { FALL_NAMEN } from '../fixtures/index.mjs' + +test('Geräte und Zeitlimit', () => { + assert.deepEqual(GERAETE.handy, { width: 390, height: 844, hasTouch: true, isMobile: true }) + assert.ok(GERAETE['handy-quer'].height <= 500 && GERAETE['handy-quer'].hasTouch) // trifft die Handy-Abfrage der Hülle + assert.deepEqual([GERAETE.desktop.width, GERAETE.desktop.hasTouch, ZEITLIMIT_MS], [1440, false, 180000]) +}) +test('kontextOptionen: bewegungsarm, Service Worker gesperrt, feste Zone; unbekanntes Gerät wirft', () => { + const o = kontextOptionen('handy') + assert.deepEqual([o.viewport, o.hasTouch, o.reducedMotion, o.serviceWorkers, o.timezoneId, o.deviceScaleFactor], [{ width: 390, height: 844 }, true, 'reduce', 'block', 'Europe/Berlin', 1]) + assert.deepEqual([kontextOptionen('desktop', { bewegung: true }).reducedMotion, kontextOptionen('desktop', { sw: 'allow' }).serviceWorkers], ['no-preference', 'allow']) + assert.throws(() => kontextOptionen('fernseher')) +}) +test('Ansichten: eindeutige IDs, bekannte Fälle und Geräte, P0-Ansichten zuerst', () => { + assert.equal(new Set(ANSICHTEN.map((a) => a.id)).size, ANSICHTEN.length) + for (const a of ANSICHTEN) { assert.ok(FALL_NAMEN.includes(a.fall), a.id); for (const g of a.geraete) assert.ok(g in GERAETE, a.id) } + assert.deepEqual(ANSICHTEN.map((a) => a.id).slice(0, 3), ['platzhalter', 'platzhalter-abgemeldet', 'login-alt']) +}) +test('waehleAnsichten: ID, Präfix, Ausschluss; Tippfehler werfen', () => { + const liste = [{ id: 'a' }, { id: 'tab-1' }, { id: 'tab-2' }, { id: 'b' }] + const ids = (auswahl) => waehleAnsichten(auswahl, liste).map((a) => a.id) + assert.deepEqual([ids(['tab-*', 'b']), ids(['*', '!tab-*']), ids(['!a'])], [['tab-1', 'tab-2', 'b'], ['a', 'b'], ['tab-1', 'tab-2', 'b']]) + for (const [schlecht, muster] of [[['nichtda'], /nichtda/], [['xx-*'], /xx-/], [['!nichtda'], /nichtda/], [[], /leer/]]) assert.throws(() => waehleAnsichten(schlecht, liste), muster) +}) +test('Gate p0 zählt nur die immer aktiven Prüfungen; ignored zählt nie; Auswahl wirft nicht', () => { + assert.deepEqual(GATES.p0.checks, ALWAYS_ON) + const bericht = [ + { ansicht: 'login-alt', geraet: 'handy', issues: [{ check: 'dark-surface' }, { check: 'lila' }] }, + { ansicht: 'platzhalter', geraet: 'handy', issues: [{ check: 'external-request' }, { check: 'console-error', ignored: true }] }, + ] + assert.deepEqual(gateErgebnis(bericht, GATES.p0), { ok: false, verstoesse: [{ ansicht: 'platzhalter', geraet: 'handy', check: 'external-request' }] }) + assert.equal(gateErgebnis(bericht, { checks: [...ALWAYS_ON, 'dark-surface', 'lila'], ansichten: ['*', '!login-alt'] }).verstoesse.length, 1) + assert.equal(gateErgebnis(bericht, { checks: [...ALWAYS_ON, 'dark-surface'], ansichten: ['*'] }).verstoesse.length, 2) + assert.equal(gateErgebnis(bericht, { checks: ALWAYS_ON, ansichten: ['gibt-es-nicht-*'] }).ok, true) +}) +test('pruefeGate wirft bei unbekannter Prüfung und toter Auswahl', () => { + assert.throws(() => pruefeGate({ checks: ['gibt-es-nicht'], ansichten: ['*'] }), /gibt-es-nicht/) + assert.throws(() => pruefeGate({ checks: ALWAYS_ON, ansichten: ['tot-*'] }), /tot-/) + assert.doesNotThrow(() => pruefeGate(GATES.p0)) +}) +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag prüfen** + +Run: `cd /root/reports/kundenansicht-neu-2026-10-15/werkzeug && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/gates.test.mjs` +Expected: FAIL mit `Cannot find module` auf `views.mjs`. + +- [ ] **Step 3: Implementieren** + +Beide Module nach den Interfaces, reine Funktionen ohne Browser. + +- [ ] **Step 4: Test grün** + +Run: wie Step 2. Expected: PASS, `fail 0`. + +- [ ] **Step 5: Commit (Werkzeug-Git)** + +```bash +KW=/root/reports/kundenansicht-neu-2026-10-15/werkzeug +git -C $KW add views.mjs gates.mjs test/gates.test.mjs +git -C $KW commit -m "Werkzeug: Ansichten, Geräte, Kontextoptionen und Gates" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 10: Statik-Server, Läufer `shots.sh` und Einstieg im Portal + +**Files:** +- Create in `KW`: `statik-server.mjs`, `vorschau/seiten.mjs`, `shots.mjs`, `shots.sh` +- Create in `WT`: `test/ui/kundenansicht-neu.ui.mjs` +- Test: `KW/test/statik.test.mjs`, `KW/test/shots.selftest.mjs` + +**Interfaces:** +- Consumes: Tasks 5, 6, 8, 9. +- Produces: + - `vorschau/seiten.mjs` (eine Quelle für Gates und Vorschau): `SEITEN = [{ ziel: 'dashboard.html', quellen: ['neu/dashboard.html'] }, { ziel: 'login.html', quellen: ['neu/oeffentlich/login.html', 'login.html'] }, { ziel: 'kuendigen.html', quellen: ['neu/oeffentlich/kuendigen.html', 'kuendigen.html'] }, { ziel: 'widerrufen.html', quellen: ['neu/oeffentlich/widerrufen.html', 'widerrufen.html'] }]`; `OHNE` = `sw.js`, `offline.html` und alle Ziele und Quellen aus `SEITEN`; `zuordnung(wurzel): Map` (die erste vorhandene Quelle gewinnt, fehlt keine ganz, fehlt das Ziel in der Map). Befehlszeile `node seiten.mjs `: Zeilen `P \t` je Zuordnung und `O ` je `OHNE`-Eintrag (für `bauen.sh`). + - `statik-server.mjs`: `starteStatik({ wurzel, port = 0, abbilden = false, nginxFallback = false }): Promise<{ url, port, anfragen: string[], stop() }>`, lauscht nur auf `127.0.0.1`. + - `abbilden` (Quellbaum `public/`): die Ziele aus `SEITEN` kommen aus `zuordnung`, `/` ergibt 302 auf `/login.html`. + - `nginxFallback` (gebauter Ordner): ein unbekannter Pfad liefert `index.html` mit 200, auch mit Dateiendung (wie die Vorschau-nginx). Ohne die Option ergibt er 404 als Text, nie HTML. + - `shots.mjs`: `STANDALONE_PATCH: string` (Skript für `addInitScript`, das `matchMedia('(display-mode: standalone)')` wahr macht), `lauf({ ansichten, gate, ausgabe, geraete, wurzel }): Promise<{ bericht, exitCode }>` (`gate` ist ein Name aus `GATES` oder ein Gate-Objekt; ohne `wurzel` gilt `WT/public` mit `abbilden: true`, mit `wurzel` wird der Ordner unverändert ausgeliefert) und `main(argv): Promise` mit ` [--only id,…] [--gate pN] [--geraete a,b]` (Exit 0, 1 bei verletztem Gate, 2 bei Fehlbedienung, schreibt `/report.json`). + - Je Ansicht und Gerät: neuer Kontext mit `kontextOptionen`, frisches `createBackend`, `setFixedTime(jetzt())` außer bei `uhr: 'echt'`, bei `standalone` der Patch, `route('**/*')` (same-origin `/api/` ans Backend, anderes same-origin `continue`, Fremdes `abort` und als `external-request` gemeldet), `beobachte`, `goto`, `waitForSelector(warte)`, `schritte`, bei `tastatur` 300 px weniger Höhe, `runChecks`, Screenshot ohne `fullPage` nach `/x/.png`, Kontext schließen. + - Scheitern Warten oder Schritt, entsteht `step-failed` ohne Absturz. Die Ergebnisse von `beobachte` und die Backend-Verstöße (`unknown-api`, `backend-error`) kommen als `{ check, detail }` in `issues` des Berichts (`detail` ist bei `external-request` die URL). Alle Operationen laufen mit `zeitlimit` der Ansicht, sonst `ZEITLIMIT_MS`. Genau ein `starteBrowser`-Aufruf je Lauf. + - `shots.sh [Optionen]`: löst `` relativ zum Aufrufer auf und führt `run-browser.sh shots.mjs …` aus. `test/ui/kundenansicht-neu.ui.mjs`: `const { main } = await import(\`${process.env.KW}/shots.mjs\`); process.exit(await main(process.argv.slice(2)))` mit Kommentar zum Aufruf über `run-browser.sh`. + +- [ ] **Step 1: Failing tests schreiben** + +`KW/test/statik.test.mjs` (`node:test`, kein Browser). Kopf: `node:test`, `node:assert/strict`, `node:fs` (`mkdirSync`, `writeFileSync`), `starteStatik`, `zuordnung`, `SEITEN`, `OHNE`; `KW` und `WT` wie in Task 5; Testordner unter `KW/tmp/`: + +```js +test('abbilden: /dashboard.html ist die neue Seite, / führt auf den Login, Fehlendes ist 404 als Text', async () => { + const s = await starteStatik({ wurzel: `${WT}/public`, abbilden: true }) + const g = (p) => fetch(s.url + p, { redirect: 'manual' }) + assert.match(await (await g('/dashboard.html')).text(), /data-kn-platzhalter/) + const w = await g('/'); assert.deepEqual([w.status, w.headers.get('location')], [302, '/login.html']) + assert.equal((await g('/login.html')).status, 200) + const weg = await g('/neu/gibt-es-nicht.js'); assert.deepEqual([weg.status, weg.headers.get('content-type')], [404, 'text/plain; charset=utf-8']) + await s.stop() +}) +test('nginxFallback: fehlende Datei mit Endung liefert index.html mit 200, ohne die Option 404', async () => { + const d = `${KW}/tmp/statik-test`; mkdirSync(d, { recursive: true }); writeFileSync(`${d}/index.html`, '

INDEX

') + const mit = await starteStatik({ wurzel: d, nginxFallback: true }); const ohne = await starteStatik({ wurzel: d }) + const a = await fetch(mit.url + '/neu/fehlt.js'); const b = await fetch(ohne.url + '/neu/fehlt.js') + assert.deepEqual([a.status, await a.text(), b.status], [200, '

INDEX

', 404]) + await Promise.all([mit.stop(), ohne.stop()]) +}) +test('zuordnung: die neue öffentliche Seite hat Vorrang; OHNE enthält alle Ziele und Quellen', () => { + const d = `${KW}/tmp/seiten-test`; mkdirSync(`${d}/neu/oeffentlich`, { recursive: true }) + for (const f of ['login.html', 'neu/oeffentlich/login.html', 'neu/dashboard.html']) writeFileSync(`${d}/${f}`, 'x') + const z = zuordnung(d) + assert.deepEqual([z.get('login.html'), z.get('dashboard.html'), z.has('kuendigen.html')], [`${d}/neu/oeffentlich/login.html`, `${d}/neu/dashboard.html`, false]) + for (const s of SEITEN) for (const q of [s.ziel, ...s.quellen]) assert.ok(OHNE.includes(q), q) + assert.ok(OHNE.includes('sw.js') && OHNE.includes('offline.html')) +}) +``` + +`KW/test/shots.selftest.mjs` (Browser, läuft mit `run-browser.sh`; Seiten als Dateien in `KW/tmp/shots-seiten/`). Kopf: `node:test` (`test`, `before`), `node:assert/strict`, `node:fs` (`mkdirSync`, `writeFileSync`, `statSync`, `readFileSync`), `lauf` und `STANDALONE_PATCH` aus `shots.mjs`, `starteStatik`, `starteBrowser`, `kontextOptionen`, `ALWAYS_ON`. Der Test hält nie zwei Browser gleichzeitig offen: `lauf` startet und schließt seinen eigenen, der Kontext-Test startet seinen erst im Testrumpf und schließt ihn dort. + +```js +const SD = `${KW}/tmp/shots-seiten` +const SEITEN_HTML = { + 'gut.html': '

Hallo

', 'extern.html': '

x

', + 'dunkel.html': '

x

', +} +const ansicht = (id, extra = {}) => ({ id, fall: 'betrieb-wartung', adresse: `/${id}.html`, geraete: ['handy'], warte: 'h1', ...extra }) +const starte = (ansichten, gate, geraete = ['handy']) => lauf({ wurzel: SD, ansichten, gate, geraete, ausgabe: `${KW}/tmp/shots-test` }) +before(() => { mkdirSync(SD, { recursive: true }); for (const [n, h] of Object.entries(SEITEN_HTML)) writeFileSync(`${SD}/${n}`, `${h}`) }) + +test('gute Seite: Exit 0, PNG nicht leer, ein Bericht je Gerät', async () => { + const r = await starte([ansicht('gut', { geraete: ['handy', 'desktop'] })], 'p0', ['handy', 'desktop']) + assert.deepEqual([r.exitCode, r.bericht.map((b) => b.geraet)], [0, ['handy', 'desktop']]) + assert.ok(statSync(`${KW}/tmp/shots-test/390x844/gut.png`).size > 1000) +}) +test('fremder Request wird abgebrochen und gemeldet; das Gate schlägt an', async () => { + const r = await starte([ansicht('extern')], 'p0') + assert.equal(r.exitCode, 1) + assert.deepEqual(r.bericht[0].issues.filter((i) => i.check === 'external-request').map((i) => i.detail), ['https://bild.example.invalid/x.png']) +}) +test('dunkle Seite: p0 grün, Gate mit dark-surface rot', async () => { + assert.equal((await starte([ansicht('dunkel')], 'p0')).exitCode, 0) + assert.equal((await starte([ansicht('dunkel')], { checks: [...ALWAYS_ON, 'dark-surface'], ansichten: ['*'] })).exitCode, 1) +}) +test('fehlendes Warten: step-failed statt Absturz', async () => { + const r = await starte([ansicht('gut', { warte: '#gibt-es-nicht', zeitlimit: 1500 })], 'p0') + assert.deepEqual([r.exitCode, r.bericht[0].issues.some((i) => i.check === 'step-failed')], [1, true]) +}) +test('Kontexte: Handy ist coarse, Querformat trifft die Handy-Abfrage, Standalone nur per Patch', async () => { + const statik = await starteStatik({ wurzel: SD }); const browser = await starteBrowser() + const frage = async (geraet, opts = {}) => { + const k = await browser.newContext(kontextOptionen(geraet, opts)); const s = await k.newPage() + if (opts.standalone) await s.addInitScript(STANDALONE_PATCH) + await s.goto(`${statik.url}/gut.html`) + const r = await s.evaluate(() => ['(pointer: coarse)', '(pointer: coarse) and (max-height: 500px)', '(display-mode: standalone)'].map((q) => matchMedia(q).matches)) + await k.close(); return r + } + try { + assert.deepEqual([await frage('handy'), await frage('handy-quer'), await frage('desktop'), await frage('handy', { standalone: true })], [[true, false, false], [true, true, false], [false, false, false], [true, false, true]]) + } finally { await browser.close(); await statik.stop() } +}) +test('Quelltext von shots.mjs: kein fullPage, Zeitlimit, genau ein starteBrowser', () => { + const src = readFileSync(`${KW}/shots.mjs`, 'utf8') + assert.deepEqual([/fullPage/.test(src), /ZEITLIMIT_MS/.test(src), (src.match(/starteBrowser\(/g) || []).length], [false, true, 1]) +}) +``` + +- [ ] **Step 2: Tests laufen lassen, Fehlschlag prüfen** + +Run: `cd /root/reports/kundenansicht-neu-2026-10-15/werkzeug && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/statik.test.mjs` +Expected: FAIL mit `Cannot find module` auf `statik-server.mjs`. + +- [ ] **Step 3: Implementieren** + +Alle Dateien nach den Interfaces. Vorbild (nur lesen): `statik-server.mjs`, `shots.mjs`, `shots.sh` im WOMS-Werkzeug. Die Fallstricke aus Karte `huelle-tests` § 4 stecken in `kontextOptionen` und `lauf`: kein `fullPage`, `reducedMotion: 'reduce'` als Vorgabe, `hasTouch` für Handy, `matchMedia`-Patch, 180 s, ein Browser. + +- [ ] **Step 4: Tests grün** + +Run: `cd /root/reports/kundenansicht-neu-2026-10-15/werkzeug && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/statik.test.mjs`, dann `free -m` und `/root/reports/kundenansicht-neu-2026-10-15/werkzeug/run-browser.sh /root/reports/kundenansicht-neu-2026-10-15/werkzeug/test/shots.selftest.mjs` +Expected: PASS, `fail 0`. +Dann der Einstieg aus dem Portal: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && /root/reports/kundenansicht-neu-2026-10-15/werkzeug/run-browser.sh test/ui/kundenansicht-neu.ui.mjs /root/reports/kundenansicht-neu-2026-10-15/werkzeug/tmp/shots-p0 --gate p0` +Expected: Exit 0, `report.json` mit fünf Einträgen (`platzhalter` und `login-alt` je Handy und Desktop, `platzhalter-abgemeldet` Handy), Bilder unter `390x844/` und `1440x900/`. Sieh dir `platzhalter.png` an: weiße Seite, „Kundenansicht neu“, „Ansicht: neu“. + +- [ ] **Step 5: Commits** + +```bash +KW=/root/reports/kundenansicht-neu-2026-10-15/werkzeug +git -C $KW add statik-server.mjs vorschau/seiten.mjs shots.mjs shots.sh test/statik.test.mjs test/shots.selftest.mjs +git -C $KW commit -m "Werkzeug: Statik-Server und Screenshot-Läufer mit Gates" -m "Co-Authored-By: Claude Opus 5.5 " +cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht +git add test/ui/kundenansicht-neu.ui.mjs +git commit -m "Kundenansicht neu: Einstieg für Sichtprüfungen über das Werkzeug" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 11: Vorschau-Bau (Service Worker, Start-Skript, Seiten-Patch, Abschlussprüfung) + +**Files:** +- Create in `KW/vorschau/`: `sw-eintrag.mjs`, `boot.js`, `index-patchen.mjs`, `pruefe-dist.mjs`, `bauen.sh`, `hinweis.html`, `demo-baeckerei.html` +- Test: `KW/test/vorschau-bau.test.mjs` + +**Interfaces:** +- Consumes: `createBackend`, `fixtures/index.mjs` (Task 6), `SEITEN`, `OHNE`, `zuordnung` und die Befehlszeile von `seiten.mjs` (Task 10). +- Produces: + - `sw-eintrag.mjs`: Service Worker. Liest `?fall=` aus der eigenen Adresse und prüft den Namen gegen `FALL_NAMEN` (die Kernfälle und alle später angehängten; unbekannt ergibt `betrieb-wartung`), hält ein Backend je Lebensdauer. Same-origin `/api/` geht ans Backend, fremde Origins (außer Navigationen) beantwortet er mit `Response.error()`, sonstiges bleibt unberührt. `install` ruft `skipWaiting`, `activate` ruft `clients.claim`, die Nachricht `claim` holt eine ungesteuerte Seite. + - `index-patchen.mjs`: `patche(html, boot): { html, skripte: Array<{ src?, type?, text? }> }` und Befehlszeile `node index-patchen.mjs `. Alle `' + const { html: aus, skripte } = patche(html, 'window.__boot = 1') + assert.deepEqual(skripte, [{ src: '/a.js' }, { type: 'module', src: '/neu/b.js' }, { text: 'init();' }]) + assert.ok(!/]*src="\/a\.js"/.test(aus) && aus.includes('')) + assert.ok(aus.includes('') && aus.indexOf('window.__boot = 1') < aus.indexOf('')) + const roh = /data-skripte="([^"]*)"/.exec(aus)[1].replaceAll('"', '"').replaceAll('<', '<').replaceAll('&', '&') + assert.deepEqual(JSON.parse(roh), skripte) +}) +test('patche: { + const { html } = patche('', 'x') + assert.equal((html.match(/<\/script>/g) || []).length, 1) + assert.throws(() => patche('', 'x'), /head/) + assert.throws(() => patche('', 'ab'), /script/) +}) +test('pruefeDist findet fehlende Dateien in HTML, JS-Importen und CSS (Query und Hash zählen nicht)', () => { + const d = `${KW}/tmp/dist-test`; mkdirSync(`${d}/neu`, { recursive: true }) + writeFileSync(`${d}/a.html`, '') + writeFileSync(`${d}/neu/x.js`, "import { y } from '/neu/fehlt.js'\nimport('/neu/da.js')") + writeFileSync(`${d}/neu/da.js`, 'export const z = 1') + writeFileSync(`${d}/s.css`, 'a{background:url(/bild.png)}') + assert.deepEqual(pruefeDist(d).sort(), ['/bild.png', '/neu/fehlt.js']) +}) +test('der Service-Worker-Eintrag bündelt für den Browser: kein node:, kein process', () => { + const aus = `${KW}/tmp/sw-test.js` + const r = spawnSync('systemd-run', ['--scope', '-q', '-p', 'MemoryMax=1000M', '-p', 'MemorySwapMax=0', ESBUILD, `${KW}/vorschau/sw-eintrag.mjs`, '--bundle', '--platform=browser', '--format=iife', '--target=es2020', '--define:process.env.FIXTURE_TODAY=undefined', `--outfile=${aus}`, '--log-level=warning'], { encoding: 'utf8' }) + assert.equal(r.status, 0, r.stderr) + const code = readFileSync(aus, 'utf8') + assert.ok(!code.includes('node:') && !/\bprocess\./.test(code)) +}) +test('boot.js: kein { + const boot = readFileSync(`${KW}/vorschau/boot.js`, 'utf8') + assert.ok(!boot.includes(' muster.test(n)) && ['../x', 'A B', '', 'x'.repeat(41)].every((n) => !muster.test(n))) +}) +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag prüfen** + +Run: `cd /root/reports/kundenansicht-neu-2026-10-15/werkzeug && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/vorschau-bau.test.mjs` +Expected: FAIL mit `Cannot find module` auf `index-patchen.mjs`. + +- [ ] **Step 3: Implementieren** + +Nach den Interfaces. Vorbild (nur lesen): `vorschau/{sw-eintrag.mjs,boot.js,index-patchen.mjs}` im WOMS-Werkzeug. Abweichungen: Fall statt Daten/Szenario, kein Appwrite-Sitzungsstub, mehrere Seiten, Module und Inline-Skripte in Reihenfolge, Abfangen von `register('/sw.js')`. + +- [ ] **Step 4: Test grün, dann ein Bau** + +Run: wie Step 2. Expected: PASS, `fail 0`. +Run: `free -m`, dann `/root/reports/kundenansicht-neu-2026-10-15/werkzeug/vorschau/bauen.sh` +Expected: Exit 0. In `/root/reports/kundenansicht-neu-2026-10-15/werkzeug/tmp/vorschau-bau/` liegen `index.html`, gepatchte `dashboard.html` und `login.html` (ohne eigene `' + writeFileSync(`${STAGE}/ereignisse.html`, patche(quelle, readFileSync(`${KW}/vorschau/boot.js`, 'utf8')).html) + await seite.goto(s.url + '/ereignisse.html') + await seite.waitForFunction(() => document.title === 't-dcl-load', null, { timeout: 30000 }) +}) +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag prüfen** + +Run: `free -m`, dann `/root/reports/kundenansicht-neu-2026-10-15/werkzeug/run-browser.sh /root/reports/kundenansicht-neu-2026-10-15/werkzeug/test/vorschau.selftest.mjs` +Expected: FAIL, solange `boot.js` oder der Worker nicht arbeiten. Ist schon alles richtig, nimm eine Aussage kurz heraus, sieh den Fehlschlag und stelle sie wieder her. + +- [ ] **Step 3: Beheben, was der Test zeigt** + +Korrigiere `boot.js`, `sw-eintrag.mjs` oder `bauen.sh` nur dort, wo der Test es verlangt. + +- [ ] **Step 4: Test grün** + +Run: wie Step 2. Expected: PASS, `fail 0`; danach `ls -l /dev/null` mit `crw-rw-rw-`. + +- [ ] **Step 5: Commit (Werkzeug-Git)** + +```bash +KW=/root/reports/kundenansicht-neu-2026-10-15/werkzeug +git -C $KW add test/vorschau.selftest.mjs +git -C $KW commit -m "Werkzeug: Selbsttest der Vorschau im Browser (Worker, Fall-Wechsel, nginx-Fallback)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +(Wurden `boot.js`, `sw-eintrag.mjs` oder `bauen.sh` angepasst, gehören sie einzeln mit `git add` in diesen Commit.) + +--- + +### Task 13: Auslieferung mit Guard, Manifest und Selbsttest + +**Files:** +- Create in `KW/vorschau/`: `ziel-pruefen.sh`, `ausliefern-lib.sh`, `manifest.mjs`, `vorschau-kundenansicht.sh`, `plan-zeigen.sh`, `test-ziel.sh` +- Test: `KW/test/manifest.test.mjs`, `KW/vorschau/test-ziel.sh` + +**Interfaces:** +- Consumes: `STAGE` und `bauen.sh` (Task 11). +- Produces: + - `ziel-pruefen.sh` (nur per `source`): `ZIEL_FEST=/var/www/previews/kundenansicht-neu`, `ziel_pruefen_gegen ` (0 in Ordnung, 2 abgelehnt, Grund auf stderr), `ziel_pruefen` (= `ziel_pruefen_gegen "$ZIEL_FEST" /var/www/previews/kundenansicht-neu` mit dem Literal). Regeln: + 1. `readlink -m` des Kandidaten ist genau `readlink -m` von `fest`. + 2. `fest` ist kein Symlink, sein Elternordner ein echter Ordner (`readlink -f` gleich dem Pfad). + 3. Mindestens vier Pfadebenen. + 4. Existiert `fest`, ist es ein Ordner und leer oder enthält `vorschau-info.txt`. Fremder Inhalt nur mit `KN_ERSTLAUF_FREMDES_OK=ja`; dann werden gleichnamige Dateien überschrieben und alles andere bleibt. + - `ausliefern-lib.sh` (nur per `source`): `pfad_im_ziel `: 0, wenn `/` nach `readlink -m` unter `/` liegt, kein Bestandteil unterhalb von `` ein Symlink und ein vorhandenes Ziel kein Verzeichnis ist; sonst 1. + - `manifest.mjs`: `bereinige(rel): string|null` (null bei leer, führendem `/`, Segment `..` oder `.`, `//`, Rückwärtsstrich, Steuerzeichen oder NUL, Länge über 300), `plane({ neu: string[], altesManifest: string[] }): { kopieren, entfernen, verworfen }`. `kopieren` sind die bereinigten neuen Dateien (`vorschau-info.txt` und `vorschau-manifest.txt` zuletzt). `entfernen` sind bereinigte Einträge des alten Manifests, die nicht mehr in `neu` stehen. `verworfen` sind unsaubere Einträge aus beiden Listen. Befehlszeile `node manifest.mjs plan ` gibt Zeilen `K `, `E `, `X ` aus. + - `vorschau-kundenansicht.sh`: kennt nur `--pruefen` und den Aufruf ohne Argument. Jedes andere Argument ergibt Exit 2 mit Aufrufhilfe, bevor etwas geschieht. Das Ziel ist fest. `--pruefen` ruft nur `ziel_pruefen` auf („Zielordner in Ordnung: “), baut und kopiert nichts. Ohne Argument: `ziel_pruefen`, `bauen.sh`, `manifest.mjs plan`, je `K`-Zeile `pfad_im_ziel`, dann `mkdir -p` des Elternordners und `cp -- "$STAGE/$rel" "$ZIEL/$rel"`, je `E`-Zeile `[ -f ] && [ ! -L ]` und `pfad_im_ziel`, dann `rm -f --`. Nie `rm -rf`, `rsync` oder `--delete`, nie das Entfernen eines Ordners. `vorschau-info.txt` und `vorschau-manifest.txt` werden zuletzt kopiert; bricht ein Lauf ab, bleibt das alte Manifest stehen. + - `plan-zeigen.sh`: ohne Argumente, ruft `bauen.sh`, liest das Manifest des Ziels nur lesend und gibt den Plan (`K`/`E`/`X`-Zeilen, Summen) aus. Ändert nichts außerhalb von `KW/tmp`. + - `test-ziel.sh`: ruft nur `ziel_pruefen_gegen` und `pfad_im_ziel` mit Pfaden unter `KW/tmp/ziel-test/` auf, nie das Auslieferungs-Skript mit einem Ziel. Aufräumen von `KW/tmp/ziel-test` am Ende hinter einer `case`-Prüfung auf den genauen Pfad. + +- [ ] **Step 1: Failing tests schreiben** + +`KW/test/manifest.test.mjs` (`node:test`, `node:assert/strict`, `bereinige` und `plane` aus `vorschau/manifest.mjs`; nur Zeichenketten, nichts im Dateisystem): + +```js +test('bereinige: saubere relative Pfade bleiben, Gefährliches wird null', () => { + for (const ok of ['index.html', 'neu/tokens.css', 'vorschau/demo-baeckerei.html', 'a b/c.txt']) assert.equal(bereinige(ok), ok) + for (const schlecht of ['', '/etc/passwd', '../x', 'a/../../x', 'a/./b', './a', 'a//b', 'a\\b', 'a\nb', 'a\0b', 'x'.repeat(301), '..', '.']) assert.equal(bereinige(schlecht), null, JSON.stringify(schlecht)) +}) +test('plane: neue kopieren, veraltete aus dem alten Manifest entfernen, Unsauberes verwerfen', () => { + const p = plane({ neu: ['a.html', 'vorschau-manifest.txt', 'b.js', 'vorschau-info.txt', '../boese'], altesManifest: ['a.html', 'alt.js', '../../etc/passwd', '/root/x', 'b.js'] }) + assert.deepEqual(p.kopieren, ['a.html', 'b.js', 'vorschau-info.txt', 'vorschau-manifest.txt']) + assert.deepEqual(p.entfernen, ['alt.js']) + assert.deepEqual([...p.verworfen].sort(), ['../../etc/passwd', '../boese', '/root/x']) +}) +test('plane: leeres altes Manifest entfernt nichts', () => { + const p = plane({ neu: ['a.html'], altesManifest: [] }) + assert.deepEqual([p.kopieren, p.entfernen, p.verworfen], [['a.html'], [], []]) +}) +``` + +`KW/vorschau/test-ziel.sh` (Aufbau nach dem WOMS-Vorbild, mit Funktionsaufrufen statt Skriptaufrufen; Ausgabe am Ende `test-ziel ok (N Fälle)`, Exit 1 bei einem falschen Ergebnis). Pflichtfälle, jeder mit erwartetem Ergebnis: +- Konstante: `ZIEL_FEST` ist wörtlich `/var/www/previews/kundenansicht-neu`; `ziel_pruefen_gegen` mit diesem Pfad gegen sich selbst ist nicht abgelehnt wegen Pfadform. +- Abgelehnt, weil nicht der feste Pfad: `/var/www/previews`, `/var/www/previews/kundenansicht-neu/sub`, `/var/www/previews/kundenansicht-neu2`, `/var/www/previews/demo`, `/root`, `/`. +- In Ordnung gegen einen Testordner `$KW/tmp/ziel-test/fest`: Pfad mit doppeltem Schrägstrich und `/./`, der auf ihn zeigt (wird normalisiert). +- Gegen Testordner: `fest` als Symlink abgelehnt; Elternordner als Symlink abgelehnt; `fest` als Datei abgelehnt; `fest` mit fremdem Inhalt ohne `vorschau-info.txt` abgelehnt, mit `KN_ERSTLAUF_FREMDES_OK=ja` in Ordnung; `fest` leer in Ordnung; `fest` mit `vorschau-info.txt` in Ordnung; `fest` fehlt in Ordnung; Pfad mit weniger als vier Ebenen abgelehnt. +- `pfad_im_ziel`: normale Datei in Ordnung; Datei hinter einem Symlink-Ordner (`z/link/x.txt`, `link` zeigt nach außen) abgelehnt; `rel` ist ein Verzeichnis abgelehnt; `rel` ist ein Symlink abgelehnt. +- Das Auslieferungs-Skript nur mit `--pruefen` (Exit 0 oder 2, nie etwas anderes), mit `--unbekannt` und mit `--pruefen zusatz` (beide Exit 2, ohne etwas zu tun). + +- [ ] **Step 2: Tests laufen lassen, Fehlschlag prüfen** + +Run: `cd /root/reports/kundenansicht-neu-2026-10-15/werkzeug && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/manifest.test.mjs` +Expected: FAIL mit `Cannot find module` auf `manifest.mjs`. + +- [ ] **Step 3: Implementieren** + +Alle Dateien nach den Interfaces. Vorbild für den Guard (nur lesen): `ziel-pruefen.sh` und `test-ziel.sh` im WOMS-Werkzeug, mit festem Ziel statt Parameter. + +- [ ] **Step 4: Tests grün** + +Run: `cd /root/reports/kundenansicht-neu-2026-10-15/werkzeug && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/manifest.test.mjs` +Expected: PASS, `fail 0`. +Run: `/root/reports/kundenansicht-neu-2026-10-15/werkzeug/vorschau/test-ziel.sh` +Expected: `test-ziel ok (N Fälle)`, Exit 0. +Run: `/root/reports/kundenansicht-neu-2026-10-15/werkzeug/vorschau/vorschau-kundenansicht.sh --pruefen` +Expected: Exit 0 mit „Zielordner in Ordnung: /var/www/previews/kundenansicht-neu“ oder Exit 2 mit klarem Grund (zum Beispiel fremder Inhalt). Beides ist gültig und kommt in die Übergabe. **Nie** das Skript ohne `--pruefen` starten. + +- [ ] **Step 5: Commit (Werkzeug-Git)** + +```bash +KW=/root/reports/kundenansicht-neu-2026-10-15/werkzeug +git -C $KW add vorschau/ziel-pruefen.sh vorschau/ausliefern-lib.sh vorschau/manifest.mjs vorschau/vorschau-kundenansicht.sh vorschau/plan-zeigen.sh vorschau/test-ziel.sh test/manifest.test.mjs +git -C $KW commit -m "Werkzeug: Auslieferung mit festem Ziel, Guard, Manifest und Selbsttest" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 14: Bedienung und Übergabe an den Inhaber + +**Files:** +- Create in `KW`: `README.md`, `UEBERGABE-P0.md` + +**Interfaces:** +- Produces: die Anleitung, nach der P1 bis P4 und P7 eigene Ansichten, Fixtures, Endpunkte und Gates ergänzen, und der Arbeitsauftrag an den Inhaber. + +- [ ] **Step 1: `README.md` schreiben**, jeweils kurz und mit dem genauen Befehl: + +1. **Aufruf:** `run-browser.sh`, `shots.sh --only id --gate pN --geraete a,b`, `check.sh … | --alle`, `strang.env` (`export WT=…`) für andere Worktrees. Eine Instanz zur Zeit, die Sperre ist gemeinsam. +2. **Eine Ansicht ergänzen:** Eintrag in `views.mjs` (Felder, Fall, Geräte, `warte`, Beispiele für `schritte`, `tastatur`, `standalone`, `bewegung`). +3. **Einen Endpunkt ergänzen:** eigene Datei `fixtures/endpunkte-p.mjs` und **ein** Import in `fixtures/endpunkte.mjs`, danach Eintrag in `vertrag-endpunkte.mjs` (`VERTRAG` oder `NICHT_PRUEFBAR` mit Grund). Unbekannte `/api/*` melden `unknown-api`. + **Neue Fälle (feste Regel):** Die vierzehn Kernfälle stehen vorn in `FALL_NAMEN` und bleiben fest. Weitere Fälle nur in einer eigenen Datei anhängen (`fixtures/module.mjs` für P4, `fixtures/faelle-p7.mjs` für P7, sonst `fixtures/faelle-p.mjs`), mit genau einer Importzeile in `fixtures/faelle.mjs`. Den Pin in `test/backend.test.mjs` (er prüft nur `FALL_NAMEN.slice(0, 14)`) und `vorschau/boot.js` (die Leiste zeigt nur die Kernfälle, `?fall=` nimmt jeden Namen aus `FALL_NAMEN`) nie ändern. Eine rote Meldung dort heißt: Der Kernfall wurde verändert. Stoppen und dem Controller melden. +4. **Ein Gate ergänzen:** `GATES.p` in `gates.mjs`, mit Beispiel für `ignorieren` und der Regel zu `#111`. +5. **Fallstricke:** kein `fullPage`, `hasTouch` für Handy, Standalone nur per Patch, 180 s, nur ein Browser, danach `ls -l /dev/null`. +6. **Vorschau:** `bauen.sh` baut nach `tmp/vorschau-bau`, `plan-zeigen.sh` zeigt den Plan, `vorschau-kundenansicht.sh` liefert aus (nur der Controller mit OK des Inhabers, Agenten nur `--pruefen`). Das Portal löscht den Zielordner bei jedem Push in das Repo des Projekts und beim Archivieren oder Umbenennen (`server/services/previewDeploy.js`: `deployStatic`, `removeDeploy`). Deshalb nie in dieses Repo pushen und das Projekt nie archivieren. + +- [ ] **Step 2: `UEBERGABE-P0.md` schreiben** (du-Form, für den Inhaber, kurz) + +1. **Einmalig in WOMS:** „Neues Projekt“, Typ Website, interner Kunde (WEBklar), Subdomain `kundenansicht-neu`, Name „Kundenansicht neu (Vorschau)“, das Repo bleibt leer. Danach das Projekt nie archivieren, die Subdomain nie ändern, nichts in das Repo pushen. +2. **Danach meldest du dich beim Controller.** Er prüft `ls -A /var/www/previews/kundenansicht-neu` (der erste Deploy des leeren Repos kann Dateien anlegen), ruft `vorschau-kundenansicht.sh --pruefen` auf und liefert erst nach deinem OK aus. +3. **Der Schalter bleibt aus.** `KUNDENANSICHT_NEU` ist nicht gesetzt, also `off`. Einschalten später in der Compose-Umgebung: `KUNDENANSICHT_NEU=pilot` und `KUNDENANSICHT_NEU_PILOT_EMAILS=kenso.gri@gmail.com,…`. +4. **Offene Frage für den Controller:** `isPreviewProjectReady` verlangt `ready` oder `deployed` (`server/services/previewReadiness.js`). Wie ein Projekt ohne Repo-Inhalt diesen Status bekommt, klärt der Controller mit dir, sonst zeigt die Vorschau `not_ready`. + +- [ ] **Step 3: Plan prüfen** + +Run: `/root/reports/kundenansicht-neu-2026-10-15/werkzeug/vorschau/plan-zeigen.sh` +Expected: Exit 0, `K`-Zeilen, keine `X`-Zeilen. `git -C /root/reports/kundenansicht-neu-2026-10-15/werkzeug status --short` zeigt nur `README.md` und `UEBERGABE-P0.md`. + +- [ ] **Step 4: Commit (Werkzeug-Git)** + +```bash +KW=/root/reports/kundenansicht-neu-2026-10-15/werkzeug +git -C $KW add README.md UEBERGABE-P0.md +git -C $KW commit -m "Werkzeug: Bedienung und Übergabe an den Inhaber" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 15: Selbstprüfung + +**Files:** keine neuen. Tags `kn-p0` im Portal und im Werkzeug. + +**Interfaces:** +- Produces: das Tag `kn-p0` im Portal (aus ihm entstehen die Stränge der Phase B) und die Übergabe an den Controller. + +- [ ] **Step 1: Portal-Tests** (eigene Dateien und Pins) + +Run: `free -m`, dann `/root/reports/kundenansicht-neu-2026-10-15/werkzeug/check.sh test/kundenansichtSchalter.test.js test/kundenansichtDashboard.test.js test/kundenansichtMe.test.js test/adminPanelGone.test.js test/legalDocs.test.js test/kundenansichtGuard.test.js test/kundenansichtEnde.test.js test/zeitenSchalter.test.js` +Expected: Exit 0, `# fail 0`. + +- [ ] **Step 2: Werkzeug-Tests ohne Browser** + +Run: `cd /root/reports/kundenansicht-neu-2026-10-15/werkzeug && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/geruest.test.mjs test/backend.test.mjs test/vertrag.test.mjs test/gates.test.mjs test/statik.test.mjs test/vorschau-bau.test.mjs test/manifest.test.mjs` +Expected: `fail 0`. + +- [ ] **Step 3: Browser-Selbsttests, nacheinander** + +Run, einzeln und mit `free -m` davor: `/root/reports/kundenansicht-neu-2026-10-15/werkzeug/run-browser.sh /root/reports/kundenansicht-neu-2026-10-15/werkzeug/test/checks.selftest.mjs`, dasselbe mit `test/shots.selftest.mjs` und `test/vorschau.selftest.mjs`. +Expected: je `fail 0`, danach `ls -l /dev/null` mit `crw-rw-rw-`. + +- [ ] **Step 4: Skripte und Sichtprüfung** + +Run: `/root/reports/kundenansicht-neu-2026-10-15/werkzeug/vorschau/test-ziel.sh` (Expected: `test-ziel ok`), `/root/reports/kundenansicht-neu-2026-10-15/werkzeug/vorschau/vorschau-kundenansicht.sh --pruefen` (Expected: Exit 0 oder 2 mit Grund, beides in den Bericht), dann von `WT` aus `/root/reports/kundenansicht-neu-2026-10-15/werkzeug/run-browser.sh test/ui/kundenansicht-neu.ui.mjs /root/reports/kundenansicht-neu-2026-10-15/werkzeug/tmp/shots-p0 --gate p0` (Expected: Exit 0). + +- [ ] **Step 5: Verträge und Zuständigkeit abhaken** + +- [ ] V1: `config.kundenansichtNeu` (Task 2), `schalter.js` mit vier Exports (`neueAnsichtModus`, `neueAnsichtFuerSession`, `neueAnsicht`, `neueAnsichtFuerKunde`) und allen Regeln (Task 2), Route vor `express.static` mit `no-cache` (Task 3), Feld `ansicht` (Task 4), Platzhalter `public/neu/dashboard.html` (Task 3). +- [ ] Alte Ansicht unverändert: `git -C /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht diff 08dd766..HEAD -- public/app.js public/dashboard.html public/login.html` ist leer. +- [ ] Zuständigkeit: `git -C /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht diff --stat 08dd766..HEAD` nennt nur `server/config.js`, `server/index.js`, `server/routes/auth.js`, `server/services/kundenansicht/*`, `public/neu/dashboard.html`, die drei neuen Tests, `test/ui/kundenansicht-neu.ui.mjs` und die Plan-Datei. +- [ ] Ablaufhinweis 1 der Übersicht: `UEBERGABE-P0.md` enthält den Arbeitsauftrag an den Inhaber. + +- [ ] **Step 6: Sauberkeit und Tags** + +Run: `git -C /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht status --short` und `git -C /root/reports/kundenansicht-neu-2026-10-15/werkzeug status --short` +Expected: beide leer (`node_modules` und `tmp/` sind ignoriert). Dann `git -C /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht tag kn-p0` und `git -C /root/reports/kundenansicht-neu-2026-10-15/werkzeug tag kn-p0`. + +- [ ] **Step 7: Bericht an den Controller** + +Letzte Nachricht des Agenten: Portal-Tag mit Commit-Hash, Zahl der grünen Tests je Gruppe, Ergebnis von `--pruefen`, die Punkte aus `UEBERGABE-P0.md` (Auftrag an den Inhaber, offene Frage zum Projektstatus) und der Stand der Vertragsfragen unten. + +--- + +## Vertragsfragen an den Controller + +Alle Vorschläge sind im Plan schon umgesetzt. Bitte bestätigen oder korrigieren, bevor P1 und P5 starten. + +1. **Zusätzliche Datei im P0-Verzeichnis:** `server/services/kundenansicht/dashboardWeiche.js` enthält die Weiche als Fabrik, weil `server/index.js` beim Import den Server startet und sich nicht testen lässt. Vorschlag: P0 besitzt `server/services/kundenansicht/` ganz, andere Pakete legen dort nichts ab. +2. **Direktaufruf `/neu/dashboard.html`:** Wer die neue Ansicht nicht hat, wird mit 302 auf `/dashboard.html` geleitet. Das schließt „neue Seite per erratener Adresse“ im Modus `off`. Folge für P1: Die neue Seite bleibt unter genau diesem Dateinamen, ihre Module und CSS-Dateien bleiben unter `/neu/` frei abrufbar. +3. **„Gültige Sitzung“ in V1 präzisiert:** Kunde = `customerId` und `appwriteUserId` ohne `role: 'admin'`. Kundenansicht = `viewAs === true` mit `viewId`. Alte Mitarbeiter-Sitzungen (`role: 'admin'` ohne `viewId`) sind in jedem Modus `false`, auch bei `*`. Eine `email`, die kein Text ist, zählt als leer. Für Jobs und Dienste ohne Cookie gilt nicht diese Regel, sondern `neueAnsichtFuerKunde` (Frage 10). +4. **`ansicht` in `/api/auth/me`** steht nur in den beiden `authenticated: true`-Antworten. P5 ändert in derselben Datei nur `sanitizeCustomer` (Z. 47–59). `kundenansichtMe.test.js` pinnt nur die Schlüssel der obersten Ebene, damit P5 `customer` erweitern darf. +5. **Werkzeug-Erweiterungen durch P1 bis P4 und P7:** erlaubt sind `views.mjs` (Ansichten), `gates.mjs` (ihr Gate `p`), `fixtures/endpunkte-p.mjs` mit einem Import in `fixtures/endpunkte.mjs`, `fixtures/faelle-p.mjs` (P4: `fixtures/module.mjs`) mit einem Import in `fixtures/faelle.mjs` für weitere Fälle hinter den vierzehn Kernfällen, und `vertrag-endpunkte.mjs`. Mehr nicht. Der Pin „Kernfälle in fester Reihenfolge“ (`FALL_NAMEN.slice(0, 14)`) und `vorschau/boot.js` bleiben unberührt, auch für P4 (17 Fälle `m-*`) und P7 (`privat-ohne-abo`, `hosting-privat`); P2 legt laut seiner Vertragsfrage 7 keine Fälle an. Ändert ein Paket eine Antwortform, ergänzt es den Vertragseintrag im selben Commit. Fixtures für Endpunkte aus V5, V6, V8 folgen wörtlich der Übersicht. +6. **Schwarze Flächen:** `dark-surface` erlaubt genau `#111` (Akzent der Übersicht) für Elemente bis 25 % der Viewport-Fläche. P1 baut Balken, Knopf und Zähler in `#111`. Andere dunkle Flächen brauchen eine Ausnahme je Ansicht über `ignorieren`. +7. **Seiten der Vorschau:** Die Vorschau zeigt `public/neu/dashboard.html` als `/dashboard.html`. Für `login.html`, `kuendigen.html`, `widerrufen.html` hat `public/neu/oeffentlich/` Vorrang vor `public/`, sobald es sie gibt. Skripte der neuen Seiten dürfen klassisch oder `type="module"` sein und laufen in Dokumentreihenfolge. `boot.js` feuert nach dem Laden einmal `DOMContentLoaded` und `load`; Code sollte trotzdem `document.readyState` prüfen. +8. **Offene Betriebsfrage:** `isPreviewProjectReady` verlangt `status` oder `provisioningStatus` `ready` oder `deployed`. Wie das Projekt „kundenansicht-neu“ ohne Repo-Inhalt diesen Status bekommt, ist offen (Memory „Preview-Projekte“). Außerdem kann der erste Deploy des leeren Repos Dateien in den Zielordner legen. Das Skript nimmt sie nur mit `KN_ERSTLAUF_FREMDES_OK=ja` an und überschreibt dann nur gleichnamige Dateien. +9. **`anfrageStatus` (V5) steht in `projects[].features.`** der Antwort von `GET /api/funktionen`, weil die Übersicht „je Projekt und Funktion“ sagt, aber keinen Pfad nennt. P3 und P5 bestätigen oder nennen den Pfad. Die Fixtures und `vertragNeu` ziehen dann mit. +10. **V1 um `neueAnsichtFuerKunde({ customerId, email })` ergänzt (Befund der Prüfer, wichtig).** Nach der Regel „gültige Sitzung“ (Frage 3) ist `neueAnsichtFuerSession({ customerId, email })` ohne Cookie und ohne `appwriteUserId` in jedem Modus `false`. P5 Task 13 (`aboModellFuerKunde`), P5 `runAboCheck` und damit P6 (Team-Aktivierung, Widerruf des Wechsels, Tagesjob-Kopplung) wären still tot. Deshalb liefert `schalter.js` einen vierten Export: dieselben Modus- und Listenregeln, aber ohne Sitzungsprüfung, bei leerer `customerId` `false` (Task 2, Tests dort). Zu tun: Die Übersicht V1 bekommt die Signatur `export function neueAnsichtFuerKunde({ customerId, email }) // boolean, für Jobs und Dienste ohne Cookie` samt Regeln. P5 Task 13 und P5 Vertragsfrage 6 (Annahme zu P0) stellen auf `neueAnsichtFuerKunde` um, Annahme bestätigt mit dieser Funktion statt `neueAnsichtFuerSession`. +11. **Katalog der Fixtures mit `consent` und `hinweis`:** V5 nennt `katalog[].consent` und `katalog[].hinweis` nicht, P5 liefert `consent` (P3 und P4 verlangen es), und der Hinweis steht bei P5 als Text für Terminplaner, Webshop und Buchungskalender. Die Fixtures tragen beides (Task 6), `vertragNeu` deckt beides ab (Task 7). P5 bestätigt Form und Namen, oder die Fixtures ziehen mit. Die Übersicht V5 sollte beide Felder aufnehmen. diff --git a/docs/superpowers/plans/2026-10-16-kundenansicht-02-fundament-huelle.md b/docs/superpowers/plans/2026-10-16-kundenansicht-02-fundament-huelle.md new file mode 100644 index 0000000..6b0512c --- /dev/null +++ b/docs/superpowers/plans/2026-10-16-kundenansicht-02-fundament-huelle.md @@ -0,0 +1,1256 @@ +# Kundenansicht neu, P1 Fundament und Hülle: 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. + +**Ü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` (aus Tag `kn-p0`). Tag nach Abschluss: `kn-p1`. Das Anlegen des Worktrees steht in Task 1, Step 0. + +**Goal:** Die weiße Hülle der neuen Kundenansicht steht und ist mit Testdaten sichtbar: Tokens und Basis-CSS, Routen und Router, Fahrstuhl-Zeile (Handy) und Menü (Desktop), Konto, Widerrufsstreifen, Zähler, Etage 2, Start über `/api/auth/me` und die Seite `public/neu/dashboard.html` mit leeren Ansichten für P2 und P3. V2 und V3 der Übersicht sind damit vollständig erfüllt. + +**Architecture:** Reine Logik (Routen, History-Regeln, Zähler, Zahlenfunktionen der Leiste, Ableitung aus `/api/auth/me`) steht in Modulen ohne DOM-Zugriff beim Import und wird mit `node --test` geprüft. DOM-Teile bauen nur mit `h()` und `textContent` und werden mit `test/support/fakeDom.js` geprüft. Geometrie, Animation und Tastatur prüft allein die Sichtprüfung über das Werkzeug aus P0 (Task 13). Jedes Modul bekommt seine Abhängigkeiten (api, Fenster, Zeitgeber, Lader) als Parameter mit Vorgabe, damit kein Test einen Browser braucht. + +**Tech Stack:** Vanilla ES-Module unter `public/neu/` (relative Importe, kein Build), CSS ohne Präprozessor, `node --test` mit `fakeDom`, Playwright nur über das Werkzeug aus P0. + +**Spec:** `docs/superpowers/specs/2026-10-15-kundenansicht-neu-design.md`, Abschnitte 5.1 bis 5.3, 7 (C2, C3). Demo: `/root/reports/kundenansicht-neu-2026-10-15/handy-leiste/final-2-frei.html`. Code-Karte: `karten/huelle-tests.md`. + +## Global Constraints + +Alle Global Constraints der Übersicht gelten wörtlich (Aussehen, „du“, keine Preise für Funktionen, Hashes, rechtlich fest, Tests, Commits, Sicherheitsblock). Paketeigene Zusätze: + +- Nur Dateien aus der Dateiübersicht anlegen oder ändern; am Ende zeigt `git diff --name-only kn-p0` genau diese Liste. Nie `public/app.js`, die alte `public/dashboard.html` oder `public/*.css` anfassen. +- `public/neu/*.js`: relative Importe, kein DOM-Zugriff beim Import (Module müssen in Node importierbar sein), kein `innerHTML`/`insertAdjacentHTML`/`outerHTML`; Kundentext nur über `h()` oder `textContent`; `inert` nur über `setAttribute`/`removeAttribute` (fakeDom kennt die Eigenschaft nicht). Absolute Importe gibt es nur als `import()` der gemeinsamen Funktionsmodule (und von `/neu/funktion-einschalten.js`, Task 11) in `huelle.js`, über einen einspritzbaren Lader. +- Kein Wort `admin.html`, `kalkulation.html`, `/api/portal-admin` in neuen Dateien (`test/adminPanelGone.test.js:59` liest `public/` rekursiv). +- **CSS:** Die Grundregeln gelten am Desktop, die Handy-Abfrage `(max-width: 880px), (pointer: coarse) and (max-height: 500px)` überschreibt für das Handy. Es gibt keine gegenläufige zweite Abfrage (`not` gilt in einer Abfrageliste nur für das erste Glied). In JS steht sie genau einmal als `HANDY_ABFRAGE` (`leiste.js`). +- Bewegung: Federn `cubic-bezier(.3,1.25,.4,1)` 550 ms (Balken), Blatt und Reel `cubic-bezier(.2,.8,.2,1)` 550 ms. Unter `prefers-reduced-motion: reduce` höchstens 150 ms Überblenden. Knöpfe und Felder am Handy mindestens 44 px hoch, Textfelder 16 px. +- **Tests:** Dateien heißen `test/knHuelle.test.js`. Jede beginnt mit `import { test } from 'node:test'`, `import assert from 'node:assert/strict'`, `installFakeDom` aus `./support/fakeDom.js` (bei DOM-Tests), `h` aus `../public/neu/dom.js` und den Importen der getesteten Module. Die Hilfen `lies`, `da`, `handyTeil`, `HANDY` kommen aus `test/support/knHuelle.js` (Task 1). Jede Datei läuft einzeln mit dem Befehl der Übersicht. +- **Werkzeug:** Es liest den Quellbaum aus `WT`. Für diesen Strang gilt `KN_STRANG_ENV=/root/reports/kundenansicht-neu-2026-10-15/werkzeug/tmp/strang-ui.env` mit der Zeile `export WT=/home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui` (Task 13, Step 1). +- Commit-Trailer wörtlich `Co-Authored-By: Claude Opus 5.5 ` als zweites `-m`; Dateien einzeln hinzufügen. + +## Review Focus + +1. **Alte Links verlieren ihre Query oder landen falsch.** `#termine?bestellt=1&projekt=x`, `#webshop?stripe=zurueck&projekt=x`, `#news?bestellt=1`, `#fortschritt`, `#wartung`, `#abo`, `?abo=success` vor dem Hash: Query bleibt Zeichen für Zeichen, Ziel stimmt, die Alias-Umschreibung löscht nichts, `?abo=` bleibt in `location.search`, bis P2 es aufräumt. `#abo` und `#wartung` ergeben den ausdrücklichen Reiter und den Hash `#abo`, nie `#vertraege`: Nur `#vertraege` lässt die Ansicht den Standardreiter wählen (P2 `waehleReiter` unterscheidet am rohen Hash), sonst springt ein Kunde mit offener Rechnung vom Reiter Abo zurück auf Rechnungen und erreicht Abo nie. Module löschen mit `replaceState(null, …)` den Verlaufsstempel des Routers (`termine.js:40`, `shop.js:59`, `zeiten.js:39`, `funktionen.js:487`). Tests: Task 3 („Alte Links“, „Umkehrung“), Task 4 („Alias“, „Reiter und Bereiche“, „Stempel“), Task 11 („wunschProjekt“). +2. **Zurück-Taste und Android-Geste.** Erst Sheet, dann Etage 2, dann Website; Einstieg per Link direkt in Etage 2 hat einen Eltern-Eintrag; Reiterwechsel erzeugt keine Einträge; Neuladen mit Overlay-Stempel öffnet nichts. Tests: Task 4, Task 9, Task 13 („kn-huelle-konto“). +3. **Schmale Handys und viele Reiter.** 320 px breit: vier Wörter passen; „99+“ schiebt nichts hinaus; Etage 2 mit sechs Reitern scrollt waagerecht und rückt den aktiven Reiter ins Bild (C2); Overlay-Rückfall bei abweichenden Breiten. Tests: Task 5 (Zahlenfunktionen), Task 6 („kn:reiter“), Task 13 („kn-huelle-etage“ bei 320 px). +4. **Tastatur, Querformat, Safe-Area.** Android schließt die Tastatur ohne Blur: Leiste kommt zurück. Drehen setzt die volle Höhe zurück. Leiste und Streifen verschwinden nur bei Texteingabe. Tests: Task 10 („tastaturZustand“), Task 13 („kn-huelle-frist“, „kn-huelle-quer“). +5. **Start bei kaputtem oder fremdem Zustand.** `/api/auth/me` 500 oder Netz weg, abgelaufene Kundenansicht, geändertes Passwort; `/api/fristen` 404 (Server ohne P5) oder 500; Abo und Rechnungen fallen aus: nie eine leere weiße Seite, Zähler behalten den letzten Wert, „Vertrag widerrufen“ bleibt im Konto. Tests: Task 2 (ApiFehler), Task 7, 8, 11 („me 500“, „Fristen 404“), Task 13 („kn-huelle-quellen-fehler“). + +--- + +## Dateiübersicht + +| Datei | Aufgabe | Task | +|---|---|---| +| `public/neu/tokens.css`, `basis.css` | Tokens `--kn-*`; Knöpfe, Felder, Unterstrich-Reiter, Pillen, Listen, Dialog/Sheet, Leerzustände, Meldungen | 1 | +| `public/neu/huelle.css` | Blatt (6), Streifen (8), Konto (9), Leiste, Menü, Kopf, Querformat, reduzierte Bewegung (10) | 6, 8, 9, 10 | +| `public/neu/dom.js`, `api.js` | `h`, `leeren`, `text`; `api`, `ApiFehler` | 2 | +| `public/neu/routen.js`, `router.js` | Routentabelle; History-Regeln, Overlay, Stempel | 3, 4 | +| `public/neu/reiter.js`, `etage.js` | gemeinsame Reiter; Etage 2 | 5, 6 | +| `public/neu/zaehler.js`, `fristen-streifen.js` | Zähler V3; Widerrufsstreifen | 7, 8 | +| `public/neu/konto.js`, `leiste.js` | Avatar und Konto; Fahrstuhl-Zeile, Menü, Tastaturzustand | 9, 10 | +| `public/neu/huelle.js` | Start, Ableitung aus `/api/auth/me`, Zusammenbau, Modul-Lader | 11 | +| `public/neu/dashboard.html` (ersetzt den Platzhalter aus P0), `manifest.webmanifest` | Seite und PWA (weiß) | 12 | +| `public/neu/ansicht-{website,funktionen,chat,vertraege,passwort,gutschrift,wechsel,bestellen}.js`, `ansichten.css`, `funktionen.css`, `module.css` | Stubs, die P2 bis P4 füllen (Vertragsfrage 3) | 12 | +| `test/support/knHuelle.js` | Testhilfen | 1 | +| `test/knHuelle{Css,DomApi,Routen,Router,Reiter,Etage,Zaehler,Fristen,Konto,Leiste,Start,Seite,Quelltext}.test.js` | Tests, je Task eine | 1 bis 12, 14 | +| **Pin aus P0:** `test/kundenansichtDashboard.test.js`, Hilfsfunktion `istNeu` (Marker `data-kn-platzhalter`) | auf `` umstellen, weil die Seite den Platzhalter ersetzt | 12 | +| Werkzeug (`/root/reports/kundenansicht-neu-2026-10-15/werkzeug/`, eigenes Git): `views.mjs`, `gates.mjs`, `test/statik.test.mjs` | Ansichten `kn-huelle-*`, Gate `p1`, die zwei Platzhalter-Ansichten und der Marker im Statik-Test (Task 3 von P0 und Task 10 von P0) | 13 | + +**Bestehende Tests:** sonst keine Änderung nötig. Geprüft: `test/termineView.test.js:397,418` und die Pins in `shopView`, `shopWiring`, `zeitenView`, `zeitenBestellen` lesen `public/` ohne Unterordner; `test/adminPanelGone.test.js:59` sucht rekursiv nur die verbotenen Wörter; `test/auftraegeKundenportalHuelle.test.js:50` pinnt die alte `public/manifest.webmanifest`, die unberührt bleibt. + +**Festlegungen des Pakets (ohne Vertragswirkung):** +- Verlauf: Reiter- und Bereichswechsel `replaceState`; Etage 2 und Konto-Sheet `pushState`, in beiden Layouts (heute nur Handy, `app.js:2226-2229`). Bei Einstieg per Link legt der Router eine Anker-Kette an (Website, Eltern, Ziel), damit „Zurück“ bis zur Website führt (Demo `back()`). +- Das Konto-Sheet ist ein Menü (Tipp aufs Dunkle schließt). Formular-Dialoge `.kn-dialog` schließen nur per × und Esc. +- Desktop-Menü: aktiver Eintrag wie im Ticketsystem (`#111`, Gewicht 600, Fläche `--kn-accent-soft`). +- Das Konto hat weder Ansprechpartner-Karte noch „Abo verwalten“ (Spec 5.2 nennt sie nicht). +- iOS-Statusleiste `default`, `theme-color #ffffff`; der 1-px-Kniff der alten Seite (`login.css:644-655`) gilt nur für `black-translucent` und entfällt. Gerätecheck in Task 14. +- Fristdaten werden in `Europe/Berlin` formatiert (wie `app.js:816`). Die Hülle ruft die Ansicht bei jedem `zeige` neu auf (wie `viewRefreshers`, `app.js:1937`). + +--- + +### Task 1: Tokens und Basis-CSS + +**Files:** +- Create: `public/neu/tokens.css`, `public/neu/basis.css`, `test/support/knHuelle.js` +- Test: `test/knHuelleCss.test.js` + +**Interfaces:** +- Consumes: nichts. +- Produces: `--kn-*` auf `.kn` (Namen im Test) und diese Klassen (die Namen, die P2 in seinem VF5 annimmt): `.kn-knopf`, `.kn-knopf-primaer`, `.kn-knopf-zweit`, `.kn-textknopf`, `.kn-link-danger`, `.kn-feld` (Beschriftung, Feld, Hinweis), `.kn-input`, `.kn-select`, `.kn-textarea`, `.kn-check`, `.kn-reiter` mit `.kn-reiter-t` (Unterstrich), `.kn-pille` mit `-grau|-blau|-orange|-violett|-gruen|-rot|-schlicht` (Status), `.kn-liste`, `.kn-zeile` (`.kn-zeile-link`), `.kn-ueberlagerung`, `.kn-dialog`, `.kn-sheet`, `.kn-leer`, `.kn-meldung` (`-ok`, `-warn`), `.kn-fehler`, `.kn-titel`, `.kn-abschnitt`. +- Testhilfe `test/support/knHuelle.js`: `HANDY` (`'@media (max-width: 880px), (pointer: coarse) and (max-height: 500px)'`), `lies(datei)` (liest `public/neu/` als Text), `da(datei) -> boolean`, `handyTeil(css)` (Text ab dem ersten `HANDY`, sonst `''`). + +- [ ] **Step 0: Vorbedingungen und Worktree** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && git tag --list kn-p0 && ls public/neu/dashboard.html` +Expected: `kn-p0` und der Platzhalter. Fehlt eines, Abbruch und Controller melden (P0 nicht fertig). +Existiert `dev-kn-ui` nicht: `git -C /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht worktree add ../dev-kn-ui -b kn/ui kn-p0 && cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && ln -s ../build/node_modules node_modules`; danach `git status --short` leer. + +- [ ] **Step 1: Write the failing test** (zuerst `test/support/knHuelle.js` nach obiger Signatur, dann) + +```js +import { lies, da, handyTeil } from './support/knHuelle.js' +const tokens = (css) => Object.fromEntries([...css.matchAll(/(--kn-[\w-]+)\s*:\s*([^;]+);/g)].map((m) => [m[1], m[2].trim()])) + +test('tokens.css: Werte wörtlich aus der Übersicht und global.css:7-51', () => { + const t = tokens(lies('tokens.css')) + const soll = { + '--kn-bg': '#fff', '--kn-bg-hover': '#f6f8fa', '--kn-bg-subtle': '#f8f9fb', + '--kn-text': '#1a1f36', '--kn-text-2': '#5e6678', '--kn-text-3': '#8a93a3', + '--kn-line': '#ebeef1', '--kn-line-strong': '#d8dde3', '--kn-accent': '#111', + '--kn-grau-bg': '#ebeef1', '--kn-grau-fg': '#414552', '--kn-blau-bg': '#e0f2fe', '--kn-blau-fg': '#075985', + '--kn-orange-bg': '#fcedb9', '--kn-orange-fg': '#a82c00', '--kn-violett-bg': '#efeafe', '--kn-violett-fg': '#5b21b6', + '--kn-gruen-bg': '#d7f7c2', '--kn-gruen-fg': '#006908', '--kn-rot-bg': '#ffe7f2', '--kn-rot-fg': '#b3093c', + '--kn-danger': '#c0123c', '--kn-radius': '6px', '--kn-radius-lg': '12px', '--kn-sidebar-w': '232px', + '--kn-ring': '0 0 0 1px rgba(60,66,87,.16), 0 1px 1px rgba(0,0,0,.06)', + '--kn-font': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', + } + for (const [name, wert] of Object.entries(soll)) assert.equal(t[name], wert, name) +}) +const VERBOTEN = [/gradient\(/i, /backdrop-filter/i, /filter:\s*blur/i, /prefers-color-scheme/i, /#9d3dfe|#d4b4ff|rgba\(157/i, /font-weight:\s*[89]00/, /dot-pattern|aurora/i] +test('keine verbotenen Muster (Verläufe, Glas, Dunkelmodus, Lila, Gewicht 800)', () => { + for (const datei of ['tokens.css', 'basis.css', 'huelle.css'].filter(da)) for (const m of VERBOTEN) assert.doesNotMatch(lies(datei), m, `${datei} ${m}`) +}) +test('basis.css: Bausteine, Maße des Ticketsystems, 44 px und 16 px am Handy', () => { + const css = lies('basis.css') + for (const k of ['.kn-knopf', '.kn-knopf-primaer', '.kn-knopf-zweit', '.kn-textknopf', '.kn-link-danger', '.kn-feld', '.kn-input', '.kn-select', '.kn-textarea', '.kn-check', '.kn-reiter-t', '.kn-pille-gruen', '.kn-liste', '.kn-zeile-link', '.kn-ueberlagerung', '.kn-dialog', '.kn-sheet', '.kn-leer', '.kn-meldung-warn', '.kn-fehler', '.kn-titel', '.kn-abschnitt']) assert.ok(css.includes(k), k) + assert.match(css, /\.kn-pille\s*\{(?=[^}]*height:\s*20px)(?=[^}]*border-radius:\s*4px)/s) + assert.match(css, /\.kn-titel\s*\{(?=[^}]*font-size:\s*28px)(?=[^}]*line-height:\s*34px)(?=[^}]*font-weight:\s*700)/s) + assert.match(css, /:focus-visible\s*\{(?=[^}]*outline:\s*2px solid #111)(?=[^}]*outline-offset:\s*2px)/s) + assert.match(css, /:disabled[^{]*\{[^}]*opacity:\s*\.55/s) + assert.match(handyTeil(css), /min-height:\s*44px/); assert.match(handyTeil(css), /font-size:\s*16px/) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleCss.test.js` +Expected: FAIL (ENOENT `tokens.css`). + +- [ ] **Step 3: Implement `tokens.css` und `basis.css`** + +`tokens.css`: alle Namen aus dem Test auf `.kn`, dazu `--kn-accent-hover:#2b2b2b`, `--kn-accent-soft:rgba(17,17,17,.06)`, `--kn-accent-ring:rgba(17,17,17,.14)`, `--kn-danger-ring:rgba(192,18,60,.18)`, `--kn-shadow-btn`, `--kn-shadow-pop` (beide wörtlich aus `woms-design.md` Abschnitt 1), `--kn-overlay:rgba(26,31,54,.32)`, `--kn-sp-1…6` (4/8/12/16/24/32 px), `--kn-feder:cubic-bezier(.3,1.25,.4,1)`, `--kn-bremse:cubic-bezier(.2,.8,.2,1)`, `--kn-zeile-h:56px`, `color-scheme: light`. +`basis.css`: Maße und Selektoren 1:1 aus `/root/reports/kundenansicht-neu-2026-10-15/bestand/woms-design.md` Abschnitt 2 und 4, Klassen mit Präfix `kn-` (`.btn`→`.kn-knopf`, `.btn-primary`→`.kn-knopf-primaer`, `.btn-quiet`→`.kn-textknopf`, `.badge`→`.kn-pille`, `.tab`→`.kn-reiter-t`, `.overlay`→`.kn-ueberlagerung`, `.empty`→`.kn-leer`, `.msg.err`→`.kn-fehler`); `.kn-knopf-zweit` sieht aus wie `.kn-knopf`. Unter `HANDY`: Knöpfe, Felder und Reiter mindestens 44 px, Textfelder 16 px, `.kn-dialog`/`.kn-sheet` unten verankert (max. 92 vh). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleCss.test.js` +Expected: PASS (3 Tests). + +- [ ] **Step 5: Commit** + +```bash +git add public/neu/tokens.css public/neu/basis.css test/support/knHuelle.js test/knHuelleCss.test.js +git commit -m "Kundenansicht neu P1: Tokens und Basis-CSS im weißen Stil" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: dom.js, api.js und Quelltext-Regel + +**Files:** +- Create: `public/neu/dom.js`, `public/neu/api.js` +- Test: `test/knHuelleDomApi.test.js`, `test/knHuelleQuelltext.test.js` + +**Interfaces:** +- Consumes: nichts. +- Produces (V2): + - `h(tag, attrs = {}, ...kinder) -> Element`: `attrs.class`, `attrs.on = { click: fn }`, `true` setzt ein Attribut ohne Wert, `false/null/undefined` entfallen, Kinder werden flach gelegt, Strings werden Textknoten. + - `leeren(el) -> el`, `text(el, wert) -> el` (`textContent = String(wert ?? '')`). + - `async api(pfad, { method = 'GET', body, roh } = {}) -> Object`. `roh` ist ein Rohdaten-Body, der unverändert mit `Content-Type: application/octet-stream` geht (wie `fn-util.js:request`, Vertragsfrage 8). + - `class ApiFehler extends Error` mit `status`, `code`, `message`, `field`, `body` (immer ein Objekt). + +- [ ] **Step 1: Write the failing tests** + +`test/knHuelleDomApi.test.js`: +```js +import { api, ApiFehler } from '../public/neu/api.js' +import { leeren, text } from '../public/neu/dom.js' + +test('h: Kundentext bleibt Text; on, boolesche und fehlende Attribute; leeren, text', async () => { + const fx = installFakeDom() + try { + let klicks = 0 + const k = h('button', { class: 'kn-knopf', hidden: false, 'data-x': true, on: { click: () => { klicks += 1 } } }, 'x', [null, 'y']) + await fx.klick(k) + assert.deepEqual([klicks, k.className, k.hasAttribute('hidden'), k.getAttribute('data-x')], [1, 'kn-knopf', false, '']) + assert.deepEqual([fx.text(k), k.children.length], ['xy', 0]) + assert.equal(leeren(k).childNodes.length, 0); assert.equal(text(k, null).textContent, '') + } finally { fx.restore() } +}) +test('api: JSON, ApiFehler, Netzfehler und roh (Review Focus 5)', async () => { + const fx = installFakeDom({ antworten: [{ body: { ok: 1 } }, { status: 409, body: { error: 'Schon da', code: 'bereits_eingeschaltet', field: 'x', extra: 1 } }, { status: 502, body: {} }] }) + try { + assert.deepEqual(await api('/api/x', { method: 'POST', body: { a: 1 } }), { ok: 1 }) + assert.deepEqual(fx.fetchLog[0], { method: 'POST', url: '/api/x', body: { a: 1 } }) + const e = await api('/api/x').catch((x) => x) + assert.ok(e instanceof ApiFehler); assert.deepEqual([e.status, e.code, e.message, e.field, e.body.extra], [409, 'bereits_eingeschaltet', 'Schon da', 'x', 1]) + assert.equal((await api('/api/x').catch((x) => x)).message, 'Server nicht erreichbar. Bitte kurz warten und noch einmal versuchen.') + globalThis.fetch = async () => { throw new TypeError('Failed to fetch') } + const n = await api('/api/x').catch((x) => x); assert.deepEqual([n.status, n.message], [0, 'Keine Verbindung. Bitte versuch es gleich noch einmal.']) + let init; globalThis.fetch = async (_u, i) => { init = i; return { ok: true, status: 200, json: async () => ({}) } } + const blob = new Blob(['abc']); await api('/api/up', { method: 'POST', roh: blob }) + assert.deepEqual([init.headers['Content-Type'], init.body], ['application/octet-stream', blob]) + } finally { fx.restore() } +}) +test('api: session_revoked leitet genau einmal zur Anmeldung (letzter Test der Datei)', async () => { + const weg = { status: 401, body: { error: 'Sitzung beendet', code: 'session_revoked' } } + const fx = installFakeDom({ antworten: [weg, weg] }) + try { + await api('/api/x').catch(() => {}); assert.equal(fx.window.location.href, '/login.html?error=Sitzung%20beendet') + fx.window.location.href = 'anderswo'; await api('/api/x').catch(() => {}); assert.equal(fx.window.location.href, 'anderswo') + } finally { fx.restore() } +}) +``` +`test/knHuelleQuelltext.test.js`: +```js +import { readFileSync, existsSync } from 'node:fs' +const P1 = ['dom', 'api', 'routen', 'router', 'reiter', 'etage', 'zaehler', 'fristen-streifen', 'konto', 'leiste', 'huelle'].map((n) => `${n}.js`) +const pfad = (f) => new URL(`../public/neu/${f}`, import.meta.url) +const ohneKommentare = (t) => t.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '') + +test('P1-Module: kein innerHTML, nur relative Importe, keine Verweise auf entfernte Seiten', () => { + for (const f of P1.filter((d) => existsSync(pfad(d)))) { + const q = ohneKommentare(readFileSync(pfad(f), 'utf8')) + assert.doesNotMatch(q, /innerHTML|insertAdjacentHTML|outerHTML|document\.write|\beval\(/, f) + assert.doesNotMatch(q, /admin\.html|kalkulation\.html|\/api\/portal-admin/, f) + for (const m of q.matchAll(/from '([^']+)'/g)) assert.match(m[1], /^\.\//, `${f}: nur relative Importe`) + } +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +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/knHuelleDomApi.test.js test/knHuelleQuelltext.test.js` +Expected: `knHuelleDomApi` FAIL (Modul nicht gefunden); `knHuelleQuelltext` PASS (noch keine P1-Datei da; wird mit jedem Task schärfer). + +- [ ] **Step 3: Implement `dom.js` and `api.js`** + +`h` wie `termine-dom.js:7` (gleiche Regeln, plus `attrs.on`). `api`: `fetch(pfad, { method, credentials: 'same-origin', headers, body })`; Fehlertext `data.error`, bei 502 der Text aus dem Test, sonst `Fehler ${status}`; fetch wirft → `status 0`; `session_revoked` setzt einmal `window.location.href = '/login.html?error=' + encodeURIComponent(message)` (Merker im Modul). + +- [ ] **Step 4: Run tests to verify they pass** + +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/knHuelleDomApi.test.js test/knHuelleQuelltext.test.js` +Expected: PASS (4 Tests). + +- [ ] **Step 5: Commit** + +```bash +git add public/neu/dom.js public/neu/api.js test/knHuelleDomApi.test.js test/knHuelleQuelltext.test.js +git commit -m "Kundenansicht neu P1: h(), api() mit ApiFehler und Quelltext-Regel" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: routen.js (Routentabelle, alte Links) + +**Files:** +- Create: `public/neu/routen.js` +- Test: `test/knHuelleRouten.test.js` + +**Interfaces:** +- Consumes: nichts. +- Produces (V2 in der Fassung von Vertragsfrage 4: `vertraege` ohne Reiter, `abo` und `wartung` mit Reiter `abo`): `BEREICHE`, `ROUTEN`, `routeAusHash(hash) -> Route`, `hashFuerRoute(route) -> string`. `Route = { bereich, reiter?, etage?, produkt?, scroll?, query }`; `query` ist immer ein String ohne `?`, nicht gesetzte Felder fehlen. `produkt` (`'hosting'|'wartung'`) und `scroll` (`'aktivitaet'`) sind die Zusatzfelder zu V2 „plus Produkt“ und „scrollt zur Aktivität“ (Vertragsfrage 4). +- Zusätzlich: `hashFuerAnsicht(name) -> '#…' | null` (alter `data-view`-Name zum Hash, die Brücke für die gemeinsamen Module); `startHash({ search, hash }) -> string`; `ankerKette(route) -> string[]` (Hashes von der Website bis zum Ziel); `elternVon(route, herkunft = '') -> Route | null`; `titelFuer(route) -> string`; `FUNKTIONS_ETAGEN = ['news','termine','webshop','buchungen','zeiten','analyse']`. + +- [ ] **Step 1: Write the failing test** + +```js +import { BEREICHE, ROUTEN, routeAusHash, hashFuerRoute, hashFuerAnsicht, startHash, ankerKette, elternVon, titelFuer } from '../public/neu/routen.js' +const FAELLE = [ // Alte Links (Review Focus 1) + ['', { bereich: 'website', query: '' }], + ['#termine?bestellt=1&projekt=x', { bereich: 'funktionen', etage: 'termine', query: 'bestellt=1&projekt=x' }], + ['#webshop?stripe=zurueck&projekt=x', { bereich: 'funktionen', etage: 'webshop', query: 'stripe=zurueck&projekt=x' }], + ['#buchungen?stripe=neu&projekt=x', { bereich: 'funktionen', etage: 'buchungen', query: 'stripe=neu&projekt=x' }], + ['#news?bestellt=1', { bereich: 'funktionen', etage: 'news', query: 'bestellt=1' }], + ['#funktionen', { bereich: 'funktionen', reiter: 'meine', query: '' }], ['#entdecken', { bereich: 'funktionen', reiter: 'entdecken', query: '' }], + ['#rechnungen', { bereich: 'vertraege', reiter: 'rechnungen', query: '' }], ['#stunden', { bereich: 'vertraege', reiter: 'stunden', query: '' }], + ['#speicher', { bereich: 'vertraege', reiter: 'email', query: '' }], ['#gutschrift', { bereich: 'vertraege', etage: 'gutschrift', query: '' }], + ['#wartung', { bereich: 'vertraege', reiter: 'abo', query: '' }], ['#abo', { bereich: 'vertraege', reiter: 'abo', query: '' }], ['#vertraege', { bereich: 'vertraege', query: '' }], // #vertraege ohne Reiter: die Ansicht wählt den Standardreiter + ['#fortschritt', { bereich: 'website', scroll: 'aktivitaet', query: '' }], ['#projekt?id=p1', { bereich: 'website', etage: 'projekt', query: 'id=p1' }], + ['#Chat', { bereich: 'chat', query: '' }], ['#passwort', { bereich: 'website', etage: 'passwort', query: '' }], + ['#bestellen-wartung', { bereich: 'vertraege', etage: 'bestellen', produkt: 'wartung', query: '' }], + ['#bestellen-hosting', { bereich: 'vertraege', etage: 'bestellen', produkt: 'hosting', query: '' }], + ['#gibtesnicht', { bereich: 'website', query: '' }], +] +test('Alte Links: Ziel und Query stimmen (Review Focus 1), V2-Beispiel', () => { + for (const [hash, soll] of FAELLE) assert.deepEqual(routeAusHash(hash), soll, hash) + assert.deepEqual(routeAusHash('#termine?bestellt=1'), { bereich: 'funktionen', etage: 'termine', query: 'bestellt=1' }) +}) +test('hashFuerRoute ist die Umkehrung: Query bleibt, #wartung wird #abo, #vertraege und #abo fallen nicht zusammen; Hash-Namen nur [a-z]', () => { + const kanon = { '#wartung': '#abo', '#Chat': '#chat', '#gibtesnicht': '#', '': '#' } + for (const [hash] of FAELLE) assert.equal(hashFuerRoute(routeAusHash(hash)), kanon[hash] ?? hash, hash) + assert.deepEqual(['#vertraege', '#abo', '#wartung'].map((hash) => hashFuerRoute(routeAusHash(hash))), ['#vertraege', '#abo', '#abo']) // Review Focus 1: Reiterklick auf Abo darf nie #vertraege schreiben + assert.equal('reiter' in routeAusHash('#vertraege'), false); assert.equal(routeAusHash('#abo').reiter, 'abo') + assert.deepEqual(BEREICHE, ['website', 'funktionen', 'chat', 'vertraege']) + for (const k of Object.keys(ROUTEN).filter((k) => !k.startsWith('bestellen-'))) assert.match(k, /^[a-z]*$/, k) // Login-Whitelist app.js:466 +}) +test('data-view-Brücke: jeder Name aus VIEW_HASHES (app.js:1933 wörtlich) führt zum gleichen Hash', () => { + const ALT = { projects: '', funktionen: 'funktionen', news: 'news', analyse: 'analyse', termine: 'termine', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort', webshop: 'webshop', buchungen: 'buchungen', zeiten: 'zeiten' } + for (const [name, hash] of Object.entries(ALT)) assert.equal(hashFuerAnsicht(name), `#${hash}`, name) + assert.equal(hashFuerAnsicht('unbekannt'), null) +}) +test('startHash: ?abo= führt zum Reiter Abo (die Query bleibt in search), #wartung wird #abo, #vertraege bleibt, sonstige Query bleibt', () => { + assert.equal(startHash({ search: '?abo=success', hash: '' }), '#abo') + assert.equal(startHash({ search: '?abo=cancel', hash: '#chat' }), '#abo') + assert.equal(startHash({ search: '', hash: '#wartung' }), '#abo') + assert.equal(startHash({ search: '', hash: '#vertraege' }), '#vertraege') + assert.equal(startHash({ search: '?x=1', hash: '#termine?bestellt=1&projekt=p1' }), '#termine?bestellt=1&projekt=p1') + assert.equal(startHash({ search: '', hash: '' }), '#') +}) +test('ankerKette, elternVon, titelFuer', () => { + const r = routeAusHash + assert.deepEqual(ankerKette(r('#termine?bestellt=1')), ['#', '#funktionen', '#termine?bestellt=1']) + assert.deepEqual(ankerKette(r('#chat')), ['#', '#chat']); assert.deepEqual(ankerKette(r('#gutschrift')), ['#', '#vertraege', '#gutschrift']) + assert.deepEqual(ankerKette(r('#abo')), ['#', '#abo']); assert.deepEqual(ankerKette(r('#vertraege')), ['#', '#vertraege']) + assert.deepEqual(ankerKette(r('#passwort')), ['#', '#passwort']); assert.deepEqual(ankerKette(r('#fortschritt')), ['#fortschritt']) + assert.deepEqual(elternVon(r('#termine')), { bereich: 'funktionen', reiter: 'meine', query: '' }) + assert.deepEqual(elternVon(r('#gutschrift')), { bereich: 'vertraege', query: '' }) // Bereichs-Anker #vertraege, kein Reiter + assert.deepEqual(elternVon(r('#passwort'), '#chat'), { bereich: 'chat', query: '' }); assert.equal(elternVon(r('#passwort')).bereich, 'website'); assert.equal(elternVon(r('#chat')), null) + assert.deepEqual(['#termine', '#zeiten', '#bestellen-wartung', '#passwort', '#chat'].map((h) => titelFuer(r(h))), ['Terminplaner', 'Öffnungszeiten', 'Abo bestellen', 'Passwort ändern', 'Chat']) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleRouten.test.js` +Expected: FAIL (Modul nicht gefunden). + +- [ ] **Step 3: Implement `routen.js`** + +`ROUTEN` ist ein Objekt `name → Route-ohne-query` (V2 „Pflichtinhalt“); je Ziel steht die kanonische Schreibweise zuerst. `vertraege` ist ein eigenes Ziel ohne `reiter` (die Ansicht wählt den Standardreiter); `abo` und `wartung` sind dasselbe Ziel `{ bereich:'vertraege', reiter:'abo' }`, kanonisch `#abo` (`abo` steht vor `wartung`). So schreibt ein Klick auf den Reiter Abo `#abo` und nie `#vertraege`, und die Ansicht (P2 `waehleReiter`) kann die ausdrückliche Wahl vom Standardsprung unterscheiden. `routeAusHash` trennt am ersten `?`, klein geschrieben wird nur der Name, unbekannte Namen ergeben die Website. `hashFuerRoute` sucht den ersten Eintrag mit gleichen Feldern (`bereich`, `reiter`, `etage`, `produkt`, `scroll`), hängt `?query` an; die Website ohne Felder ergibt `'#'`. `ankerKette`: Website-Wurzel, bei Etage außerhalb der Website der Bereich (`funktionen` mit Reiter `meine`, `vertraege` ohne Reiter, Hash `#vertraege`), dann die Route selbst; eine Website-Route mit `scroll` steht allein. `elternVon` einer Etage ist derselbe Bereich, bei `passwort` die Route aus `herkunft` (sonst Website). `startHash`: ein Parameter `abo` in `search` ergibt `#abo` (der Reiter, auf dem P2 die Rückkehr von Stripe zeigt), Aliase werden kanonisch. Titel: Bereiche `Website|Funktionen|Chat|Verträge`; Etagen `Projekt|News|Terminplaner|Webshop|Buchungskalender|Öffnungszeiten|Analyse|Übernahmegutschrift|Abo wechseln|Abo bestellen|Passwort ändern`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleRouten.test.js` +Expected: PASS (5 Tests). + +- [ ] **Step 5: Commit** + +```bash +git add public/neu/routen.js test/knHuelleRouten.test.js +git commit -m "Kundenansicht neu P1: Routentabelle mit allen alten Hashes" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: router.js (History-Regeln) + +**Files:** +- Create: `public/neu/router.js` +- Test: `test/knHuelleRouter.test.js` + +**Interfaces:** +- Consumes: `routeAusHash`, `hashFuerRoute`, `startHash`, `ankerKette`, `elternVon` aus `./routen.js`. +- Produces (V2): + - `starteRouter({ zeige, umgebung = fenster() }) -> { beende() }`; `zeige(route, { nav })`, `nav` ist `'tab'|'push'|'back'|'same'`; `umgebung = { location, history, addEventListener, removeEventListener }`, Vorgabe `window`. + - `geheZu(hash, { ersetzen = false } = {})`: schreibt den Verlauf und ruft danach selbst `zeige`. + - Zusätzlich `oeffneOverlay({ schliesse })`, `beendeOverlay()`, `zurueck()` (Zurück der Etage), `stempeln()`, `aktuelleRoute()`. +- Regeln (Stempel `history.state = { kn: { tiefe, overlay } }`; URL der Website ist `pathname + search` ohne `#`): + - R1 Bereichs- und Reiterwechsel: `replaceState`, `nav 'tab'`. Geschrieben wird immer `hashFuerRoute(route)` (kanonisch: `#wartung` wird `#abo`; `#abo` und `#vertraege` sind verschiedene Routen, ein Wechsel von `#vertraege` auf `#abo` schreibt `#abo` mit `nav 'tab'` und nie `#vertraege`). Gleiche Route: nichts schreiben, `zeige` mit `'same'`. + - R2 von Ebene 1 in eine Etage (und von Etage zu Etage): `pushState`, `nav 'push'`. `ersetzen: true` erzwingt `replaceState` (nav bleibt). + - R3 `popstate`: Ebene höher → `'push'`, sonst `'back'`. + - R4 Start: `startHash` anwenden, `zeige` einmal mit `'tab'`. `location.search` bleibt am Zieleintrag unberührt (P2 liest `?abo=` dort, Vertragsfrage 7), die Anker-Einträge davor tragen `search` ohne den Parameter `abo`. Fehlt der Stempel, legt der Router die `ankerKette` an (erster Eintrag `replaceState`, die übrigen `pushState`); mit Stempel passiert nichts. + - R5 `zurueck()`: `tiefe > 0` → `history.back()`, sonst `geheZu(Eltern, { ersetzen: true })` mit `'back'`. + - R6 Overlay: `oeffneOverlay` schiebt einen Eintrag mit `overlay: true`; `popstate` darauf ruft `schliesse()` und kein `zeige`. `geheZu` bei offenem Overlay ersetzt den Overlay-Eintrag und ruft `schliesse()` genau einmal. Ein Overlay-Stempel beim Start wird gelöscht. + - R7 `stempeln()` schreibt den Stempel nach einem `replaceState(null, …)` eines Moduls neu. + +- [ ] **Step 1: Write the failing test** + +Kopf: `starteRouter, geheZu, oeffneOverlay, beendeOverlay, zurueck, stempeln` aus `../public/neu/router.js`, `hashFuerRoute` aus `../public/neu/routen.js`. Hilfe in der Datei: `baueVerlauf(url, state = null)` liefert `{ umgebung, urls(), aktuell(), zurueck() }` (ca. 25 Zeilen: Einträge `{ url, state }` mit Index; `pushState` schneidet Vorwärtseinträge ab; `zurueck()` setzt den Index und ruft die `popstate`-Handler synchron; `location.pathname|search|hash` und `history.state` kommen aus dem aktuellen Eintrag). `start(url, state)` ruft `starteRouter({ zeige, umgebung })` und sammelt `log = [[hashFuerRoute(route), nav], …]`. + +```js +test('Start auf Chat legt den Anker an; Zurück führt zur Website (Review Focus 2)', () => { + const { v, log } = start('/dashboard.html#chat') + assert.deepEqual(v.urls(), ['/dashboard.html', '/dashboard.html#chat']); assert.deepEqual(log, [['#chat', 'tab']]) + v.zurueck(); assert.deepEqual(log.at(-1), ['#', 'back']) +}) +test('Start in Etage 2: Kette Website, Funktionen, Terminplaner; Query bleibt (Review Focus 1)', () => { + assert.deepEqual(start('/dashboard.html#termine?bestellt=1&projekt=x').v.urls(), ['/dashboard.html', '/dashboard.html#funktionen', '/dashboard.html#termine?bestellt=1&projekt=x']) +}) +test('Alias wird kanonisch (#wartung wird #abo, #abo bleibt #abo), ?abo= führt zum Reiter Abo und bleibt am Zieleintrag; Neuladen mit Stempel baut keine zweite Kette', () => { + assert.equal(start('/dashboard.html#wartung').v.aktuell(), '/dashboard.html#abo'); assert.equal(start('/dashboard.html#abo').v.aktuell(), '/dashboard.html#abo') + const a = start('/dashboard.html?abo=success#chat') + assert.deepEqual([a.v.urls(), a.log[0]], [['/dashboard.html', '/dashboard.html?abo=success#abo'], ['#abo', 'tab']]) + assert.equal(start('/dashboard.html#termine', { kn: { tiefe: 2, overlay: false } }).v.urls().length, 1) +}) +test('Reiter und Bereiche ersetzen, #abo wird nie #vertraege, Etage 2 schiebt, same, ersetzen erzwingt replaceState', () => { + const { v, log } = start('/dashboard.html#vertraege'); const n = v.urls().length + geheZu('#rechnungen'); geheZu('#chat'); assert.equal(v.urls().length, n); assert.deepEqual(log.slice(1), [['#rechnungen', 'tab'], ['#chat', 'tab']]) + geheZu('#chat'); assert.equal(log.at(-1)[1], 'same') + geheZu('#vertraege'); geheZu('#abo'); assert.deepEqual([v.aktuell(), log.at(-1), v.urls().length], ['/dashboard.html#abo', ['#abo', 'tab'], n]) // Reiterklick Abo: ausdrücklicher Hash (Review Focus 1) + geheZu('#funktionen'); geheZu('#termine'); assert.equal(v.urls().length, n + 1); assert.deepEqual(log.at(-1), ['#termine', 'push']) + zurueck(); assert.deepEqual(log.at(-1), ['#funktionen', 'back']); assert.equal(v.urls().length, n + 1) + geheZu('#termine', { ersetzen: true }); assert.equal(v.urls().length, n + 1) +}) +test('Stempel: Modul löscht ihn mit replaceState(null), stempeln() stellt ihn her, Zurück bleibt history.back', () => { + const { v } = start('/dashboard.html#funktionen'); geheZu('#termine') + v.umgebung.history.replaceState(null, '', '/dashboard.html#termine') // wie termine.js:40 + assert.equal(v.umgebung.history.state, null); stempeln(); assert.ok(v.umgebung.history.state.kn.tiefe >= 1) + const n = v.urls().length; zurueck(); assert.deepEqual([v.urls().length, v.aktuell()], [n, '/dashboard.html#funktionen']) +}) +test('Zurück ohne eigenen Eintrag ersetzt durch das Elternziel', () => { + const { v, log } = start('/dashboard.html#termine', { kn: { tiefe: 0, overlay: false } }); const n = v.urls().length + zurueck(); assert.deepEqual([v.aktuell(), v.urls().length, log.at(-1)], ['/dashboard.html#funktionen', n, ['#funktionen', 'back']]) +}) +test('Overlay: Zurück schließt zuerst das Sheet; Navigation ersetzt den Overlay-Eintrag; Neuladen mit Overlay-Stempel', () => { + const { v, log } = start('/dashboard.html#chat'); let zu = 0; const sch = { schliesse: () => { zu += 1 } } + oeffneOverlay(sch); assert.equal(v.urls().length, 3) + v.zurueck(); assert.deepEqual([zu, log.length], [1, 1]) // zeige nicht erneut gerufen + oeffneOverlay(sch); geheZu('#passwort') + assert.deepEqual([zu, v.aktuell(), v.urls().length, log.at(-1)[1]], [2, '/dashboard.html#passwort', 3, 'push']) + const b = start('/dashboard.html#chat', { kn: { tiefe: 1, overlay: true } }); assert.equal(b.v.umgebung.history.state.kn.overlay, false) + oeffneOverlay(sch); beendeOverlay(); assert.equal(zu, 3) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleRouter.test.js` +Expected: FAIL (Modul nicht gefunden). + +- [ ] **Step 3: Implement `router.js`** + +Moduleigener Zustand (`aktuell`, `zeigeFn`, `overlayFn`, `tiefe`) wird von `starteRouter` zurückgesetzt; `beende()` entfernt den `popstate`-Hörer. Route aus `location.hash`, Tiefe aus dem Stempel (fehlt er: unverändert). Kein Zugriff auf `window` beim Import. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleRouter.test.js` +Expected: PASS (7 Tests). + +- [ ] **Step 5: Commit** + +```bash +git add public/neu/router.js test/knHuelleRouter.test.js +git commit -m "Kundenansicht neu P1: Router mit Anker-Kette, Overlay und Verlaufsstempel" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 5: reiter.js (gemeinsame Reiter, Zahlenfunktionen) + +**Files:** +- Create: `public/neu/reiter.js` +- Test: `test/knHuelleReiter.test.js` + +**Interfaces:** +- Consumes: `h` aus `./dom.js`. +- Produces: + - `baueReiter({ eintraege, aktiv, art = 'unterstrich', ariaLabel = 'Reiter', bei, warte = (fn, ms) => setTimeout(fn, ms) }) -> HTMLElement` (Form wie P3 VF1). Eintrag `{ key, label, kurz?, einheit?, ausrufText? }`. Der Knoten trägt die Methoden `setzeAktiv(key)`, `setzeAnzeige(key, { zahl = 0, ausruf = false })`, `messen()`. `art 'balken'` baut den Gleiter (schwarzer Balken) mit Overlay-Kopie nach `final-2-frei.html` (`.kn-balken > .kn-overlay`, Overlay `aria-hidden`, ohne Rollen), `art 'unterstrich'` die Reiter von `basis.css`. + - Rein: `balkenMasse({ basis: [{ links, breite }], aktiv, gesamtBreite }) -> { links, breite, overlayVersatz, overlayBreite }`; `overlayPasst(basisBreiten, overlayBreiten, toleranz = 0.5) -> boolean`; `schriftStufe({ inhaltsBreite, verfuegbar }) -> 15 | 14`; `sichtbarMachen({ scrollLinks, sichtbarBreite, itemLinks, itemBreite, rand = 16 }) -> number`; `anzeigeText({ zahl, ausruf }) -> string`. + +- [ ] **Step 1: Write the failing test** + +```js +import { balkenMasse, overlayPasst, schriftStufe, sichtbarMachen, anzeigeText, baueReiter } from '../public/neu/reiter.js' +test('Zahlenfunktionen: Balken, Overlay-Rückfall, Schriftstufe, Sichtbarmachen (C2)', () => { + const basis = [{ links: 0, breite: 90 }, { links: 90, breite: 100 }, { links: 190, breite: 60 }, { links: 250, breite: 90 }] + assert.deepEqual(balkenMasse({ basis, aktiv: 1, gesamtBreite: 340 }), { links: 90, breite: 100, overlayVersatz: -90, overlayBreite: 340 }) + assert.deepEqual([overlayPasst([90, 100], [90, 100.4]), overlayPasst([90, 100], [90, 101])], [true, false]) + assert.deepEqual([schriftStufe({ inhaltsBreite: 342, verfuegbar: 342 }), schriftStufe({ inhaltsBreite: 343, verfuegbar: 342 })], [15, 14]) + const s = (scrollLinks, itemLinks) => sichtbarMachen({ scrollLinks, sichtbarBreite: 300, itemLinks, itemBreite: 80 }) + assert.deepEqual([s(0, 420), s(200, 120), s(100, 150), s(50, 5)], [216, 104, 100, 0]) +}) +test('anzeigeText: Zahl, "!" und Deckel 99+ (Review Focus 3)', () => { + assert.deepEqual([0, 7, 99, 100, 1500].map((zahl) => anzeigeText({ zahl })), ['', '7', '99', '99+', '99+']) + assert.deepEqual([anzeigeText({ zahl: 3, ausruf: true }), anzeigeText({ zahl: 0, ausruf: true })], ['!', '!']) +}) +test('baueReiter: Rollen, Auswahl, Kurzlabel, Pfeiltasten, Anzeige mit Beschriftung', async () => { + const fx = installFakeDom() + try { + const gew = [] + const eintraege = [{ key: 'termine', label: 'Termine' }, { key: 'team', label: 'Team & Plätze', kurz: 'Team' }, { key: 'chat', label: 'Chat', einheit: 'ungelesen' }] + const r = baueReiter({ eintraege, aktiv: 'termine', art: 'balken', ariaLabel: 'Bereiche', bei: (k) => gew.push(k), warte: (fn) => fn() }) + const tabs = fx.alle(r, '[role=tab]') + assert.deepEqual(tabs.map((t) => t.textContent), ['Termine', 'Team', 'Chat']); assert.equal(tabs[1].getAttribute('aria-label'), 'Team & Plätze') + assert.deepEqual(tabs.map((t) => t.getAttribute('aria-selected')), ['true', 'false', 'false']); assert.equal(tabs[0].getAttribute('aria-current'), 'page') + assert.equal(fx.alle(r, '.kn-overlay [role=tab]').length, 0) + await fx.klick(tabs[1]); await fx.sende(tabs[1], 'keydown', { key: 'ArrowRight' }); assert.deepEqual(gew, ['team', 'chat']) + r.setzeAktiv('chat'); assert.equal(tabs[2].getAttribute('aria-selected'), 'true') + r.setzeAnzeige('chat', { zahl: 2 }) + assert.deepEqual([fx.finde(tabs[2], '.kn-zahl').textContent, tabs[2].getAttribute('aria-label')], ['2', 'Chat, 2 ungelesen']) + r.setzeAnzeige('chat', { zahl: 0 }); assert.deepEqual([fx.finde(tabs[2], '.kn-zahl'), tabs[2].hasAttribute('aria-label')], [null, false]) + } finally { fx.restore() } +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleReiter.test.js` +Expected: FAIL (Modul nicht gefunden). + +- [ ] **Step 3: Implement `reiter.js`** + +Reiter sind `button[role=tab]` in `div[role=tablist][aria-label]`; `aria-selected` an allen, `aria-current="page"` nur am aktiven; Pfeiltasten wählen den Nachbarn (rufen `bei`). Anzeige als `span.kn-zahl` im Tab; bei `zahl 0` ohne `ausruf` bekommt sie `kn-zahl--weg` und wird nach 260 ms über `warte` entfernt. Beschriftung `${label}, ${zahl} ${einheit}`, bei `ausruf` `${label}, ${ausrufText}`, ohne Anzeige kein `aria-label` (außer bei `kurz`: dann `label`). Die Anzeige wird im Overlay gespiegelt. `messen()` (nur mit `getBoundingClientRect`) setzt Gleiter und Overlay nach `balkenMasse`, die Klasse `kn-schrift-klein` nach `schriftStufe`, den Rückfall `kn-fb` bei `!overlayPasst` und rückt den aktiven Reiter per `sichtbarMachen` im Scroll-Container ins Bild; ein `ResizeObserver` (falls vorhanden) ruft `messen()`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleReiter.test.js` +Expected: PASS (3 Tests). + +- [ ] **Step 5: Commit** + +```bash +git add public/neu/reiter.js test/knHuelleReiter.test.js +git commit -m "Kundenansicht neu P1: gemeinsame Reiter mit Balken, Overlay-Rückfall und Zählern" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 6: etage.js (Etage 2) und Blatt-CSS + +**Files:** +- Create: `public/neu/etage.js`, `public/neu/huelle.css` +- Test: `test/knHuelleEtage.test.js` + +**Interfaces:** +- Consumes: `h` aus `./dom.js`; `baueReiter`, `sichtbarMachen` aus `./reiter.js`. +- Produces: + - `baueEtage({ blatt, haupt, zeile, titel, brot, reiterSlot, istHandy, onZurueck }) -> Etage` (alles Elemente aus `dashboard.html`, `istHandy: () => boolean`). + - `Etage = { oeffne({ titel, eltern: { label, hash }, seite }), schliesse(), setzeReiter({ eintraege, aktiv, bei }), neuZeichnen(), setzeChatPunkt(an), istOffen() }`. + - **Ereignisse** (Vertragsfrage 5): `blatt` hört auf `kn:reiter` (`detail: { eintraege: [{ key, label, kurz? }], aktiv, bei }`, leere Liste = keine Reiter) und `kn:titel` (`detail: { text }`); beide steigen aus jedem Nachfahren auf. + +- [ ] **Step 1: Write the failing test** + +```js +import { baueEtage } from '../public/neu/etage.js' +import { lies, handyTeil } from './support/knHuelle.js' +const teile = () => ({ blatt: h('div', { hidden: true }, h('section', { 'data-etage': 'termine' })), haupt: h('main'), zeile: h('div'), titel: h('h1'), brot: h('nav'), reiterSlot: h('div') }) +const REITER = ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen', 'Kalender', 'Rechnungen'].map((label, i) => ({ key: `r${i}`, label, kurz: label.split(' ')[0] })) +const EL = { label: 'Funktionen', hash: '#funktionen' } + +test('oeffne und schliesse: Titel, Zurück mit Ziel, Brotkrume, Hauptseite gesperrt', async () => { + const fx = installFakeDom() + try { + const t = teile(); let zu = 0; const e = baueEtage({ ...t, istHandy: () => true, onZurueck: () => { zu += 1 } }) + e.oeffne({ titel: 'Terminplaner', eltern: EL, seite: 'termine' }) + assert.deepEqual([t.blatt.hidden, t.haupt.getAttribute('inert'), e.istOffen(), t.titel.textContent], [false, '', true, 'Terminplaner']) + assert.deepEqual([fx.text(t.brot), fx.finde(t.brot, 'a').getAttribute('href')], ['Funktionen / Terminplaner', '#funktionen']) + const z = fx.finde(t.zeile, 'button'); assert.deepEqual([z.textContent, z.getAttribute('aria-label')], ['Zurück', 'Zurück zu Funktionen']) + await fx.klick(z); assert.equal(zu, 1) + e.schliesse(); assert.deepEqual([t.blatt.hidden, t.haupt.hasAttribute('inert'), fx.alle(t.zeile, 'button').length], [true, false, 0]) + } finally { fx.restore() } +}) +test('kn:reiter vom Inhalt: Handy Kurzlabels in der Zeile, Desktop Unterstrich im Slot, ohne Reiter füllt Zurück die Zeile (C2)', async () => { + const fx = installFakeDom() + try { + const gew = []; let handy = true; const t = teile(); const e = baueEtage({ ...t, istHandy: () => handy, onZurueck() {} }) + e.oeffne({ titel: 'Terminplaner', eltern: EL, seite: 'termine' }); const kind = t.blatt.children[0] + await fx.sende(kind, 'kn:reiter', { detail: { eintraege: REITER, aktiv: 'r0', bei: (k) => gew.push(k) } }) + const tabs = fx.alle(t.zeile, '[role=tab]') + assert.deepEqual(tabs.map((x) => x.textContent), ['Termine', 'Leistungen', 'Team', 'Einstellungen', 'Kalender', 'Rechnungen']); assert.equal(tabs[2].getAttribute('aria-label'), 'Team & Plätze') + await fx.klick(tabs[2]); assert.deepEqual(gew, ['r2']) + await fx.sende(kind, 'kn:reiter', { detail: { eintraege: [], aktiv: '', bei() {} } }) + assert.deepEqual([fx.alle(t.zeile, '[role=tab]').length, Boolean(fx.finde(t.zeile, '.kn-zurueck--voll'))], [0, true]) + handy = false; e.neuZeichnen(); await fx.sende(kind, 'kn:reiter', { detail: { eintraege: REITER, aktiv: 'r0', bei() {} } }) + assert.deepEqual([fx.alle(t.reiterSlot, '[role=tab]').length, fx.alle(t.zeile, '[role=tab]').length], [6, 0]) + await fx.sende(kind, 'kn:titel', { detail: { text: 'Kalender' } }); assert.equal(t.titel.textContent, 'Kalender') + } finally { fx.restore() } +}) +test('Chat-Punkt am Zurück', () => { + const fx = installFakeDom() + try { + const t = teile(); const e = baueEtage({ ...t, istHandy: () => true, onZurueck() {} }) + e.oeffne({ titel: 'Webshop', eltern: EL, seite: 'webshop' }); e.setzeChatPunkt(true) + assert.ok(fx.finde(t.zeile, '.kn-punkt')); assert.equal(fx.finde(t.zeile, 'button').getAttribute('aria-label'), 'Zurück zu Funktionen, neue Nachricht im Chat') + } finally { fx.restore() } +}) +test('huelle.css (Blatt): Handy fährt von unten ein, Zurück füllt die Zeile', () => { + const css = lies('huelle.css') + assert.match(handyTeil(css), /\.kn-blatt\s*\{(?=[^}]*border-radius:\s*12px 12px 0 0)(?=[^}]*transition:[^}]*550ms[^}]*var\(--kn-bremse\))/s) + assert.match(css, /\.kn-zurueck--voll\s*\{[^}]*flex:\s*1/s) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleEtage.test.js` +Expected: FAIL (Modul nicht gefunden). + +- [ ] **Step 3: Implement `etage.js` und den Abschnitt „Blatt“ in `huelle.css`** + +`setzeChatPunkt` merkt den Zustand auch für spätere `oeffne`-Aufrufe. `oeffne` zeigt `blatt`, sperrt `haupt` per `inert`, setzt Titel, baut Brotkrume (``, ` / `, Titel; nur am Desktop sichtbar) und am Handy die Zeile (`button.kn-zurueck` mit CSS-Spitze statt Icon, davor kein Zeichen im Text), zeigt nur `section[data-etage=seite]`. Reiter: am Handy in der Zeile hinter einem senkrechten Strich `.kn-vl` als `art 'balken'`, am Desktop in `reiterSlot` als `art 'unterstrich'`; Beschriftung `kurz ?? label`; nach `bei` rückt `sichtbarMachen` den aktiven Reiter ins Bild (nur mit `getBoundingClientRect`). CSS: Desktop-Grundregel: `.kn-blatt` füllt die Spalte ohne Schatten. Unter `HANDY`: `position:fixed; inset:0 0 var(--kn-zeile-h)`, `border-radius:12px 12px 0 0`, Schatten `0 0 0 1px rgba(26,31,54,.06), 0 -8px 24px rgba(26,31,54,.1)`, `translateY(100%) → 0` in `550ms var(--kn-bremse)`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleEtage.test.js` +Expected: PASS (4 Tests). + +- [ ] **Step 5: Commit** + +```bash +git add public/neu/etage.js public/neu/huelle.css test/knHuelleEtage.test.js +git commit -m "Kundenansicht neu P1: Etage 2 mit Zurück, Reiterzeile und Brotkrume" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 7: zaehler.js (V3) + +**Files:** +- Create: `public/neu/zaehler.js` +- Test: `test/knHuelleZaehler.test.js` + +**Interfaces:** +- Consumes: nichts (`api` wird übergeben). +- Produces: + - `berechneVertraege({ rechnungen = [], rueckfragen = [], paymentProblem = false }) -> { zahl, ausruf }`: `zahl` = Rechnungen mit `Number(balance) > 0` plus Rückfragen mit `status === 'rueckfrage'`; `ausruf = paymentProblem`. + - `erzeugeZaehler({ api, onChange, sichtbar = () => true, imChat = () => false, zeitgeber = { setze: setInterval, loesche: clearInterval } }) -> { async start(), stop(), async aktualisiere(was = 'alle'), setzeChat(zahl), stand() }`, `was` ist `'chat'|'vertraege'|'alle'`. `stand() = { chat, vertraege: { zahl, ausruf } }`; `onChange(stand)` bei jeder Änderung. `const CHAT_POLL_MS = 45000`. Die „Rechnung offen“-Zeile aus Spec 5.3 zeichnet dieses Paket nicht (Vertragsfrage 6). +- Quellen (geprüft): `GET /api/chat/unread-count` → `{ unread }` (`chat.js:237`); `GET /api/invoices` → `{ invoices }` (`customerData.js:18`); `GET /api/abo/takeover-credits` → `{ requests:[{ status }] }`; `GET /api/abo` → `{ subscription: { paymentProblem, switchPending } | null }` (`abo.js:115,144`). + +- [ ] **Step 1: Write the failing test** + +```js +import { berechneVertraege, erzeugeZaehler, CHAT_POLL_MS } from '../public/neu/zaehler.js' +test('Verträge: Rechnungen plus Rückfragen; "!" nur bei paymentProblem, nie bei switchPending (V3)', async () => { + assert.deepEqual(berechneVertraege({ rechnungen: [{ balance: 50 }, { balance: 0 }, { balance: '12.50' }], rueckfragen: [{ status: 'rueckfrage' }, { status: 'offen' }, { status: 'rueckfrage' }] }), { zahl: 4, ausruf: false }) + assert.deepEqual(berechneVertraege({ paymentProblem: true }), { zahl: 0, ausruf: true }) + const karte = { '/api/invoices': { invoices: [{ id: 'i1', number: 'RE-1', balance: 50 }] }, '/api/abo/takeover-credits': { requests: [{ status: 'rueckfrage' }] }, '/api/abo': { subscription: { paymentProblem: false, switchPending: { since: 'x', paymentUrl: null } } } } + const z = erzeugeZaehler({ api: async (p) => karte[p], onChange() {} }); await z.aktualisiere('vertraege') + assert.deepEqual(z.stand().vertraege, { zahl: 2, ausruf: false }) +}) +test('Fehler einer Quelle behalten ihren letzten Wert (Review Focus 5)', async () => { + let kaputt = false + const api = async (p) => { + if (p === '/api/invoices') { if (kaputt) throw Object.assign(new Error('x'), { status: 500 }); return { invoices: [{ id: 'i1', number: 'RE-1', balance: 50 }] } } + return p === '/api/abo' ? { subscription: null } : { requests: [] } + } + const z = erzeugeZaehler({ api, onChange() {} }); await z.aktualisiere('vertraege'); kaputt = true; await z.aktualisiere('vertraege') + assert.deepEqual(z.stand().vertraege, { zahl: 1, ausruf: false }) +}) +test('Chat: Poll alle 45 s, nicht im Chat und nicht bei verstecktem Tab; setzeChat(0) sofort', async () => { + const timer = []; const staende = []; let sicht = true; let chat = false; let aufrufe = 0 + const z = erzeugeZaehler({ + api: async (p) => { if (p === '/api/chat/unread-count') { aufrufe += 1; return { unread: 3 } } return p === '/api/abo' ? { subscription: null } : p === '/api/invoices' ? { invoices: [] } : { requests: [] } }, + onChange: (s) => staende.push(s.chat), sichtbar: () => sicht, imChat: () => chat, zeitgeber: { setze: (fn, ms) => timer.push({ fn, ms }) - 1, loesche() {} } }) + await z.start(); assert.deepEqual([timer.map((t) => t.ms), z.stand().chat], [[CHAT_POLL_MS], 3]) + await timer[0].fn(); assert.equal(aufrufe, 2) + sicht = false; await timer[0].fn(); sicht = true; chat = true; await timer[0].fn(); assert.equal(aufrufe, 2) + z.setzeChat(0); assert.deepEqual([z.stand().chat, staende.at(-1)], [0, 0]) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleZaehler.test.js` +Expected: FAIL (Modul nicht gefunden). + +- [ ] **Step 3: Implement `zaehler.js`** + +Jede der vier Quellen wird einzeln abgefragt (`Promise.allSettled`), nur erfolgreiche Antworten ersetzen den gemerkten Wert. `start()` lädt alles einmal und startet den Chat-Poll mit `CHAT_POLL_MS`; der Poll überspringt bei `!sichtbar()` oder `imChat()`. `stop()` löscht den Zeitgeber. `onChange` nur bei tatsächlicher Änderung. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleZaehler.test.js` +Expected: PASS (3 Tests). + +- [ ] **Step 5: Commit** + +```bash +git add public/neu/zaehler.js test/knHuelleZaehler.test.js +git commit -m "Kundenansicht neu P1: Zähler für Chat und Verträge nach V3" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 8: fristen-streifen.js (Widerrufsstreifen) + +**Files:** +- Create: `public/neu/fristen-streifen.js` +- Modify: `public/neu/huelle.css` (Abschnitt „Streifen“) +- Test: `test/knHuelleFristen.test.js` + +**Interfaces:** +- Consumes: `h` aus `./dom.js`. +- Produces: + - `async ladeFristen(api) -> { fristen, fruehestesEnde, anzeigen } | null`: ruft `api('/api/fristen')`; Fehler jeder Art (404, 500, Status 0) und `anzeigen !== true` ergeben `null`. + - `streifenText(iso) -> 'Widerrufsfrist bis TT.MM.'` (`Europe/Berlin`); `fruehesteFrist(daten) -> iso | ''` (`fruehestesEnde`, sonst das kleinste `bis`). + - `baueStreifen(wurzel, { koerper = document.body } = {}) -> { setze(daten | null) }`: füllt `wurzel` (`hidden` bei `null`) und setzt `kn-hat-streifen` am `koerper`. + +- [ ] **Step 1: Write the failing test** + +```js +import { ladeFristen, streifenText, fruehesteFrist, baueStreifen } from '../public/neu/fristen-streifen.js' +import { lies, handyTeil } from './support/knHuelle.js' +const FRIST = { fristen: [{ quelle: 'abschluss', bezeichnung: 'Abo', bis: '2026-10-21T21:59:59.999Z' }], fruehestesEnde: '2026-10-21T21:59:59.999Z', anzeigen: true } +test('ladeFristen: 404, 500, Netzfehler und anzeigen:false ergeben null; sonst die Daten (Review Focus 5)', async () => { + for (const status of [404, 500, 0]) assert.equal(await ladeFristen(async () => { throw Object.assign(new Error('x'), { status }) }), null) + assert.equal(await ladeFristen(async () => ({ fristen: [], fruehestesEnde: null, anzeigen: false })), null) + assert.deepEqual(await ladeFristen(async (p) => { assert.equal(p, '/api/fristen'); return FRIST }), FRIST) +}) +test('streifenText: Berliner Datum, nicht UTC; fruehesteFrist fällt auf das kleinste bis zurück', () => { + assert.equal(streifenText('2026-10-21T21:59:59.999Z'), 'Widerrufsfrist bis 21.10.'); assert.equal(streifenText('2026-10-21T22:30:00.000Z'), 'Widerrufsfrist bis 22.10.') + assert.equal(fruehesteFrist({ fruehestesEnde: null, fristen: [{ bis: '2026-11-03T10:00:00Z' }, { bis: '2026-10-30T10:00:00Z' }] }), '2026-10-30T10:00:00Z') +}) +test('baueStreifen: Text mit Link, nicht wegtippbar, null versteckt', () => { + const fx = installFakeDom() + try { + const w = h('div', { hidden: true }); const s = baueStreifen(w); s.setze(FRIST) + assert.deepEqual([w.hidden, fx.text(w)], [false, 'Widerrufsfrist bis 21.10. · Widerrufen']) + assert.deepEqual([fx.finde(w, 'a').getAttribute('href'), fx.alle(w, 'button').length, fx.document.body.classList.contains('kn-hat-streifen')], ['/widerrufen.html', 0, true]) + s.setze(null); assert.deepEqual([w.hidden, fx.document.body.classList.contains('kn-hat-streifen')], [true, false]) + } finally { fx.restore() } +}) +test('huelle.css (Streifen): 44 px, getönt, am Handy über der Leiste, bei Tastatur weg', () => { + const css = lies('huelle.css') + assert.match(css, /\.kn-streifen\s*\{(?=[^}]*min-height:\s*44px)(?=[^}]*background:\s*var\(--kn-orange-bg\))(?=[^}]*color:\s*var\(--kn-orange-fg\))/s) + assert.match(handyTeil(css), /\.kn-streifen\s*\{[^}]*position:\s*fixed/s) + assert.match(css, /\.kn-tastatur \.kn-streifen[^{]*\{[^}]*display:\s*none/s) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleFristen.test.js` +Expected: FAIL (Modul nicht gefunden). + +- [ ] **Step 3: Implement `fristen-streifen.js` und den Abschnitt „Streifen“ in `huelle.css`** + +Datum über `toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit' })`. Der Streifen ist ein `div.kn-streifen[role=region][aria-label="Widerrufsfrist"]` mit dem Text, ` · ` und `a.kn-streifen-link[href="/widerrufen.html"]` („Widerrufen“). CSS: Desktop-Grundregel: Streifen oben in der Spalte, nicht fixiert, Fläche `--kn-orange-bg`, Schrift `--kn-orange-fg` 14 px/600. Unter `HANDY`: `position:fixed` direkt über der Leiste (`bottom: calc(var(--kn-zeile-h) + env(safe-area-inset-bottom))`); `.kn-hat-streifen .kn-haupt` bekommt 44 px zusätzlichen Fußabstand. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleFristen.test.js` +Expected: PASS (4 Tests). + +- [ ] **Step 5: Commit** + +```bash +git add public/neu/fristen-streifen.js public/neu/huelle.css test/knHuelleFristen.test.js +git commit -m "Kundenansicht neu P1: Widerrufsstreifen mit GET /api/fristen" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 9: konto.js (Avatar, Konto-Sheet, Popover) + +**Files:** +- Create: `public/neu/konto.js` +- Modify: `public/neu/huelle.css` (Abschnitt „Konto“) +- Test: `test/knHuelleKonto.test.js` + +**Interfaces:** +- Consumes: `h` aus `./dom.js`; `oeffneOverlay`, `beendeOverlay`, `geheZu` aus `./router.js`. +- Produces: + - `initialenVon(name) -> string`; `installAngebot({ handy, standalone, prompt, ios }) -> 'prompt' | 'ios' | null`. + - `kontoEintraege({ fristen, install }) -> [{ id, text, href?, neuerTab?, pille?, betont? }]` in der Reihenfolge von V3: `passwort`, `datenschutz`, `impressum`, `installieren` (nur mit `install`), `kuendigen`, `widerrufen`, `abmelden`. + - `baueAvatar({ name, kundenId }) -> HTMLButtonElement` (`aria-label="Mein Konto"`, Initialen, Bild von `/api/avatar.svg?c=` erst nach `load`). + - `baueKonto({ me, fristen, install, onAbmelden, onInstall, overlay = { oeffne: oeffneOverlay, beende: beendeOverlay }, navigiere = geheZu }) -> { wurzel, oeffne(ausloeser), schliesse(), istOffen(), aktualisiere({ fristen, install }) }`; `aktualisiere` baut die Zeilen neu und behält den offenen Zustand. + +- [ ] **Step 1: Write the failing test** + +```js +import { initialenVon, installAngebot, kontoEintraege, baueAvatar, baueKonto } from '../public/neu/konto.js' +import { lies, handyTeil } from './support/knHuelle.js' +test('Reihenfolge, Texte und Ziele des Kontos (V3); Widerrufen immer da, "Frist läuft" nur in der Frist', () => { + const ids = (o) => kontoEintraege(o).map((e) => e.id) + assert.deepEqual(ids({ fristen: null, install: null }), ['passwort', 'datenschutz', 'impressum', 'kuendigen', 'widerrufen', 'abmelden']) + assert.deepEqual(ids({ fristen: null, install: 'ios' }), ['passwort', 'datenschutz', 'impressum', 'installieren', 'kuendigen', 'widerrufen', 'abmelden']) + const e = Object.fromEntries(kontoEintraege({ fristen: null, install: 'prompt' }).map((x) => [x.id, x])) + assert.deepEqual([e.passwort.text, e.passwort.href, e.datenschutz.href, e.impressum.href, e.kuendigen.href, e.kuendigen.betont], ['Passwort ändern', '#passwort', '/datenschutz.html', 'https://webklar.com/impressum', '/kuendigen.html', true]) + assert.deepEqual([e.widerrufen.text, e.widerrufen.href, e.widerrufen.pille, e.installieren.text, e.abmelden.text], ['Vertrag widerrufen', '/widerrufen.html', undefined, 'App installieren', 'Abmelden']) + assert.equal(kontoEintraege({ fristen: { anzeigen: true }, install: null }).find((x) => x.id === 'widerrufen').pille, 'Frist läuft') +}) +test('installAngebot: nur am Handy, nicht in der installierten App (wie app.js:2267)', () => { + assert.deepEqual([{ handy: true, standalone: false, prompt: true, ios: false }, { handy: true, standalone: false, prompt: false, ios: true }, { handy: true, standalone: true, prompt: true, ios: true }, { handy: false, standalone: false, prompt: true, ios: true }].map((o) => installAngebot(o)), ['prompt', 'ios', null, null]) +}) +test('initialenVon und Avatar: Bild erst nach load', async () => { + assert.deepEqual(['Salon Amin', 'Amin', '', ' ', 'Äsop Öl', '– Test'].map(initialenVon), ['SA', 'AM', '', '', 'ÄÖ', 'TE']) + const fx = installFakeDom() + try { + const a = baueAvatar({ name: 'Salon Amin', kundenId: 'c1' }); assert.deepEqual([a.textContent, a.getAttribute('aria-label')], ['SA', 'Mein Konto']) + const img = fx.finde(a, 'img'); assert.equal(img.getAttribute('src'), '/api/avatar.svg?c=c1') + await fx.sende(img, 'load'); assert.ok(a.classList.contains('has-img')) + } finally { fx.restore() } +}) +test('baueKonto: Kopf als Text, Sheet-Verhalten, Overlay-Kopplung, Navigation, Abmelden, aktualisiere', async () => { + const fx = installFakeDom() + try { + const log = []; const trigger = h('button'); fx.document.body.append(trigger) + const me = { customer: { id: 'c1', name: 'Anna', companyName: 'Salon', email: 'a@b.de' } } + const k = baueKonto({ me, fristen: null, install: null, onAbmelden: () => log.push('abmelden'), onInstall() {}, overlay: { oeffne: (o) => log.push(['overlay', typeof o.schliesse]), beende: () => log.push('beende') }, navigiere: (hash) => log.push(['geheZu', hash]) }) + fx.document.body.append(k.wurzel); const zeilen = () => fx.alle(k.wurzel, '.kn-konto-zeile') + assert.deepEqual([fx.finde(k.wurzel, '.kn-konto-name').textContent, fx.finde(k.wurzel, '.kn-konto-mail').textContent], ['Salon', 'a@b.de']) + assert.deepEqual(zeilen().map((z) => z.textContent), ['Passwort ändern', 'Datenschutz', 'Impressum', 'Verträge kündigen', 'Vertrag widerrufen', 'Abmelden']) + assert.deepEqual([k.wurzel.getAttribute('role'), k.wurzel.getAttribute('aria-modal')], ['dialog', 'true']) + k.oeffne(trigger); assert.deepEqual([k.istOffen(), trigger.getAttribute('aria-expanded'), log[0]], [true, 'true', ['overlay', 'function']]) + await fx.sende(k.wurzel, 'keydown', { key: 'Escape' }); assert.deepEqual([k.istOffen(), log.at(-1), fx.document.activeElement === trigger], [false, 'beende', true]) + k.oeffne(trigger); await fx.klick(zeilen()[0]); assert.deepEqual(log.at(-1), ['geheZu', '#passwort']) + await fx.klick(zeilen().at(-1)); assert.equal(log.at(-1), 'abmelden') + k.aktualisiere({ fristen: { anzeigen: true }, install: 'ios' }); assert.ok(k.istOffen()); assert.match(fx.text(k.wurzel), /App installieren[\s\S]*Vertrag widerrufen.*Frist läuft/) + await fx.klick(fx.finde(k.wurzel, '.kn-konto-scrim')); assert.equal(k.istOffen(), false) + } finally { fx.restore() } +}) +test('huelle.css (Konto): Popover als Desktop-Grundregel, Sheet am Handy', () => { + const css = lies('huelle.css') + assert.match(css, /\.kn-konto\s*\{(?=[^}]*width:\s*280px)(?=[^}]*top:)/s) + assert.match(handyTeil(css), /\.kn-konto\s*\{[^}]*border-radius:\s*12px 12px 0 0/s) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleKonto.test.js` +Expected: FAIL (Modul nicht gefunden). + +- [ ] **Step 3: Implement `konto.js` und den Abschnitt „Konto“ in `huelle.css`** + +`initialenVon` wie `app.js:2006`. Kopf: `.kn-konto-name` (`companyName || name || email`) und `.kn-konto-mail`. Zeilen sind `a.kn-konto-zeile` (Ziele wie im Test; Datenschutz und Impressum mit `target=_blank rel=noopener`), `installieren` und `abmelden` sind `button`; „Frist läuft“ ist ein `span.kn-pille.kn-pille-orange` in der Zeile. Klick auf eine interne Zeile: `preventDefault()` und `navigiere(hash)` (der Router schließt das Overlay selbst). `Abmelden` ruft nur `onAbmelden`, `installieren` nur `onInstall` (am iPhone zeigt die Zeile „Tippe auf Teilen und dann auf Zum Home-Bildschirm.“). Esc, Scrim (`.kn-konto-scrim` in `wurzel`) und „Schließen“ rufen `schliesse()`, das nur bei zuvor offenem und nicht vom Router geschlossenem Konto `overlay.beende()` ruft. Fokus beim Öffnen auf die erste Zeile, beim Schließen zurück auf `ausloeser`. CSS: Desktop-Grundregel Popover rechts oben unter dem Avatar, 280 px, `--kn-shadow-pop`; unter `HANDY` unten verankertes Sheet mit Scrim `--kn-overlay`, Radius `12px 12px 0 0`, Safe-Area unten. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleKonto.test.js` +Expected: PASS (5 Tests). + +- [ ] **Step 5: Commit** + +```bash +git add public/neu/konto.js public/neu/huelle.css test/knHuelleKonto.test.js +git commit -m "Kundenansicht neu P1: Konto mit Avatar, Sheet und Popover" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 10: leiste.js (Fahrstuhl-Zeile, Menü, Kopf, Tastatur) + +**Files:** +- Create: `public/neu/leiste.js` +- Modify: `public/neu/huelle.css` (Abschnitte „Leiste“, „Menü“, „Kopf“, „Querformat“, „Bewegung reduziert“) +- Test: `test/knHuelleLeiste.test.js` + +**Interfaces:** +- Consumes: `h` aus `./dom.js`; `baueReiter`, `anzeigeText` aus `./reiter.js`. +- Produces: + - `HANDY_ABFRAGE = '(max-width: 880px), (pointer: coarse) and (max-height: 500px)'`. + - `WOERTER = [{ key:'website', label:'Website' }, { key:'funktionen', label:'Funktionen' }, { key:'chat', label:'Chat', einheit:'ungelesen' }, { key:'vertraege', label:'Verträge', einheit:'offen', ausrufText:'Zahlung fehlgeschlagen' }]`. + - `baueNavigation({ haupt, menue, onWahl }) -> { setzeAktiv(bereich), setzeZaehler({ chat = 0, vertraege = { zahl: 0, ausruf: false } }), setzeEtage(offen) }`: `haupt` ist die Hauptzeile der Leiste (`#kn-zeile-haupt`), `menue` das Desktop-Menü (`#kn-menue`). `setzeEtage` setzt `data-etage="offen"` am Elternelement von `haupt` (dem Reel) und `inert` an `haupt`. + - `tastaturZustand(alt, ereignis) -> { voll, breite, offen, tippt }` mit `ereignis` `{ art:'vv', hoehe, breite, textfeldAktiv?, grob? }`, `{ art:'fokus', textfeld, grob }`, `{ art:'unfokus', textfeldAktiv }`; `leisteSichtbar(zustand) -> boolean`; `beobachteTastatur({ win, doc, onChange })` (Verdrahtung mit `visualViewport`, `focusin`, `focusout`; ersetzt `app.js:2475-2494` und `TEXT_INPUT`, `app.js:2137`). + +- [ ] **Step 1: Write the failing test** + +```js +import { HANDY_ABFRAGE, baueNavigation, tastaturZustand, leisteSichtbar } from '../public/neu/leiste.js' +import { lies, handyTeil, HANDY } from './support/knHuelle.js' +test('Leiste: vier Wörter, Auswahl, Zähler mit Beschriftung, "!" und 99+, Etage sperrt die Hauptzeile', async () => { + const fx = installFakeDom() + try { + const haupt = h('div'); const reel = h('div', null, haupt); const menue = h('nav'); const gew = [] + const n = baueNavigation({ haupt, menue, onWahl: (k) => gew.push(k) }); const tabs = fx.alle(haupt, '[role=tab]') + assert.deepEqual(tabs.map((t) => t.textContent), ['Website', 'Funktionen', 'Chat', 'Verträge']) + n.setzeAktiv('chat'); assert.deepEqual(tabs.map((t) => t.getAttribute('aria-selected')), ['false', 'false', 'true', 'false']); assert.equal(fx.finde(menue, '[aria-current=page]').textContent, 'Chat') + await fx.klick(tabs[3]); assert.deepEqual(gew, ['vertraege']) + n.setzeZaehler({ chat: 2, vertraege: { zahl: 1, ausruf: false } }) + assert.deepEqual([tabs[2].getAttribute('aria-label'), tabs[3].getAttribute('aria-label'), fx.finde(menue, '.kn-nav-zahl').textContent], ['Chat, 2 ungelesen', 'Verträge, 1 offen', '2']) + n.setzeZaehler({ chat: 150, vertraege: { zahl: 5, ausruf: true } }) + assert.deepEqual([fx.finde(tabs[2], '.kn-zahl').textContent, fx.finde(tabs[3], '.kn-zahl').textContent, tabs[3].getAttribute('aria-label')], ['99+', '!', 'Verträge, Zahlung fehlgeschlagen']) + n.setzeZaehler({ chat: 0, vertraege: { zahl: 0, ausruf: false } }); assert.equal(fx.finde(menue, '.kn-nav-zahl'), null) + assert.deepEqual(fx.alle(menue, 'button').map((b) => b.textContent), ['Website', 'Funktionen', 'Chat', 'Verträge']) + n.setzeEtage(true); assert.deepEqual([reel.getAttribute('data-etage'), haupt.hasAttribute('inert')], ['offen', true]) + n.setzeEtage(false); assert.equal(haupt.hasAttribute('inert'), false) + } finally { fx.restore() } +}) +test('tastaturZustand: öffnen unter voll-150, Android-Geste ohne Blur, Drehen, nur Textfelder am Touch (Review Focus 4)', () => { + let z = { voll: 844, breite: 390, offen: false, tippt: false } + z = tastaturZustand(z, { art: 'vv', hoehe: 560, breite: 390, textfeldAktiv: true, grob: true }); assert.deepEqual([z.offen, z.tippt, leisteSichtbar(z)], [true, true, false]) + z = tastaturZustand(z, { art: 'vv', hoehe: 700, breite: 390 }); assert.deepEqual([z.offen, z.tippt, leisteSichtbar(z)], [false, false, true]) // 700 >= 694, ohne Blur + z = tastaturZustand(z, { art: 'vv', hoehe: 390, breite: 844 }); assert.deepEqual([z.voll, z.breite, z.offen], [390, 844, false]) + assert.deepEqual([true, false].map((grob) => tastaturZustand(z, { art: 'fokus', textfeld: true, grob }).tippt), [true, false]) + assert.equal(tastaturZustand({ ...z, tippt: true }, { art: 'unfokus', textfeldAktiv: false }).tippt, false) +}) +test('huelle.css: Desktop ist Grundregel, Handy überschreibt (Abfrage wörtlich, 44 px, Safe-Area, Reel, Gleiter, Menü, Bewegung)', () => { + assert.equal(`@media ${HANDY_ABFRAGE}`, HANDY) + const css = lies('huelle.css'); const handy = handyTeil(css) + assert.match(css, /\.kn-t\s*\{(?=[^}]*min-height:\s*44px)(?=[^}]*font-size:\s*15px)(?=[^}]*font-weight:\s*600)/s) + assert.match(css, /\.kn-schrift-klein \.kn-t\s*\{[^}]*font-size:\s*14px/s) + assert.match(css, /\.kn-menue\s*\{(?=[^}]*width:\s*var\(--kn-sidebar-w\))(?=[^}]*border-right:\s*1px solid var\(--kn-line\))/s) + assert.match(handy, /\.kn-menue\s*\{[^}]*display:\s*none/s); assert.match(handy, /\.kn-leiste\s*\{[^}]*padding-bottom:\s*env\(safe-area-inset-bottom\)/s) + assert.match(css, /\[data-etage="offen"\][^{]*\{[^}]*translateY\(-56px\)/s) + assert.match(css, /\.kn-gleiter\s*\{(?=[^}]*top:\s*4px)(?=[^}]*height:\s*36px)(?=[^}]*border-radius:\s*6px)(?=[^}]*background:\s*var\(--kn-accent\))/s) + assert.match(css, /\.kn-tastatur \.kn-leiste[^{]*\{[^}]*(translateY\(110%\)|display:\s*none)/s) + assert.match(css, /@media \(prefers-reduced-motion: reduce\)[\s\S]*150ms/) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleLeiste.test.js` +Expected: FAIL (Modul nicht gefunden). + +- [ ] **Step 3: Implement `leiste.js` und die Abschnitte in `huelle.css`** + +`baueNavigation`: in `haupt` ein `baueReiter({ art:'balken', eintraege: WOERTER, ariaLabel:'Bereiche', bei: onWahl })`; im `menue` das Wort „WEBklar“ (Text, kein Icon) und je Wort ein `button.kn-nav-item` (`aria-current="page"` am aktiven, Zähler `span.kn-nav-zahl` mit `anzeigeText`). `tastaturZustand` ist rein: `offen = hoehe < voll − 150`; ein Breitenwechsel setzt `voll := hoehe`; schließt die Tastatur per `vv`, wird `tippt` falsch; `tippt` wird wahr bei Fokus auf ein Textfeld mit `grob` oder bei offener Tastatur mit `textfeldAktiv` und `grob`. + +Feste Werte (`final-2-frei.html`, Klassen mit Präfix `kn-`): +- Leiste (nur unter `HANDY`, sonst `display:none`): `position:fixed; left:0; right:0; bottom:0`, Fläche `--kn-bg`, oben 1 px `--kn-line`, `padding-bottom: env(safe-area-inset-bottom)`. Zeile 56 px, Reel `overflow:hidden`; `[data-etage="offen"]` schiebt das Reel `translateY(-56px)` in `550ms var(--kn-bremse)`. +- Tab `.kn-t`: `flex:1 1 auto; min-height:44px; padding:0 4px; font:600 15px; color:--kn-text-2; transition: color 250ms`; `.kn-schrift-klein` 14 px. Gleiter `.kn-gleiter`: `top:4px; height:36px; background:--kn-accent; border-radius:6px; transition: transform und width 550ms var(--kn-feder)`; Overlay-Kopie `.kn-overlay` (`top:-4px`, Schrift weiß); `.kn-fb` blendet das Overlay aus und färbt den aktiven Tab weiß. +- Zähler `.kn-zahl`: `min-width:16px; height:16px; padding:0 4px; border-radius:8px; background:#b3093c; color:#fff; font:700 11px/16px; box-shadow:0 0 0 2px #fff` (im Overlay `#111`), `top:-9px; left:100%; margin-left:-6px`; Eintritt 400 ms `cubic-bezier(.3,1.5,.5,1)` von `scale(0)`; `.kn-zahl--weg` `scale(0)` in 250 ms. +- Menü `.kn-menue` (Grundregel): 232 px, `border-right`, Einträge `padding:6px 10px; font:500 14px/20px; color:--kn-text-2`, aktiv `#111`/600/`--kn-accent-soft`. Inhalt `.kn-haupt`: max. 1160 px, Abstände wie `.page` (`woms-design.md` Abschnitt 2: 36/48/80, unter 1100 px 32/28/80). Unter `HANDY` 16 px Seitenrand und Fußabstand `calc(var(--kn-zeile-h) + env(safe-area-inset-bottom) + 28px)`. +- Kopf `.kn-kopf`: sticky, 52 px plus Safe-Area oben; kleiner Titel (15 px/600) blendet bei `.kn-titled` ein; Rand `--kn-line` bei `.kn-scrolled`; Avatar-Fläche 44 × 44 px (Kreis 32 px). +- `.kn-tastatur` blendet Leiste und Streifen aus. Querformat (`HANDY` mit `max-height:500px`): Zeile 44 px, `.kn-haupt .kn-titel` weg. Unter `prefers-reduced-motion: reduce` alle Dauern höchstens 150 ms, Blatt und Seiten nur `opacity`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleLeiste.test.js` +Expected: PASS (3 Tests). + +- [ ] **Step 5: Commit** + +```bash +git add public/neu/leiste.js public/neu/huelle.css test/knHuelleLeiste.test.js +git commit -m "Kundenansicht neu P1: Fahrstuhl-Zeile, Desktop-Menü und Tastaturzustand" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 11: huelle.js (Start, Ableitung, Zusammenbau) + +**Files:** +- Create: `public/neu/huelle.js` +- Test: `test/knHuelleStart.test.js` + +**Interfaces:** +- Consumes: alles aus Task 2 bis 10. +- Produces: + - `GERUEST_IDS`: `kn-banner, kn-ansage, kn-fehler, kn-menue, kn-kopf, kn-kopf-titel, kn-kopf-konto, kn-inhalt, bereich-website, bereich-funktionen, bereich-chat, bereich-vertraege, website-body, funktionen-body, chat-body, vertraege-body, kn-blatt, kn-brot, kn-etage-titel, kn-reiter-slot, news-body, termine-body, webshop-body, buchungen-body, zeiten-body, analyse-body, etage-body, kn-streifen, kn-leiste, kn-zeile-haupt, kn-zeile-etage, kn-konto-wurzel`. + - `ANSICHTEN = { website:['/neu/ansicht-website.js','zeigeWebsite','website-body'], funktionen:[…'zeigeFunktionen','funktionen-body'], chat:[…'zeigeChat','chat-body'], vertraege:[…'zeigeVertraege','vertraege-body'] }`; `ETAGE_ANSICHTEN = { projekt:['/neu/ansicht-website.js','zeigeWebsite','etage-body'], passwort:[…'zeigePasswort','etage-body'], gutschrift:[…'zeigeGutschrift','etage-body'], wechsel:[…'zeigeWechsel','etage-body'], bestellen:[…'zeigeBestellen','etage-body'] }` (Datei `ansicht-.js`, Funktion `zeige` + Name groß). + - `FUNKTIONS_MODULE = { news:['/funktionen.js','initNews','news-body'], termine:['/termine.js','initTermine','termine-body'], webshop:['/shop.js','initShop','webshop-body'], buchungen:['/buchungen.js','initBuchungen','buchungen-body'], zeiten:['/zeiten.js','initZeiten','zeiten-body'], analyse:['/analyse.js','initAnalyse','analyse-body'] }` (wörtlich aus `app.js:1938-1945`). + - `MIT_EINSCHALTEN = ['news', 'termine', 'webshop', 'buchungen', 'zeiten']` (die Etagen, deren Modul die Init-Option `einschalten` bekommt; `analyse` nicht, sie nutzt `POST /api/funktionen/analyse/activate`) und `einschaltenOption(lade) -> (platz, o) => Promise`, das `lade('/neu/funktion-einschalten.js')` erst beim Aufruf startet und dann `m.zeigeEinschalten(platz, o)` ruft (P3 liefert die Datei und den Export; Vertragsfrage 12). + - `leiteAb(me, { hash = '' }) -> { art:'weiter', ziel } | { art:'zeigen', kunde, viewAs, viewId, admin, anzeigename }`; `scrollZiel(nav, gemerkt) -> { top, weich }`. + - `async starteHuelle({ win = window, doc = document, api = apiStandard, lade = (p) => import(p), router = routerStandard, hash = win.location.hash } = {})`. +- Ablauf von `zeige(route, { nav })`: (1) Bereich oder Etage zeigen, Leiste, Menü, Kopftitel und `#kn-ansage` nachziehen; bei `vertraege` `zaehler.aktualisiere('vertraege')`; bei Chat `zaehler.setzeChat(0)`. (2) Steht die Wurzel leer da, den Text „Wird geladen …“ (`p.kn-leer`) hineinschreiben. (3) Ansicht aus `ANSICHTEN`/`ETAGE_ANSICHTEN` über `lade` holen und `zeigeX(wurzel, { route, me, nav })` rufen (V2); Funktionsetagen: `wurzel.dataset.wunschProjekt` aus dem Parameter `projekt` der Query setzen (falls vorhanden), `init*(wurzel, { einschalten: einschaltenOption(lade) })` rufen (nur für die Etagen aus `MIT_EINSCHALTEN`; `initAnalyse(wurzel)` ohne zweites Argument) und danach `router.stempeln()`. Die Hülle übergibt kein `projectId`, die Projektwahl bleibt `dataset.wunschProjekt` (V2). (4) Ladefehler: „Konnte nicht geladen werden.“ als `p.kn-fehler` in die Wurzel. (5) Scroll nach `scrollZiel`. + +- [ ] **Step 1: Write the failing test** + +Hilfen in der Datei: `baueGeruest(fx)` legt jede ID aus `GERUEST_IDS` als `div#id` an `fx.document.body`. `boot({ karte, hash, lade })` ruft `starteHuelle` mit einem Fenster (fakeDom-Fenster plus `matchMedia: () => ({ matches: true, addEventListener() {} })` und `KbView: { setId: (id) => log.push(['setId', id]) }`), einem `api` (`karte[pfad]`; ein `Error` wirft; ein fehlender Pfad wirft `{ status: 404 }`; jeder Aufruf steht als `['api', pfad, method]` im `log`), einem `doc` (`fx.document` mit `addEventListener`/`dispatchEvent` eines Node-`EventTarget`), einem `lade` (Vorgabe `async () => new Proxy({}, { get: () => async () => {} })`) und einer Router-Attrappe (`starteRouter({ zeige })` merkt `zeige` und ruft es sofort mit `routeAusHash(hash)` und `{ nav: 'tab' }`; `geheZu(h)` und `stempeln()` schreiben ins `log`). Danach `await fx.ruhe()`. Rückgabe `{ fx, win, doc, log, el(id), zeige(hash, nav) }`. Konstanten: `ME_KUNDE = { authenticated: true, role: 'customer', customer: { id: 'c1', name: 'Anna', companyName: '', email: 'a@b.de' } }`, `ME_VIEW = { ...ME_KUNDE, viewAs: true, viewId: 'v1', admin: { name: 'Kenso' } }`, `KARTE_KUNDE` mit `ME_KUNDE` und leeren Antworten für `/api/chat/unread-count` (`{ unread: 0 }`), `/api/invoices`, `/api/abo`, `/api/abo/takeover-credits`. + +```js +import { leiteAb, scrollZiel, GERUEST_IDS } from '../public/neu/huelle.js' +test('leiteAb: Anmeldung, Mitarbeiter, abgelaufen, Passwort geändert, Kundenansicht (wie app.js:2043-2064)', () => { + assert.deepEqual(leiteAb({ authenticated: false }, { hash: '' }), { art: 'weiter', ziel: '/login.html' }) + assert.deepEqual(leiteAb({ authenticated: false }, { hash: '#chat' }), { art: 'weiter', ziel: '/login.html?next=%2Fdashboard.html%23chat' }) + assert.deepEqual(leiteAb({ authenticated: false, reason: 'staff', redirect: 'https://ticket.webklar.com' }, {}), { art: 'weiter', ziel: 'https://ticket.webklar.com' }) + assert.equal(leiteAb({ authenticated: false, reason: 'view_expired', message: 'Bitte neu anmelden.' }, { hash: '#chat' }).ziel, '/login.html?next=%2Fdashboard.html%23chat&error=Bitte+neu+anmelden.') + assert.equal(leiteAb({ authenticated: false, reason: 'password_changed', message: 'Bitte neu anmelden.' }, {}).ziel, '/login.html?error=Bitte+neu+anmelden.') + assert.equal(leiteAb({ authenticated: true }, {}).ziel, '/login.html') + const z = leiteAb(ME_VIEW, {}); assert.deepEqual([z.art, z.viewAs, z.viewId, z.anzeigename], ['zeigen', true, 'v1', 'Anna']) +}) +test('scrollZiel: push beginnt oben, same scrollt weich nach oben, back und tab stellen wieder her', () => { + assert.deepEqual([scrollZiel('push', 120), scrollZiel('same', 120), scrollZiel('back', 120), scrollZiel('tab', undefined)], [{ top: 0, weich: false }, { top: 0, weich: true }, { top: 120, weich: false }, { top: 0, weich: false }]) +}) +test('Kundenansicht: setId vor jedem weiteren API-Aufruf, Banner, Beenden', async () => { + const b = await boot({ hash: '#chat', karte: { ...KARTE_KUNDE, '/api/auth/me': ME_VIEW, '/api/auth/kundenansicht/ende': { redirect: 'https://ticket.webklar.com/customers/c1' } } }) + assert.deepEqual(b.log.slice(0, 2), [['api', '/api/auth/me', 'GET'], ['setId', 'v1']]) + assert.equal(b.fx.text(b.el('kn-banner')), 'Kundenansicht: Anna · geöffnet von KensoBeenden') + await b.fx.klick(b.fx.finde(b.el('kn-banner'), 'button')); assert.equal(b.win.location.href, 'https://ticket.webklar.com/customers/c1') +}) +test('abgemeldet leitet um ohne weitere Aufrufe; me 500 zeigt eine Meldung statt leerer Seite (Review Focus 5)', async () => { + const a = await boot({ hash: '#chat', karte: { '/api/auth/me': { authenticated: false } } }) + assert.equal(a.win.location.href, '/login.html?next=%2Fdashboard.html%23chat'); assert.equal(a.log.filter((l) => l[0] === 'api').length, 1) + const f = await boot({ hash: '', karte: { '/api/auth/me': Object.assign(new Error('Fehler 500'), { status: 500 }) } }) + assert.deepEqual([f.el('kn-fehler').hidden, f.el('kn-fehler').textContent], [false, 'Fehler 500']) +}) +test('Route Chat: Ansicht bekommt Wurzel, route, me, nav; Navigation, Titel, Ansage folgen', async () => { + const gerufen = [] + const b = await boot({ hash: '#chat', karte: KARTE_KUNDE, lade: async (p) => ({ zeigeChat: (w, c) => gerufen.push([p, w.id, c.route, c.nav, c.me.customer.id]) }) }) + assert.deepEqual(gerufen, [['/neu/ansicht-chat.js', 'chat-body', { bereich: 'chat', query: '' }, 'tab', 'c1']]) + assert.deepEqual([b.el('bereich-chat').hidden, b.el('bereich-website').hidden, b.el('kn-blatt').hidden, b.el('kn-kopf-titel').textContent, b.el('kn-ansage').textContent], [false, true, true, 'Chat', 'Chat']) +}) +test('Ladezustand als Text, solange die Ansicht lädt', async () => { + const b = await boot({ hash: '#chat', karte: KARTE_KUNDE, lade: () => new Promise(() => {}) }) + assert.equal(b.el('chat-body').textContent, 'Wird geladen …') +}) +test('Etage Terminplaner: wunschProjekt aus ?projekt=, Modul auf #termine-body mit Option einschalten, danach stempeln, Chat-Punkt am Zurück; Ladefehler zeigt Text', async () => { + const seen = [] + const b = await boot({ hash: '#termine?bestellt=1&projekt=p7', karte: { ...KARTE_KUNDE, '/api/chat/unread-count': { unread: 3 } }, lade: async () => ({ initTermine: (root, opt) => seen.push([root.id, root.dataset.wunschProjekt, typeof opt?.einschalten]) }) }) + assert.deepEqual(seen, [['termine-body', 'p7', 'function']]); assert.ok(b.log.some((l) => l[0] === 'stempeln')) + assert.deepEqual([b.el('kn-blatt').hidden, b.el('kn-etage-titel').textContent, Boolean(b.fx.finde(b.el('kn-zeile-etage'), '.kn-punkt'))], [false, 'Terminplaner', true]) + const x = await boot({ hash: '#webshop', karte: KARTE_KUNDE, lade: async () => { throw new Error('404') } }) + assert.equal(x.el('webshop-body').textContent, 'Konnte nicht geladen werden.') +}) +test('Option einschalten: news, termine, webshop, buchungen und zeiten bekommen sie, analyse nicht; der Aufruf lädt funktion-einschalten.js erst dann', async () => { + const MODULE = { news: ['/funktionen.js', 'initNews'], termine: ['/termine.js', 'initTermine'], webshop: ['/shop.js', 'initShop'], buchungen: ['/buchungen.js', 'initBuchungen'], zeiten: ['/zeiten.js', 'initZeiten'], analyse: ['/analyse.js', 'initAnalyse'] } + for (const [etage, [pfad, init]] of Object.entries(MODULE)) { + const geladen = []; const aufrufe = [] + const lade = async (p) => { geladen.push(p); return p === '/neu/funktion-einschalten.js' ? { zeigeEinschalten: (platz, o) => aufrufe.push(['zeige', platz, o]) } : { [init]: (root, opt) => aufrufe.push(['init', root.id, opt]) } } + await boot({ hash: `#${etage}`, karte: KARTE_KUNDE, lade }) + const aufruf = aufrufe.find((a) => a[0] === 'init'); assert.ok(geladen.includes(pfad), pfad); assert.equal(aufruf[1], `${etage}-body`) + if (etage === 'analyse') { assert.equal(aufruf[2], undefined, 'analyse bekommt keine Option'); continue } + assert.equal(typeof aufruf[2].einschalten, 'function', etage); assert.equal(geladen.includes('/neu/funktion-einschalten.js'), false, `${etage}: lazy`) + const platz = { id: 'platz' }; await aufruf[2].einschalten(platz, { key: etage, projectId: 'p1' }) + assert.ok(geladen.includes('/neu/funktion-einschalten.js')); assert.deepEqual(aufrufe.at(-1), ['zeige', platz, { key: etage, projectId: 'p1' }]) + } + let versprechen // Ladefehler der Datei geht an das Modul (es zeigt E4), die Hülle schluckt ihn nicht + await boot({ hash: '#termine', karte: KARTE_KUNDE, lade: async (p) => { if (p.endsWith('funktion-einschalten.js')) throw new Error('404'); return { initTermine: (_w, o) => { versprechen = o.einschalten({}, {}); versprechen.catch(() => {}) } } } }) + await assert.rejects(versprechen, /404/) +}) +test('data-view-Brücke der Module: Name zu Hash, data-project wird ?projekt=', async () => { + const b = await boot({ hash: '#termine', karte: KARTE_KUNDE, lade: async () => ({ initTermine() {} }) }) + const chat = h('button', { 'data-view': 'chat' }); const shop = h('button', { 'data-view': 'webshop', 'data-project': 'p1' }); b.el('termine-body').append(chat, shop) + await b.fx.klick(chat); await b.fx.klick(shop) + assert.deepEqual(b.log.filter((l) => l[0] === 'geheZu').map((l) => l[1]), ['#chat', '#webshop?projekt=p1']) +}) +test('Fristen 404 (Server ohne P5): kein Streifen, "Vertrag widerrufen" bleibt ohne Pille; mit Frist Streifen und Pille', async () => { + const b = await boot({ hash: '', karte: KARTE_KUNDE }) + assert.equal(b.el('kn-streifen').hidden, true) + const konto = b.fx.text(b.el('kn-konto-wurzel')); assert.match(konto, /Vertrag widerrufen/); assert.doesNotMatch(konto, /Frist läuft/) + const p = await boot({ hash: '', karte: { ...KARTE_KUNDE, '/api/fristen': { fristen: [{ quelle: 'abschluss', bezeichnung: 'Abo', bis: '2026-10-21T21:59:59.999Z' }], fruehestesEnde: '2026-10-21T21:59:59.999Z', anzeigen: true } } }) + assert.deepEqual([p.el('kn-streifen').hidden, p.fx.text(p.el('kn-streifen'))], [false, 'Widerrufsfrist bis 21.10. · Widerrufen']); assert.match(p.fx.text(p.el('kn-konto-wurzel')), /Frist läuft/) +}) +test('Zähler: Verträge laden beim Betreten und bei kn:zaehler-neu (document) neu; der Chat-Zähler fällt beim Öffnen des Chats weg', async () => { + const b = await boot({ hash: '', karte: { ...KARTE_KUNDE, '/api/chat/unread-count': { unread: 3 } } }); const n = (pfad) => b.log.filter((l) => l[1] === pfad).length + const i0 = n('/api/invoices'); const chatTab = () => b.fx.finde(b.el('kn-zeile-haupt'), '[role=tab][aria-label="Chat, 3 ungelesen"]') + assert.ok(chatTab()) + await b.zeige('#rechnungen', 'tab'); assert.equal(n('/api/invoices'), i0 + 1) + b.doc.dispatchEvent(new CustomEvent('kn:zaehler-neu', { detail: { was: 'vertraege' } })); await b.fx.ruhe(); assert.equal(n('/api/invoices'), i0 + 2) + await b.zeige('#chat', 'tab'); assert.equal(chatTab(), null) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleStart.test.js` +Expected: FAIL (Modul nicht gefunden). + +- [ ] **Step 3: Implement `huelle.js`** + +`leiteAb` baut die Adresse mit `URLSearchParams` (`next` zuerst, dann `error`). `starteHuelle`: (1) `api('/api/auth/me')`; ein `ApiFehler` mit Status 401 führt zur Anmeldung, jeder andere Text nach `#kn-fehler`. (2) `leiteAb`. (3) Bei `viewAs` sofort `win.KbView?.setId(viewId)`, dann Banner mit `h()` („Kundenansicht: **Name** · geöffnet von X“, Knopf „Beenden“ → `POST /api/auth/kundenansicht/ende`, Ziel `redirect || 'https://ticket.webklar.com/customers'`). (4) Avatar in `#kn-kopf-konto`, Konto in `#kn-konto-wurzel` (mit `overlay` und `navigiere` aus dem Router; `aktualisiere` sobald Fristen oder Installationsangebot da sind), Streifen, Navigation, Etage und Zähler bauen; `ladeFristen` und `zaehler.start()` laufen parallel und dürfen den Start nie abbrechen; bei jeder Zähleränderung `navigation.setzeZaehler(…)` und `etage.setzeChatPunkt(stand.chat > 0)`. (5) Klick auf `doc.body`: `closest('[data-view]')` → `hashFuerAnsicht(name)` (plus `?projekt=` aus `data-project`) → `preventDefault` → `router.geheZu`. (6) Hörer: `kn:zaehler-neu` am `doc` (`detail.was`, Vertragsfrage 6), `visibilitychange` (sichtbar → `aktualisiere('alle')`), `beforeinstallprompt`/`appinstalled`, Scroll (rAF-gedrosselt: `kn-scrolled`, `kn-titled`, `scroll`-Merker je Hash ohne Query), `beobachteTastatur` (setzt `kn-tastatur` am Body), Medienwechsel (`etage.neuZeichnen()`). (7) `router.starteRouter({ zeige })`. (8) `navigator.serviceWorker.register('/sw.js')` nach `load` wie `app.js:2556-2560` (nur `isSecureContext`; so fängt `boot.js` der Vorschau den Aufruf ab). Am Dateiende: Start nur, wenn `document.body.classList.contains('kn')` und `#kn-leiste` existiert. + +Funktionsmodule in `zeige` (Interfaces, Ablauf 3): `einschaltenOption(lade)` liefert `(platz, o) => lade('/neu/funktion-einschalten.js').then((m) => m.zeigeEinschalten(platz, o))`. Der Import (P3) startet erst beim Aufruf, ein Ladefehler geht als abgelehntes Versprechen an das Modul zurück, das dann seine Fehlerkarte E4 zeigt (P4); die Hülle fängt ihn nicht ab. Alte Signaturen (`initTermine(root)`) ignorieren das zweite Argument, deshalb ist der Aufruf vor P3 und P4 unschädlich. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleStart.test.js` +Expected: PASS (11 Tests). + +- [ ] **Step 5: Commit** + +```bash +git add public/neu/huelle.js test/knHuelleStart.test.js +git commit -m "Kundenansicht neu P1: Hülle mit Start über /api/auth/me und Modul-Lader" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 12: dashboard.html, Manifest und Stubs + +**Files:** +- Modify: `public/neu/dashboard.html` (ersetzt den Platzhalter aus P0), `test/kundenansichtDashboard.test.js` (Hilfsfunktion `istNeu`) +- Create: `public/neu/manifest.webmanifest`, `public/neu/ansicht-website.js`, `ansicht-funktionen.js`, `ansicht-chat.js`, `ansicht-vertraege.js`, `ansicht-passwort.js`, `ansicht-gutschrift.js`, `ansicht-wechsel.js`, `ansicht-bestellen.js`, `public/neu/ansichten.css`, `public/neu/funktionen.css`, `public/neu/module.css` +- Test: `test/knHuelleSeite.test.js` + +**Interfaces:** +- Consumes: `GERUEST_IDS`, `ANSICHTEN`, `ETAGE_ANSICHTEN` aus `./huelle.js`. +- Produces: die Seite, die `GET /dashboard.html` im Pilot ausliefert (Marker ``, ``); acht Stubs mit je `export async function zeigeX(wurzel, { route, me, nav })`, die nur einen Satz „ (folgt)“ nach `wurzel` schreiben; drei CSS-Stubs mit einem Kommentar zum künftigen Besitzer. + +- [ ] **Step 1: Write the failing test** (und `istNeu` in `test/kundenansichtDashboard.test.js` auf `String(r.body).includes('')` statt `data-kn-platzhalter` umstellen; `` bleibt) + +```js +import { GERUEST_IDS, ANSICHTEN, ETAGE_ANSICHTEN } from '../public/neu/huelle.js' +import { lies } from './support/knHuelle.js' +const html = lies('dashboard.html') +test('dashboard.html: weiße PWA-Metas, Stile in fester Reihenfolge, klassisches Skript vor dem Modul, nichts Altes (P2 darf chat-composer ergänzen)', () => { + assert.match(html, //); assert.match(html, //) + assert.match(html, /apple-mobile-web-app-status-bar-style" content="default"/); assert.match(html, //) + assert.match(html, /viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content"/) + const css = [...html.matchAll(/ m[1]); const ORDNUNG = ['/neu/tokens.css', '/neu/basis.css', '/neu/huelle.css', '/neu/ansichten.css', '/neu/funktionen.css', '/neu/module.css'] + assert.deepEqual(css.filter((f) => f !== '/chat-composer.css'), ORDNUNG); assert.ok(css.indexOf('/chat-composer.css') === -1 || css.indexOf('/chat-composer.css') > css.indexOf('/neu/huelle.css')) + assert.match(html, //) + const skripte = [...html.matchAll(/]*)>/g)].map((m) => m[1].trim()) + assert.deepEqual([skripte[0], skripte.at(-1)], ['src="/kundenansicht.js"', 'type="module" src="/neu/huelle.js"']); assert.ok(skripte.slice(1, -1).every((s) => s === 'src="/chat-composer.js"')) + assert.doesNotMatch(html, /login\.css|kundenbereich\.css|\/app\.js|dot-background|kb-glow|class="[^"]*\bkb-/) +}) +test('dashboard.html: jede Gerüst-ID genau einmal, dazu die Body-IDs der Funktionsmodule und beide Navigationen', () => { + for (const id of GERUEST_IDS) assert.equal(html.split(`id="${id}"`).length - 1, 1, id) + for (const id of ['news-body', 'termine-body', 'webshop-body', 'buchungen-body', 'zeiten-body', 'analyse-body', 'funktionen-body']) assert.ok(GERUEST_IDS.includes(id), id) + for (const id of ['kn-menue', 'kn-leiste']) assert.match(html, new RegExp(`]*id="${id}")(?=[^>]*aria-label="Hauptnavigation")`)) +}) +test('manifest.webmanifest: weiß, gleiche Identität wie die alte App, Shortcuts Chat, Rechnungen, Verträge', () => { + const m = JSON.parse(lies('manifest.webmanifest')) + assert.deepEqual([m.id, m.start_url, m.scope, m.display, m.background_color, m.theme_color], ['/dashboard.html', '/dashboard.html', '/', 'standalone', '#ffffff', '#ffffff']) + assert.deepEqual(m.shortcuts.map((s) => [s.name, s.url]), [['Chat', '/dashboard.html#chat'], ['Rechnungen', '/dashboard.html#rechnungen'], ['Verträge', '/dashboard.html#vertraege']]) + assert.deepEqual(m.icons.map((i) => i.src), ['/img/app-icon-192.png', '/img/app-icon-512.png', '/img/app-icon-maskable-512.png']) +}) +test('Stubs: jede Ansicht der Hülle existiert unter dem Namen aus V2 (zeige + Dateiname)', async () => { + const soll = { website: 'zeigeWebsite', funktionen: 'zeigeFunktionen', chat: 'zeigeChat', vertraege: 'zeigeVertraege', passwort: 'zeigePasswort', gutschrift: 'zeigeGutschrift', wechsel: 'zeigeWechsel', bestellen: 'zeigeBestellen' } + for (const [name, fn] of Object.entries(soll)) { + assert.equal(typeof (await import(`../public/neu/ansicht-${name}.js`))[fn], 'function', fn) + assert.deepEqual(Object.values({ ...ANSICHTEN, ...ETAGE_ANSICHTEN }).find(([p]) => p === `/neu/ansicht-${name}.js`).slice(0, 2), [`/neu/ansicht-${name}.js`, fn]) + } +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleSeite.test.js` +Expected: FAIL (Platzhalter ohne Metas, Stubs fehlen). + +- [ ] **Step 3: Write the page, manifest and stubs** + +`dashboard.html`: Kopf wie im Test (Zeilen wörtlich: ``, ``, ``), dazu `Kundenbereich – WEBklar`, Icons wie `public/dashboard.html:8-10`, `mobile-web-app-capable`, `apple-mobile-web-app-capable yes`, `apple-mobile-web-app-title WEBklar`. Skripte exakt `` und ``. Gerüst in dieser Reihenfolge: Skip-Link „Zum Inhalt“, `#kn-banner[hidden]`, Spalte mit `nav#kn-menue`, `header#kn-kopf` (`#kn-kopf-titel`, `#kn-kopf-konto`), `p#kn-fehler.kn-fehler[hidden][role=alert]`, `main#kn-inhalt.kn-haupt` mit vier `section#bereich-*` (je `h1.kn-titel` mit dem Bereichsnamen und `div#…-body`; kein Erklärsatz), `div#kn-blatt.kn-blatt[hidden]` mit `#kn-brot`, `h1#kn-etage-titel`, `#kn-reiter-slot` und je einer `section[data-etage]` für `news`, `termine`, `webshop`, `buchungen`, `zeiten`, `analyse` (mit ihrem `…-body`) und `seite` (mit `#etage-body`), dann `#kn-streifen[hidden]`, `nav#kn-leiste` mit `div.kn-leiste-reel` (darin `#kn-zeile-haupt` und `#kn-zeile-etage`), `#kn-konto-wurzel` und `#kn-ansage[aria-live=polite].kn-nur-leser`. Manifest: Felder wie im Test, `name "WEBklar Kundenbereich"`, `short_name "WEBklar"`, `lang de`, Beschreibung „Website, Funktionen, Chat und Verträge – der WEBklar-Kundenbereich als App.“, `categories` und `icons` wie `public/manifest.webmanifest`. Stubs: `import { h } from './dom.js'` und `wurzel.replaceChildren(h('p', { class: 'kn-leer' }, ' (folgt)'))`. + +- [ ] **Step 4: Run tests to verify they pass** + +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/knHuelleSeite.test.js test/kundenansichtDashboard.test.js` +Expected: PASS (4 Tests plus die Tests von P0, `fail 0`). + +- [ ] **Step 5: Commit** + +```bash +git add public/neu/dashboard.html public/neu/manifest.webmanifest public/neu/ansicht-website.js public/neu/ansicht-funktionen.js public/neu/ansicht-chat.js public/neu/ansicht-vertraege.js public/neu/ansicht-passwort.js public/neu/ansicht-gutschrift.js public/neu/ansicht-wechsel.js public/neu/ansicht-bestellen.js public/neu/ansichten.css public/neu/funktionen.css public/neu/module.css test/knHuelleSeite.test.js test/kundenansichtDashboard.test.js +git commit -m "Kundenansicht neu P1: weiße Seite, Manifest und leere Ansichten für P2 und P3" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 13: Sichtprüfung über das Werkzeug (Ansichten, Gate `p1`) + +**Files:** +- Modify (Werkzeug-Git, `/root/reports/kundenansicht-neu-2026-10-15/werkzeug/`, Abkürzung `KW`): `views.mjs` (Ansichten von P1, die zwei Platzhalter-Ansichten), `gates.mjs` (Gate `p1`), `test/statik.test.mjs` (Marker). +- Modify (nur bei Verstößen, Portal-Worktree): `public/neu/huelle.css`, `basis.css`, `reiter.js`, `leiste.js`, `etage.js`. + +**Interfaces:** +- Consumes (aus P0, Task 6, 8, 9, 10): Fälle `betrieb-wartung`, `privat-frist` (Frist bis `2026-10-25`), `rechnung-offen` (`RE-2026-0042`, 50 €), `chat-ungelesen` (3), `mitarbeiter`, `abgemeldet`, `fehler` (500 für `abo`, `invoices`, `funktionen`); `GERAETE` (`handy` 390×844, `handy-quer` 844×390, `tablet` 820×1180, `desktop` 1440×900); Ansichtsfelder `{ id, fall, adresse, geraete, warte, schritte(seite), bewegung?, ignorieren? }`; `GATES.p = { checks, ansichten }` mit `ALWAYS_ON`; Prüfungen `dark-surface`, `low-contrast`, `overflow-x`, `lila`; Standard `reducedMotion: 'reduce'` (mit `bewegung: true` volle Bewegung). `schritte(seite)` wirft bei Verstoß (wird zu `step-failed`). +- Produces: Gate `p1 = { checks: [...ALWAYS_ON, 'dark-surface', 'low-contrast', 'overflow-x', 'lila'], ansichten: ['kn-huelle-*'] }` grün; die Aufnahmen unter `KW/tmp/shots-p1/` als Beleg. + +- [ ] **Step 1: Umgebung für diesen Strang** + +Run: `printf 'export WT=/home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui\n' > /root/reports/kundenansicht-neu-2026-10-15/werkzeug/tmp/strang-ui.env && cat /root/reports/kundenansicht-neu-2026-10-15/werkzeug/README.md | head -60 && git -C /root/reports/kundenansicht-neu-2026-10-15/werkzeug status --short` +Expected: Datei geschrieben, README von P0 gelesen, Werkzeug-Git sauber. Alle folgenden Läufe mit `KN_STRANG_ENV=/root/reports/kundenansicht-neu-2026-10-15/werkzeug/tmp/strang-ui.env`; vor jedem Lauf `free -m` (über 2000 MB verfügbar). + +- [ ] **Step 2: Ansichten und Gate schreiben** + +Ein Beispiel als Code, die übrigen als Tabelle (jede Zeile: genau diese Prüfungen mit diesen Werten, per `assert` in `schritte`): +```js +{ id: 'kn-huelle-website', fall: 'betrieb-wartung', adresse: '/dashboard.html', geraete: ['handy', 'tablet', 'desktop'], warte: '#kn-inhalt', + schritte: async (seite) => { + assert.equal(await seite.evaluate(() => getComputedStyle(document.body).backgroundColor), 'rgb(255, 255, 255)') + const breite = seite.viewportSize().width + if (breite <= 880) { + const r = await seite.evaluate(() => ({ unten: document.querySelector('.kn-leiste').getBoundingClientRect().bottom, innen: innerHeight, tabs: [...document.querySelectorAll('.kn-leiste [role=tab]')].map((t) => { const q = t.getBoundingClientRect(); return [q.width, q.height] }) })) + assert.equal(r.unten, r.innen); assert.ok(r.tabs.length === 4 && r.tabs.every(([b, hoehe]) => b >= 44 && hoehe >= 44)) + } else assert.equal(await seite.evaluate(() => getComputedStyle(document.querySelector('.kn-menue')).width), '232px') + } } +``` + +| id | Fall, Adresse, Geräte, `warte` | Prüfung | +|---|---|---| +| `kn-huelle-kopf` | `betrieb-wartung`, `/dashboard.html`, `handy`, `desktop`, `#kn-inhalt` | Step streckt `#website-body` auf 2000 px und scrollt 300 px: `body` trägt `kn-scrolled` und `kn-titled`, `#kn-kopf-titel` hat Opacity 1 und den Text „Website“; zurück nach oben: beide Klassen weg | +| `kn-huelle-wechsel` | `betrieb-wartung`, `/dashboard.html`, `handy`, `bewegung: true`, `#kn-leiste [role=tab]` | nacheinander Funktionen, Chat, Verträge, Website antippen: `history.length` unverändert, Hash folgt; 700 ms nach dem Tipp liegt die Mitte des Gleiters (±2 px) im Tab; Overlay-Schrift `rgb(255, 255, 255)` | +| `kn-huelle-etage` | `betrieb-wartung`, `/dashboard.html#termine`, `handy`, `desktop`, `#kn-blatt:not([hidden])` | Handy: Reel `translateY(-56px)`, „Zurück“ ≥ 44 px, Hauptzeile `inert`, `history.state.kn.tiefe` ≥ 1 auch nach dem Start des Moduls (Stempel, Review Focus 1), Tipp auf Zurück ergibt `#funktionen`; Step sendet `kn:reiter` mit sechs Reitern und danach `bei` des letzten: Zeile scrollt (`scrollWidth > clientWidth`), der letzte liegt ganz im Bild (C2); danach `setViewportSize` 320 × 640: kein `overflow-x`. Desktop: Brotkrume „Funktionen / Terminplaner“, Reiter als Unterstrich | +| `kn-huelle-konto` | `privat-frist`, `/dashboard.html`, `handy`, `desktop`, `#kn-avatar` | Profilbild tippen: acht Zeilen in V3-Reihenfolge (mit „Frist läuft“); Esc schließt, Fokus am Profilbild; `page.goBack()` schließt zuerst das Sheet, Hash unverändert (Review Focus 2); Desktop: Popover rechts oben, 280 px | +| `kn-huelle-frist` | `privat-frist`, `/dashboard.html#chat`, `handy`, `#kn-streifen:not([hidden])` | Text `Widerrufsfrist bis 25.10. · Widerrufen` unmittelbar über der Leiste, kein Schließen-Knopf; Tastatur: `setViewportSize` auf 390 × 544 und ein eingefügtes `input` fokussieren → Leiste und Streifen nicht sichtbar, zurück auf 844 → sichtbar (Review Focus 4) | +| `kn-huelle-zaehler` | `chat-ungelesen`, `/dashboard.html`, `handy`, `.kn-zahl`, `ignorieren: { 'dark-surface': ['.kn-zahl'] }` | Badge „3“ am Chat, `aria-label` „Chat, 3 ungelesen“; Tipp auf Chat entfernt es | +| `kn-huelle-ausruf` | `betrieb-wartung`, `/dashboard.html`, `handy`, `#kn-leiste [role=tab]`, `ignorieren` wie oben | Step überschreibt mit `seite.route('**/api/abo', …)` die Antwort zuerst mit `{ subscription: { paymentProblem: true, switchPending: null } }` und sendet `document.dispatchEvent(new CustomEvent('kn:zaehler-neu', { detail: { was: 'vertraege' } }))`: Badge „!“; dann `{ subscription: { paymentProblem: false, switchPending: { since: 'x', paymentUrl: null } } }`: kein Badge | +| `kn-huelle-quer` | `betrieb-wartung`, `/dashboard.html#funktionen`, `handy-quer`, `#kn-leiste [role=tab]` | Leiste ≤ 48 px hoch, `.kn-haupt .kn-titel` nicht sichtbar, kein `overflow-x` | +| `kn-huelle-reduziert` | `betrieb-wartung`, `/dashboard.html`, `handy` (Standard `reduce`), `#kn-leiste [role=tab]` | jede `transition-duration` an Leiste, Gleiter und Blatt ≤ 0,15 s | +| `kn-huelle-kundenansicht` | `mitarbeiter`, `/dashboard.html`, `handy`, `desktop`, `#kn-banner:not([hidden])` | Banner „Kundenansicht: … · geöffnet von …“ mit „Beenden“; nach `kn:zaehler-neu` trägt die abgefangene Anfrage `/api/invoices` (`seite.waitForRequest`) den Header `x-kundenansicht` | +| `kn-huelle-grenze` | `betrieb-wartung`, `/dashboard.html`, `desktop`, `#kn-inhalt` | `setViewportSize` 880 × 800: Leiste da, Menü nicht; 881 × 800: Menü da, Leiste nicht; 1024 × 768: Menü, Inhalt höchstens 1160 px breit | +| `kn-huelle-quellen-fehler` | `fehler`, `/dashboard.html`, `handy`, `#kn-leiste [role=tab]` | `#kn-fehler` bleibt versteckt, die Leiste steht, kein Badge am Reiter Verträge (Review Focus 5) | + +Außerdem: in `platzhalter` das `warte` auf `'#kn-platzhalter-ansicht:not(:empty), #kn-inhalt'` und in `platzhalter-abgemeldet` auf `'form'` (der Lauf endet auf `/login.html`) stellen, IDs unverändert (P0 pinnt sie in `test/gates.test.mjs`). In `test/statik.test.mjs` den Marker auf `/data-kn-platzhalter|/` erweitern (gilt für Platzhalter und neue Seite). + +- [ ] **Step 3: Erster Lauf (erwartet rot) und Sichtprüfung** + +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-p1 --gate p1` +Expected: mindestens ein Verstoß, weil das CSS bis hier nur textlich geprüft wurde. Danach die Aufnahmen für Handy, Tablet und Desktop mit dem Read-Werkzeug ansehen und gegen die Demo prüfen: schwarzer Balken mit weißer Schrift, Wörter nicht abgeschnitten (auch bei 320 px), Zähler nicht beschnitten, Haarlinie über der Leiste, keine Schatten außer am Blatt und am Konto, Menü ohne Icons. + +- [ ] **Step 4: Korrigieren** + +Verstöße im Portal-Worktree beheben (nur die Dateien aus „Files“), Textabweichungen in `test/knHuelle*.test.js` mitziehen und diese Tests erneut einzeln laufen lassen. + +- [ ] **Step 5: Run gate to verify it passes** + +Run: derselbe Befehl wie in Step 3. Dazu `cd /root/reports/kundenansicht-neu-2026-10-15/werkzeug && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/gates.test.mjs test/statik.test.mjs`. +Expected: Exit 0 ohne Verstöße; Werkzeug-Tests `fail 0`; danach `ls -l /dev/null` zeigt `crw-rw-rw-`. + +- [ ] **Step 6: Commit** + +```bash +git -C /root/reports/kundenansicht-neu-2026-10-15/werkzeug add views.mjs gates.mjs test/statik.test.mjs +git -C /root/reports/kundenansicht-neu-2026-10-15/werkzeug commit -m "P1 Hülle: Ansichten kn-huelle-*, Gate p1, Platzhalter-Ansichten angepasst" -m "Co-Authored-By: Claude Opus 5.5 " +# Portal-Dateien nur, wenn Step 4 etwas geändert hat; jede geänderte Datei einzeln nennen: +git add public/neu/huelle.css +git commit -m "Kundenansicht neu P1: Korrekturen aus der Sichtprüfung" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 14: Selbstprüfung + +**Files:** +- Modify: `test/knHuelleQuelltext.test.js` (Vollständigkeits-Test) + +**Interfaces:** +- Consumes: alle Tasks. Produces: Tag `kn-p1`. + +- [ ] **Step 1: Vollständigkeits-Test anhängen und laufen lassen** + +An `test/knHuelleQuelltext.test.js` anhängen: +```js +test('Dateiübersicht P1 ist vollständig vorhanden', () => { + const soll = [...P1, 'tokens.css', 'basis.css', 'huelle.css', 'dashboard.html', 'manifest.webmanifest', 'ansichten.css', 'funktionen.css', 'module.css', + ...['website', 'funktionen', 'chat', 'vertraege', 'passwort', 'gutschrift', 'wechsel', 'bestellen'].map((n) => `ansicht-${n}.js`)] + for (const f of soll) assert.ok(existsSync(pfad(f)), f) +}) +``` +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleQuelltext.test.js` +Expected: PASS (2 Tests). + +- [ ] **Step 2: Alle eigenen Testdateien und die Nachbarn mit Pins** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && free -m | sed -n 2p && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/knHuelleCss.test.js test/knHuelleDomApi.test.js test/knHuelleRouten.test.js test/knHuelleRouter.test.js test/knHuelleReiter.test.js test/knHuelleEtage.test.js test/knHuelleZaehler.test.js test/knHuelleFristen.test.js test/knHuelleKonto.test.js test/knHuelleLeiste.test.js test/knHuelleStart.test.js test/knHuelleSeite.test.js test/knHuelleQuelltext.test.js` +Expected: `# fail 0`. +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/kundenansichtDashboard.test.js test/adminPanelGone.test.js test/auftraegeKundenportalHuelle.test.js test/kundenansichtClient.test.js test/termineView.test.js` +Expected: `# fail 0` (alte Ansicht, ihre Pins und die Weiche von P0 sind unberührt). + +- [ ] **Step 3: Gate noch einmal, Verträge abhaken, Diff prüfen** + +Run: derselbe Gate-Befehl wie in Task 13, Step 3. Expected: Exit 0. +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git diff --name-only kn-p0 | sort` +Expected: genau die Dateien der Dateiübersicht (`public/neu/…`, `test/knHuelle…`, `test/support/knHuelle.js`, `test/kundenansichtDashboard.test.js`), sonst nichts. + +Verträge sind abgehakt, wenn der Beleg grün ist: + +| Vertrag | Beleg | +|---|---| +| V2 Grundregeln (`public/neu`, ES-Module, ``, Präfix `kn-`), `dom.js`, `api.js` | Seite (12), Css (1), DomApi (2), Quelltext | +| V2 `routen.js` samt Pflichtinhalt, `router.js` (`starteRouter`, `geheZu`, `nav`-Werte) | Routen (3), Router (4) | +| V2 Hülle (`huelle.js`, `leiste.js`, `konto.js`, `fristen-streifen.js`, `zaehler.js`, `etage.js`, `reiter.js`), Ansichten `zeigeX`, Module über `#…-body`, `wunschProjekt`, Init-Option `einschalten` für die fünf Module (Test „Option einschalten“) | Tests 5 bis 12 | +| V3 Handy-Abfrage, Wörter, Gleiter 36 px/Radius 6, Tap-Ziele, Safe-Area, Tastatur | Leiste (10), Gate `kn-huelle-website`, `-frist` | +| V3 Etage 2 (Zurück, Reiter, Scrollen, aktiver Reiter im Bild), Desktop (232 px, Haarlinie, Brotkrume, Popover) | Etage (6), Reiter (5), Konto (9), Gate `-etage`, `-grenze` | +| V3 Konto in der Reihenfolge, Widerrufen immer da; Widerrufsstreifen | Konto (9), Fristen (8), Start (11) | +| V3 Zähler (Chat 45 s, Verträge, „!“ nur bei `paymentProblem`), Bewegung reduziert | Zaehler (7), Gate `-ausruf`, `-reduziert` | + +- [ ] **Step 4: Sauberkeit und Tag** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git status --short && ls -l /dev/null && git tag kn-p1 && git -C /root/reports/kundenansicht-neu-2026-10-15/werkzeug status --short` +Expected: beide `git status` leer (der Symlink `node_modules` ist ignoriert), `/dev/null` zeigt `crw-rw-rw-`, Tag gesetzt. +Im Abschlussbericht nennen, was hier niemand prüfen kann: iPhone-Standalone (Statusleiste `default`, Leiste bündig unten, kein Sprung beim Öffnen des Sheets), Android-Zurück-Geste in der installierten App, Fahrstuhl-Zeile auf einem echten Handy, Schriftbreiten (der Host hat nur DejaVu Sans). + +- [ ] **Step 5: Commit** + +```bash +git add test/knHuelleQuelltext.test.js +git commit -m "Kundenansicht neu P1: Vollständigkeitstest der Dateiübersicht" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +## Vertragsfragen an den Controller + +Die Pläne von P0, P2, P3 und P4 lagen beim Schreiben vor. Alle Vorschläge sind in diesem Plan umgesetzt; wo ein anderer Plan etwas anderes annimmt, steht es dabei. + +1. **Platzhalter und Weichen-Test (P0 ↔ P1).** P0 pinnt den Platzhalter über `data-kn-platzhalter` (`test/kundenansichtDashboard.test.js`, `istNeu`; Werkzeug `test/statik.test.mjs`). P1 ersetzt die Seite und stellt `istNeu` in Task 12 auf `` um; die Werkzeug-Tests akzeptieren beide Marker (Task 13). Besser wäre, P0 nähme den Meta-Marker von Anfang an. Außerdem gelten im Werkzeug die Ansichten `platzhalter` und `platzhalter-abgemeldet` weiter (nur `warte` wird angepasst), sonst bricht `test/gates.test.mjs` von P0. +2. **Werkzeug.** P1 nutzt die Form aus P0 (Ansichten, Gate `p1`, `KN_STRANG_ENV` mit `WT=…/dev-kn-ui`, `shots.sh --gate p1`). P1 ändert im Werkzeug nur `views.mjs`, `gates.mjs` und den Marker in `test/statik.test.mjs`, keine Fälle. Zwei Folgen aus P0: Die Prüfung `dark-surface` meldet den roten Zähler (16 px, `#b3093c`), deshalb `ignorieren: { 'dark-surface': ['.kn-zahl'] }` in den Ansichten mit Zähler. Und `paymentProblem`/`switchPending` kommen in keinem Fall vor, deshalb überschreibt der Step die Antwort von `/api/abo` mit `seite.route`. +3. **Stubs und Besitz.** P1 legt acht Ansichts-Stubs (`ansicht-website|funktionen|chat|vertraege|passwort|gutschrift|wechsel|bestellen.js`) und drei CSS-Stubs (`ansichten.css`, `funktionen.css`, `module.css`) an und verlinkt die CSS-Dateien statisch in `dashboard.html` (P3 VF9, P4 VF5). Danach gehören die Dateien P2, P3 und P4 (gleicher Strang, kein Merge-Konflikt). Export-Namen: `zeige` + Dateiname groß; die Hülle ruft die vier Bereiche und die Etagen `projekt` (über `zeigeWebsite`), `passwort`, `gutschrift`, `wechsel`, `bestellen` mit `{ route, me, nav }` (P2 VF2 exportiert diese Funktionen alle). `zeigeVertraege` rendert die Reiter `rechnungen|abo|stunden|email` selbst. Neu in der Zuständigkeitstabelle: `public/neu/manifest.webmanifest` und `test/support/knHuelle.js` (beide P1; die Testhilfe dürfen P2 bis P4 benutzen). + **Bausteinklassen:** `basis.css` führt die Namen aus P2 VF5 (`kn-knopf`, `-primaer`, `-zweit`, `kn-textknopf`, `kn-pille-`, `kn-feld`, `kn-liste`, `kn-zeile`, `kn-abschnitt`, `kn-meldung`, `kn-fehler`, `kn-leer`). Der schwarze Balken der Leiste heißt deshalb `.kn-gleiter`, nicht `.kn-pille`. + **Chat-Composer:** `/chat-composer.js` und `/chat-composer.css` (P2 VF9) lädt P1 noch nicht. `chat-composer.css` enthält dreimal das Lila (`#9d3dfe`), und die Prüfung `lila` von P0 liest jedes geladene Stylesheet als Text; die Zeilen würden das Gate `p1` kippen. P2 trägt beide Zeilen in `dashboard.html` ein (der Test in Task 12 erlaubt sie) und löst das Lila (Vorschlag: eine bereinigte Kopie unter `public/neu/` oder eine begründete Ausnahme in `lila`, P0). +4. **V2 ergänzen (Routen).** Route-Felder `produkt` (`'hosting'|'wartung'`) und `scroll` (`'aktivitaet'`), `query` immer als String. Zusätzliche Exporte von `routen.js`: `hashFuerAnsicht(name)` (die `data-view`-Brücke, auf die die gemeinsamen Module angewiesen sind: `data-view="chat"`, `"vertraege"` und die Funktionsnamen), `startHash`, `ankerKette`, `elternVon`, `titelFuer`, `FUNKTIONS_ETAGEN`. Ein `data-project` an einem `[data-view]`-Element wird zu `?projekt=`. Das entspricht P3 VF2 und VF3. + **Änderung am „Pflichtinhalt von ROUTEN“ (V2 der Übersicht muss nachgezogen werden, Befund 2):** Die Zeile `vertraege`, `abo`, `wartung` → vertraege, reiter `abo` entfällt. Neu: `vertraege` → vertraege **ohne Reiter** (die Ansicht wählt den Standardreiter); `abo` und `wartung` → vertraege, reiter `abo`, kanonischer Hash `#abo` (`#wartung` wird `#abo`, `#vertraege` bleibt `#vertraege`). Grund: Fielen `#abo` und `#vertraege` zusammen, könnte P2 `waehleReiter` (nur `#vertraege` darf bei offener Rechnung auf Rechnungen springen, Reiterklicks und `#abo`/`#wartung` nie) den ausdrücklichen Reiter Abo nicht vom Standardsprung unterscheiden. `ankerKette` und `elternVon` liefern weiter `#vertraege` als Bereichs-Anker. `?abo=success|cancel` führt zu `#abo` (P2 zeigt die Rückkehr von Stripe auf dem Reiter Abo und räumt `?abo=` dort auf). Für P2: Auf `#vertraege` ist `route.reiter` jetzt nicht gesetzt, `waehleReiter` fällt dann wie bei einem nicht sichtbaren Reiter auf `abo` zurück. +5. **Reiter und Etage (P2 VF3, P3 VF1/VF2, P4 VF4).** Drei Pläne nehmen drei Formen von `baueReiter` an. P1 folgt P3: `baueReiter({ eintraege: [{ key, label, kurz? }], aktiv, art, ariaLabel, bei(key) })` liefert ein `HTMLElement` mit den Methoden `setzeAktiv`, `setzeAnzeige`, `messen`; P2 passt `vertraegeReiter()` an. Inhalte in Etage 2 melden Reiter und Titel ohne Import: `kn:reiter` mit `detail { eintraege, aktiv, bei }` (leere Liste = keine Reiter) und `kn:titel` mit `detail { text }`, jeweils `bubbles: true` aus einem Nachfahren des Blatts; das alte Dashboard ignoriert sie. P4 VF4 will die Modul-Reiter dagegen im Modul lassen (Unterstrich oben). Spec 5.2, V3 und C2 verlangen sie in Etage 2. Vorschlag: Spec gilt, P4 sendet `kn:reiter` aus den Modulen (nur im Abo-Modell) und bekommt dafür einen Folge-Task. +6. **Zähler und „Rechnung offen“ (P2 VF6/VF7, P3 VF8).** P1 zeichnet die Zeile aus Spec 5.3 nicht. P2 hat dafür `public/neu/rechnung-offen.js` (`ladeOffeneRechnungen`, `rechnungOffenZeile`), P3 soll sie von dort übernehmen; P3 VF8 erwartet sie von P1. Vorschlag: P2 liefert, P3 importiert. Bleibt P1 gewünscht, braucht P1 einen Folge-Task (`zaehler.stand().rechnungen`, zwei Zeilen im Gerüst). Zähler laden neu über `document.dispatchEvent(new CustomEvent('kn:zaehler-neu', { detail: { was: 'chat' | 'vertraege' | 'alle' } }))` (P2 VF6, am `document`, nicht am Fenster, weil `CustomEvent` nicht aufsteigt). Den Chat-Zähler setzt die Hülle selbst auf 0, wenn der Chat geöffnet wird. +7. **`?abo=success|cancel` (P2 VF4d).** Die Hülle öffnet dann Verträge auf dem Reiter Abo (`#abo`, Frage 4), rührt `location.search` am Zieleintrag aber nicht an. P2 liest den Parameter in `ansicht-vertraege.js`, wartet bis zu 20 s auf den Webhook (heute `stripeBilling.js:637`, `app.js:1557`) und räumt ihn danach mit `history.replaceState(history.state, '', location.pathname + location.hash)` auf (der Verlaufsstempel bleibt dabei erhalten). +8. **`api()`-Option `roh`.** Gemeint ist ein Rohdaten-Body (octet-stream) wie bei `fn-util.js:request(raw)` und `shop-tab-artikel.js:68`, nicht eine rohe Antwort. Bitte bestätigen. +9. **Funktionsmodule und der Verlaufsstempel.** `termine.js`, `shop.js`, `zeiten.js` und `funktionen.js` (News) schreiben nach dem Lesen der Rückkehr-Query `history.replaceState(null, …)`. P1 fängt das mit `stempeln()` ab. P4 darf diese Aufrufe nicht entfernen oder verändern, sonst gilt Task 4 („Stempel“) nicht mehr. +10. **Manifest und Service Worker.** `public/neu/manifest.webmanifest` hat dieselbe `id`, `start_url` und `scope` wie die alte, damit die installierte App dieselbe bleibt. `sw.js` bleibt gemeinsam und liefert bis P10 die alte, dunkle Offline-Seite. +11. **Überschriften (P2 VF3).** P2 will, dass jede Ansicht eine eigene `h1.kn-seitentitel` rendert und P1 sie beobachtet. P1 zeichnet die große Überschrift selbst (`h1.kn-titel` je Bereich in `dashboard.html`, Titel der Etage aus `titelFuer(route)`, ersetzbar per `kn:titel`) und steuert damit das Einklappen in den Kopf. Vorschlag: Ansichten rendern keine eigene `h1`, sonst entstehen zwei. +12. **`einschalten` für die Funktionsmodule (P4 VF1, P3 VF1/VF5, Befund 1 und 3).** Entschieden und in Task 11 umgesetzt: Die Hülle ruft `initNews`, `initTermine`, `initShop`, `initBuchungen` und `initZeiten` als `init*(wurzel, { einschalten })` mit `einschalten = (platz, o) => lade('/neu/funktion-einschalten.js').then((m) => m.zeigeEinschalten(platz, o))` (lazy über den Lader, `MIT_EINSCHALTEN`); `initAnalyse(wurzel)` bekommt keine Option. `projectId` übergibt die Hülle nicht, die Projektwahl bleibt `wurzel.dataset.wunschProjekt` (V2). Ohne die Option zeigen die Module im Abo-Modell nur die Fehlerkarte E4, obwohl die Tests von P3 und P4 grün sind, weil sie `einschalten` einspritzen. **V2 der Übersicht muss nachgezogen werden:** Im Absatz „Funktionsmodule“ (Zeile „werden von der neuen Hülle wie heute aufgerufen“) ergänzen: `initNews|initTermine|initShop|initBuchungen|initZeiten(wurzel, { einschalten })`, `einschalten(platz, { key, projectId, onFertig, viewAs? }) → Promise` ruft `zeigeEinschalten` aus `public/neu/funktion-einschalten.js` (P3); `initAnalyse(wurzel)` unverändert. Die Verdrahtung pinnt `test/knHuelleStart.test.js` („Option einschalten“); P4 Task 15 ersetzt seine Zeile `command grep -a -n einschalten public/neu/huelle.js` durch den Lauf dieses Tests. diff --git a/docs/superpowers/plans/2026-10-16-kundenansicht-03-website-chat-vertraege.md b/docs/superpowers/plans/2026-10-16-kundenansicht-03-website-chat-vertraege.md new file mode 100644 index 0000000..43b8f18 --- /dev/null +++ b/docs/superpowers/plans/2026-10-16-kundenansicht-03-website-chat-vertraege.md @@ -0,0 +1,1569 @@ +# Kundenansicht neu P2: Website, Chat, Verträge Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Die Bereiche Website, Chat und Verträge sowie die Etagen Projektdetails, Gutschrift, Passwort, Bestellen und Wechsel laufen mit den heutigen APIs in der neuen weißen Oberfläche. Die öffentlichen Seiten (Login, Kündigen, Widerrufen, Auftragsbestätigung, Offline) gibt es im neuen Stil unter `public/neu/oeffentlich/`, noch nicht ausgeliefert. + +**Architecture:** Je Ansicht ein ES-Modul `public/neu/ansicht-*.js` mit `zeigeX(wurzel, { route, me, nav })`, gebaut mit `h()` und `textContent` auf den heutigen Endpunkten (Karte woms-vorschau B4). Die acht Stub-Dateien und `ansichten.css`, die P1 anlegt, werden gefüllt. Reine Logik (Formate, Reiterwahl, Status, Zeitleiste) steht in kleinen exportierten Funktionen und wird mit `node --test` geprüft, DOM-Teile laufen gegen `test/support/fakeDom.js`. Die Wortlaut-Seiten (Bestellen, Wechsel, E-Mail-Speicher, Gutschrift, öffentliche Seiten) übernehmen den Text der Fassung 5 unverändert. Ein Wortlaut-Orakel lädt `public/app.js` in eine VM und vergleicht den sichtbaren Text Zeichen für Zeichen mit der neuen Seite. + +**Tech Stack:** ES-Module ohne Build (Browser, Node 20/24), `node --test`, `test/support/fakeDom.js`, `window.ChatComposer` (klassisches Skript `public/chat-composer.js`), `public/auftrag-portal.js`, Sichtprüfung nur über das Werkzeug aus P0. + +**Spec:** `docs/superpowers/specs/2026-10-15-kundenansicht-neu-design.md` (5.1 Aussehen, 5.3 Gruppierung, 5.7 Sprache, C14 Wortlaut der Bestellseiten) + +**Übersicht/Verträge:** /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht/docs/superpowers/plans/2026-10-16-kundenansicht-00-uebersicht.md (gilt wörtlich) + +**Worktree und Branch:** `/home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui`, Branch `kn/ui` (Strang UI, nach P1, Stand Tag `kn-p1`). Alle Pfade dieses Plans sind relativ zu diesem Worktree, soweit sie nicht mit `/` beginnen. Tag am Ende: `kn-p2`. + +## Global Constraints + +Alle Global Constraints der Übersicht gelten (Schalter, Aussehen mit allen Verboten, Anrede, keine Funktionspreise, Hashes, rechtlich Fest, Testbefehle mit Speicherbremse, Commits mit Zeile `Co-Authored-By: Claude Opus 5.5 `, Sicherheitsblock, kein Push). Paketeigene Zusätze: + +- **Importe relativ** in `public/neu/*.js` (`./dom.js`, `../auftrag-portal.js`), damit `node --test` die Module direkt lädt. `ChatComposer` kommt über `globalThis`. +- **Kein HTML-Einsetzen:** kein `innerHTML`, `insertAdjacentHTML`, `outerHTML`. Kundentext nur über `h()` und `textContent`. URLs aus Daten (`previewUrl`, `portalUrl`, `paymentUrl`) nur über `safeUrl` (http/https) in `href`/`src`. +- **Anrede:** `du` in allen P2-Texten. Ausnahme nur die Wortlaut-Dateien `ansicht-bestellen.js`, `ansicht-wechsel.js`, `ansicht-speicher.js`, `ansicht-gutschrift.js` und die Seiten unter `oeffentlich/`: Dort bleibt der Text der Fassung 5 wörtlich (C14, Sie-Form inklusive). Untertitel unter Überschriften entfallen überall (Spec 5.7). +- **Keine eigene `h1`:** Bereichstitel zeichnet die Hülle (P1 Task 12), Etagentitel kommen aus `titelFuer(route)`. Weicht der Titel ab (Projektname, Produktname), meldet die Ansicht ihn mit `meldeTitel(wurzel, text)` (Ereignis `kn:titel`, P1 Task 6). +- **Links zwischen Ansichten** tragen `href` und `data-view` (Brücke der Hülle, `hashFuerAnsicht`), erzeugt mit `zuAnsicht(name, projektId?)`. Programmatisch navigiert nur `navigiere(hash, { ersetzen })`. +- **Wiederholbar:** Jeder `zeigeX`-Aufruf leert `wurzel`, zeigt sofort den Text `Wird geladen …`, verwirft späte Antworten (Zähler je Aufruf) und räumt Timer ab. Fällt eine Quelle aus, zeigt nur ihr Abschnitt einen Fehlertext. +- **Ansichtsmodus** (`me.viewAs === true`): Jeder schreibende Knopf ist gesperrt und es geht kein schreibender Aufruf raus. Neuer Sperrtext `Im Ansichtsmodus nicht möglich.`, Chat, Passwort und Gutschrift behalten ihre alten Sperrtexte. +- **Schreibende Aufrufe** über `api()` (V2) mit Body als Objekt. Alt-Module, die einen JSON-String senden (`auftrag-portal.js`), nur über `legacyRequest(api)`. +- **Verlaufsstempel (P1 VF7, VF9):** Wer die Adresse aufräumt, ruft `history.replaceState(history.state, …)`, nie mit `null`. +- **Schreibverbot für Altes:** `public/app.js`, `dashboard.html`, `login.html`, `kuendigen.html`, `widerrufen.html`, `offline.html`, `auftrag.html`, `auftrag.css`, `legal-form.*`, `auftrag*.js`, `chat-composer.*` (alle direkt unter `public/`) bleiben unverändert (Prüfung in Task 20). Was die neue Oberfläche anders braucht, liegt als neue Datei unter `public/neu/`. +- **Kein Lila in `public/neu/`:** Die Prüfung `lila` (P0) liest den Text jedes geladenen Stylesheets, auch ungenutzter Regeln. Die neue Oberfläche lädt deshalb weder `/chat-composer.css` noch `/auftrag.css`, `/login.css` oder `/legal-form.css`, sondern bereinigte Dateien unter `public/neu/` (`chat-composer.css` in Task 7, `oeffentlich/oeffentlich.css` in Task 17). Ein Quelltext-Test (Task 1) sucht in jeder Textdatei unter `public/neu/` nach `#9d3dfe` und `rgba(157, 61, 254`. +- **Pin:** `test/adminPanelGone.test.js:55-66` liest `public/` rekursiv. Kein neuer Quelltext enthält `admin.html`, `kalkulation.html` oder `/api/portal-admin`. +- **Testcode in diesem Plan** zeigt Namen und Assertions. Importe (`node:test`, `node:assert/strict`, `fakeDom`, `knDaten`, das Modul) und Hilfsfunktionen schreibt der Implementierer dazu. + +## Review Focus + +1. **Alt-Modul sendet doppelt kodierten Body:** `auftrag-portal.js` schickt `JSON.stringify(...)`, das neue `api()` kodiert Objekte selbst. Ohne Adapter scheitert „Auftrag bestätigen“ still mit 400. Tests: Task 1 (`legacyRequest`), Task 8 (POST-Body ist ein Objekt). +2. **Reiter-Sprung überschreibt ausdrückliche Wahl:** Mit offener Rechnung springt nur der Bereichs-Einstieg (Route ohne `reiter`) auf Rechnungen. `#abo`, `#wartung`, `#stunden`, `#speicher` und ein Klick auf einen Reiter nie. Allein `route.reiter` entscheidet, die Adressleiste ist keine zweite Wahrheit. Hosting-Kunden dürfen auf `#stunden` nicht ins Leere laufen. Tests: Task 16. +3. **Ansichtsmodus schreibt als Kunde:** Mitarbeiter markieren Chat gelesen, bestellen, ändern Speicher oder Passwort. Tests: Task 7 (Chat), 8 (Aufträge), 9 (Zahlungsart), 11 (Speicher), 12 (Bestellen), 13 (Wechsel), 14 (Gutschrift), 15 (Passwort). +4. **Wortlaut der Fassung 5 driftet:** Ein geänderter Satz auf Bestell-, Wechsel-, Speicher-, Gutschrift- oder öffentlichen Seiten wäre eine unbemerkte Rechtsänderung. Tests: Orakel in Task 11 bis 14 und 17, Hash-Pin der Gutschrift-Bedingungen in Task 14. +5. **Teilausfall und fremde Inhalte:** Fällt Aktivität, Rechnungen, Aufträge, Stundenkonto, Verträge oder Vorschau aus, oder steht `` bzw. `javascript:` in Projektname, Rechnungsprojekt, Chat, Rückfrage oder Vorschau-Adresse, bleibt der Rest der Seite stehen und nichts wird als HTML oder Link ausgeführt. Tests: Task 5, 6, 8, 9, 10, 14, 16. + +## Festlegungen dieses Plans + +Diese Punkte legen weder Spec noch Übersicht fest. +- **Projektdetails** sind immer Etage 2 (`#projekt?projekt=`), auch bei einer einzigen Website. P1 ruft dafür `zeigeWebsite` mit `route.etage === 'projekt'` (Wurzel `#etage-body`). Die ID liest die Ansicht aus `projekt=`, hilfsweise aus `id=`. +- **Reiter nach Abo:** Ohne laufendes Abo gibt es nur Rechnungen und Abo (dort die Abo-Auswahl). Hosting zeigt zusätzlich E-Mail, Wartung zusätzlich Stunden und E-Mail. +- **„Verträge hier kündigen“** steht unter jedem Reiter, auch ohne Abo. „Vertrag widerrufen · bis TT.MM.“ steht im Reiter Abo nur bei `withdrawalOpen`. +- **„Postfach anfragen“** wandert in den Reiter E-Mail. Der Chat öffnet mit der Vorgabe `Bitte lege ein neues Postfach an: `. +- **Offene Rechnung** heißt wie in V3 `Number(balance) > 0`. Eine offene: `Rechnung offen · RE-… · 50,00 € · Zahlen`. Mehrere: `2 Rechnungen offen · 150,00 € · Zahlen`. +- **Reiterwahl in Verträge:** Allein `route.reiter` entscheidet. Die Ansicht liest `location.hash` nicht. Fehlt `route.reiter` (Bereichs-Einstieg über `#vertraege` oder die Leiste), gilt Rechnungen bei offener Rechnung, sonst Abo. Das setzt voraus, dass P1 `routeAusHash('#vertraege')` ohne `reiter` liefert (VF8). Reiterklicks navigieren mit den ausdrücklichen Hashes `#rechnungen`, `#abo`, `#stunden`, `#speicher`. +- **Wechsel mit gemerkter Funktion (E14, C5):** Hat `ansicht-wechsel.js` eine Funktion gesendet und liefert der Server danach `subscription.wechselFunktion`, geht die Ansicht mit `ersetzen` zu `#funktionen`, nicht zu `#vertraege`. Dort öffnet P3 (Task 6) den Einschalt-Ablauf. Dasselbe gilt für die Rückkehr mit `?abo=success` in Task 16. Ohne gemerkte Funktion bleibt alles wie in Fassung 5. +- **Stunden** erscheinen als `2,5 Std.` (Komma, höchstens zwei Stellen). +- Der Erklärtext unter dem Chat-Composer („Enter = Senden …“) und alle Untertitel unter Überschriften entfallen. + +## Dateiübersicht + +Quelle `Tabelle` = Zuständigkeitstabelle der Übersicht, `Stub` = Datei, die P1 als Stub anlegt und die P2 füllt (P1 Task 12), `VF1` = Zusatzdatei, die P2 braucht (Vertragsfrage VF1). + +| Datei | Quelle | Aufgabe | Task | +|---|---|---|---| +| `public/neu/ansicht-hilfen.js` | VF1 | Formate, Bausteinklassen `K`, `legacyRequest`, `navigiere`, `zuAnsicht`, Ereignisse, Hinweis- und Chat-Vorgabe-Speicher | 1 | +| `test/support/knDaten.js`, `test/support/knWortlautOrakel.js` | VF1 | Testdaten und `mit()`-Rahmen, VM-Orakel für den alten Text | 1, 2 | +| `public/neu/rechnung-offen.js` | VF1, VF3 | Zeile „Rechnung offen“ für Website und Funktionen (P3) | 3 | +| `public/neu/install-hinweis.js` | VF1 | Installationshinweis | 4 | +| `public/neu/website-details.js` | VF1 | Auftrag, Änderungen, Arbeiten, Dateien, Aktivität | 5 | +| `public/neu/ansicht-website.js` | Tabelle, Stub | Projektliste, Etage `projekt`, Aktivität | 6 | +| `public/neu/ansicht-chat.js` | Tabelle, Stub | Chat | 7 | +| `public/neu/dashboard.html` | P1-Datei | zwei Zeilen für den Composer (P1 Task 12 erlaubt sie): Skript und die bereinigte CSS-Kopie | 7 | +| `public/neu/chat-composer.css` | VF1 | Kopie von `public/chat-composer.css` ohne Lila, Gewicht 800 und Glas (Prüfung `lila` liest den Text jedes Stylesheets) | 7 | +| `test/knHuelleSeite.test.js` | P1-Test | eine Zeile: erlaubt `/neu/chat-composer.css` statt `/chat-composer.css` | 7 | +| `public/neu/vertraege-rechnungen.js` | VF1 | Reiter Rechnungen mit Aufträgen | 8 | +| `public/neu/vertraege-abo.js` | VF1 | Reiter Abo, Abo-Auswahl, Einstiege | 9 | +| `public/neu/ansicht-stunden.js` | Tabelle | Stundenkonto mit Vorschuss | 10 | +| `public/neu/ansicht-speicher.js` | Tabelle | E-Mail-Speicher 1:1, Postfach anfragen | 11 | +| `public/neu/ansicht-bestellen.js`, `ansicht-wechsel.js` | Tabelle, Stub | Bestellen und Wechsel 1:1 (Fassung 5); der Wechsel mit gemerkter Funktion springt zu `#funktionen` | 12, 13 | +| `public/neu/ansicht-gutschrift.js`, `ansicht-passwort.js` | Tabelle, Stub | Gutschrift 1:1, Passwort | 14, 15 | +| `public/neu/ansicht-vertraege.js` | Tabelle, Stub | Rahmen mit Reitern | 16 | +| `public/neu/oeffentlich/{login,kuendigen,widerrufen,offline}.html` | Tabelle | Öffentliche Seiten im neuen Stil | 17 | +| `public/neu/oeffentlich/auftrag.html` | Spec 5.1, VF1 | Auftragsbestätigung im neuen Stil (Link aus Mail K31) | 17 | +| `public/neu/oeffentlich/oeffentlich.css` | VF1 | Aussehen dieser fünf Seiten, auch der `ab-*`-Klassen der Auftragsseite | 17 | +| `public/neu/ansichten.css` | Tabelle, Stub | Aussehen aller P2-Ansichten und des Aufträge-Abschnitts | 18 | +| `/root/reports/kundenansicht-neu-2026-10-15/werkzeug/` (eigenes Git): `views.mjs`, `gates.mjs`, `fixtures/endpunkte-p2.mjs`, `fixtures/endpunkte.mjs`, `vertrag-endpunkte.mjs`, `vorschau/seiten.mjs` (ein Eintrag) | Tabelle (P0 legt an, P2 ergänzt) | Ansichten, Gate `p2`, drei Endpunkte, Seite `auftrag.html` | 19 | +| `test/knAnsicht*.test.js` (19 Dateien) | VF1 | Tests dieses Pakets | 1 bis 18 | + +**Bestehende Testdateien und Pins:** Genau eine wird geändert, `test/knHuelleSeite.test.js` (P1-eigene Datei im selben Strang, eine Zeile in Task 7). Geprüft: Kein Test liest `public/` rekursiv außer `adminPanelGone.test.js:32-66` (siehe Constraint); die `readdirSync`-Stellen in `termineView.test.js:324,397,418`, `termineFragebogenView.test.js:1023`, `shopWiring.test.js:49`, `zeitenView.test.js:391` lesen `public/` nicht rekursiv. `test/knHuelleSeite.test.js` (P1) erlaubt `/chat-composer.js` und `/chat-composer.css` in `dashboard.html`; Task 7 stellt die Stil-Zeile auf `/neu/chat-composer.css` um (VF10). Nur lesend benutzt: `legalTexts.test.js:18` (Hash `2b7a745c…473e62` des Blocks `TAKEOVER_CONDITIONS`, Task 14) und `auftraegeKundenportalDaten.js` (Testdaten `eintrag`, Task 8). + +--- + +### Task 1: Hilfen, Testdaten und Quelltext-Regeln + +**Files:** +- Create: `public/neu/ansicht-hilfen.js`, `test/support/knDaten.js` +- Test: `test/knAnsichtHilfen.test.js`, `test/knAnsichtQuelltext.test.js` + +**Interfaces:** +- Consumes: `api(pfad, { method, body })` und `ApiFehler` aus `./api.js`, `geheZu(hash, { ersetzen })` aus `./router.js`, `hashFuerAnsicht(name)` aus `./routen.js` (alle P1), `safeUrl` aus `../fn-util.js`. +- Produces `ansicht-hilfen.js`: +```js +export const K // Bausteinklassen aus P1 basis.css: knopf, primaer, zweit, textknopf, feld, liste, zeile, abschnitt, meldung, fehler, leer, versteckt:'hidden', pille:{grau,blau,orange,violett,gruen,rot} +export const fmtEuro // (euro) → '50,00\u00a0€' (geschütztes Leerzeichen vor dem Euro-Zeichen) +export const fmtDatum // (wert) → 'TT.MM.JJJJ' (Europe/Berlin), 'TT.MM.JJJJ' bleibt, leer → '–' +export const fmtDatumZeit // (wert) → 'TT.MM.JJ, HH:MM' (Europe/Berlin) +export const fmtTagMonat // (wert) → 'TT.MM.' (Europe/Berlin), ungültig → '' +export const fmtStd // (minuten) → '2,5 Std.' | '2 Std.' | '0,75 Std.' +export const fmtMinuten // (minuten) → '' | '45 min' | '2 h' | '2 h 30 min' +export const fmtBytes // (bytes) → '12 B' | '1,5 KB' | '5,0 MB' +export const ABO_NICHT_GELADEN = 'Deine Verträge konnten gerade nicht geladen werden. Bitte versuche es später noch einmal.' +export function legacyRequest(api) // → (pfad, opts) => api(pfad, { ...opts, body: Objekt }); String-Body wird per JSON.parse zum Objekt +export function navigiere(hash, { ersetzen = false } = {}) // geheZu(); bei Ausnahme window.location.hash = hash +export function zuAnsicht(name, projektId = '') // → { href, 'data-view': name, 'data-project'?: projektId }; href = hashFuerAnsicht(name) (+ '?projekt=') +export function meldeTitel(el, text) // el.dispatchEvent(new CustomEvent('kn:titel', { bubbles: true, detail: { text } })) +export function meldeZaehlerNeu(was) // 'chat'|'vertraege'|'alle': document.dispatchEvent(new CustomEvent('kn:zaehler-neu', { bubbles: true, detail: { was } })), no-op ohne document/CustomEvent +export function merkeHinweis(text); export function nimmHinweis() // einmaliger Hinweis für die nächste Verträge-Ansicht, danach '' +export function setzeChatVorgabe(text); export function nimmChatVorgabe() // Nachrichtenvorgabe für den nächsten Chat +export async function ladeAbo() // GET /api/abo → Objekt | null (Fehler → null) +export function wechselFunktionAus(sub) // sub = subscription | undefined | null → { key, projectId } | null (V11: beide Felder nichtleere Strings) +export { safeUrl } +``` +- Produces `test/support/knDaten.js` (Formen laut `server/routes/abo.js:88-214`, `routes/customerData.js:18-100` mit `services/customerInvoices.js:61-87`, `routes/projects.js:25-90`, `routes/chat.js:26-35`, `services/takeoverCredits.js:366-384`): +```js +export const mit = (tabelle, fn) => async () => … // installFakeDom mit 80 Antwortfunktionen; Schlüssel 'GET /api/abo' (oder nur Pfad); Wert Objekt (200) oder (aufruf) => ({ status, body }); unbekannt → 404 { error: 'unbekannt' }. Setzt globalThis.CustomEvent auf eine Attrappe mit beschreibbaren Feldern (fakeDom schreibt target und ergebnisse) und stellt sie danach zurück +export const mitDom = (fn) => … // wie mit() ohne Antworten, führt fn(fx) sofort aus (synchrone Tests) +export const neueWurzel = (fx) => Element // div, an document.body gehängt +export const VORSCHAU = { prorationDate: 1760000000, amountDueCents: 3000, creditCents: 1500, monthlyCents: 5000, nextChargeAt: '2026-11-16T00:00:00.000Z', minTermEnd: '2027-10-16T00:00:00.000Z' } // POST /api/abo/switch/preview +export const VORSCHAU_SPEICHER = { direction: 'down', units: 1, totalGb: 10, monthlyCents: 100, deltaMonthlyCents: -100, prorationCents: 0, nextChargeAt: '2026-11-16T00:00:00.000Z', effectiveAt: '2026-11-01T00:00:00.000Z' } // POST /api/abo/storage/preview +export const LIMITS = { maxFiles: 3, maxFileBytes: 8388608, maxTotalBytes: 10485760 } // takeover-credits +export const me = (over = {}), projekt = (over = {}), rechnung = (over = {}), nachricht = (over = {}), angebot = (over = {}) +export const abo = ({ produkt = 'wartung', ...over } = {}) // subscription: active, productKey, priceAmount, storage, canChangeStorage, canSwitch, withdrawals … +export const ledger = (over = {}), aboAntwort = (over = {}), anfrageGutschrift = (over = {}) +``` + +- [ ] **Step 1: Voraussetzungen prüfen** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git tag --list 'kn-p*' && ls public/neu` +Expected: `kn-p0` und `kn-p1` stehen in der Liste. `ls` zeigt `dom.js api.js routen.js router.js reiter.js etage.js tokens.css basis.css huelle.css dashboard.html` und die Stubs `ansicht-website.js ansicht-chat.js ansicht-vertraege.js ansicht-passwort.js ansicht-gutschrift.js ansicht-wechsel.js ansicht-bestellen.js ansichten.css`. Fehlt etwas, anhalten und dem Controller melden. + +- [ ] **Step 2: Tests schreiben** + +`test/knAnsichtHilfen.test.js`: +```js +test('Formate: Euro mit geschütztem Leerzeichen, Daten in Berliner Zeit', () => { + assert.equal(fmtEuro(50), '50,00\u00a0€') + assert.equal(fmtDatum('2026-10-17T10:00:00Z'), '17.10.2026') + assert.equal(fmtDatum('17.10.2026'), '17.10.2026') + assert.equal(fmtDatum(''), '–') + assert.equal(fmtDatumZeit('2026-10-17T10:00:00Z'), '17.10.26, 12:00') + assert.equal(fmtTagMonat('2026-10-17T21:59:59Z'), '17.10.') + assert.equal(fmtTagMonat('2026-10-17T22:00:00Z'), '18.10.') + assert.equal(fmtTagMonat('kaputt'), '') +}) +test('Formate: Stunden, Minuten, Bytes', () => { + assert.deepEqual([150, 120, 45, 0].map(fmtStd), ['2,5 Std.', '2 Std.', '0,75 Std.', '0 Std.']) + assert.deepEqual([0, 45, 120, 150].map(fmtMinuten), ['', '45 min', '2 h', '2 h 30 min']) + assert.deepEqual([12, 1536, 5 * 1024 * 1024].map(fmtBytes), ['12 B', '1,5 KB', '5,0 MB']) +}) +test('legacyRequest: String-Body wird zum Objekt, Objekt und fehlender Body bleiben', async () => { + const aufrufe = [] + const alt = legacyRequest(async (pfad, opts) => { aufrufe.push([pfad, opts]); return {} }) + await alt('/a', { method: 'POST', body: '{"x":1}' }); await alt('/b', { method: 'POST', body: { y: 2 } }); await alt('/c') + assert.deepEqual(aufrufe, [['/a', { method: 'POST', body: { x: 1 } }], ['/b', { method: 'POST', body: { y: 2 } }], ['/c', {}]]) +}) +test('meldeZaehlerNeu ohne Dokument tut nichts, Hinweis und Chat-Vorgabe gelten einmal', () => { + assert.doesNotThrow(() => meldeZaehlerNeu('chat')) + merkeHinweis('Gespeichert.'); assert.equal(nimmHinweis(), 'Gespeichert.'); assert.equal(nimmHinweis(), '') + setzeChatVorgabe('Hallo'); assert.equal(nimmChatVorgabe(), 'Hallo'); assert.equal(nimmChatVorgabe(), '') +}) +test('wechselFunktionAus: nur vollständige Werte zählen (V11)', () => { + assert.deepEqual(wechselFunktionAus({ wechselFunktion: { key: 'termine', projectId: 'p1', extra: 1 } }), { key: 'termine', projectId: 'p1' }) + for (const sub of [undefined, null, {}, { wechselFunktion: null }, { wechselFunktion: { key: 'termine' } }, { wechselFunktion: { key: '', projectId: 'p1' } }, { wechselFunktion: { key: 1, projectId: 'p1' } }]) assert.equal(wechselFunktionAus(sub), null) +}) +test('zuAnsicht ist die Brücke der Hülle, meldeTitel steigt zu den Eltern auf', () => mitDom((fx) => { + assert.deepEqual(zuAnsicht('takeover'), { href: '#gutschrift', 'data-view': 'takeover' }) + assert.deepEqual(zuAnsicht('projekt', 'p1'), { href: '#projekt?projekt=p1', 'data-view': 'projekt', 'data-project': 'p1' }) + assert.equal(zuAnsicht('invoices').href, '#rechnungen') + const aussen = fx.document.createElement('div'); const innen = fx.document.createElement('p'); aussen.append(innen); const titel = [] + aussen.addEventListener('kn:titel', (e) => titel.push(e.detail.text)); meldeTitel(innen, 'Zwei'); assert.deepEqual(titel, ['Zwei']) +})) +``` +`test/knAnsichtQuelltext.test.js` prüft die vorhandenen Module aus der Liste `ansicht-hilfen, rechnung-offen, install-hinweis, website-details, ansicht-website, ansicht-chat, ansicht-vertraege, vertraege-rechnungen, vertraege-abo, ansicht-stunden, ansicht-speicher, ansicht-bestellen, ansicht-wechsel, ansicht-gutschrift, ansicht-passwort` (Kommentare vorher entfernt, fehlende Dateien übersprungen, `WORTLAUT = ['ansicht-speicher','ansicht-bestellen','ansicht-wechsel','ansicht-gutschrift']`). Die Stubs von P1 bestehen die Regeln schon: +```js +test('P2-Module: kein HTML-Einsetzen, nur relative Importe, keine Admin-Verweise', () => { + for (const n of vorhanden) { + const q = lies(n) + assert.doesNotMatch(q, /innerHTML|insertAdjacentHTML|outerHTML|document\.write|console\.log/, n) + assert.doesNotMatch(q, /from\s+'\/|import\(\s*'\//, `${n}: absoluter Import`) + assert.doesNotMatch(q, /admin\.html|kalkulation\.html|\/api\/portal-admin/, n) + } +}) +test('P2-Module: Du-Form, nur die vier Wortlaut-Dateien behalten die Sie-Form (C14)', () => { + for (const n of vorhanden.filter((x) => !WORTLAUT.includes(x))) assert.doesNotMatch(lies(n), /\b(Sie|Ihr(e[nmrs]?)?|Ihnen)\b/, n) +}) +test('public/neu/: kein Lila in irgendeiner Textdatei (die Prüfung lila liest jedes geladene Stylesheet samt ungenutzter Regeln)', () => { + for (const pfad of dateienUnter('public/neu', /\.(css|html|js|mjs|svg|webmanifest)$/)) assert.doesNotMatch(readFileSync(pfad, 'utf8'), /#9d3dfe|rgba?\(\s*157\s*,\s*61\s*,\s*254/i, pfad) +}) +``` +Der Lila-Test liest den Rohtext einschließlich Kommentaren (so wie die Prüfung den Stylesheet-Text liest); `dateienUnter(ordner, muster)` durchsucht rekursiv und gehört zu den Hilfen des Tests. Er läuft ab Task 1 und deckt damit auch alle späteren Dateien dieses Pakets (Task 7, 17, 18) und die von P3 und P4 ab. + +- [ ] **Step 3: Test laufen lassen, muss scheitern** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knAnsichtHilfen.test.js test/knAnsichtQuelltext.test.js` +Expected: `knAnsichtHilfen` FAIL mit `ERR_MODULE_NOT_FOUND` für `public/neu/ansicht-hilfen.js`, `knAnsichtQuelltext` grün. + +- [ ] **Step 4: Implementieren** + +`ansicht-hilfen.js` nach der Signatur oben. Datums- und Zeitformate über `Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', … }).formatToParts` zusammensetzen, nicht auf die Ausgabe von `toLocaleString` verlassen. `K` mit den Klassen, die P1 `basis.css` führt (`kn-knopf`, `kn-knopf kn-knopf-primaer`, `kn-pille kn-pille-` usw.). `knDaten.js` mit den Bauern; Standardwerte aus den Quellzeilen oben: Abo mit `startedAt`, `currentPeriodEnd`, `priceAmount` in Cent, `storage:{units,pendingSetup,pendingReduction}`; Produkte `hosting` 2000 und `wartung` 5000 Cent mit je einer Feature-Zeile `['Titel','Detail']`; `termsVersion: '2026-09-30-v5'`, `lateStartAt: '2026-10-30T00:00:00.000Z'`, `mailStorage: { includedGb: 5, gbPerUnit: 5, unitPriceCents: 100, maxUnits: 10 }`, `available: true`; `me()` mit Kunde `anna@example.com`. + +- [ ] **Step 5: Lauf grün** + +Run: derselbe Befehl wie Step 3. Expected: PASS, 9 Tests. + +- [ ] **Step 6: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/ansicht-hilfen.js test/support/knDaten.js test/knAnsichtHilfen.test.js test/knAnsichtQuelltext.test.js && git commit -m "Kundenansicht neu P2: Hilfen, Testdaten und Quelltext-Regeln" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: Wortlaut-Orakel + +**Files:** +- Create: `test/support/knWortlautOrakel.js` +- Test: `test/knAnsichtOrakel.test.js` + +**Interfaces:** +- Consumes: `public/app.js` (klassisches Skript, nur gelesen), `test/support/knDaten.js`. +- Produces: +```js +export function sichtbarerText(quelle) // HTML-String oder Element → Text ohne Tags, Kommentare,

'), 'HalloWelt&Co') + assert.equal(sichtbarerText('50,00\u00a0€ '), '50,00€') +}) +test('ohneKopf streicht nur den Kopf, nicht die Absätze danach', () => { + assert.equal(sichtbarerText(ohneKopf('

A

B

C

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

A

C

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

C

')), 'C') +}) +test('altes app.js rendert in der VM: Bestellen, Wechsel, Speicher, Gutschrift', async () => { + const alt = ladeAlteApp() + assert.match(sichtbarerText(alt.bestellen(aboAntwort(), 'wartung')), /Zahlungspflichtigbestellen/) + const hosting = aboAntwort({ subscription: abo({ produkt: 'hosting', canSwitch: true }) }) + assert.match(sichtbarerText(await alt.wechsel(hosting, VORSCHAU)), /Zahlungspflichtigbestellen/) + assert.match(sichtbarerText(await alt.speicher(aboAntwort({ subscription: abo() }))), /E-Mail-Speicher/) + assert.match(sichtbarerText(await alt.gutschrift(aboAntwort({ subscription: abo() }), { requests: [], limits: LIMITS })), /Antragabsenden/) +}) +``` + +- [ ] **Step 2: Lauf, muss scheitern** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knAnsichtOrakel.test.js` +Expected: FAIL `ERR_MODULE_NOT_FOUND` für `knWortlautOrakel.js`. + +- [ ] **Step 3: Orakel implementieren** (Signaturen und Attrappen wie oben). + +- [ ] **Step 4: Lauf grün** + +Run: derselbe Befehl. Expected: PASS, 3 Tests. + +- [ ] **Step 5: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add test/support/knWortlautOrakel.js test/knAnsichtOrakel.test.js && git commit -m "Kundenansicht neu P2: Wortlaut-Orakel für die Texte der Fassung 5" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: Zeile „Rechnung offen“ (für Website und Funktionen) + +**Files:** +- Create: `public/neu/rechnung-offen.js` +- Test: `test/knAnsichtRechnungOffen.test.js` + +**Interfaces:** +- Consumes: `api` (`./api.js`), `h` (`./dom.js`), `fmtEuro`, `safeUrl`, `zuAnsicht` (`./ansicht-hilfen.js`), `GET /api/invoices` → `{ invoices:[{ id, number, balance, portalUrl, … }], incomplete }`. +- Produces (P3 verbraucht es, VF3): +```js +export function offeneRechnungen(antwort) // → Rechnung[] mit Number(balance) > 0, Reihenfolge der Antwort +export async function ladeOffeneRechnungen() // → Rechnung[]; Fehler → [] +export function rechnungOffenZeile(offen) // →

| null (leere Liste) +``` + +- [ ] **Step 1: Test schreiben** + +```js +const NB = '\u00a0' +test('eine offene Rechnung: Text und Link auf die Rechnung', () => mitDom((fx) => { + const p = rechnungOffenZeile([rechnung({ number: 'RE-2026-0042', balance: 50, portalUrl: 'https://invoice.stripe.com/i/x' })]) + assert.equal(p.textContent, `Rechnung offen · RE-2026-0042 · 50,00${NB}€ · Zahlen`) + const a = fx.finde(p, 'a'); assert.equal(a.getAttribute('href'), 'https://invoice.stripe.com/i/x'); assert.equal(a.getAttribute('rel'), 'noopener noreferrer') +})) +test('mehrere offene: Summe, Link über die Brücke zu #rechnungen; unsicherer Link fällt darauf zurück', () => mitDom((fx) => { + const zwei = [rechnung({ balance: 100 }), rechnung({ balance: 50 })] + assert.equal(rechnungOffenZeile(zwei).textContent, `2 Rechnungen offen · 150,00${NB}€ · Zahlen`) + const a = fx.finde(rechnungOffenZeile(zwei), 'a'); assert.deepEqual([a.getAttribute('href'), a.getAttribute('data-view')], ['#rechnungen', 'invoices']) + assert.equal(fx.finde(rechnungOffenZeile([rechnung({ balance: 5, portalUrl: 'javascript:alert(1)' })]), 'a').getAttribute('href'), '#rechnungen') + assert.equal(rechnungOffenZeile([]), null) +})) +test('offeneRechnungen filtert nach Betrag, ladeOffeneRechnungen schluckt Fehler', mit({ 'GET /api/invoices': () => ({ status: 500, body: { error: 'x' } }) }, async () => { + assert.deepEqual(offeneRechnungen({ invoices: [rechnung({ balance: 0 }), rechnung({ id: 'b', balance: 1 })] }).map((r) => r.id), ['b']) + assert.deepEqual(await ladeOffeneRechnungen(), []) +})) +``` + +- [ ] **Step 2: Lauf, muss scheitern** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knAnsichtRechnungOffen.test.js` +Expected: FAIL `ERR_MODULE_NOT_FOUND` für `rechnung-offen.js`. + +- [ ] **Step 3: Implementieren** (Signaturen oben). Eine Rechnung mit sicherem `portalUrl`: `target="_blank" rel="noopener noreferrer"`. Alle anderen Fälle: `zuAnsicht('invoices')`. Der Betrag ist die Summe der `balance`. + +- [ ] **Step 4: Lauf grün.** Run wie Step 2. Expected: PASS, 3 Tests. + +- [ ] **Step 5: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/rechnung-offen.js test/knAnsichtRechnungOffen.test.js && git commit -m "Kundenansicht neu P2: Zeile Rechnung offen" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: Installationshinweis + +**Files:** +- Create: `public/neu/install-hinweis.js` +- Test: `test/knAnsichtInstall.test.js` + +**Interfaces:** +- Consumes: `h` (`./dom.js`). Logik aus `app.js:2153-2161` (Standalone, iOS) und `:2267-2293`. +- Produces: +```js +export function installAngebot({ appLayout, standalone, canPrompt, ios, dismissed }) // → { zeigen: boolean, art: 'prompt'|'ios'|null } +export function baueInstallHinweis({ art, onInstall, onDismiss }) // → HTMLElement +export function starteInstallQuelle(win = window) // je win genau eine Quelle (WeakMap) → { ios, standalone(), kannPrompt(), installiere(), beiAenderung(fn) }; hört beforeinstallprompt und appinstalled +export function istAusgeblendet(); export function merkeAusgeblendet() // localStorage 'kb-install-dismissed' = '1', immer in try/catch +``` + +- [ ] **Step 1: Test schreiben** + +```js +const a = (o) => installAngebot({ appLayout: true, standalone: false, canPrompt: false, ios: false, dismissed: false, ...o }) +test('installAngebot: nur Handy, nicht installiert, mit Prompt oder iOS, nicht ausgeblendet', () => { + assert.deepEqual(a({ canPrompt: true }), { zeigen: true, art: 'prompt' }) + assert.deepEqual(a({ ios: true }), { zeigen: true, art: 'ios' }) + assert.deepEqual(a({ ios: true, canPrompt: true }), { zeigen: true, art: 'prompt' }) + for (const o of [{}, { canPrompt: true, standalone: true }, { canPrompt: true, appLayout: false }, { canPrompt: true, dismissed: true }]) assert.equal(a(o).zeigen, false) +}) +test('Hinweis: Texte, Knöpfe, Sperre bei kaputtem localStorage', () => mitDom((fx) => { + let n = 0; const zaehle = () => { n += 1 } + const ios = baueInstallHinweis({ art: 'ios', onInstall() {}, onDismiss() {} }) + assert.match(ios.textContent, /WEBklar als App/); assert.match(ios.textContent, /Tippe auf „Teilen“ und dann auf „Zum Home-Bildschirm“\./) + const prompt = baueInstallHinweis({ art: 'prompt', onInstall: zaehle, onDismiss: zaehle }) + assert.match(prompt.textContent, /Mit einem Tipp auf deinem Homescreen – ohne App-Store\./) + fx.klick(fx.finde(prompt, (e) => e.textContent === 'Installieren')); fx.klick(fx.finde(prompt, '[aria-label="Hinweis ausblenden"]')); assert.equal(n, 2) + globalThis.localStorage = { getItem() { throw new Error('x') }, setItem() { throw new Error('x') } } + assert.equal(istAusgeblendet(), false); assert.doesNotThrow(merkeAusgeblendet); delete globalThis.localStorage +})) +test('Installquelle: beforeinstallprompt merken, installieren, appinstalled räumt', async () => { + const handler = {}; const win = { addEventListener: (t, f) => { handler[t] = f }, matchMedia: () => ({ matches: false }), navigator: { userAgent: 'iPhone' } } + const q = starteInstallQuelle(win); assert.equal(q.ios, true); assert.equal(starteInstallQuelle(win), q) + let prompted = 0; handler.beforeinstallprompt({ preventDefault() {}, prompt: async () => { prompted += 1 } }) + assert.equal(q.kannPrompt(), true); await q.installiere(); assert.equal(prompted, 1); assert.equal(q.kannPrompt(), false) +}) +``` + +- [ ] **Step 2: Lauf, muss scheitern** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knAnsichtInstall.test.js` +Expected: FAIL `ERR_MODULE_NOT_FOUND`. + +- [ ] **Step 3: Implementieren** (Signaturen oben, Texte wörtlich aus dem Test, kein Icon). Das iOS-Erkennen wie `app.js:2157`. Fehlendes `matchMedia` zählt als `false`. + +- [ ] **Step 4: Lauf grün.** Run wie Step 2. Expected: PASS, 3 Tests. + +- [ ] **Step 5: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/install-hinweis.js test/knAnsichtInstall.test.js && git commit -m "Kundenansicht neu P2: Installationshinweis" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 5: Bausteine der Projektdetails und Aktivität + +**Files:** +- Create: `public/neu/website-details.js` +- Test: `test/knAnsichtWebsiteDetails.test.js` + +**Interfaces:** +- Consumes: `h`, `K`, `zuAnsicht`, `fmtBytes`, `fmtMinuten`, `fmtDatum`. Antworten: `GET /api/projects/:id/git` → `{ changes:[{ date, title, details }] }`, `/files` → `{ totalFiles, totalSizeBytes, truncated, topLevel:[{ path, type, fileCount, sizeBytes }] }`, `/work` → `{ order:{ number, topic, status:{ label, tone, step } }|null, entries:[{ date, kind, label, person, minutes, changes, text, supports }], totalMinutes }`, `GET /api/activity` → `{ tickets:[order + createdAt + entries] }` (`routes/projects.js:57-100`, `routes/customerData.js:86-104`). +- Produces: +```js +export const AUFTRAG_SCHRITTE = ['Eingegangen', 'In Arbeit', 'Prüfung', 'Erledigt'] +export function statusTon(tone) // new→grau active→blau waiting→orange review→violett done→gruen paused→grau stopped→rot, sonst blau +export function tagesUeberschrift(datum, jetzt = new Date()) // 'Heute' | 'Gestern' | 'Mittwoch, 14. Oktober' | 'Mittwoch, 24. Dezember 2025' (Berliner Tag) +export function baueAuftrag(order, gesamtMinuten = 0) // Nummer, Betreff, Status-Pille, Fortschrittsliste, bei tone waiting 'Wir brauchen noch eine Info von dir.' + Link zuAnsicht('chat') +export function baueAenderungen(changes, jetzt) // nach Tagen gruppiert, 5 sichtbar +export function baueArbeiten(entries, leerText) // 4 sichtbar +export function baueDateien(files) +export function baueAktivitaet(tickets, jetzt) // je Auftrag baueAuftrag + baueArbeiten, 3 sichtbar, Knopf 'Alle anzeigen (n)' +export async function ladeProjektDetails(projectId) // → { git, files, work }, jede Quelle einzeln .catch(() => null) +export function baueProjektDetails({ git, files, work }, jetzt) // Abschnitte 'Letzte Änderungen', 'Arbeiten am Projekt', 'Dateien' (je mit Fehlertext, wenn null) +``` +Einklappen über `data-einklappbar` (Wrapper, Klasse `is-open`), `data-extra` (versteckte Zeilen), `data-mehr` (Knopf). Fehlertexte und Leertexte wörtlich wie `app.js:301, 345, 362, 399-403`. + +- [ ] **Step 1: Test schreiben** + +```js +test('Tagesüberschrift im Berliner Tag', () => { + const jetzt = new Date('2026-10-16T09:00:00Z') + assert.deepEqual(['2026-10-15T22:30:00Z', '2026-10-15T20:00:00Z', '2026-10-14T10:00:00Z', '2025-12-24T10:00:00Z'].map((d) => tagesUeberschrift(new Date(d), jetzt)), + ['Heute', 'Gestern', 'Mittwoch, 14. Oktober', 'Mittwoch, 24. Dezember 2025']) +}) +test('statusTon', () => assert.deepEqual(['new', 'active', 'waiting', 'review', 'done', 'paused', 'stopped', '?'].map(statusTon), ['grau', 'blau', 'orange', 'violett', 'gruen', 'grau', 'rot', 'blau'])) +test('Änderungen: nach Tagen, fünf sichtbar, Rest hinter „weitere“, Titel bleibt Text', () => mitDom((fx) => { + const tag = (d, n, p) => Array.from({ length: n }, (_, i) => ({ date: `2026-10-${d}T0${i + 6}:00:00.000Z`, title: `${p} ${i}`, details: [] })) + const el = baueAenderungen([...tag(15, 4, 'Neu'), ...tag(14, 3, 'Alt'), { date: '2026-10-13T06:00:00.000Z', title: '', details: [] }], new Date('2026-10-16T09:00:00Z')) + assert.equal(fx.alle(el, '[data-extra]').length, 3) + assert.equal(fx.finde(el, '[data-mehr]').textContent, '3 weitere Änderungen anzeigen') + assert.equal(fx.finde(el, 'img'), null) + fx.klick(fx.finde(el, '[data-mehr]')); assert.ok(fx.finde(el, '[data-einklappbar]').classList.contains('is-open')); assert.equal(fx.finde(el, '[data-mehr]').textContent, 'Weniger anzeigen') +})) +test('Arbeiten und Dateien: Zähltexte, Leertexte, Ordner mit Schrägstrich', () => mitDom((fx) => { + const e = (i) => ({ date: '2026-10-1' + i, kind: 'work', label: 'Arbeit', minutes: 15, person: 'Kenso' }) + assert.equal(fx.finde(baueArbeiten([1, 2, 3, 4, 5].map(e), 'leer'), '[data-mehr]').textContent, '1 weiteren Eintrag anzeigen') + assert.equal(baueArbeiten([], 'Noch keine Arbeiten.').textContent, 'Noch keine Arbeiten.') + const d = baueDateien({ totalFiles: 3, totalSizeBytes: 1536, truncated: true, topLevel: [{ path: 'img', type: 'dir', fileCount: 2, sizeBytes: 1024 }, { path: 'index.html', type: 'file', fileCount: 1, sizeBytes: 512 }] }) + assert.match(d.textContent, /3 Dateien · 1,5 KB \(Übersicht unvollständig – sehr großes Projekt\)/); assert.match(d.textContent, /img\//) + assert.equal(baueDateien({ totalFiles: 0 }).textContent, 'Keine Datei-Informationen verfügbar.') +})) +test('Projektdetails: Teilausfall ersetzt nur den betroffenen Abschnitt', () => mitDom(() => { + const el = baueProjektDetails({ git: null, files: { totalFiles: 0 }, work: { order: null, entries: [] } }, new Date()) + assert.match(el.textContent, /Die Änderungen können gerade nicht geladen werden\./); assert.match(el.textContent, /Keine Datei-Informationen verfügbar\./) +})) +``` + +- [ ] **Step 2: Lauf, muss scheitern** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knAnsichtWebsiteDetails.test.js` +Expected: FAIL `ERR_MODULE_NOT_FOUND`. + +- [ ] **Step 3: Implementieren** (Signaturen oben). Alles über `h()`. Tag-Vergleich über den Berliner Kalendertag. Zeit je Änderung `HH:MM` in Berliner Zeit. „Arbeiten am Projekt“ erscheint wie `app.js:400`: wenn `order` da ist oder `work` fehlt. Keine Emojis, keine Icons. + +- [ ] **Step 4: Lauf grün.** Run wie Step 2. Expected: PASS, 5 Tests. + +- [ ] **Step 5: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/website-details.js test/knAnsichtWebsiteDetails.test.js && git commit -m "Kundenansicht neu P2: Bausteine für Projektdetails und Aktivität" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 6: Ansicht Website + +**Files:** +- Modify: `public/neu/ansicht-website.js` (Stub von P1 ersetzen) +- Test: `test/knAnsichtWebsite.test.js` + +**Interfaces:** +- Consumes: `api`, `h`, `leeren` (V2), `K`, `zuAnsicht`, `meldeTitel`, `fmtDatumZeit`, `safeUrl` (`./ansicht-hilfen.js`), alles aus `./website-details.js`, `ladeOffeneRechnungen`, `rechnungOffenZeile` (`./rechnung-offen.js`), `starteInstallQuelle`, `installAngebot`, `baueInstallHinweis`, `istAusgeblendet`, `merkeAusgeblendet` (`./install-hinweis.js`). `GET /api/projects` → `{ projects:[{ id, projectName, subdomain, previewUrl, liveDomain, status, provisioningStatus, updatedAt }] }` (`routes/projects.js:25-55`). +- Produces: +```js +export async function zeigeWebsite(wurzel, { route, me, nav }) // route.etage: undefined | 'projekt' (query 'projekt=', hilfsweise 'id='); route.scroll === 'aktivitaet' scrollt zur Aktivität +export function projektStatus(project) // → { ton, text }: 'Vorschau online' gruen | 'Vorschau gestört' rot (provisioningStatus/status enthält 'fail') | 'Vorschau wird vorbereitet' orange (previewUrl, nicht bereit) | 'Noch keine Website' grau +export function vorschauBereit(project) // status ready|deployed UND previewUrl (wie app.js:434) +``` +Aufbau Hauptseite: Zeile „Rechnung offen“ (nur wenn offen), Installationshinweis (nur wenn `installAngebot().zeigen`), Projektliste (`[data-projekt=]` mit Frame nur bei `vorschauBereit` und `safeUrl`, Name, Status-Pille, Fakten `Vorschau-Adresse`, `Live-Domain` (`noch nicht verbunden`), `Letzte Änderung`, Links `Website öffnen` und `Projektdetails` = `zuAnsicht('projekt', id)`), `section[data-abschnitt="aktivitaet"]` mit Überschrift `Was wir zuletzt erledigt haben`. Texte ohne Frame: `Vorschau wird vorbereitet …` bzw. `Noch keine Website veröffentlicht`. Leer: `Noch keine Website-Projekte zugewiesen.` Aktivität-Fehler: `Aktivität konnte nicht geladen werden: `, leer: `Es wurden noch keine Arbeiten dokumentiert.` Etage `projekt`: `meldeTitel(wurzel, )` und `baueProjektDetails(await ladeProjektDetails(id))`. + +- [ ] **Step 1: Test schreiben** + +```js +const HAUPT = { 'GET /api/projects': { projects: [projekt({ id: 'p1', projectName: 'Bäckerei Roth' })] }, 'GET /api/activity': { tickets: [] }, 'GET /api/invoices': { invoices: [] } } +const zeige = async (fx, route = { bereich: 'website', query: '' }, m = me()) => { const w = neueWurzel(fx); await zeigeWebsite(w, { route, me: m, nav: 'tab' }); await fx.ruhe(); return w } +test('projektStatus', () => { + const s = (o) => projektStatus(projekt(o)) + assert.deepEqual(s({}), { ton: 'gruen', text: 'Vorschau online' }) + assert.deepEqual(s({ status: 'building' }), { ton: 'orange', text: 'Vorschau wird vorbereitet' }) + assert.deepEqual(s({ provisioningStatus: 'failed' }), { ton: 'rot', text: 'Vorschau gestört' }) + assert.deepEqual(s({ previewUrl: '' }), { ton: 'grau', text: 'Noch keine Website' }) +}) +test('Website: Ladezustand ist Text und verschwindet', mit(HAUPT, async (fx) => { + const w = neueWurzel(fx); const lauf = zeigeWebsite(w, { route: { bereich: 'website', query: '' }, me: me(), nav: 'tab' }) + assert.match(fx.text(w), /Wird geladen …/); await lauf; await fx.ruhe(); assert.doesNotMatch(fx.text(w), /Wird geladen …/) +})) +test('Website: Zeile je Projekt mit Frame, Status, Link über die Brücke; Name bleibt Text', mit(HAUPT, async (fx) => { + const w = await zeige(fx) + assert.equal(fx.finde(w, 'iframe').getAttribute('src'), 'https://baeckerei.project.webklar.com') + assert.match(fx.text(w), /Vorschau online/); assert.match(fx.text(w), /Bäckerei Roth<\/b>/); assert.equal(fx.finde(w, 'b'), null) + const a = fx.finde(w, '[data-projekt="p1"] a[data-view="projekt"]'); assert.deepEqual([a.textContent, a.getAttribute('href'), a.getAttribute('data-project')], ['Projektdetails', '#projekt?projekt=p1', 'p1']) +})) +test('Website: unsichere Vorschau-Adresse ergibt weder Frame noch Link', mit({ ...HAUPT, 'GET /api/projects': { projects: [projekt({ previewUrl: 'javascript:alert(1)' })] } }, async (fx) => { + const w = await zeige(fx) + assert.equal(fx.finde(w, 'iframe'), null); assert.ok(fx.alle(w, 'a').every((a) => !/^javascript/i.test(a.getAttribute('href') || ''))) +})) +test('Website: Aktivität fällt aus, die Projekte bleiben', mit({ ...HAUPT, 'GET /api/activity': () => ({ status: 500, body: { error: 'Server kaputt' } }) }, async (fx) => { + const w = await zeige(fx) + assert.match(fx.text(w), /Aktivität konnte nicht geladen werden: Server kaputt/); assert.ok(fx.finde(w, '[data-projekt="p1"]')) +})) +test('Website: leer', mit({ ...HAUPT, 'GET /api/projects': { projects: [] } }, async (fx) => { assert.match(fx.text(await zeige(fx)), /Noch keine Website-Projekte zugewiesen\./) })) +test('Website: offene Rechnung steht als erste Zeile', mit({ ...HAUPT, 'GET /api/invoices': { invoices: [rechnung({ number: 'RE-2026-0042', balance: 50 })] } }, async (fx) => { + const w = await zeige(fx); assert.match(fx.finde(w, '.kn-rechnung-offen').textContent, /^Rechnung offen · RE-2026-0042/) +})) +test('Website: route.scroll scrollt genau einmal zur Aktivität', mit(HAUPT, async (fx) => { + let n = 0; const create = fx.document.createElement.bind(fx.document) + fx.document.createElement = (t) => { const el = create(t); if (t === 'section') el.scrollIntoView = () => { n += 1 }; return el } + await zeige(fx, { bereich: 'website', scroll: 'aktivitaet', query: '' }); assert.equal(n, 1) +})) +test('Etage projekt: Titel, drei Abschnitte, nur das gewählte Projekt (projekt= und id=)', mit({ + ...HAUPT, 'GET /api/projects': { projects: [projekt({ id: 'p1', projectName: 'Eins' }), projekt({ id: 'p2', projectName: 'Zwei' })] }, + 'GET /api/projects/p1/git': { changes: [] }, 'GET /api/projects/p1/files': { totalFiles: 0 }, 'GET /api/projects/p1/work': { order: null, entries: [] }, + 'GET /api/projects/p2/git': { changes: [] }, 'GET /api/projects/p2/files': { totalFiles: 0 }, 'GET /api/projects/p2/work': { order: null, entries: [] }, +}, async (fx) => { + const titel = []; const w = neueWurzel(fx); w.addEventListener('kn:titel', (e) => titel.push(e.detail.text)) + await zeigeWebsite(w, { route: { bereich: 'website', etage: 'projekt', query: 'projekt=p2' }, me: me(), nav: 'push' }); await fx.ruhe() + assert.deepEqual(titel, ['Zwei']); for (const t of ['Letzte Änderungen', 'Arbeiten am Projekt', 'Dateien']) assert.match(fx.text(w), new RegExp(t)) + assert.ok(fx.fetchLog.some((a) => a.url === '/api/projects/p2/git')); assert.ok(!fx.fetchLog.some((a) => a.url === '/api/projects/p1/git')) + await zeigeWebsite(w, { route: { bereich: 'website', etage: 'projekt', query: 'id=p1' }, me: me(), nav: 'push' }); await fx.ruhe(); assert.deepEqual(titel, ['Zwei', 'Eins']) +})) +test('Website: Installationshinweis nur im Handy-Layout und noch nicht installiert', mit(HAUPT, async (fx) => { + fx.window.matchMedia = (q) => ({ matches: q.includes('max-width: 880px') }); fx.window.navigator = { userAgent: 'iPhone', maxTouchPoints: 5 } + assert.match(fx.text(await zeige(fx)), /WEBklar als App/) +})) +``` + +- [ ] **Step 2: Lauf, muss scheitern** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knAnsichtWebsite.test.js` +Expected: FAIL (der Stub exportiert `projektStatus` nicht, `zeigeWebsite` schreibt nur „Website (folgt)“). + +- [ ] **Step 3: Implementieren** (Signaturen und Aufbau oben). Die Frame-Skalierung (Basisbreite 1280 px, `ResizeObserver` nur wenn vorhanden) wie `app.js:98-112` als kleine interne Funktion `skaliereVorschau(rahmen)`. Aktivität lädt parallel zu den Projekten, jede Quelle mit eigenem Fehlerweg. Fehlt `projekt=` und `id=`, gilt das erste Projekt. Späte Antworten über einen Zähler je Aufruf verwerfen. Fehlendes `matchMedia` zählt als „kein Handy-Layout“. + +- [ ] **Step 4: Lauf grün.** Run wie Step 2. Expected: PASS, 10 Tests. + +- [ ] **Step 5: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/ansicht-website.js test/knAnsichtWebsite.test.js && git commit -m "Kundenansicht neu P2: Ansicht Website mit Projektdetails als Etage" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 7: Ansicht Chat + +**Files:** +- Create: `public/neu/chat-composer.css` (bereinigte Kopie von `public/chat-composer.css`, Original bleibt unverändert) +- Modify: `public/neu/ansicht-chat.js` (Stub von P1 ersetzen), `public/neu/dashboard.html` (zwei Zeilen, P1 Task 12 erlaubt sie), `test/knHuelleSeite.test.js` (nur die Stil-Zeile, siehe Step 3) +- Test: `test/knAnsichtChat.test.js` + +**Interfaces:** +- Consumes: `api`, `h`, `fmtDatumZeit`, `meldeZaehlerNeu`, `nimmChatVorgabe`; `globalThis.ChatComposer` (`create(opts) → { destroy, focus }`, `renderMessage(msg, { mine, meta, fileUrl })`, `trackScroll(box)`, `public/chat-composer.js:542-1174`). `GET /api/chat/messages[?since=]` → `{ messages:[{ id, sender, senderName, text, attachments, createdAt }], unread }`, `POST /api/chat/messages` `{ text, attachmentIds }` → `{ message }`, `POST /api/chat/read` (`routes/chat.js:107-246`). +- Produces: +```js +export async function zeigeChat(wurzel, { route, me, nav }) +export const CHAT_TEXTE = { platzhalter: 'Nachricht an das WEBklar-Team…', kurz: 'Nachricht…', gesperrt: 'Im Ansichtsmodus kann nicht als Kunde geschrieben werden.', leer: 'Noch keine Nachrichten.' } +export function nachrichtMeta(msg) // 'Du · 14.10.26, 10:05' | ' · …' +``` +Composer-Optionen wie `app.js:762-778`: `root`, `dropZone`, `uploadUrl: '/api/chat/attachments'`, `placeholder`, `placeholderShort`, `disabled: me.viewAs === true`, `disabledText`, `send`, `onSent`. Poll alle 10 s mit `?since=`, Nachrichten nach `id` entdoppelt, Poll bricht ab bei `document.hidden` oder `!wurzel.isConnected`. `POST /api/chat/read` nach dem Laden und bei neuen Nachrichten, nie im Ansichtsmodus; danach `meldeZaehlerNeu('chat')`. Ein zweiter Aufruf ruft `destroy()` des alten Composers und stoppt den alten Timer. In `dashboard.html` kommen `` zwischen `/neu/huelle.css` und `/neu/ansichten.css` und `` zwischen `/kundenansicht.js` und das Modul `/neu/huelle.js`. Das alte `/chat-composer.css` wird **nicht** geladen: Es enthält `#9d3dfe` und `rgba(157, 61, 254, …)` (Zeilen 4 bis 7), die Prüfung `lila` von P0 liest den Text jedes geladenen Stylesheets, und das Gate `p2` und alle späteren (p3, p4, p7) würden in jeder Ansicht rot (P1 VF3). Die Kopie ändert gegenüber dem Original nur: neuer Kopfkommentar (Zweck der Kopie, ohne Farbwerte), der `:root`-Block mit hellen, neutralen Variablen, Gewicht 700 statt 800 in `.chat-file-ext` und kein `backdrop-filter` in `.chat-overlay` (Bildansicht und Kamera bleiben dunkel, sie erscheinen erst auf Antippen). + +- [ ] **Step 1: Test schreiben** (Attrappe `stub()` setzt `globalThis.ChatComposer`: `create` merkt `opts`, hängt ein `textarea` in `opts.root` und liefert `{ destroy, focus }`, `renderMessage` baut `div.chat-bubble[data-id]` mit `meta + ' ' + text`; `mock` kommt aus `node:test`). + +```js +const NACHRICHTEN = { messages: [nachricht({ id: 'm1', sender: 'admin', senderName: 'Kenso', text: 'Hallo', createdAt: '2026-10-14T08:00:00.000Z' }), nachricht({ id: 'm2', sender: 'customer', text: 'Danke', createdAt: '2026-10-14T08:05:00.000Z' })], unread: 1 } +test('Chat: Blasen, Composer-Optionen, genau eine Gelesen-Markierung', mit({ 'GET /api/chat/messages': NACHRICHTEN, 'POST /api/chat/read': { updated: 1 } }, async (fx) => { + const log = stub(); const events = []; fx.document.dispatchEvent = (e) => events.push(`${e.type}:${e.detail.was}`) + const w = neueWurzel(fx); await zeigeChat(w, { route: { bereich: 'chat', query: '' }, me: me(), nav: 'tab' }); await fx.ruhe() + assert.deepEqual(fx.alle(w, '.chat-bubble').map((e) => e.textContent), ['Kenso · 14.10.26, 10:00 Hallo', 'Du · 14.10.26, 10:05 Danke']) + assert.deepEqual([log.create.uploadUrl, log.create.placeholder, log.create.disabled], ['/api/chat/attachments', 'Nachricht an das WEBklar-Team…', false]) + assert.equal(fx.fetchLog.filter((a) => a.method === 'POST' && a.url === '/api/chat/read').length, 1); assert.ok(events.includes('kn:zaehler-neu:chat')) +})) +test('Chat im Ansichtsmodus: Composer gesperrt, kein schreibender Aufruf', mit({ 'GET /api/chat/messages': NACHRICHTEN }, async (fx) => { + const log = stub(); await zeigeChat(neueWurzel(fx), { route: { bereich: 'chat', query: '' }, me: me({ viewAs: true }), nav: 'tab' }); await fx.ruhe() + assert.equal(log.create.disabled, true); assert.equal(log.create.disabledText, 'Im Ansichtsmodus kann nicht als Kunde geschrieben werden.') + assert.deepEqual(fx.fetchLog.filter((a) => a.method !== 'GET'), []) +})) +test('Chat: senden, Poll mit since, Dopplungen, zweiter Aufruf räumt auf', mit({ 'GET /api/chat/messages': NACHRICHTEN, 'POST /api/chat/read': {}, 'POST /api/chat/messages': { message: { id: 'm3' } } }, async (fx) => { + mock.timers.enable({ apis: ['setInterval'] }) + try { + const log = stub(); const w = neueWurzel(fx); const args = { route: { bereich: 'chat', query: '' }, me: me(), nav: 'tab' } + await zeigeChat(w, args); await fx.ruhe() + assert.deepEqual(await log.create.send({ text: 'Hi', attachmentIds: ['a1'] }), { id: 'm3' }) + assert.deepEqual(fx.fetchLog.at(-1).body, { text: 'Hi', attachmentIds: ['a1'] }) + mock.timers.tick(10000); await fx.ruhe() + assert.equal(fx.fetchLog.at(-1).url, '/api/chat/messages?since=2026-10-14T08%3A05%3A00.000Z'); assert.equal(fx.alle(w, '.chat-bubble').length, 2) + await zeigeChat(w, args); await fx.ruhe(); assert.equal(log.destroyed, 1) + const vorher = fx.fetchLog.length; mock.timers.tick(10000); await fx.ruhe(); assert.equal(fx.fetchLog.filter((a, i) => i >= vorher && a.url.includes('since=')).length, 1) + } finally { mock.timers.reset() } +})) +test('Chat: Vorgabe aus „Postfach anfragen“ landet im Eingabefeld, leerer Verlauf sagt es', mit({ 'GET /api/chat/messages': { messages: [], unread: 0 }, 'POST /api/chat/read': {} }, async (fx) => { + stub(); setzeChatVorgabe('Bitte lege ein neues Postfach an: '); const w = neueWurzel(fx); await zeigeChat(w, { route: { bereich: 'chat', query: '' }, me: me(), nav: 'tab' }); await fx.ruhe() + assert.equal(fx.finde(w, 'textarea').value, 'Bitte lege ein neues Postfach an: '); assert.match(fx.text(w), /Noch keine Nachrichten\./) +})) +test('dashboard.html lädt den Composer klassisch und die bereinigte CSS-Kopie zwischen huelle.css und ansichten.css, nie das alte /chat-composer.css', () => { + const html = readFileSync(new URL('../public/neu/dashboard.html', import.meta.url), 'utf8') + assert.match(html, /