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