This commit is contained in:
SowinskiBraeden committed 2026-06-22 00:25:57 -07:00
1 parent 7e7b3a7011
commit 77198e5b47
19 files changed
+2920 -2656

No files matched your search

+10
View File
@@ -58,6 +58,16 @@ A few examples:
- a `note` event for bookkeeping context - a `note` event for bookkeeping context
- `session_open` / `session_close` to mark whether a game night is still live - `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. 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 ## CSV format
+139 -34
View File
@@ -3,7 +3,6 @@ from __future__ import annotations
import csv import csv
import os import os
import shutil
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -32,12 +31,23 @@ from stats import (
session_sort_key, session_sort_key,
unique_player_names, 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 BASE_DIR = Path(__file__).resolve().parent
DATA_PATH = BASE_DIR / "data" / "entries.csv" DATA_PATH = BASE_DIR / "data" / "entries.csv"
ENV_PATH = BASE_DIR / ".env" 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: def load_local_env(env_path: Path) -> None:
if not env_path.exists(): if not env_path.exists():
@@ -105,18 +115,20 @@ def leaderboard() -> str:
all_sessions = build_session_summaries(events) all_sessions = build_session_summaries(events)
ordered_sessions = sorted(all_sessions, key=session_sort_key) 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() 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 = "" label = ""
cutoff_index = len(session_ids) - 1
if session_ids: if session_ids:
if selected_session_id in session_ids: if selected_session_id in session_ids:
cutoff_index = session_ids.index(selected_session_id) cutoff_index = session_ids.index(selected_session_id)
label = session_label(ordered_sessions[cutoff_index]) label = session_label(ordered_sessions[cutoff_index])
else: else:
cutoff_index = len(session_ids) - 1 label = session_label(ordered_sessions[cutoff_index])
selected_session = ordered_sessions[cutoff_index]
label = session_label(selected_session)
filtered_sessions = ordered_sessions[: cutoff_index + 1] filtered_sessions = ordered_sessions[: cutoff_index + 1]
previous_sessions = ordered_sessions[:cutoff_index] previous_sessions = ordered_sessions[:cutoff_index]
@@ -124,17 +136,48 @@ def leaderboard() -> str:
filtered_sessions = [] filtered_sessions = []
previous_sessions = [] previous_sessions = []
# Build full board (all filtered sessions) for rank changes and counts
board = build_leaderboard(filtered_sessions) board = build_leaderboard(filtered_sessions)
previous_board = build_leaderboard(previous_sessions) previous_board = build_leaderboard(previous_sessions)
board = apply_rank_changes(board, previous_board) 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) 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( return render_template(
"leaderboard.html", "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), session_count=len(filtered_sessions),
total_session_count=len(all_sessions), total_session_count=len(all_sessions),
cash_paid_out_cents=cash_paid_out_cents,
chart_data=chart_data, chart_data=chart_data,
available_sessions=all_sessions, available_sessions=all_sessions,
selected_session_id=selected_session_id, selected_session_id=selected_session_id,
@@ -190,6 +233,15 @@ def session_detail(session_id: str) -> str:
return redirect(url_for("sessions")) return redirect(url_for("sessions"))
target_session = sessions[target_index] 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 = ( prev_session = (
sessions[target_index + 1] if target_index < len(sessions) - 1 else None 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, session=target_session,
next_session=next_session, next_session=next_session,
prev_session=prev_session, prev_session=prev_session,
session_number=session_number,
raw_events=session_events(events, session_id), raw_events=session_events(events, session_id),
chart_data=session_breakdown_series(target_session), chart_data=session_breakdown_series(target_session),
session_label=session_label, session_label=session_label,
@@ -218,10 +271,19 @@ def player_detail(player_name: str) -> str:
flash("That player was not found.", "error") flash("That player was not found.", "error")
return redirect(url_for("leaderboard")) 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) chart_data = player_session_series(sessions, player_name)
return render_template( return render_template(
"player_detail.html", "player_detail.html",
player=player_stats, player=player_stats,
player_rank=player_rank,
chart_data=chart_data, chart_data=chart_data,
) )
@@ -290,6 +352,7 @@ def admin_session_state() -> str:
@app.get("/admin/export") @app.get("/admin/export")
@app.get("/admin/export.csv")
def admin_export_csv(): def admin_export_csv():
if not is_admin(): if not is_admin():
flash("Admin login required.", "error") flash("Admin login required.", "error")
@@ -333,37 +396,39 @@ def admin_import_csv():
flash("CSV file is empty.", "error") flash("CSV file is empty.", "error")
return redirect(url_for("admin_dashboard")) 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: try:
uploaded_header = next(csv.reader(uploaded_lines)) uploaded_header = next(csv.reader(uploaded_lines))
except Exception: except Exception:
flash("Could not read the uploaded CSV header.", "error") flash("Could not read the uploaded CSV header.", "error")
return redirect(url_for("admin_dashboard")) 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") flash("CSV header does not match the current audit format.", "error")
return redirect(url_for("admin_dashboard")) 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: 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: new_rows.append({header: row.get(header, "") for header in CSV_HEADERS})
handle.write(uploaded_text) 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: except Exception:
flash("Import failed. Existing CSV was not updated.", "error") flash("Import failed. Existing CSV was not updated.", "error")
@@ -582,13 +647,41 @@ def admin_dashboard():
) )
flash("Event added.", "success") 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) events = load_events(DATA_PATH)
sessions = build_session_summaries(events) sessions = build_session_summaries(events)
open_sessions = [session for session in sessions if session.status == "open"] 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_sessions = sessions[:8]
recent_events = list(reversed(events[-20:])) 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 = [ debt_entries = [
{"session": session, "entry": entry} {"session": session, "entry": entry}
for session in sessions for session in sessions
@@ -598,12 +691,12 @@ def admin_dashboard():
admin_totals = { admin_totals = {
"cash_in_cents": sum(session.total_cash_in_cents for session in sessions), "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), "cash_out_cents": sum(session.total_paid_out_cents for session in sessions),
"still_owed_cents": sum( "house_owes_players_cents": sum(
session.total_current_due_cents for session in sessions session.total_current_due_to_player_cents for session in sessions
), ),
"players_owe_cents": sum( "players_owe_house_cents": sum(
session.total_player_owes_cents for session in sessions session.total_current_due_to_house_cents for session in sessions
), ),
"collected_cents": sum( "collected_cents": sum(
session.total_front_collected_cents for session in sessions session.total_front_collected_cents for session in sessions
@@ -611,10 +704,14 @@ def admin_dashboard():
"written_off_cents": sum( "written_off_cents": sum(
session.total_front_writeoff_cents for session in sessions session.total_front_writeoff_cents for session in sessions
), ),
"open_balance_cents": sum( "net_book_position_cents": sum(
session.total_open_balance_cents for session in sessions 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( return render_template(
"admin_dashboard.html", "admin_dashboard.html",
@@ -622,7 +719,15 @@ def admin_dashboard():
recent_sessions=recent_sessions, recent_sessions=recent_sessions,
recent_events=recent_events, recent_events=recent_events,
debt_entries=debt_entries, 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), 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, session_label=session_label,
admin_totals=admin_totals, admin_totals=admin_totals,
) )
+3
View File
@@ -0,0 +1,3 @@
# Memory Index
- [Project overview](project_overview.md) — Flask poker ledger app, Graphite+Violet design system, append-only CSV event model
+26
View File
@@ -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.
+1236 -1160
View File
File diff suppressed because it is too large. Load diff
+84
View File
@@ -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;
}
+153 -74
View File
@@ -6,23 +6,27 @@ from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from markupsafe import Markup
from storage import EventRow from storage import EventRow
PLAYER_PALETTE = [ PLAYER_PALETTE = [
"#9b8cf0", # --line-1 violet
"#6fc093", # --line-2 green
"#e0b15c", # --line-3 gold
"#cf6f86", # --line-4 pink
"#8f93c2", # --line-5 lavender
"#7cb9e0",
"#f4a261", "#f4a261",
"#2a9d8f", "#a78bb0",
"#8d99ae", "#5cb8a0",
"#e76f51", "#e58f3a",
"#7c6cf2",
"#84cc16", "#84cc16",
"#f59e0b",
"#10b981",
"#ef4444",
"#06b6d4", "#06b6d4",
"#a855f7",
"#eab308",
] ]
BREAK_EVEN_TOLERANCE_CENTS = 100
@dataclass @dataclass
class SessionEntry: class SessionEntry:
@@ -41,76 +45,113 @@ class SessionEntry:
@property @property
def invested_cents(self) -> int: 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 return self.buy_in_cents + self.front_cents + self.rollover_in_cents
@property @property
def net_cents(self) -> int: def net_cents(self) -> int:
return self.cash_out_cents - self.invested_cents 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 @property
def gross_payout_cents(self) -> int: 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) return max(self.cash_out_cents - self.front_cents, 0)
@property @property
def settled_cents(self) -> int: def player_owes_gross_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:
return max(self.front_cents - self.cash_out_cents, 0) 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 @property
def overpaid_front_cents(self) -> int: def overpaid_front_cents(self) -> int:
return max(self.settled_cents - self.gross_payout_cents, 0) return 0
@property @property
def front_writeoff_applied_cents(self) -> int: def front_writeoff_applied_cents(self) -> int:
return min( return min(
self.front_writeoff_cents, 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 @property
def front_collected_applied_cents(self) -> int: 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 @property
def front_resolved_cents(self) -> int: 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 @property
def payout_status(self) -> str: def payout_status(self) -> str:
if self.player_owes_cents > 0: if self.current_due_to_house_cents > 0:
return "owes" return "owes"
if self.front_writeoff_applied_cents > 0: if self.front_writeoff_cents > 0:
return "written_off" return "written_off"
if self.front_collected_applied_cents > 0: if self.front_collected_cents > 0:
return "collected" return "collected"
if self.gross_payout_cents <= 0: if self.gross_payout_cents <= 0:
return "none" return "none"
if self.current_due_cents <= 0: if self.current_due_to_player_cents <= 0:
return "paid" return "paid"
if self.settled_cents <= 0: if self.settled_to_player_cents <= 0:
return "unpaid" return "unpaid"
return "partial" return "partial"
@@ -149,11 +190,11 @@ class SessionSummary:
@property @property
def total_payout_due_cents(self) -> int: 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 @property
def total_remaining_cents(self) -> int: 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 @property
def total_net_cents(self) -> int: def total_net_cents(self) -> int:
@@ -172,32 +213,63 @@ class SessionSummary:
return sum(entry.rollover_out_cents for entry in self.entries) return sum(entry.rollover_out_cents for entry in self.entries)
@property @property
def total_current_due_cents(self) -> int: def total_settled_to_player_cents(self) -> int:
return sum(entry.current_due_cents for entry in self.entries) return sum(entry.settled_to_player_cents for entry in self.entries)
@property @property
def total_player_owes_cents(self) -> int: def total_current_due_to_player_cents(self) -> int:
return sum(entry.player_owes_cents for entry in self.entries) 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 @property
def total_front_writeoff_cents(self) -> int: 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 @property
def total_front_collected_cents(self) -> int: 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 @property
def total_cash_in_cents(self) -> int: 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 @property
def total_paid_out_cents(self) -> int: 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 @property
def total_open_balance_cents(self) -> int: 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 @dataclass
@@ -251,9 +323,9 @@ def apply_rank_changes(
return current_board return current_board
def cents_to_dollars(cents: int) -> str: def cents_to_dollars(cents: int) -> Markup:
value = cents / 100 value = cents / 100
return f"${value:,.2f}" return Markup(f'<span class="currency-symbol">$</span>{value:,.2f}')
def session_sort_key(session: SessionSummary) -> tuple[str, int, str, str]: 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)] 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: def net_tone(value_cents: int) -> str:
if value_cents > 0: bucket = net_result_bucket(value_cents)
return "#22c55e"
if value_cents < 0: if bucket == "win":
return "#ef4444" return "#6fc093" # --pos
return "#f59e0b" if bucket == "loss":
return "#e0758a" # --neg
return "#84828e" # --muted-2
def build_session_summaries(events: list[EventRow]) -> list[SessionSummary]: 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: for entry in ordered_entries:
net = entry.net_cents net = entry.net_cents
bucket = net_result_bucket(net)
if best_entry is None or net > best_entry.net_cents: if best_entry is None or net > best_entry.net_cents:
best_entry = entry 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: if worst_entry is None or net < worst_entry.net_cents:
worst_entry = entry worst_entry = entry
if net > 0: if bucket == "win":
current_run_wins += 1 current_run_wins += 1
current_run_losses = 0 current_run_losses = 0
elif net < 0: elif bucket == "loss":
current_run_losses += 1 current_run_losses += 1
current_run_wins = 0 current_run_wins = 0
else: else:
@@ -450,13 +533,13 @@ def summarize_player_runs(entries: list[SessionEntry]) -> dict[str, int | str |
current_loss_streak = 0 current_loss_streak = 0
for entry in reversed(ordered_entries): 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: if current_loss_streak > 0:
break break
current_win_streak += 1 current_win_streak += 1
elif net < 0: elif bucket == "loss":
if current_win_streak > 0: if current_win_streak > 0:
break break
current_loss_streak += 1 current_loss_streak += 1
@@ -485,8 +568,8 @@ def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]:
for player_name, entries in player_entries.items(): for player_name, entries in player_entries.items():
nets = [entry.net_cents for entry in entries] nets = [entry.net_cents for entry in entries]
run_summary = summarize_player_runs(entries) run_summary = summarize_player_runs(entries)
wins = [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 value < 0] losses = [value for value in nets if net_result_bucket(value) == "loss"]
sessions_played = len(entries) sessions_played = len(entries)
winning_sessions = len(wins) 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_buy_in = sum(entry.buy_in_cents for entry in entries)
total_front = sum(entry.front_cents for entry in entries) total_front = sum(entry.front_cents for entry in entries)
total_front_collected = sum( total_front_collected = sum(entry.front_collected_cents for entry in entries)
entry.front_collected_applied_cents for entry in entries total_front_writeoff = sum(entry.front_writeoff_cents for entry in entries)
)
total_front_writeoff = sum(
entry.front_writeoff_applied_cents for entry in entries
)
current_player_owes = sum(entry.player_owes_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_rollover_in = sum(entry.rollover_in_cents for entry in entries)
total_invested = sum(entry.invested_cents for entry in entries) total_invested = sum(entry.invested_cents for entry in entries)
+271 -285
View File
@@ -1,342 +1,328 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Admin - Poker Portal{% endblock %} {% block title %}Admin · boker.club{% endblock %}
{% block page_class %}page--admin{% endblock %}
{% block content %} {% block content %}
<section class="hero-card compact">
<div> <div class="hero" style="padding-bottom:12px;">
<p class="eyebrow">Admin</p> <div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap;">
<h1>Ledger controls</h1> <p class="hero__kicker" style="margin:0;">Admin</p>
<p class="muted-text">Append new buy-ins, cash-outs, corrections, and note-only events to the audit trail.</p> {% if open_sessions %}
<span class="live-badge">{{ open_sessions|length }} session{{ 's' if open_sessions|length != 1 else '' }} live</span>
{% endif %}
</div> </div>
</section> <h1 class="hero__title">Ledger controls</h1>
<p class="hero__sub">Append events to the audit trail. All history is permanent.</p>
</div>
<section class="grid two-col admin-layout"> <!-- Banker cash/book position. Cash tiles are real money only; rollovers only
<form class="panel form-card" method="post"> affect receivables/payables through the session settlement formulas. -->
<p class="eyebrow">New event</p> <div class="cash-strip">
<h2>Add to the ledger</h2> <div class="stat stat--rail" style="--rail:var(--accent);">
<span class="kicker">Cash in</span>
<div class="stat__val" style="color:var(--num)">{{ admin_totals.cash_in_cents | money }}</div>
</div>
<div class="stat stat--rail" style="--rail:var(--pos);">
<span class="kicker">Cash out</span>
<div class="stat__val" style="color:var(--pos)">{{ admin_totals.cash_out_cents | money }}</div>
</div>
<div class="stat stat--rail" style="--rail:var(--neg);">
<span class="kicker">House owes players</span>
<div class="stat__val {{ 'num-neg' if admin_totals.house_owes_players_cents > 0 else '' }}">{{ admin_totals.house_owes_players_cents | money }}</div>
</div>
<div class="stat stat--rail" style="--rail:var(--owes);">
<span class="kicker">Players owe house</span>
<div class="stat__val {{ 'num-neg' if admin_totals.players_owe_house_cents > 0 else '' }}">{{ admin_totals.players_owe_house_cents | money }}</div>
</div>
<div class="stat stat--rail" style="--rail:{{ 'var(--pos)' if admin_totals.net_book_position_cents == 0 else 'var(--warn)' }};">
<span class="kicker">Net book position</span>
<div class="stat__val {% if admin_totals.net_book_position_cents == 0 %}num-muted{% elif admin_totals.net_book_position_cents > 0 %}num-pos{% else %}num-neg{% endif %}">
{{ admin_totals.net_book_position_cents | money }}
</div>
<div class="stat__sub">cash in - cash out + receivables - payables</div>
</div>
</div>
<label> <!-- Two-column workspace -->
<span>Open session</span> <div class="admin-workspace">
<select name="session_id" required>
<option value="">Select session</option>
{% for session in open_sessions %}
<option value="{{ session.session_id }}">
{{ session_label(session) }}
</option>
{% endfor %}
</select>
</label>
<label> <!-- LEFT: append form + recent sessions -->
<span>Player name</span> <div class="col-main">
<input list="player_names" type="text" name="player_name" placeholder="Peter Venkman" required>
<datalist id="player_names">
{% for player_name in player_names %}
<option value="{{ player_name }}"></option>
{% endfor %}
</datalist>
</label>
<label> <form class="panel append" method="post" action="{{ url_for('admin_dashboard') }}">
<span>Event type</span> <div class="panel__head">
<select name="event_type" required> <h2 class="panel__title">Append ledger event</h2>
<option value="buyin">Buy-in</option> <span class="panel__tag">fast entry</span>
<option value="front">Front</option> </div>
<option value="rollover_in">Rollover in</option> <div class="append__body">
<option value="cashout">Cash-out</option> <div class="append__grid">
<option value="paid">Paid out</option> <label class="field">
<option value="rollover_out">Rollover out</option> <span class="field__label">Session</span>
<option value="note">Note only</option> <select class="control" name="session_id" required>
</select> <option value="">Select session</option>
</label> {% for s in open_sessions %}
<option value="{{ s.session_id }}" {{ 'selected' if append_form.session_id == s.session_id else '' }}>
<label> {{ session_label(s) }}{{ ' (live)' if s.status == 'open' else '' }}
<span>Amount</span> </option>
<input type="number" step="0.01" name="amount" placeholder="5.00"> {% endfor %}
</label> </select>
</label>
<label> <label class="field">
<span>Note</span> <span class="field__label">Player</span>
<textarea name="note" rows="4" placeholder="Rebuy, fronted chips, correction, partial payout, e-transfer sent, cash paid, etc."></textarea> <input class="control" list="player_names_list" type="text" name="player_name" placeholder="Player name" value="{{ append_form.player_name }}" required>
</label> <datalist id="player_names_list">
{% for name in player_names %}<option value="{{ name }}"></option>{% endfor %}
<button class="primary-button" type="submit">Add event</button> </datalist>
</form> </label>
<label class="field">
<section class="grid"> <span class="field__label">Event type</span>
<form class="panel form-card" method="post" action="{{ url_for('admin_open_session') }}"> <select class="control" name="event_type" required id="eventTypeSelect">
<p class="eyebrow">Session state</p> {% for value, label, color in event_types %}
<h2>Open a new session</h2> <option value="{{ value }}" data-color="{{ color }}" data-label="{{ label }}" {{ 'selected' if append_form.event_type == value else '' }}>{{ label }}</option>
{% endfor %}
<label> </select>
<span>Session date</span> </label>
<input type="date" name="session_date" required> <label class="field">
</label> <span class="field__label">Amount</span>
<span class="control control--money">
<div class="button-row"> <span class="control__prefix">$</span>
<button class="primary-button" type="submit">Open session</button> <input name="amount" inputmode="decimal" placeholder="0" autofocus>
</span>
</label>
</div>
<label class="field append__note">
<span class="field__label">Note <span class="field__opt">(optional)</span></span>
<input class="control" name="note" placeholder="e.g. fronted buy-in, settle next week">
</label>
<div class="append__footer">
<button type="submit" class="btn btn--primary">Append event</button>
<span class="append__preview">Will record as
<span class="evtag-pill" id="eventTypePreview" style="color:{{ event_type_colors[append_form.event_type] }}">{{ event_type_labels[append_form.event_type] | upper }}</span>
</span>
</div>
</div> </div>
</form> </form>
<section class="panel form-card"> </div><!-- /.col-main -->
<div class="panel-header">
<div>
<p class="eyebrow">Tools</p>
<h2>Audit CSV</h2>
</div>
</div>
<div class="admin-tools-grid"> <!-- RIGHT: session controls + CSV + raw events feed -->
<div class="helper-box"> <div class="col-side">
<p class="muted-text">
Export the current audit trail or import a replacement CSV. Import creates a timestamped backup first.
</p>
<div class="button-row">
<a class="primary-button" href="{{ url_for('admin_export_csv') }}">Export CSV</a>
</div>
</div>
<form method="post" action="{{ url_for('admin_import_csv') }}" enctype="multipart/form-data" class="helper-box import-form"> <div class="panel panel--pad">
<label for="csv_file"> <h2 class="panel__title panel__title--mb">Session controls</h2>
<span>Import replacement CSV</span>
</label> {% if live_session %}
<input id="csv_file" name="csv_file" type="file" accept=".csv,text/csv" required> <div class="livecard">
<div class="button-row"> <div class="livecard__head">
<button <span class="dot dot--live"></span>
class="secondary-button" <span class="livecard__name">{{ session_label(live_session) }}</span>
type="submit" <span class="livecard__no">{{ live_session_no }}</span>
onclick="return confirm('Replace the current audit CSV? A backup will be created first.');" </div>
> <div class="livecard__metrics">
Import CSV <div><div class="micro">In play</div><div class="mono16">{{ live_session.total_invested_cents | money }}</div></div>
</button> <div><div class="micro">Seated</div><div class="mono16">{{ live_session.entries|length }}</div></div>
</div> <div><div class="micro">Open</div><div class="mono16 mono16--warn">{{ live_unresolved_count }}</div></div>
</div>
<form method="post" action="{{ url_for('admin_session_state') }}">
<input type="hidden" name="session_id" value="{{ live_session.session_id }}">
<button class="btn-close" type="submit" name="state" value="closed">Close &amp; settle session</button>
</form> </form>
</div> </div>
</section> {% endif %}
</section> <form method="post" action="{{ url_for('admin_open_session') }}">
</section> <input type="hidden" name="session_date" value="{{ admin_today }}">
<button class="btn-newsession {{ 'btn-newsession--primary' if not live_session else '' }}" type="submit">+ Open new session</button>
</form>
</div>
<section class="panel debt-panel"> <div class="panel panel--pad">
<div class="panel-header"> <h2 class="panel__title" style="margin:0 0 4px">Import / export</h2>
<p class="csv__desc">Back up or migrate the raw event ledger.</p>
<form method="post" action="{{ url_for('admin_import_csv') }}" enctype="multipart/form-data" class="import-form">
<div class="csv__btns">
<a class="csv-btn-ghost" href="{{ url_for('admin_export_csv') }}">&darr; Export CSV</a>
<label class="csv-btn-ghost" for="csvTopImport">&uarr; Import CSV</label>
</div>
<input id="csvTopImport" type="file" accept=".csv,text/csv" name="csv_file" hidden onchange="this.form.submit()">
<label class="csv__drop" id="csvDrop" for="csvTopImport">Drop ledger.csv to import</label>
</form>
</div>
</div><!-- /.col-side -->
</div><!-- /.admin-workspace -->
<div class="admin-bottom-grid">
<div class="panel">
<div class="panel__head">
<div>
<span class="kicker" style="margin:0;">Recent sessions</span>
<h2 class="panel__title">Current totals</h2>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Session</th>
<th>Players</th>
<th>Cash in</th>
<th>Cash out</th>
<th>House owes</th>
<th>Players owe house</th>
<th>Book position</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% for s in recent_sessions %}
<tr>
<td><a href="{{ url_for('session_detail', session_id=s.session_id) }}">{{ session_label(s) }}</a></td>
<td style="font-family:var(--font-mono);color:var(--num)">{{ s.entries|length }}</td>
<td style="font-family:var(--font-mono);color:var(--num)">{{ s.total_cash_in_cents | money }}</td>
<td style="font-family:var(--font-mono);color:var(--pos)">{{ s.total_paid_out_cents | money }}</td>
<td class="{{ 'negative' if s.total_current_due_to_player_cents > 0 else '' }}" style="font-family:var(--font-mono)">{{ s.total_current_due_to_player_cents | money }}</td>
<td class="{{ 'negative' if s.total_current_due_to_house_cents > 0 else '' }}" style="font-family:var(--font-mono)">{{ s.total_current_due_to_house_cents | money }}</td>
<td class="{% if s.total_net_book_position_cents > 0 %}positive{% elif s.total_net_book_position_cents < 0 %}negative{% endif %}" style="font-family:var(--font-mono)">{{ s.total_net_book_position_cents | money }}</td>
<td><span class="status-badge status-{{ s.status }}">{{ s.status }}</span></td>
<td>
<form method="post" action="{{ url_for('admin_session_state') }}" class="inline-action-form">
<input type="hidden" name="session_id" value="{{ s.session_id }}">
{% if s.status == "open" %}
<button class="table-action-button" type="submit" name="state" value="closed">Close</button>
{% else %}
<button class="table-action-button" type="submit" name="state" value="open">Reopen</button>
{% endif %}
</form>
</td>
</tr>
{% else %}
<tr><td colspan="9">No sessions yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="panel">
<div class="panel__head">
<div>
<span class="kicker" style="margin:0;">Recent events</span>
<h2 class="panel__title">Ledger feed</h2>
</div>
<span class="badge badge--closed" style="font-size:9.5px;">append-only</span>
</div>
<div class="event-feed feed feed--inline">
{% for event in recent_events %}
<div class="event-card event-card--inline">
<div class="event-card-top">
<strong>{{ event.player_name or "Session" }}</strong>
<span class="pill pill-{{ event.event_type }}">{{ event.event_type | replace('_', ' ') }}</span>
{% if event.note %}<span class="event-note">{{ event.note }}</span>{% endif %}
</div>
<p class="event-amount">{{ event.amount_cents | money }}</p>
<p class="tiny-text">{{ event.session_date | pretty_date }}</p>
</div>
{% else %}
<p class="muted-text" style="padding:20px;">No recent events.</p>
{% endfor %}
</div>
</div>
</div>
<script>
const eventTypeSelect = document.getElementById('eventTypeSelect');
const eventTypePreview = document.getElementById('eventTypePreview');
eventTypeSelect?.addEventListener('change', () => {
const option = eventTypeSelect.selectedOptions[0];
if (!option || !eventTypePreview) return;
eventTypePreview.style.color = option.dataset.color || 'var(--muted)';
eventTypePreview.textContent = (option.dataset.label || option.value).toUpperCase();
});
const csvDrop = document.getElementById('csvDrop');
const csvInput = document.getElementById('csvTopImport');
csvDrop?.addEventListener('dragover', (event) => {
event.preventDefault();
csvDrop.classList.add('is-drag');
});
csvDrop?.addEventListener('dragleave', () => csvDrop.classList.remove('is-drag'));
csvDrop?.addEventListener('drop', (event) => {
event.preventDefault();
csvDrop.classList.remove('is-drag');
if (!csvInput || !event.dataTransfer?.files?.length) return;
csvInput.files = event.dataTransfer.files;
csvInput.form?.submit();
});
</script>
<!-- Front debt resolution -->
{% if debt_entries %}
<div class="panel debt-panel" style="margin-top:20px;">
<div class="panel__head">
<div> <div>
<p class="eyebrow">Front debt</p> <span class="kicker" style="margin:0;">Front receivables</span>
<h2>Debt resolution</h2> <h2 class="panel__title">Debt resolution</h2>
</div> </div>
<div class="debt-summary"> <div class="debt-summary">
<span>Open {{ admin_totals.players_owe_cents | money }}</span> <span class="badge badge--owes">Open {{ admin_totals.players_owe_house_cents | money }}</span>
<span>Collected {{ admin_totals.collected_cents | money }}</span> <span class="badge badge--paid">Repaid {{ admin_totals.collected_cents | money }}</span>
<span>Written off {{ admin_totals.written_off_cents | money }}</span> <span class="badge badge--written_off">Written off {{ admin_totals.written_off_cents | money }}</span>
</div> </div>
</div> </div>
<div class="debt-action-grid" style="padding:20px;">
{% if debt_entries %} <form class="form-card" style="padding:0;background:transparent;" method="post" action="{{ url_for('admin_collect_front') }}">
<div class="debt-action-grid">
<form class="form-card compact-form" method="post" action="{{ url_for('admin_collect_front') }}">
<h3>Collect debt</h3> <h3>Collect debt</h3>
<label> <label>
<span>Outstanding debt</span> <span>Outstanding debt</span>
<select name="debt_key" required> <select name="debt_key" required>
<option value="">Select debt</option> <option value="">Select debt</option>
{% for item in debt_entries %} {% for item in debt_entries %}
{% set session = item.session %} {% set s = item.session %}{% set e = item.entry %}
{% set entry = item.entry %} <option value="{{ s.session_id }}||{{ e.player_name }}">
<option value="{{ session.session_id }}||{{ entry.player_name }}"> {{ e.player_name }} · {{ session_label(s) }} · owes {{ e.player_owes_cents | money }}
{{ entry.player_name }} · {{ session_label(session) }} · owes {{ entry.player_owes_cents | money }} </option>
</option>
{% endfor %} {% endfor %}
</select> </select>
</label> </label>
<label> <label>
<span>Amount collected</span> <span>Amount collected</span>
<input type="number" step="0.01" name="amount" placeholder="5.00" required> <input type="number" step="0.01" name="amount" placeholder="0.00" required>
</label> </label>
<label> <label>
<span>Note</span> <span>Note</span>
<textarea name="note" rows="3" placeholder="E-transfer received, cash collected, etc."></textarea> <textarea name="note" rows="2" placeholder="E-transfer received, cash, etc."></textarea>
</label> </label>
<button class="primary-button" type="submit">Mark collected</button>
<div class="button-row">
<button class="primary-button" type="submit">Mark collected</button>
</div>
</form> </form>
<form class="form-card compact-form" method="post" action="{{ url_for('admin_write_off_front') }}"> <form class="form-card" style="padding:0;background:transparent;" method="post" action="{{ url_for('admin_write_off_front') }}">
<h3>Write off debt</h3> <h3>Write off debt</h3>
<label> <label>
<span>Outstanding debt</span> <span>Outstanding debt</span>
<select name="debt_key" required> <select name="debt_key" required>
<option value="">Select debt</option> <option value="">Select debt</option>
{% for item in debt_entries %} {% for item in debt_entries %}
{% set session = item.session %} {% set s = item.session %}{% set e = item.entry %}
{% set entry = item.entry %} <option value="{{ s.session_id }}||{{ e.player_name }}">
<option value="{{ session.session_id }}||{{ entry.player_name }}"> {{ e.player_name }} · {{ session_label(s) }} · owes {{ e.player_owes_cents | money }}
{{ entry.player_name }} · {{ session_label(session) }} · owes {{ entry.player_owes_cents | money }} </option>
</option>
{% endfor %} {% endfor %}
</select> </select>
</label> </label>
<label> <label>
<span>Amount to write off</span> <span>Amount to write off</span>
<input type="number" step="0.01" name="amount" placeholder="5.00" required> <input type="number" step="0.01" name="amount" placeholder="0.00" required>
</label> </label>
<label> <label>
<span>Note</span> <span>Note</span>
<textarea name="note" rows="3" placeholder="Won't be collected."></textarea> <textarea name="note" rows="2" placeholder="Won't be collected."></textarea>
</label> </label>
<button class="secondary-button" type="submit">Write off front</button>
<div class="button-row">
<button class="secondary-button" type="submit">Write off front</button>
</div>
</form> </form>
</div> </div>
{% else %} </div>
<p class="muted-text">No outstanding front debt.</p> {% endif %}
{% endif %}
</section>
<section class="grid admin-cash-grid">
<article class="panel stat-card">
<span>Cash in</span>
<strong>{{ admin_totals.cash_in_cents | money }}</strong>
</article>
<article class="panel stat-card">
<span>Paid out</span>
<strong>{{ admin_totals.paid_out_cents | money }}</strong>
</article>
<article class="panel stat-card">
<span>Still owed</span>
<strong class="{{ 'negative' if admin_totals.still_owed_cents > 0 else 'positive' }}">
{{ admin_totals.still_owed_cents | money }}
</strong>
</article>
<article class="panel stat-card">
<span>Players owe</span>
<strong class="{{ 'negative' if admin_totals.players_owe_cents > 0 else '' }}">
{{ admin_totals.players_owe_cents | money }}
</strong>
</article>
<article class="panel stat-card">
<span>Written off</span>
<strong class="{{ 'negative' if admin_totals.written_off_cents > 0 else '' }}">
{{ admin_totals.written_off_cents | money }}
</strong>
</article>
<article class="panel stat-card">
<span>Open balance</span>
<strong class="{% if admin_totals.open_balance_cents > 0 %}negative{% elif admin_totals.open_balance_cents < 0 %}positive{% endif %}">
{{ admin_totals.open_balance_cents | money }}
</strong>
</article>
</section>
<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>Session</th>
<th>Players</th>
<th>Cash in</th>
<th>Paid out</th>
<th>Still owed</th>
<th>Players owe</th>
<th>Collected</th>
<th>Written off</th>
<th>Open balance</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% for session in recent_sessions %}
<tr>
<td>
<a href="{{ url_for('session_detail', session_id=session.session_id) }}">
{{ session_label(session) }}
</a>
</td>
<td>{{ session.entries|length }}</td>
<td>{{ session.total_cash_in_cents | money }}</td>
<td>{{ session.total_paid_out_cents | money }}</td>
<td class="{{ 'negative' if session.total_current_due_cents > 0 else 'positive' }}">
{{ session.total_current_due_cents | money }}
</td>
<td class="{{ 'negative' if session.total_player_owes_cents > 0 else '' }}">
{{ session.total_player_owes_cents | money }}
</td>
<td class="{{ 'positive' if session.total_front_collected_cents > 0 else '' }}">
{{ session.total_front_collected_cents | money }}
</td>
<td class="{{ 'negative' if session.total_front_writeoff_cents > 0 else '' }}">
{{ session.total_front_writeoff_cents | money }}
</td>
<td class="{% if session.total_open_balance_cents > 0 %}negative{% elif session.total_open_balance_cents < 0 %}positive{% endif %}">
{{ session.total_open_balance_cents | money }}
</td>
<td>
<span class="status-badge status-{{ session.status }}">
{{ session.status }}
</span>
</td>
<td>
<form method="post" action="{{ url_for('admin_session_state') }}" class="inline-action-form">
<input type="hidden" name="session_id" value="{{ session.session_id }}">
{% if session.status == "open" %}
<button class="table-action-button" type="submit" name="state" value="closed">Close</button>
{% else %}
<button class="table-action-button" type="submit" name="state" value="open">Reopen</button>
{% endif %}
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="11">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 pill-{{ event.event_type }}">{{ 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>
{% endblock %} {% endblock %}
+12 -11
View File
@@ -1,23 +1,24 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Admin Login · Poker Portal{% endblock %} {% block title %}Admin Login · boker.club{% endblock %}
{% block content %} {% 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>
<div class="form-shell narrow">
<form class="panel form-card" method="post">
<div>
<p class="eyebrow">Restricted access</p>
<h1 style="font-size:28px;margin-bottom:8px;">Admin login</h1>
<p class="muted-text" style="margin-bottom:8px;">Only boker.club manager needs credentials. All stats pages stay public.</p>
</div>
<label> <label>
<span>Username</span> <span>Username</span>
<input type="text" name="username" required> <input type="text" name="username" autocomplete="username" required>
</label> </label>
<label> <label>
<span>Password</span> <span>Password</span>
<input type="password" name="password" required> <input type="password" name="password" autocomplete="current-password" required>
</label> </label>
<button class="primary-button" type="submit">Login</button> <button class="primary-button" type="submit">Login</button>
</form> </form>
</section> </div>
{% endblock %} {% endblock %}
+28 -25
View File
@@ -3,47 +3,50 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Poker Portal{% endblock %}</title> <title>{% block title %}boker.club{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="stylesheet" href="{{ url_for('static', filename='css/tokens.css') }}">
<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') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head> </head>
<body> <body>
<div class="site-shell"> <header class="topbar">
<header class="topbar"> <div class="topbar__inner">
<div> <a class="brand" href="{{ url_for('leaderboard') }}">
<a class="brand" href="{{ url_for('leaderboard') }}">Poker Portal</a> <span class="brand__mark"></span>
<p class="brand-subtitle">Low-stakes hold'em ledger and session tracker</p> <span class="brand__name">boker.club</span>
</div> </a>
<nav class="nav-links"> <nav class="mainnav">
<a href="{{ url_for('leaderboard') }}">Leaderboard</a> <a class="navlink {{ 'is-active' if request.endpoint == 'leaderboard' }}" href="{{ url_for('leaderboard') }}">Leaderboard</a>
<a href="{{ url_for('sessions') }}">Sessions</a> <a class="navlink {{ 'is-active' if request.endpoint in ('sessions', 'session_detail') }}" href="{{ url_for('sessions') }}">Sessions</a>
{% if is_admin %} {% if is_admin %}
<a href="{{ url_for('admin_dashboard') }}">Admin</a> <a class="navlink {{ 'is-active' if request.endpoint == 'admin_dashboard' }}" 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 %} {% else %}
<a href="{{ url_for('admin_login') }}">Admin Login</a> <a class="navlink {{ 'is-active' if request.endpoint == 'admin_login' }}" href="{{ url_for('admin_login') }}">Admin</a>
{% endif %} {% endif %}
</nav> </nav>
</header> <span class="topbar__spacer"></span>
{% if is_admin %}
<div class="topbar__admin-actions">
<form method="post" action="{{ url_for('admin_logout') }}" style="margin:0">
<button class="btn btn--ghost btn--sm" type="submit">Logout</button>
</form>
</div>
{% endif %}
</div>
</header>
<main class="page {% block page_class %}page--public{% endblock %}">
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %} {% if messages %}
<section class="flash-stack"> <div class="flash-stack">
{% for category, message in messages %} {% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div> <div class="flash {{ category }}">{{ message }}</div>
{% endfor %} {% endfor %}
</section> </div>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
<main class="page-content"> {% block content %}{% endblock %}
{% block content %}{% endblock %} </main>
</main>
</div>
</body> </body>
</html> </html>
+311 -559
View File
@@ -1,596 +1,348 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Leaderboard · Poker Portal{% endblock %} {% block title %}Leaderboard · boker.club{% endblock %}
{% block content %} {% 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{% if selected_session_date %} through {{ selected_session_label }}{% endif %}.</p>
</div>
</section>
<section class="grid two-col leaderboard-layout"> <div class="hero" style="display:flex;flex-wrap:wrap;gap:var(--s6);align-items:flex-start;justify-content:space-between;">
<div class="panel chart-panel chart-panel-large"> <div>
<div class="panel-header panel-header-with-actions"> <p class="hero__kicker">All-time results</p>
<div> <h1 class="hero__title">Leaderboard</h1>
<p class="eyebrow">Trend</p> <p class="hero__sub">{{ session_count }} session{{ 's' if session_count != 1 else '' }}{% if selected_session_date %} through {{ selected_session_label }}{% endif %}</p>
<h2>Cumulative profit over time</h2> </div>
</div> <div class="leaderboard-hero-stats">
<div class="panel-actions"> <div class="stat">
<button class="ghost-button compact-button" type="button" id="resetLeaderboardZoom" hidden>Reset zoom</button> <span class="kicker">Cash paid out</span>
<button class="secondary-button" type="button" id="expandLeaderboardChart">Expand chart</button> <div class="stat__val" style="color:var(--accent)">{{ cash_paid_out_cents | money }}</div>
</div>
</div> </div>
<div class="chart-frame chart-frame-interactive" id="leaderboardChartFrame" role="button" tabindex="0" aria-label="Expand cumulative profit chart"> <div class="stat">
<span class="kicker">Sessions</span>
<div class="stat__val" style="color:var(--text-strong)">{{ session_count }}{% if total_session_count != session_count %}<span style="color:var(--faintest);font-size:16px"> / {{ total_session_count }}</span>{% endif %}</div>
</div>
<div class="stat">
<span class="kicker">Players</span>
<div class="stat__val" style="color:var(--text-strong)">{{ all_count }}</div>
</div>
</div>
</div>
<div class="panel stack" style="gap:0;margin-bottom:20px;">
<div class="panel__head">
<div>
<span class="kicker">Trend</span>
<h2 class="panel__title">Cumulative profit</h2>
</div>
<button class="btn btn--ghost btn--sm" type="button" id="expandLeaderboardChart">Expand</button>
</div>
<div class="chart-wrap">
<div class="chart-frame chart-frame--tall">
<canvas id="leaderboardChart"></canvas> <canvas id="leaderboardChart"></canvas>
</div> </div>
</div> </div>
</div>
<div class="panel stat-panel"> <div class="toolbar">
<p class="eyebrow">Quick read</p> <div class="toolbar__left">
<h2>Table snapshot</h2> <div class="segmented">
<div class="stat-list"> <button class="seg {{ 'is-on' if mode=='eligible' }}" data-mode="eligible">
<div class="stat-row"><span>Players tracked</span><strong>{{ leaderboard|length }}</strong></div> Eligible <span class="seg__count">{{ eligible_count }}</span>
<div class="stat-row"><span>Sessions tracked</span><strong>{{ session_count }}{% if total_session_count != session_count %} / {{ total_session_count }}{% endif %}</strong></div> </button>
<div class="stat-row"><span>Through session</span><strong>{{ selected_session_label }}</strong></div> <button class="seg {{ 'is-on' if mode=='all' }}" data-mode="all">
<div class="stat-row"><span>Best player</span><strong>{% if leaderboard %}{{ leaderboard[0].player_name }}{% else %}—{% endif %}</strong></div> All players <span class="seg__count">{{ all_count }}</span>
<div class="stat-row"><span>Worst player</span><strong>{% if leaderboard %}{{ leaderboard[-1].player_name }}{% else %}—{% endif %}</strong></div> </button>
<button class="seg {{ 'is-on' if mode=='recent' }}" data-mode="recent">
Recent <span class="seg__count">5 sess</span>
</button>
</div> </div>
<p class="toolbar__hint">
{% 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 %}
</p>
</div> </div>
</section> <div class="toolbar__right">
<form class="session-filter-form" method="get">
<section class="panel"> <input type="hidden" name="mode" value="{{ mode }}">
<div class="panel-header leaderboard-header"> <select class="session-filter-select" name="through_session" onchange="this.form.submit()">
<div> <option value="">Latest session</option>
<p class="eyebrow">Standings</p> {% for s in available_sessions %}
<h2>All-time leaderboard</h2> <option value="{{ s.session_id }}" {{ 'selected' if selected_session_id == s.session_id else '' }}>{{ session_label(s) }}</option>
<p class="muted-text small">Cash in / paid out reflect real money movement. Net / ROI reflect poker results.</p>
</div>
<div class="leaderboard-controls">
<form method="get" class="session-filter-form">
<label for="through_session" class="session-filter-label">Through session</label>
<select id="through_session" name="through_session" class="session-filter-select" onchange="this.form.submit()">
<option value="" {% if not selected_session_id %}selected{% endif %}>Latest session</option>
{% for session in available_sessions %}
<option value="{{ session.session_id }}" {% if selected_session_id == session.session_id %}selected{% endif %}>{{ session_label(session) }}</option>
{% endfor %}
</select>
{% if selected_session_date %}
<a class="ghost-button compact-button" href="{{ url_for('leaderboard') }}">Reset</a>
{% endif %}
</form>
<p class="muted-text small">Click any column to sort.</p>
</div>
</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="rankdelta" data-sort-type="number" data-initial-direction="desc">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="cashin" data-sort-type="number" data-initial-direction="desc">Cash in <span class="sort-indicator"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="paidout" data-sort-type="number" data-initial-direction="desc">Paid out <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.rank_change }}">
{% if player.rank_change > 0 %}
<span class="rank-move rank-up">↑ {{ player.rank_change }}</span>
{% elif player.rank_change < 0 %}
<span class="rank-move rank-down">↓ {{ player.rank_change | abs }}</span>
{% else %}
<span class="rank-move rank-neutral">-</span>
{% endif %}
</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_paid_cents }}">{{ player.total_paid_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="11">No data yet. Add the first event from the admin page.</td>
</tr>
{% endfor %} {% endfor %}
</tbody> </select>
</table> {% if selected_session_id %}
<a class="btn btn--ghost btn--sm" href="{{ url_for('leaderboard', mode=mode) }}">Reset</a>
{% endif %}
</form>
</div> </div>
</section> </div>
<div class="chart-modal" id="chartModal" aria-hidden="true"> <div class="panel tbl-scroll" style="margin-bottom:20px;">
<div class="chart-modal-backdrop" data-close-chart-modal></div> <table class="tbl" id="leaderboardTable" data-sort-key="net" data-sort-direction="desc">
<div class="chart-modal-card"> <thead>
<div class="panel-header panel-header-with-actions"> <tr>
<div> <th class="col-rank" style="width:52px;">
<p class="eyebrow">Expanded view</p> <span style="font-family:var(--font-mono);font-size:10.5px;letter-spacing:.10em;text-transform:uppercase;color:var(--muted);">#</span>
<h2>Cumulative profit over time</h2> </th>
</div> <th class="col-player">
<div class="panel-actions"> <button type="button" class="sort-btn" data-sort-key="player" data-sort-type="string" data-initial-direction="asc">Player <span class="sort-arrow"></span></button>
<button class="secondary-button" type="button" id="fullscreenLeaderboardChart">Fullscreen</button> </th>
<button class="ghost-button" type="button" data-close-chart-modal>Close</button> <th class="col-rank-delta"><button type="button" class="sort-btn" data-sort-key="rankdelta" data-sort-type="number" data-initial-direction="desc">Δ Rank <span class="sort-arrow"></span></button></th>
<th><button type="button" class="sort-btn" data-sort-key="net" data-sort-type="number" data-initial-direction="desc">Net <span class="sort-arrow"></span></button></th>
<th><button type="button" class="sort-btn" data-sort-key="winpct" data-sort-type="number" data-initial-direction="desc">Win% <span class="sort-arrow"></span></button></th>
<th><button type="button" class="sort-btn" data-sort-key="roi" data-sort-type="number" data-initial-direction="desc">ROI <span class="sort-arrow"></span></button></th>
<th><button type="button" class="sort-btn" data-sort-key="avgwin" data-sort-type="number" data-initial-direction="desc">Avg Win <span class="sort-arrow"></span></button></th>
<th><button type="button" class="sort-btn" data-sort-key="avgloss" data-sort-type="number" data-initial-direction="desc">Avg Loss <span class="sort-arrow"></span></button></th>
<th><button type="button" class="sort-btn" data-sort-key="sessions" data-sort-type="number" data-initial-direction="desc">Sessions <span class="sort-arrow"></span></button></th>
</tr>
</thead>
<tbody>
{% for player in main_board %}
{% set idx = loop.index %}
<tr data-player-row="true" data-original-rank="{{ idx }}"
onclick="window.location='{{ url_for('player_detail', player_name=player.player_name) }}'">
<td class="col-rank" data-sort-value="{{ idx }}">
<span class="rank-num {{ 'rank-1' if idx==1 else 'rank-2' if idx==2 else 'rank-3' if idx==3 else '' }}" data-rank-cell>#{{ idx }}</span>
</td>
<td class="col-player" data-sort-value="{{ player.player_name|lower }}">
<a href="{{ url_for('player_detail', player_name=player.player_name) }}">{{ player.player_name }}</a>
</td>
<td class="col-rank-delta" data-sort-value="{{ player.rank_change }}">
{% if player.rank_change > 0 %}
<span class="rank-delta rank-delta--up">▲{{ player.rank_change }}</span>
{% elif player.rank_change < 0 %}
<span class="rank-delta rank-delta--down">▼{{ player.rank_change | abs }}</span>
{% else %}
<span class="rank-delta rank-delta--flat"></span>
{% endif %}
</td>
<td class="num-net {{ 'num-pos' if player.total_net_cents > 0 else 'num-neg' if player.total_net_cents < 0 else 'num-muted' }}"
data-sort-value="{{ player.total_net_cents }}">
{{ '+' if player.total_net_cents > 0 else '' }}{{ player.total_net_cents | money }}
</td>
<td data-sort-value="{{ '%.4f'|format(player.win_pct) }}">{{ "%.1f"|format(player.win_pct) }}%</td>
<td class="{{ 'num-pos' if player.roi_pct > 0 else 'num-neg' if player.roi_pct < 0 else '' }}"
data-sort-value="{{ '%.4f'|format(player.roi_pct) }}">{{ "%.1f"|format(player.roi_pct) }}%</td>
<td class="num-pos" data-sort-value="{{ player.avg_win_cents }}">{{ player.avg_win_cents | money }}</td>
<td class="num-neg" 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="9" style="text-align:left;padding:28px;color:var(--muted);">
No players yet — add the first event from the admin page.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if mode == 'eligible' and provisional_board %}
<div style="margin-bottom:20px;">
<div class="toolbar" style="margin-top:0;margin-bottom:12px;">
<span class="kicker" style="margin:0;">Provisional · fewer than {{ eligible_min_sessions }} sessions</span>
</div>
<div class="provisional-grid">
{% for player in provisional_board %}
<a class="provisional-card" href="{{ url_for('player_detail', player_name=player.player_name) }}"
style="display:block;text-decoration:none;">
<p class="provisional-card__name">{{ player.player_name }}</p>
<div class="provisional-card__meta">
<span>{{ player.sessions_played }} sess</span>
<span class="{{ 'num-pos' if player.total_net_cents > 0 else 'num-neg' if player.total_net_cents < 0 else '' }}"
style="font-family:var(--font-mono);font-size:11px;font-weight:600;">{{ player.total_net_cents | money }}</span>
</div> </div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Expand modal -->
<div id="chartModal" style="display:none;position:fixed;inset:0;z-index:80;align-items:center;justify-content:center;">
<div style="position:absolute;inset:0;background:rgba(8,10,12,.82);backdrop-filter:blur(6px);" data-close-modal></div>
<div style="position:relative;z-index:1;width:min(1280px,calc(100vw - 48px));margin:24px auto;padding:22px;background:var(--surface);border:1px solid var(--border-strong);border-radius:var(--r-lg);box-shadow:0 24px 64px rgba(0,0,0,.4);">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:18px;">
<h2 style="margin:0;">Cumulative profit</h2>
<button class="btn btn--ghost btn--sm" data-close-modal>Close</button>
</div> </div>
<div class="chart-modal-frame" id="chartModalFrame"> <div style="height:min(70vh,780px);position:relative;">
<canvas id="leaderboardChartExpanded"></canvas> <canvas id="leaderboardChartExpanded"></canvas>
</div> </div>
</div> </div>
</div> </div>
<script> <script>
const leaderboardChartData = {{ chart_data | tojson }}; const CHART_PALETTE = ['#9b8cf0','#6fc093','#e0b15c','#cf6f86','#8f93c2','#7cb9e0','#f4a261','#a78bb0','#5cb8a0','#e58f3a'];
const rootStyles = getComputedStyle(document.documentElement); const leaderboardChartData = {{ chart_data | tojson }};
const textMuted = rootStyles.getPropertyValue('--text-muted').trim(); const rootStyles = getComputedStyle(document.documentElement);
const lineColor = rootStyles.getPropertyValue('--line').trim(); const textMuted = rootStyles.getPropertyValue('--text-muted').trim();
const lineColor = rootStyles.getPropertyValue('--line').trim();
function mainLeaderboardChartData(chartData) { leaderboardChartData.datasets.forEach((ds, i) => {
const repeatedPlayerDatasets = chartData.datasets.filter((dataset) => { ds.borderColor = CHART_PALETTE[i % CHART_PALETTE.length];
return Number(dataset.sessions_played || 0) > 1; ds.backgroundColor = CHART_PALETTE[i % CHART_PALETTE.length];
}); });
const visibleDatasets = repeatedPlayerDatasets.length > 0
? repeatedPlayerDatasets
: chartData.datasets;
return { function buildChartConfig(data, compact) {
labels: [...chartData.labels], return {
datasets: visibleDatasets.map((dataset) => ({ type: 'line',
...dataset, data: {
data: [...dataset.data], labels: data.labels,
pointRadius: 0, datasets: data.datasets.map(ds => ({
...ds,
pointRadius: compact ? 0 : 2,
pointHoverRadius: 5, pointHoverRadius: 5,
borderWidth: 2.5, borderWidth: compact ? 2 : 2.5,
tension: 0.28, tension: 0.28,
fill: false,
})), })),
};
}
function moneyTickLabel(value) {
const numericValue = Number(value);
const absoluteValue = Math.abs(numericValue);
const formattedValue = Number.isInteger(absoluteValue)
? absoluteValue.toFixed(0)
: absoluteValue.toFixed(2);
return `${numericValue < 0 ? '-' : ''}$${formattedValue}`;
}
function chartYScaleOptions(chartData, compact) {
const values = chartData.datasets
.flatMap((dataset) => dataset.data)
.filter((value) => value !== null && value !== undefined)
.map(Number);
if (values.length === 0) {
return {};
}
const minValue = Math.min(...values);
const maxValue = Math.max(...values);
const maxAbsValue = Math.max(Math.abs(minValue), Math.abs(maxValue));
const stepSize = maxAbsValue <= 75 ? 25 : undefined;
return {
suggestedMin: minValue < 0 ? minValue * 1.12 : 0,
suggestedMax: maxValue > 0 ? maxValue * 1.12 : 0,
ticks: {
color: textMuted,
maxTicksLimit: compact ? 8 : 10,
stepSize,
callback(value) {
return moneyTickLabel(value);
},
},
grid: { color: lineColor },
};
}
function buildLeaderboardChartConfig(chartData, options = {}) {
const compact = options.compact === true;
return {
type: 'line',
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'nearest', intersect: false },
elements: {
point: {
radius: compact ? 0 : 2,
hoverRadius: 5,
},
line: {
borderWidth: compact ? 2.5 : 2,
tension: 0.28,
},
},
plugins: {
legend: {
display: true,
position: 'bottom',
labels: {
color: textMuted,
usePointStyle: true,
boxWidth: compact ? 8 : 10,
boxHeight: compact ? 8 : 10,
padding: compact ? 12 : 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,
autoSkip: true,
maxTicksLimit: compact ? 6 : 10,
},
grid: {
color: lineColor,
display: !compact,
},
},
y: chartYScaleOptions(chartData, compact),
},
},
};
}
const zoomSelectionPlugin = {
id: 'zoomSelection',
beforeDraw(chart) {
const drag = chart.$zoomDrag;
if (!drag || !drag.active || drag.startX === drag.endX) {
return;
}
const { ctx, chartArea } = chart;
const left = Math.max(chartArea.left, Math.min(drag.startX, drag.endX));
const right = Math.min(chartArea.right, Math.max(drag.startX, drag.endX));
ctx.save();
ctx.fillStyle = 'rgba(229, 139, 58, 0.14)';
ctx.strokeStyle = 'rgba(229, 139, 58, 0.74)';
ctx.lineWidth = 1;
ctx.fillRect(left, chartArea.top, right - left, chartArea.bottom - chartArea.top);
ctx.strokeRect(left, chartArea.top, right - left, chartArea.bottom - chartArea.top);
ctx.restore();
}, },
}; options: {
responsive: true,
Chart.register(zoomSelectionPlugin); maintainAspectRatio: false,
interaction: { mode: 'nearest', intersect: false },
function clamp(value, min, max) { plugins: {
return Math.max(min, Math.min(max, value)); legend: {
} display: true,
position: 'bottom',
function enableDragZoom(chart, resetButton) { labels: { color: textMuted, usePointStyle: true, boxWidth: 8, boxHeight: 8, padding: 14 },
if (!chart || !chart.canvas || !resetButton) { },
return; tooltip: {
} callbacks: {
label(ctx) {
const canvas = chart.canvas; const v = ctx.parsed.y;
const minDragDistance = 14; return v == null ? `${ctx.dataset.label}: —` : `${ctx.dataset.label}: $${Number(v).toFixed(2)}`;
let draggedToZoom = false; }
}
function pointerX(event) {
const rect = canvas.getBoundingClientRect();
return event.clientX - rect.left;
}
function eventInChartArea(event) {
const x = pointerX(event);
const y = event.clientY - canvas.getBoundingClientRect().top;
const area = chart.chartArea;
return x >= area.left && x <= area.right && y >= area.top && y <= area.bottom;
}
canvas.addEventListener('pointerdown', (event) => {
if (!eventInChartArea(event)) {
return;
}
canvas.setPointerCapture(event.pointerId);
const x = pointerX(event);
chart.$zoomDrag = { active: true, startX: x, endX: x };
chart.update('none');
});
canvas.addEventListener('pointermove', (event) => {
if (!chart.$zoomDrag?.active) {
return;
}
chart.$zoomDrag.endX = pointerX(event);
chart.draw();
});
canvas.addEventListener('pointerup', (event) => {
const drag = chart.$zoomDrag;
if (!drag?.active) {
return;
}
drag.active = false;
const distance = Math.abs(drag.endX - drag.startX);
if (distance < minDragDistance) {
chart.draw();
return;
}
draggedToZoom = true;
const area = chart.chartArea;
const startPixel = clamp(Math.min(drag.startX, drag.endX), area.left, area.right);
const endPixel = clamp(Math.max(drag.startX, drag.endX), area.left, area.right);
const xScale = chart.scales.x;
const minIndex = clamp(Math.floor(xScale.getValueForPixel(startPixel)), 0, chart.data.labels.length - 1);
const maxIndex = clamp(Math.ceil(xScale.getValueForPixel(endPixel)), 0, chart.data.labels.length - 1);
if (maxIndex <= minIndex) {
chart.draw();
return;
}
xScale.options.min = minIndex;
xScale.options.max = maxIndex;
resetButton.hidden = false;
chart.update();
});
canvas.addEventListener('click', (event) => {
if (!draggedToZoom) {
return;
}
event.stopPropagation();
event.preventDefault();
draggedToZoom = false;
});
canvas.addEventListener('pointercancel', () => {
if (chart.$zoomDrag) {
chart.$zoomDrag.active = false;
chart.draw();
}
});
resetButton.addEventListener('click', () => {
delete chart.options.scales.x.min;
delete chart.options.scales.x.max;
resetButton.hidden = true;
chart.update();
});
}
function suppressLegendClickExpansion(chart) {
if (!chart?.canvas) {
return;
}
chart.canvas.addEventListener('click', (event) => {
const legend = chart.legend;
if (!legend || !legend.legendHitBoxes) {
return;
}
const rect = chart.canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const clickedLegendItem = legend.legendHitBoxes.some((box) => {
return x >= box.left
&& x <= box.left + box.width
&& y >= box.top
&& y <= box.top + box.height;
});
if (clickedLegendItem) {
event.stopPropagation();
}
});
}
const leaderboardCtx = document.getElementById('leaderboardChart');
const resetZoomButton = document.getElementById('resetLeaderboardZoom');
let leaderboardChart = null;
if (leaderboardCtx && leaderboardChartData.labels.length > 0) {
leaderboardChart = new Chart(
leaderboardCtx,
buildLeaderboardChartConfig(mainLeaderboardChartData(leaderboardChartData), { compact: true })
);
enableDragZoom(leaderboardChart, resetZoomButton);
suppressLegendClickExpansion(leaderboardChart);
}
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,
rankdelta: 1,
player: 2,
cashin: 3,
paidout: 4,
net: 5,
winpct: 6,
roi: 7,
avgwin: 8,
avgloss: 9,
sessions: 10,
};
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; scales: {
if (stringResult !== 0) { x: {
return stringResult; ticks: { color: textMuted, maxRotation: 0, autoSkip: true, maxTicksLimit: compact ? 6 : 10 },
grid: { color: lineColor, display: !compact },
},
y: {
ticks: {
color: textMuted,
maxTicksLimit: compact ? 8 : 10,
callback(v) { return (v < 0 ? '-' : '') + '$' + Math.abs(Number(v)).toFixed(0); }
},
grid: { color: lineColor },
} }
} }
}
};
}
return Number(leftRow.dataset.originalRank) - Number(rightRow.dataset.originalRank); const ctx = document.getElementById('leaderboardChart');
}); let mainChart = null;
if (ctx && leaderboardChartData.labels.length > 0) {
const filtered = leaderboardChartData.datasets.filter(ds => (ds.sessions_played || 0) > 1);
const chartData = {
labels: leaderboardChartData.labels,
datasets: filtered.length ? filtered : leaderboardChartData.datasets,
};
mainChart = new Chart(ctx, buildChartConfig(chartData, true));
}
rows.forEach((row) => tbody.appendChild(row)); const modal = document.getElementById('chartModal');
leaderboardTable.dataset.sortKey = sortKey; const expandBtn = document.getElementById('expandLeaderboardChart');
leaderboardTable.dataset.sortDirection = direction; let expandedChart = null;
updateRankCells();
setActiveSortButton(sortKey, direction);
}
sortButtons.forEach((button) => { expandBtn?.addEventListener('click', () => {
button.addEventListener('click', () => { if (!leaderboardChartData.labels.length) return;
sortLeaderboard(button.dataset.sortKey, button.dataset.sortType); modal.style.display = 'flex';
}); document.body.style.overflow = 'hidden';
if (expandedChart) expandedChart.destroy();
expandedChart = new Chart(
document.getElementById('leaderboardChartExpanded'),
buildChartConfig(leaderboardChartData, false)
);
});
document.querySelectorAll('[data-close-modal]').forEach(el => {
el.addEventListener('click', () => {
modal.style.display = 'none';
document.body.style.overflow = '';
if (expandedChart) { expandedChart.destroy(); expandedChart = null; }
}); });
});
setActiveSortButton('net', 'desc'); document.addEventListener('keydown', e => {
if (e.key === 'Escape' && modal.style.display === 'flex') {
modal.style.display = 'none';
document.body.style.overflow = '';
if (expandedChart) { expandedChart.destroy(); expandedChart = null; }
}
});
// Segmented mode switcher
document.querySelectorAll('[data-mode]').forEach(btn => {
btn.addEventListener('click', () => {
const url = new URL(window.location.href);
url.searchParams.set('mode', btn.dataset.mode);
window.location.href = url.toString();
});
});
// Table sort
const tbl = document.getElementById('leaderboardTable');
const sortBtns = tbl ? Array.from(tbl.querySelectorAll('.sort-btn[data-sort-key]')) : [];
const colMap = { rank: 0, player: 1, rankdelta: 2, net: 3, winpct: 4, roi: 5, avgwin: 6, avgloss: 7, sessions: 8 };
function setActiveSort(key, dir) {
sortBtns.forEach(b => {
const active = b.dataset.sortKey === key;
b.classList.toggle('is-active', active);
const arrow = b.querySelector('.sort-arrow');
if (arrow) arrow.textContent = active ? (dir === 'asc' ? '↑' : '↓') : '↕';
});
}
function sortTable(key, sortType, forcedDir) {
if (!tbl) return;
const tbody = tbl.tBodies[0];
const rows = Array.from(tbody.querySelectorAll('tr[data-player-row]'));
const curKey = tbl.dataset.sortKey;
const curDir = tbl.dataset.sortDirection || 'desc';
let dir = forcedDir;
if (!dir) {
if (curKey === key) dir = curDir === 'desc' ? 'asc' : 'desc';
else {
const btn = sortBtns.find(b => b.dataset.sortKey === key);
dir = btn?.dataset.initialDirection || 'desc';
}
}
const mult = dir === 'asc' ? 1 : -1;
const ci = colMap[key] ?? 0;
rows.sort((a, b) => {
const av = a.children[ci]?.dataset.sortValue ?? '';
const bv = b.children[ci]?.dataset.sortValue ?? '';
if (sortType === 'number') {
const n = (Number(av) - Number(bv)) * mult;
if (n !== 0) return n;
} else {
const s = av.localeCompare(bv) * mult;
if (s !== 0) return s;
}
return Number(a.dataset.originalRank) - Number(b.dataset.originalRank);
});
rows.forEach(r => tbody.appendChild(r));
tbl.dataset.sortKey = key;
tbl.dataset.sortDirection = dir;
rows.forEach((row, i) => {
const rc = row.querySelector('[data-rank-cell]');
if (rc) rc.textContent = `#${i + 1}`;
});
setActiveSort(key, dir);
}
sortBtns.forEach(b => {
b.addEventListener('click', () => sortTable(b.dataset.sortKey, b.dataset.sortType));
});
setActiveSort('net', 'desc');
</script> </script>
{% endblock %} {% endblock %}
+282 -195
View File
@@ -1,231 +1,318 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ player.player_name }} · Poker Portal{% endblock %} {% block title %}{{ player.player_name }} · boker.club{% endblock %}
{% block content %} {% block content %}
<section class="hero-card compact">
<div class="hero">
<div> <div>
<p class="eyebrow">Player profile</p> {% if player_rank %}
<h1>{{ player.player_name }}</h1> <p class="player-rank">Rank #{{ player_rank }}</p>
<p class="muted-text">A quick read on lifetime performance, hit rate, and session-by-session movement.</p> {% endif %}
<h1 class="hero__title">{{ player.player_name }}</h1>
<p class="hero__sub">{{ player.sessions_played }} session{{ 's' if player.sessions_played != 1 else '' }} · {{ "%.1f"|format(player.win_pct) }}% win rate</p>
</div> </div>
</section> </div>
<section class="grid three-col profile-summary-grid"> <div class="player-stat-grid" style="margin-bottom:20px;">
<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> <div class="stat">
<article class="panel stat-card"><span>Total invested</span><strong>{{ player.total_invested_cents | money }}</strong></article> <span class="kicker">Total net</span>
<article class="panel stat-card"><span>Cash-outs</span><strong>{{ player.total_cash_out_cents | money }}</strong></article> <div class="stat__val {{ 'num-pos' if player.total_net_cents > 0 else 'num-neg' if player.total_net_cents < 0 else 'num-muted' }}">
<article class="panel stat-card"><span>Current debt</span><strong class="{{ 'negative' if player.current_player_owes_cents > 0 else '' }}">{{ player.current_player_owes_cents | money }}</strong></article> {{ '+' if player.total_net_cents > 0 else '' }}{{ player.total_net_cents | money }}
<article class="panel stat-card"><span>Win %</span><strong>{{ "%.1f"|format(player.win_pct) }}%</strong></article>
<article class="panel stat-card"><span>Sessions</span><strong>{{ player.sessions_played }}</strong></article>
</section>
<section class="grid one-col">
<div class="panel chart-panel">
<div class="panel-header">
<div>
<p class="eyebrow">Trend</p>
<h2>Cumulative profit</h2>
</div>
</div> </div>
<div class="chart-frame"> </div>
<div class="stat">
<span class="kicker">Win rate</span>
<div class="stat__val" style="color:var(--text-strong)">{{ "%.1f"|format(player.win_pct) }}%</div>
<div class="stat__sub">{{ player.winning_sessions }}W · {{ player.losing_sessions }}L · {{ player.break_even_sessions }}E</div>
</div>
<div class="stat">
<span class="kicker">ROI</span>
<div class="stat__val {{ 'num-pos' if player.roi_pct > 0 else 'num-neg' if player.roi_pct < 0 else 'num-muted' }}">{{ "%.1f"|format(player.roi_pct) }}%</div>
</div>
<div class="stat">
<span class="kicker">Sessions</span>
<div class="stat__val" style="color:var(--text-strong)">{{ player.sessions_played }}</div>
</div>
<div class="stat">
<span class="kicker">Current W/L</span>
<div class="stat-pair">
<span class="stat-pair__val" style="color:var(--pos)">{{ player.current_win_streak }}W</span>
<span class="stat-pair__val" style="color:var(--neg)">{{ player.current_loss_streak }}L</span>
</div>
<div class="stat__sub">Longest {{ player.longest_win_streak }}W · {{ player.longest_loss_streak }}L</div>
</div>
<div class="stat">
<span class="kicker">Best / worst</span>
<div class="stat-pair">
<span class="stat-pair__val" style="color:var(--pos)">{{ player.biggest_win_cents | money }}</span>
<span class="stat-pair__val" style="color:var(--neg)">{{ player.biggest_loss_cents | money }}</span>
</div>
</div>
<div class="stat">
<span class="kicker">Total invested</span>
<div class="stat__val" style="color:var(--num)">{{ player.total_invested_cents | money }}</div>
</div>
<div class="stat">
<span class="kicker">Cash-outs</span>
<div class="stat__val" style="color:var(--num)">{{ player.total_cash_out_cents | money }}</div>
</div>
</div>
<!-- Cumulative profit chart -->
<div class="panel" style="margin-bottom:20px;">
<div class="panel__head">
<span class="kicker" style="margin:0;">Trend</span>
<h2 class="panel__title">Cumulative profit</h2>
</div>
<div class="chart-wrap">
<div class="chart-frame chart-frame--tall">
<canvas id="playerCumulativeChart"></canvas> <canvas id="playerCumulativeChart"></canvas>
</div> </div>
</div> </div>
</section> </div>
<section class="grid two-col"> <!-- Charts row: session results + result mix -->
<div class="panel chart-panel"> <div class="split" style="margin-bottom:20px;">
<div class="panel-header"> <div class="panel col-main">
<div> <div class="panel__head">
<p class="eyebrow">Volatility</p> <span class="kicker" style="margin:0;">Session by session</span>
<h2>Session results</h2> <h2 class="panel__title">Results</h2>
</div>
<div class="chart-wrap">
<div class="chart-frame">
<canvas id="playerSessionChart"></canvas>
</div> </div>
</div> </div>
<div class="chart-frame">
<canvas id="playerSessionChart"></canvas>
</div>
</div> </div>
<div class="panel chart-panel"> <div class="panel col-side">
<div class="panel-header"> <div class="panel__head">
<div> <span class="kicker" style="margin:0;">Breakdown</span>
<p class="eyebrow">Breakdown</p> <h2 class="panel__title">Result mix</h2>
<h2>Result mix</h2> </div>
<div class="chart-wrap">
<div class="result-mix" aria-label="Player result mix">
{% set result_total = player.winning_sessions + player.losing_sessions + player.break_even_sessions %}
<div class="result-mix__bar">
{% if player.winning_sessions > 0 %}
<span class="result-mix__seg result-mix__seg--win" style="flex-grow:{{ player.winning_sessions }}"></span>
{% endif %}
{% if player.break_even_sessions > 0 %}
<span class="result-mix__seg result-mix__seg--even" style="flex-grow:{{ player.break_even_sessions }}"></span>
{% endif %}
{% if player.losing_sessions > 0 %}
<span class="result-mix__seg result-mix__seg--loss" style="flex-grow:{{ player.losing_sessions }}"></span>
{% endif %}
</div>
<div class="result-mix__legend">
<div class="result-mix__legend-row">
<span><i class="result-mix__key result-mix__key--win"></i>Wins</span>
<strong>{{ player.winning_sessions }} · {{ "%.1f"|format((player.winning_sessions / result_total * 100) if result_total else 0) }}%</strong>
</div>
<div class="result-mix__legend-row">
<span><i class="result-mix__key result-mix__key--even"></i>Even</span>
<strong>{{ player.break_even_sessions }} · {{ "%.1f"|format((player.break_even_sessions / result_total * 100) if result_total else 0) }}%</strong>
</div>
<div class="result-mix__legend-row">
<span><i class="result-mix__key result-mix__key--loss"></i>Losses</span>
<strong>{{ player.losing_sessions }} · {{ "%.1f"|format((player.losing_sessions / result_total * 100) if result_total else 0) }}%</strong>
</div>
</div>
{% if result_total > 0 %}
<div class="result-mix__note">±$1.00 counts as even</div>
{% endif %}
</div> </div>
</div> </div>
<div class="chart-frame chart-frame-small">
<canvas id="playerBreakdownChart"></canvas>
</div>
</div> </div>
</section> </div>
<section class="panel"> <!-- Perf vs Cash two-panel split -->
<div class="panel-header"> <div class="split" style="margin-bottom:20px;">
<div>
<p class="eyebrow">Breakdown</p> <!-- Poker performance -->
<h2>Detailed stats</h2> <div class="panel col-main">
<div class="panel__head">
<div>
<div class="section-marker">
<span class="section-marker__dot section-marker__dot--accent"></span>
Poker performance
</div>
<h2 class="panel__title">Game results</h2>
</div>
</div> </div>
</div> <table style="width:100%;border-collapse:collapse;">
<div class="table-wrap">
<table>
<tbody> <tbody>
<tr><th>Total Net</th><td>{{ player.total_net_cents | money }}</td></tr>
<tr><th>Win %</th><td>{{ "%.1f"|format(player.win_pct) }}%</td></tr>
<tr><th>ROI</th><td>{{ "%.1f"|format(player.roi_pct) }}%</td></tr>
<tr><th>Sessions</th><td>{{ player.sessions_played }}</td></tr>
<tr><th>Paid Buy-ins</th><td>{{ player.total_buy_in_cents | money }}</td></tr>
<tr><th>Fronted</th><td>{{ player.total_front_cents | money }}</td></tr>
<tr><th>Debt Collected</th><td>{{ player.total_front_collected_cents | money }}</td></tr>
<tr><th>Current Debt</th><td>{{ player.current_player_owes_cents | money }}</td></tr>
<tr><th>Written Off</th><td>{{ player.total_front_writeoff_cents | money }}</td></tr>
<tr><th>Rolled In</th><td>{{ player.total_rollover_in_cents | money }}</td></tr>
<tr><th>Total Invested</th><td>{{ player.total_invested_cents | money }}</td></tr>
<tr><th>Cash-outs</th><td>{{ player.total_cash_out_cents | money }}</td></tr>
<tr><th>Rolled Out</th><td>{{ player.total_rollover_out_cents | money }}</td></tr>
<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>
<tr><th>Current Win Streak</th><td>{{ player.current_win_streak }}</td></tr>
<tr><th>Current Loss Streak</th><td>{{ player.current_loss_streak }}</td></tr>
<tr><th>Longest Win Streak</th><td>{{ player.longest_win_streak }}</td></tr>
<tr><th>Longest Loss Streak</th><td>{{ player.longest_loss_streak }}</td></tr>
<tr> <tr>
<th>Best Session</th> <td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Total Net</td>
<td> <td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);font-variant-numeric:tabular-nums;border-top:1px solid var(--divider);"
{{ player.best_session_net_cents | money }} class="{{ 'num-pos' if player.total_net_cents > 0 else 'num-neg' if player.total_net_cents < 0 else '' }}">
{% if player.best_session_date %} {{ '+' if player.total_net_cents > 0 else '' }}{{ player.total_net_cents | money }}
<span class="muted-text small">on {{ player.best_session_date | pretty_date }}</span>
{% endif %}
</td> </td>
</tr> </tr>
<tr> <tr>
<th>Worst Session</th> <td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Win %</td>
<td> <td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">{{ "%.1f"|format(player.win_pct) }}%</td>
{{ player.worst_session_net_cents | money }} </tr>
{% if player.worst_session_date %} <tr>
<span class="muted-text small">on {{ player.worst_session_date | pretty_date }}</span> <td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">ROI</td>
{% endif %} <td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);"
class="{{ 'num-pos' if player.roi_pct > 0 else 'num-neg' if player.roi_pct < 0 else '' }}">{{ "%.1f"|format(player.roi_pct) }}%</td>
</tr>
<tr>
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Average Win</td>
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--pos);">{{ player.avg_win_cents | money }}</td>
</tr>
<tr>
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Average Loss</td>
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--neg);">{{ player.avg_loss_cents | money }}</td>
</tr>
<tr>
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Best / Worst</td>
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">
<span style="color:var(--pos)">{{ player.biggest_win_cents | money }}</span>
<span style="color:var(--faintest);margin:0 4px;">/</span>
<span style="color:var(--neg)">{{ player.biggest_loss_cents | money }}</span>
</td>
</tr>
<tr>
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Win / Loss Streak</td>
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">
<span style="color:var(--pos)">{{ player.current_win_streak }}W</span>
<span style="color:var(--faintest);margin:0 4px;">·</span>
<span style="color:var(--neg)">{{ player.current_loss_streak }}L</span>
</td>
</tr>
<tr>
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Longest Win / Loss</td>
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">
{{ player.longest_win_streak }}W · {{ player.longest_loss_streak }}L
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</section>
<!-- Poker inputs and settlement. Fronts/rollovers are part of poker basis,
while paid/collected rows are real cash movement. -->
<div class="panel col-side">
<div class="panel__head">
<div>
<div class="section-marker">
<span class="section-marker__dot section-marker__dot--rolled"></span>
Poker basis
</div>
<h2 class="panel__title">Investment and settlement</h2>
</div>
</div>
<table style="width:100%;border-collapse:collapse;">
<tbody>
<tr>
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Buy-ins paid</td>
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">{{ player.total_buy_in_cents | money }}</td>
</tr>
<tr>
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Fronted</td>
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--warn);">{{ player.total_front_cents | money }}</td>
</tr>
<tr>
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Rolled in</td>
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--rolled);">{{ player.total_rollover_in_cents | money }}</td>
</tr>
<tr>
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Total invested</td>
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">{{ player.total_invested_cents | money }}</td>
</tr>
<tr>
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Cash-outs</td>
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">{{ player.total_cash_out_cents | money }}</td>
</tr>
<tr>
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Rolled out</td>
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--rolled);">{{ player.total_rollover_out_cents | money }}</td>
</tr>
<tr>
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Paid out</td>
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--pos);">{{ player.total_paid_cents | money }}</td>
</tr>
{% if player.total_front_collected_cents > 0 %}
<tr>
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Debt repaid</td>
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--pos);">{{ player.total_front_collected_cents | money }}</td>
</tr>
{% endif %}
{% if player.current_player_owes_cents > 0 %}
<tr>
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Still owes</td>
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--owes);">{{ player.current_player_owes_cents | money }}</td>
</tr>
{% endif %}
{% if player.total_front_writeoff_cents > 0 %}
<tr>
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Written off</td>
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--neg);">{{ player.total_front_writeoff_cents | money }}</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<script> <script>
const playerChartData = {{ chart_data | tojson }}; const playerChartData = {{ chart_data | tojson }};
const rootStyles = getComputedStyle(document.documentElement); const rootStyles = getComputedStyle(document.documentElement);
const textMuted = rootStyles.getPropertyValue('--text-muted').trim(); const textMuted = rootStyles.getPropertyValue('--text-muted').trim();
const lineColor = rootStyles.getPropertyValue('--line').trim(); const lineColor = rootStyles.getPropertyValue('--line').trim();
const sharedScaleOptions = { const sharedOpts = {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
plugins: { plugins: {
legend: { legend: { position: 'bottom', labels: { color: textMuted, usePointStyle: true, boxWidth: 9, boxHeight: 9, padding: 16 } },
position: 'bottom', },
labels: { scales: {
color: textMuted, x: { ticks: { color: textMuted, maxRotation: 0 }, grid: { color: lineColor } },
usePointStyle: true, y: { ticks: { color: textMuted }, grid: { color: lineColor } },
boxWidth: 10, },
boxHeight: 10, };
padding: 18,
}, 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 + '38',
borderWidth: 2.5,
pointRadius: 3,
pointHoverRadius: 5,
tension: 0.22,
fill: true,
}]
}, },
scales: { options: sharedOpts,
x: { });
ticks: { color: textMuted, maxRotation: 0 }, }
grid: { color: lineColor },
}, const sessionCtx = document.getElementById('playerSessionChart');
y: { if (sessionCtx) {
ticks: { color: textMuted }, new Chart(sessionCtx, {
grid: { color: lineColor }, type: 'bar',
}, data: {
labels: playerChartData.labels,
datasets: [{
label: 'Session Net',
data: playerChartData.net_values,
backgroundColor: playerChartData.net_colors,
borderColor: playerChartData.net_colors,
borderRadius: 6,
borderSkipped: false,
}]
}, },
}; options: sharedOpts,
});
}
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>
<script>
const breakdownCtx = document.getElementById('playerBreakdownChart');
if (breakdownCtx) {
new Chart(breakdownCtx, {
type: 'doughnut',
data: {
labels: ['Wins', 'Losses', 'Break-even'],
datasets: [{
data: [
{{ player.winning_sessions }},
{{ player.losing_sessions }},
{{ player.break_even_sessions }}
],
backgroundColor: [
'#59d98e',
'#f06c72',
'#e4bf65'
],
borderWidth: 0,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: {
color: textMuted,
usePointStyle: true,
boxWidth: 10,
boxHeight: 10,
padding: 18,
},
},
},
},
});
}
</script> </script>
{% endblock %} {% endblock %}
+214 -283
View File
@@ -1,164 +1,146 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ session.session_date }} · Poker Portal{% endblock %} {% block title %}{{ session_label(session) }} · boker.club{% endblock %}
{% block page_class %}page--session{% endblock %}
{% block content %} {% block content %}
<section class="hero-card compact">
<div style="display: flex; justify-content: space-between;"> <div class="breadcrumb">
<div> <a href="{{ url_for('sessions') }}">Sessions</a>
<p class="eyebrow">Session detail</p> <span class="breadcrumb__sep">/</span>
<h1>{{ session_label(session) }}</h1> <span>{{ session_label(session) }}</span>
<p class="muted-text">Current totals are derived from the event log for this date.</p> <span style="flex:1"></span>
{% if prev_session %}
<a class="btn btn--ghost btn--sm" href="{{ url_for('session_detail', session_id=prev_session.session_id) }}">← Prev</a>
{% endif %}
{% if next_session %}
<a class="btn btn--ghost btn--sm" href="{{ url_for('session_detail', session_id=next_session.session_id) }}">Next →</a>
{% endif %}
</div>
<div class="hero" style="padding-top:0;">
<p class="hero__kicker session-number">
Session #{{ "%03d"|format(session_number) }}
</p>
<h1 class="hero__title">{{ session_label(session) }}</h1>
<p class="hero__sub">{{ session.entries|length }} player{{ 's' if session.entries|length != 1 else '' }} · <span class="status-badge status-{{ session.status }}">{{ session.status }}</span></p>
</div>
<!-- Recon cards -->
<div class="recon-grid" style="margin-bottom:20px;">
<div class="panel">
<div class="recon-card-head">
<span class="kicker kicker--dot kicker--dot-accent">Cash flow</span>
</div> </div>
<div class="recon-metric-grid">
<div style="align-self: center;"> <div class="recon-cell">
{% if prev_session %} <span class="kicker" style="font-size:10px;">Cash in</span>
<a class="secondary-button" href="{{ url_for('session_detail', session_id=prev_session.session_id) }}"> <div class="recon-val recon-val--num">{{ session.total_cash_in_cents | money }}</div>
Previous Session </div>
</a> <div class="recon-cell">
{% endif %} <span class="kicker" style="font-size:10px;">Gross payout</span>
<div class="recon-val recon-val--num">{{ session.total_gross_payout_cents | money }}</div>
{% if next_session %} </div>
<a class="secondary-button" href="{{ url_for('session_detail', session_id=next_session.session_id) }}"> <div class="recon-cell">
Next Session <span class="kicker" style="font-size:10px;">Cash paid out</span>
</a> <div class="recon-val recon-val--pos">{{ session.total_paid_cents | money }}</div>
{% endif %} </div>
<div class="recon-cell">
<span class="kicker" style="font-size:10px;">Rolled out</span>
<div class="recon-val" style="color:var(--rolled)">{{ session.total_rollover_out_cents | money }}</div>
</div>
</div> </div>
</div> </div>
</section> <div class="panel">
<div class="recon-card-head">
<section class="grid payout-summary-grid"> <span class="kicker kicker--dot kicker--dot-warn">Open items</span>
<article class="panel stat-card">
<span>Gross payout</span>
<strong>{{ session.total_gross_payout_cents | money }}</strong>
</article>
<article class="panel stat-card">
<span>Total rolled out</span>
<strong>{{ session.total_rollover_out_cents | money }}</strong>
</article>
<article class="panel stat-card">
<span>Total paid</span>
<strong class="positive">{{ session.total_paid_cents | money }}</strong>
</article>
<article class="panel stat-card">
<span>Still owed</span>
<strong class="{{ 'negative' if session.total_current_due_cents > 0 else 'positive' }}">
{{ session.total_current_due_cents | money }}
</strong>
</article>
<article class="panel stat-card">
<span>Players still owe</span>
<strong class="{{ 'negative' if session.total_player_owes_cents > 0 else '' }}">
{{ session.total_player_owes_cents | money }}
</strong>
</article>
<article class="panel stat-card">
<span>Written off</span>
<strong class="{{ 'negative' if session.total_front_writeoff_cents > 0 else '' }}">
{{ session.total_front_writeoff_cents | money }}
</strong>
</article>
</section>
<section class="panel results-panel results-panel-full">
<div class="panel-header">
<div>
<p class="eyebrow">Results</p>
<h2>Player totals</h2>
</div> </div>
<div class="recon-metric-grid">
<div class="table-toolbar"> <div class="recon-cell">
<button type="button" class="secondary-button table-view-toggle is-active" data-view="compact"> <span class="kicker" style="font-size:10px;">House owes</span>
Compact <div class="recon-val {{ 'recon-val--neg' if session.total_current_due_to_player_cents > 0 else 'recon-val--pos' }}">
</button> {{ session.total_current_due_to_player_cents | money }}
<button type="button" class="secondary-button table-view-toggle" data-view="full"> </div>
Full details </div>
</button> <div class="recon-cell">
<span class="kicker" style="font-size:10px;">Players owe house</span>
<div class="recon-val {{ 'recon-val--warn' if session.total_current_due_to_house_cents > 0 else '' }}">
{{ session.total_current_due_to_house_cents | money }}
</div>
</div>
<div class="recon-cell">
<span class="kicker" style="font-size:10px;">Debt repaid</span>
<div class="recon-val" style="color:var(--pos)">{{ session.total_front_collected_cents | money }}</div>
</div>
<div class="recon-cell">
<span class="kicker" style="font-size:10px;">Written off</span>
<div class="recon-val" style="color:var(--neg)">{{ session.total_front_writeoff_cents | money }}</div>
</div>
</div> </div>
</div> </div>
</div>
<div class="table-scroll table-scroll-open"> <!-- Player totals table -->
<table class="data-table session-results-table"> <div class="panel" style="margin-bottom:20px;">
<div class="panel__head">
<div>
<span class="kicker" style="margin:0;">Results</span>
<h2 class="panel__title">Player totals</h2>
</div>
<div class="segmented" id="viewToggle">
<button class="seg is-on" data-view="compact">Compact</button>
<button class="seg" data-view="full">Full ledger</button>
</div>
</div>
<div class="tbl-scroll">
<table class="tbl tbl--session" id="sessionTable">
<thead> <thead>
<tr> <tr>
<th>Player</th> <th class="col-player">Player</th>
<th>In</th> <th>Invested</th>
<th class="detail-col">Fronted</th> <th>Cash-out</th>
<th class="detail-col">Rolled in</th>
<th>Out</th>
<th>Net</th> <th>Net</th>
<th class="detail-col">Gross due</th> <th>Cash paid</th>
<th class="detail-col">Paid</th> <th>Settlement</th>
<th class="detail-col">Rolled out</th>
<th class="detail-col">Collected</th>
<th class="detail-col">Written off</th>
<th>Still owed</th>
<th>Status</th> <th>Status</th>
<th>Notes</th> <th class="col-acct">Fronted</th>
<th class="col-acct">Rolled in</th>
<th class="col-acct">Gross payout</th>
<th class="col-acct">Rolled out</th>
<th class="col-acct">Notes</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for entry in session.entries %} {% for entry in session.entries %}
<tr> <tr onclick="window.location='{{ url_for('player_detail', player_name=entry.player_name) }}'">
<td> <td class="col-player">
<a href="{{ url_for('player_detail', player_name=entry.player_name) }}"> <a href="{{ url_for('player_detail', player_name=entry.player_name) }}">{{ entry.player_name }}</a>
{{ entry.player_name }}
</a>
</td> </td>
<td>{{ entry.invested_cents | money }}</td> <td>{{ entry.invested_cents | money }}</td>
<td class="detail-col">{{ entry.front_cents | money }}</td>
<td class="detail-col">{{ entry.rollover_in_cents | money }}</td>
<td>{{ entry.cash_out_cents | money }}</td> <td>{{ entry.cash_out_cents | money }}</td>
<td class="num-net {{ 'num-pos' if entry.net_cents > 0 else 'num-neg' if entry.net_cents < 0 else 'num-muted' }}">
<td class="{{ 'positive' if entry.net_cents > 0 else 'negative' if entry.net_cents < 0 else '' }}"> {{ '+' if entry.net_cents > 0 else '' }}{{ entry.net_cents | money }}
{{ entry.net_cents | money }}
</td> </td>
<td style="color:var(--pos)">{{ entry.paid_cents | money }}</td>
<td class="detail-col"> <td class="{{ 'num-neg' if entry.current_due_to_player_cents > 0 else '' }}">
{% if entry.raw_player_owes_cents > 0 %} {% if entry.current_due_to_house_cents > 0 %}
<span style="color:var(--owes)">owes {{ entry.current_due_to_house_cents | money }}</span>
{% else %} {% else %}
{{ entry.gross_payout_cents | money }} {{ entry.current_due_to_player_cents | money }}
{% endif %} {% endif %}
</td> </td>
<td class="detail-col">{{ entry.paid_cents | money }}</td>
<td class="detail-col">{{ entry.rollover_out_cents | money }}</td>
<td class="detail-col">{{ entry.front_collected_applied_cents | money }}</td>
<td class="detail-col">{{ entry.front_writeoff_applied_cents | money }}</td>
<td class="{% if entry.player_owes_cents > 0 %}negative{% elif entry.front_writeoff_applied_cents > 0 %}negative{% elif entry.front_collected_applied_cents > 0 %}positive{% elif entry.current_due_cents > 0 %}negative{% else %}positive{% endif %}">
{% 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 %}
</td>
<td> <td>
<span class="status-badge status-{{ entry.payout_status }}"> <span class="badge badge--{{ entry.payout_status }}">{{ entry.payout_status }}</span>
{{ entry.payout_status }}
</span>
</td> </td>
<td class="col-acct" style="color:var(--warn)">{{ entry.front_cents | money }}</td>
<td> <td class="col-acct" style="color:var(--rolled)">{{ entry.rollover_in_cents | money }}</td>
<td class="col-acct">
{% if entry.player_owes_gross_cents > 0 %}—{% else %}{{ entry.gross_payout_cents | money }}{% endif %}
</td>
<td class="col-acct" style="color:var(--rolled)">{{ entry.rollover_out_cents | money }}</td>
<td class="col-acct" style="text-align:left;font-family:var(--font-ui);">
{% if entry.notes %} {% if entry.notes %}
<ul class="note-list"> <ul class="note-list">{% for n in entry.notes %}<li>{{ n }}</li>{% endfor %}</ul>
{% for note in entry.notes %}
<li>{{ note }}</li>
{% endfor %}
</ul>
{% else %} {% else %}
<span style="color:var(--faintest-2)"></span>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@@ -166,185 +148,134 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</section> </div>
<section class="grid two-col chart-row"> <!-- Bottom row: net bars + snapshot + audit feed -->
<div class="panel chart-panel chart-panel-compact"> <div class="split" style="align-items:stretch;">
<div class="panel-header"> <div class="panel col-main">
<div> <div class="panel__head">
<p class="eyebrow">Breakdown</p> <span class="kicker" style="margin:0;">Breakdown</span>
<h2>Net by player</h2> <h2 class="panel__title">Net by player</h2>
</div>
<div class="chart-wrap">
<div class="session-net-bars" id="sessionBreakdownChart" aria-label="Net by player"></div>
</div> </div>
</div>
<div class="chart-frame chart-frame-compact">
<canvas id="sessionBreakdownChart"></canvas>
</div>
</div> </div>
<div class="panel session-insights-panel"> <div class="panel col-side" style="display:flex;flex-direction:column;">
<div class="panel-header"> <div class="panel__head">
<div> <span class="kicker" style="margin:0;">Quick read</span>
<p class="eyebrow">Quick read</p> <h2 class="panel__title">Session snapshot</h2>
<h2>Session snapshot</h2>
</div>
</div> </div>
{% set winners = session.entries | selectattr('net_cents', 'gt', 0) | list %} {% set winners = session.entries | selectattr('net_cents', 'gt', 0) | list %}
{% set losers = session.entries | selectattr('net_cents', 'lt', 0) | list %} {% set losers = session.entries | selectattr('net_cents', 'lt', 0) | list %}
<div class="snapshot-grid" style="flex:1;">
<div class="detail-grid"> <div class="snapshot-cell">
<div class="detail-row"> <span class="kicker" style="font-size:10px;">Biggest winner</span>
<span>Players</span> <div class="snapshot-val" style="color:var(--pos)">
<strong>{{ session.entries|length }}</strong> {% if winners %}{{ (winners | sort(attribute='net_cents', reverse=True) | first).net_cents | money }}{% else %}—{% endif %}
</div>
{% if winners %}<div style="font-size:12px;color:var(--muted);margin-top:4px;">{{ winners | sort(attribute='net_cents', reverse=True) | first | attr('player_name') }}</div>{% endif %}
</div> </div>
<div class="detail-row"> <div class="snapshot-cell">
<span>Biggest winner</span> <span class="kicker" style="font-size:10px;">Biggest loser</span>
<strong> <div class="snapshot-val" style="color:var(--neg)">
{% if winners %} {% if losers %}{{ (losers | sort(attribute='net_cents') | first).net_cents | money }}{% else %}—{% endif %}
{{ winners | sort(attribute='net_cents', reverse=True) | first | attr('player_name') }} </div>
{% else %} {% if losers %}<div style="font-size:12px;color:var(--muted);margin-top:4px;">{{ losers | sort(attribute='net_cents') | first | attr('player_name') }}</div>{% endif %}
{% endif %}
</strong>
</div> </div>
<div class="detail-row"> <div class="snapshot-cell">
<span>Best result</span> <span class="kicker" style="font-size:10px;">Total cash-out</span>
<strong class="positive"> <div class="snapshot-val">{{ session.total_cash_out_cents | money }}</div>
{% if winners %}
{{ (winners | sort(attribute='net_cents', reverse=True) | first).net_cents | money }}
{% else %}
$0.00
{% endif %}
</strong>
</div> </div>
<div class="detail-row"> <div class="snapshot-cell">
<span>Biggest loser</span> <span class="kicker" style="font-size:10px;">Open items</span>
<strong> <div class="snapshot-val {{ 'recon-val--neg' if (session.total_current_due_to_player_cents + session.total_current_due_to_house_cents) > 0 else '' }}">
{% if losers %} {{ (session.total_current_due_to_player_cents + session.total_current_due_to_house_cents) | money }}
{{ losers | sort(attribute='net_cents') | first | attr('player_name') }} </div>
{% else %}
{% endif %}
</strong>
</div>
<div class="detail-row">
<span>Worst result</span>
<strong class="negative">
{% if losers %}
{{ (losers | sort(attribute='net_cents') | first).net_cents | money }}
{% else %}
$0.00
{% endif %}
</strong>
</div>
<div class="detail-row">
<span>Unpaid total</span>
<strong class="{{ 'negative' if session.total_remaining_cents > 0 else 'positive' }}">
{{ session.total_remaining_cents | money }}
</strong>
</div> </div>
</div> </div>
</div> </div>
</section> </div>
<section class="panel"> <!-- Audit feed -->
<div class="panel-header"> <div class="panel" style="margin-top:20px;">
<div class="panel__head">
<div> <div>
<p class="eyebrow">Audit trail</p> <span class="kicker" style="margin:0;">Audit trail</span>
<h2>Raw ledger events</h2> <h2 class="panel__title">Raw ledger events</h2>
</div> </div>
<span class="badge badge--closed" style="font-size:9.5px;letter-spacing:.08em;">append-only</span>
</div> </div>
<div class="event-feed"> <div class="event-feed feed feed--inline" style="max-height:none;">
{% for event in raw_events | reverse %} {% for event in raw_events | reverse %}
<article class="event-card"> <div class="event-card event-card--inline">
<div class="event-card-top"> <div class="event-card-top">
<strong>{{ event.player_name or "Session" }}</strong> <strong>{{ event.player_name or "Session" }}</strong>
<span class="pill pill-{{ event.event_type }}">{{ event.event_type }}</span> <span class="pill pill-{{ event.event_type }}">{{ event.event_type | replace('_', ' ') }}</span>
{% if event.note %}<span class="event-note">{{ event.note }}</span>{% endif %}
</div> </div>
<p class="event-amount">{{ event.amount_cents | money }}</p> <p class="event-amount">{{ event.amount_cents | money }}</p>
{% if event.note %} <p class="tiny-text" style="white-space:nowrap;">{{ event.created_at }}</p>
<p class="muted-text small">{{ event.note }}</p> </div>
{% endif %}
<p class="tiny-text">{{ event.created_at }} · by {{ event.actor }}</p>
</article>
{% else %} {% else %}
<p class="muted-text">No raw events recorded.</p> <p class="muted-text" style="padding:20px;">No events recorded.</p>
{% endfor %} {% endfor %}
</div> </div>
</section> </div>
<script> <script>
const sessionChartData = {{ chart_data | tojson }}; const sessionChartData = {{ chart_data | tojson }};
const rootStyles = getComputedStyle(document.documentElement);
const textMuted = rootStyles.getPropertyValue('--text-muted').trim();
const lineColor = rootStyles.getPropertyValue('--line').trim();
const sessionBreakdownCtx = document.getElementById('sessionBreakdownChart'); const breakdownCtx = document.getElementById('sessionBreakdownChart');
if (sessionBreakdownCtx) { if (breakdownCtx) {
new Chart(sessionBreakdownCtx, { const values = sessionChartData.net_values || [];
type: 'bar', const labels = sessionChartData.labels || [];
data: { const colors = sessionChartData.net_colors || [];
labels: sessionChartData.labels, const maxAbs = Math.max(...values.map(value => Math.abs(value)), 1);
datasets: [{ const moneyFormatter = new Intl.NumberFormat('en-US', {
label: 'Session Net', style: 'currency',
data: sessionChartData.net_values, currency: 'USD',
backgroundColor: sessionChartData.net_colors, });
borderColor: sessionChartData.net_colors, const escapeHtml = value => String(value).replace(/[&<>"']/g, char => ({
borderRadius: 8, '&': '&amp;',
borderSkipped: false, '<': '&lt;',
}] '>': '&gt;',
}, '"': '&quot;',
options: { "'": '&#39;',
responsive: true, }[char]));
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 },
},
},
},
});
}
</script>
<script> breakdownCtx.innerHTML = labels.map((label, index) => {
const sessionTable = document.querySelector('.session-results-table'); const value = Number(values[index] || 0);
const viewButtons = document.querySelectorAll('.table-view-toggle'); const width = Math.min(Math.abs(value) / maxAbs * 50, 50);
const sideClass = value < 0 ? 'session-net-bars__bar--neg' : 'session-net-bars__bar--pos';
const amount = `${value > 0 ? '+' : ''}${moneyFormatter.format(value)}`;
const color = colors[index] || (value < 0 ? '#e0758a' : value > 0 ? '#6fc093' : '#84828e');
if (sessionTable && viewButtons.length) { return `
sessionTable.classList.add('compact'); <div class="session-net-bars__row">
<div class="session-net-bars__name">${escapeHtml(label)}</div>
<div class="session-net-bars__track">
<span class="session-net-bars__bar ${sideClass}" style="width:${width}%;background:${color};"></span>
</div>
<div class="session-net-bars__amount">${amount}</div>
</div>
`;
}).join('');
}
viewButtons.forEach((button) => { // Compact / Full toggle
button.addEventListener('click', () => { const sessionTable = document.getElementById('sessionTable');
const view = button.dataset.view; document.querySelectorAll('#viewToggle .seg').forEach(btn => {
btn.addEventListener('click', () => {
viewButtons.forEach((btn) => btn.classList.remove('is-active')); document.querySelectorAll('#viewToggle .seg').forEach(b => b.classList.remove('is-on'));
button.classList.add('is-active'); btn.classList.add('is-on');
if (btn.dataset.view === 'full') {
if (view === 'full') { sessionTable.classList.add('is-full');
sessionTable.classList.remove('compact'); } else {
} else { sessionTable.classList.remove('is-full');
sessionTable.classList.add('compact'); }
} });
}); });
});
}
</script> </script>
{% endblock %} {% endblock %}
+32 -30
View File
@@ -1,52 +1,54 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Sessions · Poker Portal{% endblock %} {% block title %}Sessions · boker.club{% endblock %}
{% block content %} {% 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="hero">
<div class="panel-header"> <p class="hero__kicker">Session history</p>
<h1 class="hero__title">Sessions</h1>
<p class="hero__sub">Browse every recorded game night and inspect table totals.</p>
</div>
<div class="panel">
<div class="panel__head">
<div> <div>
<p class="eyebrow">Archive</p> <span class="kicker" style="margin:0;">Archive</span>
<h2>All sessions by date</h2> <h2 class="panel__title">All sessions</h2>
</div> </div>
</div> </div>
<div class="table-wrap"> <div class="tbl-scroll">
<table> <table class="tbl" style="min-width:600px;">
<thead> <thead>
<tr> <tr>
<th>Date</th> <th class="col-left">Date</th>
<th>Status</th> <th>Status</th>
<th>Players</th> <th>Players</th>
<th>Total Buy-ins</th> <th>Invested</th>
<th>Total Cash-outs</th> <th>Cash out</th>
<th>Table Net</th> <th>Table net</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for session in sessions %} {% for s in sessions %}
<tr> <tr onclick="window.location='{{ url_for('session_detail', session_id=s.session_id) }}'">
<td><a href="{{ url_for('session_detail', session_id=session.session_id) }}">{{ session_label(session) }}</a></td> <td class="col-left">
<td><span class="status-badge status-{{ session.status }}"> <a href="{{ url_for('session_detail', session_id=s.session_id) }}">{{ session_label(s) }}</a>
{{ session.status }} </td>
</span></td> <td><span class="badge badge--{{ s.status }}">{{ s.status }}</span></td>
<td>{{ session.entries|length }}</td> <td>{{ s.entries|length }}</td>
<td>{{ session.total_buy_in_cents | money }}</td> <td>{{ s.total_invested_cents | money }}</td>
<td>{{ session.total_cash_out_cents | money }}</td> <td>{{ s.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> <td class="{{ 'num-pos' if s.total_net_cents > 0 else 'num-neg' if s.total_net_cents < 0 else '' }}">
{{ s.total_net_cents | money }}
</td>
</tr> </tr>
{% else %} {% else %}
<tr> <tr>
<td colspan="5">No sessions yet.</td> <td colspan="6" style="text-align:left;padding:28px;color:var(--muted);">No sessions yet.</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
</section> </div>
{% endblock %} {% endblock %}
+1
View File
@@ -0,0 +1 @@
Binary file not shown.
Binary file not shown.
Binary file not shown.
+118
View File
@@ -0,0 +1,118 @@
import unittest
from stats import build_session_summaries
def cents(amount: float) -> 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()