Files
SkinsPins/app/templates/games/game.html
T
Rolf c51c872c33 Add Skins format, GolfCourseAPI import, and UX improvements
- Add Skins game format with optional carryover, mirroring BBB scoring pattern
- Add GolfCourseAPI integration for course search and import (httpx)
- Unwrap {"course": {}} API response envelope on import
- Add searchable course combobox and player filter to new game form
- Redirect to summary page after finishing a game
- Fix auth to allow registered users without a pending invitation
- Fix hardcoded BBB format in create_game; store carryover flag in DB
- Add game summary/scorecard view for completed games
- Add live games social stream to dashboard
- Add course name + subtitle to recent game cards
- Reorder nav: Home → Games → Admin → Profile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 09:18:04 +01:00

538 lines
21 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 %}Game Skins &amp; Pins{% endblock %}
{% block content %}
<div id="game-app"
x-data="gameApp()"
x-init="init()"
style="height: calc(100vh - 64px); display:flex; flex-direction:column; overflow:hidden;">
{# ── 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"
: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 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_PARS[currentHole] && HOLE_SIS[currentHole] ? ' · ' : '') + (HOLE_SIS[currentHole] ? 'SI ' + HOLE_SIS[currentHole] : '')"></span>
</template>
<template x-if="view === 'card'">
<span>{{ game.course_name or '' }}</span>
</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>
</div>
</div>
{# ── Swipeable panels ── #}
<div class="swipe-container"
@touchstart="touchStart($event)"
@touchend="touchEnd($event)">
{# Score entry panel #}
<div class="swipe-panel" :class="{ active: view === 'score' }">
<div class="panel-scroll">
{# Skip hole #}
<div style="display:flex; justify-content:flex-end; padding: 0 0 0.75rem;">
<button class="btn-skip"
:class="{ skipped: isSkipped(currentHole) }"
@click="toggleSkip(currentHole)">
<span x-text="isSkipped(currentHole) ? '↩ Unskip hole' : 'Skip hole'"></span>
</button>
</div>
<div x-show="!isSkipped(currentHole)" id="player-list">
<template x-for="(p, idx) in players" :key="p.id">
<div class="player-score-card"
draggable="true"
:class="{ 'drag-source': dragSrc === idx, 'drag-over': dragOver === idx }"
@dragstart="dragSrc = idx; $event.dataTransfer.effectAllowed = 'move'"
@dragenter.prevent="dragOver = idx"
@dragover.prevent
@drop.prevent="dropPlayer(idx)"
@dragend="endDrag()">
<div class="psc-name">
<span class="drag-handle"
@touchstart.stop="dragSrc = idx; touchStartX = null"
@touchmove.stop.prevent="touchDragMove($event)"
@touchend.stop="touchDragEnd()"></span>
<span x-text="p.name"></span>
</div>
<div class="psc-controls">
<span class="stroke-controls">
<button type="button" class="stroke-btn stroke-minus"
@click="adjustStrokes(currentHole, p.id, -1)"></button>
<span class="stroke-val"
x-text="getScore(currentHole, p.id).strokes || '—'"></span>
<button type="button" class="stroke-btn stroke-plus"
@click="adjustStrokes(currentHole, p.id, 1)">+</button>
</span>
<template x-if="GAME_FORMAT === 'bingo_bango_bongo'">
<span>
<button type="button" class="bbb-btn bingo-btn"
:class="{ active: getScore(currentHole, p.id).bingo }"
@click="setBingo(currentHole, p.id)">Bingo</button>
<button type="button" class="bbb-btn bango-btn"
:class="{ active: getScore(currentHole, p.id).bango > 0 }"
@click="toggleBango(currentHole, p.id)">Bango</button>
<button type="button" class="bbb-btn bongo-btn"
:class="{ active: getScore(currentHole, p.id).bongo }"
@click="setBongo(currentHole, p.id)">Bongo</button>
</span>
</template>
</div>
</div>
</template>
</div>
<div x-show="isSkipped(currentHole)" class="empty-state" style="padding: 2rem 0;">
<div class="empty-icon"></div>
<p>Hole {{ '{{ currentHole }}' }} skipped</p>
</div>
{% if game.status == 'active' %}
<template x-if="allHolesDone()">
<form method="post" action="/games/{{ game.id }}/finish" style="margin-top: 1rem;">
<button type="submit" class="btn btn-primary">Finish game</button>
</form>
</template>
{% endif %}
</div>
</div>
{# Scorecard panel #}
<div class="swipe-panel" :class="{ active: view === 'card' }">
<div class="panel-scroll">
{# Scorecard sub-toggle #}
<div class="scorecard-toggle">
<button @click="cardView = 'strokes'" :class="{ active: cardView === 'strokes' }">Strokes</button>
<template x-if="GAME_FORMAT === 'bingo_bango_bongo'">
<button @click="cardView = 'points'" :class="{ active: cardView === 'points' }">Points</button>
</template>
<template x-if="GAME_FORMAT === 'skins'">
<button @click="cardView = 'skins'" :class="{ active: cardView === 'skins' }">Skins</button>
</template>
</div>
{# Strokes scorecard #}
<table class="scorecard" x-show="cardView === 'strokes'">
<thead>
<tr>
<th>Hole</th>
{% if hole_pars %}<th style="color:var(--text-muted);">Par</th>{% endif %}
{% for p in players %}
<th>
{% if p.avatar and p.avatar.startswith('/') %}
<img src="{{ p.avatar }}" style="width:1.3rem;height:1.3rem;border-radius:50%;object-fit:cover;vertical-align:middle;">
{% else %}
{{ p.avatar or '👤' }}
{% endif %}<br>
<a href="/users/{{ p.id }}" style="font-size:0.7rem; color:inherit; text-decoration:none;">{{ p.name.split()[0] }}</a>
{% set ph = playing_handicaps.get(p.id) %}
{% if ph is not none %}<br><span style="font-size:0.65rem; color:var(--text-muted);">HCP {{ ph }}</span>{% endif %}
</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for h in holes %}
<tr :class="{ 'hole-skipped': isSkipped({{ h }}) }">
<td class="hole-num">{{ h }}</td>
{% if hole_pars %}<td class="score-cell" style="color:var(--text-muted);">{{ hole_pars.get(h, '—') }}</td>{% endif %}
{% for p in players %}
<td class="score-cell">
<span x-text="holeStrokesStr({{ h }}, '{{ p.id }}')"
:class="holeScoreClass({{ h }}, '{{ p.id }}')"></span>
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="totals-row">
<td>Total</td>
{% if hole_pars %}<td style="color:var(--text-muted);">{{ hole_pars.values() | sum }}</td>{% endif %}
{% for p in players %}
<td x-text="Object.values(scores).reduce((sum, h) => sum + (h['{{ p.id }}']?.strokes || 0), 0) || '—'"></td>
{% endfor %}
</tr>
</tfoot>
</table>
{# Points scorecard #}
<table class="scorecard" x-show="cardView === 'points'">
<thead>
<tr>
<th>Hole</th>
{% for p in players %}
<th>
{% if p.avatar and p.avatar.startswith('/') %}
<img src="{{ p.avatar }}" style="width:1.3rem;height:1.3rem;border-radius:50%;object-fit:cover;vertical-align:middle;">
{% else %}
{{ p.avatar or '👤' }}
{% endif %}<br>
<a href="/users/{{ p.id }}" style="font-size:0.7rem; color:inherit; text-decoration:none;">{{ p.name.split()[0] }}</a>
{% set ph = playing_handicaps.get(p.id) %}
{% if ph is not none %}<br><span style="font-size:0.65rem; color:var(--text-muted);">HCP {{ ph }}</span>{% endif %}
</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for h in holes %}
<tr :class="{ 'hole-skipped': isSkipped({{ h }}) }">
<td class="hole-num">{{ h }}</td>
{% for p in players %}
<td class="score-cell">
<span x-text="holeTotalStr({{ h }}, '{{ p.id }}')"></span>
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="totals-row">
<td>Total</td>
{% for p in players %}
<td x-text="fmt(totals['{{ p.id }}']?.total ?? 0)"></td>
{% endfor %}
</tr>
</tfoot>
</table>
{# Skins scorecard #}
<table class="scorecard" x-show="cardView === 'skins'">
<thead>
<tr>
<th>Hole</th>
{% if hole_pars %}<th style="color:var(--text-muted);">Par</th>{% endif %}
{% for p in players %}
<th>
{% if p.avatar and p.avatar.startswith('/') %}
<img src="{{ p.avatar }}" style="width:1.3rem;height:1.3rem;border-radius:50%;object-fit:cover;vertical-align:middle;">
{% else %}
{{ p.avatar or '👤' }}
{% endif %}<br>
<a href="/users/{{ p.id }}" style="font-size:0.7rem; color:inherit; text-decoration:none;">{{ p.name.split()[0] }}</a>
</th>
{% endfor %}
<th style="color:var(--text-muted);">Skin</th>
</tr>
</thead>
<tbody>
{% for h in holes %}
<tr :class="{ 'hole-skipped': isSkipped({{ h }}) }">
<td class="hole-num">{{ h }}</td>
{% if hole_pars %}<td class="score-cell" style="color:var(--text-muted);">{{ hole_pars.get(h, '—') }}</td>{% endif %}
{% for p in players %}
<td class="score-cell">
<span x-text="holeStrokesStr({{ h }}, '{{ p.id }}')"
:class="[holeScoreClass({{ h }}, '{{ p.id }}'), isSkinWinner({{ h }}, '{{ p.id }}') ? 'skin-winner' : '']"></span>
</td>
{% endfor %}
<td class="score-cell" x-text="skinHoleLabel({{ h }})"></td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="totals-row">
<td>Skins</td>
{% if hole_pars %}<td></td>{% endif %}
{% for p in players %}
<td x-text="calcSkins().skins['{{ p.id }}'] || 0"></td>
{% endfor %}
<td x-text="calcSkins().unclaimed ? '(' + calcSkins().unclaimed + ')' : ''"></td>
</tr>
</tfoot>
</table>
{% if game.status == 'active' %}
<template x-if="allHolesDone()">
<form method="post" action="/games/{{ game.id }}/finish" style="margin-top: 1rem;">
<button type="submit" class="btn btn-primary">Finish game</button>
</form>
</template>
{% endif %}
</div>
</div>
</div>{# end swipe-container #}
{# ── View toggle ── #}
<div class="view-toggle">
<button @click="view = 'score'" :class="{ active: view === 'score' }">Score</button>
<button @click="if (view === 'score') saveHole(currentHole); view = 'card'" :class="{ active: view === 'card' }">Card</button>
</div>
</div>
<script>
const GAME_ID = "{{ game.id }}";
const GAME_HOLES = {{ game.holes_count }};
const GAME_FORMAT = "{{ game.format }}";
const SKINS_CARRYOVER = {{ carryover | tojson }};
const HOLE_PARS = {{ hole_pars | tojson }};
const HOLE_SIS = {{ hole_stroke_indices | tojson }};
const PLAYERS_DATA = {{ players | tojson }};
// Initial scores from server
const initialScores = {{ scores | tojson }};
const initialTotals = {{ totals | tojson }};
function gameApp() {
return {
view: 'score',
cardView: 'strokes',
currentHole: 1,
players: [],
scores: {}, // { hole: { uid: {strokes,bingo,bango,bongo} } }
totals: {},
skipped: new Set(),
ws: null,
touchStartX: 0,
dragSrc: null,
dragOver: null,
init() {
this.players = PLAYERS_DATA;
// Load initial scores from server
this.totals = initialTotals;
for (const [hole, playerScores] of Object.entries(initialScores)) {
const h = parseInt(hole);
this.scores[h] = {};
for (const [uid, s] of Object.entries(playerScores)) {
this.scores[h][uid] = { strokes: s.strokes ?? 0, bingo: s.bingo, bango: s.bango, bongo: s.bongo };
}
}
// Jump to the last scored hole for active games
const scoredHoles = Object.keys(initialScores).map(Number).filter(h => Object.keys(initialScores[h]).length > 0);
if (scoredHoles.length > 0) this.currentHole = Math.max(...scoredHoles);
this.connectWs();
},
connectWs() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
this.ws = new WebSocket(`${proto}://${location.host}/games/${GAME_ID}/ws`);
this.ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'score_update') {
const h = msg.hole;
if (!this.scores[h]) this.scores[h] = {};
for (const [uid, s] of Object.entries(msg.scores)) {
this.scores[h][uid] = s;
}
this.totals = msg.totals;
}
};
this.ws.onclose = () => setTimeout(() => this.connectWs(), 2000);
},
getScore(hole, uid) {
return this.scores[hole]?.[uid] ?? { strokes: 0, bingo: 0, bango: 0.0, bongo: 0 };
},
_ensurePlayer(hole, uid) {
if (!this.scores[hole]) this.scores[hole] = {};
if (!this.scores[hole][uid]) this.scores[hole][uid] = { strokes: 0, bingo: 0, bango: 0.0, bongo: 0 };
},
adjustStrokes(hole, uid, delta) {
this._ensurePlayer(hole, uid);
const cur = this.scores[hole][uid].strokes;
if (!cur) {
// First press: start at par for this hole
this.scores[hole][uid].strokes = HOLE_PARS[hole] || 4;
} else {
this.scores[hole][uid].strokes = Math.max(1, cur + delta);
}
},
setBingo(hole, uid) {
this._ensurePlayer(hole, uid);
const current = this.scores[hole][uid].bingo;
for (const p in this.scores[hole]) this.scores[hole][p].bingo = 0;
this.scores[hole][uid].bingo = current ? 0 : 1;
},
toggleBango(hole, uid) {
this._ensurePlayer(hole, uid);
this.scores[hole][uid].bango = this.scores[hole][uid].bango > 0 ? 0 : 1;
// Recalculate split
const winners = Object.entries(this.scores[hole]).filter(([, s]) => s.bango > 0).map(([u]) => u);
const share = winners.length ? Math.round(10000 / winners.length) / 10000 : 0;
for (const p in this.scores[hole]) this.scores[hole][p].bango = winners.includes(p) ? share : 0;
},
setBongo(hole, uid) {
this._ensurePlayer(hole, uid);
const current = this.scores[hole][uid].bongo;
for (const p in this.scores[hole]) this.scores[hole][p].bongo = 0;
this.scores[hole][uid].bongo = current ? 0 : 1;
},
saveHole(hole) {
const holeScores = this.scores[hole] ?? {};
const params = new URLSearchParams();
params.set('hole', hole);
for (const [uid, s] of Object.entries(holeScores)) {
if (s.bingo) params.set('bingo', uid);
if (s.bongo) params.set('bongo', uid);
if (s.strokes) params.append('strokes_' + uid, s.strokes);
}
const bangoWinners = Object.entries(holeScores).filter(([, s]) => s.bango > 0).map(([uid]) => uid);
for (const uid of bangoWinners) params.append('bango', uid);
fetch(`/games/${GAME_ID}/score`, { method: 'POST', body: params });
},
holeScoreClass(hole, uid) {
const s = this.getScore(hole, uid);
if (!s.strokes || this.isSkipped(hole)) return '';
const par = HOLE_PARS[hole];
if (!par) return '';
if (s.strokes === 1) return 'score-hio';
const diff = s.strokes - par;
if (diff <= -2) return 'score-eagle';
if (diff === -1) return 'score-birdie';
if (diff === 1) return 'score-bogey';
if (diff >= 2) return 'score-double-bogey';
return '';
},
holeStrokesStr(hole, uid) {
if (this.isSkipped(hole)) return '—';
const s = this.getScore(hole, uid);
return s.strokes ? s.strokes : '';
},
holeTotalStr(hole, uid) {
if (this.isSkipped(hole)) return '—';
const holeScores = this.scores[hole];
if (!holeScores || Object.keys(holeScores).length === 0) return '';
const s = this.getScore(hole, uid);
const t = s.bingo + s.bango + s.bongo;
return this.fmt(t);
},
toggleSkip(hole) {
if (this.skipped.has(hole)) {
this.skipped.delete(hole);
} else {
this.skipped.add(hole);
}
// Alpine reactivity workaround for Set
this.skipped = new Set(this.skipped);
},
isSkipped(hole) {
return this.skipped.has(hole);
},
allHolesDone() {
for (let h = 1; h <= GAME_HOLES; h++) {
if (this.isSkipped(h)) continue;
const holeScores = this.scores[h];
if (!holeScores || Object.keys(holeScores).length === 0) return false;
}
return true;
},
prevHole() { if (this.currentHole > 1) { this.saveHole(this.currentHole); this.currentHole--; } },
nextHole() { if (this.currentHole < GAME_HOLES) { this.saveHole(this.currentHole); this.currentHole++; } },
fmt(v) {
return Number.isInteger(v) || v % 1 === 0 ? v.toFixed(0) : v.toFixed(1);
},
dropPlayer(targetIdx) {
if (this.dragSrc === null || targetIdx === null || this.dragSrc === targetIdx) {
this.dragSrc = null; this.dragOver = null; return;
}
const moved = this.players.splice(this.dragSrc, 1)[0];
this.players.splice(targetIdx, 0, moved);
this.dragSrc = null; this.dragOver = null;
},
endDrag() { this.dragSrc = null; this.dragOver = null; },
touchDragMove(evt) {
const touch = evt.touches[0];
const el = document.elementFromPoint(touch.clientX, touch.clientY);
const card = el?.closest('.player-score-card');
if (!card) return;
const cards = [...document.getElementById('player-list').querySelectorAll('.player-score-card')];
const idx = cards.indexOf(card);
if (idx !== -1) this.dragOver = idx;
},
touchDragEnd() { this.dropPlayer(this.dragOver ?? this.dragSrc); },
calcSkins() {
const skins = {};
PLAYERS_DATA.forEach(p => skins[p.id] = 0);
const holes = {};
let pot = 0;
for (let hole = 1; hole <= GAME_HOLES; hole++) {
if (this.isSkipped(hole)) continue;
const holeScores = this.scores[hole] || {};
const scored = PLAYERS_DATA
.map(p => ({ id: p.id, strokes: holeScores[p.id]?.strokes || 0 }))
.filter(x => x.strokes > 0);
if (scored.length === 0) continue;
pot += 1;
const min = Math.min(...scored.map(x => x.strokes));
const winners = scored.filter(x => x.strokes === min);
if (winners.length === 1) {
skins[winners[0].id] += pot;
holes[hole] = { winner: winners[0].id, pot };
pot = 0;
} else {
holes[hole] = { winner: null, pot };
if (!SKINS_CARRYOVER) pot = 0;
}
}
return { skins, holes, unclaimed: pot };
},
isSkinWinner(hole, uid) {
return this.calcSkins().holes[hole]?.winner === uid;
},
skinHoleLabel(hole) {
const h = this.calcSkins().holes[hole];
if (!h) return '';
if (h.winner) return h.pot > 1 ? '✓ ' + h.pot : '✓';
return '→ ' + h.pot;
},
touchStart(e) {
if (e.target.closest('.drag-handle')) return;
this.touchStartX = e.changedTouches[0].screenX;
},
touchEnd(e) {
if (!this.touchStartX) return;
const dx = e.changedTouches[0].screenX - this.touchStartX;
this.touchStartX = 0;
if (Math.abs(dx) > 60) {
const next = dx < 0 ? 'card' : 'score';
if (next === 'card' && this.view === 'score') this.saveHole(this.currentHole);
this.view = next;
}
},
};
}
</script>
{% endblock %}