From c24f7d581048c5a4319f8dc5749a48fc32105f4d Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sun, 22 Mar 2026 13:52:59 -0700 Subject: [PATCH] implement rollover tracking + simple view session summary --- app.py | 150 ++++++++++++++++++++++++--------- static/css/style.css | 15 ++++ stats.py | 139 ++++++++++++++++++++++-------- storage.py | 9 +- templates/admin_dashboard.html | 26 ++++-- templates/player_detail.html | 8 +- templates/session_detail.html | 112 ++++++++++++++++++------ templates/sessions.html | 2 +- 8 files changed, 348 insertions(+), 113 deletions(-) diff --git a/app.py b/app.py index d8fd051..70ae2af 100644 --- a/app.py +++ b/app.py @@ -28,6 +28,7 @@ from stats import ( safe_date_label, session_breakdown_series, session_events, + session_label, unique_player_names, ) from storage import append_event, ensure_data_file, load_events @@ -52,6 +53,22 @@ def load_local_env(env_path: Path) -> None: os.environ.setdefault(key, value) +def next_session_id(sessions, session_date: str) -> str: + matching = [session for session in sessions if session.session_date == session_date] + + highest = 0 + for s in matching: + if s.session_id == session_date: + highest = max(highest, 1) + continue + + suffix = s.session_id.replace(f"{session_date}-", "") + if suffix.isdigit(): + highest = max(highest, int(suffix)) + + return f"{session_date}-{highest + 1:02d}" + + load_local_env(ENV_PATH) app = Flask(__name__) @@ -126,21 +143,26 @@ def leaderboard() -> str: @app.get("/sessions") -def sessions() -> str: +def sessions(): events = load_events(DATA_PATH) - session_summaries = build_session_summaries(events) - return render_template("sessions.html", sessions=session_summaries) + sessions = build_session_summaries(events) + + return render_template( + "sessions.html", + sessions=sessions, + session_label=session_label, + ) -@app.get("/sessions/") -def session_detail(session_date: str) -> str: +@app.get("/sessions/") +def session_detail(session_id: str) -> str: events = load_events(DATA_PATH) sessions = build_session_summaries(events) target_session = next( ( session_summary for session_summary in sessions - if session_summary.session_date == session_date + if session_summary.session_id == session_id ), None, ) @@ -148,27 +170,33 @@ def session_detail(session_date: str) -> str: flash("That session was not found.", "error") return redirect(url_for("sessions")) - # sessions are in reverse order - idx = sessions.index(target_session) - next_session_idx = idx - 1 - prev_session_idx = idx + 1 + target_index = next( + ( + index + for index, session in enumerate(sessions) + if session.session_id == session_id + ), + None, + ) - next_session = None - prev_session = None + if target_index is None: + flash("That session was not found.", "error") + return redirect(url_for("sessions")) - if next_session_idx >= 0: - next_session = sessions[next_session_idx] - - if prev_session_idx < len(sessions): - prev_session = sessions[prev_session_idx] + target_session = sessions[target_index] + prev_session = ( + sessions[target_index + 1] if target_index < len(sessions) - 1 else None + ) + next_session = sessions[target_index - 1] if target_index > 0 else None return render_template( "session_detail.html", session=target_session, next_session=next_session, prev_session=prev_session, - raw_events=session_events(events, session_date), + raw_events=session_events(events, session_id), chart_data=session_breakdown_series(target_session), + session_label=session_label, ) @@ -224,11 +252,16 @@ def admin_session_state() -> str: flash("Admin login required.", "error") return redirect(url_for("admin_login")) - session_date = request.form.get("session_date", "").strip() + session_id = request.form.get("session_id", "").strip() state = request.form.get("state", "").strip() - if not session_date: - flash("Session date is required.", "error") + 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: + flash("Session not found.", "error") return redirect(url_for("admin_dashboard")) if state not in {"open", "closed"}: @@ -237,13 +270,15 @@ def admin_session_state() -> str: append_event( DATA_PATH, - session_date=session_date, + session_id=target.session_id, + session_date=target.session_date, + amount_cents=0, player_name="", event_type="session_open" if state == "open" else "session_close", - amount_cents=0, note=f"Session marked {state}.", actor=app.config["ADMIN_USERNAME"], ) + flash(f"Session marked {state}.", "success") return redirect(url_for("admin_dashboard")) @@ -329,64 +364,97 @@ def admin_import_csv(): return redirect(url_for("admin_dashboard")) -@app.route("/admin", methods=["GET", "POST"]) -def admin_dashboard() -> str: +@app.post("/admin/open-session") +def admin_open_session() -> str: if not is_admin(): flash("Admin login required.", "error") return redirect(url_for("admin_login")) + session_date = request.form.get("session_date", "").strip() + if not session_date: + flash("Session date is required.", "error") + return redirect(url_for("admin_dashboard")) + + events = load_events(DATA_PATH) + sessions = build_session_summaries(events) + session_id = next_session_id(sessions, session_date) + + append_event( + DATA_PATH, + session_id=session_id, + session_date=session_date, + amount_cents=0, + player_name="", + event_type="session_open", + note="Session opened.", + actor=app.config["ADMIN_USERNAME"], + ) + + flash(f"Opened session {session_id}.", "success") + return redirect(url_for("admin_dashboard")) + + +@app.route("/admin", methods=["GET", "POST"]) +def admin_dashboard(): + if not is_admin(): + return redirect(url_for("admin_login")) + if request.method == "POST": - session_date = request.form.get("session_date", "").strip() + session_id = request.form.get("session_id", "").strip() player_name = request.form.get("player_name", "").strip() event_type = request.form.get("event_type", "").strip() - amount_raw = request.form.get("amount", "0").strip() note = request.form.get("note", "").strip() - if not session_date or not player_name or not event_type: - flash("Session date, player name, and event type are required.", "error") - return redirect(url_for("admin_dashboard")) - + amount_raw = request.form.get("amount", "0").strip() try: - amount_cents = 0 if event_type == "note" else round(float(amount_raw) * 100) + amount_cents = int(round(float(amount_raw) * 100)) except ValueError: - flash("Amount must be a valid number.", "error") + flash("Amount must be a number.", "error") return redirect(url_for("admin_dashboard")) events = load_events(DATA_PATH) sessions = build_session_summaries(events) - status_by_date = {session.session_date: session.status for session in sessions} + by_id = {session.session_id: session for session in sessions} + target = by_id.get(session_id) - if status_by_date.get(session_date, "closed") != "open": + if target is None: + flash("Select a valid open session.", "error") + return redirect(url_for("admin_dashboard")) + + if target.status != "open": flash( - "That session is closed. Open it first before adding player events.", + "That session is closed. Reopen it first if you need to add events.", "error", ) return redirect(url_for("admin_dashboard")) append_event( DATA_PATH, - session_date=session_date, + session_id=target.session_id, + session_date=target.session_date, player_name=player_name, event_type=event_type, amount_cents=amount_cents, note=note, actor=app.config["ADMIN_USERNAME"], ) - flash("Event added to the ledger.", "success") + + flash("Event added.", "success") return redirect(url_for("admin_dashboard")) events = load_events(DATA_PATH) sessions = build_session_summaries(events) - recent_sessions = sessions[:6] open_sessions = [session for session in sessions if session.status == "open"] - recent_events = list(reversed(events[-8:])) + recent_sessions = sessions[:8] + recent_events = list(reversed(events[-20:])) return render_template( "admin_dashboard.html", - recent_sessions=recent_sessions, open_sessions=open_sessions, + recent_sessions=recent_sessions, recent_events=recent_events, player_names=unique_player_names(events), + session_label=session_label, ) diff --git a/static/css/style.css b/static/css/style.css index 89126f9..412cd27 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1054,6 +1054,21 @@ td a:hover { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.table-toolbar { + display: flex; + gap: 0.65rem; + margin-bottom: 1rem; +} + +.table-view-toggle.is-active { + background: rgba(255, 166, 77, 0.12); + border-color: rgba(255, 166, 77, 0.28); +} + +.session-results-table.compact .detail-col { + display: none; +} + @media (max-width: 1100px) { .payout-summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/stats.py b/stats.py index 4f7ce14..0fc8c3d 100644 --- a/stats.py +++ b/stats.py @@ -26,17 +26,20 @@ PLAYER_PALETTE = [ @dataclass class SessionEntry: + session_id: str session_date: str player_name: str buy_in_cents: int = 0 front_cents: int = 0 cash_out_cents: int = 0 paid_cents: int = 0 + rollover_in_cents: int = 0 + rollover_out_cents: int = 0 notes: list[str] = field(default_factory=list) @property def invested_cents(self) -> int: - return self.buy_in_cents + self.front_cents + return self.buy_in_cents + self.front_cents + self.rollover_in_cents @property def net_cents(self) -> int: @@ -47,31 +50,45 @@ class SessionEntry: return max(self.cash_out_cents - self.front_cents, 0) @property - def player_owes_cents(self) -> int: - return max(self.front_cents - self.cash_out_cents, 0) + def payout_remaining_cents(self) -> int: + return max(self.payout_due_cents - self.paid_cents - self.rollover_out_cents, 0) @property - def payout_remaining_cents(self) -> int: - return max(self.payout_due_cents - self.paid_cents, 0) + def gross_payout_cents(self) -> int: + return max(self.cash_out_cents - self.front_cents, 0) + + @property + def settled_cents(self) -> int: + return self.paid_cents + self.rollover_out_cents + + @property + def current_due_cents(self) -> int: + return max(self.gross_payout_cents - self.settled_cents, 0) + + @property + def player_owes_cents(self) -> int: + return max(self.front_cents - self.cash_out_cents, 0) @property def payout_status(self) -> str: if self.player_owes_cents > 0: return "owes" - if self.payout_due_cents <= 0: + if self.gross_payout_cents <= 0: return "none" - if self.paid_cents <= 0: - return "unpaid" - if self.payout_remaining_cents <= 0: + if self.current_due_cents <= 0: return "paid" + if self.settled_cents <= 0: + return "unpaid" return "partial" @dataclass class SessionSummary: + session_id: str session_date: str entries: list[SessionEntry] status: str = "closed" + opened_at: str = "" @property def is_open(self) -> bool: @@ -85,6 +102,10 @@ class SessionSummary: def total_front_cents(self) -> int: return sum(entry.front_cents for entry in self.entries) + @property + def total_rollover_in_cents(self) -> int: + return sum(entry.rollover_in_cents for entry in self.entries) + @property def total_invested_cents(self) -> int: return sum(entry.invested_cents for entry in self.entries) @@ -93,18 +114,10 @@ class SessionSummary: def total_cash_out_cents(self) -> int: return sum(entry.cash_out_cents for entry in self.entries) - @property - def total_paid_cents(self) -> int: - return sum(entry.paid_cents for entry in self.entries) - @property def total_payout_due_cents(self) -> int: return sum(entry.payout_due_cents for entry in self.entries) - @property - def total_player_owes_cents(self) -> int: - return sum(entry.player_owes_cents for entry in self.entries) - @property def total_remaining_cents(self) -> int: return sum(entry.payout_remaining_cents for entry in self.entries) @@ -113,6 +126,26 @@ class SessionSummary: def total_net_cents(self) -> int: return sum(entry.net_cents for entry in self.entries) + @property + def total_gross_payout_cents(self) -> int: + return sum(entry.gross_payout_cents for entry in self.entries) + + @property + def total_paid_cents(self) -> int: + return sum(entry.paid_cents for entry in self.entries) + + @property + def total_rollover_out_cents(self) -> int: + return sum(entry.rollover_out_cents for entry in self.entries) + + @property + def total_current_due_cents(self) -> int: + return sum(entry.current_due_cents for entry in self.entries) + + @property + def total_player_owes_cents(self) -> int: + return sum(entry.player_owes_cents for entry in self.entries) + @dataclass class PlayerStats: @@ -128,8 +161,11 @@ class PlayerStats: biggest_loss_cents: int total_buy_in_cents: int total_front_cents: int + total_rollover_in_cents: int total_invested_cents: int total_cash_out_cents: int + total_paid_cents: int + total_rollover_out_cents: int total_net_cents: int roi_pct: float current_win_streak: int @@ -164,11 +200,21 @@ def cents_to_dollars(cents: int) -> str: return f"${value:,.2f}" -def safe_date_label(session_date: str) -> str: +def safe_date_label(raw_date: str) -> str: try: - return datetime.strptime(session_date, "%Y-%m-%d").strftime("%b %d, %Y") + return datetime.strptime(raw_date, "%Y-%m-%d").strftime("%b %d, %Y") except ValueError: - return session_date + return raw_date + + +def session_label(session: SessionSummary) -> str: + if session.session_id == session.session_date: + 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: @@ -191,32 +237,36 @@ def build_session_summaries(events: list[EventRow]) -> list[SessionSummary]: grouped: dict[tuple[str, str], SessionEntry] = {} by_session: dict[str, list[SessionEntry]] = defaultdict(list) session_status: dict[str, str] = {} - session_dates_seen: set[str] = set() + session_dates: dict[str, str] = {} + session_opened_at: dict[str, str] = {} for event in events: - session_date = event["session_date"] - event_type = event["event_type"] + session_id = event["session_id"].strip() or event["session_date"].strip() + session_date = event["session_date"].strip() + event_type = event["event_type"].strip() - if not session_date: + if not session_id or not session_date: continue - session_dates_seen.add(session_date) + session_dates[session_id] = session_date + session_opened_at.setdefault(session_id, event["created_at"]) if event_type == "session_open": - session_status[session_date] = "open" + session_status[session_id] = "open" continue if event_type == "session_close": - session_status[session_date] = "closed" + session_status[session_id] = "closed" continue player_name = event["player_name"].strip() if not player_name: continue - key = (session_date, player_name) + key = (session_id, player_name) if key not in grouped: grouped[key] = SessionEntry( + session_id=session_id, session_date=session_date, player_name=player_name, ) @@ -227,33 +277,46 @@ def build_session_summaries(events: list[EventRow]) -> list[SessionSummary]: entry.buy_in_cents += event["amount_cents"] elif event_type == "front": entry.front_cents += event["amount_cents"] + elif event_type == "rollover_in": + entry.rollover_in_cents += event["amount_cents"] elif event_type == "cashout": entry.cash_out_cents += event["amount_cents"] elif event_type == "paid": entry.paid_cents += event["amount_cents"] + elif event_type == "rollover_out": + entry.rollover_out_cents += event["amount_cents"] if event["note"]: entry.notes.append(event["note"]) for entry in grouped.values(): - by_session[entry.session_date].append(entry) + by_session[entry.session_id].append(entry) sessions: list[SessionSummary] = [] - for session_date in session_dates_seen: + for session_id, session_date in session_dates.items(): entries = sorted( - by_session.get(session_date, []), + by_session.get(session_id, []), key=lambda entry: entry.player_name.casefold(), ) sessions.append( SessionSummary( + session_id=session_id, session_date=session_date, entries=entries, - status=session_status.get(session_date, "closed"), + status=session_status.get(session_id, "closed"), + opened_at=session_opened_at.get(session_id, ""), ) ) - sessions.sort(key=lambda session: session.session_date, reverse=True) + sessions.sort( + key=lambda session: ( + session.session_date, + session.opened_at, + session.session_id, + ), + reverse=True, + ) return sessions @@ -346,8 +409,11 @@ def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]: total_buy_in = sum(entry.buy_in_cents for entry in entries) total_front = sum(entry.front_cents for entry in entries) + total_rollover_in = sum(entry.rollover_in_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_paid = sum(entry.paid_cents for entry in entries) + total_rollover_out = sum(entry.rollover_out_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 @@ -365,8 +431,11 @@ def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]: biggest_loss_cents=biggest_loss, total_buy_in_cents=total_buy_in, total_front_cents=total_front, + total_rollover_in_cents=total_rollover_in, total_invested_cents=total_invested, total_cash_out_cents=total_cash_out, + total_paid_cents=total_paid, + total_rollover_out_cents=total_rollover_out, total_net_cents=total_net, roi_pct=roi_pct, current_win_streak=run_summary["current_win_streak"], @@ -496,8 +565,8 @@ def session_breakdown_series(session: SessionSummary) -> dict[str, Any]: } -def session_events(events: list[EventRow], session_date: str) -> list[EventRow]: - return [event for event in events if event["session_date"] == session_date] +def session_events(events: list[EventRow], session_id: str) -> list[EventRow]: + return [event for event in events if event["session_id"] == session_id] def unique_player_names(events: list[EventRow]) -> list[str]: diff --git a/storage.py b/storage.py index b825ab8..f9c6662 100644 --- a/storage.py +++ b/storage.py @@ -10,6 +10,7 @@ from typing import TypedDict CSV_HEADERS = [ "id", "created_at", + "session_id", "session_date", "player_name", "event_type", @@ -22,6 +23,7 @@ CSV_HEADERS = [ class EventRow(TypedDict): id: str created_at: str + session_id: str session_date: str player_name: str event_type: str @@ -32,9 +34,11 @@ class EventRow(TypedDict): VALID_EVENT_TYPES = { "buyin", - "cashout", "front", + "cashout", "paid", + "rollover_in", + "rollover_out", "note", "session_open", "session_close", @@ -62,6 +66,7 @@ def load_events(csv_path: Path) -> list[EventRow]: EventRow( id=row["id"], created_at=row["created_at"], + session_id=row["session_id"], session_date=row["session_date"], player_name=row["player_name"], event_type=row["event_type"], @@ -79,6 +84,7 @@ def load_events(csv_path: Path) -> list[EventRow]: def append_event( csv_path: Path, + session_id: str, session_date: str, player_name: str, event_type: str, @@ -98,6 +104,7 @@ def append_event( event = EventRow( id=str(uuid.uuid4()), created_at=datetime.now(timezone.utc).isoformat(), + session_id=session_id.strip(), session_date=session_date.strip(), player_name=player_name.strip(), event_type=normalized_type, diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html index 5d5f19e..a5042d3 100644 --- a/templates/admin_dashboard.html +++ b/templates/admin_dashboard.html @@ -15,8 +15,15 @@

