← 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 →