Hustler: Name in der Oberfläche, Budget höchstens 150 $/Monat (6 $/Tag) mit hartem Deckel

Kenso, 17.10.: "150 € im Monat max", "nenne es Hustler". Technische
Namen (workers*) bleiben.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-17 20:26:42 +00:00
1 parent 740089c997
commit 11e4280a92
8 files changed
+184 -156

No files matched your search

@@ -2,7 +2,7 @@
> **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 `/workers` 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.
**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.
@@ -27,12 +27,14 @@
- 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 Workers-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).
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.
@@ -67,7 +69,7 @@ Alles unter `$WK`. **Neu:**
| `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, `/leads` → `/workers`), `src/lib/navLinks.js` (Eintrag `workers`), `tests/unit/navLinks.test.mjs`.
**Ä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).
---
@@ -172,7 +174,7 @@ Alles unter `$WK`. **Neu:**
- [ ] **Step 1: Failing test schreiben** (`tests/unit/workersView.test.mjs`, `const V = await loadModule('src/lib/workersView.js')`)
```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: 8.5, apiMonthUsd: 50, dailyBudgetUsd: 10, monthlyBudgetUsd: 250 }
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), '–')
@@ -194,10 +196,10 @@ Alles unter `$WK`. **Neu:**
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', `8,50${NB}$`])
assert.equal(t[0].sub, `60${NB}€ neu pro Monat`); assert.equal(t[5].sub, `von 10,00${NB}$`)
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: 10 })[5].tone, 'neg'); assert.equal(V.kpiTiles({ ...K, apiTodayUsd: 7.9 })[5].tone, '')
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)', () => {
@@ -383,7 +385,7 @@ Alles unter `$WK`. **Neu:**
// 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 Workers-Dienst ist gerade nicht erreichbar.' · 'Der Workers-Dienst antwortet nicht richtig.' · 'Anmeldung abgelaufen. Bitte neu anmelden.' · 'Dafür fehlt dir die Berechtigung.'
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
```
@@ -420,13 +422,13 @@ Alles unter `$WK`. **Neu:**
})
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 Workers-Dienst antwortet nicht richtig.', 200]) // nginx liefert index.html
assert.deepEqual(await run({ status: 404, body: undefined }), ['Der Workers-Dienst antwortet nicht richtig.', 404])
assert.deepEqual(await run({ status: 502, body: undefined }), ['Der Workers-Dienst ist gerade nicht erreichbar.', 502])
assert.deepEqual(await run(new TypeError('fetch failed')), ['Der Workers-Dienst ist gerade nicht erreichbar.', 0])
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: {} }), ['Workers-Fehler 500', 500])
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 () => {
@@ -459,7 +461,7 @@ Alles unter `$WK`. **Neu:**
```
- [ ] **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 `Workers-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 `/workers?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 })`.
`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**
```bash
@@ -519,8 +521,8 @@ Alles unter `$WK`. **Neu:**
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 Workers-Dienst ist gerade nicht erreichbar.'), []] }); err.c.start(); await err.settle(); await err.advance(5000)
assert.deepEqual(err.log.errors, ['Der Workers-Dienst ist gerade nicht erreichbar.', null])
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 () => {
@@ -931,7 +933,7 @@ Alles unter `$WK`. **Neu:**
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: 8.5, apiMonthUsd: 50, dailyBudgetUsd: 10, monthlyBudgetUsd: 250 }`, überschrieben durch `kpis`), `ev(kind, workerId, extra)` ⇒ Ereignis mit eindeutiger `id`, `ts` (zählt je Aufruf um 1 s hoch), `text: 'Text'`.
`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`)
```js
@@ -1180,11 +1182,11 @@ Alles unter `$WK`. **Neu:**
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.`); `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`).
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')`)
```js
const S0 = { paused: true, dailyBudgetUsd: 10, monthlyBudgetUsd: 250, warnPct: 80, mailColdPerDay: 3, mailReplyCapPerDay: 15, sendWindowStart: '09:00', sendWindowEnd: '16:00', sendWeekdaysOnly: true, runsPerNight: 1, candidatesPerRun: 10,
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' }
@@ -1204,7 +1206,8 @@ Alles unter `$WK`. **Neu:**
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: '300' }).dailyBudgetUsd, 'Die Tagesgrenze darf nicht über der Monatsgrenze liegen.'); assert.equal(bad({ dailyBudgetUsd: '0' }).dailyBudgetUsd, 'Tagesgrenze muss größer als 0 sein.')
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.')
@@ -1279,7 +1282,7 @@ Alles unter `$WK`. **Neu:**
**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 })` — 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.
- 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**
@@ -1288,7 +1291,7 @@ Alles unter `$WK`. **Neu:**
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 ['Tagesgrenze (USD)', 'Monatsgrenze (USD)', 'Unverlangte Mails pro Tag', 'Angebotstext', 'Regionen', 'Speichern', 'Abbrechen']) assert.ok(s.includes(t), t)
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'))
@@ -1323,8 +1326,8 @@ Alles unter `$WK`. **Neu:**
// 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 Workers. 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 Workers-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: <Grund>. 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.
**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: <Grund>. 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**
```js
@@ -1335,7 +1338,7 @@ Alles unter `$WK`. **Neu:**
})
test('WorkersBoard: Zustände und Rechte (Review Focus 1)', () => {
const s = read('WorkersBoard.jsx')
assert.match(s, /Der Workers-Dienst ist noch nicht erreichbar/); assert.match(s, /Verbindung gestört/); assert.match(s, /reduceEvents\(/); assert.match(s, /pauseLabel\(/)
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"/)
@@ -1363,24 +1366,27 @@ Alles unter `$WK`. **Neu:**
**Interfaces:**
- Consumes: `WorkersBoard` (P2-T19), `workersApi` (P2-T04), `PageHeader`/`Tabs` (`../components/ui`), `pickTab` (P2-T02), `useAuth().isAdmin`.
- Produces: `export default function WorkersPage()` (Titel „Workers“, 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 `/workers` (lazy), `/leads` leitet auf `/workers` um; Menüeintrag `{ key: 'workers', label: 'Workers', href: '/workers' }` an der Stelle von `leads`, ohne `paused`.
- 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 `'workers'` ersetzen und die Zeile `assert.equal(links.find((l) => l.key === 'leads').paused, true)` durch
`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
```js
assert.deepEqual(links.find((l) => l.key === 'workers'), { key: 'workers', label: 'Workers', href: '/workers' })
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`):
```js
test('App: /workers lazy hinter ProtectedRoute, /leads leitet um, kein statischer Import', () => {
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="\/workers" element=\{<ProtectedRoute><Suspense[\s\S]*?<WorkersPage \/><\/Suspense><\/ProtectedRoute>\} \/>/)
assert.match(s, /<Route path="\/leads" element=\{<Navigate to="\/workers" replace \/>\} \/>/)
assert.ok(s.indexOf('path="/workers"') < s.indexOf('path="*"'))
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: Reiter hochhaus (kein Platzhalter), Reiterleiste erst ab zwei Reitern, api-Instanz, Admin-Weitergabe', () => {
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\}/)
})
@@ -1410,12 +1416,12 @@ Alles unter `$WK`. **Neu:**
- [ ] **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'`, `const WorkersPage = lazy(() => import('./pages/WorkersPage'))`, Route wie im Test (Fallback `<div className="loading">Wird geladen …</div>`), `/leads` ⇒ `/workers`; `navLinks.js`: Zeile `leads` ersetzen durch `{ key: 'workers', label: 'Workers', href: '/workers' }` (Kommentar „Leads auf Eis“ entfällt).
(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**
```bash
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 /workers (lazy), Menü statt Leads, Stil und Schrift Pixelify Sans" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
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>"
```
---
@@ -1464,7 +1470,7 @@ Alles unter `$WK`. **Neu:**
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 „Tagesgrenze (USD)“, „Abbrechen“ schließt ihn;
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`;
@@ -1509,13 +1515,13 @@ Alles unter `$WK`. **Neu:**
## 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 Workers-Dienst ist noch nicht erreichbar“ (gewollt, kein Absturz).
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**, sondern Claude bitten, `feature/workers` auf `origin/test` zu rebasen und Schritt 3 zu wiederholen.
3. **Letzter Test:** `cd /home/webklar/apps/ticket.webklar.com/dev-workers && free -m && timeout 600 node --max-old-space-size=1024 --test tests/*.test.mjs tests/unit/*.test.mjs` ⇒ `# fail 0`.
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 | head -3` ⇒ `HTTP/… 200`; 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 „Workers“ statt des grauen „Leads“; `https://ticket.webklar.com/leads` springt auf `/workers`; 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. 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, 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: <Grund>. 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, öffnet den Prüfstand aus P2-T21 mit `?halted=1`.
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. 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: <Grund>. 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, öffnet den Prüfstand aus P2-T21 mit `?halted=1`.
7. **Zurücknehmen, falls nötig:** `git -C /home/webklar/apps/ticket.webklar.com/dev-workers revert --no-edit ALT..HEAD` (ALT aus Schritt 2; 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).