diff --git a/lyra/modes.py b/lyra/modes.py index 49e4ccb..950e53c 100644 --- a/lyra/modes.py +++ b/lyra/modes.py @@ -44,6 +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", ) # 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 d0bea6b..13868e7 100644 --- a/lyra/poker.py +++ b/lyra/poker.py @@ -242,6 +242,90 @@ def delete_session(session_id: int) -> dict: return counts +# --- per-entry deletes / undo (fix fat-fingered live logging) --- + +def delete_hand(hand_id: int) -> bool: + """Delete one hand and any player observations derived from it.""" + conn = _c() + with conn: + conn.execute("DELETE FROM player_observations WHERE hand_id = ?", (hand_id,)) + cur = conn.execute("DELETE FROM poker_hands WHERE id = ?", (hand_id,)) + return cur.rowcount > 0 + + +def delete_stack(stack_id: int) -> bool: + conn = _c() + with conn: + cur = conn.execute("DELETE FROM poker_stack_log WHERE id = ?", (stack_id,)) + return cur.rowcount > 0 + + +def delete_read(read_id: int) -> bool: + conn = _c() + with conn: + cur = conn.execute("DELETE FROM player_reads WHERE id = ?", (read_id,)) + return cur.rowcount > 0 + + +def delete_ritual(ritual_id: int) -> bool: + conn = _c() + with conn: + cur = conn.execute("DELETE FROM poker_rituals WHERE id = ?", (ritual_id,)) + return cur.rowcount > 0 + + +def delete_entry(kind: str, entry_id: int) -> bool: + """Dispatch a per-entry delete by kind — for the HUD's row delete buttons.""" + return { + "hand": delete_hand, "stack": delete_stack, + "read": delete_read, "ritual": delete_ritual, + }.get(kind, lambda _id: False)(entry_id) + + +def undo_last(kind: str, session_id: int | None = None) -> str | None: + """Delete the most-recent entry of `kind` in the live session and return a short + description of what was removed (None if there was nothing). For "scratch that". + + kind: hand | stack | read | scar | confidence | reset | ritual. + """ + sid = _resolve(session_id) + if sid is None: + raise ValueError("no live session") + k = (kind or "").lower().strip() + + if k in ("scar", "confidence", "reset", "ritual"): + sql = ("SELECT id, kind, content FROM poker_rituals WHERE session_id = ? " + + ("AND kind = ? " if k != "ritual" else "AND kind IN ('scar','confidence','reset') ") + + "ORDER BY id DESC LIMIT 1") + params = (sid, k) if k != "ritual" else (sid,) + r = _c().execute(sql, params).fetchone() + if not r: + return None + delete_ritual(r["id"]) + label = _RITUAL_LABEL.get(r["kind"], r["kind"]) + return f"{label}" + (f": {r['content']}" if r["content"] else "") + + table, desc_cols = { + "hand": ("poker_hands", "position, hole_cards"), + "stack": ("poker_stack_log", "amount"), + "read": ("player_reads", "note"), + }.get(k, (None, None)) + if not table: + return None + r = _c().execute( + f"SELECT id, {desc_cols} FROM {table} WHERE session_id = ? ORDER BY id DESC LIMIT 1", + (sid,), + ).fetchone() + if not r: + return None + delete_entry(k, r["id"]) + if k == "hand": + return f"hand ({(r['position'] or '?')} {r['hole_cards'] or ''})".strip() + if k == "stack": + return f"stack ${r['amount']:g}" + return f"read: {r['note'][:50]}" + + def live_session() -> dict | None: """The current open session, if any.""" r = _c().execute( @@ -308,7 +392,7 @@ def stack_log(session_id: int | None = None) -> list[dict]: if sid is None: return [] return [dict(r) for r in _c().execute( - "SELECT amount, created_at FROM poker_stack_log WHERE session_id = ? ORDER BY id", + "SELECT id, amount, created_at FROM poker_stack_log WHERE session_id = ? ORDER BY id", (sid,), ).fetchall()] @@ -996,7 +1080,7 @@ def hud(session_id: int | None = None) -> dict | None: rituals = list_rituals(sid) by_kind = lambda k: [ # noqa: E731 - {"content": r["content"], "classification": r["classification"], + {"id": r["id"], "content": r["content"], "classification": r["classification"], "hand_id": r["hand_id"], "at": r["created_at"]} for r in rituals if r["kind"] == k ] diff --git a/lyra/tools.py b/lyra/tools.py index 8326f39..b5111b5 100644 --- a/lyra/tools.py +++ b/lyra/tools.py @@ -117,6 +117,25 @@ 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 _undo_last(args: dict, ctx: dict) -> str: + what = (args.get("what") or "").strip().lower() + aliases = {"hands": "hand", "stacks": "stack", "reads": "read", + "scar_note": "scar", "confidence_bank": "confidence", + "scar note": "scar", "confidence": "confidence", "note": "ritual"} + what = aliases.get(what, what) + valid = ("hand", "stack", "read", "scar", "confidence", "reset", "ritual") + if what not in valid: + return f"Tell me what to undo — one of: {', '.join(valid)}." + try: + removed = poker.undo_last(what) + except ValueError: + return "No live session to undo anything in." + if not removed: + return f"Nothing logged to undo for '{what}'." + logbus.log("info", "undo last", what=what, removed=removed[:60]) + return f"Scratched the last {what} — removed {removed}." + + def _scar_note(args: dict, ctx: dict) -> str: content = (args.get("content") or "").strip() if not content: @@ -370,6 +389,13 @@ 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"])}, + "undo_last": {"handler": _undo_last, "spec": _f( + "undo_last", + "Undo/delete the most recent logged entry in the live session when Brian says " + "'scratch that', 'delete that', 'that was wrong', etc. Specify what: 'hand', " + "'stack', 'read', 'scar', 'confidence', or 'reset'.", + {"what": {**_S, "description": "hand | stack | read | scar | confidence | reset"}}, + ["what"])}, "log_stack": {"handler": _log_stack, "spec": _f( "log_stack", "Record Brian's CURRENT total chip stack in the live session. Call whenever " diff --git a/lyra/web/server.py b/lyra/web/server.py index 444d1fc..9b3d5bf 100644 --- a/lyra/web/server.py +++ b/lyra/web/server.py @@ -113,6 +113,13 @@ def create_app() -> FastAPI: bundle = await asyncio.to_thread(poker.hud) return bundle or {"session": None} + @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.""" + ok = await asyncio.to_thread(poker.delete_entry, kind, entry_id) + logbus.log("info", "hud entry deleted", kind=kind, id=entry_id, ok=ok) + return {"ok": ok} + @app.get("/history") async def history_page() -> FileResponse: """Browsable list of past poker sessions.""" diff --git a/lyra/web/static/session.html b/lyra/web/static/session.html index f7cac0f..f0d2170 100644 --- a/lyra/web/static/session.html +++ b/lyra/web/static/session.html @@ -78,6 +78,12 @@ .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); } @@ -142,6 +148,16 @@ 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) { @@ -199,29 +215,29 @@

Hands this session

${hands.length ? `` + `).join('')}` : '

No hands logged yet.

'}

💰 Confidence Bank

${confidence.length ? `` +
  • ${esc(c.content)}${c.hand_id ? ` · hand` : ''} +
    ${ago(c.at)}
  • `).join('')}` : '

    Nothing banked yet — disciplined plays land here.

    '}

    🩹 Scar Notes

    ${scars.length ? `` +
    ${ago(sc.at)}
    `).join('')}` : '

    No scars logged — mistakes to study land here.

    '}
    diff --git a/tests/test_modes.py b/tests/test_modes.py index e5366b5..74d79f0 100644 --- a/tests/test_modes.py +++ b/tests/test_modes.py @@ -175,6 +175,46 @@ def test_session_state_readback(lyra): assert "great river fold" in out +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_ritual("scar", content="punted") + poker.log_ritual("confidence", content="good fold") + + # undo removes the most recent of each kind + assert "72o" in poker.undo_last("hand") + assert [h["hole_cards"] for h in poker.list_hands()] == ["AA"] # h2 gone, h1 stays + assert "420" in poker.undo_last("stack") + assert poker.current_stack() == 600 + assert "punted" in poker.undo_last("scar") + assert not poker.list_rituals(kinds=("scar",)) + assert poker.list_rituals(kinds=("confidence",)) # untouched + assert poker.undo_last("hand") is not None # h1 + assert poker.undo_last("hand") is None # nothing left + + # direct delete-by-id dispatch + assert poker.delete_entry("ritual", poker.list_rituals(kinds=("confidence",))[0]["id"]) is True + assert poker.delete_entry("bogus", 1) is False + + +def test_undo_last_tool(lyra): + _, poker, _, tools = lyra + poker.start_session(stakes="1/3", buy_in=300) + poker.log_hand(position="CO", hole_cards="KK") + out = tools.dispatch("undo_last", {"what": "hand"}, {}) + assert "scratched" in out.lower() and poker.list_hands() == [] + # no live session -> graceful + poker.end_session(cash_out=300) + assert "no live session" in tools.dispatch("undo_last", {"what": "hand"}, {}).lower() + # nonsense target + poker.start_session(stakes="1/3", buy_in=100) + assert "one of" in tools.dispatch("undo_last", {"what": "banana"}, {}).lower() + + def test_list_and_delete_session(lyra): _, poker, _, tools = lyra keep = poker.start_session(venue="Meadows", stakes="1/3", buy_in=300)