Add Skins as a side game for Bingo Bango Bongo

Players can now opt into a Skins side game when creating a BBB round.
The side game uses the same skins settings (carryover, net/gross, skin
value, double back nine). The scorecard gains a third Skins tab during
play and on the summary; the BBB leaderboard continues to rank by BBB
points.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Rolf
2026-03-23 22:09:16 +01:00
parent c298c473f4
commit 5432734240
5 changed files with 56 additions and 18 deletions
+1
View File
@@ -127,6 +127,7 @@ async def init_db():
"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",
"ALTER TABLE games ADD COLUMN side_skins_enabled INTEGER NOT NULL DEFAULT 0",
]:
try:
await db.execute(migration)
+9 -3
View File
@@ -284,10 +284,11 @@ async def create_game(request: Request, holes_count: int = Form(...),
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
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) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(game_id, format, holes_count, "active", user["id"], now, course_id, carryover, skin_value, skin_double_back_nine, skins_net),
"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),
)
# Pre-fetch course holes for handicap calculation
@@ -389,11 +390,14 @@ async def game_summary(game_id: str, request: Request, user: dict = Depends(get_
skin_value = game["skin_value"]
skin_double_back9 = bool(game["skin_double_back_nine"])
skins_net = bool(game["skins_net"])
if game["format"] == "skins":
side_skins_enabled = bool(game.get("side_skins_enabled", 0))
if game["format"] == "skins" or side_skins_enabled:
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, net_sph)
if game["format"] == "skins":
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)
@@ -408,6 +412,7 @@ async def game_summary(game_id: str, request: Request, user: dict = Depends(get_
"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, "skins_net": skins_net,
"side_skins_enabled": side_skins_enabled,
"carryover": bool(game["carryover"]),
})
@@ -449,6 +454,7 @@ async def game_page(game_id: str, request: Request, user: dict = Depends(get_cur
"skin_value": game["skin_value"],
"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)),
})
+22 -6
View File
@@ -11,10 +11,10 @@
{# ── Header ── #}
<div class="game-header">
<div style="display:flex; align-items:center; gap:0.75rem;">
<button @click="view === 'score' ? prevHole() : cardView = 'strokes'" class="icon-btn"
<button @click="view === 'score' ? prevHole() : cardPrev()" class="icon-btn"
:disabled="view === 'score' ? currentHole <= 1 : cardView === 'strokes'"></button>
<div style="text-align:center; flex:1;">
<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' : cardView === 'points' ? 'Points' : 'Skins')"></div>
<div style="font-size:0.72rem; color:var(--text-muted); margin-top:0.1rem;">
<template x-if="view === 'score'">
<span x-text="(HOLE_PARS[currentHole] ? 'Par ' + HOLE_PARS[currentHole] : '') + (HOLE_SIS[currentHole] ? ' · SI ' + HOLE_SIS[currentHole] : '')"></span>
@@ -24,8 +24,8 @@
</template>
</div>
</div>
<button @click="view === 'score' ? nextHole() : cardView = (GAME_FORMAT === 'skins' ? 'skins' : 'points')" class="icon-btn"
:disabled="view === 'score' ? currentHole >= {{ game.holes_count }} : cardView !== 'strokes'"></button>
<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>
</div>
</div>
@@ -281,6 +281,7 @@ 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 SIDE_SKINS_ENABLED = {{ side_skins_enabled | tojson }};
const HOLE_PARS = {{ hole_pars | tojson }};
const HOLE_SIS = {{ hole_stroke_indices | tojson }};
const STROKES_PER_HOLE = {{ strokes_per_hole | tojson }};
@@ -457,6 +458,22 @@ function gameApp() {
prevHole() { if (this.currentHole > 1) { this.saveHole(this.currentHole); this.currentHole--; } },
nextHole() { if (this.currentHole < GAME_HOLES) { this.saveHole(this.currentHole); this.currentHole++; } },
cardTabs() {
if (GAME_FORMAT === 'skins') return ['strokes', 'skins'];
if (SIDE_SKINS_ENABLED) return ['strokes', 'points', 'skins'];
return ['strokes', 'points'];
},
cardNext() {
const tabs = this.cardTabs();
const idx = tabs.indexOf(this.cardView);
if (idx < tabs.length - 1) this.cardView = tabs[idx + 1];
},
cardPrev() {
const tabs = this.cardTabs();
const idx = tabs.indexOf(this.cardView);
if (idx > 0) this.cardView = tabs[idx - 1];
},
fmt(v) {
return Number.isInteger(v) || v % 1 === 0 ? v.toFixed(0) : v.toFixed(1);
},
@@ -548,8 +565,7 @@ function gameApp() {
if (this.view === 'score') {
if (dx < 0) this.nextHole(); else this.prevHole();
} else {
const secondary = GAME_FORMAT === 'skins' ? 'skins' : 'points';
this.cardView = dx < 0 ? secondary : 'strokes';
if (dx < 0) this.cardNext(); else this.cardPrev();
}
},
};
+19 -5
View File
@@ -31,6 +31,7 @@
skinValue: '',
skinDoubleBackNine: false,
skinsNet: false,
sideSkinsEnabled: false,
holesCount: '',
selectedPlayers: ['{{ user.id }}'],
selectedTees: {},
@@ -80,6 +81,7 @@
backFromConfirm() { this.step = this.hasTees() ? 2 : 1; },
formatLabel() {
if (this.format === 'skins') return 'Skins';
if (this.sideSkinsEnabled) return 'Bingo Bango Bongo + Skins';
return 'Bingo Bango Bongo';
}
}">
@@ -106,11 +108,18 @@
</label>
<label class="radio-card" style="flex:1;">
<input type="radio" name="format" value="skins"
@change="format = $event.target.value">
@change="format = $event.target.value; sideSkinsEnabled = false">
<span>Skins</span>
</label>
</div>
<div x-show="format === 'skins'" style="margin-top:0.75rem; display:flex; flex-direction:column; gap:0.5rem;">
<div x-show="format === 'bingo_bango_bongo'" style="margin-top:0.5rem;">
<label style="display:flex; align-items:center; gap:0.5rem; cursor:pointer;">
<input type="checkbox" style="width:auto;"
@change="sideSkinsEnabled = $event.target.checked">
<span style="font-weight:400; color:var(--text-muted);">Add Skins as a side game</span>
</label>
</div>
<div x-show="format === 'skins' || sideSkinsEnabled" 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;">
@@ -288,15 +297,19 @@
<span class="confirm-label">Format</span>
<span class="confirm-value" x-text="formatLabel()"></span>
</div>
<div class="confirm-row" x-show="format === 'skins'">
<div class="confirm-row" x-show="format === 'bingo_bango_bongo' && sideSkinsEnabled">
<span class="confirm-label">Side game</span>
<span class="confirm-value">Skins</span>
</div>
<div class="confirm-row" x-show="format === 'skins' || sideSkinsEnabled">
<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'">
<div class="confirm-row" x-show="format === 'skins' || sideSkinsEnabled">
<span class="confirm-label">Carryover</span>
<span class="confirm-value" x-text="carryoverEnabled ? 'Yes' : 'No'"></span>
</div>
<div class="confirm-row" x-show="format === 'skins' && skinValue">
<div class="confirm-row" x-show="(format === 'skins' || sideSkinsEnabled) && skinValue">
<span class="confirm-label">Skin value</span>
<span class="confirm-value" x-text="skinValue + (skinDoubleBackNine ? ' · doubles on back 9' : '')"></span>
</div>
@@ -354,6 +367,7 @@
<template x-for="pid in selectedPlayers" :key="pid">
<input type="hidden" name="players" :value="pid">
</template>
<input type="hidden" name="side_skins_enabled" :value="sideSkinsEnabled ? '1' : '0'">
</form>
</div>
+5 -4
View File
@@ -84,11 +84,12 @@
{# Scorecard toggle #}
<div class="scorecard-toggle" style="margin-top:1rem;">
<button @click="view = 'strokes'" :class="{ active: view === 'strokes' }">Strokes</button>
{% if game.format == 'skins' %}
<button @click="view = 'skins'" :class="{ active: view === 'skins' }">Skins</button>
{% else %}
{% if game.format != 'skins' %}
<button @click="view = 'points'" :class="{ active: view === 'points' }">Points</button>
{% endif %}
{% if game.format == 'skins' or side_skins_enabled %}
<button @click="view = 'skins'" :class="{ active: view === 'skins' }">Skins</button>
{% endif %}
</div>
{# Strokes scorecard #}
@@ -204,7 +205,7 @@
</table>
{# Skins scorecard #}
{% if game.format == 'skins' %}
{% if game.format == 'skins' or side_skins_enabled %}
<table class="scorecard" x-show="view === 'skins'">
<thead>
<tr>