← 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 →