Files
serversdown 4ce1b05fad feat: edit hands from the viewer + "not my hand" disown
Addresses "no way to edit hands" and cleaning up misattributed ones:
- hand viewer (/hand/{id}) gets an "✎ Edit this hand" panel: position, cards,
  board, your net, tag, lesson → Save (existing PATCH), plus Delete.
- "Not my hand" → POST /hand/{id}/disown → poker.disown_hand clears the flat hero
  fields and rewrites structured with hero_involved=false, so a hand mislabeled as
  Brian's becomes a clean observed hand (replay stops showing him as hero).

1 test. Full suite 156 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 03:00:21 +00:00

338 lines
19 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 — Hand</title>
<style>
:root {
--bg:#070707; --bg-elev:#0e0e0e; --border:#2a1d12; --text:#e8e8e8;
--fade:#8a8a8a; --accent:#ff7a00; --felt:#16322a; --feltline:#0f5132;
--chip:#ffb347; --hero:#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:baseline;gap:10px;padding:12px 0;flex-wrap:wrap;}
.topbar h1{font-size:1.02rem;margin:0;font-weight:600;}
.topbar a.back{color:var(--accent);text-decoration:none;font-size:.92rem;}
.sub{color:var(--fade);font-size:.85rem;margin-left:auto;}
main{max-width:760px;margin:0 auto;padding:14px;}
.table-wrap{position:relative;width:100%;max-width:560px;margin:8px auto;aspect-ratio:1.45/1;}
.felt{position:absolute;inset:8%;background:radial-gradient(ellipse at center,#1c4a3c,var(--felt));
border:6px solid #25201a;border-radius:50%/50%;box-shadow:inset 0 0 40px rgba(0,0,0,.5);}
.center{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;width:80%;}
.board{display:flex;gap:5px;justify-content:center;min-height:46px;align-items:center;flex-wrap:wrap;}
.pot{margin-top:8px;color:var(--chip);font-size:.85rem;font-variant-numeric:tabular-nums;}
.street{color:var(--fade);font-size:.72rem;text-transform:uppercase;letter-spacing:.6px;margin-bottom:4px;}
.card{display:inline-flex;flex-direction:column;align-items:center;justify-content:center;
width:32px;height:44px;background:#f4f4f0;color:#111;border-radius:5px;font-weight:700;
box-shadow:0 1px 3px rgba(0,0,0,.4);line-height:1;}
.card.sm{width:26px;height:36px;font-size:.8rem;}
.card .r{font-size:1rem;}
.card.red{color:#c8102e;}
.card.back{background:#2a3550;color:#2a3550;}
.card.unknown{background:#2a3550;color:#7c879e;font-size:1.2rem;}
.card .nosuit{color:#9aa3b5;}
.seat{position:absolute;transform:translate(-50%,-50%);width:96px;text-align:center;
background:rgba(13,16,22,.85);border:1px solid var(--border);border-radius:10px;padding:5px 4px;}
.seat.hero{border-color:var(--hero);box-shadow:0 0 10px rgba(255,122,0,.4);}
.seat.acting{border-color:var(--chip);box-shadow:0 0 12px rgba(255,179,71,.6);}
.seat .pos{font-size:.66rem;color:var(--accent);font-weight:700;letter-spacing:.4px;}
.seat .nm{font-size:.66rem;color:var(--fade);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.seat .cards{display:flex;gap:3px;justify-content:center;margin:3px 0;}
.seat .stack{font-size:.66rem;color:var(--text);font-variant-numeric:tabular-nums;}
.seat .act{font-size:.62rem;color:var(--chip);min-height:.8em;}
.seat.folded{opacity:.4;}
.controls{display:flex;gap:8px;align-items:center;justify-content:center;margin:14px 0 6px;}
.controls button{background:#241400;border:1px solid var(--border);color:var(--text);
border-radius:8px;padding:8px 14px;font-size:.95rem;cursor:pointer;-webkit-tap-highlight-color:transparent;}
.controls button:disabled{opacity:.4;}
.step-label{color:var(--fade);font-size:.8rem;min-width:80px;text-align:center;}
.now{text-align:center;color:var(--text);font-size:.95rem;min-height:1.3em;margin-bottom:6px;}
.log{margin-top:14px;border-top:1px solid var(--border);padding-top:10px;}
.log .ln{padding:5px 8px;border-radius:6px;font-size:.9rem;display:flex;gap:8px;}
.log .ln.cur{background:#241400;}
.log .ln.brd{color:var(--fade);font-style:italic;}
.log .st{color:var(--fade);font-size:.72rem;width:54px;flex:none;text-transform:uppercase;}
.summary{margin-top:14px;background:var(--bg-elev);border:1px solid var(--border);border-radius:10px;padding:12px;}
.summary .lbl{color:var(--fade);font-size:.72rem;text-transform:uppercase;letter-spacing:.5px;}
.err{color:#ff6b6b;text-align:center;padding:40px;}
.net-pos{color:#8fd694;} .net-neg{color:#ff6b6b;}
</style>
</head>
<body>
<header>
<div class="topbar">
<h1>🃏 Hand</h1>
<a class="back" href="/">← Chat</a>
<span class="sub" id="sub"></span>
</div>
</header>
<main id="root"><p class="err" id="boot">Loading hand…</p></main>
<script>
const SUIT = {s:"♠", h:"♥", d:"♦", c:"♣"};
const RED = new Set(["h", "d"]);
function esc(s){const d=document.createElement('div');d.textContent=s==null?'':String(s);return d.innerHTML;}
function cardEl(code, sm){
if(!code) return '';
const c = String(code).trim();
if(c.toLowerCase()==='x') return `<span class="card${sm?' sm':''} unknown">?</span>`;
const m = c.match(/^(10|[2-9TJQKA])\s*([shdcx])$/i);
if(!m) return `<span class="card${sm?' sm':''}">${esc(c)}</span>`;
const r = m[1].toUpperCase().replace('10','T'); const s = m[2].toLowerCase();
if(s==='x') return `<span class="card${sm?' sm':''}"><span class="r">${r}</span><span class="nosuit">·</span></span>`;
return `<span class="card${sm?' sm':''}${RED.has(s)?' red':''}"><span class="r">${r}</span><span>${SUIT[s]}</span></span>`;
}
const cards = (arr, sm) => (arr||[]).map(c=>cardEl(c,sm)).join('');
// Split a loose card string ("KhQh", "Qh Qc", "Tc 8s Js 6d", "Ax") into codes.
const parseCards = s => (String(s||'').match(/(10|[2-9TJQKA])[shdcx]/gi) || []);
// Flat (quick-logged) hands have no structured replay — show a readable static
// view of everything that WAS captured, plus an on-demand "build replay".
function renderFlat(h){
document.getElementById('sub').textContent = h.position || '';
const hole = parseCards(h.hole_cards), board = parseCards(h.board);
const streets = [['Preflop',h.preflop],['Flop',h.flop],['Turn',h.turn],['River',h.river],['Showdown',h.showdown]]
.filter(x=>x[1]);
const canBuild = streets.length > 0;
document.getElementById('root').innerHTML = `
<div class="summary" style="text-align:center">
<div class="lbl">Hero ${esc(h.position||'')}${h.tag?' · '+esc(h.tag):''}</div>
<div style="display:flex;gap:5px;justify-content:center;margin:10px 0">
${hole.length?cards(hole):'<span class="card unknown">?</span>'}</div>
${board.length?`<div class="lbl" style="margin-top:6px">Board</div>
<div style="display:flex;gap:5px;justify-content:center;margin-top:6px">${cards(board)}</div>`:''}
</div>
${streets.length?`<div class="log">${streets.map(s=>`<div class="ln"><span class="st">${s[0]}</span>${esc(s[1])}</div>`).join('')}</div>`:''}
${h.result!=null?`<div class="summary"><div class="lbl">Result</div>
<div class="${h.result>=0?'net-pos':'net-neg'}">Hero net: ${h.result>=0?'+':''}${esc(h.result)}</div></div>`:''}
${h.lesson?`<div class="summary"><div class="lbl">Lesson</div><div>${esc(h.lesson)}</div></div>`:''}
<div class="controls">
${canBuild?'<button id="build">▶ Build replay</button>':''}
</div>
<p style="color:var(--fade);text-align:center;font-size:.78rem;margin-top:10px">
${canBuild?'Quick-logged hand (static). Build replay to reconstruct a step-through.':'Quick-logged hand — limited detail captured.'}</p>`;
const b = document.getElementById('build');
if(b) b.onclick = async () => {
b.disabled = true; b.textContent = '… building';
try{
const r = await fetch(`/hand/${h.id}/reconstruct`,{method:'POST'});
const d = await r.json();
if(d.ok) location.reload(); else { b.disabled=false; b.textContent='▶ Build replay'; alert("Couldn't reconstruct this one."); }
}catch(e){ b.disabled=false; b.textContent='▶ Build replay'; alert('Failed: '+e.message); }
};
}
function render(h){
const sub = document.getElementById('sub');
const data = h.structured;
const hasReplay = data && (((data.players||[]).length) || ((data.actions||[]).length));
if(!hasReplay){ renderFlat(h); return; }
const players = (data.players||[]).slice();
// order so hero sits at the bottom
let heroIdx = players.findIndex(p => p.pos === data.hero_pos);
if(heroIdx < 0) heroIdx = 0;
const ordered = players.slice(heroIdx).concat(players.slice(0, heroIdx));
const n = Math.max(ordered.length, 1);
const acts = data.actions || [];
let step = 0; // number of actions applied
sub.textContent = [data.stakes, data.game].filter(Boolean).join(' ');
const root = document.getElementById('root');
root.innerHTML = `
<div class="table-wrap" id="tw">
<div class="felt"></div>
<div class="center">
<div class="street" id="street"></div>
<div class="board" id="board"></div>
<div class="pot" id="pot"></div>
</div>
<div id="seats"></div>
</div>
<div class="now" id="now"></div>
<div class="controls">
<button id="prev">◀ Prev</button>
<span class="step-label" id="steplab"></span>
<button id="next">Next ▶</button>
<button id="all">End</button>
</div>
<div class="log" id="log"></div>
${data.result ? `<div class="summary"><div class="lbl">Result</div>
<div>${esc(data.result.summary||'')}</div>
${data.result.hero_net!=null ? `<div class="${data.result.hero_net>=0?'net-pos':'net-neg'}">Hero net: ${data.result.hero_net>=0?'+':''}${esc(data.result.hero_net)}</div>`:''}
</div>`:''}
`;
// place seats around the oval
const seatsEl = document.getElementById('seats');
const starts = {};
ordered.forEach((p,i)=>{
starts[p.pos] = (p.stack!=null ? Number(p.stack) : null);
const ang = (90 + i*(360/n)) * Math.PI/180; // bottom = 90deg
const x = 50 + 46*Math.cos(ang), y = 50 + 44*Math.sin(ang);
const el = document.createElement('div');
el.className = 'seat' + (p.pos===data.hero_pos?' hero':'');
el.style.left = x+'%'; el.style.top = y+'%';
el.dataset.pos = p.pos;
const hcards = (p.pos===data.hero_pos ? (p.cards||data.hero_cards) : p.cards);
el.innerHTML = `<div class="pos">${esc(p.pos||'')}</div>`
+ (p.name?`<div class="nm">${esc(p.name)}</div>`:'')
+ `<div class="cards">${hcards?cards(hcards,true):'<span class="card sm back">x</span><span class="card sm back">x</span>'}</div>`
+ `<div class="stack" data-stack>${p.stack!=null?esc(p.stack):''}</div>`
+ `<div class="act" data-act></div>`;
seatsEl.appendChild(el);
});
const boardEl=document.getElementById('board'), potEl=document.getElementById('pot'),
streetEl=document.getElementById('street'), nowEl=document.getElementById('now'),
logEl=document.getElementById('log'), steplab=document.getElementById('steplab');
// build the log
logEl.innerHTML = acts.map((a,idx)=>{
if(a.board) return `<div class="ln brd" data-i="${idx}"><span class="st">${esc(a.street)}</span>${cards(a.board,true)}</div>`;
const amt = a.amount!=null ? ' '+a.amount : '';
return `<div class="ln" data-i="${idx}"><span class="st">${esc(a.street||'')}</span>${esc(a.pos||'')} ${esc(a.action||'')}${amt}</div>`;
}).join('');
const cap = s => s ? s[0].toUpperCase()+s.slice(1) : s;
const fmt = n => Number.isInteger(n) ? n : Math.round(n*100)/100;
function draw(){
let board = [], street = 'Preflop';
const lastAct = {}, folded = {};
// street-aware chip accounting: amounts are "to" totals for the street
const contrib = {}; // committed in prior (flushed) streets
let streetCommit = {}, streetBet = 0, curStreet = 'preflop';
const flushStreet = () => { for(const p in streetCommit){ contrib[p]=(contrib[p]||0)+streetCommit[p]; } streetCommit={}; streetBet=0; };
for(let i=0;i<step;i++){
const a = acts[i];
if(a.board){ flushStreet(); curStreet=a.street; board=a.board; street=cap(a.street); continue; }
if(a.street && a.street!==curStreet){ flushStreet(); curStreet=a.street; }
if(a.street) street = cap(a.street);
const pos=a.pos, amt=(a.amount!=null?Number(a.amount):null);
if(pos){
switch(a.action){
case 'post': case 'bet': streetCommit[pos]=amt||0; streetBet=Math.max(streetBet, amt||0); break;
case 'raise': case 'allin': streetCommit[pos]=(amt!=null?amt:streetBet); streetBet=Math.max(streetBet, streetCommit[pos]); break;
case 'call': streetCommit[pos]=(amt!=null?amt:streetBet); break;
case 'fold': folded[pos]=true; break;
}
lastAct[pos]=(a.action||'')+(amt!=null?' '+amt:'');
}
}
// committed total per player (flushed streets + current street), pot = sum
const committed={}, allPos=new Set([...Object.keys(contrib),...Object.keys(streetCommit)]);
let pot=0;
allPos.forEach(p=>{ committed[p]=(contrib[p]||0)+(streetCommit[p]||0); pot+=committed[p]; });
boardEl.innerHTML = cards(board);
potEl.textContent = pot ? ('Pot '+fmt(pot)) : '';
streetEl.textContent = street;
document.querySelectorAll('.seat').forEach(s=>{
const pos=s.dataset.pos;
s.querySelector('[data-act]').textContent = lastAct[pos]||'';
s.classList.toggle('folded', !!folded[pos]);
s.classList.remove('acting');
const stEl=s.querySelector('[data-stack]'), start=starts[pos], c=committed[pos]||0;
if(start!=null){ const rem=start-c; stEl.textContent = rem<=0 ? 'all in' : fmt(rem); }
else { stEl.textContent = c ? ''+fmt(c) : ''; }
});
const cur = acts[step-1];
if(cur && cur.pos){
const s = [...document.querySelectorAll('.seat')].find(x=>x.dataset.pos===cur.pos);
if(s) s.classList.add('acting');
}
nowEl.innerHTML = step===0 ? 'Cards dealt — preflop.'
: (cur.board ? `${cur.street[0].toUpperCase()+cur.street.slice(1)}: ${cards(cur.board,true)}`
: `${esc(cur.pos||'')} ${esc(cur.action||'')}${cur.amount!=null?' '+cur.amount:''}`);
steplab.textContent = `${step} / ${acts.length}`;
document.getElementById('prev').disabled = step===0;
document.getElementById('next').disabled = step>=acts.length;
logEl.querySelectorAll('.ln').forEach(l=>l.classList.toggle('cur', Number(l.dataset.i)===step-1));
const curln = logEl.querySelector('.ln.cur'); if(curln) curln.scrollIntoView({block:'nearest'});
}
document.getElementById('prev').onclick=()=>{if(step>0){step--;draw();}};
document.getElementById('next').onclick=()=>{if(step<acts.length){step++;draw();}};
document.getElementById('all').onclick=()=>{step=acts.length;draw();};
document.addEventListener('keydown',e=>{
if(e.key==='ArrowRight'){if(step<acts.length){step++;draw();}}
if(e.key==='ArrowLeft'){if(step>0){step--;draw();}}
});
logEl.querySelectorAll('.ln').forEach(l=>l.onclick=()=>{step=Number(l.dataset.i)+1;draw();});
draw();
}
async function load(){
const id = location.pathname.split('/')[2];
try{
const r = await fetch(`/hand/${id}/data`,{cache:'no-store'});
const h = await r.json();
if(!h || !h.id){ document.getElementById('root').innerHTML='<p class="err">Hand not found.</p>'; return; }
render(h);
renderEditor(h);
}catch(e){ document.getElementById('root').innerHTML='<p class="err">Couldn\'t load the hand.</p>'; }
}
function renderEditor(h){
const wrap = document.createElement('div');
wrap.style.cssText = 'max-width:520px;margin:18px auto 0;border-top:1px solid #241a10;padding-top:12px;';
const tags = ['','well_played','leak','cooler','confidence','notable'];
wrap.innerHTML = `
<details style="font-size:.9rem;">
<summary style="cursor:pointer;color:var(--accent,#ff7a00);">✎ Edit this hand</summary>
<div style="display:flex;flex-direction:column;gap:8px;margin-top:10px;">
<label>Position <input id="e_pos" value="${esc(h.position||'')}" placeholder="e.g. CO (blank if not yours)"></label>
<label>Your cards <input id="e_hole" value="${esc(h.hole_cards||'')}" placeholder="e.g. As Ks (blank if not yours)"></label>
<label>Board <input id="e_board" value="${esc(h.board||'')}" placeholder="e.g. Tc 8s Js 6d"></label>
<label>Your net <input id="e_res" value="${h.result!=null?esc(h.result):''}" placeholder="+ / chips (blank if not yours)"></label>
<label>Tag <select id="e_tag">${tags.map(t=>`<option value="${t}" ${h.tag===t?'selected':''}>${t||'—'}</option>`).join('')}</select></label>
<label>Lesson <input id="e_lesson" value="${esc(h.lesson||'')}"></label>
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:4px;">
<button onclick="saveHand(${h.id})" style="border-color:var(--accent,#ff7a00);color:var(--accent,#ff7a00);">Save</button>
<button onclick="disown(${h.id})" title="It was someone else's hand — clear it from you">Not my hand</button>
<button onclick="delHand(${h.id})" style="margin-left:auto;color:#ff6b6b;">Delete</button>
</div>
</div>
</details>`;
wrap.querySelectorAll('input,select').forEach(el=>{el.style.cssText='font:inherit;font-size:.86rem;padding:5px 8px;border-radius:6px;border:1px solid #241a10;background:#0b0b0b;color:#e8e8e8;margin-left:8px;';});
wrap.querySelectorAll('label').forEach(el=>{el.style.cssText='display:flex;justify-content:space-between;align-items:center;color:#8a8a8a;';});
wrap.querySelectorAll('button').forEach(el=>{el.style.cssText+=';font:inherit;font-size:.84rem;padding:6px 12px;border-radius:7px;border:1px solid #241a10;background:#141414;color:#e8e8e8;cursor:pointer;';});
document.getElementById('root').appendChild(wrap);
}
const val = id => document.getElementById(id).value.trim();
async function saveHand(id){
const body = {position:val('e_pos'), hole_cards:val('e_hole'), board:val('e_board'),
tag:val('e_tag'), lesson:val('e_lesson')};
const res = val('e_res'); if(res!=='') body.result = Number(res);
await fetch(`/hand/${id}`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
load();
}
async function disown(id){
if(!confirm("Mark this as someone else's hand? It'll be cleared from your stats.")) return;
await fetch(`/hand/${id}/disown`,{method:'POST'});
load();
}
async function delHand(id){
if(!confirm('Delete this hand for good?')) return;
await fetch(`/hand/${id}`,{method:'DELETE'});
location.href='/hands';
}
load();
</script>
<script src="/nav.js"></script>
</body>
</html>