From 77198e5b472a143b76442c9022fc130a84aa6f5f Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Mon, 22 Jun 2026 00:25:57 -0700 Subject: [PATCH] overhaul --- README.md | 10 + app.py | 173 +- memory/MEMORY.md | 3 + memory/project_overview.md | 26 + static/css/style.css | 2396 +++++++++-------- static/css/tokens.css | 84 + stats.py | 227 +- templates/admin_dashboard.html | 556 ++-- templates/admin_login.html | 23 +- templates/base.html | 53 +- templates/leaderboard.html | 870 +++--- templates/player_detail.html | 477 ++-- templates/session_detail.html | 497 ++-- templates/sessions.html | 62 +- tests/__init__.py | 1 + tests/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 141 bytes .../test_accounting.cpython-313.pyc | Bin 0 -> 8233 bytes .../test_accounting.cpython-314.pyc | Bin 0 -> 8549 bytes tests/test_accounting.py | 118 + 19 files changed, 2920 insertions(+), 2656 deletions(-) create mode 100644 memory/MEMORY.md create mode 100644 memory/project_overview.md create mode 100644 static/css/tokens.css create mode 100644 tests/__init__.py create mode 100644 tests/__pycache__/__init__.cpython-313.pyc create mode 100644 tests/__pycache__/test_accounting.cpython-313.pyc create mode 100644 tests/__pycache__/test_accounting.cpython-314.pyc create mode 100644 tests/test_accounting.py diff --git a/README.md b/README.md index f2638ed..2178134 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,16 @@ A few examples: - a `note` event for bookkeeping context - `session_open` / `session_close` to mark whether a game night is still live +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` +- `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 + 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. ## CSV format diff --git a/app.py b/app.py index f77d83d..e7ef56c 100644 --- a/app.py +++ b/app.py @@ -3,7 +3,6 @@ from __future__ import annotations import csv import os -import shutil from datetime import datetime, timezone from pathlib import Path @@ -32,12 +31,23 @@ from stats import ( session_sort_key, unique_player_names, ) -from storage import append_event, ensure_data_file, load_events +from storage import CSV_HEADERS, append_event, ensure_data_file, load_events BASE_DIR = Path(__file__).resolve().parent DATA_PATH = BASE_DIR / "data" / "entries.csv" ENV_PATH = BASE_DIR / ".env" +ELIGIBLE_MIN_SESSIONS = 3 +ADMIN_EVENT_TYPES = [ + ("buyin", "Buy-in", "var(--accent)"), + ("front", "Fronted", "var(--warn)"), + ("rollover_in", "Rollover-in", "var(--rolled)"), + ("cashout", "Cash-out", "var(--rank-2)"), + ("paid", "Paid-out", "var(--pos)"), + ("rollover_out", "Rollover-out", "var(--rolled)"), + ("note", "Note", "var(--muted)"), +] + def load_local_env(env_path: Path) -> None: if not env_path.exists(): @@ -105,18 +115,20 @@ def leaderboard() -> str: all_sessions = build_session_summaries(events) ordered_sessions = sorted(all_sessions, key=session_sort_key) - session_ids = [session.session_id for session in ordered_sessions] + session_ids = [s.session_id for s in ordered_sessions] selected_session_id = request.args.get("through_session", "").strip() + mode = request.args.get("mode", "eligible").strip() + if mode not in ("eligible", "all", "recent"): + mode = "eligible" label = "" + cutoff_index = len(session_ids) - 1 if session_ids: if selected_session_id in session_ids: cutoff_index = session_ids.index(selected_session_id) label = session_label(ordered_sessions[cutoff_index]) else: - cutoff_index = len(session_ids) - 1 - selected_session = ordered_sessions[cutoff_index] - label = session_label(selected_session) + label = session_label(ordered_sessions[cutoff_index]) filtered_sessions = ordered_sessions[: cutoff_index + 1] previous_sessions = ordered_sessions[:cutoff_index] @@ -124,17 +136,48 @@ def leaderboard() -> str: filtered_sessions = [] previous_sessions = [] + # Build full board (all filtered sessions) for rank changes and counts board = build_leaderboard(filtered_sessions) previous_board = build_leaderboard(previous_sessions) board = apply_rank_changes(board, previous_board) + # Segmented-control counts + eligible_count = sum(1 for p in board if p.sessions_played >= ELIGIBLE_MIN_SESSIONS) + all_count = len(board) + recent_sessions_slice = filtered_sessions[-5:] + recent_count = len({ + entry.player_name + for s in recent_sessions_slice + for entry in s.entries + }) + + # Mode-specific boards + if mode == "recent": + mode_board = build_leaderboard(recent_sessions_slice) + main_board = mode_board + provisional_board = [] + elif mode == "eligible": + main_board = [p for p in board if p.sessions_played >= ELIGIBLE_MIN_SESSIONS] + provisional_board = [p for p in board if p.sessions_played < ELIGIBLE_MIN_SESSIONS] + else: + main_board = board + provisional_board = [] + chart_data = cumulative_profit_series(filtered_sessions) + cash_paid_out_cents = sum(session.total_paid_out_cents for session in all_sessions) return render_template( "leaderboard.html", - leaderboard=board, + main_board=main_board, + provisional_board=provisional_board, + mode=mode, + eligible_count=eligible_count, + all_count=all_count, + recent_count=recent_count, + eligible_min_sessions=ELIGIBLE_MIN_SESSIONS, session_count=len(filtered_sessions), total_session_count=len(all_sessions), + cash_paid_out_cents=cash_paid_out_cents, chart_data=chart_data, available_sessions=all_sessions, selected_session_id=selected_session_id, @@ -190,6 +233,15 @@ def session_detail(session_id: str) -> str: 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, + ) prev_session = ( sessions[target_index + 1] if target_index < len(sessions) - 1 else None ) @@ -200,6 +252,7 @@ def session_detail(session_id: str) -> str: session=target_session, next_session=next_session, prev_session=prev_session, + session_number=session_number, raw_events=session_events(events, session_id), chart_data=session_breakdown_series(target_session), session_label=session_label, @@ -218,10 +271,19 @@ def player_detail(player_name: str) -> str: flash("That player was not found.", "error") return redirect(url_for("leaderboard")) + player_rank = next( + ( + index + for index, ranked_player in enumerate(board, start=1) + if ranked_player.player_name == player_name + ), + None, + ) chart_data = player_session_series(sessions, player_name) return render_template( "player_detail.html", player=player_stats, + player_rank=player_rank, chart_data=chart_data, ) @@ -290,6 +352,7 @@ def admin_session_state() -> str: @app.get("/admin/export") +@app.get("/admin/export.csv") def admin_export_csv(): if not is_admin(): flash("Admin login required.", "error") @@ -333,37 +396,39 @@ def admin_import_csv(): flash("CSV file is empty.", "error") return redirect(url_for("admin_dashboard")) - try: - current_header = next( - csv.reader(open(DATA_PATH, "r", encoding="utf-8", newline="")) - ) - except Exception: - flash("Could not read the current audit CSV header.", "error") - return redirect(url_for("admin_dashboard")) - try: uploaded_header = next(csv.reader(uploaded_lines)) except Exception: flash("Could not read the uploaded CSV header.", "error") return redirect(url_for("admin_dashboard")) - if uploaded_header != current_header: + if uploaded_header != CSV_HEADERS: flash("CSV header does not match the current audit format.", "error") return redirect(url_for("admin_dashboard")) - data_dir = os.path.dirname(DATA_PATH) - backup_name = ( - f"entries_backup_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.csv" - ) - backup_path = os.path.join(data_dir, backup_name) - try: - shutil.copy2(DATA_PATH, backup_path) + existing_ids = {event["id"] for event in load_events(DATA_PATH)} + reader = csv.DictReader(uploaded_lines) + new_rows = [] + skipped_count = 0 + for row in reader: + event_id = row.get("id", "") + if not event_id or event_id in existing_ids: + skipped_count += 1 + continue - with open(DATA_PATH, "w", encoding="utf-8", newline="") as handle: - handle.write(uploaded_text) + new_rows.append({header: row.get(header, "") for header in CSV_HEADERS}) + existing_ids.add(event_id) - flash(f"CSV imported successfully. Backup created: {backup_name}", "success") + if new_rows: + with open(DATA_PATH, "a", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=CSV_HEADERS) + writer.writerows(new_rows) + + flash( + f"Imported {len(new_rows)} new events, skipped {skipped_count} duplicates.", + "success", + ) except Exception: flash("Import failed. Existing CSV was not updated.", "error") @@ -582,13 +647,41 @@ def admin_dashboard(): ) flash("Event added.", "success") - return redirect(url_for("admin_dashboard")) + return redirect( + url_for( + "admin_dashboard", + session_id=session_id, + player_name=player_name, + event_type=event_type, + ) + ) events = load_events(DATA_PATH) sessions = build_session_summaries(events) open_sessions = [session for session in sessions if session.status == "open"] + live_session = open_sessions[0] if open_sessions else None + live_session_no = "" + live_unresolved_count = 0 + if live_session is not None: + live_session_no = f"#{len(sessions):03d}" + live_unresolved_count = sum( + 1 + for entry in live_session.entries + if entry.current_due_cents > 0 or entry.player_owes_cents > 0 + ) + recent_sessions = sessions[:8] 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} + selected_event_type = request.args.get("event_type", "buyin").strip() + if selected_event_type not in event_type_colors: + selected_event_type = "buyin" + append_form = { + "session_id": request.args.get("session_id", "").strip(), + "player_name": request.args.get("player_name", "").strip(), + "event_type": selected_event_type, + } debt_entries = [ {"session": session, "entry": entry} for session in sessions @@ -598,12 +691,12 @@ def admin_dashboard(): admin_totals = { "cash_in_cents": sum(session.total_cash_in_cents for session in sessions), - "paid_out_cents": sum(session.total_paid_out_cents for session in sessions), - "still_owed_cents": sum( - session.total_current_due_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_cents": sum( - session.total_player_owes_cents for session in sessions + "players_owe_house_cents": sum( + session.total_current_due_to_house_cents for session in sessions ), "collected_cents": sum( session.total_front_collected_cents for session in sessions @@ -611,10 +704,14 @@ def admin_dashboard(): "written_off_cents": sum( session.total_front_writeoff_cents for session in sessions ), - "open_balance_cents": sum( - session.total_open_balance_cents for session in sessions + "net_book_position_cents": sum( + session.total_net_book_position_cents for session in sessions ), } + 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", @@ -622,7 +719,15 @@ def admin_dashboard(): recent_sessions=recent_sessions, recent_events=recent_events, debt_entries=debt_entries, + live_session=live_session, + live_session_no=live_session_no, + live_unresolved_count=live_unresolved_count, player_names=unique_player_names(events), + append_form=append_form, + event_types=ADMIN_EVENT_TYPES, + event_type_colors=event_type_colors, + event_type_labels=event_type_labels, + admin_today=datetime.now().date().isoformat(), session_label=session_label, admin_totals=admin_totals, ) diff --git a/memory/MEMORY.md b/memory/MEMORY.md new file mode 100644 index 0000000..98e9838 --- /dev/null +++ b/memory/MEMORY.md @@ -0,0 +1,3 @@ +# Memory Index + +- [Project overview](project_overview.md) — Flask poker ledger app, Graphite+Violet design system, append-only CSV event model diff --git a/memory/project_overview.md b/memory/project_overview.md new file mode 100644 index 0000000..3eef37a --- /dev/null +++ b/memory/project_overview.md @@ -0,0 +1,26 @@ +--- +name: project-overview +description: Flask poker ledger app — design system, tech stack, key files +metadata: + type: project +--- + +Flask/Jinja app: poker session tracker and leaderboard. Append-only CSV event ledger (`data/entries.csv`). + +**Design system (Graphite + Violet):** Implemented June 2026. +- `static/css/tokens.css` — all CSS custom properties (--accent: #9b8cf0 violet, not orange) +- `static/css/style.css` — component classes: .panel, .stat, .tbl, .badge, .segmented, .btn, .feed, etc. +- Fonts: Spectral (display), Hanken Grotesk (UI), IBM Plex Mono (numbers/labels) + +**Pages:** leaderboard, sessions, session_detail, player_detail, admin_dashboard, admin_login. + +**Key patterns:** +- Leaderboard has Eligible/All/Recent segmented control; `ELIGIBLE_MIN_SESSIONS = 3` in app.py +- Session detail has Compact/Full toggle for the table (`col-acct` class, `.is-full` on table) +- Chart.js colors: leaderboard uses CHART_PALETTE from template JS; bars use `net_tone()` from stats.py +- `--text-muted` and `--line` CSS vars kept as backward compat for Chart.js JS reads +- `.venv/bin/python` is the Python runtime + +**Data model:** EventRow CSV → SessionEntry → SessionSummary, PlayerStats (via stats.py). No DB. + +**Why:** Design ported from "Poker Ledger.dc.html" prototype spec per handoff document. diff --git a/static/css/style.css b/static/css/style.css index 3890f2e..104ef36 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1,1297 +1,1373 @@ -:root { - --bg: #0f1012; - --bg-panel: rgba(20, 22, 26, 0.92); - --bg-panel-strong: rgba(24, 27, 31, 0.98); - --bg-soft: rgba(255, 255, 255, 0.026); - --line: rgba(255, 255, 255, 0.08); - --line-strong: rgba(255, 255, 255, 0.13); - --text: #f5f3ef; - --text-muted: #a9adb6; - --text-soft: #868b94; - --accent: #e58b3a; - --accent-2: #ffb66d; - --accent-soft: rgba(229, 139, 58, 0.14); - --positive: #58d38f; - --negative: #ef6a72; - --neutral: #ddb662; - --shadow: 0 24px 64px rgba(0, 0, 0, 0.34); - --shadow-soft: 0 12px 34px rgba(0, 0, 0, 0.22); - --radius-sm: 8px; - --radius: 12px; - --radius-lg: 16px; -} - -* { - box-sizing: border-box; -} - -html { - color-scheme: dark; -} - +/* ================================================================ + RESET + BASE + ================================================================ */ +*, *::before, *::after { box-sizing: border-box; } +html { color-scheme: dark; scroll-behavior: smooth; } body { - margin: 0; - min-height: 100vh; - font-family: "Inter", sans-serif; - color: var(--text); - background: - radial-gradient( - circle at top left, - rgba(229, 139, 58, 0.11), - transparent 22% - ), - radial-gradient( - circle at top right, - rgba(255, 182, 109, 0.05), - transparent 18% - ), - linear-gradient(180deg, #0c0d0f 0%, #111418 100%); + margin: 0; + min-height: 100vh; + font-family: var(--font-ui); + font-size: 15px; + line-height: 1.5; + color: var(--text-body); + background: var(--bg); + -webkit-font-smoothing: antialiased; } +img, canvas, svg { display: block; max-width: 100%; } +a { color: inherit; text-decoration: none; } +button, input, select, textarea { font: inherit; } +button { color: inherit; cursor: pointer; } +h1, h2, h3, h4 { margin: 0; } +::-webkit-scrollbar { width: 7px; height: 7px; } +::-webkit-scrollbar-track { background: var(--surface); } +::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 4px; } -body::before { - content: ""; - position: fixed; - inset: 0; - background-image: - linear-gradient(rgba(255, 255, 255, 0.012) 1px, transparent 1px), - linear-gradient(90deg, rgba(255, 255, 255, 0.012) 1px, transparent 1px); - background-size: 40px 40px; - opacity: 0.22; - pointer-events: none; -} - -body.modal-open { - overflow: hidden; -} - -img, -canvas { - display: block; - max-width: 100%; -} - -a { - color: inherit; - text-decoration: none; -} - -button, -input, -select, -textarea { - font: inherit; -} - -button { - color: inherit; -} - -.site-shell { - width: min(1280px, calc(100% - 40px)); - margin: 0 auto; - padding: 24px 0 44px; +/* ================================================================ + FONTS + ================================================================ */ +:root { + --font-ui: Aptos, Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-display: var(--font-ui); + --font-mono: ui-monospace, "SFMono-Regular", "Cascadia Mono", "Roboto Mono", "Liberation Mono", monospace; + --font-money: var(--font-ui); } +/* ================================================================ + APP SHELL + ================================================================ */ .topbar { - display: flex; - justify-content: space-between; - align-items: center; - gap: 18px; - padding: 18px 22px; - margin-bottom: 24px; - border: 1px solid var(--line); - border-radius: var(--radius-lg); - background: rgba(16, 18, 22, 0.84); - backdrop-filter: blur(10px); - box-shadow: var(--shadow-soft); + position: sticky; top: 0; z-index: 50; + background: rgba(15,15,18,.86); + backdrop-filter: blur(8px); + border-bottom: 1px solid var(--border); +} +.topbar__inner { + max-width: var(--w-admin); + margin: 0 auto; + padding: 0 var(--gutter); + height: 62px; + display: flex; + align-items: center; + gap: 28px; } - .brand { - display: inline-block; - font-size: 1.3rem; - font-weight: 800; - letter-spacing: -0.045em; + display: flex; + align-items: center; + gap: 11px; + flex: none; } - -.brand-subtitle { - margin: 6px 0 0; - color: var(--text-muted); - font-size: 0.95rem; +.brand__mark { + width: 26px; height: 26px; + border-radius: 5px; + background: var(--accent); + box-shadow: 0 0 0 1px var(--accent-a30), 0 4px 14px var(--accent-a22); + position: relative; + flex: none; } - -.nav-links { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; +.brand__mark::after { + content: ""; + position: absolute; + inset: 0; margin: auto; + width: 11px; height: 11px; + border-radius: 2px; + background: var(--bg); + transform: rotate(45deg); } - -.nav-links form { - margin: 0; +.brand__name { + font-family: var(--font-display); + font-size: 19px; + font-weight: 600; + letter-spacing: 0; + color: var(--text); + white-space: nowrap; } - -.nav-links a, -.ghost-button, -.secondary-button, -.primary-button, -.sort-button { - transition: - background-color 140ms ease, - border-color 140ms ease, - color 140ms ease, - transform 140ms ease, - box-shadow 140ms ease; +.mainnav { display: flex; gap: 4px; } +.navlink { + font-size: 13.5px; + font-weight: 600; + padding: 7px 12px; + border-radius: var(--r-pill); + color: var(--muted); + transition: color 140ms, background 140ms; } - -.nav-links a, -.ghost-button, -.secondary-button, -.primary-button { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 42px; - padding: 0 14px; - border-radius: 10px; - border: 1px solid transparent; - background: transparent; - color: var(--text); - cursor: pointer; +.navlink:hover { color: var(--text-strong); } +.navlink.is-active { color: var(--text-strong); background: var(--field-alt); } +.topbar__spacer { flex: 1; } +.topbar__season { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: .12em; + text-transform: uppercase; + color: var(--faintest); } +.topbar__admin-actions { display: flex; align-items: center; gap: 8px; } -.nav-links a:hover, -.ghost-button:hover, -.secondary-button:hover { - background: rgba(255, 255, 255, 0.038); - border-color: var(--line); -} - -.secondary-button { - border-color: var(--line); - background: rgba(255, 255, 255, 0.025); -} - -.secondary-button:hover { - border-color: rgba(229, 139, 58, 0.3); - box-shadow: inset 0 0 0 1px rgba(229, 139, 58, 0.1); -} - -.primary-button { - padding: 0 16px; - border-color: rgba(229, 139, 58, 0.3); - background: linear-gradient( - 180deg, - rgba(229, 139, 58, 0.2), - rgba(229, 139, 58, 0.12) - ); - color: #fff3e5; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); -} - -.primary-button:hover { - border-color: rgba(255, 182, 109, 0.42); - background: linear-gradient( - 180deg, - rgba(229, 139, 58, 0.25), - rgba(229, 139, 58, 0.14) - ); -} - -.flash-stack { - display: grid; - gap: 10px; - margin-bottom: 18px; +.page { + margin: 0 auto; + padding: clamp(22px,3.4vw,46px) var(--gutter) 96px; } +.page--public { max-width: var(--w-public); } +.page--session { max-width: var(--w-session); } +.page--admin { max-width: var(--w-admin); } +/* ================================================================ + FLASH MESSAGES + ================================================================ */ +.flash-stack { display: grid; gap: 10px; margin-bottom: 24px; } .flash { - padding: 14px 16px; - border: 1px solid var(--line); - border-radius: var(--radius); - background: var(--bg-panel); - box-shadow: var(--shadow-soft); + padding: 11px 16px; + border-radius: var(--r-sm); + font-size: 14px; + border: 1px solid var(--border); + background: var(--surface); } - .flash.success { - border-color: rgba(88, 211, 143, 0.2); - background: linear-gradient( - 180deg, - rgba(88, 211, 143, 0.08), - rgba(20, 22, 26, 0.94) - ); + color: var(--pos); + border-color: var(--pos-tint-bd); + background: var(--pos-tint); } - .flash.error { - border-color: rgba(239, 106, 114, 0.2); - background: linear-gradient( - 180deg, - rgba(239, 106, 114, 0.08), - rgba(20, 22, 26, 0.94) - ); + color: var(--neg); + border-color: var(--neg-tint-bd); + background: var(--neg-tint); } -.page-content { - display: grid; - gap: 24px; +/* ================================================================ + TYPOGRAPHY + ================================================================ */ +.kicker { + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: .14em; + text-transform: uppercase; + color: var(--faintest); + margin: 0 0 6px; + display: block; } - -.hero-card, -.panel { - position: relative; - overflow: hidden; - border: 1px solid var(--line); - border-radius: var(--radius-lg); - background: var(--bg-panel); - box-shadow: var(--shadow-soft); +.kicker--dot { + display: inline-flex; + align-items: center; + gap: 7px; } - -.hero-card::before, -.panel::before { - content: ""; - position: absolute; - inset: 0; - background: linear-gradient( - 180deg, - rgba(255, 255, 255, 0.018), - transparent 34% - ); - pointer-events: none; +.kicker--dot::before { + content: ""; + width: 7px; + height: 7px; + border-radius: 50%; + flex: none; } - -.hero-card { - padding: 30px; -} - -.hero-card.compact { - padding: 24px 28px; -} - -.panel { - padding: 22px; -} - +.kicker--dot-accent::before { background: var(--accent); } +.kicker--dot-warn::before { background: var(--warn); } .eyebrow { - margin: 0 0 8px; - color: var(--accent-2); - font-size: 0.74rem; - font-weight: 700; - letter-spacing: 0.12em; - text-transform: uppercase; + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: .14em; + text-transform: uppercase; + color: var(--accent); + margin: 0 0 8px; } - -h1, -h2 { - margin: 0; - letter-spacing: -0.05em; -} - h1 { - font-size: clamp(2.15rem, 4.2vw, 3.7rem); - line-height: 0.98; + font-family: var(--font-display); + font-size: clamp(30px,4.4vw,46px); + font-weight: 600; + letter-spacing: 0; + color: var(--text); + line-height: 1.05; } - h2 { - font-size: 1.34rem; - line-height: 1.15; + font-family: var(--font-display); + font-size: 18px; + font-weight: 600; + color: var(--text-strong); } - h3 { - position: relative; - z-index: 1; - margin: 0; - font-size: 1.05rem; - line-height: 1.2; + font-size: 15px; + font-weight: 600; + color: var(--text-strong); } -.muted-text { - margin: 0; - color: var(--text-muted); - line-height: 1.65; +/* page intro */ +.hero { padding: 28px 0 22px; } +.hero__kicker { + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: .14em; + text-transform: uppercase; + color: var(--accent); + margin: 0 0 10px; +} +.session-number { + color: var(--accent); + margin-bottom: 8px; +} +.hero__title { + font-family: var(--font-display); + font-size: clamp(30px,4.4vw,46px); + font-weight: 600; + letter-spacing: 0; + color: var(--text); + line-height: 1.05; + margin: 0 0 10px; } -.muted-text.small, -.tiny-text { - font-size: 0.9rem; +.currency-symbol { + font-family: var(--font-money); + font-variant-numeric: normal; +} +.hero__sub { + color: var(--muted); + font-size: 14.5px; + margin: 0; + max-width: 54ch; } -.tiny-text { - color: var(--text-soft); +/* ================================================================ + PANEL / CARD + ================================================================ */ +.panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-lg); + overflow: hidden; } - -.grid { - display: grid; - gap: 24px; +.panel__head { + padding: 16px 22px; + border-bottom: 1px solid var(--field-alt); + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + background: var(--surface-head); } - -.two-col { - grid-template-columns: minmax(0, 1.65fr) minmax(320px, 0.95fr); -} - -.leaderboard-layout { - grid-template-columns: minmax(0, 2.3fr) minmax(280px, 0.7fr); -} - -.three-col { - grid-template-columns: repeat(3, minmax(0, 1fr)); -} - -.four-col { - grid-template-columns: repeat(4, minmax(0, 1fr)); -} - -.stack-gap { - display: grid; - gap: 18px; -} - -.form-shell { - display: grid; - justify-content: center; -} - -.form-shell.narrow { - grid-template-columns: minmax(320px, 540px); -} - -.form-card { - display: grid; - gap: 16px; - align-content: start; -} - -.form-card label { - display: grid; - gap: 8px; - position: relative; - z-index: 1; -} - -.form-card label > span { - color: var(--text-soft); - font-size: 0.84rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.form-card input, -.form-card select, -.form-card textarea { - width: 100%; - border: 1px solid var(--line); - border-radius: 10px; - background: rgba(255, 255, 255, 0.028); - color: var(--text); - padding: 12px 13px; - outline: none; - appearance: none; -} - -.form-card textarea { - resize: vertical; - min-height: 112px; -} - -.compact-form textarea { - min-height: 92px; -} - -.form-card input:focus, -.form-card select:focus, -.form-card textarea:focus { - border-color: rgba(229, 139, 58, 0.42); - box-shadow: 0 0 0 4px rgba(229, 139, 58, 0.1); -} - -.admin-layout .form-card { - align-self: start; -} - -.admin-layout { - grid-template-columns: minmax(420px, 1.15fr) minmax(360px, 0.85fr); -} - -.admin-layout > .grid { - gap: 18px; +.panel__title { + font-family: var(--font-display); + font-weight: 600; + font-size: 18px; + margin: 0; + color: var(--text-strong); } +.panel__tag { font-family: var(--font-mono); font-size: 11px; color: var(--faintest); } +.panel__body { padding: 20px; } +/* panel-header — old utility kept for admin forms */ .panel-header { - position: relative; - z-index: 1; - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: 14px; - margin-bottom: 18px; + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 14px; + padding: 20px 20px 0; +} +.panel-header--center { align-items: center; } +.panel-actions { display: flex; align-items: center; gap: 10px; } + +/* ================================================================ + STAT / METRIC TILE + ================================================================ */ +.stat { + padding: 15px 16px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-md); +} +.stat__val { + font-family: var(--font-display); + font-size: 26px; + font-weight: 600; + line-height: 1; + margin: 4px 0 0; +} +.stat__sub { + font-family: var(--font-mono); + font-size: 11.5px; + color: var(--muted); + margin-top: 8px; +} +.stat--hero .stat__val { font-size: 34px; } +.player-rank { + margin: 0 0 4px; + color: var(--accent); + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: .12em; + text-transform: uppercase; +} +.player-stat-grid { + display: grid; + grid-template-columns: repeat(8, minmax(0, 1fr)); + gap: var(--s3); +} +.player-stat-grid .stat { + min-width: 0; +} +.player-stat-grid .stat__val { + font-size: clamp(19px, 1.7vw, 24px); + word-break: keep-all; +} +.stat-pair { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 10px; + margin-top: 4px; +} +.stat-pair__val { + font-family: var(--font-display); + font-size: clamp(17px, 1.55vw, 22px); + font-weight: 600; + line-height: 1; + white-space: nowrap; +} +.stat--rail { position: relative; padding-left: 20px; } +.stat--rail::before { + content: ""; + position: absolute; + left: 0; top: 0; bottom: 0; + width: 3px; + background: var(--rail, var(--accent)); } -.panel-header-with-actions { - align-items: center; +/* hero stat row (small inline tiles) */ +.stat-row-inline { + display: flex; + flex-wrap: wrap; + gap: 12px; } -.panel-actions { - display: flex; - align-items: center; - gap: 10px; +/* ================================================================ + TOOLBAR + ================================================================ */ +.toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 14px; + margin: 24px 0 12px; } - -.chart-panel { - min-height: 420px; +.toolbar__left { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } +.toolbar__right { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.toolbar__hint { + font-size: 12.5px; + color: var(--muted); + max-width: 42ch; + line-height: 1.4; + margin: 0; } +.sortnote { font-family: var(--font-mono); font-size: 11.5px; color: var(--faintest); } +.sortnote b { color: var(--text-2); font-weight: 500; } -.chart-panel-large { - min-height: 600px; +/* ================================================================ + SEGMENTED CONTROL + ================================================================ */ +.segmented { + display: inline-flex; + padding: 3px; + gap: 2px; + background: var(--surface-raised); + border: 1px solid var(--border); + border-radius: var(--r-sm); } - -.chart-frame, -.chart-modal-frame { - position: relative; - min-height: 340px; +.seg { + display: flex; + align-items: center; + gap: 7px; + padding: 7px 13px; + border: 0; + border-radius: var(--r-pill); + background: transparent; + color: var(--muted); + font: 600 13px var(--font-ui); + cursor: pointer; + white-space: nowrap; + transition: background 120ms, color 120ms; } - -.chart-panel-large .chart-frame { - min-height: 510px; +.seg:hover { color: var(--text-2); } +.seg.is-on { + background: var(--accent-chip); + color: var(--text-strong); + box-shadow: inset 0 0 0 1px var(--accent-chip-bd); } +.seg__count { font-family: var(--font-mono); font-size: 11px; font-weight: 500; color: var(--faintest); } +.seg.is-on .seg__count { color: var(--accent); } -.chart-frame-interactive { - cursor: zoom-in; +/* ================================================================ + INPUTS / SELECTS + ================================================================ */ +.fieldlabel { + display: block; + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: .10em; + text-transform: uppercase; + color: var(--faintest); + margin-bottom: 7px; } - -.chart-frame-interactive:focus-visible { - outline: 2px solid rgba(229, 139, 58, 0.55); - outline-offset: 5px; +.field-input, .field-select { + width: 100%; + padding: 10px 12px; + background: var(--field); + border: 1px solid var(--border-strong); + border-radius: var(--r-sm); + color: var(--text-strong); + font: 14px var(--font-ui); + outline: none; + appearance: none; } - -.chart-panel canvas, -.chart-frame canvas, -.chart-modal-frame canvas { - width: 100% !important; - height: 100% !important; +.field-input:focus, .field-select:focus { + border-color: var(--accent-chip-bd); + box-shadow: 0 0 0 3px var(--accent-a22); } +.field-input::placeholder { color: var(--faintest-2); } -.stat-panel, -.stat-card { - display: grid; - align-content: start; +/* form-card (admin forms) */ +.form-card { display: grid; gap: 16px; align-content: start; padding: 22px; } +.form-card label { display: grid; gap: 6px; } +.form-card label > span { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: .10em; + text-transform: uppercase; + color: var(--faintest); } - -.stat-list { - display: grid; - gap: 8px; - position: relative; - z-index: 1; +.form-card input, .form-card select, .form-card textarea { + width: 100%; + padding: 10px 12px; + background: var(--field); + border: 1px solid var(--border-strong); + border-radius: var(--r-sm); + color: var(--text-strong); + font: 14px var(--font-ui); + outline: none; + appearance: none; } - -.stat-row { - display: flex; - justify-content: space-between; - align-items: center; - gap: 14px; - padding: 14px 0; - border-bottom: 1px solid var(--line); -} - -.stat-row:last-child { - border-bottom: none; -} - -.stat-row span { - color: var(--text-muted); -} - -.stat-card { - gap: 10px; - min-height: 128px; -} - -.stat-card span { - color: var(--text-soft); - font-size: 0.78rem; - font-weight: 700; - letter-spacing: 0.12em; - text-transform: uppercase; -} - -.stat-card strong { - font-size: clamp(1.8rem, 3vw, 2.35rem); - line-height: 1; - letter-spacing: -0.06em; -} - -.rank-move { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 2.5rem; - font-weight: 650; - letter-spacing: 0.01em; -} - -.rank-up { - color: #22c55e; -} - -.rank-down { - color: #ef4444; -} - -.rank-flat { - color: var(--text-muted); -} - -.inline-filter-form select { - min-width: 12rem; -} - -.table-wrap { - position: relative; - z-index: 1; - overflow-x: auto; - border-radius: 10px; - border: 1px solid var(--line); - background: rgba(255, 255, 255, 0.018); -} - -.small-table th, -.small-table td { - padding: 12px 14px; -} - -table { - width: 100%; - border-collapse: collapse; -} - -th, -td { - padding: 14px; - border-bottom: 1px solid var(--line); - text-align: left; - white-space: nowrap; - vertical-align: top; -} - -th { - color: var(--text-soft); - font-size: 0.76rem; - font-weight: 700; - letter-spacing: 0.12em; - text-transform: uppercase; -} - -tbody tr { - transition: background-color 140ms ease; -} - -tbody tr:hover { - background: rgba(255, 255, 255, 0.026); -} - -tbody tr:last-child td, -tbody tr:last-child th { - border-bottom: none; -} - -td a { - font-weight: 600; -} - -td a:hover { - color: var(--accent-2); -} - -.sort-button { - display: inline-flex; - align-items: center; - gap: 6px; - width: 100%; - padding: 0; - border: 0; - background: transparent; - color: inherit; - text-align: left; - cursor: pointer; -} - -.sort-button:hover { - color: var(--text); -} - -.sort-button.is-active { - color: var(--accent-2); -} - -.sort-indicator { - color: var(--text-soft); - font-size: 0.9em; -} - -.sort-button.is-active .sort-indicator { - color: var(--accent-2); -} - -.leaderboard-controls { - display: grid; - gap: 8px; - justify-items: end; -} - -.session-filter-form { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; -} - -.session-filter-label { - color: var(--text-muted); - font-size: 0.9rem; - font-weight: 600; +.form-card input:focus, .form-card select:focus, .form-card textarea:focus { + border-color: var(--accent-chip-bd); + box-shadow: 0 0 0 3px var(--accent-a22); } +.form-card textarea { resize: vertical; min-height: 92px; } +.form-card input::placeholder, .form-card textarea::placeholder { color: var(--faintest-2); } +.session-filter-form { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } .session-filter-select { - min-width: 220px; - min-height: 42px; - padding: 0 14px; - border: 1px solid var(--line-strong); - border-radius: 12px; - background: rgba(255, 255, 255, 0.035); - color: var(--text); - outline: none; + min-width: 200px; + padding: 8px 12px; + background: var(--field); + border: 1px solid var(--border-strong); + border-radius: var(--r-sm); + color: var(--text-strong); + font: 13.5px var(--font-ui); + outline: none; + appearance: none; } - .session-filter-select:focus { - border-color: rgba(240, 138, 56, 0.42); - box-shadow: 0 0 0 4px rgba(240, 138, 56, 0.12); + border-color: var(--accent-chip-bd); + box-shadow: 0 0 0 3px var(--accent-a22); } -.compact-button { - min-height: 42px; +/* ================================================================ + TABLE + ================================================================ */ +.tbl-scroll { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} +.tbl { + width: 100%; + border-collapse: collapse; + font-size: 14px; + min-width: 700px; +} +.tbl thead tr { background: var(--surface-head); } +.tbl th { + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: .10em; + text-transform: uppercase; + color: var(--muted); + font-weight: 500; + padding: 13px 14px; + text-align: right; + white-space: nowrap; + border-bottom: 1px solid var(--border); +} +.tbl th.col-player, .tbl th.col-left { text-align: left; } +.tbl td { + padding: 12px 14px; + text-align: right; + border-top: 1px solid var(--divider); + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + color: var(--num); + white-space: nowrap; +} +.tbl td.col-player, .tbl td.col-left { + text-align: left; + font-family: var(--font-ui); + color: var(--text-body); +} +.tbl tbody tr { cursor: pointer; transition: background 80ms; } +.tbl tbody tr:hover { background: #1e1e25; } +.tbl .num-net { font-weight: 600; font-size: 15px; } +.tbl .num-pos { color: var(--pos); } +.tbl .num-neg { color: var(--neg); } +.tbl .num-neutral { color: var(--muted-2); } +.tbl .num-muted { color: var(--muted-2); } +.tbl .rank-num { font-family: var(--font-display); font-size: 15px; font-weight: 600; color: var(--num); } +.tbl .rank-1 { color: var(--rank-1); } +.tbl .rank-2 { color: var(--rank-2); } +.tbl .rank-3 { color: var(--rank-3); } + +/* accounting columns (hidden until .is-full) */ +.tbl .col-acct { display: none; } +.tbl.is-full .col-acct { display: table-cell; } +.tbl--session { + min-width: 920px; +} +.tbl--session.is-full { + min-width: 1240px; +} +.tbl--session th:nth-child(7), +.tbl--session td:nth-child(7) { + width: 1%; +} +.tbl--session .badge { + padding-inline: 7px; + font-size: 10px; } -.positive, -.negative, -.neutral { - font-weight: 700; -} +/* sticky rank+player on horizontal scroll */ +.tbl .col-rank { position: sticky; left: 0; background: var(--surface); z-index: 1; } +.tbl .col-player { position: sticky; left: 52px; background: var(--surface); z-index: 1; } +.tbl thead .col-rank { background: var(--surface-head); z-index: 2; } +.tbl thead .col-player { background: var(--surface-head); z-index: 2; } +.tbl--session .col-player { left: 0; min-width: 148px; } -.positive { - color: var(--positive); +/* sort button inside th */ +.sort-btn { + display: inline-flex; + align-items: center; + gap: 4px; + background: transparent; + border: 0; + color: inherit; + font: inherit; + cursor: pointer; + padding: 0; } +.sort-btn.is-active { color: var(--text-2); } +.sort-arrow { color: var(--accent); font-size: 10px; } -.negative { - color: var(--negative); +/* rank delta cell */ +.tbl .col-rank-delta { text-align: center; } +.rank-delta { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 34px; + height: 22px; + padding: 0 8px; + border-radius: var(--r-pill); + font-family: var(--font-mono); + font-size: 11px; + font-weight: 700; } +.rank-delta--up { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); } +.rank-delta--down { color: var(--neg); background: var(--neg-tint); box-shadow: inset 0 0 0 1px var(--neg-tint-bd); } +.rank-delta--flat { color: var(--faintest-2); background: var(--surface-sunk); box-shadow: inset 0 0 0 1px var(--border); } -.neutral { - color: var(--neutral); +/* old positive/negative/neutral utility (kept for compatibility) */ +.positive { color: var(--pos) !important; font-weight: 600; } +.negative { color: var(--neg) !important; font-weight: 600; } +.neutral { color: var(--muted-2) !important; font-weight: 600; } + +/* old table-wrap (admin uses plain table still) */ +.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; } +table { width: 100%; border-collapse: collapse; } +th, td { padding: 11px 14px; text-align: left; white-space: nowrap; vertical-align: middle; } +th { + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: .10em; + text-transform: uppercase; + color: var(--muted); + font-weight: 500; + background: var(--surface-head); + border-bottom: 1px solid var(--border); } +tbody tr { transition: background 80ms; } +tbody tr:hover { background: #1e1e25; } +tbody tr:not(:last-child) td { border-bottom: 1px solid var(--divider); } +td { font-size: 13.5px; color: var(--text-body); } +td a { font-weight: 600; color: var(--text-strong); } +td a:hover { color: var(--accent); } -.event-feed { - display: grid; - gap: 12px; - position: relative; - z-index: 1; +/* ================================================================ + BADGES / STATUS PILLS + ================================================================ */ +.badge { + display: inline-block; + padding: 3px 9px; + border-radius: 5px; + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: .05em; + text-transform: uppercase; + font-weight: 500; + white-space: nowrap; } +.badge--paid { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); } +.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); } +.badge--none { color: var(--faintest-2); } +.badge--open { color: var(--warn); background: var(--warn-tint); box-shadow: inset 0 0 0 1px var(--warn-tint-bd); } +.badge--closed { color: var(--muted-2); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); } +.badge--written_off { color: var(--neg); background: var(--neg-tint); box-shadow: inset 0 0 0 1px var(--neg-tint-bd); } +.badge--collected { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); } -.event-feed.condensed { - gap: 10px; -} - -.event-card { - display: grid; - gap: 8px; - padding: 14px; - border: 1px solid var(--line); - border-radius: 10px; - background: rgba(255, 255, 255, 0.022); -} - -.event-card-top { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} - -.event-amount { - margin: 0; - font-size: 1.1rem; - font-weight: 700; - letter-spacing: -0.03em; +/* old status-badge pattern (kept for templates) */ +.status-badge { + display: inline-flex; + align-items: center; + padding: 3px 9px; + border-radius: 5px; + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: .05em; + text-transform: uppercase; + font-weight: 500; + white-space: nowrap; } +.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-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); } +.status-none { color: var(--faintest-2); } +.status-written_off { color: var(--neg); background: var(--neg-tint); box-shadow: inset 0 0 0 1px var(--neg-tint-bd); } +.status-collected { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); } +/* event-type pills */ .pill { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 26px; - padding: 0 10px; - border: 1px solid rgba(229, 139, 58, 0.25); - border-radius: 999px; - background: var(--accent-soft); - color: var(--accent-2); - font-size: 0.72rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; + display: inline-flex; + align-items: center; + padding: 3px 9px; + border-radius: 5px; + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: .05em; + text-transform: uppercase; + font-weight: 500; +} +.pill-buyin { color: var(--accent); background: var(--accent-tint); box-shadow: inset 0 0 0 1px var(--accent-chip-bd); } +.pill-front { color: var(--warn); background: var(--warn-tint); box-shadow: inset 0 0 0 1px var(--warn-tint-bd); } +.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-rollover_out { color: var(--rolled); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); } +.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); } +.pill-session_open { color: var(--warn); background: var(--warn-tint); box-shadow: inset 0 0 0 1px var(--warn-tint-bd); } +.pill-session_close { color: var(--muted-2); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); } + +/* ================================================================ + CHARTS + ================================================================ */ +.chart-wrap { padding: 0 20px 20px; position: relative; } +.chart-frame { + position: relative; + height: clamp(220px, 30vw, 300px); +} +.chart-frame--tall { height: clamp(260px, 36vw, 340px); } +.chart-frame canvas { width: 100% !important; height: 100% !important; } + +.session-net-bars { + display: grid; + gap: 11px; + min-height: clamp(220px, 30vw, 300px); + align-content: center; + padding-top: 4px; +} +.session-net-bars__row { + display: grid; + grid-template-columns: minmax(92px, 150px) minmax(160px, 1fr) minmax(86px, max-content); + gap: 12px; + align-items: center; +} +.session-net-bars__name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-2); + font-size: 13px; + font-weight: 600; +} +.session-net-bars__track { + position: relative; + height: 18px; + border-radius: 5px; + background: var(--surface-sunk); + overflow: hidden; +} +.session-net-bars__track::before { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 50%; + width: 1px; + background: var(--border-hi); + z-index: 1; +} +.session-net-bars__bar { + position: absolute; + top: 4px; + height: 10px; + min-width: 2px; + border-radius: 5px; +} +.session-net-bars__bar--pos { left: 50%; } +.session-net-bars__bar--neg { right: 50%; } +.session-net-bars__amount { + font-family: var(--font-mono); + font-size: 12.5px; + color: var(--num); + text-align: right; + white-space: nowrap; } -.note-list { - margin: 0; - padding-left: 18px; - color: var(--text-muted); - white-space: normal; +.result-mix { + min-height: clamp(220px, 30vw, 300px); + display: grid; + align-content: center; + gap: 18px; +} +.result-mix__bar { + display: flex; + height: 24px; + overflow: hidden; + border-radius: 6px; + background: var(--surface-sunk); + box-shadow: inset 0 0 0 1px var(--border); +} +.result-mix__seg { + min-width: 0; + flex-basis: 0; +} +.result-mix__seg--win { background: var(--pos); } +.result-mix__seg--even { background: var(--muted-2); } +.result-mix__seg--loss { background: var(--neg); } +.result-mix__legend { + display: grid; + gap: 10px; + color: var(--muted); + font-family: var(--font-mono); + font-size: 11.5px; +} +.result-mix__legend-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 16px; + align-items: center; +} +.result-mix__legend-row span { + display: inline-flex; + align-items: center; + gap: 6px; +} +.result-mix__legend-row strong { + color: var(--text-2); + font-weight: 600; + text-align: right; + white-space: nowrap; +} +.result-mix__key { + width: 8px; + height: 8px; + border-radius: 50%; + flex: none; +} +.result-mix__key--win { background: var(--pos); } +.result-mix__key--even { background: var(--muted-2); } +.result-mix__key--loss { background: var(--neg); } +.result-mix__note { + color: var(--faintest); + font-family: var(--font-mono); + font-size: 11px; } -.note-list li + li { - margin-top: 6px; +/* ================================================================ + RECON CARDS (session cash flow / open items) + ================================================================ */ +.recon-grid { display: grid; gap: var(--s3); grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); } +.recon-card-head { padding: 14px 20px 0; } +.recon-metric-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1px; + background: var(--border); + border-top: 1px solid var(--border); + margin-top: 12px; +} +.recon-cell { background: var(--surface); padding: 14px 20px; } +.recon-val { + font-family: var(--font-display); + font-size: 22px; + font-weight: 600; + color: var(--text-strong); + line-height: 1; + margin: 4px 0 0; +} +.recon-val--pos { color: var(--pos); } +.recon-val--neg { color: var(--neg); } +.recon-val--warn { color: var(--warn); } +.recon-val--num { color: var(--num); } + +/* ================================================================ + QUICK SNAPSHOT (2×2 grid inside a panel) + ================================================================ */ +.snapshot-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1px; + background: var(--border); + border-top: 1px solid var(--border); +} +.snapshot-cell { background: var(--surface); padding: 16px 20px; } +.snapshot-val { + font-family: var(--font-display); + font-size: 22px; + font-weight: 600; + color: var(--text-strong); + line-height: 1; + margin: 4px 0 0; } -.chart-modal { - position: fixed; - inset: 0; - z-index: 80; - display: none; +/* ================================================================ + AUDIT / RAW-EVENTS FEED + ================================================================ */ +.feed { max-height: 360px; overflow-y: auto; } +.feed__row { + display: flex; + align-items: center; + gap: 11px; + padding: 9px 20px; + border-top: 1px solid var(--divider); } +.feed__row:first-child { border-top: none; } +.feed__name { flex: 1; font-size: 13px; color: var(--text-2); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.feed__amt { font-family: var(--font-mono); font-size: 12.5px; color: var(--num); white-space: nowrap; } +.feed__time { font-family: var(--font-mono); font-size: 10.5px; color: var(--faintest-2); white-space: nowrap; } +.feed__tag { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .06em; font-weight: 500; text-transform: uppercase; white-space: nowrap; } -.chart-modal.is-open { - display: block; +/* old event-card (used in session detail and admin) */ +.event-feed { display: grid; gap: 0; } +.event-card { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 20px; + border-top: 1px solid var(--divider); } - -.chart-modal-backdrop { - position: absolute; - inset: 0; - background: rgba(8, 10, 12, 0.78); - backdrop-filter: blur(6px); +.event-card:first-child { border-top: none; } +.event-card-top { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-width: 0; } - -.chart-modal-card { - position: relative; - z-index: 1; - width: min(1320px, calc(100vw - 48px)); - max-height: calc(100vh - 48px); - margin: 24px auto; - padding: 22px; - border: 1px solid var(--line-strong); - border-radius: 18px; - background: var(--bg-panel-strong); - box-shadow: var(--shadow); +.event-card-top strong { + font-size: 13px; + color: var(--text-strong); + white-space: nowrap; } - -.chart-modal-frame { - height: min(74vh, 820px); +.event-card--inline { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 10px; + padding: 9px 16px; } - -.leaderboard-layout, -.session-layout { - align-items: stretch; +.event-card--inline .event-card-top { + gap: 8px; } - -.admin-layout { - align-items: start; +.event-card--inline .pill { + padding: 2px 7px; + font-size: 9.5px; } - -.results-panel-full { - display: block; +.event-note { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + 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; } +.muted-text { color: var(--muted); font-size: 13.5px; margin: 0; } +.muted-text.small, .tiny-text { font-size: 12px; } +.tiny-text { color: var(--faintest); } +.note-list { margin: 0; padding-left: 16px; color: var(--muted); font-size: 13px; } +.note-list li + li { margin-top: 4px; } -.table-scroll-open { - overflow-x: auto; - overflow-y: visible; - max-height: none; - padding-right: 0; +/* ================================================================ + BUTTONS + ================================================================ */ +.btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + border: 0; + border-radius: var(--r-sm); + font: 700 14px var(--font-ui); + cursor: pointer; + white-space: nowrap; + transition: opacity 120ms, box-shadow 120ms; } +.btn--primary { background: var(--accent); color: var(--accent-ink); box-shadow: 0 4px 16px var(--accent-a30); } +.btn--primary:hover { opacity: .88; } +.btn--ghost { background: var(--field); color: var(--text-2); border: 1px solid var(--border-strong); font-weight: 600; } +.btn--ghost:hover { border-color: var(--border-hi); color: var(--text-strong); } +.btn--outline { background: var(--accent-tint); color: var(--accent); border: 1px solid var(--accent-chip-bd); } +.btn--outline:hover { background: var(--accent-chip); } +.btn--sm { padding: 7px 13px; font-size: 13px; } -.chart-row { - align-items: stretch; +/* legacy button classes mapped to new */ +.primary-button { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + border: 0; + border-radius: var(--r-sm); + font: 700 14px var(--font-ui); + cursor: pointer; + background: var(--accent); + color: var(--accent-ink); + box-shadow: 0 4px 16px var(--accent-a30); + text-decoration: none; + transition: opacity 120ms; } - -.chart-panel-compact, -.session-insights-panel { - display: flex; - flex-direction: column; +.primary-button:hover { opacity: .88; } +.secondary-button, .ghost-button { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + border-radius: var(--r-sm); + font: 600 13.5px var(--font-ui); + cursor: pointer; + background: var(--field); + color: var(--text-2); + border: 1px solid var(--border-strong); + text-decoration: none; + transition: border-color 120ms, color 120ms; } - -.chart-panel-compact .chart-frame-compact { - flex: 1; - min-height: 220px; - height: auto; +.secondary-button:hover, .ghost-button:hover { + border-color: var(--border-hi); + color: var(--text-strong); } - -.session-results-table { - min-width: 980px; -} - -.session-results-table th, -.session-results-table td { - vertical-align: top; - white-space: nowrap; -} - -.session-results-table th:last-child, -.session-results-table td:last-child { - min-width: 18rem; - width: 22rem; - white-space: normal; -} - -.session-results-table thead th { - position: sticky; - top: 0; - z-index: 2; - background: rgba(16, 20, 28, 0.96); - backdrop-filter: blur(6px); -} - -.detail-grid { - display: grid; - gap: 0.9rem; -} - -.detail-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - padding: 0.8rem 0; - border-bottom: 1px solid var(--line); -} - -.detail-row:last-child { - border-bottom: 0; -} - -.button-row { - display: flex; - gap: 0.75rem; - flex-wrap: wrap; -} - -.secondary-button { - appearance: none; - border: 1px solid rgba(255, 255, 255, 0.12); - background: rgba(255, 255, 255, 0.04); - color: var(--text-strong); - border-radius: 12px; - padding: 0.8rem 1rem; - font: inherit; - font-weight: 600; - cursor: pointer; -} - -.secondary-button:hover { - background: rgba(255, 255, 255, 0.07); -} - -.status-badge { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 5.5rem; - padding: 0.35rem 0.65rem; - border-radius: 999px; - font-size: 0.78rem; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; -} - -.status-open { - color: #f59e0b; - background: rgba(245, 158, 11, 0.12); - border: 1px solid rgba(245, 158, 11, 0.24); -} - -.status-closed { - color: #94a3b8; - background: rgba(148, 163, 184, 0.1); - border: 1px solid rgba(148, 163, 184, 0.2); -} - -.inline-action-form { - margin: 0; -} - .table-action-button { - appearance: none; - border: 1px solid rgba(255, 255, 255, 0.1); - background: rgba(255, 255, 255, 0.04); - color: var(--text-strong); - border-radius: 10px; - padding: 0.45rem 0.7rem; - font: inherit; - font-size: 0.88rem; - font-weight: 600; - cursor: pointer; + display: inline-flex; + align-items: center; + padding: 5px 11px; + border-radius: var(--r-pill); + font: 600 12px var(--font-ui); + cursor: pointer; + background: var(--field); + color: var(--text-2); + border: 1px solid var(--border-strong); + transition: border-color 120ms, color 120ms; +} +.table-action-button:hover { border-color: var(--accent-chip-bd); color: var(--accent); } + +/* ================================================================ + LAYOUT UTILITIES + ================================================================ */ +.cards { display: grid; gap: var(--s3); grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); } +.cards-2 { display: grid; gap: var(--s4); grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); } +.leaderboard-hero-stats { + display: flex; + gap: 12px; + flex-wrap: wrap; + align-self: center; + justify-content: flex-end; +} +.leaderboard-hero-stats .stat { + min-width: 132px; +} +.leaderboard-hero-stats .stat:last-child { + min-width: 168px; +} +.split { display: flex; flex-wrap: wrap; gap: var(--s4); align-items: flex-start; } +.split > .col-main { flex: 2; min-width: 360px; } +.split > .col-side { flex: 1; min-width: 300px; } +.stack { display: grid; gap: 20px; } +.inline-action-form { margin: 0; } +.button-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } + +/* Breadcrumb */ +.breadcrumb { + display: flex; + align-items: center; + gap: 6px; + font-family: var(--font-mono); + font-size: 11.5px; + color: var(--faintest); + margin-bottom: 20px; + flex-wrap: wrap; +} +.breadcrumb a { color: var(--text-2); } +.breadcrumb a:hover { color: var(--accent); } +.breadcrumb__sep { color: var(--slash); } + +/* Admin workspace */ +.admin-workspace { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(300px, 1fr); + gap: var(--s4); + align-items: flex-start; +} +.admin-workspace__main { flex: 2; min-width: 360px; } +.admin-workspace__side { flex: 1; min-width: 300px; } +.admin-workspace .col-main, +.admin-workspace .col-side { + display: flex; + flex-direction: column; + gap: var(--s4); + min-width: 0; +} +.admin-bottom-grid { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(300px, 1fr); + gap: var(--s4); + align-items: stretch; + 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; +} +.admin-bottom-grid > .panel:last-child .event-feed { + flex: 1; + align-content: start; + max-height: none; + overflow-y: auto; } -.table-action-button:hover { - background: rgba(255, 255, 255, 0.07); +/* Cash position strip */ +.cash-strip { + display: grid; + gap: var(--s3); + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + margin-bottom: 28px; } -.chart-frame-small { - min-height: 260px; +/* Live indicator dot */ +.live-badge { + display: inline-flex; + align-items: center; + gap: 6px; + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: .08em; + text-transform: uppercase; + color: var(--pos); + font-weight: 500; +} +.live-badge::before { + content: ""; + width: 7px; height: 7px; + border-radius: 50%; + background: var(--pos); + animation: livepulse 2.2s ease-in-out infinite; +} +@keyframes livepulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: .5; transform: scale(.78); } } -.status-none { - color: #94a3b8; - background: rgba(148, 163, 184, 0.1); - border: 1px solid rgba(148, 163, 184, 0.2); +/* Provisional (sub-threshold players) */ +.provisional-grid { + display: grid; + gap: var(--s3); + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); +} +.provisional-card { + padding: 14px 16px; + background: var(--surface-sunk); + border: 1px dashed var(--border-hi); + border-radius: var(--r-md); + transition: border-color 120ms; +} +.provisional-card:hover { border-color: var(--accent-chip-bd); } +.provisional-card__name { + font-weight: 600; + font-size: 14px; + color: var(--text-2); + margin: 0 0 6px; +} +.provisional-card__meta { + display: flex; + gap: 12px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--faintest); } -.status-unpaid { - color: #f59e0b; - background: rgba(245, 158, 11, 0.12); - border: 1px solid rgba(245, 158, 11, 0.24); +/* Perf / Cash section separator dot */ +.section-marker { + display: inline-flex; + align-items: center; + gap: 6px; + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: .12em; + text-transform: uppercase; + color: var(--faintest); + margin-bottom: 16px; } - -.status-partial { - color: #fb923c; - background: rgba(251, 146, 60, 0.12); - border: 1px solid rgba(251, 146, 60, 0.24); -} - -.status-paid { - color: #22c55e; - background: rgba(34, 197, 94, 0.12); - border: 1px solid rgba(34, 197, 94, 0.24); -} - -.pill-paid { - color: #22c55e; - background: rgba(34, 197, 94, 0.12); - border: 1px solid rgba(34, 197, 94, 0.22); -} - -.pill-cashout { - color: #f59e0b; - background: rgba(245, 158, 11, 0.12); - border: 1px solid rgba(245, 158, 11, 0.22); -} - -.pill-buyin { - color: #60a5fa; - background: rgba(96, 165, 250, 0.12); - border: 1px solid rgba(96, 165, 250, 0.22); -} - -.pill-front { - color: #fb923c; - background: rgba(251, 146, 60, 0.12); - border: 1px solid rgba(251, 146, 60, 0.22); -} - -.pill-front_collected { - color: #22c55e; - background: rgba(34, 197, 94, 0.12); - border: 1px solid rgba(34, 197, 94, 0.22); -} - -.pill-front_writeoff { - color: #f87171; - background: rgba(248, 113, 113, 0.12); - border: 1px solid rgba(248, 113, 113, 0.22); -} - -.pill-note { - color: #cbd5e1; - background: rgba(203, 213, 225, 0.08); - border: 1px solid rgba(203, 213, 225, 0.14); -} - -.status-badge { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 5.5rem; - padding: 0.35rem 0.65rem; - border-radius: 999px; - font-size: 0.78rem; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; -} - -.status-owes { - color: #ef4444; - background: rgba(239, 68, 68, 0.12); - border: 1px solid rgba(239, 68, 68, 0.24); -} - -.status-written_off { - color: #f87171; - background: rgba(248, 113, 113, 0.12); - border: 1px solid rgba(248, 113, 113, 0.24); -} - -.status-collected { - color: #22c55e; - background: rgba(34, 197, 94, 0.12); - border: 1px solid rgba(34, 197, 94, 0.24); -} - -.inline-action-form { - margin: 0; -} - -.table-action-button { - appearance: none; - border: 1px solid rgba(255, 255, 255, 0.12); - background: rgba(255, 255, 255, 0.05); - color: var(--text-strong); - border-radius: 10px; - padding: 0.45rem 0.72rem; - font: inherit; - font-size: 0.88rem; - font-weight: 600; - line-height: 1; - cursor: pointer; - transition: - background 140ms ease, - border-color 140ms ease, - transform 140ms ease; -} - -.table-action-button:hover { - background: rgba(255, 166, 77, 0.12); - border-color: rgba(255, 166, 77, 0.28); -} - -.table-action-button:active { - transform: translateY(1px); -} - -.table-action-button:focus-visible { - outline: none; - box-shadow: 0 0 0 3px rgba(255, 166, 77, 0.18); - border-color: rgba(255, 166, 77, 0.35); -} - -.admin-tools-grid { - display: grid; - gap: 1rem; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); -} - -.debt-panel { - display: grid; - gap: 18px; -} - -.debt-summary { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 10px; - flex-wrap: wrap; -} - -.debt-summary span { - display: inline-flex; - min-height: 34px; - align-items: center; - border: 1px solid var(--line); - border-radius: 999px; - padding: 0 12px; - color: var(--text-muted); - font-size: 0.86rem; - font-weight: 700; -} - -.debt-action-grid { - position: relative; - z-index: 1; - display: grid; - gap: 18px; - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -.import-form input[type="file"] { - width: 100%; - margin-top: 0.5rem; - margin-bottom: 1rem; - color: var(--text-muted); +.section-marker__dot { + width: 8px; height: 8px; + border-radius: 50%; + flex: none; } +.section-marker__dot--accent { background: var(--accent); } +.section-marker__dot--rolled { background: var(--rolled); } +/* Debt / admin tools */ +.debt-panel { display: grid; gap: 18px; } +.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)); } +.import-form input[type="file"] { width: 100%; margin: 4px 0 8px; color: var(--muted); } .import-form input[type="file"]::file-selector-button { - appearance: none; - border: 1px solid rgba(255, 255, 255, 0.12); - background: rgba(255, 255, 255, 0.05); - color: var(--text-strong); - border-radius: 10px; - padding: 0.55rem 0.8rem; - font: inherit; - font-size: 0.9rem; - font-weight: 600; - margin-right: 0.75rem; - cursor: pointer; + appearance: none; + border: 1px solid var(--border-strong); + background: var(--field); + color: var(--text-2); + border-radius: var(--r-pill); + padding: 5px 11px; + font: 600 13px var(--font-ui); + margin-right: 8px; + cursor: pointer; } - .import-form input[type="file"]::file-selector-button:hover { - background: rgba(255, 166, 77, 0.12); - border-color: rgba(255, 166, 77, 0.28); + border-color: var(--accent-chip-bd); + color: var(--accent); } -.payout-summary-grid { - display: grid; - gap: 1rem; - grid-template-columns: repeat(3, minmax(0, 1fr)); +/* Admin: append ledger event */ +.append__body { padding: 20px 22px; } +.append__grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 14px; + margin-bottom: 16px; +} +.field { display: block; } +.field__label { + display: block; + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: .10em; + text-transform: uppercase; + color: var(--faintest); + margin-bottom: 7px; +} +.field__opt { text-transform: none; letter-spacing: 0; } +.control { + width: 100%; + padding: 10px 12px; + background: var(--field); + border: 1px solid var(--border-strong); + border-radius: 8px; + color: var(--text-strong); + font: 14px var(--font-ui); + outline: none; +} +select.control { cursor: pointer; } +.control--money { + display: flex; + align-items: center; + gap: 0; + padding: 0 12px; +} +.control__prefix { color: var(--faint); font-family: var(--font-mono); } +.control--money input { + width: 100%; + padding: 10px 6px; + background: transparent; + border: 0; + outline: none; + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 14px; +} +.control:focus, +.control--money:focus-within { + border-color: var(--accent-chip-bd); + box-shadow: 0 0 0 3px var(--accent-a22); +} +.append__note { margin-bottom: 18px; } +.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 { + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: .05em; + font-weight: 500; + padding: 3px 8px; + border-radius: 5px; + background: rgba(255,255,255,.03); + box-shadow: inset 0 0 0 1px var(--border-strong); } -.table-toolbar { - display: flex; - gap: 0.65rem; - margin-bottom: 1rem; +/* Admin: session controls */ +.panel--pad { padding: 20px 22px; } +.panel__title--mb { margin: 0 0 16px; } +.livecard { + padding: 15px 16px; + background: var(--surface-sunk); + border: 1px solid var(--border-strong); + border-radius: 11px; + margin-bottom: 14px; } - -.table-view-toggle.is-active { - background: rgba(255, 166, 77, 0.12); - border-color: rgba(255, 166, 77, 0.28); +.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; } +.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; } +.micro { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: .10em; + text-transform: uppercase; + color: var(--faintest); + margin-bottom: 4px; } - -.session-results-table.compact .detail-col { - display: none; +.mono16 { font-family: var(--font-mono); font-size: 16px; color: var(--text-strong); } +.mono16--warn { color: var(--warn); } +.btn-close { + display: block; + width: 100%; + text-align: center; + padding: 9px; + border-radius: 8px; + background: var(--accent-tint); + border: 1px solid var(--accent-chip-bd); + color: var(--accent); + font: 600 13px var(--font-ui); + cursor: pointer; } - -.admin-cash-grid { - display: grid; - gap: 1rem; - grid-template-columns: repeat(6, minmax(150px, 1fr)); - margin-bottom: 1rem; +.btn-close:hover { background: var(--accent-chip); } +.btn-newsession { + display: flex; + width: 100%; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px; + border-radius: 9px; + background: transparent; + border: 1px dashed var(--border-hi); + color: var(--text-2); + font: 600 14px var(--font-ui); + cursor: pointer; } - -@media (max-width: 1180px) { - .admin-cash-grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } +.btn-newsession:hover { border-color: var(--accent); color: var(--accent); } +.btn-newsession--primary { + background: var(--accent); + border-style: solid; + border-color: var(--accent); + color: var(--accent-ink); + box-shadow: 0 4px 16px var(--accent-a30); } +.btn-newsession--primary:hover { color: var(--accent-ink); opacity: .88; } -@media (max-width: 1180px) { - .profile-summary-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } +/* Admin: CSV tools */ +.csv__desc { margin: 0 0 16px; font-size: 12.5px; color: var(--faint); } +.csv__btns { display: flex; gap: 10px; margin-bottom: 12px; } +.csv-btn-ghost { + flex: 1; + text-align: center; + padding: 10px; + border-radius: 8px; + background: var(--field); + border: 1px solid var(--border-strong); + color: var(--text-2); + font: 600 13px var(--font-ui); + text-decoration: none; + cursor: pointer; } - -@media (max-width: 760px) { - .admin-cash-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .debt-action-grid { - grid-template-columns: 1fr; - } - - .debt-summary { - justify-content: flex-start; - } +.csv-btn-ghost:hover { border-color: var(--border-hi); } +.csv__drop { + display: block; + padding: 18px; + border: 1px dashed var(--border-strong); + border-radius: 9px; + text-align: center; + font-family: var(--font-mono); + font-size: 11.5px; + color: var(--faintest); + cursor: pointer; } +.csv__drop.is-drag { border-color: var(--accent); color: var(--accent); } -@media (max-width: 520px) { - .admin-cash-grid { - grid-template-columns: 1fr; - } -} - -@media (max-width: 1100px) { - .payout-summary-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} - -@media (max-width: 640px) { - .payout-summary-grid { - grid-template-columns: 1fr; - } -} - -@media (max-width: 980px) { - .site-shell { - width: min(100% - 24px, 100%); - } - - .topbar { - align-items: flex-start; - flex-direction: column; - border-radius: 14px; - } - - .two-col, - .three-col, - .four-col, - .leaderboard-layout, - .session-layout, - .admin-layout { - grid-template-columns: 1fr; - } - - .chart-panel-large .chart-frame { - min-height: 360px; - } -} - -@media (max-width: 640px) { - .site-shell { - padding-top: 16px; - } - - .topbar, - .hero-card, - .panel, - .chart-modal-card { - border-radius: 14px; - } - - .topbar, - .hero-card, - .panel, - .chart-modal-card { - padding-left: 16px; - padding-right: 16px; - } - - h1 { - font-size: 1.95rem; - } - - .nav-links { - width: 100%; - } - - .nav-links a, - .ghost-button, - .secondary-button, - .primary-button { - min-height: 40px; - } - - th, - td { - padding: 12px; - } - - .chart-modal-card { - width: min(100vw - 20px, 100%); - margin: 10px auto; - max-height: calc(100vh - 20px); - } - - .chart-modal-frame { - height: min(66vh, 680px); - } -} +/* Form shell (admin login) */ +.form-shell { display: grid; justify-content: center; padding-top: 40px; } +.form-shell.narrow { grid-template-columns: minmax(320px, 480px); } +/* ================================================================ + RESPONSIVE + ================================================================ */ @media (max-width: 860px) { - .leaderboard-header, - .leaderboard-controls { - align-items: start; - justify-items: start; - } + .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; } + .admin-bottom-grid { grid-template-columns: 1fr; } +} - .session-filter-form { - width: 100%; - } +@media (max-width: 1180px) { + .player-stat-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } +} - .session-filter-select { - width: 100%; - } +@media (max-width: 640px) { + h1 { font-size: 28px; } + .hero { padding: 18px 0 14px; } + .topbar__season { display: none; } + .cash-strip { grid-template-columns: 1fr 1fr; } + .debt-action-grid { grid-template-columns: 1fr; } + .snapshot-grid { grid-template-columns: 1fr; } + .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; + } + .event-card--inline .tiny-text { + grid-column: 1 / -1; + justify-self: end; + } + .leaderboard-hero-stats { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + width: 100%; + } + .leaderboard-hero-stats .stat, + .leaderboard-hero-stats .stat:last-child { + min-width: 0; + } +} + +@media (max-width: 440px) { + .cash-strip { grid-template-columns: 1fr; } } diff --git a/static/css/tokens.css b/static/css/tokens.css new file mode 100644 index 0000000..00e4681 --- /dev/null +++ b/static/css/tokens.css @@ -0,0 +1,84 @@ +:root { + /* ---- surfaces (darkest → lightest) ---- */ + --bg: #0f0f12; + --surface: #16161c; + --surface-raised:#1a1a20; + --surface-head: #1c1c24; + --surface-sunk: #131318; + --field: #1f1f27; + --field-alt: #20202a; + + /* ---- borders / dividers ---- */ + --border: #26262f; + --border-strong: #2e2e38; + --border-hi: #34343f; + --divider: #1d1d25; + --slash: #3a3a45; + + /* ---- text ---- */ + --text: #f3f1f8; + --text-strong: #ecebf1; + --text-body: #e7e5ec; + --text-2: #bcbac6; + --num: #b0aeba; + --muted: #8f8d99; + --muted-2: #84828e; + --faint: #7d7b87; + --faint-2: #757380; + --faintest: #66646f; + --faintest-2: #57555f; + + /* ---- accent (violet) ---- */ + --accent: #9b8cf0; + --accent-ink: #12101e; + --accent-tint: #1f1d2e; + --accent-chip: #242137; + --accent-chip-bd:#383152; + --accent-a22: rgba(155,140,240,0.22); + --accent-a30: rgba(155,140,240,0.30); + + /* ---- semantic: poker value scale ---- */ + --pos: #6fc093; + --pos-bar: #41815a; + --pos-tint: rgba(111,192,147,0.12); + --pos-tint-bd: rgba(111,192,147,0.30); + --neg: #e0758a; + --neg-bar: #9e445a; + --neg-tint: rgba(224,117,138,0.13); + --neg-tint-bd: rgba(224,117,138,0.32); + --owes: #e8829a; + --owes-tint: rgba(232,130,154,0.15); + --owes-tint-bd: rgba(232,130,154,0.40); + + /* ---- semantic: cash-movement hues ---- */ + --warn: #e0b15c; + --warn-tint: rgba(224,177,92,0.13); + --warn-tint-bd: rgba(224,177,92,0.32); + --rolled: #8f93c2; + + /* ---- medals ---- */ + --rank-1: var(--accent); + --rank-2: #c2bcd0; + --rank-3: #a78bb0; + + /* ---- chart line palette (top-N order) ---- */ + --line-1: #9b8cf0; --line-2: #6fc093; --line-3: #e0b15c; + --line-4: #cf6f86; --line-5: #8f93c2; + + /* ---- spacing scale (4pt) ---- */ + --s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:20px; + --s6:24px; --s8:32px; --s10:40px; --s12:48px; + + /* ---- radii ---- */ + --r-pill:6px; --r-sm:9px; --r-md:11px; --r-lg:14px; + + /* ---- containers ---- */ + --w-public:1320px; + --w-session:1440px; + --w-admin:1580px; + --gutter: clamp(16px, 4vw, 40px); + + /* ---- Chart.js backward compat (read as raw strings by JS) ---- */ + --text-muted: #bcbac6; + --line: #26262f; +} diff --git a/stats.py b/stats.py index 8ae8c63..8514c0f 100644 --- a/stats.py +++ b/stats.py @@ -6,23 +6,27 @@ from dataclasses import dataclass, field from datetime import datetime from typing import Any +from markupsafe import Markup + from storage import EventRow PLAYER_PALETTE = [ + "#9b8cf0", # --line-1 violet + "#6fc093", # --line-2 green + "#e0b15c", # --line-3 gold + "#cf6f86", # --line-4 pink + "#8f93c2", # --line-5 lavender + "#7cb9e0", "#f4a261", - "#2a9d8f", - "#8d99ae", - "#e76f51", - "#7c6cf2", + "#a78bb0", + "#5cb8a0", + "#e58f3a", "#84cc16", - "#f59e0b", - "#10b981", - "#ef4444", "#06b6d4", - "#a855f7", - "#eab308", ] +BREAK_EVEN_TOLERANCE_CENTS = 100 + @dataclass class SessionEntry: @@ -41,76 +45,113 @@ class SessionEntry: @property def invested_cents(self) -> int: + # Poker performance: everything that entered play, including house fronts + # and prior unpaid winnings rolled into this session. 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 payout_due_cents(self) -> int: - return max(self.cash_out_cents - self.front_cents, 0) - - @property - def payout_remaining_cents(self) -> int: - return max(self.payout_due_cents - self.paid_cents - self.rollover_out_cents, 0) - @property def gross_payout_cents(self) -> int: + # Settlement: cashout is a gross chip count/result, not proof that cash + # was paid. Fronts are recovered from the cashout before the player has + # a claim against the house. return max(self.cash_out_cents - self.front_cents, 0) @property - def settled_cents(self) -> int: - return self.paid_cents + self.rollover_out_cents - - @property - def current_due_cents(self) -> int: - return max(self.gross_payout_cents - self.settled_cents, 0) - - @property - def player_owes_cents(self) -> int: - return max(self.raw_player_owes_cents - self.front_resolved_cents, 0) - - @property - def raw_player_owes_cents(self) -> int: - return self.front_shortfall_cents + self.overpaid_front_cents - - @property - def front_shortfall_cents(self) -> int: + def player_owes_gross_cents(self) -> int: return max(self.front_cents - self.cash_out_cents, 0) + @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 + + @property + def current_due_to_player_cents(self) -> int: + return max(self.gross_payout_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 + + @property + def current_due_to_house_cents(self) -> int: + return max(self.player_owes_gross_cents - self.settled_to_house_cents, 0) + + @property + def real_cash_in_cents(self) -> int: + return self.buy_in_cents + self.front_collected_cents + + @property + def real_cash_out_cents(self) -> int: + return self.paid_cents + + @property + def payout_due_cents(self) -> int: + return self.gross_payout_cents + + @property + def payout_remaining_cents(self) -> int: + return self.current_due_to_player_cents + + @property + def settled_cents(self) -> int: + return self.settled_to_player_cents + + @property + def current_due_cents(self) -> int: + return self.current_due_to_player_cents + + @property + def player_owes_cents(self) -> int: + return self.current_due_to_house_cents + + @property + def raw_player_owes_cents(self) -> int: + return self.player_owes_gross_cents + + @property + def front_shortfall_cents(self) -> int: + return self.player_owes_gross_cents + @property def overpaid_front_cents(self) -> int: - return max(self.settled_cents - self.gross_payout_cents, 0) + return 0 @property def front_writeoff_applied_cents(self) -> int: return min( self.front_writeoff_cents, - max(self.raw_player_owes_cents - self.front_collected_applied_cents, 0), + max(self.player_owes_gross_cents - self.front_collected_applied_cents, 0), ) @property def front_collected_applied_cents(self) -> int: - return min(self.front_collected_cents, self.raw_player_owes_cents) + return min(self.front_collected_cents, self.player_owes_gross_cents) @property def front_resolved_cents(self) -> int: - return self.front_collected_applied_cents + self.front_writeoff_applied_cents + return min(self.settled_to_house_cents, self.player_owes_gross_cents) @property def payout_status(self) -> str: - if self.player_owes_cents > 0: + if self.current_due_to_house_cents > 0: return "owes" - if self.front_writeoff_applied_cents > 0: + if self.front_writeoff_cents > 0: return "written_off" - if self.front_collected_applied_cents > 0: + if self.front_collected_cents > 0: return "collected" if self.gross_payout_cents <= 0: return "none" - if self.current_due_cents <= 0: + if self.current_due_to_player_cents <= 0: return "paid" - if self.settled_cents <= 0: + if self.settled_to_player_cents <= 0: return "unpaid" return "partial" @@ -149,11 +190,11 @@ class SessionSummary: @property def total_payout_due_cents(self) -> int: - return sum(entry.payout_due_cents for entry in self.entries) + return self.total_gross_payout_cents @property def total_remaining_cents(self) -> int: - return sum(entry.payout_remaining_cents for entry in self.entries) + return self.total_current_due_to_player_cents @property def total_net_cents(self) -> int: @@ -172,32 +213,63 @@ class SessionSummary: return sum(entry.rollover_out_cents for entry in self.entries) @property - def total_current_due_cents(self) -> int: - return sum(entry.current_due_cents for entry in self.entries) + def total_settled_to_player_cents(self) -> int: + return sum(entry.settled_to_player_cents for entry in self.entries) @property - def total_player_owes_cents(self) -> int: - return sum(entry.player_owes_cents for entry in self.entries) + def total_current_due_to_player_cents(self) -> int: + return sum(entry.current_due_to_player_cents for entry in self.entries) + + @property + def total_player_owes_gross_cents(self) -> int: + return sum(entry.player_owes_gross_cents for entry in self.entries) + + @property + def total_settled_to_house_cents(self) -> int: + return sum(entry.settled_to_house_cents for entry in self.entries) + + @property + def total_current_due_to_house_cents(self) -> int: + return sum(entry.current_due_to_house_cents for entry in self.entries) @property def total_front_writeoff_cents(self) -> int: - return sum(entry.front_writeoff_applied_cents for entry in self.entries) + return sum(entry.front_writeoff_cents for entry in self.entries) @property def total_front_collected_cents(self) -> int: - return sum(entry.front_collected_applied_cents for entry in self.entries) + return sum(entry.front_collected_cents for entry in self.entries) @property def total_cash_in_cents(self) -> int: - return sum(entry.buy_in_cents for entry in self.entries) + return sum(entry.real_cash_in_cents for entry in self.entries) @property def total_paid_out_cents(self) -> int: - return sum(entry.paid_cents for entry in self.entries) + return sum(entry.real_cash_out_cents for entry in self.entries) + + @property + def total_current_due_cents(self) -> int: + return self.total_current_due_to_player_cents + + @property + def total_player_owes_cents(self) -> int: + return self.total_current_due_to_house_cents @property def total_open_balance_cents(self) -> int: - return self.total_current_due_cents - self.total_player_owes_cents + return self.total_current_due_to_player_cents - self.total_current_due_to_house_cents + + @property + def total_net_book_position_cents(self) -> int: + # Banker view: cash currently held, plus collectible receivables, minus + # unpaid player claims. Rollover-outs reduce payables but are not cash. + return ( + self.total_cash_in_cents + - self.total_paid_out_cents + + self.total_current_due_to_house_cents + - self.total_current_due_to_player_cents + ) @dataclass @@ -251,9 +323,9 @@ def apply_rank_changes( return current_board -def cents_to_dollars(cents: int) -> str: +def cents_to_dollars(cents: int) -> Markup: value = cents / 100 - return f"${value:,.2f}" + return Markup(f'${value:,.2f}') def session_sort_key(session: SessionSummary) -> tuple[str, int, str, str]: @@ -314,12 +386,22 @@ def color_for_name(name: str, names: list[str]) -> str: return PLAYER_PALETTE[index % len(PLAYER_PALETTE)] +def net_result_bucket(value_cents: int) -> str: + if value_cents > BREAK_EVEN_TOLERANCE_CENTS: + return "win" + if value_cents < -BREAK_EVEN_TOLERANCE_CENTS: + return "loss" + return "even" + + def net_tone(value_cents: int) -> str: - if value_cents > 0: - return "#22c55e" - if value_cents < 0: - return "#ef4444" - return "#f59e0b" + bucket = net_result_bucket(value_cents) + + if bucket == "win": + return "#6fc093" # --pos + if bucket == "loss": + return "#e0758a" # --neg + return "#84828e" # --muted-2 def build_session_summaries(events: list[EventRow]) -> list[SessionSummary]: @@ -426,6 +508,7 @@ def summarize_player_runs(entries: list[SessionEntry]) -> dict[str, int | str | for entry in ordered_entries: net = entry.net_cents + bucket = net_result_bucket(net) if best_entry is None or net > best_entry.net_cents: best_entry = entry @@ -433,10 +516,10 @@ def summarize_player_runs(entries: list[SessionEntry]) -> dict[str, int | str | if worst_entry is None or net < worst_entry.net_cents: worst_entry = entry - if net > 0: + if bucket == "win": current_run_wins += 1 current_run_losses = 0 - elif net < 0: + elif bucket == "loss": current_run_losses += 1 current_run_wins = 0 else: @@ -450,13 +533,13 @@ def summarize_player_runs(entries: list[SessionEntry]) -> dict[str, int | str | current_loss_streak = 0 for entry in reversed(ordered_entries): - net = entry.net_cents + bucket = net_result_bucket(entry.net_cents) - if net > 0: + if bucket == "win": if current_loss_streak > 0: break current_win_streak += 1 - elif net < 0: + elif bucket == "loss": if current_win_streak > 0: break current_loss_streak += 1 @@ -485,8 +568,8 @@ def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]: for player_name, entries in player_entries.items(): nets = [entry.net_cents for entry in entries] run_summary = summarize_player_runs(entries) - wins = [value for value in nets if value > 0] - losses = [value for value in nets if value < 0] + wins = [value for value in nets if net_result_bucket(value) == "win"] + losses = [value for value in nets if net_result_bucket(value) == "loss"] sessions_played = len(entries) winning_sessions = len(wins) @@ -502,12 +585,8 @@ 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_applied_cents for entry in entries - ) - total_front_writeoff = sum( - entry.front_writeoff_applied_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) 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_invested = sum(entry.invested_cents for entry in entries) diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html index f4decf8..f88db58 100644 --- a/templates/admin_dashboard.html +++ b/templates/admin_dashboard.html @@ -1,342 +1,328 @@ {% extends "base.html" %} -{% block title %}Admin - Poker Portal{% endblock %} +{% block title %}Admin · boker.club{% endblock %} +{% block page_class %}page--admin{% endblock %} {% block content %} -
-
-

