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