← 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 →