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];