Animationsreihenfolge bei Ticket-Statuswechseln umkehren (Issue #55)

Bisher wurde ein Ticket sofort an seiner neuen Spalte gezeichnet, sobald ein
Statuswechsel erkannt wurde - der Tuerbesuch (Rolle "user") bzw. PO-Besuch
liefen davon unabhaengig erst danach als reine Kosmetik ab. Jetzt haelt
office-render.js das betroffene Ticket an seiner alten Stelle (neue Tickets
bleiben ganz verborgen), bis die zugehoerige Persona am Board angekommen ist
("interacting_with_board"), verschiebt es dann sichtbar und laesst die
Persona danach eine Sekunde stehen, bevor sie den Rueckweg antritt.

applyPendingTicketMoves() in office-scene.js kapselt die dafuer noetige
Merge-Logik pur und testbar; office-render.js reicht die passenden
onArrived-Callbacks an triggerDoorVisit()/triggerPoVisit() durch.
This commit is contained in:
fds2610-bot 2026-08-20 16:50:47 +02:00
parent 69bf57da71
commit 1331d05d7b
3 changed files with 157 additions and 17 deletions

View file

@ -44,6 +44,7 @@ import {
detectUnassignedTicketChanges,
advancePoVisitStage,
PO_VISIT_STAGES,
applyPendingTicketMoves,
PO_AGENT_ID,
PO_DISPLAY_NAME,
resolveReloadIntervalSeconds,
@ -120,6 +121,10 @@ const IDLE_MIN_DELAY_MS = 15000;
const IDLE_MAX_DELAY_MS = 40000;
const IDLE_ACTIVITY_DURATION_MS = 8000;
const DOOR_VISIT_STAGE_DURATION_MS = 2200;
// Issue #55: sobald die Persona am Board angekommen ist ("interacting_with_board") und das
// Ticket an seine neue Stelle gewechselt hat, wartet sie genau diese Spanne, bevor sie
// (Tuerbesucher: "leaving", PO: "walking_back") wieder losgeht.
const TICKET_PLACED_WAIT_MS = 1000;
function el(tag, attrs = {}, children = []) {
const node = document.createElementNS(SVG_NS, tag);
@ -995,6 +1000,9 @@ function repaintAgentsLayer() {
let poIsLive = false;
let poState = "idle";
let poVisitTimeoutId = null;
// Issue #55: wird aufgerufen, sobald der laufende Besuch das Board erreicht - das ist der
// richtige Zeitpunkt, um die bis dahin zurueckgehaltenen Ticket-Aenderungen sichtbar zu machen.
let poOnArrived = null;
function poPlacement() {
if (!poIsLive) {
@ -1017,6 +1025,19 @@ function advancePoVisit() {
return;
}
poState = nextStage;
// Issue #55: erst wenn der PO am Board angekommen ist, wird das/die zurueckgehaltene(n)
// Ticket(s) an ihre neue Stelle bewegt - danach wartet er TICKET_PLACED_WAIT_MS, bevor er
// wieder losgeht, statt sofort weiterzulaufen.
if (poState === PO_VISIT_STAGES[1]) {
const onArrived = poOnArrived;
poOnArrived = null;
onArrived?.();
repaintAgentsLayer();
poVisitTimeoutId = window.setTimeout(advancePoVisit, TICKET_PLACED_WAIT_MS);
return;
}
repaintAgentsLayer();
poVisitTimeoutId = window.setTimeout(advancePoVisit, DOOR_VISIT_STAGE_DURATION_MS);
}
@ -1025,11 +1046,17 @@ function advancePoVisit() {
// hintereinander das Label "automate") loesen bewusst nur einen Besuch aus, statt den PO fuer
// jede einzelne Aenderung hin- und herlaufen zu lassen - ein laufender Besuch wird zu Ende
// animiert, statt unterbrochen zu werden.
function triggerPoVisit() {
//
// @param {() => void} [onArrived] - wird aufgerufen, sobald der PO am Board ankommt (Issue #55).
// Laeuft bereits ein Besuch, gibt es dafuer keine eigene Animation mehr - onArrived wird dann
// sofort aufgerufen, damit die zurueckgehaltene Aenderung nicht dauerhaft verborgen bleibt.
function triggerPoVisit(onArrived) {
if (!poIsLive || poState !== "idle") {
onArrived?.();
return;
}
poState = PO_VISIT_STAGES[0];
poOnArrived = onArrived;
repaintAgentsLayer();
poVisitTimeoutId = window.setTimeout(advancePoVisit, DOOR_VISIT_STAGE_DURATION_MS);
}
@ -1125,15 +1152,31 @@ function advanceDoorVisit(id) {
}
visitor.stage = nextStage;
// Issue #55: erst am Board ("interacting_with_board") wird das zurueckgehaltene Ticket an
// seine neue Stelle bewegt - danach wartet die Persona TICKET_PLACED_WAIT_MS, bevor sie
// ("leaving") wieder zur Tuer geht, statt sofort weiterzulaufen.
if (visitor.stage === DOOR_VISIT_STAGES[1]) {
visitor.onArrived?.();
visitor.onArrived = null;
repaintAgentsLayer();
window.setTimeout(() => advanceDoorVisit(id), TICKET_PLACED_WAIT_MS);
return;
}
repaintAgentsLayer();
window.setTimeout(() => advanceDoorVisit(id), DOOR_VISIT_STAGE_DURATION_MS);
}
/** @param {{type: string, ticketId: number}} visit */
function triggerDoorVisit(visit) {
/**
* @param {{type: string, ticketId: number}} visit
* @param {() => void} [onArrived] - wird aufgerufen, sobald diese Persona am Board ankommt
* (Issue #55) - der richtige Zeitpunkt, um das zurueckgehaltene Ticket sichtbar zu verschieben.
*/
function triggerDoorVisit(visit, onArrived) {
doorVisitCounter += 1;
const id = `door-visit-${visit.type}-${visit.ticketId}-${doorVisitCounter}`;
doorVisitors.set(id, { id, stage: DOOR_VISIT_STAGES[0], atDoor: true });
doorVisitors.set(id, { id, stage: DOOR_VISIT_STAGES[0], atDoor: true, onArrived });
repaintAgentsLayer();
window.setTimeout(() => advanceDoorVisit(id), DOOR_VISIT_STAGE_DURATION_MS);
}
@ -1891,20 +1934,20 @@ async function boot() {
// Demo-Fallback (kein "echter" Anlass ohne Live-API) und wird nur bei erreichbarer API
// gepflegt, damit die Tuerbesuche ausschliesslich auf echten Zustandswechseln beruhen.
let previousTickets = null;
// Issue #55: letzter vollstaendige API-Stand, damit commitTicketMoves() die Szene ohne neuen
// Fetch neu zeichnen kann, sobald eine Persona am Board ankommt.
let lastLiveData = null;
// ticketId -> alter Ticket-Stand (null bei einem brandneuen Ticket, siehe
// applyPendingTicketMoves() in office-scene.js). Ein Eintrag bleibt bestehen, bis die
// 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();
async function refresh() {
try {
const data = await loadLiveData();
if (previousTickets) {
detectDoorVisits(previousTickets, data.tickets).forEach(triggerDoorVisit);
if (detectUnassignedTicketChanges(previousTickets, data.tickets).length > 0) {
triggerPoVisit();
}
}
previousTickets = data.tickets;
function renderLiveScene(data) {
const displayTickets = applyPendingTicketMoves(data.tickets, pendingTicketMoves);
renderOfficeScene(container, {
ticketCounts: aggregateTicketsByColumn(data.tickets),
tickets: data.tickets,
ticketCounts: aggregateTicketsByColumn(displayTickets),
tickets: displayTickets,
agents: data.agents,
systems: data.systems,
events: data.events,
@ -1912,6 +1955,40 @@ async function boot() {
hooksCount: data.hooksCount,
isLive: true,
});
}
// Wird als onArrived-Callback an triggerDoorVisit()/triggerPoVisit() gereicht: macht die bis
// dahin zurueckgehaltene(n) Ticket-Aenderung(en) sichtbar, sobald die Persona am Board steht.
function commitTicketMoves(ticketIds) {
ticketIds.forEach((id) => pendingTicketMoves.delete(id));
if (lastLiveData) {
renderLiveScene(lastLiveData);
}
}
async function refresh() {
try {
const data = await loadLiveData();
lastLiveData = data;
if (previousTickets) {
const previousById = new Map(previousTickets.map((ticket) => [ticket.id, ticket]));
detectDoorVisits(previousTickets, data.tickets).forEach((visit) => {
pendingTicketMoves.set(visit.ticketId, previousById.get(visit.ticketId) ?? null);
triggerDoorVisit(visit, () => commitTicketMoves([visit.ticketId]));
});
const unassignedChanges = detectUnassignedTicketChanges(previousTickets, data.tickets);
if (unassignedChanges.length > 0) {
const ticketIds = unassignedChanges.map((change) => change.ticketId);
ticketIds.forEach((ticketId) => {
pendingTicketMoves.set(ticketId, previousById.get(ticketId) ?? null);
});
triggerPoVisit(() => commitTicketMoves(ticketIds));
}
}
previousTickets = data.tickets;
renderLiveScene(data);
setApiBanner(false);
} catch (err) {
console.warn("[office] Live-API nicht erreichbar, zeige Demo-Daten.", err);

View file

@ -826,6 +826,42 @@ export function detectUnassignedTicketChanges(previousTickets = [], currentTicke
return changes;
}
/**
* 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
* Kosmetik ab, wirkten also "hinterher". Richtig ist es andersherum: die Persona soll zuerst am
* Board ankommen, bevor sich das Ticket sichtbar an die neue Stelle bewegt.
*
* office-render.js haelt dafuer pro Ticket, dessen Persona noch unterwegs ist, den alten Stand
* in einer Map (ticketId -> alter Ticket-Stand, oder null bei einem brandneuen Ticket) und
* ersetzt damit den neuen Stand aus der API-Antwort, bis die Persona am Board angekommen ist
* (siehe DOOR_VISIT_STAGES/PO_VISIT_STAGES, Stage "interacting_with_board"). Reine Merge-Logik
* ohne DOM-/Zeit-Abhaengigkeit, daher hier statt in office-render.js (siehe Teststrategie in
* Visualization.md Abschnitt 8).
*
* @param {Array<object>} latestTickets - aktuellster Stand aus der API.
* @param {Map<number, object|null>} pendingMoves - ticketId -> alter Ticket-Stand (null = Ticket
* ist neu und wird bis zum Eintreffen der Persona ganz ausgeblendet).
* @returns {Array<object>} zur Anzeige bestimmte Ticketliste.
*/
export function applyPendingTicketMoves(latestTickets = [], pendingMoves) {
if (!pendingMoves || pendingMoves.size === 0) {
return latestTickets;
}
const result = [];
for (const ticket of latestTickets) {
if (!pendingMoves.has(ticket.id)) {
result.push(ticket);
continue;
}
const oldTicket = pendingMoves.get(ticket.id);
if (oldTicket) {
result.push(oldTicket);
}
}
return result;
}
// Ablauf eines PO-Board-Besuchs: vom festen Platz zum Board laufen, dort kurz interagieren,
// zurueck zum Platz. Nutzt bewusst dieselben state-Keys wie die Agenten-Zustandsmaschine
// (resolveAgentVisualState), damit keine zweite Ort/Pose-Zuordnung noetig ist.

View file

@ -47,6 +47,7 @@ import {
PO_VISIT_STAGES,
detectUnassignedTicketChanges,
advancePoVisitStage,
applyPendingTicketMoves,
DEFAULT_RELOAD_INTERVAL_SECONDS,
resolveReloadIntervalSeconds,
nextCountdownSeconds,
@ -763,6 +764,32 @@ test("advancePoVisitStage liefert bei unbekannter Stage null", () => {
assert.equal(advancePoVisitStage("unbekannt"), null);
});
test("applyPendingTicketMoves gibt die Liste unveraendert zurueck ohne laufende Besuche (Issue #55)", () => {
const tickets = [{ id: 1, column_name: "bereit" }];
assert.equal(applyPendingTicketMoves(tickets, new Map()), tickets);
assert.equal(applyPendingTicketMoves(tickets, null), tickets);
});
test("applyPendingTicketMoves zeigt ein Ticket mit laufendem Besuch weiter am alten Platz (Issue #55)", () => {
const oldTicket = { id: 1, column_name: "bereit" };
const latest = [{ id: 1, column_name: "fertig" }, { id: 2, column_name: "qa" }];
const pending = new Map([[1, oldTicket]]);
assert.deepEqual(applyPendingTicketMoves(latest, pending), [oldTicket, { id: 2, column_name: "qa" }]);
});
test("applyPendingTicketMoves blendet ein brandneues Ticket aus, bis die Persona am Board ist (Issue #55)", () => {
const latest = [{ id: 1, column_name: "backlog" }];
const pending = new Map([[1, null]]);
assert.deepEqual(applyPendingTicketMoves(latest, pending), []);
});
test("applyPendingTicketMoves laesst Tickets ohne laufenden Besuch unangetastet, auch wenn andere pending sind (Issue #55)", () => {
const latest = [{ id: 1, column_name: "fertig" }, { id: 2, column_name: "qa" }];
const pending = new Map([[1, { id: 1, column_name: "bereit" }]]);
const result = applyPendingTicketMoves(latest, pending);
assert.deepEqual(result[1], { id: 2, column_name: "qa" });
});
test("resolveReloadIntervalSeconds uebernimmt gueltige positive Zahlen (auch als String)", () => {
assert.equal(resolveReloadIntervalSeconds(30), 30);
assert.equal(resolveReloadIntervalSeconds("45"), 45);