Files
tickte-system/docs/superpowers/plans/2026-10-17-workers-p2-hochhaus-woms.md
T

168 KiB
Raw Blame History

Workers P2 – Hochhaus in WOMS – Umsetzungsplan

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: WOMS bekommt unter /hustler (Menüpunkt „Hustler“) das Pixel-Hochhaus: 10 Etagen, 19 Worker, echte Ereignisse als Animation, Kennzahlen, Worker-Karte mit Spur, Pausenschalter für alle Mitarbeiter und Einstellungen für Admins.

Architecture: Eine Plain-Canvas-2D-Engine (ES-Module unter src/components/workers/engine/, Layout als Daten, Regie über reine Schritte aus eventToSteps) wird vom React-Teil nur eingehängt: useWorkersFeed holt Zustand (15 s) und Ereignisse (5 s) über die Dienst-API (Übersicht §6.2) und reicht sie an mountTower weiter; Klick auf eine Figur ruft onSelect(workerId), React lädt die Spur. Die Seite wird per React.lazy geladen, damit Engine und Schrift nicht im Haupt-Bundle liegen.

Tech Stack: React 18, Vite 5, Canvas 2D (kein PixiJS), node:test + esbuild-loadModule, node:vm für den Engine-Rauchtest, Playwright (headless shell) nur als manuelle Abnahme, Pixelify Sans (SIL OFL) selbst gehostet.

Spec: /home/webklar/apps/ticket.webklar.com/dev-workers/docs/superpowers/specs/2026-10-17-workers-design.md · Schnittstellen: /home/webklar/apps/ticket.webklar.com/dev-workers/docs/superpowers/plans/2026-10-17-workers-00-uebersicht.md (verbindlich)

