From 70e62342ee587a67bdf04188402963ea131d191b Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Mon, 22 Jun 2026 13:51:38 -0700 Subject: [PATCH] visual overhaul + accounting corrections --- .gitignore | 1 + README.md | 30 +- app.py | 250 ++++++++-- static/css/style.css | 380 +++++++++++++-- stats.py | 224 ++++++--- storage.py | 16 + templates/admin_dashboard.html | 448 +++++++++++------- templates/admin_login.html | 4 +- templates/base.html | 4 +- templates/leaderboard.html | 2 +- templates/player_detail.html | 60 ++- templates/session_detail.html | 50 +- templates/sessions.html | 4 +- tests/__pycache__/__init__.cpython-313.pyc | Bin 141 -> 0 bytes .../test_accounting.cpython-313.pyc | Bin 8233 -> 0 bytes .../test_accounting.cpython-314.pyc | Bin 8549 -> 0 bytes tests/test_accounting.py | 184 ++++++- 17 files changed, 1268 insertions(+), 389 deletions(-) delete mode 100644 tests/__pycache__/__init__.cpython-313.pyc delete mode 100644 tests/__pycache__/test_accounting.cpython-313.pyc delete mode 100644 tests/__pycache__/test_accounting.cpython-314.pyc diff --git a/.gitignore b/.gitignore index dc0553c..c6de75f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /__pycache__/* /.venv/* /data/* +test/* diff --git a/README.md b/README.md index 2178134..08b2b71 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A small Flask app I put together for tracking our low-stakes hold'em nights without having to keep a spreadsheet open all the time. -The idea is pretty simple: public pages for stats and session history, plus a small admin area for recording buy-ins, cash-outs, paid amounts, and notes. The data sits in a CSV audit log, so everything is append-only and easy to follow later. +The idea is pretty simple: public pages for stats and session history, plus a small admin area for recording buy-ins, gross cashout results, actual paid-out cash, and notes. The data sits in a CSV audit log, so everything is append-only and easy to follow later. ## What it does @@ -11,8 +11,8 @@ The idea is pretty simple: public pages for stats and session history, plus a sm - per-player stat pages - admin login for recording events - open / closed session tracking -- payout tracking with `paid` events -- front debt collections and write-offs with `front_collected` / `front_writeoff` events +- actual cash payout tracking with `paid_out` events +- debt repayments and write-offs with `debt_repayment` / `writeoff` events - session and player charts - CSV import / export from the admin page - append-only `entries.csv` ledger instead of overwriting old rows @@ -38,11 +38,12 @@ Current event types: - `buyin` - `front` -- `front_collected` -- `front_writeoff` +- `debt_repayment` +- `writeoff` - `cashout` -- `paid` +- `paid_out` - `rollover_in` +- `payout_carry_in` - `rollover_out` - `note` - `session_open` @@ -52,9 +53,9 @@ A few examples: - another `buyin` for a rebuy - another `cashout` if chip counts are corrected later -- a `paid` event when someone is actually settled up -- a `front_collected` event when a front is repaid outside poker -- a `front_writeoff` event when a front will not be collected +- a `paid_out` event when someone is actually settled up +- a `debt_repayment` event when a front is repaid outside poker +- a `writeoff` event when a front will not be collected - a `note` event for bookkeeping context - `session_open` / `session_close` to mark whether a game night is still live @@ -62,11 +63,16 @@ Accounting in the app keeps poker results separate from banker cashflow: - poker investment is `buyin + front + rollover_in` - poker net is `cashout - poker investment` -- real cash in is `buyin + front_collected` -- real cash out is `paid` +- real cash in is `buyin + debt_repayment` +- real cash out is `paid_out` - `rollover_out` settles the source session without counting as cash out - `rollover_in` enters play in the destination session without counting as cash in -- `front_writeoff` resolves a receivable without counting as cash in +- `payout_carry_in` records prior-session value carried into a later payout; it increases the destination payout due without counting as poker investment or cash in +- `writeoff` resolves a receivable without counting as cash in + +Older ledgers may still contain `paid`, `front_collected`, or `front_writeoff`. +The app reads those historical names as aliases for `paid_out`, +`debt_repayment`, and `writeoff`. It is still just a small side project, but I wanted the event model to stay clean enough that the numbers are easy to trust and the history is easy to read back through. diff --git a/app.py b/app.py index e7ef56c..db1ce04 100644 --- a/app.py +++ b/app.py @@ -31,7 +31,14 @@ from stats import ( session_sort_key, unique_player_names, ) -from storage import CSV_HEADERS, append_event, ensure_data_file, load_events +from storage import ( + CSV_HEADERS, + EventRow, + append_event, + ensure_data_file, + load_events, + write_events, +) BASE_DIR = Path(__file__).resolve().parent DATA_PATH = BASE_DIR / "data" / "entries.csv" @@ -40,13 +47,15 @@ ENV_PATH = BASE_DIR / ".env" ELIGIBLE_MIN_SESSIONS = 3 ADMIN_EVENT_TYPES = [ ("buyin", "Buy-in", "var(--accent)"), - ("front", "Fronted", "var(--warn)"), + ("front", "Front", "var(--warn)"), ("rollover_in", "Rollover-in", "var(--rolled)"), - ("cashout", "Cash-out", "var(--rank-2)"), - ("paid", "Paid-out", "var(--pos)"), + ("payout_carry_in", "Payout carry-in", "var(--rolled)"), + ("cashout", "Cashout result", "var(--rank-2)"), + ("paid_out", "Paid out cash", "var(--pos)"), ("rollover_out", "Rollover-out", "var(--rolled)"), ("note", "Note", "var(--muted)"), ] +SESSION_MARKER_TYPES = {"session_open", "session_close"} def load_local_env(env_path: Path) -> None: @@ -80,6 +89,54 @@ def next_session_id(sessions, session_date: str) -> str: return f"{session_date}-{highest + 1:02d}" +def prunable_empty_session_ids(events: list[EventRow], sessions) -> set[str]: + empty_session_ids = { + session.session_id for session in sessions if len(session.entries) == 0 + } + session_events: dict[str, list[EventRow]] = {} + + for event in events: + session_id = event["session_id"].strip() or event["session_date"].strip() + if session_id in empty_session_ids: + session_events.setdefault(session_id, []).append(event) + + return { + session_id + for session_id, rows in session_events.items() + if rows + and all( + row["event_type"] in SESSION_MARKER_TYPES + and not row["player_name"].strip() + and row["amount_cents"] == 0 + for row in rows + ) + } + + +def pending_payout_carry_items(sessions) -> list[dict[str, object]]: + carried_out: dict[str, int] = {} + carried_in: dict[str, int] = {} + + for session_summary in sessions: + for entry in session_summary.entries: + carried_out[entry.player_name] = ( + carried_out.get(entry.player_name, 0) + entry.rollover_out_cents + ) + carried_in[entry.player_name] = ( + carried_in.get(entry.player_name, 0) + + entry.rollover_in_cents + + entry.payout_carry_in_cents + ) + + items = [] + for player_name, amount_out in carried_out.items(): + pending_cents = amount_out - carried_in.get(player_name, 0) + if pending_cents > 0: + items.append({"player_name": player_name, "amount_cents": pending_cents}) + + return sorted(items, key=lambda item: str(item["player_name"]).casefold()) + + load_local_env(ENV_PATH) app = Flask(__name__) @@ -219,33 +276,24 @@ def session_detail(session_id: str) -> str: flash("That session was not found.", "error") return redirect(url_for("sessions")) - target_index = next( - ( - index - for index, session in enumerate(sessions) - if session.session_id == session_id - ), - None, - ) - - if target_index is None: - flash("That session was not found.", "error") - return redirect(url_for("sessions")) - - target_session = sessions[target_index] chronological_sessions = sorted(sessions, key=session_sort_key) - session_number = next( - ( - index - for index, session in enumerate(chronological_sessions, start=1) - if session.session_id == session_id - ), - 1, + chronological_index = next( + index + for index, session in enumerate(chronological_sessions) + if session.session_id == session_id ) + target_session = chronological_sessions[chronological_index] + session_number = chronological_index + 1 prev_session = ( - sessions[target_index + 1] if target_index < len(sessions) - 1 else None + chronological_sessions[chronological_index - 1] + if chronological_index > 0 + else None + ) + next_session = ( + chronological_sessions[chronological_index + 1] + if chronological_index < len(chronological_sessions) - 1 + else None ) - next_session = sessions[target_index - 1] if target_index > 0 else None return render_template( "session_detail.html", @@ -478,7 +526,7 @@ def admin_write_off_front() -> str: ) if entry is None or entry.player_owes_cents <= 0: - flash("That player does not have an outstanding front debt.", "error") + flash("That player does not have an outstanding debt.", "error") return redirect(url_for("admin_dashboard")) if amount_cents > entry.player_owes_cents: @@ -493,13 +541,13 @@ def admin_write_off_front() -> str: session_id=target.session_id, session_date=target.session_date, player_name=entry.player_name, - event_type="front_writeoff", + event_type="writeoff", amount_cents=amount_cents, - note=note or "Front debt written off.", + note=note or "Debt written off.", actor=app.config["ADMIN_USERNAME"], ) - flash("Front debt written off.", "success") + flash("Debt written off.", "success") return redirect(url_for("admin_dashboard")) @@ -546,7 +594,7 @@ def admin_collect_front() -> str: ) if entry is None or entry.player_owes_cents <= 0: - flash("That player does not have an outstanding front debt.", "error") + flash("That player does not have an outstanding debt.", "error") return redirect(url_for("admin_dashboard")) if amount_cents > entry.player_owes_cents: @@ -561,13 +609,13 @@ def admin_collect_front() -> str: session_id=target.session_id, session_date=target.session_date, player_name=entry.player_name, - event_type="front_collected", + event_type="debt_repayment", amount_cents=amount_cents, - note=note or "Front debt collected.", + note=note or "Debt repayment collected.", actor=app.config["ADMIN_USERNAME"], ) - flash("Front debt collected.", "success") + flash("Debt repayment collected.", "success") return redirect(url_for("admin_dashboard")) @@ -601,6 +649,86 @@ def admin_open_session() -> str: return redirect(url_for("admin_dashboard")) +@app.post("/admin/prune-empty-sessions") +def admin_prune_empty_sessions() -> str: + if not is_admin(): + flash("Admin login required.", "error") + return redirect(url_for("admin_login")) + + events = load_events(DATA_PATH) + sessions = build_session_summaries(events) + prunable_session_ids = prunable_empty_session_ids(events, sessions) + + if not prunable_session_ids: + flash("No empty marker-only sessions to prune.", "success") + return redirect(url_for("admin_dashboard")) + + pruned_events = [ + event + for event in events + if (event["session_id"].strip() or event["session_date"].strip()) + not in prunable_session_ids + ] + write_events(DATA_PATH, pruned_events) + + flash( + f"Pruned {len(prunable_session_ids)} empty session" + f"{'s' if len(prunable_session_ids) != 1 else ''}.", + "success", + ) + return redirect(url_for("admin_dashboard")) + + +@app.post("/admin/apply-payout-carry-in") +def admin_apply_payout_carry_in() -> str: + if not is_admin(): + flash("Admin login required.", "error") + return redirect(url_for("admin_login")) + + session_id = request.form.get("session_id", "").strip() + player_name = request.form.get("player_name", "").strip() + + events = load_events(DATA_PATH) + sessions = build_session_summaries(events) + by_id = {session.session_id: session for session in sessions} + target = by_id.get(session_id) + + if target is None or target.status != "open": + flash("Select a valid open session.", "error") + return redirect(url_for("admin_dashboard")) + + if not any(entry.player_name == player_name for entry in target.entries): + flash("Add that player to the open session before applying carry-in.", "error") + return redirect(url_for("admin_dashboard")) + + pending_by_player = { + str(item["player_name"]): int(item["amount_cents"]) + for item in pending_payout_carry_items(sessions) + } + amount_cents = pending_by_player.get(player_name, 0) + + if amount_cents <= 0: + flash("No pending payout carry-in found for that player.", "error") + return redirect(url_for("admin_dashboard")) + + append_event( + DATA_PATH, + session_id=target.session_id, + session_date=target.session_date, + player_name=player_name, + event_type="payout_carry_in", + amount_cents=amount_cents, + note="Payout carry-in from prior carry-out.", + actor=app.config["ADMIN_USERNAME"], + ) + + flash( + f"Applied {cents_to_dollars(amount_cents)} payout carry-in for {player_name}.", + "success", + ) + return redirect(url_for("admin_dashboard")) + + @app.route("/admin", methods=["GET", "POST"]) def admin_dashboard(): if not is_admin(): @@ -658,8 +786,18 @@ def admin_dashboard(): events = load_events(DATA_PATH) sessions = build_session_summaries(events) + empty_session_count = len(prunable_empty_session_ids(events, sessions)) + pending_carry_items = pending_payout_carry_items(sessions) + pending_carry_total_cents = sum( + int(item["amount_cents"]) for item in pending_carry_items + ) open_sessions = [session for session in sessions if session.status == "open"] live_session = open_sessions[0] if open_sessions else None + live_session_player_names = ( + {entry.player_name for entry in live_session.entries} + if live_session is not None + else set() + ) live_session_no = "" live_unresolved_count = 0 if live_session is not None: @@ -670,7 +808,7 @@ def admin_dashboard(): if entry.current_due_cents > 0 or entry.player_owes_cents > 0 ) - recent_sessions = sessions[:8] + recent_sessions = sessions recent_events = list(reversed(events[-20:])) event_type_colors = {value: color for value, _label, color in ADMIN_EVENT_TYPES} event_type_labels = {value: label for value, label, _color in ADMIN_EVENT_TYPES} @@ -689,29 +827,34 @@ def admin_dashboard(): if entry.player_owes_cents > 0 ] + cash_in_cents = sum(session.total_banker_cash_in_cents for session in sessions) + cash_out_cents = sum(session.total_banker_cash_out_cents for session in sessions) + house_owes_players_cents = ( + sum(session.total_current_due_to_player_cents for session in sessions) + + pending_carry_total_cents + ) + players_owe_house_cents = sum( + session.total_current_due_to_house_cents for session in sessions + ) admin_totals = { - "cash_in_cents": sum(session.total_cash_in_cents for session in sessions), - "cash_out_cents": sum(session.total_paid_out_cents for session in sessions), - "house_owes_players_cents": sum( - session.total_current_due_to_player_cents for session in sessions - ), - "players_owe_house_cents": sum( - session.total_current_due_to_house_cents for session in sessions - ), + "cash_in_cents": cash_in_cents, + "cash_out_cents": cash_out_cents, + "house_owes_players_cents": house_owes_players_cents, + "players_owe_house_cents": players_owe_house_cents, + "pending_carry_cents": pending_carry_total_cents, "collected_cents": sum( - session.total_front_collected_cents for session in sessions + session.total_debt_repayment_cents for session in sessions ), "written_off_cents": sum( - session.total_front_writeoff_cents for session in sessions + session.total_writeoff_cents for session in sessions ), - "net_book_position_cents": sum( - session.total_net_book_position_cents for session in sessions + "net_book_position_cents": ( + cash_in_cents + - cash_out_cents + + players_owe_house_cents + - house_owes_players_cents ), } - admin_totals["paid_out_cents"] = admin_totals["cash_out_cents"] - admin_totals["still_owed_cents"] = admin_totals["house_owes_players_cents"] - admin_totals["players_owe_cents"] = admin_totals["players_owe_house_cents"] - admin_totals["open_balance_cents"] = admin_totals["net_book_position_cents"] return render_template( "admin_dashboard.html", @@ -720,8 +863,10 @@ def admin_dashboard(): recent_events=recent_events, debt_entries=debt_entries, live_session=live_session, + live_session_player_names=live_session_player_names, live_session_no=live_session_no, live_unresolved_count=live_unresolved_count, + pending_carry_items=pending_carry_items, player_names=unique_player_names(events), append_form=append_form, event_types=ADMIN_EVENT_TYPES, @@ -730,6 +875,7 @@ def admin_dashboard(): admin_today=datetime.now().date().isoformat(), session_label=session_label, admin_totals=admin_totals, + empty_session_count=empty_session_count, ) diff --git a/static/css/style.css b/static/css/style.css index 104ef36..50c4fc6 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -109,6 +109,13 @@ h1, h2, h3, h4 { margin: 0; } .page--public { max-width: var(--w-public); } .page--session { max-width: var(--w-session); } .page--admin { max-width: var(--w-admin); } +@media (min-width: 1280px) { + .page--admin, + .page--session { + padding-left: 28px; + padding-right: 28px; + } +} /* ================================================================ FLASH MESSAGES @@ -198,7 +205,8 @@ h3 { } .session-number { color: var(--accent); - margin-bottom: 8px; + font-size: 12.5px; + margin-bottom: 9px; } .hero__title { font-family: var(--font-display); @@ -284,10 +292,10 @@ h3 { } .stat--hero .stat__val { font-size: 34px; } .player-rank { - margin: 0 0 4px; + margin: 0 0 7px; color: var(--accent); font-family: var(--font-mono); - font-size: 11px; + font-size: 12.5px; letter-spacing: .12em; text-transform: uppercase; } @@ -521,10 +529,15 @@ h3 { .tbl .col-acct { display: none; } .tbl.is-full .col-acct { display: table-cell; } .tbl--session { - min-width: 920px; + min-width: 860px; + font-size: 13.5px; } .tbl--session.is-full { - min-width: 1240px; + min-width: 1180px; +} +.tbl--session th, +.tbl--session td { + padding-inline: 12px; } .tbl--session th:nth-child(7), .tbl--session td:nth-child(7) { @@ -616,6 +629,7 @@ td a:hover { color: var(--accent); } white-space: nowrap; } .badge--paid { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); } +.badge--settled { color: var(--rolled); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); } .badge--partial { color: var(--warn); background: var(--warn-tint); box-shadow: inset 0 0 0 1px var(--warn-tint-bd); } .badge--unpaid { color: var(--neg); background: var(--neg-tint); box-shadow: inset 0 0 0 1px var(--neg-tint-bd); } .badge--owes { color: var(--owes); background: var(--owes-tint); box-shadow: inset 0 0 0 1px var(--owes-tint-bd); } @@ -641,6 +655,7 @@ td a:hover { color: var(--accent); } .status-open { color: var(--warn); background: var(--warn-tint); box-shadow: inset 0 0 0 1px var(--warn-tint-bd); } .status-closed { color: var(--muted-2); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); } .status-paid { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); } +.status-settled { color: var(--rolled); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); } .status-partial { color: var(--warn); background: var(--warn-tint); box-shadow: inset 0 0 0 1px var(--warn-tint-bd); } .status-unpaid { color: var(--neg); background: var(--neg-tint); box-shadow: inset 0 0 0 1px var(--neg-tint-bd); } .status-owes { color: var(--owes); background: var(--owes-tint); box-shadow: inset 0 0 0 1px var(--owes-tint-bd); } @@ -665,7 +680,11 @@ td a:hover { color: var(--accent); } .pill-cashout { color: var(--rank-2); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); } .pill-paid { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); } .pill-rollover_in { color: var(--rolled); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); } +.pill-payout_carry_in { color: var(--rolled); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); } .pill-rollover_out { color: var(--rolled); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); } +.pill-paid_out { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); } +.pill-debt_repayment { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); } +.pill-writeoff { color: var(--neg); background: var(--neg-tint); box-shadow: inset 0 0 0 1px var(--neg-tint-bd); } .pill-front_collected { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); } .pill-front_writeoff { color: var(--neg); background: var(--neg-tint); box-shadow: inset 0 0 0 1px var(--neg-tint-bd); } .pill-note { color: var(--muted-2); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); } @@ -779,10 +798,19 @@ td a:hover { color: var(--accent); } gap: 6px; } .result-mix__legend-row strong { + display: grid; + grid-template-columns: minmax(2ch, auto) 6ch; + gap: 8px; color: var(--text-2); font-weight: 600; text-align: right; white-space: nowrap; + font-variant-numeric: tabular-nums; +} +.result-mix__legend-row strong b, +.result-mix__legend-row strong em { + font: inherit; + text-align: right; } .result-mix__key { width: 8px; @@ -887,17 +915,35 @@ td a:hover { color: var(--accent); } } .event-card--inline { display: grid; - grid-template-columns: minmax(0, 1fr) auto auto; - gap: 10px; + grid-template-columns: minmax(82px, 120px) max-content minmax(0, 1fr) minmax(88px, auto) minmax(74px, auto); + gap: 12px; + align-items: center; padding: 9px 16px; } .event-card--inline .event-card-top { - gap: 8px; + display: contents; +} +.event-card--inline .event-card-top strong { + grid-column: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; } .event-card--inline .pill { + grid-column: 2; + justify-self: start; padding: 2px 7px; font-size: 9.5px; } +.event-card--inline .event-note { + grid-column: 3; +} +.event-card--inline .event-amount { + grid-column: 4; +} +.event-card--inline .tiny-text { + grid-column: 5; +} .event-note { min-width: 0; overflow: hidden; @@ -906,10 +952,10 @@ td a:hover { color: var(--accent); } color: var(--muted); font-size: 12.5px; } -.event-amount { font-family: var(--font-mono); font-size: 13px; font-weight: 600; color: var(--num); margin: 0; white-space: nowrap; } +.event-amount { font-family: var(--font-mono); font-size: 13px; font-weight: 600; color: var(--num); margin: 0; white-space: nowrap; text-align: right; } .muted-text { color: var(--muted); font-size: 13.5px; margin: 0; } .muted-text.small, .tiny-text { font-size: 12px; } -.tiny-text { color: var(--faintest); } +.tiny-text { color: var(--faintest); margin: 0; text-align: right; white-space: nowrap; } .note-list { margin: 0; padding-left: 16px; color: var(--muted); font-size: 13px; } .note-list li + li { margin-top: 4px; } @@ -971,6 +1017,10 @@ td a:hover { color: var(--accent); } border-color: var(--border-hi); color: var(--text-strong); } +.secondary-button:disabled, .ghost-button:disabled { + cursor: not-allowed; + opacity: .45; +} .table-action-button { display: inline-flex; align-items: center; @@ -1028,20 +1078,17 @@ td a:hover { color: var(--accent); } /* Admin workspace */ .admin-workspace { display: grid; - grid-template-columns: minmax(0, 2fr) minmax(300px, 1fr); + grid-template-columns: minmax(280px, 1fr) minmax(280px, 1fr) minmax(260px, .9fr); gap: var(--s4); - align-items: flex-start; + align-items: stretch; } -.admin-workspace__main { flex: 2; min-width: 360px; } -.admin-workspace__side { flex: 1; min-width: 300px; } -.admin-workspace .col-main, -.admin-workspace .col-side { +.admin-workspace > .panel { display: flex; flex-direction: column; - gap: var(--s4); min-width: 0; } .admin-bottom-grid { + --admin-bottom-body-h: 340px; display: grid; grid-template-columns: minmax(0, 2fr) minmax(300px, 1fr); gap: var(--s4); @@ -1049,19 +1096,22 @@ td a:hover { color: var(--accent); } margin-top: var(--s4); } .admin-bottom-grid > .panel { - min-width: 0; -} -.admin-bottom-grid > .panel:last-child { display: flex; flex-direction: column; - min-height: 0; + min-width: 0; } -.admin-bottom-grid > .panel:last-child .event-feed { - flex: 1; - align-content: start; - max-height: none; +.admin-bottom-grid .table-wrap, +.admin-bottom-grid .event-feed { + height: var(--admin-bottom-body-h); + min-height: 0; overflow-y: auto; } +.admin-bottom-grid .table-wrap { + overflow-x: auto; +} +.admin-bottom-grid .event-feed { + align-content: start; +} /* Cash position strip */ .cash-strip { @@ -1144,9 +1194,97 @@ td a:hover { color: var(--accent); } .section-marker__dot--rolled { background: var(--rolled); } /* Debt / admin tools */ -.debt-panel { display: grid; gap: 18px; } +.debt-panel { + display: grid; + gap: 0; + margin-top: var(--s4); +} .debt-summary { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } -.debt-action-grid { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); } +.debt-layout { + display: grid; + grid-template-columns: minmax(240px, .85fr) minmax(0, 2fr); + gap: 16px; + padding: 16px 20px 18px; +} +.debt-list { + display: grid; + align-content: start; + gap: 8px; + max-height: 245px; + overflow-y: auto; +} +.debt-list-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + padding: 10px 12px; + background: var(--surface-sunk); + border: 1px solid var(--border); + border-radius: var(--r-sm); +} +.debt-list-item strong { + display: block; + color: var(--text-strong); + font-size: 13.5px; + margin-bottom: 2px; +} +.debt-list-item span { + display: block; + color: var(--faintest); + font-family: var(--font-mono); + font-size: 10.5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.debt-list-item b { + color: var(--owes); + font-family: var(--font-mono); + font-size: 13px; + white-space: nowrap; +} +.debt-empty { + display: grid; + place-items: center; + gap: 8px; + padding: 30px 20px 34px; + color: var(--muted); + text-align: center; +} +.debt-empty__mark { + display: grid; + place-items: center; + min-width: 64px; + height: 40px; + padding: 0 14px; + border-radius: var(--r-pill); + background: var(--surface-sunk); + border: 1px solid var(--border); + color: var(--pos); + font-family: var(--font-mono); + font-weight: 700; +} +.debt-empty p { + margin: 0; + font-size: 13.5px; +} +.debt-action-grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(2, minmax(260px, 1fr)); +} +.debt-form { + gap: 10px; + padding: 0; + background: transparent; +} +.debt-form h3 { + margin-bottom: 2px; +} +.debt-form textarea { + min-height: 58px; +} .import-form input[type="file"] { width: 100%; margin: 4px 0 8px; color: var(--muted); } .import-form input[type="file"]::file-selector-button { appearance: none; @@ -1165,12 +1303,17 @@ td a:hover { color: var(--accent); } } /* Admin: append ledger event */ -.append__body { padding: 20px 22px; } +.append { + min-width: 0; +} +.append__body { + padding: 18px 20px; +} .append__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: 14px; - margin-bottom: 16px; + gap: 12px; + margin-bottom: 14px; } .field { display: block; } .field__label { @@ -1216,7 +1359,13 @@ select.control { cursor: pointer; } border-color: var(--accent-chip-bd); box-shadow: 0 0 0 3px var(--accent-a22); } -.append__note { margin-bottom: 18px; } +.append__note { + margin-bottom: 14px; +} +.append__textarea { + min-height: 96px; + resize: vertical; +} .append__footer { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; } .append__preview { display: inline-flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--faint); } .evtag-pill { @@ -1231,9 +1380,44 @@ select.control { cursor: pointer; } } /* Admin: session controls */ -.panel--pad { padding: 20px 22px; } +.panel--pad { padding: 18px 20px; } .panel__title--mb { margin: 0 0 16px; } +.session-control-panel { + min-width: 0; +} +.session-summary-strip { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1px; + overflow: hidden; + margin-bottom: 14px; + border: 1px solid var(--border); + border-radius: var(--r-sm); + background: var(--border); +} +.session-summary-strip > div { + min-width: 0; + padding: 9px 10px; + background: var(--surface-sunk); +} +.session-summary-strip strong { + display: block; + margin-top: 3px; + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 12.5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.import-export-panel { + min-width: 0; +} .livecard { + display: flex; + flex-direction: column; + justify-content: space-between; + min-height: 140px; padding: 15px 16px; background: var(--surface-sunk); border: 1px solid var(--border-strong); @@ -1243,6 +1427,7 @@ select.control { cursor: pointer; } .livecard__head { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; } .dot { width: 7px; height: 7px; border-radius: 50%; } .dot--live { background: var(--pos); animation: livepulse 1.6s ease-in-out infinite; } +.dot--idle { background: var(--muted-2); } .livecard__name { font-weight: 600; color: var(--text-strong); font-size: 14px; } .livecard__no { font-family: var(--font-mono); font-size: 10.5px; color: var(--faintest); } .livecard__metrics { display: flex; gap: 18px; margin-bottom: 14px; } @@ -1292,14 +1477,68 @@ select.control { cursor: pointer; } box-shadow: 0 4px 16px var(--accent-a30); } .btn-newsession--primary:hover { color: var(--accent-ink); opacity: .88; } +.prune-session-form { + display: flex; + justify-content: center; + padding-top: 12px; +} +.prune-session-form .secondary-button { + justify-content: center; +} +.carry-recs { + display: grid; + gap: 8px; + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid var(--divider); +} +.carry-rec { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 10px; + align-items: center; + padding: 8px 10px; + background: var(--surface-sunk); + border: 1px solid var(--border); + border-radius: var(--r-sm); +} +.carry-rec span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-strong); + font-size: 13px; + font-weight: 600; +} +.carry-rec strong { + color: var(--rolled); + font-family: var(--font-mono); + font-size: 12.5px; + white-space: nowrap; +} +.carry-rec form { + margin: 0; +} +.carry-rec em { + color: var(--faintest); + font-size: 11.5px; + font-style: normal; + white-space: nowrap; +} /* Admin: CSV tools */ -.csv__desc { margin: 0 0 16px; font-size: 12.5px; color: var(--faint); } +.csv__desc { margin: 0 0 12px; font-size: 12.5px; color: var(--faint); } +.import-form { + display: flex; + flex: 1; + flex-direction: column; +} .csv__btns { display: flex; gap: 10px; margin-bottom: 12px; } .csv-btn-ghost { flex: 1; text-align: center; - padding: 10px; + padding: 9px 10px; border-radius: 8px; background: var(--field); border: 1px solid var(--border-strong); @@ -1310,8 +1549,11 @@ select.control { cursor: pointer; } } .csv-btn-ghost:hover { border-color: var(--border-hi); } .csv__drop { - display: block; - padding: 18px; + display: grid; + flex: 1; + min-height: 112px; + place-items: center; + padding: 18px 14px; border: 1px dashed var(--border-strong); border-radius: 9px; text-align: center; @@ -1326,17 +1568,65 @@ select.control { cursor: pointer; } .form-shell { display: grid; justify-content: center; padding-top: 40px; } .form-shell.narrow { grid-template-columns: minmax(320px, 480px); } +/* Modal */ +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 50; + display: grid; + place-items: center; + padding: 20px; + background: rgba(5, 5, 8, .68); + backdrop-filter: blur(5px); +} +.modal-backdrop[hidden] { display: none; } +.modal-card { + width: min(100%, 440px); + overflow: hidden; + border-radius: var(--r-md); + background: var(--surface); + border: 1px solid var(--border-strong); + box-shadow: 0 24px 70px rgba(0, 0, 0, .45); +} +.modal-card__head { + padding: 18px 20px 0; +} +.modal-card__text { + margin: 0; + padding: 14px 20px 18px; + color: var(--muted); + font-size: 13.5px; + line-height: 1.5; +} +.modal-card__actions { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 16px 20px; + border-top: 1px solid var(--divider); + background: var(--surface-sunk); + flex-wrap: wrap; +} + /* ================================================================ RESPONSIVE ================================================================ */ @media (max-width: 860px) { .split > .col-main, .split > .col-side { min-width: 100%; } - .admin-workspace__main, .admin-workspace__side { min-width: 100%; } .admin-workspace { grid-template-columns: 1fr; } - .admin-workspace .col-main, .admin-workspace .col-side { min-width: 0; } + .append__textarea { min-height: 120px; } + .debt-layout { + grid-template-columns: 1fr; + } .admin-bottom-grid { grid-template-columns: 1fr; } } +@media (min-width: 861px) and (max-width: 1180px) { + .admin-workspace { + grid-template-columns: repeat(2, minmax(280px, 1fr)); + } +} + @media (max-width: 1180px) { .player-stat-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } } @@ -1351,7 +1641,19 @@ select.control { cursor: pointer; } .recon-metric-grid { grid-template-columns: 1fr; } .player-stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .event-card--inline { - grid-template-columns: minmax(0, 1fr) auto; + grid-template-columns: minmax(0, 1fr) max-content auto; + } + .event-card--inline .event-card-top strong { + grid-column: 1; + } + .event-card--inline .pill { + grid-column: 2; + } + .event-card--inline .event-amount { + grid-column: 3; + } + .event-card--inline .event-note { + grid-column: 1 / -1; } .event-card--inline .tiny-text { grid-column: 1 / -1; diff --git a/stats.py b/stats.py index 8514c0f..f89d4b2 100644 --- a/stats.py +++ b/stats.py @@ -28,6 +28,18 @@ PLAYER_PALETTE = [ BREAK_EVEN_TOLERANCE_CENTS = 100 +def session_sequence_number(session_id: str, session_date: str) -> int: + suffix = session_id.strip().replace(f"{session_date.strip()}-", "", 1) + + if session_id.strip() == session_date.strip(): + return 1 + if suffix.isdigit(): + return int(suffix) + if suffix.lower().startswith("s") and suffix[1:].isdigit(): + return int(suffix[1:]) + return 9999 + + @dataclass class SessionEntry: session_id: str @@ -40,19 +52,32 @@ class SessionEntry: cash_out_cents: int = 0 paid_cents: int = 0 rollover_in_cents: int = 0 + payout_carry_in_cents: int = 0 rollover_out_cents: int = 0 notes: list[str] = field(default_factory=list) @property def invested_cents(self) -> int: - # Poker performance: everything that entered play, including house fronts - # and prior unpaid winnings rolled into this session. + # Poker performance: money/chips that entered play. This is intentionally + # broader than real cash because fronts and rollover-ins affect results. return self.buy_in_cents + self.front_cents + self.rollover_in_cents @property def net_cents(self) -> int: return self.cash_out_cents - self.invested_cents + @property + def paid_out_cents(self) -> int: + return self.paid_cents + + @property + def debt_repayment_cents(self) -> int: + return self.front_collected_cents + + @property + def writeoff_cents(self) -> int: + return self.front_writeoff_cents + @property def gross_payout_cents(self) -> int: # Settlement: cashout is a gross chip count/result, not proof that cash @@ -64,33 +89,42 @@ class SessionEntry: def player_owes_gross_cents(self) -> int: return max(self.front_cents - self.cash_out_cents, 0) + @property + def gross_due_to_house_cents(self) -> int: + return self.player_owes_gross_cents + @property def settled_to_player_cents(self) -> int: # Rollover-out resolves the source session's payable even though it is # not real cash out. The destination session records it as rollover-in. - return self.paid_cents + self.rollover_out_cents + return self.paid_out_cents + self.rollover_out_cents @property def current_due_to_player_cents(self) -> int: - return max(self.gross_payout_cents - self.settled_to_player_cents, 0) + return max( + self.gross_payout_cents + + self.payout_carry_in_cents + - self.settled_to_player_cents, + 0, + ) @property def settled_to_house_cents(self) -> int: # Front collections are real cash in. Writeoffs are not cash, but they # do resolve the receivable. - return self.front_collected_cents + self.front_writeoff_cents + return self.debt_repayment_cents + self.writeoff_cents @property def current_due_to_house_cents(self) -> int: - return max(self.player_owes_gross_cents - self.settled_to_house_cents, 0) + return max(self.gross_due_to_house_cents - self.settled_to_house_cents, 0) @property def real_cash_in_cents(self) -> int: - return self.buy_in_cents + self.front_collected_cents + return self.buy_in_cents + self.debt_repayment_cents @property def real_cash_out_cents(self) -> int: - return self.paid_cents + return self.paid_out_cents @property def payout_due_cents(self) -> int: @@ -114,11 +148,11 @@ class SessionEntry: @property def raw_player_owes_cents(self) -> int: - return self.player_owes_gross_cents + return self.gross_due_to_house_cents @property def front_shortfall_cents(self) -> int: - return self.player_owes_gross_cents + return self.gross_due_to_house_cents @property def overpaid_front_cents(self) -> int: @@ -127,29 +161,31 @@ class SessionEntry: @property def front_writeoff_applied_cents(self) -> int: return min( - self.front_writeoff_cents, - max(self.player_owes_gross_cents - self.front_collected_applied_cents, 0), + self.writeoff_cents, + max(self.gross_due_to_house_cents - self.front_collected_applied_cents, 0), ) @property def front_collected_applied_cents(self) -> int: - return min(self.front_collected_cents, self.player_owes_gross_cents) + return min(self.debt_repayment_cents, self.gross_due_to_house_cents) @property def front_resolved_cents(self) -> int: - return min(self.settled_to_house_cents, self.player_owes_gross_cents) + return min(self.settled_to_house_cents, self.gross_due_to_house_cents) @property def payout_status(self) -> str: if self.current_due_to_house_cents > 0: return "owes" - if self.front_writeoff_cents > 0: + if self.writeoff_cents > 0: return "written_off" - if self.front_collected_cents > 0: + if self.debt_repayment_cents > 0: return "collected" if self.gross_payout_cents <= 0: return "none" if self.current_due_to_player_cents <= 0: + if self.paid_out_cents < self.gross_payout_cents: + return "settled" return "paid" if self.settled_to_player_cents <= 0: return "unpaid" @@ -180,6 +216,10 @@ class SessionSummary: def total_rollover_in_cents(self) -> int: return sum(entry.rollover_in_cents for entry in self.entries) + @property + def total_payout_carry_in_cents(self) -> int: + return sum(entry.payout_carry_in_cents for entry in self.entries) + @property def total_invested_cents(self) -> int: return sum(entry.invested_cents for entry in self.entries) @@ -206,7 +246,11 @@ class SessionSummary: @property def total_paid_cents(self) -> int: - return sum(entry.paid_cents for entry in self.entries) + return self.total_paid_out_cents + + @property + def total_paid_out_cents(self) -> int: + return sum(entry.paid_out_cents for entry in self.entries) @property def total_rollover_out_cents(self) -> int: @@ -222,7 +266,11 @@ class SessionSummary: @property def total_player_owes_gross_cents(self) -> int: - return sum(entry.player_owes_gross_cents for entry in self.entries) + return sum(entry.gross_due_to_house_cents for entry in self.entries) + + @property + def total_gross_due_to_house_cents(self) -> int: + return self.total_player_owes_gross_cents @property def total_settled_to_house_cents(self) -> int: @@ -234,19 +282,39 @@ class SessionSummary: @property def total_front_writeoff_cents(self) -> int: - return sum(entry.front_writeoff_cents for entry in self.entries) + return self.total_writeoff_cents @property def total_front_collected_cents(self) -> int: - return sum(entry.front_collected_cents for entry in self.entries) + return self.total_debt_repayment_cents + + @property + def total_debt_repayment_cents(self) -> int: + return sum(entry.debt_repayment_cents for entry in self.entries) + + @property + def total_writeoff_cents(self) -> int: + return sum(entry.writeoff_cents for entry in self.entries) @property def total_cash_in_cents(self) -> int: return sum(entry.real_cash_in_cents for entry in self.entries) @property - def total_paid_out_cents(self) -> int: - return sum(entry.real_cash_out_cents for entry in self.entries) + def total_real_cash_in_cents(self) -> int: + return self.total_cash_in_cents + + @property + def total_real_cash_out_cents(self) -> int: + return self.total_paid_out_cents + + @property + def total_banker_cash_in_cents(self) -> int: + return self.total_real_cash_in_cents + + @property + def total_banker_cash_out_cents(self) -> int: + return self.total_real_cash_out_cents @property def total_current_due_cents(self) -> int: @@ -290,10 +358,13 @@ class PlayerStats: total_front_writeoff_cents: int current_player_owes_cents: int total_rollover_in_cents: int + total_payout_carry_in_cents: int total_invested_cents: int total_cash_out_cents: int + total_gross_payout_cents: int total_paid_cents: int total_rollover_out_cents: int + current_due_to_player_cents: int total_net_cents: int roi_pct: float current_win_streak: int @@ -306,6 +377,30 @@ class PlayerStats: worst_session_net_cents: int rank_change: int = 0 + @property + def total_debt_repayment_cents(self) -> int: + return self.total_front_collected_cents + + @property + def total_writeoff_cents(self) -> int: + return self.total_front_writeoff_cents + + @property + def current_due_to_house_cents(self) -> int: + return self.current_player_owes_cents + + @property + def total_paid_out_cents(self) -> int: + return self.total_paid_cents + + @property + def total_real_cash_out_cents(self) -> int: + return self.total_paid_out_cents + + @property + def total_real_cash_in_cents(self) -> int: + return self.total_buy_in_cents + self.total_debt_repayment_cents + def apply_rank_changes( current_board: list[PlayerStats], prev_board: list[PlayerStats] @@ -331,34 +426,38 @@ def cents_to_dollars(cents: int) -> Markup: def session_sort_key(session: SessionSummary) -> tuple[str, int, str, str]: session_id = session.session_id.strip() session_date = session.session_date.strip() + return ( + session_date, + session_sequence_number(session_id, session_date), + session.opened_at, + session_id, + ) - if session_id == session_date: - return (session_date, 1, session.opened_at, session_id) - suffix = session_id.replace(f"{session_date}-", "") +def entry_sort_key(entry: SessionEntry) -> tuple[str, int, str]: + return ( + entry.session_date, + session_sequence_number(entry.session_id, entry.session_date), + entry.session_id, + ) + + +def session_display_suffix(session_id: str, session_date: str) -> str: + suffix = session_id.strip().replace(f"{session_date.strip()}-", "", 1) + + if session_id.strip() == session_date.strip(): + return "" if suffix.isdigit(): - return (session_date, int(suffix), session.opened_at, session_id) - + return f"S{int(suffix)}" if suffix.lower().startswith("s") and suffix[1:].isdigit(): - return (session_date, int(suffix[1:]), session.opened_at, session_id) - - return (session_date, 9999, session.opened_at, session_id) + return f"S{int(suffix[1:])}" + return suffix def session_chart_label(session: SessionSummary) -> str: - session_id = session.session_id.strip() - - if session_id == session.session_date: - return safe_date_label(session.session_date) - - suffix = session_id.replace(f"{session.session_date}-", "") - if suffix.isdigit(): - return f"{safe_date_label(session.session_date)} · S{int(suffix)}" - - if suffix.lower().startswith("s") and suffix[1:].isdigit(): - return f"{safe_date_label(session.session_date)} · S{int(suffix[1:])}" - - return f"{safe_date_label(session.session_date)} · {suffix}" + suffix = session_display_suffix(session.session_id, session.session_date) + date_label = safe_date_label(session.session_date) + return f"{date_label} · {suffix}" if suffix else date_label def safe_date_label(raw_date: str) -> str: @@ -369,13 +468,7 @@ def safe_date_label(raw_date: str) -> str: def session_label(session: SessionSummary) -> str: - if session.session_id == session.session_date: - return safe_date_label(session.session_date) - - suffix = session.session_id.replace(f"{session.session_date}-", "") - if suffix.isdigit(): - return f"{safe_date_label(session.session_date)} · S{int(suffix)}" - return f"{safe_date_label(session.session_date)} · {suffix}" + return session_chart_label(session) def color_for_name(name: str, names: list[str]) -> str: @@ -450,14 +543,22 @@ def build_session_summaries(events: list[EventRow]) -> list[SessionSummary]: entry.front_cents += event["amount_cents"] elif event_type == "front_collected": entry.front_collected_cents += event["amount_cents"] + elif event_type == "debt_repayment": + entry.front_collected_cents += event["amount_cents"] elif event_type == "front_writeoff": entry.front_writeoff_cents += event["amount_cents"] + elif event_type == "writeoff": + entry.front_writeoff_cents += event["amount_cents"] elif event_type == "rollover_in": entry.rollover_in_cents += event["amount_cents"] + elif event_type == "payout_carry_in": + entry.payout_carry_in_cents += event["amount_cents"] elif event_type == "cashout": entry.cash_out_cents += event["amount_cents"] elif event_type == "paid": entry.paid_cents += event["amount_cents"] + elif event_type == "paid_out": + entry.paid_cents += event["amount_cents"] elif event_type == "rollover_out": entry.rollover_out_cents += event["amount_cents"] @@ -484,19 +585,12 @@ def build_session_summaries(events: list[EventRow]) -> list[SessionSummary]: ) ) - sessions.sort( - key=lambda session: ( - session.session_date, - session.opened_at, - session.session_id, - ), - reverse=True, - ) + sessions.sort(key=session_sort_key, reverse=True) return sessions def summarize_player_runs(entries: list[SessionEntry]) -> dict[str, int | str | None]: - ordered_entries = sorted(entries, key=lambda entry: entry.session_date) + ordered_entries = sorted(entries, key=entry_sort_key) longest_win_streak = 0 longest_loss_streak = 0 @@ -585,14 +679,17 @@ def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]: total_buy_in = sum(entry.buy_in_cents for entry in entries) total_front = sum(entry.front_cents for entry in entries) - total_front_collected = sum(entry.front_collected_cents for entry in entries) - total_front_writeoff = sum(entry.front_writeoff_cents for entry in entries) + total_front_collected = sum(entry.debt_repayment_cents for entry in entries) + total_front_writeoff = sum(entry.writeoff_cents for entry in entries) current_player_owes = sum(entry.player_owes_cents for entry in entries) total_rollover_in = sum(entry.rollover_in_cents for entry in entries) + total_payout_carry_in = sum(entry.payout_carry_in_cents for entry in entries) total_invested = sum(entry.invested_cents for entry in entries) total_cash_out = sum(entry.cash_out_cents for entry in entries) - total_paid = sum(entry.paid_cents for entry in entries) + total_gross_payout = sum(entry.gross_payout_cents for entry in entries) + total_paid = sum(entry.paid_out_cents for entry in entries) total_rollover_out = sum(entry.rollover_out_cents for entry in entries) + current_due_to_player = sum(entry.current_due_to_player_cents for entry in entries) total_net = sum(entry.net_cents for entry in entries) roi_pct = (total_net / total_invested * 100) if total_invested else 0.0 @@ -614,10 +711,13 @@ def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]: total_front_writeoff_cents=total_front_writeoff, current_player_owes_cents=current_player_owes, total_rollover_in_cents=total_rollover_in, + total_payout_carry_in_cents=total_payout_carry_in, total_invested_cents=total_invested, total_cash_out_cents=total_cash_out, + total_gross_payout_cents=total_gross_payout, total_paid_cents=total_paid, total_rollover_out_cents=total_rollover_out, + current_due_to_player_cents=current_due_to_player, total_net_cents=total_net, roi_pct=roi_pct, current_win_streak=run_summary["current_win_streak"], diff --git a/storage.py b/storage.py index e891c27..0cc324a 100644 --- a/storage.py +++ b/storage.py @@ -39,8 +39,12 @@ VALID_EVENT_TYPES = { "front_writeoff", "cashout", "paid", + "paid_out", "rollover_in", + "payout_carry_in", "rollover_out", + "debt_repayment", + "writeoff", "note", "session_open", "session_close", @@ -120,3 +124,15 @@ def append_event( writer.writerow(event) return event + + +def write_events(csv_path: Path, events: list[EventRow]) -> None: + ensure_data_file(csv_path) + + tmp_path = csv_path.with_suffix(f"{csv_path.suffix}.tmp") + with tmp_path.open("w", newline="", encoding="utf-8") as file: + writer = csv.DictWriter(file, fieldnames=CSV_HEADERS) + writer.writeheader() + writer.writerows(events) + + tmp_path.replace(csv_path) diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html index f88db58..6064d90 100644 --- a/templates/admin_dashboard.html +++ b/templates/admin_dashboard.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}Admin · boker.club{% endblock %} +{% block title %}Admin · myboker.org{% endblock %} {% block page_class %}page--admin{% endblock %} {% block content %} @@ -11,7 +11,7 @@ {% endif %}

Ledger controls

-

Append events to the audit trail. All history is permanent.

+

Append events to the audit trail. Player ledger history stays permanent.

+
- -
- -
-
-

Append ledger event

- fast entry -
-
-
- - - - -
-