Admin

-

Ledger controls

-

Append new buy-ins, cash-outs, corrections, and note-only events to the audit trail.

+ +
+
+

Admin

+ {% if open_sessions %} + {{ open_sessions|length }} session{{ 's' if open_sessions|length != 1 else '' }} live + {% endif %}
-
+

Ledger controls

+

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

+ -
-
-

New event

-

Add to the ledger

+ +
+
+ Cash in +
{{ admin_totals.cash_in_cents | money }}
+
+
+ Cash out +
{{ admin_totals.cash_out_cents | money }}
+
+
+ House owes players +
{{ admin_totals.house_owes_players_cents | money }}
+
+
+ Players owe house +
{{ admin_totals.players_owe_house_cents | money }}
+
+
+ Net book position +
+ {{ admin_totals.net_book_position_cents | money }} +
+
cash in - cash out + receivables - payables
+
+
- + +
- + +
- - - - - - - - - -
-
-

Session state

-

Open a new session

- - - -
- + +
+

Append ledger event

+ fast entry +
+
+
+ + + + +
+ +
-
-
-
-

Tools

-

Audit CSV

-
-
+
-
-
-

- Export the current audit trail or import a replacement CSV. Import creates a timestamped backup first. -

- -
+ +
-
- - -
- -
+
+

