diff --git a/html/js/office-render.js b/html/js/office-render.js index ab60e9e..deda218 100644 --- a/html/js/office-render.js +++ b/html/js/office-render.js @@ -27,6 +27,7 @@ import { mapAgentsToDesks, normalizeAgentForDisplay, resolveAgentVisualState, + isAgentPresent, pickIdleActivity, AnimationQueue, detectDoorVisits, @@ -1003,6 +1004,7 @@ function agentPlacements(agents, deskLayout) { let overflowIndex = 0; for (const raw of agents) { if (seatedIds.has(raw.id)) continue; + if (!isAgentPresent(raw)) continue; const agent = normalizeAgentForDisplay(raw); const homeFeet = { x: 70 + (overflowIndex % 3) * 26, diff --git a/html/js/office-scene.js b/html/js/office-scene.js index 32d2337..5cbebb6 100644 --- a/html/js/office-scene.js +++ b/html/js/office-scene.js @@ -355,20 +355,49 @@ export function resolveAgentVisualState(state) { 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 * 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" * 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} agents * @param {number} [deskCount] + * @param {number} [now] - Referenzzeitpunkt fuer den Praesenz-Check (injizierbar fuer Tests). * @returns {Array} */ -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); for (const agent of agents) { + if (!isAgentPresent(agent, now)) { + continue; + } const deskIndex = agent.desk_index; if (typeof deskIndex === "number" && deskIndex >= 0 && deskIndex < deskCount) { desks[deskIndex] = agent; diff --git a/tests/frontend/office-scene.test.js b/tests/frontend/office-scene.test.js index c5e3bda..15e0b05 100644 --- a/tests/frontend/office-scene.test.js +++ b/tests/frontend/office-scene.test.js @@ -24,6 +24,8 @@ import { resolveAgentVisualState, mapAgentsToDesks, normalizeAgentForDisplay, + isAgentPresent, + AGENT_PRESENCE_TIMEOUT_MS, pickIdleActivity, AnimationQueue, DOOR_VISIT_TYPES, @@ -281,6 +283,36 @@ test("mapAgentsToDesks ignoriert Agenten ohne Schreibtisch (desk_index null, Rol 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", () => { const normalized = normalizeAgentForDisplay({ id: 1, display_name: "Alice", state: "working" }); assert.equal(normalized.name, "Alice");