← 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+'