From c35773d33780f6f19fdbca01efdfc018339bed29 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sun, 15 Mar 2026 14:23:17 -0700 Subject: [PATCH 01/83] 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 f596b7ee193f852ceecca1f3277a79feee477f09 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sun, 15 Mar 2026 14:28:16 -0700 Subject: [PATCH 02/83] 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 3a6bbe6eae129ceaaee4e6a6222be54b26478cba Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sun, 15 Mar 2026 15:30:55 -0700 Subject: [PATCH 03/83] 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 686d1e9b2dfa63d71118292b2eb4c8acb8528f05 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sun, 15 Mar 2026 15:48:21 -0700 Subject: [PATCH 04/83] 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 ea20529b6e226a8fbc048117c816c29af4835f82 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sun, 15 Mar 2026 16:18:29 -0700 Subject: [PATCH 05/83] 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 403924ad3727ae8eea3fc2720897cddf37541ad6 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Mon, 16 Mar 2026 12:10:51 -0700 Subject: [PATCH 06/83] 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 b21a5dc002bee6605e59d9ec30d70dfb4d67490f Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Mon, 16 Mar 2026 16:03:47 -0700 Subject: [PATCH 07/83] 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 7648bf6e6a04cf97bdb6c77bbfdf203b5ec6468b Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Fri, 20 Mar 2026 23:28:22 -0700 Subject: [PATCH 08/83] 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 5d10136ee4c0717360de279d35d42464a94df06f Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sat, 21 Mar 2026 00:10:52 -0700 Subject: [PATCH 09/83] 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 faa90f385a42f887b9596b367dcd27619fe6686a Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sat, 21 Mar 2026 00:34:32 -0700 Subject: [PATCH 10/83] 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 6a655002d499db35154405c7a84ffdc8cee7a14c Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sat, 21 Mar 2026 00:45:02 -0700 Subject: [PATCH 11/83] 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 666b859a34a28141bce2f8c9d5f1e40fbd91f63a Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sat, 21 Mar 2026 01:06:31 -0700 Subject: [PATCH 12/83] 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 c24f7d581048c5a4319f8dc5749a48fc32105f4d Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sun, 22 Mar 2026 13:52:59 -0700 Subject: [PATCH 21/83] 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 @@ {% endfor %} diff --git a/tests/test_accounting.py b/tests/test_accounting.py index f4e2c8c..4b7c075 100644 --- a/tests/test_accounting.py +++ b/tests/test_accounting.py @@ -1,15 +1,12 @@ -from pathlib import Path -import tempfile import unittest -import app as app_module from charts import player_session_series from services import ( build_leaderboard, build_session_summaries, pending_payout_carry_items, + prunable_empty_session_ids, ) -from storage import load_events, write_events from utils import session_sort_key @@ -189,7 +186,7 @@ class AccountingTests(unittest.TestCase): 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): + def test_pending_payout_carry_in_preserves_poker_investment(self): rows = [ event("s1", "2026-01-01", "", "session_open", 0, 1), event("s1", "2026-01-01", "A", "buyin", 10, 2), @@ -200,37 +197,19 @@ class AccountingTests(unittest.TestCase): 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.app.config["DATA_PATH"] - app_module.app.config["DATA_PATH"] = data_path + sessions = build_session_summaries(rows) + self.assertEqual( + pending_payout_carry_items(sessions), + [{"player_name": "A", "amount_cents": cents(5)}], + ) - try: - sessions = build_session_summaries(load_events(data_path)) - self.assertEqual( - 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.app.config["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)) + rows.append(event("s2", "2026-01-02", "A", "payout_carry_in", 5, 8)) + updated_sessions = build_session_summaries(rows) + 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 = [ @@ -248,7 +227,7 @@ class AccountingTests(unittest.TestCase): ["Mar 21, 2026 · S1", "Mar 21, 2026 · S2"], ) - def test_admin_prunes_only_empty_marker_sessions(self): + def test_prunable_empty_sessions_excludes_played_sessions(self): rows = [ event("empty-1", "2026-01-01", "", "session_open", 0, 1), event("empty-1", "2026-01-01", "", "session_close", 0, 2), @@ -258,31 +237,21 @@ class AccountingTests(unittest.TestCase): 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.app.config["DATA_PATH"] - app_module.app.config["DATA_PATH"] = data_path + sessions = build_session_summaries(rows) + prunable_ids = prunable_empty_session_ids(rows, sessions) + self.assertEqual(prunable_ids, {"empty-1"}) - 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.app.config["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"], - ) + remaining = [ + row + for row in rows + if (row["session_id"].strip() or row["session_date"].strip()) + not in prunable_ids + ] + 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__": diff --git a/tests/test_stats.py b/tests/test_stats.py index 36ceaf3..940cdda 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -1,10 +1,11 @@ import unittest -from stats import SessionEntry, SessionSummary, build_leaderboard +from models import SessionEntry, SessionSummary +from services import build_leaderboard class CashInAccountingTest(unittest.TestCase): - def test_front_recovered_from_cashout_counts_as_session_cash_in(self): + def test_front_is_poker_investment_not_cash_in(self): entry = SessionEntry( session_id="2026-06-21-01", session_date="2026-06-21", @@ -20,10 +21,10 @@ class CashInAccountingTest(unittest.TestCase): ) self.assertEqual(session.total_buy_in_cents, 2500) - self.assertEqual(session.total_front_collected_cents, 0) - self.assertEqual(session.total_cash_in_cents, 3000) + self.assertEqual(session.total_front_cents, 500) + self.assertEqual(session.total_cash_in_cents, 2500) - def test_collected_front_shortfall_counts_as_session_cash_in(self): + def test_debt_repayment_counts_as_session_cash_in(self): entry = SessionEntry( session_id="2026-06-21-01", session_date="2026-06-21", @@ -39,7 +40,7 @@ class CashInAccountingTest(unittest.TestCase): ) self.assertEqual(session.total_front_collected_cents, 600) - self.assertEqual(session.total_cash_in_cents, 1000) + self.assertEqual(session.total_cash_in_cents, 600) def test_written_off_front_does_not_count_as_session_cash_in(self): entry = SessionEntry( @@ -58,7 +59,7 @@ class CashInAccountingTest(unittest.TestCase): 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): + def test_leaderboard_cash_in_uses_real_cash_only(self): session = SessionSummary( session_id="2026-06-21-01", session_date="2026-06-21", @@ -69,6 +70,7 @@ class CashInAccountingTest(unittest.TestCase): player_name="Alex", buy_in_cents=2500, front_cents=500, + front_collected_cents=200, cash_out_cents=4000, ) ], @@ -77,8 +79,8 @@ class CashInAccountingTest(unittest.TestCase): 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) + self.assertEqual(player.total_front_collected_cents, 200) + self.assertEqual(player.total_real_cash_in_cents, 2700) if __name__ == "__main__": From 51c8656a6982c6a62fe50989a0e7a61c38d625f9 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sun, 28 Jun 2026 12:11:22 -0700 Subject: [PATCH 75/83] internal statistics & tooling --- app.py | 5 +- config.py | 2 +- emails.py | 21 + routes/internal.py | 663 ++++++++++++++++++++++++++ static/css/style.css | 206 ++++++++ templates/_internal_sidebar.html | 56 +++ templates/internal_dashboard.html | 374 +++++++++++++++ templates/internal_league_detail.html | 128 +++++ templates/internal_leagues.html | 71 +++ templates/internal_user_detail.html | 148 ++++++ templates/internal_users.html | 73 +++ tests/test_internal.py | 320 +++++++++++++ 12 files changed, 2065 insertions(+), 2 deletions(-) create mode 100644 routes/internal.py create mode 100644 templates/_internal_sidebar.html create mode 100644 templates/internal_dashboard.html create mode 100644 templates/internal_league_detail.html create mode 100644 templates/internal_leagues.html create mode 100644 templates/internal_user_detail.html create mode 100644 templates/internal_users.html create mode 100644 tests/test_internal.py diff --git a/app.py b/app.py index a660084..529ec5a 100644 --- a/app.py +++ b/app.py @@ -4,11 +4,12 @@ from __future__ import annotations import click from flask import Flask, render_template -from auth import current_user_id, is_logged_in, normalize_email +from auth import current_user_id, is_logged_in, is_site_admin, normalize_email from config import Config from db import database_extensions_available, db, init_database from extensions import csrf, limiter, mail from routes.account import account_bp +from routes.internal import internal_bp from routes.leagues import leagues_bp from routes.public import public_bp from storage import ensure_data_file @@ -36,10 +37,12 @@ def create_app(config_overrides: dict | None = None) -> Flask: "app_version": app.config["APP_VERSION"], "current_user_id": current_user_id(), "is_logged_in": is_logged_in(), + "current_user_is_site_admin": is_site_admin(), } app.register_blueprint(public_bp) app.register_blueprint(account_bp) + app.register_blueprint(internal_bp) app.register_blueprint(leagues_bp) @app.errorhandler(404) diff --git a/config.py b/config.py index 5a4dfc4..d54a306 100644 --- a/config.py +++ b/config.py @@ -9,7 +9,7 @@ DATA_PATH = BASE_DIR / "data" / "entries.csv" DEFAULT_DATABASE_URL = f"sqlite:///{BASE_DIR / 'data' / 'boker-dev.sqlite3'}" ELIGIBLE_MIN_SESSIONS = 3 -APP_VERSION = "2.5.24" +APP_VERSION = "2.5.28" def load_local_env(env_path: Path) -> None: diff --git a/emails.py b/emails.py index 477b4eb..10391c3 100644 --- a/emails.py +++ b/emails.py @@ -127,6 +127,27 @@ def send_password_reset(to_email: str, reset_url: str) -> None: _send_message(msg) +def send_temporary_password(to_email: str, temporary_password: str) -> None: + body = ( + "A temporary password was created for your myboker.org account.\n\n" + f"Temporary password:\n\n{temporary_password}\n\n" + "Sign in with this password, then change it from account settings." + ) + msg = Message( + subject="Temporary myboker.org password", + recipients=[to_email], + body=body, + html=_html_email( + "Temporary password", + "A site administrator created a temporary password for your myboker.org account.", + code=temporary_password, + note="Sign in with this password, then change it from account settings.", + ), + sender=current_app.config.get("MAIL_DEFAULT_SENDER"), + ) + _send_message(msg) + + def send_email_verification_code(to_email: str, code: str) -> None: body = ( "Verify your myboker.org account with this code:\n\n" diff --git a/routes/internal.py b/routes/internal.py new file mode 100644 index 0000000..f83e941 --- /dev/null +++ b/routes/internal.py @@ -0,0 +1,663 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import secrets +import string +from datetime import datetime, timedelta, timezone + +from flask import Blueprint, current_app, flash, redirect, render_template, request, url_for +from sqlalchemy import or_ +from sqlalchemy.exc import IntegrityError + +from auth import ( + current_user_id, + generate_reset_token, + hash_password, + normalize_email, + site_admin_required, +) +from db import db +from db_models import LedgerEvent, League, LeagueMembership, Player, PokerSession, User, utc_now +from league_repositories import delete_league, transfer_league_ownership +from utils import slugify + + +internal_bp = Blueprint("internal", __name__, url_prefix="/internal") + + +def _current_admin() -> User: + return User.query.filter_by(id=current_user_id()).one() + + +def _user_or_404(user_id: str) -> User: + return User.query.filter_by(id=user_id).one_or_404() + + +def _league_or_404(league_id: str) -> League: + return League.query.filter_by(id=league_id).one_or_404() + + +def _owner_for_league(league_id: str) -> User | None: + row = ( + db.session.query(User) + .join(LeagueMembership, LeagueMembership.user_id == User.id) + .filter( + LeagueMembership.league_id == league_id, + LeagueMembership.role == "owner", + LeagueMembership.disabled_at.is_(None), + ) + .one_or_none() + ) + return row + + +def _temporary_password(length: int = 14) -> str: + alphabet = string.ascii_letters + string.digits + return "".join(secrets.choice(alphabet) for _ in range(length)) + + +_CASH_IN_TYPES = ("buyin", "debt_repayment", "front_collected") +_PAGE_SIZE = 50 + + +def _page_number() -> int: + try: + return max(1, int(request.args.get("page", "1"))) + except (TypeError, ValueError): + return 1 + + +def _paginate(query, page: int, per_page: int = _PAGE_SIZE) -> dict: + total = query.order_by(None).count() + pages = max(1, (total + per_page - 1) // per_page) + page = min(page, pages) + items = query.limit(per_page).offset((page - 1) * per_page).all() + return { + "items": items, + "page": page, + "pages": pages, + "per_page": per_page, + "total": total, + "has_prev": page > 1, + "has_next": page < pages, + "prev_page": page - 1, + "next_page": page + 1, + "start": ((page - 1) * per_page + 1) if total else 0, + "end": min(page * per_page, total), + } + + +def _site_stats() -> dict: + now = datetime.now(timezone.utc) + today = now.date() + day_ago = now - timedelta(days=1) + week_ago = now - timedelta(days=7) + week_ago_date = week_ago.date() + month_ago = now - timedelta(days=30) + + cash_in_cents = ( + db.session.query(db.func.coalesce(db.func.sum(LedgerEvent.amount_cents), 0)) + .filter(LedgerEvent.voided_at.is_(None), LedgerEvent.event_type.in_(_CASH_IN_TYPES)) + .scalar() + ) + cash_out_cents = ( + db.session.query(db.func.coalesce(db.func.sum(LedgerEvent.amount_cents), 0)) + .filter(LedgerEvent.voided_at.is_(None), LedgerEvent.event_type == "paid_out") + .scalar() + ) + + # avg distinct players per session (via buyin events) + session_player_counts = ( + db.session.query( + LedgerEvent.session_id, + db.func.count(db.func.distinct(LedgerEvent.player_id)).label("n"), + ) + .filter(LedgerEvent.event_type == "buyin", LedgerEvent.voided_at.is_(None)) + .group_by(LedgerEvent.session_id) + .subquery() + ) + avg_players = db.session.query( + db.func.coalesce(db.func.avg(session_player_counts.c.n), 0) + ).scalar() + + users_in_leagues = ( + db.session.query(db.func.count(db.func.distinct(LeagueMembership.user_id))) + .filter(LeagueMembership.disabled_at.is_(None)) + .scalar() + ) + leagues_with_sessions = ( + db.session.query(db.func.count(db.func.distinct(PokerSession.league_id))) + .scalar() + ) + leagues_active_7d = ( + db.session.query(db.func.count(db.func.distinct(PokerSession.league_id))) + .filter(PokerSession.session_date >= week_ago_date) + .scalar() + ) + sessions_with_events = ( + db.session.query(db.func.count(db.func.distinct(LedgerEvent.session_id))) + .filter(LedgerEvent.voided_at.is_(None)) + .scalar() + ) + + session_count = PokerSession.query.count() + active_league_count = League.query.filter_by(archived_at=None).count() + active_users_24h = User.query.filter(User.last_login_at >= day_ago).count() + active_users_7d = User.query.filter(User.last_login_at >= week_ago).count() + active_users_30d = User.query.filter(User.last_login_at >= month_ago).count() + returning_users_7d = User.query.filter(User.created_at < week_ago, User.last_login_at >= week_ago).count() + returning_users_30d = User.query.filter(User.created_at < month_ago, User.last_login_at >= month_ago).count() + total_users = User.query.count() + voided_events = LedgerEvent.query.filter(LedgerEvent.voided_at.is_not(None)).count() + ledger_events = LedgerEvent.query.filter_by(voided_at=None).count() + + return { + "users": total_users, + "active_users": User.query.filter_by(disabled_at=None).count(), + "disabled_users": User.query.filter(User.disabled_at.is_not(None)).count(), + "verified_users": User.query.filter(User.email_verified_at.is_not(None)).count(), + "unverified_users": User.query.filter(User.email_verified_at.is_(None)).count(), + "users_in_leagues": int(users_in_leagues or 0), + "orphan_users": total_users - int(users_in_leagues or 0), + "site_admins": User.query.filter_by(is_site_admin=True, disabled_at=None).count(), + "new_users_24h": User.query.filter(User.created_at >= day_ago).count(), + "new_users_7d": User.query.filter(User.created_at >= week_ago).count(), + "active_users_24h": active_users_24h, + "active_users_7d": active_users_7d, + "active_users_30d": active_users_30d, + "returning_users_7d": returning_users_7d, + "returning_users_30d": returning_users_30d, + "leagues": League.query.count(), + "active_leagues": active_league_count, + "archived_leagues": League.query.filter(League.archived_at.is_not(None)).count(), + "public_leagues": League.query.filter_by(visibility="public", archived_at=None).count(), + "leagues_with_sessions": int(leagues_with_sessions or 0), + "leagues_without_sessions": max(active_league_count - int(leagues_with_sessions or 0), 0), + "leagues_active_7d": int(leagues_active_7d or 0), + "sessions": session_count, + "open_sessions": PokerSession.query.filter_by(status="open").count(), + "sessions_24h": PokerSession.query.filter(PokerSession.session_date == today).count(), + "sessions_7d": PokerSession.query.filter(PokerSession.session_date >= week_ago_date).count(), + "sessions_without_events": max(session_count - int(sessions_with_events or 0), 0), + "ledger_events": ledger_events, + "ledger_events_24h": LedgerEvent.query.filter(LedgerEvent.created_at >= day_ago).count(), + "ledger_events_7d": LedgerEvent.query.filter(LedgerEvent.created_at >= week_ago).count(), + "voided_events": voided_events, + "void_rate": round((voided_events / (ledger_events + voided_events)) * 100, 1) if ledger_events or voided_events else 0, + "cash_in_cents": int(cash_in_cents or 0), + "cash_out_cents": int(cash_out_cents or 0), + "avg_cash_in_per_session_cents": int((cash_in_cents or 0) / session_count) if session_count else 0, + "avg_sessions_per_active_league": round(session_count / active_league_count, 1) if active_league_count else 0, + "avg_players_per_session": round(float(avg_players or 0), 1), + } + + +def _top_leagues(limit: int = 8): + rows = ( + db.session.query(League, db.func.count(PokerSession.id).label("n")) + .outerjoin(PokerSession, PokerSession.league_id == League.id) + .filter(League.archived_at.is_(None)) + .group_by(League.id) + .order_by(db.func.count(PokerSession.id).desc()) + .limit(limit) + .all() + ) + return [(league, count) for league, count in rows] + + +def _sessions_by_weekday() -> dict: + from sqlalchemy import func as f + rows = ( + db.session.query( + f.strftime("%w", PokerSession.session_date).label("dow"), + f.count(PokerSession.id).label("cnt"), + ) + .group_by("dow") + .all() + ) + counts = {str(i): 0 for i in range(7)} + for row in rows: + counts[str(row.dow)] = row.cnt + return { + "labels": ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + "data": [counts[str(i)] for i in range(7)], + } + + +def _growth_data(weeks: int = 8) -> dict: + now = datetime.now(timezone.utc) + labels, user_signups, session_counts, cash_in_weekly = [], [], [], [] + for i in range(weeks - 1, -1, -1): + start = now - timedelta(weeks=i + 1) + end = now - timedelta(weeks=i) + start_date = start.date() + end_date = end.date() + labels.append(start.strftime("%b %-d")) + user_signups.append(User.query.filter(User.created_at >= start, User.created_at < end).count()) + session_counts.append( + PokerSession.query.filter( + PokerSession.session_date >= start_date, + PokerSession.session_date < end_date, + ).count() + ) + cash = ( + db.session.query(db.func.coalesce(db.func.sum(LedgerEvent.amount_cents), 0)) + .filter( + LedgerEvent.event_type.in_(_CASH_IN_TYPES), + LedgerEvent.voided_at.is_(None), + LedgerEvent.created_at >= start, + LedgerEvent.created_at < end, + ) + .scalar() + ) + cash_in_weekly.append(round(int(cash or 0) / 100, 2)) + return { + "labels": labels, + "user_signups": user_signups, + "session_counts": session_counts, + "cash_in_weekly": cash_in_weekly, + } + + +def _trend_delta(current: int | float, previous: int | float) -> dict: + if previous == 0: + pct = None if current else 0 + else: + pct = round(((current - previous) / previous) * 100, 1) + direction = "flat" + if current > previous: + direction = "up" + elif current < previous: + direction = "down" + return {"current": current, "previous": previous, "pct": pct, "direction": direction} + + +def _trend_cards(growth: dict) -> list[dict]: + labels = growth["labels"] + user_series = growth["user_signups"] + session_series = growth["session_counts"] + cash_series = growth["cash_in_weekly"] + current_start = datetime.now(timezone.utc) - timedelta(days=7) + previous_start = current_start - timedelta(days=7) + + active_current = User.query.filter(User.last_login_at >= current_start).count() + active_previous = User.query.filter( + User.last_login_at >= previous_start, + User.last_login_at < current_start, + ).count() + event_current = LedgerEvent.query.filter( + LedgerEvent.voided_at.is_(None), + LedgerEvent.created_at >= current_start, + ).count() + event_previous = LedgerEvent.query.filter( + LedgerEvent.voided_at.is_(None), + LedgerEvent.created_at >= previous_start, + LedgerEvent.created_at < current_start, + ).count() + + return [ + { + "key": "signups", + "label": "New users", + "value": user_series[-1] if user_series else 0, + "delta": _trend_delta(user_series[-1] if user_series else 0, user_series[-2] if len(user_series) > 1 else 0), + "labels": labels, + "series": user_series, + }, + { + "key": "active", + "label": "Active users", + "value": active_current, + "delta": _trend_delta(active_current, active_previous), + "labels": ["Previous", "Current"], + "series": [active_previous, active_current], + }, + { + "key": "sessions", + "label": "Sessions", + "value": session_series[-1] if session_series else 0, + "delta": _trend_delta(session_series[-1] if session_series else 0, session_series[-2] if len(session_series) > 1 else 0), + "labels": labels, + "series": session_series, + }, + { + "key": "ledger", + "label": "Ledger records", + "value": event_current, + "delta": _trend_delta(event_current, event_previous), + "labels": ["Previous", "Current"], + "series": [event_previous, event_current], + }, + { + "key": "volume", + "label": "Recorded volume", + "value": f"${cash_series[-1]:,.0f}" if cash_series else "$0", + "delta": _trend_delta(cash_series[-1] if cash_series else 0, cash_series[-2] if len(cash_series) > 1 else 0), + "labels": labels, + "series": cash_series, + }, + ] + + +@internal_bp.get("/") +@site_admin_required +def dashboard(): + recent_users = User.query.order_by(User.created_at.desc()).limit(10).all() + recent_leagues = League.query.order_by(League.created_at.desc()).limit(8).all() + top_leagues = _top_leagues() + owners = {league.id: _owner_for_league(league.id) for league in recent_leagues + [l for l, _ in top_leagues]} + growth = _growth_data() + return render_template( + "internal_dashboard.html", + admin_user=_current_admin(), + stats=_site_stats(), + growth=growth, + trend_cards=_trend_cards(growth), + weekday_data=_sessions_by_weekday(), + top_leagues=top_leagues, + recent_users=recent_users, + recent_leagues=recent_leagues, + owners=owners, + ) + + +@internal_bp.get("/users") +@site_admin_required +def users(): + q = request.args.get("q", "").strip() + page = _page_number() + query = User.query + if q: + query = query.filter(User.email.ilike(f"%{normalize_email(q)}%")) + pagination = _paginate(query.order_by(User.created_at.desc()), page) + return render_template("internal_users.html", users=pagination["items"], pagination=pagination, q=q) + + +@internal_bp.get("/users/") +@site_admin_required +def user_detail(user_id: str): + user = _user_or_404(user_id) + memberships = ( + db.session.query(LeagueMembership, League) + .join(League, League.id == LeagueMembership.league_id) + .filter(LeagueMembership.user_id == user.id) + .order_by(LeagueMembership.created_at.desc()) + .all() + ) + owned_leagues = League.query.filter_by(created_by_user_id=user.id).order_by(League.created_at.desc()).all() + event_count = LedgerEvent.query.filter_by(created_by_user_id=user.id).count() + return render_template( + "internal_user_detail.html", + user=user, + memberships=memberships, + owned_leagues=owned_leagues, + event_count=event_count, + current_admin_id=current_user_id(), + ) + + +@internal_bp.post("/users//email") +@site_admin_required +def update_user_email(user_id: str): + user = _user_or_404(user_id) + new_email = normalize_email(request.form.get("email", "")) + + if not new_email or "@" not in new_email: + flash("Enter a valid email address.", "error") + return redirect(url_for("internal.user_detail", user_id=user.id)) + + existing = User.query.filter(User.email == new_email, User.id != user.id).one_or_none() + if existing: + flash("That email is already in use.", "error") + return redirect(url_for("internal.user_detail", user_id=user.id)) + + user.email = new_email + db.session.commit() + flash("User email updated.", "success") + return redirect(url_for("internal.user_detail", user_id=user.id)) + + +@internal_bp.post("/users//password-reset") +@site_admin_required +def send_user_password_reset(user_id: str): + user = _user_or_404(user_id) + if user.disabled_at is not None: + flash("Disabled users cannot receive password resets.", "error") + return redirect(url_for("internal.user_detail", user_id=user.id)) + + from emails import send_password_reset + + token = generate_reset_token(user.id) + base_url = current_app.config.get("APP_BASE_URL", "").rstrip("/") + reset_url = f"{base_url}{url_for('account.reset_password', token=token)}" + try: + send_password_reset(user.email, reset_url) + flash("Password reset email sent.", "success") + except Exception: + flash("Password reset email could not be sent. Check mail configuration.", "error") + return redirect(url_for("internal.user_detail", user_id=user.id)) + + +@internal_bp.post("/users//temporary-password") +@site_admin_required +def create_user_temporary_password(user_id: str): + user = _user_or_404(user_id) + if user.disabled_at is not None: + flash("Disabled users cannot receive temporary passwords.", "error") + return redirect(url_for("internal.user_detail", user_id=user.id)) + + from emails import send_temporary_password + + temporary_password = _temporary_password() + user.password_hash = hash_password(temporary_password) + db.session.commit() + try: + send_temporary_password(user.email, temporary_password) + flash("Temporary password created and emailed.", "success") + except Exception: + flash(f"Temporary password created but email failed: {temporary_password}", "error") + return redirect(url_for("internal.user_detail", user_id=user.id)) + + +@internal_bp.post("/users//disable") +@site_admin_required +def disable_user(user_id: str): + user = _user_or_404(user_id) + if user.id == current_user_id(): + flash("You cannot disable your own admin account.", "error") + elif user.disabled_at is None: + user.disabled_at = utc_now() + db.session.commit() + flash("User disabled.", "success") + return redirect(url_for("internal.user_detail", user_id=user.id)) + + +@internal_bp.post("/users//restore") +@site_admin_required +def restore_user(user_id: str): + user = _user_or_404(user_id) + user.disabled_at = None + db.session.commit() + flash("User restored.", "success") + return redirect(url_for("internal.user_detail", user_id=user.id)) + + +@internal_bp.post("/users//delete") +@site_admin_required +def delete_user(user_id: str): + user = _user_or_404(user_id) + confirm = request.form.get("confirm", "").strip() + if user.id == current_user_id(): + flash("You cannot delete your own admin account.", "error") + return redirect(url_for("internal.user_detail", user_id=user.id)) + if confirm != "DELETE": + flash("Confirmation text did not match.", "error") + return redirect(url_for("internal.user_detail", user_id=user.id)) + + owned_leagues = League.query.filter_by(created_by_user_id=user.id).count() + if owned_leagues: + flash("Transfer or delete this user's owned leagues before deleting the account.", "error") + return redirect(url_for("internal.user_detail", user_id=user.id)) + + LeagueMembership.query.filter_by(user_id=user.id).delete(synchronize_session=False) + try: + db.session.delete(user) + db.session.commit() + flash("User deleted.", "success") + return redirect(url_for("internal.users")) + except IntegrityError: + db.session.rollback() + flash("User has audit history and cannot be deleted safely. Disable the account instead.", "error") + return redirect(url_for("internal.user_detail", user_id=user.id)) + + +@internal_bp.get("/leagues") +@site_admin_required +def leagues(): + q = request.args.get("q", "").strip() + page = _page_number() + query = db.session.query(League) + if q: + term = f"%{q.strip()}%" + query = ( + query.outerjoin(LeagueMembership, LeagueMembership.league_id == League.id) + .outerjoin(User, User.id == LeagueMembership.user_id) + .filter( + or_( + League.name.ilike(term), + League.slug.ilike(term), + User.email.ilike(term), + ) + ) + .distinct() + ) + pagination = _paginate(query.order_by(League.created_at.desc()), page) + leagues = pagination["items"] + owners = {league.id: _owner_for_league(league.id) for league in leagues} + return render_template("internal_leagues.html", leagues=leagues, owners=owners, pagination=pagination, q=q) + + +@internal_bp.get("/leagues/") +@site_admin_required +def league_detail(league_id: str): + league = _league_or_404(league_id) + members = ( + db.session.query(LeagueMembership, User) + .join(User, User.id == LeagueMembership.user_id) + .filter(LeagueMembership.league_id == league.id) + .order_by(LeagueMembership.created_at.asc()) + .all() + ) + stats = { + "members": len([m for m, _u in members if m.disabled_at is None]), + "players": Player.query.filter_by(league_id=league.id).count(), + "sessions": PokerSession.query.filter_by(league_id=league.id).count(), + "events": LedgerEvent.query.filter_by(league_id=league.id).count(), + "voided_events": LedgerEvent.query.filter(LedgerEvent.league_id == league.id, LedgerEvent.voided_at.is_not(None)).count(), + } + owner = _owner_for_league(league.id) + return render_template("internal_league_detail.html", league=league, owner=owner, members=members, stats=stats) + + +@internal_bp.post("/leagues//archive") +@site_admin_required +def archive_league(league_id: str): + league = _league_or_404(league_id) + league.archived_at = utc_now() + db.session.commit() + flash("League archived.", "success") + return redirect(url_for("internal.league_detail", league_id=league.id)) + + +@internal_bp.post("/leagues//settings") +@site_admin_required +def update_league_settings(league_id: str): + league = _league_or_404(league_id) + raw_eligible = request.form.get("eligible_min_sessions", "3").strip() + raw_break_even = request.form.get("break_even_dollars", "1.00").strip() + name = request.form.get("name", "").strip() + description = request.form.get("description", "").strip() + visibility = request.form.get("visibility", "private").strip() + + try: + eligible_min = int(raw_eligible) + break_even_cents = round(float(raw_break_even) * 100) + except (ValueError, TypeError): + flash("Eligible sessions and break-even threshold must be valid numbers.", "error") + return redirect(url_for("internal.league_detail", league_id=league.id)) + + if len(name) < 2: + flash("League name must be at least 2 characters.", "error") + elif visibility not in ("private", "public"): + flash("Invalid visibility value.", "error") + elif eligible_min < 1 or eligible_min > 100: + flash("Eligible minimum must be between 1 and 100.", "error") + elif break_even_cents < 0 or break_even_cents > 10000: + flash("Break-even threshold must be between $0.00 and $100.00.", "error") + else: + league.name = name + league.slug = slugify(name) + league.description = description or None + league.visibility = visibility + league.eligible_min_sessions = eligible_min + league.break_even_cents = break_even_cents + db.session.commit() + flash("League settings updated.", "success") + + return redirect(url_for("internal.league_detail", league_id=league.id)) + + +@internal_bp.post("/leagues//restore") +@site_admin_required +def restore_league(league_id: str): + league = _league_or_404(league_id) + league.archived_at = None + db.session.commit() + flash("League restored.", "success") + return redirect(url_for("internal.league_detail", league_id=league.id)) + + +@internal_bp.post("/leagues//transfer") +@site_admin_required +def transfer_league_owner(league_id: str): + league = _league_or_404(league_id) + new_owner_email = normalize_email(request.form.get("new_owner_email", "")) + new_owner = User.query.filter_by(email=new_owner_email, disabled_at=None).one_or_none() + current_owner = _owner_for_league(league.id) + + if current_owner is None: + flash("Current owner could not be found.", "error") + elif new_owner is None: + flash("New owner must be an active user.", "error") + else: + membership = LeagueMembership.query.filter_by(league_id=league.id, user_id=new_owner.id).one_or_none() + if membership is None: + membership = LeagueMembership(league_id=league.id, user_id=new_owner.id, role="manager") + db.session.add(membership) + db.session.flush() + membership.disabled_at = None + result = transfer_league_ownership(league, current_owner.id, new_owner.id) + if result is None: + flash("Ownership transfer failed.", "error") + else: + db.session.commit() + flash("League ownership transferred.", "success") + return redirect(url_for("internal.league_detail", league_id=league.id)) + + db.session.rollback() + return redirect(url_for("internal.league_detail", league_id=league.id)) + + +@internal_bp.post("/leagues//delete") +@site_admin_required +def delete_internal_league(league_id: str): + league = _league_or_404(league_id) + confirm = request.form.get("confirm", "").strip() + if confirm != "DELETE": + flash("Confirmation text did not match.", "error") + return redirect(url_for("internal.league_detail", league_id=league.id)) + + delete_league(league) + db.session.commit() + flash("League deleted.", "success") + return redirect(url_for("internal.leagues")) diff --git a/static/css/style.css b/static/css/style.css index 07e2a5f..1af07e5 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -239,6 +239,133 @@ h1, h2, h3, h4 { margin: 0; } .sidebar__back svg { width: 12px; height: 12px; } .sidebar__back:hover { color: var(--text-2); } +/* ---- Admin sidebar variant ---- */ +.sidebar--admin .sidebar__badge--admin { + background: var(--warn-tint); + border: 1px solid var(--warn-tint-bd); + color: var(--warn); + border-radius: var(--r-sm); + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.sidebar--admin .sidebar__link.is-active { + background: var(--warn-tint); + color: var(--warn); +} +.sidebar--admin .sidebar__link.is-active svg { opacity: 1; color: var(--warn); } +.sidebar--admin .sidebar__link:hover { color: var(--warn); } + +/* ---- Internal admin dashboard ---- */ +.int-hero-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 14px; + margin-bottom: 14px; +} +.int-page-header { + margin-bottom: 20px; +} +.int-section-grid { + margin-bottom: 14px; +} +.int-section-grid--last { + margin-bottom: 40px; +} +.int-hero-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: 18px 20px; + display: flex; + flex-direction: column; + gap: 6px; +} +.int-hero-card__icon { + width: 36px; height: 36px; + border-radius: var(--r-sm); + display: flex; align-items: center; justify-content: center; + margin-bottom: 6px; +} +.int-hero-card__icon--users { background: var(--accent-chip); border: 1px solid var(--accent-chip-bd); color: var(--accent); } +.int-hero-card__icon--leagues { background: rgba(111,192,147,.12); border: 1px solid rgba(111,192,147,.25); color: var(--pos); } +.int-hero-card__icon--sessions { background: var(--warn-tint); border: 1px solid var(--warn-tint-bd); color: var(--warn); } +.int-hero-card__icon--health { background: rgba(120,180,240,.1); border: 1px solid rgba(120,180,240,.22); color: #78b4f0; } +.int-hero-card__num { + font-size: 28px; + font-weight: 700; + color: var(--text-strong); + letter-spacing: -.02em; + line-height: 1; +} +.int-hero-card__label { font-size: 12px; color: var(--muted); font-weight: 500; } +.int-hero-card__sub { font-size: 11.5px; color: var(--faintest); margin-top: 4px; } +.int-chart-panel { padding: 0; overflow: hidden; } +.int-chart-head { padding: 16px 20px 0; } +.int-chart-wrap { padding: 16px 20px 20px; position: relative; height: 220px; } +.int-trend-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 14px; +} +.int-trend-card { + display: grid; + grid-template-columns: minmax(0, 1fr) 88px; + gap: 12px; + align-items: center; + min-height: 112px; + padding: 16px 18px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-md); +} +.int-trend-card__label, +.int-trend-card__delta span { + display: block; + color: var(--muted); + font-size: 11.5px; +} +.int-trend-card__value { + display: block; + margin: 4px 0; + color: var(--text-strong); + font-size: 24px; + font-weight: 700; + line-height: 1; +} +.int-trend-card__delta { + color: var(--muted); + font-size: 12px; + font-weight: 700; +} +.int-trend-card__delta--up { color: var(--pos); } +.int-trend-card__delta--down { color: var(--neg); } +.int-sparkline-wrap { + height: 54px; + min-width: 0; +} +.int-breakdown-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 20px; } +.int-breakdown-row { + display: flex; align-items: center; justify-content: space-between; + padding: 6px 0; border-bottom: 1px solid var(--divider); font-size: 13px; +} +.int-breakdown-row:last-child { border-bottom: none; } +.int-breakdown-row__label { color: var(--muted); } +.int-breakdown-row__val { font-weight: 600; color: var(--text-strong); font-variant-numeric: tabular-nums; } + +@media (max-width: 900px) { + .int-hero-grid { grid-template-columns: repeat(2, 1fr); } + .admin-searchbar { + grid-template-columns: 1fr; + } + .admin-searchbar .btn { + justify-content: center; + } +} + /* ---- App page (paired with sidebar) ---- */ .page--app { flex: 1; @@ -1483,6 +1610,85 @@ td a:hover { color: var(--accent); } .stack { display: grid; gap: 20px; } .inline-action-form { margin: 0; } .button-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } +.form-stack { display: grid; gap: 14px; } +.inline-form { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} +.inline-form .field-input { + flex: 1 1 220px; + min-width: 0; +} +.searchbar { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + justify-content: flex-end; + max-width: 560px; +} +.searchbar .field-input { + min-width: 240px; +} +.tbl__actions { + text-align: right; + white-space: nowrap; +} +.admin-list-tools { + margin: 0 0 14px; + padding: 14px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-md); +} +.admin-searchbar { + display: grid; + grid-template-columns: minmax(260px, 1fr) auto auto; + gap: 8px; + align-items: center; + max-width: 720px; +} +.admin-list-panel { + overflow: hidden; +} +.admin-table { + min-width: 760px; +} +.admin-table th { + color: var(--muted); + font-size: 11px; + letter-spacing: .04em; + text-transform: uppercase; +} +.admin-table td { + vertical-align: middle; +} +.admin-table__primary a { + color: var(--text-strong); + font-weight: 700; +} +.admin-table__muted { + color: var(--muted); + font-size: 12px; +} +.pagination-row { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + padding: 14px 18px; + border-top: 1px solid var(--border); +} +.pagination-row__label { + color: var(--muted); + font-size: 12px; +} +.btn.is-disabled { + opacity: .4; + pointer-events: none; +} /* Breadcrumb */ .breadcrumb { diff --git a/templates/_internal_sidebar.html b/templates/_internal_sidebar.html new file mode 100644 index 0000000..f66dd42 --- /dev/null +++ b/templates/_internal_sidebar.html @@ -0,0 +1,56 @@ + diff --git a/templates/internal_dashboard.html b/templates/internal_dashboard.html new file mode 100644 index 0000000..112be94 --- /dev/null +++ b/templates/internal_dashboard.html @@ -0,0 +1,374 @@ +{% extends "base.html" %} +{% block title %}Overview · Admin · myboker.org{% endblock %} +{% block sidebar %}{% include "_internal_sidebar.html" %}{% endblock %} +{% block page_class %}page--app{% endblock %} +{% block content %} + +
+
+
+ Site Admin + {% if stats.open_sessions > 0 %} + {{ stats.open_sessions }} live session{{ 's' if stats.open_sessions != 1 }} + {% endif %} +
+

Overview

+

Signed in as {{ admin_user.email }}. Site health, growth, and recent activity at a glance.

+
+
+ + +
+
+
+ + + + + +
+ {{ stats.users }} + Total users + {{ stats.active_users }} active · +{{ stats.new_users_7d }} this week +
+
+
+ + + + +
+ {{ stats.active_leagues }} + Active leagues + {{ stats.public_leagues }} public · {{ stats.archived_leagues }} archived · {{ '%.1f'|format(stats.avg_sessions_per_active_league) }} avg sessions +
+
+
+ + + + +
+ {{ stats.sessions }} + Total sessions + {{ stats.open_sessions }} open · {{ stats.sessions_7d }} this week · {{ '%.1f'|format(stats.avg_players_per_session) }} avg players +
+
+
+ + + + +
+ {{ stats.active_users_30d }} + Active users · 30d + {{ stats.returning_users_30d }} returning · {{ stats.unverified_users }} unverified +
+
+ +
+ {% for card in trend_cards %} +
+
+ {{ card.label }} + {{ card.value }} + + {% if card.delta.pct is none %} + New + {% else %} + {{ '+' if card.delta.current > card.delta.previous else '' }}{{ '%.1f'|format(card.delta.pct) }}% + {% endif %} + vs previous + +
+
+ +
+
+ {% endfor %} +
+ + +
+
+
+

Growth

+

User signups · 8 weeks

+
+
+ +
+
+
+
+

Activity

+

Sessions per week · 8 weeks

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

Recorded ledger volume

+

Buy-ins and collections · 8 weeks

+
+
+ +
+
+
+
+

Patterns

+

Sessions by day of week

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

Activity breakdown

+ 24h / 7d +
+
+
+
+
New users (24h){{ stats.new_users_24h }}
+
New users (7d){{ stats.new_users_7d }}
+
Active users (24h){{ stats.active_users_24h }}
+
Active users (7d){{ stats.active_users_7d }}
+
Active users (30d){{ stats.active_users_30d }}
+
Returning users (7d){{ stats.returning_users_7d }}
+
Returning users (30d){{ stats.returning_users_30d }}
+
Users in leagues{{ stats.users_in_leagues }}
+
No-league signups{{ stats.orphan_users }}
+
Verified accounts{{ stats.verified_users }}
+
Unverified{{ stats.unverified_users }}
+
Disabled{{ stats.disabled_users }}
+
Site admins{{ stats.site_admins }}
+
+
+
Sessions (24h){{ stats.sessions_24h }}
+
Sessions (7d){{ stats.sessions_7d }}
+
Open sessions{{ stats.open_sessions }}
+
Leagues active (7d){{ stats.leagues_active_7d }}
+
Leagues with no sessions{{ stats.leagues_without_sessions }}
+
Sessions with no ledger records{{ stats.sessions_without_events }}
+
Avg players/session{{ '%.1f'|format(stats.avg_players_per_session) }}
+
Ledger events (24h){{ stats.ledger_events_24h }}
+
Ledger events (7d){{ stats.ledger_events_7d }}
+
Total ledger events{{ stats.ledger_events }}
+
Voided ledger records{{ stats.voided_events }} · {{ '%.1f'|format(stats.void_rate) }}%
+
Avg sessions / league{{ stats.avg_sessions_per_active_league }}
+
+
+
+
+ +
+
+

Top leagues

+ All leagues → +
+
+
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 055b9f4409a1bf51c314cc786776e8c4a0d75961 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sat, 21 Mar 2026 01:18:26 -0700 Subject: [PATCH 13/83] 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 8f82ee9958817b326b8357cefbfdec8fb3615e25 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sat, 21 Mar 2026 01:22:44 -0700 Subject: [PATCH 14/83] 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 500625a4988be85febbcc6330c523bddf56818f7 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sat, 21 Mar 2026 01:30:42 -0700 Subject: [PATCH 15/83] 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 2800525e93a2a3c470a5b38908b334c4fda4d4fc Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sat, 21 Mar 2026 12:52:16 -0700 Subject: [PATCH 16/83] 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 ce795d68cd4b8bcd4caa7fd375227afc6fbd64d2 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sat, 21 Mar 2026 13:23:15 -0700 Subject: [PATCH 17/83] 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 f0f6ac8e697d5cca6619b234d7618a95936e782a Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sat, 21 Mar 2026 14:53:47 -0700 Subject: [PATCH 18/83] 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 73eb69b7eaaf0c30562d30af2043d8aa0cc8f279 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sat, 21 Mar 2026 15:12:12 -0700 Subject: [PATCH 19/83] 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 3248731f511aaa28cb684996fc4e21adcedaab46 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sat, 21 Mar 2026 15:14:55 -0700 Subject: [PATCH 20/83] 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 7cdd89ef2c5115bcca864feaa4371e985fbb10ba Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sun, 22 Mar 2026 14:24:30 -0700 Subject: [PATCH 22/83] 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 a17b194659665834dcf8542ae25a6037f4ef1c20 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sun, 22 Mar 2026 15:00:15 -0700 Subject: [PATCH 25/83] 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 6ed0f6905081cb63ec668a14dde31a6f4b0c69cf Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sat, 28 Mar 2026 11:16:22 -0700 Subject: [PATCH 26/83] 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 8b74f277b2c163500a8397f7354ebca28d01a7bd Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sat, 28 Mar 2026 11:26:35 -0700 Subject: [PATCH 27/83] 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 51686de47399cd02671df2cc623df4ee62801bef Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sun, 22 Mar 2026 14:28:53 -0700 Subject: [PATCH 23/83] 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 4635286a09cc978f96edbd2efc5a3a7071eb073c Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sun, 22 Mar 2026 14:40:24 -0700 Subject: [PATCH 24/83] 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 7e7b3a701128067415a66dd0da2b629bf7976bad Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Thu, 11 Jun 2026 13:27:00 -0700 Subject: [PATCH 30/83] 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 3ad833b4e7318776fbcc76e647f9ef7168bca435 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Mon, 22 Jun 2026 14:17:10 -0700 Subject: [PATCH 33/83] 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 5d30dde31631d4b0c85fb072c74151536a50f33a Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Mon, 22 Jun 2026 17:41:16 -0700 Subject: [PATCH 34/83] 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 434793d89ee9a153cbd95e4b1e848b4c201d9d72 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Mon, 22 Jun 2026 18:34:40 -0700 Subject: [PATCH 35/83] 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 6fcdc2946db3420606387848d78272919f3f3e7c Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Mon, 22 Jun 2026 18:40:12 -0700 Subject: [PATCH 36/83] 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 00aa0a9225d4a3dee91e4e78b8e6db6d30ac3cdb Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Mon, 22 Jun 2026 20:05:07 -0700 Subject: [PATCH 37/83] 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 49b0b54c2dc428b4eca13816beae72eb247360b8 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Mon, 22 Jun 2026 20:51:19 -0700 Subject: [PATCH 38/83] 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 77198e5b472a143b76442c9022fc130a84aa6f5f Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Mon, 22 Jun 2026 00:25:57 -0700 Subject: [PATCH 31/83] 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 70e62342ee587a67bdf04188402963ea131d191b Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Mon, 22 Jun 2026 13:51:38 -0700 Subject: [PATCH 32/83] 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 }}
+ + + + + + + + + + + + + + + {% for player in main_board %} + {% set idx = loop.index %} + {% set player_href = url_for('leagues.player_detail', league_ref=league.url_ref, player_id=player_ids[player.player_name]) if player_ids.get(player.player_name) else '' %} + + + + + + + + + + + + {% else %} + + + + {% endfor %} + +
+ # + + +
+ #{{ idx }} + + {% if player_href %}{{ player.player_name }}{% else %}{{ player.player_name }}{% endif %} + + {% if player.rank_change > 0 %} + ▲{{ player.rank_change }} + {% elif player.rank_change < 0 %} + ▼{{ player.rank_change | abs }} + {% else %} + + {% endif %} + + {{ '+' if player.total_net_cents > 0 else '' }}{{ 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 players yet — add the first event from a league session ledger. +
+
+ +{% if mode == 'eligible' and provisional_board %} +
+
+ Provisional · fewer than {{ eligible_min_sessions }} sessions +
+
+ {% for player in provisional_board %} + {% set player_href = url_for('leagues.player_detail', league_ref=league.url_ref, player_id=player_ids[player.player_name]) if player_ids.get(player.player_name) else '' %} + +

{{ player.player_name }}

+
+ {{ player.sessions_played }} sess + {{ player.total_net_cents | money }} +
+
+ {% endfor %} +
+
+{% endif %} + + + + + +{% endblock %} diff --git a/templates/league_ledger.html b/templates/league_ledger.html new file mode 100644 index 0000000..bb696bf --- /dev/null +++ b/templates/league_ledger.html @@ -0,0 +1,303 @@ +{% extends "base.html" %} +{% block title %}Ledger · {{ league.name }} · myboker.org{% endblock %} +{% block subnav %}{% include "_league_subnav.html" %}{% endblock %} +{% block content %} + +
+
+ Cash in Total real cash brought into the pot across all sessions. Counts buy-ins and collected fronts only — not fronts that haven't been repaid. +
{{ totals.cash_in_cents | money }}
+
+
+ Cash paid out Total cash physically returned to players via 'paid out' events. Should approach Cash In once everyone has settled. +
{{ totals.cash_out_cents | money }}
+
+
+ House holds Cash in minus cash paid out — what the house physically holds right now before settling remaining claims. +
{{ totals.book_held_cents | money }}
+
+
+ Due to players Cash the house still owes to players who cashed out but haven't been paid yet. Record a 'paid out' event to clear these. +
{{ totals.due_to_players_cents | money }}
+
+
+ Due to house Outstanding fronts — chips the house covered that players haven't repaid yet. Collect or write off using the Debt resolution panel below. +
{{ totals.due_to_house_cents | money }}
+
+
+ Net position House holds + owed by players − owed to players. Should be near zero once all sessions are settled. +
{{ totals.net_position_cents | money }}
+
+
+ Events Total number of active ledger entries across all sessions. +
{{ totals.events }}
+
+
+ + +{% if open_sessions %} +
+
+ {{ open_sessions | length }} live Open sessions +
+
+ {% for s in open_sessions %} +
+ {{ session_label(s) }} + + {{ s.entries | length }} entries · {{ s.total_real_cash_in_cents | money }} in + View → + +
+ {% endfor %} +
+
+{% endif %} + + +
+
+
+ Front receivables A front is chips the house covered for a player who didn't have cash. This section tracks who still owes money back to the house. +

Debt resolution

+
+
+ Open {{ totals.due_to_house_cents | money }} + Collected {{ totals.collected_cents | money }} + Written off {{ totals.written_off_cents | money }} +
+
+ {% if debt_entries %} +
+
+ {% for item in debt_entries %} + {% set s = item.session %} + {% set e = item.entry %} +
+
+ {{ e.player_name }} + {{ session_label(s) }} +
+ {{ e.current_due_to_house_cents | money }} +
+ {% endfor %} +
+ + {% if can_manage %} +
+
+ +

Collect debt

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

Write off debt

+ + + + + + + +
+
+ {% endif %} +
+ {% else %} +
+
$0
+

No open player debts right now.

+
+ {% endif %} +
+ + +{% if due_to_player_items %} +
+
+
+ Unpaid cashout claims +

Due to players

+
+ {{ totals.due_to_players_cents | money }} +
+
+ {% for item in due_to_player_items %} +
+
+ {{ item.entry.player_name }} + {{ session_label(item.session) }} +
+ {{ item.entry.current_due_to_player_cents | money }} +
+ {% endfor %} +
+
+{% endif %} + + +{% if can_manage %} +
+

Import / export

+

Back up or migrate the raw event ledger for this league.

+
+ +
+ ↓ Export CSV + +
+ + +
+
+{% else %} +
+

Export

+

Download the raw event ledger for this league.

+ +
+{% endif %} + + +
+
+
+
+ All sessions +

Ledger totals

+
+
+
+ + + + + + + + + + + + + {% for s in sessions %} + + + + + + + + + {% else %} + + {% endfor %} + +
SessionPlayersCash inPaid outDue to houseStatus
{{ session_label(s) }}{{ s.entries|length }}{{ s.total_real_cash_in_cents | money }}{{ s.total_real_cash_out_cents | money }}{{ s.total_current_due_to_house_cents | money }}{{ s.status }}
No sessions yet.
+
+
+ +
+
+
+ All events +

Ledger feed

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

{{ event.amount_cents | money }}

+ {% if event.voided_at %} +

{{ event.void_reason }}

+ {% else %} +

{{ event.session_date }}

+ {% endif %} +
+ {% else %} +

No events recorded.

+ {% endfor %} +
+
+
+ + + +{% endblock %} diff --git a/templates/league_new.html b/templates/league_new.html new file mode 100644 index 0000000..46867c0 --- /dev/null +++ b/templates/league_new.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}New League · myboker.org{% endblock %} +{% block content %} + +
+
+ +
+

League setup

+

Create league

+

Leagues are long-lived containers. Seasons can be added later.

+
+ + + + Cancel +
+
+ +{% endblock %} diff --git a/templates/league_player_detail.html b/templates/league_player_detail.html new file mode 100644 index 0000000..e80cdbd --- /dev/null +++ b/templates/league_player_detail.html @@ -0,0 +1,346 @@ +{% extends "base.html" %} +{% block title %}{{ player.player_name }} · {{ league.name }} · myboker.org{% endblock %} +{% block subnav %}{% include "_league_subnav.html" %}{% endblock %} +{% block content %} + + +
+
+ {% if player_rank %} +

Rank #{{ player_rank }}

+ {% endif %} +

{{ player.player_name }}

+

{{ player.sessions_played }} session{{ 's' if player.sessions_played != 1 else '' }} · {{ "%.1f"|format(player.win_pct) }}% win rate

+
+
+ +
+
+ Total net +
+ {{ '+' if player.total_net_cents > 0 else '' }}{{ player.total_net_cents | money }} +
+
+
+ Win rate +
{{ "%.1f"|format(player.win_pct) }}%
+
{{ player.winning_sessions }}W · {{ player.losing_sessions }}L · {{ player.break_even_sessions }}E
+
+
+ ROI +
{{ "%.1f"|format(player.roi_pct) }}%
+
+
+ Sessions +
{{ player.sessions_played }}
+
+
+ Current W/L +
+ {{ player.current_win_streak }}W + {{ player.current_loss_streak }}L +
+
Longest {{ player.longest_win_streak }}W · {{ player.longest_loss_streak }}L
+
+
+ Best / worst +
+ {{ player.biggest_win_cents | money }} + {{ player.biggest_loss_cents | money }} +
+
+
+ Total invested +
{{ player.total_invested_cents | money }}
+
+
+ Gross cashouts +
{{ player.total_cash_out_cents | money }}
+
+
+ + +
+
+ Trend +

Cumulative profit

+
+
+
+ +
+
+
+ + +
+
+
+ Session by session +

Results

+
+
+
+ +
+
+
+
+
+ Breakdown +

Result mix

+
+
+
+ {% set result_total = player.winning_sessions + player.losing_sessions + player.break_even_sessions %} +
+ {% if player.winning_sessions > 0 %} + + {% endif %} + {% if player.break_even_sessions > 0 %} + + {% endif %} + {% if player.losing_sessions > 0 %} + + {% endif %} +
+
+
+ Wins + {{ player.winning_sessions }}{{ "%.1f"|format((player.winning_sessions / result_total * 100) if result_total else 0) }}% +
+
+ Even + {{ player.break_even_sessions }}{{ "%.1f"|format((player.break_even_sessions / result_total * 100) if result_total else 0) }}% +
+
+ Losses + {{ player.losing_sessions }}{{ "%.1f"|format((player.losing_sessions / result_total * 100) if result_total else 0) }}% +
+
+ {% if result_total > 0 %} +
±$1.00 counts as even
+ {% endif %} +
+
+
+
+ + +
+ + +
+
+
+
+ + Poker performance +
+

Game results

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Total Net + {{ '+' if player.total_net_cents > 0 else '' }}{{ player.total_net_cents | money }} +
Win %{{ "%.1f"|format(player.win_pct) }}%
Record + {{ player.winning_sessions }}W + · + {{ player.losing_sessions }}L + · + {{ player.break_even_sessions }}E +
ROI{{ "%.1f"|format(player.roi_pct) }}%
Average Win{{ player.avg_win_cents | money }}
Average Loss{{ player.avg_loss_cents | money }}
Best / Worst + {{ player.biggest_win_cents | money }} + / + {{ player.biggest_loss_cents | money }} +
Win / Loss Streak + {{ player.current_win_streak }}W + · + {{ player.current_loss_streak }}L +
Longest Win / Loss + {{ player.longest_win_streak }}W · {{ player.longest_loss_streak }}L +
+
+ + +
+
+
+
+ + Poker basis + settlement +
+

Investment and settlement

+
+
+ + + + + + + + + + + + + + + {% if player.total_payout_carry_in_cents > 0 %} + + + + + {% endif %} + + + + + + + + + + + + + + + + + + + + + {% if player.total_debt_repayment_cents > 0 %} + + + + + {% endif %} + {% if player.current_due_to_player_cents > 0 %} + + + + + {% endif %} + {% if player.current_due_to_house_cents > 0 %} + + + + + {% endif %} + {% if player.total_writeoff_cents > 0 %} + + + + + {% endif %} + +
Buy-ins paid{{ player.total_buy_in_cents | money }}
Fronted{{ player.total_front_cents | money }}
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 }}
Gross 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 }}
Cash paid out{{ player.total_paid_out_cents | money }}
Cash debt repaid{{ player.total_debt_repayment_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_writeoff_cents | money }}
+
+
+ + +{% endblock %} diff --git a/templates/league_players.html b/templates/league_players.html new file mode 100644 index 0000000..7ed2cd3 --- /dev/null +++ b/templates/league_players.html @@ -0,0 +1,266 @@ +{% extends "base.html" %} +{% block title %}Players · {{ league.name }} · myboker.org{% endblock %} +{% block subnav %}{% include "_league_subnav.html" %}{% endblock %} +{% block content %} + +{% if can_manage %} +
+ +
+

Roster

+

Add player

+
+
+ + +
+
+ +
+
+{% endif %} + +{% set active_players = players | selectattr('status', 'equalto', 'active') | list %} +{% set archived_players = players | selectattr('status', 'equalto', 'archived') | list %} + +
+
+
+

Roster

+

Active players

+
+ {{ active_players|length }} players +
+ {% if active_players %} +
+ {% for player in active_players %} + {% set stats = stats_by_name.get(player.display_name) %} + {% set initials = player.display_name.split()|map('first')|join('')|upper %} + {% set avatar_cls = 'player-avatar--pos' if stats and stats.total_net_cents > 0 else 'player-avatar--neg' if stats and stats.total_net_cents < 0 else '' %} +
+
+
{{ initials[:2] }}
+
+ {{ player.display_name }} + + {% if stats %}{{ stats.sessions_played }} session{{ 's' if stats.sessions_played != 1 else '' }}{% else %}No sessions yet{% endif %} + +
+ {% if can_manage %} +
+ + +
+ {% endif %} +
+ {% if stats %} +
+
+ Net + + {{ '+' if stats.total_net_cents > 0 else '' }}{{ stats.total_net_cents | money }} + +
+
+ Win rate + {{ "%.0f"|format(stats.win_pct * 100) }}% +
+
+ Best win + {{ stats.biggest_win_cents | money }} +
+
+ {% endif %} +
+ {% endfor %} +
+ {% else %} +
+

Empty roster

+

No players yet

+

Add the people who play in this league. Players don't need accounts.

+
+ {% endif %} +
+ +{% if archived_players %} +
+
+
+

Archive

+

Archived players

+
+ {{ archived_players|length }} +
+
+ {% for player in archived_players %} + {% set stats = stats_by_name.get(player.display_name) %} +
+
+ {{ player.display_name }} + {% if stats %}{{ stats.sessions_played }} sessions · {{ stats.total_net_cents | money }} net{% endif %} +
+
+ {% if can_manage %} +
+ + +
+ {% endif %} +
+
+ {% endfor %} +
+
+{% endif %} + +{% if can_manage %} + + + + + +{% endif %} + +{% endblock %} diff --git a/templates/league_session_detail.html b/templates/league_session_detail.html new file mode 100644 index 0000000..8ac0fa9 --- /dev/null +++ b/templates/league_session_detail.html @@ -0,0 +1,382 @@ +{% extends "base.html" %} +{% block title %}{{ session_model.display_label }} · {{ league.name }} · myboker.org{% endblock %} +{% block subnav %}{% include "_league_subnav.html" %}{% endblock %} +{% block content %} + +
+
+

{{ session_model.display_label }}

+

{{ session_model.session_date.isoformat() | pretty_date }} · {{ session_model.status }}

+
+
+ Public view + {% if can_manage %} + + {% if session_model.status == 'open' %} +
+ + +
+ {% else %} + + {% endif %} + {% endif %} + {% if is_owner %} + + {% endif %} +
+
+ +
+
+ Invested +
{{ session.total_invested_cents | money }}
+
+
+ Cashout +
{{ session.total_cash_out_cents | money }}
+
+
+ Paid out +
{{ session.total_paid_out_cents | money }}
+
+
+ Open items +
{{ (session.total_current_due_to_player_cents + session.total_current_due_to_house_cents) | money }}
+
+
+ +
+ {% if can_manage %} +
+ +
+

Append event

+

Ledger entry

+
+ {% if session_model.status != 'open' %} +
+ Session closed + Books assumed settled. Reopen to add events. +
+ {% endif %} +
+ + + + + +
+
+ {% else %} +
+

Read only

+

Your role can inspect this session but cannot append ledger events.

+
+ {% endif %} + +
+
+
+

Results

+

Player totals

+
+ {{ session.entries|length }} players +
+ {% if session.entries %} +
+ {% for entry in session.entries %} + {% set row_cls = 'player-ledger-row--pos' if entry.net_cents > 0 else 'player-ledger-row--neg' if entry.net_cents < 0 else '' %} +
+
+ {{ entry.player_name }} + {% 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 %} + Settled + {% endif %} +
+
+
+ Buy in + {{ entry.invested_cents | money }} +
+
+ Cashout claim + {{ entry.cash_out_cents | money }} +
+
+ Paid out + {{ entry.paid_out_cents | money if entry.paid_out_cents else '—' }} +
+
+ Net + + {{ '+' if entry.net_cents > 0 else '' }}{{ entry.net_cents | money }} + +
+
+ {% if entry.rollover_in_cents or entry.rollover_out_cents or entry.payout_carry_in_cents %} +
+
+ Rolled in + {{ entry.rollover_in_cents | money }} +
+
+ Carry in + {{ entry.payout_carry_in_cents | money }} +
+
+ Rolled out + {{ entry.rollover_out_cents | money }} +
+
+ {% endif %} +
+ {% endfor %} +
+ {% else %} +

No player ledger events yet.

+ {% endif %} +
+
+ +
+
+
+

Audit trail

+

Raw ledger events

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

{{ event.amount_cents | money }}

+ {% if event.voided_at %} +

{{ event.void_reason }}

+ {% else %} +

{{ event.created_at }}

+ {% if is_owner %} + + {% endif %} + {% endif %} +
+ {% else %} +

No events recorded.

+ {% endfor %} +
+
+ + +{% if is_owner %} + +{% endif %} + + + + + +{% if can_manage %} + +{% endif %} + + + + + + +{% endblock %} diff --git a/templates/league_session_view.html b/templates/league_session_view.html new file mode 100644 index 0000000..f18ba6d --- /dev/null +++ b/templates/league_session_view.html @@ -0,0 +1,263 @@ +{% extends "base.html" %} +{% block title %}{{ session_model.display_label }} · {{ league.name }} · myboker.org{% endblock %} +{% block subnav %}{% include "_league_subnav.html" %}{% endblock %} +{% block page_class %}page--session{% endblock %} +{% block content %} + + + +
+

Session #{{ "%03d"|format(session_number) }}

+

{{ session_model.display_label }}

+

{{ session.entries|length }} player{{ 's' if session.entries|length != 1 else '' }} · {{ session_model.status }}

+
+ + +
+
+
+ Banker cash flow +
+
+
+ Actual cash in +
{{ session.total_real_cash_in_cents | money }}
+
+
+ Gross player claim +
{{ session.total_gross_payout_cents | money }}
+
+
+ Cash paid out +
{{ session.total_real_cash_out_cents | money }}
+
+
+ Rolled out +
{{ session.total_rollover_out_cents | money }}
+
+
+
+
+
+ Open items +
+
+
+ House owes players +
+ {{ session.total_current_due_to_player_cents | money }} +
+
+
+ Players owe house +
+ {{ session.total_current_due_to_house_cents | money }} +
+
+
+ Cash debt repaid +
{{ session.total_debt_repayment_cents | money }}
+
+
+ Written off +
{{ session.total_writeoff_cents | money }}
+
+
+
+
+ + +
+
+
+ Results +

Player totals

+
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + {% for entry in session.entries %} + + + + + + + + + + + + + + + + {% else %} + + {% endfor %} + +
PlayerInvestedGross cashoutNetPaid outOpen settlementSettlement statusFrontRolled inPayout carry-inGross player claimRolled outNotes
{{ entry.player_name }}{{ entry.invested_cents | money }}{{ entry.cash_out_cents | money }} + {{ '+' if entry.net_cents > 0 else '' }}{{ entry.net_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 %} + {{ 0 | money }} + {% endif %} + {{ entry.payout_status }}{{ entry.front_cents | money }}{{ entry.rollover_in_cents | money }}{{ entry.payout_carry_in_cents | money }} + {% if entry.gross_due_to_house_cents > 0 %}—{% else %}{{ entry.gross_payout_cents | money }}{% endif %} + {{ entry.rollover_out_cents | money }} + {% if entry.notes %} +
    {% for n in entry.notes %}
  • {{ n }}
  • {% endfor %}
+ {% else %} + + {% endif %} +
No player events yet.
+
+
+ + +
+
+
+
+ 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 %} +
+
+ 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 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 %} +
+
+ Gross cashout result +
{{ session.total_cash_out_cents | money }}
+
+
+ Open items +
+ {{ (session.total_current_due_to_player_cents + session.total_current_due_to_house_cents) | money }} +
+
+
+
+
+ + + +{% endblock %} diff --git a/templates/league_sessions.html b/templates/league_sessions.html new file mode 100644 index 0000000..30e15c8 --- /dev/null +++ b/templates/league_sessions.html @@ -0,0 +1,149 @@ +{% extends "base.html" %} +{% block title %}Sessions · {{ league.name }} · myboker.org{% endblock %} +{% block subnav %}{% include "_league_subnav.html" %}{% endblock %} +{% block content %} + +
+ + {% if can_manage %} +
+ +
+

Add session

+

New session

+
+ +
+ + + +
+
+ +
+
+ {% endif %} + +
+
+
+

History

+

All sessions

+
+
+ {{ sessions|length }} total + {% if can_manage and empty_count > 0 %} +
+ + +
+ {% endif %} +
+
+ {% if sessions %} +
+ {% for session in sessions %} + {% set sm = db_id_to_summary.get(session.id) %} +
+
+
+ {{ session.session_date.strftime('%b') }} + {{ session.session_date.strftime('%-d') }} +
+
+ +
+ S{{ session.sequence_on_date }} + {% if sm and sm.entries %} + · {{ sm.entries|length }} player{{ 's' if sm.entries|length != 1 else '' }} + · {{ sm.total_invested_cents | money }} pot + {% if sm.total_current_due_to_house_cents > 0 or sm.total_current_due_to_player_cents > 0 %} + · {{ (sm.total_current_due_to_house_cents + sm.total_current_due_to_player_cents) | money }} open + {% endif %} + {% endif %} + {% if session.notes %}· {{ session.notes }}{% endif %} +
+
+
+
+ {{ session.status }} + View + {% if can_manage %} + Manage + {% if session.status == 'open' %} +
+ + +
+ {% else %} + + {% endif %} + {% endif %} +
+
+ {% endfor %} +
+ {% else %} +
+

No sessions yet

+

Create the first session above. Same-day sessions will automatically become S1, S2, and so on.

+
+ {% endif %} +
+ +
+ + + + + + +{% endblock %} diff --git a/templates/league_settings.html b/templates/league_settings.html new file mode 100644 index 0000000..4f316fb --- /dev/null +++ b/templates/league_settings.html @@ -0,0 +1,168 @@ +{% extends "base.html" %} +{% block title %}Settings · {{ league.name }} · myboker.org{% endblock %} +{% block subnav %}{% include "_league_subnav.html" %}{% endblock %} +{% block content %} + + +
+ +
+ +
+

General

+

League info

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

Access

+

Members

+
+ {{ members|length }} member{{ 's' if members|length != 1 else '' }} +
+
+ {% for membership, member_user in members %} +
+
+ {{ member_user.email }} + {{ membership.role }} +
+ {% if membership.role != 'owner' %} +
+ + +
+ {% endif %} +
+ {% endfor %} +
+
+ +

Invite by email

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

Danger zone

+

Destructive actions

+
+
+
+
+
+ Archive league +

Removes this league from your dashboard. All players, sessions, and ledger data are preserved.

+
+ +
+
+
+ +
+ + + + + +{% endblock %} diff --git a/templates/leagues_index.html b/templates/leagues_index.html new file mode 100644 index 0000000..1c6aa9f --- /dev/null +++ b/templates/leagues_index.html @@ -0,0 +1,136 @@ +{% extends "base.html" %} +{% block title %}Leagues · myboker.org{% endblock %} +{% block content %} + +
+

Leagues

+

Your poker leagues

+

Owner and manager workspace.

+
+ +
+ +
+ +{% if leagues %} +
+
+ Leagues +
{{ page_summary.league_count }}
+
+
+ Live +
{{ page_summary.live_count }}
+
+
+ Sessions +
{{ page_summary.sessions_count }}
+
+
+ Open items +
{{ page_summary.open_items_cents | money }}
+
+
+ +
+ {% for item in leagues %} + {% set league = item.league %} + {% set membership = item.membership %} +
+
+ +
+ {{ membership.role }} +

{{ league.name }}

+
+ {% if item.live_session %} + {{ session_label(item.live_session) }} + {% else %} + idle + {% endif %} +
+
+ + {% if league.visibility == 'public' %} + + public + {% else %} + + private + {% endif %} + +
+

+ {% if league.description %}{{ league.description }}{% else %}{{ league.slug }}{% endif %} +

+
+ {% if item.latest_session %} + Latest {{ session_label(item.latest_session) }} + {% else %} + No sessions yet + {% endif %} + {% if item.leader %} + Leader {{ item.leader.player_name }} · {{ item.leader.total_net_cents | money }} + {% endif %} +
+
+ +
+
Players{{ item.players_count }}
+
Sessions{{ item.sessions_count }}
+
Paid out{{ item.cash_paid_out_cents | money }}
+
Open{{ item.open_items_cents | money }}
+
+ +
+
+ Top 3 + Full board +
+ {% if item.top_players %} +
    + {% for player in item.top_players %} +
  1. + {{ player.player_name }} + + {{ '+' if player.total_net_cents > 0 else '' }}{{ player.total_net_cents | money }} + +
  2. + {% endfor %} +
+ {% else %} +
+ No results yet + Create a session +
+ {% endif %} +
+ + +
+ {% endfor %} +
+{% else %} +
+

No leagues yet

+

Create the first league to start adding players, sessions, and ledger events.

+ Create league +
+{% endif %} + +{% endblock %} diff --git a/templates/reset_password.html b/templates/reset_password.html new file mode 100644 index 0000000..4e38060 --- /dev/null +++ b/templates/reset_password.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block title %}Reset Password · myboker.org{% endblock %} +{% block content %} + +
+
+ +
+

Account recovery

+

Reset password

+

Enter a new password for your account.

+
+ + + +
+
+ +{% endblock %} diff --git a/utils.py b/utils.py index badd71f..2fa8e79 100644 --- a/utils.py +++ b/utils.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime +import re from markupsafe import Markup @@ -11,6 +12,11 @@ from models import SessionEntry, SessionSummary BREAK_EVEN_TOLERANCE_CENTS = 100 +def slugify(value: str, fallback: str = "league") -> str: + slug = re.sub(r"[^a-z0-9]+", "-", value.strip().casefold()).strip("-") + return slug or fallback + + def cents_to_dollars(cents: int) -> Markup: value = cents / 100 return Markup(f'${value:,.2f}') From ac278065d2f0d7d79a9eac7662854d6fcd57e030 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Wed, 24 Jun 2026 19:08:33 -0700 Subject: [PATCH 41/83] security --- .env.example | 1 + app.py | 9 ++++++--- routes/account.py | 6 +++++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index b73c996..5fd77fe 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ +FLASK_ENV=production SECRET_KEY=replace-this-with-a-long-random-string DATABASE_URL=postgresql+psycopg://user:password@host/dbname APP_BASE_URL=https://myboker.org diff --git a/app.py b/app.py index b0164e7..c2c707b 100644 --- a/app.py +++ b/app.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 from __future__ import annotations +import os + import click from flask import Flask from auth import current_user_id, is_logged_in -from config import Config +from config import Config, ProductionConfig from db import database_extensions_available, db, init_database from extensions import csrf, limiter, mail from routes.account import account_bp @@ -17,7 +19,8 @@ from utils import cents_to_dollars, safe_date_label def create_app(config_overrides: dict | None = None) -> Flask: app = Flask(__name__) - app.config.from_object(Config) + cfg = ProductionConfig if os.getenv("FLASK_ENV") == "production" else Config + app.config.from_object(cfg) if config_overrides: app.config.update(config_overrides) @@ -60,4 +63,4 @@ def create_app(config_overrides: dict | None = None) -> Flask: app = create_app() if __name__ == "__main__": - app.run(debug=True) + app.run(debug=os.getenv("FLASK_DEBUG", "0") == "1") diff --git a/routes/account.py b/routes/account.py index 1bd77c0..ba0f5ec 100644 --- a/routes/account.py +++ b/routes/account.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 from __future__ import annotations +from urllib.parse import urlparse + from flask import Blueprint, flash, redirect, render_template, request, url_for from auth import ( @@ -97,7 +99,9 @@ def login(): flash("That account is disabled.", "error") else: log_user_in(user.id) - next_url = request.args.get("next") or url_for("leagues.index") + raw_next = request.args.get("next", "") + parsed = urlparse(raw_next) + next_url = raw_next if (raw_next and not parsed.netloc and not parsed.scheme) else url_for("leagues.index") flash("Logged in.", "success") return redirect(next_url) From 6da4c3e37972ded76e3c50b07e07172164f4ebac Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Wed, 24 Jun 2026 19:53:19 -0700 Subject: [PATCH 42/83] major file restrucutre --- app.py | 62 +--------- boker/__init__.py | 60 +++++++++ auth.py => boker/auth.py | 0 charts.py => boker/charts.py | 4 +- config.py => boker/config.py | 4 +- db.py => boker/db.py | 2 +- db_models.py => boker/db_models.py | 2 +- emails.py => boker/emails.py | 2 +- extensions.py => boker/extensions.py | 0 models.py => boker/models.py | 0 {routes => boker/repositories}/__init__.py | 0 .../repositories/leagues.py | 12 +- .../repositories/ledger.py | 6 +- boker/routes/__init__.py | 1 + {routes => boker/routes}/account.py | 31 ++--- {routes => boker/routes}/admin.py | 8 +- {routes => boker/routes}/leagues.py | 117 +++++++++--------- {routes => boker/routes}/public.py | 16 +-- services.py => boker/services.py | 6 +- {static => boker/static}/css/style.css | 0 {static => boker/static}/css/tokens.css | 0 {static => boker/static}/favicon.svg | 0 storage.py => boker/storage.py | 0 .../templates}/_league_subnav.html | 0 .../templates}/account_login.html | 0 .../templates}/account_register.html | 0 .../templates}/account_settings.html | 0 .../templates}/admin_dashboard.html | 0 .../templates}/admin_login.html | 0 {templates => boker/templates}/base.html | 0 {templates => boker/templates}/docs.html | 0 {templates => boker/templates}/explore.html | 0 .../templates}/forgot_password.html | 0 {templates => boker/templates}/landing.html | 0 .../templates}/leaderboard.html | 0 .../templates}/league_dashboard.html | 0 .../templates}/league_leaderboard.html | 0 .../templates}/league_ledger.html | 0 .../templates}/league_new.html | 0 .../templates}/league_player_detail.html | 0 .../templates}/league_players.html | 0 .../templates}/league_session_detail.html | 0 .../templates}/league_session_view.html | 0 .../templates}/league_sessions.html | 0 .../templates}/league_settings.html | 0 .../templates}/leagues_index.html | 0 .../templates}/player_detail.html | 0 .../templates}/reset_password.html | 0 .../templates}/session_detail.html | 0 {templates => boker/templates}/sessions.html | 0 utils.py => boker/utils.py | 2 +- tests/test_accounting.py | 28 +++-- tests/test_stats.py | 5 +- 53 files changed, 188 insertions(+), 180 deletions(-) create mode 100644 boker/__init__.py rename auth.py => boker/auth.py (100%) rename charts.py => boker/charts.py (97%) rename config.py => boker/config.py (96%) rename db.py => boker/db.py (90%) rename db_models.py => boker/db_models.py (99%) rename emails.py => boker/emails.py (97%) rename extensions.py => boker/extensions.py (100%) rename models.py => boker/models.py (100%) rename {routes => boker/repositories}/__init__.py (100%) rename league_repositories.py => boker/repositories/leagues.py (97%) rename ledger_repositories.py => boker/repositories/ledger.py (98%) create mode 100644 boker/routes/__init__.py rename {routes => boker/routes}/account.py (93%) rename {routes => boker/routes}/admin.py (99%) rename {routes => boker/routes}/leagues.py (92%) rename {routes => boker/routes}/public.py (93%) rename services.py => boker/services.py (98%) rename {static => boker/static}/css/style.css (100%) rename {static => boker/static}/css/tokens.css (100%) rename {static => boker/static}/favicon.svg (100%) rename storage.py => boker/storage.py (100%) rename {templates => boker/templates}/_league_subnav.html (100%) rename {templates => boker/templates}/account_login.html (100%) rename {templates => boker/templates}/account_register.html (100%) rename {templates => boker/templates}/account_settings.html (100%) rename {templates => boker/templates}/admin_dashboard.html (100%) rename {templates => boker/templates}/admin_login.html (100%) rename {templates => boker/templates}/base.html (100%) rename {templates => boker/templates}/docs.html (100%) rename {templates => boker/templates}/explore.html (100%) rename {templates => boker/templates}/forgot_password.html (100%) rename {templates => boker/templates}/landing.html (100%) rename {templates => boker/templates}/leaderboard.html (100%) rename {templates => boker/templates}/league_dashboard.html (100%) rename {templates => boker/templates}/league_leaderboard.html (100%) rename {templates => boker/templates}/league_ledger.html (100%) rename {templates => boker/templates}/league_new.html (100%) rename {templates => boker/templates}/league_player_detail.html (100%) rename {templates => boker/templates}/league_players.html (100%) rename {templates => boker/templates}/league_session_detail.html (100%) rename {templates => boker/templates}/league_session_view.html (100%) rename {templates => boker/templates}/league_sessions.html (100%) rename {templates => boker/templates}/league_settings.html (100%) rename {templates => boker/templates}/leagues_index.html (100%) rename {templates => boker/templates}/player_detail.html (100%) rename {templates => boker/templates}/reset_password.html (100%) rename {templates => boker/templates}/session_detail.html (100%) rename {templates => boker/templates}/sessions.html (100%) rename utils.py => boker/utils.py (98%) diff --git a/app.py b/app.py index c2c707b..d71a532 100644 --- a/app.py +++ b/app.py @@ -1,66 +1,8 @@ #!/usr/bin/env python3 -from __future__ import annotations - -import os - -import click -from flask import Flask - -from auth import current_user_id, is_logged_in -from config import Config, ProductionConfig -from db import database_extensions_available, db, init_database -from extensions import csrf, limiter, mail -from routes.account import account_bp -from routes.leagues import leagues_bp -from routes.public import public_bp -from storage import ensure_data_file -from utils import cents_to_dollars, safe_date_label - - -def create_app(config_overrides: dict | None = None) -> Flask: - app = Flask(__name__) - cfg = ProductionConfig if os.getenv("FLASK_ENV") == "production" else Config - app.config.from_object(cfg) - if config_overrides: - app.config.update(config_overrides) - - ensure_data_file(app.config["DATA_PATH"]) - init_database(app) - csrf.init_app(app) - limiter.init_app(app) - mail.init_app(app) - - app.jinja_env.filters["money"] = cents_to_dollars - app.jinja_env.filters["pretty_date"] = safe_date_label - - @app.context_processor - def inject_globals() -> dict: - return { - "app_version": app.config["APP_VERSION"], - "current_user_id": current_user_id(), - "is_logged_in": is_logged_in(), - } - - app.register_blueprint(public_bp) - app.register_blueprint(account_bp) - app.register_blueprint(leagues_bp) - - @app.cli.command("init-db") - def init_db_command() -> None: - if not database_extensions_available() or db is None: - raise click.ClickException( - "Database dependencies are not installed. Run pip install -r requirements.txt." - ) - - with app.app_context(): - db.create_all() - - click.echo("Initialized database tables.") - - return app - +from boker import create_app app = create_app() if __name__ == "__main__": + import os app.run(debug=os.getenv("FLASK_DEBUG", "0") == "1") diff --git a/boker/__init__.py b/boker/__init__.py new file mode 100644 index 0000000..a1cc031 --- /dev/null +++ b/boker/__init__.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import os + +import click +from flask import Flask + +from .auth import current_user_id, is_logged_in +from .config import Config, ProductionConfig +from .db import database_extensions_available, db, init_database +from .extensions import csrf, limiter, mail +from .routes.account import account_bp +from .routes.leagues import leagues_bp +from .routes.public import public_bp +from .storage import ensure_data_file +from .utils import cents_to_dollars, safe_date_label + + +def create_app(config_overrides: dict | None = None) -> Flask: + app = Flask(__name__) + cfg = ProductionConfig if os.getenv("FLASK_ENV") == "production" else Config + app.config.from_object(cfg) + if config_overrides: + app.config.update(config_overrides) + + ensure_data_file(app.config["DATA_PATH"]) + init_database(app) + csrf.init_app(app) + limiter.init_app(app) + mail.init_app(app) + + app.jinja_env.filters["money"] = cents_to_dollars + app.jinja_env.filters["pretty_date"] = safe_date_label + + @app.context_processor + def inject_globals() -> dict: + return { + "app_version": app.config["APP_VERSION"], + "current_user_id": current_user_id(), + "is_logged_in": is_logged_in(), + } + + app.register_blueprint(public_bp) + app.register_blueprint(account_bp) + app.register_blueprint(leagues_bp) + + @app.cli.command("init-db") + def init_db_command() -> None: + if not database_extensions_available() or db is None: + raise click.ClickException( + "Database dependencies are not installed. Run pip install -r requirements.txt." + ) + + with app.app_context(): + db.create_all() + + click.echo("Initialized database tables.") + + return app diff --git a/auth.py b/boker/auth.py similarity index 100% rename from auth.py rename to boker/auth.py diff --git a/charts.py b/boker/charts.py similarity index 97% rename from charts.py rename to boker/charts.py index 0a8d074..de70f3b 100644 --- a/charts.py +++ b/boker/charts.py @@ -4,8 +4,8 @@ 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 +from .models import SessionEntry, SessionSummary +from .utils import net_result_bucket, session_chart_label, session_sort_key PLAYER_PALETTE = [ "#9b8cf0", # --line-1 violet diff --git a/config.py b/boker/config.py similarity index 96% rename from config.py rename to boker/config.py index c5a83af..0730954 100644 --- a/config.py +++ b/boker/config.py @@ -4,12 +4,12 @@ from __future__ import annotations import os from pathlib import Path -BASE_DIR = Path(__file__).resolve().parent +BASE_DIR = Path(__file__).resolve().parent.parent DATA_PATH = BASE_DIR / "data" / "entries.csv" DEFAULT_DATABASE_URL = f"sqlite:///{BASE_DIR / 'data' / 'boker-dev.sqlite3'}" ELIGIBLE_MIN_SESSIONS = 3 -APP_VERSION = "2.3.0" +APP_VERSION = "2.3.1" def load_local_env(env_path: Path) -> None: diff --git a/db.py b/boker/db.py similarity index 90% rename from db.py rename to boker/db.py index 050416c..430a768 100644 --- a/db.py +++ b/boker/db.py @@ -24,7 +24,7 @@ def init_database(app: Any) -> bool: app.config["DATABASE_EXTENSIONS_AVAILABLE"] = False return False - import db_models # noqa: F401 - registers SQLAlchemy models with metadata. + from . import db_models # noqa: F401 - registers SQLAlchemy models with metadata. db.init_app(app) migrate.init_app(app, db) diff --git a/db_models.py b/boker/db_models.py similarity index 99% rename from db_models.py rename to boker/db_models.py index 23b2915..229ebe9 100644 --- a/db_models.py +++ b/boker/db_models.py @@ -4,7 +4,7 @@ from __future__ import annotations import uuid from datetime import date, datetime, timezone -from db import db +from .db import db if db is None: # pragma: no cover - imported only when DB dependencies exist. raise RuntimeError("Database dependencies are not installed.") diff --git a/emails.py b/boker/emails.py similarity index 97% rename from emails.py rename to boker/emails.py index 9f15ce6..7b02ddf 100644 --- a/emails.py +++ b/boker/emails.py @@ -4,7 +4,7 @@ from __future__ import annotations from flask import current_app from flask_mail import Message -from extensions import mail +from .extensions import mail def send_password_reset(to_email: str, reset_url: str) -> None: diff --git a/extensions.py b/boker/extensions.py similarity index 100% rename from extensions.py rename to boker/extensions.py diff --git a/models.py b/boker/models.py similarity index 100% rename from models.py rename to boker/models.py diff --git a/routes/__init__.py b/boker/repositories/__init__.py similarity index 100% rename from routes/__init__.py rename to boker/repositories/__init__.py diff --git a/league_repositories.py b/boker/repositories/leagues.py similarity index 97% rename from league_repositories.py rename to boker/repositories/leagues.py index 3d4417a..b23dbc3 100644 --- a/league_repositories.py +++ b/boker/repositories/leagues.py @@ -5,13 +5,13 @@ import secrets import string from datetime import date -from auth import hash_password, normalize_email -from db import db +from ..auth import hash_password, normalize_email +from ..db import db if db is None: # pragma: no cover - imported only when DB dependencies exist. raise RuntimeError("Database dependencies are not installed.") -from db_models import ( # noqa: E402 +from ..db_models import ( # noqa: E402 LedgerEvent, League, LeagueMembership, @@ -24,7 +24,7 @@ from db_models import ( # noqa: E402 make_user, normalize_lookup, ) -from utils import slugify +from ..utils import slugify PUBLIC_KEY_ALPHABET = string.ascii_lowercase + string.digits @@ -223,7 +223,7 @@ def set_session_status(session: PokerSession, status: str) -> PokerSession: if status not in {"open", "closed"}: raise ValueError(f"Unsupported session status: {status}") - from db_models import utc_now + from ..db_models import utc_now session.status = status if status == "open": @@ -313,7 +313,7 @@ def add_league_member( def remove_league_member(league_id: str, user_id: str) -> None: - from db_models import utc_now + from ..db_models import utc_now membership = LeagueMembership.query.filter_by( league_id=league_id, diff --git a/ledger_repositories.py b/boker/repositories/ledger.py similarity index 98% rename from ledger_repositories.py rename to boker/repositories/ledger.py index 93cb1e2..748caaa 100644 --- a/ledger_repositories.py +++ b/boker/repositories/ledger.py @@ -1,12 +1,12 @@ #!/usr/bin/env python3 from __future__ import annotations -from db import db +from ..db import db if db is None: # pragma: no cover - imported only when DB dependencies exist. raise RuntimeError("Database dependencies are not installed.") -from db_models import ( # noqa: E402 +from ..db_models import ( # noqa: E402 CANONICAL_EVENT_TYPES, LedgerEvent, Player, @@ -14,7 +14,7 @@ from db_models import ( # noqa: E402 User, canonical_event_type, ) -from storage import EventRow # noqa: E402 +from ..storage import EventRow # noqa: E402 def session_event_ref(session: PokerSession) -> str: diff --git a/boker/routes/__init__.py b/boker/routes/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/boker/routes/__init__.py @@ -0,0 +1 @@ + diff --git a/routes/account.py b/boker/routes/account.py similarity index 93% rename from routes/account.py rename to boker/routes/account.py index ba0f5ec..4eee3b3 100644 --- a/routes/account.py +++ b/boker/routes/account.py @@ -5,7 +5,7 @@ from urllib.parse import urlparse from flask import Blueprint, flash, redirect, render_template, request, url_for -from auth import ( +from ..auth import ( current_user_id, generate_invite_token, generate_reset_token, @@ -18,8 +18,8 @@ from auth import ( verify_password, verify_reset_token, ) -from db import database_extensions_available, db -from extensions import limiter +from ..db import database_extensions_available, db +from ..extensions import limiter account_bp = Blueprint("account", __name__, url_prefix="/account") @@ -49,7 +49,7 @@ def register(): } if request.method == "POST": - from league_repositories import create_user, find_user_by_email + from ..repositories.leagues import create_user, find_user_by_email email = normalize_email(form["email"]) password = request.form.get("password", "") @@ -88,7 +88,7 @@ def login(): } if request.method == "POST": - from league_repositories import find_user_by_email + from ..repositories.leagues import find_user_by_email user = find_user_by_email(form["email"]) password = request.form.get("password", "") @@ -122,7 +122,7 @@ def settings(): flash("Account database is not available.", "error") return redirect(url_for("leagues.index")) - from db_models import User + from ..db_models import User user = db.session.get(User, current_user_id()) if user is None: @@ -139,7 +139,7 @@ def update_email(): flash("Account database is not available.", "error") return redirect(url_for("account.settings")) - from db_models import User + from ..db_models import User user = db.session.get(User, current_user_id()) if user is None: @@ -154,7 +154,7 @@ def update_email(): elif not verify_password(user.password_hash, current_password): flash("Current password is incorrect.", "error") else: - from league_repositories import find_user_by_email + from ..repositories.leagues import find_user_by_email existing = find_user_by_email(new_email) if existing and existing.id != user.id: @@ -174,7 +174,7 @@ def update_password(): flash("Account database is not available.", "error") return redirect(url_for("account.settings")) - from db_models import User + from ..db_models import User user = db.session.get(User, current_user_id()) if user is None: @@ -206,7 +206,7 @@ def delete_account(): flash("Account database is not available.", "error") return redirect(url_for("account.settings")) - from db_models import League, User, utc_now + from ..db_models import League, User, utc_now user = db.session.get(User, current_user_id()) if user is None: @@ -246,9 +246,10 @@ def forgot_password(): flash("Account database is not available.", "error") return redirect(url_for("account.forgot_password")) - from emails import send_password_reset from flask import current_app - from league_repositories import find_user_by_email + + from ..emails import send_password_reset + from ..repositories.leagues import find_user_by_email email = normalize_email(request.form.get("email", "")) user = find_user_by_email(email) @@ -282,7 +283,7 @@ def reset_password(token): flash("Account database is not available.", "error") return redirect(url_for("account.login")) - from db_models import User + from ..db_models import User user = db.session.get(User, user_id) if user is None or user.disabled_at is not None: @@ -320,8 +321,8 @@ def accept_invite(token): flash("Account database is not available.", "error") return redirect(url_for("leagues.index")) - from db_models import User - from league_repositories import add_league_member, find_league_by_id, find_membership + from ..db_models import User + from ..repositories.leagues import add_league_member, find_league_by_id, find_membership user = db.session.get(User, current_user_id()) if user is None or user.email != data.get("email"): diff --git a/routes/admin.py b/boker/routes/admin.py similarity index 99% rename from routes/admin.py rename to boker/routes/admin.py index f959d89..625e236 100644 --- a/routes/admin.py +++ b/boker/routes/admin.py @@ -16,16 +16,16 @@ from flask import ( url_for, ) -from auth import is_admin -from services import ( +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 +from ..storage import CSV_HEADERS, append_event, load_events, write_events +from ..utils import cents_to_dollars, session_label admin_bp = Blueprint("admin", __name__) diff --git a/routes/leagues.py b/boker/routes/leagues.py similarity index 92% rename from routes/leagues.py rename to boker/routes/leagues.py index 44bcb13..4d46c83 100644 --- a/routes/leagues.py +++ b/boker/routes/leagues.py @@ -9,12 +9,12 @@ from datetime import datetime, timezone from flask import Blueprint, abort, flash, make_response, redirect, render_template, request, url_for -from auth import current_user_id, login_required, normalize_email -from charts import cumulative_profit_series, player_session_series -from db import database_extensions_available, db -from models import SessionSummary -from services import apply_rank_changes, build_leaderboard, build_session_summaries, session_events -from utils import cents_to_dollars, session_label, session_sort_key +from ..auth import current_user_id, login_required, normalize_email +from ..charts import cumulative_profit_series, player_session_series +from ..db import database_extensions_available, db +from ..models import SessionSummary +from ..services import apply_rank_changes, build_leaderboard, build_session_summaries, session_events +from ..utils import cents_to_dollars, session_label, session_sort_key leagues_bp = Blueprint("leagues", __name__) @@ -48,7 +48,7 @@ def split_league_ref(league_ref: str) -> tuple[str, str]: def require_league(league_ref: str, allowed_roles: set[str]): - from league_repositories import find_league_by_public_key, user_has_league_role + from ..repositories.leagues import find_league_by_public_key, user_has_league_role _slug, public_key = split_league_ref(league_ref) league = find_league_by_public_key(public_key) @@ -64,7 +64,7 @@ def require_league(league_ref: str, allowed_roles: set[str]): def get_league_with_visibility_gate(league_ref: str): """Load league; if private, enforce login + membership. Returns (league, None) or (None, redirect).""" - from league_repositories import find_league_by_public_key, user_has_league_role + from ..repositories.leagues import find_league_by_public_key, user_has_league_role _slug, public_key = split_league_ref(league_ref) league = find_league_by_public_key(public_key) @@ -99,8 +99,8 @@ def empty_session_summary(session) -> SessionSummary: def session_ref_map(league_id: str) -> dict[str, str]: - from ledger_repositories import session_event_ref - from league_repositories import list_sessions_for_league + from ..repositories.ledger import session_event_ref + from ..repositories.leagues import list_sessions_for_league return {session_event_ref(session): session.id for session in list_sessions_for_league(league_id)} @@ -112,8 +112,8 @@ def index(): flash("League database is not available.", "error") return render_template("leagues_index.html", leagues=[]) - from league_repositories import list_leagues_for_user - from ledger_repositories import list_event_rows_for_league + from ..repositories.leagues import list_leagues_for_user + from ..repositories.ledger import list_event_rows_for_league memberships = list_leagues_for_user(current_user_id() or "") league_summaries = [] @@ -190,8 +190,8 @@ def new(): } if request.method == "POST": - from db_models import User - from league_repositories import create_league, unique_league_slug + from ..db_models import User + from ..repositories.leagues import create_league, unique_league_slug owner = db.session.get(User, current_user_id()) if owner is None: @@ -221,7 +221,7 @@ def dashboard(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import league_counts, user_has_league_role + from ..repositories.leagues import league_counts, user_has_league_role league, resp = get_league_with_visibility_gate(league_ref) if resp: @@ -242,7 +242,7 @@ def legacy_dashboard_redirect(league_id: str, league_slug: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import find_league_by_id, user_has_league_role + from ..repositories.leagues import find_league_by_id, user_has_league_role league = find_league_by_id(league_id) if league is None: @@ -259,9 +259,9 @@ def leaderboard(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from config import ELIGIBLE_MIN_SESSIONS - from ledger_repositories import list_event_rows_for_league - from league_repositories import list_players_for_league, user_has_league_role + from ..config import ELIGIBLE_MIN_SESSIONS + from ..repositories.ledger import list_event_rows_for_league + from ..repositories.leagues import list_players_for_league, user_has_league_role league, resp = get_league_with_visibility_gate(league_ref) if resp: @@ -366,12 +366,12 @@ def ledger(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from ledger_repositories import ( + from ..repositories.ledger import ( append_ledger_event, list_all_event_rows_for_league, list_event_rows_for_league, ) - from league_repositories import ( + from ..repositories.leagues import ( find_player_for_league, find_session_for_league, list_players_for_league, @@ -506,7 +506,7 @@ def void_event(league_ref: str, event_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from ledger_repositories import void_ledger_event + from ..repositories.ledger import void_ledger_event league = require_league(league_ref, {"owner"}) reason = request.form.get("void_reason", "").strip() @@ -529,8 +529,8 @@ def player_detail(league_ref: str, player_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from ledger_repositories import list_event_rows_for_league - from league_repositories import find_player_for_league, user_has_league_role + from ..repositories.ledger import list_event_rows_for_league + from ..repositories.leagues import find_player_for_league, user_has_league_role league, resp = get_league_with_visibility_gate(league_ref) if resp: @@ -588,7 +588,7 @@ def players(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import ( + from ..repositories.leagues import ( create_player, list_players_for_league, player_name_exists, @@ -627,7 +627,7 @@ def players(league_ref: str): flash("Player added.", "success") return redirect(url_for("leagues.players", **league_url_values(league))) - from ledger_repositories import list_event_rows_for_league + from ..repositories.ledger import list_event_rows_for_league all_sessions = build_session_summaries(list_event_rows_for_league(league.id)) board = build_leaderboard(all_sessions) @@ -661,7 +661,7 @@ def update_player_status(league_ref: str, player_id: str, status: str, message: flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import find_player_for_league, set_player_status + from ..repositories.leagues import find_player_for_league, set_player_status league = require_league(league_ref, {"owner", "manager"}) player = find_player_for_league(league.id, player_id) @@ -681,7 +681,7 @@ def edit_player(league_ref: str, player_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import find_player_for_league, player_name_taken, update_player + from ..repositories.leagues import find_player_for_league, player_name_taken, update_player league = require_league(league_ref, {"owner", "manager"}) player = find_player_for_league(league.id, player_id) @@ -709,8 +709,8 @@ def sessions(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from ledger_repositories import append_ledger_event - from league_repositories import ( + from ..repositories.ledger import append_ledger_event + from ..repositories.leagues import ( create_poker_session, list_sessions_for_league, user_has_league_role, @@ -747,7 +747,7 @@ def sessions(league_ref: str): session.label = form["label"] or None session.notes = form["notes"] or None if status == "open": - from league_repositories import set_session_status + from ..repositories.leagues import set_session_status set_session_status(session, "open") @@ -765,8 +765,8 @@ def sessions(league_ref: str): flash(f"Created {session.display_label}.", "success") return redirect(url_for("leagues.sessions", **league_url_values(league))) - from db_models import LedgerEvent - from ledger_repositories import list_event_rows_for_league + from ..db_models import LedgerEvent + from ..repositories.ledger import list_event_rows_for_league all_sessions = list_sessions_for_league(league.id) summaries = build_session_summaries(list_event_rows_for_league(league.id)) @@ -803,8 +803,8 @@ def prune_empty_sessions(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from db_models import LedgerEvent, PokerSession - from league_repositories import list_sessions_for_league + from ..db_models import LedgerEvent, PokerSession + from ..repositories.leagues import list_sessions_for_league league = require_league(league_ref, {"owner", "manager"}) @@ -839,7 +839,7 @@ def delete_session(league_ref: str, session_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import find_session_for_league + from ..repositories.leagues import find_session_for_league league = require_league(league_ref, {"owner"}) session = find_session_for_league(league.id, session_id) @@ -859,7 +859,7 @@ def edit_session(league_ref: str, session_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import find_session_for_league + from ..repositories.leagues import find_session_for_league league = require_league(league_ref, {"owner", "manager"}) session = find_session_for_league(league.id, session_id) @@ -877,7 +877,7 @@ def edit_session(league_ref: str, session_id: str): return redirect(url_for("leagues.session_detail", league_ref=league.url_ref, session_id=session_id)) if new_date != session.session_date: - from db_models import PokerSession as _PS + from ..db_models import PokerSession as _PS max_seq = db.session.query(db.func.max(_PS.sequence_on_date)).filter( _PS.league_id == league.id, _PS.session_date == new_date, @@ -900,8 +900,8 @@ def session_detail(league_ref: str, session_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from ledger_repositories import append_ledger_event, list_all_event_rows_for_session, list_event_rows_for_session - from league_repositories import ( + from ..repositories.ledger import append_ledger_event, list_all_event_rows_for_session, list_event_rows_for_session + from ..repositories.leagues import ( find_session_for_league, list_players_for_league, user_has_league_role, @@ -989,9 +989,9 @@ def session_public_view(league_ref: str, session_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from charts import session_breakdown_series - from ledger_repositories import list_event_rows_for_league, list_event_rows_for_session - from league_repositories import find_league_by_public_key, find_session_for_league, list_sessions_for_league, user_has_league_role + from ..charts import session_breakdown_series + from ..repositories.ledger import list_event_rows_for_league, list_event_rows_for_session + from ..repositories.leagues import find_league_by_public_key, find_session_for_league, list_sessions_for_league, user_has_league_role _slug, public_key = split_league_ref(league_ref) league = find_league_by_public_key(public_key) @@ -1062,8 +1062,8 @@ def update_session_status(league_ref: str, session_id: str, status: str, message flash("League database is not available.", "error") return redirect(url_for("public.home")) - from ledger_repositories import append_ledger_event - from league_repositories import find_session_for_league, set_session_status + from ..repositories.ledger import append_ledger_event + from ..repositories.leagues import find_session_for_league, set_session_status league = require_league(league_ref, {"owner", "manager"}) session = find_session_for_league(league.id, session_id) @@ -1114,7 +1114,7 @@ def league_settings(league_ref: str): elif form["visibility"] not in ("private", "public"): flash("Invalid visibility value.", "error") else: - from utils import slugify + from ..utils import slugify league.name = form["name"] league.slug = slugify(form["name"]) @@ -1124,7 +1124,7 @@ def league_settings(league_ref: str): flash("League settings saved.", "success") return redirect(url_for("leagues.league_settings", league_ref=league.url_ref)) - from league_repositories import list_members_for_league + from ..repositories.leagues import list_members_for_league members = list_members_for_league(league.id) return render_template("league_settings.html", league=league, form=form, is_owner=True, members=members) @@ -1137,10 +1137,11 @@ def invite_member(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from auth import generate_invite_token - from emails import send_league_invite from flask import current_app - from league_repositories import find_membership, find_user_by_email + + from ..auth import generate_invite_token + from ..emails import send_league_invite + from ..repositories.leagues import find_membership, find_user_by_email league = require_league(league_ref, {"owner"}) email = normalize_email(request.form.get("email", "")) @@ -1181,7 +1182,7 @@ def remove_member(league_ref: str, user_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import remove_league_member + from ..repositories.leagues import remove_league_member league = require_league(league_ref, {"owner"}) @@ -1202,7 +1203,7 @@ def archive_league(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from db_models import utc_now + from ..db_models import utc_now league = require_league(league_ref, {"owner"}) confirm_name = request.form.get("confirm_name", "").strip() @@ -1224,8 +1225,8 @@ def export_ledger_csv(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from ledger_repositories import list_event_rows_for_league - from storage import CSV_HEADERS + from ..repositories.ledger import list_event_rows_for_league + from ..storage import CSV_HEADERS league = require_league(league_ref, {"owner", "manager", "viewer"}) rows = list_event_rows_for_league(league.id) @@ -1250,14 +1251,14 @@ def import_ledger_csv(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from db_models import CANONICAL_EVENT_TYPES, canonical_event_type - from ledger_repositories import ( + from ..db_models import CANONICAL_EVENT_TYPES, canonical_event_type + from ..repositories.ledger import ( append_ledger_event, list_ledger_events_for_league, session_event_ref, ) - from league_repositories import list_players_for_league, list_sessions_for_league - from storage import CSV_HEADERS + from ..repositories.leagues import list_players_for_league, list_sessions_for_league + from ..storage import CSV_HEADERS league = require_league(league_ref, {"owner", "manager"}) diff --git a/routes/public.py b/boker/routes/public.py similarity index 93% rename from routes/public.py rename to boker/routes/public.py index c2371be..f88828f 100644 --- a/routes/public.py +++ b/boker/routes/public.py @@ -4,16 +4,16 @@ 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 ( +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 +from ..storage import load_events +from ..utils import session_label, session_sort_key public_bp = Blueprint("public", __name__) @@ -30,9 +30,9 @@ def help(): @public_bp.get("/explore") def explore(): - from auth import is_logged_in - from db import database_extensions_available - from league_repositories import league_counts, list_public_leagues + from ..auth import is_logged_in + from ..db import database_extensions_available + from ..repositories.leagues import league_counts, list_public_leagues q = request.args.get("q", "").strip() leagues = list_public_leagues(q) if database_extensions_available() else [] diff --git a/services.py b/boker/services.py similarity index 98% rename from services.py rename to boker/services.py index c364759..6d6123a 100644 --- a/services.py +++ b/boker/services.py @@ -4,9 +4,9 @@ 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 +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"} diff --git a/static/css/style.css b/boker/static/css/style.css similarity index 100% rename from static/css/style.css rename to boker/static/css/style.css diff --git a/static/css/tokens.css b/boker/static/css/tokens.css similarity index 100% rename from static/css/tokens.css rename to boker/static/css/tokens.css diff --git a/static/favicon.svg b/boker/static/favicon.svg similarity index 100% rename from static/favicon.svg rename to boker/static/favicon.svg diff --git a/storage.py b/boker/storage.py similarity index 100% rename from storage.py rename to boker/storage.py diff --git a/templates/_league_subnav.html b/boker/templates/_league_subnav.html similarity index 100% rename from templates/_league_subnav.html rename to boker/templates/_league_subnav.html diff --git a/templates/account_login.html b/boker/templates/account_login.html similarity index 100% rename from templates/account_login.html rename to boker/templates/account_login.html diff --git a/templates/account_register.html b/boker/templates/account_register.html similarity index 100% rename from templates/account_register.html rename to boker/templates/account_register.html diff --git a/templates/account_settings.html b/boker/templates/account_settings.html similarity index 100% rename from templates/account_settings.html rename to boker/templates/account_settings.html diff --git a/templates/admin_dashboard.html b/boker/templates/admin_dashboard.html similarity index 100% rename from templates/admin_dashboard.html rename to boker/templates/admin_dashboard.html diff --git a/templates/admin_login.html b/boker/templates/admin_login.html similarity index 100% rename from templates/admin_login.html rename to boker/templates/admin_login.html diff --git a/templates/base.html b/boker/templates/base.html similarity index 100% rename from templates/base.html rename to boker/templates/base.html diff --git a/templates/docs.html b/boker/templates/docs.html similarity index 100% rename from templates/docs.html rename to boker/templates/docs.html diff --git a/templates/explore.html b/boker/templates/explore.html similarity index 100% rename from templates/explore.html rename to boker/templates/explore.html diff --git a/templates/forgot_password.html b/boker/templates/forgot_password.html similarity index 100% rename from templates/forgot_password.html rename to boker/templates/forgot_password.html diff --git a/templates/landing.html b/boker/templates/landing.html similarity index 100% rename from templates/landing.html rename to boker/templates/landing.html diff --git a/templates/leaderboard.html b/boker/templates/leaderboard.html similarity index 100% rename from templates/leaderboard.html rename to boker/templates/leaderboard.html diff --git a/templates/league_dashboard.html b/boker/templates/league_dashboard.html similarity index 100% rename from templates/league_dashboard.html rename to boker/templates/league_dashboard.html diff --git a/templates/league_leaderboard.html b/boker/templates/league_leaderboard.html similarity index 100% rename from templates/league_leaderboard.html rename to boker/templates/league_leaderboard.html diff --git a/templates/league_ledger.html b/boker/templates/league_ledger.html similarity index 100% rename from templates/league_ledger.html rename to boker/templates/league_ledger.html diff --git a/templates/league_new.html b/boker/templates/league_new.html similarity index 100% rename from templates/league_new.html rename to boker/templates/league_new.html diff --git a/templates/league_player_detail.html b/boker/templates/league_player_detail.html similarity index 100% rename from templates/league_player_detail.html rename to boker/templates/league_player_detail.html diff --git a/templates/league_players.html b/boker/templates/league_players.html similarity index 100% rename from templates/league_players.html rename to boker/templates/league_players.html diff --git a/templates/league_session_detail.html b/boker/templates/league_session_detail.html similarity index 100% rename from templates/league_session_detail.html rename to boker/templates/league_session_detail.html diff --git a/templates/league_session_view.html b/boker/templates/league_session_view.html similarity index 100% rename from templates/league_session_view.html rename to boker/templates/league_session_view.html diff --git a/templates/league_sessions.html b/boker/templates/league_sessions.html similarity index 100% rename from templates/league_sessions.html rename to boker/templates/league_sessions.html diff --git a/templates/league_settings.html b/boker/templates/league_settings.html similarity index 100% rename from templates/league_settings.html rename to boker/templates/league_settings.html diff --git a/templates/leagues_index.html b/boker/templates/leagues_index.html similarity index 100% rename from templates/leagues_index.html rename to boker/templates/leagues_index.html diff --git a/templates/player_detail.html b/boker/templates/player_detail.html similarity index 100% rename from templates/player_detail.html rename to boker/templates/player_detail.html diff --git a/templates/reset_password.html b/boker/templates/reset_password.html similarity index 100% rename from templates/reset_password.html rename to boker/templates/reset_password.html diff --git a/templates/session_detail.html b/boker/templates/session_detail.html similarity index 100% rename from templates/session_detail.html rename to boker/templates/session_detail.html diff --git a/templates/sessions.html b/boker/templates/sessions.html similarity index 100% rename from templates/sessions.html rename to boker/templates/sessions.html diff --git a/utils.py b/boker/utils.py similarity index 98% rename from utils.py rename to boker/utils.py index 2fa8e79..95d83dd 100644 --- a/utils.py +++ b/boker/utils.py @@ -7,7 +7,7 @@ import re from markupsafe import Markup -from models import SessionEntry, SessionSummary +from .models import SessionEntry, SessionSummary BREAK_EVEN_TOLERANCE_CENTS = 100 diff --git a/tests/test_accounting.py b/tests/test_accounting.py index f4e2c8c..799e3e8 100644 --- a/tests/test_accounting.py +++ b/tests/test_accounting.py @@ -2,15 +2,17 @@ from pathlib import Path import tempfile import unittest -import app as app_module -from charts import player_session_series -from services import ( +from boker import create_app +from boker.charts import player_session_series +from boker.services import ( build_leaderboard, build_session_summaries, pending_payout_carry_items, ) -from storage import load_events, write_events -from utils import session_sort_key +from boker.storage import load_events, write_events +from boker.utils import session_sort_key + +_app = create_app() def cents(amount: float) -> int: @@ -203,8 +205,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.app.config["DATA_PATH"] - app_module.app.config["DATA_PATH"] = data_path + original_path = _app.config["DATA_PATH"] + _app.config["DATA_PATH"] = data_path try: sessions = build_session_summaries(load_events(data_path)) @@ -213,7 +215,7 @@ class AccountingTests(unittest.TestCase): [{"player_name": "A", "amount_cents": cents(5)}], ) - with app_module.app.test_client() as client: + with _app.test_client() as client: with client.session_transaction() as flask_session: flask_session["is_admin"] = True @@ -222,7 +224,7 @@ class AccountingTests(unittest.TestCase): data={"session_id": "s2", "player_name": "A"}, ) finally: - app_module.app.config["DATA_PATH"] = original_path + _app.config["DATA_PATH"] = original_path self.assertEqual(response.status_code, 302) updated_sessions = build_session_summaries(load_events(data_path)) @@ -261,17 +263,17 @@ class AccountingTests(unittest.TestCase): with tempfile.TemporaryDirectory() as tmpdir: data_path = Path(tmpdir) / "entries.csv" write_events(data_path, rows) - original_path = app_module.app.config["DATA_PATH"] - app_module.app.config["DATA_PATH"] = data_path + original_path = _app.config["DATA_PATH"] + _app.config["DATA_PATH"] = data_path try: - with app_module.app.test_client() as client: + with _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.app.config["DATA_PATH"] = original_path + _app.config["DATA_PATH"] = original_path self.assertEqual(response.status_code, 302) remaining = load_events(data_path) diff --git a/tests/test_stats.py b/tests/test_stats.py index 36ceaf3..5f3b1cd 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -1,6 +1,7 @@ import unittest -from stats import SessionEntry, SessionSummary, build_leaderboard +from boker.models import SessionEntry, SessionSummary +from boker.services import build_leaderboard class CashInAccountingTest(unittest.TestCase): @@ -78,7 +79,7 @@ class CashInAccountingTest(unittest.TestCase): self.assertEqual(player.total_buy_in_cents, 2500) self.assertEqual(player.total_front_collected_cents, 0) - self.assertEqual(player.total_cash_in_cents, 3000) + self.assertEqual(player.total_real_cash_in_cents, 2500) if __name__ == "__main__": From f07803478974b214d968eecaef4c0bfa5c807d10 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Wed, 24 Jun 2026 19:59:12 -0700 Subject: [PATCH 43/83] update readme --- README.md | 270 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 163 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index 08b2b71..862152d 100644 --- a/README.md +++ b/README.md @@ -1,132 +1,188 @@ -# Poker Portal +# Boker -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. +A Flask web app for tracking low-stakes poker nights. Multiple leagues, per-player stats, session history, leaderboards, and a full double-entry ledger — without keeping a spreadsheet open. -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. +## Features -## What it does - -- all-time leaderboard -- per-session pages -- per-player stat pages -- admin login for recording events -- open / closed session tracking -- 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 +- User accounts with registration and login +- Create and manage multiple leagues +- Invite league members by email with role-based access (owner / manager / viewer) +- Public or private league visibility +- Per-session event ledger (buy-ins, cashouts, fronts, rollovers, payouts) +- All-time leaderboard with rank tracking and eligibility thresholds +- Per-player stat pages with session history and charts +- Open / closed session tracking +- Debt tracking: fronts, repayments, and write-offs +- CSV export of any league ledger +- CSV import for migrating historical data +- Rate limiting and CSRF protection ## Stack -Built with a pretty lightweight setup: - -- Python -- Flask -- Jinja templates +- Python 3 / Flask +- SQLAlchemy + Flask-Migrate (SQLite for local dev, PostgreSQL in production) +- Jinja2 templates - Chart.js -- plain CSS -- CSV event log for storage +- Plain CSS + +## Setup + +### 1. Clone and create a virtual environment + +```bash +git clone https://github.com/SowinskiBraeden/boker +cd boker +python -m venv .venv +source .venv/bin/activate +``` + +### 2. Install dependencies + +```bash +pip install -r requirements.txt +``` + +### 3. Configure environment + +```bash +cp .env.example .env +``` + +Open `.env` and set at minimum: + +```env +SECRET_KEY=replace-this-with-a-long-random-string +``` + +The other values can be left as defaults for local development. See the [Environment variables](#environment-variables) section for the full list. + +### 4. Initialize the database + +```bash +flask --app app db upgrade +``` + +This creates `data/boker-dev.sqlite3` and applies all migrations. Run this again whenever you pull new migrations. + +### 5. Run the development server + +```bash +flask --app app run +``` + +Then open `http://127.0.0.1:5000` and register an account. + +## Environment variables + +All variables are read from `.env` at startup. Copy `.env.example` as a starting point. + +| Variable | Required | Default | Description | +|---|---|---|---| +| `SECRET_KEY` | Yes | `change-this-before-deploying` | Flask session signing key. Use a long random string in production. | +| `DATABASE_URL` | No | `sqlite:///data/boker-dev.sqlite3` | SQLAlchemy connection URL. For production use a `postgresql+psycopg://` URL. | +| `FLASK_ENV` | No | _(unset)_ | Set to `production` to enable secure cookie flags. | +| `APP_BASE_URL` | No | `http://localhost:5000` | Base URL used when generating links in email (invite, password reset). | +| `MAIL_SERVER` | No | _(empty)_ | SMTP server hostname. Email features are disabled if left blank. | +| `MAIL_PORT` | No | `587` | SMTP port. | +| `MAIL_USE_TLS` | No | `true` | Set to `false` to disable STARTTLS. | +| `MAIL_USERNAME` | No | _(empty)_ | SMTP username / API key. | +| `MAIL_PASSWORD` | No | _(empty)_ | SMTP password / API key secret. | +| `MAIL_DEFAULT_SENDER` | No | `noreply@myboker.org` | From address on outgoing mail. | +| `FLASK_DEBUG` | No | `0` | Set to `1` to enable the Flask reloader and debugger. | + +## Running tests + +```bash +python -m pytest tests/ +``` + +Or with the standard library runner: + +```bash +python -m unittest discover tests/ +``` + +## Production deployment + +### Database + +Set `DATABASE_URL` to a PostgreSQL connection string: + +```env +DATABASE_URL=postgresql+psycopg://user:password@host/dbname +``` + +`psycopg` (v3) is already in `requirements.txt`. + +After deploying, run migrations: + +```bash +flask --app app db upgrade +``` + +### Environment + +Set `FLASK_ENV=production` to enable: +- `Secure` flag on the session cookie +- `SameSite=Strict` cookie policy +- HTTPS preferred URL scheme + +Generate a strong secret key: + +```bash +python -c "import secrets; print(secrets.token_hex(32))" +``` + +### WSGI + +Run with a production WSGI server (gunicorn, uWSGI, etc.) rather than the Flask dev server: + +```bash +gunicorn "app:app" +``` ## Ledger model -The app treats `data/entries.csv` as the source of truth. +Each event in the ledger is an append-only row. Nothing is edited or deleted — corrections are new rows. This keeps the full history readable. -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. +### Event types -Current event types: +| Event | Description | +|---|---| +| `buyin` | Player buys chips. Counts as poker investment and real cash in. | +| `front` | House fronts chips to a player. Counts as poker investment; cash is owed back. | +| `cashout` | Gross chip result at end of session. Not cash movement — sets the payout claim. | +| `paid_out` | Cash physically paid out to a player. Settles the payout claim. | +| `rollover_out` | Player carries winnings into the next session instead of being paid. Settles source session without cash movement. | +| `rollover_in` | Carried funds enter play in the destination session. Counts as investment without new cash in. | +| `payout_carry_in` | Prior-session credit applied to a later payout. Increases the payout due without counting as investment. | +| `debt_repayment` | Player repays a front outside of poker. Counts as real cash in; reduces the receivable. | +| `writeoff` | Front is forgiven. Resolves the receivable without cash. | +| `note` | Free-text bookkeeping note. No financial effect. | +| `session_open` | Marks a session as live. | +| `session_close` | Marks a session as closed. | -- `buyin` -- `front` -- `debt_repayment` -- `writeoff` -- `cashout` -- `paid_out` -- `rollover_in` -- `payout_carry_in` -- `rollover_out` -- `note` -- `session_open` -- `session_close` +Legacy ledgers may contain `paid`, `front_collected`, or `front_writeoff` — the app reads these as aliases for `paid_out`, `debt_repayment`, and `writeoff`. -A few examples: +### Accounting summary -- another `buyin` for a rebuy -- another `cashout` if chip counts are corrected later -- 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 - -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 + 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 -- `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. +- **Poker investment** = `buyin + front + rollover_in` +- **Poker net** = `cashout − investment` +- **Real cash in** = `buyin + debt_repayment` +- **Real cash out** = `paid_out` +- **Rollover-out** settles a session without cash leaving the book +- **Writeoff** resolves a receivable without cash coming in ## CSV format -Main file: - -`data/entries.csv` +Each league's ledger can be exported and re-imported as CSV. Header: -```csv +``` id,created_at,session_id,session_date,player_name,event_type,amount_cents,note,actor ``` -Amounts are stored in cents to avoid floating-point issues. +Amounts are stored in cents (integer) to avoid floating-point rounding. -## Running it locally - -```bash -python -m venv .venv -source .venv/bin/activate -pip install -r requirements.txt -cp .env.example .env -python app.py -``` - -Then open: - -`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. +Import validates headers, event types, session references, and player names before writing anything. Rows with a matching `id` are skipped so re-importing a previous export is safe. From 5c7a498af65fc13fd44fd9985a575b65573c9879 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Wed, 24 Jun 2026 23:15:13 -0700 Subject: [PATCH 44/83] admin improvements --- boker/__init__.py | 2 + boker/auth.py | 25 + boker/config.py | 2 +- boker/db_models.py | 1 + boker/repositories/leagues.py | 20 + boker/routes/leagues.py | 34 +- boker/routes/site_admin.py | 614 +++++++++++++++++++++++ boker/static/css/admin.css | 562 +++++++++++++++++++++ boker/static/css/style.css | 7 + boker/static/css/tokens.css | 6 + boker/templates/admin/base.html | 95 ++++ boker/templates/admin/dashboard.html | 269 ++++++++++ boker/templates/admin/league_detail.html | 209 ++++++++ boker/templates/admin/leagues.html | 90 ++++ boker/templates/admin/user_detail.html | 168 +++++++ boker/templates/admin/users.html | 68 +++ boker/templates/league_settings.html | 61 ++- migrations/versions/0003_site_admin.py | 32 ++ 18 files changed, 2261 insertions(+), 4 deletions(-) create mode 100644 boker/routes/site_admin.py create mode 100644 boker/static/css/admin.css create mode 100644 boker/templates/admin/base.html create mode 100644 boker/templates/admin/dashboard.html create mode 100644 boker/templates/admin/league_detail.html create mode 100644 boker/templates/admin/leagues.html create mode 100644 boker/templates/admin/user_detail.html create mode 100644 boker/templates/admin/users.html create mode 100644 migrations/versions/0003_site_admin.py diff --git a/boker/__init__.py b/boker/__init__.py index a1cc031..b68942d 100644 --- a/boker/__init__.py +++ b/boker/__init__.py @@ -13,6 +13,7 @@ from .extensions import csrf, limiter, mail from .routes.account import account_bp from .routes.leagues import leagues_bp from .routes.public import public_bp +from .routes.site_admin import site_admin_bp from .storage import ensure_data_file from .utils import cents_to_dollars, safe_date_label @@ -44,6 +45,7 @@ def create_app(config_overrides: dict | None = None) -> Flask: app.register_blueprint(public_bp) app.register_blueprint(account_bp) app.register_blueprint(leagues_bp) + app.register_blueprint(site_admin_bp) @app.cli.command("init-db") def init_db_command() -> None: diff --git a/boker/auth.py b/boker/auth.py index c33f1f1..14c9794 100644 --- a/boker/auth.py +++ b/boker/auth.py @@ -77,3 +77,28 @@ def login_required(view): return view(*args, **kwargs) return wrapped_view + + +def current_user_is_site_admin() -> bool: + user_id = current_user_id() + if not user_id: + return False + from .db import db + from .db_models import User + if db is None: + return False + user = db.session.get(User, user_id) + return bool(user and user.is_site_admin and user.disabled_at is None) + + +def site_admin_required(view): + @wraps(view) + def wrapped_view(*args, **kwargs): + if not is_logged_in(): + return redirect(url_for("account.login", next=request.full_path)) + if not current_user_is_site_admin(): + from flask import abort + abort(403) + return view(*args, **kwargs) + + return wrapped_view diff --git a/boker/config.py b/boker/config.py index 0730954..a5f5c85 100644 --- a/boker/config.py +++ b/boker/config.py @@ -9,7 +9,7 @@ DATA_PATH = BASE_DIR / "data" / "entries.csv" DEFAULT_DATABASE_URL = f"sqlite:///{BASE_DIR / 'data' / 'boker-dev.sqlite3'}" ELIGIBLE_MIN_SESSIONS = 3 -APP_VERSION = "2.3.1" +APP_VERSION = "2.4.3" def load_local_env(env_path: Path) -> None: diff --git a/boker/db_models.py b/boker/db_models.py index 229ebe9..98e4fc6 100644 --- a/boker/db_models.py +++ b/boker/db_models.py @@ -75,6 +75,7 @@ class User(TimestampMixin, db.Model): email_verified_at = db.Column(db.DateTime(timezone=True), nullable=True) last_login_at = db.Column(db.DateTime(timezone=True), nullable=True) disabled_at = db.Column(db.DateTime(timezone=True), nullable=True) + is_site_admin = db.Column(db.Boolean, nullable=False, default=False) class League(TimestampMixin, db.Model): diff --git a/boker/repositories/leagues.py b/boker/repositories/leagues.py index b23dbc3..176c9c4 100644 --- a/boker/repositories/leagues.py +++ b/boker/repositories/leagues.py @@ -322,3 +322,23 @@ def remove_league_member(league_id: str, user_id: str) -> None: ).one_or_none() if membership and membership.role != "owner": membership.disabled_at = utc_now() + + +def transfer_league_ownership(league_id: str, new_owner_user_id: str) -> None: + current_owner = LeagueMembership.query.filter_by( + league_id=league_id, + role="owner", + disabled_at=None, + ).one_or_none() + new_owner_membership = LeagueMembership.query.filter_by( + league_id=league_id, + user_id=new_owner_user_id, + disabled_at=None, + ).one_or_none() + if current_owner is None or new_owner_membership is None: + raise ValueError("Invalid transfer: owner or target not found.") + league = db.session.get(League, league_id) + if league: + league.created_by_user_id = new_owner_user_id + current_owner.role = "manager" + new_owner_membership.role = "owner" diff --git a/boker/routes/leagues.py b/boker/routes/leagues.py index 4d46c83..d868fb8 100644 --- a/boker/routes/leagues.py +++ b/boker/routes/leagues.py @@ -1127,7 +1127,8 @@ def league_settings(league_ref: str): from ..repositories.leagues import list_members_for_league members = list_members_for_league(league.id) - return render_template("league_settings.html", league=league, form=form, is_owner=True, members=members) + has_managers = any(m.role == "manager" for m, u in members) + return render_template("league_settings.html", league=league, form=form, is_owner=True, members=members, has_managers=has_managers) @leagues_bp.post("/l//settings/invite") @@ -1196,6 +1197,37 @@ def remove_member(league_ref: str, user_id: str): return redirect(url_for("leagues.league_settings", league_ref=league.url_ref)) +@leagues_bp.post("/l//settings/transfer") +@login_required +def transfer_ownership(league_ref: str): + if not db_ready(): + flash("League database is not available.", "error") + return redirect(url_for("public.home")) + + from ..repositories.leagues import find_membership, transfer_league_ownership + + league = require_league(league_ref, {"owner"}) + new_owner_id = request.form.get("new_owner_user_id", "").strip() + + if not new_owner_id: + flash("Select a manager to transfer ownership to.", "error") + return redirect(url_for("leagues.league_settings", league_ref=league.url_ref)) + + membership = find_membership(league.id, new_owner_id) + if membership is None or membership.role != "manager": + flash("Target user must be a manager of this league.", "error") + return redirect(url_for("leagues.league_settings", league_ref=league.url_ref)) + + try: + transfer_league_ownership(league.id, new_owner_id) + db.session.commit() + flash("Ownership transferred. You are now a manager of this league.", "success") + except ValueError as exc: + flash(str(exc), "error") + + return redirect(url_for("leagues.dashboard", **league_url_values(league))) + + @leagues_bp.post("/l//archive") @login_required def archive_league(league_ref: str): diff --git a/boker/routes/site_admin.py b/boker/routes/site_admin.py new file mode 100644 index 0000000..1e9f4e7 --- /dev/null +++ b/boker/routes/site_admin.py @@ -0,0 +1,614 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone + +from flask import Blueprint, flash, redirect, render_template, request, url_for + +from ..auth import ( + current_user_id, + generate_reset_token, + hash_password, + normalize_email, + site_admin_required, +) +from ..db import db + +site_admin_bp = Blueprint("site_admin", __name__, url_prefix="/admin") + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +@site_admin_bp.get("/") +def index(): + return redirect(url_for("site_admin.dashboard")) + + +@site_admin_bp.get("/dashboard") +@site_admin_required +def dashboard(): + from ..db_models import League, LedgerEvent, LeagueMembership, PokerSession, User + + now = utc_now() + day_ago = now - timedelta(days=1) + week_ago = now - timedelta(days=7) + month_ago = now - timedelta(days=30) + + total_users = db.session.query(db.func.count(User.id)).scalar() or 0 + active_users = db.session.query(db.func.count(User.id)).filter(User.disabled_at.is_(None)).scalar() or 0 + disabled_users = total_users - active_users + new_users_7d = db.session.query(db.func.count(User.id)).filter(User.created_at >= week_ago).scalar() or 0 + new_users_30d = db.session.query(db.func.count(User.id)).filter(User.created_at >= month_ago).scalar() or 0 + admin_count = db.session.query(db.func.count(User.id)).filter(User.is_site_admin.is_(True)).scalar() or 0 + + dau = db.session.query(db.func.count(User.id)).filter(User.last_login_at >= day_ago).scalar() or 0 + wau = db.session.query(db.func.count(User.id)).filter(User.last_login_at >= week_ago).scalar() or 0 + mau = db.session.query(db.func.count(User.id)).filter(User.last_login_at >= month_ago).scalar() or 0 + + total_leagues = db.session.query(db.func.count(League.id)).scalar() or 0 + active_leagues = db.session.query(db.func.count(League.id)).filter(League.archived_at.is_(None)).scalar() or 0 + public_leagues = db.session.query(db.func.count(League.id)).filter( + League.visibility == "public", League.archived_at.is_(None) + ).scalar() or 0 + new_leagues_30d = db.session.query(db.func.count(League.id)).filter(League.created_at >= month_ago).scalar() or 0 + + total_sessions = db.session.query(db.func.count(PokerSession.id)).scalar() or 0 + open_sessions = db.session.query(db.func.count(PokerSession.id)).filter(PokerSession.status == "open").scalar() or 0 + + total_events = db.session.query(db.func.count(LedgerEvent.id)).filter(LedgerEvent.voided_at.is_(None)).scalar() or 0 + + league_session_rows = db.session.query( + PokerSession.league_id, db.func.count(PokerSession.id) + ).group_by(PokerSession.league_id).all() + avg_sessions_per_league = round( + sum(r[1] for r in league_session_rows) / len(league_session_rows), 1 + ) if league_session_rows else 0 + + league_buyin_rows = db.session.query( + LedgerEvent.league_id, db.func.sum(LedgerEvent.amount_cents) + ).filter( + LedgerEvent.event_type == "buyin", + LedgerEvent.voided_at.is_(None), + ).group_by(LedgerEvent.league_id).all() + avg_buyin_cents = int( + sum(r[1] for r in league_buyin_rows) / len(league_buyin_rows) + ) if league_buyin_rows else 0 + + today = now.date() + chart_days = [today - timedelta(days=i) for i in range(29, -1, -1)] + chart_labels = [d.strftime("%b %d") for d in chart_days] + chart_iso = [d.isoformat() for d in chart_days] + + signup_raw = db.session.query( + db.func.date(User.created_at), + db.func.count(User.id), + ).filter(User.created_at >= month_ago).group_by(db.func.date(User.created_at)).all() + signup_by_day = { + (r[0].isoformat() if hasattr(r[0], "isoformat") else r[0]): r[1] + for r in signup_raw + } + chart_signups = [signup_by_day.get(d, 0) for d in chart_iso] + + login_raw = db.session.query( + db.func.date(User.last_login_at), + db.func.count(User.id), + ).filter(User.last_login_at >= month_ago).group_by(db.func.date(User.last_login_at)).all() + login_by_day = { + (r[0].isoformat() if hasattr(r[0], "isoformat") else r[0]): r[1] + for r in login_raw + } + chart_logins = [login_by_day.get(d, 0) for d in chart_iso] + + session_raw = db.session.query( + PokerSession.session_date, + db.func.count(PokerSession.id), + ).filter(PokerSession.session_date >= chart_days[0]).group_by(PokerSession.session_date).all() + session_by_day = { + (r[0].isoformat() if hasattr(r[0], "isoformat") else r[0]): r[1] + for r in session_raw + } + chart_sessions = [session_by_day.get(d, 0) for d in chart_iso] + + chart_data = json.dumps({ + "labels": chart_labels, + "signups": chart_signups, + "logins": chart_logins, + "sessions": chart_sessions, + }) + + recent_users = ( + db.session.query(User) + .order_by(User.created_at.desc()) + .limit(10) + .all() + ) + recent_leagues = ( + db.session.query(League) + .order_by(League.created_at.desc()) + .limit(10) + .all() + ) + league_owner_ids = {league.created_by_user_id for league in recent_leagues} + owners_by_id = { + u.id: u + for u in db.session.query(User).filter(User.id.in_(league_owner_ids)).all() + } if league_owner_ids else {} + + stats = { + "total_users": total_users, + "active_users": active_users, + "disabled_users": disabled_users, + "new_users_7d": new_users_7d, + "new_users_30d": new_users_30d, + "admin_count": admin_count, + "dau": dau, + "wau": wau, + "mau": mau, + "total_leagues": total_leagues, + "active_leagues": active_leagues, + "public_leagues": public_leagues, + "new_leagues_30d": new_leagues_30d, + "total_sessions": total_sessions, + "open_sessions": open_sessions, + "total_events": total_events, + "avg_sessions_per_league": avg_sessions_per_league, + "avg_buyin_cents": avg_buyin_cents, + } + + return render_template( + "admin/dashboard.html", + stats=stats, + recent_users=recent_users, + recent_leagues=recent_leagues, + owners_by_id=owners_by_id, + chart_data=chart_data, + ) + + +@site_admin_bp.get("/users") +@site_admin_required +def users(): + from ..db_models import League, LeagueMembership, User + + search = request.args.get("q", "").strip() + page = max(1, int(request.args.get("page", 1))) + per_page = 50 + + query = db.session.query(User) + if search: + query = query.filter(User.email.ilike(f"%{search}%")) + query = query.order_by(User.created_at.desc()) + + total = query.count() + user_list = query.offset((page - 1) * per_page).limit(per_page).all() + + user_ids = [u.id for u in user_list] + league_counts = {} + if user_ids: + rows = ( + db.session.query( + LeagueMembership.user_id, + db.func.count(LeagueMembership.id), + ) + .filter(LeagueMembership.user_id.in_(user_ids)) + .group_by(LeagueMembership.user_id) + .all() + ) + league_counts = {row[0]: row[1] for row in rows} + + total_pages = max(1, (total + per_page - 1) // per_page) + + return render_template( + "admin/users.html", + users=user_list, + league_counts=league_counts, + search=search, + page=page, + total=total, + total_pages=total_pages, + ) + + +@site_admin_bp.get("/users/") +@site_admin_required +def user_detail(user_id: str): + from flask import current_app + + from ..db_models import League, LeagueMembership, User + + user = db.session.get(User, user_id) + if user is None: + flash("User not found.", "error") + return redirect(url_for("site_admin.users")) + + memberships = ( + db.session.query(LeagueMembership, League) + .join(League, League.id == LeagueMembership.league_id) + .filter(LeagueMembership.user_id == user_id) + .order_by(LeagueMembership.created_at.desc()) + .all() + ) + + reset_url = None + if request.args.get("show_reset") == "1": + token = generate_reset_token(user.id) + base_url = current_app.config.get("APP_BASE_URL", "").rstrip("/") + reset_url = f"{base_url}{url_for('account.reset_password', token=token)}" + + return render_template( + "admin/user_detail.html", + user=user, + memberships=memberships, + reset_url=reset_url, + viewing_self=user_id == current_user_id(), + ) + + +@site_admin_bp.post("/users//send-reset") +@site_admin_required +def send_reset(user_id: str): + from flask import current_app + + from ..db_models import User + from ..emails import send_password_reset + + user = db.session.get(User, user_id) + if user is None: + flash("User not found.", "error") + return redirect(url_for("site_admin.users")) + + if user.disabled_at is not None: + flash("Cannot send a reset link to a disabled account.", "error") + return redirect(url_for("site_admin.user_detail", user_id=user_id)) + + token = generate_reset_token(user.id) + base_url = current_app.config.get("APP_BASE_URL", "").rstrip("/") + reset_url = f"{base_url}{url_for('account.reset_password', token=token)}" + + email_sent = False + try: + send_password_reset(user.email, reset_url) + email_sent = True + except Exception: + pass + + if email_sent: + flash(f"Password reset email sent to {user.email}.", "success") + else: + flash("Email not sent (mail not configured). Copy the link below.", "error") + + return redirect(url_for("site_admin.user_detail", user_id=user_id, show_reset="1")) + + +@site_admin_bp.post("/users//disable") +@site_admin_required +def disable_user(user_id: str): + from ..db_models import User + + if user_id == current_user_id(): + flash("You cannot disable your own account.", "error") + return redirect(url_for("site_admin.user_detail", user_id=user_id)) + + user = db.session.get(User, user_id) + if user is None: + flash("User not found.", "error") + return redirect(url_for("site_admin.users")) + + if user.disabled_at is None: + user.disabled_at = utc_now() + db.session.commit() + flash(f"{user.email} has been disabled.", "success") + else: + flash("Account is already disabled.", "error") + + return redirect(url_for("site_admin.user_detail", user_id=user_id)) + + +@site_admin_bp.post("/users//enable") +@site_admin_required +def enable_user(user_id: str): + from ..db_models import User + + user = db.session.get(User, user_id) + if user is None: + flash("User not found.", "error") + return redirect(url_for("site_admin.users")) + + if user.disabled_at is not None: + user.disabled_at = None + db.session.commit() + flash(f"{user.email} has been re-enabled.", "success") + else: + flash("Account is not disabled.", "error") + + return redirect(url_for("site_admin.user_detail", user_id=user_id)) + + +@site_admin_bp.post("/users//grant-admin") +@site_admin_required +def grant_admin(user_id: str): + from ..db_models import User + + user = db.session.get(User, user_id) + if user is None: + flash("User not found.", "error") + return redirect(url_for("site_admin.users")) + + user.is_site_admin = True + db.session.commit() + flash(f"{user.email} is now a site admin.", "success") + return redirect(url_for("site_admin.user_detail", user_id=user_id)) + + +@site_admin_bp.post("/users//revoke-admin") +@site_admin_required +def revoke_admin(user_id: str): + from ..db_models import User + + if user_id == current_user_id(): + flash("You cannot revoke your own admin access.", "error") + return redirect(url_for("site_admin.user_detail", user_id=user_id)) + + user = db.session.get(User, user_id) + if user is None: + flash("User not found.", "error") + return redirect(url_for("site_admin.users")) + + user.is_site_admin = False + db.session.commit() + flash(f"Admin access removed from {user.email}.", "success") + return redirect(url_for("site_admin.user_detail", user_id=user_id)) + + +@site_admin_bp.post("/users//reset-password") +@site_admin_required +def admin_reset_password(user_id: str): + from ..db_models import User + + user = db.session.get(User, user_id) + if user is None: + flash("User not found.", "error") + return redirect(url_for("site_admin.users")) + + new_password = request.form.get("new_password", "") + if len(new_password) < 8: + flash("Password must be at least 8 characters.", "error") + return redirect(url_for("site_admin.user_detail", user_id=user_id)) + + user.password_hash = hash_password(new_password) + db.session.commit() + flash(f"Password updated for {user.email}.", "success") + return redirect(url_for("site_admin.user_detail", user_id=user_id)) + + +@site_admin_bp.get("/leagues") +@site_admin_required +def leagues(): + from ..db_models import League, LeagueMembership, PokerSession, User + + search = request.args.get("q", "").strip() + visibility = request.args.get("visibility", "").strip() + show_archived = request.args.get("archived", "0") == "1" + page = max(1, int(request.args.get("page", 1))) + per_page = 50 + + query = ( + db.session.query(League) + .outerjoin(User, User.id == League.created_by_user_id) + ) + if search: + query = query.filter( + db.or_( + League.name.ilike(f"%{search}%"), + User.email.ilike(f"%{search}%"), + ) + ) + if visibility in ("public", "private"): + query = query.filter(League.visibility == visibility) + if not show_archived: + query = query.filter(League.archived_at.is_(None)) + query = query.order_by(League.created_at.desc()) + + total = query.count() + league_list = query.offset((page - 1) * per_page).limit(per_page).all() + + league_ids = [lg.id for lg in league_list] + owner_ids = [lg.created_by_user_id for lg in league_list] + + owners_by_id = {} + if owner_ids: + owners_by_id = { + u.id: u + for u in db.session.query(User).filter(User.id.in_(owner_ids)).all() + } + + member_counts = {} + session_counts = {} + if league_ids: + for row in ( + db.session.query(LeagueMembership.league_id, db.func.count(LeagueMembership.id)) + .filter(LeagueMembership.league_id.in_(league_ids)) + .group_by(LeagueMembership.league_id) + .all() + ): + member_counts[row[0]] = row[1] + + for row in ( + db.session.query(PokerSession.league_id, db.func.count(PokerSession.id)) + .filter(PokerSession.league_id.in_(league_ids)) + .group_by(PokerSession.league_id) + .all() + ): + session_counts[row[0]] = row[1] + + total_pages = max(1, (total + per_page - 1) // per_page) + + return render_template( + "admin/leagues.html", + leagues=league_list, + owners_by_id=owners_by_id, + member_counts=member_counts, + session_counts=session_counts, + search=search, + visibility=visibility, + show_archived=show_archived, + page=page, + total=total, + total_pages=total_pages, + ) + + +@site_admin_bp.get("/leagues/") +@site_admin_required +def league_detail(league_id: str): + from ..db_models import League, LedgerEvent, LeagueMembership, Player, PokerSession, User + + league = db.session.get(League, league_id) + if league is None: + flash("League not found.", "error") + return redirect(url_for("site_admin.leagues")) + + memberships = ( + db.session.query(LeagueMembership, User) + .join(User, User.id == LeagueMembership.user_id) + .filter(LeagueMembership.league_id == league_id) + .order_by(LeagueMembership.created_at.asc()) + .all() + ) + + owner = db.session.get(User, league.created_by_user_id) + + session_count = db.session.query(db.func.count(PokerSession.id)).filter( + PokerSession.league_id == league_id + ).scalar() or 0 + + open_sessions = db.session.query(db.func.count(PokerSession.id)).filter( + PokerSession.league_id == league_id, + PokerSession.status == "open", + ).scalar() or 0 + + event_count = db.session.query(db.func.count(LedgerEvent.id)).filter( + LedgerEvent.league_id == league_id, + LedgerEvent.voided_at.is_(None), + ).scalar() or 0 + + player_count = db.session.query(db.func.count(Player.id)).filter( + Player.league_id == league_id + ).scalar() or 0 + + return render_template( + "admin/league_detail.html", + league=league, + memberships=memberships, + owner=owner, + session_count=session_count, + open_sessions=open_sessions, + event_count=event_count, + player_count=player_count, + ) + + +@site_admin_bp.post("/leagues//invite") +@site_admin_required +def admin_invite_member(league_id: str): + from flask import current_app + + from ..auth import generate_invite_token + from ..db_models import League + from ..emails import send_league_invite + from ..repositories.leagues import find_membership, find_user_by_email + + league = db.session.get(League, league_id) + if league is None: + flash("League not found.", "error") + return redirect(url_for("site_admin.leagues")) + + email = normalize_email(request.form.get("email", "")) + role = request.form.get("role", "manager").strip() + + if not email or "@" not in email: + flash("Enter a valid email address.", "error") + return redirect(url_for("site_admin.league_detail", league_id=league_id)) + + if role not in ("manager", "viewer"): + flash("Invalid role.", "error") + return redirect(url_for("site_admin.league_detail", league_id=league_id)) + + existing_user = find_user_by_email(email) + if existing_user: + existing_membership = find_membership(league.id, existing_user.id) + if existing_membership: + flash(f"{email} is already a member of this league.", "error") + return redirect(url_for("site_admin.league_detail", league_id=league_id)) + + token = generate_invite_token(league.id, email, role, current_user_id() or "") + base_url = current_app.config.get("APP_BASE_URL", "").rstrip("/") + invite_url = f"{base_url}{url_for('account.accept_invite', token=token)}" + + try: + send_league_invite(email, league.name, invite_url, current_user_id() or "") + flash(f"Invitation sent to {email}.", "success") + except Exception: + flash(f"Email not configured. Invite link (copy manually): {invite_url}", "info") + + return redirect(url_for("site_admin.league_detail", league_id=league_id)) + + +@site_admin_bp.post("/leagues//members//remove") +@site_admin_required +def admin_remove_member(league_id: str, user_id: str): + from ..db_models import League, LeagueMembership + from ..repositories.leagues import remove_league_member + + league = db.session.get(League, league_id) + if league is None: + flash("League not found.", "error") + return redirect(url_for("site_admin.leagues")) + + membership = LeagueMembership.query.filter_by( + league_id=league_id, + user_id=user_id, + disabled_at=None, + ).one_or_none() + + if membership is None: + flash("Member not found.", "error") + return redirect(url_for("site_admin.league_detail", league_id=league_id)) + + if membership.role == "owner": + flash("Cannot remove the league owner. Transfer ownership first.", "error") + return redirect(url_for("site_admin.league_detail", league_id=league_id)) + + remove_league_member(league_id, user_id) + db.session.commit() + flash("Member removed.", "success") + return redirect(url_for("site_admin.league_detail", league_id=league_id)) + + +@site_admin_bp.post("/leagues//transfer") +@site_admin_required +def admin_transfer_ownership(league_id: str): + from ..db_models import League + from ..repositories.leagues import transfer_league_ownership + + league = db.session.get(League, league_id) + if league is None: + flash("League not found.", "error") + return redirect(url_for("site_admin.leagues")) + + new_owner_id = request.form.get("new_owner_user_id", "").strip() + if not new_owner_id: + flash("Select a member to transfer ownership to.", "error") + return redirect(url_for("site_admin.league_detail", league_id=league_id)) + + try: + transfer_league_ownership(league_id, new_owner_id) + db.session.commit() + flash("Ownership transferred.", "success") + except ValueError as exc: + flash(str(exc), "error") + + return redirect(url_for("site_admin.league_detail", league_id=league_id)) diff --git a/boker/static/css/admin.css b/boker/static/css/admin.css new file mode 100644 index 0000000..2b40188 --- /dev/null +++ b/boker/static/css/admin.css @@ -0,0 +1,562 @@ +/* ================================================================ + ADMIN SHELL — left sidebar layout + ================================================================ */ +.admin-html, .admin-body { + margin: 0; + height: 100%; +} +.admin-body { + display: flex; + min-height: 100vh; + background: var(--bg); + color: var(--text-body); + font-family: var(--font-ui); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +/* ---- sidebar ---- */ +.adm-sidebar { + width: 220px; + flex-shrink: 0; + display: flex; + flex-direction: column; + background: var(--surface); + border-right: 1px solid var(--border); + position: fixed; + top: 0; left: 0; bottom: 0; + z-index: 100; + overflow-y: auto; +} +.adm-sidebar__logo { + padding: 20px 16px 16px; + border-bottom: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 2px; +} +.adm-sidebar__wordmark { + font-size: 13px; + font-weight: 700; + color: var(--text-strong); + letter-spacing: -.01em; +} +.adm-sidebar__tag { + font-size: 10px; + font-weight: 600; + letter-spacing: .06em; + text-transform: uppercase; + color: var(--accent); + background: var(--accent-chip); + border: 1px solid var(--accent-chip-bd); + border-radius: 4px; + padding: 1px 5px; + display: inline-block; + width: fit-content; +} +.adm-nav { + flex: 1; + padding: 12px 8px; + display: flex; + flex-direction: column; + gap: 2px; +} +.adm-nav__section { + font-size: 10px; + font-weight: 600; + letter-spacing: .07em; + text-transform: uppercase; + color: var(--faintest); + padding: 12px 8px 4px; +} +.adm-nav__link { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 10px; + border-radius: var(--r-pill); + color: var(--text-2); + text-decoration: none; + font-size: 13px; + font-weight: 500; + transition: background .12s, color .12s; +} +.adm-nav__link:hover { + background: var(--surface-raised); + color: var(--text); +} +.adm-nav__link.is-active { + background: var(--accent-chip); + color: var(--accent); + font-weight: 600; +} +.adm-nav__icon { + width: 16px; + height: 16px; + flex-shrink: 0; + opacity: .7; +} +.adm-nav__link.is-active .adm-nav__icon { opacity: 1; } +/* ---- main content ---- */ +.adm-main { + margin-left: 220px; + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +/* ---- header ---- */ +.adm-header { + height: 60px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 28px; + gap: 16px; + background: var(--surface); + position: sticky; + top: 0; + z-index: 50; +} +.adm-header__left { + display: flex; + align-items: center; + min-width: 0; +} +.adm-header__title { + font-size: 16px; + font-weight: 700; + color: var(--text-strong); + letter-spacing: -.01em; + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.adm-header__breadcrumb { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + min-width: 0; +} +.adm-header__breadcrumb a { + color: var(--faintest); + text-decoration: none; + font-weight: 500; + transition: color .12s; + white-space: nowrap; +} +.adm-header__breadcrumb a:hover { color: var(--accent); } +.adm-header__sep { color: var(--border-strong); flex-shrink: 0; } +.adm-header__title.adm-header__breadcrumb-end { + font-size: 14px; + font-weight: 600; + color: var(--text-strong); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +/* breadcrumb leaf node */ +.adm-header__breadcrumb .adm-header__title { + font-size: 14px; + font-weight: 600; + color: var(--text-strong); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin: 0; +} +.adm-header__right { + display: flex; + align-items: center; + gap: 14px; + flex-shrink: 0; +} +.adm-header__user { + display: flex; + align-items: center; + gap: 5px; + font-size: 12px; + color: var(--faintest); + font-family: var(--font-mono); + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.adm-header__back { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 12px; + font-weight: 500; + color: var(--text-2); + text-decoration: none; + border: 1px solid var(--border-strong); + border-radius: var(--r-pill); + padding: 4px 10px; + transition: border-color .12s, color .12s, background .12s; + white-space: nowrap; +} +.adm-header__back:hover { + color: var(--text-strong); + border-color: var(--border-hi); + background: var(--surface-raised); +} +.adm-content { + padding: 28px; + max-width: 1200px; +} + +/* ---- flash messages ---- */ +.adm-flash-stack { + padding: 0 28px 0; + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 16px; +} +.adm-flash { + padding: 10px 14px; + border-radius: var(--r-sm); + font-size: 13px; +} +.adm-flash.success { background: var(--pos-tint); color: var(--pos); border: 1px solid var(--pos-tint-bd); } +.adm-flash.error { background: var(--neg-tint); color: var(--neg); border: 1px solid var(--neg-tint-bd); } +.adm-flash.info { background: var(--accent-tint); color: var(--accent); border: 1px solid var(--accent-chip-bd); } + +/* ---- stat sections ---- */ +.adm-stat-section { + margin-bottom: 24px; +} +.adm-stat-section__label { + font-size: 10px; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; + color: var(--faintest); + margin-bottom: 10px; + padding-left: 2px; +} +.adm-stat-section__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); + gap: 10px; +} + +/* ---- stat grid (legacy, still used on other pages) ---- */ +.adm-stat-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 28px; +} +.adm-stat { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: 16px 18px; +} +.adm-stat--accent { + border-left: 3px solid var(--accent); + padding-left: 15px; + background: var(--accent-tint); +} +.adm-stat--pos { + border-left: 3px solid var(--pos); + padding-left: 15px; + background: var(--pos-tint); +} +.adm-stat--warn { + border-left: 3px solid var(--warn); + padding-left: 15px; + background: var(--warn-tint); +} +.adm-stat__label { + font-size: 11px; + font-weight: 600; + letter-spacing: .05em; + text-transform: uppercase; + color: var(--faintest); + margin-bottom: 6px; +} +.adm-stat__val { + font-size: 28px; + font-weight: 700; + color: var(--text-strong); + line-height: 1; +} +.adm-stat__sub { + font-size: 11px; + color: var(--faintest); + margin-top: 4px; +} +.adm-stat__sub b { color: var(--text-2); } + +/* ---- section heading ---- */ +.adm-section { + margin-bottom: 28px; +} +.adm-section__head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + gap: 12px; +} +.adm-section__title { + font-size: 13px; + font-weight: 600; + color: var(--text-strong); +} + +/* ---- table ---- */ +.adm-table-wrap { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: var(--r-md); + background: var(--surface); +} +.adm-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} +.adm-table th { + padding: 9px 14px; + text-align: left; + font-size: 11px; + font-weight: 600; + letter-spacing: .04em; + text-transform: uppercase; + color: var(--faintest); + border-bottom: 1px solid var(--border); + white-space: nowrap; + background: var(--surface-head); +} +.adm-table td { + padding: 10px 14px; + border-bottom: 1px solid var(--divider); + color: var(--text-2); + vertical-align: middle; +} +.adm-table tr:last-child td { border-bottom: none; } +.adm-table tr:hover td { background: var(--surface-raised); } +.adm-table a { color: var(--accent); text-decoration: none; } +.adm-table a:hover { text-decoration: underline; } +.adm-table .adm-mono { font-family: var(--font-mono); font-size: 12px; } +.adm-table .cell-muted { color: var(--faintest); } + +/* ---- pill badges ---- */ +.adm-pill { + display: inline-block; + font-size: 10px; + font-weight: 600; + letter-spacing: .04em; + text-transform: uppercase; + padding: 2px 7px; + border-radius: 99px; + white-space: nowrap; +} +.adm-pill--active { background: var(--pos-tint); color: var(--pos); border: 1px solid var(--pos-tint-bd); } +.adm-pill--disabled{ background: var(--neg-tint); color: var(--neg); border: 1px solid var(--neg-tint-bd); } +.adm-pill--admin { background: var(--accent-chip); color: var(--accent); border: 1px solid var(--accent-chip-bd); } +.adm-pill--open { background: var(--warn-tint); color: var(--warn); border: 1px solid var(--warn-tint-bd); } +.adm-pill--public { background: var(--pos-tint); color: var(--pos); border: 1px solid var(--pos-tint-bd); } +.adm-pill--private { background: var(--surface-raised); color: var(--faintest); border: 1px solid var(--border); } +.adm-pill--owner { background: var(--accent-chip); color: var(--accent); border: 1px solid var(--accent-chip-bd); } +.adm-pill--manager { background: var(--warn-tint); color: var(--warn); border: 1px solid var(--warn-tint-bd); } +.adm-pill--viewer { background: var(--surface-raised); color: var(--faintest); border: 1px solid var(--border); } +.adm-pill--archived{ background: var(--surface-raised); color: var(--faintest-2); border: 1px solid var(--border); } + +/* ---- search bar ---- */ +.adm-search { + display: flex; + gap: 8px; + margin-bottom: 16px; +} +.adm-search input { + flex: 1; + min-width: 0; + padding: 8px 12px; + background: var(--field); + border: 1px solid var(--border-strong); + border-radius: var(--r-sm); + color: var(--text); + font-size: 13px; + font-family: inherit; +} +.adm-search input:focus { + outline: none; + border-color: var(--accent); +} + +/* ---- buttons ---- */ +.adm-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + border-radius: var(--r-sm); + font-size: 12px; + font-weight: 600; + font-family: inherit; + cursor: pointer; + border: 1px solid transparent; + text-decoration: none; + transition: opacity .12s, background .12s; + white-space: nowrap; +} +.adm-btn:hover { opacity: .85; } +.adm-btn--primary { background: var(--accent); color: var(--accent-ink); } +.adm-btn--ghost { background: transparent; color: var(--text-2); border-color: var(--border-strong); } +.adm-btn--ghost:hover { background: var(--surface-raised); opacity: 1; } +.adm-btn--danger { background: var(--neg-tint); color: var(--neg); border-color: var(--neg-tint-bd); } +.adm-btn--warn { background: var(--warn-tint); color: var(--warn); border-color: var(--warn-tint-bd); } +.adm-btn--sm { padding: 4px 10px; font-size: 11px; } + +/* ---- card ---- */ +.adm-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: 20px; + margin-bottom: 20px; +} +.adm-card__title { + font-size: 13px; + font-weight: 600; + color: var(--text-strong); + margin-bottom: 14px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border); +} +.adm-kv { display: grid; gap: 8px; } +.adm-kv-row { + display: grid; + grid-template-columns: 140px 1fr; + gap: 12px; + font-size: 13px; +} +.adm-kv-row dt { color: var(--faintest); font-size: 12px; } +.adm-kv-row dd { margin: 0; color: var(--text-2); word-break: break-all; } +.adm-kv-row dd b { color: var(--text-strong); font-weight: 600; } + +/* ---- action group ---- */ +.adm-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +/* ---- reset link box ---- */ +.adm-reset-box { + margin-top: 12px; + padding: 12px 14px; + background: var(--warn-tint); + border: 1px solid var(--warn-tint-bd); + border-radius: var(--r-sm); +} +.adm-reset-box__label { + font-size: 11px; + font-weight: 600; + color: var(--warn); + letter-spacing: .04em; + text-transform: uppercase; + margin-bottom: 6px; +} +.adm-reset-box__url { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-2); + word-break: break-all; + user-select: all; +} + +/* ---- pagination ---- */ +.adm-pagination { + display: flex; + align-items: center; + gap: 6px; + margin-top: 16px; + font-size: 12px; + color: var(--faintest); +} +.adm-pagination a { + padding: 4px 10px; + border: 1px solid var(--border-strong); + border-radius: var(--r-pill); + color: var(--text-2); + text-decoration: none; +} +.adm-pagination a:hover { background: var(--surface-raised); } +.adm-pagination span { padding: 4px 10px; color: var(--faintest); } + +/* ---- inline form ---- */ +.adm-inline-form { display: inline; } +.adm-pass-form { + display: flex; + gap: 8px; + align-items: flex-end; + margin-top: 8px; +} +.adm-pass-form input { + padding: 6px 10px; + background: var(--field); + border: 1px solid var(--border-strong); + border-radius: var(--r-sm); + color: var(--text); + font-size: 12px; + font-family: inherit; + min-width: 220px; +} +.adm-pass-form input:focus { outline: none; border-color: var(--accent); } + +/* ---- chart layout ---- */ +.adm-chart-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 28px; +} +@media (max-width: 768px) { + .adm-chart-row { grid-template-columns: 1fr; } +} +.adm-chart-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: 16px 18px 14px; +} +.adm-chart-card__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} +.adm-chart-card__title { + font-size: 12px; + font-weight: 700; + color: var(--text-strong); + text-transform: uppercase; + letter-spacing: .05em; + line-height: 1.2; +} +.adm-chart-card__sub { + font-size: 11px; + color: var(--faintest); + margin-top: 3px; +} +.adm-chart-card__meta { + font-size: 11px; + color: var(--faintest); + white-space: nowrap; + margin-top: 2px; +} +.adm-chart-card__meta b { color: var(--text-2); } diff --git a/boker/static/css/style.css b/boker/static/css/style.css index 6d36ff5..bc6975b 100644 --- a/boker/static/css/style.css +++ b/boker/static/css/style.css @@ -597,6 +597,13 @@ h3 { outline: none; appearance: none; } +.form-card select { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='11' height='7' viewBox='0 0 11 7'%3E%3Cpath d='M1 1l4.5 4.5L10 1' stroke='%2366646f' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + padding-right: 34px; + cursor: pointer; +} .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); diff --git a/boker/static/css/tokens.css b/boker/static/css/tokens.css index 00e4681..f434282 100644 --- a/boker/static/css/tokens.css +++ b/boker/static/css/tokens.css @@ -78,6 +78,12 @@ --w-admin:1580px; --gutter: clamp(16px, 4vw, 40px); + /* ---- typography ---- */ + --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); + /* ---- Chart.js backward compat (read as raw strings by JS) ---- */ --text-muted: #bcbac6; --line: #26262f; diff --git a/boker/templates/admin/base.html b/boker/templates/admin/base.html new file mode 100644 index 0000000..1c0cd90 --- /dev/null +++ b/boker/templates/admin/base.html @@ -0,0 +1,95 @@ + + + + + + {% block title %}Admin{% endblock %} · boker admin + + + + + + + + + +
+
+
+ {% block breadcrumb %} +

{% block page_title %}{% endblock %}

+ {% endblock %} +
+
+ + + + + + {{ current_user_id }} + + + + + + Back to site + +
+
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + +
+ {% block content %}{% endblock %} +
+
+ + +{% block extra_scripts %}{% endblock %} + + diff --git a/boker/templates/admin/dashboard.html b/boker/templates/admin/dashboard.html new file mode 100644 index 0000000..1cb2d71 --- /dev/null +++ b/boker/templates/admin/dashboard.html @@ -0,0 +1,269 @@ +{% extends "admin/base.html" %} +{% block title %}Dashboard{% endblock %} +{% block page_title %}Dashboard{% endblock %} + +{% block content %} + +
+ +
+
+
Total users
+
{{ stats.total_users }}
+
{{ stats.new_users_7d }} new this week · {{ stats.new_users_30d }} this month
+
+
+
Active accounts
+
{{ stats.active_users }}
+
{{ stats.disabled_users }} disabled · {{ stats.admin_count }} admin{% if stats.admin_count != 1 %}s{% endif %}
+
+
+
Daily active
+
{{ stats.dau }}
+
logged in today
+
+
+
Weekly active
+
{{ stats.wau }}
+
past 7 days
+
+
+
Monthly active
+
{{ stats.mau }}
+
past 30 days
+
+
+
+ +
+ +
+
+
Active leagues
+
{{ stats.active_leagues }}
+
{{ stats.public_leagues }} public · {{ stats.new_leagues_30d }} new this month
+
+
+
Avg sessions / league
+
{{ stats.avg_sessions_per_league }}
+
across all leagues
+
+
+
Avg buyin / league
+
{{ stats.avg_buyin_cents | money }}
+
total buyins per league
+
+
+
+ +
+ +
+
+
Sessions
+
{{ stats.total_sessions }}
+
{{ stats.open_sessions }} currently open
+
+
+
Ledger events
+
{{ stats.total_events }}
+
across all leagues
+
+
+
+ +
+
+
+
+
User activity
+
Logins & new signups — last 30 days
+
+ DAU {{ stats.dau }} · MAU {{ stats.mau }} +
+ +
+
+
+
+
Session frequency
+
Sessions played per day — last 30 days
+
+ Total {{ stats.total_sessions }} +
+ +
+
+ +
+
+ Recent sign-ups + View all users +
+ {% if recent_users %} +
+ + + + + + + + + + {% for user in recent_users %} + + + + + + {% endfor %} + +
EmailJoinedStatus
{{ user.email }}{% if user.is_site_admin %} admin{% endif %}{{ user.created_at.strftime('%b %d, %Y') }} + {% if user.disabled_at %} + Disabled + {% else %} + Active + {% endif %} +
+
+ {% else %} +

No users yet.

+ {% endif %} +
+ +
+
+ Recently created leagues + View all leagues +
+ {% if recent_leagues %} +
+ + + + + + + + + + + {% for league in recent_leagues %} + + + + + + + {% endfor %} + +
NameOwnerVisibilityCreated
{{ league.name }}{% if league.archived_at %} archived{% endif %} + {% set owner = owners_by_id.get(league.created_by_user_id) %} + {% if owner %} + {{ owner.email }} + {% else %} + + {% endif %} + + {% if league.visibility == 'public' %} + Public + {% else %} + Private + {% endif %} + {{ league.created_at.strftime('%b %d, %Y') }}
+
+ {% else %} +

No leagues yet.

+ {% endif %} +
+ +{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/boker/templates/admin/league_detail.html b/boker/templates/admin/league_detail.html new file mode 100644 index 0000000..0aadc9d --- /dev/null +++ b/boker/templates/admin/league_detail.html @@ -0,0 +1,209 @@ +{% extends "admin/base.html" %} +{% block title %}{{ league.name }}{% endblock %} + +{% block breadcrumb %} +
+ Leagues + / + {{ league.name }} +
+{% endblock %} + +{% block content %} + +
+
League info
+
+
+
Name
+
{{ league.name }}
+
+
+
League ID
+
{{ league.id }}
+
+
+
Slug / key
+
{{ league.slug }}-{{ league.public_key }}
+
+ {% if league.description %} +
+
Description
+
{{ league.description }}
+
+ {% endif %} +
+
Visibility
+
+ {% if league.visibility == 'public' %} + Public + {% else %} + Private + {% endif %} +
+
+
+
Owner
+
+ {% if owner %} + {{ owner.email }} + {% else %} + Unknown + {% endif %} +
+
+
+
Created
+
{{ league.created_at.strftime('%b %d, %Y at %H:%M UTC') }}
+
+
+
Status
+
+ {% if league.archived_at %} + Archived + {{ league.archived_at.strftime('%b %d, %Y') }} + {% else %} + Active + {% endif %} +
+
+
+
+ +
+
+
Members
+
{{ memberships | length }}
+
+
+
Players
+
{{ player_count }}
+
added to this league
+
+
+
Sessions
+
{{ session_count }}
+
{{ open_sessions }} open
+
+
+
Ledger events
+
{{ event_count }}
+
+
+ +{% if not league.archived_at %} + +{% endif %} + +{% if memberships %} +
+
Members ({{ memberships | length }})
+
+ + + + + + + + + + {% for membership, user in memberships %} + + + + + + {% endfor %} + +
UserRoleJoined league
+ {{ user.email }} + {% if user.is_site_admin %}admin{% endif %} + {% if user.disabled_at %}disabled{% endif %} + {{ membership.role }}{{ membership.created_at.strftime('%b %d, %Y') }}
+
+
+{% endif %} + +
+
Admin actions
+ +
+
Send invite
+
+ +
+ + +
+
+ + +
+ +
+
+ + {% set has_non_owners = [] %} + {% for membership, user in memberships %}{% if membership.role != 'owner' %}{% set _ = has_non_owners.append(1) %}{% endif %}{% endfor %} + {% if has_non_owners %} +
+
Remove member
+
+ + + + + + {% for membership, user in memberships %} + {% if membership.role != 'owner' %} + + + + + + {% endif %} + {% endfor %} + +
UserRole
{{ user.email }}{{ membership.role }} +
+ + +
+
+
+
+ {% endif %} + + {% set has_managers = [] %} + {% for membership, user in memberships %}{% if membership.role == 'manager' %}{% set _ = has_managers.append((membership, user)) %}{% endif %}{% endfor %} + {% if has_managers %} +
+
Transfer ownership
+
+ +
+ + +
+ +
+
+ {% endif %} +
+ +{% endblock %} diff --git a/boker/templates/admin/leagues.html b/boker/templates/admin/leagues.html new file mode 100644 index 0000000..b4a8f75 --- /dev/null +++ b/boker/templates/admin/leagues.html @@ -0,0 +1,90 @@ +{% extends "admin/base.html" %} +{% block title %}Leagues{% endblock %} +{% block page_title %}Leagues {{ total }}{% endblock %} + +{% block content %} + + + +{% if leagues %} +
+ + + + + + + + + + + + + + {% for league in leagues %} + + + + + + + + + + {% endfor %} + +
NameOwnerVisibilityMembersSessionsCreatedStatus
{{ league.name }} + {% set owner = owners_by_id.get(league.created_by_user_id) %} + {% if owner %} + {{ owner.email }} + {% else %} + + {% endif %} + + {% if league.visibility == 'public' %} + Public + {% else %} + Private + {% endif %} + {{ member_counts.get(league.id, 0) }}{{ session_counts.get(league.id, 0) }}{{ league.created_at.strftime('%b %d, %Y') }} + {% if league.archived_at %} + Archived + {% else %} + Active + {% endif %} +
+
+ +{% if total_pages > 1 %} +
+ {% if page > 1 %} + ← Prev + {% endif %} + Page {{ page }} of {{ total_pages }} + {% if page < total_pages %} + Next → + {% endif %} +
+{% endif %} + +{% else %} +

+ {% if search or visibility %}No leagues matching those filters.{% else %}No leagues yet.{% endif %} +

+{% endif %} + +{% endblock %} diff --git a/boker/templates/admin/user_detail.html b/boker/templates/admin/user_detail.html new file mode 100644 index 0000000..95c27eb --- /dev/null +++ b/boker/templates/admin/user_detail.html @@ -0,0 +1,168 @@ +{% extends "admin/base.html" %} +{% block title %}{{ user.email }}{% endblock %} + +{% block breadcrumb %} +
+ Users + / + {{ user.email }} +
+{% endblock %} + +{% block content %} + +
+
Account
+
+
+
Email
+
{{ user.email }}
+
+
+
User ID
+
{{ user.id }}
+
+
+
Joined
+
{{ user.created_at.strftime('%b %d, %Y at %H:%M UTC') }}
+
+
+
Last login
+
{% if user.last_login_at %}{{ user.last_login_at.strftime('%b %d, %Y at %H:%M UTC') }}{% else %}Never{% endif %}
+
+
+
Status
+
+ {% if user.disabled_at %} + Disabled + since {{ user.disabled_at.strftime('%b %d, %Y') }} + {% else %} + Active + {% endif %} +
+
+
+
Site admin
+
+ {% if user.is_site_admin %} + Yes + {% else %} + No + {% endif %} +
+
+
+
Leagues
+
{{ memberships | length }}
+
+
+
+ +
+
Actions
+ +
+
Password reset
+
+
+ + +
+ + Generate link (no email) + +
+ {% if reset_url %} +
+
Reset link — valid 1 hour
+
{{ reset_url }}
+
+ {% endif %} +
+ +
+
Set password directly
+
+ + + +
+
+ + {% if not viewing_self %} +
+
Account status
+
+ {% if user.disabled_at %} +
+ + +
+ {% else %} +
+ + +
+ {% endif %} +
+
+ +
+
Admin access
+
+ {% if user.is_site_admin %} +
+ + +
+ {% else %} +
+ + +
+ {% endif %} +
+
+ {% endif %} +
+ +{% if memberships %} +
+
League memberships ({{ memberships | length }})
+
+ + + + + + + + + + + {% for membership, league in memberships %} + + + + + + + {% endfor %} + +
LeagueRoleVisibilityJoined
+ {{ league.name }} + {% if league.archived_at %}archived{% endif %} + {{ membership.role }} + {% if league.visibility == 'public' %} + Public + {% else %} + Private + {% endif %} + {{ membership.created_at.strftime('%b %d, %Y') }}
+
+
+{% endif %} + +{% endblock %} diff --git a/boker/templates/admin/users.html b/boker/templates/admin/users.html new file mode 100644 index 0000000..80cc6c3 --- /dev/null +++ b/boker/templates/admin/users.html @@ -0,0 +1,68 @@ +{% extends "admin/base.html" %} +{% block title %}Users{% endblock %} +{% block page_title %}Users {{ total }}{% endblock %} + +{% block content %} + + + +{% if users %} +
+ + + + + + + + + + + + {% for user in users %} + + + + + + + + {% endfor %} + +
EmailJoinedLast loginLeaguesStatus
+ {{ user.email }} + {% if user.is_site_admin %}admin{% endif %} + {{ user.created_at.strftime('%b %d, %Y') }} + {% if user.last_login_at %}{{ user.last_login_at.strftime('%b %d, %Y') }}{% else %}—{% endif %} + {{ league_counts.get(user.id, 0) }} + {% if user.disabled_at %} + Disabled + {% else %} + Active + {% endif %} +
+
+ +{% if total_pages > 1 %} +
+ {% if page > 1 %} + ← Prev + {% endif %} + Page {{ page }} of {{ total_pages }} + {% if page < total_pages %} + Next → + {% endif %} +
+{% endif %} + +{% else %} +

+ {% if search %}No users matching "{{ search }}".{% else %}No users yet.{% endif %} +

+{% endif %} + +{% endblock %} diff --git a/boker/templates/league_settings.html b/boker/templates/league_settings.html index 4f316fb..54ba9cd 100644 --- a/boker/templates/league_settings.html +++ b/boker/templates/league_settings.html @@ -64,18 +64,33 @@ Email -
{{ user.email }} {% if user.is_site_admin %}admin{% endif %} + {% if not user.email_verified_at %}unverified{% endif %} {{ user.created_at.strftime('%b %d, %Y') }} diff --git a/boker/templates/base.html b/boker/templates/base.html index 1cb7909..2a261a8 100644 --- a/boker/templates/base.html +++ b/boker/templates/base.html @@ -63,6 +63,16 @@ {% endif %} {% endwith %} + {% if is_logged_in and email_verified is not none and not email_verified %} +
+ Your email address hasn't been verified. + + + + +
+ {% endif %} + {% block content %}{% endblock %}
diff --git a/tests/test_emails.py b/tests/test_emails.py new file mode 100644 index 0000000..9ac1182 --- /dev/null +++ b/tests/test_emails.py @@ -0,0 +1,84 @@ +import socket +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from flask_mail import Message + +from boker import create_app +from boker.db import db +from boker.emails import MailNotConfiguredError, send_email_verification +from boker.repositories.leagues import create_user + + +class EmailDeliveryTests(unittest.TestCase): + def test_blank_mail_server_fails_before_smtp_send(self): + app = create_app({"MAIL_SERVER": "", "TESTING": True}) + + with app.app_context(): + with patch("boker.emails.mail.send") as send: + with self.assertRaises(MailNotConfiguredError): + send_email_verification("user@example.com", "http://localhost/verify") + + send.assert_not_called() + + def test_smtp_send_uses_configured_socket_timeout(self): + app = create_app({"MAIL_SERVER": "smtp.example.com", "MAIL_SEND_TIMEOUT": 1.5, "TESTING": True}) + msg = Message( + subject="Test", + recipients=["user@example.com"], + body="body", + sender="noreply@example.com", + ) + observed_timeout = None + + def capture_timeout(_msg): + nonlocal observed_timeout + observed_timeout = socket.getdefaulttimeout() + + with app.app_context(): + previous_timeout = socket.getdefaulttimeout() + with patch("boker.emails.mail.send", side_effect=capture_timeout): + from boker.emails import _send + + _send(msg) + + self.assertEqual(observed_timeout, 1.5) + self.assertEqual(socket.getdefaulttimeout(), previous_timeout) + + def test_resend_verification_returns_when_mail_is_disabled(self): + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "test.sqlite3" + app = create_app( + { + "SQLALCHEMY_DATABASE_URI": f"sqlite:///{db_path}", + "MAIL_SERVER": "", + "TESTING": True, + "WTF_CSRF_ENABLED": False, + } + ) + + with app.app_context(): + db.create_all() + user = create_user("user@example.com", "password123") + db.session.commit() + user_id = user.id + + with app.test_client() as client: + with client.session_transaction() as flask_session: + flask_session["user_id"] = user_id + + response = client.post("/account/resend-verification") + + with app.app_context(): + db.session.remove() + db.drop_all() + db.engine.dispose() + + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers["Location"], "/account/settings") + + +if __name__ == "__main__": + unittest.main() From 988f170ea4f65ed588ce5b60e7d2a19a2e0a8fc1 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Thu, 25 Jun 2026 01:02:15 -0700 Subject: [PATCH 46/83] fix imports --- routes/leagues.py | 44 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/routes/leagues.py b/routes/leagues.py index 44bcb13..4d27d0f 100644 --- a/routes/leagues.py +++ b/routes/leagues.py @@ -1291,6 +1291,8 @@ def import_ledger_csv(league_ref: str): flash(f"CSV header mismatch. Expected: {', '.join(CSV_HEADERS)}", "error") return redirect(url_for("leagues.ledger", **league_url_values(league))) + from db_models import PokerSession, make_session + sessions = list_sessions_for_league(league.id) session_by_ref = {session_event_ref(s): s for s in sessions} @@ -1303,6 +1305,43 @@ def import_ledger_csv(league_ref: str): if e.legacy_event_id } + # Auto-create any sessions referenced in the CSV that don't exist yet. + # Refs are "YYYY-MM-DD-NN" so we can recover exact date and sequence. + missing_refs: set[str] = set() + for row in csv.DictReader(lines): + ref = row.get("session_id", "").strip() + if ref and ref not in session_by_ref: + missing_refs.add(ref) + + sessions_created = 0 + for ref in sorted(missing_refs): + try: + date_part, seq_part = ref.rsplit("-", 1) + session_date = date.fromisoformat(date_part) + sequence = int(seq_part) + except (ValueError, IndexError): + continue # malformed ref — row will produce a clear error below + + existing = PokerSession.query.filter_by( + league_id=league.id, + session_date=session_date, + sequence_on_date=sequence, + ).one_or_none() + if existing: + session_by_ref[ref] = existing + continue + + new_session = make_session( + league_id=league.id, + session_date=session_date, + sequence_on_date=sequence, + status="closed", + ) + db.session.add(new_session) + db.session.flush() + session_by_ref[ref] = new_session + sessions_created += 1 + errors: list[str] = [] queued: list[dict] = [] skipped = 0 @@ -1373,5 +1412,8 @@ def import_ledger_csv(league_ref: str): db.session.commit() s_count = f"{len(queued)} event{'s' if len(queued) != 1 else ''}" s_skip = f"{skipped} duplicate{'s' if skipped != 1 else ''}" - flash(f"Imported {s_count}, skipped {s_skip}.", "success") + parts = [f"Imported {s_count}", f"skipped {s_skip}"] + if sessions_created: + parts.append(f"created {sessions_created} session{'s' if sessions_created != 1 else ''}") + flash(", ".join(parts) + ".", "success") return redirect(url_for("leagues.ledger", **league_url_values(league))) From 56c9a1084cbb31c6d503ce414d8e5063f002413f Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Thu, 25 Jun 2026 01:05:38 -0700 Subject: [PATCH 47/83] create player on import --- routes/leagues.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/routes/leagues.py b/routes/leagues.py index 4d27d0f..d7032f3 100644 --- a/routes/leagues.py +++ b/routes/leagues.py @@ -1256,7 +1256,7 @@ def import_ledger_csv(league_ref: str): list_ledger_events_for_league, session_event_ref, ) - from league_repositories import list_players_for_league, list_sessions_for_league + from league_repositories import create_player, list_players_for_league, list_sessions_for_league, unique_player_slug from storage import CSV_HEADERS league = require_league(league_ref, {"owner", "manager"}) @@ -1342,6 +1342,26 @@ def import_ledger_csv(league_ref: str): session_by_ref[ref] = new_session sessions_created += 1 + # Auto-create any players referenced in the CSV that don't exist yet. + # Only rows that need a player (not note/session_open/session_close) are considered. + non_player_types = {"note", "session_open", "session_close"} + missing_players: set[str] = set() + for row in csv.DictReader(lines): + raw_type = row.get("event_type", "").strip() + if canonical_event_type(raw_type) in non_player_types: + continue + name = row.get("player_name", "").strip() + if name and name.casefold() not in player_by_name: + missing_players.add(name) + + players_created = 0 + for name in sorted(missing_players): + slug = unique_player_slug(league.id, name) + new_player = create_player(league.id, name, slug) + db.session.flush() + player_by_name[name.casefold()] = new_player + players_created += 1 + errors: list[str] = [] queued: list[dict] = [] skipped = 0 @@ -1415,5 +1435,7 @@ def import_ledger_csv(league_ref: str): parts = [f"Imported {s_count}", f"skipped {s_skip}"] if sessions_created: parts.append(f"created {sessions_created} session{'s' if sessions_created != 1 else ''}") + if players_created: + parts.append(f"created {players_created} player{'s' if players_created != 1 else ''}") flash(", ".join(parts) + ".", "success") return redirect(url_for("leagues.ledger", **league_url_values(league))) From a5cb488b9168118ca16b0cee900db85e151d9c61 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Fri, 26 Jun 2026 21:24:00 -0700 Subject: [PATCH 48/83] visual redesign: landing, dashboard overview, session calendar (2.3.1) - Redesign landing page hero with split layout, features section, and in-app session/result demos - Add fixed sidebar scroll isolation (app-shell overflow lock pattern) - Redesign leagues index cards with initial badge, podium leader chips, and action links - Redesign league dashboard: color-coded nav tiles, cash/stats overview strip, session history heatmap calendar - Dashboard cash stats use real cash only (excludes rollovers and carry-ins) - Session history calendar: JS-driven full-year grid, year navigation, hover tooltips, full-width responsive layout - Bump leaderboard tile color from gold to blue (distinct from ledger orange) - Bump version to 2.3.1 --- config.py | 2 +- routes/leagues.py | 45 + static/css/style.css | 2081 ++++++++++++++++++++++++-- static/css/tokens.css | 67 +- static/favicon.svg | 2 +- templates/_league_sidebar.html | 87 ++ templates/account_login.html | 81 +- templates/account_register.html | 85 +- templates/account_settings.html | 15 +- templates/base.html | 23 +- templates/explore.html | 54 +- templates/landing.html | 420 ++++-- templates/league_dashboard.html | 360 ++++- templates/league_leaderboard.html | 9 +- templates/league_ledger.html | 3 +- templates/league_player_detail.html | 3 +- templates/league_players.html | 10 +- templates/league_session_detail.html | 3 +- templates/league_session_view.html | 4 +- templates/league_sessions.html | 10 +- templates/league_settings.html | 3 +- templates/leagues_index.html | 205 ++- 22 files changed, 2975 insertions(+), 597 deletions(-) create mode 100644 templates/_league_sidebar.html diff --git a/config.py b/config.py index c5a83af..edb1d78 100644 --- a/config.py +++ b/config.py @@ -9,7 +9,7 @@ DATA_PATH = BASE_DIR / "data" / "entries.csv" DEFAULT_DATABASE_URL = f"sqlite:///{BASE_DIR / 'data' / 'boker-dev.sqlite3'}" ELIGIBLE_MIN_SESSIONS = 3 -APP_VERSION = "2.3.0" +APP_VERSION = "2.3.1" def load_local_env(env_path: Path) -> None: diff --git a/routes/leagues.py b/routes/leagues.py index d7032f3..da5bf33 100644 --- a/routes/leagues.py +++ b/routes/leagues.py @@ -222,16 +222,61 @@ def dashboard(league_ref: str): return redirect(url_for("public.home")) from league_repositories import league_counts, user_has_league_role + from ledger_repositories import list_event_rows_for_league league, resp = get_league_with_visibility_gate(league_ref) if resp: return resp is_owner = user_has_league_role(current_user_id() or "", league.id, {"owner"}) + + all_sessions = build_session_summaries(list_event_rows_for_league(league.id)) + closed_sessions = [s for s in all_sessions if not s.is_open] + closed_count = len(closed_sessions) + + total_cash_in = sum(s.total_real_cash_in_cents for s in closed_sessions) + total_paid_out = sum(s.total_paid_out_cents for s in closed_sessions) + total_fronts = sum(s.total_front_cents for s in all_sessions) + closed_entries = sum(len(s.entries) for s in closed_sessions) + all_entries = sum(len(s.entries) for s in all_sessions) + + # Avg sessions per month since the first session + session_date_strs = [s.session_date[:10] for s in all_sessions if s.session_date] + avg_sessions_per_month = 0.0 + if session_date_strs: + first_date = date.fromisoformat(min(session_date_strs)) + today_d = date.today() + months_elapsed = (today_d.year - first_date.year) * 12 + (today_d.month - first_date.month) + 1 + avg_sessions_per_month = round(len(all_sessions) / months_elapsed, 1) + + # Session calendar data: date → count of sessions on that date + sessions_by_date: dict[str, int] = {} + for s in all_sessions: + if s.session_date: + d = s.session_date[:10] + sessions_by_date[d] = sessions_by_date.get(d, 0) + 1 + first_session_year = int(min(sessions_by_date)[:4]) if sessions_by_date else date.today().year + + cash_stats = { + "total_cash_in": total_cash_in, + "total_paid_out": total_paid_out, + "total_fronts": total_fronts, + "avg_pot": total_cash_in // closed_count if closed_count else 0, + "avg_players": round(all_entries / len(all_sessions)) if all_sessions else 0, + "avg_buyin": sum(s.total_buy_in_cents for s in closed_sessions) // closed_entries if closed_entries else 0, + "biggest_pot": max((s.total_real_cash_in_cents for s in all_sessions), default=0), + "avg_sessions_per_month": avg_sessions_per_month, + "session_map": sessions_by_date, + "first_session_year": first_session_year, + "has_data": closed_count > 0, + "has_sessions": bool(all_sessions), + } + return render_template( "league_dashboard.html", league=league, counts=league_counts(league.id), is_owner=is_owner, + cash_stats=cash_stats, ) diff --git a/static/css/style.css b/static/css/style.css index 6d36ff5..f213241 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -36,7 +36,7 @@ h1, h2, h3, h4 { margin: 0; } FONTS ================================================================ */ :root { - --font-ui: Aptos, Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-ui: '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); @@ -100,13 +100,191 @@ h1, h2, h3, h4 { margin: 0; } .subnav__link.is-active { color: var(--accent); font-weight: 600; } /* ================================================================ - APP SHELL + APP SHELL + SIDEBAR + ================================================================ */ +.app-shell { + flex: 1; + display: flex; + align-items: stretch; + min-height: calc(100vh - 62px); + position: relative; +} +/* Lock app-shell to viewport height so only the main content area scrolls */ +.app-shell:has(.sidebar) { + height: calc(100vh - 62px); + overflow: hidden; +} + +/* Full-height sidebar background strip */ +.app-shell:has(.sidebar)::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 224px; + background: var(--surface); + border-right: 1px solid var(--border); + pointer-events: none; + z-index: 0; +} + +/* ---- Sidebar ---- */ +.sidebar { + width: 224px; + flex-shrink: 0; + background: transparent; + height: 100%; + overflow-y: hidden; + display: flex; + flex-direction: column; + z-index: 1; +} + +.sidebar__header { + display: flex; + align-items: center; + gap: 10px; + padding: 18px 14px 16px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.sidebar__badge { + width: 34px; + height: 34px; + border-radius: var(--r-md); + background: var(--accent-chip); + border: 1px solid var(--accent-chip-bd); + color: var(--accent); + font-size: 15px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + letter-spacing: -.01em; +} +.sidebar__meta { min-width: 0; } +.sidebar__league-name { + display: block; + font-size: .8125rem; + font-weight: 600; + color: var(--text-strong); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.3; +} +.sidebar__role { + display: block; + font-size: .6875rem; + color: var(--accent); + font-weight: 500; + margin-top: 2px; +} + +.sidebar__nav { + flex: 1; + padding: 10px 8px; + display: flex; + flex-direction: column; + gap: 1px; +} +.sidebar__link { + display: flex; + align-items: center; + gap: 9px; + padding: 8px 10px; + border-radius: var(--r-sm); + font-size: .8125rem; + font-weight: 500; + color: var(--text-2); + text-decoration: none; + transition: color .13s, background .13s; + line-height: 1; +} +.sidebar__link svg { + width: 16px; + height: 16px; + flex-shrink: 0; + opacity: .65; + transition: opacity .13s; +} +.sidebar__link:hover { + color: var(--text); + background: var(--surface-raised); +} +.sidebar__link:hover svg { opacity: .9; } +.sidebar__link.is-active { + color: var(--accent); + background: var(--accent-tint); + font-weight: 600; +} +.sidebar__link.is-active svg { opacity: 1; } + +.sidebar__footer { + flex-shrink: 0; + padding: 12px 14px 14px; + border-top: 1px solid var(--border); +} +.sidebar__back { + display: flex; + align-items: center; + gap: 5px; + font-size: .75rem; + color: var(--muted); + text-decoration: none; + transition: color .13s; +} +.sidebar__back svg { width: 12px; height: 12px; } +.sidebar__back:hover { color: var(--text-2); } + +/* ---- App page (paired with sidebar) ---- */ +.page--app { + flex: 1; + min-width: 0; + max-width: none; + margin: 0; + padding: clamp(20px, 3vw, 36px) clamp(24px, 4vw, 48px) 96px; + overflow-y: auto; + height: 100%; +} + +/* ---- Sidebar mobile collapse ---- */ +@media (max-width: 768px) { + .app-shell { flex-direction: column; min-height: 0; } + .app-shell:has(.sidebar) { height: auto; overflow: visible; } + .app-shell:has(.sidebar)::before { display: none; } + .sidebar { + width: 100%; + height: auto; + position: static; + background: var(--surface); + border-right: none; + border-bottom: 1px solid var(--border); + flex-direction: row; + align-items: center; + z-index: auto; + } + .sidebar__header { border-bottom: none; padding: 10px 12px; flex-shrink: 0; } + .sidebar__badge { width: 28px; height: 28px; font-size: 12px; } + .sidebar__role { display: none; } + .sidebar__nav { flex-direction: row; padding: 0 8px; gap: 0; overflow-x: auto; scrollbar-width: none; } + .sidebar__nav::-webkit-scrollbar { display: none; } + .sidebar__link { padding: 10px 10px; white-space: nowrap; gap: 6px; } + .sidebar__link svg { display: none; } + .sidebar__footer { display: none; } + .page--app { padding: 16px 16px 64px; overflow-y: visible; height: auto; } +} + +/* ================================================================ + APP SHELL (topbar) ================================================================ */ .topbar { position: sticky; top: 0; z-index: 50; - background: rgba(15,15,18,.86); - backdrop-filter: blur(8px); - border-bottom: 1px solid var(--border); + background: rgba(10,10,13,.94); + backdrop-filter: blur(14px) saturate(180%); + border-bottom: 1px solid rgba(255,255,255,.06); } .topbar__inner { max-width: var(--w-admin); @@ -125,10 +303,10 @@ h1, h2, h3, h4 { margin: 0; } flex: none; } .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); + width: 30px; height: 30px; + border-radius: 7px; + background: linear-gradient(135deg, var(--accent) 0%, color-mix(in srgb, var(--accent) 72%, #7c6fd4) 100%); + box-shadow: 0 0 0 1px var(--accent-a30), 0 4px 18px var(--accent-a22); position: relative; flex: none; } @@ -136,19 +314,23 @@ h1, h2, h3, h4 { margin: 0; } content: ""; position: absolute; inset: 0; margin: auto; - width: 11px; height: 11px; + width: 12px; height: 12px; border-radius: 2px; - background: var(--bg); + background: var(--accent-ink); transform: rotate(45deg); } .brand__name { font-family: var(--font-display); - font-size: 19px; - font-weight: 600; - letter-spacing: 0; + font-size: 17px; + font-weight: 700; + letter-spacing: -.01em; color: var(--text); white-space: nowrap; } +.brand__tld { + color: var(--faintest); + font-weight: 400; +} .mainnav { display: flex; gap: 4px; } .nav-toggle { display: none; @@ -231,34 +413,62 @@ h1, h2, h3, h4 { margin: 0; } } .site-footer { margin-top: auto; - padding-top: clamp(44px, 6vw, 76px); + padding: clamp(28px, 4vw, 48px) 0 clamp(20px, 3vw, 32px); border-top: 1px solid var(--divider); - display: grid; - grid-template-columns: 1fr auto 1fr; + display: flex; align-items: center; - gap: 8px 18px; + flex-wrap: wrap; + gap: 6px 20px; color: var(--faintest); - font-size: 12.5px; + font-size: 12px; } -.site-footer span { +.site-footer__copy { + display: flex; + align-items: center; + gap: 10px; white-space: nowrap; } -.site-footer span:nth-child(2) { - color: var(--muted); - justify-self: center; +.site-footer__copy strong { color: var(--faint-2); font-weight: 500; } +.site-footer__version { + font-family: var(--font-mono); + font-size: 10.5px; + color: var(--faintest-2); } -.site-footer span:nth-child(3) { - justify-self: end; +.site-footer__links { + display: flex; + align-items: center; + gap: 2px; + flex-wrap: wrap; } +.site-footer__links a { + color: var(--faintest); + text-decoration: none; + padding: 3px 7px; + border-radius: 5px; + white-space: nowrap; + transition: color .13s, background .13s; +} +.site-footer__links a:hover { color: var(--text-2); background: var(--surface-raised); } +.site-footer__brand { + margin-left: auto; + white-space: nowrap; +} +.site-footer__brand a { + color: var(--faintest); + text-decoration: none; + font-weight: 500; + transition: color .13s; +} +.site-footer__brand a:hover { color: var(--text-2); } @media (max-width: 640px) { .site-footer { - grid-template-columns: 1fr; - justify-items: center; - gap: 6px; - margin-top: 36px; + flex-direction: column; + align-items: flex-start; + gap: 8px; padding: 18px 0 max(20px, env(safe-area-inset-bottom)); - text-align: center; } + .site-footer__brand { margin-left: 0; } + /* legacy compat — nothing to do */ .site-footer span:nth-child(2), .site-footer span:nth-child(3) { justify-self: center; @@ -323,26 +533,28 @@ h1, h2, h3, h4 { margin: 0; } } h1 { font-family: var(--font-display); - font-size: clamp(30px,4.4vw,46px); - font-weight: 600; - letter-spacing: 0; + font-size: clamp(32px, 4.5vw, 52px); + font-weight: 800; + letter-spacing: -.025em; color: var(--text); line-height: 1.05; } h2 { font-family: var(--font-display); font-size: 18px; - font-weight: 600; + font-weight: 700; + letter-spacing: -.01em; color: var(--text-strong); } h3 { font-size: 15px; font-weight: 600; + letter-spacing: -.005em; color: var(--text-strong); } /* page intro */ -.hero { padding: 28px 0 22px; } +.hero { padding: 32px 0 24px; } .hero__kicker { font-family: var(--font-mono); font-size: 10.5px; @@ -350,6 +562,7 @@ h3 { text-transform: uppercase; color: var(--accent); margin: 0 0 10px; + display: block; } .session-number { color: var(--accent); @@ -358,9 +571,9 @@ h3 { } .hero__title { font-family: var(--font-display); - font-size: clamp(30px,4.4vw,46px); - font-weight: 600; - letter-spacing: 0; + font-size: clamp(32px, 4.5vw, 52px); + font-weight: 800; + letter-spacing: -.025em; color: var(--text); line-height: 1.05; margin: 0 0 10px; @@ -382,10 +595,12 @@ h3 { ================================================================ */ .panel { background: var(--surface); - border: 1px solid var(--border); + border: var(--glass-border); border-radius: var(--r-lg); + box-shadow: var(--shadow-sm); overflow: hidden; min-width: 0; + transition: box-shadow 180ms, transform 180ms; } .panel__head { padding: 16px 22px; @@ -398,9 +613,10 @@ h3 { } .panel__title { font-family: var(--font-display); - font-weight: 600; - font-size: 18px; + font-weight: 700; + font-size: 17px; margin: 0; + letter-spacing: -.01em; color: var(--text-strong); } .panel__tag { font-family: var(--font-mono); font-size: 11px; color: var(--faintest); } @@ -421,23 +637,28 @@ h3 { STAT / METRIC TILE ================================================================ */ .stat { - padding: 15px 16px; + padding: 18px 20px; background: var(--surface); - border: 1px solid var(--border); + border: var(--glass-border); border-radius: var(--r-md); + box-shadow: var(--shadow-sm); + position: relative; + overflow: hidden; } .stat__val { font-family: var(--font-display); - font-size: 26px; - font-weight: 600; + font-size: 28px; + font-weight: 700; line-height: 1; - margin: 4px 0 0; + margin: 6px 0 0; + letter-spacing: -.02em; } .stat__sub { font-family: var(--font-mono); - font-size: 11.5px; + font-size: 11px; color: var(--muted); margin-top: 8px; + display: block; } .stat--hero .stat__val { font-size: 34px; } .player-rank { @@ -474,13 +695,14 @@ h3 { line-height: 1; white-space: nowrap; } -.stat--rail { position: relative; padding-left: 20px; } +.stat--rail { position: relative; padding-left: 20px; background: linear-gradient(135deg, var(--surface) 50%, color-mix(in srgb, var(--rail, var(--accent)) 6%, transparent) 100%); } .stat--rail::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--rail, var(--accent)); + border-radius: 0 2px 2px 0; } /* hero stat row (small inline tiles) */ @@ -684,7 +906,7 @@ fieldset[disabled] button { color: var(--text-body); } .tbl tbody tr { cursor: pointer; transition: background 80ms; } -.tbl tbody tr:hover { background: #1e1e25; } +.tbl tbody tr:hover { background: rgba(155,140,240,.04); } .tbl .num-net { font-weight: 600; font-size: 15px; } .tbl .num-pos { color: var(--pos); } .tbl .num-neg { color: var(--neg); } @@ -778,7 +1000,7 @@ th { border-bottom: 1px solid var(--border); } tbody tr { transition: background 80ms; } -tbody tr:hover { background: #1e1e25; } +tbody tr:hover { background: rgba(155,140,240,.04); } 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); } @@ -1144,8 +1366,8 @@ td a:hover { color: var(--accent); } 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--primary { background: var(--accent); color: var(--accent-ink); box-shadow: 0 2px 12px var(--accent-a30); font-weight: 700; } +.btn--primary:hover { opacity: .88; box-shadow: 0 4px 20px var(--accent-a30); } .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); } @@ -1164,11 +1386,11 @@ td a:hover { color: var(--accent); } cursor: pointer; background: var(--accent); color: var(--accent-ink); - box-shadow: 0 4px 16px var(--accent-a30); + box-shadow: 0 2px 12px var(--accent-a30); text-decoration: none; - transition: opacity 120ms; + transition: opacity 120ms, box-shadow 120ms; } -.primary-button:hover { opacity: .88; } +.primary-button:hover { opacity: .88; box-shadow: 0 4px 20px var(--accent-a30); } .secondary-button, .ghost-button { display: inline-flex; align-items: center; @@ -1743,8 +1965,11 @@ select.control { cursor: pointer; } .csv__drop.is-drag { border-color: var(--accent); color: var(--accent); } /* Form shell (admin login) */ -.form-shell { display: grid; justify-content: center; padding-top: 40px; } -.form-shell.narrow { grid-template-columns: minmax(320px, 480px); } +.form-shell { display: grid; justify-content: center; padding-top: 48px; } +.form-shell.narrow { grid-template-columns: minmax(320px, 440px); } +.form-shell .panel { + box-shadow: 0 24px 64px rgba(0,0,0,.28), 0 0 0 1px var(--border-strong); +} /* Account / league shell */ .league-grid { @@ -1766,11 +1991,12 @@ select.control { cursor: pointer; } gap: 8px; padding: 18px; color: inherit; - transition: border-color 140ms, transform 140ms; + transition: border-color 160ms, transform 160ms, box-shadow 160ms; } .league-card:hover { - border-color: var(--border-hi); - transform: translateY(-1px); + border-color: var(--accent); + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(0,0,0,.18), 0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent); } .league-card--disabled { opacity: .72; @@ -1806,6 +2032,7 @@ select.control { cursor: pointer; } align-items: stretch; overflow: hidden; border-top: 2px solid var(--accent); + transition: box-shadow 180ms; } .league-row__identity { grid-area: identity; @@ -1837,6 +2064,10 @@ select.control { cursor: pointer; } .league-row__title:hover h2 { color: var(--accent); } +.league-row:hover { + box-shadow: var(--shadow-lg), 0 0 0 1px color-mix(in srgb, var(--accent) 22%, transparent); + transform: translateY(-2px); +} .league-row__desc { margin: 0; color: var(--muted); @@ -2000,16 +2231,21 @@ select.control { cursor: pointer; } padding: 16px; } .player-card { - border: 1px solid var(--border); - border-radius: 8px; - padding: 16px; + border: var(--glass-border); + border-radius: var(--r-md); + padding: 18px; display: flex; flex-direction: column; gap: 12px; text-decoration: none; - transition: border-color 0.15s; + box-shadow: var(--shadow-sm); + transition: border-color 0.18s, box-shadow 0.18s, transform 0.18s; +} +.player-card:hover { + border-color: color-mix(in srgb, var(--accent) 40%, transparent); + box-shadow: var(--shadow-lg), 0 0 0 1px color-mix(in srgb, var(--accent) 18%, transparent); + transform: translateY(-2px); } -.player-card:hover { border-color: var(--accent); } .player-card--archived { opacity: 0.5; } .player-card__head { display: flex; @@ -2551,6 +2787,43 @@ select.control { cursor: pointer; } .hero__title, h1 { font-size: 24px; } } +/* ================================================================ + PAGE HEADER (app pages) + ================================================================ */ +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 24px; + padding-bottom: 20px; + border-bottom: 1px solid var(--border); +} +.page-header__chips { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; +} +.page-header__title { + font-size: clamp(20px, 2.4vw, 26px); + font-weight: 700; + letter-spacing: -.025em; + color: var(--text-strong); + margin: 0 0 3px; +} +.page-header__sub { + font-size: .875rem; + color: var(--muted); + margin: 0; +} +.page-header__actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + /* ================================================================ LEAGUE DASHBOARD ================================================================ */ @@ -2675,50 +2948,78 @@ select.control { cursor: pointer; } margin-bottom: 20px; } -/* Navigation card grid */ -.dashboard-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 14px; -} -.dashboard-card { - position: relative; - display: grid; - grid-template-rows: auto auto 1fr auto; - gap: 8px; - padding: 20px; - padding-top: 22px; - color: inherit; - text-decoration: none; - overflow: hidden; - transition: border-color 140ms, transform 140ms, box-shadow 140ms; - min-height: 152px; -} -.dashboard-card::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 3px; - background: var(--card-accent, var(--border-strong)); - border-radius: var(--r-lg) var(--r-lg) 0 0; - transition: opacity 140ms; -} -.dashboard-card:hover { - border-color: var(--border-hi); - transform: translateY(-2px); - box-shadow: 0 10px 28px rgba(0,0,0,.22); -} -.dashboard-card--live { - border-color: var(--pos-tint-bd); - background: linear-gradient(160deg, rgba(111,192,147,.07) 0%, transparent 55%), var(--surface); -} -.dashboard-card--live:hover { border-color: var(--pos); } -.dashboard-card__top { +/* KPI tile row (league dashboard) */ +.db-kpi-row { display: flex; align-items: center; - justify-content: space-between; + gap: 0; + margin-bottom: 24px; + padding: 14px 20px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-lg); +} +.db-kpi { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + padding: 0 20px; +} +.db-kpi:first-child { padding-left: 0; } +.db-kpi:last-child { padding-right: 0; } +.db-kpi + .db-kpi { border-left: 1px solid var(--border); } +.db-kpi__label { + font-size: 10.5px; + font-weight: 500; + letter-spacing: .07em; + text-transform: uppercase; + color: var(--muted); +} +.db-kpi__val { + font-size: 22px; + font-weight: 800; + letter-spacing: -.03em; + color: var(--text-strong); + line-height: 1.1; +} +.db-kpi__sub { + font-size: 11px; + color: var(--muted); + margin-top: 1px; +} + +/* Navigation card list */ +.dashboard-grid { + display: flex; + flex-direction: column; + gap: 0; + border: 1px solid var(--border); + border-radius: var(--r-lg); + overflow: hidden; + background: var(--border); +} +.dashboard-card { + display: flex; + align-items: center; + gap: 16px; + padding: 16px 20px; + color: inherit; + text-decoration: none; + background: var(--surface); + transition: background 120ms; +} +.dashboard-card:hover { + background: var(--surface-head); +} +.dashboard-card--live { + border-left: 3px solid var(--pos); + padding-left: 17px; +} +.dashboard-card__body { flex: 1; min-width: 0; } +.dashboard-card__top { + display: flex; + align-items: baseline; gap: 8px; } .dashboard-card__top .kicker { margin: 0; } @@ -2729,30 +3030,34 @@ select.control { cursor: pointer; } } .dashboard-card__title { display: block; - font-size: 21px; - font-weight: 700; + font-size: 15px; + font-weight: 600; color: var(--text-strong); } .dashboard-card__desc { - margin: 0; + margin: 2px 0 0; color: var(--muted); - font-size: 13.5px; - line-height: 1.45; + font-size: 12.5px; + line-height: 1.4; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .dashboard-card__arrow { - font-size: 18px; + font-size: 16px; color: var(--faintest); - justify-self: end; - transition: color 140ms, transform 140ms; + flex: none; + transition: color 120ms, transform 120ms; } .dashboard-card:hover .dashboard-card__arrow { - color: var(--card-accent, var(--accent)); + color: var(--muted); transform: translateX(3px); } @media (max-width: 640px) { .db-stats { grid-template-columns: 1fr 1fr; } - .dashboard-grid { grid-template-columns: 1fr; } + .db-kpi-row { flex-wrap: wrap; } + .db-kpi { min-width: 40%; } .db-hero { flex-direction: column; gap: 14px; } .db-hero__bg-letter { font-size: 140px; } } @@ -2763,52 +3068,48 @@ select.control { cursor: pointer; } inset: 0; z-index: -1; pointer-events: none; - background: - radial-gradient(ellipse 70% 55% at 12% 18%, rgba(155,140,240,.11) 0%, transparent 65%), - radial-gradient(ellipse 50% 45% at 88% 78%, rgba(111,192,147,.07) 0%, transparent 55%), - radial-gradient(ellipse 55% 50% at 68% -8%, rgba(155,140,240,.06) 0%, transparent 60%); } .l-bg::after { content: ""; position: absolute; inset: 0; - background-image: radial-gradient(circle, rgba(255,255,255,.032) 1px, transparent 1px); - background-size: 28px 28px; - mask-image: radial-gradient(ellipse 90% 90% at 50% 40%, black 30%, transparent 100%); + background: + radial-gradient(ellipse 75% 60% at 12% 12%, rgba(155,140,240,.07) 0%, transparent 55%), + radial-gradient(ellipse 55% 45% at 88% 88%, rgba(111,192,147,.04) 0%, transparent 52%); } .l-hero { - padding: clamp(52px, 8vw, 96px) 0 clamp(36px, 5vw, 52px); - max-width: 700px; + padding: clamp(72px, 10vw, 128px) 0 clamp(44px, 6vw, 68px); + max-width: 820px; + position: relative; } .l-hero__kicker { - display: inline-block; - padding: 4px 12px; - border-radius: var(--r-pill); - background: var(--accent-chip); - border: 1px solid var(--accent-chip-bd); - color: var(--accent); + display: block; font-family: var(--font-mono); font-size: 10.5px; - letter-spacing: .10em; + letter-spacing: .12em; text-transform: uppercase; font-weight: 500; - margin-bottom: 22px; + color: var(--muted); + margin-bottom: 20px; } .l-hero__h1 { - font-size: clamp(36px, 5.8vw, 66px); - font-weight: 700; - line-height: 1.04; - letter-spacing: -.02em; + font-size: clamp(48px, 8vw, 96px); + font-weight: 800; + line-height: 1.0; + letter-spacing: -.035em; color: var(--text); - margin: 0 0 18px; + margin: 0 0 22px; +} +.l-hero__accent { + color: var(--accent); } .l-hero__sub { font-size: clamp(15px, 1.8vw, 17px); color: var(--muted); line-height: 1.65; - margin: 0 0 28px; - max-width: 54ch; + margin: 0 0 30px; + max-width: 56ch; } .l-hero__cta { display: flex; @@ -2816,23 +3117,63 @@ select.control { cursor: pointer; } gap: 10px; } +.l-stat-strip { + display: flex; + align-items: center; + gap: 0; + margin-top: 40px; + padding: 18px 24px; + background: var(--surface); + border: var(--glass-border); + border-radius: var(--r-lg); + box-shadow: var(--shadow-sm); +} +.l-stat-strip__item { + display: flex; + flex-direction: column; + gap: 3px; + flex: 1; + padding: 0 24px; +} +.l-stat-strip__item:first-child { padding-left: 0; } +.l-stat-strip__item:last-child { padding-right: 0; } +.l-stat-strip__item + .l-stat-strip__item { + border-left: 1px solid var(--border); +} +.l-stat-strip__label { + font-size: 10.5px; + font-weight: 500; + letter-spacing: .08em; + text-transform: uppercase; + color: var(--muted); +} +.l-stat-strip__val { + font-size: 22px; + font-weight: 800; + letter-spacing: -.025em; + color: var(--text-strong); + line-height: 1; +} +.l-stat-strip__val--accent { color: var(--accent); } + /* Preview strip */ .l-preview { display: grid; - grid-template-columns: 1fr 1.25fr 1fr; - gap: 10px; - margin-bottom: clamp(48px, 7vw, 76px); + grid-template-columns: 1fr 1.35fr 1fr; + gap: 14px; + margin-bottom: clamp(36px, 5vw, 56px); align-items: start; } .l-preview__panel { background: var(--surface); - border: 1px solid var(--border); + border: var(--glass-border); border-radius: var(--r-lg); overflow: hidden; + box-shadow: var(--shadow-md); } .l-preview__panel--featured { border-color: var(--accent-chip-bd); - box-shadow: 0 0 0 1px var(--accent-chip-bd), 0 8px 32px rgba(155,140,240,.10); + box-shadow: var(--shadow-xl), 0 0 0 1px var(--accent-chip-bd); } .l-preview__head { display: flex; @@ -2939,33 +3280,34 @@ select.control { cursor: pointer; } /* Feature grid */ .l-features { display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 1px; - background: var(--border); + grid-template-columns: repeat(2, 1fr); + gap: 0; border: 1px solid var(--border); border-radius: var(--r-lg); overflow: hidden; - margin-bottom: clamp(40px, 6vw, 64px); + margin-bottom: clamp(40px, 6vw, 72px); } .l-feature { - background: var(--surface); - padding: 26px 22px; - display: grid; - align-content: start; - gap: 10px; + padding: 28px 28px; + display: flex; + align-items: flex-start; + gap: 16px; + border-right: 1px solid var(--border); + border-bottom: 1px solid var(--border); } +.l-feature:nth-child(2n) { border-right: none; } +.l-feature:nth-last-child(-n+2) { border-bottom: none; } .l-feature__icon { display: flex; align-items: center; justify-content: center; - width: 42px; - height: 42px; - border-radius: var(--r-sm); - background: var(--accent-chip); - border: 1px solid var(--accent-chip-bd); - color: var(--accent); - margin-bottom: 4px; + width: 36px; + height: 36px; + flex: none; + color: var(--muted); + margin-top: 2px; } +.l-feature__body { display: grid; gap: 6px; } .l-feature__title { font-size: 15px; font-weight: 600; @@ -2999,17 +3341,19 @@ select.control { cursor: pointer; } display: flex; flex-direction: column; gap: 14px; - padding: 18px 20px; - border: 1px solid var(--border); + padding: 20px 22px; + border: var(--glass-border); border-radius: var(--r-lg); background: var(--surface); + box-shadow: var(--shadow-sm); text-decoration: none; color: inherit; - transition: border-color 0.15s, box-shadow 0.15s; + transition: border-color 0.18s, box-shadow 0.18s, transform 0.18s; } .explore-card:hover { border-color: var(--accent); - box-shadow: 0 2px 12px color-mix(in srgb, var(--accent) 10%, transparent); + box-shadow: 0 4px 24px color-mix(in srgb, var(--accent) 12%, transparent), 0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent); + transform: translateY(-2px); } .explore-card__head { display: flex; flex-direction: column; gap: 4px; } .explore-card__name { font-size: 16px; font-weight: 700; color: var(--text-strong); margin: 0; } @@ -3213,6 +3557,23 @@ select.control { cursor: pointer; } .l-features { grid-template-columns: 1fr; } + .l-stat-strip { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0; + padding: 14px 16px; + } + .l-stat-strip__item { + padding: 10px 12px; + border-left: none !important; + border-top: 1px solid var(--border); + } + .l-stat-strip__item:first-child, + .l-stat-strip__item:nth-child(2) { + border-top: none; + } + .l-stat-strip__item:nth-child(odd) { border-left: none !important; } + .l-stat-strip__item:nth-child(even) { border-left: 1px solid var(--border) !important; } .league-hub-summary, .league-row__metrics { grid-template-columns: 1fr 1fr; @@ -3352,3 +3713,1391 @@ select.control { cursor: pointer; } .card-menu__item:hover { background: var(--field); color: var(--text); } .card-menu__item--warn { color: var(--neg); } .card-menu__item--warn:hover { background: var(--neg-tint); color: var(--neg); } + +/* ================================================================ + LANDING v2 (lp-* classes) — split hero + ================================================================ */ + +/* Remove default padding; hero handles its own layout */ +.page--landing { + padding: 0; + max-width: none; +} +.page--landing .flash-stack { + max-width: var(--w-public); + margin: 0 auto; + padding: 16px var(--gutter) 0; + width: 100%; +} +.page--landing .site-footer { + max-width: var(--w-public); + margin: 0 auto; + padding-left: var(--gutter); + padding-right: var(--gutter); +} + +/* ================================================================ + LANDING PAGE — split hero + ================================================================ */ +.lp-hero { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: clamp(48px, 7vw, 100px); + min-height: calc(100vh - 62px); + padding: clamp(56px, 6vw, 80px) clamp(24px, 5vw, 80px); + position: relative; + overflow: hidden; + background: + radial-gradient(ellipse 90% 70% at 50% 110%, #1c0e3e 0%, #100820 55%, var(--bg) 100%), + radial-gradient(ellipse 55% 50% at 85% 20%, rgba(155,140,240,.09) 0%, transparent 60%); +} +.lp-hero::before { + content: "♠"; + position: absolute; + font-size: clamp(420px, 55vw, 820px); + color: rgba(155,140,240,.055); + line-height: 1; + pointer-events: none; + user-select: none; + top: 50%; + left: 5%; + right: auto; + transform: translateY(-50%); +} + +.lp-hero__content { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: flex-start; + flex: 1; + max-width: 540px; +} +.lp-hero__visual { + position: relative; + z-index: 1; + flex: 1; + min-width: 0; + max-width: 520px; +} + +.lp-eyebrow { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 5px 14px; + border-radius: 999px; + background: var(--accent-chip); + border: 1px solid var(--accent-chip-bd); + color: var(--accent); + font: 600 10.5px var(--font-mono); + letter-spacing: .10em; + text-transform: uppercase; + margin-bottom: 20px; + align-self: flex-start; +} +.lp-eyebrow::before { + content: ""; + width: 5px; height: 5px; + border-radius: 50%; + background: var(--accent); + flex-shrink: 0; +} + +.lp-h1 { + font-size: clamp(48px, 7vw, 96px); + font-weight: 900; + letter-spacing: -.045em; + line-height: .88; + color: var(--text); + margin: 0 0 24px; +} +.lp-h1__line { display: block; } +.lp-h1__accent { display: block; color: var(--accent); } + +.lp-sub { + font-size: clamp(14px, 1.2vw, 16px); + color: rgba(255,255,255,.55); + line-height: 1.65; + max-width: 44ch; + margin: 0 0 28px; +} + +.lp-cta { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 20px; } +.btn--lg { padding: 13px 26px; font-size: 15px; border-radius: var(--r-md); } + +.lp-trust { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + font-size: 11.5px; + color: rgba(255,255,255,.3); + margin-bottom: 0; +} +.lp-trust__sep { color: rgba(255,255,255,.15); } + +/* ---- Demo card ---- */ +.lp-demo { + position: relative; + z-index: 1; + width: 100%; + max-width: 860px; + margin-top: 52px; +} +.lp-demo__card { + background: rgba(14,12,22,.9); + border: 1px solid rgba(155,140,240,.22); + border-radius: var(--r-lg); + overflow: hidden; + backdrop-filter: blur(16px); + box-shadow: + 0 0 0 1px rgba(155,140,240,.07), + 0 28px 72px rgba(0,0,0,.55), + 0 0 120px rgba(155,140,240,.07); +} +.lp-demo__head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 22px; + border-bottom: 1px solid rgba(255,255,255,.07); + background: rgba(255,255,255,.03); + gap: 12px; +} +.lp-demo__kicker { + display: block; + font-size: .6rem; + font-family: var(--font-mono); + letter-spacing: .12em; + text-transform: uppercase; + color: var(--accent); + margin-bottom: 3px; +} +.lp-demo__title { + font-size: .875rem; + font-weight: 600; + color: rgba(255,255,255,.82); +} +.lp-demo__meta { + font-size: .72rem; + font-family: var(--font-mono); + color: rgba(255,255,255,.3); + letter-spacing: .04em; + white-space: nowrap; +} +.lp-demo__table { padding: 0; } +.lp-demo__thead, +.lp-demo__row { + display: grid; + grid-template-columns: 36px 1fr repeat(4, minmax(70px, 90px)); + padding: 0 22px; + align-items: center; +} +.lp-demo__thead { + padding-top: 9px; + padding-bottom: 9px; + background: rgba(255,255,255,.025); + border-bottom: 1px solid rgba(255,255,255,.06); +} +.lp-demo__th { + font-family: var(--font-mono); + font-size: .6rem; + letter-spacing: .1em; + text-transform: uppercase; + color: rgba(255,255,255,.28); + text-align: right; +} +.lp-demo__th:nth-child(1), +.lp-demo__th:nth-child(2) { text-align: left; } +.lp-demo__row { + padding-top: 11px; + padding-bottom: 11px; + border-bottom: 1px solid rgba(255,255,255,.04); + transition: background .1s; +} +.lp-demo__row:last-child { border-bottom: none; } +.lp-demo__row:hover { background: rgba(155,140,240,.05); } +.lp-demo__row--fade { opacity: .65; } +.lp-demo__row--fade2 { opacity: .38; } +.lp-demo__rank { + font-family: var(--font-mono); + font-size: .8125rem; + font-weight: 700; + color: rgba(255,255,255,.28); + text-align: left; +} +.lp-demo__rank--1 { color: #f4c430; } +.lp-demo__rank--2 { color: #b0b8c1; } +.lp-demo__rank--3 { color: #c87941; } +.lp-demo__player { + font-size: .875rem; + font-weight: 500; + color: rgba(255,255,255,.82); +} +.lp-demo__num { + font-family: var(--font-mono); + font-size: .8125rem; + color: rgba(255,255,255,.38); + text-align: right; + font-variant-numeric: tabular-nums; +} +.lp-demo__num--pos { color: var(--pos); } +.lp-demo__num--neg { color: var(--neg); } + +/* ---- Leaderboard demo variant (with rank-delta column) ---- */ +.lp-demo__thead--lb, +.lp-demo__row--lb { + grid-template-columns: 36px 1fr 36px repeat(3, minmax(60px, 80px)); +} +.lp-demo__delta { + font-family: var(--font-mono); + font-size: .72rem; + font-weight: 700; + color: rgba(255,255,255,.22); + text-align: left; +} +.lp-demo__delta--up { color: var(--pos); } +.lp-demo__delta--down { color: var(--neg); } + +/* ---- Shared section header ---- */ +.lp-sec-head { + text-align: center; + margin-bottom: 32px; +} +.lp-sec-title { + font-size: clamp(20px, 2.4vw, 28px); + font-weight: 800; + color: var(--text); + letter-spacing: -.03em; + margin: 10px 0 0; + line-height: 1.2; +} +.lp-sec-sub { + font-size: .875rem; + color: rgba(255,255,255,.4); + margin: 8px 0 0; + line-height: 1.6; +} + +/* ================================================================ + FEATURES SECTION + ================================================================ */ +.lp-features { + padding: clamp(48px, 5vw, 72px) clamp(24px, 5vw, 80px); + border-top: 1px solid rgba(255,255,255,.06); +} +.lp-features__inner { + max-width: var(--w-public); + margin: 0 auto; +} +.lp-feat-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0; + background: rgba(255,255,255,.05); + border: 1px solid rgba(255,255,255,.07); + border-radius: var(--r-lg); + overflow: hidden; +} +.lp-feat { + padding: 24px 28px; + background: var(--bg); + display: flex; + flex-direction: column; + gap: 8px; +} +.lp-feat + .lp-feat { border-left: 1px solid rgba(255,255,255,.06); } +.lp-feat__icon { + width: 32px; + height: 32px; + color: var(--accent); + opacity: .85; + margin-bottom: 2px; +} +.lp-feat__icon svg { width: 100%; height: 100%; } +.lp-feat__title { + font-size: .9375rem; + font-weight: 700; + color: var(--text-strong); + letter-spacing: -.015em; +} +.lp-feat__desc { + font-size: .85rem; + color: rgba(255,255,255,.42); + line-height: 1.7; +} + +/* ================================================================ + SHOWCASE SECTION + ================================================================ */ +.lp-showcase { + padding: clamp(48px, 5vw, 72px) clamp(24px, 5vw, 80px); + border-top: 1px solid rgba(255,255,255,.06); + background: rgba(155,140,240,.018); +} +.lp-showcase__inner { + max-width: var(--w-public); + margin: 0 auto; +} +.lp-showcase__grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 20px; + align-items: start; +} + +/* ---- Sessions demo ---- */ +.lp-sess-list { padding: 0; } +.lp-sess-row { + display: flex; + align-items: center; + gap: 14px; + padding: 12px 22px; + border-bottom: 1px solid rgba(255,255,255,.04); + transition: background .1s; +} +.lp-sess-row:last-child { border-bottom: none; } +.lp-sess-row:hover { background: rgba(155,140,240,.05); } +.lp-sess-row--fade { opacity: .6; } +.lp-sess-row--fade2 { opacity: .32; } +.lp-sess-date { + flex-shrink: 0; + width: 38px; + text-align: center; + background: rgba(255,255,255,.04); + border: 1px solid rgba(255,255,255,.07); + border-radius: var(--r-sm); + padding: 4px 0; +} +.lp-sess-date__mon { + display: block; + font-family: var(--font-mono); + font-size: .58rem; + letter-spacing: .1em; + text-transform: uppercase; + color: var(--accent); + line-height: 1; +} +.lp-sess-date__day { + display: block; + font-size: .9375rem; + font-weight: 700; + color: rgba(255,255,255,.75); + line-height: 1.2; + margin-top: 1px; +} +.lp-sess-info { flex: 1; min-width: 0; } +.lp-sess-label { + display: block; + font-size: .8125rem; + font-weight: 500; + color: rgba(255,255,255,.8); + line-height: 1.3; +} +.lp-sess-meta { + display: block; + font-size: .72rem; + font-family: var(--font-mono); + color: rgba(255,255,255,.3); + margin-top: 2px; +} +.lp-sess-pill { + flex-shrink: 0; + font-family: var(--font-mono); + font-size: .6rem; + letter-spacing: .09em; + text-transform: uppercase; + padding: 3px 8px; + border-radius: 999px; + font-weight: 600; +} +.lp-sess-pill--open { + background: rgba(111,192,147,.15); + border: 1px solid rgba(111,192,147,.3); + color: var(--pos); +} +.lp-sess-pill--closed { + background: rgba(255,255,255,.05); + border: 1px solid rgba(255,255,255,.1); + color: rgba(255,255,255,.35); +} + +/* ---- Ledger / settlement demo ---- */ +.lp-ledger-stats { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0; + border-bottom: 1px solid rgba(255,255,255,.06); +} +.lp-ledger-stat { + padding: 14px 22px; + border-right: 1px solid rgba(255,255,255,.05); + border-bottom: 1px solid rgba(255,255,255,.05); +} +.lp-ledger-stat:nth-child(2n) { border-right: none; } +.lp-ledger-stat:nth-last-child(-n+2) { border-bottom: none; } +.lp-ledger-stat__label { + display: block; + font-family: var(--font-mono); + font-size: .6rem; + letter-spacing: .1em; + text-transform: uppercase; + color: rgba(255,255,255,.28); + margin-bottom: 4px; +} +.lp-ledger-stat__val { + font-family: var(--font-mono); + font-size: .9375rem; + font-weight: 700; + color: rgba(255,255,255,.7); + font-variant-numeric: tabular-nums; +} +.lp-settle-section { padding: 0; } +.lp-settle-head { + padding: 9px 22px; + font-family: var(--font-mono); + font-size: .6rem; + letter-spacing: .1em; + text-transform: uppercase; + color: rgba(255,255,255,.28); + background: rgba(255,255,255,.025); + border-bottom: 1px solid rgba(255,255,255,.06); +} +.lp-settle-row { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 22px; + border-bottom: 1px solid rgba(255,255,255,.04); + transition: background .1s; +} +.lp-settle-row:last-child { border-bottom: none; } +.lp-settle-row:hover { background: rgba(155,140,240,.05); } +.lp-settle-row .lp-demo__meta { flex: 1; text-align: left; } + +/* ---- Session results demo (recon metrics + player table) ---- */ +.lp-recon-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1px; + background: rgba(255,255,255,.07); + border-bottom: 1px solid rgba(255,255,255,.07); +} +.lp-recon-cell { + background: rgba(10,9,18,.92); + padding: 12px 18px; +} +.lp-recon-label { + display: block; + font-family: var(--font-mono); + font-size: .595rem; + letter-spacing: .09em; + text-transform: uppercase; + color: rgba(255,255,255,.28); + margin-bottom: 5px; +} +.lp-recon-val { + font-family: var(--font-mono); + font-size: 1.1rem; + font-weight: 700; + color: rgba(255,255,255,.72); + font-variant-numeric: tabular-nums; +} +.lp-result-thead { + display: grid; + grid-template-columns: 1fr 64px 72px 90px; + padding: 7px 18px; + background: rgba(255,255,255,.025); + border-bottom: 1px solid rgba(255,255,255,.06); +} +.lp-result-thead > span { + font-family: var(--font-mono); + font-size: .595rem; + letter-spacing: .09em; + text-transform: uppercase; + color: rgba(255,255,255,.28); + text-align: right; +} +.lp-result-thead > span:first-child { text-align: left; } +.lp-result-row { + display: grid; + grid-template-columns: 1fr 64px 72px 90px; + padding: 9px 18px; + border-bottom: 1px solid rgba(255,255,255,.04); + align-items: center; + transition: background .1s; +} +.lp-result-row:last-child { border-bottom: none; } +.lp-result-row:hover { background: rgba(155,140,240,.05); } +.lp-result-due { + font-family: var(--font-mono); + font-size: .72rem; + color: var(--neg); + opacity: .75; + text-align: right; +} +.lp-result-settled { + font-family: var(--font-mono); + font-size: .72rem; + color: rgba(255,255,255,.22); + text-align: right; +} + +/* ================================================================ + BOTTOM CTA SECTION + ================================================================ */ +.lp-bottom-cta { + padding: clamp(48px, 5vw, 72px) clamp(24px, 5vw, 80px); + text-align: center; + border-top: 1px solid rgba(255,255,255,.06); + background: radial-gradient(ellipse 70% 90% at 50% 100%, #1c0e3e 0%, var(--bg) 70%); +} +.lp-bottom-cta__inner { + max-width: 600px; + margin: 0 auto; +} +.lp-bottom-cta__title { + font-size: clamp(24px, 3vw, 36px); + font-weight: 800; + color: var(--text); + letter-spacing: -.03em; + margin: 0 0 12px; +} +.lp-bottom-cta__sub { + font-size: .9375rem; + color: rgba(255,255,255,.4); + margin: 0 0 28px; + line-height: 1.6; +} + +/* ================================================================ + LANDING RESPONSIVE + ================================================================ */ +@media (max-width: 960px) { + .lp-hero { + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + min-height: 0; + } + .lp-hero__content { max-width: 100%; } + .lp-hero__visual { max-width: 100%; width: 100%; } + .lp-feat-grid { grid-template-columns: 1fr; } + .lp-feat + .lp-feat { border-left: none; border-top: 1px solid rgba(255,255,255,.06); } +} +@media (max-width: 680px) { + .lp-hero__visual { display: none; } + .lp-showcase__grid { grid-template-columns: 1fr; } + .lp-demo__thead--lb, + .lp-demo__row--lb { grid-template-columns: 28px 1fr 32px 80px; } + .lp-demo__thead--lb > span:nth-child(5), + .lp-demo__thead--lb > span:nth-child(6), + .lp-demo__row--lb > span:nth-child(5), + .lp-demo__row--lb > span:nth-child(6) { display: none; } +} +@media (max-width: 440px) { + .lp-hero { padding-left: 16px; padding-right: 16px; } + .lp-features, + .lp-showcase, + .lp-bottom-cta { padding-left: 16px; padding-right: 16px; } + .lp-feat-grid { border-radius: var(--r-md); } +} + + +/* ================================================================ + DASHBOARD OVERVIEW REDESIGN + ================================================================ */ +.db-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 28px; +} +.db-header__chips { + display: flex; + gap: 6px; + margin-bottom: 10px; + flex-wrap: wrap; + align-items: center; +} +.db-header__title { + font-size: clamp(22px, 3vw, 30px); + font-weight: 800; + color: var(--text-strong); + letter-spacing: -.025em; + line-height: 1.15; + margin: 0; +} +.db-header__sub { + margin: 6px 0 0; + color: var(--text-muted); + font-size: .875rem; + line-height: 1.5; +} +.db-header__actions { + display: flex; + gap: 8px; + flex-shrink: 0; + padding-top: 2px; +} + +.db-live-alert { + display: flex; + align-items: center; + gap: 10px; + background: rgba(111,192,147,.07); + border: 1px solid rgba(111,192,147,.22); + border-radius: var(--r-md); + padding: 11px 16px; + margin-bottom: 24px; + font-size: .875rem; + color: var(--pos); +} +.db-live-alert__pulse { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--pos); + flex-shrink: 0; + box-shadow: 0 0 0 0 rgba(111,192,147,.4); + animation: pulse-ring 2s ease-in-out infinite; +} +@keyframes pulse-ring { + 0% { box-shadow: 0 0 0 0 rgba(111,192,147,.5); } + 70% { box-shadow: 0 0 0 6px rgba(111,192,147,0); } + 100% { box-shadow: 0 0 0 0 rgba(111,192,147,0); } +} +.db-live-alert__text { flex: 1; font-weight: 500; } +.db-live-alert__link { color: var(--pos); font-weight: 600; white-space: nowrap; } +.db-live-alert__link:hover { text-decoration: underline; } + +.db-stat-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; + margin-bottom: 24px; +} +.db-stat { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: 16px 18px; + display: flex; + align-items: center; + gap: 14px; +} +.db-stat__icon { + width: 40px; + height: 40px; + border-radius: var(--r-sm); + background: rgba(255,255,255,.05); + color: var(--text-muted); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.db-stat__icon--accent { background: var(--accent-chip); border: 1px solid var(--accent-chip-bd); color: var(--accent); } +.db-stat__icon--pos { background: rgba(111,192,147,.12); color: var(--pos); } +.db-stat__icon--warn { background: rgba(224,177,92,.12); color: var(--warn); } +.db-stat__num { + display: block; + font-size: 1.65rem; + font-weight: 800; + color: var(--text-strong); + letter-spacing: -.035em; + line-height: 1.1; +} +.db-stat__label { + display: block; + font-size: .72rem; + color: var(--text-muted); + margin-top: 3px; + font-weight: 500; + letter-spacing: .02em; +} + +.db-cash { + display: flex; + align-items: stretch; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-lg); + overflow: hidden; + margin-bottom: 16px; +} +.db-cash__stat { + flex: 1; + padding: 16px 20px; + display: flex; + flex-direction: column; + gap: 5px; + min-width: 0; +} +.db-cash__div { + width: 1px; + background: var(--border); + flex-shrink: 0; +} +.db-cash__label { + font-size: .68rem; + font-weight: 600; + color: var(--text-muted); + letter-spacing: .05em; + text-transform: uppercase; + font-family: var(--font-mono); + white-space: nowrap; +} +.db-cash__val { + font-size: 1.375rem; + font-weight: 800; + color: var(--text-strong); + letter-spacing: -.03em; + line-height: 1.1; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.db-cal { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-lg); + padding: 16px 20px 18px; + margin-bottom: 16px; + position: relative; +} +.db-cal__hdr { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} +.db-cal__title { + font-size: .68rem; + font-weight: 600; + color: var(--muted); + text-transform: uppercase; + letter-spacing: .05em; + font-family: var(--font-mono); +} +.db-cal__nav-group { + display: flex; + align-items: center; + gap: 6px; +} +.db-cal__year { + font-size: .78rem; + font-weight: 600; + color: var(--text-2); + font-family: var(--font-mono); + min-width: 34px; + text-align: center; +} +.db-cal__nav { + background: none; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--muted); + cursor: pointer; + font-size: 1rem; + line-height: 1; + padding: 1px 6px 3px; + transition: color .15s, border-color .15s; +} +.db-cal__nav:hover:not(:disabled) { + color: var(--text); + border-color: var(--border-strong); +} +.db-cal__nav:disabled { + opacity: .25; + cursor: not-allowed; +} +.db-cal__grid { + display: grid; + grid-template-rows: 14px repeat(7, 1fr); + grid-auto-flow: column; + grid-auto-columns: 1fr; + gap: 3px; + height: 132px; + width: 100%; +} +.db-cal__mc { + font-size: .59rem; + font-family: var(--font-mono); + color: var(--faintest); + letter-spacing: .02em; + display: flex; + align-items: flex-end; + padding-bottom: 1px; + user-select: none; +} +.db-cal__cell { + border-radius: 3px; + background: var(--border); + min-width: 0; + min-height: 0; +} +.db-cal__cell--on { + background: var(--accent); + opacity: .82; + cursor: default; +} +.db-cal__cell--on:hover { + opacity: 1; +} +.db-cal__cell--multi { + background: var(--accent); + opacity: 1; +} +.db-cal__cell--dim { + background: var(--surface-raised); +} +.db-cal__cell--out { + background: var(--surface-sunk); + opacity: .5; +} +.db-cal__tip { + position: fixed; + z-index: 300; + background: var(--surface-raised); + border: 1px solid var(--border-strong); + border-radius: 6px; + padding: 5px 10px; + font-size: .72rem; + color: var(--text-body); + pointer-events: none; + white-space: nowrap; + font-family: var(--font-mono); + box-shadow: 0 4px 16px rgba(0,0,0,.45); +} + +.db-nav-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; +} +.db-nav-tile { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-lg); + padding: 20px; + display: flex; + align-items: flex-start; + gap: 16px; + text-decoration: none; + transition: border-color .15s, background .15s, box-shadow .15s; + position: relative; +} +.db-nav-tile:hover { + border-color: var(--accent-chip-bd); + background: rgba(155,140,240,.04); + box-shadow: 0 4px 24px rgba(0,0,0,.18); +} +.db-nav-tile__icon { + width: 44px; + height: 44px; + border-radius: 12px; + background: var(--accent-chip); + border: 1px solid var(--accent-chip-bd); + color: var(--accent); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: background .15s; +} +.db-nav-tile__body { flex: 1; min-width: 0; } +.db-nav-tile__title-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; } +.db-nav-tile__title { + font-size: .9375rem; + font-weight: 700; + color: var(--text-strong); +} +.db-nav-tile__count { + font-size: .7rem; + color: var(--text-muted); + font-weight: 600; + background: var(--surface-sunk); + border: 1px solid var(--border); + border-radius: 999px; + padding: 2px 9px; + line-height: 1.6; + white-space: nowrap; +} +.db-nav-tile__desc { + font-size: .8125rem; + color: var(--text-muted); + margin: 0; + line-height: 1.5; +} +.db-nav-tile__arrow { + font-size: 17px; + color: var(--text-muted); + opacity: .3; + transition: opacity .15s, transform .15s; + flex-shrink: 0; + align-self: center; +} +.db-nav-tile:hover .db-nav-tile__arrow { opacity: .65; transform: translateX(4px); } + +/* Per-section tile accents */ +.db-nav-tile--sessions { + border-left: 3px solid rgba(111,192,147,.32); +} +.db-nav-tile--sessions .db-nav-tile__icon { + background: rgba(111,192,147,.12); + border: 1px solid rgba(111,192,147,.28); + color: var(--pos); +} +.db-nav-tile--sessions:hover { border-left-color: rgba(111,192,147,.55); } + +.db-nav-tile--leaderboard { + border-left: 3px solid rgba(90,168,232,.28); +} +.db-nav-tile--leaderboard .db-nav-tile__icon { + background: rgba(90,168,232,.09); + border: 1px solid rgba(90,168,232,.24); + color: #5aa8e8; +} +.db-nav-tile--leaderboard:hover { border-left-color: rgba(90,168,232,.5); } + +.db-nav-tile--players { + border-left: 3px solid rgba(155,140,240,.3); +} +.db-nav-tile--players .db-nav-tile__icon { + background: var(--accent-chip); + border: 1px solid var(--accent-chip-bd); + color: var(--accent); +} +.db-nav-tile--players:hover { border-left-color: rgba(155,140,240,.55); } + +.db-nav-tile--ledger { + border-left: 3px solid rgba(224,177,92,.28); +} +.db-nav-tile--ledger .db-nav-tile__icon { + background: rgba(224,177,92,.1); + border: 1px solid rgba(224,177,92,.24); + color: var(--warn); +} +.db-nav-tile--ledger:hover { border-left-color: rgba(224,177,92,.5); } + +/* Live tile overrides the sessions color */ +.db-nav-tile--sessions.db-nav-tile--live { + border-left-color: rgba(111,192,147,.55); + background: rgba(111,192,147,.04); +} +.db-nav-tile--sessions.db-nav-tile--live:hover { + border-left-color: rgba(111,192,147,.78); + background: rgba(111,192,147,.08); +} +.db-nav-tile--sessions.db-nav-tile--live .db-nav-tile__icon { + background: rgba(111,192,147,.18); + border-color: rgba(111,192,147,.4); +} + +@media (max-width: 960px) { + .db-stat-grid { grid-template-columns: repeat(2, 1fr); } + .db-nav-grid { grid-template-columns: 1fr; } +} +@media (max-width: 700px) { + .db-cash { flex-wrap: wrap; } + .db-cash__stat { flex: 1 1 40%; } + .db-cash__div { display: none; } + .db-cal__grid { height: 110px; } +} +@media (max-width: 560px) { + .db-header { flex-direction: column; gap: 12px; } + .db-cash__val { font-size: 1.125rem; } + .db-cal__grid { height: 96px; } + .db-cal__mc { font-size: .52rem; } +} + +/* ================================================================ + LEAGUES INDEX — card list + ================================================================ */ +.li-board { + display: flex; + flex-direction: column; + gap: 10px; +} +.li-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-lg); + overflow: hidden; + transition: border-color .15s, box-shadow .15s; + display: flex; + flex-direction: row; +} +.li-card:hover { border-color: var(--border-strong); box-shadow: 0 2px 16px rgba(0,0,0,.14); } +.li-card--live { border-color: rgba(111,192,147,.3); } +.li-card--live:hover { border-color: rgba(111,192,147,.5); } + +/* left initial badge column */ +.li-card__aside { + width: 56px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + background: var(--surface-sunk); + border-right: 1px solid var(--border); +} +.li-card--live .li-card__aside { + background: rgba(111,192,147,.05); + border-right-color: rgba(111,192,147,.2); +} +.li-card__initial { + font-size: 1.375rem; + font-weight: 800; + color: var(--accent); + letter-spacing: -.03em; + line-height: 1; + opacity: .75; + user-select: none; +} +.li-card--live .li-card__initial { color: var(--pos); } + +/* right content column */ +.li-card__body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +/* top section: name + badges + desc on left, stats on right */ +.li-card__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + padding: 18px 20px 14px; +} +.li-card__info { flex: 1; min-width: 0; } +.li-card__name-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + flex-wrap: wrap; +} +.li-card__name { + font-size: 1.05rem; + font-weight: 700; + color: var(--text-strong); + text-decoration: none; + line-height: 1.25; +} +.li-card__name:hover { color: var(--accent); } +.li-card__chips { + display: flex; + gap: 5px; + flex-wrap: wrap; + margin-bottom: 6px; +} +.li-card__desc { + font-size: .8125rem; + color: var(--text-muted); + margin: 0; + line-height: 1.5; +} +.li-card__stats { + display: flex; + gap: 20px; + flex-shrink: 0; + align-self: flex-start; + padding-top: 2px; +} +.li-stat { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; +} +.li-stat span { + font-size: .65rem; + color: var(--text-muted); + font-weight: 500; + letter-spacing: .05em; + text-transform: uppercase; + white-space: nowrap; +} +.li-stat strong { + font-size: 1rem; + font-weight: 700; + color: var(--text-strong); + font-variant-numeric: tabular-nums; + font-family: var(--font-mono); +} + +/* leaders strip — podium chips */ +.li-card__leaders { + display: flex; + align-items: center; + gap: 0; + padding: 9px 20px; + border-top: 1px solid var(--border); + background: rgba(255,255,255,.015); + overflow: hidden; +} +.li-card__leaders-label { + font-size: .6rem; + font-weight: 600; + letter-spacing: .09em; + text-transform: uppercase; + color: var(--text-muted); + margin-right: 14px; + white-space: nowrap; + flex-shrink: 0; +} +.li-podium { + display: flex; + gap: 7px; + flex: 1; + min-width: 0; + flex-wrap: wrap; +} +.li-podium-item { + display: flex; + align-items: center; + gap: 5px; + padding: 4px 10px 4px 7px; + border-radius: var(--r-md); + border: 1px solid var(--border); + background: var(--surface-sunk); + flex-shrink: 0; +} +.li-podium-item--1 { border-color: rgba(244,196,48,.3); background: rgba(244,196,48,.05); } +.li-podium-item--2 { border-color: rgba(176,184,193,.2); background: rgba(176,184,193,.04); } +.li-podium-item--3 { border-color: rgba(200,121,65,.22); background: rgba(200,121,65,.04); } +.li-podium-rank { + font-family: var(--font-mono); + font-size: .65rem; + font-weight: 700; + color: var(--text-muted); +} +.li-podium-item--1 .li-podium-rank { color: #f4c430; } +.li-podium-item--2 .li-podium-rank { color: #b0b8c1; } +.li-podium-item--3 .li-podium-rank { color: #c87941; } +.li-podium-name { + font-size: .8125rem; + color: var(--text-2); +} +.li-podium-net { + font-family: var(--font-mono); + font-size: .75rem; + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +/* footer: meta + actions */ +.li-card__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 9px 20px; + border-top: 1px solid var(--border); +} +.li-card__meta { + font-size: .75rem; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.li-card__actions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} +.li-action-link { + font-size: .75rem; + color: var(--text-muted); + padding: 4px 9px; + border-radius: var(--r-sm); + text-decoration: none; + transition: color .12s, background .12s; + font-weight: 500; +} +.li-action-link:hover { color: var(--text-2); background: var(--surface-raised); } + +@media (max-width: 640px) { + .li-card__aside { width: 44px; } + .li-card__initial { font-size: 1.1rem; } + .li-card__head { flex-direction: column; gap: 12px; } + .li-card__stats { flex-direction: row; align-self: auto; } + .li-stat { align-items: flex-start; } + .li-card__footer { flex-wrap: wrap; gap: 8px; } + .li-action-link { display: none; } +} +@media (max-width: 400px) { + .li-card__aside { display: none; } +} + + +/* ================================================================ + ACCOUNT SETTINGS REDESIGN + ================================================================ */ +.settings-profile { + display: flex; + align-items: center; + gap: 18px; + margin-bottom: 28px; + padding-bottom: 24px; + border-bottom: 1px solid var(--border); +} +.settings-avatar { + width: 52px; + height: 52px; + border-radius: 50%; + background: var(--accent-chip); + border: 2px solid var(--accent-chip-bd); + color: var(--accent); + font-size: 1.25rem; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + letter-spacing: -.01em; +} +.settings-profile__title { + font-size: 1.25rem; + font-weight: 700; + color: var(--text-strong); + margin: 0 0 4px; +} +.settings-profile__email { + font-size: .875rem; + color: var(--text-muted); + margin: 0; +} +.settings-profile__meta { display: flex; flex-direction: column; } + +/* ================================================================ + EXPLORE PAGE REDESIGN + ================================================================ */ +.explore-header { + margin-bottom: 28px; +} +.explore-header__eyebrow { + font: 600 11px var(--font-mono); + letter-spacing: .1em; + text-transform: uppercase; + color: var(--accent); + margin: 0 0 10px; +} +.explore-header__title { + font-size: clamp(22px, 3vw, 30px); + font-weight: 800; + color: var(--text-strong); + letter-spacing: -.025em; + margin: 0 0 6px; +} +.explore-header__sub { + font-size: .875rem; + color: var(--text-muted); + margin: 0; +} + +.explore-search-row { + display: flex; + gap: 8px; + margin-bottom: 28px; + max-width: 560px; +} +.explore-search-row .field-input { + flex: 1; + background: var(--surface); + border: 1px solid var(--border-strong); + border-radius: var(--r-md); + padding: 10px 14px; + font-size: .9375rem; + color: var(--text); + transition: border-color .15s, box-shadow .15s; +} +.explore-search-row .field-input:focus { + outline: none; + border-color: var(--accent-chip-bd); + box-shadow: 0 0 0 3px var(--accent-a22); +} +.explore-search-row .field-input::placeholder { color: var(--text-muted); } + +.explore-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; +} +.ex-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-lg); + padding: 20px; + text-decoration: none; + display: flex; + flex-direction: column; + gap: 0; + transition: border-color .15s, box-shadow .15s; +} +.ex-card:hover { + border-color: var(--accent-chip-bd); + box-shadow: 0 4px 24px rgba(0,0,0,.2); +} +.ex-card__initial { + width: 40px; + height: 40px; + border-radius: var(--r-sm); + background: var(--accent-chip); + border: 1px solid var(--accent-chip-bd); + color: var(--accent); + font-size: 1rem; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 14px; + flex-shrink: 0; +} +.ex-card__name { + font-size: 1rem; + font-weight: 700; + color: var(--text-strong); + margin: 0 0 6px; +} +.ex-card__desc { + font-size: .8125rem; + color: var(--text-muted); + margin: 0 0 16px; + line-height: 1.5; + flex: 1; +} +.ex-card__stats { + display: flex; + gap: 16px; + border-top: 1px solid var(--border); + padding-top: 14px; + margin-top: auto; +} +.ex-card__stat { display: flex; flex-direction: column; gap: 2px; } +.ex-card__stat .kicker { font-size: .65rem; } +.ex-card__stat strong { font-size: .9375rem; font-weight: 700; color: var(--text-strong); } +.ex-card__footer { + display: flex; + justify-content: flex-end; + padding-top: 12px; + margin-top: 2px; +} +.ex-card__link { + font-size: .75rem; + font-weight: 600; + color: var(--accent); + transition: gap .15s; +} diff --git a/static/css/tokens.css b/static/css/tokens.css index 00e4681..3e68c57 100644 --- a/static/css/tokens.css +++ b/static/css/tokens.css @@ -1,34 +1,37 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap'); + :root { /* ---- surfaces (darkest → lightest) ---- */ - --bg: #0f0f12; - --surface: #16161c; - --surface-raised:#1a1a20; - --surface-head: #1c1c24; - --surface-sunk: #131318; - --field: #1f1f27; - --field-alt: #20202a; + --bg: #0a0a0d; + --surface: #111115; + --surface-raised:#16161c; + --surface-head: #18181f; + --surface-sunk: #0d0d11; + --field: #1a1a22; + --field-alt: #1c1c25; /* ---- borders / dividers ---- */ - --border: #26262f; - --border-strong: #2e2e38; - --border-hi: #34343f; - --divider: #1d1d25; - --slash: #3a3a45; + --border: #22222b; + --border-strong: #2a2a34; + --border-hi: #32323d; + --divider: #191921; + --slash: #383843; /* ---- 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; + --text: #f4f2f9; + --text-strong: #edeaf5; + --text-body: #e8e6ef; + --text-2: #bdbbc8; + --num: #b2b0bb; + --muted: #8c8a96; + --muted-2: #82808c; + --faint: #7a7885; + --faint-2: #72707d; + --faintest: #64626d; + --faintest-2: #55535e; - /* ---- accent (violet) ---- */ + /* ---- accent ---- */ + /* To swap brand color later: edit these 7 vars only */ --accent: #9b8cf0; --accent-ink: #12101e; --accent-tint: #1f1d2e; @@ -65,12 +68,22 @@ --line-1: #9b8cf0; --line-2: #6fc093; --line-3: #e0b15c; --line-4: #cf6f86; --line-5: #8f93c2; + /* ---- elevation shadows ---- */ + --shadow-sm: 0 1px 3px rgba(0,0,0,.30), 0 1px 2px rgba(0,0,0,.22); + --shadow-md: 0 4px 16px rgba(0,0,0,.34), 0 2px 6px rgba(0,0,0,.24); + --shadow-lg: 0 12px 36px rgba(0,0,0,.42), 0 4px 12px rgba(0,0,0,.28); + --shadow-xl: 0 24px 64px rgba(0,0,0,.52), 0 8px 24px rgba(0,0,0,.32); + + /* ---- glass panel effect ---- */ + --glass-border: 1px solid rgba(255,255,255,.055); + --glass-bg: rgba(255,255,255,.02); + /* ---- 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; + --r-pill:6px; --r-sm:9px; --r-md:12px; --r-lg:16px; /* ---- containers ---- */ --w-public:1320px; @@ -79,6 +92,6 @@ --gutter: clamp(16px, 4vw, 40px); /* ---- Chart.js backward compat (read as raw strings by JS) ---- */ - --text-muted: #bcbac6; - --line: #26262f; + --text-muted: #bdbbc8; + --line: #22222b; } diff --git a/static/favicon.svg b/static/favicon.svg index fe83d79..29d8775 100644 --- a/static/favicon.svg +++ b/static/favicon.svg @@ -1,4 +1,4 @@ - + diff --git a/templates/_league_sidebar.html b/templates/_league_sidebar.html new file mode 100644 index 0000000..4fbaef3 --- /dev/null +++ b/templates/_league_sidebar.html @@ -0,0 +1,87 @@ + diff --git a/templates/account_login.html b/templates/account_login.html index da8ddb8..20ad18b 100644 --- a/templates/account_login.html +++ b/templates/account_login.html @@ -1,27 +1,68 @@ {% extends "base.html" %} -{% block title %}Login · myboker.org{% endblock %} +{% block title %}Sign in · myboker.org{% endblock %} +{% block page_class %}page--auth{% endblock %} {% block content %} -
-
- -
-

Owner access

-

Account login

-

Manage leagues, players, sessions, and ledger entries.

+
+
+
+ +
+ myboker.org +
+ +

Welcome back.

+

Sign in to manage your leagues, sessions, and ledger.

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

No account yet? Create one

- - - - Create account - Forgot password? - +
+ +
{% endblock %} diff --git a/templates/account_register.html b/templates/account_register.html index d0c65b3..3b3f5cc 100644 --- a/templates/account_register.html +++ b/templates/account_register.html @@ -1,30 +1,71 @@ {% extends "base.html" %} {% block title %}Create Account · myboker.org{% endblock %} +{% block page_class %}page--auth{% endblock %} {% block content %} -
-
- -
-

Owner account

-

Create account

-

Accounts are for league owners and managers.

+
+
+
+ +
+ myboker.org +
+ +

Start your league.

+

Create an account to manage leagues, players, and sessions.

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

Already have an account? Sign in

- - - - - Login instead - +
+ +
{% endblock %} diff --git a/templates/account_settings.html b/templates/account_settings.html index 8bbe18d..e205e82 100644 --- a/templates/account_settings.html +++ b/templates/account_settings.html @@ -2,14 +2,13 @@ {% block title %}Account settings · myboker.org{% endblock %} {% block content %} -
-

Account

-

Settings

-

Manage your email address, password, and account.

-
- -
-
+
+
{{ user.email[0] | upper }}
+
+

Account Settings

+ +
+
diff --git a/templates/base.html b/templates/base.html index 1cb7909..3fd23af 100644 --- a/templates/base.html +++ b/templates/base.html @@ -15,7 +15,7 @@
- myboker.org + myboker.org
- {% block subnav %}{% endblock %} +
+ {% block sidebar %}{% endblock %}
+ {% block subnav %}{% endblock %} {% with messages = get_flashed_messages(with_categories=true) %} {% if messages %}
@@ -66,11 +68,22 @@ {% block content %}{% endblock %}
+
+{% endif %} + +
+ + +
+ +
+
+
+ Sessions + {% if counts.open_sessions > 0 %} + {{ counts.open_sessions }} live + {% endif %} +
+

Open a session to start tracking buy-ins and cashouts in real time. Close it when everyone is done.

+
+
- -
- Roster - {{ counts.players }} active + +
+
- Players -

Add and manage player records for stats and ledger entry.

- +
+
+ Leaderboard +
+

Net profit, ROI, win rate, and rank delta. Filter by eligible players or switch to recent form.

+
+
- -
- Schedule - {% if counts.open_sessions > 0 %} - {{ counts.open_sessions }} live - {% else %} - {{ counts.sessions }} total - {% endif %} + +
+
- Sessions -

Create sessions, record events, and manage open/closed state.

- +
+
+ Players +
+

View each player's full session history, net trend, and individual stats over time.

+
+
- - diff --git a/templates/league_leaderboard.html b/templates/league_leaderboard.html index 67f87db..7d6fa2d 100644 --- a/templates/league_leaderboard.html +++ b/templates/league_leaderboard.html @@ -1,12 +1,13 @@ {% extends "base.html" %} {% block title %}Leaderboard · {{ league.name }} · myboker.org{% endblock %} -{% block subnav %}{% include "_league_subnav.html" %}{% endblock %} +{% block sidebar %}{% include "_league_sidebar.html" %}{% endblock %} +{% block page_class %}page--app{% endblock %} {% block content %} -
+
- No players yet — add the first event from the admin page. + No players yet.
+ + + {% for league, session_count in top_leagues %} + {% set owner = owners.get(league.id) %} + + + + + + {% else %} + + {% endfor %} + +
LeagueOwnerSessions
+ {{ league.name }} + {% if league.visibility == 'public' %}public{% endif %} + {% if owner %}{{ owner.email }}{% else %}{% endif %}{{ session_count }}
No leagues yet.
+
+
+ + +
+
+

Recorded ledger volume

+ Not platform revenue +
+
+
+
Recorded buy-ins and collections${{ '%.2f'|format(stats.cash_in_cents / 100) }}
+
Recorded payouts${{ '%.2f'|format(stats.cash_out_cents / 100) }}
+
Average recorded volume / session${{ '%.2f'|format(stats.avg_cash_in_per_session_cents / 100) }}
+
Ledger records{{ stats.ledger_events }}
+
+
+
+ + +
+
+
+

Recent users

+ All users → +
+
+ + + + {% for user in recent_users %} + + + + + + {% endfor %} + +
EmailStatusJoined
+ {{ user.email }} + {% if user.is_site_admin %}Admin{% endif %} + {% if user.disabled_at %}Disabled{% elif not user.email_verified_at %}Unverified{% else %}Active{% endif %}{{ user.created_at.strftime('%b %-d') if user.created_at else '—' }}
+
+
+ +
+
+

Recent leagues

+ All leagues → +
+
+ + + + {% for league in recent_leagues %} + + + + + + {% endfor %} + +
LeagueVisibilityCreated
{{ league.name }}{{ league.visibility }}{{ league.created_at.strftime('%b %-d') if league.created_at else '—' }}
+
+
+
+ + + +{% endblock %} diff --git a/templates/internal_league_detail.html b/templates/internal_league_detail.html new file mode 100644 index 0000000..977b864 --- /dev/null +++ b/templates/internal_league_detail.html @@ -0,0 +1,128 @@ +{% extends "base.html" %} +{% block title %}{{ league.name }} · Admin · myboker.org{% endblock %} +{% block sidebar %}{% include "_internal_sidebar.html" %}{% endblock %} +{% block page_class %}page--app{% endblock %} +{% block content %} + +
+
+
+ ← Leagues + {% if league.archived_at %}Archived{% else %}Active{% endif %} + {{ league.visibility }} +
+

{{ league.name }}

+

Owner: {% if owner %}{{ owner.email }}{% else %}missing{% endif %}

+
+ Manage league +
+ +
+
{{ stats.members }}Active members
+
{{ stats.players }}Players
+
{{ stats.sessions }}Sessions
+
{{ stats.events }}Ledger events
+
+ +
+
+
+

Members

+ {{ members|length }} +
+
+ + + + {% for membership, member_user in members %} + + + + + + {% else %} + + {% endfor %} + +
EmailRoleStatus
{{ member_user.email }}{{ membership.role }}{% if membership.disabled_at %}Removed{% else %}Active{% endif %}
No members.
+
+
+ +
+
+

League settings

+ Internal manage +
+
+
+ + + + + + + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+
+
+

League actions

+ Support tools +
+
+
+ + +
+ + +
+
+ + {% if league.archived_at %} +
+ + +
+ {% else %} +
+ + +
+ {% endif %} + +
+ + +
+ + +
+
+
+
+
+ +{% endblock %} diff --git a/templates/internal_leagues.html b/templates/internal_leagues.html new file mode 100644 index 0000000..2347e86 --- /dev/null +++ b/templates/internal_leagues.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} +{% block title %}Leagues · Admin · myboker.org{% endblock %} +{% block sidebar %}{% include "_internal_sidebar.html" %}{% endblock %} +{% block page_class %}page--app{% endblock %} +{% block content %} + +
+
+

Leagues

+

Search by league name, slug, or owner email.

+
+
+ +
+ +
+ +
+
+

Matching leagues

+ {{ pagination.start }}-{{ pagination.end }} of {{ pagination.total }} +
+
+ + + + + + + + + + + + {% for league in leagues %} + {% set owner = owners.get(league.id) %} + + + + + + + + {% else %} + + {% endfor %} + +
LeagueOwnerVisibilityStatus
{{ league.name }}{% if owner %}{{ owner.email }}{% else %}Missing{% endif %}{{ league.visibility }}{% if league.archived_at %}Archived{% else %}Active{% endif %}View
No leagues found.
+
+ {% if pagination.pages > 1 %} +
+ {% if pagination.has_prev %} + Previous + {% else %} + Previous + {% endif %} + Page {{ pagination.page }} of {{ pagination.pages }} + {% if pagination.has_next %} + Next + {% else %} + Next + {% endif %} +
+ {% endif %} +
+ +{% endblock %} diff --git a/templates/internal_user_detail.html b/templates/internal_user_detail.html new file mode 100644 index 0000000..e1ad739 --- /dev/null +++ b/templates/internal_user_detail.html @@ -0,0 +1,148 @@ +{% extends "base.html" %} +{% block title %}{{ user.email }} · Admin · myboker.org{% endblock %} +{% block sidebar %}{% include "_internal_sidebar.html" %}{% endblock %} +{% block page_class %}page--app{% endblock %} +{% block content %} + +
+
+
+ ← Users + {% if user.disabled_at %}Disabled{% else %}Active{% endif %} + {% if user.is_site_admin %}Site admin{% endif %} +
+

{{ user.email }}

+

Created {{ user.created_at.strftime('%Y-%m-%d %H:%M') if user.created_at else 'unknown' }}.

+
+
+ +
+
+ {{ 'Yes' if user.email_verified_at else 'No' }} + Email verified +
+
+ {{ memberships|length }} + League memberships +
+
+ {{ owned_leagues|length }} + Owned leagues +
+
+ {{ event_count }} + Ledger events created +
+
+ +
+
+
+

Account

+ Support tools +
+
+
+ + +
+ + +
+
+ +
+

Last login: {{ user.last_login_at.strftime('%Y-%m-%d %H:%M') if user.last_login_at else 'Never' }}

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

Danger zone

+ Account state +
+
+ {% if user.disabled_at %} +
+ + +
+ {% else %} +
+ + +
+ {% endif %} + +
+ + +
+ + +
+
+
+
+
+ +
+
+
+

Memberships

+ {{ memberships|length }} +
+
+ + + + {% for membership, league in memberships %} + + + + + + {% else %} + + {% endfor %} + +
LeagueRoleStatus
{{ league.name }}{{ membership.role }}{% if membership.disabled_at %}Removed{% else %}Active{% endif %}
No memberships.
+
+
+ +
+
+

Owned leagues

+ {{ owned_leagues|length }} +
+
+ + + + {% for league in owned_leagues %} + + + + + + {% else %} + + {% endfor %} + +
LeagueStatus
{{ league.name }}{% if league.archived_at %}Archived{% else %}Active{% endif %}Manage
No owned leagues.
+
+
+
+ +{% endblock %} diff --git a/templates/internal_users.html b/templates/internal_users.html new file mode 100644 index 0000000..bb65776 --- /dev/null +++ b/templates/internal_users.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} +{% block title %}Users · Admin · myboker.org{% endblock %} +{% block sidebar %}{% include "_internal_sidebar.html" %}{% endblock %} +{% block page_class %}page--app{% endblock %} +{% block content %} + +
+
+

Users

+

Search accounts by email and inspect account status.

+
+
+ +
+ +
+ +
+
+

Matching users

+ {{ pagination.start }}-{{ pagination.end }} of {{ pagination.total }} +
+
+ + + + + + + + + + + + {% for user in users %} + + + + + + + + {% else %} + + {% endfor %} + +
EmailStatusVerifiedCreated
+ {{ user.email }} + {% if user.is_site_admin %}Admin{% endif %} + {% if user.disabled_at %}Disabled{% else %}Active{% endif %}{% if user.email_verified_at %}Yes{% else %}No{% endif %}{{ user.created_at.strftime('%Y-%m-%d') if user.created_at else 'Unknown' }}View
No users found.
+
+ {% if pagination.pages > 1 %} +
+ {% if pagination.has_prev %} + Previous + {% else %} + Previous + {% endif %} + Page {{ pagination.page }} of {{ pagination.pages }} + {% if pagination.has_next %} + Next + {% else %} + Next + {% endif %} +
+ {% endif %} +
+ +{% endblock %} diff --git a/tests/test_internal.py b/tests/test_internal.py new file mode 100644 index 0000000..6d2587a --- /dev/null +++ b/tests/test_internal.py @@ -0,0 +1,320 @@ +import tempfile +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from app import create_app +from auth import hash_password +from db import db + + +class InternalAdminAccessTests(unittest.TestCase): + def setUp(self): + self.tmpdir = tempfile.TemporaryDirectory() + db_path = Path(self.tmpdir.name) / "test.sqlite3" + self.app = create_app( + { + "TESTING": True, + "SQLALCHEMY_DATABASE_URI": f"sqlite:///{db_path}", + "WTF_CSRF_ENABLED": False, + } + ) + self.client = self.app.test_client() + + with self.app.app_context(): + from db_models import League, LeagueMembership, User + + db.create_all() + self.user = User( + email="viewer@example.com", + password_hash=hash_password("password123"), + ) + self.manager = User( + email="manager@example.com", + password_hash=hash_password("password123"), + ) + self.admin = User( + email="admin@example.com", + password_hash=hash_password("password123"), + is_site_admin=True, + ) + db.session.add_all([self.user, self.manager, self.admin]) + db.session.flush() + self.league = League( + name="Friday Poker", + slug="friday-poker", + public_key="abc123", + created_by_user_id=self.admin.id, + visibility="public", + ) + db.session.add(self.league) + db.session.flush() + db.session.add_all( + [ + LeagueMembership(league_id=self.league.id, user_id=self.admin.id, role="owner"), + LeagueMembership(league_id=self.league.id, user_id=self.manager.id, role="manager"), + ] + ) + db.session.commit() + self.user_id = self.user.id + self.manager_id = self.manager.id + self.admin_id = self.admin.id + self.league_id = self.league.id + + def tearDown(self): + with self.app.app_context(): + db.session.remove() + db.drop_all() + db.engine.dispose() + self.tmpdir.cleanup() + + def login_as(self, user_id: str, **extra_session): + with self.client.session_transaction() as session: + session["user_id"] = user_id + for key, value in extra_session.items(): + session[key] = value + + def test_internal_dashboard_requires_login(self): + response = self.client.get("/internal/") + + self.assertEqual(response.status_code, 302) + self.assertIn("/account/login", response.headers["Location"]) + + def test_internal_dashboard_rejects_regular_users(self): + self.login_as(self.user_id) + + response = self.client.get("/internal/") + + self.assertEqual(response.status_code, 403) + + def test_internal_dashboard_ignores_client_side_admin_spoof(self): + self.login_as(self.user_id, is_site_admin=True) + + response = self.client.get("/internal/") + + self.assertEqual(response.status_code, 403) + + def test_internal_dashboard_allows_site_admins(self): + self.login_as(self.admin_id) + + response = self.client.get("/internal/") + + self.assertEqual(response.status_code, 200) + self.assertIn(b"Overview", response.data) + self.assertIn(b"admin@example.com", response.data) + self.assertIn(b"Active users", response.data) + self.assertIn(b"Returning users", response.data) + self.assertIn(b"Recorded ledger volume", response.data) + self.assertIn(b"Not platform revenue", response.data) + + def test_admin_can_search_users(self): + self.login_as(self.admin_id) + + response = self.client.get("/internal/users?q=viewer") + + self.assertEqual(response.status_code, 200) + self.assertIn(b"viewer@example.com", response.data) + self.assertNotIn(b"manager@example.com", response.data) + + def test_admin_users_page_is_paginated(self): + with self.app.app_context(): + from db_models import User + + users = [ + User(email=f"bulk{i:02d}@example.com", password_hash=hash_password("password123")) + for i in range(55) + ] + db.session.add_all(users) + db.session.commit() + self.login_as(self.admin_id) + + response = self.client.get("/internal/users?page=2") + + self.assertEqual(response.status_code, 200) + self.assertIn(b"Page 2 of 2", response.data) + self.assertIn(b"Previous", response.data) + + def test_admin_leagues_page_is_paginated(self): + with self.app.app_context(): + from db_models import League + + leagues = [ + League( + name=f"Bulk League {i:02d}", + slug=f"bulk-league-{i:02d}", + public_key=f"bk{i:010d}"[:12], + created_by_user_id=self.admin_id, + visibility="private", + ) + for i in range(55) + ] + db.session.add_all(leagues) + db.session.commit() + self.login_as(self.admin_id) + + response = self.client.get("/internal/leagues?page=2") + + self.assertEqual(response.status_code, 200) + self.assertIn(b"Page 2 of 2", response.data) + self.assertIn(b"Previous", response.data) + + def test_session_growth_uses_session_date_not_created_at(self): + with self.app.app_context(): + from db_models import PokerSession + from routes.internal import _growth_data + + now = datetime.now(timezone.utc) + old_session = PokerSession( + league_id=self.league_id, + session_date=(now - timedelta(weeks=5)).date(), + sequence_on_date=1, + status="closed", + created_at=now, + ) + current_session = PokerSession( + league_id=self.league_id, + session_date=(now - timedelta(days=2)).date(), + sequence_on_date=1, + status="closed", + created_at=now, + ) + db.session.add_all([old_session, current_session]) + db.session.commit() + + growth = _growth_data() + + self.assertEqual(sum(growth["session_counts"]), 2) + self.assertLess(growth["session_counts"][-1], 2) + + def test_regular_user_cannot_update_user_email(self): + self.login_as(self.user_id) + + response = self.client.post( + f"/internal/users/{self.user_id}/email", + data={"email": "changed@example.com"}, + ) + + self.assertEqual(response.status_code, 403) + with self.app.app_context(): + from db_models import User + + user = db.session.get(User, self.user_id) + self.assertEqual(user.email, "viewer@example.com") + + def test_admin_can_update_user_email(self): + self.login_as(self.admin_id) + + response = self.client.post( + f"/internal/users/{self.user_id}/email", + data={"email": "changed@example.com"}, + follow_redirects=True, + ) + + self.assertEqual(response.status_code, 200) + with self.app.app_context(): + from db_models import User + + user = db.session.get(User, self.user_id) + self.assertEqual(user.email, "changed@example.com") + + def test_admin_delete_user_blocks_owned_leagues(self): + self.login_as(self.admin_id) + with self.app.app_context(): + from db_models import League, LeagueMembership + + owned_league = League( + name="Manager Owned Poker", + slug="manager-owned-poker", + public_key="mgr123", + created_by_user_id=self.manager_id, + visibility="private", + ) + db.session.add(owned_league) + db.session.flush() + db.session.add(LeagueMembership(league_id=owned_league.id, user_id=self.manager_id, role="owner")) + db.session.commit() + + response = self.client.post( + f"/internal/users/{self.manager_id}/delete", + data={"confirm": "DELETE"}, + ) + + self.assertEqual(response.status_code, 302) + with self.app.app_context(): + from db_models import User + + self.assertIsNotNone(db.session.get(User, self.manager_id)) + + def test_admin_can_archive_and_restore_league(self): + self.login_as(self.admin_id) + + archive_response = self.client.post(f"/internal/leagues/{self.league_id}/archive") + self.assertEqual(archive_response.status_code, 302) + with self.app.app_context(): + from db_models import League + + league = db.session.get(League, self.league_id) + self.assertIsNotNone(league.archived_at) + + restore_response = self.client.post(f"/internal/leagues/{self.league_id}/restore") + self.assertEqual(restore_response.status_code, 302) + with self.app.app_context(): + from db_models import League + + league = db.session.get(League, self.league_id) + self.assertIsNone(league.archived_at) + + def test_admin_can_update_league_settings(self): + self.login_as(self.admin_id) + + response = self.client.post( + f"/internal/leagues/{self.league_id}/settings", + data={ + "name": "Saturday Poker", + "description": "Updated by support", + "visibility": "private", + "eligible_min_sessions": "5", + "break_even_dollars": "2.50", + }, + ) + + self.assertEqual(response.status_code, 302) + with self.app.app_context(): + from db_models import League + + league = db.session.get(League, self.league_id) + self.assertEqual(league.name, "Saturday Poker") + self.assertEqual(league.slug, "saturday-poker") + self.assertEqual(league.description, "Updated by support") + self.assertEqual(league.visibility, "private") + self.assertEqual(league.eligible_min_sessions, 5) + self.assertEqual(league.break_even_cents, 250) + + def test_admin_can_transfer_league_ownership(self): + self.login_as(self.admin_id) + + response = self.client.post( + f"/internal/leagues/{self.league_id}/transfer", + data={"new_owner_email": "manager@example.com"}, + ) + + self.assertEqual(response.status_code, 302) + with self.app.app_context(): + from db_models import League, LeagueMembership + + league = db.session.get(League, self.league_id) + self.assertEqual(league.created_by_user_id, self.manager_id) + old_owner_membership = LeagueMembership.query.filter_by( + league_id=self.league_id, + user_id=self.admin_id, + ).one() + new_owner_membership = LeagueMembership.query.filter_by( + league_id=self.league_id, + user_id=self.manager_id, + ).one() + self.assertEqual(old_owner_membership.role, "manager") + self.assertEqual(new_owner_membership.role, "owner") + + +if __name__ == "__main__": + unittest.main() From 734b3bdcaa3826f3871edb37b034274d225458d0 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sun, 28 Jun 2026 12:19:57 -0700 Subject: [PATCH 76/83] update terms/privacy --- config.py | 2 +- templates/privacy.html | 34 ++++++++++++++++++++++++---------- templates/support.html | 2 +- templates/terms.html | 24 +++++++++++++++--------- 4 files changed, 41 insertions(+), 21 deletions(-) diff --git a/config.py b/config.py index d54a306..8a81ae4 100644 --- a/config.py +++ b/config.py @@ -9,7 +9,7 @@ DATA_PATH = BASE_DIR / "data" / "entries.csv" DEFAULT_DATABASE_URL = f"sqlite:///{BASE_DIR / 'data' / 'boker-dev.sqlite3'}" ELIGIBLE_MIN_SESSIONS = 3 -APP_VERSION = "2.5.28" +APP_VERSION = "2.5.29" def load_local_env(env_path: Path) -> None: diff --git a/templates/privacy.html b/templates/privacy.html index 8bc883b..0d50e43 100644 --- a/templates/privacy.html +++ b/templates/privacy.html @@ -7,7 +7,7 @@
@@ -62,7 +62,7 @@
{{ stats.active_users_30d }} Active users · 30d - {{ stats.returning_users_30d }} returning · {{ stats.unverified_users }} unverified + {{ stats.users_in_leagues }} in leagues · {{ stats.orphan_users }} solo
@@ -110,15 +110,15 @@ - +
-

Recorded ledger volume

-

Buy-ins and collections · 8 weeks

+

Growth

+

New leagues per week · 8 weeks

- +
@@ -137,38 +137,25 @@

Activity breakdown

- 24h / 7d + 7d
-
New users (24h){{ stats.new_users_24h }}
New users (7d){{ stats.new_users_7d }}
-
Active users (24h){{ stats.active_users_24h }}
Active users (7d){{ stats.active_users_7d }}
Active users (30d){{ stats.active_users_30d }}
-
Returning users (7d){{ stats.returning_users_7d }}
-
Returning users (30d){{ stats.returning_users_30d }}
Users in leagues{{ stats.users_in_leagues }}
-
No-league signups{{ stats.orphan_users }}
-
Verified accounts{{ stats.verified_users }}
-
Unverified{{ stats.unverified_users }}
-
Disabled{{ stats.disabled_users }}
-
Site admins{{ stats.site_admins }}
+
Unverified accounts{{ stats.unverified_users }}
+
Disabled accounts{{ stats.disabled_users }}
-
Sessions (24h){{ stats.sessions_24h }}
Sessions (7d){{ stats.sessions_7d }}
Open sessions{{ stats.open_sessions }}
Leagues active (7d){{ stats.leagues_active_7d }}
-
Leagues with no sessions{{ stats.leagues_without_sessions }}
-
Sessions with no ledger records{{ stats.sessions_without_events }}
-
Avg players/session{{ '%.1f'|format(stats.avg_players_per_session) }}
-
Ledger events (24h){{ stats.ledger_events_24h }}
+
Avg players / session{{ '%.1f'|format(stats.avg_players_per_session) }}
Ledger events (7d){{ stats.ledger_events_7d }}
-
Total ledger events{{ stats.ledger_events }}
-
Voided ledger records{{ stats.voided_events }} · {{ '%.1f'|format(stats.void_rate) }}%
-
Avg sessions / league{{ stats.avg_sessions_per_active_league }}
+
Avg sessions / league{{ '%.1f'|format(stats.avg_sessions_per_active_league) }}
@@ -204,15 +191,19 @@
-

Recorded ledger volume

- Not platform revenue +

Totals at a glance

+ All time
-
-
Recorded buy-ins and collections${{ '%.2f'|format(stats.cash_in_cents / 100) }}
-
Recorded payouts${{ '%.2f'|format(stats.cash_out_cents / 100) }}
-
Average recorded volume / session${{ '%.2f'|format(stats.avg_cash_in_per_session_cents / 100) }}
-
Ledger records{{ stats.ledger_events }}
+
+
{{ stats.users }}Total users
+
{{ stats.leagues }}Total leagues
+
{{ stats.sessions }}Total sessions
+
{{ stats.ledger_events }}Ledger events
+
${{ '%.0f'|format(stats.cash_in_cents / 100) }}Recorded buy-ins
+
${{ '%.0f'|format(stats.cash_out_cents / 100) }}Recorded payouts
+
${{ '%.0f'|format(stats.avg_cash_in_per_session_cents / 100) }}Avg volume / session
+
{{ stats.verified_users }}Verified accounts
@@ -270,7 +261,7 @@ const labels = {{ growth.labels | tojson }}; const userSignups = {{ growth.user_signups | tojson }}; const sessionData = {{ growth.session_counts | tojson }}; - const cashData = {{ growth.cash_in_weekly | tojson }}; + const leagueData = {{ growth.league_counts | tojson }}; const wdLabels = {{ weekday_data.labels | tojson }}; const wdData = {{ weekday_data.data | tojson }}; const trendCards = {{ trend_cards | tojson }}; @@ -326,19 +317,13 @@ options: baseOpts, }); - new Chart(document.getElementById('chart-cash'), { + new Chart(document.getElementById('chart-leagues'), { type: 'bar', data: { labels, - datasets: [{ data: cashData, backgroundColor: 'rgba(120,180,240,.45)', borderColor: 'rgba(120,180,240,.9)', borderWidth: 1, borderRadius: 4 }] - }, - options: { - ...baseOpts, - scales: { - x: axisStyle, - y: { ...axisStyle, beginAtZero: true, ticks: { ...axisStyle.ticks, callback: v => '$' + v } }, - }, + datasets: [{ data: leagueData, backgroundColor: 'rgba(111,192,147,.45)', borderColor: 'rgba(111,192,147,.9)', borderWidth: 1, borderRadius: 4 }] }, + options: baseOpts, }); new Chart(document.getElementById('chart-weekday'), { From fbf56e127173a285fa621600378df4dde81fba48 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sun, 28 Jun 2026 17:07:29 -0700 Subject: [PATCH 78/83] Prepare redesign for public deployment --- .env.example | 2 ++ README.md | 4 ++++ app.py | 15 +++++++++++++-- config.py | 4 +++- migrations/versions/0001_public_foundation.py | 1 - requirements.txt | 2 ++ tests/test_internal.py | 6 +++--- 7 files changed, 27 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index b73c996..f68f5c5 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,8 @@ SECRET_KEY=replace-this-with-a-long-random-string +APP_ENV=production DATABASE_URL=postgresql+psycopg://user:password@host/dbname APP_BASE_URL=https://myboker.org +RATELIMIT_STORAGE_URI=redis://localhost:6379/0 MAIL_SERVER=smtp.sendgrid.net MAIL_PORT=587 MAIL_USE_TLS=true diff --git a/README.md b/README.md index bdeba2e..66fa1be 100644 --- a/README.md +++ b/README.md @@ -74,8 +74,10 @@ Then open: The app reads these from `.env`: - `SECRET_KEY` +- `APP_ENV` - `DATABASE_URL` - `APP_BASE_URL` +- `RATELIMIT_STORAGE_URI` - `MAIL_SERVER` - `MAIL_PORT` - `MAIL_USE_TLS` @@ -84,6 +86,8 @@ The app reads these from `.env`: - `MAIL_PASSWORD` - `MAIL_DEFAULT_SENDER` +For public deployments, set `APP_ENV=production`. Production mode enables secure cookies and refuses to start with the development `SECRET_KEY`. Set `RATELIMIT_STORAGE_URI` to a shared backend such as Redis so login and signup limits are enforced across processes. + ## Site Admin Access Internal admin access uses normal database-backed accounts, not hardcoded `.env` credentials. diff --git a/app.py b/app.py index 529ec5a..5fa6fca 100644 --- a/app.py +++ b/app.py @@ -2,10 +2,11 @@ from __future__ import annotations import click +import os from flask import Flask, render_template from auth import current_user_id, is_logged_in, is_site_admin, normalize_email -from config import Config +from config import DEFAULT_SECRET_KEY, Config, ProductionConfig from db import database_extensions_available, db, init_database from extensions import csrf, limiter, mail from routes.account import account_bp @@ -16,12 +17,22 @@ from storage import ensure_data_file from utils import cents_to_dollars, safe_date_label +def _config_for_environment(): + app_env = os.getenv("APP_ENV", os.getenv("FLASK_ENV", "")).lower() + if app_env in {"prod", "production"}: + return ProductionConfig + return Config + + def create_app(config_overrides: dict | None = None) -> Flask: app = Flask(__name__) - app.config.from_object(Config) + app.config.from_object(_config_for_environment()) if config_overrides: app.config.update(config_overrides) + if app.config["SESSION_COOKIE_SECURE"] and app.config["SECRET_KEY"] == DEFAULT_SECRET_KEY: + raise RuntimeError("Set SECRET_KEY before running in production.") + ensure_data_file(app.config["DATA_PATH"]) init_database(app) csrf.init_app(app) diff --git a/config.py b/config.py index 8a81ae4..a8384a2 100644 --- a/config.py +++ b/config.py @@ -7,6 +7,7 @@ from pathlib import Path BASE_DIR = Path(__file__).resolve().parent DATA_PATH = BASE_DIR / "data" / "entries.csv" DEFAULT_DATABASE_URL = f"sqlite:///{BASE_DIR / 'data' / 'boker-dev.sqlite3'}" +DEFAULT_SECRET_KEY = "change-this-before-deploying" ELIGIBLE_MIN_SESSIONS = 3 APP_VERSION = "2.5.29" @@ -32,7 +33,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") + SECRET_KEY: str = os.getenv("SECRET_KEY", DEFAULT_SECRET_KEY) SQLALCHEMY_DATABASE_URI: str = os.getenv("DATABASE_URL", DEFAULT_DATABASE_URL) SQLALCHEMY_TRACK_MODIFICATIONS: bool = False SESSION_COOKIE_HTTPONLY: bool = True @@ -52,6 +53,7 @@ class Config: MAIL_PASSWORD: str | None = os.getenv("MAIL_PASSWORD") or None MAIL_DEFAULT_SENDER: str = os.getenv("MAIL_DEFAULT_SENDER", "noreply@myboker.org") MAIL_TIMEOUT: float = float(os.getenv("MAIL_TIMEOUT", "5")) + RATELIMIT_STORAGE_URI: str | None = os.getenv("RATELIMIT_STORAGE_URI") or None class ProductionConfig(Config): diff --git a/migrations/versions/0001_public_foundation.py b/migrations/versions/0001_public_foundation.py index f002d06..8f431e3 100644 --- a/migrations/versions/0001_public_foundation.py +++ b/migrations/versions/0001_public_foundation.py @@ -184,7 +184,6 @@ def downgrade() -> None: op.drop_index(op.f("ix_league_memberships_league_id"), table_name="league_memberships") op.drop_table("league_memberships") op.drop_index(op.f("ix_leagues_slug"), table_name="leagues") - op.drop_index(op.f("ix_leagues_public_key"), table_name="leagues") op.drop_index(op.f("ix_leagues_created_by_user_id"), table_name="leagues") op.drop_table("leagues") op.drop_index(op.f("ix_users_email"), table_name="users") diff --git a/requirements.txt b/requirements.txt index b0ea65e..f5af794 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,6 @@ Flask-Mail>=0.10,<1.0 Flask-Migrate>=4.0,<5.0 Flask-SQLAlchemy>=3.1,<4.0 Flask-WTF>=1.2,<2.0 +gunicorn>=22,<24 psycopg[binary]>=3.2,<4.0 +redis>=5,<6 diff --git a/tests/test_internal.py b/tests/test_internal.py index 6d2587a..e636a2e 100644 --- a/tests/test_internal.py +++ b/tests/test_internal.py @@ -103,9 +103,9 @@ class InternalAdminAccessTests(unittest.TestCase): self.assertIn(b"Overview", response.data) self.assertIn(b"admin@example.com", response.data) self.assertIn(b"Active users", response.data) - self.assertIn(b"Returning users", response.data) - self.assertIn(b"Recorded ledger volume", response.data) - self.assertIn(b"Not platform revenue", response.data) + self.assertIn(b"Activity breakdown", response.data) + self.assertIn(b"Ledger events", response.data) + self.assertIn(b"Totals at a glance", response.data) def test_admin_can_search_users(self): self.login_as(self.admin_id) From 83c9fec4893e7936a38b9800fd833698f7ca402a Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sun, 28 Jun 2026 17:11:09 -0700 Subject: [PATCH 79/83] Clean migration header whitespace --- migrations/versions/0001_public_foundation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/versions/0001_public_foundation.py b/migrations/versions/0001_public_foundation.py index 8f431e3..5057e0e 100644 --- a/migrations/versions/0001_public_foundation.py +++ b/migrations/versions/0001_public_foundation.py @@ -1,7 +1,7 @@ """public multi-league foundation Revision ID: 0001_public_foundation -Revises: +Revises: Create Date: 2026-06-23 """ from __future__ import annotations From e376b3628aaab825aaaad85e8587b011ede0d910 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sun, 28 Jun 2026 17:41:11 -0700 Subject: [PATCH 80/83] Move application code into package --- README.md | 8 + app.py | 119 +------------- boker/__init__.py | 1 + boker/app.py | 122 +++++++++++++++ auth.py => boker/auth.py | 4 +- charts.py => boker/charts.py | 4 +- config.py => boker/config.py | 2 +- db.py => boker/db.py | 2 +- db_models.py => boker/db_models.py | 2 +- emails.py => boker/emails.py | 2 +- extensions.py => boker/extensions.py | 0 .../league_repositories.py | 14 +- .../ledger_repositories.py | 6 +- models.py => boker/models.py | 0 {routes => boker/routes}/__init__.py | 0 {routes => boker/routes}/account.py | 42 ++--- {routes => boker/routes}/internal.py | 14 +- {routes => boker/routes}/leagues.py | 148 +++++++++--------- {routes => boker/routes}/public.py | 16 +- services.py => boker/services.py | 6 +- storage.py => boker/storage.py | 0 utils.py => boker/utils.py | 2 +- tests/test_accounting.py | 6 +- tests/test_internal.py | 30 ++-- tests/test_stats.py | 4 +- 25 files changed, 285 insertions(+), 269 deletions(-) create mode 100644 boker/__init__.py create mode 100644 boker/app.py rename auth.py => boker/auth.py (96%) rename charts.py => boker/charts.py (96%) rename config.py => boker/config.py (97%) rename db.py => boker/db.py (90%) rename db_models.py => boker/db_models.py (99%) rename emails.py => boker/emails.py (99%) rename extensions.py => boker/extensions.py (100%) rename league_repositories.py => boker/league_repositories.py (97%) rename ledger_repositories.py => boker/ledger_repositories.py (98%) rename models.py => boker/models.py (100%) rename {routes => boker/routes}/__init__.py (100%) rename {routes => boker/routes}/account.py (94%) rename {routes => boker/routes}/internal.py (98%) rename {routes => boker/routes}/leagues.py (92%) rename {routes => boker/routes}/public.py (93%) rename services.py => boker/services.py (98%) rename storage.py => boker/storage.py (100%) rename utils.py => boker/utils.py (97%) diff --git a/README.md b/README.md index 66fa1be..361c621 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,14 @@ The current app is account-based and league-based. League owners/managers record - Chart.js - plain CSS +## Project Layout + +- `app.py` is the deployment entrypoint for `flask --app app` and Gunicorn. +- `boker/` contains the Flask application package, routes, models, services, repositories, and config. +- `templates/` and `static/` contain Jinja views and public assets. +- `migrations/` contains Alembic migrations for database deploys. +- `tests/` contains the committed regression suite. + ## Ledger Model Each ledger row is an event, not a final snapshot. Corrections are made by appending or voiding events, so the history remains auditable. diff --git a/app.py b/app.py index 5fa6fca..ab569a9 100644 --- a/app.py +++ b/app.py @@ -1,125 +1,10 @@ #!/usr/bin/env python3 from __future__ import annotations -import click -import os -from flask import Flask, render_template +from boker.app import app, create_app -from auth import current_user_id, is_logged_in, is_site_admin, normalize_email -from config import DEFAULT_SECRET_KEY, Config, ProductionConfig -from db import database_extensions_available, db, init_database -from extensions import csrf, limiter, mail -from routes.account import account_bp -from routes.internal import internal_bp -from routes.leagues import leagues_bp -from routes.public import public_bp -from storage import ensure_data_file -from utils import cents_to_dollars, safe_date_label +__all__ = ["app", "create_app"] -def _config_for_environment(): - app_env = os.getenv("APP_ENV", os.getenv("FLASK_ENV", "")).lower() - if app_env in {"prod", "production"}: - return ProductionConfig - return Config - - -def create_app(config_overrides: dict | None = None) -> Flask: - app = Flask(__name__) - app.config.from_object(_config_for_environment()) - if config_overrides: - app.config.update(config_overrides) - - if app.config["SESSION_COOKIE_SECURE"] and app.config["SECRET_KEY"] == DEFAULT_SECRET_KEY: - raise RuntimeError("Set SECRET_KEY before running in production.") - - ensure_data_file(app.config["DATA_PATH"]) - init_database(app) - csrf.init_app(app) - limiter.init_app(app) - mail.init_app(app) - - app.jinja_env.filters["money"] = cents_to_dollars - app.jinja_env.filters["pretty_date"] = safe_date_label - - @app.context_processor - def inject_globals() -> dict: - return { - "app_version": app.config["APP_VERSION"], - "current_user_id": current_user_id(), - "is_logged_in": is_logged_in(), - "current_user_is_site_admin": is_site_admin(), - } - - app.register_blueprint(public_bp) - app.register_blueprint(account_bp) - app.register_blueprint(internal_bp) - app.register_blueprint(leagues_bp) - - @app.errorhandler(404) - def not_found(e): - return render_template("404.html"), 404 - - @app.errorhandler(403) - def forbidden(e): - return render_template("403.html"), 403 - - @app.cli.command("init-db") - def init_db_command() -> None: - if not database_extensions_available() or db is None: - raise click.ClickException( - "Database dependencies are not installed. Run pip install -r requirements.txt." - ) - - with app.app_context(): - db.create_all() - - click.echo("Initialized database tables.") - - @app.cli.command("grant-site-admin") - @click.argument("email") - def grant_site_admin_command(email: str) -> None: - if not database_extensions_available() or db is None: - raise click.ClickException( - "Database dependencies are not installed. Run pip install -r requirements.txt." - ) - - from db_models import User - - normalized_email = normalize_email(email) - with app.app_context(): - user = User.query.filter_by(email=normalized_email, disabled_at=None).one_or_none() - if user is None: - raise click.ClickException(f"No active user found for {normalized_email}.") - user.is_site_admin = True - db.session.commit() - - click.echo(f"Granted site admin access to {normalized_email}.") - - @app.cli.command("revoke-site-admin") - @click.argument("email") - def revoke_site_admin_command(email: str) -> None: - if not database_extensions_available() or db is None: - raise click.ClickException( - "Database dependencies are not installed. Run pip install -r requirements.txt." - ) - - from db_models import User - - normalized_email = normalize_email(email) - with app.app_context(): - user = User.query.filter_by(email=normalized_email).one_or_none() - if user is None: - raise click.ClickException(f"No user found for {normalized_email}.") - user.is_site_admin = False - db.session.commit() - - click.echo(f"Revoked site admin access from {normalized_email}.") - - return app - - -app = create_app() - if __name__ == "__main__": app.run(debug=True) diff --git a/boker/__init__.py b/boker/__init__.py new file mode 100644 index 0000000..afd4634 --- /dev/null +++ b/boker/__init__.py @@ -0,0 +1 @@ +"""Application package for myboker.org.""" diff --git a/boker/app.py b/boker/app.py new file mode 100644 index 0000000..3247e99 --- /dev/null +++ b/boker/app.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import click +import os +from flask import Flask, render_template + +from boker.auth import current_user_id, is_logged_in, is_site_admin, normalize_email +from boker.config import DEFAULT_SECRET_KEY, Config, ProductionConfig +from boker.db import database_extensions_available, db, init_database +from boker.extensions import csrf, limiter, mail +from boker.routes.account import account_bp +from boker.routes.internal import internal_bp +from boker.routes.leagues import leagues_bp +from boker.routes.public import public_bp +from boker.storage import ensure_data_file +from boker.utils import cents_to_dollars, safe_date_label + + +def _config_for_environment(): + app_env = os.getenv("APP_ENV", os.getenv("FLASK_ENV", "")).lower() + if app_env in {"prod", "production"}: + return ProductionConfig + return Config + + +def create_app(config_overrides: dict | None = None) -> Flask: + app = Flask(__name__, template_folder="../templates", static_folder="../static") + app.config.from_object(_config_for_environment()) + if config_overrides: + app.config.update(config_overrides) + + if app.config["SESSION_COOKIE_SECURE"] and app.config["SECRET_KEY"] == DEFAULT_SECRET_KEY: + raise RuntimeError("Set SECRET_KEY before running in production.") + + ensure_data_file(app.config["DATA_PATH"]) + init_database(app) + csrf.init_app(app) + limiter.init_app(app) + mail.init_app(app) + + app.jinja_env.filters["money"] = cents_to_dollars + app.jinja_env.filters["pretty_date"] = safe_date_label + + @app.context_processor + def inject_globals() -> dict: + return { + "app_version": app.config["APP_VERSION"], + "current_user_id": current_user_id(), + "is_logged_in": is_logged_in(), + "current_user_is_site_admin": is_site_admin(), + } + + app.register_blueprint(public_bp) + app.register_blueprint(account_bp) + app.register_blueprint(internal_bp) + app.register_blueprint(leagues_bp) + + @app.errorhandler(404) + def not_found(e): + return render_template("404.html"), 404 + + @app.errorhandler(403) + def forbidden(e): + return render_template("403.html"), 403 + + @app.cli.command("init-db") + def init_db_command() -> None: + if not database_extensions_available() or db is None: + raise click.ClickException( + "Database dependencies are not installed. Run pip install -r requirements.txt." + ) + + with app.app_context(): + db.create_all() + + click.echo("Initialized database tables.") + + @app.cli.command("grant-site-admin") + @click.argument("email") + def grant_site_admin_command(email: str) -> None: + if not database_extensions_available() or db is None: + raise click.ClickException( + "Database dependencies are not installed. Run pip install -r requirements.txt." + ) + + from boker.db_models import User + + normalized_email = normalize_email(email) + with app.app_context(): + user = User.query.filter_by(email=normalized_email, disabled_at=None).one_or_none() + if user is None: + raise click.ClickException(f"No active user found for {normalized_email}.") + user.is_site_admin = True + db.session.commit() + + click.echo(f"Granted site admin access to {normalized_email}.") + + @app.cli.command("revoke-site-admin") + @click.argument("email") + def revoke_site_admin_command(email: str) -> None: + if not database_extensions_available() or db is None: + raise click.ClickException( + "Database dependencies are not installed. Run pip install -r requirements.txt." + ) + + from boker.db_models import User + + normalized_email = normalize_email(email) + with app.app_context(): + user = User.query.filter_by(email=normalized_email).one_or_none() + if user is None: + raise click.ClickException(f"No user found for {normalized_email}.") + user.is_site_admin = False + db.session.commit() + + click.echo(f"Revoked site admin access from {normalized_email}.") + + return app + + +app = create_app() diff --git a/auth.py b/boker/auth.py similarity index 96% rename from auth.py rename to boker/auth.py index 86be33b..b5ad923 100644 --- a/auth.py +++ b/boker/auth.py @@ -72,12 +72,12 @@ def is_site_admin() -> bool: if not user_id: return False - from db import database_extensions_available + from boker.db import database_extensions_available if not database_extensions_available(): return False - from db_models import User + from boker.db_models import User user = User.query.filter_by(id=user_id, disabled_at=None).one_or_none() return bool(user and user.is_site_admin) diff --git a/charts.py b/boker/charts.py similarity index 96% rename from charts.py rename to boker/charts.py index 8f6bf09..b21fdd0 100644 --- a/charts.py +++ b/boker/charts.py @@ -4,8 +4,8 @@ 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 +from boker.models import SessionEntry, SessionSummary +from boker.utils import net_result_bucket, session_chart_label, session_sort_key PLAYER_PALETTE = [ "#9b8cf0", # --line-1 violet diff --git a/config.py b/boker/config.py similarity index 97% rename from config.py rename to boker/config.py index a8384a2..d90062a 100644 --- a/config.py +++ b/boker/config.py @@ -4,7 +4,7 @@ from __future__ import annotations import os from pathlib import Path -BASE_DIR = Path(__file__).resolve().parent +BASE_DIR = Path(__file__).resolve().parent.parent DATA_PATH = BASE_DIR / "data" / "entries.csv" DEFAULT_DATABASE_URL = f"sqlite:///{BASE_DIR / 'data' / 'boker-dev.sqlite3'}" DEFAULT_SECRET_KEY = "change-this-before-deploying" diff --git a/db.py b/boker/db.py similarity index 90% rename from db.py rename to boker/db.py index 050416c..018f6a0 100644 --- a/db.py +++ b/boker/db.py @@ -24,7 +24,7 @@ def init_database(app: Any) -> bool: app.config["DATABASE_EXTENSIONS_AVAILABLE"] = False return False - import db_models # noqa: F401 - registers SQLAlchemy models with metadata. + import boker.db_models # noqa: F401 - registers SQLAlchemy models with metadata. db.init_app(app) migrate.init_app(app, db) diff --git a/db_models.py b/boker/db_models.py similarity index 99% rename from db_models.py rename to boker/db_models.py index cf2c78a..a0da8d9 100644 --- a/db_models.py +++ b/boker/db_models.py @@ -4,7 +4,7 @@ from __future__ import annotations import uuid from datetime import date, datetime, timezone -from db import db +from boker.db import db if db is None: # pragma: no cover - imported only when DB dependencies exist. raise RuntimeError("Database dependencies are not installed.") diff --git a/emails.py b/boker/emails.py similarity index 99% rename from emails.py rename to boker/emails.py index 10391c3..c6874ab 100644 --- a/emails.py +++ b/boker/emails.py @@ -7,7 +7,7 @@ import socket from flask import current_app from flask_mail import Message -from extensions import mail +from boker.extensions import mail def _mail_send_suppressed() -> bool: diff --git a/extensions.py b/boker/extensions.py similarity index 100% rename from extensions.py rename to boker/extensions.py diff --git a/league_repositories.py b/boker/league_repositories.py similarity index 97% rename from league_repositories.py rename to boker/league_repositories.py index 9f4e5ab..7536b7b 100644 --- a/league_repositories.py +++ b/boker/league_repositories.py @@ -5,13 +5,13 @@ import secrets import string from datetime import date -from auth import hash_password, normalize_email -from db import db +from boker.auth import hash_password, normalize_email +from boker.db import db if db is None: # pragma: no cover - imported only when DB dependencies exist. raise RuntimeError("Database dependencies are not installed.") -from db_models import ( # noqa: E402 +from boker.db_models import ( # noqa: E402 LedgerEvent, League, LeagueMembership, @@ -24,7 +24,7 @@ from db_models import ( # noqa: E402 make_user, normalize_lookup, ) -from utils import slugify +from boker.utils import slugify PUBLIC_KEY_ALPHABET = string.ascii_lowercase + string.digits @@ -223,7 +223,7 @@ def set_session_status(session: PokerSession, status: str) -> PokerSession: if status not in {"open", "closed"}: raise ValueError(f"Unsupported session status: {status}") - from db_models import utc_now + from boker.db_models import utc_now session.status = status if status == "open": @@ -323,7 +323,7 @@ def add_league_member( def remove_league_member(league_id: str, user_id: str) -> None: - from db_models import utc_now + from boker.db_models import utc_now membership = LeagueMembership.query.filter_by( league_id=league_id, @@ -421,7 +421,7 @@ def delete_season(season: Season) -> None: def delete_league(league: League) -> None: - from db_models import LedgerEvent, LeagueMembership, Player, PokerSession, Season + from boker.db_models import LedgerEvent, LeagueMembership, Player, PokerSession, Season LedgerEvent.query.filter_by(league_id=league.id).delete(synchronize_session=False) PokerSession.query.filter_by(league_id=league.id).delete(synchronize_session=False) Player.query.filter_by(league_id=league.id).delete(synchronize_session=False) diff --git a/ledger_repositories.py b/boker/ledger_repositories.py similarity index 98% rename from ledger_repositories.py rename to boker/ledger_repositories.py index 93cb1e2..0037160 100644 --- a/ledger_repositories.py +++ b/boker/ledger_repositories.py @@ -1,12 +1,12 @@ #!/usr/bin/env python3 from __future__ import annotations -from db import db +from boker.db import db if db is None: # pragma: no cover - imported only when DB dependencies exist. raise RuntimeError("Database dependencies are not installed.") -from db_models import ( # noqa: E402 +from boker.db_models import ( # noqa: E402 CANONICAL_EVENT_TYPES, LedgerEvent, Player, @@ -14,7 +14,7 @@ from db_models import ( # noqa: E402 User, canonical_event_type, ) -from storage import EventRow # noqa: E402 +from boker.storage import EventRow # noqa: E402 def session_event_ref(session: PokerSession) -> str: diff --git a/models.py b/boker/models.py similarity index 100% rename from models.py rename to boker/models.py diff --git a/routes/__init__.py b/boker/routes/__init__.py similarity index 100% rename from routes/__init__.py rename to boker/routes/__init__.py diff --git a/routes/account.py b/boker/routes/account.py similarity index 94% rename from routes/account.py rename to boker/routes/account.py index 3511698..78d8cdf 100644 --- a/routes/account.py +++ b/boker/routes/account.py @@ -7,7 +7,7 @@ from urllib.parse import urlsplit from flask import Blueprint, flash, redirect, render_template, request, session as flask_session, url_for -from auth import ( +from boker.auth import ( current_user_id, generate_invite_token, generate_reset_token, @@ -20,8 +20,8 @@ from auth import ( verify_password, verify_reset_token, ) -from db import database_extensions_available, db -from extensions import limiter +from boker.db import database_extensions_available, db +from boker.extensions import limiter account_bp = Blueprint("account", __name__, url_prefix="/account") EMAIL_VERIFICATION_TTL = timedelta(minutes=15) @@ -64,7 +64,7 @@ def _new_verification_code() -> str: def _issue_verification_code(user) -> str: - from db_models import utc_now + from boker.db_models import utc_now code = _new_verification_code() user.email_verification_code_hash = hash_password(code) @@ -73,7 +73,7 @@ def _issue_verification_code(user) -> str: def _send_verification_code(user) -> None: - from emails import send_email_verification_code + from boker.emails import send_email_verification_code code = _issue_verification_code(user) db.session.commit() @@ -112,7 +112,7 @@ def register(): } if request.method == "POST": - from league_repositories import create_user, find_user_by_email + from boker.league_repositories import create_user, find_user_by_email email = normalize_email(form["email"]) password = request.form.get("password", "") @@ -158,7 +158,7 @@ def login(): } if request.method == "POST": - from league_repositories import find_user_by_email + from boker.league_repositories import find_user_by_email user = find_user_by_email(form["email"]) password = request.form.get("password", "") @@ -195,7 +195,7 @@ def verify_email(): flash("Account database is not available.", "error") return redirect(url_for("account.login")) - from db_models import User, utc_now + from boker.db_models import User, utc_now user_id = flask_session.get("pending_verification_user_id") user = db.session.get(User, user_id) if user_id else None @@ -245,7 +245,7 @@ def resend_verification_code(): flash("Account database is not available.", "error") return redirect(url_for("account.login")) - from db_models import User + from boker.db_models import User user_id = flask_session.get("pending_verification_user_id") user = db.session.get(User, user_id) if user_id else None @@ -275,7 +275,7 @@ def settings(): flash("Account database is not available.", "error") return redirect(url_for("leagues.index")) - from db_models import User + from boker.db_models import User user = db.session.get(User, current_user_id()) if user is None: @@ -292,7 +292,7 @@ def update_email(): flash("Account database is not available.", "error") return redirect(url_for("account.settings")) - from db_models import User + from boker.db_models import User user = db.session.get(User, current_user_id()) if user is None: @@ -307,7 +307,7 @@ def update_email(): elif not verify_password(user.password_hash, current_password): flash("Current password is incorrect.", "error") else: - from league_repositories import find_user_by_email + from boker.league_repositories import find_user_by_email existing = find_user_by_email(new_email) if existing and existing.id != user.id: @@ -327,7 +327,7 @@ def update_password(): flash("Account database is not available.", "error") return redirect(url_for("account.settings")) - from db_models import User + from boker.db_models import User user = db.session.get(User, current_user_id()) if user is None: @@ -359,7 +359,7 @@ def disable_account(): flash("Account database is not available.", "error") return redirect(url_for("account.settings")) - from db_models import League, User, utc_now + from boker.db_models import League, User, utc_now user = db.session.get(User, current_user_id()) if user is None: @@ -395,8 +395,8 @@ def delete_account(): flash("Account database is not available.", "error") return redirect(url_for("account.settings")) - from db_models import League, LeagueMembership, User, utc_now - from league_repositories import delete_league + from boker.db_models import League, LeagueMembership, User, utc_now + from boker.league_repositories import delete_league user = db.session.get(User, current_user_id()) if user is None: @@ -436,9 +436,9 @@ def forgot_password(): flash("Account database is not available.", "error") return redirect(url_for("account.forgot_password")) - from emails import send_password_reset + from boker.emails import send_password_reset from flask import current_app - from league_repositories import find_user_by_email + from boker.league_repositories import find_user_by_email email = normalize_email(request.form.get("email", "")) user = find_user_by_email(email) @@ -472,7 +472,7 @@ def reset_password(token): flash("Account database is not available.", "error") return redirect(url_for("account.login")) - from db_models import User + from boker.db_models import User user = db.session.get(User, user_id) if user is None or user.disabled_at is not None: @@ -507,8 +507,8 @@ def accept_invite(token): flash("Account database is not available.", "error") return redirect(url_for("leagues.index")) - from db_models import User - from league_repositories import add_league_member, find_league_by_id, find_membership, find_user_by_email + from boker.db_models import User + from boker.league_repositories import add_league_member, find_league_by_id, find_membership, find_user_by_email invite_email = normalize_email(data.get("email", "")) invite_next = url_for("account.accept_invite", token=token) diff --git a/routes/internal.py b/boker/routes/internal.py similarity index 98% rename from routes/internal.py rename to boker/routes/internal.py index 9eacee5..339f4d5 100644 --- a/routes/internal.py +++ b/boker/routes/internal.py @@ -9,17 +9,17 @@ from flask import Blueprint, current_app, flash, redirect, render_template, requ from sqlalchemy import or_ from sqlalchemy.exc import IntegrityError -from auth import ( +from boker.auth import ( current_user_id, generate_reset_token, hash_password, normalize_email, site_admin_required, ) -from db import db -from db_models import LedgerEvent, League, LeagueMembership, Player, PokerSession, User, utc_now -from league_repositories import delete_league, transfer_league_ownership -from utils import slugify +from boker.db import db +from boker.db_models import LedgerEvent, League, LeagueMembership, Player, PokerSession, User, utc_now +from boker.league_repositories import delete_league, transfer_league_ownership +from boker.utils import slugify internal_bp = Blueprint("internal", __name__, url_prefix="/internal") @@ -415,7 +415,7 @@ def send_user_password_reset(user_id: str): flash("Disabled users cannot receive password resets.", "error") return redirect(url_for("internal.user_detail", user_id=user.id)) - from emails import send_password_reset + from boker.emails import send_password_reset token = generate_reset_token(user.id) base_url = current_app.config.get("APP_BASE_URL", "").rstrip("/") @@ -436,7 +436,7 @@ def create_user_temporary_password(user_id: str): flash("Disabled users cannot receive temporary passwords.", "error") return redirect(url_for("internal.user_detail", user_id=user.id)) - from emails import send_temporary_password + from boker.emails import send_temporary_password temporary_password = _temporary_password() user.password_hash = hash_password(temporary_password) diff --git a/routes/leagues.py b/boker/routes/leagues.py similarity index 92% rename from routes/leagues.py rename to boker/routes/leagues.py index 804121f..ad73fb9 100644 --- a/routes/leagues.py +++ b/boker/routes/leagues.py @@ -9,12 +9,12 @@ from datetime import datetime, timezone from flask import Blueprint, abort, flash, make_response, redirect, render_template, request, url_for -from auth import current_user_id, login_required, normalize_email -from charts import cumulative_profit_series, player_session_series -from db import database_extensions_available, db -from models import SessionSummary -from services import apply_rank_changes, build_leaderboard, build_session_summaries, session_events -from utils import cents_to_dollars, session_label, session_sort_key +from boker.auth import current_user_id, login_required, normalize_email +from boker.charts import cumulative_profit_series, player_session_series +from boker.db import database_extensions_available, db +from boker.models import SessionSummary +from boker.services import apply_rank_changes, build_leaderboard, build_session_summaries, session_events +from boker.utils import cents_to_dollars, session_label, session_sort_key leagues_bp = Blueprint("leagues", __name__) @@ -48,7 +48,7 @@ def split_league_ref(league_ref: str) -> tuple[str, str]: def require_league(league_ref: str, allowed_roles: set[str]): - from league_repositories import find_league_by_public_key, user_has_league_role + from boker.league_repositories import find_league_by_public_key, user_has_league_role _slug, public_key = split_league_ref(league_ref) league = find_league_by_public_key(public_key) @@ -64,7 +64,7 @@ def require_league(league_ref: str, allowed_roles: set[str]): def get_league_with_visibility_gate(league_ref: str): """Load league; if private, enforce login + membership. Returns (league, None) or (None, redirect).""" - from league_repositories import find_league_by_public_key, user_has_league_role + from boker.league_repositories import find_league_by_public_key, user_has_league_role _slug, public_key = split_league_ref(league_ref) league = find_league_by_public_key(public_key) @@ -99,8 +99,8 @@ def empty_session_summary(session) -> SessionSummary: def session_ref_map(league_id: str) -> dict[str, str]: - from ledger_repositories import session_event_ref - from league_repositories import list_sessions_for_league + from boker.ledger_repositories import session_event_ref + from boker.league_repositories import list_sessions_for_league return {session_event_ref(session): session.id for session in list_sessions_for_league(league_id)} @@ -112,8 +112,8 @@ def index(): flash("League database is not available.", "error") return render_template("leagues_index.html", leagues=[]) - from league_repositories import list_leagues_for_user - from ledger_repositories import list_event_rows_for_league + from boker.league_repositories import list_leagues_for_user + from boker.ledger_repositories import list_event_rows_for_league memberships = list_leagues_for_user(current_user_id() or "") league_summaries = [] @@ -190,8 +190,8 @@ def new(): } if request.method == "POST": - from db_models import User - from league_repositories import create_league, unique_league_slug + from boker.db_models import User + from boker.league_repositories import create_league, unique_league_slug owner = db.session.get(User, current_user_id()) if owner is None: @@ -221,8 +221,8 @@ def dashboard(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import league_counts, user_has_league_role - from ledger_repositories import list_event_rows_for_league + from boker.league_repositories import league_counts, user_has_league_role + from boker.ledger_repositories import list_event_rows_for_league league, resp = get_league_with_visibility_gate(league_ref) if resp: @@ -289,7 +289,7 @@ def legacy_dashboard_redirect(league_id: str, league_slug: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import find_league_by_id, user_has_league_role + from boker.league_repositories import find_league_by_id, user_has_league_role league = find_league_by_id(league_id) if league is None: @@ -306,8 +306,8 @@ def leaderboard(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from ledger_repositories import list_event_rows_for_league - from league_repositories import ( + from boker.ledger_repositories import list_event_rows_for_league + from boker.league_repositories import ( list_players_for_league, list_seasons_for_league, user_has_league_role, @@ -328,10 +328,10 @@ def leaderboard(league_ref: str): selected_season_id = request.args.get("season", "").strip() selected_season = None if selected_season_id: - from league_repositories import find_season, list_sessions_for_league + from boker.league_repositories import find_season, list_sessions_for_league selected_season = find_season(league.id, selected_season_id) if selected_season: - from ledger_repositories import session_event_ref + from boker.ledger_repositories import session_event_ref season_db_sessions = list_sessions_for_league(league.id) season_refs = { session_event_ref(s) @@ -442,12 +442,12 @@ def ledger(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from ledger_repositories import ( + from boker.ledger_repositories import ( append_ledger_event, list_all_event_rows_for_league, list_event_rows_for_league, ) - from league_repositories import ( + from boker.league_repositories import ( find_player_for_league, find_session_for_league, list_players_for_league, @@ -579,7 +579,7 @@ def void_event(league_ref: str, event_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from ledger_repositories import void_ledger_event + from boker.ledger_repositories import void_ledger_event league = require_league(league_ref, {"owner"}) reason = request.form.get("void_reason", "").strip() @@ -602,8 +602,8 @@ def player_detail(league_ref: str, player_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from ledger_repositories import list_event_rows_for_league - from league_repositories import find_player_for_league, user_has_league_role + from boker.ledger_repositories import list_event_rows_for_league + from boker.league_repositories import find_player_for_league, user_has_league_role league, resp = get_league_with_visibility_gate(league_ref) if resp: @@ -661,7 +661,7 @@ def players(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import ( + from boker.league_repositories import ( create_player, list_players_for_league, player_name_exists, @@ -700,7 +700,7 @@ def players(league_ref: str): flash("Player added.", "success") return redirect(url_for("leagues.players", **league_url_values(league))) - from ledger_repositories import list_event_rows_for_league + from boker.ledger_repositories import list_event_rows_for_league all_sessions = build_session_summaries(list_event_rows_for_league(league.id)) board = build_leaderboard(all_sessions, league.break_even_cents) @@ -734,7 +734,7 @@ def update_player_status(league_ref: str, player_id: str, status: str, message: flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import find_player_for_league, set_player_status + from boker.league_repositories import find_player_for_league, set_player_status league = require_league(league_ref, {"owner", "manager"}) player = find_player_for_league(league.id, player_id) @@ -754,7 +754,7 @@ def edit_player(league_ref: str, player_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import find_player_for_league, player_name_taken, update_player + from boker.league_repositories import find_player_for_league, player_name_taken, update_player league = require_league(league_ref, {"owner", "manager"}) player = find_player_for_league(league.id, player_id) @@ -782,8 +782,8 @@ def sessions(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from ledger_repositories import append_ledger_event - from league_repositories import ( + from boker.ledger_repositories import append_ledger_event + from boker.league_repositories import ( create_poker_session, list_seasons_for_league, list_sessions_for_league, @@ -816,10 +816,10 @@ def sessions(league_ref: str): status = "closed" if form["status"] == "closed" else "open" season_id = form["season_id"] or None if season_id is None: - from league_repositories import auto_assign_sessions_to_seasons as _auto + from boker.league_repositories import auto_assign_sessions_to_seasons as _auto # try auto-assign: create the session first, then let the # function match it; we pass a temporary session date check inline - from league_repositories import list_seasons_for_league as _ls + from boker.league_repositories import list_seasons_for_league as _ls eligible = [ s for s in _ls(league.id, include_archived=False) if s.start_date and s.end_date @@ -836,7 +836,7 @@ def sessions(league_ref: str): session.label = form["label"] or None session.notes = form["notes"] or None if status == "open": - from league_repositories import set_session_status + from boker.league_repositories import set_session_status set_session_status(session, "open") @@ -854,8 +854,8 @@ def sessions(league_ref: str): flash(f"Created {session.display_label}.", "success") return redirect(url_for("leagues.sessions", **league_url_values(league))) - from db_models import LedgerEvent - from ledger_repositories import list_event_rows_for_league + from boker.db_models import LedgerEvent + from boker.ledger_repositories import list_event_rows_for_league all_sessions = list_sessions_for_league(league.id) summaries = build_session_summaries(list_event_rows_for_league(league.id)) @@ -908,8 +908,8 @@ def prune_empty_sessions(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from db_models import LedgerEvent, PokerSession - from league_repositories import list_sessions_for_league + from boker.db_models import LedgerEvent, PokerSession + from boker.league_repositories import list_sessions_for_league league = require_league(league_ref, {"owner", "manager"}) @@ -944,7 +944,7 @@ def delete_session(league_ref: str, session_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import find_session_for_league + from boker.league_repositories import find_session_for_league league = require_league(league_ref, {"owner"}) session = find_session_for_league(league.id, session_id) @@ -964,7 +964,7 @@ def edit_session(league_ref: str, session_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import find_session_for_league + from boker.league_repositories import find_session_for_league league = require_league(league_ref, {"owner", "manager"}) session = find_session_for_league(league.id, session_id) @@ -983,7 +983,7 @@ def edit_session(league_ref: str, session_id: str): return redirect(url_for("leagues.session_detail", league_ref=league.url_ref, session_id=session_id)) if new_date != session.session_date: - from db_models import PokerSession as _PS + from boker.db_models import PokerSession as _PS max_seq = db.session.query(db.func.max(_PS.sequence_on_date)).filter( _PS.league_id == league.id, _PS.session_date == new_date, @@ -993,7 +993,7 @@ def edit_session(league_ref: str, session_id: str): session.session_date = new_date if new_season_id is not None: - from league_repositories import find_season + from boker.league_repositories import find_season valid = find_season(league.id, new_season_id) new_season_id = valid.id if valid else None @@ -1012,8 +1012,8 @@ def session_detail(league_ref: str, session_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from ledger_repositories import append_ledger_event, list_all_event_rows_for_session, list_event_rows_for_session - from league_repositories import ( + from boker.ledger_repositories import append_ledger_event, list_all_event_rows_for_session, list_event_rows_for_session + from boker.league_repositories import ( find_session_for_league, list_players_for_league, list_seasons_for_league, @@ -1101,9 +1101,9 @@ def session_public_view(league_ref: str, session_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from charts import session_breakdown_series - from ledger_repositories import list_event_rows_for_league, list_event_rows_for_session - from league_repositories import find_league_by_public_key, find_season, find_session_for_league, list_sessions_for_league, user_has_league_role + from boker.charts import session_breakdown_series + from boker.ledger_repositories import list_event_rows_for_league, list_event_rows_for_session + from boker.league_repositories import find_league_by_public_key, find_season, find_session_for_league, list_sessions_for_league, user_has_league_role _slug, public_key = split_league_ref(league_ref) league = find_league_by_public_key(public_key) @@ -1182,8 +1182,8 @@ def update_session_status(league_ref: str, session_id: str, status: str, message flash("League database is not available.", "error") return redirect(url_for("public.home")) - from ledger_repositories import append_ledger_event - from league_repositories import find_session_for_league, set_session_status + from boker.ledger_repositories import append_ledger_event + from boker.league_repositories import find_session_for_league, set_session_status league = require_league(league_ref, {"owner", "manager"}) session = find_session_for_league(league.id, session_id) @@ -1214,7 +1214,7 @@ def seasons(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import ( + from boker.league_repositories import ( create_season, list_seasons_for_league, user_has_league_role, @@ -1283,7 +1283,7 @@ def auto_assign_seasons(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import auto_assign_sessions_to_seasons + from boker.league_repositories import auto_assign_sessions_to_seasons league = require_league(league_ref, {"owner", "manager"}) count = auto_assign_sessions_to_seasons(league.id) @@ -1302,7 +1302,7 @@ def update_season(league_ref: str, season_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import find_season, update_season as repo_update_season + from boker.league_repositories import find_season, update_season as repo_update_season league = require_league(league_ref, {"owner", "manager"}) season = find_season(league.id, season_id) @@ -1338,7 +1338,7 @@ def archive_season(league_ref: str, season_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import archive_season as repo_archive, find_season + from boker.league_repositories import archive_season as repo_archive, find_season league = require_league(league_ref, {"owner", "manager"}) season = find_season(league.id, season_id) @@ -1359,7 +1359,7 @@ def unarchive_season(league_ref: str, season_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import find_season, unarchive_season as repo_unarchive + from boker.league_repositories import find_season, unarchive_season as repo_unarchive league = require_league(league_ref, {"owner", "manager"}) season = find_season(league.id, season_id) @@ -1380,7 +1380,7 @@ def delete_season(league_ref: str, season_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import delete_season as repo_delete, find_season + from boker.league_repositories import delete_season as repo_delete, find_season league = require_league(league_ref, {"owner", "manager"}) season = find_season(league.id, season_id) @@ -1437,7 +1437,7 @@ def league_settings(league_ref: str): elif break_even_cents < 0 or break_even_cents > 10000: flash("Break-even threshold must be between $0.00 and $100.00.", "error") else: - from utils import slugify + from boker.utils import slugify league.name = form["name"] league.slug = slugify(form["name"]) @@ -1449,7 +1449,7 @@ def league_settings(league_ref: str): flash("League settings saved.", "success") return redirect(url_for("leagues.league_settings", league_ref=league.url_ref)) - from league_repositories import list_members_for_league + from boker.league_repositories import list_members_for_league members = list_members_for_league(league.id) return render_template("league_settings.html", league=league, form=form, is_owner=True, can_manage=True, members=members) @@ -1462,11 +1462,11 @@ def invite_member(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from auth import generate_invite_token - from emails import send_league_invite + from boker.auth import generate_invite_token + from boker.emails import send_league_invite from flask import current_app - from db_models import User - from league_repositories import find_membership, find_user_by_email + from boker.db_models import User + from boker.league_repositories import find_membership, find_user_by_email league = require_league(league_ref, {"owner"}) email = normalize_email(request.form.get("email", "")) @@ -1509,7 +1509,7 @@ def remove_member(league_ref: str, user_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import remove_league_member + from boker.league_repositories import remove_league_member league = require_league(league_ref, {"owner"}) @@ -1530,7 +1530,7 @@ def update_member_role(league_ref: str, user_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import set_league_member_role + from boker.league_repositories import set_league_member_role league = require_league(league_ref, {"owner"}) role = request.form.get("role", "").strip() @@ -1560,7 +1560,7 @@ def transfer_ownership(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import transfer_league_ownership + from boker.league_repositories import transfer_league_ownership league = require_league(league_ref, {"owner"}) new_owner_user_id = request.form.get("new_owner_user_id", "").strip() @@ -1588,7 +1588,7 @@ def archive_league(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from db_models import utc_now + from boker.db_models import utc_now league = require_league(league_ref, {"owner"}) confirm_name = request.form.get("confirm_name", "").strip() @@ -1610,7 +1610,7 @@ def delete_league_route(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import delete_league + from boker.league_repositories import delete_league league = require_league(league_ref, {"owner"}) confirm_name = request.form.get("confirm_name", "").strip() @@ -1633,8 +1633,8 @@ def export_ledger_csv(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from ledger_repositories import list_event_rows_for_league - from storage import CSV_HEADERS + from boker.ledger_repositories import list_event_rows_for_league + from boker.storage import CSV_HEADERS league = require_league(league_ref, {"owner", "manager"}) rows = list_event_rows_for_league(league.id) @@ -1659,14 +1659,14 @@ def import_ledger_csv(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from db_models import CANONICAL_EVENT_TYPES, canonical_event_type - from ledger_repositories import ( + from boker.db_models import CANONICAL_EVENT_TYPES, canonical_event_type + from boker.ledger_repositories import ( append_ledger_event, list_ledger_events_for_league, session_event_ref, ) - from league_repositories import create_player, list_players_for_league, list_sessions_for_league, unique_player_slug - from storage import CSV_HEADERS + from boker.league_repositories import create_player, list_players_for_league, list_sessions_for_league, unique_player_slug + from boker.storage import CSV_HEADERS league = require_league(league_ref, {"owner", "manager"}) @@ -1700,7 +1700,7 @@ def import_ledger_csv(league_ref: str): flash(f"CSV header mismatch. Expected: {', '.join(CSV_HEADERS)}", "error") return redirect(url_for("leagues.ledger", **league_url_values(league))) - from db_models import PokerSession, make_session + from boker.db_models import PokerSession, make_session sessions = list_sessions_for_league(league.id) session_by_ref = {session_event_ref(s): s for s in sessions} diff --git a/routes/public.py b/boker/routes/public.py similarity index 93% rename from routes/public.py rename to boker/routes/public.py index 90ef5f8..063b7fb 100644 --- a/routes/public.py +++ b/boker/routes/public.py @@ -4,16 +4,16 @@ 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 ( +from boker.charts import cumulative_profit_series, player_session_series, session_breakdown_series +from boker.config import ELIGIBLE_MIN_SESSIONS +from boker.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 +from boker.storage import load_events +from boker.utils import session_label, session_sort_key public_bp = Blueprint("public", __name__) @@ -40,9 +40,9 @@ def terms(): @public_bp.get("/explore") def explore(): - from auth import is_logged_in - from db import database_extensions_available - from league_repositories import league_counts, list_public_leagues + from boker.auth import is_logged_in + from boker.db import database_extensions_available + from boker.league_repositories import league_counts, list_public_leagues q = request.args.get("q", "").strip() leagues = list_public_leagues(q) if database_extensions_available() else [] diff --git a/services.py b/boker/services.py similarity index 98% rename from services.py rename to boker/services.py index 13458b0..424116c 100644 --- a/services.py +++ b/boker/services.py @@ -4,9 +4,9 @@ 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 +from boker.models import PlayerStats, SessionEntry, SessionSummary +from boker.storage import EventRow +from boker.utils import entry_sort_key, net_result_bucket, session_sort_key SESSION_MARKER_TYPES = {"session_open", "session_close"} diff --git a/storage.py b/boker/storage.py similarity index 100% rename from storage.py rename to boker/storage.py diff --git a/utils.py b/boker/utils.py similarity index 97% rename from utils.py rename to boker/utils.py index 44094f7..516aa06 100644 --- a/utils.py +++ b/boker/utils.py @@ -7,7 +7,7 @@ import re from markupsafe import Markup -from models import SessionEntry, SessionSummary +from boker.models import SessionEntry, SessionSummary BREAK_EVEN_TOLERANCE_CENTS = 100 diff --git a/tests/test_accounting.py b/tests/test_accounting.py index 4b7c075..1d98330 100644 --- a/tests/test_accounting.py +++ b/tests/test_accounting.py @@ -1,13 +1,13 @@ import unittest -from charts import player_session_series -from services import ( +from boker.charts import player_session_series +from boker.services import ( build_leaderboard, build_session_summaries, pending_payout_carry_items, prunable_empty_session_ids, ) -from utils import session_sort_key +from boker.utils import session_sort_key def cents(amount: float) -> int: diff --git a/tests/test_internal.py b/tests/test_internal.py index e636a2e..2a5ef14 100644 --- a/tests/test_internal.py +++ b/tests/test_internal.py @@ -4,8 +4,8 @@ from datetime import datetime, timedelta, timezone from pathlib import Path from app import create_app -from auth import hash_password -from db import db +from boker.auth import hash_password +from boker.db import db class InternalAdminAccessTests(unittest.TestCase): @@ -22,7 +22,7 @@ class InternalAdminAccessTests(unittest.TestCase): self.client = self.app.test_client() with self.app.app_context(): - from db_models import League, LeagueMembership, User + from boker.db_models import League, LeagueMembership, User db.create_all() self.user = User( @@ -118,7 +118,7 @@ class InternalAdminAccessTests(unittest.TestCase): def test_admin_users_page_is_paginated(self): with self.app.app_context(): - from db_models import User + from boker.db_models import User users = [ User(email=f"bulk{i:02d}@example.com", password_hash=hash_password("password123")) @@ -136,7 +136,7 @@ class InternalAdminAccessTests(unittest.TestCase): def test_admin_leagues_page_is_paginated(self): with self.app.app_context(): - from db_models import League + from boker.db_models import League leagues = [ League( @@ -160,8 +160,8 @@ class InternalAdminAccessTests(unittest.TestCase): def test_session_growth_uses_session_date_not_created_at(self): with self.app.app_context(): - from db_models import PokerSession - from routes.internal import _growth_data + from boker.db_models import PokerSession + from boker.routes.internal import _growth_data now = datetime.now(timezone.utc) old_session = PokerSession( @@ -196,7 +196,7 @@ class InternalAdminAccessTests(unittest.TestCase): self.assertEqual(response.status_code, 403) with self.app.app_context(): - from db_models import User + from boker.db_models import User user = db.session.get(User, self.user_id) self.assertEqual(user.email, "viewer@example.com") @@ -212,7 +212,7 @@ class InternalAdminAccessTests(unittest.TestCase): self.assertEqual(response.status_code, 200) with self.app.app_context(): - from db_models import User + from boker.db_models import User user = db.session.get(User, self.user_id) self.assertEqual(user.email, "changed@example.com") @@ -220,7 +220,7 @@ class InternalAdminAccessTests(unittest.TestCase): def test_admin_delete_user_blocks_owned_leagues(self): self.login_as(self.admin_id) with self.app.app_context(): - from db_models import League, LeagueMembership + from boker.db_models import League, LeagueMembership owned_league = League( name="Manager Owned Poker", @@ -241,7 +241,7 @@ class InternalAdminAccessTests(unittest.TestCase): self.assertEqual(response.status_code, 302) with self.app.app_context(): - from db_models import User + from boker.db_models import User self.assertIsNotNone(db.session.get(User, self.manager_id)) @@ -251,7 +251,7 @@ class InternalAdminAccessTests(unittest.TestCase): archive_response = self.client.post(f"/internal/leagues/{self.league_id}/archive") self.assertEqual(archive_response.status_code, 302) with self.app.app_context(): - from db_models import League + from boker.db_models import League league = db.session.get(League, self.league_id) self.assertIsNotNone(league.archived_at) @@ -259,7 +259,7 @@ class InternalAdminAccessTests(unittest.TestCase): restore_response = self.client.post(f"/internal/leagues/{self.league_id}/restore") self.assertEqual(restore_response.status_code, 302) with self.app.app_context(): - from db_models import League + from boker.db_models import League league = db.session.get(League, self.league_id) self.assertIsNone(league.archived_at) @@ -280,7 +280,7 @@ class InternalAdminAccessTests(unittest.TestCase): self.assertEqual(response.status_code, 302) with self.app.app_context(): - from db_models import League + from boker.db_models import League league = db.session.get(League, self.league_id) self.assertEqual(league.name, "Saturday Poker") @@ -300,7 +300,7 @@ class InternalAdminAccessTests(unittest.TestCase): self.assertEqual(response.status_code, 302) with self.app.app_context(): - from db_models import League, LeagueMembership + from boker.db_models import League, LeagueMembership league = db.session.get(League, self.league_id) self.assertEqual(league.created_by_user_id, self.manager_id) diff --git a/tests/test_stats.py b/tests/test_stats.py index 940cdda..757b885 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -1,7 +1,7 @@ import unittest -from models import SessionEntry, SessionSummary -from services import build_leaderboard +from boker.models import SessionEntry, SessionSummary +from boker.services import build_leaderboard class CashInAccountingTest(unittest.TestCase): From adbc60bfc3b6f7a10b9352597de872f47f0d241c Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sun, 28 Jun 2026 17:47:58 -0700 Subject: [PATCH 81/83] Add friendly crash page --- boker/app.py | 19 +++++++++++++++++- static/css/style.css | 15 ++++++++++++++ templates/500.html | 20 +++++++++++++++++++ tests/test_errors.py | 47 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 templates/500.html create mode 100644 tests/test_errors.py diff --git a/boker/app.py b/boker/app.py index 3247e99..3465e12 100644 --- a/boker/app.py +++ b/boker/app.py @@ -3,7 +3,10 @@ from __future__ import annotations import click import os -from flask import Flask, render_template +from uuid import uuid4 + +from flask import Flask, render_template, request +from werkzeug.exceptions import HTTPException from boker.auth import current_user_id, is_logged_in, is_site_admin, normalize_email from boker.config import DEFAULT_SECRET_KEY, Config, ProductionConfig @@ -64,6 +67,20 @@ def create_app(config_overrides: dict | None = None) -> Flask: def forbidden(e): return render_template("403.html"), 403 + @app.errorhandler(Exception) + def unexpected_error(error): + if isinstance(error, HTTPException): + return error + + crash_id = uuid4().hex[:12] + app.logger.exception( + "Unhandled exception [%s] during %s %s", + crash_id, + request.method, + request.path, + ) + return render_template("500.html", crash_id=crash_id), 500 + @app.cli.command("init-db") def init_db_command() -> None: if not database_extensions_available() or db is None: diff --git a/static/css/style.css b/static/css/style.css index 2aa6683..09da5f2 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -5925,6 +5925,9 @@ select.control { cursor: pointer; } .error-pg--403 .error-pg__suit { color: rgba(155,140,240,.06); } +.error-pg--500 .error-pg__suit { + color: rgba(224,177,92,.07); +} .error-pg__body { position: relative; @@ -5971,6 +5974,18 @@ select.control { cursor: pointer; } justify-content: center; } +.error-pg__trace { + margin: -12px 0 28px; + padding: 6px 10px; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--faint); + background: var(--panel); + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.4; +} + /* ================================================================ LANDING — hero kicker (replaces pill eyebrow in hero only) ================================================================ */ diff --git a/templates/500.html b/templates/500.html new file mode 100644 index 0000000..24ece0f --- /dev/null +++ b/templates/500.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% block title %}500 · Something Went Wrong · myboker.org{% endblock %} +{% block page_class %}page--error{% endblock %} +{% block content %} + +
+ +
+ 500 +

Something went wrong.

+

The app hit an unexpected error. Try again in a minute, or contact support if it keeps happening.

+

Incident {{ crash_id }}

+ +
+
+ +{% endblock %} diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..ea0d844 --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,47 @@ +import re +import tempfile +import unittest +from pathlib import Path + +from app import create_app +from boker.db import db + + +class ErrorPageTests(unittest.TestCase): + def setUp(self): + self.tmpdir = tempfile.TemporaryDirectory() + db_path = Path(self.tmpdir.name) / "test.sqlite3" + self.app = create_app( + { + "TESTING": True, + "PROPAGATE_EXCEPTIONS": False, + "SQLALCHEMY_DATABASE_URI": f"sqlite:///{db_path}", + "WTF_CSRF_ENABLED": False, + } + ) + + @self.app.get("/explode") + def explode(): + raise RuntimeError("boom") + + self.client = self.app.test_client() + + def tearDown(self): + with self.app.app_context(): + db.session.remove() + db.drop_all() + db.engine.dispose() + self.tmpdir.cleanup() + + def test_unexpected_errors_render_crash_page_with_incident_id(self): + response = self.client.get("/explode") + + self.assertEqual(response.status_code, 500) + self.assertIn(b"Something went wrong.", response.data) + self.assertIn(b"Incident", response.data) + self.assertNotIn(b"RuntimeError", response.data) + self.assertRegex(response.get_data(as_text=True), re.compile(r"Incident [a-f0-9]{12}")) + + +if __name__ == "__main__": + unittest.main() From 593420f984d5a7de4656de5f6ece7e780f613c5f Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sun, 28 Jun 2026 17:52:22 -0700 Subject: [PATCH 82/83] Require production database configuration --- README.md | 2 +- boker/app.py | 6 +++++- tests/test_config.py | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 tests/test_config.py diff --git a/README.md b/README.md index 361c621..c0f974b 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ The app reads these from `.env`: - `MAIL_PASSWORD` - `MAIL_DEFAULT_SENDER` -For public deployments, set `APP_ENV=production`. Production mode enables secure cookies and refuses to start with the development `SECRET_KEY`. Set `RATELIMIT_STORAGE_URI` to a shared backend such as Redis so login and signup limits are enforced across processes. +For public deployments, set `APP_ENV=production`. Production mode enables secure cookies and refuses to start with the development `SECRET_KEY` or the local SQLite database. Use PostgreSQL for `DATABASE_URL`, then run `flask --app app db upgrade` during deploy. Set `RATELIMIT_STORAGE_URI` to a shared backend such as Redis so login and signup limits are enforced across processes. ## Site Admin Access diff --git a/boker/app.py b/boker/app.py index 3465e12..bd8fdce 100644 --- a/boker/app.py +++ b/boker/app.py @@ -9,7 +9,7 @@ from flask import Flask, render_template, request from werkzeug.exceptions import HTTPException from boker.auth import current_user_id, is_logged_in, is_site_admin, normalize_email -from boker.config import DEFAULT_SECRET_KEY, Config, ProductionConfig +from boker.config import DEFAULT_DATABASE_URL, DEFAULT_SECRET_KEY, Config, ProductionConfig from boker.db import database_extensions_available, db, init_database from boker.extensions import csrf, limiter, mail from boker.routes.account import account_bp @@ -35,6 +35,10 @@ def create_app(config_overrides: dict | None = None) -> Flask: if app.config["SESSION_COOKIE_SECURE"] and app.config["SECRET_KEY"] == DEFAULT_SECRET_KEY: raise RuntimeError("Set SECRET_KEY before running in production.") + if app.config["SESSION_COOKIE_SECURE"]: + database_url = app.config["SQLALCHEMY_DATABASE_URI"] + if database_url == DEFAULT_DATABASE_URL or database_url.startswith("sqlite:"): + raise RuntimeError("Set DATABASE_URL to a production PostgreSQL database before running in production.") ensure_data_file(app.config["DATA_PATH"]) init_database(app) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..d6c8027 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,37 @@ +import os +import unittest +from unittest.mock import patch + +from app import create_app + + +class ProductionConfigTests(unittest.TestCase): + def test_production_refuses_default_sqlite_database(self): + with patch.dict( + os.environ, + {"APP_ENV": "production"}, + clear=False, + ): + with self.assertRaisesRegex(RuntimeError, "DATABASE_URL"): + create_app({"SECRET_KEY": "test-production-secret"}) + + def test_production_accepts_postgresql_database_url(self): + with patch.dict( + os.environ, + {"APP_ENV": "production"}, + clear=False, + ): + app = create_app( + { + "SECRET_KEY": "test-production-secret", + "SQLALCHEMY_DATABASE_URI": "postgresql+psycopg://user:password@example.com/dbname", + } + ) + + self.assertTrue(app.config["SESSION_COOKIE_SECURE"]) + self.assertEqual(app.config["SESSION_COOKIE_SAMESITE"], "Strict") + self.assertTrue(app.config["SQLALCHEMY_DATABASE_URI"].startswith("postgresql+psycopg://")) + + +if __name__ == "__main__": + unittest.main() From 8740ff31cd30dff19612acf4d9f6d250b0f94137 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sun, 28 Jun 2026 18:02:12 -0700 Subject: [PATCH 83/83] Finalize database deployment readiness --- .env.example | 14 +- .env.production.example | 11 + README.md | 12 +- migrations/versions/0002_league_public_key.py | 23 +- .../versions/0005_site_admin_accounts.py | 18 +- tests/test_db_foundation.py | 115 ++++ tests/test_phase2_auth_leagues.py | 502 ++++++++++++++++++ 7 files changed, 662 insertions(+), 33 deletions(-) create mode 100644 .env.production.example create mode 100644 tests/test_db_foundation.py create mode 100644 tests/test_phase2_auth_leagues.py diff --git a/.env.example b/.env.example index f68f5c5..cf706a3 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,9 @@ -SECRET_KEY=replace-this-with-a-long-random-string -APP_ENV=production -DATABASE_URL=postgresql+psycopg://user:password@host/dbname -APP_BASE_URL=https://myboker.org -RATELIMIT_STORAGE_URI=redis://localhost:6379/0 -MAIL_SERVER=smtp.sendgrid.net +SECRET_KEY=local-dev-secret-change-me +APP_ENV=development +APP_BASE_URL=http://localhost:5000 +MAIL_SERVER= MAIL_PORT=587 MAIL_USE_TLS=true -MAIL_USERNAME=apikey -MAIL_PASSWORD=your-sendgrid-api-key +MAIL_USERNAME= +MAIL_PASSWORD= MAIL_DEFAULT_SENDER=noreply@myboker.org diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..f68f5c5 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,11 @@ +SECRET_KEY=replace-this-with-a-long-random-string +APP_ENV=production +DATABASE_URL=postgresql+psycopg://user:password@host/dbname +APP_BASE_URL=https://myboker.org +RATELIMIT_STORAGE_URI=redis://localhost:6379/0 +MAIL_SERVER=smtp.sendgrid.net +MAIL_PORT=587 +MAIL_USE_TLS=true +MAIL_USERNAME=apikey +MAIL_PASSWORD=your-sendgrid-api-key +MAIL_DEFAULT_SENDER=noreply@myboker.org diff --git a/README.md b/README.md index c0f974b..bf32e32 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Then open: ## Environment Values -The app reads these from `.env`: +The app reads these from `.env`. Use `.env.example` for local development and `.env.production.example` as the production checklist. - `SECRET_KEY` - `APP_ENV` @@ -94,7 +94,15 @@ The app reads these from `.env`: - `MAIL_PASSWORD` - `MAIL_DEFAULT_SENDER` -For public deployments, set `APP_ENV=production`. Production mode enables secure cookies and refuses to start with the development `SECRET_KEY` or the local SQLite database. Use PostgreSQL for `DATABASE_URL`, then run `flask --app app db upgrade` during deploy. Set `RATELIMIT_STORAGE_URI` to a shared backend such as Redis so login and signup limits are enforced across processes. +For local development, omit `DATABASE_URL` to use the default SQLite database at `data/boker-dev.sqlite3`. + +For public deployments, set `APP_ENV=production`. Production mode enables secure cookies and refuses to start with the development `SECRET_KEY` or any SQLite database. Use PostgreSQL for `DATABASE_URL`, install dependencies from `requirements.txt`, then run: + +```bash +flask --app app db upgrade +``` + +Set `RATELIMIT_STORAGE_URI` to a shared backend such as Redis so login and signup limits are enforced across processes. ## Site Admin Access diff --git a/migrations/versions/0002_league_public_key.py b/migrations/versions/0002_league_public_key.py index 6d8eb5b..fba7488 100644 --- a/migrations/versions/0002_league_public_key.py +++ b/migrations/versions/0002_league_public_key.py @@ -6,9 +6,6 @@ Create Date: 2026-06-23 """ from __future__ import annotations -import secrets -import string - from alembic import op import sqlalchemy as sa @@ -18,28 +15,10 @@ down_revision = "0001_public_foundation" branch_labels = None depends_on = None -ALPHABET = string.ascii_lowercase + string.digits - - -def _key(existing: set[str], length: int = 6) -> str: - while True: - value = "".join(secrets.choice(ALPHABET) for _ in range(length)) - if value not in existing: - existing.add(value) - return value - def upgrade() -> None: op.add_column("leagues", sa.Column("public_key", sa.String(length=12), nullable=True)) - - bind = op.get_bind() - rows = bind.execute(sa.text("SELECT id FROM leagues")).mappings().all() - existing: set[str] = set() - for row in rows: - bind.execute( - sa.text("UPDATE leagues SET public_key = :public_key WHERE id = :id"), - {"public_key": _key(existing), "id": row["id"]}, - ) + op.execute("UPDATE leagues SET public_key = substr(replace(id, '-', ''), 1, 12) WHERE public_key IS NULL") with op.batch_alter_table("leagues") as batch_op: batch_op.alter_column("public_key", existing_type=sa.String(length=12), nullable=False) diff --git a/migrations/versions/0005_site_admin_accounts.py b/migrations/versions/0005_site_admin_accounts.py index 45c745c..34674d8 100644 --- a/migrations/versions/0005_site_admin_accounts.py +++ b/migrations/versions/0005_site_admin_accounts.py @@ -6,7 +6,7 @@ Create Date: 2026-06-28 """ from __future__ import annotations -from alembic import op +from alembic import context, op import sqlalchemy as sa @@ -17,6 +17,18 @@ depends_on = None def upgrade() -> None: + if context.is_offline_mode(): + op.add_column( + "users", + sa.Column( + "is_site_admin", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + ) + return + columns = {column["name"] for column in sa.inspect(op.get_bind()).get_columns("users")} if "is_site_admin" not in columns: op.add_column( @@ -31,6 +43,10 @@ def upgrade() -> None: def downgrade() -> None: + if context.is_offline_mode(): + op.drop_column("users", "is_site_admin") + return + columns = {column["name"] for column in sa.inspect(op.get_bind()).get_columns("users")} if "is_site_admin" in columns: op.drop_column("users", "is_site_admin") diff --git a/tests/test_db_foundation.py b/tests/test_db_foundation.py new file mode 100644 index 0000000..7b80337 --- /dev/null +++ b/tests/test_db_foundation.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from datetime import date +import unittest + +from boker.db import database_extensions_available + + +@unittest.skipUnless( + database_extensions_available(), + "Database dependencies are not installed.", +) +class DatabaseFoundationTests(unittest.TestCase): + def setUp(self): + from app import create_app + from boker.db import db + + self.db = db + self.app = create_app( + { + "TESTING": True, + "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:", + } + ) + self.ctx = self.app.app_context() + self.ctx.push() + self.db.create_all() + + def tearDown(self): + self.db.session.remove() + self.db.drop_all() + self.db.engine.dispose() + self.ctx.pop() + + def test_owner_league_players_sessions_season_and_events(self): + from boker.db_models import LedgerEvent, LeagueMembership, PokerSession + from boker.ledger_repositories import append_ledger_event + from boker.league_repositories import ( + create_league, + create_player, + create_poker_session, + create_season, + create_user, + user_has_league_role, + ) + + owner = create_user("Owner@Example.com", "correct horse battery staple") + self.db.session.flush() + league = create_league(owner, "Friday Poker", "friday-poker") + player = create_player(league.id, "Alex", "alex") + season = create_season( + league.id, + "Fall 2026", + start_date=date(2026, 9, 1), + end_date=date(2026, 12, 31), + ) + self.db.session.flush() + + first_session = create_poker_session( + league.id, + date(2026, 10, 2), + season_id=season.id, + status="open", + ) + second_session = create_poker_session( + league.id, + date(2026, 10, 2), + season_id=season.id, + ) + self.db.session.flush() + + append_ledger_event( + league.id, + first_session.id, + "buyin", + 2500, + owner.id, + player_id=player.id, + ) + append_ledger_event( + league.id, + first_session.id, + "paid", + 1000, + owner.id, + player_id=player.id, + ) + append_ledger_event( + league.id, + first_session.id, + "session_close", + 9999, + owner.id, + note="Closed after payout review.", + ) + self.db.session.commit() + + membership = LeagueMembership.query.filter_by( + league_id=league.id, + user_id=owner.id, + ).one() + sessions = PokerSession.query.order_by(PokerSession.sequence_on_date.asc()).all() + events = LedgerEvent.query.order_by(LedgerEvent.created_at.asc()).all() + + self.assertEqual(owner.email, "owner@example.com") + self.assertEqual(membership.role, "owner") + self.assertTrue(user_has_league_role(owner.id, league.id, {"owner"})) + self.assertEqual([s.sequence_on_date for s in sessions], [1, 2]) + self.assertEqual(sessions[0].season_id, season.id) + self.assertEqual(events[1].event_type, "paid_out") + self.assertEqual(events[2].amount_cents, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_phase2_auth_leagues.py b/tests/test_phase2_auth_leagues.py new file mode 100644 index 0000000..3b2fde1 --- /dev/null +++ b/tests/test_phase2_auth_leagues.py @@ -0,0 +1,502 @@ +from __future__ import annotations + +from datetime import date +import unittest + +from boker.db import database_extensions_available + + +@unittest.skipUnless( + database_extensions_available(), + "Database dependencies are not installed.", +) +class Phase2AuthLeagueRouteTests(unittest.TestCase): + def setUp(self): + from app import create_app + from boker.db import db + + self.db = db + self.app = create_app( + { + "TESTING": True, + "SECRET_KEY": "test-secret", + "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:", + "WTF_CSRF_ENABLED": False, + } + ) + self.ctx = self.app.app_context() + self.ctx.push() + self.db.create_all() + self.client = self.app.test_client() + + def tearDown(self): + self.db.session.remove() + self.db.drop_all() + self.db.engine.dispose() + self.ctx.pop() + + def league_path(self, league=None) -> str: + if league is None: + from boker.db_models import League + + league = League.query.one() + return f"/l/{league.url_ref}" + + def create_verified_user(self, email="owner@example.com"): + from boker.db_models import utc_now + from boker.league_repositories import create_user + + user = create_user(email, "password123") + self.db.session.flush() + user.email_verified_at = utc_now() + self.db.session.commit() + return user + + def login_as(self, user): + with self.client.session_transaction() as flask_session: + flask_session["user_id"] = user.id + + def create_logged_in_league(self, email="owner@example.com", name="Friday Poker"): + from boker.league_repositories import create_league + + owner = self.create_verified_user(email) + league = create_league(owner, name, name.lower().replace(" ", "-")) + self.db.session.commit() + self.login_as(owner) + return owner, league, self.league_path(league) + + def test_registration_starts_email_verification_before_login(self): + from boker.db_models import User + + response = self.client.post( + "/account/register", + data={ + "email": "Owner@Example.com", + "password": "password123", + "confirm_password": "password123", + }, + ) + + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers["Location"], "/account/verify-email") + + user = User.query.one() + self.assertEqual(user.email, "owner@example.com") + self.assertIsNone(user.email_verified_at) + self.assertIsNotNone(user.email_verification_code_hash) + + with self.client.session_transaction() as flask_session: + self.assertNotIn("user_id", flask_session) + self.assertEqual(flask_session["pending_verification_user_id"], user.id) + + def test_verified_user_can_create_league_and_owner_membership(self): + from boker.db_models import League, LeagueMembership, User + + user = self.create_verified_user("Owner@Example.com") + self.login_as(user) + + response = self.client.post( + "/leagues/new", + data={ + "name": "Friday Poker", + "slug": "", + "description": "Private home league", + }, + ) + self.assertEqual(response.status_code, 302) + + self.assertEqual(User.query.count(), 1) + league = League.query.one() + self.assertEqual(response.headers["Location"], self.league_path(league)) + membership = LeagueMembership.query.one() + self.assertEqual(league.slug, "friday-poker") + self.assertEqual(membership.user_id, user.id) + self.assertEqual(membership.role, "owner") + + dashboard = self.client.get(self.league_path(league)) + self.assertEqual(dashboard.status_code, 200) + self.assertIn(b"Friday Poker", dashboard.data) + + def test_legacy_admin_routes_are_not_registered_by_default(self): + response = self.client.get("/admin/login") + self.assertEqual(response.status_code, 404) + + def test_duplicate_league_slug_is_allowed_because_route_uses_public_key(self): + owner = self.create_verified_user() + self.login_as(owner) + self.client.post("/leagues/new", data={"name": "Friday Poker"}) + self.client.post("/leagues/new", data={"name": "Friday Poker"}) + + from boker.db_models import League + + self.assertEqual( + [league.slug for league in League.query.order_by(League.slug.asc()).all()], + ["friday-poker", "friday-poker"], + ) + + def test_legacy_uuid_league_route_redirects_to_public_key_route(self): + from boker.db import db + from boker.league_repositories import create_league, create_user + + owner = create_user("owner@example.com", "password123") + db.session.flush() + league = create_league(owner, "Private League", "private-league") + db.session.commit() + + with self.client.session_transaction() as flask_session: + flask_session["user_id"] = owner.id + + response = self.client.get(f"/l/{league.id}/{league.slug}") + self.assertEqual(response.status_code, 301) + self.assertEqual(response.headers["Location"], self.league_path(league)) + + def test_league_dashboard_requires_membership(self): + from boker.db import db + from boker.league_repositories import create_league, create_user + + owner = create_user("owner@example.com", "password123") + other = create_user("other@example.com", "password123") + db.session.flush() + league = create_league(owner, "Private League", "private-league") + db.session.commit() + + with self.client.session_transaction() as flask_session: + flask_session["user_id"] = other.id + + response = self.client.get(self.league_path(league)) + self.assertEqual(response.status_code, 403) + + def test_owner_can_add_archive_and_reactivate_player(self): + from boker.db_models import Player + + _, _, league_base = self.create_logged_in_league() + + response = self.client.post( + f"{league_base}/players", + data={ + "display_name": "Alex", + "slug": "", + "notes": "Usually hosts.", + }, + ) + self.assertEqual(response.status_code, 302) + + player = Player.query.one() + self.assertEqual(player.display_name, "Alex") + self.assertEqual(player.slug, "alex") + self.assertEqual(player.status, "active") + self.assertEqual(player.notes, "Usually hosts.") + + duplicate = self.client.post( + f"{league_base}/players", + data={"display_name": " alex "}, + ) + self.assertEqual(duplicate.status_code, 200) + self.assertIn(b"already exists", duplicate.data) + self.assertEqual(Player.query.count(), 1) + + archive = self.client.post(f"{league_base}/players/{player.id}/archive") + self.assertEqual(archive.status_code, 302) + self.db.session.refresh(player) + self.assertEqual(player.status, "archived") + + reactivate = self.client.post(f"{league_base}/players/{player.id}/reactivate") + self.assertEqual(reactivate.status_code, 302) + self.db.session.refresh(player) + self.assertEqual(player.status, "active") + + def test_viewer_can_view_players_but_cannot_add_or_archive(self): + from boker.db import db + from boker.db_models import LeagueMembership, Player + from boker.league_repositories import create_league, create_player, create_user + + owner = create_user("owner@example.com", "password123") + viewer = create_user("viewer@example.com", "password123") + db.session.flush() + league = create_league(owner, "Private League", "private-league") + player = create_player(league.id, "Alex", "alex") + db.session.flush() + db.session.add( + LeagueMembership( + league_id=league.id, + user_id=viewer.id, + role="viewer", + ) + ) + db.session.commit() + + with self.client.session_transaction() as flask_session: + flask_session["user_id"] = viewer.id + + league_base = self.league_path(league) + response = self.client.get(f"{league_base}/players") + self.assertEqual(response.status_code, 200) + self.assertIn(b"Alex", response.data) + self.assertNotIn(b"Add player", response.data) + + add = self.client.post( + f"{league_base}/players", + data={"display_name": "Blair"}, + ) + self.assertEqual(add.status_code, 403) + self.assertEqual(Player.query.count(), 1) + + archive = self.client.post(f"{league_base}/players/{player.id}/archive") + self.assertEqual(archive.status_code, 403) + self.db.session.refresh(player) + self.assertEqual(player.status, "active") + + def test_owner_can_create_same_day_sessions_and_close_them(self): + from boker.db_models import LedgerEvent, PokerSession + + _, _, league_base = self.create_logged_in_league() + + first = self.client.post( + f"{league_base}/sessions", + data={ + "session_date": "2026-10-02", + "label": "", + "status": "open", + "notes": "Main game", + }, + ) + second = self.client.post( + f"{league_base}/sessions", + data={ + "session_date": "2026-10-02", + "label": "Late table", + "status": "open", + }, + ) + + self.assertEqual(first.status_code, 302) + self.assertEqual(second.status_code, 302) + + sessions = PokerSession.query.order_by(PokerSession.sequence_on_date.asc()).all() + self.assertEqual([session.sequence_on_date for session in sessions], [1, 2]) + self.assertEqual(sessions[0].status, "open") + self.assertEqual(sessions[0].notes, "Main game") + self.assertEqual(sessions[1].display_label, "Late table") + + events = LedgerEvent.query.order_by(LedgerEvent.created_at.asc()).all() + self.assertEqual([event.event_type for event in events], ["session_open", "session_open"]) + + close = self.client.post(f"{league_base}/sessions/{sessions[0].id}/close") + self.assertEqual(close.status_code, 302) + self.db.session.refresh(sessions[0]) + self.assertEqual(sessions[0].status, "closed") + self.assertIsNotNone(sessions[0].closed_at) + self.assertEqual(LedgerEvent.query.count(), 3) + self.assertEqual( + LedgerEvent.query.order_by(LedgerEvent.created_at.desc()).first().event_type, + "session_close", + ) + + def test_closed_session_creation_does_not_append_marker_event_until_opened(self): + from boker.db_models import LedgerEvent, PokerSession + + _, _, league_base = self.create_logged_in_league() + self.client.post( + f"{league_base}/sessions", + data={"session_date": "2026-10-02", "status": "closed"}, + ) + + session = PokerSession.query.one() + self.assertEqual(session.status, "closed") + self.assertEqual(LedgerEvent.query.count(), 0) + + open_response = self.client.post(f"{league_base}/sessions/{session.id}/open") + self.assertEqual(open_response.status_code, 302) + self.db.session.refresh(session) + self.assertEqual(session.status, "open") + self.assertIsNotNone(session.opened_at) + self.assertEqual(LedgerEvent.query.one().event_type, "session_open") + + def test_viewer_can_view_sessions_but_cannot_create_or_close(self): + from boker.db import db + from boker.db_models import LeagueMembership, PokerSession + from boker.league_repositories import create_league, create_poker_session, create_user + + owner = create_user("owner@example.com", "password123") + viewer = create_user("viewer@example.com", "password123") + db.session.flush() + league = create_league(owner, "Private League", "private-league") + session = create_poker_session(league.id, date(2026, 10, 2), status="open") + db.session.flush() + db.session.add( + LeagueMembership( + league_id=league.id, + user_id=viewer.id, + role="viewer", + ) + ) + db.session.commit() + + with self.client.session_transaction() as flask_session: + flask_session["user_id"] = viewer.id + + league_base = self.league_path(league) + response = self.client.get(f"{league_base}/sessions") + self.assertEqual(response.status_code, 200) + self.assertIn(b"2026-10-02", response.data) + self.assertNotIn(b"Create session", response.data) + + create = self.client.post( + f"{league_base}/sessions", + data={"session_date": "2026-10-03"}, + ) + self.assertEqual(create.status_code, 403) + self.assertEqual(PokerSession.query.count(), 1) + + close = self.client.post(f"{league_base}/sessions/{session.id}/close") + self.assertEqual(close.status_code, 403) + self.db.session.refresh(session) + self.assertEqual(session.status, "open") + + def test_owner_can_append_events_and_view_db_backed_stats(self): + from boker.db_models import LedgerEvent, Player, PokerSession + + _, _, league_base = self.create_logged_in_league() + self.client.post(f"{league_base}/players", data={"display_name": "Alex"}) + self.client.post( + f"{league_base}/sessions", + data={"session_date": "2026-10-02", "status": "open"}, + ) + + player = Player.query.one() + session = PokerSession.query.one() + + buyin = self.client.post( + f"{league_base}/sessions/{session.id}", + data={ + "player_id": player.id, + "event_type": "buyin", + "amount": "20", + }, + ) + cashout = self.client.post( + f"{league_base}/sessions/{session.id}", + data={ + "player_id": player.id, + "event_type": "cashout", + "amount": "35", + }, + ) + paid = self.client.post( + f"{league_base}/sessions/{session.id}", + data={ + "player_id": player.id, + "event_type": "paid_out", + "amount": "15", + }, + ) + + self.assertEqual(buyin.status_code, 302) + self.assertEqual(cashout.status_code, 302) + self.assertEqual(paid.status_code, 302) + self.assertEqual(LedgerEvent.query.filter_by(player_id=player.id).count(), 3) + + leaderboard = self.client.get(f"{league_base}/leaderboard") + self.assertEqual(leaderboard.status_code, 200) + self.assertIn(b"Alex", leaderboard.data) + self.assertIn(b"15.00", leaderboard.data) + + detail = self.client.get(f"{league_base}/sessions/{session.id}") + self.assertEqual(detail.status_code, 200) + self.assertIn(b"cashout", detail.data) + self.assertIn(b"paid out", detail.data) + + player_detail = self.client.get(f"{league_base}/players/{player.id}") + self.assertEqual(player_detail.status_code, 200) + self.assertIn(b"Total net", player_detail.data) + self.assertIn(b"20.00", player_detail.data) + + def test_league_ledger_shows_and_resolves_player_debts(self): + from boker.db_models import LedgerEvent, Player, PokerSession + + _, _, league_base = self.create_logged_in_league() + self.client.post(f"{league_base}/players", data={"display_name": "Alex"}) + self.client.post( + f"{league_base}/sessions", + data={"session_date": "2026-10-02", "status": "open"}, + ) + + player = Player.query.one() + session = PokerSession.query.one() + self.client.post( + f"{league_base}/sessions/{session.id}", + data={"player_id": player.id, "event_type": "front", "amount": "50"}, + ) + self.client.post( + f"{league_base}/sessions/{session.id}", + data={"player_id": player.id, "event_type": "cashout", "amount": "20"}, + ) + + ledger = self.client.get(f"{league_base}/ledger") + self.assertEqual(ledger.status_code, 200) + self.assertIn(b"Debt resolution", ledger.data) + self.assertIn(b"Front receivables", ledger.data) + self.assertIn(b"30.00", ledger.data) + + repayment = self.client.post( + f"{league_base}/ledger", + data={ + "session_id": session.id, + "player_id": player.id, + "event_type": "debt_repayment", + "amount": "10", + }, + ) + self.assertEqual(repayment.status_code, 302) + self.assertEqual( + LedgerEvent.query.order_by(LedgerEvent.created_at.desc()).first().event_type, + "debt_repayment", + ) + + ledger = self.client.get(f"{league_base}/ledger") + self.assertEqual(ledger.status_code, 200) + self.assertIn(b"20.00", ledger.data) + + def test_viewer_cannot_append_ledger_events(self): + from boker.db import db + from boker.db_models import LeagueMembership, LedgerEvent + from boker.league_repositories import ( + create_league, + create_player, + create_poker_session, + create_user, + ) + + owner = create_user("owner@example.com", "password123") + viewer = create_user("viewer@example.com", "password123") + db.session.flush() + league = create_league(owner, "Private League", "private-league") + player = create_player(league.id, "Alex", "alex") + session = create_poker_session(league.id, date(2026, 10, 2), status="open") + db.session.flush() + db.session.add( + LeagueMembership( + league_id=league.id, + user_id=viewer.id, + role="viewer", + ) + ) + db.session.commit() + + with self.client.session_transaction() as flask_session: + flask_session["user_id"] = viewer.id + + response = self.client.post( + f"{self.league_path(league)}/sessions/{session.id}", + data={ + "player_id": player.id, + "event_type": "buyin", + "amount": "20", + }, + ) + self.assertEqual(response.status_code, 403) + self.assertEqual(LedgerEvent.query.count(), 0) + + +if __name__ == "__main__": + unittest.main()