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

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.
+1264 -1188
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)
+255 -269
View File
@@ -1,304 +1,195 @@
{% 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>
<!-- LEFT: append form + recent sessions -->
<div class="col-main">
<form class="panel append" method="post" action="{{ url_for('admin_dashboard') }}">
<div class="panel__head">
<h2 class="panel__title">Append ledger event</h2>
<span class="panel__tag">fast entry</span>
</div>
<div class="append__body">
<div class="append__grid">
<label class="field">
<span class="field__label">Session</span>
<select class="control" name="session_id" required>
<option value="">Select session</option> <option value="">Select session</option>
{% for session in open_sessions %} {% for s in open_sessions %}
<option value="{{ session.session_id }}"> <option value="{{ s.session_id }}" {{ 'selected' if append_form.session_id == s.session_id else '' }}>
{{ session_label(session) }} {{ session_label(s) }}{{ ' (live)' if s.status == 'open' else '' }}
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
</label> </label>
<label class="field">
<label> <span class="field__label">Player</span>
<span>Player name</span> <input class="control" list="player_names_list" type="text" name="player_name" placeholder="Player name" value="{{ append_form.player_name }}" required>
<input list="player_names" type="text" name="player_name" placeholder="Peter Venkman" required> <datalist id="player_names_list">
<datalist id="player_names"> {% for name in player_names %}<option value="{{ name }}"></option>{% endfor %}
{% for player_name in player_names %}
<option value="{{ player_name }}"></option>
{% endfor %}
</datalist> </datalist>
</label> </label>
<label class="field">
<label> <span class="field__label">Event type</span>
<span>Event type</span> <select class="control" name="event_type" required id="eventTypeSelect">
<select name="event_type" required> {% for value, label, color in event_types %}
<option value="buyin">Buy-in</option> <option value="{{ value }}" data-color="{{ color }}" data-label="{{ label }}" {{ 'selected' if append_form.event_type == value else '' }}>{{ label }}</option>
<option value="front">Front</option>
<option value="rollover_in">Rollover in</option>
<option value="cashout">Cash-out</option>
<option value="paid">Paid out</option>
<option value="rollover_out">Rollover out</option>
<option value="note">Note only</option>
</select>
</label>
<label>
<span>Amount</span>
<input type="number" step="0.01" name="amount" placeholder="5.00">
</label>
<label>
<span>Note</span>
<textarea name="note" rows="4" placeholder="Rebuy, fronted chips, correction, partial payout, e-transfer sent, cash paid, etc."></textarea>
</label>
<button class="primary-button" type="submit">Add event</button>
</form>
<section class="grid">
<form class="panel form-card" method="post" action="{{ url_for('admin_open_session') }}">
<p class="eyebrow">Session state</p>
<h2>Open a new session</h2>
<label>
<span>Session date</span>
<input type="date" name="session_date" required>
</label>
<div class="button-row">
<button class="primary-button" type="submit">Open session</button>
</div>
</form>
<section class="panel form-card">
<div class="panel-header">
<div>
<p class="eyebrow">Tools</p>
<h2>Audit CSV</h2>
</div>
</div>
<div class="admin-tools-grid">
<div class="helper-box">
<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">
<label for="csv_file">
<span>Import replacement CSV</span>
</label>
<input id="csv_file" name="csv_file" type="file" accept=".csv,text/csv" required>
<div class="button-row">
<button
class="secondary-button"
type="submit"
onclick="return confirm('Replace the current audit CSV? A backup will be created first.');"
>
Import CSV
</button>
</div>
</form>
</div>
</section>
</section>
</section>
<section class="panel debt-panel">
<div class="panel-header">
<div>
<p class="eyebrow">Front debt</p>
<h2>Debt resolution</h2>
</div>
<div class="debt-summary">
<span>Open {{ admin_totals.players_owe_cents | money }}</span>
<span>Collected {{ admin_totals.collected_cents | money }}</span>
<span>Written off {{ admin_totals.written_off_cents | money }}</span>
</div>
</div>
{% if debt_entries %}
<div class="debt-action-grid">
<form class="form-card compact-form" method="post" action="{{ url_for('admin_collect_front') }}">
<h3>Collect debt</h3>
<label>
<span>Outstanding debt</span>
<select name="debt_key" required>
<option value="">Select debt</option>
{% for item in debt_entries %}
{% set session = item.session %}
{% set entry = item.entry %}
<option value="{{ session.session_id }}||{{ entry.player_name }}">
{{ entry.player_name }} · {{ session_label(session) }} · owes {{ entry.player_owes_cents | money }}
</option>
{% endfor %} {% endfor %}
</select> </select>
</label> </label>
<label class="field">
<label> <span class="field__label">Amount</span>
<span>Amount collected</span> <span class="control control--money">
<input type="number" step="0.01" name="amount" placeholder="5.00" required> <span class="control__prefix">$</span>
<input name="amount" inputmode="decimal" placeholder="0" autofocus>
</span>
</label> </label>
</div>
<label> <label class="field append__note">
<span>Note</span> <span class="field__label">Note <span class="field__opt">(optional)</span></span>
<textarea name="note" rows="3" placeholder="E-transfer received, cash collected, etc."></textarea> <input class="control" name="note" placeholder="e.g. fronted buy-in, settle next week">
</label> </label>
<div class="append__footer">
<div class="button-row"> <button type="submit" class="btn btn--primary">Append event</button>
<button class="primary-button" type="submit">Mark collected</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>
<form class="form-card compact-form" method="post" action="{{ url_for('admin_write_off_front') }}"> </div><!-- /.col-main -->
<h3>Write off debt</h3>
<label> <!-- RIGHT: session controls + CSV + raw events feed -->
<span>Outstanding debt</span> <div class="col-side">
<select name="debt_key" required>
<option value="">Select debt</option>
{% for item in debt_entries %}
{% set session = item.session %}
{% set entry = item.entry %}
<option value="{{ session.session_id }}||{{ entry.player_name }}">
{{ entry.player_name }} · {{ session_label(session) }} · owes {{ entry.player_owes_cents | money }}
</option>
{% endfor %}
</select>
</label>
<label> <div class="panel panel--pad">
<span>Amount to write off</span> <h2 class="panel__title panel__title--mb">Session controls</h2>
<input type="number" step="0.01" name="amount" placeholder="5.00" required>
</label>
<label> {% if live_session %}
<span>Note</span> <div class="livecard">
<textarea name="note" rows="3" placeholder="Won't be collected."></textarea> <div class="livecard__head">
</label> <span class="dot dot--live"></span>
<span class="livecard__name">{{ session_label(live_session) }}</span>
<div class="button-row"> <span class="livecard__no">{{ live_session_no }}</span>
<button class="secondary-button" type="submit">Write off front</button>
</div> </div>
<div class="livecard__metrics">
<div><div class="micro">In play</div><div class="mono16">{{ live_session.total_invested_cents | money }}</div></div>
<div><div class="micro">Seated</div><div class="mono16">{{ live_session.entries|length }}</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>
{% else %}
<p class="muted-text">No outstanding front debt.</p>
{% endif %} {% endif %}
</section>
<section class="grid admin-cash-grid"> <form method="post" action="{{ url_for('admin_open_session') }}">
<article class="panel stat-card"> <input type="hidden" name="session_date" value="{{ admin_today }}">
<span>Cash in</span> <button class="btn-newsession {{ 'btn-newsession--primary' if not live_session else '' }}" type="submit">+ Open new session</button>
<strong>{{ admin_totals.cash_in_cents | money }}</strong> </form>
</article> </div>
<article class="panel stat-card"> <div class="panel panel--pad">
<span>Paid out</span> <h2 class="panel__title" style="margin:0 0 4px">Import / export</h2>
<strong>{{ admin_totals.paid_out_cents | money }}</strong> <p class="csv__desc">Back up or migrate the raw event ledger.</p>
</article> <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>
<article class="panel stat-card"> </div><!-- /.col-side -->
<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"> </div><!-- /.admin-workspace -->
<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"> <div class="admin-bottom-grid">
<span>Written off</span> <div class="panel">
<strong class="{{ 'negative' if admin_totals.written_off_cents > 0 else '' }}"> <div class="panel__head">
{{ 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> <div>
<p class="eyebrow">Recent sessions</p> <span class="kicker" style="margin:0;">Recent sessions</span>
<h2>Current totals</h2> <h2 class="panel__title">Current totals</h2>
</div> </div>
</div> </div>
<div class="table-wrap small-table"> <div class="table-wrap">
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Session</th> <th>Session</th>
<th>Players</th> <th>Players</th>
<th>Cash in</th> <th>Cash in</th>
<th>Paid out</th> <th>Cash out</th>
<th>Still owed</th> <th>House owes</th>
<th>Players owe</th> <th>Players owe house</th>
<th>Collected</th> <th>Book position</th>
<th>Written off</th>
<th>Open balance</th>
<th>Status</th> <th>Status</th>
<th>Action</th> <th>Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for session in recent_sessions %} {% for s in recent_sessions %}
<tr> <tr>
<td> <td><a href="{{ url_for('session_detail', session_id=s.session_id) }}">{{ session_label(s) }}</a></td>
<a href="{{ url_for('session_detail', session_id=session.session_id) }}"> <td style="font-family:var(--font-mono);color:var(--num)">{{ s.entries|length }}</td>
{{ session_label(session) }} <td style="font-family:var(--font-mono);color:var(--num)">{{ s.total_cash_in_cents | money }}</td>
</a> <td style="font-family:var(--font-mono);color:var(--pos)">{{ s.total_paid_out_cents | money }}</td>
</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>{{ session.entries|length }}</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>{{ session.total_cash_in_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>{{ session.total_paid_out_cents | money }}</td> <td><span class="status-badge status-{{ s.status }}">{{ s.status }}</span></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> <td>
<form method="post" action="{{ url_for('admin_session_state') }}" class="inline-action-form"> <form method="post" action="{{ url_for('admin_session_state') }}" class="inline-action-form">
<input type="hidden" name="session_id" value="{{ session.session_id }}"> <input type="hidden" name="session_id" value="{{ s.session_id }}">
{% if session.status == "open" %} {% if s.status == "open" %}
<button class="table-action-button" type="submit" name="state" value="closed">Close</button> <button class="table-action-button" type="submit" name="state" value="closed">Close</button>
{% else %} {% else %}
<button class="table-action-button" type="submit" name="state" value="open">Reopen</button> <button class="table-action-button" type="submit" name="state" value="open">Reopen</button>
@@ -307,36 +198,131 @@
</td> </td>
</tr> </tr>
{% else %} {% else %}
<tr> <tr><td colspan="9">No sessions yet.</td></tr>
<td colspan="11">No sessions yet.</td>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
</section> </div>
<section class="panel"> <div class="panel">
<div class="panel-header"> <div class="panel__head">
<div> <div>
<p class="eyebrow">Recent raw events</p> <span class="kicker" style="margin:0;">Recent events</span>
<h2>Ledger feed</h2> <h2 class="panel__title">Ledger feed</h2>
</div> </div>
<span class="badge badge--closed" style="font-size:9.5px;">append-only</span>
</div> </div>
<div class="event-feed condensed"> <div class="event-feed feed feed--inline">
{% for event in recent_events %} {% for event in recent_events %}
<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 }}</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>
<p class="muted-text small">{{ event.session_date | pretty_date }}</p> <p class="tiny-text">{{ event.session_date | pretty_date }}</p>
{% if event.note %}<p class="muted-text small">{{ event.note }}</p>{% endif %} </div>
</article>
{% else %} {% else %}
<p class="muted-text">No recent events.</p> <p class="muted-text" style="padding:20px;">No recent events.</p>
{% endfor %} {% endfor %}
</div> </div>
</section> </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>
<span class="kicker" style="margin:0;">Front receivables</span>
<h2 class="panel__title">Debt resolution</h2>
</div>
<div class="debt-summary">
<span class="badge badge--owes">Open {{ admin_totals.players_owe_house_cents | money }}</span>
<span class="badge badge--paid">Repaid {{ admin_totals.collected_cents | money }}</span>
<span class="badge badge--written_off">Written off {{ admin_totals.written_off_cents | money }}</span>
</div>
</div>
<div class="debt-action-grid" style="padding:20px;">
<form class="form-card" style="padding:0;background:transparent;" method="post" action="{{ url_for('admin_collect_front') }}">
<h3>Collect debt</h3>
<label>
<span>Outstanding debt</span>
<select name="debt_key" required>
<option value="">Select debt</option>
{% for item in debt_entries %}
{% set s = item.session %}{% set e = item.entry %}
<option value="{{ s.session_id }}||{{ e.player_name }}">
{{ e.player_name }} · {{ session_label(s) }} · owes {{ e.player_owes_cents | money }}
</option>
{% endfor %}
</select>
</label>
<label>
<span>Amount collected</span>
<input type="number" step="0.01" name="amount" placeholder="0.00" required>
</label>
<label>
<span>Note</span>
<textarea name="note" rows="2" placeholder="E-transfer received, cash, etc."></textarea>
</label>
<button class="primary-button" type="submit">Mark collected</button>
</form>
<form class="form-card" style="padding:0;background:transparent;" method="post" action="{{ url_for('admin_write_off_front') }}">
<h3>Write off debt</h3>
<label>
<span>Outstanding debt</span>
<select name="debt_key" required>
<option value="">Select debt</option>
{% for item in debt_entries %}
{% set s = item.session %}{% set e = item.entry %}
<option value="{{ s.session_id }}||{{ e.player_name }}">
{{ e.player_name }} · {{ session_label(s) }} · owes {{ e.player_owes_cents | money }}
</option>
{% endfor %}
</select>
</label>
<label>
<span>Amount to write off</span>
<input type="number" step="0.01" name="amount" placeholder="0.00" required>
</label>
<label>
<span>Note</span>
<textarea name="note" rows="2" placeholder="Won't be collected."></textarea>
</label>
<button class="secondary-button" type="submit">Write off front</button>
</form>
</div>
</div>
{% endif %}
{% 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 %}
+24 -21
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> <div class="topbar__inner">
<a class="brand" href="{{ url_for('leaderboard') }}">Poker Portal</a> <a class="brand" href="{{ url_for('leaderboard') }}">
<p class="brand-subtitle">Low-stakes hold'em ledger and session tracker</p> <span class="brand__mark"></span>
</div> <span class="brand__name">boker.club</span>
<nav class="nav-links"> </a>
<a href="{{ url_for('leaderboard') }}">Leaderboard</a> <nav class="mainnav">
<a href="{{ url_for('sessions') }}">Sessions</a> <a class="navlink {{ 'is-active' if request.endpoint == 'leaderboard' }}" href="{{ url_for('leaderboard') }}">Leaderboard</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>
<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> </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>
+255 -503
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 class="panel-header panel-header-with-actions">
<div> <div>
<p class="eyebrow">Trend</p> <p class="hero__kicker">All-time results</p>
<h2>Cumulative profit over time</h2> <h1 class="hero__title">Leaderboard</h1>
<p class="hero__sub">{{ session_count }} session{{ 's' if session_count != 1 else '' }}{% if selected_session_date %} through {{ selected_session_label }}{% endif %}</p>
</div> </div>
<div class="panel-actions"> <div class="leaderboard-hero-stats">
<button class="ghost-button compact-button" type="button" id="resetLeaderboardZoom" hidden>Reset zoom</button> <div class="stat">
<button class="secondary-button" type="button" id="expandLeaderboardChart">Expand chart</button> <span class="kicker">Cash paid out</span>
<div class="stat__val" style="color:var(--accent)">{{ cash_paid_out_cents | money }}</div>
</div>
<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>
<div class="chart-frame chart-frame-interactive" id="leaderboardChartFrame" role="button" tabindex="0" aria-label="Expand cumulative profit chart"> </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 %} {% endfor %}
</select> </select>
{% if selected_session_date %} {% if selected_session_id %}
<a class="ghost-button compact-button" href="{{ url_for('leaderboard') }}">Reset</a> <a class="btn btn--ghost btn--sm" href="{{ url_for('leaderboard', mode=mode) }}">Reset</a>
{% endif %} {% endif %}
</form> </form>
<p class="muted-text small">Click any column to sort.</p>
</div>
</div> </div>
</div>
<div class="table-wrap"> <div class="panel tbl-scroll" style="margin-bottom:20px;">
<table id="leaderboardTable" class="sortable-table" data-sort-key="net" data-sort-direction="desc"> <table class="tbl" id="leaderboardTable" data-sort-key="net" data-sort-direction="desc">
<thead> <thead>
<tr> <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 class="col-rank" style="width:52px;">
<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> <span style="font-family:var(--font-mono);font-size:10.5px;letter-spacing:.10em;text-transform:uppercase;color:var(--muted);">#</span>
<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>
<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 class="col-player">
<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> <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>
<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>
<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 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-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-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-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-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-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-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-button" data-sort-key="sessions" data-sort-type="number" data-initial-direction="desc">Sessions <span class="sort-indicator"></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> </tr>
</thead> </thead>
<tbody> <tbody>
{% for player in leaderboard %} {% for player in main_board %}
<tr data-player-row="true" data-original-rank="{{ loop.index }}"> {% set idx = loop.index %}
<td data-sort-value="{{ loop.index }}" data-rank-cell>#{{ loop.index }}</td> <tr data-player-row="true" data-original-rank="{{ idx }}"
<td data-sort-value="{{ player.rank_change }}"> 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 %} {% if player.rank_change > 0 %}
<span class="rank-move rank-up">{{ player.rank_change }}</span> <span class="rank-delta rank-delta--up">{{ player.rank_change }}</span>
{% elif player.rank_change < 0 %} {% elif player.rank_change < 0 %}
<span class="rank-move rank-down">{{ player.rank_change | abs }}</span> <span class="rank-delta rank-delta--down">{{ player.rank_change | abs }}</span>
{% else %} {% else %}
<span class="rank-move rank-neutral">-</span> <span class="rank-delta rank-delta--flat"></span>
{% endif %} {% endif %}
</td> </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 class="num-net {{ 'num-pos' if player.total_net_cents > 0 else 'num-neg' if player.total_net_cents < 0 else 'num-muted' }}"
<td data-sort-value="{{ player.total_buy_in_cents }}">{{ player.total_buy_in_cents | money }}</td> data-sort-value="{{ player.total_net_cents }}">
<td data-sort-value="{{ player.total_paid_cents }}">{{ player.total_paid_cents | money }}</td> {{ '+' if player.total_net_cents > 0 else '' }}{{ player.total_net_cents | money }}
<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>
<td data-sort-value="{{ '%.4f'|format(player.win_pct) }}">{{ "%.1f"|format(player.win_pct) }}%</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 class="{{ 'num-pos' if player.roi_pct > 0 else 'num-neg' if player.roi_pct < 0 else '' }}"
<td data-sort-value="{{ player.avg_win_cents }}">{{ player.avg_win_cents | money }}</td> data-sort-value="{{ '%.4f'|format(player.roi_pct) }}">{{ "%.1f"|format(player.roi_pct) }}%</td>
<td data-sort-value="{{ player.avg_loss_cents }}">{{ player.avg_loss_cents | money }}</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> <td data-sort-value="{{ player.sessions_played }}">{{ player.sessions_played }}</td>
</tr> </tr>
{% else %} {% else %}
<tr> <tr>
<td colspan="11">No data yet. Add the first event from the admin page.</td> <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> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
</section>
<div class="chart-modal" id="chartModal" aria-hidden="true"> {% if mode == 'eligible' and provisional_board %}
<div class="chart-modal-backdrop" data-close-chart-modal></div> <div style="margin-bottom:20px;">
<div class="chart-modal-card"> <div class="toolbar" style="margin-top:0;margin-bottom:12px;">
<div class="panel-header panel-header-with-actions"> <span class="kicker" style="margin:0;">Provisional · fewer than {{ eligible_min_sessions }} sessions</span>
<div>
<p class="eyebrow">Expanded view</p>
<h2>Cumulative profit over time</h2>
</div> </div>
<div class="panel-actions"> <div class="provisional-grid">
<button class="secondary-button" type="button" id="fullscreenLeaderboardChart">Fullscreen</button> {% for player in provisional_board %}
<button class="ghost-button" type="button" data-close-chart-modal>Close</button> <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>
<div class="chart-modal-frame" id="chartModalFrame"> </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 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 {
labels: [...chartData.labels],
datasets: visibleDatasets.map((dataset) => ({
...dataset,
data: [...dataset.data],
pointRadius: 0,
pointHoverRadius: 5,
borderWidth: 2.5,
tension: 0.28,
})),
};
}
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;
function buildChartConfig(data, compact) {
return { return {
type: 'line', type: 'line',
data: chartData, data: {
labels: data.labels,
datasets: data.datasets.map(ds => ({
...ds,
pointRadius: compact ? 0 : 2,
pointHoverRadius: 5,
borderWidth: compact ? 2 : 2.5,
tension: 0.28,
fill: false,
})),
},
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
interaction: { mode: 'nearest', intersect: false }, interaction: { mode: 'nearest', intersect: false },
elements: {
point: {
radius: compact ? 0 : 2,
hoverRadius: 5,
},
line: {
borderWidth: compact ? 2.5 : 2,
tension: 0.28,
},
},
plugins: { plugins: {
legend: { legend: {
display: true, display: true,
position: 'bottom', position: 'bottom',
labels: { labels: { color: textMuted, usePointStyle: true, boxWidth: 8, boxHeight: 8, padding: 14 },
color: textMuted,
usePointStyle: true,
boxWidth: compact ? 8 : 10,
boxHeight: compact ? 8 : 10,
padding: compact ? 12 : 18,
},
}, },
tooltip: { tooltip: {
callbacks: { callbacks: {
label(context) { label(ctx) {
const value = context.parsed.y; const v = ctx.parsed.y;
if (value === null || value === undefined) { return v == null ? `${ctx.dataset.label}: —` : `${ctx.dataset.label}: $${Number(v).toFixed(2)}`;
return `${context.dataset.label}: —`; }
}
} }
return `${context.dataset.label}: $${Number(value).toFixed(2)}`;
},
},
},
}, },
scales: { scales: {
x: { x: {
ticks: { color: textMuted, maxRotation: 0, autoSkip: true, maxTicksLimit: compact ? 6 : 10 },
grid: { color: lineColor, display: !compact },
},
y: {
ticks: { ticks: {
color: textMuted, color: textMuted,
maxRotation: 0, maxTicksLimit: compact ? 8 : 10,
autoSkip: true, callback(v) { return (v < 0 ? '-' : '') + '$' + Math.abs(Number(v)).toFixed(0); }
maxTicksLimit: compact ? 6 : 10,
},
grid: {
color: lineColor,
display: !compact,
},
},
y: chartYScaleOptions(chartData, compact),
},
}, },
grid: { color: lineColor },
}
}
}
}; };
} }
const zoomSelectionPlugin = { const ctx = document.getElementById('leaderboardChart');
id: 'zoomSelection', let mainChart = null;
beforeDraw(chart) { if (ctx && leaderboardChartData.labels.length > 0) {
const drag = chart.$zoomDrag; const filtered = leaderboardChartData.datasets.filter(ds => (ds.sessions_played || 0) > 1);
if (!drag || !drag.active || drag.startX === drag.endX) { const chartData = {
return; labels: leaderboardChartData.labels,
} datasets: filtered.length ? filtered : leaderboardChartData.datasets,
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();
},
}; };
mainChart = new Chart(ctx, buildChartConfig(chartData, true));
}
Chart.register(zoomSelectionPlugin); const modal = document.getElementById('chartModal');
const expandBtn = document.getElementById('expandLeaderboardChart');
let expandedChart = null;
function clamp(value, min, max) { expandBtn?.addEventListener('click', () => {
return Math.max(min, Math.min(max, value)); if (!leaderboardChartData.labels.length) return;
} modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
function enableDragZoom(chart, resetButton) { if (expandedChart) expandedChart.destroy();
if (!chart || !chart.canvas || !resetButton) { expandedChart = new Chart(
return; document.getElementById('leaderboardChartExpanded'),
} buildChartConfig(leaderboardChartData, false)
const canvas = chart.canvas;
const minDragDistance = 14;
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'); document.querySelectorAll('[data-close-modal]').forEach(el => {
const expandButton = document.getElementById('expandLeaderboardChart'); el.addEventListener('click', () => {
const chartFrame = document.getElementById('leaderboardChartFrame'); modal.style.display = 'none';
const fullscreenButton = document.getElementById('fullscreenLeaderboardChart'); document.body.style.overflow = '';
const modalFrame = document.getElementById('chartModalFrame'); if (expandedChart) { expandedChart.destroy(); expandedChart = null; }
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) => { document.addEventListener('keydown', e => {
element.addEventListener('click', closeChartModal); 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();
}); });
});
document.addEventListener('keydown', (event) => { // Table sort
if (event.key === 'Escape' && chartModal?.classList.contains('is-open')) { const tbl = document.getElementById('leaderboardTable');
closeChartModal(); 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' ? '↑' : '↓') : '↕';
}); });
}
fullscreenButton?.addEventListener('click', async () => { function sortTable(key, sortType, forcedDir) {
if (!modalFrame) { if (!tbl) return;
return; const tbody = tbl.tBodies[0];
} const rows = Array.from(tbody.querySelectorAll('tr[data-player-row]'));
if (document.fullscreenElement) { const curKey = tbl.dataset.sortKey;
await document.exitFullscreen(); const curDir = tbl.dataset.sortDirection || 'desc';
return; let dir = forcedDir;
} if (!dir) {
if (modalFrame.requestFullscreen) { if (curKey === key) dir = curDir === 'desc' ? 'asc' : 'desc';
await modalFrame.requestFullscreen(); else {
} const btn = sortBtns.find(b => b.dataset.sortKey === key);
}); dir = btn?.dataset.initialDirection || 'desc';
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 mult = dir === 'asc' ? 1 : -1;
const multiplier = direction === 'asc' ? 1 : -1; const ci = colMap[key] ?? 0;
const columnIndex = columnIndexMap[sortKey] ?? 0; rows.sort((a, b) => {
const av = a.children[ci]?.dataset.sortValue ?? '';
rows.sort((leftRow, rightRow) => { const bv = b.children[ci]?.dataset.sortValue ?? '';
const leftCell = leftRow.children[columnIndex];
const rightCell = rightRow.children[columnIndex];
const leftValue = leftCell?.dataset.sortValue ?? '';
const rightValue = rightCell?.dataset.sortValue ?? '';
if (sortType === 'number') { if (sortType === 'number') {
const numericResult = (Number(leftValue) - Number(rightValue)) * multiplier; const n = (Number(av) - Number(bv)) * mult;
if (numericResult !== 0) { if (n !== 0) return n;
return numericResult;
}
} else { } else {
const stringResult = leftValue.localeCompare(rightValue) * multiplier; const s = av.localeCompare(bv) * mult;
if (stringResult !== 0) { if (s !== 0) return s;
return stringResult;
} }
} return Number(a.dataset.originalRank) - Number(b.dataset.originalRank);
return Number(leftRow.dataset.originalRank) - Number(rightRow.dataset.originalRank);
});
rows.forEach((row) => tbody.appendChild(row));
leaderboardTable.dataset.sortKey = sortKey;
leaderboardTable.dataset.sortDirection = direction;
updateRankCells();
setActiveSortButton(sortKey, direction);
}
sortButtons.forEach((button) => {
button.addEventListener('click', () => {
sortLeaderboard(button.dataset.sortKey, button.dataset.sortType);
}); });
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);
}
setActiveSortButton('net', 'desc'); sortBtns.forEach(b => {
b.addEventListener('click', () => sortTable(b.dataset.sortKey, b.dataset.sortType));
});
setActiveSort('net', 'desc');
</script> </script>
{% endblock %} {% endblock %}
+250 -163
View File
@@ -1,154 +1,280 @@
{% 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>
<p class="eyebrow">Player profile</p>
<h1>{{ player.player_name }}</h1>
<p class="muted-text">A quick read on lifetime performance, hit rate, and session-by-session movement.</p>
</div>
</section>
<section class="grid three-col profile-summary-grid"> <div class="hero">
<article class="panel stat-card"><span>Total Net</span><strong class="{{ 'positive' if player.total_net_cents > 0 else 'negative' if player.total_net_cents < 0 else 'neutral' }}">{{ player.total_net_cents | money }}</strong></article>
<article class="panel stat-card"><span>Total invested</span><strong>{{ player.total_invested_cents | money }}</strong></article>
<article class="panel stat-card"><span>Cash-outs</span><strong>{{ player.total_cash_out_cents | money }}</strong></article>
<article class="panel stat-card"><span>Current debt</span><strong class="{{ 'negative' if player.current_player_owes_cents > 0 else '' }}">{{ player.current_player_owes_cents | money }}</strong></article>
<article class="panel stat-card"><span>Win %</span><strong>{{ "%.1f"|format(player.win_pct) }}%</strong></article>
<article class="panel stat-card"><span>Sessions</span><strong>{{ player.sessions_played }}</strong></article>
</section>
<section class="grid one-col">
<div class="panel chart-panel">
<div class="panel-header">
<div> <div>
<p class="eyebrow">Trend</p> {% if player_rank %}
<h2>Cumulative profit</h2> <p class="player-rank">Rank #{{ player_rank }}</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>
<div class="player-stat-grid" style="margin-bottom:20px;">
<div class="stat">
<span class="kicker">Total net</span>
<div class="stat__val {{ 'num-pos' if player.total_net_cents > 0 else 'num-neg' if player.total_net_cents < 0 else 'num-muted' }}">
{{ '+' if player.total_net_cents > 0 else '' }}{{ player.total_net_cents | money }}
</div> </div>
</div> </div>
<div class="chart-frame"> <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> </div>
<div class="chart-wrap">
<div class="chart-frame"> <div class="chart-frame">
<canvas id="playerSessionChart"></canvas> <canvas id="playerSessionChart"></canvas>
</div> </div>
</div> </div>
<div class="panel chart-panel">
<div class="panel-header">
<div>
<p class="eyebrow">Breakdown</p>
<h2>Result mix</h2>
</div> </div>
<div class="panel col-side">
<div class="panel__head">
<span class="kicker" style="margin:0;">Breakdown</span>
<h2 class="panel__title">Result mix</h2>
</div> </div>
<div class="chart-frame chart-frame-small"> <div class="chart-wrap">
<canvas id="playerBreakdownChart"></canvas> <div class="result-mix" aria-label="Player result mix">
</div> {% set result_total = player.winning_sessions + player.losing_sessions + player.break_even_sessions %}
</div> <div class="result-mix__bar">
</section> {% if player.winning_sessions > 0 %}
<span class="result-mix__seg result-mix__seg--win" style="flex-grow:{{ player.winning_sessions }}"></span>
<section class="panel">
<div class="panel-header">
<div>
<p class="eyebrow">Breakdown</p>
<h2>Detailed stats</h2>
</div>
</div>
<div class="table-wrap">
<table>
<tbody>
<tr><th>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>
<th>Best Session</th>
<td>
{{ player.best_session_net_cents | money }}
{% if player.best_session_date %}
<span class="muted-text small">on {{ player.best_session_date | pretty_date }}</span>
{% endif %} {% 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>
<!-- Perf vs Cash two-panel split -->
<div class="split" style="margin-bottom:20px;">
<!-- Poker performance -->
<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>
<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);">Total Net</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);"
class="{{ 'num-pos' if player.total_net_cents > 0 else 'num-neg' if player.total_net_cents < 0 else '' }}">
{{ '+' if player.total_net_cents > 0 else '' }}{{ player.total_net_cents | money }}
</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: {
color: textMuted,
usePointStyle: true,
boxWidth: 10,
boxHeight: 10,
padding: 18,
},
},
}, },
scales: { scales: {
x: { x: { ticks: { color: textMuted, maxRotation: 0 }, grid: { color: lineColor } },
ticks: { color: textMuted, maxRotation: 0 }, y: { ticks: { color: textMuted }, grid: { color: lineColor } },
grid: { color: lineColor },
}, },
y: { };
ticks: { color: textMuted },
grid: { color: lineColor },
},
},
};
const cumulativeCtx = document.getElementById('playerCumulativeChart'); const cumulativeCtx = document.getElementById('playerCumulativeChart');
if (cumulativeCtx) { if (cumulativeCtx) {
new Chart(cumulativeCtx, { new Chart(cumulativeCtx, {
type: 'line', type: 'line',
data: { data: {
@@ -157,19 +283,20 @@
label: 'Cumulative Profit', label: 'Cumulative Profit',
data: playerChartData.cumulative_values, data: playerChartData.cumulative_values,
borderColor: playerChartData.color, borderColor: playerChartData.color,
backgroundColor: playerChartData.color, backgroundColor: playerChartData.color + '38',
borderWidth: 2.5, borderWidth: 2.5,
pointRadius: 3, pointRadius: 3,
pointHoverRadius: 5, pointHoverRadius: 5,
tension: 0.22, tension: 0.22,
fill: true,
}] }]
}, },
options: sharedScaleOptions, options: sharedOpts,
}); });
} }
const sessionCtx = document.getElementById('playerSessionChart'); const sessionCtx = document.getElementById('playerSessionChart');
if (sessionCtx) { if (sessionCtx) {
new Chart(sessionCtx, { new Chart(sessionCtx, {
type: 'bar', type: 'bar',
data: { data: {
@@ -179,53 +306,13 @@
data: playerChartData.net_values, data: playerChartData.net_values,
backgroundColor: playerChartData.net_colors, backgroundColor: playerChartData.net_colors,
borderColor: playerChartData.net_colors, borderColor: playerChartData.net_colors,
borderRadius: 8, borderRadius: 6,
borderSkipped: false, borderSkipped: false,
}] }]
}, },
options: sharedScaleOptions, options: sharedOpts,
}); });
} }
</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 %}
+209 -278
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>
<p class="eyebrow">Session detail</p>
<h1>{{ session_label(session) }}</h1>
<p class="muted-text">Current totals are derived from the event log for this date.</p>
</div>
<div style="align-self: center;"> <div class="breadcrumb">
<a href="{{ url_for('sessions') }}">Sessions</a>
<span class="breadcrumb__sep">/</span>
<span>{{ session_label(session) }}</span>
<span style="flex:1"></span>
{% if prev_session %} {% if prev_session %}
<a class="secondary-button" href="{{ url_for('session_detail', session_id=prev_session.session_id) }}"> <a class="btn btn--ghost btn--sm" href="{{ url_for('session_detail', session_id=prev_session.session_id) }}">← Prev</a>
Previous Session
</a>
{% endif %} {% endif %}
{% if next_session %} {% if next_session %}
<a class="secondary-button" href="{{ url_for('session_detail', session_id=next_session.session_id) }}"> <a class="btn btn--ghost btn--sm" href="{{ url_for('session_detail', session_id=next_session.session_id) }}">Next →</a>
Next Session
</a>
{% endif %} {% 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 class="recon-metric-grid">
<div class="recon-cell">
<span class="kicker" style="font-size:10px;">Cash in</span>
<div class="recon-val recon-val--num">{{ session.total_cash_in_cents | money }}</div>
</div>
<div class="recon-cell">
<span class="kicker" style="font-size:10px;">Gross payout</span>
<div class="recon-val recon-val--num">{{ session.total_gross_payout_cents | money }}</div>
</div>
<div class="recon-cell">
<span class="kicker" style="font-size:10px;">Cash paid out</span>
<div class="recon-val recon-val--pos">{{ session.total_paid_cents | money }}</div>
</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>
</section> </div>
<div class="panel">
<div class="recon-card-head">
<span class="kicker kicker--dot kicker--dot-warn">Open items</span>
</div>
<div class="recon-metric-grid">
<div class="recon-cell">
<span class="kicker" style="font-size:10px;">House owes</span>
<div class="recon-val {{ 'recon-val--neg' if session.total_current_due_to_player_cents > 0 else 'recon-val--pos' }}">
{{ session.total_current_due_to_player_cents | money }}
</div>
</div>
<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>
<section class="grid payout-summary-grid"> <!-- Player totals table -->
<article class="panel stat-card"> <div class="panel" style="margin-bottom:20px;">
<span>Gross payout</span> <div class="panel__head">
<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> <div>
<p class="eyebrow">Results</p> <span class="kicker" style="margin:0;">Results</span>
<h2>Player totals</h2> <h2 class="panel__title">Player totals</h2>
</div> </div>
<div class="segmented" id="viewToggle">
<div class="table-toolbar"> <button class="seg is-on" data-view="compact">Compact</button>
<button type="button" class="secondary-button table-view-toggle is-active" data-view="compact"> <button class="seg" data-view="full">Full ledger</button>
Compact
</button>
<button type="button" class="secondary-button table-view-toggle" data-view="full">
Full details
</button>
</div> </div>
</div> </div>
<div class="tbl-scroll">
<div class="table-scroll table-scroll-open"> <table class="tbl tbl--session" id="sessionTable">
<table class="data-table session-results-table">
<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>
</div> <div class="chart-wrap">
<div class="chart-frame chart-frame-compact"> <div class="session-net-bars" id="sessionBreakdownChart" aria-label="Net by player"></div>
<canvas id="sessionBreakdownChart"></canvas>
</div> </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="snapshot-cell">
<span class="kicker" style="font-size:10px;">Biggest winner</span>
<div class="snapshot-val" style="color:var(--pos)">
{% 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 class="snapshot-cell">
<span class="kicker" style="font-size:10px;">Biggest loser</span>
<div class="snapshot-val" style="color:var(--neg)">
{% if losers %}{{ (losers | sort(attribute='net_cents') | first).net_cents | money }}{% else %}—{% endif %}
</div>
{% if losers %}<div style="font-size:12px;color:var(--muted);margin-top:4px;">{{ losers | sort(attribute='net_cents') | first | attr('player_name') }}</div>{% endif %}
</div>
<div class="snapshot-cell">
<span class="kicker" style="font-size:10px;">Total cash-out</span>
<div class="snapshot-val">{{ session.total_cash_out_cents | money }}</div>
</div>
<div class="snapshot-cell">
<span class="kicker" style="font-size:10px;">Open items</span>
<div class="snapshot-val {{ 'recon-val--neg' if (session.total_current_due_to_player_cents + session.total_current_due_to_house_cents) > 0 else '' }}">
{{ (session.total_current_due_to_player_cents + session.total_current_due_to_house_cents) | money }}
</div>
</div>
</div>
</div>
</div>
<div class="detail-grid"> <!-- Audit feed -->
<div class="detail-row"> <div class="panel" style="margin-top:20px;">
<span>Players</span> <div class="panel__head">
<strong>{{ session.entries|length }}</strong>
</div>
<div class="detail-row">
<span>Biggest winner</span>
<strong>
{% if winners %}
{{ winners | sort(attribute='net_cents', reverse=True) | first | attr('player_name') }}
{% else %}
{% endif %}
</strong>
</div>
<div class="detail-row">
<span>Best result</span>
<strong class="positive">
{% if winners %}
{{ (winners | sort(attribute='net_cents', reverse=True) | first).net_cents | money }}
{% else %}
$0.00
{% endif %}
</strong>
</div>
<div class="detail-row">
<span>Biggest loser</span>
<strong>
{% if losers %}
{{ losers | sort(attribute='net_cents') | first | attr('player_name') }}
{% 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>
</section>
<section class="panel">
<div class="panel-header">
<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,
borderRadius: 8,
borderSkipped: false,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: {
color: textMuted,
usePointStyle: true,
boxWidth: 10,
boxHeight: 10,
padding: 18,
},
},
},
scales: {
x: {
ticks: { color: textMuted, maxRotation: 0 },
grid: { color: lineColor },
},
y: {
ticks: { color: textMuted },
grid: { color: lineColor },
},
},
},
}); });
} const escapeHtml = value => String(value).replace(/[&<>"']/g, char => ({
</script> '&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[char]));
<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.add('compact'); sessionTable.classList.remove('is-full');
} }
}); });
}); });
}
</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()