diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..6634400
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,25 @@
+# Secret key for signing session cookies — use a long random string in production
+SECRET_KEY=change-me-to-a-long-random-string
+
+# Email address of the admin user
+ADMIN_EMAIL=admin@example.com
+
+# Base URL used to build magic links (no trailing slash)
+SITE_URL=http://localhost:8000
+
+# development | production
+ENV=development
+
+# Path to the SQLite database file (inside the container)
+DATABASE_PATH=/data/skinspins.db
+
+# SMTP email settings (leave SMTP_HOST empty to disable email and log links instead)
+SMTP_HOST=smtp.example.com
+SMTP_PORT=587
+SMTP_USER=your@email.com
+SMTP_PASSWORD=your-smtp-password
+SMTP_FROM=noreply@example.com
+# Use implicit SSL/TLS (port 465). Set SMTP_TLS=false when using SSL.
+SMTP_SSL=false
+# Use STARTTLS (port 587). Set to "false" when using SSL.
+SMTP_TLS=true
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9ea68b9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+__pycache__/
+*.pyc
+*.pyo
+.env
+data/
+.DS_Store
diff --git a/CLAUDE.md b/CLAUDE.md
index ea6e0ae..1be0e89 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,5 +4,45 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## What This Is
-Skins&Pins is a social golf score tracking PWA. Multiple players in a group track scores per hole in real-time. Anyone in a group can enter or edit scores for any player. Built as a self-hosted Python app running in Docker Compose with a single PostgreSQL database and traefik as a Proxy. The PWA has optimizations for Mobile use.
+Skins&Pins is a social golf score tracking PWA. Multiple players in a group track scores per hole in real-time. Anyone in a group can enter or edit scores for any player. Built as a self-hosted Python app running in Docker Compose with a single SQLite database and traefik as a Proxy. The PWA has optimizations for Mobile use.
+
+## Language
+
+All backend code must be written in Python. Do not introduce other backend languages.
+
+## Features
+
+- **Navigation:** Bottom navigation bar with large icons, optimized for thumb reach on mobile
+- **Authentication:** Passwordless login via magic link (email weblink); in development the magic link is printed to the log instead of being emailed
+- **Admin user:** Single admin user defined by email address in the `.env` file; has access to an additional Admin menu not visible to regular users
+- **User invitations:** Admin invites users by email address via the Admin menu; only invited users can log in
+- **Onboarding:** On first login, user sets their Name, Handicap, and Avatar
+- **Profile:** Name, Handicap, and Avatar can be updated at any time via a Profile menu
+- **Dashboard:** Landing page after login; shows the user's last played rounds/games with results and statistics derived from game data (e.g. scores, skins won, handicap trend, best/worst holes)
+- **Game formats:** Architecture must support multiple formats. Only one format is implemented initially: Bingo Bango Bongo
+- **Round length:** Players choose 9 or 18 holes when starting a game
+- **Hole skipping:** Players can skip a hole; skipped holes are excluded from scoring
+- **Score entry UI:** A swipe gesture toggles between the score entry view (current hole) and the score overview (all holes, running totals)
+
+## Game Format: Bingo Bango Bongo
+
+Three points are available per hole:
+- **Bingo:** First player to get their ball on the green
+- **Bango:** Player whose ball is closest to the pin once all balls are on the green; if multiple players are equidistant the point is split equally between them — Bango must support awarding fractional points to multiple players
+- **Bongo:** First player to hole out
+
+Players play in order of furthest from the hole (standard golf rules). Any number of players can participate. Points are tallied across all 18 holes; highest total wins.
+
+## Design Principles
+
+- Simple and clean — prefer straightforward solutions over clever ones
+- No unnecessary functions, abstractions, or helpers
+- Only build what is needed for the current feature; do not design for hypothetical future requirements
+
+## Stack
+
+- **Backend:** FastAPI (async, ASGI via Uvicorn)
+- **Database:** SQLite
+- **Frontend:** HTMX + Alpine.js + Jinja2 templates (no build step, no npm)
+- **Real-time:** WebSocket for live score updates across players in the same group
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..60ea61d
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,11 @@
+FROM python:3.12-slim
+
+WORKDIR /app
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY app/ ./app/
+COPY static/ ./static/
+
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/app/__init__.py b/app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/auth.py b/app/auth.py
new file mode 100644
index 0000000..ffc39cc
--- /dev/null
+++ b/app/auth.py
@@ -0,0 +1,93 @@
+import os
+import secrets
+import logging
+from datetime import datetime, timedelta, timezone
+
+import aiosqlite
+
+from app.email import send_magic_link
+
+logger = logging.getLogger(__name__)
+
+SITE_URL = os.getenv("SITE_URL", "http://localhost:8000")
+ENV = os.getenv("ENV", "development")
+TOKEN_TTL_MINUTES = 15
+SESSION_TTL_DAYS = 30
+
+
+def _now() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def _in(minutes: int = 0, days: int = 0) -> str:
+ delta = timedelta(minutes=minutes, days=days)
+ return (datetime.now(timezone.utc) + delta).isoformat()
+
+
+def _expired(iso: str) -> bool:
+ return datetime.now(timezone.utc) > datetime.fromisoformat(iso)
+
+
+async def create_magic_token(email: str, db: aiosqlite.Connection) -> str:
+ # Clean up old unused tokens for this email
+ await db.execute(
+ "DELETE FROM magic_link_tokens WHERE email = ? AND used_at IS NULL",
+ (email,),
+ )
+
+ token_id = secrets.token_urlsafe(16)
+ token = secrets.token_urlsafe(32)
+ expires_at = _in(minutes=TOKEN_TTL_MINUTES)
+
+ await db.execute(
+ "INSERT INTO magic_link_tokens (id, email, token, expires_at) VALUES (?, ?, ?, ?)",
+ (token_id, email, token, expires_at),
+ )
+ await db.commit()
+
+ link = f"{SITE_URL}/auth/verify?token={token}"
+ await send_magic_link(email, link)
+ return token
+
+
+async def verify_magic_token(token: str, db: aiosqlite.Connection):
+ """Returns the email if token is valid and unused, else None."""
+ async with db.execute(
+ "SELECT email, expires_at, used_at FROM magic_link_tokens WHERE token = ?",
+ (token,),
+ ) as cursor:
+ row = await cursor.fetchone()
+
+ if row is None:
+ return None
+ if row["used_at"] is not None:
+ return None
+ if _expired(row["expires_at"]):
+ return None
+
+ # Mark token as used
+ await db.execute(
+ "UPDATE magic_link_tokens SET used_at = ? WHERE token = ?",
+ (_now(), token),
+ )
+ await db.commit()
+
+ return row["email"]
+
+
+async def create_session(user_id: str, db: aiosqlite.Connection) -> str:
+ session_id = secrets.token_urlsafe(16)
+ token = secrets.token_urlsafe(32)
+ expires_at = _in(days=SESSION_TTL_DAYS)
+
+ await db.execute(
+ "INSERT INTO sessions (id, user_id, token, expires_at, created_at) VALUES (?, ?, ?, ?, ?)",
+ (session_id, user_id, token, expires_at, _now()),
+ )
+ await db.commit()
+ return token
+
+
+async def delete_session(token: str, db: aiosqlite.Connection):
+ await db.execute("DELETE FROM sessions WHERE token = ?", (token,))
+ await db.commit()
diff --git a/app/avatar.py b/app/avatar.py
new file mode 100644
index 0000000..8c2b815
--- /dev/null
+++ b/app/avatar.py
@@ -0,0 +1,29 @@
+import os
+from pathlib import Path
+
+from fastapi import UploadFile
+
+AVATARS_DIR = Path(os.getenv("DATABASE_PATH", "/data/skinspins.db")).parent / "avatars"
+ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
+MAX_SIZE = 2 * 1024 * 1024 # 2 MB
+
+
+async def save_avatar(file: UploadFile, user_id: str) -> tuple[str | None, str | None]:
+ """Returns (url_path, error). url_path is like /avatars/user_id.jpg"""
+ if file.content_type not in ALLOWED_TYPES:
+ return None, "Only JPEG, PNG, WebP or GIF images are allowed."
+
+ data = await file.read()
+ if len(data) > MAX_SIZE:
+ return None, "Image must be smaller than 2 MB."
+
+ ext = file.content_type.split("/")[-1].replace("jpeg", "jpg")
+ filename = f"{user_id}.{ext}"
+ dest = AVATARS_DIR / filename
+
+ # Remove any previous avatar for this user
+ for old in AVATARS_DIR.glob(f"{user_id}.*"):
+ old.unlink(missing_ok=True)
+
+ dest.write_bytes(data)
+ return f"/avatars/{filename}", None
diff --git a/app/database.py b/app/database.py
new file mode 100644
index 0000000..793a5fb
--- /dev/null
+++ b/app/database.py
@@ -0,0 +1,128 @@
+import os
+import aiosqlite
+
+DATABASE_PATH = os.getenv("DATABASE_PATH", "/data/skinspins.db")
+
+
+async def get_db():
+ async with aiosqlite.connect(DATABASE_PATH) as db:
+ db.row_factory = aiosqlite.Row
+ yield db
+
+
+async def init_db():
+ async with aiosqlite.connect(DATABASE_PATH) as db:
+ await db.executescript("""
+ CREATE TABLE IF NOT EXISTS users (
+ id TEXT PRIMARY KEY,
+ email TEXT UNIQUE NOT NULL,
+ name TEXT,
+ handicap REAL,
+ avatar TEXT,
+ is_onboarded INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS invitations (
+ id TEXT PRIMARY KEY,
+ email TEXT UNIQUE NOT NULL,
+ invited_by TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ FOREIGN KEY (invited_by) REFERENCES users(id)
+ );
+
+ CREATE TABLE IF NOT EXISTS magic_link_tokens (
+ id TEXT PRIMARY KEY,
+ email TEXT NOT NULL,
+ token TEXT UNIQUE NOT NULL,
+ expires_at TEXT NOT NULL,
+ used_at TEXT
+ );
+
+ CREATE TABLE IF NOT EXISTS sessions (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL,
+ token TEXT UNIQUE NOT NULL,
+ expires_at TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ FOREIGN KEY (user_id) REFERENCES users(id)
+ );
+
+ CREATE TABLE IF NOT EXISTS courses (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ holes INTEGER NOT NULL DEFAULT 18,
+ location TEXT,
+ created_at TEXT NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS course_holes (
+ id TEXT PRIMARY KEY,
+ course_id TEXT NOT NULL,
+ hole_number INTEGER NOT NULL,
+ par INTEGER NOT NULL DEFAULT 4,
+ UNIQUE (course_id, hole_number),
+ FOREIGN KEY (course_id) REFERENCES courses(id)
+ );
+
+ CREATE TABLE IF NOT EXISTS course_tees (
+ id TEXT PRIMARY KEY,
+ course_id TEXT NOT NULL,
+ name TEXT NOT NULL,
+ slope INTEGER NOT NULL,
+ rating REAL NOT NULL,
+ FOREIGN KEY (course_id) REFERENCES courses(id)
+ );
+
+ CREATE TABLE IF NOT EXISTS games (
+ id TEXT PRIMARY KEY,
+ format TEXT NOT NULL DEFAULT 'bingo_bango_bongo',
+ holes_count INTEGER NOT NULL,
+ status TEXT NOT NULL DEFAULT 'lobby',
+ created_by TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ course_id TEXT,
+ FOREIGN KEY (created_by) REFERENCES users(id),
+ FOREIGN KEY (course_id) REFERENCES courses(id)
+ );
+
+ CREATE TABLE IF NOT EXISTS game_players (
+ id TEXT PRIMARY KEY,
+ game_id TEXT NOT NULL,
+ user_id TEXT NOT NULL,
+ handicap_snapshot REAL,
+ joined_at TEXT NOT NULL,
+ UNIQUE (game_id, user_id),
+ FOREIGN KEY (game_id) REFERENCES games(id),
+ FOREIGN KEY (user_id) REFERENCES users(id)
+ );
+
+ CREATE TABLE IF NOT EXISTS hole_scores (
+ id TEXT PRIMARY KEY,
+ game_id TEXT NOT NULL,
+ hole_number INTEGER NOT NULL,
+ user_id TEXT NOT NULL,
+ strokes INTEGER,
+ bingo INTEGER NOT NULL DEFAULT 0,
+ bango REAL NOT NULL DEFAULT 0.0,
+ bongo INTEGER NOT NULL DEFAULT 0,
+ updated_by TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ UNIQUE (game_id, hole_number, user_id),
+ FOREIGN KEY (game_id) REFERENCES games(id),
+ FOREIGN KEY (user_id) REFERENCES users(id),
+ FOREIGN KEY (updated_by) REFERENCES users(id)
+ );
+ """)
+ await db.commit()
+ # Migrations for columns added after initial release
+ for migration in [
+ "ALTER TABLE games ADD COLUMN course_id TEXT REFERENCES courses(id)",
+ "ALTER TABLE game_players ADD COLUMN tee_id TEXT REFERENCES course_tees(id)",
+ "ALTER TABLE course_holes ADD COLUMN stroke_index INTEGER",
+ ]:
+ try:
+ await db.execute(migration)
+ await db.commit()
+ except Exception:
+ pass # column already exists
diff --git a/app/dependencies.py b/app/dependencies.py
new file mode 100644
index 0000000..706a692
--- /dev/null
+++ b/app/dependencies.py
@@ -0,0 +1,55 @@
+import os
+from datetime import datetime, timezone
+
+from fastapi import Request, Depends
+import aiosqlite
+
+from app.database import get_db
+
+ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "")
+SESSION_COOKIE = "session"
+
+
+class NeedsLogin(Exception):
+ pass
+
+
+class NeedsOnboarding(Exception):
+ pass
+
+
+class NeedsAdmin(Exception):
+ pass
+
+
+async def get_current_user(request: Request, db: aiosqlite.Connection = Depends(get_db)):
+ token = request.cookies.get(SESSION_COOKIE)
+ if not token:
+ raise NeedsLogin()
+
+ async with db.execute(
+ """
+ SELECT u.id, u.email, u.name, u.handicap, u.avatar, u.is_onboarded
+ FROM sessions s
+ JOIN users u ON s.user_id = u.id
+ WHERE s.token = ? AND s.expires_at > ?
+ """,
+ (token, datetime.now(timezone.utc).isoformat()),
+ ) as cursor:
+ row = await cursor.fetchone()
+
+ if row is None:
+ raise NeedsLogin()
+
+ user = dict(row)
+
+ if not user["is_onboarded"] and request.url.path not in ("/onboarding", "/logout"):
+ raise NeedsOnboarding()
+
+ return user
+
+
+async def require_admin(user: dict = Depends(get_current_user)):
+ if user["email"] != ADMIN_EMAIL:
+ raise NeedsAdmin()
+ return user
diff --git a/app/email.py b/app/email.py
new file mode 100644
index 0000000..db21332
--- /dev/null
+++ b/app/email.py
@@ -0,0 +1,96 @@
+"""Async email sending via SMTP. Falls back to logging if SMTP is not configured."""
+import logging
+import os
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+
+import aiosmtplib
+
+logger = logging.getLogger(__name__)
+
+SMTP_HOST = os.getenv("SMTP_HOST", "")
+SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
+SMTP_USER = os.getenv("SMTP_USER", "")
+SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
+SMTP_FROM = os.getenv("SMTP_FROM", SMTP_USER)
+SMTP_SSL = os.getenv("SMTP_SSL", "false").lower() == "true"
+SMTP_TLS = os.getenv("SMTP_TLS", "true").lower() != "false"
+
+SITE_NAME = "Skins & Pins"
+
+
+def _configured() -> bool:
+ return bool(SMTP_HOST and SMTP_USER and SMTP_FROM)
+
+
+async def _send(to: str, subject: str, body_text: str, body_html: str) -> None:
+ msg = MIMEMultipart("alternative")
+ msg["Subject"] = subject
+ msg["From"] = f"{SITE_NAME} <{SMTP_FROM}>"
+ msg["To"] = to
+ msg.attach(MIMEText(body_text, "plain"))
+ msg.attach(MIMEText(body_html, "html"))
+
+ await aiosmtplib.send(
+ msg,
+ hostname=SMTP_HOST,
+ port=SMTP_PORT,
+ username=SMTP_USER,
+ password=SMTP_PASSWORD,
+ use_tls=SMTP_SSL,
+ start_tls=(SMTP_TLS and not SMTP_SSL),
+ timeout=10,
+ )
+
+
+async def send_magic_link(to: str, link: str) -> None:
+ subject = f"Your {SITE_NAME} login link"
+ body_text = f"Click the link below to log in (expires in 15 minutes):\n\n{link}\n\nIf you didn't request this, you can ignore this email."
+ body_html = f"""
+
+
⛳ {SITE_NAME}
+
Click the button below to log in. This link expires in 15 minutes.
+
+
+ Log in to {SITE_NAME}
+
+
+
If the button doesn't work, copy this link: {link}
+
If you didn't request this, you can ignore this email.
+
"""
+
+ if _configured():
+ try:
+ await _send(to, subject, body_text, body_html)
+ logger.info("Magic link email sent to %s", to)
+ except Exception as e:
+ logger.error("Failed to send magic link email to %s: %s", to, e)
+ else:
+ logger.info("Magic link for %s: %s", to, link)
+
+
+async def send_invitation(to: str, invited_by_name: str, login_url: str) -> None:
+ subject = f"You've been invited to {SITE_NAME}"
+ body_text = f"{invited_by_name} has invited you to join {SITE_NAME}.\n\nLog in here:\n{login_url}"
+ body_html = f"""
+
+
⛳ {SITE_NAME}
+
{invited_by_name} has invited you to join {SITE_NAME} — a golf score tracking app.
+
+
+ Accept invitation
+
+
+
If the button doesn't work, copy this link: {login_url}
+
"""
+
+ if _configured():
+ try:
+ await _send(to, subject, body_text, body_html)
+ logger.info("Invitation email sent to %s", to)
+ except Exception as e:
+ logger.error("Failed to send invitation email to %s: %s", to, e)
+ else:
+ logger.info("Invitation for %s — login at: %s", to, login_url)
diff --git a/app/main.py b/app/main.py
new file mode 100644
index 0000000..4a50b07
--- /dev/null
+++ b/app/main.py
@@ -0,0 +1,69 @@
+import logging
+import os
+from contextlib import asynccontextmanager
+from pathlib import Path
+
+from dotenv import load_dotenv
+load_dotenv()
+
+from fastapi import FastAPI, Request, Response
+from fastapi.responses import RedirectResponse
+from fastapi.staticfiles import StaticFiles
+from starlette.middleware.sessions import SessionMiddleware
+
+from app.database import init_db
+from app.dependencies import NeedsLogin, NeedsOnboarding, NeedsAdmin
+from app.routers import auth, onboarding, dashboard, admin, profile, games, courses, users
+
+logging.basicConfig(level=logging.INFO)
+
+AVATARS_DIR = Path(os.getenv("DATABASE_PATH", "/data/skinspins.db")).parent / "avatars"
+AVATARS_DIR.mkdir(parents=True, exist_ok=True)
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ await init_db()
+ yield
+
+
+app = FastAPI(lifespan=lifespan)
+
+app.add_middleware(SessionMiddleware, secret_key=os.getenv("SECRET_KEY", "dev-secret"))
+
+app.mount("/static", StaticFiles(directory="static"), name="static")
+app.mount("/avatars", StaticFiles(directory=str(AVATARS_DIR)), name="avatars")
+
+app.include_router(auth.router)
+app.include_router(onboarding.router)
+app.include_router(dashboard.router)
+app.include_router(admin.router)
+app.include_router(profile.router)
+app.include_router(games.router)
+app.include_router(courses.router)
+app.include_router(users.router)
+
+
+@app.exception_handler(NeedsLogin)
+async def needs_login_handler(request: Request, exc: NeedsLogin):
+ response = RedirectResponse("/login", status_code=302)
+ response.delete_cookie("session")
+ return response
+
+
+@app.exception_handler(NeedsOnboarding)
+async def needs_onboarding_handler(request: Request, exc: NeedsOnboarding):
+ return RedirectResponse("/onboarding", status_code=302)
+
+
+@app.exception_handler(NeedsAdmin)
+async def needs_admin_handler(request: Request, exc: NeedsAdmin):
+ return RedirectResponse("/", status_code=302)
+
+
+@app.middleware("http")
+async def security_headers(request: Request, call_next):
+ response: Response = await call_next(request)
+ response.headers["X-Content-Type-Options"] = "nosniff"
+ response.headers["X-Frame-Options"] = "DENY"
+ return response
diff --git a/app/routers/__init__.py b/app/routers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/routers/admin.py b/app/routers/admin.py
new file mode 100644
index 0000000..df5cbe8
--- /dev/null
+++ b/app/routers/admin.py
@@ -0,0 +1,271 @@
+import os
+import secrets
+from datetime import datetime, timezone
+
+from fastapi import APIRouter, Depends, Form, Request, UploadFile, File
+from fastapi.responses import HTMLResponse, RedirectResponse
+import aiosqlite
+
+from app.database import get_db
+from app.dependencies import require_admin
+from app.email import send_invitation
+from app.templates_config import templates
+from app.routers.onboarding import AVATARS
+from app.avatar import save_avatar
+
+router = APIRouter()
+
+
+async def _list_context(db: aiosqlite.Connection):
+ async with db.execute(
+ "SELECT id, email, created_at FROM invitations ORDER BY created_at DESC"
+ ) as c:
+ invitations = await c.fetchall()
+ async with db.execute(
+ "SELECT id, email, name, avatar, handicap, created_at FROM users ORDER BY created_at DESC"
+ ) as c:
+ users = await c.fetchall()
+ return invitations, users
+
+
+@router.get("/admin", response_class=HTMLResponse)
+async def admin_page(request: Request, user: dict = Depends(require_admin),
+ db: aiosqlite.Connection = Depends(get_db)):
+ invitations, users = await _list_context(db)
+ return templates.TemplateResponse(
+ "admin/admin.html",
+ {"request": request, "user": user, "invitations": invitations, "users": users},
+ )
+
+
+@router.post("/admin/invite", response_class=HTMLResponse)
+async def admin_invite(request: Request, email: str = Form(...),
+ user: dict = Depends(require_admin),
+ db: aiosqlite.Connection = Depends(get_db)):
+ email = email.strip().lower()
+ error = None
+
+ async with db.execute("SELECT id FROM invitations WHERE email = ?", (email,)) as c:
+ if await c.fetchone():
+ error = f"{email} has already been invited."
+
+ if not error:
+ async with db.execute("SELECT id FROM users WHERE email = ?", (email,)) as c:
+ if await c.fetchone():
+ error = f"{email} is already a registered user."
+
+ if not error:
+ await db.execute(
+ "INSERT INTO invitations (id, email, invited_by, created_at) VALUES (?, ?, ?, ?)",
+ (secrets.token_urlsafe(16), email, user["id"], datetime.now(timezone.utc).isoformat()),
+ )
+ await db.commit()
+ login_url = f"{os.getenv('SITE_URL', 'http://localhost:8000')}/login"
+ await send_invitation(email, user.get("name") or "Someone", login_url)
+
+ invitations, users = await _list_context(db)
+ return templates.TemplateResponse(
+ "admin/admin.html",
+ {"request": request, "user": user, "invitations": invitations, "users": users,
+ "error": error, "success": None if error else f"Invitation sent to {email}."},
+ )
+
+
+@router.post("/admin/invitations/{inv_id}/delete")
+async def delete_invitation(inv_id: str, user: dict = Depends(require_admin),
+ db: aiosqlite.Connection = Depends(get_db)):
+ await db.execute("DELETE FROM invitations WHERE id = ?", (inv_id,))
+ await db.commit()
+ return RedirectResponse("/admin?success=Invitation+deleted.", status_code=302)
+
+
+@router.post("/admin/invitations/{inv_id}/resend")
+async def resend_invitation(inv_id: str, user: dict = Depends(require_admin),
+ db: aiosqlite.Connection = Depends(get_db)):
+ async with db.execute("SELECT email FROM invitations WHERE id = ?", (inv_id,)) as c:
+ row = await c.fetchone()
+ if row:
+ login_url = f"{os.getenv('SITE_URL', 'http://localhost:8000')}/login"
+ await send_invitation(row["email"], user.get("name") or "Admin", login_url)
+ return RedirectResponse("/admin?success=Invitation+resent.", status_code=302)
+
+
+@router.get("/admin/users/new", response_class=HTMLResponse)
+async def new_user_page(request: Request, user: dict = Depends(require_admin)):
+ return templates.TemplateResponse(
+ "admin/new_user.html",
+ {"request": request, "user": user, "avatars": AVATARS},
+ )
+
+
+@router.post("/admin/users/new", response_class=HTMLResponse)
+async def new_user_submit(request: Request,
+ name: str = Form(...), email: str = Form(...),
+ handicap: float = Form(...),
+ avatar_emoji: str = Form(""),
+ avatar_file: UploadFile = File(None),
+ user: dict = Depends(require_admin),
+ db: aiosqlite.Connection = Depends(get_db)):
+ name = name.strip()
+ email = email.strip().lower()
+ errors = {}
+ if not name:
+ errors["name"] = "Name is required."
+ if not email:
+ errors["email"] = "Email is required."
+ if not (0.0 <= handicap <= 54.0):
+ errors["handicap"] = "Handicap must be between 0 and 54."
+
+ if not errors:
+ async with db.execute("SELECT id FROM users WHERE email = ?", (email,)) as c:
+ if await c.fetchone():
+ errors["email"] = f"{email} is already registered."
+
+ uid = secrets.token_urlsafe(16)
+ avatar = None
+ if avatar_file and avatar_file.filename:
+ avatar, err = await save_avatar(avatar_file, uid)
+ if err:
+ errors["avatar"] = err
+ elif avatar_emoji in AVATARS:
+ avatar = avatar_emoji
+
+ if errors:
+ return templates.TemplateResponse(
+ "admin/new_user.html",
+ {"request": request, "user": user, "avatars": AVATARS, "errors": errors,
+ "form": {"name": name, "email": email, "handicap": handicap}},
+ status_code=422,
+ )
+
+ now = datetime.now(timezone.utc).isoformat()
+ await db.execute(
+ """INSERT INTO users (id, email, name, handicap, avatar, is_onboarded, created_at)
+ VALUES (?, ?, ?, ?, ?, 1, ?)""",
+ (uid, email, name, handicap, avatar, now),
+ )
+ await db.commit()
+ return RedirectResponse("/admin?success=User+added.", status_code=302)
+
+
+@router.get("/admin/users/{uid}/edit", response_class=HTMLResponse)
+async def edit_user_page(uid: str, request: Request, user: dict = Depends(require_admin),
+ db: aiosqlite.Connection = Depends(get_db)):
+ async with db.execute(
+ "SELECT id, email, name, handicap, avatar FROM users WHERE id = ?", (uid,)
+ ) as c:
+ target = await c.fetchone()
+ if not target:
+ return RedirectResponse("/admin", status_code=302)
+ return templates.TemplateResponse(
+ "admin/edit_user.html",
+ {"request": request, "user": user, "target": dict(target), "avatars": AVATARS},
+ )
+
+
+@router.post("/admin/users/{uid}/edit", response_class=HTMLResponse)
+async def edit_user_submit(uid: str, request: Request,
+ name: str = Form(...), email: str = Form(...),
+ handicap: float = Form(...),
+ avatar_emoji: str = Form(""),
+ avatar_file: UploadFile = File(None),
+ user: dict = Depends(require_admin),
+ db: aiosqlite.Connection = Depends(get_db)):
+ async with db.execute(
+ "SELECT id, email, name, handicap, avatar FROM users WHERE id = ?", (uid,)
+ ) as c:
+ target = await c.fetchone()
+ if not target:
+ return RedirectResponse("/admin", status_code=302)
+
+ errors = {}
+ name = name.strip()
+ email = email.strip().lower()
+ if not name:
+ errors["name"] = "Name is required."
+ if not email:
+ errors["email"] = "Email is required."
+ if not (0.0 <= handicap <= 54.0):
+ errors["handicap"] = "Handicap must be between 0 and 54."
+
+ avatar = target["avatar"]
+ if avatar_file and avatar_file.filename:
+ avatar, err = await save_avatar(avatar_file, uid)
+ if err:
+ errors["avatar"] = err
+ elif avatar_emoji in AVATARS:
+ avatar = avatar_emoji
+
+ if errors:
+ return templates.TemplateResponse(
+ "admin/edit_user.html",
+ {"request": request, "user": user, "target": dict(target),
+ "avatars": AVATARS, "errors": errors},
+ )
+
+ await db.execute(
+ "UPDATE users SET name = ?, email = ?, handicap = ?, avatar = ? WHERE id = ?",
+ (name, email, handicap, avatar, uid),
+ )
+ await db.commit()
+ return RedirectResponse("/admin?success=User+updated.", status_code=302)
+
+
+@router.post("/admin/users/{uid}/delete")
+async def delete_user(uid: str, user: dict = Depends(require_admin),
+ db: aiosqlite.Connection = Depends(get_db)):
+ # Don't allow deleting yourself
+ if uid == user["id"]:
+ return RedirectResponse("/admin?error=Cannot+delete+your+own+account.", status_code=302)
+ await db.execute("DELETE FROM sessions WHERE user_id = ?", (uid,))
+ await db.execute("DELETE FROM game_players WHERE user_id = ?", (uid,))
+ await db.execute("DELETE FROM hole_scores WHERE user_id = ?", (uid,))
+ await db.execute("DELETE FROM users WHERE id = ?", (uid,))
+ await db.commit()
+ return RedirectResponse("/admin?success=User+deleted.", status_code=302)
+
+
+# ── Admin: Games ──────────────────────────────────────────────────────────────
+
+@router.get("/admin/games", response_class=HTMLResponse)
+async def admin_games_page(request: Request, user: dict = Depends(require_admin),
+ db: aiosqlite.Connection = Depends(get_db)):
+ async with db.execute(
+ """SELECT g.id, g.holes_count, g.status, g.created_at,
+ c.name as course_name, u.name as creator_name
+ FROM games g
+ JOIN users u ON g.created_by = u.id
+ LEFT JOIN courses c ON c.id = g.course_id
+ ORDER BY g.created_at DESC"""
+ ) as cur:
+ games = [dict(r) for r in await cur.fetchall()]
+
+ # Fetch all players grouped by game_id
+ async with db.execute(
+ """SELECT gp.game_id, u.id AS user_id, u.name, u.avatar
+ FROM game_players gp JOIN users u ON gp.user_id = u.id
+ ORDER BY gp.joined_at"""
+ ) as cur:
+ player_rows = await cur.fetchall()
+
+ players_by_game: dict[str, list] = {}
+ for r in player_rows:
+ players_by_game.setdefault(r["game_id"], []).append(
+ {"id": r["user_id"], "name": r["name"], "avatar": r["avatar"]}
+ )
+
+ return templates.TemplateResponse(
+ "admin/games.html",
+ {"request": request, "user": user, "games": games,
+ "players_by_game": players_by_game},
+ )
+
+
+@router.post("/admin/games/{gid}/delete")
+async def admin_delete_game(gid: str, user: dict = Depends(require_admin),
+ db: aiosqlite.Connection = Depends(get_db)):
+ await db.execute("DELETE FROM hole_scores WHERE game_id = ?", (gid,))
+ await db.execute("DELETE FROM game_players WHERE game_id = ?", (gid,))
+ await db.execute("DELETE FROM games WHERE id = ?", (gid,))
+ await db.commit()
+ return RedirectResponse("/admin/games?success=Game+deleted.", status_code=302)
diff --git a/app/routers/auth.py b/app/routers/auth.py
new file mode 100644
index 0000000..b716ca4
--- /dev/null
+++ b/app/routers/auth.py
@@ -0,0 +1,105 @@
+import os
+import secrets
+from datetime import datetime, timezone
+
+from fastapi import APIRouter, Depends, Form, Request
+from fastapi.responses import HTMLResponse, RedirectResponse
+import aiosqlite
+
+from app.database import get_db
+from app.auth import create_magic_token, verify_magic_token, create_session, delete_session
+from app.templates_config import templates
+
+router = APIRouter()
+
+ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "")
+SESSION_COOKIE = "session"
+SESSION_TTL_DAYS = 30
+
+
+@router.get("/login", response_class=HTMLResponse)
+async def login_page(request: Request):
+ if request.cookies.get(SESSION_COOKIE):
+ return RedirectResponse("/", status_code=302)
+ return templates.TemplateResponse("auth/login.html", {"request": request})
+
+
+@router.post("/login", response_class=HTMLResponse)
+async def login_submit(
+ request: Request,
+ email: str = Form(...),
+ db: aiosqlite.Connection = Depends(get_db),
+):
+ email = email.strip().lower()
+
+ # Check email is admin or invited
+ if email != ADMIN_EMAIL:
+ async with db.execute(
+ "SELECT id FROM invitations WHERE email = ?", (email,)
+ ) as cursor:
+ invitation = await cursor.fetchone()
+ if invitation is None:
+ return templates.TemplateResponse(
+ "auth/login.html",
+ {"request": request, "error": "This email address has not been invited."},
+ )
+
+ await create_magic_token(email, db)
+
+ return templates.TemplateResponse("auth/check_email.html", {"request": request, "email": email})
+
+
+@router.get("/auth/verify", response_class=HTMLResponse)
+async def auth_verify(
+ request: Request,
+ token: str,
+ db: aiosqlite.Connection = Depends(get_db),
+):
+ email = await verify_magic_token(token, db)
+
+ if email is None:
+ return templates.TemplateResponse(
+ "auth/login.html",
+ {"request": request, "error": "This link is invalid or has expired. Please request a new one."},
+ )
+
+ # Get or create user
+ async with db.execute("SELECT id, is_onboarded FROM users WHERE email = ?", (email,)) as cursor:
+ user = await cursor.fetchone()
+
+ if user is None:
+ user_id = secrets.token_urlsafe(16)
+ now = datetime.now(timezone.utc).isoformat()
+ await db.execute(
+ "INSERT INTO users (id, email, created_at) VALUES (?, ?, ?)",
+ (user_id, email, now),
+ )
+ await db.execute("DELETE FROM invitations WHERE email = ?", (email,))
+ await db.commit()
+ is_onboarded = False
+ else:
+ user_id = user["id"]
+ is_onboarded = bool(user["is_onboarded"])
+
+ session_token = await create_session(user_id, db)
+
+ redirect_to = "/" if is_onboarded else "/onboarding"
+ response = RedirectResponse(redirect_to, status_code=302)
+ response.set_cookie(
+ SESSION_COOKIE,
+ session_token,
+ max_age=SESSION_TTL_DAYS * 86400,
+ httponly=True,
+ samesite="lax",
+ )
+ return response
+
+
+@router.get("/logout")
+async def logout(request: Request, db: aiosqlite.Connection = Depends(get_db)):
+ token = request.cookies.get(SESSION_COOKIE)
+ if token:
+ await delete_session(token, db)
+ response = RedirectResponse("/login", status_code=302)
+ response.delete_cookie(SESSION_COOKIE)
+ return response
diff --git a/app/routers/courses.py b/app/routers/courses.py
new file mode 100644
index 0000000..3d7599b
--- /dev/null
+++ b/app/routers/courses.py
@@ -0,0 +1,266 @@
+import secrets
+from datetime import datetime, timezone
+
+from fastapi import APIRouter, Depends, Form, Request
+from fastapi.responses import HTMLResponse, RedirectResponse
+import aiosqlite
+
+from app.database import get_db
+from app.dependencies import require_admin
+from app.templates_config import templates
+
+router = APIRouter()
+
+
+async def _get_course_detail(cid: str, db: aiosqlite.Connection):
+ """Returns (course_dict, holes_list, tees_list) or (None, [], [])."""
+ async with db.execute(
+ "SELECT id, name, holes, location FROM courses WHERE id = ?", (cid,)
+ ) as c:
+ course = await c.fetchone()
+ if not course:
+ return None, [], []
+ async with db.execute(
+ "SELECT hole_number, par, stroke_index FROM course_holes WHERE course_id = ? ORDER BY hole_number",
+ (cid,),
+ ) as c:
+ holes = [dict(r) for r in await c.fetchall()]
+ async with db.execute(
+ "SELECT id, name, slope, rating FROM course_tees WHERE course_id = ? ORDER BY name",
+ (cid,),
+ ) as c:
+ tees = [dict(r) for r in await c.fetchall()]
+ return dict(course), holes, tees
+
+
+# ── List ──────────────────────────────────────────────────────────────────────
+
+@router.get("/admin/courses", response_class=HTMLResponse)
+async def courses_page(request: Request, user: dict = Depends(require_admin),
+ db: aiosqlite.Connection = Depends(get_db)):
+ async with db.execute(
+ "SELECT id, name, holes, location FROM courses ORDER BY name"
+ ) as c:
+ courses = [dict(r) for r in await c.fetchall()]
+ return templates.TemplateResponse(
+ "admin/courses.html",
+ {"request": request, "user": user, "courses": courses},
+ )
+
+
+# ── Create ────────────────────────────────────────────────────────────────────
+
+@router.get("/admin/courses/new", response_class=HTMLResponse)
+async def new_course_page(request: Request, user: dict = Depends(require_admin)):
+ return templates.TemplateResponse(
+ "admin/new_course.html", {"request": request, "user": user}
+ )
+
+
+@router.post("/admin/courses/new")
+async def create_course(request: Request,
+ name: str = Form(...), holes: int = Form(...),
+ location: str = Form(""),
+ user: dict = Depends(require_admin),
+ db: aiosqlite.Connection = Depends(get_db)):
+ form = await request.form()
+ name = name.strip()
+ location = location.strip()
+ errors = {}
+ if not name:
+ errors["name"] = "Name is required."
+ if holes not in (9, 18):
+ errors["holes"] = "Holes must be 9 or 18."
+
+ hole_count = holes if holes in (9, 18) else 18
+ pars = {}
+ stroke_indices = {}
+ for i in range(1, hole_count + 1):
+ raw = form.get(f"par_{i}", "4")
+ try:
+ p = int(raw)
+ if p < 3 or p > 5:
+ errors[f"par_{i}"] = f"Hole {i}: par must be 3–5."
+ else:
+ pars[i] = p
+ except (ValueError, TypeError):
+ errors[f"par_{i}"] = f"Hole {i}: invalid par."
+ raw_si = form.get(f"si_{i}", "")
+ if raw_si:
+ try:
+ si = int(raw_si)
+ if si < 1 or si > hole_count:
+ errors[f"si_{i}"] = f"Hole {i}: SI must be 1–{hole_count}."
+ else:
+ stroke_indices[i] = si
+ except (ValueError, TypeError):
+ errors[f"si_{i}"] = f"Hole {i}: invalid SI."
+ else:
+ stroke_indices[i] = None
+
+ if errors:
+ return templates.TemplateResponse(
+ "admin/new_course.html",
+ {"request": request, "user": user, "errors": errors,
+ "form": {"name": name, "holes": holes, "location": location,
+ "pars": pars, "stroke_indices": stroke_indices}},
+ status_code=422,
+ )
+
+ cid = secrets.token_urlsafe(10)
+ now = datetime.now(timezone.utc).isoformat()
+ await db.execute(
+ "INSERT INTO courses (id, name, holes, location, created_at) VALUES (?,?,?,?,?)",
+ (cid, name, holes, location or None, now),
+ )
+ for hole_num, par in pars.items():
+ await db.execute(
+ "INSERT INTO course_holes (id, course_id, hole_number, par, stroke_index) VALUES (?,?,?,?,?)",
+ (secrets.token_urlsafe(10), cid, hole_num, par, stroke_indices.get(hole_num)),
+ )
+ await db.commit()
+ return RedirectResponse(
+ f"/admin/courses/{cid}/edit?success=Course+created.+Add+tees+below.",
+ status_code=302,
+ )
+
+
+# ── Edit ──────────────────────────────────────────────────────────────────────
+
+@router.get("/admin/courses/{cid}/edit", response_class=HTMLResponse)
+async def edit_course_page(cid: str, request: Request,
+ user: dict = Depends(require_admin),
+ db: aiosqlite.Connection = Depends(get_db)):
+ course, holes, tees = await _get_course_detail(cid, db)
+ if not course:
+ return RedirectResponse("/admin/courses", status_code=302)
+ return templates.TemplateResponse(
+ "admin/edit_course.html",
+ {"request": request, "user": user, "course": course, "holes": holes, "tees": tees},
+ )
+
+
+@router.post("/admin/courses/{cid}/edit")
+async def edit_course_submit(cid: str, request: Request,
+ name: str = Form(...), holes: int = Form(...),
+ location: str = Form(""),
+ user: dict = Depends(require_admin),
+ db: aiosqlite.Connection = Depends(get_db)):
+ form = await request.form()
+ course, existing_holes, tees = await _get_course_detail(cid, db)
+ if not course:
+ return RedirectResponse("/admin/courses", status_code=302)
+
+ name = name.strip()
+ location = location.strip()
+ errors = {}
+ if not name:
+ errors["name"] = "Name is required."
+ if holes not in (9, 18):
+ errors["holes"] = "Holes must be 9 or 18."
+
+ hole_count = holes if holes in (9, 18) else 18
+ pars = {}
+ stroke_indices = {}
+ for i in range(1, hole_count + 1):
+ raw = form.get(f"par_{i}", "4")
+ try:
+ p = int(raw)
+ if p < 3 or p > 5:
+ errors[f"par_{i}"] = f"Hole {i}: par must be 3–5."
+ else:
+ pars[i] = p
+ except (ValueError, TypeError):
+ errors[f"par_{i}"] = f"Hole {i}: invalid par."
+ raw_si = form.get(f"si_{i}", "")
+ if raw_si:
+ try:
+ si = int(raw_si)
+ if si < 1 or si > hole_count:
+ errors[f"si_{i}"] = f"Hole {i}: SI must be 1–{hole_count}."
+ else:
+ stroke_indices[i] = si
+ except (ValueError, TypeError):
+ errors[f"si_{i}"] = f"Hole {i}: invalid SI."
+ else:
+ stroke_indices[i] = None
+
+ if errors:
+ return templates.TemplateResponse(
+ "admin/edit_course.html",
+ {"request": request, "user": user, "course": course,
+ "holes": existing_holes, "tees": tees, "errors": errors},
+ status_code=422,
+ )
+
+ await db.execute(
+ "UPDATE courses SET name = ?, holes = ?, location = ? WHERE id = ?",
+ (name, holes, location or None, cid),
+ )
+ for hole_num, par in pars.items():
+ await db.execute(
+ """INSERT INTO course_holes (id, course_id, hole_number, par, stroke_index) VALUES (?,?,?,?,?)
+ ON CONFLICT(course_id, hole_number) DO UPDATE SET
+ par=excluded.par, stroke_index=excluded.stroke_index""",
+ (secrets.token_urlsafe(10), cid, hole_num, par, stroke_indices.get(hole_num)),
+ )
+ # Remove holes beyond new count if holes was reduced
+ await db.execute(
+ "DELETE FROM course_holes WHERE course_id = ? AND hole_number > ?", (cid, holes)
+ )
+ await db.commit()
+ return RedirectResponse(f"/admin/courses/{cid}/edit?success=Course+updated.", status_code=302)
+
+
+# ── Delete course ─────────────────────────────────────────────────────────────
+
+@router.post("/admin/courses/{cid}/delete")
+async def delete_course(cid: str, user: dict = Depends(require_admin),
+ db: aiosqlite.Connection = Depends(get_db)):
+ await db.execute("DELETE FROM course_holes WHERE course_id = ?", (cid,))
+ await db.execute("DELETE FROM course_tees WHERE course_id = ?", (cid,))
+ await db.execute("DELETE FROM courses WHERE id = ?", (cid,))
+ await db.commit()
+ return RedirectResponse("/admin/courses?success=Course+deleted.", status_code=302)
+
+
+# ── Tees ──────────────────────────────────────────────────────────────────────
+
+@router.post("/admin/courses/{cid}/tees/new")
+async def add_tee(cid: str, name: str = Form(...),
+ slope: int = Form(...), rating: float = Form(...),
+ user: dict = Depends(require_admin),
+ db: aiosqlite.Connection = Depends(get_db)):
+ await db.execute(
+ "INSERT INTO course_tees (id, course_id, name, slope, rating) VALUES (?,?,?,?,?)",
+ (secrets.token_urlsafe(10), cid, name.strip(), slope, rating),
+ )
+ await db.commit()
+ return RedirectResponse(f"/admin/courses/{cid}/edit?success=Tee+added.", status_code=302)
+
+
+@router.post("/admin/courses/{cid}/tees/{tid}/edit")
+async def edit_tee(cid: str, tid: str, name: str = Form(...),
+ slope: int = Form(...), rating: float = Form(...),
+ user: dict = Depends(require_admin),
+ db: aiosqlite.Connection = Depends(get_db)):
+ await db.execute(
+ "UPDATE course_tees SET name = ?, slope = ?, rating = ? WHERE id = ? AND course_id = ?",
+ (name.strip(), slope, rating, tid, cid),
+ )
+ await db.commit()
+ return RedirectResponse(f"/admin/courses/{cid}/edit?success=Tee+updated.", status_code=302)
+
+
+@router.post("/admin/courses/{cid}/tees/{tid}/delete")
+async def delete_tee(cid: str, tid: str, user: dict = Depends(require_admin),
+ db: aiosqlite.Connection = Depends(get_db)):
+ # Nullify references in game_players before deleting
+ await db.execute(
+ "UPDATE game_players SET tee_id = NULL WHERE tee_id = ?", (tid,)
+ )
+ await db.execute(
+ "DELETE FROM course_tees WHERE id = ? AND course_id = ?", (tid, cid)
+ )
+ await db.commit()
+ return RedirectResponse(f"/admin/courses/{cid}/edit?success=Tee+deleted.", status_code=302)
diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py
new file mode 100644
index 0000000..9580f9a
--- /dev/null
+++ b/app/routers/dashboard.py
@@ -0,0 +1,112 @@
+from fastapi import APIRouter, Depends, Request
+from fastapi.responses import HTMLResponse
+import aiosqlite
+
+from app.database import get_db
+from app.dependencies import get_current_user
+from app.templates_config import templates
+
+router = APIRouter()
+
+
+@router.get("/", response_class=HTMLResponse)
+async def dashboard(request: Request, user: dict = Depends(get_current_user),
+ db: aiosqlite.Connection = Depends(get_db)):
+ uid = user["id"]
+
+ # Games played (completed)
+ async with db.execute(
+ """SELECT COUNT(*) FROM games
+ WHERE status = 'completed'
+ AND id IN (SELECT game_id FROM game_players WHERE user_id = ?)""",
+ (uid,),
+ ) as c:
+ games_played = (await c.fetchone())[0]
+
+ # Victories: completed games where this user has the highest total points
+ # For each completed game the user played, rank players by total points
+ async with db.execute(
+ """SELECT gp.game_id,
+ SUM(CASE WHEN hs.user_id = ? THEN hs.bingo + hs.bango + hs.bongo ELSE 0 END) AS my_total,
+ MAX(hs.bingo + hs.bango + hs.bongo) AS top_hole_score
+ FROM game_players gp
+ JOIN games g ON g.id = gp.game_id
+ JOIN hole_scores hs ON hs.game_id = gp.game_id
+ WHERE gp.user_id = ? AND g.status = 'completed'
+ GROUP BY gp.game_id""",
+ (uid, uid),
+ ) as c:
+ game_rows = await c.fetchall()
+
+ victories = 0
+ for row in game_rows:
+ game_id = row["game_id"]
+ my_total = row["my_total"]
+ # Get the max total among all players in this game
+ async with db.execute(
+ """SELECT MAX(player_total) FROM (
+ SELECT user_id, SUM(bingo + bango + bongo) AS player_total
+ FROM hole_scores WHERE game_id = ? GROUP BY user_id
+ )""",
+ (game_id,),
+ ) as c2:
+ max_total = (await c2.fetchone())[0] or 0
+ if my_total >= max_total and my_total > 0:
+ victories += 1
+
+ # Recent completed games with results
+ async with db.execute(
+ """SELECT g.id, g.holes_count, g.created_at, g.format, c.name AS course_name
+ FROM games g
+ LEFT JOIN courses c ON c.id = g.course_id
+ JOIN game_players gp ON gp.game_id = g.id
+ WHERE gp.user_id = ? AND g.status = 'completed'
+ ORDER BY g.created_at DESC LIMIT 5""",
+ (uid,),
+ ) as c:
+ recent_games = [dict(r) for r in await c.fetchall()]
+
+ # For each recent game, get player totals
+ for game in recent_games:
+ async with db.execute(
+ """SELECT u.id, u.name, u.avatar,
+ SUM(hs.bingo + hs.bango + hs.bongo) AS total
+ FROM hole_scores hs JOIN users u ON hs.user_id = u.id
+ WHERE hs.game_id = ?
+ GROUP BY hs.user_id
+ ORDER BY total DESC""",
+ (game["id"],),
+ ) as c:
+ game["results"] = [dict(r) for r in await c.fetchall()]
+
+ # All active games (social stream)
+ async with db.execute(
+ """SELECT g.id, g.holes_count, g.created_at, g.format, c.name AS course_name
+ FROM games g
+ LEFT JOIN courses c ON c.id = g.course_id
+ WHERE g.status = 'active'
+ ORDER BY g.created_at DESC""",
+ ) as c:
+ active_games = [dict(r) for r in await c.fetchall()]
+
+ for game in active_games:
+ async with db.execute(
+ """SELECT u.id, u.name, u.avatar,
+ COALESCE(SUM(hs.bingo + hs.bango + hs.bongo), 0) AS total
+ FROM game_players gp
+ JOIN users u ON u.id = gp.user_id
+ LEFT JOIN hole_scores hs ON hs.game_id = gp.game_id AND hs.user_id = u.id
+ WHERE gp.game_id = ?
+ GROUP BY u.id
+ ORDER BY total DESC""",
+ (game["id"],),
+ ) as c:
+ game["players"] = [dict(r) for r in await c.fetchall()]
+ game["is_participant"] = any(p["id"] == uid for p in game["players"])
+
+ return templates.TemplateResponse("dashboard/dashboard.html", {
+ "request": request, "user": user,
+ "games_played": games_played, "victories": victories,
+ "recent_games": recent_games,
+ "active_games": active_games,
+ })
diff --git a/app/routers/games.py b/app/routers/games.py
new file mode 100644
index 0000000..70b47c5
--- /dev/null
+++ b/app/routers/games.py
@@ -0,0 +1,374 @@
+import json
+import secrets
+from datetime import datetime, timezone
+
+import aiosqlite
+from fastapi import APIRouter, Depends, Form, Request, WebSocket, WebSocketDisconnect
+from fastapi.responses import HTMLResponse, RedirectResponse
+
+from app.database import get_db, DATABASE_PATH
+from app.dependencies import get_current_user
+from app.templates_config import templates
+
+router = APIRouter()
+
+# game_id -> set of connected websockets
+_connections: dict[str, set[WebSocket]] = {}
+
+
+# ── Helpers ──────────────────────────────────────────────────────────────────
+
+async def _get_game(game_id: str, db: aiosqlite.Connection):
+ async with db.execute(
+ """SELECT g.*, c.name as course_name
+ FROM games g LEFT JOIN courses c ON c.id = g.course_id
+ WHERE g.id = ?""",
+ (game_id,),
+ ) as c:
+ return await c.fetchone()
+
+
+async def _get_players(game_id: str, db: aiosqlite.Connection):
+ async with db.execute(
+ """SELECT u.id, u.name, u.avatar, gp.handicap_snapshot,
+ ct.slope, ct.rating as tee_rating, ct.name as tee_name
+ FROM game_players gp JOIN users u ON gp.user_id = u.id
+ LEFT JOIN course_tees ct ON ct.id = gp.tee_id
+ WHERE gp.game_id = ? ORDER BY gp.joined_at""",
+ (game_id,),
+ ) as c:
+ return [dict(r) for r in await c.fetchall()]
+
+
+async def _get_scores(game_id: str, db: aiosqlite.Connection):
+ """Returns {hole_number: {user_id: score_row}}"""
+ async with db.execute(
+ "SELECT * FROM hole_scores WHERE game_id = ?", (game_id,)
+ ) as c:
+ rows = await c.fetchall()
+ scores: dict[int, dict] = {}
+ for r in rows:
+ h = r["hole_number"]
+ if h not in scores:
+ scores[h] = {}
+ scores[h][r["user_id"]] = dict(r)
+ return scores
+
+
+async def _broadcast(game_id: str, message: dict):
+ dead = set()
+ for ws in _connections.get(game_id, set()):
+ try:
+ await ws.send_text(json.dumps(message))
+ except Exception:
+ dead.add(ws)
+ _connections.get(game_id, set()).difference_update(dead)
+
+
+def _totals(players: list, scores: dict) -> dict:
+ """Returns {user_id: {bingo, bango, bongo, total}}"""
+ totals = {p["id"]: {"bingo": 0, "bango": 0.0, "bongo": 0, "total": 0.0} for p in players}
+ for hole_scores in scores.values():
+ for uid, s in hole_scores.items():
+ if uid in totals:
+ totals[uid]["bingo"] += s["bingo"]
+ totals[uid]["bango"] += s["bango"]
+ totals[uid]["bongo"] += s["bongo"]
+ totals[uid]["total"] += s["bingo"] + s["bango"] + s["bongo"]
+ return totals
+
+
+# ── Routes ────────────────────────────────────────────────────────────────────
+
+@router.get("/games", response_class=HTMLResponse)
+async def games_list(request: Request, user: dict = Depends(get_current_user),
+ db: aiosqlite.Connection = Depends(get_db)):
+ async with db.execute(
+ """SELECT g.id, g.holes_count, g.status, g.created_at,
+ u.name as creator_name, c.name as course_name
+ FROM games g JOIN users u ON g.created_by = u.id
+ LEFT JOIN courses c ON c.id = g.course_id
+ WHERE g.id IN (SELECT game_id FROM game_players WHERE user_id = ?)
+ ORDER BY g.created_at DESC LIMIT 20""",
+ (user["id"],),
+ ) as c:
+ games = [dict(r) for r in await c.fetchall()]
+
+ # For completed games, find the winner (highest total points)
+ async with db.execute(
+ """SELECT hs.game_id, u.name, u.avatar,
+ SUM(hs.bingo + hs.bango + hs.bongo) AS total
+ FROM hole_scores hs JOIN users u ON hs.user_id = u.id
+ WHERE hs.game_id IN (SELECT id FROM games WHERE status = 'completed')
+ GROUP BY hs.game_id, hs.user_id
+ ORDER BY hs.game_id, total DESC""",
+ ) as c:
+ rows = await c.fetchall()
+
+ # Keep only the top scorer per game
+ winners: dict[str, dict] = {}
+ for r in rows:
+ if r["game_id"] not in winners:
+ winners[r["game_id"]] = {"name": r["name"], "avatar": r["avatar"], "total": r["total"]}
+
+ return templates.TemplateResponse(
+ "games/games.html", {"request": request, "user": user, "games": games, "winners": winners}
+ )
+
+
+@router.get("/games/new", response_class=HTMLResponse)
+async def new_game_page(request: Request, user: dict = Depends(get_current_user),
+ db: aiosqlite.Connection = Depends(get_db)):
+ async with db.execute(
+ "SELECT id, name, avatar FROM users WHERE is_onboarded = 1 ORDER BY name"
+ ) as c:
+ all_users = [dict(r) for r in await c.fetchall()]
+ async with db.execute("SELECT id, name, holes, location FROM courses ORDER BY name") as c:
+ courses = [dict(r) for r in await c.fetchall()]
+ async with db.execute(
+ "SELECT id, course_id, name, slope, rating FROM course_tees ORDER BY course_id, name"
+ ) as c:
+ tee_rows = [dict(r) for r in await c.fetchall()]
+
+ # Group tees by course_id for JSON embed in template
+ tees_by_course: dict[str, list] = {}
+ for t in tee_rows:
+ tees_by_course.setdefault(t["course_id"], []).append(
+ {"id": t["id"], "name": t["name"], "slope": t["slope"], "rating": t["rating"]}
+ )
+
+ return templates.TemplateResponse(
+ "games/new_game.html",
+ {"request": request, "user": user, "all_users": all_users,
+ "courses": courses, "tees_by_course": tees_by_course},
+ )
+
+
+VALID_FORMATS = {"bingo_bango_bongo"}
+
+@router.post("/games/new")
+async def create_game(request: Request, holes_count: int = Form(...),
+ course_id: str = Form(...),
+ format: str = Form("bingo_bango_bongo"),
+ user: dict = Depends(get_current_user),
+ db: aiosqlite.Connection = Depends(get_db)):
+ form = await request.form()
+ player_ids = form.getlist("players")
+ if format not in VALID_FORMATS:
+ format = "bingo_bango_bongo"
+
+ if user["id"] not in player_ids:
+ player_ids.insert(0, user["id"])
+
+ async with db.execute("SELECT id, name, holes, location FROM courses ORDER BY name") as c:
+ courses = [dict(r) for r in await c.fetchall()]
+
+ error = None
+ if len(player_ids) < 2:
+ error = "Select at least 2 players."
+ if not course_id or not any(c["id"] == course_id for c in courses):
+ error = error or "Select a course."
+ if not error:
+ async with db.execute(
+ "SELECT id FROM course_tees WHERE course_id = ? LIMIT 1", (course_id,)
+ ) as c:
+ has_tees = await c.fetchone()
+ if has_tees and any(not form.get(f"tee_{uid}") for uid in player_ids):
+ error = "All players must select a tee."
+
+ if error:
+ async with db.execute(
+ "SELECT id, name, avatar FROM users WHERE is_onboarded = 1 ORDER BY name"
+ ) as c:
+ all_users = [dict(r) for r in await c.fetchall()]
+ async with db.execute(
+ "SELECT id, course_id, name, slope, rating FROM course_tees ORDER BY course_id, name"
+ ) as c:
+ tee_rows = [dict(r) for r in await c.fetchall()]
+ tees_by_course: dict[str, list] = {}
+ for t in tee_rows:
+ tees_by_course.setdefault(t["course_id"], []).append(
+ {"id": t["id"], "name": t["name"], "slope": t["slope"], "rating": t["rating"]}
+ )
+ return templates.TemplateResponse(
+ "games/new_game.html",
+ {"request": request, "user": user, "all_users": all_users,
+ "courses": courses, "tees_by_course": tees_by_course, "error": error},
+ )
+
+ game_id = secrets.token_urlsafe(10)
+ now = datetime.now(timezone.utc).isoformat()
+
+ await db.execute(
+ "INSERT INTO games (id, format, holes_count, status, created_by, created_at, course_id) VALUES (?,?,?,?,?,?,?)",
+ (game_id, "bingo_bango_bongo", holes_count, "active", user["id"], now, course_id),
+ )
+ for uid in player_ids:
+ async with db.execute("SELECT handicap FROM users WHERE id = ?", (uid,)) as c:
+ row = await c.fetchone()
+ hc = row["handicap"] if row else None
+ tee_id = form.get(f"tee_{uid}") or None
+ await db.execute(
+ "INSERT OR IGNORE INTO game_players (id, game_id, user_id, handicap_snapshot, tee_id, joined_at) VALUES (?,?,?,?,?,?)",
+ (secrets.token_urlsafe(10), game_id, uid, hc, tee_id, now),
+ )
+ await db.commit()
+
+ return RedirectResponse(f"/games/{game_id}", status_code=302)
+
+
+@router.get("/games/{game_id}/summary", response_class=HTMLResponse)
+async def game_summary(game_id: str, request: Request, user: dict = Depends(get_current_user),
+ db: aiosqlite.Connection = Depends(get_db)):
+ game = await _get_game(game_id, db)
+ if not game:
+ return RedirectResponse("/games", status_code=302)
+
+ players = await _get_players(game_id, db)
+ scores = await _get_scores(game_id, db)
+ totals = _totals(players, scores)
+ holes = list(range(1, game["holes_count"] + 1))
+
+ hole_pars: dict[int, int] = {}
+ hole_stroke_indices: dict[int, int] = {}
+ if game["course_id"]:
+ async with db.execute(
+ "SELECT hole_number, par, stroke_index FROM course_holes WHERE course_id = ? ORDER BY hole_number",
+ (game["course_id"],),
+ ) as c:
+ for row in await c.fetchall():
+ hole_pars[row["hole_number"]] = row["par"]
+ if row["stroke_index"] is not None:
+ hole_stroke_indices[row["hole_number"]] = row["stroke_index"]
+
+ results = sorted(players, key=lambda p: totals.get(p["id"], {}).get("total", 0), reverse=True)
+
+ return templates.TemplateResponse("games/summary.html", {
+ "request": request, "user": user, "game": dict(game),
+ "players": players, "scores": scores, "totals": totals,
+ "holes": holes, "hole_pars": hole_pars,
+ "results": results,
+ })
+
+
+@router.get("/games/{game_id}", response_class=HTMLResponse)
+async def game_page(game_id: str, request: Request, user: dict = Depends(get_current_user),
+ db: aiosqlite.Connection = Depends(get_db)):
+ game = await _get_game(game_id, db)
+ if not game:
+ return RedirectResponse("/games", status_code=302)
+
+ players = await _get_players(game_id, db)
+ scores = await _get_scores(game_id, db)
+ totals = _totals(players, scores)
+ holes = list(range(1, game["holes_count"] + 1))
+
+ # Load hole pars and stroke indices if the game has a course
+ hole_pars: dict[int, int] = {}
+ hole_stroke_indices: dict[int, int] = {}
+ if game["course_id"]:
+ async with db.execute(
+ "SELECT hole_number, par, stroke_index FROM course_holes WHERE course_id = ? ORDER BY hole_number",
+ (game["course_id"],),
+ ) as c:
+ for row in await c.fetchall():
+ hole_pars[row["hole_number"]] = row["par"]
+ if row["stroke_index"] is not None:
+ hole_stroke_indices[row["hole_number"]] = row["stroke_index"]
+
+ # Playing handicap per player: HI × (slope / 113) + (course_rating − par)
+ course_par = sum(hole_pars.values())
+ playing_handicaps: dict[str, int | None] = {}
+ for p in players:
+ hc = p.get("handicap_snapshot")
+ slope = p.get("slope")
+ rating = p.get("tee_rating")
+ if hc is not None and slope and rating and course_par:
+ playing_handicaps[p["id"]] = round(hc * (slope / 113) + (rating - course_par))
+ else:
+ playing_handicaps[p["id"]] = None
+
+ return templates.TemplateResponse("games/game.html", {
+ "request": request, "user": user, "game": dict(game),
+ "players": players, "scores": scores, "totals": totals,
+ "holes": holes, "hole_pars": hole_pars, "hole_stroke_indices": hole_stroke_indices,
+ "playing_handicaps": playing_handicaps,
+ })
+
+
+@router.post("/games/{game_id}/score")
+async def save_score(game_id: str, request: Request, user: dict = Depends(get_current_user),
+ db: aiosqlite.Connection = Depends(get_db)):
+ form = await request.form()
+ hole = int(form["hole"])
+ now = datetime.now(timezone.utc).isoformat()
+
+ game = await _get_game(game_id, db)
+ if not game:
+ return HTMLResponse("Not found", status_code=404)
+
+ players = await _get_players(game_id, db)
+ player_ids = [p["id"] for p in players]
+
+ # Determine Bingo (first on green) and Bongo (first to hole out) — single winner
+ bingo_winner = form.get("bingo", "")
+ bongo_winner = form.get("bongo", "")
+
+ # Bango: may be multiple winners (split point)
+ bango_winners = form.getlist("bango")
+
+ bango_value = round(1.0 / len(bango_winners), 4) if bango_winners else 0.0
+
+ for uid in player_ids:
+ bingo = 1 if uid == bingo_winner else 0
+ bango = bango_value if uid in bango_winners else 0.0
+ bongo = 1 if uid == bongo_winner else 0
+ strokes_val = form.get(f"strokes_{uid}")
+ strokes = int(strokes_val) if strokes_val and strokes_val.isdigit() else None
+
+ await db.execute(
+ """INSERT INTO hole_scores (id, game_id, hole_number, user_id, strokes, bingo, bango, bongo, updated_by, updated_at)
+ VALUES (?,?,?,?,?,?,?,?,?,?)
+ ON CONFLICT(game_id, hole_number, user_id) DO UPDATE SET
+ strokes=excluded.strokes, bingo=excluded.bingo, bango=excluded.bango, bongo=excluded.bongo,
+ updated_by=excluded.updated_by, updated_at=excluded.updated_at""",
+ (secrets.token_urlsafe(10), game_id, hole, uid, strokes, bingo, bango, bongo, user["id"], now),
+ )
+ await db.commit()
+
+ # Broadcast updated scores to all connected players
+ scores = await _get_scores(game_id, db)
+ totals = _totals(players, scores)
+ hole_data = {uid: scores.get(hole, {}).get(uid, {}) for uid in player_ids}
+
+ await _broadcast(game_id, {
+ "type": "score_update",
+ "hole": hole,
+ "scores": {uid: {"strokes": d.get("strokes"), "bingo": d.get("bingo", 0),
+ "bango": d.get("bango", 0.0), "bongo": d.get("bongo", 0)}
+ for uid, d in hole_data.items()},
+ "totals": totals,
+ })
+
+ return HTMLResponse("", status_code=204)
+
+
+@router.post("/games/{game_id}/finish")
+async def finish_game(game_id: str, user: dict = Depends(get_current_user),
+ db: aiosqlite.Connection = Depends(get_db)):
+ await db.execute("UPDATE games SET status = 'completed' WHERE id = ?", (game_id,))
+ await db.commit()
+ return RedirectResponse(f"/games/{game_id}", status_code=302)
+
+
+# ── WebSocket ─────────────────────────────────────────────────────────────────
+
+@router.websocket("/games/{game_id}/ws")
+async def game_ws(game_id: str, websocket: WebSocket):
+ await websocket.accept()
+ _connections.setdefault(game_id, set()).add(websocket)
+ try:
+ while True:
+ await websocket.receive_text() # keep alive
+ except WebSocketDisconnect:
+ _connections.get(game_id, set()).discard(websocket)
diff --git a/app/routers/onboarding.py b/app/routers/onboarding.py
new file mode 100644
index 0000000..bcb8a49
--- /dev/null
+++ b/app/routers/onboarding.py
@@ -0,0 +1,63 @@
+from fastapi import APIRouter, Depends, Form, Request, UploadFile, File
+from fastapi.responses import HTMLResponse, RedirectResponse
+import aiosqlite
+
+from app.database import get_db
+from app.dependencies import get_current_user
+from app.templates_config import templates
+from app.avatar import save_avatar
+
+router = APIRouter()
+
+AVATARS = ["🏌️", "⛳", "🦅", "🐦", "🏆", "🎯", "🌟", "🔥", "💪", "🦁", "🐯", "🦊"]
+
+
+@router.get("/onboarding", response_class=HTMLResponse)
+async def onboarding_page(request: Request, user: dict = Depends(get_current_user)):
+ if user["is_onboarded"]:
+ return RedirectResponse("/", status_code=302)
+ return templates.TemplateResponse(
+ "onboarding/setup.html", {"request": request, "user": user, "avatars": AVATARS}
+ )
+
+
+@router.post("/onboarding", response_class=HTMLResponse)
+async def onboarding_submit(
+ request: Request,
+ name: str = Form(...),
+ handicap: float = Form(...),
+ avatar_emoji: str = Form(""),
+ avatar_file: UploadFile = File(None),
+ user: dict = Depends(get_current_user),
+ db: aiosqlite.Connection = Depends(get_db),
+):
+ errors = {}
+ name = name.strip()
+ if not name:
+ errors["name"] = "Name is required."
+ if not (0.0 <= handicap <= 54.0):
+ errors["handicap"] = "Handicap must be between 0 and 54."
+
+ avatar = None
+ if avatar_file and avatar_file.filename:
+ avatar, err = await save_avatar(avatar_file, user["id"])
+ if err:
+ errors["avatar"] = err
+ elif avatar_emoji in AVATARS:
+ avatar = avatar_emoji
+ else:
+ errors["avatar"] = "Please upload a photo or select an emoji."
+
+ if errors:
+ return templates.TemplateResponse(
+ "onboarding/setup.html",
+ {"request": request, "user": user, "avatars": AVATARS, "errors": errors,
+ "values": {"name": name, "handicap": handicap, "avatar_emoji": avatar_emoji}},
+ )
+
+ await db.execute(
+ "UPDATE users SET name = ?, handicap = ?, avatar = ?, is_onboarded = 1 WHERE id = ?",
+ (name, handicap, avatar, user["id"]),
+ )
+ await db.commit()
+ return RedirectResponse("/", status_code=302)
diff --git a/app/routers/profile.py b/app/routers/profile.py
new file mode 100644
index 0000000..8cc2b18
--- /dev/null
+++ b/app/routers/profile.py
@@ -0,0 +1,62 @@
+from fastapi import APIRouter, Depends, Form, Request, UploadFile, File
+from fastapi.responses import HTMLResponse
+import aiosqlite
+
+from app.database import get_db
+from app.dependencies import get_current_user
+from app.routers.onboarding import AVATARS
+from app.templates_config import templates
+from app.avatar import save_avatar
+
+router = APIRouter()
+
+
+@router.get("/profile", response_class=HTMLResponse)
+async def profile_page(request: Request, user: dict = Depends(get_current_user)):
+ return templates.TemplateResponse(
+ "profile/profile.html", {"request": request, "user": user, "avatars": AVATARS}
+ )
+
+
+@router.post("/profile", response_class=HTMLResponse)
+async def profile_submit(
+ request: Request,
+ name: str = Form(...),
+ handicap: float = Form(...),
+ avatar_emoji: str = Form(""),
+ avatar_file: UploadFile = File(None),
+ user: dict = Depends(get_current_user),
+ db: aiosqlite.Connection = Depends(get_db),
+):
+ errors = {}
+ name = name.strip()
+ if not name:
+ errors["name"] = "Name is required."
+ if not (0.0 <= handicap <= 54.0):
+ errors["handicap"] = "Handicap must be between 0 and 54."
+
+ avatar = user["avatar"] # keep existing if nothing new provided
+ if avatar_file and avatar_file.filename:
+ avatar, err = await save_avatar(avatar_file, user["id"])
+ if err:
+ errors["avatar"] = err
+ elif avatar_emoji in AVATARS:
+ avatar = avatar_emoji
+
+ if errors:
+ return templates.TemplateResponse(
+ "profile/profile.html",
+ {"request": request, "user": user, "avatars": AVATARS, "errors": errors},
+ )
+
+ await db.execute(
+ "UPDATE users SET name = ?, handicap = ?, avatar = ? WHERE id = ?",
+ (name, handicap, avatar, user["id"]),
+ )
+ await db.commit()
+
+ return templates.TemplateResponse(
+ "profile/profile.html",
+ {"request": request, "user": {**user, "name": name, "handicap": handicap, "avatar": avatar},
+ "avatars": AVATARS, "success": "Profile updated."},
+ )
diff --git a/app/routers/users.py b/app/routers/users.py
new file mode 100644
index 0000000..dd2394e
--- /dev/null
+++ b/app/routers/users.py
@@ -0,0 +1,66 @@
+from fastapi import APIRouter, Depends, Request
+from fastapi.responses import HTMLResponse
+import aiosqlite
+
+from app.database import get_db
+from app.dependencies import get_current_user
+from app.templates_config import templates
+
+router = APIRouter()
+
+
+@router.get("/users/{user_id}", response_class=HTMLResponse)
+async def user_profile(user_id: str, request: Request,
+ user: dict = Depends(get_current_user),
+ db: aiosqlite.Connection = Depends(get_db)):
+ async with db.execute(
+ "SELECT id, name, handicap, avatar FROM users WHERE id = ? AND is_onboarded = 1",
+ (user_id,),
+ ) as c:
+ profile = await c.fetchone()
+ if not profile:
+ from fastapi.responses import RedirectResponse
+ return RedirectResponse("/", status_code=302)
+
+ uid = user_id
+
+ async with db.execute(
+ """SELECT COUNT(*) FROM games
+ WHERE status = 'completed'
+ AND id IN (SELECT game_id FROM game_players WHERE user_id = ?)""",
+ (uid,),
+ ) as c:
+ games_played = (await c.fetchone())[0]
+
+ async with db.execute(
+ """SELECT gp.game_id,
+ SUM(CASE WHEN hs.user_id = ? THEN hs.bingo + hs.bango + hs.bongo ELSE 0 END) AS my_total
+ FROM game_players gp
+ JOIN games g ON g.id = gp.game_id
+ JOIN hole_scores hs ON hs.game_id = gp.game_id
+ WHERE gp.user_id = ? AND g.status = 'completed'
+ GROUP BY gp.game_id""",
+ (uid, uid),
+ ) as c:
+ game_rows = await c.fetchall()
+
+ victories = 0
+ for row in game_rows:
+ async with db.execute(
+ """SELECT MAX(player_total) FROM (
+ SELECT user_id, SUM(bingo + bango + bongo) AS player_total
+ FROM hole_scores WHERE game_id = ? GROUP BY user_id
+ )""",
+ (row["game_id"],),
+ ) as c2:
+ max_total = (await c2.fetchone())[0] or 0
+ if row["my_total"] >= max_total and row["my_total"] > 0:
+ victories += 1
+
+ win_pct = round((victories / games_played) * 100) if games_played > 0 else None
+
+ return templates.TemplateResponse(
+ "users/profile.html",
+ {"request": request, "user": user, "profile": dict(profile),
+ "games_played": games_played, "victories": victories, "win_pct": win_pct},
+ )
diff --git a/app/templates/admin/admin.html b/app/templates/admin/admin.html
new file mode 100644
index 0000000..fbfb799
--- /dev/null
+++ b/app/templates/admin/admin.html
@@ -0,0 +1,108 @@
+{% extends "base.html" %}
+{% block title %}Admin – Skins & Pins{% endblock %}
+
+{% block content %}
+
+
+
+
+
+ {% if request.query_params.get('error') %}
+
{{ request.query_params.get('error') }}
+ {% endif %}
+ {% if request.query_params.get('success') %}
+
{{ request.query_params.get('success') }}
+ {% endif %}
+ {% if error %}
{{ error }}
{% endif %}
+ {% if success %}
{{ success }}
{% endif %}
+
+
+
+
+
Pending invitations
+ {% if invitations %}
+
+
+ Email Invited
+
+
+ {% for inv in invitations %}
+
+ {{ inv.email }}
+ {{ inv.created_at[:10] }}
+
+
+
+
+
+ {% endfor %}
+
+
+ {% else %}
+
No pending invitations.
+ {% endif %}
+
+
+
+
+ {% if users %}
+
+
+ User Hcp
+
+
+ {% for u in users %}
+
+
+ {% if u.avatar and u.avatar.startswith('/') %}
+
+ {% else %}
+ {{ u.avatar or '👤' }}
+ {% endif %}
+ {% if u.name %}{{ u.name }} {% else %}(not set up){% endif %}
+ {{ u.email }}
+
+ {{ u.handicap if u.handicap is not none else '—' }}
+
+ Edit
+ {% if u.id != user.id %}
+
+ {% endif %}
+
+
+ {% endfor %}
+
+
+ {% else %}
+
No users yet.
+ {% endif %}
+
+
+{% endblock %}
diff --git a/app/templates/admin/courses.html b/app/templates/admin/courses.html
new file mode 100644
index 0000000..a1c175b
--- /dev/null
+++ b/app/templates/admin/courses.html
@@ -0,0 +1,56 @@
+{% extends "base.html" %}
+{% block title %}Courses – Skins & Pins{% endblock %}
+
+{% block content %}
+
+
+
+
+
+ {% if request.query_params.get('success') %}
+
{{ request.query_params.get('success') }}
+ {% endif %}
+
+
+
+ {% if courses %}
+
+
+
+ Course Holes Location
+
+
+ {% for c in courses %}
+
+ {{ c.name }}
+ {{ c.holes }}
+ {{ c.location or '—' }}
+
+ Edit
+
+
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+ {% endif %}
+
+{% endblock %}
diff --git a/app/templates/admin/edit_course.html b/app/templates/admin/edit_course.html
new file mode 100644
index 0000000..f2fbcec
--- /dev/null
+++ b/app/templates/admin/edit_course.html
@@ -0,0 +1,149 @@
+{% extends "base.html" %}
+{% block title %}Edit Course – Skins & Pins{% endblock %}
+
+{% block content %}
+
+
+
+ {% if request.query_params.get('success') %}
+
{{ request.query_params.get('success') }}
+ {% endif %}
+
+ {# ── Course info + hole pars ── #}
+
+
+ {# ── Tees ── #}
+
+
Tees
+
+ {% if tees %}
+
+ {% for t in tees %}
+
+
+
+ {{ t.name }}
+ Slope {{ t.slope }}
+ Rating {{ t.rating }}
+
+ Edit
+
+
+
+
+
+
+
+ {% endfor %}
+
+ {% else %}
+
No tees yet. Add at least one tee so players can start a game on this course.
+ {% endif %}
+
+ {# Add tee form #}
+
Add tee
+
+
+
+{% endblock %}
diff --git a/app/templates/admin/edit_user.html b/app/templates/admin/edit_user.html
new file mode 100644
index 0000000..a8d2528
--- /dev/null
+++ b/app/templates/admin/edit_user.html
@@ -0,0 +1,84 @@
+{% extends "base.html" %}
+{% block title %}Edit User – Skins & Pins{% endblock %}
+
+{% block extra_head %}
+
+
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+ {% include "partials/crop_modal.html" %}
+
+
+{% endblock %}
diff --git a/app/templates/admin/games.html b/app/templates/admin/games.html
new file mode 100644
index 0000000..1031024
--- /dev/null
+++ b/app/templates/admin/games.html
@@ -0,0 +1,64 @@
+{% extends "base.html" %}
+{% block title %}Games – Admin – Skins & Pins{% endblock %}
+
+{% block content %}
+
+
+
+
+
+ {% if request.query_params.get('success') %}
+
{{ request.query_params.get('success') }}
+ {% endif %}
+
+ {% if games %}
+
+ {% for g in games %}
+ {% set plist = players_by_game.get(g.id, []) %}
+
+
+
+
+ {{ g.course_name or 'Unknown course' }}
+ {{ g.status }}
+
+
+ {{ g.holes_count }} holes · {{ g.created_at[:10] }} · by {{ g.creator_name }}
+
+
+ {% for p in plist %}
+ {% if p.avatar and p.avatar.startswith('/') %}
+
+ {% else %}
+ {{ p.avatar or '👤' }}
+ {% endif %}
+
{{ p.name }} {% if not loop.last %}, {% endif %}
+ {% endfor %}
+
+
+
+
+
+
+ {% endfor %}
+
+ {% else %}
+
+ {% endif %}
+
+{% endblock %}
diff --git a/app/templates/admin/new_course.html b/app/templates/admin/new_course.html
new file mode 100644
index 0000000..936d0da
--- /dev/null
+++ b/app/templates/admin/new_course.html
@@ -0,0 +1,77 @@
+{% extends "base.html" %}
+{% block title %}New Course – Skins & Pins{% endblock %}
+
+{% block content %}
+
+{% endblock %}
diff --git a/app/templates/admin/new_user.html b/app/templates/admin/new_user.html
new file mode 100644
index 0000000..ce118d9
--- /dev/null
+++ b/app/templates/admin/new_user.html
@@ -0,0 +1,81 @@
+{% extends "base.html" %}
+{% block title %}Add User – Skins & Pins{% endblock %}
+
+{% block extra_head %}
+
+
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+ {% include "partials/crop_modal.html" %}
+
+
+{% endblock %}
diff --git a/app/templates/auth/check_email.html b/app/templates/auth/check_email.html
new file mode 100644
index 0000000..db89e69
--- /dev/null
+++ b/app/templates/auth/check_email.html
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+ Check your email – Skins & Pins
+
+
+
+
📬
+
Check your email
+
We sent a login link to {{ email }}
+
+
+
+ The link expires in 15 minutes. You can close this tab.
+
+
+
+
+ ← Use a different email
+
+
+
+
diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html
new file mode 100644
index 0000000..361b582
--- /dev/null
+++ b/app/templates/auth/login.html
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+ Login – Skins & Pins
+
+
+
+
⛳
+
Skins & Pins
+
Enter your email to get a login link
+
+
+ {% if error %}
+
{{ error }}
+ {% endif %}
+
+
+
+
+
+
diff --git a/app/templates/base.html b/app/templates/base.html
new file mode 100644
index 0000000..857850b
--- /dev/null
+++ b/app/templates/base.html
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+
+
+
+
+
+ {% block title %}Skins & Pins{% endblock %}
+
+
+ {% block extra_head %}{% endblock %}
+
+
+
+ {# PWA install banner #}
+
+
+
Install Skins & Pins for the best experience
+
+ Not now
+ Install
+
+
+
+ Install: tap Share then Add to Home Screen
+ ✕
+
+
+
+ {% block content %}{% endblock %}
+
+ {% if user is defined %}
+ {% include "nav.html" %}
+ {% endif %}
+
+
+
+
diff --git a/app/templates/dashboard/dashboard.html b/app/templates/dashboard/dashboard.html
new file mode 100644
index 0000000..ad2161b
--- /dev/null
+++ b/app/templates/dashboard/dashboard.html
@@ -0,0 +1,105 @@
+{% extends "base.html" %}
+{% block title %}Dashboard – Skins & Pins{% endblock %}
+
+{% block content %}
+
+
+
+ {% if games_played > 0 %}
+ {# Stats row #}
+
+
+
{{ games_played }}
+
Games played
+
+
+
{{ victories }}
+
Victories
+
+
+
+ {{ ((victories / games_played) * 100) | round | int }}%
+
+
Win rate
+
+
+ {% endif %}
+
+ {# Social stream: active games #}
+ {% if active_games %}
+
Live games
+ {% for game in active_games %}
+
+
+
{{ game.course_name or '—' }}
+ {% if game.is_participant %}
+
▶ Play
+ {% endif %}
+
+
+ {{ game.format | replace('_', ' ') | title }} · {{ game.holes_count }} holes · {{ game.created_at[:10] }}
+
+ {% for p in game.players %}
+
+
{{ loop.index }}
+
+ {% if p.avatar and p.avatar.startswith('/') %}
+
+ {% else %}
+ {{ p.avatar or '👤' }}
+ {% endif %}
+
+
{{ p.name }}
+
{{ p.total | round(1) }} pts
+
+ {% endfor %}
+
+ {% endfor %}
+ {% endif %}
+
+ {# Recent games #}
+ {% if recent_games %}
+
Recent games
+ {% for game in recent_games %}
+
+
{{ game.course_name or '—' }}
+
+ {{ game.format | replace('_', ' ') | title }} · {{ game.holes_count }} holes · {{ game.created_at[:10] }}
+
+ {% for r in game.results %}
+
+
{% if loop.first %}🏆{% else %}{{ loop.index }}{% endif %}
+
+ {% if r.avatar and r.avatar.startswith('/') %}
+
+ {% else %}
+ {{ r.avatar or '👤' }}
+ {% endif %}
+
+
{{ r.name }}
+
{{ r.total | round(1) }} pts
+
+ {% endfor %}
+
+ {% endfor %}
+ {% endif %}
+
+ {% if not games_played and not active_games %}
+
+ {% endif %}
+
+{% endblock %}
diff --git a/app/templates/games/game.html b/app/templates/games/game.html
new file mode 100644
index 0000000..f1dce3e
--- /dev/null
+++ b/app/templates/games/game.html
@@ -0,0 +1,442 @@
+{% extends "base.html" %}
+{% block title %}Game – Skins & Pins{% endblock %}
+
+
+{% block content %}
+
+
+ {# ── Header ── #}
+
+
+ {# ── Swipeable panels ── #}
+
+
+ {# Score entry panel #}
+
+
+ {# Scorecard panel #}
+
+
+
{# end swipe-container #}
+
+ {# ── View toggle ── #}
+
+ Score
+ Card
+
+
+
+
+
+
+{% endblock %}
diff --git a/app/templates/games/games.html b/app/templates/games/games.html
new file mode 100644
index 0000000..6b055d7
--- /dev/null
+++ b/app/templates/games/games.html
@@ -0,0 +1,39 @@
+{% extends "base.html" %}
+{% block title %}Games – Skins & Pins{% endblock %}
+
+{% block content %}
+
+{% endblock %}
diff --git a/app/templates/games/new_game.html b/app/templates/games/new_game.html
new file mode 100644
index 0000000..7feea50
--- /dev/null
+++ b/app/templates/games/new_game.html
@@ -0,0 +1,141 @@
+{% extends "base.html" %}
+{% block title %}New Game – Skins & Pins{% endblock %}
+
+{% block extra_head %}
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+ {% if error %}
{{ error }}
{% endif %}
+
+
+
+{% endblock %}
diff --git a/app/templates/games/summary.html b/app/templates/games/summary.html
new file mode 100644
index 0000000..9e211fe
--- /dev/null
+++ b/app/templates/games/summary.html
@@ -0,0 +1,154 @@
+{% extends "base.html" %}
+{% block title %}{{ game.course_name or 'Game' }} – Skins & Pins{% endblock %}
+
+{% block content %}
+
+
+
+ {# Leaderboard #}
+
+ {% for p in results %}
+ {% set t = totals.get(p.id, {}) %}
+
+
{% if loop.first %}🏆{% else %}{{ loop.index }}{% endif %}
+
+ {% if p.avatar and p.avatar.startswith('/') %}
+
+ {% else %}
+ {{ p.avatar or '👤' }}
+ {% endif %}
+
+
{{ p.name }}
+
{{ t.total | round(1) }} pts
+
+ B{{ t.bingo | int }}/{{ t.bango | round(1) }}/{{ t.bongo | int }}
+
+
+ {% endfor %}
+
+
+ {# Scorecard toggle #}
+
+ Strokes
+ Points
+
+
+ {# Strokes scorecard #}
+
+
+
+ Hole
+ {% if hole_pars %}Par {% endif %}
+ {% for p in players %}
+
+ {% if p.avatar and p.avatar.startswith('/') %}
+
+ {% else %}
+ {{ p.avatar or '👤' }}
+ {% endif %}
+ {{ p.name.split()[0] }}
+
+ {% endfor %}
+
+
+
+ {% for h in holes %}
+
+ {{ h }}
+ {% if hole_pars %}{{ hole_pars.get(h, '—') }} {% endif %}
+ {% for p in players %}
+ {% set s = scores.get(h, {}).get(p.id, {}) %}
+ {% set strokes = s.strokes if s else 0 %}
+ {% set par = hole_pars.get(h, 0) %}
+
+ {% if strokes %}
+ {% if par %}
+ {% set diff = strokes - par %}
+ {% if strokes == 1 %}
+ {{ strokes }}
+ {% elif diff <= -2 %}
+ {{ strokes }}
+ {% elif diff == -1 %}
+ {{ strokes }}
+ {% elif diff == 1 %}
+ {{ strokes }}
+ {% elif diff >= 2 %}
+ {{ strokes }}
+ {% else %}
+ {{ strokes }}
+ {% endif %}
+ {% else %}
+ {{ strokes }}
+ {% endif %}
+ {% else %}
+ —
+ {% endif %}
+
+ {% endfor %}
+
+ {% endfor %}
+
+
+
+ Total
+ {% if hole_pars %}{{ hole_pars.values() | sum }} {% endif %}
+ {% for p in players %}
+ {% set stroke_total = namespace(val=0) %}
+ {% for h in holes %}
+ {% set s = scores.get(h, {}).get(p.id, {}) %}
+ {% if s and s.strokes %}{% set stroke_total.val = stroke_total.val + s.strokes %}{% endif %}
+ {% endfor %}
+ {{ stroke_total.val or '—' }}
+ {% endfor %}
+
+
+
+
+ {# Points scorecard #}
+
+
+
+ Hole
+ {% for p in players %}
+
+ {% if p.avatar and p.avatar.startswith('/') %}
+
+ {% else %}
+ {{ p.avatar or '👤' }}
+ {% endif %}
+ {{ p.name.split()[0] }}
+
+ {% endfor %}
+
+
+
+ {% for h in holes %}
+
+ {{ h }}
+ {% for p in players %}
+ {% set s = scores.get(h, {}).get(p.id, {}) %}
+ {% set pts = (s.bingo + s.bango + s.bongo) if s else 0 %}
+ {{ pts | round(1) if pts else '—' }}
+ {% endfor %}
+
+ {% endfor %}
+
+
+
+ Total
+ {% for p in players %}
+ {{ totals.get(p.id, {}).get('total', 0) | round(1) }}
+ {% endfor %}
+
+
+
+
+
+{% endblock %}
diff --git a/app/templates/nav.html b/app/templates/nav.html
new file mode 100644
index 0000000..3399112
--- /dev/null
+++ b/app/templates/nav.html
@@ -0,0 +1,48 @@
+{% set path = request.url.path %}
+
+
+
+
+
+
+
+
+ Home
+
+
+
+
+
+
+
+
+
+ Games
+
+ {% if user.email == admin_email %}
+
+
+
+
+
+
+
+ Admin
+
+ {% endif %}
+
+
+ {% if user.avatar and user.avatar.startswith('/') %}
+
+ {% elif user.avatar %}
+ {{ user.avatar }}
+ {% else %}
+
+
+
+
+ {% endif %}
+
+ Profile
+
+
diff --git a/app/templates/onboarding/setup.html b/app/templates/onboarding/setup.html
new file mode 100644
index 0000000..dddcbbf
--- /dev/null
+++ b/app/templates/onboarding/setup.html
@@ -0,0 +1,76 @@
+{% extends "base.html" %}
+{% block title %}Welcome – Skins & Pins{% endblock %}
+
+{% block extra_head %}
+
+
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+ {% include "partials/crop_modal.html" %}
+
+{% endblock %}
diff --git a/app/templates/partials/crop_modal.html b/app/templates/partials/crop_modal.html
new file mode 100644
index 0000000..2fcb2aa
--- /dev/null
+++ b/app/templates/partials/crop_modal.html
@@ -0,0 +1,17 @@
+{# Crop modal — requires avatarUpload() Alpine component on a parent element #}
+
+
+
+
+
+
+
Pinch or scroll to zoom · drag to reposition
+
+ Cancel
+ Use photo
+
+
+
diff --git a/app/templates/profile/profile.html b/app/templates/profile/profile.html
new file mode 100644
index 0000000..6024f66
--- /dev/null
+++ b/app/templates/profile/profile.html
@@ -0,0 +1,83 @@
+{% extends "base.html" %}
+{% block title %}Profile – Skins & Pins{% endblock %}
+
+{% block extra_head %}
+
+
+
+{% endblock %}
+
+{% block content %}
+
+
+
+ {% if success %}
{{ success }}
{% endif %}
+
+
+
+
+ {% include "partials/crop_modal.html" %}
+
+
+
+
+{% endblock %}
diff --git a/app/templates/users/profile.html b/app/templates/users/profile.html
new file mode 100644
index 0000000..8a973f7
--- /dev/null
+++ b/app/templates/users/profile.html
@@ -0,0 +1,43 @@
+{% extends "base.html" %}
+{% block title %}{{ profile.name }} – Skins & Pins{% endblock %}
+
+{% block content %}
+
+
+
+
+ {% if profile.avatar and profile.avatar.startswith('/') %}
+
+ {% else %}
+
{{ profile.avatar or '👤' }}
+ {% endif %}
+
+
+
{{ profile.name }}
+
+ Handicap {{ profile.handicap if profile.handicap is not none else '—' }}
+
+
+
+
+ {% if games_played > 0 %}
+
+
+
{{ games_played }}
+
Games played
+
+
+
{{ victories }}
+
Victories
+
+
+
{{ win_pct }}%
+
Win rate
+
+
+ {% endif %}
+
+{% endblock %}
diff --git a/app/templates_config.py b/app/templates_config.py
new file mode 100644
index 0000000..64d8ae0
--- /dev/null
+++ b/app/templates_config.py
@@ -0,0 +1,5 @@
+import os
+from fastapi.templating import Jinja2Templates
+
+templates = Jinja2Templates(directory="app/templates")
+templates.env.globals["admin_email"] = os.getenv("ADMIN_EMAIL", "")
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
new file mode 100644
index 0000000..2a46c1c
--- /dev/null
+++ b/docker-compose.dev.yml
@@ -0,0 +1,20 @@
+services:
+ app:
+ build: .
+ ports:
+ - "8000:8000"
+ volumes:
+ - data:/data
+ - ./app:/app/app
+ - ./static:/app/static
+ env_file:
+ - .env
+ command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
+ networks:
+ - internal
+
+volumes:
+ data:
+
+networks:
+ internal:
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..9dea85f
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,25 @@
+services:
+ app:
+ build: .
+ restart: unless-stopped
+ volumes:
+ - data:/data
+ env_file:
+ - .env
+ labels:
+ - "traefik.enable=true"
+ - "traefik.http.routers.skinspins.rule=Host(`${APP_DOMAIN}`)"
+ - "traefik.http.routers.skinspins.entrypoints=websecure"
+ - "traefik.http.routers.skinspins.tls.certresolver=letsencrypt"
+ - "traefik.http.services.skinspins.loadbalancer.server.port=8000"
+ networks:
+ - traefik
+ - internal
+
+volumes:
+ data:
+
+networks:
+ traefik:
+ external: true
+ internal:
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..5cc0a99
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,8 @@
+fastapi==0.115.5
+uvicorn[standard]==0.32.1
+aiosqlite==0.20.0
+jinja2==3.1.4
+python-multipart==0.0.12
+python-dotenv==1.0.1
+itsdangerous==2.2.0
+aiosmtplib==3.0.1
diff --git a/static/avatar-crop.js b/static/avatar-crop.js
new file mode 100644
index 0000000..148fe7a
--- /dev/null
+++ b/static/avatar-crop.js
@@ -0,0 +1,72 @@
+function avatarUpload(formAction, redirectTo) {
+ return {
+ tab: 'emoji',
+ showModal: false,
+ previewUrl: null,
+ croppedBlob: null,
+ cropper: null,
+
+ openFile(e) {
+ const file = e.target.files[0];
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = (ev) => {
+ this.previewUrl = ev.target.result;
+ this.showModal = true;
+ this.$nextTick(() => this._initCropper());
+ };
+ reader.readAsDataURL(file);
+ // Clear the input so re-selecting the same file triggers change again
+ e.target.value = '';
+ },
+
+ _initCropper() {
+ if (this.cropper) { this.cropper.destroy(); this.cropper = null; }
+ const img = this.$refs.cropImg;
+ this.cropper = new Cropper(img, {
+ aspectRatio: 1,
+ viewMode: 1,
+ dragMode: 'move',
+ autoCropArea: 1,
+ cropBoxMovable: false,
+ cropBoxResizable: false,
+ toggleDragModeOnDblclick: false,
+ background: false,
+ });
+ },
+
+ confirmCrop() {
+ const canvas = this.cropper.getCroppedCanvas({ width: 256, height: 256 });
+ canvas.toBlob((blob) => {
+ this.croppedBlob = blob;
+ this.previewUrl = canvas.toDataURL('image/jpeg');
+ this.showModal = false;
+ this.cropper.destroy();
+ this.cropper = null;
+ }, 'image/jpeg', 0.88);
+ },
+
+ cancelCrop() {
+ this.showModal = false;
+ if (this.cropper) { this.cropper.destroy(); this.cropper = null; }
+ if (!this.croppedBlob) this.previewUrl = null;
+ },
+
+ async submitForm(e) {
+ e.preventDefault();
+ const form = e.target;
+ const fd = new FormData(form);
+
+ if (this.tab === 'upload' && this.croppedBlob) {
+ fd.delete('avatar_file');
+ fd.set('avatar_file', this.croppedBlob, 'avatar.jpg');
+ fd.set('avatar_emoji', '');
+ } else if (this.tab === 'emoji') {
+ fd.delete('avatar_file');
+ }
+
+ const resp = await fetch(formAction, { method: 'POST', body: fd, redirect: 'follow' });
+ window.location.href = resp.url || redirectTo;
+ },
+ };
+}
diff --git a/static/icons/icon-192.png b/static/icons/icon-192.png
new file mode 100644
index 0000000..7f4e832
Binary files /dev/null and b/static/icons/icon-192.png differ
diff --git a/static/icons/icon-512.png b/static/icons/icon-512.png
new file mode 100644
index 0000000..4efacea
Binary files /dev/null and b/static/icons/icon-512.png differ
diff --git a/static/icons/icon.svg b/static/icons/icon.svg
new file mode 100644
index 0000000..a979fc6
--- /dev/null
+++ b/static/icons/icon.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/manifest.json b/static/manifest.json
new file mode 100644
index 0000000..3c8f8b8
--- /dev/null
+++ b/static/manifest.json
@@ -0,0 +1,24 @@
+{
+ "name": "Skins & Pins",
+ "short_name": "Skins&Pins",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "orientation": "portrait",
+ "background_color": "#1a1a2e",
+ "theme_color": "#2d6a4f",
+ "icons": [
+ {
+ "src": "/static/icons/icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/static/icons/icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any maskable"
+ }
+ ]
+}
diff --git a/static/style.css b/static/style.css
new file mode 100644
index 0000000..6572262
--- /dev/null
+++ b/static/style.css
@@ -0,0 +1,967 @@
+:root {
+ --green: #2d6a4f;
+ --green-light: #40916c;
+ --green-dark: #1b4332;
+ --bg: #1a1a2e;
+ --surface: #16213e;
+ --surface2: #0f3460;
+ --text: #e8f5e9;
+ --text-muted: #a8c5a0;
+ --accent: #52b788;
+ --danger: #e63946;
+ --nav-height: 64px;
+}
+
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+ -webkit-tap-highlight-color: transparent;
+}
+
+html, body {
+ height: 100%;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ font-size: 16px;
+}
+
+body {
+ padding-bottom: calc(var(--nav-height) + env(safe-area-inset-bottom));
+}
+
+/* ── Layout ── */
+.page {
+ max-width: 480px;
+ margin: 0 auto;
+ padding: 1.5rem 1rem;
+}
+
+/* ── PWA Install Banner ── */
+#pwa-banner {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: 200;
+ background: var(--green-dark);
+ border-bottom: 1px solid var(--green-light);
+ padding: 0.65rem 1rem;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ font-size: 0.85rem;
+ color: var(--text);
+}
+#pwa-banner-android,
+#pwa-banner-ios {
+ width: 100%;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+}
+.pwa-banner-actions { display: flex; gap: 0.4rem; flex-shrink: 0; }
+.pwa-install-btn {
+ padding: 0.35rem 0.75rem;
+ border-radius: 0.4rem;
+ border: 1px solid var(--green-light);
+ background: transparent;
+ color: var(--text);
+ font-size: 0.8rem;
+ cursor: pointer;
+}
+.pwa-install-primary {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: #000;
+ font-weight: 600;
+}
+
+/* ── Bottom Nav ── */
+.bottom-nav {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: calc(var(--nav-height) + env(safe-area-inset-bottom));
+ padding-bottom: env(safe-area-inset-bottom);
+ background: var(--surface);
+ border-top: 1px solid var(--surface2);
+ display: flex;
+ justify-content: space-around;
+ align-items: center;
+ z-index: 100;
+}
+
+.nav-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 2px;
+ flex: 1;
+ height: var(--nav-height);
+ text-decoration: none;
+ color: var(--text-muted);
+ font-size: 0.65rem;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ transition: color 0.15s;
+ min-width: 44px;
+}
+
+.nav-item:hover,
+.nav-item.active {
+ color: var(--accent);
+}
+
+.nav-icon {
+ font-size: 1.75rem;
+ line-height: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+}
+
+.nav-icon svg {
+ width: 26px;
+ height: 26px;
+}
+
+.nav-avatar-img {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ object-fit: cover;
+}
+
+/* ── Cards ── */
+.card {
+ background: var(--surface);
+ border-radius: 12px;
+ padding: 1.25rem;
+ margin-bottom: 1rem;
+}
+
+/* ── Forms ── */
+.form-group {
+ margin-bottom: 1.25rem;
+}
+
+label {
+ display: block;
+ font-size: 0.85rem;
+ color: var(--text-muted);
+ margin-bottom: 0.4rem;
+ font-weight: 500;
+}
+
+input[type="text"],
+input[type="email"],
+input[type="number"] {
+ width: 100%;
+ padding: 0.75rem 1rem;
+ border: 1px solid var(--surface2);
+ border-radius: 8px;
+ background: var(--bg);
+ color: var(--text);
+ font-size: 1rem;
+ outline: none;
+ transition: border-color 0.15s;
+}
+
+input:focus {
+ border-color: var(--accent);
+}
+
+/* ── Buttons ── */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.85rem 1.5rem;
+ border: none;
+ border-radius: 8px;
+ font-size: 1rem;
+ font-weight: 600;
+ cursor: pointer;
+ text-decoration: none;
+ transition: opacity 0.15s;
+ width: 100%;
+}
+
+.btn:active { opacity: 0.8; }
+
+.btn-primary {
+ background: var(--green);
+ color: #fff;
+}
+
+.btn-danger {
+ background: var(--danger);
+ color: #fff;
+}
+
+.btn-ghost {
+ background: transparent;
+ color: var(--accent);
+ border: 1px solid var(--accent);
+}
+
+/* ── Alerts ── */
+.alert {
+ padding: 0.75rem 1rem;
+ border-radius: 8px;
+ margin-bottom: 1rem;
+ font-size: 0.9rem;
+}
+
+.alert-error { background: rgba(230,57,70,0.15); color: #ff8fa3; border: 1px solid rgba(230,57,70,0.3); }
+.alert-success { background: rgba(82,183,136,0.15); color: var(--accent); border: 1px solid rgba(82,183,136,0.3); }
+
+/* ── Avatar picker ── */
+.avatar-grid {
+ display: grid;
+ grid-template-columns: repeat(6, 1fr);
+ gap: 0.5rem;
+}
+
+.avatar-option input[type="radio"] {
+ display: none;
+}
+
+.avatar-option label {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.75rem;
+ width: 100%;
+ aspect-ratio: 1;
+ border-radius: 8px;
+ border: 2px solid transparent;
+ background: var(--bg);
+ cursor: pointer;
+ transition: border-color 0.15s;
+}
+
+.avatar-option input[type="radio"]:checked + label {
+ border-color: var(--accent);
+ background: rgba(82,183,136,0.15);
+}
+
+/* ── Avatar tabs ── */
+.avatar-tabs {
+ display: flex;
+ gap: 0.5rem;
+ margin-bottom: 0.25rem;
+}
+
+.avatar-tab {
+ flex: 1;
+ padding: 0.5rem;
+ border: 1px solid var(--surface2);
+ border-radius: 6px;
+ background: var(--bg);
+ color: var(--text-muted);
+ font-size: 0.85rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: color 0.15s, border-color 0.15s;
+}
+
+.avatar-tab.active {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+.file-input {
+ width: 100%;
+ padding: 0.65rem;
+ border: 1px dashed var(--surface2);
+ border-radius: 8px;
+ background: var(--bg);
+ color: var(--text-muted);
+ font-size: 0.9rem;
+ cursor: pointer;
+}
+
+.file-input::-webkit-file-upload-button {
+ background: var(--green);
+ color: #fff;
+ border: none;
+ padding: 0.4rem 0.75rem;
+ border-radius: 4px;
+ cursor: pointer;
+ margin-right: 0.75rem;
+}
+
+.avatar-preview {
+ width: 64px;
+ height: 64px;
+ border-radius: 50%;
+ object-fit: cover;
+ border: 2px solid var(--accent);
+}
+
+/* ── Page header ── */
+.page-header {
+ margin-bottom: 1.5rem;
+}
+
+.page-header h1 {
+ font-size: 1.5rem;
+ font-weight: 700;
+}
+
+.page-header p {
+ color: var(--text-muted);
+ font-size: 0.9rem;
+ margin-top: 0.25rem;
+}
+
+/* ── Empty state ── */
+.empty-state {
+ text-align: center;
+ padding: 3rem 1rem;
+ color: var(--text-muted);
+}
+
+.empty-state .empty-icon {
+ font-size: 3rem;
+ margin-bottom: 1rem;
+}
+
+/* ── Table ── */
+.data-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.85rem;
+}
+
+.data-table th {
+ text-align: left;
+ color: var(--text-muted);
+ padding: 0.5rem 0;
+ border-bottom: 1px solid var(--surface2);
+ font-weight: 500;
+}
+
+.data-table td {
+ padding: 0.6rem 0;
+ border-bottom: 1px solid rgba(255,255,255,0.05);
+}
+
+/* ── Login page ── */
+.login-container {
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 2rem 1.5rem;
+}
+
+.login-logo {
+ font-size: 3rem;
+ margin-bottom: 0.5rem;
+}
+
+.login-title {
+ font-size: 1.75rem;
+ font-weight: 700;
+ margin-bottom: 0.25rem;
+}
+
+.login-subtitle {
+ color: var(--text-muted);
+ margin-bottom: 2rem;
+ font-size: 0.9rem;
+}
+
+.login-card {
+ width: 100%;
+ max-width: 360px;
+}
+
+/* ── Game card (list) ── */
+.game-card { transition: opacity 0.15s; }
+.game-card:active { opacity: 0.7; }
+.status-badge {
+ font-size: 0.7rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ padding: 0.2rem 0.5rem;
+ border-radius: 4px;
+ letter-spacing: 0.05em;
+}
+.status-active { background: rgba(82,183,136,0.2); color: var(--accent); }
+.status-completed { background: rgba(255,255,255,0.08); color: var(--text-muted); }
+.status-lobby { background: rgba(255,200,80,0.15); color: #ffc850; }
+
+/* ── New game form ── */
+.radio-card {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.35rem;
+ padding: 0.75rem;
+ border: 2px solid var(--surface2);
+ border-radius: 10px;
+ cursor: pointer;
+ font-size: 1rem;
+ font-weight: 600;
+ transition: border-color 0.15s;
+}
+.radio-card input { display: none; }
+.radio-card:has(input:checked) { border-color: var(--accent); color: var(--accent); }
+
+.player-list { display: flex; flex-direction: column; gap: 0.5rem; }
+.player-option {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.65rem 0.75rem;
+ border: 2px solid var(--surface2);
+ border-radius: 10px;
+ cursor: pointer;
+ transition: border-color 0.15s;
+}
+.player-option:has(input:checked) { border-color: var(--accent); }
+.player-option input { display: none; }
+.player-avatar { font-size: 1.5rem; }
+.player-name { flex: 1; font-weight: 500; }
+
+/* ── Game screen ── */
+.game-header {
+ padding: 0.75rem 1rem 0.5rem;
+ background: var(--surface);
+ border-bottom: 1px solid var(--surface2);
+ flex-shrink: 0;
+}
+
+.hole-label {
+ font-size: 1rem;
+ font-weight: 700;
+}
+
+.icon-btn {
+ background: none;
+ border: none;
+ color: var(--text);
+ font-size: 1.75rem;
+ padding: 0 0.5rem;
+ cursor: pointer;
+ line-height: 1;
+ opacity: 0.8;
+}
+.icon-btn:disabled { opacity: 0.2; cursor: default; }
+
+.swipe-container {
+ flex: 1;
+ overflow: hidden;
+ position: relative;
+}
+
+.swipe-panel {
+ position: absolute;
+ inset: 0;
+ opacity: 0;
+ pointer-events: none;
+ transform: translateX(30px);
+ transition: opacity 0.2s, transform 0.2s;
+}
+.swipe-panel.active {
+ opacity: 1;
+ pointer-events: all;
+ transform: translateX(0);
+}
+
+.panel-scroll {
+ height: 100%;
+ overflow-y: auto;
+ padding: 1rem;
+ -webkit-overflow-scrolling: touch;
+}
+
+/* ── Score sections ── */
+.score-section {
+ margin-bottom: 1.25rem;
+}
+
+.score-label {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ margin-bottom: 0.6rem;
+}
+
+.score-badge {
+ font-size: 0.65rem;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+ padding: 0.2rem 0.5rem;
+ border-radius: 4px;
+}
+.bingo-badge { background: rgba(255,190,50,0.2); color: #ffbe32; }
+.bango-badge { background: rgba(82,183,136,0.2); color: var(--accent); }
+.bongo-badge { background: rgba(120,120,255,0.2); color: #9090ff; }
+
+.score-desc { color: var(--text-muted); font-size: 0.82rem; }
+
+.player-buttons {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.player-btn {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ padding: 0.55rem 0.85rem;
+ border: 2px solid var(--surface2);
+ border-radius: 10px;
+ background: var(--surface);
+ color: var(--text);
+ cursor: pointer;
+ font-size: 0.9rem;
+ transition: border-color 0.15s, background 0.15s;
+}
+.player-btn.selected {
+ border-color: var(--accent);
+ background: rgba(82,183,136,0.15);
+}
+.pbtn-avatar { font-size: 1.2rem; }
+.pbtn-name { font-weight: 500; }
+.pbtn-pts { color: var(--accent); font-weight: 700; font-size: 0.8rem; }
+
+.save-btn { margin-top: 0.5rem; }
+.save-btn.saved:not(.dirty) { background: var(--green-dark); opacity: 0.8; }
+
+.btn-skip {
+ background: none;
+ border: 1px solid var(--surface2);
+ color: var(--text-muted);
+ border-radius: 6px;
+ padding: 0.3rem 0.75rem;
+ font-size: 0.8rem;
+ cursor: pointer;
+}
+.btn-skip.skipped { border-color: var(--danger); color: #ff8fa3; }
+
+/* ── Scorecard table ── */
+.scorecard {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.85rem;
+}
+.scorecard th {
+ text-align: center;
+ padding: 0.4rem 0.3rem;
+ border-bottom: 1px solid var(--surface2);
+ color: var(--text-muted);
+ font-weight: 600;
+}
+.scorecard th:first-child { text-align: left; }
+.scorecard td {
+ text-align: center;
+ padding: 0.45rem 0.3rem;
+ border-bottom: 1px solid rgba(255,255,255,0.04);
+}
+.hole-num { text-align: left; color: var(--text-muted); font-weight: 600; }
+.score-cell { font-weight: 500; }
+.hole-skipped td { opacity: 0.35; }
+.totals-row td {
+ font-weight: 700;
+ color: var(--accent);
+ padding-top: 0.6rem;
+ border-top: 1px solid var(--surface2);
+}
+
+/* ── View toggle ── */
+.view-toggle {
+ display: flex;
+ border-top: 1px solid var(--surface2);
+ background: var(--surface);
+ flex-shrink: 0;
+}
+.view-toggle button {
+ flex: 1;
+ padding: 0.6rem;
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ font-size: 0.85rem;
+ font-weight: 600;
+ cursor: pointer;
+ border-bottom: 2px solid transparent;
+ transition: color 0.15s, border-color 0.15s;
+}
+.view-toggle button.active {
+ color: var(--accent);
+ border-bottom-color: var(--accent);
+}
+
+/* ── Avatar crop modal ── */
+[x-cloak] { display: none !important; }
+
+.crop-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,0.8);
+ z-index: 200;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1rem;
+}
+
+.crop-modal {
+ background: var(--surface);
+ border-radius: 14px;
+ width: 100%;
+ max-width: 380px;
+ overflow: hidden;
+}
+
+.crop-modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.85rem 1rem;
+ border-bottom: 1px solid var(--surface2);
+ font-weight: 600;
+}
+
+.crop-container {
+ width: 100%;
+ aspect-ratio: 1;
+ background: #000;
+ overflow: hidden;
+}
+
+/* Circular crop preview overlay */
+.crop-container .cropper-view-box,
+.crop-container .cropper-face {
+ border-radius: 50%;
+}
+
+.crop-hint {
+ text-align: center;
+ color: var(--text-muted);
+ font-size: 0.78rem;
+ padding: 0.5rem 1rem;
+}
+
+.crop-modal-actions {
+ display: flex;
+ gap: 0.75rem;
+ padding: 0.75rem 1rem 1rem;
+}
+
+/* ── File input button ── */
+.file-input-label {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.65rem 1rem;
+ border: 1px dashed var(--surface2);
+ border-radius: 8px;
+ background: var(--bg);
+ color: var(--text-muted);
+ font-size: 0.9rem;
+ cursor: pointer;
+ width: 100%;
+ transition: border-color 0.15s, color 0.15s;
+}
+.file-input-label:hover { border-color: var(--accent); color: var(--accent); }
+
+/* ── Per-player score card ── */
+.player-score-card {
+ display: flex;
+ flex-direction: column;
+ gap: 0.55rem;
+ background: var(--surface);
+ border-radius: 12px;
+ padding: 0.75rem 0.85rem;
+ margin-bottom: 0.6rem;
+}
+
+.psc-name {
+ font-weight: 700;
+ font-size: 1rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.psc-controls {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.drag-handle {
+ color: var(--text-muted);
+ font-size: 1.1rem;
+ cursor: grab;
+ margin-right: 0.5rem;
+ user-select: none;
+ touch-action: none;
+}
+.drag-handle:active { cursor: grabbing; }
+/* ── Golf score markers ── */
+.score-hio, .score-eagle, .score-birdie, .score-bogey, .score-double-bogey {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.6rem;
+ height: 1.6rem;
+ font-weight: 600;
+}
+.score-hio {
+ background: gold;
+ color: #000;
+ border-radius: 50%;
+}
+.score-eagle {
+ border: 2px solid #3b82f6;
+ border-radius: 50%;
+ outline: 2px solid #3b82f6;
+ outline-offset: 2px;
+ color: #3b82f6;
+}
+.score-birdie {
+ border: 2px solid var(--green-light);
+ border-radius: 50%;
+ color: var(--green-light);
+}
+.score-bogey {
+ border: 2px solid var(--danger);
+ color: var(--danger);
+}
+.score-double-bogey {
+ border: 2px solid #888;
+ outline: 2px solid #888;
+ outline-offset: 2px;
+ color: #888;
+}
+
+.drag-source { opacity: 0.4; }
+.drag-over { outline: 2px solid var(--accent); border-radius: 0.65rem; }
+
+.stroke-controls {
+ display: inline-flex;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ align-items: stretch;
+ flex-shrink: 0;
+ background: var(--bg);
+ border: 1px solid var(--surface2);
+ border-radius: 8px;
+ overflow: hidden;
+ vertical-align: middle;
+}
+
+.stroke-btn {
+ width: 2.8rem;
+ min-height: 3.2rem;
+ border: none;
+ background: none;
+ color: var(--text);
+ font-size: 1.25rem;
+ font-weight: 700;
+ cursor: pointer;
+ user-select: none;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background 0.1s;
+}
+.stroke-btn:active { background: var(--surface2); }
+
+.stroke-val {
+ min-width: 2.4rem;
+ min-height: 3.2rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.2rem;
+ font-weight: 700;
+ user-select: none;
+ flex-shrink: 0;
+}
+
+.bbb-btn {
+ height: 3.2rem;
+ padding: 0 0.75rem;
+ border-radius: 8px;
+ border: 2px solid transparent;
+ font-size: 0.78rem;
+ font-weight: 700;
+ letter-spacing: 0.03em;
+ cursor: pointer;
+ transition: background 0.15s, border-color 0.15s;
+ background: var(--bg);
+ color: var(--text-muted);
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+.bbb-btn:active { opacity: 0.7; }
+
+.bingo-btn.active { background: rgba(255,190,50,0.15); border-color: #ffbe32; color: #ffbe32; }
+.bango-btn.active { background: rgba(82,183,136,0.15); border-color: var(--accent); color: var(--accent); }
+.bongo-btn.active { background: rgba(120,120,255,0.15); border-color: #9090ff; color: #9090ff; }
+
+.bingo-btn { border-color: rgba(255,190,50,0.3); }
+.bango-btn { border-color: rgba(82,183,136,0.3); }
+.bongo-btn { border-color: rgba(120,120,255,0.3); }
+
+/* ── Scorecard sub-toggle ── */
+.scorecard-toggle {
+ display: flex;
+ gap: 0.5rem;
+ margin-bottom: 0.75rem;
+}
+
+.scorecard-toggle button {
+ flex: 1;
+ padding: 0.45rem;
+ background: var(--bg);
+ border: 1px solid var(--surface2);
+ border-radius: 6px;
+ color: var(--text-muted);
+ font-size: 0.85rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: color 0.15s, border-color 0.15s;
+}
+
+.scorecard-toggle button.active {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+/* ── Admin sub-navigation ── */
+.admin-subnav {
+ display: flex;
+ gap: 0.5rem;
+ margin-bottom: 1.25rem;
+}
+.admin-subnav-item {
+ padding: 0.4rem 1rem;
+ border-radius: 2rem;
+ font-size: 0.85rem;
+ font-weight: 500;
+ text-decoration: none;
+ color: var(--text-muted);
+ border: 1px solid var(--border);
+ transition: background 0.15s, color 0.15s;
+}
+.admin-subnav-item.active,
+.admin-subnav-item:hover {
+ background: var(--accent);
+ color: #fff;
+ border-color: var(--accent);
+}
+
+/* ── Hole par grid (course admin) ── */
+.hole-pars-grid {
+ display: grid;
+ grid-template-columns: repeat(9, 1fr);
+ gap: 0.4rem;
+}
+.hole-par-cell {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.2rem;
+}
+.hole-par-label {
+ font-size: 0.7rem;
+ color: var(--text-muted);
+ font-weight: 600;
+}
+.hole-par-select {
+ width: 100%;
+ padding: 0.35rem 0.1rem;
+ text-align: center;
+ border-radius: 0.4rem;
+ border: 1px solid var(--border);
+ background: var(--surface);
+ color: var(--text);
+ font-size: 0.9rem;
+}
+/* ── Table action buttons ── */
+.btn-table-action {
+ display: inline-block;
+ padding: 0.25rem 0.6rem;
+ border-radius: 5px;
+ font-size: 0.78rem;
+ font-weight: 600;
+ cursor: pointer;
+ text-decoration: none;
+ border: 1px solid var(--surface2);
+ background: var(--bg);
+ color: var(--text-muted);
+ margin-left: 0.25rem;
+}
+.btn-table-action.danger { border-color: rgba(230,57,70,0.4); color: #ff8fa3; }
+
+/* ── Dashboard stats ── */
+.stats-row {
+ display: flex;
+ gap: 0.75rem;
+ margin-bottom: 1.5rem;
+}
+
+.stat-card {
+ flex: 1;
+ background: var(--surface);
+ border-radius: 12px;
+ padding: 1rem 0.75rem;
+ text-align: center;
+}
+
+.stat-value {
+ font-size: 1.75rem;
+ font-weight: 800;
+ color: var(--accent);
+ line-height: 1;
+}
+
+.stat-label {
+ font-size: 0.7rem;
+ color: var(--text-muted);
+ margin-top: 0.3rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+/* ── Game result card ── */
+.game-result-card {
+ margin-bottom: 0.75rem;
+}
+
+.result-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.25rem 0;
+ font-size: 0.9rem;
+}
+
+.result-winner { color: var(--accent); font-weight: 600; }
+.result-rank { width: 1.4rem; text-align: center; font-size: 0.85rem; color: var(--text-muted); }
+.result-winner .result-rank { color: var(--accent); }
+.result-avatar { font-size: 1.1rem; line-height: 1; }
+.result-name { flex: 1; color: var(--text); font-weight: 500; }
+.result-winner .result-name { color: var(--accent); }
+.result-pts { font-weight: 600; font-size: 0.85rem; color: var(--text); }
+.result-winner .result-pts { color: var(--accent); }
diff --git a/static/sw.js b/static/sw.js
new file mode 100644
index 0000000..4d64511
--- /dev/null
+++ b/static/sw.js
@@ -0,0 +1,27 @@
+const CACHE = "skinspins-v1";
+const PRECACHE = ["/static/style.css", "/static/manifest.json"];
+
+self.addEventListener("install", (e) => {
+ e.waitUntil(caches.open(CACHE).then((c) => c.addAll(PRECACHE)));
+ self.skipWaiting();
+});
+
+self.addEventListener("activate", (e) => {
+ e.waitUntil(
+ caches.keys().then((keys) =>
+ Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
+ )
+ );
+ self.clients.claim();
+});
+
+self.addEventListener("fetch", (e) => {
+ // Network-first for navigation; cache-first for static assets
+ if (e.request.mode === "navigate") {
+ e.respondWith(fetch(e.request).catch(() => caches.match(e.request)));
+ } else {
+ e.respondWith(
+ caches.match(e.request).then((cached) => cached || fetch(e.request))
+ );
+ }
+});