From f8f8df2cbd05ee8e763750f3f9ff92329defcc24 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sun, 18 Oct 2026 09:47:38 +0000
Subject: [PATCH] =?UTF-8?q?Workers=20P3:=20Reiter=20Freigabe=20mit=20Kopie?=
=?UTF-8?q?ren=20&=20=C3=B6ffnen?=
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/PultList.jsx | 287 ++++++++++++++++++++++
src/hooks/usePultFeed.js | 65 +++++
src/pages/WorkersPage.jsx | 7 +-
src/styles/workers.css | 32 +++
tests/unit/workersPultUi.test.mjs | 359 ++++++++++++++++++++++++++++
tests/unit/workersRoute.test.mjs | 4 +-
6 files changed, 750 insertions(+), 4 deletions(-)
create mode 100644 src/components/workers/PultList.jsx
create mode 100644 src/hooks/usePultFeed.js
create mode 100644 tests/unit/workersPultUi.test.mjs
diff --git a/src/components/workers/PultList.jsx b/src/components/workers/PultList.jsx
new file mode 100644
index 0000000..7f563cc
--- /dev/null
+++ b/src/components/workers/PultList.jsx
@@ -0,0 +1,287 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import Badge from '../ui/Badge'
+import Button from '../ui/Button'
+import EmptyState from '../ui/EmptyState'
+import { copyText } from '../CopyableCredential'
+import { useEmployees } from '../../hooks/useEmployees'
+import { usePultFeed } from '../../hooks/usePultFeed'
+import { PULT_FILTERS, copyAndOpen, mailtoHref, rowModel } from '../../lib/workersPult'
+
+const CONFIRM_MS = 4000 // so lange gilt die Rückfrage „Wirklich verwerfen?“ nach dem ersten Klick
+const ACTION_FAILED = 'Das hat nicht geklappt.'
+
+// Leerzustand je Filter
+const EMPTY = {
+ offen: { title: 'Nichts wartet auf dich', hint: 'Neue Antworten erscheinen hier, sobald ein Entwurf bereit ist.' },
+ veraltet: { title: 'Nichts ist veraltet', hint: 'Einträge, die zu lange liegen, landen hier und lassen sich weiter bearbeiten.' },
+ erledigt: { title: 'Noch nichts erledigt', hint: 'Gesendete, verworfene und erledigte Einträge stehen hier.' },
+}
+
+// Neuer Tab ohne Rückbezug auf diese Seite
+const openTab = (url) => window.open(url, '_blank', 'noopener,noreferrer')
+
+// Meldung nach „Kopieren & öffnen“: ok nur, wenn alles geklappt hat, was die Zeile anbietet (ohne Anzeigen-Adresse gibt es nur Kopieren)
+function copyNote(result, hasUrl) {
+ if (!hasUrl) {
+ return result.copied
+ ? { kind: 'ok', text: 'Kopiert.' }
+ : { kind: 'err', text: 'Kopieren hat nicht geklappt. Bitte den Text von Hand markieren und kopieren.' }
+ }
+ if (result.copied && result.opened) return { kind: 'ok', text: 'Kopiert und geöffnet.' }
+ if (result.copied) return { kind: 'err', text: 'Kopiert, aber die Anzeige ließ sich nicht öffnen. Ein Pop-up-Blocker kann der Grund sein.' }
+ if (result.opened) return { kind: 'err', text: 'Geöffnet, aber das Kopieren hat nicht geklappt. Bitte den Text von Hand kopieren.' }
+ return { kind: 'err', text: 'Kopieren und Öffnen haben nicht geklappt.' }
+}
+
+// Eine Zeile des Pults. Die Zeile hält nur, was ihr gehört (läuft gerade etwas, Rückfrage, Meldung); der bearbeitete Text liegt in der
+// Liste, damit er einen Wechsel des Filters und das Neuladen überlebt. api.pultAction führt aus, der Dienst entscheidet über Rechte und
+// Reihenfolge (409 mit deutschem Satz); danach wird die Liste neu geholt.
+function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails }) {
+ const [busy, setBusy] = useState(false)
+ const [confirming, setConfirming] = useState(false)
+ const [note, setNote] = useState(null) // { kind: 'ok' | 'err', text }
+ const busyRef = useRef(false) // sperrt schon im selben Takt, in dem busy noch nicht greift (Doppelklick)
+ const aliveRef = useRef(true)
+
+ useEffect(() => {
+ aliveRef.current = true
+ return () => { aliveRef.current = false }
+ }, [])
+
+ // Die Rückfrage vor dem Verwerfen verfällt nach 4 s
+ useEffect(() => {
+ if (!confirming) return undefined
+ const timer = window.setTimeout(() => setConfirming(false), CONFIRM_MS)
+ return () => window.clearTimeout(timer)
+ }, [confirming])
+
+ const begin = () => {
+ if (busyRef.current) return false
+ busyRef.current = true
+ setBusy(true)
+ setConfirming(false)
+ setNote(null)
+ return true
+ }
+ const end = () => {
+ busyRef.current = false
+ if (aliveRef.current) setBusy(false)
+ }
+
+ // Aktion am Dienst. finished: der Eintrag verlässt die Liste, die Erfolgsmeldung steht deshalb oben bei der Liste und nicht in der Zeile.
+ const run = async (call, done, finished) => {
+ if (!begin()) return
+ try {
+ await call()
+ if (finished) onFinished(row.id, `„${row.title}“ ${done}`)
+ else if (aliveRef.current) setNote({ kind: 'ok', text: done })
+ } catch (err) {
+ if (aliveRef.current) setNote({ kind: 'err', text: err?.message || ACTION_FAILED })
+ }
+ await refresh()
+ end()
+ }
+
+ // Kopieren & öffnen: copyAndOpen steht ganz vorn und ohne await davor, nur im Zug des Klicks darf der Browser den Tab öffnen.
+ // Danach wird der Eintrag best-effort übernommen; ein Kollege, der schon dran ist, erscheint als Hinweis.
+ const copyOpen = async () => {
+ if (!begin()) return
+ const result = await copyAndOpen({ text, url: row.sourceHref, copy: copyText, open: openTab })
+ let next = copyNote(result, row.sourceHref !== null)
+ if (!row.claimedByMe) {
+ try {
+ await api.pultAction(row.id, 'claim')
+ } catch (err) {
+ const hint = err?.status === 409 && row.claimedByOther ? `Wird schon von ${row.claimedByLabel} bearbeitet.` : err?.message || ACTION_FAILED
+ next = { kind: 'err', text: `${next.text} ${hint}` }
+ }
+ await refresh()
+ }
+ if (aliveRef.current) setNote(next)
+ end()
+ }
+
+ const discard = () => {
+ if (!confirming) {
+ setConfirming(true)
+ return
+ }
+ run(() => api.pultAction(row.id, 'discard', {}), 'verworfen.', true)
+ }
+
+ const { prospect } = row
+ const place = prospect ? [prospect.postalCode, prospect.city].filter(Boolean).join(' ') : ''
+ const info = [place, prospect && prospect.score != null ? `Score ${prospect.score}` : ''].filter(Boolean).join(' · ')
+ // Der Mail-Link trägt den Text aus dem Feld (auch Bearbeitetes), nicht den gespeicherten Entwurf
+ const mail = row.kind === 'mail_antwort' && prospect && prospect.email
+ ? mailtoHref({ to: prospect.email, subject: prospect.name ? `Re: ${prospect.name}` : '', body: text })
+ : ''
+
+ return (
+
+
+ {row.kindLabel}
+ {row.title}
+ {row.stale.stale && {row.stale.label}}
+
+
+ {row.stale.soon &&
{row.stale.label}}
+ {row.claimedByLabel &&
übernommen von {row.claimedByLabel}}
+ {row.ageLabel &&
{row.ageLabel}}
+ {info &&
{info}}
+ {row.sourceHref && (
+
Anzeige öffnen
+ )}
+ {prospect && (
+
+ )}
+
+
+ {item.originalText && (
+
+ Originalanfrage
+ {item.originalText}
+
+ )}
+
+ {row.canSend ? (
+
+ )
+}
+
+// Reiter „Freigabe“: die Einträge des Pults, die ein Mensch absendet, übernimmt oder abschließt. Nichts hier versendet selbst etwas:
+// „Kopieren & öffnen“ bringt die Antwort in die Zwischenablage und öffnet die Anzeige, abgeschickt wird dort von Hand.
+// me = { id, name } des Angemeldeten.
+export default function PultList({ api, me }) {
+ const [filterId, setFilterId] = useState('offen')
+ const [drafts, setDrafts] = useState({}) // bearbeitete Texte je Eintrag; was fehlt, zeigt den Entwurf des Dienstes
+ const [finished, setFinished] = useState('') // Meldung über den zuletzt abgeschlossenen Eintrag (seine Zeile ist dann schon weg)
+ const [, setDetailId] = useState(null) // „Details“ öffnet ab P3-T20 das Interessenten-Panel
+ const filter = PULT_FILTERS.find((f) => f.id === filterId) ?? PULT_FILTERS[0]
+ const { items, loading, error, refresh } = usePultFeed({ api, statuses: filter.statuses })
+ const { employees } = useEmployees()
+ const nowMs = Date.now()
+
+ const chooseFilter = (id) => {
+ setFilterId(id)
+ setFinished('')
+ }
+ const setText = useCallback((id, value) => setDrafts((d) => ({ ...d, [id]: value })), [])
+ const onFinished = useCallback((id, message) => {
+ setFinished(message)
+ setDrafts((d) => {
+ const { [id]: _gone, ...rest } = d
+ return rest
+ })
+ }, [])
+
+ const rows = items.map((item) => ({ item, row: rowModel(item, { me, nowMs, employees }) }))
+
+ let body
+ if (loading) {
+ body = Wird geladen …
+ } else if (rows.length === 0 && error) {
+ body = (
+ {error}
}
+ action={}
+ />
+ )
+ } else if (rows.length === 0) {
+ const empty = EMPTY[filter.id] ?? EMPTY.offen
+ body = {empty.hint}} />
+ } else {
+ body = (
+
+ {rows.map(({ item, row }) => (
+
+ ))}
+
+ )
+ }
+
+ return (
+ <>
+
+
+ {PULT_FILTERS.map((f) => (
+
+ ))}
+
+
+ {finished && {finished}
}
+ {error && rows.length > 0 && Verbindung gestört: {error} Es wird weiter versucht.
}
+ {body}
+ >
+ )
+}
diff --git a/src/hooks/usePultFeed.js b/src/hooks/usePultFeed.js
new file mode 100644
index 0000000..e975d17
--- /dev/null
+++ b/src/hooks/usePultFeed.js
@@ -0,0 +1,65 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+
+// Die Liste des Pults wird bei sichtbarem Tab alle 30 s neu geholt
+const POLL_MS = 30000
+const UNKNOWN_ERROR = 'Unbekannter Fehler'
+
+const isVisible = () => document.visibilityState === 'visible'
+
+// { items, loading, error, refresh }: Pult-Einträge von der Dienst-API (api.pult). `statuses` ist die Komma-Liste eines Filters aus
+// PULT_FILTERS (ein Text, kein Array: er ist der Schlüssel des Abrufs). Beim Start und bei jedem Wechsel des Filters wird sofort
+// geladen, danach alle 30 s und beim Zurückkehren in den Tab (visibilitychange), jeweils nur bei sichtbarem Tab.
+// items zeigt nie die Einträge eines anderen Filters (loading, bis die erste Antwort für diesen Filter da ist); schlägt ein
+// späterer Abruf fehl, bleiben die letzten Einträge stehen und error ist der deutsche Satz aus der API (sonst null).
+// Nur die jüngste Anfrage zählt: eine späte Antwort (auch ein später Fehler) einer älteren Anfrage, eines früheren Filters
+// oder nach dem Abbau wird verworfen. refresh() holt sofort neu (z. B. nach einer Aktion) und endet, wenn die Antwort verarbeitet ist.
+export function usePultFeed({ api, statuses }) {
+ const key = Array.isArray(statuses) ? statuses.join(',') : String(statuses ?? '')
+ const [state, setState] = useState({ key: null, items: [], error: null })
+ const seqRef = useRef(0)
+ const aliveRef = useRef(true)
+ const loadRef = useRef(null)
+
+ useEffect(() => {
+ aliveRef.current = true
+ return () => { aliveRef.current = false }
+ }, [])
+
+ useEffect(() => {
+ const load = async () => {
+ seqRef.current += 1
+ const mine = seqRef.current
+ try {
+ const data = await api.pult(key)
+ if (!aliveRef.current || mine !== seqRef.current) return
+ setState({ key, items: Array.isArray(data?.items) ? data.items : [], error: null })
+ } catch (err) {
+ if (!aliveRef.current || mine !== seqRef.current) return
+ // die letzten Einträge desselben Filters bleiben stehen, der Fehler kommt dazu
+ setState((prev) => ({ key, items: prev.key === key ? prev.items : [], error: err?.message || UNKNOWN_ERROR }))
+ }
+ }
+ loadRef.current = load
+
+ const timer = window.setInterval(() => { if (isVisible()) load() }, POLL_MS)
+ const onVisibility = () => { if (isVisible()) load() }
+ document.addEventListener('visibilitychange', onVisibility)
+ load()
+ return () => {
+ seqRef.current += 1 // eine noch offene Anfrage dieses Filters zählt nicht mehr
+ loadRef.current = null
+ window.clearInterval(timer)
+ document.removeEventListener('visibilitychange', onVisibility)
+ }
+ }, [api, key])
+
+ const refresh = useCallback(() => (loadRef.current ? loadRef.current() : Promise.resolve()), [])
+
+ const current = state.key === key
+ return {
+ items: current ? state.items : [],
+ loading: !current,
+ error: current ? state.error : null,
+ refresh,
+ }
+}
diff --git a/src/pages/WorkersPage.jsx b/src/pages/WorkersPage.jsx
index 26f6bfd..23f5f03 100644
--- a/src/pages/WorkersPage.jsx
+++ b/src/pages/WorkersPage.jsx
@@ -1,15 +1,17 @@
import { useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { PageHeader, Tabs } from '../components/ui'
+import PultList from '../components/workers/PultList'
import WorkersBoard from '../components/workers/WorkersBoard'
import { useAuth } from '../context/AuthContext'
import { workersApi } from '../lib/workersClient'
import { pickTab } from '../lib/workersView'
import '../styles/workers.css'
-// Reiter der Seite. Später kommen freigabe (P3) und botleads (P6) dazu; die Reiterleiste erscheint erst ab zwei Reitern.
+// Reiter der Seite. Später kommt botleads (P6) dazu; die Reiterleiste erscheint erst ab zwei Reitern.
const WORKERS_TABS = [
{ id: 'hochhaus', label: 'Hochhaus' },
+ { id: 'freigabe', label: 'Freigabe' },
]
const api = workersApi
@@ -17,7 +19,7 @@ const api = workersApi
// Seite „Hustler“ (Dateiname bleibt WorkersPage): Titel, Reiterwahl über ?tab= und der Inhalt des gewählten Reiters.
// Auth und Dienst-API kommen von hier; das Board selbst kennt weder Anmeldung noch Appwrite.
export default function WorkersPage() {
- const { isAdmin } = useAuth()
+ const { isAdmin, user } = useAuth()
const [params, setParams] = useSearchParams()
const active = pickTab(WORKERS_TABS, params.get('tab'))
@@ -35,6 +37,7 @@ export default function WorkersPage() {
setParams({ tab: id })} />
)}
{active.id === 'hochhaus' && }
+ {active.id === 'freigabe' && }
)
}
diff --git a/src/styles/workers.css b/src/styles/workers.css
index 04f01e3..dae8d73 100644
--- a/src/styles/workers.css
+++ b/src/styles/workers.css
@@ -171,6 +171,34 @@
}
.wk-halt .wk-set-title { color: var(--red-fg); }
+/* ---------- Freigabe-Pult ---------- */
+
+.wk-pult-filters { margin-bottom: 16px; }
+.wk-pult-filters ~ .msg { margin-bottom: 12px; }
+
+.wk-pult-list { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
+.wk-pult-row { min-width: 0; padding: 16px 0; border-bottom: 1px solid var(--line); }
+.wk-pult-row .msg { margin-top: 8px; }
+
+.wk-pult-head { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; }
+.wk-pult-title { min-width: 0; font-weight: 600; overflow-wrap: anywhere; }
+.wk-pult-meta {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 2px 16px;
+ margin: 4px 0 12px;
+ font-size: 13px;
+ color: var(--text-2);
+}
+
+.wk-pult-original { margin: 0 0 12px; }
+.wk-pult-original summary { cursor: pointer; color: var(--text-2); font-size: 13px; }
+.wk-pult-original-text { margin: 8px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
+
+.wk-pult-row .wk-pult-draft { display: block; width: 100%; min-height: 140px; margin: 0 0 12px; }
+.wk-pult-row .wk-pult-actions { align-items: center; gap: 8px 16px; }
+
/* ---------- Schmale Bildschirme ---------- */
@media (max-width: 1100px) {
@@ -186,6 +214,10 @@
.wk-zoom { gap: 6px; }
.wk-zoom .btn { min-width: 36px; min-height: 36px; }
.wk-toolbar .btn { min-height: 36px; }
+ /* Pult: Aktionen untereinander, Schaltflächen über die volle Breite */
+ .wk-pult-row { padding: 12px 0; }
+ .wk-pult-row .wk-pult-actions { flex-direction: column; align-items: stretch; gap: 8px; }
+ .wk-pult-row .wk-pult-actions .btn, .wk-pult-row .wk-pult-actions .link-danger { width: 100%; min-height: 40px; justify-content: center; text-align: center; }
}
/* ---------- Weniger Bewegung ---------- */
diff --git a/tests/unit/workersPultUi.test.mjs b/tests/unit/workersPultUi.test.mjs
new file mode 100644
index 0000000..6881979
--- /dev/null
+++ b/tests/unit/workersPultUi.test.mjs
@@ -0,0 +1,359 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import esbuild from 'esbuild'
+
+// Die React-Teile sind unter Node nicht ladbar (JSX, Router): geprüft wird der Quelltext (Muster adminRoute.test.mjs); die Logik
+// steckt in P3-T18. Nur usePultFeed läuft wirklich, mit einem kleinen Hook-Läufer anstelle von React (siehe unten).
+const read = (p) => fs.readFileSync(new URL('../../' + p, import.meta.url), 'utf8')
+
+test('WorkersPage hängt den Reiter freigabe an hochhaus an und zeigt dort die PultList', () => {
+ const s = read('src/pages/WorkersPage.jsx')
+ assert.match(s, /id: 'hochhaus'/); assert.match(s, /id: 'freigabe'/); assert.match(s, /import PultList from/); assert.match(s, /]*api=\{api\}/)
+})
+test('PultList: sichere Links, kein innerHTML, Entwurf bearbeitbar, Filter aus PULT_FILTERS', () => {
+ const s = read('src/components/workers/PultList.jsx')
+ assert.doesNotMatch(s, /dangerouslySetInnerHTML/)
+ assert.match(s, /rel="noopener noreferrer"/); assert.match(s, /PULT_FILTERS/); assert.match(s, /copyAndOpen/)
+ assert.doesNotMatch(s, /readOnly[^>]*wk-pult-draft|wk-pult-draft[^>]*readOnly/)
+})
+test('usePultFeed: 30-s-Takt nur bei sichtbarem Tab', () => {
+ const s = read('src/hooks/usePultFeed.js')
+ assert.match(s, /30000/); assert.match(s, /document\.visibilityState/); assert.match(s, /visibilitychange/)
+})
+test('CSS: wk-pult-Klassen und Handy-Umbruch; jede im JSX benutzte wk-Klasse ist definiert', () => {
+ const css = read('src/styles/workers.css')
+ assert.match(css, /\.wk-pult-row/); assert.match(css, /@media \(max-width: 860px\)[\s\S]*\.wk-pult-row/)
+ for (const f of ['PultList', 'ProspectPanel']) { const p = 'src/components/workers/' + f + '.jsx'; if (fs.existsSync(new URL('../../' + p, import.meta.url))) for (const m of read(p).matchAll(/(? {
+ const s = read('src/components/workers/PultList.jsx')
+ assert.match(s, /api\.pultAction\(row\.id, 'claim'\)/); assert.match(s, /api\.pultAction\(row\.id, 'sent', \{ text \}\)/)
+ assert.match(s, /api\.pultAction\(row\.id, 'discard', \{\}\)/); assert.match(s, /api\.pultAction\(row\.id, 'done'\)/)
+ // Kopieren & öffnen: Text ist der Inhalt des Textfelds (nicht row.copyText), das Öffnen geschieht ohne Referrer und ohne Rückbezug
+ assert.match(s, /copyAndOpen\(\{ text, url: row\.sourceHref, copy: copyText, open: openTab \}\)/)
+ assert.match(s, /window\.open\(url, '_blank', 'noopener,noreferrer'\)/)
+ assert.match(s, /Wirklich verwerfen\?/); assert.match(s, /CONFIRM_MS = 4000/)
+ for (const t of ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Verwerfen', 'Erledigt', 'Originalanfrage', 'Nichts wartet auf dich', 'Details']) assert.ok(s.includes(t), t)
+ assert.match(s, /className=\{`msg \$\{/); assert.match(s, /'ok'/); assert.match(s, /'err'/)
+ // der Mail-Link trägt den bearbeiteten Text, nicht den gespeicherten Entwurf
+ assert.match(s, /mailtoHref\(\{[\s\S]*?body: text \}\)/)
+ // der Text erscheint nur als Textknoten
+ assert.doesNotMatch(s, /innerHTML/)
+})
+
+// ---- usePultFeed läuft wirklich: Mini-Läufer für Hooks statt React, falsche document/window mit von Hand weitergestelltem Takt ----
+
+const FAKE_REACT = `
+let active = null
+export function __mount(hook, getProps) {
+ const run = { hooks: [], index: 0, effects: [], dirty: false, rendering: false, unmounted: false, late: 0, result: undefined }
+ run.render = () => {
+ if (run.rendering) { run.dirty = true; return }
+ run.rendering = true
+ do {
+ run.dirty = false
+ run.index = 0
+ active = run
+ run.result = hook(getProps())
+ active = null
+ for (const effect of run.effects.splice(0)) effect()
+ } while (run.dirty)
+ run.rendering = false
+ }
+ run.render()
+ return {
+ result: () => run.result,
+ rerender: () => run.render(),
+ lateUpdates: () => run.late,
+ unmount: () => {
+ run.unmounted = true
+ for (const h of run.hooks) if (h && typeof h.cleanup === 'function') h.cleanup()
+ },
+ }
+}
+export function useState(initial) {
+ const run = active
+ const i = run.index++
+ if (!run.hooks[i]) run.hooks[i] = { value: typeof initial === 'function' ? initial() : initial }
+ const slot = run.hooks[i]
+ return [slot.value, (next) => {
+ if (run.unmounted) { run.late += 1; return }
+ const value = typeof next === 'function' ? next(slot.value) : next
+ if (Object.is(value, slot.value)) return
+ slot.value = value
+ run.render()
+ }]
+}
+export function useRef(initial) {
+ const run = active
+ const i = run.index++
+ if (!run.hooks[i]) run.hooks[i] = { current: initial }
+ return run.hooks[i]
+}
+export function useCallback(fn, deps) {
+ const run = active
+ const i = run.index++
+ const slot = run.hooks[i]
+ if (slot && deps.every((d, k) => Object.is(d, slot.deps[k]))) return slot.fn
+ run.hooks[i] = { fn, deps }
+ return fn
+}
+export function useEffect(fn, deps) {
+ const run = active
+ const i = run.index++
+ if (!run.hooks[i]) run.hooks[i] = { deps: undefined, cleanup: undefined }
+ const slot = run.hooks[i]
+ if (slot.deps && deps && deps.every((d, k) => Object.is(d, slot.deps[k]))) return
+ run.effects.push(() => {
+ if (typeof slot.cleanup === 'function') slot.cleanup()
+ slot.deps = deps
+ slot.cleanup = fn()
+ })
+}
+`
+
+async function loadFeed() {
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
+ const fakeReact = {
+ name: 'fake-react',
+ setup(build) {
+ build.onResolve({ filter: /^react$/ }, () => ({ path: 'react', namespace: 'fake-react' }))
+ build.onLoad({ filter: /.*/, namespace: 'fake-react' }, () => ({ contents: FAKE_REACT, loader: 'js' }))
+ },
+ }
+ const out = await esbuild.build({
+ stdin: { contents: "export { usePultFeed } from './src/hooks/usePultFeed.js'\nexport { __mount } from 'react'", resolveDir: root, loader: 'js' },
+ bundle: true, format: 'esm', write: false, platform: 'neutral', plugins: [fakeReact],
+ })
+ return import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`)
+}
+
+// document und window nur für die Dauer eines Tests: Takt (setInterval) und visibilitychange werden von Hand ausgelöst
+function fakeBrowser() {
+ const listeners = new Map()
+ const timers = new Map()
+ let nextTimer = 1
+ const doc = {
+ visibilityState: 'visible',
+ addEventListener: (type, fn) => listeners.set(type, [...(listeners.get(type) ?? []), fn]),
+ removeEventListener: (type, fn) => listeners.set(type, (listeners.get(type) ?? []).filter((f) => f !== fn)),
+ }
+ const win = {
+ setInterval: (fn, ms) => { const id = nextTimer++; timers.set(id, { fn, ms }); return id },
+ clearInterval: (id) => { timers.delete(id) },
+ }
+ const saved = { document: globalThis.document, window: globalThis.window }
+ globalThis.document = doc
+ globalThis.window = win
+ return {
+ doc,
+ timers,
+ listenerCount: (type) => (listeners.get(type) ?? []).length,
+ fire: (type) => { for (const fn of listeners.get(type) ?? []) fn() },
+ tick: () => { for (const t of [...timers.values()]) t.fn() },
+ restore: () => { globalThis.document = saved.document; globalThis.window = saved.window },
+ }
+}
+
+// api.pult-Attrappe: jeder Aufruf ist ein offenes Versprechen, das der Test selbst auflöst
+function fakeApi() {
+ const calls = []
+ return {
+ calls,
+ pult: (status) => new Promise((resolve, reject) => { calls.push({ status, resolve, reject }) }),
+ }
+}
+const settle = () => new Promise((resolve) => setImmediate(resolve))
+const itemsOf = (...ids) => ({ items: ids.map((id) => ({ $id: id })) })
+
+test('usePultFeed: lädt beim Start, zeigt die Einträge, deutscher Fehler bleibt neben den letzten Einträgen stehen', async () => {
+ const { usePultFeed, __mount } = await loadFeed()
+ const b = fakeBrowser()
+ try {
+ const api = fakeApi()
+ const hook = __mount(() => usePultFeed({ api, statuses: 'offen,in_arbeit' }), () => ({}))
+ assert.deepEqual(api.calls.map((c) => c.status), ['offen,in_arbeit'])
+ assert.deepEqual([hook.result().loading, hook.result().items, hook.result().error], [true, [], null])
+
+ api.calls[0].resolve(itemsOf('a', 'b')); await settle()
+ assert.deepEqual([hook.result().loading, hook.result().items.map((i) => i.$id), hook.result().error], [false, ['a', 'b'], null])
+
+ // Takt: ein Abruf schlägt fehl, die Einträge bleiben, der Satz des Dienstes erscheint
+ assert.equal([...b.timers.values()][0].ms, 30000)
+ b.tick()
+ api.calls[1].reject(Object.assign(new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'), { status: 0 })); await settle()
+ assert.deepEqual([hook.result().items.map((i) => i.$id), hook.result().error], [['a', 'b'], 'Der Hustler-Dienst ist gerade nicht erreichbar.'])
+ // der nächste Erfolg räumt den Fehler weg
+ b.tick(); api.calls[2].resolve(itemsOf('a')); await settle()
+ assert.deepEqual([hook.result().items.map((i) => i.$id), hook.result().error], [['a'], null])
+ hook.unmount()
+ } finally { b.restore() }
+})
+
+test('usePultFeed: Takt und Neuladen nur bei sichtbarem Tab; zurück im Vordergrund wird sofort geladen', async () => {
+ const { usePultFeed, __mount } = await loadFeed()
+ const b = fakeBrowser()
+ try {
+ const api = fakeApi()
+ const hook = __mount(() => usePultFeed({ api, statuses: 'offen' }), () => ({}))
+ api.calls[0].resolve(itemsOf('a')); await settle()
+ b.doc.visibilityState = 'hidden'
+ b.tick(); b.fire('visibilitychange') // versteckt: weder Takt noch Wechsel-Ereignis rufen den Dienst
+ assert.equal(api.calls.length, 1)
+ b.doc.visibilityState = 'visible'; b.fire('visibilitychange')
+ assert.equal(api.calls.length, 2) // zurück im Vordergrund: sofort
+ b.tick()
+ assert.equal(api.calls.length, 3)
+ hook.unmount()
+ assert.equal(b.timers.size, 0); assert.equal(b.listenerCount('visibilitychange'), 0) // aufgeräumt
+ } finally { b.restore() }
+})
+
+test('usePultFeed: nur die letzte Anfrage zählt (späte alte Antwort wird verworfen); refresh() holt sofort neu', async () => {
+ const { usePultFeed, __mount } = await loadFeed()
+ const b = fakeBrowser()
+ try {
+ const api = fakeApi()
+ const hook = __mount(() => usePultFeed({ api, statuses: 'offen' }), () => ({}))
+ const first = api.calls[0]
+ const refreshed = hook.result().refresh() // zweite Anfrage, während die erste noch läuft
+ assert.equal(api.calls.length, 2)
+ api.calls[1].resolve(itemsOf('neu')); await refreshed; await settle()
+ first.resolve(itemsOf('alt')); await settle() // die ältere Antwort kommt zuletzt und darf nichts überschreiben
+ assert.deepEqual(hook.result().items.map((i) => i.$id), ['neu'])
+ // ein später Fehler der alten Anfrage zählt ebenfalls nicht
+ b.tick(); const late = api.calls[2]
+ b.tick(); api.calls[3].resolve(itemsOf('neuer')); await settle()
+ late.reject(new Error('zu spät')); await settle()
+ assert.deepEqual([hook.result().items.map((i) => i.$id), hook.result().error], [['neuer'], null])
+ hook.unmount()
+ } finally { b.restore() }
+})
+
+test('usePultFeed: Wechsel des Filters zeigt nie die Einträge des alten Filters und verwirft dessen späte Antwort', async () => {
+ const { usePultFeed, __mount } = await loadFeed()
+ const b = fakeBrowser()
+ try {
+ const api = fakeApi()
+ let statuses = 'offen,in_arbeit'
+ const hook = __mount(() => usePultFeed({ api, statuses }), () => ({}))
+ api.calls[0].resolve(itemsOf('a')); await settle()
+ statuses = 'veraltet'; hook.rerender()
+ assert.equal(api.calls.at(-1).status, 'veraltet')
+ assert.deepEqual([hook.result().loading, hook.result().items], [true, []])
+ statuses = 'gesendet,verworfen,erledigt'; hook.rerender() // noch einmal gewechselt, bevor 'veraltet' antwortet
+ api.calls.find((c) => c.status === 'veraltet').resolve(itemsOf('v')); await settle()
+ assert.deepEqual([hook.result().loading, hook.result().items], [true, []])
+ api.calls.at(-1).resolve(itemsOf('e')); await settle()
+ assert.deepEqual([hook.result().loading, hook.result().items.map((i) => i.$id)], [false, ['e']])
+ hook.unmount()
+ } finally { b.restore() }
+})
+
+test('usePultFeed: nach dem Abbau setzt eine späte Antwort nichts mehr', async () => {
+ const { usePultFeed, __mount } = await loadFeed()
+ const b = fakeBrowser()
+ try {
+ const api = fakeApi()
+ const hook = __mount(() => usePultFeed({ api, statuses: 'offen' }), () => ({}))
+ hook.unmount()
+ api.calls[0].resolve(itemsOf('a')); await settle()
+ assert.equal(hook.lateUpdates(), 0)
+ // auch ein später Fehler setzt nichts
+ const second = fakeApi()
+ const again = __mount(() => usePultFeed({ api: second, statuses: 'offen' }), () => ({}))
+ again.unmount()
+ second.calls[0].reject(new Error('zu spät')); await settle()
+ assert.equal(again.lateUpdates(), 0)
+ } finally { b.restore() }
+})
+
+// ---- PultList wird mit echtem React auf einen Text gerendert (renderToStaticMarkup); der Feed, die Mitarbeiterliste und die Zwischenablage sind Attrappen ----
+
+const STUBS = {
+ usePultFeed: 'export function usePultFeed() { return globalThis.__pultFeed }',
+ useEmployees: 'export function useEmployees() { return { employees: globalThis.__pultEmployees ?? [] } }',
+ CopyableCredential: 'export async function copyText() {}',
+}
+
+async function loadList() {
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
+ const stubs = {
+ name: 'pult-stubs',
+ setup(build) {
+ build.onResolve({ filter: /hooks\/(usePultFeed|useEmployees)$|CopyableCredential$/ }, (a) => ({ path: a.path.split('/').pop(), namespace: 'pult-stub' }))
+ build.onLoad({ filter: /.*/, namespace: 'pult-stub' }, (a) => ({ contents: STUBS[a.path], loader: 'js' }))
+ },
+ }
+ const out = await esbuild.build({
+ stdin: {
+ contents: "import { createElement } from 'react'\nimport { renderToStaticMarkup } from 'react-dom/server'\nimport PultList from './src/components/workers/PultList.jsx'\nexport const render = (props) => renderToStaticMarkup(createElement(PultList, props))",
+ resolveDir: root, loader: 'js',
+ },
+ bundle: true, format: 'esm', platform: 'node', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [stubs],
+ // react-dom/server ist CommonJS und holt sich Node-Module über require: im Data-URL-Modul gibt es keins
+ banner: { js: "import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(process.cwd() + '/');" },
+ })
+ return import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`)
+}
+
+test('PultList (gerendert): Aktionen je Art und Status, Fremdtext nur als Text, sichere Links, Mail-Link mit genau einer Adresse', async () => {
+ const { render } = await loadList()
+ const now = Date.now()
+ const iso = (ms) => new Date(ms).toISOString()
+ const me = { id: 'u1', name: 'Justin' }
+ const show = (items, extra = {}) => {
+ globalThis.__pultFeed = { items, loading: false, error: null, refresh: async () => {}, ...extra }
+ globalThis.__pultEmployees = [{ userId: 'u2', displayName: 'Kenso' }]
+ return render({ api: {}, me })
+ }
+ const base = { $id: 'a', kind: 'plattform_antwort', status: 'offen', title: 'Suche Webdesigner', draft: 'Hallo', createdAt: iso(now - 3 * 3600e3), sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein', city: 'Namborn', postalCode: '66640', score: 72 } }
+ try {
+ // offene Plattform-Antwort: alle vier Aktionen, Link mit rel, Kurzinfo, Details; das Textfeld trägt den Entwurf
+ let html = show([{ ...base, originalText: 'Original
', draft: 'Hallo ', title: 'T x' }])
+ for (const t of ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Verwerfen', 'Details', '66640 Namborn · Score 72', 'vor 3 Std.']) assert.ok(html.includes(t), t)
+ const erledigt = '' // der Filter „Erledigt“ steht ebenfalls auf der Seite: nur den Knopf der Zeile meinen
+ assert.ok(!html.includes(erledigt)); assert.match(html, //)
+ assert.match(html, /