Merge pull request 'Agent-Praesenz mit Zehn-Minuten-Timeout versehen' (#18) from fds2610-bot/monitor:main into main
Reviewed-on: https://codeberg.org/fds2610/monitor/pulls/18
This commit is contained in:
commit
5a645e1d12
3 changed files with 310 additions and 15 deletions
|
|
@ -27,12 +27,18 @@ import {
|
||||||
mapAgentsToDesks,
|
mapAgentsToDesks,
|
||||||
normalizeAgentForDisplay,
|
normalizeAgentForDisplay,
|
||||||
resolveAgentVisualState,
|
resolveAgentVisualState,
|
||||||
|
isAgentPresent,
|
||||||
pickIdleActivity,
|
pickIdleActivity,
|
||||||
AnimationQueue,
|
AnimationQueue,
|
||||||
detectDoorVisits,
|
detectDoorVisits,
|
||||||
resolveDoorVisitVisual,
|
resolveDoorVisitVisual,
|
||||||
advanceDoorVisitStage,
|
advanceDoorVisitStage,
|
||||||
DOOR_VISIT_STAGES,
|
DOOR_VISIT_STAGES,
|
||||||
|
detectUnassignedTicketChanges,
|
||||||
|
advancePoVisitStage,
|
||||||
|
PO_VISIT_STAGES,
|
||||||
|
PO_AGENT_ID,
|
||||||
|
PO_DISPLAY_NAME,
|
||||||
resolveReloadIntervalSeconds,
|
resolveReloadIntervalSeconds,
|
||||||
nextCountdownSeconds,
|
nextCountdownSeconds,
|
||||||
} from "./office-scene.js";
|
} from "./office-scene.js";
|
||||||
|
|
@ -61,6 +67,11 @@ const DESK_ROW_Y = [540, 840];
|
||||||
const DESK_COL_X = [232, 644, 1056, 1468];
|
const DESK_COL_X = [232, 644, 1056, 1468];
|
||||||
const COFFEE_CORNER = { x: 960, y: 780 };
|
const COFFEE_CORNER = { x: 960, y: 780 };
|
||||||
|
|
||||||
|
// Fester Platz des Product Owner (Issue #19): bewusst ausserhalb des dynamischen 8er-Rasters
|
||||||
|
// (DESK_COL_X/DESK_ROW_Y), in der freien Nische zwischen Board/Monitor-Wand und der ersten
|
||||||
|
// Schreibtischreihe - so kollidiert er nie mit einem echten, ueber die API registrierten Agenten.
|
||||||
|
const PO_DESK = { x: 870, y: 380, w: 180 };
|
||||||
|
|
||||||
const NOTE_W = 96;
|
const NOTE_W = 96;
|
||||||
const NOTE_H = 50;
|
const NOTE_H = 50;
|
||||||
const NOTE_STEP_Y = 56;
|
const NOTE_STEP_Y = 56;
|
||||||
|
|
@ -73,6 +84,9 @@ const COFFEE_FEET = { x: COFFEE_CORNER.x, y: COFFEE_CORNER.y + 26 };
|
||||||
// Ruheplatz der Tuerbesucher (Phase 6, F2): direkt vor der linken Tuer, wie schon der
|
// Ruheplatz der Tuerbesucher (Phase 6, F2): direkt vor der linken Tuer, wie schon der
|
||||||
// Ueberlaufplatz fuer Agenten ohne Schreibtisch (siehe agentPlacements()).
|
// Ueberlaufplatz fuer Agenten ohne Schreibtisch (siehe agentPlacements()).
|
||||||
const DOOR_FEET = { x: 20, y: DOOR.y + DOOR.h / 2 };
|
const DOOR_FEET = { x: 20, y: DOOR.y + DOOR.h / 2 };
|
||||||
|
// Ruheplatz des PO an seinem festen Platz (Issue #19), analog zu den Fuss-Positionen der
|
||||||
|
// regulaeren Schreibtische (deskSeat().feet).
|
||||||
|
const PO_DESK_FEET = { x: PO_DESK.x + PO_DESK.w / 2, y: PO_DESK.y + 40 + DESK.h + 30 };
|
||||||
|
|
||||||
const IDLE_MIN_DELAY_MS = 15000;
|
const IDLE_MIN_DELAY_MS = 15000;
|
||||||
const IDLE_MAX_DELAY_MS = 40000;
|
const IDLE_MAX_DELAY_MS = 40000;
|
||||||
|
|
@ -428,16 +442,17 @@ function deskScreenPoseClass(pose) {
|
||||||
// Zeichnet nur die Moebel/Deko + den Namen des Schreibtisch-Besitzers - die eigentliche
|
// Zeichnet nur die Moebel/Deko + den Namen des Schreibtisch-Besitzers - die eigentliche
|
||||||
// Person ist ein eigenstaendiger, beweglicher Avatar (siehe renderAgentAvatar/
|
// Person ist ein eigenstaendiger, beweglicher Avatar (siehe renderAgentAvatar/
|
||||||
// updateAgentsLayer), damit sie zum Board/Monitor "laufen" kann, ohne den Schreibtisch neu
|
// updateAgentsLayer), damit sie zum Board/Monitor "laufen" kann, ohne den Schreibtisch neu
|
||||||
// zeichnen zu muessen.
|
// zeichnen zu muessen. Parametrisiert ueber origin/width, damit sowohl die regulaeren
|
||||||
function renderDesk({ deskIndex, agent }) {
|
// Schreibtische (deskSeat()) als auch der feste PO-Platz (PO_DESK, Issue #19) dieselbe
|
||||||
const { origin, feet } = deskSeat(deskIndex);
|
// Zeichenlogik/Deko (inkl. rollenabhaengiger desk.deco) nutzen koennen.
|
||||||
|
function renderDeskAt(origin, agent, width = DESK.w) {
|
||||||
const role = agent ? ROLES[agent.role] : null;
|
const role = agent ? ROLES[agent.role] : null;
|
||||||
const g = el("g", {
|
const g = el("g", {
|
||||||
class: `desk ${agent ? "desk-occupied" : "desk-empty"}`,
|
class: `desk ${agent ? "desk-occupied" : "desk-empty"}`,
|
||||||
transform: `translate(${origin.x}, ${origin.y})`,
|
transform: `translate(${origin.x}, ${origin.y})`,
|
||||||
});
|
});
|
||||||
|
|
||||||
g.appendChild(el("rect", { x: 0, y: 40, width: DESK.w, height: DESK.h, rx: 6, class: "desk-table" }));
|
g.appendChild(el("rect", { x: 0, y: 40, width, height: DESK.h, rx: 6, class: "desk-table" }));
|
||||||
g.appendChild(
|
g.appendChild(
|
||||||
el("rect", {
|
el("rect", {
|
||||||
x: 20,
|
x: 20,
|
||||||
|
|
@ -448,18 +463,18 @@ function renderDesk({ deskIndex, agent }) {
|
||||||
class: `desk-screen ${agent ? deskScreenPoseClass(agent.pose) : ""}`,
|
class: `desk-screen ${agent ? deskScreenPoseClass(agent.pose) : ""}`,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
g.appendChild(el("circle", { cx: DESK.w / 2, cy: feet.y - origin.y, r: 22, class: "desk-chair" }));
|
g.appendChild(el("circle", { cx: width / 2, cy: 40 + DESK.h + 30, r: 22, class: "desk-chair" }));
|
||||||
|
|
||||||
if (role) {
|
if (role) {
|
||||||
if (role.deco === "star") {
|
if (role.deco === "star") {
|
||||||
g.appendChild(
|
g.appendChild(
|
||||||
el("path", {
|
el("path", {
|
||||||
d: "M170,20 l4,10 11,0 -9,7 3,11 -9,-7 -9,7 3,-11 -9,-7 11,0 z",
|
d: `M${width - 50},20 l4,10 11,0 -9,7 3,11 -9,-7 -9,7 3,-11 -9,-7 11,0 z`,
|
||||||
class: "desk-deco desk-deco-star",
|
class: "desk-deco desk-deco-star",
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else if (role.deco === "flower") {
|
} else if (role.deco === "flower") {
|
||||||
const cx = 175;
|
const cx = width - 45;
|
||||||
const cy = 22;
|
const cy = 22;
|
||||||
g.appendChild(
|
g.appendChild(
|
||||||
el("g", { class: "desk-deco desk-deco-flower" }, [
|
el("g", { class: "desk-deco desk-deco-flower" }, [
|
||||||
|
|
@ -472,13 +487,13 @@ function renderDesk({ deskIndex, agent }) {
|
||||||
);
|
);
|
||||||
} else if (role.deco === "check") {
|
} else if (role.deco === "check") {
|
||||||
g.appendChild(
|
g.appendChild(
|
||||||
el("path", { d: "M162,24 l8,8 16,-18", class: "desk-deco desk-deco-check" })
|
el("path", { d: `M${width - 58},24 l8,8 16,-18`, class: "desk-deco desk-deco-check" })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
g.appendChild(
|
g.appendChild(
|
||||||
text(agent.name, {
|
text(agent.name, {
|
||||||
x: DESK.w / 2,
|
x: width / 2,
|
||||||
y: 40 + DESK.h + 74,
|
y: 40 + DESK.h + 74,
|
||||||
class: "desk-name",
|
class: "desk-name",
|
||||||
"text-anchor": "middle",
|
"text-anchor": "middle",
|
||||||
|
|
@ -487,7 +502,7 @@ function renderDesk({ deskIndex, agent }) {
|
||||||
} else {
|
} else {
|
||||||
g.appendChild(
|
g.appendChild(
|
||||||
text("frei", {
|
text("frei", {
|
||||||
x: DESK.w / 2,
|
x: width / 2,
|
||||||
y: 40 + DESK.h + 74,
|
y: 40 + DESK.h + 74,
|
||||||
class: "desk-name desk-name-empty",
|
class: "desk-name desk-name-empty",
|
||||||
"text-anchor": "middle",
|
"text-anchor": "middle",
|
||||||
|
|
@ -498,9 +513,21 @@ function renderDesk({ deskIndex, agent }) {
|
||||||
return g;
|
return g;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDesks(desks) {
|
function renderDesk({ deskIndex, agent }) {
|
||||||
|
return renderDeskAt(deskSeat(deskIndex).origin, agent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Array<object>} desks - reguläre 8er-Raster-Plaetze (echte Agenten, F1).
|
||||||
|
* @param {object|null} poAgent - {name: "PO", role: "product_owner", pose} bei erreichbarer
|
||||||
|
* API, sonst null (dann zeigt der feste PO-Platz "frei", Issue #19).
|
||||||
|
*/
|
||||||
|
function renderDesks(desks, poAgent) {
|
||||||
const g = el("g", { class: "desks" });
|
const g = el("g", { class: "desks" });
|
||||||
desks.forEach((desk) => g.appendChild(renderDesk(desk)));
|
desks.forEach((desk) => g.appendChild(renderDesk(desk)));
|
||||||
|
const poDeskGroup = renderDeskAt(PO_DESK, poAgent, PO_DESK.w);
|
||||||
|
poDeskGroup.classList.add("po-desk");
|
||||||
|
g.appendChild(poDeskGroup);
|
||||||
return g;
|
return g;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -718,10 +745,63 @@ function updateDoorActiveClass() {
|
||||||
|
|
||||||
function repaintAgentsLayer() {
|
function repaintAgentsLayer() {
|
||||||
if (!currentAgentsLayer) return;
|
if (!currentAgentsLayer) return;
|
||||||
updateAgentsLayer(currentAgentsLayer, [...currentRegularPlacements, ...doorVisitorPlacements()]);
|
updateAgentsLayer(currentAgentsLayer, [
|
||||||
|
...currentRegularPlacements,
|
||||||
|
...poPlacement(),
|
||||||
|
...doorVisitorPlacements(),
|
||||||
|
]);
|
||||||
updateDoorActiveClass();
|
updateDoorActiveClass();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// Product Owner (Issue #19): fester Platz + Avatar, der bei "niemandem zugeordneten"
|
||||||
|
// Ticket-Statuswechseln zum Board laeuft (siehe detectUnassignedTicketChanges() in
|
||||||
|
// office-scene.js). Anders als die Tuerbesucher (mehrere gleichzeitig moeglich, Phase 6) gibt
|
||||||
|
// es nur einen PO - poState ist daher ein einzelner Zustand statt einer Map.
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let poIsLive = false;
|
||||||
|
let poState = "idle";
|
||||||
|
let poVisitTimeoutId = null;
|
||||||
|
|
||||||
|
function poPlacement() {
|
||||||
|
if (!poIsLive) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const visual = resolveAgentVisualState(poState);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
agent: { id: PO_AGENT_ID, name: PO_DISPLAY_NAME, role: "product_owner", pose: visual.pose, location: visual.location },
|
||||||
|
homeFeet: PO_DESK_FEET,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function advancePoVisit() {
|
||||||
|
const nextStage = advancePoVisitStage(poState);
|
||||||
|
if (nextStage === null) {
|
||||||
|
poState = "idle";
|
||||||
|
repaintAgentsLayer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
poState = nextStage;
|
||||||
|
repaintAgentsLayer();
|
||||||
|
poVisitTimeoutId = window.setTimeout(advancePoVisit, DOOR_VISIT_STAGE_DURATION_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mehrere gleichzeitige unzugeordnete Aenderungen (z.B. mehrere Tickets erhalten kurz
|
||||||
|
// 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() {
|
||||||
|
if (!poIsLive || poState !== "idle") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
poState = PO_VISIT_STAGES[0];
|
||||||
|
repaintAgentsLayer();
|
||||||
|
poVisitTimeoutId = window.setTimeout(advancePoVisit, DOOR_VISIT_STAGE_DURATION_MS);
|
||||||
|
}
|
||||||
|
|
||||||
function advanceDoorVisit(id) {
|
function advanceDoorVisit(id) {
|
||||||
const visitor = doorVisitors.get(id);
|
const visitor = doorVisitors.get(id);
|
||||||
if (!visitor) {
|
if (!visitor) {
|
||||||
|
|
@ -901,6 +981,7 @@ export function renderOfficeScene(container, data = {}) {
|
||||||
events = DEMO_EVENTS,
|
events = DEMO_EVENTS,
|
||||||
kpis = [],
|
kpis = [],
|
||||||
tickets = [],
|
tickets = [],
|
||||||
|
isLive = false,
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
let svg = container.querySelector(".office-svg");
|
let svg = container.querySelector(".office-svg");
|
||||||
|
|
@ -921,8 +1002,15 @@ export function renderOfficeScene(container, data = {}) {
|
||||||
idleCosmeticTimers.forEach((handle) => window.clearTimeout(handle.timeoutId));
|
idleCosmeticTimers.forEach((handle) => window.clearTimeout(handle.timeoutId));
|
||||||
idleCosmeticTimers.clear();
|
idleCosmeticTimers.clear();
|
||||||
doorVisitors.clear();
|
doorVisitors.clear();
|
||||||
|
if (poVisitTimeoutId) window.clearTimeout(poVisitTimeoutId);
|
||||||
|
poVisitTimeoutId = null;
|
||||||
|
poState = "idle";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Der PO gilt als anwesend, sobald die API erreichbar ist (isLive) - nicht ueber
|
||||||
|
// last_seen_at/AGENT_PRESENCE_TIMEOUT_MS wie registrierte Agenten (Issue #19).
|
||||||
|
poIsLive = isLive;
|
||||||
|
|
||||||
const groupedTickets = groupTicketsByColumn(tickets);
|
const groupedTickets = groupTicketsByColumn(tickets);
|
||||||
|
|
||||||
const boardGroup = renderBoard(ticketCounts, (columnKey) => {
|
const boardGroup = renderBoard(ticketCounts, (columnKey) => {
|
||||||
|
|
@ -953,7 +1041,8 @@ export function renderOfficeScene(container, data = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const deskLayout = buildDeskLayout(normalizedDeskAgents(agents));
|
const deskLayout = buildDeskLayout(normalizedDeskAgents(agents));
|
||||||
const desksGroup = renderDesks(deskLayout);
|
const poDeskAgent = isLive ? { name: PO_DISPLAY_NAME, role: "product_owner", pose: "idle" } : null;
|
||||||
|
const desksGroup = renderDesks(deskLayout, poDeskAgent);
|
||||||
const existingDesks = svg.querySelector(".desks");
|
const existingDesks = svg.querySelector(".desks");
|
||||||
if (existingDesks) {
|
if (existingDesks) {
|
||||||
existingDesks.replaceWith(desksGroup);
|
existingDesks.replaceWith(desksGroup);
|
||||||
|
|
@ -1003,6 +1092,7 @@ function agentPlacements(agents, deskLayout) {
|
||||||
let overflowIndex = 0;
|
let overflowIndex = 0;
|
||||||
for (const raw of agents) {
|
for (const raw of agents) {
|
||||||
if (seatedIds.has(raw.id)) continue;
|
if (seatedIds.has(raw.id)) continue;
|
||||||
|
if (!isAgentPresent(raw)) continue;
|
||||||
const agent = normalizeAgentForDisplay(raw);
|
const agent = normalizeAgentForDisplay(raw);
|
||||||
const homeFeet = {
|
const homeFeet = {
|
||||||
x: 70 + (overflowIndex % 3) * 26,
|
x: 70 + (overflowIndex % 3) * 26,
|
||||||
|
|
@ -1061,6 +1151,9 @@ async function boot() {
|
||||||
const data = await loadLiveData();
|
const data = await loadLiveData();
|
||||||
if (previousTickets) {
|
if (previousTickets) {
|
||||||
detectDoorVisits(previousTickets, data.tickets).forEach(triggerDoorVisit);
|
detectDoorVisits(previousTickets, data.tickets).forEach(triggerDoorVisit);
|
||||||
|
if (detectUnassignedTicketChanges(previousTickets, data.tickets).length > 0) {
|
||||||
|
triggerPoVisit();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
previousTickets = data.tickets;
|
previousTickets = data.tickets;
|
||||||
renderOfficeScene(container, {
|
renderOfficeScene(container, {
|
||||||
|
|
@ -1070,6 +1163,7 @@ async function boot() {
|
||||||
systems: data.systems,
|
systems: data.systems,
|
||||||
events: data.events,
|
events: data.events,
|
||||||
kpis: data.kpis,
|
kpis: data.kpis,
|
||||||
|
isLive: true,
|
||||||
});
|
});
|
||||||
setApiBanner(false);
|
setApiBanner(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -355,20 +355,49 @@ export function resolveAgentVisualState(state) {
|
||||||
return AGENT_STATE_VISUALS[state] ?? AGENT_STATE_VISUALS.idle;
|
return AGENT_STATE_VISUALS[state] ?? AGENT_STATE_VISUALS.idle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Praesenz-Timeout (Issue #17): ein registrierter Agent gilt nur noch als anwesend, solange
|
||||||
|
// last_seen_at nicht laenger als zehn Minuten zurueckliegt. Meldet er sich laenger nicht,
|
||||||
|
// bleibt sein Schreibtisch/Platz leer, obwohl er weiterhin registriert ist.
|
||||||
|
export const AGENT_PRESENCE_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{last_seen_at?: string}} agent
|
||||||
|
* @param {number} [now] - Referenzzeitpunkt in ms seit Epoch (injizierbar fuer Tests).
|
||||||
|
* @returns {boolean} true, wenn der Agent als anwesend gilt. Fehlt last_seen_at (z.B.
|
||||||
|
* Demo-Daten ohne API-Felder) oder ist es nicht parsbar, gilt der Agent defensiv als
|
||||||
|
* anwesend, statt faelschlich einen leeren Platz zu zeigen.
|
||||||
|
*/
|
||||||
|
export function isAgentPresent(agent, now = Date.now()) {
|
||||||
|
if (!agent || !agent.last_seen_at) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const lastSeenAt = new Date(agent.last_seen_at).getTime();
|
||||||
|
if (Number.isNaN(lastSeenAt)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return now - lastSeenAt <= AGENT_PRESENCE_TIMEOUT_MS;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ordnet eine rohe Agentenliste (GET /api/v1/agents) den Schreibtischen zu - anders als
|
* Ordnet eine rohe Agentenliste (GET /api/v1/agents) den Schreibtischen zu - anders als
|
||||||
* buildDeskLayout()/Demo-Daten steht der Sitzplatz hier nicht in der Array-Reihenfolge,
|
* buildDeskLayout()/Demo-Daten steht der Sitzplatz hier nicht in der Array-Reihenfolge,
|
||||||
* sondern im Feld desk_index (API.md "Agenten"). Agenten ohne Schreibtisch (role "user"
|
* sondern im Feld desk_index (API.md "Agenten"). Agenten ohne Schreibtisch (role "user"
|
||||||
* oder alle Schreibtische belegt, desk_index === null) erscheinen hier nicht - sie kommen
|
* oder alle Schreibtische belegt, desk_index === null) erscheinen hier nicht - sie kommen
|
||||||
* gemaess F2 nur anlassbezogen durch eine Tuer und haben keinen festen Platz.
|
* gemaess F2 nur anlassbezogen durch eine Tuer und haben keinen festen Platz. Agenten, die
|
||||||
|
* laenger als AGENT_PRESENCE_TIMEOUT_MS nicht mehr gesehen wurden (last_seen_at, Issue #17),
|
||||||
|
* erscheinen ebenfalls nicht - ihr Schreibtisch bleibt frei.
|
||||||
*
|
*
|
||||||
* @param {Array<object>} agents
|
* @param {Array<object>} agents
|
||||||
* @param {number} [deskCount]
|
* @param {number} [deskCount]
|
||||||
|
* @param {number} [now] - Referenzzeitpunkt fuer den Praesenz-Check (injizierbar fuer Tests).
|
||||||
* @returns {Array<object|null>}
|
* @returns {Array<object|null>}
|
||||||
*/
|
*/
|
||||||
export function mapAgentsToDesks(agents = [], deskCount = DEFAULT_DESK_COUNT) {
|
export function mapAgentsToDesks(agents = [], deskCount = DEFAULT_DESK_COUNT, now = Date.now()) {
|
||||||
const desks = new Array(deskCount).fill(null);
|
const desks = new Array(deskCount).fill(null);
|
||||||
for (const agent of agents) {
|
for (const agent of agents) {
|
||||||
|
if (!isAgentPresent(agent, now)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const deskIndex = agent.desk_index;
|
const deskIndex = agent.desk_index;
|
||||||
if (typeof deskIndex === "number" && deskIndex >= 0 && deskIndex < deskCount) {
|
if (typeof deskIndex === "number" && deskIndex >= 0 && deskIndex < deskCount) {
|
||||||
desks[deskIndex] = agent;
|
desks[deskIndex] = agent;
|
||||||
|
|
@ -539,6 +568,71 @@ export function advanceDoorVisitStage(stage) {
|
||||||
return DOOR_VISIT_STAGES[index + 1];
|
return DOOR_VISIT_STAGES[index + 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// Product Owner (Issue #19): fester Platz + Board-Besuch bei Ticket-Statuswechseln, die noch
|
||||||
|
// keiner Rolle zugeordnet sind (Visualization.md Abschnitt 4.4/9, Analogon zu Phase 6/Tueren).
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Der PO ist keine echte Registrierung ueber POST /api/v1/agents, sondern repraesentiert die
|
||||||
|
// Produktverantwortung des Systems selbst - er bekommt daher keinen dynamisch zugewiesenen
|
||||||
|
// Schreibtisch aus dem 8er-Raster, sondern einen eigenen, festen Platz (siehe office-render.js,
|
||||||
|
// PO_DESK), und gilt als anwesend, sobald die API erreichbar ist (nicht ueber
|
||||||
|
// last_seen_at/AGENT_PRESENCE_TIMEOUT_MS wie registrierte Agenten, siehe isAgentPresent()).
|
||||||
|
export const PO_AGENT_ID = "po";
|
||||||
|
export const PO_DISPLAY_NAME = "PO";
|
||||||
|
|
||||||
|
// Statuswechsel, die bereits einer anderen Rolle zugeordnet sind: neue Backlog-Tickets sowie
|
||||||
|
// der Wechsel nach "fertig"/"done" loesen bereits einen Tuerbesuch der Rolle "user" aus
|
||||||
|
// (siehe DOOR_VISIT_TYPES). Alles andere - z.B. der Wechsel nach "bereit" durch das Label
|
||||||
|
// "automate" (das genau dieses Issue #19 ausgeloest hat) - ist bislang niemandem zugeordnet
|
||||||
|
// und damit Sache des PO.
|
||||||
|
const PO_UNASSIGNED_EXCLUDED_COLUMNS = new Set(["fertig", "done"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erkennt Ticket-Statuswechsel, die keiner Rolle zugeordnet sind (Gegenstueck zu
|
||||||
|
* detectDoorVisits() fuer die bereits zugeordneten Faelle). Neu angelegte Tickets zaehlen hier
|
||||||
|
* nicht mit - die sind bereits Sache von detectDoorVisits()/NEW_BACKLOG_TICKET.
|
||||||
|
*
|
||||||
|
* @param {Array<{id: number, column_name: string}>} previousTickets
|
||||||
|
* @param {Array<{id: number, column_name: string}>} currentTickets
|
||||||
|
* @returns {Array<{ticketId: number, column: string}>}
|
||||||
|
*/
|
||||||
|
export function detectUnassignedTicketChanges(previousTickets = [], currentTickets = []) {
|
||||||
|
const previousById = new Map(previousTickets.map((ticket) => [ticket.id, ticket]));
|
||||||
|
const changes = [];
|
||||||
|
|
||||||
|
for (const ticket of currentTickets) {
|
||||||
|
const before = previousById.get(ticket.id);
|
||||||
|
if (!before || before.column_name === ticket.column_name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (PO_UNASSIGNED_EXCLUDED_COLUMNS.has(ticket.column_name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
changes.push({ ticketId: ticket.id, column: ticket.column_name });
|
||||||
|
}
|
||||||
|
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
export const PO_VISIT_STAGES = ["walking_to_board", "interacting_with_board", "walking_back"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} stage - aktuelle Stage aus PO_VISIT_STAGES.
|
||||||
|
* @returns {string|null} naechste Stage, oder null, wenn der Besuch abgeschlossen ist (der PO
|
||||||
|
* ist danach wieder "idle" am eigenen Platz).
|
||||||
|
*/
|
||||||
|
export function advancePoVisitStage(stage) {
|
||||||
|
const index = PO_VISIT_STAGES.indexOf(stage);
|
||||||
|
if (index === -1 || index === PO_VISIT_STAGES.length - 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return PO_VISIT_STAGES[index + 1];
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------
|
||||||
// Reload-Countdown im Header (Issue #12): rein kosmetischer Sekundenzaehler, der die
|
// Reload-Countdown im Header (Issue #12): rein kosmetischer Sekundenzaehler, der die
|
||||||
// Sync-Frequenz des Backends (MONITOR_SYNC_INTERVAL, siehe .env.example) im Header
|
// Sync-Frequenz des Backends (MONITOR_SYNC_INTERVAL, siehe .env.example) im Header
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ import {
|
||||||
resolveAgentVisualState,
|
resolveAgentVisualState,
|
||||||
mapAgentsToDesks,
|
mapAgentsToDesks,
|
||||||
normalizeAgentForDisplay,
|
normalizeAgentForDisplay,
|
||||||
|
isAgentPresent,
|
||||||
|
AGENT_PRESENCE_TIMEOUT_MS,
|
||||||
pickIdleActivity,
|
pickIdleActivity,
|
||||||
AnimationQueue,
|
AnimationQueue,
|
||||||
DOOR_VISIT_TYPES,
|
DOOR_VISIT_TYPES,
|
||||||
|
|
@ -31,6 +33,11 @@ import {
|
||||||
detectDoorVisits,
|
detectDoorVisits,
|
||||||
resolveDoorVisitVisual,
|
resolveDoorVisitVisual,
|
||||||
advanceDoorVisitStage,
|
advanceDoorVisitStage,
|
||||||
|
PO_AGENT_ID,
|
||||||
|
PO_DISPLAY_NAME,
|
||||||
|
PO_VISIT_STAGES,
|
||||||
|
detectUnassignedTicketChanges,
|
||||||
|
advancePoVisitStage,
|
||||||
DEFAULT_RELOAD_INTERVAL_SECONDS,
|
DEFAULT_RELOAD_INTERVAL_SECONDS,
|
||||||
resolveReloadIntervalSeconds,
|
resolveReloadIntervalSeconds,
|
||||||
nextCountdownSeconds,
|
nextCountdownSeconds,
|
||||||
|
|
@ -281,6 +288,36 @@ test("mapAgentsToDesks ignoriert Agenten ohne Schreibtisch (desk_index null, Rol
|
||||||
assert.ok(desks.every((d) => d === null));
|
assert.ok(desks.every((d) => d === null));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("isAgentPresent gilt als anwesend, solange last_seen_at hoechstens zehn Minuten zurueckliegt (Issue #17)", () => {
|
||||||
|
const now = Date.parse("2026-07-29T12:00:00Z");
|
||||||
|
assert.equal(
|
||||||
|
isAgentPresent({ last_seen_at: "2026-07-29T11:50:00Z" }, now),
|
||||||
|
true,
|
||||||
|
"genau zehn Minuten alt gilt noch als anwesend"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isAgentPresent({ last_seen_at: "2026-07-29T11:49:59Z" }, now),
|
||||||
|
false,
|
||||||
|
"aelter als zehn Minuten gilt nicht mehr als anwesend"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isAgentPresent faellt bei fehlendem/kaputtem last_seen_at defensiv auf anwesend zurueck", () => {
|
||||||
|
assert.equal(isAgentPresent({ id: 1 }), true);
|
||||||
|
assert.equal(isAgentPresent({ id: 1, last_seen_at: "nicht-parsbar" }), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mapAgentsToDesks laesst den Schreibtisch eines Agenten frei, der laenger als zehn Minuten nicht gesehen wurde", () => {
|
||||||
|
const now = Date.parse("2026-07-29T12:00:00Z");
|
||||||
|
const staleSeenAt = new Date(now - AGENT_PRESENCE_TIMEOUT_MS - 1000).toISOString();
|
||||||
|
const desks = mapAgentsToDesks(
|
||||||
|
[{ id: 1, desk_index: 0, last_seen_at: staleSeenAt }],
|
||||||
|
2,
|
||||||
|
now
|
||||||
|
);
|
||||||
|
assert.equal(desks[0], null);
|
||||||
|
});
|
||||||
|
|
||||||
test("normalizeAgentForDisplay leitet name/pose/location aus display_name/state ab", () => {
|
test("normalizeAgentForDisplay leitet name/pose/location aus display_name/state ab", () => {
|
||||||
const normalized = normalizeAgentForDisplay({ id: 1, display_name: "Alice", state: "working" });
|
const normalized = normalizeAgentForDisplay({ id: 1, display_name: "Alice", state: "working" });
|
||||||
assert.equal(normalized.name, "Alice");
|
assert.equal(normalized.name, "Alice");
|
||||||
|
|
@ -408,6 +445,76 @@ test("advanceDoorVisitStage liefert bei unbekannter Stage null", () => {
|
||||||
assert.equal(advanceDoorVisitStage("unbekannt"), null);
|
assert.equal(advanceDoorVisitStage("unbekannt"), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// Product Owner (Issue #19)
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test("PO_AGENT_ID/PO_DISPLAY_NAME sind stabile Konstanten (der PO heisst schlicht 'PO')", () => {
|
||||||
|
assert.equal(PO_AGENT_ID, "po");
|
||||||
|
assert.equal(PO_DISPLAY_NAME, "PO");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detectUnassignedTicketChanges erkennt einen Statuswechsel nach 'bereit' (Label automate)", () => {
|
||||||
|
const before = [{ id: 19, column_name: "backlog" }];
|
||||||
|
const after = [{ id: 19, column_name: "bereit" }];
|
||||||
|
assert.deepEqual(detectUnassignedTicketChanges(before, after), [{ ticketId: 19, column: "bereit" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detectUnassignedTicketChanges ignoriert Wechsel nach fertig/done (schon Sache der Rolle user)", () => {
|
||||||
|
const before = [
|
||||||
|
{ id: 1, column_name: "qa" },
|
||||||
|
{ id: 2, column_name: "testing" },
|
||||||
|
];
|
||||||
|
const after = [
|
||||||
|
{ id: 1, column_name: "fertig" },
|
||||||
|
{ id: 2, column_name: "done" },
|
||||||
|
];
|
||||||
|
assert.deepEqual(detectUnassignedTicketChanges(before, after), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detectUnassignedTicketChanges ignoriert neu angelegte Tickets (schon Sache von detectDoorVisits)", () => {
|
||||||
|
const visits = detectUnassignedTicketChanges([], [{ id: 1, column_name: "bereit" }]);
|
||||||
|
assert.deepEqual(visits, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detectUnassignedTicketChanges ignoriert Tickets ohne Spaltenwechsel", () => {
|
||||||
|
const before = [{ id: 1, column_name: "in_arbeit" }];
|
||||||
|
const after = [{ id: 1, column_name: "in_arbeit" }];
|
||||||
|
assert.deepEqual(detectUnassignedTicketChanges(before, after), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detectUnassignedTicketChanges verarbeitet mehrere gleichzeitige Wechsel", () => {
|
||||||
|
const before = [
|
||||||
|
{ id: 1, column_name: "backlog" },
|
||||||
|
{ id: 2, column_name: "backlog" },
|
||||||
|
{ id: 3, column_name: "qa" },
|
||||||
|
];
|
||||||
|
const after = [
|
||||||
|
{ id: 1, column_name: "bereit" },
|
||||||
|
{ id: 2, column_name: "in_arbeit" },
|
||||||
|
{ id: 3, column_name: "fertig" },
|
||||||
|
];
|
||||||
|
assert.deepEqual(detectUnassignedTicketChanges(before, after), [
|
||||||
|
{ ticketId: 1, column: "bereit" },
|
||||||
|
{ ticketId: 2, column: "in_arbeit" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("advancePoVisitStage durchlaeuft alle Stages und liefert am Ende null", () => {
|
||||||
|
let stage = PO_VISIT_STAGES[0];
|
||||||
|
const seen = [stage];
|
||||||
|
for (let i = 0; i < PO_VISIT_STAGES.length; i++) {
|
||||||
|
stage = advancePoVisitStage(stage);
|
||||||
|
if (stage !== null) seen.push(stage);
|
||||||
|
}
|
||||||
|
assert.deepEqual(seen, PO_VISIT_STAGES);
|
||||||
|
assert.equal(advancePoVisitStage(PO_VISIT_STAGES[PO_VISIT_STAGES.length - 1]), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("advancePoVisitStage liefert bei unbekannter Stage null", () => {
|
||||||
|
assert.equal(advancePoVisitStage("unbekannt"), null);
|
||||||
|
});
|
||||||
|
|
||||||
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);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue