[object Object]

← back to Ticket System

auto-save: 2026-07-27T15:21:37 (1 files) — office.html

67de8704ab4669b071378bf39f9daf7278fc854a · 2026-07-27 15:21:38 -0700 · Steve Abrams

Files touched

Diff

commit 67de8704ab4669b071378bf39f9daf7278fc854a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jul 27 15:21:38 2026 -0700

    auto-save: 2026-07-27T15:21:37 (1 files) — office.html
---
 office.html | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 54 insertions(+), 2 deletions(-)

diff --git a/office.html b/office.html
index bd33b2f..65d047f 100644
--- a/office.html
+++ b/office.html
@@ -248,6 +248,34 @@ function makeLabel(lines,{ w=520, scale=1, glow='#6db3f2' }={}){
 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(); }
 
+// ── live clock chip (repaintable canvas sprite) — one per DOING room ──────────
+function fmtDur(ms){ if(ms<0||!isFinite(ms)) ms=0; let s=Math.floor(ms/1000);
+  const h=Math.floor(s/3600); s-=h*3600; const m=Math.floor(s/60); s-=m*60;
+  const p=n=>String(n).padStart(2,'0');
+  return h>0 ? h+':'+p(m)+':'+p(s) : p(m)+':'+p(s); }
+function makeClockSprite(){ const w=340,h=64,dpr=2,cnv=document.createElement('canvas');
+  cnv.width=w*dpr; cnv.height=h*dpr; const g=cnv.getContext('2d'); g.scale(dpr,dpr);
+  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, h/40, 1); spr.userData={ cnv, g, tex, w, h, last:'' }; return spr; }
+// state: {live, dur, worker, blink} — repaints only when the visible string changes
+function drawClock(spr,st){ const key=(st.live?'L':'I')+'|'+st.dur+'|'+(st.worker||'')+'|'+(st.blink?1:0);
+  const u=spr.userData; if(key===u.last) return; u.last=key; const { g,w,h }=u;
+  const accent = st.live ? '#3ee6a0' : '#f5b74a';        // green when a live agent is on it, amber when idle
+  g.clearRect(0,0,w,h);
+  g.fillStyle='rgba(9,11,17,0.82)'; roundRect(g,0,0,w,h,14); g.fill();
+  g.strokeStyle=accent+ (st.live?'cc':'88'); g.lineWidth=1.6; roundRect(g,1,1,w-2,h-2,13); g.stroke();
+  g.textBaseline='middle';
+  // status dot (blinks while live) + label
+  g.fillStyle = st.live && !st.blink ? '#0b0d11' : accent;
+  g.beginPath(); g.arc(24,h/2,7,0,Math.PI*2); g.fill();
+  g.shadowColor=accent; g.shadowBlur=st.live?14:6;
+  g.font='800 30px ui-monospace,SFMono-Regular,Menlo,monospace'; g.fillStyle=st.live?'#8affce':'#ffd98a';
+  g.textAlign='left'; g.fillText(st.dur, 42, h/2+1);
+  g.shadowBlur=0; g.font='600 17px -apple-system,Segoe UI,sans-serif'; g.fillStyle=accent;
+  g.textAlign='right'; g.fillText(st.live?('● '+(st.worker||'working')):'idle · no live agent', w-16, h/2+1);
+  u.tex.needsUpdate=true; }
+
 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(n){ const c=new THREE.Color(); c.setHSL(hashHue(n)/360,0.7,0.6); return c; }
 const hex=n=>'#'+n.toString(16).padStart(6,'0');
