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:
+56
-8
@@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user