Files
Rolf 0be6e908e6 Refactor and optimize: deduplicate, batch queries, fix bugs
- Remove unused DATABASE_PATH import from games.py
- Extract _new_game_context(db) helper to deduplicate GET/POST error path
- Extract _parse_hole_form() helper to deduplicate hole validation in create/edit course
- Batch N+1 player handicap and tee queries in create_game into two IN() queries
- Fix hole count import bug: use max() across all tees instead of first match
- Fix migration exception handling: re-raise unless "duplicate column name"
- Cache calcSkins() result in _skinsCache; invalidate on score/skip changes
- Collapse two identical Next buttons into one in new_game.html
- Fix stale skinDoubleBackNine checkbox: reset on mode change, use x-model

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:52:45 +01:00

395 lines
20 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}New Game Skins &amp; Pins{% endblock %}
{% block extra_head %}
<script>
const TEES_BY_COURSE = {{ tees_by_course | tojson }};
const COURSES_DATA = {{ courses | tojson }};
const ALL_PLAYERS = {{ all_users | tojson }};
</script>
<style>
.picker-item { padding:0.65rem 0.75rem; cursor:pointer; border-bottom:1px solid var(--border); }
.picker-item:last-child { border-bottom:none; }
.picker-item.selected { background:var(--surface2); }
.confirm-row { display:flex; justify-content:space-between; align-items:baseline; padding:0.45rem 0; border-bottom:1px solid var(--border); font-size:0.95rem; }
.confirm-row:last-child { border-bottom:none; }
.confirm-label { color:var(--text-muted); flex-shrink:0; margin-right:1rem; }
.confirm-value { font-weight:500; text-align:right; }
</style>
{% endblock %}
{% block content %}
<div class="page"
x-data="{
step: 1,
courseId: '',
courseLabel: '',
courseOpen: false,
courseSearch: '',
format: 'bingo_bango_bongo',
carryoverEnabled: true,
skinValue: '',
skinDoubleBackNine: false,
skinsNet: false,
sideSkinsEnabled: false,
holesMode: '',
selectedPlayers: ['{{ user.id }}'],
selectedTees: {},
playerSearch: '',
filteredCourses() {
const q = this.courseSearch.toLowerCase();
if (!q) return COURSES_DATA;
return COURSES_DATA.filter(c =>
c.name.toLowerCase().includes(q) ||
(c.location || '').toLowerCase().includes(q)
);
},
selectCourse(c) {
this.courseId = c.id;
this.courseLabel = c.name + (c.location ? ' · ' + c.location : '') + ' (' + c.holes + ' holes)';
this.courseOpen = false;
this.courseSearch = '';
this.holesMode = c.holes === 9 ? 'all9' : '';
},
togglePlayer(id) {
const idx = this.selectedPlayers.indexOf(id);
if (idx === -1) this.selectedPlayers.push(id);
else if (id !== '{{ user.id }}') this.selectedPlayers.splice(idx, 1);
},
hasTees() { return (TEES_BY_COURSE[this.courseId] || []).length > 0; },
tees() { return TEES_BY_COURSE[this.courseId] || []; },
getPlayer(uid) { return ALL_PLAYERS.find(p => p.id === uid) || {}; },
getTee(uid) {
const id = this.selectedTees[uid];
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 || !this.holesMode) return null;
const nineOfEighteen = this.holesMode === 'front9' || this.holesMode === 'back9';
const nineHoleDedicated = this.holesMode === 'all9';
if (this.holesMode === 'front9' && tee.front_slope && tee.front_rating) {
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;
if (!par) return null;
const hcForFormula = nineHoleDedicated ? player.handicap / 2 : player.handicap;
const ph = Math.round(hcForFormula * (tee.slope / 113) + (tee.rating - par));
return nineOfEighteen ? Math.round(ph / 2) : ph;
},
goToConfirm() { this.step = 3; },
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';
},
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 '—';
}
}">
<div class="page-header">
<h1>New Game</h1>
</div>
{% if error %}<div class="alert alert-error">{{ error }}</div>{% endif %}
<form method="post" action="/games/new">
{# ── Step 1: Format, course, holes, players ── #}
<div x-show="step === 1">
<div class="card">
<div class="form-group">
<label>Format</label>
<div style="display:flex; gap:0.75rem; flex-wrap:wrap;">
<label class="radio-card" style="flex:1;">
<input type="radio" name="format" value="bingo_bango_bongo" checked required
@change="format = $event.target.value">
<span>Bingo Bango Bongo</span>
</label>
<label class="radio-card" style="flex:1;">
<input type="radio" name="format" value="skins"
@change="format = $event.target.value; sideSkinsEnabled = false">
<span>Skins</span>
</label>
</div>
<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;">
<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">
<span style="font-weight:400; color:var(--text-muted);">Carryover (ties carry to next hole)</span>
</label>
<div style="display:flex; align-items:center; gap:0.75rem;">
<span style="font-weight:400; color:var(--text-muted); font-size:0.95rem;">Value per skin</span>
<input type="number" name="skin_value" x-model="skinValue" min="0" step="0.5"
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;">
</div>
<div x-show="skinValue && holesMode === '18'">
<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;"
x-model="skinDoubleBackNine">
<span style="font-weight:400; color:var(--text-muted);">Double value on back nine (holes 1018)</span>
</label>
</div>
</div>
</div>
{# ── Course picker ── #}
<div class="form-group" @click.outside="courseOpen = false; courseSearch = ''">
<label>Course</label>
{% if courses %}
<input type="hidden" name="course_id" :value="courseId">
<button type="button"
@click="courseOpen = !courseOpen; if (courseOpen) $nextTick(() => $refs.courseSearch && $refs.courseSearch.focus())"
style="width:100%; display:flex; align-items:center; gap:0.5rem; padding:0.65rem 0.75rem; border-radius:0.5rem; border:1px solid var(--border); background:var(--surface); color:var(--text); font-size:1rem; cursor:pointer; text-align:left;">
<span style="flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;"
:style="courseId ? '' : 'color:var(--text-muted)'"
x-text="courseLabel || 'Select a course…'"></span>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="flex-shrink:0; transition:transform 0.15s;"
:style="courseOpen ? 'transform:rotate(180deg)' : ''">
<path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div x-show="courseOpen"
style="border:1px solid var(--border); border-radius:0.5rem; background:var(--surface); margin-top:0.25rem; overflow:hidden; box-shadow:0 4px 16px rgba(0,0,0,0.15);">
<div style="padding:0.5rem; border-bottom:1px solid var(--border);">
<input type="text" x-model="courseSearch" x-ref="courseSearch"
placeholder="Search by name or location…"
@keydown.escape="courseOpen = false; courseSearch = ''"
style="width:100%; padding:0.5rem 0.65rem; border-radius:0.4rem; border:1px solid var(--border); background:var(--surface2); color:var(--text); font-size:0.95rem; box-sizing:border-box;">
</div>
<div style="max-height:14rem; overflow-y:auto; -webkit-overflow-scrolling:touch;">
<template x-for="c in filteredCourses()" :key="c.id">
<div class="picker-item" @click="selectCourse(c)" :class="courseId === c.id ? 'selected' : ''">
<div x-text="c.name" style="font-weight:500;"></div>
<div x-text="(c.location ? c.location + ' · ' : '') + c.holes + ' holes'"
style="font-size:0.8rem; color:var(--text-muted);"></div>
</div>
</template>
<div x-show="filteredCourses().length === 0"
style="padding:0.75rem; text-align:center; color:var(--text-muted); font-size:0.9rem;">
No courses found
</div>
</div>
</div>
{% else %}
<p style="color:var(--text-muted); font-size:0.9rem;">No courses available. Ask the admin to add courses first.</p>
<input type="hidden" name="course_id" value="">
{% endif %}
</div>
{# ── 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>
<div style="display:flex; gap:0.75rem;">
<label class="radio-card" style="flex:1;">
<input type="radio" name="_holes_mode" value="18" @change="holesMode = '18'">
<span>18 holes</span>
</label>
<label class="radio-card" style="flex:1;">
<input type="radio" name="_holes_mode" value="front9" @change="holesMode = 'front9'; skinDoubleBackNine = false">
<span>Front 9</span>
</label>
<label class="radio-card" style="flex:1;">
<input type="radio" name="_holes_mode" value="back9" @change="holesMode = 'back9'; skinDoubleBackNine = false">
<span>Back 9</span>
</label>
</div>
</div>
{# ── Player picker ── #}
<div class="form-group">
<label>Players</label>
<input type="text" x-model="playerSearch" placeholder="Filter players…"
style="width:100%; padding:0.5rem 0.65rem; border-radius:0.4rem; border:1px solid var(--border); background:var(--surface2); color:var(--text); font-size:0.95rem; margin-bottom:0.5rem; box-sizing:border-box;">
<div class="player-list">
{% for u in all_users %}
<label class="player-option"
data-name="{{ u.name | lower }}"
x-show="{% if u.id == user.id %}true{% else %}!playerSearch || $el.dataset.name.includes(playerSearch.toLowerCase()){% endif %}"
:class="selectedPlayers.includes('{{ u.id }}') ? 'selected' : ''">
<input type="checkbox" value="{{ u.id }}"
{% if u.id == user.id %}checked disabled{% endif %}
@change="togglePlayer('{{ u.id }}')"
:checked="selectedPlayers.includes('{{ u.id }}')">
<span class="player-avatar">
{% if u.avatar and u.avatar.startswith('/') %}
<img src="{{ u.avatar }}" style="width:1.3rem;height:1.3rem;border-radius:50%;object-fit:cover;vertical-align:middle;">
{% else %}
{{ u.avatar or '👤' }}
{% endif %}
</span>
<span class="player-name">{{ u.name }}</span>
{% if u.id == user.id %}<span style="color:var(--text-muted); font-size:0.75rem;">you</span>{% endif %}
</label>
{% endfor %}
</div>
</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;">
<button type="button" class="btn btn-primary" @click="hasTees() ? step = 2 : goToConfirm()" :disabled="!holesMode">Next →</button>
</div>
</div>
{# ── Step 2: Tee selection ── #}
<div x-show="step === 2" style="display:none;">
<div class="card">
<h2 style="font-size:0.95rem; color:var(--text-muted); margin-bottom:1rem;">Select tees</h2>
{% for u in all_users %}
<div class="form-group" x-show="selectedPlayers.includes('{{ u.id }}')">
<label>
{% if u.avatar and u.avatar.startswith('/') %}
<img src="{{ u.avatar }}" style="width:1.1rem;height:1.1rem;border-radius:50%;object-fit:cover;vertical-align:middle;margin-right:0.3rem;">
{% else %}
{{ u.avatar or '👤' }}
{% endif %}
{{ u.name }}
</label>
<select name="tee_{{ u.id }}"
@change="selectedTees['{{ u.id }}'] = $event.target.value"
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>
<template x-for="t in tees()" :key="t.id">
<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>
</select>
</div>
{% endfor %}
</div>
<div style="display:flex; gap:0.75rem;">
<button type="button" @click="step = 1"
style="flex:1; padding:0.85rem; border-radius:0.65rem; border:1px solid var(--border); background:var(--surface2); color:var(--text); font-size:1rem; cursor:pointer;">← Back</button>
<button type="button" class="btn btn-primary" style="flex:2;" @click="goToConfirm()">Next →</button>
</div>
</div>
{# ── Step 3: Confirmation ── #}
<div x-show="step === 3" style="display:none;">
<div class="card">
<h2 style="font-size:0.95rem; color:var(--text-muted); margin-bottom:0.75rem;">Game details</h2>
<div class="confirm-row">
<span class="confirm-label">Format</span>
<span class="confirm-value" x-text="formatLabel()"></span>
</div>
<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' || 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' || sideSkinsEnabled) && skinValue">
<span class="confirm-label">Skin value</span>
<span class="confirm-value" x-text="skinValue + (skinDoubleBackNine ? ' · doubles on back 9' : '')"></span>
</div>
<div class="confirm-row">
<span class="confirm-label">Course</span>
<span class="confirm-value" x-text="courseLabel || '—'"></span>
</div>
<div class="confirm-row">
<span class="confirm-label">Holes</span>
<span class="confirm-value" x-text="holesModeLabel()"></span>
</div>
</div>
<div class="card" style="margin-top:1rem;">
<h2 style="font-size:0.95rem; color:var(--text-muted); margin-bottom:0.75rem;">Players</h2>
<template x-for="uid in selectedPlayers" :key="uid">
<div style="display:flex; align-items:center; gap:0.75rem; padding:0.6rem 0; border-bottom:1px solid var(--border);">
{# Avatar — rendered per player via Alpine #}
<template x-if="getPlayer(uid).avatar && getPlayer(uid).avatar.startsWith('/')">
<img :src="getPlayer(uid).avatar"
style="width:2rem;height:2rem;border-radius:50%;object-fit:cover;flex-shrink:0;">
</template>
<template x-if="!(getPlayer(uid).avatar && getPlayer(uid).avatar.startsWith('/'))">
<span style="font-size:1.4rem;flex-shrink:0;" x-text="getPlayer(uid).avatar || '👤'"></span>
</template>
<div style="flex:1; min-width:0;">
<div style="font-weight:500;" x-text="getPlayer(uid).name"></div>
<div style="font-size:0.8rem; color:var(--text-muted);">
<span x-text="'HI\u00a0' + (getPlayer(uid).handicap != null ? getPlayer(uid).handicap : '—')"></span>
<template x-if="getTee(uid)">
<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 x-if="getCourseHandicap(uid) != null">
<span style="color:var(--accent); font-weight:600;" x-text="' · HC\u00a0' + getCourseHandicap(uid)"></span>
</template>
</div>
</div>
</div>
</template>
</div>
<div style="display:flex; gap:0.75rem; margin-top:1rem;">
<button type="button" @click="backFromConfirm()"
style="flex:1; padding:0.85rem; border-radius:0.65rem; border:1px solid var(--border); background:var(--surface2); color:var(--text); font-size:1rem; cursor:pointer;">← Back</button>
<button type="submit" class="btn btn-primary" style="flex:2;">Start game ⛳</button>
</div>
</div>
{# Hidden inputs driven by Alpine — always submitted regardless of step #}
<template x-for="pid in selectedPlayers" :key="pid">
<input type="hidden" name="players" :value="pid">
</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'">
</form>
</div>
{% endblock %}