Add playing handicap, 6-digit login code, and game UX improvements

- Playing handicap: computed at game creation (WHS formula), stored on
  game_players alongside per-hole strokes allocation; displayed on game
  summary with HC card and / markers on scorecard (only when SI defined);
  live game shows +N per player card when SI exists, HC total otherwise;
  new game confirmation previews computed HC per player

- Login: 6-digit code sent alongside magic link in every auth email;
  check_email page has auto-advancing digit inputs with paste support;
  new POST /auth/verify-code route handles code verification

- Game view: Score/Card toggle fixed with position:fixed to always show
  above nav bar on mobile; swipe on score view changes holes, swipe on
  scorecard switches tabs; dirty tracking prevents saving unmodified holes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Rolf
2026-03-23 20:37:08 +01:00
parent c658c970ac
commit 8601584099
10 changed files with 335 additions and 123 deletions
+49 -1
View File
@@ -7,7 +7,7 @@ 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.auth import create_magic_token, verify_magic_token, verify_magic_code, create_session, delete_session
from app.templates_config import templates
router = APIRouter()
@@ -52,6 +52,54 @@ async def login_submit(
return templates.TemplateResponse("auth/check_email.html", {"request": request, "email": email})
@router.post("/auth/verify-code", response_class=HTMLResponse)
async def verify_code(
request: Request,
email: str = Form(...),
code: str = Form(...),
db: aiosqlite.Connection = Depends(get_db),
):
code = code.strip()
verified_email = await verify_magic_code(email, code, db)
if verified_email is None:
return templates.TemplateResponse(
"auth/check_email.html",
{"request": request, "email": email, "error": "Invalid or expired code. Try again."},
status_code=400,
)
async with db.execute("SELECT id, is_onboarded FROM users WHERE email = ?", (verified_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, verified_email, now),
)
await db.execute("DELETE FROM invitations WHERE email = ?", (verified_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("/auth/verify", response_class=HTMLResponse)
async def auth_verify(
request: Request,
+76 -45
View File
@@ -31,13 +31,18 @@ async def _get_game(game_id: str, db: aiosqlite.Connection):
async def _get_players(game_id: str, db: aiosqlite.Connection):
async with db.execute(
"""SELECT u.id, u.name, u.avatar, gp.handicap_snapshot,
gp.playing_handicap, gp.strokes_allocation,
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()]
rows = [dict(r) for r in await c.fetchall()]
for p in rows:
raw = p.get("strokes_allocation")
p["strokes_allocation"] = {int(k): v for k, v in json.loads(raw).items()} if raw else {}
return rows
async def _get_scores(game_id: str, db: aiosqlite.Connection):
@@ -65,42 +70,23 @@ async def _broadcast(game_id: str, message: dict):
_connections.get(game_id, set()).difference_update(dead)
def _playing_data(
players: list,
holes: list,
hole_pars: dict,
hole_stroke_indices: dict,
) -> tuple[dict, dict]:
"""Return (playing_handicaps, strokes_per_hole).
playing_handicaps {player_id: course_handicap (int) or None}
strokes_per_hole {player_id: {hole_number: strokes_received}}
"""
course_par = sum(hole_pars.get(h, 0) for h in holes if h in hole_pars)
playing_handicaps: dict = {}
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
strokes_per_hole: dict = {}
def _strokes_per_hole(players: list, holes: list, hole_stroke_indices: dict) -> dict:
"""Fallback: compute strokes-per-hole from playing_handicap + stroke indices."""
played_with_si = {h: hole_stroke_indices[h] for h in holes if h in hole_stroke_indices}
if played_with_si:
n_si = len(played_with_si)
for p in players:
ph = playing_handicaps.get(p["id"])
if ph is not None and ph > 0:
base, extra = divmod(ph, n_si)
strokes_per_hole[p["id"]] = {
h: base + (1 if played_with_si[h] <= extra else 0)
for h in holes if h in played_with_si
}
return playing_handicaps, strokes_per_hole
if not played_with_si:
return {}
n_si = len(played_with_si)
sorted_holes = sorted(played_with_si, key=lambda h: played_with_si[h])
hole_rank = {h: rank + 1 for rank, h in enumerate(sorted_holes)}
result = {}
for p in players:
ph = p.get("playing_handicap")
if ph is not None and ph > 0:
base, extra = divmod(ph, n_si)
result[p["id"]] = {h: base + (1 if hole_rank[h] <= extra else 0)
for h in holes if h in played_with_si}
return result
def _calc_skins(players: list, scores: dict, holes: list, carryover: bool = True):
@@ -199,7 +185,9 @@ async def new_game_page(request: Request, user: dict = Depends(get_current_user)
"SELECT id, name, avatar, handicap 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:
async with db.execute(
"SELECT c.id, c.name, c.holes, c.location, COALESCE((SELECT SUM(par) FROM course_holes WHERE course_id=c.id),0) AS par, COALESCE((SELECT SUM(par) FROM course_holes WHERE course_id=c.id AND hole_number<=9),0) AS par_9 FROM courses c ORDER BY c.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"
@@ -236,7 +224,9 @@ async def create_game(request: Request, holes_count: int = Form(...),
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:
async with db.execute(
"SELECT c.id, c.name, c.holes, c.location, COALESCE((SELECT SUM(par) FROM course_holes WHERE course_id=c.id),0) AS par, COALESCE((SELECT SUM(par) FROM course_holes WHERE course_id=c.id AND hole_number<=9),0) AS par_9 FROM courses c ORDER BY c.name"
) as c:
courses = [dict(r) for r in await c.fetchall()]
error = None
@@ -280,14 +270,50 @@ async def create_game(request: Request, holes_count: int = Form(...),
"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),
)
# Pre-fetch course holes for handicap calculation
course_par = 0
hole_stroke_indices: dict[int, int] = {}
if course_id:
async with db.execute(
"SELECT hole_number, par, stroke_index FROM course_holes WHERE course_id = ? AND hole_number <= ? ORDER BY hole_number",
(course_id, holes_count),
) as c:
for row in await c.fetchall():
course_par += row["par"] or 0
if row["stroke_index"] is not None:
hole_stroke_indices[row["hole_number"]] = row["stroke_index"]
played_holes = list(range(1, holes_count + 1))
played_with_si = {h: hole_stroke_indices[h] for h in played_holes if h in hole_stroke_indices}
hole_rank: dict[int, int] = {}
if played_with_si:
sorted_holes = sorted(played_with_si, key=lambda h: played_with_si[h])
hole_rank = {h: rank + 1 for rank, h in enumerate(sorted_holes)}
n_si = len(played_with_si)
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
playing_handicap = None
strokes_allocation_json = None
if hc is not None and tee_id and course_par:
async with db.execute("SELECT slope, rating FROM course_tees WHERE id = ?", (tee_id,)) as c:
tee_row = await c.fetchone()
if tee_row:
playing_handicap = round(hc * (tee_row["slope"] / 113) + (tee_row["rating"] - course_par))
if hole_rank and playing_handicap > 0:
base, extra = divmod(playing_handicap, n_si)
allocation = {str(h): base + (1 if hole_rank[h] <= extra else 0)
for h in played_holes if h in played_with_si}
strokes_allocation_json = json.dumps(allocation)
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),
"INSERT OR IGNORE INTO game_players (id, game_id, user_id, handicap_snapshot, tee_id, joined_at, playing_handicap, strokes_allocation) VALUES (?,?,?,?,?,?,?,?)",
(secrets.token_urlsafe(10), game_id, uid, hc, tee_id, now, playing_handicap, strokes_allocation_json),
)
await db.commit()
@@ -318,7 +344,10 @@ 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"]
playing_handicaps, strokes_per_hole = _playing_data(players, holes, hole_pars, hole_stroke_indices)
playing_handicaps = {p["id"]: p.get("playing_handicap") for p in players}
computed = _strokes_per_hole(players, holes, hole_stroke_indices)
strokes_per_hole = {p["id"]: p["strokes_allocation"] or computed.get(p["id"], {}) for p in players}
has_stroke_index = bool(hole_stroke_indices)
skins_won, skins_hole_results, unclaimed = None, None, 0
if game["format"] == "skins":
@@ -331,9 +360,10 @@ async def game_summary(game_id: str, request: Request, user: dict = Depends(get_
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, "hole_stroke_indices": hole_stroke_indices,
"results": results,
"holes": holes, "hole_pars": hole_pars,
"playing_handicaps": playing_handicaps, "strokes_per_hole": strokes_per_hole,
"has_stroke_index": has_stroke_index,
"results": results,
"skins_won": skins_won, "skins_hole_results": skins_hole_results, "unclaimed": unclaimed,
"carryover": bool(game["carryover"]),
})
@@ -351,7 +381,6 @@ async def game_page(game_id: str, request: Request, user: dict = Depends(get_cur
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"]:
@@ -364,13 +393,15 @@ async def game_page(game_id: str, request: Request, user: dict = Depends(get_cur
if row["stroke_index"] is not None:
hole_stroke_indices[row["hole_number"]] = row["stroke_index"]
playing_handicaps, strokes_per_hole = _playing_data(players, holes, hole_pars, hole_stroke_indices)
computed = _strokes_per_hole(players, holes, hole_stroke_indices)
strokes_per_hole = {p["id"]: p["strokes_allocation"] or computed.get(p["id"], {}) for p in players}
has_stroke_index = bool(hole_stroke_indices)
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, "strokes_per_hole": strokes_per_hole,
"strokes_per_hole": strokes_per_hole, "has_stroke_index": has_stroke_index,
"carryover": bool(game["carryover"]),
})