Import gender tees + front/back split ratings; front/back/9 hole selection

Course import now fetches both male and female tees, each with their own
per-tee stroke indices extracted from the holes array. Front and back 9
slope/rating are stored per tee for more accurate WHS handicap calculation.

New game creation replaces the 9/18 radio with a course-aware selector:
18-hole courses offer 18 holes, Front 9, or Back 9; 9-hole courses auto-
select. start_hole is stored on the game and used throughout scoring,
handicap calculation, and scorecard rendering.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Rolf
2026-03-23 22:37:20 +01:00
parent 5432734240
commit 3e98f5ae54
6 changed files with 211 additions and 78 deletions
+7
View File
@@ -128,6 +128,13 @@ async def init_db():
"ALTER TABLE games ADD COLUMN skin_double_back_nine INTEGER NOT NULL DEFAULT 0", "ALTER TABLE games ADD COLUMN skin_double_back_nine INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE games ADD COLUMN skins_net INTEGER NOT NULL DEFAULT 0", "ALTER TABLE games ADD COLUMN skins_net INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE games ADD COLUMN side_skins_enabled INTEGER NOT NULL DEFAULT 0", "ALTER TABLE games ADD COLUMN side_skins_enabled INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE games ADD COLUMN start_hole INTEGER NOT NULL DEFAULT 1",
"ALTER TABLE course_tees ADD COLUMN gender TEXT",
"ALTER TABLE course_tees ADD COLUMN stroke_indices TEXT",
"ALTER TABLE course_tees ADD COLUMN front_slope INTEGER",
"ALTER TABLE course_tees ADD COLUMN front_rating REAL",
"ALTER TABLE course_tees ADD COLUMN back_slope INTEGER",
"ALTER TABLE course_tees ADD COLUMN back_rating REAL",
]: ]:
try: try:
await db.execute(migration) await db.execute(migration)
+34 -10
View File
@@ -1,3 +1,4 @@
import json
import os import os
import secrets import secrets
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -43,7 +44,7 @@ async def _get_course_detail(cid: str, db: aiosqlite.Connection):
) as c: ) as c:
holes = [dict(r) for r in await c.fetchall()] holes = [dict(r) for r in await c.fetchall()]
async with db.execute( async with db.execute(
"SELECT id, name, slope, rating FROM course_tees WHERE course_id = ? ORDER BY name", "SELECT id, name, slope, rating, gender, front_slope, front_rating, back_slope, back_rating FROM course_tees WHERE course_id = ? ORDER BY gender, name",
(cid,), (cid,),
) as c: ) as c:
tees = [dict(r) for r in await c.fetchall()] tees = [dict(r) for r in await c.fetchall()]
@@ -197,10 +198,10 @@ async def import_course(api_id: int, request: Request,
holes_count = n holes_count = n
break break
# Hole par + stroke index from first tee that has hole data # Hole par + course-level stroke index from first male tee with hole data (fallback for display)
hole_pars: dict[int, int] = {} hole_pars: dict[int, int] = {}
hole_sis: dict[int, int] = {} hole_sis: dict[int, int] = {}
for t in all_tees: for t in tees_male:
hole_data = t.get("holes") or [] hole_data = t.get("holes") or []
if hole_data: if hole_data:
for i, h in enumerate(hole_data[:holes_count], 1): for i, h in enumerate(hole_data[:holes_count], 1):
@@ -220,14 +221,29 @@ async def import_course(api_id: int, request: Request,
"INSERT INTO course_holes (id, course_id, hole_number, par, stroke_index) VALUES (?,?,?,?,?)", "INSERT INTO course_holes (id, course_id, hole_number, par, stroke_index) VALUES (?,?,?,?,?)",
(secrets.token_urlsafe(10), cid, i, hole_pars.get(i, 4), hole_sis.get(i)), (secrets.token_urlsafe(10), cid, i, hole_pars.get(i, 4), hole_sis.get(i)),
) )
for t in tees_male:
def _tee_stroke_indices(t: dict) -> str | None:
"""Extract per-hole stroke indices from a tee's holes array as JSON."""
si_map = {}
for i, h in enumerate((t.get("holes") or [])[:holes_count], 1):
if h.get("handicap"):
si_map[i] = h["handicap"]
return json.dumps(si_map) if si_map else None
for gender, tee_list in (("male", tees_male), ("female", tees_female)):
for t in tee_list:
tname = (t.get("tee_name") or "").strip() tname = (t.get("tee_name") or "").strip()
slope = t.get("slope_rating") slope = t.get("slope_rating")
rating = t.get("course_rating") rating = t.get("course_rating")
if tname and slope and rating: if tname and slope and rating:
await db.execute( await db.execute(
"INSERT INTO course_tees (id, course_id, name, slope, rating) VALUES (?,?,?,?,?)", """INSERT INTO course_tees
(secrets.token_urlsafe(10), cid, tname, slope, rating), (id, course_id, name, slope, rating, gender, stroke_indices,
front_slope, front_rating, back_slope, back_rating)
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
(secrets.token_urlsafe(10), cid, tname, slope, rating, gender, _tee_stroke_indices(t),
t.get("front_slope_rating"), t.get("front_course_rating"),
t.get("back_slope_rating"), t.get("back_course_rating")),
) )
await db.commit() await db.commit()
return RedirectResponse( return RedirectResponse(
@@ -339,11 +355,15 @@ async def delete_course(cid: str, user: dict = Depends(require_admin),
@router.post("/admin/courses/{cid}/tees/new") @router.post("/admin/courses/{cid}/tees/new")
async def add_tee(cid: str, name: str = Form(...), async def add_tee(cid: str, name: str = Form(...),
slope: int = Form(...), rating: float = Form(...), slope: int = Form(...), rating: float = Form(...),
gender: str = Form("male"),
front_slope: int | None = Form(None), front_rating: float | None = Form(None),
back_slope: int | None = Form(None), back_rating: float | None = Form(None),
user: dict = Depends(require_admin), user: dict = Depends(require_admin),
db: aiosqlite.Connection = Depends(get_db)): db: aiosqlite.Connection = Depends(get_db)):
gender = gender if gender in ("male", "female") else "male"
await db.execute( await db.execute(
"INSERT INTO course_tees (id, course_id, name, slope, rating) VALUES (?,?,?,?,?)", "INSERT INTO course_tees (id, course_id, name, slope, rating, gender, front_slope, front_rating, back_slope, back_rating) VALUES (?,?,?,?,?,?,?,?,?,?)",
(secrets.token_urlsafe(10), cid, name.strip(), slope, rating), (secrets.token_urlsafe(10), cid, name.strip(), slope, rating, gender, front_slope, front_rating, back_slope, back_rating),
) )
await db.commit() await db.commit()
return RedirectResponse(f"/admin/courses/{cid}/edit?success=Tee+added.", status_code=302) return RedirectResponse(f"/admin/courses/{cid}/edit?success=Tee+added.", status_code=302)
@@ -352,11 +372,15 @@ async def add_tee(cid: str, name: str = Form(...),
@router.post("/admin/courses/{cid}/tees/{tid}/edit") @router.post("/admin/courses/{cid}/tees/{tid}/edit")
async def edit_tee(cid: str, tid: str, name: str = Form(...), async def edit_tee(cid: str, tid: str, name: str = Form(...),
slope: int = Form(...), rating: float = Form(...), slope: int = Form(...), rating: float = Form(...),
gender: str = Form("male"),
front_slope: int | None = Form(None), front_rating: float | None = Form(None),
back_slope: int | None = Form(None), back_rating: float | None = Form(None),
user: dict = Depends(require_admin), user: dict = Depends(require_admin),
db: aiosqlite.Connection = Depends(get_db)): db: aiosqlite.Connection = Depends(get_db)):
gender = gender if gender in ("male", "female") else "male"
await db.execute( await db.execute(
"UPDATE course_tees SET name = ?, slope = ?, rating = ? WHERE id = ? AND course_id = ?", "UPDATE course_tees SET name = ?, slope = ?, rating = ?, gender = ?, front_slope = ?, front_rating = ?, back_slope = ?, back_rating = ? WHERE id = ? AND course_id = ?",
(name.strip(), slope, rating, tid, cid), (name.strip(), slope, rating, gender, front_slope, front_rating, back_slope, back_rating, tid, cid),
) )
await db.commit() await db.commit()
return RedirectResponse(f"/admin/courses/{cid}/edit?success=Tee+updated.", status_code=302) return RedirectResponse(f"/admin/courses/{cid}/edit?success=Tee+updated.", status_code=302)
+60 -33
View File
@@ -205,7 +205,7 @@ async def new_game_page(request: Request, user: dict = Depends(get_current_user)
) as c: ) 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, gender, front_slope, front_rating, back_slope, back_rating FROM course_tees ORDER BY course_id, gender, name"
) as c: ) as c:
tee_rows = [dict(r) for r in await c.fetchall()] tee_rows = [dict(r) for r in await c.fetchall()]
@@ -213,7 +213,9 @@ async def new_game_page(request: Request, user: dict = Depends(get_current_user)
tees_by_course: dict[str, list] = {} tees_by_course: dict[str, list] = {}
for t in tee_rows: for t in tee_rows:
tees_by_course.setdefault(t["course_id"], []).append( tees_by_course.setdefault(t["course_id"], []).append(
{"id": t["id"], "name": t["name"], "slope": t["slope"], "rating": t["rating"]} {"id": t["id"], "name": t["name"], "slope": t["slope"], "rating": t["rating"],
"gender": t["gender"], "front_slope": t["front_slope"], "front_rating": t["front_rating"],
"back_slope": t["back_slope"], "back_rating": t["back_rating"]}
) )
return templates.TemplateResponse( return templates.TemplateResponse(
@@ -263,13 +265,15 @@ async def create_game(request: Request, holes_count: int = Form(...),
) 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( 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, gender, front_slope, front_rating, back_slope, back_rating FROM course_tees ORDER BY course_id, gender, name"
) as c: ) as c:
tee_rows = [dict(r) for r in await c.fetchall()] tee_rows = [dict(r) for r in await c.fetchall()]
tees_by_course: dict[str, list] = {} tees_by_course: dict[str, list] = {}
for t in tee_rows: for t in tee_rows:
tees_by_course.setdefault(t["course_id"], []).append( tees_by_course.setdefault(t["course_id"], []).append(
{"id": t["id"], "name": t["name"], "slope": t["slope"], "rating": t["rating"]} {"id": t["id"], "name": t["name"], "slope": t["slope"], "rating": t["rating"],
"gender": t["gender"], "front_slope": t["front_slope"], "front_rating": t["front_rating"],
"back_slope": t["back_slope"], "back_rating": t["back_rating"]}
) )
return templates.TemplateResponse( return templates.TemplateResponse(
"games/new_game.html", "games/new_game.html",
@@ -279,6 +283,8 @@ async def create_game(request: Request, holes_count: int = Form(...),
game_id = secrets.token_urlsafe(10) game_id = secrets.token_urlsafe(10)
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
start_hole_raw = form.get("start_hole", "1")
start_hole = int(start_hole_raw) if start_hole_raw in ("1", "10") else 1
carryover = 1 if form.get("carryover") == "1" else 0 carryover = 1 if form.get("carryover") == "1" else 0
skin_value_str = (form.get("skin_value") or "").strip() skin_value_str = (form.get("skin_value") or "").strip()
skin_value = float(skin_value_str) if skin_value_str else None skin_value = float(skin_value_str) if skin_value_str else None
@@ -287,12 +293,12 @@ async def create_game(request: Request, holes_count: int = Form(...),
side_skins_enabled = 1 if form.get("side_skins_enabled") == "1" else 0 side_skins_enabled = 1 if form.get("side_skins_enabled") == "1" else 0
await db.execute( await db.execute(
"INSERT INTO games (id, format, holes_count, status, created_by, created_at, course_id, carryover, skin_value, skin_double_back_nine, skins_net, side_skins_enabled) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", "INSERT INTO games (id, format, holes_count, start_hole, status, created_by, created_at, course_id, carryover, skin_value, skin_double_back_nine, skins_net, side_skins_enabled) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
(game_id, format, holes_count, "active", user["id"], now, course_id, carryover, skin_value, skin_double_back_nine, skins_net, side_skins_enabled), (game_id, format, holes_count, start_hole, "active", user["id"], now, course_id, carryover, skin_value, skin_double_back_nine, skins_net, side_skins_enabled),
) )
# Pre-fetch course holes for handicap calculation # Pre-fetch course holes for handicap calculation
course_par_played = 0 # par for the holes being played course_par_played = 0 # par for the actual holes being played
course_total_holes = holes_count # assume matches unless we find otherwise course_total_holes = holes_count # assume matches unless we find otherwise
hole_stroke_indices: dict[int, int] = {} hole_stroke_indices: dict[int, int] = {}
if course_id: if course_id:
@@ -305,23 +311,20 @@ async def create_game(request: Request, holes_count: int = Form(...),
(course_id,), (course_id,),
) as c: ) as c:
for row in await c.fetchall(): for row in await c.fetchall():
if row["hole_number"] <= holes_count: h = row["hole_number"]
if start_hole <= h < start_hole + holes_count:
course_par_played += row["par"] or 0 course_par_played += row["par"] or 0
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[h] = row["stroke_index"]
# For a 9-hole game on an 18-hole course the tee ratings are 18-hole values.
# Per WHS: compute 18-hole course handicap using played-par×2 as the 18-hole par, then halve.
# Using played-par×2 avoids relying on back-nine holes being present in course_holes.
nine_of_eighteen = (holes_count == 9 and course_total_holes == 18) nine_of_eighteen = (holes_count == 9 and course_total_holes == 18)
nine_hole_dedicated = (holes_count == 9 and not nine_of_eighteen)
is_back_nine = nine_of_eighteen and start_hole == 10
played_holes = list(range(1, holes_count + 1)) played_holes = list(range(start_hole, start_hole + holes_count))
played_with_si = {h: hole_stroke_indices[h] for h in played_holes if h in hole_stroke_indices} # Fallback formula for 9-of-18 without split ratings: compute 18-hole CH then halve.
hole_rank: dict[int, int] = {} # Use played-par×2 as estimated 18-hole par.
if played_with_si: par_for_formula = course_par_played * 2 if nine_of_eighteen else course_par_played
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:
@@ -329,23 +332,44 @@ async def create_game(request: Request, holes_count: int = Form(...),
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
tee_row = None
if tee_id:
async with db.execute(
"SELECT slope, rating, stroke_indices, front_slope, front_rating, back_slope, back_rating FROM course_tees WHERE id = ?",
(tee_id,),
) as c:
tee_row = await c.fetchone()
playing_handicap = None playing_handicap = None
strokes_allocation_json = None strokes_allocation_json = None
par_for_formula = course_par_played * 2 if nine_of_eighteen else course_par_played if hc is not None and tee_row and par_for_formula:
# Dedicated 9-hole courses have 9-hole ratings: WHS uses HI÷2 in the formula. if is_back_nine and tee_row["back_slope"] and tee_row["back_rating"]:
# For 9-of-18 the full HI is used and the result halved instead. # Back 9 specific ratings available — use directly
nine_hole_dedicated = (holes_count == 9 and not nine_of_eighteen) ph = round(hc * (tee_row["back_slope"] / 113) + (tee_row["back_rating"] - course_par_played))
if hc is not None and tee_id and par_for_formula: playing_handicap = ph
async with db.execute("SELECT slope, rating FROM course_tees WHERE id = ?", (tee_id,)) as c: elif nine_of_eighteen and not is_back_nine and tee_row["front_slope"] and tee_row["front_rating"]:
tee_row = await c.fetchone() # Front 9 specific ratings available — use directly
if tee_row: ph = round(hc * (tee_row["front_slope"] / 113) + (tee_row["front_rating"] - course_par_played))
playing_handicap = ph
else:
hc_for_formula = hc / 2 if nine_hole_dedicated else hc hc_for_formula = hc / 2 if nine_hole_dedicated else hc
ph = round(hc_for_formula * (tee_row["slope"] / 113) + (tee_row["rating"] - par_for_formula)) ph = round(hc_for_formula * (tee_row["slope"] / 113) + (tee_row["rating"] - par_for_formula))
playing_handicap = round(ph / 2) if nine_of_eighteen else ph playing_handicap = round(ph / 2) if nine_of_eighteen else ph
if hole_rank and playing_handicap > 0:
base, extra = divmod(playing_handicap, n_si) # Per-tee stroke indices take priority over course-level fallback
allocation = {str(h): base + (1 if hole_rank[h] <= extra else 0) if tee_row["stroke_indices"]:
for h in played_holes if h in played_with_si} raw = json.loads(tee_row["stroke_indices"])
player_si = {int(k): v for k, v in raw.items()}
else:
player_si = hole_stroke_indices
played_with_si_p = {h: player_si[h] for h in played_holes if h in player_si}
if played_with_si_p and playing_handicap > 0:
n_si_p = len(played_with_si_p)
sorted_holes_p = sorted(played_with_si_p, key=lambda h: played_with_si_p[h])
hole_rank_p = {h: rank + 1 for rank, h in enumerate(sorted_holes_p)}
base, extra = divmod(playing_handicap, n_si_p)
allocation = {str(h): base + (1 if hole_rank_p[h] <= extra else 0)
for h in played_holes if h in played_with_si_p}
strokes_allocation_json = json.dumps(allocation) strokes_allocation_json = json.dumps(allocation)
await db.execute( await db.execute(
@@ -367,7 +391,8 @@ async def game_summary(game_id: str, request: Request, user: dict = Depends(get_
players = await _get_players(game_id, db) players = await _get_players(game_id, db)
scores = await _get_scores(game_id, db) scores = await _get_scores(game_id, db)
totals = _totals(players, scores) totals = _totals(players, scores)
holes = list(range(1, game["holes_count"] + 1)) start_hole = game["start_hole"] if game["start_hole"] else 1
holes = list(range(start_hole, start_hole + game["holes_count"]))
hole_pars: dict[int, int] = {} hole_pars: dict[int, int] = {}
hole_stroke_indices: dict[int, int] = {} hole_stroke_indices: dict[int, int] = {}
@@ -427,7 +452,8 @@ async def game_page(game_id: str, request: Request, user: dict = Depends(get_cur
players = await _get_players(game_id, db) players = await _get_players(game_id, db)
scores = await _get_scores(game_id, db) scores = await _get_scores(game_id, db)
totals = _totals(players, scores) totals = _totals(players, scores)
holes = list(range(1, game["holes_count"] + 1)) start_hole = game["start_hole"] if game["start_hole"] else 1
holes = list(range(start_hole, start_hole + game["holes_count"]))
hole_pars: dict[int, int] = {} hole_pars: dict[int, int] = {}
hole_stroke_indices: dict[int, int] = {} hole_stroke_indices: dict[int, int] = {}
@@ -455,6 +481,7 @@ async def game_page(game_id: str, request: Request, user: dict = Depends(get_cur
"skin_double_back9": bool(game["skin_double_back_nine"]), "skin_double_back9": bool(game["skin_double_back_nine"]),
"skins_net": bool(game["skins_net"]), "skins_net": bool(game["skins_net"]),
"side_skins_enabled": bool(game.get("side_skins_enabled", 0)), "side_skins_enabled": bool(game.get("side_skins_enabled", 0)),
"start_hole": start_hole,
}) })
+53
View File
@@ -83,8 +83,11 @@
<div x-show="!editing" style="display:flex; align-items:center; gap:0.75rem; flex-wrap:wrap;"> <div x-show="!editing" style="display:flex; align-items:center; gap:0.75rem; flex-wrap:wrap;">
<span style="font-weight:500; min-width:5rem;">{{ t.name }}</span> <span style="font-weight:500; min-width:5rem;">{{ t.name }}</span>
{% if t.gender %}<span style="font-size:0.75rem; padding:0.1rem 0.4rem; border-radius:0.25rem; background:var(--surface2); color:var(--text-muted);">{{ t.gender }}</span>{% endif %}
<span style="color:var(--text-muted); font-size:0.85rem;">Slope {{ t.slope }}</span> <span style="color:var(--text-muted); font-size:0.85rem;">Slope {{ t.slope }}</span>
<span style="color:var(--text-muted); font-size:0.85rem;">Rating {{ t.rating }}</span> <span style="color:var(--text-muted); font-size:0.85rem;">Rating {{ t.rating }}</span>
{% if t.front_slope and t.front_rating %}<span style="color:var(--text-muted); font-size:0.8rem;">· F {{ t.front_slope }}/{{ t.front_rating }}</span>{% endif %}
{% if t.back_slope and t.back_rating %}<span style="color:var(--text-muted); font-size:0.8rem;">B {{ t.back_slope }}/{{ t.back_rating }}</span>{% endif %}
<span style="margin-left:auto; display:flex; gap:0.4rem;"> <span style="margin-left:auto; display:flex; gap:0.4rem;">
<button type="button" @click="editing = true" class="btn-table-action">Edit</button> <button type="button" @click="editing = true" class="btn-table-action">Edit</button>
<form method="post" action="/admin/courses/{{ course.id }}/tees/{{ t.id }}/delete" <form method="post" action="/admin/courses/{{ course.id }}/tees/{{ t.id }}/delete"
@@ -102,6 +105,13 @@
<label style="font-size:0.8rem;">Name</label> <label style="font-size:0.8rem;">Name</label>
<input type="text" name="name" value="{{ t.name }}" required style="padding:0.4rem 0.6rem;"> <input type="text" name="name" value="{{ t.name }}" required style="padding:0.4rem 0.6rem;">
</div> </div>
<div class="form-group" style="margin:0; width:6.5rem;">
<label style="font-size:0.8rem;">Gender</label>
<select name="gender" style="padding:0.4rem 0.6rem; border-radius:0.4rem; border:1px solid var(--border); background:var(--surface); color:var(--text); font-size:0.9rem;">
<option value="male" {% if t.gender == 'male' or not t.gender %}selected{% endif %}>Male</option>
<option value="female" {% if t.gender == 'female' %}selected{% endif %}>Female</option>
</select>
</div>
<div class="form-group" style="margin:0; width:6rem;"> <div class="form-group" style="margin:0; width:6rem;">
<label style="font-size:0.8rem;">Slope</label> <label style="font-size:0.8rem;">Slope</label>
<input type="number" name="slope" value="{{ t.slope }}" min="55" max="155" required style="padding:0.4rem 0.6rem;"> <input type="number" name="slope" value="{{ t.slope }}" min="55" max="155" required style="padding:0.4rem 0.6rem;">
@@ -112,6 +122,24 @@
min="{{ '29' if course.holes == 9 else '60' }}" max="{{ '40' if course.holes == 9 else '80' }}" min="{{ '29' if course.holes == 9 else '60' }}" max="{{ '40' if course.holes == 9 else '80' }}"
required style="padding:0.4rem 0.6rem;"> required style="padding:0.4rem 0.6rem;">
</div> </div>
{% if course.holes == 18 %}
<div class="form-group" style="margin:0; width:5.5rem;">
<label style="font-size:0.8rem;">Front slope</label>
<input type="number" name="front_slope" value="{{ t.front_slope or '' }}" min="55" max="155" style="padding:0.4rem 0.6rem;">
</div>
<div class="form-group" style="margin:0; width:6rem;">
<label style="font-size:0.8rem;">Front rating</label>
<input type="number" step="0.1" name="front_rating" value="{{ t.front_rating or '' }}" min="29" max="45" style="padding:0.4rem 0.6rem;">
</div>
<div class="form-group" style="margin:0; width:5.5rem;">
<label style="font-size:0.8rem;">Back slope</label>
<input type="number" name="back_slope" value="{{ t.back_slope or '' }}" min="55" max="155" style="padding:0.4rem 0.6rem;">
</div>
<div class="form-group" style="margin:0; width:6rem;">
<label style="font-size:0.8rem;">Back rating</label>
<input type="number" step="0.1" name="back_rating" value="{{ t.back_rating or '' }}" min="29" max="45" style="padding:0.4rem 0.6rem;">
</div>
{% endif %}
<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>
<button type="button" @click="editing = false" <button type="button" @click="editing = false"
@@ -134,6 +162,13 @@
<label style="font-size:0.8rem;">Name</label> <label style="font-size:0.8rem;">Name</label>
<input type="text" name="name" placeholder="e.g. White" required style="padding:0.4rem 0.6rem;"> <input type="text" name="name" placeholder="e.g. White" required style="padding:0.4rem 0.6rem;">
</div> </div>
<div class="form-group" style="margin:0; width:6.5rem;">
<label style="font-size:0.8rem;">Gender</label>
<select name="gender" style="padding:0.4rem 0.6rem; border-radius:0.4rem; border:1px solid var(--border); background:var(--surface); color:var(--text); font-size:0.9rem;">
<option value="male">Male</option>
<option value="female">Female</option>
</select>
</div>
<div class="form-group" style="margin:0; width:6rem;"> <div class="form-group" style="margin:0; width:6rem;">
<label style="font-size:0.8rem;">Slope</label> <label style="font-size:0.8rem;">Slope</label>
<input type="number" name="slope" placeholder="113" min="55" max="155" required style="padding:0.4rem 0.6rem;"> <input type="number" name="slope" placeholder="113" min="55" max="155" required style="padding:0.4rem 0.6rem;">
@@ -145,6 +180,24 @@
min="{{ '29' if course.holes == 9 else '60' }}" max="{{ '40' if course.holes == 9 else '80' }}" min="{{ '29' if course.holes == 9 else '60' }}" max="{{ '40' if course.holes == 9 else '80' }}"
required style="padding:0.4rem 0.6rem;"> required style="padding:0.4rem 0.6rem;">
</div> </div>
{% if course.holes == 18 %}
<div class="form-group" style="margin:0; width:5.5rem;">
<label style="font-size:0.8rem;">Front slope</label>
<input type="number" name="front_slope" placeholder="—" min="55" max="155" style="padding:0.4rem 0.6rem;">
</div>
<div class="form-group" style="margin:0; width:6rem;">
<label style="font-size:0.8rem;">Front rating</label>
<input type="number" step="0.1" name="front_rating" placeholder="—" min="29" max="45" style="padding:0.4rem 0.6rem;">
</div>
<div class="form-group" style="margin:0; width:5.5rem;">
<label style="font-size:0.8rem;">Back slope</label>
<input type="number" name="back_slope" placeholder="—" min="55" max="155" style="padding:0.4rem 0.6rem;">
</div>
<div class="form-group" style="margin:0; width:6rem;">
<label style="font-size:0.8rem;">Back rating</label>
<input type="number" step="0.1" name="back_rating" placeholder="—" min="29" max="45" style="padding:0.4rem 0.6rem;">
</div>
{% endif %}
<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>
</div> </div>
+8 -7
View File
@@ -14,7 +14,7 @@
<button @click="view === 'score' ? prevHole() : cardPrev()" class="icon-btn" <button @click="view === 'score' ? prevHole() : cardPrev()" class="icon-btn"
:disabled="view === 'score' ? currentHole <= 1 : cardView === 'strokes'"></button> :disabled="view === 'score' ? currentHole <= 1 : cardView === 'strokes'"></button>
<div style="text-align:center; flex:1;"> <div style="text-align:center; flex:1;">
<div class="hole-label" x-text="view === 'score' ? 'Hole ' + currentHole + ' / {{ game.holes_count }}' : (cardView === 'strokes' ? 'Strokes' : cardView === 'points' ? 'Points' : 'Skins')"></div> <div class="hole-label" x-text="view === 'score' ? 'Hole ' + currentHole + ' / ' + (START_HOLE + GAME_HOLES - 1) : (cardView === 'strokes' ? 'Strokes' : cardView === 'points' ? 'Points' : 'Skins')"></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_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>
@@ -25,7 +25,7 @@
</div> </div>
</div> </div>
<button @click="view === 'score' ? nextHole() : cardNext()" class="icon-btn" <button @click="view === 'score' ? nextHole() : cardNext()" class="icon-btn"
:disabled="view === 'score' ? currentHole >= {{ game.holes_count }} : cardView === (GAME_FORMAT === 'skins' || SIDE_SKINS_ENABLED ? 'skins' : 'points')"></button> :disabled="view === 'score' ? currentHole >= START_HOLE + GAME_HOLES - 1 : cardView === (GAME_FORMAT === 'skins' || SIDE_SKINS_ENABLED ? 'skins' : 'points')"></button>
</div> </div>
</div> </div>
@@ -282,6 +282,7 @@ const SKIN_VALUE = {{ skin_value | tojson }};
const SKIN_DOUBLE_BACK9 = {{ skin_double_back9 | tojson }}; const SKIN_DOUBLE_BACK9 = {{ skin_double_back9 | tojson }};
const SKINS_NET = {{ skins_net | tojson }}; const SKINS_NET = {{ skins_net | tojson }};
const SIDE_SKINS_ENABLED = {{ side_skins_enabled | tojson }}; const SIDE_SKINS_ENABLED = {{ side_skins_enabled | tojson }};
const START_HOLE = {{ start_hole | 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 STROKES_PER_HOLE = {{ strokes_per_hole | tojson }};
@@ -296,7 +297,7 @@ function gameApp() {
return { return {
view: 'score', view: 'score',
cardView: 'strokes', cardView: 'strokes',
currentHole: 1, currentHole: START_HOLE,
players: [], players: [],
scores: {}, // { hole: { uid: {strokes,bingo,bango,bongo} } } scores: {}, // { hole: { uid: {strokes,bingo,bango,bongo} } }
totals: {}, totals: {},
@@ -447,7 +448,7 @@ function gameApp() {
}, },
allHolesDone() { allHolesDone() {
for (let h = 1; h <= GAME_HOLES; h++) { for (let h = START_HOLE; h <= START_HOLE + GAME_HOLES - 1; h++) {
if (this.isSkipped(h)) continue; if (this.isSkipped(h)) continue;
const holeScores = this.scores[h]; const holeScores = this.scores[h];
if (!holeScores || Object.keys(holeScores).length === 0) return false; if (!holeScores || Object.keys(holeScores).length === 0) return false;
@@ -455,8 +456,8 @@ function gameApp() {
return true; return true;
}, },
prevHole() { if (this.currentHole > 1) { this.saveHole(this.currentHole); this.currentHole--; } }, prevHole() { if (this.currentHole > START_HOLE) { this.saveHole(this.currentHole); this.currentHole--; } },
nextHole() { if (this.currentHole < GAME_HOLES) { this.saveHole(this.currentHole); this.currentHole++; } }, nextHole() { if (this.currentHole < START_HOLE + GAME_HOLES - 1) { this.saveHole(this.currentHole); this.currentHole++; } },
cardTabs() { cardTabs() {
if (GAME_FORMAT === 'skins') return ['strokes', 'skins']; if (GAME_FORMAT === 'skins') return ['strokes', 'skins'];
@@ -508,7 +509,7 @@ function gameApp() {
const holes = {}; const holes = {};
let pot = 0; let pot = 0;
let moneyPot = 0; let moneyPot = 0;
for (let hole = 1; hole <= GAME_HOLES; hole++) { for (let hole = START_HOLE; hole <= START_HOLE + GAME_HOLES - 1; hole++) {
if (this.isSkipped(hole)) continue; if (this.isSkipped(hole)) continue;
const holeScores = this.scores[hole] || {}; const holeScores = this.scores[hole] || {};
const scored = PLAYERS_DATA const scored = PLAYERS_DATA
+42 -21
View File
@@ -32,7 +32,7 @@
skinDoubleBackNine: false, skinDoubleBackNine: false,
skinsNet: false, skinsNet: false,
sideSkinsEnabled: false, sideSkinsEnabled: false,
holesCount: '', holesMode: '',
selectedPlayers: ['{{ user.id }}'], selectedPlayers: ['{{ user.id }}'],
selectedTees: {}, selectedTees: {},
playerSearch: '', playerSearch: '',
@@ -49,6 +49,7 @@
this.courseLabel = c.name + (c.location ? ' · ' + c.location : '') + ' (' + c.holes + ' holes)'; this.courseLabel = c.name + (c.location ? ' · ' + c.location : '') + ' (' + c.holes + ' holes)';
this.courseOpen = false; this.courseOpen = false;
this.courseSearch = ''; this.courseSearch = '';
this.holesMode = c.holes === 9 ? 'all9' : '';
}, },
togglePlayer(id) { togglePlayer(id) {
const idx = this.selectedPlayers.indexOf(id); const idx = this.selectedPlayers.indexOf(id);
@@ -66,11 +67,19 @@
const player = this.getPlayer(uid); const player = this.getPlayer(uid);
const tee = this.getTee(uid); const tee = this.getTee(uid);
const course = COURSES_DATA.find(c => c.id === this.courseId); const course = COURSES_DATA.find(c => c.id === this.courseId);
if (!course || !tee || player.handicap == null) return null; if (!course || !tee || player.handicap == null || !this.holesMode) return null;
const nineOfEighteen = parseInt(this.holesCount) === 9 && course.holes === 18; const nineOfEighteen = this.holesMode === 'front9' || this.holesMode === 'back9';
const nineHoleDedicated = parseInt(this.holesCount) === 9 && course.holes === 9; const nineHoleDedicated = this.holesMode === 'all9';
// For 9-of-18: use par_9×2 as the 18-hole par, compute full CH then halve. if (this.holesMode === 'front9' && tee.front_slope && tee.front_rating) {
// For dedicated 9-hole: WHS formula uses HI÷2 directly with 9-hole ratings. if (!course.par_9) return null;
return Math.round(player.handicap * (tee.front_slope / 113) + (tee.front_rating - course.par_9));
}
if (this.holesMode === 'back9' && tee.back_slope && tee.back_rating) {
const parBack9 = course.par - course.par_9;
if (!parBack9) return null;
return Math.round(player.handicap * (tee.back_slope / 113) + (tee.back_rating - parBack9));
}
// Fallback: for 9-of-18 use par_9×2 as 18-hole par then halve; for dedicated 9-hole use HI÷2.
const par = nineOfEighteen ? course.par_9 * 2 : course.par; const par = nineOfEighteen ? course.par_9 * 2 : course.par;
if (!par) return null; if (!par) return null;
const hcForFormula = nineHoleDedicated ? player.handicap / 2 : player.handicap; const hcForFormula = nineHoleDedicated ? player.handicap / 2 : player.handicap;
@@ -83,6 +92,13 @@
if (this.format === 'skins') return 'Skins'; if (this.format === 'skins') return 'Skins';
if (this.sideSkinsEnabled) return 'Bingo Bango Bongo + Skins'; if (this.sideSkinsEnabled) return 'Bingo Bango Bongo + Skins';
return 'Bingo Bango Bongo'; return 'Bingo Bango Bongo';
},
holesModeLabel() {
if (this.holesMode === '18') return '18 holes';
if (this.holesMode === 'front9') return 'Front 9 (holes 19)';
if (this.holesMode === 'back9') return 'Back 9 (holes 1018)';
if (this.holesMode === 'all9') return '9 holes';
return '—';
} }
}"> }">
@@ -144,7 +160,7 @@
placeholder="—" placeholder="—"
style="width:5rem; padding:0.4rem 0.5rem; border-radius:0.4rem; border:1px solid var(--border); background:var(--surface2); color:var(--text); font-size:1rem; text-align:center;"> style="width:5rem; padding:0.4rem 0.5rem; border-radius:0.4rem; border:1px solid var(--border); background:var(--surface2); color:var(--text); font-size:1rem; text-align:center;">
</div> </div>
<div x-show="skinValue && holesCount == 18"> <div x-show="skinValue && holesMode === '18'">
<label style="display:flex; align-items:center; gap:0.5rem; cursor:pointer;"> <label style="display:flex; align-items:center; gap:0.5rem; cursor:pointer;">
<input type="checkbox" name="skin_double_back_nine" value="1" style="width:auto;" <input type="checkbox" name="skin_double_back_nine" value="1" style="width:auto;"
@change="skinDoubleBackNine = $event.target.checked"> @change="skinDoubleBackNine = $event.target.checked">
@@ -201,19 +217,22 @@
{% endif %} {% endif %}
</div> </div>
<div class="form-group"> {# ── Holes selection — only for 18-hole courses ── #}
<div class="form-group" x-show="courseId && (COURSES_DATA.find(c=>c.id===courseId)||{}).holes===18">
<label>Holes</label> <label>Holes</label>
<div style="display:flex; gap:0.75rem;"> <div style="display:flex; gap:0.75rem;">
<label class="radio-card"> <label class="radio-card" style="flex:1;">
<input type="radio" name="holes_count" value="9" required <input type="radio" name="_holes_mode" value="18" @change="holesMode = '18'">
@change="holesCount = $event.target.value">
<span>9 holes</span>
</label>
<label class="radio-card">
<input type="radio" name="holes_count" value="18"
@change="holesCount = $event.target.value">
<span>18 holes</span> <span>18 holes</span>
</label> </label>
<label class="radio-card" style="flex:1;">
<input type="radio" name="_holes_mode" value="front9" @change="holesMode = 'front9'">
<span>Front 9</span>
</label>
<label class="radio-card" style="flex:1;">
<input type="radio" name="_holes_mode" value="back9" @change="holesMode = 'back9'">
<span>Back 9</span>
</label>
</div> </div>
</div> </div>
@@ -249,8 +268,8 @@
</div> </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;"> <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="step = 2" :disabled="!holesMode">Next →</button>
<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="goToConfirm()" :disabled="!holesMode">Next →</button>
</div> </div>
</div> </div>
@@ -273,7 +292,7 @@
style="width:100%; padding:0.65rem 0.75rem; border-radius:0.5rem; border:1px solid var(--border); background:var(--surface); color:var(--text); font-size:1rem;"> style="width:100%; padding:0.65rem 0.75rem; border-radius:0.5rem; border:1px solid var(--border); background:var(--surface); color:var(--text); font-size:1rem;">
<option value="" disabled selected>Select tee…</option> <option value="" disabled selected>Select tee…</option>
<template x-for="t in tees()" :key="t.id"> <template x-for="t in tees()" :key="t.id">
<option :value="t.id" x-text="t.name + ' (Slope ' + t.slope + ' · Rating ' + t.rating + ')'"></option> <option :value="t.id" x-text="(t.gender ? t.gender.charAt(0).toUpperCase() + t.gender.slice(1) + ' ' : '') + t.name + ' (Slope ' + t.slope + ' · Rating ' + t.rating + ')'"></option>
</template> </template>
</select> </select>
</div> </div>
@@ -319,7 +338,7 @@
</div> </div>
<div class="confirm-row"> <div class="confirm-row">
<span class="confirm-label">Holes</span> <span class="confirm-label">Holes</span>
<span class="confirm-value" x-text="holesCount ? holesCount + ' holes' : '—'"></span> <span class="confirm-value" x-text="holesModeLabel()"></span>
</div> </div>
</div> </div>
@@ -343,7 +362,7 @@
<div style="font-size:0.8rem; color:var(--text-muted);"> <div style="font-size:0.8rem; color:var(--text-muted);">
<span x-text="'HI\u00a0' + (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).gender ? getTee(uid).gender.charAt(0).toUpperCase() + getTee(uid).gender.slice(1) + ' ' : '') + getTee(uid).name + ' (Slope\u00a0' + getTee(uid).slope + ' · Rating\u00a0' + getTee(uid).rating + ')'"></span>
</template> </template>
<template x-if="getCourseHandicap(uid) != null"> <template x-if="getCourseHandicap(uid) != null">
<span style="color:var(--accent); font-weight:600;" x-text="' · HC\u00a0' + getCourseHandicap(uid)"></span> <span style="color:var(--accent); font-weight:600;" x-text="' · HC\u00a0' + getCourseHandicap(uid)"></span>
@@ -367,6 +386,8 @@
<template x-for="pid in selectedPlayers" :key="pid"> <template x-for="pid in selectedPlayers" :key="pid">
<input type="hidden" name="players" :value="pid"> <input type="hidden" name="players" :value="pid">
</template> </template>
<input type="hidden" name="holes_count" :value="holesMode === '18' ? 18 : 9">
<input type="hidden" name="start_hole" :value="holesMode === 'back9' ? 10 : 1">
<input type="hidden" name="side_skins_enabled" :value="sideSkinsEnabled ? '1' : '0'"> <input type="hidden" name="side_skins_enabled" :value="sideSkinsEnabled ? '1' : '0'">
</form> </form>