Session controls

+ + {% if live_session %} +
+
+ + {{ session_label(live_session) }} + {{ live_session_no }} +
+
+
In play
{{ live_session.total_invested_cents | money }}
+
Seated
{{ live_session.entries|length }}
+
Open
{{ live_unresolved_count }}
+
+ + +
-
+ {% endif %} -
- +
+ + +
+ -
-
+
+

Import / export

+

Back up or migrate the raw event ledger.

+
+
+ ↓ Export CSV + +
+ + +
+
+ +
+ + + +
+
+
+
+ Recent sessions +

Current totals

+
+
+
+ + + + + + + + + + + + + + + + {% for s in recent_sessions %} + + + + + + + + + + + + {% else %} + + {% endfor %} + +
SessionPlayersCash inCash outHouse owesPlayers owe houseBook positionStatusAction
{{ session_label(s) }}{{ s.entries|length }}{{ s.total_cash_in_cents | money }}{{ s.total_paid_out_cents | money }}{{ s.total_current_due_to_player_cents | money }}{{ s.total_current_due_to_house_cents | money }}{{ s.total_net_book_position_cents | money }}{{ s.status }} +
+ + {% if s.status == "open" %} + + {% else %} + + {% endif %} +
+
No sessions yet.
+
+
+ +
+
+
+ Recent events +

