Statuswechsel von/zu "in_arbeit" (Label "wip") loest ein Agent selbst aus, wenn er ein Ticket nimmt bzw. abgibt, und meldet die Bewegung ueber seinen eigenen state. detectUnassignedTicketChanges() zaehlte diese Wechsel bislang trotzdem als "niemandem zugeordnet" und liess zusaetzlich den PO dafuer zum Board laufen. Schliesst #32
708 lines
27 KiB
JavaScript
708 lines
27 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;
|
|
|
|
// 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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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",
|
|
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" },
|
|
};
|
|
|
|
/**
|
|
* @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);
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* @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 (!isAgentPresent(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;
|
|
}
|
|
|
|
// 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;
|
|
}
|