visual overhaul + accounting corrections

This commit is contained in:
SowinskiBraeden committed 2026-06-22 13:51:38 -07:00
1 parent 1f1c5d2ecc
commit 5349130972
17 files changed
+1268 -389

No files matched your search

+1
View File
@@ -2,3 +2,4 @@
/__pycache__/* /__pycache__/*
/.venv/* /.venv/*
/data/* /data/*
test/*
+18 -12
View File
@@ -2,7 +2,7 @@
A small Flask app I put together for tracking our low-stakes hold'em nights without having to keep a spreadsheet open all the time. A small Flask app I put together for tracking our low-stakes hold'em nights without having to keep a spreadsheet open all the time.
The idea is pretty simple: public pages for stats and session history, plus a small admin area for recording buy-ins, cash-outs, paid amounts, and notes. The data sits in a CSV audit log, so everything is append-only and easy to follow later. The idea is pretty simple: public pages for stats and session history, plus a small admin area for recording buy-ins, gross cashout results, actual paid-out cash, and notes. The data sits in a CSV audit log, so everything is append-only and easy to follow later.
## What it does ## What it does
@@ -11,8 +11,8 @@ The idea is pretty simple: public pages for stats and session history, plus a sm
- per-player stat pages - per-player stat pages
- admin login for recording events - admin login for recording events
- open / closed session tracking - open / closed session tracking
- payout tracking with `paid` events - actual cash payout tracking with `paid_out` events
- front debt collections and write-offs with `front_collected` / `front_writeoff` events - debt repayments and write-offs with `debt_repayment` / `writeoff` events
- session and player charts - session and player charts
- CSV import / export from the admin page - CSV import / export from the admin page
- append-only `entries.csv` ledger instead of overwriting old rows - append-only `entries.csv` ledger instead of overwriting old rows
@@ -38,11 +38,12 @@ Current event types:
- `buyin` - `buyin`
- `front` - `front`
- `front_collected` - `debt_repayment`
- `front_writeoff` - `writeoff`
- `cashout` - `cashout`
- `paid` - `paid_out`
- `rollover_in` - `rollover_in`
- `payout_carry_in`
- `rollover_out` - `rollover_out`
- `note` - `note`
- `session_open` - `session_open`
@@ -52,9 +53,9 @@ A few examples:
- another `buyin` for a rebuy - another `buyin` for a rebuy
- another `cashout` if chip counts are corrected later - another `cashout` if chip counts are corrected later
- a `paid` event when someone is actually settled up - a `paid_out` event when someone is actually settled up
- a `front_collected` event when a front is repaid outside poker - a `debt_repayment` event when a front is repaid outside poker
- a `front_writeoff` event when a front will not be collected - a `writeoff` event when a front will not be collected
- 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
@@ -62,11 +63,16 @@ Accounting in the app keeps poker results separate from banker cashflow:
- poker investment is `buyin + front + rollover_in` - poker investment is `buyin + front + rollover_in`
- poker net is `cashout - poker investment` - poker net is `cashout - poker investment`
- real cash in is `buyin + front_collected` - real cash in is `buyin + debt_repayment`
- real cash out is `paid` - real cash out is `paid_out`
- `rollover_out` settles the source session without counting as cash out - `rollover_out` settles the source session without counting as cash out
- `rollover_in` enters play in the destination session without counting as cash in - `rollover_in` enters play in the destination session without counting as cash in
- `front_writeoff` resolves a receivable without counting as cash in - `payout_carry_in` records prior-session value carried into a later payout; it increases the destination payout due without counting as poker investment or cash in
- `writeoff` resolves a receivable without counting as cash in
Older ledgers may still contain `paid`, `front_collected`, or `front_writeoff`.
The app reads those historical names as aliases for `paid_out`,
`debt_repayment`, and `writeoff`.
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.
+198 -52
View File
@@ -31,7 +31,14 @@ from stats import (
session_sort_key, session_sort_key,
unique_player_names, unique_player_names,
) )
from storage import CSV_HEADERS, append_event, ensure_data_file, load_events from storage import (
CSV_HEADERS,
EventRow,
append_event,
ensure_data_file,
load_events,
write_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"
@@ -40,13 +47,15 @@ ENV_PATH = BASE_DIR / ".env"
ELIGIBLE_MIN_SESSIONS = 3 ELIGIBLE_MIN_SESSIONS = 3
ADMIN_EVENT_TYPES = [ ADMIN_EVENT_TYPES = [
("buyin", "Buy-in", "var(--accent)"), ("buyin", "Buy-in", "var(--accent)"),
("front", "Fronted", "var(--warn)"), ("front", "Front", "var(--warn)"),
("rollover_in", "Rollover-in", "var(--rolled)"), ("rollover_in", "Rollover-in", "var(--rolled)"),
("cashout", "Cash-out", "var(--rank-2)"), ("payout_carry_in", "Payout carry-in", "var(--rolled)"),
("paid", "Paid-out", "var(--pos)"), ("cashout", "Cashout result", "var(--rank-2)"),
("paid_out", "Paid out cash", "var(--pos)"),
("rollover_out", "Rollover-out", "var(--rolled)"), ("rollover_out", "Rollover-out", "var(--rolled)"),
("note", "Note", "var(--muted)"), ("note", "Note", "var(--muted)"),
] ]
SESSION_MARKER_TYPES = {"session_open", "session_close"}
def load_local_env(env_path: Path) -> None: def load_local_env(env_path: Path) -> None:
@@ -80,6 +89,54 @@ def next_session_id(sessions, session_date: str) -> str:
return f"{session_date}-{highest + 1:02d}" return f"{session_date}-{highest + 1:02d}"
def prunable_empty_session_ids(events: list[EventRow], sessions) -> set[str]:
empty_session_ids = {
session.session_id for session in sessions if len(session.entries) == 0
}
session_events: dict[str, list[EventRow]] = {}
for event in events:
session_id = event["session_id"].strip() or event["session_date"].strip()
if session_id in empty_session_ids:
session_events.setdefault(session_id, []).append(event)
return {
session_id
for session_id, rows in session_events.items()
if rows
and all(
row["event_type"] in SESSION_MARKER_TYPES
and not row["player_name"].strip()
and row["amount_cents"] == 0
for row in rows
)
}
def pending_payout_carry_items(sessions) -> list[dict[str, object]]:
carried_out: dict[str, int] = {}
carried_in: dict[str, int] = {}
for session_summary in sessions:
for entry in session_summary.entries:
carried_out[entry.player_name] = (
carried_out.get(entry.player_name, 0) + entry.rollover_out_cents
)
carried_in[entry.player_name] = (
carried_in.get(entry.player_name, 0)
+ entry.rollover_in_cents
+ entry.payout_carry_in_cents
)
items = []
for player_name, amount_out in carried_out.items():
pending_cents = amount_out - carried_in.get(player_name, 0)
if pending_cents > 0:
items.append({"player_name": player_name, "amount_cents": pending_cents})
return sorted(items, key=lambda item: str(item["player_name"]).casefold())
load_local_env(ENV_PATH) load_local_env(ENV_PATH)
app = Flask(__name__) app = Flask(__name__)
@@ -219,33 +276,24 @@ def session_detail(session_id: str) -> str:
flash("That session was not found.", "error") flash("That session was not found.", "error")
return redirect(url_for("sessions")) return redirect(url_for("sessions"))
target_index = next(
(
index
for index, session in enumerate(sessions)
if session.session_id == session_id
),
None,
)
if target_index is None:
flash("That session was not found.", "error")
return redirect(url_for("sessions"))
target_session = sessions[target_index]
chronological_sessions = sorted(sessions, key=session_sort_key) chronological_sessions = sorted(sessions, key=session_sort_key)
session_number = next( chronological_index = next(
( index
index for index, session in enumerate(chronological_sessions)
for index, session in enumerate(chronological_sessions, start=1) if session.session_id == session_id
if session.session_id == session_id
),
1,
) )
target_session = chronological_sessions[chronological_index]
session_number = chronological_index + 1
prev_session = ( prev_session = (
sessions[target_index + 1] if target_index < len(sessions) - 1 else None chronological_sessions[chronological_index - 1]
if chronological_index > 0
else None
)
next_session = (
chronological_sessions[chronological_index + 1]
if chronological_index < len(chronological_sessions) - 1
else None
) )
next_session = sessions[target_index - 1] if target_index > 0 else None
return render_template( return render_template(
"session_detail.html", "session_detail.html",
@@ -478,7 +526,7 @@ def admin_write_off_front() -> str:
) )
if entry is None or entry.player_owes_cents <= 0: if entry is None or entry.player_owes_cents <= 0:
flash("That player does not have an outstanding front debt.", "error") flash("That player does not have an outstanding debt.", "error")
return redirect(url_for("admin_dashboard")) return redirect(url_for("admin_dashboard"))
if amount_cents > entry.player_owes_cents: if amount_cents > entry.player_owes_cents:
@@ -493,13 +541,13 @@ def admin_write_off_front() -> str:
session_id=target.session_id, session_id=target.session_id,
session_date=target.session_date, session_date=target.session_date,
player_name=entry.player_name, player_name=entry.player_name,
event_type="front_writeoff", event_type="writeoff",
amount_cents=amount_cents, amount_cents=amount_cents,
note=note or "Front debt written off.", note=note or "Debt written off.",
actor=app.config["ADMIN_USERNAME"], actor=app.config["ADMIN_USERNAME"],
) )
flash("Front debt written off.", "success") flash("Debt written off.", "success")
return redirect(url_for("admin_dashboard")) return redirect(url_for("admin_dashboard"))
@@ -546,7 +594,7 @@ def admin_collect_front() -> str:
) )
if entry is None or entry.player_owes_cents <= 0: if entry is None or entry.player_owes_cents <= 0:
flash("That player does not have an outstanding front debt.", "error") flash("That player does not have an outstanding debt.", "error")
return redirect(url_for("admin_dashboard")) return redirect(url_for("admin_dashboard"))
if amount_cents > entry.player_owes_cents: if amount_cents > entry.player_owes_cents:
@@ -561,13 +609,13 @@ def admin_collect_front() -> str:
session_id=target.session_id, session_id=target.session_id,
session_date=target.session_date, session_date=target.session_date,
player_name=entry.player_name, player_name=entry.player_name,
event_type="front_collected", event_type="debt_repayment",
amount_cents=amount_cents, amount_cents=amount_cents,
note=note or "Front debt collected.", note=note or "Debt repayment collected.",
actor=app.config["ADMIN_USERNAME"], actor=app.config["ADMIN_USERNAME"],
) )
flash("Front debt collected.", "success") flash("Debt repayment collected.", "success")
return redirect(url_for("admin_dashboard")) return redirect(url_for("admin_dashboard"))
@@ -601,6 +649,86 @@ def admin_open_session() -> str:
return redirect(url_for("admin_dashboard")) return redirect(url_for("admin_dashboard"))
@app.post("/admin/prune-empty-sessions")
def admin_prune_empty_sessions() -> str:
if not is_admin():
flash("Admin login required.", "error")
return redirect(url_for("admin_login"))
events = load_events(DATA_PATH)
sessions = build_session_summaries(events)
prunable_session_ids = prunable_empty_session_ids(events, sessions)
if not prunable_session_ids:
flash("No empty marker-only sessions to prune.", "success")
return redirect(url_for("admin_dashboard"))
pruned_events = [
event
for event in events
if (event["session_id"].strip() or event["session_date"].strip())
not in prunable_session_ids
]
write_events(DATA_PATH, pruned_events)
flash(
f"Pruned {len(prunable_session_ids)} empty session"
f"{'s' if len(prunable_session_ids) != 1 else ''}.",
"success",
)
return redirect(url_for("admin_dashboard"))
@app.post("/admin/apply-payout-carry-in")
def admin_apply_payout_carry_in() -> str:
if not is_admin():
flash("Admin login required.", "error")
return redirect(url_for("admin_login"))
session_id = request.form.get("session_id", "").strip()
player_name = request.form.get("player_name", "").strip()
events = load_events(DATA_PATH)
sessions = build_session_summaries(events)
by_id = {session.session_id: session for session in sessions}
target = by_id.get(session_id)
if target is None or target.status != "open":
flash("Select a valid open session.", "error")
return redirect(url_for("admin_dashboard"))
if not any(entry.player_name == player_name for entry in target.entries):
flash("Add that player to the open session before applying carry-in.", "error")
return redirect(url_for("admin_dashboard"))
pending_by_player = {
str(item["player_name"]): int(item["amount_cents"])
for item in pending_payout_carry_items(sessions)
}
amount_cents = pending_by_player.get(player_name, 0)
if amount_cents <= 0:
flash("No pending payout carry-in found for that player.", "error")
return redirect(url_for("admin_dashboard"))
append_event(
DATA_PATH,
session_id=target.session_id,
session_date=target.session_date,
player_name=player_name,
event_type="payout_carry_in",
amount_cents=amount_cents,
note="Payout carry-in from prior carry-out.",
actor=app.config["ADMIN_USERNAME"],
)
flash(
f"Applied {cents_to_dollars(amount_cents)} payout carry-in for {player_name}.",
"success",
)
return redirect(url_for("admin_dashboard"))
@app.route("/admin", methods=["GET", "POST"]) @app.route("/admin", methods=["GET", "POST"])
def admin_dashboard(): def admin_dashboard():
if not is_admin(): if not is_admin():
@@ -658,8 +786,18 @@ def admin_dashboard():
events = load_events(DATA_PATH) events = load_events(DATA_PATH)
sessions = build_session_summaries(events) sessions = build_session_summaries(events)
empty_session_count = len(prunable_empty_session_ids(events, sessions))
pending_carry_items = pending_payout_carry_items(sessions)
pending_carry_total_cents = sum(
int(item["amount_cents"]) for item in pending_carry_items
)
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 = open_sessions[0] if open_sessions else None
live_session_player_names = (
{entry.player_name for entry in live_session.entries}
if live_session is not None
else set()
)
live_session_no = "" live_session_no = ""
live_unresolved_count = 0 live_unresolved_count = 0
if live_session is not None: if live_session is not None:
@@ -670,7 +808,7 @@ def admin_dashboard():
if entry.current_due_cents > 0 or entry.player_owes_cents > 0 if entry.current_due_cents > 0 or entry.player_owes_cents > 0
) )
recent_sessions = sessions[:8] recent_sessions = sessions
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_colors = {value: color for value, _label, color in ADMIN_EVENT_TYPES}
event_type_labels = {value: label for value, label, _color in ADMIN_EVENT_TYPES} event_type_labels = {value: label for value, label, _color in ADMIN_EVENT_TYPES}
@@ -689,29 +827,34 @@ def admin_dashboard():
if entry.player_owes_cents > 0 if entry.player_owes_cents > 0
] ]
cash_in_cents = sum(session.total_banker_cash_in_cents for session in sessions)
cash_out_cents = sum(session.total_banker_cash_out_cents for session in sessions)
house_owes_players_cents = (
sum(session.total_current_due_to_player_cents for session in sessions)
+ pending_carry_total_cents
)
players_owe_house_cents = sum(
session.total_current_due_to_house_cents for session in sessions
)
admin_totals = { admin_totals = {
"cash_in_cents": sum(session.total_cash_in_cents for session in sessions), "cash_in_cents": cash_in_cents,
"cash_out_cents": sum(session.total_paid_out_cents for session in sessions), "cash_out_cents": cash_out_cents,
"house_owes_players_cents": sum( "house_owes_players_cents": house_owes_players_cents,
session.total_current_due_to_player_cents for session in sessions "players_owe_house_cents": players_owe_house_cents,
), "pending_carry_cents": pending_carry_total_cents,
"players_owe_house_cents": sum(
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_debt_repayment_cents for session in sessions
), ),
"written_off_cents": sum( "written_off_cents": sum(
session.total_front_writeoff_cents for session in sessions session.total_writeoff_cents for session in sessions
), ),
"net_book_position_cents": sum( "net_book_position_cents": (
session.total_net_book_position_cents for session in sessions cash_in_cents
- cash_out_cents
+ players_owe_house_cents
- house_owes_players_cents
), ),
} }
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",
@@ -720,8 +863,10 @@ def admin_dashboard():
recent_events=recent_events, recent_events=recent_events,
debt_entries=debt_entries, debt_entries=debt_entries,
live_session=live_session, live_session=live_session,
live_session_player_names=live_session_player_names,
live_session_no=live_session_no, live_session_no=live_session_no,
live_unresolved_count=live_unresolved_count, live_unresolved_count=live_unresolved_count,
pending_carry_items=pending_carry_items,
player_names=unique_player_names(events), player_names=unique_player_names(events),
append_form=append_form, append_form=append_form,
event_types=ADMIN_EVENT_TYPES, event_types=ADMIN_EVENT_TYPES,
@@ -730,6 +875,7 @@ def admin_dashboard():
admin_today=datetime.now().date().isoformat(), admin_today=datetime.now().date().isoformat(),
session_label=session_label, session_label=session_label,
admin_totals=admin_totals, admin_totals=admin_totals,
empty_session_count=empty_session_count,
) )
+341 -39
View File
@@ -109,6 +109,13 @@ h1, h2, h3, h4 { margin: 0; }
.page--public { max-width: var(--w-public); } .page--public { max-width: var(--w-public); }
.page--session { max-width: var(--w-session); } .page--session { max-width: var(--w-session); }
.page--admin { max-width: var(--w-admin); } .page--admin { max-width: var(--w-admin); }
@media (min-width: 1280px) {
.page--admin,
.page--session {
padding-left: 28px;
padding-right: 28px;
}
}
/* ================================================================ /* ================================================================
FLASH MESSAGES FLASH MESSAGES
@@ -198,7 +205,8 @@ h3 {
} }
.session-number { .session-number {
color: var(--accent); color: var(--accent);
margin-bottom: 8px; font-size: 12.5px;
margin-bottom: 9px;
} }
.hero__title { .hero__title {
font-family: var(--font-display); font-family: var(--font-display);
@@ -284,10 +292,10 @@ h3 {
} }
.stat--hero .stat__val { font-size: 34px; } .stat--hero .stat__val { font-size: 34px; }
.player-rank { .player-rank {
margin: 0 0 4px; margin: 0 0 7px;
color: var(--accent); color: var(--accent);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 11px; font-size: 12.5px;
letter-spacing: .12em; letter-spacing: .12em;
text-transform: uppercase; text-transform: uppercase;
} }
@@ -521,10 +529,15 @@ h3 {
.tbl .col-acct { display: none; } .tbl .col-acct { display: none; }
.tbl.is-full .col-acct { display: table-cell; } .tbl.is-full .col-acct { display: table-cell; }
.tbl--session { .tbl--session {
min-width: 920px; min-width: 860px;
font-size: 13.5px;
} }
.tbl--session.is-full { .tbl--session.is-full {
min-width: 1240px; min-width: 1180px;
}
.tbl--session th,
.tbl--session td {
padding-inline: 12px;
} }
.tbl--session th:nth-child(7), .tbl--session th:nth-child(7),
.tbl--session td:nth-child(7) { .tbl--session td:nth-child(7) {
@@ -616,6 +629,7 @@ td a:hover { color: var(--accent); }
white-space: nowrap; white-space: nowrap;
} }
.badge--paid { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); } .badge--paid { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); }
.badge--settled { color: var(--rolled); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); }
.badge--partial { color: var(--warn); background: var(--warn-tint); box-shadow: inset 0 0 0 1px var(--warn-tint-bd); } .badge--partial { color: var(--warn); background: var(--warn-tint); box-shadow: inset 0 0 0 1px var(--warn-tint-bd); }
.badge--unpaid { color: var(--neg); background: var(--neg-tint); box-shadow: inset 0 0 0 1px var(--neg-tint-bd); } .badge--unpaid { color: var(--neg); background: var(--neg-tint); box-shadow: inset 0 0 0 1px var(--neg-tint-bd); }
.badge--owes { color: var(--owes); background: var(--owes-tint); box-shadow: inset 0 0 0 1px var(--owes-tint-bd); } .badge--owes { color: var(--owes); background: var(--owes-tint); box-shadow: inset 0 0 0 1px var(--owes-tint-bd); }
@@ -641,6 +655,7 @@ td a:hover { color: var(--accent); }
.status-open { color: var(--warn); background: var(--warn-tint); box-shadow: inset 0 0 0 1px var(--warn-tint-bd); } .status-open { color: var(--warn); background: var(--warn-tint); box-shadow: inset 0 0 0 1px var(--warn-tint-bd); }
.status-closed { color: var(--muted-2); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); } .status-closed { color: var(--muted-2); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); }
.status-paid { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); } .status-paid { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); }
.status-settled { color: var(--rolled); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); }
.status-partial { color: var(--warn); background: var(--warn-tint); box-shadow: inset 0 0 0 1px var(--warn-tint-bd); } .status-partial { color: var(--warn); background: var(--warn-tint); box-shadow: inset 0 0 0 1px var(--warn-tint-bd); }
.status-unpaid { color: var(--neg); background: var(--neg-tint); box-shadow: inset 0 0 0 1px var(--neg-tint-bd); } .status-unpaid { color: var(--neg); background: var(--neg-tint); box-shadow: inset 0 0 0 1px var(--neg-tint-bd); }
.status-owes { color: var(--owes); background: var(--owes-tint); box-shadow: inset 0 0 0 1px var(--owes-tint-bd); } .status-owes { color: var(--owes); background: var(--owes-tint); box-shadow: inset 0 0 0 1px var(--owes-tint-bd); }
@@ -665,7 +680,11 @@ td a:hover { color: var(--accent); }
.pill-cashout { color: var(--rank-2); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); } .pill-cashout { color: var(--rank-2); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); }
.pill-paid { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); } .pill-paid { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); }
.pill-rollover_in { color: var(--rolled); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); } .pill-rollover_in { color: var(--rolled); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); }
.pill-payout_carry_in { color: var(--rolled); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); }
.pill-rollover_out { color: var(--rolled); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); } .pill-rollover_out { color: var(--rolled); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); }
.pill-paid_out { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); }
.pill-debt_repayment { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); }
.pill-writeoff { color: var(--neg); background: var(--neg-tint); box-shadow: inset 0 0 0 1px var(--neg-tint-bd); }
.pill-front_collected { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); } .pill-front_collected { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); }
.pill-front_writeoff { color: var(--neg); background: var(--neg-tint); box-shadow: inset 0 0 0 1px var(--neg-tint-bd); } .pill-front_writeoff { color: var(--neg); background: var(--neg-tint); box-shadow: inset 0 0 0 1px var(--neg-tint-bd); }
.pill-note { color: var(--muted-2); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); } .pill-note { color: var(--muted-2); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); }
@@ -779,10 +798,19 @@ td a:hover { color: var(--accent); }
gap: 6px; gap: 6px;
} }
.result-mix__legend-row strong { .result-mix__legend-row strong {
display: grid;
grid-template-columns: minmax(2ch, auto) 6ch;
gap: 8px;
color: var(--text-2); color: var(--text-2);
font-weight: 600; font-weight: 600;
text-align: right; text-align: right;
white-space: nowrap; white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.result-mix__legend-row strong b,
.result-mix__legend-row strong em {
font: inherit;
text-align: right;
} }
.result-mix__key { .result-mix__key {
width: 8px; width: 8px;
@@ -887,17 +915,35 @@ td a:hover { color: var(--accent); }
} }
.event-card--inline { .event-card--inline {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto auto; grid-template-columns: minmax(82px, 120px) max-content minmax(0, 1fr) minmax(88px, auto) minmax(74px, auto);
gap: 10px; gap: 12px;
align-items: center;
padding: 9px 16px; padding: 9px 16px;
} }
.event-card--inline .event-card-top { .event-card--inline .event-card-top {
gap: 8px; display: contents;
}
.event-card--inline .event-card-top strong {
grid-column: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
} }
.event-card--inline .pill { .event-card--inline .pill {
grid-column: 2;
justify-self: start;
padding: 2px 7px; padding: 2px 7px;
font-size: 9.5px; font-size: 9.5px;
} }
.event-card--inline .event-note {
grid-column: 3;
}
.event-card--inline .event-amount {
grid-column: 4;
}
.event-card--inline .tiny-text {
grid-column: 5;
}
.event-note { .event-note {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
@@ -906,10 +952,10 @@ td a:hover { color: var(--accent); }
color: var(--muted); color: var(--muted);
font-size: 12.5px; font-size: 12.5px;
} }
.event-amount { font-family: var(--font-mono); font-size: 13px; font-weight: 600; color: var(--num); margin: 0; white-space: nowrap; } .event-amount { font-family: var(--font-mono); font-size: 13px; font-weight: 600; color: var(--num); margin: 0; white-space: nowrap; text-align: right; }
.muted-text { color: var(--muted); font-size: 13.5px; margin: 0; } .muted-text { color: var(--muted); font-size: 13.5px; margin: 0; }
.muted-text.small, .tiny-text { font-size: 12px; } .muted-text.small, .tiny-text { font-size: 12px; }
.tiny-text { color: var(--faintest); } .tiny-text { color: var(--faintest); margin: 0; text-align: right; white-space: nowrap; }
.note-list { margin: 0; padding-left: 16px; color: var(--muted); font-size: 13px; } .note-list { margin: 0; padding-left: 16px; color: var(--muted); font-size: 13px; }
.note-list li + li { margin-top: 4px; } .note-list li + li { margin-top: 4px; }
@@ -971,6 +1017,10 @@ td a:hover { color: var(--accent); }
border-color: var(--border-hi); border-color: var(--border-hi);
color: var(--text-strong); color: var(--text-strong);
} }
.secondary-button:disabled, .ghost-button:disabled {
cursor: not-allowed;
opacity: .45;
}
.table-action-button { .table-action-button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1028,20 +1078,17 @@ td a:hover { color: var(--accent); }
/* Admin workspace */ /* Admin workspace */
.admin-workspace { .admin-workspace {
display: grid; display: grid;
grid-template-columns: minmax(0, 2fr) minmax(300px, 1fr); grid-template-columns: minmax(280px, 1fr) minmax(280px, 1fr) minmax(260px, .9fr);
gap: var(--s4); gap: var(--s4);
align-items: flex-start; align-items: stretch;
} }
.admin-workspace__main { flex: 2; min-width: 360px; } .admin-workspace > .panel {
.admin-workspace__side { flex: 1; min-width: 300px; }
.admin-workspace .col-main,
.admin-workspace .col-side {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--s4);
min-width: 0; min-width: 0;
} }
.admin-bottom-grid { .admin-bottom-grid {
--admin-bottom-body-h: 340px;
display: grid; display: grid;
grid-template-columns: minmax(0, 2fr) minmax(300px, 1fr); grid-template-columns: minmax(0, 2fr) minmax(300px, 1fr);
gap: var(--s4); gap: var(--s4);
@@ -1049,19 +1096,22 @@ td a:hover { color: var(--accent); }
margin-top: var(--s4); margin-top: var(--s4);
} }
.admin-bottom-grid > .panel { .admin-bottom-grid > .panel {
min-width: 0;
}
.admin-bottom-grid > .panel:last-child {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-width: 0;
} }
.admin-bottom-grid > .panel:last-child .event-feed { .admin-bottom-grid .table-wrap,
flex: 1; .admin-bottom-grid .event-feed {
align-content: start; height: var(--admin-bottom-body-h);
max-height: none; min-height: 0;
overflow-y: auto; overflow-y: auto;
} }
.admin-bottom-grid .table-wrap {
overflow-x: auto;
}
.admin-bottom-grid .event-feed {
align-content: start;
}
/* Cash position strip */ /* Cash position strip */
.cash-strip { .cash-strip {
@@ -1144,9 +1194,97 @@ td a:hover { color: var(--accent); }
.section-marker__dot--rolled { background: var(--rolled); } .section-marker__dot--rolled { background: var(--rolled); }
/* Debt / admin tools */ /* Debt / admin tools */
.debt-panel { display: grid; gap: 18px; } .debt-panel {
display: grid;
gap: 0;
margin-top: var(--s4);
}
.debt-summary { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .debt-summary { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.debt-action-grid { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); } .debt-layout {
display: grid;
grid-template-columns: minmax(240px, .85fr) minmax(0, 2fr);
gap: 16px;
padding: 16px 20px 18px;
}
.debt-list {
display: grid;
align-content: start;
gap: 8px;
max-height: 245px;
overflow-y: auto;
}
.debt-list-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 10px 12px;
background: var(--surface-sunk);
border: 1px solid var(--border);
border-radius: var(--r-sm);
}
.debt-list-item strong {
display: block;
color: var(--text-strong);
font-size: 13.5px;
margin-bottom: 2px;
}
.debt-list-item span {
display: block;
color: var(--faintest);
font-family: var(--font-mono);
font-size: 10.5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.debt-list-item b {
color: var(--owes);
font-family: var(--font-mono);
font-size: 13px;
white-space: nowrap;
}
.debt-empty {
display: grid;
place-items: center;
gap: 8px;
padding: 30px 20px 34px;
color: var(--muted);
text-align: center;
}
.debt-empty__mark {
display: grid;
place-items: center;
min-width: 64px;
height: 40px;
padding: 0 14px;
border-radius: var(--r-pill);
background: var(--surface-sunk);
border: 1px solid var(--border);
color: var(--pos);
font-family: var(--font-mono);
font-weight: 700;
}
.debt-empty p {
margin: 0;
font-size: 13.5px;
}
.debt-action-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(2, minmax(260px, 1fr));
}
.debt-form {
gap: 10px;
padding: 0;
background: transparent;
}
.debt-form h3 {
margin-bottom: 2px;
}
.debt-form textarea {
min-height: 58px;
}
.import-form input[type="file"] { width: 100%; margin: 4px 0 8px; color: var(--muted); } .import-form input[type="file"] { width: 100%; margin: 4px 0 8px; color: var(--muted); }
.import-form input[type="file"]::file-selector-button { .import-form input[type="file"]::file-selector-button {
appearance: none; appearance: none;
@@ -1165,12 +1303,17 @@ td a:hover { color: var(--accent); }
} }
/* Admin: append ledger event */ /* Admin: append ledger event */
.append__body { padding: 20px 22px; } .append {
min-width: 0;
}
.append__body {
padding: 18px 20px;
}
.append__grid { .append__grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 14px; gap: 12px;
margin-bottom: 16px; margin-bottom: 14px;
} }
.field { display: block; } .field { display: block; }
.field__label { .field__label {
@@ -1216,7 +1359,13 @@ select.control { cursor: pointer; }
border-color: var(--accent-chip-bd); border-color: var(--accent-chip-bd);
box-shadow: 0 0 0 3px var(--accent-a22); box-shadow: 0 0 0 3px var(--accent-a22);
} }
.append__note { margin-bottom: 18px; } .append__note {
margin-bottom: 14px;
}
.append__textarea {
min-height: 96px;
resize: vertical;
}
.append__footer { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; } .append__footer { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.append__preview { display: inline-flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--faint); } .append__preview { display: inline-flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--faint); }
.evtag-pill { .evtag-pill {
@@ -1231,9 +1380,44 @@ select.control { cursor: pointer; }
} }
/* Admin: session controls */ /* Admin: session controls */
.panel--pad { padding: 20px 22px; } .panel--pad { padding: 18px 20px; }
.panel__title--mb { margin: 0 0 16px; } .panel__title--mb { margin: 0 0 16px; }
.session-control-panel {
min-width: 0;
}
.session-summary-strip {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
overflow: hidden;
margin-bottom: 14px;
border: 1px solid var(--border);
border-radius: var(--r-sm);
background: var(--border);
}
.session-summary-strip > div {
min-width: 0;
padding: 9px 10px;
background: var(--surface-sunk);
}
.session-summary-strip strong {
display: block;
margin-top: 3px;
color: var(--text-strong);
font-family: var(--font-mono);
font-size: 12.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.import-export-panel {
min-width: 0;
}
.livecard { .livecard {
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 140px;
padding: 15px 16px; padding: 15px 16px;
background: var(--surface-sunk); background: var(--surface-sunk);
border: 1px solid var(--border-strong); border: 1px solid var(--border-strong);
@@ -1243,6 +1427,7 @@ select.control { cursor: pointer; }
.livecard__head { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; } .livecard__head { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
.dot { width: 7px; height: 7px; border-radius: 50%; } .dot { width: 7px; height: 7px; border-radius: 50%; }
.dot--live { background: var(--pos); animation: livepulse 1.6s ease-in-out infinite; } .dot--live { background: var(--pos); animation: livepulse 1.6s ease-in-out infinite; }
.dot--idle { background: var(--muted-2); }
.livecard__name { font-weight: 600; color: var(--text-strong); font-size: 14px; } .livecard__name { font-weight: 600; color: var(--text-strong); font-size: 14px; }
.livecard__no { font-family: var(--font-mono); font-size: 10.5px; color: var(--faintest); } .livecard__no { font-family: var(--font-mono); font-size: 10.5px; color: var(--faintest); }
.livecard__metrics { display: flex; gap: 18px; margin-bottom: 14px; } .livecard__metrics { display: flex; gap: 18px; margin-bottom: 14px; }
@@ -1292,14 +1477,68 @@ select.control { cursor: pointer; }
box-shadow: 0 4px 16px var(--accent-a30); box-shadow: 0 4px 16px var(--accent-a30);
} }
.btn-newsession--primary:hover { color: var(--accent-ink); opacity: .88; } .btn-newsession--primary:hover { color: var(--accent-ink); opacity: .88; }
.prune-session-form {
display: flex;
justify-content: center;
padding-top: 12px;
}
.prune-session-form .secondary-button {
justify-content: center;
}
.carry-recs {
display: grid;
gap: 8px;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--divider);
}
.carry-rec {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 10px;
align-items: center;
padding: 8px 10px;
background: var(--surface-sunk);
border: 1px solid var(--border);
border-radius: var(--r-sm);
}
.carry-rec span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-strong);
font-size: 13px;
font-weight: 600;
}
.carry-rec strong {
color: var(--rolled);
font-family: var(--font-mono);
font-size: 12.5px;
white-space: nowrap;
}
.carry-rec form {
margin: 0;
}
.carry-rec em {
color: var(--faintest);
font-size: 11.5px;
font-style: normal;
white-space: nowrap;
}
/* Admin: CSV tools */ /* Admin: CSV tools */
.csv__desc { margin: 0 0 16px; font-size: 12.5px; color: var(--faint); } .csv__desc { margin: 0 0 12px; font-size: 12.5px; color: var(--faint); }
.import-form {
display: flex;
flex: 1;
flex-direction: column;
}
.csv__btns { display: flex; gap: 10px; margin-bottom: 12px; } .csv__btns { display: flex; gap: 10px; margin-bottom: 12px; }
.csv-btn-ghost { .csv-btn-ghost {
flex: 1; flex: 1;
text-align: center; text-align: center;
padding: 10px; padding: 9px 10px;
border-radius: 8px; border-radius: 8px;
background: var(--field); background: var(--field);
border: 1px solid var(--border-strong); border: 1px solid var(--border-strong);
@@ -1310,8 +1549,11 @@ select.control { cursor: pointer; }
} }
.csv-btn-ghost:hover { border-color: var(--border-hi); } .csv-btn-ghost:hover { border-color: var(--border-hi); }
.csv__drop { .csv__drop {
display: block; display: grid;
padding: 18px; flex: 1;
min-height: 112px;
place-items: center;
padding: 18px 14px;
border: 1px dashed var(--border-strong); border: 1px dashed var(--border-strong);
border-radius: 9px; border-radius: 9px;
text-align: center; text-align: center;
@@ -1326,17 +1568,65 @@ select.control { cursor: pointer; }
.form-shell { display: grid; justify-content: center; padding-top: 40px; } .form-shell { display: grid; justify-content: center; padding-top: 40px; }
.form-shell.narrow { grid-template-columns: minmax(320px, 480px); } .form-shell.narrow { grid-template-columns: minmax(320px, 480px); }
/* Modal */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
padding: 20px;
background: rgba(5, 5, 8, .68);
backdrop-filter: blur(5px);
}
.modal-backdrop[hidden] { display: none; }
.modal-card {
width: min(100%, 440px);
overflow: hidden;
border-radius: var(--r-md);
background: var(--surface);
border: 1px solid var(--border-strong);
box-shadow: 0 24px 70px rgba(0, 0, 0, .45);
}
.modal-card__head {
padding: 18px 20px 0;
}
.modal-card__text {
margin: 0;
padding: 14px 20px 18px;
color: var(--muted);
font-size: 13.5px;
line-height: 1.5;
}
.modal-card__actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px 20px;
border-top: 1px solid var(--divider);
background: var(--surface-sunk);
flex-wrap: wrap;
}
/* ================================================================ /* ================================================================
RESPONSIVE RESPONSIVE
================================================================ */ ================================================================ */
@media (max-width: 860px) { @media (max-width: 860px) {
.split > .col-main, .split > .col-side { min-width: 100%; } .split > .col-main, .split > .col-side { min-width: 100%; }
.admin-workspace__main, .admin-workspace__side { min-width: 100%; }
.admin-workspace { grid-template-columns: 1fr; } .admin-workspace { grid-template-columns: 1fr; }
.admin-workspace .col-main, .admin-workspace .col-side { min-width: 0; } .append__textarea { min-height: 120px; }
.debt-layout {
grid-template-columns: 1fr;
}
.admin-bottom-grid { grid-template-columns: 1fr; } .admin-bottom-grid { grid-template-columns: 1fr; }
} }
@media (min-width: 861px) and (max-width: 1180px) {
.admin-workspace {
grid-template-columns: repeat(2, minmax(280px, 1fr));
}
}
@media (max-width: 1180px) { @media (max-width: 1180px) {
.player-stat-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } .player-stat-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
} }
@@ -1351,7 +1641,19 @@ select.control { cursor: pointer; }
.recon-metric-grid { grid-template-columns: 1fr; } .recon-metric-grid { grid-template-columns: 1fr; }
.player-stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .player-stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.event-card--inline { .event-card--inline {
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) max-content auto;
}
.event-card--inline .event-card-top strong {
grid-column: 1;
}
.event-card--inline .pill {
grid-column: 2;
}
.event-card--inline .event-amount {
grid-column: 3;
}
.event-card--inline .event-note {
grid-column: 1 / -1;
} }
.event-card--inline .tiny-text { .event-card--inline .tiny-text {
grid-column: 1 / -1; grid-column: 1 / -1;
+162 -62
View File
@@ -28,6 +28,18 @@ PLAYER_PALETTE = [
BREAK_EVEN_TOLERANCE_CENTS = 100 BREAK_EVEN_TOLERANCE_CENTS = 100
def session_sequence_number(session_id: str, session_date: str) -> int:
suffix = session_id.strip().replace(f"{session_date.strip()}-", "", 1)
if session_id.strip() == session_date.strip():
return 1
if suffix.isdigit():
return int(suffix)
if suffix.lower().startswith("s") and suffix[1:].isdigit():
return int(suffix[1:])
return 9999
@dataclass @dataclass
class SessionEntry: class SessionEntry:
session_id: str session_id: str
@@ -40,19 +52,32 @@ class SessionEntry:
cash_out_cents: int = 0 cash_out_cents: int = 0
paid_cents: int = 0 paid_cents: int = 0
rollover_in_cents: int = 0 rollover_in_cents: int = 0
payout_carry_in_cents: int = 0
rollover_out_cents: int = 0 rollover_out_cents: int = 0
notes: list[str] = field(default_factory=list) notes: list[str] = field(default_factory=list)
@property @property
def invested_cents(self) -> int: def invested_cents(self) -> int:
# Poker performance: everything that entered play, including house fronts # Poker performance: money/chips that entered play. This is intentionally
# and prior unpaid winnings rolled into this session. # broader than real cash because fronts and rollover-ins affect results.
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 paid_out_cents(self) -> int:
return self.paid_cents
@property
def debt_repayment_cents(self) -> int:
return self.front_collected_cents
@property
def writeoff_cents(self) -> int:
return self.front_writeoff_cents
@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 # Settlement: cashout is a gross chip count/result, not proof that cash
@@ -64,33 +89,42 @@ class SessionEntry:
def player_owes_gross_cents(self) -> int: def player_owes_gross_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 gross_due_to_house_cents(self) -> int:
return self.player_owes_gross_cents
@property @property
def settled_to_player_cents(self) -> int: def settled_to_player_cents(self) -> int:
# Rollover-out resolves the source session's payable even though it is # Rollover-out resolves the source session's payable even though it is
# not real cash out. The destination session records it as rollover-in. # not real cash out. The destination session records it as rollover-in.
return self.paid_cents + self.rollover_out_cents return self.paid_out_cents + self.rollover_out_cents
@property @property
def current_due_to_player_cents(self) -> int: def current_due_to_player_cents(self) -> int:
return max(self.gross_payout_cents - self.settled_to_player_cents, 0) return max(
self.gross_payout_cents
+ self.payout_carry_in_cents
- self.settled_to_player_cents,
0,
)
@property @property
def settled_to_house_cents(self) -> int: def settled_to_house_cents(self) -> int:
# Front collections are real cash in. Writeoffs are not cash, but they # Front collections are real cash in. Writeoffs are not cash, but they
# do resolve the receivable. # do resolve the receivable.
return self.front_collected_cents + self.front_writeoff_cents return self.debt_repayment_cents + self.writeoff_cents
@property @property
def current_due_to_house_cents(self) -> int: def current_due_to_house_cents(self) -> int:
return max(self.player_owes_gross_cents - self.settled_to_house_cents, 0) return max(self.gross_due_to_house_cents - self.settled_to_house_cents, 0)
@property @property
def real_cash_in_cents(self) -> int: def real_cash_in_cents(self) -> int:
return self.buy_in_cents + self.front_collected_cents return self.buy_in_cents + self.debt_repayment_cents
@property @property
def real_cash_out_cents(self) -> int: def real_cash_out_cents(self) -> int:
return self.paid_cents return self.paid_out_cents
@property @property
def payout_due_cents(self) -> int: def payout_due_cents(self) -> int:
@@ -114,11 +148,11 @@ class SessionEntry:
@property @property
def raw_player_owes_cents(self) -> int: def raw_player_owes_cents(self) -> int:
return self.player_owes_gross_cents return self.gross_due_to_house_cents
@property @property
def front_shortfall_cents(self) -> int: def front_shortfall_cents(self) -> int:
return self.player_owes_gross_cents return self.gross_due_to_house_cents
@property @property
def overpaid_front_cents(self) -> int: def overpaid_front_cents(self) -> int:
@@ -127,29 +161,31 @@ class SessionEntry:
@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.writeoff_cents,
max(self.player_owes_gross_cents - self.front_collected_applied_cents, 0), max(self.gross_due_to_house_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.player_owes_gross_cents) return min(self.debt_repayment_cents, self.gross_due_to_house_cents)
@property @property
def front_resolved_cents(self) -> int: def front_resolved_cents(self) -> int:
return min(self.settled_to_house_cents, self.player_owes_gross_cents) return min(self.settled_to_house_cents, self.gross_due_to_house_cents)
@property @property
def payout_status(self) -> str: def payout_status(self) -> str:
if self.current_due_to_house_cents > 0: if self.current_due_to_house_cents > 0:
return "owes" return "owes"
if self.front_writeoff_cents > 0: if self.writeoff_cents > 0:
return "written_off" return "written_off"
if self.front_collected_cents > 0: if self.debt_repayment_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_to_player_cents <= 0: if self.current_due_to_player_cents <= 0:
if self.paid_out_cents < self.gross_payout_cents:
return "settled"
return "paid" return "paid"
if self.settled_to_player_cents <= 0: if self.settled_to_player_cents <= 0:
return "unpaid" return "unpaid"
@@ -180,6 +216,10 @@ class SessionSummary:
def total_rollover_in_cents(self) -> int: def total_rollover_in_cents(self) -> int:
return sum(entry.rollover_in_cents for entry in self.entries) return sum(entry.rollover_in_cents for entry in self.entries)
@property
def total_payout_carry_in_cents(self) -> int:
return sum(entry.payout_carry_in_cents for entry in self.entries)
@property @property
def total_invested_cents(self) -> int: def total_invested_cents(self) -> int:
return sum(entry.invested_cents for entry in self.entries) return sum(entry.invested_cents for entry in self.entries)
@@ -206,7 +246,11 @@ class SessionSummary:
@property @property
def total_paid_cents(self) -> int: def total_paid_cents(self) -> int:
return sum(entry.paid_cents for entry in self.entries) return self.total_paid_out_cents
@property
def total_paid_out_cents(self) -> int:
return sum(entry.paid_out_cents for entry in self.entries)
@property @property
def total_rollover_out_cents(self) -> int: def total_rollover_out_cents(self) -> int:
@@ -222,7 +266,11 @@ class SessionSummary:
@property @property
def total_player_owes_gross_cents(self) -> int: def total_player_owes_gross_cents(self) -> int:
return sum(entry.player_owes_gross_cents for entry in self.entries) return sum(entry.gross_due_to_house_cents for entry in self.entries)
@property
def total_gross_due_to_house_cents(self) -> int:
return self.total_player_owes_gross_cents
@property @property
def total_settled_to_house_cents(self) -> int: def total_settled_to_house_cents(self) -> int:
@@ -234,19 +282,39 @@ class SessionSummary:
@property @property
def total_front_writeoff_cents(self) -> int: def total_front_writeoff_cents(self) -> int:
return sum(entry.front_writeoff_cents for entry in self.entries) return self.total_writeoff_cents
@property @property
def total_front_collected_cents(self) -> int: def total_front_collected_cents(self) -> int:
return sum(entry.front_collected_cents for entry in self.entries) return self.total_debt_repayment_cents
@property
def total_debt_repayment_cents(self) -> int:
return sum(entry.debt_repayment_cents for entry in self.entries)
@property
def total_writeoff_cents(self) -> int:
return sum(entry.writeoff_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.real_cash_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_real_cash_in_cents(self) -> int:
return sum(entry.real_cash_out_cents for entry in self.entries) return self.total_cash_in_cents
@property
def total_real_cash_out_cents(self) -> int:
return self.total_paid_out_cents
@property
def total_banker_cash_in_cents(self) -> int:
return self.total_real_cash_in_cents
@property
def total_banker_cash_out_cents(self) -> int:
return self.total_real_cash_out_cents
@property @property
def total_current_due_cents(self) -> int: def total_current_due_cents(self) -> int:
@@ -290,10 +358,13 @@ class PlayerStats:
total_front_writeoff_cents: int total_front_writeoff_cents: int
current_player_owes_cents: int current_player_owes_cents: int
total_rollover_in_cents: int total_rollover_in_cents: int
total_payout_carry_in_cents: int
total_invested_cents: int total_invested_cents: int
total_cash_out_cents: int total_cash_out_cents: int
total_gross_payout_cents: int
total_paid_cents: int total_paid_cents: int
total_rollover_out_cents: int total_rollover_out_cents: int
current_due_to_player_cents: int
total_net_cents: int total_net_cents: int
roi_pct: float roi_pct: float
current_win_streak: int current_win_streak: int
@@ -306,6 +377,30 @@ class PlayerStats:
worst_session_net_cents: int worst_session_net_cents: int
rank_change: int = 0 rank_change: int = 0
@property
def total_debt_repayment_cents(self) -> int:
return self.total_front_collected_cents
@property
def total_writeoff_cents(self) -> int:
return self.total_front_writeoff_cents
@property
def current_due_to_house_cents(self) -> int:
return self.current_player_owes_cents
@property
def total_paid_out_cents(self) -> int:
return self.total_paid_cents
@property
def total_real_cash_out_cents(self) -> int:
return self.total_paid_out_cents
@property
def total_real_cash_in_cents(self) -> int:
return self.total_buy_in_cents + self.total_debt_repayment_cents
def apply_rank_changes( def apply_rank_changes(
current_board: list[PlayerStats], prev_board: list[PlayerStats] current_board: list[PlayerStats], prev_board: list[PlayerStats]
@@ -331,34 +426,38 @@ def cents_to_dollars(cents: int) -> Markup:
def session_sort_key(session: SessionSummary) -> tuple[str, int, str, str]: def session_sort_key(session: SessionSummary) -> tuple[str, int, str, str]:
session_id = session.session_id.strip() session_id = session.session_id.strip()
session_date = session.session_date.strip() session_date = session.session_date.strip()
return (
session_date,
session_sequence_number(session_id, session_date),
session.opened_at,
session_id,
)
if session_id == session_date:
return (session_date, 1, session.opened_at, session_id)
suffix = session_id.replace(f"{session_date}-", "") def entry_sort_key(entry: SessionEntry) -> tuple[str, int, str]:
return (
entry.session_date,
session_sequence_number(entry.session_id, entry.session_date),
entry.session_id,
)
def session_display_suffix(session_id: str, session_date: str) -> str:
suffix = session_id.strip().replace(f"{session_date.strip()}-", "", 1)
if session_id.strip() == session_date.strip():
return ""
if suffix.isdigit(): if suffix.isdigit():
return (session_date, int(suffix), session.opened_at, session_id) return f"S{int(suffix)}"
if suffix.lower().startswith("s") and suffix[1:].isdigit(): if suffix.lower().startswith("s") and suffix[1:].isdigit():
return (session_date, int(suffix[1:]), session.opened_at, session_id) return f"S{int(suffix[1:])}"
return suffix
return (session_date, 9999, session.opened_at, session_id)
def session_chart_label(session: SessionSummary) -> str: def session_chart_label(session: SessionSummary) -> str:
session_id = session.session_id.strip() suffix = session_display_suffix(session.session_id, session.session_date)
date_label = safe_date_label(session.session_date)
if session_id == session.session_date: return f"{date_label} · {suffix}" if suffix else date_label
return safe_date_label(session.session_date)
suffix = session_id.replace(f"{session.session_date}-", "")
if suffix.isdigit():
return f"{safe_date_label(session.session_date)} · S{int(suffix)}"
if suffix.lower().startswith("s") and suffix[1:].isdigit():
return f"{safe_date_label(session.session_date)} · S{int(suffix[1:])}"
return f"{safe_date_label(session.session_date)} · {suffix}"
def safe_date_label(raw_date: str) -> str: def safe_date_label(raw_date: str) -> str:
@@ -369,13 +468,7 @@ def safe_date_label(raw_date: str) -> str:
def session_label(session: SessionSummary) -> str: def session_label(session: SessionSummary) -> str:
if session.session_id == session.session_date: return session_chart_label(session)
return safe_date_label(session.session_date)
suffix = session.session_id.replace(f"{session.session_date}-", "")
if suffix.isdigit():
return f"{safe_date_label(session.session_date)} · S{int(suffix)}"
return f"{safe_date_label(session.session_date)} · {suffix}"
def color_for_name(name: str, names: list[str]) -> str: def color_for_name(name: str, names: list[str]) -> str:
@@ -450,14 +543,22 @@ def build_session_summaries(events: list[EventRow]) -> list[SessionSummary]:
entry.front_cents += event["amount_cents"] entry.front_cents += event["amount_cents"]
elif event_type == "front_collected": elif event_type == "front_collected":
entry.front_collected_cents += event["amount_cents"] entry.front_collected_cents += event["amount_cents"]
elif event_type == "debt_repayment":
entry.front_collected_cents += event["amount_cents"]
elif event_type == "front_writeoff": elif event_type == "front_writeoff":
entry.front_writeoff_cents += event["amount_cents"] entry.front_writeoff_cents += event["amount_cents"]
elif event_type == "writeoff":
entry.front_writeoff_cents += event["amount_cents"]
elif event_type == "rollover_in": elif event_type == "rollover_in":
entry.rollover_in_cents += event["amount_cents"] entry.rollover_in_cents += event["amount_cents"]
elif event_type == "payout_carry_in":
entry.payout_carry_in_cents += event["amount_cents"]
elif event_type == "cashout": elif event_type == "cashout":
entry.cash_out_cents += event["amount_cents"] entry.cash_out_cents += event["amount_cents"]
elif event_type == "paid": elif event_type == "paid":
entry.paid_cents += event["amount_cents"] entry.paid_cents += event["amount_cents"]
elif event_type == "paid_out":
entry.paid_cents += event["amount_cents"]
elif event_type == "rollover_out": elif event_type == "rollover_out":
entry.rollover_out_cents += event["amount_cents"] entry.rollover_out_cents += event["amount_cents"]
@@ -484,19 +585,12 @@ def build_session_summaries(events: list[EventRow]) -> list[SessionSummary]:
) )
) )
sessions.sort( sessions.sort(key=session_sort_key, reverse=True)
key=lambda session: (
session.session_date,
session.opened_at,
session.session_id,
),
reverse=True,
)
return sessions return sessions
def summarize_player_runs(entries: list[SessionEntry]) -> dict[str, int | str | None]: def summarize_player_runs(entries: list[SessionEntry]) -> dict[str, int | str | None]:
ordered_entries = sorted(entries, key=lambda entry: entry.session_date) ordered_entries = sorted(entries, key=entry_sort_key)
longest_win_streak = 0 longest_win_streak = 0
longest_loss_streak = 0 longest_loss_streak = 0
@@ -585,14 +679,17 @@ 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(entry.front_collected_cents for entry in entries) total_front_collected = sum(entry.debt_repayment_cents for entry in entries)
total_front_writeoff = sum(entry.front_writeoff_cents for entry in entries) total_front_writeoff = sum(entry.writeoff_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_payout_carry_in = sum(entry.payout_carry_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)
total_cash_out = sum(entry.cash_out_cents for entry in entries) total_cash_out = sum(entry.cash_out_cents for entry in entries)
total_paid = sum(entry.paid_cents for entry in entries) total_gross_payout = sum(entry.gross_payout_cents for entry in entries)
total_paid = sum(entry.paid_out_cents for entry in entries)
total_rollover_out = sum(entry.rollover_out_cents for entry in entries) total_rollover_out = sum(entry.rollover_out_cents for entry in entries)
current_due_to_player = sum(entry.current_due_to_player_cents for entry in entries)
total_net = sum(entry.net_cents for entry in entries) total_net = sum(entry.net_cents for entry in entries)
roi_pct = (total_net / total_invested * 100) if total_invested else 0.0 roi_pct = (total_net / total_invested * 100) if total_invested else 0.0
@@ -614,10 +711,13 @@ def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]:
total_front_writeoff_cents=total_front_writeoff, total_front_writeoff_cents=total_front_writeoff,
current_player_owes_cents=current_player_owes, current_player_owes_cents=current_player_owes,
total_rollover_in_cents=total_rollover_in, total_rollover_in_cents=total_rollover_in,
total_payout_carry_in_cents=total_payout_carry_in,
total_invested_cents=total_invested, total_invested_cents=total_invested,
total_cash_out_cents=total_cash_out, total_cash_out_cents=total_cash_out,
total_gross_payout_cents=total_gross_payout,
total_paid_cents=total_paid, total_paid_cents=total_paid,
total_rollover_out_cents=total_rollover_out, total_rollover_out_cents=total_rollover_out,
current_due_to_player_cents=current_due_to_player,
total_net_cents=total_net, total_net_cents=total_net,
roi_pct=roi_pct, roi_pct=roi_pct,
current_win_streak=run_summary["current_win_streak"], current_win_streak=run_summary["current_win_streak"],
+16
View File
@@ -39,8 +39,12 @@ VALID_EVENT_TYPES = {
"front_writeoff", "front_writeoff",
"cashout", "cashout",
"paid", "paid",
"paid_out",
"rollover_in", "rollover_in",
"payout_carry_in",
"rollover_out", "rollover_out",
"debt_repayment",
"writeoff",
"note", "note",
"session_open", "session_open",
"session_close", "session_close",
@@ -120,3 +124,15 @@ def append_event(
writer.writerow(event) writer.writerow(event)
return event return event
def write_events(csv_path: Path, events: list[EventRow]) -> None:
ensure_data_file(csv_path)
tmp_path = csv_path.with_suffix(f"{csv_path.suffix}.tmp")
with tmp_path.open("w", newline="", encoding="utf-8") as file:
writer = csv.DictWriter(file, fieldnames=CSV_HEADERS)
writer.writeheader()
writer.writerows(events)
tmp_path.replace(csv_path)
+278 -170
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Admin · boker.club{% endblock %} {% block title %}Admin · myboker.org{% endblock %}
{% block page_class %}page--admin{% endblock %} {% block page_class %}page--admin{% endblock %}
{% block content %} {% block content %}
@@ -11,7 +11,7 @@
{% endif %} {% endif %}
</div> </div>
<h1 class="hero__title">Ledger controls</h1> <h1 class="hero__title">Ledger controls</h1>
<p class="hero__sub">Append events to the audit trail. All history is permanent.</p> <p class="hero__sub">Append events to the audit trail. Player ledger history stays permanent.</p>
</div> </div>
<!-- Banker cash/book position. Cash tiles are real money only; rollovers only <!-- Banker cash/book position. Cash tiles are real money only; rollovers only
@@ -22,7 +22,7 @@
<div class="stat__val" style="color:var(--num)">{{ admin_totals.cash_in_cents | money }}</div> <div class="stat__val" style="color:var(--num)">{{ admin_totals.cash_in_cents | money }}</div>
</div> </div>
<div class="stat stat--rail" style="--rail:var(--pos);"> <div class="stat stat--rail" style="--rail:var(--pos);">
<span class="kicker">Cash out</span> <span class="kicker">Cash paid out</span>
<div class="stat__val" style="color:var(--pos)">{{ admin_totals.cash_out_cents | money }}</div> <div class="stat__val" style="color:var(--pos)">{{ admin_totals.cash_out_cents | money }}</div>
</div> </div>
<div class="stat stat--rail" style="--rail:var(--neg);"> <div class="stat stat--rail" style="--rail:var(--neg);">
@@ -38,125 +38,255 @@
<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 %}"> <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 }} {{ admin_totals.net_book_position_cents | money }}
</div> </div>
<div class="stat__sub">cash in - cash out + receivables - payables</div> <div class="stat__sub">cash in - cash paid out + receivables - payables</div>
</div> </div>
</div> </div>
<!-- Two-column workspace --> <!-- Admin workspace -->
<div class="admin-workspace"> <div class="admin-workspace">
<!-- LEFT: append form + recent sessions --> <form class="panel append" method="post" action="{{ url_for('admin_dashboard') }}">
<div class="col-main"> <div class="panel__head">
<h2 class="panel__title">Append ledger event</h2>
<form class="panel append" method="post" action="{{ url_for('admin_dashboard') }}"> <span class="panel__tag">fast entry</span>
<div class="panel__head"> </div>
<h2 class="panel__title">Append ledger event</h2> <div class="append__body">
<span class="panel__tag">fast entry</span> <div class="append__grid">
</div> <label class="field">
<div class="append__body"> <span class="field__label">Session</span>
<div class="append__grid"> <select class="control" name="session_id" required>
<label class="field"> <option value="">Select session</option>
<span class="field__label">Session</span> {% for s in open_sessions %}
<select class="control" name="session_id" required> <option value="{{ s.session_id }}" {{ 'selected' if append_form.session_id == s.session_id else '' }}>
<option value="">Select session</option> {{ session_label(s) }}{{ ' (live)' if s.status == 'open' else '' }}
{% for s in open_sessions %} </option>
<option value="{{ s.session_id }}" {{ 'selected' if append_form.session_id == s.session_id else '' }}> {% endfor %}
{{ session_label(s) }}{{ ' (live)' if s.status == 'open' else '' }} </select>
</option>
{% endfor %}
</select>
</label>
<label class="field">
<span class="field__label">Player</span>
<input class="control" list="player_names_list" type="text" name="player_name" placeholder="Player name" value="{{ append_form.player_name }}" required>
<datalist id="player_names_list">
{% for name in player_names %}<option value="{{ name }}"></option>{% endfor %}
</datalist>
</label>
<label class="field">
<span class="field__label">Event type</span>
<select class="control" name="event_type" required id="eventTypeSelect">
{% for value, label, color in event_types %}
<option value="{{ value }}" data-color="{{ color }}" data-label="{{ label }}" {{ 'selected' if append_form.event_type == value else '' }}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label class="field">
<span class="field__label">Amount</span>
<span class="control control--money">
<span class="control__prefix">$</span>
<input name="amount" inputmode="decimal" placeholder="0" autofocus>
</span>
</label>
</div>
<label class="field append__note">
<span class="field__label">Note <span class="field__opt">(optional)</span></span>
<input class="control" name="note" placeholder="e.g. fronted buy-in, settle next week">
</label> </label>
<div class="append__footer"> <label class="field">
<button type="submit" class="btn btn--primary">Append event</button> <span class="field__label">Player</span>
<span class="append__preview">Will record as <input class="control" list="player_names_list" type="text" name="player_name" placeholder="Player name" value="{{ append_form.player_name }}" required>
<span class="evtag-pill" id="eventTypePreview" style="color:{{ event_type_colors[append_form.event_type] }}">{{ event_type_labels[append_form.event_type] | upper }}</span> <datalist id="player_names_list">
{% for name in player_names %}<option value="{{ name }}"></option>{% endfor %}
</datalist>
</label>
<label class="field">
<span class="field__label">Event type</span>
<select class="control" name="event_type" required id="eventTypeSelect">
{% for value, label, color in event_types %}
<option value="{{ value }}" data-color="{{ color }}" data-label="{{ label }}" {{ 'selected' if append_form.event_type == value else '' }}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label class="field">
<span class="field__label">Amount</span>
<span class="control control--money">
<span class="control__prefix">$</span>
<input name="amount" inputmode="decimal" placeholder="0" autofocus>
</span> </span>
</div> </label>
</div> </div>
</form> <label class="field append__note">
<span class="field__label">Note <span class="field__opt">(optional)</span></span>
</div><!-- /.col-main --> <textarea class="control append__textarea" name="note" placeholder="e.g. fronted buy-in, settle next week"></textarea>
</label>
<!-- RIGHT: session controls + CSV + raw events feed --> <div class="append__footer">
<div class="col-side"> <button type="submit" class="btn btn--primary">Append event</button>
<span class="append__preview">Will record as
<div class="panel panel--pad"> <span class="evtag-pill" id="eventTypePreview" style="color:{{ event_type_colors[append_form.event_type] }}">{{ event_type_labels[append_form.event_type] | upper }}</span>
<h2 class="panel__title panel__title--mb">Session controls</h2> </span>
</div>
{% if live_session %} {% if pending_carry_items %}
<div class="livecard"> <div class="carry-recs">
<div class="livecard__head"> <div class="micro">Recommended carry-ins</div>
<span class="dot dot--live"></span> {% for item in pending_carry_items %}
<span class="livecard__name">{{ session_label(live_session) }}</span> <div class="carry-rec">
<span class="livecard__no">{{ live_session_no }}</span> <span>{{ item.player_name }}</span>
<strong>{{ item.amount_cents | money }}</strong>
{% if live_session and item.player_name in live_session_player_names %}
<form method="post" action="{{ url_for('admin_apply_payout_carry_in') }}">
<input type="hidden" name="session_id" value="{{ live_session.session_id }}">
<input type="hidden" name="player_name" value="{{ item.player_name }}">
<button class="table-action-button" type="submit">Apply</button>
</form>
{% elif live_session %}
<em>Add buy-in first</em>
{% else %}
<em>Open session</em>
{% endif %}
</div> </div>
<div class="livecard__metrics"> {% endfor %}
<div><div class="micro">In play</div><div class="mono16">{{ live_session.total_invested_cents | money }}</div></div>
<div><div class="micro">Seated</div><div class="mono16">{{ live_session.entries|length }}</div></div>
<div><div class="micro">Open</div><div class="mono16 mono16--warn">{{ live_unresolved_count }}</div></div>
</div>
<form method="post" action="{{ url_for('admin_session_state') }}">
<input type="hidden" name="session_id" value="{{ live_session.session_id }}">
<button class="btn-close" type="submit" name="state" value="closed">Close &amp; settle session</button>
</form>
</div> </div>
{% endif %} {% endif %}
</div>
</form>
<form method="post" action="{{ url_for('admin_open_session') }}"> <div class="panel panel--pad session-control-panel">
<input type="hidden" name="session_date" value="{{ admin_today }}"> <h2 class="panel__title panel__title--mb">Session controls</h2>
<button class="btn-newsession {{ 'btn-newsession--primary' if not live_session else '' }}" type="submit">+ Open new session</button> <div class="session-summary-strip">
</form> <div>
<span class="micro">Today</span>
<strong>{{ admin_today }}</strong>
</div>
<div>
<span class="micro">Empty</span>
<strong>{{ empty_session_count }}</strong>
</div>
</div> </div>
<div class="panel panel--pad"> {% if live_session %}
<h2 class="panel__title" style="margin:0 0 4px">Import / export</h2> <div class="livecard">
<p class="csv__desc">Back up or migrate the raw event ledger.</p> <div class="livecard__head">
<form method="post" action="{{ url_for('admin_import_csv') }}" enctype="multipart/form-data" class="import-form"> <span class="dot dot--live"></span>
<div class="csv__btns"> <span class="livecard__name">{{ session_label(live_session) }}</span>
<a class="csv-btn-ghost" href="{{ url_for('admin_export_csv') }}">&darr; Export CSV</a> <span class="livecard__no">{{ live_session_no }}</span>
<label class="csv-btn-ghost" for="csvTopImport">&uarr; Import CSV</label> </div>
</div> <div class="livecard__metrics">
<input id="csvTopImport" type="file" accept=".csv,text/csv" name="csv_file" hidden onchange="this.form.submit()"> <div><div class="micro">Invested</div><div class="mono16">{{ live_session.total_invested_cents | money }}</div></div>
<label class="csv__drop" id="csvDrop" for="csvTopImport">Drop ledger.csv to import</label> <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') }}"
{% if live_unresolved_count > 0 %}data-close-warning="true"{% endif %}>
<input type="hidden" name="session_id" value="{{ live_session.session_id }}">
<button class="btn-close" type="submit" name="state" value="closed">Close session</button>
</form> </form>
</div> </div>
{% else %}
<div class="livecard livecard--empty">
<div class="livecard__head">
<span class="dot dot--idle"></span>
<span class="livecard__name">No open session</span>
<span class="livecard__no">idle</span>
</div>
<div class="livecard__metrics">
<div><div class="micro">Invested</div><div class="mono16">{{ 0 | money }}</div></div>
<div><div class="micro">Seated</div><div class="mono16">0</div></div>
<div><div class="micro">Open</div><div class="mono16">0</div></div>
</div>
</div>
{% endif %}
</div><!-- /.col-side --> <form method="post" action="{{ url_for('admin_open_session') }}">
<input type="hidden" name="session_date" value="{{ admin_today }}">
<button class="btn-newsession {{ 'btn-newsession--primary' if not live_session else '' }}" type="submit">+ Open new session</button>
</form>
<form method="post" action="{{ url_for('admin_prune_empty_sessions') }}" class="prune-session-form">
<button class="secondary-button" type="submit" {% if empty_session_count == 0 %}disabled{% endif %}>
Prune empty sessions{% if empty_session_count > 0 %} ({{ empty_session_count }}){% endif %}
</button>
</form>
</div>
<div class="panel panel--pad import-export-panel">
<h2 class="panel__title" style="margin:0 0 4px">Import / export</h2>
<p class="csv__desc">Back up or migrate the raw event ledger.</p>
<form method="post" action="{{ url_for('admin_import_csv') }}" enctype="multipart/form-data" class="import-form">
<div class="csv__btns">
<a class="csv-btn-ghost" href="{{ url_for('admin_export_csv') }}">&darr; Export CSV</a>
<label class="csv-btn-ghost" for="csvTopImport">&uarr; Import CSV</label>
</div>
<input id="csvTopImport" type="file" accept=".csv,text/csv" name="csv_file" hidden onchange="this.form.submit()">
<label class="csv__drop" id="csvDrop" for="csvTopImport">Drop ledger.csv to import</label>
</form>
</div>
</div><!-- /.admin-workspace --> </div><!-- /.admin-workspace -->
<!-- Debt resolution -->
<div class="panel debt-panel">
<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">Cash repaid {{ admin_totals.collected_cents | money }}</span>
<span class="badge badge--written_off">Written off {{ admin_totals.written_off_cents | money }}</span>
</div>
</div>
{% if debt_entries %}
<div class="debt-layout">
<div class="debt-list">
{% for item in debt_entries %}
{% set s = item.session %}{% set e = item.entry %}
<div class="debt-list-item">
<div>
<strong>{{ e.player_name }}</strong>
<span>{{ session_label(s) }}</span>
</div>
<b>{{ e.player_owes_cents | money }}</b>
</div>
{% endfor %}
</div>
<div class="debt-action-grid">
<form class="form-card debt-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 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 debt-form" 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 debt</button>
</form>
</div>
</div>
{% else %}
<div class="debt-empty">
<div class="debt-empty__mark">$0</div>
<p>No open player debts right now.</p>
</div>
{% endif %}
</div>
<div class="admin-bottom-grid"> <div class="admin-bottom-grid">
<div class="panel"> <div class="panel">
<div class="panel__head"> <div class="panel__head">
<div> <div>
<span class="kicker" style="margin:0;">Recent sessions</span> <span class="kicker" style="margin:0;">All sessions</span>
<h2 class="panel__title">Current totals</h2> <h2 class="panel__title">Current totals</h2>
</div> </div>
</div> </div>
@@ -167,8 +297,8 @@
<th>Session</th> <th>Session</th>
<th>Players</th> <th>Players</th>
<th>Cash in</th> <th>Cash in</th>
<th>Cash out</th> <th>Cash paid out</th>
<th>House owes</th> <th>House owes players</th>
<th>Players owe house</th> <th>Players owe house</th>
<th>Book position</th> <th>Book position</th>
<th>Status</th> <th>Status</th>
@@ -180,8 +310,8 @@
<tr> <tr>
<td><a href="{{ url_for('session_detail', session_id=s.session_id) }}">{{ session_label(s) }}</a></td> <td><a href="{{ url_for('session_detail', session_id=s.session_id) }}">{{ session_label(s) }}</a></td>
<td style="font-family:var(--font-mono);color:var(--num)">{{ s.entries|length }}</td> <td style="font-family:var(--font-mono);color:var(--num)">{{ s.entries|length }}</td>
<td style="font-family:var(--font-mono);color:var(--num)">{{ s.total_cash_in_cents | money }}</td> <td style="font-family:var(--font-mono);color:var(--num)">{{ s.total_banker_cash_in_cents | money }}</td>
<td style="font-family:var(--font-mono);color:var(--pos)">{{ s.total_paid_out_cents | money }}</td> <td style="font-family:var(--font-mono);color:var(--pos)">{{ s.total_banker_cash_out_cents | money }}</td>
<td class="{{ 'negative' if s.total_current_due_to_player_cents > 0 else '' }}" style="font-family:var(--font-mono)">{{ s.total_current_due_to_player_cents | money }}</td> <td class="{{ 'negative' if s.total_current_due_to_player_cents > 0 else '' }}" style="font-family:var(--font-mono)">{{ s.total_current_due_to_player_cents | money }}</td>
<td class="{{ 'negative' if s.total_current_due_to_house_cents > 0 else '' }}" style="font-family:var(--font-mono)">{{ s.total_current_due_to_house_cents | money }}</td> <td class="{{ 'negative' if s.total_current_due_to_house_cents > 0 else '' }}" style="font-family:var(--font-mono)">{{ s.total_current_due_to_house_cents | money }}</td>
<td class="{% if s.total_net_book_position_cents > 0 %}positive{% elif s.total_net_book_position_cents < 0 %}negative{% endif %}" style="font-family:var(--font-mono)">{{ s.total_net_book_position_cents | money }}</td> <td 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>
@@ -231,6 +361,20 @@
</div> </div>
</div> </div>
<div class="modal-backdrop" id="closeSessionModal" hidden>
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="closeSessionModalTitle">
<div class="modal-card__head">
<span class="kicker" style="margin:0;">Open settlement</span>
<h2 class="panel__title" id="closeSessionModalTitle">Close session?</h2>
</div>
<p class="modal-card__text">This session has unpaid or uncarried cashouts, or unresolved debts. Closing stops table entry, but the open settlement items will remain tracked.</p>
<div class="modal-card__actions">
<button class="secondary-button" type="button" id="cancelCloseSession">Keep open</button>
<button class="btn btn--primary" type="button" id="confirmCloseSession">Close session</button>
</div>
</div>
</div>
<script> <script>
const eventTypeSelect = document.getElementById('eventTypeSelect'); const eventTypeSelect = document.getElementById('eventTypeSelect');
const eventTypePreview = document.getElementById('eventTypePreview'); const eventTypePreview = document.getElementById('eventTypePreview');
@@ -255,74 +399,38 @@ csvDrop?.addEventListener('drop', (event) => {
csvInput.files = event.dataTransfer.files; csvInput.files = event.dataTransfer.files;
csvInput.form?.submit(); csvInput.form?.submit();
}); });
const closeSessionModal = document.getElementById('closeSessionModal');
const cancelCloseSession = document.getElementById('cancelCloseSession');
const confirmCloseSession = document.getElementById('confirmCloseSession');
let pendingCloseForm = null;
document.querySelectorAll('form[data-close-warning="true"]').forEach((form) => {
form.addEventListener('submit', (event) => {
if (form.dataset.confirmed === 'true') return;
event.preventDefault();
pendingCloseForm = form;
closeSessionModal.hidden = false;
confirmCloseSession?.focus();
});
});
cancelCloseSession?.addEventListener('click', () => {
closeSessionModal.hidden = true;
pendingCloseForm = null;
});
closeSessionModal?.addEventListener('click', (event) => {
if (event.target !== closeSessionModal) return;
closeSessionModal.hidden = true;
pendingCloseForm = null;
});
confirmCloseSession?.addEventListener('click', () => {
if (!pendingCloseForm) return;
pendingCloseForm.dataset.confirmed = 'true';
pendingCloseForm.submit();
});
</script> </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 %}
+2 -2
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Admin Login · boker.club{% endblock %} {% block title %}Admin Login · myboker.org{% endblock %}
{% block content %} {% block content %}
<div class="form-shell narrow"> <div class="form-shell narrow">
@@ -7,7 +7,7 @@
<div> <div>
<p class="eyebrow">Restricted access</p> <p class="eyebrow">Restricted access</p>
<h1 style="font-size:28px;margin-bottom:8px;">Admin login</h1> <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> <p class="muted-text" style="margin-bottom:8px;">Only myboker.org manager needs credentials. All stats pages stay public.</p>
</div> </div>
<label> <label>
<span>Username</span> <span>Username</span>
+2 -2
View File
@@ -3,7 +3,7 @@
<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 %}boker.club{% endblock %}</title> <title>{% block title %}myboker.org{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/tokens.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/tokens.css') }}">
<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>
@@ -13,7 +13,7 @@
<div class="topbar__inner"> <div class="topbar__inner">
<a class="brand" href="{{ url_for('leaderboard') }}"> <a class="brand" href="{{ url_for('leaderboard') }}">
<span class="brand__mark"></span> <span class="brand__mark"></span>
<span class="brand__name">boker.club</span> <span class="brand__name">myboker.org</span>
</a> </a>
<nav class="mainnav"> <nav class="mainnav">
<a class="navlink {{ 'is-active' if request.endpoint == 'leaderboard' }}" href="{{ url_for('leaderboard') }}">Leaderboard</a> <a class="navlink {{ 'is-active' if request.endpoint == 'leaderboard' }}" href="{{ url_for('leaderboard') }}">Leaderboard</a>
+1 -1
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Leaderboard · boker.club{% endblock %} {% block title %}Leaderboard · myboker.org{% endblock %}
{% block content %} {% block content %}
<div class="hero" style="display:flex;flex-wrap:wrap;gap:var(--s6);align-items:flex-start;justify-content:space-between;"> <div class="hero" style="display:flex;flex-wrap:wrap;gap:var(--s6);align-items:flex-start;justify-content:space-between;">
+43 -17
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ player.player_name }} · boker.club{% endblock %} {% block title %}{{ player.player_name }} · myboker.org{% endblock %}
{% block content %} {% block content %}
<div class="hero"> <div class="hero">
@@ -52,7 +52,7 @@
<div class="stat__val" style="color:var(--num)">{{ player.total_invested_cents | money }}</div> <div class="stat__val" style="color:var(--num)">{{ player.total_invested_cents | money }}</div>
</div> </div>
<div class="stat"> <div class="stat">
<span class="kicker">Cash-outs</span> <span class="kicker">Gross cashouts</span>
<div class="stat__val" style="color:var(--num)">{{ player.total_cash_out_cents | money }}</div> <div class="stat__val" style="color:var(--num)">{{ player.total_cash_out_cents | money }}</div>
</div> </div>
</div> </div>
@@ -105,15 +105,15 @@
<div class="result-mix__legend"> <div class="result-mix__legend">
<div class="result-mix__legend-row"> <div class="result-mix__legend-row">
<span><i class="result-mix__key result-mix__key--win"></i>Wins</span> <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> <strong><b>{{ player.winning_sessions }}</b><em>{{ "%.1f"|format((player.winning_sessions / result_total * 100) if result_total else 0) }}%</em></strong>
</div> </div>
<div class="result-mix__legend-row"> <div class="result-mix__legend-row">
<span><i class="result-mix__key result-mix__key--even"></i>Even</span> <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> <strong><b>{{ player.break_even_sessions }}</b><em>{{ "%.1f"|format((player.break_even_sessions / result_total * 100) if result_total else 0) }}%</em></strong>
</div> </div>
<div class="result-mix__legend-row"> <div class="result-mix__legend-row">
<span><i class="result-mix__key result-mix__key--loss"></i>Losses</span> <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> <strong><b>{{ player.losing_sessions }}</b><em>{{ "%.1f"|format((player.losing_sessions / result_total * 100) if result_total else 0) }}%</em></strong>
</div> </div>
</div> </div>
{% if result_total > 0 %} {% if result_total > 0 %}
@@ -151,6 +151,16 @@
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Win %</td> <td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Win %</td>
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">{{ "%.1f"|format(player.win_pct) }}%</td> <td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">{{ "%.1f"|format(player.win_pct) }}%</td>
</tr> </tr>
<tr>
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Record</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.winning_sessions }}W</span>
<span style="color:var(--faintest);margin:0 4px;">·</span>
<span style="color:var(--neg)">{{ player.losing_sessions }}L</span>
<span style="color:var(--faintest);margin:0 4px;">·</span>
<span>{{ player.break_even_sessions }}E</span>
</td>
</tr>
<tr> <tr>
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">ROI</td> <td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">ROI</td>
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);" <td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);"
@@ -197,7 +207,7 @@
<div> <div>
<div class="section-marker"> <div class="section-marker">
<span class="section-marker__dot section-marker__dot--rolled"></span> <span class="section-marker__dot section-marker__dot--rolled"></span>
Poker basis Poker basis + settlement
</div> </div>
<h2 class="panel__title">Investment and settlement</h2> <h2 class="panel__title">Investment and settlement</h2>
</div> </div>
@@ -216,38 +226,54 @@
<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;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> <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>
{% if player.total_payout_carry_in_cents > 0 %}
<tr>
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Payout carry-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_payout_carry_in_cents | money }}</td>
</tr>
{% endif %}
<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;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> <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>
<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;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Gross cashout result</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> <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>
<tr>
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Gross player claim</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_gross_payout_cents | money }}</td>
</tr>
<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;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> <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>
<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;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Cash 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> <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_out_cents | money }}</td>
</tr> </tr>
{% if player.total_front_collected_cents > 0 %} {% if player.total_debt_repayment_cents > 0 %}
<tr> <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;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Cash 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> <td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--pos);">{{ player.total_debt_repayment_cents | money }}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if player.current_player_owes_cents > 0 %} {% if player.current_due_to_player_cents > 0 %}
<tr> <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;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">House owes player</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> <td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--neg);">{{ player.current_due_to_player_cents | money }}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if player.total_front_writeoff_cents > 0 %} {% if player.current_due_to_house_cents > 0 %}
<tr>
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Owes house</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_due_to_house_cents | money }}</td>
</tr>
{% endif %}
{% if player.total_writeoff_cents > 0 %}
<tr> <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;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> <td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--neg);">{{ player.total_writeoff_cents | money }}</td>
</tr> </tr>
{% endif %} {% endif %}
</tbody> </tbody>
+27 -23
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ session_label(session) }} · boker.club{% endblock %} {% block title %}{{ session_label(session) }} · myboker.org{% endblock %}
{% block page_class %}page--session{% endblock %} {% block page_class %}page--session{% endblock %}
{% block content %} {% block content %}
@@ -28,20 +28,20 @@
<div class="recon-grid" style="margin-bottom:20px;"> <div class="recon-grid" style="margin-bottom:20px;">
<div class="panel"> <div class="panel">
<div class="recon-card-head"> <div class="recon-card-head">
<span class="kicker kicker--dot kicker--dot-accent">Cash flow</span> <span class="kicker kicker--dot kicker--dot-accent">Banker cash flow</span>
</div> </div>
<div class="recon-metric-grid"> <div class="recon-metric-grid">
<div class="recon-cell"> <div class="recon-cell">
<span class="kicker" style="font-size:10px;">Cash in</span> <span class="kicker" style="font-size:10px;">Actual cash in</span>
<div class="recon-val recon-val--num">{{ session.total_cash_in_cents | money }}</div> <div class="recon-val recon-val--num">{{ session.total_real_cash_in_cents | money }}</div>
</div> </div>
<div class="recon-cell"> <div class="recon-cell">
<span class="kicker" style="font-size:10px;">Gross payout</span> <span class="kicker" style="font-size:10px;">Gross player claim</span>
<div class="recon-val recon-val--num">{{ session.total_gross_payout_cents | money }}</div> <div class="recon-val recon-val--num">{{ session.total_gross_payout_cents | money }}</div>
</div> </div>
<div class="recon-cell"> <div class="recon-cell">
<span class="kicker" style="font-size:10px;">Cash paid out</span> <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 class="recon-val recon-val--pos">{{ session.total_real_cash_out_cents | money }}</div>
</div> </div>
<div class="recon-cell"> <div class="recon-cell">
<span class="kicker" style="font-size:10px;">Rolled out</span> <span class="kicker" style="font-size:10px;">Rolled out</span>
@@ -55,7 +55,7 @@
</div> </div>
<div class="recon-metric-grid"> <div class="recon-metric-grid">
<div class="recon-cell"> <div class="recon-cell">
<span class="kicker" style="font-size:10px;">House owes</span> <span class="kicker" style="font-size:10px;">House owes players</span>
<div class="recon-val {{ 'recon-val--neg' if session.total_current_due_to_player_cents > 0 else 'recon-val--pos' }}"> <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 }} {{ session.total_current_due_to_player_cents | money }}
</div> </div>
@@ -67,12 +67,12 @@
</div> </div>
</div> </div>
<div class="recon-cell"> <div class="recon-cell">
<span class="kicker" style="font-size:10px;">Debt repaid</span> <span class="kicker" style="font-size:10px;">Cash debt repaid</span>
<div class="recon-val" style="color:var(--pos)">{{ session.total_front_collected_cents | money }}</div> <div class="recon-val" style="color:var(--pos)">{{ session.total_debt_repayment_cents | money }}</div>
</div> </div>
<div class="recon-cell"> <div class="recon-cell">
<span class="kicker" style="font-size:10px;">Written off</span> <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 class="recon-val" style="color:var(--neg)">{{ session.total_writeoff_cents | money }}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -96,14 +96,15 @@
<tr> <tr>
<th class="col-player">Player</th> <th class="col-player">Player</th>
<th>Invested</th> <th>Invested</th>
<th>Cash-out</th> <th>Gross cashout</th>
<th>Net</th> <th>Net</th>
<th>Cash paid</th> <th>Paid out</th>
<th>Settlement</th> <th>Open settlement</th>
<th>Status</th> <th>Settlement status</th>
<th class="col-acct">Fronted</th> <th class="col-acct">Front</th>
<th class="col-acct">Rolled in</th> <th class="col-acct">Rolled in</th>
<th class="col-acct">Gross payout</th> <th class="col-acct">Payout carry-in</th>
<th class="col-acct">Gross player claim</th>
<th class="col-acct">Rolled out</th> <th class="col-acct">Rolled out</th>
<th class="col-acct">Notes</th> <th class="col-acct">Notes</th>
</tr> </tr>
@@ -119,10 +120,12 @@
<td class="num-net {{ 'num-pos' if entry.net_cents > 0 else 'num-neg' if entry.net_cents < 0 else 'num-muted' }}"> <td class="num-net {{ 'num-pos' if entry.net_cents > 0 else 'num-neg' if entry.net_cents < 0 else 'num-muted' }}">
{{ '+' if entry.net_cents > 0 else '' }}{{ entry.net_cents | money }} {{ '+' if entry.net_cents > 0 else '' }}{{ entry.net_cents | money }}
</td> </td>
<td style="color:var(--pos)">{{ entry.paid_cents | money }}</td> <td style="color:var(--pos)">{{ entry.paid_out_cents | money }}</td>
<td class="{{ 'num-neg' if entry.current_due_to_player_cents > 0 else '' }}"> <td class="{{ 'num-neg' if entry.current_due_to_player_cents > 0 else '' }}">
{% if entry.current_due_to_house_cents > 0 %} {% if entry.current_due_to_house_cents > 0 %}
<span style="color:var(--owes)">owes {{ entry.current_due_to_house_cents | money }}</span> <span style="color:var(--owes)">owes {{ entry.current_due_to_house_cents | money }}</span>
{% elif entry.current_due_to_player_cents > 0 %}
<span style="color:var(--neg)">due {{ entry.current_due_to_player_cents | money }}</span>
{% else %} {% else %}
{{ entry.current_due_to_player_cents | money }} {{ entry.current_due_to_player_cents | money }}
{% endif %} {% endif %}
@@ -132,8 +135,9 @@
</td> </td>
<td class="col-acct" style="color:var(--warn)">{{ entry.front_cents | money }}</td> <td class="col-acct" style="color:var(--warn)">{{ entry.front_cents | money }}</td>
<td class="col-acct" style="color:var(--rolled)">{{ entry.rollover_in_cents | money }}</td> <td class="col-acct" style="color:var(--rolled)">{{ entry.rollover_in_cents | money }}</td>
<td class="col-acct" style="color:var(--rolled)">{{ entry.payout_carry_in_cents | money }}</td>
<td class="col-acct"> <td class="col-acct">
{% if entry.player_owes_gross_cents > 0 %}—{% else %}{{ entry.gross_payout_cents | money }}{% endif %} {% if entry.gross_due_to_house_cents > 0 %}—{% else %}{{ entry.gross_payout_cents | money }}{% endif %}
</td> </td>
<td class="col-acct" style="color:var(--rolled)">{{ entry.rollover_out_cents | money }}</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);"> <td class="col-acct" style="text-align:left;font-family:var(--font-ui);">
@@ -151,7 +155,7 @@
</div> </div>
<!-- Bottom row: net bars + snapshot + audit feed --> <!-- Bottom row: net bars + snapshot + audit feed -->
<div class="split" style="align-items:stretch;"> <div class="split">
<div class="panel col-main"> <div class="panel col-main">
<div class="panel__head"> <div class="panel__head">
<span class="kicker" style="margin:0;">Breakdown</span> <span class="kicker" style="margin:0;">Breakdown</span>
@@ -159,17 +163,17 @@
</div> </div>
<div class="chart-wrap"> <div class="chart-wrap">
<div class="session-net-bars" id="sessionBreakdownChart" aria-label="Net by player"></div> <div class="session-net-bars" id="sessionBreakdownChart" aria-label="Net by player"></div>
</div> </div>
</div> </div>
<div class="panel col-side" style="display:flex;flex-direction:column;"> <div class="panel col-side">
<div class="panel__head"> <div class="panel__head">
<span class="kicker" style="margin:0;">Quick read</span> <span class="kicker" style="margin:0;">Quick read</span>
<h2 class="panel__title">Session snapshot</h2> <h2 class="panel__title">Session snapshot</h2>
</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-grid">
<div class="snapshot-cell"> <div class="snapshot-cell">
<span class="kicker" style="font-size:10px;">Biggest winner</span> <span class="kicker" style="font-size:10px;">Biggest winner</span>
<div class="snapshot-val" style="color:var(--pos)"> <div class="snapshot-val" style="color:var(--pos)">
@@ -185,7 +189,7 @@
{% if losers %}<div style="font-size:12px;color:var(--muted);margin-top:4px;">{{ losers | sort(attribute='net_cents') | first | attr('player_name') }}</div>{% endif %} {% 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>
<div class="snapshot-cell"> <div class="snapshot-cell">
<span class="kicker" style="font-size:10px;">Total cash-out</span> <span class="kicker" style="font-size:10px;">Gross cashout result</span>
<div class="snapshot-val">{{ session.total_cash_out_cents | money }}</div> <div class="snapshot-val">{{ session.total_cash_out_cents | money }}</div>
</div> </div>
<div class="snapshot-cell"> <div class="snapshot-cell">
+2 -2
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Sessions · boker.club{% endblock %} {% block title %}Sessions · myboker.org{% endblock %}
{% block content %} {% block content %}
<div class="hero"> <div class="hero">
@@ -23,7 +23,7 @@
<th>Status</th> <th>Status</th>
<th>Players</th> <th>Players</th>
<th>Invested</th> <th>Invested</th>
<th>Cash out</th> <th>Gross cashout</th>
<th>Table net</th> <th>Table net</th>
</tr> </tr>
</thead> </thead>
Binary file not shown.
Binary file not shown.
Binary file not shown.
+177 -7
View File
@@ -1,6 +1,15 @@
from pathlib import Path
import tempfile
import unittest import unittest
from stats import build_session_summaries import app as app_module
from stats import (
build_leaderboard,
build_session_summaries,
player_session_series,
session_sort_key,
)
from storage import load_events, write_events
def cents(amount: float) -> int: def cents(amount: float) -> int:
@@ -40,7 +49,7 @@ class AccountingTests(unittest.TestCase):
[ [
event("s1", "2026-01-01", "A", "buyin", 10, 1), event("s1", "2026-01-01", "A", "buyin", 10, 1),
event("s1", "2026-01-01", "A", "cashout", 20, 2), event("s1", "2026-01-01", "A", "cashout", 20, 2),
event("s1", "2026-01-01", "A", "paid", 20, 3), event("s1", "2026-01-01", "A", "paid_out", 20, 3),
] ]
) )
@@ -56,7 +65,7 @@ class AccountingTests(unittest.TestCase):
[ [
event("s1", "2026-01-01", "A", "front", 5, 1), event("s1", "2026-01-01", "A", "front", 5, 1),
event("s1", "2026-01-01", "A", "cashout", 8, 2), event("s1", "2026-01-01", "A", "cashout", 8, 2),
event("s1", "2026-01-01", "A", "paid", 3, 3), event("s1", "2026-01-01", "A", "paid_out", 3, 3),
] ]
) )
@@ -80,6 +89,7 @@ class AccountingTests(unittest.TestCase):
self.assertEqual(entry.gross_payout_cents, cents(19.50)) self.assertEqual(entry.gross_payout_cents, cents(19.50))
self.assertEqual(entry.current_due_to_player_cents, cents(9.50)) self.assertEqual(entry.current_due_to_player_cents, cents(9.50))
self.assertEqual(session.total_paid_out_cents, 0) self.assertEqual(session.total_paid_out_cents, 0)
self.assertEqual(entry.payout_status, "partial")
def test_rollover_in_is_invested_without_new_cash_in(self): def test_rollover_in_is_invested_without_new_cash_in(self):
rows = [ rows = [
@@ -98,20 +108,180 @@ class AccountingTests(unittest.TestCase):
self.assertEqual(dest_entry.invested_cents, cents(10)) self.assertEqual(dest_entry.invested_cents, cents(10))
self.assertEqual(dest.total_cash_in_cents, 0) self.assertEqual(dest.total_cash_in_cents, 0)
def test_payout_carry_in_does_not_count_as_investment_or_cash(self):
rows = [
event("s1", "2026-01-01", "A", "buyin", 10, 1),
event("s1", "2026-01-01", "A", "payout_carry_in", 0.60, 2),
event("s1", "2026-01-01", "A", "cashout", 6.55, 3),
event("s1", "2026-01-01", "A", "paid_out", 6.55, 4),
]
session, entry = self.entry_for(rows)
player = build_leaderboard([session])[0]
self.assertEqual(entry.payout_carry_in_cents, cents(0.60))
self.assertEqual(entry.invested_cents, cents(10))
self.assertEqual(entry.net_cents, cents(-3.45))
self.assertEqual(entry.current_due_to_player_cents, cents(0.60))
self.assertEqual(session.total_cash_in_cents, cents(10))
self.assertEqual(player.total_payout_carry_in_cents, cents(0.60))
def test_debt_repayment_and_writeoff_resolve_front_without_inflating_cash(self): def test_debt_repayment_and_writeoff_resolve_front_without_inflating_cash(self):
session, entry = self.entry_for( session, entry = self.entry_for(
[ [
event("s1", "2026-01-01", "A", "front", 10, 1), event("s1", "2026-01-01", "A", "front", 10, 1),
event("s1", "2026-01-01", "A", "cashout", 0, 2), 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", "debt_repayment", 6, 3),
event("s1", "2026-01-01", "A", "front_writeoff", 4, 4), event("s1", "2026-01-01", "A", "writeoff", 4, 4),
] ]
) )
self.assertEqual(entry.player_owes_gross_cents, cents(10)) self.assertEqual(entry.gross_due_to_house_cents, cents(10))
self.assertEqual(entry.current_due_to_house_cents, 0) self.assertEqual(entry.current_due_to_house_cents, 0)
self.assertEqual(session.total_cash_in_cents, cents(6)) self.assertEqual(session.total_cash_in_cents, cents(6))
self.assertEqual(session.total_front_writeoff_cents, cents(4)) self.assertEqual(session.total_writeoff_cents, cents(4))
def test_banker_book_position_separates_cash_and_open_items(self):
rows = [
event("s1", "2026-01-01", "A", "buyin", 10, 1),
event("s1", "2026-01-01", "A", "cashout", 25, 2),
event("s1", "2026-01-01", "A", "paid_out", 5, 3),
event("s1", "2026-01-01", "A", "rollover_out", 10, 4),
event("s1", "2026-01-01", "B", "front", 20, 5),
event("s1", "2026-01-01", "B", "cashout", 0, 6),
event("s1", "2026-01-01", "B", "debt_repayment", 8, 7),
event("s1", "2026-01-01", "B", "writeoff", 2, 8),
]
sessions = build_session_summaries(rows)
session = next(s for s in sessions if s.session_id == "s1")
self.assertEqual(session.total_invested_cents, cents(30))
self.assertEqual(session.total_cash_out_cents, cents(25))
self.assertEqual(session.total_real_cash_in_cents, cents(18))
self.assertEqual(session.total_real_cash_out_cents, cents(5))
self.assertEqual(session.total_current_due_to_player_cents, cents(10))
self.assertEqual(session.total_current_due_to_house_cents, cents(10))
self.assertEqual(session.total_net_book_position_cents, cents(13))
def test_player_stats_track_house_owes_player_separately_from_cash_paid(self):
rows = [
event("s1", "2026-01-01", "A", "buyin", 10, 1),
event("s1", "2026-01-01", "A", "cashout", 25, 2),
event("s1", "2026-01-01", "A", "paid_out", 5, 3),
]
player = build_leaderboard(build_session_summaries(rows))[0]
self.assertEqual(player.total_cash_out_cents, cents(25))
self.assertEqual(player.total_gross_payout_cents, cents(25))
self.assertEqual(player.total_paid_out_cents, cents(5))
self.assertEqual(player.current_due_to_player_cents, cents(20))
self.assertEqual(player.total_real_cash_out_cents, cents(5))
def test_rollover_out_can_settle_without_being_marked_paid(self):
rows = [
event("s1", "2026-01-01", "A", "buyin", 10, 1),
event("s1", "2026-01-01", "A", "cashout", 20, 2),
event("s1", "2026-01-01", "A", "rollover_out", 20, 3),
]
entry = build_session_summaries(rows)[0].entries[0]
self.assertEqual(entry.current_due_to_player_cents, 0)
self.assertEqual(entry.paid_out_cents, 0)
self.assertEqual(entry.payout_status, "settled")
def test_admin_can_apply_pending_payout_carry_in_to_open_session(self):
rows = [
event("s1", "2026-01-01", "", "session_open", 0, 1),
event("s1", "2026-01-01", "A", "buyin", 10, 2),
event("s1", "2026-01-01", "A", "cashout", 20, 3),
event("s1", "2026-01-01", "A", "rollover_out", 5, 4),
event("s1", "2026-01-01", "", "session_close", 0, 5),
event("s2", "2026-01-02", "", "session_open", 0, 6),
event("s2", "2026-01-02", "A", "buyin", 10, 7),
]
with tempfile.TemporaryDirectory() as tmpdir:
data_path = Path(tmpdir) / "entries.csv"
write_events(data_path, rows)
original_path = app_module.DATA_PATH
app_module.DATA_PATH = data_path
try:
sessions = build_session_summaries(load_events(data_path))
self.assertEqual(
app_module.pending_payout_carry_items(sessions),
[{"player_name": "A", "amount_cents": cents(5)}],
)
with app_module.app.test_client() as client:
with client.session_transaction() as flask_session:
flask_session["is_admin"] = True
response = client.post(
"/admin/apply-payout-carry-in",
data={"session_id": "s2", "player_name": "A"},
)
finally:
app_module.DATA_PATH = original_path
self.assertEqual(response.status_code, 302)
updated_sessions = build_session_summaries(load_events(data_path))
target = next(s for s in updated_sessions if s.session_id == "s2")
target_entry = target.entries[0]
self.assertEqual(target_entry.payout_carry_in_cents, cents(5))
self.assertEqual(target_entry.invested_cents, cents(10))
self.assertEqual(target_entry.current_due_to_player_cents, cents(5))
def test_same_day_sessions_sort_by_session_sequence(self):
rows = [
event("2026-03-21-02", "2026-03-21", "A", "buyin", 10, 1),
event("2026-03-21-02", "2026-03-21", "A", "cashout", 11, 2),
event("2026-03-21-01", "2026-03-21", "A", "buyin", 10, 3),
event("2026-03-21-01", "2026-03-21", "A", "cashout", 9, 4),
]
sessions = build_session_summaries(rows)
ordered = sorted(sessions, key=session_sort_key)
self.assertEqual([s.session_id for s in ordered], ["2026-03-21-01", "2026-03-21-02"])
self.assertEqual(
player_session_series(sessions, "A")["labels"],
["Mar 21, 2026 · S1", "Mar 21, 2026 · S2"],
)
def test_admin_prunes_only_empty_marker_sessions(self):
rows = [
event("empty-1", "2026-01-01", "", "session_open", 0, 1),
event("empty-1", "2026-01-01", "", "session_close", 0, 2),
event("played-1", "2026-01-02", "", "session_open", 0, 3),
event("played-1", "2026-01-02", "A", "buyin", 10, 4),
event("played-1", "2026-01-02", "A", "cashout", 15, 5),
event("played-1", "2026-01-02", "", "session_close", 0, 6),
]
with tempfile.TemporaryDirectory() as tmpdir:
data_path = Path(tmpdir) / "entries.csv"
write_events(data_path, rows)
original_path = app_module.DATA_PATH
app_module.DATA_PATH = data_path
try:
with app_module.app.test_client() as client:
with client.session_transaction() as flask_session:
flask_session["is_admin"] = True
response = client.post("/admin/prune-empty-sessions")
finally:
app_module.DATA_PATH = original_path
self.assertEqual(response.status_code, 302)
remaining = load_events(data_path)
self.assertEqual(
{row["session_id"] for row in remaining},
{"played-1"},
)
self.assertEqual(
[row["event_type"] for row in remaining],
["session_open", "buyin", "cashout", "session_close"],
)
if __name__ == "__main__": if __name__ == "__main__":