fix player win % and visual overhaul
This commit is contained in:
16 files changed
+3039
-1406
No files matched your search
@@ -98,6 +98,8 @@ class League(TimestampMixin, db.Model):
|
||||
archived_at = db.Column(db.DateTime(timezone=True), nullable=True)
|
||||
eligible_min_sessions = db.Column(db.Integer, nullable=False, default=3)
|
||||
break_even_cents = db.Column(db.Integer, nullable=False, default=100)
|
||||
default_buyin_cents = db.Column(db.Integer, nullable=False, default=0)
|
||||
default_rebuy_cents = db.Column(db.Integer, nullable=False, default=0)
|
||||
|
||||
__table_args__ = (
|
||||
db.CheckConstraint(
|
||||
|
||||
+91
-39
@@ -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)}
|
||||
|
||||
|
||||
@leagues_bp.get("/leagues")
|
||||
@leagues_bp.route("/leagues", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def index():
|
||||
if not db_ready():
|
||||
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.ledger_repositories import list_event_rows_for_league
|
||||
@@ -138,6 +191,7 @@ def index():
|
||||
+ session.total_current_due_to_house_cents
|
||||
for session in summaries
|
||||
)
|
||||
cash_paid_out_cents = sum(session.total_paid_out_cents for session in summaries)
|
||||
total_sessions += len(summaries)
|
||||
total_players += players_count
|
||||
total_events += len(events)
|
||||
@@ -155,9 +209,7 @@ def index():
|
||||
"players_count": players_count,
|
||||
"sessions_count": len(summaries),
|
||||
"events_count": len(events),
|
||||
"cash_paid_out_cents": sum(
|
||||
session.total_paid_out_cents for session in summaries
|
||||
),
|
||||
"cash_paid_out_cents": cash_paid_out_cents,
|
||||
"open_items_cents": open_items_cents,
|
||||
}
|
||||
)
|
||||
@@ -168,51 +220,21 @@ def index():
|
||||
"sessions_count": total_sessions,
|
||||
"events_count": total_events,
|
||||
"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(
|
||||
"leagues_index.html",
|
||||
leagues=league_summaries,
|
||||
page_summary=page_summary,
|
||||
session_label=session_label,
|
||||
form=form,
|
||||
)
|
||||
|
||||
|
||||
@leagues_bp.route("/leagues/new", methods=["GET", "POST"])
|
||||
@leagues_bp.get("/leagues/new")
|
||||
@login_required
|
||||
def new():
|
||||
if not db_ready():
|
||||
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)
|
||||
return redirect(url_for("leagues.index"))
|
||||
|
||||
|
||||
@leagues_bp.get("/l/<league_ref>")
|
||||
@@ -272,6 +294,14 @@ def dashboard(league_ref: str):
|
||||
"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(
|
||||
"league_dashboard.html",
|
||||
league=league,
|
||||
@@ -279,6 +309,12 @@ def dashboard(league_ref: str):
|
||||
can_manage=can_manage,
|
||||
is_owner=is_owner,
|
||||
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,
|
||||
session_label=session_label,
|
||||
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,
|
||||
"eligible_min_sessions": league.eligible_min_sessions,
|
||||
"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":
|
||||
raw_eligible = request.form.get("eligible_min_sessions", "3").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 = {
|
||||
"name": request.form.get("name", "").strip(),
|
||||
"description": request.form.get("description", "").strip(),
|
||||
"visibility": request.form.get("visibility", "private").strip(),
|
||||
"eligible_min_sessions": raw_eligible,
|
||||
"break_even_dollars": raw_break_even,
|
||||
"default_buyin_dollars": raw_default_buyin,
|
||||
"default_rebuy_dollars": raw_default_rebuy,
|
||||
}
|
||||
try:
|
||||
eligible_min = int(raw_eligible)
|
||||
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):
|
||||
flash("Eligible sessions and break-even threshold must be valid numbers.", "error")
|
||||
else:
|
||||
@@ -1436,6 +1482,10 @@ def league_settings(league_ref: str):
|
||||
flash("Eligible minimum must be between 1 and 100.", "error")
|
||||
elif break_even_cents < 0 or break_even_cents > 10000:
|
||||
flash("Break-even threshold must be between $0.00 and $100.00.", "error")
|
||||
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:
|
||||
from boker.utils import slugify
|
||||
|
||||
@@ -1445,6 +1495,8 @@ def league_settings(league_ref: str):
|
||||
league.visibility = form["visibility"]
|
||||
league.eligible_min_sessions = eligible_min
|
||||
league.break_even_cents = break_even_cents
|
||||
league.default_buyin_cents = default_buyin_cents
|
||||
league.default_rebuy_cents = default_rebuy_cents
|
||||
db.session.commit()
|
||||
flash("League settings saved.", "success")
|
||||
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
File diff suppressed because it is too large.
Load diff
+76
-61
@@ -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 {
|
||||
/* Core surfaces */
|
||||
--bg: #0a0a0d;
|
||||
--surface: #111115;
|
||||
--surface-raised:#16161c;
|
||||
--surface-head: #18181f;
|
||||
--surface-sunk: #0d0d11;
|
||||
--field: #1a1a22;
|
||||
--field-alt: #1c1c25;
|
||||
--bg: #101419;
|
||||
--surface: #171D25;
|
||||
--surface-raised:#202835;
|
||||
--surface-head: #202835;
|
||||
--surface-sunk: #0D1116;
|
||||
--surface-2: #202835;
|
||||
--field: #1D2530;
|
||||
--field-alt: #26313E;
|
||||
--panel: #171D25;
|
||||
--panel-hover: #202835;
|
||||
|
||||
/* ---- borders / dividers ---- */
|
||||
--border: #22222b;
|
||||
--border-strong: #2a2a34;
|
||||
--border-hi: #32323d;
|
||||
--divider: #191921;
|
||||
--slash: #383843;
|
||||
--border: #2B3542;
|
||||
--border-strong: #3A4655;
|
||||
--border-hi: #505D6E;
|
||||
--divider: #25303B;
|
||||
--slash: #3A4655;
|
||||
|
||||
/* ---- text ---- */
|
||||
--text: #f4f2f9;
|
||||
--text-strong: #edeaf5;
|
||||
--text-body: #e8e6ef;
|
||||
--text-2: #bdbbc8;
|
||||
--num: #b2b0bb;
|
||||
--muted: #8c8a96;
|
||||
--muted-2: #82808c;
|
||||
--faint: #7a7885;
|
||||
--faint-2: #72707d;
|
||||
--faintest: #64626d;
|
||||
--faintest-2: #55535e;
|
||||
--text: #F2F0ED;
|
||||
--text-strong: #F2F0ED;
|
||||
--text-body: #C7CED8;
|
||||
--text-2: #C7CED8;
|
||||
--num: #A1ACBA;
|
||||
--muted: #9AA5B4;
|
||||
--muted-2: #8793A3;
|
||||
--faint: #6E7A8A;
|
||||
--faint-2: #647080;
|
||||
--faintest: #576272;
|
||||
--faintest-2: #4B5666;
|
||||
|
||||
/* Brand accent */
|
||||
--accent: #9b8cf0;
|
||||
--accent-ink: #12101e;
|
||||
--accent-tint: #1f1d2e;
|
||||
--accent-chip: #242137;
|
||||
--accent-chip-bd:#383152;
|
||||
--accent-a22: rgba(155,140,240,0.22);
|
||||
--accent-a30: rgba(155,140,240,0.30);
|
||||
/* Brand accent — muted purple */
|
||||
--accent: #9B8FE3;
|
||||
--accent-ink: #0d0b18;
|
||||
--accent-tint: #211D36;
|
||||
--accent-chip: #292442;
|
||||
--accent-chip-bd:#4D4581;
|
||||
--accent-a22: rgba(155,143,227,0.22);
|
||||
--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 ---- */
|
||||
--pos: #6fc093;
|
||||
--pos-bar: #41815a;
|
||||
--pos-tint: rgba(111,192,147,0.12);
|
||||
--pos-tint-bd: rgba(111,192,147,0.30);
|
||||
--neg: #e0758a;
|
||||
--neg-bar: #9e445a;
|
||||
--neg-tint: rgba(224,117,138,0.13);
|
||||
--neg-tint-bd: rgba(224,117,138,0.32);
|
||||
--owes: #e8829a;
|
||||
--owes-tint: rgba(232,130,154,0.15);
|
||||
--owes-tint-bd: rgba(232,130,154,0.40);
|
||||
--pos: #58D18E;
|
||||
--pos-bar: #379562;
|
||||
--pos-tint: rgba(88,209,142,0.12);
|
||||
--pos-tint-bd: rgba(88,209,142,0.30);
|
||||
--neg: #F06F7A;
|
||||
--neg-bar: #AC4550;
|
||||
--neg-tint: rgba(240,111,122,0.12);
|
||||
--neg-tint-bd: rgba(240,111,122,0.30);
|
||||
--owes: #F06F7A;
|
||||
--owes-tint: rgba(240,111,122,0.14);
|
||||
--owes-tint-bd: rgba(240,111,122,0.34);
|
||||
|
||||
/* ---- semantic: cash-movement hues ---- */
|
||||
--warn: #e0b15c;
|
||||
--warn-tint: rgba(224,177,92,0.13);
|
||||
--warn-tint-bd: rgba(224,177,92,0.32);
|
||||
--rolled: #8f93c2;
|
||||
--warn: #E0B85C;
|
||||
--warn-tint: rgba(224,184,92,0.12);
|
||||
--warn-tint-bd: rgba(224,184,92,0.30);
|
||||
--rolled: #8FA4BB;
|
||||
|
||||
/* ---- medals ---- */
|
||||
--rank-1: var(--accent);
|
||||
--rank-2: #c2bcd0;
|
||||
--rank-3: #a78bb0;
|
||||
--rank-2: var(--accent2);
|
||||
--rank-3: var(--accent3);
|
||||
|
||||
/* ---- chart line palette (top-N order) ---- */
|
||||
--line-1: #9b8cf0; --line-2: #6fc093; --line-3: #e0b15c;
|
||||
--line-4: #cf6f86; --line-5: #8f93c2;
|
||||
--line-1: #9B8FE3; --line-2: #58D18E; --line-3: #E0B85C;
|
||||
--line-4: #F06F7A; --line-5: #8FA4BB;
|
||||
|
||||
/* Elevation */
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,.28);
|
||||
--shadow-md: 0 3px 12px rgba(0,0,0,.32), 0 1px 4px rgba(0,0,0,.24);
|
||||
--shadow-lg: 0 10px 28px rgba(0,0,0,.38), 0 3px 10px rgba(0,0,0,.26);
|
||||
--shadow-xl: 0 18px 48px rgba(0,0,0,.46), 0 6px 18px rgba(0,0,0,.30);
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,.38);
|
||||
--shadow-md: 0 2px 10px rgba(0,0,0,.42), 0 1px 3px rgba(0,0,0,.28);
|
||||
--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,.54), 0 4px 16px rgba(0,0,0,.32);
|
||||
|
||||
/* ---- glass panel effect ---- */
|
||||
--glass-border: 1px solid rgba(255,255,255,.055);
|
||||
--glass-bg: rgba(255,255,255,.02);
|
||||
--glass-border: 1px solid var(--border);
|
||||
--glass-bg: transparent;
|
||||
|
||||
/* ---- spacing scale (4pt) ---- */
|
||||
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:20px;
|
||||
--s6:24px; --s8:32px; --s10:40px; --s12:48px;
|
||||
|
||||
/* ---- radii ---- */
|
||||
--r-pill:5px; --r-sm:7px; --r-md:9px; --r-lg:12px;
|
||||
/* ---- radii — industrial, minimal rounding ---- */
|
||||
--r-pill:4px; --r-sm:4px; --r-md:5px; --r-lg:6px;
|
||||
|
||||
/* ---- containers ---- */
|
||||
--w-public:1320px;
|
||||
@@ -91,6 +106,6 @@
|
||||
--gutter: clamp(16px, 4vw, 40px);
|
||||
|
||||
/* ---- Chart.js backward compat (read as raw strings by JS) ---- */
|
||||
--text-muted: #bdbbc8;
|
||||
--line: #22222b;
|
||||
--text-muted: #9AA5B4;
|
||||
--line: #2B3542;
|
||||
}
|
||||
+121
-386
@@ -81,433 +81,168 @@
|
||||
{% block page_class %}page--landing{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="lp-hero">
|
||||
<div class="lp-hero__content">
|
||||
<p class="lp-kicker">Home Poker Tracking</p>
|
||||
<h1 class="lp-h1">
|
||||
<span class="lp-h1__line">Free poker</span>
|
||||
<span class="lp-h1__line">tracker</span>
|
||||
<span class="lp-h1__accent">for home games.</span>
|
||||
</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">
|
||||
<section class="home-hero home-hero--quiet">
|
||||
<div class="home-hero__inner">
|
||||
<div class="home-hero__copy">
|
||||
<p class="home-kicker">Poker ledger & league tracker</p>
|
||||
<h1 class="home-title">Free poker ledger for home games.</h1>
|
||||
<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>
|
||||
<div class="home-actions">
|
||||
{% 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 %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="lp-trust">
|
||||
<span>No fees</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>
|
||||
<div class="home-proof" aria-label="Product highlights">
|
||||
<span>Free</span>
|
||||
<span>Private by default</span>
|
||||
{% if not is_logged_in %}
|
||||
<span class="lp-trust__sep">·</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 %}
|
||||
<span>No payment processor</span>
|
||||
<span>Append-only ledger</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lp-hero__visual" aria-hidden="true">
|
||||
<div class="lp-demo__card">
|
||||
<div class="lp-demo__head">
|
||||
<div class="home-board" aria-label="Example poker ledger interface">
|
||||
<div class="home-board__top">
|
||||
<div>
|
||||
<span class="lp-demo__kicker">Friday Night Poker · Session #006</span>
|
||||
<span class="lp-demo__title">Live event log <span class="lp-live-dot">● live</span></span>
|
||||
<span class="home-board__eyebrow">Example League</span>
|
||||
<strong>Session 17</strong>
|
||||
</div>
|
||||
<span class="lp-demo__meta">Jun 21 · 5 players</span>
|
||||
<span class="home-board__status">Open</span>
|
||||
</div>
|
||||
<div class="lp-feed__rows">
|
||||
<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 & 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 class="home-board__metrics home-board__metrics--four">
|
||||
<div>
|
||||
<span class="lp-demo__kicker">Friday Night Poker</span>
|
||||
<span class="lp-demo__title">Sessions</span>
|
||||
<span>Players</span>
|
||||
<strong>16</strong>
|
||||
</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>
|
||||
<span class="lp-demo__kicker">Friday Night Poker</span>
|
||||
<span class="lp-demo__title">Session #006 · Jun 21</span>
|
||||
<span>Buy-ins</span>
|
||||
<strong>$327.50</strong>
|
||||
</div>
|
||||
<span class="lp-demo__meta">5 players · open</span>
|
||||
<div>
|
||||
<span>Cash in</span>
|
||||
<strong>$292.50</strong>
|
||||
</div>
|
||||
<div class="lp-recon-grid">
|
||||
<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>
|
||||
<div>
|
||||
<span>Open</span>
|
||||
</div>
|
||||
<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>
|
||||
<strong>$35.00</strong>
|
||||
</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 class="home-ledger__row">
|
||||
<span>8:14</span><strong>Player A</strong><em>buy-in</em><b>$20.00</b>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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 class="home-ledger__row">
|
||||
<span>8:22</span><strong>Player B</strong><em>rebuy</em><b>$10.00</b>
|
||||
</div>
|
||||
<div class="lp-callout__demo" aria-hidden="true">
|
||||
<div class="lp-demo__card">
|
||||
<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 class="home-ledger__row">
|
||||
<span>9:41</span><strong>Player C</strong><em>cashout</em><b class="num-pos">$35.00</b>
|
||||
</div>
|
||||
<span class="lp-demo__meta">18 sessions · eligible</span>
|
||||
</div>
|
||||
<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 class="home-ledger__row">
|
||||
<span>10:08</span><strong>Player D</strong><em>front</em><b class="num-neg">$10.00</b>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home-board__footer">
|
||||
<span>Leaderboard updates on close</span>
|
||||
<strong class="num-pos">+$26.30</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="lp-seo">
|
||||
<div class="lp-seo__inner">
|
||||
<div class="lp-sec-head">
|
||||
<span class="lp-eyebrow">Free poker tracker</span>
|
||||
<h2 class="lp-sec-title">Poker records without a spreadsheet.</h2>
|
||||
<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>
|
||||
<section class="home-strip" aria-label="Core product facts">
|
||||
<div class="home-strip__inner">
|
||||
<div><strong>Sessions</strong><span>open, close, and review each game night</span></div>
|
||||
<div><strong>Ledger</strong><span>buy-ins, cashouts, fronts, rollovers, writeoffs</span></div>
|
||||
<div><strong>Stats</strong><span>net, ROI, win rate, best win, recent form</span></div>
|
||||
<div><strong>Leagues</strong><span>private by default, public only when enabled</span></div>
|
||||
</div>
|
||||
<div class="lp-seo__grid">
|
||||
<article class="lp-seo__item">
|
||||
<h3>Track poker profits and losses</h3>
|
||||
<p>Record each buy-in, re-buy, cashout, correction, and note as it happens. Player profit and loss updates from the ledger automatically.</p>
|
||||
<a class="lp-seo__link" href="{{ url_for('public.use_case', slug='poker-profit-loss-tracker') }}">Profit and loss tracking</a>
|
||||
</section>
|
||||
|
||||
<section class="home-section">
|
||||
<div class="home-section__head">
|
||||
<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 class="lp-seo__item">
|
||||
<h3>Keep a clean poker ledger</h3>
|
||||
<p>The append-only ledger keeps sessions, settlements, fronts, and payouts in order while preserving the history behind every total.</p>
|
||||
<a class="lp-seo__link" href="{{ url_for('public.use_case', slug='poker-ledger') }}">Poker ledger details</a>
|
||||
<article>
|
||||
<span>02</span>
|
||||
<h3>Settle cleanly</h3>
|
||||
<p>See who was paid, who still owes, and what carried forward before the session is closed.</p>
|
||||
</article>
|
||||
<article class="lp-seo__item">
|
||||
<h3>Build poker stats automatically</h3>
|
||||
<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>
|
||||
<a class="lp-seo__link" href="{{ url_for('public.use_case', slug='poker-stats-tracker') }}">Poker stats tracking</a>
|
||||
<article>
|
||||
<span>03</span>
|
||||
<h3>Rank fairly</h3>
|
||||
<p>Use session minimums and seasons so leaderboards reflect regular players, not one good night.</p>
|
||||
</article>
|
||||
<article class="lp-seo__item">
|
||||
<h3>Built for home games</h3>
|
||||
<p>Private leagues, shared results, and public league pages are all optional. Your group decides what stays private and what gets shared.</p>
|
||||
<a class="lp-seo__link" href="{{ url_for('public.use_case', slug='home-poker-league-tracker') }}">League tracking</a>
|
||||
<article>
|
||||
<span>04</span>
|
||||
<h3>Keep history</h3>
|
||||
<p>Every result traces back to ledger entries, so totals are easy to audit months later.</p>
|
||||
</article>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
{% if not is_logged_in %}
|
||||
<section class="lp-bottom-cta">
|
||||
<div class="lp-bottom-cta__inner">
|
||||
<h2 class="lp-bottom-cta__title">Start tracking your league</h2>
|
||||
<p class="lp-bottom-cta__sub">Free to use. No payment processor. Your data stays yours.</p>
|
||||
<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--ghost btn--lg" href="{{ url_for('public.explore') }}">Search public leagues</a>
|
||||
</div>
|
||||
<section class="home-final">
|
||||
<p class="home-kicker">Start simple</p>
|
||||
<h2>Create a league, add players, and track the next session properly.</h2>
|
||||
<div class="home-actions home-actions--center">
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
+163
-165
@@ -4,16 +4,17 @@
|
||||
{% block page_class %}page--app{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{# ── Header ── #}
|
||||
<div class="db-header">
|
||||
<div class="db-header__info">
|
||||
<div class="db-header__chips">
|
||||
<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>
|
||||
<h1 class="db-header__title">{{ league.name }}</h1>
|
||||
{% if league.description %}<p class="db-header__sub">{{ league.description }}</p>{% endif %}
|
||||
</div>
|
||||
{% if is_owner %}
|
||||
{% if can_manage %}
|
||||
<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--primary btn--sm" href="{{ url_for('leagues.sessions', league_ref=league.url_ref) }}">New session</a>
|
||||
@@ -21,131 +22,141 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if counts.open_sessions > 0 %}
|
||||
<div class="db-live-alert">
|
||||
<div class="db-live-alert__pulse"></div>
|
||||
<span class="db-live-alert__text">
|
||||
{{ counts.open_sessions }} session{{ 's' if counts.open_sessions != 1 else '' }} in progress
|
||||
</span>
|
||||
<a class="db-live-alert__link" href="{{ url_for('leagues.sessions', league_ref=league.url_ref) }}">View sessions →</a>
|
||||
{# ── Live session banner ── #}
|
||||
{% if live_session %}
|
||||
<div class="db-live-banner">
|
||||
<div class="db-live-banner__top">
|
||||
<div class="db-live-banner__status">
|
||||
<span class="db-live-dot"></span>
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
<div class="db-stat-grid">
|
||||
<div class="db-stat">
|
||||
<div class="db-stat__icon db-stat__icon--accent">
|
||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<circle cx="8" cy="7" r="3" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M2 17c0-3.314 2.686-6 6-6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<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>
|
||||
{# ── Overview stats strip ── #}
|
||||
<div class="db-overview-strip">
|
||||
<div class="db-overview-stat db-overview-stat--players">
|
||||
<span class="db-overview-stat__label">Players</span>
|
||||
<strong class="db-overview-stat__val">{{ counts.players }}</strong>
|
||||
<span class="db-overview-stat__sub">{{ counts.players }} active</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="db-stat__num">{{ counts.players }}</span>
|
||||
<span class="db-stat__label">Players</span>
|
||||
<div class="db-overview-stat db-overview-stat--sessions">
|
||||
<span class="db-overview-stat__label">Sessions</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 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 class="db-stat">
|
||||
<div class="db-stat__icon db-stat__icon--pos">
|
||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<rect x="2.5" y="3.5" width="15" height="13" rx="2" stroke="currentColor" stroke-width="1.5"/>
|
||||
<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 class="db-overview-stat db-overview-stat--paidout">
|
||||
<span class="db-overview-stat__label">Paid out</span>
|
||||
<strong class="db-overview-stat__val">{{ cash_stats.total_paid_out | money }}</strong>
|
||||
<span class="db-overview-stat__sub">to {{ counts.players }} players</span>
|
||||
</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>
|
||||
|
||||
{# ── League averages ── #}
|
||||
{% if cash_stats.has_data %}
|
||||
<div class="db-cash">
|
||||
<div class="db-cash__stat">
|
||||
<span class="db-cash__label">Total cash in</span>
|
||||
<span class="db-cash__val">{{ cash_stats.total_cash_in | money }}</span>
|
||||
<div class="db-averages">
|
||||
<p class="db-averages__title">League averages</p>
|
||||
<div class="db-averages__grid">
|
||||
<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 class="db-cash__div"></div>
|
||||
<div class="db-cash__stat">
|
||||
<span class="db-cash__label">Total paid out</span>
|
||||
<span class="db-cash__val">{{ cash_stats.total_paid_out | money }}</span>
|
||||
<div class="db-avg-item">
|
||||
<span class="db-avg-label">Avg players</span>
|
||||
<strong class="db-avg-val">{{ cash_stats.avg_players }}</strong>
|
||||
</div>
|
||||
<div class="db-cash__div"></div>
|
||||
<div class="db-cash__stat">
|
||||
<span class="db-cash__label">Avg pot / session</span>
|
||||
<span class="db-cash__val">{{ cash_stats.avg_pot | money }}</span>
|
||||
<div class="db-avg-item">
|
||||
<span class="db-avg-label">Avg buy-in</span>
|
||||
<strong class="db-avg-val">{{ cash_stats.avg_buyin | money }}</strong>
|
||||
</div>
|
||||
<div class="db-cash__div"></div>
|
||||
<div class="db-cash__stat">
|
||||
<span class="db-cash__label">Avg players</span>
|
||||
<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 class="db-avg-item">
|
||||
<span class="db-avg-label">Biggest session</span>
|
||||
<strong class="db-avg-val">{{ cash_stats.biggest_pot | money }}</strong>
|
||||
</div>
|
||||
{% if cash_stats.avg_sessions_per_month > 0 %}
|
||||
<div class="db-cash__div"></div>
|
||||
<div class="db-cash__stat">
|
||||
<span class="db-cash__label">Sessions / month</span>
|
||||
<span class="db-cash__val">{{ cash_stats.avg_sessions_per_month }}</span>
|
||||
<div class="db-avg-item">
|
||||
<span class="db-avg-label">Sessions / month</span>
|
||||
<strong class="db-avg-val">{{ cash_stats.avg_sessions_per_month }}</strong>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="db-avg-row">
|
||||
{% if cash_stats.total_fronts > 0 %}
|
||||
<div class="db-cash__div"></div>
|
||||
<div class="db-cash__stat">
|
||||
<span class="db-cash__label">Fronts issued</span>
|
||||
<span class="db-cash__val">{{ cash_stats.total_fronts | money }}</span>
|
||||
<div class="db-avg-item">
|
||||
<span class="db-avg-label">Fronts issued</span>
|
||||
<strong class="db-avg-val">{{ cash_stats.total_fronts | money }}</strong>
|
||||
</div>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
|
||||
{# ── Session history heatmap ── #}
|
||||
{% if cash_stats.has_sessions %}
|
||||
<div class="db-cal"
|
||||
id="db-cal"
|
||||
data-sessions='{{ cash_stats.session_map | tojson }}'
|
||||
data-first-year="{{ cash_stats.first_session_year }}">
|
||||
<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">
|
||||
<button class="db-cal__nav" id="db-cal-prev" aria-label="Previous year">‹</button>
|
||||
<span class="db-cal__year" id="db-cal-year"></span>
|
||||
<button class="db-cal__nav" id="db-cal-next" aria-label="Next year">›</button>
|
||||
</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>
|
||||
<script>
|
||||
@@ -250,94 +261,81 @@
|
||||
</script>
|
||||
{% 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-nav-tile__icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2.5" stroke="currentColor" stroke-width="1.6"/>
|
||||
<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 class="db-panel">
|
||||
<div class="db-panel__head">
|
||||
<span class="db-panel__title">Recent sessions</span>
|
||||
<a class="db-panel__link" href="{{ url_for('leagues.sessions', league_ref=league.url_ref) }}">All {{ total_sessions }} →</a>
|
||||
</div>
|
||||
<div class="db-nav-tile__body">
|
||||
<div class="db-nav-tile__title-row">
|
||||
<strong class="db-nav-tile__title">Sessions</strong>
|
||||
{% if counts.open_sessions > 0 %}
|
||||
<span class="live-badge">{{ counts.open_sessions }} live</span>
|
||||
{% if recent_sessions %}
|
||||
<div class="db-table">
|
||||
{% for s in recent_sessions %}
|
||||
<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="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 %}
|
||||
</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>
|
||||
<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 class="db-nav-tile db-nav-tile--leaderboard" href="{{ url_for('leagues.leaderboard', league_ref=league.url_ref) }}">
|
||||
<div class="db-nav-tile__icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<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 class="db-quicknav__item" href="{{ url_for('leagues.players', league_ref=league.url_ref) }}">
|
||||
<div class="db-quicknav__title">Players →</div>
|
||||
<div class="db-quicknav__desc">Session history, net trend</div>
|
||||
</a>
|
||||
|
||||
<a class="db-nav-tile db-nav-tile--players" href="{{ url_for('leagues.players', league_ref=league.url_ref) }}">
|
||||
<div class="db-nav-tile__icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<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 class="db-quicknav__item" href="{{ url_for('leagues.leaderboard', league_ref=league.url_ref) }}">
|
||||
<div class="db-quicknav__title">Leaderboard →</div>
|
||||
<div class="db-quicknav__desc">Net profit, win rate, rank delta</div>
|
||||
</a>
|
||||
|
||||
<a class="db-nav-tile db-nav-tile--seasons" href="{{ url_for('leagues.seasons', league_ref=league.url_ref) }}">
|
||||
<div class="db-nav-tile__icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<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 class="db-quicknav__item" href="{{ url_for('leagues.seasons', league_ref=league.url_ref) }}">
|
||||
<div class="db-quicknav__title">Seasons →</div>
|
||||
<div class="db-quicknav__desc">Browse by season, standings</div>
|
||||
</a>
|
||||
|
||||
{% if can_manage %}
|
||||
<a class="db-nav-tile db-nav-tile--ledger" href="{{ url_for('leagues.ledger', league_ref=league.url_ref) }}">
|
||||
<div class="db-nav-tile__icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<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 class="db-quicknav__item" href="{{ url_for('leagues.ledger', league_ref=league.url_ref) }}">
|
||||
<div class="db-quicknav__title">Ledger →</div>
|
||||
<div class="db-quicknav__desc">House balance, fronts, open items</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
<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>
|
||||
</div>
|
||||
<div class="leaderboard-hero-stats">
|
||||
</div>
|
||||
<div class="leaderboard-hero-stats">
|
||||
<div class="stat">
|
||||
<span class="kicker">Cash paid out</span>
|
||||
<div class="stat__val" style="color:var(--accent)">{{ cash_paid_out_cents | money }}</div>
|
||||
@@ -22,7 +23,6 @@
|
||||
<span class="kicker">Players</span>
|
||||
<div class="stat__val" style="color:var(--text-strong)">{{ all_count }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel stack" style="gap:0;margin-bottom:20px;">
|
||||
@@ -30,10 +30,14 @@
|
||||
<div>
|
||||
<span class="kicker">Trend</span>
|
||||
<h2 class="panel__title">Cumulative profit</h2>
|
||||
<span class="panel__note" id="chartSeriesNote"></span>
|
||||
</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 class="chart-wrap">
|
||||
</div>
|
||||
<div class="chart-wrap" id="leaderboardChartWrap">
|
||||
<div class="chart-frame chart-frame--tall">
|
||||
<canvas id="leaderboardChart"></canvas>
|
||||
</div>
|
||||
@@ -53,15 +57,6 @@
|
||||
Recent <span class="seg__count">5 sess</span>
|
||||
</button>
|
||||
</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 class="toolbar__right">
|
||||
{% if seasons %}
|
||||
@@ -90,6 +85,15 @@
|
||||
</form>
|
||||
</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;">
|
||||
<table class="tbl" id="leaderboardTable" data-sort-key="net" data-sort-direction="desc">
|
||||
@@ -190,12 +194,17 @@
|
||||
</div>
|
||||
|
||||
<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 rootStyles = getComputedStyle(document.documentElement);
|
||||
const textMuted = rootStyles.getPropertyValue('--text-muted').trim();
|
||||
const lineColor = rootStyles.getPropertyValue('--line').trim();
|
||||
const textMuted = rootStyles.getPropertyValue('--muted').trim() || '#9AA5B4';
|
||||
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) => {
|
||||
ds.borderColor = 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,
|
||||
datasets: data.datasets.map(ds => ({
|
||||
...ds,
|
||||
pointRadius: compact ? 0 : 2,
|
||||
pointHoverRadius: 5,
|
||||
borderWidth: compact ? 2 : 2.5,
|
||||
tension: 0.28,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
borderWidth: 2,
|
||||
tension: 0.35,
|
||||
spanGaps: true,
|
||||
fill: false,
|
||||
})),
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'nearest', intersect: false },
|
||||
interaction: { mode: 'nearest', axis: 'x', intersect: false },
|
||||
elements: { line: { capBezierPoints: true }, point: { hitRadius: 8 } },
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
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: {
|
||||
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: {
|
||||
label(ctx) {
|
||||
const v = ctx.parsed.y;
|
||||
@@ -236,16 +252,19 @@ function buildChartConfig(data, compact) {
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: { color: textMuted, maxRotation: 0, autoSkip: true, maxTicksLimit: compact ? 6 : 10 },
|
||||
grid: { color: lineColor, display: !compact },
|
||||
ticks: { color: textMuted, maxRotation: 0, autoSkip: true, maxTicksLimit: compact ? 6 : 10, font: { size: 11 } },
|
||||
grid: { display: false },
|
||||
border: { color: axisColor },
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
color: textMuted,
|
||||
maxTicksLimit: compact ? 8 : 10,
|
||||
maxTicksLimit: compact ? 6 : 9,
|
||||
font: { size: 11 },
|
||||
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');
|
||||
let mainChart = null;
|
||||
if (ctx && leaderboardChartData.labels.length > 0) {
|
||||
const filtered = leaderboardChartData.datasets.filter(ds => (ds.sessions_played || 0) > 1);
|
||||
const chartData = {
|
||||
labels: leaderboardChartData.labels,
|
||||
datasets: filtered.length ? filtered : leaderboardChartData.datasets,
|
||||
datasets: leaderboardChartData.datasets.slice(0, DEFAULT_SERIES),
|
||||
};
|
||||
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');
|
||||
@@ -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 => {
|
||||
if (e.key === 'Escape' && modal.style.display === 'flex') {
|
||||
modal.style.display = 'none';
|
||||
|
||||
@@ -43,15 +43,27 @@
|
||||
<p class="kicker" style="margin:0;">Roster</p>
|
||||
<h2 class="panel__title">Active players</h2>
|
||||
</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>
|
||||
{% if active_players %}
|
||||
<div class="player-grid">
|
||||
<div class="player-grid" id="playerGrid">
|
||||
{% for player in active_players %}
|
||||
{% set stats = stats_by_name.get(player.display_name) %}
|
||||
{% 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 '' %}
|
||||
<div class="player-card">
|
||||
<div class="player-card" data-player-name="{{ player.display_name | lower }}">
|
||||
<div class="player-card__head">
|
||||
<div class="player-avatar {{ avatar_cls }}">{{ initials[:2] }}</div>
|
||||
<div class="player-card__name-block">
|
||||
@@ -87,7 +99,7 @@
|
||||
</div>
|
||||
<div class="player-card__stat">
|
||||
<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 class="player-card__stat">
|
||||
<span class="kicker">Best win</span>
|
||||
@@ -98,6 +110,7 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="player-grid__empty" id="playerSearchEmpty">No players match your search.</p>
|
||||
{% else %}
|
||||
<div class="empty-panel">
|
||||
<p class="kicker">Empty roster</p>
|
||||
@@ -147,6 +160,55 @@
|
||||
</div>
|
||||
{% 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 %}
|
||||
<div class="modal-backdrop" id="modal-edit-player" hidden>
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="modal-edit-player-title">
|
||||
|
||||
@@ -29,58 +29,80 @@
|
||||
</div>
|
||||
|
||||
<div class="cash-strip">
|
||||
<div class="stat stat--rail" style="--rail:var(--accent);">
|
||||
<span class="kicker">Invested</span>
|
||||
<div class="stat__val" style="color:var(--num)">{{ session.total_invested_cents | money }}</div>
|
||||
<div class="stat">
|
||||
<span class="kicker">Cash in</span>
|
||||
<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 class="stat stat--rail" style="--rail:var(--rank-2);">
|
||||
<span class="kicker">Cashout</span>
|
||||
{% if session.total_payout_carry_in_cents > 0 or session.total_rollover_in_cents > 0 %}
|
||||
<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>
|
||||
<div class="stat stat--rail" style="--rail:var(--pos);">
|
||||
<div class="stat">
|
||||
<span class="kicker">Paid out</span>
|
||||
<div class="stat__val" style="color:var(--pos)">{{ session.total_paid_out_cents | money }}</div>
|
||||
</div>
|
||||
<div class="stat stat--rail" style="--rail:var(--warn);">
|
||||
<span class="kicker">Open items</span>
|
||||
<div class="stat__val" style="color:var(--num)">{{ (session.total_current_due_to_player_cents + session.total_current_due_to_house_cents) | money }}</div>
|
||||
{% set total_unsettled = session.total_current_due_to_player_cents + session.total_current_due_to_house_cents %}
|
||||
<div class="stat">
|
||||
<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 class="league-manage-grid" style="margin-top:20px;align-items:stretch;">
|
||||
<div class="stack">
|
||||
|
||||
{% 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() }}">
|
||||
<div>
|
||||
<p class="eyebrow">Append event</p>
|
||||
<h2 class="panel__title" style="margin-top:4px;">Ledger entry</h2>
|
||||
<div class="ledger-form__head">
|
||||
<span class="eyebrow">Ledger entry</span>
|
||||
{% 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>
|
||||
{% if session_model.status != 'open' %}
|
||||
<div class="session-locked-banner">
|
||||
<strong>Session closed</strong>
|
||||
<span>Books assumed settled. Reopen to add events.</span>
|
||||
<span>Reopen to add events.</span>
|
||||
</div>
|
||||
{% 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>
|
||||
<span>Player</span>
|
||||
<div class="player-picker" id="playerPicker">
|
||||
<div class="player-picker__input-wrap">
|
||||
<input
|
||||
type="text"
|
||||
id="playerSearch"
|
||||
list="playerList"
|
||||
placeholder="Start typing a name…"
|
||||
class="player-picker__search"
|
||||
placeholder="Select a player…"
|
||||
autocomplete="off"
|
||||
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 %}
|
||||
<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 %}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="player_id" id="playerIdInput" value="{{ append_form.player_id or '' }}">
|
||||
</label>
|
||||
<div class="ledger-form__ta-row">
|
||||
<div class="ledger-form__inputs">
|
||||
<label>
|
||||
<span>Event type</span>
|
||||
<span>Type</span>
|
||||
<select name="event_type" id="eventTypeSelect" required>
|
||||
{% for value, label in event_types %}
|
||||
<option value="{{ value }}" {{ 'selected' if append_form.event_type == value else '' }}>{{ label }}</option>
|
||||
@@ -89,13 +111,41 @@
|
||||
</label>
|
||||
<label>
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
</form>
|
||||
{% else %}
|
||||
@@ -111,16 +161,31 @@
|
||||
<p class="kicker" style="margin:0;">Results</p>
|
||||
<h2 class="panel__title">Player totals</h2>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
{% if session.entries %}
|
||||
<div class="player-ledger-list">
|
||||
{% 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 {{ row_cls }}">
|
||||
<div class="player-ledger-row__header">
|
||||
<div class="player-ledger-row">
|
||||
<div class="player-ledger-row__who">
|
||||
<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>
|
||||
{% elif entry.current_due_to_player_cents > 0 %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="player-ledger-row__fields">
|
||||
<div class="player-ledger-row__field">
|
||||
<span class="player-ledger-row__lbl">Buy in</span>
|
||||
<div class="player-ledger-row__stats">
|
||||
<div class="player-ledger-row__stat">
|
||||
<span class="player-ledger-row__lbl">In</span>
|
||||
<span class="player-ledger-row__val">{{ entry.invested_cents | money }}</span>
|
||||
</div>
|
||||
<div class="player-ledger-row__field">
|
||||
<span class="player-ledger-row__lbl">Cashout claim</span>
|
||||
<div class="player-ledger-row__stat">
|
||||
<span class="player-ledger-row__lbl">Cashout</span>
|
||||
<span class="player-ledger-row__val">{{ entry.cash_out_cents | money }}</span>
|
||||
</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__val" style="color:var(--pos)">{{ entry.paid_out_cents | money if entry.paid_out_cents else '—' }}</span>
|
||||
</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__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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
{% 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__field">
|
||||
<div class="player-ledger-row__stat">
|
||||
<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>
|
||||
</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__val" style="color:var(--rolled)">{{ entry.payout_carry_in_cents | money }}</span>
|
||||
</div>
|
||||
<div class="player-ledger-row__field">
|
||||
<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>
|
||||
{% endif %}
|
||||
</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>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -171,6 +258,7 @@
|
||||
<p class="muted-text" style="padding:28px;">No player ledger events yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="panel" style="margin-top:20px;">
|
||||
@@ -315,37 +403,159 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const playerMap = {
|
||||
{% for player in players %}"{{ player.display_name | replace('"', '\\"') }}": "{{ player.id }}"{% if not loop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
};
|
||||
const searchInput = document.getElementById('playerSearch');
|
||||
const idInput = document.getElementById('playerIdInput');
|
||||
const eventTypeSelect = document.getElementById('eventTypeSelect');
|
||||
// --- Custom player picker ---
|
||||
(function () {
|
||||
const picker = document.getElementById('playerPicker');
|
||||
if (!picker) return;
|
||||
const searchInput = document.getElementById('playerSearch');
|
||||
const idInput = document.getElementById('playerIdInput');
|
||||
const dropdown = document.getElementById('playerDropdown');
|
||||
const opts = dropdown ? Array.from(dropdown.querySelectorAll('.player-picker__opt')) : [];
|
||||
const eventTypeSelect = document.getElementById('eventTypeSelect');
|
||||
|
||||
if (searchInput && idInput) {
|
||||
searchInput.addEventListener('input', function () {
|
||||
idInput.value = playerMap[this.value.trim()] || '';
|
||||
function openDropdown() {
|
||||
if (!dropdown) return;
|
||||
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()] || '';
|
||||
});
|
||||
}
|
||||
|
||||
if (eventTypeSelect && searchInput && idInput) {
|
||||
eventTypeSelect.addEventListener('change', function () {
|
||||
if (this.value === 'note') {
|
||||
}
|
||||
function selectPlayer(name, id) {
|
||||
searchInput.value = name;
|
||||
idInput.value = id;
|
||||
filterOpts(name);
|
||||
closeDropdown();
|
||||
}
|
||||
function clearPlayer() {
|
||||
searchInput.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.disabled = true;
|
||||
} else {
|
||||
searchInput.placeholder = 'Start typing a name…';
|
||||
searchInput.placeholder = 'Search or select a player…';
|
||||
searchInput.disabled = false;
|
||||
}
|
||||
});
|
||||
if (eventTypeSelect.value === '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) {
|
||||
btn.addEventListener('click', function() {
|
||||
|
||||
@@ -219,7 +219,7 @@ if (breakdownCtx) {
|
||||
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 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">
|
||||
<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>
|
||||
|
||||
@@ -121,16 +121,16 @@
|
||||
</div>
|
||||
<div class="session-row__actions">
|
||||
<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 %}
|
||||
<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' %}
|
||||
<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() }}">
|
||||
<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>
|
||||
{% 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-reopen-action="{{ url_for('leagues.open_league_session', league_ref=league.url_ref, session_id=session.id) }}">Reopen</button>
|
||||
{% endif %}
|
||||
|
||||
@@ -28,6 +28,20 @@
|
||||
<option value="public" {{ 'selected' if form.visibility == 'public' else '' }}>Public — leaderboard is visible to anyone</option>
|
||||
</select>
|
||||
</label>
|
||||
<div style="margin-top:8px;padding-top:20px;border-top:1px solid var(--border);">
|
||||
<p class="eyebrow">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);">
|
||||
<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>
|
||||
|
||||
+281
-88
@@ -1,100 +1,179 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Leagues · myboker.org{% endblock %}
|
||||
{% block page_class %}page--session page--leagues{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-header__title">Your leagues</h1>
|
||||
{% if leagues %}
|
||||
<p class="page-header__sub">
|
||||
{{ page_summary.league_count }} league{{ 's' if page_summary.league_count != 1 else '' }}
|
||||
{% if page_summary.live_count > 0 %} · <span style="color:var(--pos);">{{ page_summary.live_count }} live</span>{% endif %}
|
||||
· {{ page_summary.sessions_count }} session{{ 's' if page_summary.sessions_count != 1 else '' }}
|
||||
</p>
|
||||
{% endif %}
|
||||
<div class="li-page-header">
|
||||
<div class="li-page-header__left">
|
||||
<p class="eyebrow" style="margin:0 0 6px;">Your account</p>
|
||||
<h1 class="li-page-header__title">Your leagues</h1>
|
||||
</div>
|
||||
<div class="li-page-header__stats">
|
||||
<div class="li-header-stat">
|
||||
<span class="li-header-stat__label">Leagues</span>
|
||||
<strong class="li-header-stat__val">{{ page_summary.league_count }}</strong>
|
||||
</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 class="page-header__actions">
|
||||
<a class="btn btn--primary btn--sm" href="{{ url_for('leagues.new') }}">New league</a>
|
||||
</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 %}
|
||||
{% set league = item.league %}
|
||||
{% 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">
|
||||
<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__top">
|
||||
<div class="li-card__name-row">
|
||||
<a class="li-card__name" href="{{ url_for('leagues.dashboard', league_ref=league.url_ref) }}">{{ league.name }}</a>
|
||||
{% if item.live_session %}
|
||||
<span class="live-badge">{{ session_label(item.live_session) }}</span>
|
||||
<span class="li-pill li-pill--live">Live</span>
|
||||
{% else %}
|
||||
<span class="status-pill status-pill--closed">idle</span>
|
||||
<span class="li-pill li-pill--idle">Idle</span>
|
||||
{% endif %}
|
||||
{% if item.latest_session %}<span class="li-card__season">{{ session_label(item.latest_session) }}</span>{% endif %}
|
||||
</div>
|
||||
<div class="li-card__chips">
|
||||
<span class="db-chip db-chip--accent">{{ membership.role }}</span>
|
||||
<span class="db-chip {% if league.visibility == 'public' %}db-chip--public{% endif %}">
|
||||
{% if league.visibility == 'public' %}
|
||||
<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 style="display:flex;align-items:center;gap:8px;flex-shrink:0;">
|
||||
<span class="li-badge">{{ membership.role }}</span>
|
||||
<span class="li-badge">{{ league.visibility }}</span>
|
||||
<a class="li-open-btn" href="{{ url_for('leagues.dashboard', league_ref=league.url_ref) }}">Open</a>
|
||||
</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 %}
|
||||
<div class="li-card__leaders">
|
||||
<span class="li-card__leaders-label">Leaders</span>
|
||||
<div class="li-podium">
|
||||
<div class="li-card__leaders-col">
|
||||
<span class="li-stat-label">Leaders</span>
|
||||
<div class="li-leaders-list">
|
||||
{% for player in item.top_players %}
|
||||
<div class="li-podium-item li-podium-item--{{ loop.index }}">
|
||||
<span class="li-podium-rank">#{{ loop.index }}</span>
|
||||
<span class="li-podium-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' }}">
|
||||
<div class="li-leader-row">
|
||||
<span class="li-leader-rank">#{{ loop.index }}</span>
|
||||
<span class="li-leader-name">{{ player.player_name }}</span>
|
||||
<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 }}
|
||||
</strong>
|
||||
</div>
|
||||
@@ -102,32 +181,146 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="li-card__footer">
|
||||
<span class="li-card__meta">
|
||||
{% if item.latest_session %}Latest {{ session_label(item.latest_session) }}{% elif not item.top_players %}No sessions yet{% endif %}
|
||||
<span class="li-card__footer-meta">
|
||||
{% 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>
|
||||
<div class="li-card__actions">
|
||||
<a class="btn btn--primary btn--sm" href="{{ url_for('leagues.dashboard', league_ref=league.url_ref) }}">Open league</a>
|
||||
<a class="li-action-link" href="{{ url_for('leagues.sessions', league_ref=league.url_ref) }}">Sessions</a>
|
||||
<a class="li-action-link" href="{{ url_for('leagues.leaderboard', league_ref=league.url_ref) }}">Leaderboard</a>
|
||||
<div class="li-card__footer-links">
|
||||
<a class="li-footer-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>
|
||||
{% 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 %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</article>
|
||||
{% 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>
|
||||
|
||||
{% else %}
|
||||
<div class="panel empty-panel">
|
||||
<h2 class="panel__title">No leagues yet</h2>
|
||||
<p class="muted-text">Create your first league to start adding players, sessions, and ledger events.</p>
|
||||
<a class="btn btn--primary" href="{{ url_for('leagues.new') }}">Create league</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<script>
|
||||
(function () {
|
||||
// --- Visibility toggle ---
|
||||
var visInput = document.getElementById('lf-visibility');
|
||||
var visHint = document.getElementById('visHint');
|
||||
document.querySelectorAll('.li-vis-btn').forEach(function (btn) {
|
||||
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 %}
|
||||
@@ -5,6 +5,42 @@ from boker.services import build_leaderboard
|
||||
|
||||
|
||||
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):
|
||||
entry = SessionEntry(
|
||||
session_id="2026-06-21-01",
|
||||
|
||||
Reference in new issue
Block a user