Add to the ledger

@@ -54,9 +63,9 @@
-
+

Session state

-

Open or close a session

+

Open a new session

- - +
@@ -130,8 +138,8 @@ {% for session in recent_sessions %} - - {{ session.session_date | pretty_date }} + + {{ session_label(session) }} {{ session.entries|length }} @@ -144,7 +152,7 @@
- + {% if session.status == "open" %} {% else %} diff --git a/templates/player_detail.html b/templates/player_detail.html index b2c1926..9f23b3e 100644 --- a/templates/player_detail.html +++ b/templates/player_detail.html @@ -14,8 +14,9 @@
Win %{{ "%.1f"|format(player.win_pct) }}%
ROI{{ "%.1f"|format(player.roi_pct) }}%
Total invested{{ player.total_invested_cents | money }}
-
Paid Buy-ins{{ player.total_buy_in_cents | money }}
+
Paid Buy-in{{ player.total_buy_in_cents | money }}
Fronted{{ player.total_front_cents | money }}
+
Cash-outs{{ player.total_cash_out_cents | money }}
Sessions{{ player.sessions_played }}
@@ -84,6 +85,11 @@
+ + + + + diff --git a/templates/session_detail.html b/templates/session_detail.html index ed15c8b..c23d52b 100644 --- a/templates/session_detail.html +++ b/templates/session_detail.html @@ -5,16 +5,21 @@

