From c35773d33780f6f19fdbca01efdfc018339bed29 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sun, 15 Mar 2026 14:23:17 -0700 Subject: [PATCH] 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 %}