// Reine Layout-/Datenlogik fuer die Buero-Visualisierung (Phase 2, siehe Visualization.md // Abschnitt 9). Enthaelt keine DOM-Zugriffe, damit die Funktionen ohne Browser (z.B. mit dem // Node-Testrunner) getestet werden koennen. Das eigentliche Zeichnen uebernimmt office-render.js. // Spaltenreihenfolge exakt wie API.md ("Tickets" -> Spalten, F3). export const BOARD_COLUMNS = [ { key: "backlog", label: "Backlog" }, { key: "bereit", label: "Bereit" }, { key: "in_arbeit", label: "In Arbeit" }, { key: "qa", label: "QA" }, { key: "testing", label: "Testing" }, { key: "fertig", label: "Fertig" }, { key: "done", label: "Done" }, ]; // Rollen exakt wie API.md ("Agenten", F1/F2). "user" hat nie einen Schreibtisch. export const ROLES = { entwickler: { label: "Entwickler:in", accentColor: "#3b82f6", deco: "star" }, product_owner: { label: "Product Owner:in", accentColor: "#ec4899", deco: "flower" }, qa: { label: "QA / Tester:in", accentColor: "#22c55e", deco: "check" }, ops: { label: "OPs", accentColor: "#8b5cf6", deco: "gear" }, user: { label: "User", accentColor: "#f59e0b", deco: "none" }, }; export const MAX_VISIBLE_NOTES_PER_COLUMN = 4; export const MAX_SYSTEM_TILES = 9; export const MAX_EVENT_LINES = 5; export const DEFAULT_DESK_COUNT = 8; // Ein Stapel wird aus so vielen versetzten Zetteln gezeichnet (siehe renderStackBadge()) - // entsprechend viele Projektfarben kann er zeigen. export const NOTE_STACK_LAYERS = 3; /** * Baut aus rohen Ticket-Zaehlern pro Spalte die Darstellung fuer das Board. * Regel (Detailantworten F3): mehr als 5 Tickets in einer Spalte -> die aeltesten 4 werden * einzeln dargestellt, der Rest als ein "Stapel" mit der Anzahl der zusaetzlichen Tickets. * Regel (Detailantworten F2): die Spalte "done" wird immer als ein Stapel (Zeit-Historie) * dargestellt, unabhaengig von der Anzahl. * * Die Farben stammen aus dem Quell-Projekt des Tickets (ticket.color, gepflegt im * Projekte-Modal am PO, Issue #27) und sind optional: ohne groupedTickets - etwa im * Demo-Modus, der nur Zaehler kennt - bleiben die Zettel einfarbig wie zuvor. * * @param {Record} ticketCounts - Anzahl Tickets je Spalten-Key. * @param {Record>} groupedTickets - Tickets je Spalte in derselben * Reihenfolge wie gezaehlt (siehe groupTicketsByColumn()). * @returns {Array<{key: string, label: string, count: number, visibleNotes: number, * stackCount: number, noteColors: Array, stackColors: Array}>} */ export function buildBoardColumnLayout(ticketCounts = {}, groupedTickets = {}) { return BOARD_COLUMNS.map(({ key, label }) => { const count = Math.max(0, ticketCounts[key] ?? 0); const tickets = groupedTickets[key] ?? []; const withColors = (visibleNotes, stackCount) => ({ key, label, count, visibleNotes, stackCount, noteColors: noteColors(tickets.slice(0, visibleNotes), visibleNotes), stackColors: stackCount > 0 ? stackLayerColors(tickets.slice(visibleNotes)) : [], }); if (key === "done") { return withColors(0, count); } if (count <= MAX_VISIBLE_NOTES_PER_COLUMN + 1) { return withColors(count, 0); } return withColors(MAX_VISIBLE_NOTES_PER_COLUMN, count - MAX_VISIBLE_NOTES_PER_COLUMN); }); } // null steht fuer "keine Projektfarbe" (Ticket ohne Quell-Projekt, z.B. per API gemeldet) - // solche Zettel behalten die Standardfarbe aus dem Stylesheet. function noteColors(tickets, length) { return Array.from({ length }, (_, i) => tickets[i]?.color ?? null); } // Ein Stapel steht fuer viele Tickets, aber nur fuer NOTE_STACK_LAYERS Zettel: er zeigt // deshalb die ersten verschiedenen Projektfarben darin, statt eine davon dreimal zu // wiederholen. So ist auch an "Done" (immer ein Stapel) zu sehen, aus welchen Projekten die // Tickets stammen. // // Reichen die gefundenen Farben nicht fuer alle Zettel, werden sie zyklisch wiederholt: ein // ungefaerbter Rest-Zettel wuerde sonst wie ein weiteres Projekt aussehen, denn die // Standardfarbe (--color-note) ist von einem gelben Projekt kaum zu unterscheiden. Nur wenn // gar keine Farbe vorkommt, bleibt der Stapel komplett im Standard. function stackLayerColors(tickets) { const found = []; for (const ticket of tickets) { if (ticket.color && !found.includes(ticket.color)) { found.push(ticket.color); if (found.length === NOTE_STACK_LAYERS) break; } } if (found.length === 0) { return []; } return Array.from({ length: NOTE_STACK_LAYERS }, (_, i) => found[i % found.length]); } /** * Ordnet Agenten-Demo-Daten den Schreibtischen zu. Unbesetzte Schreibtische bleiben leer. * Ueberzaehlige Agenten (mehr als deskCount) werden ignoriert - Phase 2 zeigt nur den * statischen Grundzustand, die "weitere Agenten kommen durch die Tuer"-Logik ist Teil von * Phase 5 (Animation). * * @param {Array<{name: string, role: string, pose?: string}>} agents * @param {number} deskCount * @returns {Array<{deskIndex: number, agent: object|null}>} */ export function buildDeskLayout(agents = [], deskCount = DEFAULT_DESK_COUNT) { const desks = []; for (let deskIndex = 0; deskIndex < deskCount; deskIndex++) { const agent = agents[deskIndex] ?? null; desks.push({ deskIndex, agent: agent ? { pose: "idle", ...agent } : null }); } return desks; } /** * Bereitet Systemkacheln fuer den System-Monitor auf: Fehler zuerst (wie GET /api/v1/systems), * maximal MAX_SYSTEM_TILES Kacheln (F6). * * @param {Array<{id: string, name: string, status: "ok"|"error"}>} systems * @returns {Array} */ export function buildSystemTiles(systems = []) { const sorted = [...systems].sort((a, b) => { if (a.status === b.status) return 0; return a.status === "error" ? -1 : 1; }); return sorted.slice(0, MAX_SYSTEM_TILES); } /** * Bereitet die Zeilen des Event-Monitors auf: neuestes Event zuerst, maximal * MAX_EVENT_LINES Zeilen (F5). Erwartet bereits absteigend nach Zeit sortierte Events * (wie GET /api/v1/events sie liefert). * * @param {Array<{severity: "warning"|"error"}>} events * @returns {Array} */ export function buildEventLines(events = []) { return events.slice(0, MAX_EVENT_LINES); } // --------------------------------------------------------------------------------------- // Phase 3 - Kanban-Board live (siehe Visualization.md Abschnitt 9, API.md "Tickets") // --------------------------------------------------------------------------------------- /** * Aggregiert eine rohe Ticketliste (GET /api/v1/tickets) zu Zaehlern je Spalte, wie sie * buildBoardColumnLayout() erwartet. Unbekannte Spalten-Keys werden ignoriert (defensiv * gegen zukuenftige/kaputte Daten). * * @param {Array<{column_name: string}>} tickets * @returns {Record} */ export function aggregateTicketsByColumn(tickets = []) { const counts = Object.fromEntries(BOARD_COLUMNS.map(({ key }) => [key, 0])); for (const ticket of tickets) { if (ticket.column_name in counts) { counts[ticket.column_name] += 1; } } return counts; } /** * Gruppiert eine rohe Ticketliste je Spalte. GET /api/v1/tickets liefert bereits pro Spalte * nach Alter aufsteigend sortiert (siehe TicketRepository::all()), die Reihenfolge wird hier * beibehalten. * * @param {Array<{column_name: string}>} tickets * @returns {Record>} */ export function groupTicketsByColumn(tickets = []) { const groups = Object.fromEntries(BOARD_COLUMNS.map(({ key }) => [key, []])); for (const ticket of tickets) { if (ticket.column_name in groups) { groups[ticket.column_name].push(ticket); } } return groups; } /** * Baut das Inhaltsmodell fuer das Board-Spalten-Modal (Visualization.md 4.2/4.5): echter * Titel/Beschreibung je Ticket, plus Link auf die Codeberg-Quelle sofern das Ticket per * Codeberg-Sync angelegt wurde (F4). Seit F26 (Tickets aus mehreren Codeberg-Projekten) * traegt jedes Ticket sein Quell-Repo ("owner/name") selbst in ticket.repo; opts dient nur * noch als Fallback fuer aeltere Tickets ohne dieses Feld. * * @param {string} columnKey * @param {Array} tickets * @param {{codebergOwner?: string, codebergRepo?: string}} [opts] */ export function buildTicketModalContent(columnKey, tickets = [], opts = {}) { const { codebergOwner = "fds2610", codebergRepo = "monitor" } = opts; const column = BOARD_COLUMNS.find((c) => c.key === columnKey); return { title: column ? column.label : columnKey, items: tickets.map((ticket) => ({ id: ticket.id, title: ticket.title, description: ticket.description || "", type: ticket.type || null, color: ticket.color || null, link: ticket.source === "codeberg" && ticket.external_ref ? `https://codeberg.org/${ticket.repo || `${codebergOwner}/${codebergRepo}`}/issues/${ticket.external_ref}` : null, })), }; } // --------------------------------------------------------------------------------------- // Phase 4 - Monitor-Wand live (Events/Systeme/KPIs, siehe API.md) // --------------------------------------------------------------------------------------- /** * Formatiert einen ISO-8601-Zeitstempel als lokale Uhrzeit (F16: keine eigene * Zeitzonen-Auswahl, es gilt schlicht die Zeitzone des Browsers/Servers, in der Praxis * CEST/CET). * * @param {string} isoString * @returns {string} */ export function formatEventTime(isoString) { const date = new Date(isoString); if (Number.isNaN(date.getTime())) { return "--:--"; } return date.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }); } /** * Uebersetzt ein rohes Event (GET /api/v1/events) in die kompakte Zeilendarstellung der * Monitor-Wand ({time, system, message, severity}, wie sie renderEventMonitor erwartet). * * @param {{occurred_at: string, name: string, message: string, severity: string}} event */ export function mapEventForDisplay(event) { return { time: formatEventTime(event.occurred_at), system: event.name, message: event.message, severity: event.severity, }; } /** * Volltext einer Event-Zeile, wie er ungekuerzt im Tooltip steht. * * @param {{time: string, system: string, message: string}} eventLine * @returns {string} */ export function formatEventLineText(eventLine) { return `${eventLine.time} ${eventLine.system}: ${eventLine.message}`; } // Zeichenbreite der Monospace-Schrift des Event-Monitors (13px, siehe .monitor-event-line in // office.css). Bei Monospace ist die Breite fuer alle Zeichen gleich; der Wert ist am // breitesten der in Frage kommenden Schriften ausgerichtet (Courier New / DejaVu Sans Mono, // ~0.6 em; Consolas ist schmaler), damit die Zeile auf keinem System ueberlaeuft. So bleibt // die Abschaetzung ohne DOM-Messung auskommend und damit testbar. export const EVENT_LINE_CHAR_WIDTH = 7.9; /** * Kuerzt einen Text auf die Breite, die im Monitor tatsaechlich zur Verfuegung steht, und * markiert die Kuerzung mit einer Ellipse. Zu lange Zeilen liefen sonst ueber den * Monitorrahmen hinaus in die Szene hinein; die volle Meldung zeigt der Tooltip * (siehe renderEventMonitor()). * * @param {string} value * @param {number} availableWidth - verfuegbare Breite in SVG-Einheiten * @param {number} [charWidth] * @returns {string} */ export function truncateToWidth(value, availableWidth, charWidth = EVENT_LINE_CHAR_WIDTH) { const maxChars = Math.floor(availableWidth / charWidth); if (maxChars <= 0) { return ""; } if (value.length <= maxChars) { return value; } if (maxChars === 1) { return "…"; } return `${value.slice(0, maxChars - 1).trimEnd()}…`; } /** * Inhaltsmodell fuer das Event-Monitor-Modal: volle Liste (kein MAX_EVENT_LINES-Limit). * * @param {Array} events */ export function buildEventModalContent(events = []) { return { title: "Events", items: events.map((event) => ({ time: formatEventTime(event.occurred_at), name: event.name, message: event.message, severity: event.severity, systemId: event.system_id ?? null, })), }; } /** * Inhaltsmodell fuer das System-Monitor-Modal. * * @param {Array} systems */ export function buildSystemModalContent(systems = []) { return { title: "Systeme", items: systems.map((system) => ({ id: system.id, name: system.name, status: system.status, type: system.type || "host", detail: system.detail || "", lastCheckAt: formatSystemCheckTime(system.last_check_at), })), }; } /** * Issue #70: Die Kacheln des System-Monitors, die zu keinem konfigurierten Ping-Host * gehoeren - fuer den unteren Abschnitt der Host-Verwaltung. Sie melden sich entweder selbst * per REST, oder ihr letzter Stand steht nur noch in der Datenbank, weil ihr Host laengst aus * der Ueberwachung raus ist. * * Die Zuordnung laeuft ueber die vom Server mitgelieferten system_ids: Ein Host kann unter * zwei Ids melden (Hostname aus dem status.txt-Import, Slug aus der REST-Meldung), und diese * Ableitung hier nachzubauen hiesse, sie an zwei Stellen richtig halten zu muessen. * * @param {Array<{id: string}>} systems * @param {Array<{system_ids?: Array}>} hosts */ export function systemsWithoutHostConfig(systems = [], hosts = []) { const known = new Set(hosts.flatMap((host) => host.system_ids || [])); return systems.filter((system) => !known.has(system.id)); } /** * Formatiert last_check_at eines Systems (GET /api/v1/systems, Issue #57: "wann der letzte * erfolgreiche Ping war") fuers Modal. Zwei Quellformate kommen vor: ISO-8601 UTC (per API * gemeldete Systeme) und "YYYY-MM-DD HH:MM" ohne Zeitzone (Legacy-Import aus status.txt, * geschrieben von check_fds.sh in Serverzeit) - beide werden von `Date` geparst, fehlende/ * ungueltige Werte liefern `null` statt eines irrefuehrenden Zeitstempels. * * @param {string|null|undefined} value * @returns {string|null} */ export function formatSystemCheckTime(value) { if (!value) { return null; } const date = new Date(value); if (Number.isNaN(date.getTime())) { return null; } return date.toLocaleString("de-DE", { day: "2-digit", month: "2-digit", year: "2-digit", hour: "2-digit", minute: "2-digit", }); } /** * Erzeugt die Punkte-Liste (SVG ) fuer eine einfache * KPI-Sparkline - bewusst ohne Chart-Lib gezeichnet (F10). Die API liefert die Historie * absteigend (neuester Wert zuerst, siehe KpiRepository::history()), fuer die Sparkline * wird chronologisch (aeltester zuerst) von links nach rechts gezeichnet. * * @param {Array<{value: number}>} history * @param {number} [width] * @param {number} [height] * @param {number} [padding] * @returns {string} */ export function buildKpiSparklinePoints(history = [], width = 140, height = 32, padding = 4) { if (history.length === 0) { return ""; } const chronological = [...history].reverse(); const values = chronological.map((point) => point.value); const min = Math.min(...values); const max = Math.max(...values); const range = max - min || 1; const stepX = chronological.length > 1 ? (width - padding * 2) / (chronological.length - 1) : 0; return chronological .map((point, index) => { const x = padding + index * stepX; const y = height - padding - ((point.value - min) / range) * (height - padding * 2); return `${x.toFixed(1)},${y.toFixed(1)}`; }) .join(" "); } // Fuenf statt vier Kacheln, seit "Problem" (abgelaufene Agenten-Anmeldung) hinzugekommen ist: // renderOfficeScene() reserviert bereits die letzte Kachel fuer "Hooks" (Issue #38), womit // "Agenten"/"Online"/"Problem" die uebrigen drei fuellen wuerden - fuer die gemeldeten KPIs (F7) // bliebe dann kein Platz mehr. Der KPI-Monitor ist hoch genug: eine Zeile ohne Sparkline braucht // 24 px von 340 (siehe renderKpiMonitor() in office-render.js). export const MAX_KPI_TILES = 5; /** * Bereitet bis zu MAX_KPI_TILES KPI-Kacheln fuer die Monitor-Wand auf. F7 ist weiterhin * offen ("kommt spaeter") - ist noch kein KPI gemeldet, bleibt das Ergebnis leer und die * Wand zeigt weiterhin den Platzhaltertext (keine erfundenen Kennzahlen). * * @param {Array} kpis */ export function buildKpiTiles(kpis = []) { return kpis.slice(0, MAX_KPI_TILES).map((kpi) => ({ key: kpi.key, label: kpi.label || kpi.key, unit: kpi.unit || "", currentValue: kpi.current_value, // Nur abgeleitete Kacheln setzen einen Schweregrad (aktuell "Problem", siehe // buildAgentCountKpis()); gemeldete KPIs (F7) kennen kein solches Feld und bleiben neutral. severity: kpi.severity ?? null, sparklinePoints: buildKpiSparklinePoints(kpi.history || []), })); } /** * KPI-foermiger Eintrag fuer den Hooks-KPI (Issue #38): Anzahl der seit dem letzten Start des * api-Containers empfangenen Webhooks (GET /api/v1/webhooks/stats). Anders als * buildAgentCountKpis() nicht aus bereits ohnehin geladenen Daten ableitbar, sondern ein * eigener serverseitiger Zaehler - daher eine eigene, injizierbare Funktion statt Teil davon. * Soll laut Issue #38 immer als letzte (rechte) Kachel im KPI-Monitor erscheinen, siehe * renderOfficeScene() in office-render.js, das dafuer eine Kachel reserviert statt sie der * MAX_KPI_TILES-Kappung der uebrigen KPIs zu unterwerfen. * * @param {number} count * @returns {object} */ export function buildHooksKpi(count) { return { key: "hooks_received", label: "Hooks", unit: "", current_value: count, history: [] }; } /** * Inhaltsmodell fuer das KPI-Monitor-Modal (groessere Sparkline als die Wandkachel). * * @param {Array} kpis */ export function buildKpiModalContent(kpis = []) { return { title: "KPIs", items: kpis.map((kpi) => ({ key: kpi.key, label: kpi.label || kpi.key, unit: kpi.unit || "", currentValue: kpi.current_value, history: kpi.history || [], sparklinePoints: buildKpiSparklinePoints(kpi.history || [], 320, 80), })), }; } // --------------------------------------------------------------------------------------- // Phase 5 - Agenten-Zustandsmaschine & Animation (siehe Visualization.md Abschnitt 4.4) // --------------------------------------------------------------------------------------- /** Orte, an denen ein Agent sichtbar sein kann. */ export const AGENT_LOCATIONS = { DESK: "desk", BOARD: "board", MONITOR_EVENTS: "monitor-events", MONITOR_SYSTEMS: "monitor-systems", MONITOR_KPI: "monitor-kpi", COFFEE_CORNER: "coffee-corner", DOOR: "door", }; // Zustandsmaschine wie in Visualization.md 4.4 vorgeschlagen: jeder von der API gemeldete // "state" (POST /api/v1/agents/{id}/activity) wird auf einen Ort + eine Pose abgebildet. // Unbekannte/fehlende Zustaende fallen defensiv auf "idle am Schreibtisch" zurueck. const AGENT_STATE_VISUALS = { idle: { location: AGENT_LOCATIONS.DESK, pose: "idle" }, working: { location: AGENT_LOCATIONS.DESK, pose: "working" }, walking_to_board: { location: AGENT_LOCATIONS.BOARD, pose: "walking" }, interacting_with_board: { location: AGENT_LOCATIONS.BOARD, pose: "interacting" }, walking_to_monitor: { location: AGENT_LOCATIONS.MONITOR_EVENTS, pose: "walking" }, interacting_with_monitor: { location: AGENT_LOCATIONS.MONITOR_EVENTS, pose: "interacting" }, walking_back: { location: AGENT_LOCATIONS.DESK, pose: "walking" }, reading_newspaper: { location: AGENT_LOCATIONS.DESK, pose: "reading_newspaper" }, at_coffee_corner: { location: AGENT_LOCATIONS.COFFEE_CORNER, pose: "coffee" }, talking_to_colleague: { location: AGENT_LOCATIONS.DESK, pose: "talking" }, // Abgelaufene Claude-Anmeldung (Problemzustaende, siehe AGENT_PROBLEM_STATES): der Agent // bleibt an seinem Platz - er ist nicht weg, er kommt nur nicht mehr weiter. auth_expiring: { location: AGENT_LOCATIONS.DESK, pose: "auth_expiring" }, auth_expired: { location: AGENT_LOCATIONS.DESK, pose: "auth_expired" }, }; /** * @param {string} state - Wert aus Agent.state (API). * @returns {{location: string, pose: string}} */ export function resolveAgentVisualState(state) { return AGENT_STATE_VISUALS[state] ?? AGENT_STATE_VISUALS.idle; } // Praesenz (Issue #17, seit Issue #20 vom Server berechnet): ein registrierter Agent gilt nur // noch als anwesend, solange er sich innerhalb eines Timeouts zuletzt gemeldet hat. Der Server // kennt ueber gmdate() zuverlaessig UTC und liefert das Ergebnis direkt als is_present-Feld mit // (siehe AgentRepository::isPresent(), API.md) - der Client macht dazu keine eigene // Zeit-Arithmetik/-Parsing mehr, das hatte in der lokalen Zeitzone des Clients zu falschen // Ergebnissen gefuehrt. export function isAgentPresent(agent) { if (!agent || typeof agent.is_present === "undefined") { return true; } return Boolean(agent.is_present); } /** * Zustaende, die ein menschliches Eingreifen erfordern: laeuft einem Agenten die * Claude-Anmeldung aus, laeuft das Cron-Skript drumherum unbeeindruckt weiter - es meldet zu * Beginn "working", ruft claude auf, bekommt nichts Brauchbares zurueck und meldet am Ende * "idle". Ohne eigenen Zustand ist der Ausfall vom Normalbetrieb nicht zu unterscheiden; der * Agent sieht beschaeftigt aus, waehrend Lauf fuer Lauf nichts entsteht. * * Der Agent meldet diese Zustaende ueber denselben Endpunkt wie jeden anderen (POST * /api/v1/agents/{id}/activity) - sein Monitor-API-Key ist von der Claude-Anmeldung unabhaengig * und bleibt gueltig. Der Zustand bleibt kleben, bis der Agent selbst etwas anderes meldet: * bewusst kein Timeout, denn ein Blocker soll sichtbar bleiben, bis er behoben ist. */ export const AGENT_PROBLEM_STATES = { auth_expiring: "warning", auth_expired: "error", }; /** * @param {object} agent * @returns {"warning"|"error"|null} Schweregrad des Problemzustands, oder null im Normalbetrieb. * Die Werte entsprechen den Event-Schweregraden (API.md), damit Kacheln, Banner und * Event-Zeilen dieselben Farben nutzen koennen. */ export function agentProblemSeverity(agent) { if (!agent) { return null; } return AGENT_PROBLEM_STATES[agent.state] ?? null; } /** * Ob ein Agent in der Szene gezeigt wird. Bewusst getrennt von isAgentPresent(): hat ein Agent * "auth_expired" gemeldet, bricht sein Skript den Lauf ab und meldet bis zum naechsten * Cron-Start nichts mehr. Liegt der Cron-Abstand ueber dem Praesenz-Timeout * (AgentRepository::PRESENCE_TIMEOUT_SECONDS), verschwaende der Agent genau dann vom * Schreibtisch, wenn seine Warnung dort stehen soll. * * isAgentPresent() bleibt davon unberuehrt - sonst zaehlte die KPI "Online" einen blockierten * Agenten als arbeitsfaehig mit. */ export function isAgentVisible(agent) { return isAgentPresent(agent) || agentProblemSeverity(agent) !== null; } /** * @param {Array} agents * @returns {number} Anzahl Agenten in einem Problemzustand (Grundlage der KPI-Kachel "Problem"). */ export function countAgentProblems(agents = []) { return agents.filter((agent) => agentProblemSeverity(agent) !== null).length; } /** * @param {Array} agents * @returns {Array} Agenten mit Problemzustand, gruppiert nach Schweregrad - Grundlage * fuer den Header-Banner, der die betroffenen Namen nennt. */ export function agentsWithProblems(agents = []) { return agents.filter((agent) => agentProblemSeverity(agent) !== null); } /** * Die beiden im KPI-Monitor erwarteten Kennzahlen "Agenten" (registrierte Agenten) und * "Online" (davon innerhalb der letzten 10 Minuten aktiv, siehe isAgentPresent()) werden * anders als die per POST /api/v1/kpis/{key} gemeldeten Kennzahlen (F7, weiterhin offen) * direkt aus der ohnehin geladenen Agentenliste abgeleitet statt separat gemeldet. * Gibt KPI-foermige Eintraege zurueck (gleiche Form wie GET /api/v1/kpis), damit sie sich * mit den gemeldeten KPIs vor buildKpiTiles()/buildKpiModalContent() zusammenfuehren lassen. * * @param {Array} agents - z.B. leere Schreibtisch-Plaetze der Demo-Daten sind null. * @returns {Array} */ export function buildAgentCountKpis(agents = []) { const registered = agents.filter(Boolean); const online = registered.filter(isAgentPresent).length; const problems = countAgentProblems(registered); return [ { key: "agents_total", label: "Agenten", unit: "", current_value: registered.length, history: [] }, { key: "agents_online", label: "Online", unit: "", current_value: online, history: [] }, // "Problem" faerbt sich nur ein, wenn es wirklich eines gibt - eine dauerhaft rote Kachel // mit einer 0 waere genau das Rauschen, das den Ernstfall untergehen liesse. { key: "agents_problem", label: "Problem", unit: "", current_value: problems, history: [], severity: problems > 0 ? "error" : null, }, ]; } /** * Ordnet eine rohe Agentenliste (GET /api/v1/agents) den Schreibtischen zu - anders als * buildDeskLayout()/Demo-Daten steht der Sitzplatz hier nicht in der Array-Reihenfolge, * sondern im Feld desk_index (API.md "Agenten"). Agenten ohne Schreibtisch (role "user" * oder alle Schreibtische belegt, desk_index === null) erscheinen hier nicht - sie kommen * gemaess F2 nur anlassbezogen durch eine Tuer und haben keinen festen Platz. Agenten, die laut * is_present-Feld nicht mehr als anwesend gelten (Issue #17/#20), erscheinen ebenfalls nicht - * ihr Schreibtisch bleibt frei. Ausnahme sind die Problemzustaende (isAgentVisible()): ein * blockierter Agent bleibt sitzen, auch wenn er sich laenger nicht gemeldet hat. * * @param {Array} agents * @param {number} [deskCount] * @returns {Array} */ export function mapAgentsToDesks(agents = [], deskCount = DEFAULT_DESK_COUNT) { const desks = new Array(deskCount).fill(null); for (const agent of agents) { if (!isAgentVisible(agent)) { continue; } const deskIndex = agent.desk_index; if (typeof deskIndex === "number" && deskIndex >= 0 && deskIndex < deskCount) { desks[deskIndex] = agent; } } return desks; } /** * Bereitet einen rohen API-Agenten fuer die Darstellung auf: Anzeigename + Pose/Ort aus * dem gemeldeten state ableiten (statt der Demo-only "pose"). * * @param {object} agent */ export function normalizeAgentForDisplay(agent) { const visual = resolveAgentVisualState(agent.state); return { ...agent, name: agent.display_name ?? agent.name, pose: visual.pose, location: visual.location, }; } const IDLE_ACTIVITIES = ["reading_newspaper", "at_coffee_corner", "talking_to_colleague"]; /** * Waehlt zufaellig eine idle-Aktivitaet (F8: "Nur idle Aktivitaeten wie Kaffeetrinken oder * Schwaetzchen halten steuert das System selber random" - rein kosmetisch, wird nicht an * die API gemeldet). Der Zufallsgenerator ist injizierbar, damit die Auswahl testbar ist. * * @param {() => number} [random] * @returns {string} */ export function pickIdleActivity(random = Math.random) { const index = Math.min(Math.floor(random() * IDLE_ACTIVITIES.length), IDLE_ACTIVITIES.length - 1); return IDLE_ACTIVITIES[index]; } /** * Serialisiert Animations-Trigger, damit bei sehr vielen gleichzeitigen Ereignissen (F9: * "20 Tickets in einer Sekunde") nicht alle Animationen gleichzeitig starten, sondern in * einer Warteschlange mit Mindestabstand abgearbeitet werden. Reine Logik ohne DOM-Zugriff, * damit sie ohne Browser testbar ist - office-render.js haengt eine window.setInterval- * Schleife an processDue() an. */ export class AnimationQueue { /** @param {{minIntervalMs?: number, now?: () => number}} [opts] */ constructor(opts = {}) { const { minIntervalMs = 150, now = () => Date.now() } = opts; this.minIntervalMs = minIntervalMs; this.now = now; this.queue = []; this.lastRunAt = -Infinity; } /** @param {() => void} task */ enqueue(task) { this.queue.push(task); } get size() { return this.queue.length; } /** Fuehrt so viele faellige Tasks wie moeglich aus, mindestens minIntervalMs auseinander. */ processDue() { let ranCount = 0; let current = this.now(); while (this.queue.length > 0 && current - this.lastRunAt >= this.minIntervalMs) { const task = this.queue.shift(); task(); ranCount += 1; this.lastRunAt = current; current = this.now(); } return ranCount; } } // --------------------------------------------------------------------------------------- // Phase 6 - Externe Rollen & Tueren (siehe Visualization.md Abschnitt 9, Detailantworten F2) // --------------------------------------------------------------------------------------- /** * Anlaesse, zu denen die Rolle "user" laut Detailantworten F2 durch die Tuer hereinkommt. * Der Text auf den Klebezetteln bleibt weiterhin absichtlich unlesbar - der Anlass dient nur * dazu, den richtigen kurzen Auftritt (Board-Besuch) auszuloesen. */ export const DOOR_VISIT_TYPES = { // Neues Ticket im Backlog, das nicht von einer Rolle dort abgelegt wurde (F4: Backlog wird // per Codeberg-Sync befuellt) - der User "traegt" es symbolisch aufs Board. NEW_BACKLOG_TICKET: "new_backlog_ticket", // Ticket erreicht Spalte "fertig" - der User schaut es sich an. TICKET_READY_FOR_REVIEW: "ticket_ready_for_review", // Ticket erreicht Spalte "done" (geschlossen) - der User legt es auf den Done-Stapel. TICKET_DONE: "ticket_done", }; /** * Erkennt Tuerbesuch-Anlaesse (F2) durch Vergleich zweier Ticketlisten-Snapshots * (z. B. zwei aufeinanderfolgende GET /api/v1/tickets-Antworten). Reine Diff-Logik ohne * DOM-/Zeit-Abhaengigkeit, daher hier statt in office-render.js - so bleibt sie mit dem * Node-Testrunner pruefbar (siehe Teststrategie in Visualization.md Abschnitt 8). * * @param {Array<{id: number, column_name: string, source?: string}>} previousTickets * @param {Array<{id: number, column_name: string, source?: string}>} currentTickets * @returns {Array<{type: string, ticketId: number}>} */ export function detectDoorVisits(previousTickets = [], currentTickets = []) { const previousById = new Map(previousTickets.map((ticket) => [ticket.id, ticket])); const visits = []; for (const ticket of currentTickets) { const before = previousById.get(ticket.id); if (!before) { if (ticket.column_name === "backlog" && ticket.source === "codeberg") { visits.push({ type: DOOR_VISIT_TYPES.NEW_BACKLOG_TICKET, ticketId: ticket.id }); } continue; } if (before.column_name === ticket.column_name) { continue; } if (ticket.column_name === "fertig") { visits.push({ type: DOOR_VISIT_TYPES.TICKET_READY_FOR_REVIEW, ticketId: ticket.id }); } else if (ticket.column_name === "done") { visits.push({ type: DOOR_VISIT_TYPES.TICKET_DONE, ticketId: ticket.id }); } } return visits; } // Ablauf eines Tuerbesuchs (F2): zum Board laufen, dort kurz interagieren, wieder hinaus zur // Tuer gehen. Der Anlass (DOOR_VISIT_TYPES) unterscheidet nur den Grund, nicht den Ablauf. export const DOOR_VISIT_STAGES = ["walking_to_board", "interacting_with_board", "leaving"]; const DOOR_VISIT_STAGE_VISUALS = { walking_to_board: { location: AGENT_LOCATIONS.BOARD, pose: "walking" }, interacting_with_board: { location: AGENT_LOCATIONS.BOARD, pose: "interacting" }, leaving: { location: AGENT_LOCATIONS.DOOR, pose: "walking" }, }; /** * @param {string} stage - Wert aus DOOR_VISIT_STAGES. * @returns {{location: string, pose: string}} */ export function resolveDoorVisitVisual(stage) { return DOOR_VISIT_STAGE_VISUALS[stage] ?? DOOR_VISIT_STAGE_VISUALS[DOOR_VISIT_STAGES[0]]; } /** * Schaltet einen Tuerbesuch zur naechsten Stage weiter. * * @param {string} stage - aktuelle Stage aus DOOR_VISIT_STAGES. * @returns {string|null} naechste Stage, oder null, wenn der Besuch abgeschlossen ist * (Avatar verlaesst danach die Szene wieder durch die Tuer). */ export function advanceDoorVisitStage(stage) { const index = DOOR_VISIT_STAGES.indexOf(stage); if (index === -1 || index === DOOR_VISIT_STAGES.length - 1) { return null; } return DOOR_VISIT_STAGES[index + 1]; } // --------------------------------------------------------------------------------------- // Product Owner (Issue #19): fester Platz + Board-Besuch bei Ticket-Statuswechseln, die noch // keiner Rolle zugeordnet sind (Visualization.md Abschnitt 4.4/9, Analogon zu Phase 6/Tueren). // --------------------------------------------------------------------------------------- // Der PO ist keine echte Registrierung ueber POST /api/v1/agents, sondern repraesentiert die // Produktverantwortung des Systems selbst - er bekommt daher keinen dynamisch zugewiesenen // Schreibtisch aus dem 8er-Raster, sondern einen eigenen, festen Platz (siehe office-render.js, // PO_DESK), und gilt als anwesend, sobald die API erreichbar ist (nicht ueber das // is_present-Feld wie registrierte Agenten, siehe isAgentPresent()). export const PO_AGENT_ID = "po"; export const PO_DISPLAY_NAME = "PO"; // Statuswechsel, die bereits einer anderen Rolle zugeordnet sind: neue Backlog-Tickets sowie // der Wechsel nach "fertig"/"done" loesen bereits einen Tuerbesuch der Rolle "user" aus // (siehe DOOR_VISIT_TYPES). Alles andere - z.B. der Wechsel nach "bereit" durch das Label // "automate" (das genau dieses Issue #19 ausgeloest hat) - ist bislang niemandem zugeordnet // und damit Sache des PO. const PO_UNASSIGNED_EXCLUDED_COLUMNS = new Set(["fertig", "done"]); // Issue #32: Wechsel von/zu "in_arbeit" (Label "wip") setzt bzw. entfernt der Agent selbst, der // das Ticket nimmt bzw. abgibt - er meldet diese Bewegung ueber seinen eigenen state (POST // /api/v1/agents/{id}/activity, siehe resolveAgentVisualState()), nicht der PO. Ohne diesen // Ausschluss lief bislang faelschlich zusaetzlich der PO zum Board, sobald ein Agent ein Ticket // nahm oder abgab. const AGENT_OWNED_COLUMN = "in_arbeit"; /** * Erkennt Ticket-Statuswechsel, die keiner Rolle zugeordnet sind (Gegenstueck zu * detectDoorVisits() fuer die bereits zugeordneten Faelle, sowie zu den vom Agenten selbst * gemeldeten "in_arbeit"-Wechseln, siehe AGENT_OWNED_COLUMN). Neu angelegte Tickets zaehlen hier * nicht mit - die sind bereits Sache von detectDoorVisits()/NEW_BACKLOG_TICKET. * * @param {Array<{id: number, column_name: string}>} previousTickets * @param {Array<{id: number, column_name: string}>} currentTickets * @returns {Array<{ticketId: number, column: string}>} */ export function detectUnassignedTicketChanges(previousTickets = [], currentTickets = []) { const previousById = new Map(previousTickets.map((ticket) => [ticket.id, ticket])); const changes = []; for (const ticket of currentTickets) { const before = previousById.get(ticket.id); if (!before || before.column_name === ticket.column_name) { continue; } if (PO_UNASSIGNED_EXCLUDED_COLUMNS.has(ticket.column_name)) { continue; } if (before.column_name === AGENT_OWNED_COLUMN || ticket.column_name === AGENT_OWNED_COLUMN) { continue; } changes.push({ ticketId: ticket.id, column: ticket.column_name }); } return changes; } /** * Issue #61: Gegenstueck zu detectUnassignedTicketChanges() fuer genau die dort ausgeschlossenen * Wechsel von/zu "in_arbeit" (siehe AGENT_OWNED_COLUMN). office-render.js loest fuer jeden hier * erkannten Wechsel einen sichtbaren Board-Besuch des zustaendigen Agenten aus, analog zu den * Tuer-/PO-Besuchen (Issue #76: bis dahin blieb der Agent sichtbar an seinem Platz sitzen, weil * er die dafuer vorgesehenen states "walking_to_board"/"interacting_with_board" in der Praxis * nicht meldete). Meldet der Agent seine Bewegung ausnahmsweise doch selbst (siehe * isAgentReportingBoardVisit()), bleibt dieser selbst getaktete Besuch aus und die echte Meldung * behaelt Vorrang - office-render.js nutzt das Ergebnis dann weiterhin, um das Ticket so lange an * seiner alten Stelle zu halten, bis hasAgentReachedBoard() fuer den zustaendigen Agenten zutrifft * (siehe dort) - sonst wuerde das Ticket bereits die Spalte wechseln, waehrend der Entwickler * sichtbar noch auf dem Weg zum Board ist. * * @param {Array<{id: number, column_name: string, assignee_agent_id?: number|string|null}>} previousTickets * @param {Array<{id: number, column_name: string, assignee_agent_id?: number|string|null}>} currentTickets * @returns {Array<{ticketId: number, agentId: number|string|null}>} */ export function detectAgentOwnedTicketChanges(previousTickets = [], currentTickets = []) { const previousById = new Map(previousTickets.map((ticket) => [ticket.id, ticket])); const changes = []; for (const ticket of currentTickets) { const before = previousById.get(ticket.id); if (!before || before.column_name === ticket.column_name) { continue; } if (before.column_name !== AGENT_OWNED_COLUMN && ticket.column_name !== AGENT_OWNED_COLUMN) { continue; } const agentId = ticket.assignee_agent_id ?? before.assignee_agent_id ?? null; changes.push({ ticketId: ticket.id, agentId }); } return changes; } // Stages aus AGENT_STATE_VISUALS, waehrend derer der zustaendige Agent laut eigenem state // nachweislich noch NICHT am Board angekommen ist (Issue #61). Jeder andere gemeldete state - // insbesondere "interacting_with_board" und "walking_back", aber auch ein voellig unbekannter/ // fehlender Agent - gilt als "Ankunft bereits erfolgt (oder nicht mehr feststellbar)", damit ein // zurueckgehaltenes Ticket nicht dauerhaft an seiner alten Stelle haengen bleibt, nur weil eine // Zwischen-Stage zwischen zwei Polls verpasst wurde. const STATES_BEFORE_BOARD_ARRIVAL = new Set(["walking_to_board"]); /** * @param {string|undefined|null} agentState - zuletzt gemeldeter Agent.state (API). * @returns {boolean} true, sobald das zurueckgehaltene Ticket sichtbar an die neue Spalte * wechseln darf (Issue #61). */ export function hasAgentReachedBoard(agentState) { return !STATES_BEFORE_BOARD_ARRIVAL.has(agentState); } // States, an denen zu erkennen ist, dass der zustaendige Agent seine Bewegung zum Board bereits // selbst meldet (Loesungsrichtung 1 aus Issue #76, ueber POST /api/v1/agents/{id}/activity, // siehe API.md). In der Praxis meldeten Agenten diese States bislang so gut wie nie (siehe // detectAgentOwnedTicketChanges()) - kommt die Meldung doch, soll sie Vorrang vor dem selbst // getakteten Board-Besuch (Loesungsrichtung 2) behalten, statt dass beide gleichzeitig laufen. const AGENT_REPORTED_BOARD_STATES = new Set(["walking_to_board", "interacting_with_board"]); /** * @param {string|undefined|null} agentState - zuletzt gemeldeter Agent.state (API). * @returns {boolean} true, wenn der Agent seine Bewegung zum Board bereits selbst meldet - in dem * Fall soll office-render.js keinen zusaetzlichen, selbst getakteten Board-Besuch anstossen * (Issue #76). */ export function isAgentReportingBoardVisit(agentState) { return AGENT_REPORTED_BOARD_STATES.has(agentState); } /** * Issue #55: Bislang wurde ein Ticket sofort an seinem neuen Platz gezeichnet, sobald eine * Aenderung erkannt wurde - Tuerbesuch/PO-Besuch liefen davon unabhaengig erst danach als reine * Kosmetik ab, wirkten also "hinterher". Richtig ist es andersherum: die Persona soll zuerst am * Board ankommen, bevor sich das Ticket sichtbar an die neue Stelle bewegt. * * office-render.js haelt dafuer pro Ticket, dessen Persona noch unterwegs ist, den alten Stand * in einer Map (ticketId -> alter Ticket-Stand, oder null bei einem brandneuen Ticket) und * ersetzt damit den neuen Stand aus der API-Antwort, bis die Persona am Board angekommen ist * (siehe DOOR_VISIT_STAGES/PO_VISIT_STAGES, Stage "interacting_with_board"). Reine Merge-Logik * ohne DOM-/Zeit-Abhaengigkeit, daher hier statt in office-render.js (siehe Teststrategie in * Visualization.md Abschnitt 8). * * @param {Array} latestTickets - aktuellster Stand aus der API. * @param {Map} pendingMoves - ticketId -> alter Ticket-Stand (null = Ticket * ist neu und wird bis zum Eintreffen der Persona ganz ausgeblendet). * @returns {Array} zur Anzeige bestimmte Ticketliste. */ export function applyPendingTicketMoves(latestTickets = [], pendingMoves) { if (!pendingMoves || pendingMoves.size === 0) { return latestTickets; } const result = []; for (const ticket of latestTickets) { if (!pendingMoves.has(ticket.id)) { result.push(ticket); continue; } const oldTicket = pendingMoves.get(ticket.id); if (oldTicket) { result.push(oldTicket); } } return result; } // Ablauf eines PO-Board-Besuchs: vom festen Platz zum Board laufen, dort kurz interagieren, // zurueck zum Platz. Nutzt bewusst dieselben state-Keys wie die Agenten-Zustandsmaschine // (resolveAgentVisualState), damit keine zweite Ort/Pose-Zuordnung noetig ist. export const PO_VISIT_STAGES = ["walking_to_board", "interacting_with_board", "walking_back"]; /** * @param {string} stage - aktuelle Stage aus PO_VISIT_STAGES. * @returns {string|null} naechste Stage, oder null, wenn der Besuch abgeschlossen ist (der PO * ist danach wieder "idle" am eigenen Platz). */ export function advancePoVisitStage(stage) { const index = PO_VISIT_STAGES.indexOf(stage); if (index === -1 || index === PO_VISIT_STAGES.length - 1) { return null; } return PO_VISIT_STAGES[index + 1]; } // --------------------------------------------------------------------------------------- // Reload-Countdown im Header (Issue #12): rein kosmetischer Sekundenzaehler, der die // Sync-Frequenz des Backends (MONITOR_SYNC_INTERVAL, siehe .env.example) im Header // widerspiegelt - ohne selbst irgendetwas neu zu laden (siehe office-render.js). // --------------------------------------------------------------------------------------- export const DEFAULT_RELOAD_INTERVAL_SECONDS = 600; /** * @param {unknown} rawValue - z.B. window.MONITOR_SYNC_INTERVAL (aus config.js). * @returns {number} gueltige Intervall-Laenge in Sekunden, faellt auf den Default zurueck, * wenn der Rohwert fehlt oder keine positive Zahl ist. */ export function resolveReloadIntervalSeconds(rawValue) { const parsed = Number(rawValue); return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : DEFAULT_RELOAD_INTERVAL_SECONDS; } /** * @param {number} current - aktueller Zaehlerstand in Sekunden. * @param {number} intervalSeconds - Intervall-Laenge, auf die nach Ablauf zurueckgesetzt wird. * @returns {number} naechster Zaehlerstand (zaehlt herunter, springt bei <=1 zurueck auf den vollen Wert). */ export function nextCountdownSeconds(current, intervalSeconds) { return current <= 1 ? intervalSeconds : current - 1; }