[object Object]

← back to Gated Queue Runner

queue viewer: add priority RANKING + per-item RATINGS (value/urgency/easy/safe) — rank badge, tier, rating bars, priority/value/urgency sorts + Rank column

f843c57e7c6410fabfe2ce340a70bdf78a406416 · 2026-08-18 14:31:49 -0700 · Steve Abrams

Files touched

Diff

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

    queue viewer: add priority RANKING + per-item RATINGS (value/urgency/easy/safe) — rank badge, tier, rating bars, priority/value/urgency sorts + Rank column
---
 index.html | 36 ++++++++++++++++++++++++++----
 server.js  | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 2 files changed, 106 insertions(+), 5 deletions(-)

diff --git a/index.html b/index.html
index 5c2d1fc..53e8ab4 100644
--- a/index.html
+++ b/index.html
@@ -63,6 +63,9 @@
     </label>
     <label>Sort
       <select id="sort">
+        <option value="priority">🔥 Priority (top first)</option>
+        <option value="value">💰 Biggest value</option>
+        <option value="urgency">⏰ Most urgent</option>
         <option value="new">Newest first</option>
         <option value="old">Oldest first</option>
         <option value="cat">By kind</option>
@@ -149,7 +152,7 @@ function getRows(){
     (!q || (x.about+x.effect+x.file+x.title+x.catLabel).toLowerCase().includes(q)));
 }
 function sortByCol(g){
-  const key={kind:'catLabel',pick:'file'}[tSort.field]||tSort.field;
+  const key={kind:'catLabel',pick:'file',pri:'priority'}[tSort.field]||tSort.field;
   g.sort((a,b)=>{let va=a[key],vb=b[key]; if(typeof va==='string'){va=va.toLowerCase();vb=(vb||'').toLowerCase();} return (va<vb?-1:va>vb?1:0)*tSort.dir;});
   return g;
 }
@@ -163,15 +166,37 @@ function render(){
     const s=$('sort').value;
     if(s==='new')g.sort((a,b)=>b.created-a.created); else if(s==='old')g.sort((a,b)=>a.created-b.created);
     else if(s==='az')g.sort((a,b)=>a.title.localeCompare(b.title)); else if(s==='cat')g.sort((a,b)=>a.category.localeCompare(b.category)||b.created-a.created);
+    else if(s==='value')g.sort((a,b)=>(b.ratings.value-a.ratings.value)||(b.money-a.money)||b.priority-a.priority);
+    else if(s==='urgency')g.sort((a,b)=>(b.ratings.urgency-a.ratings.urgency)||((a.days??999)-(b.days??999))||b.priority-a.priority);
+    else g.sort((a,b)=>b.priority-a.priority||b.created-a.created); // priority (default)
     renderCards(g);
   }
 }
