Files
project-lyra/lyra/web/static/session.html
T
serversdown 67cf51a53f feat: undo / delete logged entries (fix fat-fingered live logging)
Previously the only delete was whole-session, so a mis-logged stack or a
mis-parsed hand was stuck on the HUD. Now:

- undo_last tool ("scratch that") — deletes the most recent hand/stack/read/
  scar/confidence/reset in the live session; added to the Cash toolset.
- poker.delete_hand/stack/read/ritual + delete_entry dispatch + undo_last.
- DELETE /session/entry/{kind}/{id} endpoint.
- HUD: per-row × delete buttons on hands, confidence-bank, and scar-note rows
  (stack/read deletes via the tool). Row ids now surfaced in the hud() bundle.
- test_modes.py +2 (undo_last across kinds, tool handler); 46 green.

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

292 lines
15 KiB
HTML
Raw 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 — Session</title>
<style>
:root {
--bg: #070707; --bg-elev: #0e0e0e; --bg-line: #141414; --border: #2a1d12;
--text: #e8e8e8; --fade: #8a8a8a; --accent: #ff7a00;
--good: #8fd694; --mid: #ffb347; --low: #ff6b6b;
}
* { 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 12px; }
.topbar h1 { font-size: 1.05rem; margin: 0; font-weight: 600; }
.topbar a.back { color: var(--accent); text-decoration: none; font-size: .95rem; }
.updated { margin-left: auto; color: var(--fade); font-size: .78rem; }
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--good); box-shadow: 0 0 8px var(--good); flex: none; opacity: .35; transition: opacity .2s; }
.dot.pulse { opacity: 1; }
main { max-width: 680px; margin: 0 auto; padding: 16px 14px 40px; }
.card { background: var(--bg-elev); border: 1px solid var(--border); border-radius: 14px; padding: 16px; margin-bottom: 14px; }
.label { color: var(--fade); font-size: .72rem; text-transform: uppercase; letter-spacing: .6px; margin: 0 0 10px; }
/* Header card */
.sess-top { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.sess-title { font-size: 1.25rem; font-weight: 700; }
.sess-sub { color: var(--fade); font-size: .9rem; }
.chips { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
.chip { font-size: .8rem; color: var(--fade); background: var(--bg-line); border: 1px solid var(--border); border-radius: 999px; padding: 3px 10px; }
.chip b { color: var(--text); font-weight: 600; }
/* Stack card */
.stack-row { display: flex; align-items: flex-end; gap: 16px; flex-wrap: wrap; }
.stack-now { font-size: 2.3rem; font-weight: 800; letter-spacing: .2px; font-variant-numeric: tabular-nums; }
.net { font-size: 1.2rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.net.up { color: var(--good); } .net.down { color: var(--low); } .net.flat { color: var(--fade); }
.stack-meta { color: var(--fade); font-size: .85rem; margin-left: auto; text-align: right; }
svg.spark { display: block; width: 100%; height: 56px; margin-top: 14px; }
/* Hands */
ul.rows { list-style: none; margin: 0; padding: 0; }
ul.rows li { padding: 10px 0; border-bottom: 1px solid var(--bg-line); font-size: .95rem; line-height: 1.45; }
ul.rows li:last-child { border-bottom: none; }
a.hand { color: var(--text); text-decoration: none; display: flex; gap: 8px; align-items: baseline; }
a.hand:hover { color: var(--accent); }
.pos { color: var(--accent); font-weight: 700; min-width: 38px; }
.cards { font-variant-numeric: tabular-nums; }
.res { margin-left: auto; font-variant-numeric: tabular-nums; }
.res.up { color: var(--good); } .res.down { color: var(--low); }
.tag { font-size: .7rem; color: var(--mid); border: 1px solid var(--border); border-radius: 999px; padding: 1px 7px; }
.villain b { color: var(--text); } .villain .cat { color: var(--mid); font-size: .78rem; }
.note-meta { color: var(--fade); font-size: .72rem; }
/* Rituals */
.gator {
display: flex; align-items: center; gap: 12px; background: #1a2e10;
border: 1px solid #3c6b1e; border-radius: 14px; padding: 14px 16px; margin-bottom: 14px;
}
.gator .ico { font-size: 1.7rem; }
.gator b { color: #b6e88a; } .gator .sub { color: #8fbf6a; font-size: .82rem; }
.scar-cls {
font-size: .68rem; text-transform: uppercase; letter-spacing: .4px; border-radius: 999px;
padding: 1px 7px; border: 1px solid var(--border); margin-left: 6px;
}
.scar-cls.punt { color: var(--low); border-color: var(--low); }
.scar-cls.cooler { color: var(--mid); border-color: var(--mid); }
.scar-cls.standard { color: var(--fade); }
.card.scar { border-color: #4a2222; } .card.scar .label { color: #d98a8a; }
.card.conf { border-color: #234a23; } .card.conf .label { color: var(--good); }
/* per-row delete (fix fat-fingered live logging) */
li.row-del { display: flex; align-items: center; gap: 8px; }
li.row-del > a.hand, li.row-del > .row-body { flex: 1; min-width: 0; }
.del-x { flex: none; background: none; border: none; color: var(--fade); font-size: 1.15rem;
line-height: 1; padding: 2px 6px; cursor: pointer; -webkit-tap-highlight-color: transparent; }
.del-x:active { color: var(--low); }
.empty { color: var(--fade); font-size: .92rem; }
.err { color: var(--low); text-align: center; padding: 30px; }
.big-empty { text-align: center; padding: 50px 20px; color: var(--fade); }
.big-empty .ico { font-size: 2.4rem; }
.big-empty a { color: var(--accent); text-decoration: none; }
</style>
</head>
<body>
<header>
<div class="topbar">
<span class="dot" id="dot"></span>
<h1>🎬 Session</h1>
<a class="back" href="/">← Chat</a>
<a class="back" href="/history" title="Past sessions">📚 Sessions</a>
<a class="back" href="/hands" title="All recorded hands">🃏 Hands</a>
<span class="updated" id="updated"></span>
</div>
</header>
<main id="root"><p class="err" id="boot">Loading the table…</p></main>
<script>
const root = document.getElementById('root');
const dot = document.getElementById('dot');
const updatedEl = document.getElementById('updated');
function esc(s){ const d=document.createElement('div'); d.textContent = s==null?'':String(s); return d.innerHTML; }
function money(v){ if (v == null) return '—'; const n = Number(v); return (n<0?'-$':'$') + Math.abs(n).toLocaleString(); }
function signed(v){ if (v == null) return '—'; const n = Number(v); return (n>0?'+$':n<0?'-$':'$') + Math.abs(n).toLocaleString(); }
function ago(iso){
if(!iso) return '—';
const s = Math.max(0, (Date.now() - new Date(iso).getTime())/1000);
if(s < 60) return 'just now';
if(s < 3600) return Math.round(s/60)+'m ago';
if(s < 86400) return Math.round(s/3600)+'h ago';
return Math.round(s/86400)+'d ago';
}
function elapsed(iso){
if(!iso) return '—';
const s = Math.max(0, (Date.now() - new Date(iso).getTime())/1000);
const h = Math.floor(s/3600), m = Math.round((s%3600)/60);
return h ? `${h}h ${m}m` : `${m}m`;
}
// Tiny inline sparkline of the stack-over-time series.
function sparkline(series){
const pts = series.map(p => Number(p.amount)).filter(n => !isNaN(n));
if (pts.length < 2) return '';
const W = 600, H = 56, pad = 4;
const min = Math.min(...pts), max = Math.max(...pts), span = (max - min) || 1;
const x = i => pad + (i / (pts.length - 1)) * (W - 2*pad);
const y = v => H - pad - ((v - min) / span) * (H - 2*pad);
const d = pts.map((v,i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ');
const last = pts[pts.length-1], first = pts[0];
const col = last >= first ? 'var(--good)' : 'var(--low)';
return `<svg class="spark" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none">
<polyline points="${d}" fill="none" stroke="${col}" stroke-width="2"
stroke-linejoin="round" stroke-linecap="round" />
<circle cx="${x(pts.length-1).toFixed(1)}" cy="${y(last).toFixed(1)}" r="3" fill="${col}" />
</svg>`;
}
function netClass(v){ return v == null ? 'flat' : v > 0 ? 'up' : v < 0 ? 'down' : 'flat'; }
// Delete one logged entry (hand | ritual | read | stack), then refresh.
async function del(kind, id){
if(!confirm('Delete this entry?')) return;
try {
const r = await fetch('/session/entry/'+kind+'/'+id, { method:'DELETE' });
if(!r.ok) throw new Error('HTTP '+r.status);
refresh();
} catch(e){ alert('Delete failed: '+e.message); }
}
function render(data){
const s = data.session;
if (!s) {
root.innerHTML = `<div class="big-empty">
<div class="ico">🪑</div>
<p>No live session right now.<br>Start one from <a href="/">chat</a> — switch to ♠ Cash and tell Lyra you're sitting down.</p>
</div>`;
updatedEl.textContent = '';
return;
}
const stack = data.stack || {};
const hands = data.hands || [];
const villains = data.villains || [];
const notes = data.notes || [];
const stats = data.stats || {};
const rituals = data.rituals || {};
const scars = rituals.scars || [];
const confidence = rituals.confidence || [];
const resets = rituals.resets || [];
const title = [s.stakes, s.game].filter(Boolean).join(' ') || 'Session';
const tagBits = Object.entries(stats.tags || {}).map(([k,v]) => `${k}×${v}`).join(' · ');
root.innerHTML = `
${rituals.alligator ? `<div class="gator">
<span class="ico">🐊</span>
<div><b>Alligator Blood</b><div class="sub">refuse to die · no forced miracles · make them beat you correctly</div></div>
</div>` : ''}
<div class="card">
<div class="sess-top">
<span class="sess-title">${esc(title)}</span>
<span class="sess-sub">${esc(s.venue || 'unknown room')}${s.status && s.status!=='live' ? ' · '+esc(s.status) : ''}</span>
</div>
<div class="chips">
<span class="chip">⏱ <b>${elapsed(s.started_at)}</b></span>
<span class="chip">in <b>${money(s.buy_in_total)}</b></span>
<span class="chip">${esc(s.format || 'cash')}</span>
<span class="chip"><b>${hands.length}</b> hands</span>
${resets.length ? `<span class="chip">🔄 <b>${resets.length}</b> reset${resets.length>1?'s':''}</span>` : ''}
</div>
</div>
<div class="card">
<p class="label">Stack</p>
<div class="stack-row">
<span class="stack-now">${stack.current == null ? '—' : money(stack.current)}</span>
<span class="net ${netClass(stack.net)}">${stack.net == null ? '' : signed(stack.net)}</span>
<span class="stack-meta">bought in ${money(stack.buy_in)}<br>${(stack.log||[]).length} update(s)</span>
</div>
${sparkline(stack.log || [])}
${stack.current == null ? '<p class="empty" style="margin:12px 0 0">No stack logged yet — tell Lyra your stack ("I\'m at 350").</p>' : ''}
</div>
<div class="card">
<p class="label">Hands this session</p>
${hands.length ? `<ul class="rows">${hands.slice().reverse().map(h => `
<li class="row-del"><a class="hand" href="/hand/${h.id}">
<span class="pos">${esc(h.position || '?')}</span>
<span class="cards">${esc(h.hole_cards || '')}${h.board ? ' · '+esc(h.board) : ''}</span>
${h.tag ? `<span class="tag">${esc(h.tag)}</span>` : ''}
${h.result != null ? `<span class="res ${h.result>=0?'up':'down'}">${signed(h.result)}</span>` : ''}
</a><button class="del-x" title="Delete hand" onclick="del('hand',${h.id})">×</button></li>`).join('')}</ul>`
: '<p class="empty">No hands logged yet.</p>'}
</div>
<div class="card conf">
<p class="label">💰 Confidence Bank</p>
${confidence.length ? `<ul class="rows">${confidence.slice().reverse().map(c => `
<li class="row-del"><span class="row-body">${esc(c.content)}${c.hand_id ? ` · <a class="hand" style="display:inline" href="/hand/${c.hand_id}">hand</a>` : ''}
<div class="note-meta">${ago(c.at)}</div></span><button class="del-x" title="Delete" onclick="del('ritual',${c.id})">×</button></li>`).join('')}</ul>`
: '<p class="empty">Nothing banked yet — disciplined plays land here.</p>'}
</div>
<div class="card scar">
<p class="label">🩹 Scar Notes</p>
${scars.length ? `<ul class="rows">${scars.slice().reverse().map(sc => `
<li class="row-del"><span class="row-body">${esc(sc.content)}${sc.classification ? `<span class="scar-cls ${esc(sc.classification)}">${esc(sc.classification)}</span>` : ''}
${sc.hand_id ? ` · <a class="hand" style="display:inline" href="/hand/${sc.hand_id}">hand</a>` : ''}
<div class="note-meta">${ago(sc.at)}</div></span><button class="del-x" title="Delete" onclick="del('ritual',${sc.id})">×</button></li>`).join('')}</ul>`
: '<p class="empty">No scars logged — mistakes to study land here.</p>'}
</div>
<div class="card">
<p class="label">Villains seen</p>
${villains.length ? `<ul class="rows">${villains.map(v => `
<li class="villain">
<b>${esc(v.name)}</b> ${v.category ? `<span class="cat">[${esc(v.category)}]</span>` : ''}
${v.tendencies ? `<div>${esc(v.tendencies)}</div>` : ''}
${v.last_note ? `<div class="note-meta">“${esc(v.last_note)}”</div>` : ''}
</li>`).join('')}</ul>`
: '<p class="empty">No reads logged this session.</p>'}
</div>
<div class="card">
<p class="label">Her notes</p>
${notes.length ? `<ul class="rows">${notes.map(n => `
<li>${esc(n.content)}<div class="note-meta">${esc(n.kind)} · ${ago(n.created_at)}</div></li>`).join('')}</ul>`
: '<p class="empty">Nothing jotted this session.</p>'}
</div>
<div class="card">
<p class="label">Session stats</p>
<div class="chips">
<span class="chip">logged <b>${stats.hands_logged ?? 0}</b></span>
${tagBits ? `<span class="chip">${esc(tagBits)}</span>` : ''}
${stats.context_per_hour != null ? `<span class="chip">${esc(title)} lifetime <b>${signed(stats.context_per_hour)}/hr</b></span>` : ''}
</div>
</div>
`;
updatedEl.textContent = 'updated ' + ago(data._fetched);
}
async function refresh(){
try {
const r = await fetch('/session/data', { cache: 'no-store' });
const data = await r.json();
data._fetched = new Date().toISOString();
dot.classList.add('pulse'); setTimeout(() => dot.classList.remove('pulse'), 400);
render(data);
} catch (e) {
if (!root.querySelector('.card')) root.innerHTML = '<p class="err">Couldn\'t reach the table. Is the server up?</p>';
}
}
refresh();
setInterval(refresh, 5000);
document.addEventListener('visibilitychange', () => { if (!document.hidden) refresh(); });
</script>
</body>
</html>