Global Constraints

  • Alle Regeln aus Übersicht §2 gelten.
  • Pfad WK=/home/webklar/apps/ticket.webklar.com/dev-workers (Worktree, Branch feature/workers); Agenten pushen nie, Live nur durch Kenso (git push origin feature/workers:test); git add nur mit Dateinamen; Commit-Nachrichten enden mit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.
  • Testbefehl je Datei: cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/<name>.test.mjs; Gesamtlauf: cd $WK && timeout 600 node --max-old-space-size=1024 --test tests/*.test.mjs tests/unit/*.test.mjs; vor schweren Läufen free -m.
  • Stil wie WOMS: ES-Module, keine Semikolons, einfache Anführungszeichen, deutsche Kommentare, UI-Texte deutsch mit Umlauten, keine Emojis; Dateien unter src/lib/ und engine/ importieren weder React noch Appwrite und greifen beim Import nicht auf window/document zu (laden mit loadModule() aus tests/unit/load.mjs).
  • Keine neue npm-Abhängigkeit (kein PixiJS, kein Test-Framework); package.json bleibt unverändert.
  • Welt 320 × 256 logische Pixel (5:4); Turm x 14..230, Räume ab x 42 (186 breit), Etage 22 hoch plus 1 Bodenlinie (Raster 23), Brücke 34 hoch, 10 Etagen in FLOORS-Reihenfolge, 19 Worker.
  • Takt: höchstens 30 Bilder/s (FRAME_MS 33), dt ≤ 0,05 s, höchstens 3 Sprechblasen gleichzeitig (≤ 20 Zeichen), höchstens 80 gleichzeitige Effekte, Canvas-Breite ≤ 2400 Geräte-Pixel.
  • Rückstau: je pushEvents-Aufruf animieren nur die neuesten 6 Ereignisse (BACKLOG_KEEP), ältere wirken still (nur Aufgabe/Pose); je Worker höchstens 4 wartende Gruppen; ein Schritt höchstens 45 s (STEP_TIMEOUT_S).
  • Polling: Zustand alle 15 000 ms, Ereignisse alle 5 000 ms (limit=200, höchstens 5 Seiten je Runde), Spur limit=50; nichts bei document.visibilityState === 'hidden', sofortiger Abruf beim Zurückkehren; JWT 10 min gecacht (createJwtCache), Header X-Appwrite-JWT, Basis /api/workers.
  • Rechte: Pausenschalter für alle Mitarbeiter; WorkersSettingsDialog nur für isAdmin; mailColdPerDay ≤ 3 (Spec §8.1), mailReplyCapPerDay ≤ 15; der Dialog sendet nie paused; die Formulargrenzen sind gleich oder enger als die des Dienstes (retentionDays 7–90, offerText 1–400, offerProductKey nur hosting, A11). Die Bounce-Notbremse (H4) zeigt WOMS nur an und hebt sie nur für Admins auf: Dialog-Knopf „Versand wieder freigeben“ sendet genau { sendingHalted: false }, nie als Teil des Formular-Patches; sendingHalted, sendingHaltedReason und privacyInteressentenLive sind schreibgeschützt (READONLY_KEYS, Übersicht §7).
  • Weißes Theme (Konzept 2026-10-04): keine Karten/Kästen, nur Haarlinien var(--line), nur Tokens aus global.css (keine Hex-Farben in workers.css), Klassen mit Präfix wk-, Zahlen tabular-nums, Breakpoints 1100 px und 860 px, „ד statt Icons.
  • Schrift: Pixelify Sans (SIL OFL 1.1) als public/fonts/PixelifySans-Variable.woff2 (Gewichte 400–700) plus Lizenztext public/fonts/OFL-PixelifySans.txt, font-display: swap, kein Google-Fonts-Link; Download nur in P2-T20, in einen neuen leeren Ordner, Prüfung der Magic Bytes wOF2.
  • Bewegung und Lebenszyklus: prefers-reduced-motion ⇒ keine Schleife, Standbild je Änderung; visibilitychange ⇒ setPaused; mountTower/destroy idempotent (React StrictMode: mount, destroy, mount).
  • Playwright/Chromium nur seriell unter systemd-run --scope -q -p MemoryMax=700M -p MemorySwapMax=0, vite-Build unter MemoryMax=1500M mit NODE_OPTIONS=--max-old-space-size=1200; Ausgabeordner immer neu unter $TMPDIR.
  • Keine .env-, Compose- oder nginx-Änderung in P2 (der /api/workers/-Block gehört zu P1); P2 schreibt nie nach Appwrite.
  • Anzeigename [Kenso, 17.10.: Name „Hustler“]: Alles, was Mitarbeiter sehen, heißt „Hustler“, nicht „Workers“: Menüpunkt, Seitentitel (H1), Browser-Tab-Titel, Titel des Einstellungs-Dialogs („Hustler-Einstellungen“), Leerzustände, Hinweise, Fehlertexte (Der Hustler-Dienst …, Hustler-Fehler <status>) und die Beschriftung der Leinwand. Die Seite liegt unter /hustler; /leads und /workers leiten per <Navigate replace> dorthin um und behalten die Query (/workers?tab=botleads&bot=<id> → /hustler?tab=botleads&bot=<id>, Übersicht §7). Technische Bezeichner bleiben unverändert: Sammlungen workers*, Dienst/Container webklar-workers, Repo knso/workers-dienst, API /api/workers/*, Dateien und Komponenten (WorkersPage.jsx, workersApi.js, workersRoster.js …), CSS-Präfix wk-, Umgebungsvariablen WORKERS_*, workerId, Ereignisarten. Das Wort „Worker“ für eine einzelne Figur oder Rolle bleibt (Übersicht §7, Namensregel). „Workers“ in Commit-Präfixen und Plan-Dateinamen ist das Projekt-Kürzel.
  • Kosten [Kenso, 17.10.: max. 150 € im Monat]: Anthropic rechnet in USD ab, die Grenzen sind deshalb monthlyBudgetUsd 150 (150 ≈ 130 €, solange 1 € ≥ 1), dailyBudgetUsd 6 (Ausreißerschutz, vorher 10) und warnPct 80. P2 legt keine Standardwerte fest (die setzt der Dienst, P1); sie stehen hier nur in Test-Fixtures, Beispielwerten und Formulartests. Das harte Limit ist das Ausgabenlimit des Console-Workspaces (150 $/Monat, Betriebsanleitung P1).

Ergänzungen E1–E7 (Fixture-Form, JWT, Zusatzdateien, mountTower-Optionen, API-Formate, Ereignis-meta, Reiter/Tagesziel) sind in der Übersicht nachgetragen: siehe Übersicht §11.1, P2-E1 bis P2-E7 (dort auch der Reiter freigabe, den P3 anhängt: P3-E11); „E1“ bis „E7“ in diesem Plan meinen diese Zeilen.

Review Focus

  1. Dienst nicht erreichbar oder nginx-Block fehlt (HTML-Seite mit Status 200, 502, Netzfehler) → deutsche Meldung „Der Hustler-Dienst antwortet nicht richtig.“ bzw. „… ist gerade nicht erreichbar.“ statt Absturz oder NaN; die Seite bleibt bedienbar und fragt weiter ab. Tests: P2-T02 (kpiTiles ohne Daten), P2-T04 (Fehlertexte), P2-T19 (Board-Zustände).
  2. Tab lange im Hintergrund, danach 200+ Ereignisse auf einmal → keine Animationsflut: höchstens 6 Gruppen laufen, der Rest wirkt still, alle Warteschlangen sind nach ≤ 60 s Simulationszeit leer; der Feed holt seitenweise (200, höchstens 5 je Runde). Tests: P2-T05, P2-T13.
  3. Dasselbe Ereignis kommt in zwei Abfragen (gleicher Zeitstempel am Cursor) und die Vorgeschichte vor dem Öffnen der Seite → nie doppelt abgespielt, Vorgeschichte nicht nachgespielt. Tests: P2-T03 (takeNewEvents), P2-T05 (Cursor aus dem Snapshot).
  4. Raum ohne Quelle (nur die acht Such-Räume aus SEARCH_ROOMS können es sein; der Dienst meldet alle anderen Worker von Anfang an als angeschlossen, P1-E9), Worker fehlt im Zustand, unbekannte Ereignisart oder unbekannte workerId → grauer Raum mit Schild „noch nicht angeschlossen“, schlafende Figur, keine vorgespielte Arbeit, kein Wurf. Tests: P2-T03, P2-T11, P2-T12, P2-T13.
  5. link im Ereignisstrom mit javascript:, data: oder //fremder.host → nur Text, nie ein href; externe Links mit rel="noopener noreferrer". Tests: P2-T02 (safeLink), P2-T17 (Quelltext-Test der Karte).
  6. Bounce-Notbremse (H4): state.sendingHalted → rote Warnzeile im Board mit dem Grund als Textknoten (nie HTML); „Versand wieder freigeben“ nur im Admin-Dialog, sendet allein { sendingHalted: false } (kein Patch aus dem Formular, kein paused), und die drei Felder sendingHalted, sendingHaltedReason, privacyInteressentenLive landen nie im Formular-Patch. Tests: P2-T16 (READONLY_KEYS, leerer Patch), P2-T18, P2-T19, P2-T21 (Prüfstand).
  7. Das Formular lässt nichts zu, was der Dienst ablehnt (sonst kommt eine Serverfehlermeldung statt der Feldmeldung): retentionDays 7–90, offerText 1–400 Zeichen. Test: P2-T16 (Grenzen 6/7/90/91 und 400/401).

File Structure

Alles unter $WK. Neu:

Datei Verantwortung
src/lib/workersRoster.js Spiegel von Übersicht §3: ROLES, FLOORS, FLOOR_TITLES, ROOM_TITLES, MODEL_IDS, hatForModel, hatForWorker, roleOf
tests/fixtures/workers-roster.json gemeinsame Fixture (E1)
src/lib/workersView.js reine Anzeige-Ableitungen (Formate, Status, Kacheln, Zähler, safeLink, Spur-Zeilen, Reiterwahl)
src/lib/workersEvents.js EVENT_KINDS, Deduplizierung, reduceEvents, eventToSteps, needsStateRefresh
src/lib/workersApi.js · src/lib/workersClient.js API-Client mit Fehlertexten (fetch injiziert) · Verdrahtung mit Appwrite-JWT
src/lib/workersFeed.js · src/hooks/useWorkersFeed.js Polling-Steuerung (Timer injiziert, testbar) · React-Hooks useWorkersFeed, useWorkerTrace
src/lib/workersSettingsForm.js Formularlogik der Einstellungen (Umwandlung, Prüfung mit den Grenzen des Dienstes, Patch; schreibgeschützt: READONLY_KEYS inkl. privacyInteressentenLive, sendingHalted, sendingHaltedReason)
engine/sprites.js Raster setGrid/px, Palette, Figuren mit Posen/Grau, Text, Sprechblasen (Quelle entwuerfe/sprites.js Z. 7–142, 277–307)
engine/props.js Möbel/Requisiten und 7×7-Branchen-Icons (Quelle sprites.js Z. 146–273)
engine/layout.js · engine/camera.js Etagen/Räume/Plätze als Daten · Kamera, Zoom, Umkehr-Transformation, Treffer-Auswahl
engine/fx.js · engine/lift.js Effekt-System · Aufzug (Quelle scene-turm.js Z. 366–393, 316–330, 354–492)
engine/rooms.js · engine/building.js Raumzeichnung nach kind, Pokal-Seiten · Gebäudehülle, Schacht, Etagenschilder, Pausenschleier (Quelle scene-turm.js Z. 99–314)
engine/director.js Agenten, Snapshot ohne Animation, Schritt-Regie, Rückstau-Kappung, Leerlauf, Wachhund (Quelle scene-turm.js Z. 416–464, 864–905)
engine/scene.js mountTower: Größe, Schleife, Eingabe, Kamera, Lebenszyklus
src/components/workers/{WorkersKpis,WorkerCard,WorkerList,WorkersSettingsDialog,WorkersStage,WorkersBoard}.jsx UI-Teile (Board = Inhalt des Reiters „Hochhaus“, ohne Auth, damit der Prüfstand ihn mounten kann; Board zeigt bei sendingHalted eine Warnzeile, der Einstellungs-Dialog hat den Block „Versand wieder freigeben“, H4)
src/pages/WorkersPage.jsx · src/styles/workers.css Seite mit WORKERS_TABS (zunächst nur hochhaus) und der api-Instanz · Stil (wk-, @font-face)
public/fonts/PixelifySans-Variable.woff2 · public/fonts/OFL-PixelifySans.txt Schrift und Lizenz
scripts/workers-harness/{build.mjs,entry.jsx,index.html,scripted-api.js,shot.cjs} Prüfstand für die manuelle Playwright-Abnahme
tests/unit/helpers/{fakeCtx,workersFixtures,engineVm}.mjs Test-Hilfen (aufzeichnender Kontext, 19-Worker-Zustand, vm-Sandbox)
tests/unit/workers{Roster,View,Events,Api,Feed,Sprites,Props,Layout,Camera,Fx,Lift,Rooms,Director,Scene,Smoke,SettingsForm,Components,Css,Font,Route,Harness}.test.mjs je ein Test pro Baustein

Ändern: src/App.jsx (lazy Route /hustler, /leads und /workers → /hustler), src/lib/navLinks.js (Eintrag hustler), tests/unit/navLinks.test.mjs. Kein Offscreen-Cache für die Kulisse (Entscheidung: erst messen; stats().avgDrawMs wird in P2-T21 geprüft).


P2-T01: Roster-Spiegel und gemeinsame Fixture

Files:

  • Create: src/lib/workersRoster.js, tests/fixtures/workers-roster.json
  • Test: tests/unit/workersRoster.test.mjs

Interfaces:

  • Consumes: Übersicht §3 (Tabelle), E1.

  • Produces:

    export const FLOORS            // ['bruecke','anfragen','ohne_website','bewertung','gespraeche','werkstatt','pruefung','freigabe','tresor','keller']
    export const ROLES             // 19 × { workerId, name, floor, room, model:'opus'|'sonnet'|'haiku', role, caps }
    export const MODEL_IDS         // { opus:'claude-opus-5-5', sonnet:'claude-sonnet-5-5', haiku:'claude-haiku-5-5' }
    export const SEARCH_ROOMS      // ['netz','kleinanzeigen','portale','foren','aerzte','handwerk','selbst','laden'] – Spiegel von `src/roles.js` des Dienstes (Räume der Etagen anfragen + ohne_website); nur diese Räume können „nicht angeschlossen“ (grau) sein, alle anderen Worker meldet der Dienst als angeschlossen (Übersicht §11.1 P1-E9)
    export const FLOOR_TITLES      // id → 'Kommandobrücke' | 'Anfragen' | 'Ohne Website' | 'Bewertung' | 'Gespräche' | 'Werkstatt' | 'Prüfung' | 'Freigabe' | 'Tresor' | 'Keller'
    export const ROOM_TITLES       // 21 Räume (19 Worker-Räume + 'pult','tresor') → Titel, Werte siehe Schritt 3
    export function hatForModel(model): 'opus'|'sonnet'|'haiku'          // Kurzname oder voller Modellname, sonst 'haiku'
    export function hatForWorker(workerId): 'overseer'|'opus'|'sonnet'|'haiku'   // otto → 'overseer'
    export function roleOf(workerId): object | null
    
  • Step 1: Failing test schreiben (tests/unit/workersRoster.test.mjs)

    import test from 'node:test'
    import assert from 'node:assert/strict'
    import { readFileSync } from 'node:fs'
    import { loadModule } from './load.mjs'
    const { ROLES, FLOORS, SEARCH_ROOMS, FLOOR_TITLES, ROOM_TITLES, MODEL_IDS, hatForModel, hatForWorker, roleOf } = await loadModule('src/lib/workersRoster.js')
    const fixture = JSON.parse(readFileSync(new URL('../fixtures/workers-roster.json', import.meta.url), 'utf8'))
    
    test('Spiegel ist gleich der gemeinsamen Fixture', () => {
      assert.deepEqual([...FLOORS], fixture.floors)
      assert.deepEqual(JSON.parse(JSON.stringify(ROLES)), fixture.roles)
    })
    test('19 Worker, workerId und Raum einmalig, Etagen und Titel vollständig', () => {
      assert.equal(ROLES.length, 19)
      assert.equal(new Set(ROLES.map((r) => r.workerId)).size, 19)
      assert.equal(new Set(ROLES.map((r) => r.room)).size, 19)
      for (const r of ROLES) assert.ok(FLOORS.includes(r.floor), r.workerId)
      assert.equal(FLOORS.length, 10)
      assert.deepEqual(Object.keys(FLOOR_TITLES).sort(), [...FLOORS].sort())
      assert.deepEqual(Object.keys(ROOM_TITLES).sort(), [...ROLES.map((r) => r.room), 'pult', 'tresor'].sort())
    })
    test('Hut zeigt das Modell, Otto trägt die Kommandomütze', () => {
      assert.deepEqual(['opus', 'claude-sonnet-5-5', 'claude-haiku-5-5', 'claude-xyz', undefined].map(hatForModel), ['opus', 'sonnet', 'haiku', 'haiku', 'haiku'])
      assert.deepEqual(['otto', 'bob', 'bea', 'kim', 'zed'].map(hatForWorker), ['overseer', 'opus', 'sonnet', 'haiku', 'haiku'])
      assert.equal(roleOf('paul').room, 'pruefung')
      assert.equal(roleOf('zed'), null)
      assert.deepEqual(MODEL_IDS, { opus: 'claude-opus-5-5', sonnet: 'claude-sonnet-5-5', haiku: 'claude-haiku-5-5' })
    })
    test('SEARCH_ROOMS: festgenagelt wie im Dienst (P1-T05) und aus der Fixture ableitbar; nur diese Worker können grau sein (P1-E9)', () => {
      assert.deepEqual([...SEARCH_ROOMS], ['netz', 'kleinanzeigen', 'portale', 'foren', 'aerzte', 'handwerk', 'selbst', 'laden'])
      assert.deepEqual([...SEARCH_ROOMS], fixture.roles.filter((r) => ['anfragen', 'ohne_website'].includes(r.floor)).map((r) => r.room))
      assert.deepEqual(ROLES.filter((r) => SEARCH_ROOMS.includes(r.room)).map((r) => r.workerId), ['rita', 'kim', 'pia', 'finn', 'ali', 'hanna', 'sven', 'lea'])
      assert.equal(ROLES.filter((r) => !SEARCH_ROOMS.includes(r.room)).length, 11)
    })
    
  • Step 2: Test laufen lassen, Scheitern sehen Einmalig: cd $WK && git status --short (keine geänderten Dateien; ??-Zeilen für noch nicht eingecheckte Plan-Dokumente unter docs/superpowers/plans/ sind erlaubt) und ln -s ../build/node_modules node_modules (erscheint als ?? node_modules, nie committen). Run: cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersRoster.test.mjs Expected: FAIL, esbuild Could not resolve …/src/lib/workersRoster.js.

  • Step 3: Implementieren in src/lib/workersRoster.js und tests/fixtures/workers-roster.json Fixture und ROLES aus Übersicht §3 erzeugen (P2-E1): Form { "floors": [<10 Etagen-Ids>], "roles": [{ "workerId", "name", "floor", "room", "model", "role", "caps" }] }, 19 Einträge in Tabellenreihenfolge, model = opus|sonnet|haiku, role = Spalte „Einzige Rolle“ wörtlich, caps = Spalte „Erlaubte Fähigkeiten“ (bei „wie ali“ aufgelöst), 2 Leerzeichen Einrückung, Zeilenumbruch am Ende. Liegt /home/webklar/apps/workers-dev/test/fixtures/workers-roster.json (P1) schon vor, wird sie byte-identisch kopiert und ROLES danach ausgerichtet. ROLES/FLOORS mit Object.freeze (tief genug für deepEqual nach JSON-Rundlauf). SEARCH_ROOMS = Räume der Etagen anfragen und ohne_website in ROLES-Reihenfolge, eingefroren (dieselbe Ableitung wie in P1-T05; WOMS berechnet connected nicht selbst, die Konstante dient Anzeige und Prüfstand). ROOM_TITLES: netz „Netz-Radar“, kleinanzeigen „Kleinanzeigen“, portale „Portale“, foren „Foren & Fragen“, aerzte „Ärzte & Praxen“, handwerk „Handwerk“, selbst „Selbstständige“, laden „Läden & Gastro“, waage „Waage“, mail „Mail-Stube“, forum „Forum-Stube“, posteingang „Posteingang“, abschluss „Abschluss“, tisch1/2/3 „Tisch 1/2/3“, pruefung „Prüftisch“, archiv „Archiv & Register“, bruecke „Brücke“, pult „Dein Pult“, tresor „Tresor“. hatForModel: Treffer auf /opus|sonnet|haiku/ im Text, sonst 'haiku'.

  • Step 4: Test laufen lassen, Bestehen sehen — gleicher Befehl wie Step 2. Expected: # pass 4, # fail 0.

  • Step 5: Commit

    cd $WK && git add src/lib/workersRoster.js tests/fixtures/workers-roster.json tests/unit/workersRoster.test.mjs
    git commit -m "Workers P2: Roster-Spiegel und gemeinsame Fixture" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
    

P2-T02: Anzeige-Logik workersView.js

Files:

  • Create: src/lib/workersView.js
  • Test: tests/unit/workersView.test.mjs

Interfaces:

  • Consumes: workersRoster.js (MODEL_IDS).

  • Produces (Namen aus Übersicht §7 fett):

    export function formatUsd(n): string        // 7.84 → '7,84 $' (NBSP vor $), null/NaN/Text → '–'
    export function formatEur(n): string        // 120 → '120 €', 19.5 → '19,50 €' (NBSP)
    export function formatCount(n): string      // 1234 → '1.234', sonst '–'
    export function formatTime(iso, now = Date.now()): string   // Europe/Berlin; gleicher Tag 'HH:MM', sonst 'TT.MM. HH:MM'; ungültig '–'
    export function workerStatusLabel(status): string
    export function statusTone(status): 'green'|'orange'|'plain'|'red'
    export function kpiTiles(kpis): [{ key, label, value, sub, tone }]       // 6 Kacheln
    export function counterLines(counters): [{ key, label, value }]
    export function tokenTotal(tokens): number                               // Zahl oder { in, out }
    export function safeLink(link): { href, external } | null
    export function traceRows(events, now?): [{ id, time, text, link }]      // neueste zuerst, höchstens 50
    export function pauseLabel(paused): string
    export function pickTab(tabs, id): tab                                    // unbekannt → erster Reiter
    
  • Step 1: Failing test schreiben (tests/unit/workersView.test.mjs, const V = await loadModule('src/lib/workersView.js'))

    const NB = String.fromCharCode(160)   // geschütztes Leerzeichen vor $ und €
    const K = { newAbosMonth: 3, mrrNewEur: 60, dealsMonth: 3, openThreads: 7, pultOpen: 2, botLeads: 112, apiTodayUsd: 5.1, apiMonthUsd: 50, dailyBudgetUsd: 6, monthlyBudgetUsd: 150 }   // [Kenso, 17.10.: max. 150 € im Monat]: Tagesgrenze 6 $, Monatsgrenze 150 $
    test('Zahlenformate', () => {
      assert.equal(V.formatUsd(7.84), `7,84${NB}$`); assert.equal(V.formatUsd(1234.5), `1.234,50${NB}$`); assert.equal(V.formatUsd(0), `0,00${NB}$`)
      for (const bad of [null, undefined, NaN, 'x']) assert.equal(V.formatUsd(bad), '–')
      assert.equal(V.formatEur(120), `120${NB}€`); assert.equal(V.formatEur(19.5), `19,50${NB}€`)
      assert.equal(V.formatCount(1234), '1.234'); assert.equal(V.formatCount(null), '–')
    })
    test('formatTime: Berlin-Zeit, heute nur Uhrzeit', () => {
      const now = Date.parse('2026-10-17T10:00:00Z')
      assert.equal(V.formatTime('2026-10-17T20:30:00Z', now), '22:30')
      assert.equal(V.formatTime('2026-10-16T21:30:00Z', now), '16.10. 23:30')
      assert.equal(V.formatTime('kaputt', now), '–'); assert.equal(V.formatTime(null, now), '–')
    })
    test('Status: Texte und Töne', () => {
      const s = ['arbeitet', 'wartet', 'leerlauf', 'grau', 'fehler', 'x']
      assert.deepEqual(s.map(V.workerStatusLabel), ['Arbeitet', 'Wartet auf Auftrag', 'Leerlauf', 'Noch nicht angeschlossen', 'Fehler', 'Unbekannt'])
      assert.deepEqual(s.map(V.statusTone), ['green', 'orange', 'plain', 'plain', 'red', 'plain'])
    })
    test('kpiTiles: sechs Kacheln, Töne nach 80-%-Regel', () => {
      const t = V.kpiTiles(K)
      assert.deepEqual(t.map((x) => x.key), ['abos', 'deals', 'threads', 'pult', 'botleads', 'api'])
      assert.deepEqual(t.map((x) => x.label), ['Neue Abos (Monat)', 'Deals (Monat)', 'Gespräche offen', 'Wartet auf dich', 'Bot-Leads gesamt', 'API heute'])
      assert.deepEqual(t.map((x) => x.value), ['3', '3', '7', '2', '112', `5,10${NB}$`])
      assert.equal(t[0].sub, `60${NB}€ neu pro Monat`); assert.equal(t[5].sub, `von 6,00${NB}$`)
      assert.deepEqual(t.map((x) => x.tone), ['', '', '', 'warn', '', 'warn'])
      assert.equal(V.kpiTiles({ ...K, apiTodayUsd: 6 })[5].tone, 'neg'); assert.equal(V.kpiTiles({ ...K, apiTodayUsd: 4.7 })[5].tone, '')
      assert.equal(V.kpiTiles({ ...K, dailyBudgetUsd: 0 })[5].tone, ''); assert.equal(V.kpiTiles({ ...K, pultOpen: 0 })[3].tone, '')
    })
    test('kpiTiles ohne Daten: Striche, kein NaN (Review Focus 1)', () => {
      for (const k of [null, undefined, {}]) for (const x of V.kpiTiles(k)) { assert.equal(x.value, '–'); assert.ok(!/NaN|undefined/.test(x.sub)); assert.equal(x.tone, '') }
    })
    test('counterLines: bekannte Zähler zuerst, Rest alphabetisch, unbekannter Schlüssel als Beschriftung', () => {
      assert.deepEqual(V.counterLines({ zeta: 1, gesendet: 3, gelesen: 41 }), [
        { key: 'gelesen', label: 'gelesen', value: '41' }, { key: 'gesendet', label: 'gesendet', value: '3' }, { key: 'zeta', label: 'zeta', value: '1' }])
      assert.equal(V.counterLines({ entwuerfe: 2 })[0].label, 'Entwürfe'); assert.deepEqual(V.counterLines(null), [])
      assert.equal(V.tokenTotal(1200), 1200); assert.equal(V.tokenTotal({ in: 1000, out: 234 }), 1234); assert.equal(V.tokenTotal(undefined), 0)
    })
    test('safeLink: nur http(s) und interne Pfade (Review Focus 5)', () => {
      assert.deepEqual(V.safeLink('https://x.de/a'), { href: 'https://x.de/a', external: true })
      assert.deepEqual(V.safeLink('/customers/1'), { href: '/customers/1', external: false })
      for (const bad of ['javascript:alert(1)', ' JavaScript:alert(1)', 'data:text/html,x', '//evil.example', 'mailto:a@b.de', '', null, 42]) assert.equal(V.safeLink(bad), null, String(bad))
    })
    test('traceRows: neueste zuerst, höchstens 50, unsicherer Link bleibt Text', () => {
      const evs = Array.from({ length: 60 }, (_, i) => ({ id: `e${i}`, ts: new Date(Date.UTC(2026, 9, 17, 8, 0, i)).toISOString(), text: `T${i}`, link: i === 59 ? 'javascript:x' : null }))
      const rows = V.traceRows(evs, Date.parse('2026-10-17T12:00:00Z'))
      assert.equal(rows.length, 50); assert.equal(rows[0].id, 'e59'); assert.equal(rows[0].link, null); assert.equal(rows[0].text, 'T59')
    })
    test('pauseLabel und pickTab', () => {
      assert.equal(V.pauseLabel(false), 'Alles anhalten'); assert.equal(V.pauseLabel(true), 'Fortsetzen')
      const tabs = [{ id: 'hochhaus' }, { id: 'pult' }]
      assert.equal(V.pickTab(tabs, 'pult').id, 'pult'); assert.equal(V.pickTab(tabs, 'nope').id, 'hochhaus'); assert.equal(V.pickTab(tabs, null).id, 'hochhaus')
    })
    
  • Step 2: Scheitern sehen — cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersView.test.mjs. Expected: FAIL Could not resolve …/workersView.js.

  • Step 3: Implementieren src/lib/workersView.js Zahlen über Intl.NumberFormat('de-DE'); nur typeof n === 'number' && Number.isFinite(n) zählt, sonst '–'; Zeichen vor $/€ ist das geschützte Leerzeichen (Code 160, im Test NB). formatEur ohne Nachkommastellen bei ganzen Zahlen. formatTime über Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', … }), „gleicher Tag“ = gleiches Berliner Datum wie now. Kachel-Ton api: ratio = apiTodayUsd / dailyBudgetUsd nur bei dailyBudgetUsd > 0: ≥ 1 ⇒ 'neg', ≥ 0,8 ⇒ 'warn', sonst ''; Kachel pult 'warn' bei pultOpen > 0. Bekannte Zähler (Reihenfolge, Beschriftung): gelesen, gefunden, passend, bewertet, entwuerfe „Entwürfe“, gesendet, gebaut, geprueft „geprüft“. safeLink trimmt, prüft /^https?:\/\//i (extern) oder /^\/(?!\/)/ (intern). traceRows sortiert per Date.parse absteigend, ungültige Zeiten zuletzt.

  • Step 4: Bestehen sehen — gleicher Befehl. Expected: # pass 9, # fail 0.

  • Step 5: Commit

    cd $WK && git add src/lib/workersView.js tests/unit/workersView.test.mjs
    git commit -m "Workers P2: Anzeige-Logik (Formate, Kacheln, safeLink, Spur-Zeilen)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
    

P2-T03: Ereignis-Logik workersEvents.js

Files:

  • Create: src/lib/workersEvents.js
  • Test: tests/unit/workersEvents.test.mjs

Interfaces:

  • Consumes: workersRoster.js (ROLES).

  • Produces (reduceEvents, eventToSteps laut Übersicht §7):

    export const EVENT_KINDS                  // die 26 Arten aus Übersicht §5, in dieser Reihenfolge
    export const STEP_OPS                     // ['task','say','pose','give','fx','cheer']
    export const SPOTS                        // ['pult','tresor','aussen','archiv']
    export function takeNewEvents(seen: Set<string>, events): event[]      // neu = Id noch nicht gesehen, aufsteigend nach ts
    export function latestTs(events, prev = null): string | null
    export function indexWorkers(state): { [workerId]: { status, task, lastTs, connected, counters } }
    export function reduceEvents(stateByWorker, events): { [workerId]: { status, task, lastTs, … } }   // unverändert eingegebenes Objekt
    export function eventToSteps(event): step[]
    export function needsStateRefresh(events): boolean
    export function cutBubble(text): string   // höchstens 20 Zeichen
    

    Schritte: {op:'task',who,text} · {op:'say',who,text} · {op:'pose',who,pose:'type'|'stand'|'coffee'|'sleep'} · {op:'give',from,to} · {op:'fx',fx:'paper'|'envelope'|'coin'|'stampOk'|'stampNo'|'sparkle'|'alert'|'drawer',at,to?,delay?} (at/to = workerId oder Platz aus SPOTS) · {op:'cheer',who}.

  • Step 1: Failing test schreiben (tests/unit/workersEvents.test.mjs, const E = await loadModule('src/lib/workersEvents.js'), Roster per zweitem loadModule für ROLES)

    const TS = '2026-10-17T20:00:00.000Z'
    const ev = (kind, workerId, extra = {}) => ({ id: `${kind}-${workerId}`, ts: TS, workerId, kind, text: 'Text', ...extra })
    
    test('EVENT_KINDS: 26 Arten aus Übersicht §5', () => {
      assert.deepEqual([...E.EVENT_KINDS], ['auftrag', 'leerlauf', 'gelesen', 'fund', 'doppelt', 'uebergabe', 'bewertet', 'top3', 'botlead', 'entwurf', 'pult', 'gesendet', 'eingang', 'sortiert', 'bau_start', 'bau_fertig', 'pruefung_zurueck', 'pruefung_ok', 'vorschau_link', 'angebot', 'deal', 'gesperrt', 'budget_warnung', 'budget_stopp', 'pause', 'fehler'])
    })
    test('takeNewEvents: dedupliziert nach id, sortiert nach ts, Gedächtnis begrenzt (Review Focus 3)', () => {
      const seen = new Set(), a = { id: 'a', ts: '2026-10-17T20:00:02Z' }, b = { id: 'b', ts: '2026-10-17T20:00:01Z' }
      assert.deepEqual(E.takeNewEvents(seen, [a, b]).map((e) => e.id), ['b', 'a'])
      assert.deepEqual(E.takeNewEvents(seen, [a, b, { id: 'c', ts: '2026-10-17T20:00:03Z' }]).map((e) => e.id), ['c'])
      const noId = { ts: '2026-10-17T20:00:04Z', workerId: 'kim', kind: 'gelesen', text: 'x' }
      assert.equal(E.takeNewEvents(seen, [noId]).length, 1); assert.equal(E.takeNewEvents(seen, [noId]).length, 0)
      assert.deepEqual(E.takeNewEvents(seen, [null, {}, { id: 'z' }]), [])
      const big = new Set(); E.takeNewEvents(big, Array.from({ length: 1500 }, (_, i) => ({ id: `i${i}`, ts: new Date(Date.UTC(2026, 9, 17, 0, 0, i)).toISOString() })))
      assert.ok(big.size <= 1000)
    })
    test('latestTs: größter gültiger Zeitstempel', () => {
      assert.equal(E.latestTs([{ ts: '2026-10-17T20:00:01+00:00' }, { ts: '2026-10-17T20:00:05Z' }, { ts: 'kaputt' }], null), '2026-10-17T20:00:05Z')
      assert.equal(E.latestTs([], 'alt'), 'alt'); assert.equal(E.latestTs([], null), null)
    })
    test('reduceEvents: Status aus der Ereignisart, grau bleibt grau, Altes und Fremdes wird ignoriert (Review Focus 4)', () => {
      const base = { kim: { status: 'wartet', task: '', lastTs: null }, rita: { status: 'grau', task: '', lastTs: null }, bea: { status: 'arbeitet', task: 'alt', lastTs: '2026-10-17T20:00:09Z' } }
      const copy = JSON.parse(JSON.stringify(base))
      const out = E.reduceEvents(base, [ev('gelesen', 'kim', { text: 'Kim hat 3 Gesuche gelesen' }), ev('fund', 'rita'), ev('fehler', 'bea'), ev('pause', 'system'), ev('fund', 'zed'), ev('auftrag', 'otto', { to: 'kim' })])
      assert.deepEqual(base, copy)
      assert.deepEqual(out.kim, { status: 'wartet', task: 'Text', lastTs: TS })          // zuletzt: Auftrag von Otto
      assert.equal(out.rita.status, 'grau'); assert.equal(out.bea.status, 'arbeitet'); assert.equal(out.bea.task, 'alt')   // Ereignis älter als lastTs
      assert.equal(E.reduceEvents(base, [ev('leerlauf', 'kim')]).kim.status, 'leerlauf')
      assert.equal(E.reduceEvents(base, [ev('fehler', 'kim')]).kim.status, 'fehler')
      assert.equal(E.indexWorkers({ lastEventTs: TS, workers: [{ workerId: 'kim', status: 'arbeitet', task: 't', connected: false, counters: { a: 1 } }] }).kim.status, 'grau')
    })
    test('eventToSteps: feste Zeilen', () => {
      assert.deepEqual(E.eventToSteps(ev('gelesen', 'kim', { meta: { count: 3 } })), [{ op: 'pose', who: 'kim', pose: 'type' }, { op: 'task', who: 'kim', text: 'Text' }, { op: 'say', who: 'kim', text: '3 gelesen' }])
      assert.deepEqual(E.eventToSteps(ev('doppelt', 'arne')), [{ op: 'task', who: 'arne', text: 'Text' }, { op: 'say', who: 'arne', text: 'Schon bekannt' }, { op: 'fx', fx: 'stampNo', at: 'arne' }])
      assert.deepEqual(E.eventToSteps(ev('uebergabe', 'rita', { to: 'bea' })), [{ op: 'task', who: 'rita', text: 'Text' }, { op: 'give', from: 'rita', to: 'bea' }])
      assert.deepEqual(E.eventToSteps(ev('pruefung_zurueck', 'paul', { to: 'bob', meta: { round: 2 } })), [{ op: 'task', who: 'paul', text: 'Text' }, { op: 'fx', fx: 'stampNo', at: 'paul' }, { op: 'say', who: 'paul', text: 'Runde 2 zurück' }, { op: 'give', from: 'paul', to: 'bob' }])
      assert.deepEqual(E.eventToSteps(ev('deal', 'system')), [0, 0.4, 0.8].map((delay) => ({ op: 'fx', fx: 'coin', at: 'aussen', to: 'tresor', delay })).concat([{ op: 'cheer', who: 'otto' }, { op: 'say', who: 'otto', text: 'Deal! Super!' }]))
      assert.deepEqual(E.eventToSteps(ev('gesperrt', 'system')), [{ op: 'fx', fx: 'stampNo', at: 'arne' }, { op: 'say', who: 'arne', text: 'Gesperrt' }])   // system: kein task-Schritt, Arne stempelt
    })
    test('eventToSteps: jede Art liefert gültige Schritte, Blasen ≤ 20 Zeichen, Unbekanntes wird verworfen (Review Focus 4)', () => {
      const ids = new Set(ROLES.map((r) => r.workerId)), known = (v) => ids.has(v) || E.SPOTS.includes(v)
      for (const kind of E.EVENT_KINDS) for (const w of ['kim', 'system', 'zed']) {
        for (const s of E.eventToSteps(ev(kind, w, { to: 'bea', meta: { count: 99999999, score: 123456789, round: 2, classification: 'ja' } }))) {
          assert.ok(E.STEP_OPS.includes(s.op), kind)
          if (s.op === 'say') assert.ok(s.text.length <= 20, s.text)
          if (s.op === 'fx') { assert.ok(known(s.at), `${kind}/${w}: at=${s.at}`); if (s.to) assert.ok(known(s.to)) }
          else for (const k of ['who', 'from', 'to']) if (s[k]) assert.ok(ids.has(s[k]), `${kind}/${w}: ${k}=${s[k]}`)
        }
      }
      assert.deepEqual(E.eventToSteps(ev('fund', 'zed')), []); assert.deepEqual(E.eventToSteps(ev('fund', 'system')), [])
      assert.deepEqual(E.eventToSteps(ev('neueArt', 'kim')), [{ op: 'task', who: 'kim', text: 'Text' }]); assert.deepEqual(E.eventToSteps(ev('neueArt', 'zed')), [])
      assert.deepEqual(E.eventToSteps(ev('uebergabe', 'rita', { to: 'zed' })), [{ op: 'task', who: 'rita', text: 'Text' }])
    })
    test('needsStateRefresh und cutBubble', () => {
      assert.equal(E.needsStateRefresh([ev('gelesen', 'kim'), ev('pult', 'clara')]), true); assert.equal(E.needsStateRefresh([ev('gelesen', 'kim'), ev('fund', 'kim')]), false)
      for (const k of ['pult', 'gesendet', 'deal', 'botlead', 'gesperrt', 'budget_warnung', 'budget_stopp', 'pause', 'top3', 'angebot']) assert.equal(E.needsStateRefresh([ev(k, 'otto')]), true, k)
      assert.equal(E.cutBubble('Dies ist ein sehr langer Satz!'), 'Dies ist ein sehr la'); assert.equal(E.cutBubble(null), '')
    })
    

    Hinweis zu gesperrt: Bei workerId: 'system' fällt der task-Schritt weg, weil task nur für bekannte Worker erzeugt wird; Arne stempelt trotzdem (Tabelle: A = arne).

  • Step 2: Scheitern sehen — cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersEvents.test.mjs. Expected: FAIL Could not resolve …/workersEvents.js.

  • Step 3: Implementieren src/lib/workersEvents.js takeNewEvents: Schlüssel = id, ohne id ts|workerId|kind|text; ohne gültiges ts verwerfen; Set auf 1000 Einträge begrenzen (ältesten zuerst löschen); Ergebnis stabil nach Date.parse(ts) aufsteigend. reduceEvents: nur Worker, die in stateByWorker stehen und in ROLES vorkommen; ein Ereignis wirkt nur, wenn Date.parse(ts) >= Date.parse(lastTs) (oder lastTs leer); grau bleibt grau; Status: leerlauf ⇒ 'leerlauf', fehler ⇒ 'fehler', jede andere Art ⇒ 'arbeitet', task = text, lastTs = ts; der Empfänger to von auftrag/uebergabe wird (falls nicht grau und nicht neuer) 'wartet' mit task = text; Rückgabe ist ein neues Objekt, Einträge als Kopien (Zusatzfelder wie connected/counters bleiben). indexWorkers: status = connected === false ? 'grau' : status, lastTs = state.lastEventTs ?? null. eventToSteps: erst Schritte nach der Tabelle bilden, dann alle Schritte verwerfen, die auf unbekannte workerId/Plätze zeigen (system und zed fallen weg); meta darf Objekt, JSON-String oder null sein (P1 liefert null, wenn leer); W = event.workerId, T = text:

    kind Schritte in dieser Reihenfolge
    auftrag task W; say W ${Name(to)}, los!; say to Verstanden
    leerlauf task W; pose W coffee
    gelesen pose W type; task W; say W (meta.count Zahl ? ${count} gelesen : Gelesen)
    fund pose W type; task W; say W Neuer Fund!; fx paper at W
    doppelt task W; say W Schon bekannt; fx stampNo at W
    uebergabe task W; give W→to
    bewertet pose W type; task W; say W (meta.score Zahl ? ${score} Punkte : Bewertet)
    top3 task W; say W Top 3!; fx stampOk at W
    botlead task W; say W Abgelegt; fx drawer at W
    entwurf pose W type; task W; say W Entwurf fertig
    pult task W; say W Zur Freigabe; fx envelope at W to pult
    gesendet task W; (W bekannt: say W Raus damit!; fx envelope at W to aussen) sonst: fx envelope at pult to aussen
    eingang task W; fx envelope at aussen to W; say W Antwort!
    sortiert task W; fx stampOk at W; say W (Beschriftung zu meta.classification: ja Ja!, frage Frage, nein Nein, abmelden Abmeldung, abwesend Abwesend, unzustellbar Unzustellbar, beschwerde Beschwerde!, unklar Unklar, sonst Sortiert)
    bau_start pose W type; task W; say W Los geht’s
    bau_fertig task W; say W Fertig!; give W→paul
    pruefung_zurueck task W; fx stampNo at W; say W (meta.round Zahl ? Runde ${round} zurück : Zurück); give W→to
    pruefung_ok task W; fx stampOk at W; say W Passt!
    vorschau_link task W; say W Link raus; fx envelope at W to aussen
    angebot task W; say W Kauf-Link!; fx paper at W
    deal fx coin at aussen to tresor mit delay 0, 0.4, 0.8; cheer otto; say otto Deal! Super!
    gesperrt task W (entfällt bei system); fx stampNo at A; say A Gesperrt mit A = W bekannt ? W : arne
    budget_warnung task W; say otto Budget 80 %; fx alert at otto
    budget_stopp task W; say otto Budget leer; fx alert at otto
    pause (keine)
    fehler task W; fx alert at W; say W Fehler
    andere Art task W

    Alle say-Texte laufen durch cutBubble. needsStateRefresh: wahr bei pult, gesendet, deal, botlead, gesperrt, budget_warnung, budget_stopp, pause, top3, angebot.

  • Step 4: Bestehen sehen — gleicher Befehl wie Step 2. Expected: # pass 7, # fail 0.

  • Step 5: Commit

    cd $WK && git add src/lib/workersEvents.js tests/unit/workersEvents.test.mjs
    git commit -m "Workers P2: Ereignis-Logik (Dedup, reduceEvents, eventToSteps)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
    

P2-T04: API-Client workersApi.js und workersClient.js

Files:

  • Create: src/lib/workersApi.js, src/lib/workersClient.js
  • Test: tests/unit/workersApi.test.mjs

Interfaces:

  • Consumes: Übersicht §6.2 (Pfade), E2; createJwtCache aus serverpulsAuth.js (nur workersClient.js).

  • Produces (Übersicht §7):

    export function createWorkersApi({ getJwt, fetchImpl }): {
      state(), events(after, limit = 200), trace(workerId, limit = 50), pause(paused),
      settings(), saveSettings(patch), pult(status = 'offen'), pultAction(id, action, body?),
      botleads({ room, q, page, id }), prospect(id) }
    // pult(status): ein Pult-Status, eine Komma-Liste ('offen,in_arbeit', Übersicht §7 und §6.2) oder ein Array; jeder Eintrag (getrimmt) muss in PULT_STATUS stehen,
    // sonst Error('Unbekannter Pult-Status: <Wert>') ohne Anfrage; leer = 'offen'. botleads reicht id schon hier durch (P6 ändert diese Datei nicht mehr).
    export const PULT_STATUS   // ['offen','in_arbeit','gesendet','verworfen','veraltet','erledigt']
    export const DOWN_TEXT, BAD_REPLY_TEXT, SESSION_TEXT, FORBIDDEN_TEXT   // 'Der Hustler-Dienst ist gerade nicht erreichbar.' · 'Der Hustler-Dienst antwortet nicht richtig.' · 'Anmeldung abgelaufen. Bitte neu anmelden.' · 'Dafür fehlt dir die Berechtigung.'
    // workersClient.js
    export const workersApi   // createWorkersApi mit account.createJWT, 10-min-Cache
    

    Fehler sind Error mit message (deutsch) und status.

  • Step 1: Failing test schreiben (tests/unit/workersApi.test.mjs)

    const { createWorkersApi } = await loadModule('src/lib/workersApi.js')
    const jwts = []
    const getJwt = async (o) => { jwts.push(o?.fresh === true); return o?.fresh ? 'jwt-neu' : 'jwt-alt' }
    function fakeFetch(replies) {
      const calls = []
      const fn = async (url, init) => { calls.push({ url, init }); const r = replies.shift(); if (r instanceof Error) throw r
        return { ok: r.status >= 200 && r.status < 300, status: r.status, json: async () => { if (r.body === undefined) throw new SyntaxError('kein JSON'); return r.body } } }
      fn.calls = calls; return fn
    }
    test('URLs, Methoden, Header, Body', async () => {
      const f = fakeFetch(Array.from({ length: 12 }, () => ({ status: 200, body: {} })))
      const api = createWorkersApi({ getJwt, fetchImpl: f })
      await api.state(); await api.events('2026-10-17T20:00:00.000+00:00'); await api.events(null, 500); await api.trace('kim'); await api.pause(true)
      await api.settings(); await api.saveSettings({ warnPct: 70 }); await api.pult(); await api.pultAction('p1', 'claim')
      await api.botleads({ room: 'netz', q: 'Bäcker', page: 2 }); await api.botleads({ id: 'p1' }); await api.prospect('x/y')
      assert.deepEqual(f.calls.map((c) => [c.init.method || 'GET', c.url]), [
        ['GET', '/api/workers/state'],
        ['GET', '/api/workers/events?after=2026-10-17T20%3A00%3A00.000%2B00%3A00&limit=200'],
        ['GET', '/api/workers/events?limit=200'],
        ['GET', '/api/workers/worker/kim/trace?limit=50'],
        ['POST', '/api/workers/pause'], ['GET', '/api/workers/settings'], ['PUT', '/api/workers/settings'],
        ['GET', '/api/workers/pult?status=offen'], ['POST', '/api/workers/pult/p1/claim'],
        ['GET', '/api/workers/botleads?room=netz&q=B%C3%A4cker&page=2'], ['GET', '/api/workers/botleads?id=p1'], ['GET', '/api/workers/prospect/x%2Fy']])
      assert.equal(f.calls[0].init.headers['X-Appwrite-JWT'], 'jwt-alt')
      assert.equal(f.calls[4].init.body, JSON.stringify({ paused: true })); assert.equal(f.calls[4].init.headers['Content-Type'], 'application/json')
      assert.equal(f.calls[6].init.body, JSON.stringify({ warnPct: 70 }))
    })
    test('Fehlertexte (Review Focus 1)', async () => {
      const run = (reply) => createWorkersApi({ getJwt, fetchImpl: fakeFetch([reply]) }).state().then(() => null, (e) => [e.message, e.status])
      assert.deepEqual(await run({ status: 200, body: undefined }), ['Der Hustler-Dienst antwortet nicht richtig.', 200])   // nginx liefert index.html
      assert.deepEqual(await run({ status: 404, body: undefined }), ['Der Hustler-Dienst antwortet nicht richtig.', 404])
      assert.deepEqual(await run({ status: 502, body: undefined }), ['Der Hustler-Dienst ist gerade nicht erreichbar.', 502])
      assert.deepEqual(await run(new TypeError('fetch failed')), ['Der Hustler-Dienst ist gerade nicht erreichbar.', 0])
      assert.deepEqual(await run({ status: 403, body: {} }), ['Dafür fehlt dir die Berechtigung.', 403])
      assert.deepEqual(await run({ status: 400, body: { error: 'Tagesgrenze ungültig.' } }), ['Tagesgrenze ungültig.', 400])
      assert.deepEqual(await run({ status: 500, body: {} }), ['Hustler-Fehler 500', 500])
      await assert.rejects(createWorkersApi({ getJwt: async () => { throw new Error('x') }, fetchImpl: fakeFetch([]) }).state(), { message: 'Anmeldung abgelaufen. Bitte neu anmelden.', status: 401 })
    })
    test('401 ⇒ einmal mit frischem JWT wiederholen, danach Meldung', async () => {
      jwts.length = 0
      const f = fakeFetch([{ status: 401, body: {} }, { status: 200, body: { ok: 1 } }])
      assert.deepEqual(await createWorkersApi({ getJwt, fetchImpl: f }).state(), { ok: 1 })
      assert.deepEqual(jwts, [false, true]); assert.equal(f.calls[1].init.headers['X-Appwrite-JWT'], 'jwt-neu')
      await assert.rejects(createWorkersApi({ getJwt, fetchImpl: fakeFetch([{ status: 401, body: {} }, { status: 401, body: {} }]) }).state(), { message: 'Anmeldung abgelaufen. Bitte neu anmelden.', status: 401 })
    })
    test('pult: ein Status, Komma-Liste oder Array (Übersicht §7); unbekannte Status werden abgewiesen, ohne Anfrage', async () => {
      const f = fakeFetch(Array.from({ length: 5 }, () => ({ status: 200, body: {} }))); const api = createWorkersApi({ getJwt, fetchImpl: f })
      await api.pult('offen,in_arbeit'); await api.pult(['gesendet', 'verworfen', 'erledigt']); await api.pult(' veraltet '); await api.pult(''); await api.pult('gesendet, verworfen , erledigt')
      assert.deepEqual(f.calls.map((c) => c.url), ['/api/workers/pult?status=offen,in_arbeit', '/api/workers/pult?status=gesendet,verworfen,erledigt', '/api/workers/pult?status=veraltet', '/api/workers/pult?status=offen', '/api/workers/pult?status=gesendet,verworfen,erledigt'])   // Kommas bleiben unkodiert, Einträge getrimmt, leer = offen
      for (const bad of ['offen,../x', 'quatsch', ['offen', 'x y'], 'offen;in_arbeit', 'offen,,in_arbeit']) await assert.rejects(api.pult(bad), /Unbekannter Pult-Status/)
      assert.equal(f.calls.length, 5)   // die fünf abgewiesenen Aufrufe haben nichts gesendet
    })
    test('pultAction: nur bekannte Aktionen, Pfad-Tricks abgewiesen', async () => {
      const f = fakeFetch(Array.from({ length: 7 }, () => ({ status: 200, body: {} }))); const api = createWorkersApi({ getJwt, fetchImpl: f })
      for (const bad of ['../x', 'delete', '']) await assert.rejects(api.pultAction('p1', bad), /Unbekannte Aktion/)
      assert.equal(f.calls.length, 0)
      for (const a of ['claim', 'sent', 'discard', 'done', 'reply', 'approve-preview', 'paste']) await api.pultAction('p1', a, a === 'reply' || a === 'paste' ? { text: 'x' } : undefined)
      assert.deepEqual(f.calls.map((c) => c.url.split('/').pop()), ['claim', 'sent', 'discard', 'done', 'reply', 'approve-preview', 'paste']); assert.equal(f.calls[4].init.body, JSON.stringify({ text: 'x' }))
      assert.deepEqual([f.calls[6].url, f.calls[6].init.method, f.calls[6].init.body], ['/api/workers/pult/p1/paste', 'POST', JSON.stringify({ text: 'x' })])   // Plattform-Folge-Antwort einfügen (P4-T34)
    })
    test('limit wird geklemmt (Ereignisse 1..200, Spur 1..50)', async () => {
      const f = fakeFetch(Array.from({ length: 3 }, () => ({ status: 200, body: {} }))); const api = createWorkersApi({ getJwt, fetchImpl: f })
      await api.trace('kim', 999); await api.events(null, 0); await api.events(null, 150)
      assert.deepEqual(f.calls.map((c) => c.url), ['/api/workers/worker/kim/trace?limit=50', '/api/workers/events?limit=1', '/api/workers/events?limit=150'])
    })
    
  • Step 2: Scheitern sehen — cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersApi.test.mjs. Expected: FAIL Could not resolve …/workersApi.js.

  • Step 3: Implementieren src/lib/workersApi.js: ein request(path, { method, body }) nach Muster financeRequest.js: fetchImpl wirft ⇒ DOWN_TEXT (status 0); getJwt wirft ⇒ SESSION_TEXT (401); Antwort 401 ⇒ einmal getJwt({ fresh: true }), danach erneut 401 ⇒ SESSION_TEXT; JSON-Parsefehler ⇒ bei 502/503/504 DOWN_TEXT, sonst BAD_REPLY_TEXT (mit dem echten Status); 403 ohne error ⇒ FORBIDDEN_TEXT; sonstiger Fehlerstatus ⇒ data.error oder Hustler-Fehler <status>. Erlaubte pultAction-Namen: claim, sent, discard, done, reply, approve-preview, paste (paste = Antwort des Kunden von einer Plattform einfügen, Körper { text }, P4-T34). pult(status = 'offen'): String (an Kommas teilen, Einträge trimmen) oder Array; leer ⇒ offen; jeder Eintrag muss in PULT_STATUS stehen (offen, in_arbeit, gesendet, verworfen, veraltet, erledigt), sonst Error('Unbekannter Pult-Status: <Wert>') vor jeder Anfrage; Anfrage GET /api/workers/pult?status=<Einträge mit Komma verbunden> (das Komma bleibt unkodiert, die Einträge bestehen nur aus Kleinbuchstaben und _). botleads({ room, q, page, id }): Query-Reihenfolge room, q, page, id, leere Werte fallen weg (P6 braucht id für den Ereignislink /hustler?tab=botleads&bot=<prospectId>). limit hat den Standard (200 bzw. 50) nur bei undefined (nicht bei 0) und wird auf 1..200 (Ereignisse) bzw. 1..50 (Spur) geklemmt; leere Werte fallen aus der Query. src/lib/workersClient.js (nicht unter Node geladen, wie serverpulsApi.js): account.createJWT + createJwtCache (10 min), getJwt = (o) => { if (o?.fresh) cache.invalidate(); return cache.get() }, export const workersApi = createWorkersApi({ getJwt }).

  • Step 4: Bestehen sehen — gleicher Befehl. Expected: # pass 6, # fail 0.

  • Step 5: Commit

    cd $WK && git add src/lib/workersApi.js src/lib/workersClient.js tests/unit/workersApi.test.mjs
    git commit -m "Workers P2: API-Client mit deutschen Fehlertexten" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
    

P2-T05: Feed-Steuerung workersFeed.js und Hooks

Files:

  • Create: src/lib/workersFeed.js, src/hooks/useWorkersFeed.js
  • Test: tests/unit/workersFeed.test.mjs

Interfaces:

  • Consumes: takeNewEvents, latestTs, needsStateRefresh (P2-T03); api.state(), api.events(after, limit), api.trace(id) (P2-T04); createRequestGuard (src/lib/requestGuard.js).

  • Produces:

    export function createFeedController({ api, setTimer, clearTimer, isVisible, onSnapshot, onEvents, onError,
      stateMs = 15000, eventsMs = 5000, pageLimit = 200, maxPages = 5 }): { start(), stop(), visibilityChanged(), refreshState() }
    // useWorkersFeed.js
    export function useWorkersFeed(api, { onEvents }): { state, error, loaded, refresh }
    export function useWorkerTrace(api, workerId): { trace, loading, error }
    
  • Step 1: Failing test schreiben (tests/unit/workersFeed.test.mjs) Hilfsfunktion makeFeedHarness({ state, eventReplies, eventsHang }) im Testfile: Fake-Timer (setTimer(fn, ms)/clearTimer, timers als Map, advance(ms) feuert fällige Timer in Zeitreihenfolge und wartet je Timer einen setImmediate), settle() (zwei setImmediate), Fake-api mit state() (zählt in log.state, gibt state zurück, Standard { lastEventTs: '2026-10-17T20:00:00.000Z', workers: [] }) und events(after, limit) (hängt [after, limit] an log.events; antwortet mit eventReplies.shift() ?? [], ein Error in der Liste wird als Ablehnung zurückgegeben; mit eventsHang kehrt der Aufruf nie zurück), Schalter visible.v, Protokolle log.snapshots (je Aufruf von onSnapshot der Zustand), log.batches (je onEvents die Liste) und log.errors (je onError(e) der Eintrag e ? e.message : null).

    const mk = (i) => ({ id: `e${i}`, ts: new Date(Date.UTC(2026, 9, 17, 20, 0, 1 + i)).toISOString(), workerId: 'kim', kind: 'fund', text: 't' })
    test('Start: Zustand, Cursor aus lastEventTs, dann Ereignisse (Review Focus 3)', async () => {
      const h = makeFeedHarness({}); h.c.start(); await h.settle()
      assert.equal(h.log.snapshots.length, 1); assert.deepEqual(h.log.events[0], ['2026-10-17T20:00:00.000Z', 200])
      const h2 = makeFeedHarness({ state: { workers: [] } }); h2.c.start(); await h2.settle()
      assert.equal(h2.log.events[0][0], '1970-01-01T00:00:00.000Z')
    })
    test('Takt: Ereignisse alle 5 s mit weitergeschobenem Cursor, Zustand alle 15 s', async () => {
      const h = makeFeedHarness({ eventReplies: [[mk(1)], [], []] }); h.c.start(); await h.settle()
      await h.advance(5000); assert.equal(h.log.events[1][0], mk(1).ts)
      await h.advance(10000); assert.equal(h.log.state, 2); assert.equal(h.log.events.length, 4)
    })
    test('Hintergrund: keine Abrufe, beim Zurückkehren sofort beide', async () => {
      const h = makeFeedHarness({}); h.c.start(); await h.settle()
      h.visible.v = false; await h.advance(60000); assert.equal(h.log.state, 1); assert.equal(h.log.events.length, 1)
      h.visible.v = true; h.c.visibilityChanged(); await h.settle(); assert.equal(h.log.state, 2); assert.equal(h.log.events.length, 2)
      await h.advance(5000); assert.equal(h.log.events.length, 3); assert.equal(h.timers.size, 2)   // eine Kette je Schleife, kein doppelter Timer nach der Rückkehr
    })
    test('Doppelte Ereignisse werden nie zweimal gemeldet (Review Focus 3)', async () => {
      const h = makeFeedHarness({ eventReplies: [[mk(1), mk(2)], [mk(1), mk(2), mk(3)]] }); h.c.start(); await h.settle(); await h.advance(5000)
      assert.deepEqual(h.log.batches.map((b) => b.map((e) => e.id)), [['e1', 'e2'], ['e3']])
    })
    test('Rückstau: volle Seiten werden sofort nachgeladen, höchstens 5 je Runde (Review Focus 2)', async () => {
      const page = (n) => Array.from({ length: 200 }, (_, i) => mk(n * 200 + i))
      const h = makeFeedHarness({ eventReplies: [page(0), page(1), page(2), page(3), page(4), page(5)] }); h.c.start(); await h.settle()
      assert.equal(h.log.events.length, 5); assert.equal(h.log.events[1][0], mk(199).ts)
    })
    test('keine überlappenden Abrufe, Fehler laufen weiter, Zustand-Auffrischung bei wichtigen Ereignissen', async () => {
      const hang = makeFeedHarness({ eventsHang: true }); hang.c.start(); await hang.settle(); await hang.advance(60000)
      assert.equal(hang.log.events.length, 1)
      const err = makeFeedHarness({ eventReplies: [new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'), []] }); err.c.start(); await err.settle(); await err.advance(5000)
      assert.deepEqual(err.log.errors, ['Der Hustler-Dienst ist gerade nicht erreichbar.', null])
      const deal = makeFeedHarness({ eventReplies: [[{ ...mk(1), kind: 'deal' }]] }); deal.c.start(); await deal.settle(); assert.equal(deal.log.state, 2)
    })
    test('stop räumt auf, start ist idempotent, späte Antworten werden verworfen', async () => {
      const h = makeFeedHarness({}); h.c.start(); h.c.start(); await h.settle(); assert.equal(h.log.state, 1)
      h.c.stop(); assert.equal(h.timers.size, 0); await h.advance(60000); assert.equal(h.log.state, 1)
    })
    test('Hooks: Sichtbarkeit und Aufräumen im Quelltext', () => {
      const src = readFileSync(new URL('../../src/hooks/useWorkersFeed.js', import.meta.url), 'utf8')
      assert.match(src, /visibilitychange/); assert.match(src, /removeEventListener\('visibilitychange'/); assert.match(src, /\.stop\(\)/); assert.match(src, /createRequestGuard/)
    })
    
  • Step 2: Scheitern sehen — cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersFeed.test.mjs. Expected: FAIL Could not resolve …/workersFeed.js.

  • Step 3: Implementieren createFeedController (Regeln): Zustand und Ereignisse laufen mit je einem setTimer nach Abschluss des vorherigen Abrufs (kein Intervall, kein Überlappen; inFlight.state/inFlight.events; je Schleife höchstens ein wartender Timer: visibilityChanged() und refreshState() löschen ihn vor dem sofortigen Abruf, nach dem Abruf wird neu gesetzt); Start: sichtbar ⇒ loadState(), bei Erfolg onSnapshot(state), beim ersten Erfolg cursor = state.lastEventTs || '1970-01-01T00:00:00.000Z' und danach loadEvents(); loadEvents: api.events(cursor, pageLimit) ⇒ fresh = takeNewEvents(seen, res.events), cursor = latestTs(res.events, cursor), fresh.length ⇒ onEvents(fresh), needsStateRefresh(fresh) ⇒ refreshState(); kam eine volle Seite (res.events.length >= pageLimit) und pages < maxPages, sofort die nächste; Fehler ⇒ onError(err) einmal je Fehlerfolge, nächster Erfolg ⇒ onError(null); Timer feuern bei !isVisible() ohne Abruf und planen neu; visibilityChanged() sichtbar ⇒ sofort loadState() und (Cursor vorhanden) loadEvents(); stop() löscht Timer, erhöht eine Generationszahl (späte Antworten ändern nichts), start() nach stop() beginnt neu. useWorkersFeed: Controller mit echten Timern und document.visibilityState, visibilitychange-Listener, onEvents über useRef (stabil), Cleanup stop() + Listener entfernen; refresh = controller.refreshState(). useWorkerTrace: lädt bei workerId-Wechsel und alle 15 s bei sichtbarem Tab, createRequestGuard verwirft späte Antworten, workerId null ⇒ { trace: null }.

  • Step 4: Bestehen sehen — gleicher Befehl. Expected: # pass 8, # fail 0.

  • Step 5: Commit

    cd $WK && git add src/lib/workersFeed.js src/hooks/useWorkersFeed.js tests/unit/workersFeed.test.mjs
    git commit -m "Workers P2: Feed-Steuerung und Hooks (Zustand 15 s, Ereignisse 5 s)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
    

P2-T06: Engine-Grundlagen sprites.js (Raster, Figuren, Text)

Files:

  • Create: src/components/workers/engine/sprites.js, tests/unit/helpers/fakeCtx.mjs
  • Test: tests/unit/workersSprites.test.mjs

Interfaces:

  • Consumes: nichts. Quelle der Portierung: /root/reports/workers-2026-10-16/entwuerfe/sprites.js Z. 7–142 (Palette, Sprite-Karten, drawMap, drawWorker, drawSelect, drawPaper) und Z. 277–307 (label, drawBubble). ES5-IIFE ⇒ benannte ES-Exporte; die Konstante S entfällt zugunsten des Rasters.

  • Produces:

    export const PAL, HAT, TEAM, FONT_FAMILY   // wie Vorlage; FONT_FAMILY = '"Pixelify Sans", ui-monospace, Menlo, monospace'
    export function setGrid(ctx, { k, ox = 0, oy = 0 })    // k = Geräte-Pixel je logischer Einheit; speichert auf ctx.wkGrid; ohne Aufruf k = 3, ox = oy = 0
    export function px(ctx, x, y, w, h, color)             // links = round(x*k+ox), rechts = round((x+w)*k+ox), analog y; Breite/Höhe mindestens 1
    export function tint(ctx, x, y, w, h, color, alpha)
    export function label(ctx, text, x, y, { size = 12, color = PAL.ink, weight = 500, align = 'left', bg } = {})   // Schrift = size*k/3 px
    export function drawBubble(ctx, x, y, text)            // Spitze bei (x,y), klemmt in ctx.canvas.width (Rand 2)
    export function drawAlertMark(ctx, x, y)               // rotes „!“ (persistent bei Status fehler)
    export function drawPaper(ctx, x, y)
    export function drawWorker(ctx, x, y, { hat, frame = 0, facing = 1, carrying = false, scale = 1, selected = false, pose = 'stand', grey = false, t = 0 }): { x, y, w, h }
    

    tests/unit/helpers/fakeCtx.mjs: makeCtx({ width = 960, height = 768 } = {}) → { ctx, rects:[{x,y,w,h,fill}], texts:[{text,x,y,font}], counts:{save,restore}, alphas:[] }: Proxy nach Muster smoke.js (fillRect/fillText aufzeichnen mit aktuellem fillStyle/font, fillText mit Nicht-String wirft, measureText ⇒ { width: länge*7 }, globalAlpha-Zuweisungen in alphas, canvas = { width, height }, alles Übrige No-op).

  • Step 1: Failing test schreiben (tests/unit/workersSprites.test.mjs, const S = await loadModule('src/components/workers/engine/sprites.js'), import { makeCtx } from './helpers/fakeCtx.mjs')

    test('px: gerundete Kanten ohne Naht, Standardraster 3, Versatz, Mindestgröße', () => {
      let { ctx, rects } = makeCtx(); S.setGrid(ctx, { k: 3.4, ox: 0, oy: 0 })
      S.px(ctx, 0, 0, 1, 1, '#000'); S.px(ctx, 1, 0, 1, 1, '#111'); S.px(ctx, 2, 0, 1, 1, '#222')
      assert.equal(rects[0].x + rects[0].w, rects[1].x); assert.equal(rects[1].x + rects[1].w, rects[2].x)
      ;({ ctx, rects } = makeCtx()); S.px(ctx, 1, 2, 3, 4, '#fff'); assert.deepEqual([rects[0].x, rects[0].y, rects[0].w, rects[0].h], [3, 6, 9, 12])
      ;({ ctx, rects } = makeCtx()); S.setGrid(ctx, { k: 2, ox: -10, oy: 0 }); S.px(ctx, 5, 0, 0.1, 1, '#fff'); assert.equal(rects[0].x, 0); assert.ok(rects[0].w >= 1)
    })
    test('label: Schriftgröße wächst mit dem Raster', () => {
      const { ctx, texts } = makeCtx(); S.setGrid(ctx, { k: 6 }); S.label(ctx, 'Hallo', 1, 1, { size: 11 }); assert.match(texts[0].font, /22px/)
    })
    test('drawWorker: Trefferfläche', () => {
      const { ctx } = makeCtx()
      assert.deepEqual(S.drawWorker(ctx, 10, 20, { hat: 'haiku' }), { x: 10, y: 18, w: 12, h: 16 })
      assert.equal(S.drawWorker(ctx, 10, 20, { hat: 'haiku', carrying: true }).w, 14)
      assert.deepEqual(S.drawWorker(ctx, 10, 20, { hat: 'overseer', scale: 2 }), { x: 10, y: 16, w: 24, h: 32 })
    })
    test('drawWorker: Hutfarbe zeigt das Modell, Augen je Pose, Grau mit Alpha', () => {
      for (const hat of ['opus', 'sonnet', 'haiku', 'overseer']) { const { ctx, rects } = makeCtx(); S.drawWorker(ctx, 0, 0, { hat }); assert.ok(rects.some((r) => r.fill === S.HAT[hat][0]), hat) }
      const eyes = (pose) => { const { ctx, rects } = makeCtx(); S.drawWorker(ctx, 0, 0, { hat: 'haiku', pose }); return rects.filter((r) => r.fill === S.PAL.e).length }
      assert.equal(eyes('stand'), 4); assert.equal(eyes('sleep'), 2)
      for (const pose of ['stand', 'type', 'coffee', 'sleep']) { const { ctx, rects } = makeCtx(); S.drawWorker(ctx, 0, 0, { hat: 'sonnet', pose, t: 1.3 }); assert.ok(rects.length > 20, pose) }
      const g = makeCtx(); S.drawWorker(g.ctx, 0, 0, { hat: 'opus', grey: true }); assert.ok(g.alphas.includes(0.55)); assert.equal(g.counts.save, g.counts.restore)
    })
    test('drawBubble bleibt im Bild (Spitze nahe am linken und rechten Rand, Raster 3, Breite 960)', () => {
      const l = makeCtx(); S.drawBubble(l.ctx, 2, 30, 'Hallo'); assert.ok(Math.min(...l.rects.map((r) => r.x)) >= 0)
      const r = makeCtx(); S.drawBubble(r.ctx, 318, 30, 'Hallo'); assert.ok(Math.max(...r.rects.map((q) => q.x + q.w)) <= 960)
    })
    
  • Step 2: Scheitern sehen — cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersSprites.test.mjs. Expected: FAIL Could not resolve …/engine/sprites.js.

  • Step 3: Implementieren engine/sprites.js und tests/unit/helpers/fakeCtx.mjs Portieren wie oben. Neu gegenüber der Vorlage: (1) Raster über ctx.wkGrid; (2) Posen: stand und type zeichnen gleich (das Wippen macht die Regie), sleep ersetzt die Augenzeile 4 durch Körperfarbe (nur die Zeile 5 bleibt, also 2 Augenpunkte) und setzt ein kleines „z“ aus 3 Pixeln über den Hut (steigt mit t), coffee zeichnet eine 3×3-Tasse an der Hand und 1 Dampfpixel (flackert mit t); (3) grey: ctx.save(), ctx.globalAlpha = 0.55, Körper-/Hutfarben auf Grautöne abbilden, Trefferfläche unverändert, ctx.restore(); (4) drawAlertMark: 3×7-Pixel-„!“ in PAL.red mit Umriss. drawBubble klemmt bx in [2, ctx.canvas.width - bw - 2] (ohne ctx.canvas Standard 960).

  • Step 4: Bestehen sehen — gleicher Befehl. Expected: # pass 5, # fail 0.

  • Step 5: Commit

    cd $WK && git add src/components/workers/engine/sprites.js tests/unit/helpers/fakeCtx.mjs tests/unit/workersSprites.test.mjs
    git commit -m "Workers P2: Engine-Sprites (Raster, Figuren mit Posen, Text, Blasen)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
    

P2-T07: Requisiten props.js

Files:

  • Create: src/components/workers/engine/props.js
  • Test: tests/unit/workersProps.test.mjs

Interfaces:

  • Consumes: px, label, PAL, TEAM, HAT aus sprites.js. Quelle: entwuerfe/sprites.js Z. 146–273.

  • Produces:

    export const ICONS    // { netz, kleinanzeigen, portale, foren, aerzte, handwerk, selbst, laden } → 7 Zeilen à 7 Zeichen ('.' leer, 'x' Linie, 'a' Akzent)
    export function drawIcon(ctx, name, x, y, color)
    export function drawDesk(ctx, x, y, w, accent) · drawMonitor(ctx, x, y, t, color, on = true) · drawCabinet(ctx, x, y, drawers)
    export function drawPlant(ctx, x, y) · drawCoin(ctx, x, y, t) · drawTrophy(ctx, x, y) · drawStamp(ctx, x, y, ok)
    export function drawRadar(ctx, x, y, t) · drawMailbox(ctx, x, y, count) · drawVault(ctx, x, y, t) · drawScale(ctx, x, y, tilt)
    export function drawTray(ctx, x, y, filled)                // Ablagekorb/Eingangskorb 10×4
    export function drawSign(ctx, x, y, w, text)               // weißes Schild mit dunklem Rand, Text zentriert
    
  • Step 1: Failing test schreiben (const P = await loadModule('…/engine/props.js'))

    test('alle Requisiten zeichnen ohne Wurf und mindestens ein Rechteck', () => {
      const calls = { desk: (c) => P.drawDesk(c, 0, 0, 18, '#f00'), monitor: (c) => P.drawMonitor(c, 0, 0, 1, '#7fb2ff'), cabinet: (c) => P.drawCabinet(c, 0, 0, 3), plant: (c) => P.drawPlant(c, 0, 0),
        coin: (c) => P.drawCoin(c, 0, 0, 0.3), trophy: (c) => P.drawTrophy(c, 0, 0), stampOk: (c) => P.drawStamp(c, 0, 0, true), stampNo: (c) => P.drawStamp(c, 0, 0, false),
        radar: (c) => P.drawRadar(c, 0, 0, 1), mailbox: (c) => P.drawMailbox(c, 0, 0, 2), vault: (c) => P.drawVault(c, 0, 0, 1), scale: (c) => P.drawScale(c, 0, 0, 2), tray: (c) => P.drawTray(c, 0, 0, true) }
      for (const [name, fn] of Object.entries(calls)) { const { ctx, rects } = makeCtx(); fn(ctx); assert.ok(rects.length >= 1, name) }
    })
    test('Briefkasten-Abzeichen nur bei count > 0; Monitor aus zeigt keine Inhaltszeilen', () => {
      const red = (n) => { const { ctx, rects } = makeCtx(); P.drawMailbox(ctx, 0, 0, n); return rects.filter((r) => r.fill === PAL.red).length }
      assert.equal(red(0), 0); assert.ok(red(3) >= 1)
      const lines = (on) => { const { ctx, rects } = makeCtx(); P.drawMonitor(ctx, 0, 0, 1, '#abcdef', on); return rects.filter((r) => r.fill === '#abcdef').length }
      assert.ok(lines(true) >= 2); assert.equal(lines(false), 0)
    })
    test('ICONS: genau die 8 Suchräume, je 7 Zeilen à 7 Zeichen; drawSign schreibt den Text', () => {
      assert.deepEqual(Object.keys(P.ICONS).sort(), ['aerzte', 'foren', 'handwerk', 'kleinanzeigen', 'laden', 'netz', 'portale', 'selbst'])
      for (const [k, rows] of Object.entries(P.ICONS)) { assert.equal(rows.length, 7, k); for (const r of rows) assert.match(r, /^[.xa]{7}$/, k) }
      const { ctx, texts, rects } = makeCtx(); P.drawSign(ctx, 0, 0, 40, 'noch nicht angeschlossen'); assert.equal(texts[0].text, 'noch nicht angeschlossen'); assert.ok(rects.length >= 2)
      const t = makeCtx(); P.drawIcon(t.ctx, 'aerzte', 0, 0, '#3fb6b2'); assert.ok(t.rects.length >= 5)
    })
    

    (PAL aus sprites.js per eigenem loadModule im Test importieren.)

  • Step 2: Scheitern sehen — cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersProps.test.mjs. Expected: FAIL Could not resolve …/engine/props.js.

  • Step 3: Implementieren engine/props.js Die elf Vorlagenfunktionen (drawDesk bis drawScale) 1:1 portieren (px aus sprites.js); drawTray, drawSign, drawIcon und ICONS sind neu; drawMonitor bekommt on (aus ⇒ nur Rahmen und Schirm, keine Zeilen, kein Blinken). ICONS als 7×7-Zeichenkarten (aerzte Kreuz, handwerk Hammer, selbst Person, laden Markise, netz Funkwellen, kleinanzeigen Umschlag mit @-Punkt, portale Zeilenliste, foren Sprechblase); drawIcon zeichnet x in color, a in PAL.gold. Entfällt aus der Vorlage: drawConveyor, drawZone.

  • Step 4: Bestehen sehen — gleicher Befehl. Expected: # pass 3, # fail 0.

  • Step 5: Commit

    cd $WK && git add src/components/workers/engine/props.js tests/unit/workersProps.test.mjs
    git commit -m "Workers P2: Engine-Requisiten und Branchen-Icons" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
    

P2-T08: Layout als Daten und Kamera

Files:

  • Create: src/components/workers/engine/layout.js, src/components/workers/engine/camera.js
  • Test: tests/unit/workersLayout.test.mjs, tests/unit/workersCamera.test.mjs

Interfaces:

  • Consumes: ROLES, FLOORS, ROOM_TITLES (workersRoster.js).

  • Produces:

    // layout.js
    export const WORLD = { w: 320, h: 256 }
    export const FLOOR_PITCH = 23, BRIDGE_H = 34
    export const ROOM_META      // roomId → { kind, team }; kinds: bruecke radar inbox feed board scout waage mail schreiber sortier vertrag tisch pruefung pult tresor archiv
    export function buildLayout(roles = ROLES, floors = FLOORS): { floors:[{ id, index, title, top, floorY, h, rooms:[{ id, kind, title, team, x, w, workerId|null }] }], homes:{ [workerId]: { x, y, floor, room } }, spots:{ pult, tresor, aussen, archiv } }
    export function floorIndex(layout, id) · roomById(layout, roomId) · homeOf(layout, workerId) · fy(layout, i) · cabTop(layout, i)
    // camera.js
    export const ZOOM_MIN = 1, ZOOM_MAX = 3
    export function fitZoom(cssWidth): number
    export function createCamera(): { zoom:1, x:0, y:0 }
    export function clampCamera(cam, world = WORLD): cam
    export function zoomAt(cam, factor, fx, fy): cam       // fx, fy = Bruchteile 0..1 der Ansicht; der Weltpunkt unter (fx,fy) bleibt stehen
    export function panBy(cam, dxFrac, dyFrac): cam        // in Bruchteilen der Ansicht
    export function centerOn(cam, x, y): cam
    export function viewToWorld(cam, vx, vy): { x, y }     // vx, vy = Bruchteile 0..1 der Ansicht → logische Weltkoordinaten
    export function worldToView(cam, x, y): { vx, vy }
    export function pickAgent(boxes, x, y, pad = 0): id | null    // boxes: [{ id, box:{x,y,w,h} }] in Zeichenreihenfolge, der letzte liegt oben
    
  • Step 1: Failing tests schreiben tests/unit/workersLayout.test.mjs:

    const { buildLayout, homeOf, roomById, fy, cabTop, WORLD } = await loadModule('src/components/workers/engine/layout.js')
    const { ROLES, FLOORS } = await loadModule('src/lib/workersRoster.js')
    const layout = buildLayout()
    const rooms = (id) => layout.floors.find((f) => f.id === id).rooms.map((r) => [r.id, r.x, r.w])
    test('10 Etagen in FLOORS-Reihenfolge mit festen Höhen, Welt 320×256', () => {
      assert.deepEqual(layout.floors.map((f) => f.id), [...FLOORS]); assert.deepEqual([WORLD.w, WORLD.h], [320, 256])
      assert.deepEqual(layout.floors.map((f) => [f.top, f.floorY]), [[6, 40], [41, 63], [64, 86], [87, 109], [110, 132], [133, 155], [156, 178], [179, 201], [202, 224], [225, 247]])
      assert.equal(fy(layout, 3), 95); assert.equal(cabTop(layout, 3), 88)
    })
    test('Räume je Etage', () => {
      assert.deepEqual(rooms('anfragen'), [['netz', 42, 46], ['kleinanzeigen', 88, 46], ['portale', 134, 46], ['foren', 180, 48]])
      assert.deepEqual(rooms('gespraeche').map((r) => r[0]), ['mail', 'forum', 'posteingang', 'abschluss'])
      assert.deepEqual(rooms('werkstatt'), [['tisch1', 42, 62], ['tisch2', 104, 62], ['tisch3', 166, 62]])
      for (const id of ['bruecke', 'bewertung', 'pruefung', 'freigabe', 'tresor', 'keller']) assert.deepEqual(rooms(id).map((r) => [r[1], r[2]]), [[42, 186]], id)
    })
    test('19 Worker haben genau einen Platz im eigenen Raum; Freigabe und Tresor ohne Worker', () => {
      for (const r of ROLES) { const h = homeOf(layout, r.workerId), room = roomById(layout, r.room); const wide = r.workerId === 'otto' ? 24 : 12
        assert.ok(h.x >= room.x && h.x + wide <= room.x + room.w, r.workerId); assert.equal(h.floor, FLOORS.indexOf(r.floor)) }
      assert.equal(homeOf(layout, 'otto').y, 13); assert.equal(homeOf(layout, 'kim').y, 49)
      assert.deepEqual(layout.floors.filter((f) => f.rooms.some((r) => !r.workerId)).map((f) => f.id), ['freigabe', 'tresor'])
      assert.deepEqual(layout.spots, { pult: { x: 135, y: 186 }, tresor: { x: 163, y: 213 }, aussen: { x: 324, y: 212 }, archiv: { x: 130, y: 231 } })
    })
    test('neue Räume kommen als Daten; unbekannter Raum wirft', () => {
      assert.throws(() => buildLayout([{ workerId: 'x', name: 'X', floor: 'anfragen', room: 'zz', model: 'haiku', role: '', caps: [] }], ['anfragen']), /Unbekannter Raum: zz/)
    })
    

    tests/unit/workersCamera.test.mjs:

    const C = await loadModule('src/components/workers/engine/camera.js')
    test('fitZoom: ab 720 px 1, darunter wachsend bis 2,2', () => {
      assert.equal(C.fitZoom(1280), 1); assert.equal(C.fitZoom(720), 1); assert.ok(Math.abs(C.fitZoom(390) - 720 / 390) < 1e-9); assert.equal(C.fitZoom(200), 2.2)
    })
    test('zoomAt hält den Punkt unter dem Zeiger fest, klemmt Zoom und Rand', () => {
      const p0 = C.viewToWorld(C.createCamera(), 0.25, 0.75)
      const cam = C.zoomAt(C.createCamera(), 2, 0.25, 0.75)
      assert.equal(cam.zoom, 2); const p1 = C.viewToWorld(cam, 0.25, 0.75); assert.ok(Math.abs(p1.x - p0.x) < 1e-9 && Math.abs(p1.y - p0.y) < 1e-9)
      assert.equal(C.zoomAt(C.createCamera(), 10, 0.5, 0.5).zoom, 3); assert.equal(C.zoomAt(C.createCamera(), 0.1, 0.5, 0.5).zoom, 1)
      const edge = C.panBy({ zoom: 2, x: 0, y: 0 }, 5, 5); assert.deepEqual([edge.x, edge.y], [160, 128])
      assert.deepEqual([C.panBy({ zoom: 2, x: 10, y: 10 }, -5, -5).x, C.panBy({ zoom: 2, x: 10, y: 10 }, -5, -5).y], [0, 0])
    })
    test('viewToWorld und worldToView sind zueinander invers; centerOn', () => {
      const cam = { zoom: 2.5, x: 40, y: 60 }
      const w = C.viewToWorld(cam, 0.3, 0.6), v = C.worldToView(cam, w.x, w.y); assert.ok(Math.abs(v.vx - 0.3) < 1e-9 && Math.abs(v.vy - 0.6) < 1e-9)
      const c = C.centerOn({ zoom: 2, x: 0, y: 0 }, 160, 128); assert.deepEqual([c.x, c.y], [80, 64])
    })
    test('pickAgent: oberste Figur gewinnt, Polster vergrößert die Fläche, Treffer nur im Kasten', () => {
      const boxes = [{ id: 'a', box: { x: 10, y: 10, w: 12, h: 16 } }, { id: 'b', box: { x: 16, y: 10, w: 12, h: 16 } }]
      assert.equal(C.pickAgent(boxes, 18, 14), 'b'); assert.equal(C.pickAgent(boxes, 11, 14), 'a'); assert.equal(C.pickAgent(boxes, 40, 14), null)
      assert.equal(C.pickAgent(boxes, 30, 14, 0), null); assert.equal(C.pickAgent(boxes, 30, 14, 3), 'b')
    })
    
  • Step 2: Scheitern sehen — cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersLayout.test.mjs tests/unit/workersCamera.test.mjs. Expected: FAIL Could not resolve …/layout.js.

  • Step 3: Implementieren layout.js: Etage 0 top 6 / floorY 40, Etage i ≥ 1: top = 41 + 23*(i-1), floorY = top + 22; fy(layout, i) = floorY − 14, cabTop(layout, i) = floorY − 21 (Kabinenhöhe als Zahl, layout.js importiert lift.js nicht). Räume je Etage aus roles (Reihenfolge der Rollen) plus { id:'pult' } in freigabe und { id:'tresor' } in tresor; Aufteilung: 4 Räume ⇒ x [42, 88, 134, 180], w [46, 46, 46, 48]; 3 Räume ⇒ x [42, 104, 166], w 62; 1 Raum ⇒ 42/186; sonst gleichmäßig. Heim: x = room.x + floor((room.w - breite)/2) mit breite 24 für Otto, sonst 12; y = fy = floorY - 14, Otto floorY - 27. ROOM_META aus der Tabelle (netz→radar, kleinanzeigen→inbox, portale→feed, foren→board, aerzte/handwerk/selbst/laden→scout, waage, mail, forum→schreiber, posteingang→sortier, abschluss→vertrag, tisch1–3→tisch, pruefung, pult, tresor, archiv, bruecke); Team-Farbschlüssel aus TEAM (Suche-Räume radar bzw. eigener Branchenschlüssel, Gespräche/Brücke/Pult freigabe, Tische werkstatt, Waage bewertung). spots wie im Test. camera.js: Sichtfeld = W/zoom × H/zoom; zoomAt setzt zoom = clamp(zoom*factor, 1, 3) und x = wx - fx*W/zoom' (mit wx vorher aus viewToWorld), danach clampCamera; fitZoom = w >= 720 ? 1 : min(2.2, 720/w); pickAgent prüft von hinten nach vorn die um pad vergrößerten Kästen.

  • Step 4: Bestehen sehen — gleicher Befehl wie Step 2. Expected: # pass 8, # fail 0.

  • Step 5: Commit

    cd $WK && git add src/components/workers/engine/layout.js src/components/workers/engine/camera.js tests/unit/workersLayout.test.mjs tests/unit/workersCamera.test.mjs
    git commit -m "Workers P2: Layout als Daten (10 Etagen, 19 Plätze) und Kamera mit Umkehr-Transformation" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
    

P2-T09: Effekt-System fx.js

Files:

  • Create: src/components/workers/engine/fx.js
  • Test: tests/unit/workersFx.test.mjs

Interfaces:

  • Consumes: px, PAL (sprites.js), drawCoin, drawStamp (props.js). Quelle: scene-turm.js Z. 366–393 (addFx/updateFx/drawFx/envelope).

  • Produces:

    export const FX_NAMES, MAX_FX      // ['paper','envelope','coin','stampOk','stampNo','sparkle','alert','drawer'], 80
    export function createFx(): { add(life, draw, onEnd?, delay = 0), update(dt), draw(ctx), clear(), size() }
    export function spawnFx(fx, name, { from, to, delay = 0, onEnd }): void   // from/to = { x, y } in Weltkoordinaten
    

    draw(ctx, progress 0..1, effect); onEnd genau einmal beim Ablauf; clear() ruft kein onEnd; über MAX_FX hinaus wird der neue Effekt verworfen und sein onEnd sofort gerufen.

  • Step 1: Failing test schreiben (const F = await loadModule('…/engine/fx.js'))

    test('Verzögerung, Lebensdauer, onEnd genau einmal', () => {
      const fx = F.createFx(), log = []; fx.add(1, (c, p) => log.push(['draw', p]), () => log.push(['end']), 0.5)
      const { ctx } = makeCtx(); fx.update(0.4); fx.draw(ctx); assert.equal(log.length, 0)
      fx.update(0.4); fx.draw(ctx); assert.equal(log.at(-1)[0], 'draw')
      fx.update(1); assert.deepEqual(log.at(-1), ['end']); fx.update(1); assert.equal(log.filter((l) => l[0] === 'end').length, 1); assert.equal(fx.size(), 0)
    })
    test('Obergrenze 80: Überschuss wird verworfen und meldet onEnd; clear ruft kein onEnd', () => {
      const fx = F.createFx(); let ended = 0
      for (let i = 0; i < 200; i++) fx.add(5, () => {}, () => { ended++ })
      assert.equal(fx.size(), F.MAX_FX); assert.equal(ended, 120); fx.clear(); assert.equal(fx.size(), 0); assert.equal(ended, 120)
    })
    test('jeder Effektname zeichnet und endet in höchstens 6 s', () => {
      for (const name of F.FX_NAMES) { const fx = F.createFx(), { ctx, rects } = makeCtx(); let ended = false
        F.spawnFx(fx, name, { from: { x: 50, y: 100 }, to: { x: 150, y: 60 }, onEnd: () => { ended = true } })
        for (let i = 0; i < 60; i++) { fx.update(0.1); fx.draw(ctx) }
        assert.ok(rects.length > 0, name); assert.equal(fx.size(), 0, name); assert.ok(ended, name) }
    })
    
  • Step 2: Scheitern sehen — cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersFx.test.mjs. Expected: FAIL Could not resolve …/engine/fx.js.

  • Step 3: Implementieren engine/fx.js Lebensdauern: paper 0,9 s (Bogen von from nach to), envelope 3,4 s (Bogen mit Höhe 12, wie sentOut), coin 0,7 s (Bogen, 14 hoch), stampOk/stampNo 1,2 s (Stempel hüpft über from), sparkle 1,0 s, alert 1,5 s (blinkendes „!“ über from), drawer 0,9 s (Papier fliegt von from nach to, Ende kürzer 3 px). Jeder Effekt zeichnet nur mit px/label.

  • Step 4: Bestehen sehen — gleicher Befehl. Expected: # pass 3, # fail 0.

  • Step 5: Commit

    cd $WK && git add src/components/workers/engine/fx.js tests/unit/workersFx.test.mjs
    git commit -m "Workers P2: Effekt-System mit Obergrenze" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
    

P2-T10: Aufzug lift.js

Files:

  • Create: src/components/workers/engine/lift.js
  • Test: tests/unit/workersLift.test.mjs

Interfaces:

  • Consumes: cabTop (layout.js), px, tint (sprites.js). Quelle: scene-turm.js Z. 316–330 (Kabine), 354–357, 466–492 (Zustandsautomat).

  • Produces:

    export const LIFT = { cabWx: 22, exitX: 50, slot0: 52, slotStep: 14, speed: 110, cabH: 21 }
    export function createLift(layout): { y, state, request(agent, from, to), update(dt), cancel(agent), reset(), at(floorIndex), drawBack(ctx), drawFront(ctx) }
    

    request ⇒ req = { a, from, to, st } mit st: queued → open → boarded → arrived → out, außerdem cancelled; die Übergänge boarded und out setzt der Fahrgast (Regie), alle anderen der Aufzug. state: idle|toPick|boarding|closing|riding|unloading. Während der Fahrt folgt req.a: a.y = lift.y + 7, a.x = LIFT.cabWx, a.frame = 0.

  • Step 1: Failing test schreiben (const { createLift, LIFT } = await loadModule('…/engine/lift.js'), buildLayout, cabTop aus layout.js)

    const layout = buildLayout()
    function until(lift, fn, max = 12) { let t = 0; while (!fn() && t < max) { lift.update(0.05); t += 0.05 } return t }
    test('Fahrt 1 → 8: queued, open, boarded, arrived, out; Fahrgast folgt der Kabine; danach idle', () => {
      const lift = createLift(layout), a = { x: 0, y: 0, frame: 1 }, req = lift.request(a, 1, 8)
      assert.equal(req.st, 'queued')
      let t = until(lift, () => req.st === 'open'); assert.ok(lift.at(1)); assert.equal(lift.state, 'boarding')
      req.st = 'boarded'; t += until(lift, () => req.st === 'arrived'); assert.ok(lift.at(8)); assert.equal(a.y, cabTop(layout, 8) + 7); assert.equal(a.x, LIFT.cabWx)
      assert.ok(t < 12); req.st = 'out'; lift.update(0.05); assert.equal(lift.state, 'idle')
    })
    test('FIFO: zweite Anfrage wartet bis die erste ausgestiegen ist', () => {
      const lift = createLift(layout), r1 = lift.request({}, 1, 4), r2 = lift.request({}, 5, 2)
      until(lift, () => r1.st === 'open'); r1.st = 'boarded'; until(lift, () => r1.st === 'arrived'); assert.equal(r2.st, 'queued')
      r1.st = 'out'; until(lift, () => r2.st === 'open'); assert.equal(r2.st, 'open')
    })
    test('cancel und reset geben die Kabine frei (kein Festhängen)', () => {
      const lift = createLift(layout), a = {}, r1 = lift.request(a, 7, 1), r2 = lift.request({}, 2, 3)
      lift.update(0.05); lift.cancel(a); assert.equal(r1.st, 'cancelled'); until(lift, () => r2.st === 'open'); assert.equal(r2.st, 'open')
      lift.reset(); assert.equal(r2.st, 'cancelled'); assert.equal(lift.state, 'idle')
    })
    
  • Step 2: Scheitern sehen — cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersLift.test.mjs. Expected: FAIL Could not resolve …/engine/lift.js.

  • Step 3: Implementieren engine/lift.js Zustandsautomat 1:1 aus der Vorlage (Z. 466–492) mit cabMove (speed 110) und follow; neu: cancel(agent) markiert Anfragen des Agenten cancelled, entfernt sie aus der Warteschlange und setzt bei der laufenden Anfrage req = null, state = 'idle'; reset() macht dasselbe für alle; update überspringt cancelled. Startposition y = cabTop(layout, 2). drawBack/drawFront aus Z. 316–330 (Kabine 20 × 21 bei x 18, Seil, Lampe), Etagenlampen zeichnet building.js über at(i).

  • Step 4: Bestehen sehen — gleicher Befehl. Expected: # pass 3, # fail 0.

  • Step 5: Commit

    cd $WK && git add src/components/workers/engine/lift.js tests/unit/workersLift.test.mjs
    git commit -m "Workers P2: Aufzug mit Abbruch und Reset" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
    

P2-T11: Räume und Gebäude (rooms.js, building.js)

Files:

  • Create: src/components/workers/engine/rooms.js, src/components/workers/engine/building.js
  • Test: tests/unit/workersRooms.test.mjs

Interfaces:

  • Consumes: layout.js (buildLayout, ROOM_META), props.js, sprites.js, ROOM_TITLES/FLOOR_TITLES (workersRoster.js), lift.at. Quelle: scene-turm.js Z. 99–314.

  • Produces:

    // rooms.js
    export function createLook(): { greyRooms:Set<string>, busyRooms:Set<string>, pultOpen:0, dealsMonth:0, botLeads:0, trophyPage:0, pageT:0, trophyPop:0, vaultFlash:0, paused:false }
    export function trophyPaging(total, page, perPage = 8): { shown, page, pages, from }
    export function drawRooms(ctx, layout, t, look): void
    // building.js
    export function drawOutside(ctx): void                 // Boden, Erde, Bodenlinie bei y 224 (Tresor-Etage), Dachlinie
    export function drawTower(ctx, layout, t, lift): void  // Hülle, Schacht, Etagenböden/-wände, Lampen, Etagenschilder rechts außen (x 234)
    export function drawPauseVeil(ctx, look): void         // weißer Schleier 45 % und „Pausiert“, nur bei look.paused
    

    Raumtabelle (kind ⇒ Inhalt): bruecke Konsole mit 2 Monitoren, Tafel, Fenster, Pflanze · radar drawRadar + Tisch · inbox Tisch, Monitor, drawTray · feed Tisch, Monitor mit Zeilen · board Tisch, Monitor, Sprechblasen-Tafel · scout Tisch in Teamfarbe, Monitor, Branchen-Icon · waage drawScale, Tisch · mail Tisch, Monitor, Briefstapel · schreiber Tisch, Monitor (Schreib-Animation) · sortier Tisch, 3 Ablagekörbe · vertrag Tisch, Vertrag, Stempel · tisch Tisch, Monitor · pruefung Tisch, 2 Stempel, Handy · pult großer drawMailbox mit Zahl · tresor Pokalbrett (trophyPaging), drawVault, Zähler ${dealsMonth} Deals · archiv 4 Schränke, Kartons, Zähler ${botLeads} Bot-Leads. Jeder Raum bekommt Titel (ROOM_TITLES) und Teamfarbton (Alpha 0,10); Monitore laufen nur in Räumen aus look.busyRooms; Räume in look.greyRooms: Grautönung, Monitore aus, drawSign „noch nicht angeschlossen“ mittig.

  • Step 1: Failing test schreiben (const R = await loadModule('…/engine/rooms.js'), B = …/building.js, Layout/Roster/PAL)

    const layout = buildLayout()
    const draw = (look, t = 1) => { const m = makeCtx(); R.drawRooms(m.ctx, layout, t, look); return m }
    test('trophyPaging: 8 je Seite, Seite wird geklemmt', () => {
      assert.deepEqual(R.trophyPaging(0, 0), { shown: 0, page: 0, pages: 1, from: 0 }); assert.deepEqual(R.trophyPaging(8, 0), { shown: 8, page: 0, pages: 1, from: 0 })
      assert.deepEqual(R.trophyPaging(9, 1), { shown: 1, page: 1, pages: 2, from: 8 }); assert.deepEqual(R.trophyPaging(9, 5), { shown: 1, page: 1, pages: 2, from: 8 })
      assert.deepEqual(R.trophyPaging(25, -1), { shown: 8, page: 0, pages: 4, from: 0 })
    })
    test('alle 21 Räume zeichnen, jeder Titel erscheint', () => {
      const { texts, rects } = draw(R.createLook()); assert.ok(rects.length > 300)
      for (const title of Object.values(ROOM_TITLES)) assert.ok(texts.some((x) => x.text === title), title)
    })
    test('graue Räume tragen das Schild, sonst keins (Review Focus 4)', () => {
      const look = R.createLook(); look.greyRooms = new Set(['kleinanzeigen', 'portale'])
      assert.equal(draw(look).texts.filter((x) => x.text === 'noch nicht angeschlossen').length, 2)
      assert.equal(draw(R.createLook()).texts.filter((x) => x.text === 'noch nicht angeschlossen').length, 0)
    })
    test('Pult-Zahl, Pokal- und Bot-Lead-Zähler', () => {
      const look = R.createLook(); look.pultOpen = 7; look.dealsMonth = 9; look.botLeads = 112
      const t = draw(look).texts.map((x) => x.text); assert.ok(t.includes('7')); assert.ok(t.includes('9 Deals')); assert.ok(t.includes('112 Bot-Leads'))
      assert.ok(!draw(R.createLook()).texts.some((x) => x.text === '7'))
    })
    test('Gebäude: Etagenschilder, Lampen, Pausenschleier', () => {
      const m = makeCtx(); B.drawTower(m.ctx, layout, 1, { at: (i) => i === 3 }); for (const title of Object.values(FLOOR_TITLES)) assert.ok(m.texts.some((x) => x.text === title), title)
      const on = makeCtx(); const look = R.createLook(); look.paused = true; B.drawPauseVeil(on.ctx, look); assert.ok(on.texts.some((x) => x.text === 'Pausiert'))
      const off = makeCtx(); B.drawPauseVeil(off.ctx, R.createLook()); assert.equal(off.texts.length, 0); B.drawOutside(makeCtx().ctx)
    })
    
  • Step 2: Scheitern sehen — cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersRooms.test.mjs. Expected: FAIL Could not resolve …/engine/rooms.js.

  • Step 3: Implementieren engine/rooms.js, engine/building.js Eine drawRoom(ctx, room, floor, t, look) je kind (Tabelle oben) über ein Objekt ROOM_DRAWERS[kind], alle Koordinaten relativ zu room.x/floor.floorY; Schriften nur über label. Räume mit workerId null (pult, tresor) werden nie grau. trophyPaging: pages = max(1, ceil(total/perPage)), page auf 0..pages-1 geklemmt, from = page*perPage, shown = min(perPage, total-from). drawTower: Hülle/Schacht/Böden/Schachttüren aus Vorlage Z. 118–172 auf 10 Etagen, Etagenschild rechts außen als Teamfarbpunkt plus FLOOR_TITLES-Text bei x 234. Die Stadtsilhouette der Vorlage entfällt.

  • Step 4: Bestehen sehen — gleicher Befehl. Expected: # pass 5, # fail 0.

  • Step 5: Commit

    cd $WK && git add src/components/workers/engine/rooms.js src/components/workers/engine/building.js tests/unit/workersRooms.test.mjs
    git commit -m "Workers P2: Räume nach kind, Pokal-Seiten, graue Räume, Gebäudehülle" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
    

P2-T12: Regie 1 – Agenten, Snapshot, Posen, Leerlauf (director.js)

Files:

  • Create: src/components/workers/engine/director.js, tests/unit/helpers/workersFixtures.mjs
  • Test: tests/unit/workersDirector.test.mjs

Interfaces:

  • Consumes: layout.js (homeOf, fy), lift.js, fx.js, rooms.js (createLook, trophyPaging), sprites.js (drawWorker, drawBubble, drawAlertMark), camera.js (pickAgent), workersRoster.js (ROLES, hatForWorker), workersEvents.js (indexWorkers).

  • Produces:

    export const BACKLOG_KEEP = 6, GROUPS_PER_AGENT = 4, IDLE_COFFEE_S = 25, STEP_TIMEOUT_S = 45, BUBBLE_MAX = 3
    export function createDirector({ layout, lift, fx, look, roles = ROLES, rnd = Math.random }): {
      agents, byId, applyState(state), pushEvents(events), update(dt), flush(), pending(), boxes(), reset(), debug() }
    export function drawAgents(ctx, director, selectedId, t): void    // y-sortiert, setzt agent.box, Blasen, Auswahlpfeil, „!“ bei alert
    

    Agent: { id, name, hat, scale, x, y, floor, facing, frame, pose:'stand'|'type'|'coffee'|'sleep', carrying, stackN, say, bubT, grey, alert, status, task, counters, lane, bob, idleT, cheer, q, cur, stepT, groups, box, home }. tests/unit/helpers/workersFixtures.mjs: IDS (die 19 workerIds in Rollenreihenfolge), makeState({ statuses = {}, connected = {}, kpis = {}, paused = false, sendingHalted = false, sendingHaltedReason = '', lastEventTs } = {}) ⇒ Zustand (paused, sendingHalted, sendingHaltedReason, lastEventTs, wie Übersicht §6.2) mit 19 workers ({ workerId, status: statuses[id] ?? 'arbeitet', task: '', counters: {}, connected: connected[id] ?? true }; wie im Dienst (P1-E9) sind nur Such-Worker nicht angeschlossen: die Tests setzen connected: false ausschließlich für rita, kim, pia, finn, ali, hanna, sven, lea) und kpis (Standard { newAbosMonth: 3, mrrNewEur: 60, dealsMonth: 3, openThreads: 7, pultOpen: 2, botLeads: 112, apiTodayUsd: 5.1, apiMonthUsd: 50, dailyBudgetUsd: 6, monthlyBudgetUsd: 150 } (Kosten: [Kenso, 17.10.: max. 150 € im Monat]), überschrieben durch kpis), ev(kind, workerId, extra) ⇒ Ereignis mit eindeutiger id, ts (zählt je Aufruf um 1 s hoch), text: 'Text'.

  • Step 1: Failing test schreiben (tests/unit/workersDirector.test.mjs)

    function setup() { const layout = buildLayout(), lift = createLift(layout), fx = createFx(), look = createLook(); return { layout, lift, fx, look, d: createDirector({ layout, lift, fx, look, rnd: () => 0.5 }) } }
    test('erster Snapshot: alle 19 an ihrem Platz, Pose nach Status, „!“ bei Fehler', () => {
      const { d, layout } = setup(); d.applyState(makeState({ statuses: { kim: 'leerlauf', rita: 'wartet', bea: 'fehler' } }))
      assert.equal(d.agents.length, 19)
      for (const a of d.agents) { const h = homeOf(layout, a.id); assert.deepEqual([a.x, a.y], [h.x, h.y], a.id) }
      assert.deepEqual([d.byId.kim.pose, d.byId.rita.pose, d.byId.otto.pose, d.byId.bea.alert], ['coffee', 'stand', 'type', true])
    })
    test('nicht angeschlossen oder im Zustand fehlend ⇒ grau, schläft, Raum in look.greyRooms (Review Focus 4)', () => {
      const { d, look } = setup(); const st = makeState({ connected: { kim: false } }); st.workers = st.workers.filter((w) => w.workerId !== 'pia')
      d.applyState(st)
      assert.deepEqual([d.byId.kim.grey, d.byId.kim.pose, d.byId.pia.grey, d.byId.pia.pose, d.byId.bob.grey], [true, 'sleep', true, 'sleep', false])
      assert.deepEqual([...look.greyRooms].sort(), ['kleinanzeigen', 'portale'])
    })
    test('Kennzahlen landen in look; neuer Pokal blinkt nur bei steigender Zahl, nicht beim ersten Snapshot', () => {
      const { d, look } = setup(); d.applyState(makeState({ kpis: { pultOpen: 4, dealsMonth: 9, botLeads: 112 } }))
      assert.deepEqual([look.pultOpen, look.dealsMonth, look.botLeads, look.trophyPop], [4, 9, 112, 0])
      d.applyState(makeState({ kpis: { pultOpen: 4, dealsMonth: 10, botLeads: 112 } })); assert.ok(look.trophyPop > 0)
      d.applyState(makeState({ paused: true })); assert.equal(look.paused, true)
      d.applyState(makeState({ sendingHalted: true, sendingHaltedReason: 'Zwei harte Bounces' })); assert.equal(look.paused, false)   // Notbremse (H4) ist keine Pause: kein Schleier, die Meldung zeigt das Board
    })
    test('Leerlauf: erst Kaffee, nach 25 s Schlaf; Pokal-Seiten blättern alle 6 s', () => {
      const { d, look } = setup(); d.applyState(makeState({ statuses: { kim: 'leerlauf' }, kpis: { dealsMonth: 9 } }))
      const step = (n) => { for (let i = 0; i < n; i++) d.update(0.1) }
      step(70); assert.equal(look.trophyPage, 1)                                  // 7 s: erste Blätterung bei 6 s
      step(60); assert.equal(look.trophyPage, 0); assert.equal(d.byId.kim.pose, 'coffee')   // 13 s: zweite Blätterung bei 12 s
      step(140); assert.equal(d.byId.kim.pose, 'sleep')                           // 27 s > 25 s
    })
    test('drawAgents: Trefferflächen für alle 19, jeder ist anklickbar', () => {
      const { d } = setup(); d.applyState(makeState({ connected: { kim: false }, statuses: { rita: 'fehler' } })); const { ctx } = makeCtx(); drawAgents(ctx, d, 'bob', 1)
      const boxes = d.boxes(); assert.equal(boxes.length, 19)
      for (const { id, box } of boxes) assert.equal(pickAgent(boxes, box.x + box.w / 2, box.y + box.h / 2), id, id)
    })
    
  • Step 2: Scheitern sehen — cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersDirector.test.mjs. Expected: FAIL Could not resolve …/engine/director.js.

  • Step 3: Implementieren engine/director.js (Teil 1) und tests/unit/helpers/workersFixtures.mjs applyState(state): workers = indexWorkers(state); je Agent w = Eintrag oder fehlend ⇒ grey; grey = status === 'grau'; look.greyRooms aus den Räumen grauer Agenten; look.busyRooms = Räume mit Status arbeitet; Kennzahlen nach look; steigt kpis.dealsMonth gegenüber dem vorigen Snapshot (nicht beim ersten), look.trophyPop = 1.2; look.paused = !!state.paused. Pose-Regel poseFor(a): grau ⇒ sleep; leerlauf ⇒ idleT >= 25 ? sleep : coffee; arbeitet ⇒ type; sonst stand; alert = status === 'fehler'. Der erste Snapshot setzt alle Agenten auf homeOf; spätere Snapshots bewegen nie, sie setzen Status/Pose nur bei Agenten ohne laufende Gruppe (sonst nach Ende der Gruppe). update(dt): bei look.paused sofort zurück; sonst Agenten (idleT wächst bei Status leerlauf, Pose jedes Bild neu nach poseFor für Agenten ohne laufende Gruppe, Wippen, Blasen-Timer), lift.update, fx.update, look.trophyPop/vaultFlash herunterzählen, look.pageT += dt ⇒ bei ≥ 6 trophyPage = (trophyPage + 1) % pages. drawAgents: nach y + lane + bob + 13*scale sortieren, drawWorker mit pose/grey/t, agent.box merken, Stapel-Papiere und „!“ zeichnen, Blasen zuletzt (Otto seitlich wie in der Vorlage Z. 929–944). bubble(a, text): cutBubble, höchstens BUBBLE_MAX gleichzeitig, sonst stillschweigend verworfen. pushEvents, flush, reset, debug kommen erst in P2-T13; hier liefert pending() vorerst 0.

  • Step 4: Bestehen sehen — gleicher Befehl. Expected: # pass 5, # fail 0.

  • Step 5: Commit

    cd $WK && git add src/components/workers/engine/director.js tests/unit/helpers/workersFixtures.mjs tests/unit/workersDirector.test.mjs
    git commit -m "Workers P2: Regie 1 (Agenten, Snapshot ohne Animation, Posen, Leerlauf)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
    

P2-T13: Regie 2 – Schritte, Aufzugfahrten, Rückstau, Wachhund

Files:

  • Modify: src/components/workers/engine/director.js
  • Test: tests/unit/workersDirector.test.mjs (erweitern)

Interfaces:

  • Consumes: eventToSteps, reduceEvents, cutBubble (P2-T03), LIFT (P2-T10), spawnFx (P2-T09), layout.spots.

  • Produces (ergänzt P2-T12): pushEvents(events), flush(), pending() (wartende + laufende Gruppen über alle Agenten), reset() (Warteschlangen leeren, Aufzug und Effekte zurücksetzen, alle Agenten heim), debug() ⇒ { door: number[10], groups: number }.

  • Step 1: Failing tests ergänzen (ev, IDS, makeState aus den Fixtures)

    const run = (d, max = 60) => { let t = 0; while (d.pending() > 0 && t < max) { d.update(0.05); t += 0.05 } return t }
    test('Übergabe über zwei Etagen: Rita bringt Bea ein Dokument, fährt Aufzug, steht danach wieder zuhause', () => {
      const { d, lift, layout } = setup(); d.applyState(makeState()); d.pushEvents([ev('uebergabe', 'rita', { to: 'bea', text: 'Rita bringt 2 Funde zu Bea' })])
      assert.ok(d.pending() >= 1); let ride = false, t = 0; while (d.pending() > 0 && t < 60) { d.update(0.05); t += 0.05; if (lift.state !== 'idle') ride = true }
      assert.ok(ride); assert.ok(t < 40, String(t)); const h = homeOf(layout, 'rita')
      assert.deepEqual([Math.round(d.byId.rita.x), Math.round(d.byId.rita.y), d.byId.rita.carrying, lift.state], [h.x, h.y, false, 'idle']); assert.equal(d.byId.rita.task, 'Rita bringt 2 Funde zu Bea')
    })
    test('gleiche Etage: kein Aufzug', () => {
      const { d, lift } = setup(); d.applyState(makeState()); d.pushEvents([ev('uebergabe', 'kim', { to: 'pia' })]); let ride = false; for (let i = 0; i < 1200 && d.pending() > 0; i++) { d.update(0.05); if (lift.state !== 'idle') ride = true }
      assert.equal(ride, false); assert.equal(d.pending(), 0)
    })
    test('Rückstau: 300 Ereignisse ⇒ höchstens 6 Gruppen, Rest still, nach ≤ 60 s ruhig, letzte Aufgaben stehen (Review Focus 2)', () => {
      const { d, lift, fx } = setup(); d.applyState(makeState())
      d.pushEvents(Array.from({ length: 300 }, (_, i) => ev('uebergabe', IDS[i % 19], { to: IDS[(i + 3) % 19], text: `Aufgabe ${i}` })))
      assert.ok(d.pending() <= 6, String(d.pending())); assert.ok(run(d) < 60); assert.equal(d.pending(), 0); assert.equal(lift.state, 'idle')
      for (let k = 0; k < 19; k++) { const last = 299 - ((299 - k) % 19); assert.equal(d.byId[IDS[k]].task, `Aufgabe ${last}`, IDS[k]) }
      d.pushEvents(Array.from({ length: 10 }, (_, i) => ev('fund', 'kim', { text: `K${i}` }))); assert.ok(d.byId.kim.groups <= 4)
    })
    test('graue, fremde und unbekannte Ereignisse: keine Arbeit, kein Wurf (Review Focus 4)', () => {
      const { d } = setup(); d.applyState(makeState({ connected: { kim: false } }))
      d.pushEvents([ev('gelesen', 'kim'), ev('fund', 'zed'), ev('neueArt', 'rita'), ev('uebergabe', 'kim', { to: 'bea' })])
      assert.equal(d.byId.kim.say, null); assert.equal(d.byId.kim.pose, 'sleep'); assert.equal(d.byId.kim.groups, 0)
    })
    test('Blasen: höchstens drei gleichzeitig', () => {
      const { d } = setup(); d.applyState(makeState()); d.pushEvents(['kim', 'rita', 'pia', 'finn', 'ali'].map((w) => ev('fund', w))); d.update(0.05)
      assert.ok(d.agents.filter((a) => a.say).length <= 3)
    })
    test('Pause friert ein, Fortsetzen läuft weiter', () => {
      const { d } = setup(); d.applyState(makeState({ paused: true })); d.pushEvents([ev('uebergabe', 'kim', { to: 'pia' })]); const x0 = d.byId.kim.x
      for (let i = 0; i < 400; i++) d.update(0.05); assert.equal(d.byId.kim.x, x0)
      d.applyState(makeState({ paused: false })); for (let i = 0; i < 40; i++) d.update(0.05); assert.notEqual(d.byId.kim.x, x0)
    })
    test('Aufzug zurückgesetzt mitten in der Fahrt: Fahrgast erholt sich, Türzähler 0; hängender Aufzug: Wachhund nach 45 s', () => {
      const a = setup(); a.d.applyState(makeState()); a.d.pushEvents([ev('uebergabe', 'rita', { to: 'bea' })])
      let g = 0; while (a.lift.state !== 'riding' && g++ < 400) a.d.update(0.05)
      assert.equal(a.lift.state, 'riding'); a.lift.reset(); assert.ok(run(a.d) < 10); assert.deepEqual(a.d.debug().door, new Array(10).fill(0))
      const layout = buildLayout(), fx = createFx(), look = createLook(), stuck = { y: 0, state: 'idle', request: (ag, f, t) => ({ a: ag, from: f, to: t, st: 'queued' }), update() {}, cancel() {}, reset() {}, at: () => false, drawBack() {}, drawFront() {} }
      const d = createDirector({ layout, lift: stuck, fx, look }); d.applyState(makeState()); d.pushEvents([ev('uebergabe', 'rita', { to: 'bea' })])
      const t = run(d, 80); assert.ok(t >= 45 && t < 60, String(t)); assert.deepEqual(d.debug().door, new Array(10).fill(0))
    })
    test('flush: alles sofort ruhig, alle zuhause; Deal löst Münzen und Jubel aus', () => {
      const { d, lift, fx, layout } = setup(); d.applyState(makeState()); d.pushEvents(Array.from({ length: 20 }, (_, i) => ev('uebergabe', IDS[i % 19], { to: IDS[(i + 5) % 19] })))
      d.flush(); assert.equal(d.pending(), 0); assert.equal(lift.state, 'idle'); assert.equal(fx.size(), 0)
      for (const a of d.agents) { const h = homeOf(layout, a.id); assert.deepEqual([Math.round(a.x), Math.round(a.y)], [h.x, h.y], a.id) }
      d.pushEvents([ev('deal', 'system')]); d.update(0.1); assert.ok(fx.size() >= 3); assert.ok(d.byId.otto.cheer > 0); run(d); for (let i = 0; i < 100; i++) d.update(0.1); assert.equal(fx.size(), 0)
    })
    
  • Step 2: Scheitern sehen — cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersDirector.test.mjs. Expected: FAIL (d.pushEvents is not a function bzw. Assertion-Fehler bei pending).

  • Step 3: Implementieren in engine/director.js pushEvents(events): (1) workers = reduceEvents(workers, events), nur den Status auf die Agenten übertragen (agent.task setzen allein task-Schritte und der Snapshot) und look.busyRooms sowie look.greyRooms aus den Agenten neu bilden (dieselbe Hilfsfunktion wie in applyState); (2) je Ereignis ops = eventToSteps(e), Besitzer der Gruppe = from des ersten give, sonst W (wenn in ROLES), sonst otto; (3) die letzten BACKLOG_KEEP Gruppen sind animiert, alle älteren still; zusätzlich wird eine Gruppe still, wenn der Besitzer grau ist oder schon GROUPS_PER_AGENT Gruppen hält; (4) stille Gruppen führen sofort nur task/pose aus, animierte werden an agent.q gehängt (groups++). Besitzer-Queue wie in der Vorlage (nextStep/stepAgent, Z. 864–883), jeder Op wird zu Schritten: task setzt agent.task; say ruft bubble (Ziel who); pose setzt Pose und idleT = 0 (bei grauen Agenten wirkungslos); cheer setzt cheer = 2; fx ruft spawnFx mit from = posOf(at), to = posOf(to) (Agent: Kopfmitte, Platz: layout.spots; drawer ohne to ⇒ spots.archiv; bei coin-Ende look.vaultFlash = 0.7); give = Folge: Dokument aufnehmen (carrying = true) → zum Empfänger (bei anderer Etage rideStep(vonEtage, nachEtage), Algorithmus unverändert aus der Vorlage Z. 435–464 mit lift.request, Wartepositionen LIFT.slot0 + LIFT.slotStep * door[etage]++) → neben den Empfänger laufen (links oder rechts, je nach Seite, Abstand 12), Empfänger bubble('Danke!') → carrying = false → Rückfahrt → walkTo(home.x) → Blickrichtung und poseFor. rideStep bricht in jeder Phase bei req.st === 'cancelled' ab (Agent sofort heim; der Türzähler wird nur zurückgenommen, wenn der Agent noch mitgezählt ist, also vor dem Einsteigen). Wachhund: läuft ein Schritt länger als STEP_TIMEOUT_S, wird lift.cancel(agent) gerufen, der Türzähler zurückgenommen, der Agent heimgesetzt und die Gruppe beendet. pending() = Summe groups + laufende. flush(): wiederholt update(0.25) (höchstens 2000 Mal) bis pending() === 0, setzt alle Agenten heim, lift.reset(), fx.clear(). reset() = leeren + heim.

  • Step 4: Bestehen sehen — gleicher Befehl. Expected: # pass 13, # fail 0 (5 aus P2-T12 + 8 neue).

  • Step 5: Commit

    cd $WK && git add src/components/workers/engine/director.js tests/unit/workersDirector.test.mjs
    git commit -m "Workers P2: Regie 2 (Schritte, Aufzugfahrten, Rückstau-Kappung, Wachhund)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
    

P2-T14: Szene scene.js (mountTower)

Files:

  • Create: src/components/workers/engine/scene.js, tests/unit/helpers/engineVm.mjs
  • Test: tests/unit/workersScene.test.mjs

Interfaces:

  • Consumes: alle Engine-Module, pickAgent/Kamera, createDirector, drawAgents, drawOutside/drawTower/drawPauseVeil, drawRooms, createLift, createFx, createLook.

  • Produces (Übersicht §7 plus E4):

    export function mountTower(canvas, { onSelect, reducedMotion = false, coarsePointer = false, fontsReady, dpr = 1, raf, caf, now }): {
      destroy(), setState(state), pushEvents(events), select(workerId|null), setPaused(bool),
      resize(), zoomBy(faktor), resetView(), locate(workerId): { x, y } | null, stats(): { frames, avgDrawMs, zoom }, debug() }
    export const FRAME_MS = 33, DT_MAX = 0.05, CANVAS_MAX_W = 2400, ZOOM_STEP = 1.4, DRAG_PX = 4
    

    raf/caf/now Standard: globalThis.requestAnimationFrame usw. beim Aufruf (nie beim Import). onSelect kommt nur bei Klicks des Nutzers, nie bei select(). tests/unit/helpers/engineVm.mjs: createEngineSandbox({ cssWidth = 960, dpr = 1 } = {}) bündelt scene.js mit esbuild als IIFE (platform: 'neutral', globalName: 'WKEngine'), führt es in vm.createContext aus (Sandbox ohne window/document, nur console, Math, Date, JSON) und liefert { mountTower, canvas, counts, seenTexts, listeners, opts(extra), tick(seconds), rafPending(), click(cssX, cssY), pointer(type, cssX, cssY) } (counts = { fillRect, fillText } zählt nur, es speichert keine Aufrufe, sonst sprengen 7 000 Bilder den Speicher; seenTexts = Set der verschiedenen gezeichneten Texte): Fake-Canvas (getContext ⇒ Proxy-Kontext nach smoke.js, addEventListener/removeEventListener führen Buch, getBoundingClientRect ⇒ { left:0, top:0, width: cssWidth, height: cssWidth*0.8 }, style, width, height), opts(extra) ⇒ Optionen mit raf/caf/now/dpr aus dem Sandbox-Takt, tick(s) rückt now um 1000/60 ms je Schritt vor und führt fällige raf-Rückrufe aus.

  • Step 1: Failing tests schreiben (tests/unit/workersScene.test.mjs; Imports: createEngineSandbox aus ./helpers/engineVm.mjs, makeState, ev, IDS aus ./helpers/workersFixtures.mjs)

    const sel = []
    const open = async (o = {}, extra = {}) => { const sb = await createEngineSandbox(o); const h = sb.mountTower(sb.canvas, sb.opts({ onSelect: (id) => sel.push(id), ...extra })); return { sb, h } }
    test('Handle hat die Methoden aus Übersicht §7 und E4; Mount zeichnet sofort', async () => {
      const { sb, h } = await open(); for (const m of ['destroy', 'setState', 'pushEvents', 'select', 'setPaused', 'resize', 'zoomBy', 'resetView', 'locate', 'stats', 'debug']) assert.equal(typeof h[m], 'function', m)
      assert.ok(sb.counts.fillRect > 100); h.destroy()
    })
    test('Größe: Breite·dpr (max. 2400), Höhe 4/5 davon', async () => {
      let { sb, h } = await open({ cssWidth: 960, dpr: 1 }); assert.deepEqual([sb.canvas.width, sb.canvas.height], [960, 768]); h.destroy()
      ;({ sb, h } = await open({ cssWidth: 1500, dpr: 2 })); assert.deepEqual([sb.canvas.width, sb.canvas.height], [2400, 1920]); h.destroy()
    })
    test('Takt: höchstens 30 Bilder je Sekunde', async () => {
      const { sb, h } = await open(); h.setState(makeState()); const f0 = h.stats().frames; sb.tick(1); const f = h.stats().frames - f0; assert.ok(f >= 28 && f <= 32, String(f)); h.destroy()
    })
    test('Lebenszyklus: destroy doppelt, nichts danach, Listener weg; StrictMode mount → destroy → mount', async () => {
      const sb = await createEngineSandbox(); const a = sb.mountTower(sb.canvas, sb.opts({})); a.destroy(); a.destroy()
      const n = sb.counts.fillRect; sb.tick(1); assert.equal(sb.counts.fillRect, n); assert.equal(Object.values(sb.listeners).flat().length, 0)
      const b = sb.mountTower(sb.canvas, sb.opts({})); sb.tick(0.5); assert.ok(sb.counts.fillRect > n); a.setState(makeState()); a.pushEvents([ev('fund', 'kim')]); b.destroy()
      assert.equal(Object.values(sb.listeners).flat().length, 0)
    })
    test('fontsReady: Ablehnung oder Zerstörung davor lässt nichts mehr zeichnen', async () => {
      let resolve; const p = new Promise((r) => { resolve = r }); const { sb, h } = await open({}, { fontsReady: p }); h.destroy(); const n = sb.counts.fillRect; resolve(); await new Promise((r) => setImmediate(r)); assert.equal(sb.counts.fillRect, n)
      const sb2 = await createEngineSandbox(); sb2.mountTower(sb2.canvas, sb2.opts({ fontsReady: Promise.reject(new Error('x')) })).destroy()   // abgelehntes Versprechen erst nach dem Sandbox-Aufbau erzeugen, sonst „unhandled rejection“
    })
    test('reducedMotion: keine Schleife, jede Änderung zeichnet ein Standbild und beruhigt die Regie', async () => {
      const { sb, h } = await open({}, { reducedMotion: true }); assert.equal(sb.rafPending(), 0); const n = sb.counts.fillRect
      h.setState(makeState()); h.pushEvents(Array.from({ length: 30 }, (_, i) => ev('uebergabe', IDS[i % 19], { to: IDS[(i + 4) % 19] }))); assert.ok(sb.counts.fillRect > n); assert.equal(h.debug().pending, 0); assert.equal(sb.rafPending(), 0); h.destroy()
    })
    test('setPaused hält die Schleife an und setzt sie fort', async () => {
      const { sb, h } = await open(); h.setPaused(true); sb.tick(0.2); const n = sb.counts.fillRect; sb.tick(1); assert.equal(sb.counts.fillRect, n); h.setPaused(false); sb.tick(0.5); assert.ok(sb.counts.fillRect > n); h.destroy()
    })
    test('Klick trifft über die Umkehr-Transformation, auch gezoomt und verschoben', async () => {
      const { sb, h } = await open(); h.setState(makeState()); sel.length = 0
      for (const id of ['otto', 'ben', 'arne']) { const p = h.locate(id); sb.click(p.x, p.y); assert.equal(sel.at(-1), id) }
      h.zoomBy(2.5); h.select('ben'); const p = h.locate('ben'); assert.ok(p); sb.click(p.x, p.y); assert.equal(sel.at(-1), 'ben'); assert.equal(h.stats().zoom, 2.5)
      h.resetView(); assert.equal(h.stats().zoom, 1); sel.length = 0; h.select('kim'); assert.equal(sel.length, 0); h.destroy()
    })
    test('Ziehen verschiebt bei Zoom > 1 und unterdrückt den Klick', async () => {
      const { sb, h } = await open(); h.setState(makeState()); h.zoomBy(2); h.select('bea'); const before = h.locate('bea'); sel.length = 0
      sb.pointer('pointerdown', 400, 300); sb.pointer('pointermove', 460, 300); sb.pointer('pointerup', 460, 300); sb.click(460, 300); assert.equal(sel.length, 0)
      const after = h.locate('bea'); assert.ok(Math.abs(after.x - (before.x + 60)) < 2); assert.ok(Math.abs(after.y - before.y) < 1e-6); h.destroy()   // 60 CSS-Pixel nach rechts gezogen
    })
    
  • Step 2: Scheitern sehen — cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersScene.test.mjs. Expected: FAIL Could not resolve …/engine/scene.js.

  • Step 3: Implementieren engine/scene.js und tests/unit/helpers/engineVm.mjs Regeln: resize() liest canvas.getBoundingClientRect().width (unter 10 ⇒ überspringen), canvas.width = min(2400, round(cssW*dpr)), canvas.height = round(width*256/320); Raster je Bild: k = canvas.width / (320/zoom), ox = -cam.x*k, oy = -cam.y*k; Startzoom fitZoom(cssW). Reihenfolge je Bild: Weiß füllen → drawOutside → drawTower → drawRooms → lift.drawBack → drawAgents → lift.drawFront → fx.draw → drawPauseVeil. Schleife: raf-Rückruf, Bild höchstens alle FRAME_MS (dazwischen nur neu planen), dt = min(DT_MAX, Δt), director.update(dt) vor dem Zeichnen; stats() misst avgDrawMs mit now(). Eingabe: click ⇒ viewToWorld aus Klickposition ⇒ pickAgent(boxes, x, y, pad) mit pad = (coarsePointer ? 10 : 4) / (cssW / (320/zoom)) ⇒ onSelect(id|null); pointerdown/move/up verschieben die Kamera (panBy) nur bei zoom > 1, Bewegung über DRAG_PX unterdrückt den nächsten click; wheel zoomt nur mit ctrlKey (passive: false); style.touchAction = 'none' bei Zoom > 1, sonst 'pan-y'. select(id) setzt die Markierung und zentriert bei Zoom > 1. setState und pushEvents zeichnen sofort ein Bild (drawOnce), damit Klick und locate frische Trefferflächen haben. locate(id) ⇒ Mitte der Trefferfläche (Weltkoordinaten der letzten Zeichnung, durch die Kamera in CSS-Pixel umgerechnet) oder null außerhalb der Ansicht. reducedMotion: kein raf; setState/pushEvents/select/resize/zoomBy rufen director.flush() (nur bei pushEvents) und zeichnen ein Standbild. fontsReady.then(drawOnce, () => {}) mit Zerstörungswächter. destroy(): Flagge, caf, alle Listener entfernen, spätere Aufrufe ohne Wirkung. debug() ⇒ { pending, agents:[{ id, x, y, floor, pose, grey }], lift: lift.state, fx: fx.size() }.

  • Step 4: Bestehen sehen — gleicher Befehl. Expected: # pass 9, # fail 0.

  • Step 5: Commit

    cd $WK && git add src/components/workers/engine/scene.js tests/unit/helpers/engineVm.mjs tests/unit/workersScene.test.mjs
    git commit -m "Workers P2: mountTower (Größe, 30-fps-Schleife, Eingabe, Kamera, Lebenszyklus)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
    

P2-T15: Engine-Rauchtest (19 anklickbar, 150 s Ereignisfeed)

Files:

  • Test: tests/unit/workersSmoke.test.mjs

Interfaces:

  • Consumes: createEngineSandbox (P2-T14), makeState/ev/IDS (P2-T12), EVENT_KINDS (P2-T03). Muster: /root/reports/workers-2026-10-16/entwuerfe/smoke.js.

  • Produces: nichts (reine Absicherung).

  • Step 1: Test schreiben (tests/unit/workersSmoke.test.mjs) Gerüst: scripted(sec) baut die Ereignisse für Sekunde sec: alle 26 Arten aus EVENT_KINDS verteilt auf die Sekunden 3, 8, 13, … (eine Art alle 5 s, in der Reihenfolge der folgenden Liste, Arbeiter passend zur Art: gelesen kim, fund rita, doppelt arne, uebergabe rita→bea, bewertet bea, top3 bea, botlead arne, entwurf max, pult clara, gesendet max, eingang nora, sortiert nora, bau_start bob, bau_fertig bob, pruefung_zurueck paul→bob (meta.round 1), pruefung_ok paul, vorschau_link max, angebot theo, deal system, gesperrt system, budget_warnung otto, budget_stopp otto, pause system, fehler ben, auftrag otto→kim, leerlauf pia); zusätzlich ab Sekunde 100 ein Stoß von 120 uebergabe-Ereignissen auf einmal.

    test('alle 19 Worker sind anklickbar (Auswahl liefert die richtige workerId)', async () => {
      const sb = await createEngineSandbox(), picked = []; const h = sb.mountTower(sb.canvas, sb.opts({ onSelect: (id) => picked.push(id) })); h.setState(makeState())
      for (const id of IDS) { const p = h.locate(id); assert.ok(p, id); sb.click(p.x, p.y); assert.equal(picked.at(-1), id) }
      assert.deepEqual([...new Set(picked)].sort(), [...IDS].sort()); h.destroy()
    })
    test('150 s mit allen 26 Ereignisarten: kein Wurf, alle endlich und im Bild, danach ruhig', async () => {
      const sb = await createEngineSandbox(), h = sb.mountTower(sb.canvas, sb.opts({ onSelect() {} })); h.setState(makeState({ connected: { kim: false, pia: false, finn: false } }))
      for (let s = 0; s < 150; s++) { const evs = scripted(s); if (evs.length) h.pushEvents(evs); if (s % 15 === 0) h.setState(makeState({ connected: { kim: false, pia: false, finn: false }, kpis: { pultOpen: s % 7, dealsMonth: 3 + Math.floor(s / 40), botLeads: 112 + s } })); sb.tick(1)
        if (s % 2 === 0) for (let x = 10; x < 960; x += 40) for (let y = 10; y < 768; y += 40) sb.click(x, y) }
      for (let s = 0; s < 90; s++) sb.tick(1)
      const dbg = h.debug(); assert.equal(dbg.pending, 0); assert.equal(dbg.lift, 'idle'); assert.equal(dbg.fx, 0)
      for (const a of dbg.agents) assert.ok(Number.isFinite(a.x) && Number.isFinite(a.y) && a.x > -30 && a.x < 350 && a.floor >= 0 && a.floor <= 9, a.id)
      assert.ok(sb.counts.fillRect > 100000); assert.ok(sb.counts.fillText > 1000); assert.ok(sb.seenTexts.has('noch nicht angeschlossen'))
      h.destroy(); assert.equal(Object.values(sb.listeners).flat().length, 0)
    })
    
  • Step 2: Laufen lassen — cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersSmoke.test.mjs. Expected: PASS ohne weitere Implementierung, wenn P2-T06–T14 stimmen; ein Fehlschlag zeigt den Fehler in der Engine (dann dort beheben, nicht den Test lockern). Zeitlimit des Laufs 300 s; typisch < 60 s.

  • Step 3: Falls nötig Engine-Fehler beheben (kleinste Änderung am betroffenen Engine-Modul, zugehörigen Modultest ergänzen), erneut laufen lassen. Expected: # pass 2, # fail 0.

  • Step 4: Gesamte Engine-Gruppe gegenprüfen — cd $WK && timeout 600 node --max-old-space-size=1024 --test tests/unit/workers*.test.mjs. Expected: # fail 0.

  • Step 5: Commit

    cd $WK && git add tests/unit/workersSmoke.test.mjs
    git commit -m "Workers P2: Engine-Rauchtest (19 anklickbar, 150 s Ereignisfeed)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
    

P2-T16: Formularlogik der Einstellungen

Files:

  • Create: src/lib/workersSettingsForm.js
  • Test: tests/unit/workersSettingsForm.test.mjs

Interfaces:

  • Consumes: ROLES, ROOM_TITLES (workersRoster.js); workersSettings aus Übersicht §4.1.

  • Produces:

    export const ROOM_CHOICES      // die 8 Suchräume [{ id, title }] (Räume der Etagen anfragen und ohne_website, Rollenreihenfolge)
    export const READONLY_KEYS     // ['paused','previewsSent','updatedAt','updatedBy','offerProductKey','connectedRooms','privacyInteressentenLive','sendingHalted','sendingHaltedReason'] (Übersicht §7)
    export function settingsToForm(settings): form            // Zahlen als Text, regions als Zeilen, Räume als Id-Listen
    export function validateForm(form): { ok, errors: { [feld]: 'Text' } }
    export function formToPatch(form, original): object       // nur geänderte, getypte Felder; nie READONLY_KEYS
    

    Regeln, gleich oder enger als die Prüfung des Dienstes (P1-T07; Fehlertext in Klammern): dailyBudgetUsd Zahl > 0 (Tagesgrenze muss größer als 0 sein.) und ≤ monthlyBudgetUsd (Die Tagesgrenze darf nicht über der Monatsgrenze liegen.); monthlyBudgetUsd > 0 (Monatsgrenze muss größer als 0 sein.) und ≤ 150 (Monatsgrenze höchstens 150 $ (Vorgabe: max. 150 € im Monat).); warnPct ganz 1..100 (Warnschwelle: ganze Zahl von 1 bis 100.); mailColdPerDay ganz 0..3 (Höchstens 3 unverlangte Mails pro Tag.); mailReplyCapPerDay ganz 0..15 (Höchstens 15 Antwort-Mails pro Tag.); sendWindowStart/sendWindowEnd HH:MM, Beginn vor Ende (Uhrzeit als HH:MM, Beginn vor Ende.); runsPerNight ganz 1..2 (Läufe pro Nacht: 1 oder 2.); candidatesPerRun ganz 1..20 (Kandidaten pro Lauf: 1 bis 20.); regions 1..30 Zeilen, je 1..64 Zeichen (Mindestens eine Region, höchstens 30, je Zeile höchstens 64 Zeichen.); previewGate ∈ erste_n|immer|nie; previewGateN ganz 0..1000; offerText 1..400 Zeichen (Angebotstext: 1 bis 400 Zeichen.; 400 hält eine Kaltmail unter 180 Wörtern, H3/A11); pultStaleDays ganz 1..30; followupDays/previewReminderDays ganz 1..60; retentionDays ganz 7..90 (Aufbewahrung: 7 bis 90 Tage.; der Datenschutztext verspricht höchstens 90 Tage, A11); previewDeleteDays ganz 7..365; reportEmail einfache Adresse. Dezimalkomma wird akzeptiert (12,5). Schreibgeschützt (nie im Patch, nie im Formular bearbeitbar): READONLY_KEYS; connectedRooms setzt allein der Dienst (P3 pflegt die Anfragen-Räume, Kenso die Branchen-Räume per Skript, P2-E5), privacyInteressentenLive der Datenschutz-Schalter (A5), sendingHalted und sendingHaltedReason die Notbremse (H4; aufgehoben wird sie nur über den Knopf des Dialogs, P2-T18). offerProductKey ist ebenfalls nur Anzeige (der Dienst erlaubt nur hosting).

  • Step 1: Failing test schreiben (const F = await loadModule('src/lib/workersSettingsForm.js'))

    const S0 = { paused: true, dailyBudgetUsd: 6, monthlyBudgetUsd: 150, warnPct: 80, mailColdPerDay: 3, mailReplyCapPerDay: 15, sendWindowStart: '09:00', sendWindowEnd: '16:00', sendWeekdaysOnly: true, runsPerNight: 1, candidatesPerRun: 10,
      activeRooms: ['netz', 'kleinanzeigen', 'portale', 'foren', 'aerzte', 'handwerk', 'selbst', 'laden'], connectedRooms: [], regions: ['Rheinland-Pfalz'], previewGate: 'erste_n', previewGateN: 10, previewsSent: 4, offerText: 'Gratis-Vorschau …', offerProductKey: 'hosting',
      pultStaleDays: 3, retentionDays: 90, followupDays: 7, previewReminderDays: 5, previewDeleteDays: 60, reportEmail: 'kenso.gri@gmail.com',
      privacyInteressentenLive: false, sendingHalted: false, sendingHaltedReason: '', updatedAt: '2026-10-17T10:00:00Z', updatedBy: 'x' }
    test('unverändertes Formular ⇒ leerer Patch; schreibgeschützte Felder (auch H4/A5) nie im Patch', () => {
      const f = F.settingsToForm(S0); assert.deepEqual(F.formToPatch(f, S0), {}); assert.equal(F.validateForm(f).ok, true)
      assert.deepEqual(F.formToPatch({ ...f, paused: false, previewsSent: 99, updatedBy: 'y', offerProductKey: 'x', connectedRooms: ['netz'] }, S0), {})
      assert.deepEqual(F.formToPatch({ ...f, privacyInteressentenLive: true, sendingHalted: true, sendingHaltedReason: 'x' }, S0), {})      // die drei neuen Felder: Änderungen ergeben {}
      assert.deepEqual([...F.READONLY_KEYS], ['paused', 'previewsSent', 'updatedAt', 'updatedBy', 'offerProductKey', 'connectedRooms', 'privacyInteressentenLive', 'sendingHalted', 'sendingHaltedReason'])
      const halted = { ...S0, sendingHalted: true, sendingHaltedReason: 'Zwei harte Bounces in den letzten 10 Mails.' }       // Notbremse gesetzt: Formular bleibt gültig, Patch leer
      assert.deepEqual(F.formToPatch(F.settingsToForm(halted), halted), {}); assert.equal(F.validateForm(F.settingsToForm(halted)).ok, true)
      assert.deepEqual(F.formToPatch({ ...f, warnPct: '70', sendingHalted: true }, S0), { warnPct: 70 })                          // ein echtes Feld nimmt die schreibgeschützten nicht mit
    })
    test('Patch enthält nur Geändertes, getypt; Dezimalkomma, Zeilen, Räume', () => {
      const f = { ...F.settingsToForm(S0), dailyBudgetUsd: '12,5', regions: 'Rheinland-Pfalz\n\n Hessen ', activeRooms: S0.activeRooms.filter((r) => r !== 'kleinanzeigen'), previewGate: 'immer' }
      assert.deepEqual(F.formToPatch(f, S0), { dailyBudgetUsd: 12.5, regions: ['Rheinland-Pfalz', 'Hessen'], activeRooms: ['netz', 'portale', 'foren', 'aerzte', 'handwerk', 'selbst', 'laden'], previewGate: 'immer' })
    })
    test('Prüfung mit den Spec-Grenzen', () => {
      const bad = (over) => F.validateForm({ ...F.settingsToForm(S0), ...over }).errors
      assert.equal(bad({ mailColdPerDay: '4' }).mailColdPerDay, 'Höchstens 3 unverlangte Mails pro Tag.'); assert.equal(bad({ mailReplyCapPerDay: '16' }).mailReplyCapPerDay, 'Höchstens 15 Antwort-Mails pro Tag.')
      assert.equal(bad({ dailyBudgetUsd: '151' }).dailyBudgetUsd, 'Die Tagesgrenze darf nicht über der Monatsgrenze liegen.'); assert.equal(bad({ monthlyBudgetUsd: '151' }).monthlyBudgetUsd, 'Monatsgrenze höchstens 150 $ (Vorgabe: max. 150 € im Monat).'); assert.equal(bad({ dailyBudgetUsd: '150' }).dailyBudgetUsd, undefined)   // Monatsgrenze 150 aus S0
      assert.equal(bad({ dailyBudgetUsd: '0' }).dailyBudgetUsd, 'Tagesgrenze muss größer als 0 sein.')
      assert.equal(bad({ sendWindowStart: '16:00', sendWindowEnd: '09:00' }).sendWindowStart, 'Uhrzeit als HH:MM, Beginn vor Ende.'); assert.equal(bad({ regions: '  \n' }).regions, 'Mindestens eine Region, höchstens 30, je Zeile höchstens 64 Zeichen.')
      assert.equal(bad({ offerText: '' }).offerText, 'Angebotstext: 1 bis 400 Zeichen.'); assert.equal(bad({ offerText: 'x'.repeat(401) }).offerText, 'Angebotstext: 1 bis 400 Zeichen.'); assert.equal(bad({ offerText: 'x'.repeat(400) }).offerText, undefined)
      assert.equal(bad({ retentionDays: '5' }).retentionDays, 'Aufbewahrung: 7 bis 90 Tage.'); assert.equal(bad({ retentionDays: '6' }).retentionDays, 'Aufbewahrung: 7 bis 90 Tage.'); assert.equal(bad({ retentionDays: '91' }).retentionDays, 'Aufbewahrung: 7 bis 90 Tage.')
      assert.equal(bad({ retentionDays: '7' }).retentionDays, undefined); assert.equal(bad({ retentionDays: '90' }).retentionDays, undefined); assert.equal(bad({ warnPct: 'x' }).warnPct, 'Warnschwelle: ganze Zahl von 1 bis 100.')
      assert.equal(bad({ runsPerNight: '3' }).runsPerNight, 'Läufe pro Nacht: 1 oder 2.'); assert.equal(bad({ candidatesPerRun: '21' }).candidatesPerRun, 'Kandidaten pro Lauf: 1 bis 20.')
      assert.equal(F.validateForm(F.settingsToForm(S0)).ok, true); assert.deepEqual(F.ROOM_CHOICES.map((r) => r.id), S0.activeRooms)
    })
    
  • Step 2: Scheitern sehen — cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersSettingsForm.test.mjs. Expected: FAIL Could not resolve …/workersSettingsForm.js.

  • Step 3: Implementieren src/lib/workersSettingsForm.js nach den Regeln oben; Feldregeln als Datentabelle RULES (Typ, Grenzen, Fehlertext), formToPatch vergleicht typisiert mit original (Listen für Räume reihenfolgeunabhängig, in ROOM_CHOICES-Reihenfolge ausgegeben; regions reihenfolgetreu) und lässt READONLY_KEYS immer weg.

  • Step 4: Bestehen sehen — gleicher Befehl. Expected: # pass 3, # fail 0.

  • Step 5: Commit

    cd $WK && git add src/lib/workersSettingsForm.js tests/unit/workersSettingsForm.test.mjs
    git commit -m "Workers P2: Formularlogik der Einstellungen (Prüfung, Patch nur Geändertes)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
    

P2-T17: Kennzahlen, Worker-Karte, Worker-Liste

Files:

  • Create: src/components/workers/WorkersKpis.jsx, src/components/workers/WorkerCard.jsx, src/components/workers/WorkerList.jsx
  • Test: tests/unit/workersComponents.test.mjs

Interfaces:

  • Consumes: Stat (../ui/Stat), Badge (../ui/Badge), kpiTiles, counterLines, tokenTotal, traceRows, workerStatusLabel, statusTone, formatUsd, formatCount (workersView.js), ROLES, FLOOR_TITLES, MODEL_IDS, roleOf, hatForWorker (workersRoster.js), drawWorker, setGrid (engine/sprites.js), react-router Link.

  • Produces:

    export default function WorkersKpis({ kpis })                                  // <div className="stats wk-kpis"> + Stat je Kachel
    export default function WorkerCard({ workerId, worker, trace, loading, error, onClose })
    //   worker = state.workers-Eintrag (status, task, counters); trace = Antwort von api.trace
    //   Aufbau: wk-card · Porträt (Canvas, drawWorker) · Name (wk-name) · Rolle · Modell-Chip wk-chip wk-chip-<hat> mit MODEL_IDS-Text · Status-Badge
    //   · „Macht gerade“ · Zähler heute · „Tokens heute“ · „Kosten heute“ · „Letzte Aktionen“ (wk-trace, bis 50 Zeilen, Zeit + Text + Link)
    export default function WorkerList({ workers, selectedId, onSelect })            // Liste aller Worker je Etage: <button data-wk-worker={id} aria-pressed aria-label="Name, Status">
    

    Links aus der Spur nur über safeLink: extern <a href target="_blank" rel="noopener noreferrer">, intern <Link to>, sonst Text.

  • Step 1: Failing Quelltext-Test schreiben (Muster adminRoute.test.mjs; read(p) = readFileSync relativ zu src/components/workers/)

    test('WorkerCard: Links nur über safeLink, extern mit rel noopener; deutsche Beschriftungen (Review Focus 5)', () => {
      const src = read('WorkerCard.jsx')
      assert.match(src, /safeLink\(/); assert.match(src, /rel="noopener noreferrer"/); assert.match(src, /target="_blank"/)
      for (const t of ['Macht gerade', 'Tokens heute', 'Kosten heute', 'Letzte Aktionen']) assert.ok(src.includes(t), t)
      assert.match(src, /MODEL_IDS/); assert.match(src, /wk-chip-/)
    })
    test('kein innerHTML in Komponenten und Engine', () => {
      for (const dir of ['', 'engine/']) for (const f of readdirSync(new URL('../../src/components/workers/' + dir, import.meta.url))) { if (!/\.(jsx|js)$/.test(f)) continue; const s = read(dir + f); assert.doesNotMatch(s, /dangerouslySetInnerHTML|innerHTML/, dir + f) }
    })
    test('WorkersKpis nutzt Stat und kpiTiles; WorkerList: Knopf je Worker mit data-wk-worker, aria-pressed und Statustext', () => {
      assert.match(read('WorkersKpis.jsx'), /kpiTiles\(/); assert.match(read('WorkersKpis.jsx'), /<Stat /)
      const l = read('WorkerList.jsx'); assert.match(l, /data-wk-worker=/); assert.match(l, /aria-pressed=/); assert.match(l, /aria-label=/); assert.match(l, /FLOOR_TITLES/)
    })
    
  • Step 2: Scheitern sehen — cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersComponents.test.mjs. Expected: FAIL ENOENT … WorkerCard.jsx.

  • Step 3: Implementieren die drei Komponenten Klassen (alle in workers.css, P2-T20): wk-kpis, wk-card, wk-portrait, wk-name, wk-role, wk-chip mit wk-chip-opus|sonnet|haiku|overseer, wk-facts, wk-counters, wk-trace, wk-trace-time, wk-list, wk-list-floor, wk-dot mit wk-dot-<status>, wk-hint; Klassen mit Suffix stehen als Wörterbuch im Code (CHIP = { opus: 'wk-chip-opus', … }, DOT = { arbeitet: 'wk-dot-arbeitet', … }), damit der CSS-Test in P2-T20 sie findet; sonst nur globale Klassen (btn, badge, msg, stats). Karte: ohne workerId ⇒ Hinweis „Klicke eine Figur oder wähle einen Worker aus der Liste.“; loading ⇒ „Spur wird geladen …“; error ⇒ <div className="msg err" role="alert">; Modell-Chip-Text = trace.worker.modelId, sonst MODEL_IDS[role.model]; Kosten = formatUsd(trace.today.costUsd); Porträt: <canvas width=72 height=96>, useEffect ⇒ setGrid(ctx, { k: 4 }), drawWorker(ctx, 6, 8, { hat: hatForWorker(id), grey: status === 'grau', scale: 1 }). Liste: Reihenfolge = FLOORS mit FLOOR_TITLES-Zwischenüberschrift, Statuspunkt aus statusTone.

  • Step 4: Bestehen sehen — gleicher Befehl. Expected: # pass 3, # fail 0.

  • Step 5: Commit

    cd $WK && git add src/components/workers/WorkersKpis.jsx src/components/workers/WorkerCard.jsx src/components/workers/WorkerList.jsx tests/unit/workersComponents.test.mjs
    git commit -m "Workers P2: Kennzahlen, Worker-Karte mit Spur und Worker-Liste" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
    

P2-T18: Einstellungs-Dialog (nur Admin)

Files:

  • Create: src/components/workers/WorkersSettingsDialog.jsx
  • Test: tests/unit/workersComponents.test.mjs (erweitern)

Interfaces:

  • Consumes: Dialog (../ui/Dialog), Field (../ui/Field), Button (../ui/Button), settingsToForm, validateForm, formToPatch, ROOM_CHOICES (P2-T16), api.settings(), api.saveSettings(patch).

  • Produces: export default function WorkersSettingsDialog({ api, onClose, onSaved }) — Dialogtitel „Hustler-Einstellungen“ (Übersicht §7, Namensregel); lädt die Einstellungen beim Öffnen („Wird geladen …“), zeigt Gruppen Kosten (Tagesgrenze (USD), Monatsgrenze (USD), Warnschwelle (%)), Mails (Unverlangte Mails pro Tag, Antwort-Mails pro Tag, Sendefenster von/bis, nur werktags), Läufe (Läufe pro Nacht, Kandidaten pro Lauf), Räume (je Suchraum ein Haken „aktiv“; darunter als reiner Text „Angeschlossen (meldet der Dienst): …“ aus settings.connectedRooms, nicht änderbar), Regionen (Textfeld, eine je Zeile), Vorschau (Freigabe: erste N / immer / nie, N, bisher verschickt als Text), Angebot (Angebotstext, Produkt als Text ohne Eingabe), Fristen (Pult veraltet nach, Aufbewahrung, Nachfassen nach, Erinnerung nach, Vorschau löschen nach, Bericht an). „Speichern“ ist gesperrt, solange der Patch leer ist oder Fehler bestehen; Fehler stehen am Feld (aria-invalid, Text darunter), Serverfehler als <div className="msg err" role="alert">; nach Erfolg onSaved(neueEinstellungen) und onClose(). Der Dialog kennt paused nicht. Block „Versand gestoppt“ (H4, A21): Ist settings.sendingHalted wahr, steht oben im Dialog (über den Gruppen) ein Block mit der Überschrift „Versand gestoppt“, dem Grund aus settings.sendingHaltedReason als Textknoten (React-Text, nie HTML; ist er leer: „Grund nicht angegeben“) und dem Knopf „Versand wieder freigeben“. Der Knopf ruft api.saveSettings({ sendingHalted: false }) (genau dieses eine Feld; der Dienst leert dabei den Grund) und danach onSaved(neueEinstellungen); der Dialog bleibt offen, mischt die Antwort in sein settings ein ({ ...settings, ...antwort }; der Block verschwindet, weil sendingHalted nun falsch ist) und behält die Eingaben im Formular. Während des Aufrufs ist der Knopf gesperrt; ein Fehler steht als <div className="msg err" role="alert"> im Block. Der Block liegt außerhalb von formToPatch/validateForm: „Speichern“ sendet ihn nie, und das Freigeben ändert den Patch-Zustand des Formulars nicht.

  • Step 1: Failing Test ergänzen

    test('Einstellungs-Dialog: Dialog-Baustein, Formularlogik, kein paused, Admin-Beschriftungen, Notbremsen-Block', () => {
      const s = read('WorkersSettingsDialog.jsx')
      assert.match(s, /from '\.\.\/ui\/Dialog'/); assert.match(s, /formToPatch\(/); assert.match(s, /validateForm\(/); assert.match(s, /api\.saveSettings\(/)
      assert.doesNotMatch(s, /paused/)
      for (const t of ['Hustler-Einstellungen', 'Tagesgrenze (USD)', 'Monatsgrenze (USD)', 'Unverlangte Mails pro Tag', 'Angebotstext', 'Regionen', 'Speichern', 'Abbrechen']) assert.ok(s.includes(t), t)
      assert.match(s, /disabled=\{/)
      // H4/A21: Block „Versand gestoppt“ mit Freigabe, außerhalb des Formular-Patches
      assert.match(s, /sendingHalted/); assert.ok(s.includes('Versand gestoppt')); assert.ok(s.includes('Versand wieder freigeben'))
      assert.match(s, /api\.saveSettings\(\{ sendingHalted: false \}\)/); assert.match(s, /sendingHaltedReason/)
      assert.doesNotMatch(s, /formToPatch\([^)]*sendingHalted/)
    })
    
  • Step 2: Scheitern sehen — cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersComponents.test.mjs. Expected: FAIL ENOENT … WorkersSettingsDialog.jsx.

  • Step 3: Implementieren WorkersSettingsDialog.jsx Dialog wide mit as="form"; Formularzustand useState(settingsToForm(settings)); errors = validateForm(form).errors; patch = formToPatch(form, settings); busy während des Speicherns; Hinweis unter „Unverlangte Mails pro Tag“: „Höchstens 3 (Spec §8.1).“; Räume-Tabelle mit ROOM_CHOICES und .check-Haken. Der Notbremsen-Block (siehe Produces) hat eigenen Zustand (releasing, releaseError), steht nur bei settings.sendingHalted im Dialog und schreibt api.saveSettings({ sendingHalted: false }) exakt so (der Quelltext-Test sucht diese Zeichenfolge); in der Quelldatei kommt das Wort paused nirgends vor, auch nicht in Kommentaren.

  • Step 4: Bestehen sehen — gleicher Befehl. Expected: # pass 4, # fail 0.

  • Step 5: Commit

    cd $WK && git add src/components/workers/WorkersSettingsDialog.jsx tests/unit/workersComponents.test.mjs
    git commit -m "Workers P2: Einstellungs-Dialog für Admins" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
    

P2-T19: Bühne und Board (WorkersStage, WorkersBoard)

Files:

  • Create: src/components/workers/WorkersStage.jsx, src/components/workers/WorkersBoard.jsx
  • Test: tests/unit/workersComponents.test.mjs (erweitern)

Interfaces:

  • Consumes: mountTower (P2-T14), useWorkersFeed, useWorkerTrace (P2-T05), reduceEvents, indexWorkers (P2-T03), WorkersKpis, WorkerCard, WorkerList, WorkersSettingsDialog, pauseLabel, EmptyState, Button.

  • Produces:

    // WorkersStage: forwardRef; Handle { pushEvents(events), locate(id), stats(), debug() }
    export default forwardRef(function WorkersStage({ state, selectedId, onSelect }, ref) {...})
    // WorkersBoard: Inhalt des Reiters „Hochhaus“, ohne Auth-Bezug
    export default function WorkersBoard({ api, isAdmin, stageRef })   // stageRef optional (nur der Prüfstand reicht eine durch)
    

    Stage: <div className="wk-stage"><canvas className="wk-canvas" role="img" aria-label="Pixel-Hochhaus: Etagen und Räume der Hustler. Auswahl auch über die Liste."/> plus wk-zoom mit „+“, „−“, „Ganz“. Mount-Effekt (Abhängigkeit: reduceMotion): mountTower(canvas, { onSelect: (id) => onSelectRef.current(id), reducedMotion, coarsePointer: matchMedia('(pointer: coarse)').matches, fontsReady: document.fonts?.load('600 12px "Pixelify Sans"'), dpr: devicePixelRatio }), ResizeObserver ⇒ resize(), visibilitychange ⇒ setPaused(document.hidden), matchMedia('(prefers-reduced-motion: reduce)')-change ⇒ Zustand reduceMotion (Neu-Mount), Cleanup entfernt alles und ruft destroy(). Zweiter Effekt [state, engineKey] ⇒ handle.setState(state), dritter [selectedId] ⇒ handle.select(selectedId ?? null). Board: Ohne übergebenes stageRef (die echte Seite übergibt keins) legt das Board selbst eins an: const ownRef = useRef(null); const ref = stageRef ?? ownRef; <WorkersStage ref={ref} …> und alle Zugriffe laufen über ref (sonst stürzt die Seite beim ersten Ereignis ab, der Prüfstand würde es nicht zeigen, weil er ein stageRef übergibt). useWorkersFeed(api, { onEvents }); onEvents ⇒ setWorkers(w => reduceEvents(w, evs)) und ref.current?.pushEvents(evs); bei neuem state setWorkers(indexWorkers(state)); useWorkerTrace(api, selectedId). Zustände: nie geladen + Fehler ⇒ EmptyState title="Der Hustler-Dienst ist noch nicht erreichbar" mit Fehlertext und „Neu laden“ (Review Focus 1); nie geladen ohne Fehler ⇒ <div className="loading">Wird geladen …</div>; geladen mit Fehler ⇒ Bühne weiter sichtbar plus <div className="msg warn" role="status">Verbindung gestört: … Es wird weiter versucht.</div>. Kopfzeile wk-toolbar: Statustext („Läuft“ / „Angehalten: Es wird nichts gesendet und nichts gebaut.“), Pausenknopf pauseLabel(state.paused) für alle Mitarbeiter (sperrt während des Aufrufs; Fehler als msg err; danach refresh()), isAdmin ⇒ „Einstellungen“ (öffnet den Dialog). Notbremse (H4, A21): Ist state.sendingHalted wahr, steht zwischen Kopfzeile und wk-main (für alle Mitarbeiter, nicht nur Admins) eine Zeile <div className="msg err" role="alert"> mit dem Text „Versand gestoppt: . Ein Admin kann ihn in den Einstellungen freigeben.“; <Grund> = state.sendingHaltedReason als Textknoten, getrimmt und ohne abschließende Punkte (damit kein „..“ entsteht), leer ⇒ „Grund nicht angegeben“. Sie erscheint zusätzlich zu msg warn (Verbindung gestört), ersetzt es nicht, und verschwindet mit dem nächsten state, in dem sendingHalted falsch ist; WorkersSettingsDialog bekommt onSaved={() => refresh()}, damit die Zeile nach dem Freigeben sofort verschwindet. Layout: wk-main mit Bühne links und Seitenspalte (WorkerCard, darunter WorkerList) rechts.

  • Step 1: Failing Quelltext-Test ergänzen

    test('WorkersStage: Aufräumen, Sichtbarkeit, Bewegungsreduktion', () => {
      const s = read('WorkersStage.jsx')
      assert.match(s, /\.destroy\(\)/); assert.match(s, /visibilitychange/); assert.match(s, /removeEventListener\('visibilitychange'/); assert.match(s, /prefers-reduced-motion: reduce/)
      assert.match(s, /ResizeObserver/); assert.match(s, /disconnect\(\)/); assert.match(s, /fontsReady/); assert.match(s, /forwardRef/)
    })
    test('WorkersBoard: Zustände und Rechte (Review Focus 1)', () => {
      const s = read('WorkersBoard.jsx')
      assert.match(s, /Der Hustler-Dienst ist noch nicht erreichbar/); assert.match(s, /Verbindung gestört/); assert.match(s, /reduceEvents\(/); assert.match(s, /pauseLabel\(/)
      assert.match(s, /isAdmin &&/); assert.match(s, /stageRef\s*(\?\?|\|\|)/); assert.match(s, /useRef\(/); assert.doesNotMatch(s, /useAuth|appwrite/)
      // H4/A21: Notbremsen-Zeile für alle, Grund als Textknoten
      assert.match(s, /state\.sendingHalted/); assert.match(s, /state\.sendingHaltedReason/); assert.match(s, /className="msg err"/); assert.match(s, /role="alert"/)
      assert.ok(s.includes('Versand gestoppt: ')); assert.ok(s.includes('Ein Admin kann ihn in den Einstellungen freigeben.')); assert.ok(s.includes('Grund nicht angegeben'))
      assert.match(s, /onSaved=\{/); assert.doesNotMatch(s, /dangerouslySetInnerHTML|innerHTML/)
    })
    
  • Step 2: Scheitern sehen — cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersComponents.test.mjs. Expected: FAIL ENOENT … WorkersStage.jsx.

  • Step 3: Implementieren beide Komponenten nach den Angaben oben (Hooks-Regeln: Callback-Refs für onSelect/onEvents, damit der Mount-Effekt stabil bleibt). Die Texte der Notbremsen-Zeile stehen als zusammenhängende Literale in jeweils einer Zeile (Versand gestoppt: und Ein Admin kann ihn in den Einstellungen freigeben. getrennt von {Grund} im JSX), weil der Quelltext-Test sie wörtlich sucht.

  • Step 4: Bestehen sehen — gleicher Befehl. Expected: # pass 6, # fail 0.

  • Step 5: Commit

    cd $WK && git add src/components/workers/WorkersStage.jsx src/components/workers/WorkersBoard.jsx tests/unit/workersComponents.test.mjs
    git commit -m "Workers P2: Bühne (StrictMode-fest) und Board mit Pausenschalter" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
    

P2-T20: Seite, Route, Menü, Stil, Schrift

Files:

  • Create: src/pages/WorkersPage.jsx, src/styles/workers.css, public/fonts/PixelifySans-Variable.woff2, public/fonts/OFL-PixelifySans.txt
  • Modify: src/App.jsx, src/lib/navLinks.js, tests/unit/navLinks.test.mjs
  • Test: tests/unit/workersRoute.test.mjs, tests/unit/workersCss.test.mjs, tests/unit/workersFont.test.mjs, tests/unit/navLinks.test.mjs

Interfaces:

  • Consumes: WorkersBoard (P2-T19), workersApi (P2-T04), PageHeader/Tabs (../components/ui), pickTab (P2-T02), useAuth().isAdmin.

  • Produces: export default function WorkersPage() (Titel „Hustler“ als H1 und als Browser-Tab-Titel (document.title = 'Hustler' in einem Effekt, beim Verlassen der Seite wird der vorige Titel zurückgesetzt), Untertitel „Die Nachtschicht deiner Claude-Armee“; const api = workersApi; WORKERS_TABS = [{ id: 'hochhaus', label: 'Hochhaus' }] (P3 hängt freigabe an und passt workersRoute.test.mjs an, P3-E11; P6 hängt botleads an; kein Platzhalter-Reiter, P2-E7), Auswahl über ?tab= mit pickTab, Tabs nur bei WORKERS_TABS.length > 1; Reiter hochhaus rendert <WorkersBoard api={api} isAdmin={isAdmin} /> ohne stageRef); Route /hustler (lazy); /leads und /workers leiten über die kleine Komponente HustlerRedirect (<Navigate to={/hustler${search}} replace />, search aus useLocation()) auf /hustler um und behalten die Query; Menüeintrag { key: 'hustler', label: 'Hustler', href: '/hustler' } an der Stelle von leads, ohne paused.

  • Step 1: Failing Tests schreiben tests/unit/navLinks.test.mjs anpassen: in beiden deepEqual-Listen 'leads' durch 'hustler' ersetzen (an derselben Stelle; die Schlüssel leads und workers kommen nicht mehr vor) und die Zeile assert.equal(links.find((l) => l.key === 'leads').paused, true) durch

    assert.deepEqual(links.find((l) => l.key === 'hustler'), { key: 'hustler', label: 'Hustler', href: '/hustler' })
    assert.ok(!links.some((l) => l.key === 'leads' || l.key === 'workers'))
    

    tests/unit/workersRoute.test.mjs (Quelltext von src/App.jsx):

    test('App: /hustler lazy hinter ProtectedRoute, /leads und /workers leiten mit erhaltener Query auf /hustler um, kein statischer Import', () => {
      const s = readFileSync(new URL('../../src/App.jsx', import.meta.url), 'utf8')
      assert.match(s, /const WorkersPage = lazy\(\(\) => import\('\.\/pages\/WorkersPage'\)\)/); assert.doesNotMatch(s, /import WorkersPage from/)
      assert.match(s, /<Route path="\/hustler" element=\{<ProtectedRoute><Suspense[\s\S]*?<WorkersPage \/><\/Suspense><\/ProtectedRoute>\} \/>/)
      assert.match(s, /<Route path="\/leads" element=\{<HustlerRedirect \/>\} \/>/); assert.match(s, /<Route path="\/workers" element=\{<HustlerRedirect \/>\} \/>/)
      assert.match(s, /function HustlerRedirect\(\)[\s\S]*?useLocation\(\)[\s\S]*?<Navigate to=\{`\/hustler\$\{search\}`\} replace \/>/)   // /workers?tab=botleads&bot=x → /hustler?tab=botleads&bot=x
      assert.ok(s.indexOf('path="/hustler"') < s.indexOf('path="*"')); assert.ok(s.indexOf('path="/workers"') < s.indexOf('path="*"'))
    })
    test('WorkersPage: Titel Hustler (H1 und Tab-Titel), Reiter hochhaus (kein Platzhalter), Reiterleiste erst ab zwei Reitern, api-Instanz, Admin-Weitergabe', () => {
      const s = readFileSync(new URL('../../src/pages/WorkersPage.jsx', import.meta.url), 'utf8')
      assert.match(s, /title="Hustler"/); assert.match(s, /document\.title = 'Hustler'/)
      assert.match(s, /id: 'hochhaus'/); assert.doesNotMatch(s, /id: 'freigabe'/); assert.match(s, /WORKERS_TABS\.length > 1/)
      assert.match(s, /const api = workersApi/); assert.match(s, /pickTab\(/); assert.match(s, /isAdmin=\{isAdmin\}/)
    })
    

    tests/unit/workersCss.test.mjs:

    test('workers.css: nur wk-Klassen, @font-face, keine Hex-Farben, reduzierte Bewegung', () => {
      const css = readFileSync(new URL('../../src/styles/workers.css', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')   // Kommentare vorher entfernen
      for (const m of css.matchAll(/([^{}]+)\{/g)) { const pre = m[1].trim(); if (pre.startsWith('@')) continue; for (const sel of pre.split(',')) assert.ok(sel.trim().startsWith('.wk-'), sel) }   // jede Regel beginnt mit einer wk-Klasse (Nachfahren dürfen globale Klassen nutzen)
      assert.match(css, /@font-face\s*\{[^}]*font-family:\s*"Pixelify Sans"[^}]*PixelifySans-Variable\.woff2[^}]*font-display:\s*swap/s); assert.doesNotMatch(css, /#[0-9a-fA-F]{3,8}\b/)
      assert.match(css, /prefers-reduced-motion/); assert.match(css, /@media \(max-width: 1100px\)/); assert.match(css, /@media \(max-width: 860px\)/)
    })
    test('jede im JSX verwendete wk-Klasse ist definiert', () => {
      const css = readFileSync(new URL('../../src/styles/workers.css', import.meta.url), 'utf8'); const dir = new URL('../../src/components/workers/', import.meta.url)
      for (const f of readdirSync(dir).filter((x) => x.endsWith('.jsx'))) for (const m of readFileSync(new URL(f, dir), 'utf8').matchAll(/(?<![\w-])wk-[a-z0-9-]+/g)) assert.ok(css.includes('.' + m[0]), `${f}: ${m[0]}`)   // Lookbehind: das Attribut data-wk-worker ist keine Klasse
    })
    

    (Klassen mit dynamischem Suffix wie wk-chip-${hat} stehen in den Komponenten als Wörterbuch { opus: 'wk-chip-opus', … }, damit der Test sie findet.) tests/unit/workersFont.test.mjs:

    test('Schrift: woff2-Magic-Bytes, plausible Größe, OFL-Lizenztext', () => {
      const buf = readFileSync(new URL('../../public/fonts/PixelifySans-Variable.woff2', import.meta.url)); assert.equal(buf.subarray(0, 4).toString('latin1'), 'wOF2'); assert.ok(buf.length > 10000 && buf.length < 400000, String(buf.length))
      const lic = readFileSync(new URL('../../public/fonts/OFL-PixelifySans.txt', import.meta.url), 'utf8'); assert.match(lic, /SIL OPEN FONT LICENSE Version 1\.1/); assert.match(lic, /Pixelify Sans/)
    })
    
  • Step 2: Scheitern sehen — cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/navLinks.test.mjs tests/unit/workersRoute.test.mjs tests/unit/workersCss.test.mjs tests/unit/workersFont.test.mjs. Expected: FAIL (navLinks: leads noch vorhanden; übrige ENOENT).

  • Step 3: Implementieren (a) Schrift besorgen (einziger Netzschritt des Plans): neuen Ordner D=$(mktemp -d "$TMPDIR/wk-font.XXXXXX"); curl -sfL --max-time 30 -A 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120 Safari/537.36' 'https://fonts.googleapis.com/css2?family=Pixelify+Sans:wght@400..700&display=swap' -o "$D/pixelify.css"; die Adresse aus dem /* latin */-Block lesen mit rg -A6 '^/\* latin \*/$' "$D/pixelify.css" | rg -o 'https://fonts\.gstatic\.com/[^)]+\.woff2' (genau ein Treffer) und mit curl -sfL --max-time 60 -o "$D/PixelifySans-Variable.woff2" '<url>' laden; den Lizenztext (Open Font License 1.1 mit Copyright-Zeile „Copyright 2021 The Pixelify Sans Project Authors“) mit curl -sfL --max-time 30 -o "$D/OFL-PixelifySans.txt" 'https://raw.githubusercontent.com/google/fonts/main/ofl/pixelifysans/OFL.txt'; prüfen: head -c 4 "$D/PixelifySans-Variable.woff2" ist wOF2, wc -c zwischen 10 000 und 400 000, die Lizenz enthält „SIL OPEN FONT LICENSE Version 1.1“; erst dann mkdir -p "$WK/public/fonts" (der Ordner existiert noch nicht) und beide Dateien dorthin kopieren. Scheitert der Download (kein Netz), meldet der Implementierer BLOCKED mit dem Befehl, statt eine andere Quelle zu raten. (b) workers.css: @font-face { font-family: "Pixelify Sans"; src: url("/fonts/PixelifySans-Variable.woff2") format("woff2"); font-weight: 400 700; font-style: normal; font-display: swap; }, Klassen der Komponenten (weiß, Haarlinien, tabular-nums, .wk-main { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 32px } mit border-left: 1px solid var(--line) an .wk-side, .wk-canvas { display: block; width: 100%; aspect-ratio: 5 / 4; image-rendering: pixelated; cursor: pointer; touch-action: pan-y }, .wk-name { font-family: "Pixelify Sans", var(--font) }, Chips mit Tokenpaaren --orange, --blue, --green, --violet), unter 1100 px einspaltig (Seitenspalte unten, Haarlinie oben), unter 860 px Zoomknöpfe größer (min. 36 px), @media (prefers-reduced-motion: reduce) ohne Übergänge; keine @keyframes, keine Kommentare mit geschweiften Klammern. (c) WorkersPage.jsx (Muster LegalPage.jsx, useSearchParams für ?tab= und setSearchParams beim Reiterwechsel, page wk-page, <Tabs> nur bei WORKERS_TABS.length > 1), App.jsx: import { lazy, Suspense, … } from 'react', useLocation aus react-router-dom ergänzen, const WorkersPage = lazy(() => import('./pages/WorkersPage')), Route /hustler wie im Test (Fallback <div className="loading">Wird geladen …</div>), function HustlerRedirect() { const { search } = useLocation(); return <Navigate to={/hustler${search}} replace /> } und die Routen /leads und /workers ⇒ <HustlerRedirect /> (beide vor path="*"; die Route /leads hat bisher auf /tickets verwiesen); navLinks.js: Zeile leads ersetzen durch { key: 'hustler', label: 'Hustler', href: '/hustler' } (Kommentar „Leads auf Eis“ entfällt).

  • Step 4: Bestehen sehen — gleicher Befehl wie Step 2. Expected: # fail 0.

  • Step 5: Commit

    cd $WK && git add src/pages/WorkersPage.jsx src/styles/workers.css public/fonts/PixelifySans-Variable.woff2 public/fonts/OFL-PixelifySans.txt src/App.jsx src/lib/navLinks.js tests/unit/navLinks.test.mjs tests/unit/workersRoute.test.mjs tests/unit/workersCss.test.mjs tests/unit/workersFont.test.mjs
    git commit -m "Workers P2: Seite /hustler (lazy), Menü „Hustler“ statt Leads, Stil und Schrift Pixelify Sans" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
    

