diff --git a/.env.example b/.env.example index 7dc64de..8abea45 100644 --- a/.env.example +++ b/.env.example @@ -20,4 +20,7 @@ CODEBERG_TOKEN= # Abstand zwischen zwei Codeberg-Sync-Laeufen in Sekunden (Service "sync" in # docker-compose.yml). Der erste Lauf passiert direkt beim Start des Services. +# Speist ausserdem den rein kosmetischen Reload-Countdown im Header der Buero-Visualisierung +# (Service "web", siehe docker/nginx/default.conf.template) - der Countdown zaehlt nur mit, +# er loest selbst keinen echten Reload/Sync aus. MONITOR_SYNC_INTERVAL=600 diff --git a/docker-compose.yml b/docker-compose.yml index 0f6c891..e31a514 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -77,6 +77,10 @@ services: # Wird in docker/nginx/default.conf.template eingesetzt und als /config.js an den # Browser ausgeliefert. Leer = gleicher Origin (relatives /api/v1). MONITOR_API_BASE: ${MONITOR_API_BASE:-} + # Speist nur den kosmetischen Reload-Countdown im Header (Issue #12) - gleicher + # Default wie beim sync-Service oben, damit die Anzeige zur tatsaechlichen + # Sync-Frequenz passt, wenn MONITOR_SYNC_INTERVAL nicht gesetzt ist. + MONITOR_SYNC_INTERVAL: ${MONITOR_SYNC_INTERVAL:-600} ports: - "8090:80" diff --git a/docker/nginx/default.conf.template b/docker/nginx/default.conf.template index d20b788..dfb8435 100644 --- a/docker/nginx/default.conf.template +++ b/docker/nginx/default.conf.template @@ -10,17 +10,20 @@ server { index office.html; # Laufzeit-Konfiguration fuers Frontend: Das html/-Verzeichnis ist read-only gemountet, - # deshalb wird die API-Basis-URL nicht als Datei abgelegt, sondern hier aus der Umgebung - # ausgeliefert (MONITOR_API_BASE, siehe .env). Das nginx-Image ersetzt ${MONITOR_API_BASE} - # beim Containerstart per envsubst - nur Variablen, die tatsaechlich in der Umgebung - # definiert sind, nginx-eigene wie $uri bleiben unangetastet. Ist MONITOR_API_BASE gar - # nicht definiert, bleibt der Platzhalter stehen und nginx verweigert den Start + # deshalb werden API-Basis-URL und Sync-Intervall nicht als Datei abgelegt, sondern hier + # aus der Umgebung ausgeliefert (MONITOR_API_BASE, MONITOR_SYNC_INTERVAL, siehe .env). + # Das nginx-Image ersetzt ${MONITOR_API_BASE}/${MONITOR_SYNC_INTERVAL} beim Containerstart + # per envsubst - nur Variablen, die tatsaechlich in der Umgebung definiert sind, + # nginx-eigene wie $uri bleiben unangetastet. Ist eine der beiden Variablen gar nicht + # definiert, bleibt ihr Platzhalter stehen und nginx verweigert den Start # ("unknown \"monitor_api_base\" variable") - besser ein lauter Fehler als eine still - # falsche URL. docker-compose.yml setzt die Variable daher immer (ggf. leer). + # falsche URL. docker-compose.yml setzt beide Variablen daher immer (ggf. mit Default). + # MONITOR_SYNC_INTERVAL speist nur den rein kosmetischen Reload-Countdown im Header + # (Issue #12) - hat keinen Einfluss auf den tatsaechlichen Update-Takt der Szene. location = /config.js { default_type application/javascript; add_header Cache-Control "no-store"; - return 200 'window.MONITOR_API_BASE = "${MONITOR_API_BASE}";'; + return 200 'window.MONITOR_API_BASE = "${MONITOR_API_BASE}"; window.MONITOR_SYNC_INTERVAL = ${MONITOR_SYNC_INTERVAL};'; } location / { diff --git a/html/css/office.css b/html/css/office.css index 95308b1..63d4db1 100644 --- a/html/css/office.css +++ b/html/css/office.css @@ -52,6 +52,7 @@ header.office-header { padding: 12px 24px; display: flex; align-items: baseline; + justify-content: space-between; gap: 16px; } @@ -60,10 +61,14 @@ header.office-header h1 { font-size: 1.4rem; } -header.office-header p { - margin: 0; +/* Rein kosmetischer Countdown bis zum naechsten Codeberg-Sync (Issue #12), rechtsbuendig + anstelle des frueheren erklaerenden Fliesstexts. Tabular-nums verhindert Wackeln der + Breite, wenn sich die Ziffernzahl aendert (z.B. 9s -> 10s). */ +.reload-countdown { color: #475569; font-size: 0.9rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; } /* Warnbanner, wenn die API nicht erreichbar ist und die Szene auf Demo-Daten zurueckfaellt @@ -126,14 +131,6 @@ header.office-header p { } } -/* Bei knappem Platz weicht zuerst der erklaerende Fliesstext - er kostet in flachen - oder schmalen Fenstern mehrere Zeilen Hoehe, die der Szene fehlen. */ -@media (max-height: 640px), (max-width: 760px) { - header.office-header p { - display: none; - } -} - @media (max-height: 640px) { header.office-header { padding: 8px 16px; diff --git a/html/js/office-render.js b/html/js/office-render.js index e765cf5..ab60e9e 100644 --- a/html/js/office-render.js +++ b/html/js/office-render.js @@ -33,6 +33,8 @@ import { resolveDoorVisitVisual, advanceDoorVisitStage, DOOR_VISIT_STAGES, + resolveReloadIntervalSeconds, + nextCountdownSeconds, } from "./office-scene.js"; import { DEMO_TICKET_COUNTS, @@ -1024,6 +1026,25 @@ function setApiBanner(visible) { } } +// Rein kosmetischer Countdown im Header (Issue #12): zaehlt unabhaengig vom tatsaechlichen +// SSE-/Polling-Takt von MONITOR_SYNC_INTERVAL (aus config.js, siehe office.html) auf 0 +// herunter und startet danach von vorn - loest selbst keinen Reload aus. +function startReloadCountdown() { + const countdownEl = document.getElementById("reload-countdown"); + if (!countdownEl) { + return; + } + + const intervalSeconds = resolveReloadIntervalSeconds(window.MONITOR_SYNC_INTERVAL); + let remaining = intervalSeconds; + countdownEl.textContent = `${remaining}s`; + + window.setInterval(() => { + remaining = nextCountdownSeconds(remaining, intervalSeconds); + countdownEl.textContent = `${remaining}s`; + }, 1000); +} + async function boot() { const container = document.getElementById("office-scene"); if (!container) { @@ -1064,4 +1085,5 @@ async function boot() { document.addEventListener("DOMContentLoaded", () => { boot(); + startReloadCountdown(); }); diff --git a/html/js/office-scene.js b/html/js/office-scene.js index dce5c0a..32d2337 100644 --- a/html/js/office-scene.js +++ b/html/js/office-scene.js @@ -538,3 +538,30 @@ export function advanceDoorVisitStage(stage) { } return DOOR_VISIT_STAGES[index + 1]; } + +// --------------------------------------------------------------------------------------- +// Reload-Countdown im Header (Issue #12): rein kosmetischer Sekundenzaehler, der die +// Sync-Frequenz des Backends (MONITOR_SYNC_INTERVAL, siehe .env.example) im Header +// widerspiegelt - ohne selbst irgendetwas neu zu laden (siehe office-render.js). +// --------------------------------------------------------------------------------------- + +export const DEFAULT_RELOAD_INTERVAL_SECONDS = 600; + +/** + * @param {unknown} rawValue - z.B. window.MONITOR_SYNC_INTERVAL (aus config.js). + * @returns {number} gueltige Intervall-Laenge in Sekunden, faellt auf den Default zurueck, + * wenn der Rohwert fehlt oder keine positive Zahl ist. + */ +export function resolveReloadIntervalSeconds(rawValue) { + const parsed = Number(rawValue); + return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : DEFAULT_RELOAD_INTERVAL_SECONDS; +} + +/** + * @param {number} current - aktueller Zaehlerstand in Sekunden. + * @param {number} intervalSeconds - Intervall-Laenge, auf die nach Ablauf zurueckgesetzt wird. + * @returns {number} naechster Zaehlerstand (zaehlt herunter, springt bei <=1 zurueck auf den vollen Wert). + */ +export function nextCountdownSeconds(current, intervalSeconds) { + return current <= 1 ? intervalSeconds : current - 1; +} diff --git a/html/office.html b/html/office.html index faf6ead..fa18959 100644 --- a/html/office.html +++ b/html/office.html @@ -9,22 +9,19 @@
-

