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:
commit
0228ee4ae6
3 changed files with 157 additions and 17 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue