From c51c872c332d4124edb81eece923e47c96841008 Mon Sep 17 00:00:00 2001 From: Rolf Date: Sun, 22 Mar 2026 09:18:04 +0100 Subject: [PATCH] Add Skins format, GolfCourseAPI import, and UX improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Skins game format with optional carryover, mirroring BBB scoring pattern - Add GolfCourseAPI integration for course search and import (httpx) - Unwrap {"course": {}} API response envelope on import - Add searchable course combobox and player filter to new game form - Redirect to summary page after finishing a game - Fix auth to allow registered users without a pending invitation - Fix hardcoded BBB format in create_game; store carryover flag in DB - Add game summary/scorecard view for completed games - Add live games social stream to dashboard - Add course name + subtitle to recent game cards - Reorder nav: Home → Games → Admin → Profile Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 4 + app/database.py | 1 + app/routers/auth.py | 23 +++-- app/routers/courses.py | 110 ++++++++++++++++++++++ app/routers/games.py | 64 +++++++++++-- app/templates/admin/course_search.html | 86 ++++++++++++++++++ app/templates/admin/courses.html | 3 +- app/templates/games/game.html | 121 ++++++++++++++++++++++--- app/templates/games/new_game.html | 97 +++++++++++++++++--- app/templates/games/summary.html | 89 +++++++++++++++++- requirements.txt | 1 + static/style.css | 1 + 12 files changed, 554 insertions(+), 46 deletions(-) create mode 100644 app/templates/admin/course_search.html diff --git a/.env.example b/.env.example index 6634400..44748b4 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,10 @@ ENV=development # Path to the SQLite database file (inside the container) DATABASE_PATH=/data/skinspins.db +# GolfCourseAPI key — get one at https://www.golfcourseapi.com +# Leave empty to disable the course search/import feature +GOLF_COURSE_API_KEY= + # SMTP email settings (leave SMTP_HOST empty to disable email and log links instead) SMTP_HOST=smtp.example.com SMTP_PORT=587 diff --git a/app/database.py b/app/database.py index 793a5fb..f1b4351 100644 --- a/app/database.py +++ b/app/database.py @@ -120,6 +120,7 @@ async def init_db(): "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", + "ALTER TABLE games ADD COLUMN carryover INTEGER NOT NULL DEFAULT 1", ]: try: await db.execute(migration) diff --git a/app/routers/auth.py b/app/routers/auth.py index b716ca4..cb9eef3 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -32,17 +32,20 @@ async def login_submit( ): email = email.strip().lower() - # Check email is admin or invited + # Check email is admin, already registered, or has a pending invitation 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."}, - ) + async with db.execute("SELECT id FROM users WHERE email = ?", (email,)) as cursor: + existing_user = await cursor.fetchone() + if existing_user is None: + 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) diff --git a/app/routers/courses.py b/app/routers/courses.py index 3d7599b..8e22a1d 100644 --- a/app/routers/courses.py +++ b/app/routers/courses.py @@ -1,6 +1,8 @@ +import os import secrets from datetime import datetime, timezone +import httpx from fastapi import APIRouter, Depends, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse import aiosqlite @@ -9,6 +11,21 @@ from app.database import get_db from app.dependencies import require_admin from app.templates_config import templates +GOLF_COURSE_API_KEY = os.getenv("GOLF_COURSE_API_KEY", "") +_GOLF_API_BASE = "https://api.golfcourseapi.com/v1" + + +async def _golf_api(path: str, params: dict = None): + async with httpx.AsyncClient() as client: + r = await client.get( + f"{_GOLF_API_BASE}{path}", + headers={"Authorization": f"Key {GOLF_COURSE_API_KEY}"}, + params=params, + timeout=10, + ) + r.raise_for_status() + return r.json() + router = APIRouter() @@ -125,6 +142,99 @@ async def create_course(request: Request, ) +# ── API Search & Import ─────────────────────────────────────────────────────── + +@router.get("/admin/courses/search", response_class=HTMLResponse) +async def search_courses_page(request: Request, user: dict = Depends(require_admin), + q: str = ""): + results, error = [], None + if q and GOLF_COURSE_API_KEY: + try: + data = await _golf_api("/search", {"search_query": q}) + results = data.get("courses", []) + except Exception: + error = "Search failed. Check your API key or try again." + return templates.TemplateResponse("admin/course_search.html", { + "request": request, "user": user, "q": q, "results": results, + "error": error, "api_configured": bool(GOLF_COURSE_API_KEY), + }) + + +@router.post("/admin/courses/import/{api_id}") +async def import_course(api_id: int, request: Request, + user: dict = Depends(require_admin), + db: aiosqlite.Connection = Depends(get_db)): + try: + data = await _golf_api(f"/courses/{api_id}") + except Exception: + return RedirectResponse("/admin/courses/search?error=Import+failed.", status_code=302) + + # Unwrap {"course": {...}} envelope if present + data = data.get("course", data) + + # Course name + club = data.get("club_name", "").strip() + cname = data.get("course_name", "").strip() + name = club if (not cname or club == cname) else f"{club} – {cname}" + + # Location + loc = data.get("location", {}) + if loc.get("state") and loc.get("country") == "United States": + location = f"{loc.get('city', '')}, {loc['state']}" + elif loc.get("city") and loc.get("country"): + location = f"{loc['city']}, {loc['country']}" + else: + location = (loc.get("address") or "").strip() + + # Determine hole count from tees + tees_male = data.get("tees", {}).get("male") or [] + tees_female = data.get("tees", {}).get("female") or [] + all_tees = tees_male + tees_female + holes_count = 18 + for t in all_tees: + n = t.get("number_of_holes") + if n in (9, 18): + holes_count = n + break + + # Hole par + stroke index from first tee that has hole data + hole_pars: dict[int, int] = {} + hole_sis: dict[int, int] = {} + for t in all_tees: + hole_data = t.get("holes") or [] + if hole_data: + for i, h in enumerate(hole_data[:holes_count], 1): + hole_pars[i] = h.get("par", 4) + if h.get("handicap"): + hole_sis[i] = h["handicap"] + break + + now = datetime.now(timezone.utc).isoformat() + cid = secrets.token_urlsafe(10) + await db.execute( + "INSERT INTO courses (id, name, holes, location, created_at) VALUES (?,?,?,?,?)", + (cid, name, holes_count, location or None, now), + ) + for i in range(1, holes_count + 1): + await db.execute( + "INSERT INTO course_holes (id, course_id, hole_number, par, stroke_index) VALUES (?,?,?,?,?)", + (secrets.token_urlsafe(10), cid, i, hole_pars.get(i, 4), hole_sis.get(i)), + ) + for t in tees_male: + tname = (t.get("tee_name") or "").strip() + slope = t.get("slope_rating") + rating = t.get("course_rating") + if tname and slope and rating: + await db.execute( + "INSERT INTO course_tees (id, course_id, name, slope, rating) VALUES (?,?,?,?,?)", + (secrets.token_urlsafe(10), cid, tname, slope, rating), + ) + await db.commit() + return RedirectResponse( + f"/admin/courses/{cid}/edit?success=Course+imported+successfully.", status_code=302 + ) + + # ── Edit ────────────────────────────────────────────────────────────────────── @router.get("/admin/courses/{cid}/edit", response_class=HTMLResponse) diff --git a/app/routers/games.py b/app/routers/games.py index 70b47c5..ee4dfc0 100644 --- a/app/routers/games.py +++ b/app/routers/games.py @@ -65,6 +65,29 @@ async def _broadcast(game_id: str, message: dict): _connections.get(game_id, set()).difference_update(dead) +def _calc_skins(players: list, scores: dict, holes: list, carryover: bool = True): + """Returns (skins_per_player, hole_results, unclaimed_pot)""" + skins = {p["id"]: 0 for p in players} + hole_results = {} + pot = 0 + for hole in holes: + scored = {uid: s["strokes"] for uid, s in scores.get(hole, {}).items() if s.get("strokes")} + if not scored: + continue + pot += 1 + min_score = min(scored.values()) + winners = [uid for uid, s in scored.items() if s == min_score] + if len(winners) == 1: + skins[winners[0]] += pot + hole_results[hole] = {"winner": winners[0], "pot": pot, "min_score": min_score} + pot = 0 + else: + hole_results[hole] = {"winner": None, "pot": pot, "min_score": min_score} + if not carryover: + pot = 0 + return skins, hole_results, pot + + 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} @@ -84,7 +107,7 @@ def _totals(players: list, scores: dict) -> dict: 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, + """SELECT g.id, g.holes_count, g.status, g.created_at, g.format, g.carryover, 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 @@ -94,12 +117,12 @@ async def games_list(request: Request, user: dict = Depends(get_current_user), ) as c: games = [dict(r) for r in await c.fetchall()] - # For completed games, find the winner (highest total points) + # For completed BBB 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') + WHERE hs.game_id IN (SELECT id FROM games WHERE status = 'completed' AND format = 'bingo_bango_bongo') GROUP BY hs.game_id, hs.user_id ORDER BY hs.game_id, total DESC""", ) as c: @@ -111,6 +134,21 @@ async def games_list(request: Request, user: dict = Depends(get_current_user), if r["game_id"] not in winners: winners[r["game_id"]] = {"name": r["name"], "avatar": r["avatar"], "total": r["total"]} + # For completed skins games, calculate winner via skins algorithm + for g in games: + if g["status"] == "completed" and g["format"] == "skins": + gid = g["id"] + players_g = await _get_players(gid, db) + scores_g = await _get_scores(gid, db) + holes_g = list(range(1, g["holes_count"] + 1)) + skins_g, _, _ = _calc_skins(players_g, scores_g, holes_g, bool(g["carryover"])) + if skins_g: + winner_id = max(skins_g, key=skins_g.get) + if skins_g[winner_id] > 0: + wp = next((p for p in players_g if p["id"] == winner_id), None) + if wp: + winners[gid] = {"name": wp["name"], "avatar": wp["avatar"], "total": skins_g[winner_id]} + return templates.TemplateResponse( "games/games.html", {"request": request, "user": user, "games": games, "winners": winners} ) @@ -144,7 +182,7 @@ async def new_game_page(request: Request, user: dict = Depends(get_current_user) ) -VALID_FORMATS = {"bingo_bango_bongo"} +VALID_FORMATS = {"bingo_bango_bongo", "skins"} @router.post("/games/new") async def create_game(request: Request, holes_count: int = Form(...), @@ -198,10 +236,11 @@ async def create_game(request: Request, holes_count: int = Form(...), game_id = secrets.token_urlsafe(10) now = datetime.now(timezone.utc).isoformat() + carryover = 1 if form.get("carryover") == "1" else 0 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), + "INSERT INTO games (id, format, holes_count, status, created_by, created_at, course_id, carryover) VALUES (?,?,?,?,?,?,?,?)", + (game_id, format, holes_count, "active", user["id"], now, course_id, carryover), ) for uid in player_ids: async with db.execute("SELECT handicap FROM users WHERE id = ?", (uid,)) as c: @@ -241,13 +280,21 @@ async def game_summary(game_id: str, request: Request, user: dict = Depends(get_ 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) + skins_won, skins_hole_results, unclaimed = None, None, 0 + if game["format"] == "skins": + carryover = bool(game["carryover"]) + skins_won, skins_hole_results, unclaimed = _calc_skins(players, scores, holes, carryover) + results = sorted(players, key=lambda p: skins_won.get(p["id"], 0), reverse=True) + else: + 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, + "skins_won": skins_won, "skins_hole_results": skins_hole_results, "unclaimed": unclaimed, + "carryover": bool(game["carryover"]), }) @@ -293,6 +340,7 @@ async def game_page(game_id: str, request: Request, user: dict = Depends(get_cur "players": players, "scores": scores, "totals": totals, "holes": holes, "hole_pars": hole_pars, "hole_stroke_indices": hole_stroke_indices, "playing_handicaps": playing_handicaps, + "carryover": bool(game["carryover"]), }) @@ -358,7 +406,7 @@ 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) + return RedirectResponse(f"/games/{game_id}/summary", status_code=302) # ── WebSocket ───────────────────────────────────────────────────────────────── diff --git a/app/templates/admin/course_search.html b/app/templates/admin/course_search.html new file mode 100644 index 0000000..525e03b --- /dev/null +++ b/app/templates/admin/course_search.html @@ -0,0 +1,86 @@ +{% extends "base.html" %} +{% block title %}Search Courses – Skins & Pins{% endblock %} + +{% block content %} +
+ + +
+ Users + Courses + Games +
+ +
+ ← Courses + + Manual +
+ + {% if not api_configured %} +
+ GOLF_COURSE_API_KEY is not configured. + Add it to your .env file to enable course search. + Get a key at golfcourseapi.com. +
+ {% endif %} + +
+
+ + +
+
+ + {% if error %} +
{{ error }}
+ {% endif %} + + {% if q and results is not none %} + {% if results %} +

