diff --git a/API.md b/API.md index bd9fd61..70baa3d 100644 --- a/API.md +++ b/API.md @@ -46,7 +46,7 @@ Zwei Schluesseltypen, beide statisch (F14): | Header | Verwendet fuer | Herkunft | |---|---|---| -| `X-Admin-Key` | `POST /api/v1/agents` (Registrierung neuer Agenten), `POST`/`PATCH`/`DELETE /api/v1/repo-configs` (Projekte des Codeberg-Sync) | Umgebungsvariable `MONITOR_ADMIN_API_KEY` | +| `X-Admin-Key` | `POST /api/v1/agents` (Registrierung neuer Agenten), `POST`/`PATCH`/`DELETE /api/v1/repo-configs` (Projekte des Codeberg-Sync), `POST /api/v1/tickets/resync` (Board neu einlesen) | Umgebungsvariable `MONITOR_ADMIN_API_KEY` | | `X-Api-Key` | Alle anderen schreibenden Endpunkte | Wird bei der Agenten-Registrierung einmalig zurueckgegeben | Ausnahme ist der Codeberg-Webhook (`POST /api/v1/webhooks/codeberg`): Codeberg kennt unsere @@ -216,6 +216,22 @@ Auth: `X-Api-Key`. Erlaubte Felder im Body: `column`, `title`, `description`, `t Response `200` mit dem aktualisierten Ticket, `404` falls unbekannt, `422` bei ungueltiger `column`. +### `POST /api/v1/tickets/resync` — Board komplett neu einlesen + +Auth: `X-Admin-Key`. Issue #51: manueller "Reload"-Knopf am Kanban-Board im Frontend fuer den +Fall, dass Tickets (geloescht, verschoben, o.ae.) vom regulaeren Codeberg-Sync nicht mehr +bereinigt werden. Liest synchron alle konfigurierten Codeberg-Projekte neu ein (siehe +[Codeberg-Sync](#codeberg-sync-backlogdone)) und entfernt dabei je Projekt zusaetzlich Tickets, +deren Issue nicht mehr unter den offenen/geschlossenen Issues auftaucht - selbst wenn sie +manuell auf eine nicht vom Sync verwaltete Spalte wie `fertig` bewegt wurden. Tickets ohne +Projektbezug (z.B. per API angelegt) bleiben unberuehrt. Anders als der Webhook-Trigger kein +asynchroner Anstoss des sync-Containers, sondern derselbe Ablauf direkt im api-Container, damit +die Antwortzeit die tatsaechliche Sync-Dauer widerspiegelt. + +Response `200` mit `{ "tickets": [...] }` (neuer Board-Zustand), `502` falls die Codeberg-API +nicht erreichbar war (bereits verarbeitete Projekte bleiben dann auf dem neuen Stand, noch +nicht erreichte auf dem alten - der naechste periodische Sync-Lauf oder Webhook gleicht das an). + ## Events `severity`: `warning` (gelb) oder `error` (rot). Neuestes Event zuerst (F5). diff --git a/api/src/Application.php b/api/src/Application.php index 42f70c9..3a964c5 100644 --- a/api/src/Application.php +++ b/api/src/Application.php @@ -31,6 +31,8 @@ use Monitor\Api\Repository\LegacyStatusFileReader; use Monitor\Api\Repository\RepoConfigRepository; use Monitor\Api\Repository\SystemRepository; use Monitor\Api\Repository\TicketRepository; +use Monitor\Api\Sync\CodebergSyncService; +use Monitor\Api\Sync\CurlCodebergClient; use Monitor\Api\Sync\SyncTrigger; use Throwable; @@ -61,7 +63,15 @@ final class Application $this->webhookAuth = new WebhookAuthenticator($config->codebergWebhookSecret()); $agentController = new AgentController($agents); - $ticketController = new TicketController($tickets); + // Issue #51: der "Board neu einlesen"-Knopf synchronisiert direkt im api-Container, + // unabhaengig vom periodisch/webhook-getriebenen sync-Container (bin/sync-codeberg.php) + // - beide teilen sich denselben CodebergSyncService, nur der Aufloeser ist verschieden. + $syncService = new CodebergSyncService( + new CurlCodebergClient($config->codebergApiBase(), $config->codebergToken()), + $tickets, + $repoConfigs, + ); + $ticketController = new TicketController($tickets, $syncService); $eventController = new EventController($events); $systemController = new SystemController($systems, new LegacyStatusFileReader(), $config->statusFilePath()); $kpiController = new KpiController($kpis); @@ -78,6 +88,10 @@ final class Application $this->router->add('GET', '/api/v1/tickets', fn (Request $r, array $p) => $ticketController->index($r)); $this->router->add('POST', '/api/v1/tickets', fn (Request $r, array $p) => $ticketController->create($r), 'agent'); $this->router->add('PATCH', '/api/v1/tickets/{id}', fn (Request $r, array $p) => $ticketController->update($r, $p), 'agent'); + // Issue #51: schreibend wie die Projekte-Verwaltung (Admin-Key), da ein manueller + // Voll-Reset des Kanban-Boards potenziell teuer ist (mehrere Codeberg-Requests) und + // kein normaler Agenten-Vorgang. + $this->router->add('POST', '/api/v1/tickets/resync', fn (Request $r, array $p) => $ticketController->resync($r), 'admin'); $this->router->add('GET', '/api/v1/events', fn (Request $r, array $p) => $eventController->index($r)); $this->router->add('POST', '/api/v1/events', fn (Request $r, array $p) => $eventController->create($r), 'agent'); diff --git a/api/src/Controller/TicketController.php b/api/src/Controller/TicketController.php index 28dcbde..cc98fa7 100644 --- a/api/src/Controller/TicketController.php +++ b/api/src/Controller/TicketController.php @@ -8,11 +8,15 @@ use InvalidArgumentException; use Monitor\Api\Http\Request; use Monitor\Api\Http\Response; use Monitor\Api\Repository\TicketRepository; +use Monitor\Api\Sync\CodebergSyncService; +use RuntimeException; final class TicketController { - public function __construct(private readonly TicketRepository $tickets) - { + public function __construct( + private readonly TicketRepository $tickets, + private readonly CodebergSyncService $syncService, + ) { } public function index(Request $request): Response @@ -52,4 +56,23 @@ final class TicketController return Response::json(['ticket' => $ticket]); } + + /** + * Issue #51: "Board neu einlesen"-Knopf im Frontend - liest alle konfigurierten + * Codeberg-Projekte synchron neu ein und bereinigt dabei Tickets, die dort nicht mehr + * auftauchen (siehe CodebergSyncService::resync()), statt (wie der Webhook-Trigger) nur + * asynchron einen Lauf des sync-Containers anzustossen. Synchron bewusst so gewaehlt, weil + * das Frontend die tatsaechliche Laufzeit sichtbar machen soll (PO steht am Board, bis + * diese Anfrage antwortet). + */ + public function resync(Request $request): Response + { + try { + $this->syncService->resync(); + } catch (RuntimeException $e) { + return Response::error('Codeberg-Sync fehlgeschlagen: ' . $e->getMessage(), 502); + } + + return Response::json(['tickets' => $this->tickets->all()]); + } } diff --git a/api/src/Repository/TicketRepository.php b/api/src/Repository/TicketRepository.php index e969c5b..d57babb 100644 --- a/api/src/Repository/TicketRepository.php +++ b/api/src/Repository/TicketRepository.php @@ -210,6 +210,34 @@ final class TicketRepository $stmt->execute(['repo' => $repo]); } + /** + * Entfernt Tickets eines Projekts, deren external_ref beim letzten Neueinlesen nicht mehr + * gesehen wurde (Issue #51: manueller "Board neu einlesen"-Knopf, CodebergSyncService:: + * resync()). Anders als der periodische Sync (reines Upsert, ruehrt bei Codeberg + * verschwundene Issues nie an, siehe upsertFromCodeberg()) bereinigt dieser Aufruf gezielt + * genau solche Karteileichen, selbst wenn sie manuell auf eine nicht vom Sync verwaltete + * Spalte (z.B. "fertig") bewegt wurden. Tickets ohne Projektbezug (repo = NULL, z.B. per + * API angelegt) bleiben unberuehrt, weil dieser Aufruf nur je konfiguriertem Projekt + * erfolgt. + * + * @param string[] $keepExternalRefs - external_refs, die beim aktuellen Lauf tatsaechlich + * bei Codeberg gesehen wurden. + */ + public function deleteOrphansForRepo(string $repo, array $keepExternalRefs): void + { + if ($keepExternalRefs === []) { + $this->deleteByRepo($repo); + + return; + } + + $placeholders = implode(', ', array_fill(0, count($keepExternalRefs), '?')); + $stmt = $this->db->pdo()->prepare( + "DELETE FROM tickets WHERE repo = ? AND external_ref NOT IN ($placeholders)" + ); + $stmt->execute([$repo, ...$keepExternalRefs]); + } + /** * Billiger "Fingerabdruck" (Anzahl + juengster Aenderungszeitpunkt) fuer den SSE-Endpoint * (Realtime\ChangeFeed, F13) - aendert sich bei jedem Anlegen/Verschieben eines Tickets. diff --git a/api/src/Sync/CodebergSyncService.php b/api/src/Sync/CodebergSyncService.php index 0db9880..6f1fc3e 100644 --- a/api/src/Sync/CodebergSyncService.php +++ b/api/src/Sync/CodebergSyncService.php @@ -52,25 +52,63 @@ final class CodebergSyncService // Issue #34: Tickets aus zwischenzeitlich entfernten oder umbenannten Projekten werden // oben nicht mehr angefasst und wuerden sonst dauerhaft in der alten, nicht mehr // zutreffenden Projektfarbe angezeigt. - $this->tickets->clearColorForReposNotIn(array_map( - static fn (array $repoConfig): string => $repoConfig['owner'] . '/' . $repoConfig['repo'], - $repoConfigs - )); + $this->tickets->clearColorForReposNotIn($this->repoSlugs($repoConfigs)); } - private function syncRepo(string $owner, string $repo, string $color): void + /** + * Issue #51: manueller "Board neu einlesen"-Knopf im Frontend. Anders als sync() (reines + * Upsert, laesst zwischenzeitlich bei Codeberg geloeschte oder anderweitig verschwundene + * Issues als Karteileichen im Kanban stehen) entfernt resync() je Projekt zusaetzlich alle + * Tickets, deren external_ref beim Lauf nicht (mehr) unter den offenen/geschlossenen + * Issues auftauchte - selbst wenn sie zuvor manuell auf eine nicht vom Sync verwaltete + * Spalte (z.B. "fertig") bewegt wurden. Tickets ohne Projektbezug (z.B. per API angelegt) + * bleiben unberuehrt, ebenso wie Tickets zu Issues, die bei Codeberg weiterhin existieren + * (siehe upsertFromCodeberg() - deren manuell gesetzte Spalte bleibt erhalten). + */ + public function resync(): void + { + $repoConfigs = $this->repoConfigs->all(); + + foreach ($repoConfigs as $repoConfig) { + $slug = $repoConfig['owner'] . '/' . $repoConfig['repo']; + $seenExternalRefs = $this->syncRepo($repoConfig['owner'], $repoConfig['repo'], $repoConfig['color']); + $this->tickets->deleteOrphansForRepo($slug, $seenExternalRefs); + } + + $this->tickets->clearColorForReposNotIn($this->repoSlugs($repoConfigs)); + } + + /** @return string[] external_refs, die bei diesem Lauf tatsaechlich bei Codeberg gesehen wurden. */ + private function syncRepo(string $owner, string $repo, string $color): array { $slug = $owner . '/' . $repo; + $seenExternalRefs = []; foreach ($this->client->listIssues($owner, $repo, 'open') as $issue) { $this->tickets->upsertFromCodeberg( $this->mapIssue($issue, $this->columnForOpenIssue($issue), $slug, $color) ); + $seenExternalRefs[] = (string) $issue['number']; } foreach ($this->client->listIssues($owner, $repo, 'closed') as $issue) { $this->tickets->upsertFromCodeberg($this->mapIssue($issue, 'done', $slug, $color)); + $seenExternalRefs[] = (string) $issue['number']; } + + return $seenExternalRefs; + } + + /** + * @param array $repoConfigs + * @return string[] + */ + private function repoSlugs(array $repoConfigs): array + { + return array_map( + static fn (array $repoConfig): string => $repoConfig['owner'] . '/' . $repoConfig['repo'], + $repoConfigs + ); } /** diff --git a/api/tests/Unit/Controller/TicketControllerTest.php b/api/tests/Unit/Controller/TicketControllerTest.php index ec62134..616818a 100644 --- a/api/tests/Unit/Controller/TicketControllerTest.php +++ b/api/tests/Unit/Controller/TicketControllerTest.php @@ -8,8 +8,29 @@ use Monitor\Api\Controller\TicketController; use Monitor\Api\Db\Database; use Monitor\Api\Http\NotFoundException; use Monitor\Api\Http\Request; +use Monitor\Api\Repository\RepoConfigRepository; use Monitor\Api\Repository\TicketRepository; +use Monitor\Api\Sync\CodebergClient; +use Monitor\Api\Sync\CodebergSyncService; use PHPUnit\Framework\TestCase; +use RuntimeException; + +/** Testdouble fuer die Resync-Tests unten - liefert nie echte Issues, kann aber auf Wunsch fehlschlagen. */ +final class StubCodebergClient implements CodebergClient +{ + public function __construct(private readonly bool $shouldFail = false) + { + } + + public function listIssues(string $owner, string $repo, string $state): array + { + if ($this->shouldFail) { + throw new RuntimeException('Codeberg-API nicht erreichbar.'); + } + + return []; + } +} final class TicketControllerTest extends TestCase { @@ -19,7 +40,10 @@ final class TicketControllerTest extends TestCase protected function setUp(): void { $this->repository = new TicketRepository(Database::inMemory()); - $this->controller = new TicketController($this->repository); + $this->controller = new TicketController( + $this->repository, + new CodebergSyncService(new StubCodebergClient(), $this->repository, new RepoConfigRepository(Database::inMemory())) + ); } public function testIndexReturnsAllTickets(): void @@ -76,4 +100,35 @@ final class TicketControllerTest extends TestCase ['id' => '999'] ); } + + public function testResyncRemovesOrphanedTicketOfConfiguredRepoAndReturnsNewBoardState(): void + { + // 'fds2610/monitor' ist eines der per Migration seedenden Default-Projekte (siehe + // 003_repo_configs.sql) - der StubCodebergClient liefert dafuer keine Issues, das + // Ticket ist also eine Karteileiche im Sinne von Issue #51. + $this->repository->create([ + 'title' => 'Karteileiche', + 'repo' => 'fds2610/monitor', + 'external_ref' => '1', + 'source' => 'codeberg', + ]); + + $response = $this->controller->resync(Request::create('POST', '/api/v1/tickets/resync')); + + self::assertSame(200, $response->status); + self::assertSame([], $response->body()['tickets']); + } + + public function testResyncReturns502WhenCodebergUnreachable(): void + { + $repository = new TicketRepository(Database::inMemory()); + $controller = new TicketController( + $repository, + new CodebergSyncService(new StubCodebergClient(shouldFail: true), $repository, new RepoConfigRepository(Database::inMemory())) + ); + + $response = $controller->resync(Request::create('POST', '/api/v1/tickets/resync')); + + self::assertSame(502, $response->status); + } } diff --git a/api/tests/Unit/Sync/CodebergSyncServiceTest.php b/api/tests/Unit/Sync/CodebergSyncServiceTest.php index c665fe9..02e8454 100644 --- a/api/tests/Unit/Sync/CodebergSyncServiceTest.php +++ b/api/tests/Unit/Sync/CodebergSyncServiceTest.php @@ -209,6 +209,49 @@ final class CodebergSyncServiceTest extends TestCase self::assertSame('in_arbeit', $tickets->findByExternalRef('1', self::MONITOR_SLUG)['column_name']); } + public function testResyncRemovesTicketsThatNoLongerExistAtCodeberg(): void + { + $tickets = new TicketRepository(Database::inMemory()); + $repoConfigs = self::repoConfigsFor([self::MONITOR_REPO]); + $client = self::clientForMonitor([ + 'open' => [['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => []]], + 'closed' => [], + ]); + (new CodebergSyncService($client, $tickets, $repoConfigs))->sync(); + // Manuell auf "fertig" bewegt, damit sync() es normalerweise unangetastet liesse + // (siehe testSyncDoesNotResetManuallyMovedTicket) - Issue #51 ist genau fuer solche + // liegen gebliebenen Karteileichen gedacht, die der normale Sync nicht mehr anfasst. + $orphanTicket = $tickets->create(['title' => 'Verwaist', 'repo' => self::MONITOR_SLUG, 'external_ref' => '2', 'column' => 'fertig']); + + // Issue 1 wurde bei Codeberg inzwischen komplett geloescht (taucht weder offen noch + // geschlossen auf) - genau der Fall aus Issue #51, den der normale Sync nicht bereinigt. + (new CodebergSyncService(new FakeCodebergClient([self::MONITOR_SLUG => ['open' => [], 'closed' => []]]), $tickets, $repoConfigs))->resync(); + + self::assertNull($tickets->findByExternalRef('1', self::MONITOR_SLUG)); + self::assertNull($tickets->find((int) $orphanTicket['id'])); + } + + public function testResyncKeepsManuallyMovedTicketWhoseIssueStillExistsAtCodeberg(): void + { + $tickets = new TicketRepository(Database::inMemory()); + $repoConfigs = self::repoConfigsFor([self::MONITOR_REPO]); + $client = self::clientForMonitor([ + 'open' => [['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => []]], + 'closed' => [], + ]); + (new CodebergSyncService($client, $tickets, $repoConfigs))->sync(); + $ticket = $tickets->findByExternalRef('1', self::MONITOR_SLUG); + $tickets->update((int) $ticket['id'], ['column' => 'fertig']); + + // Dasselbe Issue existiert bei Codeberg weiterhin (offen) - resync() darf es trotz + // manuell gesetzter, nicht vom Sync verwalteter Spalte nicht als Karteileiche werfen. + (new CodebergSyncService($client, $tickets, $repoConfigs))->resync(); + + $stillFertig = $tickets->findByExternalRef('1', self::MONITOR_SLUG); + self::assertNotNull($stillFertig); + self::assertSame('fertig', $stillFertig['column_name']); + } + public function testSyncClosesTicketFromAnyAutomatedColumnIntoDone(): void { $tickets = new TicketRepository(Database::inMemory()); diff --git a/html/css/office.css b/html/css/office.css index 9c84f0c..7aaf2fb 100644 --- a/html/css/office.css +++ b/html/css/office.css @@ -428,6 +428,45 @@ header.office-header h1 { stroke: #0284c7; } +/* Reload-Symbol neben der Board-Ueberschrift (Issue #51): stoesst ein komplettes + Neueinlesen des Kanban-Boards an, analog zum Zahnrad am PO-Platz. */ +.board-reload-hint-bg { + fill: #ffffff; + stroke: #94a3b8; + stroke-width: 1.5; + opacity: 0.85; +} + +.board-reload-hint-arrow { + fill: none; + stroke: #475569; + stroke-width: 2; + stroke-linecap: round; +} + +.board-reload-hint-arrowhead { + fill: #475569; +} + +.board-reload-hint-hit { + fill: transparent; + pointer-events: all; +} + +.board-reload-hint:hover .board-reload-hint-bg { + fill: #e0f2fe; + stroke: #0284c7; + opacity: 1; +} + +.board-reload-hint:hover .board-reload-hint-arrow { + stroke: #0284c7; +} + +.board-reload-hint:hover .board-reload-hint-arrowhead { + fill: #0284c7; +} + .just-updated .board-frame, .just-updated .monitor-frame { animation: office-flash 0.9s ease-out; diff --git a/html/js/office-api.js b/html/js/office-api.js index 0542491..c167daf 100644 --- a/html/js/office-api.js +++ b/html/js/office-api.js @@ -142,3 +142,16 @@ export async function updateRepoConfig(id, data, adminKey) { export async function deleteRepoConfig(id, adminKey) { await adminJson("DELETE", `/repo-configs/${encodeURIComponent(id)}`, undefined, adminKey); } + +/** + * Issue #51: "Board neu einlesen"-Knopf am Kanban-Board - loescht serverseitig alle Tickets + * und liest alle konfigurierten Codeberg-Projekte synchron neu ein. Braucht wie die + * Projekte-Verwaltung den Admin-Key, weil ein Voll-Reset des Boards potenziell teuer + * (mehrere Codeberg-Requests) und kein normaler Agenten-Vorgang ist. + * + * @returns {Promise>} der neue Board-Zustand. + */ +export async function resyncTickets(adminKey) { + const { tickets } = await adminJson("POST", "/tickets/resync", undefined, adminKey); + return tickets; +} diff --git a/html/js/office-render.js b/html/js/office-render.js index 9d0aaab..9612e39 100644 --- a/html/js/office-render.js +++ b/html/js/office-render.js @@ -67,6 +67,7 @@ import { createRepoConfig, updateRepoConfig, deleteRepoConfig, + resyncTickets, getStoredAdminKey, setStoredAdminKey, } from "./office-api.js"; @@ -208,14 +209,17 @@ function renderStackBadge(x, y, count, stackColors = []) { * @param {Record} ticketCounts * @param {Record>} groupedTickets - liefert die Projektfarbe je Zettel. * @param {(columnKey: string) => void} onColumnClick + * @param {() => void} onReloadClick - Issue #51: Klick auf das Reload-Symbol neben der + * Ueberschrift stoesst ein komplettes Neueinlesen des Boards an. */ -function renderBoard(ticketCounts, groupedTickets, onColumnClick) { +function renderBoard(ticketCounts, groupedTickets, onColumnClick, onReloadClick) { const columns = buildBoardColumnLayout(ticketCounts, groupedTickets); const g = el("g", { class: "board" }); g.appendChild(el("rect", { ...BOARD_FRAME, rx: 10, class: "board-frame" })); g.appendChild( text("Kanban-Board", { x: BOARD_FRAME.x + 16, y: BOARD_FRAME.y - 14, class: "wall-label" }) ); + g.appendChild(renderBoardReloadHint(onReloadClick)); const columnWidth = BOARD_FRAME.w / columns.length; @@ -279,6 +283,42 @@ function renderBoard(ticketCounts, groupedTickets, onColumnClick) { return g; } +// Issue #51: das Reload-Symbol sitzt neben der Board-Ueberschrift, analog zum Zahnrad am +// PO-Platz (renderPoConfigHint) - selbe "aus Grundformen statt Icon-Bibliothek gebaute +// Silhouette" plus Hover-Hervorhebung, damit beide Hinweise als ein visuelles System wirken. +function renderBoardReloadHint(onClick) { + const cx = BOARD_FRAME.x + 210; + const cy = BOARD_FRAME.y - 21; + const g = el("g", { class: "board-reload-hint clickable", transform: `translate(${cx}, ${cy})` }); + + const title = document.createElementNS(SVG_NS, "title"); + title.textContent = "Kanban-Board komplett neu einlesen"; + g.appendChild(title); + + g.appendChild(el("circle", { cx: 0, cy: 0, r: 13, class: "board-reload-hint-bg" })); + // Kreisbogen mit Luecke (grosser Bogen, eine Richtung) plus Pfeilspitze an dessen Anfang - + // ergibt in dieser Groesse ein erkennbares "Reload"-Symbol. + g.appendChild( + el("path", { + d: "M 6.5 -4 A 7.5 7.5 0 1 1 -7.2 -2.6", + class: "board-reload-hint-arrow", + }) + ); + g.appendChild( + el("polygon", { points: "-7.2,-8.4 -1.4,-2.6 -9.8,-0.4", class: "board-reload-hint-arrowhead" }) + ); + // Groessere unsichtbare Trefferflaeche, analog zu .po-desk-hit - das sichtbare Symbol ist + // fuer einen zuverlaessigen Klick zu klein. + g.appendChild(el("rect", { x: -16, y: -16, width: 32, height: 32, class: "board-reload-hint-hit" })); + + g.addEventListener("click", (event) => { + event.stopPropagation(); + onClick(); + }); + + return g; +} + function renderEventMonitor(frame, events) { const lines = buildEventLines(events).map(mapEventForDisplay); const g = el("g", { class: "monitor monitor-events" }); @@ -993,6 +1033,76 @@ function triggerPoVisit() { poVisitTimeoutId = window.setTimeout(advancePoVisit, DOOR_VISIT_STAGE_DURATION_MS); } +// Issue #51: Board-Besuch fuer den manuellen Reload-Knopf. Anders als triggerPoVisit()/ +// advancePoVisit() (feste Dauer je Stage) bleibt der PO in "interacting_with_board", bis die +// uebergebene Aktion abgeschlossen ist - genau das macht die Standzeit am Board zur sichtbaren +// Anzeige, wie lange das Neueinlesen tatsaechlich gedauert hat (Issue-Text: "daran kann man +// erkennen, wie lange er dafuer benoetigt"). "walking_to_board" und "walking_back" behalten +// weiterhin ihre feste Animationsdauer. +// +// Ist der PO gerade anderweitig unterwegs (poState !== "idle"), wird kein zweiter Besuch +// angestossen - die Aktion laeuft trotzdem, nur ohne eigene Animation. +function triggerPoReloadVisit(action) { + if (!poIsLive || poState !== "idle") { + action(); + return; + } + + poState = PO_VISIT_STAGES[0]; // walking_to_board + repaintAgentsLayer(); + poVisitTimeoutId = window.setTimeout(async () => { + poState = PO_VISIT_STAGES[1]; // interacting_with_board + repaintAgentsLayer(); + + try { + await action(); + } finally { + poState = PO_VISIT_STAGES[2]; // walking_back + repaintAgentsLayer(); + poVisitTimeoutId = window.setTimeout(() => { + poState = "idle"; + repaintAgentsLayer(); + }, DOOR_VISIT_STAGE_DURATION_MS); + } + }, DOOR_VISIT_STAGE_DURATION_MS); +} + +// Wird von boot() gesetzt: laedt den Board-Zustand sofort neu, statt auf den naechsten +// SSE-Push/Sicherheitsnetz-Poll zu warten (siehe office-realtime.js) - nach einem manuellen +// Reload soll das Board unmittelbar den neuen Stand zeigen. +let requestSceneRefresh = null; +let boardResyncInFlight = false; + +async function handleBoardResync() { + if (boardResyncInFlight) { + return; + } + + let adminKey = getStoredAdminKey(); + if (!adminKey) { + adminKey = window.prompt("Admin-Key für das Neueinlesen des Boards:") || ""; + if (!adminKey) { + return; + } + } + + boardResyncInFlight = true; + triggerPoReloadVisit(async () => { + try { + await resyncTickets(adminKey); + setStoredAdminKey(adminKey); + await requestSceneRefresh?.(); + } catch (err) { + if (err && err.isAuthError) { + setStoredAdminKey(""); + } + window.alert(err instanceof Error ? err.message : "Board konnte nicht neu eingelesen werden."); + } finally { + boardResyncInFlight = false; + } + }); +} + function advanceDoorVisit(id) { const visitor = doorVisitors.get(id); if (!visitor) { @@ -1502,12 +1612,17 @@ export function renderOfficeScene(container, data = {}) { const groupedTickets = groupTicketsByColumn(tickets); - const boardGroup = renderBoard(ticketCounts, groupedTickets, (columnKey) => { - openModal( - buildTicketModalContent(columnKey, groupedTickets[columnKey] || []), - renderTicketModalBody - ); - }); + const boardGroup = renderBoard( + ticketCounts, + groupedTickets, + (columnKey) => { + openModal( + buildTicketModalContent(columnKey, groupedTickets[columnKey] || []), + renderTicketModalBody + ); + }, + handleBoardResync + ); const existingBoard = svg.querySelector(".board"); if (existingBoard) { existingBoard.replaceWith(boardGroup); @@ -1700,6 +1815,8 @@ async function boot() { } } + requestSceneRefresh = refresh; + await refresh(); startRealtimeUpdates(refresh); }