<!DOCTYPE html>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<link rel="icon" type="image/svg+xml" href="favicon.svg">
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
:root {
--bg-primary: #0f3460;
--bg-secondary: #16213e;
--border-color: #3b4272;
--text-primary: #ffffff;
--text-secondary: #d1d5db;
--text-muted: #9ca3af;
--accent: #06b6d4;
--accent-dark: #0891b2;
--btn-gray: #333333;
--btn-gray-dark: #444444;
--timer-color: #ff9800;
--selection-border: #ffffff;
--radius-sm: 6px;
--radius-md: 12px;
--btn-padding: 8px 16px;
--ui-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--mono-font: ui-monospace, 'SF Mono', 'Fira Code', 'Roboto Mono', 'Consolas', monospace;
}
@media (prefers-color-scheme: light) {
:root {
--bg-primary: #f5f5f5;
--bg-secondary: #ffffff;
--border-color: #d1d5db;
--text-primary: #1a1a1a;
--text-secondary: #4a4a4a;
--text-muted: #888888;
--accent: #0891b2;
--accent-dark: #06b6d4;
--btn-gray: #e5e5e5;
--btn-gray-dark: #d1d5db;
--timer-color: #d97706;
--selection-border: #000000;
}
}
html, body {
height: 100%;
min-height: 100dvh;
margin: 0;
padding: 0;
overflow: hidden;
overflow-x: hidden;
}
body {
font-family: var(--ui-font);
background: var(--bg-primary);
color: var(--text-primary);
display: flex;
flex-direction: column;
touch-action: none;
user-select: none;
max-width: 100vw;
align-items: stretch;
}
.hidden {
display: none !important;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 12px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
height: 50px;
flex-shrink: 0;
box-sizing: border-box;
}
.header-left,
.header-right {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
}
.header-left {
justify-content: flex-start;
}
.header-right {
justify-content: flex-end;
}
.header-center {
display: flex;
justify-content: center;
align-items: center;
padding: 0 12px;
}
.header-right .player-name {
text-align: right;
}
.player-name {
font-size: 16px;
color: var(--text-secondary);
cursor: pointer;
padding: 4px 8px;
display: inline-block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border-bottom: 1px dashed var(--text-muted);
}
.player-name:hover {
border-bottom-color: var(--accent);
border-bottom-style: solid;
}
.main-area {
width: 100%;
height: calc((100vw - 10px) * 2 / 3);
max-height: calc(100vh - 180px);
padding: 10px 5px;
text-align: center;
display: flex;
flex-direction: column;
gap: 10px;
justify-content: center;
box-sizing: border-box;
}
.scores-container {
display: flex;
justify-content: center;
align-items: center;
gap: 0;
flex: 1;
min-height: 0;
position: relative;
}
.vs-text {
position: absolute;
left: 50%;
transform: translateX(-50%);
color: var(--text-muted);
font-weight: bold;
pointer-events: none;
}
.score-button {
background: var(--bg-secondary);
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
cursor: pointer;
position: relative;
overflow: hidden;
transition: transform 0.1s, background 0.2s;
}
.score-button:active {
transform: scale(0.95);
}
.score-value {
font-family: var(--mono-font);
font-size: 2.67ch;
font-weight: bold;
text-shadow: 0 0.05em 0.1em rgba(0, 0, 0, 0.5);
transition: transform 0.15s;
}
@keyframes flash {
0%, 50%, 100% { opacity: 1; }
25%, 75% { opacity: 0.3; }
}
.score-value.flash {
animation: flash 0.2s ease-in-out 5;
}
.game-btn,
.undo-btn,
.new-btn {
background: var(--accent);
color: white;
border: none;
padding: var(--btn-padding);
font-size: 14px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.2s, transform 0.1s;
}
.game-btn:active,
.undo-btn:active,
.new-btn:active {
transform: scale(0.95);
background: var(--accent-dark);
}
.game-number {
font-size: 16px;
font-weight: bold;
color: var(--text-primary);
}
.game-log-section {
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
min-height: 100px;
overflow: hidden;
}
.game-log-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 10px;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.game-log-header-left,
.game-log-header-right {
display: flex;
align-items: center;
}
.game-log-header-left {
gap: 12px;
}
.timer {
font-family: var(--mono-font);
font-size: 16px;
color: var(--timer-color);
font-weight: bold;
}
.game-log-title {
font-size: 14px;
color: var(--text-muted);
}
.export-btn {
background: var(--accent);
color: white;
border: none;
padding: var(--btn-padding);
font-size: 12px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.2s;
}
.export-btn:hover {
background: var(--accent-dark);
}
.game-log-list {
flex: 1;
overflow-y: auto;
padding: 0 16px 8px 16px;
min-height: 80px;
}
.game-log-item {
font-family: var(--ui-font);
font-size: 13px;
padding: 6px 0;
color: var(--text-muted);
position: relative;
}
.game-log-item:first-child {
padding-top: 8px;
}
.game-log-item:not(:last-child)::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background: var(--border-color);
}
.game-log-item:last-child {
color: var(--text-secondary);
}
.footer {
display: flex;
align-items: center;
padding: 8px 12px;
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
flex-shrink: 0;
}
.footer-favicon {
height: 30px;
width: auto;
flex-shrink: 0;
margin-right: 20px;
}
.footer-spacer {
flex-shrink: 0;
width: 50px;
}
.footer-buttons {
flex: 1;
display: flex;
justify-content: center;
gap: 12px;
}
.footer-buttons button {
flex: 1;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: flex-start;
padding-top: 10vh;
z-index: 1000;
overflow-y: auto;
}
.modal-content {
background: var(--bg-secondary);
padding: 24px;
border-radius: var(--radius-md);
text-align: center;
min-width: 280px;
max-height: 80vh;
overflow-y: auto;
margin: auto;
}
.modal-content h3 {
margin-bottom: 16px;
color: var(--text-primary);
}
.modal-content input[type="text"] {
width: 100%;
padding: 10px;
font-size: 16px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-primary);
color: var(--text-primary);
margin-bottom: 12px;
box-sizing: border-box;
}
.modal-content input[type="color"] {
width: 100%;
height: 50px;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
margin-bottom: 16px;
}
.modal-content .color-picker {
display: flex;
gap: 8px;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 16px;
}
.modal-content .color-option {
flex: 0 0 40px;
width: 40px;
height: 40px;
display: block;
border-radius: 50%;
cursor: pointer;
transition: transform 0.2s, border-color 0.2s;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
.modal-content .color-option.selected {
outline: 3px solid var(--selection-border);
outline-offset: 2px;
}
.modal-content .color-option.selected:not(.two-tone) {
outline-width: 3px;
outline-color: var(--selection-border);
outline-offset: 0;
}
.modal-content .modal-buttons {
display: flex;
gap: 12px;
justify-content: center;
}
.modal-content button {
padding: 10px 24px;
font-size: 14px;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
min-width: 100px;
width: 100px;
text-align: center;
}
.modal-content .btn-cancel {
background: var(--btn-gray);
color: white;
}
.modal-content .btn-save {
background: var(--accent);
color: white;
}
.confirm-dialog .modal-content {
min-width: 260px;
}
.confirm-dialog .modal-content p {
margin-bottom: 20px;
color: var(--text-secondary);
font-size: 15px;
}
<header class="header">
<div class="header-left">
<div class="player-name" id="nameA">Player A
</div>
<div class="header-center">
<div class="game-number" id="gameNumber">0 : 0
</div>
<div class="header-right">
<div class="player-name" id="nameB">Player B
</div>
</header>
<main class="main-area">
<div class="scores-container">
<div class="score-button" id="scoreBtnA" data-player="A">
<div class="score-value" id="scoreA">0
</div>
<div class="score-button" id="scoreBtnB" data-player="B">
<div class="score-value" id="scoreB">0
</div>
</main>
<section class="game-log-section">
<div class="game-log-header">
<div class="game-log-header-left">
<span class="game-log-title">Game Log
</span>
<div class="game-log-header-right">
<div class="timer" id="timer">00:00
</div>
<div class="game-log-list" id="gameLogList"></div>
</section>
<footer class="footer">
<img class="footer-favicon" src="favicon.svg" alt="Logo">
<div class="footer-buttons">
<div class="footer-spacer"></div>
</footer>
const STORAGE_KEY = 'badminton_score_keeper';
const SWIPE_THRESHOLD = 30;
const CLICK_LOCK_MS = 100;
const AUTO_END_DELAY_MS = 2000;
const WIN_POINTS = 21;
const DEUCE_TRIGGER = 20;
const MAX_POINTS = 30;
const DEFAULT_STATE = {
playerA: 'Player A',
playerB: 'Player B',
playerColorA: '#e94560',
playerColorB: '#2196f3',
scoreA: 0,
scoreB: 0,
gamesWonA: 0,
gamesWonB: 0,
currentGame: 1,
gameState: 'not_started',
timerElapsed: 0,
gameLog: [],
firstGameStartTime: null,
manualEndMode: false,
customMatch: false
};
const elements = {
nameA: document.getElementById('nameA'),
nameB: document.getElementById('nameB'),
timer: document.getElementById('timer'),
scoreA: document.getElementById('scoreA'),
scoreB: document.getElementById('scoreB'),
scoreBtnA: document.getElementById('scoreBtnA'),
scoreBtnB: document.getElementById('scoreBtnB'),
gameBtn: document.getElementById('gameBtn'),
gameNumber: document.getElementById('gameNumber'),
gameLogList: document.getElementById('gameLogList'),
newBtn: document.getElementById('newBtn'),
undoBtn: document.getElementById('undoBtn'),
exportBtn: document.getElementById('exportBtn')
};
let state = loadState();
let timerInterval = null;
let isProcessingClick = false;
let isFlashing = false;
function normalizeState(raw) {
const merged = { ...DEFAULT_STATE, ...(raw || {}) };
merged.gameLog = Array.isArray(merged.gameLog) ? [...merged.gameLog] : [];
if (!merged.playerColorA) merged.playerColorA = DEFAULT_STATE.playerColorA;
if (!merged.playerColorB) merged.playerColorB = DEFAULT_STATE.playerColorB;
if (!Number.isFinite(merged.timerElapsed) || merged.timerElapsed < 0) {
merged.timerElapsed = 0;
}
if (!['not_started', 'playing', 'ended'].includes(merged.gameState)) {
merged.gameState = 'not_started';
}
if (!Number.isFinite(merged.currentGame) || merged.currentGame < 1) {
merged.currentGame = 1;
}
return merged;
}
function loadState() {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (!saved) return normalizeState();
return normalizeState(JSON.parse(saved));
} catch (err) {
return normalizeState();
}
}
function saveState() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
function isMatchWon() {
return state.gamesWonA >= 2 || state.gamesWonB >= 2;
}
function formatTime(seconds) {
const safeSeconds = Math.max(0, Number.isFinite(seconds) ? Math.floor(seconds) : 0);
const h = Math.floor(safeSeconds / 3600);
const m = Math.floor((safeSeconds % 3600) / 60);
const s = safeSeconds % 60;
if (h > 0) {
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
function formatExportTime(seconds) {
const safeSeconds = Math.max(0, Number.isFinite(seconds) ? Math.floor(seconds) : 0);
const h = Math.floor(safeSeconds / 3600);
const m = Math.floor((safeSeconds % 3600) / 60);
const s = safeSeconds % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
function parseTimeToSeconds(timeStr) {
if (typeof timeStr !== 'string') return 0;
const parts = timeStr.split(':').map(Number);
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
if (parts.length === 2) return parts[0] * 60 + parts[1];
return 0;
}
function shouldTimerRun() {
if (!state.firstGameStartTime) return false;
if (state.customMatch) return true;
return !isMatchWon();
}
function recomputeElapsed() {
if (!state.firstGameStartTime) {
state.timerElapsed = 0;
return;
}
const elapsed = Math.floor((Date.now() - state.firstGameStartTime) / 1000);
state.timerElapsed = Math.max(0, elapsed);
}
function updateTimerDisplay() {
elements.timer.textContent = formatTime(state.timerElapsed);
}
function syncTimerLoop() {
const running = shouldTimerRun();
if (running && !timerInterval) {
timerInterval = setInterval(() => {
recomputeElapsed();
updateTimerDisplay();
saveState();
}, 1000);
} else if (!running && timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
}
function getScoreTextColor(bgColor) {
return String(bgColor).toLowerCase() === '#ffffff' ? '#0f3460' : '#ffffff';
}
function syncPlayerUI() {
elements.nameA.textContent = state.playerA;
elements.nameB.textContent = state.playerB;
elements.scoreBtnA.style.backgroundColor = state.playerColorA;
elements.scoreBtnB.style.backgroundColor = state.playerColorB;
elements.scoreA.style.color = getScoreTextColor(state.playerColorA);
elements.scoreB.style.color = getScoreTextColor(state.playerColorB);
}
function syncScoreUI() {
elements.scoreA.textContent = String(state.scoreA);
elements.scoreB.textContent = String(state.scoreB);
elements.gameNumber.textContent = `${state.gamesWonA} : ${state.gamesWonB}`;
}
function syncGameButtonUI() {
if (state.gameState === 'playing') {
elements.gameBtn.textContent = 'End Game';
} else if (state.gameState === 'not_started') {
elements.gameBtn.textContent = 'Start Game';
} else {
elements.gameBtn.textContent = 'Start Game';
}
}
function getHistoryScoreA(entry) {
return Number.isFinite(entry.scoreA) ? entry.scoreA : entry.leftScore;
}
function getHistoryScoreB(entry) {
return Number.isFinite(entry.scoreB) ? entry.scoreB : entry.rightScore;
}
function formatHistoryEntry(entry) {
const leftName = entry.leftPlayerName || state.playerA;
const rightName = entry.rightPlayerName || state.playerB;
const scoreA = getHistoryScoreA(entry) ?? 0;
const scoreB = getHistoryScoreB(entry) ?? 0;
switch (entry.action) {
case 'start_game':
return `${entry.time} - Game ${entry.game} Started (${leftName} vs ${rightName})`;
case 'point':
return `${entry.time} - ${scoreA} : ${scoreB} (+1 ${entry.playerName || (entry.player === 'A' ? state.playerA : state.playerB)})`;
case 'end_game':
return `${entry.time} - Game ${entry.game} Ended (${entry.winnerName || ''}, ${scoreA} : ${scoreB})`;
case 'switch_sides':
return `${entry.time} - Sides switched (${leftName} vs ${rightName})`;
default:
return '';
}
}
function renderHistory() {
elements.gameLogList.innerHTML = '';
for (let i = 0; i < state.gameLog.length; i++) {
const entry = state.gameLog[i];
if (entry.action === 'free_scoring_started' || entry.action === 'auto_end_game') continue;
const div = document.createElement('div');
div.className = 'game-log-item';
let text = '';
switch (entry.action) {
case 'start_game':
if (entry.game === 1) {
text = `${entry.time} - Game ${entry.game} started (${entry.leftPlayerName} vs ${entry.rightPlayerName})`;
} else {
text = `${entry.time} - Game ${entry.game} started (${entry.leftPlayerName} ${entry.gamesWonA}:${entry.gamesWonB} ${entry.rightPlayerName})`;
}
break;
case 'point':
text = `${entry.time} - ${entry.leftPlayerName} ${entry.leftScore}:${entry.rightScore} ${entry.rightPlayerName}`;
break;
case 'end_game':
text = `${entry.time} - Game ${entry.game} ended (${entry.leftPlayerName} ${entry.gamesWonA}:${entry.gamesWonB} ${entry.rightPlayerName})`;
break;
case 'switch_sides':
text = `${entry.time} - Sides switched (${entry.leftPlayerName} vs ${entry.rightPlayerName})`;
break;
}
div.textContent = text;
elements.gameLogList.appendChild(div);
}
}
function syncUI() {
syncPlayerUI();
syncScoreUI();
syncGameButtonUI();
updateTimerDisplay();
renderHistory();
}
function addHistoryEntry(action, data = {}) {
const entry = {
time: formatTime(state.timerElapsed),
action,
...data
};
state.gameLog.unshift(entry);
renderHistory();
}
function lockClicks() {
isProcessingClick = true;
setTimeout(() => {
isProcessingClick = false;
}, CLICK_LOCK_MS);
}
function hasAutoEndInCurrentGame() {
for (const entry of state.gameLog) {
if (entry.action === 'start_game') break;
if (entry.action === 'auto_end_game') return true;
}
return false;
}
function hasSwitchInCurrentGame() {
for (const entry of state.gameLog) {
if (entry.action === 'start_game') break;
if (entry.action === 'switch_sides') return true;
}
return false;
}
function doSwitchSides() {
[state.playerA, state.playerB] = [state.playerB, state.playerA];
[state.playerColorA, state.playerColorB] = [state.playerColorB, state.playerColorA];
[state.scoreA, state.scoreB] = [state.scoreB, state.scoreA];
[state.gamesWonA, state.gamesWonB] = [state.gamesWonB, state.gamesWonA];
syncPlayerUI();
syncScoreUI();
}
function checkWinningCondition() {
const scoreA = state.scoreA;
const scoreB = state.scoreB;
if (scoreA < WIN_POINTS && scoreB < WIN_POINTS) {
return { won: false };
}
if (scoreA >= WIN_POINTS && scoreB <= DEUCE_TRIGGER - 1) {
return { won: true, winner: 'A', winnerName: state.playerA };
}
if (scoreB >= WIN_POINTS && scoreA <= DEUCE_TRIGGER - 1) {
return { won: true, winner: 'B', winnerName: state.playerB };
}
if (scoreA >= DEUCE_TRIGGER && scoreB >= DEUCE_TRIGGER) {
if (scoreA === MAX_POINTS || scoreB === MAX_POINTS) {
if (scoreA !== scoreB) {
const winner = scoreA > scoreB ? 'A' : 'B';
return { won: true, winner, winnerName: winner === 'A' ? state.playerA : state.playerB };
}
} else if (Math.abs(scoreA - scoreB) >= 2) {
const winner = scoreA > scoreB ? 'A' : 'B';
return { won: true, winner, winnerName: winner === 'A' ? state.playerA : state.playerB };
}
}
return { won: false };
}
function animateScore(element, direction, oldValue) {
const parent = element.parentElement;
const rect = element.getBoundingClientRect();
const parentRect = parent.getBoundingClientRect();
const fontSize = parseFloat(getComputedStyle(element).fontSize);
const scoreGap = (rect.height + fontSize) / 2;
const isPlayerA = element === elements.scoreA;
const textColor = getScoreTextColor(isPlayerA ? state.playerColorA : state.playerColorB);
const oldScoreEl = document.createElement('div');
oldScoreEl.className = 'score-value';
oldScoreEl.textContent = oldValue;
oldScoreEl.style.cssText = `
position: absolute;
left: ${rect.left - parentRect.left}px;
top: ${rect.top - parentRect.top}px;
width: ${rect.width}px;
height: ${rect.height}px;
font-size: ${fontSize}px;
font-weight: bold;
color: ${textColor};
text-shadow: 0 0.05em 0.1em rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 10;
pointer-events: none;
transition: transform 0.15s ease-out;
`;
const newScoreEl = document.createElement('div');
newScoreEl.className = 'score-value';
newScoreEl.textContent = direction === 1 ? oldValue + 1 : oldValue - 1;
newScoreEl.style.cssText = `
position: absolute;
left: ${rect.left - parentRect.left}px;
top: ${rect.top - parentRect.top}px;
width: ${rect.width}px;
height: ${rect.height}px;
font-size: ${fontSize}px;
font-weight: bold;
color: ${textColor};
text-shadow: 0 0.05em 0.1em rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 11;
pointer-events: none;
transform: translateY(${direction === 1 ? scoreGap : -scoreGap}px);
transition: transform 0.15s ease-out;
`;
parent.appendChild(oldScoreEl);
parent.appendChild(newScoreEl);
element.style.opacity = '0';
requestAnimationFrame(() => {
oldScoreEl.style.transform = direction === 1 ? `translateY(-${scoreGap}px)` : `translateY(${scoreGap}px)`;
newScoreEl.style.transform = 'translateY(0)';
});
setTimeout(() => {
element.style.opacity = '1';
oldScoreEl.remove();
newScoreEl.remove();
}, 150);
}
async function maybeSwitchSidesBeforeGame() {
const lastWasSwitch = state.gameLog[0] && state.gameLog[0].action === 'switch_sides';
if (lastWasSwitch) return false;
if (await showConfirmDialog('Switch sides?')) {
doSwitchSides();
addHistoryEntry('switch_sides', {
leftPlayerName: state.playerA,
rightPlayerName: state.playerB
});
saveState();
return true;
}
return false;
}
async function startGameFlow() {
if (state.gameState === 'playing') return false;
let switched = false;
if (state.gameState === 'ended') {
if (isMatchWon()) {
state.customMatch = true;
}
switched = await maybeSwitchSidesBeforeGame();
}
if (!state.firstGameStartTime) {
state.firstGameStartTime = Date.now();
state.timerElapsed = 0;
}
state.gameState = 'playing';
state.manualEndMode = false;
state.scoreA = 0;
state.scoreB = 0;
addHistoryEntry('start_game', {
game: state.currentGame,
gamesWonA: state.gamesWonA,
gamesWonB: state.gamesWonB,
leftPlayerName: state.playerA,
rightPlayerName: state.playerB
});
syncScoreUI();
syncGameButtonUI();
recomputeElapsed();
updateTimerDisplay();
syncTimerLoop();
saveState();
return switched;
}
async function finalizeAutoEnd(result) {
const winnerElement = result.winner === 'A' ? elements.scoreA : elements.scoreB;
winnerElement.classList.add('flash');
isFlashing = true;
setTimeout(() => winnerElement.classList.remove('flash'), 1000);
await new Promise((resolve) => setTimeout(resolve, AUTO_END_DELAY_MS));
winnerElement.classList.remove('flash');
isFlashing = false;
state.gameState = 'ended';
state.manualEndMode = true;
if (result.winner === 'A') state.gamesWonA += 1;
if (result.winner === 'B') state.gamesWonB += 1;
const finalScoreA = state.scoreA;
const finalScoreB = state.scoreB;
addHistoryEntry('end_game', {
game: state.currentGame,
winner: result.winner,
winnerName: result.winnerName,
leftPlayerName: state.playerA,
rightPlayerName: state.playerB,
scoreA: finalScoreA,
scoreB: finalScoreB,
gamesWonA: state.gamesWonA,
gamesWonB: state.gamesWonB
});
state.currentGame += 1;
state.scoreA = 0;
state.scoreB = 0;
syncScoreUI();
syncGameButtonUI();
syncTimerLoop();
saveState();
}
async function updateScore(player, delta, animate = true) {
if (state.gameState !== 'playing') return;
const oldScoreA = state.scoreA;
const oldScoreB = state.scoreB;
if (player === 'A') {
state.scoreA = Math.max(0, state.scoreA + delta);
} else {
state.scoreB = Math.max(0, state.scoreB + delta);
}
const scoreEl = player === 'A' ? elements.scoreA : elements.scoreB;
if (animate) {
animateScore(scoreEl, delta > 0 ? 1 : -1, player === 'A' ? oldScoreA : oldScoreB);
}
syncScoreUI();
addHistoryEntry('point', {
player,
playerName: player === 'A' ? state.playerA : state.playerB,
leftScore: state.scoreA,
rightScore: state.scoreB,
prevScoreA: oldScoreA,
prevScoreB: oldScoreB,
game: state.currentGame,
leftPlayerName: state.playerA,
rightPlayerName: state.playerB
});
if (!state.manualEndMode && !state.customMatch && state.currentGame === 3) {
const wasUnderEleven = Math.max(oldScoreA, oldScoreB) < 11;
const reachedEleven = Math.max(state.scoreA, state.scoreB) >= 11;
if (wasUnderEleven && reachedEleven && !hasSwitchInCurrentGame()) {
if (await showConfirmDialog('Switch sides?')) {
doSwitchSides();
addHistoryEntry('switch_sides', {
leftPlayerName: state.playerA,
rightPlayerName: state.playerB
});
saveState();
}
}
}
if (!state.manualEndMode) {
const result = checkWinningCondition();
if (result.won) {
addHistoryEntry('auto_end_game');
await finalizeAutoEnd(result);
}
}
saveState();
}
async function handleScoreClick(player) {
if (isFlashing || isProcessingClick) return;
lockClicks();
let switchedSide = false;
if (state.gameState !== 'playing') {
switchedSide = await startGameFlow();
}
if (switchedSide) {
player = player === 'A' ? 'B' : 'A';
}
await updateScore(player, 1, true);
}
async function handleSwipe(player, deltaY, animate = true) {
if (isFlashing) return;
if (deltaY < -SWIPE_THRESHOLD) {
if (state.gameState !== 'playing') {
await startGameFlow();
}
await updateScore(player, 1, animate);
} else if (deltaY > SWIPE_THRESHOLD) {
const last = state.gameLog[0];
if (state.gameState === 'playing' && last && last.action === 'point' && last.player === player) {
await handleUndo(true);
}
}
}
function manualEndGame() {
state.gameState = 'ended';
let winner = null;
let winnerName = '';
if (state.scoreA > state.scoreB) {
winner = 'A';
winnerName = state.playerA;
state.gamesWonA += 1;
} else if (state.scoreB > state.scoreA) {
winner = 'B';
winnerName = state.playerB;
state.gamesWonB += 1;
}
addHistoryEntry('end_game', {
game: state.currentGame,
winner,
winnerName,
leftPlayerName: state.playerA,
rightPlayerName: state.playerB,
scoreA: state.scoreA,
scoreB: state.scoreB,
gamesWonA: state.gamesWonA,
gamesWonB: state.gamesWonB
});
state.currentGame += 1;
state.scoreA = 0;
state.scoreB = 0;
syncScoreUI();
syncGameButtonUI();
syncTimerLoop();
saveState();
}
async function handleGameToggle() {
if (isProcessingClick || isFlashing) return;
lockClicks();
if (state.gameState === 'playing') {
manualEndGame();
return;
}
await startGameFlow();
}
async function handleNew() {
const ok = await showConfirmDialog('Start a new match? This will clear all game log and scores.');
if (!ok) return;
localStorage.removeItem(STORAGE_KEY);
state = normalizeState();
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
syncUI();
syncTimerLoop();
saveState();
}
async function handleUndo(silent = false) {
if (state.gameLog.length === 0) return;
if (!silent && isProcessingClick) return;
if (!silent) lockClicks();
let index = 0;
let hiddenOnTop = false;
if (state.gameLog[0] && state.gameLog[0].action === 'auto_end_game') {
hiddenOnTop = true;
index = 1;
}
const lastEntry = state.gameLog[index];
if (!lastEntry) return;
if (!silent) {
const entrySeconds = parseTimeToSeconds(lastEntry.time);
const currentSeconds = state.timerElapsed;
const minutesAgo = Math.floor((currentSeconds - entrySeconds) / 60);
if (minutesAgo >= 1) {
const ok = await showConfirmDialog(`This action happened ${minutesAgo} minute${minutesAgo > 1 ? 's' : ''} ago. Are you sure you want to undo?`);
if (!ok) return;
}
}
if (lastEntry.action === 'end_game') {
state.gameState = 'playing';
state.currentGame = Math.max(1, state.currentGame - 1);
if (lastEntry.winner === 'A') state.gamesWonA = Math.max(0, state.gamesWonA - 1);
if (lastEntry.winner === 'B') state.gamesWonB = Math.max(0, state.gamesWonB - 1);
state.scoreA = Number.isFinite(lastEntry.scoreA) ? lastEntry.scoreA : (lastEntry.leftScore || 0);
state.scoreB = Number.isFinite(lastEntry.scoreB) ? lastEntry.scoreB : (lastEntry.rightScore || 0);
state.gameLog.splice(index, 1);
state.manualEndMode = hasAutoEndInCurrentGame();
} else if (lastEntry.action === 'point') {
state.scoreA = Number.isFinite(lastEntry.prevScoreA) ? lastEntry.prevScoreA : Math.max(0, state.scoreA - (lastEntry.player === 'A' ? 1 : 0));
state.scoreB = Number.isFinite(lastEntry.prevScoreB) ? lastEntry.prevScoreB : Math.max(0, state.scoreB - (lastEntry.player === 'B' ? 1 : 0));
if (hiddenOnTop) {
state.gameLog.splice(0, 2);
state.manualEndMode = false;
} else {
state.gameLog.splice(index, 1);
state.manualEndMode = hasAutoEndInCurrentGame();
}
} else if (lastEntry.action === 'start_game') {
if (state.gameLog.length === 1) {
state.firstGameStartTime = null;
state.timerElapsed = 0;
state.gameState = 'not_started';
state.currentGame = 1;
state.gamesWonA = 0;
state.gamesWonB = 0;
state.scoreA = 0;
state.scoreB = 0;
state.gameLog = [];
state.manualEndMode = false;
state.customMatch = false;
} else {
state.gameLog.splice(index, 1);
state.gameState = 'ended';
state.scoreA = 0;
state.scoreB = 0;
}
} else if (lastEntry.action === 'switch_sides') {
doSwitchSides();
state.gameLog.splice(index, 1);
} else {
state.gameLog.splice(index, 1);
}
syncScoreUI();
syncGameButtonUI();
renderHistory();
syncTimerLoop();
updateTimerDisplay();
saveState();
}
function gameLogEntryToExportText(entry) {
const leftName = entry.leftPlayerName || state.playerA;
const rightName = entry.rightPlayerName || state.playerB;
const scoreA = getHistoryScoreA(entry) ?? 0;
const scoreB = getHistoryScoreB(entry) ?? 0;
switch (entry.action) {
case 'start_game':
return `Game ${entry.game} Started (${leftName} vs ${rightName})`;
case 'point':
return `${scoreA} : ${scoreB} (+1 ${entry.playerName || (entry.player === 'A' ? state.playerA : state.playerB)})`;
case 'end_game':
return `Game ${entry.game} Ended (${entry.winnerName || ''}, ${scoreA} : ${scoreB})`;
case 'switch_sides':
return `Sides switched (${leftName} vs ${rightName})`;
default:
return '';
}
}
function handleExport() {
const visible = state.gameLog.filter((e) => e.action !== 'auto_end_game' && e.action !== 'free_scoring_started');
if (visible.length === 0) {
alert('No game log to export');
return;
}
let filename = `${state.playerA} vs ${state.playerB}`;
if (state.firstGameStartTime) {
const d = new Date(state.firstGameStartTime);
const date = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
const time = `${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}${String(d.getSeconds()).padStart(2, '0')}`;
filename += ` ${date}_${time}`;
}
filename += '.txt';
const entries = [...visible].reverse();
let content = '';
entries.forEach((entry, idx) => {
const sec = parseTimeToSeconds(entry.time);
const stamp = formatExportTime(sec);
const text = gameLogEntryToExportText(entry);
if (!text) return;
content += `${idx + 1}\n${stamp} --> ${stamp}\n${text}\n\n`;
});
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
function showConfirmDialog(message) {
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay confirm-dialog';
const modal = document.createElement('div');
modal.className = 'modal-content';
const messageEl = document.createElement('p');
messageEl.textContent = message;
modal.appendChild(messageEl);
const buttons = document.createElement('div');
buttons.className = 'modal-buttons';
const noBtn = document.createElement('button');
noBtn.className = 'btn-cancel';
noBtn.textContent = 'No';
noBtn.addEventListener('click', () => {
document.body.removeChild(overlay);
resolve(false);
});
const yesBtn = document.createElement('button');
yesBtn.className = 'btn-save';
yesBtn.textContent = 'Yes';
yesBtn.addEventListener('click', () => {
document.body.removeChild(overlay);
resolve(true);
});
buttons.appendChild(noBtn);
buttons.appendChild(yesBtn);
modal.appendChild(buttons);
overlay.appendChild(modal);
document.body.appendChild(overlay);
});
}
function setupNameEditing(element, player) {
element.addEventListener('click', () => {
const colorOptions = ['#000000', '#2196f3', '#e94560', '#4caf50', '#ff9800', '#ffffff'];
const currentName = player === 'A' ? state.playerA : state.playerB;
const currentColor = player === 'A' ? state.playerColorA : state.playerColorB;
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
const modal = document.createElement('div');
modal.className = 'modal-content';
const title = document.createElement('h3');
title.textContent = `Edit ${currentName}`;
modal.appendChild(title);
const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.value = currentName;
modal.appendChild(nameInput);
const colorPicker = document.createElement('div');
colorPicker.className = 'color-picker';
let selectedColor = currentColor;
const selectionBorder = getComputedStyle(document.documentElement).getPropertyValue('--selection-border').trim();
colorOptions.forEach((color) => {
const option = document.createElement('div');
option.className = 'color-option';
if (color === '#ffffff') option.classList.add('white-option');
option.style.backgroundColor = color;
if (color === selectionBorder) option.classList.add('two-tone');
if (color.toLowerCase() === currentColor.toLowerCase()) {
option.classList.add('selected');
}
option.addEventListener('click', () => {
colorPicker.querySelectorAll('.color-option').forEach((node) => node.classList.remove('selected'));
option.classList.add('selected');
selectedColor = color;
});
colorPicker.appendChild(option);
});
modal.appendChild(colorPicker);
const buttons = document.createElement('div');
buttons.className = 'modal-buttons';
const cancelBtn = document.createElement('button');
cancelBtn.className = 'btn-cancel';
cancelBtn.textContent = 'Cancel';
cancelBtn.addEventListener('click', () => {
document.body.removeChild(overlay);
});
const saveBtn = document.createElement('button');
saveBtn.className = 'btn-save';
saveBtn.textContent = 'Save';
saveBtn.addEventListener('click', () => {
const fallbackName = player === 'A' ? 'Player A' : 'Player B';
const oldName = player === 'A' ? state.playerA : state.playerB;
const newName = nameInput.value.trim() || fallbackName;
if (player === 'A') {
state.playerA = newName;
state.playerColorA = selectedColor;
} else {
state.playerB = newName;
state.playerColorB = selectedColor;
}
for (const entry of state.gameLog) {
if (entry.action === 'point' && entry.player === player) {
entry.playerName = newName;
}
if (entry.action === 'end_game' && entry.winner === player) {
entry.winnerName = newName;
}
if (entry.leftPlayerName === oldName) entry.leftPlayerName = newName;
if (entry.rightPlayerName === oldName) entry.rightPlayerName = newName;
}
syncPlayerUI();
renderHistory();
saveState();
document.body.removeChild(overlay);
});
buttons.appendChild(cancelBtn);
buttons.appendChild(saveBtn);
modal.appendChild(buttons);
overlay.appendChild(modal);
document.body.appendChild(overlay);
nameInput.focus();
nameInput.select();
});
}
function setupTouchHandlers(element, player) {
let startX = null;
let startY = null;
let hasMoved = false;
let direction = null;
let trackingDeltaY = 0;
let touchHandled = false;
let oldScoreClone = null;
let newScoreClone = null;
const scoreElement = player === 'A' ? elements.scoreA : elements.scoreB;
const scoreBtn = player === 'A' ? elements.scoreBtnA : elements.scoreBtnB;
function clearPreview(showRealScore = true) {
if (showRealScore) {
scoreElement.style.opacity = '1';
}
if (oldScoreClone) oldScoreClone.remove();
if (newScoreClone) newScoreClone.remove();
oldScoreClone = null;
newScoreClone = null;
}
function createPreviewNodes(directionNow, currentScore, scoreGap, textColor, canUndo, prevScore) {
oldScoreClone = document.createElement('div');
oldScoreClone.textContent = currentScore;
oldScoreClone.style.cssText = `
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
font-family: ui-monospace, 'SF Mono', 'Fira Code', 'Roboto Mono', 'Consolas', monospace;
font-size: ${getComputedStyle(scoreElement).fontSize};
font-weight: bold;
color: ${textColor};
text-shadow: 0 0.05em 0.1em rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 10;
pointer-events: none;
`;
newScoreClone = document.createElement('div');
newScoreClone.textContent = directionNow === 'up' ? currentScore + 1 : (canUndo ? prevScore : '');
newScoreClone.style.cssText = `
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
font-family: ui-monospace, 'SF Mono', 'Fira Code', 'Roboto Mono', 'Consolas', monospace;
font-size: ${getComputedStyle(scoreElement).fontSize};
font-weight: bold;
color: ${textColor};
text-shadow: 0 0.05em 0.1em rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 9;
pointer-events: none;
transform: translateY(${directionNow === 'up' ? scoreGap : -scoreGap}px);
`;
scoreBtn.appendChild(oldScoreClone);
scoreBtn.appendChild(newScoreClone);
scoreElement.style.opacity = '0';
}
element.addEventListener('touchstart', (e) => {
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
hasMoved = false;
direction = null;
trackingDeltaY = 0;
touchHandled = false;
clearPreview(false);
}, { passive: true });
element.addEventListener('touchmove', (e) => {
if (startX === null || startY === null) return;
const dx = e.touches[0].clientX - startX;
const dy = e.touches[0].clientY - startY;
if (!hasMoved && Math.abs(dy) > Math.abs(dx) && Math.abs(dy) > 10) {
hasMoved = true;
direction = dy > 0 ? 'down' : 'up';
trackingDeltaY = dy;
}
if (!hasMoved) return;
if (Math.abs(dy) > Math.abs(dx)) {
e.preventDefault();
}
const currentDirection = dy > 0 ? 'down' : 'up';
const currentDelta = dy - trackingDeltaY;
const fontSize = parseFloat(getComputedStyle(scoreElement).fontSize);
const scoreGap = (scoreBtn.offsetHeight + fontSize) / 2;
const currentScore = Number.parseInt(scoreElement.textContent, 10) || 0;
const textColor = getScoreTextColor(player === 'A' ? state.playerColorA : state.playerColorB);
const lastEntry = state.gameLog[0];
const canUndo = state.gameState === 'playing' && lastEntry && lastEntry.action === 'point' && lastEntry.player === player;
const prevScore = canUndo ? (player === 'A' ? lastEntry.prevScoreA : lastEntry.prevScoreB) : '';
if (currentDirection !== direction || !oldScoreClone || !newScoreClone) {
direction = currentDirection;
trackingDeltaY = dy;
clearPreview(false);
createPreviewNodes(direction, currentScore, scoreGap, textColor, canUndo, prevScore);
}
if (!oldScoreClone || !newScoreClone) return;
if (direction === 'up') {
const clampedDelta = Math.max(-scoreGap, Math.min(0, currentDelta));
oldScoreClone.style.transform = `translateY(${clampedDelta}px)`;
newScoreClone.style.transform = `translateY(${scoreGap + clampedDelta}px)`;
} else {
const clampedDelta = Math.max(0, Math.min(scoreGap, currentDelta));
oldScoreClone.style.transform = `translateY(${clampedDelta}px)`;
newScoreClone.style.transform = `translateY(${clampedDelta - scoreGap}px)`;
}
}, { passive: false });
element.addEventListener('touchend', async (e) => {
if (startX === null || startY === null) return;
const dx = e.changedTouches[0].clientX - startX;
const dy = e.changedTouches[0].clientY - startY;
const fontSize = parseFloat(getComputedStyle(scoreElement).fontSize);
const scoreGap = (scoreBtn.offsetHeight + fontSize) / 2;
const currentDelta = dy - trackingDeltaY;
const lastEntry = state.gameLog[0];
const canUndo = state.gameState === 'playing' && lastEntry && lastEntry.action === 'point' && lastEntry.player === player;
startX = null;
startY = null;
if (hasMoved && direction === 'up' && currentDelta < -SWIPE_THRESHOLD) {
touchHandled = true;
clearPreview();
await handleSwipe(player, dy, false);
} else if (hasMoved && direction === 'down' && currentDelta > SWIPE_THRESHOLD && canUndo) {
touchHandled = true;
clearPreview();
await handleUndo(true);
} else if (hasMoved && oldScoreClone && newScoreClone) {
oldScoreClone.style.transition = 'transform 0.15s ease-out';
newScoreClone.style.transition = 'transform 0.15s ease-out';
oldScoreClone.style.transform = 'translateY(0)';
newScoreClone.style.transform = direction === 'up' ? `translateY(${scoreGap}px)` : `translateY(-${scoreGap}px)`;
setTimeout(() => {
clearPreview();
}, 150);
} else if (Math.abs(dy) > Math.abs(dx) && Math.abs(dy) > SWIPE_THRESHOLD) {
touchHandled = true;
await handleSwipe(player, dy, true);
}
hasMoved = false;
direction = null;
trackingDeltaY = 0;
});
element.addEventListener('click', (e) => {
if (touchHandled) {
touchHandled = false;
return;
}
e.preventDefault();
handleScoreClick(player);
});
}
function updateScoreFontSize() {
const main = document.querySelector('.main-area');
if (!main || main.offsetHeight <= 0) return;
const maxFromHeight = main.offsetHeight * 0.8;
const maxFromWidth = main.offsetWidth * 0.4;
const buttonSize = Math.min(maxFromHeight, maxFromWidth);
const fontSize = buttonSize * 0.75;
elements.scoreBtnA.style.flexBasis = `${buttonSize}px`;
elements.scoreBtnA.style.width = `${buttonSize}px`;
elements.scoreBtnA.style.height = `${buttonSize}px`;
elements.scoreBtnA.style.marginRight = `${buttonSize * 0.15}px`;
elements.scoreBtnB.style.flexBasis = `${buttonSize}px`;
elements.scoreBtnB.style.width = `${buttonSize}px`;
elements.scoreBtnB.style.height = `${buttonSize}px`;
elements.scoreBtnB.style.marginLeft = `${buttonSize * 0.15}px`;
elements.scoreA.style.fontSize = `${fontSize}px`;
elements.scoreB.style.fontSize = `${fontSize}px`;
const vsText = document.querySelector('.vs-text');
if (vsText) {
vsText.style.fontSize = `${fontSize * 0.6}px`;
}
}
function init() {
recomputeElapsed();
syncUI();
syncTimerLoop();
updateScoreFontSize();
setupNameEditing(elements.nameA, 'A');
setupNameEditing(elements.nameB, 'B');
setupTouchHandlers(elements.scoreBtnA, 'A');
setupTouchHandlers(elements.scoreBtnB, 'B');
elements.gameBtn.addEventListener('click', handleGameToggle);
elements.newBtn.addEventListener('click', handleNew);
elements.undoBtn.addEventListener('click', () => handleUndo(false));
elements.exportBtn.addEventListener('click', handleExport);
window.addEventListener('resize', updateScoreFontSize);
}
init();