Session detail

-

{{ session.session_date | pretty_date }}

+

{{ session_label(session) }}

Current totals are derived from the event log for this date.

-
+
{% if prev_session %} - Previous Session + + Previous Session + {% endif %} + {% if next_session %} - Next Session + + Next Session + {% endif %}
@@ -22,8 +27,13 @@
- Total payout due - {{ session.total_payout_due_cents | money }} + Gross payout + {{ session.total_gross_payout_cents | money }} +
+ +
+ Total rolled out + {{ session.total_rollover_out_cents | money }}
@@ -32,17 +42,12 @@
- Total remaining - - {{ session.total_remaining_cents | money }} + Still owed + + {{ session.total_current_due_cents | money }}
-
- Total fronted - {{ session.total_front_cents | money }} -
-
Players still owe @@ -51,12 +56,22 @@
+

Results

Player totals

+ +
+ + +
@@ -64,13 +79,16 @@
- - - + + + + + + + + - - @@ -82,21 +100,41 @@ {{ entry.player_name }} - - + + + + + + + + + + + + + - - + {% for session in sessions %} - +
Paid Buy-ins{{ player.total_buy_in_cents | money }}
Fronted{{ player.total_front_cents | money }}
Rolled In{{ player.total_rollover_in_cents | money }}
Total Invested{{ player.total_invested_cents | money }}
Rolled Out{{ player.total_rollover_out_cents | money }}
Winning Sessions{{ player.winning_sessions }}
Losing Sessions{{ player.losing_sessions }}
Break-even Sessions{{ player.break_even_sessions }}
PlayerBuy-inFrontedCash-outInFrontedRolled inOut NetGross duePaidRolled outStill owed StatusPaidRemaining Notes
{{ entry.buy_in_cents | money }}{{ entry.front_cents | money }}{{ entry.invested_cents | money }}{{ entry.front_cents | money }}{{ entry.rollover_in_cents | money }} {{ entry.cash_out_cents | money }} {{ entry.net_cents | money }} + {% if entry.player_owes_cents > 0 %} + — + {% else %} + {{ entry.gross_payout_cents | money }} + {% endif %} + {{ entry.paid_cents | money }}{{ entry.rollover_out_cents | money }} + {% if entry.player_owes_cents > 0 %} + Owes {{ entry.player_owes_cents | money }} + {% else %} + {{ entry.current_due_cents | money }} + {% endif %} + {{ entry.payout_status }} {{ entry.paid_cents | money }} - {{ entry.payout_remaining_cents | money }} - {% if entry.notes %}
    @@ -270,4 +308,28 @@ }); } + + {% endblock %} diff --git a/templates/sessions.html b/templates/sessions.html index 61a51f8..9587dda 100644 --- a/templates/sessions.html +++ b/templates/sessions.html @@ -31,7 +31,7 @@
{{ session.session_date | pretty_date }}{{ session_label(session) }} {{ session.status }}