99 KiB
Kundenansicht neu, P3 Funktionen-Rahmen: 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 Bereich „Funktionen“ der neuen Kundenansicht zeigt die Reiter „Meine“ und „Entdecken“ aus dem Katalog des Abo-Modells, schaltet Funktionen mit einer kurzen Bestätigungsseite ein und führt gebuchte Funktionen in ihre Etage 2.
Architecture: Zwei ES-Module unter public/neu/, ohne Build. ansicht-funktionen.js (Stub aus P1) hält das Modell (Meine/Entdecken) und zeigeFunktionen(wurzel, { route, me, nav }) (V2, ohne Zähler). Die Zeile „Rechnung offen“ lädt die Ansicht selbst (GET /api/invoices über abh.api, Filter und Zeile aus rechnung-offen.js von P2); sie hängt nicht am Zähler der Hülle, denn P1 ruft jede Ansicht nur mit { route, me, nav } und liefert in stand() nur chat und vertraege. funktion-einschalten.js (neu) hält die Einschalt-Logik (Vorprüfung, Fristen, Fehlercodes) und die Bestätigungsseite. Die Seite erscheint innerhalb von Funktionen als Overlay-Zustand des P1-Routers (oeffneOverlay): Die Zurück-Geste schließt sie, es braucht keinen eigenen Hash. Die Funktionsetagen (#termine?projekt=<id>) öffnet die Hülle von P1 (huelle.js, FUNKTIONS_MODULE, setzt dataset.wunschProjekt); P3 liefert nur das Ziel. Für die Module exportiert funktion-einschalten.js den Ablauf zeigeEinschalten(platz, { key, projectId, onFertig }) (Vorschlag von P4, VF1). Alle Abhängigkeiten (api, geheZu, Overlay, Zeit, Speicher) sind über den Parameter abh einspeisbar, damit die Tests ohne Browser laufen. C2 (Kurzlabels, scrollende Reiterzeile in Etage 2) liegt bei P1 und P4.
Tech Stack: Vanilla-JS (ES-Module), h() aus public/neu/dom.js (kein innerHTML), baueReiter aus public/neu/reiter.js, node --test mit test/support/fakeDom.js, sharp (vorhanden, package.json) für die Bilder, Sichtprüfung nur über das Werkzeug aus P0.
Spec: docs/superpowers/specs/2026-10-15-kundenansicht-neu-design.md (5.3, 5.4, 5.5, C1, C2, C9, E6, E13, E14, E17).
Ü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 (rebased auf den Hauptzweig nach dem Server-Merge). Start nach P2 und Server-Merge. Tag am Ende: kn-p3.
Abgleich mit den anderen Plänen (Stand beim Schreiben): P0 (01), P1 (02), P2 (03), P4 (05), P5 (06) liegen vor und sind eingearbeitet (Katalogfeld consent, baueReiter, Overlay, Werkzeug-Form, rechnung-offen.js). Offen bleibt VF1 bis VF5 am Ende.
Global Constraints
Alle Global Constraints der Übersicht gelten (Aussehen, „du“, keine Preise für Funktionen, Hashes, Kundenart, Tests, Commits, Sicherheitsblock, Prod-Daten). Zusätze für P3:
- Keine Server-Logik, keine Änderung an fremden Dateien.
dashboard.html,huelle.js,router.jsgehören P1. P3 ändert nur die Stubsansicht-funktionen.jsundfunktionen.cssund legtfunktion-einschalten.jsund die Bilder an. - Fremdtext (Katalog, Websitenamen, Servermeldungen) nur über
h()/textContent; in den zwei Modulen kommeninnerHTML,insertAdjacentHTML,evalnicht vor. - Importe: nur relative, statische (
./dom.js,./api.js,./router.js,./reiter.js,./rechnung-offen.js); P1 und P2 halten sie unter Node importierbar. Kein Zugriff aufdocument,window,sessionStoragebeim Import. - Kundenart: Alles außer
'unternehmen'gilt alsprivat(E4). Der Client sendet niekundenart. - Texte stehen als Festwerte in
TEXTE(Task 1) und in den Tests wörtlich. Kein Preis, kein€, keine Sie-Anrede. - Hash: nur die Query
projekt=<projectId>an Etagen (so liest sie P1 schon), keine neuen Hash-Namen. - CSS: Klassen
kn-fn-*, Farben alsvar(--kn-…, Fallback)mit den Werten der Übersicht als Fallback, Radien nur 6/12 px (Pillen 999 px), keine Schatten außer dem Ring, kein Gewicht 800. - Bilder:
public/neu/bilder/<key>.webp, 960×600, höchstens 120 KB (122880 Byte), aus Testdaten, keine Fremdbilder. - Befehle: Einzeldatei
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/<datei>.test.js, mehrere Dateien nur mit--test-concurrency=1. Werkzeug:WZ=/root/reports/kundenansicht-neu-2026-10-15/werkzeug(Ausgabe in einen eigenen Scratch-Ordner,TMPDIRdorthin), davorfree -m(über 2000 MB verfügbar). Die Kurzform$WTin Step 0 von Task 1 gilt nur innerhalb dieses Blocks.
Review Focus
Die fünf Fehlerfälle, die die Spec nicht ausspricht und die im Betrieb am ehesten auftreten:
- Doppeltes oder widersprüchliches Einschalten: zwei Taps, schon eingeschaltet, Netzfehler mitten im Senden, Fassung veraltet. Erwartung: höchstens ein POST je Klick-Folge, klare Meldung, nach Netzfehler erneut möglich. Tests: Task 4 („zweiter Tap“, „Netzfehler“, „weitere 409-Codes“, „fassung_veraltet“).
- Privatkunde an der Fristgrenze: Fristende am letzten Tag 23:59 Berliner Zeit, Sommer- und Winterzeit, leere Kundenart, Betrieb mit Frist. Erwartung: Häkchen nur für privat (leer zählt als privat) bis zum Fristende, danach keines; ohne Häkchen kein POST. Tests: Task 1 („tagNachFrist“, „beginnLage“), Task 3 („Privatkunde in der Frist“, „Häkchen nie für Betrieb“), Task 4 („beginn_noetig“).
- Abo-Lagen: kein Abo, Hosting bei Wartungs-Funktion, gekündigt, noch nicht aktiv,
sessionStoragenicht beschreibbar. Erwartung: jeweils der richtige Ort oder Text; der Wechsel geht auch ohne Speicher. Tests: Task 1 („einschaltWeg“, „fehlerAktion“), Task 4 („navigiere“), Task 5 („Einschalten-Tap“), Task 6 („wechselFunktion“). - Mehrere Websites: Zeile je Funktion und Website, Entdecken solange eine Website fehlt, Website-Auswahl, die richtige Website beim Öffnen. Erwartung: nie die falsche Website. Tests: Task 2 („mehrere Websites“, „Kandidaten“), Task 5 („Meine“, „Zwei Websites“).
- Fremdtext und unvollständige Antworten: HTML im Funktionsnamen, fehlendes Register
consent, fehlendes Bild, keine Websites,verfuegbar: false, Analyseunknown, langsame ältere Antwort, ausgefallener Rechnungsabruf. Erwartung: nichts bricht, nichts wird als HTML gelesen, ein fehlendes Register sperrt das Absenden, ohne Rechnungsantwort fehlt nur die Zeile „Rechnung offen“. Tests: Task 2 („Analyse“, „C9“), Task 3 („Fremdtext“, „Register fehlt“), Task 5 („veraltete Antwort“, „Fremdtext und fehlendes Bild“, „keine Websites“, „Rechnungszeile oben“).
Dateiübersicht
| Datei | Aufgabe | Task |
|---|---|---|
public/neu/funktion-einschalten.js (neu) |
Einschalt-Logik rein, Bestätigungsseite, Senden, Navigation, zeigeEinschalten |
1, 3, 4 |
public/neu/ansicht-funktionen.js (Stub aus P1, ändern) |
Modell Meine/Entdecken, Ansicht, Bestätigung als Overlay, Wechsel-Rückkehr | 2, 5, 6 |
public/neu/funktionen.css (Stub aus P1, ändern) |
Aussehen der beiden Module | 7 |
public/neu/bilder/{news,termine,shop,unterkunft,zeiten}.webp (neu; analyse.webp kommt mit P4, VF2) |
Bilder für „Entdecken“ | 8 |
test/support/knFunktionenDaten.js (neu) |
Testdaten nach V5 | 1 |
test/knEinschaltenLogik.test.js, knFunktionenModell.test.js, knEinschaltenSeite.test.js, knFunktionenAnsicht.test.js, knFunktionenStatik.test.js (neu) |
Tests | 1, 2, 3+4, 5+6, 7+8 |
Werkzeug (eigenes Git): fixtures/endpunkte-p3.mjs (+ ein Import in fixtures/endpunkte.mjs, Einträge in vertrag-endpunkte.mjs), views.mjs, gates.mjs, bilder/erzeuge.mjs, bilder/seiten/*.html |
Bildquellen, Ansichten, Gate p3 |
8, 9 |
Pins: Keine bestehende Testdatei und kein Pin wird geändert. Belege: test/adminPanelGone.test.js:32-65 liest public/ rekursiv, sperrt aber nur admin.html, kalkulation.html, /api/portal-admin (kommen in P3 nicht vor). Die Quelltext-Pins lesen public/ flach und filtern auf alte Namen (termineView.test.js:324,397,418, shopView.test.js:560, shopWiring.test.js:49, zeitenView.test.js:391, zeitenBestellen.test.js:845, termineFragebogenView.test.js:1023); public/neu/ fällt nicht hinein. P1s knHuelleSeite.test.js prüft von den Stubs nur die Exportnamen. Task 10 lässt die Nachbarn als Gegenprobe laufen.
Task 1: Einschalt-Logik (rein) und Testdaten
Files:
- Create:
public/neu/funktion-einschalten.js(in diesem Task nur reine Exporte) - Create:
test/support/knFunktionenDaten.js - Test:
test/knEinschaltenLogik.test.js
Interfaces:
- Consumes: Antwortform von
GET /api/funktionen(V5, gebaut in P5 Task 11):modell,kundenart,abo,katalog[]mitconsent,projects[]mitfeatures.<shop|unterkunft>.anfrageStatus. - Produces (alle in
funktion-einschalten.js):
export const ETAGE_FUER // { news:'news', termine:'termine', shop:'webshop', unterkunft:'buchungen', zeiten:'zeiten', analyse:'analyse' }
export const TEXTE // Festtexte, siehe Step 3
export function consentVon(daten, key) // → katalog[key].consent | null (einzige Stelle für das Register)
export function anfrageStatusVon(projekt, key) // → projekt.features[key].anfrageStatus | '' (einzige Stelle)
export function einschaltWeg({ eintrag, projekt, abo, kundenart })
// → { art:'seite' } | { art:'etage', etage } | { art:'bestellen', produkt } | { art:'wechsel' } | { art:'meldung', text }
export function tagNachFrist(wert) // Zeitpunkt → Berliner Folgetag 'JJJJ-MM-TT' | ''
export function datumKurz(tag) // 'JJJJ-MM-TT' → 'TT.MM.' | ''
export function beginnLage({ kundenart, abo, jetzt, serverAb = '' }) // → { noetig, ab }
export function einschaltKoerper({ projectId, consent, beginnGesetzt }) // → { projectId, zustimmung: { version, haken } }
export function linkzeile(text, unterlagen) // → [{ text, href? }]
export function sichereAdresse(url) // → url | ''
export function fehlerAktion(fehler, { eintrag }) // fehler = { status, code?, message, body }
// → { art:'wechsel' } | { art:'bestellen', produkt } | { art:'etage', etage } | { art:'beginn', ab } | { art:'neu-laden', text } | { art:'schon' } | { art:'meldung', text }
Testdaten-Baustein test/support/knFunktionenDaten.js (Körper schreibt der Implementierer):
export function katalogEintrag(key, edit = {}) // Standardwerte je key, edit überschreibt Felder
export function feature(state = 'off', extra = {}) // { state, siteKey:null, ticketWoid:null, activatedAt:null, disabledReason:null, loeschungAm:null, ...extra }
export function projekt(projectId, projectName, features = {}, analyse = { state: 'off' }) // { projectId, projectName, liveUrl:'', features:{ news, termine, shop, unterkunft, zeiten: feature('off'), ...features }, analyse }
export function daten({ abo = {}, kundenart = 'unternehmen', projekte, katalogEdit = {} } = {})
// → { modell:'abo', kundenart, abo:{ produkt:'wartung', laeuft:true, gekuendigt:false, widerrufsfristBis:null, ...abo }, katalog:[6 Einträge, katalogEdit[key] darübergelegt], projects: projekte ?? [projekt('p1','Bäckerei Sonnenschein')] }
Standardwerte katalogEintrag (Reihenfolge news, termine, shop, unterkunft, zeiten, analyse): Namen News-Pinnwand, Terminplaner, Webshop, Buchungskalender, Öffnungszeiten live, Analyse; enthaltenIn: 'wartung', nur analyse 'hosting'; ablauf: 'fragebogen' (termine), 'anfrage' (shop, unterkunft), sonst 'direkt'; nurBetrieb: true nur shop; einrichtung: 'meist in 1–2 Wochen' (termine, unterkunft), 'meist in 2–3 Wochen' (shop), sonst 'meist in wenigen Tagen'; verfuegbar: true; beschreibung: 'Beschreibung <Name>.'; bild: '/neu/bilder/<key>.webp'; unterlagen: { leistungsbeschreibung: { version:'2026-10-05', url:'/rechtliches/leistungsbeschreibung-<key>-2026-10-05.html' }, avv: { version:'2026-10-05', url:'/rechtliches/avv-<key>-2026-10-05.html' } }; 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 }] }.
- Step 0: Voraussetzungen prüfen (nur lesen)
Run:
WT=/home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui
git -C $WT log --oneline -3 && git -C $WT tag --list 'kn-p*' && command grep -a -n "^export" $WT/public/neu/dom.js $WT/public/neu/api.js $WT/public/neu/router.js $WT/public/neu/reiter.js $WT/public/neu/rechnung-offen.js
command grep -a -n "katalog" $WT/server/routes/funktionen.js | head -5
Expected: kn/ui trägt kn-p1, kn-p2 und den Server-Merge; die Exporte h, leeren, api, geheZu, oeffneOverlay, beendeOverlay, baueReiter, offeneRechnungen, rechnungOffenZeile sind da; routes/funktionen.js kennt katalog. Weicht ein Name oder die Antwortform ab, ändert sich nur die eine Stelle (consentVon, anfrageStatusVon, die Importzeile); Befund dem Controller melden. Fehlt etwas Größeres, stoppen.
- Step 1: Failing Tests schreiben in
test/knEinschaltenLogik.test.js
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { ETAGE_FUER, consentVon, anfrageStatusVon, einschaltWeg, tagNachFrist, datumKurz, beginnLage, einschaltKoerper, linkzeile, sichereAdresse, fehlerAktion } from '../public/neu/funktion-einschalten.js'
import { daten, katalogEintrag, projekt, feature } from './support/knFunktionenDaten.js'
const abo = (produkt, extra = {}) => ({ produkt, laeuft: produkt !== null, gekuendigt: false, widerrufsfristBis: null, ...extra })
const angenommen = (key) => projekt('p1', 'B', { [key]: feature('off', { anfrageStatus: 'angenommen' }) })
const GEKUENDIGT = 'Dein Abo ist gekündigt. Solange die Kündigung läuft, kannst du nichts Neues einschalten.'
const NICHT_AKTIV = 'Dein Abo ist noch nicht aktiv. Sobald die erste Zahlung da ist, kannst du einschalten.'
const NUR_BETRIEB = 'Webshop gibt es für Betriebe. Stimmt das nicht? Schreib uns im Chat.'
test('Etagen-Tabelle und Zugriffsfunktionen', () => {
assert.deepEqual(ETAGE_FUER, { news: 'news', termine: 'termine', shop: 'webshop', unterkunft: 'buchungen', zeiten: 'zeiten', analyse: 'analyse' })
assert.equal(consentVon(daten(), 'news').version, '2026-10-20')
assert.equal(consentVon(daten({ katalogEdit: { analyse: { consent: null } } }), 'analyse'), null)
assert.equal(consentVon(daten(), 'gibtsnicht'), null)
assert.equal(anfrageStatusVon(angenommen('shop'), 'shop'), 'angenommen')
assert.equal(anfrageStatusVon(projekt('p1', 'B'), 'shop'), '')
})
test('einschaltWeg: Vorprüfung in der Reihenfolge des Servers (V5)', () => {
const faelle = [ // [erwartet, key, abo, kundenart, projekt, Katalogänderung]
[{ art: 'meldung', text: 'Diese Funktion ist für dich gerade nicht verfügbar.' }, 'news', abo('wartung'), 'unternehmen', undefined, { verfuegbar: false }],
[{ art: 'meldung', text: GEKUENDIGT }, 'news', abo('wartung', { gekuendigt: true })],
[{ art: 'bestellen', produkt: 'wartung' }, 'news', abo(null)],
[{ art: 'bestellen', produkt: 'hosting' }, 'analyse', abo(null)],
[{ art: 'meldung', text: NICHT_AKTIV }, 'news', abo('wartung', { laeuft: false })],
[{ art: 'wechsel' }, 'news', abo('hosting')],
[{ art: 'etage', etage: 'analyse' }, 'analyse', abo('hosting')],
[{ art: 'meldung', text: NUR_BETRIEB }, 'shop', abo('wartung'), 'privat'],
[{ art: 'meldung', text: NUR_BETRIEB }, 'shop', abo('wartung'), ''], // leere Kundenart zählt als privat (E4)
[{ art: 'seite' }, 'news', abo('wartung')],
[{ art: 'seite' }, 'zeiten', abo('wartung')],
[{ art: 'etage', etage: 'termine' }, 'termine', abo('wartung')],
[{ art: 'etage', etage: 'webshop' }, 'shop', abo('wartung')],
[{ art: 'etage', etage: 'buchungen' }, 'unterkunft', abo('wartung')],
[{ art: 'seite' }, 'shop', abo('wartung'), 'unternehmen', angenommen('shop')],
[{ art: 'seite' }, 'unterkunft', abo('wartung'), 'unternehmen', angenommen('unterkunft')],
]
for (const [erwartet, key, a, kundenart = 'unternehmen', p = projekt('p1', 'B'), edit = {}] of faelle) {
assert.deepEqual(einschaltWeg({ eintrag: katalogEintrag(key, edit), projekt: p, abo: a, kundenart }), erwartet, `${key} ${JSON.stringify(a)} ${kundenart}`)
}
})
test('tagNachFrist und datumKurz: Berliner Folgetag, Sommer- und Winterzeit', () => {
assert.equal(tagNachFrist('2026-10-30T22:59:59.999Z'), '2026-10-31') // 23:59:59 MEZ
assert.equal(tagNachFrist('2026-08-14T21:59:59.999Z'), '2026-08-15') // 23:59:59 MESZ
assert.equal(tagNachFrist('2026-12-31T22:59:59.999Z'), '2027-01-01')
assert.deepEqual([tagNachFrist(null), tagNachFrist('kein Datum'), datumKurz('2026-10-31'), datumKurz('')], ['', '', '31.10.', ''])
})
test('beginnLage: Häkchen nur für privat bis zum Fristende; die Antwort des Servers gewinnt', () => {
const jetzt = new Date('2026-10-20T10:00:00Z')
const frist = '2026-10-30T22:59:59.999Z'
const lage = (kundenart, widerrufsfristBis, extra = {}) => beginnLage({ kundenart, abo: { widerrufsfristBis }, jetzt, ...extra })
assert.deepEqual(lage('privat', frist), { noetig: true, ab: '2026-10-31' })
assert.deepEqual(lage('', frist), { noetig: true, ab: '2026-10-31' })
assert.deepEqual(lage('unternehmen', frist), { noetig: false, ab: '' })
assert.deepEqual(lage('privat', null), { noetig: false, ab: '' })
assert.equal(lage('privat', frist, { jetzt: new Date(frist) }).noetig, true) // genau am Ende noch Frist
assert.equal(lage('privat', frist, { jetzt: new Date('2026-10-30T23:00:00.000Z') }).noetig, false)
assert.deepEqual(lage('unternehmen', null, { serverAb: '2026-11-02T23:00:00.000Z' }), { noetig: true, ab: '2026-11-03' }) // Form von P5: Berliner Mitternacht
assert.equal(lage('privat', null, { serverAb: '2026-11-03' }).ab, '2026-11-03')
})
test('einschaltKoerper: Fassung aus dem Register, Haken nur "beginn", nie die Kundenart', () => {
const c = consentVon(daten(), 'news')
assert.deepEqual(einschaltKoerper({ projectId: 'p1', consent: c, beginnGesetzt: false }), { projectId: 'p1', zustimmung: { version: '2026-10-20', haken: [] } })
const mit = einschaltKoerper({ projectId: 'p1', consent: c, beginnGesetzt: true })
assert.deepEqual(mit.zustimmung.haken, ['beginn'])
assert.equal('kundenart' in mit, false)
})
test('sichereAdresse: nur https und Pfade unter /rechtliches/ oder /vertragsunterlagen/', () => {
for (const gut of ['/rechtliches/avv-2026-10-05.html', '/vertragsunterlagen/funktionen-leistungsbeschreibung.html', 'https://example.org/a.pdf']) assert.equal(sichereAdresse(gut), gut)
for (const schlecht of ['javascript:alert(1)', 'http://example.org/', '//example.org/x', '/rechtliches/../geheim', '/rechtliches/%2e%2e/x', 'https://user:pw@example.org/', '/api/abo', '/rechtliches/a b', '', null, 42]) assert.equal(sichereAdresse(schlecht), '', String(schlecht))
})
test('linkzeile: die zwei Wörter werden Links; unsichere oder fehlende Adressen nicht', () => {
const text = 'Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung 2026-10-05).'
const u = katalogEintrag('termine').unterlagen
assert.deepEqual(linkzeile(text, u), [
{ text: 'Es gelten ' }, { text: 'Leistungsbeschreibung', href: u.leistungsbeschreibung.url }, { text: ' und ' },
{ text: 'Auftragsverarbeitung', href: u.avv.url }, { text: ' (Fassung 2026-10-05).' },
])
assert.deepEqual(linkzeile('Es gilt die Leistungsbeschreibung (Fassung 2026-10-04).', { leistungsbeschreibung: u.leistungsbeschreibung, avv: null }), [
{ text: 'Es gilt die ' }, { text: 'Leistungsbeschreibung', href: u.leistungsbeschreibung.url }, { text: ' (Fassung 2026-10-04).' },
])
assert.deepEqual(linkzeile(text, { leistungsbeschreibung: { url: 'javascript:alert(1)' }, avv: null }), [{ text }])
assert.deepEqual(linkzeile('Es gelten Leistungsbeschreibung (Fassung 2026-10-05).', u), [
{ text: 'Es gelten ' }, { text: 'Leistungsbeschreibung', href: u.leistungsbeschreibung.url }, { text: ' (Fassung 2026-10-05). ' }, { text: 'Auftragsverarbeitung', href: u.avv.url },
])
})
test('fehlerAktion: alle 409-Codes aus V5 und die Randfälle', () => {
const f = (status, code, body = {}, message = 'Servertext') => ({ status, code, message, body: { code, ...body } })
const AB = '2026-11-02T23:00:00.000Z'
const faelle = [ // [Fehler, Funktion, erwartete Aktion]
[f(409, 'nicht_verfuegbar'), 'termine', { art: 'meldung', text: 'Diese Funktion ist für dich gerade nicht verfügbar.' }],
[f(409, 'abo_fehlt', { grund: 'falsches_abo', wechsel: true }), 'termine', { art: 'wechsel' }],
[f(409, 'abo_fehlt', { grund: 'kein_abo' }), 'termine', { art: 'bestellen', produkt: 'wartung' }],
[f(409, 'abo_fehlt', { grund: 'kein_abo' }), 'analyse', { art: 'bestellen', produkt: 'hosting' }],
[f(409, 'abo_fehlt', { grund: 'gekuendigt' }), 'termine', { art: 'meldung', text: GEKUENDIGT }],
[f(409, 'abo_fehlt', { grund: 'nicht_aktiv' }), 'termine', { art: 'meldung', text: NICHT_AKTIV }],
[f(409, 'abo_fehlt', { grund: 'falsches_abo' }), 'termine', { art: 'meldung', text: 'Dein Abo enthält diese Funktion nicht.' }],
[f(409, 'nur_betrieb'), 'shop', { art: 'meldung', text: NUR_BETRIEB }],
[f(409, 'bestellung_offen', { offen: [{ schritt: 'zeiten', text: 'x' }] }), 'termine', { art: 'etage', etage: 'termine' }],
[f(409, 'bestellung_offen', { offen: ['Anfrage'] }), 'shop', { art: 'etage', etage: 'webshop' }],
[f(409, 'beginn_noetig', { ab: AB }), 'termine', { art: 'beginn', ab: AB }],
[f(409, 'bereits_eingeschaltet'), 'termine', { art: 'schon' }],
[f(409, 'fassung_veraltet'), 'termine', { art: 'neu-laden', text: 'Die Bedingungen wurden aktualisiert. Bitte lies die Zeile noch einmal.' }],
[f(409, 'bestellung_laeuft'), 'termine', { art: 'meldung', text: 'Servertext' }], // weitere Codes aus P5: Text des Servers
[f(409, 'abgeschaltet'), 'termine', { art: 'meldung', text: 'Servertext' }],
[f(503, 'unterlagen_fehlen'), 'termine', { art: 'meldung', text: 'Servertext' }],
[f(403, 'view_blocked'), 'termine', { art: 'meldung', text: 'Servertext' }],
[{ status: 0, message: 'Keine Verbindung. Bitte versuch es gleich noch einmal.', body: {} }, 'termine', { art: 'meldung', text: 'Keine Verbindung. Bitte versuch es gleich noch einmal.' }],
[{ status: 500, message: '', body: {} }, 'termine', { art: 'meldung', text: 'Das Einschalten hat gerade nicht geklappt. Versuch es gleich noch einmal oder schreib uns im Chat.' }],
]
for (const [fehler, key, erwartet] of faelle) assert.deepEqual(fehlerAktion(fehler, { eintrag: katalogEintrag(key) }), erwartet, `${fehler.status} ${fehler.code}`)
})
-
Step 2: Fehlschlag prüfen. Run:
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knEinschaltenLogik.test.js. Expected: FAIL (ERR_MODULE_NOT_FOUNDfürfunktion-einschalten.jsbzw.knFunktionenDaten.js). -
Step 3:
public/neu/funktion-einschalten.jsund den Testdaten-Baustein anlegen (Signaturen oben)
TEXTE (Texte wörtlich wie in den Tests): stundenkonto: 'Die Einrichtung geht von deinem Stundenkonto ab, auch wenn das Guthaben noch nicht reicht. Extrakosten entstehen keine.', nichtVerfuegbar, gekuendigt, nichtAktiv, falschesAbo, fassungVeraltet, allgemein, nurBetrieb: (name) => \${name} gibt es für Betriebe. Stimmt das nicht? Schreib uns im Chat.`, registerFehlt: 'Einschalten ist gerade nicht möglich. Schreib uns im Chat.', kundenansicht: 'In der Kundenansicht kann nichts eingeschaltet werden.'`.
-
einschaltWegprüft in der Reihenfolge der Tabelle im Test: verfügbar, gekündigt, kein Abo (bestellenmiteintrag.enthaltenIn), nicht aktiv, Hosting bei Wartungs-Funktion (wechsel),nurBetriebbeikundenart !== 'unternehmen'; danach Analyse undablauf: 'fragebogen'→ Etage,ablauf: 'anfrage'ohneangenommen→ Etage, sonstseite. Die Analyse hat einen eigenen Endpunkt und geht nie überPOST …/einschalten. Die Vorprüfung spiegeltaboDeckt(V5); bei Abweichung entscheidet der Server per 409. -
berlinTag(wert)(nicht exportiert): Zeitpunkt oderJJJJ-MM-TT→ Berliner Kalendertag übertoLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' }), unlesbar →'';tagNachFrist=berlinTagplus ein Tag überDate.UTC.beginnLage:noetig, wenn privat undDate.parse(abo.widerrufsfristBis) >= jetzt.getTime()(ab = tagNachFrist), oder wennserverAbnicht leer ist (ab = berlinTag(serverAb)). -
linkzeile: erstes Vorkommen des Wortes → Link, wenn die Unterlage existiert undsichereAdressenicht leer ist; fehlt das Wort bei einer sicheren Unterlage, hängt ihr Link mit einem Leerzeichen hinten an (zwei Links mit·dazwischen); benachbarte Text-Segmente werden zusammengefasst.sichereAdresse:https://mit Rechnername ohne Zugangsdaten, oder Pfad unter/rechtliches/bzw./vertragsunterlagen/ohne..,%2e, Leerraum, Steuerzeichen, Rückwärtsschrägstrich. -
fehlerAktion:abo_fehltmitbody.wechsel === true→ Wechsel, sonst nachbody.grund;bestellung_offen→ EtageETAGE_FUER[eintrag.key];beginn_noetig→abunverändert ausbody.ab; alles ohne eigene Zeile im Test (auchstatus 0) →{ art:'meldung', text: fehler.message || TEXTE.allgemein }. -
Step 4: Test laufen lassen. Run:
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knEinschaltenLogik.test.js. Expected: PASS. -
Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/funktion-einschalten.js test/support/knFunktionenDaten.js test/knEinschaltenLogik.test.js && git commit -m "Funktionen neu: Einschalt-Logik (Vorprüfung, Fristen, 409-Codes) und Testdaten" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 2: Modell für „Meine“ und „Entdecken“ (rein)
Files:
- Modify:
public/neu/ansicht-funktionen.js(Stub aus P1; hier nur reine Exporte ergänzen, der Stub-ExportzeigeFunktionenbleibt bis Task 5) - Test:
test/knFunktionenModell.test.js
Interfaces:
- Consumes:
anfrageStatusVon,ETAGE_FUERaus./funktion-einschalten.js(Task 1), Testdaten (Task 1). - Produces:
export function meineZeilen(daten)
// → [{ key, name, projectId, projektName, zustand, anfrageStatus, pille:{ text, ton }, status, zeile2 }]
// Reihenfolge: Katalog, dann Website. ton: 'gruen'|'blau'|'orange'|'violett'|'rot'|'grau'. zeile2 = Websitename bei mehreren Websites, sonst status.
export function entdeckenEintraege(daten) // → [{ eintrag, kandidaten: [{ projectId, projektName }] }] in Katalogreihenfolge
export function zeilenWeg(zeile) // → { art:'seite' } | { art:'etage', etage } (Tipp auf eine Zeile in Meine)
export function datumDe(wert) // Zeitpunkt oder 'JJJJ-MM-TT' → 'TT.MM.JJJJ' (Europe/Berlin) | ''
Regeln: Katalogeinträge mit verfuegbar !== true fehlen überall (C9). Eine Funktion steht für eine Website in Meine, wenn state !== 'off', oder bei ablauf === 'fragebogen' ein Entwurf vorliegt (state === 'off' und siteKey, wie funktionen.js:148), oder bei ablauf === 'anfrage' der anfrageStatus offen, angenommen oder abgelehnt ist (zurueckgezogen zählt nicht). Analyse: Zeile nur bei analyse.state active oder disabled. Entdecken zeigt einen Eintrag, solange eine Website die Funktion nicht hat oder es keine Website gibt; kandidaten sind die Websites ohne die Funktion, bei der Analyse nur mit analyse.state === 'off'.
- Step 1: Failing Tests schreiben in
test/knFunktionenModell.test.js
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { meineZeilen, entdeckenEintraege, zeilenWeg } from '../public/neu/ansicht-funktionen.js'
import { daten, projekt, feature } from './support/knFunktionenDaten.js'
test('Meine: alles außer off, dazu Entwurf und Anfrage; zurückgezogen zählt nicht', () => {
const d = daten({ projekte: [projekt('p1', 'Bäckerei', {
news: feature('active', { activatedAt: '2026-10-05T08:00:00.000Z' }),
termine: feature('off', { siteKey: 'abcdefghijklmnop' }),
shop: feature('off', { anfrageStatus: 'offen' }),
unterkunft: feature('off', { anfrageStatus: 'zurueckgezogen' }),
zeiten: feature('disabled', { disabledReason: 'abo_ende', loeschungAm: '2026-11-20' }),
})] })
const z = meineZeilen(d)
assert.deepEqual(z.map((r) => [r.key, r.pille.text, r.pille.ton]), [['news', 'Aktiv', 'gruen'], ['termine', 'Entwurf', 'grau'], ['shop', 'Angefragt', 'violett'], ['zeiten', 'Abgeschaltet', 'grau']])
assert.deepEqual(z.map((r) => r.status), ['Aktiv seit 05.10.2026', 'Fragebogen fortsetzen', 'Wir prüfen deine Anfrage', 'Dein Abo ist beendet. Deine Daten bleiben bis 20.11.2026 gespeichert.'])
assert.equal(z[0].zeile2, z[0].status)
})
test('Status je Zustand: Pille, Ton, Zeile', () => {
const zeile = (key, f) => meineZeilen(daten({ projekte: [projekt('p1', 'B', { [key]: f })] })).find((r) => r.key === key)
const faelle = [
['news', feature('setup', { ticketWoid: '100201' }), 'In Einrichtung', 'blau', 'Einrichtung läuft · Ticket #100201'],
['news', feature('setup'), 'In Einrichtung', 'blau', 'Einrichtung läuft'],
['news', feature('ordered'), 'Zahlung offen', 'orange', 'Wir warten auf deine Zahlung'],
['news', feature('active'), 'Aktiv', 'gruen', 'Läuft auf deiner Website'],
['news', feature('disabled'), 'Abgeschaltet', 'grau', 'Schreib uns im Chat, wenn du die Funktion wieder möchtest.'],
['news', feature('disabled', { disabledReason: 'abo_ende' }), 'Abgeschaltet', 'grau', 'Dein Abo ist beendet.'],
['shop', feature('off', { anfrageStatus: 'angenommen' }), 'Angenommen', 'blau', 'Jetzt einschalten'],
['shop', feature('off', { anfrageStatus: 'abgelehnt' }), 'Abgelehnt', 'rot', 'Schreib uns im Chat'],
]
for (const [key, f, pille, ton, status] of faelle) {
const r = zeile(key, f)
assert.deepEqual([r.pille.text, r.pille.ton, r.status], [pille, ton, status], `${key} ${f.state}`)
}
})
test('mehrere Websites: eine Zeile je Funktion und Website, zweite Zeile ist der Websitename', () => {
const d = daten({ projekte: [projekt('p1', 'Bäckerei', { termine: feature('active') }), projekt('p2', 'Café', { termine: feature('setup'), news: feature('active') })] })
assert.deepEqual(meineZeilen(d).map((r) => [r.key, r.projectId, r.zeile2]), [['news', 'p2', 'Café'], ['termine', 'p1', 'Bäckerei'], ['termine', 'p2', 'Café']])
})
test('Entdecken: Kandidaten sind die Websites ohne die Funktion; eine einzelne Website mit aktiver Funktion blendet sie aus', () => {
const d = daten({ projekte: [projekt('p1', 'Bäckerei', { news: feature('active') }), projekt('p2', 'Café')] })
assert.deepEqual(entdeckenEintraege(d).find((e) => e.eintrag.key === 'news').kandidaten, [{ projectId: 'p2', projektName: 'Café' }])
assert.equal(entdeckenEintraege(daten({ projekte: [projekt('p1', 'B', { news: feature('active') })] })).some((e) => e.eintrag.key === 'news'), false)
})
test('C9: verfuegbar:false fehlt in Meine und Entdecken; ohne Websites zeigt Entdecken alles ohne Kandidaten', () => {
const d = daten({ katalogEdit: { shop: { verfuegbar: false } }, projekte: [projekt('p1', 'B', { shop: feature('active') }, { state: 'active' })] })
assert.deepEqual(meineZeilen(d).map((r) => r.key), ['analyse'])
assert.deepEqual(entdeckenEintraege(d).map((e) => e.eintrag.key), ['news', 'termine', 'unterkunft', 'zeiten'])
const leer = daten({ projekte: [] })
assert.deepEqual(meineZeilen(leer), [])
assert.deepEqual(entdeckenEintraege(leer).map((e) => e.kandidaten.length), [0, 0, 0, 0, 0, 0])
})
test('Analyse: Zeile nur bei active/disabled; unknown und comingSoon sind kein Kandidat', () => {
const zeilen = (analyse) => meineZeilen(daten({ projekte: [projekt('p1', 'B', {}, analyse)] })).filter((r) => r.key === 'analyse').map((r) => r.pille.text)
assert.deepEqual([zeilen({ state: 'active' }), zeilen({ state: 'disabled' }), zeilen({ state: 'off' }), zeilen({ state: 'unknown' }), zeilen({ comingSoon: true })], [['Aktiv'], ['Gesperrt'], [], [], []])
const kand = (analyse) => entdeckenEintraege(daten({ projekte: [projekt('p1', 'B', {}, analyse)] })).find((e) => e.eintrag.key === 'analyse')?.kandidaten.length ?? -1
assert.deepEqual([kand({ state: 'off' }), kand({ state: 'unknown' }), kand({ comingSoon: true }), kand({ state: 'active' })], [1, 0, 0, -1])
})
test('zeilenWeg: angenommene Anfrage geht zur Bestätigung, alles andere in die Etage', () => {
const z = meineZeilen(daten({ projekte: [projekt('p1', 'B', { shop: feature('off', { anfrageStatus: 'angenommen' }), termine: feature('active') })] }))
assert.deepEqual(z.map(zeilenWeg), [{ art: 'etage', etage: 'termine' }, { art: 'seite' }])
})
-
Step 2: Fehlschlag prüfen. Run:
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knFunktionenModell.test.js. Expected: FAIL („does not provide an export named ‚meineZeilen‘“). -
Step 3: Reine Funktionen in
public/neu/ansicht-funktionen.jsergänzen (Signaturen und Regeln oben)
Festtexte wörtlich wie in den Tests. Aktiv seit <datumDe> nur mit activatedAt, Einrichtung läuft · Ticket #<woid> nur mit ticketWoid. Analyse active: Pille „Aktiv“, Zeile „Läuft auf deiner Website“; disabled: Pille „Gesperrt“ (grau), Zeile „Schreib uns im Chat“. Bei disabled mit disabledReason === 'abo_ende' lautet die Zeile Dein Abo ist beendet. plus, wenn loeschungAm da ist, Deine Daten bleiben bis TT.MM.JJJJ gespeichert.. datumDe nutzt toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' }); ein JJJJ-MM-TT-Wert wird als Tag um 12:00 UTC gelesen.
-
Step 4: Test laufen lassen. Run:
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knFunktionenModell.test.js. Expected: PASS. -
Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/ansicht-funktionen.js test/knFunktionenModell.test.js && git commit -m "Funktionen neu: Modell für Meine und Entdecken (mehrere Websites, Anfragestatus)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 3: Bestätigungsseite zeichnen
Files:
- Modify:
public/neu/funktion-einschalten.js - Test:
test/knEinschaltenSeite.test.js(Teil 1)
Interfaces:
- Consumes: Task 1 (
consentVon,beginnLage,linkzeile,TEXTE,datumKurz);h,leerenaus./dom.js(V2). - Produces (das Senden folgt in Task 4):
export function renderEinschalten(behaelter, { key, projectId, daten, kundenartWirksam = daten?.kundenart, viewAs = false, imModul = false, jetzt = () => new Date(), api, geheZu, speicher, bei = {} })
// daten = Antwort von GET /api/funktionen (modell 'abo'); api(pfad, { method, body }) wirft Fehler mit status/code/message/body
// bei = { fertig(antwort|null), weiter(weg), abbrechen(), neuLaden() → Promise<daten> }, alles optional (Standards in Task 4)
// imModul: true, wenn ein Funktionsmodul die Seite einbettet (kein „Abbrechen“; bestellung_offen zeigt den Servertext)
// → HTMLElement (die Seite, in behaelter gezeichnet)
Aufbau (Selektoren, auf die Tests und Werkzeug bauen): section.kn-fn-einschalten[data-key][data-projekt]; h2 = <Name> einschalten; dl.kn-fn-fakten mit „Website“ (nur bei mehr als einer Website in daten.projects), „Leistung“ = Einrichtung durch WEBklar, „Dauer“ = eintrag.einrichtung, „Laufzeit“ = läuft, solange dein Wartungs-Abo läuft (bzw. Hosting-Abo nach daten.abo.produkt); p.kn-fn-konto = TEXTE.stundenkonto; p.kn-fn-recht = Linkzeile aus dem Register-Item einschalten-zeile (Links mit target="_blank" rel="noopener noreferrer"); label.kn-fn-haken > input[type=checkbox][data-haken="beginn"] nur wenn beginnLage(...).noetig (Text aus dem Item beginn); p[role=alert][data-meldung]; button[data-aktion="einschalten"] (Text Einschalten, bei nötigem und fehlendem Häkchen Einschalten ab TT.MM., gesperrt) und, wenn nicht imModul, button[data-aktion="abbrechen"] (Text Abbrechen).
- Step 1: Failing Tests schreiben in
test/knEinschaltenSeite.test.js(Teil 1)
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { installFakeDom } from './support/fakeDom.js'
import { renderEinschalten } from '../public/neu/funktion-einschalten.js'
import { daten, projekt } from './support/knFunktionenDaten.js'
const JETZT = () => new Date('2026-10-20T10:00:00Z')
const FRIST = '2026-10-30T22:59:59.999Z'
const OK = { feature: { state: 'setup' }, ticketWoid: '100301' }
const mit = (fn) => async () => { const fx = installFakeDom(); try { await fn(fx) } finally { fx.restore() } }
function seite(fx, { antwort = () => OK, ...opt } = {}) {
const b = fx.document.createElement('div'); fx.document.body.append(b)
const aufrufe = []; const rueck = []
const api = async (pfad, o = {}) => { const a = { pfad, method: o.method ?? 'GET', body: o.body }; aufrufe.push(a); return antwort(a) }
const bei = { fertig: (a) => rueck.push(['fertig', a]), weiter: (w) => rueck.push(['weiter', w]), abbrechen: () => rueck.push(['abbrechen']) }
const el = renderEinschalten(b, { key: 'news', projectId: 'p1', daten: daten(), kundenartWirksam: 'unternehmen', jetzt: JETZT, api, bei, ...opt })
return { b, el, aufrufe, rueck }
}
const knopf = (fx, b) => fx.finde(b, '[data-aktion="einschalten"]')
const haken = (fx, b) => fx.alle(b, 'input[type="checkbox"]')
test('Betrieb: Fakten, Stundenkonto-Satz, Linkzeile, Knopf, kein Häkchen, kein Preis', mit((fx) => {
const { b } = seite(fx)
const t = fx.text(b)
assert.match(t, /News-Pinnwand einschalten/)
assert.match(t, /Leistung.*Einrichtung durch WEBklar/)
assert.match(t, /Dauer.*meist in wenigen Tagen/)
assert.match(t, /Laufzeit.*läuft, solange dein Wartungs-Abo läuft/)
assert.match(t, /Die Einrichtung geht von deinem Stundenkonto ab, auch wenn das Guthaben noch nicht reicht\. Extrakosten entstehen keine\./)
assert.match(t, /Es gelten Leistungsbeschreibung und Auftragsverarbeitung \(Fassung 2026-10-05\)\./)
assert.deepEqual(fx.alle(b, 'a').map((a) => a.getAttribute('href')), ['/rechtliches/leistungsbeschreibung-news-2026-10-05.html', '/rechtliches/avv-news-2026-10-05.html'])
assert.equal(haken(fx, b).length, 0)
assert.equal(knopf(fx, b).textContent, 'Einschalten')
assert.ok(fx.finde(b, '[data-aktion="abbrechen"]'))
assert.doesNotMatch(t, /€|Euro/)
}))
test('Privatkunde in der Frist: Häkchen sichtbar, Knopf nennt das Datum und bleibt gesperrt, bis das Häkchen sitzt', mit(async (fx) => {
const { b } = seite(fx, { kundenartWirksam: 'privat', daten: daten({ abo: { widerrufsfristBis: FRIST } }) })
const h1 = fx.finde(b, 'input[data-haken="beginn"]')
assert.equal(h1.checked, false)
assert.match(fx.text(b), /Bitte beginnt sofort mit der Einrichtung\./)
assert.deepEqual([knopf(fx, b).textContent, knopf(fx, b).disabled], ['Einschalten ab 31.10.', true])
await fx.klick(h1)
assert.deepEqual([knopf(fx, b).textContent, knopf(fx, b).disabled], ['Einschalten', false])
await fx.klick(h1)
assert.equal(knopf(fx, b).disabled, true)
}))
test('Häkchen nie für Betrieb, nie nach Fristende; leere Kundenart zählt als privat', mit((fx) => {
const d = daten({ abo: { widerrufsfristBis: FRIST } })
assert.equal(haken(fx, seite(fx, { daten: d, kundenartWirksam: 'unternehmen' }).b).length, 0)
assert.equal(haken(fx, seite(fx, { daten: d, kundenartWirksam: 'privat', jetzt: () => new Date('2026-10-31T09:00:00Z') }).b).length, 0)
assert.equal(haken(fx, seite(fx, { daten: d, kundenartWirksam: '' }).b).length, 1)
}))
test('Register fehlt: kein Absenden, freundlicher Text', mit((fx) => {
const { b } = seite(fx, { daten: daten({ katalogEdit: { news: { consent: null } } }) })
assert.match(fx.text(b), /Einschalten ist gerade nicht möglich\. Schreib uns im Chat\./)
assert.equal(knopf(fx, b), null)
}))
test('Kundenansicht (viewAs): Knopf gesperrt mit Hinweis', mit((fx) => {
const { b } = seite(fx, { viewAs: true })
assert.equal(knopf(fx, b).disabled, true)
assert.match(fx.text(b), /In der Kundenansicht kann nichts eingeschaltet werden\./)
}))
test('Fremdtext: HTML in Funktions- und Websitenamen nur als Text; Websitezeile erst ab zwei Websites', mit((fx) => {
const d = daten({ katalogEdit: { news: { name: '<img src=x onerror=alert(1)>' } }, projekte: [projekt('p1', '<b>Café</b>'), projekt('p2', 'Kiosk')] })
const { b } = seite(fx, { daten: d })
assert.equal(fx.alle(b, 'img').length + fx.alle(b, 'b').length, 0)
assert.match(fx.text(b), /<img src=x onerror=alert\(1\)> einschalten/)
assert.match(fx.text(b), /Website.*<b>Café<\/b>/)
assert.doesNotMatch(fx.text(seite(fx).b), /Website/)
}))
-
Step 2: Fehlschlag prüfen. Run:
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knEinschaltenSeite.test.js. Expected: FAIL (renderEinschaltennicht exportiert). -
Step 3:
renderEinschaltenzeichnen inpublic/neu/funktion-einschalten.js(Aufbau und Zustand von Häkchen und Knopf; Senden folgt in Task 4)
Der Knopf ist gesperrt bei viewAs, bei nötigem und fehlendem Häkchen und während des Sendens; das change-Ereignis des Häkchens aktualisiert Knopf und Beschriftung. Fehlt das Register (consentVon null, das Item einschalten-zeile fehlt, oder beginn ist nötig und das Item fehlt), zeichnet die Seite statt Linkzeile und Knopf TEXTE.registerFehlt und einen Knopf „Im Chat schreiben“ (geheZu('#chat')). Der Websitename für „Website“ kommt aus daten.projects zum projectId.
-
Step 4: Test laufen lassen. Run:
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knEinschaltenSeite.test.js. Expected: PASS. -
Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/funktion-einschalten.js test/knEinschaltenSeite.test.js && git commit -m "Funktionen neu: Bestätigungsseite zum Einschalten (Linkzeile, Häkchen nur für privat in der Frist)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 4: Einschalten senden, Fehlerfolgen, Navigation, zeigeEinschalten
Files:
- Modify:
public/neu/funktion-einschalten.js - Test:
test/knEinschaltenSeite.test.js(Teil 2; Importkopf umnavigiere, erfolgText, merkeMeldung, nimmMeldung, zeigeEinschaltenerweitern)
Interfaces:
- Consumes: Task 1 (
einschaltKoerper,fehlerAktion,ETAGE_FUER,TEXTE), Task 3 (renderEinschalten), Standardsapiaus./api.jsundgeheZuaus./router.js. - Produces:
export async function navigiere(weg, ziel, { geheZu, speicher } = {})
// weg = Ergebnis von einschaltWeg/fehlerAktion ('etage'|'bestellen'|'wechsel'), ziel = { key, projectId }; 'seite' behandelt die Ansicht selbst
// etage → geheZu(`#${weg.etage}?projekt=${projectId}`)
// bestellen → geheZu(`#bestellen-${weg.produkt}`)
// wechsel → speicher.setItem('kn-wechsel-funktion', JSON.stringify({ key, projectId })) in try/catch, dann geheZu('#wechsel') (V11)
export function erfolgText(name, antwort) // antwort null → "<Name> ist schon eingeschaltet."
export function merkeMeldung(text) // merkt genau eine Meldung für den nächsten Aufbau von Meine/Entdecken
export function nimmMeldung() // → Text | '' und vergisst ihn
export async function zeigeEinschalten(platz, { key, projectId, onFertig, daten, kundenartWirksam, viewAs = false, api, geheZu, speicher, jetzt })
// Vertrag mit P4 (VF1): lädt GET /api/funktionen, wenn daten fehlt, zeichnet die Seite mit imModul: true;
// onFertig({ feature, ticketWoid }) nach 200, onFertig({ feature: null, ticketWoid: null }) nach 409 bereits_eingeschaltet; alle anderen Fehler zeigt die Seite selbst
Standards in renderEinschalten: bei.fertig = merkeMeldung(erfolgText(name, antwort)) und geheZu('#funktionen', { ersetzen: true }); bei.weiter = navigiere; bei.abbrechen = geheZu('#entdecken', { ersetzen: true }); bei.neuLaden = api('/api/funktionen'). Senden: POST /api/funktionen/<key>/einschalten mit dem Körper aus einschaltKoerper.
- Step 1: Failing Tests ergänzen in
test/knEinschaltenSeite.test.js
const fehler = (status, code, body = {}, message = 'Servertext') => Object.assign(new Error(message), { status, code, body: { code, ...body } })
const posts = (s) => s.aufrufe.filter((a) => a.method === 'POST')
test('Erfolg: genau ein POST mit dem Körper aus dem Register, ohne Kundenart; fertig bekommt die Antwort', mit(async (fx) => {
const s = seite(fx)
await fx.klick(knopf(fx, s.b))
assert.deepEqual(posts(s), [{ pfad: '/api/funktionen/news/einschalten', method: 'POST', body: { projectId: 'p1', zustimmung: { version: '2026-10-20', haken: [] } } }])
assert.deepEqual(s.rueck, [['fertig', OK]])
}))
test('zweiter Tap: höchstens ein POST', mit(async (fx) => {
const s = seite(fx)
await Promise.all([fx.klick(knopf(fx, s.b)), fx.klick(knopf(fx, s.b))])
assert.equal(posts(s).length, 1)
}))
test('Privatkunde: ohne Häkchen geht nichts raus, mit Häkchen ["beginn"]', mit(async (fx) => {
const s = seite(fx, { kundenartWirksam: 'privat', daten: daten({ abo: { widerrufsfristBis: FRIST } }) })
await fx.klick(knopf(fx, s.b))
assert.equal(s.aufrufe.length, 0)
await fx.klick(fx.finde(s.b, 'input[data-haken="beginn"]'))
await fx.klick(knopf(fx, s.b))
assert.deepEqual(posts(s)[0].body.zustimmung.haken, ['beginn'])
}))
test('beginn_noetig vom Server: Häkchen erscheint, Knopf nennt das Datum, zweiter Versuch mit Häkchen', mit(async (fx) => {
let n = 0
const s = seite(fx, { antwort: () => { if (++n === 1) throw fehler(409, 'beginn_noetig', { ab: '2026-11-02T23:00:00.000Z' }); return OK } })
await fx.klick(knopf(fx, s.b))
assert.deepEqual([knopf(fx, s.b).textContent, knopf(fx, s.b).disabled], ['Einschalten ab 03.11.', true])
await fx.klick(fx.finde(s.b, 'input[data-haken="beginn"]'))
await fx.klick(knopf(fx, s.b))
assert.deepEqual(posts(s).map((p) => p.body.zustimmung.haken), [[], ['beginn']])
}))
test('weitere 409-Codes führen an den richtigen Ort', mit(async (fx) => {
const faelle = [
[fehler(409, 'abo_fehlt', { grund: 'falsches_abo', wechsel: true }), ['weiter', { art: 'wechsel' }]],
[fehler(409, 'abo_fehlt', { grund: 'kein_abo' }), ['weiter', { art: 'bestellen', produkt: 'wartung' }]],
[fehler(409, 'bestellung_offen', { offen: [] }), ['weiter', { art: 'etage', etage: 'news' }]],
[fehler(409, 'bereits_eingeschaltet'), ['fertig', null]],
]
for (const [f, erwartet] of faelle) {
const s = seite(fx, { antwort: () => { throw f } })
await fx.klick(knopf(fx, s.b))
assert.deepEqual(s.rueck, [erwartet], f.code)
}
}))
test('fassung_veraltet: lädt neu, zeigt die Zeile erneut und sendet mit der neuen Fassung', mit(async (fx) => {
const neu = daten(); neu.katalog[0].consent.version = '2026-11-01'
let n = 0
const s = seite(fx, { antwort: () => { if (++n === 1) throw fehler(409, 'fassung_veraltet'); return OK }, bei: { neuLaden: async () => neu, fertig: () => {} } })
await fx.klick(knopf(fx, s.b))
assert.match(fx.text(s.b), /Die Bedingungen wurden aktualisiert\. Bitte lies die Zeile noch einmal\./)
await fx.klick(knopf(fx, s.b))
assert.equal(posts(s).at(-1).body.zustimmung.version, '2026-11-01')
}))
test('Netzfehler: Meldung, Knopf wieder frei, zweiter Versuch geht durch', mit(async (fx) => {
let n = 0
const s = seite(fx, { antwort: () => { if (++n === 1) throw Object.assign(new Error('Keine Verbindung. Bitte versuch es gleich noch einmal.'), { status: 0, body: {} }); return OK } })
await fx.klick(knopf(fx, s.b))
assert.match(fx.text(fx.finde(s.b, '[data-meldung]')), /Keine Verbindung/)
assert.equal(knopf(fx, s.b).disabled, false)
await fx.klick(knopf(fx, s.b))
assert.equal(posts(s).length, 2)
assert.deepEqual(s.rueck, [['fertig', OK]])
}))
test('zeigeEinschalten (Vertrag mit P4): lädt die Liste, kein Abbrechen, onFertig nach 200 und nach bereits_eingeschaltet, Servertext bei bestellung_offen', mit(async (fx) => {
const lauf = async (antwort) => {
const platz = fx.document.createElement('div'); fx.document.body.append(platz)
const log = { onFertig: [], gegangen: [], pfade: [] }
const api = async (pfad, o = {}) => { log.pfade.push(`${o.method ?? 'GET'} ${pfad}`); if (!o.method) return daten(); return antwort() }
await zeigeEinschalten(platz, { key: 'news', projectId: 'p1', onFertig: (a) => log.onFertig.push(a), api, geheZu: (h) => log.gegangen.push(h), jetzt: JETZT })
assert.equal(fx.finde(platz, '[data-aktion="abbrechen"]'), null)
await fx.klick(knopf(fx, platz))
return { platz, log }
}
const ok = await lauf(() => OK)
assert.deepEqual([ok.log.pfade, ok.log.onFertig], [['GET /api/funktionen', 'POST /api/funktionen/news/einschalten'], [OK]])
const schon = await lauf(() => { throw fehler(409, 'bereits_eingeschaltet') })
assert.deepEqual(schon.log.onFertig, [{ feature: null, ticketWoid: null }])
const offen = await lauf(() => { throw fehler(409, 'bestellung_offen', { offen: [] }, 'Es fehlen noch Angaben.') })
assert.match(fx.text(offen.platz), /Es fehlen noch Angaben\./)
assert.deepEqual([offen.log.gegangen, offen.log.onFertig], [[], []])
}))
test('navigiere: Ziele je Weg; der Wechsel merkt die Funktion und geht auch ohne beschreibbaren Speicher', async () => {
const gegangen = []; const geheZu = (hash, o) => { gegangen.push(o ? [hash, o] : [hash]) }
const gespeichert = {}; const speicher = { setItem: (k, v) => { gespeichert[k] = v } }
const ziel = { key: 'termine', projectId: 'p1' }
await navigiere({ art: 'etage', etage: 'webshop' }, { key: 'shop', projectId: 'p2' }, { geheZu, speicher })
await navigiere({ art: 'bestellen', produkt: 'hosting' }, { key: 'analyse', projectId: 'p1' }, { geheZu, speicher })
assert.deepEqual(gegangen, [['#webshop?projekt=p2'], ['#bestellen-hosting']])
gegangen.length = 0
await navigiere({ art: 'wechsel' }, ziel, { geheZu, speicher })
assert.deepEqual(JSON.parse(gespeichert['kn-wechsel-funktion']), ziel)
await navigiere({ art: 'wechsel' }, ziel, { geheZu, speicher: { setItem() { throw new Error('voll') } } })
assert.deepEqual(gegangen, [['#wechsel'], ['#wechsel']])
})
test('erfolgText und Meldung: eine Meldung, einmal gelesen', () => {
assert.equal(erfolgText('News-Pinnwand', { ticketWoid: '100301' }), 'News-Pinnwand ist eingeschaltet. Die Einrichtung läuft jetzt. Ticket #100301.')
assert.equal(erfolgText('News-Pinnwand', { ticketWoid: null }), 'News-Pinnwand ist eingeschaltet. Die Einrichtung läuft jetzt.')
assert.equal(erfolgText('News-Pinnwand', null), 'News-Pinnwand ist schon eingeschaltet.')
merkeMeldung('x'); assert.equal(nimmMeldung(), 'x'); assert.equal(nimmMeldung(), '')
})
-
Step 2: Fehlschlag prüfen. Run:
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knEinschaltenSeite.test.js. Expected: FAIL (Senden nicht verdrahtet;navigiere,zeigeEinschaltenfehlen). -
Step 3: Senden und Navigation implementieren in
public/neu/funktion-einschalten.js
Ablauf des Klicks: (1) bei gesperrtem Knopf nichts; (2) Knopf sperren, Meldung leeren; (3) POST mit einschaltKoerper; (4) Erfolg → bei.fertig(antwort); (5) Fehler → fehlerAktion(fehler, { eintrag }): wechsel/bestellen → bei.weiter(aktion); etage → bei.weiter(aktion), bei imModul stattdessen der Servertext in [data-meldung]; schon → bei.fertig(null); beginn → serverAb merken und die Seite neu zeichnen (Häkchen sichtbar, Knopf gesperrt mit Datum); neu-laden → bei.neuLaden() und neu zeichnen mit der Meldung aktion.text; meldung → Text in [data-meldung], Knopf frei. Ein Fehler beim Neuladen zeigt TEXTE.allgemein. zeigeEinschalten übersetzt bei.fertig(antwort) zu onFertig(antwort ?? { feature: null, ticketWoid: null }). Fehlende Optionen: api aus ./api.js, geheZu aus ./router.js, speicher = try/catch-Zugriff auf globalThis.sessionStorage erst beim Aufruf.
-
Step 4: Test laufen lassen. Run:
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knEinschaltenSeite.test.js. Expected: PASS (Task 3 und 4). -
Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/funktion-einschalten.js test/knEinschaltenSeite.test.js && git commit -m "Funktionen neu: Einschalten senden, 409-Folgen, Wechsel mit gemerkter Funktion, zeigeEinschalten für die Module" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 5: Ansicht mit Reitern, Meine, Entdecken, Website-Auswahl und Rechnungszeile
Files:
- Modify:
public/neu/ansicht-funktionen.js - Test:
test/knFunktionenAnsicht.test.js(Teil 1)
Interfaces:
- Consumes: Task 1 bis 4;
h,leeren(V2);api,geheZu,oeffneOverlay,beendeOverlay(P1) überabh;baueReiter({ eintraege:[{ key, label, kurz? }], aktiv, art = 'unterstrich', ariaLabel, bei(key) }) → HTMLElementaus./reiter.js(P1 Task 5, dieselbe Form wie P2 Task 16: das Element selbst, nicht{ el }; am ElementsetzeAktiv(key),setzeAnzeige,messen);offeneRechnungen(antwort) → Rechnung[](nurNumber(balance) > 0) undrechnungOffenZeile(offen) → <p class="kn-rechnung-offen"> | nullaus./rechnung-offen.js(P2 Task 3). Nicht genutzt:ladeOffeneRechnungen()(nimmt keineapientgegen, im Test nicht einspeisbar) undzaehler.stand().rechnungen(gibt es nicht, P1 Task 7). - Produces:
export async function zeigeFunktionen(wurzel, { route, me, nav }, abh = {}) // V2, genau die Form von P1 Task 11
// abh: { api, geheZu, oeffneOverlay, beendeOverlay, speicher, jetzt }, jeder Eintrag optional (Standards: ./api.js, ./router.js, sessionStorage, new Date())
export function vergissMerker() // setzt die Merker des Moduls zurück (nur für Tests)
Aufbau (Selektoren): oben p.kn-rechnung-offen (nur mit offenen Rechnungen, die die Ansicht selbst über GET /api/invoices lädt, in Meine und in Entdecken); die Reiterleiste baueReiter({ eintraege: [{ key:'meine', label:'Meine' }, { key:'entdecken', label:'Entdecken' }], aktiv: route.reiter ?? 'meine', art:'unterstrich', ariaLabel:'Funktionen', bei }) (liefert direkt das Element mit button[role=tab]); Meine: ul.kn-fn-liste > li.kn-fn-zeile[data-fn][data-projekt] > button.kn-fn-zeile-knopf mit .kn-fn-name, .kn-fn-zeile2, .kn-fn-pille.kn-fn-pille--<ton>; Entdecken: section.kn-fn-eintrag[data-fn] mit img.kn-fn-bild (src = eintrag.bild, alt = Beispiel: <Name>, width=960 height=600 loading=lazy), h3, Beschreibung, p.kn-fn-enthalten (Im Wartungs-Abo enthalten / Im Hosting-Abo enthalten nach enthaltenIn), button[data-aktion="einschalten"], div[role=group][aria-label="Für welche Website?"] mit einem Knopf je Kandidat (nur bei mehr als einem Kandidaten), p[role=alert] für Meldungen. Meldung nach Erfolg: p[role=status] aus nimmMeldung(). Texte: Wird geladen …; Die Funktionen konnten gerade nicht geladen werden. mit button[data-aktion="nochmal"] („Nochmal versuchen“, ruft zeigeFunktionen mit denselben Argumenten erneut); modell !== 'abo' → Die Funktionen sind gerade nicht verfügbar. Lade die Seite neu.; Meine leer und ausdrücklich gewählt → Du hast noch keine Funktion eingeschaltet. mit Knopf Entdecken; Entdecken ohne Websites → Sobald deine Website angelegt ist, kannst du Funktionen einschalten. statt Knöpfe; Entdecken ohne Einträge → Du hast schon alles eingeschaltet, was es gibt.
- Step 0: Signaturen von
baueReiterundrechnung-offen.jslesen (nur lesen)
Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && sed -n 1,60p public/neu/reiter.js && command grep -a -n "^export" public/neu/rechnung-offen.js
Expected: baueReiter({ eintraege: [{ key, label, kurz? }], aktiv, art, ariaLabel, bei(key) }) liefert ein HTMLElement; offeneRechnungen und rechnungOffenZeile sind exportiert. Weicht eine Form ab, wird nur die interne Funktion funktionenReiter(aktiv, bei) bzw. ladeRechnungen(api) in ansicht-funktionen.js angepasst; die Tests unten fragen [role="tab"] und .kn-rechnung-offen ab. Befund dem Controller melden.
- Step 1: Failing Tests schreiben in
test/knFunktionenAnsicht.test.js(Teil 1)
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { installFakeDom } from './support/fakeDom.js'
import { zeigeFunktionen, vergissMerker } from '../public/neu/ansicht-funktionen.js'
import { daten, projekt, feature } from './support/knFunktionenDaten.js'
const JETZT = () => new Date('2026-10-20T10:00:00Z')
const MEINE = { bereich: 'funktionen', reiter: 'meine', query: '' }
const ENTDECKEN = { bereich: 'funktionen', reiter: 'entdecken', query: '' }
const mit = (fn) => async () => { vergissMerker(); const fx = installFakeDom(); try { await fn(fx) } finally { fx.restore() } }
function umgebung(fx, { d = daten(), abo, api, post = { feature: { state: 'setup' }, ticketWoid: '100301' }, me = {} } = {}) {
const wurzel = fx.document.createElement('div'); fx.document.body.append(wurzel)
const u = { wurzel, d, rechnungen: [], rechnungenFehler: false, gegangen: [], aufrufe: [], gespeichert: {}, overlay: null }
const basis = async (pfad, o = {}) => {
u.aufrufe.push([o.method ?? 'GET', pfad])
if (o.method === 'POST') return post
if (pfad === '/api/funktionen') return u.d
if (pfad === '/api/invoices') { if (u.rechnungenFehler) throw Object.assign(new Error('x'), { status: 500, body: {} }); return { invoices: u.rechnungen, incomplete: false } }
if (pfad === '/api/abo') { if (abo instanceof Error) throw abo; return abo ?? { subscription: null } }
throw new Error(`unerwartet ${pfad}`)
}
const abh = {
api: api ?? basis, jetzt: JETZT, speicher: { setItem: (k, v) => { u.gespeichert[k] = v } },
geheZu: (hash, o) => { u.gegangen.push(o ? [hash, o] : [hash]) },
oeffneOverlay: ({ schliesse }) => { u.overlay = schliesse },
beendeOverlay: () => { const s = u.overlay; u.overlay = null; s?.() },
}
u.zeige = (route, ctx = {}) => zeigeFunktionen(wurzel, { route, me: { customer: { kundenartWirksam: 'unternehmen' }, viewAs: false, ...me }, nav: 'tab', ...ctx }, abh)
return u
}
const tab = (fx, u, text) => fx.alle(u.wurzel, '[role="tab"]').find((t) => t.textContent === text)
test('Meine: Liste mit Name, zweiter Zeile und Pille; Tipp öffnet die Etage mit der Website der Zeile, eine angenommene Anfrage die Bestätigung', mit(async (fx) => {
const u = umgebung(fx, { d: daten({ projekte: [projekt('p1', 'Bäckerei', { termine: feature('active'), shop: feature('off', { anfrageStatus: 'angenommen' }) }), projekt('p2', 'Café', { termine: feature('active') })] }) })
await u.zeige(MEINE)
const zeilen = fx.alle(u.wurzel, 'li[data-fn]')
assert.deepEqual(zeilen.map((z) => [z.getAttribute('data-fn'), z.getAttribute('data-projekt')]), [['termine', 'p1'], ['termine', 'p2'], ['shop', 'p1']])
assert.match(fx.text(zeilen[1]), /Terminplaner.*Café.*Aktiv/)
await fx.klick(fx.finde(zeilen[0], 'button'))
await fx.klick(fx.finde(zeilen[1], 'button'))
assert.deepEqual(u.gegangen, [['#termine?projekt=p1'], ['#termine?projekt=p2']])
await fx.klick(fx.finde(zeilen[2], 'button'))
assert.ok(fx.finde(u.wurzel, 'section.kn-fn-einschalten[data-key="shop"][data-projekt="p1"]'))
}))
test('Meine leer: der erste Aufruf springt ersetzend zu Entdecken, ein ausdrücklich gewähltes Meine zeigt den Leerzustand', mit(async (fx) => {
const u = umgebung(fx)
await u.zeige(MEINE)
assert.deepEqual(u.gegangen, [['#entdecken', { ersetzen: true }]])
await u.zeige(ENTDECKEN)
u.gegangen.length = 0
await fx.klick(tab(fx, u, 'Meine'))
assert.deepEqual(u.gegangen, [['#funktionen', { ersetzen: true }]])
await u.zeige(MEINE) // so ruft der Router danach auf
assert.equal(u.gegangen.length, 1)
assert.match(fx.text(u.wurzel), /Du hast noch keine Funktion eingeschaltet\./)
}))
test('Rechnungszeile oben: die Ansicht lädt GET /api/invoices selbst (ohne Zähler), nur balance > 0, in Meine und Entdecken; fällt der Abruf aus, bleibt die Liste', mit(async (fx) => {
const u = umgebung(fx, { d: daten({ projekte: [projekt('p1', 'B', { news: feature('active') })] }) })
await u.zeige(MEINE)
assert.equal(fx.finde(u.wurzel, '.kn-rechnung-offen'), null)
assert.ok(u.aufrufe.some(([m, p]) => m === 'GET' && p === '/api/invoices'))
u.rechnungen = [{ id: 'r0', number: 'RE-2026-0040', balance: 0, portalUrl: null }, { id: 'r1', number: 'RE-2026-0042', balance: 50, portalUrl: 'https://invoice.stripe.com/i/x' }]
await u.zeige(MEINE)
assert.match(fx.text(fx.finde(u.wurzel, '.kn-rechnung-offen')), /^Rechnung offen · RE-2026-0042 · 50,00\s€ · Zahlen$/)
assert.ok(fx.finde(u.wurzel, 'li[data-fn]'))
await u.zeige(ENTDECKEN)
assert.ok(fx.finde(u.wurzel, '.kn-rechnung-offen'))
u.rechnungenFehler = true
await u.zeige(MEINE)
assert.equal(fx.finde(u.wurzel, '.kn-rechnung-offen'), null)
assert.ok(fx.finde(u.wurzel, 'li[data-fn]'))
}))
test('Entdecken: Bild, Name, Beschreibung, "Im … Abo enthalten", Knopf; Katalogpreise erscheinen nie', mit(async (fx) => {
const u = umgebung(fx, { d: daten({ katalogEdit: { termine: { priceLabel: '149 €' } } }) })
await u.zeige(ENTDECKEN)
const e = fx.alle(u.wurzel, 'section[data-fn]')
assert.deepEqual(e.map((x) => x.getAttribute('data-fn')), ['news', 'termine', 'shop', 'unterkunft', 'zeiten', 'analyse'])
assert.equal(fx.finde(e[1], 'img').getAttribute('src'), '/neu/bilder/termine.webp')
assert.match(fx.text(e[1]), /Terminplaner.*Beschreibung Terminplaner\..*Im Wartungs-Abo enthalten/)
assert.match(fx.text(e[5]), /Im Hosting-Abo enthalten/)
assert.equal(fx.finde(e[1], '[data-aktion="einschalten"]').textContent, 'Einschalten')
assert.doesNotMatch(fx.text(u.wurzel), /€|Euro/)
}))
test('Einschalten-Tap (eine Website) geht je Abo-Lage an den richtigen Ort', mit(async (fx) => {
const faelle = [ // [Abo, Funktion, Sprung ('seite' = Bestätigung ohne Sprung)]
[{ produkt: 'wartung' }, 'news', 'seite'],
[{ produkt: 'wartung' }, 'termine', ['#termine?projekt=p1']],
[{ produkt: 'wartung' }, 'shop', ['#webshop?projekt=p1']],
[{ produkt: 'hosting' }, 'analyse', ['#analyse?projekt=p1']],
[{ produkt: 'hosting' }, 'zeiten', ['#wechsel']],
[{ produkt: null, laeuft: false }, 'zeiten', ['#bestellen-wartung']],
[{ produkt: null, laeuft: false }, 'analyse', ['#bestellen-hosting']],
]
for (const [abo, key, sprung] of faelle) {
const u = umgebung(fx, { d: daten({ abo }) })
await u.zeige(ENTDECKEN)
await fx.klick(fx.finde(u.wurzel, `section[data-fn="${key}"] [data-aktion="einschalten"]`))
if (sprung === 'seite') { assert.deepEqual(u.gegangen, []); assert.ok(fx.finde(u.wurzel, `section.kn-fn-einschalten[data-key="${key}"]`)); continue }
assert.deepEqual(u.gegangen, [sprung], `${abo.produkt} ${key}`)
if (sprung[0] === '#wechsel') assert.deepEqual(JSON.parse(u.gespeichert['kn-wechsel-funktion']), { key, projectId: 'p1' })
}
}))
test('Privatkunde: Webshop zeigt den Hinweis statt des Knopfes; ohne Websites steht ein Hinweis statt der Knöpfe', mit(async (fx) => {
const u = umgebung(fx, { d: daten({ kundenart: 'privat' }), me: { customer: { kundenartWirksam: 'privat' } } })
await u.zeige(ENTDECKEN)
const shop = fx.finde(u.wurzel, 'section[data-fn="shop"]')
assert.match(fx.text(shop), /Webshop gibt es für Betriebe\. Stimmt das nicht\? Schreib uns im Chat\./)
assert.equal(fx.finde(shop, '[data-aktion="einschalten"]'), null)
const leer = umgebung(fx, { d: daten({ projekte: [] }) })
await leer.zeige(ENTDECKEN)
assert.match(fx.text(leer.wurzel), /Sobald deine Website angelegt ist, kannst du Funktionen einschalten\./)
assert.equal(fx.alle(leer.wurzel, '[data-aktion="einschalten"]').length, 0)
}))
test('Zwei Websites: Einschalten fragt "Für welche Website?" und bietet nur Websites ohne die Funktion', mit(async (fx) => {
const u = umgebung(fx, { d: daten({ projekte: [projekt('p1', 'Bäckerei', { news: feature('active') }), projekt('p2', 'Café'), projekt('p3', 'Kiosk')] }) })
await u.zeige(ENTDECKEN)
const news = fx.finde(u.wurzel, 'section[data-fn="news"]')
await fx.klick(fx.finde(news, '[data-aktion="einschalten"]'))
const wahl = fx.finde(news, '[role="group"]')
assert.equal(wahl.getAttribute('aria-label'), 'Für welche Website?')
assert.deepEqual(fx.alle(wahl, 'button').map((b) => b.textContent), ['Café', 'Kiosk'])
await fx.klick(fx.alle(wahl, 'button')[1])
assert.ok(fx.finde(u.wurzel, 'section.kn-fn-einschalten[data-key="news"][data-projekt="p3"]'))
}))
test('Laden und Fehler: erst "Wird geladen …", dann Text und "Nochmal versuchen"; Antwort ohne Abo-Modell', mit(async (fx) => {
let los; const warten = new Promise((r) => { los = r }); let n = 0
const u = umgebung(fx, { api: async (pfad) => { if (pfad === '/api/invoices') return { invoices: [], incomplete: false }; if (++n === 1) { await warten; throw Object.assign(new Error('x'), { status: 500, body: {} }) } return n === 2 ? daten() : { ...daten(), modell: undefined } } })
const lauf = u.zeige(ENTDECKEN)
assert.match(fx.text(u.wurzel), /Wird geladen …/)
los(); await lauf
assert.match(fx.text(u.wurzel), /Die Funktionen konnten gerade nicht geladen werden\./)
await fx.klick(fx.finde(u.wurzel, '[data-aktion="nochmal"]'))
assert.equal(fx.alle(u.wurzel, 'section[data-fn]').length, 6)
await u.zeige(ENTDECKEN)
assert.match(fx.text(u.wurzel), /Die Funktionen sind gerade nicht verfügbar\. Lade die Seite neu\./)
}))
test('veraltete Antwort überschreibt die neuere nicht', mit(async (fx) => {
let langsam; const erster = new Promise((r) => { langsam = r }); let n = 0
const mit1 = (name) => daten({ katalogEdit: { news: { name } }, projekte: [projekt('p1', 'B', { news: feature('active') })] })
const u = umgebung(fx, { api: async (pfad) => (pfad === '/api/invoices' ? { invoices: [], incomplete: false } : ++n === 1 ? (await erster, mit1('Alt-Pinnwand')) : mit1('Neu-Pinnwand')) })
const a = u.zeige(MEINE); const b = u.zeige(MEINE)
await b; langsam(); await a
assert.match(fx.text(u.wurzel), /Neu-Pinnwand/)
assert.doesNotMatch(fx.text(u.wurzel), /Alt-Pinnwand/)
}))
test('Fremdtext und fehlendes Bild: Namen nur als Text, ohne Bild bleibt der Eintrag lesbar', mit(async (fx) => {
const u = umgebung(fx, { d: daten({ katalogEdit: { news: { name: '<b>News</b>', beschreibung: '<i>x</i>' } }, projekte: [projekt('p1', '<u>Café</u>')] }) })
await u.zeige(ENTDECKEN)
assert.equal(fx.alle(u.wurzel, 'b, i, u').length, 0)
const news = fx.finde(u.wurzel, 'section[data-fn="news"]')
await fx.sende(fx.finde(news, 'img'), 'error')
assert.equal(fx.finde(news, 'img'), null)
assert.match(fx.text(news), /<b>News<\/b>/)
}))
-
Step 2: Fehlschlag prüfen. Run:
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knFunktionenAnsicht.test.js. Expected: FAIL (der Stub zeichnet nur „Funktionen (folgt)“;vergissMerkerfehlt). -
Step 3:
zeigeFunktionenfür Listen und Bestätigung implementieren inpublic/neu/ansicht-funktionen.js(den Stub ersetzen)
Ablauf eines Aufrufs: (1) fehlende abh-Einträge mit den Standards füllen; (2) Lauf-Zähler am wurzel (dataset.knFnLauf), damit eine ältere Antwort verworfen wird; (3) Wird geladen … (vor dem ersten await); (4) GET /api/funktionen und parallel (Promise.all) die offenen Rechnungen über die interne Funktion ladeRechnungen(api) = offeneRechnungen(await api('/api/invoices')), jeder Fehler dort → [] (dann fehlt nur die Zeile, die Liste bleibt); Fehler von /api/funktionen und modell !== 'abo' mit den Texten oben; (5) Weiche nach route.reiter ?? 'meine': Meine ohne Zeilen und ohne ausdrückliche Wahl → geheZu('#entdecken', { ersetzen: true }) und Ende. Den Merker reiterGewaehlt setzt der Tipp auf „Meine“: bei(key) der Reiterleiste (aus funktionenReiter(aktiv, bei), dem einzigen Aufruf von baueReiter) ruft geheZu('#funktionen' | '#entdecken', { ersetzen: true }) für 'meine' bzw. 'entdecken'; der nächste Aufruf verbraucht den Merker; (6) die Liste zeichnet eine eigene Funktion zeichneListe(daten, offen, optionen) (in Task 6 auch aus dem Zwischenspeicher aufrufbar; daten und offen liegen beide im Zwischenspeicher des Aufrufs): ganz oben rechnungOffenZeile(offen), wenn nicht null, darunter die Reiterleiste; nimmMeldung() erst dort. Kundenart: me?.customer?.kundenartWirksam ?? daten.kundenart. Tipp auf eine Meine-Zeile und auf „Einschalten“: zeilenWeg bzw. einschaltWeg entscheidet; seite → oeffneSeite(key, projectId) (hier ein renderEinschalten direkt in wurzel, Task 6 ergänzt das Overlay), etage/bestellen/wechsel → navigiere(weg, { key, projectId }, { geheZu, speicher }), meldung → Text in p[role=alert] des Eintrags. Bei mehreren Kandidaten öffnet „Einschalten“ erst die Auswahlgruppe (zweiter Tipp schließt sie). nurBetrieb bei privat → Hinweis (TEXTE.nurBetrieb) statt Knopf. Der error-Handler des Bildes entfernt das img.
-
Step 4: Test laufen lassen. Run:
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knFunktionenAnsicht.test.js. Expected: PASS. -
Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/ansicht-funktionen.js test/knFunktionenAnsicht.test.js && git commit -m "Funktionen neu: Ansicht mit Meine und Entdecken, Website-Auswahl, Hinweise je Abo-Lage, Rechnungszeile" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 6: Bestätigungsseite als Overlay, Erfolgsmeldung, Wechsel-Rückkehr
Files:
- Modify:
public/neu/ansicht-funktionen.js - Test:
test/knFunktionenAnsicht.test.js(Teil 2)
Interfaces:
-
Consumes: Task 3 bis 5;
oeffneOverlay({ schliesse })undbeendeOverlay()aus./router.js(P1 Task 4, R6: das Overlay schiebt einen Verlaufseintrag, die Zurück-Geste ruftschliesse()ohnezeige,geheZubei offenem Overlay ersetzt den Eintrag und ruftschliesse()genau einmal). -
Produces (innerhalb von
zeigeFunktionen):oeffneSeite(key, projectId): ersetzt den Inhalt vonwurzeldurchrenderEinschalten(wurzel, …)(mitviewAsausme,bei.abbrechen=beendeOverlay) und ruftoeffneOverlay({ schliesse }).schliessezeichnet die Liste aus den bereits geladenen Daten und Rechnungen (kein neuer Abruf, auch nicht von/api/invoices;nimmMeldung()bleibt unangetastet) und wirkt nur, wenn die Seite noch die aktuelle Anzeige ist (Lauf-Zähler).- Erfolg:
bei.fertig(Standard aus Task 4) merkt die Meldung und ruftgeheZu('#funktionen', { ersetzen: true }); der folgende Aufruf vonzeigeFunktionenzeigt die Meldung alsp[role=status]genau einmal. - Wechsel-Rückkehr (V11): nach dem Laden der Daten, vor der Weiche Meine/Entdecken. Bei Abo
wartungundlaeuftruft die AnsichtGET /api/abo; stehtsubscription.wechselFunktion{ key, projectId }da, ist die Funktion im Katalog verfügbar und hat die Website sie noch nicht, öffnet sie den Ablauf dieser Funktion (einschaltWeg:seite→oeffneSeite,etage→navigiere), pro Seitenaufruf einmal jekey:projectId(modulweitesSet). Fehler von/api/abowerden ignoriert.
-
Step 1: Failing Tests ergänzen in
test/knFunktionenAnsicht.test.js
test('Bestätigung als Overlay: Zurück-Geste bringt die Liste ohne neuen Abruf zurück, Abbrechen ebenso', mit(async (fx) => {
const u = umgebung(fx)
await u.zeige(ENTDECKEN)
const geladen = () => u.aufrufe.filter(([m, p]) => m === 'GET' && p === '/api/funktionen').length
await fx.klick(fx.finde(u.wurzel, 'section[data-fn="news"] [data-aktion="einschalten"]'))
assert.match(fx.text(u.wurzel), /News-Pinnwand einschalten/)
assert.equal(fx.alle(u.wurzel, 'section[data-fn]').length, 0)
u.overlay() // Zurück-Geste des Browsers (der Router ruft schliesse)
assert.equal(fx.alle(u.wurzel, 'section[data-fn]').length, 6)
await fx.klick(fx.finde(u.wurzel, 'section[data-fn="news"] [data-aktion="einschalten"]'))
await fx.klick(fx.finde(u.wurzel, '[data-aktion="abbrechen"]'))
assert.equal(fx.alle(u.wurzel, 'section[data-fn]').length, 6)
assert.deepEqual([geladen(), u.gegangen], [1, []])
}))
test('Erfolg: Meldung merken, ersetzend zu Meine, Meldung genau einmal', mit(async (fx) => {
const u = umgebung(fx)
await u.zeige(ENTDECKEN)
await fx.klick(fx.finde(u.wurzel, 'section[data-fn="news"] [data-aktion="einschalten"]'))
await fx.klick(fx.finde(u.wurzel, '[data-aktion="einschalten"]'))
assert.deepEqual(u.aufrufe.filter(([m]) => m === 'POST'), [['POST', '/api/funktionen/news/einschalten']])
assert.deepEqual(u.gegangen, [['#funktionen', { ersetzen: true }]])
u.d = daten({ projekte: [projekt('p1', 'Bäckerei Sonnenschein', { news: feature('setup', { ticketWoid: '100301' }) })] })
await u.zeige(MEINE) // so ruft der Router danach auf
assert.equal(fx.text(fx.finde(u.wurzel, '[role="status"]')), 'News-Pinnwand ist eingeschaltet. Die Einrichtung läuft jetzt. Ticket #100301.')
await u.zeige(MEINE)
assert.equal(fx.finde(u.wurzel, '[role="status"]'), null)
}))
test('wechselFunktion (V11): öffnet den Ablauf der Funktion, einmal, nur mit Wartungs-Abo', mit(async (fx) => {
const aboMit = (key) => ({ subscription: { wechselFunktion: { key, projectId: 'p1' } } })
const abgefragt = (u) => u.aufrufe.some(([, p]) => p === '/api/abo')
let u = umgebung(fx, { abo: aboMit('news') })
await u.zeige(MEINE)
assert.ok(fx.finde(u.wurzel, 'section.kn-fn-einschalten[data-key="news"]')) // Bestätigung, kein Sprung
assert.deepEqual(u.gegangen, [])
await u.zeige(MEINE) // zweiter Aufruf: nicht noch einmal in die Funktion
assert.deepEqual(u.gegangen, [['#entdecken', { ersetzen: true }]])
vergissMerker()
u = umgebung(fx, { abo: aboMit('termine') }); await u.zeige(MEINE)
assert.deepEqual(u.gegangen, [['#termine?projekt=p1']]) // Fragebogen zuerst
vergissMerker()
u = umgebung(fx, { d: daten({ abo: { produkt: 'hosting' } }), abo: aboMit('news') }); await u.zeige(MEINE)
assert.equal(abgefragt(u), false)
vergissMerker()
for (const abo of [aboMit('gibtsnicht'), new Error('weg')]) {
u = umgebung(fx, { abo }); await u.zeige(MEINE)
assert.deepEqual(u.gegangen, [['#entdecken', { ersetzen: true }]]) // ungültig oder Fehler: wie ohne Wechsel
}
}))
-
Step 2: Fehlschlag prüfen. Run:
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knFunktionenAnsicht.test.js. Expected: FAIL in den drei neuen Tests (kein Overlay, keine Meldung, keine Wechsel-Rückkehr). -
Step 3: Overlay, Erfolgsmeldung und Wechsel-Rückkehr implementieren in
public/neu/ansicht-funktionen.js
oeffneSeite ersetzt den Platzhalter aus Task 5. Der Zwischenspeicher der geladenen daten, der offenen Rechnungen offen und der route lebt in der Closure des Aufrufs. Der Merker der Wechsel-Rückkehr ist ein modulweites Set aus key:projectId; vergissMerker() leert es und setzt reiterGewaehlt zurück.
-
Step 4: Test laufen lassen. Run:
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knFunktionenAnsicht.test.js. Expected: PASS (Task 5 und 6). -
Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/ansicht-funktionen.js test/knFunktionenAnsicht.test.js && git commit -m "Funktionen neu: Bestätigung als Overlay der Zurück-Geste, Erfolgsmeldung, Rückkehr nach dem Wechsel" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 7: Aussehen (funktionen.css) und Quelltext-Prüfung
Files:
- Modify:
public/neu/funktionen.css(Stub aus P1;dashboard.htmlbindet die Datei schon in fester Reihenfolge ein) - Test:
test/knFunktionenStatik.test.js
Interfaces:
- Consumes: alle
kn-fn-*-Klassen aus Task 3 bis 6; die Tokens--kn-*aus P1tokens.css(z. B.--kn-text,--kn-text-2,--kn-text-3,--kn-line,--kn-bg-hover,--kn-bg-subtle,--kn-accent,--kn-danger,--kn-radius,--kn-radius-lg,--kn-ring,--kn-<farbe>-bg|fg) mit den Werten der Übersicht als Fallback. - Produces: eine CSS-Datei, die jede im JS benutzte
kn-fn-*-Klasse gestaltet.
Vorgaben (Werte aus den Global Constraints der Übersicht): Liste mit Haarlinien #ebeef1, Hover #f6f8fa, Zeilenknopf min-height: 44px, Name #1a1f36 Gewicht 600, zweite Zeile #5e6678. Pillen ohne Rand, Radius 999 px, Fläche/Schrift: grau #ebeef1/#414552, blau #e0f2fe/#075985, orange #fcedb9/#a82c00, violett #efeafe/#5b21b6, grün #d7f7c2/#006908, rot #ffe7f2/#b3093c. Primärknopf #111 mit weißer Schrift, Zweitknopf mit Ring-Schatten 0 0 0 1px rgba(60,66,87,.16), 0 1px 1px rgba(0,0,0,.06), beide Radius 6 px, mindestens 44 px hoch; gesperrt: Schrift #8a93a3, Fläche #f8f9fb. Fehlertext #c0123c. Entdecken ohne Rahmen: Bild (16:10, Radius 12 px) über dem Text am Handy, ab @media (min-width: 881px) zwei Spalten. Keine Animation. Die Rechnungszeile .kn-rechnung-offen gehört P2.
- Step 1: Failing Tests schreiben in
test/knFunktionenStatik.test.js
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
const lies = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8')
const ohneKommentare = (t) => t.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
const JS = ['public/neu/ansicht-funktionen.js', 'public/neu/funktion-einschalten.js']
const css = lies('public/neu/funktionen.css')
test('funktionen.css: Pillen-Farben aus den Global Constraints', () => {
const PILLEN = { grau: ['#ebeef1', '#414552'], blau: ['#e0f2fe', '#075985'], orange: ['#fcedb9', '#a82c00'], violett: ['#efeafe', '#5b21b6'], gruen: ['#d7f7c2', '#006908'], rot: ['#ffe7f2', '#b3093c'] }
for (const [ton, [flaeche, schrift]] of Object.entries(PILLEN)) {
const regel = (css.match(new RegExp(`\\.kn-fn-pille--${ton}\\s*\\{([^}]*)\\}`)) ?? [, ''])[1].toLowerCase()
assert.ok(regel.includes(flaeche) && regel.includes(schrift), ton)
}
})
test('funktionen.css: verbotene Mittel fehlen, Radien nur 6 und 12 px, Tap-Ziel 44 px', () => {
assert.doesNotMatch(css, /gradient|backdrop-filter|glow|prefers-color-scheme\s*:\s*dark|#9d3dfe|rgba\(\s*157/i)
assert.doesNotMatch(css, /font-weight\s*:\s*(800|900)/)
for (const m of css.matchAll(/border-radius\s*:\s*([^;}]+)/g)) assert.match(m[1].trim(), /^(0|6px|12px|999px|50%)$/, m[0])
assert.match((css.match(/\.kn-fn-zeile-knopf\s*\{([^}]*)\}/) ?? [, ''])[1], /min-height\s*:\s*44px/)
assert.match(css, /@media\s*\(min-width:\s*881px\)/)
})
test('jede kn-fn-Klasse der JS-Dateien ist im CSS gestaltet', () => {
for (const datei of JS) for (const k of new Set(ohneKommentare(lies(datei)).match(/kn-fn-[a-z0-9-]+/g) ?? [])) assert.ok(css.includes(`.${k}`), `${datei}: .${k} fehlt im CSS`)
})
for (const datei of JS) {
test(`${datei}: kein innerHTML, nur relative Importe, keine Preise, keine Sie-Anrede`, () => {
const code = ohneKommentare(lies(datei))
assert.doesNotMatch(code, /innerHTML|insertAdjacentHTML|outerHTML|document\.write|\beval\(|new Function/)
for (const m of code.matchAll(/(?:from|import\()\s*'([^']+)'/g)) assert.match(m[1], /^\.{1,2}\//, m[1])
assert.doesNotMatch(code, /€|\bEuro\b/)
assert.doesNotMatch(code, /\b(Sie|Ihre[rnms]?|Ihnen|Ihr)\b/)
})
}
test('beide Module laden unter Node ohne DOM (kein Zugriff beim Import)', async () => {
assert.equal(typeof globalThis.document, 'undefined')
await import('../public/neu/ansicht-funktionen.js')
await import('../public/neu/funktion-einschalten.js')
})
-
Step 2: Fehlschlag prüfen. Run:
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knFunktionenStatik.test.js. Expected: FAIL (der Stubfunktionen.csshat nur einen Kommentar: Pillen-Regeln und Klassen fehlen). -
Step 3:
public/neu/funktionen.cssschreiben nach den Vorgaben oben. Jede Farbe alsvar(--kn-<name>, <Wert>); die Pillen-Klassen heißen.kn-fn-pille--grau|blau|orange|violett|gruen|rot. Die Klassenliste ergibt sich aus den zwei JS-Dateien; der Test „jede kn-fn-Klasse“ meldet fehlende. -
Step 4: Test laufen lassen. Run:
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knFunktionenStatik.test.js. Expected: PASS. -
Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/funktionen.css test/knFunktionenStatik.test.js && git commit -m "Funktionen neu: Aussehen (weiß, Pillen, 44-px-Zeilen) mit Quelltext-Prüfung" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 8: Bilder für „Entdecken“ aus Testdaten
Files:
- Create:
public/neu/bilder/{news,termine,shop,unterkunft,zeiten}.webp - Create (Werkzeug, eigenes Git):
$WZ/fixtures/endpunkte-p3.mjs(+ ein Import infixtures/endpunkte.mjs, Einträge invertrag-endpunkte.mjsalsNICHT_PRUEFBARmit dem Grund „nur Bildquelle“),$WZ/bilder/erzeuge.mjs,$WZ/bilder/seiten/{news,termine,shop,unterkunft,zeiten}.html - Test:
test/knFunktionenStatik.test.js(Bild-Test)
Interfaces:
- Consumes: die echten Einbettungen
public/embed/{news,termine,shop,unterkunft,zeiten}.jsmit den Wegen/api/public/news/<siteKey>(embed/news.js:168),/api/public/termine/v1/(termine.js:12),/api/public/shop/v1/(shop.js:11),/api/public/unterkunft/v1/(unterkunft.js:13),/api/public/zeiten/v1/<siteKey>(zeiten.js:45) und den Server-Gegenstücken (server/index.js:77,79,81,94,95); das Werkzeug aus P0:run-browser.sh <skript.mjs> [args…],browser.mjs(starteBrowser),statik-server.mjs(starteStatik),backend.mjs(createBackend),fixtures/index.mjs. - Produces: fünf Dateien, 960×600, WebP, je höchstens 122880 Byte, benannt nach
katalog[].bild. Skript:bash $WZ/run-browser.sh $WZ/bilder/erzeuge.mjs [--ziel <Ordner>] [key …].analyse.webpentsteht erst mit P4 (VF2); das Skript meldetübersprungen analyse, solange P4 keine Werkzeug-Ansichtkn-modul-analyseliefert.
Bildquellen (Testdaten „Bäckerei Sonnenschein“, keine Fremdbilder): je Funktion eine eigene schlichte Beispiel-Website-Seite (bilder/seiten/<key>.html, nur eigenes HTML und CSS, keine externen Dateien) mit der echten Einbettung aus public/embed/<key>.js und dem Platzhalter-siteKey aus public/embed/<key>-demo.html. Die öffentlichen Endpunkte beantwortet das Fake-Backend des Werkzeugs (endpunkte-p3.mjs, Fall betrieb-wartung); die Antwortformen stammen aus server/routes/publicNews.js:67, publicTermine.js, publicShop.js, publicUnterkunft.js, publicZeiten.js:69 und den Einbettungs-Skripten, plausibel gefüllt (drei Pinnwand-Zettel, freie Termine eines Tages, sechs Artikel, zwei Unterkünfte mit Belegung, Öffnungszeiten mit Status).
- Step 1: Failing Test ergänzen in
test/knFunktionenStatik.test.js(die zwei Importzeilen oben einsortieren)
import { statSync, readdirSync } from 'node:fs'
import sharp from 'sharp'
test('Bilder: fünf webp-Dateien, 960×600, höchstens 120 KB; analyse.webp, falls da, ebenso', async () => {
const PFLICHT = ['news', 'termine', 'shop', 'unterkunft', 'zeiten']
const ordner = new URL('../public/neu/bilder/', import.meta.url)
const da = readdirSync(ordner).sort()
for (const d of da) assert.ok([...PFLICHT, 'analyse'].map((k) => `${k}.webp`).includes(d), `unerwartete Datei ${d}`)
for (const key of PFLICHT) assert.ok(da.includes(`${key}.webp`), `${key}.webp fehlt`)
for (const d of da) {
const pfad = new URL(d, ordner).pathname
const meta = await sharp(pfad).metadata()
assert.deepEqual([meta.format, meta.width, meta.height], ['webp', 960, 600], d)
assert.ok(statSync(pfad).size <= 122880, `${d} größer als 120 KB`)
}
})
-
Step 2: Fehlschlag prüfen. Run:
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knFunktionenStatik.test.js. Expected: FAIL (ENOENTfürpublic/neu/bilder/). -
Step 3: Werkzeug-Teile anlegen (Dateien im Werkzeug, nicht im Worktree)
-
fixtures/endpunkte-p3.mjs:ENDPUNKTE-Einträge (Form laut P0 Task 6) für die fünf öffentlichen Endpunkt-Gruppen; unbekannte/api/*bleiben 404 mit Verstoß. -
bilder/seiten/<key>.html: Seite „Bäckerei Sonnenschein“ (Kopf mit Name, ein Absatz Text, darunter die Einbettung), ruhige Akzentfarbe, keine Fremdschriften. -
bilder/erzeuge.mjs: startet überstarteStatik({ wurzel: process.env.WT + '/public' })(env.shdes Werkzeugs exportiertWT) und genau einenstarteBrowser()je Schlüssel einen Kontext 1200×750, Gerätefaktor 1,reducedMotion: 'reduce',setFixedTime;route('**/*')wie inshots.mjs(same-origin/api/anscreateBackend,/bilder-seite/<key>.htmlausbilder/seiten/, Fremdesabort). Wartet, bis der Ausweichtext der Seite (#ausweich-inline,.static-empty,.ersatz,#ausweich-shop) verschwunden ist, sonst Abbruch mit Meldung (die Einbettung hat keine Daten bekommen). PNG-Screenshot (niefullPage), mitsharp(Import ausprocess.env.WT + '/node_modules/sharp/lib/index.js') auf Breite 960 skalieren, als WebP (Qualität 80) schreiben; Größe über 122880 Byte → Abbruch. Schreibt nur<key>.webpfür die festen Schlüssel in den Ordner--ziel, derbilderheißen und existieren muss; löscht nichts. -
Step 4: Bilder erzeugen und ansehen
Run:
WZ=/root/reports/kundenansicht-neu-2026-10-15/werkzeug
export TMPDIR=<eigener Scratch-Ordner>
free -m
mkdir -p /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui/public/neu/bilder
bash "$WZ/run-browser.sh" "$WZ/bilder/erzeuge.mjs" --ziel /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui/public/neu/bilder
ls -l /dev/null
Expected: fünf Zeilen ok <key>.webp <n> KB und eine Zeile übersprungen analyse, Exit 0; /dev/null zeigt crw-rw-rw-. Danach jede der fünf Dateien mit dem Lesewerkzeug öffnen: Funktion deutlich sichtbar, keine Fehlermeldung, kein Ausweichtext, keine Platzhaltertexte, keine echten Kundendaten.
- Step 5: Test laufen lassen und committen. Run:
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knFunktionenStatik.test.js. Expected: PASS.
WZ=/root/reports/kundenansicht-neu-2026-10-15/werkzeug
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/bilder/news.webp public/neu/bilder/termine.webp public/neu/bilder/shop.webp public/neu/bilder/unterkunft.webp public/neu/bilder/zeiten.webp test/knFunktionenStatik.test.js && git commit -m "Funktionen neu: Bilder für Entdecken aus Testdaten (Einbettungen auf Beispiel-Website)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
git -C "$WZ" add fixtures/endpunkte-p3.mjs fixtures/endpunkte.mjs vertrag-endpunkte.mjs bilder/erzeuge.mjs bilder/seiten/news.html bilder/seiten/termine.html bilder/seiten/shop.html bilder/seiten/unterkunft.html bilder/seiten/zeiten.html && git -C "$WZ" commit -m "Werkzeug: Bildquellen und Skript für die Funktionen-Bilder" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 9: Werkzeug-Ansichten und Gate p3 für den Funktionen-Bereich
Files:
- Modify (Werkzeug-Git):
$WZ/views.mjs(Ansichten),$WZ/gates.mjs(GATES.p3),$WZ/fixtures/endpunkte-p3.mjs(POST /api/funktionen/:key/einschalten);$WZ/strang.env(ungetrackt) zeigt aufdev-kn-ui - Modify (bei Befund):
public/neu/funktionen.css,public/neu/ansicht-funktionen.js,public/neu/funktion-einschalten.js
Interfaces:
- Consumes: Werkzeug aus P0:
ANSICHTEN-Einträge{ id, fall, adresse, geraete, warte, schritte?(seite) },GATES,ALWAYS_ON,SEITEN_PRUEFUNGEN = ['dark-surface', 'low-contrast', 'overflow-x', 'lila'], Läufershots.sh <ausgabe> --only <ids> --gate <name> --geraete <liste>, die Fällebetrieb-wartung,funktionen-alle,mehrere-websites,hosting,ohne-abo,privat-frist;public/neu/dashboard.htmlaus P1. - Produces: die Ansichten der Tabelle (Geräte
handy,tablet,desktop;reducedMotionist die Vorgabe) undGATES.p3 = { checks: [...ALWAYS_ON, ...SEITEN_PRUEFUNGEN], ansichten: ['kn-funktionen-*'] }. Der EndpunktPOST /api/funktionen/:key/einschaltensetzt das Feature der Website imzustandaufsetupmitticketWoid: '100301'und antwortet{ feature, ticketWoid }.
| id | Fall und Adresse | warte / Schritte |
Zeigt |
|---|---|---|---|
kn-funktionen-meine |
funktionen-alle, /dashboard.html#funktionen |
li[data-fn] |
alle Zustände, zwei Websites, Pillen |
kn-funktionen-entdecken |
betrieb-wartung, #entdecken |
section[data-fn] |
Einträge mit Bild und Knopf |
kn-funktionen-entdecken-hosting |
hosting, #entdecken |
Schritt: Tipp auf den Knopf des Terminplaners | „Im Wartungs-Abo enthalten“, danach Hash #wechsel |
kn-funktionen-entdecken-ohne-abo |
ohne-abo, #entdecken |
Schritt: Tipp auf den Knopf von „Öffnungszeiten live“ | Hash #bestellen-wartung |
kn-funktionen-entdecken-privat |
privat-frist, #entdecken |
section[data-fn="shop"] |
Webshop-Hinweis statt Knopf |
kn-funktionen-wahl |
mehrere-websites, #entdecken |
Schritt: Tipp auf den ersten Knopf „Einschalten“ | [role=group] „Für welche Website?“ |
kn-funktionen-einschalten |
betrieb-wartung, #entdecken |
Schritt: Tipp auf „Öffnungszeiten live“ | Bestätigung ohne Häkchen; page.goBack() zeigt wieder Entdecken |
kn-funktionen-einschalten-privat |
privat-frist, #entdecken |
Schritt wie oben | Häkchen, Knopf „Einschalten ab TT.MM.“ |
kn-funktionen-einschalten-fertig |
betrieb-wartung, #entdecken |
Schritt: Bestätigung, Tipp auf „Einschalten“ | [role=status] „… ist eingeschaltet …“, in Meine die Zeile mit „In Einrichtung“ |
kn-funktionen-etage |
betrieb-wartung, #funktionen |
Schritt: Tipp auf die Zeile „News-Pinnwand“ | Hash beginnt mit #news?projekt=, #kn-blatt sichtbar (die Hülle von P1 öffnet die Etage; die Optik des Moduls prüft P4) |
-
Step 1: Werkzeug lesen. Run:
head -60 /root/reports/kundenansicht-neu-2026-10-15/werkzeug/README.md; git -C /root/reports/kundenansicht-neu-2026-10-15/werkzeug status --short; cat /root/reports/kundenansicht-neu-2026-10-15/werkzeug/strang.env. Expected: Anleitung von P0, Git sauber,strang.envsetztWTaufdev-kn-ui(sonst anlegen).export TMPDIR=<eigener Scratch-Ordner>setzen (Sicherheitsblock). Weicht das README vom Plan ab, gilt das README; Befund dem Controller melden. -
Step 2: Ansichten, Gate und Endpunkt schreiben (Tabelle oben; die
schritteprüfen mitassertdie Spalte „Zeigt“, Vorbildkn-huelle-*aus P1 Task 13) -
Step 3: Erster Lauf (rot ist möglich) und Sichtprüfung
Run:
WZ=/root/reports/kundenansicht-neu-2026-10-15/werkzeug
export TMPDIR=<eigener Scratch-Ordner>
free -m
bash "$WZ/shots.sh" "$TMPDIR/kn-p3" --gate p3 --only 'kn-funktionen-*'
Expected: Bericht in $TMPDIR/kn-p3/report.json. Danach die Aufnahmen für Handy, Tablet und Desktop mit dem Lesewerkzeug ansehen und gegen die Spalte „Zeigt“ prüfen: keine Zeile unter 44 px Höhe, Pille und Name ohne Abschneiden, Entdecken ohne Rahmen und ohne Schatten, Bild im Verhältnis 16:10, Bestätigung ohne Häkchen beim Betrieb.
-
Step 4: Korrigieren und Tests wiederholen. Befunde im Worktree beheben (CSS oder Module). 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/knEinschaltenLogik.test.js test/knFunktionenModell.test.js test/knEinschaltenSeite.test.js test/knFunktionenAnsicht.test.js test/knFunktionenStatik.test.js. Expected: PASS. -
Step 5: Gate grün, Commit. Run:
export TMPDIR=<eigener Scratch-Ordner>; bash /root/reports/kundenansicht-neu-2026-10-15/werkzeug/shots.sh "$TMPDIR/kn-p3" --gate p3 --only 'kn-funktionen-*'; ls -l /dev/null. Expected: Exit 0, keine Verstöße;/dev/nullzeigtcrw-rw-rw-.
WZ=/root/reports/kundenansicht-neu-2026-10-15/werkzeug
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git status --short
git add public/neu/funktionen.css && git commit -m "Funktionen neu: Korrekturen aus den Gates (Handy, Tablet, Desktop)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
git -C "$WZ" add views.mjs gates.mjs fixtures/endpunkte-p3.mjs && git -C "$WZ" commit -m "Werkzeug: Ansichten und Gate p3 für den Funktionen-Bereich" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
(Nur die in diesem Task wirklich geänderten Worktree-Dateien einzeln hinzufügen; gab es keine Korrekturen, entfällt der erste Commit.)
Task 10: Selbstprüfung
Files:
-
keine Änderung (nur Prüfung); Tag
kn-p3 -
Step 1: Alle eigenen Tests grün. Run (einzeln, damit ein Fehler eindeutig zuzuordnen ist):
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui
for t in knEinschaltenLogik knFunktionenModell knEinschaltenSeite knFunktionenAnsicht knFunktionenStatik; do
timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/$t.test.js || break
done
Expected: fünfmal PASS, keine übersprungenen Tests.
- Step 2: Gegenprobe an den Nachbarn (Quelltext-Pins über
public/, Stubs, Hülle). Run:
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui
timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/adminPanelGone.test.js test/termineView.test.js test/shopView.test.js test/shopWiring.test.js test/zeitenView.test.js test/knHuelleSeite.test.js test/knHuelleStart.test.js
Expected: PASS. Schlägt eine Datei fehl, liegt es an einer Datei in public/neu/ (etwa einer verbotenen Zeichenkette aus adminPanelGone): in P3 beheben, den Test nicht ändern.
-
Step 3: Verträge abhaken (jeweils mit Fundstelle im Code oder Test)
-
V2:
zeigeFunktionen(wurzel, { route, me, nav })(keinzaehler;command grep -a -n "zaehler" public/neu/ansicht-funktionen.jsbleibt leer); Ziel#<etage>?projekt=<id>(Test „Meine“); die Etage selbst öffnet P1 (Werkzeug-Ansichtkn-funktionen-etage) -
V4: leere oder unbekannte Kundenart gilt als privat; der Client sendet nie
kundenart(Tests „beginnLage“, „einschaltKoerper“, „Häkchen nie für Betrieb“) -
V5: Katalogreihenfolge,
verfuegbar:falseausgeblendet,modell:'abo', alle sieben 409-Codes (Test „fehlerAktion“), Körper{ projectId, zustimmung: { version, haken } }, Analyse nie über…/einschalten -
V11:
sessionStorage['kn-wechsel-funktion']mit{ key, projectId }in try/catch, danach#wechsel;subscription.wechselFunktionöffnet den Ablauf (Tests „navigiere“, „wechselFunktion“) -
Spec 5.3: Zeile „Rechnung offen“ oben in Meine und Entdecken, von der Ansicht selbst über
GET /api/invoicesgeladen (Test „Rechnungszeile oben“: Abruf, nurbalance > 0, Ausfall lässt die Liste stehen) -
Global Constraints: keine Preise, „du“, keine Karten und Verläufe (Statik-Test), Bildformat, Hashes unverändert
-
VF1:
command grep -a -n "einschalten" public/neu/huelle.jszeigt, ob die HüllezeigeEinschaltenals Init-Option übergibt; wenn nicht, dem Controller melden (Datei gehört P1) -
Vertragsfragen VF1 bis VF5 an den Controller übergeben
-
Step 4: Zustand prüfen. Run:
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui
git status --short
git diff --name-only kn-p2 HEAD
git -C /root/reports/kundenansicht-neu-2026-10-15/werkzeug status --short
ls -l /dev/null
Expected: git status leer (der Symlink node_modules ist ignoriert); git diff --name-only zeigt genau die Worktree-Dateien der Dateiübersicht (funktion-einschalten.js, ansicht-funktionen.js, funktionen.css, fünf Bilder, knFunktionenDaten.js, fünf Testdateien); das Werkzeug-git status ist leer (strang.env ist ignoriert); /dev/null zeigt crw-rw-rw-.
- Step 5: Tag setzen
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git tag kn-p3 && git log --oneline -12
Expected: Tag angelegt, das Log zeigt die Commits der Tasks 1 bis 9. Kein Push.
Vertragsfragen an den Controller
Die Pläne der anderen Pakete liegen vor und sind eingearbeitet. Offen bleibt:
- VF1 Verdrahtung des Einschalt-Ablaufs (P1, P4, P3). P4 erwartet
zeigeEinschalten(platz, { key, projectId, onFertig })als Init-OptioneinschaltenaninitTermine,initNews,initShop,initBuchungen,initZeiten(P4 VF1: „Die Hülle übergibt die Funktion“). P1 Task 11 ruft die Module aber nur alsinit*(wurzel)auf. P3 liefert die Funktion (Task 4) und darfhuelle.jsnicht ändern. Vorschlag: P1 ergänzt inhuelle.jsden Aufrufinit*(wurzel, { einschalten: (platz, o) => import('/neu/funktion-einschalten.js').then((m) => m.zeigeEinschalten(platz, o)) })(lazy, weil P1 vor P3 liegt). Fehlt das beim Merge, bleiben die Module im Abo-Modell auf der Fehlerkarte E4 aus P4. - VF2 Bild der Analyse (P4).
analyse.webpzeigt das Analyse-Modul, dessen Optik und Testdaten erst P4 liefert. Vorschlag: P4 legt die Werkzeug-Ansichtkn-modul-analysean, erzeugt die Datei miterzeuge.mjs analyseund committet nurpublic/neu/bilder/analyse.webp. Bis dahin entfernt dererror-Handler in „Entdecken“ das fehlende Bild (Test „fehlendes Bild“). Der Bild-Test von P3 akzeptiertanalyse.webp, verlangt sie aber nicht. - VF3 Werkzeug (P0). (a)
fixtures/endpunkte-p3.mjsergänztPOST /api/funktionen/:key/einschaltenund die fünf öffentlichen Bild-Endpunkte; das README von P0 sieht dieses Muster vor. (b)SEITEN_PRUEFUNGENheißt in P0dark-surface,low-contrast,overflow-x,lila; P1 nimmt zusätzlichbad-textundicon-svgan. P3 folgt P0. (c)strang.envmuss aufdev-kn-uizeigen. - VF4 Weitere Antwortcodes der Einschalt-Route (P5). P5 nennt zusätzlich
bestellung_laeuft,abgeschaltet, 400zustimmung_fehlt, 503unterlagen_fehlen. P3 zeigt dazu den Text des Servers (Test „fehlerAktion“). Bitte in V5 aufnehmen (P5 VF3 fragt dasselbe). - VF5 Overlay (P1) und Rechnungszeile (P2). P3 nutzt
oeffneOverlay/beendeOverlayausrouter.jsfür die Bestätigungsseite (P1 Task 4, R6, dort für das Konto-Sheet gedacht). Bitte bestätigen, dassoeffneOverlayaus Ansichten heraus gerufen werden darf und nie zwei Overlays zugleich offen sind. Die Zeile „Rechnung offen“ braucht von P1 nichts und V2 bleibt unverändert: Die Ansicht lädtGET /api/invoicesselbst und nutztoffeneRechnungenundrechnungOffenZeileaus P2 Task 3 (wie P2 Task 6 für die Website).ladeOffeneRechnungen()bleibt ungenutzt, weil es keineapientgegennimmt; gibt P2 ihm einen optionalen ParameterladeOffeneRechnungen(apiFn = api), ersetzt P3ladeRechnungendurch einen Aufruf.