feat: /players browser + identity review queue UI

Phase 4. A Players page that browses the whole villain file (named + nameless,
expandable to episodic recall — reads, notable hands, stats, descriptors) and, at
the top, the identity-resolution queue: possible-merges (same/different/dismiss)
and needs-clarification tasks. Rename a nameless villain, set category, or run a
dupe scan inline.

- poker.players_overview() for the list.
- routes: /players (page), /players/data, /player/{id}/data, POST /player/{id}
  (rename/retag), POST /identity/{id}/resolve, POST /players/scan.
- nav: 👤 Players.

Full suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 22:54:54 +00:00
parent f2944ed402
commit 3b3878ada1
4 changed files with 219 additions and 0 deletions
+166
View File
@@ -0,0 +1,166 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#070707" />
<title>Lyra — Players</title>
<style>
:root{--bg:#070707;--bg-elev:#0e0e0e;--bg-line:#141414;--border:#2a1d12;--text:#e8e8e8;--fade:#8a8a8a;--accent:#ff7a00;}
*{box-sizing:border-box;}
html,body{margin:0;min-height:100%;background:var(--bg);color:var(--text);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;-webkit-text-size-adjust:100%;}
header{position:sticky;top:0;z-index:10;background:var(--bg-elev);border-bottom:1px solid var(--border);
padding:env(safe-area-inset-top) 14px 0;}
.topbar{display:flex;align-items:center;gap:10px;padding:13px 0;}
.topbar h1{font-size:1.05rem;margin:0;font-weight:600;}
.topbar a.back{color:var(--accent);text-decoration:none;font-size:.92rem;}
.count{margin-left:auto;color:var(--fade);font-size:.8rem;}
main{max-width:640px;margin:0 auto;padding:12px 12px 44px;}
h2.sec{font-size:.74rem;text-transform:uppercase;letter-spacing:.6px;color:var(--fade);margin:20px 2px 8px;}
.queue{background:#160d05;border:1px solid var(--accent);border-radius:10px;padding:11px 12px;margin-bottom:9px;}
.queue .k{font-size:.62rem;text-transform:uppercase;letter-spacing:.5px;color:var(--accent);}
.queue .q-body{font-size:.9rem;margin:5px 0 9px;}
.queue .who{font-weight:600;}
.btns{display:flex;flex-wrap:wrap;gap:7px;}
button{font:inherit;font-size:.82rem;padding:6px 11px;border-radius:7px;border:1px solid var(--border);
background:var(--bg-line);color:var(--text);cursor:pointer;}
button.pri{border-color:var(--accent);color:var(--accent);}
button:active{background:#241400;}
.card{background:var(--bg-elev);border:1px solid var(--border);border-radius:10px;padding:10px 12px;margin-bottom:8px;}
.card .row{display:flex;align-items:center;gap:9px;cursor:pointer;}
.nm{font-size:.96rem;font-weight:600;}
.nm.desc{font-weight:500;font-style:italic;color:#e8d3bf;}
.meta{font-size:.74rem;color:var(--fade);}
.pill{font-size:.6rem;text-transform:uppercase;letter-spacing:.4px;border:1px solid var(--border);
border-radius:20px;padding:1px 7px;color:var(--fade);}
.pill.desc{border-color:#5a3c1e;color:#d0a56e;}
.spacer{margin-left:auto;}
.detail{margin-top:9px;padding-top:9px;border-top:1px solid var(--bg-line);font-size:.86rem;display:none;}
.detail.open{display:block;}
.detail .lbl{color:var(--fade);font-size:.72rem;text-transform:uppercase;letter-spacing:.4px;margin:8px 0 3px;}
.detail ul{margin:3px 0;padding-left:18px;} .detail li{margin:2px 0;}
.detail a{color:var(--accent);text-decoration:none;}
.edit{display:flex;flex-wrap:wrap;gap:6px;margin-top:9px;}
.edit input,.edit select{font:inherit;font-size:.82rem;padding:5px 8px;border-radius:6px;
border:1px solid var(--border);background:var(--bg);color:var(--text);}
.empty{color:var(--fade);text-align:center;padding:34px 16px;}
</style>
</head>
<body>
<header>
<div class="topbar">
<h1>👤 Players</h1>
<a class="back" href="/">← Chat</a>
<span class="count" id="count"></span>
</div>
</header>
<main id="root"><p class="empty">Loading…</p></main>
<script>
function esc(s){const d=document.createElement('div');d.textContent=s==null?'':String(s);return d.innerHTML;}
let DATA={players:[],queue:[]};
async function load(){
try{ DATA=await (await fetch('/players/data',{cache:'no-store'})).json(); }
catch(e){ document.getElementById('root').innerHTML='<p class="empty">Couldn\'t load players.</p>'; return; }
render();
}
function render(){
const {players,queue}=DATA;
const named=players.filter(p=>p.named), nameless=players.filter(p=>!p.named);
document.getElementById('count').textContent=`${players.length} player${players.length===1?'':'s'}`;
let html='';
if(queue.length){
html+=`<h2 class="sec">⚠ Needs your call — ${queue.length}</h2>`;
html+=queue.map(qCard).join('');
}
html+=`<h2 class="sec">Named — ${named.length} <button class="pri" style="float:right;padding:3px 9px" onclick="scan()">Scan for dupes</button></h2>`;
html+= named.length ? named.map(pCard).join('') : '<p class="empty">No named players yet.</p>';
html+=`<h2 class="sec">By description — ${nameless.length}</h2>`;
html+= nameless.length ? nameless.map(pCard).join('') : '<p class="empty">No nameless villains yet — they show up here as you describe players at the table.</p>';
document.getElementById('root').innerHTML=html;
}
function qCard(q){
const ps=q.players||[];
if(q.kind==='merge_candidate' && ps.length===2){
const a=ps[0], b=ps[1];
return `<div class="queue"><div class="k">Possible merge${q.confidence?` · ${Math.round(q.confidence*100)}%`:''}</div>
<div class="q-body">Same person? <span class="who">${label(a)}</span> &nbsp;vs&nbsp; <span class="who">${label(b)}</span></div>
<div class="btns">
<button class="pri" onclick="resolveTask(${q.id},'merge',{keep_id:${keepId(a,b)},dup_id:${dupId(a,b)}})">✓ Same — merge</button>
<button onclick="resolveTask(${q.id},'distinct',{a_id:${a.id},b_id:${b.id}})">✕ Different</button>
<button onclick="resolveTask(${q.id},'dismiss',{})">Dismiss</button>
</div></div>`;
}
const who=ps[0]?label(ps[0]):'?';
return `<div class="queue"><div class="k">Needs clarification</div>
<div class="q-body">You referred to <span class="who">“${esc(q.descriptor||'')}”</span>${ps[0]?` — is that ${who}?`:''}</div>
<div class="btns"><button onclick="resolveTask(${q.id},'dismiss',{})">Got it</button></div></div>`;
}
const label=p=>`${esc(p.name)}${p.named?'':' <span class="pill desc">desc</span>'}${p.venue?` · ${esc(p.venue)}`:''}${p.obs?` · ${p.obs}h`:''}`;
const keepId=(a,b)=>a.named?a.id:(b.named?b.id:a.id);
const dupId=(a,b)=>a.named?b.id:(b.named?a.id:b.id);
function pCard(p){
const pills=[p.named?'':'<span class="pill desc">desc</span>',p.category?`<span class="pill">${esc(p.category)}</span>`:''].join('');
const meta=[p.venue,p.obs?`${p.obs} hands`:'',p.reads?`${p.reads} reads`:''].filter(Boolean).join(' · ');
return `<div class="card" id="p${p.id}">
<div class="row" onclick="toggle(${p.id})">
<span class="nm ${p.named?'':'desc'}">${p.named?esc(p.name):'“'+esc(p.name)+'”'}</span>
${pills}<span class="spacer"></span><span class="meta">${esc(meta)}</span>
</div>
<div class="detail" id="d${p.id}"></div></div>`;
}
async function toggle(id){
const el=document.getElementById('d'+id);
if(el.classList.contains('open')){el.classList.remove('open');return;}
el.classList.add('open'); el.innerHTML='<span class="meta">Loading…</span>';
const r=await (await fetch(`/player/${id}/data`,{cache:'no-store'})).json();
el.innerHTML=detailHtml(id,r);
}
function detailHtml(id,r){
const p=r.player||{}; let h='';
const seen=[r.times_seen?`seen ${r.times_seen}×`:'', r.last_seen?`last ${String(r.last_seen).slice(0,10)}`:''].filter(Boolean).join(' · ');
if(seen) h+=`<div class="meta">${esc(seen)}</div>`;
if(r.stats) h+=`<div class="lbl">Stats</div><div>VPIP ${r.stats.vpip_pct} · PFR ${r.stats.pfr_pct} · WTSD ${r.stats.wtsd_pct} <span class="meta">(${r.stats.hands} hands)</span></div>`;
if(r.descriptors) h+=`<div class="lbl">Descriptors</div><div>${esc(r.descriptors)}</div>`;
if(p.tendencies) h+=`<div class="lbl">Tendencies</div><div>${esc(p.tendencies)}</div>`;
if(p.adjustment) h+=`<div class="lbl">Exploit</div><div>${esc(p.adjustment)}</div>`;
if((r.reads||[]).length){h+='<div class="lbl">Reads</div><ul>'+r.reads.slice(0,8).map(x=>`<li>${esc(x)}</li>`).join('')+'</ul>';}
if((r.notable_hands||[]).length){h+='<div class="lbl">Notable hands</div><ul>'+r.notable_hands.map(x=>`<li><a href="/hand/${x.hand_id}">hand #${x.hand_id}</a>${x.cards?' — '+esc(x.cards):''}${x.summary?' <span class="meta">'+esc(x.summary)+'</span>':''}</li>`).join('')+'</ul>';}
h+=`<div class="edit">
${p.named?'':`<input id="nm${id}" placeholder="give a name…" size="12"><button onclick="rename(${id})">Name</button>`}
<select id="cat${id}" onchange="setCat(${id})">
${['','feeder','risky','reg','unknown'].map(c=>`<option value="${c}" ${p.category===c?'selected':''}>${c||'category…'}</option>`).join('')}
</select></div>`;
return h;
}
async function rename(id){
const v=document.getElementById('nm'+id).value.trim(); if(!v)return;
await fetch(`/player/${id}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:v})});
load();
}
async function setCat(id){
const v=document.getElementById('cat'+id).value;
await fetch(`/player/${id}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({category:v})});
}
async function resolveTask(id,action,kw){
await fetch(`/identity/${id}/resolve`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action,...kw})});
load();
}
async function scan(){
const r=await (await fetch('/players/scan',{method:'POST'})).json();
load();
}
load();
</script>
<script src="/nav.js"></script>
</body>
</html>