feat: view past sessions, edit session details, log rituals while reviewing

- View any past session as a read-only HUD: /session?id=N (hud(session_id) +
  /session/data?id=); /history rows now link there. Closed sessions show played
  duration + final net; recap link when one exists.
- Edit session details during or after play: poker.update_session (recomputes net
  when buy-in/cash-out change), PATCH /session/{id}, an update_session tool ("venue
  was actually Bellagio", "I bought in for 600"), and an inline ✎ Edit form on the HUD.
- Rituals attach to the most-recent session post-close (poker.review_session_id),
  so scar/confidence/reset work while reviewing after you rack up.
- Edit form is poll-safe (won't clobber mid-edit); past-session view doesn't poll.
- test_modes.py +3 (edit, review rituals, past-session HUD); 49 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 05:12:13 +00:00
parent cca8322ee2
commit 559faaed30
7 changed files with 224 additions and 29 deletions
+1 -1
View File
@@ -44,7 +44,7 @@ _CASH_TOOLS = _BASE + _LOOKUPS + (
"start_session", "add_buyin", "log_stack", "log_hand", "record_hand",
"add_read", "analyze_spot", "session_stats", "session_state", "end_session",
"generate_recap", "scar_note", "confidence_bank", "alligator_blood", "reset_ritual",
"undo_last",
"undo_last", "update_session",
)
# Talk mode also gets start_session as the *entry point*: opening a session from a
+48 -1
View File
@@ -341,6 +341,50 @@ def _resolve(session_id: int | None) -> int | None:
return live["id"] if live else None
def review_session_id() -> int | None:
"""The session to attach reflective entries to: the live one if any, else the
most-recent real session (closed). Lets rituals/notes land while reviewing after
you've racked up. Excludes the standing 'Hand Reviews' bucket."""
live = live_session()
if live:
return live["id"]
r = _c().execute(
"SELECT id FROM poker_sessions WHERE status != 'review' ORDER BY id DESC LIMIT 1"
).fetchone()
return int(r["id"]) if r else None
_EDITABLE = ("venue", "stakes", "game", "format", "buy_in_total", "cash_out",
"mantra", "mood")
def update_session(session_id: int, **fields) -> dict | None:
"""Edit session details (during or after play). Only known columns are touched;
net is recomputed when buy-in/cash-out change and both are known."""
s = get_session(session_id)
if not s:
return None
sets, vals = [], []
for k, v in fields.items():
if k in _EDITABLE and v is not None:
sets.append(f"{k} = ?")
vals.append(float(v) if k in ("buy_in_total", "cash_out") else v)
if sets:
conn = _c()
with conn:
conn.execute(f"UPDATE poker_sessions SET {', '.join(sets)} WHERE id = ?",
(*vals, session_id))
s = get_session(session_id)
# keep net consistent if the money fields changed and both are present
if s.get("cash_out") is not None and s.get("buy_in_total") is not None:
net = float(s["cash_out"]) - float(s["buy_in_total"])
if net != s.get("net"):
with conn:
conn.execute("UPDATE poker_sessions SET net = ? WHERE id = ?", (net, session_id))
s = get_session(session_id)
return s
def add_buyin(amount: float, session_id: int | None = None) -> float:
"""Add a buy-in/rebuy to a session. Returns the new total in."""
sid = _resolve(session_id)
@@ -1090,7 +1134,10 @@ def hud(session_id: int | None = None) -> dict | None:
"id": sid, "venue": s.get("venue"), "stakes": s.get("stakes"),
"game": s.get("game"), "format": s.get("format"),
"status": s.get("status"), "started_at": s.get("started_at"),
"buy_in_total": s.get("buy_in_total"),
"ended_at": s.get("ended_at"), "hours": s.get("hours"),
"buy_in_total": s.get("buy_in_total"), "cash_out": s.get("cash_out"),
"net": s.get("net"), "mantra": s.get("mantra"), "mood": s.get("mood"),
"is_live": s.get("status") == "live", "has_recap": bool(s.get("recap_md")),
},
"stack": {
"current": state["current"], "buy_in": state["buy_in"], "net": state["net"],
+41 -12
View File
@@ -117,6 +117,21 @@ def _log_stack(args: dict, ctx: dict) -> str:
return f"Stack ${amount:g} logged" + (f" (net {net:+.0f})." if net is not None else ".")
def _update_session(args: dict, ctx: dict) -> str:
sid = poker.review_session_id()
if sid is None:
return "No session to edit yet."
fields = {k: args.get(k) for k in ("venue", "stakes", "game", "format",
"buy_in_total", "cash_out", "mantra", "mood") if args.get(k) not in (None, "")}
if not fields:
return "Tell me what to change (venue, stakes, game, buy-in, etc.)."
s = poker.update_session(sid, **fields)
if not s:
return "Couldn't find that session."
changed = ", ".join(f"{k}={v}" for k, v in fields.items())
return f"Session #{sid} updated — {changed}."
def _undo_last(args: dict, ctx: dict) -> str:
what = (args.get("what") or "").strip().lower()
aliases = {"hands": "hand", "stacks": "stack", "reads": "read",
@@ -143,11 +158,11 @@ def _scar_note(args: dict, ctx: dict) -> str:
cls = (args.get("classification") or "").strip().lower() or None
if cls and cls not in ("punt", "cooler", "standard"):
cls = None
try:
sid = poker.review_session_id() # live, or the most-recent session (post-game review)
if sid is None:
return "No session yet — start one and I'll keep the scar notes."
poker.log_ritual("scar", content=content, classification=cls,
hand_id=args.get("hand_id"))
except ValueError:
return "No live session — start one and I'll keep the scar notes."
hand_id=args.get("hand_id"), session_id=sid)
return f"Scar note logged{f' ({cls})' if cls else ''}."
@@ -155,10 +170,10 @@ def _confidence_bank(args: dict, ctx: dict) -> str:
content = (args.get("content") or "").strip()
if not content:
return "Nothing to bank — tell me the good process."
try:
poker.log_ritual("confidence", content=content, hand_id=args.get("hand_id"))
except ValueError:
return "No live session — start one and I'll run the confidence bank."
sid = poker.review_session_id()
if sid is None:
return "No session yet — start one and I'll run the confidence bank."
poker.log_ritual("confidence", content=content, hand_id=args.get("hand_id"), session_id=sid)
return "Banked. 💰"
@@ -174,10 +189,10 @@ def _alligator_blood(args: dict, ctx: dict) -> str:
def _reset_ritual(args: dict, ctx: dict) -> str:
content = (args.get("content") or "").strip() or None
try:
poker.log_ritual("reset", content=content)
except ValueError:
return "No live session to reset."
sid = poker.review_session_id()
if sid is None:
return "No session to reset."
poker.log_ritual("reset", content=content, session_id=sid)
return "Reset logged. Clean slate — this is a new session in your head."
@@ -389,6 +404,20 @@ TOOLS.update({
"add_buyin": {"handler": _add_buyin, "spec": _f(
"add_buyin", "Record a rebuy / additional buy-in in the live session.",
{"amount": {**_N, "description": "Amount added"}}, ["amount"])},
"update_session": {"handler": _update_session, "spec": _f(
"update_session",
"Edit details of the current/most-recent session — during or after play. Use "
"when Brian corrects something ('change the stakes to 2/5', 'venue was actually "
"Bellagio', 'I bought in for 600', 'cashed out 1240'). Only pass fields that change.",
{"venue": {**_S, "description": "Casino/room"},
"stakes": {**_S, "description": "e.g. '1/3', '2/5'"},
"game": {**_S, "description": "NLH, PLO, ..."},
"format": {**_S, "description": "cash | tournament"},
"buy_in_total": {**_N, "description": "Total bought in"},
"cash_out": {**_N, "description": "Final cashout (recomputes net)"},
"mantra": {**_S, "description": "Pre-session focus/anchor"},
"mood": {**_S, "description": "Mental-game note"}},
[])},
"undo_last": {"handler": _undo_last, "spec": _f(
"undo_last",
"Undo/delete the most recent logged entry in the live session when Brian says "
+11 -3
View File
@@ -108,11 +108,19 @@ def create_app() -> FastAPI:
return FileResponse(str(_STATIC / "session.html"))
@app.get("/session/data")
async def session_hud_data() -> dict:
"""The current live session's HUD bundle (or {session: None} if none open)."""
bundle = await asyncio.to_thread(poker.hud)
async def session_hud_data(id: int | None = None) -> dict:
"""HUD bundle for the live session, or a specific past session via ?id=."""
bundle = await asyncio.to_thread(poker.hud, id)
return bundle or {"session": None}
@app.patch("/session/{session_id}")
async def session_update(session_id: int, request: Request) -> dict:
"""Edit a session's details (venue/stakes/game/buy-in/cash-out/…)."""
body = await request.json()
s = await asyncio.to_thread(lambda: poker.update_session(session_id, **body))
logbus.log("info", "session edited", id=session_id, fields=list(body))
return {"ok": s is not None, "session": s}
@app.delete("/session/entry/{kind}/{entry_id}")
async def delete_entry(kind: str, entry_id: int) -> dict:
"""Delete one HUD entry (hand | stack | read | ritual) by id."""
+1 -1
View File
@@ -85,7 +85,7 @@
const date=(s.started_at||'').slice(0,10);
const meta=[date,s.venue,`${s.hands} hand${s.hands===1?'':'s'}`,
s.hours?`${(+s.hours).toFixed(1)}h`:''].filter(Boolean).join(' · ');
const href=s.has_recap?`/recap/${s.id}`:`/session`;
const href=`/session?id=${s.id}`; // read-only HUD detail for any session
const net=s.net!=null?money(s.net):(s.status==='live'?'live':'—');
return `<div class="row">
<a class="body" href="${href}">
+72 -4
View File
@@ -84,6 +84,21 @@
.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); }
/* session edit form */
.edit-btn { margin-left: auto; background: #241400; border: 1px solid var(--border); color: var(--accent);
border-radius: 8px; padding: 5px 10px; font-size: .8rem; cursor: pointer; -webkit-tap-highlight-color: transparent; }
.mantra { color: var(--mid); font-style: italic; font-size: .9rem; margin-top: 10px; }
.edit-form { grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 14px; }
.edit-form label { display: flex; flex-direction: column; gap: 4px; font-size: .68rem;
color: var(--fade); text-transform: uppercase; letter-spacing: .4px; }
.edit-form label.wide { grid-column: 1 / -1; }
.edit-form input { background: var(--bg-line); border: 1px solid var(--border); border-radius: 8px;
padding: 8px 10px; color: var(--text); font-size: 16px; }
.edit-form input:focus { outline: none; border-color: var(--accent); }
.edit-actions { grid-column: 1 / -1; display: flex; gap: 8px; justify-content: flex-end; }
.edit-actions button { background: var(--bg-line); border: 1px solid var(--border); color: var(--text);
border-radius: 8px; padding: 8px 16px; cursor: pointer; }
.edit-actions button.save { background: var(--accent); color: #0a0a0a; border-color: var(--accent); font-weight: 600; }
.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); }
@@ -108,6 +123,8 @@
const root = document.getElementById('root');
const dot = document.getElementById('dot');
const updatedEl = document.getElementById('updated');
const SID = new URLSearchParams(location.search).get('id'); // past-session view when set
let curSession = null; // the session object currently rendered (for the edit form)
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(); }
@@ -127,6 +144,16 @@
const h = Math.floor(s/3600), m = Math.round((s%3600)/60);
return h ? `${h}h ${m}m` : `${m}m`;
}
// For a live session: time since start. For a closed one: actual played duration.
function clock(sess){
if(sess.is_live) return elapsed(sess.started_at);
if(sess.hours != null) return (+sess.hours).toFixed(1) + 'h';
if(sess.started_at && sess.ended_at){
const s = Math.max(0,(new Date(sess.ended_at)-new Date(sess.started_at))/1000);
const h=Math.floor(s/3600), m=Math.round((s%3600)/60); return h?`${h}h ${m}m`:`${m}m`;
}
return '—';
}
// Tiny inline sparkline of the stack-over-time series.
function sparkline(series){
@@ -148,6 +175,28 @@
function netClass(v){ return v == null ? 'flat' : v > 0 ? 'up' : v < 0 ? 'down' : 'flat'; }
function toggleEdit(){
const f = document.getElementById('editForm');
if(f) f.style.display = (f.style.display === 'none' || !f.style.display) ? 'grid' : 'none';
}
async function saveEdit(){
if(!curSession) return;
const body = {};
for(const k of ['venue','stakes','game','format','buy_in_total','cash_out','mantra','mood']){
const el = document.getElementById('ed_'+k);
if(!el) continue;
let v = el.value.trim();
if(v === '') continue;
body[k] = (k==='buy_in_total'||k==='cash_out') ? Number(v) : v;
}
try {
const r = await fetch('/session/' + curSession.id, {
method:'PATCH', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
if(!r.ok) throw new Error('HTTP '+r.status);
toggleEdit(); refresh();
} catch(e){ alert('Save failed: '+e.message); }
}
// Delete one logged entry (hand | ritual | read | stack), then refresh.
async function del(kind, id){
if(!confirm('Delete this entry?')) return;
@@ -168,6 +217,7 @@
updatedEl.textContent = '';
return;
}
curSession = s;
const stack = data.stack || {};
const hands = data.hands || [];
const villains = data.villains || [];
@@ -190,14 +240,29 @@
<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>
<span class="sess-sub">${esc(s.venue || 'unknown room')}${!s.is_live && s.status ? ' · '+esc(s.status) : ''}</span>
<button class="edit-btn" onclick="toggleEdit()" title="Edit session details">✎ Edit</button>
</div>
<div class="chips">
<span class="chip"><b>${elapsed(s.started_at)}</b></span>
<span class="chip"><b>${clock(s)}</b></span>
<span class="chip">in <b>${money(s.buy_in_total)}</b></span>
${!s.is_live && s.net != null ? `<span class="chip">net <b class="${netClass(s.net)}" style="font-weight:700">${signed(s.net)}</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>` : ''}
${s.has_recap ? `<a class="chip" style="color:var(--accent);text-decoration:none" href="/recap/${s.id}">📝 recap</a>` : ''}
</div>
${s.mantra ? `<div class="mantra">“${esc(s.mantra)}”</div>` : ''}
<div id="editForm" class="edit-form" style="display:none">
<label>Venue<input id="ed_venue" value="${esc(s.venue||'')}"></label>
<label>Stakes<input id="ed_stakes" value="${esc(s.stakes||'')}"></label>
<label>Game<input id="ed_game" value="${esc(s.game||'')}"></label>
<label>Format<input id="ed_format" value="${esc(s.format||'')}"></label>
<label>Buy-in $<input id="ed_buy_in_total" type="number" value="${s.buy_in_total??''}"></label>
<label>Cash-out $<input id="ed_cash_out" type="number" value="${s.cash_out??''}"></label>
<label class="wide">Mantra<input id="ed_mantra" value="${esc(s.mantra||'')}"></label>
<label class="wide">Mood<input id="ed_mood" value="${esc(s.mood||'')}"></label>
<div class="edit-actions"><button onclick="saveEdit()" class="save">Save</button><button onclick="toggleEdit()">Cancel</button></div>
</div>
</div>
@@ -272,8 +337,11 @@
}
async function refresh(){
// don't clobber the edit form mid-edit on a poll tick
const ef = document.getElementById('editForm');
if (ef && ef.style.display === 'grid') return;
try {
const r = await fetch('/session/data', { cache: 'no-store' });
const r = await fetch('/session/data' + (SID ? ('?id=' + encodeURIComponent(SID)) : ''), { cache: 'no-store' });
const data = await r.json();
data._fetched = new Date().toISOString();
dot.classList.add('pulse'); setTimeout(() => dot.classList.remove('pulse'), 400);
@@ -284,7 +352,7 @@
}
refresh();
setInterval(refresh, 5000);
if (!SID) setInterval(refresh, 5000); // live HUD polls; a past session is static
document.addEventListener('visibilitychange', () => { if (!document.hidden) refresh(); });
</script>
</body>
+49 -6
View File
@@ -179,9 +179,10 @@ def test_undo_last_and_delete_entry(lyra):
_, poker, modes, tools = lyra
assert "undo_last" in modes.CASH.tools
poker.start_session(venue="Meadows", stakes="2/5", buy_in=500)
h1 = poker.log_hand(position="UTG", hole_cards="AA")
h2 = poker.log_hand(position="BTN", hole_cards="72o")
poker.log_stack(600); poker.log_stack(420)
poker.log_hand(position="UTG", hole_cards="AA")
poker.log_hand(position="BTN", hole_cards="72o")
poker.log_stack(600)
poker.log_stack(420)
poker.log_ritual("scar", content="punted")
poker.log_ritual("confidence", content="good fold")
@@ -215,6 +216,48 @@ def test_undo_last_tool(lyra):
assert "one of" in tools.dispatch("undo_last", {"what": "banana"}, {}).lower()
def test_update_session_edit(lyra):
_, poker, modes, tools = lyra
assert "update_session" in modes.CASH.tools
sid = poker.start_session(venue="Meadows", stakes="1/3", buy_in=300)
s = poker.update_session(sid, stakes="2/5", buy_in_total=600, cash_out=900, venue="Bellagio")
assert s["stakes"] == "2/5" and s["venue"] == "Bellagio"
assert s["buy_in_total"] == 600 and s["cash_out"] == 900
assert s["net"] == 300 # recomputed from cash_out - buy_in
# via the tool (edits the live/most-recent session)
out = tools.dispatch("update_session", {"mood": "locked in"}, {})
assert "updated" in out.lower() and poker.get_session(sid)["mood"] == "locked in"
assert "what to change" in tools.dispatch("update_session", {}, {}).lower()
def test_review_session_and_post_close_rituals(lyra):
_, poker, _, tools = lyra
sid = poker.start_session(venue="Meadows", stakes="2/5", buy_in=500)
poker.end_session(cash_out=720)
assert poker.live_session() is None
assert poker.review_session_id() == sid # most-recent closed session
# rituals attach to the closed session during review (no live session needed)
out = tools.dispatch("scar_note", {"content": "should've folded turn", "classification": "punt"}, {})
assert "logged" in out.lower()
tools.dispatch("confidence_bank", {"content": "good thin value river"}, {})
assert len(poker.list_rituals(session_id=sid, kinds=("scar",))) == 1
assert len(poker.list_rituals(session_id=sid, kinds=("confidence",))) == 1
def test_hud_for_past_session(lyra):
_, poker, _, _ = lyra
sid = poker.start_session(venue="Meadows", stakes="2/5", buy_in=500)
poker.log_hand(position="BTN", hole_cards="AKs")
poker.end_session(cash_out=650)
# a *new* live session so live HUD != the one we query
poker.start_session(venue="Wynn", stakes="1/3", buy_in=300)
past = poker.hud(sid)
assert past["session"]["id"] == sid and past["session"]["is_live"] is False
assert past["session"]["net"] == 150 and len(past["hands"]) == 1
assert poker.hud()["session"]["venue"] == "Wynn" # live one unaffected
def test_list_and_delete_session(lyra):
_, poker, _, tools = lyra
keep = poker.start_session(venue="Meadows", stakes="1/3", buy_in=300)
@@ -244,9 +287,9 @@ def test_recent_sessions_tool(lyra):
assert "Meadows" in out and "+220" in out
def test_rituals_require_live_session(lyra):
def test_rituals_require_a_session(lyra):
_, poker, _, tools = lyra
# tools degrade gracefully (no exception) when nothing is open
assert "no live session" in tools.dispatch("scar_note", {"content": "x"}, {}).lower()
# with no session at all, the tool degrades gracefully (no exception)
assert "no session" in tools.dispatch("scar_note", {"content": "x"}, {}).lower()
with pytest.raises(ValueError):
poker.log_ritual("scar", content="x")