/** * NewTon DC - Chalker App * x01 scoring application for tablet use at dartboards */ (function() { 'use strict'; // Keep in sync with APP_VERSION in js/main.js const CHALKER_VERSION = '5.5.0'; /** * Escape a value for safe interpolation into innerHTML (element text and quoted * attributes). Chalker is standalone, so it carries its own copy — mirrors the * canonical escapeHtml in js/main.js. Player names and scanned QR payload fields * are untrusted and must pass through this before reaching innerHTML. * @param {*} value * @returns {string} */ function escapeHtml(value) { if (value == null) return ''; return String(value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // ================ // State Management // ================ /** * @typedef {Object} Visit * @property {1|2} player - Player number * @property {number} score - Points scored this visit * @property {number} dartsUsed - Darts thrown (3, or 1-3 for checkout) * @property {boolean} isCheckout - Whether this visit completed a leg * @property {number} remainingBefore - Score before this visit */ /** * @typedef {Object} Leg * @property {number} startingScore - Starting score for the leg * @property {Visit[]} visits - All visits in this leg * @property {1|2|null} winner - Winner of the leg (null if in progress) */ /** * @typedef {Object} MatchConfig * @property {string} laneName - Lane identifier * @property {string} player1Name - Player 1 display name * @property {string} player2Name - Player 2 display name * @property {number} startingScore - Starting score (101-1001) * @property {number} bestOf - Number of legs to play (1-11) * @property {number} maxRounds - Maximum rounds before tiebreak */ /** * @typedef {Object} MatchState * @property {MatchConfig} config - Match configuration * @property {Leg[]} legs - All legs in the match * @property {number} player1Legs - Legs won by player 1 * @property {number} player2Legs - Legs won by player 2 * @property {1|2} currentPlayer - Current player's turn * @property {number} player1Score - Player 1's current remaining score * @property {number} player2Score - Player 2's current remaining score * @property {boolean} matchComplete - Whether match is finished * @property {1|2|null} matchWinner - Winner of the match */ /** @type {MatchState|null} */ let state = null; /** @type {string} */ let inputBuffer = ''; /** @type {number|null} - Index of visit being edited (null = new entry mode) */ let editingVisitIndex = null; /** @type {'tiebreak'|'starting'|null} - Current mode of the selection modal */ let selectionModalMode = null; // ============ // DOM Elements // ============ const elements = { // Screens scoringScreen: document.getElementById('scoring-screen'), endScreen: document.getElementById('end-screen'), // Config inputs (now in modal) // laneName removed - reserved for network mode player1Name: document.getElementById('player1-name'), player2Name: document.getElementById('player2-name'), startingScore: document.getElementById('starting-score'), startingScoreCustom: document.getElementById('starting-score-custom'), startingScoreCustomToggle: document.getElementById('starting-score-custom-toggle'), bestOf: document.getElementById('best-of'), maxRounds: document.getElementById('max-rounds'), startMatchBtn: document.getElementById('start-match-btn'), // Scoring display scoringHeader: document.getElementById('scoring-header'), player1Header: document.getElementById('player1-header'), player2Header: document.getElementById('player2-header'), legDisplay: document.getElementById('leg-display'), player1Anchor: document.getElementById('player1-anchor'), player2Anchor: document.getElementById('player2-anchor'), player1Score: document.getElementById('player1-score'), player2Score: document.getElementById('player2-score'), chalkboardBody: document.getElementById('chalkboard-body'), matchInfoBar: document.getElementById('match-info-bar'), // Modals configModal: document.getElementById('config-modal'), configCancelBtn: document.getElementById('config-cancel-btn'), settingsModal: document.getElementById('settings-modal'), settingsModalMessage: document.getElementById('settings-modal-message'), settingsCancelBtn: document.getElementById('settings-cancel-btn'), newMatchBtn: document.getElementById('new-match-btn'), networkModeBtn: document.getElementById('network-mode-btn'), networkModal: document.getElementById('network-modal'), networkLane: document.getElementById('network-lane'), networkStartBtn: document.getElementById('network-start-btn'), networkCancelBtn: document.getElementById('network-cancel-btn'), checkoutModal: document.getElementById('checkout-modal'), checkoutCancelBtn: document.getElementById('checkout-cancel-btn'), tiebreakModal: document.getElementById('tiebreak-modal'), tiebreakTitle: document.getElementById('tiebreak-title'), tiebreakMessage: document.getElementById('tiebreak-message'), tiebreakP1Btn: document.getElementById('tiebreak-p1'), tiebreakP2Btn: document.getElementById('tiebreak-p2'), tiebreakRandomBtn: document.getElementById('tiebreak-random'), tiebreakCancelBtn: document.getElementById('tiebreak-cancel-btn'), rematchBtn: document.getElementById('rematch-btn'), qrModeBtn: document.getElementById('qr-mode-btn'), qrScanModal: document.getElementById('qr-scan-modal'), qrScanVideo: document.getElementById('qr-scan-video'), qrScanCancelBtn: document.getElementById('qr-scan-cancel-btn'), qrScanError: document.getElementById('qr-scan-error'), qrScanHint: document.getElementById('qr-scan-hint'), qrImageInput: document.getElementById('qr-image-input'), qrImageLabel: document.getElementById('qr-image-label'), qrImageCanvas: document.getElementById('qr-image-canvas'), qrConfirmModal: document.getElementById('qr-confirm-modal'), qrConfirmDetails: document.getElementById('qr-confirm-details'), qrConfirmStartBtn: document.getElementById('qr-confirm-start-btn'), qrConfirmCancelBtn: document.getElementById('qr-confirm-cancel-btn'), confirmModal: document.getElementById('confirm-modal'), confirmTitle: document.getElementById('confirm-title'), confirmMessage: document.getElementById('confirm-message'), confirmYesBtn: document.getElementById('confirm-yes-btn'), confirmNoBtn: document.getElementById('confirm-no-btn'), // End screen winnerName: document.getElementById('winner-name'), finalScore: document.getElementById('final-score'), loserName: document.getElementById('loser-name'), statsPlayer1: document.getElementById('stats-player1'), statsPlayer2: document.getElementById('stats-player2'), endStatsBody: document.getElementById('end-stats-body'), endMatchInfo: document.getElementById('end-match-info'), endLegsContainer: document.getElementById('end-legs-container'), endResultQrBtn: document.getElementById('end-result-qr-btn'), endBackBtn: document.getElementById('end-back-btn'), matchCompleteBanner: document.getElementById('match-complete-banner'), // Result QR modal resultQrModal: document.getElementById('result-qr-modal'), resultQrTitle: document.getElementById('result-qr-title'), resultQrSubtitle: document.getElementById('result-qr-subtitle'), resultQrCode: document.getElementById('result-qr-code'), resultQrCloseBtn: document.getElementById('result-qr-close-btn'), // History historyScreen: document.getElementById('history-screen'), historyList: document.getElementById('history-list'), historyBackBtn: document.getElementById('history-back-btn'), // History Detail historyDetailScreen: document.getElementById('history-detail-screen'), historyDetailBackBtn: document.getElementById('history-detail-back-btn'), detailPlayer1: document.getElementById('detail-player1'), detailPlayer2: document.getElementById('detail-player2'), detailScore: document.getElementById('detail-score'), detailDate: document.getElementById('detail-date'), detailStatsP1: document.getElementById('detail-stats-p1'), detailStatsP2: document.getElementById('detail-stats-p2'), detailStatsBody: document.getElementById('detail-stats-body'), detailLegsContainer: document.getElementById('detail-legs-container'), historyDetailQrBtn: document.getElementById('history-detail-qr-btn'), // Keypad action buttons keyNew: document.getElementById('key-new'), keyHistory: document.getElementById('key-history'), keyStats: document.getElementById('key-stats'), // Live Stats screen statsScreen: document.getElementById('stats-screen'), statsBackBtn: document.getElementById('stats-back-btn'), statsPlayer1Name: document.getElementById('stats-player1-name'), statsPlayer2Name: document.getElementById('stats-player2-name'), statsLiveScore: document.getElementById('stats-live-score'), statsMatchInfo: document.getElementById('stats-match-info'), statsHeaderP1: document.getElementById('stats-header-p1'), statsHeaderP2: document.getElementById('stats-header-p2'), statsStatsBody: document.getElementById('stats-stats-body'), statsLegsContainer: document.getElementById('stats-legs-container') }; // =========== // Screen Flow // =========== /** * Show a specific screen * @param {'scoring'|'end'|'history'|'history-detail'|'stats'} screenName */ function showScreen(screenName) { elements.scoringScreen.classList.remove('active'); elements.endScreen.classList.remove('active'); elements.historyScreen.classList.remove('active'); elements.historyDetailScreen.classList.remove('active'); elements.statsScreen.classList.remove('active'); switch (screenName) { case 'scoring': elements.scoringScreen.classList.add('active'); break; case 'end': elements.endScreen.classList.add('active'); break; case 'history': elements.historyScreen.classList.add('active'); break; case 'history-detail': elements.historyDetailScreen.classList.add('active'); break; case 'stats': elements.statsScreen.classList.add('active'); break; } } /** * Show a modal * @param {HTMLElement} modal */ function showModal(modal) { modal.classList.add('active'); } /** * Hide a modal * @param {HTMLElement} modal */ function hideModal(modal) { modal.classList.remove('active'); } // ========== // Game Logic // ========== /** * Start a new match with current config */ async function startMatch() { const config = { laneName: '', // Reserved for network mode player1Name: elements.player1Name.value.trim() || 'Player 1', player2Name: elements.player2Name.value.trim() || 'Player 2', startingScore: elements.startingScoreCustomToggle.checked ? (parseInt(elements.startingScoreCustom.value, 10) || 501) : parseInt(elements.startingScore.value, 10), bestOf: parseInt(elements.bestOf.value, 10), maxRounds: parseInt(elements.maxRounds.value, 10) }; // Save settings for next time saveSettings(config); // Close config modal hideModal(elements.configModal); state = { config: config, legs: [], player1Legs: 0, player2Legs: 0, currentPlayer: 1, player1Score: config.startingScore, player2Score: config.startingScore, matchComplete: false, matchWinner: null, firstLegStarter: 1 // Will be set by selection modal }; // Update UI elements.player1Header.textContent = config.player1Name; elements.player2Header.textContent = config.player2Name; // Show starting player selection showStartingPlayerModal(); } /** * Complete match start after starting player selection * @param {number} starter - 1 or 2 */ function completeMatchStart(starter) { state.firstLegStarter = starter; // Start first leg startNewLeg(); updateDisplay(); showScreen('scoring'); saveCurrentMatch(); } /** * Get which player starts the current leg * Alternates based on firstLegStarter and leg number * @returns {number} 1 or 2 */ function getLegStarter() { const legIndex = state.legs.length - 1; // 0-indexed // First leg: firstLegStarter, then alternate if (legIndex % 2 === 0) { return state.firstLegStarter; } else { return state.firstLegStarter === 1 ? 2 : 1; } } /** * Start a new leg */ function startNewLeg() { state.legs.push({ startingScore: state.config.startingScore, visits: [], winner: null }); state.player1Score = state.config.startingScore; state.player2Score = state.config.startingScore; // Set current player based on leg starter state.currentPlayer = getLegStarter(); } /** * Record a visit (score entry) * @param {number} score - Points scored * @param {number} dartsUsed - Darts used (default 3) * @param {boolean} isCheckout - Whether this is a checkout */ function recordVisit(score, dartsUsed = 3, isCheckout = false) { // Clear input buffer before saving (prevents old input appearing after resume) inputBuffer = ''; const currentLeg = state.legs[state.legs.length - 1]; const remainingBefore = state.currentPlayer === 1 ? state.player1Score : state.player2Score; const visit = { player: state.currentPlayer, score: score, dartsUsed: dartsUsed, isCheckout: isCheckout, remainingBefore: remainingBefore }; currentLeg.visits.push(visit); // Update score if (state.currentPlayer === 1) { state.player1Score -= score; } else { state.player2Score -= score; } if (isCheckout) { completeLeg(); } else { // Switch player state.currentPlayer = state.currentPlayer === 1 ? 2 : 1; // Check for tie-break: max rounds reached by both players, no checkout const maxRounds = state.config.maxRounds; const p1Visits = currentLeg.visits.filter(v => v.player === 1).length; const p2Visits = currentLeg.visits.filter(v => v.player === 2).length; if (p1Visits >= maxRounds && p2Visits >= maxRounds && !currentLeg.winner) { updateDisplay(); showTiebreakModal(); return; } } updateDisplay(); saveCurrentMatch(); } /** * Called when match is won — saves to history and stays on scoreboard. * The operator can review the final state, correct a mistaken entry, * tap Stats to see full statistics, or tap NEW to start the next match. */ function onMatchComplete() { const stats = calculateStats(); const match = { config: state.config, legs: state.legs, player1Legs: state.player1Legs, player2Legs: state.player2Legs, matchWinner: state.matchWinner, firstLegStarter: state.firstLegStarter, stats: stats }; saveMatchToHistory(match); updateDisplay(); } /** * Complete the current leg */ function completeLeg() { const currentLeg = state.legs[state.legs.length - 1]; currentLeg.winner = state.currentPlayer; if (state.currentPlayer === 1) { state.player1Legs++; } else { state.player2Legs++; } // Check for match win const legsToWin = Math.ceil(state.config.bestOf / 2); if (state.player1Legs >= legsToWin) { state.matchComplete = true; state.matchWinner = 1; onMatchComplete(); } else if (state.player2Legs >= legsToWin) { state.matchComplete = true; state.matchWinner = 2; onMatchComplete(); } else { // Start next leg startNewLeg(); } } /** * Handle score submission * @param {number} score */ // Scores impossible to achieve with 3 darts const ILLEGAL_SCORES = [163, 166, 169, 172, 173, 175, 176, 178, 179]; // Checkouts impossible to finish on a double const ILLEGAL_CHECKOUTS = [159, 162, 163, 165, 166, 168, 169]; // Short leg thresholds by starting score format const SHORT_LEG_THRESHOLDS = { 101: 4, 201: 8, 301: 13, 401: 17, 501: 21, 601: 25, 701: 29, 801: 34, 901: 38, 1001: 42 }; // Short-leg threshold for a match: the Tournament Manager's configured value when the // match was assigned from TM (carried in the QR payload as `slt`), otherwise the // format-scaled default. Keeps the "Short Leg" badge in step with the achievement the // TM will award from the returned raw scores. function getShortLegThreshold(config) { if (config && config.shortLegThreshold != null) return config.shortLegThreshold; return SHORT_LEG_THRESHOLDS[config && config.startingScore] || 21; } /** * Check if a score is valid (achievable with 3 darts) * @param {number} score * @returns {boolean} */ function isValidScore(score) { if (score < 0 || score > 180) return false; if (ILLEGAL_SCORES.includes(score)) return false; return true; } /** * Check if a checkout score is valid (can be finished on a double) * @param {number} score * @returns {boolean} */ function isValidCheckout(score) { if (score > 170) return false; if (ILLEGAL_CHECKOUTS.includes(score)) return false; return true; } /** * Handle score submission * @param {number} score */ function submitScore(score) { const currentScore = state.currentPlayer === 1 ? state.player1Score : state.player2Score; const newScore = currentScore - score; // Validate score if (!isValidScore(score)) { // Invalid/impossible score return; } // Check for checkout if (newScore === 0) { // Validate checkout is possible (remaining score must be checkable) if (!isValidCheckout(currentScore)) { return; } // Checkout! Ask for darts used showCheckoutModal(score); } else if (newScore < 0 || newScore === 1) { // Bust (score exceeds remaining or leaves 1) - reject, user must enter 0 explicitly return; } else { // Normal score recordVisit(score); } clearInput(); } /** * Get minimum darts needed for a checkout * @param {number} score - Remaining score before checkout * @returns {number} 1, 2, or 3 */ function getMinDartsForCheckout(score) { // 1-dart: doubles (2-40) and bull (50) if ((score >= 2 && score <= 40 && score % 2 === 0) || score === 50) { return 1; } // 2-dart checkouts: 41-98, 100, 101, 104, 107, 110 const twoDartCheckouts = [100, 101, 104, 107, 110]; if ((score >= 41 && score <= 98) || twoDartCheckouts.includes(score)) { return 2; } // Everything else requires 3 darts return 3; } /** * Show checkout modal to ask for darts used * @param {number} checkoutScore */ function showCheckoutModal(checkoutScore) { const currentScore = state.currentPlayer === 1 ? state.player1Score : state.player2Score; const minDarts = getMinDartsForCheckout(currentScore); // Store the checkout score for when we get the darts count elements.checkoutModal.dataset.checkoutScore = checkoutScore; // Show/hide dart buttons based on minimum darts needed const dartButtons = elements.checkoutModal.querySelectorAll('.btn-dart'); dartButtons.forEach(btn => { const darts = parseInt(btn.dataset.darts, 10); btn.style.display = darts >= minDarts ? 'block' : 'none'; }); showModal(elements.checkoutModal); } /** * Show starting player selection modal */ function showStartingPlayerModal() { selectionModalMode = 'starting'; const p1Name = state.config.player1Name; const p2Name = state.config.player2Name; // Set modal content for starting player selection elements.tiebreakTitle.textContent = 'Starting Player'; elements.tiebreakMessage.textContent = 'Select who throws first:'; // Player 1 shown first (as registered) elements.tiebreakP1Btn.textContent = p1Name; elements.tiebreakP2Btn.textContent = p2Name; elements.tiebreakP1Btn.dataset.player = '1'; elements.tiebreakP2Btn.dataset.player = '2'; // Show random button, hide cancel button elements.tiebreakRandomBtn.style.display = 'block'; elements.tiebreakCancelBtn.style.display = 'none'; showModal(elements.tiebreakModal); } /** * Show tie-break modal when max rounds reached without checkout */ function showTiebreakModal() { selectionModalMode = 'tiebreak'; const legStarter = getLegStarter(); const p1Name = state.config.player1Name; const p2Name = state.config.player2Name; // Set modal content for tie-break elements.tiebreakTitle.textContent = 'Tie-Break'; elements.tiebreakMessage.textContent = 'Max rounds reached. Each player throws 3 darts - highest score wins.'; // Show leg starter first if (legStarter === 1) { elements.tiebreakP1Btn.textContent = p1Name; elements.tiebreakP2Btn.textContent = p2Name; elements.tiebreakP1Btn.dataset.player = '1'; elements.tiebreakP2Btn.dataset.player = '2'; } else { elements.tiebreakP1Btn.textContent = p2Name; elements.tiebreakP2Btn.textContent = p1Name; elements.tiebreakP1Btn.dataset.player = '2'; elements.tiebreakP2Btn.dataset.player = '1'; } // Hide random button, show cancel button elements.tiebreakRandomBtn.style.display = 'none'; elements.tiebreakCancelBtn.style.display = 'block'; showModal(elements.tiebreakModal); } /** * Handle player selection from modal * @param {number} buttonIndex - 1, 2, or 'random' */ function handleSelectionModalChoice(buttonIndex) { let player; if (buttonIndex === 'random') { player = Math.random() < 0.5 ? 1 : 2; } else { player = parseInt(elements[`tiebreakP${buttonIndex}Btn`].dataset.player, 10); } hideModal(elements.tiebreakModal); if (selectionModalMode === 'starting') { completeMatchStart(player); } else if (selectionModalMode === 'tiebreak') { completeTiebreak(player); } selectionModalMode = null; } /** * Complete tie-break with winner * @param {number} player - 1 or 2 */ function completeTiebreak(player) { const currentLeg = state.legs[state.legs.length - 1]; currentLeg.winner = player; currentLeg.tiebreak = true; // Mark as tie-break win // Update leg scores if (player === 1) { state.player1Legs++; } else { state.player2Legs++; } // Check for match end or start new leg const legsToWin = Math.ceil(state.config.bestOf / 2); if (state.player1Legs >= legsToWin || state.player2Legs >= legsToWin) { state.matchComplete = true; state.matchWinner = state.player1Legs >= legsToWin ? 1 : 2; onMatchComplete(); } else { startNewLeg(); updateDisplay(); } saveCurrentMatch(); } /** * Cancel tie-break modal and return to chalkboard * Allows referee to correct a score before declaring tie-break winner */ function cancelTiebreak() { hideModal(elements.tiebreakModal); selectionModalMode = null; // Stay on current leg - referee can tap a score to edit it. // The board is full, so there is no new-entry cell: move input to the // last recorded visit (edit mode) so typing stays within rendered rows // instead of landing in an invisible row beyond maxRounds. const currentLeg = state.legs[state.legs.length - 1]; if (currentLeg && currentLeg.visits.length > 0) { startEditingVisit(currentLeg.visits.length - 1); } } /** * Complete checkout with dart count * @param {number} dartsUsed */ function completeCheckout(dartsUsed) { const checkoutScore = parseInt(elements.checkoutModal.dataset.checkoutScore, 10); const editVisitIdx = elements.checkoutModal.dataset.editVisitIndex; hideModal(elements.checkoutModal); // Clear the edit visit index from modal data delete elements.checkoutModal.dataset.editVisitIndex; if (editVisitIdx !== undefined && editVisitIdx !== '') { // This is an edit that resulted in checkout completeEditCheckout(checkoutScore, parseInt(editVisitIdx, 10), dartsUsed); } else { // Normal new checkout recordVisit(checkoutScore, dartsUsed, true); } } /** * Cancel checkout and return to scoring */ function cancelCheckout() { hideModal(elements.checkoutModal); // Clear any stored data delete elements.checkoutModal.dataset.checkoutScore; delete elements.checkoutModal.dataset.editVisitIndex; // Clear input and re-render inputBuffer = ''; editingVisitIndex = null; renderChalkboard(); } /** * Complete an edit that results in a checkout * @param {number} checkoutScore * @param {number} visitIdx * @param {number} dartsUsed */ function completeEditCheckout(checkoutScore, visitIdx, dartsUsed) { const currentLeg = state.legs[state.legs.length - 1]; if (!currentLeg || visitIdx < 0 || visitIdx >= currentLeg.visits.length) return; const visit = currentLeg.visits[visitIdx]; // Update the visit to be a checkout visit.score = checkoutScore; visit.dartsUsed = dartsUsed; visit.isCheckout = true; // Remove all visits that came after this checkout // (opponent may have entered scores before the edit was made) if (visitIdx < currentLeg.visits.length - 1) { currentLeg.visits.splice(visitIdx + 1); } // Set the leg winner currentLeg.winner = visit.player; if (visit.player === 1) { state.player1Legs++; } else { state.player2Legs++; } // Check for match win const legsToWin = Math.ceil(state.config.bestOf / 2); if (state.player1Legs >= legsToWin || state.player2Legs >= legsToWin) { state.matchComplete = true; state.matchWinner = state.player1Legs >= legsToWin ? 1 : 2; } // Recalculate scores recalculateScores(); // Exit edit mode editingVisitIndex = null; inputBuffer = ''; // Save to history or start next leg if (state.matchComplete) { onMatchComplete(); } else { startNewLeg(); updateDisplay(); } saveCurrentMatch(); } /** * Undo the last visit */ function undoLastVisit() { if (!state || state.legs.length === 0) return; let currentLeg = state.legs[state.legs.length - 1]; // If current leg is empty and there are previous legs, go back if (currentLeg.visits.length === 0 && state.legs.length > 1) { // Remove current leg and restore previous leg state.legs.pop(); currentLeg = state.legs[state.legs.length - 1]; // Undo the leg completion if (currentLeg.winner === 1) { state.player1Legs--; } else if (currentLeg.winner === 2) { state.player2Legs--; } currentLeg.winner = null; // Clear tie-break flag so a leg later won normally isn't reported as tie-break currentLeg.tiebreak = false; // The last visit was a checkout, undo it if (currentLeg.visits.length > 0) { const lastVisit = currentLeg.visits.pop(); restoreFromVisit(lastVisit); } } else if (currentLeg.visits.length > 0) { // Normal undo within current leg const lastVisit = currentLeg.visits.pop(); restoreFromVisit(lastVisit); } // Recalculate both players' scores from the visit log // (restoreFromVisit only restores the undone player's score, // which leaves the opponent wrong after stepping back a leg) recalculateScores(); state.matchComplete = false; state.matchWinner = null; updateDisplay(); showScreen('scoring'); saveCurrentMatch(); } /** * Restore state from undone visit * @param {Visit} visit */ function restoreFromVisit(visit) { if (visit.player === 1) { state.player1Score = visit.remainingBefore; } else { state.player2Score = visit.remainingBefore; } state.currentPlayer = visit.player; } /** @type {Function|null} */ let pendingConfirmAction = null; /** * Show confirmation modal * @param {string} title * @param {string} message * @param {Function} onConfirm */ function showConfirm(title, message, onConfirm) { elements.confirmTitle.textContent = title; elements.confirmMessage.textContent = message; pendingConfirmAction = onConfirm; showModal(elements.confirmModal); } /** * Handle confirm yes */ function handleConfirmYes() { hideModal(elements.confirmModal); if (pendingConfirmAction) { pendingConfirmAction(); pendingConfirmAction = null; } } /** * Handle confirm no */ function handleConfirmNo() { hideModal(elements.confirmModal); pendingConfirmAction = null; } /** * Handle Rematch button from New Match modal * Starts new match with same config (no additional confirmation needed) * Rebuilds from the active match config, not the form — after a * QR-assigned match the form still holds older manual values. */ function handleRematchFromModal() { hideModal(elements.settingsModal); if (state && state.config) { // Strip QR identity fields (matchId/tournamentId/serverId) so a // casual rematch isn't reported as the tournament match const { matchId, tournamentId, serverId, ...config } = state.config; saveSettings(config); state = { config: config, legs: [], player1Legs: 0, player2Legs: 0, currentPlayer: 1, player1Score: config.startingScore, player2Score: config.startingScore, matchComplete: false, matchWinner: null, firstLegStarter: 1 // Will be set by selection modal }; // Update UI elements.player1Header.textContent = config.player1Name; elements.player2Header.textContent = config.player2Name; // Show starting player selection showStartingPlayerModal(); } else { startMatch(); } } /** * Handle New Match button from New Match modal * Opens config modal to change settings */ function handleNewMatchFromModal() { hideModal(elements.settingsModal); showModal(elements.configModal); } /** * Handle Network button from New Match modal * Opens network modal for lane selection (future feature) */ function handleNetworkModeFromModal() { hideModal(elements.settingsModal); loadNetworkLane(); showModal(elements.networkModal); } // ================= // QR Scanning Flow // ================= /** @type {object|null} - Parsed and verified QR assignment payload pending confirmation */ let qrPayload = null; /** @type {object|null} - Match currently shown in history detail (for Result QR) */ let currentHistoryMatch = null; /** @type {number|null} - setInterval handle for QR scan loop */ let qrScanInterval = null; /** * Detect iOS/iPadOS. iPadOS 13+ reports as "Macintosh" in the UA, * so we check for touch support on Mac platforms to catch iPads. * @returns {boolean} */ function isIOS() { return /iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); } /** * Scan a video element for QR codes using native BarcodeDetector. * The Chalker runs on mobile/tablet where BarcodeDetector is available. * * NOTE: The TM (js/qr-bridge.js) has its own detectQRCode/isQRScanAvailable. * They INTENTIONALLY DIFFER and are not kept in sync: the Chalker uses an iOS * image-capture path (isIOS); the TM copy has a jsQR fallback. Do not "unify" them. * @param {HTMLVideoElement} videoEl * @returns {Promise} decoded QR string, or null */ async function detectQRCode(videoEl) { if (!('BarcodeDetector' in window)) return null; const detector = detectQRCode._detector || (detectQRCode._detector = new BarcodeDetector({ formats: ['qr_code'] })); const codes = await detector.detect(videoEl); return codes.length > 0 ? codes[0].rawValue : null; } /** * Check if QR scanning is available. * On iOS, image capture is always available (no BarcodeDetector needed). * On other platforms, requires native BarcodeDetector. * @returns {boolean} */ function isQRScanAvailable() { return isIOS() || 'BarcodeDetector' in window; } /** * Handle QR button from New Match modal. * Opens camera immediately in scan modal. */ function handleQRModeFromModal() { hideModal(elements.settingsModal); startQRScanner(); } /** * Start QR scanner. On iOS, uses image capture; on other platforms, uses live camera. * Shows qr-scan-modal; on successful scan calls handleQRPayload(). */ async function startQRScanner() { elements.qrScanError.style.display = 'none'; elements.qrScanError.textContent = ''; if (isIOS()) { startImageCapture(); return; } if (!isQRScanAvailable()) { elements.qrScanError.textContent = 'QR scanning is not available. Camera requires Chrome, Edge, or a secure context (HTTPS).'; elements.qrScanError.style.display = 'block'; showModal(elements.qrScanModal); return; } try { const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' } } }); elements.qrScanVideo.srcObject = stream; showModal(elements.qrScanModal); qrScanInterval = setInterval(async () => { try { const result = await detectQRCode(elements.qrScanVideo); if (result) handleQRPayload(result); } catch (_) { /* ignore individual frame errors */ } }, 300); } catch (err) { elements.qrScanError.textContent = `Camera error: ${err.name} — ${err.message}`; elements.qrScanError.style.display = 'block'; showModal(elements.qrScanModal); } } /** * Start image capture flow for iOS/iPadOS. * Opens native camera via file input, decodes captured image with jsQR. */ function startImageCapture() { elements.qrScanVideo.style.display = 'none'; elements.qrScanHint.textContent = 'Tap the button below to take a photo of the QR code.'; elements.qrScanError.style.display = 'none'; // Show styled label button — tapping it triggers the hidden file input (iOS user gesture requirement) elements.qrImageLabel.style.display = ''; elements.qrImageInput.value = ''; elements.qrImageInput.onchange = async (e) => { const file = e.target.files[0]; if (!file) return; elements.qrScanHint.textContent = 'Decoding...'; elements.qrScanError.style.display = 'none'; try { const result = await decodeImageQR(file); if (result) { handleQRPayload(result); } else { showQRScanError('Could not read QR code. Try filling the frame and avoid glare.'); elements.qrScanHint.textContent = 'Tap the button to try again.'; elements.qrImageInput.value = ''; } } catch (err) { showQRScanError('Image decode error: ' + (err.message || err)); elements.qrScanHint.textContent = 'Tap the button to try again.'; elements.qrImageInput.value = ''; } }; showModal(elements.qrScanModal); } /** * Decode a QR code from an image file. * Tries decoders in order: QrScanner (ZXing-based, handles perspective), * native BarcodeDetector (Safari 15.4+, Chrome, Edge), then jsQR as * last resort with full resolution and a 2000px downscaled retry. * @param {File} file - captured image file * @returns {Promise} decoded QR string, or null */ function decodeImageQR(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const img = new Image(); img.onload = async () => { // Try QrScanner (ZXing-based — handles perspective, glare, angles) // Always downscale to max 1500px — faster and more reliable than full-res if (typeof QrScanner !== 'undefined' && QrScanner.scanImage) { try { const canvas = elements.qrImageCanvas; const ctx = canvas.getContext('2d'); const maxDim = 1500; let w = img.width; let h = img.height; if (Math.max(w, h) > maxDim) { const scale = maxDim / Math.max(w, h); w = Math.round(w * scale); h = Math.round(h * scale); } canvas.width = w; canvas.height = h; ctx.drawImage(img, 0, 0, w, h); const qrResult = await QrScanner.scanImage(canvas, { returnDetailedScanResult: true, alsoTryWithoutScanRegion: true }); if (qrResult && qrResult.data) { console.log('QR decoded via QrScanner (' + w + 'x' + h + ')'); resolve(qrResult.data); return; } } catch (e) { console.log('QrScanner: no decode (' + img.width + 'x' + img.height + ')'); } } const canvas = elements.qrImageCanvas; const ctx = canvas.getContext('2d'); // Fallback: jsQR at multiple resolutions /** * Try jsQR at a given resolution. * @param {number} maxDim - max longest side (0 = native resolution) * @returns {string|null} */ function tryJsQR(maxDim) { let w = img.width; let h = img.height; if (maxDim && (w > maxDim || h > maxDim)) { const scale = maxDim / Math.max(w, h); w = Math.round(w * scale); h = Math.round(h * scale); } canvas.width = w; canvas.height = h; ctx.drawImage(img, 0, 0, w, h); const imageData = ctx.getImageData(0, 0, w, h); const code = jsQR(imageData.data, w, h); if (code) console.log('QR decoded via jsQR (' + w + 'x' + h + ')'); return code ? code.data : null; } let result = tryJsQR(0); if (!result && Math.max(img.width, img.height) > 2000) { result = tryJsQR(2000); } resolve(result); }; img.onerror = () => reject(new Error('Could not load image')); img.src = reader.result; }; reader.onerror = () => reject(new Error('Could not read file')); reader.readAsDataURL(file); }); } /** * Stop camera stream and scan interval. */ function stopQRScanner() { clearInterval(qrScanInterval); qrScanInterval = null; if (elements.qrScanVideo.srcObject) { elements.qrScanVideo.srcObject.getTracks().forEach(t => t.stop()); elements.qrScanVideo.srcObject = null; } // Reset image capture state elements.qrScanVideo.style.display = ''; elements.qrScanHint.textContent = 'Point the camera at the QR code on the Tournament Manager screen.'; elements.qrImageInput.value = ''; elements.qrImageInput.onchange = null; elements.qrImageLabel.style.display = 'none'; } /** * Handle a raw QR scan result. * Parses JSON, verifies CRC, checks payload type, then shows confirmation. * On failure, shows error in scan modal and keeps camera open. * @param {string} rawValue */ function handleQRPayload(rawValue) { let payload; try { payload = JSON.parse(rawValue); } catch (_) { showQRScanError('Invalid QR code. Please scan a NewTon assignment QR.'); return; } if (payload.t !== 'a') { showQRScanError('Wrong QR type. Please scan a match assignment QR from the Tournament Manager.'); return; } if (!NewtonIntegrity.verify(payload)) { showQRScanError('QR integrity check failed. The code may be corrupted — please try again.'); return; } // Valid — stop camera and show confirmation stopQRScanner(); hideModal(elements.qrScanModal); qrPayload = payload; showQRConfirm(payload); } /** * Show an error in the scan modal without closing it. * @param {string} message */ function showQRScanError(message) { elements.qrScanError.textContent = message; elements.qrScanError.style.display = 'block'; } /** * Populate and show the QR confirmation modal. * @param {object} payload - Verified assignment payload */ function showQRConfirm(payload) { const lines = [ `${escapeHtml(payload.p1)} vs ${escapeHtml(payload.p2)}`, `${escapeHtml(payload.sc)} · Bo${escapeHtml(payload.bo)} · ${payload.mr >= 100 ? 'Unlimited' : escapeHtml(payload.mr) + ' rounds'}`, `Lane ${escapeHtml(payload.ln || 'Not set')} · Ref: ${escapeHtml(payload.ref || 'Not set')}` ]; elements.qrConfirmDetails.innerHTML = lines.map(l => `

${l}

`).join(''); showModal(elements.qrConfirmModal); } /** * Start a match from the confirmed QR assignment payload. * Builds match state directly from payload, bypassing the config form. */ function startMatchFromQR() { const payload = qrPayload; qrPayload = null; hideModal(elements.qrConfirmModal); const config = { player1Name: payload.p1, player2Name: payload.p2, startingScore: payload.sc, shortLegThreshold: payload.slt, bestOf: payload.bo, maxRounds: payload.mr, laneName: payload.ln ? `Lane ${payload.ln}` : '', matchId: payload.mid, tournamentId: payload.tid, serverId: payload.sid, lane: payload.ln, referee: payload.ref }; saveSettings(config); state = { config, legs: [], player1Legs: 0, player2Legs: 0, currentPlayer: 1, player1Score: config.startingScore, player2Score: config.startingScore, matchComplete: false, matchWinner: null, firstLegStarter: 1 }; elements.player1Header.textContent = config.player1Name; elements.player2Header.textContent = config.player2Name; showStartingPlayerModal(); } /** * Cancel QR scan modal — stop camera and return to idle. */ function cancelQRScan() { stopQRScanner(); hideModal(elements.qrScanModal); } /** * Cancel QR confirmation — discard payload and return to idle. */ function cancelQRConfirm() { qrPayload = null; hideModal(elements.qrConfirmModal); } /** * Load saved network lane selection */ async function loadNetworkLane() { try { const saved = await dbGet('settings', 'networkLane'); if (saved && saved.lane) { elements.networkLane.value = saved.lane; } } catch (e) { console.warn('Failed to load network lane:', e); } } /** * Save network lane selection (for future use) * @param {string} lane - Lane number */ async function saveNetworkLane(lane) { try { await dbPut('settings', { id: 'networkLane', lane: lane }); } catch (e) { console.warn('Failed to save network lane:', e); } } /** * Start network mode - show scoring screen in waiting state */ async function startNetworkMode() { const lane = elements.networkLane.value; await saveNetworkLane(lane); // Clear any active match state = null; await clearCurrentMatch(); // Set network waiting state networkWaitingLane = lane; // Hide modal and show scoring screen hideModal(elements.networkModal); showScreen('scoring'); updateDisplayForNetworkWaiting(); // Wake the network client, if one is loaded if (window.NewtonNetwork && typeof window.NewtonNetwork.start === 'function') { window.NewtonNetwork.start(lane); } } /** @type {string|null} - Lane number when waiting for network match */ let networkWaitingLane = null; /** * Update display for network waiting state */ function updateDisplayForNetworkWaiting() { // Update info bar with lane and waiting status elements.matchInfoBar.textContent = `Lane ${networkWaitingLane} • Waiting...`; // Show network mode styling elements.scoringHeader.classList.add('network-mode'); // Show idle-style placeholders elements.player1Score.textContent = '---'; elements.player2Score.textContent = '---'; elements.player1Score.classList.add('idle'); elements.player2Score.classList.add('idle'); // Reset leg display elements.legDisplay.textContent = '0 - 0'; // Show placeholder player names elements.player1Header.textContent = '---'; elements.player2Header.textContent = '---'; // Remove active state from anchors elements.player1Anchor.classList.remove('active'); elements.player2Anchor.classList.remove('active'); // Clear chalkboard document.getElementById('chalk-tbody').innerHTML = ''; // Disable keypad updateKeypadState(); } /** * Start a rematch (same config) - used from end screen */ function rematch() { startMatch(); } /** * Show config modal for new match */ function showConfigModal() { showModal(elements.configModal); } /** * Show new match modal (Rematch / New Match options) */ function showSettingsModal() { const versionEl = document.getElementById('chalker-version'); if (versionEl) versionEl.textContent = 'v' + CHALKER_VERSION; showModal(elements.settingsModal); } // ======= // Display // ======= /** * Update display for idle state (no match) */ function updateIdleDisplay() { // Remove network mode styling elements.scoringHeader.classList.remove('network-mode'); // Hide match complete banner elements.matchCompleteBanner.style.display = 'none'; // Show idle scores elements.player1Score.textContent = '---'; elements.player2Score.textContent = '---'; elements.player1Score.classList.add('idle'); elements.player2Score.classList.add('idle'); // Reset leg display elements.legDisplay.textContent = '0 - 0'; // Reset player names to default or last used elements.player1Header.textContent = elements.player1Name.value || 'Player 1'; elements.player2Header.textContent = elements.player2Name.value || 'Player 2'; // Remove active state from anchors elements.player1Anchor.classList.remove('active'); elements.player2Anchor.classList.remove('active'); // Clear chalkboard document.getElementById('chalk-tbody').innerHTML = ''; // Show hint in info bar elements.matchInfoBar.textContent = 'Tap NEW to start'; // Disable numpad keys updateKeypadState(); } /** * Update keypad enabled/disabled state */ function updateKeypadState() { const isIdle = !state || state.matchComplete; // Disable numpad keys when idle, but keep action buttons enabled document.querySelectorAll('.keypad .key').forEach(key => { key.disabled = isIdle; }); // Action buttons are always enabled document.querySelectorAll('.keypad-actions .key').forEach(key => { key.disabled = false; }); } /** * Update all display elements */ function updateDisplay() { if (!state) { updateIdleDisplay(); return; } // Remove idle/network state elements.player1Score.classList.remove('idle'); elements.player2Score.classList.remove('idle'); elements.scoringHeader.classList.remove('network-mode'); // Update anchor scores elements.player1Score.textContent = state.player1Score; elements.player2Score.textContent = state.player2Score; // Update leg display elements.legDisplay.textContent = `${state.player1Legs} - ${state.player2Legs}`; // Update match info bar const infoParts = []; if (state.config.laneName) infoParts.push(state.config.laneName); infoParts.push(`Leg ${state.legs.length} of ${state.config.bestOf}`); if (state.config.referee) infoParts.push(`Ref: ${state.config.referee}`); elements.matchInfoBar.textContent = infoParts.join(' • '); // Update active player indicator elements.player1Anchor.classList.toggle('active', state.currentPlayer === 1); elements.player2Anchor.classList.toggle('active', state.currentPlayer === 2); // Enable keypad updateKeypadState(); // Show match complete banner or hide it if (state.matchComplete) { const winnerName = state.matchWinner === 1 ? state.config.player1Name : state.config.player2Name; elements.matchCompleteBanner.textContent = `${winnerName} wins! Tap STATS to review or NEW to start another match.`; elements.matchCompleteBanner.style.display = ''; } else { elements.matchCompleteBanner.style.display = 'none'; } // Render chalkboard renderChalkboard(); } /** * Render the chalkboard ledger for current leg * Pre-renders all rows based on maxRounds, fills in scores as available * Shows current input in the active cell (either new entry or editing) */ function renderChalkboard() { const currentLeg = state.legs[state.legs.length - 1]; if (!currentLeg) return; const maxRounds = state.config.maxRounds; const legStarter = getLegStarter(); // Build data from visits, tracking visit indices for each cell const roundData = []; let p1Score = state.config.startingScore; let p2Score = state.config.startingScore; // Process visits into rounds const visits = currentLeg.visits; let visitIndex = 0; for (let roundNum = 0; roundNum < maxRounds; roundNum++) { const round = { p1Scored: '', p1ToGo: '', p1VisitIndex: -1, p2Scored: '', p2ToGo: '', p2VisitIndex: -1 }; // Determine who throws first this round based on leg starter const firstThisRound = legStarter; const secondThisRound = legStarter === 1 ? 2 : 1; // Check for first thrower's visit const firstVisit = visits[visitIndex]; if (firstVisit && firstVisit.player === firstThisRound) { if (firstThisRound === 1) { p1Score -= firstVisit.score; round.p1Scored = firstVisit.score; round.p1ToGo = p1Score; round.p1VisitIndex = visitIndex; } else { p2Score -= firstVisit.score; round.p2Scored = firstVisit.score; round.p2ToGo = p2Score; round.p2VisitIndex = visitIndex; } visitIndex++; // Check for second thrower's visit in same round const secondVisit = visits[visitIndex]; if (secondVisit && secondVisit.player === secondThisRound) { if (secondThisRound === 1) { p1Score -= secondVisit.score; round.p1Scored = secondVisit.score; round.p1ToGo = p1Score; round.p1VisitIndex = visitIndex; } else { p2Score -= secondVisit.score; round.p2Scored = secondVisit.score; round.p2ToGo = p2Score; round.p2VisitIndex = visitIndex; } visitIndex++; } } roundData.push(round); } // Determine which cell is the active input cell const totalVisits = visits.length; const activeRound = Math.floor(totalVisits / 2); const activePlayer = state.currentPlayer; // Build all rows let html = ''; // Rows for each round (no starting score row - info bar shows variant) for (let r = 0; r < maxRounds; r++) { const dartCount = (r + 1) * 3; const data = roundData[r]; const isActiveRow = r === activeRound && !currentLeg.winner && editingVisitIndex === null; // Determine if P1 or P2 cell is active (new entry mode) const p1IsNewEntry = isActiveRow && activePlayer === 1 && data.p1Scored === ''; const p2IsNewEntry = isActiveRow && activePlayer === 2 && data.p2Scored === ''; // Determine if cell is being edited const p1IsEditing = editingVisitIndex !== null && data.p1VisitIndex === editingVisitIndex; const p2IsEditing = editingVisitIndex !== null && data.p2VisitIndex === editingVisitIndex; // Content for cells - wrap tons in span for ring styling let p1Content, p2Content; if (p1IsNewEntry || p1IsEditing) { p1Content = inputBuffer || ''; } else { const p1Num = parseInt(data.p1Scored, 10); p1Content = p1Num >= 100 ? `${data.p1Scored}` : data.p1Scored; } if (p2IsNewEntry || p2IsEditing) { p2Content = inputBuffer || ''; } else { const p2Num = parseInt(data.p2Scored, 10); p2Content = p2Num >= 100 ? `${data.p2Scored}` : data.p2Scored; } // Classes for cells const p1IsActive = p1IsNewEntry || p1IsEditing; const p2IsActive = p2IsNewEntry || p2IsEditing; const p1Class = p1IsActive ? 'input-active' : getScoreClass(data.p1Scored); const p2Class = p2IsActive ? 'input-active' : getScoreClass(data.p2Scored); // Row is empty if no scores and not the active row const hasData = data.p1Scored !== '' || data.p2Scored !== '' || p1IsActive || p2IsActive; let rowClass = hasData ? '' : 'chalk-row-empty'; // Tiebreak warning gradient for last 3 rows const roundsFromEnd = maxRounds - r; if (roundsFromEnd <= 3) { rowClass += ` tiebreak-warning-${roundsFromEnd}`; } // Show To Go values only if there's data in that column const p1ToGo = data.p1ToGo !== '' ? data.p1ToGo : ''; const p2ToGo = data.p2ToGo !== '' ? data.p2ToGo : ''; // Add data-visit attribute for tap-to-edit on recorded scores const p1DataAttr = data.p1VisitIndex >= 0 && !p1IsActive ? `data-visit="${data.p1VisitIndex}"` : ''; const p2DataAttr = data.p2VisitIndex >= 0 && !p2IsActive ? `data-visit="${data.p2VisitIndex}"` : ''; html += ` ${p1Content} ${p1ToGo} ${dartCount} ${p2Content} ${p2ToGo} `; } document.getElementById('chalk-tbody').innerHTML = html; // Add click handlers for tap-to-edit elements.chalkboardBody.querySelectorAll('[data-visit]').forEach(cell => { cell.addEventListener('click', () => { const visitIdx = parseInt(cell.dataset.visit, 10); startEditingVisit(visitIdx); }); }); // Auto-scroll to keep active input cell visible const activeCell = elements.chalkboardBody.querySelector('.input-active'); if (activeCell) { activeCell.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } // Mark leg starter in header elements.player1Header.classList.toggle('leg-starter', legStarter === 1); elements.player2Header.classList.toggle('leg-starter', legStarter === 2); } /** * Start editing a specific visit * @param {number} visitIdx - Index of visit to edit */ function startEditingVisit(visitIdx) { if (state && state.matchComplete) return; const currentLeg = state.legs[state.legs.length - 1]; if (!currentLeg || visitIdx < 0 || visitIdx >= currentLeg.visits.length) return; editingVisitIndex = visitIdx; // Pre-populate input buffer with existing score (like Excel) inputBuffer = String(currentLeg.visits[visitIdx].score); renderChalkboard(); } /** * Cancel editing and return to normal mode */ function cancelEditing() { editingVisitIndex = null; inputBuffer = ''; renderChalkboard(); } /** * Get CSS class for score highlighting * @param {number|string} score * @returns {string} */ function getScoreClass(score) { if (score === '' || score === undefined) return ''; const num = parseInt(score, 10); if (num === 180) return 'ton80'; if (num >= 100) return 'ton'; return ''; } /** * Format a visit for scoresheet display (scored, left, dart count) * Single source of truth for visit rendering across all contexts * @param {Visit} visit - The visit to format * @param {number} cumulativeDarts - Running dart total after this visit * @returns {{scored: string, left: string|number, dartDisplay: string|number}} */ function formatVisitDisplay(visit) { if (visit.isCheckout) { return { scored: `x${visit.dartsUsed || 3}`, left: '' }; } if (visit.score === 180) { return { scored: `${visit.score}`, left: visit.remainingBefore - visit.score }; } if (visit.score >= 100) { return { scored: `${visit.score}`, left: visit.remainingBefore - visit.score }; } return { scored: visit.score, left: visit.remainingBefore - visit.score }; } /** * Clear the input buffer */ function clearInput() { inputBuffer = ''; renderChalkboard(); } /** * Update input display (re-renders chalkboard to show input in active cell) */ function updateInputDisplay() { renderChalkboard(); } // ========== // Statistics // ========== /** * Calculate all match statistics * @returns {Object} Statistics object */ function calculateStats() { const shortLegThreshold = getShortLegThreshold(state.config); function makePlayerStats() { return { score60plus: 0, // Visits scoring 60+ score80plus: 0, // Visits scoring 80+ tons: 0, // Visits scoring 100+ score120plus: 0, // Visits scoring 120+ ton40s: 0, // Visits scoring 140+ score170plus: 0, // Visits scoring 170+ ton80s: 0, // Visits scoring 180 highFinish: 0, // Highest checkout score bestLeg: null, // Fewest darts in a won leg worstLeg: null, // Most darts in a won leg shortLegs: [], // Array of dart counts for short legs highOuts: [], // Array of checkout scores >= 100 first9Scores: [], totalScore: 0, totalDarts: 0 }; } const stats = { player1: makePlayerStats(), player2: makePlayerStats(), legs: [] }; state.legs.forEach((leg, legIndex) => { const legStats = { player1: { score: 0, darts: 0, visits: 0, checkoutScore: null, tons: 0, ton80s: 0 }, player2: { score: 0, darts: 0, visits: 0, checkoutScore: null, tons: 0, ton80s: 0 } }; leg.visits.forEach((visit) => { const playerStats = visit.player === 1 ? stats.player1 : stats.player2; const playerLegStats = visit.player === 1 ? legStats.player1 : legStats.player2; // Score range counts (cumulative) if (visit.score >= 60) playerStats.score60plus++; if (visit.score >= 80) playerStats.score80plus++; if (visit.score >= 100) { playerStats.tons++; playerLegStats.tons++; } if (visit.score >= 120) playerStats.score120plus++; if (visit.score >= 140) playerStats.ton40s++; if (visit.score >= 170) playerStats.score170plus++; if (visit.score === 180) { playerStats.ton80s++; playerLegStats.ton80s++; } // Track checkout score if (visit.isCheckout) { playerLegStats.checkoutScore = visit.remainingBefore; // High finish tracking if (visit.remainingBefore > playerStats.highFinish) { playerStats.highFinish = visit.remainingBefore; } // 100+ finishes if (visit.remainingBefore >= 100) { playerStats.highOuts.push(visit.remainingBefore); } } // First 9 (first 3 visits per player per leg) if (playerLegStats.visits < 3) { playerStats.first9Scores.push(visit.score); } // Accumulate for averages playerLegStats.score += visit.score; playerLegStats.darts += visit.dartsUsed; playerLegStats.visits++; playerStats.totalScore += visit.score; playerStats.totalDarts += visit.dartsUsed; }); // Calculate leg averages const p1Avg = legStats.player1.darts > 0 ? (legStats.player1.score / legStats.player1.darts) * 3 : null; const p2Avg = legStats.player2.darts > 0 ? (legStats.player2.score / legStats.player2.darts) * 3 : null; // Track short legs, best/worst legs (winner only) if (leg.winner) { const winnerLegStats = leg.winner === 1 ? legStats.player1 : legStats.player2; const winnerStats = leg.winner === 1 ? stats.player1 : stats.player2; if (winnerLegStats.darts <= shortLegThreshold) { winnerStats.shortLegs.push(winnerLegStats.darts); } if (winnerStats.bestLeg === null || winnerLegStats.darts < winnerStats.bestLeg) { winnerStats.bestLeg = winnerLegStats.darts; } if (winnerStats.worstLeg === null || winnerLegStats.darts > winnerStats.worstLeg) { winnerStats.worstLeg = winnerLegStats.darts; } } // Store per-leg stats for display stats.legs.push({ leg: legIndex + 1, winner: leg.winner, tiebreak: leg.tiebreak || false, player1: { avg: p1Avg, darts: legStats.player1.darts, checkoutScore: legStats.player1.checkoutScore, tons: legStats.player1.tons, ton80s: legStats.player1.ton80s }, player2: { avg: p2Avg, darts: legStats.player2.darts, checkoutScore: legStats.player2.checkoutScore, tons: legStats.player2.tons, ton80s: legStats.player2.ton80s } }); }); // Calculate first 9 averages across all legs stats.player1.first9Avg = stats.player1.first9Scores.length > 0 ? stats.player1.first9Scores.reduce((a, b) => a + b, 0) / stats.player1.first9Scores.length : 0; stats.player2.first9Avg = stats.player2.first9Scores.length > 0 ? stats.player2.first9Scores.reduce((a, b) => a + b, 0) / stats.player2.first9Scores.length : 0; // Match averages (per 3 darts) stats.player1.matchAvg = stats.player1.totalDarts > 0 ? (stats.player1.totalScore / stats.player1.totalDarts) * 3 : 0; stats.player2.matchAvg = stats.player2.totalDarts > 0 ? (stats.player2.totalScore / stats.player2.totalDarts) * 3 : 0; return stats; } /** * Populate match stats into DOM elements (shared by end screen, live stats, and history detail) * Dynamically generates stats table rows - single source of truth for all stat definitions * @param {object} match - Match object with config, legs, stats, player1Legs, player2Legs * @param {object} els - { player1Name, player2Name, finalScore, statsP1, statsP2, statsBody } */ function populateMatchStats(match, els) { const config = match.config; const stats = match.stats || {}; const p1 = stats.player1 || {}; const p2 = stats.player2 || {}; // Player names and score els.player1Name.textContent = config.player1Name; els.player2Name.textContent = config.player2Name; els.finalScore.textContent = `${match.player1Legs} - ${match.player2Legs}`; // Stats table headers els.statsP1.textContent = config.player1Name; els.statsP2.textContent = config.player2Name; const shortLegThreshold = getShortLegThreshold(config); // Calculate leg averages const legs = match.legs || []; const p1LegAvgs = []; const p2LegAvgs = []; const firstLegStarter = match.firstLegStarter || null; legs.forEach((leg, legIndex) => { const legStarter = firstLegStarter ? (legIndex % 2 === 0 ? firstLegStarter : (firstLegStarter === 1 ? 2 : 1)) : null; const visits = leg.visits || []; const p1Visits = visits.filter(v => v.player === 1); const p2Visits = visits.filter(v => v.player === 2); if (p1Visits.length > 0) { const score = p1Visits.reduce((sum, v) => sum + v.score, 0); const darts = p1Visits.reduce((sum, v) => sum + (v.dartsUsed || 3), 0); const avg = ((score / darts) * 3).toFixed(1); p1LegAvgs.push(legStarter === 1 ? `${avg}` : avg); } else { p1LegAvgs.push('-'); } if (p2Visits.length > 0) { const score = p2Visits.reduce((sum, v) => sum + v.score, 0); const darts = p2Visits.reduce((sum, v) => sum + (v.dartsUsed || 3), 0); const avg = ((score / darts) * 3).toFixed(1); p2LegAvgs.push(legStarter === 2 ? `${avg}` : avg); } else { p2LegAvgs.push('-'); } }); // Helper: format count from array or number (backward compat with old history data) const count = (val) => Array.isArray(val) ? val.length : (val || 0); // Define all stat rows const rows = [ { label: '60+', p1: p1.score60plus || 0, p2: p2.score60plus || 0 }, { label: '80+', p1: p1.score80plus || 0, p2: p2.score80plus || 0 }, { label: '100+', p1: p1.tons || 0, p2: p2.tons || 0 }, { label: '120+', p1: p1.score120plus || 0, p2: p2.score120plus || 0 }, { label: '140+', p1: p1.ton40s || 0, p2: p2.ton40s || 0 }, { label: '170+', p1: p1.score170plus || 0, p2: p2.score170plus || 0 }, { label: '180', p1: p1.ton80s || 0, p2: p2.ton80s || 0 }, { label: 'High Finish', p1: p1.highFinish || 0, p2: p2.highFinish || 0 }, { label: 'Best Leg', p1: p1.bestLeg || 0, p2: p2.bestLeg || 0 }, { label: 'Worst Leg', p1: p1.worstLeg || 0, p2: p2.worstLeg || 0 }, { label: `Short Legs (\u2264${shortLegThreshold})`, p1: count(p1.shortLegs), p2: count(p2.shortLegs) }, { label: '100+ Finishes', p1: count(p1.highOuts), p2: count(p2.highOuts) }, { label: 'First 9 Avg', p1: p1.first9Avg > 0 ? p1.first9Avg.toFixed(1) : '-', p2: p2.first9Avg > 0 ? p2.first9Avg.toFixed(1) : '-' }, { label: 'Match Avg', p1: p1.matchAvg > 0 ? p1.matchAvg.toFixed(1) : '-', p2: p2.matchAvg > 0 ? p2.matchAvg.toFixed(1) : '-' }, { label: 'Leg Avgs', p1: p1LegAvgs.join(', ') || '-', p2: p2LegAvgs.join(', ') || '-', cls: 'leg-avgs' } ]; // Generate table body HTML let html = ''; for (const row of rows) { const tdCls = row.cls ? ` class="${row.cls}"` : ''; html += `${row.p1}${row.label}${row.p2}`; } els.statsBody.innerHTML = html; } /** * Show end screen with statistics (matches history detail format) */ function showEndScreen() { // Build match object from current state (same structure as saved to history) const stats = calculateStats(); const match = { config: state.config, legs: state.legs, player1Legs: state.player1Legs, player2Legs: state.player2Legs, matchWinner: state.matchWinner, firstLegStarter: state.firstLegStarter, stats: stats }; // Hand the finished match to the network client, if one is loaded. Same signed // payload the result QR carries — one definition of a result, two ways to send it. if (window.NewtonChalkerBridge && typeof window.NewtonChalkerBridge.onMatchComplete === 'function') { try { window.NewtonChalkerBridge.onMatchComplete(buildResultPayload(match)); } catch (e) { console.warn('Network result handoff failed:', e); } } // Match info line (end screen specific - shows format without date) const bestOf = match.config.bestOf || 3; elements.endMatchInfo.textContent = `${match.config.startingScore} • Best of ${bestOf}`; // Populate stats using shared function populateMatchStats(match, { player1Name: elements.winnerName, player2Name: elements.loserName, finalScore: elements.finalScore, statsP1: elements.statsPlayer1, statsP2: elements.statsPlayer2, statsBody: elements.endStatsBody }); // Render leg scoresheets using shared function renderLegScoresheets(match, elements.endLegsContainer); // Show Result QR button only for QR-assigned matches elements.endResultQrBtn.style.display = state.config.matchId ? '' : 'none'; showScreen('end'); } // ================ // Result QR // ================ /** * Encode a byte array as base64. * @param {number[]} arr * @returns {string} */ function uint8ToBase64(arr) { let binary = ''; for (let i = 0; i < arr.length; i++) binary += String.fromCharCode(arr[i]); return btoa(binary); } /** * Build and sign the result payload from a completed match object. * @param {object} match - Match object (config, legs, matchWinner, firstLegStarter, etc.) * @returns {object} Signed payload ready for QR encoding */ function buildResultPayload(match) { const config = match.config; const legs = match.legs.map(leg => { const p1Scores = leg.visits.filter(v => v.player === 1).map(v => v.score); const p2Scores = leg.visits.filter(v => v.player === 2).map(v => v.score); const checkoutVisit = leg.visits.find(v => v.isCheckout); const cd = checkoutVisit ? checkoutVisit.dartsUsed : 0; const s = uint8ToBase64(p1Scores) + '|' + uint8ToBase64(p2Scores); return { w: leg.winner, s, cd }; }); const payload = { v: 1, t: 'r', mid: config.matchId, tid: config.tournamentId, sid: config.serverId, p1: config.player1Name, p2: config.player2Name, sc: config.startingScore, bo: config.bestOf, mr: config.maxRounds, w: match.matchWinner, fls: match.firstLegStarter, legs, ts: Math.floor(Date.now() / 1000) }; if (config.lane) payload.ln = config.lane; return NewtonIntegrity.sign(payload); } /** * Show the result QR modal for a completed match. * @param {object} match - Match object with config, legs, matchWinner, firstLegStarter */ function showResultQRModal(match) { const config = match.config; const winnerName = match.matchWinner === 1 ? config.player1Name : config.player2Name; const loserName = match.matchWinner === 1 ? config.player2Name : config.player1Name; const legScore = `${match.player1Legs}-${match.player2Legs}`; elements.resultQrTitle.textContent = `${winnerName} beat ${loserName} ${legScore}`; const parts = [`${config.player1Name} vs ${config.player2Name}`]; if (config.lane) parts.push(`Lane ${config.lane}`); parts.push(`${config.startingScore} Bo${config.bestOf}`); elements.resultQrSubtitle.textContent = parts.join(' · '); elements.resultQrCode.innerHTML = ''; try { const signed = buildResultPayload(match); const json = JSON.stringify(signed); const qr = qrcode(0, 'M'); qr.addData(json); qr.make(); elements.resultQrCode.innerHTML = qr.createSvgTag(6, 2); } catch (e) { elements.resultQrCode.innerHTML = '

Failed to generate QR code.

'; } showModal(elements.resultQrModal); } // ============== // Event Handlers // ============== /** * Check if any modal is currently active * @returns {boolean} */ function isModalActive() { return elements.checkoutModal.classList.contains('active') || elements.tiebreakModal.classList.contains('active') || elements.configModal.classList.contains('active') || elements.settingsModal.classList.contains('active') || elements.confirmModal.classList.contains('active') || elements.networkModal.classList.contains('active') || elements.qrScanModal.classList.contains('active') || elements.qrConfirmModal.classList.contains('active') || elements.resultQrModal.classList.contains('active'); } /** * Handle keypad button press * @param {string} value */ function handleKeyPress(value) { if (isModalActive()) return; if (inputBuffer.length < 3) { inputBuffer += value; updateInputDisplay(); } } /** * Handle delete button */ function handleDelete() { if (isModalActive()) return; inputBuffer = inputBuffer.slice(0, -1); updateInputDisplay(); } /** * Handle enter button */ function handleEnter() { if (isModalActive()) return; // Empty input is allowed in edit mode (deletes the last visit) if (inputBuffer === '' && editingVisitIndex === null) return; const score = parseInt(inputBuffer, 10); // Check if we're in edit mode if (editingVisitIndex !== null) { submitEditedScore(score); } else { submitScore(score); } } /** * Submit an edited score for an existing visit * @param {number} newScore */ function submitEditedScore(newScore) { const currentLeg = state.legs[state.legs.length - 1]; if (!currentLeg || editingVisitIndex === null) return; const visit = currentLeg.visits[editingVisitIndex]; if (!visit) return; // Handle empty input as delete request if (inputBuffer === '') { // Only allow deleting the very last visit if (editingVisitIndex === currentLeg.visits.length - 1) { const deletedVisit = currentLeg.visits.pop(); state.currentPlayer = deletedVisit.player; recalculateScores(); editingVisitIndex = null; updateDisplay(); saveCurrentMatch(); } return; } // Validate score if (!isValidScore(newScore)) { return; } // Calculate what the player's score would be before this visit // by replaying all visits up to (but not including) this one let scoreBeforeVisit = state.config.startingScore; for (let i = 0; i < editingVisitIndex; i++) { if (currentLeg.visits[i].player === visit.player) { scoreBeforeVisit -= currentLeg.visits[i].score; } } const newRemaining = scoreBeforeVisit - newScore; // Check if edit would make subsequent visits invalid // Replay this player's remaining visits with the new score let simulatedRemaining = newRemaining; for (let i = editingVisitIndex + 1; i < currentLeg.visits.length; i++) { if (currentLeg.visits[i].player === visit.player) { simulatedRemaining -= currentLeg.visits[i].score; // Invalid if any subsequent visit would cause bust or checkout // (checkout at 0 requires editing that visit directly) if (simulatedRemaining <= 1) { return; // Reject edit silently } } } // Check if this would be a checkout (exactly 0) if (newRemaining === 0) { // Validate checkout is possible if (!isValidCheckout(scoreBeforeVisit)) { return; } // This edit results in a checkout - show checkout modal showEditCheckoutModal(newScore, editingVisitIndex); return; } // Update the visit const oldScore = visit.score; const wasCheckout = visit.isCheckout; visit.score = newScore; visit.isCheckout = false; visit.dartsUsed = 3; // If this was the winning checkout, undo the leg win if (wasCheckout && currentLeg.winner === visit.player) { currentLeg.winner = null; if (visit.player === 1) { state.player1Legs--; } else { state.player2Legs--; } // Also check if match was complete if (state.matchComplete) { state.matchComplete = false; state.matchWinner = null; } } // Recalculate current scores from all visits recalculateScores(); // Exit edit mode editingVisitIndex = null; inputBuffer = ''; updateDisplay(); saveCurrentMatch(); } /** * Show checkout modal for an edit that results in checkout * @param {number} checkoutScore * @param {number} visitIdx */ function showEditCheckoutModal(checkoutScore, visitIdx) { // Calculate score before the visit being edited const currentLeg = state.legs[state.legs.length - 1]; const visit = currentLeg.visits[visitIdx]; let scoreBeforeVisit = state.config.startingScore; for (let i = 0; i < visitIdx; i++) { if (currentLeg.visits[i].player === visit.player) { scoreBeforeVisit -= currentLeg.visits[i].score; } } const minDarts = getMinDartsForCheckout(scoreBeforeVisit); elements.checkoutModal.dataset.checkoutScore = checkoutScore; elements.checkoutModal.dataset.editVisitIndex = visitIdx; // Show/hide dart buttons based on minimum darts needed const dartButtons = elements.checkoutModal.querySelectorAll('.btn-dart'); dartButtons.forEach(btn => { const darts = parseInt(btn.dataset.darts, 10); btn.style.display = darts >= minDarts ? 'block' : 'none'; }); showModal(elements.checkoutModal); } /** * Recalculate player scores based on all visits in current leg */ function recalculateScores() { const currentLeg = state.legs[state.legs.length - 1]; if (!currentLeg) return; let p1Score = state.config.startingScore; let p2Score = state.config.startingScore; for (const visit of currentLeg.visits) { if (visit.player === 1) { p1Score -= visit.score; } else { p2Score -= visit.score; } } state.player1Score = p1Score; state.player2Score = p2Score; } /** * Handle NEW button - shows options modal (with confirmation during active match) */ function handleNewButton() { if (state && !state.matchComplete) { // During match: confirm first, then show options showConfirm('New Match?', 'Current match will be lost.', () => { showSettingsModalForState(false); }); } else { // Idle: show options directly (no Rematch) showSettingsModalForState(true); } } /** * Show settings modal configured for current state * @param {boolean} isIdle - Whether we're in idle state (no active match) */ function showSettingsModalForState(isIdle) { // Hide/show Rematch button and message based on state elements.rematchBtn.style.display = isIdle ? 'none' : 'block'; elements.settingsModalMessage.style.display = 'none'; // Message shown in confirm dialog const versionEl = document.getElementById('chalker-version'); if (versionEl) versionEl.textContent = 'v' + CHALKER_VERSION; showModal(elements.settingsModal); } /** * Show live stats for the current match */ function showLiveStats() { if (!state) return; // Build match object from current state const stats = calculateStats(); const match = { config: state.config, legs: state.legs, player1Legs: state.player1Legs, player2Legs: state.player2Legs, firstLegStarter: state.firstLegStarter, stats: stats }; // Match info line const bestOf = match.config.bestOf || 3; elements.statsMatchInfo.textContent = `${match.config.startingScore} • Best of ${bestOf}`; // Populate stats using shared function populateMatchStats(match, { player1Name: elements.statsPlayer1Name, player2Name: elements.statsPlayer2Name, finalScore: elements.statsLiveScore, statsP1: elements.statsHeaderP1, statsP2: elements.statsHeaderP2, statsBody: elements.statsStatsBody }); // Render leg scoresheets (includes current leg in progress) renderLegScoresheets(match, elements.statsLegsContainer); showScreen('stats'); } /** * Render leg scoresheets into a container * Used by live stats, match complete, and history detail screens * @param {object} match - Match object with config, legs * @param {HTMLElement} container - Target container element */ function renderLegScoresheets(match, container) { const config = match.config; const legs = match.legs || []; if (legs.length === 0) { container.innerHTML = '

No leg data available

'; return; } let html = '

Leg Scoresheets

'; // Track running score after each leg let p1LegsWon = 0; let p2LegsWon = 0; legs.forEach((leg, legIndex) => { const isInProgress = !leg.winner; const winnerName = leg.winner ? (leg.winner === 1 ? config.player1Name : config.player2Name) : null; // Update running score after this leg (only for completed legs) if (leg.winner === 1) { p1LegsWon++; } else if (leg.winner === 2) { p2LegsWon++; } const runningScore = `${p1LegsWon} - ${p2LegsWon}`; // Calculate total darts for this leg to determine if short leg const visits = leg.visits || []; const winnerVisits = leg.winner ? visits.filter(v => v.player === leg.winner) : []; const totalDarts = winnerVisits.reduce((sum, v) => sum + (v.dartsUsed || 3), 0); const shortLegThreshold = getShortLegThreshold(config); const isShortLeg = leg.winner && totalDarts <= shortLegThreshold; const shortLegBadge = isShortLeg ? 'Short Leg' : ''; // Determine who throws first this leg (alternating from firstLegStarter) const firstLegStarter = match.firstLegStarter || null; let p1Marker = '', p2Marker = ''; if (firstLegStarter) { const legStarter = legIndex % 2 === 0 ? firstLegStarter : (firstLegStarter === 1 ? 2 : 1); p1Marker = legStarter === 1 ? '* ' : ''; p2Marker = legStarter === 2 ? '* ' : ''; } // Status text let statusText; if (isInProgress) { statusText = 'In Progress'; } else { statusText = `${escapeHtml(winnerName)} wins ${shortLegBadge}`; } html += `
Leg ${legIndex + 1} ${isInProgress ? '' : runningScore} ${statusText}
`; // Build visit data by round const startingScore = leg.startingScore || config.startingScore; const p1Visits = visits.filter(v => v.player === 1); const p2Visits = visits.filter(v => v.player === 2); const maxRounds = Math.max(p1Visits.length, p2Visits.length, isInProgress ? 1 : 0); for (let round = 0; round < maxRounds; round++) { const p1Visit = p1Visits[round]; const p2Visit = p2Visits[round]; let p1Scored = '', p1Left = ''; let p2Scored = '', p2Left = ''; if (p1Visit) { const fmt = formatVisitDisplay(p1Visit); p1Scored = fmt.scored; p1Left = fmt.left; } if (p2Visit) { const fmt = formatVisitDisplay(p2Visit); p2Scored = fmt.scored; p2Left = fmt.left; } html += ` `; } // Show remaining scores for in-progress leg if (isInProgress) { const p1Remaining = startingScore - p1Visits.reduce((sum, v) => sum + v.score, 0); const p2Remaining = startingScore - p2Visits.reduce((sum, v) => sum + v.score, 0); if (p1Remaining !== startingScore || p2Remaining !== startingScore) { html += ` `; } } html += `
${p1Marker}${escapeHtml(config.player1Name.substring(0, 8))} To Go ${p2Marker}${escapeHtml(config.player2Name.substring(0, 8))} To Go
${p1Scored} ${p1Left} ${(round + 1) * 3} ${p2Scored} ${p2Left}
${p1Remaining} ${p2Remaining}
`; }); container.innerHTML = html; } /** * Go back from stats to scoring screen */ function statsBack() { showScreen('scoring'); } /** * Initialize event listeners */ function initEventListeners() { // Start match (from config modal) elements.startMatchBtn.addEventListener('click', startMatch); elements.configCancelBtn.addEventListener('click', () => hideModal(elements.configModal)); // New Match modal (Rematch / New Match / QR / Network) elements.settingsCancelBtn.addEventListener('click', () => hideModal(elements.settingsModal)); elements.rematchBtn.addEventListener('click', handleRematchFromModal); elements.newMatchBtn.addEventListener('click', handleNewMatchFromModal); elements.qrModeBtn.addEventListener('click', handleQRModeFromModal); elements.networkModeBtn.addEventListener('click', handleNetworkModeFromModal); // Custom starting score toggle elements.startingScoreCustomToggle.addEventListener('change', () => { const checked = elements.startingScoreCustomToggle.checked; elements.startingScore.style.display = checked ? 'none' : ''; elements.startingScoreCustom.style.display = checked ? '' : 'none'; if (checked) { elements.startingScoreCustom.value = elements.startingScore.value; elements.startingScoreCustom.focus(); } }); // QR scan modal elements.qrScanCancelBtn.addEventListener('click', cancelQRScan); // QR confirm modal elements.qrConfirmStartBtn.addEventListener('click', startMatchFromQR); elements.qrConfirmCancelBtn.addEventListener('click', cancelQRConfirm); // Network modal elements.networkStartBtn.addEventListener('click', startNetworkMode); elements.networkCancelBtn.addEventListener('click', () => hideModal(elements.networkModal)); // Keypad number keys document.querySelectorAll('.keypad .key[data-value]').forEach(key => { key.addEventListener('click', () => handleKeyPress(key.dataset.value)); }); document.querySelector('.key[data-action="delete"]').addEventListener('click', handleDelete); document.querySelector('.key[data-action="enter"]').addEventListener('click', handleEnter); // Keypad action buttons elements.keyNew.addEventListener('click', handleNewButton); elements.keyHistory.addEventListener('click', showHistoryScreen); elements.keyStats.addEventListener('click', () => { if (state && state.matchComplete) { showEndScreen(); } else { showLiveStats(); } }); // Stats back button elements.statsBackBtn.addEventListener('click', statsBack); // Confirm modal elements.confirmYesBtn.addEventListener('click', handleConfirmYes); elements.confirmNoBtn.addEventListener('click', handleConfirmNo); // Checkout modal document.querySelectorAll('.btn-dart').forEach(btn => { btn.addEventListener('click', () => completeCheckout(parseInt(btn.dataset.darts, 10))); }); elements.checkoutCancelBtn.addEventListener('click', cancelCheckout); // Selection modal (starting player / tie-break) elements.tiebreakP1Btn.addEventListener('click', () => handleSelectionModalChoice(1)); elements.tiebreakP2Btn.addEventListener('click', () => handleSelectionModalChoice(2)); elements.tiebreakRandomBtn.addEventListener('click', () => handleSelectionModalChoice('random')); elements.tiebreakCancelBtn.addEventListener('click', cancelTiebreak); // End screen (stats view) buttons elements.endResultQrBtn.addEventListener('click', () => { const stats = calculateStats(); showResultQRModal({ config: state.config, legs: state.legs, player1Legs: state.player1Legs, player2Legs: state.player2Legs, matchWinner: state.matchWinner, firstLegStarter: state.firstLegStarter, stats }); }); elements.endBackBtn.addEventListener('click', () => showScreen('scoring')); // History elements.historyBackBtn.addEventListener('click', historyBack); elements.historyDetailBackBtn.addEventListener('click', historyDetailBack); elements.historyDetailQrBtn.addEventListener('click', () => { if (currentHistoryMatch) showResultQRModal(currentHistoryMatch); }); elements.resultQrCloseBtn.addEventListener('click', () => hideModal(elements.resultQrModal)); // Keyboard support (for testing on desktop) document.addEventListener('keydown', (e) => { if (elements.scoringScreen.classList.contains('active') && !isModalActive()) { if (e.key >= '0' && e.key <= '9') { handleKeyPress(e.key); } else if (e.key === 'Backspace') { handleDelete(); } else if (e.key === 'Enter') { handleEnter(); } } }); } // ========== // Initialize // ========== /** * Load saved settings and pre-fill config form */ async function loadSettings() { try { const saved = await dbGet('settings', 'default'); if (saved) { // laneName not loaded - reserved for network mode elements.player1Name.value = saved.player1Name || ''; elements.player2Name.value = saved.player2Name || ''; const sc = saved.startingScore || 501; const presets = Array.from(elements.startingScore.options).map(o => parseInt(o.value)); if (presets.includes(sc)) { elements.startingScore.value = sc; } else { elements.startingScoreCustomToggle.checked = true; elements.startingScore.style.display = 'none'; elements.startingScoreCustom.style.display = ''; elements.startingScoreCustom.value = sc; } elements.bestOf.value = saved.bestOf || 3; elements.maxRounds.value = saved.maxRounds || 20; } } catch (e) { console.warn('Failed to load settings:', e); } } /** * Save current settings to IndexedDB * @param {object} config - Match configuration */ async function saveSettings(config) { try { await dbPut('settings', { id: 'default', ...config }); } catch (e) { console.warn('Failed to save settings:', e); } } /** * Save current match state to IndexedDB */ async function saveCurrentMatch() { if (!state) return; try { await dbPut('current_match', { id: 'active', state: state, inputBuffer: inputBuffer, timestamp: Date.now() }); } catch (e) { console.warn('Failed to save match:', e); } } /** * Clear saved match from IndexedDB */ async function clearCurrentMatch() { try { await dbDelete('current_match', 'active'); } catch (e) { console.warn('Failed to clear match:', e); } } /** * Save completed match to history * @param {object} [matchData] - Optional pre-built match object (avoids recalculating stats) */ async function saveMatchToHistory(matchData) { if (!state || !state.matchComplete) return; try { // Use provided match data or build from state const match = matchData ? { timestamp: Date.now(), config: matchData.config, legs: matchData.legs, player1Legs: matchData.player1Legs, player2Legs: matchData.player2Legs, matchWinner: matchData.matchWinner, firstLegStarter: matchData.firstLegStarter || null, stats: matchData.stats } : { timestamp: Date.now(), config: state.config, legs: state.legs, player1Legs: state.player1Legs, player2Legs: state.player2Legs, matchWinner: state.matchWinner, firstLegStarter: state.firstLegStarter || null, stats: calculateStats() }; await dbPut('match_history', match); await clearCurrentMatch(); await trimHistory(); } catch (e) { console.warn('Failed to save match to history:', e); } } /** * Trim history to MAX_HISTORY matches */ async function trimHistory() { try { const count = await dbCount('match_history'); if (count > MAX_HISTORY) { const all = await dbGetAllByIndex('match_history', 'timestamp', 'next'); // oldest first const toDelete = all.slice(0, count - MAX_HISTORY); for (const match of toDelete) { await dbDelete('match_history', match.id); } } } catch (e) { console.warn('Failed to trim history:', e); } } /** * Show match history screen */ async function showHistoryScreen() { try { const matches = await dbGetAllByIndex('match_history', 'timestamp', 'prev'); renderHistoryList(matches); } catch (e) { console.warn('Failed to load history:', e); renderHistoryList([]); } showScreen('history'); } /** @type {Array} Cached history matches for detail view */ let historyMatches = []; /** * Format date as YYYY-MM-DD * @param {Date} date * @returns {string} */ function formatDateYMD(date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } /** * Render history list * @param {array} matches */ function renderHistoryList(matches) { historyMatches = matches; // Cache for detail view if (matches.length === 0) { elements.historyList.innerHTML = '
No match history yet
'; return; } elements.historyList.innerHTML = matches.map((m, index) => { const date = new Date(m.timestamp); const dateStr = formatDateYMD(date); const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }); const winnerName = m.matchWinner === 1 ? m.config.player1Name : m.config.player2Name; const bestOf = m.config.bestOf || 3; return `
${escapeHtml(m.config.player1Name)} vs ${escapeHtml(m.config.player2Name)}${m.config.matchId ? ' QR' : ''}
${m.player1Legs} - ${m.player2Legs}
${dateStr} ${timeStr} • ${m.config.startingScore} • Best of ${bestOf} • ${escapeHtml(winnerName)} wins
`; }).join(''); // Add click handlers for each item elements.historyList.querySelectorAll('.history-item').forEach(item => { item.addEventListener('click', () => { const index = parseInt(item.dataset.index, 10); showHistoryDetail(historyMatches[index]); }); }); } /** * Show history detail for a match * @param {object} match */ function showHistoryDetail(match) { const config = match.config; // Date line (history specific - includes timestamp) const date = new Date(match.timestamp); const bestOf = config.bestOf || 3; elements.detailDate.textContent = formatDateYMD(date) + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }) + ` • ${config.startingScore} • Best of ${bestOf}`; // Populate stats using shared function populateMatchStats(match, { player1Name: elements.detailPlayer1, player2Name: elements.detailPlayer2, finalScore: elements.detailScore, statsP1: elements.detailStatsP1, statsP2: elements.detailStatsP2, statsBody: elements.detailStatsBody }); // Render leg scoresheets using shared function renderLegScoresheets(match, elements.detailLegsContainer); // Show Result QR button only for QR-assigned matches currentHistoryMatch = match; elements.historyDetailQrBtn.style.display = match.config.matchId ? '' : 'none'; showScreen('history-detail'); } /** * Go back from history detail to history list */ function historyDetailBack() { showScreen('history'); } /** * Go back from history to scoring screen */ function historyBack() { showScreen('scoring'); if (!state) { updateIdleDisplay(); } else { updateDisplay(); } } /** * Check for active match on load and offer to resume */ async function checkForActiveMatch() { try { const saved = await dbGet('current_match', 'active'); if (saved && saved.state && !saved.state.matchComplete) { // Resume the match state = saved.state; inputBuffer = saved.inputBuffer || ''; // Update UI with match info elements.player1Header.textContent = state.config.player1Name; elements.player2Header.textContent = state.config.player2Name; updateDisplay(); showScreen('scoring'); return true; } } catch (e) { console.warn('Failed to check for active match:', e); } return false; } async function init() { await openDB(); initEventListeners(); await loadSettings(); // Check for active match to resume const resumed = await checkForActiveMatch(); if (!resumed) { // Show scoring screen in idle state showScreen('scoring'); updateIdleDisplay(); } } // Start the app init(); // PWA install prompt var _installPrompt = null; window.addEventListener('beforeinstallprompt', function(e) { e.preventDefault(); if (localStorage.getItem('chalker_install_dismissed')) return; _installPrompt = e; var banner = document.getElementById('installBanner'); if (banner) banner.classList.add('visible'); }); window.addEventListener('appinstalled', function() { _installPrompt = null; var banner = document.getElementById('installBanner'); if (banner) banner.classList.remove('visible'); localStorage.setItem('chalker_install_dismissed', '1'); }); /** Hide the install banner. */ function hideInstallBanner() { var banner = document.getElementById('installBanner'); if (banner) banner.classList.remove('visible'); } window.installApp = function() { // The deferred prompt can be invalidated by the browser (app already installed, // or the event has gone stale). Previously this returned silently, leaving the // banner sitting over the keypad advertising a button that did nothing. if (!_installPrompt) { hideInstallBanner(); localStorage.setItem('chalker_install_dismissed', '1'); return; } _installPrompt.prompt(); _installPrompt.userChoice.then(function() { _installPrompt = null; hideInstallBanner(); }); }; window.dismissInstall = function() { hideInstallBanner(); localStorage.setItem('chalker_install_dismissed', '1'); }; /** * Bridge for the optional network client in `licensed/`. * * That directory is separately licensed and may be absent entirely, so this is a * deliberately narrow surface: start a match from an assignment payload, report a * finished one, and answer what the device is currently doing. No scoring logic * crosses it — the network client is a transport, and the Chalker remains the only * thing that knows how to score darts. */ window.NewtonChalkerBridge = { /** Start a match from a verified assignment payload (same path a scanned QR takes). */ startFromAssignment: function(payload) { qrPayload = payload; startMatchFromQR(); }, /** Set by the network client; called with the signed result payload on completion. */ onMatchComplete: null, /** Lane this device is claiming, or null when not in network mode. */ getLane: function() { return networkWaitingLane; }, /** Match currently being scored, or null. */ getCurrentMatchId: function() { return (state && state.config && state.config.matchId) ? state.config.matchId : null; }, /** True when the device is free to take a new match. */ isIdle: function() { return !state || state.matchComplete === true; } }; // Wire the banner buttons with listeners rather than inline onclick attributes. // They were the only inline handlers left in the Chalker, which made them the only // thing here that a strict Content-Security-Policy (one without 'unsafe-inline') // would silently disable — leaving an undismissable bar over the bottom keypad row. // The banner markup also sits after this script, so it isn't in the DOM yet. document.addEventListener('DOMContentLoaded', function() { var installBtn = document.getElementById('installAppBtn'); var dismissBtn = document.getElementById('installDismissBtn'); if (installBtn) installBtn.addEventListener('click', window.installApp); if (dismissBtn) dismissBtn.addEventListener('click', window.dismissInstall); }); })();