81 KiB
Öffnungszeiten live – Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Die Funktion „Öffnungszeiten live“ im Kundenportal: Zeitpläne im Portal pflegen, Live-Status/Tabelle/Hinweis-Leiste/Kontakt-Leiste/JSON-LD per Skript auf der Kundenseite, Bestellung (30 €) oder Einschalten im Wartungs-Abo, Abo-Kopplung, Feiertags-Erinnerung, WOMS-Anbindung und eine Station auf der Relaunch-Seite.
Architecture: Eigenes Modul server/services/zeiten/ auf dem Funktions-Register des Terminplaner-TP2-Branchs. Der Server rechnet alle Zeiten (Europe/Berlin, Feiertage) und liefert über eine öffentliche, gecachte GET-Schnittstelle fertige Texte und Wechselzeitpunkte; das abhängigkeitsfreie Skript public/embed/zeiten.js zeigt sie nur an. Der Preview-Runner setzt das Skript beim Deploy ein; das Team baut Platzhalter per WOMS-Ticket ein.
Tech Stack: Node 20 (Produktion; Host Node 24), Express, Vanilla-ES-Module ohne Build, Appwrite (node-appwrite), Stripe, node:test, Playwright-core (UI-Prüfungen), feiertagejs 1.5.1; WOMS: React 18 + Vite; Relaunch: React + Vite + Vitest 3.
Spec: docs/superpowers/specs/2026-10-04-oeffnungszeiten-live-design.md (Entscheidungen Z1–Z23). Anschluss-Fakten: /root/reports/funktionen-ultra-update-2026-10-04/plan-anschluesse.md.
Global Constraints
- Arbeitsorte: Portal
/home/webklar/apps/kundenbereich.webklar.com/dev-zeiten(Branchfeature/oeffnungszeiten); WOMS eigener Worktree/home/webklar/apps/ticket.webklar.com/dev-zeitenauffeature/terminplaner-tp2(Task 31); Relaunch eigener Worktree/home/webklar/apps/webklar.com/dev-zeiten-station(Task 34). Nie indev-termine(beide Repos) oderbuild/schreiben, nie pushen, niegit add -A(nur die genannten Dateien). - Produktion läuft Node 20: keine Syntax/APIs jenseits Node 20. Runner-Code nur Node-20-Syntax ohne Abhängigkeiten.
server/config.jsimportiert nichts statisch aus./services/(TestfeatureRegistry.test.jsprüft per Regex);siteFeatures.jsimportiertzeiten/*nur perawait import().- Sperren (
withLocks) sind nicht wiederbetretbar. Schlüssel:feature-order:<customerId>:<projectId>(Einschalten/Checkout),feature-admin:<projectId>(Team, Abgleich),zeiten:<projectId>(Speichern),zeiten-neubau(Neubau anstoßen). T19 (meldeErstattung) nie innerhalbfeature-admin:/feature-paid:. - Werte aus der Spec, wörtlich: Preis
3000Cent, Pläne ≤ 5, Fenster/Tag ≤ 2, Ausnahmen ≤ 100, Hinweise ≤ 20, Hinweistext ≤ 140, Plan-/Ausnahmetext ≤ 60, Knopftext ≤ 30, Starttext ≤ 200, Adresse ≤ 200, Telefon ≤ 30, Link ≤ 300,navSelektor≤ 120,daten≤ 32 KB, 60 Speichervorgänge/Stunde, APImax-age=60, Speichercache 30 s als LRU ≤ 500, 120 Anfragen/Minute je IP, Skript lädt ≤ alle 10 min neu, Daten > 24 h alt ⇒ aus, Uhrabweichung > 5 min ⇒ Serverzeit, Erinnerung 1–7 Tage vorher zwischen 08:00 und 20:00 Berlin, Frist 30 Tage + Datenfrist 30 Tage, Kontakt-Leiste nur(max-width: 768px)undz-index: 40, Tippflächen ≥ 44 px, Kontrast ≥ 4,5:1. - Texte: Kundentexte in Du-Form (Mails wie News: „Du“ groß), Rechtstexte in Sie-Form. Status-Texte ohne Anrede und nie „24 Uhr“. Die Schreibweise ist „WEBklar“.
- Website-Skript: keine Cookies, kein Storage, keine Anfragen an Dritte, kein
https://-Literal,Datenur alsDate.now(undDate.parse(, Kundentexte nur pertextContent. - Neue Dateien heißen
zeiten*(nietermine*); Teststest/zeiten<Thema>.test.js, Testnamen deutsch. Einzeltest:node --test test/<datei>.test.js; alles:npm test. Bekannt wackelig/rot in der Basis:test/previewDevRouter.test.js(flaky),test/analyticsContract.test.js(Node 20). - Commits: deutsche Betreffzeile, Abschluss
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>. - Abweichung von Spec 2.3 (bewusst): Die neuen
portalFeatures-Felder legtscripts/zeiten-setup.mjsan (nichtfeature-setup.mjs, dessen Tests genau 3 Attribute pinnen);test/support/schema.jshängt sie anschemaKeys('portalFeatures')an. - Abweichung von Spec 7 (bewusst): Der K02/K03-Block
funktionenist Rechtstext mit Pins; er wird nur als Entwurf für die Rechtsrunde geschrieben (Task 33), nicht im Code geändert.
Review Focus
- Feiertage über den Jahreswechsel: Die 12-Monats-Liste im Editor und die 60-Tage-Auflösung müssen Feiertage aus zwei Kalenderjahren liefern (Ende Dezember bis Anfang Januar). → Test in Task 3 und Task 4.
- Telefonnummern in allen üblichen Schreibweisen: „0171 1234567“, „+49 171 1234567“, „0049171…“, „(06 31) 12 34“ müssen dieselbe
tel:/wa.me-Nummer ergeben; Buchstaben werden abgelehnt. → Test in Task 8. - Kontakt-Leiste mit Teildaten: Nur Telefon ⇒ ein Knopf; nur Adresse ⇒ nur Route;
an: trueohne jede Angabe ⇒ keine Leiste (kein leerer Balken). → Test in Task 11 (Antwort) und Task 26 (Skript). - Plan löschen, der auf der Website steht: Ein gelöschter Plan lässt Platzhalter mit seinem Kurznamen beim Ersatz-HTML stehen; der Editor warnt vor dem Löschen („Steht dieser Plan auf deiner Website, zeigt sie dort wieder die feste Tabelle“). → Test in Task 29.
- Zu viele Daten: Überschreitet
daten32 KB, kommt eine verständliche Feldmeldung statt eines 500/413. → Test in Task 8.
Block A – Grundlagen
Task 1: Arbeitsumgebung, Schalter, Collections und Felder
Files:
- Create:
server/services/zeiten/schema.js,server/services/zeiten/schalter.js,scripts/zeiten-setup.mjs - Modify:
server/config.js(collections, neuer Blockzeiten,zeitenSetup),test/support/schema.js - Test:
test/zeitenSetup.test.js,test/zeitenSchalter.test.js
Interfaces:
-
Produces:
ZEITEN_COLLECTIONS({ oeffnungszeiten: {attributes, indexes}, zeitenErinnerungen: {...} }im Format vonserver/services/termine/schema.js),ZEITEN_FEATURE_ATTRIBUTES(Attributliste fürportalFeatures),zeitenModus() → 'off'|'pilot'|'on',zeitenAus() → boolean,zeitenFuerKunde(email) → boolean;config.zeiten = { mode, pilotEmails, keinNeubau },config.zeitenSetup,config.collections.oeffnungszeiten,config.collections.zeitenErinnerungen;schemaKeys('oeffnungszeiten' | 'zeitenErinnerungen'). -
Step 1: Abhängigkeiten installieren und Basislauf festhalten
Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-zeiten && free -m && nice npm ci && (cd runner && npm test) && nice npm test 2>&1 | tail -5
Expected: Installation ok; Basis grün bis auf die zwei bekannten Kandidaten (siehe Global Constraints). Abweichungen im Ledger notieren.
- Step 2: Failing tests schreiben
// test/zeitenSchalter.test.js (Muster: test/termineSchalter.test.js, freshConfig)
test('Vorgabe ist aus', …) // ZEITEN_ENABLED leer ⇒ zeitenModus()==='off', zeitenAus()===true
test('pilot gilt nur für die Pilotliste', …) // mode 'pilot', pilotEmails ['a@b.de'] ⇒ zeitenFuerKunde('A@b.de')===true, ('x@y.de')===false
test('on gilt für alle', …)
test('keinNeubau kommt aus ZEITEN_KEIN_NEUBAU, Vorgabe emmas-schatzkiste', …) // deepEqual(config.zeiten.keinNeubau, ['emmas-schatzkiste'])
test('zeitenSetup: 3000 Cent einmalig', …) // config.zeitenSetup.amountCents===3000, oneTime===true, lookupKey==='webklar_zeiten_einrichtung_3000'
// test/zeitenSetup.test.js (Muster: test/termineSetup.test.js)
test('oeffnungszeiten hat die Felder aus Spec 2.1', …) // keys ⊇ customerId, projectId, featureId, version, daten, teamVersion, team, ersatzHash, updatedAt, updatedBy
test('daten fasst 32 KB JSON', …) // Größe von daten ≥ 40000
test('zeitenErinnerungen: featureId, datum, createdAt', …)
test('portalFeatures bekommt unlockedBy(8), aboEndeAt, einbau(12), behaltenSessionId(128), k24At', …)
test('--check meldet fehlende Collections und Felder, ohne zu schreiben', …)
test('schemaKeys liefert die Zeiten-Collections und die neuen portalFeatures-Felder', …)
-
Step 3: Run → FAIL (
node --test test/zeitenSchalter.test.js test/zeitenSetup.test.js, Module fehlen) -
Step 4: Implementieren
config.js:zeitenSetup = { key:'zeitenSetup', name:'Öffnungszeiten live – Einrichtung', amountCents:3000, lookupKey:'webklar_zeiten_einrichtung_3000', oneTime:true, priceNickname:'Öffnungszeiten-Einrichtung 30 €' }(exportiert wietermineSetup);zeiten: { mode, pilotEmails, keinNeubau }ausZEITEN_ENABLED,ZEITEN_PILOT_EMAILS,ZEITEN_KEIN_NEUBAU(Komma-Liste, Vorgabe'emmas-schatzkiste'), als Geschwister vonconfig.termine(nicht darin:termineSchalter.test.jspinntconfig.termine);collections.oeffnungszeiten/zeitenErinnerungenmitAPPWRITE_COLLECTION_…-Variablen.schalter.js: Kopie vontermine/schalter.jsaufconfig.zeiten.schema.js: reine Daten, keinconfig-Import;datenString 40000,teamString 2000,ersatzHashString 64,updatedByString 64,version/teamVersionInteger; Indexidx_customeraufcustomerId(Löschung), Erinnerungen Indexidx_datum.zeiten-setup.mjs: Klon vonscripts/termine-setup.mjs(main({check, client, log})), legt beide Collections undZEITEN_FEATURE_ATTRIBUTESanportalFeaturesan (idempotent).test/support/schema.js: Zweig fürZEITEN_COLLECTIONS; beiportalFeatureszusätzlichZEITEN_FEATURE_ATTRIBUTES.
-
Step 5: Run → PASS, dann
npm test(Pins unverändert grün). -
Step 6: Commit
git add server/services/zeiten/schema.js server/services/zeiten/schalter.js scripts/zeiten-setup.mjs server/config.js test/support/schema.js test/zeitenSetup.test.js test/zeitenSchalter.test.js && git commit -m "Öffnungszeiten: Schalter, Collections und Felder"
Task 2: Register-Eintrag zeiten und Stripe-Katalog
Files:
- Modify:
server/config.js(features.zeiten),server/services/featureRegistry.js(STANDARD_HAKEN.zeiten,freiweg),server/services/siteFeatures.js(FEATURE_KEYS),server/services/stripeBilling.js(PRODUCT_METADATA,catalogFor),scripts/stripe-products.mjs(KEYS,catalogEntry) - Create:
server/services/zeiten/ticketDetails.js(vorläufig, ausgebaut in Task 14) - Test:
test/zeitenRegister.test.js; Pins anpassen intest/featureRegistry.test.js,test/siteFeatures.test.js,test/featureFreischaltung.test.js,test/funktionenTermine.test.js,test/funktionenRoutes.test.js,test/stripeProductsScript.test.js,test/featureMails.test.js(läuft automatisch über alle Einträge)
Interfaces:
-
Consumes:
zeitenFuerKunde(Task 1). -
Produces:
featureConfig('zeiten')mit den Feldern unten;freiweg(feature) → { ...feature, consent: feature.consentFrei };STANDARD_HAKEN.zeiten = { verfuegbar, unterlagenBereit, belehrung, freischaltbar }(freischaltbar zunächstasync () => true, echt in Task 17);zeitenTicketDetails(ctx) → Promise<string>. -
Step 1: Failing tests schreiben
// test/zeitenRegister.test.js
test('zeiten steht im Register mit 30 € und Abo-Inklusive', …)
// const f = featureConfig('zeiten'); f.product==='zeitenSetup'; f.priceLabel==='30 €'; f.requiresWartungsAbo===false;
// f.aboInklusive===true; f.ticketFreischaltung===true; deepEqual(f.teilweiseLive, ['setup','active']); f.returnHash==='zeiten'; f.returnProjekt===true
test('Zustimmung bezahlt: zwei Haken, Knopf „Zahlungspflichtig bestellen · 30 €“, § 356 Abs. 5 Nr. 2', …)
test('Zustimmung gratis: ein Haken, Knopf „Einschalten“, kein „zahlungspflichtig“', …) // doesNotMatch(JSON.stringify(f.consentFrei), /zahlungspflichtig/i)
test('freiweg ersetzt nur consent', …) // freiweg(f).consent === f.consentFrei; freiweg(f).product === f.product
test('verfuegbar folgt ZEITEN_ENABLED', …) // mode 'off' ⇒ featureSperre('zeiten','a@b.de') === f.unavailableMessage; mode 'on' ⇒ null
test('oeffentlicheAnsicht(zeiten) hat dieselben Schlüssel wie news', …) // kein aboInklusive/consentFrei darin
test('Stripe-Katalog: zeitenSetup mit eigener Metadaten-Marke', …) // PRODUCT_METADATA.zeitenSetup === 'zeiten-einrichtung'
- Step 2: Run → FAIL
- Step 3: Implementieren – Eintrag in
config.featuresnachtermine(Feldsatz wienews, Werte):label: { name:'Öffnungszeiten live', kurz:'Zeiten', keine:'keine Öffnungszeiten live' },setupEstimate:'meist innerhalb weniger Tage'.consent: { version:'2026-10-04', knopf:'Zahlungspflichtig bestellen · 30 €', items:[{ id:'leistung', haken:true, text:'Ich habe die Leistungsbeschreibung „Öffnungszeiten live“ gelesen und bestelle die Einrichtung.' }, { id:'beginn', haken:true, text:'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit vollständiger Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB).' }] }.consentFrei: { version:'2026-10-04', knopf:'Einschalten', items:[{ id:'leistung', haken:true, text:'Ich habe die Leistungsbeschreibung „Öffnungszeiten live“ gelesen und schalte die Funktion ein. Sie ist in meinem Wartungs-Abo ohne Aufpreis enthalten.' }] }.leistungsbeschreibung: { version:'2026-10-04', url:'/vertragsunterlagen/zeiten-leistungsbeschreibung.html' },avv:null,widerruf: wie beimtermine-Eintrag.ticket.topic: (project) => \Öffnungszeiten live einbauen – ${ticketProjektName(project)}`.trim(),ticket.details: async (ctx) => (await import('./services/zeiten/ticketDetails.js')).zeitenTicketDetails(ctx)`.mails: { funktion:'Öffnungszeiten live', formen:{ dein:'Deine Öffnungszeiten live', nom:'Öffnungszeiten live', akk:'Öffnungszeiten live', gen:'von Öffnungszeiten live', pron:'sie' }, knopf:'Zeiten bearbeiten', danach:'Danach kannst Du Deine Zeiten im Portal eintragen.', bereit:'Deine Öffnungszeiten stehen ab sofort live auf Deiner Seite.', preisHinweis:'einmalig, ohne Umsatzsteuer nach § 19 UStG; die Nutzung ist kostenlos, es läuft kein Abo', anleitung:false }.disabledMessage:'Öffnungszeiten live wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du die Funktion wieder möchtest.',orderedConflictMessage:'Öffnungszeiten live ist für dieses Projekt schon bestellt oder eingeschaltet.',unavailableMessage:'Öffnungszeiten live ist noch nicht verfügbar.'.FEATURE_KEYS+='zeiten'; Stripe:PRODUCT_METADATA.zeitenSetup = 'zeiten-einrichtung',catalogFor/catalogEntry/KEYSergänzen.ticketDetails.js: vorläufigminimaleTicketDetails(ctx)ausfeatureRegistry.jszurückgeben.
- Step 4: Pins anpassen (Schlüssellisten um
'zeiten'; Zählungen), Runnpm test→ PASS. - Step 5: Commit
"Öffnungszeiten: Register-Eintrag und Stripe-Katalog"
Block B – Zeitlogik
Task 3: Feiertage mit Namen und „bitte prüfen“
Files:
- Modify:
server/services/termine/feiertage.js(nur neue Exporte) - Test:
test/zeitenFeiertage.test.js
Interfaces:
-
Produces:
feiertagName(tag, land) → string|null(wirft nie);feiertageImZeitraum(von, bis, land) → [{ tag, name, art: 'feiertag'|'pruefen' }](inklusive Grenzen, sortiert, über Jahresgrenzen). -
Step 1: Failing tests
test('Tag der Deutschen Einheit hat einen Namen', …) // feiertagName('2026-10-03','RP') === 'Tag der Deutschen Einheit'
test('normaler Tag hat keinen Namen', …) // feiertagName('2026-10-05','RP') === null
test('Heiligabend und Silvester sind „pruefen“', …) // feiertageImZeitraum('2026-12-20','2027-01-02','RP') enthält {tag:'2026-12-24',art:'pruefen'}, {tag:'2026-12-31',art:'pruefen'}, {tag:'2027-01-01',art:'feiertag'}
test('Jahreswechsel: beide Jahre werden geliefert', …) // tags sortiert, enthält 2026-12-25, 2026-12-26 und 2027-01-01
test('Mariä Himmelfahrt in BY ist „pruefen“, im SL „feiertag“', …) // '2028-08-15': BY ⇒ pruefen, SL ⇒ feiertag
test('Fronleichnam in SN und TH ist „pruefen“ (Datum aus RP)', …) // '2026-06-04' in SN ⇒ {art:'pruefen'}, RP ⇒ 'feiertag'
test('ungültiges Land wirft nicht', …) // feiertagName('2026-10-03','XX') === null
- Step 2: Run → FAIL
- Step 3: Implementieren über
feiertagejs.getHolidays(jahr, land)je Jahr im Zeitraum (nichtistFeiertagje Tag), Namen perholiday.translate('de'), Datum perholiday.dateString. „pruefen“: 24.12., 31.12.,MARIAHIMMELFAHRTnur für BY, Fronleichnam für SN/TH aus der RP-Liste. Ein „pruefen“-Tag, der zugleich landesweiter Feiertag ist, bleibtfeiertag. - Step 4: Run → PASS,
node --test test/termine*.test.jsunverändert grün. - Step 5: Commit
"Feiertage: Namen und Prüftage im Zeitraum"
Task 4: Zeitlogik I – Tagesbild und Öffnungsintervalle
Files:
- Create:
server/services/zeiten/zeitlogik.js - Test:
test/zeitenZeitlogik.test.js
Interfaces:
-
Consumes:
termine/zeit.js(zuUtc,ausUtc,wochentag,plusTage,heute), Task 3. -
Produces (alle rein,
nowimmer als Parameter):hauptplan(daten) → plan|null;planNachKurz(daten, kurz) → plan|nullzuUtcOderDanach(tag, hhmm) → Date(März-Lücke ⇒ erste existierende Minute danach, 03:00;'24:00'⇒ Folgetag 00:00)tagesbild(daten, plan, tag) → { tag, art: 'regulaer'|'feiertag'|'urlaub'|'sondertag', name: string|null, modus, fenster }oeffnungsIntervalle(daten, plan, vonTag, bisTag) → [{ vonMs, bisMs }](zusammengefügt; enthält das invonTaghineinlaufende Fenster des Vortags)
-
Step 1: Failing tests (Fixture-Helfer
plan({1:['09:00','18:00'], …}),daten({land:'RP', plaene, ausnahmen, feiertage}))
test('Wochenplan an einem normalen Montag', …) // tagesbild(d,p,'2026-10-05') ⇒ {art:'regulaer', modus:'zeiten', fenster:[['09:00','18:00']]}
test('Feiertag ist zu, mit Namen', …) // '2026-10-03' RP ⇒ {art:'feiertag', name:'Tag der Deutschen Einheit', modus:'zu'}
test('Feiertags-Abweichung gilt', …) // daten.feiertage['2026-10-03']={modus:'zeiten',fenster:[['10:00','14:00']]} ⇒ modus 'zeiten'
test('Prüftag folgt dem Wochenplan', …) // '2026-12-24' (Do) ⇒ art 'regulaer', Fenster des Donnerstags
test('Urlaub schlägt Feiertag, Sondertag schlägt Urlaub', …)
test('planbezogene Ausnahme schlägt globale gleicher Art', …)
test('zwei überlappende Urlaube: der später angelegte gilt', …)
test('Urlaub über den Jahreswechsel', …) // von '2026-12-28' bis '2027-01-03' ⇒ '2027-01-02' art 'urlaub'
test('Fenster über Mitternacht gehört zum Starttag', …) // Sa 18:00–02:00, So Feiertag ⇒ Intervall Sa 18:00 bis So 02:00 Berliner Zeit
test('rund um die Uhr an zwei Tagen wird ein Intervall', …) // Mo+Di 'rund' ⇒ ein Intervall Mo 00:00 bis Mi 00:00
test('März-Lücke rückt auf 03:00', …) // zuUtcOderDanach('2026-03-29','02:30').toISOString()==='2026-03-29T01:00:00.000Z'
test('Oktober: doppelte Stunde nimmt das erste Vorkommen', …) // zuUtcOderDanach('2026-10-25','02:30') === 00:30Z
test('nach Vereinbarung erzeugt kein Intervall', …)
- Step 2: Run → FAIL
- Step 3: Implementieren nach Spec 6 (Vorrang Sondertag > Urlaub > Feiertag > Wochenplan; planbezogen vor global; bei Gleichstand die später angelegte = höherer Index in
ausnahmen). Kalendertage nur mitplusTage, nie+86400000. - Step 4: Run → PASS
- Step 5: Commit
"Öffnungszeiten: Tagesbild und Öffnungsintervalle"
Task 5: Zeitlogik II – Status, Texte und Wechsel
Files:
- Modify:
server/services/zeiten/zeitlogik.js - Test:
test/zeitenZeitlogik.test.js(erweitern)
Interfaces:
-
Produces:
uhrText(hhmm) → string;statusZu(daten, plan, now) → { offen: true|false|null, text, kurz };wechselListe(daten, plan, now) → [{ ab, tag, offen, text, kurz }](8 Tage; erster Eintragab= Beginn der aktuellen Minute; ein Eintrag an jeder Berliner Mitternacht, an der sich der Text ändert;tag= Berliner Datum beiab);tageListe(daten, plan, vonTag, anzahl) → [{ datum, art, name, modus, fenster }]. -
Step 1: Failing tests (
JETZT = new Date('2026-10-05T08:00:00.000Z')= Mo 10:00)
test('uhrText', …) // '09:00'⇒'9 Uhr', '18:30'⇒'18:30 Uhr', '00:00'⇒'Mitternacht'
test('offen: schließt um 18 Uhr', …) // text==='Jetzt geöffnet · schließt um 18 Uhr', kurz==='Geöffnet', offen===true
test('Schluss um Mitternacht', …) // Fenster 18:00–00:00 ⇒ '… schließt um Mitternacht'
test('Schluss > 24 h: „bis Dienstag 14 Uhr“', …)
test('7 Tage durchgehend: „Rund um die Uhr geöffnet“', …)
test('zu: heute / morgen / Wochentag / Datum', …) // '… öffnet heute um 14 Uhr', 'morgen um 9 Uhr', 'Montag um 9 Uhr', 'am 18.08. um 9 Uhr'
test('keine Öffnung in 60 Tagen: „Derzeit geschlossen“', …)
test('Grund an geschlossenen Tagen', …) // 'Heute geschlossen (Betriebsurlaub bis 17.08.)', '(Feiertag: Tag der Deutschen Einheit)'
test('nach Vereinbarung', …) // {offen:null, text:'Heute nach Vereinbarung', kurz:'Nach Vereinbarung'}
test('nie „24 Uhr“ in irgendeinem Text', …) // für jede Viertelstunde einer Woche mit rund/24h-Plänen: doesNotMatch(text, /24 Uhr/)
test('Fenster 18–02: um 01:00 Folgetag offen', …)
test('wechselListe: erster Eintrag ist jetzt, danach Wechsel um 18:00', …)
test('wechselListe: Mitternachtseintrag wechselt „morgen“ zu „heute“', …)
test('wechselListe hat tag (Berliner Datum)', …)
test('Sommerzeit 25.10.: Fenster 02:00–06:00, Text „schließt um 6 Uhr“', …)
- Step 2: Run → FAIL
- Step 3: Implementieren (Texte exakt nach Spec 6, Wochentage deutsch, Datum
TT.MM.). - Step 4: Run → PASS
- Step 5: Commit
"Öffnungszeiten: Live-Status, Texte und Wechselzeitpunkte"
Task 6: Zeitlogik III – Hinweise, JSON-LD, Google-Text, Erinnerungstage
Files:
- Modify:
server/services/zeiten/zeitlogik.js - Test:
test/zeitenZeitlogik.test.js(erweitern)
Interfaces:
-
Produces:
HINWEIS_RANG = { wichtig:4, urlaub:3, aktion:2, info:1 };hinweisZeitraum(h) → { ab, bis }(ab=von00:00 Berlin,bis=bis24:00 Berlin, ms);gueltigeHinweise(daten, now) → [{ art, text, knopf, ab, bis, rang }](jetzt gültig oder Beginn in ≤ 8 Tagen).jsonLdFuer(daten, plan, now) → { openingHoursSpecification, specialOpeningHoursSpecification, neuerBlock }(neuerBlock= vollständiger{ '@context':'https://schema.org', '@type':'LocalBusiness', … }ohnename/address/url; die setzt das Skript).googleText(daten, plan, now) → string.erinnerungsTage(daten, now) → [{ tag, name, art }];imErinnerungsfenster(now) → boolean.
-
Step 1: Failing tests
test('Hinweis-Zeitraum in Berliner Zeit über die Zeitumstellung', …) // von '2026-10-24' bis '2026-10-25' ⇒ ab 2026-10-23T22:00Z, bis 2026-10-25T23:00Z
test('gueltigeHinweise: vergangene fehlen, künftige ≤ 8 Tage sind dabei, Rang gesetzt', …)
test('JSON-LD: Mitternachtsfenster mit closes < opens', …) // {dayOfWeek:'Saturday', opens:'18:00', closes:'02:00'}
test('JSON-LD: rund um die Uhr 00:00–23:59, nach Vereinbarung fehlt', …)
test('JSON-LD: Urlaub als Sonderzeit geschlossen 00:00–00:00 mit validFrom/validThrough', …)
test('Google-Text: Montag zuerst, „geschlossen“, Sonderzeiten danach', …) // beginnt mit 'Montag: 09:00–18:00'
test('Erinnerung: Feiertag in 1–7 Tagen, offener Wochentag', …) // JETZT Mo 28.09. ⇒ [{tag:'2026-10-03', art:'feiertag'}] nur wenn Samstag offen
test('Erinnerung: nicht bei geschlossenem Tag, Ausnahme oder gespeicherter Abweichung', …)
test('Erinnerung: Heiligabend als „pruefen“', …)
test('Erinnerungsfenster 08:00–20:00 Berlin', …) // 07:59 ⇒ false, 08:00 ⇒ true, 19:59 ⇒ true, 20:00 ⇒ false
- Step 2: Run → FAIL - [ ] Step 3: Implementieren - [ ] Step 4: Run → PASS
- Step 5: Commit
"Öffnungszeiten: Hinweise, JSON-LD, Google-Text und Erinnerungstage"
Task 7: Ersatz-HTML und Wochenplan-Hash
Files:
- Create:
server/services/zeiten/ersatz.js - Test:
test/zeitenErsatz.test.js
Interfaces:
-
Consumes:
hauptplan,planNachKurz(Task 4). -
Produces:
ersatzHtml(daten, plan) → string(schlichte<table class="wkz-ersatz">mit<caption>= Planname, Zeilen Montag–Sonntag; nur ASCII, Umlaute/Gedankenstrich als Entitiesäöüß–);wochenplanHash(daten) → string(sha1 dertagedes Hauptplans, 16 Hex-Zeichen;''ohne Hauptplan). -
Step 1: Failing tests
test('Ersatz-HTML ist reines ASCII', …) // /^[\x00-\x7F]*$/.test(html)
test('Ersatz-HTML zeigt alle Wochentage', …) // enthält 'Montag', 'Dienstag', … 'Sonntag', '09:00–18:00', 'geschlossen'
test('Kundentext wird maskiert', …) // Planname '<b>' ⇒ '<b>'
test('Hash ändert sich nur mit dem Wochenplan des Hauptplans', …) // Ausnahme/Hinweis ändern ⇒ gleich; Montag ändern ⇒ anders
- Step 2–4: FAIL → Implementieren → PASS
- Step 5: Commit
"Öffnungszeiten: Ersatz-Tabelle und Wochenplan-Hash"
Block C – Speichern und Kundenschnittstelle
Task 8: Prüfregeln (normalisiereZeiten, normalisiereTeam)
Files:
- Create:
server/services/zeiten/pruefen.js - Test:
test/zeitenPruefen.test.js
Interfaces:
-
Consumes:
BUNDESLAENDER(termine/feiertage.js),text()austermine/einstellungen.js(Muster). -
Produces:
normalisiereZeiten(eingabe, { bisher, now }) → { daten } | { fehler: { [pfad]: text } }(Pfade mit IDs:plaene.<id>.tage.3,ausnahmen.<id>.von,hinweise.<id>.knopf.ziel,kontakt.telefon,daten);normalisiereTeam(eingabe) → { team } | { fehler };telefonZiffern(text) → string|null(deutsche Nummern mit führendem49);kurzname(name, vorhandene) → string. -
Step 1: Failing tests
test('gültiger Plan bleibt, IDs und Kurznamen bleiben unverändert', …)
test('neuer Plan bekommt Kurznamen aus dem Namen', …) // 'Küche' ⇒ 'kueche', zweites 'Küche' ⇒ 'kueche-2'
test('genau ein Hauptplan, höchstens 5 Pläne', …)
test('Fensterregeln', …) // Überschneidung, bis==von, Mitternacht nicht als letztes, ragt in Folgetag, 5-Minuten-Raster ⇒ je eigener fehler-Pfad
test('Telefon in allen Schreibweisen', …) // '0171 1234567', '+49 171 1234567', '0049 171 1234567' ⇒ '491711234567'; '(06 31) 12 34' ⇒ '496311234'; '0171 ABC' ⇒ fehler 'kontakt.telefon'
test('Link nur https ohne Benutzerdaten, höchstens 300 Zeichen', …) // 'javascript:…', 'http://…', 'https://u:p@x.de' ⇒ fehler
test('Aktion braucht Enddatum, Urlaub höchstens 366 Tage', …)
test('Längen aus der Spec', …) // Hinweistext 141 Zeichen ⇒ fehler; Starttext 201 ⇒ fehler
test('Vergangenes (> 30 Tage) wird entfernt', …) // Ausnahme bis 2026-08-01 bei now 2026-10-05 ⇒ fehlt in daten
test('zu viele Daten: verständliche Meldung statt Absturz', …) // 100 Ausnahmen mit langen Texten > 32 KB ⇒ fehler.daten === 'Zu viele Einträge – bitte lösche alte Ausnahmen oder Hinweise.'
test('WhatsApp verlangt Nummer und bestaetigtAt', …)
test('navSelektor-Regeln', …) // 'header.nav' ok; 'a{}', 'a;b', '<x>', 121 Zeichen ⇒ fehler
- Step 2–4: FAIL → Implementieren (Regeln Spec 2.1) → PASS
- Step 5: Commit
"Öffnungszeiten: Prüfregeln für Zeiten und Team-Einstellungen"
Task 9: Speicher (speicher.js) und Cache-Instanz
Files:
- Create:
server/services/zeiten/lru.js,server/services/zeiten/cache.js,server/services/zeiten/speicher.js - Test:
test/zeitenSpeicher.test.js,test/zeitenLru.test.js
Interfaces:
-
Consumes: Task 8,
withLocks. -
Produces:
neuerLru({ max, ttlMs }) → { hole(key, nowMs), setze(key, wert, nowMs), vergiss(key), leere(), groesse() }(echtes LRU).cache.js:zeitenCache(max: 500,ttlMs: 30000),vergissZeiten(siteKey),resetZeitenCacheForTests().ladeZeiten(featureId) → Promise<{ daten, version, team, teamVersion, ersatzHash } | null>speichereZeiten({ feature, eingabe, version, by, now }) → Promise<{ daten, version }>(wirfterr.status=400, err.fehlerbzw.err.status=409, err.code='ZEITEN_VERALTET')speichereTeam({ feature, eingabe, teamVersion, by }) → Promise<{ team, teamVersion }>setzeErsatzHash(feature, hash),loescheZeiten(featureId),loescheZeitenVonKunde(customerId) → Promise<number>
-
Step 1: Failing tests (Appwrite-Nachbau mit
schemaKeys('oeffnungszeiten'))
test('LRU verdrängt den ältesten, Treffer erneuern', …) // max 2: a,b,hole(a),c ⇒ b fehlt
test('LRU-Einträge verfallen nach ttl', …)
test('erstes Speichern legt das Dokument mit version 1 an', …)
test('veraltete Version ⇒ 409 ZEITEN_VERALTET', …)
test('Prüffehler ⇒ 400 mit fehler', …)
test('Speichern leert den Cache-Eintrag des siteKeys', …)
test('Team-Einstellungen haben eine eigene Version', …)
test('gleichzeitiges Speichern: genau einer gewinnt', …) // Promise.all zweier Speichervorgänge mit version 1 ⇒ ein 409
test('loescheZeitenVonKunde löscht Daten und Merker', …)
- Step 2–4: FAIL → Implementieren (alle Schreibzugriffe unter
withLocks([zeiten:${feature.projectId}]), Version innerhalb der Sperre prüfen) → PASS - Step 5: Commit
"Öffnungszeiten: Speicher mit Versionen und Cache"
Task 10: Kundenroute /api/zeiten
Files:
- Create:
server/routes/zeiten.js - Modify:
server/index.js(Mountapp.use('/api/zeiten', …)nach dem Termine-Mount),server/middleware/kundenansichtGuard.js(['PUT', /^\/api\/zeiten\/[^/]+\/?$/]),server/services/customerViewLog.js(VIEW_ACTIONS+='zeiten_gespeichert') - Test:
test/zeitenRoutes.test.js; Pins:test/kundenansichtGuard.test.js(Liste, Zählung,MOUNTS+=/api/zeiten),test/customerViewLog.test.js(elf Aktionen)
Interfaces:
-
Consumes: Tasks 4–9,
getFeatureDoc,assertOwnProject,isKundenansicht,logIfKundenansicht,createWriteLimiter. -
Produces:
GET /api/zeiten/:projectId→{ state, daten|null, version, kundenansicht, vorschau: {offen,text,kurz}|null, feiertage: [{tag,name,art}] (12 Monate), googleText }.PUT /api/zeiten/:projectId{ version, daten }→ wie GET; 400{ fehler }, 409{ code:'ZEITEN_VERALTET', error:'Inzwischen geändert – bitte neu laden.' }, 429 bei > 60/Stunde.GET /api/zeiten/:projectId/vorschau?zeit=YYYY-MM-DDTHH:MM→{ offen, text, kurz }(Berliner Wanduhrzeit, gespeicherte Daten).- Zugriff nur bei
state ∈ {ordered, setup, active};disabledmit Daten ⇒ GET lesbar, PUT 403; sonst 403.setzeUhrFuerTests(fn)exportiert.
-
Step 1: Failing tests
test('fremdes Projekt ⇒ 403', …)
test('ohne Funktion (off) ⇒ 403', …)
test('GET vor dem ersten Speichern: daten null, version 0, vorschau null', …)
test('PUT speichert und liefert vorschau und googleText', …)
test('PUT mit alter Version ⇒ 409 ZEITEN_VERALTET', …)
test('PUT in der Kundenansicht ist erlaubt und wird protokolliert', …) // customerViewLog-Eintrag 'zeiten_gespeichert'
test('Kundenansicht darf whatsapp.bestaetigtAt nicht neu setzen', …) // 403
test('disabled: GET ja, PUT 403', …)
test('vorschau rechnet zur angefragten Berliner Uhrzeit', …)
test('61. Speichern in einer Stunde ⇒ 429', …)
- Step 2–4: FAIL → Implementieren → PASS, dann
npm test(Pins). - Step 5: Commit
"Öffnungszeiten: Kundenschnittstelle zum Bearbeiten"
Block D – Öffentliche Schnittstelle
Task 11: Öffentliche Antwort (oeffentlich.js)
Files:
- Create:
server/services/zeiten/oeffentlich.js - Modify:
server/services/siteFeatures.js(findActiveBySiteKey(siteKey, featureKey='news', { states = ['active'] } = {})mitQuery.equal('state', states)) - Test:
test/zeitenOeffentlich.test.js,test/zeitenSiteKey.test.js
Interfaces:
-
Consumes: Tasks 4–6, 9;
customerOf,projectName(siteFeatures.js). -
Produces:
baueOeffentlicheAntwort({ daten, team, firma, now }) → object(rein; Form Spec 4.1 mitplaene[].{kurz,name,haupt,woche,tage,wechsel},hinweise,kontakt,jsonld,firma,team,aktiv: true,stand).oeffentlicheZeitenAntwort(siteKey, now) → Promise<object>:zeitenAus()⇒{aktiv:false}; Format^[a-z2-7]{16}$sonst{aktiv:false}ohne Cache/Abfrage; Cache-Treffer;findActiveBySiteKey(siteKey, 'zeiten', { states:['setup','active'] }); Kunde fehlt ⇒{aktiv:false};ladeZeiten; bauen; im Cache ablegen.
-
Step 1: Failing tests
test('findActiveBySiteKey: Vorgabe nur active, mit states auch setup', …)
test('setup liefert dasselbe wie active', …)
test('vor dem ersten Speichern: aktiv true, plaene leer, kein jsonld', …)
test('Kontakt-Leiste mit Teildaten', …) // nur telefon ⇒ {telefon:'491711234567', routeUrl:null, whatsappUrl:null}; nur adresse ⇒ nur routeUrl; an:true ohne Angaben ⇒ kontakt null
test('routeUrl und whatsappUrl', …) // routeUrl==='https://www.google.com/maps/dir/?api=1&destination=' + encodeURIComponent(adresse); whatsappUrl==='https://wa.me/491711234567?text=' + encodeURIComponent(starttext)
test('firma ohne Kundendokument fällt auf den Projektnamen, sonst leer', …)
test('Kunde gelöscht ⇒ aktiv false', …)
test('ungültiger Schlüssel: keine Abfrage, kein Cache-Eintrag', …) // aw.listCalls('portalFeatures').length === 0; zeitenCache.groesse() === 0
test('zweiter Abruf innerhalb von 30 s aus dem Cache', …)
test('keine internen IDs in der Antwort', …) // rekursiv: keine Schlüssel $id, customerId, projectId, featureId
- Step 2–4: FAIL → Implementieren → PASS,
node --test test/siteFeatures.test.js test/publicNews*.test.js test/publicTermine*.test.jsgrün. - Step 5: Commit
"Öffnungszeiten: öffentliche Antwort mit Cache"
Task 12: Route /api/public/zeiten/v1
Files:
- Create:
server/routes/publicZeiten.js,server/services/zeiten/limits.js - Modify:
server/index.js(Mount direkt nachapp.use('/api/public/news', …)) - Test:
test/publicZeiten.test.js
Interfaces:
-
Consumes: Task 11;
zaehle,ipSchluessel(termine/limits.js). -
Produces:
export default router,setzeUhrFuerTests(fn),resetPublicZeitenForTests();darfZeitenAnfrage(ip, now) → boolean(ip:zeiten:<ipSchluessel>, 120 je 60000 ms). -
Step 1: Failing tests (Muster
test/publicTermine.test.js, eigene IP je Test überx-forwarded-for)
test('CORS * und OPTIONS 204', …)
test('nur GET/HEAD, sonst 405', …)
test('Cache-Control public, max-age=60 und Expose-Headers Date', …)
test('ETag bleibt gleich, obwohl stand sich ändert ⇒ 304', …)
test('121. Anfrage in einer Minute ⇒ 429 mit Retry-After', …)
test('Appwrite-Fehler ⇒ 503 ohne Details', …)
test('aktiv:false bei Schalter aus, unbekanntem Schlüssel, Zustand off/ordered/disabled', …)
test('unbekannter Pfad ⇒ 404', …)
- Step 2–4: FAIL → Implementieren (Vorlage
publicNews.js: CORS-Block,sendeMitEtaghashtJSON.stringify({ ...body, stand: undefined })) → PASS - Step 5: Commit
"Öffnungszeiten: öffentliche Route"
Block E – Freischalten, Kauf, Abo
Task 13: Mails K24–K27 und Blöcke in K15/T16
Files:
- Create:
server/mail-templates/K24 Zeiten eingeschaltet.html,K25 Zeiten Feiertage pruefen.html,K26 Zeiten Abo beendet.html,K27 Zeiten abgeschaltet.html;server/services/zeiten/mails.js - Modify:
server/mail-templates/K15 Bestellung Funktion.html(Blockbehalten),server/mail-templates/T16 Funktion bestellt.html(Blöckeabo,team),server/services/siteFeatures.js(sendT16Default-Blöckeabo:false, team:false;bestellMailBlockbehalten) - Test:
test/zeitenMails.test.js; Pins:test/mailTemplates.test.js(ids('K', 27), Du-Form-Liste += K24–K27, K15/T16-Blöcke),test/featureMails.test.js
Interfaces:
-
Produces:
k24Mail({ kunde, projekt, einbau, wieder, ticketNr }) → { template:'K24', values, blocks:{ angestossen, manuell, wieder } };k25Mail({ kunde, projekt, tage }) → { template:'K25', values:{ …, tage: 'Sa 03.10. – Tag der Deutschen Einheit\n…' }, blocks:{ pruefen, mehrere } };k26Mail({ kunde, projekt, art, frist, preis }) → { template:'K26', blocks:{ abo_ende, hosting, wechsel_widerrufen } };k27Mail({ kunde, projekt, loeschdatum, preis }) → { template:'K27', … };sendeZeitenMail(customer, nachricht, { bccTeam = true }) → Promise<{ sent, reason? }>(wirft nie; ohne E-Mail{sent:false, reason:'keine_adresse'}; Log ohne Adresse/Namen). -
Step 1: Failing tests
test('Vorlagen K24–K27 bestehen die allgemeinen Regeln', …) // über mailTemplates.test.js-EXPECTED automatisch
test('Builder decken genau die Platzhalter ab', …) // renderMail(id, values, blocks).problems deepEqual []
test('K24: Text je nach einbau', …) // angestossen ⇒ 'nach dem nächsten Veröffentlichen'; manuell ⇒ 'baut unser Team'
test('K25 geht ohne BCC', …) // sendeZeitenMail(c, k25Mail(...), {bccTeam:false}) ⇒ sink.sent[0].bcc fehlt
test('K26-Varianten nach art', …)
test('ohne E-Mail-Adresse: nichts senden, nichts werfen', …)
test('Log enthält weder Adresse noch Namen', …)
test('K15 mit Block behalten, T16 mit Blöcken abo/team', …)
- Step 2–4: FAIL → Implementieren (Skelett K23, Du-Form, Logo; Texte nach Spec 7; Betreffs: K24 „Öffnungszeiten live ist eingeschaltet“, K25 „Feiertage stehen an – stimmen Deine Zeiten?“, K26 „Dein Wartungs-Abo ist beendet – Öffnungszeiten live behalten?“, K27 „Öffnungszeiten live ist abgeschaltet“) → PASS,
npm test. - Step 5: Commit
"Öffnungszeiten: Mails K24 bis K27"
Task 14: Neubau anstoßen und Runner-Feld zeiten
Files:
- Create:
server/services/zeiten/neubau.js,server/services/zeiten/einbau.js - Modify:
server/services/previewDeploy.js(Job:zeiten: await buildZeiten(doc); Dev-Pfad:zeiten: undefinednebenanalytics: undefined) - Test:
test/zeitenNeubau.test.js; Pins:test/previewRunner.test.js(Job-Body mitzeiten: null, Dev-PUT ohnezeiten)
Interfaces:
-
Produces:
einbau.js:buildZeiten(project) → Promise<{ siteKey } | null>(wirft nie; nur bei Feature-Zustandsetup/active;nullbei Subdomain inconfig.zeiten.keinNeubau). Darf wederneubau.jsnochpreviewRebuild.jsimportieren (Zyklus).neubau.js:zeitenDeps = { rebuild: triggerRebuild };stosseNeubauAn(project) → Promise<'angestossen'|'manuell'>unterwithLocks(['zeiten-neubau']);'angestossen'nur beiresult?.queued === true;'manuell'bei fehlendem Repo, Preview aus, Dev-Modus (previewDetectedbeginnt mitdev), Sperrliste, Fehler oder 503.
-
Step 1: Failing tests
test('buildZeiten: setup und active liefern siteKey, off/ordered/disabled null', …)
test('buildZeiten: Sperrliste ⇒ null', …)
test('buildZeiten wirft nie (Appwrite-Fehler ⇒ null)', …)
test('Neubau: queued ⇒ angestossen', …) // zeitenDeps.rebuild = async () => ({ queued:true })
test('Neubau: skipped/kein Repo/Dev/Sperrliste/503 ⇒ manuell', …)
test('Neubauten laufen nacheinander', …) // zwei gleichzeitige stosseNeubauAn ⇒ rebuild-Aufrufe überlappen nicht
- Step 2–4: FAIL → Implementieren → PASS,
node --test test/previewRunner.test.js. - Step 5: Commit
"Öffnungszeiten: Neubau anstoßen und Feld im Runner-Auftrag"
Task 15: Ticket-Text (ticketDetails.js) und Ersatz-Endpunkt für das Team
Files:
- Modify:
server/services/zeiten/ticketDetails.js,server/routes/admin/funktionenAdmin.js(GET /:customerId/zeiten/ersatz?projectId=&plan=→{ html }, 409 bei leeren Plänen) - Test:
test/zeitenTicket.test.js
Interfaces:
-
Consumes: Tasks 7, 9, 14 (
doc.einbau). -
Produces:
zeitenTicketDetails(ctx) → Promise<string>(≤TICKET_DETAILS_MAX): siteKey, Einbau-Zeile<script data-wk-zeiten src="https://project.webklar.com/embed/zeiten.js" data-site="<siteKey>" defer></script>, Ergebniseinbau(„automatisch eingesetzt“ / „bitte von Hand einsetzen“), Platzhalter-Beispiele mit Kurznamen, Hinweis „Ersatz-HTML: WOMS → Funktionen → Ersatz-HTML kopieren (erst nach dem ersten Speichern des Kunden)“, Prüfliste aus Spec 4.6. -
Step 1: Failing tests
test('Ticket nennt siteKey, Einbau-Zeile und Prüfliste', …)
test('einbau manuell ⇒ „bitte von Hand einsetzen“', …)
test('Ticket bleibt unter TICKET_DETAILS_MAX', …)
test('Ersatz-Endpunkt: Team-JWT nötig, {html} als JSON, 409 ohne gespeicherte Zeiten', …)
- Step 2–4: FAIL → Implementieren → PASS
- Step 5: Commit
"Öffnungszeiten: Einbau-Ticket und Ersatz-HTML für das Team"
Task 16: Gratisweg „Einschalten“ und Angebot in GET /api/funktionen
Files:
- Create:
server/services/zeiten/einschalten.js - Modify:
server/routes/funktionen.js(POST /zeiten/einschalten, Antwortfeldzeiten,zeitenStatusje Projekt;speichereNachweisnachserver/services/featureConsents.jsalsspeichereNachweisAusAnfrage(req, …)auslagern),server/services/featureOrder.js(pruefeAboFuerEinschalten),server/services/siteFeatures.js(featureView+unlockedBy,aboEndeAt,einbau,loeschungAmfürzeiten) - Test:
test/zeitenEinschalten.test.js; Pins:test/funktionenRoutes.test.js,test/kundenansichtGuard.test.js(Einschalten bleibt in der Kundenansicht gesperrt)
Interfaces:
-
Consumes: Tasks 2, 13, 14, 15;
freiweg,pruefeZustimmung,speichereZustimmung,bestellbar,ensureFeatureDoc,assertOwnProject,createFeatureTicket. -
Produces:
pruefeAboFuerEinschalten(customerId) → Promise<void>(frischbestellbar();kein_abo/gekuendigt⇒ 409 Codekein_abo/gekuendigtmit Text „Ohne laufendes Wartungs-Abo kannst Du Öffnungszeiten live für einmalig 30 € bestellen.“;nicht_aktiv⇒ 409 „Einschaltbar, sobald Dein Abo aktiv ist.“; Lesefehler ⇒Object.assign(new Error('Gerade nicht prüfbar – bitte versuch es gleich noch einmal.'), { status: 503 })).einschaltenZeiten({ customerId, projectId, by: 'kunde'|'team', staff = null, nachweis = null, now }) → Promise<{ doc, changed }>(Spec 3.2 Schritte 1–6; Abo-Prüfung nur beiby === 'kunde';unlockedBy='abo'bzw.'team'; unterfeature-order:<c>:<p>; Neubau/Ticket/Mail nach der Sperre).holeZeitenNach(doc, { now }) → Promise<doc>(Ticket fehlt insetup⇒ anlegen;unlockedBy === 'abo'undk24Atleer ⇒ K24, dannk24At).GET /api/funktionen:zeiten: { verfuegbar, inklusive: true|false|null, grund, preis: '30 €', einschalten: { consent, leistungsbeschreibung } }; je Projektfeatures.zeiten(erweitertefeatureView),zeitenStatus: { offen, kurz, text } | null,zeitenLeer: boolean.POST /api/funktionen/zeiten/einschalten{ projectId, zustimmung: { version, haken } }→ 200{ feature, einbau }; viewAs ⇒ 403.
-
Step 1: Failing tests
test('Einschalten mit laufendem Abo: setup, unlockedBy abo, Nachweis ohne Sitzung', …)
test('Reihenfolge: Neubau vor Ticket, Ticket-Text nennt einbau, dann K24, dann k24At', …)
test('kein Abo / gekündigt ⇒ 409 mit Hinweis auf 30 €', …)
test('Abo nicht aktiv ⇒ 409 „sobald Dein Abo aktiv ist“', …)
test('Abo-Lesefehler ⇒ 503, nichts geschrieben', …)
test('Kundenansicht ⇒ 403', …)
test('Zustimmung fehlt oder veraltet ⇒ 400/409 wie beim Checkout', …)
test('archiviertes Projekt ⇒ 409', …)
test('zweiter Klick ist idempotent und holt Fehlendes nach', …) // erster Lauf: Ticket-Anlage scheitert (aw.failNext) ⇒ zweiter Klick legt Ticket an, kein zweites K24
test('Angebot: inklusive true/false/null je nach Abo-Lage', …)
test('Projektliste enthält zeitenStatus ohne Zusatzabfrage pro Kachel', …)
- Step 2–4: FAIL → Implementieren → PASS,
npm test. - Step 5: Commit
"Öffnungszeiten: Einschalten im Wartungs-Abo"
Task 17: Checkout-Sperre „inklusive“ und unlockedBy beim Kauf
Files:
- Modify:
server/services/featureOrder.js(pruefeInklusive(feature, customerId)),server/routes/funktionen.js(Stufe nachpruefeAbo),server/services/siteFeatures.js(markPaid/späte Zahlung schreibenunlockedBy: 'kauf'; LesehelferunlockedByVon(doc) = doc.unlockedBy || (doc.unlockedByPurchase ? 'kauf' : 'team')) - Test:
test/zeitenCheckout.test.js
Interfaces:
-
Produces:
pruefeInklusive(feature, customerId) → Promise<void>(nur beifeature.aboInklusive === true;bestellbarok odernicht_aktiv⇒bestellFehler(409, 'Im Wartungs-Abo bereits enthalten – einfach einschalten.', 'inklusive', { grund }); Lesefehler ⇒ wiepruefeAbo);unlockedByVon(doc) → 'kauf'|'abo'|'team'. -
Step 1: Failing tests
test('Checkout zeiten mit laufendem Abo ⇒ 409 inklusive', …)
test('Checkout zeiten bei Abo nicht aktiv ⇒ 409 inklusive', …)
test('Checkout zeiten ohne Abo / gekündigt ⇒ Stripe-Sitzung über 3000 Cent', …)
test('Zahlung ⇒ setup, unlockedBy kauf, Ticket, K15', …)
test('News und Termine unverändert (kein inklusive-Check)', …)
- Step 2–4: FAIL → Implementieren → PASS
- Step 5: Commit
"Öffnungszeiten: Bezahlen gesperrt, wenn im Abo enthalten"
Task 18: Freischalten nur mit Zeiten, Selbstheilung, Ersatz-Hash
Files:
- Modify:
server/services/featureRegistry.js(STANDARD_HAKEN.zeiten.freischaltbar, neuer HakennachFreischaltunginHAKEN_NAMEN),server/services/siteFeatures.js(runActivation: nachactivateClosedTicketsnachFreischaltungaufrufen; fürzeiteninsetupperawait import('./zeiten/einschalten.js')holeZeitenNach) - Test:
test/zeitenFreischalten.test.js; Pins:test/featureFreischaltung.test.js,test/featureRegistry.test.js(HAKEN_NAMEN)
Interfaces:
-
Produces:
zeitenFreischaltbar(doc) → Promise<boolean>(Hauptplan gespeichert und mindestens ein Tag nichtzu; fail-closed);nachFreischaltung(doc)fürzeiten=setzeErsatzHash(feature, wochenplanHash(daten)). -
Step 1: Failing tests
test('Ticket zu, aber keine Zeiten ⇒ bleibt setup, kein K16', …)
test('Ticket zu und Zeiten gespeichert ⇒ active, K16, ersatzHash gesetzt', …)
test('Selbstheilung: setup ohne ticketId ⇒ Job legt Ticket an', …)
test('Selbstheilung: unlockedBy abo ohne k24At ⇒ Job sendet K24 genau einmal', …)
test('Lesefehler im Haken ⇒ bleibt setup', …)
- Step 2–4: FAIL → Implementieren → PASS,
npm test. - Step 5: Commit
"Öffnungszeiten: Freischalten erst mit eingetragenen Zeiten"
Task 19: Team – Aktivieren über den Gratis-Pfad, Einstellungen, Abschalten
Files:
- Create:
server/services/zeiten/abschalten.js - Modify:
server/routes/admin/funktionenAdmin.js(fürzeiten:activate⇒einschaltenZeiten({ by:'team', staff });disable⇒disable(...)+nachAbschaltung;PATCH /:customerId/zeiten/team{ projectId, teamVersion, navSelektor, kontaktleisteAus, hinweisleisteAus };adminViewliefert fürzeitenzusätzlichplaene: [{kurz,name,haupt}],team,teamVersion,hindernis: 'zeiten_fehlen'|null) - Test:
test/zeitenTeam.test.js
Interfaces:
-
Consumes: Tasks 9, 16.
-
Produces:
nachAbschaltung(doc, { now }) → Promise<'angelegt'|'aktuell'|'fehler'>(weichtwochenplanHash(daten)vondoc-DatensatzersatzHashab ⇒ WOMS-Ticket „Ersatz-Tabelle aktualisieren – “ mit aktuellem Ersatz-HTML; wirft nie). -
Step 1: Failing tests
test('Team aktiviert zeiten: setup, unlockedBy team, Neubau, Ticket, kein K24', …)
test('Team-Einstellungen mit Version; ungültiger Selektor ⇒ 400', …)
test('Abschalten durch Team: disabled Grund team, keine Löschfrist', …)
test('Abschalten mit geändertem Wochenplan ⇒ Ersatz-Ticket', …)
test('Abschalten ohne Änderung ⇒ kein Ersatz-Ticket', …)
test('adminView zeigt Pläne, team und hindernis', …)
- Step 2–4: FAIL → Implementieren (Sperren: Route hält
feature-admin:<projectId>,einschaltenZeitennimmt nurfeature-order:…) → PASS - Step 5: Commit
"Öffnungszeiten: Team-Aktionen und Ersatz-Ticket beim Abschalten"
Task 20: „Für 30 € behalten“
Files:
- Create:
server/services/zeiten/behalten.js - Modify:
server/services/stripeBilling.js(createFeatureCheckout(customer, { projectId, key, consentAt, zweck = null }):zweckinmetadataundinvoice_data.metadata; beizweck === 'behalten'Rücksprung#zeiten?behalten=1&projekt=<id>),server/services/siteFeatures.js(handleFeatureCheckoutEvent: Zweigmetadata.zweck === 'behalten'vormarkPaid/adoptPaidSession, per dynamischem Import),server/routes/funktionen.js(POST /zeiten/behalten) - Test:
test/zeitenBehalten.test.js
Interfaces:
-
Produces:
starteBehalten({ customer, projectId, nachweis, now }) → Promise<{ url }>(nur beiaboEndeAtgesetzt oderdisabledmitabo_endeund Daten vorhanden, sonst 409; setztbehaltenSessionId; Zustand unverändert);bucheBehalten(session) → Promise<{ fall: 'behalten'|'unpassend' }>(unterfeature-admin:<projectId>:unlockedBy:'kauf',aboEndeAt:null,behaltenSessionId:'',stripeSessionId = session.id, ausdisabledzurück aufactive/setup+ Neubau; Nachweis mit dieser Sitzung; K15 mit Blockbehalten; unpassend ⇒ T19 nach der Sperre). -
Step 1: Failing tests
test('Behalten nur nach Abo-Ende, sonst 409', …)
test('während der Zahlung bleibt der Zustand (Leisten bleiben live)', …)
test('Webhook behalten: kauf, Frist weg, kein neues Ticket, K15 behalten', …)
test('Behalten aus disabled: zurück auf active und Neubau', …)
test('Sitzung passt nicht oder Daten gelöscht ⇒ T19 Erstattung fällig', …)
test('normaler Kauf-Webhook unverändert', …)
- Step 2–4: FAIL → Implementieren → PASS,
npm test. - Step 5: Commit
"Öffnungszeiten: nach Abo-Ende für 30 € behalten"
Task 21: Abo-Abgleich, Abschaltung nach 30 Tagen, Löschung
Files:
- Create:
server/services/zeiten/aboAbgleich.js - Modify:
server/services/siteFeatures.js(activate()leert zusätzlichaboEndeAtundbehaltenSessionId) - Test:
test/zeitenAboAbgleich.test.js
Interfaces:
-
Consumes: Tasks 13, 14, 19;
aboMemo,aboLage,loeschzeit(termine/frist.js). -
Produces:
gleicheZeitenAboAb(now) → Promise<{ geprueft, aboEnde, abgeschaltet, wiederEin, geloescht, mails, fehler }>(wirft nie; nurunlockedBy === 'abo'; Schritte Spec 5: K26 mit Variante nachart, nachaboEndeAt + 30 Tage⇒disabled/abo_ende+ K27 +nachAbschaltung, Rückkehr bisaboEndeAt + 60 Tage⇒ zurück + Neubau ausdisabled+ K24wieder,disabled/abo_ende+ 30 Tage ⇒loescheZeiten+state:'off'+ticketId,einbau,k24At,aboEndeAt,behaltenSessionIdgeleert; Lesefehler ⇒ Kunde überspringen). -
Step 1: Failing tests (
nowals Parameter, Tage überplusTage)
test('Abo endet: aboEndeAt gesetzt, K26 einmal', …)
test('K26-Variante bei Wechsel zu Hosting', …)
test('Tag 29: noch aktiv; Tag 30: disabled abo_ende, K27', …)
test('Rückkehr an Tag 45: zurück auf active, Neubau, K24 wieder', …)
test('Tag 60 nach Abschaltung: Daten gelöscht, state off', …)
test('gekaufte und Team-Freischaltungen bleiben unberührt', …)
test('Abo-Lesefehler: überspringen, nie abschalten', …)
test('Grund team wird nicht gelöscht', …)
test('nach der Löschung: ticketId, einbau, k24At, ersatzHash leer; Neubestellung legt ein neues Ticket an', …)
- Step 2–4: FAIL → Implementieren → PASS
- Step 5: Commit
"Öffnungszeiten: Abo-Abgleich mit 30 Tagen Frist"
Task 22: Kunde gelöscht
Files:
-
Modify:
server/services/customerAdmin.js(neben dem Termine-Aufruf:disableCustomerFeatures(customerId, 'zeiten', …)undloescheZeitenVonKunde(customerId), beides per dynamischem Import in try/catch),server/services/retention.js(wo Funktionen eines gelöschten Kunden abgeschaltet werden: fürzeitenzusätzlichloescheZeitenVonKunde) -
Test:
test/zeitenLoeschen.test.js -
Step 1: Failing tests
test('Kundenlöschung schaltet zeiten ab und löscht Daten und Merker', …)
test('öffentliche Antwort danach aktiv false', …)
test('Fehler beim Löschen der Zeiten bricht die Kundenlöschung nicht ab', …)
- Step 2–4: FAIL → Implementieren → PASS,
node --test test/funktionenTermine.test.js test/analyse.test.js test/retention.test.js. - Step 5: Commit
"Öffnungszeiten: Daten bei Kundenlöschung entfernen"
Task 23: Feiertags-Erinnerung und Jobs
Files:
- Create:
server/services/zeiten/erinnerung.js,server/services/zeiten/jobs.js - Modify:
server/index.js(Abo-JobsetTimeout240 s, dannsetInterval24 h; ErinnerungsetTimeout300 s, dannsetInterval1 h; beide.unref(), Termine-Blöcke nicht anfassen) - Test:
test/zeitenErinnerung.test.js,test/zeitenJobs.test.js
Interfaces:
-
Consumes: Tasks 6, 13, 21;
ZEITEN_COLLECTIONS.zeitenErinnerungen. -
Produces:
erinnereFeiertage(now) → Promise<{ geprueft, mails, merker, fehler }>(nur Schalterpilot/onund freigegebene Kunden, nursetup/active, nur im Fenster; je Tag zuerst Merkersha1(featureId|datum)anlegen, 409 ⇒ überspringen; eine K25 je Dokument mit allen neuen Tagen,bccTeam:false; Merker älter als 30 Tage löschen);zeitenAboJob(now),zeitenErinnerungsJob(now)(eigene Überlappsperre),resetZeitenJobsForTests(). -
Step 1: Failing tests
test('eine Mail für zwei Feiertage im Fenster', …)
test('zweiter Lauf: keine zweite Mail (Merker)', …)
test('Neustart zwischen Merker und Mail: keine doppelte Mail', …) // Merker existiert, Mail fehlt ⇒ nichts senden
test('außerhalb 08–20 Uhr: nichts', …)
test('Schalter off: nichts', …)
test('alte Merker werden aufgeräumt', …)
test('Jobs überlappen nicht', …)
test('index.js registriert beide Jobs mit .unref()', …) // Quelltext-Pin wie termineJobs.test.js
- Step 2–4: FAIL → Implementieren → PASS,
npm test. - Step 5: Commit
"Öffnungszeiten: Feiertags-Erinnerung und Jobs"
Block G – Runner
Task 24: Runner setzt das Zeiten-Skript ein
Files:
- Modify:
runner/src/config.js(zeitenPublicBaseausZEITEN_PUBLIC_BASE, Vorgabe'https://project.webklar.com/embed/', gleiche Regex wieanalyticsPublicBase, FehlertextZEITEN_PUBLIC_BASE muss mit https:// beginnen und mit / enden),runner/src/validate.js(Blockzeiten+ Whitelist),runner/src/analytics-inject.js(injectIntoHtml(buf, tag, marker = 'data-wk-analytics'), Marker gegen/^data-wk-[a-z]+$/prüfen; neuzeitenScriptTag(zeiten, baseUrl),injectZeiten(dir, zeiten, { baseUrl, onLine, fs })),runner/src/jobs.js(inpublish()nach dem Analyse-Aufruf:if (job.zeiten) await injectZeiten(fresh, job.zeiten, { baseUrl: config.zeitenPublicBase, onLine, fs })) - Test:
runner/test/zeiten-inject.test.js
Interfaces:
-
Produces:
zeitenScriptTag({ siteKey }, baseUrl) → '<script data-wk-zeiten src="<baseUrl>zeiten.js" data-site="<siteKey>" defer></script>';injectZeiten(...)wirftError('Zeiten: ungültiger siteKey')/Error('Zeiten: ungültige öffentliche Basis-URL'), LogzeileZeiten-Skript in n HTML-Datei(en) eingesetzt, keine Datenschutzseite. -
Step 1: Failing tests (Helfer aus
runner/test/analytics-inject.test.jskopieren)
test('Tag steht vor </head>', …)
test('zweimal einsetzen ergibt genau einen Tag', …) // count('data-wk-zeiten') === 1
test('alter Zeiten-Tag wird ersetzt', …)
test('Analyse und Zeiten in einem Job: je ein Tag', …) // includes beider Marken, keine Reihenfolge-Prüfung
test('nur Zeiten: keine datenschutz-analyse.html', …)
test('Fehler ⇒ Job failed, alte Preview bleibt', …)
test('validateJob: gültig, null, ungültiger Schlüssel, Zusatzfelder verworfen', …)
test('loadConfig: Vorgabe, Override, ungültig wirft', …)
test('injectIntoHtml lehnt fremde Marker ab', …)
- Step 2–4: FAIL → Implementieren → PASS (
cd runner && npm test; bestehende Analyse-Tests unverändert grün) - Step 5: Commit
"Runner: Zeiten-Skript beim Veröffentlichen einsetzen"
Block H – Website-Skript
Task 25: zeiten.js – Abruf, Status und Platzhalter
Files:
- Create:
public/embed/zeiten.js,public/embed/zeiten-demo.html(noindex, Testseite mit allen drei Platzhalter-Modi) - Test:
test/zeitenEmbedStatic.test.js
Interfaces:
-
Consumes: Antwort aus Task 11/12.
-
Produces (reine Funktionen zwischen Marken
// @rein-anfang…// @rein-ende, pervm.runInNewContexttestbar):waehleWechsel(wechsel, jetztMs) → eintrag|null,naechsterWechsel(wechsel, hinweise, jetztMs) → ms|null,waehleHinweis(hinweise, jetztMs) → hinweis|null,jetztMitServer(datumsKopf, lokalMs) → ms(Abweichung > 5 min ⇒ Serverzeit),tabelleDaten(plan, heuteTag) → { woche, abweichungen14 };telefonLinkzwischen// @telefon-anfang/// @telefon-ende(gleiche Regel wiepublic/embed/termine.js). -
Step 1: Failing tests
test('Quelltext: keine Cookies, kein Storage, kein fremder Host', …) // doesNotMatch(/document\.cookie|localStorage|sessionStorage|indexedDB/), doesNotMatch(/https?:\/\//)
test('Quelltext: Date nur als Date.now( und Date.parse(', …) // keine getHours/getDay/toLocale/Intl./getTimezoneOffset
test('Quelltext: Kundentexte nie per innerHTML', …)
test('Doppel-Laden-Schutz __wkZeiten', …)
test('waehleWechsel nimmt den letzten Eintrag mit ab ≤ jetzt', …)
test('naechsterWechsel berücksichtigt Hinweis-Beginn und -Ende', …)
test('waehleHinweis: höchster Rang, bei Gleichstand späterer Beginn', …)
test('jetztMitServer: 4 min Abweichung ⇒ lokal, 6 min ⇒ Server', …)
test('tabelleDaten: heutiger Tag markiert, Abweichungen der nächsten 14 Tage', …)
- Step 2–4: FAIL → Implementieren → PASS. Ablauf im Skript: siteKey/API-Basis wie
termine.js(SELF=document.currentScript,data-api); genau einfetch(… , { credentials:'omit' }); Zeitpunkt des letzten erfolgreichen Abrufs merken (nichtbody.stand); Timer aufnaechsterWechsel, Neuladen frühestens alle 10 min und beivisibilitychange; älter als 24 h oder Fehler ⇒ Platzhalter unverändert lassen; Platzhalter[data-webklar-zeiten]mitdata-modustabelle|status|badgeunddata-plan(Kurzname, unbekannt ⇒ Ersatz-HTML bleiben +console.warn); Light DOM,@layer wkz, Klassenwkz-…, Variablen--wkz-…, Tabelle mit<caption>,th scope="row",aria-current="date". - Step 5: Commit
"Öffnungszeiten: Website-Skript mit Status und Tabelle"
Task 26: zeiten.js – Leisten, JSON-LD und UI-Prüfung
Files:
- Modify:
public/embed/zeiten.js - Create:
test/ui/zeitenTestServer.mjs,test/ui/zeiten-embed.ui.mjs - Test:
test/zeitenEmbedStatic.test.js(erweitern)
Interfaces:
-
Produces (rein, zwischen den Marken):
jsonLdEinbauen(bloecke, jsonld, seite) → { geaendert: [index], neu: object|null }(bloecke= geparsteld+json-Inhalte, unparsbare alsnull; LocalBusiness-Typen auch in@graph;seite = { name, adresse, url }fülltneuerBlock);kontaktKnoepfe(kontakt) → [{ art, href, text }];istEigeneAnrufLeiste(elemente) → boolean. -
Step 1: Failing tests (statisch)
test('JSON-LD: vorhandener Restaurant-Knoten wird ergänzt, kein zweiter Block', …)
test('JSON-LD: @graph mit HairSalon wird ergänzt', …)
test('JSON-LD: unparsbarer Block bleibt unberührt, neuer Block kommt dazu', …)
test('JSON-LD: ohne LocalBusiness ⇒ neuerBlock mit name/url/address', …)
test('Kontakt-Knöpfe aus Teildaten', …) // nur telefon ⇒ 1 Knopf tel:; nur routeUrl ⇒ 1 Knopf Route; leer ⇒ []
test('Links aus der API werden erneut geprüft', …) // 'javascript:x' im knopf.ziel ⇒ kein Link
- Step 2: Failing UI-Prüfung schreiben (
test/ui/zeiten-embed.ui.mjs, Mustertest/ui/embed.ui.mjs+termineTestServer.mjs; Seiten vom Testserver selbst ausliefern;ctx.clock.install/fastForwardfür Timer). Prüfungen:hinweisleiste_aus(Team-Schalter ⇒ keine Leiste),fester_header_rueckt(Header mitposition:fixed; top:0steht unter der Leiste, nach Scrollen wieder bei 0),klebender_header,eigene_anrufleiste(Seite mit festera[href^="tel:"]-Leiste ⇒ keine Kontakt-Leiste),kontaktleiste_nur_handy(390 px ja, 1280 px nein; Abstandhalter vorhanden),jsonld_ergaenzt,aktiv_false_ersatz_bleibt,unbekannter_plan,kein_storage(document.cookie==='',localStorage.length===0),doppelt_geladen,reduzierte_bewegung,ungueltiger_selektor(kein Fehler, Leiste trotzdem da),status_wechselt_um_18_uhr(Uhr vorspulen). - Step 3: Run → FAIL (
free -m && nice node test/ui/zeiten-embed.ui.mjs) - Step 4: Implementieren nach Spec 4.3: Hinweis-Leiste als Shadow-DOM-Host erstes Kind von
body, schiebt den Inhalt, feste/klebende Elemente mittop:0(oderteam.navSelektor,querySelectorAllin try/catch) per Inline-topummax(0, Höhe − scrollY)rücken (rAF beim Scrollen, beim Entfernen zurücksetzen),scroll-padding-top; Farben je Art (Kontrast ≥ 4,5:1),role="region",aria-label="Hinweis"; Kontakt-Leiste nur beimatchMedia('(max-width: 768px)'), Shadow DOM,z-index:40, Abstandhalter am Ende vonbody,scroll-padding-bottom,rel="noopener noreferrer"; JSON-LD pertextContent = JSON.stringify(...). - Step 5: Run → PASS (statisch + UI)
- Step 6: Commit
"Öffnungszeiten: Hinweis-Leiste, Kontakt-Leiste und JSON-LD im Website-Skript"
Block I – Portal-Oberfläche
Task 27: Stand abgleichen und reine Editor-Helfer
Files:
- Create:
public/zeiten-view.js - Test:
test/zeitenView.test.js
Interfaces:
-
Produces:
ZEITEN_LIMITS = { plaene:5, ausnahmen:100, hinweise:20, urlaubTage:366 },ZEITEN_MAX = { hinweisText:140, name:60, ausnahmeText:60, knopfText:30, starttext:200, adresse:200, telefon:30, link:300, kurz:24 },MODI,HINWEIS_ARTEN;neueZeitenId(praefix: 'p'|'x'|'h') → string;kurzname(name, vorhandene) → string(gleiche Regel wie Server, Task 8);fensterPruefen(fenster, { folgetagErstes }) → string|null;wochenFehler(plan) → { [tagNr]: text };leererPlan({ name, kurz, haupt });hauptplanWechseln(daten, planId) → daten;planLoeschenGrund(daten, planId) → string|null;hinweisVorschlag(urlaub) → { art:'urlaub', text, von, bis } | null(ab 2 Tagen;von= Urlaubsbeginn − 7 Tage,bis= Urlaubsende; TextBetriebsurlaub vom TT.MM. bis TT.MM.);preisHinweisNoetig(text) → boolean(/%|statt/i);linkOk(url) → boolean. -
Step 1: Stand abgleichen –
git -C ../dev-termine log --oneline -5undgit fetch-freier Vergleich: Hat der TP2-Branch seit6ae0f67neue Commits,git rebase feature/terminplaner-tp2(nur bei sauberem Baum), dannnpm test. Konflikte inpublic/funktionen.js/app.jszugunsten von TP2 lösen und im Ledger notieren. -
Step 2: Failing tests
test('kurzname wie auf dem Server', …) // 'Küche'⇒'kueche'; Dublette ⇒ 'kueche-2'; 30 Zeichen ⇒ 24
test('fensterPruefen: Überschneidung, gleiche Zeiten, Mitternacht nur zuletzt', …)
test('Hauptplan wechseln und Löschgründe', …) // Hauptplan ⇒ 'Erst einen anderen Plan zum Hauptplan machen.'; letzter Plan ⇒ Grund
test('hinweisVorschlag ab 2 Tagen Urlaub', …) // Urlaub 12.–26.10. ⇒ {von:'2026-10-05', bis:'2026-10-26', text:'Betriebsurlaub vom 12.10. bis 26.10.'}; 1 Tag ⇒ null
test('Preis-Hinweis bei % oder statt', …)
test('Quelltext-Pins: zeiten*.js importieren nur relativ', …)
- Step 3–4: FAIL → Implementieren → PASS
- Step 5: Commit
"Öffnungszeiten: Helfer für den Editor"
Task 28: Kachel, Ansicht und Bestellen/Einschalten
Files:
- Modify:
public/funktionen.js(Kachel inline, keine neuen Importe:ICONS.clock,zeitenTile(projects, angebot),pickZeitenProject, Knopfdata-view="zeiten" data-project, Übergabe an#zeiten-body.dataset.wunschProjekt),public/app.js(FEATURE_LABELS.zeiten='Öffnungszeiten',VIEW_HASHES,viewRefreshers.zeitenmitimport('/zeiten.js')und Sperre gegen Neuaufbau bei ungespeicherten Änderungen,APP_PARENTzeiten:'funktionen'hintertermine),public/dashboard.html(<section class="kb-view" id="view-zeiten">mit#zeiten-body,<link href="/zeiten.css">) - Create:
public/zeiten.js,public/zeiten-bestellen.js,public/zeiten.css - Test:
test/funktionenZeitenTile.test.js,test/zeitenBestellen.test.js; Pins:test/termineView.test.js(APP_PARENT/VIEW_HASHES/FEATURE_LABELS),test/funktionenRoutes.test.js
Interfaces:
-
Consumes: Task 16 (
zeiten,features.zeiten,zeitenStatus,zeitenLeer), Task 17, Task 20;bestellKoerper,sicherLink(funktion-bestellen.js);h,request,hakenFeld,infoKarte,fehlerKarte(termine-dom.js). -
Produces:
initZeiten(root, { projectId } = {});renderZeitenBestellen(root, { ctx, angebot, projekt });zeitenStatusKarte({ projekt, feature, angebot, kundenansicht }) → Element. -
Step 1: Failing tests
// test/funktionenZeitenTile.test.js (eigener Loader: alle Importe in funktionen.js generisch umbiegen)
test('ohne verfuegbar keine Kachel', …)
test('Abo: „Im Wartungs-Abo enthalten“ und Knopf Einschalten', …)
test('Abo nicht aktiv: „sobald dein Abo aktiv ist“, kein Bezahlen', …)
test('ohne Abo: „30 € einmalig“', …)
test('setup: Text je nach einbau, Hinweis „Trag jetzt deine Zeiten ein“ bei zeitenLeer', …)
test('active: Kurzstatus aus zeitenStatus, keine Zusatzabfrage', …) // fetch-Aufrufe deepEqual ['/api/funktionen']
test('aboEndeAt: Behalten-Hinweis mit Datum', …)
test('ordered: „Zahlung wird bestätigt …“', …)
test('disabled abo_ende mit Daten: Knopf „Für 30 € wieder einschalten“', …)
test('Abo-Lesefehler (inklusive null): „Gerade nicht prüfbar – bitte später erneut“', …)
// test/zeitenBestellen.test.js (fakeDom)
test('Einschalten: Haken schaltet Knopf frei, POST /api/funktionen/zeiten/einschalten', …)
test('Bestellen: POST /api/funktionen/zeiten/checkout, Weiterleitung zu Stripe', …)
test('409 inklusive beim Bestellen ⇒ wechselt zur Einschalt-Ansicht', …)
test('Kundenansicht: Einschalten/Bestellen gesperrt mit Hinweis', …)
test('Rückkehr #zeiten?bestellt=1 und ?behalten=1 zeigt Danke-Karte', …)
- Step 2–4: FAIL → Implementieren → PASS,
npm test;nice node test/ui/funktionen.ui.mjs(Zahl „Kommt bald“-Kacheln unverändert). - Step 5: Commit
"Öffnungszeiten: Kachel und Bestell-/Einschaltweg im Portal"
Task 29: Editor I – Speichern, Zeitpläne, Feiertage, Ausnahmen
Files:
- Create:
public/zeiten-speichern.js,public/zeiten-editor.js,public/zeiten-plaene.js,public/zeiten-ausnahmen.js - Test:
test/zeitenEditor.test.js
Interfaces:
-
Consumes: Tasks 10, 27;
editorZeile,neuerEintrag,abschnitt,feld,knopfZeile,setzeFehler,zeigeFehler(termine-dom.js). -
Produces:
neuerZeitenSpeicher(ctx, wurzel, neuZeichnen) → { speichere(aendere, { container }), konflikt, nochHier }(PUT /api/zeiten/<pid> { version, daten }; 409 ⇒'Inzwischen geändert – bitte neu laden.'; 400 ⇒ Fehler an die Felder per ID-Pfad);renderEditor(root, ctx); Abschnitteplaene(t),feiertage(t),ausnahmen(t)mitt = { ctx, sp, zeichne, wurzel }. -
Step 1: Failing tests (fakeDom)
test('leerer Start: „Trag deine Öffnungszeiten ein“ und Hauptplan', …)
test('Wochentag: Haken geöffnet, zwei Zeitfelder step 300, rund/Vereinbarung', …)
test('Plan hinzufügen zeigt Kurznamen, Hauptplan wechseln', …)
test('Plan löschen warnt: „Steht dieser Plan auf deiner Website, zeigt sie dort wieder die feste Tabelle.“', …)
test('Feiertage: Bundesland, Liste mit Namen, „bitte prüfen“ markiert, Hinweis Gemeinde-Feiertage', …)
test('Urlaub ≥ 2 Tage ⇒ Frage „Hinweis-Leiste dazu anlegen?“ füllt den Hinweis vor', …)
test('409 beim Speichern ⇒ Meldung und Neu-laden-Knopf', …)
test('400 ⇒ Fehler am richtigen Feld (plaene.<id>.tage.3)', …)
test('Kundenansicht: Hinweis-Box, Speichern erlaubt', …)
- Step 2–4: FAIL → Implementieren → PASS
- Step 5: Commit
"Öffnungszeiten: Editor für Pläne, Feiertage und Ausnahmen"
Task 30: Editor II – Hinweise, Kontakt, Google, Vorschau
Files:
- Create:
public/zeiten-leisten.js,public/zeiten-google.js - Modify:
public/zeiten-editor.js(Vorschau oben, Regler „Uhrzeit testen“) - Create:
test/ui/zeiten-portal.ui.mjs - Test:
test/zeitenEditor.test.js(erweitern)
Interfaces:
-
Produces:
hinweise(t),kontakt(t),google(t); Vorschau ruftGET /api/zeiten/<pid>/vorschau?zeit=…; Knopf „Hinweis-Leiste anlegen“ (genau dieser Text, die Relaunch-Station tippt ihn an). -
Step 1: Failing tests
test('Hinweis: Aktion ohne Enddatum ⇒ Feldfehler; % im Text ⇒ Preis-Hinweis', …)
test('Hinweis-Knopf: nur https-Link oder Telefon', …)
test('Kontakt: WhatsApp verlangt den Datenschutz-Haken; in der Kundenansicht gesperrt', …)
test('Google: „Für Google kopieren“ nutzt googleText vom Server, Link business.google.com', …)
test('Vorschau-Regler fragt /vorschau mit Tag und Uhrzeit', …)
- Step 2: UI-Prüfung schreiben (
test/ui/zeiten-portal.ui.mjs, Mustertermine-portal.ui.mjsmit Fixture-JSON):fx_kachel_zustaende,fx_einschalten,fx_editor_leer,fx_speichern_konflikt,fx_hinweis_vorschlag,fx_handy_390(kein waagerechtes Scrollen),fx_tastatur(alle Felder per Tab erreichbar, Fokus sichtbar). - Step 3–4: FAIL → Implementieren → PASS (
node --test test/zeitenEditor.test.js,free -m && nice node test/ui/zeiten-portal.ui.mjs) - Step 5: Commit
"Öffnungszeiten: Editor für Hinweis- und Kontakt-Leiste, Google und Vorschau"
Block J – WOMS
Task 31: WOMS – Register, Texte, Protokoll
Files (WOMS-Worktree /home/webklar/apps/ticket.webklar.com/dev-zeiten):
- Vorbereitung:
git -C /home/webklar/apps/ticket.webklar.com/build worktree add ../dev-zeiten -b feature/oeffnungszeiten feature/terminplaner-tp2 && cd ../dev-zeiten && nice npm ci - Modify:
src/lib/funktionenView.js(Eintragzeitenhintertermine,siteKeyOffür['termine','zeiten'],aktionenfürzeiten:off ['activate'],ordered ['disable'],setup ['disable'],active ['disable'],disabled ['activate']– Signatur so übernehmen, wie TP2 sie zum Zeitpunkt hat),src/lib/viewLog.js(zeiten_gespeichert: 'Öffnungszeiten gespeichert') - Create:
src/lib/zeitenView.js - Test:
tests/unit/zeitenView.test.mjs; Pins:tests/unit/funktionenView.test.mjs,tests/unit/viewLog.test.mjs
Interfaces:
-
Consumes:
adminViewfürzeiten(Task 19). -
Produces:
zeitenStatusText(feature) → string(„Leisten live – Einbau läuft“ / „wartet auf die Zeiten des Kunden“ beihindernis === 'zeiten_fehlen'/ Abo-Ende-Hinweis mit Frist);navSelektorFehler(text) → string|null(Spiegel der Serverregel Task 8);teamBody({ projectId, teamVersion, navSelektor, kontaktleisteAus, hinweisleisteAus }) → object. -
Step 1: TP2-Stand prüfen –
git log --oneline -10im neuen Worktree; sind TP2 D9/D10/F13b/F14 schon da, deren Signaturen (aktionen(state, key, …), Panel-Aufbau) verwenden. Im Ledger notieren. -
Step 2: Failing tests
test('FUNKTIONEN enthält zeiten hinter termine', …)
test('siteKeyOf liefert für zeiten den Schlüssel, für news weiter ""', …)
test('aktionen für zeiten', …)
test('zeitenStatusText: setup / wartet / Abo-Ende', …)
test('navSelektorFehler wie auf dem Server', …)
test('viewLog-Label für zeiten_gespeichert', …)
- Step 3–4: FAIL → Implementieren → PASS (
node --test tests/unit/*.test.mjs) - Step 5: Commit (im WOMS-Worktree)
"Öffnungszeiten: Register und Texte im Ticketsystem"
Task 32: WOMS – Panel, Team-API, Rechtliches-Karte
Files (WOMS-Worktree):
-
Create:
src/components/customer/ZeitenPanel.jsx -
Modify:
src/lib/teamApi.js(getZeitenErsatz(customerId, projectId, plan)⇒GET /funktionen/<id>/zeiten/ersatz,patchZeitenTeam(customerId, body)⇒PATCH /funktionen/<id>/zeiten/team),src/components/customer/CustomerFunktionen.jsx(Panel unter der Zeiten-Zeile, eigene Zeile außerhalb des 5-Spalten-Grids; Aktionsbeschriftung je Aktion),scripts/setup-legal.mjs(Karte{ key:'leistungsbeschreibung-oeffnungszeiten', title:'Leistungsbeschreibung Öffnungszeiten live', liveUrl:'https://project.webklar.com/vertragsunterlagen/zeiten-leistungsbeschreibung.html' }am Ende vonDOCUMENTS) -
Test: bestehende WOMS-Tests + Bau
-
Step 1: Failing test – Pin für
setup-legal.mjs(Schlüssel, Titel,sortlückenlos) in der bestehenden Testdatei dieses Skripts ergänzen. -
Step 2: Implementieren – Panel: Felder Header-Selektor (Client-Prüfung
navSelektorFehler), Haken „Kontakt-Leiste aus“, „Hinweis-Leiste aus“, Speichern mitteamVersion(409 ⇒ „Inzwischen geändert – bitte neu laden“), je Plan Knopf „Ersatz-HTML kopieren“ (navigator.clipboard.writeText(html); 409 ⇒ „Der Kunde hat noch keine Zeiten eingetragen“). -
Step 3: Run
npm testundnice npx vite build→ grün. -
Step 4: Commit (im WOMS-Worktree)
"Öffnungszeiten: Team-Panel, Ersatz-HTML und Rechtliches-Karte"
Block K – Rechtstexte und Einbau-Doku
Task 33: Entwürfe für die Rechtsrunde und Einbau-Anleitung
Files:
-
Create:
public/vertragsunterlagen/zeiten-leistungsbeschreibung.html(Kopf-KommentarENTWURF – vor Livegang Rechtsrunde, Gliederung Spec 9.1),docs/rechtliches/zeiten-datenschutz-6d-entwurf.md(§ 6d und Ergänzung § 6),docs/rechtliches/zeiten-k02-k03-entwurf.md(vorgeschlagener Satz für den Blockfunktionen, Sie-Form, mit den heutigen Pins als Hinweis),docs/funktionen/zeiten-einbau.md(Einbau-Zeile, Platzhalter-Modi,data-plan, Ersatz-HTML, Header-Selektor, Prüfliste Spec 4.6) -
Modify:
docs/funktionen/website-datenschutz-absatz.md(Absatz „Öffnungszeiten live“ für Variante A und B, WhatsApp und Google Maps erst nach Klick),docs/rechtliches/Kanzlei-Fragen (⚖ „AVV Funktionen“ per Haken?) dort ergänzen, wo TP2 das Kanzlei-Paket führt -
Test:
test/zeitenRecht.test.js -
Step 1: Failing tests
test('Leistungsbeschreibung ist erreichbar und als Entwurf markiert', …) // Datei existiert, enthält 'ENTWURF', '§ 19 UStG', 'ein Einbau je Website'
test('Leistungsbeschreibung nennt Haftung, Ende nach Abo und Einstellung', …)
test('Register verlinkt die Leistungsbeschreibung', …) // featureConfig('zeiten').leistungsbeschreibung.url zeigt auf die Datei
test('unterlagenBereit für zeiten ist erfüllt', …)
- Step 2–4: FAIL → Texte schreiben (Spec 9, Sie-Form) → PASS
- Step 5: Commit
"Öffnungszeiten: Rechtstext-Entwürfe und Einbau-Anleitung"
Block L – Relaunch-Station
Task 34: Ablauf der Station (rein, getestet)
Files (Relaunch-Worktree):
- Vorbereitung:
git -C /home/webklar/apps/webklar.com/build worktree add ../dev-zeiten-station -b feature/zeiten-station main && ln -s ../build/node_modules ../dev-zeiten-station/node_modules - Create:
src/components/home/demos/baeckerei/teile.tsx(Logo,Stempel,Pfeilunverändert verschoben;BaeckereiSeite.tsximportiert sie),src/components/home/demos/zeiten/ablauf.ts - Test:
src/test/demo-zeiten.test.tsx
Interfaces:
-
Produces:
type ZeitenPhase = "start"|"lauf"|"tippen"|"leiste";type ZeitenZustand = { phase, uhr /* Minuten ab Mo 5.10.2026 00:00 */, status: { offen, text, kurz }, leiste: boolean, klick: number, bedient: boolean, laeuft: boolean, statisch: boolean, setzeUhr: (min: number|null) => void }; KonstantenZEIT_MIN=300,ZEIT_MAX=1320,ZEIT_SCHRITT=10,START_UHR=350,ENDE_UHR=1090,HINWEIS_TEXT="Betriebsurlaub 12.–26. Oktober",ZEITEN_START,ZEITEN_RUNDE_MS,HINWEIS_ANLEGEN_PUNKT;statusFuer(minute) → status(Bäckerei Mo 6–18 Uhr);formatUhr(min) → "05:50";useZeitenAblauf(laeuft, statisch) → ZeitenZustand. -
Step 1: Failing tests (Muster
demo-pinnwand.test.tsx,vi.useFakeTimers)
it('statusFuer deckt jede Minute ab, nie „24“', …) // für m in ZEIT_MIN..ZEIT_MAX: text nicht /24/
it('05:50 geschlossen, 06:00 geöffnet, 18:00 geschlossen · öffnet morgen um 6 Uhr', …)
it('Ablauf: Uhr läuft, Finger tippt, Leiste erscheint', …)
it('Bedienen stoppt den Ablauf', …) // setzeUhr(700) ⇒ vi.getTimerCount() === 0, bedient true
it('statisch: Endzustand 18:10 mit Leiste, ohne Timer', …)
it('ablauf.ts importiert kein CSS und keine URL', …)
- Step 2–4: FAIL → Implementieren → PASS (
npx vitest run src/test/demo-zeiten.test.tsx;demo-chat-vorschau.test.tsxmitteile.tsxin der Scan-Liste grün) - Step 5: Commit (im Relaunch-Worktree)
"Relaunch: Ablauf der Station Öffnungszeiten live"
Task 35: Station einbauen (Seite, Handy, Regler, Bühne)
Files (Relaunch-Worktree):
-
Create:
src/components/home/demos/zeiten/ZeitenSeite.tsx,ZeitenHandy.tsx,ZeitenRegler.tsx,zeiten.css -
Modify:
src/components/home/demos/steuerung.tsx(ZeitenSteuerungmitaktiv, bei!aktivsetzeUhr(null)),src/components/home/Kundenportal.tsx(DemoArt+="zeiten", Eintrag nach der Pinnwand:id:"oeffnungszeiten",titel:"Öffnungszeiten live",demo:"zeiten",bald:true,adresse:"baeckerei-sonnenschein.example",handy:{ name:"phone-12-zeiten", alt:"Öffnungszeiten im Kundenportal bearbeiten" }; lazy-Importe,VORLADEN/PLATZHALTER("#f7f0e4"), lokalesHINWEIS_PUNKT, Zustand, Steuerung, Website- und Handy-Ebene,.kp-regler,ZeitenKarte,HandyReihe, Label „Neun Funktionen…“; ausablauf.tsnurimport type),src/components/home/kundenportal.css(.kp-reglerin die Selektorlisten,.kp-karte__regler), Kommentare/Spec-Zählungen („acht Stationen“, „Höchstens 7 Karten“) -
Test:
src/test/demo-zeiten.test.tsx(erweitern); Pinssrc/test/relaunch-kundenportal.test.tsx(alle Stellen aus dem Anschluss-Datenblatt „relaunch (d) 1“:TITEL,UNTERSCHRIFTEN, Zählungen 8⇒9,.kp-bald1⇒2,meldeMarke(7)⇒(8)für „Als App“,2 + 9Bilder, Selektorlisten,.kp-beispiel,.kp-karte--demo4⇒5,[data-demo]3⇒4) -
Step 1: Failing tests
it('Seite: Pille ≥ 30 pt, Leiste ≥ 24 pt, nicht unter dem iPhone', …) // Leiste links von x 1150 oder oberhalb y 410
it('nichts fokussierbar außer genau einem Regler', …)
it('Regler inaktiv: aria-hidden und inert', …)
it('Bewegung nur transform/opacity hinter no-preference', …)
it('Bühne: neun Stationen, Station 8 ist Öffnungszeiten live mit „Bald verfügbar“', …)
- Step 2–4: FAIL → Implementieren → PASS (
npm test;npx eslint src/components/home) - Step 5: Bauen und Budget prüfen –
npm run build && gzip -9 -c dist/assets/index-*.js | wc -c≤ 133 120 (≤ +0,7 KB gegenüber 127 638 B, Grenze 130 KB); neue Chunks ≤ 8 KB gz. - Step 6: Commit (im Relaunch-Worktree)
"Relaunch: Station Öffnungszeiten live"
Task 36: Handy-Bild phone-12-zeiten
Files:
-
Fixture-Kopie:
/root/reports/webklar-relaunch-2026-10-01/zeiten-station/fixture/aus…/funktionen-demos/fixture/(PORTAL⇒dev-zeiten, Port 5329 oder frei aus 5326/5327; Profilbaeckereimitfeatures.zeiten,zeiten-Angebot, GET-Mocks/api/zeiten/:projectIdund/vorschauvor dem generischen/api/) -
Modify (Relaunch-Worktree):
scripts/relaunch-bilder.py(BILDER["phone-12-zeiten"]),docs/relaunch/assets/portal/phone-12-zeiten.webp,public/relaunch/portal/phone-12-zeiten-780.webp,HINWEIS_PUNKTinKundenportal.tsxaus der gemessenen Knopfmitte -
Step 1: Aufnehmen – Capture nach Muster
capture-funktionen.mjs(402×874 @2x, feste Uhr Mo 5.10. 17:30): Funktionen → Kachel „Öffnungszeiten live“ → Editor, Knopf „Hinweis-Leiste anlegen“ sichtbar; Knopfmitte alsphone-12-hinweis-punkt.jsonspeichern. -
Step 2: Umwandeln –
webp-funktionen.mjsum den Namen ergänzen,python3 scripts/relaunch-bilder.py phone-12⇒ Handy-Bild ≤ 60 KB. -
Step 3: Prüfen –
npm test,npm run build, Durchlauf mit/root/reports/webklar-relaunch-2026-10-01/vorschau-chat/pruefen/kurz.mjs(Station sichtbar, Finger trifft den Knopf). -
Step 4: Commit (im Relaunch-Worktree)
"Relaunch: Portal-Bild für Öffnungszeiten live"
Block M – Abschluss
Task 37: Gesamtprüfung und Übergabe
- Step 1: Alle Suiten – Portal
npm testundcd runner && npm test; UInice node test/ui/zeiten-embed.ui.mjs,nice node test/ui/zeiten-portal.ui.mjs,nice node test/ui/funktionen.ui.mjs; WOMSnpm test+nice npx vite build; Relaunchnpm test+npm run buildmit Budget. Erwartet: grün bis auf die bekannten Basis-Kandidaten. - Step 2: Gegen die Spec abhaken – Abschnitte 1–12 der Spec einzeln gegen Code/Tests prüfen; Lücken als Nacharbeit-Task ergänzen.
- Step 3: Livegang-Notiz schreiben (
docs/superpowers/plans/2026-10-04-oeffnungszeiten-live-livegang.md), Reihenfolge aus Spec 13.6: nach TP2-Livegang Rebase aufmain;node scripts/zeiten-setup.mjs(vorher--check);node scripts/stripe-products.mjs --pruefen;.env/Compose:ZEITEN_ENABLED=pilot,ZEITEN_PILOT_EMAILS=kenso.gri@gmail.com,ZEITEN_KEIN_NEUBAU=emmas-schatzkiste, RunnerZEITEN_PUBLIC_BASE(Vorgabe reicht); Inhaber pusht Portal, WOMS, Relaunch (Relaunch-Station bleibtbald:truebison); Probelauf auftest-website; Rechtsrunde abgeschlossen ⇒on, Relaunchbald:false. - Step 4: Commit
"Öffnungszeiten: Livegang-Notiz"