@@ -293,7 +321,11 @@ function buildRoom(t){
 
   const label=roomLabel(t);
   label.position.set(0,6.6,0); grp.add(label);
-  grp.userData={ ticket:t, floor, rim, trim, mon, label, runEdge, baseEmis:floor.material.emissiveIntensity, flash:0, col };
+  // live clock chip — only shown for DOING rooms (see apply() + tick())
+  const clock=makeClockSprite(); clock.position.set(0,5.2,0); clock.visible=false; grp.add(clock);
+  grp.userData={ ticket:t, floor, rim, trim, mon, label, clock, runEdge,
+    baseEmis:floor.material.emissiveIntensity, flash:0, col,
+    workSince:+new Date(t.updated_at||t.created_at||Date.now()), worker:null, liveWork:false };
   return grp;
 }
 function roomLabel(t){
@@ -491,6 +523,21 @@ function apply(tk,msgs){
   let liveCount=0;
   for(const [nm,av] of avatarByName){ const live=(now-(lastByAgent.get(nm)||0))<LIVE_MS; av.userData.live=live; if(live) liveCount++; }
   const liveEl=document.getElementById('s-live'); if(liveEl) liveEl.textContent=liveCount;
+
+  // ── per-DOING-room work clock: anchor on the last real ACTION (work event),
+  //    and light up only when the agent who last worked it is still live. This is
+  //    what makes the ticker "see actual working agents" instead of ticking blind.
+  for(const t of tk){ const room=roomByTicket.get(t.id); if(!room) continue; const u=room.userData;
+    if(t.status!=='doing'){ u.liveWork=false; continue; }
+    let lwt=0, worker=null;
+    for(const a of t.actions||[]){ const ts=+new Date(a.ts); if(ts>lwt){ lwt=ts; worker=a.agent; } }
+    if(!lwt){ lwt=+new Date(t.updated_at||t.created_at||now); worker=t.assignee||null; }
+    // a live worker = the agent on the most-recent action is still within the heartbeat window,
+    // OR the assignee is live right now
+    const workerLive = worker && (now-(lastByAgent.get(worker)||0))<LIVE_MS;
+    const asgLive = t.assignee && (now-(lastByAgent.get(t.assignee)||0))<LIVE_MS;
+    u.workSince=lwt; u.worker=(workerLive?worker:(asgLive?t.assignee:worker));
+    u.liveWork=!!(workerLive||asgLive); }
   let maxTs=lastActionTs, latest=null;
   for(const t of tk) 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;
@@ -565,7 +612,7 @@ window.__ping=()=>B('b-ping').click(); window.__orbit=on=>{ controls.autoRotate=
 // ───────────────────────── animate ─────────────────────────
 const clock=new THREE.Clock(); const boot={ t:0, from:new THREE.Vector3(30,540,540) };
 function tick(){
-  const dt=Math.min(clock.getDelta(),0.05), el=clock.elapsedTime;
+  const dt=Math.min(clock.getDelta(),0.05), el=clock.elapsedTime, nowMs=Date.now();
 
   // cinematic fly-in
   if(boot.t<1){ boot.t=Math.min(1,boot.t+dt/2.6); const e=1-Math.pow(1-boot.t,3);
@@ -596,6 +643,11 @@ function tick(){
     u.floor.material.emissiveIntensity=em; u.rim.material.opacity=0.7+0.3*Math.sin(el*2+room.position.x);
     if(u.runEdge){ const on=showRunning&&u.ticket.status==='doing';
       u.runEdge.material.opacity=on?0.5+0.45*Math.sin(el*4+room.position.z*0.3):0; }
+    // live clock chip — ticks up from the last real action; blinks while a live agent is on it
+    if(u.clock){ const on=u.ticket.status==='doing'&&showLabels; u.clock.visible=on;
+      if(on) drawClock(u.clock,{ live:u.liveWork, dur:fmtDur(nowMs-u.workSince),
+        worker:u.worker, blink:u.liveWork&&(nowMs%1000<500) });
+      u.clock.position.y=5.2+Math.sin(el*2.2+room.position.z*0.2)*0.12; }
     u.label.visible=showLabels; }
   if(laneSigns) laneSigns.children.forEach((s,i)=>{ s.visible=showLabels; s.position.y=12+Math.sin(el*1.2+i)*0.5; });
 

← 18f29be chore: harden browser-side esc() to escape single-quote (ses  ·  back to Ticket System  ·  office board: dynamic live clock on DOING rooms (per-ticket 8e488ba →