From a6f1b0c56d46c28bd6dcfabb6c3832ed55715b38 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 04:20:35 +0000 Subject: [PATCH] =?UTF-8?q?Workers=20P2:=20B=C3=BChne=20(StrictMode-fest)?= =?UTF-8?q?=20und=20Board=20mit=20Pausenschalter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/components/workers/WorkersBoard.jsx | 124 ++++++++++++++++++++++++ src/components/workers/WorkersStage.jsx | 120 +++++++++++++++++++++++ tests/unit/workersComponents.test.mjs | 30 ++++++ 3 files changed, 274 insertions(+) create mode 100644 src/components/workers/WorkersBoard.jsx create mode 100644 src/components/workers/WorkersStage.jsx diff --git a/src/components/workers/WorkersBoard.jsx b/src/components/workers/WorkersBoard.jsx new file mode 100644 index 0000000..e9f5afd --- /dev/null +++ b/src/components/workers/WorkersBoard.jsx @@ -0,0 +1,124 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import Button from '../ui/Button' +import EmptyState from '../ui/EmptyState' +import { useWorkerTrace, useWorkersFeed } from '../../hooks/useWorkersFeed' +import { indexWorkers, reduceEvents } from '../../lib/workersEvents' +import { pauseLabel } from '../../lib/workersView' +import WorkerCard from './WorkerCard' +import WorkerList from './WorkerList' +import WorkersKpis from './WorkersKpis' +import WorkersSettingsDialog from './WorkersSettingsDialog' +import WorkersStage from './WorkersStage' + +const RUNNING_TEXT = 'Läuft' +const PAUSED_TEXT = 'Angehalten: Es wird nichts gesendet und nichts gebaut.' +const PAUSE_FAILED_TEXT = 'Umschalten fehlgeschlagen.' +const NO_REASON_TEXT = 'Grund nicht angegeben' + +// Grund der Notbremse für den Satz „Versand gestoppt: . …“: getrimmt, ohne abschließende Punkte (sonst entstünde „..“) +function haltReasonOf(reason) { + const text = typeof reason === 'string' ? reason.trim().replace(/[.\s]+$/, '') : '' + return text || NO_REASON_TEXT +} + +// Inhalt des Reiters „Hochhaus“: Kopfzeile mit Pausenschalter, Kennzahlen, Hochhaus mit Seitenspalte (Karte und Liste). +// Ohne Auth-Bezug: isAdmin kommt von der Seite. stageRef ist optional (nur der Prüfstand reicht eine durch); ohne eins legt das Board +// selbst eins an, damit pushEvents die Bühne immer erreicht. +export default function WorkersBoard({ api, isAdmin, stageRef }) { + const ownRef = useRef(null) + const ref = stageRef ?? ownRef + const aliveRef = useRef(true) + const [workers, setWorkers] = useState({}) + const [selectedId, setSelectedId] = useState(null) + const [settingsOpen, setSettingsOpen] = useState(false) + const [pausing, setPausing] = useState(false) + const [pauseError, setPauseError] = useState('') + + useEffect(() => { + aliveRef.current = true + return () => { aliveRef.current = false } + }, []) + + // Neue Ereignisse: Tabelle fortschreiben und der Bühne vorspielen (die Bühne kennt den Rückstau selbst) + const onEvents = useCallback((events) => { + setWorkers((w) => reduceEvents(w, events)) + if (ref.current) ref.current.pushEvents(events) + }, [ref]) + + const { state, error, loaded, refresh } = useWorkersFeed(api, { onEvents }) + useEffect(() => { + if (state) setWorkers(indexWorkers(state)) + }, [state]) + + const { trace, loading: traceLoading, error: traceError } = useWorkerTrace(api, selectedId) + + if (!loaded || !state) { + if (error) { + return ( + {error}

} + action={} + /> + ) + } + return
Wird geladen …
+ } + + const paused = Boolean(state.paused) + const worker = selectedId && Object.hasOwn(workers, selectedId) ? workers[selectedId] : undefined + + // Pause für alle Mitarbeiter: der Dienst bekommt den Zielwert, danach wird der Zustand sofort neu geholt + const togglePause = async () => { + if (pausing) return + setPausing(true) + setPauseError('') + try { + await api.pause(!paused) + refresh() + } catch (err) { + if (aliveRef.current) setPauseError(err?.message || PAUSE_FAILED_TEXT) + } + if (aliveRef.current) setPausing(false) + } + + return ( + <> +
+ {paused ? PAUSED_TEXT : RUNNING_TEXT} + + + {isAdmin && } +
+ + {pauseError &&
{pauseError}
} + {state.sendingHalted === true && ( +
+ Versand gestoppt: {haltReasonOf(state.sendingHaltedReason)}. Ein Admin kann ihn in den Einstellungen freigeben. +
+ )} + {error &&
Verbindung gestört: {error} Es wird weiter versucht.
} + + + +
+ +
+ setSelectedId(null)} + /> + +
+
+ + {isAdmin && settingsOpen && ( + setSettingsOpen(false)} onSaved={() => refresh()} /> + )} + + ) +} diff --git a/src/components/workers/WorkersStage.jsx b/src/components/workers/WorkersStage.jsx new file mode 100644 index 0000000..eafc9f7 --- /dev/null +++ b/src/components/workers/WorkersStage.jsx @@ -0,0 +1,120 @@ +import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react' +import Button from '../ui/Button' +import { ZOOM_STEP, mountTower } from './engine/scene' + +const FONT = '600 12px "Pixelify Sans"' +const REDUCED_MOTION = '(prefers-reduced-motion: reduce)' +const COARSE_POINTER = '(pointer: coarse)' + +// matchMedia, wo es das gibt (sonst null) +const media = (query) => (typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia(query) : null) + +const NO_STATS = { frames: 0, avgDrawMs: 0, zoom: 1 } +const NO_DEBUG = { pending: 0, agents: [], lift: 'idle', fx: 0 } + +// Bühne des Hochhauses: ein Canvas, den die Engine (mountTower) bespielt, und die Zoomknöpfe. +// state = Zustand der Dienst-API (oder null), selectedId = markierte Figur, onSelect(id | null) kommt nur bei Klicks auf die Leinwand. +// Handle über ref: { pushEvents(events), locate(id), stats(), debug() }; vor dem Einhängen der Engine sind alle Aufrufe leer. +// Die Engine wird neu eingehängt, wenn sich „weniger Bewegung“ ändert (Standbild statt Schleife); engineKey zählt die Einhängungen, +// damit Zustand und Markierung an jede neue Engine gehen (auch bei StrictMode: einhängen, abbauen, einhängen). +// Im Standbild kostet jeder Aufruf ein Bild: darum kein setState vor der ersten Einhängung, kein select ohne Änderung und +// kein resize, solange die Breite gleich bleibt. +export default forwardRef(function WorkersStage({ state, selectedId, onSelect }, ref) { + const canvasRef = useRef(null) + const handleRef = useRef(null) + const onSelectRef = useRef(onSelect) + const shownRef = useRef(null) // Markierung, die die aktuelle Engine zeigt (eine neue Engine startet ohne) + const [reduceMotion, setReduceMotion] = useState(() => Boolean(media(REDUCED_MOTION)?.matches)) + const [engineKey, setEngineKey] = useState(0) + + useEffect(() => { onSelectRef.current = onSelect }, [onSelect]) + + useImperativeHandle(ref, () => ({ + pushEvents: (events) => { if (handleRef.current) handleRef.current.pushEvents(events) }, + locate: (id) => (handleRef.current ? handleRef.current.locate(id) : null), + stats: () => (handleRef.current ? handleRef.current.stats() : NO_STATS), + debug: () => (handleRef.current ? handleRef.current.debug() : NO_DEBUG), + }), []) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return undefined + const handle = mountTower(canvas, { + onSelect: (id) => { if (onSelectRef.current) onSelectRef.current(id) }, + reducedMotion: reduceMotion, + coarsePointer: Boolean(media(COARSE_POINTER)?.matches), + fontsReady: document.fonts && typeof document.fonts.load === 'function' ? document.fonts.load(FONT) : undefined, + dpr: window.devicePixelRatio || 1, + }) + handleRef.current = handle + shownRef.current = null + + // mountTower hat schon gemessen; erst eine andere Breite löst resize() aus (der erste Aufruf des Beobachters meldet nur den Ist-Wert) + let width = canvas.getBoundingClientRect().width + const observer = typeof ResizeObserver === 'function' + ? new ResizeObserver(() => { + const next = canvas.getBoundingClientRect().width + if (next === width) return + width = next + handle.resize() + }) + : null + if (observer) observer.observe(canvas) + + // Versteckter Tab: die Szene ruht. Beim Zurückkehren wird die Regie beruhigt (flush), damit nichts aus der Zeit davor nachläuft. + // Die Ereignisse, die inzwischen aufgelaufen sind, holt der Feed selbst und gibt sie danach über pushEvents herein. + const onVisibility = () => { + const hidden = document.hidden + handle.setPaused(hidden) + if (!hidden) handle.flush() + } + document.addEventListener('visibilitychange', onVisibility) + if (document.hidden) handle.setPaused(true) + + const motion = media(REDUCED_MOTION) + const onMotion = (e) => setReduceMotion(Boolean(e.matches)) + if (motion) motion.addEventListener('change', onMotion) + + setEngineKey((k) => k + 1) + + return () => { + document.removeEventListener('visibilitychange', onVisibility) + if (motion) motion.removeEventListener('change', onMotion) + if (observer) observer.disconnect() + handle.destroy() + if (handleRef.current === handle) handleRef.current = null + } + }, [reduceMotion]) + + // engineKey 0 = noch keine Engine eingehängt (der Zähler springt gleich nach dem Einhängen auf 1) + useEffect(() => { + if (engineKey > 0 && state && handleRef.current) handleRef.current.setState(state) + }, [state, engineKey]) + + useEffect(() => { + const handle = handleRef.current + const wanted = selectedId ?? null + if (!handle || wanted === shownRef.current) return + shownRef.current = wanted + handle.select(wanted) + }, [selectedId, engineKey]) + + const zoomBy = (factor) => { if (handleRef.current) handleRef.current.zoomBy(factor) } + const resetView = () => { if (handleRef.current) handleRef.current.resetView() } + + return ( +
+ +
+ + + +
+
+ ) +}) diff --git a/tests/unit/workersComponents.test.mjs b/tests/unit/workersComponents.test.mjs index 9585ea3..775fde5 100644 --- a/tests/unit/workersComponents.test.mjs +++ b/tests/unit/workersComponents.test.mjs @@ -35,3 +35,33 @@ test('Einstellungs-Dialog: Dialog-Baustein, Formularlogik, kein paused, Admin-Be assert.match(s, /api\.saveSettings\(\{ sendingHalted: false \}\)/); assert.match(s, /sendingHaltedReason/) assert.doesNotMatch(s, /formToPatch\([^)]*sendingHalted/) }) +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/) + // Controller-Beschluss: Sichtbarkeit steuert die Szene (setPaused), beim Zurückkehren wird die Regie beruhigt (flush) + assert.match(s, /setPaused\(/); assert.match(s, /\.flush\(\)/); assert.match(s, /mountTower\(/); assert.match(s, /engineKey/) + assert.match(s, /className="wk-stage"/); assert.match(s, /className="wk-canvas"/); assert.match(s, /className="wk-zoom"/) + assert.ok(s.includes('Pixel-Hochhaus: Etagen und Räume der Hustler. Auswahl auch über die Liste.')) + for (const t of ['+', '−', 'Ganz']) assert.ok(s.includes(`>${t}<`), t) + // jeder Knopf der Bühne ist ein type="button" (WOMS-Button hat keinen Standard-type, PF-08) + assert.equal((s.match(/]*type="button"/g) || []).length) +}) +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/) + // Controller-Beschluss T19: Tabelle nach workerId an Karte und Liste, Karte bekommt Eintrag und Spur, Zustand kommt aus indexWorkers + assert.match(s, /indexWorkers\(/); assert.match(s, /useWorkersFeed\(/); assert.match(s, /useWorkerTrace\(/); assert.match(s, /api\.pause\(/) + assert.match(s, /]*\bworker=\{/); assert.match(s, /]*\btrace=\{/); assert.match(s, /]*\bworkers=\{workers\}/); assert.match(s, /]|]/) + assert.match(s, /className="wk-toolbar"/); assert.match(s, /className="wk-main"/); assert.match(s, /className="loading"/) + assert.ok(s.includes('Angehalten: Es wird nichts gesendet und nichts gebaut.')); assert.ok(s.includes('Neu laden')); assert.ok(s.includes('Grund nicht angegeben')) + // PF-08: jeder Knopf ein type="button" (WOMS-Button hat keinen Standard-type) + assert.equal((s.match(/]*type="button"/g) || []).length) +})