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
- `session_open` / `session_close` to mark whether a game night is still live
Accounting in the app keeps poker results separate from banker cashflow:
- poker investment is `buyin + front + rollover_in`
- poker net is `cashout - poker investment`
- real cash in is `buyin + front_collected`
- real cash out is `paid`
- `rollover_out` settles the source session without counting as cash out
- `rollover_in` enters play in the destination session without counting as cash in
- `front_writeoff` resolves a receivable without counting as cash in
It is still just a small side project, but I wanted the event model to stay clean enough that the numbers are easy to trust and the history is easy to read back through.
## CSV format
+139 -34
View File
@@ -3,7 +3,6 @@ from __future__ import annotations
import csv
import os
import shutil
from datetime import datetime, timezone
from pathlib import Path
@@ -32,12 +31,23 @@ from stats import (
session_sort_key,
unique_player_names,
)
from storage import append_event, ensure_data_file, load_events
from storage import CSV_HEADERS, append_event, ensure_data_file, load_events
BASE_DIR = Path(__file__).resolve().parent
DATA_PATH = BASE_DIR / "data" / "entries.csv"
ENV_PATH = BASE_DIR / ".env"
ELIGIBLE_MIN_SESSIONS = 3
ADMIN_EVENT_TYPES = [
("buyin", "Buy-in", "var(--accent)"),
("front", "Fronted", "var(--warn)"),
("rollover_in", "Rollover-in", "var(--rolled)"),
("cashout", "Cash-out", "var(--rank-2)"),
("paid", "Paid-out", "var(--pos)"),
("rollover_out", "Rollover-out", "var(--rolled)"),
("note", "Note", "var(--muted)"),
]
def load_local_env(env_path: Path) -> None:
if not env_path.exists():
@@ -105,18 +115,20 @@ def leaderboard() -> str:
all_sessions = build_session_summaries(events)
ordered_sessions = sorted(all_sessions, key=session_sort_key)
session_ids = [session.session_id for session in ordered_sessions]
session_ids = [s.session_id for s in ordered_sessions]
selected_session_id = request.args.get("through_session", "").strip()
mode = request.args.get("mode", "eligible").strip()
if mode not in ("eligible", "all", "recent"):
mode = "eligible"
label = ""
cutoff_index = len(session_ids) - 1
if session_ids:
if selected_session_id in session_ids:
cutoff_index = session_ids.index(selected_session_id)
label = session_label(ordered_sessions[cutoff_index])
else:
cutoff_index = len(session_ids) - 1
selected_session = ordered_sessions[cutoff_index]
label = session_label(selected_session)
label = session_label(ordered_sessions[cutoff_index])
filtered_sessions = ordered_sessions[: cutoff_index + 1]
previous_sessions = ordered_sessions[:cutoff_index]
@@ -124,17 +136,48 @@ def leaderboard() -> str:
filtered_sessions = []
previous_sessions = []
# Build full board (all filtered sessions) for rank changes and counts
board = build_leaderboard(filtered_sessions)
previous_board = build_leaderboard(previous_sessions)
board = apply_rank_changes(board, previous_board)
# Segmented-control counts
eligible_count = sum(1 for p in board if p.sessions_played >= ELIGIBLE_MIN_SESSIONS)
all_count = len(board)
recent_sessions_slice = filtered_sessions[-5:]
recent_count = len({
entry.player_name
for s in recent_sessions_slice
for entry in s.entries
})
# Mode-specific boards
if mode == "recent":
mode_board = build_leaderboard(recent_sessions_slice)
main_board = mode_board
provisional_board = []
elif mode == "eligible":
main_board = [p for p in board if p.sessions_played >= ELIGIBLE_MIN_SESSIONS]
provisional_board = [p for p in board if p.sessions_played < ELIGIBLE_MIN_SESSIONS]
else:
main_board = board
provisional_board = []
chart_data = cumulative_profit_series(filtered_sessions)
cash_paid_out_cents = sum(session.total_paid_out_cents for session in all_sessions)
return render_template(
"leaderboard.html",
leaderboard=board,
main_board=main_board,
provisional_board=provisional_board,
mode=mode,
eligible_count=eligible_count,
all_count=all_count,
recent_count=recent_count,
eligible_min_sessions=ELIGIBLE_MIN_SESSIONS,
session_count=len(filtered_sessions),
total_session_count=len(all_sessions),
cash_paid_out_cents=cash_paid_out_cents,
chart_data=chart_data,
available_sessions=all_sessions,
selected_session_id=selected_session_id,
@@ -190,6 +233,15 @@ def session_detail(session_id: str) -> str:
return redirect(url_for("sessions"))
target_session = sessions[target_index]
chronological_sessions = sorted(sessions, key=session_sort_key)
session_number = next(
(
index
for index, session in enumerate(chronological_sessions, start=1)
if session.session_id == session_id
),
1,
)
prev_session = (
sessions[target_index + 1] if target_index < len(sessions) - 1 else None
)
@@ -200,6 +252,7 @@ def session_detail(session_id: str) -> str:
session=target_session,
next_session=next_session,
prev_session=prev_session,
session_number=session_number,
raw_events=session_events(events, session_id),
chart_data=session_breakdown_series(target_session),
session_label=session_label,
@@ -218,10 +271,19 @@ def player_detail(player_name: str) -> str:
flash("That player was not found.", "error")
return redirect(url_for("leaderboard"))
player_rank = next(
(
index
for index, ranked_player in enumerate(board, start=1)
if ranked_player.player_name == player_name
),
None,
)
chart_data = player_session_series(sessions, player_name)
return render_template(
"player_detail.html",
player=player_stats,
player_rank=player_rank,
chart_data=chart_data,
)
@@ -290,6 +352,7 @@ def admin_session_state() -> str:
@app.get("/admin/export")
@app.get("/admin/export.csv")
def admin_export_csv():
if not is_admin():
flash("Admin login required.", "error")
@@ -333,37 +396,39 @@ def admin_import_csv():
flash("CSV file is empty.", "error")
return redirect(url_for("admin_dashboard"))
try:
current_header = next(
csv.reader(open(DATA_PATH, "r", encoding="utf-8", newline=""))
)
except Exception:
flash("Could not read the current audit CSV header.", "error")
return redirect(url_for("admin_dashboard"))
try:
uploaded_header = next(csv.reader(uploaded_lines))
except Exception:
flash("Could not read the uploaded CSV header.", "error")
return redirect(url_for("admin_dashboard"))
if uploaded_header != current_header:
if uploaded_header != CSV_HEADERS:
flash("CSV header does not match the current audit format.", "error")
return redirect(url_for("admin_dashboard"))
data_dir = os.path.dirname(DATA_PATH)
backup_name = (
f"entries_backup_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.csv"
)
backup_path = os.path.join(data_dir, backup_name)
try:
shutil.copy2(DATA_PATH, backup_path)
existing_ids = {event["id"] for event in load_events(DATA_PATH)}
reader = csv.DictReader(uploaded_lines)
new_rows = []
skipped_count = 0
for row in reader:
event_id = row.get("id", "")
if not event_id or event_id in existing_ids:
skipped_count += 1
continue
with open(DATA_PATH, "w", encoding="utf-8", newline="") as handle:
handle.write(uploaded_text)
new_rows.append({header: row.get(header, "") for header in CSV_HEADERS})
existing_ids.add(event_id)
flash(f"CSV imported successfully. Backup created: {backup_name}", "success")
if new_rows:
with open(DATA_PATH, "a", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=CSV_HEADERS)
writer.writerows(new_rows)
flash(
f"Imported {len(new_rows)} new events, skipped {skipped_count} duplicates.",
"success",
)
except Exception:
flash("Import failed. Existing CSV was not updated.", "error")
@@ -582,13 +647,41 @@ def admin_dashboard():
)
flash("Event added.", "success")
return redirect(url_for("admin_dashboard"))
return redirect(
url_for(
"admin_dashboard",
session_id=session_id,
player_name=player_name,
event_type=event_type,
)
)
events = load_events(DATA_PATH)
sessions = build_session_summaries(events)
open_sessions = [session for session in sessions if session.status == "open"]
live_session = open_sessions[0] if open_sessions else None
live_session_no = ""
live_unresolved_count = 0
if live_session is not None:
live_session_no = f"#{len(sessions):03d}"
live_unresolved_count = sum(
1
for entry in live_session.entries
if entry.current_due_cents > 0 or entry.player_owes_cents > 0
)
recent_sessions = sessions[:8]
recent_events = list(reversed(events[-20:]))
event_type_colors = {value: color for value, _label, color in ADMIN_EVENT_TYPES}
event_type_labels = {value: label for value, label, _color in ADMIN_EVENT_TYPES}
selected_event_type = request.args.get("event_type", "buyin").strip()
if selected_event_type not in event_type_colors:
selected_event_type = "buyin"
append_form = {
"session_id": request.args.get("session_id", "").strip(),
"player_name": request.args.get("player_name", "").strip(),
"event_type": selected_event_type,
}
debt_entries = [
{"session": session, "entry": entry}
for session in sessions
@@ -598,12 +691,12 @@ def admin_dashboard():
admin_totals = {
"cash_in_cents": sum(session.total_cash_in_cents for session in sessions),
"paid_out_cents": sum(session.total_paid_out_cents for session in sessions),
"still_owed_cents": sum(
session.total_current_due_cents for session in sessions
"cash_out_cents": sum(session.total_paid_out_cents for session in sessions),
"house_owes_players_cents": sum(
session.total_current_due_to_player_cents for session in sessions
),
"players_owe_cents": sum(
session.total_player_owes_cents for session in sessions
"players_owe_house_cents": sum(
session.total_current_due_to_house_cents for session in sessions
),
"collected_cents": sum(
session.total_front_collected_cents for session in sessions
@@ -611,10 +704,14 @@ def admin_dashboard():
"written_off_cents": sum(
session.total_front_writeoff_cents for session in sessions
),
"open_balance_cents": sum(
session.total_open_balance_cents for session in sessions
"net_book_position_cents": sum(
session.total_net_book_position_cents for session in sessions
),
}
admin_totals["paid_out_cents"] = admin_totals["cash_out_cents"]
admin_totals["still_owed_cents"] = admin_totals["house_owes_players_cents"]
admin_totals["players_owe_cents"] = admin_totals["players_owe_house_cents"]
admin_totals["open_balance_cents"] = admin_totals["net_book_position_cents"]
return render_template(
"admin_dashboard.html",
@@ -622,7 +719,15 @@ def admin_dashboard():
recent_sessions=recent_sessions,
recent_events=recent_events,
debt_entries=debt_entries,
live_session=live_session,
live_session_no=live_session_no,
live_unresolved_count=live_unresolved_count,
player_names=unique_player_names(events),
append_form=append_form,
event_types=ADMIN_EVENT_TYPES,
event_type_colors=event_type_colors,
event_type_labels=event_type_labels,
admin_today=datetime.now().date().isoformat(),
session_label=session_label,
admin_totals=admin_totals,
)
+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 typing import Any
from markupsafe import Markup
from storage import EventRow
PLAYER_PALETTE = [
"#9b8cf0", # --line-1 violet
"#6fc093", # --line-2 green
"#e0b15c", # --line-3 gold
"#cf6f86", # --line-4 pink
"#8f93c2", # --line-5 lavender
"#7cb9e0",
"#f4a261",
"#2a9d8f",
"#8d99ae",
"#e76f51",
"#7c6cf2",
"#a78bb0",
"#5cb8a0",
"#e58f3a",
"#84cc16",
"#f59e0b",
"#10b981",
"#ef4444",
"#06b6d4",
"#a855f7",
"#eab308",
]
BREAK_EVEN_TOLERANCE_CENTS = 100
@dataclass
class SessionEntry:
@@ -41,76 +45,113 @@ class SessionEntry:
@property
def invested_cents(self) -> int:
# Poker performance: everything that entered play, including house fronts
# and prior unpaid winnings rolled into this session.
return self.buy_in_cents + self.front_cents + self.rollover_in_cents
@property
def net_cents(self) -> int:
return self.cash_out_cents - self.invested_cents
@property
def payout_due_cents(self) -> int:
return max(self.cash_out_cents - self.front_cents, 0)
@property
def payout_remaining_cents(self) -> int:
return max(self.payout_due_cents - self.paid_cents - self.rollover_out_cents, 0)
@property
def gross_payout_cents(self) -> int:
# Settlement: cashout is a gross chip count/result, not proof that cash
# was paid. Fronts are recovered from the cashout before the player has
# a claim against the house.
return max(self.cash_out_cents - self.front_cents, 0)
@property
def settled_cents(self) -> int:
return self.paid_cents + self.rollover_out_cents
@property
def current_due_cents(self) -> int:
return max(self.gross_payout_cents - self.settled_cents, 0)
@property
def player_owes_cents(self) -> int:
return max(self.raw_player_owes_cents - self.front_resolved_cents, 0)
@property
def raw_player_owes_cents(self) -> int:
return self.front_shortfall_cents + self.overpaid_front_cents
@property
def front_shortfall_cents(self) -> int:
def player_owes_gross_cents(self) -> int:
return max(self.front_cents - self.cash_out_cents, 0)
@property
def settled_to_player_cents(self) -> int:
# Rollover-out resolves the source session's payable even though it is
# not real cash out. The destination session records it as rollover-in.
return self.paid_cents + self.rollover_out_cents
@property
def current_due_to_player_cents(self) -> int:
return max(self.gross_payout_cents - self.settled_to_player_cents, 0)
@property
def settled_to_house_cents(self) -> int:
# Front collections are real cash in. Writeoffs are not cash, but they
# do resolve the receivable.
return self.front_collected_cents + self.front_writeoff_cents
@property
def current_due_to_house_cents(self) -> int:
return max(self.player_owes_gross_cents - self.settled_to_house_cents, 0)
@property
def real_cash_in_cents(self) -> int:
return self.buy_in_cents + self.front_collected_cents
@property
def real_cash_out_cents(self) -> int:
return self.paid_cents
@property
def payout_due_cents(self) -> int:
return self.gross_payout_cents
@property
def payout_remaining_cents(self) -> int:
return self.current_due_to_player_cents
@property
def settled_cents(self) -> int:
return self.settled_to_player_cents
@property
def current_due_cents(self) -> int:
return self.current_due_to_player_cents
@property
def player_owes_cents(self) -> int:
return self.current_due_to_house_cents
@property
def raw_player_owes_cents(self) -> int:
return self.player_owes_gross_cents
@property
def front_shortfall_cents(self) -> int:
return self.player_owes_gross_cents
@property
def overpaid_front_cents(self) -> int:
return max(self.settled_cents - self.gross_payout_cents, 0)
return 0
@property
def front_writeoff_applied_cents(self) -> int:
return min(
self.front_writeoff_cents,
max(self.raw_player_owes_cents - self.front_collected_applied_cents, 0),
max(self.player_owes_gross_cents - self.front_collected_applied_cents, 0),
)
@property
def front_collected_applied_cents(self) -> int:
return min(self.front_collected_cents, self.raw_player_owes_cents)
return min(self.front_collected_cents, self.player_owes_gross_cents)
@property
def front_resolved_cents(self) -> int:
return self.front_collected_applied_cents + self.front_writeoff_applied_cents
return min(self.settled_to_house_cents, self.player_owes_gross_cents)
@property
def payout_status(self) -> str:
if self.player_owes_cents > 0:
if self.current_due_to_house_cents > 0:
return "owes"
if self.front_writeoff_applied_cents > 0:
if self.front_writeoff_cents > 0:
return "written_off"
if self.front_collected_applied_cents > 0:
if self.front_collected_cents > 0:
return "collected"
if self.gross_payout_cents <= 0:
return "none"
if self.current_due_cents <= 0:
if self.current_due_to_player_cents <= 0:
return "paid"
if self.settled_cents <= 0:
if self.settled_to_player_cents <= 0:
return "unpaid"
return "partial"
@@ -149,11 +190,11 @@ class SessionSummary:
@property
def total_payout_due_cents(self) -> int:
return sum(entry.payout_due_cents for entry in self.entries)
return self.total_gross_payout_cents
@property
def total_remaining_cents(self) -> int:
return sum(entry.payout_remaining_cents for entry in self.entries)
return self.total_current_due_to_player_cents
@property
def total_net_cents(self) -> int:
@@ -172,32 +213,63 @@ class SessionSummary:
return sum(entry.rollover_out_cents for entry in self.entries)
@property
def total_current_due_cents(self) -> int:
return sum(entry.current_due_cents for entry in self.entries)
def total_settled_to_player_cents(self) -> int:
return sum(entry.settled_to_player_cents for entry in self.entries)
@property
def total_player_owes_cents(self) -> int:
return sum(entry.player_owes_cents for entry in self.entries)
def total_current_due_to_player_cents(self) -> int:
return sum(entry.current_due_to_player_cents for entry in self.entries)
@property
def total_player_owes_gross_cents(self) -> int:
return sum(entry.player_owes_gross_cents for entry in self.entries)
@property
def total_settled_to_house_cents(self) -> int:
return sum(entry.settled_to_house_cents for entry in self.entries)
@property
def total_current_due_to_house_cents(self) -> int:
return sum(entry.current_due_to_house_cents for entry in self.entries)
@property
def total_front_writeoff_cents(self) -> int:
return sum(entry.front_writeoff_applied_cents for entry in self.entries)
return sum(entry.front_writeoff_cents for entry in self.entries)
@property
def total_front_collected_cents(self) -> int:
return sum(entry.front_collected_applied_cents for entry in self.entries)
return sum(entry.front_collected_cents for entry in self.entries)
@property
def total_cash_in_cents(self) -> int:
return sum(entry.buy_in_cents for entry in self.entries)
return sum(entry.real_cash_in_cents for entry in self.entries)
@property
def total_paid_out_cents(self) -> int:
return sum(entry.paid_cents for entry in self.entries)
return sum(entry.real_cash_out_cents for entry in self.entries)
@property
def total_current_due_cents(self) -> int:
return self.total_current_due_to_player_cents
@property
def total_player_owes_cents(self) -> int:
return self.total_current_due_to_house_cents
@property
def total_open_balance_cents(self) -> int:
return self.total_current_due_cents - self.total_player_owes_cents
return self.total_current_due_to_player_cents - self.total_current_due_to_house_cents
@property
def total_net_book_position_cents(self) -> int:
# Banker view: cash currently held, plus collectible receivables, minus
# unpaid player claims. Rollover-outs reduce payables but are not cash.
return (
self.total_cash_in_cents
- self.total_paid_out_cents
+ self.total_current_due_to_house_cents
- self.total_current_due_to_player_cents
)
@dataclass
@@ -251,9 +323,9 @@ def apply_rank_changes(
return current_board
def cents_to_dollars(cents: int) -> str:
def cents_to_dollars(cents: int) -> Markup:
value = cents / 100
return f"${value:,.2f}"
return Markup(f'<span class="currency-symbol">$</span>{value:,.2f}')
def session_sort_key(session: SessionSummary) -> tuple[str, int, str, str]:
@@ -314,12 +386,22 @@ def color_for_name(name: str, names: list[str]) -> str:
return PLAYER_PALETTE[index % len(PLAYER_PALETTE)]
def net_result_bucket(value_cents: int) -> str:
if value_cents > BREAK_EVEN_TOLERANCE_CENTS:
return "win"
if value_cents < -BREAK_EVEN_TOLERANCE_CENTS:
return "loss"
return "even"
def net_tone(value_cents: int) -> str:
if value_cents > 0:
return "#22c55e"
if value_cents < 0:
return "#ef4444"
return "#f59e0b"
bucket = net_result_bucket(value_cents)
if bucket == "win":
return "#6fc093" # --pos
if bucket == "loss":
return "#e0758a" # --neg
return "#84828e" # --muted-2
def build_session_summaries(events: list[EventRow]) -> list[SessionSummary]:
@@ -426,6 +508,7 @@ def summarize_player_runs(entries: list[SessionEntry]) -> dict[str, int | str |
for entry in ordered_entries:
net = entry.net_cents
bucket = net_result_bucket(net)
if best_entry is None or net > best_entry.net_cents:
best_entry = entry
@@ -433,10 +516,10 @@ def summarize_player_runs(entries: list[SessionEntry]) -> dict[str, int | str |
if worst_entry is None or net < worst_entry.net_cents:
worst_entry = entry
if net > 0:
if bucket == "win":
current_run_wins += 1
current_run_losses = 0
elif net < 0:
elif bucket == "loss":
current_run_losses += 1
current_run_wins = 0
else:
@@ -450,13 +533,13 @@ def summarize_player_runs(entries: list[SessionEntry]) -> dict[str, int | str |
current_loss_streak = 0
for entry in reversed(ordered_entries):
net = entry.net_cents
bucket = net_result_bucket(entry.net_cents)
if net > 0:
if bucket == "win":
if current_loss_streak > 0:
break
current_win_streak += 1
elif net < 0:
elif bucket == "loss":
if current_win_streak > 0:
break
current_loss_streak += 1
@@ -485,8 +568,8 @@ def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]:
for player_name, entries in player_entries.items():
nets = [entry.net_cents for entry in entries]
run_summary = summarize_player_runs(entries)
wins = [value for value in nets if value > 0]
losses = [value for value in nets if value < 0]
wins = [value for value in nets if net_result_bucket(value) == "win"]
losses = [value for value in nets if net_result_bucket(value) == "loss"]
sessions_played = len(entries)
winning_sessions = len(wins)
@@ -502,12 +585,8 @@ def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]:
total_buy_in = sum(entry.buy_in_cents for entry in entries)
total_front = sum(entry.front_cents for entry in entries)
total_front_collected = sum(
entry.front_collected_applied_cents for entry in entries
)
total_front_writeoff = sum(
entry.front_writeoff_applied_cents for entry in entries
)
total_front_collected = sum(entry.front_collected_cents for entry in entries)
total_front_writeoff = sum(entry.front_writeoff_cents for entry in entries)
current_player_owes = sum(entry.player_owes_cents for entry in entries)
total_rollover_in = sum(entry.rollover_in_cents for entry in entries)
total_invested = sum(entry.invested_cents for entry in entries)
+255 -269
View File
@@ -1,304 +1,195 @@
{% extends "base.html" %}
{% block title %}Admin - Poker Portal{% endblock %}
{% block title %}Admin · boker.club{% endblock %}
{% block page_class %}page--admin{% endblock %}
{% block content %}
<section class="hero-card compact">
<div>
<p class="eyebrow">Admin</p>
<h1>Ledger controls</h1>
<p class="muted-text">Append new buy-ins, cash-outs, corrections, and note-only events to the audit trail.</p>
<div class="hero" style="padding-bottom:12px;">
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap;">
<p class="hero__kicker" style="margin:0;">Admin</p>
{% if open_sessions %}
<span class="live-badge">{{ open_sessions|length }} session{{ 's' if open_sessions|length != 1 else '' }} live</span>
{% endif %}
</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">
<form class="panel form-card" method="post">
<p class="eyebrow">New event</p>
<h2>Add to the ledger</h2>
<!-- Banker cash/book position. Cash tiles are real money only; rollovers only
affect receivables/payables through the session settlement formulas. -->
<div class="cash-strip">
<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>
<span>Open session</span>
<select name="session_id" required>
<!-- Two-column workspace -->
<div class="admin-workspace">
<!-- 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>
{% for session in open_sessions %}
<option value="{{ session.session_id }}">
{{ session_label(session) }}
{% for s in open_sessions %}
<option value="{{ s.session_id }}" {{ 'selected' if append_form.session_id == s.session_id else '' }}>
{{ session_label(s) }}{{ ' (live)' if s.status == 'open' else '' }}
</option>
{% endfor %}
</select>
</label>
<label>
<span>Player name</span>
<input list="player_names" type="text" name="player_name" placeholder="Peter Venkman" required>
<datalist id="player_names">
{% for player_name in player_names %}
<option value="{{ player_name }}"></option>
{% endfor %}
<label class="field">
<span class="field__label">Player</span>
<input class="control" list="player_names_list" type="text" name="player_name" placeholder="Player name" value="{{ append_form.player_name }}" required>
<datalist id="player_names_list">
{% for name in player_names %}<option value="{{ name }}"></option>{% endfor %}
</datalist>
</label>
<label>
<span>Event type</span>
<select name="event_type" required>
<option value="buyin">Buy-in</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>
<label class="field">
<span class="field__label">Event type</span>
<select class="control" name="event_type" required id="eventTypeSelect">
{% for value, label, color in event_types %}
<option value="{{ value }}" data-color="{{ color }}" data-label="{{ label }}" {{ 'selected' if append_form.event_type == value else '' }}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label>
<span>Amount collected</span>
<input type="number" step="0.01" name="amount" placeholder="5.00" required>
<label class="field">
<span class="field__label">Amount</span>
<span class="control control--money">
<span class="control__prefix">$</span>
<input name="amount" inputmode="decimal" placeholder="0" autofocus>
</span>
</label>
<label>
<span>Note</span>
<textarea name="note" rows="3" placeholder="E-transfer received, cash collected, etc."></textarea>
</div>
<label class="field append__note">
<span class="field__label">Note <span class="field__opt">(optional)</span></span>
<input class="control" name="note" placeholder="e.g. fronted buy-in, settle next week">
</label>
<div class="button-row">
<button class="primary-button" type="submit">Mark collected</button>
<div class="append__footer">
<button type="submit" class="btn btn--primary">Append event</button>
<span class="append__preview">Will record as
<span class="evtag-pill" id="eventTypePreview" style="color:{{ event_type_colors[append_form.event_type] }}">{{ event_type_labels[append_form.event_type] | upper }}</span>
</span>
</div>
</div>
</form>
<form class="form-card compact-form" method="post" action="{{ url_for('admin_write_off_front') }}">
<h3>Write off debt</h3>
</div><!-- /.col-main -->
<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 %}
</select>
</label>
<!-- RIGHT: session controls + CSV + raw events feed -->
<div class="col-side">
<label>
<span>Amount to write off</span>
<input type="number" step="0.01" name="amount" placeholder="5.00" required>
</label>
<div class="panel panel--pad">
<h2 class="panel__title panel__title--mb">Session controls</h2>
<label>
<span>Note</span>
<textarea name="note" rows="3" placeholder="Won't be collected."></textarea>
</label>
<div class="button-row">
<button class="secondary-button" type="submit">Write off front</button>
{% if live_session %}
<div class="livecard">
<div class="livecard__head">
<span class="dot dot--live"></span>
<span class="livecard__name">{{ session_label(live_session) }}</span>
<span class="livecard__no">{{ live_session_no }}</span>
</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>
</div>
{% else %}
<p class="muted-text">No outstanding front debt.</p>
{% endif %}
</section>
<section class="grid admin-cash-grid">
<article class="panel stat-card">
<span>Cash in</span>
<strong>{{ admin_totals.cash_in_cents | money }}</strong>
</article>
<form method="post" action="{{ url_for('admin_open_session') }}">
<input type="hidden" name="session_date" value="{{ admin_today }}">
<button class="btn-newsession {{ 'btn-newsession--primary' if not live_session else '' }}" type="submit">+ Open new session</button>
</form>
</div>
<article class="panel stat-card">
<span>Paid out</span>
<strong>{{ admin_totals.paid_out_cents | money }}</strong>
</article>
<div class="panel panel--pad">
<h2 class="panel__title" style="margin:0 0 4px">Import / export</h2>
<p class="csv__desc">Back up or migrate the raw event ledger.</p>
<form method="post" action="{{ url_for('admin_import_csv') }}" enctype="multipart/form-data" class="import-form">
<div class="csv__btns">
<a class="csv-btn-ghost" href="{{ url_for('admin_export_csv') }}">&darr; Export CSV</a>
<label class="csv-btn-ghost" for="csvTopImport">&uarr; Import CSV</label>
</div>
<input id="csvTopImport" type="file" accept=".csv,text/csv" name="csv_file" hidden onchange="this.form.submit()">
<label class="csv__drop" id="csvDrop" for="csvTopImport">Drop ledger.csv to import</label>
</form>
</div>
<article class="panel stat-card">
<span>Still owed</span>
<strong class="{{ 'negative' if admin_totals.still_owed_cents > 0 else 'positive' }}">
{{ admin_totals.still_owed_cents | money }}
</strong>
</article>
</div><!-- /.col-side -->
<article class="panel stat-card">
<span>Players owe</span>
<strong class="{{ 'negative' if admin_totals.players_owe_cents > 0 else '' }}">
{{ admin_totals.players_owe_cents | money }}
</strong>
</article>
</div><!-- /.admin-workspace -->
<article class="panel stat-card">
<span>Written off</span>
<strong class="{{ 'negative' if admin_totals.written_off_cents > 0 else '' }}">
{{ admin_totals.written_off_cents | money }}
</strong>
</article>
<article class="panel stat-card">
<span>Open balance</span>
<strong class="{% if admin_totals.open_balance_cents > 0 %}negative{% elif admin_totals.open_balance_cents < 0 %}positive{% endif %}">
{{ admin_totals.open_balance_cents | money }}
</strong>
</article>
</section>
<section class="panel">
<div class="panel-header">
<div class="admin-bottom-grid">
<div class="panel">
<div class="panel__head">
<div>
<p class="eyebrow">Recent sessions</p>
<h2>Current totals</h2>
<span class="kicker" style="margin:0;">Recent sessions</span>
<h2 class="panel__title">Current totals</h2>
</div>
</div>
<div class="table-wrap small-table">
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Session</th>
<th>Players</th>
<th>Cash in</th>
<th>Paid out</th>
<th>Still owed</th>
<th>Players owe</th>
<th>Collected</th>
<th>Written off</th>
<th>Open balance</th>
<th>Cash out</th>
<th>House owes</th>
<th>Players owe house</th>
<th>Book position</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% for session in recent_sessions %}
{% for s in recent_sessions %}
<tr>
<td>
<a href="{{ url_for('session_detail', session_id=session.session_id) }}">
{{ session_label(session) }}
</a>
</td>
<td>{{ session.entries|length }}</td>
<td>{{ session.total_cash_in_cents | money }}</td>
<td>{{ session.total_paid_out_cents | money }}</td>
<td class="{{ 'negative' if session.total_current_due_cents > 0 else 'positive' }}">
{{ session.total_current_due_cents | money }}
</td>
<td class="{{ 'negative' if session.total_player_owes_cents > 0 else '' }}">
{{ session.total_player_owes_cents | money }}
</td>
<td class="{{ 'positive' if session.total_front_collected_cents > 0 else '' }}">
{{ session.total_front_collected_cents | money }}
</td>
<td class="{{ 'negative' if session.total_front_writeoff_cents > 0 else '' }}">
{{ session.total_front_writeoff_cents | money }}
</td>
<td class="{% if session.total_open_balance_cents > 0 %}negative{% elif session.total_open_balance_cents < 0 %}positive{% endif %}">
{{ session.total_open_balance_cents | money }}
</td>
<td>
<span class="status-badge status-{{ session.status }}">
{{ session.status }}
</span>
</td>
<td><a href="{{ url_for('session_detail', session_id=s.session_id) }}">{{ session_label(s) }}</a></td>
<td style="font-family:var(--font-mono);color:var(--num)">{{ s.entries|length }}</td>
<td style="font-family:var(--font-mono);color:var(--num)">{{ s.total_cash_in_cents | money }}</td>
<td style="font-family:var(--font-mono);color:var(--pos)">{{ s.total_paid_out_cents | money }}</td>
<td class="{{ 'negative' if s.total_current_due_to_player_cents > 0 else '' }}" style="font-family:var(--font-mono)">{{ s.total_current_due_to_player_cents | money }}</td>
<td class="{{ 'negative' if s.total_current_due_to_house_cents > 0 else '' }}" style="font-family:var(--font-mono)">{{ s.total_current_due_to_house_cents | money }}</td>
<td class="{% if s.total_net_book_position_cents > 0 %}positive{% elif s.total_net_book_position_cents < 0 %}negative{% endif %}" style="font-family:var(--font-mono)">{{ s.total_net_book_position_cents | money }}</td>
<td><span class="status-badge status-{{ s.status }}">{{ s.status }}</span></td>
<td>
<form method="post" action="{{ url_for('admin_session_state') }}" class="inline-action-form">
<input type="hidden" name="session_id" value="{{ session.session_id }}">
{% if session.status == "open" %}
<input type="hidden" name="session_id" value="{{ s.session_id }}">
{% if s.status == "open" %}
<button class="table-action-button" type="submit" name="state" value="closed">Close</button>
{% else %}
<button class="table-action-button" type="submit" name="state" value="open">Reopen</button>
@@ -307,36 +198,131 @@
</td>
</tr>
{% else %}
<tr>
<td colspan="11">No sessions yet.</td>
</tr>
<tr><td colspan="9">No sessions yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
</div>
<section class="panel">
<div class="panel-header">
<div class="panel">
<div class="panel__head">
<div>
<p class="eyebrow">Recent raw events</p>
<h2>Ledger feed</h2>
<span class="kicker" style="margin:0;">Recent events</span>
<h2 class="panel__title">Ledger feed</h2>
</div>
<span class="badge badge--closed" style="font-size:9.5px;">append-only</span>
</div>
<div class="event-feed condensed">
<div class="event-feed feed feed--inline">
{% for event in recent_events %}
<article class="event-card">
<div class="event-card event-card--inline">
<div class="event-card-top">
<strong>{{ event.player_name }}</strong>
<span class="pill pill-{{ event.event_type }}">{{ event.event_type }}</span>
<strong>{{ event.player_name or "Session" }}</strong>
<span class="pill pill-{{ event.event_type }}">{{ event.event_type | replace('_', ' ') }}</span>
{% if event.note %}<span class="event-note">{{ event.note }}</span>{% endif %}
</div>
<p class="event-amount">{{ event.amount_cents | money }}</p>
<p class="muted-text small">{{ event.session_date | pretty_date }}</p>
{% if event.note %}<p class="muted-text small">{{ event.note }}</p>{% endif %}
</article>
<p class="tiny-text">{{ event.session_date | pretty_date }}</p>
</div>
{% else %}
<p class="muted-text">No recent events.</p>
<p class="muted-text" style="padding:20px;">No recent events.</p>
{% endfor %}
</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 %}
+12 -11
View File
@@ -1,23 +1,24 @@
{% extends "base.html" %}
{% block title %}Admin Login · Poker Portal{% endblock %}
{% block title %}Admin Login · boker.club{% endblock %}
{% block content %}
<section class="form-shell narrow">
<form class="panel form-card" method="post">
<p class="eyebrow">Restricted access</p>
<h1>Admin login</h1>
<p class="muted-text">Only the ledger manager needs credentials. All stats pages stay public.</p>
<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>
<span>Username</span>
<input type="text" name="username" required>
<input type="text" name="username" autocomplete="username" required>
</label>
<label>
<span>Password</span>
<input type="password" name="password" required>
<input type="password" name="password" autocomplete="current-password" required>
</label>
<button class="primary-button" type="submit">Login</button>
</form>
</section>
</div>
{% endblock %}
+24 -21
View File
@@ -3,47 +3,50 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Poker Portal{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<title>{% block title %}boker.club{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/tokens.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head>
<body>
<div class="site-shell">
<header class="topbar">
<div>
<a class="brand" href="{{ url_for('leaderboard') }}">Poker Portal</a>
<p class="brand-subtitle">Low-stakes hold'em ledger and session tracker</p>
</div>
<nav class="nav-links">
<a href="{{ url_for('leaderboard') }}">Leaderboard</a>
<a href="{{ url_for('sessions') }}">Sessions</a>
<div class="topbar__inner">
<a class="brand" href="{{ url_for('leaderboard') }}">
<span class="brand__mark"></span>
<span class="brand__name">boker.club</span>
</a>
<nav class="mainnav">
<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 %}
<a href="{{ url_for('admin_dashboard') }}">Admin</a>
<form method="post" action="{{ url_for('admin_logout') }}">
<button class="ghost-button" type="submit">Logout</button>
</form>
<a class="navlink {{ 'is-active' if request.endpoint == 'admin_dashboard' }}" href="{{ url_for('admin_dashboard') }}">Admin</a>
{% 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 %}
</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>
<main class="page {% block page_class %}page--public{% endblock %}">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section class="flash-stack">
<div class="flash-stack">
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
</section>
</div>
{% endif %}
{% endwith %}
<main class="page-content">
{% block content %}{% endblock %}
</main>
</div>
</body>
</html>
+255 -503
View File
@@ -1,596 +1,348 @@
{% extends "base.html" %}
{% block title %}Leaderboard · Poker Portal{% endblock %}
{% block title %}Leaderboard · boker.club{% endblock %}
{% block content %}
<section class="hero-card">
<div>
<p class="eyebrow">All-time results</p>
<h1>Leaderboard</h1>
<p class="muted-text">Track who is up, who is chasing, and how everyone has moved across {{ session_count }} recorded sessions{% if selected_session_date %} through {{ selected_session_label }}{% endif %}.</p>
</div>
</section>
<section class="grid two-col leaderboard-layout">
<div class="panel chart-panel chart-panel-large">
<div class="panel-header panel-header-with-actions">
<div class="hero" style="display:flex;flex-wrap:wrap;gap:var(--s6);align-items:flex-start;justify-content:space-between;">
<div>
<p class="eyebrow">Trend</p>
<h2>Cumulative profit over time</h2>
<p class="hero__kicker">All-time results</p>
<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 class="panel-actions">
<button class="ghost-button compact-button" type="button" id="resetLeaderboardZoom" hidden>Reset zoom</button>
<button class="secondary-button" type="button" id="expandLeaderboardChart">Expand chart</button>
<div class="leaderboard-hero-stats">
<div class="stat">
<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 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>
</div>
</div>
</div>
<div class="panel stat-panel">
<p class="eyebrow">Quick read</p>
<h2>Table snapshot</h2>
<div class="stat-list">
<div class="stat-row"><span>Players tracked</span><strong>{{ leaderboard|length }}</strong></div>
<div class="stat-row"><span>Sessions tracked</span><strong>{{ session_count }}{% if total_session_count != session_count %} / {{ total_session_count }}{% endif %}</strong></div>
<div class="stat-row"><span>Through session</span><strong>{{ selected_session_label }}</strong></div>
<div class="stat-row"><span>Best player</span><strong>{% if leaderboard %}{{ leaderboard[0].player_name }}{% else %}—{% endif %}</strong></div>
<div class="stat-row"><span>Worst player</span><strong>{% if leaderboard %}{{ leaderboard[-1].player_name }}{% else %}—{% endif %}</strong></div>
<div class="toolbar">
<div class="toolbar__left">
<div class="segmented">
<button class="seg {{ 'is-on' if mode=='eligible' }}" data-mode="eligible">
Eligible <span class="seg__count">{{ eligible_count }}</span>
</button>
<button class="seg {{ 'is-on' if mode=='all' }}" data-mode="all">
All players <span class="seg__count">{{ all_count }}</span>
</button>
<button class="seg {{ 'is-on' if mode=='recent' }}" data-mode="recent">
Recent <span class="seg__count">5 sess</span>
</button>
</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>
</section>
<section class="panel">
<div class="panel-header leaderboard-header">
<div>
<p class="eyebrow">Standings</p>
<h2>All-time leaderboard</h2>
<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>
<div class="toolbar__right">
<form class="session-filter-form" method="get">
<input type="hidden" name="mode" value="{{ mode }}">
<select class="session-filter-select" name="through_session" onchange="this.form.submit()">
<option value="">Latest session</option>
{% for s in available_sessions %}
<option value="{{ s.session_id }}" {{ 'selected' if selected_session_id == s.session_id else '' }}>{{ session_label(s) }}</option>
{% endfor %}
</select>
{% if selected_session_date %}
<a class="ghost-button compact-button" href="{{ url_for('leaderboard') }}">Reset</a>
{% if selected_session_id %}
<a class="btn btn--ghost btn--sm" href="{{ url_for('leaderboard', mode=mode) }}">Reset</a>
{% endif %}
</form>
<p class="muted-text small">Click any column to sort.</p>
</div>
</div>
</div>
<div class="table-wrap">
<table id="leaderboardTable" class="sortable-table" data-sort-key="net" data-sort-direction="desc">
<div class="panel tbl-scroll" style="margin-bottom:20px;">
<table class="tbl" id="leaderboardTable" data-sort-key="net" data-sort-direction="desc">
<thead>
<tr>
<th><button type="button" class="sort-button" data-sort-key="rank" data-sort-type="number" data-initial-direction="asc">Rank <span class="sort-indicator"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="rankdelta" data-sort-type="number" data-initial-direction="desc">Rank Δ <span class="sort-indicator"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="player" data-sort-type="string" data-initial-direction="asc">Player <span class="sort-indicator"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="cashin" data-sort-type="number" data-initial-direction="desc">Cash in <span class="sort-indicator"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="paidout" data-sort-type="number" data-initial-direction="desc">Paid out <span class="sort-indicator"></span></button></th>
<th><button type="button" class="sort-button is-active" data-sort-key="net" data-sort-type="number" data-initial-direction="desc">Net <span class="sort-indicator"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="winpct" data-sort-type="number" data-initial-direction="desc">Win % <span class="sort-indicator"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="roi" data-sort-type="number" data-initial-direction="desc">ROI <span class="sort-indicator"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="avgwin" data-sort-type="number" data-initial-direction="desc">Avg Win <span class="sort-indicator"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="avgloss" data-sort-type="number" data-initial-direction="desc">Avg Loss <span class="sort-indicator"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="sessions" data-sort-type="number" data-initial-direction="desc">Sessions <span class="sort-indicator"></span></button></th>
<th class="col-rank" style="width:52px;">
<span style="font-family:var(--font-mono);font-size:10.5px;letter-spacing:.10em;text-transform:uppercase;color:var(--muted);">#</span>
</th>
<th class="col-player">
<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>
<th class="col-rank-delta"><button type="button" class="sort-btn" data-sort-key="rankdelta" data-sort-type="number" data-initial-direction="desc">Δ Rank <span class="sort-arrow"></span></button></th>
<th><button type="button" class="sort-btn" data-sort-key="net" data-sort-type="number" data-initial-direction="desc">Net <span class="sort-arrow"></span></button></th>
<th><button type="button" class="sort-btn" data-sort-key="winpct" data-sort-type="number" data-initial-direction="desc">Win% <span class="sort-arrow"></span></button></th>
<th><button type="button" class="sort-btn" data-sort-key="roi" data-sort-type="number" data-initial-direction="desc">ROI <span class="sort-arrow"></span></button></th>
<th><button type="button" class="sort-btn" data-sort-key="avgwin" data-sort-type="number" data-initial-direction="desc">Avg Win <span class="sort-arrow"></span></button></th>
<th><button type="button" class="sort-btn" data-sort-key="avgloss" data-sort-type="number" data-initial-direction="desc">Avg Loss <span class="sort-arrow"></span></button></th>
<th><button type="button" class="sort-btn" data-sort-key="sessions" data-sort-type="number" data-initial-direction="desc">Sessions <span class="sort-arrow"></span></button></th>
</tr>
</thead>
<tbody>
{% for player in leaderboard %}
<tr data-player-row="true" data-original-rank="{{ loop.index }}">
<td data-sort-value="{{ loop.index }}" data-rank-cell>#{{ loop.index }}</td>
<td data-sort-value="{{ player.rank_change }}">
{% for player in main_board %}
{% set idx = loop.index %}
<tr data-player-row="true" data-original-rank="{{ idx }}"
onclick="window.location='{{ url_for('player_detail', player_name=player.player_name) }}'">
<td class="col-rank" data-sort-value="{{ idx }}">
<span class="rank-num {{ 'rank-1' if idx==1 else 'rank-2' if idx==2 else 'rank-3' if idx==3 else '' }}" data-rank-cell>#{{ idx }}</span>
</td>
<td class="col-player" data-sort-value="{{ player.player_name|lower }}">
<a href="{{ url_for('player_detail', player_name=player.player_name) }}">{{ player.player_name }}</a>
</td>
<td class="col-rank-delta" data-sort-value="{{ player.rank_change }}">
{% if player.rank_change > 0 %}
<span class="rank-move rank-up">{{ player.rank_change }}</span>
<span class="rank-delta rank-delta--up">{{ player.rank_change }}</span>
{% 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 %}
<span class="rank-move rank-neutral">-</span>
<span class="rank-delta rank-delta--flat"></span>
{% endif %}
</td>
<td data-sort-value="{{ player.player_name|lower }}"><a href="{{ url_for('player_detail', player_name=player.player_name) }}">{{ player.player_name }}</a></td>
<td data-sort-value="{{ player.total_buy_in_cents }}">{{ player.total_buy_in_cents | money }}</td>
<td data-sort-value="{{ player.total_paid_cents }}">{{ player.total_paid_cents | money }}</td>
<td data-sort-value="{{ player.total_net_cents }}" class="{{ 'positive' if player.total_net_cents > 0 else 'negative' if player.total_net_cents < 0 else 'neutral' }}">{{ player.total_net_cents | money }}</td>
<td class="num-net {{ 'num-pos' if player.total_net_cents > 0 else 'num-neg' if player.total_net_cents < 0 else 'num-muted' }}"
data-sort-value="{{ player.total_net_cents }}">
{{ '+' if player.total_net_cents > 0 else '' }}{{ player.total_net_cents | money }}
</td>
<td data-sort-value="{{ '%.4f'|format(player.win_pct) }}">{{ "%.1f"|format(player.win_pct) }}%</td>
<td data-sort-value="{{ '%.4f'|format(player.roi_pct) }}">{{ "%.1f"|format(player.roi_pct) }}%</td>
<td data-sort-value="{{ player.avg_win_cents }}">{{ player.avg_win_cents | money }}</td>
<td data-sort-value="{{ player.avg_loss_cents }}">{{ player.avg_loss_cents | money }}</td>
<td class="{{ 'num-pos' if player.roi_pct > 0 else 'num-neg' if player.roi_pct < 0 else '' }}"
data-sort-value="{{ '%.4f'|format(player.roi_pct) }}">{{ "%.1f"|format(player.roi_pct) }}%</td>
<td class="num-pos" data-sort-value="{{ player.avg_win_cents }}">{{ player.avg_win_cents | money }}</td>
<td class="num-neg" data-sort-value="{{ player.avg_loss_cents }}">{{ player.avg_loss_cents | money }}</td>
<td data-sort-value="{{ player.sessions_played }}">{{ player.sessions_played }}</td>
</tr>
{% else %}
<tr>
<td colspan="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>
{% endfor %}
</tbody>
</table>
</div>
</section>
</div>
<div class="chart-modal" id="chartModal" aria-hidden="true">
<div class="chart-modal-backdrop" data-close-chart-modal></div>
<div class="chart-modal-card">
<div class="panel-header panel-header-with-actions">
<div>
<p class="eyebrow">Expanded view</p>
<h2>Cumulative profit over time</h2>
{% if mode == 'eligible' and provisional_board %}
<div style="margin-bottom:20px;">
<div class="toolbar" style="margin-top:0;margin-bottom:12px;">
<span class="kicker" style="margin:0;">Provisional · fewer than {{ eligible_min_sessions }} sessions</span>
</div>
<div class="panel-actions">
<button class="secondary-button" type="button" id="fullscreenLeaderboardChart">Fullscreen</button>
<button class="ghost-button" type="button" data-close-chart-modal>Close</button>
<div class="provisional-grid">
{% for player in provisional_board %}
<a class="provisional-card" href="{{ url_for('player_detail', player_name=player.player_name) }}"
style="display:block;text-decoration:none;">
<p class="provisional-card__name">{{ player.player_name }}</p>
<div class="provisional-card__meta">
<span>{{ player.sessions_played }} sess</span>
<span class="{{ 'num-pos' if player.total_net_cents > 0 else 'num-neg' if player.total_net_cents < 0 else '' }}"
style="font-family:var(--font-mono);font-size:11px;font-weight:600;">{{ player.total_net_cents | money }}</span>
</div>
</a>
{% endfor %}
</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>
</div>
</div>
</div>
<script>
const leaderboardChartData = {{ chart_data | tojson }};
const rootStyles = getComputedStyle(document.documentElement);
const textMuted = rootStyles.getPropertyValue('--text-muted').trim();
const lineColor = rootStyles.getPropertyValue('--line').trim();
const CHART_PALETTE = ['#9b8cf0','#6fc093','#e0b15c','#cf6f86','#8f93c2','#7cb9e0','#f4a261','#a78bb0','#5cb8a0','#e58f3a'];
const leaderboardChartData = {{ chart_data | tojson }};
const rootStyles = getComputedStyle(document.documentElement);
const textMuted = rootStyles.getPropertyValue('--text-muted').trim();
const lineColor = rootStyles.getPropertyValue('--line').trim();
function mainLeaderboardChartData(chartData) {
const repeatedPlayerDatasets = chartData.datasets.filter((dataset) => {
return Number(dataset.sessions_played || 0) > 1;
});
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;
leaderboardChartData.datasets.forEach((ds, i) => {
ds.borderColor = CHART_PALETTE[i % CHART_PALETTE.length];
ds.backgroundColor = CHART_PALETTE[i % CHART_PALETTE.length];
});
function buildChartConfig(data, compact) {
return {
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: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'nearest', intersect: false },
elements: {
point: {
radius: compact ? 0 : 2,
hoverRadius: 5,
},
line: {
borderWidth: compact ? 2.5 : 2,
tension: 0.28,
},
},
plugins: {
legend: {
display: true,
position: 'bottom',
labels: {
color: textMuted,
usePointStyle: true,
boxWidth: compact ? 8 : 10,
boxHeight: compact ? 8 : 10,
padding: compact ? 12 : 18,
},
labels: { color: textMuted, usePointStyle: true, boxWidth: 8, boxHeight: 8, padding: 14 },
},
tooltip: {
callbacks: {
label(context) {
const value = context.parsed.y;
if (value === null || value === undefined) {
return `${context.dataset.label}: —`;
label(ctx) {
const v = ctx.parsed.y;
return v == null ? `${ctx.dataset.label}: —` : `${ctx.dataset.label}: $${Number(v).toFixed(2)}`;
}
}
}
return `${context.dataset.label}: $${Number(value).toFixed(2)}`;
},
},
},
},
scales: {
x: {
ticks: { color: textMuted, maxRotation: 0, autoSkip: true, maxTicksLimit: compact ? 6 : 10 },
grid: { color: lineColor, display: !compact },
},
y: {
ticks: {
color: textMuted,
maxRotation: 0,
autoSkip: true,
maxTicksLimit: compact ? 6 : 10,
},
grid: {
color: lineColor,
display: !compact,
},
},
y: chartYScaleOptions(chartData, compact),
},
maxTicksLimit: compact ? 8 : 10,
callback(v) { return (v < 0 ? '-' : '') + '$' + Math.abs(Number(v)).toFixed(0); }
},
grid: { color: lineColor },
}
}
}
};
}
}
const zoomSelectionPlugin = {
id: 'zoomSelection',
beforeDraw(chart) {
const drag = chart.$zoomDrag;
if (!drag || !drag.active || drag.startX === drag.endX) {
return;
}
const { ctx, chartArea } = chart;
const left = Math.max(chartArea.left, Math.min(drag.startX, drag.endX));
const right = Math.min(chartArea.right, Math.max(drag.startX, drag.endX));
ctx.save();
ctx.fillStyle = 'rgba(229, 139, 58, 0.14)';
ctx.strokeStyle = 'rgba(229, 139, 58, 0.74)';
ctx.lineWidth = 1;
ctx.fillRect(left, chartArea.top, right - left, chartArea.bottom - chartArea.top);
ctx.strokeRect(left, chartArea.top, right - left, chartArea.bottom - chartArea.top);
ctx.restore();
},
const ctx = document.getElementById('leaderboardChart');
let mainChart = null;
if (ctx && leaderboardChartData.labels.length > 0) {
const filtered = leaderboardChartData.datasets.filter(ds => (ds.sessions_played || 0) > 1);
const chartData = {
labels: leaderboardChartData.labels,
datasets: filtered.length ? filtered : leaderboardChartData.datasets,
};
mainChart = new Chart(ctx, buildChartConfig(chartData, true));
}
Chart.register(zoomSelectionPlugin);
const modal = document.getElementById('chartModal');
const expandBtn = document.getElementById('expandLeaderboardChart');
let expandedChart = null;
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function enableDragZoom(chart, resetButton) {
if (!chart || !chart.canvas || !resetButton) {
return;
}
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 })
expandBtn?.addEventListener('click', () => {
if (!leaderboardChartData.labels.length) return;
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
if (expandedChart) expandedChart.destroy();
expandedChart = new Chart(
document.getElementById('leaderboardChartExpanded'),
buildChartConfig(leaderboardChartData, false)
);
enableDragZoom(leaderboardChart, resetZoomButton);
suppressLegendClickExpansion(leaderboardChart);
}
});
const chartModal = document.getElementById('chartModal');
const expandButton = document.getElementById('expandLeaderboardChart');
const chartFrame = document.getElementById('leaderboardChartFrame');
const fullscreenButton = document.getElementById('fullscreenLeaderboardChart');
const modalFrame = document.getElementById('chartModalFrame');
const expandedCtx = document.getElementById('leaderboardChartExpanded');
let expandedChart = null;
function openChartModal() {
if (!chartModal || !expandedCtx || leaderboardChartData.labels.length === 0) {
return;
}
chartModal.classList.add('is-open');
chartModal.setAttribute('aria-hidden', 'false');
document.body.classList.add('modal-open');
if (expandedChart) {
expandedChart.destroy();
}
expandedChart = new Chart(expandedCtx, buildLeaderboardChartConfig(leaderboardChartData));
}
function closeChartModal() {
if (!chartModal) {
return;
}
chartModal.classList.remove('is-open');
chartModal.setAttribute('aria-hidden', 'true');
document.body.classList.remove('modal-open');
if (expandedChart) {
expandedChart.destroy();
expandedChart = null;
}
}
expandButton?.addEventListener('click', openChartModal);
chartFrame?.addEventListener('click', openChartModal);
chartFrame?.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openChartModal();
}
document.querySelectorAll('[data-close-modal]').forEach(el => {
el.addEventListener('click', () => {
modal.style.display = 'none';
document.body.style.overflow = '';
if (expandedChart) { expandedChart.destroy(); expandedChart = null; }
});
});
chartModal?.querySelectorAll('[data-close-chart-modal]').forEach((element) => {
element.addEventListener('click', closeChartModal);
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && modal.style.display === 'flex') {
modal.style.display = 'none';
document.body.style.overflow = '';
if (expandedChart) { expandedChart.destroy(); expandedChart = null; }
}
});
// Segmented mode switcher
document.querySelectorAll('[data-mode]').forEach(btn => {
btn.addEventListener('click', () => {
const url = new URL(window.location.href);
url.searchParams.set('mode', btn.dataset.mode);
window.location.href = url.toString();
});
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && chartModal?.classList.contains('is-open')) {
closeChartModal();
}
// Table sort
const tbl = document.getElementById('leaderboardTable');
const sortBtns = tbl ? Array.from(tbl.querySelectorAll('.sort-btn[data-sort-key]')) : [];
const colMap = { rank: 0, player: 1, rankdelta: 2, net: 3, winpct: 4, roi: 5, avgwin: 6, avgloss: 7, sessions: 8 };
function setActiveSort(key, dir) {
sortBtns.forEach(b => {
const active = b.dataset.sortKey === key;
b.classList.toggle('is-active', active);
const arrow = b.querySelector('.sort-arrow');
if (arrow) arrow.textContent = active ? (dir === 'asc' ? '↑' : '↓') : '↕';
});
}
fullscreenButton?.addEventListener('click', async () => {
if (!modalFrame) {
return;
}
if (document.fullscreenElement) {
await document.exitFullscreen();
return;
}
if (modalFrame.requestFullscreen) {
await modalFrame.requestFullscreen();
}
});
const leaderboardTable = document.getElementById('leaderboardTable');
const sortButtons = Array.from(document.querySelectorAll('.sort-button'));
const columnIndexMap = {
rank: 0,
rankdelta: 1,
player: 2,
cashin: 3,
paidout: 4,
net: 5,
winpct: 6,
roi: 7,
avgwin: 8,
avgloss: 9,
sessions: 10,
};
function updateRankCells() {
if (!leaderboardTable) {
return;
}
Array.from(leaderboardTable.tBodies[0].querySelectorAll('tr[data-player-row="true"]')).forEach((row, index) => {
const rankCell = row.querySelector('[data-rank-cell]');
if (rankCell) {
rankCell.textContent = `#${index + 1}`;
}
});
}
function setActiveSortButton(sortKey, direction) {
sortButtons.forEach((button) => {
const isActive = button.dataset.sortKey === sortKey;
button.classList.toggle('is-active', isActive);
const indicator = button.querySelector('.sort-indicator');
if (indicator) {
indicator.textContent = isActive ? (direction === 'asc' ? '↑' : '↓') : '↕';
}
});
}
function sortLeaderboard(sortKey, sortType, forcedDirection) {
if (!leaderboardTable) {
return;
}
const tbody = leaderboardTable.tBodies[0];
const rows = Array.from(tbody.querySelectorAll('tr[data-player-row="true"]'));
const currentKey = leaderboardTable.dataset.sortKey;
const currentDirection = leaderboardTable.dataset.sortDirection || 'desc';
let direction = forcedDirection;
if (!direction) {
if (currentKey === sortKey) {
direction = currentDirection === 'desc' ? 'asc' : 'desc';
} else {
const trigger = sortButtons.find((button) => button.dataset.sortKey === sortKey);
direction = trigger?.dataset.initialDirection || 'desc';
function sortTable(key, sortType, forcedDir) {
if (!tbl) return;
const tbody = tbl.tBodies[0];
const rows = Array.from(tbody.querySelectorAll('tr[data-player-row]'));
const curKey = tbl.dataset.sortKey;
const curDir = tbl.dataset.sortDirection || 'desc';
let dir = forcedDir;
if (!dir) {
if (curKey === key) dir = curDir === 'desc' ? 'asc' : 'desc';
else {
const btn = sortBtns.find(b => b.dataset.sortKey === key);
dir = btn?.dataset.initialDirection || 'desc';
}
}
const multiplier = direction === 'asc' ? 1 : -1;
const columnIndex = columnIndexMap[sortKey] ?? 0;
rows.sort((leftRow, rightRow) => {
const leftCell = leftRow.children[columnIndex];
const rightCell = rightRow.children[columnIndex];
const leftValue = leftCell?.dataset.sortValue ?? '';
const rightValue = rightCell?.dataset.sortValue ?? '';
const mult = dir === 'asc' ? 1 : -1;
const ci = colMap[key] ?? 0;
rows.sort((a, b) => {
const av = a.children[ci]?.dataset.sortValue ?? '';
const bv = b.children[ci]?.dataset.sortValue ?? '';
if (sortType === 'number') {
const numericResult = (Number(leftValue) - Number(rightValue)) * multiplier;
if (numericResult !== 0) {
return numericResult;
}
const n = (Number(av) - Number(bv)) * mult;
if (n !== 0) return n;
} else {
const stringResult = leftValue.localeCompare(rightValue) * multiplier;
if (stringResult !== 0) {
return stringResult;
const s = av.localeCompare(bv) * mult;
if (s !== 0) return s;
}
}
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);
return Number(a.dataset.originalRank) - Number(b.dataset.originalRank);
});
rows.forEach(r => tbody.appendChild(r));
tbl.dataset.sortKey = key;
tbl.dataset.sortDirection = dir;
rows.forEach((row, i) => {
const rc = row.querySelector('[data-rank-cell]');
if (rc) rc.textContent = `#${i + 1}`;
});
setActiveSort(key, dir);
}
setActiveSortButton('net', 'desc');
sortBtns.forEach(b => {
b.addEventListener('click', () => sortTable(b.dataset.sortKey, b.dataset.sortType));
});
setActiveSort('net', 'desc');
</script>
{% endblock %}
+250 -163
View File
@@ -1,154 +1,280 @@
{% extends "base.html" %}
{% block title %}{{ player.player_name }} · Poker Portal{% endblock %}
{% block title %}{{ player.player_name }} · boker.club{% endblock %}
{% block content %}
<section class="hero-card compact">
<div>
<p class="eyebrow">Player profile</p>
<h1>{{ player.player_name }}</h1>
<p class="muted-text">A quick read on lifetime performance, hit rate, and session-by-session movement.</p>
</div>
</section>
<section class="grid three-col profile-summary-grid">
<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 class="hero">
<div>
<p class="eyebrow">Trend</p>
<h2>Cumulative profit</h2>
{% if player_rank %}
<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 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>
</div>
</div>
</section>
</div>
<section class="grid two-col">
<div class="panel chart-panel">
<div class="panel-header">
<div>
<p class="eyebrow">Volatility</p>
<h2>Session results</h2>
</div>
<!-- Charts row: session results + result mix -->
<div class="split" style="margin-bottom:20px;">
<div class="panel col-main">
<div class="panel__head">
<span class="kicker" style="margin:0;">Session by session</span>
<h2 class="panel__title">Results</h2>
</div>
<div class="chart-wrap">
<div class="chart-frame">
<canvas id="playerSessionChart"></canvas>
</div>
</div>
<div class="panel chart-panel">
<div class="panel-header">
<div>
<p class="eyebrow">Breakdown</p>
<h2>Result mix</h2>
</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 class="chart-frame chart-frame-small">
<canvas id="playerBreakdownChart"></canvas>
</div>
</div>
</section>
<section class="panel">
<div class="panel-header">
<div>
<p class="eyebrow">Breakdown</p>
<h2>Detailed stats</h2>
</div>
</div>
<div class="table-wrap">
<table>
<tbody>
<tr><th>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>
<div class="chart-wrap">
<div class="result-mix" aria-label="Player result mix">
{% set result_total = player.winning_sessions + player.losing_sessions + player.break_even_sessions %}
<div class="result-mix__bar">
{% if player.winning_sessions > 0 %}
<span class="result-mix__seg result-mix__seg--win" style="flex-grow:{{ player.winning_sessions }}"></span>
{% endif %}
{% if player.break_even_sessions > 0 %}
<span class="result-mix__seg result-mix__seg--even" style="flex-grow:{{ player.break_even_sessions }}"></span>
{% endif %}
{% if player.losing_sessions > 0 %}
<span class="result-mix__seg result-mix__seg--loss" style="flex-grow:{{ player.losing_sessions }}"></span>
{% endif %}
</div>
<div class="result-mix__legend">
<div class="result-mix__legend-row">
<span><i class="result-mix__key result-mix__key--win"></i>Wins</span>
<strong>{{ player.winning_sessions }} · {{ "%.1f"|format((player.winning_sessions / result_total * 100) if result_total else 0) }}%</strong>
</div>
<div class="result-mix__legend-row">
<span><i class="result-mix__key result-mix__key--even"></i>Even</span>
<strong>{{ player.break_even_sessions }} · {{ "%.1f"|format((player.break_even_sessions / result_total * 100) if result_total else 0) }}%</strong>
</div>
<div class="result-mix__legend-row">
<span><i class="result-mix__key result-mix__key--loss"></i>Losses</span>
<strong>{{ player.losing_sessions }} · {{ "%.1f"|format((player.losing_sessions / result_total * 100) if result_total else 0) }}%</strong>
</div>
</div>
{% if result_total > 0 %}
<div class="result-mix__note">±$1.00 counts as even</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- 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>
</tr>
<tr>
<th>Worst Session</th>
<td>
{{ player.worst_session_net_cents | money }}
{% if player.worst_session_date %}
<span class="muted-text small">on {{ player.worst_session_date | pretty_date }}</span>
{% endif %}
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Win %</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>
</tr>
<tr>
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">ROI</td>
<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>
</tr>
</tbody>
</table>
</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>
const playerChartData = {{ chart_data | tojson }};
const rootStyles = getComputedStyle(document.documentElement);
const textMuted = rootStyles.getPropertyValue('--text-muted').trim();
const lineColor = rootStyles.getPropertyValue('--line').trim();
const playerChartData = {{ chart_data | tojson }};
const rootStyles = getComputedStyle(document.documentElement);
const textMuted = rootStyles.getPropertyValue('--text-muted').trim();
const lineColor = rootStyles.getPropertyValue('--line').trim();
const sharedScaleOptions = {
const sharedOpts = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: {
color: textMuted,
usePointStyle: true,
boxWidth: 10,
boxHeight: 10,
padding: 18,
},
},
legend: { position: 'bottom', labels: { color: textMuted, usePointStyle: true, boxWidth: 9, boxHeight: 9, padding: 16 } },
},
scales: {
x: {
ticks: { color: textMuted, maxRotation: 0 },
grid: { color: lineColor },
x: { ticks: { color: textMuted, maxRotation: 0 }, grid: { color: lineColor } },
y: { ticks: { color: textMuted }, grid: { color: lineColor } },
},
y: {
ticks: { color: textMuted },
grid: { color: lineColor },
},
},
};
};
const cumulativeCtx = document.getElementById('playerCumulativeChart');
if (cumulativeCtx) {
const cumulativeCtx = document.getElementById('playerCumulativeChart');
if (cumulativeCtx) {
new Chart(cumulativeCtx, {
type: 'line',
data: {
@@ -157,19 +283,20 @@
label: 'Cumulative Profit',
data: playerChartData.cumulative_values,
borderColor: playerChartData.color,
backgroundColor: playerChartData.color,
backgroundColor: playerChartData.color + '38',
borderWidth: 2.5,
pointRadius: 3,
pointHoverRadius: 5,
tension: 0.22,
fill: true,
}]
},
options: sharedScaleOptions,
options: sharedOpts,
});
}
}
const sessionCtx = document.getElementById('playerSessionChart');
if (sessionCtx) {
const sessionCtx = document.getElementById('playerSessionChart');
if (sessionCtx) {
new Chart(sessionCtx, {
type: 'bar',
data: {
@@ -179,53 +306,13 @@
data: playerChartData.net_values,
backgroundColor: playerChartData.net_colors,
borderColor: playerChartData.net_colors,
borderRadius: 8,
borderRadius: 6,
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>
{% endblock %}
+209 -278
View File
@@ -1,164 +1,146 @@
{% 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 %}
<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 %}
<a class="secondary-button" href="{{ url_for('session_detail', session_id=prev_session.session_id) }}">
Previous Session
</a>
<a class="btn btn--ghost btn--sm" href="{{ url_for('session_detail', session_id=prev_session.session_id) }}">← Prev</a>
{% endif %}
{% if next_session %}
<a class="secondary-button" href="{{ url_for('session_detail', session_id=next_session.session_id) }}">
Next Session
</a>
<a class="btn btn--ghost btn--sm" href="{{ url_for('session_detail', session_id=next_session.session_id) }}">Next →</a>
{% endif %}
</div>
<div class="hero" style="padding-top:0;">
<p class="hero__kicker session-number">
Session #{{ "%03d"|format(session_number) }}
</p>
<h1 class="hero__title">{{ session_label(session) }}</h1>
<p class="hero__sub">{{ session.entries|length }} player{{ 's' if session.entries|length != 1 else '' }} · <span class="status-badge status-{{ session.status }}">{{ session.status }}</span></p>
</div>
<!-- Recon cards -->
<div class="recon-grid" style="margin-bottom:20px;">
<div class="panel">
<div class="recon-card-head">
<span class="kicker kicker--dot kicker--dot-accent">Cash flow</span>
</div>
<div 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>
</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">
<article class="panel stat-card">
<span>Gross payout</span>
<strong>{{ session.total_gross_payout_cents | money }}</strong>
</article>
<article class="panel stat-card">
<span>Total rolled out</span>
<strong>{{ session.total_rollover_out_cents | money }}</strong>
</article>
<article class="panel stat-card">
<span>Total paid</span>
<strong class="positive">{{ session.total_paid_cents | money }}</strong>
</article>
<article class="panel stat-card">
<span>Still owed</span>
<strong class="{{ 'negative' if session.total_current_due_cents > 0 else 'positive' }}">
{{ session.total_current_due_cents | money }}
</strong>
</article>
<article class="panel stat-card">
<span>Players still owe</span>
<strong class="{{ 'negative' if session.total_player_owes_cents > 0 else '' }}">
{{ session.total_player_owes_cents | money }}
</strong>
</article>
<article class="panel stat-card">
<span>Written off</span>
<strong class="{{ 'negative' if session.total_front_writeoff_cents > 0 else '' }}">
{{ session.total_front_writeoff_cents | money }}
</strong>
</article>
</section>
<section class="panel results-panel results-panel-full">
<div class="panel-header">
<!-- Player totals table -->
<div class="panel" style="margin-bottom:20px;">
<div class="panel__head">
<div>
<p class="eyebrow">Results</p>
<h2>Player totals</h2>
<span class="kicker" style="margin:0;">Results</span>
<h2 class="panel__title">Player totals</h2>
</div>
<div class="table-toolbar">
<button type="button" class="secondary-button table-view-toggle is-active" data-view="compact">
Compact
</button>
<button type="button" class="secondary-button table-view-toggle" data-view="full">
Full details
</button>
<div class="segmented" id="viewToggle">
<button class="seg is-on" data-view="compact">Compact</button>
<button class="seg" data-view="full">Full ledger</button>
</div>
</div>
<div class="table-scroll table-scroll-open">
<table class="data-table session-results-table">
<div class="tbl-scroll">
<table class="tbl tbl--session" id="sessionTable">
<thead>
<tr>
<th>Player</th>
<th>In</th>
<th class="detail-col">Fronted</th>
<th class="detail-col">Rolled in</th>
<th>Out</th>
<th class="col-player">Player</th>
<th>Invested</th>
<th>Cash-out</th>
<th>Net</th>
<th class="detail-col">Gross due</th>
<th class="detail-col">Paid</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>Cash paid</th>
<th>Settlement</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>
</thead>
<tbody>
{% for entry in session.entries %}
<tr>
<td>
<a href="{{ url_for('player_detail', player_name=entry.player_name) }}">
{{ entry.player_name }}
</a>
<tr onclick="window.location='{{ url_for('player_detail', player_name=entry.player_name) }}'">
<td class="col-player">
<a href="{{ url_for('player_detail', player_name=entry.player_name) }}">{{ entry.player_name }}</a>
</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 class="{{ 'positive' if entry.net_cents > 0 else 'negative' if entry.net_cents < 0 else '' }}">
{{ entry.net_cents | money }}
<td class="num-net {{ 'num-pos' if entry.net_cents > 0 else 'num-neg' if entry.net_cents < 0 else 'num-muted' }}">
{{ '+' if entry.net_cents > 0 else '' }}{{ entry.net_cents | money }}
</td>
<td class="detail-col">
{% if entry.raw_player_owes_cents > 0 %}
<td style="color:var(--pos)">{{ entry.paid_cents | money }}</td>
<td class="{{ 'num-neg' if entry.current_due_to_player_cents > 0 else '' }}">
{% if entry.current_due_to_house_cents > 0 %}
<span style="color:var(--owes)">owes {{ entry.current_due_to_house_cents | money }}</span>
{% else %}
{{ entry.gross_payout_cents | money }}
{{ entry.current_due_to_player_cents | money }}
{% endif %}
</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>
<span class="status-badge status-{{ entry.payout_status }}">
{{ entry.payout_status }}
</span>
<span class="badge badge--{{ entry.payout_status }}">{{ entry.payout_status }}</span>
</td>
<td>
<td class="col-acct" style="color:var(--warn)">{{ entry.front_cents | money }}</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 %}
<ul class="note-list">
{% for note in entry.notes %}
<li>{{ note }}</li>
{% endfor %}
</ul>
<ul class="note-list">{% for n in entry.notes %}<li>{{ n }}</li>{% endfor %}</ul>
{% else %}
<span style="color:var(--faintest-2)"></span>
{% endif %}
</td>
</tr>
@@ -166,185 +148,134 @@
</tbody>
</table>
</div>
</section>
</div>
<section class="grid two-col chart-row">
<div class="panel chart-panel chart-panel-compact">
<div class="panel-header">
<div>
<p class="eyebrow">Breakdown</p>
<h2>Net by player</h2>
<!-- Bottom row: net bars + snapshot + audit feed -->
<div class="split" style="align-items:stretch;">
<div class="panel col-main">
<div class="panel__head">
<span class="kicker" style="margin:0;">Breakdown</span>
<h2 class="panel__title">Net by player</h2>
</div>
</div>
<div class="chart-frame chart-frame-compact">
<canvas id="sessionBreakdownChart"></canvas>
<div class="chart-wrap">
<div class="session-net-bars" id="sessionBreakdownChart" aria-label="Net by player"></div>
</div>
</div>
<div class="panel session-insights-panel">
<div class="panel-header">
<div>
<p class="eyebrow">Quick read</p>
<h2>Session snapshot</h2>
<div class="panel col-side" style="display:flex;flex-direction:column;">
<div class="panel__head">
<span class="kicker" style="margin:0;">Quick read</span>
<h2 class="panel__title">Session snapshot</h2>
</div>
</div>
{% set winners = session.entries | selectattr('net_cents', 'gt', 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">
<div class="detail-row">
<span>Players</span>
<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">
<!-- Audit feed -->
<div class="panel" style="margin-top:20px;">
<div class="panel__head">
<div>
<p class="eyebrow">Audit trail</p>
<h2>Raw ledger events</h2>
<span class="kicker" style="margin:0;">Audit trail</span>
<h2 class="panel__title">Raw ledger events</h2>
</div>
<span class="badge badge--closed" style="font-size:9.5px;letter-spacing:.08em;">append-only</span>
</div>
<div class="event-feed">
<div class="event-feed feed feed--inline" style="max-height:none;">
{% for event in raw_events | reverse %}
<article class="event-card">
<div class="event-card event-card--inline">
<div class="event-card-top">
<strong>{{ event.player_name or "Session" }}</strong>
<span class="pill pill-{{ event.event_type }}">{{ event.event_type }}</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>
<p class="event-amount">{{ event.amount_cents | money }}</p>
{% if event.note %}
<p class="muted-text small">{{ event.note }}</p>
{% endif %}
<p class="tiny-text">{{ event.created_at }} · by {{ event.actor }}</p>
</article>
<p class="tiny-text" style="white-space:nowrap;">{{ event.created_at }}</p>
</div>
{% else %}
<p class="muted-text">No raw events recorded.</p>
<p class="muted-text" style="padding:20px;">No events recorded.</p>
{% endfor %}
</div>
</section>
</div>
<script>
const sessionChartData = {{ chart_data | tojson }};
const rootStyles = getComputedStyle(document.documentElement);
const textMuted = rootStyles.getPropertyValue('--text-muted').trim();
const lineColor = rootStyles.getPropertyValue('--line').trim();
const sessionChartData = {{ chart_data | tojson }};
const sessionBreakdownCtx = document.getElementById('sessionBreakdownChart');
if (sessionBreakdownCtx) {
new Chart(sessionBreakdownCtx, {
type: 'bar',
data: {
labels: sessionChartData.labels,
datasets: [{
label: 'Session Net',
data: sessionChartData.net_values,
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 breakdownCtx = document.getElementById('sessionBreakdownChart');
if (breakdownCtx) {
const values = sessionChartData.net_values || [];
const labels = sessionChartData.labels || [];
const colors = sessionChartData.net_colors || [];
const maxAbs = Math.max(...values.map(value => Math.abs(value)), 1);
const moneyFormatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
});
}
</script>
const escapeHtml = value => String(value).replace(/[&<>"']/g, char => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[char]));
<script>
const sessionTable = document.querySelector('.session-results-table');
const viewButtons = document.querySelectorAll('.table-view-toggle');
breakdownCtx.innerHTML = labels.map((label, index) => {
const value = Number(values[index] || 0);
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) {
sessionTable.classList.add('compact');
return `
<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) => {
button.addEventListener('click', () => {
const view = button.dataset.view;
viewButtons.forEach((btn) => btn.classList.remove('is-active'));
button.classList.add('is-active');
if (view === 'full') {
sessionTable.classList.remove('compact');
// Compact / Full toggle
const sessionTable = document.getElementById('sessionTable');
document.querySelectorAll('#viewToggle .seg').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('#viewToggle .seg').forEach(b => b.classList.remove('is-on'));
btn.classList.add('is-on');
if (btn.dataset.view === 'full') {
sessionTable.classList.add('is-full');
} else {
sessionTable.classList.add('compact');
sessionTable.classList.remove('is-full');
}
});
});
}
});
</script>
{% endblock %}
+32 -30
View File
@@ -1,52 +1,54 @@
{% extends "base.html" %}
{% block title %}Sessions · Poker Portal{% endblock %}
{% block title %}Sessions · boker.club{% endblock %}
{% block content %}
<section class="hero-card compact">
<div>
<p class="eyebrow">Session history</p>
<h1>Sessions</h1>
<p class="muted-text">Browse every recorded game night, inspect the table totals, and drill into each player result.</p>
</div>
</section>
<section class="panel">
<div class="panel-header">
<div class="hero">
<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>
<p class="eyebrow">Archive</p>
<h2>All sessions by date</h2>
<span class="kicker" style="margin:0;">Archive</span>
<h2 class="panel__title">All sessions</h2>
</div>
</div>
<div class="table-wrap">
<table>
<div class="tbl-scroll">
<table class="tbl" style="min-width:600px;">
<thead>
<tr>
<th>Date</th>
<th class="col-left">Date</th>
<th>Status</th>
<th>Players</th>
<th>Total Buy-ins</th>
<th>Total Cash-outs</th>
<th>Table Net</th>
<th>Invested</th>
<th>Cash out</th>
<th>Table net</th>
</tr>
</thead>
<tbody>
{% for session in sessions %}
<tr>
<td><a href="{{ url_for('session_detail', session_id=session.session_id) }}">{{ session_label(session) }}</a></td>
<td><span class="status-badge status-{{ session.status }}">
{{ session.status }}
</span></td>
<td>{{ session.entries|length }}</td>
<td>{{ session.total_buy_in_cents | money }}</td>
<td>{{ session.total_cash_out_cents | money }}</td>
<td class="{{ 'positive' if session.total_net_cents > 0 else 'negative' if session.total_net_cents < 0 else '' }}">{{ session.total_net_cents | money }}</td>
{% for s in sessions %}
<tr onclick="window.location='{{ url_for('session_detail', session_id=s.session_id) }}'">
<td class="col-left">
<a href="{{ url_for('session_detail', session_id=s.session_id) }}">{{ session_label(s) }}</a>
</td>
<td><span class="badge badge--{{ s.status }}">{{ s.status }}</span></td>
<td>{{ s.entries|length }}</td>
<td>{{ s.total_invested_cents | money }}</td>
<td>{{ s.total_cash_out_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>
{% else %}
<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>
{% endfor %}
</tbody>
</table>
</div>
</section>
</div>
{% 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()