Workers P2: Bühne (StrictMode-fest) und Board mit Pausenschalter
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
319e731d0e
commit
a6f1b0c56d
3 files changed
+274
No files matched your search
@@ -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: <Grund>. …“: 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 (
|
||||
<EmptyState
|
||||
title="Der Hustler-Dienst ist noch nicht erreichbar"
|
||||
hint={<p>{error}</p>}
|
||||
action={<Button type="button" onClick={() => refresh()}>Neu laden</Button>}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <div className="loading">Wird geladen …</div>
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div className="wk-toolbar">
|
||||
<span className="wk-status">{paused ? PAUSED_TEXT : RUNNING_TEXT}</span>
|
||||
<span className="grow" />
|
||||
<Button type="button" onClick={togglePause} disabled={pausing}>{pauseLabel(paused)}</Button>
|
||||
{isAdmin && <Button type="button" onClick={() => setSettingsOpen(true)}>Einstellungen</Button>}
|
||||
</div>
|
||||
|
||||
{pauseError && <div className="msg err" role="alert">{pauseError}</div>}
|
||||
{state.sendingHalted === true && (
|
||||
<div className="msg err" role="alert">
|
||||
Versand gestoppt: {haltReasonOf(state.sendingHaltedReason)}. Ein Admin kann ihn in den Einstellungen freigeben.
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="msg warn" role="status">Verbindung gestört: {error} Es wird weiter versucht.</div>}
|
||||
|
||||
<WorkersKpis kpis={state.kpis} />
|
||||
|
||||
<div className="wk-main">
|
||||
<WorkersStage ref={ref} state={state} selectedId={selectedId} onSelect={setSelectedId} />
|
||||
<div className="wk-side">
|
||||
<WorkerCard
|
||||
workerId={selectedId}
|
||||
worker={worker}
|
||||
trace={trace}
|
||||
loading={traceLoading}
|
||||
error={traceError}
|
||||
onClose={() => setSelectedId(null)}
|
||||
/>
|
||||
<WorkerList workers={workers} selectedId={selectedId} onSelect={setSelectedId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAdmin && settingsOpen && (
|
||||
<WorkersSettingsDialog api={api} onClose={() => setSettingsOpen(false)} onSaved={() => refresh()} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="wk-stage">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="wk-canvas"
|
||||
role="img"
|
||||
aria-label="Pixel-Hochhaus: Etagen und Räume der Hustler. Auswahl auch über die Liste."
|
||||
/>
|
||||
<div className="wk-zoom">
|
||||
<Button type="button" onClick={() => zoomBy(ZOOM_STEP)} aria-label="Vergrößern">+</Button>
|
||||
<Button type="button" onClick={() => zoomBy(1 / ZOOM_STEP)} aria-label="Verkleinern">−</Button>
|
||||
<Button type="button" onClick={resetView} aria-label="Ganzes Hochhaus zeigen">Ganz</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -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(/<Button\b/g) || []).length, (s.match(/<Button\b[^>]*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, /<WorkerCard\b[^>]*\bworker=\{/); assert.match(s, /<WorkerCard\b[^>]*\btrace=\{/); assert.match(s, /<WorkerList\b[^>]*\bworkers=\{workers\}/); assert.match(s, /<WorkersKpis\b/)
|
||||
// Links entstehen nur in der Karte über safeLink, das Board hat keine
|
||||
assert.doesNotMatch(s, /href=|<a[\s>]|<Link[\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(/<Button\b/g) || []).length, (s.match(/<Button\b[^>]*type="button"/g) || []).length)
|
||||
})
|
||||
Reference in new issue
Block a user