fix player win % and visual overhaul

This commit is contained in:
SowinskiBraeden committed 2026-09-05 19:19:46 -07:00
1 parent 1b9f9510ec
commit 5c09266d75
16 files changed
+3039 -1406

No files matched your search

+2
View File
@@ -98,6 +98,8 @@ class League(TimestampMixin, db.Model):
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) eligible_min_sessions = db.Column(db.Integer, nullable=False, default=3)
break_even_cents = db.Column(db.Integer, nullable=False, default=100) break_even_cents = db.Column(db.Integer, nullable=False, default=100)
default_buyin_cents = db.Column(db.Integer, nullable=False, default=0)
default_rebuy_cents = db.Column(db.Integer, nullable=False, default=0)
__table_args__ = ( __table_args__ = (
db.CheckConstraint( db.CheckConstraint(
+91 -39
View File
@@ -105,12 +105,65 @@ def session_ref_map(league_id: str) -> dict[str, str]:
return {session_event_ref(session): session.id for session in list_sessions_for_league(league_id)} return {session_event_ref(session): session.id for session in list_sessions_for_league(league_id)}
@leagues_bp.get("/leagues") @leagues_bp.route("/leagues", methods=["GET", "POST"])
@login_required @login_required
def index(): def index():
if not db_ready(): if not db_ready():
flash("League database is not available.", "error") flash("League database is not available.", "error")
return render_template("leagues_index.html", leagues=[]) return render_template("leagues_index.html", leagues=[], form={})
def _parse_cents(key: str) -> int:
raw = request.form.get(key, "").strip()
try:
return max(0, int(round(float(raw) * 100))) if raw else 0
except ValueError:
return 0
form = {
"name": request.form.get("name", "").strip(),
"description": request.form.get("description", "").strip(),
"visibility": request.form.get("visibility", "private"),
"default_buyin_dollars": request.form.get("default_buyin_dollars", "").strip(),
"default_rebuy_dollars": request.form.get("default_rebuy_dollars", "").strip(),
"eligible_min_sessions": request.form.get("eligible_min_sessions", "").strip(),
"break_even_dollars": request.form.get("break_even_dollars", "").strip(),
}
if request.method == "POST":
from boker.db_models import User
from boker.league_repositories import create_league, unique_league_slug
owner = db.session.get(User, current_user_id())
if owner is None:
flash("Login required.", "error")
return redirect(url_for("account.login"))
if len(form["name"]) < 2:
flash("League name must be at least 2 characters.", "error")
else:
try:
eligible_min = int(form["eligible_min_sessions"]) if form["eligible_min_sessions"] else 3
break_even_cents = round(float(form["break_even_dollars"]) * 100) if form["break_even_dollars"] else 100
except (ValueError, TypeError):
flash("Sessions to rank and break-even threshold must be valid numbers.", "error")
eligible_min = None
if eligible_min is not None:
slug = unique_league_slug(form["name"])
league = create_league(
owner=owner,
name=form["name"],
slug=slug,
description=form["description"] or None,
)
league.visibility = form["visibility"] if form["visibility"] in ("public", "private") else "private"
league.default_buyin_cents = _parse_cents("default_buyin_dollars")
league.default_rebuy_cents = _parse_cents("default_rebuy_dollars")
league.eligible_min_sessions = max(1, eligible_min)
league.break_even_cents = max(0, break_even_cents)
db.session.commit()
flash("League created.", "success")
return redirect(url_for("leagues.dashboard", **league_url_values(league)))
from boker.league_repositories import list_leagues_for_user from boker.league_repositories import list_leagues_for_user
from boker.ledger_repositories import list_event_rows_for_league from boker.ledger_repositories import list_event_rows_for_league
@@ -138,6 +191,7 @@ def index():
+ session.total_current_due_to_house_cents + session.total_current_due_to_house_cents
for session in summaries for session in summaries
) )
cash_paid_out_cents = sum(session.total_paid_out_cents for session in summaries)
total_sessions += len(summaries) total_sessions += len(summaries)
total_players += players_count total_players += players_count
total_events += len(events) total_events += len(events)
@@ -155,9 +209,7 @@ def index():
"players_count": players_count, "players_count": players_count,
"sessions_count": len(summaries), "sessions_count": len(summaries),
"events_count": len(events), "events_count": len(events),
"cash_paid_out_cents": sum( "cash_paid_out_cents": cash_paid_out_cents,
session.total_paid_out_cents for session in summaries
),
"open_items_cents": open_items_cents, "open_items_cents": open_items_cents,
} }
) )
@@ -168,51 +220,21 @@ def index():
"sessions_count": total_sessions, "sessions_count": total_sessions,
"events_count": total_events, "events_count": total_events,
"open_items_cents": total_open_items_cents, "open_items_cents": total_open_items_cents,
"total_paid_out_cents": sum(item["cash_paid_out_cents"] for item in league_summaries),
} }
return render_template( return render_template(
"leagues_index.html", "leagues_index.html",
leagues=league_summaries, leagues=league_summaries,
page_summary=page_summary, page_summary=page_summary,
session_label=session_label, session_label=session_label,
form=form,
) )
@leagues_bp.route("/leagues/new", methods=["GET", "POST"]) @leagues_bp.get("/leagues/new")
@login_required @login_required
def new(): def new():
if not db_ready(): return redirect(url_for("leagues.index"))
flash("League database is not available.", "error")
return render_template("league_new.html", form={})
form = {
"name": request.form.get("name", "").strip(),
"description": request.form.get("description", "").strip(),
}
if request.method == "POST":
from boker.db_models import User
from boker.league_repositories import create_league, unique_league_slug
owner = db.session.get(User, current_user_id())
if owner is None:
flash("Login required.", "error")
return redirect(url_for("account.login"))
if len(form["name"]) < 2:
flash("League name must be at least 2 characters.", "error")
else:
slug = unique_league_slug(form["name"])
league = create_league(
owner=owner,
name=form["name"],
slug=slug,
description=form["description"] or None,
)
db.session.commit()
flash("League created.", "success")
return redirect(url_for("leagues.dashboard", **league_url_values(league)))
return render_template("league_new.html", form=form)
@leagues_bp.get("/l/<league_ref>") @leagues_bp.get("/l/<league_ref>")
@@ -272,6 +294,14 @@ def dashboard(league_ref: str):
"has_sessions": bool(all_sessions), "has_sessions": bool(all_sessions),
} }
from boker.services import build_leaderboard
leaderboard = build_leaderboard(all_sessions, league.break_even_cents)
live_sessions = [s for s in all_sessions if s.is_open]
live_session = live_sessions[0] if live_sessions else None
recent_sessions = sorted(all_sessions, key=session_sort_key, reverse=True)[:3]
house_balance_cents = total_paid_out - total_cash_in
return render_template( return render_template(
"league_dashboard.html", "league_dashboard.html",
league=league, league=league,
@@ -279,6 +309,12 @@ def dashboard(league_ref: str):
can_manage=can_manage, can_manage=can_manage,
is_owner=is_owner, is_owner=is_owner,
cash_stats=cash_stats, cash_stats=cash_stats,
live_session=live_session,
recent_sessions=recent_sessions,
leaderboard_top=leaderboard[:3],
house_balance_cents=house_balance_cents,
session_label=session_label,
total_sessions=len(all_sessions),
) )
@@ -1092,6 +1128,8 @@ def session_detail(league_ref: str, session_id: str):
is_owner=is_owner, is_owner=is_owner,
session_label=session_label, session_label=session_label,
seasons=seasons, seasons=seasons,
default_buyin_cents=league.default_buyin_cents,
default_rebuy_cents=league.default_rebuy_cents,
) )
@@ -1410,21 +1448,29 @@ def league_settings(league_ref: str):
"visibility": league.visibility, "visibility": league.visibility,
"eligible_min_sessions": league.eligible_min_sessions, "eligible_min_sessions": league.eligible_min_sessions,
"break_even_dollars": f"{league.break_even_cents / 100:.2f}", "break_even_dollars": f"{league.break_even_cents / 100:.2f}",
"default_buyin_dollars": f"{league.default_buyin_cents / 100:.2f}",
"default_rebuy_dollars": f"{league.default_rebuy_cents / 100:.2f}",
} }
if request.method == "POST": if request.method == "POST":
raw_eligible = request.form.get("eligible_min_sessions", "3").strip() raw_eligible = request.form.get("eligible_min_sessions", "3").strip()
raw_break_even = request.form.get("break_even_dollars", "1.00").strip() raw_break_even = request.form.get("break_even_dollars", "1.00").strip()
raw_default_buyin = request.form.get("default_buyin_dollars", "0").strip()
raw_default_rebuy = request.form.get("default_rebuy_dollars", "0").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, "eligible_min_sessions": raw_eligible,
"break_even_dollars": raw_break_even, "break_even_dollars": raw_break_even,
"default_buyin_dollars": raw_default_buyin,
"default_rebuy_dollars": raw_default_rebuy,
} }
try: try:
eligible_min = int(raw_eligible) eligible_min = int(raw_eligible)
break_even_cents = round(float(raw_break_even) * 100) break_even_cents = round(float(raw_break_even) * 100)
default_buyin_cents = round(float(raw_default_buyin or "0") * 100)
default_rebuy_cents = round(float(raw_default_rebuy or "0") * 100)
except (ValueError, TypeError): except (ValueError, TypeError):
flash("Eligible sessions and break-even threshold must be valid numbers.", "error") flash("Eligible sessions and break-even threshold must be valid numbers.", "error")
else: else:
@@ -1436,6 +1482,10 @@ def league_settings(league_ref: str):
flash("Eligible minimum must be between 1 and 100.", "error") flash("Eligible minimum must be between 1 and 100.", "error")
elif break_even_cents < 0 or break_even_cents > 10000: elif break_even_cents < 0 or break_even_cents > 10000:
flash("Break-even threshold must be between $0.00 and $100.00.", "error") flash("Break-even threshold must be between $0.00 and $100.00.", "error")
elif default_buyin_cents < 0 or default_buyin_cents > 1000000:
flash("Default buy-in must be between $0.00 and $10,000.00.", "error")
elif default_rebuy_cents < 0 or default_rebuy_cents > 1000000:
flash("Default rebuy must be between $0.00 and $10,000.00.", "error")
else: else:
from boker.utils import slugify from boker.utils import slugify
@@ -1445,6 +1495,8 @@ def league_settings(league_ref: str):
league.visibility = form["visibility"] league.visibility = form["visibility"]
league.eligible_min_sessions = eligible_min league.eligible_min_sessions = eligible_min
league.break_even_cents = break_even_cents league.break_even_cents = break_even_cents
league.default_buyin_cents = default_buyin_cents
league.default_rebuy_cents = default_rebuy_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))
@@ -0,0 +1,43 @@
"""add default_buyin_cents to leagues
Revision ID: 0006_league_default_buyin
Revises: 0005_site_admin_accounts
Create Date: 2026-08-21
"""
from __future__ import annotations
from alembic import context, op
import sqlalchemy as sa
revision = "0006_league_default_buyin"
down_revision = "0005_site_admin_accounts"
branch_labels = None
depends_on = None
def upgrade() -> None:
if context.is_offline_mode():
op.add_column(
"leagues",
sa.Column("default_buyin_cents", sa.Integer(), nullable=False, server_default="0"),
)
return
columns = {column["name"] for column in sa.inspect(op.get_bind()).get_columns("leagues")}
if "default_buyin_cents" not in columns:
with op.batch_alter_table("leagues") as batch_op:
batch_op.add_column(sa.Column(
"default_buyin_cents", sa.Integer(), nullable=False, server_default="0"
))
def downgrade() -> None:
if context.is_offline_mode():
op.drop_column("leagues", "default_buyin_cents")
return
columns = {column["name"] for column in sa.inspect(op.get_bind()).get_columns("leagues")}
if "default_buyin_cents" in columns:
with op.batch_alter_table("leagues") as batch_op:
batch_op.drop_column("default_buyin_cents")
@@ -0,0 +1,43 @@
"""add default_rebuy_cents to leagues
Revision ID: 0007_league_default_rebuy
Revises: 0006_league_default_buyin
Create Date: 2026-08-21
"""
from __future__ import annotations
from alembic import context, op
import sqlalchemy as sa
revision = "0007_league_default_rebuy"
down_revision = "0006_league_default_buyin"
branch_labels = None
depends_on = None
def upgrade() -> None:
if context.is_offline_mode():
op.add_column(
"leagues",
sa.Column("default_rebuy_cents", sa.Integer(), nullable=False, server_default="0"),
)
return
columns = {column["name"] for column in sa.inspect(op.get_bind()).get_columns("leagues")}
if "default_rebuy_cents" not in columns:
with op.batch_alter_table("leagues") as batch_op:
batch_op.add_column(sa.Column(
"default_rebuy_cents", sa.Integer(), nullable=False, server_default="0"
))
def downgrade() -> None:
if context.is_offline_mode():
op.drop_column("leagues", "default_rebuy_cents")
return
columns = {column["name"] for column in sa.inspect(op.get_bind()).get_columns("leagues")}
if "default_rebuy_cents" in columns:
with op.batch_alter_table("leagues") as batch_op:
batch_op.drop_column("default_rebuy_cents")
+1734 -564
View File
File diff suppressed because it is too large. Load diff
+76 -61
View File
@@ -1,88 +1,103 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Archivo:wght@500;600;700;800;900&family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700;800&display=swap');
:root { :root {
/* Core surfaces */ /* Core surfaces */
--bg: #0a0a0d; --bg: #101419;
--surface: #111115; --surface: #171D25;
--surface-raised:#16161c; --surface-raised:#202835;
--surface-head: #18181f; --surface-head: #202835;
--surface-sunk: #0d0d11; --surface-sunk: #0D1116;
--field: #1a1a22; --surface-2: #202835;
--field-alt: #1c1c25; --field: #1D2530;
--field-alt: #26313E;
--panel: #171D25;
--panel-hover: #202835;
/* ---- borders / dividers ---- */ /* ---- borders / dividers ---- */
--border: #22222b; --border: #2B3542;
--border-strong: #2a2a34; --border-strong: #3A4655;
--border-hi: #32323d; --border-hi: #505D6E;
--divider: #191921; --divider: #25303B;
--slash: #383843; --slash: #3A4655;
/* ---- text ---- */ /* ---- text ---- */
--text: #f4f2f9; --text: #F2F0ED;
--text-strong: #edeaf5; --text-strong: #F2F0ED;
--text-body: #e8e6ef; --text-body: #C7CED8;
--text-2: #bdbbc8; --text-2: #C7CED8;
--num: #b2b0bb; --num: #A1ACBA;
--muted: #8c8a96; --muted: #9AA5B4;
--muted-2: #82808c; --muted-2: #8793A3;
--faint: #7a7885; --faint: #6E7A8A;
--faint-2: #72707d; --faint-2: #647080;
--faintest: #64626d; --faintest: #576272;
--faintest-2: #55535e; --faintest-2: #4B5666;
/* Brand accent */ /* Brand accent — muted purple */
--accent: #9b8cf0; --accent: #9B8FE3;
--accent-ink: #12101e; --accent-ink: #0d0b18;
--accent-tint: #1f1d2e; --accent-tint: #211D36;
--accent-chip: #242137; --accent-chip: #292442;
--accent-chip-bd:#383152; --accent-chip-bd:#4D4581;
--accent-a22: rgba(155,140,240,0.22); --accent-a22: rgba(155,143,227,0.22);
--accent-a30: rgba(155,140,240,0.30); --accent-a30: rgba(155,143,227,0.30);
/* Secondary accent — teal */
--accent2: #35C6B8;
--accent2-ink: #04211e;
--accent2-tint: rgba(53,198,184,0.12);
--accent2-tint-bd:rgba(53,198,184,0.34);
/* Tertiary accent — azure */
--accent3: #5AA2F5;
--accent3-ink: #041427;
--accent3-tint: rgba(90,162,245,0.12);
--accent3-tint-bd:rgba(90,162,245,0.34);
/* ---- semantic: poker value scale ---- */ /* ---- semantic: poker value scale ---- */
--pos: #6fc093; --pos: #58D18E;
--pos-bar: #41815a; --pos-bar: #379562;
--pos-tint: rgba(111,192,147,0.12); --pos-tint: rgba(88,209,142,0.12);
--pos-tint-bd: rgba(111,192,147,0.30); --pos-tint-bd: rgba(88,209,142,0.30);
--neg: #e0758a; --neg: #F06F7A;
--neg-bar: #9e445a; --neg-bar: #AC4550;
--neg-tint: rgba(224,117,138,0.13); --neg-tint: rgba(240,111,122,0.12);
--neg-tint-bd: rgba(224,117,138,0.32); --neg-tint-bd: rgba(240,111,122,0.30);
--owes: #e8829a; --owes: #F06F7A;
--owes-tint: rgba(232,130,154,0.15); --owes-tint: rgba(240,111,122,0.14);
--owes-tint-bd: rgba(232,130,154,0.40); --owes-tint-bd: rgba(240,111,122,0.34);
/* ---- semantic: cash-movement hues ---- */ /* ---- semantic: cash-movement hues ---- */
--warn: #e0b15c; --warn: #E0B85C;
--warn-tint: rgba(224,177,92,0.13); --warn-tint: rgba(224,184,92,0.12);
--warn-tint-bd: rgba(224,177,92,0.32); --warn-tint-bd: rgba(224,184,92,0.30);
--rolled: #8f93c2; --rolled: #8FA4BB;
/* ---- medals ---- */ /* ---- medals ---- */
--rank-1: var(--accent); --rank-1: var(--accent);
--rank-2: #c2bcd0; --rank-2: var(--accent2);
--rank-3: #a78bb0; --rank-3: var(--accent3);
/* ---- chart line palette (top-N order) ---- */ /* ---- chart line palette (top-N order) ---- */
--line-1: #9b8cf0; --line-2: #6fc093; --line-3: #e0b15c; --line-1: #9B8FE3; --line-2: #58D18E; --line-3: #E0B85C;
--line-4: #cf6f86; --line-5: #8f93c2; --line-4: #F06F7A; --line-5: #8FA4BB;
/* Elevation */ /* Elevation */
--shadow-sm: 0 1px 2px rgba(0,0,0,.28); --shadow-sm: 0 1px 3px rgba(0,0,0,.38);
--shadow-md: 0 3px 12px rgba(0,0,0,.32), 0 1px 4px rgba(0,0,0,.24); --shadow-md: 0 2px 10px rgba(0,0,0,.42), 0 1px 3px rgba(0,0,0,.28);
--shadow-lg: 0 10px 28px rgba(0,0,0,.38), 0 3px 10px rgba(0,0,0,.26); --shadow-lg: 0 8px 28px rgba(0,0,0,.46), 0 2px 8px rgba(0,0,0,.28);
--shadow-xl: 0 18px 48px rgba(0,0,0,.46), 0 6px 18px rgba(0,0,0,.30); --shadow-xl: 0 18px 48px rgba(0,0,0,.54), 0 4px 16px rgba(0,0,0,.32);
/* ---- glass panel effect ---- */ /* ---- glass panel effect ---- */
--glass-border: 1px solid rgba(255,255,255,.055); --glass-border: 1px solid var(--border);
--glass-bg: rgba(255,255,255,.02); --glass-bg: transparent;
/* ---- spacing scale (4pt) ---- */ /* ---- spacing scale (4pt) ---- */
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:20px; --s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:20px;
--s6:24px; --s8:32px; --s10:40px; --s12:48px; --s6:24px; --s8:32px; --s10:40px; --s12:48px;
/* ---- radii ---- */ /* ---- radii — industrial, minimal rounding ---- */
--r-pill:5px; --r-sm:7px; --r-md:9px; --r-lg:12px; --r-pill:4px; --r-sm:4px; --r-md:5px; --r-lg:6px;
/* ---- containers ---- */ /* ---- containers ---- */
--w-public:1320px; --w-public:1320px;
@@ -91,6 +106,6 @@
--gutter: clamp(16px, 4vw, 40px); --gutter: clamp(16px, 4vw, 40px);
/* ---- Chart.js backward compat (read as raw strings by JS) ---- */ /* ---- Chart.js backward compat (read as raw strings by JS) ---- */
--text-muted: #bdbbc8; --text-muted: #9AA5B4;
--line: #22222b; --line: #2B3542;
} }
+121 -386
View File
@@ -81,433 +81,168 @@
{% block page_class %}page--landing{% endblock %} {% block page_class %}page--landing{% endblock %}
{% block content %} {% block content %}
<div class="lp-hero"> <section class="home-hero home-hero--quiet">
<div class="lp-hero__content"> <div class="home-hero__inner">
<p class="lp-kicker">Home Poker Tracking</p> <div class="home-hero__copy">
<h1 class="lp-h1"> <p class="home-kicker">Poker ledger &amp; league tracker</p>
<span class="lp-h1__line">Free poker</span> <h1 class="home-title">Free poker ledger for home games.</h1>
<span class="lp-h1__line">tracker</span> <p class="home-sub">Run a private poker league without rebuilding a spreadsheet after every session. Track players, buy-ins, cashouts, standings, and settlement history in one clean place.</p>
<span class="lp-h1__accent">for home games.</span> <div class="home-actions">
</h1>
<p class="lp-sub">Track buy-ins, cashouts, player stats, profit and loss, settlements, and league leaderboards without rebuilding the same spreadsheet every game night.</p>
<div class="lp-cta">
{% if is_logged_in %} {% if is_logged_in %}
<a class="btn btn--primary btn--lg" href="{{ url_for('leagues.index') }}">Open my leagues</a> <a class="btn btn--primary btn--lg" href="{{ url_for('leagues.index') }}">Open my leagues</a>
{% else %} {% else %}
<a class="btn btn--primary btn--lg" href="{{ url_for('account.register') }}">Start your league</a> <a class="btn btn--primary btn--lg" href="{{ url_for('account.register') }}">Create a league</a>
<a class="btn btn--ghost btn--lg" href="{{ url_for('account.login') }}">Sign in</a> <a class="btn btn--ghost btn--lg" href="{{ url_for('account.login') }}">Sign in</a>
{% endif %} {% endif %}
</div> </div>
<div class="lp-trust"> <div class="home-proof" aria-label="Product highlights">
<span>No fees</span> <span>Free</span>
<span class="lp-trust__sep">·</span>
<span>Append-only ledger</span>
<span class="lp-trust__sep">·</span>
<span>Poker stats</span>
<span class="lp-trust__sep">·</span>
<span>Private by default</span> <span>Private by default</span>
{% if not is_logged_in %} <span>No payment processor</span>
<span class="lp-trust__sep">·</span> <span>Append-only ledger</span>
<a href="{{ url_for('public.explore') }}" style="color:inherit;text-decoration:underline;text-decoration-color:rgba(255,255,255,.2);">Search public leagues</a>
{% endif %}
</div> </div>
</div> </div>
<div class="lp-hero__visual" aria-hidden="true"> <div class="home-board" aria-label="Example poker ledger interface">
<div class="lp-demo__card"> <div class="home-board__top">
<div class="lp-demo__head">
<div> <div>
<span class="lp-demo__kicker">Friday Night Poker · Session #006</span> <span class="home-board__eyebrow">Example League</span>
<span class="lp-demo__title">Live event log <span class="lp-live-dot">● live</span></span> <strong>Session 17</strong>
</div> </div>
<span class="lp-demo__meta">Jun 21 · 5 players</span> <span class="home-board__status">Open</span>
</div> </div>
<div class="lp-feed__rows"> <div class="home-board__metrics home-board__metrics--four">
<div class="lp-feed__row">
<span class="lp-feed__time">10:14</span>
<span class="lp-feed__player">Rivera</span>
<span class="lp-feed__pill lp-feed__pill--buyin">buy-in</span>
<span class="lp-feed__amt">$100</span>
</div>
<div class="lp-feed__row">
<span class="lp-feed__time">10:22</span>
<span class="lp-feed__player">Chen</span>
<span class="lp-feed__pill lp-feed__pill--buyin">buy-in</span>
<span class="lp-feed__amt">$50</span>
</div>
<div class="lp-feed__row">
<span class="lp-feed__time">10:41</span>
<span class="lp-feed__player">Okafor</span>
<span class="lp-feed__pill lp-feed__pill--buyin">buy-in</span>
<span class="lp-feed__amt">$50</span>
</div>
<div class="lp-feed__row">
<span class="lp-feed__time">11:56</span>
<span class="lp-feed__player">Novak</span>
<span class="lp-feed__pill lp-feed__pill--cashout">cashout</span>
<span class="lp-feed__amt lp-demo__num--pos">$160</span>
</div>
<div class="lp-feed__row">
<span class="lp-feed__time">12:02</span>
<span class="lp-feed__player">Rivera</span>
<span class="lp-feed__pill lp-feed__pill--rebuy">re-buy</span>
<span class="lp-feed__amt">$50</span>
</div>
<div class="lp-feed__row lp-demo__row--fade">
<span class="lp-feed__time">12:08</span>
<span class="lp-feed__player">Walsh</span>
<span class="lp-feed__pill lp-feed__pill--cashout">cashout</span>
<span class="lp-feed__amt lp-demo__num--neg">$30</span>
</div>
</div>
<div class="lp-feed__summary">
<div class="lp-feed__summary-cell">
<span class="lp-recon-label">Cash in</span>
<span class="lp-recon-val">$350</span>
</div>
<div class="lp-feed__summary-cell">
<span class="lp-recon-label">Paid out</span>
<span class="lp-recon-val lp-demo__num--pos">$190</span>
</div>
<div class="lp-feed__summary-cell">
<span class="lp-recon-label">House holds</span>
<span class="lp-recon-val lp-demo__num--neg">$160</span>
</div>
</div>
</div>
</div>
</div>
<div class="lp-stats-bar">
<div class="lp-stats-bar__inner">
<div class="lp-stats-bar__item">
<span class="lp-stats-bar__val">$0</span>
<span class="lp-stats-bar__lbl">Platform fee, always</span>
</div>
<span class="lp-stats-bar__sep"></span>
<div class="lp-stats-bar__item">
<span class="lp-stats-bar__val"></span>
<span class="lp-stats-bar__lbl">Sessions &amp; players</span>
</div>
<span class="lp-stats-bar__sep"></span>
<div class="lp-stats-bar__item">
<span class="lp-stats-bar__val">Append-only</span>
<span class="lp-stats-bar__lbl">Tamper-proof ledger</span>
</div>
<span class="lp-stats-bar__sep"></span>
<div class="lp-stats-bar__item">
<span class="lp-stats-bar__val">Private</span>
<span class="lp-stats-bar__lbl">Your data, your league</span>
</div>
</div>
</div>
<section class="lp-features">
<div class="lp-features__inner">
<div class="lp-sec-head">
<span class="lp-eyebrow">What's included</span>
<h2 class="lp-sec-title">A free poker ledger and stats tracker for home games.</h2>
<p class="lp-sec-sub">Everything your group needs to run a real poker league, built from the ground up for home games.</p>
</div>
<div class="lp-feat-grid">
<div class="lp-feat">
<div class="lp-feat__icon">
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true">
<rect x="2" y="4" width="16" height="14" rx="2.5" stroke="currentColor" stroke-width="1.5"/>
<path d="M6 2v4M14 2v4M2 9h16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M6 12.5h3M6 15.5h5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
</svg>
</div>
<h3 class="lp-feat__title">Free poker session tracker</h3>
<p class="lp-feat__desc">Open a session at the start of the night, log buy-ins and cashouts as they happen, then close it when the last player leaves. Multiple poker sessions on the same date auto-sequence as S1, S2.</p>
</div>
<div class="lp-feat">
<div class="lp-feat__icon">
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true">
<rect x="1.5" y="11" width="4.5" height="7" rx="1.2" stroke="currentColor" stroke-width="1.5"/>
<rect x="7.75" y="7" width="4.5" height="11" rx="1.2" stroke="currentColor" stroke-width="1.5"/>
<rect x="14" y="2" width="4.5" height="16" rx="1.2" stroke="currentColor" stroke-width="1.5"/>
</svg>
</div>
<h3 class="lp-feat__title">Poker stats and leaderboards</h3>
<p class="lp-feat__desc">Set a minimum session count so regulars rank and one-time guests go provisional until they qualify. Sort by net profit, ROI, win rate, or recent form. Every stat is derived directly from raw ledger events.</p>
</div>
<div class="lp-feat">
<div class="lp-feat__icon">
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true">
<rect x="3" y="2" width="14" height="16" rx="2" stroke="currentColor" stroke-width="1.5"/>
<path d="M7 7h6M7 10.5h6M7 14h3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</div>
<h3 class="lp-feat__title">Clean records for every night</h3>
<p class="lp-feat__desc">Every event is logged and kept: buy-ins, cashouts, fronts, corrections, profits, and losses. Mistakes get fixed by appending a new entry, so the session history stays intact and settlement stays clear.</p>
</div>
</div>
</div>
</section>
<section class="lp-showcase">
<div class="lp-showcase__inner">
<div class="lp-sec-head">
<span class="lp-eyebrow">In the app</span>
<h2 class="lp-sec-title">Your game. Your data. Always in sync.</h2>
<p class="lp-sec-sub">Every number your group tracks, in one place — from the moment chips hit the table.</p>
</div>
<div class="lp-showcase__grid" aria-hidden="true">
<div class="lp-demo__card">
<div class="lp-demo__head">
<div> <div>
<span class="lp-demo__kicker">Friday Night Poker</span> <span>Players</span>
<span class="lp-demo__title">Sessions</span> <strong>16</strong>
</div> </div>
<span class="lp-demo__meta">6 total</span>
</div>
<div class="lp-sess-list">
<div class="lp-sess-row">
<div class="lp-sess-date">
<span class="lp-sess-date__mon">Jun</span>
<span class="lp-sess-date__day">21</span>
</div>
<div class="lp-sess-info">
<span class="lp-sess-label">Session 1</span>
<span class="lp-sess-meta">5 players · $250 pot</span>
</div>
<span class="lp-sess-pill lp-sess-pill--open">open</span>
</div>
<div class="lp-sess-row">
<div class="lp-sess-date">
<span class="lp-sess-date__mon">Jun</span>
<span class="lp-sess-date__day">14</span>
</div>
<div class="lp-sess-info">
<span class="lp-sess-label">Session 1</span>
<span class="lp-sess-meta">5 players · $300 pot</span>
</div>
<span class="lp-sess-pill lp-sess-pill--closed">closed</span>
</div>
<div class="lp-sess-row">
<div class="lp-sess-date">
<span class="lp-sess-date__mon">Jun</span>
<span class="lp-sess-date__day">7</span>
</div>
<div class="lp-sess-info">
<span class="lp-sess-label">Session 1</span>
<span class="lp-sess-meta">4 players · $200 pot</span>
</div>
<span class="lp-sess-pill lp-sess-pill--closed">closed</span>
</div>
<div class="lp-sess-row lp-sess-row--fade">
<div class="lp-sess-date">
<span class="lp-sess-date__mon">May</span>
<span class="lp-sess-date__day">31</span>
</div>
<div class="lp-sess-info">
<span class="lp-sess-label">Session 1</span>
<span class="lp-sess-meta">6 players · $350 pot</span>
</div>
<span class="lp-sess-pill lp-sess-pill--closed">closed</span>
</div>
<div class="lp-sess-row lp-sess-row--fade2">
<div class="lp-sess-date">
<span class="lp-sess-date__mon">May</span>
<span class="lp-sess-date__day">24</span>
</div>
<div class="lp-sess-info">
<span class="lp-sess-label">Session 1</span>
<span class="lp-sess-meta">5 players · $250 pot</span>
</div>
<span class="lp-sess-pill lp-sess-pill--closed">closed</span>
</div>
</div>
</div>
<div class="lp-demo__card">
<div class="lp-demo__head">
<div> <div>
<span class="lp-demo__kicker">Friday Night Poker</span> <span>Buy-ins</span>
<span class="lp-demo__title">Session #006 · Jun 21</span> <strong>$327.50</strong>
</div> </div>
<span class="lp-demo__meta">5 players · open</span> <div>
<span>Cash in</span>
<strong>$292.50</strong>
</div> </div>
<div class="lp-recon-grid"> <div>
<div class="lp-recon-cell">
<span class="lp-recon-label">Cash in</span>
<span class="lp-recon-val">$300</span>
</div>
<div class="lp-recon-cell">
<span class="lp-recon-label">Paid out</span>
<span class="lp-recon-val lp-demo__num--pos">$190</span>
</div>
<div class="lp-recon-cell">
<span class="lp-recon-label">House owes players</span>
<span class="lp-recon-val lp-demo__num--neg">$110</span>
</div>
<div class="lp-recon-cell">
<span class="lp-recon-label">Players owe house</span>
<span class="lp-recon-val">$0</span>
</div>
</div>
<div class="lp-result-thead">
<span>Player</span>
<span>Invested</span>
<span>Net</span>
<span>Open</span> <span>Open</span>
</div> <strong>$35.00</strong>
<div class="lp-result-row">
<span class="lp-demo__player">Rivera</span>
<span class="lp-demo__num">$50</span>
<span class="lp-demo__num lp-demo__num--pos">+$120</span>
<span class="lp-result-due">due $120</span>
</div>
<div class="lp-result-row">
<span class="lp-demo__player">Chen</span>
<span class="lp-demo__num">$100</span>
<span class="lp-demo__num lp-demo__num--pos">+$90</span>
<span class="lp-result-due">due $90</span>
</div>
<div class="lp-result-row">
<span class="lp-demo__player">Walsh</span>
<span class="lp-demo__num">$50</span>
<span class="lp-demo__num lp-demo__num--neg">-$50</span>
<span class="lp-result-settled">settled</span>
</div>
<div class="lp-result-row lp-demo__row--fade">
<span class="lp-demo__player">Okafor</span>
<span class="lp-demo__num">$50</span>
<span class="lp-demo__num lp-demo__num--pos">+$30</span>
<span class="lp-result-due">due $30</span>
</div>
<div class="lp-result-row lp-demo__row--fade2">
<span class="lp-demo__player">Novak</span>
<span class="lp-demo__num">$100</span>
<span class="lp-demo__num lp-demo__num--neg">-$100</span>
<span class="lp-result-settled">settled</span>
</div> </div>
</div> </div>
<div class="home-ledger">
<div class="home-ledger__row home-ledger__row--head">
<span>Time</span><span>Player</span><span>Event</span><span>Amount</span>
</div> </div>
<div class="home-ledger__row">
<span>8:14</span><strong>Player A</strong><em>buy-in</em><b>$20.00</b>
</div> </div>
</section> <div class="home-ledger__row">
<span>8:22</span><strong>Player B</strong><em>rebuy</em><b>$10.00</b>
<section class="lp-callout">
<div class="lp-callout__inner">
<div class="lp-callout__text">
<span class="lp-eyebrow">Leaderboards</span>
<h2 class="lp-callout__title">Rankings built on raw data, not trust.</h2>
<p class="lp-callout__body">Every leaderboard stat is computed directly from the same append-only ledger that records each buy-in and cashout. Net profit, ROI, and win rate update the moment a session closes — no manual entry, no formulas to break.</p>
<ul class="lp-callout__list">
<li>Set a session minimum before a player earns a rank</li>
<li>Track rank movement with live ▲▼ deltas</li>
<li>Multiple sort modes: net, ROI, win rate, recent form</li>
<li>Provisional players shown separately until they qualify</li>
</ul>
</div> </div>
<div class="lp-callout__demo" aria-hidden="true"> <div class="home-ledger__row">
<div class="lp-demo__card"> <span>9:41</span><strong>Player C</strong><em>cashout</em><b class="num-pos">$35.00</b>
<div class="lp-demo__head">
<div>
<span class="lp-demo__kicker">Friday Night Poker</span>
<span class="lp-demo__title">Leaderboard · Sort: Net</span>
</div> </div>
<span class="lp-demo__meta">18 sessions · eligible</span> <div class="home-ledger__row">
</div> <span>10:08</span><strong>Player D</strong><em>front</em><b class="num-neg">$10.00</b>
<div class="lp-demo__table">
<div class="lp-demo__thead lp-demo__thead--lb">
<span class="lp-demo__th">#</span>
<span class="lp-demo__th">Player</span>
<span class="lp-demo__th">Δ</span>
<span class="lp-demo__th">Net</span>
<span class="lp-demo__th">Win%</span>
<span class="lp-demo__th">ROI</span>
</div>
<div class="lp-demo__row lp-demo__row--lb">
<span class="lp-demo__rank lp-demo__rank--1">1</span>
<span class="lp-demo__player">Rivera</span>
<span class="lp-demo__delta lp-demo__delta--up">▲1</span>
<span class="lp-demo__num lp-demo__num--pos">+$1,420</span>
<span class="lp-demo__num lp-demo__num--pos">72%</span>
<span class="lp-demo__num lp-demo__num--pos">+46%</span>
</div>
<div class="lp-demo__row lp-demo__row--lb">
<span class="lp-demo__rank lp-demo__rank--2">2</span>
<span class="lp-demo__player">Chen</span>
<span class="lp-demo__delta"></span>
<span class="lp-demo__num lp-demo__num--pos">+$640</span>
<span class="lp-demo__num lp-demo__num--pos">56%</span>
<span class="lp-demo__num lp-demo__num--pos">+18%</span>
</div>
<div class="lp-demo__row lp-demo__row--lb">
<span class="lp-demo__rank lp-demo__rank--3">3</span>
<span class="lp-demo__player">Okafor</span>
<span class="lp-demo__delta lp-demo__delta--down">▼1</span>
<span class="lp-demo__num lp-demo__num--neg">$210</span>
<span class="lp-demo__num lp-demo__num--neg">43%</span>
<span class="lp-demo__num lp-demo__num--neg">7%</span>
</div>
<div class="lp-demo__row lp-demo__row--lb lp-demo__row--fade">
<span class="lp-demo__rank">4</span>
<span class="lp-demo__player">Walsh</span>
<span class="lp-demo__delta"></span>
<span class="lp-demo__num lp-demo__num--neg">$850</span>
<span class="lp-demo__num lp-demo__num--neg">31%</span>
<span class="lp-demo__num lp-demo__num--neg">27%</span>
</div>
<div class="lp-demo__row lp-demo__row--lb lp-demo__row--fade2">
<span class="lp-demo__rank">5</span>
<span class="lp-demo__player">Novak</span>
<span class="lp-demo__delta lp-demo__delta--down">▼2</span>
<span class="lp-demo__num lp-demo__num--neg">$1,000</span>
<span class="lp-demo__num lp-demo__num--neg">25%</span>
<span class="lp-demo__num lp-demo__num--neg">42%</span>
</div> </div>
</div> </div>
<div class="home-board__footer">
<span>Leaderboard updates on close</span>
<strong class="num-pos">+$26.30</strong>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<section class="lp-seo"> <section class="home-strip" aria-label="Core product facts">
<div class="lp-seo__inner"> <div class="home-strip__inner">
<div class="lp-sec-head"> <div><strong>Sessions</strong><span>open, close, and review each game night</span></div>
<span class="lp-eyebrow">Free poker tracker</span> <div><strong>Ledger</strong><span>buy-ins, cashouts, fronts, rollovers, writeoffs</span></div>
<h2 class="lp-sec-title">Poker records without a spreadsheet.</h2> <div><strong>Stats</strong><span>net, ROI, win rate, best win, recent form</span></div>
<p class="lp-sec-sub">Keep reliable records for money in, money out, and every player's result without turning game night into admin work.</p> <div><strong>Leagues</strong><span>private by default, public only when enabled</span></div>
</div> </div>
<div class="lp-seo__grid"> </section>
<article class="lp-seo__item">
<h3>Track poker profits and losses</h3> <section class="home-section">
<p>Record each buy-in, re-buy, cashout, correction, and note as it happens. Player profit and loss updates from the ledger automatically.</p> <div class="home-section__head">
<a class="lp-seo__link" href="{{ url_for('public.use_case', slug='poker-profit-loss-tracker') }}">Profit and loss tracking</a> <p class="home-kicker">Built for the actual workflow</p>
<h2>The parts that usually get lost in chat threads and tabs.</h2>
</div>
<div class="home-feature-grid">
<article>
<span>01</span>
<h3>Record the night</h3>
<p>Log buy-ins, re-buys, fronts, cashouts, notes, and corrections while the game is running.</p>
</article> </article>
<article class="lp-seo__item"> <article>
<h3>Keep a clean poker ledger</h3> <span>02</span>
<p>The append-only ledger keeps sessions, settlements, fronts, and payouts in order while preserving the history behind every total.</p> <h3>Settle cleanly</h3>
<a class="lp-seo__link" href="{{ url_for('public.use_case', slug='poker-ledger') }}">Poker ledger details</a> <p>See who was paid, who still owes, and what carried forward before the session is closed.</p>
</article> </article>
<article class="lp-seo__item"> <article>
<h3>Build poker stats automatically</h3> <span>03</span>
<p>League leaderboards, net winnings, ROI, win rate, session counts, recent form, and rank movement update from the same data your group enters on game night.</p> <h3>Rank fairly</h3>
<a class="lp-seo__link" href="{{ url_for('public.use_case', slug='poker-stats-tracker') }}">Poker stats tracking</a> <p>Use session minimums and seasons so leaderboards reflect regular players, not one good night.</p>
</article> </article>
<article class="lp-seo__item"> <article>
<h3>Built for home games</h3> <span>04</span>
<p>Private leagues, shared results, and public league pages are all optional. Your group decides what stays private and what gets shared.</p> <h3>Keep history</h3>
<a class="lp-seo__link" href="{{ url_for('public.use_case', slug='home-poker-league-tracker') }}">League tracking</a> <p>Every result traces back to ledger entries, so totals are easy to audit months later.</p>
</article> </article>
</div> </div>
</section>
<section class="home-workflow">
<div class="home-workflow__copy">
<p class="home-kicker">Less admin, better memory</p>
<h2>Designed around poker accounting, not generic expense tracking.</h2>
<p>myboker separates poker results from banker cash flow. That means a player's net, money owed, paid out amount, and rolled-over value can all be represented without losing the story behind the number.</p>
</div>
<div class="home-steps">
<div><span>Buy-in</span><strong>Counts toward poker investment and real cash in.</strong></div>
<div><span>Front</span><strong>Counts in the game but leaves an amount owed.</strong></div>
<div><span>Cashout</span><strong>Calculates player net without assuming payout happened.</strong></div>
<div><span>Paid out</span><strong>Settles the banker side separately from poker results.</strong></div>
</div>
</section>
<section class="home-proof-panel">
<div class="home-proof-panel__table" aria-label="Example leaderboard">
<div class="home-table__row home-table__row--head">
<span>Rank</span><span>Player</span><span>Sessions</span><span>Net</span><span>Win rate</span>
</div>
<div class="home-table__row">
<span>#1</span><strong>Player A</strong><span>17</span><b class="num-pos">+$26.30</b><span>58.82%</span>
</div>
<div class="home-table__row">
<span>#2</span><strong>Player B</strong><span>9</span><b class="num-pos">+$15.70</b><span>55.56%</span>
</div>
<div class="home-table__row">
<span>#3</span><strong>Player C</strong><span>1</span><b class="num-pos">+$15.00</b><span>100%</span>
</div>
<div class="home-table__row">
<span>#4</span><strong>Player D</strong><span>16</span><b class="num-neg">-$33.60</b><span>31.25%</span>
</div>
</div>
<div class="home-proof-panel__copy">
<p class="home-kicker">Stats without extra work</p>
<h2>Leaderboards come from the ledger automatically.</h2>
<p>Net, win rate, ROI, session counts, best wins, and ranking eligibility update from the records you already enter during each session.</p>
<div class="home-link-row">
<a href="{{ url_for('public.use_case', slug='poker-ledger') }}">Ledger details</a>
<a href="{{ url_for('public.use_case', slug='poker-stats-tracker') }}">Stats tracking</a>
<a href="{{ url_for('public.use_case', slug='home-poker-league-tracker') }}">League tracking</a>
</div>
</div> </div>
</section> </section>
{% if not is_logged_in %} {% if not is_logged_in %}
<section class="lp-bottom-cta"> <section class="home-final">
<div class="lp-bottom-cta__inner"> <p class="home-kicker">Start simple</p>
<h2 class="lp-bottom-cta__title">Start tracking your league</h2> <h2>Create a league, add players, and track the next session properly.</h2>
<p class="lp-bottom-cta__sub">Free to use. No payment processor. Your data stays yours.</p> <div class="home-actions home-actions--center">
<div class="lp-cta" style="justify-content:center;"> <a class="btn btn--primary btn--lg" href="{{ url_for('account.register') }}">Create an account</a>
<a class="btn btn--primary btn--lg" href="{{ url_for('account.register') }}">Create an account →</a> <a class="btn btn--ghost btn--lg" href="{{ url_for('public.explore') }}">Explore public leagues</a>
<a class="btn btn--ghost btn--lg" href="{{ url_for('public.explore') }}">Search public leagues</a>
</div>
</div> </div>
</section> </section>
{% endif %} {% endif %}
+163 -165
View File
@@ -4,16 +4,17 @@
{% block page_class %}page--app{% endblock %} {% block page_class %}page--app{% endblock %}
{% block content %} {% block content %}
{# ── Header ── #}
<div class="db-header"> <div class="db-header">
<div class="db-header__info"> <div class="db-header__info">
<div class="db-header__chips"> <div class="db-header__chips">
<span class="db-chip">{{ league.visibility }}</span> <span class="db-chip">{{ league.visibility }}</span>
{% if is_owner %}<span class="db-chip db-chip--accent">Owner</span>{% endif %} {% if is_owner %}<span class="db-chip db-chip--accent">Owner</span>{% elif can_manage %}<span class="db-chip db-chip--accent">Manager</span>{% endif %}
</div> </div>
<h1 class="db-header__title">{{ league.name }}</h1> <h1 class="db-header__title">{{ league.name }}</h1>
{% if league.description %}<p class="db-header__sub">{{ league.description }}</p>{% endif %} {% if league.description %}<p class="db-header__sub">{{ league.description }}</p>{% endif %}
</div> </div>
{% if is_owner %} {% if can_manage %}
<div class="db-header__actions"> <div class="db-header__actions">
<a class="btn btn--ghost btn--sm" href="{{ url_for('leagues.league_settings', league_ref=league.url_ref) }}">Settings</a> <a class="btn btn--ghost btn--sm" href="{{ url_for('leagues.league_settings', league_ref=league.url_ref) }}">Settings</a>
<a class="btn btn--primary btn--sm" href="{{ url_for('leagues.sessions', league_ref=league.url_ref) }}">New session</a> <a class="btn btn--primary btn--sm" href="{{ url_for('leagues.sessions', league_ref=league.url_ref) }}">New session</a>
@@ -21,131 +22,141 @@
{% endif %} {% endif %}
</div> </div>
{% if counts.open_sessions > 0 %} {# ── Live session banner ── #}
<div class="db-live-alert"> {% if live_session %}
<div class="db-live-alert__pulse"></div> <div class="db-live-banner">
<span class="db-live-alert__text"> <div class="db-live-banner__top">
{{ counts.open_sessions }} session{{ 's' if counts.open_sessions != 1 else '' }} in progress <div class="db-live-banner__status">
</span> <span class="db-live-dot"></span>
<a class="db-live-alert__link" href="{{ url_for('leagues.sessions', league_ref=league.url_ref) }}">View sessions →</a> <span class="db-live-label">Live now</span>
<span class="db-live-name">{{ session_label(live_session) }}</span>
{% if live_session.opened_at %}
<span class="db-live-since">open since {{ live_session.opened_at[:5] }}</span>
{% endif %}
</div>
<a class="btn btn--primary btn--sm" href="{{ url_for('leagues.session_detail', league_ref=league.url_ref, session_id=live_session.session_id) }}">Open session →</a>
</div>
<div class="db-live-banner__stats">
<div class="db-live-stat">
<span class="db-live-stat__label">Seated</span>
<strong class="db-live-stat__val">{{ live_session.entries | length }}</strong>
</div>
<div class="db-live-stat">
<span class="db-live-stat__label">On the table</span>
<strong class="db-live-stat__val db-live-stat__val--accent">{{ live_session.total_real_cash_in_cents | money }}</strong>
</div>
<div class="db-live-stat">
<span class="db-live-stat__label">Buy-ins</span>
<strong class="db-live-stat__val">{{ live_session.total_buy_in_cents | money }}</strong>
</div>
{% if live_session.entries %}
<div class="db-live-stat db-live-stat--players">
<span class="db-live-stat__label">At the table</span>
<span class="db-live-stat__names">{{ live_session.entries | map(attribute='player_name') | join(' · ') }}</span>
</div>
{% endif %}
</div>
</div> </div>
{% endif %} {% endif %}
<div class="db-stat-grid"> {# ── Overview stats strip ── #}
<div class="db-stat"> <div class="db-overview-strip">
<div class="db-stat__icon db-stat__icon--accent"> <div class="db-overview-stat db-overview-stat--players">
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" aria-hidden="true"> <span class="db-overview-stat__label">Players</span>
<circle cx="8" cy="7" r="3" stroke="currentColor" stroke-width="1.5"/> <strong class="db-overview-stat__val">{{ counts.players }}</strong>
<path d="M2 17c0-3.314 2.686-6 6-6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> <span class="db-overview-stat__sub">{{ counts.players }} active</span>
<circle cx="14" cy="8" r="2.5" stroke="currentColor" stroke-width="1.5"/>
<path d="M18 17c0-2.761-1.79-5-4-5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</div> </div>
<div> <div class="db-overview-stat db-overview-stat--sessions">
<span class="db-stat__num">{{ counts.players }}</span> <span class="db-overview-stat__label">Sessions</span>
<span class="db-stat__label">Players</span> <strong class="db-overview-stat__val">{{ total_sessions }}</strong>
{% if counts.open_sessions > 0 %}<span class="db-overview-stat__sub" style="color:var(--pos);">{{ counts.open_sessions }} live now</span>{% else %}<span class="db-overview-stat__sub">all closed</span>{% endif %}
</div> </div>
<div class="db-overview-stat db-overview-stat--cashin">
<span class="db-overview-stat__label">Cash in</span>
<strong class="db-overview-stat__val">{{ cash_stats.total_cash_in | money }}</strong>
<span class="db-overview-stat__sub">across {{ total_sessions }} sessions</span>
</div> </div>
<div class="db-stat"> <div class="db-overview-stat db-overview-stat--paidout">
<div class="db-stat__icon db-stat__icon--pos"> <span class="db-overview-stat__label">Paid out</span>
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" aria-hidden="true"> <strong class="db-overview-stat__val">{{ cash_stats.total_paid_out | money }}</strong>
<rect x="2.5" y="3.5" width="15" height="13" rx="2" stroke="currentColor" stroke-width="1.5"/> <span class="db-overview-stat__sub">to {{ counts.players }} players</span>
<path d="M6 2v3M14 2v3M2.5 8h15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</div>
<div>
<span class="db-stat__num">{{ counts.sessions }}</span>
<span class="db-stat__label">Sessions{% if counts.open_sessions > 0 %} · <span style="color:var(--pos);">{{ counts.open_sessions }} live</span>{% endif %}</span>
</div>
</div>
<div class="db-stat">
<div class="db-stat__icon db-stat__icon--warn">
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M5 3h10l3 3v11a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Z" stroke="currentColor" stroke-width="1.5"/>
<path d="M7 8h6M7 11h4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</div>
<div>
<span class="db-stat__num">{{ counts.events }}</span>
<span class="db-stat__label">Ledger events</span>
</div>
</div>
<div class="db-stat">
<div class="db-stat__icon">
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M10 2v3M10 15v3M2 10h3M15 10h3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<circle cx="10" cy="10" r="4.5" stroke="currentColor" stroke-width="1.5"/>
</svg>
</div>
<div>
<span class="db-stat__num">{{ counts.seasons }}</span>
<span class="db-stat__label">Seasons</span>
</div> </div>
<div class="db-overview-stat">
<span class="db-overview-stat__label">House balance</span>
<strong class="db-overview-stat__val {{ 'num-neg' if house_balance_cents > 0 else 'num-pos' if house_balance_cents < 0 else '' }}">
{{ '+' if house_balance_cents < 0 else ('' if house_balance_cents > 0 else '') }}{{ house_balance_cents | abs | money }}
</strong>
<span class="db-overview-stat__sub">{{ 'paid out over cash in' if house_balance_cents > 0 else 'cash in over paid out' if house_balance_cents < 0 else 'balanced' }}</span>
</div> </div>
</div> </div>
{# ── League averages ── #}
{% if cash_stats.has_data %} {% if cash_stats.has_data %}
<div class="db-cash"> <div class="db-averages">
<div class="db-cash__stat"> <p class="db-averages__title">League averages</p>
<span class="db-cash__label">Total cash in</span> <div class="db-averages__grid">
<span class="db-cash__val">{{ cash_stats.total_cash_in | money }}</span> <div class="db-avg-row">
<div class="db-avg-item">
<span class="db-avg-label">Avg pot / session</span>
<strong class="db-avg-val">{{ cash_stats.avg_pot | money }}</strong>
</div> </div>
<div class="db-cash__div"></div> <div class="db-avg-item">
<div class="db-cash__stat"> <span class="db-avg-label">Avg players</span>
<span class="db-cash__label">Total paid out</span> <strong class="db-avg-val">{{ cash_stats.avg_players }}</strong>
<span class="db-cash__val">{{ cash_stats.total_paid_out | money }}</span>
</div> </div>
<div class="db-cash__div"></div> <div class="db-avg-item">
<div class="db-cash__stat"> <span class="db-avg-label">Avg buy-in</span>
<span class="db-cash__label">Avg pot / session</span> <strong class="db-avg-val">{{ cash_stats.avg_buyin | money }}</strong>
<span class="db-cash__val">{{ cash_stats.avg_pot | money }}</span>
</div> </div>
<div class="db-cash__div"></div> <div class="db-avg-item">
<div class="db-cash__stat"> <span class="db-avg-label">Biggest session</span>
<span class="db-cash__label">Avg players</span> <strong class="db-avg-val">{{ cash_stats.biggest_pot | money }}</strong>
<span class="db-cash__val">{{ cash_stats.avg_players }}</span>
</div>
<div class="db-cash__div"></div>
<div class="db-cash__stat">
<span class="db-cash__label">Avg buy-in</span>
<span class="db-cash__val">{{ cash_stats.avg_buyin | money }}</span>
</div>
<div class="db-cash__div"></div>
<div class="db-cash__stat">
<span class="db-cash__label">Biggest session</span>
<span class="db-cash__val">{{ cash_stats.biggest_pot | money }}</span>
</div> </div>
{% if cash_stats.avg_sessions_per_month > 0 %} {% if cash_stats.avg_sessions_per_month > 0 %}
<div class="db-cash__div"></div> <div class="db-avg-item">
<div class="db-cash__stat"> <span class="db-avg-label">Sessions / month</span>
<span class="db-cash__label">Sessions / month</span> <strong class="db-avg-val">{{ cash_stats.avg_sessions_per_month }}</strong>
<span class="db-cash__val">{{ cash_stats.avg_sessions_per_month }}</span>
</div> </div>
{% endif %} {% endif %}
</div>
<div class="db-avg-row">
{% if cash_stats.total_fronts > 0 %} {% if cash_stats.total_fronts > 0 %}
<div class="db-cash__div"></div> <div class="db-avg-item">
<div class="db-cash__stat"> <span class="db-avg-label">Fronts issued</span>
<span class="db-cash__label">Fronts issued</span> <strong class="db-avg-val">{{ cash_stats.total_fronts | money }}</strong>
<span class="db-cash__val">{{ cash_stats.total_fronts | money }}</span>
</div> </div>
{% endif %} {% endif %}
<div class="db-avg-item">
<span class="db-avg-label">Ledger events</span>
<strong class="db-avg-val">{{ counts.events }}</strong>
</div>
<div class="db-avg-item">
<span class="db-avg-label">Seasons</span>
<strong class="db-avg-val">{{ counts.seasons }}</strong>
</div>
</div>
</div>
</div> </div>
{% endif %} {% endif %}
{# ── Session history heatmap ── #}
{% if cash_stats.has_sessions %} {% if cash_stats.has_sessions %}
<div class="db-cal" <div class="db-cal"
id="db-cal" id="db-cal"
data-sessions='{{ cash_stats.session_map | tojson }}' data-sessions='{{ cash_stats.session_map | tojson }}'
data-first-year="{{ cash_stats.first_session_year }}"> data-first-year="{{ cash_stats.first_session_year }}">
<div class="db-cal__hdr"> <div class="db-cal__hdr">
<span class="db-cal__title">Session history</span> <div>
<span class="kicker" style="margin:0 0 2px;display:block;">Session history</span>
<span class="db-cal__meta">{{ total_sessions }} session{{ 's' if total_sessions != 1 }} · {{ cash_stats.total_cash_in | money }} in pots</span>
</div>
<div class="db-cal__nav-group"> <div class="db-cal__nav-group">
<button class="db-cal__nav" id="db-cal-prev" aria-label="Previous year">&#8249;</button> <button class="db-cal__nav" id="db-cal-prev" aria-label="Previous year">&#8249;</button>
<span class="db-cal__year" id="db-cal-year"></span> <span class="db-cal__year" id="db-cal-year"></span>
<button class="db-cal__nav" id="db-cal-next" aria-label="Next year">&#8250;</button> <button class="db-cal__nav" id="db-cal-next" aria-label="Next year">&#8250;</button>
</div> </div>
</div> </div>
<div class="db-cal__grid" id="db-cal-grid"></div> <div class="db-cal__scroll"><div class="db-cal__grid" id="db-cal-grid"></div></div>
<div class="db-cal__tip" id="db-cal-tip" hidden></div> <div class="db-cal__tip" id="db-cal-tip" hidden></div>
</div> </div>
<script> <script>
@@ -250,94 +261,81 @@
</script> </script>
{% endif %} {% endif %}
<div class="db-nav-grid"> {# ── Recent sessions + Leaderboard ── #}
<div class="db-two-col">
<a class="db-nav-tile db-nav-tile--sessions {% if counts.open_sessions > 0 %}db-nav-tile--live{% endif %}" href="{{ url_for('leagues.sessions', league_ref=league.url_ref) }}"> <div class="db-panel">
<div class="db-nav-tile__icon"> <div class="db-panel__head">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"> <span class="db-panel__title">Recent sessions</span>
<rect x="3" y="4" width="18" height="16" rx="2.5" stroke="currentColor" stroke-width="1.6"/> <a class="db-panel__link" href="{{ url_for('leagues.sessions', league_ref=league.url_ref) }}">All {{ total_sessions }} →</a>
<path d="M3 9h18M8 3v2M16 3v2" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
<circle cx="8" cy="14" r="1.5" fill="currentColor"/>
<circle cx="12" cy="14" r="1.5" fill="currentColor"/>
<circle cx="16" cy="14" r="1.5" fill="currentColor"/>
</svg>
</div> </div>
<div class="db-nav-tile__body"> {% if recent_sessions %}
<div class="db-nav-tile__title-row"> <div class="db-table">
<strong class="db-nav-tile__title">Sessions</strong> {% for s in recent_sessions %}
{% if counts.open_sessions > 0 %} <a class="db-session-row{% if s.is_open %} db-session-row--live{% endif %}" href="{{ url_for('leagues.session_detail', league_ref=league.url_ref, session_id=s.session_id) }}">
<span class="live-badge">{{ counts.open_sessions }} live</span> <span class="db-session-row__num">{{ session_label(s) }}</span>
<span class="db-session-row__meta">{{ s.entries | length }} player{{ 's' if s.entries | length != 1 }}{% if s.is_open %} · <span class="db-live-tag">live</span>{% endif %}</span>
<strong class="db-session-row__pot {{ 'db-session-row__pot--live' if s.is_open }}">{{ s.total_real_cash_in_cents | money }}</strong>
</a>
{% endfor %}
</div>
{% else %}
<p class="db-panel__empty">No sessions yet.</p>
{% endif %} {% endif %}
</div> </div>
<p class="db-nav-tile__desc">Open a session to start tracking buy-ins and cashouts in real time. Close it when everyone is done.</p>
<div class="db-panel">
<div class="db-panel__head">
<span class="db-panel__title">Leaderboard <span class="db-panel__sub">Net</span></span>
<a class="db-panel__link" href="{{ url_for('leagues.leaderboard', league_ref=league.url_ref) }}">Full board →</a>
</div> </div>
<span class="db-nav-tile__arrow"></span> {% if leaderboard_top %}
<div class="db-table">
{% for player in leaderboard_top %}
<div class="db-leader-row db-leader-row--{{ loop.index }}">
<span class="db-leader-rank">{{ loop.index }}</span>
<span class="db-leader-name">{{ player.player_name }}</span>
<span class="db-leader-sess">{{ player.sessions_played }} sess</span>
<strong class="db-leader-net {{ 'num-pos' if player.total_net_cents > 0 else 'num-neg' if player.total_net_cents < 0 else 'num-muted' }}">
{{ '+' if player.total_net_cents > 0 else '' }}{{ player.total_net_cents | money }}
</strong>
</div>
{% endfor %}
</div>
{% else %}
<p class="db-panel__empty">No data yet.</p>
{% endif %}
</div>
</div>
{# ── Quick nav links (flat, no card background) ── #}
<div class="db-quicknav">
<a class="db-quicknav__item" href="{{ url_for('leagues.sessions', league_ref=league.url_ref) }}">
<div class="db-quicknav__title">Sessions {% if counts.open_sessions > 0 %}<span class="live-badge">{{ counts.open_sessions }} live</span>{% endif %} →</div>
<div class="db-quicknav__desc">Buy-ins, cashouts, live session ledger</div>
</a> </a>
<a class="db-nav-tile db-nav-tile--leaderboard" href="{{ url_for('leagues.leaderboard', league_ref=league.url_ref) }}"> <a class="db-quicknav__item" href="{{ url_for('leagues.players', league_ref=league.url_ref) }}">
<div class="db-nav-tile__icon"> <div class="db-quicknav__title">Players →</div>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"> <div class="db-quicknav__desc">Session history, net trend</div>
<path d="M3 20h18M6 20V13M10 20V9M14 20V11M18 20V5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
</svg>
</div>
<div class="db-nav-tile__body">
<div class="db-nav-tile__title-row">
<strong class="db-nav-tile__title">Leaderboard</strong>
</div>
<p class="db-nav-tile__desc">Net profit, ROI, win rate, and rank delta. Filter by eligible players or switch to recent form.</p>
</div>
<span class="db-nav-tile__arrow"></span>
</a> </a>
<a class="db-nav-tile db-nav-tile--players" href="{{ url_for('leagues.players', league_ref=league.url_ref) }}"> <a class="db-quicknav__item" href="{{ url_for('leagues.leaderboard', league_ref=league.url_ref) }}">
<div class="db-nav-tile__icon"> <div class="db-quicknav__title">Leaderboard →</div>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"> <div class="db-quicknav__desc">Net profit, win rate, rank delta</div>
<circle cx="9" cy="8" r="3.5" stroke="currentColor" stroke-width="1.6"/>
<path d="M2.5 21c0-3.866 2.91-7 6.5-7" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
<circle cx="16.5" cy="9.5" r="3" stroke="currentColor" stroke-width="1.6"/>
<path d="M21.5 21c0-3.314-2.239-6-5-6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
</svg>
</div>
<div class="db-nav-tile__body">
<div class="db-nav-tile__title-row">
<strong class="db-nav-tile__title">Players</strong>
</div>
<p class="db-nav-tile__desc">View each player's full session history, net trend, and individual stats over time.</p>
</div>
<span class="db-nav-tile__arrow"></span>
</a> </a>
<a class="db-nav-tile db-nav-tile--seasons" href="{{ url_for('leagues.seasons', league_ref=league.url_ref) }}"> <a class="db-quicknav__item" href="{{ url_for('leagues.seasons', league_ref=league.url_ref) }}">
<div class="db-nav-tile__icon"> <div class="db-quicknav__title">Seasons →</div>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"> <div class="db-quicknav__desc">Browse by season, standings</div>
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.6"/>
<path d="M12 7v5l3.5 3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div class="db-nav-tile__body">
<div class="db-nav-tile__title-row">
<strong class="db-nav-tile__title">Seasons</strong>
</div>
<p class="db-nav-tile__desc">Browse sessions by season, view season standings, and manage date ranges.</p>
</div>
<span class="db-nav-tile__arrow"></span>
</a> </a>
{% if can_manage %} {% if can_manage %}
<a class="db-nav-tile db-nav-tile--ledger" href="{{ url_for('leagues.ledger', league_ref=league.url_ref) }}"> <a class="db-quicknav__item" href="{{ url_for('leagues.ledger', league_ref=league.url_ref) }}">
<div class="db-nav-tile__icon"> <div class="db-quicknav__title">Ledger →</div>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"> <div class="db-quicknav__desc">House balance, fronts, open items</div>
<path d="M5 3h14a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H9l-4-4V4a1 1 0 0 1 1-1Z" stroke="currentColor" stroke-width="1.6"/>
<path d="M9 19v-4H5M8 9h8M8 13h5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
</svg>
</div>
<div class="db-nav-tile__body">
<div class="db-nav-tile__title-row">
<strong class="db-nav-tile__title">League ledger</strong>
</div>
<p class="db-nav-tile__desc">Track what the house holds, what it owes, and outstanding fronts across all sessions.</p>
</div>
<span class="db-nav-tile__arrow"></span>
</a> </a>
{% endif %} {% endif %}
+88 -28
View File
@@ -9,7 +9,8 @@
<h1 class="page-header__title">Leaderboard</h1> <h1 class="page-header__title">Leaderboard</h1>
<p class="page-header__sub">{{ session_count }} session{{ 's' if session_count != 1 else '' }}{% if selected_session_date %} through {{ selected_session_label }}{% endif %}</p> <p class="page-header__sub">{{ session_count }} session{{ 's' if session_count != 1 else '' }}{% if selected_session_date %} through {{ selected_session_label }}{% endif %}</p>
</div> </div>
<div class="leaderboard-hero-stats"> </div>
<div class="leaderboard-hero-stats">
<div class="stat"> <div class="stat">
<span class="kicker">Cash paid out</span> <span class="kicker">Cash paid out</span>
<div class="stat__val" style="color:var(--accent)">{{ cash_paid_out_cents | money }}</div> <div class="stat__val" style="color:var(--accent)">{{ cash_paid_out_cents | money }}</div>
@@ -22,7 +23,6 @@
<span class="kicker">Players</span> <span class="kicker">Players</span>
<div class="stat__val" style="color:var(--text-strong)">{{ all_count }}</div> <div class="stat__val" style="color:var(--text-strong)">{{ all_count }}</div>
</div> </div>
</div>
</div> </div>
<div class="panel stack" style="gap:0;margin-bottom:20px;"> <div class="panel stack" style="gap:0;margin-bottom:20px;">
@@ -30,10 +30,14 @@
<div> <div>
<span class="kicker">Trend</span> <span class="kicker">Trend</span>
<h2 class="panel__title">Cumulative profit</h2> <h2 class="panel__title">Cumulative profit</h2>
<span class="panel__note" id="chartSeriesNote"></span>
</div> </div>
<button class="btn btn--ghost btn--sm" type="button" id="expandLeaderboardChart">Expand</button> <div style="display:flex;gap:6px;">
<button class="btn btn--ghost btn--sm" type="button" id="collapseLeaderboardChart">Collapse</button>
<button class="btn btn--ghost btn--sm" type="button" id="expandLeaderboardChart">Show all</button>
</div> </div>
<div class="chart-wrap"> </div>
<div class="chart-wrap" id="leaderboardChartWrap">
<div class="chart-frame chart-frame--tall"> <div class="chart-frame chart-frame--tall">
<canvas id="leaderboardChart"></canvas> <canvas id="leaderboardChart"></canvas>
</div> </div>
@@ -53,15 +57,6 @@
Recent <span class="seg__count">5 sess</span> Recent <span class="seg__count">5 sess</span>
</button> </button>
</div> </div>
<p class="toolbar__hint">
{% if mode == 'eligible' %}
{{ eligible_min_sessions }}+ sessions required. Keeps one-off guests from skewing standings.
{% elif mode == 'all' %}
Everyone who's sat down — rankings can swing on tiny samples.
{% else %}
Form guide — aggregated over the last 5 sessions only.
{% endif %}
</p>
</div> </div>
<div class="toolbar__right"> <div class="toolbar__right">
{% if seasons %} {% if seasons %}
@@ -90,6 +85,15 @@
</form> </form>
</div> </div>
</div> </div>
<p class="toolbar__hint">
{% if mode == 'eligible' %}
{{ eligible_min_sessions }}+ sessions required. Keeps one-off guests from skewing standings.
{% elif mode == 'all' %}
Everyone who's sat down — rankings can swing on tiny samples.
{% else %}
Form guide — aggregated over the last 5 sessions only.
{% endif %}
</p>
<div class="panel tbl-scroll" style="margin-bottom:20px;"> <div class="panel tbl-scroll" style="margin-bottom:20px;">
<table class="tbl" id="leaderboardTable" data-sort-key="net" data-sort-direction="desc"> <table class="tbl" id="leaderboardTable" data-sort-key="net" data-sort-direction="desc">
@@ -190,12 +194,17 @@
</div> </div>
<script> <script>
const CHART_PALETTE = ['#9b8cf0','#6fc093','#e0b15c','#cf6f86','#8f93c2','#7cb9e0','#f4a261','#a78bb0','#5cb8a0','#e58f3a']; const CHART_PALETTE = ['#9B8FE3','#35C6B8','#5AA2F5','#E0B85C','#58D18E','#F06F7A','#B0A6EF','#8FA4BB','#C99BE0','#6FB2C9'];
const DEFAULT_SERIES = 6;
const leaderboardChartData = {{ chart_data | tojson }}; const leaderboardChartData = {{ chart_data | tojson }};
const rootStyles = getComputedStyle(document.documentElement); const rootStyles = getComputedStyle(document.documentElement);
const textMuted = rootStyles.getPropertyValue('--text-muted').trim(); const textMuted = rootStyles.getPropertyValue('--muted').trim() || '#9AA5B4';
const lineColor = rootStyles.getPropertyValue('--line').trim(); const gridColor = 'rgba(255,255,255,0.05)';
const axisColor = 'rgba(255,255,255,0.10)';
// Order players by activity so the most relevant get the clearest colors,
// then default the chart to the most active players to keep it readable.
leaderboardChartData.datasets.sort((a, b) => (b.sessions_played || 0) - (a.sessions_played || 0));
leaderboardChartData.datasets.forEach((ds, i) => { leaderboardChartData.datasets.forEach((ds, i) => {
ds.borderColor = CHART_PALETTE[i % CHART_PALETTE.length]; ds.borderColor = CHART_PALETTE[i % CHART_PALETTE.length];
ds.backgroundColor = CHART_PALETTE[i % CHART_PALETTE.length]; ds.backgroundColor = CHART_PALETTE[i % CHART_PALETTE.length];
@@ -208,24 +217,31 @@ function buildChartConfig(data, compact) {
labels: data.labels, labels: data.labels,
datasets: data.datasets.map(ds => ({ datasets: data.datasets.map(ds => ({
...ds, ...ds,
pointRadius: compact ? 0 : 2, pointRadius: 0,
pointHoverRadius: 5, pointHoverRadius: 4,
borderWidth: compact ? 2 : 2.5, borderWidth: 2,
tension: 0.28, tension: 0.35,
spanGaps: true,
fill: false, fill: false,
})), })),
}, },
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
interaction: { mode: 'nearest', intersect: false }, interaction: { mode: 'nearest', axis: 'x', intersect: false },
elements: { line: { capBezierPoints: true }, point: { hitRadius: 8 } },
plugins: { plugins: {
legend: { legend: {
display: true, display: true,
position: 'bottom', position: 'bottom',
labels: { color: textMuted, usePointStyle: true, boxWidth: 8, boxHeight: 8, padding: 14 }, labels: { color: textMuted, usePointStyle: true, pointStyle: 'line', boxWidth: 18, boxHeight: 2, padding: 16, font: { size: 12 } },
}, },
tooltip: { tooltip: {
mode: 'nearest', axis: 'x', intersect: false,
backgroundColor: 'rgba(13,17,22,0.95)',
borderColor: 'rgba(255,255,255,0.12)', borderWidth: 1,
padding: 10, cornerRadius: 6, usePointStyle: true,
titleColor: '#F2F0ED', bodyColor: '#C7CED8',
callbacks: { callbacks: {
label(ctx) { label(ctx) {
const v = ctx.parsed.y; const v = ctx.parsed.y;
@@ -236,16 +252,19 @@ function buildChartConfig(data, compact) {
}, },
scales: { scales: {
x: { x: {
ticks: { color: textMuted, maxRotation: 0, autoSkip: true, maxTicksLimit: compact ? 6 : 10 }, ticks: { color: textMuted, maxRotation: 0, autoSkip: true, maxTicksLimit: compact ? 6 : 10, font: { size: 11 } },
grid: { color: lineColor, display: !compact }, grid: { display: false },
border: { color: axisColor },
}, },
y: { y: {
ticks: { ticks: {
color: textMuted, color: textMuted,
maxTicksLimit: compact ? 8 : 10, maxTicksLimit: compact ? 6 : 9,
font: { size: 11 },
callback(v) { return (v < 0 ? '-' : '') + '$' + Math.abs(Number(v)).toFixed(0); } callback(v) { return (v < 0 ? '-' : '') + '$' + Math.abs(Number(v)).toFixed(0); }
}, },
grid: { color: lineColor }, grid: { color: gridColor, drawTicks: false },
border: { display: false },
} }
} }
} }
@@ -255,12 +274,20 @@ function buildChartConfig(data, compact) {
const ctx = document.getElementById('leaderboardChart'); const ctx = document.getElementById('leaderboardChart');
let mainChart = null; let mainChart = null;
if (ctx && leaderboardChartData.labels.length > 0) { if (ctx && leaderboardChartData.labels.length > 0) {
const filtered = leaderboardChartData.datasets.filter(ds => (ds.sessions_played || 0) > 1);
const chartData = { const chartData = {
labels: leaderboardChartData.labels, labels: leaderboardChartData.labels,
datasets: filtered.length ? filtered : leaderboardChartData.datasets, datasets: leaderboardChartData.datasets.slice(0, DEFAULT_SERIES),
}; };
mainChart = new Chart(ctx, buildChartConfig(chartData, true)); mainChart = new Chart(ctx, buildChartConfig(chartData, true));
const note = document.getElementById('chartSeriesNote');
if (note) {
const total = leaderboardChartData.datasets.length;
const shown = chartData.datasets.length;
note.textContent = total > shown
? `Showing ${shown} most active players of ${total} — use Show all`
: `${total} player${total === 1 ? '' : 's'}`;
}
} }
const modal = document.getElementById('chartModal'); const modal = document.getElementById('chartModal');
@@ -286,6 +313,39 @@ document.querySelectorAll('[data-close-modal]').forEach(el => {
}); });
}); });
(function () {
const wrap = document.getElementById('leaderboardChartWrap');
const collapseBtn = document.getElementById('collapseLeaderboardChart');
const STORAGE_KEY = 'leaderboard_chart_collapsed';
let collapsed = localStorage.getItem(STORAGE_KEY) === '1';
function apply(animate) {
if (collapsed) {
if (animate) {
wrap.style.transition = 'max-height .25s ease, opacity .2s ease';
}
wrap.style.maxHeight = '0';
wrap.style.overflow = 'hidden';
wrap.style.opacity = '0';
collapseBtn.textContent = 'Expand chart';
} else {
wrap.style.transition = animate ? 'max-height .3s ease, opacity .25s ease' : '';
wrap.style.maxHeight = '600px';
wrap.style.overflow = '';
wrap.style.opacity = '1';
collapseBtn.textContent = 'Collapse';
}
}
apply(false);
collapseBtn.addEventListener('click', () => {
collapsed = !collapsed;
localStorage.setItem(STORAGE_KEY, collapsed ? '1' : '0');
apply(true);
});
})();
document.addEventListener('keydown', e => { document.addEventListener('keydown', e => {
if (e.key === 'Escape' && modal.style.display === 'flex') { if (e.key === 'Escape' && modal.style.display === 'flex') {
modal.style.display = 'none'; modal.style.display = 'none';
+66 -4
View File
@@ -43,15 +43,27 @@
<p class="kicker" style="margin:0;">Roster</p> <p class="kicker" style="margin:0;">Roster</p>
<h2 class="panel__title">Active players</h2> <h2 class="panel__title">Active players</h2>
</div> </div>
<span class="panel__tag">{{ active_players|length }} players</span> {% if active_players %}
<div class="player-toolbar">
<input id="playerSearch" type="search" placeholder="Search players…" autocomplete="off"
class="player-search">
<div class="player-view-toggle" role="group" aria-label="Player view">
<button class="player-view-toggle__btn is-on" type="button" data-player-view="cards" aria-pressed="true">Cards</button>
<button class="player-view-toggle__btn" type="button" data-player-view="list" aria-pressed="false">List</button>
</div>
<span class="panel__tag" id="playerCount">{{ active_players|length }} players</span>
</div>
{% else %}
<span class="panel__tag">0 players</span>
{% endif %}
</div> </div>
{% if active_players %} {% if active_players %}
<div class="player-grid"> <div class="player-grid" id="playerGrid">
{% for player in active_players %} {% for player in active_players %}
{% set stats = stats_by_name.get(player.display_name) %} {% set stats = stats_by_name.get(player.display_name) %}
{% set initials = player.display_name.split()|map('first')|join('')|upper %} {% set initials = player.display_name.split()|map('first')|join('')|upper %}
{% set avatar_cls = 'player-avatar--pos' if stats and stats.total_net_cents > 0 else 'player-avatar--neg' if stats and stats.total_net_cents < 0 else '' %} {% set avatar_cls = 'player-avatar--pos' if stats and stats.total_net_cents > 0 else 'player-avatar--neg' if stats and stats.total_net_cents < 0 else '' %}
<div class="player-card"> <div class="player-card" data-player-name="{{ player.display_name | lower }}">
<div class="player-card__head"> <div class="player-card__head">
<div class="player-avatar {{ avatar_cls }}">{{ initials[:2] }}</div> <div class="player-avatar {{ avatar_cls }}">{{ initials[:2] }}</div>
<div class="player-card__name-block"> <div class="player-card__name-block">
@@ -87,7 +99,7 @@
</div> </div>
<div class="player-card__stat"> <div class="player-card__stat">
<span class="kicker">Win rate</span> <span class="kicker">Win rate</span>
<span class="player-card__stat-val">{{ "%.0f"|format(stats.win_pct * 100) }}%</span> <span class="player-card__stat-val">{{ "%.0f"|format(stats.win_pct) }}%</span>
</div> </div>
<div class="player-card__stat"> <div class="player-card__stat">
<span class="kicker">Best win</span> <span class="kicker">Best win</span>
@@ -98,6 +110,7 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<p class="player-grid__empty" id="playerSearchEmpty">No players match your search.</p>
{% else %} {% else %}
<div class="empty-panel"> <div class="empty-panel">
<p class="kicker">Empty roster</p> <p class="kicker">Empty roster</p>
@@ -147,6 +160,55 @@
</div> </div>
{% endif %} {% endif %}
<script>
(function () {
var input = document.getElementById('playerSearch');
var grid = document.getElementById('playerGrid');
var empty = document.getElementById('playerSearchEmpty');
var count = document.getElementById('playerCount');
var viewButtons = Array.from(document.querySelectorAll('[data-player-view]'));
if (!input || !grid) return;
var cards = Array.from(grid.querySelectorAll('.player-card'));
var total = cards.length;
function setView(view) {
var nextView = view === 'list' ? 'list' : 'cards';
grid.dataset.view = nextView;
try { window.localStorage.setItem('myboker.playerView', nextView); } catch (e) {}
viewButtons.forEach(function (button) {
var active = button.dataset.playerView === nextView;
button.classList.toggle('is-on', active);
button.setAttribute('aria-pressed', active ? 'true' : 'false');
});
}
viewButtons.forEach(function (button) {
button.addEventListener('click', function () {
setView(this.dataset.playerView);
});
});
try {
setView(window.localStorage.getItem('myboker.playerView') || 'cards');
} catch (e) {
setView('cards');
}
input.addEventListener('input', function () {
var q = this.value.trim().toLowerCase();
var visible = 0;
cards.forEach(function (card) {
var name = card.dataset.playerName || '';
var show = !q || name.includes(q);
card.style.display = show ? '' : 'none';
if (show) visible++;
});
if (empty) empty.style.display = (q && visible === 0) ? 'block' : 'none';
if (count) count.textContent = (q ? visible + ' of ' + total : total) + ' players';
});
})();
</script>
{% if can_manage %} {% if can_manage %}
<div class="modal-backdrop" id="modal-edit-player" hidden> <div class="modal-backdrop" id="modal-edit-player" hidden>
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="modal-edit-player-title"> <div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="modal-edit-player-title">
+276 -66
View File
@@ -29,58 +29,80 @@
</div> </div>
<div class="cash-strip"> <div class="cash-strip">
<div class="stat stat--rail" style="--rail:var(--accent);"> <div class="stat">
<span class="kicker">Invested</span> <span class="kicker">Cash in</span>
<div class="stat__val" style="color:var(--num)">{{ session.total_invested_cents | money }}</div> <div class="stat__val" style="color:var(--num)">{{ session.total_buy_in_cents | money }}</div>
{% if session.total_front_cents > 0 %}<div class="stat__sub">+ {{ session.total_front_cents | money }} fronted</div>{% endif %}
</div> </div>
<div class="stat stat--rail" style="--rail:var(--rank-2);"> {% if session.total_payout_carry_in_cents > 0 or session.total_rollover_in_cents > 0 %}
<span class="kicker">Cashout</span> <div class="stat">
<span class="kicker">Carry in</span>
<div class="stat__val" style="color:var(--rolled)">{{ (session.total_payout_carry_in_cents + session.total_rollover_in_cents) | money }}</div>
</div>
{% endif %}
<div class="stat">
<span class="kicker">Claimed</span>
<div class="stat__val" style="color:var(--num)">{{ session.total_cash_out_cents | money }}</div> <div class="stat__val" style="color:var(--num)">{{ session.total_cash_out_cents | money }}</div>
</div> </div>
<div class="stat stat--rail" style="--rail:var(--pos);"> <div class="stat">
<span class="kicker">Paid out</span> <span class="kicker">Paid out</span>
<div class="stat__val" style="color:var(--pos)">{{ session.total_paid_out_cents | money }}</div> <div class="stat__val" style="color:var(--pos)">{{ session.total_paid_out_cents | money }}</div>
</div> </div>
<div class="stat stat--rail" style="--rail:var(--warn);"> {% set total_unsettled = session.total_current_due_to_player_cents + session.total_current_due_to_house_cents %}
<span class="kicker">Open items</span> <div class="stat">
<div class="stat__val" style="color:var(--num)">{{ (session.total_current_due_to_player_cents + session.total_current_due_to_house_cents) | money }}</div> <span class="kicker">Unsettled</span>
<div class="stat__val" style="color:{{ 'var(--warn)' if total_unsettled > 0 else 'var(--muted)' }}">{{ total_unsettled | money }}</div>
{% if session.total_current_due_to_house_cents > 0 %}<div class="stat__sub" style="color:var(--neg)">{{ session.total_current_due_to_house_cents | money }} owed to house</div>{% endif %}
{% if session.total_current_due_to_player_cents > 0 %}<div class="stat__sub" style="color:var(--warn)">{{ session.total_current_due_to_player_cents | money }} owed to players</div>{% endif %}
</div> </div>
</div> </div>
<div class="league-manage-grid" style="margin-top:20px;align-items:stretch;"> <div class="stack">
{% if can_manage %} {% if can_manage %}
<form class="panel form-card" method="post" id="appendForm" style="align-self:stretch;"> <form class="panel form-card ledger-form" method="post" id="appendForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div> <div class="ledger-form__head">
<p class="eyebrow">Append event</p> <span class="eyebrow">Ledger entry</span>
<h2 class="panel__title" style="margin-top:4px;">Ledger entry</h2> {% if session_model.status == 'open' %}
<span class="ledger-form__status ledger-form__status--open">Live</span>
{% else %}
<span class="ledger-form__status ledger-form__status--closed">Closed</span>
{% endif %}
</div> </div>
{% if session_model.status != 'open' %} {% if session_model.status != 'open' %}
<div class="session-locked-banner"> <div class="session-locked-banner">
<strong>Session closed</strong> <strong>Session closed</strong>
<span>Books assumed settled. Reopen to add events.</span> <span>Reopen to add events.</span>
</div> </div>
{% endif %} {% endif %}
<fieldset {% if session_model.status != 'open' %}disabled{% endif %} style="border:none;padding:0;margin:0;min-width:0;"> <fieldset {% if session_model.status != 'open' %}disabled{% endif %} style="border:none;padding:0;margin:0;min-width:0;display:grid;gap:10px;">
<label> <label>
<span>Player</span> <span>Player</span>
<div class="player-picker" id="playerPicker">
<div class="player-picker__input-wrap">
<input <input
type="text" type="text"
id="playerSearch" id="playerSearch"
list="playerList" class="player-picker__search"
placeholder="Start typing a name…" placeholder="Select a player…"
autocomplete="off" autocomplete="off"
value="{{ players | selectattr('id', 'equalto', append_form.player_id) | map(attribute='display_name') | first | default('') }}" value="{{ players | selectattr('id', 'equalto', append_form.player_id) | map(attribute='display_name') | first | default('') }}"
> >
<datalist id="playerList"> <svg class="player-picker__chevron" width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true"><path d="M2 4l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<div class="player-picker__dropdown" id="playerDropdown" hidden>
{% for player in players %} {% for player in players %}
<option value="{{ player.display_name }}"></option> <button type="button" class="player-picker__opt" data-name="{{ player.display_name }}" data-id="{{ player.id }}">{{ player.display_name }}</button>
{% endfor %} {% endfor %}
</datalist> </div>
</div>
<input type="hidden" name="player_id" id="playerIdInput" value="{{ append_form.player_id or '' }}"> <input type="hidden" name="player_id" id="playerIdInput" value="{{ append_form.player_id or '' }}">
</label> </label>
<div class="ledger-form__ta-row">
<div class="ledger-form__inputs">
<label> <label>
<span>Event type</span> <span>Type</span>
<select name="event_type" id="eventTypeSelect" required> <select name="event_type" id="eventTypeSelect" required>
{% for value, label in event_types %} {% for value, label in event_types %}
<option value="{{ value }}" {{ 'selected' if append_form.event_type == value else '' }}>{{ label }}</option> <option value="{{ value }}" {{ 'selected' if append_form.event_type == value else '' }}>{{ label }}</option>
@@ -89,13 +111,41 @@
</label> </label>
<label> <label>
<span>Amount</span> <span>Amount</span>
<input type="number" step="0.01" min="0" name="amount" value="{{ append_form.amount }}" placeholder="0.00"> <input type="number" step="0.01" min="0" name="amount" id="amountInput" value="{{ append_form.amount }}" placeholder="0.00">
</label> </label>
</div>
{% if default_buyin_cents > 0 or default_rebuy_cents > 0 %}
<div class="amount-quickset" id="amountQuickset">
<div class="amount-quickset__presets">
{% if default_buyin_cents > 0 %}
<button type="button" class="amount-quickset__btn"
data-event-type="buyin"
data-base="{{ '%.2f' % (default_buyin_cents / 100) }}">
<span class="amount-quickset__lbl">Buy-in</span>
<span class="amount-quickset__val">${{ '%.2f' % (default_buyin_cents / 100) }}</span>
</button>
{% endif %}
{% if default_rebuy_cents > 0 %}
<button type="button" class="amount-quickset__btn"
data-event-type="buyin"
data-base="{{ '%.2f' % (default_rebuy_cents / 100) }}">
<span class="amount-quickset__lbl">Rebuy</span>
<span class="amount-quickset__val">${{ '%.2f' % (default_rebuy_cents / 100) }}</span>
</button>
{% endif %}
</div>
<div class="amount-quickset__mults">
<button type="button" class="amount-quickpick__btn" data-mult="1">1×</button>
<button type="button" class="amount-quickpick__btn" data-mult="2">2×</button>
</div>
</div>
{% endif %}
</div>
<label> <label>
<span>Note <span class="form-opt-hint">(optional)</span></span> <span>Note <span class="form-opt-hint">(optional)</span></span>
<textarea name="note" placeholder="Optional context">{{ append_form.note or '' }}</textarea> <textarea name="note" rows="1" placeholder="Optional context" style="resize:vertical;">{{ append_form.note or '' }}</textarea>
</label> </label>
<button class="btn btn--primary" type="submit" style="margin-top:8px;">Append event</button> <button class="btn btn--primary ledger-form__submit" type="submit">Append event</button>
</fieldset> </fieldset>
</form> </form>
{% else %} {% else %}
@@ -111,16 +161,31 @@
<p class="kicker" style="margin:0;">Results</p> <p class="kicker" style="margin:0;">Results</p>
<h2 class="panel__title">Player totals</h2> <h2 class="panel__title">Player totals</h2>
</div> </div>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
{% set ns = namespace(playing=0, settled=0, owes=0, owed=0) %}
{% for entry in session.entries %}
{% if entry.cash_out_cents == 0 and entry.invested_cents > 0 %}{% set ns.playing = ns.playing + 1 %}
{% elif entry.current_due_to_house_cents > 0 %}{% set ns.owes = ns.owes + 1 %}
{% elif entry.current_due_to_player_cents > 0 %}{% set ns.owed = ns.owed + 1 %}
{% else %}{% set ns.settled = ns.settled + 1 %}
{% endif %}
{% endfor %}
{% if ns.playing > 0 %}<span class="ledger-tally ledger-tally--playing">{{ ns.playing }} in play</span>{% endif %}
{% if ns.owes > 0 %}<span class="ledger-tally ledger-tally--owes">{{ ns.owes }} owe</span>{% endif %}
{% if ns.owed > 0 %}<span class="ledger-tally ledger-tally--owed">{{ ns.owed }} owed</span>{% endif %}
{% if ns.settled > 0 %}<span class="ledger-tally ledger-tally--settled">{{ ns.settled }} settled</span>{% endif %}
<span class="panel__tag">{{ session.entries|length }} players</span> <span class="panel__tag">{{ session.entries|length }} players</span>
</div> </div>
</div>
{% if session.entries %} {% if session.entries %}
<div class="player-ledger-list"> <div class="player-ledger-list">
{% for entry in session.entries %} {% for entry in session.entries %}
{% set row_cls = 'player-ledger-row--pos' if entry.net_cents > 0 else 'player-ledger-row--neg' if entry.net_cents < 0 else '' %} <div class="player-ledger-row">
<div class="player-ledger-row {{ row_cls }}"> <div class="player-ledger-row__who">
<div class="player-ledger-row__header">
<span class="player-ledger-row__name">{{ entry.player_name }}</span> <span class="player-ledger-row__name">{{ entry.player_name }}</span>
{% if entry.current_due_to_house_cents > 0 %} {% if entry.cash_out_cents == 0 and entry.invested_cents > 0 %}
<span class="badge badge--playing">In play</span>
{% elif entry.current_due_to_house_cents > 0 %}
<span class="badge badge--owes">Owes {{ entry.current_due_to_house_cents | money }}</span> <span class="badge badge--owes">Owes {{ entry.current_due_to_house_cents | money }}</span>
{% elif entry.current_due_to_player_cents > 0 %} {% elif entry.current_due_to_player_cents > 0 %}
<span class="badge badge--unpaid">Due {{ entry.current_due_to_player_cents | money }}</span> <span class="badge badge--unpaid">Due {{ entry.current_due_to_player_cents | money }}</span>
@@ -128,40 +193,62 @@
<span class="badge badge--settled">Settled</span> <span class="badge badge--settled">Settled</span>
{% endif %} {% endif %}
</div> </div>
<div class="player-ledger-row__fields"> <div class="player-ledger-row__stats">
<div class="player-ledger-row__field"> <div class="player-ledger-row__stat">
<span class="player-ledger-row__lbl">Buy in</span> <span class="player-ledger-row__lbl">In</span>
<span class="player-ledger-row__val">{{ entry.invested_cents | money }}</span> <span class="player-ledger-row__val">{{ entry.invested_cents | money }}</span>
</div> </div>
<div class="player-ledger-row__field"> <div class="player-ledger-row__stat">
<span class="player-ledger-row__lbl">Cashout claim</span> <span class="player-ledger-row__lbl">Cashout</span>
<span class="player-ledger-row__val">{{ entry.cash_out_cents | money }}</span> <span class="player-ledger-row__val">{{ entry.cash_out_cents | money }}</span>
</div> </div>
<div class="player-ledger-row__field"> <div class="player-ledger-row__stat">
<span class="player-ledger-row__lbl">Paid out</span> <span class="player-ledger-row__lbl">Paid out</span>
<span class="player-ledger-row__val" style="color:var(--pos)">{{ entry.paid_out_cents | money if entry.paid_out_cents else '—' }}</span> <span class="player-ledger-row__val" style="color:var(--pos)">{{ entry.paid_out_cents | money if entry.paid_out_cents else '—' }}</span>
</div> </div>
<div class="player-ledger-row__field player-ledger-row__field--net"> <div class="player-ledger-row__stat player-ledger-row__stat--net">
<span class="player-ledger-row__lbl">Net</span> <span class="player-ledger-row__lbl">Net</span>
<span class="player-ledger-row__val {{ 'num-pos' if entry.net_cents > 0 else 'num-neg' if entry.net_cents < 0 else 'num-muted' }}"> <span class="player-ledger-row__val {{ '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 }}</span>
{{ '+' if entry.net_cents > 0 else '' }}{{ entry.net_cents | money }}
</span>
</div>
</div> </div>
{% if entry.rollover_in_cents or entry.rollover_out_cents or entry.payout_carry_in_cents %} {% if entry.rollover_in_cents or entry.rollover_out_cents or entry.payout_carry_in_cents %}
<div class="player-ledger-row__fields player-ledger-row__fields--secondary"> <div class="player-ledger-row__stat">
<div class="player-ledger-row__field">
<span class="player-ledger-row__lbl">Rolled in</span> <span class="player-ledger-row__lbl">Rolled in</span>
<span class="player-ledger-row__val" style="color:var(--rolled)">{{ entry.rollover_in_cents | money }}</span> <span class="player-ledger-row__val" style="color:var(--rolled)">{{ entry.rollover_in_cents | money }}</span>
</div> </div>
<div class="player-ledger-row__field"> <div class="player-ledger-row__stat">
<span class="player-ledger-row__lbl">Carry in</span> <span class="player-ledger-row__lbl">Carry in</span>
<span class="player-ledger-row__val" style="color:var(--rolled)">{{ entry.payout_carry_in_cents | money }}</span> <span class="player-ledger-row__val" style="color:var(--rolled)">{{ entry.payout_carry_in_cents | money }}</span>
</div> </div>
<div class="player-ledger-row__field"> {% endif %}
<span class="player-ledger-row__lbl">Rolled out</span>
<span class="player-ledger-row__val" style="color:var(--rolled)">{{ entry.rollover_out_cents | money }}</span>
</div> </div>
{% if can_manage and session_model.status == 'open' %}
<div class="player-ledger-row__actions">
{% if default_rebuy_cents > 0 %}
<form method="post" style="margin:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="player_id" value="{{ entry.player_id }}">
<input type="hidden" name="event_type" value="buyin">
<input type="hidden" name="amount" value="{{ '%.2f' % (default_rebuy_cents / 100) }}">
<button type="submit" class="btn btn--ghost">Rebuy ${{ '%.2f' % (default_rebuy_cents / 100) }}</button>
</form>
{% elif default_buyin_cents > 0 %}
<form method="post" style="margin:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="player_id" value="{{ entry.player_id }}">
<input type="hidden" name="event_type" value="buyin">
<input type="hidden" name="amount" value="{{ '%.2f' % (default_buyin_cents / 100) }}">
<button type="submit" class="btn btn--ghost">Rebuy ${{ '%.2f' % (default_buyin_cents / 100) }}</button>
</form>
{% else %}
<button type="button" class="btn btn--ghost player-row-prefill"
data-player-id="{{ entry.player_id }}"
data-player-name="{{ entry.player_name }}"
data-event-type="buyin">Rebuy</button>
{% endif %}
<button type="button" class="btn btn--ghost player-row-prefill"
data-player-id="{{ entry.player_id }}"
data-player-name="{{ entry.player_name }}"
data-event-type="cashout">Cash out</button>
</div> </div>
{% endif %} {% endif %}
</div> </div>
@@ -171,6 +258,7 @@
<p class="muted-text" style="padding:28px;">No player ledger events yet.</p> <p class="muted-text" style="padding:28px;">No player ledger events yet.</p>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="panel" style="margin-top:20px;"> <div class="panel" style="margin-top:20px;">
@@ -315,37 +403,159 @@
</div> </div>
<script> <script>
const playerMap = { // --- Custom player picker ---
{% for player in players %}"{{ player.display_name | replace('"', '\\"') }}": "{{ player.id }}"{% if not loop.last %},{% endif %} (function () {
{% endfor %} const picker = document.getElementById('playerPicker');
}; if (!picker) return;
const searchInput = document.getElementById('playerSearch'); const searchInput = document.getElementById('playerSearch');
const idInput = document.getElementById('playerIdInput'); const idInput = document.getElementById('playerIdInput');
const eventTypeSelect = document.getElementById('eventTypeSelect'); const dropdown = document.getElementById('playerDropdown');
const opts = dropdown ? Array.from(dropdown.querySelectorAll('.player-picker__opt')) : [];
const eventTypeSelect = document.getElementById('eventTypeSelect');
if (searchInput && idInput) { function openDropdown() {
searchInput.addEventListener('input', function () { if (!dropdown) return;
idInput.value = playerMap[this.value.trim()] || ''; dropdown.removeAttribute('hidden');
}
function closeDropdown() {
if (dropdown) dropdown.setAttribute('hidden', '');
}
function filterOpts(q) {
const lq = q.toLowerCase().trim();
opts.forEach(opt => {
opt.style.display = (!lq || opt.dataset.name.toLowerCase().includes(lq)) ? '' : 'none';
}); });
searchInput.addEventListener('change', function () { }
idInput.value = playerMap[this.value.trim()] || ''; function selectPlayer(name, id) {
}); searchInput.value = name;
} idInput.value = id;
filterOpts(name);
if (eventTypeSelect && searchInput && idInput) { closeDropdown();
eventTypeSelect.addEventListener('change', function () { }
if (this.value === 'note') { function clearPlayer() {
searchInput.value = ''; searchInput.value = '';
idInput.value = ''; idInput.value = '';
filterOpts('');
}
searchInput.addEventListener('focus', () => { filterOpts(searchInput.value); openDropdown(); });
searchInput.addEventListener('input', function () {
filterOpts(this.value);
openDropdown();
const matched = opts.find(o => o.dataset.name.toLowerCase() === this.value.trim().toLowerCase());
idInput.value = matched ? matched.dataset.id : '';
});
searchInput.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeDropdown();
if (e.key === 'Enter') {
const visible = opts.filter(o => o.style.display !== 'none');
if (visible.length === 1) { e.preventDefault(); selectPlayer(visible[0].dataset.name, visible[0].dataset.id); }
}
});
opts.forEach(opt => {
opt.addEventListener('mousedown', e => { e.preventDefault(); selectPlayer(opt.dataset.name, opt.dataset.id); });
});
document.addEventListener('click', e => { if (!picker.contains(e.target)) closeDropdown(); });
if (eventTypeSelect) {
eventTypeSelect.addEventListener('change', function () {
if (this.value === 'note') {
clearPlayer();
searchInput.placeholder = 'No player (session note)'; searchInput.placeholder = 'No player (session note)';
searchInput.disabled = true;
} else { } else {
searchInput.placeholder = 'Start typing a name…'; searchInput.placeholder = 'Search or select a player…';
searchInput.disabled = false;
} }
}); });
if (eventTypeSelect.value === 'note') { if (eventTypeSelect.value === 'note') {
searchInput.placeholder = 'No player (session note)'; searchInput.placeholder = 'No player (session note)';
searchInput.disabled = true;
} }
} }
})();
// --- Quick-set amount buttons (Buy-in / Rebuy) + 1x/2x multipliers ---
(function () {
const amountInput = document.getElementById('amountInput');
const eventTypeSelect = document.getElementById('eventTypeSelect');
let quickBase = null;
let currentMult = 1;
function setAmount(val) {
if (!amountInput) return;
amountInput.value = parseFloat(val).toFixed(2);
}
function syncQuicksetActive(activeBtn) {
document.querySelectorAll('.amount-quickset__btn').forEach(b => {
b.classList.toggle('is-active', b === activeBtn);
});
}
function syncMultActive(mult) {
currentMult = mult;
document.querySelectorAll('.amount-quickpick__btn').forEach(b => {
b.classList.toggle('is-active', parseInt(b.dataset.mult) === mult);
});
}
document.querySelectorAll('.amount-quickset__btn').forEach(btn => {
btn.addEventListener('click', function () {
quickBase = parseFloat(this.dataset.base);
if (eventTypeSelect && this.dataset.eventType) {
eventTypeSelect.value = this.dataset.eventType;
}
setAmount(quickBase * currentMult);
syncQuicksetActive(this);
});
});
document.querySelectorAll('.amount-quickpick__btn').forEach(btn => {
btn.addEventListener('click', function () {
const mult = parseInt(this.dataset.mult);
syncMultActive(mult);
if (quickBase !== null) setAmount(quickBase * mult);
});
});
// Reset multiplier to 1× when user types manually
if (amountInput) {
amountInput.addEventListener('input', function () {
syncMultActive(1);
syncQuicksetActive(null);
quickBase = parseFloat(this.value) || null;
});
}
// Auto-select the Buy-in quickset button on load if buyin type and no amount
if (!amountInput || amountInput.value) return;
const buyinBtn = document.querySelector('.amount-quickset__btn[data-event-type="buyin"]');
if (buyinBtn && eventTypeSelect && eventTypeSelect.value === 'buyin') {
quickBase = parseFloat(buyinBtn.dataset.base);
setAmount(quickBase);
syncQuicksetActive(buyinBtn);
}
})();
// --- Per-player quick-action pre-fill ---
document.querySelectorAll('.player-row-prefill').forEach(function(btn) {
btn.addEventListener('click', function() {
var playerId = this.dataset.playerId;
var playerName = this.dataset.playerName;
var eventType = this.dataset.eventType;
var idInput = document.getElementById('playerIdInput');
var searchInput = document.getElementById('playerSearch');
var typeSelect = document.getElementById('eventTypeSelect');
var amountInput = document.getElementById('amountInput');
if (idInput) idInput.value = playerId;
if (searchInput) searchInput.value = playerName;
if (typeSelect) typeSelect.value = eventType;
if (amountInput) { amountInput.value = ''; amountInput.focus(); }
var form = document.getElementById('appendForm');
if (form) form.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
});
document.querySelectorAll('[data-modal]').forEach(function(btn) { document.querySelectorAll('[data-modal]').forEach(function(btn) {
btn.addEventListener('click', function() { btn.addEventListener('click', function() {
+1 -1
View File
@@ -219,7 +219,7 @@ if (breakdownCtx) {
const w = Math.min(Math.abs(val) / maxAbs * 50, 50); const w = Math.min(Math.abs(val) / maxAbs * 50, 50);
const cls = val < 0 ? 'session-net-bars__bar--neg' : 'session-net-bars__bar--pos'; const cls = val < 0 ? 'session-net-bars__bar--neg' : 'session-net-bars__bar--pos';
const amt = `${val > 0 ? '+' : ''}${fmt.format(val)}`; const amt = `${val > 0 ? '+' : ''}${fmt.format(val)}`;
const color = colors[i] || (val < 0 ? '#e0758a' : val > 0 ? '#6fc093' : '#84828e'); const color = colors[i] || (val < 0 ? '#D46B72' : val > 0 ? '#67B987' : '#6E7F91');
return `<div class="session-net-bars__row"> return `<div class="session-net-bars__row">
<div class="session-net-bars__name">${esc(label)}</div> <div class="session-net-bars__name">${esc(label)}</div>
<div class="session-net-bars__track"><span class="session-net-bars__bar ${cls}" style="width:${w}%;background:${color};"></span></div> <div class="session-net-bars__track"><span class="session-net-bars__bar ${cls}" style="width:${w}%;background:${color};"></span></div>
+4 -4
View File
@@ -121,16 +121,16 @@
</div> </div>
<div class="session-row__actions"> <div class="session-row__actions">
<span class="status-pill status-pill--{{ session.status }}">{{ session.status }}</span> <span class="status-pill status-pill--{{ session.status }}">{{ session.status }}</span>
<a class="btn btn--ghost btn--sm" href="{{ url_for('leagues.session_public_view', league_ref=league.url_ref, session_id=session.id) }}">View</a> <a class="btn btn--ghost btn--sm session-row__btn" href="{{ url_for('leagues.session_public_view', league_ref=league.url_ref, session_id=session.id) }}">View</a>
{% if can_manage %} {% if can_manage %}
<a class="btn btn--ghost btn--sm" href="{{ url_for('leagues.session_detail', league_ref=league.url_ref, session_id=session.id) }}">Manage</a> <a class="btn btn--ghost btn--sm session-row__btn" href="{{ url_for('leagues.session_detail', league_ref=league.url_ref, session_id=session.id) }}">Manage</a>
{% if session.status == 'open' %} {% if session.status == 'open' %}
<form method="post" action="{{ url_for('leagues.close_league_session', league_ref=league.url_ref, session_id=session.id) }}" style="margin:0;"> <form method="post" action="{{ url_for('leagues.close_league_session', league_ref=league.url_ref, session_id=session.id) }}" style="margin:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn--ghost btn--sm" type="submit">Close</button> <button class="btn btn--ghost btn--sm session-row__btn" type="submit">Close</button>
</form> </form>
{% else %} {% else %}
<button class="btn btn--outline btn--sm" type="button" <button class="btn btn--outline btn--sm session-row__btn" type="button"
data-modal="modal-reopen-session" data-modal="modal-reopen-session"
data-reopen-action="{{ url_for('leagues.open_league_session', league_ref=league.url_ref, session_id=session.id) }}">Reopen</button> data-reopen-action="{{ url_for('leagues.open_league_session', league_ref=league.url_ref, session_id=session.id) }}">Reopen</button>
{% endif %} {% endif %}
+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">Session defaults</p>
<p class="muted-text" style="margin-top:4px;margin-bottom:0;font-size:.82rem;">Quick-pick buttons appear in the ledger entry form so common amounts are one click. Set to 0 to omit.</p>
</div>
<div class="session-create-row" style="grid-template-columns:1fr 1fr;">
<label>
<span>Default buy-in ($) <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">Shows a "Buy-in: $X" button in the ledger form. Also pre-fills the amount when a buy-in type is selected.</span></span></span>
<input type="number" name="default_buyin_dollars" value="{{ form.default_buyin_dollars }}" min="0" max="10000" step="0.50" placeholder="0.00">
</label>
<label>
<span>Default rebuy ($) <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">Shows a "Rebuy: $X" quick-pick button and a one-click rebuy action on each player row during a live session.</span></span></span>
<input type="number" name="default_rebuy_dollars" value="{{ form.default_rebuy_dollars }}" min="0" max="10000" step="0.50" placeholder="0.00">
</label>
</div>
<div style="margin-top:8px;padding-top:20px;border-top:1px solid var(--border);"> <div style="margin-top:8px;padding-top:20px;border-top:1px solid var(--border);">
<p class="eyebrow">Leaderboard</p> <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> <p class="muted-text" style="margin-top:4px;margin-bottom:0;font-size:.82rem;">Controls how players qualify and how sessions are classified.</p>
+281 -88
View File
@@ -1,100 +1,179 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Leagues · myboker.org{% endblock %} {% block title %}Leagues · myboker.org{% endblock %}
{% block page_class %}page--session page--leagues{% endblock %}
{% block content %} {% block content %}
<div class="page-header"> <div class="li-page-header">
<div> <div class="li-page-header__left">
<h1 class="page-header__title">Your leagues</h1> <p class="eyebrow" style="margin:0 0 6px;">Your account</p>
{% if leagues %} <h1 class="li-page-header__title">Your leagues</h1>
<p class="page-header__sub"> </div>
{{ page_summary.league_count }} league{{ 's' if page_summary.league_count != 1 else '' }} <div class="li-page-header__stats">
{% if page_summary.live_count > 0 %}&nbsp;·&nbsp;<span style="color:var(--pos);">{{ page_summary.live_count }} live</span>{% endif %} <div class="li-header-stat">
&nbsp;·&nbsp;{{ page_summary.sessions_count }} session{{ 's' if page_summary.sessions_count != 1 else '' }} <span class="li-header-stat__label">Leagues</span>
</p> <strong class="li-header-stat__val">{{ page_summary.league_count }}</strong>
{% endif %} </div>
<div class="li-header-stat">
<span class="li-header-stat__label">Live now</span>
<strong class="li-header-stat__val {{ 'num-pos' if page_summary.live_count > 0 else '' }}">{{ page_summary.live_count }}</strong>
</div>
<div class="li-header-stat">
<span class="li-header-stat__label">Sessions</span>
<strong class="li-header-stat__val">{{ page_summary.sessions_count }}</strong>
</div>
<div class="li-header-stat">
<span class="li-header-stat__label">Total paid out</span>
<strong class="li-header-stat__val">{{ page_summary.total_paid_out_cents | money }}</strong>
</div> </div>
<div class="page-header__actions">
<a class="btn btn--primary btn--sm" href="{{ url_for('leagues.new') }}">New league</a>
</div> </div>
</div> </div>
{% if leagues %} <div class="leagues-split">
<div class="li-board"> <aside class="leagues-split__form">
<form class="li-new-form" method="post" action="{{ url_for('leagues.index') }}" id="newLeagueForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<p class="eyebrow" style="margin:0 0 4px;">League setup</p>
<h2 class="li-new-form__title">New league</h2>
<div class="li-new-form__field">
<label class="li-new-form__label" for="lf-name">Name</label>
<input class="li-new-form__input" id="lf-name" type="text" name="name" value="{{ form.name or '' }}" placeholder="Friday Poker" required>
</div>
<div class="li-new-form__field">
<label class="li-new-form__label" for="lf-desc">Description <span class="form-opt-hint">optional</span></label>
<textarea class="li-new-form__input li-new-form__textarea" id="lf-desc" name="description" placeholder="Private home league">{{ form.description or '' }}</textarea>
</div>
<div class="li-new-form__field">
<span class="li-new-form__label">Visibility</span>
<div class="li-visibility-toggle">
<button type="button" class="li-vis-btn {% if (form.visibility or 'private') == 'private' %}is-on{% endif %}" data-vis="private">Private</button>
<button type="button" class="li-vis-btn {% if (form.visibility or 'private') == 'public' %}is-on{% endif %}" data-vis="public">Public</button>
</div>
<input type="hidden" name="visibility" id="lf-visibility" value="{{ form.visibility or 'private' }}">
<p class="li-new-form__hint" id="visHint">
{% if (form.visibility or 'private') == 'public' %}Visible in Explore. Anyone can view.{% else %}Invite-only. Nothing appears in Explore.{% endif %}
</p>
</div>
<div class="li-new-form__field">
<span class="li-new-form__label">Default buy-in</span>
<div class="li-preset-row">
<button type="button" class="li-preset-btn" data-field="buyin" data-val="5">$5</button>
<button type="button" class="li-preset-btn" data-field="buyin" data-val="10">$10</button>
<button type="button" class="li-preset-btn" data-field="buyin" data-val="20">$20</button>
<button type="button" class="li-preset-btn" data-field="buyin" data-val="50">$50</button>
<input class="li-preset-custom" type="number" name="default_buyin_dollars" id="lf-buyin" min="0" step="0.01" placeholder="Custom" value="{{ form.default_buyin_dollars or '' }}">
</div>
</div>
<div class="li-new-form__field">
<span class="li-new-form__label">Default rebuy</span>
<div class="li-preset-row">
<button type="button" class="li-preset-btn" data-field="rebuy" data-val="5">$5</button>
<button type="button" class="li-preset-btn" data-field="rebuy" data-val="10">$10</button>
<button type="button" class="li-preset-btn" data-field="rebuy" data-val="20">$20</button>
<button type="button" class="li-preset-btn" data-field="rebuy" data-val="50">$50</button>
<input class="li-preset-custom" type="number" name="default_rebuy_dollars" id="lf-rebuy" min="0" step="0.01" placeholder="Custom" value="{{ form.default_rebuy_dollars or '' }}">
</div>
</div>
<div class="li-new-form__two-col">
<div class="li-new-form__field">
<label class="li-new-form__label" for="lf-min-sessions">Sessions to rank <span class="form-opt-hint">optional</span></label>
<input class="li-new-form__input" id="lf-min-sessions" type="number" name="eligible_min_sessions" min="1" step="1" placeholder="3 (default)" value="{{ form.eligible_min_sessions or '' }}">
<p class="li-new-form__hint">Min sessions to appear on leaderboard.</p>
</div>
<div class="li-new-form__field">
<label class="li-new-form__label" for="lf-break-even">Break-even threshold <span class="form-opt-hint">optional</span></label>
<div class="li-input-prefix-wrap">
<span class="li-input-prefix">$</span>
<input class="li-new-form__input li-input-has-prefix" id="lf-break-even" type="number" name="break_even_dollars" min="0" step="0.01" placeholder="1.00 (default)" value="{{ form.break_even_dollars or '' }}">
</div>
<p class="li-new-form__hint">Net within this = break even.</p>
</div>
</div>
<button class="li-new-form__submit" type="submit">Create league</button>
</form>
</aside>
<section class="leagues-split__list">
<div class="li-toolbar">
<input class="li-search" type="search" id="liSearch" placeholder="Search leagues…" autocomplete="off">
<div class="li-filter-pills">
<button class="li-filter-btn is-on" data-filter="all">All</button>
<button class="li-filter-btn" data-filter="live">Live</button>
<button class="li-filter-btn" data-filter="owner">Owner</button>
<button class="li-filter-btn" data-filter="public">Public</button>
</div>
<div class="li-sort-tabs">
<span class="li-sort-label">Sort</span>
<button class="li-sort-btn is-on" data-sort="activity">Activity</button>
<button class="li-sort-btn" data-sort="money">Money</button>
<button class="li-sort-btn" data-sort="name">Name</button>
</div>
</div>
{% if leagues %}
<div class="li-board" id="liBoard">
{% for item in leagues %} {% for item in leagues %}
{% set league = item.league %} {% set league = item.league %}
{% set membership = item.membership %} {% set membership = item.membership %}
<article class="li-card{% if item.live_session %} li-card--live{% endif %}"> <article class="li-card{% if item.live_session %} li-card--live{% endif %}"
data-name="{{ league.name | lower }}"
data-live="{{ '1' if item.live_session else '0' }}"
data-role="{{ membership.role }}"
data-visibility="{{ league.visibility }}"
data-sessions="{{ item.sessions_count }}"
data-paid="{{ item.cash_paid_out_cents }}"
data-name-raw="{{ league.name }}">
<div class="li-card__aside"> <div class="li-card__top">
<span class="li-card__initial">{{ league.name[0]|upper }}</span>
</div>
<div class="li-card__body">
<div class="li-card__head">
<div class="li-card__info">
<div class="li-card__name-row"> <div class="li-card__name-row">
<a class="li-card__name" href="{{ url_for('leagues.dashboard', league_ref=league.url_ref) }}">{{ league.name }}</a> <a class="li-card__name" href="{{ url_for('leagues.dashboard', league_ref=league.url_ref) }}">{{ league.name }}</a>
{% if item.live_session %} {% if item.live_session %}
<span class="live-badge">{{ session_label(item.live_session) }}</span> <span class="li-pill li-pill--live">Live</span>
{% else %} {% else %}
<span class="status-pill status-pill--closed">idle</span> <span class="li-pill li-pill--idle">Idle</span>
{% endif %} {% endif %}
{% if item.latest_session %}<span class="li-card__season">{{ session_label(item.latest_session) }}</span>{% endif %}
</div> </div>
<div class="li-card__chips"> <div style="display:flex;align-items:center;gap:8px;flex-shrink:0;">
<span class="db-chip db-chip--accent">{{ membership.role }}</span> <span class="li-badge">{{ membership.role }}</span>
<span class="db-chip {% if league.visibility == 'public' %}db-chip--public{% endif %}"> <span class="li-badge">{{ league.visibility }}</span>
{% if league.visibility == 'public' %} <a class="li-open-btn" href="{{ url_for('leagues.dashboard', league_ref=league.url_ref) }}">Open</a>
<svg width="10" height="10" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<circle cx="10" cy="10" r="8" stroke="currentColor" stroke-width="1.6"/>
<path d="M10 2c-2.5 3-2.5 13 0 16" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" fill="none"/>
<path d="M10 2c2.5 3 2.5 13 0 16" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" fill="none"/>
<path d="M2.5 10h15" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
</svg>
public
{% else %}
<svg width="9" height="11" viewBox="0 0 10 12" fill="none" aria-hidden="true">
<rect x="1" y="5" width="8" height="6.5" rx="1.5" stroke="currentColor" stroke-width="1.5"/>
<path d="M3 5V3.5A2 2 0 0 1 7 3.5V5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
private
{% endif %}
</span>
</div>
{% if league.description %}
<p class="li-card__desc">{{ league.description }}</p>
{% endif %}
</div>
<div class="li-card__stats">
<div class="li-stat">
<span>Players</span>
<strong>{{ item.players_count }}</strong>
</div>
<div class="li-stat">
<span>Sessions</span>
<strong>{{ item.sessions_count }}</strong>
</div>
<div class="li-stat">
<span>Paid out</span>
<strong>{{ item.cash_paid_out_cents | money }}</strong>
</div>
<div class="li-stat">
<span>Open</span>
<strong class="{{ 'num-neg' if item.open_items_cents > 0 else 'num-muted' }}">{{ item.open_items_cents | money }}</strong>
</div>
</div> </div>
</div> </div>
<div class="li-card__body">
<div class="li-card__stats-col">
<span class="li-stat-label">Players</span>
<strong class="li-stat-num">{{ item.players_count }}</strong>
</div>
<div class="li-card__stats-col">
<span class="li-stat-label">Sessions</span>
<strong class="li-stat-num">{{ item.sessions_count }}</strong>
</div>
<div class="li-card__stats-col">
<span class="li-stat-label">Paid out</span>
<strong class="li-stat-num">{{ item.cash_paid_out_cents | money }}</strong>
</div>
<div class="li-card__stats-col">
<span class="li-stat-label">Open</span>
<strong class="li-stat-num {{ 'num-neg' if item.open_items_cents > 0 else 'num-muted' }}">{{ item.open_items_cents | money }}</strong>
</div>
{% if item.top_players %} {% if item.top_players %}
<div class="li-card__leaders"> <div class="li-card__leaders-col">
<span class="li-card__leaders-label">Leaders</span> <span class="li-stat-label">Leaders</span>
<div class="li-podium"> <div class="li-leaders-list">
{% for player in item.top_players %} {% for player in item.top_players %}
<div class="li-podium-item li-podium-item--{{ loop.index }}"> <div class="li-leader-row">
<span class="li-podium-rank">#{{ loop.index }}</span> <span class="li-leader-rank">#{{ loop.index }}</span>
<span class="li-podium-name">{{ player.player_name }}</span> <span class="li-leader-name">{{ player.player_name }}</span>
<strong class="li-podium-net {{ 'num-pos' if player.total_net_cents > 0 else 'num-neg' if player.total_net_cents < 0 else 'num-muted' }}"> <strong class="li-leader-net {{ 'num-pos' if player.total_net_cents > 0 else 'num-neg' if player.total_net_cents < 0 else 'num-muted' }}">
{{ '+' if player.total_net_cents > 0 else '' }}{{ player.total_net_cents | money }} {{ '+' if player.total_net_cents > 0 else '' }}{{ player.total_net_cents | money }}
</strong> </strong>
</div> </div>
@@ -102,32 +181,146 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
</div>
<div class="li-card__footer"> <div class="li-card__footer">
<span class="li-card__meta"> <span class="li-card__footer-meta">
{% if item.latest_session %}Latest {{ session_label(item.latest_session) }}{% elif not item.top_players %}No sessions yet{% endif %} {% if item.live_session %}Live now · table open{% else %}{% if item.latest_session %}Last played {{ session_label(item.latest_session) }}{% else %}No sessions yet{% endif %}{% endif %}
</span> </span>
<div class="li-card__actions"> <div class="li-card__footer-links">
<a class="btn btn--primary btn--sm" href="{{ url_for('leagues.dashboard', league_ref=league.url_ref) }}">Open league</a> <a class="li-footer-link" href="{{ url_for('leagues.sessions', league_ref=league.url_ref) }}">Sessions</a>
<a class="li-action-link" href="{{ url_for('leagues.sessions', league_ref=league.url_ref) }}">Sessions</a> <a class="li-footer-link" href="{{ url_for('leagues.leaderboard', league_ref=league.url_ref) }}">Leaderboard</a>
<a class="li-action-link" href="{{ url_for('leagues.leaderboard', league_ref=league.url_ref) }}">Leaderboard</a>
{% if membership.role in ('owner', 'manager') %} {% if membership.role in ('owner', 'manager') %}
<a class="li-action-link" href="{{ url_for('leagues.ledger', league_ref=league.url_ref) }}">Ledger</a> <a class="li-footer-link" href="{{ url_for('leagues.ledger', league_ref=league.url_ref) }}">Ledger</a>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div>
</article> </article>
{% endfor %} {% endfor %}
</div>
<p class="li-empty-search" id="liEmptySearch" style="display:none;">No leagues match your search or filter.</p>
{% else %}
<div class="panel empty-panel">
<h2 class="panel__title">No leagues yet</h2>
<p class="muted-text">Fill in the form to create your first league.</p>
</div>
{% endif %}
</section>
</div> </div>
{% else %} <script>
<div class="panel empty-panel"> (function () {
<h2 class="panel__title">No leagues yet</h2> // --- Visibility toggle ---
<p class="muted-text">Create your first league to start adding players, sessions, and ledger events.</p> var visInput = document.getElementById('lf-visibility');
<a class="btn btn--primary" href="{{ url_for('leagues.new') }}">Create league</a> var visHint = document.getElementById('visHint');
</div> document.querySelectorAll('.li-vis-btn').forEach(function (btn) {
{% endif %} btn.addEventListener('click', function () {
document.querySelectorAll('.li-vis-btn').forEach(function (b) { b.classList.remove('is-on'); });
this.classList.add('is-on');
visInput.value = this.dataset.vis;
visHint.textContent = this.dataset.vis === 'public'
? 'Visible in Explore. Anyone can view.'
: 'Invite-only. Nothing appears in Explore.';
});
});
// --- Preset buy-in / rebuy buttons ---
document.querySelectorAll('.li-preset-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
var field = this.dataset.field;
var val = this.dataset.val;
var input = document.getElementById('lf-' + field);
// toggle: clicking active preset clears it
var active = this.classList.contains('is-on');
document.querySelectorAll('.li-preset-btn[data-field="' + field + '"]').forEach(function (b) {
b.classList.remove('is-on');
});
if (!active) {
this.classList.add('is-on');
input.value = val;
} else {
input.value = '';
}
});
});
// Sync custom input → deselect preset if user types a custom value
['buyin', 'rebuy'].forEach(function (field) {
var input = document.getElementById('lf-' + field);
if (!input) return;
input.addEventListener('input', function () {
var v = this.value.trim();
document.querySelectorAll('.li-preset-btn[data-field="' + field + '"]').forEach(function (b) {
b.classList.toggle('is-on', b.dataset.val === v);
});
});
// Highlight preset if form was repopulated with an error
var cur = input.value.trim();
if (cur) {
document.querySelectorAll('.li-preset-btn[data-field="' + field + '"]').forEach(function (b) {
if (b.dataset.val === cur) b.classList.add('is-on');
});
}
});
// --- Search + filter + sort ---
var cards = Array.from(document.querySelectorAll('.li-card'));
var board = document.getElementById('liBoard');
var empty = document.getElementById('liEmptySearch');
var search = document.getElementById('liSearch');
var activeFilter = 'all';
var activeSort = 'activity';
function applyAll() {
var q = search ? search.value.trim().toLowerCase() : '';
var visible = [];
cards.forEach(function (c) {
var nameMatch = !q || c.dataset.name.includes(q);
var filterMatch = true;
if (activeFilter === 'live') filterMatch = c.dataset.live === '1';
if (activeFilter === 'owner') filterMatch = c.dataset.role === 'owner';
if (activeFilter === 'public') filterMatch = c.dataset.visibility === 'public';
var show = nameMatch && filterMatch;
c.style.display = show ? '' : 'none';
if (show) visible.push(c);
});
// Sort
visible.sort(function (a, b) {
if (activeSort === 'name') return a.dataset.nameRaw < b.dataset.nameRaw ? -1 : 1;
if (activeSort === 'money') return Number(b.dataset.paid) - Number(a.dataset.paid);
// activity: live first, then by sessions desc
var la = a.dataset.live === '1' ? 1 : 0;
var lb = b.dataset.live === '1' ? 1 : 0;
if (lb !== la) return lb - la;
return Number(b.dataset.sessions) - Number(a.dataset.sessions);
});
visible.forEach(function (c) { board && board.appendChild(c); });
if (empty) empty.style.display = (visible.length === 0) ? 'block' : 'none';
}
if (search) search.addEventListener('input', applyAll);
document.querySelectorAll('.li-filter-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
document.querySelectorAll('.li-filter-btn').forEach(function (b) { b.classList.remove('is-on'); });
this.classList.add('is-on');
activeFilter = this.dataset.filter;
applyAll();
});
});
document.querySelectorAll('.li-sort-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
document.querySelectorAll('.li-sort-btn').forEach(function (b) { b.classList.remove('is-on'); });
this.classList.add('is-on');
activeSort = this.dataset.sort;
applyAll();
});
});
applyAll();
})();
</script>
{% endblock %} {% endblock %}
+36
View File
@@ -5,6 +5,42 @@ from boker.services import build_leaderboard
class CashInAccountingTest(unittest.TestCase): class CashInAccountingTest(unittest.TestCase):
def test_leaderboard_win_pct_is_percent_not_ratio(self):
sessions = [
SessionSummary(
session_id="2026-06-21-01",
session_date="2026-06-21",
entries=[
SessionEntry(
session_id="2026-06-21-01",
session_date="2026-06-21",
player_name="Alex",
buy_in_cents=1000,
cash_out_cents=1500,
)
],
),
SessionSummary(
session_id="2026-06-28-01",
session_date="2026-06-28",
entries=[
SessionEntry(
session_id="2026-06-28-01",
session_date="2026-06-28",
player_name="Alex",
buy_in_cents=1000,
cash_out_cents=0,
)
],
),
]
player = build_leaderboard(sessions)[0]
self.assertEqual(player.winning_sessions, 1)
self.assertEqual(player.sessions_played, 2)
self.assertEqual(player.win_pct, 50.0)
def test_front_is_poker_investment_not_cash_in(self): def test_front_is_poker_investment_not_cash_in(self):
entry = SessionEntry( entry = SessionEntry(
session_id="2026-06-21-01", session_id="2026-06-21-01",