feat(web): iPhone PWA fixes (M1) + warm RTO redesign (M2) #3

Merged
serversdown merged 19 commits from dev into main 2026-06-21 02:10:53 -04:00
6 changed files with 182 additions and 8 deletions
Showing only changes of commit 67cf51a53f - Show all commits
+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",
) )
# 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
+86 -2
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(
@@ -308,7 +392,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()]
@@ -996,7 +1080,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
] ]
+26
View File
@@ -117,6 +117,25 @@ def _log_stack(args: dict, ctx: dict) -> str:
return f"Stack ${amount:g} logged" + (f" (net {net:+.0f})." if net is not None else ".") return f"Stack ${amount:g} logged" + (f" (net {net:+.0f})." if net is not None else ".")
def _undo_last(args: dict, ctx: dict) -> str:
what = (args.get("what") or "").strip().lower()
aliases = {"hands": "hand", "stacks": "stack", "reads": "read",
"scar_note": "scar", "confidence_bank": "confidence",
"scar note": "scar", "confidence": "confidence", "note": "ritual"}
what = aliases.get(what, what)
valid = ("hand", "stack", "read", "scar", "confidence", "reset", "ritual")
if what not in valid:
return f"Tell me what to undo — one of: {', '.join(valid)}."
try:
removed = poker.undo_last(what)
except ValueError:
return "No live session to undo anything in."
if not removed:
return f"Nothing logged to undo for '{what}'."
logbus.log("info", "undo last", what=what, removed=removed[:60])
return f"Scratched the last {what} — removed {removed}."
def _scar_note(args: dict, ctx: dict) -> str: 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:
@@ -370,6 +389,13 @@ 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"])},
"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 "
+7
View File
@@ -113,6 +113,13 @@ def create_app() -> FastAPI:
bundle = await asyncio.to_thread(poker.hud) bundle = await asyncio.to_thread(poker.hud)
return bundle or {"session": None} return bundle or {"session": None}
@app.delete("/session/entry/{kind}/{entry_id}")
async def delete_entry(kind: str, entry_id: int) -> dict:
"""Delete one HUD entry (hand | stack | read | ritual) by id."""
ok = await asyncio.to_thread(poker.delete_entry, kind, entry_id)
logbus.log("info", "hud entry deleted", kind=kind, id=entry_id, ok=ok)
return {"ok": ok}
@app.get("/history") @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."""
+22 -6
View File
@@ -78,6 +78,12 @@
.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); }
.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); }
@@ -142,6 +148,16 @@
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'; }
// 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) {
@@ -199,29 +215,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>
+40
View File
@@ -175,6 +175,46 @@ def test_session_state_readback(lyra):
assert "great river fold" in out assert "great river fold" in out
def test_undo_last_and_delete_entry(lyra):
_, poker, modes, tools = lyra
assert "undo_last" in modes.CASH.tools
poker.start_session(venue="Meadows", stakes="2/5", buy_in=500)
h1 = poker.log_hand(position="UTG", hole_cards="AA")
h2 = poker.log_hand(position="BTN", hole_cards="72o")
poker.log_stack(600); poker.log_stack(420)
poker.log_ritual("scar", content="punted")
poker.log_ritual("confidence", content="good fold")
# undo removes the most recent of each kind
assert "72o" in poker.undo_last("hand")
assert [h["hole_cards"] for h in poker.list_hands()] == ["AA"] # h2 gone, h1 stays
assert "420" in poker.undo_last("stack")
assert poker.current_stack() == 600
assert "punted" in poker.undo_last("scar")
assert not poker.list_rituals(kinds=("scar",))
assert poker.list_rituals(kinds=("confidence",)) # untouched
assert poker.undo_last("hand") is not None # h1
assert poker.undo_last("hand") is None # nothing left
# direct delete-by-id dispatch
assert poker.delete_entry("ritual", poker.list_rituals(kinds=("confidence",))[0]["id"]) is True
assert poker.delete_entry("bogus", 1) is False
def test_undo_last_tool(lyra):
_, poker, _, tools = lyra
poker.start_session(stakes="1/3", buy_in=300)
poker.log_hand(position="CO", hole_cards="KK")
out = tools.dispatch("undo_last", {"what": "hand"}, {})
assert "scratched" in out.lower() and poker.list_hands() == []
# no live session -> graceful
poker.end_session(cash_out=300)
assert "no live session" in tools.dispatch("undo_last", {"what": "hand"}, {}).lower()
# nonsense target
poker.start_session(stakes="1/3", buy_in=100)
assert "one of" in tools.dispatch("undo_last", {"what": "banana"}, {}).lower()
def test_list_and_delete_session(lyra): 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)