c298c473f4
Dedicated 9-hole courses have 9-hole ratings and pars, so the WHS formula is: round((HI÷2) × (slope/113) + (rating − par)). The previous code used the full HI, giving roughly double the correct result (e.g. 22 instead of 11 on Augwil White Male for HI 18). The three cases are now handled correctly: - Dedicated 9-hole course: HI÷2 with 9-hole ratings - 9 holes on 18-hole course: full HI with 18-hole ratings, result halved - Full 18 holes: full HI with 18-hole ratings Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
361 lines
18 KiB
HTML
361 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;
|
||
const nineHoleDedicated = parseInt(this.holesCount) === 9 && course.holes === 9;
|
||
// For 9-of-18: use par_9×2 as the 18-hole par, compute full CH then halve.
|
||
// For dedicated 9-hole: WHS formula uses HI÷2 directly with 9-hole ratings.
|
||
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';
|
||
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 %}
|