Add straight up / net handicap option for skins games

When creating a skins game, players can now choose between straight up
(gross scores) or net scoring (strokes subtracted per hole based on
playing handicap and stroke index). Net mode is stored on the game and
applied in both the live scorecard and the summary.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Rolf
2026-03-23 21:16:10 +01:00
parent 6317422316
commit 2c0717948a
5 changed files with 44 additions and 10 deletions
+1
View File
@@ -126,6 +126,7 @@ async def init_db():
"ALTER TABLE magic_link_tokens ADD COLUMN code TEXT",
"ALTER TABLE games ADD COLUMN skin_value REAL",
"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",
]:
try:
await db.execute(migration)
+17 -7
View File
@@ -90,16 +90,22 @@ def _strokes_per_hole(players: list, holes: list, hole_stroke_indices: dict) ->
def _calc_skins(players: list, scores: dict, holes: list, carryover: bool = True,
skin_value: float | None = None, skin_double_back9: bool = False):
skin_value: float | None = None, skin_double_back9: bool = False,
strokes_per_hole: dict | None = None):
"""Returns (skins_per_player, hole_results, unclaimed_pot, money_per_player, unclaimed_money)
money_per_player is None when skin_value is not set."""
money_per_player is None when skin_value is not set.
strokes_per_hole is {uid: {hole: strokes}} for net scoring."""
skins = {p["id"]: 0 for p in players}
money = {p["id"]: 0.0 for p in players}
hole_results = {}
pot = 0
money_pot = 0.0
for hole in holes:
scored = {uid: s["strokes"] for uid, s in scores.get(hole, {}).items() if s.get("strokes")}
raw = {uid: s["strokes"] for uid, s in scores.get(hole, {}).items() if s.get("strokes")}
if strokes_per_hole:
scored = {uid: g - (strokes_per_hole.get(uid) or {}).get(hole, 0) for uid, g in raw.items()}
else:
scored = raw
if not scored:
continue
pot += 1
@@ -277,10 +283,11 @@ async def create_game(request: Request, holes_count: int = Form(...),
skin_value_str = (form.get("skin_value") or "").strip()
skin_value = float(skin_value_str) if skin_value_str else None
skin_double_back_nine = 1 if form.get("skin_double_back_nine") == "1" else 0
skins_net = 1 if form.get("skins_net") == "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) VALUES (?,?,?,?,?,?,?,?,?,?)",
(game_id, format, holes_count, "active", user["id"], now, course_id, carryover, skin_value, skin_double_back_nine),
"INSERT INTO games (id, format, holes_count, status, created_by, created_at, course_id, carryover, skin_value, skin_double_back_nine, skins_net) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(game_id, format, holes_count, "active", user["id"], now, course_id, carryover, skin_value, skin_double_back_nine, skins_net),
)
# Pre-fetch course holes for handicap calculation
@@ -378,10 +385,12 @@ async def game_summary(game_id: str, request: Request, user: dict = Depends(get_
skins_won, skins_hole_results, unclaimed, money_won, unclaimed_money = None, None, 0, None, 0.0
skin_value = game["skin_value"]
skin_double_back9 = bool(game["skin_double_back_nine"])
skins_net = bool(game["skins_net"])
if game["format"] == "skins":
carryover = bool(game["carryover"])
net_sph = strokes_per_hole if skins_net else None
skins_won, skins_hole_results, unclaimed, money_won, unclaimed_money = _calc_skins(
players, scores, holes, carryover, skin_value, skin_double_back9)
players, scores, holes, carryover, skin_value, skin_double_back9, net_sph)
results = sorted(players, key=lambda p: skins_won.get(p["id"], 0), reverse=True)
else:
results = sorted(players, key=lambda p: totals.get(p["id"], {}).get("total", 0), reverse=True)
@@ -395,7 +404,7 @@ async def game_summary(game_id: str, request: Request, user: dict = Depends(get_
"results": results,
"skins_won": skins_won, "skins_hole_results": skins_hole_results, "unclaimed": unclaimed,
"money_won": money_won, "unclaimed_money": unclaimed_money, "skin_value": skin_value,
"skin_double_back9": skin_double_back9,
"skin_double_back9": skin_double_back9, "skins_net": skins_net,
"carryover": bool(game["carryover"]),
})
@@ -436,6 +445,7 @@ async def game_page(game_id: str, request: Request, user: dict = Depends(get_cur
"carryover": bool(game["carryover"]),
"skin_value": game["skin_value"],
"skin_double_back9": bool(game["skin_double_back_nine"]),
"skins_net": bool(game["skins_net"]),
})
+7 -2
View File
@@ -280,6 +280,7 @@ const GAME_FORMAT = "{{ game.format }}";
const SKINS_CARRYOVER = {{ carryover | tojson }};
const SKIN_VALUE = {{ skin_value | tojson }};
const SKIN_DOUBLE_BACK9 = {{ skin_double_back9 | tojson }};
const SKINS_NET = {{ skins_net | tojson }};
const HOLE_PARS = {{ hole_pars | tojson }};
const HOLE_SIS = {{ hole_stroke_indices | tojson }};
const STROKES_PER_HOLE = {{ strokes_per_hole | tojson }};
@@ -494,8 +495,12 @@ function gameApp() {
if (this.isSkipped(hole)) continue;
const holeScores = this.scores[hole] || {};
const scored = PLAYERS_DATA
.map(p => ({ id: p.id, strokes: holeScores[p.id]?.strokes || 0 }))
.filter(x => x.strokes > 0);
.map(p => {
const gross = holeScores[p.id]?.strokes || 0;
const net = SKINS_NET ? gross - ((STROKES_PER_HOLE[p.id] || {})[hole] || 0) : gross;
return { id: p.id, strokes: net, gross };
})
.filter(x => x.gross > 0);
if (scored.length === 0) continue;
pot += 1;
if (SKIN_VALUE) moneyPot += SKIN_VALUE * (SKIN_DOUBLE_BACK9 && hole >= 10 ? 2 : 1);
+18
View File
@@ -30,6 +30,7 @@
carryoverEnabled: true,
skinValue: '',
skinDoubleBackNine: false,
skinsNet: false,
holesCount: '',
selectedPlayers: ['{{ user.id }}'],
selectedTees: {},
@@ -104,6 +105,19 @@
</label>
</div>
<div x-show="format === 'skins'" style="margin-top:0.75rem; display:flex; flex-direction:column; gap:0.5rem;">
<input type="hidden" name="skins_net" :value="skinsNet ? '1' : '0'">
<div style="display:flex; gap:0.75rem;">
<label class="radio-card" style="flex:1;">
<input type="radio" name="_skins_mode" value="0" checked
@change="skinsNet = false">
<span>Straight up</span>
</label>
<label class="radio-card" style="flex:1;">
<input type="radio" name="_skins_mode" value="1"
@change="skinsNet = true">
<span>Net (handicap)</span>
</label>
</div>
<label style="display:flex; align-items:center; gap:0.5rem; cursor:pointer;">
<input type="checkbox" name="carryover" value="1" checked style="width:auto;"
@change="carryoverEnabled = $event.target.checked">
@@ -268,6 +282,10 @@
<span class="confirm-label">Format</span>
<span class="confirm-value" x-text="formatLabel()"></span>
</div>
<div class="confirm-row" x-show="format === 'skins'">
<span class="confirm-label">Scoring</span>
<span class="confirm-value" x-text="skinsNet ? 'Net (handicap)' : 'Straight up'"></span>
</div>
<div class="confirm-row" x-show="format === 'skins'">
<span class="confirm-label">Carryover</span>
<span class="confirm-value" x-text="carryoverEnabled ? 'Yes' : 'No'"></span>
+1 -1
View File
@@ -7,7 +7,7 @@
<a href="javascript:history.back()" style="color:var(--text-muted); font-size:0.9rem;">← Back</a>
<div style="text-align:center;">
<div style="font-weight:600;">{{ game.course_name or 'Bingo Bango Bongo' }}</div>
<div style="font-size:0.8rem; color:var(--text-muted);">{{ game.holes_count }} holes · {{ game.created_at[:10] }}</div>
<div style="font-size:0.8rem; color:var(--text-muted);">{{ game.holes_count }} holes · {{ game.created_at[:10] }}{% if game.format == 'skins' and skins_net %} · net{% endif %}</div>
</div>
<div style="width:3rem;"></div>
</div>