// 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, buildEventModalContent, buildSystemModalContent, buildKpiTiles, buildKpiModalContent, buildAgentCountKpis, mapAgentsToDesks, normalizeAgentForDisplay, resolveAgentVisualState, isAgentPresent, pickIdleActivity, AnimationQueue, detectDoorVisits, resolveDoorVisitVisual, advanceDoorVisitStage, DOOR_VISIT_STAGES, detectUnassignedTicketChanges, advancePoVisitStage, PO_VISIT_STAGES, 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, fetchRepoConfigs, createRepoConfig, updateRepoConfig, deleteRepoConfig, 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 }; // 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; 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} ticketCounts * @param {Record>} groupedTickets - liefert die Projektfarbe je Zettel. * @param {(columnKey: string) => void} onColumnClick */ function renderBoard(ticketCounts, groupedTickets, onColumnClick) { 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" }) ); 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; } 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", }) ); lines.forEach((eventLine, index) => { g.appendChild( text(`${eventLine.time} ${eventLine.system}: ${eventLine.message}`, { x: frame.x + 18, y: frame.y + 38 + index * 26, class: `monitor-event-line severity-${eventLine.severity}`, }) ); }); 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; } const rowH = (frame.h - 30) / tiles.length; tiles.forEach((tile, index) => { const rowY = frame.y + 20 + index * rowH; g.appendChild( text(`${tile.label}: ${tile.currentValue ?? "–"} ${tile.unit}`.trim(), { x: frame.x + 16, y: rowY + 14, class: "kpi-tile-label", }) ); g.appendChild(renderSparkline(tile.sparklinePoints, frame.w - 32, rowH - 26, frame.x + 16, rowY + 20)); }); 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 }, }; } function deskScreenPoseClass(pose) { switch (pose) { case "working": return "pose-working"; 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" }) ); } g.appendChild( text(agent.name, { x: width / 2, y: 40 + DESK.h + 74, class: "desk-name", "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} 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 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). 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" })); } return nodes; } 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; 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; 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. function triggerPoVisit() { if (!poIsLive || poState !== "idle") { return; } poState = PO_VISIT_STAGES[0]; repaintAgentsLayer(); poVisitTimeoutId = window.setTimeout(advancePoVisit, DOOR_VISIT_STAGE_DURATION_MS); } 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; repaintAgentsLayer(); window.setTimeout(() => advanceDoorVisit(id), DOOR_VISIT_STAGE_DURATION_MS); } /** @param {{type: string, ticketId: number}} visit */ function triggerDoorVisit(visit) { doorVisitCounter += 1; const id = `door-visit-${visit.type}-${visit.ticketId}-${doorVisitCounter}`; doorVisitors.set(id, { id, stage: DOOR_VISIT_STAGES[0], atDoor: true }); 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}`; li.textContent = `${item.name} (${item.type}) – ${item.status === "ok" ? "OK" : "Fehler"}${item.detail ? `: ${item.detail}` : ""}`; 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", }; let repoModalBodyEl = null; let repoModalToken = null; let repoModalRepos = []; let repoModalMode = null; // null | "new" | <id des bearbeiteten Projekts> let repoModalError = ""; 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 = ""; 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); return li; } 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 ok = await withAdminKey((adminKey) => existing ? updateRepoConfig(existing.id, data, adminKey) : createRepoConfig(data, adminKey) ); if (!isCurrentModal(token)) { return; } if (!ok) { renderRepoConfigModal(); return; } repoModalMode = null; await loadRepoConfigModal(token); } 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; } } // --------------------------------------------------------------------------------------- // Datenbeschaffung + Orchestrierung // --------------------------------------------------------------------------------------- async function loadLiveData() { const [tickets, events, systems, kpis, agents] = await Promise.all([ fetchTickets(), fetchEvents(), fetchSystems(), fetchKpis(), fetchAgents(), ]); return { tickets, events, systems, kpis, agents, 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 = [], 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. const displayKpis = [...buildAgentCountKpis(agents), ...kpis]; const groupedTickets = groupTicketsByColumn(tickets); const boardGroup = renderBoard(ticketCounts, groupedTickets, (columnKey) => { openModal( buildTicketModalContent(columnKey, groupedTickets[columnKey] || []), renderTicketModalBody ); }); 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 (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; if (!isAgentPresent(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; } } // 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; async function refresh() { try { const data = await loadLiveData(); if (previousTickets) { detectDoorVisits(previousTickets, data.tickets).forEach(triggerDoorVisit); if (detectUnassignedTicketChanges(previousTickets, data.tickets).length > 0) { triggerPoVisit(); } } previousTickets = data.tickets; renderOfficeScene(container, { ticketCounts: aggregateTicketsByColumn(data.tickets), tickets: data.tickets, agents: data.agents, systems: data.systems, events: data.events, kpis: data.kpis, isLive: true, }); setApiBanner(false); } catch (err) { console.warn("[office] Live-API nicht erreichbar, zeige Demo-Daten.", err); renderOfficeScene(container); setApiBanner(true); } } await refresh(); startRealtimeUpdates(refresh); } document.addEventListener("DOMContentLoaded", () => { boot(); startReloadCountdown(); });