[object Object]

← back to Ticket System

office board: dynamic live clock on DOING rooms (per-ticket liveness, 3-state, idle-based overdue)

8e488baea8254aa379bfe3d5d6f7d9b04b9458ed · 2026-07-27 15:31:11 -0700 · Steve Abrams

Refined via DTD panel + Contrarian gate (2 rounds):
- per-ticket liveness (not global heartbeat) so a room only reads live when
  an agent is actually working IT
- big numeral = time-in-doing; sub = last-active Xs ago
- 3 states GREEN live / AMBER idle / GREY orphan; RED numeral when idle >2h (stalled)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 8e488baea8254aa379bfe3d5d6f7d9b04b9458ed
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jul 27 15:31:11 2026 -0700

    office board: dynamic live clock on DOING rooms (per-ticket liveness, 3-state, idle-based overdue)
    
    Refined via DTD panel + Contrarian gate (2 rounds):
    - per-ticket liveness (not global heartbeat) so a room only reads live when
      an agent is actually working IT
    - big numeral = time-in-doing; sub = last-active Xs ago
    - 3 states GREEN live / AMBER idle / GREY orphan; RED numeral when idle >2h (stalled)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 office.html | 89 ++++++++++++++++++++++++++++++++++++++++---------------------
 1 file changed, 58 insertions(+), 31 deletions(-)

