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:
+25
-3
@@ -37,19 +37,41 @@ async def create_magic_token(email: str, db: aiosqlite.Connection) -> str:
|
|||||||
|
|
||||||
token_id = secrets.token_urlsafe(16)
|
token_id = secrets.token_urlsafe(16)
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
|
code = f"{secrets.randbelow(1000000):06d}"
|
||||||
expires_at = _in(minutes=TOKEN_TTL_MINUTES)
|
expires_at = _in(minutes=TOKEN_TTL_MINUTES)
|
||||||
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"INSERT INTO magic_link_tokens (id, email, token, expires_at) VALUES (?, ?, ?, ?)",
|
"INSERT INTO magic_link_tokens (id, email, token, code, expires_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
(token_id, email, token, expires_at),
|
(token_id, email, token, code, expires_at),
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
link = f"{SITE_URL}/auth/verify?token={token}"
|
link = f"{SITE_URL}/auth/verify?token={token}"
|
||||||
await send_magic_link(email, link)
|
await send_magic_link(email, link, code)
|
||||||
return token
|
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):
|
async def verify_magic_token(token: str, db: aiosqlite.Connection):
|
||||||
"""Returns the email if token is valid and unused, else None."""
|
"""Returns the email if token is valid and unused, else None."""
|
||||||
async with db.execute(
|
async with db.execute(
|
||||||
|
|||||||
@@ -121,6 +121,9 @@ async def init_db():
|
|||||||
"ALTER TABLE game_players ADD COLUMN tee_id TEXT REFERENCES course_tees(id)",
|
"ALTER TABLE game_players ADD COLUMN tee_id TEXT REFERENCES course_tees(id)",
|
||||||
"ALTER TABLE course_holes ADD COLUMN stroke_index INTEGER",
|
"ALTER TABLE course_holes ADD COLUMN stroke_index INTEGER",
|
||||||
"ALTER TABLE games ADD COLUMN carryover INTEGER NOT NULL DEFAULT 1",
|
"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:
|
try:
|
||||||
await db.execute(migration)
|
await db.execute(migration)
|
||||||
|
|||||||
+14
-6
@@ -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:
|
async def send_magic_link(to: str, link: str, code: str) -> None:
|
||||||
subject = f"Your {SITE_NAME} login link"
|
subject = f"Your {SITE_NAME} login code: {code}"
|
||||||
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."
|
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"""
|
body_html = f"""
|
||||||
<div style="font-family:sans-serif;max-width:480px;margin:auto;">
|
<div style="font-family:sans-serif;max-width:480px;margin:auto;">
|
||||||
<h2 style="color:#2d6a4f;">⛳ {SITE_NAME}</h2>
|
<h2 style="color:#2d6a4f;">⛳ {SITE_NAME}</h2>
|
||||||
<p>Click the button below to log in. This link expires in 15 minutes.</p>
|
<p>Enter this code in the app to log in:</p>
|
||||||
<p style="margin:2rem 0;">
|
<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}"
|
<a href="{link}"
|
||||||
style="background:#2d6a4f;color:#fff;padding:0.75rem 1.5rem;border-radius:0.5rem;text-decoration:none;font-weight:600;">
|
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}
|
Log in to {SITE_NAME}
|
||||||
@@ -67,7 +75,7 @@ async def send_magic_link(to: str, link: str) -> None:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to send magic link email to %s: %s", to, e)
|
logger.error("Failed to send magic link email to %s: %s", to, e)
|
||||||
else:
|
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:
|
async def send_invitation(to: str, invited_by_name: str, login_url: str) -> None:
|
||||||
|
|||||||
+49
-1
@@ -7,7 +7,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
|||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
|
||||||
from app.database import get_db
|
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
|
from app.templates_config import templates
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -52,6 +52,54 @@ async def login_submit(
|
|||||||
return templates.TemplateResponse("auth/check_email.html", {"request": request, "email": email})
|
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)
|
@router.get("/auth/verify", response_class=HTMLResponse)
|
||||||
async def auth_verify(
|
async def auth_verify(
|
||||||
request: Request,
|
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 def _get_players(game_id: str, db: aiosqlite.Connection):
|
||||||
async with db.execute(
|
async with db.execute(
|
||||||
"""SELECT u.id, u.name, u.avatar, gp.handicap_snapshot,
|
"""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
|
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
|
FROM game_players gp JOIN users u ON gp.user_id = u.id
|
||||||
LEFT JOIN course_tees ct ON ct.id = gp.tee_id
|
LEFT JOIN course_tees ct ON ct.id = gp.tee_id
|
||||||
WHERE gp.game_id = ? ORDER BY gp.joined_at""",
|
WHERE gp.game_id = ? ORDER BY gp.joined_at""",
|
||||||
(game_id,),
|
(game_id,),
|
||||||
) as c:
|
) 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):
|
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)
|
_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}
|
def _strokes_per_hole(players: list, holes: list, hole_stroke_indices: dict) -> dict:
|
||||||
strokes_per_hole – {player_id: {hole_number: strokes_received}}
|
"""Fallback: compute strokes-per-hole from playing_handicap + stroke indices."""
|
||||||
"""
|
|
||||||
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 = {}
|
|
||||||
played_with_si = {h: hole_stroke_indices[h] for h in holes if h in hole_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:
|
||||||
n_si = len(played_with_si)
|
return {}
|
||||||
for p in players:
|
n_si = len(played_with_si)
|
||||||
ph = playing_handicaps.get(p["id"])
|
sorted_holes = sorted(played_with_si, key=lambda h: played_with_si[h])
|
||||||
if ph is not None and ph > 0:
|
hole_rank = {h: rank + 1 for rank, h in enumerate(sorted_holes)}
|
||||||
base, extra = divmod(ph, n_si)
|
result = {}
|
||||||
strokes_per_hole[p["id"]] = {
|
for p in players:
|
||||||
h: base + (1 if played_with_si[h] <= extra else 0)
|
ph = p.get("playing_handicap")
|
||||||
for h in holes if h in played_with_si
|
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)
|
||||||
return playing_handicaps, strokes_per_hole
|
for h in holes if h in played_with_si}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _calc_skins(players: list, scores: dict, holes: list, carryover: bool = True):
|
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"
|
"SELECT id, name, avatar, handicap FROM users WHERE is_onboarded = 1 ORDER BY name"
|
||||||
) as c:
|
) as c:
|
||||||
all_users = [dict(r) for r in await c.fetchall()]
|
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()]
|
courses = [dict(r) for r in await c.fetchall()]
|
||||||
async with db.execute(
|
async with db.execute(
|
||||||
"SELECT id, course_id, name, slope, rating FROM course_tees ORDER BY course_id, name"
|
"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:
|
if user["id"] not in player_ids:
|
||||||
player_ids.insert(0, user["id"])
|
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()]
|
courses = [dict(r) for r in await c.fetchall()]
|
||||||
|
|
||||||
error = None
|
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 (?,?,?,?,?,?,?,?)",
|
"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),
|
(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:
|
for uid in player_ids:
|
||||||
async with db.execute("SELECT handicap FROM users WHERE id = ?", (uid,)) as c:
|
async with db.execute("SELECT handicap FROM users WHERE id = ?", (uid,)) as c:
|
||||||
row = await c.fetchone()
|
row = await c.fetchone()
|
||||||
hc = row["handicap"] if row else None
|
hc = row["handicap"] if row else None
|
||||||
tee_id = form.get(f"tee_{uid}") or 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(
|
await db.execute(
|
||||||
"INSERT OR IGNORE INTO game_players (id, game_id, user_id, handicap_snapshot, tee_id, joined_at) VALUES (?,?,?,?,?,?)",
|
"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),
|
(secrets.token_urlsafe(10), game_id, uid, hc, tee_id, now, playing_handicap, strokes_allocation_json),
|
||||||
)
|
)
|
||||||
await db.commit()
|
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:
|
if row["stroke_index"] is not None:
|
||||||
hole_stroke_indices[row["hole_number"]] = row["stroke_index"]
|
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
|
skins_won, skins_hole_results, unclaimed = None, None, 0
|
||||||
if game["format"] == "skins":
|
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", {
|
return templates.TemplateResponse("games/summary.html", {
|
||||||
"request": request, "user": user, "game": dict(game),
|
"request": request, "user": user, "game": dict(game),
|
||||||
"players": players, "scores": scores, "totals": totals,
|
"players": players, "scores": scores, "totals": totals,
|
||||||
"holes": holes, "hole_pars": hole_pars, "hole_stroke_indices": hole_stroke_indices,
|
"holes": holes, "hole_pars": hole_pars,
|
||||||
"results": results,
|
|
||||||
"playing_handicaps": playing_handicaps, "strokes_per_hole": strokes_per_hole,
|
"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,
|
"skins_won": skins_won, "skins_hole_results": skins_hole_results, "unclaimed": unclaimed,
|
||||||
"carryover": bool(game["carryover"]),
|
"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)
|
totals = _totals(players, scores)
|
||||||
holes = list(range(1, game["holes_count"] + 1))
|
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_pars: dict[int, int] = {}
|
||||||
hole_stroke_indices: dict[int, int] = {}
|
hole_stroke_indices: dict[int, int] = {}
|
||||||
if game["course_id"]:
|
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:
|
if row["stroke_index"] is not None:
|
||||||
hole_stroke_indices[row["hole_number"]] = row["stroke_index"]
|
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", {
|
return templates.TemplateResponse("games/game.html", {
|
||||||
"request": request, "user": user, "game": dict(game),
|
"request": request, "user": user, "game": dict(game),
|
||||||
"players": players, "scores": scores, "totals": totals,
|
"players": players, "scores": scores, "totals": totals,
|
||||||
"holes": holes, "hole_pars": hole_pars, "hole_stroke_indices": hole_stroke_indices,
|
"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"]),
|
"carryover": bool(game["carryover"]),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -108,7 +108,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="margin:0; width:7rem;">
|
<div class="form-group" style="margin:0; width:7rem;">
|
||||||
<label style="font-size:0.8rem;">Rating</label>
|
<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>
|
||||||
<div style="display:flex; gap:0.4rem; padding-bottom:0.1rem;">
|
<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>
|
<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>
|
||||||
<div class="form-group" style="margin:0; width:7rem;">
|
<div class="form-group" style="margin:0; width:7rem;">
|
||||||
<label style="font-size:0.8rem;">Rating</label>
|
<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>
|
||||||
<div style="padding-bottom:0.1rem;">
|
<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>
|
<button type="submit" class="btn btn-primary" style="padding:0.4rem 0.75rem; font-size:0.85rem;">Add tee</button>
|
||||||
|
|||||||
@@ -12,17 +12,107 @@
|
|||||||
<div class="login-container">
|
<div class="login-container">
|
||||||
<div class="login-logo">📬</div>
|
<div class="login-logo">📬</div>
|
||||||
<h1 class="login-title">Check your email</h1>
|
<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">
|
<div class="login-card card">
|
||||||
<p style="color: var(--text-muted); font-size: 0.9rem; text-align: center;">
|
{% if error %}
|
||||||
The link expires in 15 minutes.<br>You can close this tab.
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a href="/login" style="color: var(--text-muted); font-size: 0.85rem; margin-top: 1.5rem;">
|
<a href="/login" style="color:var(--text-muted); font-size:0.85rem; margin-top:1.5rem;">
|
||||||
← Use a different email
|
← Use a different email
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<div id="game-app"
|
<div id="game-app"
|
||||||
x-data="gameApp()"
|
x-data="gameApp()"
|
||||||
x-init="init()"
|
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 ── #}
|
{# ── Header ── #}
|
||||||
<div class="game-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 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;">
|
<div style="font-size:0.72rem; color:var(--text-muted); margin-top:0.1rem;">
|
||||||
<template x-if="view === 'score'">
|
<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>
|
||||||
<template x-if="view === 'card'">
|
<template x-if="view === 'card'">
|
||||||
<span>{{ game.course_name or '' }}</span>
|
<span>{{ game.course_name or '' }}</span>
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<button @click="view === 'score' ? nextHole() : cardView = (GAME_FORMAT === 'skins' ? 'skins' : 'points')" class="icon-btn"
|
<button @click="view === 'score' ? nextHole() : cardView = (GAME_FORMAT === 'skins' ? 'skins' : 'points')" class="icon-btn"
|
||||||
:disabled="view === 'score' ? currentHole >= {{ game.holes_count }} : cardView !== 'strokes'">›</button>
|
:disabled="view === 'score' ? currentHole >= {{ game.holes_count }} : cardView !== 'strokes'">›</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -63,6 +64,14 @@
|
|||||||
@touchmove.stop.prevent="touchDragMove($event)"
|
@touchmove.stop.prevent="touchDragMove($event)"
|
||||||
@touchend.stop="touchDragEnd()">⠿</span>
|
@touchend.stop="touchDragEnd()">⠿</span>
|
||||||
<span x-text="p.name"></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>
|
||||||
<div class="psc-controls">
|
<div class="psc-controls">
|
||||||
<span class="stroke-controls">
|
<span class="stroke-controls">
|
||||||
@@ -110,17 +119,6 @@
|
|||||||
{# Scorecard panel #}
|
{# Scorecard panel #}
|
||||||
<div class="swipe-panel" :class="{ active: view === 'card' }">
|
<div class="swipe-panel" :class="{ active: view === 'card' }">
|
||||||
<div class="panel-scroll">
|
<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 #}
|
{# Strokes scorecard #}
|
||||||
<table class="scorecard" x-show="cardView === 'strokes'">
|
<table class="scorecard" x-show="cardView === 'strokes'">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -135,8 +133,6 @@
|
|||||||
{{ p.avatar or '👤' }}
|
{{ p.avatar or '👤' }}
|
||||||
{% endif %}<br>
|
{% endif %}<br>
|
||||||
<a href="/users/{{ p.id }}" style="font-size:0.7rem; color:inherit; text-decoration:none;">{{ p.name.split()[0] }}</a>
|
<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>
|
</th>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -147,9 +143,11 @@
|
|||||||
<td class="hole-num">{{ h }}</td>
|
<td class="hole-num">{{ h }}</td>
|
||||||
{% if hole_pars %}<td class="score-cell" style="color:var(--text-muted);">{{ hole_pars.get(h, '—') }}</td>{% endif %}
|
{% if hole_pars %}<td class="score-cell" style="color:var(--text-muted);">{{ hole_pars.get(h, '—') }}</td>{% endif %}
|
||||||
{% for p in players %}
|
{% for p in players %}
|
||||||
|
{% set sr = strokes_per_hole.get(p.id, {}).get(h, 0) %}
|
||||||
<td class="score-cell">
|
<td class="score-cell">
|
||||||
<span x-text="holeStrokesStr({{ h }}, '{{ p.id }}')"
|
<span x-text="holeStrokesStr({{ h }}, '{{ p.id }}')"
|
||||||
:class="holeScoreClass({{ h }}, '{{ p.id }}')"></span>
|
: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>
|
</td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -179,8 +177,6 @@
|
|||||||
{{ p.avatar or '👤' }}
|
{{ p.avatar or '👤' }}
|
||||||
{% endif %}<br>
|
{% endif %}<br>
|
||||||
<a href="/users/{{ p.id }}" style="font-size:0.7rem; color:inherit; text-decoration:none;">{{ p.name.split()[0] }}</a>
|
<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>
|
</th>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -232,9 +228,11 @@
|
|||||||
<td class="hole-num">{{ h }}</td>
|
<td class="hole-num">{{ h }}</td>
|
||||||
{% if hole_pars %}<td class="score-cell" style="color:var(--text-muted);">{{ hole_pars.get(h, '—') }}</td>{% endif %}
|
{% if hole_pars %}<td class="score-cell" style="color:var(--text-muted);">{{ hole_pars.get(h, '—') }}</td>{% endif %}
|
||||||
{% for p in players %}
|
{% for p in players %}
|
||||||
|
{% set sr = strokes_per_hole.get(p.id, {}).get(h, 0) %}
|
||||||
<td class="score-cell">
|
<td class="score-cell">
|
||||||
<span x-text="holeStrokesStr({{ h }}, '{{ p.id }}')"
|
<span x-text="holeStrokesStr({{ h }}, '{{ p.id }}')"
|
||||||
:class="[holeScoreClass({{ h }}, '{{ p.id }}'), isSkinWinner({{ h }}, '{{ p.id }}') ? 'skin-winner' : '']"></span>
|
: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>
|
</td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<td class="score-cell" x-text="skinHoleLabel({{ h }})"></td>
|
<td class="score-cell" x-text="skinHoleLabel({{ h }})"></td>
|
||||||
@@ -282,6 +280,8 @@ const GAME_FORMAT = "{{ game.format }}";
|
|||||||
const SKINS_CARRYOVER = {{ carryover | tojson }};
|
const SKINS_CARRYOVER = {{ carryover | tojson }};
|
||||||
const HOLE_PARS = {{ hole_pars | tojson }};
|
const HOLE_PARS = {{ hole_pars | tojson }};
|
||||||
const HOLE_SIS = {{ hole_stroke_indices | 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 }};
|
const PLAYERS_DATA = {{ players | tojson }};
|
||||||
|
|
||||||
// Initial scores from server
|
// Initial scores from server
|
||||||
@@ -297,6 +297,7 @@ function gameApp() {
|
|||||||
scores: {}, // { hole: { uid: {strokes,bingo,bango,bongo} } }
|
scores: {}, // { hole: { uid: {strokes,bingo,bango,bongo} } }
|
||||||
totals: {},
|
totals: {},
|
||||||
skipped: new Set(),
|
skipped: new Set(),
|
||||||
|
dirty: new Set(),
|
||||||
ws: null,
|
ws: null,
|
||||||
touchStartX: 0,
|
touchStartX: 0,
|
||||||
dragSrc: null,
|
dragSrc: null,
|
||||||
@@ -350,12 +351,8 @@ function gameApp() {
|
|||||||
adjustStrokes(hole, uid, delta) {
|
adjustStrokes(hole, uid, delta) {
|
||||||
this._ensurePlayer(hole, uid);
|
this._ensurePlayer(hole, uid);
|
||||||
const cur = this.scores[hole][uid].strokes;
|
const cur = this.scores[hole][uid].strokes;
|
||||||
if (!cur) {
|
this.scores[hole][uid].strokes = cur ? Math.max(1, cur + delta) : (HOLE_PARS[hole] || 4);
|
||||||
// First press: start at par for this hole
|
this.dirty.add(hole);
|
||||||
this.scores[hole][uid].strokes = HOLE_PARS[hole] || 4;
|
|
||||||
} else {
|
|
||||||
this.scores[hole][uid].strokes = Math.max(1, cur + delta);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
setBingo(hole, uid) {
|
setBingo(hole, uid) {
|
||||||
@@ -363,6 +360,7 @@ function gameApp() {
|
|||||||
const current = this.scores[hole][uid].bingo;
|
const current = this.scores[hole][uid].bingo;
|
||||||
for (const p in this.scores[hole]) this.scores[hole][p].bingo = 0;
|
for (const p in this.scores[hole]) this.scores[hole][p].bingo = 0;
|
||||||
this.scores[hole][uid].bingo = current ? 0 : 1;
|
this.scores[hole][uid].bingo = current ? 0 : 1;
|
||||||
|
this.dirty.add(hole);
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleBango(hole, uid) {
|
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 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;
|
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;
|
for (const p in this.scores[hole]) this.scores[hole][p].bango = winners.includes(p) ? share : 0;
|
||||||
|
this.dirty.add(hole);
|
||||||
},
|
},
|
||||||
|
|
||||||
setBongo(hole, uid) {
|
setBongo(hole, uid) {
|
||||||
@@ -379,9 +378,12 @@ function gameApp() {
|
|||||||
const current = this.scores[hole][uid].bongo;
|
const current = this.scores[hole][uid].bongo;
|
||||||
for (const p in this.scores[hole]) this.scores[hole][p].bongo = 0;
|
for (const p in this.scores[hole]) this.scores[hole][p].bongo = 0;
|
||||||
this.scores[hole][uid].bongo = current ? 0 : 1;
|
this.scores[hole][uid].bongo = current ? 0 : 1;
|
||||||
|
this.dirty.add(hole);
|
||||||
},
|
},
|
||||||
|
|
||||||
saveHole(hole) {
|
saveHole(hole) {
|
||||||
|
if (!this.dirty.has(hole)) return;
|
||||||
|
this.dirty.delete(hole);
|
||||||
const holeScores = this.scores[hole] ?? {};
|
const holeScores = this.scores[hole] ?? {};
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('hole', hole);
|
params.set('hole', hole);
|
||||||
@@ -525,10 +527,12 @@ function gameApp() {
|
|||||||
if (!this.touchStartX) return;
|
if (!this.touchStartX) return;
|
||||||
const dx = e.changedTouches[0].screenX - this.touchStartX;
|
const dx = e.changedTouches[0].screenX - this.touchStartX;
|
||||||
this.touchStartX = 0;
|
this.touchStartX = 0;
|
||||||
if (Math.abs(dx) > 60) {
|
if (Math.abs(dx) < 60) return;
|
||||||
const next = dx < 0 ? 'card' : 'score';
|
if (this.view === 'score') {
|
||||||
if (next === 'card' && this.view === 'score') this.saveHole(this.currentHole);
|
if (dx < 0) this.nextHole(); else this.prevHole();
|
||||||
this.view = next;
|
} else {
|
||||||
|
const secondary = GAME_FORMAT === 'skins' ? 'skins' : 'points';
|
||||||
|
this.cardView = dx < 0 ? secondary : 'strokes';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -58,6 +58,15 @@
|
|||||||
const id = this.selectedTees[uid];
|
const id = this.selectedTees[uid];
|
||||||
return id ? (TEES_BY_COURSE[this.courseId] || []).find(t => t.id === id) : null;
|
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; },
|
goToConfirm() { this.step = 3; },
|
||||||
backFromConfirm() { this.step = this.hasTees() ? 2 : 1; },
|
backFromConfirm() { this.step = this.hasTees() ? 2 : 1; },
|
||||||
formatLabel() {
|
formatLabel() {
|
||||||
@@ -195,8 +204,10 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" x-show="hasTees()" class="btn btn-primary" @click="step = 2">Next →</button>
|
<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="goToConfirm()">Next →</button>
|
<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>
|
</div>
|
||||||
|
|
||||||
{# ── Step 2: Tee selection ── #}
|
{# ── Step 2: Tee selection ── #}
|
||||||
@@ -274,10 +285,13 @@
|
|||||||
<div style="flex:1; min-width:0;">
|
<div style="flex:1; min-width:0;">
|
||||||
<div style="font-weight:500;" x-text="getPlayer(uid).name"></div>
|
<div style="font-weight:500;" x-text="getPlayer(uid).name"></div>
|
||||||
<div style="font-size:0.8rem; color:var(--text-muted);">
|
<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)">
|
<template x-if="getTee(uid)">
|
||||||
<span x-text="' · ' + getTee(uid).name + ' (Slope\u00a0' + getTee(uid).slope + ' · Rating\u00a0' + getTee(uid).rating + ')'"></span>
|
<span x-text="' · ' + getTee(uid).name + ' (Slope\u00a0' + getTee(uid).slope + ' · Rating\u00a0' + getTee(uid).rating + ')'"></span>
|
||||||
</template>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -49,32 +49,28 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Playing handicaps #}
|
{# 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 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>
|
<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 %}
|
{% 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;">
|
<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>
|
<span>
|
||||||
{% if p.avatar and p.avatar.startswith('/') %}
|
{% 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;">
|
<img src="{{ p.avatar }}" style="width:1.3rem;height:1.3rem;border-radius:50%;object-fit:cover;vertical-align:middle;">
|
||||||
{% else %}
|
{% else %}{{ p.avatar or '👤' }}{% endif %}
|
||||||
{{ p.avatar or '👤' }}
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
</span>
|
||||||
<span style="flex:1; font-weight:500;">{{ p.name }}</span>
|
<span style="flex:1; font-weight:500;">{{ p.name }}</span>
|
||||||
{% if p.handicap_snapshot is not none %}
|
{% if p.handicap_snapshot is not none %}
|
||||||
<span style="color:var(--text-muted);">HI {{ p.handicap_snapshot }}</span>
|
<span style="color:var(--text-muted);">HI {{ p.handicap_snapshot }}</span>
|
||||||
{% endif %}
|
{% 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 {{ ph }}</span>
|
<span style="font-weight:600; color:var(--accent); min-width:3rem; text-align:right;">HC {{ ph }}</span>
|
||||||
{% else %}
|
|
||||||
<span style="color:var(--text-muted); min-width:3rem; text-align:right;">HC —</span>
|
|
||||||
{% endif %}
|
|
||||||
{% if p.tee_name %}
|
{% if p.tee_name %}
|
||||||
<span style="color:var(--text-muted); font-size:0.8rem; min-width:4rem; text-align:right;">{{ p.tee_name }}</span>
|
<span style="color:var(--text-muted); font-size:0.8rem; min-width:4rem; text-align:right;">{{ p.tee_name }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -121,26 +117,15 @@
|
|||||||
{% if strokes %}
|
{% if strokes %}
|
||||||
{% if par %}
|
{% if par %}
|
||||||
{% set diff = strokes - par %}
|
{% set diff = strokes - par %}
|
||||||
{% if strokes == 1 %}
|
{% if strokes == 1 %}<span class="score-hio">{{ strokes }}</span>
|
||||||
<span class="score-hio">{{ strokes }}</span>
|
{% elif diff <= -2 %}<span class="score-eagle">{{ strokes }}</span>
|
||||||
{% elif diff <= -2 %}
|
{% elif diff == -1 %}<span class="score-birdie">{{ strokes }}</span>
|
||||||
<span class="score-eagle">{{ strokes }}</span>
|
{% elif diff == 1 %}<span class="score-bogey">{{ strokes }}</span>
|
||||||
{% elif diff == -1 %}
|
{% elif diff >= 2 %}<span class="score-double-bogey">{{ strokes }}</span>
|
||||||
<span class="score-birdie">{{ strokes }}</span>
|
{% else %}{{ strokes }}{% endif %}
|
||||||
{% elif diff == 1 %}
|
{% else %}{{ strokes }}{% endif %}
|
||||||
<span class="score-bogey">{{ strokes }}</span>
|
{% else %}—{% endif %}
|
||||||
{% elif diff >= 2 %}
|
{% if has_stroke_index and sr > 0 %}<sup style="color:var(--accent); font-size:0.6em; line-height:0;">{{ '/' * sr }}</sup>{% endif %}
|
||||||
<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 %}
|
|
||||||
</td>
|
</td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -159,12 +144,14 @@
|
|||||||
<td>{{ stroke_total.val or '—' }}</td>
|
<td>{{ stroke_total.val or '—' }}</td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
{% if playing_handicaps %}
|
{% set any_hc = playing_handicaps and playing_handicaps.values() | select | list %}
|
||||||
|
{% if any_hc %}
|
||||||
<tr>
|
<tr>
|
||||||
<td style="color:var(--text-muted); font-size:0.8rem;">HC</td>
|
<td style="color:var(--text-muted); font-size:0.8rem;">HC</td>
|
||||||
{% if hole_pars %}<td></td>{% endif %}
|
{% if hole_pars %}<td></td>{% endif %}
|
||||||
{% for p in players %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% 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 %}<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 %}
|
{% 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>
|
</td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<td class="score-cell">
|
<td class="score-cell">
|
||||||
|
|||||||
Reference in New Issue
Block a user