diff --git a/lyra/modes.py b/lyra/modes.py index 950e53c..3155cf9 100644 --- a/lyra/modes.py +++ b/lyra/modes.py @@ -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 diff --git a/lyra/poker.py b/lyra/poker.py index 13868e7..19f2e31 100644 --- a/lyra/poker.py +++ b/lyra/poker.py @@ -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"], diff --git a/lyra/tools.py b/lyra/tools.py index b5111b5..d98c3ed 100644 --- a/lyra/tools.py +++ b/lyra/tools.py @@ -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: - 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." + 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"), 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 " diff --git a/lyra/web/server.py b/lyra/web/server.py index 9b3d5bf..79785c9 100644 --- a/lyra/web/server.py +++ b/lyra/web/server.py @@ -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.""" diff --git a/lyra/web/static/history.html b/lyra/web/static/history.html index 167cf0c..43224c1 100644 --- a/lyra/web/static/history.html +++ b/lyra/web/static/history.html @@ -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 `
diff --git a/lyra/web/static/session.html b/lyra/web/static/session.html index f0d2170..dbc674c 100644 --- a/lyra/web/static/session.html +++ b/lyra/web/static/session.html @@ -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 @@
${esc(title)} - ${esc(s.venue || 'unknown room')}${s.status && s.status!=='live' ? ' · '+esc(s.status) : ''} + ${esc(s.venue || 'unknown room')}${!s.is_live && s.status ? ' · '+esc(s.status) : ''} +
+ ${s.mantra ? `
“${esc(s.mantra)}”
` : ''} +
@@ -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(); }); diff --git a/tests/test_modes.py b/tests/test_modes.py index 74d79f0..0c2c21d 100644 --- a/tests/test_modes.py +++ b/tests/test_modes.py @@ -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")