← back to Approvals Viewer
approvals-viewer: add star ratings (1-5) + sort-by-rating/category/security
69d3d3abe4b3154b80632a15eaef5897605d9865 · 2026-08-19 04:03:36 -0700 · Steve Abrams
- /api/rate POST endpoint stores per-memo ratings in ratings.json
- /api/memos?sort= supports: mtime (default), rating, category, security
- Star widget (★) on each card, clickable; keyboard shortcut 1-5 to rate
- Rating badge chip in header row + rating-first sort surfaces urgent items
- Toggle off same star to clear rating
Files touched
M public/index.htmlA ratings.jsonM server.js
Diff
commit 69d3d3abe4b3154b80632a15eaef5897605d9865
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Aug 19 04:03:36 2026 -0700
approvals-viewer: add star ratings (1-5) + sort-by-rating/category/security
- /api/rate POST endpoint stores per-memo ratings in ratings.json
- /api/memos?sort= supports: mtime (default), rating, category, security
- Star widget (★) on each card, clickable; keyboard shortcut 1-5 to rate
- Rating badge chip in header row + rating-first sort surfaces urgent items
- Toggle off same star to clear rating
---
public/index.html | 59 +++++++++++++++++++++++++++++++++++++++++++++----------
ratings.json | 7 +++++++
server.js | 29 +++++++++++++++++++++------
3 files changed, 79 insertions(+), 16 deletions(-)
diff --git a/public/index.html b/public/index.html
index a7ae820..909da61 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1,25 +1,33 @@
<!doctype html><html lang=en><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1">
<title>Approvals — yes / no</title>
<style>
-:root{--bg:#0e0f13;--card:#181a20;--ink:#e8e8ea;--mut:#8a8f9a;--line:#2a2d36;--ok:#1db954;--no:#e5484d;--sk:#5b6472}
+:root{--bg:#0e0f13;--card:#181a20;--ink:#e8e8ea;--mut:#8a8f9a;--line:#2a2d36;--ok:#1db954;--no:#e5484d;--sk:#5b6472;--gold:#f5c842}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
-header{position:sticky;top:0;background:var(--bg);border-bottom:1px solid var(--line);padding:10px 16px;display:flex;align-items:center;gap:14px;z-index:5}
-.bar{flex:1;height:8px;background:#23262f;border-radius:6px;overflow:hidden}.bar>i{display:block;height:100%;background:linear-gradient(90deg,#1db954,#3ea6ff);width:0;transition:width .25s}
+header{position:sticky;top:0;background:var(--bg);border-bottom:1px solid var(--line);padding:10px 16px;display:flex;align-items:center;gap:10px;z-index:5;flex-wrap:wrap}
+.bar{flex:1;min-width:80px;height:8px;background:#23262f;border-radius:6px;overflow:hidden}.bar>i{display:block;height:100%;background:linear-gradient(90deg,#1db954,#3ea6ff);width:0;transition:width .25s}
.count{font-variant-numeric:tabular-nums;color:var(--mut);white-space:nowrap;font-size:13px}
.tally{font-size:13px;color:var(--mut)}.tally b{color:var(--ink)}
+select.sort-sel{background:#23262f;border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:5px 10px;font-size:13px;cursor:pointer}
main{max-width:860px;margin:0 auto;padding:18px 16px 130px}
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:22px 24px;box-shadow:0 8px 30px rgba(0,0,0,.35)}
.chip{display:inline-block;font-size:12px;padding:3px 10px;border:1px solid var(--line);border-radius:999px;color:var(--mut);margin-right:8px}
.when{font-size:12px;color:var(--mut)}
h1.title{font-size:21px;margin:14px 0 6px;line-height:1.3}
-.file{font-size:12px;color:#6b7280;word-break:break-all;margin-bottom:14px}
+.file{font-size:12px;color:#6b7280;word-break:break-all;margin-bottom:10px}
+/* star rating */
+.stars{display:flex;align-items:center;gap:4px;margin-bottom:12px}
+.stars .star{font-size:22px;cursor:pointer;color:#444;transition:color .1s;user-select:none}
+.stars .star.on{color:var(--gold)}
+.stars .star:hover{color:var(--gold)}
+.rating-note{font-size:12px;color:var(--mut);margin-left:6px;font-style:italic}
+.rating-badge{display:inline-flex;align-items:center;gap:3px;font-size:12px;padding:3px 8px;border-radius:999px;background:#23262f;border:1px solid var(--line);color:var(--gold);margin-left:6px;font-weight:600}
.body{border-top:1px solid var(--line);padding-top:14px;max-height:52vh;overflow:auto}
.body h1,.body h2,.body h3{font-size:16px;margin:16px 0 6px;color:#fff}.body h1{font-size:18px}
.body code{background:#23262f;padding:1px 5px;border-radius:5px;font-size:13px}
.body pre{background:#101217;border:1px solid var(--line);padding:12px;border-radius:8px;overflow:auto;font-size:12.5px}
.body table{border-collapse:collapse;margin:8px 0;font-size:13px}.body td,.body th{border:1px solid var(--line);padding:5px 9px}
.body a{color:#3ea6ff}.body ul{padding-left:22px}.body blockquote{border-left:3px solid var(--sk);margin:8px 0;padding-left:12px;color:var(--mut)}
-.actions{position:fixed;left:0;right:0;bottom:0;background:linear-gradient(180deg,transparent,var(--bg) 22%);padding:16px;display:flex;gap:12px;justify-content:center}
+.actions{position:fixed;left:0;right:0;bottom:0;background:linear-gradient(180deg,transparent,var(--bg) 22%);padding:16px;display:flex;gap:12px;justify-content:center;flex-wrap:wrap}
button{border:0;border-radius:12px;padding:14px 22px;font-size:16px;font-weight:600;cursor:pointer;color:#fff;min-width:150px}
.b-no{background:var(--no)}.b-sk{background:var(--sk)}.b-ok{background:var(--ok)}.b-undo{background:#333;min-width:auto;padding:14px 16px;font-size:14px}
.hint{color:var(--mut);font-size:12px;text-align:center;margin-top:8px}
@@ -32,6 +40,12 @@ button{border:0;border-radius:12px;padding:14px 22px;font-size:16px;font-weight:
<div class=bar><i id=bar></i></div>
<span class=count id=count>—</span>
<span class=tally id=tally></span>
+ <select class=sort-sel id=sortSel title="Sort order">
+ <option value=mtime>⏱ Newest</option>
+ <option value=rating>⭐ Highest Rated</option>
+ <option value=category>🗂 By Category</option>
+ <option value=security>🔴 Security First</option>
+ </select>
</header>
<main id=main><div class=card><div class=body>Loading…</div></div></main>
<div class=actions id=actions style=display:flex>
@@ -41,11 +55,10 @@ button{border:0;border-radius:12px;padding:14px 22px;font-size:16px;font-weight:
<button class=b-ok onclick="decide('approve')">✅ Approve <span class=kbd>→</span></button>
</div>
<script>
-let memos=[],i=0,total=0,tally={approve:0,reject:0,defer:0},lastAction=null;
+let memos=[],i=0,total=0,tally={approve:0,reject:0,defer:0},lastAction=null,currentSort='mtime';
function md(s){
s=s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
s=s.replace(/```([\s\S]*?)```/g,(m,c)=>'<pre>'+c+'</pre>');
- // tables
s=s.replace(/(^\|.*\|$\n?)+/gm,blk=>{const rows=blk.trim().split('\n').filter(r=>!/^\|[\s:\-|]+\|$/.test(r));
return '<table>'+rows.map((r,ri)=>{const cells=r.split('|').slice(1,-1);const tag=ri===0?'th':'td';
return '<tr>'+cells.map(c=>'<'+tag+'>'+c.trim()+'</'+tag+'>').join('')+'</tr>';}).join('')+'</table>';});
@@ -57,6 +70,28 @@ function md(s){
s=s.replace(/\n{2,}/g,'<br><br>').replace(/\n/g,'<br>');
return s;}
function fmtDate(iso){return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});}
+
+function starsHtml(file,currentRating){
+ let h='<div class=stars>';
+ for(let s=1;s<=5;s++){
+ h+='<span class="star'+(s<=currentRating?' on':'')+'" data-file="'+file+'" data-star="'+s+'" title="Rate '+s+' star'+(s>1?'s':'')+'" onclick="rateMemo(this)">★</span>';
+ }
+ if(currentRating>0) h+='<span class=rating-note>'+currentRating+'★ priority</span>';
+ h+='</div>';
+ return h;
+}
+
+async function rateMemo(el){
+ const file=el.dataset.file,star=parseInt(el.dataset.star);
+ const x=memos.find(m=>m.file===file);if(!x)return;
+ const newRating=(x.rating===star)?0:star; // toggle off if same star clicked
+ x.rating=newRating;
+ try{await fetch('/api/rate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file,rating:newRating,note:''})});}catch(e){}
+ // re-render stars in place
+ const starsEl=document.querySelector('.stars');
+ if(starsEl) starsEl.outerHTML=starsHtml(file,newRating);
+}
+
function render(){
const m=document.getElementById('main');
document.getElementById('bar').style.width=(total?Math.round((i)/total*100):0)+'%';
@@ -67,8 +102,10 @@ function render(){
m.innerHTML='<div class=done><h2>🎉 All caught up</h2><p>'+tally.approve+' approved · '+tally.reject+' rejected · '+tally.defer+' deferred.</p><p style=color:#8a8f9a>Approved memos moved to <code>_approved/</code> · rejected to <code>_rejected/</code>. Fully reversible; nothing was executed.</p></div>';return;}
document.getElementById('actions').style.display='flex';
const x=memos[i];
- m.innerHTML='<div class=card><div><span class=chip>'+x.category.label+'</span><span class=when>🕓 '+fmtDate(x.mtime)+'</span></div>'
+ const ratingBadge=x.rating>0?'<span class=rating-badge>'+x.rating+'★</span>':'';
+ m.innerHTML='<div class=card><div><span class=chip>'+x.category.label+'</span><span class=when>🕓 '+fmtDate(x.mtime)+'</span>'+ratingBadge+'</div>'
+'<h1 class=title>'+x.title.replace(/</g,'<')+'</h1><div class=file>'+x.file+'</div>'
+ +starsHtml(x.file,x.rating)
+'<div class=body>'+md(x.body)+'</div></div>';
m.querySelector('.body').scrollTop=0;
}
@@ -83,15 +120,17 @@ async function undo(){
tally[a.decision]--;i=a.idx;lastAction=null;render();
}
document.getElementById('undo').onclick=undo;
+document.getElementById('sortSel').onchange=function(){currentSort=this.value;load();};
addEventListener('keydown',e=>{
if(i>=memos.length)return;
if(e.key==='ArrowRight'||e.key==='y')decide('approve');
else if(e.key==='ArrowLeft'||e.key==='n')decide('reject');
else if(e.key==='ArrowDown'||e.key===' '||e.key==='s'){e.preventDefault();decide('defer');}
else if(e.key==='u')undo();
+ else if(e.key>='1'&&e.key<='5'){const x=memos[i];if(x){const star=document.querySelector('.star[data-star="'+e.key+'"]');if(star)rateMemo(star);}}
});
-function load(){fetch('/api/memos').then(r=>{if(!r.ok)throw new Error('HTTP '+r.status);return r.json();})
- .then(d=>{memos=d;total=d.length;i=0;render();})
+function load(){fetch('/api/memos?sort='+currentSort).then(r=>{if(!r.ok)throw new Error('HTTP '+r.status);return r.json();})
+ .then(d=>{memos=d;total=d.length;i=0;tally={approve:0,reject:0,defer:0};render();})
.catch(e=>{document.getElementById('actions').style.display='none';
document.getElementById('main').innerHTML='<div class=done><h2>⚠️ Could not load memos</h2><p style=color:#8a8f9a>'+e.message+'</p><p><button onclick="load()">↻ Retry</button></p></div>';});}
load();
diff --git a/ratings.json b/ratings.json
new file mode 100644
index 0000000..973e3b7
--- /dev/null
+++ b/ratings.json
@@ -0,0 +1,7 @@
+{
+ "070926-gmc-orphan-reconcile-2026-08-19.md": {
+ "rating": 4,
+ "note": "GMC account-health risk - high urgency",
+ "ts": "2026-08-19T11:03:27.955Z"
+ }
+}
\ No newline at end of file
diff --git a/server.js b/server.js
index 9151547..cfcbdaa 100644
--- a/server.js
+++ b/server.js
@@ -4,8 +4,12 @@ const http=require('http'),fs=require('fs'),path=require('path');
const QUEUE=path.join(process.env.HOME,'.claude/yolo-queue/pending-approval');
const APPROVED=path.join(QUEUE,'_approved'),REJECTED=path.join(QUEUE,'_rejected');
const LOG=path.join(QUEUE,'_decisions.jsonl');
+const RATINGS_FILE=path.join(__dirname,'ratings.json');
const USER='admin',PASS='DW2024!';
for(const d of [APPROVED,REJECTED]) fs.mkdirSync(d,{recursive:true});
+
+function loadRatings(){try{return JSON.parse(fs.readFileSync(RATINGS_FILE,'utf8'));}catch{return {};}}
+function saveRatings(r){fs.writeFileSync(RATINGS_FILE,JSON.stringify(r,null,2));}
function categorize(name){const n=name.toLowerCase();
if(/tk10045|tk-10045|critical-incident|credential|godaddy|alert-|secret-rotat|tk-10024|security-harden|git-history|blocker-a|rotation/.test(n))return{key:'security',label:'\u{1F534} Security / rotation'};
if(/golive|go-live|deploy|cutover|consolidation|homesonspec|spechomes|consulting-portal|nineoh|testflight|rubikscube/.test(n))return{key:'golive',label:'\u{1F7E0} Go-live / deploy'};
@@ -14,11 +18,19 @@ function categorize(name){const n=name.toLowerCase();
if(/usre|usrealestate|rentv|houston|foia|parcel|firm-resolve|listing/.test(n))return{key:'project',label:'\u{1F535} usre / rentv'};
if(/gmc|dedup|cadence|model-arena|swap-pressure|shopify-storage|fm-mfr/.test(n))return{key:'misc',label:'⚪ GMC / recurring'};
return{key:'other',label:'⚫ Other'};}
-function listMemos(){return fs.readdirSync(QUEUE).filter(f=>f.endsWith('.md')&&!f.startsWith('_')).map(f=>{
- const fp=path.join(QUEUE,f),st=fs.statSync(fp),body=fs.readFileSync(fp,'utf8');
- const title=(body.match(/^#\s+(.+)$/m)||[,f.replace(/\.md$/,'')])[1].slice(0,140);
- return{file:f,title,category:categorize(f),mtime:st.mtime.toISOString(),size:st.size,body};
-}).sort((a,b)=>new Date(b.mtime)-new Date(a.mtime));}
+function listMemos(sort='mtime'){
+ const ratings=loadRatings();
+ const items=fs.readdirSync(QUEUE).filter(f=>f.endsWith('.md')&&!f.startsWith('_')).map(f=>{
+ const fp=path.join(QUEUE,f),st=fs.statSync(fp),body=fs.readFileSync(fp,'utf8');
+ const title=(body.match(/^#\s+(.+)$/m)||[,f.replace(/\.md$/,'')])[1].slice(0,140);
+ const r=ratings[f]||{};
+ return{file:f,title,category:categorize(f),mtime:st.mtime.toISOString(),size:st.size,body,rating:r.rating||0,ratingNote:r.note||''};
+ });
+ if(sort==='rating') return items.sort((a,b)=>b.rating-a.rating||new Date(b.mtime)-new Date(a.mtime));
+ if(sort==='category') return items.sort((a,b)=>a.category.key.localeCompare(b.category.key)||new Date(b.mtime)-new Date(a.mtime));
+ if(sort==='security') return items.sort((a,b)=>{const s=x=>x.category.key==='security'?0:1;return s(a)-s(b)||new Date(b.mtime)-new Date(a.mtime);});
+ return items.sort((a,b)=>new Date(b.mtime)-new Date(a.mtime));
+}
function send(res,c,b,t='application/json'){res.writeHead(c,{'Content-Type':t});res.end(b);}
http.createServer((req,res)=>{
// /healthz stays open (uptime/keep-alive probes); everything else requires Basic auth.
@@ -28,7 +40,12 @@ http.createServer((req,res)=>{
if(!(hdr.startsWith('Basic ')&&Buffer.from(hdr.slice(6),'base64').toString()===USER+':'+PASS)){
res.writeHead(401,{'WWW-Authenticate':'Basic realm="Approval Command Center"'});return res.end('auth required');}
if(req.url==='/'||req.url==='/index.html')return send(res,200,fs.readFileSync(path.join(__dirname,'public/index.html')),'text/html');
- if(req.url==='/api/memos')return send(res,200,JSON.stringify(listMemos()));
+ if(req.url.startsWith('/api/memos')){const u=new URL(req.url,'http://x');return send(res,200,JSON.stringify(listMemos(u.searchParams.get('sort')||'mtime')));}
+ if(req.url==='/api/rate'&&req.method==='POST'){let d='';req.on('data',c=>d+=c);req.on('end',()=>{try{
+ const{file,rating,note}=JSON.parse(d);if(!/^[\w.\-]+\.md$/.test(file))throw new Error('bad file');
+ const r=loadRatings();r[file]={rating:Math.min(5,Math.max(0,parseInt(rating)||0)),note:String(note||'').slice(0,200),ts:new Date().toISOString()};saveRatings(r);
+ send(res,200,'{"ok":true}');
+ }catch(e){send(res,400,JSON.stringify({error:String(e.message)}));}}); return;}
if(req.url==='/api/decide'&&req.method==='POST'){let d='';req.on('data',c=>d+=c);req.on('end',()=>{try{
const{file,decision}=JSON.parse(d);if(!/^[\w.\-]+\.md$/.test(file))throw new Error('bad file');
const src=path.join(QUEUE,file);if(!fs.existsSync(src))throw new Error('gone');
← 528c44e approvals-viewer: serve public/ static assets so /nav-agent/
·
back to Approvals Viewer
·
approvals-viewer: gitignore ratings.json — live queue state, a8dae36 →