Setzt Visualization.md Abschnitt 9, Phasen 3-5 vollstaendig um: Phase 3 (Kanban-Board live): Board zeigt echte Ticket-Daten aus GET /api/v1/tickets (office-api.js), Klick auf eine Spalte oeffnet ein Modal mit Titel/Beschreibung/Typ und einem Link auf die Codeberg-Quelle bei per Sync angelegten Tickets (F4). Phase 4 (Monitor-Wand live): Event-, System- und KPI-Monitor zeigen echte Daten, Klick-Modals fuer alle drei. Neuer SSE-Endpoint GET /api/v1/stream (F13) meldet per ChangeFeed-Fingerabdruck, welche Ressourcen sich geaendert haben; office-realtime.js nutzt EventSource mit Polling-Fallback. KPI-Monitor zeichnet eine eigene SVG-Sparkline (F10, kein Chart-Lib) sobald KPIs gemeldet wurden, sonst weiterhin der Phase-2-Platzhalter (F7 bleibt inhaltlich offen). API-Antworten tragen jetzt CORS-Header, da Frontend (:8090) und API (:8091) im Docker-Compose-Setup unterschiedliche Origins sind. Phase 5 (Agenten & Animation): resolveAgentVisualState bildet gemeldete Agenten-Zustaende auf Ort/Pose ab; Agenten-Avatare sind eine per CSS transition animierte, von Board/ Schreibtisch-Moebeln entkoppelte Schicht, die zwischen Schreibtisch, Board, Monitor-Wand und einer neuen Kaffee-Ecke laufen. Idle-Varianten (Zeitung/Kaffee/Schwaetzchen) werden rein kosmetisch client-seitig zufaellig ausgeloest (F8), eine AnimationQueue serialisiert Update-Animationen bei vielen gleichzeitigen Ereignissen (F9). 29 Node-Tests (100% Line-Coverage office-scene.js) und neue PHPUnit-Tests fuer Response::stream(), ChangeFeed, StreamController und die neuen Repository::fingerprint()- Methoden. PHP/Docker-Ausfuehrung konnte in dieser Umgebung nicht verifiziert werden (kein PHP verfuegbar) - vor dem Merge bitte `docker compose run --rm api composer test` pruefen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
449 lines
16 KiB
JavaScript
449 lines
16 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" },
|
|
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;
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* @param {Record<string, number>} ticketCounts - Anzahl Tickets je Spalten-Key.
|
|
* @returns {Array<{key: string, label: string, count: number, visibleNotes: number, stackCount: number}>}
|
|
*/
|
|
export function buildBoardColumnLayout(ticketCounts = {}) {
|
|
return BOARD_COLUMNS.map(({ key, label }) => {
|
|
const count = Math.max(0, ticketCounts[key] ?? 0);
|
|
|
|
if (key === "done") {
|
|
return { key, label, count, visibleNotes: 0, stackCount: count };
|
|
}
|
|
|
|
if (count <= MAX_VISIBLE_NOTES_PER_COLUMN + 1) {
|
|
return { key, label, count, visibleNotes: count, stackCount: 0 };
|
|
}
|
|
|
|
return {
|
|
key,
|
|
label,
|
|
count,
|
|
visibleNotes: MAX_VISIBLE_NOTES_PER_COLUMN,
|
|
stackCount: count - MAX_VISIBLE_NOTES_PER_COLUMN,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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).
|
|
*
|
|
* @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/${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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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: system.last_check_at,
|
|
})),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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(" ");
|
|
}
|
|
|
|
export const MAX_KPI_TILES = 3;
|
|
|
|
/**
|
|
* 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,
|
|
sparklinePoints: buildKpiSparklinePoints(kpi.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",
|
|
};
|
|
|
|
// 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" },
|
|
};
|
|
|
|
/**
|
|
* @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;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* @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) {
|
|
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;
|
|
}
|
|
}
|