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