From bd2792332194de52f2ade7bdd2d1c67d3d9a3af6 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Wed, 19 Aug 2026 19:52:09 +0200 Subject: [PATCH] Reload-Knopf am Kanban-Board: Tickets komplett neu einlesen (Issue #51) Manchmal werden bei Codeberg geloeschte oder anderweitig verschwundene Issues vom periodischen Sync nicht bereinigt (er upsertet nur, ruehrt fehlende Issues nie an). Neuer Knopf neben der "Kanban-Board"-Ueberschrift stoesst dafuer einen synchronen Voll-Resync an (POST /api/v1/tickets/resync, X-Admin-Key): jedes konfigurierte Projekt wird neu gelesen, Tickets zu inzwischen verschwundenen Issues werden je Projekt entfernt (auch wenn sie manuell auf eine nicht vom Sync verwaltete Spalte wie "fertig" bewegt wurden), Tickets zu weiterhin existierenden Issues bleiben unangetastet. Der PO geht dabei sichtbar ans Board und steht dort, bis die Anfrage abgeschlossen ist - anders als beim automatischen Board-Besuch (feste Animationsdauer je Stage) macht das die tatsaechliche Sync-Dauer sichtbar. Co-Authored-By: Claude Sonnet 5 --- API.md | 18 ++- api/src/Application.php | 16 ++- api/src/Controller/TicketController.php | 27 +++- api/src/Repository/TicketRepository.php | 28 ++++ api/src/Sync/CodebergSyncService.php | 48 ++++++- .../Unit/Controller/TicketControllerTest.php | 57 +++++++- .../Unit/Sync/CodebergSyncServiceTest.php | 43 ++++++ html/css/office.css | 39 ++++++ html/js/office-api.js | 13 ++ html/js/office-render.js | 131 +++++++++++++++++- 10 files changed, 403 insertions(+), 17 deletions(-) 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); }