Ledger feed

+
+ append-only +
+
+ {% for event in recent_events %} +
+
+ {{ event.player_name or "Session" }} + {{ event.event_type | replace('_', ' ') }} + {% if event.note %}{{ event.note }}{% endif %} +
+

{{ event.amount_cents | money }}

+

{{ event.session_date | pretty_date }}

+
+ {% else %} +

No recent events.

+ {% endfor %} +
+
+
+ + + + +{% if debt_entries %} +
+
-

Front debt

-

Debt resolution

+ Front receivables +

Debt resolution

- Open {{ admin_totals.players_owe_cents | money }} - Collected {{ admin_totals.collected_cents | money }} - Written off {{ admin_totals.written_off_cents | money }} + Open {{ admin_totals.players_owe_house_cents | money }} + Repaid {{ admin_totals.collected_cents | money }} + Written off {{ admin_totals.written_off_cents | money }}
- - {% if debt_entries %} -
-
+
+

Collect debt

- - - - -
- -
+ -
+

Write off debt

- - - - -
- -
+
- {% else %} -

No outstanding front debt.

- {% endif %} -
+ +{% endif %} -
-
- Cash in - {{ admin_totals.cash_in_cents | money }} -
- -
- Paid out - {{ admin_totals.paid_out_cents | money }} -
- -
- Still owed - - {{ admin_totals.still_owed_cents | money }} - -
- -
- Players owe - - {{ admin_totals.players_owe_cents | money }} - -
- -
- Written off - - {{ admin_totals.written_off_cents | money }} - -
- -
- Open balance - - {{ admin_totals.open_balance_cents | money }} - -
-
- -
-
-
-

