feat: per-league eligible sessions and break-even threshold settings (2.5.0)

Add two configurable leaderboard settings per league, replacing the global
constants with per-row DB columns:

- eligible_min_sessions (default 3): minimum sessions before a player ranks
  on the main leaderboard rather than showing as provisional
- break_even_cents (default 100 = $1.00): sessions within ±this of $0 net
  are classified as break-even rather than a win or loss

Changes:
- Migration 0003 adds both columns with server defaults for existing leagues
- League DB model gains eligible_min_sessions and break_even_cents columns
- net_result_bucket() and build_leaderboard() accept optional tolerance param
- summarize_player_runs() passes tolerance to net_result_bucket for streaks
- League leaderboard route reads values from the league row instead of config
- League settings form exposes both fields with validation (1-100 sessions,
  $0.00-$100.00 threshold)
This commit is contained in:
SowinskiBraeden committed 2026-06-26 21:37:47 -07:00
1 parent 0e34600714
commit 2fc7bab183
7 files changed
+98 -31

No files matched your search

+1 -1
View File
@@ -9,7 +9,7 @@ DATA_PATH = BASE_DIR / "data" / "entries.csv"
DEFAULT_DATABASE_URL = f"sqlite:///{BASE_DIR / 'data' / 'boker-dev.sqlite3'}" DEFAULT_DATABASE_URL = f"sqlite:///{BASE_DIR / 'data' / 'boker-dev.sqlite3'}"
ELIGIBLE_MIN_SESSIONS = 3 ELIGIBLE_MIN_SESSIONS = 3
APP_VERSION = "2.4.0" APP_VERSION = "2.5.0"
def load_local_env(env_path: Path) -> None: def load_local_env(env_path: Path) -> None:
+2
View File
@@ -93,6 +93,8 @@ class League(TimestampMixin, db.Model):
index=True, index=True,
) )
archived_at = db.Column(db.DateTime(timezone=True), nullable=True) archived_at = db.Column(db.DateTime(timezone=True), nullable=True)
eligible_min_sessions = db.Column(db.Integer, nullable=False, default=3)
break_even_cents = db.Column(db.Integer, nullable=False, default=100)
__table_args__ = ( __table_args__ = (
db.CheckConstraint( db.CheckConstraint(
@@ -0,0 +1,32 @@
"""add per-league eligible_min_sessions and break_even_cents
Revision ID: 0003_league_settings
Revises: 0002_league_public_key
Create Date: 2026-06-26
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0003_league_settings"
down_revision = "0002_league_public_key"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("leagues") as batch_op:
batch_op.add_column(sa.Column(
"eligible_min_sessions", sa.Integer(), nullable=False, server_default="3"
))
batch_op.add_column(sa.Column(
"break_even_cents", sa.Integer(), nullable=False, server_default="100"
))
def downgrade() -> None:
with op.batch_alter_table("leagues") as batch_op:
batch_op.drop_column("break_even_cents")
batch_op.drop_column("eligible_min_sessions")
+39 -20
View File
@@ -304,7 +304,6 @@ def leaderboard(league_ref: str):
flash("League database is not available.", "error") flash("League database is not available.", "error")
return redirect(url_for("public.home")) return redirect(url_for("public.home"))
from config import ELIGIBLE_MIN_SESSIONS
from ledger_repositories import list_event_rows_for_league from ledger_repositories import list_event_rows_for_league
from league_repositories import list_players_for_league, user_has_league_role from league_repositories import list_players_for_league, user_has_league_role
@@ -312,6 +311,8 @@ def leaderboard(league_ref: str):
if resp: if resp:
return resp return resp
is_owner = user_has_league_role(current_user_id() or "", league.id, {"owner"}) is_owner = user_has_league_role(current_user_id() or "", league.id, {"owner"})
eligible_min_sessions = league.eligible_min_sessions
break_even_cents = league.break_even_cents
all_sessions = build_session_summaries(list_event_rows_for_league(league.id)) all_sessions = build_session_summaries(list_event_rows_for_league(league.id))
ordered_sessions = sorted(all_sessions, key=session_sort_key) ordered_sessions = sorted(all_sessions, key=session_sort_key)
@@ -337,11 +338,11 @@ def leaderboard(league_ref: str):
previous_sessions = [] previous_sessions = []
board = apply_rank_changes( board = apply_rank_changes(
build_leaderboard(filtered_sessions), build_leaderboard(filtered_sessions, break_even_cents),
build_leaderboard(previous_sessions), build_leaderboard(previous_sessions, break_even_cents),
) )
eligible_count = sum( eligible_count = sum(
1 for player in board if player.sessions_played >= ELIGIBLE_MIN_SESSIONS 1 for player in board if player.sessions_played >= eligible_min_sessions
) )
all_count = len(board) all_count = len(board)
recent_sessions_slice = filtered_sessions[-5:] recent_sessions_slice = filtered_sessions[-5:]
@@ -352,18 +353,18 @@ def leaderboard(league_ref: str):
}) })
if mode == "recent": if mode == "recent":
main_board = build_leaderboard(recent_sessions_slice) main_board = build_leaderboard(recent_sessions_slice, break_even_cents)
provisional_board = [] provisional_board = []
elif mode == "eligible": elif mode == "eligible":
main_board = [ main_board = [
player player
for player in board for player in board
if player.sessions_played >= ELIGIBLE_MIN_SESSIONS if player.sessions_played >= eligible_min_sessions
] ]
provisional_board = [ provisional_board = [
player player
for player in board for player in board
if player.sessions_played < ELIGIBLE_MIN_SESSIONS if player.sessions_played < eligible_min_sessions
] ]
else: else:
main_board = board main_board = board
@@ -381,7 +382,7 @@ def leaderboard(league_ref: str):
eligible_count=eligible_count, eligible_count=eligible_count,
all_count=all_count, all_count=all_count,
recent_count=recent_count, recent_count=recent_count,
eligible_min_sessions=ELIGIBLE_MIN_SESSIONS, eligible_min_sessions=eligible_min_sessions,
session_count=len(filtered_sessions), session_count=len(filtered_sessions),
total_session_count=len(all_sessions), total_session_count=len(all_sessions),
cash_paid_out_cents=cash_paid_out_cents, cash_paid_out_cents=cash_paid_out_cents,
@@ -1146,28 +1147,46 @@ def league_settings(league_ref: str):
"name": league.name, "name": league.name,
"description": league.description or "", "description": league.description or "",
"visibility": league.visibility, "visibility": league.visibility,
"eligible_min_sessions": league.eligible_min_sessions,
"break_even_dollars": f"{league.break_even_cents / 100:.2f}",
} }
if request.method == "POST": if request.method == "POST":
raw_eligible = request.form.get("eligible_min_sessions", "3").strip()
raw_break_even = request.form.get("break_even_dollars", "1.00").strip()
form = { form = {
"name": request.form.get("name", "").strip(), "name": request.form.get("name", "").strip(),
"description": request.form.get("description", "").strip(), "description": request.form.get("description", "").strip(),
"visibility": request.form.get("visibility", "private").strip(), "visibility": request.form.get("visibility", "private").strip(),
"eligible_min_sessions": raw_eligible,
"break_even_dollars": raw_break_even,
} }
if len(form["name"]) < 2: try:
flash("League name must be at least 2 characters.", "error") eligible_min = int(raw_eligible)
elif form["visibility"] not in ("private", "public"): break_even_cents = round(float(raw_break_even) * 100)
flash("Invalid visibility value.", "error") except (ValueError, TypeError):
flash("Eligible sessions and break-even threshold must be valid numbers.", "error")
else: else:
from utils import slugify if len(form["name"]) < 2:
flash("League name must be at least 2 characters.", "error")
elif form["visibility"] not in ("private", "public"):
flash("Invalid visibility value.", "error")
elif eligible_min < 1 or eligible_min > 100:
flash("Eligible minimum must be between 1 and 100.", "error")
elif break_even_cents < 0 or break_even_cents > 10000:
flash("Break-even threshold must be between $0.00 and $100.00.", "error")
else:
from utils import slugify
league.name = form["name"] league.name = form["name"]
league.slug = slugify(form["name"]) league.slug = slugify(form["name"])
league.description = form["description"] or None league.description = form["description"] or None
league.visibility = form["visibility"] league.visibility = form["visibility"]
db.session.commit() league.eligible_min_sessions = eligible_min
flash("League settings saved.", "success") league.break_even_cents = break_even_cents
return redirect(url_for("leagues.league_settings", league_ref=league.url_ref)) db.session.commit()
flash("League settings saved.", "success")
return redirect(url_for("leagues.league_settings", league_ref=league.url_ref))
from league_repositories import list_members_for_league from league_repositories import list_members_for_league
+7 -7
View File
@@ -103,7 +103,7 @@ def build_session_summaries(events: list[EventRow]) -> list[SessionSummary]:
return sessions return sessions
def summarize_player_runs(entries: list[SessionEntry]) -> dict[str, int | str | None]: def summarize_player_runs(entries: list[SessionEntry], break_even_cents: int = 100) -> dict[str, int | str | None]:
ordered_entries = sorted(entries, key=entry_sort_key) ordered_entries = sorted(entries, key=entry_sort_key)
longest_win_streak = 0 longest_win_streak = 0
@@ -116,7 +116,7 @@ def summarize_player_runs(entries: list[SessionEntry]) -> dict[str, int | str |
for entry in ordered_entries: for entry in ordered_entries:
net = entry.net_cents net = entry.net_cents
bucket = net_result_bucket(net) bucket = net_result_bucket(net, break_even_cents)
if best_entry is None or net > best_entry.net_cents: if best_entry is None or net > best_entry.net_cents:
best_entry = entry best_entry = entry
@@ -141,7 +141,7 @@ def summarize_player_runs(entries: list[SessionEntry]) -> dict[str, int | str |
current_loss_streak = 0 current_loss_streak = 0
for entry in reversed(ordered_entries): for entry in reversed(ordered_entries):
bucket = net_result_bucket(entry.net_cents) bucket = net_result_bucket(entry.net_cents, break_even_cents)
if bucket == "win": if bucket == "win":
if current_loss_streak > 0: if current_loss_streak > 0:
@@ -166,7 +166,7 @@ def summarize_player_runs(entries: list[SessionEntry]) -> dict[str, int | str |
} }
def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]: def build_leaderboard(sessions: list[SessionSummary], break_even_cents: int = 100) -> list[PlayerStats]:
player_entries: dict[str, list[SessionEntry]] = defaultdict(list) player_entries: dict[str, list[SessionEntry]] = defaultdict(list)
for session in sessions: for session in sessions:
for entry in session.entries: for entry in session.entries:
@@ -175,9 +175,9 @@ def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]:
leaderboard: list[PlayerStats] = [] leaderboard: list[PlayerStats] = []
for player_name, entries in player_entries.items(): for player_name, entries in player_entries.items():
nets = [entry.net_cents for entry in entries] nets = [entry.net_cents for entry in entries]
run_summary = summarize_player_runs(entries) run_summary = summarize_player_runs(entries, break_even_cents)
wins = [value for value in nets if net_result_bucket(value) == "win"] wins = [value for value in nets if net_result_bucket(value, break_even_cents) == "win"]
losses = [value for value in nets if net_result_bucket(value) == "loss"] losses = [value for value in nets if net_result_bucket(value, break_even_cents) == "loss"]
sessions_played = len(entries) sessions_played = len(entries)
winning_sessions = len(wins) winning_sessions = len(wins)
+14
View File
@@ -28,6 +28,20 @@
<option value="public" {{ 'selected' if form.visibility == 'public' else '' }}>Public — leaderboard is visible to anyone</option> <option value="public" {{ 'selected' if form.visibility == 'public' else '' }}>Public — leaderboard is visible to anyone</option>
</select> </select>
</label> </label>
<div style="margin-top:8px;padding-top:20px;border-top:1px solid var(--border);">
<p class="eyebrow">Leaderboard</p>
<p class="muted-text" style="margin-top:4px;margin-bottom:0;font-size:.82rem;">Controls how players qualify and how sessions are classified.</p>
</div>
<div class="session-create-row" style="grid-template-columns:1fr 1fr;">
<label>
<span>Sessions to rank <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">Minimum number of sessions a player must have played to appear on the ranked leaderboard. Players below this threshold are shown as provisional.</span></span></span>
<input type="number" name="eligible_min_sessions" value="{{ form.eligible_min_sessions }}" min="1" max="100" step="1" required>
</label>
<label>
<span>Break-even threshold ($) <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">A session within ±this amount of $0 is counted as a break-even rather than a win or loss. Default is $1.00.</span></span></span>
<input type="number" name="break_even_dollars" value="{{ form.break_even_dollars }}" min="0" max="100" step="0.50" required>
</label>
</div>
<div> <div>
<button class="btn btn--primary" type="submit">Save changes</button> <button class="btn btn--primary" type="submit">Save changes</button>
</div> </div>
+3 -3
View File
@@ -82,9 +82,9 @@ def entry_sort_key(entry: SessionEntry) -> tuple[str, int, str]:
) )
def net_result_bucket(value_cents: int) -> str: def net_result_bucket(value_cents: int, tolerance_cents: int = BREAK_EVEN_TOLERANCE_CENTS) -> str:
if value_cents > BREAK_EVEN_TOLERANCE_CENTS: if value_cents > tolerance_cents:
return "win" return "win"
if value_cents < -BREAK_EVEN_TOLERANCE_CENTS: if value_cents < -tolerance_cents:
return "loss" return "loss"
return "even" return "even"