← back to Ticket Action Viewer
Ticket Action Viewer: RUN NOW flips card to 'doing now' + live start-time timer
a5da5f6ef73ff32f6fd3e1657269eee480c9dec0 · 2026-08-10 08:10:49 -0700 · Steve Abrams
- runNow() reloads the row after launch so status flips blocked→doing now
- status cell shows a live ⏱ timer (ticks every 1s) + '▶ started <time>'
- start time sourced by priority: explicit click-stamp (runstate.json) →
true doing-entry from the shared event log → now; backfill never frozen
- server: stamp start on RUN NOW/Take, prune on leave-doing, expose startedAt
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M .gitignoreM public/index.htmlM server.js
Diff
commit a5da5f6ef73ff32f6fd3e1657269eee480c9dec0
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Aug 10 08:10:49 2026 -0700
Ticket Action Viewer: RUN NOW flips card to 'doing now' + live start-time timer
- runNow() reloads the row after launch so status flips blocked→doing now
- status cell shows a live ⏱ timer (ticks every 1s) + '▶ started <time>'
- start time sourced by priority: explicit click-stamp (runstate.json) →
true doing-entry from the shared event log → now; backfill never frozen
- server: stamp start on RUN NOW/Take, prune on leave-doing, expose startedAt
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
.gitignore | 2 ++
public/index.html | 17 +++++++++++++--
server.js | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++-----
3 files changed, 76 insertions(+), 7 deletions(-)
diff --git a/.gitignore b/.gitignore
index ff2422c..180a384 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,5 @@ node_modules/
tmp/
*.log
.DS_Store
+data/runstate.json
+.playwright-mcp/
diff --git a/public/index.html b/public/index.html
index b3186d9..dbc065e 100644
--- a/public/index.html
+++ b/public/index.html
@@ -23,6 +23,10 @@
.ttl{color:var(--ink)}.proj{color:var(--mut);font-size:11px}
.st{font-size:11px;font-weight:700;padding:2px 8px;border-radius:999px;text-transform:uppercase;letter-spacing:.5px}
.st.open{background:#173a2a;color:var(--ok)}.st.doing{background:#3a3417;color:var(--warn)}.st.blocked{background:#3a1e1a;color:var(--gate)}
+ .timer{display:inline-flex;align-items:center;gap:5px;margin-top:5px;font:700 12px/1 ui-monospace,Menlo,monospace;color:var(--warn);font-variant-numeric:tabular-nums}
+ .timer .tdot{width:7px;height:7px;border-radius:50%;background:var(--warn);box-shadow:0 0 0 0 var(--warn);animation:tpulse 1.6s ease-out infinite}
+ @keyframes tpulse{0%{box-shadow:0 0 0 0 #f2b23a88}70%{box-shadow:0 0 0 6px #f2b23a00}100%{box-shadow:0 0 0 0 #f2b23a00}}
+ .startedat{margin-top:3px;color:var(--mut);font-size:10px;white-space:nowrap}
.owner{color:var(--mut);font-size:12px}
.next{color:#cfd6e2;max-width:340px}
.needs{max-width:320px;color:var(--mut);font-size:12px}
@@ -76,7 +80,7 @@ function render(){
<td class="rank">${i+1}</td>
<td><div class="tid">${t.shortId}</div><div class="ttl">${esc(t.title)}</div></td>
<td class="proj">${esc(t.project)}</td>
- <td><span class="st ${t.status}">${t.status}</span></td>
+ <td><span class="st ${t.status}">${t.status==='doing'?'doing now':t.status}</span>${t.status==='doing'&&t.startedAt?`<div class="timer" data-start="${esc(t.startedAt)}"><span class="tdot"></span><span class="tval">⏱ …</span></div><div class="startedat">▶ started ${fmtStart(t.startedAt)}</div>`:''}</td>
<td class="owner">${esc(t.owner)}</td>
<td class="next">${esc(t.nextStep)}${t.note?`<div class="proj" style="margin-top:4px">↳ ${esc(t.note)}</div>`:''}</td>
<td><div class="needs ${t.gated?'gated':''}">${t.gated?'🔒 ':''}${esc(t.needs||'—')}</div></td>
@@ -88,8 +92,17 @@ function render(){
<button class="k done" onclick="act('${t.fullId}','done')">Done</button>
<button class="k block" onclick="act('${t.fullId}','block')">Block</button>
</div></td></tr>`).join('');
+ tickTimers(); // paint elapsed immediately so the timer doesn't blank for the first second
}
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
+// ── live "doing now" timer: elapsed since the ticket entered doing, ticking every 1s ──
+function fmtElapsed(ms){if(ms<0)ms=0;const s=Math.floor(ms/1000),d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60),ss=s%60,p=n=>String(n).padStart(2,'0');
+ if(d)return d+'d '+p(h)+':'+p(m); // ≥1 day: 10d 04:07
+ if(h)return h+':'+p(m)+':'+p(ss); // ≥1 hour: 3:04:07
+ return m+':'+p(ss);} // <1 hour: 4:07
+function fmtStart(iso){try{return new Date(iso).toLocaleTimeString([],{hour:'numeric',minute:'2-digit'});}catch(e){return '';}}
+function tickTimers(){const now=Date.now();document.querySelectorAll('.timer[data-start]').forEach(elm=>{const st=Date.parse(elm.dataset.start);const v=elm.querySelector('.tval');if(v)v.textContent='⏱ '+fmtElapsed(now-st);});}
+setInterval(tickTimers,1000);
async function detail(id,sid){el('mtitle').textContent=sid;el('mbody').textContent='loading…';el('modal').style.display='flex';
const r=await fetch('/api/ticket?id='+encodeURIComponent(id));const d=await r.json();el('mbody').textContent=d.out||'(no output)';}
function closeM(){el('modal').style.display='none';}
@@ -97,7 +110,7 @@ async function runNow(id,title,project){
if(!confirm('RUN '+id.match(/^TK-\d+/)[0]+' in a NEW iTerm2 window? A fresh Claude session will take it and start work.'))return;
toast('opening iTerm2…');
const r=await fetch('/api/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,title,project})});
- const d=await r.json();toast(d.ok?('▶ '+d.out):('✗ '+d.out.slice(0,90)));}
+ const d=await r.json();toast(d.ok?('▶ '+d.out):('✗ '+d.out.slice(0,90)));if(d.ok)load();} // reload → row flips to "doing now" + timer starts
async function act(id,action){if((action==='done'||action==='block')&&!confirm(`${action} ${id.match(/^TK-\\d+/)[0]}?`))return;
const r=await fetch('/api/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,action})});
const d=await r.json();toast(d.ok?`✓ ${action} ok`:`✗ ${d.out.slice(0,80)}`);load();}
diff --git a/server.js b/server.js
index d5b083d..5cdf472 100644
--- a/server.js
+++ b/server.js
@@ -10,6 +10,40 @@ const PORT = process.env.PORT || 9971;
const AGENT = process.env.TK_AGENT || 'cre-agent';
const USER = 'admin', PASS = 'DW2024!';
const ANALYSIS = path.join(__dirname, 'data', 'analysis.json');
+const RUNSTATE = path.join(__dirname, 'data', 'runstate.json');
+
+// ── run-state: when a ticket entered "doing", so the viewer can show a live timer ──
+// Map of TK-#### → ISO start time. Stamped at RUN NOW / Take click, auto-backfilled
+// for any doing ticket, and pruned the moment a ticket leaves "doing".
+function loadRun() { try { return JSON.parse(fs.readFileSync(RUNSTATE, 'utf8')); } catch { return {}; } }
+function saveRun(o) { try { fs.writeFileSync(RUNSTATE, JSON.stringify(o)); } catch (_) {} }
+// Idempotent: the first start wins, so re-clicking RUN NOW never resets the clock.
+function stampStart(shortId) { if (!shortId) return null; const r = loadRun(); if (!r[shortId]) { r[shortId] = new Date().toISOString(); saveRun(r); } return r[shortId]; }
+function clearStart(shortId) { if (!shortId) return; const r = loadRun(); if (r[shortId]) { delete r[shortId]; saveRun(r); } }
+
+// True "entered doing" time per ticket, derived from the shared append-only event log
+// (so a ticket that's been doing for hours shows an accurate timer, not "just now").
+// Keyed by short id (TK-####); value = ts of the most recent *contiguous* doing entry.
+// Cached 5s so N /api/tickets polls don't each re-scan the multi-MB log.
+const EVENTS_LOG = process.env.TK_EVENTS || path.join(process.env.HOME, '.claude', 'tickets', 'events.jsonl');
+let _doingCache = { ts: 0, map: {} };
+function doingSinceMap() {
+ if (Date.now() - _doingCache.ts < 5000) return _doingCache.map;
+ const cur = {}; // shortId → current status ts while doing, else absent
+ try {
+ for (const line of fs.readFileSync(EVENTS_LOG, 'utf8').split('\n')) {
+ if (!line || line.indexOf('"status"') === -1) continue;
+ let ev; try { ev = JSON.parse(line); } catch { continue; }
+ if (ev.type !== 'status' || !ev.id) continue;
+ const short = (String(ev.id).match(/^TK-[0-9]+/) || [''])[0];
+ if (!short) continue;
+ if (ev.status === 'doing') { if (!cur[short]) cur[short] = ev.ts; } // enter doing → stamp (keep first of the run)
+ else delete cur[short]; // any other status ends the doing run
+ }
+ } catch (_) {}
+ _doingCache = { ts: Date.now(), map: cur };
+ return cur;
+}
function tk(args) {
return new Promise((resolve) => {
@@ -44,8 +78,24 @@ async function getTickets() {
nextStep: a.nextStep ?? derive({ status }).nextStep,
gated: a.gated ?? derive({ status }).gated,
needs: a.needs ?? derive({ status }).needs,
- note: a.note || '' });
+ note: a.note || '', startedAt: null });
+ }
+ // Start time per doing ticket, by priority:
+ // 1. explicit click-stamp from runstate (RUN NOW / Take — exact click moment)
+ // 2. true doing-entry from the shared event log (accurate for long-running tickets)
+ // 3. now (last-resort so a doing ticket always has a ticking timer)
+ // NOTE: backfill (2/3) is computed fresh every load and never written back — only explicit
+ // click-stamps live in runstate, so a stale poll can't freeze a wrong start time.
+ const run = loadRun();
+ const doing = doingSinceMap();
+ const doingIds = new Set();
+ for (const t of tickets) {
+ if (t.status === 'doing') { t.startedAt = run[t.shortId] || doing[t.shortId] || new Date().toISOString(); doingIds.add(t.shortId); }
}
+ // Prune click-stamps for anything that has since left doing (done/blocked/reopened).
+ let pruned = false;
+ for (const k of Object.keys(run)) if (!doingIds.has(k)) { delete run[k]; pruned = true; }
+ if (pruned) saveRun(run);
tickets.sort((x, y) => y.priority - x.priority || x.shortId.localeCompare(y.shortId));
return tickets;
}
@@ -85,7 +135,8 @@ const server = http.createServer(async (req, res) => {
// is assigned to me and stamped the moment RUN NOW is pressed — no race, no double-work.
const when = new Date().toLocaleString('en-US', { timeZone: 'America/Los_Angeles',
year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
- await tk(['take', p.id]);
+ const startedAt = stampStart(shortId); // start the live timer at the click, before the window boots
+ await tk(['take', p.id]); // tk take also flips status → doing (the "doing now" state)
await tk(['log', p.id, `▶ STARTED ${when} PT — RESERVED for ${AGENT} + launched in a new iTerm2 window (RUN NOW)`]);
const sh = path.join('/tmp', `tav-run-${shortId}.sh`);
const N = Math.max(2, Math.min(8, parseInt(p.agents, 10) || 4)); // fan-out width (2..8, default 4)
@@ -103,18 +154,20 @@ const server = http.createServer(async (req, res) => {
fs.chmodSync(sh, 0o755);
const osa = `tell application "iTerm2"\n activate\n create window with default profile\n tell current session of current window\n write text "clear; echo '▶ RUN ${shortId} — ${title.replace(/'/g, '')}'; echo 'dir: ${dir}'; bash ${sh}"\n end tell\nend tell`;
return execFile('osascript', ['-e', osa], (err, so, se) =>
- json(res, 200, { ok: !err, out: err ? String(se || err) : `Opened iTerm2 window running ${shortId} in ${dir}`, dir }));
+ json(res, 200, { ok: !err, out: err ? String(se || err) : `Opened iTerm2 window running ${shortId} in ${dir}`, dir, startedAt }));
}
if (u.pathname === '/api/action' && req.method === 'POST') {
let p; try { p = JSON.parse(await body(req)); } catch { return json(res, 400, { ok: false, out: 'bad json' }); }
const { id, action, text } = p;
+ const short = (String(id || '').match(/^TK-[0-9]+/) || [''])[0];
// TAKE = assign to me AND stamp the start date+time into the ticket log.
if (action === 'take') {
- const r1 = await tk(['take', id]);
+ const startedAt = stampStart(short); // start the live timer at the click
+ const r1 = await tk(['take', id]); // tk take also flips status → doing (the "doing now" state)
const when = new Date().toLocaleString('en-US', { timeZone: 'America/Los_Angeles',
year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
const r2 = await tk(['log', id, `▶ STARTED ${when} PT — taken by ${AGENT} via Ticket Action Viewer`]);
- return json(res, 200, { ok: r1.ok && r2.ok, out: r1.out + '\n' + r2.out, started: when });
+ return json(res, 200, { ok: r1.ok && r2.ok, out: r1.out + '\n' + r2.out, started: when, startedAt });
}
let args;
if (action === 'done') args = ['done', id];
@@ -122,6 +175,7 @@ const server = http.createServer(async (req, res) => {
else if (action === 'log') args = ['log', id, text || ''];
else return json(res, 400, { ok: false, out: 'unknown action' });
const r = await tk(args);
+ if (action === 'done' || action === 'block') clearStart(short); // leaving doing → stop the timer
return json(res, 200, r);
}
res.writeHead(404); res.end('nf');
← e52db3d 5x: report — 2 consecutive clean sweeps (fixed favicon-404 c
·
back to Ticket Action Viewer
·
Viewer: start stamp shows date + time (was time-only); full 06752db →