Recent sessions

-

Current totals

-
-
-
- - - - - - - - - - - - - - - - - - {% for session in recent_sessions %} - - - - - - - - - - - - - - {% else %} - - - - {% endfor %} - -
SessionPlayersCash inPaid outStill owedPlayers oweCollectedWritten offOpen balanceStatusAction
- - {{ session_label(session) }} - - {{ session.entries|length }}{{ session.total_cash_in_cents | money }}{{ session.total_paid_out_cents | money }} - {{ session.total_current_due_cents | money }} - - {{ session.total_player_owes_cents | money }} - - {{ session.total_front_collected_cents | money }} - - {{ session.total_front_writeoff_cents | money }} - - {{ session.total_open_balance_cents | money }} - - - {{ session.status }} - - -
- - {% if session.status == "open" %} - - {% else %} - - {% endif %} -
-
No sessions yet.
-
-
- -
-
-
-

Recent raw events

-

Ledger feed

-
-
-
- {% for event in recent_events %} -
-
- {{ event.player_name }} - {{ event.event_type }} -
-

{{ event.amount_cents | money }}

-

{{ event.session_date | pretty_date }}

- {% if event.note %}

{{ event.note }}

{% endif %} -
- {% else %} -

No recent events.

- {% endfor %} -
-
{% endblock %} diff --git a/templates/admin_login.html b/templates/admin_login.html index 4479d22..59ba01f 100644 --- a/templates/admin_login.html +++ b/templates/admin_login.html @@ -1,23 +1,24 @@ {% extends "base.html" %} -{% block title %}Admin Login · Poker Portal{% endblock %} +{% block title %}Admin Login · boker.club{% endblock %} {% block content %} -
-
-

