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:
1 parent
0e34600714
commit
2fc7bab183
7 files changed
+86
-19
No files matched your search
@@ -9,7 +9,7 @@ DATA_PATH = BASE_DIR / "data" / "entries.csv"
|
||||
DEFAULT_DATABASE_URL = f"sqlite:///{BASE_DIR / 'data' / 'boker-dev.sqlite3'}"
|
||||
|
||||
ELIGIBLE_MIN_SESSIONS = 3
|
||||
APP_VERSION = "2.4.0"
|
||||
APP_VERSION = "2.5.0"
|
||||
|
||||
|
||||
def load_local_env(env_path: Path) -> None:
|
||||
|
||||
@@ -93,6 +93,8 @@ class League(TimestampMixin, db.Model):
|
||||
index=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__ = (
|
||||
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")
|
||||
+27
-8
@@ -304,7 +304,6 @@ def leaderboard(league_ref: str):
|
||||
flash("League database is not available.", "error")
|
||||
return redirect(url_for("public.home"))
|
||||
|
||||
from config import ELIGIBLE_MIN_SESSIONS
|
||||
from ledger_repositories import list_event_rows_for_league
|
||||
from league_repositories import list_players_for_league, user_has_league_role
|
||||
|
||||
@@ -312,6 +311,8 @@ def leaderboard(league_ref: str):
|
||||
if resp:
|
||||
return resp
|
||||
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))
|
||||
ordered_sessions = sorted(all_sessions, key=session_sort_key)
|
||||
|
||||
@@ -337,11 +338,11 @@ def leaderboard(league_ref: str):
|
||||
previous_sessions = []
|
||||
|
||||
board = apply_rank_changes(
|
||||
build_leaderboard(filtered_sessions),
|
||||
build_leaderboard(previous_sessions),
|
||||
build_leaderboard(filtered_sessions, break_even_cents),
|
||||
build_leaderboard(previous_sessions, break_even_cents),
|
||||
)
|
||||
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)
|
||||
recent_sessions_slice = filtered_sessions[-5:]
|
||||
@@ -352,18 +353,18 @@ def leaderboard(league_ref: str):
|
||||
})
|
||||
|
||||
if mode == "recent":
|
||||
main_board = build_leaderboard(recent_sessions_slice)
|
||||
main_board = build_leaderboard(recent_sessions_slice, break_even_cents)
|
||||
provisional_board = []
|
||||
elif mode == "eligible":
|
||||
main_board = [
|
||||
player
|
||||
for player in board
|
||||
if player.sessions_played >= ELIGIBLE_MIN_SESSIONS
|
||||
if player.sessions_played >= eligible_min_sessions
|
||||
]
|
||||
provisional_board = [
|
||||
player
|
||||
for player in board
|
||||
if player.sessions_played < ELIGIBLE_MIN_SESSIONS
|
||||
if player.sessions_played < eligible_min_sessions
|
||||
]
|
||||
else:
|
||||
main_board = board
|
||||
@@ -381,7 +382,7 @@ def leaderboard(league_ref: str):
|
||||
eligible_count=eligible_count,
|
||||
all_count=all_count,
|
||||
recent_count=recent_count,
|
||||
eligible_min_sessions=ELIGIBLE_MIN_SESSIONS,
|
||||
eligible_min_sessions=eligible_min_sessions,
|
||||
session_count=len(filtered_sessions),
|
||||
total_session_count=len(all_sessions),
|
||||
cash_paid_out_cents=cash_paid_out_cents,
|
||||
@@ -1146,18 +1147,34 @@ def league_settings(league_ref: str):
|
||||
"name": league.name,
|
||||
"description": league.description or "",
|
||||
"visibility": league.visibility,
|
||||
"eligible_min_sessions": league.eligible_min_sessions,
|
||||
"break_even_dollars": f"{league.break_even_cents / 100:.2f}",
|
||||
}
|
||||
|
||||
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 = {
|
||||
"name": request.form.get("name", "").strip(),
|
||||
"description": request.form.get("description", "").strip(),
|
||||
"visibility": request.form.get("visibility", "private").strip(),
|
||||
"eligible_min_sessions": raw_eligible,
|
||||
"break_even_dollars": raw_break_even,
|
||||
}
|
||||
try:
|
||||
eligible_min = int(raw_eligible)
|
||||
break_even_cents = round(float(raw_break_even) * 100)
|
||||
except (ValueError, TypeError):
|
||||
flash("Eligible sessions and break-even threshold must be valid numbers.", "error")
|
||||
else:
|
||||
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
|
||||
|
||||
@@ -1165,6 +1182,8 @@ def league_settings(league_ref: str):
|
||||
league.slug = slugify(form["name"])
|
||||
league.description = form["description"] or None
|
||||
league.visibility = form["visibility"]
|
||||
league.eligible_min_sessions = eligible_min
|
||||
league.break_even_cents = break_even_cents
|
||||
db.session.commit()
|
||||
flash("League settings saved.", "success")
|
||||
return redirect(url_for("leagues.league_settings", league_ref=league.url_ref))
|
||||
|
||||
+7
-7
@@ -103,7 +103,7 @@ def build_session_summaries(events: list[EventRow]) -> list[SessionSummary]:
|
||||
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)
|
||||
|
||||
longest_win_streak = 0
|
||||
@@ -116,7 +116,7 @@ def summarize_player_runs(entries: list[SessionEntry]) -> dict[str, int | str |
|
||||
|
||||
for entry in ordered_entries:
|
||||
net = entry.net_cents
|
||||
bucket = net_result_bucket(net)
|
||||
bucket = net_result_bucket(net, break_even_cents)
|
||||
|
||||
if best_entry is None or net > best_entry.net_cents:
|
||||
best_entry = entry
|
||||
@@ -141,7 +141,7 @@ def summarize_player_runs(entries: list[SessionEntry]) -> dict[str, int | str |
|
||||
current_loss_streak = 0
|
||||
|
||||
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 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)
|
||||
for session in sessions:
|
||||
for entry in session.entries:
|
||||
@@ -175,9 +175,9 @@ def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]:
|
||||
leaderboard: list[PlayerStats] = []
|
||||
for player_name, entries in player_entries.items():
|
||||
nets = [entry.net_cents for entry in entries]
|
||||
run_summary = summarize_player_runs(entries)
|
||||
wins = [value for value in nets if net_result_bucket(value) == "win"]
|
||||
losses = [value for value in nets if net_result_bucket(value) == "loss"]
|
||||
run_summary = summarize_player_runs(entries, break_even_cents)
|
||||
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, break_even_cents) == "loss"]
|
||||
|
||||
sessions_played = len(entries)
|
||||
winning_sessions = len(wins)
|
||||
|
||||
@@ -28,6 +28,20 @@
|
||||
<option value="public" {{ 'selected' if form.visibility == 'public' else '' }}>Public — leaderboard is visible to anyone</option>
|
||||
</select>
|
||||
</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>
|
||||
<button class="btn btn--primary" type="submit">Save changes</button>
|
||||
</div>
|
||||
|
||||
@@ -82,9 +82,9 @@ def entry_sort_key(entry: SessionEntry) -> tuple[str, int, str]:
|
||||
)
|
||||
|
||||
|
||||
def net_result_bucket(value_cents: int) -> str:
|
||||
if value_cents > BREAK_EVEN_TOLERANCE_CENTS:
|
||||
def net_result_bucket(value_cents: int, tolerance_cents: int = BREAK_EVEN_TOLERANCE_CENTS) -> str:
|
||||
if value_cents > tolerance_cents:
|
||||
return "win"
|
||||
if value_cents < -BREAK_EVEN_TOLERANCE_CENTS:
|
||||
if value_cents < -tolerance_cents:
|
||||
return "loss"
|
||||
return "even"
|
||||
Reference in new issue
Block a user