5 Commits

Author SHA1 Message Date
serversdown 44a559c5f9 fix: render flat-logged hands + on-demand "build replay"
Quick-logged hands (log_hand) store flat fields with no structured JSON, so the
hand viewer dead-ended with "no structured data to replay" — even when the full
street-by-street action was captured (e.g. the KhQh-vs-Louis hand). Now:

- hand.html renders a readable STATIC view of any flat hand (hero cards, board,
  street narratives, result, lesson) instead of erroring; also handles empty/garbage
  structured rows by falling back to the flat view.
- "▶ Build replay" button + poker.reconstruct_hand + POST /hand/{id}/reconstruct:
  parse a flat hand's narrative into structured form on demand, making any
  quick-logged hand replayable without an LLM call per log during live play.
- test_modes.py +1 (reconstruct wiring).

(Also reconstructed the two live Meadows hands and removed one empty hand in the DB.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 06:02:10 +00:00
serversdown f2de7dec61 feat(web): shared left-sidebar navigation across all pages
Desktop nav was scattered and inconsistent — the chat header was crammed with
cross-page links and each standalone page had its own ad-hoc, incomplete back-links
(e.g. /hands could only reach Chat). Now a single nav.js (one source of truth, no
build step) injects a left sidebar on desktop (>=769px) with active-page
highlighting across Chat/Session/History/Hands/Mind/Journal/Logs + Settings.

- nav.js: injects sidebar + its own CSS; body gets padding-left on desktop; hidden
  on mobile (each page keeps its bottom bar / back-links there).
- Included on every page (index, session, history, hands, self, journal, logs,
  recap, hand).
- Decluttered the chat header: removed the now-redundant cross-page links (kept the
  chat-specific session selector + inline Live Log toggle).
- Sidebar Settings opens the chat modal, or navigates to /?settings=1 from elsewhere.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 05:27:55 +00:00
serversdown 559faaed30 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>
2026-06-21 05:12:13 +00:00
serversdown cca8322ee2 perf: WAL synchronous=NORMAL — stop fsyncing every commit
lyra.db is on disk-backed ext4, where each WAL commit fsync'd (~0.15s here). Every
chat turn does several writes (remember user+assistant, summaries, poker logging),
so this was adding real per-turn latency, and made the dream loop + tests crawl.
synchronous=NORMAL is WAL's recommended companion: durable across app crashes, only
a power/OS crash can drop the last txn (never corrupts). Per-write dropped from
~0.4s to ~0.001s; test suite 72s -> 24s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 05:12:13 +00:00
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
16 changed files with 592 additions and 39 deletions
+5
View File
@@ -131,6 +131,11 @@ def _connection() -> sqlite3.Connection:
# alongside the web server without tripping "database is locked". # alongside the web server without tripping "database is locked".
_conn.execute("PRAGMA busy_timeout=5000") _conn.execute("PRAGMA busy_timeout=5000")
_conn.execute("PRAGMA journal_mode=WAL") _conn.execute("PRAGMA journal_mode=WAL")
# WAL's recommended companion: don't fsync on every commit (only at
# checkpoint). Safe against app crashes; a power/OS crash can lose the last
# txn but never corrupt. On disk-backed storage this turns ~0.15s-per-commit
# fsync latency into ~nothing — big win for per-turn writes + the dream loop.
_conn.execute("PRAGMA synchronous=NORMAL")
_conn.executescript(SCHEMA) _conn.executescript(SCHEMA)
# Migrations for DBs created before a column existed (no-op if present). # Migrations for DBs created before a column existed (no-op if present).
for ddl in ("ALTER TABLE sessions ADD COLUMN mode TEXT",): for ddl in ("ALTER TABLE sessions ADD COLUMN mode TEXT",):
+1
View File
@@ -44,6 +44,7 @@ _CASH_TOOLS = _BASE + _LOOKUPS + (
"start_session", "add_buyin", "log_stack", "log_hand", "record_hand", "start_session", "add_buyin", "log_stack", "log_hand", "record_hand",
"add_read", "analyze_spot", "session_stats", "session_state", "end_session", "add_read", "analyze_spot", "session_stats", "session_state", "end_session",
"generate_recap", "scar_note", "confidence_bank", "alligator_blood", "reset_ritual", "generate_recap", "scar_note", "confidence_bank", "alligator_blood", "reset_ritual",
"undo_last", "update_session",
) )
# Talk mode also gets start_session as the *entry point*: opening a session from a # Talk mode also gets start_session as the *entry point*: opening a session from a
+166 -3
View File
@@ -242,6 +242,90 @@ def delete_session(session_id: int) -> dict:
return counts 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: def live_session() -> dict | None:
"""The current open session, if any.""" """The current open session, if any."""
r = _c().execute( r = _c().execute(
@@ -257,6 +341,50 @@ def _resolve(session_id: int | None) -> int | None:
return live["id"] if live else 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: def add_buyin(amount: float, session_id: int | None = None) -> float:
"""Add a buy-in/rebuy to a session. Returns the new total in.""" """Add a buy-in/rebuy to a session. Returns the new total in."""
sid = _resolve(session_id) sid = _resolve(session_id)
@@ -308,7 +436,7 @@ def stack_log(session_id: int | None = None) -> list[dict]:
if sid is None: if sid is None:
return [] return []
return [dict(r) for r in _c().execute( 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,), (sid,),
).fetchall()] ).fetchall()]
@@ -585,6 +713,38 @@ def record_hand(shorthand: str, session_id: int | None = None, stakes: str | Non
return {"id": hid, "parsed": parsed, "linked": linked} return {"id": hid, "parsed": parsed, "linked": linked}
def reconstruct_hand(hand_id: int, backend: str | None = None) -> dict | None:
"""Upgrade a flat (log_hand) hand into a structured, replayable one by parsing
its captured street narratives. On-demand so quick-logged live hands can become
replayable without an LLM call per log during play."""
h = get_hand(hand_id)
if not h:
return None
parts = []
if h.get("position") or h.get("hole_cards"):
parts.append(f"Hero is {h.get('position') or '?'} with {h.get('hole_cards') or 'unknown'}.")
for st in ("preflop", "flop", "turn", "river", "showdown"):
if h.get(st):
parts.append(f"{st.capitalize()}: {h[st]}")
if h.get("board"):
parts.append(f"Final board: {h['board']}.")
if h.get("result") is not None:
parts.append(f"Hero net result: {h['result']}.")
shorthand = " ".join(parts).strip()
if not shorthand:
return None
parsed = parse_hand(shorthand, backend=backend)
if not parsed:
return None
parsed = _normalize_parsed(parsed)
conn = _c()
with conn:
conn.execute("UPDATE poker_hands SET structured = ? WHERE id = ?",
(json.dumps(parsed), hand_id))
link_hand_players(hand_id, parsed, session_id=h.get("session_id"))
return {"id": hand_id, "parsed": parsed}
def get_hand(hand_id: int) -> dict | None: def get_hand(hand_id: int) -> dict | None:
"""A stored hand with its structured JSON parsed back into a dict.""" """A stored hand with its structured JSON parsed back into a dict."""
r = _c().execute("SELECT * FROM poker_hands WHERE id = ?", (hand_id,)).fetchone() r = _c().execute("SELECT * FROM poker_hands WHERE id = ?", (hand_id,)).fetchone()
@@ -996,7 +1156,7 @@ def hud(session_id: int | None = None) -> dict | None:
rituals = list_rituals(sid) rituals = list_rituals(sid)
by_kind = lambda k: [ # noqa: E731 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"]} "hand_id": r["hand_id"], "at": r["created_at"]}
for r in rituals if r["kind"] == k for r in rituals if r["kind"] == k
] ]
@@ -1006,7 +1166,10 @@ def hud(session_id: int | None = None) -> dict | None:
"id": sid, "venue": s.get("venue"), "stakes": s.get("stakes"), "id": sid, "venue": s.get("venue"), "stakes": s.get("stakes"),
"game": s.get("game"), "format": s.get("format"), "game": s.get("game"), "format": s.get("format"),
"status": s.get("status"), "started_at": s.get("started_at"), "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": { "stack": {
"current": state["current"], "buy_in": state["buy_in"], "net": state["net"], "current": state["current"], "buy_in": state["buy_in"], "net": state["net"],
+68 -13
View File
@@ -117,6 +117,40 @@ def _log_stack(args: dict, ctx: dict) -> str:
return f"Stack ${amount:g} logged" + (f" (net {net:+.0f})." if net is not None else ".") 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",
"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: def _scar_note(args: dict, ctx: dict) -> str:
content = (args.get("content") or "").strip() content = (args.get("content") or "").strip()
if not content: if not content:
@@ -124,11 +158,11 @@ def _scar_note(args: dict, ctx: dict) -> str:
cls = (args.get("classification") or "").strip().lower() or None cls = (args.get("classification") or "").strip().lower() or None
if cls and cls not in ("punt", "cooler", "standard"): if cls and cls not in ("punt", "cooler", "standard"):
cls = None cls = None
try: sid = poker.review_session_id() # live, or the most-recent session (post-game review)
poker.log_ritual("scar", content=content, classification=cls, if sid is None:
hand_id=args.get("hand_id")) return "No session yet — start one and I'll keep the scar notes."
except ValueError: poker.log_ritual("scar", content=content, classification=cls,
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 ''}." return f"Scar note logged{f' ({cls})' if cls else ''}."
@@ -136,10 +170,10 @@ def _confidence_bank(args: dict, ctx: dict) -> str:
content = (args.get("content") or "").strip() content = (args.get("content") or "").strip()
if not content: if not content:
return "Nothing to bank — tell me the good process." return "Nothing to bank — tell me the good process."
try: sid = poker.review_session_id()
poker.log_ritual("confidence", content=content, hand_id=args.get("hand_id")) if sid is None:
except ValueError: return "No session yet — start one and I'll run the confidence bank."
return "No live session — 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. 💰" return "Banked. 💰"
@@ -155,10 +189,10 @@ def _alligator_blood(args: dict, ctx: dict) -> str:
def _reset_ritual(args: dict, ctx: dict) -> str: def _reset_ritual(args: dict, ctx: dict) -> str:
content = (args.get("content") or "").strip() or None content = (args.get("content") or "").strip() or None
try: sid = poker.review_session_id()
poker.log_ritual("reset", content=content) if sid is None:
except ValueError: return "No session to reset."
return "No live 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." return "Reset logged. Clean slate — this is a new session in your head."
@@ -370,6 +404,27 @@ TOOLS.update({
"add_buyin": {"handler": _add_buyin, "spec": _f( "add_buyin": {"handler": _add_buyin, "spec": _f(
"add_buyin", "Record a rebuy / additional buy-in in the live session.", "add_buyin", "Record a rebuy / additional buy-in in the live session.",
{"amount": {**_N, "description": "Amount added"}}, ["amount"])}, {"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 "
"'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": {"handler": _log_stack, "spec": _f(
"log_stack", "log_stack",
"Record Brian's CURRENT total chip stack in the live session. Call whenever " "Record Brian's CURRENT total chip stack in the live session. Call whenever "
+25 -3
View File
@@ -108,11 +108,26 @@ def create_app() -> FastAPI:
return FileResponse(str(_STATIC / "session.html")) return FileResponse(str(_STATIC / "session.html"))
@app.get("/session/data") @app.get("/session/data")
async def session_hud_data() -> dict: async def session_hud_data(id: int | None = None) -> dict:
"""The current live session's HUD bundle (or {session: None} if none open).""" """HUD bundle for the live session, or a specific past session via ?id=."""
bundle = await asyncio.to_thread(poker.hud) bundle = await asyncio.to_thread(poker.hud, id)
return bundle or {"session": None} 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."""
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") @app.get("/history")
async def history_page() -> FileResponse: async def history_page() -> FileResponse:
"""Browsable list of past poker sessions.""" """Browsable list of past poker sessions."""
@@ -263,6 +278,13 @@ def create_app() -> FastAPI:
async def hand_data(hand_id: int) -> dict: async def hand_data(hand_id: int) -> dict:
return poker.get_hand(hand_id) or {} return poker.get_hand(hand_id) or {}
@app.post("/hand/{hand_id}/reconstruct")
async def hand_reconstruct(hand_id: int) -> dict:
"""Parse a flat (quick-logged) hand's narrative into a replayable structure."""
out = await asyncio.to_thread(poker.reconstruct_hand, hand_id)
logbus.log("info", "hand reconstructed", id=hand_id, ok=out is not None)
return {"ok": out is not None}
@app.get("/hands") @app.get("/hands")
async def hands_page() -> FileResponse: async def hands_page() -> FileResponse:
return FileResponse(str(_STATIC / "hands.html")) return FileResponse(str(_STATIC / "hands.html"))
+41 -1
View File
@@ -95,11 +95,50 @@
return `<span class="card${sm?' sm':''}${RED.has(s)?' red':''}"><span class="r">${r}</span><span>${SUIT[s]}</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(''); 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){ function render(h){
const sub = document.getElementById('sub'); const sub = document.getElementById('sub');
const data = h.structured; const data = h.structured;
if(!data){ document.getElementById('root').innerHTML = '<p class="err">This hand has no structured data to replay.</p>'; return; } const hasReplay = data && (((data.players||[]).length) || ((data.actions||[]).length));
if(!hasReplay){ renderFlat(h); return; }
const players = (data.players||[]).slice(); const players = (data.players||[]).slice();
// order so hero sits at the bottom // order so hero sits at the bottom
@@ -247,5 +286,6 @@
} }
load(); load();
</script> </script>
<script src="/nav.js"></script>
</body> </body>
</html> </html>
+1
View File
@@ -80,5 +80,6 @@
} }
load(); load();
</script> </script>
<script src="/nav.js"></script>
</body> </body>
</html> </html>
+2 -1
View File
@@ -85,7 +85,7 @@
const date=(s.started_at||'').slice(0,10); const date=(s.started_at||'').slice(0,10);
const meta=[date,s.venue,`${s.hands} hand${s.hands===1?'':'s'}`, const meta=[date,s.venue,`${s.hands} hand${s.hands===1?'':'s'}`,
s.hours?`${(+s.hours).toFixed(1)}h`:''].filter(Boolean).join(' · '); 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':'—'); const net=s.net!=null?money(s.net):(s.status==='live'?'live':'—');
return `<div class="row"> return `<div class="row">
<a class="body" href="${href}"> <a class="body" href="${href}">
@@ -100,5 +100,6 @@
} }
load(); load();
</script> </script>
<script src="/nav.js"></script>
</body> </body>
</html> </html>
+4 -5
View File
@@ -80,11 +80,6 @@
<button id="newSessionBtn"> New</button> <button id="newSessionBtn"> New</button>
<button id="renameSessionBtn">✏️ Rename</button> <button id="renameSessionBtn">✏️ Rename</button>
<button id="thinkingStreamBtn" title="Show live activity log">📜 Live Log</button> <button id="thinkingStreamBtn" title="Show live activity log">📜 Live Log</button>
<a id="fullLogBtn" href="/logs" target="_blank" rel="noopener" title="Open the full-page log" role="button">⛶ Full Log</a>
<a id="sessionBtn" href="/session" target="_blank" rel="noopener" title="Live session HUD" role="button">🎬 Session</a>
<a id="historyBtn" href="/history" target="_blank" rel="noopener" title="Past sessions" role="button">📚 Sessions</a>
<a id="mindBtn" href="/self" target="_blank" rel="noopener" title="Read her mind — Lyra's current self-state" role="button">🧠 Mind</a>
<a id="handsBtn" href="/hands" target="_blank" rel="noopener" title="Recorded poker hands" role="button">🃏 Hands</a>
</div> </div>
<!-- Status --> <!-- Status -->
@@ -990,6 +985,9 @@
loadSessionList(); // Refresh session list when opening settings loadSessionList(); // Refresh session list when opening settings
}); });
// Sidebar "Settings" from another page navigates here with ?settings=1.
if (new URLSearchParams(location.search).get("settings")) settingsBtn.click();
// Hide modal functions // Hide modal functions
const hideModal = () => { const hideModal = () => {
settingsModal.classList.remove("show"); settingsModal.classList.remove("show");
@@ -1187,5 +1185,6 @@
}); });
}); });
</script> </script>
<script src="/nav.js"></script>
</body> </body>
</html> </html>
+1
View File
@@ -157,5 +157,6 @@
setInterval(load, 20000); setInterval(load, 20000);
document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); }); document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); });
</script> </script>
<script src="/nav.js"></script>
</body> </body>
</html> </html>
+1
View File
@@ -235,5 +235,6 @@
} }
connect(); connect();
</script> </script>
<script src="/nav.js"></script>
</body> </body>
</html> </html>
+76
View File
@@ -0,0 +1,76 @@
/* Shared app navigation — one source of truth across all pages (no build step).
Injects a left sidebar on desktop (>=769px) with active-page highlighting; stays
out of the way on mobile, where each page keeps its bottom bar / back-links. */
(function () {
const ITEMS = [
{ href: "/", icon: "💬", label: "Chat" },
{ href: "/session", icon: "♠", label: "Session" },
{ href: "/history", icon: "📚", label: "History" },
{ href: "/hands", icon: "🃏", label: "Hands" },
{ href: "/self", icon: "🧠", label: "Mind" },
{ href: "/journal", icon: "📔", label: "Journal" },
{ href: "/logs", icon: "📜", label: "Logs" },
];
const path = location.pathname;
function isActive(href) {
if (href === "/") return path === "/" || path === "";
if (href === "/hands") return path === "/hands" || path.indexOf("/hand") === 0;
if (href === "/history") return path.indexOf("/history") === 0 || path.indexOf("/recap") === 0;
return path === href || path.indexOf(href + "/") === 0;
}
const css = `
#app-nav { display: none; }
@media screen and (min-width: 769px) {
body { padding-left: 212px; }
#app-nav {
position: fixed; left: 0; top: 0; bottom: 0; width: 212px; z-index: 1000;
display: flex; flex-direction: column; gap: 2px; box-sizing: border-box;
padding: 14px 10px; background: #0b0b0b; border-right: 1px solid #2a1d12;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
#app-nav .brand {
display: flex; align-items: center; gap: 8px; text-decoration: none;
color: #ff7a00; font-weight: 700; font-size: 1.15rem; letter-spacing: .5px;
padding: 6px 11px 14px;
}
#app-nav .brand .dot { width: 8px; height: 8px; border-radius: 50%;
background: #8fd694; box-shadow: 0 0 8px rgba(143,214,148,.6); }
#app-nav .navitem {
display: flex; align-items: center; gap: 11px; width: 100%; text-align: left;
padding: 9px 11px; border-radius: 9px; border: none; background: none;
color: #cfcfcf; text-decoration: none; font-size: .95rem; cursor: pointer;
font-family: inherit; -webkit-tap-highlight-color: transparent;
}
#app-nav .navitem .i { font-size: 1.05rem; width: 20px; text-align: center; filter: grayscale(.3); }
#app-nav .navitem:hover { background: rgba(255,122,0,.08); color: #fff; }
#app-nav .navitem.active { background: rgba(255,122,0,.14); color: #ff7a00; }
#app-nav .navitem.active .i { filter: none; }
#app-nav .spacer { flex: 1; }
}`;
const style = document.createElement("style");
style.textContent = css;
document.head.appendChild(style);
const nav = document.createElement("nav");
nav.id = "app-nav";
nav.setAttribute("aria-label", "App navigation");
nav.innerHTML =
'<a class="brand" href="/"><span class="dot"></span> Lyra</a>' +
ITEMS.map(function (it) {
return '<a class="navitem' + (isActive(it.href) ? " active" : "") + '" href="' + it.href + '">' +
'<span class="i">' + it.icon + '</span><span class="l">' + it.label + "</span></a>";
}).join("") +
'<div class="spacer"></div>' +
'<button class="navitem" id="navSettings" type="button"><span class="i">⚙</span><span class="l">Settings</span></button>';
document.body.insertBefore(nav, document.body.firstChild);
// Settings opens the chat-page modal; from other pages, jump to chat and open it.
nav.querySelector("#navSettings").addEventListener("click", function () {
const btn = document.getElementById("settingsBtn");
if (btn) btn.click();
else location.href = "/?settings=1";
});
})();
+1
View File
@@ -74,5 +74,6 @@
} }
load(); load();
</script> </script>
<script src="/nav.js"></script>
</body> </body>
</html> </html>
+1
View File
@@ -195,5 +195,6 @@
setInterval(refresh, 12000); setInterval(refresh, 12000);
document.addEventListener('visibilitychange', () => { if (!document.hidden) refresh(); }); document.addEventListener('visibilitychange', () => { if (!document.hidden) refresh(); });
</script> </script>
<script src="/nav.js"></script>
</body> </body>
</html> </html>
+95 -10
View File
@@ -78,6 +78,27 @@
.scar-cls.standard { color: var(--fade); } .scar-cls.standard { color: var(--fade); }
.card.scar { border-color: #4a2222; } .card.scar .label { color: #d98a8a; } .card.scar { border-color: #4a2222; } .card.scar .label { color: #d98a8a; }
.card.conf { border-color: #234a23; } .card.conf .label { color: var(--good); } .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); }
/* 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; } .empty { color: var(--fade); font-size: .92rem; }
.err { color: var(--low); text-align: center; padding: 30px; } .err { color: var(--low); text-align: center; padding: 30px; }
.big-empty { text-align: center; padding: 50px 20px; color: var(--fade); } .big-empty { text-align: center; padding: 50px 20px; color: var(--fade); }
@@ -102,6 +123,8 @@
const root = document.getElementById('root'); const root = document.getElementById('root');
const dot = document.getElementById('dot'); const dot = document.getElementById('dot');
const updatedEl = document.getElementById('updated'); 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 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 money(v){ if (v == null) return '—'; const n = Number(v); return (n<0?'-$':'$') + Math.abs(n).toLocaleString(); }
@@ -121,6 +144,16 @@
const h = Math.floor(s/3600), m = Math.round((s%3600)/60); const h = Math.floor(s/3600), m = Math.round((s%3600)/60);
return h ? `${h}h ${m}m` : `${m}m`; 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. // Tiny inline sparkline of the stack-over-time series.
function sparkline(series){ function sparkline(series){
@@ -142,6 +175,38 @@
function netClass(v){ return v == null ? 'flat' : v > 0 ? 'up' : v < 0 ? 'down' : 'flat'; } 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;
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){ function render(data){
const s = data.session; const s = data.session;
if (!s) { if (!s) {
@@ -152,6 +217,7 @@
updatedEl.textContent = ''; updatedEl.textContent = '';
return; return;
} }
curSession = s;
const stack = data.stack || {}; const stack = data.stack || {};
const hands = data.hands || []; const hands = data.hands || [];
const villains = data.villains || []; const villains = data.villains || [];
@@ -174,14 +240,29 @@
<div class="card"> <div class="card">
<div class="sess-top"> <div class="sess-top">
<span class="sess-title">${esc(title)}</span> <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>
<div class="chips"> <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> <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">${esc(s.format || 'cash')}</span>
<span class="chip"><b>${hands.length}</b> hands</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>` : ''} ${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>
</div> </div>
@@ -199,29 +280,29 @@
<div class="card"> <div class="card">
<p class="label">Hands this session</p> <p class="label">Hands this session</p>
${hands.length ? `<ul class="rows">${hands.slice().reverse().map(h => ` ${hands.length ? `<ul class="rows">${hands.slice().reverse().map(h => `
<li><a class="hand" href="/hand/${h.id}"> <li class="row-del"><a class="hand" href="/hand/${h.id}">
<span class="pos">${esc(h.position || '?')}</span> <span class="pos">${esc(h.position || '?')}</span>
<span class="cards">${esc(h.hole_cards || '')}${h.board ? ' · '+esc(h.board) : ''}</span> <span class="cards">${esc(h.hole_cards || '')}${h.board ? ' · '+esc(h.board) : ''}</span>
${h.tag ? `<span class="tag">${esc(h.tag)}</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>` : ''} ${h.result != null ? `<span class="res ${h.result>=0?'up':'down'}">${signed(h.result)}</span>` : ''}
</a></li>`).join('')}</ul>` </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>'} : '<p class="empty">No hands logged yet.</p>'}
</div> </div>
<div class="card conf"> <div class="card conf">
<p class="label">💰 Confidence Bank</p> <p class="label">💰 Confidence Bank</p>
${confidence.length ? `<ul class="rows">${confidence.slice().reverse().map(c => ` ${confidence.length ? `<ul class="rows">${confidence.slice().reverse().map(c => `
<li>${esc(c.content)}${c.hand_id ? ` · <a class="hand" style="display:inline" href="/hand/${c.hand_id}">hand</a>` : ''} <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></li>`).join('')}</ul>` <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>'} : '<p class="empty">Nothing banked yet — disciplined plays land here.</p>'}
</div> </div>
<div class="card scar"> <div class="card scar">
<p class="label">🩹 Scar Notes</p> <p class="label">🩹 Scar Notes</p>
${scars.length ? `<ul class="rows">${scars.slice().reverse().map(sc => ` ${scars.length ? `<ul class="rows">${scars.slice().reverse().map(sc => `
<li>${esc(sc.content)}${sc.classification ? `<span class="scar-cls ${esc(sc.classification)}">${esc(sc.classification)}</span>` : ''} <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>` : ''} ${sc.hand_id ? ` · <a class="hand" style="display:inline" href="/hand/${sc.hand_id}">hand</a>` : ''}
<div class="note-meta">${ago(sc.at)}</div></li>`).join('')}</ul>` <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>'} : '<p class="empty">No scars logged — mistakes to study land here.</p>'}
</div> </div>
@@ -256,8 +337,11 @@
} }
async function refresh(){ 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 { 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(); const data = await r.json();
data._fetched = new Date().toISOString(); data._fetched = new Date().toISOString();
dot.classList.add('pulse'); setTimeout(() => dot.classList.remove('pulse'), 400); dot.classList.add('pulse'); setTimeout(() => dot.classList.remove('pulse'), 400);
@@ -268,8 +352,9 @@
} }
refresh(); refresh();
setInterval(refresh, 5000); if (!SID) setInterval(refresh, 5000); // live HUD polls; a past session is static
document.addEventListener('visibilitychange', () => { if (!document.hidden) refresh(); }); document.addEventListener('visibilitychange', () => { if (!document.hidden) refresh(); });
</script> </script>
<script src="/nav.js"></script>
</body> </body>
</html> </html>
+104 -3
View File
@@ -175,6 +175,107 @@ def test_session_state_readback(lyra):
assert "great river fold" in out assert "great river fold" in out
def test_reconstruct_flat_hand(lyra, monkeypatch):
_, poker, _, _ = lyra
poker.start_session(stakes="1/3", buy_in=300)
hid = poker.log_hand(position="UTG", hole_cards="KhQh",
preflop="UTG raises, BTN calls", flop="Qd Qs Jc, bet, call",
river="Kd, all in, called", showdown="hero wins", result=225)
assert poker.get_hand(hid)["structured"] is None # flat (log_hand) — not replayable yet
monkeypatch.setattr(poker, "parse_hand", lambda *a, **k: {
"hero_pos": "UTG", "hero_cards": ["Kh", "Qh"],
"players": [{"pos": "UTG"}],
"actions": [{"street": "preflop", "pos": "UTG", "action": "raise"}],
"board": ["Qd", "Qs", "Jc", "6d", "Kd"]})
out = poker.reconstruct_hand(hid)
assert out is not None
h = poker.get_hand(hid)
assert h["structured"]["hero_pos"] == "UTG" and len(h["structured"]["actions"]) == 1
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)
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")
# 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_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): def test_list_and_delete_session(lyra):
_, poker, _, tools = lyra _, poker, _, tools = lyra
keep = poker.start_session(venue="Meadows", stakes="1/3", buy_in=300) keep = poker.start_session(venue="Meadows", stakes="1/3", buy_in=300)
@@ -204,9 +305,9 @@ def test_recent_sessions_tool(lyra):
assert "Meadows" in out and "+220" in out 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 _, poker, _, tools = lyra
# tools degrade gracefully (no exception) when nothing is open # with no session at all, the tool degrades gracefully (no exception)
assert "no live session" in tools.dispatch("scar_note", {"content": "x"}, {}).lower() assert "no session" in tools.dispatch("scar_note", {"content": "x"}, {}).lower()
with pytest.raises(ValueError): with pytest.raises(ValueError):
poker.log_ritual("scar", content="x") poker.log_ritual("scar", content="x")