[object Object]

← back to Ticket System

Measure ticket runtime from latest status change

7445e60cf40f4db28f805a0919f90985859b50dc · 2026-08-31 01:55:51 -0700 · Steve Abrams

Files touched

Diff

commit 7445e60cf40f4db28f805a0919f90985859b50dc
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 31 01:55:51 2026 -0700

    Measure ticket runtime from latest status change
---
 board.html  | 2 +-
 lib.js      | 4 ++--
 lib.test.js | 8 ++++++--
 3 files changed, 9 insertions(+), 5 deletions(-)

diff --git a/board.html b/board.html
index 5608d2e9..17c739ab 100644
--- a/board.html
+++ b/board.html
@@ -120,7 +120,7 @@ const $=s=>document.querySelector(s), esc=s=>String(s==null?'':s).replace(/[&<>"
 const dt=s=>{if(!s)return '—';const d=new Date(s);if(isNaN(d))return '—';return '<span title="'+esc(new Date(s).toISOString())+'">'+esc(d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}))+'</span>';};
 const agoH=s=>s?((Date.now()-new Date(s).getTime())/3600000):null;
 const elapsed=s=>{const ms=Math.max(0,Date.now()-new Date(s||Date.now()).getTime()),m=Math.floor(ms/60000),h=Math.floor(m/60),d=Math.floor(h/24);return d?`${d}d ${h%24}h`:h?`${h}h ${m%60}m`:`${m}m`;};
-const ticketCell=t=>{const short=esc(t.id.replace(/^(TK-\d+).*/,'$1')),age=elapsed(t.created_at),running=t.status==='doing';return `<span class="ticket-top"><a class="cell mono" href="#" onclick="setQ('${esc(t.id)}');return false" title="${esc(t.id)}">${short}</a><span class="ticket-runtime${running?' running':''}" title="since ${esc(t.created_at||'unknown')}">${running?'running':'age'} ${age}</span></span>`;};
+const ticketCell=t=>{const short=esc(t.id.replace(/^(TK-\d+).*/,'$1')),running=t.status==='doing',since=running?(t.status_since||t.created_at):t.created_at,age=elapsed(since);return `<span class="ticket-top"><a class="cell mono" href="#" onclick="setQ('${esc(t.id)}');return false" title="${esc(t.id)}">${short}</a><span class="ticket-runtime${running?' running':''}" title="since ${esc(since||'unknown')}">${running?'running':'age'} ${age}</span></span>`;};
 // ── DTD run-now verdicts (fetched from /api/dtd): { TK-id: {verdict,yes,no,confidence} } ──
 let DTD={running:false,verdicts:null};
 const verdictOf=t=>{const v=DTD.verdicts&&DTD.verdicts.tickets&&DTD.verdicts.tickets[t.id];return v||null;};
diff --git a/lib.js b/lib.js
index 3fd110ef..8cc4b717 100644
--- a/lib.js
+++ b/lib.js
@@ -44,14 +44,14 @@ function tickets() {
   const map = new Map();
   for (const ev of readEvents()) {
     if (ev.type === 'create') {
-      map.set(ev.id, { id: ev.id, title: ev.title, project: ev.project || '', agent: ev.agent || '', assignee: ev.agent || '', status: 'open', created_at: ev.ts, updated_at: ev.ts, comments: [], actions: [] });
+      map.set(ev.id, { id: ev.id, title: ev.title, project: ev.project || '', agent: ev.agent || '', assignee: ev.agent || '', status: 'open', status_since: ev.ts, created_at: ev.ts, updated_at: ev.ts, comments: [], actions: [] });
       if (ev.body) map.get(ev.id).comments.push({ ts: ev.ts, agent: ev.agent || '', kind: 'comment', text: ev.body });
     } else {
       const t = map.get(ev.id); if (!t) continue; t.updated_at = ev.ts;
       if (ev.correlation_id) t.last_correlation_id = ev.correlation_id;
       if (ev.type === 'comment') t.comments.push({ ts: ev.ts, agent: ev.agent || '', kind: ev.kind || 'comment', text: ev.text, correlation_id: ev.correlation_id || '' });
       else if (ev.type === 'action') t.actions.push({ ts: ev.ts, agent: ev.agent || '', text: ev.text, correlation_id: ev.correlation_id || '' });
-      else if (ev.type === 'status' && STATUSES.includes(ev.status)) t.status = ev.status;
+      else if (ev.type === 'status' && STATUSES.includes(ev.status)) { t.status = ev.status; t.status_since = ev.ts; }
       else if (ev.type === 'assign') t.assignee = ev.agent || '';
     }
   }
diff --git a/lib.test.js b/lib.test.js
index 3e50ee38..79eafd8d 100644
--- a/lib.test.js
+++ b/lib.test.js
@@ -43,10 +43,14 @@ test('append + read round-trip and create folds to {open,title,project,agent}',
 // ── 2. status transitions, reopen, unknown-status ignored, STATUSES contract ──
 test('status transitions open→doing→blocked→done→stopped, then reopen', () => {
   const id = 'TK-10002-transitions';
-  append({ ts: iso(), type: 'create', id, title: 'Transitions', agent: 'a' });
+  const createdAt = iso(-10_000);
+  append({ ts: createdAt, type: 'create', id, title: 'Transitions', agent: 'a' });
+  assert.equal(tickets().get(id).status_since, createdAt);
   for (const s of ['doing', 'blocked', 'done', 'stopped']) {
-    append({ ts: iso(), type: 'status', id, status: s });
+    const statusAt = iso();
+    append({ ts: statusAt, type: 'status', id, status: s });
     assert.equal(tickets().get(id).status, s);
+    assert.equal(tickets().get(id).status_since, statusAt);
   }
   append({ ts: iso(), type: 'status', id, status: 'open' }); // reopen from stopped
   assert.equal(tickets().get(id).status, 'open');

← 7992a04f Show ticket runtime beside ticket number  ·  back to Ticket System  ·  keep Codex ticket loop running overnight daa6624c →