Restricted access

-

Admin login

-

Only the ledger manager needs credentials. All stats pages stay public.

+
+ +
+

Restricted access

+

Admin login

+

Only boker.club manager needs credentials. All stats pages stay public.

+
- - -
+ + {% endblock %} diff --git a/templates/base.html b/templates/base.html index fc38395..dbe08e0 100644 --- a/templates/base.html +++ b/templates/base.html @@ -3,47 +3,50 @@ - {% block title %}Poker Portal{% endblock %} - - - + {% block title %}boker.club{% endblock %} + -
-
-
- Poker Portal -

Low-stakes hold'em ledger and session tracker

-
-
+ +
{% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} -
+
{% for category, message in messages %}
{{ message }}
{% endfor %} -
+ {% endif %} {% endwith %} -
- {% block content %}{% endblock %} -
- + {% block content %}{% endblock %} +
diff --git a/templates/leaderboard.html b/templates/leaderboard.html index 1bf7cc9..d7951b2 100644 --- a/templates/leaderboard.html +++ b/templates/leaderboard.html @@ -1,596 +1,348 @@ {% extends "base.html" %} -{% block title %}Leaderboard · Poker Portal{% endblock %} +{% block title %}Leaderboard · boker.club{% endblock %} {% block content %} -
-
-

