Ticket bleibt bei WIP-Wechseln bis zur Board-Ankunft des Entwicklers zurueckgehalten (Issue #61)

Bislang wurde ein Ticket beim Wechsel von/zu "in_arbeit" sofort an seiner neuen
Spalte gezeichnet, unabhaengig davon, ob der zustaendige Entwickler laut eigenem
gemeldetem state ("walking_to_board") ueberhaupt schon am Board angekommen war -
die Animation wirkte dadurch unvollstaendig/vorgezogen. Analog zu der fuer
Tuer-/PO-Besuche in Issue #55 eingefuehrten Merge-Logik haelt office-render.js
das Ticket nun an seiner alten Stelle, bis der zustaendige Agent laut aktuellstem
Poll das Board erreicht hat (oder nicht mehr feststellbar ist).

detectAgentOwnedTicketChanges() und hasAgentReachedBoard() in office-scene.js
kapseln die dafuer noetige Erkennungs-/Entscheidungslogik pur und testbar.
This commit is contained in:
fds2610-bot 2026-08-23 14:25:06 +02:00
parent 32f3a3d30c
commit 90e8a61962
3 changed files with 124 additions and 0 deletions

View file

@ -45,6 +45,8 @@ import {
advancePoVisitStage, advancePoVisitStage,
PO_VISIT_STAGES, PO_VISIT_STAGES,
applyPendingTicketMoves, applyPendingTicketMoves,
detectAgentOwnedTicketChanges,
hasAgentReachedBoard,
PO_AGENT_ID, PO_AGENT_ID,
PO_DISPLAY_NAME, PO_DISPLAY_NAME,
resolveReloadIntervalSeconds, resolveReloadIntervalSeconds,
@ -1946,6 +1948,30 @@ 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
// 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) { function renderLiveScene(data) {
const displayTickets = applyPendingTicketMoves(data.tickets, pendingTicketMoves); const displayTickets = applyPendingTicketMoves(data.tickets, pendingTicketMoves);
@ -1990,7 +2016,16 @@ async function boot() {
}); });
triggerPoVisit(() => commitTicketMoves(ticketIds)); 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; previousTickets = data.tickets;
renderLiveScene(data); renderLiveScene(data);
setApiBanner(false); setApiBanner(false);

View file

@ -853,6 +853,56 @@ export function detectUnassignedTicketChanges(previousTickets = [], currentTicke
return changes; 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 * 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

@ -49,6 +49,8 @@ import {
detectUnassignedTicketChanges, detectUnassignedTicketChanges,
advancePoVisitStage, advancePoVisitStage,
applyPendingTicketMoves, applyPendingTicketMoves,
detectAgentOwnedTicketChanges,
hasAgentReachedBoard,
DEFAULT_RELOAD_INTERVAL_SECONDS, DEFAULT_RELOAD_INTERVAL_SECONDS,
resolveReloadIntervalSeconds, resolveReloadIntervalSeconds,
nextCountdownSeconds, nextCountdownSeconds,
@ -770,6 +772,43 @@ test("detectUnassignedTicketChanges verarbeitet mehrere gleichzeitige Wechsel",
assert.deepEqual(detectUnassignedTicketChanges(before, after), [{ ticketId: 1, column: "bereit" }]); 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", () => { 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];