Merge pull request 'Animationsreihenfolge bei Ticket-Statuswechseln umkehren (Issue #55)' (#56) from fds2610-bot/monitor:main into main

Reviewed-on: https://codeberg.org/fds2610/monitor/pulls/56
This commit is contained in:
fds2610 2026-08-20 18:28:34 +02:00
commit 0228ee4ae6
3 changed files with 157 additions and 17 deletions

View file

@ -44,6 +44,7 @@ import {
detectUnassignedTicketChanges, detectUnassignedTicketChanges,
advancePoVisitStage, advancePoVisitStage,
PO_VISIT_STAGES, PO_VISIT_STAGES,
applyPendingTicketMoves,
PO_AGENT_ID, PO_AGENT_ID,
PO_DISPLAY_NAME, PO_DISPLAY_NAME,
resolveReloadIntervalSeconds, resolveReloadIntervalSeconds,
@ -120,6 +121,10 @@ const IDLE_MIN_DELAY_MS = 15000;
const IDLE_MAX_DELAY_MS = 40000; const IDLE_MAX_DELAY_MS = 40000;
const IDLE_ACTIVITY_DURATION_MS = 8000; const IDLE_ACTIVITY_DURATION_MS = 8000;
const DOOR_VISIT_STAGE_DURATION_MS = 2200; 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 = []) { function el(tag, attrs = {}, children = []) {
const node = document.createElementNS(SVG_NS, tag); const node = document.createElementNS(SVG_NS, tag);
@ -995,6 +1000,9 @@ function repaintAgentsLayer() {
let poIsLive = false; let poIsLive = false;
let poState = "idle"; let poState = "idle";
let poVisitTimeoutId = null; 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() { function poPlacement() {
if (!poIsLive) { if (!poIsLive) {
@ -1017,6 +1025,19 @@ function advancePoVisit() {
return; return;
} }
poState = nextStage; 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(); repaintAgentsLayer();
poVisitTimeoutId = window.setTimeout(advancePoVisit, DOOR_VISIT_STAGE_DURATION_MS); 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 // 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 // jede einzelne Aenderung hin- und herlaufen zu lassen - ein laufender Besuch wird zu Ende
// animiert, statt unterbrochen zu werden. // 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") { if (!poIsLive || poState !== "idle") {
onArrived?.();
return; return;
} }
poState = PO_VISIT_STAGES[0]; poState = PO_VISIT_STAGES[0];
poOnArrived = onArrived;
repaintAgentsLayer(); repaintAgentsLayer();
poVisitTimeoutId = window.setTimeout(advancePoVisit, DOOR_VISIT_STAGE_DURATION_MS); poVisitTimeoutId = window.setTimeout(advancePoVisit, DOOR_VISIT_STAGE_DURATION_MS);
} }
@ -1125,15 +1152,31 @@ function advanceDoorVisit(id) {
} }
visitor.stage = nextStage; 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(); repaintAgentsLayer();
window.setTimeout(() => advanceDoorVisit(id), DOOR_VISIT_STAGE_DURATION_MS); 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; doorVisitCounter += 1;
const id = `door-visit-${visit.type}-${visit.ticketId}-${doorVisitCounter}`; 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(); repaintAgentsLayer();
window.setTimeout(() => advanceDoorVisit(id), DOOR_VISIT_STAGE_DURATION_MS); 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 // Demo-Fallback (kein "echter" Anlass ohne Live-API) und wird nur bei erreichbarer API
// gepflegt, damit die Tuerbesuche ausschliesslich auf echten Zustandswechseln beruhen. // gepflegt, damit die Tuerbesuche ausschliesslich auf echten Zustandswechseln beruhen.
let previousTickets = null; 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() { async function refresh() {
try { try {
const data = await loadLiveData(); const data = await loadLiveData();
lastLiveData = data;
if (previousTickets) { if (previousTickets) {
detectDoorVisits(previousTickets, data.tickets).forEach(triggerDoorVisit); const previousById = new Map(previousTickets.map((ticket) => [ticket.id, ticket]));
if (detectUnassignedTicketChanges(previousTickets, data.tickets).length > 0) {
triggerPoVisit(); 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; previousTickets = data.tickets;
renderOfficeScene(container, { renderLiveScene(data);
ticketCounts: aggregateTicketsByColumn(data.tickets),
tickets: data.tickets,
agents: data.agents,
systems: data.systems,
events: data.events,
kpis: data.kpis,
hooksCount: data.hooksCount,
isLive: true,
});
setApiBanner(false); setApiBanner(false);
} catch (err) { } catch (err) {
console.warn("[office] Live-API nicht erreichbar, zeige Demo-Daten.", 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; 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, // 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 // zurueck zum Platz. Nutzt bewusst dieselben state-Keys wie die Agenten-Zustandsmaschine
// (resolveAgentVisualState), damit keine zweite Ort/Pose-Zuordnung noetig ist. // (resolveAgentVisualState), damit keine zweite Ort/Pose-Zuordnung noetig ist.

View file

@ -47,6 +47,7 @@ import {
PO_VISIT_STAGES, PO_VISIT_STAGES,
detectUnassignedTicketChanges, detectUnassignedTicketChanges,
advancePoVisitStage, advancePoVisitStage,
applyPendingTicketMoves,
DEFAULT_RELOAD_INTERVAL_SECONDS, DEFAULT_RELOAD_INTERVAL_SECONDS,
resolveReloadIntervalSeconds, resolveReloadIntervalSeconds,
nextCountdownSeconds, nextCountdownSeconds,
@ -763,6 +764,32 @@ test("advancePoVisitStage liefert bei unbekannter Stage null", () => {
assert.equal(advancePoVisitStage("unbekannt"), 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)", () => { test("resolveReloadIntervalSeconds uebernimmt gueltige positive Zahlen (auch als String)", () => {
assert.equal(resolveReloadIntervalSeconds(30), 30); assert.equal(resolveReloadIntervalSeconds(30), 30);
assert.equal(resolveReloadIntervalSeconds("45"), 45); assert.equal(resolveReloadIntervalSeconds("45"), 45);