Merge pull request 'Entwickler-Agent laeuft sichtbar zum Board bei in_arbeit-Wechseln' (#78) from fds2610-bot/monitor:main into main

Reviewed-on: https://codeberg.org/fds2610/monitor/pulls/78
This commit is contained in:
fds2610 2026-09-02 14:51:45 +02:00
commit f2fc2bc2e0
3 changed files with 167 additions and 17 deletions

View file

@ -47,6 +47,7 @@ import {
applyPendingTicketMoves, applyPendingTicketMoves,
detectAgentOwnedTicketChanges, detectAgentOwnedTicketChanges,
hasAgentReachedBoard, hasAgentReachedBoard,
isAgentReportingBoardVisit,
PO_AGENT_ID, PO_AGENT_ID,
PO_DISPLAY_NAME, PO_DISPLAY_NAME,
resolveReloadIntervalSeconds, resolveReloadIntervalSeconds,
@ -1056,12 +1057,88 @@ function updateDoorActiveClass() {
doorLeft.classList.toggle("door-active", anyAtDoor); 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() { function repaintAgentsLayer() {
if (!currentAgentsLayer) return; if (!currentAgentsLayer) return;
updateAgentsLayer(currentAgentsLayer, [ updateAgentsLayer(currentAgentsLayer, [
...currentRegularPlacements, ...currentRegularPlacements,
...poPlacement(), ...poPlacement(),
...doorVisitorPlacements(), ...doorVisitorPlacements(),
...agentVisitorPlacements(),
]); ]);
updateDoorActiveClass(); updateDoorActiveClass();
} }
@ -2157,6 +2234,7 @@ 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();
agentBoardVisitors.clear();
if (poVisitTimeoutId) window.clearTimeout(poVisitTimeoutId); if (poVisitTimeoutId) window.clearTimeout(poVisitTimeoutId);
poVisitTimeoutId = null; poVisitTimeoutId = null;
poState = "idle"; poState = "idle";
@ -2214,7 +2292,14 @@ export function renderOfficeScene(container, data = {}) {
svg.insertBefore(wallGroup, svg.querySelector(".agents-layer")); 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; 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 // 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, // 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")); svg.insertBefore(desksGroup, svg.querySelector(".agents-layer"));
} }
const placements = agentPlacements(agents, deskLayout); const placements = agentPlacements(agents, deskLayout, visitingAgentIds);
currentAgentsLayer = svg.querySelector(".agents-layer"); currentAgentsLayer = svg.querySelector(".agents-layer");
currentRegularPlacements = placements; currentRegularPlacements = placements;
repaintAgentsLayer(); repaintAgentsLayer();
@ -2249,7 +2334,11 @@ function normalizedDeskAgents(agents) {
return mapAgentsToDesks(agents).map((agent) => (agent ? normalizeAgentForDisplay(agent) : null)); 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"); const hasDeskIndex = agents.some((a) => a && typeof a.desk_index !== "undefined");
if (!hasDeskIndex) { if (!hasDeskIndex) {
@ -2273,6 +2362,7 @@ function agentPlacements(agents, deskLayout) {
let overflowIndex = 0; let overflowIndex = 0;
for (const raw of agents) { for (const raw of agents) {
if (seatedIds.has(raw.id)) continue; if (seatedIds.has(raw.id)) continue;
if (excludeIds.has(raw.id)) continue;
// isAgentVisible statt isAgentPresent, wie bei den Schreibtischen (mapAgentsToDesks): auch // isAgentVisible statt isAgentPresent, wie bei den Schreibtischen (mapAgentsToDesks): auch
// ein blockierter Agent ohne festen Platz muss sichtbar bleiben. // ein blockierter Agent ohne festen Platz muss sichtbar bleiben.
if (!isAgentVisible(raw)) continue; if (!isAgentVisible(raw)) continue;
@ -2364,9 +2454,12 @@ async function boot() {
// zugehoerige Persona (Tuerbesucher oder PO) das Board erreicht hat - vorher wird das Ticket // 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. // weiterhin an seiner alten Stelle (bzw. bei neuen Tickets: gar nicht) angezeigt.
const pendingTicketMoves = new Map(); const pendingTicketMoves = new Map();
// Issue #61: ticketId -> zustaendiger agentId fuer zurueckgehaltene "in_arbeit"-Wechsel (siehe // Issue #61/#76: ticketId -> zustaendiger agentId fuer zurueckgehaltene "in_arbeit"-Wechsel
// detectAgentOwnedTicketChanges() in office-scene.js). Anders als bei Tuer-/PO-Besuchen gibt es // (siehe detectAgentOwnedTicketChanges() in office-scene.js), fuer die ausnahmsweise KEIN
// dafuer keinen eigenen onArrived-Callback - der Agent meldet seine Ankunft am Board // 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 // unabhaengig ueber seinen eigenen state, daher wird bei jedem refresh() erneut geprueft
// (siehe resolvePendingAgentTicketMoves()). // (siehe resolvePendingAgentTicketMoves()).
const pendingAgentTicketMoves = new Map(); const pendingAgentTicketMoves = new Map();
@ -2433,12 +2526,39 @@ async function boot() {
triggerPoVisit(() => commitTicketMoves(ticketIds)); triggerPoVisit(() => commitTicketMoves(ticketIds));
} }
// Issue #61: Wechsel von/zu "in_arbeit" meldet der zustaendige Agent selbst ueber seinen // Issue #76: Wechsel von/zu "in_arbeit" (siehe AGENT_OWNED_COLUMN in office-scene.js)
// state (siehe AGENT_OWNED_COLUMN in office-scene.js) - das Ticket bleibt daher an seiner // loesen einen selbst getakteten Board-Besuch des zustaendigen Agenten aus, analog zum
// alten Stelle, bis resolvePendingAgentTicketMoves() unten die Ankunft am Board bestaetigt. // 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) => { detectAgentOwnedTicketChanges(previousTickets, data.tickets).forEach((change) => {
pendingTicketMoves.set(change.ticketId, previousById.get(change.ticketId) ?? null); 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); resolvePendingAgentTicketMoves(data.agents);

View file

@ -875,13 +875,16 @@ export function detectUnassignedTicketChanges(previousTickets = [], currentTicke
/** /**
* Issue #61: Gegenstueck zu detectUnassignedTicketChanges() fuer genau die dort ausgeschlossenen * 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 * Wechsel von/zu "in_arbeit" (siehe AGENT_OWNED_COLUMN). office-render.js loest fuer jeden hier
* diese Wechsel keine hier selbst getaktete Animation - der zustaendige Agent meldet seine * erkannten Wechsel einen sichtbaren Board-Besuch des zustaendigen Agenten aus, analog zu den
* Bewegung zum Board (state "walking_to_board"/"interacting_with_board") unabhaengig ueber seinen * Tuer-/PO-Besuchen (Issue #76: bis dahin blieb der Agent sichtbar an seinem Platz sitzen, weil
* eigenen state. office-render.js nutzt das Ergebnis, um das Ticket so lange an seiner alten * er die dafuer vorgesehenen states "walking_to_board"/"interacting_with_board" in der Praxis
* Stelle zu halten, bis hasAgentReachedBoard() fuer den zustaendigen Agenten zutrifft (siehe dort) * nicht meldete). Meldet der Agent seine Bewegung ausnahmsweise doch selbst (siehe
* - sonst wuerde das Ticket bereits die Spalte wechseln, waehrend der Entwickler sichtbar noch * isAgentReportingBoardVisit()), bleibt dieser selbst getaktete Besuch aus und die echte Meldung
* auf dem Weg zum Board ist. * 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}>} previousTickets
* @param {Array<{id: number, column_name: string, assignee_agent_id?: number|string|null}>} currentTickets * @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); 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 * 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 * Aenderung erkannt wurde - Tuerbesuch/PO-Besuch liefen davon unabhaengig erst danach als reine

View file

@ -52,6 +52,7 @@ import {
applyPendingTicketMoves, applyPendingTicketMoves,
detectAgentOwnedTicketChanges, detectAgentOwnedTicketChanges,
hasAgentReachedBoard, hasAgentReachedBoard,
isAgentReportingBoardVisit,
DEFAULT_RELOAD_INTERVAL_SECONDS, DEFAULT_RELOAD_INTERVAL_SECONDS,
resolveReloadIntervalSeconds, resolveReloadIntervalSeconds,
nextCountdownSeconds, nextCountdownSeconds,
@ -810,6 +811,15 @@ test("hasAgentReachedBoard ist waehrend walking_to_board false, sonst true (Issu
assert.equal(hasAgentReachedBoard(undefined), true); 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", () => { test("advancePoVisitStage durchlaeuft alle Stages und liefert am Ende null", () => {
let stage = PO_VISIT_STAGES[0]; let stage = PO_VISIT_STAGES[0];
const seen = [stage]; const seen = [stage];