All-time results

-

Leaderboard

-

Track who is up, who is chasing, and how everyone has moved across {{ session_count }} recorded sessions{% if selected_session_date %} through {{ selected_session_label }}{% endif %}.

-
-
-
-
-
-
-

Trend

-

Cumulative profit over time

-
-
- - -
+
+
+

All-time results

+

Leaderboard

+

{{ session_count }} session{{ 's' if session_count != 1 else '' }}{% if selected_session_date %} through {{ selected_session_label }}{% endif %}

+
+
+
+ Cash paid out +
{{ cash_paid_out_cents | money }}
-
+
+ Sessions +
{{ session_count }}{% if total_session_count != session_count %} / {{ total_session_count }}{% endif %}
+
+
+ Players +
{{ all_count }}
+
+
+
+ +
+
+
+ Trend +

Cumulative profit

+
+ +
+
+
+
-
-

Quick read

-

Table snapshot

-
-
Players tracked{{ leaderboard|length }}
-
Sessions tracked{{ session_count }}{% if total_session_count != session_count %} / {{ total_session_count }}{% endif %}
-
Through session{{ selected_session_label }}
-
Best player{% if leaderboard %}{{ leaderboard[0].player_name }}{% else %}—{% endif %}
-
Worst player{% if leaderboard %}{{ leaderboard[-1].player_name }}{% else %}—{% endif %}
+
+
+
+ + +
+

