Merge pull request 'Reload-Knopf am Kanban-Board: Tickets komplett neu einlesen' (#52) from fds2610-bot/monitor:main into main

Reviewed-on: https://codeberg.org/fds2610/monitor/pulls/52
This commit is contained in:
fds2610 2026-08-19 20:14:14 +02:00
commit d896f4c6b1
10 changed files with 403 additions and 17 deletions

18
API.md
View file

@ -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).

View file

@ -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');

View file

@ -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()]);
}
}

View file

@ -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.

View file

@ -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<int, array{owner: string, repo: string}> $repoConfigs
* @return string[]
*/
private function repoSlugs(array $repoConfigs): array
{
return array_map(
static fn (array $repoConfig): string => $repoConfig['owner'] . '/' . $repoConfig['repo'],
$repoConfigs
);
}
/**

View file

@ -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);
}
}

View file

@ -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());

View file

@ -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;

View file

@ -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<Array<object>>} der neue Board-Zustand.
*/
export async function resyncTickets(adminKey) {
const { tickets } = await adminJson("POST", "/tickets/resync", undefined, adminKey);
return tickets;
}

View file

@ -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<string, number>} ticketCounts
* @param {Record<string, Array<object>>} 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) => {
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);
}