monitor/html/js/office-render.js
2026-09-21 13:05:50 +02:00

2662 lines
96 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,
detectAgentOwnedTicketChanges,
hasAgentReachedBoard,
isAgentReportingBoardVisit,
PO_AGENT_ID,
PO_DISPLAY_NAME,
resolveReloadIntervalSeconds,
nextCountdownSeconds,
systemsWithoutHostConfig,
} 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,
fetchHostConfigs,
createHostConfig,
updateHostConfig,
deleteHostConfig,
deleteSystem,
recordingUrl,
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;
// Zeichenbreite der Servernamen im System-Monitor: .system-tile-name ist 11px Monospace,
// dort misst ein Zeichen rund 0.61 * Schriftgroesse (dieselbe Rechnung wie
// EVENT_LINE_CHAR_WIDTH fuer die 13px-Eventzeilen).
const SYSTEM_NAME_CHAR_WIDTH = 6.7;
// 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(text("☕", { x, y, 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.
*/
// Issue #85: kleiner Zaehler-Kreis fuer offene Sprachaufnahmen auf dem Schreibtisch eines
// Agenten - gleiches Prinzip wie renderStackBadge() (Kreis + zentrierte Zahl), aber
// eigenstaendig und kleiner, weil er neben dem rollenabhaengigen Deko-Symbol Platz haben muss.
function renderRecordingBadge(cx, cy, count) {
const g = el("g", { class: "recording-badge" });
g.appendChild(el("circle", { cx, cy, r: 12, class: "recording-badge-circle" }));
g.appendChild(
text(String(count), { x: cx, y: cy + 4, class: "recording-badge-count", "text-anchor": "middle" })
);
return g;
}
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;
}
// Statussymbol einer System-Kachel: gruener Haken bei "ok", rotes X bei "error" (die einzigen
// beiden Werte, die SystemRepository::STATUSES kennt). Gezeichnet als Pfad und nicht als
// Textglyphe - auf dem Wandmonitor faellt eine fehlende Schriftart sonst als leere Kachel auf.
// Das Symbol fuellt die Kachel bis auf einen schmalen Rand aus; alle Masse haengen an der
// Kantenlaenge, damit es jede Kachelgroesse mitgeht.
function renderSystemStatusMark(status, tileX, tileY, tileSize) {
const pad = tileSize * 0.15;
const size = tileSize - pad * 2;
const strokeWidth = Math.max(2.5, size * 0.16);
const bx = tileX + pad;
const by = tileY + pad;
// Die Endpunkte liegen um eine halbe Strichbreite innen, sonst ragen die runden Enden ueber
// die Flaeche hinaus.
const lo = strokeWidth / 2;
const hi = size - strokeWidth / 2;
const d =
status === "error"
? `M ${bx + lo} ${by + lo} L ${bx + hi} ${by + hi} M ${bx + hi} ${by + lo} L ${bx + lo} ${by + hi}`
: `M ${bx + lo} ${by + size * 0.55} L ${bx + size * 0.38} ${by + hi} L ${bx + hi} ${by + lo}`;
return el("path", { d, class: "system-tile-mark", "stroke-width": strokeWidth });
}
function renderSystemMonitor(frame, systems, onConfigClick) {
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(renderSystemConfigHint(frame, onConfigClick));
g.appendChild(
el("rect", {
x: frame.x + 10,
y: frame.y + 10,
width: frame.w - 20,
height: frame.h - 20,
class: "monitor-screen",
})
);
const tileGap = 8;
const gridX = frame.x + 18;
const gridY = frame.y + 18;
// Halbe Kantenlaenge des frueheren dreispaltigen Rasters. Die Kacheln stehen jetzt
// untereinander, rechts daneben steht der Servername - MAX_SYSTEM_TILES (9) Zeilen passen
// damit in die Hoehe des Monitors (frame.h - 36).
const tileSize = (frame.w - 36 - tileGap * 2) / 6;
const nameX = gridX + tileSize + tileGap;
const nameWidth = frame.x + frame.w - 18 - nameX;
tiles.forEach((system, index) => {
const tileX = gridX;
const tileY = gridY + index * (tileSize + tileGap);
// Issue #73: Servernamen ueberlappten sich im Raster und blieben deshalb dem Tooltip
// vorbehalten. Untereinander stehen sie sich nicht mehr im Weg, der Name steht wieder in
// der Kachelzeile - auf nameWidth gekuerzt, damit er nicht ueber den Monitor hinausragt.
// Der Tooltip bleibt und zeigt den ungekuerzten Namen.
const tileGroup = el("g", { class: `system-tile status-${system.status}` });
const tileTitle = el("title");
tileTitle.textContent = system.name;
tileGroup.appendChild(tileTitle);
tileGroup.appendChild(
el("rect", { x: tileX, y: tileY, width: tileSize, height: tileSize, rx: 4, class: "system-tile-bg" })
);
tileGroup.appendChild(renderSystemStatusMark(system.status, tileX, tileY, tileSize));
tileGroup.appendChild(
text(truncateToWidth(system.name, nameWidth, SYSTEM_NAME_CHAR_WIDTH), {
x: nameX,
y: tileY + tileSize / 2,
class: "system-tile-name",
})
);
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,
* onSystemsConfigClick: () => 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, handlers.onSystemsConfigClick);
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" }));
// Issue #85: Zaehler offener Sprachaufnahmen am Schreibtisch dieses Agenten - oben links,
// damit er nicht mit der rollenabhaengigen Deko (star/flower/check/gear) oben rechts
// kollidiert. Nur sichtbar, wenn tatsaechlich etwas in der Warteschlange liegt.
if (agent && agent.pending_recordings > 0) {
g.appendChild(renderRecordingBadge(14, 4, agent.pending_recordings));
}
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" })
);
} else if (role.deco === "gear") {
const cx = width - 45;
const cy = 22;
// Zahnrad = Ring plus sechs Zaehne, jeder um 60 Grad um die Mitte gedreht.
const teeth = [];
for (let i = 0; i < 6; i++) {
teeth.push(
el("rect", {
x: cx - 2.5,
y: cy - 15,
width: 5,
height: 7,
rx: 1,
transform: `rotate(${i * 60}, ${cx}, ${cy})`,
})
);
}
g.appendChild(
el("g", { class: "desk-deco desk-deco-gear" }, [
...teeth,
el("circle", { cx, cy, r: 9 }),
el("circle", { cx, cy, r: 3.5, class: "desk-deco-gear-center" }),
])
);
}
// 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);
gearHintShapes().forEach((node) => g.appendChild(node));
return g;
}
/**
* Zahnrad aus acht Zaehnen plus Nabe - in dieser Groesse von einem exakten Zahnradpfad nicht
* zu unterscheiden und ohne fremde Icon-Bibliothek zu haben. Gemeinsame Silhouette fuer alle
* Konfig-Hinweise (PO-Platz, Issue #27; Systeme-Monitor, Issue #70): Dass ueberall dasselbe
* Symbol fuer "hier laesst sich etwas einstellen" steht, ist der halbe Wiedererkennungswert.
* Die Farben kommen ueber die Klassen config-hint-*; das Hover-Verhalten haengt dagegen an
* der umgebenden Gruppe, weil es sich unterscheidet (der PO reagiert auf den ganzen
* Schreibtisch, das Monitor-Zahnrad nur auf sich selbst).
*
* @param {number} scale - 1 = die Groesse am PO-Platz (Radius 15).
*/
function gearHintShapes(scale = 1) {
const nodes = [el("circle", { cx: 0, cy: 0, r: 15 * scale, class: "config-hint-bg" })];
for (let i = 0; i < 8; i++) {
const angle = (i * Math.PI) / 4;
nodes.push(
el("line", {
x1: Math.cos(angle) * 5.5 * scale,
y1: Math.sin(angle) * 5.5 * scale,
x2: Math.cos(angle) * 9.5 * scale,
y2: Math.sin(angle) * 9.5 * scale,
class: "config-hint-tooth",
})
);
}
nodes.push(el("circle", { cx: 0, cy: 0, r: 5 * scale, class: "config-hint-hub" }));
return nodes;
}
/**
* Issue #70: Zahnrad neben der Ueberschrift "Systeme" - oeffnet die Verwaltung der
* ueberwachten Hosts. Sitzt an derselben Stelle wie das Reload-Symbol am Board
* (renderBoardReloadHint), damit beide Bedienelemente ein System ergeben.
*/
function renderSystemConfigHint(frame, onClick) {
const g = el("g", {
class: "system-config-hint clickable",
transform: `translate(${frame.x + 128}, ${frame.y - 15})`,
});
const title = document.createElementNS(SVG_NS, "title");
title.textContent = "Überwachte Hosts konfigurieren";
g.appendChild(title);
// 13/15 = etwas kleiner als am PO-Platz, passend zum Reload-Symbol am Board.
gearHintShapes(13 / 15).forEach((node) => g.appendChild(node));
// Groessere unsichtbare Trefferflaeche, analog zu .board-reload-hint-hit.
g.appendChild(el("rect", { x: -16, y: -16, width: 32, height: 32, class: "system-config-hint-hit" }));
g.addEventListener("click", (event) => {
// Der ganze Monitor traegt einen Klick-Handler (Detailliste der Systeme) - ohne das
// Stoppen laege ueber der frisch geoeffneten Host-Verwaltung sofort das Detail-Modal.
event.stopPropagation();
onClick();
});
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);
// Echte registrierte Agenten haben eine numerische Id von der API; Demo-Agenten und die
// guest-N-Fallback-Id kennen keine Aufnahme-Warteschlange, sind also nicht klickbar.
const isRealAgent = typeof agent.id !== "undefined" && id !== PO_AGENT_ID;
let node = agentAvatarElements.get(id);
if (!node) {
node = createAgentAvatarElement();
if (id === PO_AGENT_ID) {
makePoAvatarClickable(node);
} else if (isRealAgent) {
makeAgentAvatarClickable(node, agent.id);
}
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 || isRealAgent) {
// updateAgentAvatarElement() setzt class komplett neu (Pose) - ohne diese Zeile
// verloere die Figur ihre 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);
}
// Issue #85: ein Klick auf die Personen-Figur eines registrierten Agenten (nicht auf den
// synthetischen PO-Platz, siehe makePoAvatarClickable) oeffnet die Aufnahme-Webseite fuer
// diesen Agenten in einem neuen Tab - die laufende Buero-Szene bleibt dabei unangetastet
// stehen, genau wie ein Modal es waere, nur dass record.html eine eigene Seite ist (braucht
// Mikrofon-Zugriff und ist fuers Handy gedacht).
function makeAgentAvatarClickable(node, agentId) {
const title = document.createElementNS(SVG_NS, "title");
title.textContent = "Sprachaufnahme fuer diesen Agenten aufnehmen";
node.appendChild(title);
node.addEventListener("click", () => {
window.open(recordingUrl(agentId), "_blank", "noopener");
});
}
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);
}
// ---------------------------------------------------------------------------------------
// Board-Besuch des zustaendigen Agenten bei "in_arbeit"-Wechseln (Issue #76): analog zum
// PO-Besuch (triggerPoVisit()/advancePoVisit()), aber pro Agent statt eines einzelnen globalen
// Zustands, weil mehrere Agenten gleichzeitig eigene Tickets verschieben koennen. Nutzt bewusst
// dieselben Stages/denselben Ablauf wie PO_VISIT_STAGES/advancePoVisitStage (siehe
// office-scene.js) - ein eigenes, inhaltsgleiches Stage-Array waere nur Duplikat.
//
// agentId -> {agent: {id, name, role}, homeFeet, stage, onArrived}
const agentBoardVisitors = new Map();
function agentVisitorPlacements() {
return Array.from(agentBoardVisitors.values()).map((visitor) => {
const visual = resolveAgentVisualState(visitor.stage);
return {
agent: { ...visitor.agent, pose: visual.pose, location: visual.location },
homeFeet: visitor.homeFeet,
};
});
}
function advanceAgentBoardVisit(agentId) {
const visitor = agentBoardVisitors.get(agentId);
if (!visitor) {
return;
}
const nextStage = advancePoVisitStage(visitor.stage);
if (nextStage === null) {
agentBoardVisitors.delete(agentId);
repaintAgentsLayer();
return;
}
visitor.stage = nextStage;
// Wie beim PO-/Tuerbesuch (Issue #55): erst am Board wird die zurueckgehaltene Ticket-
// Aenderung sichtbar gemacht, danach wartet die Persona TICKET_PLACED_WAIT_MS, statt sofort
// weiterzulaufen.
if (visitor.stage === PO_VISIT_STAGES[1]) {
const onArrived = visitor.onArrived;
visitor.onArrived = null;
onArrived?.();
repaintAgentsLayer();
window.setTimeout(() => advanceAgentBoardVisit(agentId), TICKET_PLACED_WAIT_MS);
return;
}
repaintAgentsLayer();
window.setTimeout(() => advanceAgentBoardVisit(agentId), DOOR_VISIT_STAGE_DURATION_MS);
}
/**
* Stoesst den sichtbaren Board-Besuch eines Entwickler-Agenten an (Issue #76). Laeuft bereits
* einer fuer denselben Agenten (z.B. mehrere Tickets wechseln kurz hintereinander), wird kein
* zweiter Besuch gestartet - onArrived wird sofort nachgeholt, damit die zurueckgehaltene(n)
* Ticket-Aenderung(en) nicht dauerhaft verborgen bleiben.
*
* @param {number|string} agentId
* @param {{id: number|string, name: string, role: string}} agentInfo
* @param {{x: number, y: number}} homeFeet - der eigene Schreibtisch des Agenten.
* @param {() => void} [onArrived] - wird aufgerufen, sobald der Agent am Board ankommt.
*/
function triggerAgentBoardVisit(agentId, agentInfo, homeFeet, onArrived) {
if (agentBoardVisitors.has(agentId)) {
onArrived?.();
return;
}
// Ein bereits laufender idle-Kosmetik-Ausflug (Kaffee/Zeitung/Schwaetzchen, siehe
// scheduleIdleCosmetic()) wuerde sonst mitten im Board-Besuch die Pose/Position ueberschreiben.
clearIdleCosmeticTimer(String(agentId));
agentBoardVisitors.set(agentId, { agent: agentInfo, homeFeet, stage: PO_VISIT_STAGES[0], onArrived });
repaintAgentsLayer();
window.setTimeout(() => advanceAgentBoardVisit(agentId), DOOR_VISIT_STAGE_DURATION_MS);
}
function repaintAgentsLayer() {
if (!currentAgentsLayer) return;
updateAgentsLayer(currentAgentsLayer, [
...currentRegularPlacements,
...poPlacement(),
...doorVisitorPlacements(),
...agentVisitorPlacements(),
]);
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";
if (item.repo) {
const repoEl = document.createElement("span");
repoEl.className = "office-modal-repo";
repoEl.textContent = item.repo;
heading.appendChild(repoEl);
}
if (item.number) {
const numEl = item.link ? document.createElement("a") : document.createElement("span");
numEl.className = "office-modal-ticket-number";
numEl.textContent = `#${item.number}`;
if (item.link) {
numEl.href = item.link;
numEl.target = "_blank";
numEl.rel = "noopener noreferrer";
}
heading.appendChild(numEl);
}
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.className = "office-modal-description";
desc.textContent = item.description;
li.appendChild(desc);
}
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
* @param {{setError: (message: string) => void, prompt: string}} [ui] - wohin die
* Fehlermeldung gehoert und wonach der Prompt fragt. Beides haengt am aufrufenden Modal
* (Projekte, Issue #27 - Hosts, Issue #70), die Key-Logik ist fuer beide dieselbe.
* @returns {Promise<boolean>}
*/
async function withAdminKey(action, ui = REPO_MODAL_ADMIN_UI) {
let adminKey = getStoredAdminKey();
if (!adminKey) {
adminKey = window.prompt(ui.prompt) || "";
if (!adminKey) {
return false;
}
}
try {
await action(adminKey);
setStoredAdminKey(adminKey);
return true;
} catch (err) {
if (err && err.isAuthError) {
setStoredAdminKey("");
ui.setError("Admin-Key ungültig.");
} else {
ui.setError(err instanceof Error ? err.message : "Aktion fehlgeschlagen.");
}
return false;
}
}
const REPO_MODAL_ADMIN_UI = {
setError: (message) => {
repoModalError = message;
},
prompt: "Admin-Key für Änderungen an Projekten:",
};
const HOST_MODAL_ADMIN_UI = {
setError: (message) => {
hostModalError = message;
},
prompt: "Admin-Key für Änderungen an den überwachten Hosts:",
};
/**
* 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, ui = REPO_MODAL_ADMIN_UI) {
let adminKey = getStoredAdminKey();
if (!adminKey) {
adminKey = window.prompt(ui.prompt) || "";
if (!adminKey) {
return undefined;
}
}
try {
const value = await action(adminKey);
setStoredAdminKey(adminKey);
return value;
} catch (err) {
if (err && err.isAuthError) {
setStoredAdminKey("");
ui.setError("Admin-Key ungültig.");
} else {
ui.setError(err instanceof Error ? err.message : "Aktion fehlgeschlagen.");
}
return undefined;
}
}
// ---------------------------------------------------------------------------------------
// Hosts-Modal (Issue #70: Zahnrad am Wandmonitor "Systeme" -> Ping-Ueberwachung verwalten)
//
// Aufbau wie das Projekte-Modal (Issue #27): interaktiv, mit Server-Rundtrip, zeichnet sich
// nach jeder Aenderung selbst neu. Zwei Abschnitte, weil es zwei Arten von Kacheln gibt:
// oben die Hosts, die ein OPs-Agent per Ping prueft (die hier wirklich gepflegt werden),
// unten die uebrigen Kacheln des System-Monitors - fuer die kann die GUI nur aufraeumen,
// denn ihre Meldung kommt von woanders.
// ---------------------------------------------------------------------------------------
let hostModalBodyEl = null;
let hostModalToken = null;
let hostModalHosts = [];
let hostModalSystems = [];
// Nur Agenten der Rolle "ops" - nur sie pingen, alle anderen haetten keine hosts.conf.
let hostModalOpsAgents = [];
let hostModalMode = null; // null | "new" | <id des bearbeiteten Hosts>
let hostModalError = "";
function openHostConfigModal(systems = []) {
hostModalHosts = [];
hostModalSystems = Array.isArray(systems) ? systems : [];
hostModalOpsAgents = [];
hostModalMode = null;
hostModalError = "";
hostModalToken = openModal({ title: "Überwachte Hosts" }, (body) => {
hostModalBodyEl = body;
renderHostConfigModal();
});
loadHostConfigModal(hostModalToken);
}
async function loadHostConfigModal(token) {
let loaded = null;
let error = "";
try {
const [hosts, agents, systems] = await Promise.all([fetchHostConfigs(), fetchAgents(), fetchSystems()]);
loaded = { hosts, agents, systems };
} catch {
error = "Hosts konnten nicht geladen werden.";
}
if (!isCurrentModal(token)) {
return;
}
if (loaded !== null) {
hostModalHosts = loaded.hosts;
hostModalOpsAgents = loaded.agents.filter((agent) => agent.role === "ops");
hostModalSystems = loaded.systems;
}
hostModalError = error;
renderHostConfigModal();
}
function renderHostConfigModal() {
const body = hostModalBodyEl;
if (!body) return;
body.replaceChildren();
if (hostModalError) {
const errorEl = document.createElement("p");
errorEl.className = "office-modal-error";
errorEl.textContent = hostModalError;
body.appendChild(errorEl);
}
// Ohne registrierten OPs-Agenten gibt es niemanden, der die Liste abholt und pingt - ein
// hier angelegter Host waere wirkungslos. Das sagt das Modal lieber, als ein Formular zu
// zeigen, das serverseitig ohnehin an der Rollenpruefung scheitert.
if (hostModalOpsAgents.length === 0) {
const hint = document.createElement("p");
hint.className = "office-modal-empty";
hint.textContent =
"Kein OPs-Agent registriert. Der Ping-Check meldet sich mit einem eigenen Agenten an – " +
"einzurichten mit register-ops-agent.sh auf dem Rechner, der die Hosts anpingt.";
body.appendChild(hint);
}
if (hostModalHosts.length === 0 && hostModalMode !== "new") {
body.appendChild(emptyMessage("Keine Hosts konfiguriert."));
} else {
const list = document.createElement("ul");
list.className = "office-modal-list repo-config-list host-config-list";
for (const host of hostModalHosts) {
list.appendChild(hostModalMode === host.id ? renderHostConfigForm(host) : renderHostConfigRow(host));
}
body.appendChild(list);
}
if (hostModalMode === "new") {
let list = body.querySelector(".host-config-list");
if (!list) {
list = document.createElement("ul");
list.className = "office-modal-list repo-config-list host-config-list";
body.appendChild(list);
}
list.appendChild(renderHostConfigForm(null));
} else if (hostModalOpsAgents.length > 0) {
const addButton = iconButton("add", "Host hinzufügen", () => {
hostModalMode = "new";
renderHostConfigModal();
});
addButton.classList.add("repo-config-add-button");
body.appendChild(addButton);
}
renderOrphanSystems(body);
}
function renderOrphanSystems(body) {
const orphans = systemsWithoutHostConfig(hostModalSystems, hostModalHosts);
if (orphans.length === 0) {
return;
}
const heading = document.createElement("h3");
heading.className = "host-config-subheading";
heading.textContent = "Weitere Kacheln im System-Monitor";
body.appendChild(heading);
const note = document.createElement("p");
note.className = "office-modal-empty";
note.textContent =
"Gehören zu keinem hier gepflegten Host: Sie melden sich selbst per REST – oder ihr " +
"letzter Stand steht noch in der Datenbank, obwohl sie niemand mehr prüft.";
body.appendChild(note);
const list = document.createElement("ul");
list.className = "office-modal-list repo-config-list host-config-list";
for (const system of orphans) {
const li = document.createElement("li");
li.className = `office-modal-item repo-config-item status-${system.status}`;
const label = document.createElement("span");
label.className = "repo-config-slug";
label.textContent = system.detail ? `${system.name} (${system.detail})` : system.name;
li.appendChild(label);
const actions = document.createElement("span");
actions.className = "repo-config-actions";
actions.appendChild(iconButton("delete", "Kachel entfernen", () => handleSystemDelete(system)));
li.appendChild(actions);
list.appendChild(li);
}
body.appendChild(list);
}
function renderHostConfigRow(host) {
const li = document.createElement("li");
li.className = "office-modal-item repo-config-item";
const label = document.createElement("span");
label.className = "repo-config-slug";
label.textContent = `${host.hostname} · ${host.address}`;
li.appendChild(label);
// Wer den Host anpingt, steht nur dann dabei, wenn es ueberhaupt mehrere Ping-Rechner
// gibt - sonst waere es bei jeder Zeile dieselbe Auskunft.
if (hostModalOpsAgents.length > 1) {
const agent = document.createElement("span");
agent.className = "host-config-agent";
agent.textContent = host.agent_name;
li.appendChild(agent);
}
const actions = document.createElement("span");
actions.className = "repo-config-actions";
actions.appendChild(
iconButton("edit", "Host bearbeiten", () => {
hostModalMode = host.id;
renderHostConfigModal();
})
);
actions.appendChild(iconButton("delete", "Host entfernen", () => handleHostConfigDelete(host)));
li.appendChild(actions);
return li;
}
function renderHostConfigForm(host) {
const li = document.createElement("li");
li.className = "office-modal-item repo-config-item repo-config-form";
const hostnameInput = document.createElement("input");
hostnameInput.type = "text";
hostnameInput.placeholder = "Name, z. B. Webserver";
hostnameInput.value = host?.hostname ?? "";
hostnameInput.setAttribute("aria-label", "Hostname");
const addressInput = document.createElement("input");
addressInput.type = "text";
addressInput.placeholder = "IP oder Adresse, z. B. 192.168.178.10";
addressInput.value = host?.address ?? "";
addressInput.setAttribute("aria-label", "IP-Adresse");
const fields = document.createElement("span");
fields.className = "repo-config-form-fields";
fields.append(hostnameInput, addressInput);
// Den Ping-Rechner waehlt man nur beim Anlegen und nur, wenn es mehrere gibt; ein Wechsel
// im Nachhinein ginge sonst still an der alten hosts.conf vorbei (siehe
// HostConfigRepository::update).
let agentSelect = null;
if (host === null && hostModalOpsAgents.length > 1) {
agentSelect = document.createElement("select");
agentSelect.setAttribute("aria-label", "Ping-Rechner");
agentSelect.title = "Welcher OPs-Agent diesen Host anpingt";
for (const agent of hostModalOpsAgents) {
const option = document.createElement("option");
option.value = String(agent.id);
option.textContent = agent.display_name;
agentSelect.appendChild(option);
}
fields.appendChild(agentSelect);
}
li.appendChild(fields);
const actions = document.createElement("span");
actions.className = "repo-config-actions";
const save = () =>
handleHostConfigSave(host, {
hostname: hostnameInput.value.trim(),
address: addressInput.value.trim(),
agent_id: agentSelect ? Number(agentSelect.value) : hostModalOpsAgents[0]?.id,
});
actions.appendChild(iconButton("confirm", "Speichern", save));
actions.appendChild(
iconButton("cancel", "Abbrechen", () => {
hostModalMode = null;
renderHostConfigModal();
})
);
li.appendChild(actions);
// Wie im Projekte-Formular: Enter wuerde sonst das <form method="dialog"> einreichen und
// das ganze Modal schliessen, statt zu speichern.
const saveOnEnter = (event) => {
if (event.key === "Enter") {
event.preventDefault();
save();
}
};
hostnameInput.addEventListener("keydown", saveOnEnter);
addressInput.addEventListener("keydown", saveOnEnter);
window.setTimeout(() => hostnameInput.focus(), 0);
return li;
}
async function handleHostConfigSave(existing, data) {
const token = hostModalToken;
if (!data.hostname || !data.address) {
hostModalError = 'Felder "Name" und "IP" sind erforderlich.';
renderHostConfigModal();
return;
}
if (!existing && !data.agent_id) {
hostModalError = "Kein OPs-Agent vorhanden, der diesen Host anpingen könnte.";
renderHostConfigModal();
return;
}
const ok = await withAdminKey(
(adminKey) => (existing ? updateHostConfig(existing.id, data, adminKey) : createHostConfig(data, adminKey)),
HOST_MODAL_ADMIN_UI
);
if (!isCurrentModal(token)) {
return;
}
if (!ok) {
renderHostConfigModal();
return;
}
hostModalMode = null;
await loadHostConfigModal(token);
}
async function handleHostConfigDelete(host) {
const token = hostModalToken;
if (
!window.confirm(
`Host "${host.hostname}" nicht mehr überwachen?\nDie Kachel im System-Monitor verschwindet mit.`
)
) {
return;
}
const ok = await withAdminKey((adminKey) => deleteHostConfig(host.id, adminKey), HOST_MODAL_ADMIN_UI);
if (!isCurrentModal(token)) {
return;
}
if (!ok) {
renderHostConfigModal();
return;
}
await loadHostConfigModal(token);
}
async function handleSystemDelete(system) {
const token = hostModalToken;
if (!window.confirm(`Kachel "${system.name}" aus dem System-Monitor entfernen?`)) {
return;
}
const ok = await withAdminKey((adminKey) => deleteSystem(system.id, adminKey), HOST_MODAL_ADMIN_UI);
if (!isCurrentModal(token)) {
return;
}
if (!ok) {
renderHostConfigModal();
return;
}
await loadHostConfigModal(token);
}
// ---------------------------------------------------------------------------------------
// 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();
agentBoardVisitors.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),
// Issue #70: die aktuell gezeigten Systeme wandern mit ins Modal - daran erkennt es,
// welche Kacheln zu keinem konfigurierten Host mehr gehoeren.
onSystemsConfigClick: () => openHostConfigModal(systems),
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"));
}
// Issue #76: waehrend ein Agent sichtbar zum Board unterwegs ist (siehe agentBoardVisitors
// oben), soll sein Schreibtisch leer wirken statt weiterhin besetzt - genau wie bei einem
// Tuer-/PO-Besuch verlaesst die Person sichtbar ihren Platz.
const visitingAgentIds = new Set(agentBoardVisitors.keys());
const deskAgents = normalizedDeskAgents(agents).map((agent) =>
agent && visitingAgentIds.has(agent.id) ? null : agent
);
const deskLayout = buildDeskLayout(deskAgents);
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, visitingAgentIds);
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));
}
// excludeIds (Issue #76): Agenten, die gerade sichtbar zum Board unterwegs sind (siehe
// agentBoardVisitors) - agentVisitorPlacements() steuert ihre Platzierung, sie duerfen hier
// nicht zusaetzlich (z.B. als Ruheplatz-Ueberlauf mit ihrem regulaeren state) auftauchen, sonst
// gaebe es denselben Agenten doppelt in der Szene.
function agentPlacements(agents, deskLayout, excludeIds = new Set()) {
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;
if (excludeIds.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();
// Issue #61/#76: ticketId -> zustaendiger agentId fuer zurueckgehaltene "in_arbeit"-Wechsel
// (siehe detectAgentOwnedTicketChanges() in office-scene.js), fuer die ausnahmsweise KEIN
// selbst getakteter Board-Besuch laeuft (triggerAgentBoardVisit() unten deckt den Regelfall
// ab) - entweder weil der Agent seine Bewegung bereits selbst meldet
// (isAgentReportingBoardVisit()) oder weil kein Schreibtisch fuer eine Animation bekannt ist.
// Dafuer gibt es keinen eigenen onArrived-Callback - der Agent meldet seine Ankunft am Board
// unabhaengig ueber seinen eigenen state, daher wird bei jedem refresh() erneut geprueft
// (siehe resolvePendingAgentTicketMoves()).
const pendingAgentTicketMoves = new Map();
// Issue #61: entlaesst genau die zurueckgehaltenen "in_arbeit"-Wechsel, deren zustaendiger
// Agent laut aktuellstem Poll bereits am Board angekommen ist (oder nicht mehr feststellbar
// ist) - siehe hasAgentReachedBoard(). Laeuft bei jedem refresh(), nicht nur beim Erkennen der
// Aenderung, da die Ankunft typischerweise erst in einem spaeteren Poll sichtbar wird.
function resolvePendingAgentTicketMoves(agents) {
if (pendingAgentTicketMoves.size === 0) {
return;
}
const agentsById = new Map(agents.map((agent) => [agent.id, agent]));
for (const [ticketId, agentId] of pendingAgentTicketMoves) {
const agent = agentsById.get(agentId);
if (hasAgentReachedBoard(agent?.state)) {
pendingTicketMoves.delete(ticketId);
pendingAgentTicketMoves.delete(ticketId);
}
}
}
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));
}
// Issue #76: Wechsel von/zu "in_arbeit" (siehe AGENT_OWNED_COLUMN in office-scene.js)
// loesen einen selbst getakteten Board-Besuch des zustaendigen Agenten aus, analog zum
// PO-/Tuerbesuch - der Agent meldete diese Bewegung in der Praxis so gut wie nie selbst
// (Issue #61 gescheitert). Mehrere Aenderungen desselben Agenten im selben Poll teilen
// sich einen Besuch statt mehrfach hin- und herzulaufen.
const agentsById = new Map(data.agents.map((agent) => [agent.id, agent]));
const agentVisitPlans = new Map();
detectAgentOwnedTicketChanges(previousTickets, data.tickets).forEach((change) => {
pendingTicketMoves.set(change.ticketId, previousById.get(change.ticketId) ?? null);
const agent = agentsById.get(change.agentId);
const homeFeet = agent && typeof agent.desk_index === "number" ? deskSeat(agent.desk_index).feet : null;
if (!homeFeet || isAgentReportingBoardVisit(agent.state)) {
// Kein bekannter Schreibtisch (z.B. Agent nicht mehr registriert), oder der Agent
// meldet seine Bewegung zum Board bereits selbst (Loesungsrichtung 1 aus Issue #76)
// - dann bleibt der selbst getaktete Besuch unten aus, die echte Meldung (bzw. die
// bestehende Ankunfts-Erkennung ueber hasAgentReachedBoard()) hat Vorrang.
pendingAgentTicketMoves.set(change.ticketId, change.agentId);
return;
}
const plan = agentVisitPlans.get(change.agentId) ?? {
agentInfo: { id: agent.id, name: agent.display_name ?? agent.name, role: agent.role },
homeFeet,
ticketIds: [],
};
plan.ticketIds.push(change.ticketId);
agentVisitPlans.set(change.agentId, plan);
});
agentVisitPlans.forEach(({ agentInfo, homeFeet, ticketIds }, agentId) => {
triggerAgentBoardVisit(agentId, agentInfo, homeFeet, () => commitTicketMoves(ticketIds));
});
}
resolvePendingAgentTicketMoves(data.agents);
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();
});