+ {% if mode == 'eligible' %} + {{ eligible_min_sessions }}+ sessions required. Keeps one-off guests from skewing standings. + {% elif mode == 'all' %} + Everyone who's sat down — rankings can swing on tiny samples. + {% else %} + Form guide — aggregated over the last 5 sessions only. + {% endif %} +

-
- -
-
-
-

Standings

-

All-time leaderboard

-

Cash in / paid out reflect real money movement. Net / ROI reflect poker results.

-
-
-
- - - {% if selected_session_date %} - Reset - {% endif %} -
-

Click any column to sort.

-
-
- -
- - - - - - - - - - - - - - - - - - {% for player in leaderboard %} - - - - - - - - - - - - - - {% else %} - - - +
+
+ +
-
#{{ loop.index }} - {% if player.rank_change > 0 %} - ↑ {{ player.rank_change }} - {% elif player.rank_change < 0 %} - ↓ {{ player.rank_change | abs }} - {% else %} - - - {% endif %} - {{ player.player_name }}{{ player.total_buy_in_cents | money }}{{ player.total_paid_cents | money }}{{ player.total_net_cents | money }}{{ "%.1f"|format(player.win_pct) }}%{{ "%.1f"|format(player.roi_pct) }}%{{ player.avg_win_cents | money }}{{ player.avg_loss_cents | money }}{{ player.sessions_played }}
No data yet. Add the first event from the admin page.
+ + {% if selected_session_id %} + Reset + {% endif %} +
-
+ - -
-
-
-
-

Breakdown

-

Net by player

+ +
+
+
+ Breakdown +

Net by player

+
+
+
-
-
- -
-
-
-
-

Quick read

-

Session snapshot

-
+
+
+ Quick read +

Session snapshot

- {% set winners = session.entries | selectattr('net_cents', 'gt', 0) | list %} - {% set losers = session.entries | selectattr('net_cents', 'lt', 0) | list %} - -
-
- Players - {{ session.entries|length }} + {% set losers = session.entries | selectattr('net_cents', 'lt', 0) | list %} +
+
+ Biggest winner +
+ {% if winners %}{{ (winners | sort(attribute='net_cents', reverse=True) | first).net_cents | money }}{% else %}—{% endif %} +
+ {% if winners %}
{{ winners | sort(attribute='net_cents', reverse=True) | first | attr('player_name') }}
{% endif %}
-
- Biggest winner - - {% if winners %} - {{ winners | sort(attribute='net_cents', reverse=True) | first | attr('player_name') }} - {% else %} - — - {% endif %} - +
+ Biggest loser +
+ {% if losers %}{{ (losers | sort(attribute='net_cents') | first).net_cents | money }}{% else %}—{% endif %} +
+ {% if losers %}
{{ losers | sort(attribute='net_cents') | first | attr('player_name') }}
{% endif %}
-
- Best result - - {% if winners %} - {{ (winners | sort(attribute='net_cents', reverse=True) | first).net_cents | money }} - {% else %} - $0.00 - {% endif %} - +
+ Total cash-out +
{{ session.total_cash_out_cents | money }}
-
- Biggest loser - - {% if losers %} - {{ losers | sort(attribute='net_cents') | first | attr('player_name') }} - {% else %} - — - {% endif %} - -
-
- Worst result - - {% if losers %} - {{ (losers | sort(attribute='net_cents') | first).net_cents | money }} - {% else %} - $0.00 - {% endif %} - -
-
- Unpaid total - - {{ session.total_remaining_cents | money }} - +
+ Open items +
+ {{ (session.total_current_due_to_player_cents + session.total_current_due_to_house_cents) | money }} +
-
+ -
-
+ +
+
-

Audit trail

-

Raw ledger events

+ Audit trail +

Raw ledger events

+ append-only
-
+
{% for event in raw_events | reverse %} -
+
{{ event.player_name or "Session" }} - {{ event.event_type }} + {{ event.event_type | replace('_', ' ') }} + {% if event.note %}{{ event.note }}{% endif %}

{{ event.amount_cents | money }}

- {% if event.note %} -

{{ event.note }}

- {% endif %} -

{{ event.created_at }} · by {{ event.actor }}

-
+

{{ event.created_at }}

+
{% else %} -

No raw events recorded.

+

No events recorded.

{% endfor %}
-
+ +const breakdownCtx = document.getElementById('sessionBreakdownChart'); +if (breakdownCtx) { + const values = sessionChartData.net_values || []; + const labels = sessionChartData.labels || []; + const colors = sessionChartData.net_colors || []; + const maxAbs = Math.max(...values.map(value => Math.abs(value)), 1); + const moneyFormatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }); + const escapeHtml = value => String(value).replace(/[&<>"']/g, char => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }[char])); - {% endblock %} diff --git a/templates/sessions.html b/templates/sessions.html index 9587dda..1e06957 100644 --- a/templates/sessions.html +++ b/templates/sessions.html @@ -1,52 +1,54 @@ {% extends "base.html" %} -{% block title %}Sessions · Poker Portal{% endblock %} +{% block title %}Sessions · boker.club{% endblock %} {% block content %} -
-
-

Session history

-

Sessions

-

Browse every recorded game night, inspect the table totals, and drill into each player result.

-
-
-
-
+
+

Session history

+

Sessions

+

Browse every recorded game night and inspect table totals.

+
+ +
+
-

Archive

-

All sessions by date

+ Archive +

All sessions

-
- +
+
- + - - - + + + - {% for session in sessions %} - - - - - - - + {% for s in sessions %} + + + + + + + {% else %} - + {% endfor %}
DateDate Status PlayersTotal Buy-insTotal Cash-outsTable NetInvestedCash outTable net
{{ session_label(session) }} - {{ session.status }} - {{ session.entries|length }}{{ session.total_buy_in_cents | money }}{{ session.total_cash_out_cents | money }}{{ session.total_net_cents | money }}
+ {{ session_label(s) }} + {{ s.status }}{{ s.entries|length }}{{ s.total_invested_cents | money }}{{ s.total_cash_out_cents | money }} + {{ s.total_net_cents | money }} +
No sessions yet.No sessions yet.
-
+ + {% endblock %} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/__pycache__/__init__.cpython-313.pyc b/tests/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0fca85c57eb5bdc9bce3105496c5aa11e7532a99 GIT binary patch literal 141 zcmey&%ge<81YaLoWHAEi#~=<2fCNC`GYgQI%8<^W$>_I|p@<121QNfcqo0wVo2s9d zlbD;SA5fH^m6}{qte=#hom!+{l3EO8$H!;pWtPOp>lIYq;;_lhPbtkwwJTx;Y6clz P3}Sp_W@Kb6Vg|ARfZ!n4 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_accounting.cpython-313.pyc b/tests/__pycache__/test_accounting.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5c020f2ea8c6948b2abf9a0c386547d86630bbb6 GIT binary patch literal 8233 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 int: + return int(round(amount * 100)) + + +def event( + session_id: str, + session_date: str, + player_name: str, + event_type: str, + amount: float, + index: int, +): + return { + "id": f"{session_id}-{index}", + "created_at": f"2026-01-01T00:00:{index:02d}+00:00", + "session_id": session_id, + "session_date": session_date, + "player_name": player_name, + "event_type": event_type, + "amount_cents": cents(amount), + "note": "", + "actor": "test", + } + + +class AccountingTests(unittest.TestCase): + def entry_for(self, rows, session_id="s1", player_name="A"): + sessions = build_session_summaries(rows) + session = next(s for s in sessions if s.session_id == session_id) + entry = next(e for e in session.entries if e.player_name == player_name) + return session, entry + + def test_buyin_cashout_and_paid_are_distinct(self): + session, entry = self.entry_for( + [ + event("s1", "2026-01-01", "A", "buyin", 10, 1), + event("s1", "2026-01-01", "A", "cashout", 20, 2), + event("s1", "2026-01-01", "A", "paid", 20, 3), + ] + ) + + self.assertEqual(entry.invested_cents, cents(10)) + self.assertEqual(entry.net_cents, cents(10)) + self.assertEqual(session.total_cash_in_cents, cents(10)) + self.assertEqual(session.total_paid_out_cents, cents(20)) + self.assertEqual(entry.current_due_to_player_cents, 0) + self.assertEqual(entry.current_due_to_house_cents, 0) + + def test_front_is_invested_but_not_cash_in_until_collected(self): + session, entry = self.entry_for( + [ + event("s1", "2026-01-01", "A", "front", 5, 1), + event("s1", "2026-01-01", "A", "cashout", 8, 2), + event("s1", "2026-01-01", "A", "paid", 3, 3), + ] + ) + + self.assertEqual(entry.invested_cents, cents(5)) + self.assertEqual(entry.net_cents, cents(3)) + self.assertEqual(entry.gross_payout_cents, cents(3)) + self.assertEqual(entry.current_due_to_player_cents, 0) + self.assertEqual(session.total_cash_in_cents, 0) + self.assertEqual(session.total_paid_out_cents, cents(3)) + + def test_rollover_out_reduces_source_session_payable_without_cash_out(self): + session, entry = self.entry_for( + [ + event("s1", "2026-01-01", "A", "buyin", 10, 1), + event("s1", "2026-01-01", "A", "cashout", 19.50, 2), + event("s1", "2026-01-01", "A", "rollover_out", 10, 3), + ] + ) + + self.assertEqual(entry.net_cents, cents(9.50)) + self.assertEqual(entry.gross_payout_cents, cents(19.50)) + self.assertEqual(entry.current_due_to_player_cents, cents(9.50)) + self.assertEqual(session.total_paid_out_cents, 0) + + def test_rollover_in_is_invested_without_new_cash_in(self): + rows = [ + event("s1", "2026-01-01", "A", "buyin", 10, 1), + event("s1", "2026-01-01", "A", "cashout", 19.50, 2), + event("s1", "2026-01-01", "A", "rollover_out", 10, 3), + event("s2", "2026-01-02", "A", "rollover_in", 10, 4), + ] + sessions = build_session_summaries(rows) + source = next(s for s in sessions if s.session_id == "s1") + dest = next(s for s in sessions if s.session_id == "s2") + dest_entry = dest.entries[0] + + self.assertEqual(sum(s.total_cash_in_cents for s in sessions), cents(10)) + self.assertEqual(source.total_current_due_to_player_cents, cents(9.50)) + self.assertEqual(dest_entry.invested_cents, cents(10)) + self.assertEqual(dest.total_cash_in_cents, 0) + + def test_debt_repayment_and_writeoff_resolve_front_without_inflating_cash(self): + session, entry = self.entry_for( + [ + event("s1", "2026-01-01", "A", "front", 10, 1), + event("s1", "2026-01-01", "A", "cashout", 0, 2), + event("s1", "2026-01-01", "A", "front_collected", 6, 3), + event("s1", "2026-01-01", "A", "front_writeoff", 4, 4), + ] + ) + + self.assertEqual(entry.player_owes_gross_cents, cents(10)) + self.assertEqual(entry.current_due_to_house_cents, 0) + self.assertEqual(session.total_cash_in_cents, cents(6)) + self.assertEqual(session.total_front_writeoff_cents, cents(4)) + + +if __name__ == "__main__": + unittest.main()