[object Object]

← back to Ticket System

Add priority ranking + per-ticket ratings to tk board (mirror :9801 scoreGate)

cf9ad290ff5fb45ba66925cadb5f22fcc695a829 · 2026-08-18 14:38:49 -0700 · Steve Abrams

- server.js: scoreTicket() computes 0-5 value/urgency/ease/safety + composite
  priority (value*2.4+urgency*2.4+ease*1.0+safety*0.4) + tier (high>=26/med>=18/low)
  for open/doing/blocked tickets; withRanking() sorts by priority desc and
  assigns rank #1..N; injected into /api/tickets payload.
- board.html: rank badge (#N, gold #1), tier chip (🔴/🟡/⚪), 4 rating bars
  columns under a new 'Priority' column group; Priority sort made default.
  Existing columns/features intact (new cols default ON for old stored sets).

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

Files touched

Diff

commit cf9ad290ff5fb45ba66925cadb5f22fcc695a829
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Aug 18 14:38:49 2026 -0700

    Add priority ranking + per-ticket ratings to tk board (mirror :9801 scoreGate)
    
    - server.js: scoreTicket() computes 0-5 value/urgency/ease/safety + composite
      priority (value*2.4+urgency*2.4+ease*1.0+safety*0.4) + tier (high>=26/med>=18/low)
      for open/doing/blocked tickets; withRanking() sorts by priority desc and
      assigns rank #1..N; injected into /api/tickets payload.
    - board.html: rank badge (#N, gold #1), tier chip (🔴/🟡/⚪), 4 rating bars
      columns under a new 'Priority' column group; Priority sort made default.
      Existing columns/features intact (new cols default ON for old stored sets).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 board.html |  27 +++++++++++++++-
 server.js  | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 2 files changed, 129 insertions(+), 2 deletions(-)

diff --git a/board.html b/board.html
index 3bdb0130..5f3e1124 100644
--- a/board.html
+++ b/board.html
@@ -46,6 +46,18 @@
   .vb small{color:var(--faint)}
   .who{color:#c9a4f2;cursor:pointer} .proj{color:var(--mut);cursor:pointer}
   .num{color:var(--mut);text-align:right}
+  /* priority ranking + ratings (mirrors :9801 approvals viewer) */
+  .rank{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:20px;padding:0 5px;border-radius:6px;
+    font-weight:700;font-size:11px;background:#1b2230;border:1px solid var(--line);color:var(--ink)}
+  .rank.r1{background:#3a2f12;border-color:#7a5f1f;color:#f0c65a}
+  .tier{display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:1px 8px;border-radius:20px;border:1px solid var(--line)}
+  .tier.high{color:#f0857d;border-color:#7a3b40}.tier.med{color:#f0c65a;border-color:#7a5f1f}.tier.low{color:var(--faint)}
+  .prio{font-weight:700;color:var(--gold);text-align:right}
+  .bars{display:inline-grid;grid-template-columns:auto 1fr;gap:1px 5px;align-items:center;font-size:9px;min-width:118px}
+  .bars .bl{color:var(--faint);letter-spacing:.02em;text-transform:uppercase}
+  .bars .bt{position:relative;height:6px;background:#232936;border-radius:3px;overflow:hidden}
+  .bars .bf{position:absolute;left:0;top:0;bottom:0;border-radius:3px}
+  .bf.v{background:#34d399}.bf.u{background:#e06c75}.bf.e{background:#6db3f2}.bf.s{background:#C8A24B}
   .thread .c{font-size:12px;margin:4px 0;color:#c6ccd8} .thread .c i{color:var(--mut);font-style:normal;font-size:10px;margin:0 4px}
   .c.k-note{color:#e8d48b}.c.k-action{color:#8fd49a}.c.k-win{color:#34d399}.c.k-challenge{color:#e06c75}.c.k-cody{color:#f59e0b}.c.none{color:var(--faint)}
   .cts{color:var(--faint);font-size:10px;margin-left:6px}
@@ -112,8 +124,21 @@ function verdictCell(t){const v=verdictOf(t);if(!v)return '<span class="vb none"
   const cls=v.verdict==='yes'?'yes':v.verdict==='no'?'no':'unknown';
   const face=v.verdict==='yes'?'✅ YES':v.verdict==='no'?'⛔ no':'? unk';
   return `<span class="vb ${cls}" title="${esc(v.voters?v.voters.join(' '):'')}">${face} <small>${v.yes}✓/${v.no}✗</small></span>`;}
+// ── priority ranking + ratings (server-computed on /api/tickets, mirrors :9801) ──
+const TIERFACE={high:'🔴 high',med:'🟡 med',low:'⚪ low'};
+function rankCell(t){if(t.rank==null)return '<span class="num" style="opacity:.4">—</span>';
+  return `<span class="rank${t.rank===1?' r1':''}" title="priority rank ${t.rank}">#${t.rank}</span>`;}
+function tierCell(t){if(!t.tier)return '<span class="tier low">—</span>';
+  return `<span class="tier ${t.tier}" title="priority ${t.priority}">${TIERFACE[t.tier]||t.tier}</span>`;}
+function barsCell(t){const r=t.ratings||{};if(!t.tier)return '<span style="opacity:.35;font-size:10px">—</span>';
+  const row=(cls,lab,v)=>`<span class="bl">${lab}</span><span class="bt" title="${lab} ${v||0}/5"><span class="bf ${cls}" style="width:${(v||0)/5*100}%"></span></span>`;
+  return `<span class="bars">${row('v','Val',r.value)}${row('u','Urg',r.urgency)}${row('e','Ease',r.ease)}${row('s','Safe',r.safety)}</span>`;}
 // column defs — order here is the default order; every field sortable; g=group for the toggle menu
 const COLS=[
+ {k:'rank',  l:'#',      g:'Priority', w:52,  def:1, cell:t=>rankCell(t), raw:t=>t.rank==null?9e9:t.rank},
+ {k:'tier',  l:'Tier',   g:'Priority', w:86,  def:1, cell:t=>tierCell(t), raw:t=>({high:3,med:2,low:1}[t.tier]||0)},
+ {k:'priority',l:'Prio', g:'Priority', w:64,  def:1, cell:t=>`<span class="prio">${t.priority!=null?t.priority.toFixed(1):'—'}</span>`, raw:t=>t.priority||0},
+ {k:'ratings',l:'Ratings',g:'Priority',w:150, def:1, cell:t=>barsCell(t), raw:t=>t.priority||0},
  {k:'id',    l:'Ticket', g:'Core', w:150, def:1, cell:t=>`<a class="cell mono" href="#" onclick="setQ('${esc(t.id)}');return false" title="${esc(t.id)}">${esc(t.id.replace(/^(TK-\d+).*/,'$1'))}</a>`, raw:t=>t.id},
  {k:'status',l:'Status', g:'Core', w:96,  def:1, cell:t=>`<span class="stpill s-${t.status}"><span class="dot" style="background:currentColor"></span>${t.status==='stopped'?'TicketStopped':t.status}</span>`, raw:t=>t.status},
  {k:'runnow',l:'Run now?',g:'Core',w:118, def:1, cell:t=>verdictCell(t), raw:t=>verdictRank(t)},
@@ -131,7 +156,7 @@ function lastTs(t){let ms=+new Date(t.updated_at||t.created_at||0);for(const a o
 function lastText(t){const acts=(t.actions||[]).slice().sort((a,b)=>+new Date(b.ts)-+new Date(a.ts));if(acts[0])return acts[0].text;const cs=(t.comments||[]).slice().sort((a,b)=>+new Date(b.ts)-+new Date(a.ts));return cs[0]?cs[0].text:'';}
 const CK=COLS.map(c=>c.k);
 const LS=(k,d)=>{try{return JSON.parse(localStorage.getItem(k))??d}catch(e){return d}}, save=(k,v)=>{try{localStorage.setItem(k,JSON.stringify(v))}catch(e){}};
-let DATA=[], q='', sortKey='age', sortDir=-1;
+let DATA=[], q='', sortKey='priority', sortDir=-1;
 let VIS=LS('tkCols',null)||Object.fromEntries(COLS.map(c=>[c.k,!!c.def]));
 for(const c of COLS)if(!(c.k in VIS))VIS[c.k]=!!c.def; // new cols (runnow) default ON even with an old stored set
 let ORDER=(Array.isArray(LS('tkOrder',null))?LS('tkOrder',null):[]).filter(k=>CK.includes(k)); for(const k of CK)if(!ORDER.includes(k))ORDER.push(k);
diff --git a/server.js b/server.js
index 84397dce..bf306d31 100644
--- a/server.js
+++ b/server.js
@@ -55,6 +55,108 @@ const AUTH = 'Basic ' + Buffer.from(process.env.TK_AUTH || 'admin:DW2024!').toSt
 
 const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
 
+// ── priority ranking + per-ticket ratings (mirrors the :9801 approvals viewer scoreGate, TK-10695) ──
+// For each OPEN/DOING/BLOCKED ticket compute 0-5 ratings + a composite priority + tier.
+// Same weighting as gated-queue-runner/server.js scoreGate: value*2.4 + urgency*2.4 + ease*1.0 + safety*0.4.
+function ticketText(t) {
+  // full corpus we score over: title + every comment + every action
+  return [t.title, ...(t.comments || []).map(c => c.text), ...(t.actions || []).map(a => a.text)].join('\n');
+}
+function nearestDeadlineDays(body) {
+  const now = Date.now(); let best = null;
+  const iso = body.match(/\b20\d\d-\d\d-\d\d\b/g) || [];
+  const named = body.match(/\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2},?\s+20\d\d\b/gi) || [];
+  for (const s of [...iso, ...named]) {
+    const t = Date.parse(s); if (isNaN(t)) continue;
+    const d = Math.round((t - now) / 86400000);
+    if (d >= -3 && (best === null || d < best)) best = d;
+  }
+  if (/\b(today|due today|expires? today)\b/i.test(body)) best = best === null ? 0 : Math.min(best, 0);
+  return best;
+}
+function parseMaxDollars(body) {
+  let max = 0;
+  for (const m of body.matchAll(/\$\s?([\d,]+(?:\.\d+)?)\s*([kKmM])?/g)) {
+    let n = parseFloat(m[1].replace(/,/g, '')); if (isNaN(n)) continue;
+    if (m[2] && /[kK]/.test(m[2])) n *= 1e3; if (m[2] && /[mM]/.test(m[2])) n *= 1e6;
+    if (n > max) max = n;
+  }
+  return max;
+}
+function lastActivityMs(t) {
+  let ms = +new Date(t.updated_at || t.created_at || 0);
+  for (const a of (t.actions || [])) ms = Math.max(ms, +new Date(a.ts));
+  for (const c of (t.comments || [])) ms = Math.max(ms, +new Date(c.ts));
+  return ms || Date.now();
+}
+function scoreTicket(t) {
+  const b = ticketText(t);
+  const now = Date.now();
+  const money = parseMaxDollars(b);
+  const days = nearestDeadlineDays(b);
+  const idleH = (now - lastActivityMs(t)) / 3600000;                 // recency of last activity (stale = higher need)
+  const ageDays = (now - +new Date(t.created_at || now)) / 86400000; // how long it's been open
+
+  // 💰 value/impact: dollars (log) OR project/keyword stakes, whichever higher
+  let value = money >= 1e5 ? 5 : money >= 1e4 ? 4 : money >= 1e3 ? 3 : money >= 100 ? 2 : money > 0 ? 1 : 0;
+  value = Math.max(value, 2); // every open ticket has baseline stakes
+  if (/\b(revenue|prod|production|customer|customer-facing|live|go-live|launch|ship|deploy)\b/i.test(b)) value = Math.min(5, value + 1);
+  if (/\b(urgent|critical|broken|down|502|500|incident|lapse|expir|money (owed|left)|five[- ]figure)\b/i.test(b)) value = Math.min(5, value + 1);
+
+  // ⏰ urgency: deadline + staleness + age + status weighting + urgent words
+  let urgency = 1;
+  if (days !== null) urgency = days <= 1 ? 5 : days <= 3 ? 4 : days <= 7 ? 3 : days <= 30 ? 2 : 1;
+  if (idleH > 72) urgency = Math.max(urgency, 4);           // stale >3d = high need
+  else if (idleH > 24) urgency = Math.max(urgency, 3);      // stale >1d
+  if (ageDays > 14) urgency = Math.min(5, urgency + 1);     // long-open drags priority up
+  if (t.status === 'blocked') urgency = Math.min(5, urgency + 2); // blocked screams for attention
+  else if (t.status === 'doing') urgency = Math.min(5, urgency + 1);
+  if (/\b(urgent|asap|lapsing|due today|deadline|expires? (today|tomorrow)|now|immediately|incident)\b/i.test(b)) urgency = Math.min(5, urgency + 1);
+  urgency = Math.max(1, Math.min(5, urgency));
+
+  // ⚡ ease (higher = quicker / lower-friction to complete)
+  let ease = 3;
+  const wc = b.split(/\s+/).length;
+  if (/\b(reversible|one[- ]click|1[- ]click|toggle|paste|quick|small|single|read-only|one[- ]line|tweak|typo)\b/i.test(b)) ease += 1;
+  if (/\b(build|migration|scrape|onboard|rebuild|multi-part|multi-step|large|backfill|thousands|batch|refactor|overhaul|end-to-end|pipeline)\b/i.test(b)) ease -= 1;
+  if ((t.actions || []).length + (t.comments || []).length <= 1 && wc < 40) ease += 1; // short/single-action ticket
+  if (wc > 400) ease -= 1;
+  ease = Math.max(1, Math.min(5, ease));
+
+  // ✅ safety/confidence (higher = safer / more reversible)
+  let safety = 3;
+  if (/\b(reversible|restore[- ]map|verified|snapshot|dry[- ]?run|git revert|rollback|local|read-only|additive)\b/i.test(b)) safety += 1;
+  if (/\b(destructive|irreversible|delete|purge|drop |wipe|history rewrite|filter-repo|unpublish|cannot be undone|force[- ]push|prod deploy|dns|spend|send-to-list)\b/i.test(b)) safety -= 2;
+  safety = Math.max(1, Math.min(5, safety));
+
+  // composite: value + urgency dominate; ease nudges; low safety slightly demotes
+  const priority = Math.round((value * 2.4 + urgency * 2.4 + ease * 1.0 + safety * 0.4) * 10) / 10;
+  const tier = priority >= 26 ? 'high' : priority >= 18 ? 'med' : 'low';
+  return { value, urgency, ease, safety, priority, tier, money: money || 0, days };
+}
+// Attach ranking/ratings to a flat ticket list. Only open/doing/blocked get ranked
+// (done/stopped are excluded from the ranking per the brief) — those get priority 0 / no rank.
+function withRanking(list) {
+  const RANKABLE = new Set(['open', 'doing', 'blocked']);
+  const scored = [];
+  for (const t of list) {
+    if (RANKABLE.has(t.status)) {
+      const s = scoreTicket(t);
+      t.priority = s.priority; t.tier = s.tier;
+      t.ratings = { value: s.value, urgency: s.urgency, ease: s.ease, safety: s.safety };
+      t.money = s.money; t.deadlineDays = s.days;
+      scored.push(t);
+    } else {
+      t.priority = 0; t.tier = null; t.rank = null;
+      t.ratings = { value: 0, urgency: 0, ease: 0, safety: 0 };
+      t.money = 0; t.deadlineDays = null;
+    }
+  }
+  scored.sort((a, b) => b.priority - a.priority || (+new Date(b.updated_at) - +new Date(a.updated_at)));
+  scored.forEach((t, i) => { t.rank = i + 1; });
+  return list;
+}
+
 function page() {
   const cols = { open: [], doing: [], blocked: [], done: [], stopped: [] };
   for (const t of tickets().values()) (cols[t.status] || (cols[t.status] = [])).push(t);
@@ -199,7 +301,7 @@ http.createServer((req, res) => {
     });
   }
   if (req.url === '/api/running') { return getRunning(d => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(d)); }); }
-  if (req.url === '/api/tickets') { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify([...tickets().values()])); }
+  if (req.url === '/api/tickets') { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify(withRanking([...tickets().values()]))); }
   if (req.url === '/api/messages') { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify([...messages().values()])); }
   if (req.url === '/kanban') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(page()); }
   // default (/) = the adjustable-columns TABLE view (Steve's list-builds rule, 2026-08-10)

← 9385a556 run-ticket.sh: foreground-launch fix — explicit create/selec  ·  back to Ticket System  ·  auto-data-snapshot: 2026-08-18T16:08:33 (3 data files) — .pl fb41540c →