Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-10-04-oeffnungszeiten-live.md
T

81 KiB
Raw Blame History

Ö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 (Branch feature/oeffnungszeiten); WOMS eigener Worktree /home/webklar/apps/ticket.webklar.com/dev-zeiten auf feature/terminplaner-tp2 (Task 31); Relaunch eigener Worktree /home/webklar/apps/webklar.com/dev-zeiten-station (Task 34). Nie in dev-termine (beide Repos) oder build/ schreiben, nie pushen, nie git 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.js importiert nichts statisch aus ./services/ (Test featureRegistry.test.js prüft per Regex); siteFeatures.js importiert zeiten/* nur per await 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 innerhalb feature-admin:/feature-paid:.
  • Werte aus der Spec, wörtlich: Preis 3000 Cent, 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, API max-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) und z-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, Date nur als Date.now( und Date.parse(, Kundentexte nur per textContent.
  • Neue Dateien heißen zeiten* (nie termine*); Tests test/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 legt scripts/zeiten-setup.mjs an (nicht feature-setup.mjs, dessen Tests genau 3 Attribute pinnen); test/support/schema.js hängt sie an schemaKeys('portalFeatures') an.
  • Abweichung von Spec 7 (bewusst): Der K02/K03-Block funktionen ist Rechtstext mit Pins; er wird nur als Entwurf für die Rechtsrunde geschrieben (Task 33), nicht im Code geändert.

Review Focus

  1. 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.
  2. 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.
  3. Kontakt-Leiste mit Teildaten: Nur Telefon ⇒ ein Knopf; nur Adresse ⇒ nur Route; an: true ohne jede Angabe ⇒ keine Leiste (kein leerer Balken). → Test in Task 11 (Antwort) und Task 26 (Skript).
  4. 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.
  5. Zu viele Daten: Überschreitet daten 32 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 Block zeiten, zeitenSetup), test/support/schema.js
  • Test: test/zeitenSetup.test.js, test/zeitenSchalter.test.js

Interfaces:

  • Produces: ZEITEN_COLLECTIONS ({ oeffnungszeiten: {attributes, indexes}, zeitenErinnerungen: {...} } im Format von server/services/termine/schema.js), ZEITEN_FEATURE_ATTRIBUTES (Attributliste für portalFeatures), 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 wie termineSetup); zeiten: { mode, pilotEmails, keinNeubau } aus ZEITEN_ENABLED, ZEITEN_PILOT_EMAILS, ZEITEN_KEIN_NEUBAU (Komma-Liste, Vorgabe 'emmas-schatzkiste'), als Geschwister von config.termine (nicht darin: termineSchalter.test.js pinnt config.termine); collections.oeffnungszeiten/zeitenErinnerungen mit APPWRITE_COLLECTION_…-Variablen.
    • schalter.js: Kopie von termine/schalter.js auf config.zeiten.
    • schema.js: reine Daten, kein config-Import; daten String 40000, team String 2000, ersatzHash String 64, updatedBy String 64, version/teamVersion Integer; Index idx_customer auf customerId (Löschung), Erinnerungen Index idx_datum.
    • zeiten-setup.mjs: Klon von scripts/termine-setup.mjs (main({check, client, log})), legt beide Collections und ZEITEN_FEATURE_ATTRIBUTES an portalFeatures an (idempotent).
    • test/support/schema.js: Zweig für ZEITEN_COLLECTIONS; bei portalFeatures zusätzlich ZEITEN_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 in test/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ächst async () => 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.features nach termine (Feldsatz wie news, 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 beim termine-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/KEYS ergänzen.
    • ticketDetails.js: vorläufig minimaleTicketDetails(ctx) aus featureRegistry.js zurückgeben.
  • Step 4: Pins anpassen (Schlüssellisten um 'zeiten'; Zählungen), Run npm 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 (nicht istFeiertag je Tag), Namen per holiday.translate('de'), Datum per holiday.dateString. „pruefen“: 24.12., 31.12., MARIAHIMMELFAHRT nur für BY, Fronleichnam für SN/TH aus der RP-Liste. Ein „pruefen“-Tag, der zugleich landesweiter Feiertag ist, bleibt feiertag.
  • Step 4: Run → PASS, node --test test/termine*.test.js unverä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, now immer als Parameter):

    • hauptplan(daten) → plan|null; planNachKurz(daten, kurz) → plan|null
    • zuUtcOderDanach(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 in vonTag hineinlaufende 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 mit plusTage, 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 Eintrag ab = Beginn der aktuellen Minute; ein Eintrag an jeder Berliner Mitternacht, an der sich der Text ändert; tag = Berliner Datum bei ab); 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 = von 00:00 Berlin, bis = bis 24: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', … } ohne name/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 &auml; &ouml; &uuml; &szlig; &#8211;); wochenplanHash(daten) → string (sha1 der tage des 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&#8211;18:00', 'geschlossen'
test('Kundentext wird maskiert', …) // Planname '<b>' ⇒ '&lt;b&gt;'
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() aus termine/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ührendem 49); 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 }> (wirft err.status=400, err.fehler bzw. 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 (Mount app.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}; disabled mit 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'] } = {}) mit Query.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 mit plaene[].{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.js grü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 nach app.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 über x-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, sendeMitEtag hasht JSON.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 (Block behalten), server/mail-templates/T16 Funktion bestellt.html (Blöcke abo, team), server/services/siteFeatures.js (sendT16 Default-Blöcke abo:false, team:false; bestellMail Block behalten)
  • 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: undefined neben analytics: undefined)
  • Test: test/zeitenNeubau.test.js; Pins: test/previewRunner.test.js (Job-Body mit zeiten: null, Dev-PUT ohne zeiten)

Interfaces:

  • Produces:

    • einbau.js: buildZeiten(project) → Promise<{ siteKey } | null> (wirft nie; nur bei Feature-Zustand setup/active; null bei Subdomain in config.zeiten.keinNeubau). Darf weder neubau.js noch previewRebuild.js importieren (Zyklus).
    • neubau.js: zeitenDeps = { rebuild: triggerRebuild }; stosseNeubauAn(project) → Promise<'angestossen'|'manuell'> unter withLocks(['zeiten-neubau']); 'angestossen' nur bei result?.queued === true; 'manuell' bei fehlendem Repo, Preview aus, Dev-Modus (previewDetected beginnt mit dev), 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>, Ergebnis einbau („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, Antwortfeld zeiten, zeitenStatus je Projekt; speichereNachweis nach server/services/featureConsents.js als speichereNachweisAusAnfrage(req, …) auslagern), server/services/featureOrder.js (pruefeAboFuerEinschalten), server/services/siteFeatures.js (featureView + unlockedBy, aboEndeAt, einbau, loeschungAm für zeiten)
  • 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> (frisch bestellbar(); kein_abo/gekuendigt ⇒ 409 Code kein_abo/gekuendigt mit 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 bei by === 'kunde'; unlockedBy = 'abo' bzw. 'team'; unter feature-order:<c>:<p>; Neubau/Ticket/Mail nach der Sperre).
    • holeZeitenNach(doc, { now }) → Promise<doc> (Ticket fehlt in setup ⇒ anlegen; unlockedBy === 'abo' und k24At leer ⇒ K24, dann k24At).
    • GET /api/funktionen: zeiten: { verfuegbar, inklusive: true|false|null, grund, preis: '30 €', einschalten: { consent, leistungsbeschreibung } }; je Projekt features.zeiten (erweiterte featureView), 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 nach pruefeAbo), server/services/siteFeatures.js (markPaid/späte Zahlung schreiben unlockedBy: 'kauf'; Lesehelfer unlockedByVon(doc) = doc.unlockedBy || (doc.unlockedByPurchase ? 'kauf' : 'team'))
  • Test: test/zeitenCheckout.test.js

Interfaces:

  • Produces: pruefeInklusive(feature, customerId) → Promise<void> (nur bei feature.aboInklusive === true; bestellbar ok oder nicht_aktiv ⇒ bestellFehler(409, 'Im Wartungs-Abo bereits enthalten – einfach einschalten.', 'inklusive', { grund }); Lesefehler ⇒ wie pruefeAbo); 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 Haken nachFreischaltung in HAKEN_NAMEN), server/services/siteFeatures.js (runActivation: nach activateClosedTickets nachFreischaltung aufrufen; für zeiten in setup per await 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 nicht zu; fail-closed); nachFreischaltung(doc) für zeiten = 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ür zeiten: activate ⇒ einschaltenZeiten({ by:'team', staff }); disable ⇒ disable(...) + nachAbschaltung; PATCH /:customerId/zeiten/team { projectId, teamVersion, navSelektor, kontaktleisteAus, hinweisleisteAus }; adminView liefert für zeiten zusätzlich plaene: [{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'> (weicht wochenplanHash(daten) von doc-Datensatz ersatzHash ab ⇒ 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>, einschaltenZeiten nimmt nur feature-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 }): zweck in metadata und invoice_data.metadata; bei zweck === 'behalten' Rücksprung #zeiten?behalten=1&projekt=<id>), server/services/siteFeatures.js (handleFeatureCheckoutEvent: Zweig metadata.zweck === 'behalten' vor markPaid/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 bei aboEndeAt gesetzt oder disabled mit abo_ende und Daten vorhanden, sonst 409; setzt behaltenSessionId; Zustand unverändert); bucheBehalten(session) → Promise<{ fall: 'behalten'|'unpassend' }> (unter feature-admin:<projectId>: unlockedBy:'kauf', aboEndeAt:null, behaltenSessionId:'', stripeSessionId = session.id, aus disabled zurück auf active/setup + Neubau; Nachweis mit dieser Sitzung; K15 mit Block behalten; 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ätzlich aboEndeAt und behaltenSessionId)
  • 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; nur unlockedBy === 'abo'; Schritte Spec 5: K26 mit Variante nach art, nach aboEndeAt + 30 Tage ⇒ disabled/abo_ende + K27 + nachAbschaltung, Rückkehr bis aboEndeAt + 60 Tage ⇒ zurück + Neubau aus disabled + K24 wieder, disabled/abo_ende + 30 Tage ⇒ loescheZeiten + state:'off' + ticketId, einbau, k24At, aboEndeAt, behaltenSessionId geleert; Lesefehler ⇒ Kunde überspringen).

  • Step 1: Failing tests (now als Parameter, Tage über plusTage)

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', …) und loescheZeitenVonKunde(customerId), beides per dynamischem Import in try/catch), server/services/retention.js (wo Funktionen eines gelöschten Kunden abgeschaltet werden: für zeiten zusätzlich loescheZeitenVonKunde)

  • 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-Job setTimeout 240 s, dann setInterval 24 h; Erinnerung setTimeout 300 s, dann setInterval 1 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 Schalter pilot/on und freigegebene Kunden, nur setup/active, nur im Fenster; je Tag zuerst Merker sha1(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 (zeitenPublicBase aus ZEITEN_PUBLIC_BASE, Vorgabe 'https://project.webklar.com/embed/', gleiche Regex wie analyticsPublicBase, Fehlertext ZEITEN_PUBLIC_BASE muss mit https:// beginnen und mit / enden), runner/src/validate.js (Block zeiten + Whitelist), runner/src/analytics-inject.js (injectIntoHtml(buf, tag, marker = 'data-wk-analytics'), Marker gegen /^data-wk-[a-z]+$/ prüfen; neu zeitenScriptTag(zeiten, baseUrl), injectZeiten(dir, zeiten, { baseUrl, onLine, fs })), runner/src/jobs.js (in publish() 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(...) wirft Error('Zeiten: ungültiger siteKey') / Error('Zeiten: ungültige öffentliche Basis-URL'), Logzeile Zeiten-Skript in n HTML-Datei(en) eingesetzt, keine Datenschutzseite.

  • Step 1: Failing tests (Helfer aus runner/test/analytics-inject.test.js kopieren)

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, per vm.runInNewContext testbar): 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 }; telefonLink zwischen // @telefon-anfang/// @telefon-ende (gleiche Regel wie public/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 ein fetch(… , { credentials:'omit' }); Zeitpunkt des letzten erfolgreichen Abrufs merken (nicht body.stand); Timer auf naechsterWechsel, Neuladen frühestens alle 10 min und bei visibilitychange; älter als 24 h oder Fehler ⇒ Platzhalter unverändert lassen; Platzhalter [data-webklar-zeiten] mit data-modus tabelle|status|badge und data-plan (Kurzname, unbekannt ⇒ Ersatz-HTML bleiben + console.warn); Light DOM, @layer wkz, Klassen wkz-…, 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 = geparste ld+json-Inhalte, unparsbare als null; LocalBusiness-Typen auch in @graph; seite = { name, adresse, url } füllt neuerBlock); 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, Muster test/ui/embed.ui.mjs + termineTestServer.mjs; Seiten vom Testserver selbst ausliefern; ctx.clock.install/fastForward für Timer). Prüfungen: hinweisleiste_aus (Team-Schalter ⇒ keine Leiste), fester_header_rueckt (Header mit position:fixed; top:0 steht unter der Leiste, nach Scrollen wieder bei 0), klebender_header, eigene_anrufleiste (Seite mit fester a[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 mit top:0 (oder team.navSelektor, querySelectorAll in try/catch) per Inline-top um max(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 bei matchMedia('(max-width: 768px)'), Shadow DOM, z-index:40, Abstandhalter am Ende von body, scroll-padding-bottom, rel="noopener noreferrer"; JSON-LD per textContent = 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; Text Betriebsurlaub vom TT.MM. bis TT.MM.); preisHinweisNoetig(text) → boolean (/%|statt/i); linkOk(url) → boolean.

  • Step 1: Stand abgleichen – git -C ../dev-termine log --oneline -5 und git fetch-freier Vergleich: Hat der TP2-Branch seit 6ae0f67 neue Commits, git rebase feature/terminplaner-tp2 (nur bei sauberem Baum), dann npm test. Konflikte in public/funktionen.js/app.js zugunsten 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, Knopf data-view="zeiten" data-project, Übergabe an #zeiten-body.dataset.wunschProjekt), public/app.js (FEATURE_LABELS.zeiten='Öffnungszeiten', VIEW_HASHES, viewRefreshers.zeiten mit import('/zeiten.js') und Sperre gegen Neuaufbau bei ungespeicherten Änderungen, APP_PARENT zeiten:'funktionen' hinter termine), 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); Abschnitte plaene(t), feiertage(t), ausnahmen(t) mit t = { 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 ruft GET /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, Muster termine-portal.ui.mjs mit 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 (Eintrag zeiten hinter termine, siteKeyOf für ['termine','zeiten'], aktionen für zeiten: 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: adminView für zeiten (Task 19).

  • Produces: zeitenStatusText(feature) → string („Leisten live – Einbau läuft“ / „wartet auf die Zeiten des Kunden“ bei hindernis === '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 -10 im 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 von DOCUMENTS)

  • Test: bestehende WOMS-Tests + Bau

  • Step 1: Failing test – Pin für setup-legal.mjs (Schlüssel, Titel, sort lü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 mit teamVersion (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 test und nice 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-Kommentar ENTWURF – 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 Block funktionen, 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, Pfeil unverändert verschoben; BaeckereiSeite.tsx importiert 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 }; Konstanten ZEIT_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.tsx mit teile.tsx in 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 (ZeitenSteuerung mit aktiv, bei !aktiv setzeUhr(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"), lokales HINWEIS_PUNKT, Zustand, Steuerung, Website- und Handy-Ebene, .kp-regler, ZeitenKarte, HandyReihe, Label „Neun Funktionen…“; aus ablauf.ts nur import type), src/components/home/kundenportal.css (.kp-regler in die Selektorlisten, .kp-karte__regler), Kommentare/Spec-Zählungen („acht Stationen“, „Höchstens 7 Karten“)

  • Test: src/test/demo-zeiten.test.tsx (erweitern); Pins src/test/relaunch-kundenportal.test.tsx (alle Stellen aus dem Anschluss-Datenblatt „relaunch (d) 1“: TITEL, UNTERSCHRIFTEN, Zählungen 8⇒9, .kp-bald 1⇒2, meldeMarke(7)⇒(8) für „Als App“, 2 + 9 Bilder, Selektorlisten, .kp-beispiel, .kp-karte--demo 4⇒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; Profil baeckerei mit features.zeiten, zeiten-Angebot, GET-Mocks /api/zeiten/:projectId und /vorschau vor 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_PUNKT in Kundenportal.tsx aus 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 als phone-12-hinweis-punkt.json speichern.

  • Step 2: Umwandeln – webp-funktionen.mjs um 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 test und cd runner && npm test; UI nice node test/ui/zeiten-embed.ui.mjs, nice node test/ui/zeiten-portal.ui.mjs, nice node test/ui/funktionen.ui.mjs; WOMS npm test + nice npx vite build; Relaunch npm test + npm run build mit 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 auf main; 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, Runner ZEITEN_PUBLIC_BASE (Vorgabe reicht); Inhaber pusht Portal, WOMS, Relaunch (Relaunch-Station bleibt bald:true bis on); Probelauf auf test-website; Rechtsrunde abgeschlossen ⇒ on, Relaunch bald:false.
  • Step 4: Commit "Öffnungszeiten: Livegang-Notiz"