Add Skins format, GolfCourseAPI import, and UX improvements

- 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 <noreply@anthropic.com>
This commit is contained in:
Rolf
2026-03-22 09:18:04 +01:00
parent ef22436c76
commit c51c872c33
12 changed files with 554 additions and 46 deletions
+4
View File
@@ -13,6 +13,10 @@ ENV=development
# Path to the SQLite database file (inside the container) # Path to the SQLite database file (inside the container)
DATABASE_PATH=/data/skinspins.db 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 email settings (leave SMTP_HOST empty to disable email and log links instead)
SMTP_HOST=smtp.example.com SMTP_HOST=smtp.example.com
SMTP_PORT=587 SMTP_PORT=587
+1
View File
@@ -120,6 +120,7 @@ async def init_db():
"ALTER TABLE games ADD COLUMN course_id TEXT REFERENCES courses(id)", "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 game_players ADD COLUMN tee_id TEXT REFERENCES course_tees(id)",
"ALTER TABLE course_holes ADD COLUMN stroke_index INTEGER", "ALTER TABLE course_holes ADD COLUMN stroke_index INTEGER",
"ALTER TABLE games ADD COLUMN carryover INTEGER NOT NULL DEFAULT 1",
]: ]:
try: try:
await db.execute(migration) await db.execute(migration)
+4 -1
View File
@@ -32,8 +32,11 @@ async def login_submit(
): ):
email = email.strip().lower() 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: if email != ADMIN_EMAIL:
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( async with db.execute(
"SELECT id FROM invitations WHERE email = ?", (email,) "SELECT id FROM invitations WHERE email = ?", (email,)
) as cursor: ) as cursor:
+110
View File
@@ -1,6 +1,8 @@
import os
import secrets import secrets
from datetime import datetime, timezone from datetime import datetime, timezone
import httpx
from fastapi import APIRouter, Depends, Form, Request from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
import aiosqlite import aiosqlite
@@ -9,6 +11,21 @@ from app.database import get_db
from app.dependencies import require_admin from app.dependencies import require_admin
from app.templates_config import templates 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() 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 ────────────────────────────────────────────────────────────────────── # ── Edit ──────────────────────────────────────────────────────────────────────
@router.get("/admin/courses/{cid}/edit", response_class=HTMLResponse) @router.get("/admin/courses/{cid}/edit", response_class=HTMLResponse)
+55 -7
View File
@@ -65,6 +65,29 @@ async def _broadcast(game_id: str, message: dict):
_connections.get(game_id, set()).difference_update(dead) _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: def _totals(players: list, scores: dict) -> dict:
"""Returns {user_id: {bingo, bango, bongo, total}}""" """Returns {user_id: {bingo, bango, bongo, total}}"""
totals = {p["id"]: {"bingo": 0, "bango": 0.0, "bongo": 0, "total": 0.0} for p in players} 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), async def games_list(request: Request, user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(get_db)): db: aiosqlite.Connection = Depends(get_db)):
async with db.execute( 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 u.name as creator_name, c.name as course_name
FROM games g JOIN users u ON g.created_by = u.id FROM games g JOIN users u ON g.created_by = u.id
LEFT JOIN courses c ON c.id = g.course_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: ) as c:
games = [dict(r) for r in await c.fetchall()] 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( async with db.execute(
"""SELECT hs.game_id, u.name, u.avatar, """SELECT hs.game_id, u.name, u.avatar,
SUM(hs.bingo + hs.bango + hs.bongo) AS total SUM(hs.bingo + hs.bango + hs.bongo) AS total
FROM hole_scores hs JOIN users u ON hs.user_id = u.id 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 GROUP BY hs.game_id, hs.user_id
ORDER BY hs.game_id, total DESC""", ORDER BY hs.game_id, total DESC""",
) as c: ) 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: if r["game_id"] not in winners:
winners[r["game_id"]] = {"name": r["name"], "avatar": r["avatar"], "total": r["total"]} 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( return templates.TemplateResponse(
"games/games.html", {"request": request, "user": user, "games": games, "winners": winners} "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") @router.post("/games/new")
async def create_game(request: Request, holes_count: int = Form(...), 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) game_id = secrets.token_urlsafe(10)
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
carryover = 1 if form.get("carryover") == "1" else 0
await db.execute( await db.execute(
"INSERT INTO games (id, format, holes_count, status, created_by, created_at, course_id) VALUES (?,?,?,?,?,?,?)", "INSERT INTO games (id, format, holes_count, status, created_by, created_at, course_id, carryover) VALUES (?,?,?,?,?,?,?,?)",
(game_id, "bingo_bango_bongo", holes_count, "active", user["id"], now, course_id), (game_id, format, holes_count, "active", user["id"], now, course_id, carryover),
) )
for uid in player_ids: for uid in player_ids:
async with db.execute("SELECT handicap FROM users WHERE id = ?", (uid,)) as c: async with db.execute("SELECT handicap FROM users WHERE id = ?", (uid,)) as c:
@@ -241,6 +280,12 @@ async def game_summary(game_id: str, request: Request, user: dict = Depends(get_
if row["stroke_index"] is not None: if row["stroke_index"] is not None:
hole_stroke_indices[row["hole_number"]] = row["stroke_index"] hole_stroke_indices[row["hole_number"]] = row["stroke_index"]
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) results = sorted(players, key=lambda p: totals.get(p["id"], {}).get("total", 0), reverse=True)
return templates.TemplateResponse("games/summary.html", { return templates.TemplateResponse("games/summary.html", {
@@ -248,6 +293,8 @@ async def game_summary(game_id: str, request: Request, user: dict = Depends(get_
"players": players, "scores": scores, "totals": totals, "players": players, "scores": scores, "totals": totals,
"holes": holes, "hole_pars": hole_pars, "holes": holes, "hole_pars": hole_pars,
"results": results, "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, "players": players, "scores": scores, "totals": totals,
"holes": holes, "hole_pars": hole_pars, "hole_stroke_indices": hole_stroke_indices, "holes": holes, "hole_pars": hole_pars, "hole_stroke_indices": hole_stroke_indices,
"playing_handicaps": playing_handicaps, "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)): db: aiosqlite.Connection = Depends(get_db)):
await db.execute("UPDATE games SET status = 'completed' WHERE id = ?", (game_id,)) await db.execute("UPDATE games SET status = 'completed' WHERE id = ?", (game_id,))
await db.commit() await db.commit()
return RedirectResponse(f"/games/{game_id}", status_code=302) return RedirectResponse(f"/games/{game_id}/summary", status_code=302)
# ── WebSocket ───────────────────────────────────────────────────────────────── # ── WebSocket ─────────────────────────────────────────────────────────────────
+86
View File
@@ -0,0 +1,86 @@
{% extends "base.html" %}
{% block title %}Search Courses Skins &amp; Pins{% endblock %}
{% block content %}
<div class="page">
<div class="page-header">
<h1>⚙️ Admin</h1>
</div>
<div class="admin-subnav">
<a href="/admin" class="admin-subnav-item">Users</a>
<a href="/admin/courses" class="admin-subnav-item active">Courses</a>
<a href="/admin/games" class="admin-subnav-item">Games</a>
</div>
<div style="display:flex; gap:0.5rem; justify-content:flex-end; margin-bottom:1rem;">
<a href="/admin/courses" class="btn" style="width:auto; padding:0.5rem 1rem; background:var(--surface2); color:var(--text);">← Courses</a>
<a href="/admin/courses/new" class="btn btn-primary" style="width:auto; padding:0.5rem 1rem;">+ Manual</a>
</div>
{% if not api_configured %}
<div class="alert alert-error">
<strong>GOLF_COURSE_API_KEY is not configured.</strong>
Add it to your <code>.env</code> file to enable course search.
Get a key at <a href="https://www.golfcourseapi.com" target="_blank" style="color:inherit;">golfcourseapi.com</a>.
</div>
{% endif %}
<div class="card">
<form method="get" action="/admin/courses/search" style="display:flex; gap:0.5rem;">
<input type="text" name="q" value="{{ q }}" placeholder="Search by course or club name…"
style="flex:1;" {% if not api_configured %}disabled{% endif %} autofocus>
<button type="submit" class="btn btn-primary" style="width:auto; padding:0.65rem 1.25rem;"
{% if not api_configured %}disabled{% endif %}>Search</button>
</form>
</div>
{% if error %}
<div class="alert alert-error" style="margin-top:0.75rem;">{{ error }}</div>
{% endif %}
{% if q and results is not none %}
{% if results %}
<p style="font-size:0.85rem; color:var(--text-muted); margin:0.75rem 0 0.4rem;">
{{ results | length }} result{{ 's' if results | length != 1 else '' }} for "<strong>{{ q }}</strong>"
</p>
<div class="card" style="padding:0;">
<table class="data-table" style="margin:0;">
<thead>
<tr><th>Club</th><th>Course</th><th>Location</th><th></th></tr>
</thead>
<tbody>
{% 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 %}
<tr>
<td style="font-weight:500;">{{ c.club_name or '—' }}</td>
<td style="color:var(--text-muted);">
{% if c.course_name and c.course_name != c.club_name %}{{ c.course_name }}{% else %}—{% endif %}
</td>
<td style="color:var(--text-muted);">{{ location_str or '—' }}</td>
<td>
<form method="post" action="/admin/courses/import/{{ c.id }}">
<button type="submit" class="btn-table-action">Import</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state" style="padding:2rem 0;">
<div class="empty-icon">🔍</div>
<p>No courses found for "<strong>{{ q }}</strong>".</p>
</div>
{% endif %}
{% endif %}
</div>
{% endblock %}
+2 -1
View File
@@ -17,7 +17,8 @@
<div class="alert alert-success">{{ request.query_params.get('success') }}</div> <div class="alert alert-success">{{ request.query_params.get('success') }}</div>
{% endif %} {% endif %}
<div style="display:flex; justify-content:flex-end; margin-bottom:1rem;"> <div style="display:flex; gap:0.5rem; justify-content:flex-end; margin-bottom:1rem;">
<a href="/admin/courses/search" class="btn" style="width:auto; padding:0.5rem 1rem; background:var(--surface2); color:var(--text);">Search & Import</a>
<a href="/admin/courses/new" class="btn btn-primary" style="width:auto; padding:0.5rem 1rem;">+ New course</a> <a href="/admin/courses/new" class="btn btn-primary" style="width:auto; padding:0.5rem 1rem;">+ New course</a>
</div> </div>
+98 -3
View File
@@ -14,7 +14,7 @@
<button @click="view === 'score' ? prevHole() : cardView = 'strokes'" class="icon-btn" <button @click="view === 'score' ? prevHole() : cardView = 'strokes'" class="icon-btn"
:disabled="view === 'score' ? currentHole <= 1 : cardView === 'strokes'"></button> :disabled="view === 'score' ? currentHole <= 1 : cardView === 'strokes'"></button>
<div style="text-align:center; flex:1;"> <div style="text-align:center; flex:1;">
<div class="hole-label" x-text="view === 'score' ? 'Hole ' + currentHole + ' / {{ game.holes_count }}' : (cardView === 'strokes' ? 'Strokes' : 'Points')"></div> <div class="hole-label" x-text="view === 'score' ? 'Hole ' + currentHole + ' / {{ game.holes_count }}' : (cardView === 'strokes' ? 'Strokes' : (GAME_FORMAT === 'skins' ? 'Skins' : 'Points'))"></div>
<div style="font-size:0.72rem; color:var(--text-muted); margin-top:0.1rem;"> <div style="font-size:0.72rem; color:var(--text-muted); margin-top:0.1rem;">
<template x-if="view === 'score'"> <template x-if="view === 'score'">
<span x-text="(HOLE_PARS[currentHole] ? 'Par ' + HOLE_PARS[currentHole] : '') + (HOLE_PARS[currentHole] && HOLE_SIS[currentHole] ? ' · ' : '') + (HOLE_SIS[currentHole] ? 'SI ' + HOLE_SIS[currentHole] : '')"></span> <span x-text="(HOLE_PARS[currentHole] ? 'Par ' + HOLE_PARS[currentHole] : '') + (HOLE_PARS[currentHole] && HOLE_SIS[currentHole] ? ' · ' : '') + (HOLE_SIS[currentHole] ? 'SI ' + HOLE_SIS[currentHole] : '')"></span>
@@ -24,8 +24,8 @@
</template> </template>
</div> </div>
</div> </div>
<button @click="view === 'score' ? nextHole() : cardView = 'points'" class="icon-btn" <button @click="view === 'score' ? nextHole() : cardView = (GAME_FORMAT === 'skins' ? 'skins' : 'points')" class="icon-btn"
:disabled="view === 'score' ? currentHole >= {{ game.holes_count }} : cardView === 'points'"></button> :disabled="view === 'score' ? currentHole >= {{ game.holes_count }} : cardView !== 'strokes'"></button>
</div> </div>
</div> </div>
@@ -73,6 +73,8 @@
<button type="button" class="stroke-btn stroke-plus" <button type="button" class="stroke-btn stroke-plus"
@click="adjustStrokes(currentHole, p.id, 1)">+</button> @click="adjustStrokes(currentHole, p.id, 1)">+</button>
</span> </span>
<template x-if="GAME_FORMAT === 'bingo_bango_bongo'">
<span>
<button type="button" class="bbb-btn bingo-btn" <button type="button" class="bbb-btn bingo-btn"
:class="{ active: getScore(currentHole, p.id).bingo }" :class="{ active: getScore(currentHole, p.id).bingo }"
@click="setBingo(currentHole, p.id)">Bingo</button> @click="setBingo(currentHole, p.id)">Bingo</button>
@@ -82,6 +84,8 @@
<button type="button" class="bbb-btn bongo-btn" <button type="button" class="bbb-btn bongo-btn"
:class="{ active: getScore(currentHole, p.id).bongo }" :class="{ active: getScore(currentHole, p.id).bongo }"
@click="setBongo(currentHole, p.id)">Bongo</button> @click="setBongo(currentHole, p.id)">Bongo</button>
</span>
</template>
</div> </div>
</div> </div>
</template> </template>
@@ -109,7 +113,12 @@
{# Scorecard sub-toggle #} {# Scorecard sub-toggle #}
<div class="scorecard-toggle"> <div class="scorecard-toggle">
<button @click="cardView = 'strokes'" :class="{ active: cardView === 'strokes' }">Strokes</button> <button @click="cardView = 'strokes'" :class="{ active: cardView === 'strokes' }">Strokes</button>
<template x-if="GAME_FORMAT === 'bingo_bango_bongo'">
<button @click="cardView = 'points'" :class="{ active: cardView === 'points' }">Points</button> <button @click="cardView = 'points'" :class="{ active: cardView === 'points' }">Points</button>
</template>
<template x-if="GAME_FORMAT === 'skins'">
<button @click="cardView = 'skins'" :class="{ active: cardView === 'skins' }">Skins</button>
</template>
</div> </div>
{# Strokes scorecard #} {# Strokes scorecard #}
@@ -198,6 +207,52 @@
</tfoot> </tfoot>
</table> </table>
{# Skins scorecard #}
<table class="scorecard" x-show="cardView === 'skins'">
<thead>
<tr>
<th>Hole</th>
{% if hole_pars %}<th style="color:var(--text-muted);">Par</th>{% endif %}
{% for p in players %}
<th>
{% if p.avatar and p.avatar.startswith('/') %}
<img src="{{ p.avatar }}" style="width:1.3rem;height:1.3rem;border-radius:50%;object-fit:cover;vertical-align:middle;">
{% else %}
{{ p.avatar or '👤' }}
{% endif %}<br>
<a href="/users/{{ p.id }}" style="font-size:0.7rem; color:inherit; text-decoration:none;">{{ p.name.split()[0] }}</a>
</th>
{% endfor %}
<th style="color:var(--text-muted);">Skin</th>
</tr>
</thead>
<tbody>
{% for h in holes %}
<tr :class="{ 'hole-skipped': isSkipped({{ h }}) }">
<td class="hole-num">{{ h }}</td>
{% if hole_pars %}<td class="score-cell" style="color:var(--text-muted);">{{ hole_pars.get(h, '—') }}</td>{% endif %}
{% for p in players %}
<td class="score-cell">
<span x-text="holeStrokesStr({{ h }}, '{{ p.id }}')"
:class="[holeScoreClass({{ h }}, '{{ p.id }}'), isSkinWinner({{ h }}, '{{ p.id }}') ? 'skin-winner' : '']"></span>
</td>
{% endfor %}
<td class="score-cell" x-text="skinHoleLabel({{ h }})"></td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="totals-row">
<td>Skins</td>
{% if hole_pars %}<td></td>{% endif %}
{% for p in players %}
<td x-text="calcSkins().skins['{{ p.id }}'] || 0"></td>
{% endfor %}
<td x-text="calcSkins().unclaimed ? '(' + calcSkins().unclaimed + ')' : ''"></td>
</tr>
</tfoot>
</table>
{% if game.status == 'active' %} {% if game.status == 'active' %}
<template x-if="allHolesDone()"> <template x-if="allHolesDone()">
<form method="post" action="/games/{{ game.id }}/finish" style="margin-top: 1rem;"> <form method="post" action="/games/{{ game.id }}/finish" style="margin-top: 1rem;">
@@ -223,6 +278,8 @@
<script> <script>
const GAME_ID = "{{ game.id }}"; const GAME_ID = "{{ game.id }}";
const GAME_HOLES = {{ game.holes_count }}; const GAME_HOLES = {{ game.holes_count }};
const GAME_FORMAT = "{{ game.format }}";
const SKINS_CARRYOVER = {{ carryover | tojson }};
const HOLE_PARS = {{ hole_pars | tojson }}; const HOLE_PARS = {{ hole_pars | tojson }};
const HOLE_SIS = {{ hole_stroke_indices | tojson }}; const HOLE_SIS = {{ hole_stroke_indices | tojson }};
const PLAYERS_DATA = {{ players | tojson }}; const PLAYERS_DATA = {{ players | tojson }};
@@ -422,6 +479,44 @@ function gameApp() {
touchDragEnd() { this.dropPlayer(this.dragOver ?? this.dragSrc); }, touchDragEnd() { this.dropPlayer(this.dragOver ?? this.dragSrc); },
calcSkins() {
const skins = {};
PLAYERS_DATA.forEach(p => skins[p.id] = 0);
const holes = {};
let pot = 0;
for (let hole = 1; hole <= GAME_HOLES; hole++) {
if (this.isSkipped(hole)) continue;
const holeScores = this.scores[hole] || {};
const scored = PLAYERS_DATA
.map(p => ({ id: p.id, strokes: holeScores[p.id]?.strokes || 0 }))
.filter(x => x.strokes > 0);
if (scored.length === 0) continue;
pot += 1;
const min = Math.min(...scored.map(x => x.strokes));
const winners = scored.filter(x => x.strokes === min);
if (winners.length === 1) {
skins[winners[0].id] += pot;
holes[hole] = { winner: winners[0].id, pot };
pot = 0;
} else {
holes[hole] = { winner: null, pot };
if (!SKINS_CARRYOVER) pot = 0;
}
}
return { skins, holes, unclaimed: pot };
},
isSkinWinner(hole, uid) {
return this.calcSkins().holes[hole]?.winner === uid;
},
skinHoleLabel(hole) {
const h = this.calcSkins().holes[hole];
if (!h) return '';
if (h.winner) return h.pot > 1 ? '✓ ' + h.pot : '✓';
return '→ ' + h.pot;
},
touchStart(e) { touchStart(e) {
if (e.target.closest('.drag-handle')) return; if (e.target.closest('.drag-handle')) return;
this.touchStartX = e.changedTouches[0].screenX; this.touchStartX = e.changedTouches[0].screenX;
+85 -12
View File
@@ -4,7 +4,13 @@
{% block extra_head %} {% block extra_head %}
<script> <script>
const TEES_BY_COURSE = {{ tees_by_course | tojson }}; const TEES_BY_COURSE = {{ tees_by_course | tojson }};
const COURSES_DATA = {{ courses | tojson }};
</script> </script>
<style>
.picker-item { padding:0.65rem 0.75rem; cursor:pointer; border-bottom:1px solid var(--border); }
.picker-item:last-child { border-bottom:none; }
.picker-item.selected { background:var(--surface2); }
</style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@@ -12,7 +18,26 @@
x-data="{ x-data="{
step: 1, step: 1,
courseId: '', courseId: '',
courseLabel: '',
courseOpen: false,
courseSearch: '',
format: 'bingo_bango_bongo',
selectedPlayers: ['{{ user.id }}'], selectedPlayers: ['{{ user.id }}'],
playerSearch: '',
filteredCourses() {
const q = this.courseSearch.toLowerCase();
if (!q) return COURSES_DATA;
return COURSES_DATA.filter(c =>
c.name.toLowerCase().includes(q) ||
(c.location || '').toLowerCase().includes(q)
);
},
selectCourse(c) {
this.courseId = c.id;
this.courseLabel = c.name + (c.location ? ' · ' + c.location : '') + ' (' + c.holes + ' holes)';
this.courseOpen = false;
this.courseSearch = '';
},
togglePlayer(id) { togglePlayer(id) {
const idx = this.selectedPlayers.indexOf(id); const idx = this.selectedPlayers.indexOf(id);
if (idx === -1) this.selectedPlayers.push(id); if (idx === -1) this.selectedPlayers.push(id);
@@ -38,23 +63,65 @@
<label>Format</label> <label>Format</label>
<div style="display:flex; gap:0.75rem; flex-wrap:wrap;"> <div style="display:flex; gap:0.75rem; flex-wrap:wrap;">
<label class="radio-card" style="flex:1;"> <label class="radio-card" style="flex:1;">
<input type="radio" name="format" value="bingo_bango_bongo" checked required> <input type="radio" name="format" value="bingo_bango_bongo" checked required
@change="format = $event.target.value">
<span>Bingo Bango Bongo</span> <span>Bingo Bango Bongo</span>
</label> </label>
<label class="radio-card" style="flex:1;">
<input type="radio" name="format" value="skins"
@change="format = $event.target.value">
<span>Skins</span>
</label>
</div>
<div x-show="format === 'skins'" style="margin-top:0.75rem;">
<label style="display:flex; align-items:center; gap:0.5rem; cursor:pointer;">
<input type="checkbox" name="carryover" value="1" checked style="width:auto;">
<span style="font-weight:400; color:var(--text-muted);">Carryover (ties carry to next hole)</span>
</label>
</div> </div>
</div> </div>
<div class="form-group"> {# ── Course picker ── #}
<label for="course_id">Course</label> <div class="form-group" @click.outside="courseOpen = false; courseSearch = ''">
<label>Course</label>
{% if courses %} {% if courses %}
<select id="course_id" name="course_id" required <input type="hidden" name="course_id" :value="courseId">
@change="courseId = $event.target.value"
style="width:100%; padding:0.65rem 0.75rem; border-radius:0.5rem; border:1px solid var(--border); background:var(--surface); color:var(--text); font-size:1rem;"> <button type="button"
<option value="" disabled selected>Select a course…</option> @click="courseOpen = !courseOpen; if (courseOpen) $nextTick(() => $refs.courseSearch && $refs.courseSearch.focus())"
{% for c in courses %} style="width:100%; display:flex; align-items:center; gap:0.5rem; padding:0.65rem 0.75rem; border-radius:0.5rem; border:1px solid var(--border); background:var(--surface); color:var(--text); font-size:1rem; cursor:pointer; text-align:left;">
<option value="{{ c.id }}">{{ c.name }}{% if c.location %} · {{ c.location }}{% endif %} ({{ c.holes }} holes)</option> <span style="flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;"
{% endfor %} :style="courseId ? '' : 'color:var(--text-muted)'"
</select> x-text="courseLabel || 'Select a course…'"></span>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="flex-shrink:0; transition:transform 0.15s;"
:style="courseOpen ? 'transform:rotate(180deg)' : ''">
<path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div x-show="courseOpen"
style="border:1px solid var(--border); border-radius:0.5rem; background:var(--surface); margin-top:0.25rem; overflow:hidden; box-shadow:0 4px 16px rgba(0,0,0,0.15);">
<div style="padding:0.5rem; border-bottom:1px solid var(--border);">
<input type="text" x-model="courseSearch" x-ref="courseSearch"
placeholder="Search by name or location…"
@keydown.escape="courseOpen = false; courseSearch = ''"
style="width:100%; padding:0.5rem 0.65rem; border-radius:0.4rem; border:1px solid var(--border); background:var(--surface2); color:var(--text); font-size:0.95rem; box-sizing:border-box;">
</div>
<div style="max-height:14rem; overflow-y:auto; -webkit-overflow-scrolling:touch;">
<template x-for="c in filteredCourses()" :key="c.id">
<div class="picker-item" @click="selectCourse(c)" :class="courseId === c.id ? 'selected' : ''">
<div x-text="c.name" style="font-weight:500;"></div>
<div x-text="(c.location ? c.location + ' · ' : '') + c.holes + ' holes'"
style="font-size:0.8rem; color:var(--text-muted);"></div>
</div>
</template>
<div x-show="filteredCourses().length === 0"
style="padding:0.75rem; text-align:center; color:var(--text-muted); font-size:0.9rem;">
No courses found
</div>
</div>
</div>
{% else %} {% else %}
<p style="color:var(--text-muted); font-size:0.9rem;">No courses available. Ask the admin to add courses first.</p> <p style="color:var(--text-muted); font-size:0.9rem;">No courses available. Ask the admin to add courses first.</p>
<input type="hidden" name="course_id" value=""> <input type="hidden" name="course_id" value="">
@@ -75,11 +142,16 @@
</div> </div>
</div> </div>
{# ── Player picker ── #}
<div class="form-group"> <div class="form-group">
<label>Players</label> <label>Players</label>
<input type="text" x-model="playerSearch" placeholder="Filter players…"
style="width:100%; padding:0.5rem 0.65rem; border-radius:0.4rem; border:1px solid var(--border); background:var(--surface2); color:var(--text); font-size:0.95rem; margin-bottom:0.5rem; box-sizing:border-box;">
<div class="player-list"> <div class="player-list">
{% for u in all_users %} {% for u in all_users %}
<label class="player-option"> <label class="player-option"
data-name="{{ u.name | lower }}"
x-show="{% if u.id == user.id %}true{% else %}!playerSearch || $el.dataset.name.includes(playerSearch.toLowerCase()){% endif %}">
<input type="checkbox" name="players" value="{{ u.id }}" <input type="checkbox" name="players" value="{{ u.id }}"
{% if u.id == user.id %}checked disabled{% endif %} {% if u.id == user.id %}checked disabled{% endif %}
@change="togglePlayer('{{ u.id }}')" @change="togglePlayer('{{ u.id }}')"
@@ -98,6 +170,7 @@
</div> </div>
<input type="hidden" name="players" value="{{ user.id }}"> <input type="hidden" name="players" value="{{ user.id }}">
</div> </div>
</div> </div>
<button type="button" x-show="hasTees()" class="btn btn-primary" @click="step = 2">Next →</button> <button type="button" x-show="hasTees()" class="btn btn-primary" @click="step = 2">Next →</button>
+86 -1
View File
@@ -15,7 +15,14 @@
{# Leaderboard #} {# Leaderboard #}
<div class="card" style="padding:0.75rem 1rem;"> <div class="card" style="padding:0.75rem 1rem;">
{% for p in results %} {% for p in results %}
{% if game.format == 'skins' %}
{% set score_val = skins_won.get(p.id, 0) %}
{% set score_label = score_val | string + ' skin' + ('s' if score_val != 1 else '') %}
{% else %}
{% set t = totals.get(p.id, {}) %} {% set t = totals.get(p.id, {}) %}
{% set score_val = t.total %}
{% set score_label = t.total | round(1) | string + ' pts' %}
{% endif %}
<div class="result-row {% if loop.first %}result-winner{% endif %}" style="padding:0.4rem 0;"> <div class="result-row {% if loop.first %}result-winner{% endif %}" style="padding:0.4rem 0;">
<span class="result-rank">{% if loop.first %}🏆{% else %}{{ loop.index }}{% endif %}</span> <span class="result-rank">{% if loop.first %}🏆{% else %}{{ loop.index }}{% endif %}</span>
<span class="result-avatar"> <span class="result-avatar">
@@ -26,18 +33,29 @@
{% endif %} {% endif %}
</span> </span>
<a href="/users/{{ p.id }}" style="flex:1; color:inherit; text-decoration:none;">{{ p.name }}</a> <a href="/users/{{ p.id }}" style="flex:1; color:inherit; text-decoration:none;">{{ p.name }}</a>
<span class="result-pts">{{ t.total | round(1) }} pts</span> <span class="result-pts">{{ score_label }}</span>
{% if game.format == 'bingo_bango_bongo' %}
<span style="font-size:0.75rem; color:var(--text-muted); margin-left:0.5rem;"> <span style="font-size:0.75rem; color:var(--text-muted); margin-left:0.5rem;">
B{{ t.bingo | int }}/{{ t.bango | round(1) }}/{{ t.bongo | int }} B{{ t.bingo | int }}/{{ t.bango | round(1) }}/{{ t.bongo | int }}
</span> </span>
{% endif %}
</div> </div>
{% endfor %} {% endfor %}
{% if game.format == 'skins' and unclaimed %}
<div style="font-size:0.8rem; color:var(--text-muted); margin-top:0.5rem; text-align:center;">
{{ unclaimed }} skin{{ 's' if unclaimed != 1 else '' }} unclaimed
</div>
{% endif %}
</div> </div>
{# Scorecard toggle #} {# Scorecard toggle #}
<div class="scorecard-toggle" style="margin-top:1rem;"> <div class="scorecard-toggle" style="margin-top:1rem;">
<button @click="view = 'strokes'" :class="{ active: view === 'strokes' }">Strokes</button> <button @click="view = 'strokes'" :class="{ active: view === 'strokes' }">Strokes</button>
{% if game.format == 'skins' %}
<button @click="view = 'skins'" :class="{ active: view === 'skins' }">Skins</button>
{% else %}
<button @click="view = 'points'" :class="{ active: view === 'points' }">Points</button> <button @click="view = 'points'" :class="{ active: view === 'points' }">Points</button>
{% endif %}
</div> </div>
{# Strokes scorecard #} {# Strokes scorecard #}
@@ -150,5 +168,72 @@
</tfoot> </tfoot>
</table> </table>
{# Skins scorecard #}
{% if game.format == 'skins' %}
<table class="scorecard" x-show="view === 'skins'">
<thead>
<tr>
<th>Hole</th>
{% if hole_pars %}<th style="color:var(--text-muted);">Par</th>{% endif %}
{% for p in players %}
<th>
{% if p.avatar and p.avatar.startswith('/') %}
<img src="{{ p.avatar }}" style="width:1.3rem;height:1.3rem;border-radius:50%;object-fit:cover;vertical-align:middle;">
{% else %}
{{ p.avatar or '👤' }}
{% endif %}<br>
<a href="/users/{{ p.id }}" style="font-size:0.7rem; color:inherit; text-decoration:none;">{{ p.name.split()[0] }}</a>
</th>
{% endfor %}
<th style="color:var(--text-muted);">Skin</th>
</tr>
</thead>
<tbody>
{% for h in holes %}
{% set hr = skins_hole_results.get(h) %}
<tr>
<td class="hole-num">{{ h }}</td>
{% if hole_pars %}<td class="score-cell" style="color:var(--text-muted);">{{ hole_pars.get(h, '—') }}</td>{% 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) %}
<td class="score-cell">
{% if strokes %}
{% set is_winner = hr and hr.winner == p.id %}
{% if par %}
{% set diff = strokes - par %}
{% if strokes == 1 %}<span class="score-hio{% if is_winner %} skin-winner{% endif %}">{{ strokes }}</span>
{% elif diff <= -2 %}<span class="score-eagle{% if is_winner %} skin-winner{% endif %}">{{ strokes }}</span>
{% elif diff == -1 %}<span class="score-birdie{% if is_winner %} skin-winner{% endif %}">{{ strokes }}</span>
{% elif diff == 1 %}<span class="score-bogey{% if is_winner %} skin-winner{% endif %}">{{ strokes }}</span>
{% elif diff >= 2 %}<span class="score-double-bogey{% if is_winner %} skin-winner{% endif %}">{{ strokes }}</span>
{% else %}<span{% if is_winner %} class="skin-winner"{% endif %}>{{ strokes }}</span>{% endif %}
{% else %}<span{% if is_winner %} class="skin-winner"{% endif %}>{{ strokes }}</span>{% endif %}
{% else %}—{% endif %}
</td>
{% endfor %}
<td class="score-cell">
{% if hr %}
{% if hr.winner %}✓{% if hr.pot > 1 %} {{ hr.pot }}{% endif %}
{% else %}→ {{ hr.pot }}{% endif %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="totals-row">
<td>Skins</td>
{% if hole_pars %}<td></td>{% endif %}
{% for p in players %}
<td>{{ skins_won.get(p.id, 0) }}</td>
{% endfor %}
<td>{% if unclaimed %}({{ unclaimed }}){% endif %}</td>
</tr>
</tfoot>
</table>
{% endif %}
</div> </div>
{% endblock %} {% endblock %}
+1
View File
@@ -6,3 +6,4 @@ python-multipart==0.0.12
python-dotenv==1.0.1 python-dotenv==1.0.1
itsdangerous==2.2.0 itsdangerous==2.2.0
aiosmtplib==3.0.1 aiosmtplib==3.0.1
httpx==0.28.1
+1
View File
@@ -752,6 +752,7 @@ input:focus {
outline-offset: 2px; outline-offset: 2px;
color: #888; color: #888;
} }
.skin-winner { font-weight: 700; color: var(--accent); }
.drag-source { opacity: 0.4; } .drag-source { opacity: 0.4; }
.drag-over { outline: 2px solid var(--accent); border-radius: 0.65rem; } .drag-over { outline: 2px solid var(--accent); border-radius: 0.65rem; }