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'}"
|
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:
|
||||||
|
|||||||
@@ -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")
|
||||||
+27
-8
@@ -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,18 +1147,34 @@ 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,
|
||||||
}
|
}
|
||||||
|
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:
|
if len(form["name"]) < 2:
|
||||||
flash("League name must be at least 2 characters.", "error")
|
flash("League name must be at least 2 characters.", "error")
|
||||||
elif form["visibility"] not in ("private", "public"):
|
elif form["visibility"] not in ("private", "public"):
|
||||||
flash("Invalid visibility value.", "error")
|
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:
|
else:
|
||||||
from utils import slugify
|
from utils import slugify
|
||||||
|
|
||||||
@@ -1165,6 +1182,8 @@ def league_settings(league_ref: str):
|
|||||||
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"]
|
||||||
|
league.eligible_min_sessions = eligible_min
|
||||||
|
league.break_even_cents = break_even_cents
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash("League settings saved.", "success")
|
flash("League settings saved.", "success")
|
||||||
return redirect(url_for("leagues.league_settings", league_ref=league.url_ref))
|
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
|
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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in new issue
Block a user