From 2dbdd9ceac32d8119c77096f8943b3eb46d86439 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Wed, 2 Sep 2026 12:57:03 +0200 Subject: [PATCH] Entwickler-Agent laeuft jetzt sichtbar zum Board bei in_arbeit-Wechseln 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 --- html/js/office-render.js | 140 ++++++++++++++++++++++++++-- html/js/office-scene.js | 34 +++++-- tests/frontend/office-scene.test.js | 10 ++ 3 files changed, 167 insertions(+), 17 deletions(-) diff --git a/html/js/office-render.js b/html/js/office-render.js index 934beb6..6b5bd0e 100644 --- a/html/js/office-render.js +++ b/html/js/office-render.js @@ -47,6 +47,7 @@ import { applyPendingTicketMoves, detectAgentOwnedTicketChanges, hasAgentReachedBoard, + isAgentReportingBoardVisit, PO_AGENT_ID, PO_DISPLAY_NAME, resolveReloadIntervalSeconds, @@ -1056,12 +1057,88 @@ function updateDoorActiveClass() { doorLeft.classList.toggle("door-active", anyAtDoor); } +// --------------------------------------------------------------------------------------- +// Board-Besuch des zustaendigen Agenten bei "in_arbeit"-Wechseln (Issue #76): analog zum +// PO-Besuch (triggerPoVisit()/advancePoVisit()), aber pro Agent statt eines einzelnen globalen +// Zustands, weil mehrere Agenten gleichzeitig eigene Tickets verschieben koennen. Nutzt bewusst +// dieselben Stages/denselben Ablauf wie PO_VISIT_STAGES/advancePoVisitStage (siehe +// office-scene.js) - ein eigenes, inhaltsgleiches Stage-Array waere nur Duplikat. +// +// agentId -> {agent: {id, name, role}, homeFeet, stage, onArrived} +const agentBoardVisitors = new Map(); + +function agentVisitorPlacements() { + return Array.from(agentBoardVisitors.values()).map((visitor) => { + const visual = resolveAgentVisualState(visitor.stage); + return { + agent: { ...visitor.agent, pose: visual.pose, location: visual.location }, + homeFeet: visitor.homeFeet, + }; + }); +} + +function advanceAgentBoardVisit(agentId) { + const visitor = agentBoardVisitors.get(agentId); + if (!visitor) { + return; + } + + const nextStage = advancePoVisitStage(visitor.stage); + if (nextStage === null) { + agentBoardVisitors.delete(agentId); + repaintAgentsLayer(); + return; + } + visitor.stage = nextStage; + + // Wie beim PO-/Tuerbesuch (Issue #55): erst am Board wird die zurueckgehaltene Ticket- + // Aenderung sichtbar gemacht, danach wartet die Persona TICKET_PLACED_WAIT_MS, statt sofort + // weiterzulaufen. + if (visitor.stage === PO_VISIT_STAGES[1]) { + const onArrived = visitor.onArrived; + visitor.onArrived = null; + onArrived?.(); + repaintAgentsLayer(); + window.setTimeout(() => advanceAgentBoardVisit(agentId), TICKET_PLACED_WAIT_MS); + return; + } + + repaintAgentsLayer(); + window.setTimeout(() => advanceAgentBoardVisit(agentId), DOOR_VISIT_STAGE_DURATION_MS); +} + +/** + * Stoesst den sichtbaren Board-Besuch eines Entwickler-Agenten an (Issue #76). Laeuft bereits + * einer fuer denselben Agenten (z.B. mehrere Tickets wechseln kurz hintereinander), wird kein + * zweiter Besuch gestartet - onArrived wird sofort nachgeholt, damit die zurueckgehaltene(n) + * Ticket-Aenderung(en) nicht dauerhaft verborgen bleiben. + * + * @param {number|string} agentId + * @param {{id: number|string, name: string, role: string}} agentInfo + * @param {{x: number, y: number}} homeFeet - der eigene Schreibtisch des Agenten. + * @param {() => void} [onArrived] - wird aufgerufen, sobald der Agent am Board ankommt. + */ +function triggerAgentBoardVisit(agentId, agentInfo, homeFeet, onArrived) { + if (agentBoardVisitors.has(agentId)) { + onArrived?.(); + return; + } + // Ein bereits laufender idle-Kosmetik-Ausflug (Kaffee/Zeitung/Schwaetzchen, siehe + // scheduleIdleCosmetic()) wuerde sonst mitten im Board-Besuch die Pose/Position ueberschreiben. + clearIdleCosmeticTimer(String(agentId)); + + agentBoardVisitors.set(agentId, { agent: agentInfo, homeFeet, stage: PO_VISIT_STAGES[0], onArrived }); + repaintAgentsLayer(); + window.setTimeout(() => advanceAgentBoardVisit(agentId), DOOR_VISIT_STAGE_DURATION_MS); +} + function repaintAgentsLayer() { if (!currentAgentsLayer) return; updateAgentsLayer(currentAgentsLayer, [ ...currentRegularPlacements, ...poPlacement(), ...doorVisitorPlacements(), + ...agentVisitorPlacements(), ]); updateDoorActiveClass(); } @@ -2157,6 +2234,7 @@ export function renderOfficeScene(container, data = {}) { idleCosmeticTimers.forEach((handle) => window.clearTimeout(handle.timeoutId)); idleCosmeticTimers.clear(); doorVisitors.clear(); + agentBoardVisitors.clear(); if (poVisitTimeoutId) window.clearTimeout(poVisitTimeoutId); poVisitTimeoutId = null; poState = "idle"; @@ -2214,7 +2292,14 @@ export function renderOfficeScene(container, data = {}) { svg.insertBefore(wallGroup, svg.querySelector(".agents-layer")); } - const deskLayout = buildDeskLayout(normalizedDeskAgents(agents)); + // Issue #76: waehrend ein Agent sichtbar zum Board unterwegs ist (siehe agentBoardVisitors + // oben), soll sein Schreibtisch leer wirken statt weiterhin besetzt - genau wie bei einem + // Tuer-/PO-Besuch verlaesst die Person sichtbar ihren Platz. + const visitingAgentIds = new Set(agentBoardVisitors.keys()); + const deskAgents = normalizedDeskAgents(agents).map((agent) => + agent && visitingAgentIds.has(agent.id) ? null : agent + ); + const deskLayout = buildDeskLayout(deskAgents); const poDeskAgent = isLive ? { name: PO_DISPLAY_NAME, role: "product_owner", pose: "idle" } : null; // Auch ohne erreichbare API klickbar: das Modal sagt dann selbst, dass es die Projekte // nicht laden konnte. Ein toter PO waere in genau der Lage, in der man nachsehen will, @@ -2227,7 +2312,7 @@ export function renderOfficeScene(container, data = {}) { svg.insertBefore(desksGroup, svg.querySelector(".agents-layer")); } - const placements = agentPlacements(agents, deskLayout); + const placements = agentPlacements(agents, deskLayout, visitingAgentIds); currentAgentsLayer = svg.querySelector(".agents-layer"); currentRegularPlacements = placements; repaintAgentsLayer(); @@ -2249,7 +2334,11 @@ function normalizedDeskAgents(agents) { return mapAgentsToDesks(agents).map((agent) => (agent ? normalizeAgentForDisplay(agent) : null)); } -function agentPlacements(agents, deskLayout) { +// excludeIds (Issue #76): Agenten, die gerade sichtbar zum Board unterwegs sind (siehe +// agentBoardVisitors) - agentVisitorPlacements() steuert ihre Platzierung, sie duerfen hier +// nicht zusaetzlich (z.B. als Ruheplatz-Ueberlauf mit ihrem regulaeren state) auftauchen, sonst +// gaebe es denselben Agenten doppelt in der Szene. +function agentPlacements(agents, deskLayout, excludeIds = new Set()) { const hasDeskIndex = agents.some((a) => a && typeof a.desk_index !== "undefined"); if (!hasDeskIndex) { @@ -2273,6 +2362,7 @@ function agentPlacements(agents, deskLayout) { let overflowIndex = 0; for (const raw of agents) { if (seatedIds.has(raw.id)) continue; + if (excludeIds.has(raw.id)) continue; // isAgentVisible statt isAgentPresent, wie bei den Schreibtischen (mapAgentsToDesks): auch // ein blockierter Agent ohne festen Platz muss sichtbar bleiben. if (!isAgentVisible(raw)) continue; @@ -2364,9 +2454,12 @@ async function boot() { // zugehoerige Persona (Tuerbesucher oder PO) das Board erreicht hat - vorher wird das Ticket // weiterhin an seiner alten Stelle (bzw. bei neuen Tickets: gar nicht) angezeigt. const pendingTicketMoves = new Map(); - // Issue #61: ticketId -> zustaendiger agentId fuer zurueckgehaltene "in_arbeit"-Wechsel (siehe - // detectAgentOwnedTicketChanges() in office-scene.js). Anders als bei Tuer-/PO-Besuchen gibt es - // dafuer keinen eigenen onArrived-Callback - der Agent meldet seine Ankunft am Board + // Issue #61/#76: ticketId -> zustaendiger agentId fuer zurueckgehaltene "in_arbeit"-Wechsel + // (siehe detectAgentOwnedTicketChanges() in office-scene.js), fuer die ausnahmsweise KEIN + // selbst getakteter Board-Besuch laeuft (triggerAgentBoardVisit() unten deckt den Regelfall + // ab) - entweder weil der Agent seine Bewegung bereits selbst meldet + // (isAgentReportingBoardVisit()) oder weil kein Schreibtisch fuer eine Animation bekannt ist. + // Dafuer gibt es keinen eigenen onArrived-Callback - der Agent meldet seine Ankunft am Board // unabhaengig ueber seinen eigenen state, daher wird bei jedem refresh() erneut geprueft // (siehe resolvePendingAgentTicketMoves()). const pendingAgentTicketMoves = new Map(); @@ -2433,12 +2526,39 @@ async function boot() { triggerPoVisit(() => commitTicketMoves(ticketIds)); } - // Issue #61: Wechsel von/zu "in_arbeit" meldet der zustaendige Agent selbst ueber seinen - // state (siehe AGENT_OWNED_COLUMN in office-scene.js) - das Ticket bleibt daher an seiner - // alten Stelle, bis resolvePendingAgentTicketMoves() unten die Ankunft am Board bestaetigt. + // Issue #76: Wechsel von/zu "in_arbeit" (siehe AGENT_OWNED_COLUMN in office-scene.js) + // loesen einen selbst getakteten Board-Besuch des zustaendigen Agenten aus, analog zum + // PO-/Tuerbesuch - der Agent meldete diese Bewegung in der Praxis so gut wie nie selbst + // (Issue #61 gescheitert). Mehrere Aenderungen desselben Agenten im selben Poll teilen + // sich einen Besuch statt mehrfach hin- und herzulaufen. + const agentsById = new Map(data.agents.map((agent) => [agent.id, agent])); + const agentVisitPlans = new Map(); detectAgentOwnedTicketChanges(previousTickets, data.tickets).forEach((change) => { pendingTicketMoves.set(change.ticketId, previousById.get(change.ticketId) ?? null); - pendingAgentTicketMoves.set(change.ticketId, change.agentId); + + const agent = agentsById.get(change.agentId); + const homeFeet = agent && typeof agent.desk_index === "number" ? deskSeat(agent.desk_index).feet : null; + + if (!homeFeet || isAgentReportingBoardVisit(agent.state)) { + // Kein bekannter Schreibtisch (z.B. Agent nicht mehr registriert), oder der Agent + // meldet seine Bewegung zum Board bereits selbst (Loesungsrichtung 1 aus Issue #76) + // - dann bleibt der selbst getaktete Besuch unten aus, die echte Meldung (bzw. die + // bestehende Ankunfts-Erkennung ueber hasAgentReachedBoard()) hat Vorrang. + pendingAgentTicketMoves.set(change.ticketId, change.agentId); + return; + } + + const plan = agentVisitPlans.get(change.agentId) ?? { + agentInfo: { id: agent.id, name: agent.display_name ?? agent.name, role: agent.role }, + homeFeet, + ticketIds: [], + }; + plan.ticketIds.push(change.ticketId); + agentVisitPlans.set(change.agentId, plan); + }); + + agentVisitPlans.forEach(({ agentInfo, homeFeet, ticketIds }, agentId) => { + triggerAgentBoardVisit(agentId, agentInfo, homeFeet, () => commitTicketMoves(ticketIds)); }); } resolvePendingAgentTicketMoves(data.agents); diff --git a/html/js/office-scene.js b/html/js/office-scene.js index 457299b..6a3d0a4 100644 --- a/html/js/office-scene.js +++ b/html/js/office-scene.js @@ -875,13 +875,16 @@ export function detectUnassignedTicketChanges(previousTickets = [], currentTicke /** * Issue #61: Gegenstueck zu detectUnassignedTicketChanges() fuer genau die dort ausgeschlossenen - * Wechsel von/zu "in_arbeit" (siehe AGENT_OWNED_COLUMN). Anders als Tuer-/PO-Besuche gibt es fuer - * diese Wechsel keine hier selbst getaktete Animation - der zustaendige Agent meldet seine - * Bewegung zum Board (state "walking_to_board"/"interacting_with_board") unabhaengig ueber seinen - * eigenen state. office-render.js nutzt das Ergebnis, 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. + * 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 @@ -923,6 +926,23 @@ 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 diff --git a/tests/frontend/office-scene.test.js b/tests/frontend/office-scene.test.js index 208d792..3670383 100644 --- a/tests/frontend/office-scene.test.js +++ b/tests/frontend/office-scene.test.js @@ -52,6 +52,7 @@ import { applyPendingTicketMoves, detectAgentOwnedTicketChanges, hasAgentReachedBoard, + isAgentReportingBoardVisit, DEFAULT_RELOAD_INTERVAL_SECONDS, resolveReloadIntervalSeconds, nextCountdownSeconds, @@ -810,6 +811,15 @@ test("hasAgentReachedBoard ist waehrend walking_to_board false, sonst true (Issu assert.equal(hasAgentReachedBoard(undefined), true); }); +test("isAgentReportingBoardVisit erkennt eigene Meldung des Agenten (Issue #76)", () => { + assert.equal(isAgentReportingBoardVisit("walking_to_board"), true); + assert.equal(isAgentReportingBoardVisit("interacting_with_board"), true); + assert.equal(isAgentReportingBoardVisit("walking_back"), false); + assert.equal(isAgentReportingBoardVisit("idle"), false); + assert.equal(isAgentReportingBoardVisit("working"), false); + assert.equal(isAgentReportingBoardVisit(undefined), false); +}); + test("advancePoVisitStage durchlaeuft alle Stages und liefert am Ende null", () => { let stage = PO_VISIT_STAGES[0]; const seen = [stage];