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>
This commit is contained in:
Rolf
2026-03-22 09:18:04 +01:00
parent ef22436c76
commit c51c872c33
12 changed files with 554 additions and 46 deletions
+108 -13
View File
@@ -14,7 +14,7 @@
<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' : 'Points')"></div>
<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>
@@ -24,8 +24,8 @@
</template>
</div>
</div>
<button @click="view === 'score' ? nextHole() : cardView = 'points'" class="icon-btn"
:disabled="view === 'score' ? currentHole >= {{ game.holes_count }} : cardView === 'points'"></button>
<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>
@@ -73,15 +73,19 @@
<button type="button" class="stroke-btn stroke-plus"
@click="adjustStrokes(currentHole, p.id, 1)">+</button>
</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>
<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>
@@ -109,7 +113,12 @@
{# Scorecard sub-toggle #}
<div class="scorecard-toggle">
<button @click="cardView = 'strokes'" :class="{ active: cardView === 'strokes' }">Strokes</button>
<button @click="cardView = 'points'" :class="{ active: cardView === 'points' }">Points</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 #}
@@ -198,6 +207,52 @@
</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;">
@@ -223,6 +278,8 @@
<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 }};
@@ -422,6 +479,44 @@ function gameApp() {
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;
+85 -12
View File
@@ -4,7 +4,13 @@
{% block extra_head %}
<script>
const TEES_BY_COURSE = {{ tees_by_course | tojson }};
const COURSES_DATA = {{ courses | 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); }
</style>
{% endblock %}
{% block content %}
@@ -12,7 +18,26 @@
x-data="{
step: 1,
courseId: '',
courseLabel: '',
courseOpen: false,
courseSearch: '',
format: 'bingo_bango_bongo',
selectedPlayers: ['{{ user.id }}'],
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);
@@ -38,23 +63,65 @@
<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>
<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;">
<label style="display:flex; align-items:center; gap:0.5rem; cursor:pointer;">
<input type="checkbox" name="carryover" value="1" checked style="width:auto;">
<span style="font-weight:400; color:var(--text-muted);">Carryover (ties carry to next hole)</span>
</label>
</div>
</div>
<div class="form-group">
<label for="course_id">Course</label>
{# ── Course picker ── #}
<div class="form-group" @click.outside="courseOpen = false; courseSearch = ''">
<label>Course</label>
{% if courses %}
<select id="course_id" name="course_id" required
@change="courseId = $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 a course…</option>
{% for c in courses %}
<option value="{{ c.id }}">{{ c.name }}{% if c.location %} · {{ c.location }}{% endif %} ({{ c.holes }} holes)</option>
{% endfor %}
</select>
<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="">
@@ -75,11 +142,16 @@
</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">
<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 %}">
<input type="checkbox" name="players" value="{{ u.id }}"
{% if u.id == user.id %}checked disabled{% endif %}
@change="togglePlayer('{{ u.id }}')"
@@ -98,6 +170,7 @@
</div>
<input type="hidden" name="players" value="{{ user.id }}">
</div>
</div>
<button type="button" x-show="hasTees()" class="btn btn-primary" @click="step = 2">Next →</button>
+87 -2
View File
@@ -15,7 +15,14 @@
{# Leaderboard #}
<div class="card" style="padding:0.75rem 1rem;">
{% for p in results %}
{% set t = totals.get(p.id, {}) %}
{% if game.format == 'skins' %}
{% set score_val = skins_won.get(p.id, 0) %}
{% set score_label = score_val | string + ' skin' + ('s' if score_val != 1 else '') %}
{% else %}
{% set t = totals.get(p.id, {}) %}
{% set score_val = t.total %}
{% set score_label = t.total | round(1) | string + ' pts' %}
{% endif %}
<div class="result-row {% if loop.first %}result-winner{% endif %}" style="padding:0.4rem 0;">
<span class="result-rank">{% if loop.first %}🏆{% else %}{{ loop.index }}{% endif %}</span>
<span class="result-avatar">
@@ -26,18 +33,29 @@
{% endif %}
</span>
<a href="/users/{{ p.id }}" style="flex:1; color:inherit; text-decoration:none;">{{ p.name }}</a>
<span class="result-pts">{{ t.total | round(1) }} pts</span>
<span class="result-pts">{{ score_label }}</span>
{% if game.format == 'bingo_bango_bongo' %}
<span style="font-size:0.75rem; color:var(--text-muted); margin-left:0.5rem;">
B{{ t.bingo | int }}/{{ t.bango | round(1) }}/{{ t.bongo | int }}
</span>
{% endif %}
</div>
{% endfor %}
{% if game.format == 'skins' and unclaimed %}
<div style="font-size:0.8rem; color:var(--text-muted); margin-top:0.5rem; text-align:center;">
{{ unclaimed }} skin{{ 's' if unclaimed != 1 else '' }} unclaimed
</div>
{% endif %}
</div>
{# Scorecard toggle #}
<div class="scorecard-toggle" style="margin-top:1rem;">
<button @click="view = 'strokes'" :class="{ active: view === 'strokes' }">Strokes</button>
{% if game.format == 'skins' %}
<button @click="view = 'skins'" :class="{ active: view === 'skins' }">Skins</button>
{% else %}
<button @click="view = 'points'" :class="{ active: view === 'points' }">Points</button>
{% endif %}
</div>
{# Strokes scorecard #}
@@ -150,5 +168,72 @@
</tfoot>
</table>
{# Skins scorecard #}
{% if game.format == 'skins' %}
<table class="scorecard" x-show="view === '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 %}
{% set hr = skins_hole_results.get(h) %}
<tr>
<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 %}
{% set s = scores.get(h, {}).get(p.id, {}) %}
{% set strokes = s.strokes if s else 0 %}
{% set par = hole_pars.get(h, 0) %}
<td class="score-cell">
{% if strokes %}
{% set is_winner = hr and hr.winner == p.id %}
{% if par %}
{% set diff = strokes - par %}
{% if strokes == 1 %}<span class="score-hio{% if is_winner %} skin-winner{% endif %}">{{ strokes }}</span>
{% elif diff <= -2 %}<span class="score-eagle{% if is_winner %} skin-winner{% endif %}">{{ strokes }}</span>
{% elif diff == -1 %}<span class="score-birdie{% if is_winner %} skin-winner{% endif %}">{{ strokes }}</span>
{% elif diff == 1 %}<span class="score-bogey{% if is_winner %} skin-winner{% endif %}">{{ strokes }}</span>
{% elif diff >= 2 %}<span class="score-double-bogey{% if is_winner %} skin-winner{% endif %}">{{ strokes }}</span>
{% else %}<span{% if is_winner %} class="skin-winner"{% endif %}>{{ strokes }}</span>{% endif %}
{% else %}<span{% if is_winner %} class="skin-winner"{% endif %}>{{ strokes }}</span>{% endif %}
{% else %}—{% endif %}
</td>
{% endfor %}
<td class="score-cell">
{% if hr %}
{% if hr.winner %}✓{% if hr.pot > 1 %} {{ hr.pot }}{% endif %}
{% else %}→ {{ hr.pot }}{% endif %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="totals-row">
<td>Skins</td>
{% if hole_pars %}<td></td>{% endif %}
{% for p in players %}
<td>{{ skins_won.get(p.id, 0) }}</td>
{% endfor %}
<td>{% if unclaimed %}({{ unclaimed }}){% endif %}</td>
</tr>
</tfoot>
</table>
{% endif %}
</div>
{% endblock %}