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
+25 -3
View File
@@ -37,19 +37,41 @@ async def create_magic_token(email: str, db: aiosqlite.Connection) -> str:
token_id = secrets.token_urlsafe(16)
token = secrets.token_urlsafe(32)
code = f"{secrets.randbelow(1000000):06d}"
expires_at = _in(minutes=TOKEN_TTL_MINUTES)
await db.execute(
"INSERT INTO magic_link_tokens (id, email, token, expires_at) VALUES (?, ?, ?, ?)",
(token_id, email, token, expires_at),
"INSERT INTO magic_link_tokens (id, email, token, code, expires_at) VALUES (?, ?, ?, ?, ?)",
(token_id, email, token, code, expires_at),
)
await db.commit()
link = f"{SITE_URL}/auth/verify?token={token}"
await send_magic_link(email, link)
await send_magic_link(email, link, code)
return token
async def verify_magic_code(email: str, code: str, db: aiosqlite.Connection):
"""Returns the email if code matches a valid unused token, else None."""
async with db.execute(
"SELECT expires_at, used_at FROM magic_link_tokens WHERE email = ? AND code = ? AND used_at IS NULL",
(email, code),
) as cursor:
row = await cursor.fetchone()
if row is None:
return None
if _expired(row["expires_at"]):
return None
await db.execute(
"UPDATE magic_link_tokens SET used_at = ? WHERE email = ? AND code = ?",
(_now(), email, code),
)
await db.commit()
return email
async def verify_magic_token(token: str, db: aiosqlite.Connection):
"""Returns the email if token is valid and unused, else None."""
async with db.execute(
+3
View File
@@ -121,6 +121,9 @@ async def init_db():
"ALTER TABLE game_players ADD COLUMN tee_id TEXT REFERENCES course_tees(id)",
"ALTER TABLE course_holes ADD COLUMN stroke_index INTEGER",
"ALTER TABLE games ADD COLUMN carryover INTEGER NOT NULL DEFAULT 1",
"ALTER TABLE game_players ADD COLUMN playing_handicap INTEGER",
"ALTER TABLE game_players ADD COLUMN strokes_allocation TEXT",
"ALTER TABLE magic_link_tokens ADD COLUMN code TEXT",
]:
try:
await db.execute(migration)
+14 -6
View File
@@ -43,14 +43,22 @@ async def _send(to: str, subject: str, body_text: str, body_html: str) -> None:
)
async def send_magic_link(to: str, link: str) -> None:
subject = f"Your {SITE_NAME} login link"
body_text = f"Click the link below to log in (expires in 15 minutes):\n\n{link}\n\nIf you didn't request this, you can ignore this email."
async def send_magic_link(to: str, link: str, code: str) -> None:
subject = f"Your {SITE_NAME} login code: {code}"
body_text = (
f"Your login code is: {code}\n\n"
f"Enter it on the app, or click the link below (both expire in 15 minutes):\n\n{link}\n\n"
f"If you didn't request this, you can ignore this email."
)
body_html = f"""
<div style="font-family:sans-serif;max-width:480px;margin:auto;">
<h2 style="color:#2d6a4f;">⛳ {SITE_NAME}</h2>
<p>Click the button below to log in. This link expires in 15 minutes.</p>
<p style="margin:2rem 0;">
<p>Enter this code in the app to log in:</p>
<div style="font-size:2.5rem;font-weight:700;letter-spacing:0.4rem;text-align:center;
background:#f0f7f4;border-radius:0.5rem;padding:1rem 1.5rem;margin:1.5rem 0;
color:#1b4332;font-family:monospace;">{code}</div>
<p style="color:#555;font-size:0.9rem;">Or click the button below — whichever is easier. Both expire in 15 minutes.</p>
<p style="margin:1.5rem 0;">
<a href="{link}"
style="background:#2d6a4f;color:#fff;padding:0.75rem 1.5rem;border-radius:0.5rem;text-decoration:none;font-weight:600;">
Log in to {SITE_NAME}
@@ -67,7 +75,7 @@ async def send_magic_link(to: str, link: str) -> None:
except Exception as e:
logger.error("Failed to send magic link email to %s: %s", to, e)
else:
logger.info("Magic link for %s: %s", to, link)
logger.info("Magic link for %s — code: %s link: %s", to, code, link)
async def send_invitation(to: str, invited_by_name: str, login_url: str) -> None:
+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,
+72 -41
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:
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 = playing_handicaps.get(p["id"])
ph = p.get("playing_handicap")
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
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"]),
})
+7 -2
View File
@@ -108,7 +108,9 @@
</div>
<div class="form-group" style="margin:0; width:7rem;">
<label style="font-size:0.8rem;">Rating</label>
<input type="number" step="0.1" name="rating" value="{{ t.rating }}" min="60" max="80" required style="padding:0.4rem 0.6rem;">
<input type="number" step="0.1" name="rating" value="{{ t.rating }}"
min="{{ '29' if course.holes == 9 else '60' }}" max="{{ '40' if course.holes == 9 else '80' }}"
required style="padding:0.4rem 0.6rem;">
</div>
<div style="display:flex; gap:0.4rem; padding-bottom:0.1rem;">
<button type="submit" class="btn btn-primary" style="padding:0.4rem 0.75rem; font-size:0.85rem;">Save</button>
@@ -138,7 +140,10 @@
</div>
<div class="form-group" style="margin:0; width:7rem;">
<label style="font-size:0.8rem;">Rating</label>
<input type="number" step="0.1" name="rating" placeholder="72.0" min="60" max="80" required style="padding:0.4rem 0.6rem;">
<input type="number" step="0.1" name="rating"
placeholder="{{ '35.0' if course.holes == 9 else '72.0' }}"
min="{{ '29' if course.holes == 9 else '60' }}" max="{{ '40' if course.holes == 9 else '80' }}"
required style="padding:0.4rem 0.6rem;">
</div>
<div style="padding-bottom:0.1rem;">
<button type="submit" class="btn btn-primary" style="padding:0.4rem 0.75rem; font-size:0.85rem;">Add tee</button>
+93 -3
View File
@@ -12,11 +12,43 @@
<div class="login-container">
<div class="login-logo">📬</div>
<h1 class="login-title">Check your email</h1>
<p class="login-subtitle">We sent a login link to <strong>{{ email }}</strong></p>
<p class="login-subtitle">We sent a code and a login link to <strong>{{ email }}</strong></p>
<div class="login-card card">
<p style="color: var(--text-muted); font-size: 0.9rem; text-align: center;">
The link expires in 15 minutes.<br>You can close this tab.
{% if error %}
<p style="color:var(--danger); font-size:0.9rem; text-align:center; margin-bottom:1rem;">{{ error }}</p>
{% endif %}
<form method="post" action="/auth/verify-code" autocomplete="off"
style="display:flex; flex-direction:column; gap:1rem;">
<input type="hidden" name="email" value="{{ email }}">
<label style="font-size:0.85rem; color:var(--text-muted); text-align:center;">
Enter your 6-digit code
</label>
<div style="display:flex; gap:0.5rem; justify-content:center;" id="code-inputs">
<input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]"
class="code-digit" tabindex="1" autocomplete="one-time-code">
<input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]"
class="code-digit" tabindex="2">
<input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]"
class="code-digit" tabindex="3">
<input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]"
class="code-digit" tabindex="4">
<input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]"
class="code-digit" tabindex="5">
<input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]"
class="code-digit" tabindex="6">
</div>
<input type="hidden" name="code" id="code-value">
<button type="submit" class="btn btn-primary" id="code-submit" disabled>Verify code</button>
</form>
<p style="color:var(--text-muted); font-size:0.85rem; text-align:center; margin-top:1rem;">
The code and link expire in 15 minutes.
</p>
</div>
@@ -24,5 +56,63 @@
← Use a different email
</a>
</div>
<style>
.code-digit {
width: 3rem;
height: 3.5rem;
text-align: center;
font-size: 1.6rem;
font-weight: 700;
border: 2px solid var(--surface2);
border-radius: 0.5rem;
background: var(--surface);
color: var(--text);
caret-color: transparent;
}
.code-digit:focus {
outline: none;
border-color: var(--accent);
}
</style>
<script>
const digits = [...document.querySelectorAll('.code-digit')];
const hidden = document.getElementById('code-value');
const submit = document.getElementById('code-submit');
function sync() {
const val = digits.map(d => d.value).join('');
hidden.value = val;
submit.disabled = val.length < 6;
}
digits.forEach((el, i) => {
el.addEventListener('input', () => {
el.value = el.value.replace(/\D/g, '').slice(-1);
sync();
if (el.value && i < digits.length - 1) digits[i + 1].focus();
});
el.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' && !el.value && i > 0) {
digits[i - 1].focus();
digits[i - 1].value = '';
sync();
}
});
el.addEventListener('paste', (e) => {
e.preventDefault();
const pasted = (e.clipboardData || window.clipboardData).getData('text').replace(/\D/g, '');
digits.forEach((d, j) => d.value = pasted[j] || '');
sync();
const next = Math.min(pasted.length, digits.length - 1);
digits[next].focus();
});
});
digits[0].focus();
</script>
</body>
</html>
+31 -27
View File
@@ -6,7 +6,7 @@
<div id="game-app"
x-data="gameApp()"
x-init="init()"
style="height: calc(100vh - 64px); display:flex; flex-direction:column; overflow:hidden;">
style="position:fixed; top:0; left:0; right:0; bottom:calc(var(--nav-height) + env(safe-area-inset-bottom)); display:flex; flex-direction:column; overflow:hidden;">
{# ── Header ── #}
<div class="game-header">
@@ -17,7 +17,7 @@
<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;">
<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_SIS[currentHole] ? ' · SI ' + HOLE_SIS[currentHole] : '')"></span>
</template>
<template x-if="view === 'card'">
<span>{{ game.course_name or '' }}</span>
@@ -26,6 +26,7 @@
</div>
<button @click="view === 'score' ? nextHole() : cardView = (GAME_FORMAT === 'skins' ? 'skins' : 'points')" class="icon-btn"
:disabled="view === 'score' ? currentHole >= {{ game.holes_count }} : cardView !== 'strokes'"></button>
</div>
</div>
@@ -63,6 +64,14 @@
@touchmove.stop.prevent="touchDragMove($event)"
@touchend.stop="touchDragEnd()"></span>
<span x-text="p.name"></span>
<template x-if="HAS_STROKE_INDEX && (STROKES_PER_HOLE[p.id] || {})[currentHole] > 0">
<span style="font-size:0.7rem; color:var(--accent); margin-left:0.35rem; font-weight:600;"
x-text="'+' + (STROKES_PER_HOLE[p.id] || {})[currentHole]"></span>
</template>
<template x-if="!HAS_STROKE_INDEX && p.playing_handicap != null">
<span style="font-size:0.7rem; color:var(--text-muted); margin-left:0.35rem;"
x-text="'HC ' + p.playing_handicap"></span>
</template>
</div>
<div class="psc-controls">
<span class="stroke-controls">
@@ -110,17 +119,6 @@
{# Scorecard panel #}
<div class="swipe-panel" :class="{ active: view === 'card' }">
<div class="panel-scroll">
{# Scorecard sub-toggle #}
<div class="scorecard-toggle">
<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>
</template>
<template x-if="GAME_FORMAT === 'skins'">
<button @click="cardView = 'skins'" :class="{ active: cardView === 'skins' }">Skins</button>
</template>
</div>
{# Strokes scorecard #}
<table class="scorecard" x-show="cardView === 'strokes'">
<thead>
@@ -135,8 +133,6 @@
{{ 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>
{% set ph = playing_handicaps.get(p.id) %}
{% if ph is not none %}<br><span style="font-size:0.65rem; color:var(--text-muted);">HCP {{ ph }}</span>{% endif %}
</th>
{% endfor %}
</tr>
@@ -147,9 +143,11 @@
<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 sr = strokes_per_hole.get(p.id, {}).get(h, 0) %}
<td class="score-cell">
<span x-text="holeStrokesStr({{ h }}, '{{ p.id }}')"
:class="holeScoreClass({{ h }}, '{{ p.id }}')"></span>
{% if has_stroke_index and sr > 0 %}<sup style="color:var(--accent); font-size:0.6em; line-height:0;">{{ '/' * sr }}</sup>{% endif %}
</td>
{% endfor %}
</tr>
@@ -179,8 +177,6 @@
{{ 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>
{% set ph = playing_handicaps.get(p.id) %}
{% if ph is not none %}<br><span style="font-size:0.65rem; color:var(--text-muted);">HCP {{ ph }}</span>{% endif %}
</th>
{% endfor %}
</tr>
@@ -232,9 +228,11 @@
<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 sr = strokes_per_hole.get(p.id, {}).get(h, 0) %}
<td class="score-cell">
<span x-text="holeStrokesStr({{ h }}, '{{ p.id }}')"
:class="[holeScoreClass({{ h }}, '{{ p.id }}'), isSkinWinner({{ h }}, '{{ p.id }}') ? 'skin-winner' : '']"></span>
{% if has_stroke_index and sr > 0 %}<sup style="color:var(--accent); font-size:0.6em; line-height:0;">{{ '/' * sr }}</sup>{% endif %}
</td>
{% endfor %}
<td class="score-cell" x-text="skinHoleLabel({{ h }})"></td>
@@ -282,6 +280,8 @@ const GAME_FORMAT = "{{ game.format }}";
const SKINS_CARRYOVER = {{ carryover | tojson }};
const HOLE_PARS = {{ hole_pars | tojson }};
const HOLE_SIS = {{ hole_stroke_indices | tojson }};
const STROKES_PER_HOLE = {{ strokes_per_hole | tojson }};
const HAS_STROKE_INDEX = {{ has_stroke_index | tojson }};
const PLAYERS_DATA = {{ players | tojson }};
// Initial scores from server
@@ -297,6 +297,7 @@ function gameApp() {
scores: {}, // { hole: { uid: {strokes,bingo,bango,bongo} } }
totals: {},
skipped: new Set(),
dirty: new Set(),
ws: null,
touchStartX: 0,
dragSrc: null,
@@ -350,12 +351,8 @@ function gameApp() {
adjustStrokes(hole, uid, delta) {
this._ensurePlayer(hole, uid);
const cur = this.scores[hole][uid].strokes;
if (!cur) {
// First press: start at par for this hole
this.scores[hole][uid].strokes = HOLE_PARS[hole] || 4;
} else {
this.scores[hole][uid].strokes = Math.max(1, cur + delta);
}
this.scores[hole][uid].strokes = cur ? Math.max(1, cur + delta) : (HOLE_PARS[hole] || 4);
this.dirty.add(hole);
},
setBingo(hole, uid) {
@@ -363,6 +360,7 @@ function gameApp() {
const current = this.scores[hole][uid].bingo;
for (const p in this.scores[hole]) this.scores[hole][p].bingo = 0;
this.scores[hole][uid].bingo = current ? 0 : 1;
this.dirty.add(hole);
},
toggleBango(hole, uid) {
@@ -372,6 +370,7 @@ function gameApp() {
const winners = Object.entries(this.scores[hole]).filter(([, s]) => s.bango > 0).map(([u]) => u);
const share = winners.length ? Math.round(10000 / winners.length) / 10000 : 0;
for (const p in this.scores[hole]) this.scores[hole][p].bango = winners.includes(p) ? share : 0;
this.dirty.add(hole);
},
setBongo(hole, uid) {
@@ -379,9 +378,12 @@ function gameApp() {
const current = this.scores[hole][uid].bongo;
for (const p in this.scores[hole]) this.scores[hole][p].bongo = 0;
this.scores[hole][uid].bongo = current ? 0 : 1;
this.dirty.add(hole);
},
saveHole(hole) {
if (!this.dirty.has(hole)) return;
this.dirty.delete(hole);
const holeScores = this.scores[hole] ?? {};
const params = new URLSearchParams();
params.set('hole', hole);
@@ -525,10 +527,12 @@ function gameApp() {
if (!this.touchStartX) return;
const dx = e.changedTouches[0].screenX - this.touchStartX;
this.touchStartX = 0;
if (Math.abs(dx) > 60) {
const next = dx < 0 ? 'card' : 'score';
if (next === 'card' && this.view === 'score') this.saveHole(this.currentHole);
this.view = next;
if (Math.abs(dx) < 60) return;
if (this.view === 'score') {
if (dx < 0) this.nextHole(); else this.prevHole();
} else {
const secondary = GAME_FORMAT === 'skins' ? 'skins' : 'points';
this.cardView = dx < 0 ? secondary : 'strokes';
}
},
};
+15 -1
View File
@@ -58,6 +58,15 @@
const id = this.selectedTees[uid];
return id ? (TEES_BY_COURSE[this.courseId] || []).find(t => t.id === id) : null;
},
getCourseHandicap(uid) {
const player = this.getPlayer(uid);
const tee = this.getTee(uid);
const course = COURSES_DATA.find(c => c.id === this.courseId);
if (!course || !tee || player.handicap == null) return null;
const coursePar = (parseInt(this.holesCount) === 9 && course.holes === 18) ? course.par_9 : course.par;
if (!coursePar) return null;
return Math.round(player.handicap * (tee.slope / 113) + (tee.rating - coursePar));
},
goToConfirm() { this.step = 3; },
backFromConfirm() { this.step = this.hasTees() ? 2 : 1; },
formatLabel() {
@@ -195,9 +204,11 @@
</div>
<div style="position:sticky; bottom:calc(var(--nav-height) + env(safe-area-inset-bottom)); background:var(--bg); padding:0.75rem 0; margin-top:0.5rem;">
<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="goToConfirm()">Next →</button>
</div>
</div>
{# ── Step 2: Tee selection ── #}
<div x-show="step === 2" style="display:none;">
@@ -274,10 +285,13 @@
<div style="flex:1; min-width:0;">
<div style="font-weight:500;" x-text="getPlayer(uid).name"></div>
<div style="font-size:0.8rem; color:var(--text-muted);">
<span x-text="'HCP ' + (getPlayer(uid).handicap != null ? getPlayer(uid).handicap : '—')"></span>
<span x-text="'HI\u00a0' + (getPlayer(uid).handicap != null ? getPlayer(uid).handicap : '—')"></span>
<template x-if="getTee(uid)">
<span x-text="' · ' + getTee(uid).name + ' (Slope\u00a0' + getTee(uid).slope + ' · Rating\u00a0' + getTee(uid).rating + ')'"></span>
</template>
<template x-if="getCourseHandicap(uid) != null">
<span style="color:var(--accent); font-weight:600;" x-text="' · HC\u00a0' + getCourseHandicap(uid)"></span>
</template>
</div>
</div>
+19 -32
View File
@@ -49,32 +49,28 @@
</div>
{# Playing handicaps #}
{% if playing_handicaps %}
{% if playing_handicaps and playing_handicaps.values() | select | list %}
<div class="card" style="margin-top:0.75rem; padding:0.75rem 1rem;">
<div style="font-size:0.8rem; color:var(--text-muted); font-weight:500; margin-bottom:0.5rem; text-transform:uppercase; letter-spacing:0.05em;">Playing Handicaps</div>
{% for p in players %}
{% set ph = playing_handicaps.get(p.id) %}
{% if ph is not none %}
<div style="display:flex; align-items:center; gap:0.6rem; padding:0.35rem 0; {% if not loop.last %}border-bottom:1px solid var(--border);{% endif %} font-size:0.9rem;">
<span>
{% 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 %}
{% else %}{{ p.avatar or '👤' }}{% endif %}
</span>
<span style="flex:1; font-weight:500;">{{ p.name }}</span>
{% if p.handicap_snapshot is not none %}
<span style="color:var(--text-muted);">HI&nbsp;{{ p.handicap_snapshot }}</span>
{% endif %}
{% set ph = playing_handicaps.get(p.id) %}
{% if ph is not none %}
<span style="font-weight:600; color:var(--accent); min-width:3rem; text-align:right;">HC&nbsp;{{ ph }}</span>
{% else %}
<span style="color:var(--text-muted); min-width:3rem; text-align:right;">HC&nbsp;</span>
{% endif %}
{% if p.tee_name %}
<span style="color:var(--text-muted); font-size:0.8rem; min-width:4rem; text-align:right;">{{ p.tee_name }}</span>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
@@ -121,26 +117,15 @@
{% if strokes %}
{% if par %}
{% set diff = strokes - par %}
{% if strokes == 1 %}
<span class="score-hio">{{ strokes }}</span>
{% elif diff <= -2 %}
<span class="score-eagle">{{ strokes }}</span>
{% elif diff == -1 %}
<span class="score-birdie">{{ strokes }}</span>
{% elif diff == 1 %}
<span class="score-bogey">{{ strokes }}</span>
{% elif diff >= 2 %}
<span class="score-double-bogey">{{ strokes }}</span>
{% else %}
{{ strokes }}
{% endif %}
{% else %}
{{ strokes }}
{% endif %}
{% else %}
{% endif %}
{% if sr > 0 %}<sup style="color:var(--accent); font-size:0.6em; line-height:0;">{{ '·' * sr }}</sup>{% endif %}
{% if strokes == 1 %}<span class="score-hio">{{ strokes }}</span>
{% elif diff <= -2 %}<span class="score-eagle">{{ strokes }}</span>
{% elif diff == -1 %}<span class="score-birdie">{{ strokes }}</span>
{% elif diff == 1 %}<span class="score-bogey">{{ strokes }}</span>
{% elif diff >= 2 %}<span class="score-double-bogey">{{ strokes }}</span>
{% else %}{{ strokes }}{% endif %}
{% else %}{{ strokes }}{% endif %}
{% else %}—{% endif %}
{% if has_stroke_index and sr > 0 %}<sup style="color:var(--accent); font-size:0.6em; line-height:0;">{{ '/' * sr }}</sup>{% endif %}
</td>
{% endfor %}
</tr>
@@ -159,12 +144,14 @@
<td>{{ stroke_total.val or '—' }}</td>
{% endfor %}
</tr>
{% if playing_handicaps %}
{% set any_hc = playing_handicaps and playing_handicaps.values() | select | list %}
{% if any_hc %}
<tr>
<td style="color:var(--text-muted); font-size:0.8rem;">HC</td>
{% if hole_pars %}<td></td>{% endif %}
{% for p in players %}
<td style="color:var(--text-muted); font-size:0.8rem;">{{ playing_handicaps.get(p.id, '—') if playing_handicaps.get(p.id) is not none else '—' }}</td>
{% set ph = playing_handicaps.get(p.id) %}
<td style="color:var(--text-muted); font-size:0.8rem;">{{ ph if ph is not none else '—' }}</td>
{% endfor %}
</tr>
{% endif %}
@@ -254,7 +241,7 @@
{% 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 %}
{% if sr > 0 %}<sup style="color:var(--accent); font-size:0.6em; line-height:0;">{{ '·' * sr }}</sup>{% endif %}
{% if has_stroke_index and sr > 0 %}<sup style="color:var(--accent); font-size:0.6em; line-height:0;">{{ '/' * sr }}</sup>{% endif %}
</td>
{% endfor %}
<td class="score-cell">