+ {{ results | length }} result{{ 's' if results | length != 1 else '' }} for "{{ q }}" +

+
+ + + + + + {% for c in results %} + {% set loc = c.location or {} %} + {% if loc.state and loc.country == 'United States' %} + {% set location_str = (loc.city or '') + ', ' + loc.state %} + {% elif loc.city and loc.country %} + {% set location_str = loc.city + ', ' + loc.country %} + {% else %} + {% set location_str = '' %} + {% endif %} + + + + + + + {% endfor %} + +
ClubCourseLocation
{{ c.club_name or '—' }} + {% if c.course_name and c.course_name != c.club_name %}{{ c.course_name }}{% else %}—{% endif %} + {{ location_str or '—' }} +
+ +
+
+
+ {% else %} +
+
🔍
+

No courses found for "{{ q }}".

+
+ {% endif %} + {% endif %} +
+{% endblock %} diff --git a/app/templates/admin/courses.html b/app/templates/admin/courses.html index a1c175b..cb7658f 100644 --- a/app/templates/admin/courses.html +++ b/app/templates/admin/courses.html @@ -17,7 +17,8 @@
{{ request.query_params.get('success') }}
{% endif %} -
+
+ Search & Import + New course
diff --git a/app/templates/games/game.html b/app/templates/games/game.html index f1dce3e..c957bd7 100644 --- a/app/templates/games/game.html +++ b/app/templates/games/game.html @@ -14,7 +14,7 @@
-
+
- +
@@ -73,15 +73,19 @@ - - - + @@ -109,7 +113,12 @@ {# Scorecard sub-toggle #}
- + +
{# Strokes scorecard #} @@ -198,6 +207,52 @@ + {# Skins scorecard #} + + + + + {% if hole_pars %}{% endif %} + {% for p in players %} + + {% endfor %} + + + + + {% for h in holes %} + + + {% if hole_pars %}{% endif %} + {% for p in players %} + + {% endfor %} + + + {% endfor %} + + + + + {% if hole_pars %}{% endif %} + {% for p in players %} + + {% endfor %} + + + +
HolePar + {% if p.avatar and p.avatar.startswith('/') %} + + {% else %} + {{ p.avatar or '👤' }} + {% endif %}
+ {{ p.name.split()[0] }} +
Skin
{{ h }}{{ hole_pars.get(h, '—') }} + +
Skins
+ {% if game.status == 'active' %}