0be6e908e6
- Remove unused DATABASE_PATH import from games.py - Extract _new_game_context(db) helper to deduplicate GET/POST error path - Extract _parse_hole_form() helper to deduplicate hole validation in create/edit course - Batch N+1 player handicap and tee queries in create_game into two IN() queries - Fix hole count import bug: use max() across all tees instead of first match - Fix migration exception handling: re-raise unless "duplicate column name" - Cache calcSkins() result in _skinsCache; invalidate on score/skip changes - Collapse two identical Next buttons into one in new_game.html - Fix stale skinDoubleBackNine checkbox: reset on mode change, use x-model Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
582 lines
23 KiB
HTML
582 lines
23 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}Game – Skins & Pins{% endblock %}
|
||
|
||
|
||
{% block content %}
|
||
<div id="game-app"
|
||
x-data="gameApp()"
|
||
x-init="init()"
|
||
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">
|
||
<div style="display:flex; align-items:center; gap:0.75rem;">
|
||
<button @click="view === 'score' ? prevHole() : cardPrev()" 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 + ' / ' + (START_HOLE + GAME_HOLES - 1) : (cardView === 'strokes' ? 'Strokes' : cardView === 'points' ? 'Points' : 'Skins')"></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_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() : cardNext()" class="icon-btn"
|
||
:disabled="view === 'score' ? currentHole >= START_HOLE + GAME_HOLES - 1 : cardView === (GAME_FORMAT === 'skins' || SIDE_SKINS_ENABLED ? 'skins' : 'points')">›</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>
|
||
<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">
|
||
<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">
|
||
{# 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>
|
||
</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 %}
|
||
{% 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>
|
||
{% 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>
|
||
</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 %}
|
||
{% 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>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
<tfoot>
|
||
<tr class="totals-row">
|
||
<td x-text="SKIN_VALUE ? 'Value' : 'Skins'"></td>
|
||
{% if hole_pars %}<td></td>{% endif %}
|
||
{% for p in players %}
|
||
<td x-text="SKIN_VALUE ? (parseFloat(calcSkins().money['{{ p.id }}'].toPrecision(4)) || 0) : (calcSkins().skins['{{ p.id }}'] || 0)"></td>
|
||
{% endfor %}
|
||
<td x-text="SKIN_VALUE ? (calcSkins().unclaimedMoney ? '(' + parseFloat(calcSkins().unclaimedMoney.toPrecision(4)) + ')' : '') : (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 SKIN_VALUE = {{ skin_value | tojson }};
|
||
const SKIN_DOUBLE_BACK9 = {{ skin_double_back9 | tojson }};
|
||
const SKINS_NET = {{ skins_net | tojson }};
|
||
const SIDE_SKINS_ENABLED = {{ side_skins_enabled | tojson }};
|
||
const START_HOLE = {{ start_hole | 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
|
||
const initialScores = {{ scores | tojson }};
|
||
const initialTotals = {{ totals | tojson }};
|
||
|
||
function gameApp() {
|
||
return {
|
||
view: 'score',
|
||
cardView: 'strokes',
|
||
currentHole: START_HOLE,
|
||
players: [],
|
||
scores: {}, // { hole: { uid: {strokes,bingo,bango,bongo} } }
|
||
totals: {},
|
||
skipped: new Set(),
|
||
dirty: new Set(),
|
||
ws: null,
|
||
touchStartX: 0,
|
||
dragSrc: null,
|
||
dragOver: null,
|
||
_skinsCache: 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._skinsCache = null;
|
||
}
|
||
};
|
||
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;
|
||
this.scores[hole][uid].strokes = cur ? Math.max(1, cur + delta) : (HOLE_PARS[hole] || 4);
|
||
this.dirty.add(hole);
|
||
this._skinsCache = null;
|
||
},
|
||
|
||
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;
|
||
this.dirty.add(hole);
|
||
},
|
||
|
||
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;
|
||
this.dirty.add(hole);
|
||
},
|
||
|
||
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;
|
||
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);
|
||
|
||
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);
|
||
this._skinsCache = null;
|
||
},
|
||
|
||
isSkipped(hole) {
|
||
return this.skipped.has(hole);
|
||
},
|
||
|
||
allHolesDone() {
|
||
for (let h = START_HOLE; h <= START_HOLE + GAME_HOLES - 1; 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 > START_HOLE) { this.saveHole(this.currentHole); this.currentHole--; } },
|
||
nextHole() { if (this.currentHole < START_HOLE + GAME_HOLES - 1) { this.saveHole(this.currentHole); this.currentHole++; } },
|
||
|
||
cardTabs() {
|
||
if (GAME_FORMAT === 'skins') return ['strokes', 'skins'];
|
||
if (SIDE_SKINS_ENABLED) return ['strokes', 'points', 'skins'];
|
||
return ['strokes', 'points'];
|
||
},
|
||
cardNext() {
|
||
const tabs = this.cardTabs();
|
||
const idx = tabs.indexOf(this.cardView);
|
||
if (idx < tabs.length - 1) this.cardView = tabs[idx + 1];
|
||
},
|
||
cardPrev() {
|
||
const tabs = this.cardTabs();
|
||
const idx = tabs.indexOf(this.cardView);
|
||
if (idx > 0) this.cardView = tabs[idx - 1];
|
||
},
|
||
|
||
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() {
|
||
if (this._skinsCache) return this._skinsCache;
|
||
const skins = {};
|
||
const money = {};
|
||
PLAYERS_DATA.forEach(p => { skins[p.id] = 0; money[p.id] = 0; });
|
||
const holes = {};
|
||
let pot = 0;
|
||
let moneyPot = 0;
|
||
for (let hole = START_HOLE; hole <= START_HOLE + GAME_HOLES - 1; hole++) {
|
||
if (this.isSkipped(hole)) continue;
|
||
const holeScores = this.scores[hole] || {};
|
||
const scored = PLAYERS_DATA
|
||
.map(p => {
|
||
const gross = holeScores[p.id]?.strokes || 0;
|
||
const net = SKINS_NET ? gross - ((STROKES_PER_HOLE[p.id] || {})[hole] || 0) : gross;
|
||
return { id: p.id, strokes: net, gross };
|
||
})
|
||
.filter(x => x.gross > 0);
|
||
if (scored.length === 0) continue;
|
||
pot += 1;
|
||
if (SKIN_VALUE) moneyPot += SKIN_VALUE * (SKIN_DOUBLE_BACK9 && hole >= 10 ? 2 : 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;
|
||
money[winners[0].id] += moneyPot;
|
||
holes[hole] = { winner: winners[0].id, pot, moneyPot };
|
||
pot = 0;
|
||
moneyPot = 0;
|
||
} else {
|
||
holes[hole] = { winner: null, pot, moneyPot };
|
||
if (!SKINS_CARRYOVER) { pot = 0; moneyPot = 0; }
|
||
}
|
||
}
|
||
this._skinsCache = { skins, money, holes, unclaimed: pot, unclaimedMoney: moneyPot };
|
||
return this._skinsCache;
|
||
},
|
||
|
||
isSkinWinner(hole, uid) {
|
||
return this.calcSkins().holes[hole]?.winner === uid;
|
||
},
|
||
|
||
skinHoleLabel(hole) {
|
||
const h = this.calcSkins().holes[hole];
|
||
if (!h) return '';
|
||
if (SKIN_VALUE) {
|
||
const v = parseFloat(h.moneyPot.toPrecision(4));
|
||
if (h.winner) return '' + v;
|
||
return '→ ' + v;
|
||
}
|
||
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) return;
|
||
if (this.view === 'score') {
|
||
if (dx < 0) this.nextHole(); else this.prevHole();
|
||
} else {
|
||
if (dx < 0) this.cardNext(); else this.cardPrev();
|
||
}
|
||
},
|
||
};
|
||
}
|
||
</script>
|
||
{% endblock %}
|