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
This commit is contained in:
fds2610-bot 2026-07-29 17:13:59 +02:00
parent e96b2e1843
commit 811a7d9f69
3 changed files with 245 additions and 13 deletions

View file

@ -34,6 +34,11 @@ import {
resolveDoorVisitVisual, resolveDoorVisitVisual,
advanceDoorVisitStage, advanceDoorVisitStage,
DOOR_VISIT_STAGES, DOOR_VISIT_STAGES,
detectUnassignedTicketChanges,
advancePoVisitStage,
PO_VISIT_STAGES,
PO_AGENT_ID,
PO_DISPLAY_NAME,
resolveReloadIntervalSeconds, resolveReloadIntervalSeconds,
nextCountdownSeconds, nextCountdownSeconds,
} from "./office-scene.js"; } from "./office-scene.js";
@ -62,6 +67,11 @@ const DESK_ROW_Y = [540, 840];
const DESK_COL_X = [232, 644, 1056, 1468]; const DESK_COL_X = [232, 644, 1056, 1468];
const COFFEE_CORNER = { x: 960, y: 780 }; 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_W = 96;
const NOTE_H = 50; const NOTE_H = 50;
const NOTE_STEP_Y = 56; 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 // Ruheplatz der Tuerbesucher (Phase 6, F2): direkt vor der linken Tuer, wie schon der
// Ueberlaufplatz fuer Agenten ohne Schreibtisch (siehe agentPlacements()). // Ueberlaufplatz fuer Agenten ohne Schreibtisch (siehe agentPlacements()).
const DOOR_FEET = { x: 20, y: DOOR.y + DOOR.h / 2 }; 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_MIN_DELAY_MS = 15000;
const IDLE_MAX_DELAY_MS = 40000; 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 // Zeichnet nur die Moebel/Deko + den Namen des Schreibtisch-Besitzers - die eigentliche
// Person ist ein eigenstaendiger, beweglicher Avatar (siehe renderAgentAvatar/ // Person ist ein eigenstaendiger, beweglicher Avatar (siehe renderAgentAvatar/
// updateAgentsLayer), damit sie zum Board/Monitor "laufen" kann, ohne den Schreibtisch neu // updateAgentsLayer), damit sie zum Board/Monitor "laufen" kann, ohne den Schreibtisch neu
// zeichnen zu muessen. // zeichnen zu muessen. Parametrisiert ueber origin/width, damit sowohl die regulaeren
function renderDesk({ deskIndex, agent }) { // Schreibtische (deskSeat()) als auch der feste PO-Platz (PO_DESK, Issue #19) dieselbe
const { origin, feet } = deskSeat(deskIndex); // Zeichenlogik/Deko (inkl. rollenabhaengiger desk.deco) nutzen koennen.
function renderDeskAt(origin, agent, width = DESK.w) {
const role = agent ? ROLES[agent.role] : null; const role = agent ? ROLES[agent.role] : null;
const g = el("g", { const g = el("g", {
class: `desk ${agent ? "desk-occupied" : "desk-empty"}`, class: `desk ${agent ? "desk-occupied" : "desk-empty"}`,
transform: `translate(${origin.x}, ${origin.y})`, 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( g.appendChild(
el("rect", { el("rect", {
x: 20, x: 20,
@ -449,18 +463,18 @@ function renderDesk({ deskIndex, agent }) {
class: `desk-screen ${agent ? deskScreenPoseClass(agent.pose) : ""}`, 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) {
if (role.deco === "star") { if (role.deco === "star") {
g.appendChild( g.appendChild(
el("path", { 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", class: "desk-deco desk-deco-star",
}) })
); );
} else if (role.deco === "flower") { } else if (role.deco === "flower") {
const cx = 175; const cx = width - 45;
const cy = 22; const cy = 22;
g.appendChild( g.appendChild(
el("g", { class: "desk-deco desk-deco-flower" }, [ el("g", { class: "desk-deco desk-deco-flower" }, [
@ -473,13 +487,13 @@ function renderDesk({ deskIndex, agent }) {
); );
} else if (role.deco === "check") { } else if (role.deco === "check") {
g.appendChild( 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( g.appendChild(
text(agent.name, { text(agent.name, {
x: DESK.w / 2, x: width / 2,
y: 40 + DESK.h + 74, y: 40 + DESK.h + 74,
class: "desk-name", class: "desk-name",
"text-anchor": "middle", "text-anchor": "middle",
@ -488,7 +502,7 @@ function renderDesk({ deskIndex, agent }) {
} else { } else {
g.appendChild( g.appendChild(
text("frei", { text("frei", {
x: DESK.w / 2, x: width / 2,
y: 40 + DESK.h + 74, y: 40 + DESK.h + 74,
class: "desk-name desk-name-empty", class: "desk-name desk-name-empty",
"text-anchor": "middle", "text-anchor": "middle",
@ -499,9 +513,21 @@ function renderDesk({ deskIndex, agent }) {
return g; return g;
} }
function renderDesks(desks) { function renderDesk({ deskIndex, agent }) {
return renderDeskAt(deskSeat(deskIndex).origin, agent);
}
/**
* @param {Array<object>} 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" }); const g = el("g", { class: "desks" });
desks.forEach((desk) => g.appendChild(renderDesk(desk))); 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; return g;
} }
@ -719,10 +745,63 @@ function updateDoorActiveClass() {
function repaintAgentsLayer() { function repaintAgentsLayer() {
if (!currentAgentsLayer) return; if (!currentAgentsLayer) return;
updateAgentsLayer(currentAgentsLayer, [...currentRegularPlacements, ...doorVisitorPlacements()]); updateAgentsLayer(currentAgentsLayer, [
...currentRegularPlacements,
...poPlacement(),
...doorVisitorPlacements(),
]);
updateDoorActiveClass(); 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) { function advanceDoorVisit(id) {
const visitor = doorVisitors.get(id); const visitor = doorVisitors.get(id);
if (!visitor) { if (!visitor) {
@ -902,6 +981,7 @@ export function renderOfficeScene(container, data = {}) {
events = DEMO_EVENTS, events = DEMO_EVENTS,
kpis = [], kpis = [],
tickets = [], tickets = [],
isLive = false,
} = data; } = data;
let svg = container.querySelector(".office-svg"); let svg = container.querySelector(".office-svg");
@ -922,8 +1002,15 @@ export function renderOfficeScene(container, data = {}) {
idleCosmeticTimers.forEach((handle) => window.clearTimeout(handle.timeoutId)); idleCosmeticTimers.forEach((handle) => window.clearTimeout(handle.timeoutId));
idleCosmeticTimers.clear(); idleCosmeticTimers.clear();
doorVisitors.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 groupedTickets = groupTicketsByColumn(tickets);
const boardGroup = renderBoard(ticketCounts, (columnKey) => { const boardGroup = renderBoard(ticketCounts, (columnKey) => {
@ -954,7 +1041,8 @@ export function renderOfficeScene(container, data = {}) {
} }
const deskLayout = buildDeskLayout(normalizedDeskAgents(agents)); 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"); const existingDesks = svg.querySelector(".desks");
if (existingDesks) { if (existingDesks) {
existingDesks.replaceWith(desksGroup); existingDesks.replaceWith(desksGroup);
@ -1063,6 +1151,9 @@ async function boot() {
const data = await loadLiveData(); const data = await loadLiveData();
if (previousTickets) { if (previousTickets) {
detectDoorVisits(previousTickets, data.tickets).forEach(triggerDoorVisit); detectDoorVisits(previousTickets, data.tickets).forEach(triggerDoorVisit);
if (detectUnassignedTicketChanges(previousTickets, data.tickets).length > 0) {
triggerPoVisit();
}
} }
previousTickets = data.tickets; previousTickets = data.tickets;
renderOfficeScene(container, { renderOfficeScene(container, {
@ -1072,6 +1163,7 @@ async function boot() {
systems: data.systems, systems: data.systems,
events: data.events, events: data.events,
kpis: data.kpis, kpis: data.kpis,
isLive: true,
}); });
setApiBanner(false); setApiBanner(false);
} catch (err) { } catch (err) {

View file

@ -568,6 +568,71 @@ export function advanceDoorVisitStage(stage) {
return DOOR_VISIT_STAGES[index + 1]; 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 // Reload-Countdown im Header (Issue #12): rein kosmetischer Sekundenzaehler, der die
// Sync-Frequenz des Backends (MONITOR_SYNC_INTERVAL, siehe .env.example) im Header // Sync-Frequenz des Backends (MONITOR_SYNC_INTERVAL, siehe .env.example) im Header

View file

@ -33,6 +33,11 @@ import {
detectDoorVisits, detectDoorVisits,
resolveDoorVisitVisual, resolveDoorVisitVisual,
advanceDoorVisitStage, advanceDoorVisitStage,
PO_AGENT_ID,
PO_DISPLAY_NAME,
PO_VISIT_STAGES,
detectUnassignedTicketChanges,
advancePoVisitStage,
DEFAULT_RELOAD_INTERVAL_SECONDS, DEFAULT_RELOAD_INTERVAL_SECONDS,
resolveReloadIntervalSeconds, resolveReloadIntervalSeconds,
nextCountdownSeconds, nextCountdownSeconds,
@ -440,6 +445,76 @@ test("advanceDoorVisitStage liefert bei unbekannter Stage null", () => {
assert.equal(advanceDoorVisitStage("unbekannt"), 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)", () => { test("resolveReloadIntervalSeconds uebernimmt gueltige positive Zahlen (auch als String)", () => {
assert.equal(resolveReloadIntervalSeconds(30), 30); assert.equal(resolveReloadIntervalSeconds(30), 30);
assert.equal(resolveReloadIntervalSeconds("45"), 45); assert.equal(resolveReloadIntervalSeconds("45"), 45);