26c3280a92
When only front-nine holes are entered in course_holes (common setup), course_par_full summed to ~36 instead of ~72, inflating the 9-hole playing handicap by roughly 2x before halving. Fix by using course_par_played×2 as the 18-hole par estimate in both the backend create_game and the new_game confirmation preview. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
358 lines
18 KiB
HTML
358 lines
18 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}New Game – Skins & 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,
|
||
holesCount: '',
|
||
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 = '';
|
||
},
|
||
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) return null;
|
||
const nineOfEighteen = parseInt(this.holesCount) === 9 && course.holes === 18;
|
||
// For 9-of-18, use par_9×2 as the 18-hole par so back-nine holes don't need to be in DB.
|
||
const par = nineOfEighteen ? course.par_9 * 2 : course.par;
|
||
if (!par) return null;
|
||
const ph = Math.round(player.handicap * (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';
|
||
return 'Bingo Bango Bongo';
|
||
}
|
||
}">
|
||
|
||
<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">
|
||
<span>Skins</span>
|
||
</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">
|
||
<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 && holesCount == 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;"
|
||
@change="skinDoubleBackNine = $event.target.checked">
|
||
<span style="font-weight:400; color:var(--text-muted);">Double value on back nine (holes 10–18)</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>
|
||
|
||
<div class="form-group">
|
||
<label>Holes</label>
|
||
<div style="display:flex; gap:0.75rem;">
|
||
<label class="radio-card">
|
||
<input type="radio" name="holes_count" value="9" required
|
||
@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>
|
||
</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" x-show="hasTees()" class="btn btn-primary" @click="step = 2">Next →</button>
|
||
<button type="button" x-show="!hasTees()" class="btn btn-primary" @click="goToConfirm()">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.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 === '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>
|
||
</div>
|
||
<div class="confirm-row" x-show="format === 'skins' && 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="holesCount ? holesCount + ' holes' : '—'"></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).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>
|
||
|
||
</form>
|
||
</div>
|
||
{% endblock %}
|