diff --git a/app/database.py b/app/database.py index 4129917..8e19abf 100644 --- a/app/database.py +++ b/app/database.py @@ -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 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 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: await db.execute(migration) diff --git a/app/routers/courses.py b/app/routers/courses.py index 8e22a1d..1ee4f40 100644 --- a/app/routers/courses.py +++ b/app/routers/courses.py @@ -1,3 +1,4 @@ +import json import os import secrets from datetime import datetime, timezone @@ -43,7 +44,7 @@ async def _get_course_detail(cid: str, db: aiosqlite.Connection): ) as c: holes = [dict(r) for r in await c.fetchall()] 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,), ) as c: 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 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_sis: dict[int, int] = {} - for t in all_tees: + for t in tees_male: hole_data = t.get("holes") or [] if hole_data: for i, h in enumerate(hole_data[:holes_count], 1): @@ -220,15 +221,30 @@ async def import_course(api_id: int, request: Request, "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)), ) - for t in tees_male: - tname = (t.get("tee_name") or "").strip() - slope = t.get("slope_rating") - rating = t.get("course_rating") - if tname and slope and rating: - await db.execute( - "INSERT INTO course_tees (id, course_id, name, slope, rating) VALUES (?,?,?,?,?)", - (secrets.token_urlsafe(10), cid, tname, slope, rating), - ) + + 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() + slope = t.get("slope_rating") + rating = t.get("course_rating") + if tname and slope and rating: + await db.execute( + """INSERT INTO course_tees + (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() return RedirectResponse( f"/admin/courses/{cid}/edit?success=Course+imported+successfully.", status_code=302 @@ -339,11 +355,15 @@ async def delete_course(cid: str, user: dict = Depends(require_admin), @router.post("/admin/courses/{cid}/tees/new") async def add_tee(cid: str, name: str = 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), db: aiosqlite.Connection = Depends(get_db)): + gender = gender if gender in ("male", "female") else "male" await db.execute( - "INSERT INTO course_tees (id, course_id, name, slope, rating) VALUES (?,?,?,?,?)", - (secrets.token_urlsafe(10), cid, name.strip(), slope, rating), + "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, gender, front_slope, front_rating, back_slope, back_rating), ) await db.commit() 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") async def edit_tee(cid: str, tid: str, name: str = 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), db: aiosqlite.Connection = Depends(get_db)): + gender = gender if gender in ("male", "female") else "male" await db.execute( - "UPDATE course_tees SET name = ?, slope = ?, rating = ? WHERE id = ? AND course_id = ?", - (name.strip(), slope, rating, tid, cid), + "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, gender, front_slope, front_rating, back_slope, back_rating, tid, cid), ) await db.commit() return RedirectResponse(f"/admin/courses/{cid}/edit?success=Tee+updated.", status_code=302) diff --git a/app/routers/games.py b/app/routers/games.py index 0560873..b00a196 100644 --- a/app/routers/games.py +++ b/app/routers/games.py @@ -205,7 +205,7 @@ async def new_game_page(request: Request, user: dict = Depends(get_current_user) ) as c: courses = [dict(r) for r in await c.fetchall()] async with db.execute( - "SELECT id, course_id, name, slope, rating FROM course_tees ORDER BY course_id, name" + "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: 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] = {} for t in tee_rows: 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( @@ -263,13 +265,15 @@ async def create_game(request: Request, holes_count: int = Form(...), ) as c: all_users = [dict(r) for r in await c.fetchall()] async with db.execute( - "SELECT id, course_id, name, slope, rating FROM course_tees ORDER BY course_id, name" + "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: tee_rows = [dict(r) for r in await c.fetchall()] tees_by_course: dict[str, list] = {} for t in tee_rows: 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( "games/new_game.html", @@ -279,6 +283,8 @@ async def create_game(request: Request, holes_count: int = Form(...), game_id = secrets.token_urlsafe(10) 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 skin_value_str = (form.get("skin_value") or "").strip() 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 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 (?,?,?,?,?,?,?,?,?,?,?,?)", - (game_id, format, holes_count, "active", user["id"], now, course_id, carryover, skin_value, skin_double_back_nine, skins_net, side_skins_enabled), + "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, 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 - 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 hole_stroke_indices: dict[int, int] = {} if course_id: @@ -305,23 +311,20 @@ async def create_game(request: Request, holes_count: int = Form(...), (course_id,), ) as c: 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 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_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_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) + played_holes = list(range(start_hole, start_hole + holes_count)) + # Fallback formula for 9-of-18 without split ratings: compute 18-hole CH then halve. + # Use played-par×2 as estimated 18-hole par. + par_for_formula = course_par_played * 2 if nine_of_eighteen else course_par_played for uid in player_ids: async with db.execute("SELECT handicap FROM users WHERE id = ?", (uid,)) as c: @@ -329,24 +332,45 @@ async def create_game(request: Request, holes_count: int = Form(...), hc = row["handicap"] if row else 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 strokes_allocation_json = None - par_for_formula = course_par_played * 2 if nine_of_eighteen else course_par_played - # Dedicated 9-hole courses have 9-hole ratings: WHS uses HI÷2 in the formula. - # For 9-of-18 the full HI is used and the result halved instead. - nine_hole_dedicated = (holes_count == 9 and not nine_of_eighteen) - if hc is not None and tee_id and par_for_formula: - async with db.execute("SELECT slope, rating FROM course_tees WHERE id = ?", (tee_id,)) as c: - tee_row = await c.fetchone() - if tee_row: + if hc is not None and tee_row and par_for_formula: + if is_back_nine and tee_row["back_slope"] and tee_row["back_rating"]: + # Back 9 specific ratings available — use directly + ph = round(hc * (tee_row["back_slope"] / 113) + (tee_row["back_rating"] - course_par_played)) + playing_handicap = ph + elif nine_of_eighteen and not is_back_nine and tee_row["front_slope"] and tee_row["front_rating"]: + # Front 9 specific ratings available — use directly + 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 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 - 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) + + # Per-tee stroke indices take priority over course-level fallback + if tee_row["stroke_indices"]: + 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) await db.execute( "INSERT OR IGNORE INTO game_players (id, game_id, user_id, handicap_snapshot, tee_id, joined_at, playing_handicap, strokes_allocation) VALUES (?,?,?,?,?,?,?,?)", @@ -367,7 +391,8 @@ async def game_summary(game_id: str, request: Request, user: dict = Depends(get_ players = await _get_players(game_id, db) scores = await _get_scores(game_id, db) 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_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) scores = await _get_scores(game_id, db) 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_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"]), "skins_net": bool(game["skins_net"]), "side_skins_enabled": bool(game.get("side_skins_enabled", 0)), + "start_hole": start_hole, }) diff --git a/app/templates/admin/edit_course.html b/app/templates/admin/edit_course.html index 53b2d34..dc88ed2 100644 --- a/app/templates/admin/edit_course.html +++ b/app/templates/admin/edit_course.html @@ -83,8 +83,11 @@
{{ t.name }} + {% if t.gender %}{{ t.gender }}{% endif %} Slope {{ t.slope }} Rating {{ t.rating }} + {% if t.front_slope and t.front_rating %}· F {{ t.front_slope }}/{{ t.front_rating }}{% endif %} + {% if t.back_slope and t.back_rating %}B {{ t.back_slope }}/{{ t.back_rating }}{% endif %}
Name
+
+ + +
@@ -112,6 +122,24 @@ min="{{ '29' if course.holes == 9 else '60' }}" max="{{ '40' if course.holes == 9 else '80' }}" required style="padding:0.4rem 0.6rem;">
+ {% if course.holes == 18 %} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {% endif %}
+
+ + +
@@ -145,6 +180,24 @@ min="{{ '29' if course.holes == 9 else '60' }}" max="{{ '40' if course.holes == 9 else '80' }}" required style="padding:0.4rem 0.6rem;">
+ {% if course.holes == 18 %} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {% endif %}
diff --git a/app/templates/games/game.html b/app/templates/games/game.html index a3f3514..a46de61 100644 --- a/app/templates/games/game.html +++ b/app/templates/games/game.html @@ -14,7 +14,7 @@
-
+