P2-T21: Prüfstand und manuelle Playwright-Abnahme

Files:

  • Create: scripts/workers-harness/build.mjs, scripts/workers-harness/entry.jsx, scripts/workers-harness/index.html, scripts/workers-harness/scripted-api.js, scripts/workers-harness/shot.cjs
  • Test: tests/unit/workersHarness.test.mjs

Interfaces:

  • Consumes: WorkersBoard mit Fake-API (kein Auth, kein Appwrite), EVENT_KINDS, ROLES.

  • Produces:

    // scripted-api.js
    export const SCRIPT                                  // [{ at: Sekunden, event }], deckt alle 26 Arten und ≥ 15 Worker ab, erstes Ereignis bei ≥ 2 s
    export function createScriptedApi({ now, halted = false }): { state, events, trace, pause, settings, saveSettings, pult, pultAction, botleads, prospect, saved }   // gleiche Form wie createWorkersApi; `saved` = Liste der an saveSettings übergebenen Patches
    

    state(): 19 Worker, kleinanzeigen/portale/foren (kim, pia, finn) nicht angeschlossen, gemischte Status, kpis wie in den Fixtures, lastEventTs = Basiszeit; events(after): nur fällige, nie doppelte (Zeitstempel = Basiszeit + at); trace(id): 12 Ereignisse, darunter eines mit javascript:alert(1) und eines mit https://example.org/fund, today mit Zählern, tokens: { in, out }, Kosten; pause(p) merkt den Zustand und state().paused folgt; settings() = Standardwerte aus Übersicht §4.1 (inkl. privacyInteressentenLive: false, sendingHalted, sendingHaltedReason); state() enthält sendingHalted und sendingHaltedReason (Standard false / ''; mit halted: true gilt true / 'Zwei harte Bounces in den letzten 10 Mails.', H4); saveSettings(patch) merkt den Patch in saved, mit sendingHalted: false hebt es die Notbremse auf (state() und settings() melden danach false / '') und liefert die Einstellungen zurück. build.mjs <outDir>: esbuild-Bündel (entry.jsx → harness.js, harness.css, index.html; jsx: 'automatic', external: ['/fonts/*'], Plattform Browser). entry.jsx: StrictMode + MemoryRouter + WorkersBoard api={scripted} isAdmin stageRef={ref} (der Prüfstand-URL-Parameter ?halted=1 erzeugt die Skript-API mit halted: true) und window.__wk = { stage: ref, api: scripted } nur im Prüfstand. shot.cjs <distDir> <outDir>: Muster entwuerfe/shot.cjs (gleicher Chromium-Pfad, gleiches createRequire, Sperre auf neue Ausgabeordner unter $TMPDIR), kleiner statischer Server auf 127.0.0.1 (liefert distDir und /fonts/* aus public/fonts).

  • Step 1: Failing Test schreiben (tests/unit/workersHarness.test.mjs)

    const { createScriptedApi, SCRIPT } = await loadModule('scripts/workers-harness/scripted-api.js')
    const { EVENT_KINDS } = await loadModule('src/lib/workersEvents.js')
    const { SEARCH_ROOMS, roleOf } = await loadModule('src/lib/workersRoster.js')
    test('Skript deckt alle 26 Ereignisarten und mindestens 15 Worker ab; erstes Ereignis nicht vor 2 s', () => {
      assert.deepEqual(new Set(SCRIPT.map((s) => s.event.kind)), new Set(EVENT_KINDS)); assert.ok(new Set(SCRIPT.map((s) => s.event.workerId).filter((w) => w !== 'system')).size >= 15); assert.ok(Math.min(...SCRIPT.map((s) => s.at)) >= 2)
    })
    test('scripted api: 19 Worker (kim, pia, finn nicht angeschlossen); events liefert nur Fälliges, nie doppelt; Pause folgt', async () => {
      let t = 0; const api = createScriptedApi({ now: () => t }); const s = await api.state()
      assert.equal(s.workers.length, 19); assert.deepEqual(s.workers.filter((w) => !w.connected).map((w) => w.workerId).sort(), ['finn', 'kim', 'pia'])
      assert.ok(s.workers.filter((w) => !w.connected).every((w) => SEARCH_ROOMS.includes(roleOf(w.workerId).room)))   // Grau-Regel (P1-E9): nur Such-Räume können nicht angeschlossen sein, die anderen elf sind es immer
      assert.equal((await api.events(null)).events.length, 0); t = 10000; const first = (await api.events(s.lastEventTs)).events; assert.ok(first.length > 0)
      const again = (await api.events(first.at(-1).ts)).events; assert.equal(again.filter((e) => first.some((f) => f.id === e.id)).length, 0)
      await api.pause(true); assert.equal((await api.state()).paused, true)
      assert.equal(s.sendingHalted, false); assert.equal(s.sendingHaltedReason, '')                                   // Standard: nicht gestoppt
      const h = createScriptedApi({ now: () => 0, halted: true }); const hs = await h.state()                       // H4: gestoppt, Grund sichtbar, Freigabe über saveSettings
      assert.equal(hs.sendingHalted, true); assert.match(hs.sendingHaltedReason, /Bounces/); assert.equal((await h.settings()).sendingHalted, true)
      const saved = await h.saveSettings({ sendingHalted: false }); assert.equal(saved.sendingHalted, false); assert.equal(saved.sendingHaltedReason, '')
      assert.deepEqual(h.saved, [{ sendingHalted: false }]); assert.equal((await h.state()).sendingHalted, false)
    })
    
  • Step 2: Scheitern sehen — cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersHarness.test.mjs. Expected: FAIL Could not resolve …/scripted-api.js.

  • Step 3: Implementieren die fünf Dateien. shot.cjs prüft (Exit 1 bei Verstoß, am Ende JSON und OK):

    1. keine pageerror/console.error; document.fonts.check('600 12px "Pixelify Sans"') ist wahr und die Schrift kam mit 200 vom Server;
    2. Desktop 1280×900: 19 Knöpfe [data-wk-worker]; Klick auf jeden ⇒ .wk-name zeigt den Namen aus ROLES, .wk-chip den vollen Modellnamen;
    3. sechs Figuren (otto, kim, rita, bea, bob, arne): __wk.stage.current.locate(id) liefert CSS-Pixel relativ zur Leinwand, geklickt wird an boundingBox(.wk-canvas) plus diese Werte ⇒ Karte zeigt dieselbe Figur;
    4. Wahl von kim: .wk-trace a[href^="javascript:"] hat 0 Treffer, die https-Zeile ist ein Link mit rel="noopener noreferrer" und target="_blank", die javascript:-Zeile steht als Text;
    5. Pausenknopf: Text wechselt auf „Fortsetzen“ und zurück auf „Alles anhalten“; „Einstellungen“ öffnet einen Dialog mit dem Titel „Hustler-Einstellungen“ und dem Feld „Tagesgrenze (USD)“, „Abbrechen“ schließt ihn;
    6. nach 20 s Skriptlauf: stats().avgDrawMs ≤ 25 (nur Warnung ausgeben, kein Abbruch, da software-gerendert), debug().pending ≥ 0;
    7. Handy 390×844 (DPR 2): document.documentElement.scrollWidth <= 390, Zoom „+“ erhöht stats().zoom;
    8. page.emulateMedia({ reducedMotion: 'reduce' }) + Neuladen: nach 2 s stats().frames <= 5;
    9. Bilder desktop-start.png, desktop-nach-20s.png, handy.png, handy-zoom.png, desktop-gestoppt.png im Ausgabeordner;
    10. Notbremse (H4/A21), Seite mit ?halted=1 (Desktop 1280×900; das Bild desktop-gestoppt.png entsteht vor dem Freigeben): .msg.err[role="alert"] zeigt genau „Versand gestoppt: Zwei harte Bounces in den letzten 10 Mails. Ein Admin kann ihn in den Einstellungen freigeben.“ (der Punkt des Grundes nicht doppelt); „Einstellungen“ öffnet den Dialog mit dem Block „Versand gestoppt“ und dem Knopf „Versand wieder freigeben“; Klick darauf ⇒ binnen 2 s sind Block und Warnzeile verschwunden und __wk.api.saved ist genau [{ sendingHalted: false }]; ohne ?halted=1 gibt es weder Zeile noch Block.
  • Step 4: Test bestehen sehen — cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersHarness.test.mjs. Expected: # pass 2, # fail 0.

  • Step 5: Abnahme-Lauf (Verifikation, Implementierer führt aus)

    cd $WK && free -m && B=$(mktemp -d "$TMPDIR/wk-harness.XXXXXX") && O=$(mktemp -d "$TMPDIR/wk-shots.XXXXXX")
    systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0 timeout 120 node --max-old-space-size=512 scripts/workers-harness/build.mjs "$B"
    systemd-run --scope -q -p MemoryMax=700M -p MemorySwapMax=0 timeout 200 node --max-old-space-size=400 scripts/workers-harness/shot.cjs "$B" "$O"
    

    Expected: letzte Zeile OK, errors: []. Danach die fünf PNGs mit dem Read-Werkzeug ansehen und festhalten: (a) 10 Etagen mit Schildern rechts, bei 1280 × 900 beim ersten Bild komplett sichtbar (Keller unten zu sehen) und die Raumtitel lesbar; ist die Bühne dort schmaler als fitZoom-Schwelle (720 px, die Seitenspalte nimmt 320 px + 32 px Abstand), wird die Spaltenbreite in workers.css verkleinert oder die Schwelle in P2-T08 samt Test angepasst, (b) Kleinanzeigen/Portale/Foren grau mit Schild, (c) 19 Figuren, Otto größer mit Kommandomütze, (d) Karte rechts mit Modell-Chip und Spur, (e) Handy ohne seitliches Scrollen, Zoom-Knöpfe sichtbar, (f) desktop-gestoppt.png: rote Zeile „Versand gestoppt: …“ zwischen Kopfzeile und Hochhaus, das Hochhaus selbst ohne Pausenschleier. Abweichungen werden im jeweiligen Engine-/CSS-Modul behoben (mit Test), dann erneut laufen lassen.

  • Step 6: Commit

    cd $WK && git add scripts/workers-harness/build.mjs scripts/workers-harness/entry.jsx scripts/workers-harness/index.html scripts/workers-harness/scripted-api.js scripts/workers-harness/shot.cjs tests/unit/workersHarness.test.mjs
    git commit -m "Workers P2: Prüfstand mit Skript-API und Playwright-Abnahme" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
    