Buero

-

- Kanban-Board, Monitor-Wand und Schreibtische zeigen live die Daten aus der - REST-API (Push-Updates per Server-Sent Events, Fallback: - Polling). Klick auf das Board oder einen Monitor oeffnet die Detailansicht. Ist - die API nicht erreichbar, zeigt die Szene ersatzweise Demo-Daten. -

+

Agent-Status

+
- + diff --git a/tests/frontend/office-scene.test.js b/tests/frontend/office-scene.test.js index 5f38a72..c5e3bda 100644 --- a/tests/frontend/office-scene.test.js +++ b/tests/frontend/office-scene.test.js @@ -31,6 +31,9 @@ import { detectDoorVisits, resolveDoorVisitVisual, advanceDoorVisitStage, + DEFAULT_RELOAD_INTERVAL_SECONDS, + resolveReloadIntervalSeconds, + nextCountdownSeconds, } from "../../html/js/office-scene.js"; test("buildBoardColumnLayout liefert alle Spalten in API.md-Reihenfolge", () => { @@ -404,3 +407,23 @@ test("advanceDoorVisitStage durchlaeuft alle Stages und liefert am Ende null", ( test("advanceDoorVisitStage liefert bei unbekannter Stage null", () => { assert.equal(advanceDoorVisitStage("unbekannt"), null); }); + +test("resolveReloadIntervalSeconds uebernimmt gueltige positive Zahlen (auch als String)", () => { + assert.equal(resolveReloadIntervalSeconds(30), 30); + assert.equal(resolveReloadIntervalSeconds("45"), 45); + assert.equal(resolveReloadIntervalSeconds(12.4), 12); +}); + +test("resolveReloadIntervalSeconds faellt bei fehlendem/ungueltigem Wert auf den Default zurueck", () => { + assert.equal(resolveReloadIntervalSeconds(undefined), DEFAULT_RELOAD_INTERVAL_SECONDS); + assert.equal(resolveReloadIntervalSeconds(null), DEFAULT_RELOAD_INTERVAL_SECONDS); + assert.equal(resolveReloadIntervalSeconds(0), DEFAULT_RELOAD_INTERVAL_SECONDS); + assert.equal(resolveReloadIntervalSeconds(-5), DEFAULT_RELOAD_INTERVAL_SECONDS); + assert.equal(resolveReloadIntervalSeconds("nicht-numerisch"), DEFAULT_RELOAD_INTERVAL_SECONDS); +}); + +test("nextCountdownSeconds zaehlt herunter und springt bei 1 auf das volle Intervall zurueck", () => { + assert.equal(nextCountdownSeconds(30, 30), 29); + assert.equal(nextCountdownSeconds(2, 30), 1); + assert.equal(nextCountdownSeconds(1, 30), 30); +});