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
+56 -8
View File
@@ -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 ─────────────────────────────────────────────────────────────────