Bisher meldeten Agenten walking_to_board/interacting_with_board fuer Ticket-Uebernahmen faktisch nie, wodurch das Ticket ohne sichtbaren Board-Besuch die Spalte wechselte. office-render.js taktet den Besuch jetzt selbst (analog zu PO-/Tuerbesuchen): der zustaendige Agent laeuft vom eigenen (dann leeren) Schreibtisch zum Board, verschiebt dort sichtbar das Ticket und laeuft zurueck. Eine echte Meldung des Agenten (walking_to_board/interacting_with_board) hat weiterhin Vorrang und unterdrueckt den selbst getakteten Besuch. Schliesst #76
1025 lines
42 KiB
JavaScript
1025 lines
42 KiB
JavaScript
// 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<string, number>} ticketCounts - Anzahl Tickets je Spalten-Key.
|
|
* @param {Record<string, Array<object>>} 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<string|null>, stackColors: Array<string>}>}
|
|
*/
|
|
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<object>}
|
|
*/
|
|
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<object>}
|
|
*/
|
|
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<string, number>}
|
|
*/
|
|
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<string, Array<object>>}
|
|
*/
|
|
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<object>} 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<object>} 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<object>} 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<string>}>} 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 <polyline points="...">) 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<object>} 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<object>} 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<object|null>} 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<object|null>} agents
|
|
* @returns {Array<object>} 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<object|null>} agents - z.B. leere Schreibtisch-Plaetze der Demo-Daten sind null.
|
|
* @returns {Array<object>}
|
|
*/
|
|
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<object>} agents
|
|
* @param {number} [deskCount]
|
|
* @returns {Array<object|null>}
|
|
*/
|
|
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<object>} latestTickets - aktuellster Stand aus der API.
|
|
* @param {Map<number, object|null>} pendingMoves - ticketId -> alter Ticket-Stand (null = Ticket
|
|
* ist neu und wird bis zum Eintreffen der Persona ganz ausgeblendet).
|
|
* @returns {Array<object>} 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;
|
|
}
|