diff --git a/office.html b/office.html
index 65d047f..75ce98a 100644
--- a/office.html
+++ b/office.html
@@ -249,31 +249,47 @@ function roundRect(g,x,y,w,h,r){ g.beginPath(); g.moveTo(x+r,y); g.arcTo(x+w,y,x
   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 ──────────
+// DTD-committed design: big number = time-in-doing (duration); small line = "last
+// active Xs ago"; 3 states GREEN(live)/AMBER(idle)/GREY(orphan); RED numeral if overdue.
 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');
+function fmtAgo(ms){ if(ms<0||!isFinite(ms)) return '—'; const s=Math.floor(ms/1000);
+  if(s<60) return s+'s'; const m=Math.floor(s/60); if(m<60) return m+'m';
+  const h=Math.floor(m/60); return h<24 ? h+'h' : Math.floor(h/24)+'d'; }
+const CLK = { live:'#3ee6a0', idle:'#f5b74a', orphan:'#586074' };  // state accents
+function makeClockSprite(){ const w=360,h=76,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);
+// st: {state:'live'|'idle'|'orphan', dur, ago, worker, blink, overdue}
+function drawClock(spr,st){
+  const key=st.state+'|'+st.dur+'|'+st.ago+'|'+(st.worker||'')+'|'+(st.blink?1:0)+'|'+(st.overdue?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
+  const accent=CLK[st.state]||CLK.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);
+  g.fillStyle='rgba(9,11,17,0.84)'; roundRect(g,0,0,w,h,15); g.fill();
+  g.strokeStyle=accent+(st.state==='live'?'cc':'88'); g.lineWidth=1.6; roundRect(g,1,1,w-2,h-2,14); g.stroke();
+  // status dot — blinks only while live; solid otherwise
+  g.fillStyle = (st.state==='live' && st.blink) ? '#0b0d11' : accent;
+  g.beginPath(); g.arc(26,h/2,7.5,0,Math.PI*2); g.fill();
+  // big duration numeral (RED when overdue >2h) — the "how long in doing" signal
+  g.textBaseline='middle'; g.textAlign='left';
+  g.shadowColor=st.overdue?'#ff6b7a':accent; g.shadowBlur=st.state==='live'?14:8;
+  g.fillStyle=st.overdue?'#ff8a95':(st.state==='live'?'#8affce':(st.state==='orphan'?'#98a0b2':'#ffd98a'));
+  g.font='800 32px ui-monospace,SFMono-Regular,Menlo,monospace'; g.fillText(st.dur, 46, h/2+1);
+  // right column: worker/state label over "last active Xs ago" — the "who/now" signal
+  g.shadowBlur=0; g.textAlign='right';
+  g.font='600 17px -apple-system,Segoe UI,sans-serif'; g.fillStyle=accent;
+  const top = st.state==='live' ? ('● '+(st.worker||'working'))
+            : st.state==='orphan' ? 'no agent' : (st.worker?('@'+st.worker):'unassigned')+' · idle';
+  g.fillText(top, w-16, h*0.34);
+  g.font='500 15px -apple-system,Segoe UI,sans-serif'; g.fillStyle='#8a93a6';
+  const sub = st.state==='orphan' ? 'no actor yet'
+            : st.state==='live' ? ('last '+st.ago+' ago') : ('last active '+st.ago+' ago');
+  g.fillText(sub, w-16, h*0.66);
   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; }
@@ -325,7 +341,9 @@ function buildRoom(t){
   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 };
+    workSince:+new Date(t.created_at||t.updated_at||Date.now()),
+    lastActionTs:+new Date(t.updated_at||t.created_at||Date.now()),
+    worker:null, liveWork:false, orphan:false };
   return grp;
 }
 function roomLabel(t){
@@ -524,20 +542,26 @@ function apply(tk,msgs){
   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.
+  // ── per-DOING-room work clock (DTD-committed) ─────────────────────────────
+  //  Liveness is scoped to activity ON THIS TICKET (not the agent's global
+  //  heartbeat) so a room only reads "live" when someone is actually working IT.
+  //  Big number = time in doing (earliest action ≈ doing-entry); sub = last-active.
   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 lwt=0, ewt=Infinity, worker=null, workerHere=0, asgHere=0;
+    for(const a of t.actions||[]){ const ts=+new Date(a.ts);
+      if(ts>lwt){ lwt=ts; worker=a.agent; }                 // most-recent action here → candidate worker
+      if(ts<ewt) ewt=ts;                                    // earliest action here → doing-entry proxy
+      if(a.agent===t.assignee && ts>asgHere) asgHere=ts; }
+    if(worker) for(const a of t.actions||[]) if(a.agent===worker){ const ts=+new Date(a.ts); if(ts>workerHere) workerHere=ts; }
+    if(!lwt) lwt=+new Date(t.updated_at||t.created_at||now);
+    if(!isFinite(ewt)) ewt=+new Date(t.created_at||t.updated_at||now);
+    const workerLive = worker && (now-workerHere)<LIVE_MS;  // worker acted on THIS ticket within 8min
+    const asgLive    = t.assignee && (now-asgHere)<LIVE_MS; // assignee acted on THIS ticket within 8min
+    u.workSince=ewt; u.lastActionTs=lwt;
+    u.worker = asgLive ? t.assignee : (workerLive ? worker : (t.assignee||worker));
+    u.liveWork = !!(workerLive||asgLive);
+    u.orphan = !worker && !t.assignee; }                    // nobody owns it and nobody's ever acted
   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;
@@ -643,10 +667,13 @@ 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
+    // live clock chip — ticks every frame; state + colour reflect ACTUAL work on this ticket
     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) });
+      if(on){ const durMs=nowMs-u.workSince, idleMs=nowMs-u.lastActionTs;
+        const state=u.orphan?'orphan':(u.liveWork?'live':'idle');
+        // OVERDUE alarm keys off IDLE time (stalled), not total age — a live room never reds
+        drawClock(u.clock,{ state, dur:fmtDur(durMs), ago:fmtAgo(idleMs),
+          worker:u.worker, blink:(nowMs%1000)<500, overdue:idleMs>2*3600*1000 }); }
       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; });

← 67de870 auto-save: 2026-07-27T15:21:37 (1 files) — office.html  ·  back to Ticket System  ·  office clock v4: legibility pass (taller chip, halo dot, tru 6c74133 →