Compare commits
5 Commits
df591e4e01
...
44a559c5f9
| Author | SHA1 | Date | |
|---|---|---|---|
| 44a559c5f9 | |||
| f2de7dec61 | |||
| 559faaed30 | |||
| cca8322ee2 | |||
| 67cf51a53f |
@@ -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",):
|
||||||
|
|||||||
@@ -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
@@ -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"],
|
||||||
|
|||||||
+67
-12
@@ -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)
|
||||||
|
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,
|
poker.log_ritual("scar", content=content, classification=cls,
|
||||||
hand_id=args.get("hand_id"))
|
hand_id=args.get("hand_id"), session_id=sid)
|
||||||
except ValueError:
|
|
||||||
return "No live session — start one and I'll keep the scar notes."
|
|
||||||
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
@@ -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"))
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -80,5 +80,6 @@
|
|||||||
}
|
}
|
||||||
load();
|
load();
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/nav.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -235,5 +235,6 @@
|
|||||||
}
|
}
|
||||||
connect();
|
connect();
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/nav.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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";
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -74,5 +74,6 @@
|
|||||||
}
|
}
|
||||||
load();
|
load();
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/nav.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user