From 3fda2ae64fd2dda7db7d946fa8865e108d75c092 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sun, 15 Mar 2026 14:23:17 -0700 Subject: [PATCH 01/39] initial commit --- .env.example | 3 + .gitignore | 4 + README.md | 86 +++++ app.py | 207 +++++++++++ requirements.txt | 1 + static/css/style.css | 609 +++++++++++++++++++++++++++++++++ stats.py | 285 +++++++++++++++ storage.py | 102 ++++++ templates/admin_dashboard.html | 120 +++++++ templates/admin_login.html | 23 ++ templates/base.html | 49 +++ templates/leaderboard.html | 335 ++++++++++++++++++ templates/player_detail.html | 141 ++++++++ templates/session_detail.html | 82 +++++ templates/sessions.html | 48 +++ 15 files changed, 2095 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.py create mode 100644 requirements.txt create mode 100644 static/css/style.css create mode 100644 stats.py create mode 100644 storage.py create mode 100644 templates/admin_dashboard.html create mode 100644 templates/admin_login.html create mode 100644 templates/base.html create mode 100644 templates/leaderboard.html create mode 100644 templates/player_detail.html create mode 100644 templates/session_detail.html create mode 100644 templates/sessions.html diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d072de1 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +SECRET_KEY=replace-this-with-a-long-random-string +ADMIN_USERNAME=admin +ADMIN_PASSWORD=replace-this-password diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc0553c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +/__pycache__/* +/.venv/* +/data/* diff --git a/README.md b/README.md new file mode 100644 index 0000000..a8b29e4 --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# Poker Portal + +A small server-rendered poker ledger app for low-stakes no-limit hold'em nights. + +## Stack + +- Python +- Flask +- Jinja templates +- Chart.js +- Custom CSS +- Append-only CSV event ledger + +## What this MVP does + +- Public all-time leaderboard +- Public session archive +- Public per-player stat pages +- Admin-only login for adding ledger events +- Append-only `entries.csv` audit trail +- Buy-ins, cash-outs, note-only events, and correction events using negative amounts +- Cumulative profit chart and player charts + +## Ledger model + +The app treats the CSV as an append-only audit trail. + +Each row is one event: + +- `buyin` +- `cashout` +- `note` + +This means you do not edit old rows when something changes. Instead, you append: + +- another buy-in row for a rebuy +- another cash-out row if they cash more later +- a negative amount to correct a mistaken buy-in or cash-out +- a `note` row for bookkeeping context + +## CSV format + +`data/entries.csv` + +```csv +id,created_at,session_date,player_name,event_type,amount_cents,note,actor +``` + +## Run locally + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env +export $(grep -v '^#' .env | xargs) +python app.py +``` + +Open: + +- public site: `http://127.0.0.1:5000/leaderboard` +- admin login: `http://127.0.0.1:5000/admin/login` + +## Default environment values + +The app reads these environment variables: + +- `SECRET_KEY` +- `ADMIN_USERNAME` +- `ADMIN_PASSWORD` + +If you do not set them, the app falls back to insecure defaults for local development only. + +## Suggested next steps + +- Add session filters like last 5, 10, 20, all +- Add player color editing +- Add export/import tools +- Add session status like open and closed +- Replace CSV storage with SQLite later without changing the page layer +- Add reverse proxy deployment with Caddy or Nginx + +## Deploy notes + +For a private home-hosted deployment, I would run this behind Caddy or Nginx and set real environment variables instead of using defaults. diff --git a/app.py b/app.py new file mode 100644 index 0000000..e0adcc4 --- /dev/null +++ b/app.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import os +from pathlib import Path + +from flask import Flask, flash, redirect, render_template, request, session, url_for + +from stats import ( + build_leaderboard, + build_session_summaries, + cents_to_dollars, + cumulative_profit_series, + player_session_series, + safe_date_label, + session_events, + unique_player_names, +) +from storage import 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" + + +def load_local_env(env_path: Path) -> None: + if not env_path.exists(): + return + + for raw_line in env_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + os.environ.setdefault(key, value) + + +load_local_env(ENV_PATH) + +app = Flask(__name__) +app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "change-this-before-deploying") +app.config["ADMIN_USERNAME"] = os.getenv("ADMIN_USERNAME", "admin") +app.config["ADMIN_PASSWORD"] = os.getenv("ADMIN_PASSWORD", "change-me") +app.config["SESSION_COOKIE_HTTPONLY"] = True +app.config["SESSION_COOKIE_SAMESITE"] = "Lax" +app.config["SESSION_COOKIE_NAME"] = "poker_portal_session" + +ensure_data_file(DATA_PATH) +app.jinja_env.filters["money"] = cents_to_dollars +app.jinja_env.filters["pretty_date"] = safe_date_label + + +def is_admin() -> bool: + return bool(session.get("is_admin")) + + +@app.context_processor +def inject_globals() -> dict[str, object]: + return {"is_admin": is_admin()} + + +@app.get("/") +def home() -> str: + return redirect(url_for("leaderboard")) + + +@app.get("/leaderboard") +def leaderboard() -> str: + events = load_events(DATA_PATH) + sessions = build_session_summaries(events) + board = build_leaderboard(sessions) + chart_data = cumulative_profit_series(sessions) + return render_template( + "leaderboard.html", + leaderboard=board, + session_count=len(sessions), + chart_data=chart_data, + ) + + +@app.get("/sessions") +def sessions() -> str: + events = load_events(DATA_PATH) + session_summaries = build_session_summaries(events) + return render_template("sessions.html", sessions=session_summaries) + + +@app.get("/sessions/") +def session_detail(session_date: str) -> str: + events = load_events(DATA_PATH) + sessions = build_session_summaries(events) + target_session = next( + ( + session_summary + for session_summary in sessions + if session_summary.session_date == session_date + ), + None, + ) + if target_session is None: + flash("That session was not found.", "error") + return redirect(url_for("sessions")) + + return render_template( + "session_detail.html", + session=target_session, + raw_events=session_events(events, session_date), + ) + + +@app.get("/players/") +def player_detail(player_name: str) -> str: + events = load_events(DATA_PATH) + sessions = build_session_summaries(events) + board = build_leaderboard(sessions) + player_stats = next( + (player for player in board if player.player_name == player_name), None + ) + if player_stats is None: + flash("That player was not found.", "error") + return redirect(url_for("leaderboard")) + + chart_data = player_session_series(sessions, player_name) + return render_template( + "player_detail.html", + player=player_stats, + chart_data=chart_data, + ) + + +@app.route("/admin/login", methods=["GET", "POST"]) +def admin_login() -> str: + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + + if ( + username == app.config["ADMIN_USERNAME"] + and password == app.config["ADMIN_PASSWORD"] + ): + session["is_admin"] = True + flash("Admin login successful.", "success") + return redirect(url_for("admin_dashboard")) + + flash("Invalid admin credentials.", "error") + + return render_template("admin_login.html") + + +@app.post("/admin/logout") +def admin_logout() -> str: + session.clear() + flash("Logged out.", "success") + return redirect(url_for("leaderboard")) + + +@app.route("/admin", methods=["GET", "POST"]) +def admin_dashboard() -> str: + if not is_admin(): + flash("Admin login required.", "error") + return redirect(url_for("admin_login")) + + if request.method == "POST": + session_date = request.form.get("session_date", "").strip() + player_name = request.form.get("player_name", "").strip() + event_type = request.form.get("event_type", "").strip() + amount_raw = request.form.get("amount", "0").strip() + note = request.form.get("note", "").strip() + + if not session_date or not player_name or not event_type: + flash("Session date, player name, and event type are required.", "error") + return redirect(url_for("admin_dashboard")) + + try: + amount_cents = 0 if event_type == "note" else round(float(amount_raw) * 100) + except ValueError: + flash("Amount must be a valid number.", "error") + return redirect(url_for("admin_dashboard")) + + append_event( + DATA_PATH, + session_date=session_date, + player_name=player_name, + event_type=event_type, + amount_cents=amount_cents, + note=note, + actor=app.config["ADMIN_USERNAME"], + ) + flash("Event added to the ledger.", "success") + return redirect(url_for("admin_dashboard")) + + events = load_events(DATA_PATH) + sessions = build_session_summaries(events) + recent_sessions = sessions[:6] + recent_events = list(reversed(events[-20:])) + return render_template( + "admin_dashboard.html", + recent_sessions=recent_sessions, + recent_events=recent_events, + player_names=unique_player_names(events), + ) + + +if __name__ == "__main__": + app.run(debug=True) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..22ac75b --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +Flask==3.1.0 diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..8455a19 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,609 @@ +:root { + --bg: #07111f; + --bg-accent: #0b1830; + --panel: rgba(11, 24, 48, 0.82); + --panel-strong: rgba(13, 28, 55, 0.95); + --line: rgba(157, 176, 199, 0.14); + --text: #e5eefb; + --text-muted: #9db0c7; + --green: #22c55e; + --red: #ef4444; + --amber: #f59e0b; + --gold: #f5c76a; + --shadow: 0 22px 60px rgba(0, 0, 0, 0.35); + --radius: 24px; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Inter", sans-serif; + color: var(--text); + background: + radial-gradient(circle at top left, rgba(124, 156, 255, 0.16), transparent 22%), + radial-gradient(circle at top right, rgba(94, 234, 212, 0.11), transparent 18%), + linear-gradient(180deg, #06101d 0%, #091426 100%); + min-height: 100vh; +} + +body::before { + content: ""; + position: fixed; + inset: 0; + background-image: linear-gradient(rgba(255,255,255,0.018) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.018) 1px, transparent 1px); + background-size: 34px 34px; + pointer-events: none; + opacity: 0.4; +} + + a { + color: var(--text); + text-decoration: none; +} + +.site-shell { + width: min(1260px, calc(100% - 32px)); + margin: 0 auto; + padding: 28px 0 52px; +} + +.topbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 24px; + margin-bottom: 28px; + padding: 18px 22px; + border: 1px solid var(--line); + border-radius: 999px; + background: rgba(6, 16, 29, 0.74); + backdrop-filter: blur(16px); + box-shadow: var(--shadow); +} + +.brand { + font-size: 1.3rem; + font-weight: 800; + letter-spacing: -0.03em; +} + +.brand-subtitle { + margin: 4px 0 0; + color: var(--text-muted); + font-size: 0.93rem; +} + +.nav-links { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} + +.nav-links a, +.ghost-button { + color: var(--text); + background: transparent; + border: none; + font: inherit; + cursor: pointer; + padding: 10px 14px; + border-radius: 999px; +} + +.nav-links a:hover, +.ghost-button:hover { + background: rgba(124, 156, 255, 0.12); +} + +.flash-stack { + display: grid; + gap: 10px; + margin-bottom: 20px; +} + +.flash { + border-radius: 18px; + padding: 14px 16px; + border: 1px solid var(--line); + backdrop-filter: blur(14px); +} + +.flash.success { + background: rgba(34, 197, 94, 0.12); +} + +.flash.error { + background: rgba(244, 63, 94, 0.12); +} + +.page-content { + display: grid; + gap: 24px; +} + +.hero-card, +.panel { + border-radius: var(--radius); + border: 1px solid var(--line); + background: var(--panel); + box-shadow: var(--shadow); + backdrop-filter: blur(18px); +} + +.hero-card { + padding: 28px; + background: + linear-gradient(135deg, rgba(124, 156, 255, 0.13), transparent 40%), + linear-gradient(180deg, rgba(11, 24, 48, 0.96), rgba(9, 20, 38, 0.92)); +} + +.hero-card.compact { + padding: 24px 28px; +} + +.panel { + padding: 24px; +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + margin-bottom: 18px; +} + +.eyebrow { + margin: 0 0 6px; + text-transform: uppercase; + letter-spacing: 0.14em; + font-size: 0.74rem; + color: var(--gold); + font-weight: 700; +} + +h1, +h2 { + margin: 0; + letter-spacing: -0.04em; +} + +h1 { + font-size: clamp(2rem, 4vw, 3.2rem); +} + +h2 { + font-size: 1.38rem; +} + +.muted-text { + color: var(--text-muted); + line-height: 1.6; +} + +.muted-text.small, +.tiny-text { + font-size: 0.88rem; +} + +.tiny-text { + color: #8092ab; +} + +.grid { + display: grid; + gap: 24px; +} + +.two-col { + grid-template-columns: 1.7fr 1fr; +} + +.three-col { + grid-template-columns: repeat(3, 1fr); +} + +.chart-panel { + min-height: 420px; +} + +.chart-panel-large { + min-height: 500px; +} + +.chart-frame { + position: relative; + min-height: 360px; +} + +.chart-panel-large .chart-frame { + min-height: 430px; +} + +.chart-frame-interactive { + cursor: zoom-in; +} + +.chart-frame-interactive:focus-visible { + outline: 2px solid rgba(245, 199, 106, 0.7); + outline-offset: 6px; +} + +.chart-panel canvas, +.chart-frame canvas, +.chart-modal-frame canvas { + width: 100% !important; + height: 100% !important; +} + +.stat-panel, +.stat-card { + display: grid; + align-content: start; +} + +.stat-list { + display: grid; + gap: 14px; +} + +.stat-row { + display: flex; + justify-content: space-between; + gap: 14px; + padding: 12px 0; + border-bottom: 1px solid var(--line); +} + +.stat-card { + gap: 8px; +} + +.stat-card span { + color: var(--text-muted); +} + +.stat-card strong { + font-size: 1.75rem; + letter-spacing: -0.04em; +} + +.table-wrap { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + text-align: left; + padding: 14px 14px; + border-bottom: 1px solid var(--line); + white-space: nowrap; +} + +th { + color: var(--text-muted); + font-size: 0.83rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +tbody tr:hover { + background: rgba(124, 156, 255, 0.06); +} + +.positive { + color: var(--green); + font-weight: 700; +} + +.negative { + color: var(--red); + font-weight: 700; +} + +.neutral { + color: var(--amber); + font-weight: 700; +} + +.form-shell { + display: flex; + justify-content: center; +} + +.form-shell.narrow { + min-height: 55vh; + align-items: center; +} + +.form-card { + display: grid; + gap: 16px; +} + +.form-card label { + display: grid; + gap: 8px; +} + +.form-card span { + font-size: 0.94rem; + color: var(--text-muted); +} + +input, +select, +textarea, +button { + font: inherit; +} + +input, +select, +textarea { + width: 100%; + padding: 13px 14px; + border-radius: 16px; + border: 1px solid rgba(157, 176, 199, 0.18); + background: rgba(7, 17, 31, 0.78); + color: var(--text); + outline: none; +} + +input:focus, +select:focus, +textarea:focus { + border-color: rgba(124, 156, 255, 0.7); + box-shadow: 0 0 0 4px rgba(124, 156, 255, 0.14); +} + +.primary-button { + padding: 14px 18px; + border: none; + border-radius: 16px; + background: linear-gradient(135deg, #f4a261 0%, #2a9d8f 100%); + color: #07111f; + font-weight: 800; + cursor: pointer; +} + +.secondary-button { + padding: 11px 14px; + border: 1px solid var(--line); + border-radius: 14px; + background: rgba(255, 255, 255, 0.04); + color: var(--text); + font-weight: 600; + cursor: pointer; +} + +.secondary-button:hover { + background: rgba(255, 255, 255, 0.08); +} + +.helper-box { + display: flex; + gap: 10px; + align-items: flex-start; + border-radius: 18px; + border: 1px solid var(--line); + padding: 14px 16px; + color: var(--text-muted); + background: rgba(244, 162, 97, 0.06); +} + +.note-list { + margin: 0; + padding-left: 18px; + color: var(--text-muted); + white-space: normal; +} + +.event-feed { + display: grid; + gap: 12px; + max-height: 580px; + overflow-y: auto; + padding-right: 2px; +} + +.event-feed.condensed { + max-height: 420px; +} + +.event-card { + padding: 16px; + border-radius: 20px; + border: 1px solid var(--line); + background: rgba(7, 17, 31, 0.72); +} + +.event-card-top { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; +} + +.event-amount { + margin: 10px 0 6px; + font-size: 1.2rem; + font-weight: 700; + letter-spacing: -0.04em; +} + +.pill { + border-radius: 999px; + padding: 6px 10px; + background: rgba(244, 162, 97, 0.12); + color: #ffd3ab; + font-size: 0.77rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.stack-gap { + display: grid; + gap: 24px; +} + +.narrow { + max-width: 520px; + width: 100%; +} + +.small-table table th, +.small-table table td { + padding: 12px; +} + +@media (max-width: 980px) { + .two-col, + .three-col, + .session-layout, + .admin-layout { + grid-template-columns: 1fr; + } + + .topbar { + border-radius: 28px; + padding: 20px; + align-items: flex-start; + flex-direction: column; + } +} + +@media (max-width: 640px) { + .site-shell { + width: min(100% - 18px, 100%); + padding-top: 18px; + } + + .panel, + .hero-card { + padding: 18px; + border-radius: 22px; + } + + h1 { + font-size: 2rem; + } + + .nav-links { + width: 100%; + } +} + + +.panel-header-with-actions { + align-items: center; +} + +.panel-actions { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +.sortable-table th { + padding-top: 10px; + padding-bottom: 10px; +} + +.sort-button { + display: inline-flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 0; + border: none; + background: transparent; + color: inherit; + text-transform: inherit; + letter-spacing: inherit; + font: inherit; + cursor: pointer; +} + +.sort-button:hover, +.sort-button.is-active { + color: var(--text); +} + +.sort-indicator { + color: var(--text-soft, #8092ab); + font-size: 0.9em; +} + +.chart-modal { + position: fixed; + inset: 0; + display: none; + align-items: stretch; + justify-content: center; + padding: 24px; + z-index: 200; +} + +.chart-modal.is-open { + display: flex; +} + +.chart-modal-backdrop { + position: absolute; + inset: 0; + background: rgba(3, 6, 12, 0.76); + backdrop-filter: blur(10px); +} + +.chart-modal-card { + position: relative; + z-index: 1; + width: min(1280px, 100%); + min-height: calc(100vh - 48px); + border-radius: 28px; + border: 1px solid var(--line); + background: rgba(11, 17, 24, 0.96); + box-shadow: var(--shadow); + padding: 24px; + display: grid; + grid-template-rows: auto 1fr; + gap: 18px; +} + +.chart-modal-frame { + min-height: 70vh; +} + +body.modal-open { + overflow: hidden; +} + +@media (max-width: 980px) { + .leaderboard-layout { + grid-template-columns: 1fr; + } + + .chart-modal { + padding: 12px; + } + + .chart-modal-card { + min-height: calc(100vh - 24px); + padding: 18px; + } + + .chart-modal-frame { + min-height: 62vh; + } +} diff --git a/stats.py b/stats.py new file mode 100644 index 0000000..8235c04 --- /dev/null +++ b/stats.py @@ -0,0 +1,285 @@ +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any + +from storage import EventRow + +PLAYER_PALETTE = [ + "#f4a261", + "#2a9d8f", + "#8d99ae", + "#e76f51", + "#7c6cf2", + "#84cc16", + "#f59e0b", + "#10b981", + "#ef4444", + "#06b6d4", + "#a855f7", + "#eab308", +] + + +@dataclass +class SessionEntry: + session_date: str + player_name: str + buy_in_cents: int = 0 + cash_out_cents: int = 0 + notes: list[str] = field(default_factory=list) + + @property + def net_cents(self) -> int: + return self.cash_out_cents - self.buy_in_cents + + +@dataclass +class SessionSummary: + session_date: str + entries: list[SessionEntry] + + @property + def total_buy_in_cents(self) -> int: + return sum(entry.buy_in_cents for entry in self.entries) + + @property + def total_cash_out_cents(self) -> int: + return sum(entry.cash_out_cents for entry in self.entries) + + @property + def total_net_cents(self) -> int: + return sum(entry.net_cents for entry in self.entries) + + +@dataclass +class PlayerStats: + player_name: str + sessions_played: int + winning_sessions: int + losing_sessions: int + break_even_sessions: int + win_pct: float + avg_win_cents: int + avg_loss_cents: int + biggest_win_cents: int + biggest_loss_cents: int + total_buy_in_cents: int + total_cash_out_cents: int + total_net_cents: int + roi_pct: float + + + +def cents_to_dollars(cents: int) -> str: + value = cents / 100 + return f"${value:,.2f}" + + + +def safe_date_label(session_date: str) -> str: + try: + return datetime.strptime(session_date, "%Y-%m-%d").strftime("%b %d, %Y") + except ValueError: + return session_date + + + +def color_for_name(name: str, names: list[str]) -> str: + try: + index = sorted(names, key=str.casefold).index(name) + except ValueError: + index = abs(hash(name)) + return PLAYER_PALETTE[index % len(PLAYER_PALETTE)] + + + +def net_tone(value_cents: int) -> str: + if value_cents > 0: + return "#22c55e" + if value_cents < 0: + return "#ef4444" + return "#f59e0b" + + + +def build_session_summaries(events: list[EventRow]) -> list[SessionSummary]: + grouped: dict[tuple[str, str], SessionEntry] = {} + + for event in events: + key = (event["session_date"], event["player_name"]) + if key not in grouped: + grouped[key] = SessionEntry( + session_date=event["session_date"], + player_name=event["player_name"], + ) + + entry = grouped[key] + if event["event_type"] == "buyin": + entry.buy_in_cents += event["amount_cents"] + elif event["event_type"] == "cashout": + entry.cash_out_cents += event["amount_cents"] + + if event["note"]: + entry.notes.append(event["note"]) + + by_session: dict[str, list[SessionEntry]] = defaultdict(list) + for entry in grouped.values(): + by_session[entry.session_date].append(entry) + + sessions = [ + SessionSummary( + session_date=session_date, + entries=sorted(entries, key=lambda entry: entry.player_name.casefold()), + ) + for session_date, entries in by_session.items() + ] + sessions.sort(key=lambda session: session.session_date, reverse=True) + return sessions + + + +def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]: + player_entries: dict[str, list[SessionEntry]] = defaultdict(list) + for session in sessions: + for entry in session.entries: + player_entries[entry.player_name].append(entry) + + leaderboard: list[PlayerStats] = [] + for player_name, entries in player_entries.items(): + nets = [entry.net_cents for entry in entries] + wins = [value for value in nets if value > 0] + losses = [value for value in nets if value < 0] + total_buy_in = sum(entry.buy_in_cents for entry in entries) + total_cash_out = sum(entry.cash_out_cents for entry in entries) + total_net = total_cash_out - total_buy_in + sessions_played = len(entries) + winning_sessions = len(wins) + losing_sessions = len(losses) + break_even_sessions = sessions_played - winning_sessions - losing_sessions + win_pct = (winning_sessions / sessions_played * 100) if sessions_played else 0.0 + avg_win = round(sum(wins) / len(wins)) if wins else 0 + avg_loss = round(sum(abs(value) for value in losses) / len(losses)) if losses else 0 + biggest_win = max(wins) if wins else 0 + biggest_loss = min(losses) if losses else 0 + roi_pct = (total_net / total_buy_in * 100) if total_buy_in else 0.0 + + leaderboard.append( + PlayerStats( + player_name=player_name, + sessions_played=sessions_played, + winning_sessions=winning_sessions, + losing_sessions=losing_sessions, + break_even_sessions=break_even_sessions, + win_pct=win_pct, + avg_win_cents=avg_win, + avg_loss_cents=avg_loss, + biggest_win_cents=biggest_win, + biggest_loss_cents=biggest_loss, + total_buy_in_cents=total_buy_in, + total_cash_out_cents=total_cash_out, + total_net_cents=total_net, + roi_pct=roi_pct, + ) + ) + + leaderboard.sort( + key=lambda player: (player.total_net_cents, player.total_cash_out_cents), + reverse=True, + ) + return leaderboard + + + +def cumulative_profit_series(sessions: list[SessionSummary]) -> dict[str, Any]: + ordered_sessions = sorted(sessions, key=lambda session: session.session_date) + player_names = sorted( + {entry.player_name for session in ordered_sessions for entry in session.entries}, + key=str.casefold, + ) + + labels = [safe_date_label(session.session_date) for session in ordered_sessions] + datasets = [] + + for player_name in player_names: + series: list[float | None] = [] + running_total = 0 + has_started = False + + for session in ordered_sessions: + matching_entry = next( + (entry for entry in session.entries if entry.player_name == player_name), + None, + ) + if matching_entry is not None: + has_started = True + running_total += matching_entry.net_cents + series.append(round(running_total / 100, 2)) + elif has_started: + series.append(round(running_total / 100, 2)) + else: + series.append(None) + + datasets.append( + { + "label": player_name, + "data": series, + "borderColor": color_for_name(player_name, player_names), + "backgroundColor": color_for_name(player_name, player_names), + "pointRadius": 3, + "pointHoverRadius": 5, + "pointHitRadius": 10, + "borderWidth": 2.5, + "tension": 0.22, + "spanGaps": False, + } + ) + + return {"labels": labels, "datasets": datasets} + + + +def player_session_series(sessions: list[SessionSummary], player_name: str) -> dict[str, Any]: + ordered_sessions = sorted(sessions, key=lambda session: session.session_date) + labels: list[str] = [] + net_values: list[float] = [] + cumulative_values: list[float] = [] + running_total = 0 + + all_player_names = sorted( + {entry.player_name for session in ordered_sessions for entry in session.entries}, + key=str.casefold, + ) + + for session in ordered_sessions: + matching_entry = next( + (entry for entry in session.entries if entry.player_name == player_name), + None, + ) + if matching_entry is None: + continue + + labels.append(safe_date_label(session.session_date)) + net_values.append(round(matching_entry.net_cents / 100, 2)) + running_total += matching_entry.net_cents + cumulative_values.append(round(running_total / 100, 2)) + + return { + "labels": labels, + "color": color_for_name(player_name, all_player_names), + "net_values": net_values, + "net_colors": [net_tone(round(value * 100)) for value in net_values], + "cumulative_values": cumulative_values, + } + + + +def session_events(events: list[EventRow], session_date: str) -> list[EventRow]: + return [event for event in events if event["session_date"] == session_date] + + + +def unique_player_names(events: list[EventRow]) -> list[str]: + return sorted({event["player_name"] for event in events}, key=str.casefold) diff --git a/storage.py b/storage.py new file mode 100644 index 0000000..478fe21 --- /dev/null +++ b/storage.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import csv +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import TypedDict + +CSV_HEADERS = [ + "id", + "created_at", + "session_date", + "player_name", + "event_type", + "amount_cents", + "note", + "actor", +] + + +class EventRow(TypedDict): + id: str + created_at: str + session_date: str + player_name: str + event_type: str + amount_cents: int + note: str + actor: str + + +VALID_EVENT_TYPES = {"buyin", "cashout", "note"} + + +def ensure_data_file(csv_path: Path) -> None: + csv_path.parent.mkdir(parents=True, exist_ok=True) + if csv_path.exists(): + return + + with csv_path.open("w", newline="", encoding="utf-8") as file: + writer = csv.DictWriter(file, fieldnames=CSV_HEADERS) + writer.writeheader() + + +def load_events(csv_path: Path) -> list[EventRow]: + ensure_data_file(csv_path) + + events: list[EventRow] = [] + with csv_path.open("r", newline="", encoding="utf-8") as file: + reader = csv.DictReader(file) + for row in reader: + events.append( + EventRow( + id=row["id"], + created_at=row["created_at"], + session_date=row["session_date"], + player_name=row["player_name"], + event_type=row["event_type"], + amount_cents=int(row["amount_cents"] or 0), + note=row.get("note", ""), + actor=row.get("actor", ""), + ) + ) + + events.sort(key=lambda event: (event["session_date"], event["created_at"], event["id"])) + return events + + +def append_event( + csv_path: Path, + session_date: str, + player_name: str, + event_type: str, + amount_cents: int, + note: str, + actor: str, +) -> EventRow: + ensure_data_file(csv_path) + + normalized_type = event_type.strip().lower() + if normalized_type not in VALID_EVENT_TYPES: + raise ValueError(f"Unsupported event type: {event_type}") + + if normalized_type == "note": + amount_cents = 0 + + event = EventRow( + id=str(uuid.uuid4()), + created_at=datetime.now(timezone.utc).isoformat(), + session_date=session_date.strip(), + player_name=player_name.strip(), + event_type=normalized_type, + amount_cents=amount_cents, + note=note.strip(), + actor=actor.strip(), + ) + + with csv_path.open("a", newline="", encoding="utf-8") as file: + writer = csv.DictWriter(file, fieldnames=CSV_HEADERS) + writer.writerow(event) + + return event diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html new file mode 100644 index 0000000..dc3fda4 --- /dev/null +++ b/templates/admin_dashboard.html @@ -0,0 +1,120 @@ +{% extends "base.html" %} +{% block title %}Admin · Poker Portal{% endblock %} +{% block content %} +
+
+

Admin

+

Ledger controls

+

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

+
+
+ +
+
+

New event

+

Add to the ledger

+ + + + + + + + + + + +
+ Tip: + Use positive amounts for normal buy-ins and cash-outs. Use negative amounts to correct a previous entry without editing history. +
+ + +
+ +
+
+
+
+

Recent sessions

+

Current totals

+
+
+
+ + + + + + + + + + + {% for session in recent_sessions %} + + + + + + + {% else %} + + + + {% endfor %} + +
DatePlayersBuy-insCash-outs
{{ session.session_date | pretty_date }}{{ session.entries|length }}{{ session.total_buy_in_cents | money }}{{ session.total_cash_out_cents | money }}
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 new file mode 100644 index 0000000..4479d22 --- /dev/null +++ b/templates/admin_login.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% block title %}Admin Login · Poker Portal{% endblock %} +{% block content %} +
+
+

Restricted access

+

Admin login

+

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

+ + + + + + +
+
+{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..fc38395 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,49 @@ + + + + + + {% block title %}Poker Portal{% 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 %} +
+
+ + diff --git a/templates/leaderboard.html b/templates/leaderboard.html new file mode 100644 index 0000000..85b9980 --- /dev/null +++ b/templates/leaderboard.html @@ -0,0 +1,335 @@ +{% extends "base.html" %} +{% block title %}Leaderboard · Poker Portal{% endblock %} +{% block content %} +
+
+

All-time results

+

Leaderboard

+

Track who is up, who is chasing, and how everyone has moved across {{ session_count }} recorded sessions.

+
+
+ +
+
+
+
+

Trend

+

Cumulative profit over time

+
+
+ +
+
+
+ +
+
+ +
+

Quick read

+

Table snapshot

+
+
Players tracked{{ leaderboard|length }}
+
Sessions tracked{{ session_count }}
+
Best player{% if leaderboard %}{{ leaderboard[0].player_name }}{% else %}—{% endif %}
+
Worst player{% if leaderboard %}{{ leaderboard[-1].player_name }}{% else %}—{% endif %}
+
+
+
+ +
+
+
+

Standings

+

All-time leaderboard

+
+

Click any column to sort.

+
+ +
+ + + + + + + + + + + + + + + + + {% for player in leaderboard %} + + + + + + + + + + + + + {% else %} + + + + {% endfor %} + +
#{{ loop.index }}{{ player.player_name }}{{ player.total_buy_in_cents | money }}{{ player.total_cash_out_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.
+
+
+ + + + +{% endblock %} diff --git a/templates/player_detail.html b/templates/player_detail.html new file mode 100644 index 0000000..2dc144e --- /dev/null +++ b/templates/player_detail.html @@ -0,0 +1,141 @@ +{% extends "base.html" %} +{% block title %}{{ player.player_name }} · Poker Portal{% endblock %} +{% block content %} +
+
+

Player profile

+

{{ player.player_name }}

+

A quick read on lifetime performance, hit rate, and session-by-session movement.

+
+
+ +
+
Total Net{{ player.total_net_cents | money }}
+
Win %{{ "%.1f"|format(player.win_pct) }}%
+
ROI{{ "%.1f"|format(player.roi_pct) }}%
+
Buy-ins{{ player.total_buy_in_cents | money }}
+
Cash-outs{{ player.total_cash_out_cents | money }}
+
Sessions{{ player.sessions_played }}
+
+ +
+
+
+
+

Trend

+

Cumulative profit

+
+
+
+ +
+
+
+
+
+

Volatility

+

Session results

+
+
+
+ +
+
+
+ +
+
+
+

Breakdown

+

Detailed stats

+
+
+
+ + + + + + + + + + +
Winning Sessions{{ player.winning_sessions }}
Losing Sessions{{ player.losing_sessions }}
Break-even Sessions{{ player.break_even_sessions }}
Average Win{{ player.avg_win_cents | money }}
Average Loss{{ player.avg_loss_cents | money }}
Biggest Win{{ player.biggest_win_cents | money }}
Biggest Loss{{ player.biggest_loss_cents | money }}
+
+
+ + +{% endblock %} diff --git a/templates/session_detail.html b/templates/session_detail.html new file mode 100644 index 0000000..a8fbb32 --- /dev/null +++ b/templates/session_detail.html @@ -0,0 +1,82 @@ +{% extends "base.html" %} +{% block title %}{{ session.session_date }} · Poker Portal{% endblock %} +{% block content %} +
+
+

Session detail

+

{{ session.session_date | pretty_date }}

+

Current totals are derived from the append-only event log for this date.

+
+
+ +
+
+
+
+

Results

+

Player totals

+
+
+
+ + + + + + + + + + + + {% for entry in session.entries %} + + + + + + + + {% endfor %} + +
PlayerBuy-inCash-outNetNotes
{{ entry.player_name }}{{ entry.buy_in_cents | money }}{{ entry.cash_out_cents | money }}{{ entry.net_cents | money }} + {% if entry.notes %} +
    + {% for note in entry.notes %} +
  • {{ note }}
  • + {% endfor %} +
+ {% else %} + — + {% endif %} +
+
+
+ +
+
+
+

Audit trail

+

Raw ledger events

+
+
+
+ {% for event in raw_events | reverse %} +
+
+ {{ event.player_name }} + {{ event.event_type }} +
+

{{ event.amount_cents | money }}

+ {% if event.note %} +

{{ event.note }}

+ {% endif %} +

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

+
+ {% else %} +

No raw events recorded.

+ {% endfor %} +
+
+
+{% endblock %} diff --git a/templates/sessions.html b/templates/sessions.html new file mode 100644 index 0000000..e4437bb --- /dev/null +++ b/templates/sessions.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% block title %}Sessions · Poker Portal{% endblock %} +{% block content %} +
+
+

Session history

+

Sessions

+

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

+
+
+ +
+
+
+

Archive

+

All sessions by date

+
+
+
+ + + + + + + + + + + + {% for session in sessions %} + + + + + + + + {% else %} + + + + {% endfor %} + +
DatePlayersTotal Buy-insTotal Cash-outsTable Net
{{ session.session_date | pretty_date }}{{ session.entries|length }}{{ session.total_buy_in_cents | money }}{{ session.total_cash_out_cents | money }}{{ session.total_net_cents | money }}
No sessions yet.
+
+
+{% endblock %} From c0894f5a680c0d3feefad9c589b64e868657ac20 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sun, 15 Mar 2026 14:28:16 -0700 Subject: [PATCH 02/39] tidy messages and stuff --- README.md | 33 ++++++--------------------------- templates/admin_dashboard.html | 11 +++-------- templates/session_detail.html | 2 +- 3 files changed, 10 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index a8b29e4..28bb573 100644 --- a/README.md +++ b/README.md @@ -8,16 +8,15 @@ A small server-rendered poker ledger app for low-stakes no-limit hold'em nights. - Flask - Jinja templates - Chart.js -- Custom CSS - Append-only CSV event ledger -## What this MVP does +## Stuff is does -- Public all-time leaderboard -- Public session archive -- Public per-player stat pages -- Admin-only login for adding ledger events -- Append-only `entries.csv` audit trail +- All-time leaderboard +- Session archive +- Per player stat pages +- Admin only login for adding ledger events +- Append only `entries.csv` audit trail - Buy-ins, cash-outs, note-only events, and correction events using negative amounts - Cumulative profit chart and player charts @@ -57,11 +56,6 @@ export $(grep -v '^#' .env | xargs) python app.py ``` -Open: - -- public site: `http://127.0.0.1:5000/leaderboard` -- admin login: `http://127.0.0.1:5000/admin/login` - ## Default environment values The app reads these environment variables: @@ -69,18 +63,3 @@ The app reads these environment variables: - `SECRET_KEY` - `ADMIN_USERNAME` - `ADMIN_PASSWORD` - -If you do not set them, the app falls back to insecure defaults for local development only. - -## Suggested next steps - -- Add session filters like last 5, 10, 20, all -- Add player color editing -- Add export/import tools -- Add session status like open and closed -- Replace CSV storage with SQLite later without changing the page layer -- Add reverse proxy deployment with Caddy or Nginx - -## Deploy notes - -For a private home-hosted deployment, I would run this behind Caddy or Nginx and set real environment variables instead of using defaults. diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html index dc3fda4..52c2715 100644 --- a/templates/admin_dashboard.html +++ b/templates/admin_dashboard.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}Admin · Poker Portal{% endblock %} +{% block title %}Admin - Poker Portal{% endblock %} {% block content %}
@@ -21,7 +21,7 @@
From f246f401dc8c39f5ce4f706ea06672f8bfb23b22 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sun, 15 Mar 2026 15:30:55 -0700 Subject: [PATCH 03/39] get rid of that damn blue --- static/css/style.css | 708 ++++++++++++++++++++++--------------------- 1 file changed, 368 insertions(+), 340 deletions(-) diff --git a/static/css/style.css b/static/css/style.css index 8455a19..8aacde9 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1,123 +1,234 @@ :root { - --bg: #07111f; - --bg-accent: #0b1830; - --panel: rgba(11, 24, 48, 0.82); - --panel-strong: rgba(13, 28, 55, 0.95); - --line: rgba(157, 176, 199, 0.14); - --text: #e5eefb; - --text-muted: #9db0c7; - --green: #22c55e; - --red: #ef4444; - --amber: #f59e0b; - --gold: #f5c76a; - --shadow: 0 22px 60px rgba(0, 0, 0, 0.35); - --radius: 24px; + --bg: #0f1113; + --bg-elevated: #14171b; + --bg-panel: rgba(20, 23, 27, 0.92); + --bg-panel-strong: rgba(24, 28, 33, 0.98); + --bg-soft: rgba(255, 255, 255, 0.028); + --line: rgba(255, 255, 255, 0.08); + --line-strong: rgba(255, 255, 255, 0.12); + --text: #f3f4f6; + --text-muted: #a8adb7; + --text-soft: #8b919c; + --accent: #f08a38; + --accent-2: #ffb36a; + --accent-soft: rgba(240, 138, 56, 0.14); + --positive: #59d98e; + --negative: #f06c72; + --neutral: #e4bf65; + --shadow: 0 20px 60px rgba(0, 0, 0, 0.32); + --shadow-soft: 0 10px 30px rgba(0, 0, 0, 0.18); + --radius-sm: 10px; + --radius: 14px; + --radius-lg: 18px; } * { box-sizing: border-box; } +html { + color-scheme: dark; +} + body { margin: 0; + min-height: 100vh; font-family: "Inter", sans-serif; color: var(--text); background: - radial-gradient(circle at top left, rgba(124, 156, 255, 0.16), transparent 22%), - radial-gradient(circle at top right, rgba(94, 234, 212, 0.11), transparent 18%), - linear-gradient(180deg, #06101d 0%, #091426 100%); - min-height: 100vh; + radial-gradient( + circle at top left, + rgba(240, 138, 56, 0.1), + transparent 22% + ), + radial-gradient( + circle at top right, + rgba(255, 179, 106, 0.06), + transparent 18% + ), + linear-gradient(180deg, #0d0f11 0%, #121519 100%); } body::before { content: ""; position: fixed; inset: 0; - background-image: linear-gradient(rgba(255,255,255,0.018) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.018) 1px, transparent 1px); - background-size: 34px 34px; + background-image: + linear-gradient(rgba(255, 255, 255, 0.016) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.016) 1px, transparent 1px); + background-size: 38px 38px; + opacity: 0.28; pointer-events: none; - opacity: 0.4; } - a { - color: var(--text); +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(1260px, calc(100% - 32px)); + width: min(1280px, calc(100% - 40px)); margin: 0 auto; - padding: 28px 0 52px; + padding: 26px 0 44px; } .topbar { display: flex; justify-content: space-between; align-items: center; - gap: 24px; - margin-bottom: 28px; - padding: 18px 22px; + gap: 20px; + padding: 18px 20px; + margin-bottom: 26px; border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: rgba(16, 18, 22, 0.82); + backdrop-filter: blur(10px); + box-shadow: var(--shadow-soft); +} + +.brand-lockup { + min-width: 0; +} + +.brand-row { + display: flex; + align-items: center; + gap: 10px; +} + +.brand-badge { + display: inline-flex; + align-items: center; + justify-content: center; + height: 24px; + padding: 0 9px; border-radius: 999px; - background: rgba(6, 16, 29, 0.74); - backdrop-filter: blur(16px); - box-shadow: var(--shadow); + background: var(--accent-soft); + border: 1px solid rgba(240, 138, 56, 0.24); + color: var(--accent-2); + font-family: "IBM Plex Mono", monospace; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; } .brand { - font-size: 1.3rem; + font-size: 1.25rem; font-weight: 800; - letter-spacing: -0.03em; + letter-spacing: -0.04em; } .brand-subtitle { - margin: 4px 0 0; + margin: 6px 0 0; color: var(--text-muted); - font-size: 0.93rem; + font-size: 0.94rem; } .nav-links { display: flex; align-items: center; - gap: 16px; + gap: 10px; flex-wrap: wrap; } .nav-links a, -.ghost-button { - color: var(--text); +.ghost-button, +.secondary-button, +.sort-button { + transition: + background-color 140ms ease, + border-color 140ms ease, + color 140ms ease, + transform 140ms ease; +} + +.nav-links a, +.ghost-button, +.secondary-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 42px; + padding: 0 14px; + border-radius: 12px; + border: 1px solid transparent; background: transparent; - border: none; - font: inherit; + color: var(--text); cursor: pointer; - padding: 10px 14px; - border-radius: 999px; } .nav-links a:hover, -.ghost-button:hover { - background: rgba(124, 156, 255, 0.12); +.ghost-button:hover, +.secondary-button:hover { + background: rgba(255, 255, 255, 0.04); + border-color: var(--line); +} + +.nav-accent, +.secondary-button { + background: rgba(255, 255, 255, 0.03); + border-color: var(--line); +} + +.nav-accent:hover, +.secondary-button:hover { + border-color: rgba(240, 138, 56, 0.26); + box-shadow: inset 0 0 0 1px rgba(240, 138, 56, 0.12); } .flash-stack { display: grid; gap: 10px; - margin-bottom: 20px; + margin-bottom: 18px; } .flash { - border-radius: 18px; padding: 14px 16px; border: 1px solid var(--line); - backdrop-filter: blur(14px); + border-radius: var(--radius); + background: var(--bg-panel); + color: var(--text); + box-shadow: var(--shadow-soft); } .flash.success { - background: rgba(34, 197, 94, 0.12); + border-color: rgba(89, 217, 142, 0.18); + background: linear-gradient( + 180deg, + rgba(89, 217, 142, 0.1), + rgba(20, 23, 27, 0.94) + ); } .flash.error { - background: rgba(244, 63, 94, 0.12); + border-color: rgba(240, 108, 114, 0.18); + background: linear-gradient( + 180deg, + rgba(240, 108, 114, 0.1), + rgba(20, 23, 27, 0.94) + ); } .page-content { @@ -127,18 +238,29 @@ body::before { .hero-card, .panel { - border-radius: var(--radius); + position: relative; + overflow: hidden; border: 1px solid var(--line); - background: var(--panel); - box-shadow: var(--shadow); - backdrop-filter: blur(18px); + border-radius: var(--radius-lg); + background: var(--bg-panel); + box-shadow: var(--shadow-soft); +} + +.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; } .hero-card { - padding: 28px; - background: - linear-gradient(135deg, rgba(124, 156, 255, 0.13), transparent 40%), - linear-gradient(180deg, rgba(11, 24, 48, 0.96), rgba(9, 20, 38, 0.92)); + padding: 30px; } .hero-card.compact { @@ -146,52 +268,48 @@ body::before { } .panel { - padding: 24px; -} - -.panel-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: 12px; - margin-bottom: 18px; + padding: 22px; } .eyebrow { - margin: 0 0 6px; - text-transform: uppercase; - letter-spacing: 0.14em; + margin: 0 0 8px; + color: var(--accent-2); + font-family: "IBM Plex Mono", monospace; font-size: 0.74rem; - color: var(--gold); - font-weight: 700; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; } h1, h2 { margin: 0; - letter-spacing: -0.04em; + letter-spacing: -0.045em; } h1 { - font-size: clamp(2rem, 4vw, 3.2rem); + font-size: clamp(2.2rem, 4.2vw, 3.7rem); + line-height: 0.98; } h2 { - font-size: 1.38rem; + font-size: 1.32rem; + line-height: 1.15; } .muted-text { + margin: 0; color: var(--text-muted); - line-height: 1.6; + line-height: 1.65; } .muted-text.small, .tiny-text { - font-size: 0.88rem; + font-size: 0.9rem; } .tiny-text { - color: #8092ab; + color: var(--text-soft); } .grid { @@ -200,11 +318,31 @@ h2 { } .two-col { - grid-template-columns: 1.7fr 1fr; + grid-template-columns: minmax(0, 1.6fr) minmax(300px, 0.9fr); } .three-col { - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.panel-header { + position: relative; + z-index: 1; + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 14px; + margin-bottom: 18px; +} + +.panel-header-with-actions { + align-items: center; +} + +.panel-actions { + display: flex; + align-items: center; + gap: 10px; } .chart-panel { @@ -212,12 +350,13 @@ h2 { } .chart-panel-large { - min-height: 500px; + min-height: 520px; } -.chart-frame { +.chart-frame, +.chart-modal-frame { position: relative; - min-height: 360px; + min-height: 340px; } .chart-panel-large .chart-frame { @@ -229,8 +368,8 @@ h2 { } .chart-frame-interactive:focus-visible { - outline: 2px solid rgba(245, 199, 106, 0.7); - outline-offset: 6px; + outline: 2px solid rgba(240, 138, 56, 0.55); + outline-offset: 5px; } .chart-panel canvas, @@ -248,32 +387,54 @@ h2 { .stat-list { display: grid; - gap: 14px; + gap: 8px; + position: relative; + z-index: 1; } .stat-row { display: flex; justify-content: space-between; + align-items: center; gap: 14px; - padding: 12px 0; + padding: 14px 0; border-bottom: 1px solid var(--line); } -.stat-card { - gap: 8px; +.stat-row:last-child { + border-bottom: none; } -.stat-card span { +.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: 1.75rem; - letter-spacing: -0.04em; + font-size: clamp(1.8rem, 3vw, 2.35rem); + line-height: 1; + letter-spacing: -0.06em; } .table-wrap { + position: relative; + z-index: 1; overflow-x: auto; + border-radius: 12px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.018); } table { @@ -283,327 +444,194 @@ table { th, td { - text-align: left; padding: 14px 14px; border-bottom: 1px solid var(--line); + text-align: left; white-space: nowrap; } th { - color: var(--text-muted); - font-size: 0.83rem; + color: var(--text-soft); + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.12em; text-transform: uppercase; - letter-spacing: 0.08em; +} + +tbody tr { + transition: background-color 140ms ease; } tbody tr:hover { - background: rgba(124, 156, 255, 0.06); + background: rgba(255, 255, 255, 0.026); } -.positive { - color: var(--green); - font-weight: 700; +tbody tr:last-child td, +tbody tr:last-child th { + border-bottom: none; } -.negative { - color: var(--red); - font-weight: 700; -} - -.neutral { - color: var(--amber); - font-weight: 700; -} - -.form-shell { - display: flex; - justify-content: center; -} - -.form-shell.narrow { - min-height: 55vh; - align-items: center; -} - -.form-card { - display: grid; - gap: 16px; -} - -.form-card label { - display: grid; - gap: 8px; -} - -.form-card span { - font-size: 0.94rem; - color: var(--text-muted); -} - -input, -select, -textarea, -button { - font: inherit; -} - -input, -select, -textarea { - width: 100%; - padding: 13px 14px; - border-radius: 16px; - border: 1px solid rgba(157, 176, 199, 0.18); - background: rgba(7, 17, 31, 0.78); - color: var(--text); - outline: none; -} - -input:focus, -select:focus, -textarea:focus { - border-color: rgba(124, 156, 255, 0.7); - box-shadow: 0 0 0 4px rgba(124, 156, 255, 0.14); -} - -.primary-button { - padding: 14px 18px; - border: none; - border-radius: 16px; - background: linear-gradient(135deg, #f4a261 0%, #2a9d8f 100%); - color: #07111f; - font-weight: 800; - cursor: pointer; -} - -.secondary-button { - padding: 11px 14px; - border: 1px solid var(--line); - border-radius: 14px; - background: rgba(255, 255, 255, 0.04); - color: var(--text); +td a { font-weight: 600; - cursor: pointer; } -.secondary-button:hover { - background: rgba(255, 255, 255, 0.08); -} - -.helper-box { - display: flex; - gap: 10px; - align-items: flex-start; - border-radius: 18px; - border: 1px solid var(--line); - padding: 14px 16px; - color: var(--text-muted); - background: rgba(244, 162, 97, 0.06); -} - -.note-list { - margin: 0; - padding-left: 18px; - color: var(--text-muted); - white-space: normal; -} - -.event-feed { - display: grid; - gap: 12px; - max-height: 580px; - overflow-y: auto; - padding-right: 2px; -} - -.event-feed.condensed { - max-height: 420px; -} - -.event-card { - padding: 16px; - border-radius: 20px; - border: 1px solid var(--line); - background: rgba(7, 17, 31, 0.72); -} - -.event-card-top { - display: flex; - justify-content: space-between; - gap: 12px; - align-items: center; -} - -.event-amount { - margin: 10px 0 6px; - font-size: 1.2rem; - font-weight: 700; - letter-spacing: -0.04em; -} - -.pill { - border-radius: 999px; - padding: 6px 10px; - background: rgba(244, 162, 97, 0.12); - color: #ffd3ab; - font-size: 0.77rem; - text-transform: uppercase; - letter-spacing: 0.08em; -} - -.stack-gap { - display: grid; - gap: 24px; -} - -.narrow { - max-width: 520px; - width: 100%; -} - -.small-table table th, -.small-table table td { - padding: 12px; -} - -@media (max-width: 980px) { - .two-col, - .three-col, - .session-layout, - .admin-layout { - grid-template-columns: 1fr; - } - - .topbar { - border-radius: 28px; - padding: 20px; - align-items: flex-start; - flex-direction: column; - } -} - -@media (max-width: 640px) { - .site-shell { - width: min(100% - 18px, 100%); - padding-top: 18px; - } - - .panel, - .hero-card { - padding: 18px; - border-radius: 22px; - } - - h1 { - font-size: 2rem; - } - - .nav-links { - width: 100%; - } -} - - -.panel-header-with-actions { - align-items: center; -} - -.panel-actions { - display: flex; - gap: 10px; - align-items: center; - flex-wrap: wrap; -} - -.sortable-table th { - padding-top: 10px; - padding-bottom: 10px; +td a:hover { + color: var(--accent-2); } .sort-button { display: inline-flex; align-items: center; - gap: 8px; + gap: 6px; width: 100%; padding: 0; - border: none; + border: 0; background: transparent; color: inherit; - text-transform: inherit; - letter-spacing: inherit; - font: inherit; + text-align: left; cursor: pointer; } -.sort-button:hover, -.sort-button.is-active { +.sort-button:hover { color: var(--text); } +.sort-button.is-active { + color: var(--accent-2); +} + .sort-indicator { - color: var(--text-soft, #8092ab); + color: var(--text-soft); font-size: 0.9em; } +.sort-button.is-active .sort-indicator { + color: var(--accent-2); +} + +.positive, +.negative, +.neutral { + font-weight: 700; +} + +.positive { + color: var(--positive); +} + +.negative { + color: var(--negative); +} + +.neutral { + color: var(--neutral); +} + .chart-modal { position: fixed; inset: 0; + z-index: 80; display: none; - align-items: stretch; - justify-content: center; - padding: 24px; - z-index: 200; } .chart-modal.is-open { - display: flex; + display: block; } .chart-modal-backdrop { position: absolute; inset: 0; - background: rgba(3, 6, 12, 0.76); - backdrop-filter: blur(10px); + background: rgba(8, 10, 12, 0.76); + backdrop-filter: blur(6px); } .chart-modal-card { position: relative; z-index: 1; - width: min(1280px, 100%); - min-height: calc(100vh - 48px); - border-radius: 28px; - border: 1px solid var(--line); - background: rgba(11, 17, 24, 0.96); + width: min(1320px, calc(100vw - 48px)); + max-height: calc(100vh - 48px); + margin: 24px auto; + padding: 22px; + border: 1px solid var(--line-strong); + border-radius: 20px; + background: var(--bg-panel-strong); box-shadow: var(--shadow); - padding: 24px; - display: grid; - grid-template-rows: auto 1fr; - gap: 18px; } .chart-modal-frame { - min-height: 70vh; + height: min(74vh, 820px); } -body.modal-open { - overflow: hidden; +.leaderboard-layout { + align-items: stretch; } @media (max-width: 980px) { + .site-shell { + width: min(100% - 24px, 100%); + } + + .topbar { + align-items: flex-start; + flex-direction: column; + border-radius: 16px; + } + + .two-col, + .three-col, .leaderboard-layout { grid-template-columns: 1fr; } - .chart-modal { - padding: 12px; + .chart-panel-large .chart-frame { + min-height: 360px; + } +} + +@media (max-width: 640px) { + .site-shell { + padding-top: 16px; + } + + .topbar, + .hero-card, + .panel { + 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 { + min-height: 40px; + } + + th, + td { + padding: 12px 12px; } .chart-modal-card { - min-height: calc(100vh - 24px); - padding: 18px; + width: min(100vw - 20px, 100%); + margin: 10px auto; } .chart-modal-frame { - min-height: 62vh; + height: min(68vh, 620px); } } From 0c182b5aac22ad60dd17d7b3f967757f25502b1c Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sun, 15 Mar 2026 15:48:21 -0700 Subject: [PATCH 04/39] fix admin panel styles --- static/css/style.css | 331 ++++++++++++++++++++++++++++++------------- 1 file changed, 233 insertions(+), 98 deletions(-) diff --git a/static/css/style.css b/static/css/style.css index 8aacde9..95d39f4 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1,25 +1,24 @@ :root { - --bg: #0f1113; - --bg-elevated: #14171b; - --bg-panel: rgba(20, 23, 27, 0.92); - --bg-panel-strong: rgba(24, 28, 33, 0.98); - --bg-soft: rgba(255, 255, 255, 0.028); + --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.12); - --text: #f3f4f6; - --text-muted: #a8adb7; - --text-soft: #8b919c; - --accent: #f08a38; - --accent-2: #ffb36a; - --accent-soft: rgba(240, 138, 56, 0.14); - --positive: #59d98e; - --negative: #f06c72; - --neutral: #e4bf65; - --shadow: 0 20px 60px rgba(0, 0, 0, 0.32); - --shadow-soft: 0 10px 30px rgba(0, 0, 0, 0.18); - --radius-sm: 10px; - --radius: 14px; - --radius-lg: 18px; + --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; } * { @@ -38,15 +37,15 @@ body { background: radial-gradient( circle at top left, - rgba(240, 138, 56, 0.1), + rgba(229, 139, 58, 0.11), transparent 22% ), radial-gradient( circle at top right, - rgba(255, 179, 106, 0.06), + rgba(255, 182, 109, 0.05), transparent 18% ), - linear-gradient(180deg, #0d0f11 0%, #121519 100%); + linear-gradient(180deg, #0c0d0f 0%, #111418 100%); } body::before { @@ -54,10 +53,10 @@ body::before { position: fixed; inset: 0; background-image: - linear-gradient(rgba(255, 255, 255, 0.016) 1px, transparent 1px), - linear-gradient(90deg, rgba(255, 255, 255, 0.016) 1px, transparent 1px); - background-size: 38px 38px; - opacity: 0.28; + 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; } @@ -90,60 +89,34 @@ button { .site-shell { width: min(1280px, calc(100% - 40px)); margin: 0 auto; - padding: 26px 0 44px; + padding: 24px 0 44px; } .topbar { display: flex; justify-content: space-between; align-items: center; - gap: 20px; - padding: 18px 20px; - margin-bottom: 26px; + gap: 18px; + padding: 18px 22px; + margin-bottom: 24px; border: 1px solid var(--line); border-radius: var(--radius-lg); - background: rgba(16, 18, 22, 0.82); + background: rgba(16, 18, 22, 0.84); backdrop-filter: blur(10px); box-shadow: var(--shadow-soft); } -.brand-lockup { - min-width: 0; -} - -.brand-row { - display: flex; - align-items: center; - gap: 10px; -} - -.brand-badge { - display: inline-flex; - align-items: center; - justify-content: center; - height: 24px; - padding: 0 9px; - border-radius: 999px; - background: var(--accent-soft); - border: 1px solid rgba(240, 138, 56, 0.24); - color: var(--accent-2); - font-family: "IBM Plex Mono", monospace; - font-size: 0.72rem; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; -} - .brand { - font-size: 1.25rem; + display: inline-block; + font-size: 1.3rem; font-weight: 800; - letter-spacing: -0.04em; + letter-spacing: -0.045em; } .brand-subtitle { margin: 6px 0 0; color: var(--text-muted); - font-size: 0.94rem; + font-size: 0.95rem; } .nav-links { @@ -153,26 +126,33 @@ button { flex-wrap: wrap; } +.nav-links form { + margin: 0; +} + .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; + transform 140ms ease, + box-shadow 140ms ease; } .nav-links a, .ghost-button, -.secondary-button { +.secondary-button, +.primary-button { display: inline-flex; align-items: center; justify-content: center; min-height: 42px; padding: 0 14px; - border-radius: 12px; + border-radius: 10px; border: 1px solid transparent; background: transparent; color: var(--text); @@ -182,20 +162,39 @@ button { .nav-links a:hover, .ghost-button:hover, .secondary-button:hover { - background: rgba(255, 255, 255, 0.04); + background: rgba(255, 255, 255, 0.038); border-color: var(--line); } -.nav-accent, .secondary-button { - background: rgba(255, 255, 255, 0.03); border-color: var(--line); + background: rgba(255, 255, 255, 0.025); } -.nav-accent:hover, .secondary-button:hover { - border-color: rgba(240, 138, 56, 0.26); - box-shadow: inset 0 0 0 1px rgba(240, 138, 56, 0.12); + 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 { @@ -209,25 +208,24 @@ button { border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-panel); - color: var(--text); box-shadow: var(--shadow-soft); } .flash.success { - border-color: rgba(89, 217, 142, 0.18); + border-color: rgba(88, 211, 143, 0.2); background: linear-gradient( 180deg, - rgba(89, 217, 142, 0.1), - rgba(20, 23, 27, 0.94) + rgba(88, 211, 143, 0.08), + rgba(20, 22, 26, 0.94) ); } .flash.error { - border-color: rgba(240, 108, 114, 0.18); + border-color: rgba(239, 106, 114, 0.2); background: linear-gradient( 180deg, - rgba(240, 108, 114, 0.1), - rgba(20, 23, 27, 0.94) + rgba(239, 106, 114, 0.08), + rgba(20, 22, 26, 0.94) ); } @@ -274,9 +272,8 @@ button { .eyebrow { margin: 0 0 8px; color: var(--accent-2); - font-family: "IBM Plex Mono", monospace; font-size: 0.74rem; - font-weight: 600; + font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; } @@ -284,16 +281,16 @@ button { h1, h2 { margin: 0; - letter-spacing: -0.045em; + letter-spacing: -0.05em; } h1 { - font-size: clamp(2.2rem, 4.2vw, 3.7rem); + font-size: clamp(2.15rem, 4.2vw, 3.7rem); line-height: 0.98; } h2 { - font-size: 1.32rem; + font-size: 1.34rem; line-height: 1.15; } @@ -318,13 +315,77 @@ h2 { } .two-col { - grid-template-columns: minmax(0, 1.6fr) minmax(300px, 0.9fr); + grid-template-columns: minmax(0, 1.65fr) minmax(320px, 0.95fr); } .three-col { grid-template-columns: repeat(3, 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; +} + +.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; +} + .panel-header { position: relative; z-index: 1; @@ -350,7 +411,7 @@ h2 { } .chart-panel-large { - min-height: 520px; + min-height: 540px; } .chart-frame, @@ -360,7 +421,7 @@ h2 { } .chart-panel-large .chart-frame { - min-height: 430px; + min-height: 450px; } .chart-frame-interactive { @@ -368,7 +429,7 @@ h2 { } .chart-frame-interactive:focus-visible { - outline: 2px solid rgba(240, 138, 56, 0.55); + outline: 2px solid rgba(229, 139, 58, 0.55); outline-offset: 5px; } @@ -432,11 +493,16 @@ h2 { position: relative; z-index: 1; overflow-x: auto; - border-radius: 12px; + 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; @@ -444,10 +510,11 @@ table { th, td { - padding: 14px 14px; + padding: 14px; border-bottom: 1px solid var(--line); text-align: left; white-space: nowrap; + vertical-align: top; } th { @@ -527,6 +594,67 @@ td a:hover { color: var(--neutral); } +.event-feed { + display: grid; + gap: 12px; + position: relative; + z-index: 1; +} + +.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; +} + +.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; +} + +.note-list { + margin: 0; + padding-left: 18px; + color: var(--text-muted); + white-space: normal; +} + +.note-list li + li { + margin-top: 6px; +} + .chart-modal { position: fixed; inset: 0; @@ -541,7 +669,7 @@ td a:hover { .chart-modal-backdrop { position: absolute; inset: 0; - background: rgba(8, 10, 12, 0.76); + background: rgba(8, 10, 12, 0.78); backdrop-filter: blur(6px); } @@ -553,7 +681,7 @@ td a:hover { margin: 24px auto; padding: 22px; border: 1px solid var(--line-strong); - border-radius: 20px; + border-radius: 18px; background: var(--bg-panel-strong); box-shadow: var(--shadow); } @@ -562,7 +690,9 @@ td a:hover { height: min(74vh, 820px); } -.leaderboard-layout { +.leaderboard-layout, +.session-layout, +.admin-layout { align-items: stretch; } @@ -574,12 +704,14 @@ td a:hover { .topbar { align-items: flex-start; flex-direction: column; - border-radius: 16px; + border-radius: 14px; } .two-col, .three-col, - .leaderboard-layout { + .leaderboard-layout, + .session-layout, + .admin-layout { grid-template-columns: 1fr; } @@ -595,7 +727,8 @@ td a:hover { .topbar, .hero-card, - .panel { + .panel, + .chart-modal-card { border-radius: 14px; } @@ -617,21 +750,23 @@ td a:hover { .nav-links a, .ghost-button, - .secondary-button { + .secondary-button, + .primary-button { min-height: 40px; } th, td { - padding: 12px 12px; + padding: 12px; } .chart-modal-card { width: min(100vw - 20px, 100%); margin: 10px auto; + max-height: calc(100vh - 20px); } .chart-modal-frame { - height: min(68vh, 620px); + height: min(66vh, 680px); } } From 1de8586b5eeafd3eb42ad791ad5be50cdc2e0b0e Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sun, 15 Mar 2026 16:18:29 -0700 Subject: [PATCH 05/39] filter leaderboard by session --- app.py | 30 ++++++++++++++++++--- static/css/style.css | 55 ++++++++++++++++++++++++++++++++++++++ templates/leaderboard.html | 23 +++++++++++++--- 3 files changed, 100 insertions(+), 8 deletions(-) diff --git a/app.py b/app.py index e0adcc4..166f7d6 100644 --- a/app.py +++ b/app.py @@ -69,14 +69,36 @@ def home() -> str: @app.get("/leaderboard") def leaderboard() -> str: events = load_events(DATA_PATH) - sessions = build_session_summaries(events) - board = build_leaderboard(sessions) - chart_data = cumulative_profit_series(sessions) + all_sessions = build_session_summaries(events) + selected_session_date = request.args.get("through_session", "").strip() + valid_session_dates = {session.session_date for session in all_sessions} + + if selected_session_date not in valid_session_dates: + selected_session_date = "" + + filtered_sessions = all_sessions + if selected_session_date: + filtered_sessions = [ + session + for session in all_sessions + if session.session_date <= selected_session_date + ] + + board = build_leaderboard(filtered_sessions) + chart_data = cumulative_profit_series(filtered_sessions) return render_template( "leaderboard.html", leaderboard=board, - session_count=len(sessions), + session_count=len(filtered_sessions), + total_session_count=len(all_sessions), chart_data=chart_data, + available_sessions=all_sessions, + selected_session_date=selected_session_date, + selected_session_label=( + safe_date_label(selected_session_date) + if selected_session_date + else "Latest session" + ), ) diff --git a/static/css/style.css b/static/css/style.css index 95d39f4..96ec9c5 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -576,6 +576,45 @@ td a:hover { 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; +} + +.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; +} + +.session-filter-select:focus { + border-color: rgba(240, 138, 56, 0.42); + box-shadow: 0 0 0 4px rgba(240, 138, 56, 0.12); +} + +.compact-button { + min-height: 42px; +} + .positive, .negative, .neutral { @@ -770,3 +809,19 @@ td a:hover { height: min(66vh, 680px); } } + +@media (max-width: 860px) { + .leaderboard-header, + .leaderboard-controls { + align-items: start; + justify-items: start; + } + + .session-filter-form { + width: 100%; + } + + .session-filter-select { + width: 100%; + } +} diff --git a/templates/leaderboard.html b/templates/leaderboard.html index 85b9980..13a66ec 100644 --- a/templates/leaderboard.html +++ b/templates/leaderboard.html @@ -5,7 +5,7 @@

All-time results

Leaderboard

-

Track who is up, who is chasing, and how everyone has moved across {{ session_count }} recorded sessions.

+

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 %}.

@@ -30,7 +30,8 @@

Table snapshot

Players tracked{{ leaderboard|length }}
-
Sessions tracked{{ session_count }}
+
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 %}
@@ -38,12 +39,26 @@
-
+

Standings

All-time leaderboard

-

Click any column to sort.

+
+
+ + + {% if selected_session_date %} + Reset + {% endif %} +
+

Click any column to sort.

+
From f23213586320d167babc2f16f458da22c51a51c5 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Mon, 16 Mar 2026 12:10:51 -0700 Subject: [PATCH 06/39] nearly forgot me shebang lines --- app.py | 1 + stats.py | 1 + storage.py | 1 + 3 files changed, 3 insertions(+) diff --git a/app.py b/app.py index 166f7d6..ab26887 100644 --- a/app.py +++ b/app.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python3 from __future__ import annotations import os diff --git a/stats.py b/stats.py index 8235c04..db88e80 100644 --- a/stats.py +++ b/stats.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python3 from __future__ import annotations from collections import defaultdict diff --git a/storage.py b/storage.py index 478fe21..b696604 100644 --- a/storage.py +++ b/storage.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python3 from __future__ import annotations import csv From 515e1a80636d8a639be637d7ce4a55d0ab24be37 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Mon, 16 Mar 2026 16:03:47 -0700 Subject: [PATCH 07/39] add next/prev session links + shorten admin recent feed --- app.py | 18 +++++++++++++++++- templates/session_detail.html | 19 +++++++++++++++---- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/app.py b/app.py index ab26887..7d31d0d 100644 --- a/app.py +++ b/app.py @@ -126,9 +126,25 @@ def session_detail(session_date: str) -> str: flash("That session was not found.", "error") return redirect(url_for("sessions")) + # sessions are in reverse order + idx = sessions.index(target_session) + next_session_idx = idx - 1 + prev_session_idx = idx + 1 + + next_session = None + prev_session = None + + if next_session_idx >= 0: + next_session = sessions[next_session_idx] + + if prev_session_idx < len(sessions): + prev_session = sessions[prev_session_idx] + return render_template( "session_detail.html", session=target_session, + next_session=next_session, + prev_session=prev_session, raw_events=session_events(events, session_date), ) @@ -217,7 +233,7 @@ def admin_dashboard() -> str: events = load_events(DATA_PATH) sessions = build_session_summaries(events) recent_sessions = sessions[:6] - recent_events = list(reversed(events[-20:])) + recent_events = list(reversed(events[-10:])) return render_template( "admin_dashboard.html", recent_sessions=recent_sessions, diff --git a/templates/session_detail.html b/templates/session_detail.html index 398ad95..be66edb 100644 --- a/templates/session_detail.html +++ b/templates/session_detail.html @@ -2,10 +2,21 @@ {% block title %}{{ session.session_date }} · Poker Portal{% endblock %} {% block content %}
-
-

Session detail

-

{{ session.session_date | pretty_date }}

-

Current totals are derived from the event log for this date.

+
+
+

Session detail

+

{{ session.session_date | pretty_date }}

+

Current totals are derived from the event log for this date.

+
+ +
+ {% if prev_session %} + Previous Session + {% endif %} + {% if next_session %} + Next Session + {% endif %} +
From d9685b30cf6a97f7e29ebcb72063e117be79a25c Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Fri, 20 Mar 2026 23:28:22 -0700 Subject: [PATCH 08/39] add rank delta to leaderboard --- app.py | 29 ++++++++++++++-------- static/css/style.css | 25 +++++++++++++++++++ stats.py | 51 +++++++++++++++++++++++++++----------- templates/leaderboard.html | 31 +++++++++++++++-------- 4 files changed, 102 insertions(+), 34 deletions(-) diff --git a/app.py b/app.py index 7d31d0d..72159bc 100644 --- a/app.py +++ b/app.py @@ -7,6 +7,7 @@ from pathlib import Path from flask import Flask, flash, redirect, render_template, request, session, url_for from stats import ( + apply_rank_changes, build_leaderboard, build_session_summaries, cents_to_dollars, @@ -71,22 +72,30 @@ def home() -> str: def leaderboard() -> str: events = load_events(DATA_PATH) all_sessions = build_session_summaries(events) + ordered_sessions = sorted(all_sessions, key=lambda s: s.session_date) + + session_dates = [session.session_date for session in ordered_sessions] selected_session_date = request.args.get("through_session", "").strip() - valid_session_dates = {session.session_date for session in all_sessions} - if selected_session_date not in valid_session_dates: - selected_session_date = "" + if session_dates: + if selected_session_date in session_dates: + cutoff_index = session_dates.index(selected_session_date) + else: + cutoff_index = len(session_dates) - 1 + selected_session = session_dates[cutoff_index] - filtered_sessions = all_sessions - if selected_session_date: - filtered_sessions = [ - session - for session in all_sessions - if session.session_date <= selected_session_date - ] + filtered_sessions = ordered_sessions[: cutoff_index + 1] + previous_sessions = ordered_sessions[:cutoff_index] + else: + filtered_sessions = [] + previous_sessions = [] board = build_leaderboard(filtered_sessions) + previous_board = build_leaderboard(previous_sessions) + board = apply_rank_changes(board, previous_board) + chart_data = cumulative_profit_series(filtered_sessions) + return render_template( "leaderboard.html", leaderboard=board, diff --git a/static/css/style.css b/static/css/style.css index 96ec9c5..b679fa4 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -489,6 +489,31 @@ h2 { 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; diff --git a/stats.py b/stats.py index db88e80..107daf9 100644 --- a/stats.py +++ b/stats.py @@ -71,15 +71,30 @@ class PlayerStats: total_cash_out_cents: int total_net_cents: int roi_pct: float + rank_change: int = 0 +def apply_rank_changes( + current_board: list[PlayerStats], prev_board: list[PlayerStats] +) -> list[PlayerStats]: + prev_ranks = {p.player_name: i for i, p in enumerate(prev_board, start=1)} + + for i, p in enumerate(current_board, start=1): + prev_rank = prev_ranks.get(p.player_name) + + if prev_rank is None: + p.rank_change = 0 + else: + p.rank_change = prev_rank - i + + return current_board + def cents_to_dollars(cents: int) -> str: value = cents / 100 return f"${value:,.2f}" - def safe_date_label(session_date: str) -> str: try: return datetime.strptime(session_date, "%Y-%m-%d").strftime("%b %d, %Y") @@ -87,7 +102,6 @@ def safe_date_label(session_date: str) -> str: return session_date - def color_for_name(name: str, names: list[str]) -> str: try: index = sorted(names, key=str.casefold).index(name) @@ -96,7 +110,6 @@ def color_for_name(name: str, names: list[str]) -> str: return PLAYER_PALETTE[index % len(PLAYER_PALETTE)] - def net_tone(value_cents: int) -> str: if value_cents > 0: return "#22c55e" @@ -105,7 +118,6 @@ def net_tone(value_cents: int) -> str: return "#f59e0b" - def build_session_summaries(events: list[EventRow]) -> list[SessionSummary]: grouped: dict[tuple[str, str], SessionEntry] = {} @@ -141,7 +153,6 @@ def build_session_summaries(events: list[EventRow]) -> list[SessionSummary]: return sessions - def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]: player_entries: dict[str, list[SessionEntry]] = defaultdict(list) for session in sessions: @@ -162,7 +173,9 @@ def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]: break_even_sessions = sessions_played - winning_sessions - losing_sessions win_pct = (winning_sessions / sessions_played * 100) if sessions_played else 0.0 avg_win = round(sum(wins) / len(wins)) if wins else 0 - avg_loss = round(sum(abs(value) for value in losses) / len(losses)) if losses else 0 + avg_loss = ( + round(sum(abs(value) for value in losses) / len(losses)) if losses else 0 + ) biggest_win = max(wins) if wins else 0 biggest_loss = min(losses) if losses else 0 roi_pct = (total_net / total_buy_in * 100) if total_buy_in else 0.0 @@ -193,11 +206,14 @@ def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]: return leaderboard - def cumulative_profit_series(sessions: list[SessionSummary]) -> dict[str, Any]: ordered_sessions = sorted(sessions, key=lambda session: session.session_date) player_names = sorted( - {entry.player_name for session in ordered_sessions for entry in session.entries}, + { + entry.player_name + for session in ordered_sessions + for entry in session.entries + }, key=str.casefold, ) @@ -211,7 +227,11 @@ def cumulative_profit_series(sessions: list[SessionSummary]) -> dict[str, Any]: for session in ordered_sessions: matching_entry = next( - (entry for entry in session.entries if entry.player_name == player_name), + ( + entry + for entry in session.entries + if entry.player_name == player_name + ), None, ) if matching_entry is not None: @@ -241,8 +261,9 @@ def cumulative_profit_series(sessions: list[SessionSummary]) -> dict[str, Any]: return {"labels": labels, "datasets": datasets} - -def player_session_series(sessions: list[SessionSummary], player_name: str) -> dict[str, Any]: +def player_session_series( + sessions: list[SessionSummary], player_name: str +) -> dict[str, Any]: ordered_sessions = sorted(sessions, key=lambda session: session.session_date) labels: list[str] = [] net_values: list[float] = [] @@ -250,7 +271,11 @@ def player_session_series(sessions: list[SessionSummary], player_name: str) -> d running_total = 0 all_player_names = sorted( - {entry.player_name for session in ordered_sessions for entry in session.entries}, + { + entry.player_name + for session in ordered_sessions + for entry in session.entries + }, key=str.casefold, ) @@ -276,11 +301,9 @@ def player_session_series(sessions: list[SessionSummary], player_name: str) -> d } - def session_events(events: list[EventRow], session_date: str) -> list[EventRow]: return [event for event in events if event["session_date"] == session_date] - def unique_player_names(events: list[EventRow]) -> list[str]: return sorted({event["player_name"] for event in events}, key=str.casefold) diff --git a/templates/leaderboard.html b/templates/leaderboard.html index 13a66ec..4c15c24 100644 --- a/templates/leaderboard.html +++ b/templates/leaderboard.html @@ -66,6 +66,7 @@ + @@ -81,6 +82,15 @@ {% for player in leaderboard %} #{{ 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_cash_out_cents | money }} @@ -93,7 +103,7 @@ {% else %} - No data yet. Add the first event from the admin page. + No data yet. Add the first event from the admin page. {% endfor %} @@ -254,15 +264,16 @@ const sortButtons = Array.from(document.querySelectorAll('.sort-button')); const columnIndexMap = { rank: 0, - player: 1, - buyins: 2, - cashouts: 3, - net: 4, - winpct: 5, - roi: 6, - avgwin: 7, - avgloss: 8, - sessions: 9, + rankdelta: 1, + player: 2, + buyins: 3, + cashouts: 4, + net: 5, + winpct: 6, + roi: 7, + avgwin: 8, + avgloss: 9, + sessions: 10, }; function updateRankCells() { From e727e0d78157f276c8a5dff101ca278baf706936 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sat, 21 Mar 2026 00:10:52 -0700 Subject: [PATCH 09/39] add session open/closed state to prevent modifications --- app.py | 46 ++++++++++++++++++++++- static/css/style.css | 68 ++++++++++++++++++++++++++++++++++ stats.py | 65 +++++++++++++++++++++++++------- storage.py | 6 ++- templates/admin_dashboard.html | 38 ++++++++++++++++++- templates/sessions.html | 4 ++ 6 files changed, 210 insertions(+), 17 deletions(-) diff --git a/app.py b/app.py index 72159bc..0efde3b 100644 --- a/app.py +++ b/app.py @@ -204,6 +204,36 @@ def admin_logout() -> str: return redirect(url_for("leaderboard")) +@app.post("/admin/session-state") +def admin_session_state() -> str: + if not is_admin(): + flash("Admin login required.", "error") + return redirect(url_for("admin_login")) + + session_date = request.form.get("session_date", "").strip() + state = request.form.get("state", "").strip() + + if not session_date: + flash("Session date is required.", "error") + return redirect(url_for("admin_dashboard")) + + if state not in {"open", "closed"}: + flash("Invalid session state.", "error") + return redirect(url_for("admin_dashboard")) + + append_event( + DATA_PATH, + session_date=session_date, + player_name="", + event_type="session_open" if state == "open" else "session_close", + amount_cents=0, + note=f"Session marked {state}.", + actor=app.config["ADMIN_USERNAME"], + ) + flash(f"Session marked {state}.", "success") + return redirect(url_for("admin_dashboard")) + + @app.route("/admin", methods=["GET", "POST"]) def admin_dashboard() -> str: if not is_admin(): @@ -227,6 +257,17 @@ def admin_dashboard() -> str: flash("Amount must be a valid number.", "error") return redirect(url_for("admin_dashboard")) + events = load_events(DATA_PATH) + sessions = build_session_summaries(events) + status_by_date = {session.session_date: session.status for session in sessions} + + if status_by_date.get(session_date, "closed") != "open": + flash( + "That session is closed. Open it first before adding player events.", + "error", + ) + return redirect(url_for("admin_dashboard")) + append_event( DATA_PATH, session_date=session_date, @@ -242,10 +283,13 @@ def admin_dashboard() -> str: events = load_events(DATA_PATH) sessions = build_session_summaries(events) recent_sessions = sessions[:6] - recent_events = list(reversed(events[-10:])) + open_sessions = [session for session in sessions if session.status == "open"] + recent_events = list(reversed(events[-20:])) + return render_template( "admin_dashboard.html", recent_sessions=recent_sessions, + open_sessions=open_sessions, recent_events=recent_events, player_names=unique_player_names(events), ) diff --git a/static/css/style.css b/static/css/style.css index b679fa4..1d4d563 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -760,6 +760,74 @@ td a:hover { align-items: stretch; } +.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; +} + +.table-action-button:hover { + background: rgba(255, 255, 255, 0.07); +} + @media (max-width: 980px) { .site-shell { width: min(100% - 24px, 100%); diff --git a/stats.py b/stats.py index 107daf9..5d18234 100644 --- a/stats.py +++ b/stats.py @@ -41,6 +41,11 @@ class SessionEntry: class SessionSummary: session_date: str entries: list[SessionEntry] + status: str = "closed" + + @property + def is_open(self) -> bool: + return self.status == "open" @property def total_buy_in_cents(self) -> int: @@ -120,35 +125,66 @@ def net_tone(value_cents: int) -> str: def build_session_summaries(events: list[EventRow]) -> list[SessionSummary]: grouped: dict[tuple[str, str], SessionEntry] = {} + by_session: dict[str, list[SessionEntry]] = defaultdict(list) + session_status: dict[str, str] = {} + session_dates_seen: set[str] = set() for event in events: - key = (event["session_date"], event["player_name"]) + session_date = event["session_date"] + event_type = event["event_type"] + + if not session_date: + continue + + session_dates_seen.add(session_date) + + if event_type == "session_open": + session_status[session_date] = "open" + continue + + if event_type == "session_close": + session_status[session_date] = "closed" + continue + + player_name = event["player_name"].strip() + if not player_name: + continue + + key = (session_date, player_name) if key not in grouped: grouped[key] = SessionEntry( - session_date=event["session_date"], - player_name=event["player_name"], + session_date=session_date, + player_name=player_name, ) entry = grouped[key] - if event["event_type"] == "buyin": + + if event_type == "buyin": entry.buy_in_cents += event["amount_cents"] - elif event["event_type"] == "cashout": + elif event_type == "cashout": entry.cash_out_cents += event["amount_cents"] if event["note"]: entry.notes.append(event["note"]) - by_session: dict[str, list[SessionEntry]] = defaultdict(list) for entry in grouped.values(): by_session[entry.session_date].append(entry) - sessions = [ - SessionSummary( - session_date=session_date, - entries=sorted(entries, key=lambda entry: entry.player_name.casefold()), + sessions: list[SessionSummary] = [] + for session_date in session_dates_seen: + entries = sorted( + by_session.get(session_date, []), + key=lambda entry: entry.player_name.casefold(), ) - for session_date, entries in by_session.items() - ] + + sessions.append( + SessionSummary( + session_date=session_date, + entries=entries, + status=session_status.get(session_date, "closed"), + ) + ) + sessions.sort(key=lambda session: session.session_date, reverse=True) return sessions @@ -306,4 +342,7 @@ def session_events(events: list[EventRow], session_date: str) -> list[EventRow]: def unique_player_names(events: list[EventRow]) -> list[str]: - return sorted({event["player_name"] for event in events}, key=str.casefold) + return sorted( + {event["player_name"] for event in events if event["player_name"].strip()}, + key=str.casefold, + ) diff --git a/storage.py b/storage.py index b696604..d2d1ee4 100644 --- a/storage.py +++ b/storage.py @@ -30,7 +30,7 @@ class EventRow(TypedDict): actor: str -VALID_EVENT_TYPES = {"buyin", "cashout", "note"} +VALID_EVENT_TYPES = {"buyin", "cashout", "note", "session_open", "session_close"} def ensure_data_file(csv_path: Path) -> None: @@ -63,7 +63,9 @@ def load_events(csv_path: Path) -> list[EventRow]: ) ) - events.sort(key=lambda event: (event["session_date"], event["created_at"], event["id"])) + events.sort( + key=lambda event: (event["session_date"], event["created_at"], event["id"]) + ) return events diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html index 52c2715..6ee5f48 100644 --- a/templates/admin_dashboard.html +++ b/templates/admin_dashboard.html @@ -51,6 +51,21 @@ +
+

Session state

+

Open or close a session

+ + + +
+ + +
+
+
@@ -67,15 +82,36 @@ Players Buy-ins Cash-outs + Status + Action {% for session in recent_sessions %} - {{ session.session_date | pretty_date }} + + + {{ session.session_date | pretty_date }} + + {{ session.entries|length }} {{ session.total_buy_in_cents | money }} {{ session.total_cash_out_cents | money }} + + + {{ session.status }} + + + +
+ + {% if session.status == "open" %} + + {% else %} + + {% endif %} +
+ {% else %} diff --git a/templates/sessions.html b/templates/sessions.html index e4437bb..61a51f8 100644 --- a/templates/sessions.html +++ b/templates/sessions.html @@ -21,6 +21,7 @@ Date + Status Players Total Buy-ins Total Cash-outs @@ -31,6 +32,9 @@ {% for session in sessions %} {{ session.session_date | pretty_date }} + + {{ session.status }} + {{ session.entries|length }} {{ session.total_buy_in_cents | money }} {{ session.total_cash_out_cents | money }} From 8452bdae73ed53ad77de44f85046700cb7b7e6e2 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sat, 21 Mar 2026 00:34:32 -0700 Subject: [PATCH 10/39] player win/loss streaks --- static/css/style.css | 4 ++ stats.py | 79 ++++++++++++++++++++++++++++++ templates/player_detail.html | 94 +++++++++++++++++++++++++++++++++++- 3 files changed, 176 insertions(+), 1 deletion(-) diff --git a/static/css/style.css b/static/css/style.css index 1d4d563..1a9470b 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -828,6 +828,10 @@ td a:hover { background: rgba(255, 255, 255, 0.07); } +.chart-frame-small { + min-height: 260px; +} + @media (max-width: 980px) { .site-shell { width: min(100% - 24px, 100%); diff --git a/stats.py b/stats.py index 5d18234..9092688 100644 --- a/stats.py +++ b/stats.py @@ -76,6 +76,14 @@ class PlayerStats: total_cash_out_cents: int total_net_cents: int roi_pct: float + current_win_streak: int + current_loss_streak: int + longest_win_streak: int + longest_loss_streak: int + best_session_date: str | None + best_session_net_cents: int + worst_session_date: str | None + worst_session_net_cents: int rank_change: int = 0 @@ -189,6 +197,68 @@ def build_session_summaries(events: list[EventRow]) -> list[SessionSummary]: return sessions +def summarize_player_runs(entries: list[SessionEntry]) -> dict[str, int | str | None]: + ordered_entries = sorted(entries, key=lambda entry: entry.session_date) + + longest_win_streak = 0 + longest_loss_streak = 0 + current_run_wins = 0 + current_run_losses = 0 + + best_entry: SessionEntry | None = None + worst_entry: SessionEntry | None = None + + for entry in ordered_entries: + net = entry.net_cents + + if best_entry is None or net > best_entry.net_cents: + best_entry = entry + + if worst_entry is None or net < worst_entry.net_cents: + worst_entry = entry + + if net > 0: + current_run_wins += 1 + current_run_losses = 0 + elif net < 0: + current_run_losses += 1 + current_run_wins = 0 + else: + current_run_wins = 0 + current_run_losses = 0 + + longest_win_streak = max(longest_win_streak, current_run_wins) + longest_loss_streak = max(longest_loss_streak, current_run_losses) + + current_win_streak = 0 + current_loss_streak = 0 + + for entry in reversed(ordered_entries): + net = entry.net_cents + + if net > 0: + if current_loss_streak > 0: + break + current_win_streak += 1 + elif net < 0: + if current_win_streak > 0: + break + current_loss_streak += 1 + else: + break + + return { + "current_win_streak": current_win_streak, + "current_loss_streak": current_loss_streak, + "longest_win_streak": longest_win_streak, + "longest_loss_streak": longest_loss_streak, + "best_session_date": best_entry.session_date if best_entry else None, + "best_session_net_cents": best_entry.net_cents if best_entry else 0, + "worst_session_date": worst_entry.session_date if worst_entry else None, + "worst_session_net_cents": worst_entry.net_cents if worst_entry else 0, + } + + def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]: player_entries: dict[str, list[SessionEntry]] = defaultdict(list) for session in sessions: @@ -198,6 +268,7 @@ def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]: leaderboard: 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] total_buy_in = sum(entry.buy_in_cents for entry in entries) @@ -232,6 +303,14 @@ def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]: total_cash_out_cents=total_cash_out, total_net_cents=total_net, roi_pct=roi_pct, + current_win_streak=run_summary["current_win_streak"], + current_loss_streak=run_summary["current_loss_streak"], + longest_win_streak=run_summary["longest_win_streak"], + longest_loss_streak=run_summary["longest_loss_streak"], + best_session_date=run_summary["best_session_date"], + best_session_net_cents=run_summary["best_session_net_cents"], + worst_session_date=run_summary["worst_session_date"], + worst_session_net_cents=run_summary["worst_session_net_cents"], ) ) diff --git a/templates/player_detail.html b/templates/player_detail.html index 2dc144e..18fad5c 100644 --- a/templates/player_detail.html +++ b/templates/player_detail.html @@ -16,9 +16,24 @@
Buy-ins{{ player.total_buy_in_cents | money }}
Cash-outs{{ player.total_cash_out_cents | money }}
Sessions{{ player.sessions_played }}
+
+ Current Streak + + {% if player.current_win_streak > 0 %} + W{{ player.current_win_streak }} + {% elif player.current_loss_streak > 0 %} + L{{ player.current_loss_streak }} + {% else %} + - + {% endif %} + +
+ +
Longest win streak{{ player.longest_win_streak }}
+
Longest loss streak{{ player.longest_loss_streak }}
-
+
@@ -30,6 +45,9 @@
+
+ +
@@ -41,6 +59,17 @@
+
+
+
+

Breakdown

+

Result mix

+
+
+
+ +
+
@@ -60,6 +89,28 @@ Average Loss{{ player.avg_loss_cents | money }} Biggest Win{{ player.biggest_win_cents | money }} Biggest Loss{{ player.biggest_loss_cents | money }} + Current Win Streak{{ player.current_win_streak }} + Current Loss Streak{{ player.current_loss_streak }} + Longest Win Streak{{ player.longest_win_streak }} + Longest Loss Streak{{ player.longest_loss_streak }} + + Best Session + + {{ player.best_session_net_cents | money }} + {% if player.best_session_date %} + on {{ player.best_session_date | pretty_date }} + {% endif %} + + + + Worst Session + + {{ player.worst_session_net_cents | money }} + {% if player.worst_session_date %} + on {{ player.worst_session_date | pretty_date }} + {% endif %} + +
@@ -138,4 +189,45 @@ }); } + + {% endblock %} From 1f93f0f93c71df294b03a229adc01dee6bbf34f8 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sat, 21 Mar 2026 00:45:02 -0700 Subject: [PATCH 11/39] paid status --- static/css/style.css | 48 ++++++++++++++++++++++++++++++++++ stats.py | 21 +++++++++++++++ storage.py | 9 ++++++- templates/admin_dashboard.html | 5 ++-- templates/session_detail.html | 18 +++++++++++-- 5 files changed, 96 insertions(+), 5 deletions(-) diff --git a/static/css/style.css b/static/css/style.css index 1a9470b..727b1be 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -832,6 +832,54 @@ td a:hover { min-height: 260px; } +.status-none { + color: #94a3b8; + background: rgba(148, 163, 184, 0.1); + border: 1px solid rgba(148, 163, 184, 0.2); +} + +.status-unpaid { + color: #f59e0b; + background: rgba(245, 158, 11, 0.12); + border: 1px solid rgba(245, 158, 11, 0.24); +} + +.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-note { + color: #cbd5e1; + background: rgba(203, 213, 225, 0.08); + border: 1px solid rgba(203, 213, 225, 0.14); +} + @media (max-width: 980px) { .site-shell { width: min(100% - 24px, 100%); diff --git a/stats.py b/stats.py index 9092688..f742373 100644 --- a/stats.py +++ b/stats.py @@ -30,12 +30,31 @@ class SessionEntry: player_name: str buy_in_cents: int = 0 cash_out_cents: int = 0 + paid_cents: int = 0 notes: list[str] = field(default_factory=list) @property def net_cents(self) -> int: return self.cash_out_cents - self.buy_in_cents + @property + def payout_due_cents(self) -> int: + return max(self.cash_out_cents, 0) + + @property + def payout_remaining_cents(self) -> int: + return max(self.payout_due_cents - self.paid_cents, 0) + + @property + def payout_status(self) -> str: + if self.payout_due_cents <= 0: + return "none" + if self.paid_cents <= 0: + return "unpaid" + if self.payout_remaining_cents <= 0: + return "paid" + return "partial" + @dataclass class SessionSummary: @@ -171,6 +190,8 @@ def build_session_summaries(events: list[EventRow]) -> list[SessionSummary]: entry.buy_in_cents += event["amount_cents"] elif event_type == "cashout": entry.cash_out_cents += event["amount_cents"] + elif event_type == "paid": + entry.paid_cents += event["amount_cents"] if event["note"]: entry.notes.append(event["note"]) diff --git a/storage.py b/storage.py index d2d1ee4..985445f 100644 --- a/storage.py +++ b/storage.py @@ -30,7 +30,14 @@ class EventRow(TypedDict): actor: str -VALID_EVENT_TYPES = {"buyin", "cashout", "note", "session_open", "session_close"} +VALID_EVENT_TYPES = { + "buyin", + "cashout", + "paid", + "note", + "session_open", + "session_close", +} def ensure_data_file(csv_path: Path) -> None: diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html index 6ee5f48..029e231 100644 --- a/templates/admin_dashboard.html +++ b/templates/admin_dashboard.html @@ -34,6 +34,7 @@ @@ -45,7 +46,7 @@ @@ -135,7 +136,7 @@
{{ event.player_name }} - {{ event.event_type }} + {{ event.event_type }}

{{ event.amount_cents | money }}

{{ event.session_date | pretty_date }}

diff --git a/templates/session_detail.html b/templates/session_detail.html index be66edb..7a74b90 100644 --- a/templates/session_detail.html +++ b/templates/session_detail.html @@ -35,6 +35,9 @@ Player Buy-in Cash-out + Paid + Remaining + Status Net Notes @@ -45,7 +48,18 @@ {{ entry.player_name }} {{ entry.buy_in_cents | money }} {{ entry.cash_out_cents | money }} - {{ entry.net_cents | money }} + {{ entry.paid_cents | money }} + + {{ entry.payout_remaining_cents | money }} + + + + {{ entry.payout_status }} + + + + {{ entry.net_cents | money }} + {% if entry.notes %}
    @@ -76,7 +90,7 @@
    {{ event.player_name }} - {{ event.event_type }} + {{ event.event_type }}

    {{ event.amount_cents | money }}

    {% if event.note %} From ba72ead2295b63a0ee83390a001d34c72686e9b9 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sat, 21 Mar 2026 01:06:31 -0700 Subject: [PATCH 12/39] imrpoved session details page --- app.py | 2 + static/css/style.css | 106 +++++++++++++++++++++++++++- stats.py | 26 +++++++ templates/session_detail.html | 128 ++++++++++++++++++++++++++++------ 4 files changed, 238 insertions(+), 24 deletions(-) diff --git a/app.py b/app.py index 0efde3b..a7fbbb2 100644 --- a/app.py +++ b/app.py @@ -14,6 +14,7 @@ from stats import ( cumulative_profit_series, player_session_series, safe_date_label, + session_breakdown_series, session_events, unique_player_names, ) @@ -155,6 +156,7 @@ def session_detail(session_date: str) -> str: next_session=next_session, prev_session=prev_session, raw_events=session_events(events, session_date), + chart_data=session_breakdown_series(target_session), ) diff --git a/static/css/style.css b/static/css/style.css index 727b1be..f973944 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -755,11 +755,102 @@ td a:hover { } .leaderboard-layout, -.session-layout, .admin-layout { align-items: stretch; } +.session-layout { + align-items: stretch; + grid-template-columns: minmax(0, 1.45fr) minmax(320px, 0.85fr); +} + +.results-panel, +.chart-panel { + display: flex; + flex-direction: column; + height: 430px; + min-height: 430px; + max-height: 430px; +} + +.results-panel .table-scroll { + flex: 1; + min-height: 0; + overflow-x: auto; + overflow-y: auto; + padding-right: 0.25rem; + + scrollbar-gutter: stable; + scrollbar-width: auto; + scrollbar-color: rgba(255, 160, 64, 0.88) rgba(255, 255, 255, 0.08); +} + +.results-panel .table-scroll::-webkit-scrollbar { + width: 14px; + height: 12px; +} + +.results-panel .table-scroll::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.06); + border-radius: 999px; +} + +.results-panel .table-scroll::-webkit-scrollbar-thumb { + background: linear-gradient( + 180deg, + rgba(255, 186, 110, 0.95), + rgba(255, 136, 48, 0.95) + ); + border-radius: 999px; + border: 2px solid rgba(10, 12, 18, 0.9); + box-shadow: + 0 0 0 1px rgba(255, 170, 90, 0.18), + 0 0 12px rgba(255, 140, 60, 0.18); +} + +.results-panel .table-scroll::-webkit-scrollbar-thumb:hover { + background: linear-gradient( + 180deg, + rgba(255, 200, 130, 1), + rgba(255, 150, 60, 1) + ); +} + +.results-panel .table-scroll::-webkit-scrollbar-corner { + background: rgba(255, 255, 255, 0.04); +} + +.chart-panel .chart-frame { + flex: 1; + min-height: 0; + height: auto; +} + +.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: 15rem; + width: 18rem; + 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); +} + .button-row { display: flex; gap: 0.75rem; @@ -880,6 +971,19 @@ td a:hover { 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; +} + @media (max-width: 980px) { .site-shell { width: min(100% - 24px, 100%); diff --git a/stats.py b/stats.py index f742373..9ebe6c1 100644 --- a/stats.py +++ b/stats.py @@ -78,6 +78,18 @@ class SessionSummary: def total_net_cents(self) -> int: return sum(entry.net_cents for entry in self.entries) + @property + def total_payout_due_cents(self) -> int: + return sum(entry.payout_due_cents for entry in self.entries) + + @property + def total_paid_cents(self) -> int: + return sum(entry.paid_cents for entry in self.entries) + + @property + def total_remaining_cents(self) -> int: + return sum(entry.payout_remaining_cents for entry in self.entries) + @dataclass class PlayerStats: @@ -437,6 +449,20 @@ def player_session_series( } +def session_breakdown_series(session: SessionSummary) -> dict[str, Any]: + ordered_entries = sorted( + session.entries, + key=lambda entry: (entry.net_cents, entry.player_name.casefold()), + reverse=True, + ) + + return { + "labels": [entry.player_name for entry in ordered_entries], + "net_values": [round(entry.net_cents / 100, 2) for entry in ordered_entries], + "net_colors": [net_tone(entry.net_cents) for entry in ordered_entries], + } + + def session_events(events: list[EventRow], session_date: str) -> list[EventRow]: return [event for event in events if event["session_date"] == session_date] diff --git a/templates/session_detail.html b/templates/session_detail.html index 7a74b90..5fae13f 100644 --- a/templates/session_detail.html +++ b/templates/session_detail.html @@ -20,16 +20,35 @@
+
+
+ Total cash-out due + {{ session.total_payout_due_cents | money }} +
+ +
+ Total paid + {{ session.total_paid_cents | money }} +
+ +
+ Total remaining + + {{ session.total_remaining_cents | money }} + +
+
+
-
+

Results

Player totals

-
- +
+
@@ -49,7 +68,7 @@ - + + - - @@ -71,17 +71,17 @@ - - - + + @@ -70,6 +83,7 @@ + - + From d7543ea13d19737fb17befd0cdafaf6a4bf40f66 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sun, 22 Mar 2026 13:52:59 -0700 Subject: [PATCH 21/39] implement rollover tracking + simple view session summary --- app.py | 150 ++++++++++++++++++++++++--------- static/css/style.css | 15 ++++ stats.py | 139 ++++++++++++++++++++++-------- storage.py | 9 +- templates/admin_dashboard.html | 26 ++++-- templates/player_detail.html | 8 +- templates/session_detail.html | 112 ++++++++++++++++++------ templates/sessions.html | 2 +- 8 files changed, 348 insertions(+), 113 deletions(-) diff --git a/app.py b/app.py index d8fd051..70ae2af 100644 --- a/app.py +++ b/app.py @@ -28,6 +28,7 @@ from stats import ( safe_date_label, session_breakdown_series, session_events, + session_label, unique_player_names, ) from storage import append_event, ensure_data_file, load_events @@ -52,6 +53,22 @@ def load_local_env(env_path: Path) -> None: os.environ.setdefault(key, value) +def next_session_id(sessions, session_date: str) -> str: + matching = [session for session in sessions if session.session_date == session_date] + + highest = 0 + for s in matching: + if s.session_id == session_date: + highest = max(highest, 1) + continue + + suffix = s.session_id.replace(f"{session_date}-", "") + if suffix.isdigit(): + highest = max(highest, int(suffix)) + + return f"{session_date}-{highest + 1:02d}" + + load_local_env(ENV_PATH) app = Flask(__name__) @@ -126,21 +143,26 @@ def leaderboard() -> str: @app.get("/sessions") -def sessions() -> str: +def sessions(): events = load_events(DATA_PATH) - session_summaries = build_session_summaries(events) - return render_template("sessions.html", sessions=session_summaries) + sessions = build_session_summaries(events) + + return render_template( + "sessions.html", + sessions=sessions, + session_label=session_label, + ) -@app.get("/sessions/") -def session_detail(session_date: str) -> str: +@app.get("/sessions/") +def session_detail(session_id: str) -> str: events = load_events(DATA_PATH) sessions = build_session_summaries(events) target_session = next( ( session_summary for session_summary in sessions - if session_summary.session_date == session_date + if session_summary.session_id == session_id ), None, ) @@ -148,27 +170,33 @@ def session_detail(session_date: str) -> str: flash("That session was not found.", "error") return redirect(url_for("sessions")) - # sessions are in reverse order - idx = sessions.index(target_session) - next_session_idx = idx - 1 - prev_session_idx = idx + 1 + target_index = next( + ( + index + for index, session in enumerate(sessions) + if session.session_id == session_id + ), + None, + ) - next_session = None - prev_session = None + if target_index is None: + flash("That session was not found.", "error") + return redirect(url_for("sessions")) - if next_session_idx >= 0: - next_session = sessions[next_session_idx] - - if prev_session_idx < len(sessions): - prev_session = sessions[prev_session_idx] + target_session = sessions[target_index] + prev_session = ( + sessions[target_index + 1] if target_index < len(sessions) - 1 else None + ) + next_session = sessions[target_index - 1] if target_index > 0 else None return render_template( "session_detail.html", session=target_session, next_session=next_session, prev_session=prev_session, - raw_events=session_events(events, session_date), + raw_events=session_events(events, session_id), chart_data=session_breakdown_series(target_session), + session_label=session_label, ) @@ -224,11 +252,16 @@ def admin_session_state() -> str: flash("Admin login required.", "error") return redirect(url_for("admin_login")) - session_date = request.form.get("session_date", "").strip() + session_id = request.form.get("session_id", "").strip() state = request.form.get("state", "").strip() - if not session_date: - flash("Session date is required.", "error") + events = load_events(DATA_PATH) + sessions = build_session_summaries(events) + by_id = {session.session_id: session for session in sessions} + target = by_id.get(session_id) + + if target is None: + flash("Session not found.", "error") return redirect(url_for("admin_dashboard")) if state not in {"open", "closed"}: @@ -237,13 +270,15 @@ def admin_session_state() -> str: append_event( DATA_PATH, - session_date=session_date, + session_id=target.session_id, + session_date=target.session_date, + amount_cents=0, player_name="", event_type="session_open" if state == "open" else "session_close", - amount_cents=0, note=f"Session marked {state}.", actor=app.config["ADMIN_USERNAME"], ) + flash(f"Session marked {state}.", "success") return redirect(url_for("admin_dashboard")) @@ -329,64 +364,97 @@ def admin_import_csv(): return redirect(url_for("admin_dashboard")) -@app.route("/admin", methods=["GET", "POST"]) -def admin_dashboard() -> str: +@app.post("/admin/open-session") +def admin_open_session() -> str: if not is_admin(): flash("Admin login required.", "error") return redirect(url_for("admin_login")) + session_date = request.form.get("session_date", "").strip() + if not session_date: + flash("Session date is required.", "error") + return redirect(url_for("admin_dashboard")) + + events = load_events(DATA_PATH) + sessions = build_session_summaries(events) + session_id = next_session_id(sessions, session_date) + + append_event( + DATA_PATH, + session_id=session_id, + session_date=session_date, + amount_cents=0, + player_name="", + event_type="session_open", + note="Session opened.", + actor=app.config["ADMIN_USERNAME"], + ) + + flash(f"Opened session {session_id}.", "success") + return redirect(url_for("admin_dashboard")) + + +@app.route("/admin", methods=["GET", "POST"]) +def admin_dashboard(): + if not is_admin(): + return redirect(url_for("admin_login")) + if request.method == "POST": - session_date = request.form.get("session_date", "").strip() + session_id = request.form.get("session_id", "").strip() player_name = request.form.get("player_name", "").strip() event_type = request.form.get("event_type", "").strip() - amount_raw = request.form.get("amount", "0").strip() note = request.form.get("note", "").strip() - if not session_date or not player_name or not event_type: - flash("Session date, player name, and event type are required.", "error") - return redirect(url_for("admin_dashboard")) - + amount_raw = request.form.get("amount", "0").strip() try: - amount_cents = 0 if event_type == "note" else round(float(amount_raw) * 100) + amount_cents = int(round(float(amount_raw) * 100)) except ValueError: - flash("Amount must be a valid number.", "error") + flash("Amount must be a number.", "error") return redirect(url_for("admin_dashboard")) events = load_events(DATA_PATH) sessions = build_session_summaries(events) - status_by_date = {session.session_date: session.status for session in sessions} + by_id = {session.session_id: session for session in sessions} + target = by_id.get(session_id) - if status_by_date.get(session_date, "closed") != "open": + if target is None: + flash("Select a valid open session.", "error") + return redirect(url_for("admin_dashboard")) + + if target.status != "open": flash( - "That session is closed. Open it first before adding player events.", + "That session is closed. Reopen it first if you need to add events.", "error", ) return redirect(url_for("admin_dashboard")) append_event( DATA_PATH, - session_date=session_date, + session_id=target.session_id, + session_date=target.session_date, player_name=player_name, event_type=event_type, amount_cents=amount_cents, note=note, actor=app.config["ADMIN_USERNAME"], ) - flash("Event added to the ledger.", "success") + + flash("Event added.", "success") return redirect(url_for("admin_dashboard")) events = load_events(DATA_PATH) sessions = build_session_summaries(events) - recent_sessions = sessions[:6] open_sessions = [session for session in sessions if session.status == "open"] - recent_events = list(reversed(events[-8:])) + recent_sessions = sessions[:8] + recent_events = list(reversed(events[-20:])) return render_template( "admin_dashboard.html", - recent_sessions=recent_sessions, open_sessions=open_sessions, + recent_sessions=recent_sessions, recent_events=recent_events, player_names=unique_player_names(events), + session_label=session_label, ) diff --git a/static/css/style.css b/static/css/style.css index 89126f9..412cd27 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1054,6 +1054,21 @@ td a:hover { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.table-toolbar { + display: flex; + gap: 0.65rem; + margin-bottom: 1rem; +} + +.table-view-toggle.is-active { + background: rgba(255, 166, 77, 0.12); + border-color: rgba(255, 166, 77, 0.28); +} + +.session-results-table.compact .detail-col { + display: none; +} + @media (max-width: 1100px) { .payout-summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/stats.py b/stats.py index 4f7ce14..0fc8c3d 100644 --- a/stats.py +++ b/stats.py @@ -26,17 +26,20 @@ PLAYER_PALETTE = [ @dataclass class SessionEntry: + session_id: str session_date: str player_name: str buy_in_cents: int = 0 front_cents: int = 0 cash_out_cents: int = 0 paid_cents: int = 0 + rollover_in_cents: int = 0 + rollover_out_cents: int = 0 notes: list[str] = field(default_factory=list) @property def invested_cents(self) -> int: - return self.buy_in_cents + self.front_cents + return self.buy_in_cents + self.front_cents + self.rollover_in_cents @property def net_cents(self) -> int: @@ -47,31 +50,45 @@ class SessionEntry: return max(self.cash_out_cents - self.front_cents, 0) @property - def player_owes_cents(self) -> int: - return max(self.front_cents - self.cash_out_cents, 0) + def payout_remaining_cents(self) -> int: + return max(self.payout_due_cents - self.paid_cents - self.rollover_out_cents, 0) @property - def payout_remaining_cents(self) -> int: - return max(self.payout_due_cents - self.paid_cents, 0) + def gross_payout_cents(self) -> int: + 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.front_cents - self.cash_out_cents, 0) @property def payout_status(self) -> str: if self.player_owes_cents > 0: return "owes" - if self.payout_due_cents <= 0: + if self.gross_payout_cents <= 0: return "none" - if self.paid_cents <= 0: - return "unpaid" - if self.payout_remaining_cents <= 0: + if self.current_due_cents <= 0: return "paid" + if self.settled_cents <= 0: + return "unpaid" return "partial" @dataclass class SessionSummary: + session_id: str session_date: str entries: list[SessionEntry] status: str = "closed" + opened_at: str = "" @property def is_open(self) -> bool: @@ -85,6 +102,10 @@ class SessionSummary: def total_front_cents(self) -> int: return sum(entry.front_cents for entry in self.entries) + @property + def total_rollover_in_cents(self) -> int: + return sum(entry.rollover_in_cents for entry in self.entries) + @property def total_invested_cents(self) -> int: return sum(entry.invested_cents for entry in self.entries) @@ -93,18 +114,10 @@ class SessionSummary: def total_cash_out_cents(self) -> int: return sum(entry.cash_out_cents for entry in self.entries) - @property - def total_paid_cents(self) -> int: - return sum(entry.paid_cents for entry in self.entries) - @property def total_payout_due_cents(self) -> int: return sum(entry.payout_due_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) - @property def total_remaining_cents(self) -> int: return sum(entry.payout_remaining_cents for entry in self.entries) @@ -113,6 +126,26 @@ class SessionSummary: def total_net_cents(self) -> int: return sum(entry.net_cents for entry in self.entries) + @property + def total_gross_payout_cents(self) -> int: + return sum(entry.gross_payout_cents for entry in self.entries) + + @property + def total_paid_cents(self) -> int: + return sum(entry.paid_cents for entry in self.entries) + + @property + def total_rollover_out_cents(self) -> int: + 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) + + @property + def total_player_owes_cents(self) -> int: + return sum(entry.player_owes_cents for entry in self.entries) + @dataclass class PlayerStats: @@ -128,8 +161,11 @@ class PlayerStats: biggest_loss_cents: int total_buy_in_cents: int total_front_cents: int + total_rollover_in_cents: int total_invested_cents: int total_cash_out_cents: int + total_paid_cents: int + total_rollover_out_cents: int total_net_cents: int roi_pct: float current_win_streak: int @@ -164,11 +200,21 @@ def cents_to_dollars(cents: int) -> str: return f"${value:,.2f}" -def safe_date_label(session_date: str) -> str: +def safe_date_label(raw_date: str) -> str: try: - return datetime.strptime(session_date, "%Y-%m-%d").strftime("%b %d, %Y") + return datetime.strptime(raw_date, "%Y-%m-%d").strftime("%b %d, %Y") except ValueError: - return session_date + return raw_date + + +def session_label(session: SessionSummary) -> str: + if session.session_id == session.session_date: + return safe_date_label(session.session_date) + + suffix = session.session_id.replace(f"{session.session_date}-", "") + if suffix.isdigit(): + return f"{safe_date_label(session.session_date)} · S{int(suffix)}" + return f"{safe_date_label(session.session_date)} · {suffix}" def color_for_name(name: str, names: list[str]) -> str: @@ -191,32 +237,36 @@ def build_session_summaries(events: list[EventRow]) -> list[SessionSummary]: grouped: dict[tuple[str, str], SessionEntry] = {} by_session: dict[str, list[SessionEntry]] = defaultdict(list) session_status: dict[str, str] = {} - session_dates_seen: set[str] = set() + session_dates: dict[str, str] = {} + session_opened_at: dict[str, str] = {} for event in events: - session_date = event["session_date"] - event_type = event["event_type"] + session_id = event["session_id"].strip() or event["session_date"].strip() + session_date = event["session_date"].strip() + event_type = event["event_type"].strip() - if not session_date: + if not session_id or not session_date: continue - session_dates_seen.add(session_date) + session_dates[session_id] = session_date + session_opened_at.setdefault(session_id, event["created_at"]) if event_type == "session_open": - session_status[session_date] = "open" + session_status[session_id] = "open" continue if event_type == "session_close": - session_status[session_date] = "closed" + session_status[session_id] = "closed" continue player_name = event["player_name"].strip() if not player_name: continue - key = (session_date, player_name) + key = (session_id, player_name) if key not in grouped: grouped[key] = SessionEntry( + session_id=session_id, session_date=session_date, player_name=player_name, ) @@ -227,33 +277,46 @@ def build_session_summaries(events: list[EventRow]) -> list[SessionSummary]: entry.buy_in_cents += event["amount_cents"] elif event_type == "front": entry.front_cents += event["amount_cents"] + elif event_type == "rollover_in": + entry.rollover_in_cents += event["amount_cents"] elif event_type == "cashout": entry.cash_out_cents += event["amount_cents"] elif event_type == "paid": entry.paid_cents += event["amount_cents"] + elif event_type == "rollover_out": + entry.rollover_out_cents += event["amount_cents"] if event["note"]: entry.notes.append(event["note"]) for entry in grouped.values(): - by_session[entry.session_date].append(entry) + by_session[entry.session_id].append(entry) sessions: list[SessionSummary] = [] - for session_date in session_dates_seen: + for session_id, session_date in session_dates.items(): entries = sorted( - by_session.get(session_date, []), + by_session.get(session_id, []), key=lambda entry: entry.player_name.casefold(), ) sessions.append( SessionSummary( + session_id=session_id, session_date=session_date, entries=entries, - status=session_status.get(session_date, "closed"), + status=session_status.get(session_id, "closed"), + opened_at=session_opened_at.get(session_id, ""), ) ) - sessions.sort(key=lambda session: session.session_date, reverse=True) + sessions.sort( + key=lambda session: ( + session.session_date, + session.opened_at, + session.session_id, + ), + reverse=True, + ) return sessions @@ -346,8 +409,11 @@ 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_rollover_in = sum(entry.rollover_in_cents for entry in entries) total_invested = sum(entry.invested_cents for entry in entries) total_cash_out = sum(entry.cash_out_cents for entry in entries) + total_paid = sum(entry.paid_cents for entry in entries) + total_rollover_out = sum(entry.rollover_out_cents for entry in entries) total_net = sum(entry.net_cents for entry in entries) roi_pct = (total_net / total_invested * 100) if total_invested else 0.0 @@ -365,8 +431,11 @@ def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]: biggest_loss_cents=biggest_loss, total_buy_in_cents=total_buy_in, total_front_cents=total_front, + total_rollover_in_cents=total_rollover_in, total_invested_cents=total_invested, total_cash_out_cents=total_cash_out, + total_paid_cents=total_paid, + total_rollover_out_cents=total_rollover_out, total_net_cents=total_net, roi_pct=roi_pct, current_win_streak=run_summary["current_win_streak"], @@ -496,8 +565,8 @@ def session_breakdown_series(session: SessionSummary) -> dict[str, Any]: } -def session_events(events: list[EventRow], session_date: str) -> list[EventRow]: - return [event for event in events if event["session_date"] == session_date] +def session_events(events: list[EventRow], session_id: str) -> list[EventRow]: + return [event for event in events if event["session_id"] == session_id] def unique_player_names(events: list[EventRow]) -> list[str]: diff --git a/storage.py b/storage.py index b825ab8..f9c6662 100644 --- a/storage.py +++ b/storage.py @@ -10,6 +10,7 @@ from typing import TypedDict CSV_HEADERS = [ "id", "created_at", + "session_id", "session_date", "player_name", "event_type", @@ -22,6 +23,7 @@ CSV_HEADERS = [ class EventRow(TypedDict): id: str created_at: str + session_id: str session_date: str player_name: str event_type: str @@ -32,9 +34,11 @@ class EventRow(TypedDict): VALID_EVENT_TYPES = { "buyin", - "cashout", "front", + "cashout", "paid", + "rollover_in", + "rollover_out", "note", "session_open", "session_close", @@ -62,6 +66,7 @@ def load_events(csv_path: Path) -> list[EventRow]: EventRow( id=row["id"], created_at=row["created_at"], + session_id=row["session_id"], session_date=row["session_date"], player_name=row["player_name"], event_type=row["event_type"], @@ -79,6 +84,7 @@ def load_events(csv_path: Path) -> list[EventRow]: def append_event( csv_path: Path, + session_id: str, session_date: str, player_name: str, event_type: str, @@ -98,6 +104,7 @@ def append_event( event = EventRow( id=str(uuid.uuid4()), created_at=datetime.now(timezone.utc).isoformat(), + session_id=session_id.strip(), session_date=session_date.strip(), player_name=player_name.strip(), event_type=normalized_type, diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html index 5d5f19e..a5042d3 100644 --- a/templates/admin_dashboard.html +++ b/templates/admin_dashboard.html @@ -15,8 +15,15 @@

Add to the ledger

@@ -54,9 +63,9 @@
-
+

Session state

-

Open or close a session

+

Open a new session

- - +
@@ -130,8 +138,8 @@ {% for session in recent_sessions %}
@@ -144,7 +152,7 @@
Player{{ entry.buy_in_cents | money }} {{ entry.cash_out_cents | money }} {{ entry.paid_cents | money }} + {{ entry.payout_remaining_cents | money }} @@ -78,30 +97,93 @@ -
+
-

Audit trail

-

Raw ledger events

+

Breakdown

+

Net by player

-
- {% for event in raw_events | reverse %} -
-
- {{ event.player_name }} - {{ event.event_type }} -
-

{{ event.amount_cents | money }}

- {% if event.note %} -

{{ event.note }}

- {% endif %} -

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

-
- {% else %} -

No raw events recorded.

- {% endfor %} +
+
+ +
+
+
+

Audit trail

+

Raw ledger events

+
+
+
+ {% for event in raw_events | reverse %} +
+
+ {{ event.player_name or "Session" }} + {{ event.event_type }} +
+

{{ event.amount_cents | money }}

+ {% if event.note %} +

{{ event.note }}

+ {% endif %} +

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

+
+ {% else %} +

No raw events recorded.

+ {% endfor %} +
+
+ + {% endblock %} From 0454b78d8d473dde59b46e9655fd32b7f7eec5b3 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sat, 21 Mar 2026 01:18:26 -0700 Subject: [PATCH 13/39] session summary layout upgrade --- static/css/style.css | 97 +++++++----------- templates/session_detail.html | 184 +++++++++++++++++++++++----------- 2 files changed, 165 insertions(+), 116 deletions(-) diff --git a/static/css/style.css b/static/css/style.css index f973944..b4b6f3c 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -755,74 +755,35 @@ td a:hover { } .leaderboard-layout, +.session-layout, .admin-layout { align-items: stretch; } -.session-layout { - align-items: stretch; - grid-template-columns: minmax(0, 1.45fr) minmax(320px, 0.85fr); +.results-panel-full { + display: block; } -.results-panel, -.chart-panel { +.table-scroll-open { + overflow-x: auto; + overflow-y: visible; + max-height: none; + padding-right: 0; +} + +.chart-row { + align-items: stretch; +} + +.chart-panel-compact, +.session-insights-panel { display: flex; flex-direction: column; - height: 430px; - min-height: 430px; - max-height: 430px; } -.results-panel .table-scroll { +.chart-panel-compact .chart-frame-compact { flex: 1; - min-height: 0; - overflow-x: auto; - overflow-y: auto; - padding-right: 0.25rem; - - scrollbar-gutter: stable; - scrollbar-width: auto; - scrollbar-color: rgba(255, 160, 64, 0.88) rgba(255, 255, 255, 0.08); -} - -.results-panel .table-scroll::-webkit-scrollbar { - width: 14px; - height: 12px; -} - -.results-panel .table-scroll::-webkit-scrollbar-track { - background: rgba(255, 255, 255, 0.06); - border-radius: 999px; -} - -.results-panel .table-scroll::-webkit-scrollbar-thumb { - background: linear-gradient( - 180deg, - rgba(255, 186, 110, 0.95), - rgba(255, 136, 48, 0.95) - ); - border-radius: 999px; - border: 2px solid rgba(10, 12, 18, 0.9); - box-shadow: - 0 0 0 1px rgba(255, 170, 90, 0.18), - 0 0 12px rgba(255, 140, 60, 0.18); -} - -.results-panel .table-scroll::-webkit-scrollbar-thumb:hover { - background: linear-gradient( - 180deg, - rgba(255, 200, 130, 1), - rgba(255, 150, 60, 1) - ); -} - -.results-panel .table-scroll::-webkit-scrollbar-corner { - background: rgba(255, 255, 255, 0.04); -} - -.chart-panel .chart-frame { - flex: 1; - min-height: 0; + min-height: 220px; height: auto; } @@ -838,8 +799,8 @@ td a:hover { .session-results-table th:last-child, .session-results-table td:last-child { - min-width: 15rem; - width: 18rem; + min-width: 18rem; + width: 22rem; white-space: normal; } @@ -851,6 +812,24 @@ td a:hover { 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; diff --git a/templates/session_detail.html b/templates/session_detail.html index 5fae13f..a87ca70 100644 --- a/templates/session_detail.html +++ b/templates/session_detail.html @@ -39,75 +39,145 @@ -
-
-
-
-

Results

-

Player totals

-
-
-
- - - - - - - - - - - - - - - {% for entry in session.entries %} - - - - - - - - - - - {% endfor %} - -
PlayerBuy-inCash-outPaidRemainingStatusNetNotes
{{ entry.player_name }}{{ entry.buy_in_cents | money }}{{ entry.cash_out_cents | money }}{{ entry.paid_cents | money }} - {{ entry.payout_remaining_cents | money }} - - - {{ entry.payout_status }} - - - {{ entry.net_cents | money }} - - {% if entry.notes %} -
    - {% for note in entry.notes %} -
  • {{ note }}
  • - {% endfor %} -
- {% else %} - — - {% endif %} -
+
+
+
+

Results

+

Player totals

-
+
+ + + + + + + + + + + + + + + {% for entry in session.entries %} + + + + + + + + + + + {% endfor %} + +
PlayerBuy-inCash-outPaidRemainingStatusNetNotes
+ + {{ entry.player_name }} + + {{ entry.buy_in_cents | money }}{{ entry.cash_out_cents | money }}{{ entry.paid_cents | money }} + {{ entry.payout_remaining_cents | money }} + + + {{ entry.payout_status }} + + + {{ entry.net_cents | money }} + + {% if entry.notes %} +
    + {% for note in entry.notes %} +
  • {{ note }}
  • + {% endfor %} +
+ {% else %} + — + {% endif %} +
+
+
+ +
+

Breakdown

Net by player

-
+
+ +
+
+
+

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 }} +
+
+ Biggest winner + + {% if winners %} + {{ winners | sort(attribute='net_cents', reverse=True) | first | attr('player_name') }} + {% else %} + — + {% endif %} + +
+
+ Best result + + {% if winners %} + {{ (winners | sort(attribute='net_cents', reverse=True) | first).net_cents | money }} + {% else %} + $0.00 + {% endif %} + +
+
+ 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 }} + +
+
+
From 613ffb394ce72473bdac6f17763259b908f7a3a5 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sat, 21 Mar 2026 01:22:44 -0700 Subject: [PATCH 14/39] update reopen css --- static/css/style.css | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/static/css/style.css b/static/css/style.css index b4b6f3c..890d74b 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -963,6 +963,43 @@ td a:hover { text-transform: uppercase; } +.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); +} + @media (max-width: 980px) { .site-shell { width: min(100% - 24px, 100%); From 6c508f4ef3257ddf275e4aa081bef5ede43780a4 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sat, 21 Mar 2026 01:30:42 -0700 Subject: [PATCH 15/39] update admin layout --- app.py | 97 +++++++++++++++- static/css/style.css | 32 ++++++ templates/admin_dashboard.html | 204 +++++++++++++++++++-------------- 3 files changed, 247 insertions(+), 86 deletions(-) diff --git a/app.py b/app.py index a7fbbb2..d8fd051 100644 --- a/app.py +++ b/app.py @@ -1,10 +1,22 @@ #!/usr/bin/env python3 from __future__ import annotations +import csv import os +import shutil +from datetime import datetime, timezone from pathlib import Path -from flask import Flask, flash, redirect, render_template, request, session, url_for +from flask import ( + Flask, + flash, + redirect, + render_template, + request, + send_file, + session, + url_for, +) from stats import ( apply_rank_changes, @@ -236,6 +248,87 @@ def admin_session_state() -> str: return redirect(url_for("admin_dashboard")) +@app.get("/admin/export") +def admin_export_csv(): + if not is_admin(): + flash("Admin login required.", "error") + return redirect(url_for("admin_login")) + + export_name = ( + f"entries_export_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.csv" + ) + + return send_file( + DATA_PATH, + as_attachment=True, + download_name=export_name, + mimetype="text/csv", + ) + + +@app.post("/admin/import") +def admin_import_csv(): + if not is_admin(): + flash("Admin login required.", "error") + return redirect(url_for("admin_login")) + + uploaded_file = request.files.get("csv_file") + if uploaded_file is None or uploaded_file.filename == "": + flash("Choose a CSV file to import.", "error") + return redirect(url_for("admin_dashboard")) + + if not uploaded_file.filename.lower().endswith(".csv"): + flash("Only CSV files are allowed.", "error") + return redirect(url_for("admin_dashboard")) + + try: + uploaded_text = uploaded_file.stream.read().decode("utf-8-sig") + except UnicodeDecodeError: + flash("CSV file must be valid UTF-8 text.", "error") + return redirect(url_for("admin_dashboard")) + + uploaded_lines = uploaded_text.splitlines() + if not uploaded_lines: + 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: + 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) + + with open(DATA_PATH, "w", encoding="utf-8", newline="") as handle: + handle.write(uploaded_text) + + flash(f"CSV imported successfully. Backup created: {backup_name}", "success") + except Exception: + flash("Import failed. Existing CSV was not updated.", "error") + + return redirect(url_for("admin_dashboard")) + + @app.route("/admin", methods=["GET", "POST"]) def admin_dashboard() -> str: if not is_admin(): @@ -286,7 +379,7 @@ def admin_dashboard() -> str: sessions = build_session_summaries(events) recent_sessions = sessions[:6] open_sessions = [session for session in sessions if session.status == "open"] - recent_events = list(reversed(events[-20:])) + recent_events = list(reversed(events[-8:])) return render_template( "admin_dashboard.html", diff --git a/static/css/style.css b/static/css/style.css index 890d74b..ce7fbef 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1000,6 +1000,38 @@ td a:hover { border-color: rgba(255, 166, 77, 0.35); } +.admin-tools-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); +} + +.import-form input[type="file"] { + width: 100%; + margin-top: 0.5rem; + margin-bottom: 1rem; + color: var(--text-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; +} + +.import-form input[type="file"]::file-selector-button:hover { + background: rgba(255, 166, 77, 0.12); + border-color: rgba(255, 166, 77, 0.28); +} + @media (max-width: 980px) { .site-shell { width: min(100% - 24px, 100%); diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html index 029e231..5255252 100644 --- a/templates/admin_dashboard.html +++ b/templates/admin_dashboard.html @@ -52,101 +52,137 @@ -
-

Session state

-

Open or close a session

+
+ +

Session state

+

Open or close a session

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

Recent sessions

-

Current totals

+

Tools

+

Audit CSV

-
- - - - - - - - - - - - - {% for session in recent_sessions %} - - - - - - - - - {% else %} - - - - {% endfor %} - -
DatePlayersBuy-insCash-outsStatusAction
- - {{ session.session_date | pretty_date }} - - {{ session.entries|length }}{{ session.total_buy_in_cents | money }}{{ session.total_cash_out_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 }} +
+
+

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

+ -

{{ event.amount_cents | money }}

-

{{ event.session_date | pretty_date }}

- {% if event.note %}

{{ event.note }}

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

No recent events.

- {% endfor %} +
+ +
+ + +
+ +
+
+
+
+ +
+
+
+

Recent sessions

+

Current totals

+
+
+
+ + + + + + + + + + + + + {% for session in recent_sessions %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
DatePlayersBuy-insCash-outsStatusAction
+ + {{ session.session_date | pretty_date }} + + {{ session.entries|length }}{{ session.total_buy_in_cents | money }}{{ session.total_cash_out_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 %} From 61acc48ed3e241410bb8004e4f180109bfb066b2 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sat, 21 Mar 2026 12:52:16 -0700 Subject: [PATCH 16/39] update order/colors in session details --- templates/session_detail.html | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/templates/session_detail.html b/templates/session_detail.html index a87ca70..df4b296 100644 --- a/templates/session_detail.html +++ b/templates/session_detail.html @@ -33,7 +33,7 @@
Total remaining - + {{ session.total_remaining_cents | money }}
@@ -54,10 +54,10 @@
Player Buy-in Cash-outNetStatus Paid RemainingStatusNet Notes
{{ entry.buy_in_cents | money }} {{ entry.cash_out_cents | money }}{{ entry.paid_cents | money }} - {{ entry.payout_remaining_cents | money }} + + {{ entry.net_cents | money }} {{ entry.payout_status }} - {{ entry.net_cents | money }} + {{ entry.paid_cents | money }} + {{ entry.payout_remaining_cents | money }} {% if entry.notes %} From 5a488daa8f0f57b078969cc68479ca993fa21218 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sat, 21 Mar 2026 13:23:15 -0700 Subject: [PATCH 17/39] update README --- README.md | 89 +++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 66 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 28bb573..88868e0 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,108 @@ # Poker Portal -A small server-rendered poker ledger app for low-stakes no-limit hold'em nights. +A small Flask app I put together for tracking our low-stakes hold'em nights without having to keep a spreadsheet open all the time. + +The idea is pretty simple: public pages for stats and session history, plus a small admin area for recording buy-ins, cash-outs, paid amounts, and notes. The data sits in a CSV audit log, so everything is append-only and easy to follow later. + +## What it does + +- all-time leaderboard +- per-session pages +- per-player stat pages +- admin login for recording events +- open / closed session tracking +- payout tracking with `paid` events +- session and player charts +- CSV import / export from the admin page +- append-only `entries.csv` ledger instead of overwriting old rows ## Stack +Built with a pretty lightweight setup: + - Python - Flask - Jinja templates - Chart.js -- Append-only CSV event ledger - -## Stuff is does - -- All-time leaderboard -- Session archive -- Per player stat pages -- Admin only login for adding ledger events -- Append only `entries.csv` audit trail -- Buy-ins, cash-outs, note-only events, and correction events using negative amounts -- Cumulative profit chart and player charts +- plain CSS +- CSV event log for storage ## Ledger model -The app treats the CSV as an append-only audit trail. +The app treats `data/entries.csv` as the source of truth. -Each row is one event: +Each row is an event, not a final snapshot. Instead of editing an old row, I append another one. That keeps rebuys, corrections, payouts, and session state changes visible in the log instead of hiding them behind edits. + +Current event types: - `buyin` - `cashout` +- `paid` - `note` +- `session_open` +- `session_close` -This means you do not edit old rows when something changes. Instead, you append: +A few examples: -- another buy-in row for a rebuy -- another cash-out row if they cash more later -- a negative amount to correct a mistaken buy-in or cash-out -- a `note` row for bookkeeping context +- another `buyin` for a rebuy +- another `cashout` if chip counts are corrected later +- a `paid` event when someone is actually settled up +- a `note` event for bookkeeping context +- `session_open` / `session_close` to mark whether a game night is still live + +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 +Main file: + `data/entries.csv` +Header: + ```csv id,created_at,session_date,player_name,event_type,amount_cents,note,actor ``` -## Run locally +Amounts are stored in cents to avoid floating-point issues. + +## Running it locally ```bash python -m venv .venv source .venv/bin/activate pip install -r requirements.txt cp .env.example .env -export $(grep -v '^#' .env | xargs) python app.py ``` -## Default environment values +Then open: -The app reads these environment variables: +`http://127.0.0.1:8000` + +## Environment values + +The app reads these from `.env`: - `SECRET_KEY` - `ADMIN_USERNAME` - `ADMIN_PASSWORD` + +Example: + +```env +SECRET_KEY=change-this +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-me +``` + +## Notes + +A few choices here were deliberate: + +- no database for now +- no user accounts, just one admin login +- public-facing stats pages, admin-only controls +- CSV backup before importing a replacement ledger + +If I ever decide to take it further, the first real upgrade would probably be moving the storage layer to SQLite while keeping the rest of the app roughly the same. From c4df0dbcbe42d78f0eb8ac36122f605f378b2611 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sat, 21 Mar 2026 14:53:47 -0700 Subject: [PATCH 18/39] front chips logic --- static/css/style.css | 34 +++++++++++++++++++ stats.py | 61 ++++++++++++++++++++++++++-------- storage.py | 3 +- templates/admin_dashboard.html | 3 +- templates/session_detail.html | 20 +++++++++-- 5 files changed, 102 insertions(+), 19 deletions(-) diff --git a/static/css/style.css b/static/css/style.css index ce7fbef..89126f9 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -322,6 +322,10 @@ h2 { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.four-col { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + .stack-gap { display: grid; gap: 18px; @@ -944,6 +948,12 @@ td a:hover { 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-note { color: #cbd5e1; background: rgba(203, 213, 225, 0.08); @@ -963,6 +973,12 @@ td a:hover { text-transform: uppercase; } +.status-owes { + color: #ef4444; + background: rgba(239, 68, 68, 0.12); + border: 1px solid rgba(239, 68, 68, 0.24); +} + .inline-action-form { margin: 0; } @@ -1032,6 +1048,24 @@ td a:hover { border-color: rgba(255, 166, 77, 0.28); } +.payout-summary-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(3, minmax(0, 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%); diff --git a/stats.py b/stats.py index 9ebe6c1..4f7ce14 100644 --- a/stats.py +++ b/stats.py @@ -29,17 +29,26 @@ class SessionEntry: session_date: str player_name: str buy_in_cents: int = 0 + front_cents: int = 0 cash_out_cents: int = 0 paid_cents: int = 0 notes: list[str] = field(default_factory=list) + @property + def invested_cents(self) -> int: + return self.buy_in_cents + self.front_cents + @property def net_cents(self) -> int: - return self.cash_out_cents - self.buy_in_cents + return self.cash_out_cents - self.invested_cents @property def payout_due_cents(self) -> int: - return max(self.cash_out_cents, 0) + return max(self.cash_out_cents - self.front_cents, 0) + + @property + def player_owes_cents(self) -> int: + return max(self.front_cents - self.cash_out_cents, 0) @property def payout_remaining_cents(self) -> int: @@ -47,6 +56,8 @@ class SessionEntry: @property def payout_status(self) -> str: + if self.player_owes_cents > 0: + return "owes" if self.payout_due_cents <= 0: return "none" if self.paid_cents <= 0: @@ -70,26 +81,38 @@ class SessionSummary: def total_buy_in_cents(self) -> int: return sum(entry.buy_in_cents for entry in self.entries) + @property + def total_front_cents(self) -> int: + return sum(entry.front_cents for entry in self.entries) + + @property + def total_invested_cents(self) -> int: + return sum(entry.invested_cents for entry in self.entries) + @property def total_cash_out_cents(self) -> int: return sum(entry.cash_out_cents for entry in self.entries) - @property - def total_net_cents(self) -> int: - return sum(entry.net_cents for entry in self.entries) - - @property - def total_payout_due_cents(self) -> int: - return sum(entry.payout_due_cents for entry in self.entries) - @property def total_paid_cents(self) -> int: return sum(entry.paid_cents for entry in self.entries) + @property + def total_payout_due_cents(self) -> int: + return sum(entry.payout_due_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) + @property def total_remaining_cents(self) -> int: return sum(entry.payout_remaining_cents for entry in self.entries) + @property + def total_net_cents(self) -> int: + return sum(entry.net_cents for entry in self.entries) + @dataclass class PlayerStats: @@ -104,6 +127,8 @@ class PlayerStats: biggest_win_cents: int biggest_loss_cents: int total_buy_in_cents: int + total_front_cents: int + total_invested_cents: int total_cash_out_cents: int total_net_cents: int roi_pct: float @@ -200,6 +225,8 @@ def build_session_summaries(events: list[EventRow]) -> list[SessionSummary]: if event_type == "buyin": entry.buy_in_cents += event["amount_cents"] + elif event_type == "front": + entry.front_cents += event["amount_cents"] elif event_type == "cashout": entry.cash_out_cents += event["amount_cents"] elif event_type == "paid": @@ -304,9 +331,7 @@ def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]: run_summary = summarize_player_runs(entries) wins = [value for value in nets if value > 0] losses = [value for value in nets if value < 0] - total_buy_in = sum(entry.buy_in_cents for entry in entries) - total_cash_out = sum(entry.cash_out_cents for entry in entries) - total_net = total_cash_out - total_buy_in + sessions_played = len(entries) winning_sessions = len(wins) losing_sessions = len(losses) @@ -318,7 +343,13 @@ def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]: ) biggest_win = max(wins) if wins else 0 biggest_loss = min(losses) if losses else 0 - roi_pct = (total_net / total_buy_in * 100) if total_buy_in else 0.0 + + total_buy_in = sum(entry.buy_in_cents for entry in entries) + total_front = sum(entry.front_cents for entry in entries) + total_invested = sum(entry.invested_cents for entry in entries) + total_cash_out = sum(entry.cash_out_cents for entry in entries) + total_net = sum(entry.net_cents for entry in entries) + roi_pct = (total_net / total_invested * 100) if total_invested else 0.0 leaderboard.append( PlayerStats( @@ -333,6 +364,8 @@ def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]: biggest_win_cents=biggest_win, biggest_loss_cents=biggest_loss, total_buy_in_cents=total_buy_in, + total_front_cents=total_front, + total_invested_cents=total_invested, total_cash_out_cents=total_cash_out, total_net_cents=total_net, roi_pct=roi_pct, diff --git a/storage.py b/storage.py index 985445f..b825ab8 100644 --- a/storage.py +++ b/storage.py @@ -33,6 +33,7 @@ class EventRow(TypedDict): VALID_EVENT_TYPES = { "buyin", "cashout", + "front", "paid", "note", "session_open", @@ -91,7 +92,7 @@ def append_event( if normalized_type not in VALID_EVENT_TYPES: raise ValueError(f"Unsupported event type: {event_type}") - if normalized_type == "note": + if normalized_type == {"note", "session_open", "session_close"}: amount_cents = 0 event = EventRow( diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html index 5255252..5d5f19e 100644 --- a/templates/admin_dashboard.html +++ b/templates/admin_dashboard.html @@ -33,6 +33,7 @@ Event type + diff --git a/templates/session_detail.html b/templates/session_detail.html index df4b296..ed15c8b 100644 --- a/templates/session_detail.html +++ b/templates/session_detail.html @@ -20,9 +20,9 @@ -
+
- Total cash-out due + Total payout due {{ session.total_payout_due_cents | money }}
@@ -33,10 +33,22 @@
Total remaining - + {{ session.total_remaining_cents | money }}
+ +
+ Total fronted + {{ session.total_front_cents | money }} +
+ +
+ Players still owe + + {{ session.total_player_owes_cents | money }} + +
@@ -53,6 +65,7 @@
Player Buy-inFronted Cash-out Net Status {{ entry.buy_in_cents | money }}{{ entry.front_cents | money }} {{ entry.cash_out_cents | money }} {{ entry.net_cents | money }} From 11362dfa31438ad6794135aa22343f9782fc5bdd Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sat, 21 Mar 2026 15:12:12 -0700 Subject: [PATCH 19/39] update player details to accurately display fronts --- templates/player_detail.html | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/templates/player_detail.html b/templates/player_detail.html index 18fad5c..b2c1926 100644 --- a/templates/player_detail.html +++ b/templates/player_detail.html @@ -13,7 +13,9 @@
Total Net{{ player.total_net_cents | money }}
Win %{{ "%.1f"|format(player.win_pct) }}%
ROI{{ "%.1f"|format(player.roi_pct) }}%
-
Buy-ins{{ player.total_buy_in_cents | money }}
+
Total invested{{ player.total_invested_cents | money }}
+
Paid Buy-ins{{ player.total_buy_in_cents | money }}
+
Fronted{{ player.total_front_cents | money }}
Cash-outs{{ player.total_cash_out_cents | money }}
Sessions{{ player.sessions_played }}
@@ -29,8 +31,8 @@
-
Longest win streak{{ player.longest_win_streak }}
-
Longest loss streak{{ player.longest_loss_streak }}
+
From f8a167074bd3e0b72d78b2fcdec8ff8bbc2f2239 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sat, 21 Mar 2026 15:14:55 -0700 Subject: [PATCH 20/39] show all invested on leaderboard so net is correct --- templates/leaderboard.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/leaderboard.html b/templates/leaderboard.html index 4c15c24..ab1b241 100644 --- a/templates/leaderboard.html +++ b/templates/leaderboard.html @@ -92,7 +92,7 @@ {% endif %}
{{ player.player_name }}{{ player.total_buy_in_cents | money }}{{ player.total_invested_cents | money }} {{ player.total_cash_out_cents | money }} {{ player.total_net_cents | money }} {{ "%.1f"|format(player.win_pct) }}%
- - {{ session.session_date | pretty_date }} + + {{ session_label(session) }} {{ session.entries|length }}
- + {% if session.status == "open" %} {% else %} diff --git a/templates/player_detail.html b/templates/player_detail.html index b2c1926..9f23b3e 100644 --- a/templates/player_detail.html +++ b/templates/player_detail.html @@ -14,8 +14,9 @@
Win %{{ "%.1f"|format(player.win_pct) }}%
ROI{{ "%.1f"|format(player.roi_pct) }}%
Total invested{{ player.total_invested_cents | money }}
-
Paid Buy-ins{{ player.total_buy_in_cents | money }}
+
Paid Buy-in{{ player.total_buy_in_cents | money }}
Fronted{{ player.total_front_cents | money }}
+
Cash-outs{{ player.total_cash_out_cents | money }}
Sessions{{ player.sessions_played }}
@@ -84,6 +85,11 @@
+ + + + + diff --git a/templates/session_detail.html b/templates/session_detail.html index ed15c8b..c23d52b 100644 --- a/templates/session_detail.html +++ b/templates/session_detail.html @@ -5,16 +5,21 @@

Session detail

-

{{ session.session_date | pretty_date }}

+

{{ session_label(session) }}

Current totals are derived from the event log for this date.

-
+
{% if prev_session %} - Previous Session + + Previous Session + {% endif %} + {% if next_session %} - Next Session + + Next Session + {% endif %}
@@ -22,8 +27,13 @@
- Total payout due - {{ session.total_payout_due_cents | money }} + Gross payout + {{ session.total_gross_payout_cents | money }} +
+ +
+ Total rolled out + {{ session.total_rollover_out_cents | money }}
@@ -32,17 +42,12 @@
- Total remaining - - {{ session.total_remaining_cents | money }} + Still owed + + {{ session.total_current_due_cents | money }}
-
- Total fronted - {{ session.total_front_cents | money }} -
-
Players still owe @@ -51,12 +56,22 @@
+

Results

Player totals

+ +
+ + +
@@ -64,13 +79,16 @@
- - - + + + + + + + + - - @@ -82,21 +100,41 @@ {{ entry.player_name }} - - + + + + + + + + + + + + + - - + {% for session in sessions %} - + From 86aed87c1b2cf4d59f2a5cebc5c725f3634eb75c Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sun, 22 Mar 2026 14:24:30 -0700 Subject: [PATCH 22/39] update admin summary --- app.py | 15 ++++++++++ static/css/style.css | 25 ++++++++++++++++ stats.py | 12 ++++++++ templates/admin_dashboard.html | 55 ++++++++++++++++++++++++++++++---- 4 files changed, 102 insertions(+), 5 deletions(-) diff --git a/app.py b/app.py index 70ae2af..b1f30e6 100644 --- a/app.py +++ b/app.py @@ -448,6 +448,20 @@ def admin_dashboard(): recent_sessions = sessions[:8] recent_events = list(reversed(events[-20:])) + 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 + ), + "players_owe_cents": sum( + session.total_player_owes_cents for session in sessions + ), + "open_balance_cents": sum( + session.total_open_balance_cents for session in sessions + ), + } + return render_template( "admin_dashboard.html", open_sessions=open_sessions, @@ -455,6 +469,7 @@ def admin_dashboard(): recent_events=recent_events, player_names=unique_player_names(events), session_label=session_label, + admin_totals=admin_totals, ) diff --git a/static/css/style.css b/static/css/style.css index 412cd27..f77f9b9 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1069,6 +1069,31 @@ td a:hover { display: none; } +.admin-cash-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(5, minmax(0, 1fr)); + margin-bottom: 1rem; +} + +@media (max-width: 1200px) { + .admin-cash-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 760px) { + .admin-cash-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@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)); diff --git a/stats.py b/stats.py index 0fc8c3d..c1d5af7 100644 --- a/stats.py +++ b/stats.py @@ -146,6 +146,18 @@ class SessionSummary: def total_player_owes_cents(self) -> int: return sum(entry.player_owes_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) + + @property + def total_paid_out_cents(self) -> int: + return sum(entry.paid_cents for entry in self.entries) + + @property + def total_open_balance_cents(self) -> int: + return self.total_current_due_cents - self.total_player_owes_cents + @dataclass class PlayerStats: diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html index a5042d3..41d9b29 100644 --- a/templates/admin_dashboard.html +++ b/templates/admin_dashboard.html @@ -115,6 +115,39 @@ +
+
+ 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 }} + +
+ +
+ Open balance + + {{ admin_totals.open_balance_cents | money }} + +
+
+
@@ -126,10 +159,13 @@
Paid Buy-ins{{ player.total_buy_in_cents | money }}
Fronted{{ player.total_front_cents | money }}
Rolled In{{ player.total_rollover_in_cents | money }}
Total Invested{{ player.total_invested_cents | money }}
Rolled Out{{ player.total_rollover_out_cents | money }}
Winning Sessions{{ player.winning_sessions }}
Losing Sessions{{ player.losing_sessions }}
Break-even Sessions{{ player.break_even_sessions }}
PlayerBuy-inFrontedCash-outInFrontedRolled inOut NetGross duePaidRolled outStill owed StatusPaidRemaining Notes
{{ entry.buy_in_cents | money }}{{ entry.front_cents | money }}{{ entry.invested_cents | money }}{{ entry.front_cents | money }}{{ entry.rollover_in_cents | money }} {{ entry.cash_out_cents | money }} {{ entry.net_cents | money }} + {% if entry.player_owes_cents > 0 %} + — + {% else %} + {{ entry.gross_payout_cents | money }} + {% endif %} + {{ entry.paid_cents | money }}{{ entry.rollover_out_cents | money }} + {% if entry.player_owes_cents > 0 %} + Owes {{ entry.player_owes_cents | money }} + {% else %} + {{ entry.current_due_cents | money }} + {% endif %} + {{ entry.payout_status }} {{ entry.paid_cents | money }} - {{ entry.payout_remaining_cents | money }} - {% if entry.notes %}
    @@ -270,4 +308,28 @@ }); } + + {% endblock %} diff --git a/templates/sessions.html b/templates/sessions.html index 61a51f8..9587dda 100644 --- a/templates/sessions.html +++ b/templates/sessions.html @@ -31,7 +31,7 @@
{{ session.session_date | pretty_date }}{{ session_label(session) }} {{ session.status }}
- + - - + + + + + @@ -143,8 +179,17 @@ - - + + + + + - - + + @@ -92,8 +93,8 @@ {% endif %} - - + + @@ -266,8 +267,8 @@ rank: 0, rankdelta: 1, player: 2, - buyins: 3, - cashouts: 4, + cashin: 3, + paidout: 4, net: 5, winpct: 6, roi: 7, From e7473d43af80a11caa0a3de656a0eb540ffd8ab9 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sun, 22 Mar 2026 15:00:15 -0700 Subject: [PATCH 25/39] fix graph order for same date sessions --- stats.py | 82 +++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 55 insertions(+), 27 deletions(-) diff --git a/stats.py b/stats.py index c1d5af7..7d8d75a 100644 --- a/stats.py +++ b/stats.py @@ -212,6 +212,38 @@ def cents_to_dollars(cents: int) -> str: return f"${value:,.2f}" +def session_sort_key(session: SessionSummary) -> tuple[str, int, str]: + session_id = session.session_id.strip() + + if session_id == session.session_date: + return (session.session_date, 1, session_id) + + suffix = session_id.replace(f"{session.session_date}-", "") + if suffix.isdigit(): + return (session.session_date, int(suffix), session_id) + + if suffix.lower().startswith("s") and suffix[1:].isdigit(): + return (session.session_date, int(suffix[1:]), session_id) + + return (session.session_date, 9999, session_id) + + +def session_chart_label(session: SessionSummary) -> str: + session_id = session.session_id.strip() + + if session_id == session.session_date: + return safe_date_label(session.session_date) + + suffix = session_id.replace(f"{session.session_date}-", "") + if suffix.isdigit(): + return f"{safe_date_label(session.session_date)} · S{int(suffix)}" + + if suffix.lower().startswith("s") and suffix[1:].isdigit(): + return f"{safe_date_label(session.session_date)} · S{int(suffix[1:])}" + + return f"{safe_date_label(session.session_date)} · {suffix}" + + def safe_date_label(raw_date: str) -> str: try: return datetime.strptime(raw_date, "%Y-%m-%d").strftime("%b %d, %Y") @@ -468,9 +500,10 @@ def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]: return leaderboard -def cumulative_profit_series(sessions: list[SessionSummary]) -> dict[str, Any]: - ordered_sessions = sorted(sessions, key=lambda session: session.session_date) - player_names = sorted( +def cumulative_profit_series(sessions: list[SessionSummary]) -> dict[str, object]: + ordered_sessions = sorted(sessions, key=session_sort_key) + + all_players = sorted( { entry.player_name for session in ordered_sessions @@ -479,13 +512,13 @@ def cumulative_profit_series(sessions: list[SessionSummary]) -> dict[str, Any]: key=str.casefold, ) - labels = [safe_date_label(session.session_date) for session in ordered_sessions] + labels = [session_chart_label(session) for session in ordered_sessions] datasets = [] - for player_name in player_names: - series: list[float | None] = [] + for player_name in all_players: running_total = 0 - has_started = False + seen_player = False + data: list[float | None] = [] for session in ordered_sessions: matching_entry = next( @@ -496,37 +529,32 @@ def cumulative_profit_series(sessions: list[SessionSummary]) -> dict[str, Any]: ), None, ) - if matching_entry is not None: - has_started = True - running_total += matching_entry.net_cents - series.append(round(running_total / 100, 2)) - elif has_started: - series.append(round(running_total / 100, 2)) - else: - series.append(None) + + if matching_entry is None: + data.append(running_total / 100 if seen_player else None) + continue + + seen_player = True + running_total += matching_entry.net_cents + data.append(round(running_total / 100, 2)) datasets.append( { "label": player_name, - "data": series, - "borderColor": color_for_name(player_name, player_names), - "backgroundColor": color_for_name(player_name, player_names), - "pointRadius": 3, - "pointHoverRadius": 5, - "pointHitRadius": 10, - "borderWidth": 2.5, - "tension": 0.22, - "spanGaps": False, + "data": data, } ) - return {"labels": labels, "datasets": datasets} + return { + "labels": labels, + "datasets": datasets, + } def player_session_series( sessions: list[SessionSummary], player_name: str ) -> dict[str, Any]: - ordered_sessions = sorted(sessions, key=lambda session: session.session_date) + ordered_sessions = sorted(sessions, key=session_sort_key) labels: list[str] = [] net_values: list[float] = [] cumulative_values: list[float] = [] @@ -549,7 +577,7 @@ def player_session_series( if matching_entry is None: continue - labels.append(safe_date_label(session.session_date)) + labels.append(session_chart_label(session)) net_values.append(round(matching_entry.net_cents / 100, 2)) running_total += matching_entry.net_cents cumulative_values.append(round(running_total / 100, 2)) From 8d3da9197de99d166a04b3c134b8fbd637964d6d Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sat, 28 Mar 2026 11:16:22 -0700 Subject: [PATCH 26/39] fix leaderboard through id not date --- app.py | 20 ++++++++++---------- templates/leaderboard.html | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/app.py b/app.py index 0ad3590..a5921af 100644 --- a/app.py +++ b/app.py @@ -104,15 +104,15 @@ def leaderboard() -> str: all_sessions = build_session_summaries(events) ordered_sessions = sorted(all_sessions, key=lambda s: s.session_date) - session_dates = [session.session_date for session in ordered_sessions] - selected_session_date = request.args.get("through_session", "").strip() + session_ids = [session.session_id for session in ordered_sessions] + selected_session_id = request.args.get("through_session", "").strip() - if session_dates: - if selected_session_date in session_dates: - cutoff_index = session_dates.index(selected_session_date) + if session_ids: + if selected_session_id in session_ids: + cutoff_index = session_ids.index(selected_session_id) else: - cutoff_index = len(session_dates) - 1 - selected_session = session_dates[cutoff_index] + cutoff_index = len(session_ids) - 1 + selected_session = session_ids[cutoff_index] filtered_sessions = ordered_sessions[: cutoff_index + 1] previous_sessions = ordered_sessions[:cutoff_index] @@ -133,10 +133,10 @@ def leaderboard() -> str: total_session_count=len(all_sessions), chart_data=chart_data, available_sessions=all_sessions, - selected_session_date=selected_session_date, + selected_session_date=selected_session_id, selected_session_label=( - safe_date_label(selected_session_date) - if selected_session_date + safe_date_label(selected_session_id) + if selected_session_id else "Latest session" ), session_label=session_label, diff --git a/templates/leaderboard.html b/templates/leaderboard.html index 37a37bd..be48faa 100644 --- a/templates/leaderboard.html +++ b/templates/leaderboard.html @@ -51,7 +51,7 @@ {% if selected_session_date %} From 32d212ad46e1df063db035a865a60f5213226cbd Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sat, 28 Mar 2026 11:26:35 -0700 Subject: [PATCH 27/39] fix leaderboard through id not date --- app.py | 12 +++++------- templates/leaderboard.html | 2 +- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/app.py b/app.py index a5921af..35a7d66 100644 --- a/app.py +++ b/app.py @@ -106,13 +106,15 @@ def leaderboard() -> str: session_ids = [session.session_id for session in ordered_sessions] selected_session_id = request.args.get("through_session", "").strip() + label = "" if session_ids: if selected_session_id in session_ids: cutoff_index = session_ids.index(selected_session_id) else: cutoff_index = len(session_ids) - 1 - selected_session = session_ids[cutoff_index] + selected_session = ordered_sessions[cutoff_index] + label = session_label(selected_session) filtered_sessions = ordered_sessions[: cutoff_index + 1] previous_sessions = ordered_sessions[:cutoff_index] @@ -133,12 +135,8 @@ def leaderboard() -> str: total_session_count=len(all_sessions), chart_data=chart_data, available_sessions=all_sessions, - selected_session_date=selected_session_id, - selected_session_label=( - safe_date_label(selected_session_id) - if selected_session_id - else "Latest session" - ), + selected_session_id=selected_session_id, + selected_session_label=(label if selected_session_id else "Latest session"), session_label=session_label, ) diff --git a/templates/leaderboard.html b/templates/leaderboard.html index be48faa..8228f9c 100644 --- a/templates/leaderboard.html +++ b/templates/leaderboard.html @@ -49,7 +49,7 @@ + + {% for item in debt_entries %} + {% set session = item.session %} + {% set entry = item.entry %} + + {% endfor %} + + + + + + + +
+ +
+ @@ -140,6 +173,13 @@
+
+ Written off + + {{ admin_totals.written_off_cents | money }} + +
+
Open balance @@ -165,6 +205,7 @@
+ @@ -187,6 +228,9 @@ + @@ -208,7 +252,7 @@ {% else %} - + {% endfor %} diff --git a/templates/player_detail.html b/templates/player_detail.html index 9f23b3e..329bee5 100644 --- a/templates/player_detail.html +++ b/templates/player_detail.html @@ -16,6 +16,8 @@
Total invested{{ player.total_invested_cents | money }}
Paid Buy-in{{ player.total_buy_in_cents | money }}
Fronted{{ player.total_front_cents | money }}
+
Current debt{{ player.current_player_owes_cents | money }}
+
Written off{{ player.total_front_writeoff_cents | money }}
Cash-outs{{ player.total_cash_out_cents | money }}
Sessions{{ player.sessions_played }}
@@ -87,6 +89,8 @@
+ + diff --git a/templates/session_detail.html b/templates/session_detail.html index c23d52b..39267f8 100644 --- a/templates/session_detail.html +++ b/templates/session_detail.html @@ -54,6 +54,13 @@ {{ session.total_player_owes_cents | money }} + +
+ Written off + + {{ session.total_front_writeoff_cents | money }} + +
@@ -87,6 +94,7 @@
+ @@ -111,7 +119,7 @@ + - + @@ -228,6 +281,9 @@ + @@ -252,7 +308,7 @@ {% else %} - + {% endfor %} diff --git a/templates/player_detail.html b/templates/player_detail.html index 329bee5..2646bce 100644 --- a/templates/player_detail.html +++ b/templates/player_detail.html @@ -9,33 +9,13 @@ -
+
Total Net{{ player.total_net_cents | money }}
-
Win %{{ "%.1f"|format(player.win_pct) }}%
-
ROI{{ "%.1f"|format(player.roi_pct) }}%
Total invested{{ player.total_invested_cents | money }}
-
Paid Buy-in{{ player.total_buy_in_cents | money }}
-
Fronted{{ player.total_front_cents | money }}
-
Current debt{{ player.current_player_owes_cents | money }}
-
Written off{{ player.total_front_writeoff_cents | money }}
-
Cash-outs{{ player.total_cash_out_cents | money }}
+
Current debt{{ player.current_player_owes_cents | money }}
+
Win %{{ "%.1f"|format(player.win_pct) }}%
Sessions{{ player.sessions_played }}
-
- Current Streak - - {% if player.current_win_streak > 0 %} - W{{ player.current_win_streak }} - {% elif player.current_loss_streak > 0 %} - L{{ player.current_loss_streak }} - {% else %} - - - {% endif %} - -
- -
@@ -87,12 +67,18 @@
DateSession PlayersBuy-insCash-outsCash inPaid outStill owedPlayers oweOpen balance Status Action
{{ session.entries|length }}{{ session.total_buy_in_cents | money }}{{ session.total_cash_out_cents | money }}{{ 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_open_balance_cents | money }} + {{ session.status }} From b4401b302d5bea9b81d80029fb01da33612b1e5d Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sun, 22 Mar 2026 14:28:53 -0700 Subject: [PATCH 23/39] leaderboard through session label update --- app.py | 1 + templates/leaderboard.html | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app.py b/app.py index b1f30e6..0ad3590 100644 --- a/app.py +++ b/app.py @@ -139,6 +139,7 @@ def leaderboard() -> str: if selected_session_date else "Latest session" ), + session_label=session_label, ) diff --git a/templates/leaderboard.html b/templates/leaderboard.html index ab1b241..99abd14 100644 --- a/templates/leaderboard.html +++ b/templates/leaderboard.html @@ -50,7 +50,7 @@ {% if selected_session_date %} From e11a950c39a475d22702a9a438b2e8bac1f240c8 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sun, 22 Mar 2026 14:40:24 -0700 Subject: [PATCH 24/39] update leaderboard stats --- templates/leaderboard.html | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/templates/leaderboard.html b/templates/leaderboard.html index 99abd14..37a37bd 100644 --- a/templates/leaderboard.html +++ b/templates/leaderboard.html @@ -43,6 +43,7 @@

Standings

All-time leaderboard

+

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

@@ -68,8 +69,8 @@
{{ player.player_name }}{{ player.total_invested_cents | money }}{{ player.total_cash_out_cents | money }}{{ 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) }}%Paid out Still owed Players oweWritten off Open balance Status Action {{ session.total_player_owes_cents | money }} + {{ session.total_front_writeoff_cents | money }} + {{ session.total_open_balance_cents | money }}
No sessions yet.No sessions yet.
Paid Buy-ins{{ player.total_buy_in_cents | money }}
Fronted{{ player.total_front_cents | money }}
Current Debt{{ player.current_player_owes_cents | money }}
Written Off{{ player.total_front_writeoff_cents | money }}
Rolled In{{ player.total_rollover_in_cents | money }}
Total Invested{{ player.total_invested_cents | money }}
Rolled Out{{ player.total_rollover_out_cents | money }}
Gross due Paid Rolled outWritten off Still owed Status Notes - {% if entry.player_owes_cents > 0 %} + {% if entry.raw_player_owes_cents > 0 %} — {% else %} {{ entry.gross_payout_cents | money }} @@ -120,10 +128,13 @@ {{ entry.paid_cents | money }} {{ entry.rollover_out_cents | money }}{{ entry.front_writeoff_applied_cents | money }} + {% if entry.player_owes_cents > 0 %} Owes {{ entry.player_owes_cents | money }} + {% elif entry.front_writeoff_applied_cents > 0 %} + Written off {{ entry.front_writeoff_applied_cents | money }} {% else %} {{ entry.current_due_cents | money }} {% endif %} From a5c216bb9e8aebe4227c63ba767f47bc4a3bf465 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Thu, 11 Jun 2026 13:27:00 -0700 Subject: [PATCH 30/39] fix layout + collect/writeoff debts --- README.md | 4 +- app.py | 71 ++++++++++++++++++++++++++ static/css/style.css | 91 ++++++++++++++++++++++++++++++++-- stats.py | 29 ++++++++++- storage.py | 1 + templates/admin_dashboard.html | 64 ++++++++++++++++++++++-- templates/player_detail.html | 32 ++++-------- templates/session_detail.html | 6 ++- 8 files changed, 263 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index d09f1f4..f2638ed 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ The idea is pretty simple: public pages for stats and session history, plus a sm - admin login for recording events - open / closed session tracking - payout tracking with `paid` events -- front debt write-offs with `front_writeoff` events +- front debt collections and write-offs with `front_collected` / `front_writeoff` events - session and player charts - CSV import / export from the admin page - append-only `entries.csv` ledger instead of overwriting old rows @@ -38,6 +38,7 @@ Current event types: - `buyin` - `front` +- `front_collected` - `front_writeoff` - `cashout` - `paid` @@ -52,6 +53,7 @@ A few examples: - another `buyin` for a rebuy - another `cashout` if chip counts are corrected later - a `paid` event when someone is actually settled up +- a `front_collected` event when a front is repaid outside poker - a `front_writeoff` event when a front will not be collected - a `note` event for bookkeeping context - `session_open` / `session_close` to mark whether a game night is still live diff --git a/app.py b/app.py index b429f71..f77d83d 100644 --- a/app.py +++ b/app.py @@ -438,6 +438,74 @@ def admin_write_off_front() -> str: return redirect(url_for("admin_dashboard")) +@app.post("/admin/collect-front") +def admin_collect_front() -> str: + if not is_admin(): + flash("Admin login required.", "error") + return redirect(url_for("admin_login")) + + debt_key = request.form.get("debt_key", "").strip() + note = request.form.get("note", "").strip() + amount_raw = request.form.get("amount", "0").strip() + + try: + amount_cents = int(round(float(amount_raw) * 100)) + except ValueError: + flash("Amount must be a number.", "error") + return redirect(url_for("admin_dashboard")) + + if amount_cents <= 0: + flash("Collected amount must be greater than zero.", "error") + return redirect(url_for("admin_dashboard")) + + try: + session_id, player_name = debt_key.split("||", 1) + except ValueError: + flash("Select a valid player debt.", "error") + return redirect(url_for("admin_dashboard")) + + events = load_events(DATA_PATH) + sessions = build_session_summaries(events) + target = next( + (session for session in sessions if session.session_id == session_id), + None, + ) + + if target is None: + flash("Session not found.", "error") + return redirect(url_for("admin_dashboard")) + + entry = next( + (entry for entry in target.entries if entry.player_name == player_name), + None, + ) + + if entry is None or entry.player_owes_cents <= 0: + flash("That player does not have an outstanding front debt.", "error") + return redirect(url_for("admin_dashboard")) + + if amount_cents > entry.player_owes_cents: + flash( + f"Collection cannot exceed {cents_to_dollars(entry.player_owes_cents)}.", + "error", + ) + return redirect(url_for("admin_dashboard")) + + append_event( + DATA_PATH, + session_id=target.session_id, + session_date=target.session_date, + player_name=entry.player_name, + event_type="front_collected", + amount_cents=amount_cents, + note=note or "Front debt collected.", + actor=app.config["ADMIN_USERNAME"], + ) + + flash("Front debt collected.", "success") + return redirect(url_for("admin_dashboard")) + + @app.post("/admin/open-session") def admin_open_session() -> str: if not is_admin(): @@ -537,6 +605,9 @@ def admin_dashboard(): "players_owe_cents": sum( session.total_player_owes_cents for session in sessions ), + "collected_cents": sum( + session.total_front_collected_cents for session in sessions + ), "written_off_cents": sum( session.total_front_writeoff_cents for session in sessions ), diff --git a/static/css/style.css b/static/css/style.css index 3873a15..3890f2e 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -294,6 +294,14 @@ h2 { line-height: 1.15; } +h3 { + position: relative; + z-index: 1; + margin: 0; + font-size: 1.05rem; + line-height: 1.2; +} + .muted-text { margin: 0; color: var(--text-muted); @@ -383,6 +391,10 @@ h2 { min-height: 112px; } +.compact-form textarea { + min-height: 92px; +} + .form-card input:focus, .form-card select:focus, .form-card textarea:focus { @@ -394,6 +406,14 @@ h2 { align-self: start; } +.admin-layout { + grid-template-columns: minmax(420px, 1.15fr) minmax(360px, 0.85fr); +} + +.admin-layout > .grid { + gap: 18px; +} + .panel-header { position: relative; z-index: 1; @@ -763,11 +783,14 @@ td a:hover { } .leaderboard-layout, -.session-layout, -.admin-layout { +.session-layout { align-items: stretch; } +.admin-layout { + align-items: start; +} + .results-panel-full { display: block; } @@ -958,6 +981,12 @@ td a:hover { 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); @@ -995,6 +1024,12 @@ td a:hover { 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; } @@ -1038,6 +1073,39 @@ td a:hover { 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; @@ -1088,20 +1156,34 @@ td a:hover { .admin-cash-grid { display: grid; gap: 1rem; - grid-template-columns: repeat(5, minmax(0, 1fr)); + grid-template-columns: repeat(6, minmax(150px, 1fr)); margin-bottom: 1rem; } -@media (max-width: 1200px) { +@media (max-width: 1180px) { .admin-cash-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } } +@media (max-width: 1180px) { + .profile-summary-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + @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; + } } @media (max-width: 520px) { @@ -1135,6 +1217,7 @@ td a:hover { .two-col, .three-col, + .four-col, .leaderboard-layout, .session-layout, .admin-layout { diff --git a/stats.py b/stats.py index 29082a4..8ae8c63 100644 --- a/stats.py +++ b/stats.py @@ -31,6 +31,7 @@ class SessionEntry: player_name: str buy_in_cents: int = 0 front_cents: int = 0 + front_collected_cents: int = 0 front_writeoff_cents: int = 0 cash_out_cents: int = 0 paid_cents: int = 0 @@ -68,7 +69,7 @@ class SessionEntry: @property def player_owes_cents(self) -> int: - return max(self.raw_player_owes_cents - self.front_writeoff_applied_cents, 0) + return max(self.raw_player_owes_cents - self.front_resolved_cents, 0) @property def raw_player_owes_cents(self) -> int: @@ -84,7 +85,18 @@ class SessionEntry: @property def front_writeoff_applied_cents(self) -> int: - return min(self.front_writeoff_cents, self.raw_player_owes_cents) + return min( + self.front_writeoff_cents, + max(self.raw_player_owes_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) + + @property + def front_resolved_cents(self) -> int: + return self.front_collected_applied_cents + self.front_writeoff_applied_cents @property def payout_status(self) -> str: @@ -92,6 +104,8 @@ class SessionEntry: return "owes" if self.front_writeoff_applied_cents > 0: return "written_off" + if self.front_collected_applied_cents > 0: + return "collected" if self.gross_payout_cents <= 0: return "none" if self.current_due_cents <= 0: @@ -169,6 +183,10 @@ class SessionSummary: def total_front_writeoff_cents(self) -> int: return sum(entry.front_writeoff_applied_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) + @property def total_cash_in_cents(self) -> int: return sum(entry.buy_in_cents for entry in self.entries) @@ -196,6 +214,7 @@ class PlayerStats: biggest_loss_cents: int total_buy_in_cents: int total_front_cents: int + total_front_collected_cents: int total_front_writeoff_cents: int current_player_owes_cents: int total_rollover_in_cents: int @@ -347,6 +366,8 @@ def build_session_summaries(events: list[EventRow]) -> list[SessionSummary]: entry.buy_in_cents += event["amount_cents"] elif event_type == "front": entry.front_cents += event["amount_cents"] + elif event_type == "front_collected": + entry.front_collected_cents += event["amount_cents"] elif event_type == "front_writeoff": entry.front_writeoff_cents += event["amount_cents"] elif event_type == "rollover_in": @@ -481,6 +502,9 @@ 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 ) @@ -507,6 +531,7 @@ def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]: biggest_loss_cents=biggest_loss, total_buy_in_cents=total_buy_in, total_front_cents=total_front, + total_front_collected_cents=total_front_collected, total_front_writeoff_cents=total_front_writeoff, current_player_owes_cents=current_player_owes, total_rollover_in_cents=total_rollover_in, diff --git a/storage.py b/storage.py index e250670..e891c27 100644 --- a/storage.py +++ b/storage.py @@ -35,6 +35,7 @@ class EventRow(TypedDict): VALID_EVENT_TYPES = { "buyin", "front", + "front_collected", "front_writeoff", "cashout", "paid", diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html index fca5e65..f4decf8 100644 --- a/templates/admin_dashboard.html +++ b/templates/admin_dashboard.html @@ -113,9 +113,58 @@ -
+ + + +
+
+

Front debt

-

Write off debt

+

Debt resolution

+
+
+ Open {{ admin_totals.players_owe_cents | money }} + Collected {{ 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 %}
@@ -205,6 +257,7 @@
Paid out Still owed Players oweCollected Written off Open balance Status {{ session.total_player_owes_cents | money }} + {{ session.total_front_collected_cents | money }} + {{ session.total_front_writeoff_cents | money }}
No sessions yet.No sessions yet.
+ + + + + + diff --git a/templates/session_detail.html b/templates/session_detail.html index 39267f8..900b493 100644 --- a/templates/session_detail.html +++ b/templates/session_detail.html @@ -94,6 +94,7 @@ + @@ -128,13 +129,16 @@ + - - - + + @@ -180,8 +310,8 @@ - - + + @@ -231,6 +361,20 @@ + + - -{% if debt_entries %} -
-
-
- Front receivables -

Debt resolution

-
-
- Open {{ admin_totals.players_owe_house_cents | money }} - Repaid {{ admin_totals.collected_cents | money }} - Written off {{ admin_totals.written_off_cents | money }} -
-
-
-
-

Collect debt

- - - - - - -
-

Write off debt

- - - - - -
-
-{% endif %} - {% endblock %} diff --git a/templates/admin_login.html b/templates/admin_login.html index 59ba01f..2ac4754 100644 --- a/templates/admin_login.html +++ b/templates/admin_login.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}Admin Login · boker.club{% endblock %} +{% block title %}Admin Login · myboker.org{% endblock %} {% block content %}
@@ -7,7 +7,7 @@

Restricted access

Admin login

-

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

+

Only myboker.org manager needs credentials. All stats pages stay public.

+ + + + + {% if player.total_payout_carry_in_cents > 0 %} + + + + + {% endif %} - + + + + + - - + + - {% if player.total_front_collected_cents > 0 %} + {% if player.total_debt_repayment_cents > 0 %} - - + + {% endif %} - {% if player.current_player_owes_cents > 0 %} + {% if player.current_due_to_player_cents > 0 %} - - + + {% endif %} - {% if player.total_front_writeoff_cents > 0 %} + {% if player.current_due_to_house_cents > 0 %} + + + + + {% endif %} + {% if player.total_writeoff_cents > 0 %} - + {% endif %} diff --git a/templates/session_detail.html b/templates/session_detail.html index 0d305a1..19bad72 100644 --- a/templates/session_detail.html +++ b/templates/session_detail.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}{{ session_label(session) }} · boker.club{% endblock %} +{% block title %}{{ session_label(session) }} · myboker.org{% endblock %} {% block page_class %}page--session{% endblock %} {% block content %} @@ -28,20 +28,20 @@
- Cash flow + Banker cash flow
- Cash in -
{{ session.total_cash_in_cents | money }}
+ Actual cash in +
{{ session.total_real_cash_in_cents | money }}
- Gross payout + Gross player claim
{{ session.total_gross_payout_cents | money }}
Cash paid out -
{{ session.total_paid_cents | money }}
+
{{ session.total_real_cash_out_cents | money }}
Rolled out @@ -55,7 +55,7 @@
- House owes + House owes players
{{ session.total_current_due_to_player_cents | money }}
@@ -67,12 +67,12 @@
- Debt repaid -
{{ session.total_front_collected_cents | money }}
+ Cash debt repaid +
{{ session.total_debt_repayment_cents | money }}
Written off -
{{ session.total_front_writeoff_cents | money }}
+
{{ session.total_writeoff_cents | money }}
@@ -96,14 +96,15 @@
- + - - - - + + + + - + + @@ -119,10 +120,12 @@ - + + - + diff --git a/tests/__pycache__/__init__.cpython-313.pyc b/tests/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 0fca85c57eb5bdc9bce3105496c5aa11e7532a99..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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 diff --git a/tests/__pycache__/test_accounting.cpython-313.pyc b/tests/__pycache__/test_accounting.cpython-313.pyc deleted file mode 100644 index 5c020f2ea8c6948b2abf9a0c386547d86630bbb6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8233 zcmd5>U2NOd6~2^6iIQbW{*C1(sbVK<)HIIm*lB(o$8zTUG>a3PQCBQpSw*5<6^ayJ zN{Jm{L(pNHSL`8v2;yV|+U8-4kpgv*0Mpt6GtdI-qXjv$!R4XFIt+f(;tW{!v~x&G zlpNY~+M?@)ba;91<+=AB-t(OwE>~7M36wv5vGmn_ucCxUgzDzHOr2P<%m4(=WFIxkksuzZ8Y%0v5#@g9$ha^F#v-WuZ~V;eMqdCCMpy7g8}CiQqzFiIJ8I z;|dp%5-FKhz%X2ejJv%;9#pu0oxb|=RAh1}=V{7SG|#ck%g{pk?J*C~P7LJLJNVRFZ;Z zQCb0;*>kGUDI94c<9_{Wp}1XzQ$<2ILLKkr!$7W(d^M@wc-1u(oRX$~IOpEB*bunA zZN_^0nVBPV^#>N~{fiA-7dJFPN7i>Uz!4A|R@YFft2vjJZVDYq^ z!6UTvEbxrr$to1AtFQ7csJv=<)mnOfLrmdyCD@X)Z0TSG2G+rc*qWj$6-ROYa@9`TbY4SMTT= zkrL9_1=_8=utK0MpeA%&98S`fu6QyMj?3LG#nDZ`M8*U2?jRSmw>*#Ob{GZxP;#Jf(pXBdqi_H`19pX#rTDPI z(&Qyg!0L1guOpDG*!3Ey846Uk0~f#lwbwua^>)CXx!Th?@97C{(NjP1(>Z>Vrj9yD z10zCEKXFgX8{s4&)(w{S;s_|lkr&{=3t-(4-#T^7e4^Jb@PcF2UMKWc6nb}A3yPJ7 z`7cz%{9r~_mXKwz6rc6(vyQ?zfh%z71TI+oxD#_u2yZLOSK`DB7JPvcZeNiT!uyI+ z!ij&1-I@3ff@>X80dHbuDSd8)1SC_US5?R96p|BUivFH+>GK%*BCvC?%LWfcjmSu^i`bN{?xZ;i_E&`5&-81aI;z&p- z-O$yflBsZ9#2Lg`LT{|q8gUL0=G9xDiKHn7r4plQNlYb0-TZ3?vvEZytXGz_2dAxc zCoT?s9ohy&2Mt|Mx2sNyx@UD6IAJ&u71i0pR1%{x8Jw3$O4++ojo14m;*$IYkSk=- zx9RQ8H#UFit-BhX`oZny+m|wyOfcu|eo$-~&opJiId6~Aa&d-c+H&55c{^L_UbfmA zoxgHV*z;cEs9fM1vV6l--GXmt*0=M$^vvL$@p*pVBJV2ny#4HrXW!d4W4RNU=R1t{ z4GX@GtgqvqlE^FPF_3g{F-RBQ} zH_)i!JB&sZPoep1qtVlFGC`wKELa6u8MC-$UC)_dPYL>&tXxGSict>TgXBdZ3O7uX ziPRE;KHZPx0FWhgKj=Xu-AE20SqVb42U~+k(08W2K$Pkcnv`Ww?C~O~&_3)vjO6=B zjshu%hdrv2s|yihGGLSrXXgMWz)3G67e+XMW65}2iU8axM@+%usrjHVR)-Eog#`mB z#xq+o13B*@qvhhvXl6^!+qE9(7`QVw&+jc!Y#ln7JKN{^-6ib{zP7Bd?Otc5>h9io zzN@Uq1R!*%nF*J1hgKbC2JVjC=X=(Gj+g%sO>-C4h-P#9#wS}7X?j8UWD)+bWzn|Z2)QR(Uh4t zJPJ*jQH3>S?#G&{xU1-FP}SMe8AK{Y(-BD)>;wdlR&!Nkg5Cj=tJ zV8Tem2ri)#!W1|3dWxY2n_!Gkr6l&ZoGk_lg13=`EGBWgwPZ}yDr49XYZj&m)yO`< zf!QnuEczDVau%hd7NN$R?^#R$X(PSv zs4hc?y7iy~9RzV`MqR)f8g+q)l=WX*I7gXxxl zMTNG0+6+vmEo$z8ZiBjv9)}+4t9b`SSQ{}QxG8lX3V@T4E)`q|lYGn&&h!irH62se zd#hn8`2NuJQ07c_XYhJ>GJLB+OUJxf)5P3xG_8`3`GzF|Nh+dAgsKZ%DGK^y7=V9c z=`}q!$ELQt-!B#OlbUip3ygBxmz6yg>xG#H?$WKk3p&2Q8lMo1-C(iUl8JtBU zQC3JAslMIn%X|%{kkz;raCvTH--pL$Mi(}<|7BDAhsPevc*x#tXk_Ras6dd5@vWLK zqC2qm0+JpeiWTzis&P`b=mG8Ov8v{l;<_NEt45|R$Y&Swv1#^6VWSWCLB_2774oI4>gv`h*E^BhFV6I4n4D|>gJR3R83~+F z*CC^&JJX&S&AEa`%Rx29b@k*OoYy(QK5~)T9fjmqRb95O_uYnEU2m?ccf$6-QB)I|CfLO~-}Rx%p?9`SiWAPy3vupmYqA^r{&+06u`lQA`!xO8;3p8e4jA^V ze!&;a`huT!epdU*fqDLv(bKr#YtQ=H@AYMx?jA1-Y-=??cLoC6&L?dkVz_DpGvA(f zS^>eAo;EY=o^Dku!8W=Jy}OE^Ez+P(|F0yRz#Eoe)ev)SPt_z;T$PiFr&xv(a%)|eXe=cqhllBppeL}>ja4@2X{+QV z@J6+O)X0ocbQ0BYRB6<3q@1Ug&@zcq`&2ZDO6OAG>4H;x9`j_F#VV`@n&o8tq9p1J zb$>UO7>#D`MEWT|Ki~W+ALST6Bizb{(3nnuWC4 z5daWOiP`m(San%SY){s==VLB&?jy%M-)m?*c)eP}YMwu6v>FMq;N92f`9ozr2eQ5c zABSe!K6>Roe?-NCY54l8IR|R40zO^wqWURP6cwi^o=-;85LbwbOB68?iXSQA&LfJ$ zF)F9xv4oUJilP?epnayNkc5y7BS`>J>O@ga!TX88S3~$P0XYXaG$Or#15j^NQ$ROk z4GDfLlbeA2f;_UY+~$WaH`o2p@8*KcL%)mL|8S#++w-rcO71A5J&q6Iy3dEfeux2= z!lx6l6rLr8!--|>;|Mn#Pr_$QHSvPZEttC$)snsZ85n#x3<+O#6{Fb8BD)ofRQN7U zB;E8UFb17|`7I!ME5k5NK;FhMTd!`*6SzzreuS4iU(MJSy)}6&TtBa_%X4te z+X&R`SmQ}mZQg-3C#kH?S76OWT%JdjPG$hv-c^39V!Kb5xG?{n2(YWdm)#oHQpR|YdCVgvuF3f zrc!04RpVCu;i5*E1}RDNqgX_WBanJ6k!mTCnx8(Mg{>_=8Z}kLKZ6q`^jF_^cK1Ha z*+7EY(cSFq%F8r8^I!KJlpNYQ*)ehN(H-KCnQu! z$XWuuOLKeTTRmwvD`7;gf&IO%yI6PJ%jq?zh@bS^cKrf|g}Sw*mpjWzTpzR;NwW3Xta7uhfq3nA@m+TW24nH4raP#j z#c9-Q(}jUZETHN3a8!etI0j;LFrcY8=mvdz^V!&-(mW6e42GL~XzZL4)YRtFu{Rak ztSKs#F_#0uAQBBn&om8<>TX#MM58e+peeFU-9X3zf3+5}G4d_pxQ+KP``xBRd|W-q z-j<-1u;pR_6Mn>&y9)|qq|agl=`%S`kOiTw)*eHzcC(N5gk^JgL(t&0(E@nr0)=^o zyc}gS!rqOvkTnFAsHRdlKgMLMp)f}76c+y`KH;AXzSEZ~*^nx1nB^O25j5rO3$Bn& z^bPjWBliV*8A-Bd?H8X_7BYJ$Ir@03f3pJ?iZR z53@U^6^nLwepOzzD8Tb;2Z53kOCl+&Tr9wnixibF7GlXw+$Hylpk%NQ*>9zCC@}W* zKw*qXu&Zv655M3hZFb2f+5LjV7ZFK-H-{f&($7hv-zF9K?UE~VD*U{)>r$cQmWrg} z<^o_>iEaZ9aocoPkSf5QkQ~r1mbJCC?cCPV3cr&rEqman+gsW~7ad#J8?AtuP>^Oq zt1!dEpt}vhk=a4fc{tyS4hJd_)M8ZUQ4D~Gcz(Q&eBU$f(;8@3aq*2TnP-pf5p95K z#%ezl#>iqhDJYt#op4Y3CY8z8W{Wp1h?TQzyw^5O+pj${eQ36NFH}sNTM(-!-3wyX z#80i)tHR{KyG09P@q}$bESacYtZ=y-S@a7%*`t~h$n&-1 zHUD;mNLvp6;F;VG|D~58>)zr-a2cM}<>`bCwZIwpZY?`lcH8`qB+r=pjN$h9N;jP@=qc`aC>zPGPS(3#VJ5g>Hik1tM!Dl|3JnOt$`M z-zWQKjwQFha=G_X?}sH1XcP1TddNGFN@YaToyZQ1wy3yh_z6R0#c4h*bczuw%1G^=&bio38LzEXEX_5f5y#q-zNQxz@a;+^v9Iplt- z`e>^B=(zJvY2{pH&->0lmi(b4RoRm&?HLyqN~*_yHY+}xQ7S-%B~z)0OY1;HQQ7yT z^`F&+WNAVP5EHSo-__5xo9LR63A2^&uk5$2*eXD)-D+*JTe5Wl#;4^lewLoKX5hE= zWFxs*-G{1{rGA%1rQHOYI$TM9N{D^S()Xgo^&sz-rSD0pco`XZ&7xHK1gpzq6ZVtG z;0kKTWhwhRiJ1htO>O;;E2GqO&Zi5fdPALeE z9EvNl7L!eb#L%3z%Nk*Fs$zUNWv2+ZbSGvnKxP7iDT|w#t;v?frhx+nq9K`$9-xXG z3aem)1~q-xa#4NUFB@^tFCiNv3!Z1+TX$vMSEW@Gp~;u7HC(%puqAw{(#|{Sn$g6D zL?Bh#m8m&DO%tuD(tV3gzNmP~?x=PBs(9SFSPH6qUR;wD*GyK;d$uJ#+dhgx| z#N7*`+iH2Q?n>Q9o2G5oy>nuFroLw0)1LIS-&7I@ZiMH=uAG(~Nzaa(@q}~(X!$ZN zFv^~!XHUX6CwAu4?oN7kCt7cd`<@=+2&*EFxGLh77--%7SkVPp4$x2^Rv*9& z4P<%{GQIo^jj1XK?`Rr0=pk%<5wmX0&?e1=fSpW`*;sN|1spOV>ohWYzLwNz=iRmCPtqczw6a5Srj3(+6y{XdunVR#{!-@J-X~*NhL+|yGIdNB( z@S5ZqKTp_p~NGtv7ch%5LnM6FYKR@_>K|C)0r(>QHM!N$-u3+hW%$@Ng9j zk*8&lNWv=c-~eOhDH|k`^J&3@^dvyDDm*;FoVcDxzytHX3YG~c?L=Wh(fl|3Ve__k z>23r#$RYPiD#uAgz6@=5rgfpA4OqEbPsHn0Ui_@Tz zj&Fg`2Tn&6`9fGj+r$uCDCoU;<@AS;#0jgP;Hn=_qi>oiOO+nXi0sc2bOtnf_bMW5 z3MPMj%bd6~NBm5|th&*Wqqj}%{Au^h##_hc#3Pw+Ofj4uzAf&4SOhUr1%8gWw3+z? znb=2O>p$%;Sk`}zuq|)R5w9W*QPe7p6T8s}}5YJlZ>3>r2HL0b3CcXfJSqN|1pA(RMN8K(y(FX_*6qI(=54 zC;HheD)J*JJUS*Kkw?TtI3p$k0a~;uj#Dq>Q#KYJpj)7mX0i1Y@r%glPzinN%J4V9liO(ssSPt9WDv8C4>Dz>*PGtx%8sUCZ{oxtAj{lJ zyn&%RkdDL9M>-z@k5_kLE*k+L2Q)WA^v8<-wB+ivM;|V|>xUlClQ0E!39>QrmAhS}YJsUE@4N;VZXT z(W$bkB%bqB)qzymfpN#3l8PiAom53vs-$b2U#Rk2?)z=u`}Ob0LPU_W)B<@~n zZFkDk{dxQg=`-*sF#nU>mlPg~N{cC$ON;l|M%ue8!I zV5h+=-SM!_L#$>x?=OHtPs{A=hpRJ7n`JbasV9YgN5<@YsMXQWXKO+W_5YtyvTVd( z(LY?(1nqZN>v*KEo7V)*@)*;%Fd0gc6SR=HTnoJf8_SGRy~OatGTvITQE`EWH6=DM z@UT^?*O(!t*aa{y4M#E)wW5cSG)lf+YrsSk%%nA^>li3+||VngH@gMBD~_PRXqQTc8=rzPD=ho zyx%zBuKgPk?_!@n!EuMViL>+7&n2s$` int: @@ -40,7 +49,7 @@ class AccountingTests(unittest.TestCase): [ 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), + event("s1", "2026-01-01", "A", "paid_out", 20, 3), ] ) @@ -56,7 +65,7 @@ class AccountingTests(unittest.TestCase): [ 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), + event("s1", "2026-01-01", "A", "paid_out", 3, 3), ] ) @@ -80,6 +89,7 @@ class AccountingTests(unittest.TestCase): 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) + self.assertEqual(entry.payout_status, "partial") def test_rollover_in_is_invested_without_new_cash_in(self): rows = [ @@ -98,20 +108,180 @@ class AccountingTests(unittest.TestCase): self.assertEqual(dest_entry.invested_cents, cents(10)) self.assertEqual(dest.total_cash_in_cents, 0) + def test_payout_carry_in_does_not_count_as_investment_or_cash(self): + rows = [ + event("s1", "2026-01-01", "A", "buyin", 10, 1), + event("s1", "2026-01-01", "A", "payout_carry_in", 0.60, 2), + event("s1", "2026-01-01", "A", "cashout", 6.55, 3), + event("s1", "2026-01-01", "A", "paid_out", 6.55, 4), + ] + session, entry = self.entry_for(rows) + player = build_leaderboard([session])[0] + + self.assertEqual(entry.payout_carry_in_cents, cents(0.60)) + self.assertEqual(entry.invested_cents, cents(10)) + self.assertEqual(entry.net_cents, cents(-3.45)) + self.assertEqual(entry.current_due_to_player_cents, cents(0.60)) + self.assertEqual(session.total_cash_in_cents, cents(10)) + self.assertEqual(player.total_payout_carry_in_cents, cents(0.60)) + 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), + event("s1", "2026-01-01", "A", "debt_repayment", 6, 3), + event("s1", "2026-01-01", "A", "writeoff", 4, 4), ] ) - self.assertEqual(entry.player_owes_gross_cents, cents(10)) + self.assertEqual(entry.gross_due_to_house_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)) + self.assertEqual(session.total_writeoff_cents, cents(4)) + + def test_banker_book_position_separates_cash_and_open_items(self): + rows = [ + event("s1", "2026-01-01", "A", "buyin", 10, 1), + event("s1", "2026-01-01", "A", "cashout", 25, 2), + event("s1", "2026-01-01", "A", "paid_out", 5, 3), + event("s1", "2026-01-01", "A", "rollover_out", 10, 4), + event("s1", "2026-01-01", "B", "front", 20, 5), + event("s1", "2026-01-01", "B", "cashout", 0, 6), + event("s1", "2026-01-01", "B", "debt_repayment", 8, 7), + event("s1", "2026-01-01", "B", "writeoff", 2, 8), + ] + sessions = build_session_summaries(rows) + session = next(s for s in sessions if s.session_id == "s1") + + self.assertEqual(session.total_invested_cents, cents(30)) + self.assertEqual(session.total_cash_out_cents, cents(25)) + self.assertEqual(session.total_real_cash_in_cents, cents(18)) + self.assertEqual(session.total_real_cash_out_cents, cents(5)) + self.assertEqual(session.total_current_due_to_player_cents, cents(10)) + self.assertEqual(session.total_current_due_to_house_cents, cents(10)) + self.assertEqual(session.total_net_book_position_cents, cents(13)) + + def test_player_stats_track_house_owes_player_separately_from_cash_paid(self): + rows = [ + event("s1", "2026-01-01", "A", "buyin", 10, 1), + event("s1", "2026-01-01", "A", "cashout", 25, 2), + event("s1", "2026-01-01", "A", "paid_out", 5, 3), + ] + player = build_leaderboard(build_session_summaries(rows))[0] + + self.assertEqual(player.total_cash_out_cents, cents(25)) + self.assertEqual(player.total_gross_payout_cents, cents(25)) + self.assertEqual(player.total_paid_out_cents, cents(5)) + self.assertEqual(player.current_due_to_player_cents, cents(20)) + self.assertEqual(player.total_real_cash_out_cents, cents(5)) + + def test_rollover_out_can_settle_without_being_marked_paid(self): + rows = [ + event("s1", "2026-01-01", "A", "buyin", 10, 1), + event("s1", "2026-01-01", "A", "cashout", 20, 2), + event("s1", "2026-01-01", "A", "rollover_out", 20, 3), + ] + entry = build_session_summaries(rows)[0].entries[0] + + self.assertEqual(entry.current_due_to_player_cents, 0) + self.assertEqual(entry.paid_out_cents, 0) + self.assertEqual(entry.payout_status, "settled") + + def test_admin_can_apply_pending_payout_carry_in_to_open_session(self): + rows = [ + event("s1", "2026-01-01", "", "session_open", 0, 1), + event("s1", "2026-01-01", "A", "buyin", 10, 2), + event("s1", "2026-01-01", "A", "cashout", 20, 3), + event("s1", "2026-01-01", "A", "rollover_out", 5, 4), + event("s1", "2026-01-01", "", "session_close", 0, 5), + event("s2", "2026-01-02", "", "session_open", 0, 6), + event("s2", "2026-01-02", "A", "buyin", 10, 7), + ] + + with tempfile.TemporaryDirectory() as tmpdir: + data_path = Path(tmpdir) / "entries.csv" + write_events(data_path, rows) + original_path = app_module.DATA_PATH + app_module.DATA_PATH = data_path + + try: + sessions = build_session_summaries(load_events(data_path)) + self.assertEqual( + app_module.pending_payout_carry_items(sessions), + [{"player_name": "A", "amount_cents": cents(5)}], + ) + + with app_module.app.test_client() as client: + with client.session_transaction() as flask_session: + flask_session["is_admin"] = True + + response = client.post( + "/admin/apply-payout-carry-in", + data={"session_id": "s2", "player_name": "A"}, + ) + finally: + app_module.DATA_PATH = original_path + + self.assertEqual(response.status_code, 302) + updated_sessions = build_session_summaries(load_events(data_path)) + target = next(s for s in updated_sessions if s.session_id == "s2") + target_entry = target.entries[0] + self.assertEqual(target_entry.payout_carry_in_cents, cents(5)) + self.assertEqual(target_entry.invested_cents, cents(10)) + self.assertEqual(target_entry.current_due_to_player_cents, cents(5)) + + def test_same_day_sessions_sort_by_session_sequence(self): + rows = [ + event("2026-03-21-02", "2026-03-21", "A", "buyin", 10, 1), + event("2026-03-21-02", "2026-03-21", "A", "cashout", 11, 2), + event("2026-03-21-01", "2026-03-21", "A", "buyin", 10, 3), + event("2026-03-21-01", "2026-03-21", "A", "cashout", 9, 4), + ] + sessions = build_session_summaries(rows) + ordered = sorted(sessions, key=session_sort_key) + + self.assertEqual([s.session_id for s in ordered], ["2026-03-21-01", "2026-03-21-02"]) + self.assertEqual( + player_session_series(sessions, "A")["labels"], + ["Mar 21, 2026 · S1", "Mar 21, 2026 · S2"], + ) + + def test_admin_prunes_only_empty_marker_sessions(self): + rows = [ + event("empty-1", "2026-01-01", "", "session_open", 0, 1), + event("empty-1", "2026-01-01", "", "session_close", 0, 2), + event("played-1", "2026-01-02", "", "session_open", 0, 3), + event("played-1", "2026-01-02", "A", "buyin", 10, 4), + event("played-1", "2026-01-02", "A", "cashout", 15, 5), + event("played-1", "2026-01-02", "", "session_close", 0, 6), + ] + + with tempfile.TemporaryDirectory() as tmpdir: + data_path = Path(tmpdir) / "entries.csv" + write_events(data_path, rows) + original_path = app_module.DATA_PATH + app_module.DATA_PATH = data_path + + try: + with app_module.app.test_client() as client: + with client.session_transaction() as flask_session: + flask_session["is_admin"] = True + + response = client.post("/admin/prune-empty-sessions") + finally: + app_module.DATA_PATH = original_path + + self.assertEqual(response.status_code, 302) + remaining = load_events(data_path) + self.assertEqual( + {row["session_id"] for row in remaining}, + {"played-1"}, + ) + self.assertEqual( + [row["event_type"] for row in remaining], + ["session_open", "buyin", "cashout", "session_close"], + ) if __name__ == "__main__": From 1dc4f0ec368534185c455d7107c04813731e31b6 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Mon, 22 Jun 2026 14:17:10 -0700 Subject: [PATCH 33/39] backend overhaul --- .gitignore | 23 +- app.py | 870 +-------------------------------- auth.py | 13 + charts.py | 149 ++++++ config.py | 20 + models.py | 366 ++++++++++++++ routes/__init__.py | 0 routes/admin.py | 583 ++++++++++++++++++++++ routes/public.py | 186 +++++++ services.py | 343 +++++++++++++ stats.py | 857 -------------------------------- templates/admin_dashboard.html | 22 +- templates/base.html | 12 +- templates/leaderboard.html | 8 +- templates/session_detail.html | 10 +- templates/sessions.html | 4 +- tests/test_accounting.py | 21 +- utils.py | 84 ++++ 18 files changed, 1824 insertions(+), 1747 deletions(-) create mode 100644 auth.py create mode 100644 charts.py create mode 100644 config.py create mode 100644 models.py create mode 100644 routes/__init__.py create mode 100644 routes/admin.py create mode 100644 routes/public.py create mode 100644 services.py delete mode 100644 stats.py create mode 100644 utils.py diff --git a/.gitignore b/.gitignore index c6de75f..c6f6011 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,20 @@ +# secrets .env -/__pycache__/* -/.venv/* -/data/* -test/* + +# Python cache — matches any depth, no need to list per-directory +__pycache__/ +*.pyc +*.pyo +*.pyd + +# test artifacts +.pytest_cache/ +.coverage +htmlcov/ + +# virtualenv +.venv/ + +# data + exports +data/ +*.csv diff --git a/app.py b/app.py index db1ce04..5198539 100644 --- a/app.py +++ b/app.py @@ -1,61 +1,17 @@ #!/usr/bin/env python3 from __future__ import annotations -import csv import os -from datetime import datetime, timezone from pathlib import Path -from flask import ( - Flask, - flash, - redirect, - render_template, - request, - send_file, - session, - url_for, -) +from flask import Flask -from stats import ( - apply_rank_changes, - build_leaderboard, - build_session_summaries, - cents_to_dollars, - cumulative_profit_series, - player_session_series, - safe_date_label, - session_breakdown_series, - session_events, - session_label, - session_sort_key, - unique_player_names, -) -from storage import ( - CSV_HEADERS, - EventRow, - append_event, - ensure_data_file, - load_events, - write_events, -) - -BASE_DIR = Path(__file__).resolve().parent -DATA_PATH = BASE_DIR / "data" / "entries.csv" -ENV_PATH = BASE_DIR / ".env" - -ELIGIBLE_MIN_SESSIONS = 3 -ADMIN_EVENT_TYPES = [ - ("buyin", "Buy-in", "var(--accent)"), - ("front", "Front", "var(--warn)"), - ("rollover_in", "Rollover-in", "var(--rolled)"), - ("payout_carry_in", "Payout carry-in", "var(--rolled)"), - ("cashout", "Cashout result", "var(--rank-2)"), - ("paid_out", "Paid out cash", "var(--pos)"), - ("rollover_out", "Rollover-out", "var(--rolled)"), - ("note", "Note", "var(--muted)"), -] -SESSION_MARKER_TYPES = {"session_open", "session_close"} +from auth import is_admin +from config import Config +from routes.admin import admin_bp +from routes.public import public_bp +from storage import ensure_data_file +from utils import cents_to_dollars, safe_date_label def load_local_env(env_path: Path) -> None: @@ -73,811 +29,29 @@ def load_local_env(env_path: Path) -> None: os.environ.setdefault(key, value) -def next_session_id(sessions, session_date: str) -> str: - matching = [session for session in sessions if session.session_date == session_date] +def create_app() -> Flask: + app = Flask(__name__) + app.config.from_object(Config) - highest = 0 - for s in matching: - if s.session_id == session_date: - highest = max(highest, 1) - continue + ensure_data_file(app.config["DATA_PATH"]) - suffix = s.session_id.replace(f"{session_date}-", "") - if suffix.isdigit(): - highest = max(highest, int(suffix)) + app.jinja_env.filters["money"] = cents_to_dollars + app.jinja_env.filters["pretty_date"] = safe_date_label - return f"{session_date}-{highest + 1:02d}" + @app.context_processor + def inject_globals() -> dict: + return {"is_admin": is_admin()} + app.register_blueprint(public_bp) + app.register_blueprint(admin_bp) -def prunable_empty_session_ids(events: list[EventRow], sessions) -> set[str]: - empty_session_ids = { - session.session_id for session in sessions if len(session.entries) == 0 - } - session_events: dict[str, list[EventRow]] = {} + return app - for event in events: - session_id = event["session_id"].strip() or event["session_date"].strip() - if session_id in empty_session_ids: - session_events.setdefault(session_id, []).append(event) - return { - session_id - for session_id, rows in session_events.items() - if rows - and all( - row["event_type"] in SESSION_MARKER_TYPES - and not row["player_name"].strip() - and row["amount_cents"] == 0 - for row in rows - ) - } - - -def pending_payout_carry_items(sessions) -> list[dict[str, object]]: - carried_out: dict[str, int] = {} - carried_in: dict[str, int] = {} - - for session_summary in sessions: - for entry in session_summary.entries: - carried_out[entry.player_name] = ( - carried_out.get(entry.player_name, 0) + entry.rollover_out_cents - ) - carried_in[entry.player_name] = ( - carried_in.get(entry.player_name, 0) - + entry.rollover_in_cents - + entry.payout_carry_in_cents - ) - - items = [] - for player_name, amount_out in carried_out.items(): - pending_cents = amount_out - carried_in.get(player_name, 0) - if pending_cents > 0: - items.append({"player_name": player_name, "amount_cents": pending_cents}) - - return sorted(items, key=lambda item: str(item["player_name"]).casefold()) - - -load_local_env(ENV_PATH) - -app = Flask(__name__) -app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "change-this-before-deploying") -app.config["ADMIN_USERNAME"] = os.getenv("ADMIN_USERNAME", "admin") -app.config["ADMIN_PASSWORD"] = os.getenv("ADMIN_PASSWORD", "change-me") -app.config["SESSION_COOKIE_HTTPONLY"] = True -app.config["SESSION_COOKIE_SAMESITE"] = "Lax" -app.config["SESSION_COOKIE_NAME"] = "poker_portal_session" - -ensure_data_file(DATA_PATH) -app.jinja_env.filters["money"] = cents_to_dollars -app.jinja_env.filters["pretty_date"] = safe_date_label - - -def is_admin() -> bool: - return bool(session.get("is_admin")) - - -@app.context_processor -def inject_globals() -> dict[str, object]: - return {"is_admin": is_admin()} - - -@app.get("/") -def home() -> str: - return redirect(url_for("leaderboard")) - - -@app.get("/leaderboard") -def leaderboard() -> str: - events = load_events(DATA_PATH) - all_sessions = build_session_summaries(events) - ordered_sessions = sorted(all_sessions, key=session_sort_key) - - 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: - label = session_label(ordered_sessions[cutoff_index]) - - filtered_sessions = ordered_sessions[: cutoff_index + 1] - previous_sessions = ordered_sessions[:cutoff_index] - else: - 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", - 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, - selected_session_label=(label if selected_session_id else "Latest session"), - selected_session_date=( - ordered_sessions[cutoff_index].session_date - if selected_session_id and session_ids - else "" - ), - session_label=session_label, - ) - - -@app.get("/sessions") -def sessions(): - events = load_events(DATA_PATH) - sessions = build_session_summaries(events) - - return render_template( - "sessions.html", - sessions=sessions, - session_label=session_label, - ) - - -@app.get("/sessions/") -def session_detail(session_id: str) -> str: - events = load_events(DATA_PATH) - sessions = build_session_summaries(events) - target_session = next( - ( - session_summary - for session_summary in sessions - if session_summary.session_id == session_id - ), - None, - ) - if target_session is None: - flash("That session was not found.", "error") - return redirect(url_for("sessions")) - - chronological_sessions = sorted(sessions, key=session_sort_key) - chronological_index = next( - index - for index, session in enumerate(chronological_sessions) - if session.session_id == session_id - ) - target_session = chronological_sessions[chronological_index] - session_number = chronological_index + 1 - prev_session = ( - chronological_sessions[chronological_index - 1] - if chronological_index > 0 - else None - ) - next_session = ( - chronological_sessions[chronological_index + 1] - if chronological_index < len(chronological_sessions) - 1 - else None - ) - - return render_template( - "session_detail.html", - 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, - ) - - -@app.get("/players/") -def player_detail(player_name: str) -> str: - events = load_events(DATA_PATH) - sessions = build_session_summaries(events) - board = build_leaderboard(sessions) - player_stats = next( - (player for player in board if player.player_name == player_name), None - ) - if player_stats is None: - 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, - ) - - -@app.route("/admin/login", methods=["GET", "POST"]) -def admin_login() -> str: - if request.method == "POST": - username = request.form.get("username", "").strip() - password = request.form.get("password", "") - - if ( - username == app.config["ADMIN_USERNAME"] - and password == app.config["ADMIN_PASSWORD"] - ): - session["is_admin"] = True - flash("Admin login successful.", "success") - return redirect(url_for("admin_dashboard")) - - flash("Invalid admin credentials.", "error") - - return render_template("admin_login.html") - - -@app.post("/admin/logout") -def admin_logout() -> str: - session.clear() - flash("Logged out.", "success") - return redirect(url_for("leaderboard")) - - -@app.post("/admin/session-state") -def admin_session_state() -> str: - if not is_admin(): - flash("Admin login required.", "error") - return redirect(url_for("admin_login")) - - session_id = request.form.get("session_id", "").strip() - state = request.form.get("state", "").strip() - - events = load_events(DATA_PATH) - sessions = build_session_summaries(events) - by_id = {session.session_id: session for session in sessions} - target = by_id.get(session_id) - - if target is None: - flash("Session not found.", "error") - return redirect(url_for("admin_dashboard")) - - if state not in {"open", "closed"}: - flash("Invalid session state.", "error") - return redirect(url_for("admin_dashboard")) - - append_event( - DATA_PATH, - session_id=target.session_id, - session_date=target.session_date, - amount_cents=0, - player_name="", - event_type="session_open" if state == "open" else "session_close", - note=f"Session marked {state}.", - actor=app.config["ADMIN_USERNAME"], - ) - - flash(f"Session marked {state}.", "success") - return redirect(url_for("admin_dashboard")) - - -@app.get("/admin/export") -@app.get("/admin/export.csv") -def admin_export_csv(): - if not is_admin(): - flash("Admin login required.", "error") - return redirect(url_for("admin_login")) - - export_name = ( - f"entries_export_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.csv" - ) - - return send_file( - DATA_PATH, - as_attachment=True, - download_name=export_name, - mimetype="text/csv", - ) - - -@app.post("/admin/import") -def admin_import_csv(): - if not is_admin(): - flash("Admin login required.", "error") - return redirect(url_for("admin_login")) - - uploaded_file = request.files.get("csv_file") - if uploaded_file is None or uploaded_file.filename == "": - flash("Choose a CSV file to import.", "error") - return redirect(url_for("admin_dashboard")) - - if not uploaded_file.filename.lower().endswith(".csv"): - flash("Only CSV files are allowed.", "error") - return redirect(url_for("admin_dashboard")) - - try: - uploaded_text = uploaded_file.stream.read().decode("utf-8-sig") - except UnicodeDecodeError: - flash("CSV file must be valid UTF-8 text.", "error") - return redirect(url_for("admin_dashboard")) - - uploaded_lines = uploaded_text.splitlines() - if not uploaded_lines: - flash("CSV file is empty.", "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 != CSV_HEADERS: - flash("CSV header does not match the current audit format.", "error") - return redirect(url_for("admin_dashboard")) - - try: - 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 - - new_rows.append({header: row.get(header, "") for header in CSV_HEADERS}) - existing_ids.add(event_id) - - 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") - - return redirect(url_for("admin_dashboard")) - - -@app.post("/admin/write-off-front") -def admin_write_off_front() -> str: - if not is_admin(): - flash("Admin login required.", "error") - return redirect(url_for("admin_login")) - - debt_key = request.form.get("debt_key", "").strip() - note = request.form.get("note", "").strip() - amount_raw = request.form.get("amount", "0").strip() - - try: - amount_cents = int(round(float(amount_raw) * 100)) - except ValueError: - flash("Amount must be a number.", "error") - return redirect(url_for("admin_dashboard")) - - if amount_cents <= 0: - flash("Write-off amount must be greater than zero.", "error") - return redirect(url_for("admin_dashboard")) - - try: - session_id, player_name = debt_key.split("||", 1) - except ValueError: - flash("Select a valid player debt.", "error") - return redirect(url_for("admin_dashboard")) - - events = load_events(DATA_PATH) - sessions = build_session_summaries(events) - target = next( - (session for session in sessions if session.session_id == session_id), - None, - ) - - if target is None: - flash("Session not found.", "error") - return redirect(url_for("admin_dashboard")) - - entry = next( - (entry for entry in target.entries if entry.player_name == player_name), - None, - ) - - if entry is None or entry.player_owes_cents <= 0: - flash("That player does not have an outstanding debt.", "error") - return redirect(url_for("admin_dashboard")) - - if amount_cents > entry.player_owes_cents: - flash( - f"Write-off cannot exceed {cents_to_dollars(entry.player_owes_cents)}.", - "error", - ) - return redirect(url_for("admin_dashboard")) - - append_event( - DATA_PATH, - session_id=target.session_id, - session_date=target.session_date, - player_name=entry.player_name, - event_type="writeoff", - amount_cents=amount_cents, - note=note or "Debt written off.", - actor=app.config["ADMIN_USERNAME"], - ) - - flash("Debt written off.", "success") - return redirect(url_for("admin_dashboard")) - - -@app.post("/admin/collect-front") -def admin_collect_front() -> str: - if not is_admin(): - flash("Admin login required.", "error") - return redirect(url_for("admin_login")) - - debt_key = request.form.get("debt_key", "").strip() - note = request.form.get("note", "").strip() - amount_raw = request.form.get("amount", "0").strip() - - try: - amount_cents = int(round(float(amount_raw) * 100)) - except ValueError: - flash("Amount must be a number.", "error") - return redirect(url_for("admin_dashboard")) - - if amount_cents <= 0: - flash("Collected amount must be greater than zero.", "error") - return redirect(url_for("admin_dashboard")) - - try: - session_id, player_name = debt_key.split("||", 1) - except ValueError: - flash("Select a valid player debt.", "error") - return redirect(url_for("admin_dashboard")) - - events = load_events(DATA_PATH) - sessions = build_session_summaries(events) - target = next( - (session for session in sessions if session.session_id == session_id), - None, - ) - - if target is None: - flash("Session not found.", "error") - return redirect(url_for("admin_dashboard")) - - entry = next( - (entry for entry in target.entries if entry.player_name == player_name), - None, - ) - - if entry is None or entry.player_owes_cents <= 0: - flash("That player does not have an outstanding debt.", "error") - return redirect(url_for("admin_dashboard")) - - if amount_cents > entry.player_owes_cents: - flash( - f"Collection cannot exceed {cents_to_dollars(entry.player_owes_cents)}.", - "error", - ) - return redirect(url_for("admin_dashboard")) - - append_event( - DATA_PATH, - session_id=target.session_id, - session_date=target.session_date, - player_name=entry.player_name, - event_type="debt_repayment", - amount_cents=amount_cents, - note=note or "Debt repayment collected.", - actor=app.config["ADMIN_USERNAME"], - ) - - flash("Debt repayment collected.", "success") - return redirect(url_for("admin_dashboard")) - - -@app.post("/admin/open-session") -def admin_open_session() -> str: - if not is_admin(): - flash("Admin login required.", "error") - return redirect(url_for("admin_login")) - - session_date = request.form.get("session_date", "").strip() - if not session_date: - flash("Session date is required.", "error") - return redirect(url_for("admin_dashboard")) - - events = load_events(DATA_PATH) - sessions = build_session_summaries(events) - session_id = next_session_id(sessions, session_date) - - append_event( - DATA_PATH, - session_id=session_id, - session_date=session_date, - amount_cents=0, - player_name="", - event_type="session_open", - note="Session opened.", - actor=app.config["ADMIN_USERNAME"], - ) - - flash(f"Opened session {session_id}.", "success") - return redirect(url_for("admin_dashboard")) - - -@app.post("/admin/prune-empty-sessions") -def admin_prune_empty_sessions() -> str: - if not is_admin(): - flash("Admin login required.", "error") - return redirect(url_for("admin_login")) - - events = load_events(DATA_PATH) - sessions = build_session_summaries(events) - prunable_session_ids = prunable_empty_session_ids(events, sessions) - - if not prunable_session_ids: - flash("No empty marker-only sessions to prune.", "success") - return redirect(url_for("admin_dashboard")) - - pruned_events = [ - event - for event in events - if (event["session_id"].strip() or event["session_date"].strip()) - not in prunable_session_ids - ] - write_events(DATA_PATH, pruned_events) - - flash( - f"Pruned {len(prunable_session_ids)} empty session" - f"{'s' if len(prunable_session_ids) != 1 else ''}.", - "success", - ) - return redirect(url_for("admin_dashboard")) - - -@app.post("/admin/apply-payout-carry-in") -def admin_apply_payout_carry_in() -> str: - if not is_admin(): - flash("Admin login required.", "error") - return redirect(url_for("admin_login")) - - session_id = request.form.get("session_id", "").strip() - player_name = request.form.get("player_name", "").strip() - - events = load_events(DATA_PATH) - sessions = build_session_summaries(events) - by_id = {session.session_id: session for session in sessions} - target = by_id.get(session_id) - - if target is None or target.status != "open": - flash("Select a valid open session.", "error") - return redirect(url_for("admin_dashboard")) - - if not any(entry.player_name == player_name for entry in target.entries): - flash("Add that player to the open session before applying carry-in.", "error") - return redirect(url_for("admin_dashboard")) - - pending_by_player = { - str(item["player_name"]): int(item["amount_cents"]) - for item in pending_payout_carry_items(sessions) - } - amount_cents = pending_by_player.get(player_name, 0) - - if amount_cents <= 0: - flash("No pending payout carry-in found for that player.", "error") - return redirect(url_for("admin_dashboard")) - - append_event( - DATA_PATH, - session_id=target.session_id, - session_date=target.session_date, - player_name=player_name, - event_type="payout_carry_in", - amount_cents=amount_cents, - note="Payout carry-in from prior carry-out.", - actor=app.config["ADMIN_USERNAME"], - ) - - flash( - f"Applied {cents_to_dollars(amount_cents)} payout carry-in for {player_name}.", - "success", - ) - return redirect(url_for("admin_dashboard")) - - -@app.route("/admin", methods=["GET", "POST"]) -def admin_dashboard(): - if not is_admin(): - return redirect(url_for("admin_login")) - - if request.method == "POST": - session_id = request.form.get("session_id", "").strip() - player_name = request.form.get("player_name", "").strip() - event_type = request.form.get("event_type", "").strip() - note = request.form.get("note", "").strip() - - amount_raw = request.form.get("amount", "0").strip() - try: - amount_cents = int(round(float(amount_raw) * 100)) - except ValueError: - flash("Amount must be a number.", "error") - return redirect(url_for("admin_dashboard")) - - events = load_events(DATA_PATH) - sessions = build_session_summaries(events) - by_id = {session.session_id: session for session in sessions} - target = by_id.get(session_id) - - if target is None: - flash("Select a valid open session.", "error") - return redirect(url_for("admin_dashboard")) - - if target.status != "open": - flash( - "That session is closed. Reopen it first if you need to add events.", - "error", - ) - return redirect(url_for("admin_dashboard")) - - append_event( - DATA_PATH, - session_id=target.session_id, - session_date=target.session_date, - player_name=player_name, - event_type=event_type, - amount_cents=amount_cents, - note=note, - actor=app.config["ADMIN_USERNAME"], - ) - - flash("Event added.", "success") - 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) - empty_session_count = len(prunable_empty_session_ids(events, sessions)) - pending_carry_items = pending_payout_carry_items(sessions) - pending_carry_total_cents = sum( - int(item["amount_cents"]) for item in pending_carry_items - ) - open_sessions = [session for session in sessions if session.status == "open"] - live_session = open_sessions[0] if open_sessions else None - live_session_player_names = ( - {entry.player_name for entry in live_session.entries} - if live_session is not None - else set() - ) - live_session_no = "" - live_unresolved_count = 0 - if live_session is not None: - 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 - 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 - for entry in session.entries - if entry.player_owes_cents > 0 - ] - - cash_in_cents = sum(session.total_banker_cash_in_cents for session in sessions) - cash_out_cents = sum(session.total_banker_cash_out_cents for session in sessions) - house_owes_players_cents = ( - sum(session.total_current_due_to_player_cents for session in sessions) - + pending_carry_total_cents - ) - players_owe_house_cents = sum( - session.total_current_due_to_house_cents for session in sessions - ) - admin_totals = { - "cash_in_cents": cash_in_cents, - "cash_out_cents": cash_out_cents, - "house_owes_players_cents": house_owes_players_cents, - "players_owe_house_cents": players_owe_house_cents, - "pending_carry_cents": pending_carry_total_cents, - "collected_cents": sum( - session.total_debt_repayment_cents for session in sessions - ), - "written_off_cents": sum( - session.total_writeoff_cents for session in sessions - ), - "net_book_position_cents": ( - cash_in_cents - - cash_out_cents - + players_owe_house_cents - - house_owes_players_cents - ), - } - - return render_template( - "admin_dashboard.html", - open_sessions=open_sessions, - recent_sessions=recent_sessions, - recent_events=recent_events, - debt_entries=debt_entries, - live_session=live_session, - live_session_player_names=live_session_player_names, - live_session_no=live_session_no, - live_unresolved_count=live_unresolved_count, - pending_carry_items=pending_carry_items, - player_names=unique_player_names(events), - append_form=append_form, - event_types=ADMIN_EVENT_TYPES, - 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, - empty_session_count=empty_session_count, - ) +_env_path = Path(__file__).resolve().parent / ".env" +load_local_env(_env_path) +app = create_app() if __name__ == "__main__": app.run(debug=True) diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..19da07b --- /dev/null +++ b/auth.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +"""Authentication helpers. + +Kept in its own module so both app.py (context processor) and routes/admin.py +(route guards) can import is_admin() without circular imports. +""" +from __future__ import annotations + +from flask import session as flask_session + + +def is_admin() -> bool: + return bool(flask_session.get("is_admin")) diff --git a/charts.py b/charts.py new file mode 100644 index 0000000..0a8d074 --- /dev/null +++ b/charts.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Chart data series builders for the frontend.""" +from __future__ import annotations + +from typing import Any + +from models import SessionEntry, SessionSummary +from utils import net_result_bucket, session_chart_label, session_sort_key + +PLAYER_PALETTE = [ + "#9b8cf0", # --line-1 violet + "#6fc093", # --line-2 green + "#e0b15c", # --line-3 gold + "#cf6f86", # --line-4 pink + "#8f93c2", # --line-5 lavender + "#7cb9e0", + "#f4a261", + "#a78bb0", + "#5cb8a0", + "#e58f3a", + "#84cc16", + "#06b6d4", +] + + +def color_for_name(name: str, names: list[str]) -> str: + try: + index = sorted(names, key=str.casefold).index(name) + except ValueError: + index = abs(hash(name)) + return PLAYER_PALETTE[index % len(PLAYER_PALETTE)] + + +def net_tone(value_cents: int) -> str: + bucket = net_result_bucket(value_cents) + + if bucket == "win": + return "#6fc093" # --pos + if bucket == "loss": + return "#e0758a" # --neg + return "#84828e" # --muted-2 + + +def cumulative_profit_series(sessions: list[SessionSummary]) -> dict[str, object]: + ordered_sessions = sorted(sessions, key=session_sort_key) + + all_players = sorted( + { + entry.player_name + for session in ordered_sessions + for entry in session.entries + }, + key=str.casefold, + ) + + labels = [session_chart_label(session) for session in ordered_sessions] + datasets = [] + + for player_name in all_players: + running_total = 0 + seen_player = False + sessions_played = 0 + data: list[float | None] = [] + + for session in ordered_sessions: + matching_entry = next( + ( + entry + for entry in session.entries + if entry.player_name == player_name + ), + None, + ) + + if matching_entry is None: + data.append(running_total / 100 if seen_player else None) + continue + + seen_player = True + sessions_played += 1 + running_total += matching_entry.net_cents + data.append(round(running_total / 100, 2)) + + datasets.append( + { + "label": player_name, + "data": data, + "sessions_played": sessions_played, + } + ) + + return { + "labels": labels, + "datasets": datasets, + } + + +def player_session_series( + sessions: list[SessionSummary], player_name: str +) -> dict[str, Any]: + ordered_sessions = sorted(sessions, key=session_sort_key) + labels: list[str] = [] + net_values: list[float] = [] + cumulative_values: list[float] = [] + running_total = 0 + + all_player_names = sorted( + { + entry.player_name + for session in ordered_sessions + for entry in session.entries + }, + key=str.casefold, + ) + + for session in ordered_sessions: + matching_entry = next( + (entry for entry in session.entries if entry.player_name == player_name), + None, + ) + if matching_entry is None: + continue + + labels.append(session_chart_label(session)) + net_values.append(round(matching_entry.net_cents / 100, 2)) + running_total += matching_entry.net_cents + cumulative_values.append(round(running_total / 100, 2)) + + return { + "labels": labels, + "color": color_for_name(player_name, all_player_names), + "net_values": net_values, + "net_colors": [net_tone(round(value * 100)) for value in net_values], + "cumulative_values": cumulative_values, + } + + +def session_breakdown_series(session: SessionSummary) -> dict[str, Any]: + ordered_entries = sorted( + session.entries, + key=lambda entry: (entry.net_cents, entry.player_name.casefold()), + reverse=True, + ) + + return { + "labels": [entry.player_name for entry in ordered_entries], + "net_values": [round(entry.net_cents / 100, 2) for entry in ordered_entries], + "net_colors": [net_tone(entry.net_cents) for entry in ordered_entries], + } diff --git a/config.py b/config.py new file mode 100644 index 0000000..442fec3 --- /dev/null +++ b/config.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent +DATA_PATH = BASE_DIR / "data" / "entries.csv" + +ELIGIBLE_MIN_SESSIONS = 3 + + +class Config: + SECRET_KEY: str = os.getenv("SECRET_KEY", "change-this-before-deploying") + ADMIN_USERNAME: str = os.getenv("ADMIN_USERNAME", "admin") + ADMIN_PASSWORD: str = os.getenv("ADMIN_PASSWORD", "change-me") + SESSION_COOKIE_HTTPONLY: bool = True + SESSION_COOKIE_SAMESITE: str = "Lax" + SESSION_COOKIE_NAME: str = "poker_portal_session" + DATA_PATH: Path = DATA_PATH diff --git a/models.py b/models.py new file mode 100644 index 0000000..223e4e5 --- /dev/null +++ b/models.py @@ -0,0 +1,366 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass +class SessionEntry: + session_id: str + session_date: str + player_name: str + buy_in_cents: int = 0 + front_cents: int = 0 + front_collected_cents: int = 0 + front_writeoff_cents: int = 0 + cash_out_cents: int = 0 + paid_cents: int = 0 + rollover_in_cents: int = 0 + payout_carry_in_cents: int = 0 + rollover_out_cents: int = 0 + notes: list[str] = field(default_factory=list) + + @property + def invested_cents(self) -> int: + # Poker performance: money/chips that entered play. This is intentionally + # broader than real cash because fronts and rollover-ins affect results. + return self.buy_in_cents + self.front_cents + self.rollover_in_cents + + @property + def net_cents(self) -> int: + return self.cash_out_cents - self.invested_cents + + @property + def paid_out_cents(self) -> int: + return self.paid_cents + + @property + def debt_repayment_cents(self) -> int: + return self.front_collected_cents + + @property + def writeoff_cents(self) -> int: + return self.front_writeoff_cents + + @property + def gross_payout_cents(self) -> int: + # Settlement: cashout is a gross chip count/result, not proof that cash + # 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 player_owes_gross_cents(self) -> int: + return max(self.front_cents - self.cash_out_cents, 0) + + @property + def gross_due_to_house_cents(self) -> int: + return self.player_owes_gross_cents + + @property + def settled_to_player_cents(self) -> int: + # Rollover-out resolves the source session's payable even though it is + # not real cash out. The destination session records it as rollover-in. + return self.paid_out_cents + self.rollover_out_cents + + @property + def current_due_to_player_cents(self) -> int: + return max( + self.gross_payout_cents + + self.payout_carry_in_cents + - self.settled_to_player_cents, + 0, + ) + + @property + def settled_to_house_cents(self) -> int: + # Front collections are real cash in. Writeoffs are not cash, but they + # do resolve the receivable. + return self.debt_repayment_cents + self.writeoff_cents + + @property + def current_due_to_house_cents(self) -> int: + return max(self.gross_due_to_house_cents - self.settled_to_house_cents, 0) + + @property + def real_cash_in_cents(self) -> int: + return self.buy_in_cents + self.debt_repayment_cents + + @property + def real_cash_out_cents(self) -> int: + return self.paid_out_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.gross_due_to_house_cents + + @property + def front_shortfall_cents(self) -> int: + return self.gross_due_to_house_cents + + @property + def overpaid_front_cents(self) -> int: + return 0 + + @property + def front_writeoff_applied_cents(self) -> int: + return min( + self.writeoff_cents, + max(self.gross_due_to_house_cents - self.front_collected_applied_cents, 0), + ) + + @property + def front_collected_applied_cents(self) -> int: + return min(self.debt_repayment_cents, self.gross_due_to_house_cents) + + @property + def front_resolved_cents(self) -> int: + return min(self.settled_to_house_cents, self.gross_due_to_house_cents) + + @property + def payout_status(self) -> str: + if self.current_due_to_house_cents > 0: + return "owes" + if self.writeoff_cents > 0: + return "written_off" + if self.debt_repayment_cents > 0: + return "collected" + if self.gross_payout_cents <= 0: + return "none" + if self.current_due_to_player_cents <= 0: + if self.paid_out_cents < self.gross_payout_cents: + return "settled" + return "paid" + if self.settled_to_player_cents <= 0: + return "unpaid" + return "partial" + + +@dataclass +class SessionSummary: + session_id: str + session_date: str + entries: list[SessionEntry] + status: str = "closed" + opened_at: str = "" + + @property + def is_open(self) -> bool: + return self.status == "open" + + @property + def total_buy_in_cents(self) -> int: + return sum(entry.buy_in_cents for entry in self.entries) + + @property + def total_front_cents(self) -> int: + return sum(entry.front_cents for entry in self.entries) + + @property + def total_rollover_in_cents(self) -> int: + return sum(entry.rollover_in_cents for entry in self.entries) + + @property + def total_payout_carry_in_cents(self) -> int: + return sum(entry.payout_carry_in_cents for entry in self.entries) + + @property + def total_invested_cents(self) -> int: + return sum(entry.invested_cents for entry in self.entries) + + @property + def total_cash_out_cents(self) -> int: + return sum(entry.cash_out_cents for entry in self.entries) + + @property + def total_payout_due_cents(self) -> int: + return self.total_gross_payout_cents + + @property + def total_remaining_cents(self) -> int: + return self.total_current_due_to_player_cents + + @property + def total_net_cents(self) -> int: + return sum(entry.net_cents for entry in self.entries) + + @property + def total_gross_payout_cents(self) -> int: + return sum(entry.gross_payout_cents for entry in self.entries) + + @property + def total_paid_cents(self) -> int: + return self.total_paid_out_cents + + @property + def total_paid_out_cents(self) -> int: + return sum(entry.paid_out_cents for entry in self.entries) + + @property + def total_rollover_out_cents(self) -> int: + return sum(entry.rollover_out_cents for entry in self.entries) + + @property + def total_settled_to_player_cents(self) -> int: + return sum(entry.settled_to_player_cents for entry in self.entries) + + @property + 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.gross_due_to_house_cents for entry in self.entries) + + @property + def total_gross_due_to_house_cents(self) -> int: + return self.total_player_owes_gross_cents + + @property + def total_settled_to_house_cents(self) -> int: + 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 self.total_writeoff_cents + + @property + def total_front_collected_cents(self) -> int: + return self.total_debt_repayment_cents + + @property + def total_debt_repayment_cents(self) -> int: + return sum(entry.debt_repayment_cents for entry in self.entries) + + @property + def total_writeoff_cents(self) -> int: + return sum(entry.writeoff_cents for entry in self.entries) + + @property + def total_cash_in_cents(self) -> int: + return sum(entry.real_cash_in_cents for entry in self.entries) + + @property + def total_real_cash_in_cents(self) -> int: + return self.total_cash_in_cents + + @property + def total_real_cash_out_cents(self) -> int: + return self.total_paid_out_cents + + @property + def total_banker_cash_in_cents(self) -> int: + return self.total_real_cash_in_cents + + @property + def total_banker_cash_out_cents(self) -> int: + return self.total_real_cash_out_cents + + @property + def total_current_due_cents(self) -> int: + 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_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 +class PlayerStats: + player_name: str + sessions_played: int + winning_sessions: int + losing_sessions: int + break_even_sessions: int + win_pct: float + avg_win_cents: int + avg_loss_cents: int + biggest_win_cents: int + biggest_loss_cents: int + total_buy_in_cents: int + total_front_cents: int + total_front_collected_cents: int + total_front_writeoff_cents: int + current_player_owes_cents: int + total_rollover_in_cents: int + total_payout_carry_in_cents: int + total_invested_cents: int + total_cash_out_cents: int + total_gross_payout_cents: int + total_paid_cents: int + total_rollover_out_cents: int + current_due_to_player_cents: int + total_net_cents: int + roi_pct: float + current_win_streak: int + current_loss_streak: int + longest_win_streak: int + longest_loss_streak: int + best_session_date: str | None + best_session_net_cents: int + worst_session_date: str | None + worst_session_net_cents: int + rank_change: int = 0 + + @property + def total_debt_repayment_cents(self) -> int: + return self.total_front_collected_cents + + @property + def total_writeoff_cents(self) -> int: + return self.total_front_writeoff_cents + + @property + def current_due_to_house_cents(self) -> int: + return self.current_player_owes_cents + + @property + def total_paid_out_cents(self) -> int: + return self.total_paid_cents + + @property + def total_real_cash_out_cents(self) -> int: + return self.total_paid_out_cents + + @property + def total_real_cash_in_cents(self) -> int: + return self.total_buy_in_cents + self.total_debt_repayment_cents diff --git a/routes/__init__.py b/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/routes/admin.py b/routes/admin.py new file mode 100644 index 0000000..08f44ab --- /dev/null +++ b/routes/admin.py @@ -0,0 +1,583 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import csv +from datetime import datetime, timezone + +from flask import ( + Blueprint, + current_app, + flash, + redirect, + render_template, + request, + send_file, + session as flask_session, + url_for, +) + +from auth import is_admin +from services import ( + build_session_summaries, + next_session_id, + pending_payout_carry_items, + prunable_empty_session_ids, + unique_player_names, +) +from storage import CSV_HEADERS, append_event, load_events, write_events +from utils import cents_to_dollars, session_label + +admin_bp = Blueprint("admin", __name__) + +ADMIN_EVENT_TYPES = [ + ("buyin", "Buy-in", "var(--accent)"), + ("front", "Front", "var(--warn)"), + ("rollover_in", "Rollover-in", "var(--rolled)"), + ("payout_carry_in", "Payout carry-in", "var(--rolled)"), + ("cashout", "Cashout result", "var(--rank-2)"), + ("paid_out", "Paid out cash", "var(--pos)"), + ("rollover_out", "Rollover-out", "var(--rolled)"), + ("note", "Note", "var(--muted)"), +] + + +@admin_bp.route("/admin/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + + if ( + username == current_app.config["ADMIN_USERNAME"] + and password == current_app.config["ADMIN_PASSWORD"] + ): + flask_session["is_admin"] = True + flash("Admin login successful.", "success") + return redirect(url_for("admin.dashboard")) + + flash("Invalid admin credentials.", "error") + + return render_template("admin_login.html") + + +@admin_bp.post("/admin/logout") +def logout(): + flask_session.clear() + flash("Logged out.", "success") + return redirect(url_for("public.leaderboard")) + + +@admin_bp.post("/admin/session-state") +def session_state(): + if not is_admin(): + flash("Admin login required.", "error") + return redirect(url_for("admin.login")) + + data_path = current_app.config["DATA_PATH"] + session_id = request.form.get("session_id", "").strip() + state = request.form.get("state", "").strip() + + events = load_events(data_path) + sessions = build_session_summaries(events) + by_id = {s.session_id: s for s in sessions} + target = by_id.get(session_id) + + if target is None: + flash("Session not found.", "error") + return redirect(url_for("admin.dashboard")) + + if state not in {"open", "closed"}: + flash("Invalid session state.", "error") + return redirect(url_for("admin.dashboard")) + + append_event( + data_path, + session_id=target.session_id, + session_date=target.session_date, + amount_cents=0, + player_name="", + event_type="session_open" if state == "open" else "session_close", + note=f"Session marked {state}.", + actor=current_app.config["ADMIN_USERNAME"], + ) + + flash(f"Session marked {state}.", "success") + return redirect(url_for("admin.dashboard")) + + +@admin_bp.get("/admin/export") +@admin_bp.get("/admin/export.csv") +def export_csv(): + if not is_admin(): + flash("Admin login required.", "error") + return redirect(url_for("admin.login")) + + data_path = current_app.config["DATA_PATH"] + export_name = ( + f"entries_export_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.csv" + ) + + return send_file( + data_path, + as_attachment=True, + download_name=export_name, + mimetype="text/csv", + ) + + +@admin_bp.post("/admin/import") +def import_csv(): + if not is_admin(): + flash("Admin login required.", "error") + return redirect(url_for("admin.login")) + + data_path = current_app.config["DATA_PATH"] + uploaded_file = request.files.get("csv_file") + if uploaded_file is None or uploaded_file.filename == "": + flash("Choose a CSV file to import.", "error") + return redirect(url_for("admin.dashboard")) + + if not uploaded_file.filename.lower().endswith(".csv"): + flash("Only CSV files are allowed.", "error") + return redirect(url_for("admin.dashboard")) + + try: + uploaded_text = uploaded_file.stream.read().decode("utf-8-sig") + except UnicodeDecodeError: + flash("CSV file must be valid UTF-8 text.", "error") + return redirect(url_for("admin.dashboard")) + + uploaded_lines = uploaded_text.splitlines() + if not uploaded_lines: + flash("CSV file is empty.", "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 != CSV_HEADERS: + flash("CSV header does not match the current audit format.", "error") + return redirect(url_for("admin.dashboard")) + + try: + 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 + + new_rows.append({header: row.get(header, "") for header in CSV_HEADERS}) + existing_ids.add(event_id) + + 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") + + return redirect(url_for("admin.dashboard")) + + +@admin_bp.post("/admin/write-off-front") +def write_off_front(): + if not is_admin(): + flash("Admin login required.", "error") + return redirect(url_for("admin.login")) + + data_path = current_app.config["DATA_PATH"] + debt_key = request.form.get("debt_key", "").strip() + note = request.form.get("note", "").strip() + amount_raw = request.form.get("amount", "0").strip() + + try: + amount_cents = int(round(float(amount_raw) * 100)) + except ValueError: + flash("Amount must be a number.", "error") + return redirect(url_for("admin.dashboard")) + + if amount_cents <= 0: + flash("Write-off amount must be greater than zero.", "error") + return redirect(url_for("admin.dashboard")) + + try: + session_id, player_name = debt_key.split("||", 1) + except ValueError: + flash("Select a valid player debt.", "error") + return redirect(url_for("admin.dashboard")) + + events = load_events(data_path) + sessions = build_session_summaries(events) + target = next((s for s in sessions if s.session_id == session_id), None) + + if target is None: + flash("Session not found.", "error") + return redirect(url_for("admin.dashboard")) + + entry = next( + (e for e in target.entries if e.player_name == player_name), + None, + ) + + if entry is None or entry.player_owes_cents <= 0: + flash("That player does not have an outstanding debt.", "error") + return redirect(url_for("admin.dashboard")) + + if amount_cents > entry.player_owes_cents: + flash( + f"Write-off cannot exceed {cents_to_dollars(entry.player_owes_cents)}.", + "error", + ) + return redirect(url_for("admin.dashboard")) + + append_event( + data_path, + session_id=target.session_id, + session_date=target.session_date, + player_name=entry.player_name, + event_type="writeoff", + amount_cents=amount_cents, + note=note or "Debt written off.", + actor=current_app.config["ADMIN_USERNAME"], + ) + + flash("Debt written off.", "success") + return redirect(url_for("admin.dashboard")) + + +@admin_bp.post("/admin/collect-front") +def collect_front(): + if not is_admin(): + flash("Admin login required.", "error") + return redirect(url_for("admin.login")) + + data_path = current_app.config["DATA_PATH"] + debt_key = request.form.get("debt_key", "").strip() + note = request.form.get("note", "").strip() + amount_raw = request.form.get("amount", "0").strip() + + try: + amount_cents = int(round(float(amount_raw) * 100)) + except ValueError: + flash("Amount must be a number.", "error") + return redirect(url_for("admin.dashboard")) + + if amount_cents <= 0: + flash("Collected amount must be greater than zero.", "error") + return redirect(url_for("admin.dashboard")) + + try: + session_id, player_name = debt_key.split("||", 1) + except ValueError: + flash("Select a valid player debt.", "error") + return redirect(url_for("admin.dashboard")) + + events = load_events(data_path) + sessions = build_session_summaries(events) + target = next((s for s in sessions if s.session_id == session_id), None) + + if target is None: + flash("Session not found.", "error") + return redirect(url_for("admin.dashboard")) + + entry = next( + (e for e in target.entries if e.player_name == player_name), + None, + ) + + if entry is None or entry.player_owes_cents <= 0: + flash("That player does not have an outstanding debt.", "error") + return redirect(url_for("admin.dashboard")) + + if amount_cents > entry.player_owes_cents: + flash( + f"Collection cannot exceed {cents_to_dollars(entry.player_owes_cents)}.", + "error", + ) + return redirect(url_for("admin.dashboard")) + + append_event( + data_path, + session_id=target.session_id, + session_date=target.session_date, + player_name=entry.player_name, + event_type="debt_repayment", + amount_cents=amount_cents, + note=note or "Debt repayment collected.", + actor=current_app.config["ADMIN_USERNAME"], + ) + + flash("Debt repayment collected.", "success") + return redirect(url_for("admin.dashboard")) + + +@admin_bp.post("/admin/open-session") +def open_session(): + if not is_admin(): + flash("Admin login required.", "error") + return redirect(url_for("admin.login")) + + data_path = current_app.config["DATA_PATH"] + session_date = request.form.get("session_date", "").strip() + if not session_date: + flash("Session date is required.", "error") + return redirect(url_for("admin.dashboard")) + + events = load_events(data_path) + sessions = build_session_summaries(events) + new_session_id = next_session_id(sessions, session_date) + + append_event( + data_path, + session_id=new_session_id, + session_date=session_date, + amount_cents=0, + player_name="", + event_type="session_open", + note="Session opened.", + actor=current_app.config["ADMIN_USERNAME"], + ) + + flash(f"Opened session {new_session_id}.", "success") + return redirect(url_for("admin.dashboard")) + + +@admin_bp.post("/admin/prune-empty-sessions") +def prune_empty_sessions(): + if not is_admin(): + flash("Admin login required.", "error") + return redirect(url_for("admin.login")) + + data_path = current_app.config["DATA_PATH"] + events = load_events(data_path) + sessions = build_session_summaries(events) + prunable_ids = prunable_empty_session_ids(events, sessions) + + if not prunable_ids: + flash("No empty marker-only sessions to prune.", "success") + return redirect(url_for("admin.dashboard")) + + pruned_events = [ + event + for event in events + if (event["session_id"].strip() or event["session_date"].strip()) + not in prunable_ids + ] + write_events(data_path, pruned_events) + + flash( + f"Pruned {len(prunable_ids)} empty session" + f"{'s' if len(prunable_ids) != 1 else ''}.", + "success", + ) + return redirect(url_for("admin.dashboard")) + + +@admin_bp.post("/admin/apply-payout-carry-in") +def apply_payout_carry_in(): + if not is_admin(): + flash("Admin login required.", "error") + return redirect(url_for("admin.login")) + + data_path = current_app.config["DATA_PATH"] + session_id = request.form.get("session_id", "").strip() + player_name = request.form.get("player_name", "").strip() + + events = load_events(data_path) + sessions = build_session_summaries(events) + by_id = {s.session_id: s for s in sessions} + target = by_id.get(session_id) + + if target is None or target.status != "open": + flash("Select a valid open session.", "error") + return redirect(url_for("admin.dashboard")) + + if not any(e.player_name == player_name for e in target.entries): + flash("Add that player to the open session before applying carry-in.", "error") + return redirect(url_for("admin.dashboard")) + + pending_by_player = { + str(item["player_name"]): int(item["amount_cents"]) + for item in pending_payout_carry_items(sessions) + } + amount_cents = pending_by_player.get(player_name, 0) + + if amount_cents <= 0: + flash("No pending payout carry-in found for that player.", "error") + return redirect(url_for("admin.dashboard")) + + append_event( + data_path, + session_id=target.session_id, + session_date=target.session_date, + player_name=player_name, + event_type="payout_carry_in", + amount_cents=amount_cents, + note="Payout carry-in from prior carry-out.", + actor=current_app.config["ADMIN_USERNAME"], + ) + + flash( + f"Applied {cents_to_dollars(amount_cents)} payout carry-in for {player_name}.", + "success", + ) + return redirect(url_for("admin.dashboard")) + + +@admin_bp.route("/admin", methods=["GET", "POST"]) +def dashboard(): + if not is_admin(): + return redirect(url_for("admin.login")) + + data_path = current_app.config["DATA_PATH"] + + if request.method == "POST": + session_id = request.form.get("session_id", "").strip() + player_name = request.form.get("player_name", "").strip() + event_type = request.form.get("event_type", "").strip() + note = request.form.get("note", "").strip() + + amount_raw = request.form.get("amount", "0").strip() + try: + amount_cents = int(round(float(amount_raw) * 100)) + except ValueError: + flash("Amount must be a number.", "error") + return redirect(url_for("admin.dashboard")) + + events = load_events(data_path) + sessions = build_session_summaries(events) + by_id = {s.session_id: s for s in sessions} + target = by_id.get(session_id) + + if target is None: + flash("Select a valid open session.", "error") + return redirect(url_for("admin.dashboard")) + + if target.status != "open": + flash( + "That session is closed. Reopen it first if you need to add events.", + "error", + ) + return redirect(url_for("admin.dashboard")) + + append_event( + data_path, + session_id=target.session_id, + session_date=target.session_date, + player_name=player_name, + event_type=event_type, + amount_cents=amount_cents, + note=note, + actor=current_app.config["ADMIN_USERNAME"], + ) + + flash("Event added.", "success") + 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) + empty_session_count = len(prunable_empty_session_ids(events, sessions)) + pending_carry_items = pending_payout_carry_items(sessions) + pending_carry_total_cents = sum( + int(item["amount_cents"]) for item in pending_carry_items + ) + open_sessions = [s for s in sessions if s.status == "open"] + live_session = open_sessions[0] if open_sessions else None + live_session_player_names = ( + {e.player_name for e in live_session.entries} + if live_session is not None + else set() + ) + live_session_no = "" + live_unresolved_count = 0 + if live_session is not None: + live_session_no = f"#{len(sessions):03d}" + live_unresolved_count = sum( + 1 + for e in live_session.entries + if e.current_due_cents > 0 or e.player_owes_cents > 0 + ) + + 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": s, "entry": e} + for s in sessions + for e in s.entries + if e.player_owes_cents > 0 + ] + + cash_in_cents = sum(s.total_banker_cash_in_cents for s in sessions) + cash_out_cents = sum(s.total_banker_cash_out_cents for s in sessions) + house_owes_players_cents = ( + sum(s.total_current_due_to_player_cents for s in sessions) + + pending_carry_total_cents + ) + players_owe_house_cents = sum( + s.total_current_due_to_house_cents for s in sessions + ) + admin_totals = { + "cash_in_cents": cash_in_cents, + "cash_out_cents": cash_out_cents, + "house_owes_players_cents": house_owes_players_cents, + "players_owe_house_cents": players_owe_house_cents, + "pending_carry_cents": pending_carry_total_cents, + "collected_cents": sum(s.total_debt_repayment_cents for s in sessions), + "written_off_cents": sum(s.total_writeoff_cents for s in sessions), + "net_book_position_cents": ( + cash_in_cents + - cash_out_cents + + players_owe_house_cents + - house_owes_players_cents + ), + } + + return render_template( + "admin_dashboard.html", + open_sessions=open_sessions, + recent_sessions=sessions, + recent_events=recent_events, + debt_entries=debt_entries, + live_session=live_session, + live_session_player_names=live_session_player_names, + live_session_no=live_session_no, + live_unresolved_count=live_unresolved_count, + pending_carry_items=pending_carry_items, + player_names=unique_player_names(events), + append_form=append_form, + event_types=ADMIN_EVENT_TYPES, + 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, + empty_session_count=empty_session_count, + ) diff --git a/routes/public.py b/routes/public.py new file mode 100644 index 0000000..93bef28 --- /dev/null +++ b/routes/public.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from flask import Blueprint, flash, redirect, render_template, request, url_for +from flask import current_app + +from charts import cumulative_profit_series, player_session_series, session_breakdown_series +from config import ELIGIBLE_MIN_SESSIONS +from services import ( + apply_rank_changes, + build_leaderboard, + build_session_summaries, + session_events, +) +from storage import load_events +from utils import session_label, session_sort_key + +public_bp = Blueprint("public", __name__) + + +@public_bp.get("/") +def home(): + return redirect(url_for("public.leaderboard")) + + +@public_bp.get("/leaderboard") +def leaderboard(): + events = load_events(current_app.config["DATA_PATH"]) + all_sessions = build_session_summaries(events) + ordered_sessions = sorted(all_sessions, key=session_sort_key) + + 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: + label = session_label(ordered_sessions[cutoff_index]) + + filtered_sessions = ordered_sessions[: cutoff_index + 1] + previous_sessions = ordered_sessions[:cutoff_index] + else: + filtered_sessions = [] + previous_sessions = [] + + board = build_leaderboard(filtered_sessions) + previous_board = build_leaderboard(previous_sessions) + board = apply_rank_changes(board, previous_board) + + 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 + }) + + 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(s.total_paid_out_cents for s in all_sessions) + + return render_template( + "leaderboard.html", + 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, + selected_session_label=(label if selected_session_id else "Latest session"), + selected_session_date=( + ordered_sessions[cutoff_index].session_date + if selected_session_id and session_ids + else "" + ), + session_label=session_label, + ) + + +@public_bp.get("/sessions") +def sessions(): + events = load_events(current_app.config["DATA_PATH"]) + all_sessions = build_session_summaries(events) + + return render_template( + "sessions.html", + sessions=all_sessions, + session_label=session_label, + ) + + +@public_bp.get("/sessions/") +def session_detail(session_id: str): + events = load_events(current_app.config["DATA_PATH"]) + all_sessions = build_session_summaries(events) + target_session = next( + (s for s in all_sessions if s.session_id == session_id), + None, + ) + if target_session is None: + flash("That session was not found.", "error") + return redirect(url_for("public.sessions")) + + chronological_sessions = sorted(all_sessions, key=session_sort_key) + chronological_index = next( + index + for index, s in enumerate(chronological_sessions) + if s.session_id == session_id + ) + target_session = chronological_sessions[chronological_index] + session_number = chronological_index + 1 + prev_session = ( + chronological_sessions[chronological_index - 1] + if chronological_index > 0 + else None + ) + next_session = ( + chronological_sessions[chronological_index + 1] + if chronological_index < len(chronological_sessions) - 1 + else None + ) + + return render_template( + "session_detail.html", + 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, + ) + + +@public_bp.get("/players/") +def player_detail(player_name: str): + events = load_events(current_app.config["DATA_PATH"]) + all_sessions = build_session_summaries(events) + board = build_leaderboard(all_sessions) + player_stats = next( + (player for player in board if player.player_name == player_name), None + ) + if player_stats is None: + flash("That player was not found.", "error") + return redirect(url_for("public.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(all_sessions, player_name) + return render_template( + "player_detail.html", + player=player_stats, + player_rank=player_rank, + chart_data=chart_data, + ) diff --git a/services.py b/services.py new file mode 100644 index 0000000..c364759 --- /dev/null +++ b/services.py @@ -0,0 +1,343 @@ +#!/usr/bin/env python3 +"""Domain logic: session building, leaderboard, and ledger helpers.""" +from __future__ import annotations + +from collections import defaultdict + +from models import PlayerStats, SessionEntry, SessionSummary +from storage import EventRow +from utils import entry_sort_key, net_result_bucket, session_sort_key + +SESSION_MARKER_TYPES = {"session_open", "session_close"} + + +def build_session_summaries(events: list[EventRow]) -> list[SessionSummary]: + grouped: dict[tuple[str, str], SessionEntry] = {} + by_session: dict[str, list[SessionEntry]] = defaultdict(list) + session_status: dict[str, str] = {} + session_dates: dict[str, str] = {} + session_opened_at: dict[str, str] = {} + + for event in events: + session_id = event["session_id"].strip() or event["session_date"].strip() + session_date = event["session_date"].strip() + event_type = event["event_type"].strip() + + if not session_id or not session_date: + continue + + session_dates[session_id] = session_date + session_opened_at.setdefault(session_id, event["created_at"]) + + if event_type == "session_open": + session_status[session_id] = "open" + continue + + if event_type == "session_close": + session_status[session_id] = "closed" + continue + + player_name = event["player_name"].strip() + if not player_name: + continue + + key = (session_id, player_name) + if key not in grouped: + grouped[key] = SessionEntry( + session_id=session_id, + session_date=session_date, + player_name=player_name, + ) + + entry = grouped[key] + + if event_type == "buyin": + entry.buy_in_cents += event["amount_cents"] + elif event_type == "front": + entry.front_cents += event["amount_cents"] + elif event_type == "front_collected": + entry.front_collected_cents += event["amount_cents"] + elif event_type == "debt_repayment": + entry.front_collected_cents += event["amount_cents"] + elif event_type == "front_writeoff": + entry.front_writeoff_cents += event["amount_cents"] + elif event_type == "writeoff": + entry.front_writeoff_cents += event["amount_cents"] + elif event_type == "rollover_in": + entry.rollover_in_cents += event["amount_cents"] + elif event_type == "payout_carry_in": + entry.payout_carry_in_cents += event["amount_cents"] + elif event_type == "cashout": + entry.cash_out_cents += event["amount_cents"] + elif event_type == "paid": + entry.paid_cents += event["amount_cents"] + elif event_type == "paid_out": + entry.paid_cents += event["amount_cents"] + elif event_type == "rollover_out": + entry.rollover_out_cents += event["amount_cents"] + + if event["note"]: + entry.notes.append(event["note"]) + + for entry in grouped.values(): + by_session[entry.session_id].append(entry) + + sessions: list[SessionSummary] = [] + for session_id, session_date in session_dates.items(): + entries = sorted( + by_session.get(session_id, []), + key=lambda entry: entry.player_name.casefold(), + ) + + sessions.append( + SessionSummary( + session_id=session_id, + session_date=session_date, + entries=entries, + status=session_status.get(session_id, "closed"), + opened_at=session_opened_at.get(session_id, ""), + ) + ) + + sessions.sort(key=session_sort_key, reverse=True) + return sessions + + +def summarize_player_runs(entries: list[SessionEntry]) -> dict[str, int | str | None]: + ordered_entries = sorted(entries, key=entry_sort_key) + + longest_win_streak = 0 + longest_loss_streak = 0 + current_run_wins = 0 + current_run_losses = 0 + + best_entry: SessionEntry | None = None + worst_entry: SessionEntry | None = None + + 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 + + if worst_entry is None or net < worst_entry.net_cents: + worst_entry = entry + + if bucket == "win": + current_run_wins += 1 + current_run_losses = 0 + elif bucket == "loss": + current_run_losses += 1 + current_run_wins = 0 + else: + current_run_wins = 0 + current_run_losses = 0 + + longest_win_streak = max(longest_win_streak, current_run_wins) + longest_loss_streak = max(longest_loss_streak, current_run_losses) + + current_win_streak = 0 + current_loss_streak = 0 + + for entry in reversed(ordered_entries): + bucket = net_result_bucket(entry.net_cents) + + if bucket == "win": + if current_loss_streak > 0: + break + current_win_streak += 1 + elif bucket == "loss": + if current_win_streak > 0: + break + current_loss_streak += 1 + else: + break + + return { + "current_win_streak": current_win_streak, + "current_loss_streak": current_loss_streak, + "longest_win_streak": longest_win_streak, + "longest_loss_streak": longest_loss_streak, + "best_session_date": best_entry.session_date if best_entry else None, + "best_session_net_cents": best_entry.net_cents if best_entry else 0, + "worst_session_date": worst_entry.session_date if worst_entry else None, + "worst_session_net_cents": worst_entry.net_cents if worst_entry else 0, + } + + +def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]: + player_entries: dict[str, list[SessionEntry]] = defaultdict(list) + for session in sessions: + for entry in session.entries: + player_entries[entry.player_name].append(entry) + + leaderboard: 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 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) + losing_sessions = len(losses) + break_even_sessions = sessions_played - winning_sessions - losing_sessions + win_pct = (winning_sessions / sessions_played * 100) if sessions_played else 0.0 + avg_win = round(sum(wins) / len(wins)) if wins else 0 + avg_loss = ( + round(sum(abs(value) for value in losses) / len(losses)) if losses else 0 + ) + biggest_win = max(wins) if wins else 0 + biggest_loss = min(losses) if losses else 0 + + 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.debt_repayment_cents for entry in entries) + total_front_writeoff = sum(entry.writeoff_cents for entry in entries) + current_player_owes = sum(entry.player_owes_cents for entry in entries) + total_rollover_in = sum(entry.rollover_in_cents for entry in entries) + total_payout_carry_in = sum(entry.payout_carry_in_cents for entry in entries) + total_invested = sum(entry.invested_cents for entry in entries) + total_cash_out = sum(entry.cash_out_cents for entry in entries) + total_gross_payout = sum(entry.gross_payout_cents for entry in entries) + total_paid = sum(entry.paid_out_cents for entry in entries) + total_rollover_out = sum(entry.rollover_out_cents for entry in entries) + current_due_to_player = sum(entry.current_due_to_player_cents for entry in entries) + total_net = sum(entry.net_cents for entry in entries) + roi_pct = (total_net / total_invested * 100) if total_invested else 0.0 + + leaderboard.append( + PlayerStats( + player_name=player_name, + sessions_played=sessions_played, + winning_sessions=winning_sessions, + losing_sessions=losing_sessions, + break_even_sessions=break_even_sessions, + win_pct=win_pct, + avg_win_cents=avg_win, + avg_loss_cents=avg_loss, + biggest_win_cents=biggest_win, + biggest_loss_cents=biggest_loss, + total_buy_in_cents=total_buy_in, + total_front_cents=total_front, + total_front_collected_cents=total_front_collected, + total_front_writeoff_cents=total_front_writeoff, + current_player_owes_cents=current_player_owes, + total_rollover_in_cents=total_rollover_in, + total_payout_carry_in_cents=total_payout_carry_in, + total_invested_cents=total_invested, + total_cash_out_cents=total_cash_out, + total_gross_payout_cents=total_gross_payout, + total_paid_cents=total_paid, + total_rollover_out_cents=total_rollover_out, + current_due_to_player_cents=current_due_to_player, + total_net_cents=total_net, + roi_pct=roi_pct, + current_win_streak=run_summary["current_win_streak"], + current_loss_streak=run_summary["current_loss_streak"], + longest_win_streak=run_summary["longest_win_streak"], + longest_loss_streak=run_summary["longest_loss_streak"], + best_session_date=run_summary["best_session_date"], + best_session_net_cents=run_summary["best_session_net_cents"], + worst_session_date=run_summary["worst_session_date"], + worst_session_net_cents=run_summary["worst_session_net_cents"], + ) + ) + + leaderboard.sort( + key=lambda player: (player.total_net_cents, player.total_cash_out_cents), + reverse=True, + ) + return leaderboard + + +def apply_rank_changes( + current_board: list[PlayerStats], prev_board: list[PlayerStats] +) -> list[PlayerStats]: + prev_ranks = {p.player_name: i for i, p in enumerate(prev_board, start=1)} + + for i, p in enumerate(current_board, start=1): + prev_rank = prev_ranks.get(p.player_name) + + if prev_rank is None: + p.rank_change = 0 + else: + p.rank_change = prev_rank - i + + return current_board + + +def next_session_id(sessions: list[SessionSummary], session_date: str) -> str: + matching = [s for s in sessions if s.session_date == session_date] + + highest = 0 + for s in matching: + if s.session_id == session_date: + highest = max(highest, 1) + continue + + suffix = s.session_id.replace(f"{session_date}-", "") + if suffix.isdigit(): + highest = max(highest, int(suffix)) + + return f"{session_date}-{highest + 1:02d}" + + +def prunable_empty_session_ids( + events: list[EventRow], sessions: list[SessionSummary] +) -> set[str]: + empty_session_ids = {s.session_id for s in sessions if len(s.entries) == 0} + session_event_map: dict[str, list[EventRow]] = {} + + for event in events: + session_id = event["session_id"].strip() or event["session_date"].strip() + if session_id in empty_session_ids: + session_event_map.setdefault(session_id, []).append(event) + + return { + session_id + for session_id, rows in session_event_map.items() + if rows + and all( + row["event_type"] in SESSION_MARKER_TYPES + and not row["player_name"].strip() + and row["amount_cents"] == 0 + for row in rows + ) + } + + +def pending_payout_carry_items(sessions: list[SessionSummary]) -> list[dict[str, object]]: + carried_out: dict[str, int] = {} + carried_in: dict[str, int] = {} + + for session_summary in sessions: + for entry in session_summary.entries: + carried_out[entry.player_name] = ( + carried_out.get(entry.player_name, 0) + entry.rollover_out_cents + ) + carried_in[entry.player_name] = ( + carried_in.get(entry.player_name, 0) + + entry.rollover_in_cents + + entry.payout_carry_in_cents + ) + + items = [] + for player_name, amount_out in carried_out.items(): + pending_cents = amount_out - carried_in.get(player_name, 0) + if pending_cents > 0: + items.append({"player_name": player_name, "amount_cents": pending_cents}) + + return sorted(items, key=lambda item: str(item["player_name"]).casefold()) + + +def session_events(events: list[EventRow], session_id: str) -> list[EventRow]: + return [event for event in events if event["session_id"] == session_id] + + +def unique_player_names(events: list[EventRow]) -> list[str]: + return sorted( + {event["player_name"] for event in events if event["player_name"].strip()}, + key=str.casefold, + ) diff --git a/stats.py b/stats.py deleted file mode 100644 index f89d4b2..0000000 --- a/stats.py +++ /dev/null @@ -1,857 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -from collections import defaultdict -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", - "#a78bb0", - "#5cb8a0", - "#e58f3a", - "#84cc16", - "#06b6d4", -] - -BREAK_EVEN_TOLERANCE_CENTS = 100 - - -def session_sequence_number(session_id: str, session_date: str) -> int: - suffix = session_id.strip().replace(f"{session_date.strip()}-", "", 1) - - if session_id.strip() == session_date.strip(): - return 1 - if suffix.isdigit(): - return int(suffix) - if suffix.lower().startswith("s") and suffix[1:].isdigit(): - return int(suffix[1:]) - return 9999 - - -@dataclass -class SessionEntry: - session_id: str - session_date: str - player_name: str - buy_in_cents: int = 0 - front_cents: int = 0 - front_collected_cents: int = 0 - front_writeoff_cents: int = 0 - cash_out_cents: int = 0 - paid_cents: int = 0 - rollover_in_cents: int = 0 - payout_carry_in_cents: int = 0 - rollover_out_cents: int = 0 - notes: list[str] = field(default_factory=list) - - @property - def invested_cents(self) -> int: - # Poker performance: money/chips that entered play. This is intentionally - # broader than real cash because fronts and rollover-ins affect results. - return self.buy_in_cents + self.front_cents + self.rollover_in_cents - - @property - def net_cents(self) -> int: - return self.cash_out_cents - self.invested_cents - - @property - def paid_out_cents(self) -> int: - return self.paid_cents - - @property - def debt_repayment_cents(self) -> int: - return self.front_collected_cents - - @property - def writeoff_cents(self) -> int: - return self.front_writeoff_cents - - @property - def gross_payout_cents(self) -> int: - # Settlement: cashout is a gross chip count/result, not proof that cash - # 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 player_owes_gross_cents(self) -> int: - return max(self.front_cents - self.cash_out_cents, 0) - - @property - def gross_due_to_house_cents(self) -> int: - return self.player_owes_gross_cents - - @property - def settled_to_player_cents(self) -> int: - # Rollover-out resolves the source session's payable even though it is - # not real cash out. The destination session records it as rollover-in. - return self.paid_out_cents + self.rollover_out_cents - - @property - def current_due_to_player_cents(self) -> int: - return max( - self.gross_payout_cents - + self.payout_carry_in_cents - - self.settled_to_player_cents, - 0, - ) - - @property - def settled_to_house_cents(self) -> int: - # Front collections are real cash in. Writeoffs are not cash, but they - # do resolve the receivable. - return self.debt_repayment_cents + self.writeoff_cents - - @property - def current_due_to_house_cents(self) -> int: - return max(self.gross_due_to_house_cents - self.settled_to_house_cents, 0) - - @property - def real_cash_in_cents(self) -> int: - return self.buy_in_cents + self.debt_repayment_cents - - @property - def real_cash_out_cents(self) -> int: - return self.paid_out_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.gross_due_to_house_cents - - @property - def front_shortfall_cents(self) -> int: - return self.gross_due_to_house_cents - - @property - def overpaid_front_cents(self) -> int: - return 0 - - @property - def front_writeoff_applied_cents(self) -> int: - return min( - self.writeoff_cents, - max(self.gross_due_to_house_cents - self.front_collected_applied_cents, 0), - ) - - @property - def front_collected_applied_cents(self) -> int: - return min(self.debt_repayment_cents, self.gross_due_to_house_cents) - - @property - def front_resolved_cents(self) -> int: - return min(self.settled_to_house_cents, self.gross_due_to_house_cents) - - @property - def payout_status(self) -> str: - if self.current_due_to_house_cents > 0: - return "owes" - if self.writeoff_cents > 0: - return "written_off" - if self.debt_repayment_cents > 0: - return "collected" - if self.gross_payout_cents <= 0: - return "none" - if self.current_due_to_player_cents <= 0: - if self.paid_out_cents < self.gross_payout_cents: - return "settled" - return "paid" - if self.settled_to_player_cents <= 0: - return "unpaid" - return "partial" - - -@dataclass -class SessionSummary: - session_id: str - session_date: str - entries: list[SessionEntry] - status: str = "closed" - opened_at: str = "" - - @property - def is_open(self) -> bool: - return self.status == "open" - - @property - def total_buy_in_cents(self) -> int: - return sum(entry.buy_in_cents for entry in self.entries) - - @property - def total_front_cents(self) -> int: - return sum(entry.front_cents for entry in self.entries) - - @property - def total_rollover_in_cents(self) -> int: - return sum(entry.rollover_in_cents for entry in self.entries) - - @property - def total_payout_carry_in_cents(self) -> int: - return sum(entry.payout_carry_in_cents for entry in self.entries) - - @property - def total_invested_cents(self) -> int: - return sum(entry.invested_cents for entry in self.entries) - - @property - def total_cash_out_cents(self) -> int: - return sum(entry.cash_out_cents for entry in self.entries) - - @property - def total_payout_due_cents(self) -> int: - return self.total_gross_payout_cents - - @property - def total_remaining_cents(self) -> int: - return self.total_current_due_to_player_cents - - @property - def total_net_cents(self) -> int: - return sum(entry.net_cents for entry in self.entries) - - @property - def total_gross_payout_cents(self) -> int: - return sum(entry.gross_payout_cents for entry in self.entries) - - @property - def total_paid_cents(self) -> int: - return self.total_paid_out_cents - - @property - def total_paid_out_cents(self) -> int: - return sum(entry.paid_out_cents for entry in self.entries) - - @property - def total_rollover_out_cents(self) -> int: - return sum(entry.rollover_out_cents for entry in self.entries) - - @property - def total_settled_to_player_cents(self) -> int: - return sum(entry.settled_to_player_cents for entry in self.entries) - - @property - 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.gross_due_to_house_cents for entry in self.entries) - - @property - def total_gross_due_to_house_cents(self) -> int: - return self.total_player_owes_gross_cents - - @property - def total_settled_to_house_cents(self) -> int: - 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 self.total_writeoff_cents - - @property - def total_front_collected_cents(self) -> int: - return self.total_debt_repayment_cents - - @property - def total_debt_repayment_cents(self) -> int: - return sum(entry.debt_repayment_cents for entry in self.entries) - - @property - def total_writeoff_cents(self) -> int: - return sum(entry.writeoff_cents for entry in self.entries) - - @property - def total_cash_in_cents(self) -> int: - return sum(entry.real_cash_in_cents for entry in self.entries) - - @property - def total_real_cash_in_cents(self) -> int: - return self.total_cash_in_cents - - @property - def total_real_cash_out_cents(self) -> int: - return self.total_paid_out_cents - - @property - def total_banker_cash_in_cents(self) -> int: - return self.total_real_cash_in_cents - - @property - def total_banker_cash_out_cents(self) -> int: - return self.total_real_cash_out_cents - - @property - def total_current_due_cents(self) -> int: - 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_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 -class PlayerStats: - player_name: str - sessions_played: int - winning_sessions: int - losing_sessions: int - break_even_sessions: int - win_pct: float - avg_win_cents: int - avg_loss_cents: int - biggest_win_cents: int - biggest_loss_cents: int - total_buy_in_cents: int - total_front_cents: int - total_front_collected_cents: int - total_front_writeoff_cents: int - current_player_owes_cents: int - total_rollover_in_cents: int - total_payout_carry_in_cents: int - total_invested_cents: int - total_cash_out_cents: int - total_gross_payout_cents: int - total_paid_cents: int - total_rollover_out_cents: int - current_due_to_player_cents: int - total_net_cents: int - roi_pct: float - current_win_streak: int - current_loss_streak: int - longest_win_streak: int - longest_loss_streak: int - best_session_date: str | None - best_session_net_cents: int - worst_session_date: str | None - worst_session_net_cents: int - rank_change: int = 0 - - @property - def total_debt_repayment_cents(self) -> int: - return self.total_front_collected_cents - - @property - def total_writeoff_cents(self) -> int: - return self.total_front_writeoff_cents - - @property - def current_due_to_house_cents(self) -> int: - return self.current_player_owes_cents - - @property - def total_paid_out_cents(self) -> int: - return self.total_paid_cents - - @property - def total_real_cash_out_cents(self) -> int: - return self.total_paid_out_cents - - @property - def total_real_cash_in_cents(self) -> int: - return self.total_buy_in_cents + self.total_debt_repayment_cents - - -def apply_rank_changes( - current_board: list[PlayerStats], prev_board: list[PlayerStats] -) -> list[PlayerStats]: - prev_ranks = {p.player_name: i for i, p in enumerate(prev_board, start=1)} - - for i, p in enumerate(current_board, start=1): - prev_rank = prev_ranks.get(p.player_name) - - if prev_rank is None: - p.rank_change = 0 - else: - p.rank_change = prev_rank - i - - return current_board - - -def cents_to_dollars(cents: int) -> Markup: - value = cents / 100 - return Markup(f'${value:,.2f}') - - -def session_sort_key(session: SessionSummary) -> tuple[str, int, str, str]: - session_id = session.session_id.strip() - session_date = session.session_date.strip() - return ( - session_date, - session_sequence_number(session_id, session_date), - session.opened_at, - session_id, - ) - - -def entry_sort_key(entry: SessionEntry) -> tuple[str, int, str]: - return ( - entry.session_date, - session_sequence_number(entry.session_id, entry.session_date), - entry.session_id, - ) - - -def session_display_suffix(session_id: str, session_date: str) -> str: - suffix = session_id.strip().replace(f"{session_date.strip()}-", "", 1) - - if session_id.strip() == session_date.strip(): - return "" - if suffix.isdigit(): - return f"S{int(suffix)}" - if suffix.lower().startswith("s") and suffix[1:].isdigit(): - return f"S{int(suffix[1:])}" - return suffix - - -def session_chart_label(session: SessionSummary) -> str: - suffix = session_display_suffix(session.session_id, session.session_date) - date_label = safe_date_label(session.session_date) - return f"{date_label} · {suffix}" if suffix else date_label - - -def safe_date_label(raw_date: str) -> str: - try: - return datetime.strptime(raw_date, "%Y-%m-%d").strftime("%b %d, %Y") - except ValueError: - return raw_date - - -def session_label(session: SessionSummary) -> str: - return session_chart_label(session) - - -def color_for_name(name: str, names: list[str]) -> str: - try: - index = sorted(names, key=str.casefold).index(name) - except ValueError: - index = abs(hash(name)) - 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: - 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]: - grouped: dict[tuple[str, str], SessionEntry] = {} - by_session: dict[str, list[SessionEntry]] = defaultdict(list) - session_status: dict[str, str] = {} - session_dates: dict[str, str] = {} - session_opened_at: dict[str, str] = {} - - for event in events: - session_id = event["session_id"].strip() or event["session_date"].strip() - session_date = event["session_date"].strip() - event_type = event["event_type"].strip() - - if not session_id or not session_date: - continue - - session_dates[session_id] = session_date - session_opened_at.setdefault(session_id, event["created_at"]) - - if event_type == "session_open": - session_status[session_id] = "open" - continue - - if event_type == "session_close": - session_status[session_id] = "closed" - continue - - player_name = event["player_name"].strip() - if not player_name: - continue - - key = (session_id, player_name) - if key not in grouped: - grouped[key] = SessionEntry( - session_id=session_id, - session_date=session_date, - player_name=player_name, - ) - - entry = grouped[key] - - if event_type == "buyin": - entry.buy_in_cents += event["amount_cents"] - elif event_type == "front": - entry.front_cents += event["amount_cents"] - elif event_type == "front_collected": - entry.front_collected_cents += event["amount_cents"] - elif event_type == "debt_repayment": - entry.front_collected_cents += event["amount_cents"] - elif event_type == "front_writeoff": - entry.front_writeoff_cents += event["amount_cents"] - elif event_type == "writeoff": - entry.front_writeoff_cents += event["amount_cents"] - elif event_type == "rollover_in": - entry.rollover_in_cents += event["amount_cents"] - elif event_type == "payout_carry_in": - entry.payout_carry_in_cents += event["amount_cents"] - elif event_type == "cashout": - entry.cash_out_cents += event["amount_cents"] - elif event_type == "paid": - entry.paid_cents += event["amount_cents"] - elif event_type == "paid_out": - entry.paid_cents += event["amount_cents"] - elif event_type == "rollover_out": - entry.rollover_out_cents += event["amount_cents"] - - if event["note"]: - entry.notes.append(event["note"]) - - for entry in grouped.values(): - by_session[entry.session_id].append(entry) - - sessions: list[SessionSummary] = [] - for session_id, session_date in session_dates.items(): - entries = sorted( - by_session.get(session_id, []), - key=lambda entry: entry.player_name.casefold(), - ) - - sessions.append( - SessionSummary( - session_id=session_id, - session_date=session_date, - entries=entries, - status=session_status.get(session_id, "closed"), - opened_at=session_opened_at.get(session_id, ""), - ) - ) - - sessions.sort(key=session_sort_key, reverse=True) - return sessions - - -def summarize_player_runs(entries: list[SessionEntry]) -> dict[str, int | str | None]: - ordered_entries = sorted(entries, key=entry_sort_key) - - longest_win_streak = 0 - longest_loss_streak = 0 - current_run_wins = 0 - current_run_losses = 0 - - best_entry: SessionEntry | None = None - worst_entry: SessionEntry | None = None - - 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 - - if worst_entry is None or net < worst_entry.net_cents: - worst_entry = entry - - if bucket == "win": - current_run_wins += 1 - current_run_losses = 0 - elif bucket == "loss": - current_run_losses += 1 - current_run_wins = 0 - else: - current_run_wins = 0 - current_run_losses = 0 - - longest_win_streak = max(longest_win_streak, current_run_wins) - longest_loss_streak = max(longest_loss_streak, current_run_losses) - - current_win_streak = 0 - current_loss_streak = 0 - - for entry in reversed(ordered_entries): - bucket = net_result_bucket(entry.net_cents) - - if bucket == "win": - if current_loss_streak > 0: - break - current_win_streak += 1 - elif bucket == "loss": - if current_win_streak > 0: - break - current_loss_streak += 1 - else: - break - - return { - "current_win_streak": current_win_streak, - "current_loss_streak": current_loss_streak, - "longest_win_streak": longest_win_streak, - "longest_loss_streak": longest_loss_streak, - "best_session_date": best_entry.session_date if best_entry else None, - "best_session_net_cents": best_entry.net_cents if best_entry else 0, - "worst_session_date": worst_entry.session_date if worst_entry else None, - "worst_session_net_cents": worst_entry.net_cents if worst_entry else 0, - } - - -def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]: - player_entries: dict[str, list[SessionEntry]] = defaultdict(list) - for session in sessions: - for entry in session.entries: - player_entries[entry.player_name].append(entry) - - leaderboard: 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 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) - losing_sessions = len(losses) - break_even_sessions = sessions_played - winning_sessions - losing_sessions - win_pct = (winning_sessions / sessions_played * 100) if sessions_played else 0.0 - avg_win = round(sum(wins) / len(wins)) if wins else 0 - avg_loss = ( - round(sum(abs(value) for value in losses) / len(losses)) if losses else 0 - ) - biggest_win = max(wins) if wins else 0 - biggest_loss = min(losses) if losses else 0 - - 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.debt_repayment_cents for entry in entries) - total_front_writeoff = sum(entry.writeoff_cents for entry in entries) - current_player_owes = sum(entry.player_owes_cents for entry in entries) - total_rollover_in = sum(entry.rollover_in_cents for entry in entries) - total_payout_carry_in = sum(entry.payout_carry_in_cents for entry in entries) - total_invested = sum(entry.invested_cents for entry in entries) - total_cash_out = sum(entry.cash_out_cents for entry in entries) - total_gross_payout = sum(entry.gross_payout_cents for entry in entries) - total_paid = sum(entry.paid_out_cents for entry in entries) - total_rollover_out = sum(entry.rollover_out_cents for entry in entries) - current_due_to_player = sum(entry.current_due_to_player_cents for entry in entries) - total_net = sum(entry.net_cents for entry in entries) - roi_pct = (total_net / total_invested * 100) if total_invested else 0.0 - - leaderboard.append( - PlayerStats( - player_name=player_name, - sessions_played=sessions_played, - winning_sessions=winning_sessions, - losing_sessions=losing_sessions, - break_even_sessions=break_even_sessions, - win_pct=win_pct, - avg_win_cents=avg_win, - avg_loss_cents=avg_loss, - biggest_win_cents=biggest_win, - biggest_loss_cents=biggest_loss, - total_buy_in_cents=total_buy_in, - total_front_cents=total_front, - total_front_collected_cents=total_front_collected, - total_front_writeoff_cents=total_front_writeoff, - current_player_owes_cents=current_player_owes, - total_rollover_in_cents=total_rollover_in, - total_payout_carry_in_cents=total_payout_carry_in, - total_invested_cents=total_invested, - total_cash_out_cents=total_cash_out, - total_gross_payout_cents=total_gross_payout, - total_paid_cents=total_paid, - total_rollover_out_cents=total_rollover_out, - current_due_to_player_cents=current_due_to_player, - total_net_cents=total_net, - roi_pct=roi_pct, - current_win_streak=run_summary["current_win_streak"], - current_loss_streak=run_summary["current_loss_streak"], - longest_win_streak=run_summary["longest_win_streak"], - longest_loss_streak=run_summary["longest_loss_streak"], - best_session_date=run_summary["best_session_date"], - best_session_net_cents=run_summary["best_session_net_cents"], - worst_session_date=run_summary["worst_session_date"], - worst_session_net_cents=run_summary["worst_session_net_cents"], - ) - ) - - leaderboard.sort( - key=lambda player: (player.total_net_cents, player.total_cash_out_cents), - reverse=True, - ) - return leaderboard - - -def cumulative_profit_series(sessions: list[SessionSummary]) -> dict[str, object]: - ordered_sessions = sorted(sessions, key=session_sort_key) - - all_players = sorted( - { - entry.player_name - for session in ordered_sessions - for entry in session.entries - }, - key=str.casefold, - ) - - labels = [session_chart_label(session) for session in ordered_sessions] - datasets = [] - - for player_name in all_players: - running_total = 0 - seen_player = False - sessions_played = 0 - data: list[float | None] = [] - - for session in ordered_sessions: - matching_entry = next( - ( - entry - for entry in session.entries - if entry.player_name == player_name - ), - None, - ) - - if matching_entry is None: - data.append(running_total / 100 if seen_player else None) - continue - - seen_player = True - sessions_played += 1 - running_total += matching_entry.net_cents - data.append(round(running_total / 100, 2)) - - datasets.append( - { - "label": player_name, - "data": data, - "sessions_played": sessions_played, - } - ) - - return { - "labels": labels, - "datasets": datasets, - } - - -def player_session_series( - sessions: list[SessionSummary], player_name: str -) -> dict[str, Any]: - ordered_sessions = sorted(sessions, key=session_sort_key) - labels: list[str] = [] - net_values: list[float] = [] - cumulative_values: list[float] = [] - running_total = 0 - - all_player_names = sorted( - { - entry.player_name - for session in ordered_sessions - for entry in session.entries - }, - key=str.casefold, - ) - - for session in ordered_sessions: - matching_entry = next( - (entry for entry in session.entries if entry.player_name == player_name), - None, - ) - if matching_entry is None: - continue - - labels.append(session_chart_label(session)) - net_values.append(round(matching_entry.net_cents / 100, 2)) - running_total += matching_entry.net_cents - cumulative_values.append(round(running_total / 100, 2)) - - return { - "labels": labels, - "color": color_for_name(player_name, all_player_names), - "net_values": net_values, - "net_colors": [net_tone(round(value * 100)) for value in net_values], - "cumulative_values": cumulative_values, - } - - -def session_breakdown_series(session: SessionSummary) -> dict[str, Any]: - ordered_entries = sorted( - session.entries, - key=lambda entry: (entry.net_cents, entry.player_name.casefold()), - reverse=True, - ) - - return { - "labels": [entry.player_name for entry in ordered_entries], - "net_values": [round(entry.net_cents / 100, 2) for entry in ordered_entries], - "net_colors": [net_tone(entry.net_cents) for entry in ordered_entries], - } - - -def session_events(events: list[EventRow], session_id: str) -> list[EventRow]: - return [event for event in events if event["session_id"] == session_id] - - -def unique_player_names(events: list[EventRow]) -> list[str]: - return sorted( - {event["player_name"] for event in events if event["player_name"].strip()}, - key=str.casefold, - ) diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html index 6064d90..975cd46 100644 --- a/templates/admin_dashboard.html +++ b/templates/admin_dashboard.html @@ -45,7 +45,7 @@
-
+

Append ledger event

fast entry @@ -104,7 +104,7 @@ {{ item.player_name }} {{ item.amount_cents | money }} {% if live_session and item.player_name in live_session_player_names %} - + @@ -146,7 +146,7 @@
Seated
{{ live_session.entries|length }}
Open
{{ live_unresolved_count }}
- 0 %}data-close-warning="true"{% endif %}> @@ -167,12 +167,12 @@
{% endif %} - + -
+ @@ -182,9 +182,9 @@

Import / export

Back up or migrate the raw event ledger.

- +
- ↓ Export CSV + ↓ Export CSV
@@ -223,7 +223,7 @@
- +

Collect debt

{% for s in recent_sessions %} - + @@ -317,7 +317,7 @@ + onclick="window.location='{{ url_for('public.player_detail', player_name=player.player_name) }}'"> {% for entry in session.entries %} - + diff --git a/templates/sessions.html b/templates/sessions.html index 6cfd272..1fa76f9 100644 --- a/templates/sessions.html +++ b/templates/sessions.html @@ -29,9 +29,9 @@ {% for s in sessions %} - + diff --git a/tests/test_accounting.py b/tests/test_accounting.py index 0ccb7ee..f4e2c8c 100644 --- a/tests/test_accounting.py +++ b/tests/test_accounting.py @@ -3,13 +3,14 @@ import tempfile import unittest import app as app_module -from stats import ( +from charts import player_session_series +from services import ( build_leaderboard, build_session_summaries, - player_session_series, - session_sort_key, + pending_payout_carry_items, ) from storage import load_events, write_events +from utils import session_sort_key def cents(amount: float) -> int: @@ -202,13 +203,13 @@ class AccountingTests(unittest.TestCase): with tempfile.TemporaryDirectory() as tmpdir: data_path = Path(tmpdir) / "entries.csv" write_events(data_path, rows) - original_path = app_module.DATA_PATH - app_module.DATA_PATH = data_path + original_path = app_module.app.config["DATA_PATH"] + app_module.app.config["DATA_PATH"] = data_path try: sessions = build_session_summaries(load_events(data_path)) self.assertEqual( - app_module.pending_payout_carry_items(sessions), + pending_payout_carry_items(sessions), [{"player_name": "A", "amount_cents": cents(5)}], ) @@ -221,7 +222,7 @@ class AccountingTests(unittest.TestCase): data={"session_id": "s2", "player_name": "A"}, ) finally: - app_module.DATA_PATH = original_path + app_module.app.config["DATA_PATH"] = original_path self.assertEqual(response.status_code, 302) updated_sessions = build_session_summaries(load_events(data_path)) @@ -260,8 +261,8 @@ class AccountingTests(unittest.TestCase): with tempfile.TemporaryDirectory() as tmpdir: data_path = Path(tmpdir) / "entries.csv" write_events(data_path, rows) - original_path = app_module.DATA_PATH - app_module.DATA_PATH = data_path + original_path = app_module.app.config["DATA_PATH"] + app_module.app.config["DATA_PATH"] = data_path try: with app_module.app.test_client() as client: @@ -270,7 +271,7 @@ class AccountingTests(unittest.TestCase): response = client.post("/admin/prune-empty-sessions") finally: - app_module.DATA_PATH = original_path + app_module.app.config["DATA_PATH"] = original_path self.assertEqual(response.status_code, 302) remaining = load_events(data_path) diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..badd71f --- /dev/null +++ b/utils.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Formatting, sorting, and classification helpers shared across the app.""" +from __future__ import annotations + +from datetime import datetime + +from markupsafe import Markup + +from models import SessionEntry, SessionSummary + +BREAK_EVEN_TOLERANCE_CENTS = 100 + + +def cents_to_dollars(cents: int) -> Markup: + value = cents / 100 + return Markup(f'${value:,.2f}') + + +def safe_date_label(raw_date: str) -> str: + try: + return datetime.strptime(raw_date, "%Y-%m-%d").strftime("%b %d, %Y") + except ValueError: + return raw_date + + +def session_sequence_number(session_id: str, session_date: str) -> int: + suffix = session_id.strip().replace(f"{session_date.strip()}-", "", 1) + + if session_id.strip() == session_date.strip(): + return 1 + if suffix.isdigit(): + return int(suffix) + if suffix.lower().startswith("s") and suffix[1:].isdigit(): + return int(suffix[1:]) + return 9999 + + +def session_display_suffix(session_id: str, session_date: str) -> str: + suffix = session_id.strip().replace(f"{session_date.strip()}-", "", 1) + + if session_id.strip() == session_date.strip(): + return "" + if suffix.isdigit(): + return f"S{int(suffix)}" + if suffix.lower().startswith("s") and suffix[1:].isdigit(): + return f"S{int(suffix[1:])}" + return suffix + + +def session_chart_label(session: SessionSummary) -> str: + suffix = session_display_suffix(session.session_id, session.session_date) + date_label = safe_date_label(session.session_date) + return f"{date_label} · {suffix}" if suffix else date_label + + +def session_label(session: SessionSummary) -> str: + return session_chart_label(session) + + +def session_sort_key(session: SessionSummary) -> tuple[str, int, str, str]: + session_id = session.session_id.strip() + session_date = session.session_date.strip() + return ( + session_date, + session_sequence_number(session_id, session_date), + session.opened_at, + session_id, + ) + + +def entry_sort_key(entry: SessionEntry) -> tuple[str, int, str]: + return ( + entry.session_date, + session_sequence_number(entry.session_id, entry.session_date), + entry.session_id, + ) + + +def 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" From 39e34536811bd30a2acaff1f4cae0caf5d66d6a1 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Mon, 22 Jun 2026 17:41:16 -0700 Subject: [PATCH 34/39] fix .env load --- app.py | 21 ----------- config.py | 18 ++++++++++ tests/test_stats.py | 85 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 21 deletions(-) create mode 100644 tests/test_stats.py diff --git a/app.py b/app.py index 5198539..5808e96 100644 --- a/app.py +++ b/app.py @@ -1,9 +1,6 @@ #!/usr/bin/env python3 from __future__ import annotations -import os -from pathlib import Path - from flask import Flask from auth import is_admin @@ -14,21 +11,6 @@ from storage import ensure_data_file from utils import cents_to_dollars, safe_date_label -def load_local_env(env_path: Path) -> None: - if not env_path.exists(): - return - - for raw_line in env_path.read_text(encoding="utf-8").splitlines(): - line = raw_line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - - key, value = line.split("=", 1) - key = key.strip() - value = value.strip().strip('"').strip("'") - os.environ.setdefault(key, value) - - def create_app() -> Flask: app = Flask(__name__) app.config.from_object(Config) @@ -48,9 +30,6 @@ def create_app() -> Flask: return app -_env_path = Path(__file__).resolve().parent / ".env" -load_local_env(_env_path) - app = create_app() if __name__ == "__main__": diff --git a/config.py b/config.py index 442fec3..f1c9d6a 100644 --- a/config.py +++ b/config.py @@ -10,6 +10,24 @@ DATA_PATH = BASE_DIR / "data" / "entries.csv" ELIGIBLE_MIN_SESSIONS = 3 +def load_local_env(env_path: Path) -> None: + if not env_path.exists(): + return + + for raw_line in env_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + os.environ.setdefault(key, value) + + +load_local_env(BASE_DIR / ".env") + + class Config: SECRET_KEY: str = os.getenv("SECRET_KEY", "change-this-before-deploying") ADMIN_USERNAME: str = os.getenv("ADMIN_USERNAME", "admin") diff --git a/tests/test_stats.py b/tests/test_stats.py new file mode 100644 index 0000000..36ceaf3 --- /dev/null +++ b/tests/test_stats.py @@ -0,0 +1,85 @@ +import unittest + +from stats import SessionEntry, SessionSummary, build_leaderboard + + +class CashInAccountingTest(unittest.TestCase): + def test_front_recovered_from_cashout_counts_as_session_cash_in(self): + entry = SessionEntry( + session_id="2026-06-21-01", + session_date="2026-06-21", + player_name="Alex", + buy_in_cents=2500, + front_cents=500, + cash_out_cents=4000, + ) + session = SessionSummary( + session_id="2026-06-21-01", + session_date="2026-06-21", + entries=[entry], + ) + + self.assertEqual(session.total_buy_in_cents, 2500) + self.assertEqual(session.total_front_collected_cents, 0) + self.assertEqual(session.total_cash_in_cents, 3000) + + def test_collected_front_shortfall_counts_as_session_cash_in(self): + entry = SessionEntry( + session_id="2026-06-21-01", + session_date="2026-06-21", + player_name="Alex", + front_cents=1000, + cash_out_cents=400, + front_collected_cents=600, + ) + session = SessionSummary( + session_id="2026-06-21-01", + session_date="2026-06-21", + entries=[entry], + ) + + self.assertEqual(session.total_front_collected_cents, 600) + self.assertEqual(session.total_cash_in_cents, 1000) + + def test_written_off_front_does_not_count_as_session_cash_in(self): + entry = SessionEntry( + session_id="2026-06-21-01", + session_date="2026-06-21", + player_name="Blair", + front_cents=1000, + front_writeoff_cents=1000, + ) + session = SessionSummary( + session_id="2026-06-21-01", + session_date="2026-06-21", + entries=[entry], + ) + + self.assertEqual(session.total_front_writeoff_cents, 1000) + self.assertEqual(session.total_cash_in_cents, 0) + + def test_leaderboard_cash_in_includes_collected_fronts(self): + session = SessionSummary( + session_id="2026-06-21-01", + session_date="2026-06-21", + entries=[ + SessionEntry( + session_id="2026-06-21-01", + session_date="2026-06-21", + player_name="Alex", + buy_in_cents=2500, + front_cents=500, + cash_out_cents=4000, + ) + ], + ) + + player = build_leaderboard([session])[0] + + self.assertEqual(player.total_buy_in_cents, 2500) + self.assertEqual(player.total_front_collected_cents, 0) + self.assertEqual(player.total_cash_in_cents, 3000) + + +if __name__ == "__main__": + unittest.main() From 5ded510dd04ff10ff35fc88c062c4f233b22bf5c Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Mon, 22 Jun 2026 18:34:40 -0700 Subject: [PATCH 35/39] footer + favicon --- app.py | 5 ++++- config.py | 2 ++ static/css/style.css | 31 +++++++++++++++++++++++++++++++ static/favicon.svg | 4 ++++ templates/base.html | 7 +++++++ 5 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 static/favicon.svg diff --git a/app.py b/app.py index 5808e96..be8c595 100644 --- a/app.py +++ b/app.py @@ -22,7 +22,10 @@ def create_app() -> Flask: @app.context_processor def inject_globals() -> dict: - return {"is_admin": is_admin()} + return { + "app_version": app.config["APP_VERSION"], + "is_admin": is_admin(), + } app.register_blueprint(public_bp) app.register_blueprint(admin_bp) diff --git a/config.py b/config.py index f1c9d6a..e8d5d7b 100644 --- a/config.py +++ b/config.py @@ -8,6 +8,7 @@ BASE_DIR = Path(__file__).resolve().parent DATA_PATH = BASE_DIR / "data" / "entries.csv" ELIGIBLE_MIN_SESSIONS = 3 +APP_VERSION = "v1.8.4" def load_local_env(env_path: Path) -> None: @@ -29,6 +30,7 @@ load_local_env(BASE_DIR / ".env") class Config: + APP_VERSION: str = APP_VERSION SECRET_KEY: str = os.getenv("SECRET_KEY", "change-this-before-deploying") ADMIN_USERNAME: str = os.getenv("ADMIN_USERNAME", "admin") ADMIN_PASSWORD: str = os.getenv("ADMIN_PASSWORD", "change-me") diff --git a/static/css/style.css b/static/css/style.css index 50c4fc6..449212f 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -116,6 +116,37 @@ h1, h2, h3, h4 { margin: 0; } padding-right: 28px; } } +.site-footer { + margin-top: clamp(44px, 6vw, 76px); + padding-top: 18px; + border-top: 1px solid var(--divider); + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 8px 18px; + color: var(--faintest); + font-size: 12.5px; +} +.site-footer span { + white-space: nowrap; +} +.site-footer span:nth-child(2) { + color: var(--muted); + justify-self: center; +} +.site-footer span:nth-child(3) { + justify-self: end; +} +@media (max-width: 640px) { + .site-footer { + grid-template-columns: 1fr; + justify-items: start; + } + .site-footer span:nth-child(2), + .site-footer span:nth-child(3) { + justify-self: start; + } +} /* ================================================================ FLASH MESSAGES diff --git a/static/favicon.svg b/static/favicon.svg new file mode 100644 index 0000000..fe83d79 --- /dev/null +++ b/static/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/templates/base.html b/templates/base.html index 4188485..d54121c 100644 --- a/templates/base.html +++ b/templates/base.html @@ -4,6 +4,7 @@ {% block title %}myboker.org{% endblock %} + @@ -47,6 +48,12 @@ {% endwith %} {% block content %}{% endblock %} + +
+ © 2026 myboker.org + Version {{ app_version }} + cedarline.digital +
From 982830957d106f41bf6d7685a38520892d3d9a9d Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Mon, 22 Jun 2026 18:40:12 -0700 Subject: [PATCH 36/39] remove v from version number --- config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.py b/config.py index e8d5d7b..6927f24 100644 --- a/config.py +++ b/config.py @@ -8,7 +8,7 @@ BASE_DIR = Path(__file__).resolve().parent DATA_PATH = BASE_DIR / "data" / "entries.csv" ELIGIBLE_MIN_SESSIONS = 3 -APP_VERSION = "v1.8.4" +APP_VERSION = "1.8.4" def load_local_env(env_path: Path) -> None: From a6ead9ac0a6586eaed6be1aedd394fd8ffd62530 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Mon, 22 Jun 2026 20:05:07 -0700 Subject: [PATCH 37/39] better mobile --- static/css/style.css | 75 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/static/css/style.css b/static/css/style.css index 449212f..c58c9b2 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1704,3 +1704,78 @@ select.control { cursor: pointer; } @media (max-width: 440px) { .cash-strip { grid-template-columns: 1fr; } } + +/* ================================================================ + MOBILE — additional responsive rules (phones ≤ 600px) + ================================================================ */ + +/* Topbar: two-row layout on phones. + Row 1 → brand (fills remaining space) + admin actions. + Row 2 → nav links (horizontally scrollable so nothing clips). */ +@media (max-width: 600px) { + .topbar__inner { + flex-wrap: wrap; + height: auto; + padding-top: 10px; + padding-bottom: 4px; + gap: 0 16px; + } + .brand { flex: 1; } + .topbar__spacer { display: none; } + .mainnav { + order: 10; /* pushes nav below brand + admin-actions */ + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + padding-bottom: 8px; + margin-top: 2px; + } + .mainnav::-webkit-scrollbar { display: none; } +} + +/* Toolbar: stack left/right sections vertically on narrow viewports */ +@media (max-width: 700px) { + .toolbar { + flex-direction: column; + align-items: stretch; + gap: 10px; + } + .toolbar__left, + .toolbar__right { width: 100%; } + .session-filter-form { width: 100%; } + .session-filter-select { min-width: 0; flex: 1; width: 100%; } +} + +/* Panel head: let title + action button wrap on narrow panels */ +@media (max-width: 580px) { + .panel__head { flex-wrap: wrap; row-gap: 6px; } +} + +/* Segmented control: scroll horizontally if it overflows on very small phones */ +@media (max-width: 380px) { + .segmented { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + max-width: 100%; + } + .segmented::-webkit-scrollbar { display: none; } +} + +/* Session net bars: tighter name column so bars have room */ +@media (max-width: 540px) { + .session-net-bars__row { + grid-template-columns: minmax(68px, 100px) minmax(80px, 1fr) minmax(70px, max-content); + gap: 8px; + } + .session-net-bars__name { font-size: 12px; } + .session-net-bars__amount { font-size: 11px; } +} + +/* Very small phones: reduce nav / segment padding to keep everything touchable */ +@media (max-width: 360px) { + .navlink { padding: 6px 10px; font-size: 12.5px; } + .seg { padding: 6px 10px; font-size: 12px; } + .hero__title, h1 { font-size: 24px; } +} From 263cd0a6d3bf5fee87be150de183705c641ef9ed Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Mon, 22 Jun 2026 20:51:19 -0700 Subject: [PATCH 38/39] mobile improvements --- static/css/style.css | 188 ++++++++++++++++++++++++++++++++++++++----- templates/base.html | 38 ++++++++- 2 files changed, 203 insertions(+), 23 deletions(-) diff --git a/static/css/style.css b/static/css/style.css index c58c9b2..8719b34 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -2,9 +2,16 @@ RESET + BASE ================================================================ */ *, *::before, *::after { box-sizing: border-box; } -html { color-scheme: dark; scroll-behavior: smooth; } +html { + color-scheme: dark; + scroll-behavior: smooth; + width: 100%; + overflow-x: hidden; +} body { margin: 0; + width: 100%; + min-width: 0; min-height: 100vh; font-family: var(--font-ui); font-size: 15px; @@ -12,6 +19,7 @@ body { color: var(--text-body); background: var(--bg); -webkit-font-smoothing: antialiased; + overflow-x: hidden; } img, canvas, svg { display: block; max-width: 100%; } a { color: inherit; text-decoration: none; } @@ -49,6 +57,7 @@ h1, h2, h3, h4 { margin: 0; } display: flex; align-items: center; gap: 28px; + min-width: 0; } .brand { display: flex; @@ -82,6 +91,29 @@ h1, h2, h3, h4 { margin: 0; } white-space: nowrap; } .mainnav { display: flex; gap: 4px; } +.nav-toggle { + display: none; + width: 42px; + height: 42px; + place-items: center; + gap: 4px; + padding: 0; + border: 1px solid var(--border-strong); + border-radius: 10px; + background: var(--field); + color: var(--text-2); +} +.nav-toggle span { + display: block; + width: 18px; + height: 2px; + border-radius: 2px; + background: currentColor; + transition: transform 160ms, opacity 160ms; +} +.nav-is-open .nav-toggle span:nth-child(1) { transform: translateY(6px) rotate(45deg); } +.nav-is-open .nav-toggle span:nth-child(2) { opacity: 0; } +.nav-is-open .nav-toggle span:nth-child(3) { transform: translateY(-6px) rotate(-45deg); } .navlink { font-size: 13.5px; font-weight: 600; @@ -105,6 +137,7 @@ h1, h2, h3, h4 { margin: 0; } .page { margin: 0 auto; padding: clamp(22px,3.4vw,46px) var(--gutter) 96px; + min-width: 0; } .page--public { max-width: var(--w-public); } .page--session { max-width: var(--w-session); } @@ -140,11 +173,15 @@ h1, h2, h3, h4 { margin: 0; } @media (max-width: 640px) { .site-footer { grid-template-columns: 1fr; - justify-items: start; + justify-items: center; + gap: 6px; + margin-top: 36px; + padding: 18px 0 max(20px, env(safe-area-inset-bottom)); + text-align: center; } .site-footer span:nth-child(2), .site-footer span:nth-child(3) { - justify-self: start; + justify-self: center; } } @@ -268,6 +305,7 @@ h3 { border: 1px solid var(--border); border-radius: var(--r-lg); overflow: hidden; + min-width: 0; } .panel__head { padding: 16px 22px; @@ -1666,6 +1704,43 @@ select.control { cursor: pointer; } h1 { font-size: 28px; } .hero { padding: 18px 0 14px; } .topbar__season { display: none; } + .page { + padding: 18px 12px 72px; + } + .panel { + border-radius: 12px; + } + .panel__head { + padding: 14px 14px; + } + .panel__body, + .form-card, + .append__body, + .panel--pad { + padding: 16px 14px; + } + .chart-wrap { + padding: 0 10px 14px; + } + .chart-frame { + height: 230px; + } + .chart-frame--tall { + height: 260px; + } + .cards, + .cards-2, + .provisional-grid, + .recon-grid, + .append__grid { + grid-template-columns: 1fr; + } + .form-shell { + padding-top: 20px; + } + .form-shell.narrow { + grid-template-columns: minmax(0, 480px); + } .cash-strip { grid-template-columns: 1fr 1fr; } .debt-action-grid { grid-template-columns: 1fr; } .snapshot-grid { grid-template-columns: 1fr; } @@ -1699,6 +1774,39 @@ select.control { cursor: pointer; } .leaderboard-hero-stats .stat:last-child { min-width: 0; } + .tbl-scroll, + .table-wrap { + width: 100%; + max-width: 100%; + overscroll-behavior-x: contain; + } + .tbl { + min-width: 620px; + } + .tbl--session, + .tbl--session.is-full { + min-width: 760px; + } + .feed, + .debt-list, + .admin-bottom-grid .table-wrap, + .admin-bottom-grid .event-feed { + max-height: none; + height: auto; + } + .csv__btns, + .append__footer, + .button-row, + .modal-card__actions { + align-items: stretch; + } + .csv__btns > *, + .append__footer > *, + .button-row > *, + .modal-card__actions > * { + flex: 1 1 auto; + justify-content: center; + } } @media (max-width: 440px) { @@ -1706,32 +1814,68 @@ select.control { cursor: pointer; } } /* ================================================================ - MOBILE — additional responsive rules (phones ≤ 600px) + MOBILE — additional responsive rules ================================================================ */ -/* Topbar: two-row layout on phones. - Row 1 → brand (fills remaining space) + admin actions. - Row 2 → nav links (horizontally scrollable so nothing clips). */ -@media (max-width: 600px) { +/* Topbar: app-style single row with an anchored menu on phones. */ +@media (max-width: 700px) { .topbar__inner { - flex-wrap: wrap; - height: auto; - padding-top: 10px; - padding-bottom: 4px; - gap: 0 16px; + height: 58px; + padding-inline: 12px; + gap: 10px; + } + .brand { + flex: 1 1 auto; + min-width: 0; + } + .brand__name { + overflow: hidden; + text-overflow: ellipsis; + } + .nav-toggle { + display: grid; + flex: none; + order: 20; } - .brand { flex: 1; } .topbar__spacer { display: none; } .mainnav { - order: 10; /* pushes nav below brand + admin-actions */ - width: 100%; - overflow-x: auto; - -webkit-overflow-scrolling: touch; - scrollbar-width: none; - padding-bottom: 8px; - margin-top: 2px; + position: fixed; + top: calc(58px + env(safe-area-inset-top)); + left: 10px; + right: 10px; + z-index: 60; + display: grid; + gap: 4px; + padding: 8px; + border: 1px solid var(--border-strong); + border-radius: 12px; + background: rgba(22, 22, 28, .98); + box-shadow: 0 18px 48px rgba(0,0,0,.42); + transform: translateY(-8px); + opacity: 0; + pointer-events: none; + visibility: hidden; + transition: opacity 150ms, transform 150ms, visibility 150ms; + overflow: hidden; + } + .nav-is-open .mainnav { + transform: translateY(0); + opacity: 1; + pointer-events: auto; + visibility: visible; + } + .navlink { + display: flex; + align-items: center; + min-height: 44px; + padding: 11px 12px; + border-radius: 8px; + font-size: 14px; + } + .topbar__admin-actions .btn { + height: 38px; + padding-inline: 11px; } - .mainnav::-webkit-scrollbar { display: none; } } /* Toolbar: stack left/right sections vertically on narrow viewports */ diff --git a/templates/base.html b/templates/base.html index d54121c..314cc60 100644 --- a/templates/base.html +++ b/templates/base.html @@ -16,7 +16,12 @@ myboker.org -
Total Net{{ player.total_net_cents | money }}
Win %{{ "%.1f"|format(player.win_pct) }}%
ROI{{ "%.1f"|format(player.roi_pct) }}%
Sessions{{ player.sessions_played }}
Paid Buy-ins{{ player.total_buy_in_cents | money }}
Fronted{{ player.total_front_cents | money }}
Debt Collected{{ player.total_front_collected_cents | money }}
Current Debt{{ player.current_player_owes_cents | money }}
Written Off{{ player.total_front_writeoff_cents | money }}
Rolled In{{ player.total_rollover_in_cents | money }}
Total Invested{{ player.total_invested_cents | money }}
Cash-outs{{ player.total_cash_out_cents | money }}
Rolled Out{{ player.total_rollover_out_cents | money }}
Winning Sessions{{ player.winning_sessions }}
Losing Sessions{{ player.losing_sessions }}
Gross due Paid Rolled outCollected Written off Still owed Status{{ entry.paid_cents | money }} {{ entry.rollover_out_cents | money }}{{ entry.front_collected_applied_cents | money }} {{ entry.front_writeoff_applied_cents | money }} + {% if entry.player_owes_cents > 0 %} Owes {{ entry.player_owes_cents | money }} {% elif entry.front_writeoff_applied_cents > 0 %} Written off {{ entry.front_writeoff_applied_cents | money }} + {% elif entry.front_collected_applied_cents > 0 %} + Collected {{ entry.front_collected_applied_cents | money }} {% else %} {{ entry.current_due_cents | money }} {% endif %} From 1f1c5d2ecc193d730af53c824df5ccc519f77ac3 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Mon, 22 Jun 2026 00:25:57 -0700 Subject: [PATCH 31/39] 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 zcmd5>U2NOd6~2^6iIQbW{*C1(sbVK<)HIIm*lB(o$8zTUG>a3PQCBQpSw*5<6^ayJ zN{Jm{L(pNHSL`8v2;yV|+U8-4kpgv*0Mpt6GtdI-qXjv$!R4XFIt+f(;tW{!v~x&G zlpNY~+M?@)ba;91<+=AB-t(OwE>~7M36wv5vGmn_ucCxUgzDzHOr2P<%m4(=WFIxkksuzZ8Y%0v5#@g9$ha^F#v-WuZ~V;eMqdCCMpy7g8}CiQqzFiIJ8I z;|dp%5-FKhz%X2ejJv%;9#pu0oxb|=RAh1}=V{7SG|#ck%g{pk?J*C~P7LJLJNVRFZ;Z zQCb0;*>kGUDI94c<9_{Wp}1XzQ$<2ILLKkr!$7W(d^M@wc-1u(oRX$~IOpEB*bunA zZN_^0nVBPV^#>N~{fiA-7dJFPN7i>Uz!4A|R@YFft2vjJZVDYq^ z!6UTvEbxrr$to1AtFQ7csJv=<)mnOfLrmdyCD@X)Z0TSG2G+rc*qWj$6-ROYa@9`TbY4SMTT= zkrL9_1=_8=utK0MpeA%&98S`fu6QyMj?3LG#nDZ`M8*U2?jRSmw>*#Ob{GZxP;#Jf(pXBdqi_H`19pX#rTDPI z(&Qyg!0L1guOpDG*!3Ey846Uk0~f#lwbwua^>)CXx!Th?@97C{(NjP1(>Z>Vrj9yD z10zCEKXFgX8{s4&)(w{S;s_|lkr&{=3t-(4-#T^7e4^Jb@PcF2UMKWc6nb}A3yPJ7 z`7cz%{9r~_mXKwz6rc6(vyQ?zfh%z71TI+oxD#_u2yZLOSK`DB7JPvcZeNiT!uyI+ z!ij&1-I@3ff@>X80dHbuDSd8)1SC_US5?R96p|BUivFH+>GK%*BCvC?%LWfcjmSu^i`bN{?xZ;i_E&`5&-81aI;z&p- z-O$yflBsZ9#2Lg`LT{|q8gUL0=G9xDiKHn7r4plQNlYb0-TZ3?vvEZytXGz_2dAxc zCoT?s9ohy&2Mt|Mx2sNyx@UD6IAJ&u71i0pR1%{x8Jw3$O4++ojo14m;*$IYkSk=- zx9RQ8H#UFit-BhX`oZny+m|wyOfcu|eo$-~&opJiId6~Aa&d-c+H&55c{^L_UbfmA zoxgHV*z;cEs9fM1vV6l--GXmt*0=M$^vvL$@p*pVBJV2ny#4HrXW!d4W4RNU=R1t{ z4GX@GtgqvqlE^FPF_3g{F-RBQ} zH_)i!JB&sZPoep1qtVlFGC`wKELa6u8MC-$UC)_dPYL>&tXxGSict>TgXBdZ3O7uX ziPRE;KHZPx0FWhgKj=Xu-AE20SqVb42U~+k(08W2K$Pkcnv`Ww?C~O~&_3)vjO6=B zjshu%hdrv2s|yihGGLSrXXgMWz)3G67e+XMW65}2iU8axM@+%usrjHVR)-Eog#`mB z#xq+o13B*@qvhhvXl6^!+qE9(7`QVw&+jc!Y#ln7JKN{^-6ib{zP7Bd?Otc5>h9io zzN@Uq1R!*%nF*J1hgKbC2JVjC=X=(Gj+g%sO>-C4h-P#9#wS}7X?j8UWD)+bWzn|Z2)QR(Uh4t zJPJ*jQH3>S?#G&{xU1-FP}SMe8AK{Y(-BD)>;wdlR&!Nkg5Cj=tJ zV8Tem2ri)#!W1|3dWxY2n_!Gkr6l&ZoGk_lg13=`EGBWgwPZ}yDr49XYZj&m)yO`< zf!QnuEczDVau%hd7NN$R?^#R$X(PSv zs4hc?y7iy~9RzV`MqR)f8g+q)l=WX*I7gXxxl zMTNG0+6+vmEo$z8ZiBjv9)}+4t9b`SSQ{}QxG8lX3V@T4E)`q|lYGn&&h!irH62se zd#hn8`2NuJQ07c_XYhJ>GJLB+OUJxf)5P3xG_8`3`GzF|Nh+dAgsKZ%DGK^y7=V9c z=`}q!$ELQt-!B#OlbUip3ygBxmz6yg>xG#H?$WKk3p&2Q8lMo1-C(iUl8JtBU zQC3JAslMIn%X|%{kkz;raCvTH--pL$Mi(}<|7BDAhsPevc*x#tXk_Ras6dd5@vWLK zqC2qm0+JpeiWTzis&P`b=mG8Ov8v{l;<_NEt45|R$Y&Swv1#^6VWSWCLB_2774oI4>gv`h*E^BhFV6I4n4D|>gJR3R83~+F z*CC^&JJX&S&AEa`%Rx29b@k*OoYy(QK5~)T9fjmqRb95O_uYnEU2m?ccf$6-QB)I|CfLO~-}Rx%p?9`SiWAPy3vupmYqA^r{&+06u`lQA`!xO8;3p8e4jA^V ze!&;a`huT!epdU*fqDLv(bKr#YtQ=H@AYMx?jA1-Y-=??cLoC6&L?dkVz_DpGvA(f zS^>eAo;EY=o^Dku!8W=Jy}OE^Ez+P(|F0yRz#Eoe)ev)SPt_z;T$PiFr&xv(a%)|eXe=cqhllBppeL}>ja4@2X{+QV z@J6+O)X0ocbQ0BYRB6<3q@1Ug&@zcq`&2ZDO6OAG>4H;x9`j_F#VV`@n&o8tq9p1J zb$>UO7>#D`MEWT|Ki~W+ALST6Bizb{(3nnuWC4 z5daWOiP`m(San%SY){s==VLB&?jy%M-)m?*c)eP}YMwu6v>FMq;N92f`9ozr2eQ5c zABSe!K6>Roe?-NCY54l8IR|R40zO^wqWURP6cwi^o=-;85LbwbOB68?iXSQA&LfJ$ zF)F9xv4oUJilP?epnayNkc5y7BS`>J>O@ga!TX88S3~$P0XYXaG$Or#15j^NQ$ROk z4GDfLlbeA2f;_UY+~$WaH`o2p@8*KcL%)mL|8S#++w-rcO71A5J&q6Iy3dEfeux2= z!lx6l6rLr8!--|>;|Mn#Pr_$QHSvPZEttC$)snsZ85n#x3<+O#6{Fb8BD)ofRQN7U zB;E8UFb17|`7I!ME5k5NK;FhMTd!`*6SzzreuS4iU(MJSy)}6&TtBa_%X4te z+X&R`SmQ}mZQg-3C#kH?S76OWT%JdjPG$hv-c^39V!Kb5xG?{n2(YWdm)#oHQpR|YdCVgvuF3f zrc!04RpVCu;i5*E1}RDNqgX_WBanJ6k!mTCnx8(Mg{>_=8Z}kLKZ6q`^jF_^cK1Ha z*+7EY(cSFq%F8r8^I!KJlpNYQ*)ehN(H-KCnQu! z$XWuuOLKeTTRmwvD`7;gf&IO%yI6PJ%jq?zh@bS^cKrf|g}Sw*mpjWzTpzR;NwW3Xta7uhfq3nA@m+TW24nH4raP#j z#c9-Q(}jUZETHN3a8!etI0j;LFrcY8=mvdz^V!&-(mW6e42GL~XzZL4)YRtFu{Rak ztSKs#F_#0uAQBBn&om8<>TX#MM58e+peeFU-9X3zf3+5}G4d_pxQ+KP``xBRd|W-q z-j<-1u;pR_6Mn>&y9)|qq|agl=`%S`kOiTw)*eHzcC(N5gk^JgL(t&0(E@nr0)=^o zyc}gS!rqOvkTnFAsHRdlKgMLMp)f}76c+y`KH;AXzSEZ~*^nx1nB^O25j5rO3$Bn& z^bPjWBliV*8A-Bd?H8X_7BYJ$Ir@03f3pJ?iZR z53@U^6^nLwepOzzD8Tb;2Z53kOCl+&Tr9wnixibF7GlXw+$Hylpk%NQ*>9zCC@}W* zKw*qXu&Zv655M3hZFb2f+5LjV7ZFK-H-{f&($7hv-zF9K?UE~VD*U{)>r$cQmWrg} z<^o_>iEaZ9aocoPkSf5QkQ~r1mbJCC?cCPV3cr&rEqman+gsW~7ad#J8?AtuP>^Oq zt1!dEpt}vhk=a4fc{tyS4hJd_)M8ZUQ4D~Gcz(Q&eBU$f(;8@3aq*2TnP-pf5p95K z#%ezl#>iqhDJYt#op4Y3CY8z8W{Wp1h?TQzyw^5O+pj${eQ36NFH}sNTM(-!-3wyX z#80i)tHR{KyG09P@q}$bESacYtZ=y-S@a7%*`t~h$n&-1 zHUD;mNLvp6;F;VG|D~58>)zr-a2cM}<>`bCwZIwpZY?`lcH8`qB+r=pjN$h9N;jP@=qc`aC>zPGPS(3#VJ5g>Hik1tM!Dl|3JnOt$`M z-zWQKjwQFha=G_X?}sH1XcP1TddNGFN@YaToyZQ1wy3yh_z6R0#c4h*bczuw%1G^=&bio38LzEXEX_5f5y#q-zNQxz@a;+^v9Iplt- z`e>^B=(zJvY2{pH&->0lmi(b4RoRm&?HLyqN~*_yHY+}xQ7S-%B~z)0OY1;HQQ7yT z^`F&+WNAVP5EHSo-__5xo9LR63A2^&uk5$2*eXD)-D+*JTe5Wl#;4^lewLoKX5hE= zWFxs*-G{1{rGA%1rQHOYI$TM9N{D^S()Xgo^&sz-rSD0pco`XZ&7xHK1gpzq6ZVtG z;0kKTWhwhRiJ1htO>O;;E2GqO&Zi5fdPALeE z9EvNl7L!eb#L%3z%Nk*Fs$zUNWv2+ZbSGvnKxP7iDT|w#t;v?frhx+nq9K`$9-xXG z3aem)1~q-xa#4NUFB@^tFCiNv3!Z1+TX$vMSEW@Gp~;u7HC(%puqAw{(#|{Sn$g6D zL?Bh#m8m&DO%tuD(tV3gzNmP~?x=PBs(9SFSPH6qUR;wD*GyK;d$uJ#+dhgx| z#N7*`+iH2Q?n>Q9o2G5oy>nuFroLw0)1LIS-&7I@ZiMH=uAG(~Nzaa(@q}~(X!$ZN zFv^~!XHUX6CwAu4?oN7kCt7cd`<@=+2&*EFxGLh77--%7SkVPp4$x2^Rv*9& z4P<%{GQIo^jj1XK?`Rr0=pk%<5wmX0&?e1=fSpW`*;sN|1spOV>ohWYzLwNz=iRmCPtqczw6a5Srj3(+6y{XdunVR#{!-@J-X~*NhL+|yGIdNB( z@S5ZqKTp_p~NGtv7ch%5LnM6FYKR@_>K|C)0r(>QHM!N$-u3+hW%$@Ng9j zk*8&lNWv=c-~eOhDH|k`^J&3@^dvyDDm*;FoVcDxzytHX3YG~c?L=Wh(fl|3Ve__k z>23r#$RYPiD#uAgz6@=5rgfpA4OqEbPsHn0Ui_@Tz zj&Fg`2Tn&6`9fGj+r$uCDCoU;<@AS;#0jgP;Hn=_qi>oiOO+nXi0sc2bOtnf_bMW5 z3MPMj%bd6~NBm5|th&*Wqqj}%{Au^h##_hc#3Pw+Ofj4uzAf&4SOhUr1%8gWw3+z? znb=2O>p$%;Sk`}zuq|)R5w9W*QPe7p6T8s}}5YJlZ>3>r2HL0b3CcXfJSqN|1pA(RMN8K(y(FX_*6qI(=54 zC;HheD)J*JJUS*Kkw?TtI3p$k0a~;uj#Dq>Q#KYJpj)7mX0i1Y@r%glPzinN%J4V9liO(ssSPt9WDv8C4>Dz>*PGtx%8sUCZ{oxtAj{lJ zyn&%RkdDL9M>-z@k5_kLE*k+L2Q)WA^v8<-wB+ivM;|V|>xUlClQ0E!39>QrmAhS}YJsUE@4N;VZXT z(W$bkB%bqB)qzymfpN#3l8PiAom53vs-$b2U#Rk2?)z=u`}Ob0LPU_W)B<@~n zZFkDk{dxQg=`-*sF#nU>mlPg~N{cC$ON;l|M%ue8!I zV5h+=-SM!_L#$>x?=OHtPs{A=hpRJ7n`JbasV9YgN5<@YsMXQWXKO+W_5YtyvTVd( z(LY?(1nqZN>v*KEo7V)*@)*;%Fd0gc6SR=HTnoJf8_SGRy~OatGTvITQE`EWH6=DM z@UT^?*O(!t*aa{y4M#E)wW5cSG)lf+YrsSk%%nA^>li3+||VngH@gMBD~_PRXqQTc8=rzPD=ho zyx%zBuKgPk?_!@n!EuMViL>+7&n2s$` 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() From 53491309728e57e5410b6f8fbb21fb0b3b845ce0 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Mon, 22 Jun 2026 13:51:38 -0700 Subject: [PATCH 32/39] visual overhaul + accounting corrections --- .gitignore | 1 + README.md | 30 +- app.py | 250 ++++++++-- static/css/style.css | 380 +++++++++++++-- stats.py | 224 ++++++--- storage.py | 16 + templates/admin_dashboard.html | 448 +++++++++++------- templates/admin_login.html | 4 +- templates/base.html | 4 +- templates/leaderboard.html | 2 +- templates/player_detail.html | 60 ++- templates/session_detail.html | 50 +- templates/sessions.html | 4 +- tests/__pycache__/__init__.cpython-313.pyc | Bin 141 -> 0 bytes .../test_accounting.cpython-313.pyc | Bin 8233 -> 0 bytes .../test_accounting.cpython-314.pyc | Bin 8549 -> 0 bytes tests/test_accounting.py | 184 ++++++- 17 files changed, 1268 insertions(+), 389 deletions(-) delete mode 100644 tests/__pycache__/__init__.cpython-313.pyc delete mode 100644 tests/__pycache__/test_accounting.cpython-313.pyc delete mode 100644 tests/__pycache__/test_accounting.cpython-314.pyc diff --git a/.gitignore b/.gitignore index dc0553c..c6de75f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /__pycache__/* /.venv/* /data/* +test/* diff --git a/README.md b/README.md index 2178134..08b2b71 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A small Flask app I put together for tracking our low-stakes hold'em nights without having to keep a spreadsheet open all the time. -The idea is pretty simple: public pages for stats and session history, plus a small admin area for recording buy-ins, cash-outs, paid amounts, and notes. The data sits in a CSV audit log, so everything is append-only and easy to follow later. +The idea is pretty simple: public pages for stats and session history, plus a small admin area for recording buy-ins, gross cashout results, actual paid-out cash, and notes. The data sits in a CSV audit log, so everything is append-only and easy to follow later. ## What it does @@ -11,8 +11,8 @@ The idea is pretty simple: public pages for stats and session history, plus a sm - per-player stat pages - admin login for recording events - open / closed session tracking -- payout tracking with `paid` events -- front debt collections and write-offs with `front_collected` / `front_writeoff` events +- actual cash payout tracking with `paid_out` events +- debt repayments and write-offs with `debt_repayment` / `writeoff` events - session and player charts - CSV import / export from the admin page - append-only `entries.csv` ledger instead of overwriting old rows @@ -38,11 +38,12 @@ Current event types: - `buyin` - `front` -- `front_collected` -- `front_writeoff` +- `debt_repayment` +- `writeoff` - `cashout` -- `paid` +- `paid_out` - `rollover_in` +- `payout_carry_in` - `rollover_out` - `note` - `session_open` @@ -52,9 +53,9 @@ A few examples: - another `buyin` for a rebuy - another `cashout` if chip counts are corrected later -- a `paid` event when someone is actually settled up -- a `front_collected` event when a front is repaid outside poker -- a `front_writeoff` event when a front will not be collected +- a `paid_out` event when someone is actually settled up +- a `debt_repayment` event when a front is repaid outside poker +- a `writeoff` event when a front will not be collected - a `note` event for bookkeeping context - `session_open` / `session_close` to mark whether a game night is still live @@ -62,11 +63,16 @@ Accounting in the app keeps poker results separate from banker cashflow: - poker investment is `buyin + front + rollover_in` - poker net is `cashout - poker investment` -- real cash in is `buyin + front_collected` -- real cash out is `paid` +- real cash in is `buyin + debt_repayment` +- real cash out is `paid_out` - `rollover_out` settles the source session without counting as cash out - `rollover_in` enters play in the destination session without counting as cash in -- `front_writeoff` resolves a receivable without counting as cash in +- `payout_carry_in` records prior-session value carried into a later payout; it increases the destination payout due without counting as poker investment or cash in +- `writeoff` resolves a receivable without counting as cash in + +Older ledgers may still contain `paid`, `front_collected`, or `front_writeoff`. +The app reads those historical names as aliases for `paid_out`, +`debt_repayment`, and `writeoff`. It is still just a small side project, but I wanted the event model to stay clean enough that the numbers are easy to trust and the history is easy to read back through. diff --git a/app.py b/app.py index e7ef56c..db1ce04 100644 --- a/app.py +++ b/app.py @@ -31,7 +31,14 @@ from stats import ( session_sort_key, unique_player_names, ) -from storage import CSV_HEADERS, append_event, ensure_data_file, load_events +from storage import ( + CSV_HEADERS, + EventRow, + append_event, + ensure_data_file, + load_events, + write_events, +) BASE_DIR = Path(__file__).resolve().parent DATA_PATH = BASE_DIR / "data" / "entries.csv" @@ -40,13 +47,15 @@ ENV_PATH = BASE_DIR / ".env" ELIGIBLE_MIN_SESSIONS = 3 ADMIN_EVENT_TYPES = [ ("buyin", "Buy-in", "var(--accent)"), - ("front", "Fronted", "var(--warn)"), + ("front", "Front", "var(--warn)"), ("rollover_in", "Rollover-in", "var(--rolled)"), - ("cashout", "Cash-out", "var(--rank-2)"), - ("paid", "Paid-out", "var(--pos)"), + ("payout_carry_in", "Payout carry-in", "var(--rolled)"), + ("cashout", "Cashout result", "var(--rank-2)"), + ("paid_out", "Paid out cash", "var(--pos)"), ("rollover_out", "Rollover-out", "var(--rolled)"), ("note", "Note", "var(--muted)"), ] +SESSION_MARKER_TYPES = {"session_open", "session_close"} def load_local_env(env_path: Path) -> None: @@ -80,6 +89,54 @@ def next_session_id(sessions, session_date: str) -> str: return f"{session_date}-{highest + 1:02d}" +def prunable_empty_session_ids(events: list[EventRow], sessions) -> set[str]: + empty_session_ids = { + session.session_id for session in sessions if len(session.entries) == 0 + } + session_events: dict[str, list[EventRow]] = {} + + for event in events: + session_id = event["session_id"].strip() or event["session_date"].strip() + if session_id in empty_session_ids: + session_events.setdefault(session_id, []).append(event) + + return { + session_id + for session_id, rows in session_events.items() + if rows + and all( + row["event_type"] in SESSION_MARKER_TYPES + and not row["player_name"].strip() + and row["amount_cents"] == 0 + for row in rows + ) + } + + +def pending_payout_carry_items(sessions) -> list[dict[str, object]]: + carried_out: dict[str, int] = {} + carried_in: dict[str, int] = {} + + for session_summary in sessions: + for entry in session_summary.entries: + carried_out[entry.player_name] = ( + carried_out.get(entry.player_name, 0) + entry.rollover_out_cents + ) + carried_in[entry.player_name] = ( + carried_in.get(entry.player_name, 0) + + entry.rollover_in_cents + + entry.payout_carry_in_cents + ) + + items = [] + for player_name, amount_out in carried_out.items(): + pending_cents = amount_out - carried_in.get(player_name, 0) + if pending_cents > 0: + items.append({"player_name": player_name, "amount_cents": pending_cents}) + + return sorted(items, key=lambda item: str(item["player_name"]).casefold()) + + load_local_env(ENV_PATH) app = Flask(__name__) @@ -219,33 +276,24 @@ def session_detail(session_id: str) -> str: flash("That session was not found.", "error") return redirect(url_for("sessions")) - target_index = next( - ( - index - for index, session in enumerate(sessions) - if session.session_id == session_id - ), - None, - ) - - if target_index is None: - flash("That session was not found.", "error") - return redirect(url_for("sessions")) - - target_session = sessions[target_index] chronological_sessions = sorted(sessions, key=session_sort_key) - session_number = next( - ( - index - for index, session in enumerate(chronological_sessions, start=1) - if session.session_id == session_id - ), - 1, + chronological_index = next( + index + for index, session in enumerate(chronological_sessions) + if session.session_id == session_id ) + target_session = chronological_sessions[chronological_index] + session_number = chronological_index + 1 prev_session = ( - sessions[target_index + 1] if target_index < len(sessions) - 1 else None + chronological_sessions[chronological_index - 1] + if chronological_index > 0 + else None + ) + next_session = ( + chronological_sessions[chronological_index + 1] + if chronological_index < len(chronological_sessions) - 1 + else None ) - next_session = sessions[target_index - 1] if target_index > 0 else None return render_template( "session_detail.html", @@ -478,7 +526,7 @@ def admin_write_off_front() -> str: ) if entry is None or entry.player_owes_cents <= 0: - flash("That player does not have an outstanding front debt.", "error") + flash("That player does not have an outstanding debt.", "error") return redirect(url_for("admin_dashboard")) if amount_cents > entry.player_owes_cents: @@ -493,13 +541,13 @@ def admin_write_off_front() -> str: session_id=target.session_id, session_date=target.session_date, player_name=entry.player_name, - event_type="front_writeoff", + event_type="writeoff", amount_cents=amount_cents, - note=note or "Front debt written off.", + note=note or "Debt written off.", actor=app.config["ADMIN_USERNAME"], ) - flash("Front debt written off.", "success") + flash("Debt written off.", "success") return redirect(url_for("admin_dashboard")) @@ -546,7 +594,7 @@ def admin_collect_front() -> str: ) if entry is None or entry.player_owes_cents <= 0: - flash("That player does not have an outstanding front debt.", "error") + flash("That player does not have an outstanding debt.", "error") return redirect(url_for("admin_dashboard")) if amount_cents > entry.player_owes_cents: @@ -561,13 +609,13 @@ def admin_collect_front() -> str: session_id=target.session_id, session_date=target.session_date, player_name=entry.player_name, - event_type="front_collected", + event_type="debt_repayment", amount_cents=amount_cents, - note=note or "Front debt collected.", + note=note or "Debt repayment collected.", actor=app.config["ADMIN_USERNAME"], ) - flash("Front debt collected.", "success") + flash("Debt repayment collected.", "success") return redirect(url_for("admin_dashboard")) @@ -601,6 +649,86 @@ def admin_open_session() -> str: return redirect(url_for("admin_dashboard")) +@app.post("/admin/prune-empty-sessions") +def admin_prune_empty_sessions() -> str: + if not is_admin(): + flash("Admin login required.", "error") + return redirect(url_for("admin_login")) + + events = load_events(DATA_PATH) + sessions = build_session_summaries(events) + prunable_session_ids = prunable_empty_session_ids(events, sessions) + + if not prunable_session_ids: + flash("No empty marker-only sessions to prune.", "success") + return redirect(url_for("admin_dashboard")) + + pruned_events = [ + event + for event in events + if (event["session_id"].strip() or event["session_date"].strip()) + not in prunable_session_ids + ] + write_events(DATA_PATH, pruned_events) + + flash( + f"Pruned {len(prunable_session_ids)} empty session" + f"{'s' if len(prunable_session_ids) != 1 else ''}.", + "success", + ) + return redirect(url_for("admin_dashboard")) + + +@app.post("/admin/apply-payout-carry-in") +def admin_apply_payout_carry_in() -> str: + if not is_admin(): + flash("Admin login required.", "error") + return redirect(url_for("admin_login")) + + session_id = request.form.get("session_id", "").strip() + player_name = request.form.get("player_name", "").strip() + + events = load_events(DATA_PATH) + sessions = build_session_summaries(events) + by_id = {session.session_id: session for session in sessions} + target = by_id.get(session_id) + + if target is None or target.status != "open": + flash("Select a valid open session.", "error") + return redirect(url_for("admin_dashboard")) + + if not any(entry.player_name == player_name for entry in target.entries): + flash("Add that player to the open session before applying carry-in.", "error") + return redirect(url_for("admin_dashboard")) + + pending_by_player = { + str(item["player_name"]): int(item["amount_cents"]) + for item in pending_payout_carry_items(sessions) + } + amount_cents = pending_by_player.get(player_name, 0) + + if amount_cents <= 0: + flash("No pending payout carry-in found for that player.", "error") + return redirect(url_for("admin_dashboard")) + + append_event( + DATA_PATH, + session_id=target.session_id, + session_date=target.session_date, + player_name=player_name, + event_type="payout_carry_in", + amount_cents=amount_cents, + note="Payout carry-in from prior carry-out.", + actor=app.config["ADMIN_USERNAME"], + ) + + flash( + f"Applied {cents_to_dollars(amount_cents)} payout carry-in for {player_name}.", + "success", + ) + return redirect(url_for("admin_dashboard")) + + @app.route("/admin", methods=["GET", "POST"]) def admin_dashboard(): if not is_admin(): @@ -658,8 +786,18 @@ def admin_dashboard(): events = load_events(DATA_PATH) sessions = build_session_summaries(events) + empty_session_count = len(prunable_empty_session_ids(events, sessions)) + pending_carry_items = pending_payout_carry_items(sessions) + pending_carry_total_cents = sum( + int(item["amount_cents"]) for item in pending_carry_items + ) open_sessions = [session for session in sessions if session.status == "open"] live_session = open_sessions[0] if open_sessions else None + live_session_player_names = ( + {entry.player_name for entry in live_session.entries} + if live_session is not None + else set() + ) live_session_no = "" live_unresolved_count = 0 if live_session is not None: @@ -670,7 +808,7 @@ def admin_dashboard(): if entry.current_due_cents > 0 or entry.player_owes_cents > 0 ) - recent_sessions = sessions[:8] + recent_sessions = sessions recent_events = list(reversed(events[-20:])) event_type_colors = {value: color for value, _label, color in ADMIN_EVENT_TYPES} event_type_labels = {value: label for value, label, _color in ADMIN_EVENT_TYPES} @@ -689,29 +827,34 @@ def admin_dashboard(): if entry.player_owes_cents > 0 ] + cash_in_cents = sum(session.total_banker_cash_in_cents for session in sessions) + cash_out_cents = sum(session.total_banker_cash_out_cents for session in sessions) + house_owes_players_cents = ( + sum(session.total_current_due_to_player_cents for session in sessions) + + pending_carry_total_cents + ) + players_owe_house_cents = sum( + session.total_current_due_to_house_cents for session in sessions + ) admin_totals = { - "cash_in_cents": sum(session.total_cash_in_cents for session in sessions), - "cash_out_cents": sum(session.total_paid_out_cents for session in sessions), - "house_owes_players_cents": sum( - session.total_current_due_to_player_cents for session in sessions - ), - "players_owe_house_cents": sum( - session.total_current_due_to_house_cents for session in sessions - ), + "cash_in_cents": cash_in_cents, + "cash_out_cents": cash_out_cents, + "house_owes_players_cents": house_owes_players_cents, + "players_owe_house_cents": players_owe_house_cents, + "pending_carry_cents": pending_carry_total_cents, "collected_cents": sum( - session.total_front_collected_cents for session in sessions + session.total_debt_repayment_cents for session in sessions ), "written_off_cents": sum( - session.total_front_writeoff_cents for session in sessions + session.total_writeoff_cents for session in sessions ), - "net_book_position_cents": sum( - session.total_net_book_position_cents for session in sessions + "net_book_position_cents": ( + cash_in_cents + - cash_out_cents + + players_owe_house_cents + - house_owes_players_cents ), } - admin_totals["paid_out_cents"] = admin_totals["cash_out_cents"] - admin_totals["still_owed_cents"] = admin_totals["house_owes_players_cents"] - admin_totals["players_owe_cents"] = admin_totals["players_owe_house_cents"] - admin_totals["open_balance_cents"] = admin_totals["net_book_position_cents"] return render_template( "admin_dashboard.html", @@ -720,8 +863,10 @@ def admin_dashboard(): recent_events=recent_events, debt_entries=debt_entries, live_session=live_session, + live_session_player_names=live_session_player_names, live_session_no=live_session_no, live_unresolved_count=live_unresolved_count, + pending_carry_items=pending_carry_items, player_names=unique_player_names(events), append_form=append_form, event_types=ADMIN_EVENT_TYPES, @@ -730,6 +875,7 @@ def admin_dashboard(): admin_today=datetime.now().date().isoformat(), session_label=session_label, admin_totals=admin_totals, + empty_session_count=empty_session_count, ) diff --git a/static/css/style.css b/static/css/style.css index 104ef36..50c4fc6 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -109,6 +109,13 @@ h1, h2, h3, h4 { margin: 0; } .page--public { max-width: var(--w-public); } .page--session { max-width: var(--w-session); } .page--admin { max-width: var(--w-admin); } +@media (min-width: 1280px) { + .page--admin, + .page--session { + padding-left: 28px; + padding-right: 28px; + } +} /* ================================================================ FLASH MESSAGES @@ -198,7 +205,8 @@ h3 { } .session-number { color: var(--accent); - margin-bottom: 8px; + font-size: 12.5px; + margin-bottom: 9px; } .hero__title { font-family: var(--font-display); @@ -284,10 +292,10 @@ h3 { } .stat--hero .stat__val { font-size: 34px; } .player-rank { - margin: 0 0 4px; + margin: 0 0 7px; color: var(--accent); font-family: var(--font-mono); - font-size: 11px; + font-size: 12.5px; letter-spacing: .12em; text-transform: uppercase; } @@ -521,10 +529,15 @@ h3 { .tbl .col-acct { display: none; } .tbl.is-full .col-acct { display: table-cell; } .tbl--session { - min-width: 920px; + min-width: 860px; + font-size: 13.5px; } .tbl--session.is-full { - min-width: 1240px; + min-width: 1180px; +} +.tbl--session th, +.tbl--session td { + padding-inline: 12px; } .tbl--session th:nth-child(7), .tbl--session td:nth-child(7) { @@ -616,6 +629,7 @@ td a:hover { color: var(--accent); } white-space: nowrap; } .badge--paid { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); } +.badge--settled { color: var(--rolled); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); } .badge--partial { color: var(--warn); background: var(--warn-tint); box-shadow: inset 0 0 0 1px var(--warn-tint-bd); } .badge--unpaid { color: var(--neg); background: var(--neg-tint); box-shadow: inset 0 0 0 1px var(--neg-tint-bd); } .badge--owes { color: var(--owes); background: var(--owes-tint); box-shadow: inset 0 0 0 1px var(--owes-tint-bd); } @@ -641,6 +655,7 @@ td a:hover { color: var(--accent); } .status-open { color: var(--warn); background: var(--warn-tint); box-shadow: inset 0 0 0 1px var(--warn-tint-bd); } .status-closed { color: var(--muted-2); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); } .status-paid { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); } +.status-settled { color: var(--rolled); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); } .status-partial { color: var(--warn); background: var(--warn-tint); box-shadow: inset 0 0 0 1px var(--warn-tint-bd); } .status-unpaid { color: var(--neg); background: var(--neg-tint); box-shadow: inset 0 0 0 1px var(--neg-tint-bd); } .status-owes { color: var(--owes); background: var(--owes-tint); box-shadow: inset 0 0 0 1px var(--owes-tint-bd); } @@ -665,7 +680,11 @@ td a:hover { color: var(--accent); } .pill-cashout { color: var(--rank-2); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); } .pill-paid { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); } .pill-rollover_in { color: var(--rolled); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); } +.pill-payout_carry_in { color: var(--rolled); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); } .pill-rollover_out { color: var(--rolled); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); } +.pill-paid_out { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); } +.pill-debt_repayment { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); } +.pill-writeoff { color: var(--neg); background: var(--neg-tint); box-shadow: inset 0 0 0 1px var(--neg-tint-bd); } .pill-front_collected { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); } .pill-front_writeoff { color: var(--neg); background: var(--neg-tint); box-shadow: inset 0 0 0 1px var(--neg-tint-bd); } .pill-note { color: var(--muted-2); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); } @@ -779,10 +798,19 @@ td a:hover { color: var(--accent); } gap: 6px; } .result-mix__legend-row strong { + display: grid; + grid-template-columns: minmax(2ch, auto) 6ch; + gap: 8px; color: var(--text-2); font-weight: 600; text-align: right; white-space: nowrap; + font-variant-numeric: tabular-nums; +} +.result-mix__legend-row strong b, +.result-mix__legend-row strong em { + font: inherit; + text-align: right; } .result-mix__key { width: 8px; @@ -887,17 +915,35 @@ td a:hover { color: var(--accent); } } .event-card--inline { display: grid; - grid-template-columns: minmax(0, 1fr) auto auto; - gap: 10px; + grid-template-columns: minmax(82px, 120px) max-content minmax(0, 1fr) minmax(88px, auto) minmax(74px, auto); + gap: 12px; + align-items: center; padding: 9px 16px; } .event-card--inline .event-card-top { - gap: 8px; + display: contents; +} +.event-card--inline .event-card-top strong { + grid-column: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; } .event-card--inline .pill { + grid-column: 2; + justify-self: start; padding: 2px 7px; font-size: 9.5px; } +.event-card--inline .event-note { + grid-column: 3; +} +.event-card--inline .event-amount { + grid-column: 4; +} +.event-card--inline .tiny-text { + grid-column: 5; +} .event-note { min-width: 0; overflow: hidden; @@ -906,10 +952,10 @@ td a:hover { color: var(--accent); } color: var(--muted); font-size: 12.5px; } -.event-amount { font-family: var(--font-mono); font-size: 13px; font-weight: 600; color: var(--num); margin: 0; white-space: nowrap; } +.event-amount { font-family: var(--font-mono); font-size: 13px; font-weight: 600; color: var(--num); margin: 0; white-space: nowrap; text-align: right; } .muted-text { color: var(--muted); font-size: 13.5px; margin: 0; } .muted-text.small, .tiny-text { font-size: 12px; } -.tiny-text { color: var(--faintest); } +.tiny-text { color: var(--faintest); margin: 0; text-align: right; white-space: nowrap; } .note-list { margin: 0; padding-left: 16px; color: var(--muted); font-size: 13px; } .note-list li + li { margin-top: 4px; } @@ -971,6 +1017,10 @@ td a:hover { color: var(--accent); } border-color: var(--border-hi); color: var(--text-strong); } +.secondary-button:disabled, .ghost-button:disabled { + cursor: not-allowed; + opacity: .45; +} .table-action-button { display: inline-flex; align-items: center; @@ -1028,20 +1078,17 @@ td a:hover { color: var(--accent); } /* Admin workspace */ .admin-workspace { display: grid; - grid-template-columns: minmax(0, 2fr) minmax(300px, 1fr); + grid-template-columns: minmax(280px, 1fr) minmax(280px, 1fr) minmax(260px, .9fr); gap: var(--s4); - align-items: flex-start; + align-items: stretch; } -.admin-workspace__main { flex: 2; min-width: 360px; } -.admin-workspace__side { flex: 1; min-width: 300px; } -.admin-workspace .col-main, -.admin-workspace .col-side { +.admin-workspace > .panel { display: flex; flex-direction: column; - gap: var(--s4); min-width: 0; } .admin-bottom-grid { + --admin-bottom-body-h: 340px; display: grid; grid-template-columns: minmax(0, 2fr) minmax(300px, 1fr); gap: var(--s4); @@ -1049,19 +1096,22 @@ td a:hover { color: var(--accent); } margin-top: var(--s4); } .admin-bottom-grid > .panel { - min-width: 0; -} -.admin-bottom-grid > .panel:last-child { display: flex; flex-direction: column; - min-height: 0; + min-width: 0; } -.admin-bottom-grid > .panel:last-child .event-feed { - flex: 1; - align-content: start; - max-height: none; +.admin-bottom-grid .table-wrap, +.admin-bottom-grid .event-feed { + height: var(--admin-bottom-body-h); + min-height: 0; overflow-y: auto; } +.admin-bottom-grid .table-wrap { + overflow-x: auto; +} +.admin-bottom-grid .event-feed { + align-content: start; +} /* Cash position strip */ .cash-strip { @@ -1144,9 +1194,97 @@ td a:hover { color: var(--accent); } .section-marker__dot--rolled { background: var(--rolled); } /* Debt / admin tools */ -.debt-panel { display: grid; gap: 18px; } +.debt-panel { + display: grid; + gap: 0; + margin-top: var(--s4); +} .debt-summary { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } -.debt-action-grid { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); } +.debt-layout { + display: grid; + grid-template-columns: minmax(240px, .85fr) minmax(0, 2fr); + gap: 16px; + padding: 16px 20px 18px; +} +.debt-list { + display: grid; + align-content: start; + gap: 8px; + max-height: 245px; + overflow-y: auto; +} +.debt-list-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + padding: 10px 12px; + background: var(--surface-sunk); + border: 1px solid var(--border); + border-radius: var(--r-sm); +} +.debt-list-item strong { + display: block; + color: var(--text-strong); + font-size: 13.5px; + margin-bottom: 2px; +} +.debt-list-item span { + display: block; + color: var(--faintest); + font-family: var(--font-mono); + font-size: 10.5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.debt-list-item b { + color: var(--owes); + font-family: var(--font-mono); + font-size: 13px; + white-space: nowrap; +} +.debt-empty { + display: grid; + place-items: center; + gap: 8px; + padding: 30px 20px 34px; + color: var(--muted); + text-align: center; +} +.debt-empty__mark { + display: grid; + place-items: center; + min-width: 64px; + height: 40px; + padding: 0 14px; + border-radius: var(--r-pill); + background: var(--surface-sunk); + border: 1px solid var(--border); + color: var(--pos); + font-family: var(--font-mono); + font-weight: 700; +} +.debt-empty p { + margin: 0; + font-size: 13.5px; +} +.debt-action-grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(2, minmax(260px, 1fr)); +} +.debt-form { + gap: 10px; + padding: 0; + background: transparent; +} +.debt-form h3 { + margin-bottom: 2px; +} +.debt-form textarea { + min-height: 58px; +} .import-form input[type="file"] { width: 100%; margin: 4px 0 8px; color: var(--muted); } .import-form input[type="file"]::file-selector-button { appearance: none; @@ -1165,12 +1303,17 @@ td a:hover { color: var(--accent); } } /* Admin: append ledger event */ -.append__body { padding: 20px 22px; } +.append { + min-width: 0; +} +.append__body { + padding: 18px 20px; +} .append__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: 14px; - margin-bottom: 16px; + gap: 12px; + margin-bottom: 14px; } .field { display: block; } .field__label { @@ -1216,7 +1359,13 @@ select.control { cursor: pointer; } border-color: var(--accent-chip-bd); box-shadow: 0 0 0 3px var(--accent-a22); } -.append__note { margin-bottom: 18px; } +.append__note { + margin-bottom: 14px; +} +.append__textarea { + min-height: 96px; + resize: vertical; +} .append__footer { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; } .append__preview { display: inline-flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--faint); } .evtag-pill { @@ -1231,9 +1380,44 @@ select.control { cursor: pointer; } } /* Admin: session controls */ -.panel--pad { padding: 20px 22px; } +.panel--pad { padding: 18px 20px; } .panel__title--mb { margin: 0 0 16px; } +.session-control-panel { + min-width: 0; +} +.session-summary-strip { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1px; + overflow: hidden; + margin-bottom: 14px; + border: 1px solid var(--border); + border-radius: var(--r-sm); + background: var(--border); +} +.session-summary-strip > div { + min-width: 0; + padding: 9px 10px; + background: var(--surface-sunk); +} +.session-summary-strip strong { + display: block; + margin-top: 3px; + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 12.5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.import-export-panel { + min-width: 0; +} .livecard { + display: flex; + flex-direction: column; + justify-content: space-between; + min-height: 140px; padding: 15px 16px; background: var(--surface-sunk); border: 1px solid var(--border-strong); @@ -1243,6 +1427,7 @@ select.control { cursor: pointer; } .livecard__head { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; } .dot { width: 7px; height: 7px; border-radius: 50%; } .dot--live { background: var(--pos); animation: livepulse 1.6s ease-in-out infinite; } +.dot--idle { background: var(--muted-2); } .livecard__name { font-weight: 600; color: var(--text-strong); font-size: 14px; } .livecard__no { font-family: var(--font-mono); font-size: 10.5px; color: var(--faintest); } .livecard__metrics { display: flex; gap: 18px; margin-bottom: 14px; } @@ -1292,14 +1477,68 @@ select.control { cursor: pointer; } box-shadow: 0 4px 16px var(--accent-a30); } .btn-newsession--primary:hover { color: var(--accent-ink); opacity: .88; } +.prune-session-form { + display: flex; + justify-content: center; + padding-top: 12px; +} +.prune-session-form .secondary-button { + justify-content: center; +} +.carry-recs { + display: grid; + gap: 8px; + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid var(--divider); +} +.carry-rec { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 10px; + align-items: center; + padding: 8px 10px; + background: var(--surface-sunk); + border: 1px solid var(--border); + border-radius: var(--r-sm); +} +.carry-rec span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-strong); + font-size: 13px; + font-weight: 600; +} +.carry-rec strong { + color: var(--rolled); + font-family: var(--font-mono); + font-size: 12.5px; + white-space: nowrap; +} +.carry-rec form { + margin: 0; +} +.carry-rec em { + color: var(--faintest); + font-size: 11.5px; + font-style: normal; + white-space: nowrap; +} /* Admin: CSV tools */ -.csv__desc { margin: 0 0 16px; font-size: 12.5px; color: var(--faint); } +.csv__desc { margin: 0 0 12px; font-size: 12.5px; color: var(--faint); } +.import-form { + display: flex; + flex: 1; + flex-direction: column; +} .csv__btns { display: flex; gap: 10px; margin-bottom: 12px; } .csv-btn-ghost { flex: 1; text-align: center; - padding: 10px; + padding: 9px 10px; border-radius: 8px; background: var(--field); border: 1px solid var(--border-strong); @@ -1310,8 +1549,11 @@ select.control { cursor: pointer; } } .csv-btn-ghost:hover { border-color: var(--border-hi); } .csv__drop { - display: block; - padding: 18px; + display: grid; + flex: 1; + min-height: 112px; + place-items: center; + padding: 18px 14px; border: 1px dashed var(--border-strong); border-radius: 9px; text-align: center; @@ -1326,17 +1568,65 @@ select.control { cursor: pointer; } .form-shell { display: grid; justify-content: center; padding-top: 40px; } .form-shell.narrow { grid-template-columns: minmax(320px, 480px); } +/* Modal */ +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 50; + display: grid; + place-items: center; + padding: 20px; + background: rgba(5, 5, 8, .68); + backdrop-filter: blur(5px); +} +.modal-backdrop[hidden] { display: none; } +.modal-card { + width: min(100%, 440px); + overflow: hidden; + border-radius: var(--r-md); + background: var(--surface); + border: 1px solid var(--border-strong); + box-shadow: 0 24px 70px rgba(0, 0, 0, .45); +} +.modal-card__head { + padding: 18px 20px 0; +} +.modal-card__text { + margin: 0; + padding: 14px 20px 18px; + color: var(--muted); + font-size: 13.5px; + line-height: 1.5; +} +.modal-card__actions { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 16px 20px; + border-top: 1px solid var(--divider); + background: var(--surface-sunk); + flex-wrap: wrap; +} + /* ================================================================ RESPONSIVE ================================================================ */ @media (max-width: 860px) { .split > .col-main, .split > .col-side { min-width: 100%; } - .admin-workspace__main, .admin-workspace__side { min-width: 100%; } .admin-workspace { grid-template-columns: 1fr; } - .admin-workspace .col-main, .admin-workspace .col-side { min-width: 0; } + .append__textarea { min-height: 120px; } + .debt-layout { + grid-template-columns: 1fr; + } .admin-bottom-grid { grid-template-columns: 1fr; } } +@media (min-width: 861px) and (max-width: 1180px) { + .admin-workspace { + grid-template-columns: repeat(2, minmax(280px, 1fr)); + } +} + @media (max-width: 1180px) { .player-stat-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } } @@ -1351,7 +1641,19 @@ select.control { cursor: pointer; } .recon-metric-grid { grid-template-columns: 1fr; } .player-stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .event-card--inline { - grid-template-columns: minmax(0, 1fr) auto; + grid-template-columns: minmax(0, 1fr) max-content auto; + } + .event-card--inline .event-card-top strong { + grid-column: 1; + } + .event-card--inline .pill { + grid-column: 2; + } + .event-card--inline .event-amount { + grid-column: 3; + } + .event-card--inline .event-note { + grid-column: 1 / -1; } .event-card--inline .tiny-text { grid-column: 1 / -1; diff --git a/stats.py b/stats.py index 8514c0f..f89d4b2 100644 --- a/stats.py +++ b/stats.py @@ -28,6 +28,18 @@ PLAYER_PALETTE = [ BREAK_EVEN_TOLERANCE_CENTS = 100 +def session_sequence_number(session_id: str, session_date: str) -> int: + suffix = session_id.strip().replace(f"{session_date.strip()}-", "", 1) + + if session_id.strip() == session_date.strip(): + return 1 + if suffix.isdigit(): + return int(suffix) + if suffix.lower().startswith("s") and suffix[1:].isdigit(): + return int(suffix[1:]) + return 9999 + + @dataclass class SessionEntry: session_id: str @@ -40,19 +52,32 @@ class SessionEntry: cash_out_cents: int = 0 paid_cents: int = 0 rollover_in_cents: int = 0 + payout_carry_in_cents: int = 0 rollover_out_cents: int = 0 notes: list[str] = field(default_factory=list) @property def invested_cents(self) -> int: - # Poker performance: everything that entered play, including house fronts - # and prior unpaid winnings rolled into this session. + # Poker performance: money/chips that entered play. This is intentionally + # broader than real cash because fronts and rollover-ins affect results. return self.buy_in_cents + self.front_cents + self.rollover_in_cents @property def net_cents(self) -> int: return self.cash_out_cents - self.invested_cents + @property + def paid_out_cents(self) -> int: + return self.paid_cents + + @property + def debt_repayment_cents(self) -> int: + return self.front_collected_cents + + @property + def writeoff_cents(self) -> int: + return self.front_writeoff_cents + @property def gross_payout_cents(self) -> int: # Settlement: cashout is a gross chip count/result, not proof that cash @@ -64,33 +89,42 @@ class SessionEntry: def player_owes_gross_cents(self) -> int: return max(self.front_cents - self.cash_out_cents, 0) + @property + def gross_due_to_house_cents(self) -> int: + return self.player_owes_gross_cents + @property def settled_to_player_cents(self) -> int: # Rollover-out resolves the source session's payable even though it is # not real cash out. The destination session records it as rollover-in. - return self.paid_cents + self.rollover_out_cents + return self.paid_out_cents + self.rollover_out_cents @property def current_due_to_player_cents(self) -> int: - return max(self.gross_payout_cents - self.settled_to_player_cents, 0) + return max( + self.gross_payout_cents + + self.payout_carry_in_cents + - self.settled_to_player_cents, + 0, + ) @property def settled_to_house_cents(self) -> int: # Front collections are real cash in. Writeoffs are not cash, but they # do resolve the receivable. - return self.front_collected_cents + self.front_writeoff_cents + return self.debt_repayment_cents + self.writeoff_cents @property def current_due_to_house_cents(self) -> int: - return max(self.player_owes_gross_cents - self.settled_to_house_cents, 0) + return max(self.gross_due_to_house_cents - self.settled_to_house_cents, 0) @property def real_cash_in_cents(self) -> int: - return self.buy_in_cents + self.front_collected_cents + return self.buy_in_cents + self.debt_repayment_cents @property def real_cash_out_cents(self) -> int: - return self.paid_cents + return self.paid_out_cents @property def payout_due_cents(self) -> int: @@ -114,11 +148,11 @@ class SessionEntry: @property def raw_player_owes_cents(self) -> int: - return self.player_owes_gross_cents + return self.gross_due_to_house_cents @property def front_shortfall_cents(self) -> int: - return self.player_owes_gross_cents + return self.gross_due_to_house_cents @property def overpaid_front_cents(self) -> int: @@ -127,29 +161,31 @@ class SessionEntry: @property def front_writeoff_applied_cents(self) -> int: return min( - self.front_writeoff_cents, - max(self.player_owes_gross_cents - self.front_collected_applied_cents, 0), + self.writeoff_cents, + max(self.gross_due_to_house_cents - self.front_collected_applied_cents, 0), ) @property def front_collected_applied_cents(self) -> int: - return min(self.front_collected_cents, self.player_owes_gross_cents) + return min(self.debt_repayment_cents, self.gross_due_to_house_cents) @property def front_resolved_cents(self) -> int: - return min(self.settled_to_house_cents, self.player_owes_gross_cents) + return min(self.settled_to_house_cents, self.gross_due_to_house_cents) @property def payout_status(self) -> str: if self.current_due_to_house_cents > 0: return "owes" - if self.front_writeoff_cents > 0: + if self.writeoff_cents > 0: return "written_off" - if self.front_collected_cents > 0: + if self.debt_repayment_cents > 0: return "collected" if self.gross_payout_cents <= 0: return "none" if self.current_due_to_player_cents <= 0: + if self.paid_out_cents < self.gross_payout_cents: + return "settled" return "paid" if self.settled_to_player_cents <= 0: return "unpaid" @@ -180,6 +216,10 @@ class SessionSummary: def total_rollover_in_cents(self) -> int: return sum(entry.rollover_in_cents for entry in self.entries) + @property + def total_payout_carry_in_cents(self) -> int: + return sum(entry.payout_carry_in_cents for entry in self.entries) + @property def total_invested_cents(self) -> int: return sum(entry.invested_cents for entry in self.entries) @@ -206,7 +246,11 @@ class SessionSummary: @property def total_paid_cents(self) -> int: - return sum(entry.paid_cents for entry in self.entries) + return self.total_paid_out_cents + + @property + def total_paid_out_cents(self) -> int: + return sum(entry.paid_out_cents for entry in self.entries) @property def total_rollover_out_cents(self) -> int: @@ -222,7 +266,11 @@ class SessionSummary: @property def total_player_owes_gross_cents(self) -> int: - return sum(entry.player_owes_gross_cents for entry in self.entries) + return sum(entry.gross_due_to_house_cents for entry in self.entries) + + @property + def total_gross_due_to_house_cents(self) -> int: + return self.total_player_owes_gross_cents @property def total_settled_to_house_cents(self) -> int: @@ -234,19 +282,39 @@ class SessionSummary: @property def total_front_writeoff_cents(self) -> int: - return sum(entry.front_writeoff_cents for entry in self.entries) + return self.total_writeoff_cents @property def total_front_collected_cents(self) -> int: - return sum(entry.front_collected_cents for entry in self.entries) + return self.total_debt_repayment_cents + + @property + def total_debt_repayment_cents(self) -> int: + return sum(entry.debt_repayment_cents for entry in self.entries) + + @property + def total_writeoff_cents(self) -> int: + return sum(entry.writeoff_cents for entry in self.entries) @property def total_cash_in_cents(self) -> int: return sum(entry.real_cash_in_cents for entry in self.entries) @property - def total_paid_out_cents(self) -> int: - return sum(entry.real_cash_out_cents for entry in self.entries) + def total_real_cash_in_cents(self) -> int: + return self.total_cash_in_cents + + @property + def total_real_cash_out_cents(self) -> int: + return self.total_paid_out_cents + + @property + def total_banker_cash_in_cents(self) -> int: + return self.total_real_cash_in_cents + + @property + def total_banker_cash_out_cents(self) -> int: + return self.total_real_cash_out_cents @property def total_current_due_cents(self) -> int: @@ -290,10 +358,13 @@ class PlayerStats: total_front_writeoff_cents: int current_player_owes_cents: int total_rollover_in_cents: int + total_payout_carry_in_cents: int total_invested_cents: int total_cash_out_cents: int + total_gross_payout_cents: int total_paid_cents: int total_rollover_out_cents: int + current_due_to_player_cents: int total_net_cents: int roi_pct: float current_win_streak: int @@ -306,6 +377,30 @@ class PlayerStats: worst_session_net_cents: int rank_change: int = 0 + @property + def total_debt_repayment_cents(self) -> int: + return self.total_front_collected_cents + + @property + def total_writeoff_cents(self) -> int: + return self.total_front_writeoff_cents + + @property + def current_due_to_house_cents(self) -> int: + return self.current_player_owes_cents + + @property + def total_paid_out_cents(self) -> int: + return self.total_paid_cents + + @property + def total_real_cash_out_cents(self) -> int: + return self.total_paid_out_cents + + @property + def total_real_cash_in_cents(self) -> int: + return self.total_buy_in_cents + self.total_debt_repayment_cents + def apply_rank_changes( current_board: list[PlayerStats], prev_board: list[PlayerStats] @@ -331,34 +426,38 @@ def cents_to_dollars(cents: int) -> Markup: def session_sort_key(session: SessionSummary) -> tuple[str, int, str, str]: session_id = session.session_id.strip() session_date = session.session_date.strip() + return ( + session_date, + session_sequence_number(session_id, session_date), + session.opened_at, + session_id, + ) - if session_id == session_date: - return (session_date, 1, session.opened_at, session_id) - suffix = session_id.replace(f"{session_date}-", "") +def entry_sort_key(entry: SessionEntry) -> tuple[str, int, str]: + return ( + entry.session_date, + session_sequence_number(entry.session_id, entry.session_date), + entry.session_id, + ) + + +def session_display_suffix(session_id: str, session_date: str) -> str: + suffix = session_id.strip().replace(f"{session_date.strip()}-", "", 1) + + if session_id.strip() == session_date.strip(): + return "" if suffix.isdigit(): - return (session_date, int(suffix), session.opened_at, session_id) - + return f"S{int(suffix)}" if suffix.lower().startswith("s") and suffix[1:].isdigit(): - return (session_date, int(suffix[1:]), session.opened_at, session_id) - - return (session_date, 9999, session.opened_at, session_id) + return f"S{int(suffix[1:])}" + return suffix def session_chart_label(session: SessionSummary) -> str: - session_id = session.session_id.strip() - - if session_id == session.session_date: - return safe_date_label(session.session_date) - - suffix = session_id.replace(f"{session.session_date}-", "") - if suffix.isdigit(): - return f"{safe_date_label(session.session_date)} · S{int(suffix)}" - - if suffix.lower().startswith("s") and suffix[1:].isdigit(): - return f"{safe_date_label(session.session_date)} · S{int(suffix[1:])}" - - return f"{safe_date_label(session.session_date)} · {suffix}" + suffix = session_display_suffix(session.session_id, session.session_date) + date_label = safe_date_label(session.session_date) + return f"{date_label} · {suffix}" if suffix else date_label def safe_date_label(raw_date: str) -> str: @@ -369,13 +468,7 @@ def safe_date_label(raw_date: str) -> str: def session_label(session: SessionSummary) -> str: - if session.session_id == session.session_date: - return safe_date_label(session.session_date) - - suffix = session.session_id.replace(f"{session.session_date}-", "") - if suffix.isdigit(): - return f"{safe_date_label(session.session_date)} · S{int(suffix)}" - return f"{safe_date_label(session.session_date)} · {suffix}" + return session_chart_label(session) def color_for_name(name: str, names: list[str]) -> str: @@ -450,14 +543,22 @@ def build_session_summaries(events: list[EventRow]) -> list[SessionSummary]: entry.front_cents += event["amount_cents"] elif event_type == "front_collected": entry.front_collected_cents += event["amount_cents"] + elif event_type == "debt_repayment": + entry.front_collected_cents += event["amount_cents"] elif event_type == "front_writeoff": entry.front_writeoff_cents += event["amount_cents"] + elif event_type == "writeoff": + entry.front_writeoff_cents += event["amount_cents"] elif event_type == "rollover_in": entry.rollover_in_cents += event["amount_cents"] + elif event_type == "payout_carry_in": + entry.payout_carry_in_cents += event["amount_cents"] elif event_type == "cashout": entry.cash_out_cents += event["amount_cents"] elif event_type == "paid": entry.paid_cents += event["amount_cents"] + elif event_type == "paid_out": + entry.paid_cents += event["amount_cents"] elif event_type == "rollover_out": entry.rollover_out_cents += event["amount_cents"] @@ -484,19 +585,12 @@ def build_session_summaries(events: list[EventRow]) -> list[SessionSummary]: ) ) - sessions.sort( - key=lambda session: ( - session.session_date, - session.opened_at, - session.session_id, - ), - reverse=True, - ) + sessions.sort(key=session_sort_key, reverse=True) return sessions def summarize_player_runs(entries: list[SessionEntry]) -> dict[str, int | str | None]: - ordered_entries = sorted(entries, key=lambda entry: entry.session_date) + ordered_entries = sorted(entries, key=entry_sort_key) longest_win_streak = 0 longest_loss_streak = 0 @@ -585,14 +679,17 @@ def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]: total_buy_in = sum(entry.buy_in_cents for entry in entries) total_front = sum(entry.front_cents for entry in entries) - total_front_collected = sum(entry.front_collected_cents for entry in entries) - total_front_writeoff = sum(entry.front_writeoff_cents for entry in entries) + total_front_collected = sum(entry.debt_repayment_cents for entry in entries) + total_front_writeoff = sum(entry.writeoff_cents for entry in entries) current_player_owes = sum(entry.player_owes_cents for entry in entries) total_rollover_in = sum(entry.rollover_in_cents for entry in entries) + total_payout_carry_in = sum(entry.payout_carry_in_cents for entry in entries) total_invested = sum(entry.invested_cents for entry in entries) total_cash_out = sum(entry.cash_out_cents for entry in entries) - total_paid = sum(entry.paid_cents for entry in entries) + total_gross_payout = sum(entry.gross_payout_cents for entry in entries) + total_paid = sum(entry.paid_out_cents for entry in entries) total_rollover_out = sum(entry.rollover_out_cents for entry in entries) + current_due_to_player = sum(entry.current_due_to_player_cents for entry in entries) total_net = sum(entry.net_cents for entry in entries) roi_pct = (total_net / total_invested * 100) if total_invested else 0.0 @@ -614,10 +711,13 @@ def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]: total_front_writeoff_cents=total_front_writeoff, current_player_owes_cents=current_player_owes, total_rollover_in_cents=total_rollover_in, + total_payout_carry_in_cents=total_payout_carry_in, total_invested_cents=total_invested, total_cash_out_cents=total_cash_out, + total_gross_payout_cents=total_gross_payout, total_paid_cents=total_paid, total_rollover_out_cents=total_rollover_out, + current_due_to_player_cents=current_due_to_player, total_net_cents=total_net, roi_pct=roi_pct, current_win_streak=run_summary["current_win_streak"], diff --git a/storage.py b/storage.py index e891c27..0cc324a 100644 --- a/storage.py +++ b/storage.py @@ -39,8 +39,12 @@ VALID_EVENT_TYPES = { "front_writeoff", "cashout", "paid", + "paid_out", "rollover_in", + "payout_carry_in", "rollover_out", + "debt_repayment", + "writeoff", "note", "session_open", "session_close", @@ -120,3 +124,15 @@ def append_event( writer.writerow(event) return event + + +def write_events(csv_path: Path, events: list[EventRow]) -> None: + ensure_data_file(csv_path) + + tmp_path = csv_path.with_suffix(f"{csv_path.suffix}.tmp") + with tmp_path.open("w", newline="", encoding="utf-8") as file: + writer = csv.DictWriter(file, fieldnames=CSV_HEADERS) + writer.writeheader() + writer.writerows(events) + + tmp_path.replace(csv_path) diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html index f88db58..6064d90 100644 --- a/templates/admin_dashboard.html +++ b/templates/admin_dashboard.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}Admin · boker.club{% endblock %} +{% block title %}Admin · myboker.org{% endblock %} {% block page_class %}page--admin{% endblock %} {% block content %} @@ -11,7 +11,7 @@ {% endif %}

Ledger controls

-

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

+

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

+
- -
- -
-
-

Append ledger event

- fast entry -
-
-
- - - - -
-
Session Players Cash inCash outHouse owesCash paid outHouse owes players Players owe house Book position Status
{{ session_label(s) }} {{ s.entries|length }}{{ s.total_cash_in_cents | money }}{{ s.total_paid_out_cents | money }}{{ s.total_banker_cash_in_cents | money }}{{ s.total_banker_cash_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 }}Win % {{ "%.1f"|format(player.win_pct) }}%
Record + {{ player.winning_sessions }}W + · + {{ player.losing_sessions }}L + · + {{ player.break_even_sessions }}E +
ROI
- Poker basis + Poker basis + settlement

Investment and settlement

@@ -216,38 +226,54 @@
Rolled in {{ player.total_rollover_in_cents | money }}
Payout carry-in{{ player.total_payout_carry_in_cents | money }}
Total invested {{ player.total_invested_cents | money }}
Cash-outsGross cashout result {{ player.total_cash_out_cents | money }}
Gross player claim{{ player.total_gross_payout_cents | money }}
Rolled out {{ player.total_rollover_out_cents | money }}
Paid out{{ player.total_paid_cents | money }}Cash paid out{{ player.total_paid_out_cents | money }}
Debt repaid{{ player.total_front_collected_cents | money }}Cash debt repaid{{ player.total_debt_repayment_cents | money }}
Still owes{{ player.current_player_owes_cents | money }}House owes player{{ player.current_due_to_player_cents | money }}
Owes house{{ player.current_due_to_house_cents | money }}
Written off{{ player.total_front_writeoff_cents | money }}{{ player.total_writeoff_cents | money }}
Player InvestedCash-outGross cashout NetCash paidSettlementStatusFrontedPaid outOpen settlementSettlement statusFront Rolled inGross payoutPayout carry-inGross player claim Rolled out Notes
{{ '+' if entry.net_cents > 0 else '' }}{{ entry.net_cents | money }} {{ entry.paid_cents | money }}{{ entry.paid_out_cents | money }} {% if entry.current_due_to_house_cents > 0 %} owes {{ entry.current_due_to_house_cents | money }} + {% elif entry.current_due_to_player_cents > 0 %} + due {{ entry.current_due_to_player_cents | money }} {% else %} {{ entry.current_due_to_player_cents | money }} {% endif %} @@ -132,8 +135,9 @@ {{ entry.front_cents | money }} {{ entry.rollover_in_cents | money }}{{ entry.payout_carry_in_cents | money }} - {% if entry.player_owes_gross_cents > 0 %}—{% else %}{{ entry.gross_payout_cents | money }}{% endif %} + {% if entry.gross_due_to_house_cents > 0 %}—{% else %}{{ entry.gross_payout_cents | money }}{% endif %} {{ entry.rollover_out_cents | money }} @@ -151,7 +155,7 @@ -
+
Breakdown @@ -159,17 +163,17 @@
-
+
-
+
Quick read

Session snapshot

{% set winners = session.entries | selectattr('net_cents', 'gt', 0) | list %} {% set losers = session.entries | selectattr('net_cents', 'lt', 0) | list %} -
+
Biggest winner
@@ -185,7 +189,7 @@ {% if losers %}
{{ losers | sort(attribute='net_cents') | first | attr('player_name') }}
{% endif %}
- Total cash-out + Gross cashout result
{{ session.total_cash_out_cents | money }}
diff --git a/templates/sessions.html b/templates/sessions.html index 1e06957..6cfd272 100644 --- a/templates/sessions.html +++ b/templates/sessions.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}Sessions · boker.club{% endblock %} +{% block title %}Sessions · myboker.org{% endblock %} {% block content %}
@@ -23,7 +23,7 @@
Status Players InvestedCash outGross cashout Table net
{{ session_label(s) }}{{ session_label(s) }} {{ s.entries|length }} {{ s.total_banker_cash_in_cents | money }} {{ s.total_banker_cash_out_cents | money }}{{ s.total_net_book_position_cents | money }} {{ s.status }} - + {% if s.status == "open" %} diff --git a/templates/base.html b/templates/base.html index 4949542..4188485 100644 --- a/templates/base.html +++ b/templates/base.html @@ -11,23 +11,23 @@
- + myboker.org {% if is_admin %}
- +
diff --git a/templates/leaderboard.html b/templates/leaderboard.html index d39249b..e07dcf5 100644 --- a/templates/leaderboard.html +++ b/templates/leaderboard.html @@ -72,7 +72,7 @@ {% endfor %} {% if selected_session_id %} - Reset + Reset {% endif %}
@@ -101,12 +101,12 @@ {% for player in main_board %} {% set idx = loop.index %}
#{{ idx }} - {{ player.player_name }} + {{ player.player_name }} {% if player.rank_change > 0 %} @@ -146,7 +146,7 @@
- {{ entry.player_name }} + {{ entry.player_name }} {{ entry.invested_cents | money }} {{ entry.cash_out_cents | money }}
- {{ session_label(s) }} + {{ session_label(s) }} {{ s.status }} {{ s.entries|length }}