diff --git a/html/js/office-render.js b/html/js/office-render.js index c32a0e3..b646034 100644 --- a/html/js/office-render.js +++ b/html/js/office-render.js @@ -45,6 +45,8 @@ import { advancePoVisitStage, PO_VISIT_STAGES, applyPendingTicketMoves, + detectAgentOwnedTicketChanges, + hasAgentReachedBoard, PO_AGENT_ID, PO_DISPLAY_NAME, resolveReloadIntervalSeconds, @@ -1946,6 +1948,30 @@ 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 + // unabhaengig ueber seinen eigenen state, daher wird bei jedem refresh() erneut geprueft + // (siehe resolvePendingAgentTicketMoves()). + const pendingAgentTicketMoves = new Map(); + + // Issue #61: entlaesst genau die zurueckgehaltenen "in_arbeit"-Wechsel, deren zustaendiger + // Agent laut aktuellstem Poll bereits am Board angekommen ist (oder nicht mehr feststellbar + // ist) - siehe hasAgentReachedBoard(). Laeuft bei jedem refresh(), nicht nur beim Erkennen der + // Aenderung, da die Ankunft typischerweise erst in einem spaeteren Poll sichtbar wird. + function resolvePendingAgentTicketMoves(agents) { + if (pendingAgentTicketMoves.size === 0) { + return; + } + const agentsById = new Map(agents.map((agent) => [agent.id, agent])); + for (const [ticketId, agentId] of pendingAgentTicketMoves) { + const agent = agentsById.get(agentId); + if (hasAgentReachedBoard(agent?.state)) { + pendingTicketMoves.delete(ticketId); + pendingAgentTicketMoves.delete(ticketId); + } + } + } function renderLiveScene(data) { const displayTickets = applyPendingTicketMoves(data.tickets, pendingTicketMoves); @@ -1990,7 +2016,16 @@ 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. + detectAgentOwnedTicketChanges(previousTickets, data.tickets).forEach((change) => { + pendingTicketMoves.set(change.ticketId, previousById.get(change.ticketId) ?? null); + pendingAgentTicketMoves.set(change.ticketId, change.agentId); + }); } + resolvePendingAgentTicketMoves(data.agents); previousTickets = data.tickets; renderLiveScene(data); setApiBanner(false); diff --git a/html/js/office-scene.js b/html/js/office-scene.js index ff531ee..e60e94c 100644 --- a/html/js/office-scene.js +++ b/html/js/office-scene.js @@ -853,6 +853,56 @@ export function detectUnassignedTicketChanges(previousTickets = [], currentTicke return changes; } +/** + * 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. + * + * @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 + * @returns {Array<{ticketId: number, agentId: number|string|null}>} + */ +export function detectAgentOwnedTicketChanges(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 (before.column_name !== AGENT_OWNED_COLUMN && ticket.column_name !== AGENT_OWNED_COLUMN) { + continue; + } + const agentId = ticket.assignee_agent_id ?? before.assignee_agent_id ?? null; + changes.push({ ticketId: ticket.id, agentId }); + } + + return changes; +} + +// Stages aus AGENT_STATE_VISUALS, waehrend derer der zustaendige Agent laut eigenem state +// nachweislich noch NICHT am Board angekommen ist (Issue #61). Jeder andere gemeldete state - +// insbesondere "interacting_with_board" und "walking_back", aber auch ein voellig unbekannter/ +// fehlender Agent - gilt als "Ankunft bereits erfolgt (oder nicht mehr feststellbar)", damit ein +// zurueckgehaltenes Ticket nicht dauerhaft an seiner alten Stelle haengen bleibt, nur weil eine +// Zwischen-Stage zwischen zwei Polls verpasst wurde. +const STATES_BEFORE_BOARD_ARRIVAL = new Set(["walking_to_board"]); + +/** + * @param {string|undefined|null} agentState - zuletzt gemeldeter Agent.state (API). + * @returns {boolean} true, sobald das zurueckgehaltene Ticket sichtbar an die neue Spalte + * wechseln darf (Issue #61). + */ +export function hasAgentReachedBoard(agentState) { + return !STATES_BEFORE_BOARD_ARRIVAL.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 8620fc8..b37e00a 100644 --- a/tests/frontend/office-scene.test.js +++ b/tests/frontend/office-scene.test.js @@ -49,6 +49,8 @@ import { detectUnassignedTicketChanges, advancePoVisitStage, applyPendingTicketMoves, + detectAgentOwnedTicketChanges, + hasAgentReachedBoard, DEFAULT_RELOAD_INTERVAL_SECONDS, resolveReloadIntervalSeconds, nextCountdownSeconds, @@ -770,6 +772,43 @@ test("detectUnassignedTicketChanges verarbeitet mehrere gleichzeitige Wechsel", assert.deepEqual(detectUnassignedTicketChanges(before, after), [{ ticketId: 1, column: "bereit" }]); }); +test("detectAgentOwnedTicketChanges erkennt Wechsel nach in_arbeit inkl. zustaendigem Agenten (Issue #61)", () => { + const before = [{ id: 1, column_name: "bereit", assignee_agent_id: null }]; + const after = [{ id: 1, column_name: "in_arbeit", assignee_agent_id: 7 }]; + assert.deepEqual(detectAgentOwnedTicketChanges(before, after), [{ ticketId: 1, agentId: 7 }]); +}); + +test("detectAgentOwnedTicketChanges erkennt Wechsel weg von in_arbeit und nutzt den vorherigen Agenten, falls das Ticket schon unassigned ist (Issue #61)", () => { + const before = [{ id: 1, column_name: "in_arbeit", assignee_agent_id: 7 }]; + const after = [{ id: 1, column_name: "testing", assignee_agent_id: null }]; + assert.deepEqual(detectAgentOwnedTicketChanges(before, after), [{ ticketId: 1, agentId: 7 }]); +}); + +test("detectAgentOwnedTicketChanges ignoriert Wechsel ohne in_arbeit-Beteiligung (Issue #61)", () => { + const before = [{ id: 1, column_name: "qa", assignee_agent_id: 7 }]; + const after = [{ id: 1, column_name: "fertig", assignee_agent_id: 7 }]; + assert.deepEqual(detectAgentOwnedTicketChanges(before, after), []); +}); + +test("detectAgentOwnedTicketChanges ignoriert Tickets ohne Spaltenwechsel und neu angelegte Tickets (Issue #61)", () => { + assert.deepEqual( + detectAgentOwnedTicketChanges( + [{ id: 1, column_name: "in_arbeit" }], + [{ id: 1, column_name: "in_arbeit" }], + ), + [], + ); + assert.deepEqual(detectAgentOwnedTicketChanges([], [{ id: 1, column_name: "in_arbeit" }]), []); +}); + +test("hasAgentReachedBoard ist waehrend walking_to_board false, sonst true (Issue #61)", () => { + assert.equal(hasAgentReachedBoard("walking_to_board"), false); + assert.equal(hasAgentReachedBoard("interacting_with_board"), true); + assert.equal(hasAgentReachedBoard("walking_back"), true); + assert.equal(hasAgentReachedBoard("idle"), true); + assert.equal(hasAgentReachedBoard(undefined), true); +}); + test("advancePoVisitStage durchlaeuft alle Stages und liefert am Ende null", () => { let stage = PO_VISIT_STAGES[0]; const seen = [stage];