initial commit
This commit is contained in:
commit
c35773d337
15 files changed
+2095
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
SECRET_KEY=replace-this-with-a-long-random-string
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=replace-this-password
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.env
|
||||||
|
/__pycache__/*
|
||||||
|
/.venv/*
|
||||||
|
/data/*
|
||||||
@@ -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.
|
||||||
@@ -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/<session_date>")
|
||||||
|
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/<player_name>")
|
||||||
|
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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Flask==3.1.0
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
+102
@@ -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
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Admin · Poker Portal{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="hero-card compact">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Admin</p>
|
||||||
|
<h1>Ledger controls</h1>
|
||||||
|
<p class="muted-text">Append new buy-ins, cash-outs, corrections, and note-only events to the audit trail.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid two-col admin-layout">
|
||||||
|
<form class="panel form-card" method="post">
|
||||||
|
<p class="eyebrow">New event</p>
|
||||||
|
<h2>Add to the ledger</h2>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>Session date</span>
|
||||||
|
<input type="date" name="session_date" required>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>Player name</span>
|
||||||
|
<input list="player_names" type="text" name="player_name" placeholder="Braeden" required>
|
||||||
|
<datalist id="player_names">
|
||||||
|
{% for player_name in player_names %}
|
||||||
|
<option value="{{ player_name }}"></option>
|
||||||
|
{% endfor %}
|
||||||
|
</datalist>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>Event type</span>
|
||||||
|
<select name="event_type" required>
|
||||||
|
<option value="buyin">Buy-in</option>
|
||||||
|
<option value="cashout">Cash-out</option>
|
||||||
|
<option value="note">Note only</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>Amount</span>
|
||||||
|
<input type="number" step="0.01" name="amount" placeholder="50.00">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>Note</span>
|
||||||
|
<textarea name="note" rows="4" placeholder="Rebuy, correction, settled chips later, etc."></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="helper-box">
|
||||||
|
<strong>Tip:</strong>
|
||||||
|
<span>Use positive amounts for normal buy-ins and cash-outs. Use negative amounts to correct a previous entry without editing history.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="primary-button" type="submit">Add event</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="stack-gap">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Recent sessions</p>
|
||||||
|
<h2>Current totals</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap small-table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Players</th>
|
||||||
|
<th>Buy-ins</th>
|
||||||
|
<th>Cash-outs</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for session in recent_sessions %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ url_for('session_detail', session_date=session.session_date) }}">{{ session.session_date | pretty_date }}</a></td>
|
||||||
|
<td>{{ session.entries|length }}</td>
|
||||||
|
<td>{{ session.total_buy_in_cents | money }}</td>
|
||||||
|
<td>{{ session.total_cash_out_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4">No sessions yet.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Recent raw events</p>
|
||||||
|
<h2>Ledger feed</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="event-feed condensed">
|
||||||
|
{% for event in recent_events %}
|
||||||
|
<article class="event-card">
|
||||||
|
<div class="event-card-top">
|
||||||
|
<strong>{{ event.player_name }}</strong>
|
||||||
|
<span class="pill">{{ event.event_type }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="event-amount">{{ event.amount_cents | money }}</p>
|
||||||
|
<p class="muted-text small">{{ event.session_date | pretty_date }}</p>
|
||||||
|
{% if event.note %}<p class="muted-text small">{{ event.note }}</p>{% endif %}
|
||||||
|
</article>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted-text">No recent events.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Admin Login · Poker Portal{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="form-shell narrow">
|
||||||
|
<form class="panel form-card" method="post">
|
||||||
|
<p class="eyebrow">Restricted access</p>
|
||||||
|
<h1>Admin login</h1>
|
||||||
|
<p class="muted-text">Only the ledger manager needs credentials. All stats pages stay public.</p>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>Username</span>
|
||||||
|
<input type="text" name="username" required>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>Password</span>
|
||||||
|
<input type="password" name="password" required>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button class="primary-button" type="submit">Login</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block title %}Poker Portal{% endblock %}</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="site-shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<a class="brand" href="{{ url_for('leaderboard') }}">Poker Portal</a>
|
||||||
|
<p class="brand-subtitle">Low-stakes hold'em ledger and session tracker</p>
|
||||||
|
</div>
|
||||||
|
<nav class="nav-links">
|
||||||
|
<a href="{{ url_for('leaderboard') }}">Leaderboard</a>
|
||||||
|
<a href="{{ url_for('sessions') }}">Sessions</a>
|
||||||
|
{% if is_admin %}
|
||||||
|
<a href="{{ url_for('admin_dashboard') }}">Admin</a>
|
||||||
|
<form method="post" action="{{ url_for('admin_logout') }}">
|
||||||
|
<button class="ghost-button" type="submit">Logout</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('admin_login') }}">Admin Login</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
<section class="flash-stack">
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="flash {{ category }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<main class="page-content">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Leaderboard · Poker Portal{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="hero-card">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">All-time results</p>
|
||||||
|
<h1>Leaderboard</h1>
|
||||||
|
<p class="muted-text">Track who is up, who is chasing, and how everyone has moved across {{ session_count }} recorded sessions.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid two-col leaderboard-layout">
|
||||||
|
<div class="panel chart-panel chart-panel-large">
|
||||||
|
<div class="panel-header panel-header-with-actions">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Trend</p>
|
||||||
|
<h2>Cumulative profit over time</h2>
|
||||||
|
</div>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<button class="secondary-button" type="button" id="expandLeaderboardChart">Expand chart</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-frame chart-frame-interactive" id="leaderboardChartFrame" role="button" tabindex="0" aria-label="Expand cumulative profit chart">
|
||||||
|
<canvas id="leaderboardChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel stat-panel">
|
||||||
|
<p class="eyebrow">Quick read</p>
|
||||||
|
<h2>Table snapshot</h2>
|
||||||
|
<div class="stat-list">
|
||||||
|
<div class="stat-row"><span>Players tracked</span><strong>{{ leaderboard|length }}</strong></div>
|
||||||
|
<div class="stat-row"><span>Sessions tracked</span><strong>{{ session_count }}</strong></div>
|
||||||
|
<div class="stat-row"><span>Best player</span><strong>{% if leaderboard %}{{ leaderboard[0].player_name }}{% else %}—{% endif %}</strong></div>
|
||||||
|
<div class="stat-row"><span>Worst player</span><strong>{% if leaderboard %}{{ leaderboard[-1].player_name }}{% else %}—{% endif %}</strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Standings</p>
|
||||||
|
<h2>All-time leaderboard</h2>
|
||||||
|
</div>
|
||||||
|
<p class="muted-text small">Click any column to sort.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table id="leaderboardTable" class="sortable-table" data-sort-key="net" data-sort-direction="desc">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><button type="button" class="sort-button" data-sort-key="rank" data-sort-type="number" data-initial-direction="asc">Rank <span class="sort-indicator">↕</span></button></th>
|
||||||
|
<th><button type="button" class="sort-button" data-sort-key="player" data-sort-type="string" data-initial-direction="asc">Player <span class="sort-indicator">↕</span></button></th>
|
||||||
|
<th><button type="button" class="sort-button" data-sort-key="buyins" data-sort-type="number" data-initial-direction="desc">Buy-ins <span class="sort-indicator">↕</span></button></th>
|
||||||
|
<th><button type="button" class="sort-button" data-sort-key="cashouts" data-sort-type="number" data-initial-direction="desc">Cash-outs <span class="sort-indicator">↕</span></button></th>
|
||||||
|
<th><button type="button" class="sort-button is-active" data-sort-key="net" data-sort-type="number" data-initial-direction="desc">Net <span class="sort-indicator">↓</span></button></th>
|
||||||
|
<th><button type="button" class="sort-button" data-sort-key="winpct" data-sort-type="number" data-initial-direction="desc">Win % <span class="sort-indicator">↕</span></button></th>
|
||||||
|
<th><button type="button" class="sort-button" data-sort-key="roi" data-sort-type="number" data-initial-direction="desc">ROI <span class="sort-indicator">↕</span></button></th>
|
||||||
|
<th><button type="button" class="sort-button" data-sort-key="avgwin" data-sort-type="number" data-initial-direction="desc">Avg Win <span class="sort-indicator">↕</span></button></th>
|
||||||
|
<th><button type="button" class="sort-button" data-sort-key="avgloss" data-sort-type="number" data-initial-direction="desc">Avg Loss <span class="sort-indicator">↕</span></button></th>
|
||||||
|
<th><button type="button" class="sort-button" data-sort-key="sessions" data-sort-type="number" data-initial-direction="desc">Sessions <span class="sort-indicator">↕</span></button></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for player in leaderboard %}
|
||||||
|
<tr data-player-row="true" data-original-rank="{{ loop.index }}">
|
||||||
|
<td data-sort-value="{{ loop.index }}" data-rank-cell>#{{ loop.index }}</td>
|
||||||
|
<td data-sort-value="{{ player.player_name|lower }}"><a href="{{ url_for('player_detail', player_name=player.player_name) }}">{{ player.player_name }}</a></td>
|
||||||
|
<td data-sort-value="{{ player.total_buy_in_cents }}">{{ player.total_buy_in_cents | money }}</td>
|
||||||
|
<td data-sort-value="{{ player.total_cash_out_cents }}">{{ player.total_cash_out_cents | money }}</td>
|
||||||
|
<td data-sort-value="{{ player.total_net_cents }}" class="{{ 'positive' if player.total_net_cents > 0 else 'negative' if player.total_net_cents < 0 else 'neutral' }}">{{ player.total_net_cents | money }}</td>
|
||||||
|
<td data-sort-value="{{ '%.4f'|format(player.win_pct) }}">{{ "%.1f"|format(player.win_pct) }}%</td>
|
||||||
|
<td data-sort-value="{{ '%.4f'|format(player.roi_pct) }}">{{ "%.1f"|format(player.roi_pct) }}%</td>
|
||||||
|
<td data-sort-value="{{ player.avg_win_cents }}">{{ player.avg_win_cents | money }}</td>
|
||||||
|
<td data-sort-value="{{ player.avg_loss_cents }}">{{ player.avg_loss_cents | money }}</td>
|
||||||
|
<td data-sort-value="{{ player.sessions_played }}">{{ player.sessions_played }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="10">No data yet. Add the first event from the admin page.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="chart-modal" id="chartModal" aria-hidden="true">
|
||||||
|
<div class="chart-modal-backdrop" data-close-chart-modal></div>
|
||||||
|
<div class="chart-modal-card">
|
||||||
|
<div class="panel-header panel-header-with-actions">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Expanded view</p>
|
||||||
|
<h2>Cumulative profit over time</h2>
|
||||||
|
</div>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<button class="secondary-button" type="button" id="fullscreenLeaderboardChart">Fullscreen</button>
|
||||||
|
<button class="ghost-button" type="button" data-close-chart-modal>Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-modal-frame" id="chartModalFrame">
|
||||||
|
<canvas id="leaderboardChartExpanded"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const leaderboardChartData = {{ chart_data | tojson }};
|
||||||
|
const rootStyles = getComputedStyle(document.documentElement);
|
||||||
|
const textMuted = rootStyles.getPropertyValue('--text-muted').trim();
|
||||||
|
const lineColor = rootStyles.getPropertyValue('--line').trim();
|
||||||
|
|
||||||
|
function buildLeaderboardChartConfig(chartData) {
|
||||||
|
return {
|
||||||
|
type: 'line',
|
||||||
|
data: chartData,
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: { mode: 'nearest', intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
labels: {
|
||||||
|
color: textMuted,
|
||||||
|
usePointStyle: true,
|
||||||
|
boxWidth: 10,
|
||||||
|
boxHeight: 10,
|
||||||
|
padding: 18,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label(context) {
|
||||||
|
const value = context.parsed.y;
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return `${context.dataset.label}: —`;
|
||||||
|
}
|
||||||
|
return `${context.dataset.label}: $${Number(value).toFixed(2)}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: { color: textMuted, maxRotation: 0 },
|
||||||
|
grid: { color: lineColor },
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
ticks: {
|
||||||
|
color: textMuted,
|
||||||
|
callback(value) {
|
||||||
|
return `$${value}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: { color: lineColor },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const leaderboardCtx = document.getElementById('leaderboardChart');
|
||||||
|
let leaderboardChart = null;
|
||||||
|
if (leaderboardCtx && leaderboardChartData.labels.length > 0) {
|
||||||
|
leaderboardChart = new Chart(leaderboardCtx, buildLeaderboardChartConfig(leaderboardChartData));
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartModal = document.getElementById('chartModal');
|
||||||
|
const expandButton = document.getElementById('expandLeaderboardChart');
|
||||||
|
const chartFrame = document.getElementById('leaderboardChartFrame');
|
||||||
|
const fullscreenButton = document.getElementById('fullscreenLeaderboardChart');
|
||||||
|
const modalFrame = document.getElementById('chartModalFrame');
|
||||||
|
const expandedCtx = document.getElementById('leaderboardChartExpanded');
|
||||||
|
let expandedChart = null;
|
||||||
|
|
||||||
|
function openChartModal() {
|
||||||
|
if (!chartModal || !expandedCtx || leaderboardChartData.labels.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
chartModal.classList.add('is-open');
|
||||||
|
chartModal.setAttribute('aria-hidden', 'false');
|
||||||
|
document.body.classList.add('modal-open');
|
||||||
|
|
||||||
|
if (expandedChart) {
|
||||||
|
expandedChart.destroy();
|
||||||
|
}
|
||||||
|
expandedChart = new Chart(expandedCtx, buildLeaderboardChartConfig(leaderboardChartData));
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeChartModal() {
|
||||||
|
if (!chartModal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chartModal.classList.remove('is-open');
|
||||||
|
chartModal.setAttribute('aria-hidden', 'true');
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
if (expandedChart) {
|
||||||
|
expandedChart.destroy();
|
||||||
|
expandedChart = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expandButton?.addEventListener('click', openChartModal);
|
||||||
|
chartFrame?.addEventListener('click', openChartModal);
|
||||||
|
chartFrame?.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
openChartModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
chartModal?.querySelectorAll('[data-close-chart-modal]').forEach((element) => {
|
||||||
|
element.addEventListener('click', closeChartModal);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape' && chartModal?.classList.contains('is-open')) {
|
||||||
|
closeChartModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fullscreenButton?.addEventListener('click', async () => {
|
||||||
|
if (!modalFrame) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
await document.exitFullscreen();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (modalFrame.requestFullscreen) {
|
||||||
|
await modalFrame.requestFullscreen();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const leaderboardTable = document.getElementById('leaderboardTable');
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
function updateRankCells() {
|
||||||
|
if (!leaderboardTable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Array.from(leaderboardTable.tBodies[0].querySelectorAll('tr[data-player-row="true"]')).forEach((row, index) => {
|
||||||
|
const rankCell = row.querySelector('[data-rank-cell]');
|
||||||
|
if (rankCell) {
|
||||||
|
rankCell.textContent = `#${index + 1}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveSortButton(sortKey, direction) {
|
||||||
|
sortButtons.forEach((button) => {
|
||||||
|
const isActive = button.dataset.sortKey === sortKey;
|
||||||
|
button.classList.toggle('is-active', isActive);
|
||||||
|
const indicator = button.querySelector('.sort-indicator');
|
||||||
|
if (indicator) {
|
||||||
|
indicator.textContent = isActive ? (direction === 'asc' ? '↑' : '↓') : '↕';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortLeaderboard(sortKey, sortType, forcedDirection) {
|
||||||
|
if (!leaderboardTable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tbody = leaderboardTable.tBodies[0];
|
||||||
|
const rows = Array.from(tbody.querySelectorAll('tr[data-player-row="true"]'));
|
||||||
|
const currentKey = leaderboardTable.dataset.sortKey;
|
||||||
|
const currentDirection = leaderboardTable.dataset.sortDirection || 'desc';
|
||||||
|
let direction = forcedDirection;
|
||||||
|
|
||||||
|
if (!direction) {
|
||||||
|
if (currentKey === sortKey) {
|
||||||
|
direction = currentDirection === 'desc' ? 'asc' : 'desc';
|
||||||
|
} else {
|
||||||
|
const trigger = sortButtons.find((button) => button.dataset.sortKey === sortKey);
|
||||||
|
direction = trigger?.dataset.initialDirection || 'desc';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const multiplier = direction === 'asc' ? 1 : -1;
|
||||||
|
const columnIndex = columnIndexMap[sortKey] ?? 0;
|
||||||
|
|
||||||
|
rows.sort((leftRow, rightRow) => {
|
||||||
|
const leftCell = leftRow.children[columnIndex];
|
||||||
|
const rightCell = rightRow.children[columnIndex];
|
||||||
|
const leftValue = leftCell?.dataset.sortValue ?? '';
|
||||||
|
const rightValue = rightCell?.dataset.sortValue ?? '';
|
||||||
|
|
||||||
|
if (sortType === 'number') {
|
||||||
|
const numericResult = (Number(leftValue) - Number(rightValue)) * multiplier;
|
||||||
|
if (numericResult !== 0) {
|
||||||
|
return numericResult;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const stringResult = leftValue.localeCompare(rightValue) * multiplier;
|
||||||
|
if (stringResult !== 0) {
|
||||||
|
return stringResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number(leftRow.dataset.originalRank) - Number(rightRow.dataset.originalRank);
|
||||||
|
});
|
||||||
|
|
||||||
|
rows.forEach((row) => tbody.appendChild(row));
|
||||||
|
leaderboardTable.dataset.sortKey = sortKey;
|
||||||
|
leaderboardTable.dataset.sortDirection = direction;
|
||||||
|
updateRankCells();
|
||||||
|
setActiveSortButton(sortKey, direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
sortButtons.forEach((button) => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
sortLeaderboard(button.dataset.sortKey, button.dataset.sortType);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setActiveSortButton('net', 'desc');
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ player.player_name }} · Poker Portal{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="hero-card compact">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Player profile</p>
|
||||||
|
<h1>{{ player.player_name }}</h1>
|
||||||
|
<p class="muted-text">A quick read on lifetime performance, hit rate, and session-by-session movement.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid three-col">
|
||||||
|
<article class="panel stat-card"><span>Total Net</span><strong class="{{ 'positive' if player.total_net_cents > 0 else 'negative' if player.total_net_cents < 0 else 'neutral' }}">{{ player.total_net_cents | money }}</strong></article>
|
||||||
|
<article class="panel stat-card"><span>Win %</span><strong>{{ "%.1f"|format(player.win_pct) }}%</strong></article>
|
||||||
|
<article class="panel stat-card"><span>ROI</span><strong>{{ "%.1f"|format(player.roi_pct) }}%</strong></article>
|
||||||
|
<article class="panel stat-card"><span>Buy-ins</span><strong>{{ player.total_buy_in_cents | money }}</strong></article>
|
||||||
|
<article class="panel stat-card"><span>Cash-outs</span><strong>{{ player.total_cash_out_cents | money }}</strong></article>
|
||||||
|
<article class="panel stat-card"><span>Sessions</span><strong>{{ player.sessions_played }}</strong></article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid two-col">
|
||||||
|
<div class="panel chart-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Trend</p>
|
||||||
|
<h2>Cumulative profit</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-frame">
|
||||||
|
<canvas id="playerCumulativeChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel chart-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Volatility</p>
|
||||||
|
<h2>Session results</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-frame">
|
||||||
|
<canvas id="playerSessionChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Breakdown</p>
|
||||||
|
<h2>Detailed stats</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr><th>Winning Sessions</th><td>{{ player.winning_sessions }}</td></tr>
|
||||||
|
<tr><th>Losing Sessions</th><td>{{ player.losing_sessions }}</td></tr>
|
||||||
|
<tr><th>Break-even Sessions</th><td>{{ player.break_even_sessions }}</td></tr>
|
||||||
|
<tr><th>Average Win</th><td>{{ player.avg_win_cents | money }}</td></tr>
|
||||||
|
<tr><th>Average Loss</th><td>{{ player.avg_loss_cents | money }}</td></tr>
|
||||||
|
<tr><th>Biggest Win</th><td>{{ player.biggest_win_cents | money }}</td></tr>
|
||||||
|
<tr><th>Biggest Loss</th><td>{{ player.biggest_loss_cents | money }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const playerChartData = {{ chart_data | tojson }};
|
||||||
|
const rootStyles = getComputedStyle(document.documentElement);
|
||||||
|
const textMuted = rootStyles.getPropertyValue('--text-muted').trim();
|
||||||
|
const lineColor = rootStyles.getPropertyValue('--line').trim();
|
||||||
|
|
||||||
|
const sharedScaleOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
labels: {
|
||||||
|
color: textMuted,
|
||||||
|
usePointStyle: true,
|
||||||
|
boxWidth: 10,
|
||||||
|
boxHeight: 10,
|
||||||
|
padding: 18,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: { color: textMuted, maxRotation: 0 },
|
||||||
|
grid: { color: lineColor },
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
ticks: { color: textMuted },
|
||||||
|
grid: { color: lineColor },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const cumulativeCtx = document.getElementById('playerCumulativeChart');
|
||||||
|
if (cumulativeCtx) {
|
||||||
|
new Chart(cumulativeCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: playerChartData.labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Cumulative Profit',
|
||||||
|
data: playerChartData.cumulative_values,
|
||||||
|
borderColor: playerChartData.color,
|
||||||
|
backgroundColor: playerChartData.color,
|
||||||
|
borderWidth: 2.5,
|
||||||
|
pointRadius: 3,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
tension: 0.22,
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: sharedScaleOptions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionCtx = document.getElementById('playerSessionChart');
|
||||||
|
if (sessionCtx) {
|
||||||
|
new Chart(sessionCtx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: playerChartData.labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Session Net',
|
||||||
|
data: playerChartData.net_values,
|
||||||
|
backgroundColor: playerChartData.net_colors,
|
||||||
|
borderColor: playerChartData.net_colors,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderSkipped: false,
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: sharedScaleOptions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ session.session_date }} · Poker Portal{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="hero-card compact">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Session detail</p>
|
||||||
|
<h1>{{ session.session_date | pretty_date }}</h1>
|
||||||
|
<p class="muted-text">Current totals are derived from the append-only event log for this date.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid two-col session-layout">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Results</p>
|
||||||
|
<h2>Player totals</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Player</th>
|
||||||
|
<th>Buy-in</th>
|
||||||
|
<th>Cash-out</th>
|
||||||
|
<th>Net</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for entry in session.entries %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ url_for('player_detail', player_name=entry.player_name) }}">{{ entry.player_name }}</a></td>
|
||||||
|
<td>{{ entry.buy_in_cents | money }}</td>
|
||||||
|
<td>{{ entry.cash_out_cents | money }}</td>
|
||||||
|
<td class="{{ 'positive' if entry.net_cents > 0 else 'negative' if entry.net_cents < 0 else '' }}">{{ entry.net_cents | money }}</td>
|
||||||
|
<td>
|
||||||
|
{% if entry.notes %}
|
||||||
|
<ul class="note-list">
|
||||||
|
{% for note in entry.notes %}
|
||||||
|
<li>{{ note }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
—
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Audit trail</p>
|
||||||
|
<h2>Raw ledger events</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="event-feed">
|
||||||
|
{% for event in raw_events | reverse %}
|
||||||
|
<article class="event-card">
|
||||||
|
<div class="event-card-top">
|
||||||
|
<strong>{{ event.player_name }}</strong>
|
||||||
|
<span class="pill">{{ event.event_type }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="event-amount">{{ event.amount_cents | money }}</p>
|
||||||
|
{% if event.note %}
|
||||||
|
<p class="muted-text small">{{ event.note }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<p class="tiny-text">{{ event.created_at }} · by {{ event.actor }}</p>
|
||||||
|
</article>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted-text">No raw events recorded.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Sessions · Poker Portal{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="hero-card compact">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Session history</p>
|
||||||
|
<h1>Sessions</h1>
|
||||||
|
<p class="muted-text">Browse every recorded game night, inspect the table totals, and drill into each player result.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Archive</p>
|
||||||
|
<h2>All sessions by date</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Players</th>
|
||||||
|
<th>Total Buy-ins</th>
|
||||||
|
<th>Total Cash-outs</th>
|
||||||
|
<th>Table Net</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for session in sessions %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ url_for('session_detail', session_date=session.session_date) }}">{{ session.session_date | pretty_date }}</a></td>
|
||||||
|
<td>{{ session.entries|length }}</td>
|
||||||
|
<td>{{ session.total_buy_in_cents | money }}</td>
|
||||||
|
<td>{{ session.total_cash_out_cents | money }}</td>
|
||||||
|
<td class="{{ 'positive' if session.total_net_cents > 0 else 'negative' if session.total_net_cents < 0 else '' }}">{{ session.total_net_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5">No sessions yet.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
Reference in new issue
Block a user