[object Object]

← back to Ticket System

auto-save: 2026-07-26T21:15:59 (2 files) — server.js office.html

f49cf458ad301733bfdea8031960be1e4ddeb6e7 · 2026-07-26 21:15:59 -0700 · Steve Abrams

Files touched

Diff

commit f49cf458ad301733bfdea8031960be1e4ddeb6e7
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Jul 26 21:15:59 2026 -0700

    auto-save: 2026-07-26T21:15:59 (2 files) — server.js office.html
---
 office.html | 530 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js   |  12 +-
 2 files changed, 541 insertions(+), 1 deletion(-)

diff --git a/office.html b/office.html
new file mode 100644
index 0000000..be75b99
--- /dev/null
+++ b/office.html
@@ -0,0 +1,530 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Fleet Office — live ticket floor</title>
+<style>
+  :root{ --bg:#0f1115; --panel:#141821; --line:#262a33; --dim:#8a93a5; --blue:#6db3f2; --red:#e06c75; --green:#34d399; --purple:#c9a4f2; }
+  *{box-sizing:border-box}
+  html,body{margin:0;height:100%;background:var(--bg);color:#e6e6e6;font:13px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;overflow:hidden}
+  #c{position:fixed;inset:0;display:block}
+  .hud{position:fixed;pointer-events:none;z-index:10}
+  #top{top:0;left:0;right:0;padding:12px 16px;display:flex;gap:18px;align-items:center;
+       background:linear-gradient(#0f1115ee,#0f111500);letter-spacing:.05em}
+  #top b{color:#fff;font-size:15px;letter-spacing:.08em}
+  #top .stat{color:var(--dim)}
+  #top .stat span{color:#fff;font-weight:600}
+  .legend{position:fixed;top:52px;left:16px;z-index:10;background:var(--panel);border:1px solid var(--line);
+          border-radius:10px;padding:10px 12px;line-height:1.9;pointer-events:auto}
+  .legend .row{display:flex;align-items:center;gap:8px}
+  .dot{width:11px;height:11px;border-radius:3px;display:inline-block}
+  .legend small{color:var(--dim)}
+  #ticker{position:fixed;bottom:0;left:0;right:0;z-index:10;background:linear-gradient(#0f111500,#0f1115ee);
+          padding:14px 16px 12px;pointer-events:none}
+  #ticker .lbl{color:var(--purple);letter-spacing:.14em;font-size:10px;text-transform:uppercase}
+  #ticker .msg{color:#c6ccd8;margin-top:3px;max-width:70vw;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+  #ticker .msg b{color:#fff}
+  .ctl{position:fixed;top:52px;right:16px;z-index:10;display:flex;flex-direction:column;gap:6px;pointer-events:auto}
+  .ctl button{background:var(--panel);border:1px solid var(--line);color:#c6ccd8;border-radius:8px;
+              padding:7px 11px;cursor:pointer;font:inherit;letter-spacing:.03em}
+  .ctl button:hover{border-color:var(--blue);color:#fff}
+  .ctl button.on{border-color:var(--blue);color:var(--blue)}
+  #tip{position:fixed;z-index:11;background:#000d;border:1px solid var(--line);border-radius:8px;padding:8px 10px;
+       max-width:340px;pointer-events:none;display:none;box-shadow:0 6px 22px #000a}
+  #tip .h{color:var(--blue);font-weight:600;margin-bottom:3px}
+  #tip .s{color:var(--dim);font-size:11px}
+  #tip .t{color:#e6e6e6;margin-top:4px}
+  #err{position:fixed;bottom:52px;left:16px;z-index:12;color:var(--red);display:none}
+</style>
+</head>
+<body>
+<canvas id="c"></canvas>
+<div class="hud" id="top">
+  <b>FLEET OFFICE</b>
+  <span class="stat">rooms <span id="s-rooms">0</span></span>
+  <span class="stat">agents <span id="s-agents">0</span></span>
+  <span class="stat">doing <span id="s-doing" style="color:var(--blue)">0</span></span>
+  <span class="stat">blocked <span id="s-blocked" style="color:var(--red)">0</span></span>
+  <span class="stat">DMs <span id="s-dms" style="color:var(--purple)">0</span></span>
+  <span class="stat" style="margin-left:auto">↻ <span id="s-age">–</span></span>
+</div>
+<div class="legend">
+  <div class="row"><span class="dot" style="background:#6db3f2"></span> doing <small>active work</small></div>
+  <div class="row"><span class="dot" style="background:#e06c75"></span> blocked</div>
+  <div class="row"><span class="dot" style="background:#8a93a5"></span> open</div>
+  <div class="row"><span class="dot" style="background:#34d399"></span> done <small>(recent)</small></div>
+  <div class="row"><span class="dot" style="background:#c9a4f2"></span> DM in flight</div>
+</div>
+<div class="ctl">
+  <button id="b-labels" class="on">Labels: on</button>
+  <button id="b-done" class="on">Done rooms: on</button>
+  <button id="b-follow">Follow DMs: off</button>
+  <button id="b-reset">Reset camera</button>
+</div>
+<div id="tip"></div>
+<div id="err"></div>
+<div class="hud" id="ticker"><div class="lbl">latest activity</div><div class="msg" id="ticker-msg">connecting to ticket board…</div></div>
+
+<script type="importmap">
+{ "imports": {
+  "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
+  "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
+}}
+</script>
+
+<script type="module">
+import * as THREE from 'three';
+import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+
+// ───────────────────────── config ─────────────────────────
+const LANES   = ['doing', 'blocked', 'open', 'done'];        // kanban lanes, left→right
+const COLOR   = { doing:0x6db3f2, blocked:0xe06c75, open:0x8a93a5, done:0x34d399 };
+const ROOM    = 9;      // room footprint (units)
+const PITCH   = 12;     // room center-to-center spacing
+const LANE_W  = 40;     // horizontal gap between lanes
+const DONE_CAP= 14;     // max done rooms to render
+const POLL_MS = 4000;   // live refresh cadence
+
+// ───────────────────────── scene ─────────────────────────
+const canvas = document.getElementById('c');
+const renderer = new THREE.WebGLRenderer({ canvas, antialias:true });
+renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
+renderer.shadowMap.enabled = true;
+renderer.shadowMap.type = THREE.PCFSoftShadowMap;
+
+const scene = new THREE.Scene();
+scene.background = new THREE.Color(0x0f1115);
+scene.fog = new THREE.Fog(0x0f1115, 160, 420);
+
+const camera = new THREE.PerspectiveCamera(45, innerWidth/innerHeight, 1, 2000);
+const HOME = { pos:new THREE.Vector3(70, 130, 150), tgt:new THREE.Vector3(30, 0, 40) };
+camera.position.copy(HOME.pos);
+
+const controls = new OrbitControls(camera, canvas);
+controls.enableDamping = true;
+controls.dampingFactor = 0.08;
+controls.maxPolarAngle = Math.PI * 0.46;   // keep it looking down-ish (from above)
+controls.minDistance = 30;
+controls.maxDistance = 520;
+controls.target.copy(HOME.tgt);
+
+// lights
+scene.add(new THREE.HemisphereLight(0xafc4ff, 0x0a0c10, 0.9));
+const sun = new THREE.DirectionalLight(0xffffff, 1.1);
+sun.position.set(60, 160, 40); sun.castShadow = true;
+sun.shadow.mapSize.set(2048,2048);
+Object.assign(sun.shadow.camera, { left:-260, right:260, top:260, bottom:-260, near:20, far:520 });
+scene.add(sun);
+
+// ground
+const ground = new THREE.Mesh(
+  new THREE.PlaneGeometry(1400, 1400),
+  new THREE.MeshStandardMaterial({ color:0x11141b, roughness:1 }));
+ground.rotation.x = -Math.PI/2; ground.position.y = -0.05; ground.receiveShadow = true;
+scene.add(ground);
+// subtle grid
+const grid = new THREE.GridHelper(1400, 140, 0x1c2130, 0x161a22);
+grid.position.y = 0; scene.add(grid);
+
+// ───────────────────────── helpers ─────────────────────────
+function hashHue(s){ let h=0; for(let i=0;i<s.length;i++) h=(h*31+s.charCodeAt(i))>>>0; return h%360; }
+function agentColor(name){ const c=new THREE.Color(); c.setHSL(hashHue(name)/360, 0.55, 0.62); return c; }
+
+// canvas-texture text sprite
+function makeLabel(lines, { fg='#fff', sub='#8a93a5', w=520, scale=1 } = {}){
+  const pad=18, lh=34, cnv=document.createElement('canvas');
+  const h = pad*2 + lines.length*lh;
+  cnv.width=w; cnv.height=h;
+  const g=cnv.getContext('2d');
+  g.fillStyle='rgba(15,17,21,0.82)';
+  roundRect(g,0,0,w,h,16); g.fill();
+  g.strokeStyle='rgba(120,140,180,0.35)'; g.lineWidth=2; roundRect(g,1,1,w-2,h-2,15); g.stroke();
+  g.textBaseline='top';
+  lines.forEach((ln,i)=>{
+    g.fillStyle = ln.sub ? sub : fg;
+    g.font = `${ln.sub?'400 ':'600 '}${ln.sub?22:26}px -apple-system,Segoe UI,sans-serif`;
+    g.fillText(ln.t, pad, pad + i*lh);
+  });
+  const tex=new THREE.CanvasTexture(cnv); tex.anisotropy=4;
+  const spr=new THREE.Sprite(new THREE.SpriteMaterial({ map:tex, transparent:true, depthWrite:false }));
+  spr.scale.set(w/40*scale, h/40*scale, 1);
+  return spr;
+}
+function roundRect(g,x,y,w,h,r){ g.beginPath(); g.moveTo(x+r,y); g.arcTo(x+w,y,x+w,y+h,r);
+  g.arcTo(x+w,y+h,x,y+h,r); g.arcTo(x,y+h,x,y,r); g.arcTo(x,y,x+w,y,r); g.closePath(); }
+
+// ───────────────────────── build one room ─────────────────────────
+function buildRoom(t){
+  const grp = new THREE.Group();
+  const col = COLOR[t.status] ?? COLOR.open;
+  const floor = new THREE.Mesh(
+    new THREE.BoxGeometry(ROOM, 0.4, ROOM),
+    new THREE.MeshStandardMaterial({ color:col, roughness:0.85, metalness:0.05,
+      emissive:col, emissiveIntensity: t.status==='doing'?0.25:(t.status==='blocked'?0.2:0.06) }));
+  floor.position.y=0.2; floor.receiveShadow=true; floor.castShadow=false;
+  grp.add(floor);
+  // low walls
+  const wallMat = new THREE.MeshStandardMaterial({ color:0x1a1f29, roughness:1,
+    transparent:true, opacity:0.9 });
+  const wallH=1.8, tk=0.3;
+  for(const [dx,dz,ww,dd] of [[0,-ROOM/2,ROOM,tk],[0,ROOM/2,ROOM,tk],[-ROOM/2,0,tk,ROOM],[ROOM/2,0,tk,ROOM]]){
+    const w=new THREE.Mesh(new THREE.BoxGeometry(ww,wallH,dd), wallMat);
+    w.position.set(dx, wallH/2, dz); w.castShadow=true; grp.add(w);
+  }
+  // status trim strip on the near wall
+  const trim=new THREE.Mesh(new THREE.BoxGeometry(ROOM,0.35,0.36),
+    new THREE.MeshStandardMaterial({ color:col, emissive:col, emissiveIntensity:0.8 }));
+  trim.position.set(0, wallH+0.1, ROOM/2); grp.add(trim);
+  // a little desk in the corner (a bit of office furniture)
+  const desk=new THREE.Mesh(new THREE.BoxGeometry(2.6,0.9,1.4),
+    new THREE.MeshStandardMaterial({ color:0x2a2f3a, roughness:0.9 }));
+  desk.position.set(-ROOM/2+2, 0.65, -ROOM/2+1.4); desk.castShadow=true; grp.add(desk);
+
+  // floating label
+  const shortTitle = (t.title||'').length>44 ? t.title.slice(0,42)+'…' : (t.title||'');
+  const label = makeLabel([
+    { t: t.id.replace(/^(TK-\d+).*/, '$1') + '  ·  ' + (t.status||'').toUpperCase() },
+    { t: shortTitle, sub:true },
+    { t: '@'+(t.assignee||'unassigned') + (t.project?('   ['+t.project+']'):''), sub:true },
+  ], { scale:0.9 });
+  label.position.set(0, 6.2, 0);
+  grp.add(label);
+  grp.userData = { ticket:t, floor, trim, label, baseEmis: floor.material.emissiveIntensity, flash:0 };
+  return grp;
+}
+
+// ───────────────────────── build one avatar ─────────────────────────
+function buildAvatar(name){
+  const grp=new THREE.Group();
+  const col=agentColor(name);
+  const body=new THREE.Mesh(new THREE.CapsuleGeometry(0.9,1.7,4,12),
+    new THREE.MeshStandardMaterial({ color:col, roughness:0.6, metalness:0.1 }));
+  body.position.y=1.7; body.castShadow=true; grp.add(body);
+  const head=new THREE.Mesh(new THREE.SphereGeometry(0.72,16,16),
+    new THREE.MeshStandardMaterial({ color:0xffe0bd, roughness:0.7 }));
+  head.position.y=3.3; head.castShadow=true; grp.add(head);
+  // a little "activity" ring at the feet (glows when working)
+  const ring=new THREE.Mesh(new THREE.RingGeometry(1.3,1.7,24),
+    new THREE.MeshBasicMaterial({ color:col, transparent:true, opacity:0.0, side:THREE.DoubleSide }));
+  ring.rotation.x=-Math.PI/2; ring.position.y=0.25; grp.add(ring);
+  const label=makeLabel([{t:name}], { w:Math.max(180,name.length*16+60), scale:0.55 });
+  label.position.set(0,4.7,0); grp.add(label);
+  grp.userData = { name, body, head, ring, label, col, target:new THREE.Vector3(),
+                   home:new THREE.Vector3(), phase:Math.random()*6.28, working:0, room:null };
+  return grp;
+}
+
+// ───────────────────────── DM packet ─────────────────────────
+const packets=[];
+function spawnPacket(fromPos, toPos, broadcast){
+  const col = broadcast ? 0xf0b24a : 0xc9a4f2;
+  const core=new THREE.Mesh(new THREE.SphereGeometry(0.6,12,12),
+    new THREE.MeshBasicMaterial({ color:col }));
+  const glow=new THREE.Mesh(new THREE.SphereGeometry(1.3,12,12),
+    new THREE.MeshBasicMaterial({ color:col, transparent:true, opacity:0.35 }));
+  core.add(glow);
+  const light=new THREE.PointLight(col, 2.2, 26); core.add(light);
+  scene.add(core);
+  const from=fromPos.clone(), to=toPos.clone();
+  const mid=from.clone().add(to).multiplyScalar(0.5);
+  mid.y += from.distanceTo(to)*0.28 + 6;   // arc apex
+  packets.push({ core, from, mid, to, t:0, dur:1.1 + from.distanceTo(to)/220 });
+}
+// quadratic bezier
+function qbez(a,b,c,t,out){ const u=1-t;
+  out.x=u*u*a.x+2*u*t*b.x+t*t*c.x; out.y=u*u*a.y+2*u*t*b.y+t*t*c.y; out.z=u*u*a.z+2*u*t*b.z+t*t*c.z; return out; }
+
+// ───────────────────────── state ─────────────────────────
+const roomsGrp = new THREE.Group();  scene.add(roomsGrp);
+const avatarsGrp = new THREE.Group(); scene.add(avatarsGrp);
+const roomByTicket = new Map();   // ticket id -> room group
+const avatarByName = new Map();    // agent   -> avatar group
+let seenMsgMids = null;            // Set, null until first load (so we don't storm on boot)
+let lastActionTs = 0;
+let showLabels = true, showDone = true, followDMs = false;
+let lastFetch = 0;
+
+// last-known action time of each ticket, so avatars gravitate to their newest room
+function agentLatestRoom(tickets){
+  const at = new Map(); // agent -> {ts, ticketId}
+  for(const t of tickets){
+    for(const a of t.actions||[])   consider(a.agent, a.ts, t.id);
+    for(const c of t.comments||[])  consider(c.agent, c.ts, t.id);
+    // assignee gets a weak claim on its ticket (so unassigned-but-owning shows)
+    if(t.assignee) consider(t.assignee, t.updated_at, t.id, true);
+  }
+  function consider(agent, ts, id, weak){
+    if(!agent) return;
+    const cur = at.get(agent);
+    const key = weak ? ts+'w' : ts;
+    if(!cur || cur.ts < ts) at.set(agent, { ts, ticketId:id });
+  }
+  return at;
+}
+
+function layout(tickets){
+  // group visible tickets by lane
+  const byLane = { doing:[], blocked:[], open:[], done:[] };
+  for(const t of tickets){ if(byLane[t.status]) byLane[t.status].push(t); }
+  for(const k of LANES) byLane[k].sort((a,b)=> a.updated_at<b.updated_at?1:-1);
+  byLane.done = byLane.done.slice(0, DONE_CAP);
+  const want = new Set();
+
+  LANES.forEach((lane, li)=>{
+    if(lane==='done' && !showDone) return;
+    const x = li*LANE_W - LANE_W*1.5 + 30;
+    byLane[lane].forEach((t, ri)=>{
+      const z = ri*PITCH;
+      want.add(t.id);
+      let room = roomByTicket.get(t.id);
+      if(!room){ room = buildRoom(t); roomByTicket.set(t.id, room); roomsGrp.add(room); }
+      else updateRoom(room, t);
+      room.position.set(x, 0, z);
+      room.visible = true;
+    });
+  });
+  // hide/remove rooms no longer present
+  for(const [id, room] of roomByTicket){
+    if(!want.has(id)){ room.visible=false; }
+  }
+  // lane header signs
+  ensureLaneSigns();
+}
+
+let laneSigns=null;
+function ensureLaneSigns(){
+  if(laneSigns) return;
+  laneSigns = new THREE.Group(); scene.add(laneSigns);
+  LANES.forEach((lane,li)=>{
+    const x=li*LANE_W - LANE_W*1.5 + 30;
+    const s=makeLabel([{t:lane.toUpperCase()}], { fg:'#'+COLOR[lane].toString(16).padStart(6,'0'), w:260, scale:1.4 });
+    s.position.set(x, 11, -PITCH); laneSigns.add(s);
+  });
+}
+
+function updateRoom(room, t){
+  const u=room.userData; const prev=u.ticket; u.ticket=t;
+  const col = COLOR[t.status] ?? COLOR.open;
+  u.floor.material.color.setHex(col);
+  u.floor.material.emissive.setHex(col);
+  u.baseEmis = t.status==='doing'?0.25:(t.status==='blocked'?0.2:0.06);
+  u.trim.material.color.setHex(col); u.trim.material.emissive.setHex(col);
+  // refresh label if status/assignee changed
+  if(prev.status!==t.status || prev.assignee!==t.assignee){
+    room.remove(u.label); u.label.material.map.dispose(); u.label.material.dispose();
+    const shortTitle=(t.title||'').length>44 ? t.title.slice(0,42)+'…' : (t.title||'');
+    u.label=makeLabel([
+      { t: t.id.replace(/^(TK-\d+).*/, '$1') + '  ·  ' + (t.status||'').toUpperCase() },
+      { t: shortTitle, sub:true },
+      { t: '@'+(t.assignee||'unassigned') + (t.project?('   ['+t.project+']'):''), sub:true },
+    ], { scale:0.9 });
+    u.label.position.set(0,6.2,0); room.add(u.label);
+  }
+}
+
+// spread N avatars around a room center so they don't overlap
+function slotInRoom(roomPos, i, n){
+  if(n<=1) return new THREE.Vector3(roomPos.x, 0, roomPos.z+1.2);
+  const r=Math.min(2.6, 1.2+n*0.15), a=(i/n)*Math.PI*2;
+  return new THREE.Vector3(roomPos.x + Math.cos(a)*r, 0, roomPos.z + Math.sin(a)*r);
+}
+
+function placeAvatars(tickets){
+  const latest = agentLatestRoom(tickets);
+  // count agents per room for slotting
+  const perRoom = new Map();
+  const assign = []; // {name, roomId}
+  for(const [name, info] of latest){
+    const room = roomByTicket.get(info.ticketId);
+    const rid = (room && room.visible) ? info.ticketId : null;
+    assign.push({ name, rid });
+    if(rid) perRoom.set(rid, (perRoom.get(rid)||0)+1);
+  }
+  // also include DM-only agents (parked in lobby)
+  const lobby=[];
+  const idx = new Map();
+  for(const a of assign){
+    let av = avatarByName.get(a.name);
+    if(!av){ av=buildAvatar(a.name); avatarByName.set(a.name, av); avatarsGrp.add(av); }
+    if(a.rid){
+      const room=roomByTicket.get(a.rid);
+      const n=perRoom.get(a.rid); const i=idx.get(a.rid)||0; idx.set(a.rid,i+1);
+      av.userData.target.copy(slotInRoom(room.position, i, n));
+      av.userData.room=a.rid;
+    } else lobby.push(av);
+  }
+  lobby.forEach((av,i)=>{
+    av.userData.target.set(-46 + (i%10)*7, 0, 30 + Math.floor(i/10)*7);
+    av.userData.room=null;
+  });
+  return latest;
+}
+
+// ───────────────────────── live data ─────────────────────────
+async function poll(){
+  try{
+    const [tr, mr] = await Promise.all([
+      fetch('/api/tickets', {credentials:'same-origin'}),
+      fetch('/api/messages',{credentials:'same-origin'}),
+    ]);
+    if(!tr.ok||!mr.ok) throw new Error('HTTP '+tr.status+'/'+mr.status);
+    const tickets = await tr.json();
+    const messages = await mr.json();
+    document.getElementById('err').style.display='none';
+    apply(tickets, messages);
+    lastFetch = Date.now();
+  }catch(e){
+    const el=document.getElementById('err'); el.textContent='⚠ '+e.message+' — retrying'; el.style.display='block';
+  }
+}
+
+function apply(tickets, messages){
+  layout(tickets);
+  const latest = placeAvatars(tickets);
+
+  // ── flash rooms with brand-new actions, mark those agents "working" ──
+  let maxTs = lastActionTs;
+  let latestActivity = null;
+  for(const t of tickets){
+    for(const a of t.actions||[]){
+      const ts=+new Date(a.ts);
+      if(ts>lastActionTs){
+        const room=roomByTicket.get(t.id);
+        if(room) room.userData.flash = 1.0;
+        const av=avatarByName.get(a.agent);
+        if(av) av.userData.working = 2.0;
+      }
+      if(ts>=+new Date((latestActivity?.ts)||0)) latestActivity={ agent:a.agent, ts:a.ts, text:a.text, id:t.id };
+      if(ts>maxTs) maxTs=ts;
+    }
+  }
+  lastActionTs = maxTs;
+  if(latestActivity){
+    document.getElementById('ticker-msg').innerHTML =
+      `<b>@${esc(latestActivity.agent)}</b> · ${esc(latestActivity.id.replace(/^(TK-\d+).*/,'$1'))} — ${esc((latestActivity.text||'').slice(0,180))}`;
+  }
+
+  // ── DM packets: animate only messages newer than last poll ──
+  let broadcastAll = [];
+  if(seenMsgMids===null){ seenMsgMids=new Set(messages.map(m=>m.mid)); }
+  else{
+    for(const m of messages){
+      if(seenMsgMids.has(m.mid)) continue;
+      seenMsgMids.add(m.mid);
+      const from = avatarByName.get(m.from);
+      if(!from) continue;
+      const bc = (m.to==='all'||m.to==='*'||m.to==='everyone');
+      if(bc){
+        for(const [nm,av] of avatarByName){ if(nm!==m.from) spawnPacket(worldPos(from), worldPos(av), true); }
+      }else{
+        const to = avatarByName.get(m.to);
+        if(to) spawnPacket(worldPos(from), worldPos(to), false);
+      }
+    }
+  }
+
+  // HUD
+  const counts={doing:0,blocked:0,open:0,done:0};
+  for(const t of tickets) if(counts[t.status]!=null) counts[t.status]++;
+  document.getElementById('s-rooms').textContent = tickets.filter(t=>t.status!=='done').length + (showDone?Math.min(counts.done,DONE_CAP):0);
+  document.getElementById('s-agents').textContent = avatarByName.size;
+  document.getElementById('s-doing').textContent = counts.doing;
+  document.getElementById('s-blocked').textContent = counts.blocked;
+  document.getElementById('s-dms').textContent = messages.length;
+}
+
+function worldPos(av){ const v=new THREE.Vector3(); av.getWorldPosition(v); v.y=3.3; return v; }
+function esc(s){ return String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
+
+// ───────────────────────── interaction: hover tooltip ─────────────────────────
+const ray=new THREE.Raycaster(); const mouse=new THREE.Vector2(); let hover=null;
+addEventListener('pointermove', e=>{
+  mouse.x=(e.clientX/innerWidth)*2-1; mouse.y=-(e.clientY/innerHeight)*2+1;
+  hover={x:e.clientX,y:e.clientY};
+});
+function updateTip(){
+  const tip=document.getElementById('tip'); if(!hover){ tip.style.display='none'; return; }
+  ray.setFromCamera(mouse, camera);
+  const hits=ray.intersectObjects(roomsGrp.children, true);
+  const room = hits.find(h=>{ let o=h.object; while(o&&o.parent){ if(o.userData&&o.userData.ticket) return true; o=o.parent;} return false; });
+  if(!room){ tip.style.display='none'; return; }
+  let o=room.object; while(o && !(o.userData&&o.userData.ticket)) o=o.parent;
+  const t=o.userData.ticket;
+  const last=(t.actions&&t.actions[t.actions.length-1])||(t.comments&&t.comments[t.comments.length-1]);
+  tip.innerHTML=`<div class="h">${esc(t.id.replace(/^(TK-\d+).*/,'$1'))} · ${esc(t.status.toUpperCase())}</div>
+    <div>${esc(t.title||'')}</div>
+    <div class="s">@${esc(t.assignee||'unassigned')}${t.project?(' · '+esc(t.project)):''} · ${(t.actions||[]).length} actions · ${(t.comments||[]).length} comments</div>
+    ${last?`<div class="t">${esc((last.text||'').slice(0,220))}</div>`:''}`;
+  tip.style.display='block';
+  tip.style.left=Math.min(hover.x+14, innerWidth-360)+'px';
+  tip.style.top=Math.min(hover.y+14, innerHeight-160)+'px';
+}
+
+// ───────────────────────── controls (buttons) ─────────────────────────
+document.getElementById('b-labels').onclick=e=>{ showLabels=!showLabels; e.target.className=showLabels?'on':''; e.target.textContent='Labels: '+(showLabels?'on':'off'); };
+document.getElementById('b-done').onclick=e=>{ showDone=!showDone; e.target.className=showDone?'on':''; e.target.textContent='Done rooms: '+(showDone?'on':'off'); poll(); };
+document.getElementById('b-follow').onclick=e=>{ followDMs=!followDMs; e.target.className=followDMs?'on':''; e.target.textContent='Follow DMs: '+(followDMs?'on':'off'); };
+document.getElementById('b-reset').onclick=()=>{ camera.position.copy(HOME.pos); controls.target.copy(HOME.tgt); };
+
+// ───────────────────────── animate ─────────────────────────
+const clock=new THREE.Clock();
+function tick(){
+  const dt=Math.min(clock.getDelta(), 0.05); const el=clock.elapsedTime;
+
+  // avatars: walk toward target, bob, working ring
+  for(const av of avatarByName.values()){
+    const u=av.userData;
+    av.position.lerp(u.target, 1-Math.pow(0.001, dt));
+    const moving = av.position.distanceToSquared(u.target) > 0.04;
+    const bob = moving ? Math.abs(Math.sin(el*7+u.phase))*0.25 : Math.sin(el*2+u.phase)*0.06;
+    u.body.position.y = 1.7 + bob; u.head.position.y = 3.3 + bob;
+    if(moving){ const dir=u.target.clone().sub(av.position); av.rotation.y = Math.atan2(dir.x, dir.z); }
+    if(u.working>0){ u.working-=dt; }
+    const ringOn = Math.max(0, u.working>0 ? 1 : (u.room? (u.roomStatusDoing?0.7:0):0));
+    u.ring.material.opacity = (u.working>0? (0.55+0.35*Math.sin(el*10)) : 0);
+    u.ring.scale.setScalar(1 + (u.working>0? 0.15*Math.sin(el*10):0));
+    u.label.visible = showLabels;
+  }
+
+  // rooms: doing pulse + action flash
+  for(const room of roomByTicket.values()){
+    if(!room.visible) continue;
+    const u=room.userData;
+    let em=u.baseEmis;
+    if(u.ticket.status==='doing') em = 0.22 + 0.14*Math.sin(el*3 + room.position.z*0.2);
+    if(u.ticket.status==='blocked') em = 0.16 + 0.12*Math.sin(el*6);
+    if(u.flash>0){ em += u.flash*0.9; u.flash-=dt*1.1; }
+    u.floor.material.emissiveIntensity=em;
+    u.trim.material.emissiveIntensity = 0.6 + (u.flash>0?u.flash:0)*1.2;
+    u.label.visible = showLabels;
+  }
+  if(laneSigns) laneSigns.children.forEach(s=>s.visible=showLabels);
+
+  // packets
+  for(let i=packets.length-1;i>=0;i--){
+    const p=packets[i]; p.t += dt/p.dur;
+    if(p.t>=1){ scene.remove(p.core); p.core.traverse(o=>{ if(o.material)o.material.dispose?.(); if(o.geometry)o.geometry.dispose?.(); }); packets.splice(i,1);
+      // little arrival pop = flash target avatar working ring handled elsewhere; skip
+      continue; }
+    qbez(p.from, p.mid, p.to, p.t, p.core.position);
+    const s=1+0.4*Math.sin(p.t*Math.PI); p.core.scale.setScalar(s);
+  }
+  if(followDMs && packets.length){ controls.target.lerp(packets[0].core.position, 0.05); }
+
+  // HUD age
+  if(lastFetch){ document.getElementById('s-age').textContent = Math.round((Date.now()-lastFetch)/1000)+'s ago'; }
+
+  updateTip();
+  controls.update();
+  renderer.render(scene,camera);
+  requestAnimationFrame(tick);
+}
+
+// ───────────────────────── boot ─────────────────────────
+function resize(){ camera.aspect=innerWidth/innerHeight; camera.updateProjectionMatrix(); renderer.setSize(innerWidth,innerHeight); }
+addEventListener('resize', resize); resize();
+poll(); setInterval(poll, POLL_MS); tick();
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
index 2e1083f..347ec6b 100644
--- a/server.js
+++ b/server.js
@@ -1,7 +1,11 @@
 // Ticket board viewer — kanban over the shared ticket store. :9794, basic-auth admin/DW2024!, open /healthz.
 const http = require('http');
+const fs = require('fs');
+const path = require('path');
 const { tickets, STATUSES, messages } = require('./lib.js');
 
+const OFFICE_HTML = path.join(__dirname, 'office.html');
+
 const PORT = process.env.PORT || 9794;
 const AUTH = 'Basic ' + Buffer.from(process.env.TK_AUTH || 'admin:DW2024!').toString('base64');
 
@@ -64,7 +68,7 @@ function page() {
   .dm .dtk{color:#6db3f2;font-size:10px;margin-left:6px}.dm .dts{float:right;color:#5b6270;font-size:10px}
   .dm .dtx{color:#c6ccd8;margin-top:2px}
 </style>
-<h1>FLEET TICKETS<small>every agent action rides a ticket — tk new / comment / note / log / dm / inbox / reply / @mention / take / done</small></h1>
+<h1>FLEET TICKETS<small>every agent action rides a ticket — tk new / comment / note / log / dm / inbox / reply / @mention / take / done</small><a href="/office" style="float:right;color:#c9a4f2;text-decoration:none;font-size:13px;border:1px solid #2c3140;padding:4px 10px;border-radius:6px">🏢 3D Office →</a></h1>
 ${dmPanel}
 <div class="board">${STATUSES.map(s => `<div class="col col-${s}"><h2>${s} (${cols[s].length})</h2>${cols[s].map(card).join('') || '<div class="c none">empty</div>'}</div>`).join('')}</div>`;
 }
@@ -72,6 +76,12 @@ ${dmPanel}
 http.createServer((req, res) => {
   if (req.url === '/healthz') { res.writeHead(200); return res.end('ok'); }
   if (req.headers.authorization !== AUTH) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="tickets"' }); return res.end('auth'); }
+  if (req.url === '/office' || req.url === '/office.html') {
+    return fs.readFile(OFFICE_HTML, (e, buf) => {
+      if (e) { res.writeHead(500); return res.end('office view missing'); }
+      res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(buf);
+    });
+  }
   if (req.url === '/api/tickets') { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify([...tickets().values()])); }
   if (req.url === '/api/messages') { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify([...messages().values()])); }
   res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(page());

← fda28cb chore: harden DM thread-walk against cyclic re-chains (cycle  ·  back to Ticket System  ·  Add live 3D top-down office visualizer (/office): rooms=tick a5e74a9 →