diff --git a/html/js/office-render.js b/html/js/office-render.js index 47c08cd..0674439 100644 --- a/html/js/office-render.js +++ b/html/js/office-render.js @@ -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,27 +1934,61 @@ 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(); + + function renderLiveScene(data) { + const displayTickets = applyPendingTicketMoves(data.tickets, pendingTicketMoves); + renderOfficeScene(container, { + ticketCounts: aggregateTicketsByColumn(displayTickets), + tickets: displayTickets, + agents: data.agents, + systems: data.systems, + events: data.events, + kpis: data.kpis, + 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) { - detectDoorVisits(previousTickets, data.tickets).forEach(triggerDoorVisit); - if (detectUnassignedTicketChanges(previousTickets, data.tickets).length > 0) { - triggerPoVisit(); + 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; - renderOfficeScene(container, { - ticketCounts: aggregateTicketsByColumn(data.tickets), - tickets: data.tickets, - agents: data.agents, - systems: data.systems, - events: data.events, - kpis: data.kpis, - hooksCount: data.hooksCount, - isLive: true, - }); + renderLiveScene(data); setApiBanner(false); } catch (err) { console.warn("[office] Live-API nicht erreichbar, zeige Demo-Daten.", err); diff --git a/html/js/office-scene.js b/html/js/office-scene.js index 7aa9dca..d686766 100644 --- a/html/js/office-scene.js +++ b/html/js/office-scene.js @@ -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} latestTickets - aktuellster Stand aus der API. + * @param {Map} pendingMoves - ticketId -> alter Ticket-Stand (null = Ticket + * ist neu und wird bis zum Eintreffen der Persona ganz ausgeblendet). + * @returns {Array} 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. diff --git a/tests/frontend/office-scene.test.js b/tests/frontend/office-scene.test.js index aee67d8..431eb06 100644 --- a/tests/frontend/office-scene.test.js +++ b/tests/frontend/office-scene.test.js @@ -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);