Files
SkinsPins/app/templates/games/new_game.html
T
Rolf 2c0717948a 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>
2026-03-23 21:16:10 +01:00

355 lines
18 KiB
HTML
Raw 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,
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 || !course.par) return null;
const nineOfEighteen = parseInt(this.holesCount) === 9 && course.holes === 18;
const ph = Math.round(player.handicap * (tee.slope / 113) + (tee.rating - course.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 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>
<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 %}