+const TIER={high:{c:'#c0392b',bg:'#fdecea',l:'🔴 High'},med:{c:'#b8860b',bg:'#fdf6e3',l:'🟡 Medium'},low:{c:'#5a6a85',bg:'#eef2fb',l:'⚪ Low'}};
+function dots(n){ let h=''; for(let i=1;i<=5;i++) h+=`<span style="display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:2px;background:${i<=n?'currentColor':'#e3e8f2'}"></span>`; return h; }
+function ratingRow(emoji,label,n,color){ return `<div style="display:flex;align-items:center;gap:6px;font-size:.72em;color:#556"><span style="width:74px;color:#667">${emoji} ${label}</span><span style="color:${color};line-height:1">${dots(n)}</span></div>`; }
+function ratingsHtml(x){
+  const money = x.money>0 ? `<span class="pill" style="background:#eafaf0;color:#1f7a44;border:1px solid #b7e4c7">💰 $${x.money>=1e6?(x.money/1e6).toFixed(x.money>=1e7?0:1)+'M':x.money>=1e3?(x.money/1e3).toFixed(0)+'k':x.money}</span>`:'';
+  const dl = x.days!=null ? `<span class="pill" style="background:${x.days<=3?'#fdecea':'#fdf6e3'};color:${x.days<=3?'#c0392b':'#8a6d1a'};border:1px solid #eee">⏰ ${x.days<=0?'due today':'in '+x.days+'d'}</span>`:'';
+  return `<div style="display:flex;gap:6px;flex-wrap:wrap;margin:2px 0 4px">${money}${dl}</div>
+    <div style="display:flex;flex-direction:column;gap:3px">
+      ${ratingRow('💰','value',x.ratings.value,'#1f9d55')}
+      ${ratingRow('⏰','urgency',x.ratings.urgency,'#c0392b')}
+      ${ratingRow('⚡','easy',x.ratings.ease,'#2d5bff')}
+      ${ratingRow('✅','safe',x.ratings.safety,'#7a5cff')}
+    </div>`;
+}
 function renderCards(g){
   const grid=$('grid'); grid.innerHTML='';
   g.forEach(x=>{
     const d=document.createElement('div'); d.className='card'; const on=SEL.has(x.file);
-    d.innerHTML=`<div class="top"><label class="pick"><input type="checkbox" ${on?'checked':''}> pick</label><span class="emoji">${x.emoji}</span><span class="cat">${x.catLabel}</span></div>
+    const t=TIER[x.tier]||TIER.low;
+    d.innerHTML=`<div class="top"><label class="pick"><input type="checkbox" ${on?'checked':''}> pick</label>
+        <span title="priority rank" style="font-weight:800;font-size:.82em;color:#fff;background:${t.c};border-radius:7px;padding:2px 7px">#${x.rank}</span>
+        <span class="emoji">${x.emoji}</span><span class="cat">${x.catLabel}</span>
+        <span style="margin-left:auto;font-size:.7em;font-weight:700;color:${t.c};background:${t.bg};border-radius:999px;padding:2px 9px">${t.l}</span></div>
       <div class="note">${esc(x.note)}</div>
+      ${ratingsHtml(x)}
       <div class="when">🕓 ${fmtWhen(x.created)}</div>
       <div class="file">${esc(x.file)}</div>
       <button>read the grown-up version →</button>`;
@@ -183,11 +208,14 @@ function renderCards(g){
   if(!g.length) grid.innerHTML='<p style="color:#889">Nothing matches.</p>';
 }
 // full column set for TABLE; compact subset for LIST — every column is click-to-sort
-const COLS_TABLE=[{f:'pick',l:'✔'},{f:'kind',l:'Kind'},{f:'title',l:'Title'},{f:'about',l:"What it's about"},{f:'effect',l:'If you say YES'},{f:'created',l:'When'},{f:'size',l:'Size'},{f:'file',l:'File'}];
-const COLS_LIST =[{f:'pick',l:'✔'},{f:'kind',l:'Kind'},{f:'title',l:'Title'},{f:'created',l:'When'}];
+const COLS_TABLE=[{f:'pick',l:'✔'},{f:'rank',l:'#'},{f:'pri',l:'Priority'},{f:'kind',l:'Kind'},{f:'title',l:'Title'},{f:'about',l:"What it's about"},{f:'effect',l:'If you say YES'},{f:'created',l:'When'},{f:'size',l:'Size'},{f:'file',l:'File'}];
+const COLS_LIST =[{f:'pick',l:'✔'},{f:'rank',l:'#'},{f:'pri',l:'Priority'},{f:'kind',l:'Kind'},{f:'title',l:'Title'},{f:'created',l:'When'}];
 function cellFor(f,x){
+  const t=(typeof TIER!=='undefined'&&TIER[x.tier])||{c:'#5a6a85',bg:'#eef2fb',l:'⚪ Low'};
   switch(f){
     case 'pick': return `<input type="checkbox" ${SEL.has(x.file)?'checked':''}>`;
+    case 'rank': return `<span style="font-weight:800;color:#fff;background:${t.c};border-radius:6px;padding:1px 6px">#${x.rank}</span>`;
+    case 'pri': return `<span style="white-space:nowrap;font-weight:700;color:${t.c}">${t.l}</span> <span style="color:#aab;font-size:.85em">${x.priority}</span>`;
     case 'kind': return `<span style="white-space:nowrap">${x.emoji} ${esc(x.catLabel)}</span>`;
     case 'title': return `<span class="ttl">${esc(x.title)}</span>`;
     case 'about': return esc(x.about);
diff --git a/server.js b/server.js
index f2339d0..38fa26c 100644
--- a/server.js
+++ b/server.js
@@ -97,6 +97,73 @@ function aboutText(cat, title, body) {
   return sum || simplify(title);
 }
 
+// ---- ranking + ratings ----
+// Turn a memo's own signals into 4 human ratings (0-5) + a composite priority,
+// so the "say YES" queue surfaces what matters instead of a flat date list.
+function parseMaxDollars(body) {
+  let max = 0;
+  const rx = /\$\s?([\d,]+(?:\.\d+)?)\s*(k|thousand|m|mm|million|billion|b)?/gi;
+  let m;
+  while ((m = rx.exec(body))) {
+    let n = parseFloat(m[1].replace(/,/g, '')); if (isNaN(n)) continue;
+    const u = (m[2] || '').toLowerCase();
+    if (u === 'k' || u === 'thousand') n *= 1e3;
+    else if (u.startsWith('m')) n *= 1e6;
+    else if (u.startsWith('b')) n *= 1e9;
+    if (n > max) max = n;
+  }
+  return max;
+}
+function nearestDeadlineDays(body) {
+  const now = Date.now();
+  let best = null;
+  // ISO dates + "Mon DD, YYYY" + explicit "deadline: <date>"
+  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; // future/just-past deadlines
+  }
+  if (/\b(today|due today|expires? today)\b/i.test(body)) best = best === null ? 0 : Math.min(best, 0);
+  return best;
+}
+function scoreGate(body, cat, size, title) {
+  const b = body || '';
+  const money = parseMaxDollars(b);
+  const days = nearestDeadlineDays(b);
+  const catWeight = { spend: 5, send: 5, dns: 4, deploy: 4, catalog: 4, shopify: 3, ga4: 2, blocked: 3, other: 2 }[cat.key] || 2;
+
+  // 💰 value: dollars (log) OR category 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, catWeight >= 4 ? 3 : 2);
+  if (/\burgent|five[- ]figure|revenue|money (owed|left)|lapse|expire/i.test(b)) value = Math.min(5, value + 1);
+
+  // ⏰ urgency: nearest deadline + urgent words
+  let urgency = 1;
+  if (days !== null) urgency = days <= 1 ? 5 : days <= 3 ? 4 : days <= 7 ? 3 : days <= 30 ? 2 : 1;
+  if (/\burgent|lapsing|due today|expires? (today|tomorrow)|deadline/i.test(b)) urgency = Math.min(5, urgency + 1);
+
+  // ⚡ ease (higher = quicker/lower-friction to say yes)
+  let ease = 3;
+  if (/\b(reversible|one[- ]click|1[- ]click|toggle|paste|30[- ]?sec|quick|small|single|read-only)\b/i.test(b)) ease += 1;
+  if (/\b(build|migration|scrape|onboard|rebuild|multi-part|large|backfill|thousands|batch of)\b/i.test(b) || size > 60000) ease -= 1;
+  if (size < 2500) 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)\b/i.test(b)) safety += 1;
+  if (/\b(destructive|irreversible|delete|purge|history rewrite|filter-repo|drop |unpublish|cannot be undone)\b/i.test(b)) safety -= 2;
+  if (cat.key === 'send' || cat.key === 'dns' || cat.key === 'spend') safety -= 1;
+  safety = Math.max(1, Math.min(5, safety));
+
+  // composite: value + urgency dominate; ease nudges; low safety slightly demotes auto-priority
+  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, days };
+}
+
 function listGates() {
   let files = [];
   try { files = fs.readdirSync(DIR).filter(f => !f.startsWith('_') && (f.endsWith('.md') || f.endsWith('.csv'))); }
@@ -110,12 +177,18 @@ function listGates() {
     const cat = categorize(body);
     const title = cleanTitle(first, f);
     const about = aboutText(cat, title, body);
+    const s = scoreGate(body, cat, st.size, title);
     return {
       file: f, title, category: cat.key, catLabel: cat.label, emoji: cat.emoji,
       about, effect: cat.yes, note: `This is about: ${about} ${cat.yes}`,
       created: st.mtimeMs, size: st.size,
+      // ranking + ratings
+      priority: s.priority, tier: s.tier,
+      ratings: { value: s.value, urgency: s.urgency, ease: s.ease, safety: s.safety },
+      money: s.money, days: s.days,
     };
-  }).sort((a, b) => b.created - a.created);
+  }).sort((a, b) => b.priority - a.priority || b.created - a.created)
+    .map((g, i) => ({ ...g, rank: i + 1 }));
 }
 
 const server = http.createServer((req, res) => {

← c0ab2e8 add List view + full 8-column sortable Table (every field cl  ·  back to Gated Queue Runner  ·  queue viewer: day/night theme toggle (persisted, respects pr e4dbef2 →