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""" + + + + + + +
+ + + + + + + + + + + {code_html} + {cta_html} + {note_html} + + + +
+ + + + + +
myboker.org
+
+

{escape(title)}

+
{escape(intro)}
+ 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.

+
+ Go home → + {% if not is_logged_in %} + Sign in + {% else %} + Go back + {% endif %} +
+
+
+ +{% 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 %} + +
+
+
+ +
+ myboker.org +
+ +

Welcome back.

+

Sign in to manage your leagues, sessions, and ledger.

+ +
+ +
+ + +
+
+ + +
+ + +
+ +

No account yet? Create one

+
+
+ + +
+ +{% 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 %} + +
+
+
+ +
+ myboker.org +
+ +

Start your league.

+

Create an account to manage leagues, players, and sessions.

+ +
+ +
+ + +
+
+ + +
+
+ + +
+ +
+ +

Already have an account? Sign in

+
+
+ + +
+ +{% 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 }}
+
+

Account Settings

+ +
+ +
+ +
+ +
+ +
+

Profile

+

Email address

+
+ + +
+ +
+
+ +
+ +
+

Security

+

Change password

+
+ + + +
+ +
+
+ +
+
+
+

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.

+
+ +
+
+
+ Delete account +

Permanently deletes your account and all leagues, sessions, and ledger data you own. This cannot be undone.

+
+ +
+
+
+ +
+ + + + + + + + + +{% 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 %} + +
+
+
+ +
+ myboker.org +
+ +

Verify your email.

+

Enter the six-digit code sent to {{ email }}.

+ +
+ +
+ + +
+ +
+ +
+ + +
+
+
+ + +
+ +{% 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
-
-
- - -
- -
-
-

Append ledger event

- fast entry -
-
-
- - - - -
- - - {% if pending_carry_items %} -
-
Recommended carry-ins
- {% for item in pending_carry_items %} -
- {{ item.player_name }} - {{ item.amount_cents | money }} - {% if live_session and item.player_name in live_session_player_names %} - - - - - - {% elif live_session %} - Add buy-in first - {% else %} - Open session - {% endif %} -
- {% endfor %} -
- {% endif %} -
- - -
-

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 %}> - - -
-
- {% else %} -
-
- - No open session - idle -
-
-
Invested
{{ 0 | money }}
-
Seated
0
-
Open
0
-
-
- {% endif %} - -
- - -
- -
- -
-
- -
-

Import / export

-

Back up or migrate the raw event ledger.

-
-
- ↓ Export CSV - -
- - -
-
- -
- - -
-
-
- 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

- - - - -
- -
-

Write off debt

- - - - -
-
-
- {% else %} -
-
$0
-

No open player debts right now.

-
- {% endif %} -
- -
-
-
-
- All sessions -

Current totals

-
-
-
- - - - - - - - - - - - - - - - {% for s in recent_sessions %} - - - - - - - - - - - - {% else %} - - {% endfor %} - -
SessionPlayersCash inCash paid outHouse owes playersPlayers owe houseBook positionStatusAction
{{ 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" %} - - {% else %} - - {% endif %} -
-
No sessions yet.
-
-
- -
-
-
- 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 %} -
-
-
- - - - - -{% 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 %} - -
-
-
-

Restricted access

-

Admin login

-

Only myboker.org manager needs credentials. All stats pages stay public.

-
- - - -
-
- -{% 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 @@ +
- + - myboker.org + myboker.org - {% if is_admin %} + {% if is_logged_in %}
-
+ +
+ {% else %} + {% endif %}
- +
+ {% block sidebar %}{% endblock %}
+ {% block subnav %}{% endblock %} {% with messages = get_flashed_messages(with_categories=true) %} {% if messages %}
@@ -55,11 +68,22 @@ {% block content %}{% endblock %}
+
+ diff --git a/templates/docs.html b/templates/docs.html new file mode 100644 index 0000000..d6af72a --- /dev/null +++ b/templates/docs.html @@ -0,0 +1,222 @@ +{% extends "base.html" %} +{% block title %}Help · myboker.org{% endblock %} +{% block content %} + +
+

Documentation

+

How myboker works

+

Everything you need to run a home poker league — sessions, ledgers, leaderboards, and settlement.

+
+ +
+ + + +
+ +
+

Leagues

+

A league is the top-level container for your home game. It holds all your players, sessions, and ledger events in one place. One account can manage multiple leagues — useful if you run separate games (e.g. a Thursday game and a weekend game).

+

When you create a league you give it a name and optional description. A unique public key is automatically generated so you can share a link to it later if you make it public.

+
+ Tip: Leagues are long-lived. You don't need to create a new league every season — use sessions to separate individual nights of play. +
+
+ +
+

Players

+

Players are the people in your league. They do not need a myboker account — only the league owner or manager needs one. You just add names.

+

Each player has a display name that appears on the leaderboard and in ledger events. You can add an optional note (like a payment handle) to help with settlement later.

+

Archiving a player hides them from the active roster but preserves all their history. You can reactivate them at any time.

+
+
+ Active + Shows on the roster and is available to record events against. +
+
+ Archived + Hidden from the roster. History and stats are preserved. Reactivate any time. +
+
+
+ +
+

Sessions

+

A session is a single night of poker. Sessions are date-stamped and sequenced — if you play twice on the same day, they become S1 and S2 automatically.

+

Sessions have two states: open and closed. While open, you can record buy-ins, cashouts, and other ledger events. When everyone has settled up and cashed out, you close the session. Closing doesn't delete anything — you can always reopen it.

+
+
+ Date + The calendar date the game was played. Used to order sessions and group same-day games. +
+
+ Label + Optional short name for this session (e.g. "Main table", "Side game"). Shown in lists. +
+
+ Notes + Optional freeform context visible in the session list (e.g. "Holiday game", "Marcus's place"). +
+
+ Open + Session is live — events can be added and settlement is still in progress. +
+
+ Closed + Session is settled. Books are considered final. Can be reopened if needed. +
+
+
+ +
+

Ledger events

+

All activity in a session is recorded as ledger events. The ledger is append-only — events are never deleted. This keeps the record clean and auditable, even if you need to correct a mistake (you just add a correcting entry).

+

There are eight event types:

+
+
+ buy-in +
+ A player buys chips with cash. +

Cash comes into the pot. Records how much the player has invested. Most common event — record one every time someone puts money on the table.

+
+
+
+ front +
+ The house covers a player's buy-in. +

When a player doesn't have cash handy, the organizer (house) can front them chips. This creates a debt — the player owes that amount back to the house. Track fronts carefully so you know who owes what at the end of the night.

+
+
+
+ cashout +
+ A player exchanges their chips for their cash value. +

Records the chip count a player walks away with. This is the "you are owed X" event — but it doesn't mean cash has left the house yet. Use paid out to record the actual cash handover.

+
+
+
+ paid out +
+ Cash physically leaves the house to the player. +

Closes the loop on a cashout. Once a player has been paid out, their balance goes to $0. Record this separately from cashout because you may pay people out at different times (end of night, next day via transfer, etc.).

+
+
+
+ rollover in +
+ Chips carried in from a previous session. +

If a player didn't cash out at the end of a session and instead carries their chip stack forward to the next game, record a rollover in at the start of the new session.

+
+
+
+ rollover out +
+ Chips carried out to a future session. +

Recorded when a player leaves a session without cashing out, intending to carry their stack forward. Paired with a rollover in at the next session.

+
+
+
+ debt repayment +
+ A player repays an outstanding front. +

When a player who was fronted chips pays the house back (cash, transfer, etc.), record a debt repayment. This reduces their outstanding balance with the house.

+
+
+
+ write-off +
+ The house forgives a debt. +

If a front won't be collected (player left, debt forgiven, etc.), write it off. The debt is cleared from the open balance without cash changing hands. This is irreversible — add a note explaining why.

+
+
+
+
+ +
+

Settlement

+

The ledger tracks two types of open balances at all times:

+
+
+ Due to players + Cash the house owes to players who have cashed out but haven't been paid yet. Pay these out to clear the balance. +
+
+ Due to house + Cash players owe to the house from unpaid fronts. Collect debt repayments or write these off to clear the balance. +
+
+ Cash in + Total real cash that has come into the pot across all sessions — buy-ins and repaid fronts only, not fronts themselves. +
+
+ Cash paid out + Total real cash that has left the house to players. Ideally this approaches cash in over time. +
+
+
+ Fronts vs buy-ins: A front doesn't add to "cash in" because no cash actually entered the pot — the house covered it. Cash in only counts real money on the table. +
+
+ +
+

Leaderboard & stats

+

The leaderboard ranks players by their all-time performance. Stats are computed from ledger events, not manually entered.

+
+
+ Net + Total cash out minus total cash in across all sessions. Positive means up, negative means down. +
+
+ ROI + Net divided by total invested, expressed as a percentage. Compares performance independent of how much someone plays. +
+
+ Win rate + Percentage of sessions where the player finished with a positive net. Ties (exactly break even) count as a loss. +
+
+ Sessions played + Number of sessions with at least one ledger event for this player. +
+
+ Biggest win + The single session where the player netted the most profit. +
+
+ Biggest loss + The single session where the player lost the most money. +
+
+
+ +
+

Public vs private

+

Each league has a visibility setting you can change in league settings.

+
+
+ Private + Only members (owners and managers) can see this league. The league won't appear on the Explore page. This is the default. +
+
+ Public + Anyone with the link — or who finds it on the Explore page — can view the leaderboard and session history. No account required to view. Members still need an account to manage it. +
+
+
+ Note: Making a league public shares leaderboard rankings and session results, but not private financial details like individual debt balances or account emails. +
+
+ +
+
+ +{% endblock %} diff --git a/templates/explore.html b/templates/explore.html new file mode 100644 index 0000000..68ce088 --- /dev/null +++ b/templates/explore.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} +{% block title %}Explore leagues · myboker.org{% endblock %} +{% block content %} + +
+

Public leagues

+

Explore

+

Browse poker leagues that have made their results publicly visible.

+
+ +
+ + + {% if q %}Clear{% endif %} +
+ +{% if leagues %} + +{% elif q %} +
+

No results for "{{ q }}"

+

Try a shorter search or browse all leagues.

+ Browse all +
+{% else %} +
+

No public leagues yet

+

League owners can set their league to public in league settings.

+
+{% endif %} + +{% endblock %} diff --git a/templates/forgot_password.html b/templates/forgot_password.html new file mode 100644 index 0000000..2b3f802 --- /dev/null +++ b/templates/forgot_password.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}Forgot Password · myboker.org{% endblock %} +{% block content %} + +
+
+ +
+

Account recovery

+

Forgot password

+

Enter your email and we'll send a reset link if an account exists.

+
+ + + Back to login +
+
+ +{% endblock %} diff --git a/templates/internal_dashboard.html b/templates/internal_dashboard.html new file mode 100644 index 0000000..dacead0 --- /dev/null +++ b/templates/internal_dashboard.html @@ -0,0 +1,359 @@ +{% extends "base.html" %} +{% block title %}Overview · Admin · myboker.org{% endblock %} +{% block sidebar %}{% include "_internal_sidebar.html" %}{% endblock %} +{% block page_class %}page--app{% endblock %} +{% block content %} + +
+
+
+ Site Admin + {% if stats.open_sessions > 0 %} + {{ stats.open_sessions }} live session{{ 's' if stats.open_sessions != 1 }} + {% endif %} +
+

Overview

+

Signed in as {{ admin_user.email }}. Site health, growth, and recent activity at a glance.

+
+
+ + +
+
+
+ + + + + +
+ {{ stats.users }} + Total users + {{ stats.active_users }} active · +{{ stats.new_users_7d }} this week +
+
+
+ + + + +
+ {{ stats.active_leagues }} + Active leagues + {{ stats.public_leagues }} public · {{ stats.archived_leagues }} archived +
+
+
+ + + + +
+ {{ stats.sessions }} + Total sessions + {{ stats.open_sessions }} open · {{ stats.sessions_7d }} this week · {{ '%.1f'|format(stats.avg_players_per_session) }} avg players +
+
+
+ + + + +
+ {{ stats.active_users_30d }} + Active users · 30d + {{ stats.users_in_leagues }} in leagues · {{ stats.orphan_users }} solo +
+
+ +
+ {% for card in trend_cards %} +
+
+ {{ card.label }} + {{ card.value }} + + {% if card.delta.pct is none %} + New + {% else %} + {{ '+' if card.delta.current > card.delta.previous else '' }}{{ '%.1f'|format(card.delta.pct) }}% + {% endif %} + vs previous + +
+
+ +
+
+ {% endfor %} +
+ + +
+
+
+

Growth

+

User signups · 8 weeks

+
+
+ +
+
+
+
+

Activity

+

Sessions per week · 8 weeks

+
+
+ +
+
+
+ + +
+
+
+

Growth

+

New leagues per week · 8 weeks

+
+
+ +
+
+
+
+

Patterns

+

Sessions by day of week

+
+
+ +
+
+
+ + +
+
+
+

Activity breakdown

+ 7d +
+
+
+
+
New users (7d){{ stats.new_users_7d }}
+
Active users (7d){{ stats.active_users_7d }}
+
Active users (30d){{ stats.active_users_30d }}
+
Users in leagues{{ stats.users_in_leagues }}
+
Unverified accounts{{ stats.unverified_users }}
+
Disabled accounts{{ stats.disabled_users }}
+
+
+
Sessions (7d){{ stats.sessions_7d }}
+
Open sessions{{ stats.open_sessions }}
+
Leagues active (7d){{ stats.leagues_active_7d }}
+
Avg players / session{{ '%.1f'|format(stats.avg_players_per_session) }}
+
Ledger events (7d){{ stats.ledger_events_7d }}
+
Avg sessions / league{{ '%.1f'|format(stats.avg_sessions_per_active_league) }}
+
+
+
+
+ +
+
+

Top leagues

+ All leagues → +
+
+ + + + {% for league, session_count in top_leagues %} + {% set owner = owners.get(league.id) %} + + + + + + {% else %} + + {% endfor %} + +
LeagueOwnerSessions
+ {{ league.name }} + {% if league.visibility == 'public' %}public{% endif %} + {% if owner %}{{ owner.email }}{% else %}{% endif %}{{ session_count }}
No leagues yet.
+
+
+
+ +
+
+

Totals at a glance

+ All time +
+
+
+
{{ stats.users }}Total users
+
{{ stats.leagues }}Total leagues
+
{{ stats.sessions }}Total sessions
+
{{ stats.ledger_events }}Ledger events
+
${{ '%.0f'|format(stats.cash_in_cents / 100) }}Recorded buy-ins
+
${{ '%.0f'|format(stats.cash_out_cents / 100) }}Recorded payouts
+
${{ '%.0f'|format(stats.avg_cash_in_per_session_cents / 100) }}Avg volume / session
+
{{ stats.verified_users }}Verified accounts
+
+
+
+ + +
+
+
+

Recent users

+ All users → +
+
+ + + + {% for user in recent_users %} + + + + + + {% endfor %} + +
EmailStatusJoined
+ {{ user.email }} + {% if user.is_site_admin %}Admin{% endif %} + {% if user.disabled_at %}Disabled{% elif not user.email_verified_at %}Unverified{% else %}Active{% endif %}{{ user.created_at.strftime('%b %-d') if user.created_at else '—' }}
+
+
+ +
+
+

Recent leagues

+ All leagues → +
+
+ + + + {% for league in recent_leagues %} + + + + + + {% endfor %} + +
LeagueVisibilityCreated
{{ league.name }}{{ league.visibility }}{{ league.created_at.strftime('%b %-d') if league.created_at else '—' }}
+
+
+
+ + + +{% endblock %} diff --git a/templates/internal_league_detail.html b/templates/internal_league_detail.html new file mode 100644 index 0000000..977b864 --- /dev/null +++ b/templates/internal_league_detail.html @@ -0,0 +1,128 @@ +{% extends "base.html" %} +{% block title %}{{ league.name }} · Admin · myboker.org{% endblock %} +{% block sidebar %}{% include "_internal_sidebar.html" %}{% endblock %} +{% block page_class %}page--app{% endblock %} +{% block content %} + +
+
+
+ ← Leagues + {% if league.archived_at %}Archived{% else %}Active{% endif %} + {{ league.visibility }} +
+

{{ league.name }}

+

Owner: {% if owner %}{{ owner.email }}{% else %}missing{% endif %}

+
+ Manage league +
+ +
+
{{ stats.members }}Active members
+
{{ stats.players }}Players
+
{{ stats.sessions }}Sessions
+
{{ stats.events }}Ledger events
+
+ +
+
+
+

Members

+ {{ members|length }} +
+
+ + + + {% for membership, member_user in members %} + + + + + + {% else %} + + {% endfor %} + +
EmailRoleStatus
{{ member_user.email }}{{ membership.role }}{% if membership.disabled_at %}Removed{% else %}Active{% endif %}
No members.
+
+
+ +
+
+

League settings

+ Internal manage +
+
+
+ + + + + + + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+
+
+

League actions

+ Support tools +
+
+
+ + +
+ + +
+
+ + {% if league.archived_at %} +
+ + +
+ {% else %} +
+ + +
+ {% endif %} + +
+ + +
+ + +
+
+
+
+
+ +{% endblock %} diff --git a/templates/internal_leagues.html b/templates/internal_leagues.html new file mode 100644 index 0000000..2347e86 --- /dev/null +++ b/templates/internal_leagues.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} +{% block title %}Leagues · Admin · myboker.org{% endblock %} +{% block sidebar %}{% include "_internal_sidebar.html" %}{% endblock %} +{% block page_class %}page--app{% endblock %} +{% block content %} + +
+
+

Leagues

+

Search by league name, slug, or owner email.

+
+
+ +
+ +
+ +
+
+

Matching leagues

+ {{ pagination.start }}-{{ pagination.end }} of {{ pagination.total }} +
+
+ + + + + + + + + + + + {% for league in leagues %} + {% set owner = owners.get(league.id) %} + + + + + + + + {% else %} + + {% endfor %} + +
LeagueOwnerVisibilityStatus
{{ league.name }}{% if owner %}{{ owner.email }}{% else %}Missing{% endif %}{{ league.visibility }}{% if league.archived_at %}Archived{% else %}Active{% endif %}View
No leagues found.
+
+ {% if pagination.pages > 1 %} +
+ {% if pagination.has_prev %} + Previous + {% else %} + Previous + {% endif %} + Page {{ pagination.page }} of {{ pagination.pages }} + {% if pagination.has_next %} + Next + {% else %} + Next + {% endif %} +
+ {% endif %} +
+ +{% endblock %} diff --git a/templates/internal_user_detail.html b/templates/internal_user_detail.html new file mode 100644 index 0000000..e1ad739 --- /dev/null +++ b/templates/internal_user_detail.html @@ -0,0 +1,148 @@ +{% extends "base.html" %} +{% block title %}{{ user.email }} · Admin · myboker.org{% endblock %} +{% block sidebar %}{% include "_internal_sidebar.html" %}{% endblock %} +{% block page_class %}page--app{% endblock %} +{% block content %} + +
+
+
+ ← Users + {% if user.disabled_at %}Disabled{% else %}Active{% endif %} + {% if user.is_site_admin %}Site admin{% endif %} +
+

{{ user.email }}

+

Created {{ user.created_at.strftime('%Y-%m-%d %H:%M') if user.created_at else 'unknown' }}.

+
+
+ +
+
+ {{ 'Yes' if user.email_verified_at else 'No' }} + Email verified +
+
+ {{ memberships|length }} + League memberships +
+
+ {{ owned_leagues|length }} + Owned leagues +
+
+ {{ event_count }} + Ledger events created +
+
+ +
+
+
+

Account

+ Support tools +
+
+
+ + +
+ + +
+
+ +
+

Last login: {{ user.last_login_at.strftime('%Y-%m-%d %H:%M') if user.last_login_at else 'Never' }}

+
+
+ + +
+
+ + +
+
+
+
+
+ +
+
+

Danger zone

+ Account state +
+
+ {% if user.disabled_at %} +
+ + +
+ {% else %} +
+ + +
+ {% endif %} + +
+ + +
+ + +
+
+
+
+
+ +
+
+
+

Memberships

+ {{ memberships|length }} +
+
+ + + + {% for membership, league in memberships %} + + + + + + {% else %} + + {% endfor %} + +
LeagueRoleStatus
{{ league.name }}{{ membership.role }}{% if membership.disabled_at %}Removed{% else %}Active{% endif %}
No memberships.
+
+
+ +
+
+

Owned leagues

+ {{ owned_leagues|length }} +
+
+ + + + {% for league in owned_leagues %} + + + + + + {% else %} + + {% endfor %} + +
LeagueStatus
{{ league.name }}{% if league.archived_at %}Archived{% else %}Active{% endif %}Manage
No owned leagues.
+
+
+
+ +{% endblock %} diff --git a/templates/internal_users.html b/templates/internal_users.html new file mode 100644 index 0000000..bb65776 --- /dev/null +++ b/templates/internal_users.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} +{% block title %}Users · Admin · myboker.org{% endblock %} +{% block sidebar %}{% include "_internal_sidebar.html" %}{% endblock %} +{% block page_class %}page--app{% endblock %} +{% block content %} + +
+
+

Users

+

Search accounts by email and inspect account status.

+
+
+ +
+ +
+ +
+
+

Matching users

+ {{ pagination.start }}-{{ pagination.end }} of {{ pagination.total }} +
+
+ + + + + + + + + + + + {% for user in users %} + + + + + + + + {% else %} + + {% endfor %} + +
EmailStatusVerifiedCreated
+ {{ user.email }} + {% if user.is_site_admin %}Admin{% endif %} + {% if user.disabled_at %}Disabled{% else %}Active{% endif %}{% if user.email_verified_at %}Yes{% else %}No{% endif %}{{ user.created_at.strftime('%Y-%m-%d') if user.created_at else 'Unknown' }}View
No users found.
+
+ {% if pagination.pages > 1 %} +
+ {% if pagination.has_prev %} + Previous + {% else %} + Previous + {% endif %} + Page {{ pagination.page }} of {{ pagination.pages }} + {% if pagination.has_next %} + Next + {% else %} + Next + {% endif %} +
+ {% endif %} +
+ +{% endblock %} diff --git a/templates/landing.html b/templates/landing.html new file mode 100644 index 0000000..0b1ae2c --- /dev/null +++ b/templates/landing.html @@ -0,0 +1,403 @@ +{% extends "base.html" %} +{% block title %}myboker.org · Poker league records{% endblock %} +{% block page_class %}page--landing{% endblock %} +{% block content %} + +
+
+

Home Poker Tracking

+

+ Your + league, + on record. +

+

Sessions, leaderboards, and settlement tracking for home poker groups. No fees, no payment apps in the way.

+
+ {% if is_logged_in %} + Open my leagues → + {% else %} + Start your league → + Sign in + {% endif %} +
+
+ No fees + · + Append-only ledger + · + Private by default + {% if not is_logged_in %} + · + Explore public leagues + {% endif %} +
+
+ + +
+ +
+
+
+ $0 + Platform fee, always +
+ +
+ + Sessions & players +
+ +
+ Append-only + Tamper-proof ledger +
+ +
+ Private + Your data, your league +
+
+
+ +
+
+
+ What's included +

No spreadsheets. No Venmo math. No guessing.

+

Everything your group needs to run a real poker league — built from the ground up for home games.

+
+
+
+
+ +
+

Game-night sessions

+

Open a session at the start of the night, log buy-ins and cashouts as they happen, then close it when the last player leaves. Multiple sessions on the same date auto-sequence as S1, S2.

+
+
+
+ +
+

Rankings that earn their spot

+

Set a minimum session count so regulars rank and one-time guests go provisional until they qualify. Sort by net, ROI, win rate, or recent form. Every number is derived directly from raw ledger events.

+
+
+
+ +
+

Settlement without the spreadsheet

+

Every event is logged and kept: buy-ins, cashouts, fronts, corrections. Nothing is deleted; mistakes get fixed by appending. When a session closes, the house knows exactly what it holds and who is owed what.

+
+
+
+
+ +
+
+
+ In the app +

Your game. Your data. Always in sync.

+

Every number your group tracks, in one place — from the moment chips hit the table.

+
+ +
+
+ +
+
+
+ Leaderboards +

Rankings built on raw data, not trust.

+

Every leaderboard stat is computed directly from the same append-only ledger that records each buy-in and cashout. Net profit, ROI, and win rate update the moment a session closes — no manual entry, no formulas to break.

+
    +
  • Set a session minimum before a player earns a rank
  • +
  • Track rank movement with live ▲▼ deltas
  • +
  • Multiple sort modes: net, ROI, win rate, recent form
  • +
  • Provisional players shown separately until they qualify
  • +
+
+ +
+
+ +{% if not is_logged_in %} +
+
+

Start tracking your league

+

Free to use. No payment processor. Your data stays yours.

+ +
+
+{% endif %} + +{% endblock %} diff --git a/templates/leaderboard.html b/templates/leaderboard.html index e07dcf5..19fa8ce 100644 --- a/templates/leaderboard.html +++ b/templates/leaderboard.html @@ -131,7 +131,7 @@ {% else %} - No players yet — add the first event from the admin page. + No players yet. {% endfor %} diff --git a/templates/league_dashboard.html b/templates/league_dashboard.html new file mode 100644 index 0000000..3cbb193 --- /dev/null +++ b/templates/league_dashboard.html @@ -0,0 +1,346 @@ +{% extends "base.html" %} +{% block title %}{{ league.name }} · myboker.org{% endblock %} +{% block sidebar %}{% include "_league_sidebar.html" %}{% endblock %} +{% block page_class %}page--app{% endblock %} +{% block content %} + +
+
+
+ {{ league.visibility }} + {% if is_owner %}Owner{% endif %} +
+

{{ league.name }}

+ {% if league.description %}

{{ league.description }}

{% endif %} +
+ {% if is_owner %} + + {% endif %} +
+ +{% if counts.open_sessions > 0 %} +
+
+ + {{ counts.open_sessions }} session{{ 's' if counts.open_sessions != 1 else '' }} in progress + + View sessions → +
+{% endif %} + +
+
+
+ +
+
+ {{ counts.players }} + Players +
+
+
+
+ +
+
+ {{ counts.sessions }} + Sessions{% if counts.open_sessions > 0 %} · {{ counts.open_sessions }} live{% endif %} +
+
+
+
+ +
+
+ {{ counts.events }} + Ledger events +
+
+
+
+ +
+
+ {{ counts.seasons }} + Seasons +
+
+
+ +{% if cash_stats.has_data %} +
+
+ Total cash in + {{ cash_stats.total_cash_in | money }} +
+
+
+ Total paid out + {{ cash_stats.total_paid_out | money }} +
+
+
+ Avg pot / session + {{ cash_stats.avg_pot | money }} +
+
+
+ Avg players + {{ cash_stats.avg_players }} +
+
+
+ Avg buy-in + {{ cash_stats.avg_buyin | money }} +
+
+
+ Biggest session + {{ cash_stats.biggest_pot | money }} +
+ {% if cash_stats.avg_sessions_per_month > 0 %} +
+
+ Sessions / month + {{ cash_stats.avg_sessions_per_month }} +
+ {% endif %} + {% if cash_stats.total_fronts > 0 %} +
+
+ Fronts issued + {{ cash_stats.total_fronts | money }} +
+ {% endif %} +
+{% endif %} + +{% if cash_stats.has_sessions %} +
+
+ Session history +
+ + + +
+
+
+ +
+ +{% endif %} + + + +{% endblock %} diff --git a/templates/league_leaderboard.html b/templates/league_leaderboard.html new file mode 100644 index 0000000..1669106 --- /dev/null +++ b/templates/league_leaderboard.html @@ -0,0 +1,363 @@ +{% extends "base.html" %} +{% block title %}Leaderboard · {{ league.name }} · myboker.org{% endblock %} +{% block sidebar %}{% include "_league_sidebar.html" %}{% endblock %} +{% block page_class %}page--app{% endblock %} +{% block content %} + + + +
+
+
+ Trend +

Cumulative profit

+
+ +
+
+
+ +
+
+
+ +
+
+
+ + + +
+

+ {% if mode == 'eligible' %} + {{ eligible_min_sessions }}+ sessions required. Keeps one-off guests from skewing standings. + {% elif mode == 'all' %} + Everyone who's sat down — rankings can swing on tiny samples. + {% else %} + Form guide — aggregated over the last 5 sessions only. + {% endif %} +

+
+
+ {% if seasons %} +
+ + +
+ {% endif %} +
+ + {% if selected_season_id %}{% endif %} + + {% if selected_session_id %} + Reset + {% endif %} +
+
+
+ +
+ + + + + + + + + + + + + + + + {% for player in main_board %} + {% set idx = loop.index %} + {% set player_href = url_for('leagues.player_detail', league_ref=league.url_ref, player_id=player_ids[player.player_name]) if player_ids.get(player.player_name) else '' %} + + + + + + + + + + + + {% else %} + + + + {% endfor %} + +
+ # + + +
+ #{{ idx }} + + {% if player_href %}{{ player.player_name }}{% else %}{{ player.player_name }}{% endif %} + + {% if player.rank_change > 0 %} + ▲{{ player.rank_change }} + {% elif player.rank_change < 0 %} + ▼{{ player.rank_change | abs }} + {% else %} + + {% endif %} + + {{ '+' if player.total_net_cents > 0 else '' }}{{ player.total_net_cents | money }} + {{ "%.1f"|format(player.win_pct) }}%{{ "%.1f"|format(player.roi_pct) }}%{{ player.avg_win_cents | money }}{{ player.avg_loss_cents | money }}{{ player.sessions_played }}
+ No players yet — add the first event from a league session ledger. +
+
+ +{% if mode == 'eligible' and provisional_board %} +
+
+ Provisional · fewer than {{ eligible_min_sessions }} sessions +
+
+ {% for player in provisional_board %} + {% set player_href = url_for('leagues.player_detail', league_ref=league.url_ref, player_id=player_ids[player.player_name]) if player_ids.get(player.player_name) else '' %} + +

{{ player.player_name }}

+
+ {{ player.sessions_played }} sess + {{ player.total_net_cents | money }} +
+
+ {% endfor %} +
+
+{% endif %} + + + + + +{% endblock %} diff --git a/templates/league_ledger.html b/templates/league_ledger.html new file mode 100644 index 0000000..ea907d4 --- /dev/null +++ b/templates/league_ledger.html @@ -0,0 +1,304 @@ +{% extends "base.html" %} +{% block title %}Ledger · {{ league.name }} · myboker.org{% endblock %} +{% block sidebar %}{% include "_league_sidebar.html" %}{% endblock %} +{% block page_class %}page--app{% endblock %} +{% block content %} + +
+
+ Cash in Total real cash brought into the pot across all sessions. Counts buy-ins and collected fronts only — not fronts that haven't been repaid. +
{{ totals.cash_in_cents | money }}
+
+
+ Cash paid out Total cash physically returned to players via 'paid out' events. Should approach Cash In once everyone has settled. +
{{ totals.cash_out_cents | money }}
+
+
+ House holds Cash in minus cash paid out — what the house physically holds right now before settling remaining claims. +
{{ totals.book_held_cents | money }}
+
+
+ Due to players Cash the house still owes to players who cashed out but haven't been paid yet. Record a 'paid out' event to clear these. +
{{ totals.due_to_players_cents | money }}
+
+
+ Due to house Outstanding fronts — chips the house covered that players haven't repaid yet. Collect or write off using the Debt resolution panel below. +
{{ totals.due_to_house_cents | money }}
+
+
+ Net position House holds + owed by players − owed to players. Should be near zero once all sessions are settled. +
{{ totals.net_position_cents | money }}
+
+
+ Events Total number of active ledger entries across all sessions. +
{{ totals.events }}
+
+
+ + +{% if open_sessions %} +
+
+ {{ open_sessions | length }} live Open sessions +
+
+ {% for s in open_sessions %} +
+ {{ session_label(s) }} + + {{ s.entries | length }} entries · {{ s.total_real_cash_in_cents | money }} in + View → + +
+ {% endfor %} +
+
+{% endif %} + + +
+
+
+ Front receivables A front is chips the house covered for a player who didn't have cash. This section tracks who still owes money back to the house. +

Debt resolution

+
+
+ Open {{ totals.due_to_house_cents | money }} + Collected {{ totals.collected_cents | money }} + Written off {{ 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.current_due_to_house_cents | money }} +
+ {% endfor %} +
+ + {% if can_manage %} +
+
+ +

Collect debt

+ + + + + + + +
+ +
+ +

Write off debt

+ + + + + + + +
+
+ {% endif %} +
+ {% else %} +
+
$0
+

No open player debts right now.

+
+ {% endif %} +
+ + +{% if due_to_player_items %} +
+
+
+ Unpaid cashout claims +

Due to players

+
+ {{ totals.due_to_players_cents | money }} +
+
+ {% for item in due_to_player_items %} +
+
+ {{ item.entry.player_name }} + {{ session_label(item.session) }} +
+ {{ item.entry.current_due_to_player_cents | money }} +
+ {% endfor %} +
+
+{% endif %} + + +{% if can_manage %} +
+

Import / export

+

Back up or migrate the raw event ledger for this league.

+
+ +
+ ↓ Export CSV + +
+ + +
+
+{% else %} +
+

Export

+

Download the raw event ledger for this league.

+ +
+{% endif %} + + +
+
+
+
+ All sessions +

Ledger totals

+
+
+
+ + + + + + + + + + + + + {% for s in sessions %} + + + + + + + + + {% else %} + + {% endfor %} + +
SessionPlayersCash inPaid outDue to houseStatus
{{ session_label(s) }}{{ s.entries|length }}{{ s.total_real_cash_in_cents | money }}{{ s.total_real_cash_out_cents | money }}{{ s.total_current_due_to_house_cents | money }}{{ s.status }}
No sessions yet.
+
+
+ +
+
+
+ All events +

Ledger feed

+
+ append-only +
+
+ {% for event in raw_events %} +
+
+ {{ event.player_name or "Session" }} + {{ event.event_type | replace('_', ' ') }} + {% if event.voided_at %}voided{% endif %} + {% if event.note and not event.voided_at %}{{ event.note }}{% endif %} +
+

{{ event.amount_cents | money }}

+ {% if event.voided_at %} +

{{ event.void_reason }}

+ {% else %} +

{{ event.session_date }}

+ {% endif %} +
+ {% else %} +

No events recorded.

+ {% endfor %} +
+
+
+ + + +{% endblock %} diff --git a/templates/league_new.html b/templates/league_new.html new file mode 100644 index 0000000..46867c0 --- /dev/null +++ b/templates/league_new.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}New League · myboker.org{% endblock %} +{% block content %} + +
+
+ +
+

League setup

+

Create league

+

Leagues are long-lived containers. Seasons can be added later.

+
+ + + + Cancel +
+
+ +{% endblock %} diff --git a/templates/league_player_detail.html b/templates/league_player_detail.html new file mode 100644 index 0000000..53e4f94 --- /dev/null +++ b/templates/league_player_detail.html @@ -0,0 +1,347 @@ +{% extends "base.html" %} +{% block title %}{{ player.player_name }} · {{ league.name }} · myboker.org{% endblock %} +{% block sidebar %}{% include "_league_sidebar.html" %}{% endblock %} +{% block page_class %}page--app{% endblock %} +{% block content %} + + +
+
+ {% if player_rank %} +

Rank #{{ player_rank }}

+ {% endif %} +

{{ player.player_name }}

+

{{ player.sessions_played }} session{{ 's' if player.sessions_played != 1 else '' }} · {{ "%.1f"|format(player.win_pct) }}% win rate

+
+
+ +
+
+ Total net +
+ {{ '+' if player.total_net_cents > 0 else '' }}{{ player.total_net_cents | money }} +
+
+
+ Win rate +
{{ "%.1f"|format(player.win_pct) }}%
+
{{ player.winning_sessions }}W · {{ player.losing_sessions }}L · {{ player.break_even_sessions }}E
+
+
+ ROI +
{{ "%.1f"|format(player.roi_pct) }}%
+
+
+ Sessions +
{{ player.sessions_played }}
+
+
+ Current W/L +
+ {{ player.current_win_streak }}W + {{ player.current_loss_streak }}L +
+
Longest {{ player.longest_win_streak }}W · {{ player.longest_loss_streak }}L
+
+
+ Best / worst +
+ {{ player.biggest_win_cents | money }} + {{ player.biggest_loss_cents | money }} +
+
+
+ Total invested +
{{ player.total_invested_cents | money }}
+
+
+ Gross cashouts +
{{ player.total_cash_out_cents | money }}
+
+
+ + +
+
+ Trend +

Cumulative profit

+
+
+
+ +
+
+
+ + +
+
+
+ Session by session +

Results

+
+
+
+ +
+
+
+
+
+ Breakdown +

Result mix

+
+
+
+ {% set result_total = player.winning_sessions + player.losing_sessions + player.break_even_sessions %} +
+ {% if player.winning_sessions > 0 %} + + {% endif %} + {% if player.break_even_sessions > 0 %} + + {% endif %} + {% if player.losing_sessions > 0 %} + + {% endif %} +
+
+
+ Wins + {{ player.winning_sessions }}{{ "%.1f"|format((player.winning_sessions / result_total * 100) if result_total else 0) }}% +
+
+ Even + {{ player.break_even_sessions }}{{ "%.1f"|format((player.break_even_sessions / result_total * 100) if result_total else 0) }}% +
+
+ Losses + {{ player.losing_sessions }}{{ "%.1f"|format((player.losing_sessions / result_total * 100) if result_total else 0) }}% +
+
+ {% if result_total > 0 %} +
±${{ "%.2f"|format(league.break_even_cents / 100) }} counts as even
+ {% endif %} +
+
+
+
+ + +
+ + +
+
+
+
+ + Poker performance +
+

Game results

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Total Net + {{ '+' if player.total_net_cents > 0 else '' }}{{ player.total_net_cents | money }} +
Win %{{ "%.1f"|format(player.win_pct) }}%
Record + {{ player.winning_sessions }}W + · + {{ player.losing_sessions }}L + · + {{ player.break_even_sessions }}E +
ROI{{ "%.1f"|format(player.roi_pct) }}%
Average Win{{ player.avg_win_cents | money }}
Average Loss{{ player.avg_loss_cents | money }}
Best / Worst + {{ player.biggest_win_cents | money }} + / + {{ player.biggest_loss_cents | money }} +
Win / Loss Streak + {{ player.current_win_streak }}W + · + {{ player.current_loss_streak }}L +
Longest Win / Loss + {{ player.longest_win_streak }}W · {{ player.longest_loss_streak }}L +
+
+ + +
+
+
+
+ + Poker basis + settlement +
+

Investment and settlement

+
+
+ + + + + + + + + + + + + + + {% if player.total_payout_carry_in_cents > 0 %} + + + + + {% endif %} + + + + + + + + + + + + + + + + + + + + + {% if player.total_debt_repayment_cents > 0 %} + + + + + {% endif %} + {% if player.current_due_to_player_cents > 0 %} + + + + + {% endif %} + {% if player.current_due_to_house_cents > 0 %} + + + + + {% endif %} + {% if player.total_writeoff_cents > 0 %} + + + + + {% endif %} + +
Buy-ins paid{{ player.total_buy_in_cents | money }}
Fronted{{ player.total_front_cents | money }}
Rolled in{{ player.total_rollover_in_cents | money }}
Payout carry-in{{ player.total_payout_carry_in_cents | money }}
Total invested{{ player.total_invested_cents | money }}
Gross cashout result{{ player.total_cash_out_cents | money }}
Gross player claim{{ player.total_gross_payout_cents | money }}
Rolled out{{ player.total_rollover_out_cents | money }}
Cash paid out{{ player.total_paid_out_cents | money }}
Cash debt repaid{{ player.total_debt_repayment_cents | money }}
House owes player{{ player.current_due_to_player_cents | money }}
Owes house{{ player.current_due_to_house_cents | money }}
Written off{{ player.total_writeoff_cents | money }}
+
+
+ + +{% endblock %} diff --git a/templates/league_players.html b/templates/league_players.html new file mode 100644 index 0000000..4fc2d3c --- /dev/null +++ b/templates/league_players.html @@ -0,0 +1,271 @@ +{% extends "base.html" %} +{% block title %}Players · {{ league.name }} · myboker.org{% endblock %} +{% block sidebar %}{% include "_league_sidebar.html" %}{% endblock %} +{% block page_class %}page--app{% endblock %} +{% block content %} + + + +{% if can_manage %} +
+ +
+

Roster

+

Add player

+
+
+ + +
+
+ +
+
+{% endif %} + +{% set active_players = players | selectattr('status', 'equalto', 'active') | list %} +{% set archived_players = players | selectattr('status', 'equalto', 'archived') | list %} + +
+
+
+

Roster

+

Active players

+
+ {{ active_players|length }} players +
+ {% if active_players %} +
+ {% for player in active_players %} + {% set stats = stats_by_name.get(player.display_name) %} + {% set initials = player.display_name.split()|map('first')|join('')|upper %} + {% set avatar_cls = 'player-avatar--pos' if stats and stats.total_net_cents > 0 else 'player-avatar--neg' if stats and stats.total_net_cents < 0 else '' %} +
+
+
{{ initials[:2] }}
+
+ {{ player.display_name }} + + {% if stats %}{{ stats.sessions_played }} session{{ 's' if stats.sessions_played != 1 else '' }}{% else %}No sessions yet{% endif %} + +
+ {% if can_manage %} +
+ + +
+ {% endif %} +
+ {% if stats %} +
+
+ Net + + {{ '+' if stats.total_net_cents > 0 else '' }}{{ stats.total_net_cents | money }} + +
+
+ Win rate + {{ "%.0f"|format(stats.win_pct * 100) }}% +
+
+ Best win + {{ stats.biggest_win_cents | money }} +
+
+ {% endif %} +
+ {% endfor %} +
+ {% else %} +
+

Empty roster

+

No players yet

+

Add the people who play in this league. Players don't need accounts.

+
+ {% endif %} +
+ +{% if archived_players %} +
+
+
+

Archive

+

Archived players

+
+ {{ archived_players|length }} +
+
+ {% for player in archived_players %} + {% set stats = stats_by_name.get(player.display_name) %} +
+
+ {{ player.display_name }} + {% if stats %}{{ stats.sessions_played }} sessions · {{ stats.total_net_cents | money }} net{% endif %} +
+
+ {% if can_manage %} +
+ + +
+ {% endif %} +
+
+ {% endfor %} +
+
+{% endif %} + +{% if can_manage %} + + + + + +{% endif %} + +{% endblock %} diff --git a/templates/league_seasons.html b/templates/league_seasons.html new file mode 100644 index 0000000..1384b57 --- /dev/null +++ b/templates/league_seasons.html @@ -0,0 +1,258 @@ +{% extends "base.html" %} +{% block title %}Seasons · {{ league.name }} · myboker.org{% endblock %} +{% block sidebar %}{% include "_league_sidebar.html" %}{% endblock %} +{% block page_class %}page--app{% endblock %} +{% block content %} + + + +
+ + {% if can_manage %} + {% set has_dated_seasons = active_seasons | selectattr('start_date') | list | length > 0 %} + {% if has_dated_seasons %} +
+
+

Auto-assign sessions

+

Assign unassigned sessions to seasons based on their date. Sessions that match zero or multiple seasons are skipped.

+
+
+ + +
+
+ {% endif %} + +
+ +
+

Seasons

+

New season

+
+
+ + + +
+
+ +
+
+ {% endif %}{# can_manage #} + +
+
+
+

Active

+

Current seasons

+
+ {{ active_seasons|length }} season{{ 's' if active_seasons|length != 1 else '' }} +
+ {% if active_seasons %} +
+ {% for season in active_seasons %} +
+
+
+ +
+ {% if season.start_date %} + {{ season.start_date.strftime('%b %-d, %Y') }} + {% endif %} + {% if season.start_date and season.end_date %} + + {% endif %} + {% if season.end_date %} + {{ season.end_date.strftime('%b %-d, %Y') }} + {% endif %} + {% if not season.start_date and not season.end_date %} + No dates set + {% endif %} +
+
+
+ {% if can_manage %} +
+ +
+ + +
+
+ {% endif %} +
+ {% endfor %} +
+ {% else %} +
+

No active seasons. Create one above.

+
+ {% endif %} +
+ + {% if archived_seasons %} +
+
+
+

Archived

+

Past seasons

+
+ {{ archived_seasons|length }} +
+
+ {% for season in archived_seasons %} +
+
+
+
{{ season.name }}
+
+ {% if season.start_date %} + {{ season.start_date.strftime('%b %-d, %Y') }} + {% endif %} + {% if season.start_date and season.end_date %} + + {% endif %} + {% if season.end_date %} + {{ season.end_date.strftime('%b %-d, %Y') }} + {% endif %} + {% if not season.start_date and not season.end_date %} + No dates set + {% endif %} +
+
+
+ {% if can_manage %} +
+
+ + +
+ +
+ {% endif %} +
+ {% endfor %} +
+
+ {% endif %} + +
+ +{% if can_manage %} + + + + + + + +{% endif %}{# can_manage #} + +{% endblock %} diff --git a/templates/league_session_detail.html b/templates/league_session_detail.html new file mode 100644 index 0000000..feddd83 --- /dev/null +++ b/templates/league_session_detail.html @@ -0,0 +1,384 @@ +{% extends "base.html" %} +{% block title %}{{ session_model.display_label }} · {{ league.name }} · myboker.org{% endblock %} +{% block sidebar %}{% include "_league_sidebar.html" %}{% endblock %} +{% block page_class %}page--app{% endblock %} +{% block content %} + +
+
+

{{ session_model.display_label }}

+

{{ session_model.session_date.isoformat() | pretty_date }} · {{ session_model.status }}

+
+
+ Public view + {% if can_manage %} + + {% if session_model.status == 'open' %} +
+ + +
+ {% else %} + + {% endif %} + {% endif %} + {% if is_owner %} + + {% endif %} +
+
+ +
+
+ Invested +
{{ session.total_invested_cents | money }}
+
+
+ Cashout +
{{ session.total_cash_out_cents | money }}
+
+
+ Paid out +
{{ session.total_paid_out_cents | money }}
+
+
+ Open items +
{{ (session.total_current_due_to_player_cents + session.total_current_due_to_house_cents) | money }}
+
+
+ +
+ {% if can_manage %} +
+ +
+

Append event

+

Ledger entry

+
+ {% if session_model.status != 'open' %} +
+ Session closed + Books assumed settled. Reopen to add events. +
+ {% endif %} +
+ + + + + +
+
+ {% else %} +
+

Read only

+

Your role can inspect this session but cannot append ledger events.

+
+ {% endif %} + +
+
+
+

Results

+

Player totals

+
+ {{ session.entries|length }} players +
+ {% if session.entries %} +
+ {% for entry in session.entries %} + {% set row_cls = 'player-ledger-row--pos' if entry.net_cents > 0 else 'player-ledger-row--neg' if entry.net_cents < 0 else '' %} +
+
+ {{ entry.player_name }} + {% if entry.current_due_to_house_cents > 0 %} + Owes {{ entry.current_due_to_house_cents | money }} + {% elif entry.current_due_to_player_cents > 0 %} + Due {{ entry.current_due_to_player_cents | money }} + {% else %} + Settled + {% endif %} +
+
+
+ Buy in + {{ entry.invested_cents | money }} +
+
+ Cashout claim + {{ entry.cash_out_cents | money }} +
+
+ Paid out + {{ entry.paid_out_cents | money if entry.paid_out_cents else '—' }} +
+
+ Net + + {{ '+' if entry.net_cents > 0 else '' }}{{ entry.net_cents | money }} + +
+
+ {% if entry.rollover_in_cents or entry.rollover_out_cents or entry.payout_carry_in_cents %} +
+
+ Rolled in + {{ entry.rollover_in_cents | money }} +
+
+ Carry in + {{ entry.payout_carry_in_cents | money }} +
+
+ Rolled out + {{ entry.rollover_out_cents | money }} +
+
+ {% endif %} +
+ {% endfor %} +
+ {% else %} +

No player ledger events yet.

+ {% endif %} +
+
+ +
+
+
+

Audit trail

+

Raw ledger events

+
+ append-only +
+
+ {% for event in raw_events | reverse %} +
+
+ {{ event.player_name or "Session" }} + {{ event.event_type | replace('_', ' ') }} + {% if event.voided_at %}voided{% endif %} + {% if event.note and not event.voided_at %}{{ event.note }}{% endif %} +
+

{{ event.amount_cents | money }}

+ {% if event.voided_at %} +

{{ event.void_reason }}

+ {% else %} +

{{ event.created_at }}

+ {% if is_owner %} + + {% endif %} + {% endif %} +
+ {% else %} +

No events recorded.

+ {% endfor %} +
+
+ +{% if is_owner %} + +{% endif %} + + + +{% if can_manage %} + +{% endif %} + + + + + +{% endblock %} diff --git a/templates/league_session_view.html b/templates/league_session_view.html new file mode 100644 index 0000000..de0ab18 --- /dev/null +++ b/templates/league_session_view.html @@ -0,0 +1,263 @@ +{% extends "base.html" %} +{% block title %}{{ session_model.display_label }} · {{ league.name }} · myboker.org{% endblock %} +{% block sidebar %}{% include "_league_sidebar.html" %}{% endblock %} +{% block page_class %}page--app{% endblock %} +{% block content %} + + + +
+

Session #{{ "%03d"|format(session_number) }}

+

{{ session_model.display_label }}

+

{{ session.entries|length }} player{{ 's' if session.entries|length != 1 else '' }} · {{ session_model.status }}{% if season %} · {{ season.name }}{% endif %}

+
+ + +
+
+
+ Banker cash flow +
+
+
+ Actual cash in +
{{ session.total_real_cash_in_cents | money }}
+
+
+ Gross player claim +
{{ session.total_gross_payout_cents | money }}
+
+
+ Cash paid out +
{{ session.total_real_cash_out_cents | money }}
+
+
+ Rolled out +
{{ session.total_rollover_out_cents | money }}
+
+
+
+
+
+ Open items +
+
+
+ House owes players +
+ {{ session.total_current_due_to_player_cents | money }} +
+
+
+ Players owe house +
+ {{ session.total_current_due_to_house_cents | money }} +
+
+
+ Cash debt repaid +
{{ session.total_debt_repayment_cents | money }}
+
+
+ Written off +
{{ session.total_writeoff_cents | money }}
+
+
+
+
+ + +
+
+
+ Results +

Player totals

+
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + {% for entry in session.entries %} + + + + + + + + + + + + + + + + {% else %} + + {% endfor %} + +
PlayerInvestedGross cashoutNetPaid outOpen settlementSettlement statusFrontRolled inPayout carry-inGross player claimRolled outNotes
{{ entry.player_name }}{{ entry.invested_cents | money }}{{ entry.cash_out_cents | money }} + {{ '+' if entry.net_cents > 0 else '' }}{{ entry.net_cents | money }} + {{ entry.paid_out_cents | money }} + {% if entry.current_due_to_house_cents > 0 %} + owes {{ entry.current_due_to_house_cents | money }} + {% elif entry.current_due_to_player_cents > 0 %} + due {{ entry.current_due_to_player_cents | money }} + {% else %} + {{ 0 | money }} + {% endif %} + {{ entry.payout_status }}{{ entry.front_cents | money }}{{ entry.rollover_in_cents | money }}{{ entry.payout_carry_in_cents | money }} + {% if entry.gross_due_to_house_cents > 0 %}—{% else %}{{ entry.gross_payout_cents | money }}{% endif %} + {{ entry.rollover_out_cents | money }} + {% if entry.notes %} +
    {% for n in entry.notes %}
  • {{ n }}
  • {% endfor %}
+ {% else %} + + {% endif %} +
No player events yet.
+
+
+ + +
+
+
+
+ Breakdown +

Net by player

+
+
+
+
+
+
+ +
+
+
+ Quick read +

Session snapshot

+
+
+ {% set winners = session.entries | selectattr('net_cents', 'gt', 0) | list %} + {% set losers = session.entries | selectattr('net_cents', 'lt', 0) | list %} +
+
+ Biggest winner +
+ {% if winners %}{{ (winners | sort(attribute='net_cents', reverse=True) | first).net_cents | money }}{% else %}—{% endif %} +
+ {% if winners %}
{{ winners | sort(attribute='net_cents', reverse=True) | first | attr('player_name') }}
{% endif %} +
+
+ Biggest loser +
+ {% if losers %}{{ (losers | sort(attribute='net_cents') | first).net_cents | money }}{% else %}—{% endif %} +
+ {% if losers %}
{{ losers | sort(attribute='net_cents') | first | attr('player_name') }}
{% endif %} +
+
+ Gross cashout result +
{{ session.total_cash_out_cents | money }}
+
+
+ Open items +
+ {{ (session.total_current_due_to_player_cents + session.total_current_due_to_house_cents) | money }} +
+
+
+
+
+ + + +{% endblock %} diff --git a/templates/league_sessions.html b/templates/league_sessions.html new file mode 100644 index 0000000..ec9af6c --- /dev/null +++ b/templates/league_sessions.html @@ -0,0 +1,212 @@ +{% extends "base.html" %} +{% block title %}Sessions · {{ league.name }} · myboker.org{% endblock %} +{% block sidebar %}{% include "_league_sidebar.html" %}{% endblock %} +{% block page_class %}page--app{% endblock %} +{% block content %} + + + +
+ + {% if can_manage %} +
+ +
+

Add session

+

New session

+
+ +
+ + + + +
+
+ +
+
+ {% endif %} + +
+
+
+

History

+

All sessions

+
+
+ {% if seasons %} + + {% if selected_season %} + Clear + {% endif %} + {% endif %} + {{ sessions|length }}{% if selected_season %} / {{ all_session_count }}{% endif %} total + {% if can_manage and empty_count > 0 and not selected_season %} +
+ + +
+ {% endif %} +
+
+ {% if sessions %} + + +
+ {% for session in sessions %} + {% set sm = db_id_to_summary.get(session.id) %} + {% set season_name = season_map[session.season_id].name if session.season_id and season_map.get(session.season_id) else '' %} +
+
+
+ {{ session.session_date.strftime('%b') }} + {{ session.session_date.strftime('%-d') }} +
+
+ +
+ S{{ session.sequence_on_date }} + {% if session.season_id and season_map.get(session.season_id) %} + {{ season_map[session.season_id].name }} + {% endif %} + {% if sm and sm.entries %} + · {{ sm.entries|length }} player{{ 's' if sm.entries|length != 1 else '' }} + · {{ sm.total_invested_cents | money }} pot + {% if sm.total_current_due_to_house_cents > 0 or sm.total_current_due_to_player_cents > 0 %} + · {{ (sm.total_current_due_to_house_cents + sm.total_current_due_to_player_cents) | money }} open + {% endif %} + {% endif %} + {% if session.notes %}· {{ session.notes }}{% endif %} +
+
+
+
+ {{ session.status }} + View + {% if can_manage %} + Manage + {% if session.status == 'open' %} +
+ + +
+ {% else %} + + {% endif %} + {% endif %} +
+
+ {% endfor %} +
+ {% else %} +
+

No sessions yet

+

Create the first session above. Same-day sessions will automatically become S1, S2, and so on.

+
+ {% endif %} +
+ +
+ + + + + + +{% endblock %} diff --git a/templates/league_settings.html b/templates/league_settings.html new file mode 100644 index 0000000..b839f52 --- /dev/null +++ b/templates/league_settings.html @@ -0,0 +1,358 @@ +{% extends "base.html" %} +{% block title %}Settings · {{ league.name }} · myboker.org{% endblock %} +{% block sidebar %}{% include "_league_sidebar.html" %}{% endblock %} +{% block page_class %}page--app{% endblock %} +{% block content %} + + +
+ +
+ +
+

General

+

League info

+
+ + + +
+

Leaderboard

+

Controls how players qualify and how sessions are classified.

+
+
+ + +
+
+ +
+
+ +
+
+
+

Access

+

Members

+
+ {{ members|length }} member{{ 's' if members|length != 1 else '' }} +
+
+
+ {% for membership, member_user in members %} +
+
+ {{ member_user.email }} + {{ membership.role }} +
+ {% if membership.role != 'owner' and member_user.id != current_user_id %} +
+ {% if membership.role == 'viewer' %} + + {% elif membership.role == 'manager' %} + + {% endif %} + +
+ {% endif %} +
+ {% endfor %} +
+
+ +
+

Invite by email

+

Add a member

+
+ + + +
+
+
+ +
+
+
+

Danger zone

+

Destructive actions

+
+
+
+
+
+ Transfer ownership +

Choose an existing member to become the league owner. Your role will change to manager.

+
+ +
+
+
+ Archive league +

Removes this league from your dashboard. All players, sessions, and ledger data are preserved.

+
+ +
+
+
+ Delete league +

Permanently deletes this league and all players, sessions, and ledger data. This cannot be undone.

+
+ +
+
+
+ +
+ + + + + + + + + + + + +{% endblock %} diff --git a/templates/leagues_index.html b/templates/leagues_index.html new file mode 100644 index 0000000..f39ff4b --- /dev/null +++ b/templates/leagues_index.html @@ -0,0 +1,133 @@ +{% extends "base.html" %} +{% block title %}Leagues · myboker.org{% endblock %} +{% block content %} + + + +{% if leagues %} + +
+ {% for item in leagues %} + {% set league = item.league %} + {% set membership = item.membership %} +
+ +
+ {{ league.name[0]|upper }} +
+ +
+
+
+
+ {{ league.name }} + {% if item.live_session %} + {{ session_label(item.live_session) }} + {% else %} + idle + {% endif %} +
+
+ {{ membership.role }} + + {% if league.visibility == 'public' %} + + public + {% else %} + + private + {% endif %} + +
+ {% if league.description %} +

{{ league.description }}

+ {% endif %} +
+ +
+
+ Players + {{ item.players_count }} +
+
+ Sessions + {{ item.sessions_count }} +
+
+ Paid out + {{ item.cash_paid_out_cents | money }} +
+
+ Open + {{ item.open_items_cents | money }} +
+
+
+ + {% if item.top_players %} +
+ Leaders +
+ {% for player in item.top_players %} +
+ #{{ loop.index }} + {{ player.player_name }} + + {{ '+' if player.total_net_cents > 0 else '' }}{{ player.total_net_cents | money }} + +
+ {% endfor %} +
+
+ {% endif %} + + +
+ +
+ {% endfor %} +
+ +{% else %} +
+

No leagues yet

+

Create your first league to start adding players, sessions, and ledger events.

+ Create league +
+{% endif %} + +{% endblock %} diff --git a/templates/privacy.html b/templates/privacy.html new file mode 100644 index 0000000..0d50e43 --- /dev/null +++ b/templates/privacy.html @@ -0,0 +1,129 @@ +{% extends "base.html" %} +{% block title %}Privacy Policy - myboker.org{% endblock %} +{% block page_class %}page--public{% endblock %} +{% block content %} + + + +{% endblock %} diff --git a/templates/reset_password.html b/templates/reset_password.html new file mode 100644 index 0000000..4e38060 --- /dev/null +++ b/templates/reset_password.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block title %}Reset Password · myboker.org{% endblock %} +{% block content %} + +
+
+ +
+

Account recovery

+

Reset password

+

Enter a new password for your account.

+
+ + + +
+
+ +{% endblock %} diff --git a/templates/support.html b/templates/support.html new file mode 100644 index 0000000..c7b29c1 --- /dev/null +++ b/templates/support.html @@ -0,0 +1,351 @@ +{% extends "base.html" %} +{% block title %}Help - myboker.org{% endblock %} +{% block content %} + +
+

Documentation

+

How myboker works

+

Everything you need to run a home poker league: sessions, ledgers, leaderboards, and settlement.

+
+ +
+ + + +
+ +
+

Leagues

+

A league is the top-level container for your home game. It holds all your members, players, sessions, and ledger events in one place. One account can manage multiple leagues, which is useful if you run separate games (a Thursday game and a weekend game, for example).

+

When you create a league you give it a name and optional description. A unique public key is generated automatically so you can share a link if you make the league public. All leagues start as private by default.

+
+ Tip: Leagues are long-lived. You don't need to create a new league every season. Use sessions for individual nights and seasons to group them into periods. +
+
+ +
+

Members & roles

+

Members are myboker.org account holders who have been granted access to a league. They are distinct from players: a member manages or observes the league, while a player is just a tracked name in the ledger.

+

Invite members from league settings. They'll receive an email with a unique link that expires after 7 days. Each member is assigned one of three roles:

+
+
+ Owner + Full control. Can manage settings, invite and remove members, create and close sessions, add players, and permanently delete the league. +
+
+ Manager + Can run sessions, record ledger events, manage players, and view all data. Cannot change league settings or manage member roles. +
+
+ Viewer + Read-only. Can view sessions, the leaderboard, and player history. Cannot record events or change anything. +
+
+
+ Removing a member: Disabling a member removes their access but preserves all their historical contributions. Past session data recorded by them remains intact. +
+
+ +
+

Players

+

Players are the people tracked in your league's ledger. They do not need a myboker.org account. A player is just a display name added by a manager or owner. Their results, stats, and history are tied to that name.

+

Display names don't have to be legal names. They can be nicknames or aliases. Player data is only visible to league members.

+

You can optionally link a player profile to a member account. This lets a real account "claim" their stats, but it is never required.

+
+
+ Active + Shows on the roster and is available to record events against. +
+
+ Archived + Hidden from the active roster. History and stats are fully preserved. Can be reactivated at any time. +
+
+
+ +
+

Seasons

+

Seasons let you group sessions into defined time periods, for example Season 1 or Spring 2026. They are entirely optional. Sessions can exist without belonging to any season.

+

When viewing the leaderboard, you can filter by season to see rankings for just that period. Seasons have optional start and end dates for reference but these do not restrict which sessions can be assigned to them.

+
+
+ Active + Visible in the league and available to assign sessions to. +
+
+ Archived + Hidden from the active list. Sessions assigned to it are preserved and remain filterable. +
+
+
+ +
+

Sessions

+

A session is a single night of poker. Sessions are date-stamped and sequenced automatically. If you play twice on the same day, they become S1 and S2 for that date.

+

Sessions have two states: open and closed. While open, you record buy-ins, cashouts, and all other ledger events. When everyone has settled, close the session. Closing doesn't delete anything and the session can always be reopened.

+
+
+ Date + The calendar date the game was played. Used to order sessions and group same-day games. +
+
+ Label + Optional short name for this session (e.g. "Main table", "Side game"). Shown in lists. +
+
+ Season + Optional. Assigns the session to a season so it appears in that season's leaderboard filter. +
+
+ Notes + Optional freeform context (e.g. "Holiday game", "Marcus's place"). +
+
+ Open + Session is live. Events can be added and settlement is still in progress. +
+
+ Closed + Session is settled. Books are considered final. Can be reopened if needed. +
+
+
+ +
+

Ledger events

+

All activity in a session is recorded as ledger events. The ledger is append-only. Events are never deleted. This keeps the record auditable even when corrections are needed. To fix a mistake, void the incorrect event and add the correct one.

+
+
+ buy-in +
+ A player buys chips with cash. +

Cash comes into the pot. Records how much the player has invested. Record one every time someone puts money on the table.

+
+
+
+ front +
+ The house covers a player's buy-in. +

When a player doesn't have cash, the house can front them chips. This creates a debt. The player owes that amount back to the house. Track fronts carefully so you know who owes what at the end of the night.

+
+
+
+ cashout +
+ A player exchanges their chips for their cash value. +

Records the chip count a player walks away with. This is the "owed X" event. It doesn't mean cash has left the house yet. Use paid out to record the actual handover.

+
+
+
+ paid out +
+ Cash physically leaves the house to the player. +

Closes the loop on a cashout. Once paid out, the player's balance goes to $0. Record this separately from cashout because payments may happen at different times.

+
+
+
+ rollover in +
+ Chips or a balance carried in from a previous session. +

Record a rollover in when a player carries an unpaid winning forward from a prior session. Always paired with a rollover out on the source session.

+
+
+
+ rollover out +
+ An unpaid winning deferred to a future session. +

Recorded when a player's payout isn't settled immediately and will be carried to the next game. Paired with a rollover in on the receiving session.

+
+
+
+ payout carry-in +
+ An outstanding winning from a prior session honoured here. +

Similar to rollover in but used when the balance isn't tied to a specific session's rollover out. Adds to the player's total payout due in this session.

+
+
+
+ debt repayment +
+ A player repays an outstanding front. +

When a player who was fronted chips pays the house back, record a debt repayment. This reduces their outstanding balance with the house.

+
+
+
+ write-off +
+ The house forgives a debt. +

If a front won't be collected (player left, debt forgiven), write it off. The debt is cleared from the open balance without cash changing hands. Add a note explaining why.

+
+
+
+
+ +
+

Settlement

+

The ledger tracks two types of open balance at all times:

+
+
+ Due to players + Cash the house owes to players who have cashed out but haven't been paid yet. Record a paid out event to clear each balance. +
+
+ Due to house + Cash players owe from unpaid fronts. Collect debt repayments or write off to clear the balance. +
+
+ Cash in + Total real cash that has entered the pot: buy-ins and repaid fronts only. Fronts themselves don't count because no cash entered. +
+
+ Cash paid out + Total real cash that has left the house to players. Should approach cash in over time once all balances are settled. +
+
+
+ Fronts vs buy-ins: A front doesn't add to "cash in" because no cash actually entered the pot. The house covered it. Cash in only counts money physically on the table. +
+
+ +
+

Leaderboard & stats

+

The leaderboard ranks players by their all-time performance within a league. Stats are computed directly from ledger events and are never manually entered.

+

Players must meet the minimum sessions threshold set in league settings (default: 3) before appearing in the ranked leaderboard. Players below the threshold are shown in a provisional section.

+
+
+ Net + Total cash out minus total invested across all sessions. Positive means up, negative means down. +
+
+ ROI + Net divided by total invested, as a percentage. Compares performance independent of how much someone plays. +
+
+ Win rate + Percentage of sessions where the player finished with a positive net. Breaking exactly even counts as a loss. +
+
+ Sessions played + Number of sessions with at least one ledger event for this player. +
+
+ Biggest win + The single session where the player netted the most profit. +
+
+ Biggest loss + The single session where the player lost the most money. +
+
+
+ +
+

Public vs private

+

Each league has a visibility setting you can change in league settings at any time.

+
+
+ Private + Only invited members can see this league. It will not appear on the Explore page. This is the default for all new leagues. +
+
+ Public + Anyone on the Explore page can view the leaderboard and session results. No account required to view. Members still need an account to manage it. +
+
+
+ Note: Making a league public shares leaderboard rankings and session results, but not private financial details like individual debt balances or member email addresses. +
+
+ + +
+

Account & login

+ +
+ I didn't receive a verification email. +
+

Check your spam or junk folder first. Verification emails are sent from noreply@myboker.org. If you still can't find it, sign in and use the Resend verification email option on your account page.

+

If you continue to have trouble, contact us at support@cedarline.digital.

+
+
+ +
+ How do I reset my password? +
+

On the sign-in page, click Forgot password? and enter your email address. You will receive a reset link that expires after 1 hour. Follow the link and choose a new password.

+

If you don't receive the email, check your spam folder or contact support.

+
+
+ +
+ How do I accept a league invitation? +
+

When a league owner invites you, you will receive an email with a unique invitation link. Clicking it will either sign you in directly (if you already have an account with that email) or guide you through creating an account first.

+

Invitation links expire after 7 days. If yours has expired, ask the league owner to send a new one.

+
+
+ +
+ How do I delete my account? +
+

Account deletion is available in your account settings. This is permanent and cannot be undone. Deleting your account removes your login credentials, all leagues you own and their complete session history, and your membership records in any leagues you belong to.

+

If you own leagues with other active members, consider notifying them before deleting your account as they will lose access to that league's data.

+
+
+
+ + +
+

Privacy & your data

+ +
+ Who can see my league data? +
+

For private leagues (the default): only invited members with active access. No one outside your league can see sessions, players, or financials.

+

For public leagues: the leaderboard is visible to any signed-in user on the Explore page. Full session detail and financials are still restricted to invited members.

+
+
+ +
+ Does myboker.org track me or use analytics? +
+

There are no third-party analytics scripts on myboker.org. We set one functional session cookie to keep you signed in. We may calculate first-party aggregate service metrics from existing account, league, session, and ledger records to understand service health and usage. We do not sell or share your data for advertising. See the full Privacy Policy for details.

+
+
+ +
+ How do I request a copy of my data or have it deleted? +
+

You can permanently delete your account and all associated data directly from account settings at any time. To request an export or to have data deleted on your behalf, email support@cedarline.digital from the address associated with your account. We respond within 30 days.

+
+
+
+ + +
+

Contact

+

myboker.org is owned and operated by Cedarline Digital.

+

For support, data requests, or anything else: support@cedarline.digital. Please allow a few days for a response.

+
+ Legal: See the Privacy Policy and Terms of Service for details on how your data is handled and the rules governing use of the service. +
+
+ +
+
+ +{% endblock %} diff --git a/templates/terms.html b/templates/terms.html new file mode 100644 index 0000000..0788162 --- /dev/null +++ b/templates/terms.html @@ -0,0 +1,110 @@ +{% extends "base.html" %} +{% block title %}Terms of Service - myboker.org{% endblock %} +{% block page_class %}page--public{% endblock %} +{% block content %} + + + +{% endblock %} diff --git a/tests/test_accounting.py b/tests/test_accounting.py index f4e2c8c..1d98330 100644 --- a/tests/test_accounting.py +++ b/tests/test_accounting.py @@ -1,16 +1,13 @@ -from pathlib import Path -import tempfile import unittest -import app as app_module -from charts import player_session_series -from services import ( +from boker.charts import player_session_series +from boker.services import ( build_leaderboard, build_session_summaries, pending_payout_carry_items, + prunable_empty_session_ids, ) -from storage import load_events, write_events -from utils import session_sort_key +from boker.utils import session_sort_key def cents(amount: float) -> int: @@ -189,7 +186,7 @@ class AccountingTests(unittest.TestCase): self.assertEqual(entry.paid_out_cents, 0) self.assertEqual(entry.payout_status, "settled") - def test_admin_can_apply_pending_payout_carry_in_to_open_session(self): + def test_pending_payout_carry_in_preserves_poker_investment(self): rows = [ event("s1", "2026-01-01", "", "session_open", 0, 1), event("s1", "2026-01-01", "A", "buyin", 10, 2), @@ -200,37 +197,19 @@ class AccountingTests(unittest.TestCase): event("s2", "2026-01-02", "A", "buyin", 10, 7), ] - with tempfile.TemporaryDirectory() as tmpdir: - data_path = Path(tmpdir) / "entries.csv" - write_events(data_path, rows) - original_path = app_module.app.config["DATA_PATH"] - app_module.app.config["DATA_PATH"] = data_path + sessions = build_session_summaries(rows) + self.assertEqual( + pending_payout_carry_items(sessions), + [{"player_name": "A", "amount_cents": cents(5)}], + ) - try: - sessions = build_session_summaries(load_events(data_path)) - self.assertEqual( - pending_payout_carry_items(sessions), - [{"player_name": "A", "amount_cents": cents(5)}], - ) - - with app_module.app.test_client() as client: - with client.session_transaction() as flask_session: - flask_session["is_admin"] = True - - response = client.post( - "/admin/apply-payout-carry-in", - data={"session_id": "s2", "player_name": "A"}, - ) - finally: - app_module.app.config["DATA_PATH"] = original_path - - self.assertEqual(response.status_code, 302) - updated_sessions = build_session_summaries(load_events(data_path)) - target = next(s for s in updated_sessions if s.session_id == "s2") - target_entry = target.entries[0] - self.assertEqual(target_entry.payout_carry_in_cents, cents(5)) - self.assertEqual(target_entry.invested_cents, cents(10)) - self.assertEqual(target_entry.current_due_to_player_cents, cents(5)) + rows.append(event("s2", "2026-01-02", "A", "payout_carry_in", 5, 8)) + updated_sessions = build_session_summaries(rows) + target = next(s for s in updated_sessions if s.session_id == "s2") + target_entry = target.entries[0] + self.assertEqual(target_entry.payout_carry_in_cents, cents(5)) + self.assertEqual(target_entry.invested_cents, cents(10)) + self.assertEqual(target_entry.current_due_to_player_cents, cents(5)) def test_same_day_sessions_sort_by_session_sequence(self): rows = [ @@ -248,7 +227,7 @@ class AccountingTests(unittest.TestCase): ["Mar 21, 2026 · S1", "Mar 21, 2026 · S2"], ) - def test_admin_prunes_only_empty_marker_sessions(self): + def test_prunable_empty_sessions_excludes_played_sessions(self): rows = [ event("empty-1", "2026-01-01", "", "session_open", 0, 1), event("empty-1", "2026-01-01", "", "session_close", 0, 2), @@ -258,31 +237,21 @@ class AccountingTests(unittest.TestCase): event("played-1", "2026-01-02", "", "session_close", 0, 6), ] - with tempfile.TemporaryDirectory() as tmpdir: - data_path = Path(tmpdir) / "entries.csv" - write_events(data_path, rows) - original_path = app_module.app.config["DATA_PATH"] - app_module.app.config["DATA_PATH"] = data_path + sessions = build_session_summaries(rows) + prunable_ids = prunable_empty_session_ids(rows, sessions) + self.assertEqual(prunable_ids, {"empty-1"}) - try: - with app_module.app.test_client() as client: - with client.session_transaction() as flask_session: - flask_session["is_admin"] = True - - response = client.post("/admin/prune-empty-sessions") - finally: - app_module.app.config["DATA_PATH"] = original_path - - self.assertEqual(response.status_code, 302) - remaining = load_events(data_path) - self.assertEqual( - {row["session_id"] for row in remaining}, - {"played-1"}, - ) - self.assertEqual( - [row["event_type"] for row in remaining], - ["session_open", "buyin", "cashout", "session_close"], - ) + remaining = [ + row + for row in rows + if (row["session_id"].strip() or row["session_date"].strip()) + not in prunable_ids + ] + self.assertEqual({row["session_id"] for row in remaining}, {"played-1"}) + self.assertEqual( + [row["event_type"] for row in remaining], + ["session_open", "buyin", "cashout", "session_close"], + ) if __name__ == "__main__": diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..d6c8027 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,37 @@ +import os +import unittest +from unittest.mock import patch + +from app import create_app + + +class ProductionConfigTests(unittest.TestCase): + def test_production_refuses_default_sqlite_database(self): + with patch.dict( + os.environ, + {"APP_ENV": "production"}, + clear=False, + ): + with self.assertRaisesRegex(RuntimeError, "DATABASE_URL"): + create_app({"SECRET_KEY": "test-production-secret"}) + + def test_production_accepts_postgresql_database_url(self): + with patch.dict( + os.environ, + {"APP_ENV": "production"}, + clear=False, + ): + app = create_app( + { + "SECRET_KEY": "test-production-secret", + "SQLALCHEMY_DATABASE_URI": "postgresql+psycopg://user:password@example.com/dbname", + } + ) + + self.assertTrue(app.config["SESSION_COOKIE_SECURE"]) + self.assertEqual(app.config["SESSION_COOKIE_SAMESITE"], "Strict") + self.assertTrue(app.config["SQLALCHEMY_DATABASE_URI"].startswith("postgresql+psycopg://")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_db_foundation.py b/tests/test_db_foundation.py new file mode 100644 index 0000000..7b80337 --- /dev/null +++ b/tests/test_db_foundation.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from datetime import date +import unittest + +from boker.db import database_extensions_available + + +@unittest.skipUnless( + database_extensions_available(), + "Database dependencies are not installed.", +) +class DatabaseFoundationTests(unittest.TestCase): + def setUp(self): + from app import create_app + from boker.db import db + + self.db = db + self.app = create_app( + { + "TESTING": True, + "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:", + } + ) + self.ctx = self.app.app_context() + self.ctx.push() + self.db.create_all() + + def tearDown(self): + self.db.session.remove() + self.db.drop_all() + self.db.engine.dispose() + self.ctx.pop() + + def test_owner_league_players_sessions_season_and_events(self): + from boker.db_models import LedgerEvent, LeagueMembership, PokerSession + from boker.ledger_repositories import append_ledger_event + from boker.league_repositories import ( + create_league, + create_player, + create_poker_session, + create_season, + create_user, + user_has_league_role, + ) + + owner = create_user("Owner@Example.com", "correct horse battery staple") + self.db.session.flush() + league = create_league(owner, "Friday Poker", "friday-poker") + player = create_player(league.id, "Alex", "alex") + season = create_season( + league.id, + "Fall 2026", + start_date=date(2026, 9, 1), + end_date=date(2026, 12, 31), + ) + self.db.session.flush() + + first_session = create_poker_session( + league.id, + date(2026, 10, 2), + season_id=season.id, + status="open", + ) + second_session = create_poker_session( + league.id, + date(2026, 10, 2), + season_id=season.id, + ) + self.db.session.flush() + + append_ledger_event( + league.id, + first_session.id, + "buyin", + 2500, + owner.id, + player_id=player.id, + ) + append_ledger_event( + league.id, + first_session.id, + "paid", + 1000, + owner.id, + player_id=player.id, + ) + append_ledger_event( + league.id, + first_session.id, + "session_close", + 9999, + owner.id, + note="Closed after payout review.", + ) + self.db.session.commit() + + membership = LeagueMembership.query.filter_by( + league_id=league.id, + user_id=owner.id, + ).one() + sessions = PokerSession.query.order_by(PokerSession.sequence_on_date.asc()).all() + events = LedgerEvent.query.order_by(LedgerEvent.created_at.asc()).all() + + self.assertEqual(owner.email, "owner@example.com") + self.assertEqual(membership.role, "owner") + self.assertTrue(user_has_league_role(owner.id, league.id, {"owner"})) + self.assertEqual([s.sequence_on_date for s in sessions], [1, 2]) + self.assertEqual(sessions[0].season_id, season.id) + self.assertEqual(events[1].event_type, "paid_out") + self.assertEqual(events[2].amount_cents, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..ea0d844 --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,47 @@ +import re +import tempfile +import unittest +from pathlib import Path + +from app import create_app +from boker.db import db + + +class ErrorPageTests(unittest.TestCase): + def setUp(self): + self.tmpdir = tempfile.TemporaryDirectory() + db_path = Path(self.tmpdir.name) / "test.sqlite3" + self.app = create_app( + { + "TESTING": True, + "PROPAGATE_EXCEPTIONS": False, + "SQLALCHEMY_DATABASE_URI": f"sqlite:///{db_path}", + "WTF_CSRF_ENABLED": False, + } + ) + + @self.app.get("/explode") + def explode(): + raise RuntimeError("boom") + + self.client = self.app.test_client() + + def tearDown(self): + with self.app.app_context(): + db.session.remove() + db.drop_all() + db.engine.dispose() + self.tmpdir.cleanup() + + def test_unexpected_errors_render_crash_page_with_incident_id(self): + response = self.client.get("/explode") + + self.assertEqual(response.status_code, 500) + self.assertIn(b"Something went wrong.", response.data) + self.assertIn(b"Incident", response.data) + self.assertNotIn(b"RuntimeError", response.data) + self.assertRegex(response.get_data(as_text=True), re.compile(r"Incident [a-f0-9]{12}")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_internal.py b/tests/test_internal.py new file mode 100644 index 0000000..2a5ef14 --- /dev/null +++ b/tests/test_internal.py @@ -0,0 +1,320 @@ +import tempfile +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from app import create_app +from boker.auth import hash_password +from boker.db import db + + +class InternalAdminAccessTests(unittest.TestCase): + def setUp(self): + self.tmpdir = tempfile.TemporaryDirectory() + db_path = Path(self.tmpdir.name) / "test.sqlite3" + self.app = create_app( + { + "TESTING": True, + "SQLALCHEMY_DATABASE_URI": f"sqlite:///{db_path}", + "WTF_CSRF_ENABLED": False, + } + ) + self.client = self.app.test_client() + + with self.app.app_context(): + from boker.db_models import League, LeagueMembership, User + + db.create_all() + self.user = User( + email="viewer@example.com", + password_hash=hash_password("password123"), + ) + self.manager = User( + email="manager@example.com", + password_hash=hash_password("password123"), + ) + self.admin = User( + email="admin@example.com", + password_hash=hash_password("password123"), + is_site_admin=True, + ) + db.session.add_all([self.user, self.manager, self.admin]) + db.session.flush() + self.league = League( + name="Friday Poker", + slug="friday-poker", + public_key="abc123", + created_by_user_id=self.admin.id, + visibility="public", + ) + db.session.add(self.league) + db.session.flush() + db.session.add_all( + [ + LeagueMembership(league_id=self.league.id, user_id=self.admin.id, role="owner"), + LeagueMembership(league_id=self.league.id, user_id=self.manager.id, role="manager"), + ] + ) + db.session.commit() + self.user_id = self.user.id + self.manager_id = self.manager.id + self.admin_id = self.admin.id + self.league_id = self.league.id + + def tearDown(self): + with self.app.app_context(): + db.session.remove() + db.drop_all() + db.engine.dispose() + self.tmpdir.cleanup() + + def login_as(self, user_id: str, **extra_session): + with self.client.session_transaction() as session: + session["user_id"] = user_id + for key, value in extra_session.items(): + session[key] = value + + def test_internal_dashboard_requires_login(self): + response = self.client.get("/internal/") + + self.assertEqual(response.status_code, 302) + self.assertIn("/account/login", response.headers["Location"]) + + def test_internal_dashboard_rejects_regular_users(self): + self.login_as(self.user_id) + + response = self.client.get("/internal/") + + self.assertEqual(response.status_code, 403) + + def test_internal_dashboard_ignores_client_side_admin_spoof(self): + self.login_as(self.user_id, is_site_admin=True) + + response = self.client.get("/internal/") + + self.assertEqual(response.status_code, 403) + + def test_internal_dashboard_allows_site_admins(self): + self.login_as(self.admin_id) + + response = self.client.get("/internal/") + + self.assertEqual(response.status_code, 200) + self.assertIn(b"Overview", response.data) + self.assertIn(b"admin@example.com", response.data) + self.assertIn(b"Active users", response.data) + self.assertIn(b"Activity breakdown", response.data) + self.assertIn(b"Ledger events", response.data) + self.assertIn(b"Totals at a glance", response.data) + + def test_admin_can_search_users(self): + self.login_as(self.admin_id) + + response = self.client.get("/internal/users?q=viewer") + + self.assertEqual(response.status_code, 200) + self.assertIn(b"viewer@example.com", response.data) + self.assertNotIn(b"manager@example.com", response.data) + + def test_admin_users_page_is_paginated(self): + with self.app.app_context(): + from boker.db_models import User + + users = [ + User(email=f"bulk{i:02d}@example.com", password_hash=hash_password("password123")) + for i in range(55) + ] + db.session.add_all(users) + db.session.commit() + self.login_as(self.admin_id) + + response = self.client.get("/internal/users?page=2") + + self.assertEqual(response.status_code, 200) + self.assertIn(b"Page 2 of 2", response.data) + self.assertIn(b"Previous", response.data) + + def test_admin_leagues_page_is_paginated(self): + with self.app.app_context(): + from boker.db_models import League + + leagues = [ + League( + name=f"Bulk League {i:02d}", + slug=f"bulk-league-{i:02d}", + public_key=f"bk{i:010d}"[:12], + created_by_user_id=self.admin_id, + visibility="private", + ) + for i in range(55) + ] + db.session.add_all(leagues) + db.session.commit() + self.login_as(self.admin_id) + + response = self.client.get("/internal/leagues?page=2") + + self.assertEqual(response.status_code, 200) + self.assertIn(b"Page 2 of 2", response.data) + self.assertIn(b"Previous", response.data) + + def test_session_growth_uses_session_date_not_created_at(self): + with self.app.app_context(): + from boker.db_models import PokerSession + from boker.routes.internal import _growth_data + + now = datetime.now(timezone.utc) + old_session = PokerSession( + league_id=self.league_id, + session_date=(now - timedelta(weeks=5)).date(), + sequence_on_date=1, + status="closed", + created_at=now, + ) + current_session = PokerSession( + league_id=self.league_id, + session_date=(now - timedelta(days=2)).date(), + sequence_on_date=1, + status="closed", + created_at=now, + ) + db.session.add_all([old_session, current_session]) + db.session.commit() + + growth = _growth_data() + + self.assertEqual(sum(growth["session_counts"]), 2) + self.assertLess(growth["session_counts"][-1], 2) + + def test_regular_user_cannot_update_user_email(self): + self.login_as(self.user_id) + + response = self.client.post( + f"/internal/users/{self.user_id}/email", + data={"email": "changed@example.com"}, + ) + + self.assertEqual(response.status_code, 403) + with self.app.app_context(): + from boker.db_models import User + + user = db.session.get(User, self.user_id) + self.assertEqual(user.email, "viewer@example.com") + + def test_admin_can_update_user_email(self): + self.login_as(self.admin_id) + + response = self.client.post( + f"/internal/users/{self.user_id}/email", + data={"email": "changed@example.com"}, + follow_redirects=True, + ) + + self.assertEqual(response.status_code, 200) + with self.app.app_context(): + from boker.db_models import User + + user = db.session.get(User, self.user_id) + self.assertEqual(user.email, "changed@example.com") + + def test_admin_delete_user_blocks_owned_leagues(self): + self.login_as(self.admin_id) + with self.app.app_context(): + from boker.db_models import League, LeagueMembership + + owned_league = League( + name="Manager Owned Poker", + slug="manager-owned-poker", + public_key="mgr123", + created_by_user_id=self.manager_id, + visibility="private", + ) + db.session.add(owned_league) + db.session.flush() + db.session.add(LeagueMembership(league_id=owned_league.id, user_id=self.manager_id, role="owner")) + db.session.commit() + + response = self.client.post( + f"/internal/users/{self.manager_id}/delete", + data={"confirm": "DELETE"}, + ) + + self.assertEqual(response.status_code, 302) + with self.app.app_context(): + from boker.db_models import User + + self.assertIsNotNone(db.session.get(User, self.manager_id)) + + def test_admin_can_archive_and_restore_league(self): + self.login_as(self.admin_id) + + archive_response = self.client.post(f"/internal/leagues/{self.league_id}/archive") + self.assertEqual(archive_response.status_code, 302) + with self.app.app_context(): + from boker.db_models import League + + league = db.session.get(League, self.league_id) + self.assertIsNotNone(league.archived_at) + + restore_response = self.client.post(f"/internal/leagues/{self.league_id}/restore") + self.assertEqual(restore_response.status_code, 302) + with self.app.app_context(): + from boker.db_models import League + + league = db.session.get(League, self.league_id) + self.assertIsNone(league.archived_at) + + def test_admin_can_update_league_settings(self): + self.login_as(self.admin_id) + + response = self.client.post( + f"/internal/leagues/{self.league_id}/settings", + data={ + "name": "Saturday Poker", + "description": "Updated by support", + "visibility": "private", + "eligible_min_sessions": "5", + "break_even_dollars": "2.50", + }, + ) + + self.assertEqual(response.status_code, 302) + with self.app.app_context(): + from boker.db_models import League + + league = db.session.get(League, self.league_id) + self.assertEqual(league.name, "Saturday Poker") + self.assertEqual(league.slug, "saturday-poker") + self.assertEqual(league.description, "Updated by support") + self.assertEqual(league.visibility, "private") + self.assertEqual(league.eligible_min_sessions, 5) + self.assertEqual(league.break_even_cents, 250) + + def test_admin_can_transfer_league_ownership(self): + self.login_as(self.admin_id) + + response = self.client.post( + f"/internal/leagues/{self.league_id}/transfer", + data={"new_owner_email": "manager@example.com"}, + ) + + self.assertEqual(response.status_code, 302) + with self.app.app_context(): + from boker.db_models import League, LeagueMembership + + league = db.session.get(League, self.league_id) + self.assertEqual(league.created_by_user_id, self.manager_id) + old_owner_membership = LeagueMembership.query.filter_by( + league_id=self.league_id, + user_id=self.admin_id, + ).one() + new_owner_membership = LeagueMembership.query.filter_by( + league_id=self.league_id, + user_id=self.manager_id, + ).one() + self.assertEqual(old_owner_membership.role, "manager") + self.assertEqual(new_owner_membership.role, "owner") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_phase2_auth_leagues.py b/tests/test_phase2_auth_leagues.py new file mode 100644 index 0000000..3b2fde1 --- /dev/null +++ b/tests/test_phase2_auth_leagues.py @@ -0,0 +1,502 @@ +from __future__ import annotations + +from datetime import date +import unittest + +from boker.db import database_extensions_available + + +@unittest.skipUnless( + database_extensions_available(), + "Database dependencies are not installed.", +) +class Phase2AuthLeagueRouteTests(unittest.TestCase): + def setUp(self): + from app import create_app + from boker.db import db + + self.db = db + self.app = create_app( + { + "TESTING": True, + "SECRET_KEY": "test-secret", + "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:", + "WTF_CSRF_ENABLED": False, + } + ) + self.ctx = self.app.app_context() + self.ctx.push() + self.db.create_all() + self.client = self.app.test_client() + + def tearDown(self): + self.db.session.remove() + self.db.drop_all() + self.db.engine.dispose() + self.ctx.pop() + + def league_path(self, league=None) -> str: + if league is None: + from boker.db_models import League + + league = League.query.one() + return f"/l/{league.url_ref}" + + def create_verified_user(self, email="owner@example.com"): + from boker.db_models import utc_now + from boker.league_repositories import create_user + + user = create_user(email, "password123") + self.db.session.flush() + user.email_verified_at = utc_now() + self.db.session.commit() + return user + + def login_as(self, user): + with self.client.session_transaction() as flask_session: + flask_session["user_id"] = user.id + + def create_logged_in_league(self, email="owner@example.com", name="Friday Poker"): + from boker.league_repositories import create_league + + owner = self.create_verified_user(email) + league = create_league(owner, name, name.lower().replace(" ", "-")) + self.db.session.commit() + self.login_as(owner) + return owner, league, self.league_path(league) + + def test_registration_starts_email_verification_before_login(self): + from boker.db_models import User + + response = self.client.post( + "/account/register", + data={ + "email": "Owner@Example.com", + "password": "password123", + "confirm_password": "password123", + }, + ) + + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers["Location"], "/account/verify-email") + + user = User.query.one() + self.assertEqual(user.email, "owner@example.com") + self.assertIsNone(user.email_verified_at) + self.assertIsNotNone(user.email_verification_code_hash) + + with self.client.session_transaction() as flask_session: + self.assertNotIn("user_id", flask_session) + self.assertEqual(flask_session["pending_verification_user_id"], user.id) + + def test_verified_user_can_create_league_and_owner_membership(self): + from boker.db_models import League, LeagueMembership, User + + user = self.create_verified_user("Owner@Example.com") + self.login_as(user) + + response = self.client.post( + "/leagues/new", + data={ + "name": "Friday Poker", + "slug": "", + "description": "Private home league", + }, + ) + self.assertEqual(response.status_code, 302) + + self.assertEqual(User.query.count(), 1) + league = League.query.one() + self.assertEqual(response.headers["Location"], self.league_path(league)) + membership = LeagueMembership.query.one() + self.assertEqual(league.slug, "friday-poker") + self.assertEqual(membership.user_id, user.id) + self.assertEqual(membership.role, "owner") + + dashboard = self.client.get(self.league_path(league)) + self.assertEqual(dashboard.status_code, 200) + self.assertIn(b"Friday Poker", dashboard.data) + + def test_legacy_admin_routes_are_not_registered_by_default(self): + response = self.client.get("/admin/login") + self.assertEqual(response.status_code, 404) + + def test_duplicate_league_slug_is_allowed_because_route_uses_public_key(self): + owner = self.create_verified_user() + self.login_as(owner) + self.client.post("/leagues/new", data={"name": "Friday Poker"}) + self.client.post("/leagues/new", data={"name": "Friday Poker"}) + + from boker.db_models import League + + self.assertEqual( + [league.slug for league in League.query.order_by(League.slug.asc()).all()], + ["friday-poker", "friday-poker"], + ) + + def test_legacy_uuid_league_route_redirects_to_public_key_route(self): + from boker.db import db + from boker.league_repositories import create_league, create_user + + owner = create_user("owner@example.com", "password123") + db.session.flush() + league = create_league(owner, "Private League", "private-league") + db.session.commit() + + with self.client.session_transaction() as flask_session: + flask_session["user_id"] = owner.id + + response = self.client.get(f"/l/{league.id}/{league.slug}") + self.assertEqual(response.status_code, 301) + self.assertEqual(response.headers["Location"], self.league_path(league)) + + def test_league_dashboard_requires_membership(self): + from boker.db import db + from boker.league_repositories import create_league, create_user + + owner = create_user("owner@example.com", "password123") + other = create_user("other@example.com", "password123") + db.session.flush() + league = create_league(owner, "Private League", "private-league") + db.session.commit() + + with self.client.session_transaction() as flask_session: + flask_session["user_id"] = other.id + + response = self.client.get(self.league_path(league)) + self.assertEqual(response.status_code, 403) + + def test_owner_can_add_archive_and_reactivate_player(self): + from boker.db_models import Player + + _, _, league_base = self.create_logged_in_league() + + response = self.client.post( + f"{league_base}/players", + data={ + "display_name": "Alex", + "slug": "", + "notes": "Usually hosts.", + }, + ) + self.assertEqual(response.status_code, 302) + + player = Player.query.one() + self.assertEqual(player.display_name, "Alex") + self.assertEqual(player.slug, "alex") + self.assertEqual(player.status, "active") + self.assertEqual(player.notes, "Usually hosts.") + + duplicate = self.client.post( + f"{league_base}/players", + data={"display_name": " alex "}, + ) + self.assertEqual(duplicate.status_code, 200) + self.assertIn(b"already exists", duplicate.data) + self.assertEqual(Player.query.count(), 1) + + archive = self.client.post(f"{league_base}/players/{player.id}/archive") + self.assertEqual(archive.status_code, 302) + self.db.session.refresh(player) + self.assertEqual(player.status, "archived") + + reactivate = self.client.post(f"{league_base}/players/{player.id}/reactivate") + self.assertEqual(reactivate.status_code, 302) + self.db.session.refresh(player) + self.assertEqual(player.status, "active") + + def test_viewer_can_view_players_but_cannot_add_or_archive(self): + from boker.db import db + from boker.db_models import LeagueMembership, Player + from boker.league_repositories import create_league, create_player, create_user + + owner = create_user("owner@example.com", "password123") + viewer = create_user("viewer@example.com", "password123") + db.session.flush() + league = create_league(owner, "Private League", "private-league") + player = create_player(league.id, "Alex", "alex") + db.session.flush() + db.session.add( + LeagueMembership( + league_id=league.id, + user_id=viewer.id, + role="viewer", + ) + ) + db.session.commit() + + with self.client.session_transaction() as flask_session: + flask_session["user_id"] = viewer.id + + league_base = self.league_path(league) + response = self.client.get(f"{league_base}/players") + self.assertEqual(response.status_code, 200) + self.assertIn(b"Alex", response.data) + self.assertNotIn(b"Add player", response.data) + + add = self.client.post( + f"{league_base}/players", + data={"display_name": "Blair"}, + ) + self.assertEqual(add.status_code, 403) + self.assertEqual(Player.query.count(), 1) + + archive = self.client.post(f"{league_base}/players/{player.id}/archive") + self.assertEqual(archive.status_code, 403) + self.db.session.refresh(player) + self.assertEqual(player.status, "active") + + def test_owner_can_create_same_day_sessions_and_close_them(self): + from boker.db_models import LedgerEvent, PokerSession + + _, _, league_base = self.create_logged_in_league() + + first = self.client.post( + f"{league_base}/sessions", + data={ + "session_date": "2026-10-02", + "label": "", + "status": "open", + "notes": "Main game", + }, + ) + second = self.client.post( + f"{league_base}/sessions", + data={ + "session_date": "2026-10-02", + "label": "Late table", + "status": "open", + }, + ) + + self.assertEqual(first.status_code, 302) + self.assertEqual(second.status_code, 302) + + sessions = PokerSession.query.order_by(PokerSession.sequence_on_date.asc()).all() + self.assertEqual([session.sequence_on_date for session in sessions], [1, 2]) + self.assertEqual(sessions[0].status, "open") + self.assertEqual(sessions[0].notes, "Main game") + self.assertEqual(sessions[1].display_label, "Late table") + + events = LedgerEvent.query.order_by(LedgerEvent.created_at.asc()).all() + self.assertEqual([event.event_type for event in events], ["session_open", "session_open"]) + + close = self.client.post(f"{league_base}/sessions/{sessions[0].id}/close") + self.assertEqual(close.status_code, 302) + self.db.session.refresh(sessions[0]) + self.assertEqual(sessions[0].status, "closed") + self.assertIsNotNone(sessions[0].closed_at) + self.assertEqual(LedgerEvent.query.count(), 3) + self.assertEqual( + LedgerEvent.query.order_by(LedgerEvent.created_at.desc()).first().event_type, + "session_close", + ) + + def test_closed_session_creation_does_not_append_marker_event_until_opened(self): + from boker.db_models import LedgerEvent, PokerSession + + _, _, league_base = self.create_logged_in_league() + self.client.post( + f"{league_base}/sessions", + data={"session_date": "2026-10-02", "status": "closed"}, + ) + + session = PokerSession.query.one() + self.assertEqual(session.status, "closed") + self.assertEqual(LedgerEvent.query.count(), 0) + + open_response = self.client.post(f"{league_base}/sessions/{session.id}/open") + self.assertEqual(open_response.status_code, 302) + self.db.session.refresh(session) + self.assertEqual(session.status, "open") + self.assertIsNotNone(session.opened_at) + self.assertEqual(LedgerEvent.query.one().event_type, "session_open") + + def test_viewer_can_view_sessions_but_cannot_create_or_close(self): + from boker.db import db + from boker.db_models import LeagueMembership, PokerSession + from boker.league_repositories import create_league, create_poker_session, create_user + + owner = create_user("owner@example.com", "password123") + viewer = create_user("viewer@example.com", "password123") + db.session.flush() + league = create_league(owner, "Private League", "private-league") + session = create_poker_session(league.id, date(2026, 10, 2), status="open") + db.session.flush() + db.session.add( + LeagueMembership( + league_id=league.id, + user_id=viewer.id, + role="viewer", + ) + ) + db.session.commit() + + with self.client.session_transaction() as flask_session: + flask_session["user_id"] = viewer.id + + league_base = self.league_path(league) + response = self.client.get(f"{league_base}/sessions") + self.assertEqual(response.status_code, 200) + self.assertIn(b"2026-10-02", response.data) + self.assertNotIn(b"Create session", response.data) + + create = self.client.post( + f"{league_base}/sessions", + data={"session_date": "2026-10-03"}, + ) + self.assertEqual(create.status_code, 403) + self.assertEqual(PokerSession.query.count(), 1) + + close = self.client.post(f"{league_base}/sessions/{session.id}/close") + self.assertEqual(close.status_code, 403) + self.db.session.refresh(session) + self.assertEqual(session.status, "open") + + def test_owner_can_append_events_and_view_db_backed_stats(self): + from boker.db_models import LedgerEvent, Player, PokerSession + + _, _, league_base = self.create_logged_in_league() + self.client.post(f"{league_base}/players", data={"display_name": "Alex"}) + self.client.post( + f"{league_base}/sessions", + data={"session_date": "2026-10-02", "status": "open"}, + ) + + player = Player.query.one() + session = PokerSession.query.one() + + buyin = self.client.post( + f"{league_base}/sessions/{session.id}", + data={ + "player_id": player.id, + "event_type": "buyin", + "amount": "20", + }, + ) + cashout = self.client.post( + f"{league_base}/sessions/{session.id}", + data={ + "player_id": player.id, + "event_type": "cashout", + "amount": "35", + }, + ) + paid = self.client.post( + f"{league_base}/sessions/{session.id}", + data={ + "player_id": player.id, + "event_type": "paid_out", + "amount": "15", + }, + ) + + self.assertEqual(buyin.status_code, 302) + self.assertEqual(cashout.status_code, 302) + self.assertEqual(paid.status_code, 302) + self.assertEqual(LedgerEvent.query.filter_by(player_id=player.id).count(), 3) + + leaderboard = self.client.get(f"{league_base}/leaderboard") + self.assertEqual(leaderboard.status_code, 200) + self.assertIn(b"Alex", leaderboard.data) + self.assertIn(b"15.00", leaderboard.data) + + detail = self.client.get(f"{league_base}/sessions/{session.id}") + self.assertEqual(detail.status_code, 200) + self.assertIn(b"cashout", detail.data) + self.assertIn(b"paid out", detail.data) + + player_detail = self.client.get(f"{league_base}/players/{player.id}") + self.assertEqual(player_detail.status_code, 200) + self.assertIn(b"Total net", player_detail.data) + self.assertIn(b"20.00", player_detail.data) + + def test_league_ledger_shows_and_resolves_player_debts(self): + from boker.db_models import LedgerEvent, Player, PokerSession + + _, _, league_base = self.create_logged_in_league() + self.client.post(f"{league_base}/players", data={"display_name": "Alex"}) + self.client.post( + f"{league_base}/sessions", + data={"session_date": "2026-10-02", "status": "open"}, + ) + + player = Player.query.one() + session = PokerSession.query.one() + self.client.post( + f"{league_base}/sessions/{session.id}", + data={"player_id": player.id, "event_type": "front", "amount": "50"}, + ) + self.client.post( + f"{league_base}/sessions/{session.id}", + data={"player_id": player.id, "event_type": "cashout", "amount": "20"}, + ) + + ledger = self.client.get(f"{league_base}/ledger") + self.assertEqual(ledger.status_code, 200) + self.assertIn(b"Debt resolution", ledger.data) + self.assertIn(b"Front receivables", ledger.data) + self.assertIn(b"30.00", ledger.data) + + repayment = self.client.post( + f"{league_base}/ledger", + data={ + "session_id": session.id, + "player_id": player.id, + "event_type": "debt_repayment", + "amount": "10", + }, + ) + self.assertEqual(repayment.status_code, 302) + self.assertEqual( + LedgerEvent.query.order_by(LedgerEvent.created_at.desc()).first().event_type, + "debt_repayment", + ) + + ledger = self.client.get(f"{league_base}/ledger") + self.assertEqual(ledger.status_code, 200) + self.assertIn(b"20.00", ledger.data) + + def test_viewer_cannot_append_ledger_events(self): + from boker.db import db + from boker.db_models import LeagueMembership, LedgerEvent + from boker.league_repositories import ( + create_league, + create_player, + create_poker_session, + create_user, + ) + + owner = create_user("owner@example.com", "password123") + viewer = create_user("viewer@example.com", "password123") + db.session.flush() + league = create_league(owner, "Private League", "private-league") + player = create_player(league.id, "Alex", "alex") + session = create_poker_session(league.id, date(2026, 10, 2), status="open") + db.session.flush() + db.session.add( + LeagueMembership( + league_id=league.id, + user_id=viewer.id, + role="viewer", + ) + ) + db.session.commit() + + with self.client.session_transaction() as flask_session: + flask_session["user_id"] = viewer.id + + response = self.client.post( + f"{self.league_path(league)}/sessions/{session.id}", + data={ + "player_id": player.id, + "event_type": "buyin", + "amount": "20", + }, + ) + self.assertEqual(response.status_code, 403) + self.assertEqual(LedgerEvent.query.count(), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_stats.py b/tests/test_stats.py index 36ceaf3..757b885 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -1,10 +1,11 @@ import unittest -from stats import SessionEntry, SessionSummary, build_leaderboard +from boker.models import SessionEntry, SessionSummary +from boker.services import build_leaderboard class CashInAccountingTest(unittest.TestCase): - def test_front_recovered_from_cashout_counts_as_session_cash_in(self): + def test_front_is_poker_investment_not_cash_in(self): entry = SessionEntry( session_id="2026-06-21-01", session_date="2026-06-21", @@ -20,10 +21,10 @@ class CashInAccountingTest(unittest.TestCase): ) self.assertEqual(session.total_buy_in_cents, 2500) - self.assertEqual(session.total_front_collected_cents, 0) - self.assertEqual(session.total_cash_in_cents, 3000) + self.assertEqual(session.total_front_cents, 500) + self.assertEqual(session.total_cash_in_cents, 2500) - def test_collected_front_shortfall_counts_as_session_cash_in(self): + def test_debt_repayment_counts_as_session_cash_in(self): entry = SessionEntry( session_id="2026-06-21-01", session_date="2026-06-21", @@ -39,7 +40,7 @@ class CashInAccountingTest(unittest.TestCase): ) self.assertEqual(session.total_front_collected_cents, 600) - self.assertEqual(session.total_cash_in_cents, 1000) + self.assertEqual(session.total_cash_in_cents, 600) def test_written_off_front_does_not_count_as_session_cash_in(self): entry = SessionEntry( @@ -58,7 +59,7 @@ class CashInAccountingTest(unittest.TestCase): self.assertEqual(session.total_front_writeoff_cents, 1000) self.assertEqual(session.total_cash_in_cents, 0) - def test_leaderboard_cash_in_includes_collected_fronts(self): + def test_leaderboard_cash_in_uses_real_cash_only(self): session = SessionSummary( session_id="2026-06-21-01", session_date="2026-06-21", @@ -69,6 +70,7 @@ class CashInAccountingTest(unittest.TestCase): player_name="Alex", buy_in_cents=2500, front_cents=500, + front_collected_cents=200, cash_out_cents=4000, ) ], @@ -77,8 +79,8 @@ class CashInAccountingTest(unittest.TestCase): player = build_leaderboard([session])[0] self.assertEqual(player.total_buy_in_cents, 2500) - self.assertEqual(player.total_front_collected_cents, 0) - self.assertEqual(player.total_cash_in_cents, 3000) + self.assertEqual(player.total_front_collected_cents, 200) + self.assertEqual(player.total_real_cash_in_cents, 2700) if __name__ == "__main__":