Add playing handicap, 6-digit login code, and game UX improvements

- Playing handicap: computed at game creation (WHS formula), stored on
  game_players alongside per-hole strokes allocation; displayed on game
  summary with HC card and / markers on scorecard (only when SI defined);
  live game shows +N per player card when SI exists, HC total otherwise;
  new game confirmation previews computed HC per player

- Login: 6-digit code sent alongside magic link in every auth email;
  check_email page has auto-advancing digit inputs with paste support;
  new POST /auth/verify-code route handles code verification

- Game view: Score/Card toggle fixed with position:fixed to always show
  above nav bar on mobile; swipe on score view changes holes, swipe on
  scorecard switches tabs; dirty tracking prevents saving unmodified holes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Rolf
2026-03-23 20:37:08 +01:00
parent c658c970ac
commit 8601584099
10 changed files with 335 additions and 123 deletions
+31 -27
View File
@@ -6,7 +6,7 @@
<div id="game-app"
x-data="gameApp()"
x-init="init()"
style="height: calc(100vh - 64px); display:flex; flex-direction:column; overflow:hidden;">
style="position:fixed; top:0; left:0; right:0; bottom:calc(var(--nav-height) + env(safe-area-inset-bottom)); display:flex; flex-direction:column; overflow:hidden;">
{# ── Header ── #}
<div class="game-header">
@@ -17,7 +17,7 @@
<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>
<span x-text="(HOLE_PARS[currentHole] ? 'Par ' + HOLE_PARS[currentHole] : '') + (HOLE_SIS[currentHole] ? ' · SI ' + HOLE_SIS[currentHole] : '')"></span>
</template>
<template x-if="view === 'card'">
<span>{{ game.course_name or '' }}</span>
@@ -26,6 +26,7 @@
</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>
@@ -63,6 +64,14 @@
@touchmove.stop.prevent="touchDragMove($event)"
@touchend.stop="touchDragEnd()"></span>
<span x-text="p.name"></span>
<template x-if="HAS_STROKE_INDEX && (STROKES_PER_HOLE[p.id] || {})[currentHole] > 0">
<span style="font-size:0.7rem; color:var(--accent); margin-left:0.35rem; font-weight:600;"
x-text="'+' + (STROKES_PER_HOLE[p.id] || {})[currentHole]"></span>
</template>
<template x-if="!HAS_STROKE_INDEX && p.playing_handicap != null">
<span style="font-size:0.7rem; color:var(--text-muted); margin-left:0.35rem;"
x-text="'HC ' + p.playing_handicap"></span>
</template>
</div>
<div class="psc-controls">
<span class="stroke-controls">
@@ -110,17 +119,6 @@
{# 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>
@@ -135,8 +133,6 @@
{{ 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>
@@ -147,9 +143,11 @@
<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 sr = strokes_per_hole.get(p.id, {}).get(h, 0) %}
<td class="score-cell">
<span x-text="holeStrokesStr({{ h }}, '{{ p.id }}')"
:class="holeScoreClass({{ h }}, '{{ p.id }}')"></span>
{% if has_stroke_index and sr > 0 %}<sup style="color:var(--accent); font-size:0.6em; line-height:0;">{{ '/' * sr }}</sup>{% endif %}
</td>
{% endfor %}
</tr>
@@ -179,8 +177,6 @@
{{ 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>
@@ -232,9 +228,11 @@
<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 sr = strokes_per_hole.get(p.id, {}).get(h, 0) %}
<td class="score-cell">
<span x-text="holeStrokesStr({{ h }}, '{{ p.id }}')"
:class="[holeScoreClass({{ h }}, '{{ p.id }}'), isSkinWinner({{ h }}, '{{ p.id }}') ? 'skin-winner' : '']"></span>
{% if has_stroke_index and sr > 0 %}<sup style="color:var(--accent); font-size:0.6em; line-height:0;">{{ '/' * sr }}</sup>{% endif %}
</td>
{% endfor %}
<td class="score-cell" x-text="skinHoleLabel({{ h }})"></td>
@@ -282,6 +280,8 @@ const GAME_FORMAT = "{{ game.format }}";
const SKINS_CARRYOVER = {{ carryover | tojson }};
const HOLE_PARS = {{ hole_pars | tojson }};
const HOLE_SIS = {{ hole_stroke_indices | tojson }};
const STROKES_PER_HOLE = {{ strokes_per_hole | tojson }};
const HAS_STROKE_INDEX = {{ has_stroke_index | tojson }};
const PLAYERS_DATA = {{ players | tojson }};
// Initial scores from server
@@ -297,6 +297,7 @@ function gameApp() {
scores: {}, // { hole: { uid: {strokes,bingo,bango,bongo} } }
totals: {},
skipped: new Set(),
dirty: new Set(),
ws: null,
touchStartX: 0,
dragSrc: null,
@@ -350,12 +351,8 @@ function gameApp() {
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);
}
this.scores[hole][uid].strokes = cur ? Math.max(1, cur + delta) : (HOLE_PARS[hole] || 4);
this.dirty.add(hole);
},
setBingo(hole, uid) {
@@ -363,6 +360,7 @@ function gameApp() {
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;
this.dirty.add(hole);
},
toggleBango(hole, uid) {
@@ -372,6 +370,7 @@ function gameApp() {
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;
this.dirty.add(hole);
},
setBongo(hole, uid) {
@@ -379,9 +378,12 @@ function gameApp() {
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;
this.dirty.add(hole);
},
saveHole(hole) {
if (!this.dirty.has(hole)) return;
this.dirty.delete(hole);
const holeScores = this.scores[hole] ?? {};
const params = new URLSearchParams();
params.set('hole', hole);
@@ -525,10 +527,12 @@ function gameApp() {
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;
if (Math.abs(dx) < 60) return;
if (this.view === 'score') {
if (dx < 0) this.nextHole(); else this.prevHole();
} else {
const secondary = GAME_FORMAT === 'skins' ? 'skins' : 'points';
this.cardView = dx < 0 ? secondary : 'strokes';
}
},
};