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:
+49
-1
@@ -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
@@ -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"]),
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user