From 811a7d9f69d004604a9718a2c5d450e508909dff Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Wed, 29 Jul 2026 17:13:59 +0200 Subject: [PATCH] PO bekommt festen Platz + laeuft bei niemandem zugeordneten Ticketwechseln zum Board Der Product Owner ("PO") ist keine ueber die API registrierte Rolle, sondern repraesentiert die Produktverantwortung des Systems selbst: eigener, fester Platz ausserhalb des dynamischen 8er-Schreibtisch-Rasters, anwesend genau dann, wenn die API erreichbar ist (statt ueber last_seen_at). Ausserdem laeuft er zum Board, sobald sich dort ein Ticket-Status aendert, der noch keiner anderen Rolle zugeordnet ist (z.B. Wechsel nach "bereit" durchs Label "automate", wie es diesem Issue selbst passiert ist) - Wechsel nach fertig/done bleiben weiterhin Sache der Rolle "user" (Tuerbesuche). Schliesst #19 --- html/js/office-render.js | 118 +++++++++++++++++++++++++--- html/js/office-scene.js | 65 +++++++++++++++ tests/frontend/office-scene.test.js | 75 ++++++++++++++++++ 3 files changed, 245 insertions(+), 13 deletions(-) diff --git a/html/js/office-render.js b/html/js/office-render.js index deda218..fe98c27 100644 --- a/html/js/office-render.js +++ b/html/js/office-render.js @@ -34,6 +34,11 @@ import { resolveDoorVisitVisual, advanceDoorVisitStage, DOOR_VISIT_STAGES, + detectUnassignedTicketChanges, + advancePoVisitStage, + PO_VISIT_STAGES, + PO_AGENT_ID, + PO_DISPLAY_NAME, resolveReloadIntervalSeconds, nextCountdownSeconds, } from "./office-scene.js"; @@ -62,6 +67,11 @@ const DESK_ROW_Y = [540, 840]; const DESK_COL_X = [232, 644, 1056, 1468]; const COFFEE_CORNER = { x: 960, y: 780 }; +// Fester Platz des Product Owner (Issue #19): bewusst ausserhalb des dynamischen 8er-Rasters +// (DESK_COL_X/DESK_ROW_Y), in der freien Nische zwischen Board/Monitor-Wand und der ersten +// Schreibtischreihe - so kollidiert er nie mit einem echten, ueber die API registrierten Agenten. +const PO_DESK = { x: 870, y: 380, w: 180 }; + const NOTE_W = 96; const NOTE_H = 50; const NOTE_STEP_Y = 56; @@ -74,6 +84,9 @@ const COFFEE_FEET = { x: COFFEE_CORNER.x, y: COFFEE_CORNER.y + 26 }; // Ruheplatz der Tuerbesucher (Phase 6, F2): direkt vor der linken Tuer, wie schon der // Ueberlaufplatz fuer Agenten ohne Schreibtisch (siehe agentPlacements()). const DOOR_FEET = { x: 20, y: DOOR.y + DOOR.h / 2 }; +// Ruheplatz des PO an seinem festen Platz (Issue #19), analog zu den Fuss-Positionen der +// regulaeren Schreibtische (deskSeat().feet). +const PO_DESK_FEET = { x: PO_DESK.x + PO_DESK.w / 2, y: PO_DESK.y + 40 + DESK.h + 30 }; const IDLE_MIN_DELAY_MS = 15000; const IDLE_MAX_DELAY_MS = 40000; @@ -429,16 +442,17 @@ function deskScreenPoseClass(pose) { // Zeichnet nur die Moebel/Deko + den Namen des Schreibtisch-Besitzers - die eigentliche // Person ist ein eigenstaendiger, beweglicher Avatar (siehe renderAgentAvatar/ // updateAgentsLayer), damit sie zum Board/Monitor "laufen" kann, ohne den Schreibtisch neu -// zeichnen zu muessen. -function renderDesk({ deskIndex, agent }) { - const { origin, feet } = deskSeat(deskIndex); +// zeichnen zu muessen. Parametrisiert ueber origin/width, damit sowohl die regulaeren +// Schreibtische (deskSeat()) als auch der feste PO-Platz (PO_DESK, Issue #19) dieselbe +// Zeichenlogik/Deko (inkl. rollenabhaengiger desk.deco) nutzen koennen. +function renderDeskAt(origin, agent, width = DESK.w) { const role = agent ? ROLES[agent.role] : null; const g = el("g", { class: `desk ${agent ? "desk-occupied" : "desk-empty"}`, transform: `translate(${origin.x}, ${origin.y})`, }); - g.appendChild(el("rect", { x: 0, y: 40, width: DESK.w, height: DESK.h, rx: 6, class: "desk-table" })); + g.appendChild(el("rect", { x: 0, y: 40, width, height: DESK.h, rx: 6, class: "desk-table" })); g.appendChild( el("rect", { x: 20, @@ -449,18 +463,18 @@ function renderDesk({ deskIndex, agent }) { class: `desk-screen ${agent ? deskScreenPoseClass(agent.pose) : ""}`, }) ); - g.appendChild(el("circle", { cx: DESK.w / 2, cy: feet.y - origin.y, r: 22, class: "desk-chair" })); + g.appendChild(el("circle", { cx: width / 2, cy: 40 + DESK.h + 30, r: 22, class: "desk-chair" })); if (role) { if (role.deco === "star") { g.appendChild( el("path", { - d: "M170,20 l4,10 11,0 -9,7 3,11 -9,-7 -9,7 3,-11 -9,-7 11,0 z", + d: `M${width - 50},20 l4,10 11,0 -9,7 3,11 -9,-7 -9,7 3,-11 -9,-7 11,0 z`, class: "desk-deco desk-deco-star", }) ); } else if (role.deco === "flower") { - const cx = 175; + const cx = width - 45; const cy = 22; g.appendChild( el("g", { class: "desk-deco desk-deco-flower" }, [ @@ -473,13 +487,13 @@ function renderDesk({ deskIndex, agent }) { ); } else if (role.deco === "check") { g.appendChild( - el("path", { d: "M162,24 l8,8 16,-18", class: "desk-deco desk-deco-check" }) + el("path", { d: `M${width - 58},24 l8,8 16,-18`, class: "desk-deco desk-deco-check" }) ); } g.appendChild( text(agent.name, { - x: DESK.w / 2, + x: width / 2, y: 40 + DESK.h + 74, class: "desk-name", "text-anchor": "middle", @@ -488,7 +502,7 @@ function renderDesk({ deskIndex, agent }) { } else { g.appendChild( text("frei", { - x: DESK.w / 2, + x: width / 2, y: 40 + DESK.h + 74, class: "desk-name desk-name-empty", "text-anchor": "middle", @@ -499,9 +513,21 @@ function renderDesk({ deskIndex, agent }) { return g; } -function renderDesks(desks) { +function renderDesk({ deskIndex, agent }) { + return renderDeskAt(deskSeat(deskIndex).origin, agent); +} + +/** + * @param {Array} desks - reguläre 8er-Raster-Plaetze (echte Agenten, F1). + * @param {object|null} poAgent - {name: "PO", role: "product_owner", pose} bei erreichbarer + * API, sonst null (dann zeigt der feste PO-Platz "frei", Issue #19). + */ +function renderDesks(desks, poAgent) { const g = el("g", { class: "desks" }); desks.forEach((desk) => g.appendChild(renderDesk(desk))); + const poDeskGroup = renderDeskAt(PO_DESK, poAgent, PO_DESK.w); + poDeskGroup.classList.add("po-desk"); + g.appendChild(poDeskGroup); return g; } @@ -719,10 +745,63 @@ function updateDoorActiveClass() { function repaintAgentsLayer() { if (!currentAgentsLayer) return; - updateAgentsLayer(currentAgentsLayer, [...currentRegularPlacements, ...doorVisitorPlacements()]); + updateAgentsLayer(currentAgentsLayer, [ + ...currentRegularPlacements, + ...poPlacement(), + ...doorVisitorPlacements(), + ]); updateDoorActiveClass(); } +// --------------------------------------------------------------------------------------- +// Product Owner (Issue #19): fester Platz + Avatar, der bei "niemandem zugeordneten" +// Ticket-Statuswechseln zum Board laeuft (siehe detectUnassignedTicketChanges() in +// office-scene.js). Anders als die Tuerbesucher (mehrere gleichzeitig moeglich, Phase 6) gibt +// es nur einen PO - poState ist daher ein einzelner Zustand statt einer Map. +// --------------------------------------------------------------------------------------- + +let poIsLive = false; +let poState = "idle"; +let poVisitTimeoutId = null; + +function poPlacement() { + if (!poIsLive) { + return []; + } + const visual = resolveAgentVisualState(poState); + return [ + { + agent: { id: PO_AGENT_ID, name: PO_DISPLAY_NAME, role: "product_owner", pose: visual.pose, location: visual.location }, + homeFeet: PO_DESK_FEET, + }, + ]; +} + +function advancePoVisit() { + const nextStage = advancePoVisitStage(poState); + if (nextStage === null) { + poState = "idle"; + repaintAgentsLayer(); + return; + } + poState = nextStage; + repaintAgentsLayer(); + poVisitTimeoutId = window.setTimeout(advancePoVisit, DOOR_VISIT_STAGE_DURATION_MS); +} + +// Mehrere gleichzeitige unzugeordnete Aenderungen (z.B. mehrere Tickets erhalten kurz +// hintereinander das Label "automate") loesen bewusst nur einen Besuch aus, statt den PO fuer +// jede einzelne Aenderung hin- und herlaufen zu lassen - ein laufender Besuch wird zu Ende +// animiert, statt unterbrochen zu werden. +function triggerPoVisit() { + if (!poIsLive || poState !== "idle") { + return; + } + poState = PO_VISIT_STAGES[0]; + repaintAgentsLayer(); + poVisitTimeoutId = window.setTimeout(advancePoVisit, DOOR_VISIT_STAGE_DURATION_MS); +} + function advanceDoorVisit(id) { const visitor = doorVisitors.get(id); if (!visitor) { @@ -902,6 +981,7 @@ export function renderOfficeScene(container, data = {}) { events = DEMO_EVENTS, kpis = [], tickets = [], + isLive = false, } = data; let svg = container.querySelector(".office-svg"); @@ -922,8 +1002,15 @@ export function renderOfficeScene(container, data = {}) { idleCosmeticTimers.forEach((handle) => window.clearTimeout(handle.timeoutId)); idleCosmeticTimers.clear(); doorVisitors.clear(); + if (poVisitTimeoutId) window.clearTimeout(poVisitTimeoutId); + poVisitTimeoutId = null; + poState = "idle"; } + // Der PO gilt als anwesend, sobald die API erreichbar ist (isLive) - nicht ueber + // last_seen_at/AGENT_PRESENCE_TIMEOUT_MS wie registrierte Agenten (Issue #19). + poIsLive = isLive; + const groupedTickets = groupTicketsByColumn(tickets); const boardGroup = renderBoard(ticketCounts, (columnKey) => { @@ -954,7 +1041,8 @@ export function renderOfficeScene(container, data = {}) { } const deskLayout = buildDeskLayout(normalizedDeskAgents(agents)); - const desksGroup = renderDesks(deskLayout); + const poDeskAgent = isLive ? { name: PO_DISPLAY_NAME, role: "product_owner", pose: "idle" } : null; + const desksGroup = renderDesks(deskLayout, poDeskAgent); const existingDesks = svg.querySelector(".desks"); if (existingDesks) { existingDesks.replaceWith(desksGroup); @@ -1063,6 +1151,9 @@ async function boot() { const data = await loadLiveData(); if (previousTickets) { detectDoorVisits(previousTickets, data.tickets).forEach(triggerDoorVisit); + if (detectUnassignedTicketChanges(previousTickets, data.tickets).length > 0) { + triggerPoVisit(); + } } previousTickets = data.tickets; renderOfficeScene(container, { @@ -1072,6 +1163,7 @@ async function boot() { systems: data.systems, events: data.events, kpis: data.kpis, + isLive: true, }); setApiBanner(false); } catch (err) { diff --git a/html/js/office-scene.js b/html/js/office-scene.js index 5cbebb6..dee9a0b 100644 --- a/html/js/office-scene.js +++ b/html/js/office-scene.js @@ -568,6 +568,71 @@ export function advanceDoorVisitStage(stage) { 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 +// last_seen_at/AGENT_PRESENCE_TIMEOUT_MS 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"]); + +/** + * Erkennt Ticket-Statuswechsel, die keiner Rolle zugeordnet sind (Gegenstueck zu + * detectDoorVisits() fuer die bereits zugeordneten Faelle). 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; + } + 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 diff --git a/tests/frontend/office-scene.test.js b/tests/frontend/office-scene.test.js index 15e0b05..4c42731 100644 --- a/tests/frontend/office-scene.test.js +++ b/tests/frontend/office-scene.test.js @@ -33,6 +33,11 @@ import { detectDoorVisits, resolveDoorVisitVisual, advanceDoorVisitStage, + PO_AGENT_ID, + PO_DISPLAY_NAME, + PO_VISIT_STAGES, + detectUnassignedTicketChanges, + advancePoVisitStage, DEFAULT_RELOAD_INTERVAL_SECONDS, resolveReloadIntervalSeconds, nextCountdownSeconds, @@ -440,6 +445,76 @@ test("advanceDoorVisitStage liefert bei unbekannter Stage null", () => { assert.equal(advanceDoorVisitStage("unbekannt"), null); }); +// --------------------------------------------------------------------------------------- +// Product Owner (Issue #19) +// --------------------------------------------------------------------------------------- + +test("PO_AGENT_ID/PO_DISPLAY_NAME sind stabile Konstanten (der PO heisst schlicht 'PO')", () => { + assert.equal(PO_AGENT_ID, "po"); + assert.equal(PO_DISPLAY_NAME, "PO"); +}); + +test("detectUnassignedTicketChanges erkennt einen Statuswechsel nach 'bereit' (Label automate)", () => { + const before = [{ id: 19, column_name: "backlog" }]; + const after = [{ id: 19, column_name: "bereit" }]; + assert.deepEqual(detectUnassignedTicketChanges(before, after), [{ ticketId: 19, column: "bereit" }]); +}); + +test("detectUnassignedTicketChanges ignoriert Wechsel nach fertig/done (schon Sache der Rolle user)", () => { + const before = [ + { id: 1, column_name: "qa" }, + { id: 2, column_name: "testing" }, + ]; + const after = [ + { id: 1, column_name: "fertig" }, + { id: 2, column_name: "done" }, + ]; + assert.deepEqual(detectUnassignedTicketChanges(before, after), []); +}); + +test("detectUnassignedTicketChanges ignoriert neu angelegte Tickets (schon Sache von detectDoorVisits)", () => { + const visits = detectUnassignedTicketChanges([], [{ id: 1, column_name: "bereit" }]); + assert.deepEqual(visits, []); +}); + +test("detectUnassignedTicketChanges ignoriert Tickets ohne Spaltenwechsel", () => { + const before = [{ id: 1, column_name: "in_arbeit" }]; + const after = [{ id: 1, column_name: "in_arbeit" }]; + assert.deepEqual(detectUnassignedTicketChanges(before, after), []); +}); + +test("detectUnassignedTicketChanges verarbeitet mehrere gleichzeitige Wechsel", () => { + const before = [ + { id: 1, column_name: "backlog" }, + { id: 2, column_name: "backlog" }, + { id: 3, column_name: "qa" }, + ]; + const after = [ + { id: 1, column_name: "bereit" }, + { id: 2, column_name: "in_arbeit" }, + { id: 3, column_name: "fertig" }, + ]; + assert.deepEqual(detectUnassignedTicketChanges(before, after), [ + { ticketId: 1, column: "bereit" }, + { ticketId: 2, column: "in_arbeit" }, + ]); +}); + +test("advancePoVisitStage durchlaeuft alle Stages und liefert am Ende null", () => { + let stage = PO_VISIT_STAGES[0]; + const seen = [stage]; + for (let i = 0; i < PO_VISIT_STAGES.length; i++) { + stage = advancePoVisitStage(stage); + if (stage !== null) seen.push(stage); + } + assert.deepEqual(seen, PO_VISIT_STAGES); + assert.equal(advancePoVisitStage(PO_VISIT_STAGES[PO_VISIT_STAGES.length - 1]), null); +}); + +test("advancePoVisitStage liefert bei unbekannter Stage null", () => { + assert.equal(advancePoVisitStage("unbekannt"), null); +}); + test("resolveReloadIntervalSeconds uebernimmt gueltige positive Zahlen (auch als String)", () => { assert.equal(resolveReloadIntervalSeconds(30), 30); assert.equal(resolveReloadIntervalSeconds("45"), 45);