diff --git a/.env.example b/.env.example
index d072de1..cf706a3 100644
--- a/.env.example
+++ b/.env.example
@@ -1,3 +1,9 @@
-SECRET_KEY=replace-this-with-a-long-random-string
-ADMIN_USERNAME=admin
-ADMIN_PASSWORD=replace-this-password
+SECRET_KEY=local-dev-secret-change-me
+APP_ENV=development
+APP_BASE_URL=http://localhost:5000
+MAIL_SERVER=
+MAIL_PORT=587
+MAIL_USE_TLS=true
+MAIL_USERNAME=
+MAIL_PASSWORD=
+MAIL_DEFAULT_SENDER=noreply@myboker.org
diff --git a/.env.production.example b/.env.production.example
new file mode 100644
index 0000000..f68f5c5
--- /dev/null
+++ b/.env.production.example
@@ -0,0 +1,11 @@
+SECRET_KEY=replace-this-with-a-long-random-string
+APP_ENV=production
+DATABASE_URL=postgresql+psycopg://user:password@host/dbname
+APP_BASE_URL=https://myboker.org
+RATELIMIT_STORAGE_URI=redis://localhost:6379/0
+MAIL_SERVER=smtp.sendgrid.net
+MAIL_PORT=587
+MAIL_USE_TLS=true
+MAIL_USERNAME=apikey
+MAIL_PASSWORD=your-sendgrid-api-key
+MAIL_DEFAULT_SENDER=noreply@myboker.org
diff --git a/README.md b/README.md
index 08b2b71..bf32e32 100644
--- a/README.md
+++ b/README.md
@@ -1,38 +1,39 @@
-# Poker Portal
+# myboker.org
-A small Flask app I put together for tracking our low-stakes hold'em nights without having to keep a spreadsheet open all the time.
+Flask app for running home-poker leagues without keeping a spreadsheet open all night.
-The idea is pretty simple: public pages for stats and session history, plus a small admin area for recording buy-ins, gross cashout results, actual paid-out cash, and notes. The data sits in a CSV audit log, so everything is append-only and easy to follow later.
+The current app is account-based and league-based. League owners/managers record sessions and ledger events, viewers can inspect public-facing league data, and site-admin access is reserved for internal tooling.
-## What it does
+## What It Does
-- all-time leaderboard
-- per-session pages
-- per-player stat pages
-- admin login for recording events
-- open / closed session tracking
-- actual cash payout tracking with `paid_out` events
-- debt repayments and write-offs with `debt_repayment` / `writeoff` events
-- session and player charts
-- CSV import / export from the admin page
-- append-only `entries.csv` ledger instead of overwriting old rows
+- account signup, login, and email verification
+- league creation, invitations, ownership transfer, and member roles
+- player, season, session, and ledger management per league
+- append-only ledger events with void/correction support
+- public/private league visibility
+- leaderboards, charts, dashboard stats, and session summaries
+- CSV import/export for league ledgers
## Stack
-Built with a pretty lightweight setup:
-
- Python
- Flask
+- SQLAlchemy / Flask-Migrate
- Jinja templates
- Chart.js
- plain CSS
-- CSV event log for storage
-## Ledger model
+## Project Layout
-The app treats `data/entries.csv` as the source of truth.
+- `app.py` is the deployment entrypoint for `flask --app app` and Gunicorn.
+- `boker/` contains the Flask application package, routes, models, services, repositories, and config.
+- `templates/` and `static/` contain Jinja views and public assets.
+- `migrations/` contains Alembic migrations for database deploys.
+- `tests/` contains the committed regression suite.
-Each row is an event, not a final snapshot. Instead of editing an old row, I append another one. That keeps rebuys, corrections, payouts, and session state changes visible in the log instead of hiding them behind edits.
+## Ledger Model
+
+Each ledger row is an event, not a final snapshot. Corrections are made by appending or voiding events, so the history remains auditable.
Current event types:
@@ -49,17 +50,7 @@ Current event types:
- `session_open`
- `session_close`
-A few examples:
-
-- another `buyin` for a rebuy
-- another `cashout` if chip counts are corrected later
-- a `paid_out` event when someone is actually settled up
-- a `debt_repayment` event when a front is repaid outside poker
-- a `writeoff` event when a front will not be collected
-- a `note` event for bookkeeping context
-- `session_open` / `session_close` to mark whether a game night is still live
-
-Accounting in the app keeps poker results separate from banker cashflow:
+Accounting keeps poker results separate from banker cashflow:
- poker investment is `buyin + front + rollover_in`
- poker net is `cashout - poker investment`
@@ -67,30 +58,12 @@ Accounting in the app keeps poker results separate from banker cashflow:
- real cash out is `paid_out`
- `rollover_out` settles the source session without counting as cash out
- `rollover_in` enters play in the destination session without counting as cash in
-- `payout_carry_in` records prior-session value carried into a later payout; it increases the destination payout due without counting as poker investment or cash in
+- `payout_carry_in` records prior-session value carried into a later payout without counting as poker investment or cash in
- `writeoff` resolves a receivable without counting as cash in
-Older ledgers may still contain `paid`, `front_collected`, or `front_writeoff`.
-The app reads those historical names as aliases for `paid_out`,
-`debt_repayment`, and `writeoff`.
+Older imports may still contain `paid`, `front_collected`, or `front_writeoff`; those are read as aliases for `paid_out`, `debt_repayment`, and `writeoff`.
-It is still just a small side project, but I wanted the event model to stay clean enough that the numbers are easy to trust and the history is easy to read back through.
-
-## CSV format
-
-Main file:
-
-`data/entries.csv`
-
-Header:
-
-```csv
-id,created_at,session_id,session_date,player_name,event_type,amount_cents,note,actor
-```
-
-Amounts are stored in cents to avoid floating-point issues.
-
-## Running it locally
+## Running Locally
```bash
python -m venv .venv
@@ -102,31 +75,49 @@ python app.py
Then open:
-`http://127.0.0.1:8000`
+`http://127.0.0.1:5000`
-## Environment values
+## Environment Values
-The app reads these from `.env`:
+The app reads these from `.env`. Use `.env.example` for local development and `.env.production.example` as the production checklist.
- `SECRET_KEY`
-- `ADMIN_USERNAME`
-- `ADMIN_PASSWORD`
+- `APP_ENV`
+- `DATABASE_URL`
+- `APP_BASE_URL`
+- `RATELIMIT_STORAGE_URI`
+- `MAIL_SERVER`
+- `MAIL_PORT`
+- `MAIL_USE_TLS`
+- `MAIL_USE_SSL`
+- `MAIL_USERNAME`
+- `MAIL_PASSWORD`
+- `MAIL_DEFAULT_SENDER`
-Example:
+For local development, omit `DATABASE_URL` to use the default SQLite database at `data/boker-dev.sqlite3`.
-```env
-SECRET_KEY=change-this
-ADMIN_USERNAME=admin
-ADMIN_PASSWORD=change-me
+For public deployments, set `APP_ENV=production`. Production mode enables secure cookies and refuses to start with the development `SECRET_KEY` or any SQLite database. Use PostgreSQL for `DATABASE_URL`, install dependencies from `requirements.txt`, then run:
+
+```bash
+flask --app app db upgrade
```
-## Notes
+Set `RATELIMIT_STORAGE_URI` to a shared backend such as Redis so login and signup limits are enforced across processes.
-A few choices here were deliberate:
+## Site Admin Access
-- no database for now
-- no user accounts, just one admin login
-- public-facing stats pages, admin-only controls
-- CSV backup before importing a replacement ledger
+Internal admin access uses normal database-backed accounts, not hardcoded `.env` credentials.
-If I ever decide to take it further, the first real upgrade would probably be moving the storage layer to SQLite while keeping the rest of the app roughly the same.
+Grant access:
+
+```bash
+flask --app app grant-site-admin user@example.com
+```
+
+Revoke access:
+
+```bash
+flask --app app revoke-site-admin user@example.com
+```
+
+The old single-password `/admin` system has been removed.
diff --git a/app.py b/app.py
index be8c595..ab569a9 100644
--- a/app.py
+++ b/app.py
@@ -1,39 +1,10 @@
#!/usr/bin/env python3
from __future__ import annotations
-from flask import Flask
+from boker.app import app, create_app
-from auth import is_admin
-from config import Config
-from routes.admin import admin_bp
-from routes.public import public_bp
-from storage import ensure_data_file
-from utils import cents_to_dollars, safe_date_label
+__all__ = ["app", "create_app"]
-def create_app() -> Flask:
- app = Flask(__name__)
- app.config.from_object(Config)
-
- ensure_data_file(app.config["DATA_PATH"])
-
- app.jinja_env.filters["money"] = cents_to_dollars
- app.jinja_env.filters["pretty_date"] = safe_date_label
-
- @app.context_processor
- def inject_globals() -> dict:
- return {
- "app_version": app.config["APP_VERSION"],
- "is_admin": is_admin(),
- }
-
- app.register_blueprint(public_bp)
- app.register_blueprint(admin_bp)
-
- return app
-
-
-app = create_app()
-
if __name__ == "__main__":
app.run(debug=True)
diff --git a/auth.py b/auth.py
deleted file mode 100644
index 19da07b..0000000
--- a/auth.py
+++ /dev/null
@@ -1,13 +0,0 @@
-#!/usr/bin/env python3
-"""Authentication helpers.
-
-Kept in its own module so both app.py (context processor) and routes/admin.py
-(route guards) can import is_admin() without circular imports.
-"""
-from __future__ import annotations
-
-from flask import session as flask_session
-
-
-def is_admin() -> bool:
- return bool(flask_session.get("is_admin"))
diff --git a/boker/__init__.py b/boker/__init__.py
new file mode 100644
index 0000000..afd4634
--- /dev/null
+++ b/boker/__init__.py
@@ -0,0 +1 @@
+"""Application package for myboker.org."""
diff --git a/boker/app.py b/boker/app.py
new file mode 100644
index 0000000..bd8fdce
--- /dev/null
+++ b/boker/app.py
@@ -0,0 +1,143 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import click
+import os
+from uuid import uuid4
+
+from flask import Flask, render_template, request
+from werkzeug.exceptions import HTTPException
+
+from boker.auth import current_user_id, is_logged_in, is_site_admin, normalize_email
+from boker.config import DEFAULT_DATABASE_URL, DEFAULT_SECRET_KEY, Config, ProductionConfig
+from boker.db import database_extensions_available, db, init_database
+from boker.extensions import csrf, limiter, mail
+from boker.routes.account import account_bp
+from boker.routes.internal import internal_bp
+from boker.routes.leagues import leagues_bp
+from boker.routes.public import public_bp
+from boker.storage import ensure_data_file
+from boker.utils import cents_to_dollars, safe_date_label
+
+
+def _config_for_environment():
+ app_env = os.getenv("APP_ENV", os.getenv("FLASK_ENV", "")).lower()
+ if app_env in {"prod", "production"}:
+ return ProductionConfig
+ return Config
+
+
+def create_app(config_overrides: dict | None = None) -> Flask:
+ app = Flask(__name__, template_folder="../templates", static_folder="../static")
+ app.config.from_object(_config_for_environment())
+ if config_overrides:
+ app.config.update(config_overrides)
+
+ if app.config["SESSION_COOKIE_SECURE"] and app.config["SECRET_KEY"] == DEFAULT_SECRET_KEY:
+ raise RuntimeError("Set SECRET_KEY before running in production.")
+ if app.config["SESSION_COOKIE_SECURE"]:
+ database_url = app.config["SQLALCHEMY_DATABASE_URI"]
+ if database_url == DEFAULT_DATABASE_URL or database_url.startswith("sqlite:"):
+ raise RuntimeError("Set DATABASE_URL to a production PostgreSQL database before running in production.")
+
+ ensure_data_file(app.config["DATA_PATH"])
+ init_database(app)
+ csrf.init_app(app)
+ limiter.init_app(app)
+ mail.init_app(app)
+
+ app.jinja_env.filters["money"] = cents_to_dollars
+ app.jinja_env.filters["pretty_date"] = safe_date_label
+
+ @app.context_processor
+ def inject_globals() -> dict:
+ return {
+ "app_version": app.config["APP_VERSION"],
+ "current_user_id": current_user_id(),
+ "is_logged_in": is_logged_in(),
+ "current_user_is_site_admin": is_site_admin(),
+ }
+
+ app.register_blueprint(public_bp)
+ app.register_blueprint(account_bp)
+ app.register_blueprint(internal_bp)
+ app.register_blueprint(leagues_bp)
+
+ @app.errorhandler(404)
+ def not_found(e):
+ return render_template("404.html"), 404
+
+ @app.errorhandler(403)
+ def forbidden(e):
+ return render_template("403.html"), 403
+
+ @app.errorhandler(Exception)
+ def unexpected_error(error):
+ if isinstance(error, HTTPException):
+ return error
+
+ crash_id = uuid4().hex[:12]
+ app.logger.exception(
+ "Unhandled exception [%s] during %s %s",
+ crash_id,
+ request.method,
+ request.path,
+ )
+ return render_template("500.html", crash_id=crash_id), 500
+
+ @app.cli.command("init-db")
+ def init_db_command() -> None:
+ if not database_extensions_available() or db is None:
+ raise click.ClickException(
+ "Database dependencies are not installed. Run pip install -r requirements.txt."
+ )
+
+ with app.app_context():
+ db.create_all()
+
+ click.echo("Initialized database tables.")
+
+ @app.cli.command("grant-site-admin")
+ @click.argument("email")
+ def grant_site_admin_command(email: str) -> None:
+ if not database_extensions_available() or db is None:
+ raise click.ClickException(
+ "Database dependencies are not installed. Run pip install -r requirements.txt."
+ )
+
+ from boker.db_models import User
+
+ normalized_email = normalize_email(email)
+ with app.app_context():
+ user = User.query.filter_by(email=normalized_email, disabled_at=None).one_or_none()
+ if user is None:
+ raise click.ClickException(f"No active user found for {normalized_email}.")
+ user.is_site_admin = True
+ db.session.commit()
+
+ click.echo(f"Granted site admin access to {normalized_email}.")
+
+ @app.cli.command("revoke-site-admin")
+ @click.argument("email")
+ def revoke_site_admin_command(email: str) -> None:
+ if not database_extensions_available() or db is None:
+ raise click.ClickException(
+ "Database dependencies are not installed. Run pip install -r requirements.txt."
+ )
+
+ from boker.db_models import User
+
+ normalized_email = normalize_email(email)
+ with app.app_context():
+ user = User.query.filter_by(email=normalized_email).one_or_none()
+ if user is None:
+ raise click.ClickException(f"No user found for {normalized_email}.")
+ user.is_site_admin = False
+ db.session.commit()
+
+ click.echo(f"Revoked site admin access from {normalized_email}.")
+
+ return app
+
+
+app = create_app()
diff --git a/boker/auth.py b/boker/auth.py
new file mode 100644
index 0000000..b5ad923
--- /dev/null
+++ b/boker/auth.py
@@ -0,0 +1,107 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+from functools import wraps
+
+from flask import abort, current_app, flash, redirect, request, session as flask_session, url_for
+from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
+from werkzeug.security import check_password_hash, generate_password_hash
+
+
+def _serializer() -> URLSafeTimedSerializer:
+ return URLSafeTimedSerializer(current_app.config["SECRET_KEY"])
+
+
+def generate_reset_token(user_id: str) -> str:
+ return _serializer().dumps(user_id, salt="password-reset")
+
+
+def verify_reset_token(token: str, max_age: int = 3600) -> str | None:
+ try:
+ return _serializer().loads(token, salt="password-reset", max_age=max_age)
+ except (SignatureExpired, BadSignature):
+ return None
+
+
+def generate_invite_token(league_id: str, email: str, role: str, invited_by_user_id: str) -> str:
+ return _serializer().dumps(
+ {"league_id": league_id, "email": email, "role": role, "invited_by": invited_by_user_id},
+ salt="league-invite",
+ )
+
+
+def verify_invite_token(token: str, max_age: int = 604800) -> dict | None:
+ try:
+ data = _serializer().loads(token, salt="league-invite", max_age=max_age)
+ return data if isinstance(data, dict) else None
+ except (SignatureExpired, BadSignature):
+ return None
+
+
+def hash_password(password: str) -> str:
+ return generate_password_hash(password)
+
+
+def verify_password(password_hash: str, password: str) -> bool:
+ return check_password_hash(password_hash, password)
+
+
+def normalize_email(email: str) -> str:
+ return email.strip().casefold()
+
+
+def log_user_in(user_id: str) -> None:
+ flask_session["user_id"] = user_id
+
+
+def log_user_out() -> None:
+ flask_session.pop("user_id", None)
+
+
+def current_user_id() -> str | None:
+ user_id = flask_session.get("user_id")
+ return str(user_id) if user_id else None
+
+
+def is_logged_in() -> bool:
+ return current_user_id() is not None
+
+
+def is_site_admin() -> bool:
+ user_id = current_user_id()
+ if not user_id:
+ return False
+
+ from boker.db import database_extensions_available
+
+ if not database_extensions_available():
+ return False
+
+ from boker.db_models import User
+
+ user = User.query.filter_by(id=user_id, disabled_at=None).one_or_none()
+ return bool(user and user.is_site_admin)
+
+
+def login_required(view):
+ @wraps(view)
+ def wrapped_view(*args, **kwargs):
+ if not is_logged_in():
+ flash("Login required.", "error")
+ return redirect(url_for("account.login", next=request.full_path))
+ return view(*args, **kwargs)
+
+ return wrapped_view
+
+
+def site_admin_required(view):
+ @wraps(view)
+ def wrapped_view(*args, **kwargs):
+ if not is_logged_in():
+ flash("Login required.", "error")
+ return redirect(url_for("account.login", next=request.full_path))
+ if not is_site_admin():
+ abort(403)
+ return view(*args, **kwargs)
+
+ return wrapped_view
diff --git a/charts.py b/boker/charts.py
similarity index 85%
rename from charts.py
rename to boker/charts.py
index 0a8d074..b21fdd0 100644
--- a/charts.py
+++ b/boker/charts.py
@@ -4,8 +4,8 @@ from __future__ import annotations
from typing import Any
-from models import SessionEntry, SessionSummary
-from utils import net_result_bucket, session_chart_label, session_sort_key
+from boker.models import SessionEntry, SessionSummary
+from boker.utils import net_result_bucket, session_chart_label, session_sort_key
PLAYER_PALETTE = [
"#9b8cf0", # --line-1 violet
@@ -31,8 +31,8 @@ def color_for_name(name: str, names: list[str]) -> str:
return PLAYER_PALETTE[index % len(PLAYER_PALETTE)]
-def net_tone(value_cents: int) -> str:
- bucket = net_result_bucket(value_cents)
+def net_tone(value_cents: int, break_even_cents: int = 100) -> str:
+ bucket = net_result_bucket(value_cents, break_even_cents)
if bucket == "win":
return "#6fc093" # --pos
@@ -96,7 +96,7 @@ def cumulative_profit_series(sessions: list[SessionSummary]) -> dict[str, object
def player_session_series(
- sessions: list[SessionSummary], player_name: str
+ sessions: list[SessionSummary], player_name: str, break_even_cents: int = 100
) -> dict[str, Any]:
ordered_sessions = sorted(sessions, key=session_sort_key)
labels: list[str] = []
@@ -130,12 +130,12 @@ def player_session_series(
"labels": labels,
"color": color_for_name(player_name, all_player_names),
"net_values": net_values,
- "net_colors": [net_tone(round(value * 100)) for value in net_values],
+ "net_colors": [net_tone(round(value * 100), break_even_cents) for value in net_values],
"cumulative_values": cumulative_values,
}
-def session_breakdown_series(session: SessionSummary) -> dict[str, Any]:
+def session_breakdown_series(session: SessionSummary, break_even_cents: int = 100) -> dict[str, Any]:
ordered_entries = sorted(
session.entries,
key=lambda entry: (entry.net_cents, entry.player_name.casefold()),
@@ -145,5 +145,5 @@ def session_breakdown_series(session: SessionSummary) -> dict[str, Any]:
return {
"labels": [entry.player_name for entry in ordered_entries],
"net_values": [round(entry.net_cents / 100, 2) for entry in ordered_entries],
- "net_colors": [net_tone(entry.net_cents) for entry in ordered_entries],
+ "net_colors": [net_tone(entry.net_cents, break_even_cents) for entry in ordered_entries],
}
diff --git a/boker/config.py b/boker/config.py
new file mode 100644
index 0000000..d90062a
--- /dev/null
+++ b/boker/config.py
@@ -0,0 +1,62 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+BASE_DIR = Path(__file__).resolve().parent.parent
+DATA_PATH = BASE_DIR / "data" / "entries.csv"
+DEFAULT_DATABASE_URL = f"sqlite:///{BASE_DIR / 'data' / 'boker-dev.sqlite3'}"
+DEFAULT_SECRET_KEY = "change-this-before-deploying"
+
+ELIGIBLE_MIN_SESSIONS = 3
+APP_VERSION = "2.5.29"
+
+
+def load_local_env(env_path: Path) -> None:
+ if not env_path.exists():
+ return
+
+ for raw_line in env_path.read_text(encoding="utf-8").splitlines():
+ line = raw_line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+
+ key, value = line.split("=", 1)
+ key = key.strip()
+ value = value.strip().strip('"').strip("'")
+ os.environ.setdefault(key, value)
+
+
+load_local_env(BASE_DIR / ".env")
+
+
+class Config:
+ APP_VERSION: str = APP_VERSION
+ SECRET_KEY: str = os.getenv("SECRET_KEY", DEFAULT_SECRET_KEY)
+ SQLALCHEMY_DATABASE_URI: str = os.getenv("DATABASE_URL", DEFAULT_DATABASE_URL)
+ SQLALCHEMY_TRACK_MODIFICATIONS: bool = False
+ SESSION_COOKIE_HTTPONLY: bool = True
+ SESSION_COOKIE_SAMESITE: str = "Lax"
+ SESSION_COOKIE_NAME: str = "myboker_org_session"
+ DATA_PATH: Path = DATA_PATH
+ WTF_CSRF_TIME_LIMIT: int = 3600
+ APP_BASE_URL: str = os.getenv("APP_BASE_URL", "http://localhost:5000")
+ MAIL_SERVER: str = os.getenv("MAIL_SERVER", "")
+ MAIL_PORT: int = int(os.getenv("MAIL_PORT", "587"))
+ MAIL_USE_TLS: bool = os.getenv("MAIL_USE_TLS", "true").lower() in ("true", "1", "yes")
+ MAIL_USE_SSL: bool = os.getenv(
+ "MAIL_USE_SSL",
+ "true" if MAIL_PORT == 465 else "false",
+ ).lower() in ("true", "1", "yes")
+ MAIL_USERNAME: str | None = os.getenv("MAIL_USERNAME") or None
+ MAIL_PASSWORD: str | None = os.getenv("MAIL_PASSWORD") or None
+ MAIL_DEFAULT_SENDER: str = os.getenv("MAIL_DEFAULT_SENDER", "noreply@myboker.org")
+ MAIL_TIMEOUT: float = float(os.getenv("MAIL_TIMEOUT", "5"))
+ RATELIMIT_STORAGE_URI: str | None = os.getenv("RATELIMIT_STORAGE_URI") or None
+
+
+class ProductionConfig(Config):
+ SESSION_COOKIE_SECURE: bool = True
+ SESSION_COOKIE_SAMESITE: str = "Strict"
+ PREFERRED_URL_SCHEME: str = "https"
diff --git a/boker/db.py b/boker/db.py
new file mode 100644
index 0000000..018f6a0
--- /dev/null
+++ b/boker/db.py
@@ -0,0 +1,32 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+from typing import Any
+
+try:
+ from flask_migrate import Migrate
+ from flask_sqlalchemy import SQLAlchemy
+except ModuleNotFoundError: # pragma: no cover - exercised only before deps install.
+ Migrate = None
+ SQLAlchemy = None
+
+
+db = SQLAlchemy() if SQLAlchemy is not None else None
+migrate = Migrate() if Migrate is not None else None
+
+
+def database_extensions_available() -> bool:
+ return db is not None and migrate is not None
+
+
+def init_database(app: Any) -> bool:
+ if not database_extensions_available():
+ app.config["DATABASE_EXTENSIONS_AVAILABLE"] = False
+ return False
+
+ import boker.db_models # noqa: F401 - registers SQLAlchemy models with metadata.
+
+ db.init_app(app)
+ migrate.init_app(app, db)
+ app.config["DATABASE_EXTENSIONS_AVAILABLE"] = True
+ return True
diff --git a/boker/db_models.py b/boker/db_models.py
new file mode 100644
index 0000000..a0da8d9
--- /dev/null
+++ b/boker/db_models.py
@@ -0,0 +1,323 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import uuid
+from datetime import date, datetime, timezone
+
+from boker.db import db
+
+if db is None: # pragma: no cover - imported only when DB dependencies exist.
+ raise RuntimeError("Database dependencies are not installed.")
+
+
+LEAGUE_ROLES = ("owner", "manager", "viewer")
+PLAYER_STATUSES = ("active", "archived")
+LEAGUE_VISIBILITIES = ("private", "public")
+SESSION_STATUSES = ("open", "closed")
+
+CANONICAL_EVENT_TYPES = {
+ "buyin",
+ "front",
+ "rollover_in",
+ "payout_carry_in",
+ "cashout",
+ "paid_out",
+ "rollover_out",
+ "debt_repayment",
+ "writeoff",
+ "note",
+ "session_open",
+ "session_close",
+}
+
+EVENT_TYPE_ALIASES = {
+ "paid": "paid_out",
+ "front_collected": "debt_repayment",
+ "front_writeoff": "writeoff",
+}
+
+SUPPORTED_EVENT_TYPES = CANONICAL_EVENT_TYPES | set(EVENT_TYPE_ALIASES)
+
+
+def utc_now() -> datetime:
+ return datetime.now(timezone.utc)
+
+
+def uuid_str() -> str:
+ return str(uuid.uuid4())
+
+
+def normalize_lookup(value: str) -> str:
+ return " ".join(value.strip().casefold().split())
+
+
+def canonical_event_type(event_type: str) -> str:
+ normalized = event_type.strip().lower()
+ return EVENT_TYPE_ALIASES.get(normalized, normalized)
+
+
+class TimestampMixin:
+ created_at = db.Column(db.DateTime(timezone=True), nullable=False, default=utc_now)
+ updated_at = db.Column(
+ db.DateTime(timezone=True),
+ nullable=False,
+ default=utc_now,
+ onupdate=utc_now,
+ )
+
+
+class User(TimestampMixin, db.Model):
+ __tablename__ = "users"
+
+ id = db.Column(db.String(36), primary_key=True, default=uuid_str)
+ email = db.Column(db.String(255), nullable=False, unique=True, index=True)
+ password_hash = db.Column(db.String(255), nullable=False)
+ email_verified_at = db.Column(db.DateTime(timezone=True), nullable=True)
+ email_verification_code_hash = db.Column(db.String(255), nullable=True)
+ email_verification_sent_at = db.Column(db.DateTime(timezone=True), nullable=True)
+ last_login_at = db.Column(db.DateTime(timezone=True), nullable=True)
+ disabled_at = db.Column(db.DateTime(timezone=True), nullable=True)
+ is_site_admin = db.Column(db.Boolean, nullable=False, default=False)
+
+
+class League(TimestampMixin, db.Model):
+ __tablename__ = "leagues"
+
+ id = db.Column(db.String(36), primary_key=True, default=uuid_str)
+ name = db.Column(db.String(160), nullable=False)
+ slug = db.Column(db.String(120), nullable=False, index=True)
+ public_key = db.Column(db.String(12), nullable=False, unique=True, index=True)
+ description = db.Column(db.Text, nullable=True)
+ visibility = db.Column(db.String(20), nullable=False, default="private")
+ created_by_user_id = db.Column(
+ db.String(36),
+ db.ForeignKey("users.id", ondelete="RESTRICT"),
+ nullable=False,
+ index=True,
+ )
+ archived_at = db.Column(db.DateTime(timezone=True), nullable=True)
+ eligible_min_sessions = db.Column(db.Integer, nullable=False, default=3)
+ break_even_cents = db.Column(db.Integer, nullable=False, default=100)
+
+ __table_args__ = (
+ db.CheckConstraint(
+ f"visibility IN {LEAGUE_VISIBILITIES}",
+ name="ck_leagues_visibility",
+ ),
+ )
+
+ @property
+ def url_ref(self) -> str:
+ return f"{self.slug}-{self.public_key}"
+
+
+class LeagueMembership(db.Model):
+ __tablename__ = "league_memberships"
+
+ id = db.Column(db.String(36), primary_key=True, default=uuid_str)
+ league_id = db.Column(
+ db.String(36),
+ db.ForeignKey("leagues.id", ondelete="CASCADE"),
+ nullable=False,
+ index=True,
+ )
+ user_id = db.Column(
+ db.String(36),
+ db.ForeignKey("users.id", ondelete="CASCADE"),
+ nullable=False,
+ index=True,
+ )
+ role = db.Column(db.String(20), nullable=False)
+ invited_by_user_id = db.Column(
+ db.String(36),
+ db.ForeignKey("users.id", ondelete="SET NULL"),
+ nullable=True,
+ )
+ created_at = db.Column(db.DateTime(timezone=True), nullable=False, default=utc_now)
+ disabled_at = db.Column(db.DateTime(timezone=True), nullable=True)
+
+ __table_args__ = (
+ db.UniqueConstraint("league_id", "user_id", name="uq_memberships_league_user"),
+ db.CheckConstraint(f"role IN {LEAGUE_ROLES}", name="ck_memberships_role"),
+ )
+
+
+class Player(TimestampMixin, db.Model):
+ __tablename__ = "players"
+
+ id = db.Column(db.String(36), primary_key=True, default=uuid_str)
+ league_id = db.Column(
+ db.String(36),
+ db.ForeignKey("leagues.id", ondelete="CASCADE"),
+ nullable=False,
+ index=True,
+ )
+ display_name = db.Column(db.String(160), nullable=False)
+ normalized_name = db.Column(db.String(160), nullable=False)
+ slug = db.Column(db.String(120), nullable=False)
+ status = db.Column(db.String(20), nullable=False, default="active")
+ linked_user_id = db.Column(
+ db.String(36),
+ db.ForeignKey("users.id", ondelete="SET NULL"),
+ nullable=True,
+ )
+ notes = db.Column(db.Text, nullable=True)
+
+ __table_args__ = (
+ db.UniqueConstraint("league_id", "normalized_name", name="uq_players_name"),
+ db.UniqueConstraint("league_id", "slug", name="uq_players_slug"),
+ db.UniqueConstraint("league_id", "id", name="uq_players_league_id"),
+ db.CheckConstraint(f"status IN {PLAYER_STATUSES}", name="ck_players_status"),
+ )
+
+
+class Season(TimestampMixin, db.Model):
+ __tablename__ = "seasons"
+
+ id = db.Column(db.String(36), primary_key=True, default=uuid_str)
+ league_id = db.Column(
+ db.String(36),
+ db.ForeignKey("leagues.id", ondelete="CASCADE"),
+ nullable=False,
+ index=True,
+ )
+ name = db.Column(db.String(160), nullable=False)
+ start_date = db.Column(db.Date, nullable=True)
+ end_date = db.Column(db.Date, nullable=True)
+ sort_order = db.Column(db.Integer, nullable=False, default=0)
+ archived_at = db.Column(db.DateTime(timezone=True), nullable=True)
+
+ __table_args__ = (
+ db.UniqueConstraint("league_id", "name", name="uq_seasons_name"),
+ db.UniqueConstraint("league_id", "id", name="uq_seasons_league_id"),
+ db.CheckConstraint(
+ "start_date IS NULL OR end_date IS NULL OR start_date <= end_date",
+ name="ck_seasons_date_order",
+ ),
+ )
+
+
+class PokerSession(TimestampMixin, db.Model):
+ __tablename__ = "sessions"
+
+ id = db.Column(db.String(36), primary_key=True, default=uuid_str)
+ league_id = db.Column(
+ db.String(36),
+ db.ForeignKey("leagues.id", ondelete="CASCADE"),
+ nullable=False,
+ index=True,
+ )
+ season_id = db.Column(db.String(36), nullable=True)
+ session_date = db.Column(db.Date, nullable=False, index=True)
+ sequence_on_date = db.Column(db.Integer, nullable=False, default=1)
+ label = db.Column(db.String(80), nullable=True)
+ status = db.Column(db.String(20), nullable=False, default="closed")
+ opened_at = db.Column(db.DateTime(timezone=True), nullable=True)
+ closed_at = db.Column(db.DateTime(timezone=True), nullable=True)
+ notes = db.Column(db.Text, nullable=True)
+
+ __table_args__ = (
+ db.UniqueConstraint(
+ "league_id",
+ "session_date",
+ "sequence_on_date",
+ name="uq_sessions_date_sequence",
+ ),
+ db.UniqueConstraint("league_id", "id", name="uq_sessions_league_id"),
+ db.ForeignKeyConstraint(
+ ["league_id", "season_id"],
+ ["seasons.league_id", "seasons.id"],
+ name="fk_sessions_season_same_league",
+ ondelete="RESTRICT",
+ ),
+ db.CheckConstraint("sequence_on_date > 0", name="ck_sessions_sequence_positive"),
+ db.CheckConstraint(f"status IN {SESSION_STATUSES}", name="ck_sessions_status"),
+ )
+
+ @property
+ def display_label(self) -> str:
+ if self.label:
+ return self.label
+ return f"{self.session_date.isoformat()} - S{self.sequence_on_date}"
+
+
+class LedgerEvent(db.Model):
+ __tablename__ = "ledger_events"
+
+ id = db.Column(db.String(36), primary_key=True, default=uuid_str)
+ league_id = db.Column(db.String(36), nullable=False, index=True)
+ session_id = db.Column(db.String(36), nullable=False, index=True)
+ player_id = db.Column(db.String(36), nullable=True, index=True)
+ event_type = db.Column(db.String(40), nullable=False)
+ amount_cents = db.Column(db.Integer, nullable=False, default=0)
+ note = db.Column(db.Text, nullable=True)
+ created_at = db.Column(db.DateTime(timezone=True), nullable=False, default=utc_now)
+ created_by_user_id = db.Column(
+ db.String(36),
+ db.ForeignKey("users.id", ondelete="RESTRICT"),
+ nullable=False,
+ index=True,
+ )
+ legacy_event_id = db.Column(db.String(80), nullable=True)
+ legacy_player_name = db.Column(db.String(160), nullable=True)
+ voided_at = db.Column(db.DateTime(timezone=True), nullable=True)
+ voided_by_user_id = db.Column(
+ db.String(36),
+ db.ForeignKey("users.id", ondelete="SET NULL"),
+ nullable=True,
+ )
+ void_reason = db.Column(db.Text, nullable=True)
+
+ __table_args__ = (
+ db.ForeignKeyConstraint(
+ ["league_id", "session_id"],
+ ["sessions.league_id", "sessions.id"],
+ name="fk_events_session_same_league",
+ ondelete="CASCADE",
+ ),
+ db.ForeignKeyConstraint(
+ ["league_id", "player_id"],
+ ["players.league_id", "players.id"],
+ name="fk_events_player_same_league",
+ ondelete="RESTRICT",
+ ),
+ db.CheckConstraint(
+ f"event_type IN {tuple(sorted(SUPPORTED_EVENT_TYPES))}",
+ name="ck_events_type",
+ ),
+ db.CheckConstraint(
+ "player_id IS NOT NULL OR event_type IN ('note', 'session_open', 'session_close')",
+ name="ck_events_player_required",
+ ),
+ db.Index("ix_events_league_created", "league_id", "created_at"),
+ db.Index("ix_events_league_session_created", "league_id", "session_id", "created_at"),
+ )
+
+
+def make_user(email: str, password_hash: str) -> User:
+ return User(email=email.strip().casefold(), password_hash=password_hash)
+
+
+def make_player(league_id: str, display_name: str, slug: str) -> Player:
+ return Player(
+ league_id=league_id,
+ display_name=display_name.strip(),
+ normalized_name=normalize_lookup(display_name),
+ slug=slug.strip().lower(),
+ )
+
+
+def make_session(
+ league_id: str,
+ session_date: date,
+ sequence_on_date: int,
+ season_id: str | None = None,
+ status: str = "closed",
+) -> PokerSession:
+ return PokerSession(
+ league_id=league_id,
+ season_id=season_id,
+ session_date=session_date,
+ sequence_on_date=sequence_on_date,
+ status=status,
+ )
diff --git a/boker/emails.py b/boker/emails.py
new file mode 100644
index 0000000..c6874ab
--- /dev/null
+++ b/boker/emails.py
@@ -0,0 +1,193 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+from html import escape
+import socket
+
+from flask import current_app
+from flask_mail import Message
+
+from boker.extensions import mail
+
+
+def _mail_send_suppressed() -> bool:
+ state = current_app.extensions.get("mail")
+ return bool(getattr(state, "suppress", False))
+
+
+def _send_message(msg: Message) -> None:
+ if not _mail_send_suppressed() and not current_app.config.get("MAIL_SERVER"):
+ raise RuntimeError("Mail server is not configured.")
+
+ previous_timeout = socket.getdefaulttimeout()
+ socket.setdefaulttimeout(float(current_app.config.get("MAIL_TIMEOUT", 5)))
+ try:
+ mail.send(msg)
+ finally:
+ socket.setdefaulttimeout(previous_timeout)
+
+
+def _html_email(title: str, intro: str, cta_label: str | None = None, cta_url: str | None = None, code: str | None = None, note: str | None = None) -> str:
+ app_url = current_app.config.get("APP_BASE_URL", "https://myboker.org").rstrip("/")
+ cta_html = ""
+ if cta_label and cta_url:
+ cta_html = f"""
+
+
+ {escape(cta_label)}
+
+
+
+
+ If the button does not work, copy and paste this link:
+ {escape(cta_url)}
+
+
+ """
+
+ code_html = ""
+ if code:
+ code_html = f"""
+
+
+ {escape(code)}
+
+
+ """
+
+ note_html = ""
+ if note:
+ note_html = f"""
+
+ {escape(note)}
+
+ """
+
+ return f"""
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {escape(title)}
+
+
+
+ {escape(intro)}
+
+ {code_html}
+ {cta_html}
+ {note_html}
+
+
+ This email was sent by myboker.org . If you did not request this, you can safely ignore it.
+
+
+
+
+
+
+
+"""
+
+
+def send_password_reset(to_email: str, reset_url: str) -> None:
+ body = (
+ f"You requested a password reset for your myboker.org account.\n\n"
+ f"Reset your password:\n\n"
+ f"{reset_url}\n\n"
+ f"This link expires in 1 hour. If you did not request this, you can ignore this email."
+ )
+ msg = Message(
+ subject="Reset your myboker.org password",
+ recipients=[to_email],
+ body=body,
+ html=_html_email(
+ "Reset your password",
+ "We received a request to reset the password for your myboker.org account.",
+ cta_label="Reset password",
+ cta_url=reset_url,
+ note="This link expires in 1 hour.",
+ ),
+ sender=current_app.config.get("MAIL_DEFAULT_SENDER"),
+ )
+ _send_message(msg)
+
+
+def send_temporary_password(to_email: str, temporary_password: str) -> None:
+ body = (
+ "A temporary password was created for your myboker.org account.\n\n"
+ f"Temporary password:\n\n{temporary_password}\n\n"
+ "Sign in with this password, then change it from account settings."
+ )
+ msg = Message(
+ subject="Temporary myboker.org password",
+ recipients=[to_email],
+ body=body,
+ html=_html_email(
+ "Temporary password",
+ "A site administrator created a temporary password for your myboker.org account.",
+ code=temporary_password,
+ note="Sign in with this password, then change it from account settings.",
+ ),
+ sender=current_app.config.get("MAIL_DEFAULT_SENDER"),
+ )
+ _send_message(msg)
+
+
+def send_email_verification_code(to_email: str, code: str) -> None:
+ body = (
+ "Verify your myboker.org account with this code:\n\n"
+ f"{code}\n\n"
+ "This code expires in 15 minutes. If you did not create an account, you can ignore this email."
+ )
+ msg = Message(
+ subject="Your myboker.org verification code",
+ recipients=[to_email],
+ body=body,
+ html=_html_email(
+ "Verify your email",
+ "Enter this verification code to finish creating your myboker.org account.",
+ code=code,
+ note="This code expires in 15 minutes. Do not share it with anyone.",
+ ),
+ sender=current_app.config.get("MAIL_DEFAULT_SENDER"),
+ )
+ _send_message(msg)
+
+
+def send_league_invite(to_email: str, league_name: str, invite_url: str, invited_by_email: str, role: str) -> None:
+ role_label = role.strip().lower() if role else "member"
+ body = (
+ f"{invited_by_email} has invited you to join {league_name} as a {role_label} on myboker.org.\n\n"
+ f"Accept your invitation:\n\n"
+ f"{invite_url}\n\n"
+ f"This link expires in 7 days. You'll need a myboker.org account with this email address to accept."
+ )
+ msg = Message(
+ subject=f"You've been invited to join {league_name} on myboker.org",
+ recipients=[to_email],
+ body=body,
+ html=_html_email(
+ "You have a league invite",
+ f"{invited_by_email} invited you to join {league_name} as a {role_label}.",
+ cta_label="Accept invite",
+ cta_url=invite_url,
+ note="This invitation link expires in 7 days.",
+ ),
+ sender=current_app.config.get("MAIL_DEFAULT_SENDER"),
+ )
+ _send_message(msg)
diff --git a/boker/extensions.py b/boker/extensions.py
new file mode 100644
index 0000000..4c711f3
--- /dev/null
+++ b/boker/extensions.py
@@ -0,0 +1,11 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+from flask_limiter import Limiter
+from flask_limiter.util import get_remote_address
+from flask_mail import Mail
+from flask_wtf.csrf import CSRFProtect
+
+csrf = CSRFProtect()
+limiter = Limiter(key_func=get_remote_address)
+mail = Mail()
diff --git a/boker/league_repositories.py b/boker/league_repositories.py
new file mode 100644
index 0000000..7536b7b
--- /dev/null
+++ b/boker/league_repositories.py
@@ -0,0 +1,460 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import secrets
+import string
+from datetime import date
+
+from boker.auth import hash_password, normalize_email
+from boker.db import db
+
+if db is None: # pragma: no cover - imported only when DB dependencies exist.
+ raise RuntimeError("Database dependencies are not installed.")
+
+from boker.db_models import ( # noqa: E402
+ LedgerEvent,
+ League,
+ LeagueMembership,
+ Player,
+ PokerSession,
+ Season,
+ User,
+ make_player,
+ make_session,
+ make_user,
+ normalize_lookup,
+)
+from boker.utils import slugify
+
+PUBLIC_KEY_ALPHABET = string.ascii_lowercase + string.digits
+
+
+def generate_public_key(length: int = 6) -> str:
+ return "".join(secrets.choice(PUBLIC_KEY_ALPHABET) for _ in range(length))
+
+
+def unique_league_public_key() -> str:
+ while True:
+ public_key = generate_public_key()
+ if League.query.filter_by(public_key=public_key).first() is None:
+ return public_key
+
+
+def create_user(email: str, password: str) -> User:
+ user = make_user(normalize_email(email), hash_password(password))
+ db.session.add(user)
+ return user
+
+
+def create_league(owner: User, name: str, slug: str, description: str | None = None) -> League:
+ league = League(
+ name=name.strip(),
+ slug=slug.strip().lower(),
+ public_key=unique_league_public_key(),
+ description=description.strip() if description else None,
+ created_by_user_id=owner.id,
+ )
+ db.session.add(league)
+ db.session.flush()
+
+ db.session.add(
+ LeagueMembership(
+ league_id=league.id,
+ user_id=owner.id,
+ role="owner",
+ )
+ )
+ return league
+
+
+def unique_league_slug(name: str, requested_slug: str | None = None) -> str:
+ return slugify(requested_slug or name)
+
+
+def find_user_by_email(email: str) -> User | None:
+ return User.query.filter_by(email=normalize_email(email)).one_or_none()
+
+
+def find_league_by_slug(slug: str) -> League | None:
+ return League.query.filter_by(slug=slug.strip().lower(), archived_at=None).one_or_none()
+
+
+def find_league_by_id(league_id: str) -> League | None:
+ return League.query.filter_by(id=league_id, archived_at=None).one_or_none()
+
+
+def find_league_by_public_key(public_key: str) -> League | None:
+ return League.query.filter_by(
+ public_key=public_key.strip().lower(),
+ archived_at=None,
+ ).one_or_none()
+
+
+def list_public_leagues(name_query: str = "") -> list[League]:
+ query = League.query.filter_by(visibility="public", archived_at=None)
+ if name_query:
+ query = query.filter(League.name.ilike(f"%{name_query}%"))
+ return query.order_by(League.name.asc()).all()
+
+
+def list_leagues_for_user(user_id: str) -> list[tuple[League, LeagueMembership]]:
+ return (
+ db.session.query(League, LeagueMembership)
+ .join(LeagueMembership, LeagueMembership.league_id == League.id)
+ .filter(
+ LeagueMembership.user_id == user_id,
+ LeagueMembership.disabled_at.is_(None),
+ League.archived_at.is_(None),
+ )
+ .order_by(League.name.asc())
+ .all()
+ )
+
+
+def league_counts(league_id: str) -> dict[str, int]:
+ return {
+ "players": Player.query.filter_by(league_id=league_id, status="active").count(),
+ "seasons": Season.query.filter_by(league_id=league_id, archived_at=None).count(),
+ "sessions": PokerSession.query.filter_by(league_id=league_id).count(),
+ "open_sessions": PokerSession.query.filter_by(league_id=league_id, status="open").count(),
+ "events": LedgerEvent.query.filter_by(league_id=league_id, voided_at=None).count(),
+ }
+
+
+def create_player(league_id: str, display_name: str, slug: str) -> Player:
+ player = make_player(league_id=league_id, display_name=display_name, slug=slug)
+ db.session.add(player)
+ return player
+
+
+def unique_player_slug(league_id: str, display_name: str, requested_slug: str | None = None) -> str:
+ base_slug = slugify(requested_slug or display_name, fallback="player")
+ candidate = base_slug
+ suffix = 2
+
+ while Player.query.filter_by(league_id=league_id, slug=candidate).first() is not None:
+ candidate = f"{base_slug}-{suffix}"
+ suffix += 1
+
+ return candidate
+
+
+def list_players_for_league(league_id: str) -> list[Player]:
+ return (
+ Player.query.filter_by(league_id=league_id)
+ .order_by(Player.status.asc(), Player.display_name.asc())
+ .all()
+ )
+
+
+def find_player_for_league(league_id: str, player_id: str) -> Player | None:
+ return Player.query.filter_by(league_id=league_id, id=player_id).one_or_none()
+
+
+def set_player_status(player: Player, status: str) -> Player:
+ if status not in {"active", "archived"}:
+ raise ValueError(f"Unsupported player status: {status}")
+
+ player.status = status
+ return player
+
+
+def create_season(
+ league_id: str,
+ name: str,
+ start_date: date | None = None,
+ end_date: date | None = None,
+ sort_order: int = 0,
+) -> Season:
+ season = Season(
+ league_id=league_id,
+ name=name.strip(),
+ start_date=start_date,
+ end_date=end_date,
+ sort_order=sort_order,
+ )
+ db.session.add(season)
+ return season
+
+
+def next_sequence_on_date(league_id: str, session_date: date) -> int:
+ current = (
+ db.session.query(db.func.max(PokerSession.sequence_on_date))
+ .filter_by(league_id=league_id, session_date=session_date)
+ .scalar()
+ )
+ return int(current or 0) + 1
+
+
+def create_poker_session(
+ league_id: str,
+ session_date: date,
+ season_id: str | None = None,
+ status: str = "closed",
+) -> PokerSession:
+ session = make_session(
+ league_id=league_id,
+ season_id=season_id,
+ session_date=session_date,
+ sequence_on_date=next_sequence_on_date(league_id, session_date),
+ status=status,
+ )
+ db.session.add(session)
+ return session
+
+
+def list_sessions_for_league(league_id: str) -> list[PokerSession]:
+ return (
+ PokerSession.query.filter_by(league_id=league_id)
+ .order_by(
+ PokerSession.session_date.desc(),
+ PokerSession.sequence_on_date.desc(),
+ PokerSession.created_at.desc(),
+ )
+ .all()
+ )
+
+
+def find_session_for_league(league_id: str, session_id: str) -> PokerSession | None:
+ return PokerSession.query.filter_by(league_id=league_id, id=session_id).one_or_none()
+
+
+def set_session_status(session: PokerSession, status: str) -> PokerSession:
+ if status not in {"open", "closed"}:
+ raise ValueError(f"Unsupported session status: {status}")
+
+ from boker.db_models import utc_now
+
+ session.status = status
+ if status == "open":
+ session.opened_at = utc_now()
+ session.closed_at = None
+ else:
+ session.closed_at = utc_now()
+ return session
+
+
+def player_name_exists(league_id: str, display_name: str) -> bool:
+ return (
+ Player.query.filter_by(
+ league_id=league_id,
+ normalized_name=normalize_lookup(display_name),
+ ).first()
+ is not None
+ )
+
+
+def player_name_taken(league_id: str, display_name: str, exclude_player_id: str) -> bool:
+ return (
+ Player.query.filter(
+ Player.league_id == league_id,
+ Player.normalized_name == normalize_lookup(display_name),
+ Player.id != exclude_player_id,
+ ).first()
+ is not None
+ )
+
+
+def update_player(player: Player, display_name: str, notes: str | None) -> Player:
+ player.display_name = display_name.strip()
+ player.normalized_name = normalize_lookup(display_name)
+ player.notes = notes.strip() if notes else None
+ return player
+
+
+def user_has_league_role(
+ user_id: str,
+ league_id: str,
+ allowed_roles: set[str] | tuple[str, ...] | list[str],
+) -> bool:
+ membership = LeagueMembership.query.filter_by(
+ user_id=user_id,
+ league_id=league_id,
+ disabled_at=None,
+ ).one_or_none()
+ return bool(membership and membership.role in set(allowed_roles))
+
+
+def find_membership(league_id: str, user_id: str) -> LeagueMembership | None:
+ return LeagueMembership.query.filter_by(
+ league_id=league_id,
+ user_id=user_id,
+ disabled_at=None,
+ ).one_or_none()
+
+
+def list_members_for_league(league_id: str) -> list[tuple[LeagueMembership, User]]:
+ return (
+ db.session.query(LeagueMembership, User)
+ .join(User, User.id == LeagueMembership.user_id)
+ .filter(
+ LeagueMembership.league_id == league_id,
+ LeagueMembership.disabled_at.is_(None),
+ )
+ .order_by(LeagueMembership.created_at.asc())
+ .all()
+ )
+
+
+def add_league_member(
+ league_id: str,
+ user_id: str,
+ role: str,
+ invited_by_user_id: str,
+) -> LeagueMembership:
+ existing = LeagueMembership.query.filter_by(
+ league_id=league_id,
+ user_id=user_id,
+ ).one_or_none()
+ if existing is not None:
+ existing.role = role
+ existing.invited_by_user_id = invited_by_user_id or None
+ existing.disabled_at = None
+ return existing
+
+ membership = LeagueMembership(
+ league_id=league_id,
+ user_id=user_id,
+ role=role,
+ invited_by_user_id=invited_by_user_id or None,
+ )
+ db.session.add(membership)
+ return membership
+
+
+def remove_league_member(league_id: str, user_id: str) -> None:
+ from boker.db_models import utc_now
+
+ membership = LeagueMembership.query.filter_by(
+ league_id=league_id,
+ user_id=user_id,
+ disabled_at=None,
+ ).one_or_none()
+ if membership and membership.role != "owner":
+ membership.disabled_at = utc_now()
+
+
+def set_league_member_role(league_id: str, user_id: str, role: str) -> LeagueMembership | None:
+ if role not in {"manager", "viewer"}:
+ raise ValueError(f"Unsupported member role: {role}")
+
+ membership = LeagueMembership.query.filter_by(
+ league_id=league_id,
+ user_id=user_id,
+ disabled_at=None,
+ ).one_or_none()
+ if membership is None or membership.role == "owner":
+ return None
+
+ membership.role = role
+ return membership
+
+
+def transfer_league_ownership(
+ league: League,
+ current_owner_user_id: str,
+ new_owner_user_id: str,
+) -> tuple[LeagueMembership, LeagueMembership] | None:
+ if current_owner_user_id == new_owner_user_id:
+ return None
+
+ current_owner = LeagueMembership.query.filter_by(
+ league_id=league.id,
+ user_id=current_owner_user_id,
+ disabled_at=None,
+ ).one_or_none()
+ new_owner = LeagueMembership.query.filter_by(
+ league_id=league.id,
+ user_id=new_owner_user_id,
+ disabled_at=None,
+ ).one_or_none()
+ if current_owner is None or current_owner.role != "owner" or new_owner is None:
+ return None
+
+ current_owner.role = "manager"
+ new_owner.role = "owner"
+ league.created_by_user_id = new_owner_user_id
+ return current_owner, new_owner
+
+
+# ---------------------------------------------------------------------------
+# Seasons
+# ---------------------------------------------------------------------------
+
+def list_seasons_for_league(league_id: str, include_archived: bool = False) -> list[Season]:
+ q = Season.query.filter_by(league_id=league_id)
+ if not include_archived:
+ q = q.filter(Season.archived_at.is_(None))
+ return q.order_by(Season.sort_order.asc(), Season.created_at.asc()).all()
+
+
+def find_season(league_id: str, season_id: str) -> Season | None:
+ return Season.query.filter_by(league_id=league_id, id=season_id).one_or_none()
+
+
+def update_season(
+ season: Season,
+ name: str,
+ start_date: date | None = None,
+ end_date: date | None = None,
+) -> Season:
+ season.name = name.strip()
+ season.start_date = start_date
+ season.end_date = end_date
+ return season
+
+
+def archive_season(season: Season) -> Season:
+ from datetime import datetime, timezone
+ season.archived_at = datetime.now(timezone.utc)
+ return season
+
+
+def unarchive_season(season: Season) -> Season:
+ season.archived_at = None
+ return season
+
+
+def delete_season(season: Season) -> None:
+ PokerSession.query.filter_by(season_id=season.id).update({"season_id": None})
+ db.session.delete(season)
+
+
+def delete_league(league: League) -> None:
+ from boker.db_models import LedgerEvent, LeagueMembership, Player, PokerSession, Season
+ LedgerEvent.query.filter_by(league_id=league.id).delete(synchronize_session=False)
+ PokerSession.query.filter_by(league_id=league.id).delete(synchronize_session=False)
+ Player.query.filter_by(league_id=league.id).delete(synchronize_session=False)
+ Season.query.filter_by(league_id=league.id).delete(synchronize_session=False)
+ LeagueMembership.query.filter_by(league_id=league.id).delete(synchronize_session=False)
+ db.session.delete(league)
+
+
+def auto_assign_sessions_to_seasons(league_id: str) -> int:
+ """Assign unassigned sessions to seasons based on date ranges.
+
+ Only seasons with both start_date and end_date set are considered.
+ Sessions with exactly one matching season are assigned; sessions that
+ match zero or multiple seasons are left untouched (caller decides).
+ Returns the number of sessions assigned.
+ """
+ eligible_seasons = [
+ s for s in list_seasons_for_league(league_id, include_archived=False)
+ if s.start_date is not None and s.end_date is not None
+ ]
+ if not eligible_seasons:
+ return 0
+
+ unassigned = PokerSession.query.filter_by(
+ league_id=league_id, season_id=None
+ ).all()
+ assigned = 0
+ for session in unassigned:
+ matches = [
+ s for s in eligible_seasons
+ if s.start_date <= session.session_date <= s.end_date
+ ]
+ if len(matches) == 1:
+ session.season_id = matches[0].id
+ assigned += 1
+ return assigned
diff --git a/boker/ledger_repositories.py b/boker/ledger_repositories.py
new file mode 100644
index 0000000..0037160
--- /dev/null
+++ b/boker/ledger_repositories.py
@@ -0,0 +1,212 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+from boker.db import db
+
+if db is None: # pragma: no cover - imported only when DB dependencies exist.
+ raise RuntimeError("Database dependencies are not installed.")
+
+from boker.db_models import ( # noqa: E402
+ CANONICAL_EVENT_TYPES,
+ LedgerEvent,
+ Player,
+ PokerSession,
+ User,
+ canonical_event_type,
+)
+from boker.storage import EventRow # noqa: E402
+
+
+def session_event_ref(session: PokerSession) -> str:
+ return f"{session.session_date.isoformat()}-{session.sequence_on_date:02d}"
+
+
+def append_ledger_event(
+ league_id: str,
+ session_id: str,
+ event_type: str,
+ amount_cents: int,
+ created_by_user_id: str,
+ player_id: str | None = None,
+ note: str | None = None,
+ legacy_event_id: str | None = None,
+ legacy_player_name: str | None = None,
+) -> LedgerEvent:
+ canonical_type = canonical_event_type(event_type)
+ if canonical_type not in CANONICAL_EVENT_TYPES:
+ raise ValueError(f"Unsupported event type: {event_type}")
+
+ if canonical_type in {"note", "session_open", "session_close"}:
+ amount_cents = 0
+
+ if player_id is None and canonical_type not in {"note", "session_open", "session_close"}:
+ raise ValueError(f"Event type {canonical_type} requires a player.")
+
+ event = LedgerEvent(
+ league_id=league_id,
+ session_id=session_id,
+ player_id=player_id,
+ event_type=canonical_type,
+ amount_cents=amount_cents,
+ note=note.strip() if note else None,
+ created_by_user_id=created_by_user_id,
+ legacy_event_id=legacy_event_id,
+ legacy_player_name=legacy_player_name,
+ )
+ db.session.add(event)
+ return event
+
+
+def list_ledger_events_for_league(league_id: str) -> list[LedgerEvent]:
+ return (
+ LedgerEvent.query.filter_by(league_id=league_id, voided_at=None)
+ .order_by(LedgerEvent.created_at.asc(), LedgerEvent.id.asc())
+ .all()
+ )
+
+
+def list_ledger_events_for_session(league_id: str, session_id: str) -> list[LedgerEvent]:
+ return (
+ LedgerEvent.query.filter_by(
+ league_id=league_id,
+ session_id=session_id,
+ voided_at=None,
+ )
+ .order_by(LedgerEvent.created_at.asc(), LedgerEvent.id.asc())
+ .all()
+ )
+
+
+def _event_row(event: LedgerEvent, session: PokerSession, player: Player | None, user: User | None) -> EventRow:
+ return EventRow(
+ id=event.id,
+ created_at=event.created_at.isoformat() if event.created_at else "",
+ session_id=session_event_ref(session),
+ session_date=session.session_date.isoformat(),
+ player_name=(player.display_name if player is not None else event.legacy_player_name or ""),
+ event_type=event.event_type,
+ amount_cents=event.amount_cents,
+ note=event.note or "",
+ actor=user.email if user is not None else "",
+ voided_at=event.voided_at.isoformat() if event.voided_at else "",
+ void_reason=event.void_reason or "",
+ )
+
+
+def list_all_event_rows_for_league(league_id: str) -> list[EventRow]:
+ """All events including voided — for display feeds only, not accounting."""
+ rows = (
+ db.session.query(LedgerEvent, PokerSession, Player, User)
+ .join(
+ PokerSession,
+ (PokerSession.league_id == LedgerEvent.league_id)
+ & (PokerSession.id == LedgerEvent.session_id),
+ )
+ .outerjoin(
+ Player,
+ (Player.league_id == LedgerEvent.league_id)
+ & (Player.id == LedgerEvent.player_id),
+ )
+ .outerjoin(User, User.id == LedgerEvent.created_by_user_id)
+ .filter(LedgerEvent.league_id == league_id)
+ .order_by(
+ PokerSession.session_date.asc(),
+ PokerSession.sequence_on_date.asc(),
+ LedgerEvent.created_at.asc(),
+ LedgerEvent.id.asc(),
+ )
+ .all()
+ )
+ return [_event_row(event, session, player, user) for event, session, player, user in rows]
+
+
+def void_ledger_event(event_id: str, league_id: str, user_id: str, reason: str) -> LedgerEvent:
+ from datetime import datetime, timezone
+ event = LedgerEvent.query.filter_by(id=event_id, league_id=league_id).first()
+ if event is None:
+ raise ValueError("Event not found.")
+ if event.voided_at is not None:
+ raise ValueError("Event is already voided.")
+ event.voided_at = datetime.now(timezone.utc)
+ event.voided_by_user_id = user_id
+ event.void_reason = reason.strip() or "Voided by owner."
+ return event
+
+
+def list_event_rows_for_league(league_id: str) -> list[EventRow]:
+ rows = (
+ db.session.query(LedgerEvent, PokerSession, Player, User)
+ .join(
+ PokerSession,
+ (PokerSession.league_id == LedgerEvent.league_id)
+ & (PokerSession.id == LedgerEvent.session_id),
+ )
+ .outerjoin(
+ Player,
+ (Player.league_id == LedgerEvent.league_id)
+ & (Player.id == LedgerEvent.player_id),
+ )
+ .outerjoin(User, User.id == LedgerEvent.created_by_user_id)
+ .filter(
+ LedgerEvent.league_id == league_id,
+ LedgerEvent.voided_at.is_(None),
+ )
+ .order_by(
+ PokerSession.session_date.asc(),
+ PokerSession.sequence_on_date.asc(),
+ LedgerEvent.created_at.asc(),
+ LedgerEvent.id.asc(),
+ )
+ .all()
+ )
+ return [_event_row(event, session, player, user) for event, session, player, user in rows]
+
+
+def list_all_event_rows_for_session(league_id: str, session_id: str) -> list[EventRow]:
+ """All events for a session including voided — for display only, not accounting."""
+ rows = (
+ db.session.query(LedgerEvent, PokerSession, Player, User)
+ .join(
+ PokerSession,
+ (PokerSession.league_id == LedgerEvent.league_id)
+ & (PokerSession.id == LedgerEvent.session_id),
+ )
+ .outerjoin(
+ Player,
+ (Player.league_id == LedgerEvent.league_id)
+ & (Player.id == LedgerEvent.player_id),
+ )
+ .outerjoin(User, User.id == LedgerEvent.created_by_user_id)
+ .filter(
+ LedgerEvent.league_id == league_id,
+ LedgerEvent.session_id == session_id,
+ )
+ .order_by(LedgerEvent.created_at.asc(), LedgerEvent.id.asc())
+ .all()
+ )
+ return [_event_row(event, session, player, user) for event, session, player, user in rows]
+
+
+def list_event_rows_for_session(league_id: str, session_id: str) -> list[EventRow]:
+ rows = (
+ db.session.query(LedgerEvent, PokerSession, Player, User)
+ .join(
+ PokerSession,
+ (PokerSession.league_id == LedgerEvent.league_id)
+ & (PokerSession.id == LedgerEvent.session_id),
+ )
+ .outerjoin(
+ Player,
+ (Player.league_id == LedgerEvent.league_id)
+ & (Player.id == LedgerEvent.player_id),
+ )
+ .outerjoin(User, User.id == LedgerEvent.created_by_user_id)
+ .filter(
+ LedgerEvent.league_id == league_id,
+ LedgerEvent.session_id == session_id,
+ LedgerEvent.voided_at.is_(None),
+ )
+ .order_by(LedgerEvent.created_at.asc(), LedgerEvent.id.asc())
+ .all()
+ )
+ return [_event_row(event, session, player, user) for event, session, player, user in rows]
diff --git a/models.py b/boker/models.py
similarity index 100%
rename from models.py
rename to boker/models.py
diff --git a/routes/__init__.py b/boker/routes/__init__.py
similarity index 100%
rename from routes/__init__.py
rename to boker/routes/__init__.py
diff --git a/boker/routes/account.py b/boker/routes/account.py
new file mode 100644
index 0000000..78d8cdf
--- /dev/null
+++ b/boker/routes/account.py
@@ -0,0 +1,541 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import secrets
+from datetime import datetime, timedelta, timezone
+from urllib.parse import urlsplit
+
+from flask import Blueprint, flash, redirect, render_template, request, session as flask_session, url_for
+
+from boker.auth import (
+ current_user_id,
+ generate_invite_token,
+ generate_reset_token,
+ hash_password,
+ log_user_in,
+ log_user_out,
+ login_required,
+ normalize_email,
+ verify_invite_token,
+ verify_password,
+ verify_reset_token,
+)
+from boker.db import database_extensions_available, db
+from boker.extensions import limiter
+
+account_bp = Blueprint("account", __name__, url_prefix="/account")
+EMAIL_VERIFICATION_TTL = timedelta(minutes=15)
+
+
+def db_ready() -> bool:
+ return database_extensions_available() and db is not None
+
+
+def safe_next_url(default: str) -> str:
+ next_url = request.args.get("next", "").strip()
+ if not next_url:
+ return default
+
+ parsed = urlsplit(next_url)
+ if parsed.scheme or parsed.netloc:
+ return default
+ if not next_url.startswith("/"):
+ return default
+ return next_url
+
+
+def _verification_next(default: str) -> str:
+ next_url = flask_session.get("pending_verification_next")
+ if isinstance(next_url, str) and next_url.startswith("/") and not urlsplit(next_url).netloc:
+ return next_url
+ return default
+
+
+def _verification_sent_at_valid(sent_at) -> bool:
+ if sent_at is None:
+ return False
+ if sent_at.tzinfo is None:
+ sent_at = sent_at.replace(tzinfo=timezone.utc)
+ return datetime.now(timezone.utc) - sent_at <= EMAIL_VERIFICATION_TTL
+
+
+def _new_verification_code() -> str:
+ return f"{secrets.randbelow(1000000):06d}"
+
+
+def _issue_verification_code(user) -> str:
+ from boker.db_models import utc_now
+
+ code = _new_verification_code()
+ user.email_verification_code_hash = hash_password(code)
+ user.email_verification_sent_at = utc_now()
+ return code
+
+
+def _send_verification_code(user) -> None:
+ from boker.emails import send_email_verification_code
+
+ code = _issue_verification_code(user)
+ db.session.commit()
+ send_email_verification_code(user.email, code)
+
+
+def _start_email_verification(user, next_url: str) -> str:
+ flask_session["pending_verification_user_id"] = user.id
+ flask_session["pending_verification_next"] = next_url
+ try:
+ _send_verification_code(user)
+ flash("Check your email for a verification code.", "success")
+ except Exception:
+ flash("Account created, but we could not send a verification code. Try resending it.", "error")
+ return url_for("account.verify_email")
+
+
+@account_bp.get("/")
+@login_required
+def home():
+ return redirect(url_for("leagues.index"))
+
+
+@account_bp.route("/register", methods=["GET", "POST"])
+@limiter.limit("10 per minute")
+def register():
+ if current_user_id():
+ return redirect(safe_next_url(url_for("leagues.index")))
+
+ if not db_ready():
+ flash("Account database is not available.", "error")
+ return render_template("account_register.html", form={})
+
+ form = {
+ "email": (request.form.get("email") or request.args.get("email") or "").strip(),
+ }
+
+ if request.method == "POST":
+ from boker.league_repositories import create_user, find_user_by_email
+
+ email = normalize_email(form["email"])
+ password = request.form.get("password", "")
+ confirm_password = request.form.get("confirm_password", "")
+ existing_user = find_user_by_email(email)
+
+ if not email or "@" not in email:
+ flash("Enter a valid email address.", "error")
+ elif len(password) < 8:
+ flash("Password must be at least 8 characters.", "error")
+ elif password != confirm_password:
+ flash("Passwords do not match.", "error")
+ elif existing_user and existing_user.disabled_at is None:
+ flash("An account already exists for that email.", "error")
+ else:
+ if existing_user:
+ user = existing_user
+ user.password_hash = hash_password(password)
+ user.disabled_at = None
+ user.email_verified_at = None
+ user.email_verification_code_hash = None
+ user.email_verification_sent_at = None
+ else:
+ user = create_user(email, password)
+ db.session.flush()
+ return redirect(_start_email_verification(user, safe_next_url(url_for("leagues.new"))))
+
+ return render_template("account_register.html", form=form)
+
+
+@account_bp.route("/login", methods=["GET", "POST"])
+@limiter.limit("20 per minute")
+def login():
+ if current_user_id():
+ return redirect(safe_next_url(url_for("leagues.index")))
+
+ if not db_ready():
+ flash("Account database is not available.", "error")
+ return render_template("account_login.html", form={})
+
+ form = {
+ "email": request.form.get("email", "").strip(),
+ }
+
+ if request.method == "POST":
+ from boker.league_repositories import find_user_by_email
+
+ user = find_user_by_email(form["email"])
+ password = request.form.get("password", "")
+
+ if user is None or not verify_password(user.password_hash, password):
+ flash("Invalid email or password.", "error")
+ elif user.disabled_at is not None:
+ flash("That account is disabled.", "error")
+ elif user.email_verified_at is None:
+ flask_session["pending_verification_user_id"] = user.id
+ flask_session["pending_verification_next"] = safe_next_url(url_for("leagues.index"))
+ try:
+ _send_verification_code(user)
+ flash("Verify your email to continue. We sent you a new code.", "success")
+ except Exception:
+ flash("Verify your email to continue. We could not send a new code.", "error")
+ return redirect(url_for("account.verify_email"))
+ else:
+ log_user_in(user.id)
+ next_url = safe_next_url(url_for("leagues.index"))
+ flash("Logged in.", "success")
+ return redirect(next_url)
+
+ return render_template("account_login.html", form=form)
+
+
+@account_bp.route("/verify-email", methods=["GET", "POST"])
+@limiter.limit("10 per minute")
+def verify_email():
+ if current_user_id():
+ return redirect(safe_next_url(url_for("leagues.index")))
+
+ if not db_ready():
+ flash("Account database is not available.", "error")
+ return redirect(url_for("account.login"))
+
+ from boker.db_models import User, utc_now
+
+ user_id = flask_session.get("pending_verification_user_id")
+ user = db.session.get(User, user_id) if user_id else None
+ if user is None or user.disabled_at is not None:
+ flask_session.pop("pending_verification_user_id", None)
+ flask_session.pop("pending_verification_next", None)
+ flash("Start again to verify your email.", "error")
+ return redirect(url_for("account.register"))
+
+ if user.email_verified_at is not None:
+ log_user_in(user.id)
+ next_url = _verification_next(url_for("leagues.index"))
+ flask_session.pop("pending_verification_user_id", None)
+ flask_session.pop("pending_verification_next", None)
+ return redirect(next_url)
+
+ if request.method == "POST":
+ code = "".join(ch for ch in request.form.get("code", "") if ch.isdigit())
+ if len(code) != 6:
+ flash("Enter the six-digit verification code.", "error")
+ elif not _verification_sent_at_valid(user.email_verification_sent_at):
+ flash("That code has expired. Request a new one.", "error")
+ elif not user.email_verification_code_hash or not verify_password(user.email_verification_code_hash, code):
+ flash("That verification code is not correct.", "error")
+ else:
+ user.email_verified_at = utc_now()
+ user.email_verification_code_hash = None
+ user.email_verification_sent_at = None
+ db.session.commit()
+ log_user_in(user.id)
+ next_url = _verification_next(url_for("leagues.index"))
+ flask_session.pop("pending_verification_user_id", None)
+ flask_session.pop("pending_verification_next", None)
+ flash("Email verified.", "success")
+ return redirect(next_url)
+
+ return render_template("account_verify_email.html", email=user.email)
+
+
+@account_bp.post("/verify-email/resend")
+@limiter.limit("3 per minute")
+def resend_verification_code():
+ if current_user_id():
+ return redirect(url_for("leagues.index"))
+
+ if not db_ready():
+ flash("Account database is not available.", "error")
+ return redirect(url_for("account.login"))
+
+ from boker.db_models import User
+
+ user_id = flask_session.get("pending_verification_user_id")
+ user = db.session.get(User, user_id) if user_id else None
+ if user is None or user.disabled_at is not None:
+ flash("Start again to verify your email.", "error")
+ return redirect(url_for("account.register"))
+
+ try:
+ _send_verification_code(user)
+ flash("A new verification code has been sent.", "success")
+ except Exception:
+ flash("We could not send a new code. Check your mail configuration.", "error")
+ return redirect(url_for("account.verify_email"))
+
+
+@account_bp.post("/logout")
+def logout():
+ log_user_out()
+ flash("Logged out.", "success")
+ return redirect(url_for("public.home"))
+
+
+@account_bp.get("/settings")
+@login_required
+def settings():
+ if not db_ready():
+ flash("Account database is not available.", "error")
+ return redirect(url_for("leagues.index"))
+
+ from boker.db_models import User
+
+ user = db.session.get(User, current_user_id())
+ if user is None:
+ flash("User not found.", "error")
+ return redirect(url_for("public.home"))
+
+ return render_template("account_settings.html", user=user)
+
+
+@account_bp.post("/settings/email")
+@login_required
+def update_email():
+ if not db_ready():
+ flash("Account database is not available.", "error")
+ return redirect(url_for("account.settings"))
+
+ from boker.db_models import User
+
+ user = db.session.get(User, current_user_id())
+ if user is None:
+ flash("User not found.", "error")
+ return redirect(url_for("account.settings"))
+
+ new_email = normalize_email(request.form.get("email", ""))
+ current_password = request.form.get("current_password", "")
+
+ if not new_email or "@" not in new_email:
+ flash("Enter a valid email address.", "error")
+ elif not verify_password(user.password_hash, current_password):
+ flash("Current password is incorrect.", "error")
+ else:
+ from boker.league_repositories import find_user_by_email
+
+ existing = find_user_by_email(new_email)
+ if existing and existing.id != user.id:
+ flash("That email is already in use.", "error")
+ else:
+ user.email = new_email
+ db.session.commit()
+ flash("Email updated.", "success")
+
+ return redirect(url_for("account.settings"))
+
+
+@account_bp.post("/settings/password")
+@login_required
+def update_password():
+ if not db_ready():
+ flash("Account database is not available.", "error")
+ return redirect(url_for("account.settings"))
+
+ from boker.db_models import User
+
+ user = db.session.get(User, current_user_id())
+ if user is None:
+ flash("User not found.", "error")
+ return redirect(url_for("account.settings"))
+
+ current_password = request.form.get("current_password", "")
+ new_password = request.form.get("new_password", "")
+ confirm_password = request.form.get("confirm_password", "")
+
+ if not verify_password(user.password_hash, current_password):
+ flash("Current password is incorrect.", "error")
+ elif len(new_password) < 8:
+ flash("New password must be at least 8 characters.", "error")
+ elif new_password != confirm_password:
+ flash("Passwords do not match.", "error")
+ else:
+ user.password_hash = hash_password(new_password)
+ db.session.commit()
+ flash("Password changed.", "success")
+
+ return redirect(url_for("account.settings"))
+
+
+@account_bp.post("/disable")
+@login_required
+def disable_account():
+ if not db_ready():
+ flash("Account database is not available.", "error")
+ return redirect(url_for("account.settings"))
+
+ from boker.db_models import League, User, utc_now
+
+ user = db.session.get(User, current_user_id())
+ if user is None:
+ flash("User not found.", "error")
+ return redirect(url_for("account.settings"))
+
+ confirm = request.form.get("confirm", "").strip()
+ current_password = request.form.get("current_password", "")
+
+ if confirm != "DISABLE":
+ flash("Confirmation text did not match.", "error")
+ return redirect(url_for("account.settings"))
+
+ if not verify_password(user.password_hash, current_password):
+ flash("Password is incorrect.", "error")
+ return redirect(url_for("account.settings"))
+
+ owned_leagues = League.query.filter_by(created_by_user_id=user.id, archived_at=None).all()
+ for league in owned_leagues:
+ league.archived_at = utc_now()
+
+ user.disabled_at = utc_now()
+ db.session.commit()
+ log_user_out()
+ flash("Your account has been disabled.", "success")
+ return redirect(url_for("public.home"))
+
+
+@account_bp.post("/delete")
+@login_required
+def delete_account():
+ if not db_ready():
+ flash("Account database is not available.", "error")
+ return redirect(url_for("account.settings"))
+
+ from boker.db_models import League, LeagueMembership, User, utc_now
+ from boker.league_repositories import delete_league
+
+ user = db.session.get(User, current_user_id())
+ if user is None:
+ flash("User not found.", "error")
+ return redirect(url_for("account.settings"))
+
+ confirm = request.form.get("confirm", "").strip()
+ current_password = request.form.get("current_password", "")
+
+ if confirm != "DELETE":
+ flash("Confirmation text did not match.", "error")
+ return redirect(url_for("account.settings"))
+
+ if not verify_password(user.password_hash, current_password):
+ flash("Password is incorrect.", "error")
+ return redirect(url_for("account.settings"))
+
+ for league in League.query.filter_by(created_by_user_id=user.id).all():
+ delete_league(league)
+
+ LeagueMembership.query.filter_by(user_id=user.id).delete(synchronize_session=False)
+ db.session.delete(user)
+ db.session.commit()
+ log_user_out()
+ flash("Your account and all data have been permanently deleted.", "success")
+ return redirect(url_for("public.home"))
+
+
+@account_bp.route("/forgot-password", methods=["GET", "POST"])
+@limiter.limit("10 per minute")
+def forgot_password():
+ if current_user_id():
+ return redirect(url_for("leagues.index"))
+
+ if request.method == "POST":
+ if not db_ready():
+ flash("Account database is not available.", "error")
+ return redirect(url_for("account.forgot_password"))
+
+ from boker.emails import send_password_reset
+ from flask import current_app
+ from boker.league_repositories import find_user_by_email
+
+ email = normalize_email(request.form.get("email", ""))
+ user = find_user_by_email(email)
+
+ if user and user.disabled_at is None:
+ token = generate_reset_token(user.id)
+ base_url = current_app.config.get("APP_BASE_URL", "").rstrip("/")
+ reset_url = f"{base_url}{url_for('account.reset_password', token=token)}"
+ try:
+ send_password_reset(email, reset_url)
+ except Exception:
+ pass
+
+ flash("If that email is registered, a reset link has been sent.", "success")
+ return redirect(url_for("account.login"))
+
+ return render_template("forgot_password.html")
+
+
+@account_bp.route("/reset-password/", methods=["GET", "POST"])
+def reset_password(token):
+ if current_user_id():
+ return redirect(url_for("leagues.index"))
+
+ user_id = verify_reset_token(token)
+ if user_id is None:
+ flash("That reset link is invalid or has expired.", "error")
+ return redirect(url_for("account.forgot_password"))
+
+ if not db_ready():
+ flash("Account database is not available.", "error")
+ return redirect(url_for("account.login"))
+
+ from boker.db_models import User
+
+ user = db.session.get(User, user_id)
+ if user is None or user.disabled_at is not None:
+ flash("That reset link is invalid or has expired.", "error")
+ return redirect(url_for("account.forgot_password"))
+
+ if request.method == "POST":
+ new_password = request.form.get("new_password", "")
+ confirm_password = request.form.get("confirm_password", "")
+
+ if len(new_password) < 8:
+ flash("Password must be at least 8 characters.", "error")
+ elif new_password != confirm_password:
+ flash("Passwords do not match.", "error")
+ else:
+ user.password_hash = hash_password(new_password)
+ db.session.commit()
+ flash("Password reset. You can now log in.", "success")
+ return redirect(url_for("account.login"))
+
+ return render_template("reset_password.html", token=token)
+
+
+@account_bp.get("/accept-invite/")
+def accept_invite(token):
+ data = verify_invite_token(token)
+ if data is None:
+ flash("That invitation link is invalid or has expired.", "error")
+ return redirect(url_for("public.home"))
+
+ if not db_ready():
+ flash("Account database is not available.", "error")
+ return redirect(url_for("leagues.index"))
+
+ from boker.db_models import User
+ from boker.league_repositories import add_league_member, find_league_by_id, find_membership, find_user_by_email
+
+ invite_email = normalize_email(data.get("email", ""))
+ invite_next = url_for("account.accept_invite", token=token)
+ if not current_user_id():
+ if find_user_by_email(invite_email):
+ flash("Sign in to accept your invitation.", "info")
+ return redirect(url_for("account.login", next=invite_next))
+
+ flash("Create an account to accept your invitation.", "info")
+ return redirect(url_for("account.register", next=invite_next, email=invite_email))
+
+ user = db.session.get(User, current_user_id())
+ if user is None or user.email != invite_email:
+ flash("This invitation was sent to a different email address.", "error")
+ return redirect(url_for("leagues.index"))
+
+ league = find_league_by_id(data["league_id"])
+ if league is None:
+ flash("That league no longer exists.", "error")
+ return redirect(url_for("leagues.index"))
+
+ existing = find_membership(league.id, user.id)
+ if existing:
+ flash(f"You are already a member of {league.name}.", "info")
+ return redirect(url_for("leagues.dashboard", league_ref=league.url_ref))
+
+ add_league_member(league.id, user.id, data["role"], data.get("invited_by", ""))
+ db.session.commit()
+ flash(f"Welcome to {league.name}! You joined as {data['role']}.", "success")
+ return redirect(url_for("leagues.dashboard", league_ref=league.url_ref))
diff --git a/boker/routes/internal.py b/boker/routes/internal.py
new file mode 100644
index 0000000..339f4d5
--- /dev/null
+++ b/boker/routes/internal.py
@@ -0,0 +1,653 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import secrets
+import string
+from datetime import datetime, timedelta, timezone
+
+from flask import Blueprint, current_app, flash, redirect, render_template, request, url_for
+from sqlalchemy import or_
+from sqlalchemy.exc import IntegrityError
+
+from boker.auth import (
+ current_user_id,
+ generate_reset_token,
+ hash_password,
+ normalize_email,
+ site_admin_required,
+)
+from boker.db import db
+from boker.db_models import LedgerEvent, League, LeagueMembership, Player, PokerSession, User, utc_now
+from boker.league_repositories import delete_league, transfer_league_ownership
+from boker.utils import slugify
+
+
+internal_bp = Blueprint("internal", __name__, url_prefix="/internal")
+
+
+def _current_admin() -> User:
+ return User.query.filter_by(id=current_user_id()).one()
+
+
+def _user_or_404(user_id: str) -> User:
+ return User.query.filter_by(id=user_id).one_or_404()
+
+
+def _league_or_404(league_id: str) -> League:
+ return League.query.filter_by(id=league_id).one_or_404()
+
+
+def _owner_for_league(league_id: str) -> User | None:
+ row = (
+ db.session.query(User)
+ .join(LeagueMembership, LeagueMembership.user_id == User.id)
+ .filter(
+ LeagueMembership.league_id == league_id,
+ LeagueMembership.role == "owner",
+ LeagueMembership.disabled_at.is_(None),
+ )
+ .one_or_none()
+ )
+ return row
+
+
+def _temporary_password(length: int = 14) -> str:
+ alphabet = string.ascii_letters + string.digits
+ return "".join(secrets.choice(alphabet) for _ in range(length))
+
+
+_CASH_IN_TYPES = ("buyin", "debt_repayment", "front_collected")
+_PAGE_SIZE = 50
+
+
+def _page_number() -> int:
+ try:
+ return max(1, int(request.args.get("page", "1")))
+ except (TypeError, ValueError):
+ return 1
+
+
+def _paginate(query, page: int, per_page: int = _PAGE_SIZE) -> dict:
+ total = query.order_by(None).count()
+ pages = max(1, (total + per_page - 1) // per_page)
+ page = min(page, pages)
+ items = query.limit(per_page).offset((page - 1) * per_page).all()
+ return {
+ "items": items,
+ "page": page,
+ "pages": pages,
+ "per_page": per_page,
+ "total": total,
+ "has_prev": page > 1,
+ "has_next": page < pages,
+ "prev_page": page - 1,
+ "next_page": page + 1,
+ "start": ((page - 1) * per_page + 1) if total else 0,
+ "end": min(page * per_page, total),
+ }
+
+
+def _site_stats() -> dict:
+ now = datetime.now(timezone.utc)
+ today = now.date()
+ day_ago = now - timedelta(days=1)
+ week_ago = now - timedelta(days=7)
+ week_ago_date = week_ago.date()
+ month_ago = now - timedelta(days=30)
+
+ cash_in_cents = (
+ db.session.query(db.func.coalesce(db.func.sum(LedgerEvent.amount_cents), 0))
+ .filter(LedgerEvent.voided_at.is_(None), LedgerEvent.event_type.in_(_CASH_IN_TYPES))
+ .scalar()
+ )
+ cash_out_cents = (
+ db.session.query(db.func.coalesce(db.func.sum(LedgerEvent.amount_cents), 0))
+ .filter(LedgerEvent.voided_at.is_(None), LedgerEvent.event_type == "paid_out")
+ .scalar()
+ )
+
+ # avg distinct players per session (via buyin events)
+ session_player_counts = (
+ db.session.query(
+ LedgerEvent.session_id,
+ db.func.count(db.func.distinct(LedgerEvent.player_id)).label("n"),
+ )
+ .filter(LedgerEvent.event_type == "buyin", LedgerEvent.voided_at.is_(None))
+ .group_by(LedgerEvent.session_id)
+ .subquery()
+ )
+ avg_players = db.session.query(
+ db.func.coalesce(db.func.avg(session_player_counts.c.n), 0)
+ ).scalar()
+
+ users_in_leagues = (
+ db.session.query(db.func.count(db.func.distinct(LeagueMembership.user_id)))
+ .filter(LeagueMembership.disabled_at.is_(None))
+ .scalar()
+ )
+ leagues_with_sessions = (
+ db.session.query(db.func.count(db.func.distinct(PokerSession.league_id)))
+ .scalar()
+ )
+ leagues_active_7d = (
+ db.session.query(db.func.count(db.func.distinct(PokerSession.league_id)))
+ .filter(PokerSession.session_date >= week_ago_date)
+ .scalar()
+ )
+ sessions_with_events = (
+ db.session.query(db.func.count(db.func.distinct(LedgerEvent.session_id)))
+ .filter(LedgerEvent.voided_at.is_(None))
+ .scalar()
+ )
+
+ session_count = PokerSession.query.count()
+ active_league_count = League.query.filter_by(archived_at=None).count()
+ active_users_24h = User.query.filter(User.last_login_at >= day_ago).count()
+ active_users_7d = User.query.filter(User.last_login_at >= week_ago).count()
+ active_users_30d = User.query.filter(User.last_login_at >= month_ago).count()
+ returning_users_7d = User.query.filter(User.created_at < week_ago, User.last_login_at >= week_ago).count()
+ returning_users_30d = User.query.filter(User.created_at < month_ago, User.last_login_at >= month_ago).count()
+ total_users = User.query.count()
+ voided_events = LedgerEvent.query.filter(LedgerEvent.voided_at.is_not(None)).count()
+ ledger_events = LedgerEvent.query.filter_by(voided_at=None).count()
+
+ return {
+ "users": total_users,
+ "active_users": User.query.filter_by(disabled_at=None).count(),
+ "disabled_users": User.query.filter(User.disabled_at.is_not(None)).count(),
+ "verified_users": User.query.filter(User.email_verified_at.is_not(None)).count(),
+ "unverified_users": User.query.filter(User.email_verified_at.is_(None)).count(),
+ "users_in_leagues": int(users_in_leagues or 0),
+ "orphan_users": total_users - int(users_in_leagues or 0),
+ "site_admins": User.query.filter_by(is_site_admin=True, disabled_at=None).count(),
+ "new_users_24h": User.query.filter(User.created_at >= day_ago).count(),
+ "new_users_7d": User.query.filter(User.created_at >= week_ago).count(),
+ "active_users_24h": active_users_24h,
+ "active_users_7d": active_users_7d,
+ "active_users_30d": active_users_30d,
+ "returning_users_7d": returning_users_7d,
+ "returning_users_30d": returning_users_30d,
+ "leagues": League.query.count(),
+ "active_leagues": active_league_count,
+ "archived_leagues": League.query.filter(League.archived_at.is_not(None)).count(),
+ "public_leagues": League.query.filter_by(visibility="public", archived_at=None).count(),
+ "leagues_with_sessions": int(leagues_with_sessions or 0),
+ "leagues_without_sessions": max(active_league_count - int(leagues_with_sessions or 0), 0),
+ "leagues_active_7d": int(leagues_active_7d or 0),
+ "sessions": session_count,
+ "open_sessions": PokerSession.query.filter_by(status="open").count(),
+ "sessions_24h": PokerSession.query.filter(PokerSession.session_date == today).count(),
+ "sessions_7d": PokerSession.query.filter(PokerSession.session_date >= week_ago_date).count(),
+ "sessions_without_events": max(session_count - int(sessions_with_events or 0), 0),
+ "ledger_events": ledger_events,
+ "ledger_events_24h": LedgerEvent.query.filter(LedgerEvent.created_at >= day_ago).count(),
+ "ledger_events_7d": LedgerEvent.query.filter(LedgerEvent.created_at >= week_ago).count(),
+ "voided_events": voided_events,
+ "void_rate": round((voided_events / (ledger_events + voided_events)) * 100, 1) if ledger_events or voided_events else 0,
+ "cash_in_cents": int(cash_in_cents or 0),
+ "cash_out_cents": int(cash_out_cents or 0),
+ "avg_cash_in_per_session_cents": int((cash_in_cents or 0) / session_count) if session_count else 0,
+ "avg_sessions_per_active_league": round(session_count / active_league_count, 1) if active_league_count else 0,
+ "avg_players_per_session": round(float(avg_players or 0), 1),
+ }
+
+
+def _top_leagues(limit: int = 8):
+ rows = (
+ db.session.query(League, db.func.count(PokerSession.id).label("n"))
+ .outerjoin(PokerSession, PokerSession.league_id == League.id)
+ .filter(League.archived_at.is_(None))
+ .group_by(League.id)
+ .order_by(db.func.count(PokerSession.id).desc())
+ .limit(limit)
+ .all()
+ )
+ return [(league, count) for league, count in rows]
+
+
+def _sessions_by_weekday() -> dict:
+ from sqlalchemy import func as f
+ rows = (
+ db.session.query(
+ f.strftime("%w", PokerSession.session_date).label("dow"),
+ f.count(PokerSession.id).label("cnt"),
+ )
+ .group_by("dow")
+ .all()
+ )
+ counts = {str(i): 0 for i in range(7)}
+ for row in rows:
+ counts[str(row.dow)] = row.cnt
+ return {
+ "labels": ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
+ "data": [counts[str(i)] for i in range(7)],
+ }
+
+
+def _growth_data(weeks: int = 8) -> dict:
+ now = datetime.now(timezone.utc)
+ labels, user_signups, session_counts, league_counts = [], [], [], []
+ for i in range(weeks - 1, -1, -1):
+ start = now - timedelta(weeks=i + 1)
+ end = now - timedelta(weeks=i)
+ start_date = start.date()
+ end_date = end.date()
+ labels.append(start.strftime("%b %-d"))
+ user_signups.append(User.query.filter(User.created_at >= start, User.created_at < end).count())
+ session_counts.append(
+ PokerSession.query.filter(
+ PokerSession.session_date >= start_date,
+ PokerSession.session_date < end_date,
+ ).count()
+ )
+ league_counts.append(League.query.filter(League.created_at >= start, League.created_at < end).count())
+ return {
+ "labels": labels,
+ "user_signups": user_signups,
+ "session_counts": session_counts,
+ "league_counts": league_counts,
+ }
+
+
+def _trend_delta(current: int | float, previous: int | float) -> dict:
+ if previous == 0:
+ pct = None if current else 0
+ else:
+ pct = round(((current - previous) / previous) * 100, 1)
+ direction = "flat"
+ if current > previous:
+ direction = "up"
+ elif current < previous:
+ direction = "down"
+ return {"current": current, "previous": previous, "pct": pct, "direction": direction}
+
+
+def _trend_cards(growth: dict) -> list[dict]:
+ labels = growth["labels"]
+ user_series = growth["user_signups"]
+ session_series = growth["session_counts"]
+ league_series = growth["league_counts"]
+ current_start = datetime.now(timezone.utc) - timedelta(days=7)
+ previous_start = current_start - timedelta(days=7)
+
+ active_current = User.query.filter(User.last_login_at >= current_start).count()
+ active_previous = User.query.filter(
+ User.last_login_at >= previous_start,
+ User.last_login_at < current_start,
+ ).count()
+ event_current = LedgerEvent.query.filter(
+ LedgerEvent.voided_at.is_(None),
+ LedgerEvent.created_at >= current_start,
+ ).count()
+ event_previous = LedgerEvent.query.filter(
+ LedgerEvent.voided_at.is_(None),
+ LedgerEvent.created_at >= previous_start,
+ LedgerEvent.created_at < current_start,
+ ).count()
+
+ return [
+ {
+ "key": "signups",
+ "label": "New users",
+ "value": user_series[-1] if user_series else 0,
+ "delta": _trend_delta(user_series[-1] if user_series else 0, user_series[-2] if len(user_series) > 1 else 0),
+ "labels": labels,
+ "series": user_series,
+ },
+ {
+ "key": "active",
+ "label": "Active users",
+ "value": active_current,
+ "delta": _trend_delta(active_current, active_previous),
+ "labels": ["Previous", "Current"],
+ "series": [active_previous, active_current],
+ },
+ {
+ "key": "sessions",
+ "label": "Sessions",
+ "value": session_series[-1] if session_series else 0,
+ "delta": _trend_delta(session_series[-1] if session_series else 0, session_series[-2] if len(session_series) > 1 else 0),
+ "labels": labels,
+ "series": session_series,
+ },
+ {
+ "key": "ledger",
+ "label": "Ledger records",
+ "value": event_current,
+ "delta": _trend_delta(event_current, event_previous),
+ "labels": ["Previous", "Current"],
+ "series": [event_previous, event_current],
+ },
+ {
+ "key": "leagues",
+ "label": "New leagues",
+ "value": league_series[-1] if league_series else 0,
+ "delta": _trend_delta(league_series[-1] if league_series else 0, league_series[-2] if len(league_series) > 1 else 0),
+ "labels": labels,
+ "series": league_series,
+ },
+ ]
+
+
+@internal_bp.get("/")
+@site_admin_required
+def dashboard():
+ recent_users = User.query.order_by(User.created_at.desc()).limit(10).all()
+ recent_leagues = League.query.order_by(League.created_at.desc()).limit(8).all()
+ top_leagues = _top_leagues()
+ owners = {league.id: _owner_for_league(league.id) for league in recent_leagues + [l for l, _ in top_leagues]}
+ growth = _growth_data()
+ return render_template(
+ "internal_dashboard.html",
+ admin_user=_current_admin(),
+ stats=_site_stats(),
+ growth=growth,
+ trend_cards=_trend_cards(growth),
+ weekday_data=_sessions_by_weekday(),
+ top_leagues=top_leagues,
+ recent_users=recent_users,
+ recent_leagues=recent_leagues,
+ owners=owners,
+ )
+
+
+@internal_bp.get("/users")
+@site_admin_required
+def users():
+ q = request.args.get("q", "").strip()
+ page = _page_number()
+ query = User.query
+ if q:
+ query = query.filter(User.email.ilike(f"%{normalize_email(q)}%"))
+ pagination = _paginate(query.order_by(User.created_at.desc()), page)
+ return render_template("internal_users.html", users=pagination["items"], pagination=pagination, q=q)
+
+
+@internal_bp.get("/users/")
+@site_admin_required
+def user_detail(user_id: str):
+ user = _user_or_404(user_id)
+ memberships = (
+ db.session.query(LeagueMembership, League)
+ .join(League, League.id == LeagueMembership.league_id)
+ .filter(LeagueMembership.user_id == user.id)
+ .order_by(LeagueMembership.created_at.desc())
+ .all()
+ )
+ owned_leagues = League.query.filter_by(created_by_user_id=user.id).order_by(League.created_at.desc()).all()
+ event_count = LedgerEvent.query.filter_by(created_by_user_id=user.id).count()
+ return render_template(
+ "internal_user_detail.html",
+ user=user,
+ memberships=memberships,
+ owned_leagues=owned_leagues,
+ event_count=event_count,
+ current_admin_id=current_user_id(),
+ )
+
+
+@internal_bp.post("/users//email")
+@site_admin_required
+def update_user_email(user_id: str):
+ user = _user_or_404(user_id)
+ new_email = normalize_email(request.form.get("email", ""))
+
+ if not new_email or "@" not in new_email:
+ flash("Enter a valid email address.", "error")
+ return redirect(url_for("internal.user_detail", user_id=user.id))
+
+ existing = User.query.filter(User.email == new_email, User.id != user.id).one_or_none()
+ if existing:
+ flash("That email is already in use.", "error")
+ return redirect(url_for("internal.user_detail", user_id=user.id))
+
+ user.email = new_email
+ db.session.commit()
+ flash("User email updated.", "success")
+ return redirect(url_for("internal.user_detail", user_id=user.id))
+
+
+@internal_bp.post("/users//password-reset")
+@site_admin_required
+def send_user_password_reset(user_id: str):
+ user = _user_or_404(user_id)
+ if user.disabled_at is not None:
+ flash("Disabled users cannot receive password resets.", "error")
+ return redirect(url_for("internal.user_detail", user_id=user.id))
+
+ from boker.emails import send_password_reset
+
+ token = generate_reset_token(user.id)
+ base_url = current_app.config.get("APP_BASE_URL", "").rstrip("/")
+ reset_url = f"{base_url}{url_for('account.reset_password', token=token)}"
+ try:
+ send_password_reset(user.email, reset_url)
+ flash("Password reset email sent.", "success")
+ except Exception:
+ flash("Password reset email could not be sent. Check mail configuration.", "error")
+ return redirect(url_for("internal.user_detail", user_id=user.id))
+
+
+@internal_bp.post("/users//temporary-password")
+@site_admin_required
+def create_user_temporary_password(user_id: str):
+ user = _user_or_404(user_id)
+ if user.disabled_at is not None:
+ flash("Disabled users cannot receive temporary passwords.", "error")
+ return redirect(url_for("internal.user_detail", user_id=user.id))
+
+ from boker.emails import send_temporary_password
+
+ temporary_password = _temporary_password()
+ user.password_hash = hash_password(temporary_password)
+ db.session.commit()
+ try:
+ send_temporary_password(user.email, temporary_password)
+ flash("Temporary password created and emailed.", "success")
+ except Exception:
+ flash(f"Temporary password created but email failed: {temporary_password}", "error")
+ return redirect(url_for("internal.user_detail", user_id=user.id))
+
+
+@internal_bp.post("/users//disable")
+@site_admin_required
+def disable_user(user_id: str):
+ user = _user_or_404(user_id)
+ if user.id == current_user_id():
+ flash("You cannot disable your own admin account.", "error")
+ elif user.disabled_at is None:
+ user.disabled_at = utc_now()
+ db.session.commit()
+ flash("User disabled.", "success")
+ return redirect(url_for("internal.user_detail", user_id=user.id))
+
+
+@internal_bp.post("/users//restore")
+@site_admin_required
+def restore_user(user_id: str):
+ user = _user_or_404(user_id)
+ user.disabled_at = None
+ db.session.commit()
+ flash("User restored.", "success")
+ return redirect(url_for("internal.user_detail", user_id=user.id))
+
+
+@internal_bp.post("/users//delete")
+@site_admin_required
+def delete_user(user_id: str):
+ user = _user_or_404(user_id)
+ confirm = request.form.get("confirm", "").strip()
+ if user.id == current_user_id():
+ flash("You cannot delete your own admin account.", "error")
+ return redirect(url_for("internal.user_detail", user_id=user.id))
+ if confirm != "DELETE":
+ flash("Confirmation text did not match.", "error")
+ return redirect(url_for("internal.user_detail", user_id=user.id))
+
+ owned_leagues = League.query.filter_by(created_by_user_id=user.id).count()
+ if owned_leagues:
+ flash("Transfer or delete this user's owned leagues before deleting the account.", "error")
+ return redirect(url_for("internal.user_detail", user_id=user.id))
+
+ LeagueMembership.query.filter_by(user_id=user.id).delete(synchronize_session=False)
+ try:
+ db.session.delete(user)
+ db.session.commit()
+ flash("User deleted.", "success")
+ return redirect(url_for("internal.users"))
+ except IntegrityError:
+ db.session.rollback()
+ flash("User has audit history and cannot be deleted safely. Disable the account instead.", "error")
+ return redirect(url_for("internal.user_detail", user_id=user.id))
+
+
+@internal_bp.get("/leagues")
+@site_admin_required
+def leagues():
+ q = request.args.get("q", "").strip()
+ page = _page_number()
+ query = db.session.query(League)
+ if q:
+ term = f"%{q.strip()}%"
+ query = (
+ query.outerjoin(LeagueMembership, LeagueMembership.league_id == League.id)
+ .outerjoin(User, User.id == LeagueMembership.user_id)
+ .filter(
+ or_(
+ League.name.ilike(term),
+ League.slug.ilike(term),
+ User.email.ilike(term),
+ )
+ )
+ .distinct()
+ )
+ pagination = _paginate(query.order_by(League.created_at.desc()), page)
+ leagues = pagination["items"]
+ owners = {league.id: _owner_for_league(league.id) for league in leagues}
+ return render_template("internal_leagues.html", leagues=leagues, owners=owners, pagination=pagination, q=q)
+
+
+@internal_bp.get("/leagues/")
+@site_admin_required
+def league_detail(league_id: str):
+ league = _league_or_404(league_id)
+ members = (
+ db.session.query(LeagueMembership, User)
+ .join(User, User.id == LeagueMembership.user_id)
+ .filter(LeagueMembership.league_id == league.id)
+ .order_by(LeagueMembership.created_at.asc())
+ .all()
+ )
+ stats = {
+ "members": len([m for m, _u in members if m.disabled_at is None]),
+ "players": Player.query.filter_by(league_id=league.id).count(),
+ "sessions": PokerSession.query.filter_by(league_id=league.id).count(),
+ "events": LedgerEvent.query.filter_by(league_id=league.id).count(),
+ "voided_events": LedgerEvent.query.filter(LedgerEvent.league_id == league.id, LedgerEvent.voided_at.is_not(None)).count(),
+ }
+ owner = _owner_for_league(league.id)
+ return render_template("internal_league_detail.html", league=league, owner=owner, members=members, stats=stats)
+
+
+@internal_bp.post("/leagues//archive")
+@site_admin_required
+def archive_league(league_id: str):
+ league = _league_or_404(league_id)
+ league.archived_at = utc_now()
+ db.session.commit()
+ flash("League archived.", "success")
+ return redirect(url_for("internal.league_detail", league_id=league.id))
+
+
+@internal_bp.post("/leagues//settings")
+@site_admin_required
+def update_league_settings(league_id: str):
+ league = _league_or_404(league_id)
+ raw_eligible = request.form.get("eligible_min_sessions", "3").strip()
+ raw_break_even = request.form.get("break_even_dollars", "1.00").strip()
+ name = request.form.get("name", "").strip()
+ description = request.form.get("description", "").strip()
+ visibility = request.form.get("visibility", "private").strip()
+
+ try:
+ eligible_min = int(raw_eligible)
+ break_even_cents = round(float(raw_break_even) * 100)
+ except (ValueError, TypeError):
+ flash("Eligible sessions and break-even threshold must be valid numbers.", "error")
+ return redirect(url_for("internal.league_detail", league_id=league.id))
+
+ if len(name) < 2:
+ flash("League name must be at least 2 characters.", "error")
+ elif visibility not in ("private", "public"):
+ flash("Invalid visibility value.", "error")
+ elif eligible_min < 1 or eligible_min > 100:
+ flash("Eligible minimum must be between 1 and 100.", "error")
+ elif break_even_cents < 0 or break_even_cents > 10000:
+ flash("Break-even threshold must be between $0.00 and $100.00.", "error")
+ else:
+ league.name = name
+ league.slug = slugify(name)
+ league.description = description or None
+ league.visibility = visibility
+ league.eligible_min_sessions = eligible_min
+ league.break_even_cents = break_even_cents
+ db.session.commit()
+ flash("League settings updated.", "success")
+
+ return redirect(url_for("internal.league_detail", league_id=league.id))
+
+
+@internal_bp.post("/leagues//restore")
+@site_admin_required
+def restore_league(league_id: str):
+ league = _league_or_404(league_id)
+ league.archived_at = None
+ db.session.commit()
+ flash("League restored.", "success")
+ return redirect(url_for("internal.league_detail", league_id=league.id))
+
+
+@internal_bp.post("/leagues//transfer")
+@site_admin_required
+def transfer_league_owner(league_id: str):
+ league = _league_or_404(league_id)
+ new_owner_email = normalize_email(request.form.get("new_owner_email", ""))
+ new_owner = User.query.filter_by(email=new_owner_email, disabled_at=None).one_or_none()
+ current_owner = _owner_for_league(league.id)
+
+ if current_owner is None:
+ flash("Current owner could not be found.", "error")
+ elif new_owner is None:
+ flash("New owner must be an active user.", "error")
+ else:
+ membership = LeagueMembership.query.filter_by(league_id=league.id, user_id=new_owner.id).one_or_none()
+ if membership is None:
+ membership = LeagueMembership(league_id=league.id, user_id=new_owner.id, role="manager")
+ db.session.add(membership)
+ db.session.flush()
+ membership.disabled_at = None
+ result = transfer_league_ownership(league, current_owner.id, new_owner.id)
+ if result is None:
+ flash("Ownership transfer failed.", "error")
+ else:
+ db.session.commit()
+ flash("League ownership transferred.", "success")
+ return redirect(url_for("internal.league_detail", league_id=league.id))
+
+ db.session.rollback()
+ return redirect(url_for("internal.league_detail", league_id=league.id))
+
+
+@internal_bp.post("/leagues//delete")
+@site_admin_required
+def delete_internal_league(league_id: str):
+ league = _league_or_404(league_id)
+ confirm = request.form.get("confirm", "").strip()
+ if confirm != "DELETE":
+ flash("Confirmation text did not match.", "error")
+ return redirect(url_for("internal.league_detail", league_id=league.id))
+
+ delete_league(league)
+ db.session.commit()
+ flash("League deleted.", "success")
+ return redirect(url_for("internal.leagues"))
diff --git a/boker/routes/leagues.py b/boker/routes/leagues.py
new file mode 100644
index 0000000..ad73fb9
--- /dev/null
+++ b/boker/routes/leagues.py
@@ -0,0 +1,1850 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+from datetime import date
+
+import csv
+import io
+from datetime import datetime, timezone
+
+from flask import Blueprint, abort, flash, make_response, redirect, render_template, request, url_for
+
+from boker.auth import current_user_id, login_required, normalize_email
+from boker.charts import cumulative_profit_series, player_session_series
+from boker.db import database_extensions_available, db
+from boker.models import SessionSummary
+from boker.services import apply_rank_changes, build_leaderboard, build_session_summaries, session_events
+from boker.utils import cents_to_dollars, session_label, session_sort_key
+
+leagues_bp = Blueprint("leagues", __name__)
+
+LEAGUE_EVENT_TYPES = [
+ ("buyin", "Buy-in"),
+ ("front", "Front"),
+ ("rollover_in", "Rollover-in"),
+ ("payout_carry_in", "Payout carry-in"),
+ ("cashout", "Cashout result"),
+ ("paid_out", "Paid out cash"),
+ ("rollover_out", "Rollover-out"),
+ ("debt_repayment", "Debt repayment"),
+ ("writeoff", "Write-off"),
+ ("note", "Note"),
+]
+
+
+def db_ready() -> bool:
+ return database_extensions_available() and db is not None
+
+
+def league_url_values(league) -> dict[str, str]:
+ return {"league_ref": league.url_ref}
+
+
+def split_league_ref(league_ref: str) -> tuple[str, str]:
+ slug, separator, public_key = league_ref.rpartition("-")
+ if not separator or not slug or not public_key:
+ abort(404)
+ return slug, public_key
+
+
+def require_league(league_ref: str, allowed_roles: set[str]):
+ from boker.league_repositories import find_league_by_public_key, user_has_league_role
+
+ _slug, public_key = split_league_ref(league_ref)
+ league = find_league_by_public_key(public_key)
+ if league is None:
+ abort(404)
+
+ user_id = current_user_id() or ""
+ if not user_has_league_role(user_id, league.id, allowed_roles):
+ abort(403)
+
+ return league
+
+
+def get_league_with_visibility_gate(league_ref: str):
+ """Load league; if private, enforce login + membership. Returns (league, None) or (None, redirect)."""
+ from boker.league_repositories import find_league_by_public_key, user_has_league_role
+
+ _slug, public_key = split_league_ref(league_ref)
+ league = find_league_by_public_key(public_key)
+ if league is None:
+ abort(404)
+
+ if league.visibility != "public":
+ user_id = current_user_id()
+ if not user_id:
+ return None, redirect(url_for("account.login", next=request.path))
+ if not user_has_league_role(user_id, league.id, {"owner", "manager", "viewer"}):
+ abort(403)
+
+ return league, None
+
+
+def parse_amount_cents(raw_amount: str) -> int | None:
+ try:
+ return int(round(float(raw_amount.strip() or "0") * 100))
+ except ValueError:
+ return None
+
+
+def empty_session_summary(session) -> SessionSummary:
+ return SessionSummary(
+ session_id=f"{session.session_date.isoformat()}-{session.sequence_on_date:02d}",
+ session_date=session.session_date.isoformat(),
+ entries=[],
+ status=session.status,
+ opened_at=session.opened_at.isoformat() if session.opened_at else "",
+ )
+
+
+def session_ref_map(league_id: str) -> dict[str, str]:
+ from boker.ledger_repositories import session_event_ref
+ from boker.league_repositories import list_sessions_for_league
+
+ return {session_event_ref(session): session.id for session in list_sessions_for_league(league_id)}
+
+
+@leagues_bp.get("/leagues")
+@login_required
+def index():
+ if not db_ready():
+ flash("League database is not available.", "error")
+ return render_template("leagues_index.html", leagues=[])
+
+ from boker.league_repositories import list_leagues_for_user
+ from boker.ledger_repositories import list_event_rows_for_league
+
+ memberships = list_leagues_for_user(current_user_id() or "")
+ league_summaries = []
+ total_sessions = 0
+ total_players = 0
+ total_events = 0
+ total_open_items_cents = 0
+ live_count = 0
+ for league, membership in memberships:
+ events = list_event_rows_for_league(league.id)
+ summaries = build_session_summaries(events)
+ leaderboard = build_leaderboard(summaries)
+ live_sessions = [session for session in summaries if session.status == "open"]
+ latest_session = sorted(summaries, key=session_sort_key, reverse=True)[0] if summaries else None
+ players_count = len({
+ entry.player_name
+ for session in summaries
+ for entry in session.entries
+ })
+ open_items_cents = sum(
+ session.total_current_due_to_player_cents
+ + session.total_current_due_to_house_cents
+ for session in summaries
+ )
+ total_sessions += len(summaries)
+ total_players += players_count
+ total_events += len(events)
+ total_open_items_cents += open_items_cents
+ live_count += len(live_sessions)
+ league_summaries.append(
+ {
+ "league": league,
+ "membership": membership,
+ "sessions": summaries,
+ "live_session": live_sessions[0] if live_sessions else None,
+ "latest_session": latest_session,
+ "top_players": leaderboard[:3],
+ "leader": leaderboard[0] if leaderboard else None,
+ "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
+ ),
+ "open_items_cents": open_items_cents,
+ }
+ )
+ page_summary = {
+ "league_count": len(league_summaries),
+ "live_count": live_count,
+ "players_count": total_players,
+ "sessions_count": total_sessions,
+ "events_count": total_events,
+ "open_items_cents": total_open_items_cents,
+ }
+ return render_template(
+ "leagues_index.html",
+ leagues=league_summaries,
+ page_summary=page_summary,
+ session_label=session_label,
+ )
+
+
+@leagues_bp.route("/leagues/new", methods=["GET", "POST"])
+@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)
+
+
+@leagues_bp.get("/l/")
+def dashboard(league_ref: str):
+ if not db_ready():
+ flash("League database is not available.", "error")
+ return redirect(url_for("public.home"))
+
+ from boker.league_repositories import league_counts, user_has_league_role
+ from boker.ledger_repositories import list_event_rows_for_league
+
+ league, resp = get_league_with_visibility_gate(league_ref)
+ if resp:
+ return resp
+ can_manage = user_has_league_role(current_user_id() or "", league.id, {"owner", "manager"})
+ is_owner = user_has_league_role(current_user_id() or "", league.id, {"owner"})
+
+ all_sessions = build_session_summaries(list_event_rows_for_league(league.id))
+ closed_sessions = [s for s in all_sessions if not s.is_open]
+ closed_count = len(closed_sessions)
+
+ total_cash_in = sum(s.total_real_cash_in_cents for s in closed_sessions)
+ total_paid_out = sum(s.total_paid_out_cents for s in closed_sessions)
+ total_fronts = sum(s.total_front_cents for s in all_sessions)
+ closed_entries = sum(len(s.entries) for s in closed_sessions)
+ all_entries = sum(len(s.entries) for s in all_sessions)
+
+ # Avg sessions per month since the first session
+ session_date_strs = [s.session_date[:10] for s in all_sessions if s.session_date]
+ avg_sessions_per_month = 0.0
+ if session_date_strs:
+ first_date = date.fromisoformat(min(session_date_strs))
+ today_d = date.today()
+ months_elapsed = (today_d.year - first_date.year) * 12 + (today_d.month - first_date.month) + 1
+ avg_sessions_per_month = round(len(all_sessions) / months_elapsed, 1)
+
+ # Session calendar data: date → count of sessions on that date
+ sessions_by_date: dict[str, int] = {}
+ for s in all_sessions:
+ if s.session_date:
+ d = s.session_date[:10]
+ sessions_by_date[d] = sessions_by_date.get(d, 0) + 1
+ first_session_year = int(min(sessions_by_date)[:4]) if sessions_by_date else date.today().year
+
+ cash_stats = {
+ "total_cash_in": total_cash_in,
+ "total_paid_out": total_paid_out,
+ "total_fronts": total_fronts,
+ "avg_pot": total_cash_in // closed_count if closed_count else 0,
+ "avg_players": round(all_entries / len(all_sessions)) if all_sessions else 0,
+ "avg_buyin": sum(s.total_buy_in_cents for s in closed_sessions) // closed_entries if closed_entries else 0,
+ "biggest_pot": max((s.total_real_cash_in_cents for s in all_sessions), default=0),
+ "avg_sessions_per_month": avg_sessions_per_month,
+ "session_map": sessions_by_date,
+ "first_session_year": first_session_year,
+ "has_data": closed_count > 0,
+ "has_sessions": bool(all_sessions),
+ }
+
+ return render_template(
+ "league_dashboard.html",
+ league=league,
+ counts=league_counts(league.id),
+ can_manage=can_manage,
+ is_owner=is_owner,
+ cash_stats=cash_stats,
+ )
+
+
+@leagues_bp.get("/l//")
+@login_required
+def legacy_dashboard_redirect(league_id: str, league_slug: str):
+ if not db_ready():
+ flash("League database is not available.", "error")
+ return redirect(url_for("public.home"))
+
+ from boker.league_repositories import find_league_by_id, user_has_league_role
+
+ league = find_league_by_id(league_id)
+ if league is None:
+ abort(404)
+ if not user_has_league_role(current_user_id() or "", league.id, {"owner", "manager", "viewer"}):
+ abort(403)
+
+ return redirect(url_for("leagues.dashboard", **league_url_values(league)), code=301)
+
+
+@leagues_bp.get("/l//leaderboard")
+def leaderboard(league_ref: str):
+ if not db_ready():
+ flash("League database is not available.", "error")
+ return redirect(url_for("public.home"))
+
+ from boker.ledger_repositories import list_event_rows_for_league
+ from boker.league_repositories import (
+ list_players_for_league,
+ list_seasons_for_league,
+ user_has_league_role,
+ )
+
+ league, resp = get_league_with_visibility_gate(league_ref)
+ if resp:
+ return resp
+ can_manage = user_has_league_role(current_user_id() or "", league.id, {"owner", "manager"})
+ is_owner = user_has_league_role(current_user_id() or "", league.id, {"owner"})
+ eligible_min_sessions = league.eligible_min_sessions
+ break_even_cents = league.break_even_cents
+ all_sessions = build_session_summaries(list_event_rows_for_league(league.id))
+ ordered_sessions = sorted(all_sessions, key=session_sort_key)
+
+ # Season filter: restrict to sessions belonging to a specific season.
+ seasons = list_seasons_for_league(league.id, include_archived=True)
+ selected_season_id = request.args.get("season", "").strip()
+ selected_season = None
+ if selected_season_id:
+ from boker.league_repositories import find_season, list_sessions_for_league
+ selected_season = find_season(league.id, selected_season_id)
+ if selected_season:
+ from boker.ledger_repositories import session_event_ref
+ season_db_sessions = list_sessions_for_league(league.id)
+ season_refs = {
+ session_event_ref(s)
+ for s in season_db_sessions
+ if s.season_id == selected_season_id
+ }
+ ordered_sessions = [s for s in ordered_sessions if s.session_id in season_refs]
+ else:
+ selected_season_id = ""
+
+ session_ids = [session.session_id for session in ordered_sessions]
+ selected_session_id = request.args.get("through_session", "").strip()
+ mode = request.args.get("mode", "eligible").strip()
+ if mode not in ("eligible", "all", "recent"):
+ mode = "eligible"
+ label = ""
+ cutoff_index = len(session_ids) - 1
+
+ if session_ids:
+ if selected_session_id in session_ids:
+ cutoff_index = session_ids.index(selected_session_id)
+ label = session_label(ordered_sessions[cutoff_index])
+ else:
+ label = session_label(ordered_sessions[cutoff_index])
+
+ filtered_sessions = ordered_sessions[: cutoff_index + 1]
+ previous_sessions = ordered_sessions[:cutoff_index]
+ else:
+ filtered_sessions = []
+ previous_sessions = []
+
+ board = apply_rank_changes(
+ build_leaderboard(filtered_sessions, break_even_cents),
+ build_leaderboard(previous_sessions, break_even_cents),
+ )
+ eligible_count = sum(
+ 1 for player in board if player.sessions_played >= eligible_min_sessions
+ )
+ all_count = len(board)
+ recent_sessions_slice = filtered_sessions[-5:]
+ recent_count = len({
+ entry.player_name
+ for session in recent_sessions_slice
+ for entry in session.entries
+ })
+
+ if mode == "recent":
+ main_board = build_leaderboard(recent_sessions_slice, break_even_cents)
+ provisional_board = []
+ elif mode == "eligible":
+ main_board = [
+ player
+ for player in board
+ if player.sessions_played >= eligible_min_sessions
+ ]
+ provisional_board = [
+ player
+ for player in board
+ if player.sessions_played < eligible_min_sessions
+ ]
+ else:
+ main_board = board
+ provisional_board = []
+
+ chart_data = cumulative_profit_series(filtered_sessions)
+ cash_paid_out_cents = sum(session.total_paid_out_cents for session in all_sessions)
+
+ return render_template(
+ "league_leaderboard.html",
+ league=league,
+ main_board=main_board,
+ provisional_board=provisional_board,
+ mode=mode,
+ eligible_count=eligible_count,
+ all_count=all_count,
+ recent_count=recent_count,
+ eligible_min_sessions=eligible_min_sessions,
+ session_count=len(filtered_sessions),
+ total_session_count=len(all_sessions),
+ cash_paid_out_cents=cash_paid_out_cents,
+ chart_data=chart_data,
+ available_sessions=ordered_sessions,
+ selected_session_id=selected_session_id,
+ selected_session_label=(label if selected_session_id else "Latest session"),
+ selected_session_date=(
+ ordered_sessions[cutoff_index].session_date
+ if selected_session_id and session_ids
+ else ""
+ ),
+ session_label=session_label,
+ session_ids=session_ref_map(league.id),
+ player_ids={
+ player.display_name: player.id
+ for player in list_players_for_league(league.id)
+ },
+ can_manage=can_manage,
+ is_owner=is_owner,
+ seasons=seasons,
+ selected_season=selected_season,
+ selected_season_id=selected_season_id,
+ )
+
+
+@leagues_bp.route("/l//ledger", methods=["GET", "POST"])
+@login_required
+def ledger(league_ref: str):
+ if not db_ready():
+ flash("League database is not available.", "error")
+ return redirect(url_for("public.home"))
+
+ from boker.ledger_repositories import (
+ append_ledger_event,
+ list_all_event_rows_for_league,
+ list_event_rows_for_league,
+ )
+ from boker.league_repositories import (
+ find_player_for_league,
+ find_session_for_league,
+ list_players_for_league,
+ user_has_league_role,
+ )
+
+ league = require_league(league_ref, {"owner", "manager"})
+ can_manage = user_has_league_role(current_user_id() or "", league.id, {"owner", "manager"})
+ is_owner = user_has_league_role(current_user_id() or "", league.id, {"owner"})
+
+ rows = list_event_rows_for_league(league.id)
+ summaries = build_session_summaries(rows)
+ session_ids = session_ref_map(league.id)
+ player_ids = {player.display_name: player.id for player in list_players_for_league(league.id)}
+ debt_entries = [
+ {"session": session, "entry": entry}
+ for session in summaries
+ for entry in session.entries
+ if entry.current_due_to_house_cents > 0
+ ]
+ due_to_player_items = [
+ {"session": session, "entry": entry}
+ for session in summaries
+ for entry in session.entries
+ if entry.current_due_to_player_cents > 0
+ ]
+ open_sessions = [s for s in summaries if s.is_open]
+
+ if request.method == "POST":
+ session_id = request.form.get("session_id", "").strip()
+ player_id = request.form.get("player_id", "").strip()
+ event_type = request.form.get("event_type", "").strip()
+ note = request.form.get("note", "").strip()
+ amount_cents = parse_amount_cents(request.form.get("amount", "0"))
+
+ session = find_session_for_league(league.id, session_id)
+ player = find_player_for_league(league.id, player_id)
+ if session is None or player is None:
+ abort(404)
+ if event_type not in {"debt_repayment", "writeoff"}:
+ flash("Select a valid debt resolution type.", "error")
+ elif amount_cents is None or amount_cents <= 0:
+ flash("Amount must be greater than zero.", "error")
+ else:
+ session_ref = next(
+ (
+ ref
+ for ref, db_session_id in session_ids.items()
+ if db_session_id == session.id
+ ),
+ "",
+ )
+ target_summary = next(
+ (summary for summary in summaries if summary.session_id == session_ref),
+ None,
+ )
+ target_entry = (
+ next(
+ (
+ entry
+ for entry in target_summary.entries
+ if entry.player_name == player.display_name
+ ),
+ None,
+ )
+ if target_summary is not None
+ else None
+ )
+ if target_entry is None or target_entry.current_due_to_house_cents <= 0:
+ flash("That player does not have an outstanding debt in this session.", "error")
+ elif amount_cents > target_entry.current_due_to_house_cents:
+ flash(
+ f"Debt resolution cannot exceed {cents_to_dollars(target_entry.current_due_to_house_cents)}.",
+ "error",
+ )
+ else:
+ append_ledger_event(
+ league.id,
+ session.id,
+ event_type,
+ amount_cents,
+ current_user_id() or "",
+ player_id=player.id,
+ note=note or ("Debt repaid." if event_type == "debt_repayment" else "Debt written off."),
+ )
+ db.session.commit()
+ flash("Debt resolution recorded.", "success")
+ return redirect(url_for("leagues.ledger", **league_url_values(league)))
+
+ cash_in_cents = sum(s.total_real_cash_in_cents for s in summaries)
+ cash_out_cents = sum(s.total_real_cash_out_cents for s in summaries)
+ due_to_players_cents = sum(s.total_current_due_to_player_cents for s in summaries)
+ due_to_house_cents = sum(s.total_current_due_to_house_cents for s in summaries)
+ totals = {
+ "cash_in_cents": cash_in_cents,
+ "cash_out_cents": cash_out_cents,
+ "due_to_players_cents": due_to_players_cents,
+ "due_to_house_cents": due_to_house_cents,
+ "book_held_cents": cash_in_cents - cash_out_cents,
+ "net_position_cents": (cash_in_cents - cash_out_cents) + due_to_house_cents - due_to_players_cents,
+ "collected_cents": sum(s.total_debt_repayment_cents for s in summaries),
+ "written_off_cents": sum(s.total_writeoff_cents for s in summaries),
+ "events": len(rows),
+ }
+
+ all_rows = list_all_event_rows_for_league(league.id)
+
+ return render_template(
+ "league_ledger.html",
+ league=league,
+ totals=totals,
+ sessions=summaries,
+ debt_entries=debt_entries,
+ due_to_player_items=due_to_player_items,
+ open_sessions=open_sessions,
+ raw_events=list(reversed(all_rows)),
+ session_label=session_label,
+ session_ids=session_ids,
+ player_ids=player_ids,
+ can_manage=can_manage,
+ is_owner=is_owner,
+ )
+
+
+@leagues_bp.post("/l//events//void")
+@login_required
+def void_event(league_ref: str, event_id: str):
+ if not db_ready():
+ flash("League database is not available.", "error")
+ return redirect(url_for("public.home"))
+
+ from boker.ledger_repositories import void_ledger_event
+
+ league = require_league(league_ref, {"owner"})
+ reason = request.form.get("void_reason", "").strip()
+ session_id = None
+ try:
+ event = void_ledger_event(event_id, league.id, current_user_id() or "", reason)
+ session_id = event.session_id
+ db.session.commit()
+ flash("Event voided.", "success")
+ except ValueError as exc:
+ flash(str(exc), "error")
+ if session_id:
+ return redirect(url_for("leagues.session_detail", **league_url_values(league), session_id=session_id))
+ return redirect(url_for("leagues.ledger", **league_url_values(league)))
+
+
+@leagues_bp.get("/l//players/")
+def player_detail(league_ref: str, player_id: str):
+ if not db_ready():
+ flash("League database is not available.", "error")
+ return redirect(url_for("public.home"))
+
+ from boker.ledger_repositories import list_event_rows_for_league
+ from boker.league_repositories import find_player_for_league, user_has_league_role
+
+ league, resp = get_league_with_visibility_gate(league_ref)
+ if resp:
+ return resp
+ user_id = current_user_id() or ""
+ is_owner = user_has_league_role(user_id, league.id, {"owner"})
+ can_manage = user_has_league_role(user_id, league.id, {"owner", "manager"})
+ player_record = find_player_for_league(league.id, player_id)
+ if player_record is None:
+ abort(404)
+
+ all_sessions = build_session_summaries(list_event_rows_for_league(league.id))
+ board = build_leaderboard(all_sessions, league.break_even_cents)
+ player_stats = next(
+ (player for player in board if player.player_name == player_record.display_name),
+ None,
+ )
+ if player_stats is None:
+ flash("That player has no ledger events yet.", "error")
+ return redirect(url_for("leagues.players", **league_url_values(league)))
+
+ player_rank = next(
+ (
+ index
+ for index, ranked_player in enumerate(board, start=1)
+ if ranked_player.player_name == player_record.display_name
+ ),
+ None,
+ )
+ player_sessions = [
+ {"session": session, "entry": entry}
+ for session in sorted(all_sessions, key=session_sort_key, reverse=True)
+ for entry in session.entries
+ if entry.player_name == player_record.display_name
+ ]
+
+ return render_template(
+ "league_player_detail.html",
+ league=league,
+ player_record=player_record,
+ player=player_stats,
+ player_rank=player_rank,
+ chart_data=player_session_series(all_sessions, player_record.display_name, league.break_even_cents),
+ player_sessions=player_sessions,
+ session_label=session_label,
+ session_ids=session_ref_map(league.id),
+ is_owner=is_owner,
+ can_manage=can_manage,
+ )
+
+
+@leagues_bp.route("/l//players", methods=["GET", "POST"])
+def players(league_ref: str):
+ if not db_ready():
+ flash("League database is not available.", "error")
+ return redirect(url_for("public.home"))
+
+ from boker.league_repositories import (
+ create_player,
+ list_players_for_league,
+ player_name_exists,
+ unique_player_slug,
+ user_has_league_role,
+ )
+
+ if request.method == "POST":
+ league = require_league(league_ref, {"owner", "manager"})
+ else:
+ league, resp = get_league_with_visibility_gate(league_ref)
+ if resp:
+ return resp
+ user_id = current_user_id() or ""
+ can_manage = user_has_league_role(user_id, league.id, {"owner", "manager"})
+ is_owner = user_has_league_role(user_id, league.id, {"owner"})
+ form = {
+ "display_name": request.form.get("display_name", "").strip(),
+ "slug": request.form.get("slug", "").strip(),
+ "notes": request.form.get("notes", "").strip(),
+ }
+
+ if request.method == "POST":
+ if len(form["display_name"]) < 2:
+ flash("Player name must be at least 2 characters.", "error")
+ elif player_name_exists(league.id, form["display_name"]):
+ flash("A player with that name already exists in this league.", "error")
+ else:
+ player = create_player(
+ league.id,
+ form["display_name"],
+ unique_player_slug(league.id, form["display_name"], form["slug"] or None),
+ )
+ player.notes = form["notes"] or None
+ db.session.commit()
+ flash("Player added.", "success")
+ return redirect(url_for("leagues.players", **league_url_values(league)))
+
+ from boker.ledger_repositories import list_event_rows_for_league
+
+ all_sessions = build_session_summaries(list_event_rows_for_league(league.id))
+ board = build_leaderboard(all_sessions, league.break_even_cents)
+ stats_by_name = {p.player_name: p for p in board}
+
+ return render_template(
+ "league_players.html",
+ league=league,
+ players=list_players_for_league(league.id),
+ stats_by_name=stats_by_name,
+ form=form,
+ can_manage=can_manage,
+ is_owner=is_owner,
+ )
+
+
+@leagues_bp.post("/l//players//archive")
+@login_required
+def archive_player(league_ref: str, player_id: str):
+ return update_player_status(league_ref, player_id, "archived", "Player archived.")
+
+
+@leagues_bp.post("/l//players//reactivate")
+@login_required
+def reactivate_player(league_ref: str, player_id: str):
+ return update_player_status(league_ref, player_id, "active", "Player reactivated.")
+
+
+def update_player_status(league_ref: str, player_id: str, status: str, message: str):
+ if not db_ready():
+ flash("League database is not available.", "error")
+ return redirect(url_for("public.home"))
+
+ from boker.league_repositories import find_player_for_league, set_player_status
+
+ league = require_league(league_ref, {"owner", "manager"})
+ player = find_player_for_league(league.id, player_id)
+ if player is None:
+ abort(404)
+
+ set_player_status(player, status)
+ db.session.commit()
+ flash(message, "success")
+ return redirect(url_for("leagues.players", **league_url_values(league)))
+
+
+@leagues_bp.post("/l//players//edit")
+@login_required
+def edit_player(league_ref: str, player_id: str):
+ if not db_ready():
+ flash("League database is not available.", "error")
+ return redirect(url_for("public.home"))
+
+ from boker.league_repositories import find_player_for_league, player_name_taken, update_player
+
+ league = require_league(league_ref, {"owner", "manager"})
+ player = find_player_for_league(league.id, player_id)
+ if player is None:
+ abort(404)
+
+ new_name = request.form.get("display_name", "").strip()
+ new_notes = request.form.get("notes", "").strip() or None
+
+ if len(new_name) < 2:
+ flash("Player name must be at least 2 characters.", "error")
+ elif player_name_taken(league.id, new_name, player.id):
+ flash("A player with that name already exists in this league.", "error")
+ else:
+ update_player(player, new_name, new_notes)
+ db.session.commit()
+ flash("Player updated.", "success")
+
+ return redirect(url_for("leagues.players", **league_url_values(league)))
+
+
+@leagues_bp.route("/l//sessions", methods=["GET", "POST"])
+def sessions(league_ref: str):
+ if not db_ready():
+ flash("League database is not available.", "error")
+ return redirect(url_for("public.home"))
+
+ from boker.ledger_repositories import append_ledger_event
+ from boker.league_repositories import (
+ create_poker_session,
+ list_seasons_for_league,
+ list_sessions_for_league,
+ user_has_league_role,
+ )
+
+ if request.method == "POST":
+ league = require_league(league_ref, {"owner", "manager"})
+ else:
+ league, resp = get_league_with_visibility_gate(league_ref)
+ if resp:
+ return resp
+ user_id = current_user_id() or ""
+ can_manage = user_has_league_role(user_id, league.id, {"owner", "manager"})
+ is_owner = user_has_league_role(user_id, league.id, {"owner"})
+ form = {
+ "session_date": request.form.get("session_date", date.today().isoformat()).strip(),
+ "label": request.form.get("label", "").strip(),
+ "notes": request.form.get("notes", "").strip(),
+ "status": request.form.get("status", "open").strip(),
+ "season_id": request.form.get("season_id", "").strip(),
+ }
+
+ if request.method == "POST":
+ try:
+ session_date = date.fromisoformat(form["session_date"])
+ except ValueError:
+ flash("Session date must be a valid date.", "error")
+ else:
+ status = "closed" if form["status"] == "closed" else "open"
+ season_id = form["season_id"] or None
+ if season_id is None:
+ from boker.league_repositories import auto_assign_sessions_to_seasons as _auto
+ # try auto-assign: create the session first, then let the
+ # function match it; we pass a temporary session date check inline
+ from boker.league_repositories import list_seasons_for_league as _ls
+ eligible = [
+ s for s in _ls(league.id, include_archived=False)
+ if s.start_date and s.end_date
+ and s.start_date <= session_date <= s.end_date
+ ]
+ if len(eligible) == 1:
+ season_id = eligible[0].id
+ session = create_poker_session(
+ league.id,
+ session_date,
+ season_id=season_id,
+ status=status,
+ )
+ session.label = form["label"] or None
+ session.notes = form["notes"] or None
+ if status == "open":
+ from boker.league_repositories import set_session_status
+
+ set_session_status(session, "open")
+
+ db.session.flush()
+ if status == "open":
+ append_ledger_event(
+ league.id,
+ session.id,
+ "session_open",
+ 0,
+ current_user_id() or "",
+ note="Session created.",
+ )
+ db.session.commit()
+ flash(f"Created {session.display_label}.", "success")
+ return redirect(url_for("leagues.sessions", **league_url_values(league)))
+
+ from boker.db_models import LedgerEvent
+ from boker.ledger_repositories import list_event_rows_for_league
+
+ all_sessions = list_sessions_for_league(league.id)
+ summaries = build_session_summaries(list_event_rows_for_league(league.id))
+ ref_to_db_id = {
+ f"{s.session_date.isoformat()}-{s.sequence_on_date:02d}": s.id
+ for s in all_sessions
+ }
+ db_id_to_summary = {
+ ref_to_db_id[sm.session_id]: sm
+ for sm in summaries
+ if sm.session_id in ref_to_db_id
+ }
+ seasons = list_seasons_for_league(league.id, include_archived=True)
+ season_map = {s.id: s for s in seasons}
+
+ selected_season_id = request.args.get("season", "").strip()
+ selected_season = season_map.get(selected_season_id) if selected_season_id else None
+ if selected_season:
+ display_sessions = [s for s in all_sessions if s.season_id == selected_season_id]
+ else:
+ selected_season_id = ""
+ display_sessions = all_sessions
+
+ empty_count = sum(
+ 1 for s in all_sessions
+ if s.id not in db_id_to_summary or not db_id_to_summary[s.id].entries
+ )
+
+ return render_template(
+ "league_sessions.html",
+ league=league,
+ sessions=display_sessions,
+ all_session_count=len(all_sessions),
+ db_id_to_summary=db_id_to_summary,
+ form=form,
+ can_manage=can_manage,
+ is_owner=is_owner,
+ empty_count=empty_count,
+ seasons=seasons,
+ season_map=season_map,
+ selected_season=selected_season,
+ selected_season_id=selected_season_id,
+ )
+
+
+@leagues_bp.post("/l//sessions/prune-empty")
+@login_required
+def prune_empty_sessions(league_ref: str):
+ if not db_ready():
+ flash("League database is not available.", "error")
+ return redirect(url_for("public.home"))
+
+ from boker.db_models import LedgerEvent, PokerSession
+ from boker.league_repositories import list_sessions_for_league
+
+ league = require_league(league_ref, {"owner", "manager"})
+
+ all_sessions = list_sessions_for_league(league.id)
+ session_ids_with_players = {
+ row[0]
+ for row in db.session.query(LedgerEvent.session_id).filter(
+ LedgerEvent.league_id == league.id,
+ LedgerEvent.player_id.isnot(None),
+ LedgerEvent.voided_at.is_(None),
+ ).all()
+ }
+ empty_sessions = [s for s in all_sessions if s.id not in session_ids_with_players]
+
+ if not empty_sessions:
+ flash("No empty sessions to prune.", "success")
+ return redirect(url_for("leagues.sessions", **league_url_values(league)))
+
+ for session in empty_sessions:
+ db.session.delete(session)
+ db.session.commit()
+
+ n = len(empty_sessions)
+ flash(f"Pruned {n} empty session{'s' if n != 1 else ''}.", "success")
+ return redirect(url_for("leagues.sessions", **league_url_values(league)))
+
+
+@leagues_bp.post("/l//sessions//delete")
+@login_required
+def delete_session(league_ref: str, session_id: str):
+ if not db_ready():
+ flash("League database is not available.", "error")
+ return redirect(url_for("public.home"))
+
+ from boker.league_repositories import find_session_for_league
+
+ league = require_league(league_ref, {"owner"})
+ session = find_session_for_league(league.id, session_id)
+ if session is None:
+ abort(404)
+ label = session.display_label
+ db.session.delete(session)
+ db.session.commit()
+ flash(f"Deleted {label} and all its events.", "success")
+ return redirect(url_for("leagues.sessions", **league_url_values(league)))
+
+
+@leagues_bp.post("/l//sessions//edit")
+@login_required
+def edit_session(league_ref: str, session_id: str):
+ if not db_ready():
+ flash("League database is not available.", "error")
+ return redirect(url_for("public.home"))
+
+ from boker.league_repositories import find_session_for_league
+
+ league = require_league(league_ref, {"owner", "manager"})
+ session = find_session_for_league(league.id, session_id)
+ if session is None:
+ abort(404)
+
+ new_label = request.form.get("label", "").strip() or None
+ new_notes = request.form.get("notes", "").strip() or None
+ new_date_str = request.form.get("session_date", "").strip()
+ new_season_id = request.form.get("season_id", "").strip() or None
+
+ try:
+ new_date = date.fromisoformat(new_date_str)
+ except ValueError:
+ flash("Invalid session date.", "error")
+ return redirect(url_for("leagues.session_detail", league_ref=league.url_ref, session_id=session_id))
+
+ if new_date != session.session_date:
+ from boker.db_models import PokerSession as _PS
+ max_seq = db.session.query(db.func.max(_PS.sequence_on_date)).filter(
+ _PS.league_id == league.id,
+ _PS.session_date == new_date,
+ _PS.id != session.id,
+ ).scalar()
+ session.sequence_on_date = int(max_seq or 0) + 1
+ session.session_date = new_date
+
+ if new_season_id is not None:
+ from boker.league_repositories import find_season
+ valid = find_season(league.id, new_season_id)
+ new_season_id = valid.id if valid else None
+
+ session.label = new_label
+ session.notes = new_notes
+ session.season_id = new_season_id
+ db.session.commit()
+ flash("Session updated.", "success")
+ return redirect(url_for("leagues.session_detail", league_ref=league.url_ref, session_id=session_id))
+
+
+@leagues_bp.route("/l//sessions/", methods=["GET", "POST"])
+@login_required
+def session_detail(league_ref: str, session_id: str):
+ if not db_ready():
+ flash("League database is not available.", "error")
+ return redirect(url_for("public.home"))
+
+ from boker.ledger_repositories import append_ledger_event, list_all_event_rows_for_session, list_event_rows_for_session
+ from boker.league_repositories import (
+ find_session_for_league,
+ list_players_for_league,
+ list_seasons_for_league,
+ user_has_league_role,
+ )
+
+ league = require_league(league_ref, {"owner", "manager"})
+ session = find_session_for_league(league.id, session_id)
+ if session is None:
+ abort(404)
+
+ can_manage = user_has_league_role(current_user_id() or "", league.id, {"owner", "manager"})
+ is_owner = user_has_league_role(current_user_id() or "", league.id, {"owner"})
+ players = [player for player in list_players_for_league(league.id) if player.status == "active"]
+ append_form = {
+ "player_id": request.form.get("player_id", "").strip(),
+ "event_type": request.form.get("event_type", "buyin").strip(),
+ "amount": request.form.get("amount", "").strip(),
+ "note": request.form.get("note", "").strip(),
+ }
+
+ if request.method == "POST":
+ if session.status != "open":
+ flash("That session is closed. Reopen it before adding events.", "error")
+ elif append_form["event_type"] not in {event_type for event_type, _ in LEAGUE_EVENT_TYPES}:
+ flash("Select a valid event type.", "error")
+ else:
+ amount_cents = parse_amount_cents(append_form["amount"])
+ if amount_cents is None:
+ flash("Amount must be a number.", "error")
+ elif amount_cents <= 0 and append_form["event_type"] != "note":
+ flash("Amount must be greater than zero.", "error")
+ else:
+ player_id = append_form["player_id"] or None
+ if append_form["event_type"] != "note" and player_id is None:
+ flash("Select a player for this event.", "error")
+ else:
+ if player_id is not None and not any(player.id == player_id for player in players):
+ abort(404)
+
+ append_ledger_event(
+ league.id,
+ session.id,
+ append_form["event_type"],
+ amount_cents,
+ current_user_id() or "",
+ player_id=player_id,
+ note=append_form["note"],
+ )
+ db.session.commit()
+ flash("Ledger event added.", "success")
+ return redirect(
+ url_for(
+ "leagues.session_detail",
+ **league_url_values(league),
+ session_id=session.id,
+ )
+ )
+
+ rows = list_event_rows_for_session(league.id, session.id)
+ summaries = build_session_summaries(rows)
+ summary = summaries[0] if summaries else empty_session_summary(session)
+ all_rows = list_all_event_rows_for_session(league.id, session.id)
+
+ seasons = list_seasons_for_league(league.id, include_archived=True)
+ return render_template(
+ "league_session_detail.html",
+ league=league,
+ session_model=session,
+ session=summary,
+ players=players,
+ raw_events=all_rows,
+ event_types=LEAGUE_EVENT_TYPES,
+ append_form=append_form,
+ can_manage=can_manage,
+ is_owner=is_owner,
+ session_label=session_label,
+ seasons=seasons,
+ )
+
+
+@leagues_bp.get("/l//sessions//view")
+def session_public_view(league_ref: str, session_id: str):
+ if not db_ready():
+ flash("League database is not available.", "error")
+ return redirect(url_for("public.home"))
+
+ from boker.charts import session_breakdown_series
+ from boker.ledger_repositories import list_event_rows_for_league, list_event_rows_for_session
+ from boker.league_repositories import find_league_by_public_key, find_season, find_session_for_league, list_sessions_for_league, user_has_league_role
+
+ _slug, public_key = split_league_ref(league_ref)
+ league = find_league_by_public_key(public_key)
+ if league is None:
+ abort(404)
+
+ if league.visibility != "public":
+ user_id = current_user_id()
+ if not user_id:
+ return redirect(url_for("account.login", next=request.path))
+ if not user_has_league_role(user_id, league.id, {"owner", "manager", "viewer"}):
+ abort(403)
+
+ user_id = current_user_id() or ""
+ can_manage = user_has_league_role(user_id, league.id, {"owner", "manager"})
+ is_owner = user_has_league_role(user_id, league.id, {"owner"})
+
+ session = find_session_for_league(league.id, session_id)
+ if session is None:
+ abort(404)
+
+ rows = list_event_rows_for_session(league.id, session.id)
+ summaries = build_session_summaries(rows)
+ summary = summaries[0] if summaries else empty_session_summary(session)
+
+ all_sessions = sorted(
+ build_session_summaries(list_event_rows_for_league(league.id)),
+ key=session_sort_key,
+ )
+ session_ref = f"{session.session_date.isoformat()}-{session.sequence_on_date:02d}"
+ chrono_idx = next((i for i, s in enumerate(all_sessions) if s.session_id == session_ref), 0)
+ session_number = chrono_idx + 1
+
+ all_db_sessions = list_sessions_for_league(league.id)
+ ref_to_db_id = {
+ f"{s.session_date.isoformat()}-{s.sequence_on_date:02d}": s.id
+ for s in all_db_sessions
+ }
+
+ prev_summary = all_sessions[chrono_idx - 1] if chrono_idx > 0 else None
+ next_summary = all_sessions[chrono_idx + 1] if chrono_idx < len(all_sessions) - 1 else None
+
+ season = find_season(league.id, session.season_id) if session.season_id else None
+
+ return render_template(
+ "league_session_view.html",
+ league=league,
+ session_model=session,
+ session=summary,
+ session_number=session_number,
+ raw_events=session_events(rows, summary.session_id),
+ chart_data=session_breakdown_series(summary, league.break_even_cents),
+ session_label=session_label,
+ prev_session_id=ref_to_db_id.get(prev_summary.session_id) if prev_summary else None,
+ next_session_id=ref_to_db_id.get(next_summary.session_id) if next_summary else None,
+ can_manage=can_manage,
+ is_owner=is_owner,
+ season=season,
+ )
+
+
+@leagues_bp.post("/l//sessions//open")
+@login_required
+def open_league_session(league_ref: str, session_id: str):
+ return update_session_status(league_ref, session_id, "open", "Session opened.")
+
+
+@leagues_bp.post("/l//sessions//close")
+@login_required
+def close_league_session(league_ref: str, session_id: str):
+ return update_session_status(league_ref, session_id, "closed", "Session closed.")
+
+
+def update_session_status(league_ref: str, session_id: str, status: str, message: str):
+ if not db_ready():
+ flash("League database is not available.", "error")
+ return redirect(url_for("public.home"))
+
+ from boker.ledger_repositories import append_ledger_event
+ from boker.league_repositories import find_session_for_league, set_session_status
+
+ league = require_league(league_ref, {"owner", "manager"})
+ session = find_session_for_league(league.id, session_id)
+ if session is None:
+ abort(404)
+
+ if session.status == status:
+ flash(f"Session is already {status}.", "success")
+ return redirect(url_for("leagues.sessions", **league_url_values(league)))
+
+ set_session_status(session, status)
+ append_ledger_event(
+ league.id,
+ session.id,
+ "session_open" if status == "open" else "session_close",
+ 0,
+ current_user_id() or "",
+ note=message,
+ )
+ db.session.commit()
+ flash(message, "success")
+ return redirect(url_for("leagues.sessions", **league_url_values(league)))
+
+
+@leagues_bp.route("/l//seasons", methods=["GET", "POST"])
+def seasons(league_ref: str):
+ if not db_ready():
+ flash("League database is not available.", "error")
+ return redirect(url_for("public.home"))
+
+ from boker.league_repositories import (
+ create_season,
+ list_seasons_for_league,
+ user_has_league_role,
+ )
+
+ if request.method == "POST":
+ league = require_league(league_ref, {"owner", "manager"})
+ else:
+ league, resp = get_league_with_visibility_gate(league_ref)
+ if resp:
+ return resp
+
+ user_id = current_user_id() or ""
+ can_manage = user_has_league_role(user_id, league.id, {"owner", "manager"})
+ is_owner = user_has_league_role(user_id, league.id, {"owner"})
+
+ name = request.form.get("name", "").strip()
+ start_raw = request.form.get("start_date", "").strip()
+ end_raw = request.form.get("end_date", "").strip()
+
+ if request.method == "POST":
+ if not name:
+ flash("Season name is required.", "error")
+ else:
+ try:
+ start_date = date.fromisoformat(start_raw) if start_raw else None
+ end_date = date.fromisoformat(end_raw) if end_raw else None
+ except ValueError:
+ flash("Invalid date format.", "error")
+ start_date = end_date = None
+ else:
+ existing = list_seasons_for_league(league.id, include_archived=True)
+ create_season(
+ league.id,
+ name,
+ start_date=start_date,
+ end_date=end_date,
+ sort_order=len(existing),
+ )
+ db.session.commit()
+ flash(f"Season \"{name}\" created.", "success")
+ return redirect(url_for("leagues.seasons", **league_url_values(league)))
+
+ active_seasons = list_seasons_for_league(league.id, include_archived=False)
+ archived_seasons = list_seasons_for_league(league.id, include_archived=True)
+ archived_seasons = [s for s in archived_seasons if s.archived_at is not None]
+ return render_template(
+ "league_seasons.html",
+ league=league,
+ active_seasons=active_seasons,
+ archived_seasons=archived_seasons,
+ form={
+ "name": name if request.method == "POST" else "",
+ "start_date": start_raw if request.method == "POST" else "",
+ "end_date": end_raw if request.method == "POST" else "",
+ },
+ can_manage=can_manage,
+ is_owner=is_owner,
+ )
+
+
+@leagues_bp.post("/l//seasons/auto-assign")
+@login_required
+def auto_assign_seasons(league_ref: str):
+ if not db_ready():
+ flash("League database is not available.", "error")
+ return redirect(url_for("public.home"))
+
+ from boker.league_repositories import auto_assign_sessions_to_seasons
+
+ league = require_league(league_ref, {"owner", "manager"})
+ count = auto_assign_sessions_to_seasons(league.id)
+ db.session.commit()
+ if count:
+ flash(f"Assigned {count} session{'s' if count != 1 else ''} to seasons.", "success")
+ else:
+ flash("No sessions could be auto-assigned. Check that your seasons have start and end dates set, and that unassigned sessions fall within exactly one season.", "info")
+ return redirect(url_for("leagues.seasons", **league_url_values(league)))
+
+
+@leagues_bp.post("/l//seasons//update")
+@login_required
+def update_season(league_ref: str, season_id: str):
+ if not db_ready():
+ flash("League database is not available.", "error")
+ return redirect(url_for("public.home"))
+
+ from boker.league_repositories import find_season, update_season as repo_update_season
+
+ league = require_league(league_ref, {"owner", "manager"})
+ season = find_season(league.id, season_id)
+ if season is None:
+ flash("Season not found.", "error")
+ return redirect(url_for("leagues.seasons", **league_url_values(league)))
+
+ name = request.form.get("name", "").strip()
+ start_raw = request.form.get("start_date", "").strip()
+ end_raw = request.form.get("end_date", "").strip()
+
+ if not name:
+ flash("Season name is required.", "error")
+ return redirect(url_for("leagues.seasons", **league_url_values(league)))
+
+ try:
+ start_date = date.fromisoformat(start_raw) if start_raw else None
+ end_date = date.fromisoformat(end_raw) if end_raw else None
+ except ValueError:
+ flash("Invalid date format.", "error")
+ return redirect(url_for("leagues.seasons", **league_url_values(league)))
+
+ repo_update_season(season, name, start_date=start_date, end_date=end_date)
+ db.session.commit()
+ flash(f"Season \"{name}\" updated.", "success")
+ return redirect(url_for("leagues.seasons", **league_url_values(league)))
+
+
+@leagues_bp.post("/l//seasons//archive")
+@login_required
+def archive_season(league_ref: str, season_id: str):
+ if not db_ready():
+ flash("League database is not available.", "error")
+ return redirect(url_for("public.home"))
+
+ from boker.league_repositories import archive_season as repo_archive, find_season
+
+ league = require_league(league_ref, {"owner", "manager"})
+ season = find_season(league.id, season_id)
+ if season is None:
+ flash("Season not found.", "error")
+ return redirect(url_for("leagues.seasons", **league_url_values(league)))
+
+ repo_archive(season)
+ db.session.commit()
+ flash(f"Season \"{season.name}\" archived.", "success")
+ return redirect(url_for("leagues.seasons", **league_url_values(league)))
+
+
+@leagues_bp.post("/l//seasons//unarchive")
+@login_required
+def unarchive_season(league_ref: str, season_id: str):
+ if not db_ready():
+ flash("League database is not available.", "error")
+ return redirect(url_for("public.home"))
+
+ from boker.league_repositories import find_season, unarchive_season as repo_unarchive
+
+ league = require_league(league_ref, {"owner", "manager"})
+ season = find_season(league.id, season_id)
+ if season is None:
+ flash("Season not found.", "error")
+ return redirect(url_for("leagues.seasons", **league_url_values(league)))
+
+ repo_unarchive(season)
+ db.session.commit()
+ flash(f"Season \"{season.name}\" restored.", "success")
+ return redirect(url_for("leagues.seasons", **league_url_values(league)))
+
+
+@leagues_bp.post("/l//seasons//delete")
+@login_required
+def delete_season(league_ref: str, season_id: str):
+ if not db_ready():
+ flash("League database is not available.", "error")
+ return redirect(url_for("public.home"))
+
+ from boker.league_repositories import delete_season as repo_delete, find_season
+
+ league = require_league(league_ref, {"owner", "manager"})
+ season = find_season(league.id, season_id)
+ if season is None:
+ flash("Season not found.", "error")
+ return redirect(url_for("leagues.seasons", **league_url_values(league)))
+
+ name = season.name
+ repo_delete(season)
+ db.session.commit()
+ flash(f"Season \"{name}\" deleted. Sessions in this season were unassigned.", "success")
+ return redirect(url_for("leagues.seasons", **league_url_values(league)))
+
+
+@leagues_bp.route("/l//settings", methods=["GET", "POST"])
+@login_required
+def league_settings(league_ref: str):
+ if not db_ready():
+ flash("League database is not available.", "error")
+ return redirect(url_for("public.home"))
+
+ league = require_league(league_ref, {"owner"})
+
+ form = {
+ "name": league.name,
+ "description": league.description or "",
+ "visibility": league.visibility,
+ "eligible_min_sessions": league.eligible_min_sessions,
+ "break_even_dollars": f"{league.break_even_cents / 100:.2f}",
+ }
+
+ if request.method == "POST":
+ raw_eligible = request.form.get("eligible_min_sessions", "3").strip()
+ raw_break_even = request.form.get("break_even_dollars", "1.00").strip()
+ form = {
+ "name": request.form.get("name", "").strip(),
+ "description": request.form.get("description", "").strip(),
+ "visibility": request.form.get("visibility", "private").strip(),
+ "eligible_min_sessions": raw_eligible,
+ "break_even_dollars": raw_break_even,
+ }
+ try:
+ eligible_min = int(raw_eligible)
+ break_even_cents = round(float(raw_break_even) * 100)
+ except (ValueError, TypeError):
+ flash("Eligible sessions and break-even threshold must be valid numbers.", "error")
+ else:
+ if len(form["name"]) < 2:
+ flash("League name must be at least 2 characters.", "error")
+ elif form["visibility"] not in ("private", "public"):
+ flash("Invalid visibility value.", "error")
+ elif eligible_min < 1 or eligible_min > 100:
+ flash("Eligible minimum must be between 1 and 100.", "error")
+ elif break_even_cents < 0 or break_even_cents > 10000:
+ flash("Break-even threshold must be between $0.00 and $100.00.", "error")
+ else:
+ from boker.utils import slugify
+
+ league.name = form["name"]
+ league.slug = slugify(form["name"])
+ league.description = form["description"] or None
+ league.visibility = form["visibility"]
+ league.eligible_min_sessions = eligible_min
+ league.break_even_cents = break_even_cents
+ db.session.commit()
+ flash("League settings saved.", "success")
+ return redirect(url_for("leagues.league_settings", league_ref=league.url_ref))
+
+ from boker.league_repositories import list_members_for_league
+
+ members = list_members_for_league(league.id)
+ return render_template("league_settings.html", league=league, form=form, is_owner=True, can_manage=True, members=members)
+
+
+@leagues_bp.post("/l//settings/invite")
+@login_required
+def invite_member(league_ref: str):
+ if not db_ready():
+ flash("League database is not available.", "error")
+ return redirect(url_for("public.home"))
+
+ from boker.auth import generate_invite_token
+ from boker.emails import send_league_invite
+ from flask import current_app
+ from boker.db_models import User
+ from boker.league_repositories import find_membership, find_user_by_email
+
+ league = require_league(league_ref, {"owner"})
+ email = normalize_email(request.form.get("email", ""))
+ role = request.form.get("role", "manager").strip()
+
+ if not email or "@" not in email:
+ flash("Enter a valid email address.", "error")
+ return redirect(url_for("leagues.league_settings", league_ref=league.url_ref))
+
+ if role not in ("manager", "viewer"):
+ flash("Invalid role.", "error")
+ return redirect(url_for("leagues.league_settings", league_ref=league.url_ref))
+
+ existing_user = find_user_by_email(email)
+ if existing_user:
+ existing_membership = find_membership(league.id, existing_user.id)
+ if existing_membership:
+ flash(f"{email} is already a member of this league.", "error")
+ return redirect(url_for("leagues.league_settings", league_ref=league.url_ref))
+
+ token = generate_invite_token(league.id, email, role, current_user_id() or "")
+ base_url = current_app.config.get("APP_BASE_URL", "").rstrip("/")
+ invite_url = f"{base_url}{url_for('account.accept_invite', token=token)}"
+ inviter = db.session.get(User, current_user_id() or "")
+ inviter_email = inviter.email if inviter is not None else "A league owner"
+
+ try:
+ send_league_invite(email, league.name, invite_url, inviter_email, role)
+ flash(f"{role.title()} invitation sent to {email}.", "success")
+ except Exception:
+ flash("Failed to send invitation email. Check your mail configuration.", "error")
+
+ return redirect(url_for("leagues.league_settings", league_ref=league.url_ref))
+
+
+@leagues_bp.post("/l//settings/members//remove")
+@login_required
+def remove_member(league_ref: str, user_id: str):
+ if not db_ready():
+ flash("League database is not available.", "error")
+ return redirect(url_for("public.home"))
+
+ from boker.league_repositories import remove_league_member
+
+ league = require_league(league_ref, {"owner"})
+
+ if user_id == current_user_id():
+ flash("You cannot remove yourself from the league.", "error")
+ return redirect(url_for("leagues.league_settings", league_ref=league.url_ref))
+
+ remove_league_member(league.id, user_id)
+ db.session.commit()
+ flash("Member removed.", "success")
+ return redirect(url_for("leagues.league_settings", league_ref=league.url_ref))
+
+
+@leagues_bp.post("/l//settings/members//role")
+@login_required
+def update_member_role(league_ref: str, user_id: str):
+ if not db_ready():
+ flash("League database is not available.", "error")
+ return redirect(url_for("public.home"))
+
+ from boker.league_repositories import set_league_member_role
+
+ league = require_league(league_ref, {"owner"})
+ role = request.form.get("role", "").strip()
+
+ if user_id == current_user_id():
+ flash("You cannot change your own league role.", "error")
+ return redirect(url_for("leagues.league_settings", league_ref=league.url_ref))
+
+ if role not in {"manager", "viewer"}:
+ flash("Invalid member role.", "error")
+ return redirect(url_for("leagues.league_settings", league_ref=league.url_ref))
+
+ membership = set_league_member_role(league.id, user_id, role)
+ if membership is None:
+ flash("That member cannot be updated.", "error")
+ return redirect(url_for("leagues.league_settings", league_ref=league.url_ref))
+
+ db.session.commit()
+ flash(f"Member changed to {role}.", "success")
+ return redirect(url_for("leagues.league_settings", league_ref=league.url_ref))
+
+
+@leagues_bp.post("/l//settings/transfer-ownership")
+@login_required
+def transfer_ownership(league_ref: str):
+ if not db_ready():
+ flash("League database is not available.", "error")
+ return redirect(url_for("public.home"))
+
+ from boker.league_repositories import transfer_league_ownership
+
+ league = require_league(league_ref, {"owner"})
+ new_owner_user_id = request.form.get("new_owner_user_id", "").strip()
+ current_owner_user_id = current_user_id() or ""
+
+ if not new_owner_user_id:
+ flash("Choose a member to transfer ownership to.", "error")
+ return redirect(url_for("leagues.league_settings", league_ref=league.url_ref))
+
+ transfer = transfer_league_ownership(league, current_owner_user_id, new_owner_user_id)
+ if transfer is None:
+ flash("Ownership can only be transferred to an active member.", "error")
+ return redirect(url_for("leagues.league_settings", league_ref=league.url_ref))
+
+ _old_owner, _new_owner = transfer
+ db.session.commit()
+ flash("League ownership transferred. Your role is now manager.", "success")
+ return redirect(url_for("leagues.dashboard", league_ref=league.url_ref))
+
+
+@leagues_bp.post("/l//archive")
+@login_required
+def archive_league(league_ref: str):
+ if not db_ready():
+ flash("League database is not available.", "error")
+ return redirect(url_for("public.home"))
+
+ from boker.db_models import utc_now
+
+ league = require_league(league_ref, {"owner"})
+ confirm_name = request.form.get("confirm_name", "").strip()
+
+ if confirm_name != league.name:
+ flash("League name did not match. Archive cancelled.", "error")
+ return redirect(url_for("leagues.league_settings", league_ref=league_ref))
+
+ league.archived_at = utc_now()
+ db.session.commit()
+ flash(f'"{league.name}" has been archived.', "success")
+ return redirect(url_for("leagues.index"))
+
+
+@leagues_bp.post("/l//delete")
+@login_required
+def delete_league_route(league_ref: str):
+ if not db_ready():
+ flash("League database is not available.", "error")
+ return redirect(url_for("public.home"))
+
+ from boker.league_repositories import delete_league
+
+ league = require_league(league_ref, {"owner"})
+ confirm_name = request.form.get("confirm_name", "").strip()
+
+ if confirm_name != league.name:
+ flash("League name did not match. Deletion cancelled.", "error")
+ return redirect(url_for("leagues.league_settings", league_ref=league_ref))
+
+ league_name = league.name
+ delete_league(league)
+ db.session.commit()
+ flash(f'"{league_name}" and all its data have been permanently deleted.', "success")
+ return redirect(url_for("leagues.index"))
+
+
+@leagues_bp.get("/l//ledger/export")
+@login_required
+def export_ledger_csv(league_ref: str):
+ if not db_ready():
+ flash("League database is not available.", "error")
+ return redirect(url_for("public.home"))
+
+ from boker.ledger_repositories import list_event_rows_for_league
+ from boker.storage import CSV_HEADERS
+
+ league = require_league(league_ref, {"owner", "manager"})
+ rows = list_event_rows_for_league(league.id)
+
+ buf = io.StringIO()
+ writer = csv.DictWriter(buf, fieldnames=CSV_HEADERS)
+ writer.writeheader()
+ for row in rows:
+ writer.writerow({k: row[k] for k in CSV_HEADERS})
+
+ filename = f"ledger_{league.slug}_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.csv"
+ response = make_response(buf.getvalue())
+ response.headers["Content-Type"] = "text/csv; charset=utf-8"
+ response.headers["Content-Disposition"] = f"attachment; filename={filename}"
+ return response
+
+
+@leagues_bp.post("/l//ledger/import")
+@login_required
+def import_ledger_csv(league_ref: str):
+ if not db_ready():
+ flash("League database is not available.", "error")
+ return redirect(url_for("public.home"))
+
+ from boker.db_models import CANONICAL_EVENT_TYPES, canonical_event_type
+ from boker.ledger_repositories import (
+ append_ledger_event,
+ list_ledger_events_for_league,
+ session_event_ref,
+ )
+ from boker.league_repositories import create_player, list_players_for_league, list_sessions_for_league, unique_player_slug
+ from boker.storage import CSV_HEADERS
+
+ league = require_league(league_ref, {"owner", "manager"})
+
+ uploaded = request.files.get("csv_file")
+ if uploaded is None or uploaded.filename == "":
+ flash("Choose a CSV file to import.", "error")
+ return redirect(url_for("leagues.ledger", **league_url_values(league)))
+
+ if not uploaded.filename.lower().endswith(".csv"):
+ flash("Only CSV files are accepted.", "error")
+ return redirect(url_for("leagues.ledger", **league_url_values(league)))
+
+ try:
+ text = uploaded.stream.read().decode("utf-8-sig")
+ except UnicodeDecodeError:
+ flash("CSV must be valid UTF-8.", "error")
+ return redirect(url_for("leagues.ledger", **league_url_values(league)))
+
+ lines = text.splitlines()
+ if not lines:
+ flash("CSV file is empty.", "error")
+ return redirect(url_for("leagues.ledger", **league_url_values(league)))
+
+ try:
+ header = next(csv.reader(lines))
+ except Exception:
+ flash("Could not parse CSV header.", "error")
+ return redirect(url_for("leagues.ledger", **league_url_values(league)))
+
+ if header != CSV_HEADERS:
+ flash(f"CSV header mismatch. Expected: {', '.join(CSV_HEADERS)}", "error")
+ return redirect(url_for("leagues.ledger", **league_url_values(league)))
+
+ from boker.db_models import PokerSession, make_session
+
+ sessions = list_sessions_for_league(league.id)
+ session_by_ref = {session_event_ref(s): s for s in sessions}
+
+ players = list_players_for_league(league.id)
+ player_by_name = {p.display_name.strip().casefold(): p for p in players}
+
+ existing_legacy_ids = {
+ e.legacy_event_id
+ for e in list_ledger_events_for_league(league.id)
+ if e.legacy_event_id
+ }
+
+ # Auto-create any sessions referenced in the CSV that don't exist yet.
+ # Refs are "YYYY-MM-DD-NN" so we can recover exact date and sequence.
+ missing_refs: set[str] = set()
+ for row in csv.DictReader(lines):
+ ref = row.get("session_id", "").strip()
+ if ref and ref not in session_by_ref:
+ missing_refs.add(ref)
+
+ sessions_created = 0
+ for ref in sorted(missing_refs):
+ try:
+ date_part, seq_part = ref.rsplit("-", 1)
+ session_date = date.fromisoformat(date_part)
+ sequence = int(seq_part)
+ except (ValueError, IndexError):
+ continue # malformed ref — row will produce a clear error below
+
+ existing = PokerSession.query.filter_by(
+ league_id=league.id,
+ session_date=session_date,
+ sequence_on_date=sequence,
+ ).one_or_none()
+ if existing:
+ session_by_ref[ref] = existing
+ continue
+
+ new_session = make_session(
+ league_id=league.id,
+ session_date=session_date,
+ sequence_on_date=sequence,
+ status="closed",
+ )
+ db.session.add(new_session)
+ db.session.flush()
+ session_by_ref[ref] = new_session
+ sessions_created += 1
+
+ # Auto-create any players referenced in the CSV that don't exist yet.
+ # Only rows that need a player (not note/session_open/session_close) are considered.
+ non_player_types = {"note", "session_open", "session_close"}
+ missing_players: set[str] = set()
+ for row in csv.DictReader(lines):
+ raw_type = row.get("event_type", "").strip()
+ if canonical_event_type(raw_type) in non_player_types:
+ continue
+ name = row.get("player_name", "").strip()
+ if name and name.casefold() not in player_by_name:
+ missing_players.add(name)
+
+ players_created = 0
+ for name in sorted(missing_players):
+ slug = unique_player_slug(league.id, name)
+ new_player = create_player(league.id, name, slug)
+ db.session.flush()
+ player_by_name[name.casefold()] = new_player
+ players_created += 1
+
+ errors: list[str] = []
+ queued: list[dict] = []
+ skipped = 0
+
+ for row_num, row in enumerate(csv.DictReader(lines), start=2):
+ event_id = row.get("id", "").strip()
+ if event_id and event_id in existing_legacy_ids:
+ skipped += 1
+ continue
+
+ raw_type = row.get("event_type", "").strip()
+ canonical = canonical_event_type(raw_type)
+ if canonical not in CANONICAL_EVENT_TYPES:
+ errors.append(f"Row {row_num}: unknown event_type '{raw_type}'")
+ continue
+
+ try:
+ amount_cents = int(row.get("amount_cents", "0"))
+ except ValueError:
+ errors.append(f"Row {row_num}: invalid amount_cents '{row.get('amount_cents')}'")
+ continue
+
+ session_ref = row.get("session_id", "").strip()
+ session = session_by_ref.get(session_ref)
+ if session is None:
+ errors.append(f"Row {row_num}: session '{session_ref}' not found in this league")
+ continue
+
+ player_name = row.get("player_name", "").strip()
+ player_id = None
+ if canonical not in {"note", "session_open", "session_close"}:
+ player = player_by_name.get(player_name.casefold())
+ if player is None:
+ errors.append(f"Row {row_num}: player '{player_name}' not found in this league")
+ continue
+ player_id = player.id
+
+ queued.append({
+ "session_id": session.id,
+ "event_type": canonical,
+ "amount_cents": amount_cents,
+ "player_id": player_id,
+ "note": row.get("note", "").strip() or None,
+ "legacy_event_id": event_id or None,
+ "legacy_player_name": player_name or None,
+ })
+
+ if errors:
+ for msg in errors[:5]:
+ flash(msg, "error")
+ if len(errors) > 5:
+ flash(f"… and {len(errors) - 5} more error(s). Nothing was imported.", "error")
+ return redirect(url_for("leagues.ledger", **league_url_values(league)))
+
+ for ev in queued:
+ append_ledger_event(
+ league.id,
+ ev["session_id"],
+ ev["event_type"],
+ ev["amount_cents"],
+ current_user_id() or "",
+ player_id=ev["player_id"],
+ note=ev["note"],
+ legacy_event_id=ev["legacy_event_id"],
+ legacy_player_name=ev["legacy_player_name"],
+ )
+
+ db.session.commit()
+ s_count = f"{len(queued)} event{'s' if len(queued) != 1 else ''}"
+ s_skip = f"{skipped} duplicate{'s' if skipped != 1 else ''}"
+ parts = [f"Imported {s_count}", f"skipped {s_skip}"]
+ if sessions_created:
+ parts.append(f"created {sessions_created} session{'s' if sessions_created != 1 else ''}")
+ if players_created:
+ parts.append(f"created {players_created} player{'s' if players_created != 1 else ''}")
+ flash(", ".join(parts) + ".", "success")
+ return redirect(url_for("leagues.ledger", **league_url_values(league)))
diff --git a/routes/public.py b/boker/routes/public.py
similarity index 84%
rename from routes/public.py
rename to boker/routes/public.py
index 93bef28..063b7fb 100644
--- a/routes/public.py
+++ b/boker/routes/public.py
@@ -4,23 +4,50 @@ from __future__ import annotations
from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask import current_app
-from charts import cumulative_profit_series, player_session_series, session_breakdown_series
-from config import ELIGIBLE_MIN_SESSIONS
-from services import (
+from boker.charts import cumulative_profit_series, player_session_series, session_breakdown_series
+from boker.config import ELIGIBLE_MIN_SESSIONS
+from boker.services import (
apply_rank_changes,
build_leaderboard,
build_session_summaries,
session_events,
)
-from storage import load_events
-from utils import session_label, session_sort_key
+from boker.storage import load_events
+from boker.utils import session_label, session_sort_key
public_bp = Blueprint("public", __name__)
@public_bp.get("/")
def home():
- return redirect(url_for("public.leaderboard"))
+ return render_template("landing.html")
+
+
+@public_bp.get("/help")
+def help():
+ return render_template("support.html")
+
+
+@public_bp.get("/privacy")
+def privacy():
+ return render_template("privacy.html")
+
+
+@public_bp.get("/terms")
+def terms():
+ return render_template("terms.html")
+
+
+@public_bp.get("/explore")
+def explore():
+ from boker.auth import is_logged_in
+ from boker.db import database_extensions_available
+ from boker.league_repositories import league_counts, list_public_leagues
+
+ q = request.args.get("q", "").strip()
+ leagues = list_public_leagues(q) if database_extensions_available() else []
+ counts = {league.id: league_counts(league.id) for league in leagues}
+ return render_template("explore.html", leagues=leagues, counts=counts, q=q)
@public_bp.get("/leaderboard")
diff --git a/services.py b/boker/services.py
similarity index 95%
rename from services.py
rename to boker/services.py
index c364759..424116c 100644
--- a/services.py
+++ b/boker/services.py
@@ -4,9 +4,9 @@ from __future__ import annotations
from collections import defaultdict
-from models import PlayerStats, SessionEntry, SessionSummary
-from storage import EventRow
-from utils import entry_sort_key, net_result_bucket, session_sort_key
+from boker.models import PlayerStats, SessionEntry, SessionSummary
+from boker.storage import EventRow
+from boker.utils import entry_sort_key, net_result_bucket, session_sort_key
SESSION_MARKER_TYPES = {"session_open", "session_close"}
@@ -103,7 +103,7 @@ def build_session_summaries(events: list[EventRow]) -> list[SessionSummary]:
return sessions
-def summarize_player_runs(entries: list[SessionEntry]) -> dict[str, int | str | None]:
+def summarize_player_runs(entries: list[SessionEntry], break_even_cents: int = 100) -> dict[str, int | str | None]:
ordered_entries = sorted(entries, key=entry_sort_key)
longest_win_streak = 0
@@ -116,7 +116,7 @@ def summarize_player_runs(entries: list[SessionEntry]) -> dict[str, int | str |
for entry in ordered_entries:
net = entry.net_cents
- bucket = net_result_bucket(net)
+ bucket = net_result_bucket(net, break_even_cents)
if best_entry is None or net > best_entry.net_cents:
best_entry = entry
@@ -141,7 +141,7 @@ def summarize_player_runs(entries: list[SessionEntry]) -> dict[str, int | str |
current_loss_streak = 0
for entry in reversed(ordered_entries):
- bucket = net_result_bucket(entry.net_cents)
+ bucket = net_result_bucket(entry.net_cents, break_even_cents)
if bucket == "win":
if current_loss_streak > 0:
@@ -166,7 +166,7 @@ def summarize_player_runs(entries: list[SessionEntry]) -> dict[str, int | str |
}
-def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]:
+def build_leaderboard(sessions: list[SessionSummary], break_even_cents: int = 100) -> list[PlayerStats]:
player_entries: dict[str, list[SessionEntry]] = defaultdict(list)
for session in sessions:
for entry in session.entries:
@@ -175,9 +175,9 @@ def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]:
leaderboard: list[PlayerStats] = []
for player_name, entries in player_entries.items():
nets = [entry.net_cents for entry in entries]
- run_summary = summarize_player_runs(entries)
- wins = [value for value in nets if net_result_bucket(value) == "win"]
- losses = [value for value in nets if net_result_bucket(value) == "loss"]
+ run_summary = summarize_player_runs(entries, break_even_cents)
+ wins = [value for value in nets if net_result_bucket(value, break_even_cents) == "win"]
+ losses = [value for value in nets if net_result_bucket(value, break_even_cents) == "loss"]
sessions_played = len(entries)
winning_sessions = len(wins)
diff --git a/storage.py b/boker/storage.py
similarity index 96%
rename from storage.py
rename to boker/storage.py
index 0cc324a..341aa36 100644
--- a/storage.py
+++ b/boker/storage.py
@@ -30,6 +30,8 @@ class EventRow(TypedDict):
amount_cents: int
note: str
actor: str
+ voided_at: str
+ void_reason: str
VALID_EVENT_TYPES = {
@@ -79,6 +81,8 @@ def load_events(csv_path: Path) -> list[EventRow]:
amount_cents=int(row["amount_cents"] or 0),
note=row.get("note", ""),
actor=row.get("actor", ""),
+ voided_at="",
+ void_reason="",
)
)
diff --git a/utils.py b/boker/utils.py
similarity index 84%
rename from utils.py
rename to boker/utils.py
index badd71f..516aa06 100644
--- a/utils.py
+++ b/boker/utils.py
@@ -3,14 +3,20 @@
from __future__ import annotations
from datetime import datetime
+import re
from markupsafe import Markup
-from models import SessionEntry, SessionSummary
+from boker.models import SessionEntry, SessionSummary
BREAK_EVEN_TOLERANCE_CENTS = 100
+def slugify(value: str, fallback: str = "league") -> str:
+ slug = re.sub(r"[^a-z0-9]+", "-", value.strip().casefold()).strip("-")
+ return slug or fallback
+
+
def cents_to_dollars(cents: int) -> Markup:
value = cents / 100
return Markup(f'$ {value:,.2f}')
@@ -76,9 +82,9 @@ def entry_sort_key(entry: SessionEntry) -> tuple[str, int, str]:
)
-def net_result_bucket(value_cents: int) -> str:
- if value_cents > BREAK_EVEN_TOLERANCE_CENTS:
+def net_result_bucket(value_cents: int, tolerance_cents: int = BREAK_EVEN_TOLERANCE_CENTS) -> str:
+ if value_cents > tolerance_cents:
return "win"
- if value_cents < -BREAK_EVEN_TOLERANCE_CENTS:
+ if value_cents < -tolerance_cents:
return "loss"
return "even"
diff --git a/config.py b/config.py
deleted file mode 100644
index 0df2473..0000000
--- a/config.py
+++ /dev/null
@@ -1,40 +0,0 @@
-#!/usr/bin/env python3
-from __future__ import annotations
-
-import os
-from pathlib import Path
-
-BASE_DIR = Path(__file__).resolve().parent
-DATA_PATH = BASE_DIR / "data" / "entries.csv"
-
-ELIGIBLE_MIN_SESSIONS = 3
-APP_VERSION = "1.8.6"
-
-
-def load_local_env(env_path: Path) -> None:
- if not env_path.exists():
- return
-
- for raw_line in env_path.read_text(encoding="utf-8").splitlines():
- line = raw_line.strip()
- if not line or line.startswith("#") or "=" not in line:
- continue
-
- key, value = line.split("=", 1)
- key = key.strip()
- value = value.strip().strip('"').strip("'")
- os.environ.setdefault(key, value)
-
-
-load_local_env(BASE_DIR / ".env")
-
-
-class Config:
- APP_VERSION: str = APP_VERSION
- SECRET_KEY: str = os.getenv("SECRET_KEY", "change-this-before-deploying")
- ADMIN_USERNAME: str = os.getenv("ADMIN_USERNAME", "admin")
- ADMIN_PASSWORD: str = os.getenv("ADMIN_PASSWORD", "change-me")
- SESSION_COOKIE_HTTPONLY: bool = True
- SESSION_COOKIE_SAMESITE: str = "Lax"
- SESSION_COOKIE_NAME: str = "poker_portal_session"
- DATA_PATH: Path = DATA_PATH
diff --git a/migrations/README b/migrations/README
new file mode 100644
index 0000000..a251733
--- /dev/null
+++ b/migrations/README
@@ -0,0 +1,13 @@
+Single-database Alembic environment managed through Flask-Migrate.
+
+Install dependencies with:
+
+ pip install -r requirements.txt
+
+Then run migrations with:
+
+ flask --app app db upgrade
+
+For a quick local development bootstrap without Alembic, the app also exposes:
+
+ flask --app app init-db
diff --git a/migrations/alembic.ini b/migrations/alembic.ini
new file mode 100644
index 0000000..056afa6
--- /dev/null
+++ b/migrations/alembic.ini
@@ -0,0 +1,39 @@
+# A generic, single-database Alembic config used by Flask-Migrate.
+
+[alembic]
+script_location = migrations
+prepend_sys_path = .
+
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
diff --git a/migrations/env.py b/migrations/env.py
new file mode 100644
index 0000000..689bbaa
--- /dev/null
+++ b/migrations/env.py
@@ -0,0 +1,55 @@
+from __future__ import annotations
+
+from logging.config import fileConfig
+
+from alembic import context
+from flask import current_app
+
+config = context.config
+
+if config.config_file_name is not None:
+ fileConfig(config.config_file_name)
+
+target_db = current_app.extensions["migrate"].db
+target_metadata = target_db.metadata
+
+
+def get_engine():
+ try:
+ return target_db.engine
+ except AttributeError:
+ return target_db.get_engine()
+
+
+def get_url() -> str:
+ return str(get_engine().url).replace("%", "%%")
+
+
+def run_migrations_offline() -> None:
+ context.configure(
+ url=get_url(),
+ target_metadata=target_metadata,
+ literal_binds=True,
+ compare_type=True,
+ )
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+def run_migrations_online() -> None:
+ with get_engine().connect() as connection:
+ context.configure(
+ connection=connection,
+ target_metadata=target_metadata,
+ compare_type=True,
+ )
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
diff --git a/migrations/script.py.mako b/migrations/script.py.mako
new file mode 100644
index 0000000..0d0804f
--- /dev/null
+++ b/migrations/script.py.mako
@@ -0,0 +1,25 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+"""
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
+depends_on = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+ ${downgrades if downgrades else "pass"}
diff --git a/migrations/versions/0001_public_foundation.py b/migrations/versions/0001_public_foundation.py
new file mode 100644
index 0000000..5057e0e
--- /dev/null
+++ b/migrations/versions/0001_public_foundation.py
@@ -0,0 +1,190 @@
+"""public multi-league foundation
+
+Revision ID: 0001_public_foundation
+Revises:
+Create Date: 2026-06-23
+"""
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision = "0001_public_foundation"
+down_revision = None
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "users",
+ sa.Column("id", sa.String(length=36), nullable=False),
+ sa.Column("email", sa.String(length=255), nullable=False),
+ sa.Column("password_hash", sa.String(length=255), nullable=False),
+ sa.Column("email_verified_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("disabled_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("email"),
+ )
+ op.create_index(op.f("ix_users_email"), "users", ["email"], unique=False)
+
+ op.create_table(
+ "leagues",
+ sa.Column("id", sa.String(length=36), nullable=False),
+ sa.Column("name", sa.String(length=160), nullable=False),
+ sa.Column("slug", sa.String(length=120), nullable=False),
+ sa.Column("description", sa.Text(), nullable=True),
+ sa.Column("visibility", sa.String(length=20), nullable=False),
+ sa.Column("created_by_user_id", sa.String(length=36), nullable=False),
+ sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
+ sa.CheckConstraint("visibility IN ('private', 'public')", name="ck_leagues_visibility"),
+ sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="RESTRICT"),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.create_index(op.f("ix_leagues_created_by_user_id"), "leagues", ["created_by_user_id"], unique=False)
+ op.create_index(op.f("ix_leagues_slug"), "leagues", ["slug"], unique=False)
+
+ op.create_table(
+ "league_memberships",
+ sa.Column("id", sa.String(length=36), nullable=False),
+ sa.Column("league_id", sa.String(length=36), nullable=False),
+ sa.Column("user_id", sa.String(length=36), nullable=False),
+ sa.Column("role", sa.String(length=20), nullable=False),
+ sa.Column("invited_by_user_id", sa.String(length=36), nullable=True),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("disabled_at", sa.DateTime(timezone=True), nullable=True),
+ sa.CheckConstraint("role IN ('owner', 'manager', 'viewer')", name="ck_memberships_role"),
+ sa.ForeignKeyConstraint(["invited_by_user_id"], ["users.id"], ondelete="SET NULL"),
+ sa.ForeignKeyConstraint(["league_id"], ["leagues.id"], ondelete="CASCADE"),
+ sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("league_id", "user_id", name="uq_memberships_league_user"),
+ )
+ op.create_index(op.f("ix_league_memberships_league_id"), "league_memberships", ["league_id"], unique=False)
+ op.create_index(op.f("ix_league_memberships_user_id"), "league_memberships", ["user_id"], unique=False)
+
+ op.create_table(
+ "players",
+ sa.Column("id", sa.String(length=36), nullable=False),
+ sa.Column("league_id", sa.String(length=36), nullable=False),
+ sa.Column("display_name", sa.String(length=160), nullable=False),
+ sa.Column("normalized_name", sa.String(length=160), nullable=False),
+ sa.Column("slug", sa.String(length=120), nullable=False),
+ sa.Column("status", sa.String(length=20), nullable=False),
+ sa.Column("linked_user_id", sa.String(length=36), nullable=True),
+ sa.Column("notes", sa.Text(), nullable=True),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
+ sa.CheckConstraint("status IN ('active', 'archived')", name="ck_players_status"),
+ sa.ForeignKeyConstraint(["league_id"], ["leagues.id"], ondelete="CASCADE"),
+ sa.ForeignKeyConstraint(["linked_user_id"], ["users.id"], ondelete="SET NULL"),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("league_id", "id", name="uq_players_league_id"),
+ sa.UniqueConstraint("league_id", "normalized_name", name="uq_players_name"),
+ sa.UniqueConstraint("league_id", "slug", name="uq_players_slug"),
+ )
+ op.create_index(op.f("ix_players_league_id"), "players", ["league_id"], unique=False)
+
+ op.create_table(
+ "seasons",
+ sa.Column("id", sa.String(length=36), nullable=False),
+ sa.Column("league_id", sa.String(length=36), nullable=False),
+ sa.Column("name", sa.String(length=160), nullable=False),
+ sa.Column("start_date", sa.Date(), nullable=True),
+ sa.Column("end_date", sa.Date(), nullable=True),
+ sa.Column("sort_order", sa.Integer(), nullable=False),
+ sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
+ sa.CheckConstraint("start_date IS NULL OR end_date IS NULL OR start_date <= end_date", name="ck_seasons_date_order"),
+ sa.ForeignKeyConstraint(["league_id"], ["leagues.id"], ondelete="CASCADE"),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("league_id", "id", name="uq_seasons_league_id"),
+ sa.UniqueConstraint("league_id", "name", name="uq_seasons_name"),
+ )
+ op.create_index(op.f("ix_seasons_league_id"), "seasons", ["league_id"], unique=False)
+
+ op.create_table(
+ "sessions",
+ sa.Column("id", sa.String(length=36), nullable=False),
+ sa.Column("league_id", sa.String(length=36), nullable=False),
+ sa.Column("season_id", sa.String(length=36), nullable=True),
+ sa.Column("session_date", sa.Date(), nullable=False),
+ sa.Column("sequence_on_date", sa.Integer(), nullable=False),
+ sa.Column("label", sa.String(length=80), nullable=True),
+ sa.Column("status", sa.String(length=20), nullable=False),
+ sa.Column("opened_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("notes", sa.Text(), nullable=True),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
+ sa.CheckConstraint("sequence_on_date > 0", name="ck_sessions_sequence_positive"),
+ sa.CheckConstraint("status IN ('open', 'closed')", name="ck_sessions_status"),
+ sa.ForeignKeyConstraint(["league_id"], ["leagues.id"], ondelete="CASCADE"),
+ sa.ForeignKeyConstraint(["league_id", "season_id"], ["seasons.league_id", "seasons.id"], name="fk_sessions_season_same_league", ondelete="RESTRICT"),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("league_id", "id", name="uq_sessions_league_id"),
+ sa.UniqueConstraint("league_id", "session_date", "sequence_on_date", name="uq_sessions_date_sequence"),
+ )
+ op.create_index(op.f("ix_sessions_league_id"), "sessions", ["league_id"], unique=False)
+ op.create_index(op.f("ix_sessions_session_date"), "sessions", ["session_date"], unique=False)
+
+ op.create_table(
+ "ledger_events",
+ sa.Column("id", sa.String(length=36), nullable=False),
+ sa.Column("league_id", sa.String(length=36), nullable=False),
+ sa.Column("session_id", sa.String(length=36), nullable=False),
+ sa.Column("player_id", sa.String(length=36), nullable=True),
+ sa.Column("event_type", sa.String(length=40), nullable=False),
+ sa.Column("amount_cents", sa.Integer(), nullable=False),
+ sa.Column("note", sa.Text(), nullable=True),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("created_by_user_id", sa.String(length=36), nullable=False),
+ sa.Column("legacy_event_id", sa.String(length=80), nullable=True),
+ sa.Column("legacy_player_name", sa.String(length=160), nullable=True),
+ sa.Column("voided_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("voided_by_user_id", sa.String(length=36), nullable=True),
+ sa.Column("void_reason", sa.Text(), nullable=True),
+ sa.CheckConstraint("event_type IN ('buyin', 'cashout', 'debt_repayment', 'front', 'front_collected', 'front_writeoff', 'note', 'paid', 'paid_out', 'payout_carry_in', 'rollover_in', 'rollover_out', 'session_close', 'session_open', 'writeoff')", name="ck_events_type"),
+ sa.CheckConstraint("player_id IS NOT NULL OR event_type IN ('note', 'session_open', 'session_close')", name="ck_events_player_required"),
+ sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="RESTRICT"),
+ sa.ForeignKeyConstraint(["league_id", "player_id"], ["players.league_id", "players.id"], name="fk_events_player_same_league", ondelete="RESTRICT"),
+ sa.ForeignKeyConstraint(["league_id", "session_id"], ["sessions.league_id", "sessions.id"], name="fk_events_session_same_league", ondelete="CASCADE"),
+ sa.ForeignKeyConstraint(["voided_by_user_id"], ["users.id"], ondelete="SET NULL"),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.create_index(op.f("ix_ledger_events_league_id"), "ledger_events", ["league_id"], unique=False)
+ op.create_index(op.f("ix_ledger_events_player_id"), "ledger_events", ["player_id"], unique=False)
+ op.create_index(op.f("ix_ledger_events_session_id"), "ledger_events", ["session_id"], unique=False)
+ op.create_index("ix_events_league_created", "ledger_events", ["league_id", "created_at"], unique=False)
+ op.create_index("ix_events_league_session_created", "ledger_events", ["league_id", "session_id", "created_at"], unique=False)
+
+
+def downgrade() -> None:
+ op.drop_index("ix_events_league_session_created", table_name="ledger_events")
+ op.drop_index("ix_events_league_created", table_name="ledger_events")
+ op.drop_index(op.f("ix_ledger_events_session_id"), table_name="ledger_events")
+ op.drop_index(op.f("ix_ledger_events_player_id"), table_name="ledger_events")
+ op.drop_index(op.f("ix_ledger_events_league_id"), table_name="ledger_events")
+ op.drop_table("ledger_events")
+ op.drop_index(op.f("ix_sessions_session_date"), table_name="sessions")
+ op.drop_index(op.f("ix_sessions_league_id"), table_name="sessions")
+ op.drop_table("sessions")
+ op.drop_index(op.f("ix_seasons_league_id"), table_name="seasons")
+ op.drop_table("seasons")
+ op.drop_index(op.f("ix_players_league_id"), table_name="players")
+ op.drop_table("players")
+ op.drop_index(op.f("ix_league_memberships_user_id"), table_name="league_memberships")
+ op.drop_index(op.f("ix_league_memberships_league_id"), table_name="league_memberships")
+ op.drop_table("league_memberships")
+ op.drop_index(op.f("ix_leagues_slug"), table_name="leagues")
+ op.drop_index(op.f("ix_leagues_created_by_user_id"), table_name="leagues")
+ op.drop_table("leagues")
+ op.drop_index(op.f("ix_users_email"), table_name="users")
+ op.drop_table("users")
diff --git a/migrations/versions/0002_league_public_key.py b/migrations/versions/0002_league_public_key.py
new file mode 100644
index 0000000..fba7488
--- /dev/null
+++ b/migrations/versions/0002_league_public_key.py
@@ -0,0 +1,31 @@
+"""add league public key
+
+Revision ID: 0002_league_public_key
+Revises: 0001_public_foundation
+Create Date: 2026-06-23
+"""
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision = "0002_league_public_key"
+down_revision = "0001_public_foundation"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column("leagues", sa.Column("public_key", sa.String(length=12), nullable=True))
+ op.execute("UPDATE leagues SET public_key = substr(replace(id, '-', ''), 1, 12) WHERE public_key IS NULL")
+
+ with op.batch_alter_table("leagues") as batch_op:
+ batch_op.alter_column("public_key", existing_type=sa.String(length=12), nullable=False)
+ batch_op.create_index("ix_leagues_public_key", ["public_key"], unique=True)
+
+
+def downgrade() -> None:
+ with op.batch_alter_table("leagues") as batch_op:
+ batch_op.drop_index("ix_leagues_public_key")
+ batch_op.drop_column("public_key")
diff --git a/migrations/versions/0003_league_settings.py b/migrations/versions/0003_league_settings.py
new file mode 100644
index 0000000..2a632b8
--- /dev/null
+++ b/migrations/versions/0003_league_settings.py
@@ -0,0 +1,32 @@
+"""add per-league eligible_min_sessions and break_even_cents
+
+Revision ID: 0003_league_settings
+Revises: 0002_league_public_key
+Create Date: 2026-06-26
+"""
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision = "0003_league_settings"
+down_revision = "0003_site_admin"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ with op.batch_alter_table("leagues") as batch_op:
+ batch_op.add_column(sa.Column(
+ "eligible_min_sessions", sa.Integer(), nullable=False, server_default="3"
+ ))
+ batch_op.add_column(sa.Column(
+ "break_even_cents", sa.Integer(), nullable=False, server_default="100"
+ ))
+
+
+def downgrade() -> None:
+ with op.batch_alter_table("leagues") as batch_op:
+ batch_op.drop_column("break_even_cents")
+ batch_op.drop_column("eligible_min_sessions")
diff --git a/migrations/versions/0003_site_admin.py b/migrations/versions/0003_site_admin.py
new file mode 100644
index 0000000..576e402
--- /dev/null
+++ b/migrations/versions/0003_site_admin.py
@@ -0,0 +1,23 @@
+"""site admin (stub — already applied)
+
+Revision ID: 0003_site_admin
+Revises: 0002_league_public_key
+Create Date: 2026-06-26
+"""
+from __future__ import annotations
+
+from alembic import op
+
+
+revision = "0003_site_admin"
+down_revision = "0002_league_public_key"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ pass
+
+
+def downgrade() -> None:
+ pass
diff --git a/migrations/versions/0004_email_verification_codes.py b/migrations/versions/0004_email_verification_codes.py
new file mode 100644
index 0000000..f6ac9ac
--- /dev/null
+++ b/migrations/versions/0004_email_verification_codes.py
@@ -0,0 +1,28 @@
+"""add email verification code fields
+
+Revision ID: 0004_email_verification_codes
+Revises: 0003_league_settings
+Create Date: 2026-06-27
+"""
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision = "0004_email_verification_codes"
+down_revision = "0003_league_settings"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ with op.batch_alter_table("users") as batch_op:
+ batch_op.add_column(sa.Column("email_verification_code_hash", sa.String(length=255), nullable=True))
+ batch_op.add_column(sa.Column("email_verification_sent_at", sa.DateTime(timezone=True), nullable=True))
+
+
+def downgrade() -> None:
+ with op.batch_alter_table("users") as batch_op:
+ batch_op.drop_column("email_verification_sent_at")
+ batch_op.drop_column("email_verification_code_hash")
diff --git a/migrations/versions/0005_site_admin_accounts.py b/migrations/versions/0005_site_admin_accounts.py
new file mode 100644
index 0000000..34674d8
--- /dev/null
+++ b/migrations/versions/0005_site_admin_accounts.py
@@ -0,0 +1,52 @@
+"""add account-backed site admin flag
+
+Revision ID: 0005_site_admin_accounts
+Revises: 0004_email_verification_codes
+Create Date: 2026-06-28
+"""
+from __future__ import annotations
+
+from alembic import context, op
+import sqlalchemy as sa
+
+
+revision = "0005_site_admin_accounts"
+down_revision = "0004_email_verification_codes"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ if context.is_offline_mode():
+ op.add_column(
+ "users",
+ sa.Column(
+ "is_site_admin",
+ sa.Boolean(),
+ nullable=False,
+ server_default=sa.false(),
+ ),
+ )
+ return
+
+ columns = {column["name"] for column in sa.inspect(op.get_bind()).get_columns("users")}
+ if "is_site_admin" not in columns:
+ op.add_column(
+ "users",
+ sa.Column(
+ "is_site_admin",
+ sa.Boolean(),
+ nullable=False,
+ server_default=sa.false(),
+ )
+ )
+
+
+def downgrade() -> None:
+ if context.is_offline_mode():
+ op.drop_column("users", "is_site_admin")
+ return
+
+ columns = {column["name"] for column in sa.inspect(op.get_bind()).get_columns("users")}
+ if "is_site_admin" in columns:
+ op.drop_column("users", "is_site_admin")
diff --git a/requirements.txt b/requirements.txt
index 22ac75b..f5af794 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1 +1,9 @@
Flask==3.1.0
+Flask-Limiter>=3.5,<4.0
+Flask-Mail>=0.10,<1.0
+Flask-Migrate>=4.0,<5.0
+Flask-SQLAlchemy>=3.1,<4.0
+Flask-WTF>=1.2,<2.0
+gunicorn>=22,<24
+psycopg[binary]>=3.2,<4.0
+redis>=5,<6
diff --git a/routes/admin.py b/routes/admin.py
deleted file mode 100644
index 08f44ab..0000000
--- a/routes/admin.py
+++ /dev/null
@@ -1,583 +0,0 @@
-#!/usr/bin/env python3
-from __future__ import annotations
-
-import csv
-from datetime import datetime, timezone
-
-from flask import (
- Blueprint,
- current_app,
- flash,
- redirect,
- render_template,
- request,
- send_file,
- session as flask_session,
- url_for,
-)
-
-from auth import is_admin
-from services import (
- build_session_summaries,
- next_session_id,
- pending_payout_carry_items,
- prunable_empty_session_ids,
- unique_player_names,
-)
-from storage import CSV_HEADERS, append_event, load_events, write_events
-from utils import cents_to_dollars, session_label
-
-admin_bp = Blueprint("admin", __name__)
-
-ADMIN_EVENT_TYPES = [
- ("buyin", "Buy-in", "var(--accent)"),
- ("front", "Front", "var(--warn)"),
- ("rollover_in", "Rollover-in", "var(--rolled)"),
- ("payout_carry_in", "Payout carry-in", "var(--rolled)"),
- ("cashout", "Cashout result", "var(--rank-2)"),
- ("paid_out", "Paid out cash", "var(--pos)"),
- ("rollover_out", "Rollover-out", "var(--rolled)"),
- ("note", "Note", "var(--muted)"),
-]
-
-
-@admin_bp.route("/admin/login", methods=["GET", "POST"])
-def login():
- if request.method == "POST":
- username = request.form.get("username", "").strip()
- password = request.form.get("password", "")
-
- if (
- username == current_app.config["ADMIN_USERNAME"]
- and password == current_app.config["ADMIN_PASSWORD"]
- ):
- flask_session["is_admin"] = True
- flash("Admin login successful.", "success")
- return redirect(url_for("admin.dashboard"))
-
- flash("Invalid admin credentials.", "error")
-
- return render_template("admin_login.html")
-
-
-@admin_bp.post("/admin/logout")
-def logout():
- flask_session.clear()
- flash("Logged out.", "success")
- return redirect(url_for("public.leaderboard"))
-
-
-@admin_bp.post("/admin/session-state")
-def session_state():
- if not is_admin():
- flash("Admin login required.", "error")
- return redirect(url_for("admin.login"))
-
- data_path = current_app.config["DATA_PATH"]
- session_id = request.form.get("session_id", "").strip()
- state = request.form.get("state", "").strip()
-
- events = load_events(data_path)
- sessions = build_session_summaries(events)
- by_id = {s.session_id: s for s in sessions}
- target = by_id.get(session_id)
-
- if target is None:
- flash("Session not found.", "error")
- return redirect(url_for("admin.dashboard"))
-
- if state not in {"open", "closed"}:
- flash("Invalid session state.", "error")
- return redirect(url_for("admin.dashboard"))
-
- append_event(
- data_path,
- session_id=target.session_id,
- session_date=target.session_date,
- amount_cents=0,
- player_name="",
- event_type="session_open" if state == "open" else "session_close",
- note=f"Session marked {state}.",
- actor=current_app.config["ADMIN_USERNAME"],
- )
-
- flash(f"Session marked {state}.", "success")
- return redirect(url_for("admin.dashboard"))
-
-
-@admin_bp.get("/admin/export")
-@admin_bp.get("/admin/export.csv")
-def export_csv():
- if not is_admin():
- flash("Admin login required.", "error")
- return redirect(url_for("admin.login"))
-
- data_path = current_app.config["DATA_PATH"]
- export_name = (
- f"entries_export_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.csv"
- )
-
- return send_file(
- data_path,
- as_attachment=True,
- download_name=export_name,
- mimetype="text/csv",
- )
-
-
-@admin_bp.post("/admin/import")
-def import_csv():
- if not is_admin():
- flash("Admin login required.", "error")
- return redirect(url_for("admin.login"))
-
- data_path = current_app.config["DATA_PATH"]
- uploaded_file = request.files.get("csv_file")
- if uploaded_file is None or uploaded_file.filename == "":
- flash("Choose a CSV file to import.", "error")
- return redirect(url_for("admin.dashboard"))
-
- if not uploaded_file.filename.lower().endswith(".csv"):
- flash("Only CSV files are allowed.", "error")
- return redirect(url_for("admin.dashboard"))
-
- try:
- uploaded_text = uploaded_file.stream.read().decode("utf-8-sig")
- except UnicodeDecodeError:
- flash("CSV file must be valid UTF-8 text.", "error")
- return redirect(url_for("admin.dashboard"))
-
- uploaded_lines = uploaded_text.splitlines()
- if not uploaded_lines:
- flash("CSV file is empty.", "error")
- return redirect(url_for("admin.dashboard"))
-
- try:
- uploaded_header = next(csv.reader(uploaded_lines))
- except Exception:
- flash("Could not read the uploaded CSV header.", "error")
- return redirect(url_for("admin.dashboard"))
-
- if uploaded_header != CSV_HEADERS:
- flash("CSV header does not match the current audit format.", "error")
- return redirect(url_for("admin.dashboard"))
-
- try:
- existing_ids = {event["id"] for event in load_events(data_path)}
- reader = csv.DictReader(uploaded_lines)
- new_rows = []
- skipped_count = 0
- for row in reader:
- event_id = row.get("id", "")
- if not event_id or event_id in existing_ids:
- skipped_count += 1
- continue
-
- new_rows.append({header: row.get(header, "") for header in CSV_HEADERS})
- existing_ids.add(event_id)
-
- if new_rows:
- with open(data_path, "a", encoding="utf-8", newline="") as handle:
- writer = csv.DictWriter(handle, fieldnames=CSV_HEADERS)
- writer.writerows(new_rows)
-
- flash(
- f"Imported {len(new_rows)} new events, skipped {skipped_count} duplicates.",
- "success",
- )
- except Exception:
- flash("Import failed. Existing CSV was not updated.", "error")
-
- return redirect(url_for("admin.dashboard"))
-
-
-@admin_bp.post("/admin/write-off-front")
-def write_off_front():
- if not is_admin():
- flash("Admin login required.", "error")
- return redirect(url_for("admin.login"))
-
- data_path = current_app.config["DATA_PATH"]
- debt_key = request.form.get("debt_key", "").strip()
- note = request.form.get("note", "").strip()
- amount_raw = request.form.get("amount", "0").strip()
-
- try:
- amount_cents = int(round(float(amount_raw) * 100))
- except ValueError:
- flash("Amount must be a number.", "error")
- return redirect(url_for("admin.dashboard"))
-
- if amount_cents <= 0:
- flash("Write-off amount must be greater than zero.", "error")
- return redirect(url_for("admin.dashboard"))
-
- try:
- session_id, player_name = debt_key.split("||", 1)
- except ValueError:
- flash("Select a valid player debt.", "error")
- return redirect(url_for("admin.dashboard"))
-
- events = load_events(data_path)
- sessions = build_session_summaries(events)
- target = next((s for s in sessions if s.session_id == session_id), None)
-
- if target is None:
- flash("Session not found.", "error")
- return redirect(url_for("admin.dashboard"))
-
- entry = next(
- (e for e in target.entries if e.player_name == player_name),
- None,
- )
-
- if entry is None or entry.player_owes_cents <= 0:
- flash("That player does not have an outstanding debt.", "error")
- return redirect(url_for("admin.dashboard"))
-
- if amount_cents > entry.player_owes_cents:
- flash(
- f"Write-off cannot exceed {cents_to_dollars(entry.player_owes_cents)}.",
- "error",
- )
- return redirect(url_for("admin.dashboard"))
-
- append_event(
- data_path,
- session_id=target.session_id,
- session_date=target.session_date,
- player_name=entry.player_name,
- event_type="writeoff",
- amount_cents=amount_cents,
- note=note or "Debt written off.",
- actor=current_app.config["ADMIN_USERNAME"],
- )
-
- flash("Debt written off.", "success")
- return redirect(url_for("admin.dashboard"))
-
-
-@admin_bp.post("/admin/collect-front")
-def collect_front():
- if not is_admin():
- flash("Admin login required.", "error")
- return redirect(url_for("admin.login"))
-
- data_path = current_app.config["DATA_PATH"]
- debt_key = request.form.get("debt_key", "").strip()
- note = request.form.get("note", "").strip()
- amount_raw = request.form.get("amount", "0").strip()
-
- try:
- amount_cents = int(round(float(amount_raw) * 100))
- except ValueError:
- flash("Amount must be a number.", "error")
- return redirect(url_for("admin.dashboard"))
-
- if amount_cents <= 0:
- flash("Collected amount must be greater than zero.", "error")
- return redirect(url_for("admin.dashboard"))
-
- try:
- session_id, player_name = debt_key.split("||", 1)
- except ValueError:
- flash("Select a valid player debt.", "error")
- return redirect(url_for("admin.dashboard"))
-
- events = load_events(data_path)
- sessions = build_session_summaries(events)
- target = next((s for s in sessions if s.session_id == session_id), None)
-
- if target is None:
- flash("Session not found.", "error")
- return redirect(url_for("admin.dashboard"))
-
- entry = next(
- (e for e in target.entries if e.player_name == player_name),
- None,
- )
-
- if entry is None or entry.player_owes_cents <= 0:
- flash("That player does not have an outstanding debt.", "error")
- return redirect(url_for("admin.dashboard"))
-
- if amount_cents > entry.player_owes_cents:
- flash(
- f"Collection cannot exceed {cents_to_dollars(entry.player_owes_cents)}.",
- "error",
- )
- return redirect(url_for("admin.dashboard"))
-
- append_event(
- data_path,
- session_id=target.session_id,
- session_date=target.session_date,
- player_name=entry.player_name,
- event_type="debt_repayment",
- amount_cents=amount_cents,
- note=note or "Debt repayment collected.",
- actor=current_app.config["ADMIN_USERNAME"],
- )
-
- flash("Debt repayment collected.", "success")
- return redirect(url_for("admin.dashboard"))
-
-
-@admin_bp.post("/admin/open-session")
-def open_session():
- if not is_admin():
- flash("Admin login required.", "error")
- return redirect(url_for("admin.login"))
-
- data_path = current_app.config["DATA_PATH"]
- session_date = request.form.get("session_date", "").strip()
- if not session_date:
- flash("Session date is required.", "error")
- return redirect(url_for("admin.dashboard"))
-
- events = load_events(data_path)
- sessions = build_session_summaries(events)
- new_session_id = next_session_id(sessions, session_date)
-
- append_event(
- data_path,
- session_id=new_session_id,
- session_date=session_date,
- amount_cents=0,
- player_name="",
- event_type="session_open",
- note="Session opened.",
- actor=current_app.config["ADMIN_USERNAME"],
- )
-
- flash(f"Opened session {new_session_id}.", "success")
- return redirect(url_for("admin.dashboard"))
-
-
-@admin_bp.post("/admin/prune-empty-sessions")
-def prune_empty_sessions():
- if not is_admin():
- flash("Admin login required.", "error")
- return redirect(url_for("admin.login"))
-
- data_path = current_app.config["DATA_PATH"]
- events = load_events(data_path)
- sessions = build_session_summaries(events)
- prunable_ids = prunable_empty_session_ids(events, sessions)
-
- if not prunable_ids:
- flash("No empty marker-only sessions to prune.", "success")
- return redirect(url_for("admin.dashboard"))
-
- pruned_events = [
- event
- for event in events
- if (event["session_id"].strip() or event["session_date"].strip())
- not in prunable_ids
- ]
- write_events(data_path, pruned_events)
-
- flash(
- f"Pruned {len(prunable_ids)} empty session"
- f"{'s' if len(prunable_ids) != 1 else ''}.",
- "success",
- )
- return redirect(url_for("admin.dashboard"))
-
-
-@admin_bp.post("/admin/apply-payout-carry-in")
-def apply_payout_carry_in():
- if not is_admin():
- flash("Admin login required.", "error")
- return redirect(url_for("admin.login"))
-
- data_path = current_app.config["DATA_PATH"]
- session_id = request.form.get("session_id", "").strip()
- player_name = request.form.get("player_name", "").strip()
-
- events = load_events(data_path)
- sessions = build_session_summaries(events)
- by_id = {s.session_id: s for s in sessions}
- target = by_id.get(session_id)
-
- if target is None or target.status != "open":
- flash("Select a valid open session.", "error")
- return redirect(url_for("admin.dashboard"))
-
- if not any(e.player_name == player_name for e in target.entries):
- flash("Add that player to the open session before applying carry-in.", "error")
- return redirect(url_for("admin.dashboard"))
-
- pending_by_player = {
- str(item["player_name"]): int(item["amount_cents"])
- for item in pending_payout_carry_items(sessions)
- }
- amount_cents = pending_by_player.get(player_name, 0)
-
- if amount_cents <= 0:
- flash("No pending payout carry-in found for that player.", "error")
- return redirect(url_for("admin.dashboard"))
-
- append_event(
- data_path,
- session_id=target.session_id,
- session_date=target.session_date,
- player_name=player_name,
- event_type="payout_carry_in",
- amount_cents=amount_cents,
- note="Payout carry-in from prior carry-out.",
- actor=current_app.config["ADMIN_USERNAME"],
- )
-
- flash(
- f"Applied {cents_to_dollars(amount_cents)} payout carry-in for {player_name}.",
- "success",
- )
- return redirect(url_for("admin.dashboard"))
-
-
-@admin_bp.route("/admin", methods=["GET", "POST"])
-def dashboard():
- if not is_admin():
- return redirect(url_for("admin.login"))
-
- data_path = current_app.config["DATA_PATH"]
-
- if request.method == "POST":
- session_id = request.form.get("session_id", "").strip()
- player_name = request.form.get("player_name", "").strip()
- event_type = request.form.get("event_type", "").strip()
- note = request.form.get("note", "").strip()
-
- amount_raw = request.form.get("amount", "0").strip()
- try:
- amount_cents = int(round(float(amount_raw) * 100))
- except ValueError:
- flash("Amount must be a number.", "error")
- return redirect(url_for("admin.dashboard"))
-
- events = load_events(data_path)
- sessions = build_session_summaries(events)
- by_id = {s.session_id: s for s in sessions}
- target = by_id.get(session_id)
-
- if target is None:
- flash("Select a valid open session.", "error")
- return redirect(url_for("admin.dashboard"))
-
- if target.status != "open":
- flash(
- "That session is closed. Reopen it first if you need to add events.",
- "error",
- )
- return redirect(url_for("admin.dashboard"))
-
- append_event(
- data_path,
- session_id=target.session_id,
- session_date=target.session_date,
- player_name=player_name,
- event_type=event_type,
- amount_cents=amount_cents,
- note=note,
- actor=current_app.config["ADMIN_USERNAME"],
- )
-
- flash("Event added.", "success")
- return redirect(
- url_for(
- "admin.dashboard",
- session_id=session_id,
- player_name=player_name,
- event_type=event_type,
- )
- )
-
- events = load_events(data_path)
- sessions = build_session_summaries(events)
- empty_session_count = len(prunable_empty_session_ids(events, sessions))
- pending_carry_items = pending_payout_carry_items(sessions)
- pending_carry_total_cents = sum(
- int(item["amount_cents"]) for item in pending_carry_items
- )
- open_sessions = [s for s in sessions if s.status == "open"]
- live_session = open_sessions[0] if open_sessions else None
- live_session_player_names = (
- {e.player_name for e in live_session.entries}
- if live_session is not None
- else set()
- )
- live_session_no = ""
- live_unresolved_count = 0
- if live_session is not None:
- live_session_no = f"#{len(sessions):03d}"
- live_unresolved_count = sum(
- 1
- for e in live_session.entries
- if e.current_due_cents > 0 or e.player_owes_cents > 0
- )
-
- recent_events = list(reversed(events[-20:]))
- event_type_colors = {value: color for value, _label, color in ADMIN_EVENT_TYPES}
- event_type_labels = {value: label for value, label, _color in ADMIN_EVENT_TYPES}
- selected_event_type = request.args.get("event_type", "buyin").strip()
- if selected_event_type not in event_type_colors:
- selected_event_type = "buyin"
- append_form = {
- "session_id": request.args.get("session_id", "").strip(),
- "player_name": request.args.get("player_name", "").strip(),
- "event_type": selected_event_type,
- }
- debt_entries = [
- {"session": s, "entry": e}
- for s in sessions
- for e in s.entries
- if e.player_owes_cents > 0
- ]
-
- cash_in_cents = sum(s.total_banker_cash_in_cents for s in sessions)
- cash_out_cents = sum(s.total_banker_cash_out_cents for s in sessions)
- house_owes_players_cents = (
- sum(s.total_current_due_to_player_cents for s in sessions)
- + pending_carry_total_cents
- )
- players_owe_house_cents = sum(
- s.total_current_due_to_house_cents for s in sessions
- )
- admin_totals = {
- "cash_in_cents": cash_in_cents,
- "cash_out_cents": cash_out_cents,
- "house_owes_players_cents": house_owes_players_cents,
- "players_owe_house_cents": players_owe_house_cents,
- "pending_carry_cents": pending_carry_total_cents,
- "collected_cents": sum(s.total_debt_repayment_cents for s in sessions),
- "written_off_cents": sum(s.total_writeoff_cents for s in sessions),
- "net_book_position_cents": (
- cash_in_cents
- - cash_out_cents
- + players_owe_house_cents
- - house_owes_players_cents
- ),
- }
-
- return render_template(
- "admin_dashboard.html",
- open_sessions=open_sessions,
- recent_sessions=sessions,
- recent_events=recent_events,
- debt_entries=debt_entries,
- live_session=live_session,
- live_session_player_names=live_session_player_names,
- live_session_no=live_session_no,
- live_unresolved_count=live_unresolved_count,
- pending_carry_items=pending_carry_items,
- player_names=unique_player_names(events),
- append_form=append_form,
- event_types=ADMIN_EVENT_TYPES,
- event_type_colors=event_type_colors,
- event_type_labels=event_type_labels,
- admin_today=datetime.now().date().isoformat(),
- session_label=session_label,
- admin_totals=admin_totals,
- empty_session_count=empty_session_count,
- )
diff --git a/static/css/style.css b/static/css/style.css
index c57957e..09da5f2 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -13,6 +13,8 @@ body {
width: 100%;
min-width: 0;
min-height: 100vh;
+ display: flex;
+ flex-direction: column;
font-family: var(--font-ui);
font-size: 15px;
line-height: 1.5;
@@ -34,19 +36,400 @@ h1, h2, h3, h4 { margin: 0; }
FONTS
================================================================ */
:root {
- --font-ui: Aptos, Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ --font-ui: 'Inter', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-display: var(--font-ui);
--font-mono: ui-monospace, "SFMono-Regular", "Cascadia Mono", "Roboto Mono", "Liberation Mono", monospace;
--font-money: var(--font-ui);
}
/* ================================================================
- APP SHELL
+ SUBNAV
+ ================================================================ */
+.subnav {
+ position: sticky; top: 62px; z-index: 40;
+ background: rgba(15,15,18,.92);
+ backdrop-filter: blur(6px);
+ border-bottom: 1px solid var(--border);
+}
+.subnav__inner {
+ max-width: var(--w-admin);
+ margin: 0 auto;
+ padding: 0 var(--gutter);
+ height: 44px;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ overflow-x: auto;
+ scrollbar-width: none;
+}
+.subnav__inner::-webkit-scrollbar { display: none; }
+.subnav__name {
+ font-size: .8125rem;
+ font-weight: 600;
+ color: var(--text-strong);
+ white-space: nowrap;
+ text-decoration: none;
+ flex-shrink: 0;
+}
+.subnav__name:hover { color: var(--accent); }
+.subnav__divider {
+ display: block;
+ width: 1px;
+ height: 16px;
+ background: var(--border);
+ margin: 0 8px;
+ flex-shrink: 0;
+}
+.subnav__nav {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ flex: 1;
+ min-width: 0;
+}
+.subnav__link {
+ padding: 4px 10px;
+ font-size: .8125rem;
+ color: var(--text-muted);
+ text-decoration: none;
+ border-radius: 6px;
+ white-space: nowrap;
+ transition: color .15s, background .15s;
+}
+.subnav__link:hover { color: var(--text); background: var(--panel-hover); }
+.subnav__link.is-active { color: var(--accent); font-weight: 600; }
+
+/* ================================================================
+ APP SHELL + SIDEBAR
+ ================================================================ */
+.app-shell {
+ flex: 1;
+ display: flex;
+ align-items: stretch;
+ min-height: calc(100vh - 62px);
+ position: relative;
+}
+/* Lock app-shell to viewport height so only the main content area scrolls */
+.app-shell:has(.sidebar) {
+ height: calc(100vh - 62px);
+ overflow: hidden;
+}
+
+/* Full-height sidebar background strip */
+.app-shell:has(.sidebar)::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ width: 224px;
+ background: var(--surface);
+ border-right: 1px solid var(--border);
+ pointer-events: none;
+ z-index: 0;
+}
+
+/* ---- Sidebar ---- */
+.sidebar {
+ width: 224px;
+ flex-shrink: 0;
+ background: transparent;
+ height: 100%;
+ overflow-y: hidden;
+ display: flex;
+ flex-direction: column;
+ z-index: 1;
+}
+
+.sidebar__header {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 18px 14px 16px;
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+}
+.sidebar__badge {
+ width: 34px;
+ height: 34px;
+ border-radius: var(--r-md);
+ background: var(--accent-chip);
+ border: 1px solid var(--accent-chip-bd);
+ color: var(--accent);
+ font-size: 15px;
+ font-weight: 700;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ letter-spacing: -.01em;
+}
+.sidebar__meta { min-width: 0; }
+.sidebar__league-name {
+ display: block;
+ font-size: .8125rem;
+ font-weight: 600;
+ color: var(--text-strong);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 1.3;
+}
+.sidebar__role {
+ display: block;
+ font-size: .6875rem;
+ color: var(--accent);
+ font-weight: 500;
+ margin-top: 2px;
+}
+
+.sidebar__nav {
+ flex: 1;
+ padding: 10px 8px;
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+}
+.sidebar__link {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ padding: 8px 10px;
+ border-radius: var(--r-sm);
+ font-size: .8125rem;
+ font-weight: 500;
+ color: var(--text-2);
+ text-decoration: none;
+ transition: color .13s, background .13s;
+ line-height: 1;
+}
+.sidebar__link svg {
+ width: 16px;
+ height: 16px;
+ flex-shrink: 0;
+ opacity: .65;
+ transition: opacity .13s;
+}
+.sidebar__link:hover {
+ color: var(--text);
+ background: var(--surface-raised);
+}
+.sidebar__link:hover svg { opacity: .9; }
+.sidebar__link.is-active {
+ color: var(--accent);
+ background: var(--accent-tint);
+ font-weight: 600;
+}
+.sidebar__link.is-active svg { opacity: 1; }
+
+.sidebar__footer {
+ flex-shrink: 0;
+ padding: 12px 14px 14px;
+ border-top: 1px solid var(--border);
+}
+.sidebar__back {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ font-size: .75rem;
+ color: var(--muted);
+ text-decoration: none;
+ transition: color .13s;
+}
+.sidebar__back svg { width: 12px; height: 12px; }
+.sidebar__back:hover { color: var(--text-2); }
+
+/* ---- Admin sidebar variant ---- */
+.sidebar--admin .sidebar__badge--admin {
+ background: var(--warn-tint);
+ border: 1px solid var(--warn-tint-bd);
+ color: var(--warn);
+ border-radius: var(--r-sm);
+ width: 36px;
+ height: 36px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+.sidebar--admin .sidebar__link.is-active {
+ background: var(--warn-tint);
+ color: var(--warn);
+}
+.sidebar--admin .sidebar__link.is-active svg { opacity: 1; color: var(--warn); }
+.sidebar--admin .sidebar__link:hover { color: var(--warn); }
+
+/* ---- Internal admin dashboard ---- */
+.int-hero-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 14px;
+ margin-bottom: 14px;
+}
+.int-page-header {
+ margin-bottom: 20px;
+}
+.int-section-grid {
+ margin-bottom: 14px;
+}
+.int-section-grid--last {
+ margin-bottom: 40px;
+}
+.int-hero-card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--r-md);
+ padding: 18px 20px;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.int-hero-card__icon {
+ width: 36px; height: 36px;
+ border-radius: var(--r-sm);
+ display: flex; align-items: center; justify-content: center;
+ margin-bottom: 6px;
+}
+.int-hero-card__icon--users { background: var(--accent-chip); border: 1px solid var(--accent-chip-bd); color: var(--accent); }
+.int-hero-card__icon--leagues { background: rgba(111,192,147,.12); border: 1px solid rgba(111,192,147,.25); color: var(--pos); }
+.int-hero-card__icon--sessions { background: var(--warn-tint); border: 1px solid var(--warn-tint-bd); color: var(--warn); }
+.int-hero-card__icon--health { background: rgba(120,180,240,.1); border: 1px solid rgba(120,180,240,.22); color: #78b4f0; }
+.int-hero-card__num {
+ font-size: 28px;
+ font-weight: 700;
+ color: var(--text-strong);
+ letter-spacing: -.02em;
+ line-height: 1;
+}
+.int-hero-card__label { font-size: 12px; color: var(--muted); font-weight: 500; }
+.int-hero-card__sub { font-size: 11.5px; color: var(--faintest); margin-top: 4px; }
+.int-chart-panel { padding: 0; overflow: hidden; }
+.int-chart-head { padding: 16px 20px 0; }
+.int-chart-wrap { padding: 16px 20px 20px; position: relative; height: 220px; }
+.int-trend-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 14px;
+}
+.int-trend-card {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 88px;
+ gap: 12px;
+ align-items: center;
+ min-height: 112px;
+ padding: 16px 18px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--r-md);
+}
+.int-trend-card__label,
+.int-trend-card__delta span {
+ display: block;
+ color: var(--muted);
+ font-size: 11.5px;
+}
+.int-trend-card__value {
+ display: block;
+ margin: 4px 0;
+ color: var(--text-strong);
+ font-size: 24px;
+ font-weight: 700;
+ line-height: 1;
+}
+.int-trend-card__delta {
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 700;
+}
+.int-trend-card__delta--up { color: var(--pos); }
+.int-trend-card__delta--down { color: var(--neg); }
+.int-sparkline-wrap {
+ height: 54px;
+ min-width: 0;
+}
+.int-breakdown-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 20px; }
+.int-breakdown-row {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 6px 0; border-bottom: 1px solid var(--divider); font-size: 13px;
+}
+.int-breakdown-row:last-child { border-bottom: none; }
+.int-breakdown-row__label { color: var(--muted); }
+.int-breakdown-row__val { font-weight: 600; color: var(--text-strong); font-variant-numeric: tabular-nums; }
+
+.int-summary-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 1px;
+ background: var(--border);
+ border-radius: 10px;
+ overflow: hidden;
+}
+.int-summary-item {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 16px 20px;
+ background: var(--surface);
+}
+.int-summary-item__val { font-size: 22px; font-weight: 700; font-variant-numeric: tabular-nums; color: var(--text-strong); line-height: 1; }
+.int-summary-item__label { font-size: 12px; color: var(--muted); }
+
+@media (max-width: 900px) {
+ .int-hero-grid { grid-template-columns: repeat(2, 1fr); }
+ .int-summary-grid { grid-template-columns: repeat(2, 1fr); }
+ .admin-searchbar {
+ grid-template-columns: 1fr;
+ }
+ .admin-searchbar .btn {
+ justify-content: center;
+ }
+}
+
+/* ---- App page (paired with sidebar) ---- */
+.page--app {
+ flex: 1;
+ min-width: 0;
+ max-width: none;
+ margin: 0;
+ padding: clamp(20px, 3vw, 36px) clamp(24px, 4vw, 48px) 0;
+ overflow-y: auto;
+ height: 100%;
+}
+
+/* ---- Sidebar mobile collapse ---- */
+@media (max-width: 768px) {
+ .app-shell { flex-direction: column; min-height: 0; }
+ .app-shell:has(.sidebar) { height: auto; overflow: visible; }
+ .app-shell:has(.sidebar)::before { display: none; }
+ .sidebar {
+ width: 100%;
+ height: auto;
+ position: static;
+ background: var(--surface);
+ border-right: none;
+ border-bottom: 1px solid var(--border);
+ flex-direction: row;
+ align-items: center;
+ z-index: auto;
+ }
+ .sidebar__header { border-bottom: none; padding: 10px 12px; flex-shrink: 0; }
+ .sidebar__badge { width: 28px; height: 28px; font-size: 12px; }
+ .sidebar__role { display: none; }
+ .sidebar__nav { flex-direction: row; padding: 0 8px; gap: 0; overflow-x: auto; scrollbar-width: none; }
+ .sidebar__nav::-webkit-scrollbar { display: none; }
+ .sidebar__link { padding: 10px 10px; white-space: nowrap; gap: 6px; }
+ .sidebar__link svg { display: none; }
+ .sidebar__footer { display: none; }
+ .page--app { padding: 16px 16px 0; overflow-y: visible; height: auto; }
+}
+
+/* ================================================================
+ APP SHELL (topbar)
================================================================ */
.topbar {
position: sticky; top: 0; z-index: 50;
- background: rgba(15,15,18,.86);
- backdrop-filter: blur(8px);
+ background: rgba(10,10,13,.94);
+ backdrop-filter: blur(10px);
border-bottom: 1px solid var(--border);
}
.topbar__inner {
@@ -66,10 +449,10 @@ h1, h2, h3, h4 { margin: 0; }
flex: none;
}
.brand__mark {
- width: 26px; height: 26px;
- border-radius: 5px;
+ width: 30px; height: 30px;
+ border-radius: 7px;
background: var(--accent);
- box-shadow: 0 0 0 1px var(--accent-a30), 0 4px 14px var(--accent-a22);
+ box-shadow: inset 0 0 0 1px rgba(255,255,255,.14);
position: relative;
flex: none;
}
@@ -77,19 +460,23 @@ h1, h2, h3, h4 { margin: 0; }
content: "";
position: absolute;
inset: 0; margin: auto;
- width: 11px; height: 11px;
+ width: 12px; height: 12px;
border-radius: 2px;
- background: var(--bg);
+ background: var(--accent-ink);
transform: rotate(45deg);
}
.brand__name {
font-family: var(--font-display);
- font-size: 19px;
- font-weight: 600;
- letter-spacing: 0;
+ font-size: 17px;
+ font-weight: 700;
+ letter-spacing: -.01em;
color: var(--text);
white-space: nowrap;
}
+.brand__tld {
+ color: var(--faintest);
+ font-weight: 400;
+}
.mainnav { display: flex; gap: 4px; }
.nav-toggle {
display: none;
@@ -140,6 +527,7 @@ h1, h2, h3, h4 { margin: 0; }
}
.navlink:hover { color: var(--text-strong); }
.navlink.is-active { color: var(--text-strong); background: var(--field-alt); }
+.navlink--mobile-auth { display: none; }
.topbar__spacer { flex: 1; }
.topbar__season {
font-family: var(--font-mono);
@@ -151,9 +539,14 @@ h1, h2, h3, h4 { margin: 0; }
.topbar__admin-actions { display: flex; align-items: center; gap: 8px; }
.page {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
margin: 0 auto;
- padding: clamp(22px,3.4vw,46px) var(--gutter) 96px;
+ padding: clamp(22px,3.4vw,46px) var(--gutter) 0;
+ width: 100%;
min-width: 0;
+ min-height: calc(100vh - 62px);
}
.page--public { max-width: var(--w-public); }
.page--session { max-width: var(--w-session); }
@@ -166,39 +559,67 @@ h1, h2, h3, h4 { margin: 0; }
}
}
.site-footer {
- margin-top: clamp(44px, 6vw, 76px);
- padding-top: 18px;
+ margin-top: auto;
+ padding: clamp(20px, 3vw, 32px) 0 clamp(16px, 2vw, 24px);
border-top: 1px solid var(--divider);
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
- gap: 8px 18px;
+ column-gap: 16px;
color: var(--faintest);
- font-size: 12.5px;
+ font-size: 12px;
}
-.site-footer span {
+.site-footer__copy {
+ display: flex;
+ align-items: center;
+ gap: 10px;
white-space: nowrap;
}
-.site-footer span:nth-child(2) {
- color: var(--muted);
- justify-self: center;
+.site-footer__copy strong { color: var(--faint-2); font-weight: 500; }
+.site-footer__version {
+ font-family: var(--font-mono);
+ font-size: 10.5px;
+ color: var(--faintest-2);
}
-.site-footer span:nth-child(3) {
+.site-footer__links {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ flex-wrap: wrap;
+}
+.site-footer__links a {
+ color: var(--faintest);
+ text-decoration: none;
+ padding: 3px 7px;
+ border-radius: 5px;
+ white-space: nowrap;
+ transition: color .13s, background .13s;
+}
+.site-footer__links a:hover { color: var(--text-2); background: var(--surface-raised); }
+.site-footer__brand {
justify-self: end;
+ white-space: nowrap;
}
+.site-footer__brand a {
+ color: var(--faintest);
+ text-decoration: none;
+ font-weight: 500;
+ transition: color .13s;
+}
+.site-footer__brand a:hover { color: var(--text-2); }
@media (max-width: 640px) {
.site-footer {
grid-template-columns: 1fr;
- justify-items: center;
- gap: 6px;
- margin-top: 36px;
+ gap: 8px;
padding: 18px 0 max(20px, env(safe-area-inset-bottom));
- text-align: center;
}
- .site-footer span:nth-child(2),
- .site-footer span:nth-child(3) {
- justify-self: center;
+ .page--landing .site-footer,
+ .page--auth .site-footer,
+ .page--error .site-footer {
+ padding-left: 16px;
+ padding-right: 16px;
}
+ .site-footer__brand { justify-self: start; }
}
/* ================================================================
@@ -259,26 +680,28 @@ h1, h2, h3, h4 { margin: 0; }
}
h1 {
font-family: var(--font-display);
- font-size: clamp(30px,4.4vw,46px);
- font-weight: 600;
- letter-spacing: 0;
+ font-size: clamp(32px, 4.5vw, 52px);
+ font-weight: 800;
+ letter-spacing: -.025em;
color: var(--text);
line-height: 1.05;
}
h2 {
font-family: var(--font-display);
font-size: 18px;
- font-weight: 600;
+ font-weight: 700;
+ letter-spacing: -.01em;
color: var(--text-strong);
}
h3 {
font-size: 15px;
font-weight: 600;
+ letter-spacing: -.005em;
color: var(--text-strong);
}
/* page intro */
-.hero { padding: 28px 0 22px; }
+.hero { padding: 32px 0 24px; }
.hero__kicker {
font-family: var(--font-mono);
font-size: 10.5px;
@@ -286,6 +709,7 @@ h3 {
text-transform: uppercase;
color: var(--accent);
margin: 0 0 10px;
+ display: block;
}
.session-number {
color: var(--accent);
@@ -294,9 +718,9 @@ h3 {
}
.hero__title {
font-family: var(--font-display);
- font-size: clamp(30px,4.4vw,46px);
- font-weight: 600;
- letter-spacing: 0;
+ font-size: clamp(32px, 4.5vw, 52px);
+ font-weight: 800;
+ letter-spacing: -.025em;
color: var(--text);
line-height: 1.05;
margin: 0 0 10px;
@@ -318,10 +742,12 @@ h3 {
================================================================ */
.panel {
background: var(--surface);
- border: 1px solid var(--border);
+ border: var(--glass-border);
border-radius: var(--r-lg);
+ box-shadow: var(--shadow-sm);
overflow: hidden;
min-width: 0;
+ transition: border-color 140ms, box-shadow 140ms;
}
.panel__head {
padding: 16px 22px;
@@ -334,9 +760,10 @@ h3 {
}
.panel__title {
font-family: var(--font-display);
- font-weight: 600;
- font-size: 18px;
+ font-weight: 700;
+ font-size: 17px;
margin: 0;
+ letter-spacing: -.01em;
color: var(--text-strong);
}
.panel__tag { font-family: var(--font-mono); font-size: 11px; color: var(--faintest); }
@@ -357,23 +784,28 @@ h3 {
STAT / METRIC TILE
================================================================ */
.stat {
- padding: 15px 16px;
+ padding: 18px 20px;
background: var(--surface);
- border: 1px solid var(--border);
+ border: var(--glass-border);
border-radius: var(--r-md);
+ box-shadow: var(--shadow-sm);
+ position: relative;
+ overflow: hidden;
}
.stat__val {
font-family: var(--font-display);
- font-size: 26px;
- font-weight: 600;
+ font-size: 28px;
+ font-weight: 700;
line-height: 1;
- margin: 4px 0 0;
+ margin: 6px 0 0;
+ letter-spacing: -.02em;
}
.stat__sub {
font-family: var(--font-mono);
- font-size: 11.5px;
+ font-size: 11px;
color: var(--muted);
margin-top: 8px;
+ display: block;
}
.stat--hero .stat__val { font-size: 34px; }
.player-rank {
@@ -410,13 +842,14 @@ h3 {
line-height: 1;
white-space: nowrap;
}
-.stat--rail { position: relative; padding-left: 20px; }
+.stat--rail { position: relative; padding-left: 20px; background: var(--surface); }
.stat--rail::before {
content: "";
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
background: var(--rail, var(--accent));
+ border-radius: 0 2px 2px 0;
}
/* hero stat row (small inline tiles) */
@@ -476,9 +909,9 @@ h3 {
}
.seg:hover { color: var(--text-2); }
.seg.is-on {
- background: var(--accent-chip);
+ background: var(--surface-head);
color: var(--text-strong);
- box-shadow: inset 0 0 0 1px var(--accent-chip-bd);
+ box-shadow: inset 0 0 0 1px var(--border-hi);
}
.seg__count { font-family: var(--font-mono); font-size: 11px; font-weight: 500; color: var(--faintest); }
.seg.is-on .seg__count { color: var(--accent); }
@@ -539,6 +972,27 @@ h3 {
}
.form-card textarea { resize: vertical; min-height: 92px; }
.form-card input::placeholder, .form-card textarea::placeholder { color: var(--faintest-2); }
+.session-locked-banner {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 10px 14px;
+ background: var(--field);
+ border: 1px solid var(--border);
+ border-left: 3px solid var(--warn);
+ border-radius: var(--r-sm);
+ font-size: .8125rem;
+}
+.session-locked-banner strong { color: var(--warn); }
+.session-locked-banner span { color: var(--text-muted); }
+fieldset[disabled] input,
+fieldset[disabled] select,
+fieldset[disabled] textarea,
+fieldset[disabled] button {
+ opacity: .45;
+ cursor: not-allowed;
+ pointer-events: none;
+}
.session-filter-form { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.session-filter-select {
@@ -557,6 +1011,17 @@ h3 {
box-shadow: 0 0 0 3px var(--accent-a22);
}
+/* Custom chevron for all themed selects */
+.form-card select,
+.field-select,
+.session-filter-select {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M2 4l4 4 4-4' stroke='%23999' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 10px center;
+ padding-right: 30px;
+ cursor: pointer;
+}
+
/* ================================================================
TABLE
================================================================ */
@@ -599,7 +1064,7 @@ h3 {
color: var(--text-body);
}
.tbl tbody tr { cursor: pointer; transition: background 80ms; }
-.tbl tbody tr:hover { background: #1e1e25; }
+.tbl tbody tr:hover { background: rgba(155,140,240,.04); }
.tbl .num-net { font-weight: 600; font-size: 15px; }
.tbl .num-pos { color: var(--pos); }
.tbl .num-neg { color: var(--neg); }
@@ -693,7 +1158,7 @@ th {
border-bottom: 1px solid var(--border);
}
tbody tr { transition: background 80ms; }
-tbody tr:hover { background: #1e1e25; }
+tbody tr:hover { background: rgba(155,140,240,.04); }
tbody tr:not(:last-child) td { border-bottom: 1px solid var(--divider); }
td { font-size: 13.5px; color: var(--text-body); }
td a { font-weight: 600; color: var(--text-strong); }
@@ -1057,15 +1522,27 @@ td a:hover { color: var(--accent); }
font: 700 14px var(--font-ui);
cursor: pointer;
white-space: nowrap;
- transition: opacity 120ms, box-shadow 120ms;
+ transition: background 120ms, border-color 120ms, color 120ms, opacity 120ms;
}
-.btn--primary { background: var(--accent); color: var(--accent-ink); box-shadow: 0 4px 16px var(--accent-a30); }
-.btn--primary:hover { opacity: .88; }
+.btn--primary { background: var(--accent); color: var(--accent-ink); box-shadow: none; font-weight: 700; }
+.btn--primary:hover { opacity: .9; }
.btn--ghost { background: var(--field); color: var(--text-2); border: 1px solid var(--border-strong); font-weight: 600; }
.btn--ghost:hover { border-color: var(--border-hi); color: var(--text-strong); }
.btn--outline { background: var(--accent-tint); color: var(--accent); border: 1px solid var(--accent-chip-bd); }
.btn--outline:hover { background: var(--accent-chip); }
+.btn--warning {
+ background: var(--warn-tint);
+ color: var(--warn);
+ border: 1px solid var(--warn-tint-bd);
+ font-weight: 700;
+}
+.btn--warning:hover {
+ background: color-mix(in srgb, var(--warn) 18%, transparent);
+ border-color: color-mix(in srgb, var(--warn) 54%, transparent);
+ color: var(--warn);
+}
.btn--sm { padding: 7px 13px; font-size: 13px; }
+.btn:disabled { opacity: .45; cursor: not-allowed; box-shadow: none; }
/* legacy button classes mapped to new */
.primary-button {
@@ -1079,11 +1556,11 @@ td a:hover { color: var(--accent); }
cursor: pointer;
background: var(--accent);
color: var(--accent-ink);
- box-shadow: 0 4px 16px var(--accent-a30);
+ box-shadow: none;
text-decoration: none;
- transition: opacity 120ms;
+ transition: opacity 120ms, background 120ms;
}
-.primary-button:hover { opacity: .88; }
+.primary-button:hover { opacity: .9; }
.secondary-button, .ghost-button {
display: inline-flex;
align-items: center;
@@ -1141,9 +1618,96 @@ td a:hover { color: var(--accent); }
.split { display: flex; flex-wrap: wrap; gap: var(--s4); align-items: flex-start; }
.split > .col-main { flex: 2; min-width: 360px; }
.split > .col-side { flex: 1; min-width: 300px; }
+.debt-row { display: flex; flex-direction: column; gap: 8px; padding: 14px 0; border-bottom: 1px solid var(--border); }
+.debt-row:last-child { border-bottom: none; }
+.debt-row__info { display: flex; flex-direction: column; gap: 2px; }
+.debt-row__info strong { font-size: .9375rem; color: var(--text-strong); }
+.debt-row__info span { font-size: .8125rem; color: var(--text-muted); }
+.debt-row__form { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
+.debt-row__form input[type=number] { width: 100px; }
+.debt-row__form select { flex: 1; min-width: 120px; }
.stack { display: grid; gap: 20px; }
.inline-action-form { margin: 0; }
.button-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
+.form-stack { display: grid; gap: 14px; }
+.inline-form {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ flex-wrap: wrap;
+}
+.inline-form .field-input {
+ flex: 1 1 220px;
+ min-width: 0;
+}
+.searchbar {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ max-width: 560px;
+}
+.searchbar .field-input {
+ min-width: 240px;
+}
+.tbl__actions {
+ text-align: right;
+ white-space: nowrap;
+}
+.admin-list-tools {
+ margin: 0 0 14px;
+ padding: 14px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--r-md);
+}
+.admin-searchbar {
+ display: grid;
+ grid-template-columns: minmax(260px, 1fr) auto auto;
+ gap: 8px;
+ align-items: center;
+ max-width: 720px;
+}
+.admin-list-panel {
+ overflow: hidden;
+}
+.admin-table {
+ min-width: 760px;
+}
+.admin-table th {
+ color: var(--muted);
+ font-size: 11px;
+ letter-spacing: .04em;
+ text-transform: uppercase;
+}
+.admin-table td {
+ vertical-align: middle;
+}
+.admin-table__primary a {
+ color: var(--text-strong);
+ font-weight: 700;
+}
+.admin-table__muted {
+ color: var(--muted);
+ font-size: 12px;
+}
+.pagination-row {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 10px;
+ padding: 14px 18px;
+ border-top: 1px solid var(--border);
+}
+.pagination-row__label {
+ color: var(--muted);
+ font-size: 12px;
+}
+.btn.is-disabled {
+ opacity: .4;
+ pointer-events: none;
+}
/* Breadcrumb */
.breadcrumb {
@@ -1650,8 +2214,584 @@ select.control { cursor: pointer; }
.csv__drop.is-drag { border-color: var(--accent); color: var(--accent); }
/* Form shell (admin login) */
-.form-shell { display: grid; justify-content: center; padding-top: 40px; }
-.form-shell.narrow { grid-template-columns: minmax(320px, 480px); }
+.form-shell { display: grid; justify-content: center; padding-top: 48px; }
+.form-shell.narrow { grid-template-columns: minmax(320px, 440px); }
+.form-shell .panel {
+ box-shadow: 0 24px 64px rgba(0,0,0,.28), 0 0 0 1px var(--border-strong);
+}
+
+/* Account / league shell */
+.league-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+ gap: 14px;
+}
+.league-action-grid,
+.league-manage-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: 16px;
+}
+.league-manage-grid {
+ align-items: start;
+}
+.league-card {
+ display: grid;
+ gap: 8px;
+ padding: 18px;
+ color: inherit;
+ transition: border-color 160ms, transform 160ms, box-shadow 160ms;
+}
+.league-card:hover {
+ border-color: var(--accent);
+ transform: translateY(-2px);
+ box-shadow: 0 6px 20px rgba(0,0,0,.18), 0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent);
+}
+.league-card--disabled {
+ opacity: .72;
+}
+.league-card--disabled:hover {
+ border-color: var(--border-strong);
+ transform: none;
+}
+.league-card strong {
+ color: var(--text-strong);
+ font-size: 18px;
+}
+.league-card span:last-child {
+ color: var(--muted);
+ line-height: 1.45;
+}
+.league-hub-summary {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 12px;
+ margin-bottom: 16px;
+}
+.league-board {
+ display: grid;
+ gap: 14px;
+}
+.league-row {
+ display: grid;
+ grid-template-columns: minmax(260px, 1.2fr) minmax(420px, 1.6fr) minmax(240px, .9fr);
+ grid-template-areas:
+ "identity metrics leaders"
+ "actions actions leaders";
+ align-items: stretch;
+ overflow: hidden;
+ border-top: 2px solid var(--accent);
+ transition: box-shadow 180ms;
+}
+.league-row__identity {
+ grid-area: identity;
+ display: grid;
+ align-content: start;
+ gap: 10px;
+ padding: 20px;
+ border-right: 1px solid var(--divider);
+}
+.league-row__chips {
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+.league-row__title {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ align-items: flex-start;
+ color: inherit;
+ text-decoration: none;
+}
+.league-row__title h2 {
+ margin: 4px 0 0;
+ color: var(--text-strong);
+ font-size: 24px;
+ transition: color 140ms;
+}
+.league-row__title:hover h2 {
+ color: var(--accent);
+}
+.league-row:hover {
+ box-shadow: var(--shadow-lg), 0 0 0 1px color-mix(in srgb, var(--accent) 22%, transparent);
+ transform: translateY(-2px);
+}
+.league-row__desc {
+ margin: 0;
+ color: var(--muted);
+ line-height: 1.45;
+}
+.league-row__meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 12px;
+ color: var(--faintest);
+ font-size: 12.5px;
+}
+.league-row__metrics {
+ grid-area: metrics;
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 1px;
+ background: var(--divider);
+ border-right: 1px solid var(--divider);
+}
+.league-row__metrics div {
+ min-width: 0;
+ display: grid;
+ align-content: center;
+ gap: 6px;
+ padding: 18px 14px;
+ background: var(--surface);
+}
+.league-row__metrics span {
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: .08em;
+ text-transform: uppercase;
+}
+.league-row__metrics strong {
+ color: var(--text-strong);
+ font-family: var(--font-mono);
+ font-size: 16px;
+ font-variant-numeric: tabular-nums;
+ overflow-wrap: anywhere;
+}
+.league-row__leaders {
+ grid-area: leaders;
+ display: grid;
+ align-content: start;
+ gap: 12px;
+ padding: 18px;
+ background: var(--surface-subtle);
+}
+.league-row__section-head {
+ display: flex;
+ justify-content: space-between;
+ gap: 10px;
+ align-items: center;
+}
+.league-row__section-head a {
+ color: var(--accent);
+ font-size: 12.5px;
+ font-weight: 700;
+ text-decoration: none;
+}
+.league-row__actions {
+ grid-area: actions;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ align-content: flex-start;
+ gap: 8px;
+ padding: 14px 20px;
+ border-top: 1px solid var(--divider);
+ border-right: 1px solid var(--divider);
+ background: rgba(255,255,255,.018);
+}
+.league-top-list {
+ display: grid;
+ gap: 1px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ background: var(--divider);
+ border: 1px solid var(--divider);
+ border-radius: var(--r-sm);
+ overflow: hidden;
+}
+.league-top-list li {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 10px 12px;
+ background: var(--surface);
+}
+.league-top-list span {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--text-strong);
+}
+.league-top-list strong {
+ flex: none;
+ font-family: var(--font-mono);
+ font-size: 12.5px;
+ font-variant-numeric: tabular-nums;
+}
+.league-row__empty {
+ display: grid;
+ gap: 6px;
+ padding: 16px;
+ border: 1px dashed var(--border-strong);
+ border-radius: var(--r-sm);
+ color: var(--muted);
+}
+.league-row__empty a {
+ color: var(--accent);
+ font-size: 13px;
+ font-weight: 700;
+ text-decoration: none;
+}
+.empty-panel {
+ display: grid;
+ gap: 12px;
+ justify-items: start;
+ padding: 22px;
+}
+.player-list {
+ display: grid;
+}
+.player-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ padding: 14px 18px;
+ border-top: 1px solid var(--border);
+}
+.player-row:first-child {
+ border-top-color: var(--border-strong);
+}
+.player-row strong {
+ display: block;
+ color: var(--text-strong);
+}
+.player-row span {
+ color: var(--muted);
+ font-size: 13px;
+}
+.player-row__actions {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+.player-row__actions form {
+ margin: 0;
+}
+/* Player card grid */
+.player-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
+ gap: 12px;
+ padding: 16px;
+}
+.player-card {
+ border: var(--glass-border);
+ border-radius: var(--r-md);
+ padding: 18px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ text-decoration: none;
+ box-shadow: var(--shadow-sm);
+ transition: border-color 0.18s, box-shadow 0.18s;
+}
+.player-card:hover {
+ border-color: color-mix(in srgb, var(--accent) 40%, transparent);
+ box-shadow: var(--shadow-lg), 0 0 0 1px color-mix(in srgb, var(--accent) 18%, transparent);
+}
+.player-card--archived { opacity: 0.5; }
+.player-card__head {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+.player-avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background: var(--surface-2);
+ color: var(--muted);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 13px;
+ font-weight: 700;
+ flex-shrink: 0;
+ letter-spacing: 0.02em;
+}
+.player-avatar--pos { background: color-mix(in srgb, var(--pos) 18%, transparent); color: var(--pos); }
+.player-avatar--neg { background: color-mix(in srgb, var(--neg) 18%, transparent); color: var(--neg); }
+.player-card__name-block { flex: 1; min-width: 0; }
+.player-card__name {
+ font-weight: 600;
+ color: var(--text);
+ text-decoration: none;
+ display: block;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.player-card__name:hover { color: var(--accent); }
+.player-card__sub { font-size: 12px; color: var(--muted); display: block; margin-top: 1px; }
+.player-card__stats {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ gap: 6px;
+ border-top: 1px solid var(--border);
+ padding-top: 12px;
+}
+.player-card__stat {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+.player-card__stat .kicker { font-size: 9px; }
+.player-card__stat-val { font-size: 13px; font-weight: 600; color: var(--text); }
+/* Danger zone row (description left, action button right) */
+.danger-row {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 20px;
+ flex-wrap: wrap;
+ padding: 4px 0 18px;
+ border-bottom: 1px solid var(--border);
+}
+.danger-row + .danger-row {
+ padding-top: 18px;
+}
+.danger-row:last-child {
+ padding-bottom: 0;
+ border-bottom: none;
+}
+.danger-row__title {
+ display: block;
+ color: var(--text-strong);
+ margin-bottom: 4px;
+}
+.danger-row__btn {
+ width: 168px;
+ justify-content: center;
+ text-align: center;
+}
+
+/* Danger button */
+.btn--danger {
+ background: var(--neg);
+ color: #fff;
+}
+.btn--danger:hover { opacity: .88; }
+.btn--danger:disabled { opacity: .4; cursor: not-allowed; }
+
+/* Session date block (used in session list rows) */
+.session-date-block {
+ flex: none;
+ display: grid;
+ text-align: center;
+ padding: 6px 10px;
+ background: var(--surface-sunk);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ min-width: 48px;
+}
+.session-date-block__mon {
+ display: block;
+ font-family: var(--font-mono);
+ font-size: 9.5px;
+ letter-spacing: .12em;
+ text-transform: uppercase;
+ color: var(--faintest);
+ line-height: 1;
+}
+.session-date-block__day {
+ display: block;
+ font-family: var(--font-display);
+ font-size: 20px;
+ font-weight: 700;
+ color: var(--text-strong);
+ line-height: 1.1;
+}
+
+/* Session create form — horizontal 3-col grid */
+.sessions-search-bar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 16px;
+ border-bottom: 1px solid var(--border);
+ background: var(--surface-sunk);
+}
+.sessions-search-bar__icon {
+ color: var(--faintest);
+ flex-shrink: 0;
+}
+.sessions-search-bar__input {
+ flex: 1;
+ background: none;
+ border: none;
+ outline: none;
+ font-size: .85rem;
+ color: var(--text);
+ font-family: inherit;
+}
+.sessions-search-bar__input::placeholder { color: var(--faintest); }
+
+.session-create-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ gap: 14px;
+}
+@media (max-width: 860px) {
+ .session-create-row {
+ grid-template-columns: 1fr 1fr;
+ }
+}
+@media (max-width: 540px) {
+ .session-create-row {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* Optional field hint inside label */
+.form-opt-hint {
+ font-family: var(--font-mono);
+ font-size: 9px;
+ letter-spacing: .06em;
+ color: var(--faintest-2);
+ text-transform: none;
+ font-weight: 400;
+}
+
+.status-pill {
+ display: inline-flex;
+ align-items: center;
+ min-height: 26px;
+ padding: 4px 9px;
+ border-radius: var(--r-pill);
+ border: 1px solid var(--border-strong);
+ color: var(--muted);
+ font: 11px var(--font-mono);
+ text-transform: uppercase;
+}
+.status-pill--active {
+ border-color: var(--pos-tint-bd);
+ color: var(--pos);
+ background: var(--pos-tint);
+}
+.status-pill--open {
+ border-color: var(--pos-tint-bd);
+ color: var(--pos);
+ background: var(--pos-tint);
+}
+.status-pill--closed,
+.status-pill--archived {
+ border-color: var(--border-strong);
+ color: var(--faint);
+ background: var(--field);
+}
+
+.season-tag {
+ display: inline-flex;
+ align-items: center;
+ padding: 1px 7px;
+ border-radius: 20px;
+ border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
+ background: color-mix(in srgb, var(--accent) 10%, transparent);
+ color: var(--accent);
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: .02em;
+ vertical-align: middle;
+ margin-left: 4px;
+}
+
+/* Session list row with left accent */
+.session-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 14px 20px;
+ border-bottom: 1px solid var(--border);
+ border-left: 3px solid transparent;
+}
+.session-row:last-child { border-bottom: none; }
+.session-row--open { border-left-color: var(--pos); }
+.session-row--closed { border-left-color: var(--border-strong); }
+.session-row__info { display: flex; align-items: center; gap: 14px; min-width: 0; }
+.session-row__text { min-width: 0; }
+.session-row__title { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.session-row__title a { color: var(--text-strong); text-decoration: none; }
+.session-row__title a:hover { color: var(--accent); }
+.session-row__meta { font-size: 12px; color: var(--muted); margin-top: 2px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
+.session-row__stat { display: inline-flex; align-items: center; gap: 4px; }
+.session-row__actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
+.session-row__actions form { margin: 0; }
+
+/* Session detail — full-width player ledger rows */
+.player-ledger-list {
+ display: flex;
+ flex-direction: column;
+ max-height: 480px;
+ overflow-y: auto;
+ border-radius: 0 0 var(--r-lg) var(--r-lg);
+}
+.player-ledger-row {
+ padding: 14px 20px;
+ border-bottom: 1px solid var(--border);
+ border-left: 3px solid var(--border-strong);
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+.player-ledger-row:last-child { border-bottom: none; }
+.player-ledger-row--pos { border-left-color: var(--pos); }
+.player-ledger-row--neg { border-left-color: var(--neg); }
+.player-ledger-row__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+.player-ledger-row__name { font-size: 15px; font-weight: 700; color: var(--text-strong); }
+.player-ledger-row__fields {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ border: 1px solid var(--border);
+ border-radius: var(--r-sm);
+ overflow: hidden;
+}
+.player-ledger-row__fields--secondary {
+ grid-template-columns: repeat(3, 1fr);
+ background: var(--surface-sunk);
+}
+.player-ledger-row__field {
+ padding: 8px 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ border-right: 1px solid var(--border);
+ background: var(--surface);
+}
+.player-ledger-row__fields--secondary .player-ledger-row__field {
+ background: var(--surface-sunk);
+}
+.player-ledger-row__field:last-child { border-right: none; }
+.player-ledger-row__field--net { background: var(--surface-subtle); }
+.player-ledger-row__lbl {
+ font-size: 9.5px;
+ text-transform: uppercase;
+ letter-spacing: .08em;
+ color: var(--faintest);
+ font-family: var(--font-mono);
+ white-space: nowrap;
+}
+.player-ledger-row__val {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text);
+ font-variant-numeric: tabular-nums;
+}
+@media (max-width: 600px) {
+ .player-ledger-row__fields { grid-template-columns: repeat(2, 1fr); }
+ .player-ledger-row__field { border-bottom: 1px solid var(--border); }
+ .player-ledger-row__fields--secondary { grid-template-columns: repeat(2, 1fr); }
+}
/* Modal */
.modal-backdrop {
@@ -1896,6 +3036,9 @@ select.control { cursor: pointer; }
height: 38px;
padding-inline: 11px;
}
+ .topbar__account-actions {
+ display: none;
+ }
}
/* Toolbar: stack left/right sections vertically on narrow viewports */
@@ -1943,3 +3086,3204 @@ select.control { cursor: pointer; }
.seg { padding: 6px 10px; font-size: 12px; }
.hero__title, h1 { font-size: 24px; }
}
+
+/* ================================================================
+ PAGE HEADER (app pages)
+ ================================================================ */
+.page-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 24px;
+ padding-bottom: 20px;
+ border-bottom: 1px solid var(--border);
+}
+.page-header__chips {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-bottom: 6px;
+}
+.page-header__title {
+ font-size: clamp(20px, 2.4vw, 26px);
+ font-weight: 700;
+ letter-spacing: -.025em;
+ color: var(--text-strong);
+ margin: 0 0 3px;
+}
+.page-header__sub {
+ font-size: .875rem;
+ color: var(--muted);
+ margin: 0;
+}
+.page-header__actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-shrink: 0;
+}
+
+/* ================================================================
+ LEAGUE DASHBOARD
+ ================================================================ */
+
+/* Hero */
+.db-hero {
+ position: relative;
+ overflow: hidden;
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 20px;
+ padding: 28px 0 26px;
+ flex-wrap: wrap;
+}
+.db-hero__bg-letter {
+ position: absolute;
+ right: -12px;
+ top: -24px;
+ font-family: var(--font-display);
+ font-size: 210px;
+ font-weight: 800;
+ line-height: 1;
+ color: var(--surface-raised);
+ user-select: none;
+ pointer-events: none;
+ z-index: 0;
+ letter-spacing: -6px;
+}
+.db-hero__body {
+ position: relative;
+ z-index: 1;
+ flex: 1;
+ min-width: 0;
+}
+.db-hero__actions {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+ flex: none;
+ padding-top: 6px;
+}
+.db-hero__chips {
+ display: flex;
+ gap: 6px;
+ margin-bottom: 12px;
+}
+.db-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 3px 10px;
+ border-radius: var(--r-pill);
+ font-family: var(--font-mono);
+ font-size: 10px;
+ letter-spacing: .08em;
+ text-transform: uppercase;
+ font-weight: 500;
+ background: var(--surface-raised);
+ border: 1px solid var(--border-strong);
+ color: var(--muted);
+}
+.db-chip--accent {
+ background: var(--accent-chip);
+ border-color: var(--accent-chip-bd);
+ color: var(--accent);
+}
+.db-chip--public {
+ background: var(--pos-tint);
+ border-color: var(--pos-tint-bd);
+ color: var(--pos);
+}
+
+.league-back-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--muted);
+ text-decoration: none;
+ margin-bottom: 4px;
+ transition: color 120ms;
+}
+.league-back-link:hover { color: var(--accent); }
+
+/* Live session banner */
+.db-live-bar {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 12px 18px;
+ background: var(--pos-tint);
+ border: 1px solid var(--pos-tint-bd);
+ border-radius: var(--r-lg);
+ margin-bottom: 16px;
+ flex-wrap: wrap;
+}
+.db-live-bar__text {
+ flex: 1;
+ min-width: 0;
+ color: var(--pos);
+ font-size: 13.5px;
+ font-weight: 500;
+}
+.db-live-bar__btn {
+ background: var(--pos);
+ color: #0f0f12;
+ font-weight: 700;
+ flex: none;
+}
+.db-live-bar__btn:hover { opacity: .88; }
+
+/* Stats row — uses existing stat + stat--rail */
+.db-stats {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: var(--s3);
+ margin-bottom: 20px;
+}
+
+/* KPI tile row (league dashboard) */
+.db-kpi-row {
+ display: flex;
+ align-items: center;
+ gap: 0;
+ margin-bottom: 24px;
+ padding: 14px 20px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--r-lg);
+}
+.db-kpi {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 0 20px;
+}
+.db-kpi:first-child { padding-left: 0; }
+.db-kpi:last-child { padding-right: 0; }
+.db-kpi + .db-kpi { border-left: 1px solid var(--border); }
+.db-kpi__label {
+ font-size: 10.5px;
+ font-weight: 500;
+ letter-spacing: .07em;
+ text-transform: uppercase;
+ color: var(--muted);
+}
+.db-kpi__val {
+ font-size: 22px;
+ font-weight: 800;
+ letter-spacing: -.03em;
+ color: var(--text-strong);
+ line-height: 1.1;
+}
+.db-kpi__sub {
+ font-size: 11px;
+ color: var(--muted);
+ margin-top: 1px;
+}
+
+/* Navigation card list */
+.dashboard-grid {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ border: 1px solid var(--border);
+ border-radius: var(--r-lg);
+ overflow: hidden;
+ background: var(--border);
+}
+.dashboard-card {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 16px 20px;
+ color: inherit;
+ text-decoration: none;
+ background: var(--surface);
+ transition: background 120ms;
+}
+.dashboard-card:hover {
+ background: var(--surface-head);
+}
+.dashboard-card--live {
+ border-left: 3px solid var(--pos);
+ padding-left: 17px;
+}
+.dashboard-card__body { flex: 1; min-width: 0; }
+.dashboard-card__top {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+}
+.dashboard-card__top .kicker { margin: 0; }
+.dashboard-card__count {
+ font-family: var(--font-mono);
+ font-size: 10.5px;
+ color: var(--faintest);
+}
+.dashboard-card__title {
+ display: block;
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--text-strong);
+}
+.dashboard-card__desc {
+ margin: 2px 0 0;
+ color: var(--muted);
+ font-size: 12.5px;
+ line-height: 1.4;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.dashboard-card__arrow {
+ font-size: 16px;
+ color: var(--faintest);
+ flex: none;
+ transition: color 120ms, transform 120ms;
+}
+.dashboard-card:hover .dashboard-card__arrow {
+ color: var(--muted);
+ transform: translateX(3px);
+}
+
+@media (max-width: 640px) {
+ .db-stats { grid-template-columns: 1fr 1fr; }
+ .db-kpi-row { flex-wrap: wrap; }
+ .db-kpi { min-width: 40%; }
+ .db-hero { flex-direction: column; gap: 14px; }
+ .db-hero__bg-letter { font-size: 140px; }
+}
+
+/* Landing page */
+.l-bg {
+ position: fixed;
+ inset: 0;
+ z-index: -1;
+ pointer-events: none;
+}
+.l-bg::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background:
+ radial-gradient(ellipse 75% 60% at 12% 12%, rgba(155,140,240,.07) 0%, transparent 55%),
+ radial-gradient(ellipse 55% 45% at 88% 88%, rgba(111,192,147,.04) 0%, transparent 52%);
+}
+
+.l-hero {
+ padding: clamp(72px, 10vw, 128px) 0 clamp(44px, 6vw, 68px);
+ max-width: 820px;
+ position: relative;
+}
+.l-hero__kicker {
+ display: block;
+ font-family: var(--font-mono);
+ font-size: 10.5px;
+ letter-spacing: .12em;
+ text-transform: uppercase;
+ font-weight: 500;
+ color: var(--muted);
+ margin-bottom: 20px;
+}
+.l-hero__h1 {
+ font-size: clamp(48px, 8vw, 96px);
+ font-weight: 800;
+ line-height: 1.0;
+ letter-spacing: -.035em;
+ color: var(--text);
+ margin: 0 0 22px;
+}
+.l-hero__accent {
+ color: var(--accent);
+}
+.l-hero__sub {
+ font-size: clamp(15px, 1.8vw, 17px);
+ color: var(--muted);
+ line-height: 1.65;
+ margin: 0 0 30px;
+ max-width: 56ch;
+}
+.l-hero__cta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
+.l-stat-strip {
+ display: flex;
+ align-items: center;
+ gap: 0;
+ margin-top: 40px;
+ padding: 18px 24px;
+ background: var(--surface);
+ border: var(--glass-border);
+ border-radius: var(--r-lg);
+ box-shadow: var(--shadow-sm);
+}
+.l-stat-strip__item {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ flex: 1;
+ padding: 0 24px;
+}
+.l-stat-strip__item:first-child { padding-left: 0; }
+.l-stat-strip__item:last-child { padding-right: 0; }
+.l-stat-strip__item + .l-stat-strip__item {
+ border-left: 1px solid var(--border);
+}
+.l-stat-strip__label {
+ font-size: 10.5px;
+ font-weight: 500;
+ letter-spacing: .08em;
+ text-transform: uppercase;
+ color: var(--muted);
+}
+.l-stat-strip__val {
+ font-size: 22px;
+ font-weight: 800;
+ letter-spacing: -.025em;
+ color: var(--text-strong);
+ line-height: 1;
+}
+.l-stat-strip__val--accent { color: var(--accent); }
+
+/* Preview strip */
+.l-preview {
+ display: grid;
+ grid-template-columns: 1fr 1.35fr 1fr;
+ gap: 14px;
+ margin-bottom: clamp(36px, 5vw, 56px);
+ align-items: start;
+}
+.l-preview__panel {
+ background: var(--surface);
+ border: var(--glass-border);
+ border-radius: var(--r-lg);
+ overflow: hidden;
+ box-shadow: var(--shadow-md);
+}
+.l-preview__panel--featured {
+ border-color: var(--accent-chip-bd);
+ box-shadow: var(--shadow-xl), 0 0 0 1px var(--accent-chip-bd);
+}
+.l-preview__head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 11px 14px;
+ border-bottom: 1px solid var(--divider);
+ background: var(--surface-head);
+}
+.l-preview__panel--featured .l-preview__head {
+ background: var(--accent-tint);
+ border-bottom-color: var(--accent-chip-bd);
+}
+.l-preview__tag {
+ font-family: var(--font-mono);
+ font-size: 9.5px;
+ letter-spacing: .08em;
+ color: var(--faintest);
+ text-transform: uppercase;
+}
+.l-preview__rows {
+ display: grid;
+}
+.l-preview__row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 9px 14px;
+ border-bottom: 1px solid var(--divider);
+ font-size: 13px;
+}
+.l-preview__row:last-child { border-bottom: none; }
+.l-preview__row--faded { opacity: .4; }
+.l-preview__rank {
+ font-family: var(--font-mono);
+ font-size: 10.5px;
+ color: var(--faintest);
+ min-width: 12px;
+}
+.l-preview__player {
+ flex: 1;
+ font-weight: 600;
+ color: var(--text-2);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.l-preview__val {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+.l-preview__val--pos { color: var(--pos); }
+.l-preview__val--neg { color: var(--neg); }
+.l-preview__stats {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 1px;
+ background: var(--divider);
+ border-bottom: 1px solid var(--divider);
+}
+.l-preview__stat {
+ padding: 12px 12px;
+ background: var(--surface);
+ display: grid;
+ gap: 5px;
+}
+.l-preview__panel--featured .l-preview__stat {
+ background: color-mix(in srgb, var(--accent) 4%, var(--surface));
+}
+.l-preview__stat strong {
+ display: block;
+ font-family: var(--font-mono);
+ font-size: 15px;
+ font-variant-numeric: tabular-nums;
+ color: var(--text-strong);
+}
+.l-preview__evts {
+ display: grid;
+ gap: 0;
+}
+.l-preview__evt {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--divider);
+ font-size: 12px;
+}
+.l-preview__evt:last-child { border-bottom: none; }
+.l-preview__evt-name {
+ flex: 1;
+ color: var(--text-2);
+ font-weight: 600;
+ font-size: 12px;
+}
+.l-preview__evt-val {
+ font-family: var(--font-mono);
+ font-size: 11.5px;
+ color: var(--num);
+}
+
+/* Feature grid */
+.l-features {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 0;
+ border: 1px solid var(--border);
+ border-radius: var(--r-lg);
+ overflow: hidden;
+ margin-bottom: clamp(40px, 6vw, 72px);
+}
+.l-feature {
+ padding: 28px 28px;
+ display: flex;
+ align-items: flex-start;
+ gap: 16px;
+ border-right: 1px solid var(--border);
+ border-bottom: 1px solid var(--border);
+}
+.l-feature:nth-child(2n) { border-right: none; }
+.l-feature:nth-last-child(-n+2) { border-bottom: none; }
+.l-feature__icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ flex: none;
+ color: var(--muted);
+ margin-top: 2px;
+}
+.l-feature__body { display: grid; gap: 6px; }
+.l-feature__title {
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--text-strong);
+ margin: 0;
+}
+.l-feature__desc {
+ font-size: 13.5px;
+ color: var(--muted);
+ line-height: 1.6;
+ margin: 0;
+}
+
+/* Explore page */
+.explore-search {
+ display: flex;
+ gap: 8px;
+ max-width: 480px;
+ margin-bottom: 24px;
+}
+.explore-search__input {
+ flex: 1;
+ width: auto;
+}
+.explore-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ gap: 12px;
+}
+.explore-card {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ padding: 20px 22px;
+ border: var(--glass-border);
+ border-radius: var(--r-lg);
+ background: var(--surface);
+ box-shadow: var(--shadow-sm);
+ text-decoration: none;
+ color: inherit;
+ transition: border-color 0.18s, box-shadow 0.18s, transform 0.18s;
+}
+.explore-card:hover {
+ border-color: var(--accent);
+ box-shadow: 0 4px 24px color-mix(in srgb, var(--accent) 12%, transparent), 0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent);
+ transform: translateY(-2px);
+}
+.explore-card__head { display: flex; flex-direction: column; gap: 4px; }
+.explore-card__name { font-size: 16px; font-weight: 700; color: var(--text-strong); margin: 0; }
+.explore-card__desc { font-size: 13px; color: var(--muted); margin: 0; line-height: 1.4; }
+.explore-card__stats {
+ display: flex;
+ gap: 16px;
+ padding: 10px 0;
+ border-top: 1px solid var(--border);
+ border-bottom: 1px solid var(--border);
+}
+.explore-card__stat { display: flex; flex-direction: column; gap: 2px; }
+.explore-card__stat strong { font-size: 18px; font-weight: 700; color: var(--text-strong); }
+.explore-card__footer { margin-top: auto; }
+.explore-card__link { font-size: 13px; color: var(--accent); font-weight: 500; }
+
+/* Info tip */
+.info-tip {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ margin-left: 5px;
+ color: var(--faintest-2);
+ cursor: default;
+ vertical-align: middle;
+ transition: color 120ms;
+}
+.info-tip:hover,
+.info-tip:focus,
+.info-tip.is-open { color: var(--accent); outline: none; }
+.info-tip__bubble {
+ position: fixed;
+ width: max-content;
+ max-width: 260px;
+ padding: 9px 13px;
+ background: var(--surface-head);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--r-sm);
+ box-shadow: 0 8px 24px rgba(0,0,0,.35);
+ font: 400 12.5px/1.5 var(--font-ui);
+ color: var(--text-2);
+ text-transform: none;
+ letter-spacing: 0;
+ white-space: normal;
+ pointer-events: none;
+ opacity: 0;
+ visibility: hidden;
+ transition: opacity 120ms;
+ z-index: 1000;
+}
+.info-tip.is-open .info-tip__bubble { opacity: 1; visibility: visible; }
+
+/* Docs / help page */
+.docs-grid {
+ display: grid;
+ grid-template-columns: 200px 1fr;
+ gap: 48px;
+ align-items: start;
+ padding-top: 12px;
+}
+.docs-nav {
+ position: sticky;
+ top: 80px;
+ display: grid;
+ gap: 4px;
+}
+.docs-nav .kicker { margin-bottom: 6px; }
+.docs-nav a {
+ display: block;
+ font-size: 13.5px;
+ color: var(--muted);
+ text-decoration: none;
+ padding: 4px 0;
+ border-left: 2px solid var(--divider);
+ padding-left: 12px;
+ transition: color 120ms, border-color 120ms;
+}
+.docs-nav a:hover { color: var(--accent); border-left-color: var(--accent); }
+.docs-body {
+ display: grid;
+ gap: 52px;
+ padding-bottom: 80px;
+}
+.docs-section { display: grid; gap: 14px; }
+.docs-h2 {
+ font-size: 22px;
+ font-weight: 700;
+ color: var(--text-strong);
+ margin: 0;
+ padding-bottom: 12px;
+ border-bottom: 1px solid var(--divider);
+}
+.docs-section p {
+ margin: 0;
+ color: var(--muted);
+ font-size: 14.5px;
+ line-height: 1.7;
+}
+.docs-section p strong { color: var(--text-2); }
+.docs-callout {
+ padding: 12px 16px;
+ background: var(--accent-tint);
+ border: 1px solid var(--accent-chip-bd);
+ border-left: 3px solid var(--accent);
+ border-radius: var(--r-sm);
+ font-size: 13.5px;
+ color: var(--text-2);
+ line-height: 1.6;
+}
+.docs-callout strong { color: var(--accent); }
+.docs-term-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+ gap: 10px;
+}
+.docs-term {
+ display: grid;
+ gap: 4px;
+ padding: 14px 16px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--r-sm);
+}
+.docs-term strong {
+ font-size: 13px;
+ color: var(--text-strong);
+}
+.docs-term span {
+ font-size: 12.5px;
+ color: var(--muted);
+ line-height: 1.5;
+}
+.docs-events {
+ display: grid;
+ gap: 12px;
+}
+.docs-event {
+ display: flex;
+ gap: 14px;
+ align-items: flex-start;
+ padding: 14px 16px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--r-sm);
+}
+.docs-event .pill { flex: none; margin-top: 2px; }
+.docs-event div { display: grid; gap: 4px; }
+.docs-event strong { font-size: 13.5px; color: var(--text-strong); }
+.docs-event p { margin: 0; font-size: 13px; color: var(--muted); line-height: 1.55; }
+@media (max-width: 760px) {
+ .docs-grid {
+ grid-template-columns: 1fr;
+ gap: 24px;
+ }
+ .docs-nav {
+ position: static;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ }
+ .docs-nav .kicker { width: 100%; }
+ .docs-nav a {
+ border-left: none;
+ padding-left: 0;
+ border-bottom: 2px solid var(--divider);
+ padding-bottom: 4px;
+ }
+}
+
+@media (max-width: 900px) {
+ .l-preview {
+ grid-template-columns: 1fr;
+ }
+ .l-preview__panel:not(.l-preview__panel--featured) {
+ display: none;
+ }
+ .l-features {
+ grid-template-columns: 1fr 1fr;
+ }
+ .league-row {
+ grid-template-columns: 1fr;
+ grid-template-areas:
+ "identity"
+ "metrics"
+ "leaders"
+ "actions";
+ }
+ .league-row__identity,
+ .league-row__metrics,
+ .league-row__actions {
+ border-right: 0;
+ }
+ .league-row__identity,
+ .league-row__metrics,
+ .league-row__leaders {
+ border-bottom: 1px solid var(--divider);
+ }
+}
+
+@media (max-width: 560px) {
+ .l-features {
+ grid-template-columns: 1fr;
+ }
+ .l-stat-strip {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0;
+ padding: 14px 16px;
+ }
+ .l-stat-strip__item {
+ padding: 10px 12px;
+ border-left: none !important;
+ border-top: 1px solid var(--border);
+ }
+ .l-stat-strip__item:first-child,
+ .l-stat-strip__item:nth-child(2) {
+ border-top: none;
+ }
+ .l-stat-strip__item:nth-child(odd) { border-left: none !important; }
+ .l-stat-strip__item:nth-child(even) { border-left: 1px solid var(--border) !important; }
+ .league-hub-summary,
+ .league-row__metrics {
+ grid-template-columns: 1fr 1fr;
+ }
+ .league-row__title {
+ display: grid;
+ }
+ .league-row__actions .btn {
+ flex: none;
+ }
+}
+
+/* ── Audit page ─────────────────────────────────────────────────── */
+.audit-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ gap: var(--s3);
+ margin-top: 24px;
+}
+
+.audit-alert { padding: 0; }
+.audit-alert .panel__head { padding: 14px 20px; }
+
+.audit-item-list { display: flex; flex-direction: column; }
+
+.audit-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 20px;
+ border-top: 1px solid var(--divider);
+}
+.audit-item:first-child { border-top: none; }
+
+.audit-item > div {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.audit-item__right {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-shrink: 0;
+}
+
+/* ── Void events ────────────────────────────────────────────────── */
+.event-card--voided {
+ opacity: 0.45;
+}
+.event-card--voided .event-amount,
+.event-card--voided strong {
+ text-decoration: line-through;
+}
+
+.void-badge {
+ font: 600 10px/1 var(--font-ui);
+ text-transform: uppercase;
+ letter-spacing: .04em;
+ color: var(--neg);
+ background: color-mix(in srgb, var(--neg) 12%, transparent);
+ border: 1px solid color-mix(in srgb, var(--neg) 30%, transparent);
+ border-radius: var(--r-sm);
+ padding: 2px 6px;
+}
+
+.void-reason { color: var(--neg); font-style: italic; }
+
+.btn-void-toggle {
+ all: unset;
+ cursor: pointer;
+ display: block;
+ font: 400 10px/1 var(--font-ui);
+ letter-spacing: .04em;
+ color: var(--text-3);
+ margin-top: 6px;
+ opacity: 0;
+ transition: opacity 150ms, color 150ms;
+}
+.event-card:hover .btn-void-toggle { opacity: 0.3; }
+.btn-void-toggle:hover { opacity: 1 !important; color: var(--neg); }
+
+
+.member-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 10px 20px;
+ border-bottom: 1px solid var(--border);
+}
+.member-row:last-child { border-bottom: none; }
+.member-row > div { min-width: 0; }
+.member-row strong { font: 600 14px/1.4 var(--font-ui); color: var(--text-1); }
+.access-panel__grid {
+ display: grid;
+ grid-template-columns: minmax(320px, 0.82fr) minmax(320px, 420px);
+ min-height: 228px;
+ border-top: 1px solid var(--border);
+}
+.access-panel__members {
+ min-width: 0;
+}
+.access-panel__invite {
+ border-left: 1px solid var(--border);
+ padding: 18px 20px 20px;
+ gap: 14px;
+}
+.access-panel__invite-title {
+ margin: 0;
+ font: 700 16px/1.25 var(--font-ui);
+ color: var(--text-strong);
+}
+.access-panel__invite .btn {
+ justify-self: start;
+}
+.member-row {
+ align-items: flex-start;
+ padding: 14px 20px;
+}
+.member-row__identity {
+ display: grid;
+ gap: 6px;
+}
+.member-row__actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+.member-role {
+ justify-self: start;
+ padding: 3px 8px;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ font: 700 10px/1 var(--font-ui);
+ letter-spacing: .04em;
+ text-transform: uppercase;
+ color: var(--muted);
+ background: var(--surface-subtle);
+}
+.member-role--owner {
+ color: var(--accent);
+ border-color: color-mix(in srgb, var(--accent) 34%, transparent);
+ background: color-mix(in srgb, var(--accent) 10%, transparent);
+}
+.member-role--manager {
+ color: var(--pos);
+ border-color: var(--pos-tint-bd);
+ background: var(--pos-tint);
+}
+.member-role--viewer {
+ color: var(--text-2);
+}
+@media (max-width: 760px) {
+ .access-panel__grid {
+ grid-template-columns: 1fr;
+ }
+ .access-panel__invite {
+ border-left: none;
+ border-top: 1px solid var(--border);
+ }
+ .member-row {
+ flex-direction: column;
+ align-items: stretch;
+ }
+ .member-row__actions {
+ justify-content: flex-start;
+ }
+}
+
+.card-menu { position: relative; flex-shrink: 0; }
+.card-menu__trigger {
+ all: unset;
+ cursor: pointer;
+ padding: 4px 8px;
+ border-radius: var(--r-sm);
+ color: var(--faint);
+ font-size: 15px;
+ font-weight: 700;
+ letter-spacing: 3px;
+ line-height: 1;
+ transition: color 120ms, background 120ms;
+}
+.card-menu__trigger:hover { color: var(--text-2); background: var(--surface-raised); }
+.card-menu__dropdown {
+ position: absolute;
+ right: 0;
+ top: calc(100% + 4px);
+ z-index: 200;
+ background: var(--surface-raised);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--r-sm);
+ min-width: 136px;
+ padding: 4px;
+ box-shadow: 0 8px 24px rgba(0,0,0,.55);
+}
+.card-menu__item {
+ all: unset;
+ cursor: pointer;
+ display: block;
+ width: 100%;
+ padding: 8px 12px;
+ font: 400 13px/1 var(--font-ui);
+ color: var(--text-2);
+ border-radius: calc(var(--r-sm) - 3px);
+ transition: background 100ms, color 100ms;
+ box-sizing: border-box;
+}
+.card-menu__item:hover { background: var(--field); color: var(--text); }
+.card-menu__item--warn { color: var(--neg); }
+.card-menu__item--warn:hover { background: var(--neg-tint); color: var(--neg); }
+
+/* ================================================================
+ LANDING v2
+ ================================================================ */
+.page--landing {
+ padding: 0;
+ max-width: none;
+}
+.page--landing .flash-stack {
+ max-width: var(--w-public);
+ margin: 0 auto;
+ padding: 16px var(--gutter) 0;
+ width: 100%;
+}
+.page--landing .site-footer,
+.page--auth .site-footer,
+.page--error .site-footer {
+ max-width: var(--w-public);
+ margin-left: auto;
+ margin-right: auto;
+ padding-left: var(--gutter);
+ padding-right: var(--gutter);
+}
+
+/* ================================================================
+ LANDING PAGE
+ ================================================================ */
+.lp-hero {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ gap: clamp(40px, 6vw, 84px);
+ min-height: min(780px, calc(100vh - 62px));
+ padding: clamp(48px, 5vw, 68px) clamp(24px, 5vw, 80px);
+ position: relative;
+ overflow: hidden;
+ background:
+ radial-gradient(ellipse 85% 62% at 50% 108%, rgba(155,140,240,.105) 0%, rgba(155,140,240,.035) 48%, transparent 72%),
+ radial-gradient(ellipse 50% 40% at 88% 16%, rgba(111,192,147,.035) 0%, transparent 58%),
+ linear-gradient(180deg, color-mix(in srgb, var(--accent) 7%, var(--bg)) 0%, var(--bg) 82%),
+ var(--bg);
+}
+.lp-hero::before {
+ content: "♠";
+ position: absolute;
+ font-size: clamp(420px, 55vw, 820px);
+ color: rgba(255,255,255,.035);
+ line-height: 1;
+ pointer-events: none;
+ user-select: none;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+}
+
+.lp-hero__content {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ flex: 1;
+ max-width: 540px;
+}
+.lp-hero__visual {
+ position: relative;
+ z-index: 1;
+ flex: 1;
+ min-width: 0;
+ max-width: 520px;
+}
+
+.lp-eyebrow {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ padding: 5px 14px;
+ border-radius: var(--r-pill);
+ background: var(--surface-raised);
+ border: 1px solid var(--border);
+ color: var(--muted);
+ font: 600 10.5px var(--font-mono);
+ letter-spacing: .10em;
+ text-transform: uppercase;
+ margin-bottom: 20px;
+ align-self: flex-start;
+}
+.lp-eyebrow::before {
+ content: "";
+ width: 5px; height: 5px;
+ border-radius: 50%;
+ background: var(--muted-2);
+ flex-shrink: 0;
+}
+
+.lp-h1 {
+ font-size: clamp(44px, 6.2vw, 82px);
+ font-weight: 800;
+ letter-spacing: -.035em;
+ line-height: .95;
+ color: var(--text);
+ margin: 0 0 24px;
+}
+.lp-h1__line { display: block; }
+.lp-h1__accent { display: block; color: var(--accent); }
+
+.lp-sub {
+ font-size: clamp(14px, 1.2vw, 16px);
+ color: var(--muted);
+ line-height: 1.6;
+ max-width: 44ch;
+ margin: 0 0 28px;
+}
+
+.lp-cta { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 20px; }
+.btn--lg { padding: 13px 26px; font-size: 15px; border-radius: var(--r-md); }
+
+.lp-trust {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+ font-size: 11.5px;
+ color: var(--faint);
+ margin-bottom: 0;
+}
+.lp-trust__sep { color: var(--border-hi); }
+
+/* ---- Demo card ---- */
+.lp-demo {
+ position: relative;
+ z-index: 1;
+ width: 100%;
+ max-width: 860px;
+ margin-top: 52px;
+}
+.lp-demo__card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--r-lg);
+ overflow: hidden;
+ box-shadow: var(--shadow-lg);
+}
+.lp-demo__head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 14px 22px;
+ border-bottom: 1px solid var(--border);
+ background: var(--surface-head);
+ gap: 12px;
+}
+.lp-demo__kicker {
+ display: block;
+ font-size: .6rem;
+ font-family: var(--font-mono);
+ letter-spacing: .12em;
+ text-transform: uppercase;
+ color: var(--muted);
+ margin-bottom: 3px;
+}
+.lp-demo__title {
+ font-size: .875rem;
+ font-weight: 600;
+ color: var(--text-strong);
+}
+.lp-demo__meta {
+ font-size: .72rem;
+ font-family: var(--font-mono);
+ color: var(--faint);
+ letter-spacing: .04em;
+ white-space: nowrap;
+}
+.lp-demo__table { padding: 0; }
+.lp-demo__thead,
+.lp-demo__row {
+ display: grid;
+ grid-template-columns: 36px 1fr repeat(4, minmax(70px, 90px));
+ padding: 0 22px;
+ align-items: center;
+}
+.lp-demo__thead {
+ padding-top: 9px;
+ padding-bottom: 9px;
+ background: var(--surface-head);
+ border-bottom: 1px solid var(--border);
+}
+.lp-demo__th {
+ font-family: var(--font-mono);
+ font-size: .6rem;
+ letter-spacing: .1em;
+ text-transform: uppercase;
+ color: var(--faint);
+ text-align: right;
+}
+.lp-demo__th:nth-child(1),
+.lp-demo__th:nth-child(2) { text-align: left; }
+.lp-demo__row {
+ padding-top: 11px;
+ padding-bottom: 11px;
+ border-bottom: 1px solid var(--divider);
+ transition: background .1s;
+}
+.lp-demo__row:last-child { border-bottom: none; }
+.lp-demo__row:hover { background: var(--surface-raised); }
+.lp-demo__row--fade { opacity: .65; }
+.lp-demo__row--fade2 { opacity: .38; }
+.lp-demo__rank {
+ font-family: var(--font-mono);
+ font-size: .8125rem;
+ font-weight: 700;
+ color: var(--faint);
+ text-align: left;
+}
+.lp-demo__rank--1 { color: #f4c430; }
+.lp-demo__rank--2 { color: #b0b8c1; }
+.lp-demo__rank--3 { color: #c87941; }
+.lp-demo__player {
+ font-size: .875rem;
+ font-weight: 500;
+ color: var(--text-2);
+}
+.lp-demo__num {
+ font-family: var(--font-mono);
+ font-size: .8125rem;
+ color: var(--num);
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+}
+.lp-demo__num--pos { color: var(--pos); }
+.lp-demo__num--neg { color: var(--neg); }
+
+/* ---- Leaderboard demo variant (with rank-delta column) ---- */
+.lp-demo__thead--lb,
+.lp-demo__row--lb {
+ grid-template-columns: 36px 1fr 36px repeat(3, minmax(60px, 80px));
+}
+.lp-demo__delta {
+ font-family: var(--font-mono);
+ font-size: .72rem;
+ font-weight: 700;
+ color: rgba(255,255,255,.22);
+ text-align: left;
+}
+.lp-demo__delta--up { color: var(--pos); }
+.lp-demo__delta--down { color: var(--neg); }
+
+/* ---- Shared section header ---- */
+.lp-sec-head {
+ text-align: center;
+ margin-bottom: 32px;
+}
+.lp-sec-title {
+ font-size: clamp(20px, 2.4vw, 28px);
+ font-weight: 700;
+ color: var(--text);
+ letter-spacing: -.03em;
+ margin: 10px 0 0;
+ line-height: 1.2;
+}
+.lp-sec-sub {
+ font-size: .875rem;
+ color: var(--muted);
+ margin: 8px 0 0;
+ line-height: 1.6;
+}
+
+/* ================================================================
+ FEATURES SECTION
+ ================================================================ */
+.lp-features {
+ padding: clamp(48px, 5vw, 72px) clamp(24px, 5vw, 80px);
+ border-top: 1px solid var(--divider);
+}
+.lp-features__inner {
+ max-width: var(--w-public);
+ margin: 0 auto;
+}
+.lp-feat-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 0;
+ background: var(--border);
+ border: 1px solid var(--border);
+ border-radius: var(--r-lg);
+ overflow: hidden;
+}
+.lp-feat {
+ padding: 24px 28px;
+ background: var(--surface);
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+.lp-feat + .lp-feat { border-left: 1px solid var(--border); }
+.lp-feat__icon {
+ width: 32px;
+ height: 32px;
+ color: var(--muted);
+ opacity: .95;
+ margin-bottom: 2px;
+}
+.lp-feat__icon svg { width: 100%; height: 100%; }
+.lp-feat__title {
+ font-size: .9375rem;
+ font-weight: 700;
+ color: var(--text-strong);
+ letter-spacing: -.015em;
+}
+.lp-feat__desc {
+ font-size: .85rem;
+ color: var(--muted);
+ line-height: 1.7;
+}
+
+/* ================================================================
+ SHOWCASE SECTION
+ ================================================================ */
+.lp-showcase {
+ padding: clamp(48px, 5vw, 72px) clamp(24px, 5vw, 80px);
+ border-top: 1px solid var(--divider);
+ background: color-mix(in srgb, var(--accent) 2.5%, var(--bg));
+}
+.lp-showcase__inner {
+ max-width: var(--w-public);
+ margin: 0 auto;
+}
+.lp-showcase__grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 20px;
+ align-items: start;
+}
+
+/* ---- Sessions demo ---- */
+.lp-sess-list { padding: 0; }
+.lp-sess-row {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 12px 22px;
+ border-bottom: 1px solid var(--divider);
+ transition: background .1s;
+}
+.lp-sess-row:last-child { border-bottom: none; }
+.lp-sess-row:hover { background: var(--surface-raised); }
+.lp-sess-row--fade { opacity: .6; }
+.lp-sess-row--fade2 { opacity: .32; }
+.lp-sess-date {
+ flex-shrink: 0;
+ width: 38px;
+ text-align: center;
+ background: var(--surface-head);
+ border: 1px solid var(--border);
+ border-radius: var(--r-sm);
+ padding: 4px 0;
+}
+.lp-sess-date__mon {
+ display: block;
+ font-family: var(--font-mono);
+ font-size: .58rem;
+ letter-spacing: .1em;
+ text-transform: uppercase;
+ color: var(--muted);
+ line-height: 1;
+}
+.lp-sess-date__day {
+ display: block;
+ font-size: .9375rem;
+ font-weight: 700;
+ color: var(--text-2);
+ line-height: 1.2;
+ margin-top: 1px;
+}
+.lp-sess-info { flex: 1; min-width: 0; }
+.lp-sess-label {
+ display: block;
+ font-size: .8125rem;
+ font-weight: 500;
+ color: var(--text-2);
+ line-height: 1.3;
+}
+.lp-sess-meta {
+ display: block;
+ font-size: .72rem;
+ font-family: var(--font-mono);
+ color: var(--faint);
+ margin-top: 2px;
+}
+.lp-sess-pill {
+ flex-shrink: 0;
+ font-family: var(--font-mono);
+ font-size: .6rem;
+ letter-spacing: .09em;
+ text-transform: uppercase;
+ padding: 3px 8px;
+ border-radius: 999px;
+ font-weight: 600;
+}
+.lp-sess-pill--open {
+ background: rgba(111,192,147,.15);
+ border: 1px solid rgba(111,192,147,.3);
+ color: var(--pos);
+}
+.lp-sess-pill--closed {
+ background: var(--surface-raised);
+ border: 1px solid var(--border);
+ color: var(--faint);
+}
+
+/* ---- Ledger / settlement demo ---- */
+.lp-ledger-stats {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0;
+ border-bottom: 1px solid var(--border);
+}
+.lp-ledger-stat {
+ padding: 14px 22px;
+ border-right: 1px solid var(--border);
+ border-bottom: 1px solid var(--border);
+}
+.lp-ledger-stat:nth-child(2n) { border-right: none; }
+.lp-ledger-stat:nth-last-child(-n+2) { border-bottom: none; }
+.lp-ledger-stat__label {
+ display: block;
+ font-family: var(--font-mono);
+ font-size: .6rem;
+ letter-spacing: .1em;
+ text-transform: uppercase;
+ color: var(--faint);
+ margin-bottom: 4px;
+}
+.lp-ledger-stat__val {
+ font-family: var(--font-mono);
+ font-size: .9375rem;
+ font-weight: 700;
+ color: var(--text-2);
+ font-variant-numeric: tabular-nums;
+}
+.lp-settle-section { padding: 0; }
+.lp-settle-head {
+ padding: 9px 22px;
+ font-family: var(--font-mono);
+ font-size: .6rem;
+ letter-spacing: .1em;
+ text-transform: uppercase;
+ color: var(--faint);
+ background: var(--surface-head);
+ border-bottom: 1px solid var(--border);
+}
+.lp-settle-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 22px;
+ border-bottom: 1px solid var(--divider);
+ transition: background .1s;
+}
+.lp-settle-row:last-child { border-bottom: none; }
+.lp-settle-row:hover { background: var(--surface-raised); }
+.lp-settle-row .lp-demo__meta { flex: 1; text-align: left; }
+
+/* ---- Session results demo (recon metrics + player table) ---- */
+.lp-recon-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1px;
+ background: var(--border);
+ border-bottom: 1px solid var(--border);
+}
+.lp-recon-cell {
+ background: var(--surface);
+ padding: 12px 18px;
+}
+.lp-recon-label {
+ display: block;
+ font-family: var(--font-mono);
+ font-size: .595rem;
+ letter-spacing: .09em;
+ text-transform: uppercase;
+ color: var(--faint);
+ margin-bottom: 5px;
+}
+.lp-recon-val {
+ font-family: var(--font-mono);
+ font-size: 1.1rem;
+ font-weight: 700;
+ color: var(--text-2);
+ font-variant-numeric: tabular-nums;
+}
+.lp-result-thead {
+ display: grid;
+ grid-template-columns: 1fr 64px 72px 90px;
+ padding: 7px 18px;
+ background: var(--surface-head);
+ border-bottom: 1px solid var(--border);
+}
+.lp-result-thead > span {
+ font-family: var(--font-mono);
+ font-size: .595rem;
+ letter-spacing: .09em;
+ text-transform: uppercase;
+ color: var(--faint);
+ text-align: right;
+}
+.lp-result-thead > span:first-child { text-align: left; }
+.lp-result-row {
+ display: grid;
+ grid-template-columns: 1fr 64px 72px 90px;
+ padding: 9px 18px;
+ border-bottom: 1px solid var(--divider);
+ align-items: center;
+ transition: background .1s;
+}
+.lp-result-row:last-child { border-bottom: none; }
+.lp-result-row:hover { background: var(--surface-raised); }
+.lp-result-due {
+ font-family: var(--font-mono);
+ font-size: .72rem;
+ color: var(--neg);
+ opacity: .75;
+ text-align: right;
+}
+.lp-result-settled {
+ font-family: var(--font-mono);
+ font-size: .72rem;
+ color: var(--faintest);
+ text-align: right;
+}
+
+/* ================================================================
+ BOTTOM CTA SECTION
+ ================================================================ */
+.lp-bottom-cta {
+ padding: clamp(48px, 5vw, 72px) clamp(24px, 5vw, 80px);
+ text-align: center;
+ border-top: 1px solid var(--divider);
+ background: var(--bg);
+}
+.lp-bottom-cta__inner {
+ max-width: 600px;
+ margin: 0 auto;
+}
+.lp-bottom-cta__title {
+ font-size: clamp(24px, 3vw, 36px);
+ font-weight: 700;
+ color: var(--text);
+ letter-spacing: -.03em;
+ margin: 0 0 12px;
+}
+.lp-bottom-cta__sub {
+ font-size: .9375rem;
+ color: var(--muted);
+ margin: 0 0 28px;
+ line-height: 1.6;
+}
+
+/* ================================================================
+ LANDING RESPONSIVE
+ ================================================================ */
+@media (max-width: 960px) {
+ .lp-hero {
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: flex-start;
+ min-height: 0;
+ }
+ .lp-hero__content { max-width: 100%; }
+ .lp-hero__visual { max-width: 100%; width: 100%; }
+ .lp-feat-grid { grid-template-columns: 1fr; }
+ .lp-feat + .lp-feat { border-left: none; border-top: 1px solid var(--border); }
+}
+@media (max-width: 680px) {
+ .lp-hero__visual { display: none; }
+ .lp-showcase__grid { grid-template-columns: 1fr; }
+ .lp-demo__thead--lb,
+ .lp-demo__row--lb { grid-template-columns: 28px 1fr 32px 80px; }
+ .lp-demo__thead--lb > span:nth-child(5),
+ .lp-demo__thead--lb > span:nth-child(6),
+ .lp-demo__row--lb > span:nth-child(5),
+ .lp-demo__row--lb > span:nth-child(6) { display: none; }
+}
+@media (max-width: 440px) {
+ .lp-hero { padding-left: 16px; padding-right: 16px; }
+ .lp-features,
+ .lp-showcase,
+ .lp-bottom-cta { padding-left: 16px; padding-right: 16px; }
+ .lp-feat-grid { border-radius: var(--r-md); }
+}
+
+
+/* ================================================================
+ DASHBOARD OVERVIEW REDESIGN
+ ================================================================ */
+.db-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 28px;
+}
+.db-header__chips {
+ display: flex;
+ gap: 6px;
+ margin-bottom: 10px;
+ flex-wrap: wrap;
+ align-items: center;
+}
+.db-header__title {
+ font-size: clamp(22px, 3vw, 30px);
+ font-weight: 800;
+ color: var(--text-strong);
+ letter-spacing: -.025em;
+ line-height: 1.15;
+ margin: 0;
+}
+.db-header__sub {
+ margin: 6px 0 0;
+ color: var(--text-muted);
+ font-size: .875rem;
+ line-height: 1.5;
+}
+.db-header__actions {
+ display: flex;
+ gap: 8px;
+ flex-shrink: 0;
+ padding-top: 2px;
+}
+
+.db-live-alert {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ background: rgba(111,192,147,.07);
+ border: 1px solid rgba(111,192,147,.22);
+ border-radius: var(--r-md);
+ padding: 11px 16px;
+ margin-bottom: 24px;
+ font-size: .875rem;
+ color: var(--pos);
+}
+.db-live-alert__pulse {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--pos);
+ flex-shrink: 0;
+ box-shadow: 0 0 0 0 rgba(111,192,147,.4);
+ animation: pulse-ring 2s ease-in-out infinite;
+}
+@keyframes pulse-ring {
+ 0% { box-shadow: 0 0 0 0 rgba(111,192,147,.5); }
+ 70% { box-shadow: 0 0 0 6px rgba(111,192,147,0); }
+ 100% { box-shadow: 0 0 0 0 rgba(111,192,147,0); }
+}
+.db-live-alert__text { flex: 1; font-weight: 500; }
+.db-live-alert__link { color: var(--pos); font-weight: 600; white-space: nowrap; }
+.db-live-alert__link:hover { text-decoration: underline; }
+
+.db-stat-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 12px;
+ margin-bottom: 24px;
+}
+.db-stat {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--r-md);
+ padding: 16px 18px;
+ display: flex;
+ align-items: center;
+ gap: 14px;
+}
+.db-stat__icon {
+ width: 40px;
+ height: 40px;
+ border-radius: var(--r-sm);
+ background: rgba(255,255,255,.05);
+ color: var(--text-muted);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+.db-stat__icon--accent { background: var(--accent-chip); border: 1px solid var(--accent-chip-bd); color: var(--accent); }
+.db-stat__icon--pos { background: rgba(111,192,147,.12); color: var(--pos); }
+.db-stat__icon--warn { background: rgba(224,177,92,.12); color: var(--warn); }
+.db-stat__num {
+ display: block;
+ font-size: 1.65rem;
+ font-weight: 800;
+ color: var(--text-strong);
+ letter-spacing: -.035em;
+ line-height: 1.1;
+}
+.db-stat__label {
+ display: block;
+ font-size: .72rem;
+ color: var(--text-muted);
+ margin-top: 3px;
+ font-weight: 500;
+ letter-spacing: .02em;
+}
+
+.db-cash {
+ display: flex;
+ align-items: stretch;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--r-lg);
+ overflow: hidden;
+ margin-bottom: 16px;
+}
+.db-cash__stat {
+ flex: 1;
+ padding: 16px 20px;
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ min-width: 0;
+}
+.db-cash__div {
+ width: 1px;
+ background: var(--border);
+ flex-shrink: 0;
+}
+.db-cash__label {
+ font-size: .68rem;
+ font-weight: 600;
+ color: var(--text-muted);
+ letter-spacing: .05em;
+ text-transform: uppercase;
+ font-family: var(--font-mono);
+ white-space: nowrap;
+}
+.db-cash__val {
+ font-size: 1.375rem;
+ font-weight: 800;
+ color: var(--text-strong);
+ letter-spacing: -.03em;
+ line-height: 1.1;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+
+.db-cal {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--r-lg);
+ padding: 16px 20px 18px;
+ margin-bottom: 16px;
+ position: relative;
+}
+.db-cal__hdr {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 12px;
+}
+.db-cal__title {
+ font-size: .68rem;
+ font-weight: 600;
+ color: var(--muted);
+ text-transform: uppercase;
+ letter-spacing: .05em;
+ font-family: var(--font-mono);
+}
+.db-cal__nav-group {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+.db-cal__year {
+ font-size: .78rem;
+ font-weight: 600;
+ color: var(--text-2);
+ font-family: var(--font-mono);
+ min-width: 34px;
+ text-align: center;
+}
+.db-cal__nav {
+ background: none;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ color: var(--muted);
+ cursor: pointer;
+ font-size: 1rem;
+ line-height: 1;
+ padding: 1px 6px 3px;
+ transition: color .15s, border-color .15s;
+}
+.db-cal__nav:hover:not(:disabled) {
+ color: var(--text);
+ border-color: var(--border-strong);
+}
+.db-cal__nav:disabled {
+ opacity: .25;
+ cursor: not-allowed;
+}
+.db-cal__grid {
+ display: grid;
+ grid-template-rows: 14px repeat(7, 1fr);
+ grid-auto-flow: column;
+ grid-auto-columns: 1fr;
+ gap: 3px;
+ height: 132px;
+ width: 100%;
+}
+.db-cal__mc {
+ font-size: .59rem;
+ font-family: var(--font-mono);
+ color: var(--faintest);
+ letter-spacing: .02em;
+ display: flex;
+ align-items: flex-end;
+ padding-bottom: 1px;
+ user-select: none;
+}
+.db-cal__cell {
+ border-radius: 3px;
+ background: var(--border);
+ min-width: 0;
+ min-height: 0;
+}
+.db-cal__cell--on {
+ background: var(--accent);
+ opacity: .82;
+ cursor: default;
+}
+.db-cal__cell--on:hover {
+ opacity: 1;
+}
+.db-cal__cell--multi {
+ background: var(--accent);
+ opacity: 1;
+}
+.db-cal__cell--dim {
+ background: var(--surface-raised);
+}
+.db-cal__cell--out {
+ background: var(--surface-sunk);
+ opacity: .5;
+}
+.db-cal__tip {
+ position: fixed;
+ z-index: 300;
+ background: var(--surface-raised);
+ border: 1px solid var(--border-strong);
+ border-radius: 6px;
+ padding: 5px 10px;
+ font-size: .72rem;
+ color: var(--text-body);
+ pointer-events: none;
+ white-space: nowrap;
+ font-family: var(--font-mono);
+ box-shadow: 0 4px 16px rgba(0,0,0,.45);
+}
+
+.db-nav-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 12px;
+}
+.db-nav-tile {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--r-lg);
+ padding: 20px;
+ display: flex;
+ align-items: flex-start;
+ gap: 16px;
+ text-decoration: none;
+ transition: border-color .15s, background .15s, box-shadow .15s;
+ position: relative;
+}
+.db-nav-tile:hover {
+ border-color: var(--accent-chip-bd);
+ background: rgba(155,140,240,.04);
+ box-shadow: 0 4px 24px rgba(0,0,0,.18);
+}
+.db-nav-tile__icon {
+ width: 44px;
+ height: 44px;
+ border-radius: 12px;
+ background: var(--accent-chip);
+ border: 1px solid var(--accent-chip-bd);
+ color: var(--accent);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ transition: background .15s;
+}
+.db-nav-tile__body { flex: 1; min-width: 0; }
+.db-nav-tile__title-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
+.db-nav-tile__title {
+ font-size: .9375rem;
+ font-weight: 700;
+ color: var(--text-strong);
+}
+.db-nav-tile__count {
+ font-size: .7rem;
+ color: var(--text-muted);
+ font-weight: 600;
+ background: var(--surface-sunk);
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ padding: 2px 9px;
+ line-height: 1.6;
+ white-space: nowrap;
+}
+.db-nav-tile__desc {
+ font-size: .8125rem;
+ color: var(--text-muted);
+ margin: 0;
+ line-height: 1.5;
+}
+.db-nav-tile__arrow {
+ font-size: 17px;
+ color: var(--text-muted);
+ opacity: .3;
+ transition: opacity .15s, transform .15s;
+ flex-shrink: 0;
+ align-self: center;
+}
+.db-nav-tile:hover .db-nav-tile__arrow { opacity: .65; transform: translateX(4px); }
+
+/* Per-section tile accents */
+.db-nav-tile--sessions {
+ border-left: 3px solid rgba(111,192,147,.32);
+}
+.db-nav-tile--sessions .db-nav-tile__icon {
+ background: rgba(111,192,147,.12);
+ border: 1px solid rgba(111,192,147,.28);
+ color: var(--pos);
+}
+.db-nav-tile--sessions:hover { border-left-color: rgba(111,192,147,.55); }
+
+.db-nav-tile--leaderboard {
+ border-left: 3px solid rgba(90,168,232,.28);
+}
+.db-nav-tile--leaderboard .db-nav-tile__icon {
+ background: rgba(90,168,232,.09);
+ border: 1px solid rgba(90,168,232,.24);
+ color: #5aa8e8;
+}
+.db-nav-tile--leaderboard:hover { border-left-color: rgba(90,168,232,.5); }
+
+.db-nav-tile--players {
+ border-left: 3px solid rgba(155,140,240,.3);
+}
+.db-nav-tile--players .db-nav-tile__icon {
+ background: var(--accent-chip);
+ border: 1px solid var(--accent-chip-bd);
+ color: var(--accent);
+}
+.db-nav-tile--players:hover { border-left-color: rgba(155,140,240,.55); }
+
+.db-nav-tile--ledger {
+ border-left: 3px solid rgba(224,177,92,.28);
+}
+.db-nav-tile--ledger .db-nav-tile__icon {
+ background: rgba(224,177,92,.1);
+ border: 1px solid rgba(224,177,92,.24);
+ color: var(--warn);
+}
+.db-nav-tile--ledger:hover { border-left-color: rgba(224,177,92,.5); }
+
+.db-nav-tile--seasons {
+ border-left: 3px solid rgba(155,140,240,.28);
+}
+.db-nav-tile--seasons .db-nav-tile__icon {
+ background: rgba(155,140,240,.1);
+ border: 1px solid rgba(155,140,240,.24);
+ color: var(--accent);
+}
+.db-nav-tile--seasons:hover { border-left-color: rgba(155,140,240,.55); }
+
+/* Live tile overrides the sessions color */
+.db-nav-tile--sessions.db-nav-tile--live {
+ border-left-color: rgba(111,192,147,.55);
+ background: rgba(111,192,147,.04);
+}
+.db-nav-tile--sessions.db-nav-tile--live:hover {
+ border-left-color: rgba(111,192,147,.78);
+ background: rgba(111,192,147,.08);
+}
+.db-nav-tile--sessions.db-nav-tile--live .db-nav-tile__icon {
+ background: rgba(111,192,147,.18);
+ border-color: rgba(111,192,147,.4);
+}
+
+@media (max-width: 960px) {
+ .db-stat-grid { grid-template-columns: repeat(2, 1fr); }
+ .db-nav-grid { grid-template-columns: 1fr; }
+}
+@media (max-width: 700px) {
+ .db-cash { flex-wrap: wrap; }
+ .db-cash__stat { flex: 1 1 40%; }
+ .db-cash__div { display: none; }
+ .db-cal__grid { height: 110px; }
+}
+@media (max-width: 560px) {
+ .db-header { flex-direction: column; gap: 12px; }
+ .db-cash__val { font-size: 1.125rem; }
+ .db-cal__grid { height: 96px; }
+ .db-cal__mc { font-size: .52rem; }
+}
+
+/* ================================================================
+ LEAGUES INDEX — card list
+ ================================================================ */
+.li-board {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+.li-card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--r-lg);
+ overflow: hidden;
+ transition: border-color .15s, box-shadow .15s;
+ display: flex;
+ flex-direction: row;
+}
+.li-card:hover { border-color: var(--border-strong); box-shadow: 0 2px 16px rgba(0,0,0,.14); }
+.li-card--live { border-color: rgba(111,192,147,.3); }
+.li-card--live:hover { border-color: rgba(111,192,147,.5); }
+
+/* left initial badge column */
+.li-card__aside {
+ width: 56px;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--surface-sunk);
+ border-right: 1px solid var(--border);
+}
+.li-card--live .li-card__aside {
+ background: rgba(111,192,147,.05);
+ border-right-color: rgba(111,192,147,.2);
+}
+.li-card__initial {
+ font-size: 1.375rem;
+ font-weight: 800;
+ color: var(--accent);
+ letter-spacing: -.03em;
+ line-height: 1;
+ opacity: .75;
+ user-select: none;
+}
+.li-card--live .li-card__initial { color: var(--pos); }
+
+/* right content column */
+.li-card__body {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+/* top section: name + badges + desc on left, stats on right */
+.li-card__head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 20px;
+ padding: 18px 20px 14px;
+}
+.li-card__info { flex: 1; min-width: 0; }
+.li-card__name-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 6px;
+ flex-wrap: wrap;
+}
+.li-card__name {
+ font-size: 1.05rem;
+ font-weight: 700;
+ color: var(--text-strong);
+ text-decoration: none;
+ line-height: 1.25;
+}
+.li-card__name:hover { color: var(--accent); }
+.li-card__chips {
+ display: flex;
+ gap: 5px;
+ flex-wrap: wrap;
+ margin-bottom: 6px;
+}
+.li-card__desc {
+ font-size: .8125rem;
+ color: var(--text-muted);
+ margin: 0;
+ line-height: 1.5;
+}
+.li-card__stats {
+ display: flex;
+ gap: 20px;
+ flex-shrink: 0;
+ align-self: flex-start;
+ padding-top: 2px;
+}
+.li-stat {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 2px;
+}
+.li-stat span {
+ font-size: .65rem;
+ color: var(--text-muted);
+ font-weight: 500;
+ letter-spacing: .05em;
+ text-transform: uppercase;
+ white-space: nowrap;
+}
+.li-stat strong {
+ font-size: 1rem;
+ font-weight: 700;
+ color: var(--text-strong);
+ font-variant-numeric: tabular-nums;
+ font-family: var(--font-mono);
+}
+
+/* leaders strip — podium chips */
+.li-card__leaders {
+ display: flex;
+ align-items: center;
+ gap: 0;
+ padding: 9px 20px;
+ border-top: 1px solid var(--border);
+ background: rgba(255,255,255,.015);
+ overflow: hidden;
+}
+.li-card__leaders-label {
+ font-size: .6rem;
+ font-weight: 600;
+ letter-spacing: .09em;
+ text-transform: uppercase;
+ color: var(--text-muted);
+ margin-right: 14px;
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+.li-podium {
+ display: flex;
+ gap: 7px;
+ flex: 1;
+ min-width: 0;
+ flex-wrap: wrap;
+}
+.li-podium-item {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ padding: 4px 10px 4px 7px;
+ border-radius: var(--r-md);
+ border: 1px solid var(--border);
+ background: var(--surface-sunk);
+ flex-shrink: 0;
+}
+.li-podium-item--1 { border-color: rgba(244,196,48,.3); background: rgba(244,196,48,.05); }
+.li-podium-item--2 { border-color: rgba(176,184,193,.2); background: rgba(176,184,193,.04); }
+.li-podium-item--3 { border-color: rgba(200,121,65,.22); background: rgba(200,121,65,.04); }
+.li-podium-rank {
+ font-family: var(--font-mono);
+ font-size: .65rem;
+ font-weight: 700;
+ color: var(--text-muted);
+}
+.li-podium-item--1 .li-podium-rank { color: #f4c430; }
+.li-podium-item--2 .li-podium-rank { color: #b0b8c1; }
+.li-podium-item--3 .li-podium-rank { color: #c87941; }
+.li-podium-name {
+ font-size: .8125rem;
+ color: var(--text-2);
+}
+.li-podium-net {
+ font-family: var(--font-mono);
+ font-size: .75rem;
+ font-variant-numeric: tabular-nums;
+ font-weight: 600;
+}
+
+/* footer: meta + actions */
+.li-card__footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 9px 20px;
+ border-top: 1px solid var(--border);
+}
+.li-card__meta {
+ font-size: .75rem;
+ color: var(--text-muted);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.li-card__actions {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ flex-shrink: 0;
+}
+.li-action-link {
+ font-size: .75rem;
+ color: var(--text-muted);
+ padding: 4px 9px;
+ border-radius: var(--r-sm);
+ text-decoration: none;
+ transition: color .12s, background .12s;
+ font-weight: 500;
+}
+.li-action-link:hover { color: var(--text-2); background: var(--surface-raised); }
+
+@media (max-width: 640px) {
+ .li-card__aside { width: 44px; }
+ .li-card__initial { font-size: 1.1rem; }
+ .li-card__head { flex-direction: column; gap: 12px; }
+ .li-card__stats { flex-direction: row; align-self: auto; }
+ .li-stat { align-items: flex-start; }
+ .li-card__footer { flex-wrap: wrap; gap: 8px; }
+ .li-action-link { display: none; }
+}
+@media (max-width: 400px) {
+ .li-card__aside { display: none; }
+}
+
+
+/* ================================================================
+ ACCOUNT SETTINGS REDESIGN
+ ================================================================ */
+.settings-profile {
+ display: flex;
+ align-items: center;
+ gap: 18px;
+ margin-bottom: 28px;
+ padding-bottom: 24px;
+ border-bottom: 1px solid var(--border);
+}
+.settings-avatar {
+ width: 52px;
+ height: 52px;
+ border-radius: 50%;
+ background: var(--accent-chip);
+ border: 2px solid var(--accent-chip-bd);
+ color: var(--accent);
+ font-size: 1.25rem;
+ font-weight: 700;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ letter-spacing: -.01em;
+}
+.settings-profile__title {
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: var(--text-strong);
+ margin: 0 0 4px;
+}
+.settings-profile__email {
+ font-size: .875rem;
+ color: var(--text-muted);
+ margin: 0;
+}
+.settings-profile__meta { display: flex; flex-direction: column; }
+
+/* ================================================================
+ EXPLORE PAGE REDESIGN
+ ================================================================ */
+.explore-header {
+ margin-bottom: 28px;
+}
+.explore-header__eyebrow {
+ font: 600 11px var(--font-mono);
+ letter-spacing: .1em;
+ text-transform: uppercase;
+ color: var(--accent);
+ margin: 0 0 10px;
+}
+.explore-header__title {
+ font-size: clamp(22px, 3vw, 30px);
+ font-weight: 800;
+ color: var(--text-strong);
+ letter-spacing: -.025em;
+ margin: 0 0 6px;
+}
+.explore-header__sub {
+ font-size: .875rem;
+ color: var(--text-muted);
+ margin: 0;
+}
+
+.explore-search-row {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 28px;
+ max-width: 560px;
+}
+.explore-search-row .field-input {
+ flex: 1;
+ background: var(--surface);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--r-md);
+ padding: 10px 14px;
+ font-size: .9375rem;
+ color: var(--text);
+ transition: border-color .15s, box-shadow .15s;
+}
+.explore-search-row .field-input:focus {
+ outline: none;
+ border-color: var(--accent-chip-bd);
+ box-shadow: 0 0 0 3px var(--accent-a22);
+}
+.explore-search-row .field-input::placeholder { color: var(--text-muted); }
+
+.explore-cards {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 16px;
+}
+.ex-card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--r-lg);
+ padding: 20px;
+ text-decoration: none;
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ transition: border-color .15s, box-shadow .15s;
+}
+.ex-card:hover {
+ border-color: var(--accent-chip-bd);
+ box-shadow: 0 4px 24px rgba(0,0,0,.2);
+}
+.ex-card__initial {
+ width: 40px;
+ height: 40px;
+ border-radius: var(--r-sm);
+ background: var(--accent-chip);
+ border: 1px solid var(--accent-chip-bd);
+ color: var(--accent);
+ font-size: 1rem;
+ font-weight: 700;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-bottom: 14px;
+ flex-shrink: 0;
+}
+.ex-card__name {
+ font-size: 1rem;
+ font-weight: 700;
+ color: var(--text-strong);
+ margin: 0 0 6px;
+}
+.ex-card__desc {
+ font-size: .8125rem;
+ color: var(--text-muted);
+ margin: 0 0 16px;
+ line-height: 1.5;
+ flex: 1;
+}
+.ex-card__stats {
+ display: flex;
+ gap: 16px;
+ border-top: 1px solid var(--border);
+ padding-top: 14px;
+ margin-top: auto;
+}
+.ex-card__stat { display: flex; flex-direction: column; gap: 2px; }
+.ex-card__stat .kicker { font-size: .65rem; }
+.ex-card__stat strong { font-size: .9375rem; font-weight: 700; color: var(--text-strong); }
+.ex-card__footer {
+ display: flex;
+ justify-content: flex-end;
+ padding-top: 12px;
+ margin-top: 2px;
+}
+.ex-card__link {
+ font-size: .75rem;
+ font-weight: 600;
+ color: var(--accent);
+ transition: gap .15s;
+}
+
+/* ================================================================
+ LANDING — stats bar
+ ================================================================ */
+.lp-stats-bar {
+ border-top: 1px solid var(--divider);
+ border-bottom: 1px solid var(--divider);
+ background: var(--surface-sunk);
+}
+.lp-stats-bar__inner {
+ max-width: var(--w-public);
+ margin: 0 auto;
+ padding: 22px clamp(24px, 5vw, 80px);
+ display: flex;
+ align-items: center;
+ justify-content: space-around;
+ gap: 16px;
+ flex-wrap: wrap;
+}
+.lp-stats-bar__item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 5px;
+ text-align: center;
+}
+.lp-stats-bar__val {
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: var(--text-strong);
+ letter-spacing: -.03em;
+ line-height: 1;
+}
+.lp-stats-bar__lbl {
+ font-family: var(--font-mono);
+ font-size: .6rem;
+ letter-spacing: .1em;
+ text-transform: uppercase;
+ color: var(--faint);
+}
+.lp-stats-bar__sep {
+ width: 1px;
+ height: 36px;
+ background: var(--border);
+ flex-shrink: 0;
+}
+@media (max-width: 680px) {
+ .lp-stats-bar__sep { display: none; }
+ .lp-stats-bar__inner { justify-content: center; gap: 16px 32px; }
+}
+
+/* ================================================================
+ LANDING — leaderboard callout section
+ ================================================================ */
+.lp-callout {
+ padding: clamp(56px, 6vw, 88px) clamp(24px, 5vw, 80px);
+ border-top: 1px solid var(--divider);
+ background:
+ radial-gradient(ellipse 48% 62% at 10% 52%, rgba(155,140,240,.045) 0%, transparent 62%),
+ var(--bg);
+}
+.lp-callout__inner {
+ max-width: var(--w-public);
+ margin: 0 auto;
+ display: grid;
+ grid-template-columns: 1fr 1.25fr;
+ gap: clamp(40px, 6vw, 88px);
+ align-items: center;
+}
+.lp-callout__text {
+ max-width: 480px;
+}
+.lp-callout__title {
+ font-size: clamp(22px, 2.8vw, 34px);
+ font-weight: 700;
+ color: var(--text);
+ letter-spacing: -.035em;
+ line-height: 1.15;
+ margin: 10px 0 16px;
+}
+.lp-callout__body {
+ font-size: .9375rem;
+ color: var(--muted);
+ line-height: 1.72;
+ margin: 0 0 22px;
+}
+.lp-callout__list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ gap: 9px;
+}
+.lp-callout__list li {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: .875rem;
+ color: var(--text-2);
+ line-height: 1.4;
+}
+.lp-callout__list li::before {
+ content: "";
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: var(--muted-2);
+ flex-shrink: 0;
+ opacity: .75;
+}
+.lp-callout__demo {
+ position: relative;
+}
+@media (max-width: 900px) {
+ .lp-callout__inner { grid-template-columns: 1fr; }
+ .lp-callout__text { max-width: none; }
+}
+@media (max-width: 440px) {
+ .lp-callout { padding-left: 16px; padding-right: 16px; }
+}
+
+/* ================================================================
+ AUTH SPLIT LAYOUT
+ ================================================================ */
+.page--auth {
+ padding: 0;
+ max-width: none;
+}
+
+.auth-split {
+ display: flex;
+ min-height: calc(100vh - 62px);
+}
+
+.auth-split__form-col {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: clamp(40px, 5vw, 72px) clamp(24px, 5vw, 60px);
+ min-width: 0;
+}
+
+.auth-split__form-inner {
+ width: 100%;
+ max-width: 380px;
+}
+
+.auth-split__brand {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ text-decoration: none;
+ margin-bottom: 36px;
+}
+
+.auth-split__brand-mark {
+ width: 28px;
+ height: 28px;
+ border-radius: 7px;
+ background: var(--accent);
+ box-shadow: inset 0 0 0 1px rgba(255,255,255,.14);
+ position: relative;
+ flex: none;
+}
+.auth-split__brand-mark::after {
+ content: "";
+ position: absolute;
+ inset: 0; margin: auto;
+ width: 11px; height: 11px;
+ border-radius: 2px;
+ background: var(--accent-ink);
+ transform: rotate(45deg);
+}
+
+.auth-split__brand-name {
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--text);
+ letter-spacing: -.01em;
+}
+
+.auth-split__brand-tld {
+ color: var(--faintest);
+ font-weight: 400;
+}
+
+.auth-split__title {
+ font-size: clamp(24px, 3vw, 32px);
+ font-weight: 700;
+ color: var(--text);
+ letter-spacing: -.035em;
+ margin: 0 0 8px;
+ line-height: 1.1;
+}
+
+.auth-split__sub {
+ font-size: .9375rem;
+ color: var(--muted);
+ margin: 0 0 28px;
+ line-height: 1.58;
+}
+
+.auth-field {
+ display: grid;
+ gap: 7px;
+ margin-bottom: 16px;
+}
+
+.auth-field label {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ letter-spacing: .1em;
+ text-transform: uppercase;
+ color: var(--faint);
+}
+
+.auth-field input {
+ width: 100%;
+ padding: 11px 14px;
+ background: var(--field);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--r-sm);
+ color: var(--text);
+ font: 14px var(--font-ui);
+ outline: none;
+ transition: border-color .15s, box-shadow .15s;
+}
+
+.auth-field input:focus {
+ border-color: var(--accent-chip-bd);
+ box-shadow: 0 0 0 3px var(--accent-a22);
+}
+
+.auth-field input::placeholder {
+ color: var(--faintest-2);
+}
+
+.auth-field--code {
+ margin-bottom: 18px;
+}
+
+.auth-code-input {
+ text-align: center;
+ font-family: var(--font-mono) !important;
+ font-size: 30px !important;
+ font-weight: 800 !important;
+ letter-spacing: .38em;
+}
+
+.auth-resend-form {
+ margin-top: 16px;
+ text-align: center;
+}
+
+.auth-link-button {
+ all: unset;
+ cursor: pointer;
+ color: var(--muted);
+ font: 600 13px/1.2 var(--font-ui);
+}
+.auth-link-button:hover {
+ color: var(--accent);
+ text-decoration: underline;
+}
+
+.auth-links {
+ margin: -8px 0 16px;
+ text-align: right;
+}
+
+.auth-links a {
+ font-size: .8125rem;
+ color: var(--muted);
+ transition: color .13s;
+}
+.auth-links a:hover { color: var(--accent); }
+
+.auth-submit {
+ width: 100%;
+ justify-content: center;
+ margin-top: 4px;
+ padding: 12px 24px;
+ font-size: 15px;
+}
+
+.auth-alt {
+ margin: 20px 0 0;
+ font-size: .875rem;
+ color: var(--faint);
+ text-align: center;
+}
+.auth-alt a {
+ color: var(--accent);
+ font-weight: 600;
+}
+.auth-alt a:hover { text-decoration: underline; }
+
+.auth-split__visual {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ overflow: hidden;
+ background:
+ radial-gradient(ellipse 76% 76% at 50% 48%, rgba(155,140,240,.13) 0%, rgba(155,140,240,.045) 48%, transparent 72%),
+ color-mix(in srgb, var(--accent) 5%, var(--bg));
+ border-left: 1px solid var(--divider);
+}
+
+.auth-split__visual::before {
+ position: absolute;
+ font-size: clamp(300px, 38vw, 520px);
+ color: rgba(255,255,255,.04);
+ line-height: 1;
+ pointer-events: none;
+ user-select: none;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+}
+.auth-split__visual--club::before { content: "♣"; }
+.auth-split__visual--diamond::before { content: "♦"; }
+
+.auth-visual__body {
+ position: relative;
+ z-index: 1;
+ max-width: 320px;
+ padding: 24px;
+}
+
+.auth-visual__icon {
+ width: 52px;
+ height: 52px;
+ background: var(--accent-chip);
+ border: 1px solid var(--accent-chip-bd);
+ border-radius: var(--r-md);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-bottom: 20px;
+ box-shadow: none;
+}
+
+.auth-visual__suit-sym {
+ font-size: 1.625rem;
+ line-height: 1;
+ color: var(--accent);
+}
+
+.auth-visual__title {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: var(--text);
+ letter-spacing: -.03em;
+ margin: 0 0 10px;
+ line-height: 1.2;
+}
+
+.auth-visual__sub {
+ font-size: .875rem;
+ color: var(--muted);
+ line-height: 1.65;
+ margin: 0 0 24px;
+}
+
+.auth-visual__preview {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--r-md);
+ padding: 16px 18px;
+}
+
+.auth-visual__preview-kicker {
+ font-family: var(--font-mono);
+ font-size: .6rem;
+ letter-spacing: .1em;
+ text-transform: uppercase;
+ color: var(--muted);
+ margin-bottom: 13px;
+}
+
+.auth-visual__preview-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 7px 0;
+ border-bottom: 1px solid var(--divider);
+ font-size: .875rem;
+ color: var(--text-2);
+}
+.auth-visual__preview-row:last-child { border-bottom: none; }
+.auth-visual__preview-row strong { font-weight: 600; }
+
+@media (max-width: 800px) {
+ .auth-split__visual { display: none; }
+ .auth-split__form-col { min-height: calc(100vh - 62px); }
+}
+@media (max-width: 480px) {
+ .auth-split__form-col {
+ padding: 32px 24px;
+ align-items: flex-start;
+ }
+}
+
+/* ================================================================
+ ERROR PAGES (404, 403)
+ ================================================================ */
+.page--error {
+ padding: 0;
+ max-width: none;
+}
+
+.error-pg {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: calc(100vh - 62px);
+ position: relative;
+ overflow: hidden;
+ background: var(--bg);
+}
+
+.error-pg__suit {
+ position: absolute;
+ font-size: clamp(320px, 46vw, 660px);
+ color: rgba(255,255,255,.04);
+ line-height: 1;
+ pointer-events: none;
+ user-select: none;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ transition: none;
+}
+.error-pg--403 .error-pg__suit {
+ color: rgba(155,140,240,.06);
+}
+.error-pg--500 .error-pg__suit {
+ color: rgba(224,177,92,.07);
+}
+
+.error-pg__body {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ gap: 0;
+ padding: clamp(32px, 5vw, 64px) clamp(24px, 5vw, 48px);
+}
+
+.error-pg__code {
+ font-family: var(--font-mono);
+ font-size: clamp(80px, 13vw, 148px);
+ font-weight: 900;
+ letter-spacing: -.04em;
+ line-height: 1;
+ color: var(--accent);
+ margin-bottom: 8px;
+}
+
+.error-pg__title {
+ font-size: clamp(28px, 4vw, 52px);
+ font-weight: 700;
+ color: var(--text);
+ letter-spacing: -.04em;
+ margin: 0 0 16px;
+ line-height: 1.1;
+}
+
+.error-pg__sub {
+ font-size: clamp(14px, 1.3vw, 16px);
+ color: var(--muted);
+ max-width: 40ch;
+ line-height: 1.68;
+ margin: 0 0 28px;
+}
+
+.error-pg__actions {
+ display: flex;
+ gap: 12px;
+ flex-wrap: wrap;
+ justify-content: center;
+}
+
+.error-pg__trace {
+ margin: -12px 0 28px;
+ padding: 6px 10px;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ color: var(--faint);
+ background: var(--panel);
+ font-family: var(--font-mono);
+ font-size: 12px;
+ line-height: 1.4;
+}
+
+/* ================================================================
+ LANDING — hero kicker (replaces pill eyebrow in hero only)
+ ================================================================ */
+.lp-kicker {
+ font-family: var(--font-mono);
+ font-size: .625rem;
+ letter-spacing: .18em;
+ text-transform: uppercase;
+ color: var(--faint);
+ margin: 0 0 18px;
+ display: block;
+}
+
+/* ================================================================
+ LANDING — hero live feed card
+ ================================================================ */
+.lp-live-dot {
+ font-family: var(--font-mono);
+ font-size: .6rem;
+ letter-spacing: .06em;
+ text-transform: uppercase;
+ color: var(--pos);
+ margin-left: 8px;
+ font-weight: 600;
+}
+
+.lp-feed__rows { padding: 0; }
+
+.lp-feed__row {
+ display: grid;
+ grid-template-columns: 38px 1fr auto auto;
+ gap: 10px;
+ align-items: center;
+ padding: 10px 22px;
+ border-bottom: 1px solid var(--divider);
+ transition: background .1s;
+}
+.lp-feed__row:last-child { border-bottom: none; }
+.lp-feed__row:hover { background: var(--surface-raised); }
+
+.lp-feed__time {
+ font-family: var(--font-mono);
+ font-size: .635rem;
+ color: var(--faintest);
+ white-space: nowrap;
+}
+
+.lp-feed__player {
+ font-size: .875rem;
+ font-weight: 500;
+ color: var(--text-2);
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.lp-feed__pill {
+ font-family: var(--font-mono);
+ font-size: .58rem;
+ letter-spacing: .07em;
+ text-transform: uppercase;
+ padding: 2px 7px;
+ border-radius: 4px;
+ font-weight: 600;
+ white-space: nowrap;
+}
+.lp-feed__pill--buyin {
+ background: var(--surface-raised);
+ color: var(--text-2);
+ border: 1px solid var(--border);
+}
+.lp-feed__pill--cashout {
+ background: rgba(111,192,147,.12);
+ color: var(--pos);
+ border: 1px solid rgba(111,192,147,.28);
+}
+.lp-feed__pill--rebuy {
+ background: rgba(224,177,92,.1);
+ color: var(--warn);
+ border: 1px solid rgba(224,177,92,.28);
+}
+
+.lp-feed__amt {
+ font-family: var(--font-mono);
+ font-size: .8125rem;
+ color: var(--num);
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+
+.lp-feed__summary {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 1px;
+ background: var(--border);
+ border-top: 1px solid var(--border);
+}
+.lp-feed__summary-cell {
+ background: var(--surface);
+ padding: 11px 16px;
+}
+
+/* ================================================================
+ LEGAL DOCUMENT PAGES (privacy, terms)
+ ================================================================ */
+.legal-doc {
+ max-width: 740px;
+ margin: 0 auto;
+ padding: clamp(40px, 5vw, 72px) 0 clamp(48px, 6vw, 80px);
+}
+.legal-doc__header {
+ margin-bottom: clamp(32px, 4vw, 52px);
+ padding-bottom: clamp(24px, 3vw, 36px);
+ border-bottom: 1px solid var(--divider);
+}
+.legal-doc__title {
+ font-size: clamp(26px, 3.5vw, 40px);
+ font-weight: 800;
+ color: var(--text-strong);
+ letter-spacing: -.04em;
+ line-height: 1.1;
+ margin: 10px 0 12px;
+}
+.legal-doc__meta {
+ font-size: .8125rem;
+ color: var(--text-muted);
+ margin: 0;
+}
+.legal-doc__meta strong { color: var(--faint-2); font-weight: 500; }
+.legal-doc__body { font-size: .9375rem; line-height: 1.8; color: var(--text-2); }
+.legal-doc__body h2 {
+ font-size: 1rem;
+ font-weight: 700;
+ color: var(--text-strong);
+ letter-spacing: -.015em;
+ margin: clamp(32px, 4vw, 48px) 0 12px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid var(--divider);
+}
+.legal-doc__body h3 {
+ font-size: .875rem;
+ font-weight: 600;
+ color: var(--text);
+ margin: 20px 0 8px;
+}
+.legal-doc__body p { margin: 0 0 14px; }
+.legal-doc__body ul {
+ margin: 0 0 14px;
+ padding-left: 20px;
+ display: grid;
+ gap: 6px;
+}
+.legal-doc__body ul li { color: var(--text-2); }
+.legal-doc__body a {
+ color: var(--accent);
+ text-decoration: none;
+ transition: opacity .13s;
+}
+.legal-doc__body a:hover { opacity: .75; }
+.legal-doc__body code {
+ font-family: var(--font-mono);
+ font-size: .8125rem;
+ background: var(--surface-raised);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ padding: 1px 6px;
+ color: var(--text);
+}
+.legal-doc__body em { color: var(--text); font-style: italic; }
+.legal-doc__body strong { color: var(--text-strong); font-weight: 600; }
+
+/* ================================================================
+ SUPPORT / HELP PAGE
+ ================================================================ */
+.support-pg {
+ max-width: 820px;
+ margin: 0 auto;
+ padding: clamp(40px, 5vw, 72px) 0 0;
+}
+.support-pg__header {
+ margin-bottom: clamp(36px, 5vw, 60px);
+}
+.support-pg__title {
+ font-size: clamp(26px, 3.5vw, 40px);
+ font-weight: 800;
+ color: var(--text-strong);
+ letter-spacing: -.04em;
+ line-height: 1.1;
+ margin: 10px 0 12px;
+}
+.support-pg__sub {
+ font-size: .9375rem;
+ color: var(--text-muted);
+ margin: 0;
+ max-width: 540px;
+}
+.support-pg__body { display: grid; gap: clamp(28px, 4vw, 48px); }
+
+/* FAQ section grouping */
+.faq-section {}
+.faq-section__title {
+ font-size: .6875rem;
+ font-family: var(--font-mono);
+ letter-spacing: .13em;
+ text-transform: uppercase;
+ color: var(--accent);
+ margin: 0 0 14px;
+ padding-bottom: 10px;
+ border-bottom: 1px solid var(--divider);
+}
+
+/* FAQ accordion item */
+.faq-item {
+ border-bottom: 1px solid var(--divider);
+}
+.faq-item__q {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 14px 0;
+ font-size: .9375rem;
+ font-weight: 600;
+ color: var(--text);
+ cursor: pointer;
+ list-style: none;
+ user-select: none;
+ transition: color .13s;
+}
+.faq-item__q::-webkit-details-marker { display: none; }
+.faq-item__q::after {
+ content: "+";
+ font-size: 1.125rem;
+ font-weight: 400;
+ color: var(--text-muted);
+ flex-shrink: 0;
+ transition: transform .2s, color .13s;
+}
+.faq-item[open] > .faq-item__q { color: var(--text-strong); }
+.faq-item[open] > .faq-item__q::after {
+ content: "−";
+ color: var(--accent);
+}
+.faq-item__q:hover { color: var(--text-strong); }
+.faq-item__a {
+ padding: 0 0 18px;
+ font-size: .9rem;
+ line-height: 1.78;
+ color: var(--text-2);
+}
+.faq-item__a p { margin: 0 0 10px; }
+.faq-item__a p:last-child { margin-bottom: 0; }
+.faq-item__a ul {
+ margin: 8px 0 10px;
+ padding-left: 20px;
+ display: grid;
+ gap: 5px;
+}
+.faq-item__a a {
+ color: var(--accent);
+ text-decoration: none;
+ transition: opacity .13s;
+}
+.faq-item__a a:hover { opacity: .75; }
+.faq-item__a strong { color: var(--text-strong); font-weight: 600; }
+.faq-item__a em { font-style: italic; color: var(--text); }
+
+/* Contact section */
+.support-contact {
+ margin-top: clamp(48px, 6vw, 80px);
+ border-top: 1px solid var(--divider);
+ padding: clamp(36px, 5vw, 60px) 0 clamp(48px, 6vw, 72px);
+}
+.support-contact__inner {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 32px;
+ flex-wrap: wrap;
+}
+.support-contact__title {
+ font-size: clamp(18px, 2.2vw, 26px);
+ font-weight: 800;
+ color: var(--text-strong);
+ letter-spacing: -.03em;
+ margin: 0 0 8px;
+}
+.support-contact__body {
+ font-size: .875rem;
+ color: var(--text-muted);
+ line-height: 1.65;
+ margin: 0;
+ max-width: 480px;
+}
+.support-contact__btn { flex-shrink: 0; }
+@media (max-width: 580px) {
+ .support-contact__inner { flex-direction: column; align-items: flex-start; }
+ .support-contact__btn { width: 100%; text-align: center; }
+}
diff --git a/static/css/tokens.css b/static/css/tokens.css
index 00e4681..01e3000 100644
--- a/static/css/tokens.css
+++ b/static/css/tokens.css
@@ -1,34 +1,36 @@
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
+
:root {
- /* ---- surfaces (darkest → lightest) ---- */
- --bg: #0f0f12;
- --surface: #16161c;
- --surface-raised:#1a1a20;
- --surface-head: #1c1c24;
- --surface-sunk: #131318;
- --field: #1f1f27;
- --field-alt: #20202a;
+ /* Core surfaces */
+ --bg: #0a0a0d;
+ --surface: #111115;
+ --surface-raised:#16161c;
+ --surface-head: #18181f;
+ --surface-sunk: #0d0d11;
+ --field: #1a1a22;
+ --field-alt: #1c1c25;
/* ---- borders / dividers ---- */
- --border: #26262f;
- --border-strong: #2e2e38;
- --border-hi: #34343f;
- --divider: #1d1d25;
- --slash: #3a3a45;
+ --border: #22222b;
+ --border-strong: #2a2a34;
+ --border-hi: #32323d;
+ --divider: #191921;
+ --slash: #383843;
/* ---- text ---- */
- --text: #f3f1f8;
- --text-strong: #ecebf1;
- --text-body: #e7e5ec;
- --text-2: #bcbac6;
- --num: #b0aeba;
- --muted: #8f8d99;
- --muted-2: #84828e;
- --faint: #7d7b87;
- --faint-2: #757380;
- --faintest: #66646f;
- --faintest-2: #57555f;
+ --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;
- /* ---- accent (violet) ---- */
+ /* Brand accent */
--accent: #9b8cf0;
--accent-ink: #12101e;
--accent-tint: #1f1d2e;
@@ -65,12 +67,22 @@
--line-1: #9b8cf0; --line-2: #6fc093; --line-3: #e0b15c;
--line-4: #cf6f86; --line-5: #8f93c2;
+ /* 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);
+
+ /* ---- glass panel effect ---- */
+ --glass-border: 1px solid rgba(255,255,255,.055);
+ --glass-bg: rgba(255,255,255,.02);
+
/* ---- spacing scale (4pt) ---- */
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:20px;
--s6:24px; --s8:32px; --s10:40px; --s12:48px;
/* ---- radii ---- */
- --r-pill:6px; --r-sm:9px; --r-md:11px; --r-lg:14px;
+ --r-pill:5px; --r-sm:7px; --r-md:9px; --r-lg:12px;
/* ---- containers ---- */
--w-public:1320px;
@@ -79,6 +91,6 @@
--gutter: clamp(16px, 4vw, 40px);
/* ---- Chart.js backward compat (read as raw strings by JS) ---- */
- --text-muted: #bcbac6;
- --line: #26262f;
+ --text-muted: #bdbbc8;
+ --line: #22222b;
}
diff --git a/static/favicon.svg b/static/favicon.svg
index fe83d79..29d8775 100644
--- a/static/favicon.svg
+++ b/static/favicon.svg
@@ -1,4 +1,4 @@
-
+
diff --git a/templates/403.html b/templates/403.html
new file mode 100644
index 0000000..625f376
--- /dev/null
+++ b/templates/403.html
@@ -0,0 +1,23 @@
+{% extends "base.html" %}
+{% block title %}403 · Access Denied · myboker.org{% endblock %}
+{% block page_class %}page--error{% endblock %}
+{% block content %}
+
+
+
♣
+
+
403
+
Access denied.
+
You don't have permission to view this page. If you think this is a mistake, contact your league owner.
+
+
+
+
+{% endblock %}
diff --git a/templates/404.html b/templates/404.html
new file mode 100644
index 0000000..22dc7f9
--- /dev/null
+++ b/templates/404.html
@@ -0,0 +1,19 @@
+{% extends "base.html" %}
+{% block title %}404 · Page Not Found · myboker.org{% endblock %}
+{% block page_class %}page--error{% endblock %}
+{% block content %}
+
+
+
♠
+
+
404
+
Page not found.
+
That URL doesn't exist or the page has been moved. Double-check the address or head back to safety.
+
+
+
+
+{% endblock %}
diff --git a/templates/500.html b/templates/500.html
new file mode 100644
index 0000000..24ece0f
--- /dev/null
+++ b/templates/500.html
@@ -0,0 +1,20 @@
+{% extends "base.html" %}
+{% block title %}500 · Something Went Wrong · myboker.org{% endblock %}
+{% block page_class %}page--error{% endblock %}
+{% block content %}
+
+
+
♦
+
+
500
+
Something went wrong.
+
The app hit an unexpected error. Try again in a minute, or contact support if it keeps happening.
+
Incident {{ crash_id }}
+
+
+
+
+{% endblock %}
diff --git a/templates/_internal_sidebar.html b/templates/_internal_sidebar.html
new file mode 100644
index 0000000..f66dd42
--- /dev/null
+++ b/templates/_internal_sidebar.html
@@ -0,0 +1,56 @@
+
diff --git a/templates/_league_sidebar.html b/templates/_league_sidebar.html
new file mode 100644
index 0000000..fd67a05
--- /dev/null
+++ b/templates/_league_sidebar.html
@@ -0,0 +1,96 @@
+
diff --git a/templates/_league_subnav.html b/templates/_league_subnav.html
new file mode 100644
index 0000000..44cc7ec
--- /dev/null
+++ b/templates/_league_subnav.html
@@ -0,0 +1,18 @@
+
diff --git a/templates/account_login.html b/templates/account_login.html
new file mode 100644
index 0000000..cf9a252
--- /dev/null
+++ b/templates/account_login.html
@@ -0,0 +1,65 @@
+{% extends "base.html" %}
+{% block title %}Sign in · myboker.org{% endblock %}
+{% block page_class %}page--auth{% endblock %}
+{% block content %}
+
+
+
+
+
+
+
+ ♣
+
+
Track every chip.
+
Sessions, leaderboards, and debts — all in one place for your private poker league.
+
+
Live leaderboard
+
+ Rivera
+ +$1,420
+
+
+ Chen
+ +$640
+
+
+ Okafor
+ −$210
+
+
+
+
+
+
+{% endblock %}
diff --git a/templates/account_register.html b/templates/account_register.html
new file mode 100644
index 0000000..679e25f
--- /dev/null
+++ b/templates/account_register.html
@@ -0,0 +1,67 @@
+{% extends "base.html" %}
+{% block title %}Create Account · myboker.org{% endblock %}
+{% block page_class %}page--auth{% endblock %}
+{% block content %}
+
+
+
+
+
+
+
+ ♦
+
+
Free to start.
+
No credit card, no monthly fees, no payment processor taking a cut of your buy-ins.
+
+
What you get
+
+ Multiple leagues
+ ✓
+
+
+ Full ledger history
+ ✓
+
+
+ Platform fee
+ $0
+
+
+
+
+
+
+{% endblock %}
diff --git a/templates/account_settings.html b/templates/account_settings.html
new file mode 100644
index 0000000..323fa2d
--- /dev/null
+++ b/templates/account_settings.html
@@ -0,0 +1,213 @@
+{% extends "base.html" %}
+{% block title %}Account settings · myboker.org{% endblock %}
+{% block content %}
+
+
+
{{ user.email[0] | upper }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Danger zone
+
Destructive actions
+
+
+
+
+
+
Disable account
+
Disables your account and archives all leagues you own. Your data is preserved — you can sign up again with the same email to restore access.
+
+
Disable account
+
+
+
+
Delete account
+
Permanently deletes your account and all leagues, sessions, and ledger data you own. This cannot be undone.
+
+
Delete account
+
+
+
+
+
+
+
+
+
+
+ Account
+
Disable account
+
+
+ Your account will be disabled and all leagues you own will be archived. Your data is preserved — you can sign up again with the same email to restore access.
+ Type DISABLE and enter your password to confirm.
+
+
+
+
+
+
+
+
+
+ Account
+
Delete account
+
+
+ Your account and all leagues, sessions, and ledger data you own will be permanently deleted . This cannot be undone.
+ Type DELETE and enter your password to confirm.
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/templates/account_verify_email.html b/templates/account_verify_email.html
new file mode 100644
index 0000000..be8cce3
--- /dev/null
+++ b/templates/account_verify_email.html
@@ -0,0 +1,46 @@
+{% extends "base.html" %}
+{% block title %}Verify Email · myboker.org{% endblock %}
+{% block page_class %}page--auth{% endblock %}
+{% block content %}
+
+
+
+
+
+
+
+ ♦
+
+
One quick check.
+
Email verification keeps league invites tied to the right account.
+
+
+
+
+{% endblock %}
diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html
deleted file mode 100644
index 975cd46..0000000
--- a/templates/admin_dashboard.html
+++ /dev/null
@@ -1,436 +0,0 @@
-{% extends "base.html" %}
-{% block title %}Admin · myboker.org{% endblock %}
-{% block page_class %}page--admin{% endblock %}
-{% block content %}
-
-
-
-
Admin
- {% if open_sessions %}
-
{{ open_sessions|length }} session{{ 's' if open_sessions|length != 1 else '' }} live
- {% endif %}
-
-
Ledger controls
-
Append events to the audit trail. Player ledger history stays permanent.
-
-
-
-
-
-
Cash in
-
{{ admin_totals.cash_in_cents | money }}
-
-
-
Cash paid out
-
{{ admin_totals.cash_out_cents | money }}
-
-
-
House owes players
-
{{ admin_totals.house_owes_players_cents | money }}
-
-
-
Players owe house
-
{{ admin_totals.players_owe_house_cents | money }}
-
-
-
Net book position
-
- {{ admin_totals.net_book_position_cents | money }}
-
-
cash in - cash paid out + receivables - payables
-
-
-
-
-
-
-
-
-
-
Session controls
-
-
- Today
- {{ admin_today }}
-
-
- Empty
- {{ empty_session_count }}
-
-
-
- {% if live_session %}
-
-
-
- {{ session_label(live_session) }}
- {{ live_session_no }}
-
-
-
Invested
{{ live_session.total_invested_cents | money }}
-
Seated
{{ live_session.entries|length }}
-
Open
{{ live_unresolved_count }}
-
-
0 %}data-close-warning="true"{% endif %}>
-
- Close session
-
-
- {% else %}
-
-
-
- No open session
- idle
-
-
-
- {% endif %}
-
-
-
- + Open new session
-
-
-
-
- Prune empty sessions{% if empty_session_count > 0 %} ({{ empty_session_count }}){% endif %}
-
-
-
-
-
-
Import / export
-
Back up or migrate the raw event ledger.
-
-
-
- Drop ledger.csv to import
-
-
-
-
-
-
-
-
-
- Front receivables
-
Debt resolution
-
-
- Open {{ admin_totals.players_owe_house_cents | money }}
- Cash repaid {{ admin_totals.collected_cents | money }}
- Written off {{ admin_totals.written_off_cents | money }}
-
-
- {% if debt_entries %}
-
-
- {% for item in debt_entries %}
- {% set s = item.session %}{% set e = item.entry %}
-
-
- {{ e.player_name }}
- {{ session_label(s) }}
-
-
{{ e.player_owes_cents | money }}
-
- {% endfor %}
-
-
-
-
- Collect debt
-
- Outstanding debt
-
- Select debt
- {% for item in debt_entries %}
- {% set s = item.session %}{% set e = item.entry %}
-
- {{ e.player_name }} · {{ session_label(s) }} · owes {{ e.player_owes_cents | money }}
-
- {% endfor %}
-
-
-
- Amount collected
-
-
-
- Note
-
-
- Mark collected
-
-
-
- Write off debt
-
- Outstanding debt
-
- Select debt
- {% for item in debt_entries %}
- {% set s = item.session %}{% set e = item.entry %}
-
- {{ e.player_name }} · {{ session_label(s) }} · owes {{ e.player_owes_cents | money }}
-
- {% endfor %}
-
-
-
- Amount to write off
-
-
-
- Note
-
-
- Write off debt
-
-
-
- {% else %}
-
-
$0
-
No open player debts right now.
-
- {% endif %}
-
-
-
-
-
-
- All sessions
-
Current totals
-
-
-
-
-
-
- Session
- Players
- Cash in
- Cash paid out
- House owes players
- Players owe house
- Book position
- Status
- Action
-
-
-
- {% for s in recent_sessions %}
-
- {{ session_label(s) }}
- {{ s.entries|length }}
- {{ s.total_banker_cash_in_cents | money }}
- {{ s.total_banker_cash_out_cents | money }}
- {{ s.total_current_due_to_player_cents | money }}
- {{ s.total_current_due_to_house_cents | money }}
- {{ s.total_net_book_position_cents | money }}
- {{ s.status }}
-
-
-
- {% if s.status == "open" %}
- Close
- {% else %}
- Reopen
- {% endif %}
-
-
-
- {% else %}
- No sessions yet.
- {% endfor %}
-
-
-
-
-
-
-
-
- Recent events
-
Ledger feed
-
-
append-only
-
-
- {% for event in recent_events %}
-
-
- {{ event.player_name or "Session" }}
- {{ event.event_type | replace('_', ' ') }}
- {% if event.note %}{{ event.note }} {% endif %}
-
-
{{ event.amount_cents | money }}
-
{{ event.session_date | pretty_date }}
-
- {% else %}
-
No recent events.
- {% endfor %}
-
-
-
-
-
-
-
- Open settlement
-
Close session?
-
-
This session has unpaid or uncarried cashouts, or unresolved debts. Closing stops table entry, but the open settlement items will remain tracked.
-
- Keep open
- Close session
-
-
-
-
-
-
-{% endblock %}
diff --git a/templates/admin_login.html b/templates/admin_login.html
deleted file mode 100644
index 2ac4754..0000000
--- a/templates/admin_login.html
+++ /dev/null
@@ -1,24 +0,0 @@
-{% extends "base.html" %}
-{% block title %}Admin Login · myboker.org{% endblock %}
-{% block content %}
-
-
-
-{% endblock %}
diff --git a/templates/base.html b/templates/base.html
index 314cc60..52e4214 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -10,11 +10,12 @@
+
-
+
+ {% block sidebar %}{% endblock %}
+ {% block subnav %}{% endblock %}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
@@ -55,11 +68,22 @@
{% block content %}{% endblock %}
+
+