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:
+108
-13
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user