monitor/html/js/office-render.js
fds2610-bot c16c4b1e46 Letzte erfolgreiche Ping-Prüfung im System-Monitor-Modal anzeigen (Issue #57)
Die per PING ueberwachten lokalen Server (check_fds.sh -> status.txt ->
LegacyStatusFileReader -> SystemRepository) landen bereits als Kacheln im
System-Monitor, dem mittleren der drei Monitore an der Buero-Wand - das ist
exakt der "alte Monitor" aus dem Issue. GET /api/v1/systems liefert
last_check_at dafuer schon mit, das Klick-Modal hat den Wert aber bisher
weder abgeholt noch angezeigt.

buildSystemModalContent() (office-scene.js) formatiert last_check_at jetzt
ueber die neue formatSystemCheckTime() - sie kommt sowohl mit ISO-8601
(API-gemeldete Systeme) als auch mit dem "YYYY-MM-DD HH:MM"-Format der
Legacy-status.txt klar. renderSystemModalBody() (office-render.js) zeigt den
Zeitpunkt nur bei Status "ok" an ("zuletzt erfolgreich geprüft: ..."), da nur
dann Erreichbarkeit UND Ping-Erfolg zusammenfallen - bei "Fehler" bliebe eine
Zeitangabe ohne Aenderung an check_fds.sh irrefuehrend.

Bewusst nicht Teil dieser Aenderung: Die im Issue zusaetzlich genannte
Portscan-Liste (Detailseite fuer weitere, nur manuell zu pruefende Server)
ist als "koennten" markiert und ohne konkrete Vorgabe zu unscharf fuer eine
automatisierte Umsetzung. Die sechs konkret genannten Hosts/IPs gehoeren in
die lokale, nicht versionierte hosts.conf (Analogon zu credentials.conf) und
damit auf den Produktivserver, nicht in dieses oeffentliche Repo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 20:49:33 +02:00

2013 lines
70 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Zeichnet die Buero-Szene als Vanilla-SVG in den Container #office-scene und haelt sie mit
// echten Daten aktuell (Phase 3/4: Board/Monitor-Wand live, Phase 5: Agenten-Animation, siehe
// Visualization.md Abschnitt 9). Reine DOM-Glue-Logik - Layoutberechnung/Datenmodelle stecken
// testbar in office-scene.js.
//
// Architektur-Hinweis fuer die Animation (Phase 5): Board, Monitor-Wand und Schreibtisch-
// Moebel werden bei jedem Update komplett neu gezeichnet (kein Animationsbedarf dort), die
// Agenten-Avatare dagegen sind stabile DOM-Knoten pro Agent-ID, deren transform-Attribut nur
// aktualisiert wird - so kann CSS transition den Laufweg animieren, statt bei jedem Update
// hart zu springen.
import {
ROLES,
AGENT_LOCATIONS,
buildBoardColumnLayout,
buildDeskLayout,
buildSystemTiles,
buildEventLines,
aggregateTicketsByColumn,
groupTicketsByColumn,
buildTicketModalContent,
mapEventForDisplay,
formatEventLineText,
truncateToWidth,
buildEventModalContent,
buildSystemModalContent,
buildKpiTiles,
buildKpiModalContent,
buildAgentCountKpis,
buildHooksKpi,
MAX_KPI_TILES,
mapAgentsToDesks,
normalizeAgentForDisplay,
resolveAgentVisualState,
isAgentVisible,
agentProblemSeverity,
agentsWithProblems,
pickIdleActivity,
AnimationQueue,
detectDoorVisits,
resolveDoorVisitVisual,
advanceDoorVisitStage,
DOOR_VISIT_STAGES,
detectUnassignedTicketChanges,
advancePoVisitStage,
PO_VISIT_STAGES,
applyPendingTicketMoves,
PO_AGENT_ID,
PO_DISPLAY_NAME,
resolveReloadIntervalSeconds,
nextCountdownSeconds,
} from "./office-scene.js";
import {
DEMO_TICKET_COUNTS,
DEMO_AGENTS,
DEMO_SYSTEMS,
DEMO_EVENTS,
DEMO_KPI_PLACEHOLDER,
} from "./demo-data.js";
import {
fetchTickets,
fetchEvents,
fetchSystems,
fetchKpis,
fetchAgents,
fetchWebhookStats,
fetchRepoConfigs,
createRepoConfig,
updateRepoConfig,
deleteRepoConfig,
checkRepoConfig,
resyncTickets,
getStoredAdminKey,
setStoredAdminKey,
} from "./office-api.js";
import { startRealtimeUpdates } from "./office-realtime.js";
import { openModal, isCurrentModal } from "./office-modal.js";
const SVG_NS = "http://www.w3.org/2000/svg";
const VIEW_W = 1920;
const VIEW_H = 1080;
const WALL_BAND_H = 420;
const BOARD_FRAME = { x: 60, y: 40, w: 1120, h: 340 };
const MONITOR_WALL = { x: 1240, y: 40, w: 620, h: 340 };
const DOOR = { w: 40, h: 220, y: 700 };
const DESK = { w: 220, h: 110 };
const DESK_ROW_Y = [540, 840];
const DESK_COL_X = [232, 644, 1056, 1468];
const COFFEE_CORNER = { x: 960, y: 780 };
// Ein Event belegt im Event-Monitor zwei Zeilen (Kopfzeile Zeit/System, darunter die
// Meldung); EVENT_BLOCK_H ist der Abstand von Event zu Event, EVENT_MESSAGE_DY der der
// beiden Zeilen zueinander. MAX_EVENT_LINES (5) Bloecke passen damit in MONITOR_WALL.h.
const EVENT_BLOCK_H = 44;
const EVENT_MESSAGE_DY = 18;
// Fester Platz des Product Owner (Issue #19): bewusst ausserhalb des dynamischen 8er-Rasters
// (DESK_COL_X/DESK_ROW_Y), in der freien Nische zwischen Board/Monitor-Wand und der ersten
// Schreibtischreihe - so kollidiert er nie mit einem echten, ueber die API registrierten Agenten.
const PO_DESK = { x: 870, y: 380, w: 180 };
const NOTE_W = 96;
const NOTE_H = 50;
const NOTE_STEP_Y = 56;
// Feste "Aufenthaltsorte" fuer die Agenten-Avatare (Phase 5), abgeleitet aus den Rahmen der
// jeweiligen Wandelemente - der Avatar steht direkt davor.
const BOARD_FEET = { x: BOARD_FRAME.x + BOARD_FRAME.w / 2, y: BOARD_FRAME.y + BOARD_FRAME.h + 40 };
const MONITOR_FEET = { x: MONITOR_WALL.x + MONITOR_WALL.w / 2, y: MONITOR_WALL.y + MONITOR_WALL.h + 40 };
const COFFEE_FEET = { x: COFFEE_CORNER.x, y: COFFEE_CORNER.y + 26 };
// Ruheplatz der Tuerbesucher (Phase 6, F2): direkt vor der linken Tuer, wie schon der
// Ueberlaufplatz fuer Agenten ohne Schreibtisch (siehe agentPlacements()).
const DOOR_FEET = { x: 20, y: DOOR.y + DOOR.h / 2 };
// Ruheplatz des PO an seinem festen Platz (Issue #19), analog zu den Fuss-Positionen der
// regulaeren Schreibtische (deskSeat().feet).
const PO_DESK_FEET = { x: PO_DESK.x + PO_DESK.w / 2, y: PO_DESK.y + 40 + DESK.h + 30 };
const IDLE_MIN_DELAY_MS = 15000;
const IDLE_MAX_DELAY_MS = 40000;
const IDLE_ACTIVITY_DURATION_MS = 8000;
const DOOR_VISIT_STAGE_DURATION_MS = 2200;
// Issue #55: sobald die Persona am Board angekommen ist ("interacting_with_board") und das
// Ticket an seine neue Stelle gewechselt hat, wartet sie genau diese Spanne, bevor sie
// (Tuerbesucher: "leaving", PO: "walking_back") wieder losgeht.
const TICKET_PLACED_WAIT_MS = 1000;
function el(tag, attrs = {}, children = []) {
const node = document.createElementNS(SVG_NS, tag);
for (const [key, value] of Object.entries(attrs)) {
if (value !== null && value !== undefined) {
node.setAttribute(key, value);
}
}
for (const child of children) {
node.appendChild(child);
}
return node;
}
function text(content, attrs = {}) {
const node = el("text", attrs);
node.textContent = content;
return node;
}
function renderRoom() {
const g = el("g", { class: "office-room" });
g.appendChild(el("rect", { x: 0, y: 0, width: VIEW_W, height: VIEW_H, class: "floor" }));
g.appendChild(el("rect", { x: 0, y: 0, width: VIEW_W, height: WALL_BAND_H, class: "wall" }));
g.appendChild(el("rect", { x: 0, y: 0, width: 40, height: VIEW_H, class: "wall wall-side" }));
g.appendChild(el("rect", { x: VIEW_W - 40, y: 0, width: 40, height: VIEW_H, class: "wall wall-side" }));
g.appendChild(el("rect", { x: 0, y: DOOR.y, width: DOOR.w, height: DOOR.h, class: "door door-left" }));
g.appendChild(
el("rect", { x: VIEW_W - DOOR.w, y: DOOR.y, width: DOOR.w, height: DOOR.h, class: "door door-right" })
);
g.appendChild(renderCoffeeCorner());
return g;
}
// Kaffee-Ecke als fester Raum-Prop zwischen den Schreibtischreihen (Ziel fuer den
// "at_coffee_corner"-Zustand, Vision-Text: "gehen in eine Kaffee-Ecke").
function renderCoffeeCorner() {
const { x, y } = COFFEE_CORNER;
const g = el("g", { class: "coffee-corner" });
g.appendChild(el("rect", { x: x - 34, y: y - 20, width: 68, height: 40, rx: 8, class: "coffee-table" }));
g.appendChild(el("circle", { cx: x - 14, cy: y - 26, r: 8, class: "coffee-cup" }));
g.appendChild(el("circle", { cx: x + 14, cy: y - 26, r: 8, class: "coffee-cup" }));
g.appendChild(text("☕", { x, y: y - 34, class: "coffee-icon", "text-anchor": "middle" }));
return g;
}
// Faerbt einen Zettel in der Farbe seines Quell-Projekts (Issue #26/#27: "welcher Farbe
// diese zugeordnet werden"). Ohne Farbe - Ticket ohne Projekt oder Demo-Modus - bleibt es
// bei der Standardfarbe aus dem Stylesheet. Die Farbe ist Nutzereingabe aus dem
// Projekte-Modal und landet deshalb ueber die style-Property im DOM, nie in einem
// zusammengesetzten Attribut-String.
function paintNote(rect, color) {
if (color) {
rect.style.fill = color;
// Die gelbe Standardkontur passt nur zum gelben Standardzettel - siehe .note-colored.
rect.classList.add("note-colored");
}
return rect;
}
function renderStackBadge(x, y, count, stackColors = []) {
const g = el("g", { class: "note-stack" });
[6, 3, 0].forEach((offset, i) => {
g.appendChild(
paintNote(
el("rect", {
x: x - offset,
y: y - offset,
width: NOTE_W,
height: NOTE_H,
rx: 6,
class: `note note-stack-layer-${i}`,
}),
stackColors[i]
)
);
});
const cx = x + NOTE_W / 2;
const cy = y + NOTE_H / 2;
g.appendChild(el("circle", { cx, cy, r: 20, class: "note-stack-badge" }));
g.appendChild(
text(String(count), { x: cx, y: cy + 7, class: "note-stack-count", "text-anchor": "middle" })
);
return g;
}
/**
* @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, 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;
columns.forEach((column, index) => {
const columnX = BOARD_FRAME.x + index * columnWidth;
const colGroup = el("g", { class: `board-column board-column-${column.key} clickable` });
colGroup.appendChild(
el("rect", {
x: columnX,
y: BOARD_FRAME.y,
width: columnWidth,
height: BOARD_FRAME.h,
class: "board-column-hit",
})
);
colGroup.addEventListener("click", () => onColumnClick(column.key));
if (index > 0) {
colGroup.appendChild(
el("line", {
x1: columnX,
y1: BOARD_FRAME.y,
x2: columnX,
y2: BOARD_FRAME.y + BOARD_FRAME.h,
class: "board-column-divider",
})
);
}
colGroup.appendChild(
text(column.label, {
x: columnX + columnWidth / 2,
y: BOARD_FRAME.y + 26,
class: "board-column-label",
"text-anchor": "middle",
})
);
const noteX = columnX + (columnWidth - NOTE_W) / 2;
let noteY = BOARD_FRAME.y + 44;
for (let i = 0; i < column.visibleNotes; i++) {
colGroup.appendChild(
paintNote(
el("rect", { x: noteX, y: noteY, width: NOTE_W, height: NOTE_H, rx: 6, class: "note" }),
column.noteColors[i]
)
);
noteY += NOTE_STEP_Y;
}
if (column.stackCount > 0) {
colGroup.appendChild(
renderStackBadge(noteX, noteY, column.stackCount, column.stackColors)
);
}
g.appendChild(colGroup);
});
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" });
g.appendChild(el("rect", { ...frame, rx: 8, class: "monitor-frame" }));
g.appendChild(text("Events", { x: frame.x + 12, y: frame.y - 10, class: "wall-label" }));
g.appendChild(
el("rect", {
x: frame.x + 10,
y: frame.y + 10,
width: frame.w - 20,
height: frame.h - 20,
class: "monitor-screen",
})
);
// Meldungen sind beliebig lang, der Monitor ist es nicht - einzeilig lief die Zeile ueber
// den Rahmen hinaus in die Szene. Jedes Event bekommt deshalb zwei Zeilen: Zeit + System
// oben, die Meldung eingerueckt darunter. Beide werden auf die Innenbreite gekuerzt, den
// Volltext zeigt der <title>-Tooltip beim Hover ueber das Event.
const textX = frame.x + 18;
const availableWidth = frame.w - 36;
const messageIndent = 12;
lines.forEach((eventLine, index) => {
const blockY = frame.y + 38 + index * EVENT_BLOCK_H;
const entry = el("g", { class: `monitor-event severity-${eventLine.severity}` });
const title = el("title");
title.textContent = formatEventLineText(eventLine);
entry.appendChild(title);
entry.appendChild(
text(truncateToWidth(`${eventLine.time} ${eventLine.system}`, availableWidth), {
x: textX,
y: blockY,
class: "monitor-event-line monitor-event-meta",
})
);
entry.appendChild(
text(truncateToWidth(eventLine.message, availableWidth - messageIndent), {
x: textX + messageIndent,
y: blockY + EVENT_MESSAGE_DY,
class: "monitor-event-line monitor-event-message",
})
);
g.appendChild(entry);
});
return g;
}
function renderSystemMonitor(frame, systems) {
const tiles = buildSystemTiles(systems);
const g = el("g", { class: "monitor monitor-systems" });
g.appendChild(el("rect", { ...frame, rx: 8, class: "monitor-frame" }));
g.appendChild(text("Systeme", { x: frame.x + 12, y: frame.y - 10, class: "wall-label" }));
g.appendChild(
el("rect", {
x: frame.x + 10,
y: frame.y + 10,
width: frame.w - 20,
height: frame.h - 20,
class: "monitor-screen",
})
);
const cols = 3;
const tileMargin = 8;
const gridX = frame.x + 18;
const gridY = frame.y + 18;
const tileSize = (frame.w - 36 - tileMargin * (cols - 1)) / cols;
tiles.forEach((system, index) => {
const col = index % cols;
const row = Math.floor(index / cols);
const tileX = gridX + col * (tileSize + tileMargin);
const tileY = gridY + row * (tileSize + tileMargin);
const tileGroup = el("g", { class: `system-tile status-${system.status}` });
tileGroup.appendChild(
el("rect", { x: tileX, y: tileY, width: tileSize, height: tileSize, rx: 6, class: "system-tile-bg" })
);
tileGroup.appendChild(
el("rect", { x: tileX + 10, y: tileY + 10, width: tileSize - 20, height: 14, rx: 2, class: "system-tile-bar" })
);
tileGroup.appendChild(
el("rect", { x: tileX + 10, y: tileY + 30, width: tileSize - 20, height: 14, rx: 2, class: "system-tile-bar" })
);
tileGroup.appendChild(el("circle", { cx: tileX + 18, cy: tileY + 17, r: 3, class: "system-tile-led" }));
tileGroup.appendChild(el("circle", { cx: tileX + 18, cy: tileY + 37, r: 3, class: "system-tile-led" }));
tileGroup.appendChild(
text(system.name, {
x: tileX + tileSize / 2,
y: tileY + tileSize - 8,
class: "system-tile-label",
"text-anchor": "middle",
})
);
g.appendChild(tileGroup);
});
return g;
}
function renderSparkline(points, width, height, x, y) {
const g = el("g", { class: "kpi-sparkline", transform: `translate(${x}, ${y})` });
g.appendChild(el("rect", { x: 0, y: 0, width, height, class: "kpi-sparkline-bg" }));
if (points) {
g.appendChild(el("polyline", { points, class: "kpi-sparkline-line" }));
}
return g;
}
function renderKpiMonitor(frame, kpis) {
const tiles = buildKpiTiles(kpis);
const g = el("g", { class: "monitor monitor-kpi" });
g.appendChild(el("rect", { ...frame, rx: 8, class: "monitor-frame" }));
g.appendChild(text("KPIs", { x: frame.x + 12, y: frame.y - 10, class: "wall-label" }));
g.appendChild(
el("rect", {
x: frame.x + 10,
y: frame.y + 10,
width: frame.w - 20,
height: frame.h - 20,
class: `monitor-screen ${tiles.length === 0 ? "monitor-screen-placeholder" : ""}`,
})
);
if (tiles.length === 0) {
renderPlaceholderText(g, frame, DEMO_KPI_PLACEHOLDER);
return g;
}
// Die Zeilen stehen direkt untereinander, statt die Monitorhoehe gleichmaessig unter den
// Kacheln aufzuteilen: Zaehler-Kacheln wie "Agenten"/"Online" (Issue #39) und "Hooks"
// (Issue #38) haben history: [] und damit keine Sparkline - verteilt man die Hoehe trotzdem,
// klaffen zwischen den Werten grosse leere Sparkline-Kaesten. Ein Verlauf bekommt seinen
// Platz deshalb nur, wenn es ihn wirklich gibt.
const LABEL_HEIGHT = 20;
// Passt zur Punkteberechnung in buildKpiSparklinePoints() (Default-Hoehe 32) - so fuellt die
// Linie den Kasten genau aus.
const SPARKLINE_HEIGHT = 32;
const ROW_GAP = 4;
let rowTop = frame.y + 20;
tiles.forEach((tile) => {
g.appendChild(
text(`${tile.label}: ${tile.currentValue ?? "–"} ${tile.unit}`.trim(), {
x: frame.x + 16,
y: rowTop + 14,
// Abgeleitete Kacheln koennen einen Schweregrad tragen ("Problem", siehe
// buildAgentCountKpis) - dieselben Klassen wie die Event-Zeilen, also dieselben Farben.
class: `kpi-tile-label${tile.severity ? ` severity-${tile.severity}` : ""}`,
})
);
rowTop += LABEL_HEIGHT;
if (tile.sparklinePoints) {
g.appendChild(renderSparkline(tile.sparklinePoints, frame.w - 32, SPARKLINE_HEIGHT, frame.x + 16, rowTop));
rowTop += SPARKLINE_HEIGHT;
}
rowTop += ROW_GAP;
});
return g;
}
function renderPlaceholderText(g, frame, placeholder) {
const words = placeholder.split(" ");
const lineHeight = 22;
const maxCharsPerLine = 16;
const lines = [];
let current = "";
for (const word of words) {
const candidate = current ? `${current} ${word}` : word;
if (candidate.length > maxCharsPerLine && current) {
lines.push(current);
current = word;
} else {
current = candidate;
}
}
if (current) lines.push(current);
const startY = frame.y + frame.h / 2 - ((lines.length - 1) * lineHeight) / 2;
lines.forEach((line, index) => {
g.appendChild(
text(line, {
x: frame.x + frame.w / 2,
y: startY + index * lineHeight,
class: "monitor-placeholder-text",
"text-anchor": "middle",
})
);
});
}
/**
* @param {{onEventsClick: () => void, onSystemsClick: () => void, onKpiClick: () => void}} handlers
*/
function renderMonitorWall(systems, events, kpis, handlers) {
const g = el("g", { class: "monitor-wall" });
const gap = 20;
const monitorWidth = (MONITOR_WALL.w - gap * 2) / 3;
const frames = [0, 1, 2].map((i) => ({
x: MONITOR_WALL.x + i * (monitorWidth + gap),
y: MONITOR_WALL.y,
w: monitorWidth,
h: MONITOR_WALL.h,
}));
const eventsGroup = renderEventMonitor(frames[0], events);
eventsGroup.classList.add("clickable");
eventsGroup.addEventListener("click", handlers.onEventsClick);
const systemsGroup = renderSystemMonitor(frames[1], systems);
systemsGroup.classList.add("clickable");
systemsGroup.addEventListener("click", handlers.onSystemsClick);
const kpiGroup = renderKpiMonitor(frames[2], kpis);
kpiGroup.classList.add("clickable");
kpiGroup.addEventListener("click", handlers.onKpiClick);
g.appendChild(eventsGroup);
g.appendChild(systemsGroup);
g.appendChild(kpiGroup);
return g;
}
function deskSeat(deskIndex) {
const col = deskIndex % DESK_COL_X.length;
const row = Math.floor(deskIndex / DESK_COL_X.length);
const x = DESK_COL_X[col];
const y = DESK_ROW_Y[row];
return {
origin: { x, y },
feet: { x: x + DESK.w / 2, y: y + 40 + DESK.h + 30 },
};
}
const DESK_NAME_PROBLEM_CLASS = {
auth_expired: "desk-name-problem-error",
auth_expiring: "desk-name-problem-warning",
};
function deskScreenPoseClass(pose) {
switch (pose) {
case "working":
return "pose-working";
case "auth_expired":
case "auth_expiring":
// Der Bildschirm faerbt sich mit - ein Schloss allein am Avatar waere weg, sobald der
// Agent laeuft; der Platz selbst soll den Zustand ebenfalls tragen.
return `pose-${pose}`;
default:
return "pose-idle";
}
}
// Zeichnet nur die Moebel/Deko + den Namen des Schreibtisch-Besitzers - die eigentliche
// Person ist ein eigenstaendiger, beweglicher Avatar (siehe renderAgentAvatar/
// updateAgentsLayer), damit sie zum Board/Monitor "laufen" kann, ohne den Schreibtisch neu
// zeichnen zu muessen. Parametrisiert ueber origin/width, damit sowohl die regulaeren
// Schreibtische (deskSeat()) als auch der feste PO-Platz (PO_DESK, Issue #19) dieselbe
// Zeichenlogik/Deko (inkl. rollenabhaengiger desk.deco) nutzen koennen.
function renderDeskAt(origin, agent, width = DESK.w) {
const role = agent ? ROLES[agent.role] : null;
const g = el("g", {
class: `desk ${agent ? "desk-occupied" : "desk-empty"}`,
transform: `translate(${origin.x}, ${origin.y})`,
});
g.appendChild(el("rect", { x: 0, y: 40, width, height: DESK.h, rx: 6, class: "desk-table" }));
g.appendChild(
el("rect", {
x: 20,
y: 10,
width: 60,
height: 40,
rx: 4,
class: `desk-screen ${agent ? deskScreenPoseClass(agent.pose) : ""}`,
})
);
g.appendChild(el("circle", { cx: width / 2, cy: 40 + DESK.h + 30, r: 22, class: "desk-chair" }));
if (role) {
if (role.deco === "star") {
g.appendChild(
el("path", {
d: `M${width - 50},20 l4,10 11,0 -9,7 3,11 -9,-7 -9,7 3,-11 -9,-7 11,0 z`,
class: "desk-deco desk-deco-star",
})
);
} else if (role.deco === "flower") {
const cx = width - 45;
const cy = 22;
g.appendChild(
el("g", { class: "desk-deco desk-deco-flower" }, [
el("circle", { cx: cx - 7, cy, r: 6 }),
el("circle", { cx: cx + 7, cy, r: 6 }),
el("circle", { cx, cy: cy - 7, r: 6 }),
el("circle", { cx, cy: cy + 7, r: 6 }),
el("circle", { cx, cy, r: 4, class: "desk-deco-flower-center" }),
])
);
} else if (role.deco === "check") {
g.appendChild(
el("path", { d: `M${width - 58},24 l8,8 16,-18`, class: "desk-deco desk-deco-check" })
);
}
// Blockierte Agenten faerben ihr Namensschild ein. Der Zustand steckt hier in der Pose, weil
// renderDeskAt() den rohen Agenten gar nicht sieht - normalizeAgentForDisplay() hat state
// bereits auf pose abgebildet.
const problem = DESK_NAME_PROBLEM_CLASS[agent.pose] ?? "";
g.appendChild(
text(agent.name, {
x: width / 2,
y: 40 + DESK.h + 74,
class: `desk-name ${problem}`.trim(),
"text-anchor": "middle",
})
);
} else {
g.appendChild(
text("frei", {
x: width / 2,
y: 40 + DESK.h + 74,
class: "desk-name desk-name-empty",
"text-anchor": "middle",
})
);
}
return g;
}
function renderDesk({ deskIndex, agent }) {
return renderDeskAt(deskSeat(deskIndex).origin, agent);
}
/**
* @param {Array<object>} desks - reguläre 8er-Raster-Plaetze (echte Agenten, F1).
* @param {object|null} poAgent - {name: "PO", role: "product_owner", pose} bei erreichbarer
* API, sonst null (dann zeigt der feste PO-Platz "frei", Issue #19).
* @param {() => void} onPoClick - oeffnet das Projekte-Modal (Issue #27).
*/
function renderDesks(desks, poAgent, onPoClick) {
const g = el("g", { class: "desks" });
desks.forEach((desk) => g.appendChild(renderDesk(desk)));
const poDeskGroup = renderDeskAt(PO_DESK, poAgent, PO_DESK.w);
poDeskGroup.classList.add("po-desk", "clickable");
poDeskGroup.setAttribute("aria-label", "Projekte des Codeberg-Sync verwalten");
poDeskGroup.appendChild(renderPoConfigHint());
// Eine <g> selbst faengt keine Klicks - nur ihre gefuellten Kinder. Zwischen Tischplatte,
// Stuhl und Namensschild liegen Luecken, in denen ein Klick "auf den PO" beim Boden landete
// (Fehlerbericht zu Issue #27). Diese unsichtbare Flaeche deckt den ganzen Platz ab, analog
// zu .board-column-hit bei den Board-Spalten.
poDeskGroup.appendChild(
el("rect", {
x: -10,
y: 0,
width: PO_DESK.w + 20,
height: 40 + DESK.h + 90,
class: "po-desk-hit",
})
);
poDeskGroup.addEventListener("click", onPoClick);
g.appendChild(poDeskGroup);
return g;
}
// Dass hinter dem PO die Projektkonfiguration steckt, war bisher nirgends zu sehen - der
// Fehlerbericht zu Issue #27 ("damit ich den Konfig Dialog irgendwie oeffnen kann") war
// deshalb auch eine Frage der Auffindbarkeit. Das Zahnrad sitzt auf der Tischplatte des
// PO-Platzes; klickbar ist weiterhin der ganze Platz (und der PO selbst). <title> ist der
// SVG-eigene Tooltip.
function renderPoConfigHint() {
const cx = PO_DESK.w - 34;
const cy = 40 + DESK.h / 2;
const g = el("g", { class: "po-config-hint", transform: `translate(${cx}, ${cy})` });
const title = document.createElementNS(SVG_NS, "title");
title.textContent = "Projekte konfigurieren";
g.appendChild(title);
g.appendChild(el("circle", { cx: 0, cy: 0, r: 15, class: "po-config-hint-bg" }));
// Zahnrad aus acht Zaehnen plus Nabe - in dieser Groesse von einem exakten Zahnradpfad
// nicht zu unterscheiden und ohne fremde Icon-Bibliothek zu haben.
for (let i = 0; i < 8; i++) {
const angle = (i * Math.PI) / 4;
g.appendChild(
el("line", {
x1: Math.cos(angle) * 5.5,
y1: Math.sin(angle) * 5.5,
x2: Math.cos(angle) * 9.5,
y2: Math.sin(angle) * 9.5,
class: "po-config-hint-tooth",
})
);
}
g.appendChild(el("circle", { cx: 0, cy: 0, r: 5, class: "po-config-hint-hub" }));
return g;
}
// ---------------------------------------------------------------------------------------
// Phase 5 - Agenten-Avatare (bewegliche Schicht, siehe Architektur-Hinweis oben)
// ---------------------------------------------------------------------------------------
const agentAvatarElements = new Map();
const idleCosmeticTimers = new Map();
function feetForLocation(location, homeFeet) {
switch (location) {
case AGENT_LOCATIONS.BOARD:
return BOARD_FEET;
case AGENT_LOCATIONS.MONITOR_EVENTS:
case AGENT_LOCATIONS.MONITOR_SYSTEMS:
case AGENT_LOCATIONS.MONITOR_KPI:
return MONITOR_FEET;
case AGENT_LOCATIONS.COFFEE_CORNER:
return COFFEE_FEET;
case AGENT_LOCATIONS.DOOR:
return DOOR_FEET;
case AGENT_LOCATIONS.DESK:
default:
return homeFeet;
}
}
function poseExtras(pose) {
const nodes = [];
if (pose === "walking") {
nodes.push(el("rect", { x: -10, y: 14, width: 8, height: 18, rx: 3, class: "person-leg person-leg-left" }));
nodes.push(el("rect", { x: 2, y: 14, width: 8, height: 18, rx: 3, class: "person-leg person-leg-right" }));
} else if (pose === "coffee") {
nodes.push(el("rect", { x: 16, y: -30, width: 10, height: 10, rx: 2, class: "person-prop-cup" }));
} else if (pose === "reading_newspaper") {
nodes.push(el("rect", { x: -16, y: -48, width: 32, height: 18, rx: 1, class: "person-prop-newspaper" }));
} else if (pose === "talking") {
nodes.push(el("circle", { cx: 20, cy: -40, r: 7, class: "person-prop-speech" }));
} else if (pose === "auth_expired" || pose === "auth_expiring") {
nodes.push(renderAuthLock());
}
return nodes;
}
// Schloss ueber dem Kopf: der Agent sitzt noch da, kommt aber nicht mehr rein (abgelaufene
// Claude-Anmeldung, siehe AGENT_PROBLEM_STATES). Bewusst ein Symbol statt nur einer Einfaerbung -
// auf dem Wandmonitor aus einigen Metern Entfernung ist eine Silhouette schneller zu erfassen
// als ein Farbton. Farbe und Pulsieren kommen ueber die Pose-Klasse aus dem Stylesheet.
function renderAuthLock() {
const g = el("g", { class: "person-prop-lock", transform: "translate(0, -74)" });
// Buegel: Halbkreis oben, offen nach unten - sitzt auf dem Korpus auf.
g.appendChild(el("path", { d: "M-6,0 v-5 a6,6 0 0 1 12,0 v5", class: "person-prop-lock-shackle" }));
g.appendChild(el("rect", { x: -9, y: 0, width: 18, height: 14, rx: 2, class: "person-prop-lock-body" }));
return g;
}
function createAgentAvatarElement() {
const g = el("g", { class: "agent-avatar" });
g.appendChild(el("circle", { cx: 0, cy: -34, r: 16, class: "person-head" }));
g.appendChild(el("rect", { x: -22, y: -20, width: 44, height: 34, rx: 12, class: "person-body" }));
g.appendChild(el("g", { class: "person-extras" }));
g.appendChild(text("", { x: 0, y: 46, class: "avatar-name", "text-anchor": "middle" }));
return g;
}
function updateAgentAvatarElement(g, agent, feet, showName) {
g.setAttribute("transform", `translate(${feet.x}, ${feet.y})`);
g.setAttribute("class", `agent-avatar pose-${agent.pose}`);
g.dataset.state = agent.state || "idle";
const role = ROLES[agent.role];
const body = g.querySelector(".person-body");
body.style.setProperty("--role-color", role ? role.accentColor : "#94a3b8");
const extras = g.querySelector(".person-extras");
extras.replaceChildren(...poseExtras(agent.pose));
const nameEl = g.querySelector(".avatar-name");
nameEl.textContent = showName ? agent.name : "";
}
/**
* Aktualisiert die Agenten-Avatar-Schicht per DOM-Diff (statt Neuzeichnen), damit CSS
* transition das transform-Attribut zwischen zwei Aufenthaltsorten weich animiert
* (Detailantworten F9: "Laufwege und Zustandswechsel realistisch animieren").
*
* @param {SVGGElement} layer
* @param {Array<{agent: object, homeFeet: {x:number,y:number}}>} placements
*/
function updateAgentsLayer(layer, placements) {
const seen = new Set();
placements.forEach(({ agent, homeFeet }, index) => {
const id = String(agent.id ?? `guest-${index}`);
seen.add(id);
let node = agentAvatarElements.get(id);
if (!node) {
node = createAgentAvatarElement();
if (id === PO_AGENT_ID) {
makePoAvatarClickable(node);
}
agentAvatarElements.set(id, node);
layer.appendChild(node);
}
const feet = feetForLocation(agent.location, homeFeet);
const showName = agent.location !== AGENT_LOCATIONS.DESK;
updateAgentAvatarElement(node, agent, feet, showName);
if (id === PO_AGENT_ID) {
// updateAgentAvatarElement() setzt class komplett neu (Pose) - ohne diese Zeile
// verloere der PO die Klickbarkeit beim ersten Zustandswechsel.
node.classList.add("clickable");
}
});
for (const [id, node] of agentAvatarElements) {
if (!seen.has(id)) {
node.remove();
agentAvatarElements.delete(id);
clearIdleCosmeticTimer(id);
}
}
}
// Das Ticket sagt woertlich "bei Klick auf den PO" - gemeint ist die Figur, nicht ihr
// Schreibtisch. Der Avatar liegt in einer eigenen Schicht ueber der Szene und hatte bisher
// keinen Klick-Handler; ein Klick auf den PO selbst lief damit ins Leere. Beides ist jetzt
// klickbar, auch wenn der PO gerade am Board steht statt an seinem Platz.
function makePoAvatarClickable(node) {
const title = document.createElementNS(SVG_NS, "title");
title.textContent = "Projekte konfigurieren";
node.appendChild(title);
node.addEventListener("click", openRepoConfigModal);
}
function clearIdleCosmeticTimer(agentId) {
const handle = idleCosmeticTimers.get(agentId);
if (handle) {
window.clearTimeout(handle.timeoutId);
idleCosmeticTimers.delete(agentId);
}
}
/**
* Rein kosmetische, zufaellige idle-Aktivitaeten (F8: "Nur idle Aktivitaeten ... steuert das
* System selber random" - wird bewusst NICHT an die API gemeldet, ein echter Zustandswechsel
* kommt ausschliesslich vom Agenten selbst). Greift nur, solange die API weiterhin "idle"
* meldet; sobald ein echter Zustand reinkommt, wird der Timer verworfen (siehe
* scheduleIdleCosmeticsForCurrentAgents()).
*/
function scheduleIdleCosmetic(agentId, node) {
if (idleCosmeticTimers.has(agentId)) {
return;
}
const delay = IDLE_MIN_DELAY_MS + Math.random() * (IDLE_MAX_DELAY_MS - IDLE_MIN_DELAY_MS);
const timeoutId = window.setTimeout(() => {
if (!agentAvatarElements.has(agentId) || node.dataset.state !== "idle") {
idleCosmeticTimers.delete(agentId);
return;
}
const activity = pickIdleActivity();
const visual = resolveAgentVisualState(activity);
const homeFeet = node.__homeFeet;
node.setAttribute("transform", `translate(${feetForLocation(visual.location, homeFeet).x}, ${feetForLocation(visual.location, homeFeet).y})`);
node.setAttribute("class", `agent-avatar pose-${visual.pose}`);
node.querySelector(".person-extras").replaceChildren(...poseExtras(visual.pose));
node.querySelector(".avatar-name").textContent = node.__agentName || "";
window.setTimeout(() => {
if (agentAvatarElements.has(agentId) && node.dataset.state === "idle") {
const restingPose = node.__idlePose || "idle";
node.setAttribute("transform", `translate(${homeFeet.x}, ${homeFeet.y})`);
node.setAttribute("class", `agent-avatar pose-${restingPose}`);
node.querySelector(".person-extras").replaceChildren(...poseExtras(restingPose));
node.querySelector(".avatar-name").textContent = "";
}
idleCosmeticTimers.delete(agentId);
}, IDLE_ACTIVITY_DURATION_MS);
}, delay);
idleCosmeticTimers.set(agentId, { timeoutId });
}
function scheduleIdleCosmeticsForCurrentAgents(placements) {
placements.forEach(({ agent, homeFeet }, index) => {
const id = String(agent.id ?? `guest-${index}`);
const node = agentAvatarElements.get(id);
if (!node) {
return;
}
node.__homeFeet = homeFeet;
node.__agentName = agent.name;
node.__idlePose = agent.pose || "idle";
if (agent.state === "idle" || !agent.state) {
scheduleIdleCosmetic(id, node);
} else {
clearIdleCosmeticTimer(id);
}
});
}
// ---------------------------------------------------------------------------------------
// Phase 6 - Tuerbesucher (F2: Rolle "user" kommt nur anlassbezogen durch die linke Tuer,
// siehe detectDoorVisits() in office-scene.js). Laeuft unabhaengig vom Daten-Refresh-Takt
// ueber eigene Timer, damit ein Besuch auch dann sauber zu Ende animiert, wenn zwischendurch
// ein neuer SSE-/Polling-Refresh hereinkommt (siehe repaintAgentsLayer()).
// ---------------------------------------------------------------------------------------
let currentAgentsLayer = null;
let currentRegularPlacements = [];
const doorVisitors = new Map();
let doorVisitCounter = 0;
function doorVisitVisual(visitor) {
if (visitor.atDoor) {
return { location: AGENT_LOCATIONS.DOOR, pose: "walking" };
}
return resolveDoorVisitVisual(visitor.stage);
}
function doorVisitorPlacements() {
return Array.from(doorVisitors.values()).map((visitor) => {
const visual = doorVisitVisual(visitor);
return {
agent: { id: visitor.id, name: "User", role: "user", pose: visual.pose, location: visual.location },
homeFeet: DOOR_FEET,
};
});
}
// Kurzes visuelles Feedback (Tuer "leuchtet" auf), waehrend mindestens ein Besucher am
// Tuerplatz steht - rein kosmetisch, siehe .door-active in office.css.
function updateDoorActiveClass() {
if (!currentAgentsLayer) return;
const svg = currentAgentsLayer.closest("svg");
const doorLeft = svg && svg.querySelector(".door-left");
if (!doorLeft) return;
const anyAtDoor = Array.from(doorVisitors.values()).some(
(visitor) => doorVisitVisual(visitor).location === AGENT_LOCATIONS.DOOR
);
doorLeft.classList.toggle("door-active", anyAtDoor);
}
function repaintAgentsLayer() {
if (!currentAgentsLayer) return;
updateAgentsLayer(currentAgentsLayer, [
...currentRegularPlacements,
...poPlacement(),
...doorVisitorPlacements(),
]);
updateDoorActiveClass();
}
// ---------------------------------------------------------------------------------------
// Product Owner (Issue #19): fester Platz + Avatar, der bei "niemandem zugeordneten"
// Ticket-Statuswechseln zum Board laeuft (siehe detectUnassignedTicketChanges() in
// office-scene.js). Anders als die Tuerbesucher (mehrere gleichzeitig moeglich, Phase 6) gibt
// es nur einen PO - poState ist daher ein einzelner Zustand statt einer Map.
// ---------------------------------------------------------------------------------------
let poIsLive = false;
let poState = "idle";
let poVisitTimeoutId = null;
// Issue #55: wird aufgerufen, sobald der laufende Besuch das Board erreicht - das ist der
// richtige Zeitpunkt, um die bis dahin zurueckgehaltenen Ticket-Aenderungen sichtbar zu machen.
let poOnArrived = null;
function poPlacement() {
if (!poIsLive) {
return [];
}
const visual = resolveAgentVisualState(poState);
return [
{
agent: { id: PO_AGENT_ID, name: PO_DISPLAY_NAME, role: "product_owner", pose: visual.pose, location: visual.location },
homeFeet: PO_DESK_FEET,
},
];
}
function advancePoVisit() {
const nextStage = advancePoVisitStage(poState);
if (nextStage === null) {
poState = "idle";
repaintAgentsLayer();
return;
}
poState = nextStage;
// Issue #55: erst wenn der PO am Board angekommen ist, wird das/die zurueckgehaltene(n)
// Ticket(s) an ihre neue Stelle bewegt - danach wartet er TICKET_PLACED_WAIT_MS, bevor er
// wieder losgeht, statt sofort weiterzulaufen.
if (poState === PO_VISIT_STAGES[1]) {
const onArrived = poOnArrived;
poOnArrived = null;
onArrived?.();
repaintAgentsLayer();
poVisitTimeoutId = window.setTimeout(advancePoVisit, TICKET_PLACED_WAIT_MS);
return;
}
repaintAgentsLayer();
poVisitTimeoutId = window.setTimeout(advancePoVisit, DOOR_VISIT_STAGE_DURATION_MS);
}
// Mehrere gleichzeitige unzugeordnete Aenderungen (z.B. mehrere Tickets erhalten kurz
// hintereinander das Label "automate") loesen bewusst nur einen Besuch aus, statt den PO fuer
// jede einzelne Aenderung hin- und herlaufen zu lassen - ein laufender Besuch wird zu Ende
// animiert, statt unterbrochen zu werden.
//
// @param {() => void} [onArrived] - wird aufgerufen, sobald der PO am Board ankommt (Issue #55).
// Laeuft bereits ein Besuch, gibt es dafuer keine eigene Animation mehr - onArrived wird dann
// sofort aufgerufen, damit die zurueckgehaltene Aenderung nicht dauerhaft verborgen bleibt.
function triggerPoVisit(onArrived) {
if (!poIsLive || poState !== "idle") {
onArrived?.();
return;
}
poState = PO_VISIT_STAGES[0];
poOnArrived = onArrived;
repaintAgentsLayer();
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) {
return;
}
if (visitor.atDoor) {
visitor.atDoor = false;
repaintAgentsLayer();
window.setTimeout(() => advanceDoorVisit(id), DOOR_VISIT_STAGE_DURATION_MS);
return;
}
const nextStage = advanceDoorVisitStage(visitor.stage);
if (nextStage === null) {
doorVisitors.delete(id);
repaintAgentsLayer();
return;
}
visitor.stage = nextStage;
// Issue #55: erst am Board ("interacting_with_board") wird das zurueckgehaltene Ticket an
// seine neue Stelle bewegt - danach wartet die Persona TICKET_PLACED_WAIT_MS, bevor sie
// ("leaving") wieder zur Tuer geht, statt sofort weiterzulaufen.
if (visitor.stage === DOOR_VISIT_STAGES[1]) {
visitor.onArrived?.();
visitor.onArrived = null;
repaintAgentsLayer();
window.setTimeout(() => advanceDoorVisit(id), TICKET_PLACED_WAIT_MS);
return;
}
repaintAgentsLayer();
window.setTimeout(() => advanceDoorVisit(id), DOOR_VISIT_STAGE_DURATION_MS);
}
/**
* @param {{type: string, ticketId: number}} visit
* @param {() => void} [onArrived] - wird aufgerufen, sobald diese Persona am Board ankommt
* (Issue #55) - der richtige Zeitpunkt, um das zurueckgehaltene Ticket sichtbar zu verschieben.
*/
function triggerDoorVisit(visit, onArrived) {
doorVisitCounter += 1;
const id = `door-visit-${visit.type}-${visit.ticketId}-${doorVisitCounter}`;
doorVisitors.set(id, { id, stage: DOOR_VISIT_STAGES[0], atDoor: true, onArrived });
repaintAgentsLayer();
window.setTimeout(() => advanceDoorVisit(id), DOOR_VISIT_STAGE_DURATION_MS);
}
// ---------------------------------------------------------------------------------------
// Modal-Inhalte (Phase 3/4 - Board-/Monitor-Klick, siehe Visualization.md 4.5)
// ---------------------------------------------------------------------------------------
function renderTicketModalBody(body, content) {
if (content.items.length === 0) {
body.appendChild(emptyMessage("Keine Tickets in dieser Spalte."));
return;
}
const list = document.createElement("ul");
list.className = "office-modal-list";
for (const item of content.items) {
const li = document.createElement("li");
li.className = "office-modal-item";
const heading = document.createElement("div");
heading.className = "office-modal-item-heading";
const titleEl = document.createElement("strong");
titleEl.textContent = item.title;
heading.appendChild(titleEl);
if (item.type) {
const badge = document.createElement("span");
badge.className = "office-modal-badge";
badge.textContent = item.type;
if (item.color) badge.style.backgroundColor = item.color;
heading.appendChild(badge);
}
li.appendChild(heading);
if (item.description) {
const desc = document.createElement("p");
desc.textContent = item.description;
li.appendChild(desc);
}
if (item.link) {
const a = document.createElement("a");
a.href = item.link;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.textContent = "Auf Codeberg öffnen";
li.appendChild(a);
}
list.appendChild(li);
}
body.appendChild(list);
}
function renderEventModalBody(body, content) {
if (content.items.length === 0) {
body.appendChild(emptyMessage("Keine Events vorhanden."));
return;
}
const list = document.createElement("ul");
list.className = "office-modal-list";
for (const item of content.items) {
const li = document.createElement("li");
li.className = `office-modal-item severity-${item.severity}`;
li.textContent = `${item.time} · ${item.name}${item.systemId ? ` (${item.systemId})` : ""}: ${item.message}`;
list.appendChild(li);
}
body.appendChild(list);
}
function renderSystemModalBody(body, content) {
if (content.items.length === 0) {
body.appendChild(emptyMessage("Keine Systeme gemeldet."));
return;
}
const list = document.createElement("ul");
list.className = "office-modal-list";
for (const item of content.items) {
const li = document.createElement("li");
li.className = `office-modal-item status-${item.status}`;
const isReachable = item.status === "ok";
const lastCheckText = isReachable && item.lastCheckAt
? ` · zuletzt erfolgreich geprüft: ${item.lastCheckAt}`
: "";
li.textContent = `${item.name} (${item.type}) – ${isReachable ? "OK" : "Fehler"}${item.detail ? `: ${item.detail}` : ""}${lastCheckText}`;
list.appendChild(li);
}
body.appendChild(list);
}
function renderKpiModalBody(body, content) {
if (content.items.length === 0) {
body.appendChild(emptyMessage("Noch keine KPIs gemeldet – kommt in einer späteren Phase."));
return;
}
const list = document.createElement("ul");
list.className = "office-modal-list";
for (const item of content.items) {
const li = document.createElement("li");
li.className = "office-modal-item";
const label = document.createElement("div");
label.textContent = `${item.label}: ${item.currentValue ?? "–"} ${item.unit}`;
li.appendChild(label);
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("viewBox", "0 0 320 80");
svg.setAttribute("class", "kpi-modal-sparkline");
svg.appendChild(el("polyline", { points: item.sparklinePoints, class: "kpi-sparkline-line" }));
li.appendChild(svg);
list.appendChild(li);
}
body.appendChild(list);
}
function emptyMessage(message) {
const p = document.createElement("p");
p.className = "office-modal-empty";
p.textContent = message;
return p;
}
// ---------------------------------------------------------------------------------------
// Projekte-Modal (Issue #27: Klick auf den PO -> Codeberg-Projekte des Sync verwalten)
//
// Anders als die uebrigen Modals (reine Anzeige eines einmal gebauten content-Objekts) ist
// dieses Modal interaktiv (Hinzufuegen/Bearbeiten/Loeschen mit Server-Rundtrip) und muss sich
// nach jeder Aenderung selbst neu zeichnen, ohne das <dialog> zu schliessen. openModal() ruft
// den renderBody-Callback dafuer nur einmal mit dem echten Body-Element auf; das Element wird
// hier in repoModalBodyEl gemerkt und fuer spaetere Refreshes wiederverwendet.
// ---------------------------------------------------------------------------------------
const REPO_CONFIG_ICONS = {
add: "M12 5v14M5 12h14",
edit: "M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z",
delete: "M4 7h16M9 7V4.5A1.5 1.5 0 0 1 10.5 3h3A1.5 1.5 0 0 1 15 4.5V7m2 0-.8 12.1a2 2 0 0 1-2 1.9H9.8a2 2 0 0 1-2-1.9L7 7",
confirm: "M20 6 9 17l-5-5",
cancel: "M18 6 6 18M6 6l12 12",
};
// Issue #53: Symbole fuer die Konfig-Pruefung (Schreibrechte + Webhook) rechts neben den
// Bearbeiten-/Loeschen-Symbolen einer Projektzeile - gruener Haken bzw. rotes Kreuz je Check.
const REPO_CHECK_ICONS = {
ok: "M20 6 9 17l-5-5",
fail: "M18 6 6 18M6 6l12 12",
};
let repoModalBodyEl = null;
let repoModalToken = null;
let repoModalRepos = [];
let repoModalMode = null; // null | "new" | <id des bearbeiteten Projekts>
let repoModalError = "";
// Issue #53: Ergebnis der Konfig-Pruefung je Projekt-Id, ausschliesslich fuer die Dauer der
// Modal-Sitzung (kein Server-State) - { write_access: {ok, message}, webhook: {ok, message} }.
// Fehlt ein Eintrag, wurde das Projekt in dieser Sitzung noch nicht (erfolgreich) geprueft.
let repoModalChecks = {};
function iconButton(kind, title, onClick, extraClass = "") {
const button = document.createElement("button");
button.type = "button";
button.className = `icon-button ${extraClass}`.trim();
button.title = title;
button.setAttribute("aria-label", title);
button.appendChild(
el("svg", { viewBox: "0 0 24 24", class: "icon-button-svg", "aria-hidden": "true" }, [
el("path", {
d: REPO_CONFIG_ICONS[kind],
fill: "none",
stroke: "currentColor",
"stroke-width": "2",
"stroke-linecap": "round",
"stroke-linejoin": "round",
}),
])
);
button.addEventListener("click", onClick);
return button;
}
function openRepoConfigModal() {
repoModalRepos = [];
repoModalMode = null;
repoModalError = "";
repoModalChecks = {};
repoModalToken = openModal({ title: "Projekte" }, (body) => {
repoModalBodyEl = body;
renderRepoConfigModal();
});
loadRepoConfigModal(repoModalToken);
}
async function loadRepoConfigModal(token) {
let repos = null;
let error = "";
try {
repos = await fetchRepoConfigs();
} catch {
error = "Projekte konnten nicht geladen werden.";
}
if (!isCurrentModal(token)) {
return;
}
if (repos !== null) {
repoModalRepos = repos;
}
repoModalError = error;
renderRepoConfigModal();
}
function renderRepoConfigModal() {
const body = repoModalBodyEl;
if (!body) return;
body.replaceChildren();
if (repoModalError) {
const errorEl = document.createElement("p");
errorEl.className = "office-modal-error";
errorEl.textContent = repoModalError;
body.appendChild(errorEl);
}
if (repoModalRepos.length === 0 && repoModalMode !== "new") {
body.appendChild(emptyMessage("Keine Projekte konfiguriert."));
} else {
const list = document.createElement("ul");
list.className = "office-modal-list repo-config-list";
for (const repo of repoModalRepos) {
list.appendChild(
repoModalMode === repo.id ? renderRepoConfigForm(repo) : renderRepoConfigRow(repo)
);
}
body.appendChild(list);
}
if (repoModalMode === "new") {
let list = body.querySelector(".repo-config-list");
if (!list) {
list = document.createElement("ul");
list.className = "office-modal-list repo-config-list";
body.appendChild(list);
}
list.appendChild(renderRepoConfigForm(null));
} else {
const addButton = iconButton("add", "Projekt hinzufügen", () => {
repoModalMode = "new";
renderRepoConfigModal();
});
addButton.classList.add("repo-config-add-button");
body.appendChild(addButton);
}
}
function renderRepoConfigRow(repo) {
const li = document.createElement("li");
li.className = "office-modal-item repo-config-item";
const swatch = document.createElement("span");
swatch.className = "repo-config-swatch";
swatch.style.backgroundColor = repo.color;
li.appendChild(swatch);
const label = document.createElement("span");
label.className = "repo-config-slug";
label.textContent = `${repo.owner}/${repo.repo}`;
li.appendChild(label);
const actions = document.createElement("span");
actions.className = "repo-config-actions";
actions.appendChild(
iconButton("edit", "Projekt bearbeiten", () => {
repoModalMode = repo.id;
renderRepoConfigModal();
})
);
actions.appendChild(
iconButton("delete", "Projekt löschen", () => handleRepoConfigDelete(repo))
);
li.appendChild(actions);
const checks = repoModalChecks[repo.id];
if (checks && checks.status === "done") {
const status = document.createElement("span");
status.className = "repo-config-status";
status.appendChild(statusIcon("Schreibrechte", checks.writeAccess));
status.appendChild(statusIcon("Webhook", checks.webhook));
li.appendChild(status);
}
return li;
}
/**
* Issue #53: einzelnes Ampel-Symbol (Haken/Kreuz) fuer eine Konfig-Pruefung. Der Tooltip
* (title/aria-label) zeigt im Erfolgsfall, welcher Service geprueft wurde, im Fehlerfall den
* konkreten Grund - beides liefert bereits RepoHealthChecker als "message".
*
* @param {string} label
* @param {{ok: boolean, message: string}} result
*/
function statusIcon(label, result) {
const span = document.createElement("span");
span.className = `repo-config-status-icon ${result.ok ? "is-ok" : "is-fail"}`;
const tooltip = `${label}: ${result.message}`;
span.title = tooltip;
span.setAttribute("aria-label", tooltip);
span.appendChild(
el("svg", { viewBox: "0 0 24 24", class: "icon-button-svg", "aria-hidden": "true" }, [
el("path", {
d: result.ok ? REPO_CHECK_ICONS.ok : REPO_CHECK_ICONS.fail,
fill: "none",
stroke: "currentColor",
"stroke-width": "2",
"stroke-linecap": "round",
"stroke-linejoin": "round",
}),
])
);
return span;
}
function renderRepoConfigForm(repo) {
const li = document.createElement("li");
li.className = "office-modal-item repo-config-item repo-config-form";
const ownerInput = document.createElement("input");
ownerInput.type = "text";
ownerInput.placeholder = "Owner, z. B. fds2610";
ownerInput.value = repo?.owner ?? "";
ownerInput.setAttribute("aria-label", "Owner");
const repoInput = document.createElement("input");
repoInput.type = "text";
repoInput.placeholder = "Repo, z. B. monitor";
repoInput.value = repo?.repo ?? "";
repoInput.setAttribute("aria-label", "Repo-Name");
const colorInput = document.createElement("input");
colorInput.type = "color";
colorInput.value = repo?.color ?? "#999999";
colorInput.setAttribute("aria-label", "Farbe");
colorInput.title = "Farbe";
const fields = document.createElement("span");
fields.className = "repo-config-form-fields";
fields.append(ownerInput, repoInput, colorInput);
li.appendChild(fields);
const actions = document.createElement("span");
actions.className = "repo-config-actions";
const save = () =>
handleRepoConfigSave(repo, {
owner: ownerInput.value.trim(),
repo: repoInput.value.trim(),
color: colorInput.value,
});
actions.appendChild(iconButton("confirm", "Speichern", save));
actions.appendChild(
iconButton("cancel", "Abbrechen", () => {
repoModalMode = null;
renderRepoConfigModal();
})
);
li.appendChild(actions);
// Enter in einem der Textfelder wuerde sonst das <form method="dialog"> ueber dessen
// regulaeren Submit-Button (den Schliessen-"×" im Modal-Header) einreichen und das ganze
// Modal schliessen, statt nur dieses Formular zu speichern.
const saveOnEnter = (event) => {
if (event.key === "Enter") {
event.preventDefault();
save();
}
};
ownerInput.addEventListener("keydown", saveOnEnter);
repoInput.addEventListener("keydown", saveOnEnter);
window.setTimeout(() => ownerInput.focus(), 0);
return li;
}
async function handleRepoConfigSave(existing, data) {
const token = repoModalToken;
if (!data.owner || !data.repo) {
repoModalError = 'Felder "Owner" und "Repo" sind erforderlich.';
renderRepoConfigModal();
return;
}
const saved = await withAdminKeyResult((adminKey) =>
existing ? updateRepoConfig(existing.id, data, adminKey) : createRepoConfig(data, adminKey)
);
if (!isCurrentModal(token)) {
return;
}
if (!saved) {
renderRepoConfigModal();
return;
}
repoModalMode = null;
await loadRepoConfigModal(token);
// Issue #53: nach dem Speichern pruefen, ob fds2610-bot Schreibrechte hat und der Webhook
// korrekt eingerichtet ist - Ergebnis erscheint als Haken/Kreuz rechts neben den Symbolen.
await runRepoConfigCheck(saved.id);
}
/**
* Issue #53: fuehrt die Konfig-Pruefung (Schreibrechte + Webhook) fuer ein Projekt aus und
* merkt das Ergebnis in repoModalChecks fuer renderRepoConfigRow(). Schlaegt schon der
* Admin-Key-Rundtrip fehl (z.B. Prompt abgebrochen), bleibt das Projekt einfach ungeprueft
* (kein Eintrag) statt faelschlich ein rotes Kreuz zur Projekt-Gesundheit zu zeigen.
*/
async function runRepoConfigCheck(repoId) {
const token = repoModalToken;
const checks = await withAdminKeyResult((adminKey) => checkRepoConfig(repoId, adminKey));
if (!isCurrentModal(token)) {
return;
}
if (checks) {
repoModalChecks[repoId] = { status: "done", writeAccess: checks.write_access, webhook: checks.webhook };
}
renderRepoConfigModal();
}
async function handleRepoConfigDelete(repo) {
const token = repoModalToken;
if (!window.confirm(`Projekt "${repo.owner}/${repo.repo}" wirklich entfernen?`)) {
return;
}
const ok = await withAdminKey((adminKey) => deleteRepoConfig(repo.id, adminKey));
if (!isCurrentModal(token)) {
return;
}
if (!ok) {
renderRepoConfigModal();
return;
}
await loadRepoConfigModal(token);
}
/**
* Fuehrt eine schreibende Aktion mit dem Admin-Key aus (aus dem lokalen Speicher oder frisch
* per Prompt erfragt, siehe office-api.js). Bei 401 wird der gemerkte Key verworfen, damit
* der naechste Versuch erneut fragt statt denselben ungueltigen Wert wiederzuverwenden.
*
* @param {(adminKey: string) => Promise<unknown>} action
* @returns {Promise<boolean>}
*/
async function withAdminKey(action) {
let adminKey = getStoredAdminKey();
if (!adminKey) {
adminKey = window.prompt("Admin-Key für Änderungen an Projekten:") || "";
if (!adminKey) {
return false;
}
}
try {
await action(adminKey);
setStoredAdminKey(adminKey);
return true;
} catch (err) {
if (err && err.isAuthError) {
setStoredAdminKey("");
repoModalError = "Admin-Key ungültig.";
} else {
repoModalError = err instanceof Error ? err.message : "Aktion fehlgeschlagen.";
}
return false;
}
}
/**
* Wie withAdminKey(), gibt bei Erfolg aber den Rueckgabewert von action() zurueck statt nur
* true - fuer Aufrufer, die (anders als Speichern/Loeschen, die den Zustand ohnehin per
* loadRepoConfigModal() neu laden) das Ergebnis selbst brauchen (Issue #53: Konfig-Pruefung).
*
* @template T
* @param {(adminKey: string) => Promise<T>} action
* @returns {Promise<T|undefined>}
*/
async function withAdminKeyResult(action) {
let adminKey = getStoredAdminKey();
if (!adminKey) {
adminKey = window.prompt("Admin-Key für Änderungen an Projekten:") || "";
if (!adminKey) {
return undefined;
}
}
try {
const value = await action(adminKey);
setStoredAdminKey(adminKey);
return value;
} catch (err) {
if (err && err.isAuthError) {
setStoredAdminKey("");
repoModalError = "Admin-Key ungültig.";
} else {
repoModalError = err instanceof Error ? err.message : "Aktion fehlgeschlagen.";
}
return undefined;
}
}
// ---------------------------------------------------------------------------------------
// Datenbeschaffung + Orchestrierung
// ---------------------------------------------------------------------------------------
async function loadLiveData() {
const [tickets, events, systems, kpis, agents, webhookStats] = await Promise.all([
fetchTickets(),
fetchEvents(),
fetchSystems(),
fetchKpis(),
fetchAgents(),
fetchWebhookStats(),
]);
return { tickets, events, systems, kpis, agents, hooksCount: webhookStats.count, isLive: true };
}
const animationQueue = new AnimationQueue({ minIntervalMs: 120 });
window.setInterval(() => animationQueue.processDue(), 50);
function flash(node) {
animationQueue.enqueue(() => {
if (!node.isConnected) return;
node.classList.add("just-updated");
window.setTimeout(() => node.classList.remove("just-updated"), 900);
});
}
export function renderOfficeScene(container, data = {}) {
const {
ticketCounts = DEMO_TICKET_COUNTS,
agents = DEMO_AGENTS,
systems = DEMO_SYSTEMS,
events = DEMO_EVENTS,
kpis = [],
tickets = [],
hooksCount = null,
isLive = false,
} = data;
let svg = container.querySelector(".office-svg");
let firstRender = false;
if (!svg) {
firstRender = true;
svg = el("svg", {
viewBox: `0 0 ${VIEW_W} ${VIEW_H}`,
class: "office-svg",
role: "img",
"aria-label": "Buero-Visualisierung mit Kanban-Board, Monitor-Wand und Schreibtischen",
});
svg.appendChild(renderRoom());
svg.appendChild(el("g", { class: "agents-layer" }));
container.replaceChildren(svg);
agentAvatarElements.clear();
idleCosmeticTimers.forEach((handle) => window.clearTimeout(handle.timeoutId));
idleCosmeticTimers.clear();
doorVisitors.clear();
if (poVisitTimeoutId) window.clearTimeout(poVisitTimeoutId);
poVisitTimeoutId = null;
poState = "idle";
}
// Der PO gilt als anwesend, sobald die API erreichbar ist (isLive) - nicht ueber das
// is_present-Feld wie registrierte Agenten (Issue #19).
poIsLive = isLive;
// "Agenten"/"Online" (Issue #39) stehen vor den gemeldeten KPIs (F7), damit sie auf der
// Wandkachel (MAX_KPI_TILES) immer sichtbar sind, statt von gemeldeten Kennzahlen verdraengt
// zu werden. "Hooks" (Issue #38) reserviert sich zusaetzlich die letzte (rechte) Kachel -
// hooksCount ist nur im Live-Betrieb gesetzt (loadLiveData()), im Demo-Fallback bleibt die
// Kachel bewusst weg statt eine erfundene Zahl zu zeigen (wie DEMO_KPI_PLACEHOLDER es fuer
// gemeldete KPIs bereits vormacht).
const hooksKpi = typeof hooksCount === "number" ? buildHooksKpi(hooksCount) : null;
const baseSlots = hooksKpi ? MAX_KPI_TILES - 1 : MAX_KPI_TILES;
const baseKpis = [...buildAgentCountKpis(agents), ...kpis].slice(0, baseSlots);
const displayKpis = hooksKpi ? [...baseKpis, hooksKpi] : baseKpis;
const groupedTickets = groupTicketsByColumn(tickets);
const boardGroup = renderBoard(
ticketCounts,
groupedTickets,
(columnKey) => {
openModal(
buildTicketModalContent(columnKey, groupedTickets[columnKey] || []),
renderTicketModalBody
);
},
handleBoardResync
);
const existingBoard = svg.querySelector(".board");
if (existingBoard) {
existingBoard.replaceWith(boardGroup);
if (!firstRender) flash(boardGroup);
} else {
svg.insertBefore(boardGroup, svg.querySelector(".agents-layer"));
}
const wallGroup = renderMonitorWall(systems, events, displayKpis, {
onEventsClick: () => openModal(buildEventModalContent(events), renderEventModalBody),
onSystemsClick: () => openModal(buildSystemModalContent(systems), renderSystemModalBody),
onKpiClick: () => openModal(buildKpiModalContent(displayKpis), renderKpiModalBody),
});
const existingWall = svg.querySelector(".monitor-wall");
if (existingWall) {
existingWall.replaceWith(wallGroup);
if (!firstRender) flash(wallGroup);
} else {
svg.insertBefore(wallGroup, svg.querySelector(".agents-layer"));
}
const deskLayout = buildDeskLayout(normalizedDeskAgents(agents));
const poDeskAgent = isLive ? { name: PO_DISPLAY_NAME, role: "product_owner", pose: "idle" } : null;
// Auch ohne erreichbare API klickbar: das Modal sagt dann selbst, dass es die Projekte
// nicht laden konnte. Ein toter PO waere in genau der Lage, in der man nachsehen will,
// die schlechtere Antwort (Fehlerbericht zu Issue #27).
const desksGroup = renderDesks(deskLayout, poDeskAgent, openRepoConfigModal);
const existingDesks = svg.querySelector(".desks");
if (existingDesks) {
existingDesks.replaceWith(desksGroup);
} else {
svg.insertBefore(desksGroup, svg.querySelector(".agents-layer"));
}
const placements = agentPlacements(agents, deskLayout);
currentAgentsLayer = svg.querySelector(".agents-layer");
currentRegularPlacements = placements;
repaintAgentsLayer();
scheduleIdleCosmeticsForCurrentAgents(placements);
// Demo-Agenten kennen kein state-Feld und loesen den Banner damit nie aus - eine erfundene
// Anmelde-Warnung waere im Fallback so falsch wie eine erfundene Kennzahl.
setAgentProblemBanner(agents);
}
// Demo-Agenten (Phase 2) tragen bereits {name, role, pose} ohne desk_index/state - diese
// Helfer machen buildDeskLayout()/renderAgentAvatar() fuer beide Formen (Demo- und
// Live-API-Agenten) nutzbar, ohne die Demo-Daten anfassen zu muessen.
function normalizedDeskAgents(agents) {
const hasDeskIndex = agents.some((a) => a && typeof a.desk_index !== "undefined");
if (!hasDeskIndex) {
return agents;
}
return mapAgentsToDesks(agents).map((agent) => (agent ? normalizeAgentForDisplay(agent) : null));
}
function agentPlacements(agents, deskLayout) {
const hasDeskIndex = agents.some((a) => a && typeof a.desk_index !== "undefined");
if (!hasDeskIndex) {
// Demo-Daten: Positionsindex in der Liste == Schreibtisch-Index (Phase 2-Verhalten).
return deskLayout
.filter((d) => d.agent)
.map((d) => ({ agent: { ...d.agent, location: AGENT_LOCATIONS.DESK, state: "idle" }, homeFeet: deskSeat(d.deskIndex).feet }));
}
const placements = [];
const seatedIds = new Set();
deskLayout.forEach((desk) => {
if (desk.agent) {
seatedIds.add(desk.agent.id);
placements.push({ agent: desk.agent, homeFeet: deskSeat(desk.deskIndex).feet });
}
});
// Agenten ohne Schreibtisch (Rolle "user" oder alle Plaetze belegt, F2) bekommen einen
// Ruheplatz vor der linken Tuer zugewiesen, gestaffelt nebeneinander.
let overflowIndex = 0;
for (const raw of agents) {
if (seatedIds.has(raw.id)) continue;
// isAgentVisible statt isAgentPresent, wie bei den Schreibtischen (mapAgentsToDesks): auch
// ein blockierter Agent ohne festen Platz muss sichtbar bleiben.
if (!isAgentVisible(raw)) continue;
const agent = normalizeAgentForDisplay(raw);
const homeFeet = {
x: 70 + (overflowIndex % 3) * 26,
y: DOOR.y + 40 + Math.floor(overflowIndex / 3) * 40,
};
placements.push({ agent, homeFeet });
overflowIndex += 1;
}
return placements;
}
// Der Demo-Fallback unten sieht auf den ersten Blick wie eine echte Szene aus (fremde
// Agenten-Namen, erfundene Ticketzahlen, aber leere Detail-Modals). Damit das auf einem
// Produktiv-Monitor nicht fuer echte Daten gehalten wird, blendet der Fallback zusaetzlich
// einen roten Banner ein - und wieder aus, sobald die API antwortet.
function setApiBanner(visible) {
const banner = document.getElementById("api-status-banner");
if (banner) {
banner.hidden = !visible;
}
}
// Ein blockierter Agent ist der einzige Zustand, den der Monitor nicht aussitzen kann: er loest
// sich nicht von selbst, sondern erst, wenn sich jemand neu anmeldet. Deshalb zusaetzlich zum
// Schloss am Avatar ein Banner im Header - der ist auch dann noch zu lesen, wenn der Monitor
// quer durch den Raum haengt und die Szene nur noch als Muster wahrgenommen wird.
function setAgentProblemBanner(agents) {
const banner = document.getElementById("agent-problem-banner");
if (!banner) {
return;
}
const names = (severity) =>
agentsWithProblems(agents)
.filter((agent) => agentProblemSeverity(agent) === severity)
.map((agent) => agent.display_name ?? agent.name ?? `Agent ${agent.id}`)
.join(", ");
const expired = names("error");
const expiring = names("warning");
const lines = [];
if (expired) lines.push(`Anmeldung abgelaufen: ${expired}`);
if (expiring) lines.push(`Anmeldung läuft ab: ${expiring}`);
banner.textContent = lines.join(" · ");
banner.hidden = lines.length === 0;
banner.classList.toggle("api-banner-warning", !expired && Boolean(expiring));
}
// 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) {
return;
}
// Vorheriger Ticket-Snapshot fuer detectDoorVisits() (Phase 6, F2) - bleibt null im
// Demo-Fallback (kein "echter" Anlass ohne Live-API) und wird nur bei erreichbarer API
// gepflegt, damit die Tuerbesuche ausschliesslich auf echten Zustandswechseln beruhen.
let previousTickets = null;
// Issue #55: letzter vollstaendige API-Stand, damit commitTicketMoves() die Szene ohne neuen
// Fetch neu zeichnen kann, sobald eine Persona am Board ankommt.
let lastLiveData = null;
// ticketId -> alter Ticket-Stand (null bei einem brandneuen Ticket, siehe
// applyPendingTicketMoves() in office-scene.js). Ein Eintrag bleibt bestehen, bis die
// zugehoerige Persona (Tuerbesucher oder PO) das Board erreicht hat - vorher wird das Ticket
// weiterhin an seiner alten Stelle (bzw. bei neuen Tickets: gar nicht) angezeigt.
const pendingTicketMoves = new Map();
function renderLiveScene(data) {
const displayTickets = applyPendingTicketMoves(data.tickets, pendingTicketMoves);
renderOfficeScene(container, {
ticketCounts: aggregateTicketsByColumn(displayTickets),
tickets: displayTickets,
agents: data.agents,
systems: data.systems,
events: data.events,
kpis: data.kpis,
hooksCount: data.hooksCount,
isLive: true,
});
}
// Wird als onArrived-Callback an triggerDoorVisit()/triggerPoVisit() gereicht: macht die bis
// dahin zurueckgehaltene(n) Ticket-Aenderung(en) sichtbar, sobald die Persona am Board steht.
function commitTicketMoves(ticketIds) {
ticketIds.forEach((id) => pendingTicketMoves.delete(id));
if (lastLiveData) {
renderLiveScene(lastLiveData);
}
}
async function refresh() {
try {
const data = await loadLiveData();
lastLiveData = data;
if (previousTickets) {
const previousById = new Map(previousTickets.map((ticket) => [ticket.id, ticket]));
detectDoorVisits(previousTickets, data.tickets).forEach((visit) => {
pendingTicketMoves.set(visit.ticketId, previousById.get(visit.ticketId) ?? null);
triggerDoorVisit(visit, () => commitTicketMoves([visit.ticketId]));
});
const unassignedChanges = detectUnassignedTicketChanges(previousTickets, data.tickets);
if (unassignedChanges.length > 0) {
const ticketIds = unassignedChanges.map((change) => change.ticketId);
ticketIds.forEach((ticketId) => {
pendingTicketMoves.set(ticketId, previousById.get(ticketId) ?? null);
});
triggerPoVisit(() => commitTicketMoves(ticketIds));
}
}
previousTickets = data.tickets;
renderLiveScene(data);
setApiBanner(false);
} catch (err) {
console.warn("[office] Live-API nicht erreichbar, zeige Demo-Daten.", err);
renderOfficeScene(container);
setApiBanner(true);
}
}
requestSceneRefresh = refresh;
await refresh();
startRealtimeUpdates(refresh);
}
document.addEventListener("DOMContentLoaded", () => {
boot();
startReloadCountdown();
});