2.0+ public access setup
This commit is contained in:
45 files changed
+7875
-32
No files matched your search
+8
-2
@@ -1,3 +1,9 @@
|
|||||||
SECRET_KEY=replace-this-with-a-long-random-string
|
SECRET_KEY=replace-this-with-a-long-random-string
|
||||||
ADMIN_USERNAME=admin
|
DATABASE_URL=postgresql+psycopg://user:password@host/dbname
|
||||||
ADMIN_PASSWORD=replace-this-password
|
APP_BASE_URL=https://myboker.org
|
||||||
|
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
|
||||||
@@ -1,21 +1,31 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import click
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
from auth import is_admin
|
from auth import current_user_id, is_logged_in
|
||||||
from config import Config
|
from config import Config
|
||||||
from routes.admin import admin_bp
|
from db import database_extensions_available, db, init_database
|
||||||
|
from extensions import csrf, limiter, mail
|
||||||
|
from routes.account import account_bp
|
||||||
|
from routes.leagues import leagues_bp
|
||||||
from routes.public import public_bp
|
from routes.public import public_bp
|
||||||
from storage import ensure_data_file
|
from storage import ensure_data_file
|
||||||
from utils import cents_to_dollars, safe_date_label
|
from utils import cents_to_dollars, safe_date_label
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> Flask:
|
def create_app(config_overrides: dict | None = None) -> Flask:
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config.from_object(Config)
|
app.config.from_object(Config)
|
||||||
|
if config_overrides:
|
||||||
|
app.config.update(config_overrides)
|
||||||
|
|
||||||
ensure_data_file(app.config["DATA_PATH"])
|
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["money"] = cents_to_dollars
|
||||||
app.jinja_env.filters["pretty_date"] = safe_date_label
|
app.jinja_env.filters["pretty_date"] = safe_date_label
|
||||||
@@ -24,11 +34,25 @@ def create_app() -> Flask:
|
|||||||
def inject_globals() -> dict:
|
def inject_globals() -> dict:
|
||||||
return {
|
return {
|
||||||
"app_version": app.config["APP_VERSION"],
|
"app_version": app.config["APP_VERSION"],
|
||||||
"is_admin": is_admin(),
|
"current_user_id": current_user_id(),
|
||||||
|
"is_logged_in": is_logged_in(),
|
||||||
}
|
}
|
||||||
|
|
||||||
app.register_blueprint(public_bp)
|
app.register_blueprint(public_bp)
|
||||||
app.register_blueprint(admin_bp)
|
app.register_blueprint(account_bp)
|
||||||
|
app.register_blueprint(leagues_bp)
|
||||||
|
|
||||||
|
@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.")
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,79 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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 __future__ import annotations
|
||||||
|
|
||||||
from flask import session as flask_session
|
from functools import wraps
|
||||||
|
|
||||||
|
from flask import 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 is_admin() -> bool:
|
def _serializer() -> URLSafeTimedSerializer:
|
||||||
return bool(flask_session.get("is_admin"))
|
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.pop("is_admin", 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 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
|
||||||
@@ -6,9 +6,10 @@ from pathlib import Path
|
|||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
DATA_PATH = BASE_DIR / "data" / "entries.csv"
|
DATA_PATH = BASE_DIR / "data" / "entries.csv"
|
||||||
|
DEFAULT_DATABASE_URL = f"sqlite:///{BASE_DIR / 'data' / 'boker-dev.sqlite3'}"
|
||||||
|
|
||||||
ELIGIBLE_MIN_SESSIONS = 3
|
ELIGIBLE_MIN_SESSIONS = 3
|
||||||
APP_VERSION = "1.8.6"
|
APP_VERSION = "2.3.0"
|
||||||
|
|
||||||
|
|
||||||
def load_local_env(env_path: Path) -> None:
|
def load_local_env(env_path: Path) -> None:
|
||||||
@@ -32,9 +33,23 @@ load_local_env(BASE_DIR / ".env")
|
|||||||
class Config:
|
class Config:
|
||||||
APP_VERSION: str = APP_VERSION
|
APP_VERSION: str = APP_VERSION
|
||||||
SECRET_KEY: str = os.getenv("SECRET_KEY", "change-this-before-deploying")
|
SECRET_KEY: str = os.getenv("SECRET_KEY", "change-this-before-deploying")
|
||||||
ADMIN_USERNAME: str = os.getenv("ADMIN_USERNAME", "admin")
|
SQLALCHEMY_DATABASE_URI: str = os.getenv("DATABASE_URL", DEFAULT_DATABASE_URL)
|
||||||
ADMIN_PASSWORD: str = os.getenv("ADMIN_PASSWORD", "change-me")
|
SQLALCHEMY_TRACK_MODIFICATIONS: bool = False
|
||||||
SESSION_COOKIE_HTTPONLY: bool = True
|
SESSION_COOKIE_HTTPONLY: bool = True
|
||||||
SESSION_COOKIE_SAMESITE: str = "Lax"
|
SESSION_COOKIE_SAMESITE: str = "Lax"
|
||||||
SESSION_COOKIE_NAME: str = "poker_portal_session"
|
SESSION_COOKIE_NAME: str = "poker_portal_session"
|
||||||
DATA_PATH: Path = DATA_PATH
|
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_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")
|
||||||
|
|
||||||
|
|
||||||
|
class ProductionConfig(Config):
|
||||||
|
SESSION_COOKIE_SECURE: bool = True
|
||||||
|
SESSION_COOKIE_SAMESITE: str = "Strict"
|
||||||
|
PREFERRED_URL_SCHEME: str = "https"
|
||||||
@@ -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 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
|
||||||
+318
@@ -0,0 +1,318 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
|
from 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)
|
||||||
|
last_login_at = db.Column(db.DateTime(timezone=True), nullable=True)
|
||||||
|
disabled_at = db.Column(db.DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
__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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
from flask_mail import Message
|
||||||
|
|
||||||
|
from extensions import mail
|
||||||
|
|
||||||
|
|
||||||
|
def send_password_reset(to_email: str, reset_url: str) -> None:
|
||||||
|
msg = Message(
|
||||||
|
subject="Reset your myboker.org password",
|
||||||
|
recipients=[to_email],
|
||||||
|
body=(
|
||||||
|
f"You requested a password reset for your myboker.org account.\n\n"
|
||||||
|
f"Click the link below to set a new 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."
|
||||||
|
),
|
||||||
|
sender=current_app.config.get("MAIL_DEFAULT_SENDER"),
|
||||||
|
)
|
||||||
|
mail.send(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def send_league_invite(to_email: str, league_name: str, invite_url: str, invited_by_email: str) -> None:
|
||||||
|
msg = Message(
|
||||||
|
subject=f"You've been invited to manage {league_name} on myboker.org",
|
||||||
|
recipients=[to_email],
|
||||||
|
body=(
|
||||||
|
f"{invited_by_email} has invited you to join {league_name} as a manager 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."
|
||||||
|
),
|
||||||
|
sender=current_app.config.get("MAIL_DEFAULT_SENDER"),
|
||||||
|
)
|
||||||
|
mail.send(msg)
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from auth import hash_password, normalize_email
|
||||||
|
from db import db
|
||||||
|
|
||||||
|
if db is None: # pragma: no cover - imported only when DB dependencies exist.
|
||||||
|
raise RuntimeError("Database dependencies are not installed.")
|
||||||
|
|
||||||
|
from db_models import ( # noqa: E402
|
||||||
|
LedgerEvent,
|
||||||
|
League,
|
||||||
|
LeagueMembership,
|
||||||
|
Player,
|
||||||
|
PokerSession,
|
||||||
|
Season,
|
||||||
|
User,
|
||||||
|
make_player,
|
||||||
|
make_session,
|
||||||
|
make_user,
|
||||||
|
normalize_lookup,
|
||||||
|
)
|
||||||
|
from 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 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:
|
||||||
|
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 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()
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from db import db
|
||||||
|
|
||||||
|
if db is None: # pragma: no cover - imported only when DB dependencies exist.
|
||||||
|
raise RuntimeError("Database dependencies are not installed.")
|
||||||
|
|
||||||
|
from db_models import ( # noqa: E402
|
||||||
|
CANONICAL_EVENT_TYPES,
|
||||||
|
LedgerEvent,
|
||||||
|
Player,
|
||||||
|
PokerSession,
|
||||||
|
User,
|
||||||
|
canonical_event_type,
|
||||||
|
)
|
||||||
|
from 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]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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"}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"""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_public_key"), 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")
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""add league public key
|
||||||
|
|
||||||
|
Revision ID: 0002_league_public_key
|
||||||
|
Revises: 0001_public_foundation
|
||||||
|
Create Date: 2026-06-23
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0002_league_public_key"
|
||||||
|
down_revision = "0001_public_foundation"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
ALPHABET = string.ascii_lowercase + string.digits
|
||||||
|
|
||||||
|
|
||||||
|
def _key(existing: set[str], length: int = 6) -> str:
|
||||||
|
while True:
|
||||||
|
value = "".join(secrets.choice(ALPHABET) for _ in range(length))
|
||||||
|
if value not in existing:
|
||||||
|
existing.add(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("leagues", sa.Column("public_key", sa.String(length=12), nullable=True))
|
||||||
|
|
||||||
|
bind = op.get_bind()
|
||||||
|
rows = bind.execute(sa.text("SELECT id FROM leagues")).mappings().all()
|
||||||
|
existing: set[str] = set()
|
||||||
|
for row in rows:
|
||||||
|
bind.execute(
|
||||||
|
sa.text("UPDATE leagues SET public_key = :public_key WHERE id = :id"),
|
||||||
|
{"public_key": _key(existing), "id": row["id"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
||||||
@@ -1 +1,7 @@
|
|||||||
Flask==3.1.0
|
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
|
||||||
|
psycopg[binary]>=3.2,<4.0
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||||
|
|
||||||
|
from 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 db import database_extensions_available, db
|
||||||
|
from extensions import limiter
|
||||||
|
|
||||||
|
account_bp = Blueprint("account", __name__, url_prefix="/account")
|
||||||
|
|
||||||
|
|
||||||
|
def db_ready() -> bool:
|
||||||
|
return database_extensions_available() and db is not None
|
||||||
|
|
||||||
|
|
||||||
|
@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(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", "").strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
from 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", "")
|
||||||
|
|
||||||
|
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 find_user_by_email(email):
|
||||||
|
flash("An account already exists for that email.", "error")
|
||||||
|
else:
|
||||||
|
user = create_user(email, password)
|
||||||
|
db.session.commit()
|
||||||
|
log_user_in(user.id)
|
||||||
|
flash("Account created.", "success")
|
||||||
|
return redirect(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(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 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")
|
||||||
|
else:
|
||||||
|
log_user_in(user.id)
|
||||||
|
next_url = request.args.get("next") or url_for("leagues.index")
|
||||||
|
flash("Logged in.", "success")
|
||||||
|
return redirect(next_url)
|
||||||
|
|
||||||
|
return render_template("account_login.html", form=form)
|
||||||
|
|
||||||
|
|
||||||
|
@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 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 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 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 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("/delete")
|
||||||
|
@login_required
|
||||||
|
def delete_account():
|
||||||
|
if not db_ready():
|
||||||
|
flash("Account database is not available.", "error")
|
||||||
|
return redirect(url_for("account.settings"))
|
||||||
|
|
||||||
|
from 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 != "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"))
|
||||||
|
|
||||||
|
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 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 emails import send_password_reset
|
||||||
|
from flask import current_app
|
||||||
|
from 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/<token>", 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 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/<token>")
|
||||||
|
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 current_user_id():
|
||||||
|
return redirect(url_for("account.login", next=url_for("account.accept_invite", token=token)))
|
||||||
|
|
||||||
|
if not db_ready():
|
||||||
|
flash("Account database is not available.", "error")
|
||||||
|
return redirect(url_for("leagues.index"))
|
||||||
|
|
||||||
|
from db_models import User
|
||||||
|
from league_repositories import add_league_member, find_league_by_id, find_membership
|
||||||
|
|
||||||
|
user = db.session.get(User, current_user_id())
|
||||||
|
if user is None or user.email != data.get("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))
|
||||||
+2
-1
@@ -51,6 +51,7 @@ def login():
|
|||||||
username == current_app.config["ADMIN_USERNAME"]
|
username == current_app.config["ADMIN_USERNAME"]
|
||||||
and password == current_app.config["ADMIN_PASSWORD"]
|
and password == current_app.config["ADMIN_PASSWORD"]
|
||||||
):
|
):
|
||||||
|
flask_session.pop("user_id", None)
|
||||||
flask_session["is_admin"] = True
|
flask_session["is_admin"] = True
|
||||||
flash("Admin login successful.", "success")
|
flash("Admin login successful.", "success")
|
||||||
return redirect(url_for("admin.dashboard"))
|
return redirect(url_for("admin.dashboard"))
|
||||||
@@ -64,7 +65,7 @@ def login():
|
|||||||
def logout():
|
def logout():
|
||||||
flask_session.clear()
|
flask_session.clear()
|
||||||
flash("Logged out.", "success")
|
flash("Logged out.", "success")
|
||||||
return redirect(url_for("public.leaderboard"))
|
return redirect(url_for("public.home"))
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.post("/admin/session-state")
|
@admin_bp.post("/admin/session-state")
|
||||||
|
|||||||
+1377
File diff suppressed because it is too large.
Load diff
+18
-1
@@ -20,7 +20,24 @@ public_bp = Blueprint("public", __name__)
|
|||||||
|
|
||||||
@public_bp.get("/")
|
@public_bp.get("/")
|
||||||
def home():
|
def home():
|
||||||
return redirect(url_for("public.leaderboard"))
|
return render_template("landing.html")
|
||||||
|
|
||||||
|
|
||||||
|
@public_bp.get("/help")
|
||||||
|
def help():
|
||||||
|
return render_template("docs.html")
|
||||||
|
|
||||||
|
|
||||||
|
@public_bp.get("/explore")
|
||||||
|
def explore():
|
||||||
|
from auth import is_logged_in
|
||||||
|
from db import database_extensions_available
|
||||||
|
from 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")
|
@public_bp.get("/leaderboard")
|
||||||
|
|||||||
+1411
-2
File diff suppressed because it is too large.
Load diff
@@ -30,6 +30,8 @@ class EventRow(TypedDict):
|
|||||||
amount_cents: int
|
amount_cents: int
|
||||||
note: str
|
note: str
|
||||||
actor: str
|
actor: str
|
||||||
|
voided_at: str
|
||||||
|
void_reason: str
|
||||||
|
|
||||||
|
|
||||||
VALID_EVENT_TYPES = {
|
VALID_EVENT_TYPES = {
|
||||||
@@ -79,6 +81,8 @@ def load_events(csv_path: Path) -> list[EventRow]:
|
|||||||
amount_cents=int(row["amount_cents"] or 0),
|
amount_cents=int(row["amount_cents"] or 0),
|
||||||
note=row.get("note", ""),
|
note=row.get("note", ""),
|
||||||
actor=row.get("actor", ""),
|
actor=row.get("actor", ""),
|
||||||
|
voided_at="",
|
||||||
|
void_reason="",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<div class="subnav">
|
||||||
|
<div class="subnav__inner">
|
||||||
|
<a class="subnav__name" href="{{ url_for('leagues.dashboard', league_ref=league.url_ref) }}">{{ league.name }}</a>
|
||||||
|
<span class="subnav__divider"></span>
|
||||||
|
<nav class="subnav__nav">
|
||||||
|
<a class="subnav__link {{ 'is-active' if request.endpoint == 'leagues.dashboard' }}" href="{{ url_for('leagues.dashboard', league_ref=league.url_ref) }}">Overview</a>
|
||||||
|
<a class="subnav__link {{ 'is-active' if request.endpoint in ('leagues.sessions', 'leagues.session_detail') }}" href="{{ url_for('leagues.sessions', league_ref=league.url_ref) }}">Sessions</a>
|
||||||
|
<a class="subnav__link {{ 'is-active' if request.endpoint in ('leagues.players', 'leagues.player_detail') }}" href="{{ url_for('leagues.players', league_ref=league.url_ref) }}">Players</a>
|
||||||
|
<a class="subnav__link {{ 'is-active' if request.endpoint == 'leagues.leaderboard' }}" href="{{ url_for('leagues.leaderboard', league_ref=league.url_ref) }}">Leaderboard</a>
|
||||||
|
{% if is_logged_in %}
|
||||||
|
<a class="subnav__link {{ 'is-active' if request.endpoint == 'leagues.ledger' }}" href="{{ url_for('leagues.ledger', league_ref=league.url_ref) }}">Ledger</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if is_owner is defined and is_owner %}
|
||||||
|
<a class="subnav__link {{ 'is-active' if request.endpoint == 'leagues.league_settings' }}" href="{{ url_for('leagues.league_settings', league_ref=league.url_ref) }}">Settings</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Login · myboker.org{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="form-shell narrow">
|
||||||
|
<form class="panel form-card" method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Owner access</p>
|
||||||
|
<h1 style="font-size:28px;margin-bottom:8px;">Account login</h1>
|
||||||
|
<p class="muted-text" style="margin-bottom:8px;">Manage leagues, players, sessions, and ledger entries.</p>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span>Email</span>
|
||||||
|
<input type="email" name="email" autocomplete="email" value="{{ form.email or '' }}" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Password</span>
|
||||||
|
<input type="password" name="password" autocomplete="current-password" required>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn--primary" type="submit">Login</button>
|
||||||
|
<a class="btn btn--ghost" href="{{ url_for('account.register') }}">Create account</a>
|
||||||
|
<a style="font:400 12px/1 var(--font-ui);color:var(--text-3);text-align:center;margin-top:4px;" href="{{ url_for('account.forgot_password') }}">Forgot password?</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Create Account · myboker.org{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="form-shell narrow">
|
||||||
|
<form class="panel form-card" method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Owner account</p>
|
||||||
|
<h1 style="font-size:28px;margin-bottom:8px;">Create account</h1>
|
||||||
|
<p class="muted-text" style="margin-bottom:8px;">Accounts are for league owners and managers.</p>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span>Email</span>
|
||||||
|
<input type="email" name="email" autocomplete="email" value="{{ form.email or '' }}" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Password</span>
|
||||||
|
<input type="password" name="password" autocomplete="new-password" minlength="8" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Confirm password</span>
|
||||||
|
<input type="password" name="confirm_password" autocomplete="new-password" minlength="8" required>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn--primary" type="submit">Create account</button>
|
||||||
|
<a class="btn btn--ghost" href="{{ url_for('account.login') }}">Login instead</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Account settings · myboker.org{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="hero" style="padding-bottom:18px;">
|
||||||
|
<p class="hero__kicker">Account</p>
|
||||||
|
<h1 class="hero__title">Settings</h1>
|
||||||
|
<p class="hero__sub">Manage your email address, password, and account.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar__left">
|
||||||
|
<a class="btn btn--ghost btn--sm" href="{{ url_for('leagues.index') }}">My leagues</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stack">
|
||||||
|
|
||||||
|
<form class="panel form-card" method="post" action="{{ url_for('account.update_email') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Profile</p>
|
||||||
|
<h2 class="panel__title" style="margin-top:4px;">Email address</h2>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span>Email</span>
|
||||||
|
<input type="email" name="email" value="{{ user.email }}" required autocomplete="email">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Current password <span class="form-opt-hint">(required to change)</span></span>
|
||||||
|
<input type="password" name="current_password" required autocomplete="current-password">
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn--primary" type="submit">Update email</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form class="panel form-card" method="post" action="{{ url_for('account.update_password') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Security</p>
|
||||||
|
<h2 class="panel__title" style="margin-top:4px;">Change password</h2>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span>Current password</span>
|
||||||
|
<input type="password" name="current_password" required autocomplete="current-password">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>New password</span>
|
||||||
|
<input type="password" name="new_password" required autocomplete="new-password">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Confirm new password</span>
|
||||||
|
<input type="password" name="confirm_password" required autocomplete="new-password">
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn--primary" type="submit">Change password</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<p class="kicker" style="margin:0;color:var(--neg);">Danger zone</p>
|
||||||
|
<h2 class="panel__title">Destructive actions</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel__body">
|
||||||
|
<div class="danger-row">
|
||||||
|
<div>
|
||||||
|
<strong class="danger-row__title">Delete account</strong>
|
||||||
|
<p class="muted-text">Permanently disables your account and archives all leagues you own. This cannot be undone.</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-modal="modal-delete-account">Delete account</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-backdrop" id="modal-delete-account" hidden>
|
||||||
|
<div class="modal-card">
|
||||||
|
<div class="modal-card__head">
|
||||||
|
<h2 style="font-size:18px;margin:0;">Delete account</h2>
|
||||||
|
</div>
|
||||||
|
<p class="modal-card__text">
|
||||||
|
Your account will be <strong>permanently disabled</strong> and all leagues you own will be archived. This cannot be undone.<br><br>
|
||||||
|
Type <strong>DELETE</strong> and enter your password to confirm.
|
||||||
|
</p>
|
||||||
|
<form method="post" action="{{ url_for('account.delete_account') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div style="padding:0 20px 16px;display:grid;gap:10px;">
|
||||||
|
<input
|
||||||
|
class="modal-confirm-input"
|
||||||
|
type="text"
|
||||||
|
name="confirm"
|
||||||
|
placeholder='Type "DELETE"'
|
||||||
|
autocomplete="off"
|
||||||
|
data-match="DELETE"
|
||||||
|
data-target="delete-confirm-btn"
|
||||||
|
data-also-requires="delete-password-input"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
class="modal-password-input"
|
||||||
|
id="delete-password-input"
|
||||||
|
type="password"
|
||||||
|
name="current_password"
|
||||||
|
placeholder="Your password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
data-target="delete-confirm-btn"
|
||||||
|
data-confirm-input="modal-delete-account"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="modal-card__actions">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-close-modal>Cancel</button>
|
||||||
|
<button class="btn btn--sm btn--danger" id="delete-confirm-btn" type="submit" disabled>Delete my account</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
function openModal(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) {
|
||||||
|
el.removeAttribute('hidden');
|
||||||
|
const inp = el.querySelector('.modal-confirm-input');
|
||||||
|
if (inp) { inp.value = ''; inp.focus(); }
|
||||||
|
const pwd = el.querySelector('.modal-password-input');
|
||||||
|
if (pwd) pwd.value = '';
|
||||||
|
refreshDeleteBtn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function closeModal(el) {
|
||||||
|
el.closest('.modal-backdrop').setAttribute('hidden', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshDeleteBtn() {
|
||||||
|
const confirmInp = document.querySelector('#modal-delete-account .modal-confirm-input');
|
||||||
|
const pwdInp = document.getElementById('delete-password-input');
|
||||||
|
const btn = document.getElementById('delete-confirm-btn');
|
||||||
|
if (!confirmInp || !pwdInp || !btn) return;
|
||||||
|
btn.disabled = !(confirmInp.value === 'DELETE' && pwdInp.value.length >= 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-modal]').forEach(trigger => {
|
||||||
|
trigger.addEventListener('click', () => openModal(trigger.dataset.modal));
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-close-modal]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => closeModal(btn));
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.modal-backdrop').forEach(backdrop => {
|
||||||
|
backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.setAttribute('hidden', ''); });
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmInp = document.querySelector('#modal-delete-account .modal-confirm-input');
|
||||||
|
const pwdInp = document.getElementById('delete-password-input');
|
||||||
|
if (confirmInp) confirmInp.addEventListener('input', refreshDeleteBtn);
|
||||||
|
if (pwdInp) pwdInp.addEventListener('input', refreshDeleteBtn);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
+55
-10
@@ -10,9 +10,10 @@
|
|||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div class="l-bg" aria-hidden="true"></div>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="topbar__inner">
|
<div class="topbar__inner">
|
||||||
<a class="brand" href="{{ url_for('public.leaderboard') }}">
|
<a class="brand" href="{{ url_for('public.home') }}">
|
||||||
<span class="brand__mark"></span>
|
<span class="brand__mark"></span>
|
||||||
<span class="brand__name">myboker.org</span>
|
<span class="brand__name">myboker.org</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -22,25 +23,35 @@
|
|||||||
<span></span>
|
<span></span>
|
||||||
</button>
|
</button>
|
||||||
<nav class="mainnav" id="mainnav">
|
<nav class="mainnav" id="mainnav">
|
||||||
<a class="navlink {{ 'is-active' if request.endpoint == 'public.leaderboard' }}" href="{{ url_for('public.leaderboard') }}">Leaderboard</a>
|
{% if is_logged_in %}
|
||||||
<a class="navlink {{ 'is-active' if request.endpoint in ('public.sessions', 'public.session_detail') }}" href="{{ url_for('public.sessions') }}">Sessions</a>
|
<a class="navlink {{ 'is-active' if request.endpoint and request.endpoint.startswith('leagues.') }}" href="{{ url_for('leagues.index') }}">Leagues</a>
|
||||||
{% if is_admin %}
|
<a class="navlink {{ 'is-active' if request.endpoint == 'public.explore' }}" href="{{ url_for('public.explore') }}">Explore</a>
|
||||||
<a class="navlink {{ 'is-active' if request.endpoint == 'admin.dashboard' }}" href="{{ url_for('admin.dashboard') }}">Admin</a>
|
<a class="navlink {{ 'is-active' if request.endpoint and request.endpoint.startswith('account.') and request.endpoint not in ('account.login', 'account.register') }}" href="{{ url_for('account.settings') }}">Account</a>
|
||||||
|
<a class="navlink {{ 'is-active' if request.endpoint == 'public.help' }}" href="{{ url_for('public.help') }}">Help</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a class="navlink {{ 'is-active' if request.endpoint == 'admin.login' }}" href="{{ url_for('admin.login') }}">Admin</a>
|
<a class="navlink {{ 'is-active' if request.endpoint == 'public.explore' }}" href="{{ url_for('public.explore') }}">Explore</a>
|
||||||
|
<a class="navlink {{ 'is-active' if request.endpoint == 'public.help' }}" href="{{ url_for('public.help') }}">Help</a>
|
||||||
|
<a class="navlink navlink--mobile-auth {{ 'is-active' if request.endpoint == 'account.login' }}" href="{{ url_for('account.login') }}">Login</a>
|
||||||
|
<a class="navlink navlink--mobile-auth {{ 'is-active' if request.endpoint == 'account.register' }}" href="{{ url_for('account.register') }}">Register</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
<span class="topbar__spacer"></span>
|
<span class="topbar__spacer"></span>
|
||||||
{% if is_admin %}
|
{% if is_logged_in %}
|
||||||
<div class="topbar__admin-actions">
|
<div class="topbar__admin-actions">
|
||||||
<form method="post" action="{{ url_for('admin.logout') }}" style="margin:0">
|
<form method="post" action="{{ url_for('account.logout') }}" style="margin:0">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn--ghost btn--sm" type="submit">Logout</button>
|
<button class="btn btn--ghost btn--sm" type="submit">Logout</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="topbar__admin-actions topbar__account-actions">
|
||||||
|
<a class="btn btn--ghost btn--sm" href="{{ url_for('account.login') }}">Login</a>
|
||||||
|
<a class="btn btn--outline btn--sm" href="{{ url_for('account.register') }}">Register</a>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
{% block subnav %}{% endblock %}
|
||||||
<main class="page {% block page_class %}page--public{% endblock %}">
|
<main class="page {% block page_class %}page--public{% endblock %}">
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
@@ -57,7 +68,7 @@
|
|||||||
<footer class="site-footer" aria-label="Site footer">
|
<footer class="site-footer" aria-label="Site footer">
|
||||||
<span>© 2026 myboker.org</span>
|
<span>© 2026 myboker.org</span>
|
||||||
<span>Version {{ app_version }}</span>
|
<span>Version {{ app_version }}</span>
|
||||||
<span>cedarline.digital</span>
|
<span><a href="{{ url_for('public.help') }}" style="color:inherit;text-decoration:none;">Help</a> · cedarline.digital</span>
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
<script>
|
<script>
|
||||||
@@ -91,5 +102,39 @@
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
function positionBubble(tip) {
|
||||||
|
const bubble = tip.querySelector('.info-tip__bubble');
|
||||||
|
if (!bubble) return;
|
||||||
|
const rect = tip.getBoundingClientRect();
|
||||||
|
const bw = bubble.offsetWidth || 220;
|
||||||
|
const bh = bubble.offsetHeight || 60;
|
||||||
|
let left = rect.left + rect.width / 2 - bw / 2;
|
||||||
|
let top = rect.top - bh - 8;
|
||||||
|
left = Math.max(8, Math.min(left, window.innerWidth - bw - 8));
|
||||||
|
if (top < 8) top = rect.bottom + 8;
|
||||||
|
bubble.style.left = left + 'px';
|
||||||
|
bubble.style.top = top + 'px';
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.info-tip').forEach(tip => {
|
||||||
|
tip.addEventListener('mouseenter', () => { positionBubble(tip); tip.classList.add('is-open'); });
|
||||||
|
tip.addEventListener('mouseleave', () => tip.classList.remove('is-open'));
|
||||||
|
tip.addEventListener('click', e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const wasOpen = tip.classList.contains('is-open');
|
||||||
|
document.querySelectorAll('.info-tip.is-open').forEach(t => t.classList.remove('is-open'));
|
||||||
|
if (!wasOpen) { positionBubble(tip); tip.classList.add('is-open'); }
|
||||||
|
});
|
||||||
|
tip.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); tip.click(); }
|
||||||
|
if (e.key === 'Escape') tip.classList.remove('is-open');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.info-tip.is-open').forEach(t => t.classList.remove('is-open'));
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Help · myboker.org{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="hero" style="padding-bottom:12px;">
|
||||||
|
<p class="hero__kicker">Documentation</p>
|
||||||
|
<h1 class="hero__title">How myboker works</h1>
|
||||||
|
<p class="hero__sub">Everything you need to run a home poker league — sessions, ledgers, leaderboards, and settlement.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="docs-grid">
|
||||||
|
|
||||||
|
<nav class="docs-nav">
|
||||||
|
<p class="kicker">Contents</p>
|
||||||
|
<a href="#leagues">Leagues</a>
|
||||||
|
<a href="#players">Players</a>
|
||||||
|
<a href="#sessions">Sessions</a>
|
||||||
|
<a href="#events">Ledger events</a>
|
||||||
|
<a href="#settlement">Settlement</a>
|
||||||
|
<a href="#leaderboard">Leaderboard & stats</a>
|
||||||
|
<a href="#visibility">Public vs private</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="docs-body">
|
||||||
|
|
||||||
|
<section class="docs-section" id="leagues">
|
||||||
|
<h2 class="docs-h2">Leagues</h2>
|
||||||
|
<p>A <strong>league</strong> 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).</p>
|
||||||
|
<p>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.</p>
|
||||||
|
<div class="docs-callout">
|
||||||
|
<strong>Tip:</strong> Leagues are long-lived. You don't need to create a new league every season — use <em>sessions</em> to separate individual nights of play.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="docs-section" id="players">
|
||||||
|
<h2 class="docs-h2">Players</h2>
|
||||||
|
<p>Players are the people in your league. They <strong>do not need a myboker account</strong> — only the league owner or manager needs one. You just add names.</p>
|
||||||
|
<p>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.</p>
|
||||||
|
<p><strong>Archiving</strong> a player hides them from the active roster but preserves all their history. You can reactivate them at any time.</p>
|
||||||
|
<div class="docs-term-grid">
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Active</strong>
|
||||||
|
<span>Shows on the roster and is available to record events against.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Archived</strong>
|
||||||
|
<span>Hidden from the roster. History and stats are preserved. Reactivate any time.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="docs-section" id="sessions">
|
||||||
|
<h2 class="docs-h2">Sessions</h2>
|
||||||
|
<p>A <strong>session</strong> 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.</p>
|
||||||
|
<p>Sessions have two states: <strong>open</strong> and <strong>closed</strong>. 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.</p>
|
||||||
|
<div class="docs-term-grid">
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Date</strong>
|
||||||
|
<span>The calendar date the game was played. Used to order sessions and group same-day games.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Label</strong>
|
||||||
|
<span>Optional short name for this session (e.g. "Main table", "Side game"). Shown in lists.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Notes</strong>
|
||||||
|
<span>Optional freeform context visible in the session list (e.g. "Holiday game", "Marcus's place").</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Open</strong>
|
||||||
|
<span>Session is live — events can be added and settlement is still in progress.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Closed</strong>
|
||||||
|
<span>Session is settled. Books are considered final. Can be reopened if needed.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="docs-section" id="events">
|
||||||
|
<h2 class="docs-h2">Ledger events</h2>
|
||||||
|
<p>All activity in a session is recorded as <strong>ledger events</strong>. The ledger is <strong>append-only</strong> — 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).</p>
|
||||||
|
<p>There are eight event types:</p>
|
||||||
|
<div class="docs-events">
|
||||||
|
<div class="docs-event">
|
||||||
|
<span class="pill pill-buyin">buy-in</span>
|
||||||
|
<div>
|
||||||
|
<strong>A player buys chips with cash.</strong>
|
||||||
|
<p>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.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docs-event">
|
||||||
|
<span class="pill pill-front">front</span>
|
||||||
|
<div>
|
||||||
|
<strong>The house covers a player's buy-in.</strong>
|
||||||
|
<p>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.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docs-event">
|
||||||
|
<span class="pill pill-cashout">cashout</span>
|
||||||
|
<div>
|
||||||
|
<strong>A player exchanges their chips for their cash value.</strong>
|
||||||
|
<p>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 <em>paid out</em> to record the actual cash handover.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docs-event">
|
||||||
|
<span class="pill pill-paid_out">paid out</span>
|
||||||
|
<div>
|
||||||
|
<strong>Cash physically leaves the house to the player.</strong>
|
||||||
|
<p>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.).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docs-event">
|
||||||
|
<span class="pill pill-rollover_in">rollover in</span>
|
||||||
|
<div>
|
||||||
|
<strong>Chips carried in from a previous session.</strong>
|
||||||
|
<p>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.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docs-event">
|
||||||
|
<span class="pill pill-rollover_out">rollover out</span>
|
||||||
|
<div>
|
||||||
|
<strong>Chips carried out to a future session.</strong>
|
||||||
|
<p>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.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docs-event">
|
||||||
|
<span class="pill pill-debt_repayment">debt repayment</span>
|
||||||
|
<div>
|
||||||
|
<strong>A player repays an outstanding front.</strong>
|
||||||
|
<p>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.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docs-event">
|
||||||
|
<span class="pill pill-writeoff">write-off</span>
|
||||||
|
<div>
|
||||||
|
<strong>The house forgives a debt.</strong>
|
||||||
|
<p>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.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="docs-section" id="settlement">
|
||||||
|
<h2 class="docs-h2">Settlement</h2>
|
||||||
|
<p>The ledger tracks two types of open balances at all times:</p>
|
||||||
|
<div class="docs-term-grid">
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Due to players</strong>
|
||||||
|
<span>Cash the house owes to players who have cashed out but haven't been paid yet. Pay these out to clear the balance.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Due to house</strong>
|
||||||
|
<span>Cash players owe to the house from unpaid fronts. Collect debt repayments or write these off to clear the balance.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Cash in</strong>
|
||||||
|
<span>Total real cash that has come into the pot across all sessions — buy-ins and repaid fronts only, not fronts themselves.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Cash paid out</strong>
|
||||||
|
<span>Total real cash that has left the house to players. Ideally this approaches cash in over time.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docs-callout">
|
||||||
|
<strong>Fronts vs buy-ins:</strong> 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.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="docs-section" id="leaderboard">
|
||||||
|
<h2 class="docs-h2">Leaderboard & stats</h2>
|
||||||
|
<p>The leaderboard ranks players by their all-time performance. Stats are computed from ledger events, not manually entered.</p>
|
||||||
|
<div class="docs-term-grid">
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Net</strong>
|
||||||
|
<span>Total cash out minus total cash in across all sessions. Positive means up, negative means down.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>ROI</strong>
|
||||||
|
<span>Net divided by total invested, expressed as a percentage. Compares performance independent of how much someone plays.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Win rate</strong>
|
||||||
|
<span>Percentage of sessions where the player finished with a positive net. Ties (exactly break even) count as a loss.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Sessions played</strong>
|
||||||
|
<span>Number of sessions with at least one ledger event for this player.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Biggest win</strong>
|
||||||
|
<span>The single session where the player netted the most profit.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Biggest loss</strong>
|
||||||
|
<span>The single session where the player lost the most money.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="docs-section" id="visibility">
|
||||||
|
<h2 class="docs-h2">Public vs private</h2>
|
||||||
|
<p>Each league has a <strong>visibility</strong> setting you can change in league settings.</p>
|
||||||
|
<div class="docs-term-grid">
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Private</strong>
|
||||||
|
<span>Only members (owners and managers) can see this league. The league won't appear on the Explore page. This is the default.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Public</strong>
|
||||||
|
<span>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.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docs-callout">
|
||||||
|
<strong>Note:</strong> Making a league public shares leaderboard rankings and session results, but not private financial details like individual debt balances or account emails.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Explore leagues · myboker.org{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="hero" style="padding-bottom:8px;">
|
||||||
|
<p class="hero__kicker">Public leagues</p>
|
||||||
|
<h1 class="hero__title">Explore</h1>
|
||||||
|
<p class="hero__sub">Browse poker leagues that have made their results publicly visible.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="get" action="{{ url_for('public.explore') }}" class="explore-search">
|
||||||
|
<input class="field-input explore-search__input" type="text" name="q" value="{{ q }}" placeholder="Search by league name…">
|
||||||
|
<button class="btn btn--primary" type="submit">Search</button>
|
||||||
|
{% if q %}<a class="btn btn--ghost" href="{{ url_for('public.explore') }}">Clear</a>{% endif %}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if leagues %}
|
||||||
|
<div class="explore-grid">
|
||||||
|
{% for league in leagues %}
|
||||||
|
{% set c = counts.get(league.id, {}) %}
|
||||||
|
<a class="explore-card" href="{{ url_for('leagues.dashboard', league_ref=league.url_ref) }}">
|
||||||
|
<div class="explore-card__head">
|
||||||
|
<h2 class="explore-card__name">{{ league.name }}</h2>
|
||||||
|
{% if league.description %}
|
||||||
|
<p class="explore-card__desc">{{ league.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="explore-card__stats">
|
||||||
|
<div class="explore-card__stat">
|
||||||
|
<span class="kicker">Players</span>
|
||||||
|
<strong>{{ c.get('players', 0) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="explore-card__stat">
|
||||||
|
<span class="kicker">Sessions</span>
|
||||||
|
<strong>{{ c.get('sessions', 0) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="explore-card__stat">
|
||||||
|
<span class="kicker">Open</span>
|
||||||
|
<strong>{{ c.get('open_sessions', 0) }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="explore-card__footer">
|
||||||
|
<span class="explore-card__link">View league →</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% elif q %}
|
||||||
|
<div class="empty-panel" style="max-width:480px;">
|
||||||
|
<h3 class="panel__title">No results for "{{ q }}"</h3>
|
||||||
|
<p class="muted-text">Try a shorter search or browse all leagues.</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-panel" style="max-width:480px;">
|
||||||
|
<h3 class="panel__title">No public leagues yet</h3>
|
||||||
|
<p class="muted-text">League owners can set their league to public in settings.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Forgot Password · myboker.org{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="form-shell narrow">
|
||||||
|
<form class="panel form-card" method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Account recovery</p>
|
||||||
|
<h1 style="font-size:28px;margin-bottom:8px;">Forgot password</h1>
|
||||||
|
<p class="muted-text" style="margin-bottom:8px;">Enter your email and we'll send a reset link if an account exists.</p>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span>Email</span>
|
||||||
|
<input type="email" name="email" autocomplete="email" required>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn--primary" type="submit">Send reset link</button>
|
||||||
|
<a class="btn btn--ghost" href="{{ url_for('account.login') }}">Back to login</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}myboker.org · Poker league records{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<section class="l-hero">
|
||||||
|
<p class="l-hero__kicker">For private poker leagues</p>
|
||||||
|
<h1 class="l-hero__h1">Your home game,<br>properly tracked.</h1>
|
||||||
|
<p class="l-hero__sub">Sessions, ledgers, leaderboards, and settlement visibility — built for home groups that want clean records without a payment app or social network in the way.</p>
|
||||||
|
<div class="l-hero__cta">
|
||||||
|
{% if is_logged_in %}
|
||||||
|
<a class="btn btn--primary" href="{{ url_for('leagues.index') }}">Open leagues</a>
|
||||||
|
{% else %}
|
||||||
|
<a class="btn btn--primary" href="{{ url_for('account.register') }}">Create account</a>
|
||||||
|
<a class="btn btn--ghost" href="{{ url_for('account.login') }}">Sign in</a>
|
||||||
|
<a class="btn btn--ghost" href="{{ url_for('public.explore') }}">Explore league stats</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="l-preview" aria-hidden="true">
|
||||||
|
<div class="l-preview__panel">
|
||||||
|
<div class="l-preview__head">
|
||||||
|
<span class="kicker" style="margin:0;">Leaderboard</span>
|
||||||
|
<span class="l-preview__tag">All-time</span>
|
||||||
|
</div>
|
||||||
|
<div class="l-preview__rows">
|
||||||
|
<div class="l-preview__row">
|
||||||
|
<span class="l-preview__rank">1</span>
|
||||||
|
<span class="l-preview__player">Rivera</span>
|
||||||
|
<span class="l-preview__val l-preview__val--pos">+$1,420</span>
|
||||||
|
</div>
|
||||||
|
<div class="l-preview__row">
|
||||||
|
<span class="l-preview__rank">2</span>
|
||||||
|
<span class="l-preview__player">Chen</span>
|
||||||
|
<span class="l-preview__val l-preview__val--pos">+$640</span>
|
||||||
|
</div>
|
||||||
|
<div class="l-preview__row">
|
||||||
|
<span class="l-preview__rank">3</span>
|
||||||
|
<span class="l-preview__player">Okafor</span>
|
||||||
|
<span class="l-preview__val l-preview__val--neg">-$210</span>
|
||||||
|
</div>
|
||||||
|
<div class="l-preview__row l-preview__row--faded">
|
||||||
|
<span class="l-preview__rank">4</span>
|
||||||
|
<span class="l-preview__player">Walsh</span>
|
||||||
|
<span class="l-preview__val l-preview__val--neg">-$850</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="l-preview__panel l-preview__panel--featured">
|
||||||
|
<div class="l-preview__head">
|
||||||
|
<span class="kicker" style="margin:0;">Session #18</span>
|
||||||
|
<span class="live-badge">live</span>
|
||||||
|
</div>
|
||||||
|
<div class="l-preview__stats">
|
||||||
|
<div class="l-preview__stat">
|
||||||
|
<span class="kicker" style="margin:0;">Cash in</span>
|
||||||
|
<strong>$1,200</strong>
|
||||||
|
</div>
|
||||||
|
<div class="l-preview__stat">
|
||||||
|
<span class="kicker" style="margin:0;">Players</span>
|
||||||
|
<strong>8</strong>
|
||||||
|
</div>
|
||||||
|
<div class="l-preview__stat">
|
||||||
|
<span class="kicker" style="margin:0;">Open debts</span>
|
||||||
|
<strong style="color:var(--warn);">$180</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="l-preview__evts">
|
||||||
|
<div class="l-preview__evt">
|
||||||
|
<span class="pill pill-buyin">buy-in</span>
|
||||||
|
<span class="l-preview__evt-name">Rivera</span>
|
||||||
|
<span class="l-preview__evt-val">$200</span>
|
||||||
|
</div>
|
||||||
|
<div class="l-preview__evt">
|
||||||
|
<span class="pill pill-front">front</span>
|
||||||
|
<span class="l-preview__evt-name">Chen</span>
|
||||||
|
<span class="l-preview__evt-val">$200</span>
|
||||||
|
</div>
|
||||||
|
<div class="l-preview__evt">
|
||||||
|
<span class="pill pill-cashout">cashout</span>
|
||||||
|
<span class="l-preview__evt-name">Okafor</span>
|
||||||
|
<span class="l-preview__evt-val">$450</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="l-preview__panel">
|
||||||
|
<div class="l-preview__head">
|
||||||
|
<span class="kicker" style="margin:0;">Open debts</span>
|
||||||
|
<span class="l-preview__tag">Session #17</span>
|
||||||
|
</div>
|
||||||
|
<div class="l-preview__rows">
|
||||||
|
<div class="l-preview__row">
|
||||||
|
<span class="l-preview__player">Walsh</span>
|
||||||
|
<span class="l-preview__val" style="color:var(--owes);">owes $120</span>
|
||||||
|
</div>
|
||||||
|
<div class="l-preview__row">
|
||||||
|
<span class="l-preview__player">Okafor</span>
|
||||||
|
<span class="l-preview__val" style="color:var(--owes);">owes $60</span>
|
||||||
|
</div>
|
||||||
|
<div class="l-preview__row l-preview__row--faded">
|
||||||
|
<span class="l-preview__player">Chen</span>
|
||||||
|
<span class="l-preview__val" style="color:var(--pos);">settled</span>
|
||||||
|
</div>
|
||||||
|
<div class="l-preview__row l-preview__row--faded">
|
||||||
|
<span class="l-preview__player">Rivera</span>
|
||||||
|
<span class="l-preview__val" style="color:var(--pos);">settled</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="l-features">
|
||||||
|
<div class="l-feature">
|
||||||
|
<div class="l-feature__icon">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" aria-hidden="true">
|
||||||
|
<rect x="1" y="7" width="14" height="13" rx="2" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
<path d="M5 7V5a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2h-2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="l-feature__title">League workspaces</h3>
|
||||||
|
<p class="l-feature__desc">Each league has its own players, sessions, leaderboard, and accounting. One account can manage multiple leagues independently.</p>
|
||||||
|
</div>
|
||||||
|
<div class="l-feature">
|
||||||
|
<div class="l-feature__icon">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" aria-hidden="true">
|
||||||
|
<rect x="3" y="3" width="16" height="16" rx="2" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
<path d="M7 8h8M7 11h6M7 14h4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="l-feature__title">Append-only ledger</h3>
|
||||||
|
<p class="l-feature__desc">Buy-ins, fronts, cashouts, rollovers, debt repayments, and write-offs stay permanently on record. Events are never deleted.</p>
|
||||||
|
</div>
|
||||||
|
<div class="l-feature">
|
||||||
|
<div class="l-feature__icon">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" aria-hidden="true">
|
||||||
|
<path d="M3 17L8 11L13 14L20 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M3 20h18" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="l-feature__title">Performance analytics</h3>
|
||||||
|
<p class="l-feature__desc">Net, ROI, win rate, and all-time cash movement are computed from ledger events. Rankings stay separate from settlement records.</p>
|
||||||
|
</div>
|
||||||
|
<div class="l-feature">
|
||||||
|
<div class="l-feature__icon">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" aria-hidden="true">
|
||||||
|
<path d="M7 5h8M15 5l-3-3m3 3l-3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M15 17H7m0 0l3 3m-3-3l3-3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="l-feature__title">Settlement visibility</h3>
|
||||||
|
<p class="l-feature__desc">Open debts, player payables, and house receivables are always tracked. Share settlement status with the group, not just final numbers.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ league.name }} · myboker.org{% endblock %}
|
||||||
|
{% block subnav %}{% include "_league_subnav.html" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
{% if is_logged_in %}
|
||||||
|
<a class="league-back-link" href="{{ url_for('leagues.index') }}">← Leagues</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="db-hero">
|
||||||
|
<div class="db-hero__bg-letter" aria-hidden="true">{{ league.name[0] | upper }}</div>
|
||||||
|
<div class="db-hero__body">
|
||||||
|
<div class="db-hero__chips">
|
||||||
|
{% if is_owner %}
|
||||||
|
<span class="db-chip db-chip--accent">Owner</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="db-chip">{{ league.visibility }}</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="hero__title" style="margin-bottom:8px;">{{ league.name }}</h1>
|
||||||
|
<p class="hero__sub" style="margin:0;">{{ league.description or 'No description set.' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if counts.open_sessions > 0 %}
|
||||||
|
<div class="db-live-bar">
|
||||||
|
<span class="live-badge">{{ counts.open_sessions }} live</span>
|
||||||
|
<span class="db-live-bar__text">
|
||||||
|
{% if counts.open_sessions == 1 %}A session is currently in progress.{% else %}{{ counts.open_sessions }} sessions are in progress.{% endif %}
|
||||||
|
</span>
|
||||||
|
<a class="btn btn--sm db-live-bar__btn" href="{{ url_for('leagues.sessions', league_ref=league.url_ref) }}">View sessions →</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="db-stats">
|
||||||
|
<div class="stat stat--rail" style="--rail:var(--pos);">
|
||||||
|
<span class="kicker">Active players</span>
|
||||||
|
<div class="stat__val" style="color:var(--text-strong);">{{ counts.players }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat stat--rail" style="--rail:var(--accent);">
|
||||||
|
<span class="kicker">Sessions</span>
|
||||||
|
<div class="stat__val" style="color:var(--text-strong);">{{ counts.sessions }}</div>
|
||||||
|
{% if counts.open_sessions > 0 %}
|
||||||
|
<div class="stat__sub" style="color:var(--pos);">{{ counts.open_sessions }} open now</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="stat stat--rail" style="--rail:var(--warn);">
|
||||||
|
<span class="kicker">Ledger events</span>
|
||||||
|
<div class="stat__val" style="color:var(--text-strong);">{{ counts.events }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat stat--rail" style="--rail:var(--rolled);">
|
||||||
|
<span class="kicker">Seasons</span>
|
||||||
|
<div class="stat__val" style="color:var(--text-strong);">{{ counts.seasons }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-grid">
|
||||||
|
|
||||||
|
<a class="dashboard-card panel" style="--card-accent:var(--warn);" href="{{ url_for('leagues.leaderboard', league_ref=league.url_ref) }}">
|
||||||
|
<div class="dashboard-card__top">
|
||||||
|
<span class="kicker">Stats</span>
|
||||||
|
<span class="dashboard-card__count">{{ counts.players }} ranked</span>
|
||||||
|
</div>
|
||||||
|
<strong class="dashboard-card__title">Leaderboard</strong>
|
||||||
|
<p class="dashboard-card__desc">All-time results, ROI, settlement totals, and standings.</p>
|
||||||
|
<span class="dashboard-card__arrow">→</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="dashboard-card panel" style="--card-accent:var(--pos);" href="{{ url_for('leagues.players', league_ref=league.url_ref) }}">
|
||||||
|
<div class="dashboard-card__top">
|
||||||
|
<span class="kicker">Roster</span>
|
||||||
|
<span class="dashboard-card__count">{{ counts.players }} active</span>
|
||||||
|
</div>
|
||||||
|
<strong class="dashboard-card__title">Players</strong>
|
||||||
|
<p class="dashboard-card__desc">Add and manage player records for stats and ledger entry.</p>
|
||||||
|
<span class="dashboard-card__arrow">→</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="dashboard-card panel {% if counts.open_sessions > 0 %}dashboard-card--live{% endif %}" style="--card-accent:var(--accent);" href="{{ url_for('leagues.sessions', league_ref=league.url_ref) }}">
|
||||||
|
<div class="dashboard-card__top">
|
||||||
|
<span class="kicker">Schedule</span>
|
||||||
|
{% if counts.open_sessions > 0 %}
|
||||||
|
<span class="live-badge">{{ counts.open_sessions }} live</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="dashboard-card__count">{{ counts.sessions }} total</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<strong class="dashboard-card__title">Sessions</strong>
|
||||||
|
<p class="dashboard-card__desc">Create sessions, record events, and manage open/closed state.</p>
|
||||||
|
<span class="dashboard-card__arrow">→</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="dashboard-card panel" style="--card-accent:var(--rolled);" href="{{ url_for('leagues.ledger', league_ref=league.url_ref) }}">
|
||||||
|
<div class="dashboard-card__top">
|
||||||
|
<span class="kicker">Accounting</span>
|
||||||
|
<span class="dashboard-card__count">{{ counts.events }} events</span>
|
||||||
|
</div>
|
||||||
|
<strong class="dashboard-card__title">League ledger</strong>
|
||||||
|
<p class="dashboard-card__desc">Review all events, open debts, cash movement, and settlements.</p>
|
||||||
|
<span class="dashboard-card__arrow">→</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Leaderboard · {{ league.name }} · myboker.org{% endblock %}
|
||||||
|
{% block subnav %}{% include "_league_subnav.html" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="hero" style="display:flex;flex-wrap:wrap;gap:var(--s6);align-items:flex-start;justify-content:space-between;">
|
||||||
|
<div>
|
||||||
|
<h1 class="hero__title">Leaderboard</h1>
|
||||||
|
<p class="hero__sub">{{ session_count }} session{{ 's' if session_count != 1 else '' }}{% if selected_session_date %} through {{ selected_session_label }}{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
<div class="leaderboard-hero-stats">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="kicker">Cash paid out</span>
|
||||||
|
<div class="stat__val" style="color:var(--accent)">{{ cash_paid_out_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="kicker">Sessions</span>
|
||||||
|
<div class="stat__val" style="color:var(--text-strong)">{{ session_count }}{% if total_session_count != session_count %}<span style="color:var(--faintest);font-size:16px"> / {{ total_session_count }}</span>{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="kicker">Players</span>
|
||||||
|
<div class="stat__val" style="color:var(--text-strong)">{{ all_count }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel stack" style="gap:0;margin-bottom:20px;">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<span class="kicker">Trend</span>
|
||||||
|
<h2 class="panel__title">Cumulative profit</h2>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" id="expandLeaderboardChart">Expand</button>
|
||||||
|
</div>
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<div class="chart-frame chart-frame--tall">
|
||||||
|
<canvas id="leaderboardChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar__left">
|
||||||
|
<div class="segmented">
|
||||||
|
<button class="seg {{ 'is-on' if mode=='eligible' }}" data-mode="eligible">
|
||||||
|
Eligible <span class="seg__count">{{ eligible_count }}</span>
|
||||||
|
</button>
|
||||||
|
<button class="seg {{ 'is-on' if mode=='all' }}" data-mode="all">
|
||||||
|
All players <span class="seg__count">{{ all_count }}</span>
|
||||||
|
</button>
|
||||||
|
<button class="seg {{ 'is-on' if mode=='recent' }}" data-mode="recent">
|
||||||
|
Recent <span class="seg__count">5 sess</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="toolbar__hint">
|
||||||
|
{% if mode == 'eligible' %}
|
||||||
|
{{ eligible_min_sessions }}+ sessions required. Keeps one-off guests from skewing standings.
|
||||||
|
{% elif mode == 'all' %}
|
||||||
|
Everyone who's sat down — rankings can swing on tiny samples.
|
||||||
|
{% else %}
|
||||||
|
Form guide — aggregated over the last 5 sessions only.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar__right">
|
||||||
|
<form class="session-filter-form" method="get">
|
||||||
|
<input type="hidden" name="mode" value="{{ mode }}">
|
||||||
|
<select class="session-filter-select" name="through_session" onchange="this.form.submit()">
|
||||||
|
<option value="">Latest session</option>
|
||||||
|
{% for s in available_sessions %}
|
||||||
|
<option value="{{ s.session_id }}" {{ 'selected' if selected_session_id == s.session_id else '' }}>{{ session_label(s) }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% if selected_session_id %}
|
||||||
|
<a class="btn btn--ghost btn--sm" href="{{ url_for('leagues.leaderboard', league_ref=league.url_ref, mode=mode) }}">Reset</a>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel tbl-scroll" style="margin-bottom:20px;">
|
||||||
|
<table class="tbl" id="leaderboardTable" data-sort-key="net" data-sort-direction="desc">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-rank" style="width:52px;">
|
||||||
|
<span style="font-family:var(--font-mono);font-size:10.5px;letter-spacing:.10em;text-transform:uppercase;color:var(--muted);">#</span>
|
||||||
|
</th>
|
||||||
|
<th class="col-player">
|
||||||
|
<button type="button" class="sort-btn" data-sort-key="player" data-sort-type="string" data-initial-direction="asc">Player <span class="sort-arrow">↕</span></button>
|
||||||
|
</th>
|
||||||
|
<th class="col-rank-delta"><button type="button" class="sort-btn" data-sort-key="rankdelta" data-sort-type="number" data-initial-direction="desc">Δ Rank <span class="sort-arrow">↕</span></button></th>
|
||||||
|
<th><button type="button" class="sort-btn" data-sort-key="net" data-sort-type="number" data-initial-direction="desc">Net <span class="sort-arrow">↓</span></button></th>
|
||||||
|
<th><button type="button" class="sort-btn" data-sort-key="winpct" data-sort-type="number" data-initial-direction="desc">Win% <span class="sort-arrow">↕</span></button></th>
|
||||||
|
<th><button type="button" class="sort-btn" data-sort-key="roi" data-sort-type="number" data-initial-direction="desc">ROI <span class="sort-arrow">↕</span></button></th>
|
||||||
|
<th><button type="button" class="sort-btn" data-sort-key="avgwin" data-sort-type="number" data-initial-direction="desc">Avg Win <span class="sort-arrow">↕</span></button></th>
|
||||||
|
<th><button type="button" class="sort-btn" data-sort-key="avgloss" data-sort-type="number" data-initial-direction="desc">Avg Loss <span class="sort-arrow">↕</span></button></th>
|
||||||
|
<th><button type="button" class="sort-btn" data-sort-key="sessions" data-sort-type="number" data-initial-direction="desc">Sessions <span class="sort-arrow">↕</span></button></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% 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 '' %}
|
||||||
|
<tr data-player-row="true" data-original-rank="{{ idx }}"
|
||||||
|
{% if player_href %}onclick="window.location='{{ player_href }}'"{% endif %}>
|
||||||
|
<td class="col-rank" data-sort-value="{{ idx }}">
|
||||||
|
<span class="rank-num {{ 'rank-1' if idx==1 else 'rank-2' if idx==2 else 'rank-3' if idx==3 else '' }}" data-rank-cell>#{{ idx }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="col-player" data-sort-value="{{ player.player_name|lower }}">
|
||||||
|
{% if player_href %}<a href="{{ player_href }}">{{ player.player_name }}</a>{% else %}{{ player.player_name }}{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="col-rank-delta" data-sort-value="{{ player.rank_change }}">
|
||||||
|
{% if player.rank_change > 0 %}
|
||||||
|
<span class="rank-delta rank-delta--up">▲{{ player.rank_change }}</span>
|
||||||
|
{% elif player.rank_change < 0 %}
|
||||||
|
<span class="rank-delta rank-delta--down">▼{{ player.rank_change | abs }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="rank-delta rank-delta--flat">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="num-net {{ 'num-pos' if player.total_net_cents > 0 else 'num-neg' if player.total_net_cents < 0 else 'num-muted' }}"
|
||||||
|
data-sort-value="{{ player.total_net_cents }}">
|
||||||
|
{{ '+' if player.total_net_cents > 0 else '' }}{{ player.total_net_cents | money }}
|
||||||
|
</td>
|
||||||
|
<td data-sort-value="{{ '%.4f'|format(player.win_pct) }}">{{ "%.1f"|format(player.win_pct) }}%</td>
|
||||||
|
<td class="{{ 'num-pos' if player.roi_pct > 0 else 'num-neg' if player.roi_pct < 0 else '' }}"
|
||||||
|
data-sort-value="{{ '%.4f'|format(player.roi_pct) }}">{{ "%.1f"|format(player.roi_pct) }}%</td>
|
||||||
|
<td class="num-pos" data-sort-value="{{ player.avg_win_cents }}">{{ player.avg_win_cents | money }}</td>
|
||||||
|
<td class="num-neg" data-sort-value="{{ player.avg_loss_cents }}">{{ player.avg_loss_cents | money }}</td>
|
||||||
|
<td data-sort-value="{{ player.sessions_played }}">{{ player.sessions_played }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" style="text-align:left;padding:28px;color:var(--muted);">
|
||||||
|
No players yet — add the first event from a league session ledger.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if mode == 'eligible' and provisional_board %}
|
||||||
|
<div style="margin-bottom:20px;">
|
||||||
|
<div class="toolbar" style="margin-top:0;margin-bottom:12px;">
|
||||||
|
<span class="kicker" style="margin:0;">Provisional · fewer than {{ eligible_min_sessions }} sessions</span>
|
||||||
|
</div>
|
||||||
|
<div class="provisional-grid">
|
||||||
|
{% 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 '' %}
|
||||||
|
<a class="provisional-card" href="{{ player_href or '#' }}"
|
||||||
|
style="display:block;text-decoration:none;">
|
||||||
|
<p class="provisional-card__name">{{ player.player_name }}</p>
|
||||||
|
<div class="provisional-card__meta">
|
||||||
|
<span>{{ player.sessions_played }} sess</span>
|
||||||
|
<span class="{{ 'num-pos' if player.total_net_cents > 0 else 'num-neg' if player.total_net_cents < 0 else '' }}"
|
||||||
|
style="font-family:var(--font-mono);font-size:11px;font-weight:600;">{{ player.total_net_cents | money }}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Expand modal -->
|
||||||
|
<div id="chartModal" style="display:none;position:fixed;inset:0;z-index:80;align-items:center;justify-content:center;">
|
||||||
|
<div style="position:absolute;inset:0;background:rgba(8,10,12,.82);backdrop-filter:blur(6px);" data-close-modal></div>
|
||||||
|
<div style="position:relative;z-index:1;width:min(1280px,calc(100vw - 48px));margin:24px auto;padding:22px;background:var(--surface);border:1px solid var(--border-strong);border-radius:var(--r-lg);box-shadow:0 24px 64px rgba(0,0,0,.4);">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:18px;">
|
||||||
|
<h2 style="margin:0;">Cumulative profit</h2>
|
||||||
|
<button class="btn btn--ghost btn--sm" data-close-modal>Close</button>
|
||||||
|
</div>
|
||||||
|
<div style="height:min(70vh,780px);position:relative;">
|
||||||
|
<canvas id="leaderboardChartExpanded"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const CHART_PALETTE = ['#9b8cf0','#6fc093','#e0b15c','#cf6f86','#8f93c2','#7cb9e0','#f4a261','#a78bb0','#5cb8a0','#e58f3a'];
|
||||||
|
const leaderboardChartData = {{ chart_data | tojson }};
|
||||||
|
const rootStyles = getComputedStyle(document.documentElement);
|
||||||
|
const textMuted = rootStyles.getPropertyValue('--text-muted').trim();
|
||||||
|
const lineColor = rootStyles.getPropertyValue('--line').trim();
|
||||||
|
|
||||||
|
leaderboardChartData.datasets.forEach((ds, i) => {
|
||||||
|
ds.borderColor = CHART_PALETTE[i % CHART_PALETTE.length];
|
||||||
|
ds.backgroundColor = CHART_PALETTE[i % CHART_PALETTE.length];
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildChartConfig(data, compact) {
|
||||||
|
return {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: data.labels,
|
||||||
|
datasets: data.datasets.map(ds => ({
|
||||||
|
...ds,
|
||||||
|
pointRadius: compact ? 0 : 2,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
borderWidth: compact ? 2 : 2.5,
|
||||||
|
tension: 0.28,
|
||||||
|
fill: false,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: { mode: 'nearest', intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: true,
|
||||||
|
position: 'bottom',
|
||||||
|
labels: { color: textMuted, usePointStyle: true, boxWidth: 8, boxHeight: 8, padding: 14 },
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label(ctx) {
|
||||||
|
const v = ctx.parsed.y;
|
||||||
|
return v == null ? `${ctx.dataset.label}: —` : `${ctx.dataset.label}: $${Number(v).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: { color: textMuted, maxRotation: 0, autoSkip: true, maxTicksLimit: compact ? 6 : 10 },
|
||||||
|
grid: { color: lineColor, display: !compact },
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
ticks: {
|
||||||
|
color: textMuted,
|
||||||
|
maxTicksLimit: compact ? 8 : 10,
|
||||||
|
callback(v) { return (v < 0 ? '-' : '') + '$' + Math.abs(Number(v)).toFixed(0); }
|
||||||
|
},
|
||||||
|
grid: { color: lineColor },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = document.getElementById('leaderboardChart');
|
||||||
|
let mainChart = null;
|
||||||
|
if (ctx && leaderboardChartData.labels.length > 0) {
|
||||||
|
const filtered = leaderboardChartData.datasets.filter(ds => (ds.sessions_played || 0) > 1);
|
||||||
|
const chartData = {
|
||||||
|
labels: leaderboardChartData.labels,
|
||||||
|
datasets: filtered.length ? filtered : leaderboardChartData.datasets,
|
||||||
|
};
|
||||||
|
mainChart = new Chart(ctx, buildChartConfig(chartData, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = document.getElementById('chartModal');
|
||||||
|
const expandBtn = document.getElementById('expandLeaderboardChart');
|
||||||
|
let expandedChart = null;
|
||||||
|
|
||||||
|
expandBtn?.addEventListener('click', () => {
|
||||||
|
if (!leaderboardChartData.labels.length) return;
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
if (expandedChart) expandedChart.destroy();
|
||||||
|
expandedChart = new Chart(
|
||||||
|
document.getElementById('leaderboardChartExpanded'),
|
||||||
|
buildChartConfig(leaderboardChartData, false)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-close-modal]').forEach(el => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
if (expandedChart) { expandedChart.destroy(); expandedChart = null; }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape' && modal.style.display === 'flex') {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
if (expandedChart) { expandedChart.destroy(); expandedChart = null; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Segmented mode switcher
|
||||||
|
document.querySelectorAll('[data-mode]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('mode', btn.dataset.mode);
|
||||||
|
window.location.href = url.toString();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Table sort
|
||||||
|
const tbl = document.getElementById('leaderboardTable');
|
||||||
|
const sortBtns = tbl ? Array.from(tbl.querySelectorAll('.sort-btn[data-sort-key]')) : [];
|
||||||
|
const colMap = { rank: 0, player: 1, rankdelta: 2, net: 3, winpct: 4, roi: 5, avgwin: 6, avgloss: 7, sessions: 8 };
|
||||||
|
|
||||||
|
function setActiveSort(key, dir) {
|
||||||
|
sortBtns.forEach(b => {
|
||||||
|
const active = b.dataset.sortKey === key;
|
||||||
|
b.classList.toggle('is-active', active);
|
||||||
|
const arrow = b.querySelector('.sort-arrow');
|
||||||
|
if (arrow) arrow.textContent = active ? (dir === 'asc' ? '↑' : '↓') : '↕';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortTable(key, sortType, forcedDir) {
|
||||||
|
if (!tbl) return;
|
||||||
|
const tbody = tbl.tBodies[0];
|
||||||
|
const rows = Array.from(tbody.querySelectorAll('tr[data-player-row]'));
|
||||||
|
const curKey = tbl.dataset.sortKey;
|
||||||
|
const curDir = tbl.dataset.sortDirection || 'desc';
|
||||||
|
let dir = forcedDir;
|
||||||
|
if (!dir) {
|
||||||
|
if (curKey === key) dir = curDir === 'desc' ? 'asc' : 'desc';
|
||||||
|
else {
|
||||||
|
const btn = sortBtns.find(b => b.dataset.sortKey === key);
|
||||||
|
dir = btn?.dataset.initialDirection || 'desc';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const mult = dir === 'asc' ? 1 : -1;
|
||||||
|
const ci = colMap[key] ?? 0;
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
const av = a.children[ci]?.dataset.sortValue ?? '';
|
||||||
|
const bv = b.children[ci]?.dataset.sortValue ?? '';
|
||||||
|
if (sortType === 'number') {
|
||||||
|
const n = (Number(av) - Number(bv)) * mult;
|
||||||
|
if (n !== 0) return n;
|
||||||
|
} else {
|
||||||
|
const s = av.localeCompare(bv) * mult;
|
||||||
|
if (s !== 0) return s;
|
||||||
|
}
|
||||||
|
return Number(a.dataset.originalRank) - Number(b.dataset.originalRank);
|
||||||
|
});
|
||||||
|
rows.forEach(r => tbody.appendChild(r));
|
||||||
|
tbl.dataset.sortKey = key;
|
||||||
|
tbl.dataset.sortDirection = dir;
|
||||||
|
rows.forEach((row, i) => {
|
||||||
|
const rc = row.querySelector('[data-rank-cell]');
|
||||||
|
if (rc) rc.textContent = `#${i + 1}`;
|
||||||
|
});
|
||||||
|
setActiveSort(key, dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
sortBtns.forEach(b => {
|
||||||
|
b.addEventListener('click', () => sortTable(b.dataset.sortKey, b.dataset.sortType));
|
||||||
|
});
|
||||||
|
setActiveSort('net', 'desc');
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Ledger · {{ league.name }} · myboker.org{% endblock %}
|
||||||
|
{% block subnav %}{% include "_league_subnav.html" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="cash-strip">
|
||||||
|
<div class="stat stat--rail" style="--rail:var(--accent);">
|
||||||
|
<span class="kicker">Cash in <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">Total real cash brought into the pot across all sessions. Counts buy-ins and collected fronts only — not fronts that haven't been repaid.</span></span></span>
|
||||||
|
<div class="stat__val" style="color:var(--num)">{{ totals.cash_in_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat stat--rail" style="--rail:var(--pos);">
|
||||||
|
<span class="kicker">Cash paid out <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">Total cash physically returned to players via 'paid out' events. Should approach Cash In once everyone has settled.</span></span></span>
|
||||||
|
<div class="stat__val" style="color:var(--pos)">{{ totals.cash_out_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat stat--rail" style="--rail:var(--rolled);">
|
||||||
|
<span class="kicker">House holds <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">Cash in minus cash paid out — what the house physically holds right now before settling remaining claims.</span></span></span>
|
||||||
|
<div class="stat__val" style="color:var(--num)">{{ totals.book_held_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat stat--rail" style="--rail:var(--neg);">
|
||||||
|
<span class="kicker">Due to players <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">Cash the house still owes to players who cashed out but haven't been paid yet. Record a 'paid out' event to clear these.</span></span></span>
|
||||||
|
<div class="stat__val {{ 'num-neg' if totals.due_to_players_cents > 0 else 'num-muted' }}">{{ totals.due_to_players_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat stat--rail" style="--rail:var(--owes);">
|
||||||
|
<span class="kicker">Due to house <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">Outstanding fronts — chips the house covered that players haven't repaid yet. Collect or write off using the Debt resolution panel below.</span></span></span>
|
||||||
|
<div class="stat__val {{ 'num-neg' if totals.due_to_house_cents > 0 else 'num-muted' }}">{{ totals.due_to_house_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat stat--rail" style="--rail:{{ 'var(--pos)' if totals.net_position_cents >= 0 else 'var(--neg)' }};">
|
||||||
|
<span class="kicker">Net position <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">House holds + owed by players − owed to players. Should be near zero once all sessions are settled.</span></span></span>
|
||||||
|
<div class="stat__val {{ 'num-pos' if totals.net_position_cents > 0 else 'num-neg' if totals.net_position_cents < 0 else 'num-muted' }}">{{ totals.net_position_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat stat--rail" style="--rail:var(--warn);">
|
||||||
|
<span class="kicker">Events <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">Total number of active ledger entries across all sessions.</span></span></span>
|
||||||
|
<div class="stat__val" style="color:var(--num)">{{ totals.events }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Open sessions alert -->
|
||||||
|
{% if open_sessions %}
|
||||||
|
<div class="panel" style="margin-top:20px; border-left:3px solid var(--pos);">
|
||||||
|
<div class="panel__head">
|
||||||
|
<span class="kicker" style="margin:0;display:flex;align-items:center;gap:8px;"><span class="live-badge">{{ open_sessions | length }} live</span> Open sessions</span>
|
||||||
|
</div>
|
||||||
|
<div class="audit-item-list">
|
||||||
|
{% for s in open_sessions %}
|
||||||
|
<div class="audit-item">
|
||||||
|
<span>{{ session_label(s) }}</span>
|
||||||
|
<span class="audit-item__right">
|
||||||
|
<span class="stat__sub">{{ s.entries | length }} entries · {{ s.total_real_cash_in_cents | money }} in</span>
|
||||||
|
<a class="btn btn--ghost btn--sm" href="{{ url_for('leagues.session_detail', league_ref=league.url_ref, session_id=session_ids[s.session_id]) }}">View →</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Debt resolution -->
|
||||||
|
<div class="panel debt-panel" style="margin-top:20px;">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<span class="kicker" style="margin:0;">Front receivables <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">A 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.</span></span></span>
|
||||||
|
<h2 class="panel__title">Debt resolution</h2>
|
||||||
|
</div>
|
||||||
|
<div class="debt-summary">
|
||||||
|
<span class="badge badge--owes">Open {{ totals.due_to_house_cents | money }}</span>
|
||||||
|
<span class="badge badge--paid">Collected {{ totals.collected_cents | money }}</span>
|
||||||
|
<span class="badge badge--written_off">Written off {{ totals.written_off_cents | money }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if debt_entries %}
|
||||||
|
<div class="debt-layout">
|
||||||
|
<div class="debt-list">
|
||||||
|
{% for item in debt_entries %}
|
||||||
|
{% set s = item.session %}
|
||||||
|
{% set e = item.entry %}
|
||||||
|
<div class="debt-list-item">
|
||||||
|
<div>
|
||||||
|
<strong>{{ e.player_name }}</strong>
|
||||||
|
<span>{{ session_label(s) }}</span>
|
||||||
|
</div>
|
||||||
|
<b>{{ e.current_due_to_house_cents | money }}</b>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if can_manage %}
|
||||||
|
<div class="debt-action-grid">
|
||||||
|
<form class="form-card debt-form" method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<h3>Collect debt</h3>
|
||||||
|
<label>
|
||||||
|
<span>Outstanding debt</span>
|
||||||
|
<select class="debt-select" data-target="collect" required>
|
||||||
|
<option value="">Select debt</option>
|
||||||
|
{% for item in debt_entries %}
|
||||||
|
{% set s = item.session %}
|
||||||
|
{% set e = item.entry %}
|
||||||
|
<option value="{{ session_ids[s.session_id] }}||{{ player_ids[e.player_name] }}">
|
||||||
|
{{ e.player_name }} · {{ session_label(s) }} · owes {{ e.current_due_to_house_cents | money }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<input type="hidden" name="session_id" value="">
|
||||||
|
<input type="hidden" name="player_id" value="">
|
||||||
|
<input type="hidden" name="event_type" value="debt_repayment">
|
||||||
|
<label>
|
||||||
|
<span>Amount</span>
|
||||||
|
<input type="number" step="0.01" min="0.01" name="amount" placeholder="0.00" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Note <span class="field__opt">(optional)</span></span>
|
||||||
|
<textarea name="note" rows="2" placeholder="E-transfer, cash, etc."></textarea>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn--primary" type="submit">Mark collected</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form class="form-card debt-form" method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<h3>Write off debt</h3>
|
||||||
|
<label>
|
||||||
|
<span>Outstanding debt</span>
|
||||||
|
<select class="debt-select" data-target="writeoff" required>
|
||||||
|
<option value="">Select debt</option>
|
||||||
|
{% for item in debt_entries %}
|
||||||
|
{% set s = item.session %}
|
||||||
|
{% set e = item.entry %}
|
||||||
|
<option value="{{ session_ids[s.session_id] }}||{{ player_ids[e.player_name] }}">
|
||||||
|
{{ e.player_name }} · {{ session_label(s) }} · owes {{ e.current_due_to_house_cents | money }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<input type="hidden" name="session_id" value="">
|
||||||
|
<input type="hidden" name="player_id" value="">
|
||||||
|
<input type="hidden" name="event_type" value="writeoff">
|
||||||
|
<label>
|
||||||
|
<span>Amount</span>
|
||||||
|
<input type="number" step="0.01" min="0.01" name="amount" placeholder="0.00" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Note <span class="field__opt">(optional)</span></span>
|
||||||
|
<textarea name="note" rows="2" placeholder="Won't be collected."></textarea>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn--ghost" type="submit">Write off</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="debt-empty">
|
||||||
|
<div class="debt-empty__mark">$0</div>
|
||||||
|
<p>No open player debts right now.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Due to players -->
|
||||||
|
{% if due_to_player_items %}
|
||||||
|
<div class="panel" style="margin-top:16px;">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<span class="kicker" style="margin:0;">Unpaid cashout claims</span>
|
||||||
|
<h2 class="panel__title">Due to players</h2>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge--owes">{{ totals.due_to_players_cents | money }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="audit-item-list">
|
||||||
|
{% for item in due_to_player_items %}
|
||||||
|
<div class="audit-item">
|
||||||
|
<div>
|
||||||
|
<strong>{{ item.entry.player_name }}</strong>
|
||||||
|
<span class="stat__sub">{{ session_label(item.session) }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="num-neg">{{ item.entry.current_due_to_player_cents | money }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Import / Export -->
|
||||||
|
{% if can_manage %}
|
||||||
|
<div class="panel panel--pad import-export-panel" style="margin-top:16px;">
|
||||||
|
<h2 class="panel__title" style="margin:0 0 4px">Import / export</h2>
|
||||||
|
<p class="csv__desc">Back up or migrate the raw event ledger for this league.</p>
|
||||||
|
<form method="post" action="{{ url_for('leagues.import_ledger_csv', league_ref=league.url_ref) }}" enctype="multipart/form-data" class="import-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="csv__btns">
|
||||||
|
<a class="csv-btn-ghost" href="{{ url_for('leagues.export_ledger_csv', league_ref=league.url_ref) }}">↓ Export CSV</a>
|
||||||
|
<label class="csv-btn-ghost" for="csvImport">↑ Import CSV</label>
|
||||||
|
</div>
|
||||||
|
<input id="csvImport" type="file" accept=".csv,text/csv" name="csv_file" hidden onchange="this.form.submit()">
|
||||||
|
<label class="csv__drop" id="csvDrop" for="csvImport">Drop ledger.csv here to import</label>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="panel panel--pad import-export-panel" style="margin-top:16px;">
|
||||||
|
<h2 class="panel__title" style="margin:0 0 4px">Export</h2>
|
||||||
|
<p class="csv__desc">Download the raw event ledger for this league.</p>
|
||||||
|
<div class="csv__btns">
|
||||||
|
<a class="csv-btn-ghost" href="{{ url_for('leagues.export_ledger_csv', league_ref=league.url_ref) }}">↓ Export CSV</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Sessions + events -->
|
||||||
|
<div class="admin-bottom-grid" style="margin-top:16px;">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<span class="kicker" style="margin:0;">All sessions</span>
|
||||||
|
<h2 class="panel__title">Ledger totals</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Session</th>
|
||||||
|
<th>Players</th>
|
||||||
|
<th>Cash in</th>
|
||||||
|
<th>Paid out</th>
|
||||||
|
<th>Due to house</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in sessions %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ url_for('leagues.session_detail', league_ref=league.url_ref, session_id=session_ids[s.session_id]) }}">{{ session_label(s) }}</a></td>
|
||||||
|
<td style="font-family:var(--font-mono);color:var(--num)">{{ s.entries|length }}</td>
|
||||||
|
<td style="font-family:var(--font-mono);color:var(--num)">{{ s.total_real_cash_in_cents | money }}</td>
|
||||||
|
<td style="font-family:var(--font-mono);color:var(--pos)">{{ s.total_real_cash_out_cents | money }}</td>
|
||||||
|
<td class="{{ 'negative' if s.total_current_due_to_house_cents > 0 else '' }}" style="font-family:var(--font-mono)">{{ s.total_current_due_to_house_cents | money }}</td>
|
||||||
|
<td><span class="status-pill status-pill--{{ s.status }}">{{ s.status }}</span></td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="6" class="muted-text">No sessions yet.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<span class="kicker" style="margin:0;">All events</span>
|
||||||
|
<h2 class="panel__title">Ledger feed</h2>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge--closed">append-only</span>
|
||||||
|
</div>
|
||||||
|
<div class="event-feed feed feed--inline">
|
||||||
|
{% for event in raw_events %}
|
||||||
|
<div class="event-card event-card--inline {% if event.voided_at %}event-card--voided{% endif %}">
|
||||||
|
<div class="event-card-top">
|
||||||
|
<strong>{{ event.player_name or "Session" }}</strong>
|
||||||
|
<span class="pill pill-{{ event.event_type }}">{{ event.event_type | replace('_', ' ') }}</span>
|
||||||
|
{% if event.voided_at %}<span class="void-badge">voided</span>{% endif %}
|
||||||
|
{% if event.note and not event.voided_at %}<span class="event-note">{{ event.note }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
<p class="event-amount">{{ event.amount_cents | money }}</p>
|
||||||
|
{% if event.voided_at %}
|
||||||
|
<p class="tiny-text void-reason">{{ event.void_reason }}</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="tiny-text">{{ event.session_date }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted-text" style="padding:20px;">No events recorded.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.querySelectorAll('.debt-select').forEach(function(sel) {
|
||||||
|
sel.addEventListener('change', function() {
|
||||||
|
var parts = this.value.split('||');
|
||||||
|
var form = this.closest('form');
|
||||||
|
form.querySelector('[name=session_id]').value = parts[0] || '';
|
||||||
|
form.querySelector('[name=player_id]').value = parts[1] || '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var csvDrop = document.getElementById('csvDrop');
|
||||||
|
var csvInput = document.getElementById('csvImport');
|
||||||
|
if (csvDrop && csvInput) {
|
||||||
|
csvDrop.addEventListener('dragover', function(e) { e.preventDefault(); csvDrop.classList.add('is-drag'); });
|
||||||
|
csvDrop.addEventListener('dragleave', function() { csvDrop.classList.remove('is-drag'); });
|
||||||
|
csvDrop.addEventListener('drop', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
csvDrop.classList.remove('is-drag');
|
||||||
|
if (!e.dataTransfer || !e.dataTransfer.files.length) return;
|
||||||
|
csvInput.files = e.dataTransfer.files;
|
||||||
|
csvInput.form.submit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}New League · myboker.org{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="form-shell narrow">
|
||||||
|
<form class="panel form-card" method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">League setup</p>
|
||||||
|
<h1 style="font-size:28px;margin-bottom:8px;">Create league</h1>
|
||||||
|
<p class="muted-text" style="margin-bottom:8px;">Leagues are long-lived containers. Seasons can be added later.</p>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span>Name <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">Pick something recognizable — it appears on your dashboard and the leaderboard.</span></span></span>
|
||||||
|
<input type="text" name="name" value="{{ form.name or '' }}" placeholder="Friday Poker" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Description <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">Optional. Add context like 'Friday night home game, $20 buy-in'. Visible to league members.</span></span></span>
|
||||||
|
<textarea name="description" placeholder="Private home league">{{ form.description or '' }}</textarea>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn--primary" type="submit">Create league</button>
|
||||||
|
<a class="btn btn--ghost" href="{{ url_for('leagues.index') }}">Cancel</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ player.player_name }} · {{ league.name }} · myboker.org{% endblock %}
|
||||||
|
{% block subnav %}{% include "_league_subnav.html" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
|
||||||
|
<div class="hero">
|
||||||
|
<div>
|
||||||
|
{% if player_rank %}
|
||||||
|
<p class="player-rank">Rank #{{ player_rank }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<h1 class="hero__title">{{ player.player_name }}</h1>
|
||||||
|
<p class="hero__sub">{{ player.sessions_played }} session{{ 's' if player.sessions_played != 1 else '' }} · {{ "%.1f"|format(player.win_pct) }}% win rate</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="player-stat-grid" style="margin-bottom:20px;">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="kicker">Total net</span>
|
||||||
|
<div class="stat__val {{ 'num-pos' if player.total_net_cents > 0 else 'num-neg' if player.total_net_cents < 0 else 'num-muted' }}">
|
||||||
|
{{ '+' if player.total_net_cents > 0 else '' }}{{ player.total_net_cents | money }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="kicker">Win rate</span>
|
||||||
|
<div class="stat__val" style="color:var(--text-strong)">{{ "%.1f"|format(player.win_pct) }}%</div>
|
||||||
|
<div class="stat__sub">{{ player.winning_sessions }}W · {{ player.losing_sessions }}L · {{ player.break_even_sessions }}E</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="kicker">ROI</span>
|
||||||
|
<div class="stat__val {{ 'num-pos' if player.roi_pct > 0 else 'num-neg' if player.roi_pct < 0 else 'num-muted' }}">{{ "%.1f"|format(player.roi_pct) }}%</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="kicker">Sessions</span>
|
||||||
|
<div class="stat__val" style="color:var(--text-strong)">{{ player.sessions_played }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="kicker">Current W/L</span>
|
||||||
|
<div class="stat-pair">
|
||||||
|
<span class="stat-pair__val" style="color:var(--pos)">{{ player.current_win_streak }}W</span>
|
||||||
|
<span class="stat-pair__val" style="color:var(--neg)">{{ player.current_loss_streak }}L</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat__sub">Longest {{ player.longest_win_streak }}W · {{ player.longest_loss_streak }}L</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="kicker">Best / worst</span>
|
||||||
|
<div class="stat-pair">
|
||||||
|
<span class="stat-pair__val" style="color:var(--pos)">{{ player.biggest_win_cents | money }}</span>
|
||||||
|
<span class="stat-pair__val" style="color:var(--neg)">{{ player.biggest_loss_cents | money }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="kicker">Total invested</span>
|
||||||
|
<div class="stat__val" style="color:var(--num)">{{ player.total_invested_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="kicker">Gross cashouts</span>
|
||||||
|
<div class="stat__val" style="color:var(--num)">{{ player.total_cash_out_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cumulative profit chart -->
|
||||||
|
<div class="panel" style="margin-bottom:20px;">
|
||||||
|
<div class="panel__head">
|
||||||
|
<span class="kicker" style="margin:0;">Trend</span>
|
||||||
|
<h2 class="panel__title">Cumulative profit</h2>
|
||||||
|
</div>
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<div class="chart-frame chart-frame--tall">
|
||||||
|
<canvas id="playerCumulativeChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts row: session results + result mix -->
|
||||||
|
<div class="split" style="margin-bottom:20px;">
|
||||||
|
<div class="panel col-main">
|
||||||
|
<div class="panel__head">
|
||||||
|
<span class="kicker" style="margin:0;">Session by session</span>
|
||||||
|
<h2 class="panel__title">Results</h2>
|
||||||
|
</div>
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<div class="chart-frame">
|
||||||
|
<canvas id="playerSessionChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel col-side">
|
||||||
|
<div class="panel__head">
|
||||||
|
<span class="kicker" style="margin:0;">Breakdown</span>
|
||||||
|
<h2 class="panel__title">Result mix</h2>
|
||||||
|
</div>
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<div class="result-mix" aria-label="Player result mix">
|
||||||
|
{% set result_total = player.winning_sessions + player.losing_sessions + player.break_even_sessions %}
|
||||||
|
<div class="result-mix__bar">
|
||||||
|
{% if player.winning_sessions > 0 %}
|
||||||
|
<span class="result-mix__seg result-mix__seg--win" style="flex-grow:{{ player.winning_sessions }}"></span>
|
||||||
|
{% endif %}
|
||||||
|
{% if player.break_even_sessions > 0 %}
|
||||||
|
<span class="result-mix__seg result-mix__seg--even" style="flex-grow:{{ player.break_even_sessions }}"></span>
|
||||||
|
{% endif %}
|
||||||
|
{% if player.losing_sessions > 0 %}
|
||||||
|
<span class="result-mix__seg result-mix__seg--loss" style="flex-grow:{{ player.losing_sessions }}"></span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="result-mix__legend">
|
||||||
|
<div class="result-mix__legend-row">
|
||||||
|
<span><i class="result-mix__key result-mix__key--win"></i>Wins</span>
|
||||||
|
<strong><b>{{ player.winning_sessions }}</b><em>{{ "%.1f"|format((player.winning_sessions / result_total * 100) if result_total else 0) }}%</em></strong>
|
||||||
|
</div>
|
||||||
|
<div class="result-mix__legend-row">
|
||||||
|
<span><i class="result-mix__key result-mix__key--even"></i>Even</span>
|
||||||
|
<strong><b>{{ player.break_even_sessions }}</b><em>{{ "%.1f"|format((player.break_even_sessions / result_total * 100) if result_total else 0) }}%</em></strong>
|
||||||
|
</div>
|
||||||
|
<div class="result-mix__legend-row">
|
||||||
|
<span><i class="result-mix__key result-mix__key--loss"></i>Losses</span>
|
||||||
|
<strong><b>{{ player.losing_sessions }}</b><em>{{ "%.1f"|format((player.losing_sessions / result_total * 100) if result_total else 0) }}%</em></strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if result_total > 0 %}
|
||||||
|
<div class="result-mix__note">±$1.00 counts as even</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Perf vs Cash two-panel split -->
|
||||||
|
<div class="split" style="margin-bottom:20px;">
|
||||||
|
|
||||||
|
<!-- Poker performance -->
|
||||||
|
<div class="panel col-main">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<div class="section-marker">
|
||||||
|
<span class="section-marker__dot section-marker__dot--accent"></span>
|
||||||
|
Poker performance
|
||||||
|
</div>
|
||||||
|
<h2 class="panel__title">Game results</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table style="width:100%;border-collapse:collapse;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Total Net</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);font-variant-numeric:tabular-nums;border-top:1px solid var(--divider);"
|
||||||
|
class="{{ 'num-pos' if player.total_net_cents > 0 else 'num-neg' if player.total_net_cents < 0 else '' }}">
|
||||||
|
{{ '+' if player.total_net_cents > 0 else '' }}{{ player.total_net_cents | money }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Win %</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">{{ "%.1f"|format(player.win_pct) }}%</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Record</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">
|
||||||
|
<span style="color:var(--pos)">{{ player.winning_sessions }}W</span>
|
||||||
|
<span style="color:var(--faintest);margin:0 4px;">·</span>
|
||||||
|
<span style="color:var(--neg)">{{ player.losing_sessions }}L</span>
|
||||||
|
<span style="color:var(--faintest);margin:0 4px;">·</span>
|
||||||
|
<span>{{ player.break_even_sessions }}E</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">ROI</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);"
|
||||||
|
class="{{ 'num-pos' if player.roi_pct > 0 else 'num-neg' if player.roi_pct < 0 else '' }}">{{ "%.1f"|format(player.roi_pct) }}%</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Average Win</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--pos);">{{ player.avg_win_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Average Loss</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--neg);">{{ player.avg_loss_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Best / Worst</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">
|
||||||
|
<span style="color:var(--pos)">{{ player.biggest_win_cents | money }}</span>
|
||||||
|
<span style="color:var(--faintest);margin:0 4px;">/</span>
|
||||||
|
<span style="color:var(--neg)">{{ player.biggest_loss_cents | money }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Win / Loss Streak</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">
|
||||||
|
<span style="color:var(--pos)">{{ player.current_win_streak }}W</span>
|
||||||
|
<span style="color:var(--faintest);margin:0 4px;">·</span>
|
||||||
|
<span style="color:var(--neg)">{{ player.current_loss_streak }}L</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Longest Win / Loss</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">
|
||||||
|
{{ player.longest_win_streak }}W · {{ player.longest_loss_streak }}L
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Poker inputs and settlement. Fronts/rollovers are part of poker basis,
|
||||||
|
while paid/collected rows are real cash movement. -->
|
||||||
|
<div class="panel col-side">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<div class="section-marker">
|
||||||
|
<span class="section-marker__dot section-marker__dot--rolled"></span>
|
||||||
|
Poker basis + settlement
|
||||||
|
</div>
|
||||||
|
<h2 class="panel__title">Investment and settlement</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table style="width:100%;border-collapse:collapse;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Buy-ins paid</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">{{ player.total_buy_in_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Fronted</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--warn);">{{ player.total_front_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Rolled in</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--rolled);">{{ player.total_rollover_in_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% if player.total_payout_carry_in_cents > 0 %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Payout carry-in</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--rolled);">{{ player.total_payout_carry_in_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Total invested</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">{{ player.total_invested_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Gross cashout result</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">{{ player.total_cash_out_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Gross player claim</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">{{ player.total_gross_payout_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Rolled out</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--rolled);">{{ player.total_rollover_out_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Cash paid out</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--pos);">{{ player.total_paid_out_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% if player.total_debt_repayment_cents > 0 %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Cash debt repaid</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--pos);">{{ player.total_debt_repayment_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if player.current_due_to_player_cents > 0 %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">House owes player</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--neg);">{{ player.current_due_to_player_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if player.current_due_to_house_cents > 0 %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Owes house</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--owes);">{{ player.current_due_to_house_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if player.total_writeoff_cents > 0 %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Written off</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--neg);">{{ player.total_writeoff_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const playerChartData = {{ chart_data | tojson }};
|
||||||
|
const rootStyles = getComputedStyle(document.documentElement);
|
||||||
|
const textMuted = rootStyles.getPropertyValue('--text-muted').trim();
|
||||||
|
const lineColor = rootStyles.getPropertyValue('--line').trim();
|
||||||
|
|
||||||
|
const sharedOpts = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'bottom', labels: { color: textMuted, usePointStyle: true, boxWidth: 9, boxHeight: 9, padding: 16 } },
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { ticks: { color: textMuted, maxRotation: 0 }, grid: { color: lineColor } },
|
||||||
|
y: { ticks: { color: textMuted }, grid: { color: lineColor } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const cumulativeCtx = document.getElementById('playerCumulativeChart');
|
||||||
|
if (cumulativeCtx) {
|
||||||
|
new Chart(cumulativeCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: playerChartData.labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Cumulative Profit',
|
||||||
|
data: playerChartData.cumulative_values,
|
||||||
|
borderColor: playerChartData.color,
|
||||||
|
backgroundColor: playerChartData.color + '38',
|
||||||
|
borderWidth: 2.5,
|
||||||
|
pointRadius: 3,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
tension: 0.22,
|
||||||
|
fill: true,
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: sharedOpts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionCtx = document.getElementById('playerSessionChart');
|
||||||
|
if (sessionCtx) {
|
||||||
|
new Chart(sessionCtx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: playerChartData.labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Session Net',
|
||||||
|
data: playerChartData.net_values,
|
||||||
|
backgroundColor: playerChartData.net_colors,
|
||||||
|
borderColor: playerChartData.net_colors,
|
||||||
|
borderRadius: 6,
|
||||||
|
borderSkipped: false,
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: sharedOpts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Players · {{ league.name }} · myboker.org{% endblock %}
|
||||||
|
{% block subnav %}{% include "_league_subnav.html" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
{% if can_manage %}
|
||||||
|
<form class="panel form-card" method="post" style="margin-bottom:20px;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Roster</p>
|
||||||
|
<h2 class="panel__title" style="margin-top:4px;">Add player</h2>
|
||||||
|
</div>
|
||||||
|
<div class="session-create-row">
|
||||||
|
<label>
|
||||||
|
<span>Name <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">The player's display name. Appears on the leaderboard and in all ledger events. Players don't need a myboker account.</span></span></span>
|
||||||
|
<input type="text" name="display_name" value="{{ form.display_name or '' }}" placeholder="Alex" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Notes <span class="form-opt-hint">(optional)</span> <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">Optional. Useful for payment handles like 'Venmo @alex'. Visible only to league managers, not on public pages.</span></span></span>
|
||||||
|
<input type="text" name="notes" value="{{ form.notes or '' }}" placeholder="Venmo @alex">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn--primary" type="submit">Add player</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% set active_players = players | selectattr('status', 'equalto', 'active') | list %}
|
||||||
|
{% set archived_players = players | selectattr('status', 'equalto', 'archived') | list %}
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<p class="kicker" style="margin:0;">Roster</p>
|
||||||
|
<h2 class="panel__title">Active players</h2>
|
||||||
|
</div>
|
||||||
|
<span class="panel__tag">{{ active_players|length }} players</span>
|
||||||
|
</div>
|
||||||
|
{% if active_players %}
|
||||||
|
<div class="player-grid">
|
||||||
|
{% for player in active_players %}
|
||||||
|
{% set stats = stats_by_name.get(player.display_name) %}
|
||||||
|
{% set initials = player.display_name.split()|map('first')|join('')|upper %}
|
||||||
|
{% set avatar_cls = 'player-avatar--pos' if stats and stats.total_net_cents > 0 else 'player-avatar--neg' if stats and stats.total_net_cents < 0 else '' %}
|
||||||
|
<div class="player-card">
|
||||||
|
<div class="player-card__head">
|
||||||
|
<div class="player-avatar {{ avatar_cls }}">{{ initials[:2] }}</div>
|
||||||
|
<div class="player-card__name-block">
|
||||||
|
<a class="player-card__name" href="{{ url_for('leagues.player_detail', league_ref=league.url_ref, player_id=player.id) }}">{{ player.display_name }}</a>
|
||||||
|
<span class="player-card__sub">
|
||||||
|
{% if stats %}{{ stats.sessions_played }} session{{ 's' if stats.sessions_played != 1 else '' }}{% else %}No sessions yet{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% if can_manage %}
|
||||||
|
<div class="card-menu">
|
||||||
|
<button class="card-menu__trigger" type="button" aria-label="Player options">···</button>
|
||||||
|
<div class="card-menu__dropdown" hidden>
|
||||||
|
<button class="card-menu__item" type="button"
|
||||||
|
data-edit-name="{{ player.display_name }}"
|
||||||
|
data-edit-notes="{{ player.notes or '' }}"
|
||||||
|
data-edit-action="{{ url_for('leagues.edit_player', league_ref=league.url_ref, player_id=player.id) }}">Edit player</button>
|
||||||
|
{% if player.status == 'active' %}
|
||||||
|
<button class="card-menu__item card-menu__item--warn" type="button"
|
||||||
|
data-archive-name="{{ player.display_name }}"
|
||||||
|
data-archive-action="{{ url_for('leagues.archive_player', league_ref=league.url_ref, player_id=player.id) }}">Archive</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if stats %}
|
||||||
|
<div class="player-card__stats">
|
||||||
|
<div class="player-card__stat">
|
||||||
|
<span class="kicker">Net</span>
|
||||||
|
<span class="player-card__stat-val {{ 'num-pos' if stats.total_net_cents > 0 else 'num-neg' if stats.total_net_cents < 0 else 'num-muted' }}">
|
||||||
|
{{ '+' if stats.total_net_cents > 0 else '' }}{{ stats.total_net_cents | money }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="player-card__stat">
|
||||||
|
<span class="kicker">Win rate</span>
|
||||||
|
<span class="player-card__stat-val">{{ "%.0f"|format(stats.win_pct * 100) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="player-card__stat">
|
||||||
|
<span class="kicker">Best win</span>
|
||||||
|
<span class="player-card__stat-val" style="color:var(--pos)">{{ stats.biggest_win_cents | money }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-panel">
|
||||||
|
<p class="kicker">Empty roster</p>
|
||||||
|
<h3 class="panel__title">No players yet</h3>
|
||||||
|
<p class="muted-text">Add the people who play in this league. Players don't need accounts.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if archived_players %}
|
||||||
|
<div class="panel" style="margin-top:20px;">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<p class="kicker" style="margin:0;">Archive</p>
|
||||||
|
<h2 class="panel__title">Archived players</h2>
|
||||||
|
</div>
|
||||||
|
<span class="panel__tag">{{ archived_players|length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="player-list">
|
||||||
|
{% for player in archived_players %}
|
||||||
|
{% set stats = stats_by_name.get(player.display_name) %}
|
||||||
|
<div class="player-row" style="opacity:0.6;">
|
||||||
|
<div>
|
||||||
|
<strong>{{ player.display_name }}</strong>
|
||||||
|
{% if stats %}<span>{{ stats.sessions_played }} sessions · {{ stats.total_net_cents | money }} net</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="player-row__actions">
|
||||||
|
{% if can_manage %}
|
||||||
|
<div class="card-menu">
|
||||||
|
<button class="card-menu__trigger" type="button" aria-label="Player options">···</button>
|
||||||
|
<div class="card-menu__dropdown" hidden>
|
||||||
|
<button class="card-menu__item" type="button"
|
||||||
|
data-edit-name="{{ player.display_name }}"
|
||||||
|
data-edit-notes="{{ player.notes or '' }}"
|
||||||
|
data-edit-action="{{ url_for('leagues.edit_player', league_ref=league.url_ref, player_id=player.id) }}">Edit player</button>
|
||||||
|
<form method="post" action="{{ url_for('leagues.reactivate_player', league_ref=league.url_ref, player_id=player.id) }}" style="margin:0;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="card-menu__item" type="submit">Reactivate</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if can_manage %}
|
||||||
|
<div class="modal-backdrop" id="modal-edit-player" hidden>
|
||||||
|
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="modal-edit-player-title">
|
||||||
|
<div class="modal-card__head">
|
||||||
|
<span class="kicker" style="margin:0;">Roster</span>
|
||||||
|
<h2 class="panel__title" id="modal-edit-player-title">Edit player</h2>
|
||||||
|
</div>
|
||||||
|
<form id="edit-player-form" method="post" action="">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="form-card" style="padding:0 20px 4px;gap:12px;">
|
||||||
|
<label>
|
||||||
|
<span>Name</span>
|
||||||
|
<input type="text" name="display_name" id="edit-player-name" required minlength="2">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Notes <span class="form-opt-hint">(optional)</span></span>
|
||||||
|
<input type="text" name="notes" id="edit-player-notes" placeholder="Venmo @alex">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="modal-card__actions">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-close-modal>Cancel</button>
|
||||||
|
<button class="btn btn--primary btn--sm" type="submit">Save changes</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-backdrop" id="modal-archive-player" hidden>
|
||||||
|
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="modal-archive-player-title">
|
||||||
|
<div class="modal-card__head">
|
||||||
|
<span class="kicker" style="margin:0;">Roster</span>
|
||||||
|
<h2 class="panel__title" id="modal-archive-player-title">Archive player?</h2>
|
||||||
|
</div>
|
||||||
|
<p style="padding:0 20px 16px;margin:0;font:400 14px/1.5 var(--font-ui);color:var(--text-2);">Archived players are hidden from the active roster. Their history is preserved. You can reactivate them any time.</p>
|
||||||
|
<form id="archive-player-form" method="post" action="">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="modal-card__actions">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-close-modal>Cancel</button>
|
||||||
|
<button class="btn btn--danger btn--sm" type="submit">Archive player</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var editForm = document.getElementById('edit-player-form');
|
||||||
|
var editName = document.getElementById('edit-player-name');
|
||||||
|
var editNotes = document.getElementById('edit-player-notes');
|
||||||
|
var editModal = document.getElementById('modal-edit-player');
|
||||||
|
var archiveForm = document.getElementById('archive-player-form');
|
||||||
|
var archiveTitle = document.getElementById('modal-archive-player-title');
|
||||||
|
var archiveModal = document.getElementById('modal-archive-player');
|
||||||
|
|
||||||
|
function closeAllDropdowns() {
|
||||||
|
document.querySelectorAll('.card-menu__dropdown').forEach(function (d) {
|
||||||
|
d.setAttribute('hidden', '');
|
||||||
|
d.style.cssText = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card menu: open with fixed positioning to escape overflow:hidden panels
|
||||||
|
document.querySelectorAll('.card-menu__trigger').forEach(function (trigger) {
|
||||||
|
trigger.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
var dropdown = this.nextElementSibling;
|
||||||
|
var isOpen = !dropdown.hasAttribute('hidden');
|
||||||
|
closeAllDropdowns();
|
||||||
|
if (!isOpen) {
|
||||||
|
var tr = trigger.getBoundingClientRect();
|
||||||
|
dropdown.style.position = 'fixed';
|
||||||
|
dropdown.style.right = 'auto';
|
||||||
|
dropdown.style.top = 'auto';
|
||||||
|
dropdown.removeAttribute('hidden');
|
||||||
|
var dw = dropdown.offsetWidth;
|
||||||
|
var dh = dropdown.offsetHeight;
|
||||||
|
var left = tr.right - dw;
|
||||||
|
var top = tr.bottom + 4;
|
||||||
|
if (top + dh > window.innerHeight - 8) top = tr.top - dh - 4;
|
||||||
|
if (left < 8) left = 8;
|
||||||
|
dropdown.style.left = left + 'px';
|
||||||
|
dropdown.style.top = top + 'px';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.addEventListener('click', closeAllDropdowns);
|
||||||
|
|
||||||
|
// Edit player
|
||||||
|
document.querySelectorAll('.card-menu__item[data-edit-action]').forEach(function (item) {
|
||||||
|
item.addEventListener('click', function () {
|
||||||
|
closeAllDropdowns();
|
||||||
|
editForm.action = this.dataset.editAction;
|
||||||
|
editName.value = this.dataset.editName;
|
||||||
|
editNotes.value = this.dataset.editNotes;
|
||||||
|
editModal.removeAttribute('hidden');
|
||||||
|
setTimeout(function () { editName.focus(); }, 50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Archive player
|
||||||
|
document.querySelectorAll('.card-menu__item[data-archive-action]').forEach(function (item) {
|
||||||
|
item.addEventListener('click', function () {
|
||||||
|
closeAllDropdowns();
|
||||||
|
archiveTitle.textContent = 'Archive ' + this.dataset.archiveName + '?';
|
||||||
|
archiveForm.action = this.dataset.archiveAction;
|
||||||
|
archiveModal.removeAttribute('hidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modal close
|
||||||
|
document.querySelectorAll('[data-close-modal]').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
this.closest('.modal-backdrop').setAttribute('hidden', '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.modal-backdrop').forEach(function (backdrop) {
|
||||||
|
backdrop.addEventListener('click', function (e) {
|
||||||
|
if (e.target === this) this.setAttribute('hidden', '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ session_model.display_label }} · {{ league.name }} · myboker.org{% endblock %}
|
||||||
|
{% block subnav %}{% include "_league_subnav.html" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="hero" style="padding-top:0;padding-bottom:12px;display:flex;flex-wrap:wrap;gap:12px;align-items:flex-end;justify-content:space-between;">
|
||||||
|
<div>
|
||||||
|
<h1 class="hero__title">{{ session_model.display_label }}</h1>
|
||||||
|
<p class="hero__sub">{{ session_model.session_date.isoformat() | pretty_date }} · <span class="status-badge status-{{ session_model.status }}">{{ session_model.status }}</span></p>
|
||||||
|
</div>
|
||||||
|
<div class="button-row">
|
||||||
|
<a class="btn btn--ghost btn--sm" href="{{ url_for('leagues.session_public_view', league_ref=league.url_ref, session_id=session_model.id) }}">Public view</a>
|
||||||
|
{% if can_manage %}
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-modal="modal-edit-session">Edit session</button>
|
||||||
|
{% if session_model.status == 'open' %}
|
||||||
|
<form method="post" action="{{ url_for('leagues.close_league_session', league_ref=league.url_ref, session_id=session_model.id) }}" style="margin:0;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="submit">Close session</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-modal="modal-reopen-session">Reopen session</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if is_owner %}
|
||||||
|
<button class="btn btn--danger btn--sm" type="button" data-modal="modal-delete-session">Delete session</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cash-strip">
|
||||||
|
<div class="stat stat--rail" style="--rail:var(--accent);">
|
||||||
|
<span class="kicker">Invested</span>
|
||||||
|
<div class="stat__val" style="color:var(--num)">{{ session.total_invested_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat stat--rail" style="--rail:var(--rank-2);">
|
||||||
|
<span class="kicker">Cashout</span>
|
||||||
|
<div class="stat__val" style="color:var(--num)">{{ session.total_cash_out_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat stat--rail" style="--rail:var(--pos);">
|
||||||
|
<span class="kicker">Paid out</span>
|
||||||
|
<div class="stat__val" style="color:var(--pos)">{{ session.total_paid_out_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat stat--rail" style="--rail:var(--warn);">
|
||||||
|
<span class="kicker">Open items</span>
|
||||||
|
<div class="stat__val" style="color:var(--num)">{{ (session.total_current_due_to_player_cents + session.total_current_due_to_house_cents) | money }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="league-manage-grid" style="margin-top:20px;align-items:stretch;">
|
||||||
|
{% if can_manage %}
|
||||||
|
<form class="panel form-card" method="post" id="appendForm" style="align-self:stretch;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Append event</p>
|
||||||
|
<h2 class="panel__title" style="margin-top:4px;">Ledger entry</h2>
|
||||||
|
</div>
|
||||||
|
{% if session_model.status != 'open' %}
|
||||||
|
<div class="session-locked-banner">
|
||||||
|
<strong>Session closed</strong>
|
||||||
|
<span>Books assumed settled. Reopen to add events.</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<fieldset {% if session_model.status != 'open' %}disabled{% endif %} style="border:none;padding:0;margin:0;min-width:0;">
|
||||||
|
<label>
|
||||||
|
<span>Player</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="playerSearch"
|
||||||
|
list="playerList"
|
||||||
|
placeholder="Start typing a name…"
|
||||||
|
autocomplete="off"
|
||||||
|
value="{{ players | selectattr('id', 'equalto', append_form.player_id) | map(attribute='display_name') | first | default('') }}"
|
||||||
|
>
|
||||||
|
<datalist id="playerList">
|
||||||
|
{% for player in players %}
|
||||||
|
<option value="{{ player.display_name }}"></option>
|
||||||
|
{% endfor %}
|
||||||
|
</datalist>
|
||||||
|
<input type="hidden" name="player_id" id="playerIdInput" value="{{ append_form.player_id or '' }}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Event type</span>
|
||||||
|
<select name="event_type" id="eventTypeSelect" required>
|
||||||
|
{% for value, label in event_types %}
|
||||||
|
<option value="{{ value }}" {{ 'selected' if append_form.event_type == value else '' }}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Amount</span>
|
||||||
|
<input type="number" step="0.01" min="0" name="amount" value="{{ append_form.amount }}" placeholder="0.00">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Note <span class="form-opt-hint">(optional)</span></span>
|
||||||
|
<textarea name="note" placeholder="Optional context">{{ append_form.note or '' }}</textarea>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn--primary" type="submit" style="margin-top:8px;">Append event</button>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<div class="panel empty-panel">
|
||||||
|
<h2 class="panel__title">Read only</h2>
|
||||||
|
<p class="muted-text">Your role can inspect this session but cannot append ledger events.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<p class="kicker" style="margin:0;">Results</p>
|
||||||
|
<h2 class="panel__title">Player totals</h2>
|
||||||
|
</div>
|
||||||
|
<span class="panel__tag">{{ session.entries|length }} players</span>
|
||||||
|
</div>
|
||||||
|
{% if session.entries %}
|
||||||
|
<div class="player-ledger-list">
|
||||||
|
{% for entry in session.entries %}
|
||||||
|
{% set row_cls = 'player-ledger-row--pos' if entry.net_cents > 0 else 'player-ledger-row--neg' if entry.net_cents < 0 else '' %}
|
||||||
|
<div class="player-ledger-row {{ row_cls }}">
|
||||||
|
<div class="player-ledger-row__header">
|
||||||
|
<span class="player-ledger-row__name">{{ entry.player_name }}</span>
|
||||||
|
{% if entry.current_due_to_house_cents > 0 %}
|
||||||
|
<span class="badge badge--owes">Owes {{ entry.current_due_to_house_cents | money }}</span>
|
||||||
|
{% elif entry.current_due_to_player_cents > 0 %}
|
||||||
|
<span class="badge badge--unpaid">Due {{ entry.current_due_to_player_cents | money }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge--settled">Settled</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="player-ledger-row__fields">
|
||||||
|
<div class="player-ledger-row__field">
|
||||||
|
<span class="player-ledger-row__lbl">Buy in</span>
|
||||||
|
<span class="player-ledger-row__val">{{ entry.invested_cents | money }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="player-ledger-row__field">
|
||||||
|
<span class="player-ledger-row__lbl">Cashout claim</span>
|
||||||
|
<span class="player-ledger-row__val">{{ entry.cash_out_cents | money }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="player-ledger-row__field">
|
||||||
|
<span class="player-ledger-row__lbl">Paid out</span>
|
||||||
|
<span class="player-ledger-row__val" style="color:var(--pos)">{{ entry.paid_out_cents | money if entry.paid_out_cents else '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="player-ledger-row__field player-ledger-row__field--net">
|
||||||
|
<span class="player-ledger-row__lbl">Net</span>
|
||||||
|
<span class="player-ledger-row__val {{ 'num-pos' if entry.net_cents > 0 else 'num-neg' if entry.net_cents < 0 else 'num-muted' }}">
|
||||||
|
{{ '+' if entry.net_cents > 0 else '' }}{{ entry.net_cents | money }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if entry.rollover_in_cents or entry.rollover_out_cents or entry.payout_carry_in_cents %}
|
||||||
|
<div class="player-ledger-row__fields player-ledger-row__fields--secondary">
|
||||||
|
<div class="player-ledger-row__field">
|
||||||
|
<span class="player-ledger-row__lbl">Rolled in</span>
|
||||||
|
<span class="player-ledger-row__val" style="color:var(--rolled)">{{ entry.rollover_in_cents | money }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="player-ledger-row__field">
|
||||||
|
<span class="player-ledger-row__lbl">Carry in</span>
|
||||||
|
<span class="player-ledger-row__val" style="color:var(--rolled)">{{ entry.payout_carry_in_cents | money }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="player-ledger-row__field">
|
||||||
|
<span class="player-ledger-row__lbl">Rolled out</span>
|
||||||
|
<span class="player-ledger-row__val" style="color:var(--rolled)">{{ entry.rollover_out_cents | money }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted-text" style="padding:28px;">No player ledger events yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel" style="margin-top:20px;">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<p class="kicker" style="margin:0;">Audit trail</p>
|
||||||
|
<h2 class="panel__title">Raw ledger events</h2>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge--closed">append-only</span>
|
||||||
|
</div>
|
||||||
|
<div class="event-feed feed feed--inline" style="max-height:none;">
|
||||||
|
{% for event in raw_events | reverse %}
|
||||||
|
<div class="event-card event-card--inline {% if event.voided_at %}event-card--voided{% endif %}">
|
||||||
|
<div class="event-card-top">
|
||||||
|
<strong>{{ event.player_name or "Session" }}</strong>
|
||||||
|
<span class="pill pill-{{ event.event_type }}">{{ event.event_type | replace('_', ' ') }}</span>
|
||||||
|
{% if event.voided_at %}<span class="void-badge">voided</span>{% endif %}
|
||||||
|
{% if event.note and not event.voided_at %}<span class="event-note">{{ event.note }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
<p class="event-amount">{{ event.amount_cents | money }}</p>
|
||||||
|
{% if event.voided_at %}
|
||||||
|
<p class="tiny-text void-reason">{{ event.void_reason }}</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="tiny-text">{{ event.created_at }}</p>
|
||||||
|
{% if is_owner %}
|
||||||
|
<button class="btn-void-toggle" type="button"
|
||||||
|
data-void-id="{{ event.id }}"
|
||||||
|
data-void-player="{{ event.player_name or 'Session' }}"
|
||||||
|
data-void-type="{{ event.event_type | replace('_', ' ') }}"
|
||||||
|
data-void-amount="${{ '%.2f' % (event.amount_cents / 100) }}"
|
||||||
|
data-void-action="{{ url_for('leagues.void_event', league_ref=league.url_ref, event_id=event.id) }}">
|
||||||
|
void
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted-text" style="padding:20px;">No events recorded.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Void event modal -->
|
||||||
|
{% if is_owner %}
|
||||||
|
<div class="modal-backdrop" id="modal-void-event" hidden>
|
||||||
|
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="modal-void-title">
|
||||||
|
<div class="modal-card__head">
|
||||||
|
<span class="kicker" style="margin:0;color:var(--warn);">Correct the record</span>
|
||||||
|
<h2 class="panel__title" id="modal-void-title">Void event</h2>
|
||||||
|
</div>
|
||||||
|
<p class="modal-card__text" id="modal-void-desc">This event will remain visible in the audit trail but will no longer count toward any calculations.</p>
|
||||||
|
<form id="modal-void-form" method="post" action="">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<label style="display:flex;flex-direction:column;gap:6px;margin-bottom:16px;">
|
||||||
|
<span style="font:600 12px/1 var(--font-ui);color:var(--text-2);text-transform:uppercase;letter-spacing:.04em;">Reason</span>
|
||||||
|
<input type="text" name="void_reason" placeholder="What was wrong with this event?" required maxlength="200"
|
||||||
|
style="background:var(--field);border:1px solid var(--border-strong);border-radius:var(--r-sm);padding:9px 12px;font:400 14px/1.4 var(--font-ui);color:var(--text-1);">
|
||||||
|
</label>
|
||||||
|
<div class="modal-card__actions">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-close-modal>Cancel</button>
|
||||||
|
<button class="btn btn--danger btn--sm" type="submit">Void this event</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Delete confirmation modal -->
|
||||||
|
<div class="modal-backdrop" id="modal-delete-session" hidden>
|
||||||
|
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="modal-delete-title">
|
||||||
|
<div class="modal-card__head">
|
||||||
|
<span class="kicker" style="margin:0;color:var(--neg);">Destructive action</span>
|
||||||
|
<h2 class="panel__title" id="modal-delete-title">Delete {{ session_model.display_label }}?</h2>
|
||||||
|
</div>
|
||||||
|
<p class="modal-card__text">This will permanently delete the session and <strong>all {{ session_model.event_count if session_model.event_count is defined else 'its' }} ledger events</strong> attached to it. There is no undo.</p>
|
||||||
|
<div class="modal-card__actions">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-close-modal>Cancel</button>
|
||||||
|
<form method="post" action="{{ url_for('leagues.delete_session', league_ref=league.url_ref, session_id=session_model.id) }}" style="margin:0;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn--danger btn--sm" type="submit">Yes, delete permanently</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit session modal -->
|
||||||
|
{% if can_manage %}
|
||||||
|
<div class="modal-backdrop" id="modal-edit-session" hidden>
|
||||||
|
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="modal-edit-session-title">
|
||||||
|
<div class="modal-card__head">
|
||||||
|
<span class="kicker" style="margin:0;">Session</span>
|
||||||
|
<h2 class="panel__title" id="modal-edit-session-title">Edit session</h2>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('leagues.edit_session', league_ref=league.url_ref, session_id=session_model.id) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="form-card" style="padding:0 20px 4px;gap:12px;">
|
||||||
|
<label>
|
||||||
|
<span>Date</span>
|
||||||
|
<input type="date" name="session_date" value="{{ session_model.session_date.isoformat() }}" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Label <span class="form-opt-hint">(optional)</span></span>
|
||||||
|
<input type="text" name="label" value="{{ session_model.label or '' }}" placeholder="e.g. Main table">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Notes <span class="form-opt-hint">(optional)</span></span>
|
||||||
|
<input type="text" name="notes" value="{{ session_model.notes or '' }}" placeholder="Optional context">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="modal-card__actions">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-close-modal>Cancel</button>
|
||||||
|
<button class="btn btn--primary btn--sm" type="submit">Save changes</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Reopen confirmation modal -->
|
||||||
|
<div class="modal-backdrop" id="modal-reopen-session" hidden>
|
||||||
|
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="modal-reopen-title">
|
||||||
|
<div class="modal-card__head">
|
||||||
|
<span class="kicker" style="margin:0;">Settled session</span>
|
||||||
|
<h2 class="panel__title" id="modal-reopen-title">Reopen this session?</h2>
|
||||||
|
</div>
|
||||||
|
<p class="modal-card__text">This session has been closed and books are assumed to be settled. Reopening will allow new ledger events to be appended and may affect settlement totals.</p>
|
||||||
|
<div class="modal-card__actions">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-close-modal>Cancel</button>
|
||||||
|
<form method="post" action="{{ url_for('leagues.open_league_session', league_ref=league.url_ref, session_id=session_model.id) }}" style="margin:0;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn--primary btn--sm" type="submit">Reopen session</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Player search — map display name → player id
|
||||||
|
const playerMap = {
|
||||||
|
{% for player in players %}"{{ player.display_name | replace('"', '\\"') }}": "{{ player.id }}"{% if not loop.last %},{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
};
|
||||||
|
const searchInput = document.getElementById('playerSearch');
|
||||||
|
const idInput = document.getElementById('playerIdInput');
|
||||||
|
const eventTypeSelect = document.getElementById('eventTypeSelect');
|
||||||
|
|
||||||
|
if (searchInput && idInput) {
|
||||||
|
searchInput.addEventListener('input', function () {
|
||||||
|
idInput.value = playerMap[this.value.trim()] || '';
|
||||||
|
});
|
||||||
|
// Also handle datalist selection which fires change
|
||||||
|
searchInput.addEventListener('change', function () {
|
||||||
|
idInput.value = playerMap[this.value.trim()] || '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear player field when event type is "note"
|
||||||
|
if (eventTypeSelect && searchInput && idInput) {
|
||||||
|
eventTypeSelect.addEventListener('change', function () {
|
||||||
|
if (this.value === 'note') {
|
||||||
|
searchInput.value = '';
|
||||||
|
idInput.value = '';
|
||||||
|
searchInput.placeholder = 'No player (session note)';
|
||||||
|
} else {
|
||||||
|
searchInput.placeholder = 'Start typing a name…';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Set initial placeholder if note is selected
|
||||||
|
if (eventTypeSelect.value === 'note') {
|
||||||
|
searchInput.placeholder = 'No player (session note)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal logic
|
||||||
|
document.querySelectorAll('[data-modal]').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var target = document.getElementById(this.dataset.modal);
|
||||||
|
if (target) target.removeAttribute('hidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-close-modal]').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
this.closest('.modal-backdrop').setAttribute('hidden', '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.modal-backdrop').forEach(function(backdrop) {
|
||||||
|
backdrop.addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) this.setAttribute('hidden', '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Void event modal
|
||||||
|
document.querySelectorAll('[data-void-id]').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var player = this.dataset.voidPlayer;
|
||||||
|
var type = this.dataset.voidType;
|
||||||
|
var amount = this.dataset.voidAmount;
|
||||||
|
document.getElementById('modal-void-title').textContent = 'Void ' + type + ' — ' + player;
|
||||||
|
document.getElementById('modal-void-desc').textContent =
|
||||||
|
'The ' + amount + ' ' + type + ' event for ' + player +
|
||||||
|
' will be marked voided and excluded from all calculations. It stays visible in the audit trail.';
|
||||||
|
var form = document.getElementById('modal-void-form');
|
||||||
|
form.action = this.dataset.voidAction;
|
||||||
|
form.querySelector('[name=void_reason]').value = '';
|
||||||
|
document.getElementById('modal-void-event').removeAttribute('hidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ session_model.display_label }} · {{ league.name }} · myboker.org{% endblock %}
|
||||||
|
{% block subnav %}{% include "_league_subnav.html" %}{% endblock %}
|
||||||
|
{% block page_class %}page--session{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a href="{{ url_for('leagues.sessions', league_ref=league.url_ref) }}">Sessions</a>
|
||||||
|
<span class="breadcrumb__sep">/</span>
|
||||||
|
<span>{{ session_model.display_label }}</span>
|
||||||
|
<span style="flex:1"></span>
|
||||||
|
{% if prev_session_id %}
|
||||||
|
<a class="btn btn--ghost btn--sm" href="{{ url_for('leagues.session_public_view', league_ref=league.url_ref, session_id=prev_session_id) }}">← Prev</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if next_session_id %}
|
||||||
|
<a class="btn btn--ghost btn--sm" href="{{ url_for('leagues.session_public_view', league_ref=league.url_ref, session_id=next_session_id) }}">Next →</a>
|
||||||
|
{% endif %}
|
||||||
|
<button class="btn btn--outline btn--sm" id="copyLinkBtn" type="button" onclick="copySessionLink(this)">Share</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hero" style="padding-top:0;">
|
||||||
|
<p class="hero__kicker session-number">Session #{{ "%03d"|format(session_number) }}</p>
|
||||||
|
<h1 class="hero__title">{{ session_model.display_label }}</h1>
|
||||||
|
<p class="hero__sub">{{ session.entries|length }} player{{ 's' if session.entries|length != 1 else '' }} · <span class="status-badge status-{{ session_model.status }}">{{ session_model.status }}</span></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recon cards -->
|
||||||
|
<div class="recon-grid" style="margin-bottom:20px;">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="recon-card-head">
|
||||||
|
<span class="kicker kicker--dot kicker--dot-accent">Banker cash flow</span>
|
||||||
|
</div>
|
||||||
|
<div class="recon-metric-grid">
|
||||||
|
<div class="recon-cell">
|
||||||
|
<span class="kicker" style="font-size:10px;">Actual cash in</span>
|
||||||
|
<div class="recon-val recon-val--num">{{ session.total_real_cash_in_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="recon-cell">
|
||||||
|
<span class="kicker" style="font-size:10px;">Gross player claim</span>
|
||||||
|
<div class="recon-val recon-val--num">{{ session.total_gross_payout_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="recon-cell">
|
||||||
|
<span class="kicker" style="font-size:10px;">Cash paid out</span>
|
||||||
|
<div class="recon-val recon-val--pos">{{ session.total_real_cash_out_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="recon-cell">
|
||||||
|
<span class="kicker" style="font-size:10px;">Rolled out</span>
|
||||||
|
<div class="recon-val" style="color:var(--rolled)">{{ session.total_rollover_out_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="recon-card-head">
|
||||||
|
<span class="kicker kicker--dot kicker--dot-warn">Open items</span>
|
||||||
|
</div>
|
||||||
|
<div class="recon-metric-grid">
|
||||||
|
<div class="recon-cell">
|
||||||
|
<span class="kicker" style="font-size:10px;">House owes players</span>
|
||||||
|
<div class="recon-val {{ 'recon-val--neg' if session.total_current_due_to_player_cents > 0 else 'recon-val--pos' }}">
|
||||||
|
{{ session.total_current_due_to_player_cents | money }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="recon-cell">
|
||||||
|
<span class="kicker" style="font-size:10px;">Players owe house</span>
|
||||||
|
<div class="recon-val {{ 'recon-val--warn' if session.total_current_due_to_house_cents > 0 else '' }}">
|
||||||
|
{{ session.total_current_due_to_house_cents | money }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="recon-cell">
|
||||||
|
<span class="kicker" style="font-size:10px;">Cash debt repaid</span>
|
||||||
|
<div class="recon-val" style="color:var(--pos)">{{ session.total_debt_repayment_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="recon-cell">
|
||||||
|
<span class="kicker" style="font-size:10px;">Written off</span>
|
||||||
|
<div class="recon-val" style="color:var(--neg)">{{ session.total_writeoff_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Player totals table -->
|
||||||
|
<div class="panel" style="margin-bottom:20px;">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<span class="kicker" style="margin:0;">Results</span>
|
||||||
|
<h2 class="panel__title">Player totals</h2>
|
||||||
|
</div>
|
||||||
|
<div class="segmented" id="viewToggle">
|
||||||
|
<button class="seg is-on" data-view="compact">Compact</button>
|
||||||
|
<button class="seg" data-view="full">Full ledger</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tbl-scroll">
|
||||||
|
<table class="tbl tbl--session" id="sessionTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-player">Player</th>
|
||||||
|
<th>Invested</th>
|
||||||
|
<th>Gross cashout</th>
|
||||||
|
<th>Net</th>
|
||||||
|
<th>Paid out</th>
|
||||||
|
<th>Open settlement</th>
|
||||||
|
<th>Settlement status</th>
|
||||||
|
<th class="col-acct">Front</th>
|
||||||
|
<th class="col-acct">Rolled in</th>
|
||||||
|
<th class="col-acct">Payout carry-in</th>
|
||||||
|
<th class="col-acct">Gross player claim</th>
|
||||||
|
<th class="col-acct">Rolled out</th>
|
||||||
|
<th class="col-acct">Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for entry in session.entries %}
|
||||||
|
<tr>
|
||||||
|
<td class="col-player">{{ entry.player_name }}</td>
|
||||||
|
<td>{{ entry.invested_cents | money }}</td>
|
||||||
|
<td>{{ entry.cash_out_cents | money }}</td>
|
||||||
|
<td class="num-net {{ 'num-pos' if entry.net_cents > 0 else 'num-neg' if entry.net_cents < 0 else 'num-muted' }}">
|
||||||
|
{{ '+' if entry.net_cents > 0 else '' }}{{ entry.net_cents | money }}
|
||||||
|
</td>
|
||||||
|
<td style="color:var(--pos)">{{ entry.paid_out_cents | money }}</td>
|
||||||
|
<td>
|
||||||
|
{% if entry.current_due_to_house_cents > 0 %}
|
||||||
|
<span style="color:var(--owes)">owes {{ entry.current_due_to_house_cents | money }}</span>
|
||||||
|
{% elif entry.current_due_to_player_cents > 0 %}
|
||||||
|
<span style="color:var(--neg)">due {{ entry.current_due_to_player_cents | money }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="num-muted">{{ 0 | money }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td><span class="badge badge--{{ entry.payout_status }}">{{ entry.payout_status }}</span></td>
|
||||||
|
<td class="col-acct" style="color:var(--warn)">{{ entry.front_cents | money }}</td>
|
||||||
|
<td class="col-acct" style="color:var(--rolled)">{{ entry.rollover_in_cents | money }}</td>
|
||||||
|
<td class="col-acct" style="color:var(--rolled)">{{ entry.payout_carry_in_cents | money }}</td>
|
||||||
|
<td class="col-acct">
|
||||||
|
{% if entry.gross_due_to_house_cents > 0 %}—{% else %}{{ entry.gross_payout_cents | money }}{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="col-acct" style="color:var(--rolled)">{{ entry.rollover_out_cents | money }}</td>
|
||||||
|
<td class="col-acct" style="text-align:left;font-family:var(--font-ui);">
|
||||||
|
{% if entry.notes %}
|
||||||
|
<ul class="note-list">{% for n in entry.notes %}<li>{{ n }}</li>{% endfor %}</ul>
|
||||||
|
{% else %}
|
||||||
|
<span style="color:var(--faintest-2)">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="13" style="text-align:left;padding:28px;color:var(--muted);">No player events yet.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Net bars + snapshot -->
|
||||||
|
<div class="split">
|
||||||
|
<div class="panel col-main">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<span class="kicker" style="margin:0;">Breakdown</span>
|
||||||
|
<h2 class="panel__title">Net by player</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<div class="session-net-bars" id="sessionBreakdownChart" aria-label="Net by player"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel col-side">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<span class="kicker" style="margin:0;">Quick read</span>
|
||||||
|
<h2 class="panel__title">Session snapshot</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% set winners = session.entries | selectattr('net_cents', 'gt', 0) | list %}
|
||||||
|
{% set losers = session.entries | selectattr('net_cents', 'lt', 0) | list %}
|
||||||
|
<div class="snapshot-grid">
|
||||||
|
<div class="snapshot-cell">
|
||||||
|
<span class="kicker" style="font-size:10px;">Biggest winner</span>
|
||||||
|
<div class="snapshot-val" style="color:var(--pos)">
|
||||||
|
{% if winners %}{{ (winners | sort(attribute='net_cents', reverse=True) | first).net_cents | money }}{% else %}—{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if winners %}<div style="font-size:12px;color:var(--muted);margin-top:4px;">{{ winners | sort(attribute='net_cents', reverse=True) | first | attr('player_name') }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="snapshot-cell">
|
||||||
|
<span class="kicker" style="font-size:10px;">Biggest loser</span>
|
||||||
|
<div class="snapshot-val" style="color:var(--neg)">
|
||||||
|
{% if losers %}{{ (losers | sort(attribute='net_cents') | first).net_cents | money }}{% else %}—{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if losers %}<div style="font-size:12px;color:var(--muted);margin-top:4px;">{{ losers | sort(attribute='net_cents') | first | attr('player_name') }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="snapshot-cell">
|
||||||
|
<span class="kicker" style="font-size:10px;">Gross cashout result</span>
|
||||||
|
<div class="snapshot-val">{{ session.total_cash_out_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="snapshot-cell">
|
||||||
|
<span class="kicker" style="font-size:10px;">Open items</span>
|
||||||
|
<div class="snapshot-val {{ 'recon-val--neg' if (session.total_current_due_to_player_cents + session.total_current_due_to_house_cents) > 0 else '' }}">
|
||||||
|
{{ (session.total_current_due_to_player_cents + session.total_current_due_to_house_cents) | money }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const sessionChartData = {{ chart_data | tojson }};
|
||||||
|
const breakdownCtx = document.getElementById('sessionBreakdownChart');
|
||||||
|
if (breakdownCtx) {
|
||||||
|
const values = sessionChartData.net_values || [];
|
||||||
|
const labels = sessionChartData.labels || [];
|
||||||
|
const colors = sessionChartData.net_colors || [];
|
||||||
|
const maxAbs = Math.max(...values.map(v => Math.abs(v)), 1);
|
||||||
|
const fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
|
||||||
|
const esc = v => String(v).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
breakdownCtx.innerHTML = labels.map((label, i) => {
|
||||||
|
const val = Number(values[i] || 0);
|
||||||
|
const w = Math.min(Math.abs(val) / maxAbs * 50, 50);
|
||||||
|
const cls = val < 0 ? 'session-net-bars__bar--neg' : 'session-net-bars__bar--pos';
|
||||||
|
const amt = `${val > 0 ? '+' : ''}${fmt.format(val)}`;
|
||||||
|
const color = colors[i] || (val < 0 ? '#e0758a' : val > 0 ? '#6fc093' : '#84828e');
|
||||||
|
return `<div class="session-net-bars__row">
|
||||||
|
<div class="session-net-bars__name">${esc(label)}</div>
|
||||||
|
<div class="session-net-bars__track"><span class="session-net-bars__bar ${cls}" style="width:${w}%;background:${color};"></span></div>
|
||||||
|
<div class="session-net-bars__amount">${amt}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('#viewToggle .seg').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('#viewToggle .seg').forEach(b => b.classList.remove('is-on'));
|
||||||
|
btn.classList.add('is-on');
|
||||||
|
document.getElementById('sessionTable').classList.toggle('is-full', btn.dataset.view === 'full');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function copySessionLink(btn) {
|
||||||
|
navigator.clipboard.writeText(window.location.href).then(function() {
|
||||||
|
var orig = btn.textContent;
|
||||||
|
btn.textContent = 'Copied!';
|
||||||
|
btn.disabled = true;
|
||||||
|
setTimeout(function() { btn.textContent = orig; btn.disabled = false; }, 2000);
|
||||||
|
}).catch(function() {
|
||||||
|
// Fallback for non-HTTPS or denied clipboard
|
||||||
|
var ta = document.createElement('textarea');
|
||||||
|
ta.value = window.location.href;
|
||||||
|
ta.style.position = 'fixed';
|
||||||
|
ta.style.opacity = '0';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
var orig = btn.textContent;
|
||||||
|
btn.textContent = 'Copied!';
|
||||||
|
btn.disabled = true;
|
||||||
|
setTimeout(function() { btn.textContent = orig; btn.disabled = false; }, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Sessions · {{ league.name }} · myboker.org{% endblock %}
|
||||||
|
{% block subnav %}{% include "_league_subnav.html" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="stack">
|
||||||
|
|
||||||
|
{% if can_manage %}
|
||||||
|
<form class="panel form-card" method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Add session</p>
|
||||||
|
<h2 class="panel__title" style="margin-top:4px;">New session</h2>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="status" value="open">
|
||||||
|
<div class="session-create-row">
|
||||||
|
<label>
|
||||||
|
<span>Date <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">The night the game was played. Same-day sessions automatically become S1, S2, etc.</span></span></span>
|
||||||
|
<input type="date" name="session_date" value="{{ form.session_date }}" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Label <span class="form-opt-hint">(optional)</span> <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">A short name for this session — 'Main table', 'Side game', etc. Shown in the session list alongside the date.</span></span></span>
|
||||||
|
<input type="text" name="label" value="{{ form.label or '' }}" placeholder="e.g. Main table">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Notes <span class="form-opt-hint">(optional)</span> <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">Optional context visible in the session list — venue, occasion, anything worth noting.</span></span></span>
|
||||||
|
<input type="text" name="notes" value="{{ form.notes or '' }}" placeholder="Optional context">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn--primary" type="submit">Create session</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<p class="kicker" style="margin:0;">History</p>
|
||||||
|
<h2 class="panel__title">All sessions</h2>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
|
<span class="panel__tag">{{ sessions|length }} total</span>
|
||||||
|
{% if can_manage and empty_count > 0 %}
|
||||||
|
<form method="post" action="{{ url_for('leagues.prune_empty_sessions', league_ref=league.url_ref) }}" style="margin:0;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="submit">Prune empty ({{ empty_count }})</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if sessions %}
|
||||||
|
<div>
|
||||||
|
{% for session in sessions %}
|
||||||
|
{% set sm = db_id_to_summary.get(session.id) %}
|
||||||
|
<div class="session-row session-row--{{ session.status }}">
|
||||||
|
<div class="session-row__info">
|
||||||
|
<div class="session-date-block">
|
||||||
|
<span class="session-date-block__mon">{{ session.session_date.strftime('%b') }}</span>
|
||||||
|
<span class="session-date-block__day">{{ session.session_date.strftime('%-d') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="session-row__text">
|
||||||
|
<div class="session-row__title">
|
||||||
|
<a href="{{ url_for('leagues.session_public_view', league_ref=league.url_ref, session_id=session.id) }}">{{ session.display_label }}</a>
|
||||||
|
</div>
|
||||||
|
<div class="session-row__meta">
|
||||||
|
<span>S{{ session.sequence_on_date }}</span>
|
||||||
|
{% if sm and sm.entries %}
|
||||||
|
<span>· {{ sm.entries|length }} player{{ 's' if sm.entries|length != 1 else '' }}</span>
|
||||||
|
<span>· {{ sm.total_invested_cents | money }} pot</span>
|
||||||
|
{% if sm.total_current_due_to_house_cents > 0 or sm.total_current_due_to_player_cents > 0 %}
|
||||||
|
<span style="color:var(--warn);">· {{ (sm.total_current_due_to_house_cents + sm.total_current_due_to_player_cents) | money }} open</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if session.notes %}<span>· {{ session.notes }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="session-row__actions">
|
||||||
|
<span class="status-pill status-pill--{{ session.status }}">{{ session.status }}</span>
|
||||||
|
<a class="btn btn--ghost btn--sm" href="{{ url_for('leagues.session_public_view', league_ref=league.url_ref, session_id=session.id) }}">View</a>
|
||||||
|
{% if can_manage %}
|
||||||
|
<a class="btn btn--ghost btn--sm" href="{{ url_for('leagues.session_detail', league_ref=league.url_ref, session_id=session.id) }}">Manage</a>
|
||||||
|
{% if session.status == 'open' %}
|
||||||
|
<form method="post" action="{{ url_for('leagues.close_league_session', league_ref=league.url_ref, session_id=session.id) }}" style="margin:0;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="submit">Close</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<button class="btn btn--outline btn--sm" type="button"
|
||||||
|
data-modal="modal-reopen-session"
|
||||||
|
data-reopen-action="{{ url_for('leagues.open_league_session', league_ref=league.url_ref, session_id=session.id) }}">Reopen</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-panel">
|
||||||
|
<h3 class="panel__title">No sessions yet</h3>
|
||||||
|
<p class="muted-text">Create the first session above. Same-day sessions will automatically become S1, S2, and so on.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reopen confirmation modal -->
|
||||||
|
<div class="modal-backdrop" id="modal-reopen-session" hidden>
|
||||||
|
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="modal-reopen-title">
|
||||||
|
<div class="modal-card__head">
|
||||||
|
<span class="kicker" style="margin:0;">Settled session</span>
|
||||||
|
<h2 class="panel__title" id="modal-reopen-title">Reopen this session?</h2>
|
||||||
|
</div>
|
||||||
|
<p class="modal-card__text">This session has been closed and books are assumed to be settled. Reopening will allow new ledger events to be appended and may affect settlement totals.</p>
|
||||||
|
<div class="modal-card__actions">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-close-modal>Cancel</button>
|
||||||
|
<form id="reopenForm" method="post" action="" style="margin:0;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn--primary btn--sm" type="submit">Reopen session</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var reopenModal = document.getElementById('modal-reopen-session');
|
||||||
|
var reopenForm = document.getElementById('reopenForm');
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-modal="modal-reopen-session"]').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
if (reopenForm && this.dataset.reopenAction) {
|
||||||
|
reopenForm.action = this.dataset.reopenAction;
|
||||||
|
}
|
||||||
|
if (reopenModal) reopenModal.removeAttribute('hidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-close-modal]').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
this.closest('.modal-backdrop').setAttribute('hidden', '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
reopenModal && reopenModal.addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) this.setAttribute('hidden', '');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Settings · {{ league.name }} · myboker.org{% endblock %}
|
||||||
|
{% block subnav %}{% include "_league_subnav.html" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
|
||||||
|
<div class="stack">
|
||||||
|
|
||||||
|
<form class="panel form-card" method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">General</p>
|
||||||
|
<h2 class="panel__title" style="margin-top:4px;">League info</h2>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span>Name <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">The display name for this league. Shown on your dashboard and the public leaderboard.</span></span></span>
|
||||||
|
<input type="text" name="name" value="{{ form.name }}" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Description <span class="form-opt-hint">(optional)</span> <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">Optional. A short note about the league — game night, buy-in rules, anything useful to members.</span></span></span>
|
||||||
|
<textarea name="description">{{ form.description }}</textarea>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Visibility <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">Private keeps the league off the Explore page. Public makes the leaderboard and session history visible to anyone with the link — no account needed.</span></span></span>
|
||||||
|
<select name="visibility">
|
||||||
|
<option value="private" {{ 'selected' if form.visibility == 'private' else '' }}>Private — only members can see this league</option>
|
||||||
|
<option value="public" {{ 'selected' if form.visibility == 'public' else '' }}>Public — leaderboard is visible to anyone</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn--primary" type="submit">Save changes</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<p class="kicker" style="margin:0;">Access</p>
|
||||||
|
<h2 class="panel__title">Members</h2>
|
||||||
|
</div>
|
||||||
|
<span class="panel__tag">{{ members|length }} member{{ 's' if members|length != 1 else '' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel__body" style="padding-bottom:0;">
|
||||||
|
{% for membership, member_user in members %}
|
||||||
|
<div class="member-row">
|
||||||
|
<div>
|
||||||
|
<strong>{{ member_user.email }}</strong>
|
||||||
|
<span class="kicker" style="margin-left:8px;text-transform:capitalize;">{{ membership.role }}</span>
|
||||||
|
</div>
|
||||||
|
{% if membership.role != 'owner' %}
|
||||||
|
<form method="post" action="{{ url_for('leagues.remove_member', league_ref=league.url_ref, user_id=member_user.id) }}" style="margin:0;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="submit">Remove</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<form class="form-card" method="post" action="{{ url_for('leagues.invite_member', league_ref=league.url_ref) }}" style="padding:16px 20px 20px;border-top:1px solid var(--border);">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<p class="kicker" style="margin-bottom:10px;">Invite by email</p>
|
||||||
|
<div class="session-create-row" style="align-items:flex-end;gap:10px;">
|
||||||
|
<label style="flex:1 1 200px;">
|
||||||
|
<span>Email</span>
|
||||||
|
<input type="email" name="email" placeholder="friend@example.com" required>
|
||||||
|
</label>
|
||||||
|
<label style="flex:0 0 130px;">
|
||||||
|
<span>Role</span>
|
||||||
|
<select name="role">
|
||||||
|
<option value="manager">Manager</option>
|
||||||
|
<option value="viewer">Viewer</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn--primary btn--sm" type="submit" style="flex:0 0 auto;margin-bottom:1px;">Send invite</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<p class="kicker" style="margin:0;color:var(--neg);">Danger zone</p>
|
||||||
|
<h2 class="panel__title">Destructive actions</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel__body">
|
||||||
|
<div class="danger-row">
|
||||||
|
<div>
|
||||||
|
<strong class="danger-row__title">Archive league</strong>
|
||||||
|
<p class="muted-text">Removes this league from your dashboard. All players, sessions, and ledger data are preserved.</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-modal="modal-archive">Archive league</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-backdrop" id="modal-archive" hidden>
|
||||||
|
<div class="modal-card">
|
||||||
|
<div class="modal-card__head">
|
||||||
|
<h2 style="font-size:18px;margin:0;">Archive league</h2>
|
||||||
|
</div>
|
||||||
|
<p class="modal-card__text">
|
||||||
|
<strong>{{ league.name }}</strong> will be hidden from your dashboard. All data is preserved and can be recovered by contacting support. This does not delete any sessions or ledger events.<br><br>
|
||||||
|
Type the league name to confirm:
|
||||||
|
</p>
|
||||||
|
<form method="post" action="{{ url_for('leagues.archive_league', league_ref=league.url_ref) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div style="padding:0 20px 16px;">
|
||||||
|
<input
|
||||||
|
class="modal-confirm-input"
|
||||||
|
type="text"
|
||||||
|
name="confirm_name"
|
||||||
|
placeholder="{{ league.name }}"
|
||||||
|
autocomplete="off"
|
||||||
|
data-match="{{ league.name }}"
|
||||||
|
data-target="archive-confirm-btn"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="modal-card__actions">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-close-modal>Cancel</button>
|
||||||
|
<button class="btn btn--sm btn--danger" id="archive-confirm-btn" type="submit" disabled>Archive league</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
function openModal(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) {
|
||||||
|
el.removeAttribute('hidden');
|
||||||
|
const inp = el.querySelector('.modal-confirm-input, input[type="password"]');
|
||||||
|
if (inp) { inp.value = ''; inp.focus(); checkConfirmInput(inp); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function closeModal(el) {
|
||||||
|
el.closest('.modal-backdrop').setAttribute('hidden', '');
|
||||||
|
}
|
||||||
|
function checkConfirmInput(inp) {
|
||||||
|
const targetId = inp.dataset.target;
|
||||||
|
const btn = document.getElementById(targetId);
|
||||||
|
if (!btn) return;
|
||||||
|
if (inp.dataset.match) {
|
||||||
|
btn.disabled = inp.value !== inp.dataset.match;
|
||||||
|
} else if (inp.dataset.minlength) {
|
||||||
|
btn.disabled = inp.value.length < parseInt(inp.dataset.minlength, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-modal]').forEach(trigger => {
|
||||||
|
trigger.addEventListener('click', () => openModal(trigger.dataset.modal));
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-close-modal]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => closeModal(btn));
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.modal-backdrop').forEach(backdrop => {
|
||||||
|
backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.setAttribute('hidden', ''); });
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.modal-confirm-input').forEach(inp => {
|
||||||
|
inp.addEventListener('input', () => checkConfirmInput(inp));
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Leagues · myboker.org{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="hero" style="padding-bottom:12px;">
|
||||||
|
<p class="hero__kicker">Leagues</p>
|
||||||
|
<h1 class="hero__title">Your poker leagues</h1>
|
||||||
|
<p class="hero__sub">Owner and manager workspace.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar__left">
|
||||||
|
<a class="btn btn--primary" href="{{ url_for('leagues.new') }}">New league</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if leagues %}
|
||||||
|
<div class="league-hub-summary">
|
||||||
|
<div class="stat stat--rail" style="--rail:var(--accent);">
|
||||||
|
<span class="kicker">Leagues</span>
|
||||||
|
<div class="stat__val" style="color:var(--num)">{{ page_summary.league_count }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat stat--rail" style="--rail:var(--pos);">
|
||||||
|
<span class="kicker">Live</span>
|
||||||
|
<div class="stat__val" style="color:var(--num)">{{ page_summary.live_count }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat stat--rail" style="--rail:var(--rolled);">
|
||||||
|
<span class="kicker">Sessions</span>
|
||||||
|
<div class="stat__val" style="color:var(--num)">{{ page_summary.sessions_count }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat stat--rail" style="--rail:var(--warn);">
|
||||||
|
<span class="kicker">Open items</span>
|
||||||
|
<div class="stat__val {{ 'num-neg' if page_summary.open_items_cents > 0 else 'num-muted' }}">{{ page_summary.open_items_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="league-board">
|
||||||
|
{% for item in leagues %}
|
||||||
|
{% set league = item.league %}
|
||||||
|
{% set membership = item.membership %}
|
||||||
|
<article class="panel league-row">
|
||||||
|
<div class="league-row__identity">
|
||||||
|
<a class="league-row__title" href="{{ url_for('leagues.dashboard', league_ref=league.url_ref) }}">
|
||||||
|
<div>
|
||||||
|
<span class="kicker">{{ membership.role }}</span>
|
||||||
|
<h2>{{ league.name }}</h2>
|
||||||
|
</div>
|
||||||
|
{% if item.live_session %}
|
||||||
|
<span class="live-badge">{{ session_label(item.live_session) }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status-pill status-pill--closed">idle</span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
<div class="league-row__chips">
|
||||||
|
<span class="db-chip {% if league.visibility == 'public' %}db-chip--public{% endif %}">
|
||||||
|
{% if league.visibility == 'public' %}
|
||||||
|
<svg width="11" height="11" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||||
|
<circle cx="10" cy="10" r="8" stroke="currentColor" stroke-width="1.6"/>
|
||||||
|
<path d="M10 2c-2.5 3-2.5 13 0 16" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" fill="none"/>
|
||||||
|
<path d="M10 2c2.5 3 2.5 13 0 16" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" fill="none"/>
|
||||||
|
<path d="M2.5 10h15" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
public
|
||||||
|
{% else %}
|
||||||
|
<svg width="10" height="12" viewBox="0 0 10 12" fill="none" aria-hidden="true">
|
||||||
|
<rect x="1" y="5" width="8" height="6.5" rx="1.5" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
<path d="M3 5V3.5A2 2 0 0 1 7 3.5V5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
private
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="league-row__desc">
|
||||||
|
{% if league.description %}{{ league.description }}{% else %}{{ league.slug }}{% endif %}
|
||||||
|
</p>
|
||||||
|
<div class="league-row__meta">
|
||||||
|
{% if item.latest_session %}
|
||||||
|
<span>Latest {{ session_label(item.latest_session) }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span>No sessions yet</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.leader %}
|
||||||
|
<span>Leader {{ item.leader.player_name }} · {{ item.leader.total_net_cents | money }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="league-row__metrics">
|
||||||
|
<div><span>Players</span><strong>{{ item.players_count }}</strong></div>
|
||||||
|
<div><span>Sessions</span><strong>{{ item.sessions_count }}</strong></div>
|
||||||
|
<div><span>Paid out</span><strong>{{ item.cash_paid_out_cents | money }}</strong></div>
|
||||||
|
<div><span>Open</span><strong class="{{ 'num-neg' if item.open_items_cents > 0 else 'num-muted' }}">{{ item.open_items_cents | money }}</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="league-row__leaders">
|
||||||
|
<div class="league-row__section-head">
|
||||||
|
<span class="kicker">Top 3</span>
|
||||||
|
<a href="{{ url_for('leagues.leaderboard', league_ref=league.url_ref) }}">Full board</a>
|
||||||
|
</div>
|
||||||
|
{% if item.top_players %}
|
||||||
|
<ol class="league-top-list">
|
||||||
|
{% for player in item.top_players %}
|
||||||
|
<li>
|
||||||
|
<span>{{ player.player_name }}</span>
|
||||||
|
<strong class="{{ 'num-pos' if player.total_net_cents > 0 else 'num-neg' if player.total_net_cents < 0 else 'num-muted' }}">
|
||||||
|
{{ '+' if player.total_net_cents > 0 else '' }}{{ player.total_net_cents | money }}
|
||||||
|
</strong>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
{% else %}
|
||||||
|
<div class="league-row__empty">
|
||||||
|
<span>No results yet</span>
|
||||||
|
<a href="{{ url_for('leagues.sessions', league_ref=league.url_ref) }}">Create a session</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="league-row__actions">
|
||||||
|
<a class="btn btn--primary btn--sm" href="{{ url_for('leagues.dashboard', league_ref=league.url_ref) }}">Open</a>
|
||||||
|
<a class="btn btn--ghost btn--sm" href="{{ url_for('leagues.sessions', league_ref=league.url_ref) }}">Sessions</a>
|
||||||
|
<a class="btn btn--ghost btn--sm" href="{{ url_for('leagues.ledger', league_ref=league.url_ref) }}">Ledger</a>
|
||||||
|
<a class="btn btn--ghost btn--sm" href="{{ url_for('leagues.players', league_ref=league.url_ref) }}">Players</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="panel empty-panel">
|
||||||
|
<h2 class="panel__title">No leagues yet</h2>
|
||||||
|
<p class="muted-text">Create the first league to start adding players, sessions, and ledger events.</p>
|
||||||
|
<a class="btn btn--primary" href="{{ url_for('leagues.new') }}">Create league</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Reset Password · myboker.org{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="form-shell narrow">
|
||||||
|
<form class="panel form-card" method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Account recovery</p>
|
||||||
|
<h1 style="font-size:28px;margin-bottom:8px;">Reset password</h1>
|
||||||
|
<p class="muted-text" style="margin-bottom:8px;">Enter a new password for your account.</p>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span>New password</span>
|
||||||
|
<input type="password" name="new_password" autocomplete="new-password" minlength="8" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Confirm new password</span>
|
||||||
|
<input type="password" name="confirm_password" autocomplete="new-password" minlength="8" required>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn--primary" type="submit">Reset password</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import re
|
||||||
|
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
|
|
||||||
@@ -11,6 +12,11 @@ from models import SessionEntry, SessionSummary
|
|||||||
BREAK_EVEN_TOLERANCE_CENTS = 100
|
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:
|
def cents_to_dollars(cents: int) -> Markup:
|
||||||
value = cents / 100
|
value = cents / 100
|
||||||
return Markup(f'<span class="currency-symbol">$</span>{value:,.2f}')
|
return Markup(f'<span class="currency-symbol">$</span>{value:,.2f}')
|
||||||
|
|||||||
Reference in new issue
Block a user