P2-T22: Gesamtlauf, Build und Chunk-Prüfung

Files:

  • Test: keine neue Datei (Verifikation des Gesamtstands)

Interfaces:

  • Consumes: alles aus P2-T01 bis P2-T21.

  • Produces: nachgewiesen grüne Suite, lazy Chunk, Branch-Stand für Kenso.

  • Step 1: Gesamtlauf — cd $WK && free -m && timeout 600 node --max-old-space-size=1024 --test tests/*.test.mjs tests/unit/*.test.mjs. Expected: # fail 0 (Anzahl der Tests = bisherige plus die neuen workers*-Tests; Zahl für die Übergabe notieren).

  • Step 2: Build (neuer leerer Ordner)

    cd $WK && D=$(mktemp -d "$TMPDIR/wk-dist.XXXXXX") && systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0 env NODE_OPTIONS=--max-old-space-size=1200 timeout 400 node_modules/.bin/vite build --outDir "$D" --emptyOutDir && echo "$D"
    

    Expected: ✓ built, kein Fehler.

  • Step 3: Chunk-Prüfung (mit dem ausgegebenen $D): ls "$D/assets" | rg WorkersPage ⇒ je eine .js und eine .css; rg -l 'noch nicht angeschlossen' "$D/assets" ⇒ nur die WorkersPage-Datei, nicht index-*.js; rg -l 'Pixelify' "$D/assets" ⇒ nur die WorkersPage-CSS; ls "$D/fonts" ⇒ PixelifySans-Variable.woff2, OFL-PixelifySans.txt. Expected: wie beschrieben (Engine und Schrift liegen nicht im Haupt-Bundle).

  • Step 4: Branch-Stand festhalten — cd $WK && git status --short (nur ?? node_modules, ggf. ??-Zeilen für nicht eingecheckte Plan-Dokumente) und git log --oneline origin/test..HEAD (nur Workers-Commits). Expected: keine geänderten Dateien; die Commit-Liste ist die Übergabe an Kenso.

  • Step 5: Kein Commit (nur Verifikation). Fehlschläge werden im verursachenden Modul behoben und committet, danach beginnt P2-T22 von vorn.


Betriebsanleitung (Kenso)

P2 braucht keine .env-, Compose- oder nginx-Änderung. Der Block location /api/workers/ und der Container webklar-workers gehören zur Betriebsanleitung von P1; ohne sie zeigt die Seite „Der Hustler-Dienst ist noch nicht erreichbar“ (gewollt, kein Absturz).

  1. P1 prüfen (empfohlen vor P2): curl -s -o /dev/null -w '%{http_code}\n' https://ticket.webklar.com/api/workers/health ⇒ 200; curl -s -o /dev/null -w '%{http_code}\n' https://ticket.webklar.com/api/workers/state ⇒ 401 (ohne JWT). Anders ⇒ erst P1 in Betrieb nehmen; WOMS kann trotzdem schon live gehen.
  2. Stand merken: git -C /home/webklar/apps/ticket.webklar.com/dev-workers fetch origin && git -C /home/webklar/apps/ticket.webklar.com/dev-workers rev-parse origin/test ⇒ SHA als ALT notieren (für das Zurücknehmen). Danach git -C /home/webklar/apps/ticket.webklar.com/dev-workers merge-base --is-ancestor origin/test HEAD && echo OK: Kommt kein OK, hat test inzwischen neue Commits; dann nicht pushen und nie erzwingen (kein --force), sondern Claude bitten, origin/test in feature/workers zusammenzuführen (git fetch origin, dann git merge origin/test; ein Rebase der 40 Commits ist nicht nötig und würde alle Commit-Nummern ändern) und danach Schritt 3 samt vite build zu wiederholen. Stand der Endprüfung (2026-10-18): Es kommt kein OK. origin/test steht auf f9b0cbc (Website-Statistik, 2026-10-17), der Branch ist 40 Commits davor und 1 dahinter, ein Push würde als Nicht-Fast-Forward abgelehnt und nichts ausliefern. src/App.jsx und src/lib/navLinks.js verschmelzen von selbst; es gibt genau einen Textkonflikt in tests/unit/navLinks.test.mjs (beide Seiten ändern dieselben deepEqual-Listen). Richtig ist die Reihenfolge … 'customers', 'hustler', 'legal', 'statistik' für Mitarbeiter und … 'customers', 'hustler', 'finance', 'legal', 'statistik', 'serverpuls', 'admin' für Admins. Eine Probezusammenführung in einer Kopie lief mit allen Tests grün und grünem Build (Stand 820ccd5: 993 Tests).
  3. Letzter Test: cd /home/webklar/apps/ticket.webklar.com/dev-workers && free -m && timeout 900 node --max-old-space-size=1024 --test --test-concurrency=2 tests/*.test.mjs tests/unit/*.test.mjs ⇒ # fail 0 (Stand nach den Endprüfung-Korrekturen: 996 Tests, rund 31 s). --test-concurrency=2 ist Pflicht: ohne den Schalter startet Node bis zu 7 Prüfprozesse mit je bis zu 1 GB Speicher, und auf diesem Server hat genau so ein Lauf am 2026-10-04 den Speicher erschöpft (OOM-Kill). Vorher mit free -m prüfen, dass mindestens 2 GB frei sind.
  4. Live schalten (Push): git -C /home/webklar/apps/ticket.webklar.com/dev-workers push origin feature/workers:test. Der Deploy-Webhook baut in 1–3 Minuten. Prüfen: curl -sI https://ticket.webklar.com/fonts/PixelifySans-Variable.woff2 | grep -iE '^(HTTP|content-type)' ⇒ HTTP/… 200 und content-type: font/woff2 (steht dort text/html, ist es nur die Startseite, die nginx bei fehlender Datei liefert, und die Schrift fehlt); das Build-Log des Ticket-Build-Containers (Name mit docker ps --format '{{.Names}}' | rg -i ticket finden, Muster webklar-ticket-build; docker logs --tail 40 <name>) endet ohne Fehler.
  5. Im Browser (als Mitarbeiter): Menü zeigt „Hustler“ statt des grauen „Leads“; https://ticket.webklar.com/leads und https://ticket.webklar.com/workers springen auf https://ticket.webklar.com/hustler (eine angehängte Query wie ?tab=botleads bleibt erhalten); Seitentitel (H1) und Browser-Tab zeigen „Hustler“; die Seite zeigt die sechs Kennzahlen, das Hochhaus mit 10 Etagen und 19 Figuren, ein Klick auf eine Figur öffnet rechts die Karte (Name, Modell, Zähler, Spur); es gibt vorerst nur den Bereich „Hochhaus“ ohne Reiterleiste (der Reiter „Freigabe“ kommt mit P3, dort P3-E11); auf dem Handy: Zoom-Knöpfe, kein seitliches Scrollen; ein Tipp auf einen Namen in der Liste holt die Karte nach oben ins Bild (der Name in der Karte hat dann den Fokus), „ד schließt sie und setzt den Fokus zurück auf den Listeneintrag. Ausfall-Probe (solange P1 noch nicht läuft, also ohne /api/workers/-Block oder ohne Container): statt des Hochhauses steht „Der Hustler-Dienst ist noch nicht erreichbar“ mit einer Meldung und dem Knopf „Neu laden“; sobald P1 läuft, zeigt ein Klick auf „Neu laden“ das Hochhaus ohne Seite neu zu laden. Fällt der Dienst später aus, bleibt das Hochhaus stehen und eine Zeile „Verbindung gestört: … Es wird weiter versucht.“ erscheint; ein hängender Abruf wird nach 20 s abgebrochen, die Seite friert nicht ein. Meldet der Dienst dauerhaft „401“, erscheint „Anmeldung abgelaufen. Bitte neu anmelden.“, und es wird höchstens alle 2 Minuten ein neues Anmelde-Token (JWT) erzeugt (Appwrite begrenzt das je Nutzer). Dieselben Fälle prüft der Prüfstand (Szenario F in scripts/workers-harness/shot.cjs) im Browser. Zu Beginn sind genau die acht Such-Räume grau mit „noch nicht angeschlossen“ (Netz-Radar, Kleinanzeigen, Portale, Foren & Fragen, Ärzte & Praxen, Handwerk, Selbstständige, Läden & Gastro); Brücke, Waage, Mail-Stube, Forum-Stube, Posteingang, Abschluss, die drei Werkstatt-Tische, Prüftisch und Archiv sind von Anfang an farbig (P1-E9). Das ist richtig: ein Such-Raum wird erst farbig, wenn der Dienst ihn als angeschlossen meldet (P3 für Netz-Radar, Kleinanzeigen, Portale, Foren; P6 für die Branchen-Räume).
  6. Pausenschalter (nur wenn P1 läuft): „Alles anhalten“ ⇒ Hinweis „Angehalten …“ und heller Schleier im Hochhaus; danach „Fortsetzen“ — den Dienst nicht angehalten lassen. Als Admin: „Einstellungen“ öffnen (Titel „Hustler-Einstellungen“; unter „Kosten“ stehen im Standard Tagesgrenze 6 und Monatsgrenze 150, jeweils USD, [Kenso, 17.10.: max. 150 € im Monat]; die harte Grenze ist das Ausgabenlimit des Console-Workspaces, 150 $/Monat, Betriebsanleitung P1), nichts ändern, „Abbrechen“; als Nicht-Admin fehlt der Knopf. Versand-Notbremse (H4): Hat der Dienst den Versand wegen Bounces gestoppt (P4), zeigt das Board für alle Mitarbeiter eine rote Zeile „Versand gestoppt: . Ein Admin kann ihn in den Einstellungen freigeben.“ und der Einstellungs-Dialog (nur Admin) oben den Block „Versand gestoppt“ mit dem Knopf „Versand wieder freigeben“. Den Knopf erst drücken, wenn die Ursache geklärt ist (Bounce-Adressen im Postfach prüfen, Sperrliste); er schickt nur sendingHalted:false, der Dienst löscht den Grund. Ohne Stopp sind Zeile und Block nicht zu sehen; wer sie vor dem Livegang sehen will, schaut sich das Bild .superpowers/sdd/2026-10-17-workers-p2-hochhaus-woms/shots/desktop-gestoppt.png an (Claude zeigt es auf Wunsch). Den Prüfstand mit ?halted=1 gibt es nur als automatisches Skript (scripts/workers-harness/shot.cjs, baut und beendet sich selbst); eine Seite zum Aufrufen im Browser gibt es nicht.
  7. Zurücknehmen, falls nötig: cd /home/webklar/apps/ticket.webklar.com/dev-workers && git revert --no-edit $(git rev-list --no-merges ALT..HEAD) (ALT aus Schritt 2; nimmt alle eigenen Commits in der richtigen Reihenfolge zurück und lässt den Merge-Commit aus Schritt 2 stehen, denn git revert kann einen Merge-Commit nicht ohne -m zurücknehmen; die Konfliktlösung im Navigations-Test bleibt dann stehen, das betrifft nur den Test, nicht die Seite; macht auch die Dokumentations-Commits rückgängig, das schadet nicht), dann git -C /home/webklar/apps/ticket.webklar.com/dev-workers push origin feature/workers:test. Prüfen: https://ticket.webklar.com/leads zeigt wieder die Tickets (graues „Leads“ im Menü).
  8. Danach (Claude): Memory-Eintrag zu WOMS-Workers aktualisieren (Stand live, Commit-SHA, Ergänzungen P2-E1 bis P2-E7 stehen in der Übersicht §11.1, die Notbremsen-Anzeige entspricht A21 und H4).

Bekannte Einschränkung vor dem Livegang: Handy-Startansicht (Entscheidung für Kenso)

Auf dem Handy (390 px Breite) startet das Hochhaus vergrößert (Zoom 1,56, damit die Raumtitel lesbar sind). Folgen: Das Bild zeigt zuerst nur die oberen zwei Drittel (Prüfung, Pult, Tresor und Keller erreicht man durch Ziehen auf dem Bild oder mit „Ganz“), und ein Wischen, das auf dem Bild beginnt, verschiebt das Bild statt die Seite zu scrollen (die Seite lässt sich über Kennzahlen, Liste und Ränder scrollen; das Bild ist nur etwa 290 px hoch). Es gehen keine Daten und keine Funktion verloren, die Liste mit allen 19 Workern ist die Alternative. Das ist so im Plan beschlossen (Startzoom nach Breite, touch-action: none ab Zoom 1) und wird bewusst nicht ohne deine Entscheidung geändert. Zur Wahl:

  1. So lassen (empfohlen für den Start): als bekannte Einschränkung hinnehmen.
  2. Auf dem Handy mit dem ganzen Hochhaus starten (Zoom 1): Seite scrollt überall, aber die Raumtitel sind dann winzig (etwa 5 px).
  3. Zoom 1,56 behalten, aber nur mit zwei Fingern oder nach „+“ verschieben, mit einem Finger scrollt die Seite (touch-action: pan-y): braucht eine kleine Änderung in scene.js plus Test.