1. <!DOCTYPE html>
  2. <html lang="en">
  3.   <meta charset="UTF-8">
  4.   <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
  5.   <title>Badminton Score Keeper</title>
  6.   <link rel="icon" type="image/svg+xml" href="favicon.svg">
  7.   <style>
  8.     * {
  9.       margin: 0;
  10.       padding: 0;
  11.       box-sizing: border-box;
  12.       -webkit-tap-highlight-color: transparent;
  13.     }
  14.  
  15.     :root {
  16.       --bg-primary: #0f3460;
  17.       --bg-secondary: #16213e;
  18.       --border-color: #3b4272;
  19.       --text-primary: #ffffff;
  20.       --text-secondary: #d1d5db;
  21.       --text-muted: #9ca3af;
  22.       --accent: #06b6d4;
  23.       --accent-dark: #0891b2;
  24.       --btn-gray: #333333;
  25.       --btn-gray-dark: #444444;
  26.       --timer-color: #ff9800;
  27.       --selection-border: #ffffff;
  28.       --radius-sm: 6px;
  29.       --radius-md: 12px;
  30.       --btn-padding: 8px 16px;
  31.       --ui-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  32.       --mono-font: ui-monospace, 'SF Mono', 'Fira Code', 'Roboto Mono', 'Consolas', monospace;
  33.     }
  34.  
  35.     @media (prefers-color-scheme: light) {
  36.       :root {
  37.         --bg-primary: #f5f5f5;
  38.         --bg-secondary: #ffffff;
  39.         --border-color: #d1d5db;
  40.         --text-primary: #1a1a1a;
  41.         --text-secondary: #4a4a4a;
  42.         --text-muted: #888888;
  43.         --accent: #0891b2;
  44.         --accent-dark: #06b6d4;
  45.         --btn-gray: #e5e5e5;
  46.         --btn-gray-dark: #d1d5db;
  47.         --timer-color: #d97706;
  48.         --selection-border: #000000;
  49.       }
  50.     }
  51.  
  52.     html, body {
  53.       height: 100%;
  54.       min-height: 100dvh;
  55.       margin: 0;
  56.       padding: 0;
  57.       overflow: hidden;
  58.       overflow-x: hidden;
  59.     }
  60.  
  61.     body {
  62.       font-family: var(--ui-font);
  63.       background: var(--bg-primary);
  64.       color: var(--text-primary);
  65.       display: flex;
  66.       flex-direction: column;
  67.       touch-action: none;
  68.       user-select: none;
  69.       max-width: 100vw;
  70.       align-items: stretch;
  71.     }
  72.  
  73.     .hidden {
  74.       display: none !important;
  75.     }
  76.  
  77.     .header {
  78.       display: flex;
  79.       justify-content: space-between;
  80.       align-items: center;
  81.       padding: 0 12px;
  82.       background: var(--bg-secondary);
  83.       border-bottom: 1px solid var(--border-color);
  84.       height: 50px;
  85.       flex-shrink: 0;
  86.       box-sizing: border-box;
  87.     }
  88.  
  89.     .header-left,
  90.     .header-right {
  91.       flex: 1;
  92.       min-width: 0;
  93.       display: flex;
  94.       align-items: center;
  95.     }
  96.  
  97.     .header-left {
  98.       justify-content: flex-start;
  99.     }
  100.  
  101.     .header-right {
  102.       justify-content: flex-end;
  103.     }
  104.  
  105.     .header-center {
  106.       display: flex;
  107.       justify-content: center;
  108.       align-items: center;
  109.       padding: 0 12px;
  110.     }
  111.  
  112.     .header-right .player-name {
  113.       text-align: right;
  114.     }
  115.  
  116.     .player-name {
  117.       font-size: 16px;
  118.       color: var(--text-secondary);
  119.       cursor: pointer;
  120.       padding: 4px 8px;
  121.       display: inline-block;
  122.       min-width: 0;
  123.       overflow: hidden;
  124.       text-overflow: ellipsis;
  125.       white-space: nowrap;
  126.       border-bottom: 1px dashed var(--text-muted);
  127.     }
  128.  
  129.     .player-name:hover {
  130.       border-bottom-color: var(--accent);
  131.       border-bottom-style: solid;
  132.     }
  133.  
  134.     .main-area {
  135.       width: 100%;
  136.       height: calc((100vw - 10px) * 2 / 3);
  137.       max-height: calc(100vh - 180px);
  138.       padding: 10px 5px;
  139.       text-align: center;
  140.       display: flex;
  141.       flex-direction: column;
  142.       gap: 10px;
  143.       justify-content: center;
  144.       box-sizing: border-box;
  145.     }
  146.  
  147.     .scores-container {
  148.       display: flex;
  149.       justify-content: center;
  150.       align-items: center;
  151.       gap: 0;
  152.       flex: 1;
  153.       min-height: 0;
  154.       position: relative;
  155.     }
  156.  
  157.     .vs-text {
  158.       position: absolute;
  159.       left: 50%;
  160.       transform: translateX(-50%);
  161.       color: var(--text-muted);
  162.       font-weight: bold;
  163.       pointer-events: none;
  164.     }
  165.  
  166.     .score-button {
  167.       background: var(--bg-secondary);
  168.       border-radius: var(--radius-md);
  169.       display: flex;
  170.       flex-direction: column;
  171.       justify-content: center;
  172.       align-items: center;
  173.       cursor: pointer;
  174.       position: relative;
  175.       overflow: hidden;
  176.       transition: transform 0.1s, background 0.2s;
  177.     }
  178.  
  179.     .score-button:active {
  180.       transform: scale(0.95);
  181.     }
  182.  
  183.     .score-value {
  184.       font-family: var(--mono-font);
  185.       font-size: 2.67ch;
  186.       font-weight: bold;
  187.       text-shadow: 0 0.05em 0.1em rgba(0, 0, 0, 0.5);
  188.       transition: transform 0.15s;
  189.     }
  190.  
  191.     @keyframes flash {
  192.       0%, 50%, 100% { opacity: 1; }
  193.       25%, 75% { opacity: 0.3; }
  194.     }
  195.  
  196.     .score-value.flash {
  197.       animation: flash 0.2s ease-in-out 5;
  198.     }
  199.  
  200.     .game-btn,
  201.     .undo-btn,
  202.     .new-btn {
  203.       background: var(--accent);
  204.       color: white;
  205.       border: none;
  206.       padding: var(--btn-padding);
  207.       font-size: 14px;
  208.       border-radius: var(--radius-sm);
  209.       cursor: pointer;
  210.       transition: background 0.2s, transform 0.1s;
  211.     }
  212.  
  213.     .game-btn:active,
  214.     .undo-btn:active,
  215.     .new-btn:active {
  216.       transform: scale(0.95);
  217.       background: var(--accent-dark);
  218.     }
  219.  
  220.     .game-number {
  221.       font-size: 16px;
  222.       font-weight: bold;
  223.       color: var(--text-primary);
  224.     }
  225.  
  226.     .game-log-section {
  227.       flex: 1;
  228.       display: flex;
  229.       flex-direction: column;
  230.       background: var(--bg-secondary);
  231.       border-top: 1px solid var(--border-color);
  232.       min-height: 100px;
  233.       overflow: hidden;
  234.     }
  235.  
  236.     .game-log-header {
  237.       display: flex;
  238.       justify-content: space-between;
  239.       align-items: center;
  240.       padding: 6px 10px;
  241.       border-bottom: 1px solid var(--border-color);
  242.       flex-shrink: 0;
  243.     }
  244.  
  245.     .game-log-header-left,
  246.     .game-log-header-right {
  247.       display: flex;
  248.       align-items: center;
  249.     }
  250.  
  251.     .game-log-header-left {
  252.       gap: 12px;
  253.     }
  254.  
  255.     .timer {
  256.       font-family: var(--mono-font);
  257.       font-size: 16px;
  258.       color: var(--timer-color);
  259.       font-weight: bold;
  260.     }
  261.  
  262.     .game-log-title {
  263.       font-size: 14px;
  264.       color: var(--text-muted);
  265.     }
  266.  
  267.     .export-btn {
  268.       background: var(--accent);
  269.       color: white;
  270.       border: none;
  271.       padding: var(--btn-padding);
  272.       font-size: 12px;
  273.       border-radius: var(--radius-sm);
  274.       cursor: pointer;
  275.       transition: background 0.2s;
  276.     }
  277.  
  278.     .export-btn:hover {
  279.       background: var(--accent-dark);
  280.     }
  281.  
  282.     .game-log-list {
  283.       flex: 1;
  284.       overflow-y: auto;
  285.       padding: 0 16px 8px 16px;
  286.       min-height: 80px;
  287.     }
  288.  
  289.     .game-log-item {
  290.       font-family: var(--ui-font);
  291.       font-size: 13px;
  292.       padding: 6px 0;
  293.       color: var(--text-muted);
  294.       position: relative;
  295.     }
  296.  
  297.     .game-log-item:first-child {
  298.       padding-top: 8px;
  299.     }
  300.  
  301.     .game-log-item:not(:last-child)::after {
  302.       content: '';
  303.       position: absolute;
  304.       bottom: 0;
  305.       left: 0;
  306.       right: 0;
  307.       height: 1px;
  308.       background: var(--border-color);
  309.     }
  310.  
  311.     .game-log-item:last-child {
  312.       color: var(--text-secondary);
  313.     }
  314.  
  315.     .footer {
  316.       display: flex;
  317.       align-items: center;
  318.       padding: 8px 12px;
  319.       background: var(--bg-secondary);
  320.       border-top: 1px solid var(--border-color);
  321.       flex-shrink: 0;
  322.     }
  323.  
  324.     .footer-favicon {
  325.       height: 30px;
  326.       width: auto;
  327.       flex-shrink: 0;
  328.       margin-right: 20px;
  329.     }
  330.  
  331.     .footer-spacer {
  332.       flex-shrink: 0;
  333.       width: 50px;
  334.     }
  335.  
  336.     .footer-buttons {
  337.       flex: 1;
  338.       display: flex;
  339.       justify-content: center;
  340.       gap: 12px;
  341.     }
  342.  
  343.     .footer-buttons button {
  344.       flex: 1;
  345.     }
  346.  
  347.     .modal-overlay {
  348.       position: fixed;
  349.       top: 0;
  350.       left: 0;
  351.       right: 0;
  352.       bottom: 0;
  353.       background: rgba(0, 0, 0, 0.7);
  354.       display: flex;
  355.       justify-content: center;
  356.       align-items: flex-start;
  357.       padding-top: 10vh;
  358.       z-index: 1000;
  359.       overflow-y: auto;
  360.     }
  361.  
  362.     .modal-content {
  363.       background: var(--bg-secondary);
  364.       padding: 24px;
  365.       border-radius: var(--radius-md);
  366.       text-align: center;
  367.       min-width: 280px;
  368.       max-height: 80vh;
  369.       overflow-y: auto;
  370.       margin: auto;
  371.     }
  372.  
  373.     .modal-content h3 {
  374.       margin-bottom: 16px;
  375.       color: var(--text-primary);
  376.     }
  377.  
  378.     .modal-content input[type="text"] {
  379.       width: 100%;
  380.       padding: 10px;
  381.       font-size: 16px;
  382.       border: 1px solid var(--border-color);
  383.       border-radius: var(--radius-sm);
  384.       background: var(--bg-primary);
  385.       color: var(--text-primary);
  386.       margin-bottom: 12px;
  387.       box-sizing: border-box;
  388.     }
  389.  
  390.     .modal-content input[type="color"] {
  391.       width: 100%;
  392.       height: 50px;
  393.       border: none;
  394.       border-radius: var(--radius-sm);
  395.       cursor: pointer;
  396.       margin-bottom: 16px;
  397.     }
  398.  
  399.     .modal-content .color-picker {
  400.       display: flex;
  401.       gap: 8px;
  402.       justify-content: center;
  403.       flex-wrap: wrap;
  404.       margin-bottom: 16px;
  405.     }
  406.  
  407.     .modal-content .color-option {
  408.       flex: 0 0 40px;
  409.       width: 40px;
  410.       height: 40px;
  411.       display: block;
  412.       border-radius: 50%;
  413.       cursor: pointer;
  414.       transition: transform 0.2s, border-color 0.2s;
  415.       box-shadow: 0 2px 4px rgba(0,0,0,0.3);
  416.     }
  417.  
  418.     .modal-content .color-option.selected {
  419.       outline: 3px solid var(--selection-border);
  420.       outline-offset: 2px;
  421.     }
  422.  
  423.     .modal-content .color-option.selected:not(.two-tone) {
  424.       outline-width: 3px;
  425.       outline-color: var(--selection-border);
  426.       outline-offset: 0;
  427.     }
  428.  
  429.     .modal-content .modal-buttons {
  430.       display: flex;
  431.       gap: 12px;
  432.       justify-content: center;
  433.     }
  434.  
  435.     .modal-content button {
  436.       padding: 10px 24px;
  437.       font-size: 14px;
  438.       border: none;
  439.       border-radius: var(--radius-sm);
  440.       cursor: pointer;
  441.       min-width: 100px;
  442.       width: 100px;
  443.       text-align: center;
  444.     }
  445.  
  446.     .modal-content .btn-cancel {
  447.       background: var(--btn-gray);
  448.       color: white;
  449.     }
  450.  
  451.     .modal-content .btn-save {
  452.       background: var(--accent);
  453.       color: white;
  454.     }
  455.  
  456.     .confirm-dialog .modal-content {
  457.       min-width: 260px;
  458.     }
  459.  
  460.     .confirm-dialog .modal-content p {
  461.       margin-bottom: 20px;
  462.       color: var(--text-secondary);
  463.       font-size: 15px;
  464.     }
  465.  
  466.   </style>
  467. </head>
  468.   <header class="header">
  469.     <div class="header-left">
  470.       <div class="player-name" id="nameA">Player A</div>
  471.     </div>
  472.     <div class="header-center">
  473.       <div class="game-number" id="gameNumber">0 : 0</div>
  474.     </div>
  475.     <div class="header-right">
  476.       <div class="player-name" id="nameB">Player B</div>
  477.     </div>
  478.   </header>
  479.  
  480.   <main class="main-area">
  481.     <div class="scores-container">
  482.       <div class="score-button" id="scoreBtnA" data-player="A">
  483.         <div class="score-value" id="scoreA">0</div>
  484.       </div>
  485.       <div class="vs-text">:</div>
  486.       <div class="score-button" id="scoreBtnB" data-player="B">
  487.         <div class="score-value" id="scoreB">0</div>
  488.       </div>
  489.     </div>
  490.  
  491.   </main>
  492.  
  493.   <section class="game-log-section">
  494.     <div class="game-log-header">
  495.       <div class="game-log-header-left">
  496.         <span class="game-log-title">Game Log</span>
  497.         <button class="export-btn" id="exportBtn">Export</button>
  498.       </div>
  499.       <div class="game-log-header-right">
  500.         <div class="timer" id="timer">00:00</div>
  501.       </div>
  502.     </div>
  503.     <div class="game-log-list" id="gameLogList"></div>
  504.   </section>
  505.  
  506.   <footer class="footer">
  507.     <img class="footer-favicon" src="favicon.svg" alt="Logo">
  508.     <div class="footer-buttons">
  509.       <button class="new-btn" id="newBtn">New Match</button>
  510.       <button class="game-btn" id="gameBtn">Start Game</button>
  511.       <button class="undo-btn" id="undoBtn">Undo</button>
  512.     </div>
  513.     <div class="footer-spacer"></div>
  514.   </footer>
  515.  
  516.   <script>
  517.     const STORAGE_KEY = 'badminton_score_keeper';
  518.     const SWIPE_THRESHOLD = 30;
  519.     const CLICK_LOCK_MS = 100;
  520.     const AUTO_END_DELAY_MS = 2000;
  521.     const WIN_POINTS = 21;
  522.     const DEUCE_TRIGGER = 20;
  523.     const MAX_POINTS = 30;
  524.  
  525.     const DEFAULT_STATE = {
  526.       playerA: 'Player A',
  527.       playerB: 'Player B',
  528.       playerColorA: '#e94560',
  529.       playerColorB: '#2196f3',
  530.       scoreA: 0,
  531.       scoreB: 0,
  532.       gamesWonA: 0,
  533.       gamesWonB: 0,
  534.       currentGame: 1,
  535.       gameState: 'not_started',
  536.       timerElapsed: 0,
  537.       gameLog: [],
  538.       firstGameStartTime: null,
  539.       manualEndMode: false,
  540.       customMatch: false
  541.     };
  542.  
  543.     const elements = {
  544.       nameA: document.getElementById('nameA'),
  545.       nameB: document.getElementById('nameB'),
  546.       timer: document.getElementById('timer'),
  547.       scoreA: document.getElementById('scoreA'),
  548.       scoreB: document.getElementById('scoreB'),
  549.       scoreBtnA: document.getElementById('scoreBtnA'),
  550.       scoreBtnB: document.getElementById('scoreBtnB'),
  551.       gameBtn: document.getElementById('gameBtn'),
  552.       gameNumber: document.getElementById('gameNumber'),
  553.       gameLogList: document.getElementById('gameLogList'),
  554.       newBtn: document.getElementById('newBtn'),
  555.       undoBtn: document.getElementById('undoBtn'),
  556.       exportBtn: document.getElementById('exportBtn')
  557.     };
  558.  
  559.     let state = loadState();
  560.     let timerInterval = null;
  561.     let isProcessingClick = false;
  562.     let isFlashing = false;
  563.  
  564.     function normalizeState(raw) {
  565.       const merged = { ...DEFAULT_STATE, ...(raw || {}) };
  566.       merged.gameLog = Array.isArray(merged.gameLog) ? [...merged.gameLog] : [];
  567.       if (!merged.playerColorA) merged.playerColorA = DEFAULT_STATE.playerColorA;
  568.       if (!merged.playerColorB) merged.playerColorB = DEFAULT_STATE.playerColorB;
  569.       if (!Number.isFinite(merged.timerElapsed) || merged.timerElapsed < 0) {
  570.        merged.timerElapsed = 0;
  571.      }
  572.      if (!['not_started', 'playing', 'ended'].includes(merged.gameState)) {
  573.        merged.gameState = 'not_started';
  574.      }
  575.      if (!Number.isFinite(merged.currentGame) || merged.currentGame < 1) {
  576.        merged.currentGame = 1;
  577.      }
  578.      return merged;
  579.    }
  580.  
  581.    function loadState() {
  582.      try {
  583.        const saved = localStorage.getItem(STORAGE_KEY);
  584.        if (!saved) return normalizeState();
  585.        return normalizeState(JSON.parse(saved));
  586.      } catch (err) {
  587.        return normalizeState();
  588.      }
  589.    }
  590.  
  591.    function saveState() {
  592.      localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
  593.    }
  594.  
  595.    function isMatchWon() {
  596.      return state.gamesWonA >= 2 || state.gamesWonB >= 2;
  597.     }
  598.  
  599.     function formatTime(seconds) {
  600.       const safeSeconds = Math.max(0, Number.isFinite(seconds) ? Math.floor(seconds) : 0);
  601.       const h = Math.floor(safeSeconds / 3600);
  602.       const m = Math.floor((safeSeconds % 3600) / 60);
  603.       const s = safeSeconds % 60;
  604.       if (h > 0) {
  605.         return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
  606.       }
  607.       return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
  608.     }
  609.  
  610.     function formatExportTime(seconds) {
  611.       const safeSeconds = Math.max(0, Number.isFinite(seconds) ? Math.floor(seconds) : 0);
  612.       const h = Math.floor(safeSeconds / 3600);
  613.       const m = Math.floor((safeSeconds % 3600) / 60);
  614.       const s = safeSeconds % 60;
  615.       return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
  616.     }
  617.  
  618.     function parseTimeToSeconds(timeStr) {
  619.       if (typeof timeStr !== 'string') return 0;
  620.       const parts = timeStr.split(':').map(Number);
  621.       if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
  622.       if (parts.length === 2) return parts[0] * 60 + parts[1];
  623.       return 0;
  624.     }
  625.  
  626.     function shouldTimerRun() {
  627.       if (!state.firstGameStartTime) return false;
  628.       if (state.customMatch) return true;
  629.       return !isMatchWon();
  630.     }
  631.  
  632.     function recomputeElapsed() {
  633.       if (!state.firstGameStartTime) {
  634.         state.timerElapsed = 0;
  635.         return;
  636.       }
  637.       const elapsed = Math.floor((Date.now() - state.firstGameStartTime) / 1000);
  638.       state.timerElapsed = Math.max(0, elapsed);
  639.     }
  640.  
  641.     function updateTimerDisplay() {
  642.       elements.timer.textContent = formatTime(state.timerElapsed);
  643.     }
  644.  
  645.     function syncTimerLoop() {
  646.       const running = shouldTimerRun();
  647.       if (running && !timerInterval) {
  648.        timerInterval = setInterval(() => {
  649.          recomputeElapsed();
  650.           updateTimerDisplay();
  651.           saveState();
  652.         }, 1000);
  653.       } else if (!running && timerInterval) {
  654.        clearInterval(timerInterval);
  655.         timerInterval = null;
  656.       }
  657.     }
  658.  
  659.     function getScoreTextColor(bgColor) {
  660.       return String(bgColor).toLowerCase() === '#ffffff' ? '#0f3460' : '#ffffff';
  661.     }
  662.  
  663.     function syncPlayerUI() {
  664.       elements.nameA.textContent = state.playerA;
  665.       elements.nameB.textContent = state.playerB;
  666.       elements.scoreBtnA.style.backgroundColor = state.playerColorA;
  667.       elements.scoreBtnB.style.backgroundColor = state.playerColorB;
  668.       elements.scoreA.style.color = getScoreTextColor(state.playerColorA);
  669.       elements.scoreB.style.color = getScoreTextColor(state.playerColorB);
  670.     }
  671.  
  672.     function syncScoreUI() {
  673.       elements.scoreA.textContent = String(state.scoreA);
  674.       elements.scoreB.textContent = String(state.scoreB);
  675.       elements.gameNumber.textContent = `${state.gamesWonA} : ${state.gamesWonB}`;
  676.     }
  677.  
  678.     function syncGameButtonUI() {
  679.       if (state.gameState === 'playing') {
  680.         elements.gameBtn.textContent = 'End Game';
  681.       } else if (state.gameState === 'not_started') {
  682.         elements.gameBtn.textContent = 'Start Game';
  683.       } else {
  684.         elements.gameBtn.textContent = 'Start Game';
  685.       }
  686.     }
  687.  
  688.     function getHistoryScoreA(entry) {
  689.       return Number.isFinite(entry.scoreA) ? entry.scoreA : entry.leftScore;
  690.     }
  691.  
  692.     function getHistoryScoreB(entry) {
  693.       return Number.isFinite(entry.scoreB) ? entry.scoreB : entry.rightScore;
  694.     }
  695.  
  696.     function formatHistoryEntry(entry) {
  697.       const leftName = entry.leftPlayerName || state.playerA;
  698.       const rightName = entry.rightPlayerName || state.playerB;
  699.       const scoreA = getHistoryScoreA(entry) ?? 0;
  700.       const scoreB = getHistoryScoreB(entry) ?? 0;
  701.       switch (entry.action) {
  702.         case 'start_game':
  703.           return `${entry.time} - Game ${entry.game} Started (${leftName} vs ${rightName})`;
  704.         case 'point':
  705.           return `${entry.time} - ${scoreA} : ${scoreB} (+1 ${entry.playerName || (entry.player === 'A' ? state.playerA : state.playerB)})`;
  706.         case 'end_game':
  707.           return `${entry.time} - Game ${entry.game} Ended (${entry.winnerName || ''}, ${scoreA} : ${scoreB})`;
  708.         case 'switch_sides':
  709.           return `${entry.time} - Sides switched (${leftName} vs ${rightName})`;
  710.         default:
  711.           return '';
  712.       }
  713.     }
  714.  
  715.     function renderHistory() {
  716.       elements.gameLogList.innerHTML = '';
  717.       for (let i = 0; i < state.gameLog.length; i++) {
  718.        const entry = state.gameLog[i];
  719.        if (entry.action === 'free_scoring_started' || entry.action === 'auto_end_game') continue;
  720.  
  721.        const div = document.createElement('div');
  722.        div.className = 'game-log-item';
  723.  
  724.        let text = '';
  725.        switch (entry.action) {
  726.          case 'start_game':
  727.            if (entry.game === 1) {
  728.              text = `${entry.time} - Game ${entry.game} started (${entry.leftPlayerName} vs ${entry.rightPlayerName})`;
  729.            } else {
  730.              text = `${entry.time} - Game ${entry.game} started (${entry.leftPlayerName} ${entry.gamesWonA}:${entry.gamesWonB} ${entry.rightPlayerName})`;
  731.            }
  732.            break;
  733.          case 'point':
  734.            text = `${entry.time} - ${entry.leftPlayerName} ${entry.leftScore}:${entry.rightScore} ${entry.rightPlayerName}`;
  735.            break;
  736.          case 'end_game':
  737.            text = `${entry.time} - Game ${entry.game} ended (${entry.leftPlayerName} ${entry.gamesWonA}:${entry.gamesWonB} ${entry.rightPlayerName})`;
  738.            break;
  739.          case 'switch_sides':
  740.            text = `${entry.time} - Sides switched (${entry.leftPlayerName} vs ${entry.rightPlayerName})`;
  741.            break;
  742.        }
  743.        div.textContent = text;
  744.        elements.gameLogList.appendChild(div);
  745.      }
  746.    }
  747.  
  748.    function syncUI() {
  749.      syncPlayerUI();
  750.      syncScoreUI();
  751.      syncGameButtonUI();
  752.      updateTimerDisplay();
  753.      renderHistory();
  754.    }
  755.  
  756.    function addHistoryEntry(action, data = {}) {
  757.      const entry = {
  758.        time: formatTime(state.timerElapsed),
  759.        action,
  760.        ...data
  761.      };
  762.      state.gameLog.unshift(entry);
  763.      renderHistory();
  764.    }
  765.  
  766.    function lockClicks() {
  767.      isProcessingClick = true;
  768.      setTimeout(() => {
  769.         isProcessingClick = false;
  770.       }, CLICK_LOCK_MS);
  771.     }
  772.  
  773.     function hasAutoEndInCurrentGame() {
  774.       for (const entry of state.gameLog) {
  775.         if (entry.action === 'start_game') break;
  776.         if (entry.action === 'auto_end_game') return true;
  777.       }
  778.       return false;
  779.     }
  780.  
  781.     function hasSwitchInCurrentGame() {
  782.       for (const entry of state.gameLog) {
  783.         if (entry.action === 'start_game') break;
  784.         if (entry.action === 'switch_sides') return true;
  785.       }
  786.       return false;
  787.     }
  788.  
  789.     function doSwitchSides() {
  790.       [state.playerA, state.playerB] = [state.playerB, state.playerA];
  791.       [state.playerColorA, state.playerColorB] = [state.playerColorB, state.playerColorA];
  792.       [state.scoreA, state.scoreB] = [state.scoreB, state.scoreA];
  793.       [state.gamesWonA, state.gamesWonB] = [state.gamesWonB, state.gamesWonA];
  794.       syncPlayerUI();
  795.       syncScoreUI();
  796.     }
  797.  
  798.     function checkWinningCondition() {
  799.       const scoreA = state.scoreA;
  800.       const scoreB = state.scoreB;
  801.       if (scoreA < WIN_POINTS && scoreB < WIN_POINTS) {
  802.        return { won: false };
  803.      }
  804.      if (scoreA >= WIN_POINTS && scoreB <= DEUCE_TRIGGER - 1) {
  805.        return { won: true, winner: 'A', winnerName: state.playerA };
  806.       }
  807.       if (scoreB >= WIN_POINTS && scoreA <= DEUCE_TRIGGER - 1) {
  808.        return { won: true, winner: 'B', winnerName: state.playerB };
  809.       }
  810.       if (scoreA >= DEUCE_TRIGGER && scoreB >= DEUCE_TRIGGER) {
  811.        if (scoreA === MAX_POINTS || scoreB === MAX_POINTS) {
  812.          if (scoreA !== scoreB) {
  813.            const winner = scoreA > scoreB ? 'A' : 'B';
  814.             return { won: true, winner, winnerName: winner === 'A' ? state.playerA : state.playerB };
  815.           }
  816.         } else if (Math.abs(scoreA - scoreB) >= 2) {
  817.           const winner = scoreA > scoreB ? 'A' : 'B';
  818.           return { won: true, winner, winnerName: winner === 'A' ? state.playerA : state.playerB };
  819.         }
  820.       }
  821.       return { won: false };
  822.     }
  823.  
  824.     function animateScore(element, direction, oldValue) {
  825.       const parent = element.parentElement;
  826.       const rect = element.getBoundingClientRect();
  827.       const parentRect = parent.getBoundingClientRect();
  828.       const fontSize = parseFloat(getComputedStyle(element).fontSize);
  829.       const scoreGap = (rect.height + fontSize) / 2;
  830.       const isPlayerA = element === elements.scoreA;
  831.       const textColor = getScoreTextColor(isPlayerA ? state.playerColorA : state.playerColorB);
  832.  
  833.       const oldScoreEl = document.createElement('div');
  834.       oldScoreEl.className = 'score-value';
  835.       oldScoreEl.textContent = oldValue;
  836.       oldScoreEl.style.cssText = `
  837.         position: absolute;
  838.         left: ${rect.left - parentRect.left}px;
  839.         top: ${rect.top - parentRect.top}px;
  840.         width: ${rect.width}px;
  841.         height: ${rect.height}px;
  842.         font-size: ${fontSize}px;
  843.         font-weight: bold;
  844.         color: ${textColor};
  845.         text-shadow: 0 0.05em 0.1em rgba(0, 0, 0, 0.5);
  846.         display: flex;
  847.         justify-content: center;
  848.         align-items: center;
  849.         z-index: 10;
  850.         pointer-events: none;
  851.         transition: transform 0.15s ease-out;
  852.       `;
  853.  
  854.       const newScoreEl = document.createElement('div');
  855.       newScoreEl.className = 'score-value';
  856.       newScoreEl.textContent = direction === 1 ? oldValue + 1 : oldValue - 1;
  857.       newScoreEl.style.cssText = `
  858.         position: absolute;
  859.         left: ${rect.left - parentRect.left}px;
  860.         top: ${rect.top - parentRect.top}px;
  861.         width: ${rect.width}px;
  862.         height: ${rect.height}px;
  863.         font-size: ${fontSize}px;
  864.         font-weight: bold;
  865.         color: ${textColor};
  866.         text-shadow: 0 0.05em 0.1em rgba(0, 0, 0, 0.5);
  867.         display: flex;
  868.         justify-content: center;
  869.         align-items: center;
  870.         z-index: 11;
  871.         pointer-events: none;
  872.         transform: translateY(${direction === 1 ? scoreGap : -scoreGap}px);
  873.         transition: transform 0.15s ease-out;
  874.       `;
  875.  
  876.       parent.appendChild(oldScoreEl);
  877.       parent.appendChild(newScoreEl);
  878.       element.style.opacity = '0';
  879.  
  880.       requestAnimationFrame(() => {
  881.         oldScoreEl.style.transform = direction === 1 ? `translateY(-${scoreGap}px)` : `translateY(${scoreGap}px)`;
  882.         newScoreEl.style.transform = 'translateY(0)';
  883.       });
  884.  
  885.       setTimeout(() => {
  886.         element.style.opacity = '1';
  887.         oldScoreEl.remove();
  888.         newScoreEl.remove();
  889.       }, 150);
  890.     }
  891.  
  892.     async function maybeSwitchSidesBeforeGame() {
  893.       const lastWasSwitch = state.gameLog[0] && state.gameLog[0].action === 'switch_sides';
  894.       if (lastWasSwitch) return false;
  895.       if (await showConfirmDialog('Switch sides?')) {
  896.         doSwitchSides();
  897.         addHistoryEntry('switch_sides', {
  898.           leftPlayerName: state.playerA,
  899.           rightPlayerName: state.playerB
  900.         });
  901.         saveState();
  902.         return true;
  903.       }
  904.       return false;
  905.     }
  906.  
  907.     async function startGameFlow() {
  908.       if (state.gameState === 'playing') return false;
  909.       let switched = false;
  910.       if (state.gameState === 'ended') {
  911.         if (isMatchWon()) {
  912.           state.customMatch = true;
  913.         }
  914.         switched = await maybeSwitchSidesBeforeGame();
  915.       }
  916.  
  917.       if (!state.firstGameStartTime) {
  918.         state.firstGameStartTime = Date.now();
  919.         state.timerElapsed = 0;
  920.       }
  921.  
  922.       state.gameState = 'playing';
  923.       state.manualEndMode = false;
  924.       state.scoreA = 0;
  925.       state.scoreB = 0;
  926.       addHistoryEntry('start_game', {
  927.         game: state.currentGame,
  928.         gamesWonA: state.gamesWonA,
  929.         gamesWonB: state.gamesWonB,
  930.         leftPlayerName: state.playerA,
  931.         rightPlayerName: state.playerB
  932.       });
  933.       syncScoreUI();
  934.       syncGameButtonUI();
  935.       recomputeElapsed();
  936.       updateTimerDisplay();
  937.       syncTimerLoop();
  938.       saveState();
  939.       return switched;
  940.     }
  941.  
  942.     async function finalizeAutoEnd(result) {
  943.       const winnerElement = result.winner === 'A' ? elements.scoreA : elements.scoreB;
  944.       winnerElement.classList.add('flash');
  945.       isFlashing = true;
  946.  
  947.       setTimeout(() => winnerElement.classList.remove('flash'), 1000);
  948.  
  949.       await new Promise((resolve) => setTimeout(resolve, AUTO_END_DELAY_MS));
  950.  
  951.       winnerElement.classList.remove('flash');
  952.       isFlashing = false;
  953.  
  954.       state.gameState = 'ended';
  955.       state.manualEndMode = true;
  956.  
  957.       if (result.winner === 'A') state.gamesWonA += 1;
  958.       if (result.winner === 'B') state.gamesWonB += 1;
  959.  
  960.       const finalScoreA = state.scoreA;
  961.       const finalScoreB = state.scoreB;
  962.  
  963.       addHistoryEntry('end_game', {
  964.         game: state.currentGame,
  965.         winner: result.winner,
  966.         winnerName: result.winnerName,
  967.         leftPlayerName: state.playerA,
  968.         rightPlayerName: state.playerB,
  969.         scoreA: finalScoreA,
  970.         scoreB: finalScoreB,
  971.         gamesWonA: state.gamesWonA,
  972.         gamesWonB: state.gamesWonB
  973.       });
  974.  
  975.       state.currentGame += 1;
  976.       state.scoreA = 0;
  977.       state.scoreB = 0;
  978.  
  979.       syncScoreUI();
  980.       syncGameButtonUI();
  981.       syncTimerLoop();
  982.       saveState();
  983.     }
  984.  
  985.     async function updateScore(player, delta, animate = true) {
  986.       if (state.gameState !== 'playing') return;
  987.  
  988.       const oldScoreA = state.scoreA;
  989.       const oldScoreB = state.scoreB;
  990.  
  991.       if (player === 'A') {
  992.         state.scoreA = Math.max(0, state.scoreA + delta);
  993.       } else {
  994.         state.scoreB = Math.max(0, state.scoreB + delta);
  995.       }
  996.  
  997.       const scoreEl = player === 'A' ? elements.scoreA : elements.scoreB;
  998.       if (animate) {
  999.         animateScore(scoreEl, delta > 0 ? 1 : -1, player === 'A' ? oldScoreA : oldScoreB);
  1000.       }
  1001.  
  1002.       syncScoreUI();
  1003.  
  1004.       addHistoryEntry('point', {
  1005.         player,
  1006.         playerName: player === 'A' ? state.playerA : state.playerB,
  1007.         leftScore: state.scoreA,
  1008.         rightScore: state.scoreB,
  1009.         prevScoreA: oldScoreA,
  1010.         prevScoreB: oldScoreB,
  1011.         game: state.currentGame,
  1012.         leftPlayerName: state.playerA,
  1013.         rightPlayerName: state.playerB
  1014.       });
  1015.  
  1016.       if (!state.manualEndMode && !state.customMatch && state.currentGame === 3) {
  1017.        const wasUnderEleven = Math.max(oldScoreA, oldScoreB) < 11;
  1018.         const reachedEleven = Math.max(state.scoreA, state.scoreB) >= 11;
  1019.         if (wasUnderEleven && reachedEleven && !hasSwitchInCurrentGame()) {
  1020.          if (await showConfirmDialog('Switch sides?')) {
  1021.            doSwitchSides();
  1022.             addHistoryEntry('switch_sides', {
  1023.               leftPlayerName: state.playerA,
  1024.               rightPlayerName: state.playerB
  1025.             });
  1026.             saveState();
  1027.           }
  1028.         }
  1029.       }
  1030.  
  1031.       if (!state.manualEndMode) {
  1032.         const result = checkWinningCondition();
  1033.         if (result.won) {
  1034.           addHistoryEntry('auto_end_game');
  1035.           await finalizeAutoEnd(result);
  1036.         }
  1037.       }
  1038.  
  1039.       saveState();
  1040.     }
  1041.  
  1042.     async function handleScoreClick(player) {
  1043.       if (isFlashing || isProcessingClick) return;
  1044.       lockClicks();
  1045.       let switchedSide = false;
  1046.       if (state.gameState !== 'playing') {
  1047.         switchedSide = await startGameFlow();
  1048.       }
  1049.       if (switchedSide) {
  1050.         player = player === 'A' ? 'B' : 'A';
  1051.       }
  1052.       await updateScore(player, 1, true);
  1053.     }
  1054.  
  1055.     async function handleSwipe(player, deltaY, animate = true) {
  1056.       if (isFlashing) return;
  1057.       if (deltaY < -SWIPE_THRESHOLD) {
  1058.        if (state.gameState !== 'playing') {
  1059.          await startGameFlow();
  1060.        }
  1061.        await updateScore(player, 1, animate);
  1062.      } else if (deltaY > SWIPE_THRESHOLD) {
  1063.         const last = state.gameLog[0];
  1064.         if (state.gameState === 'playing' && last && last.action === 'point' && last.player === player) {
  1065.          await handleUndo(true);
  1066.         }
  1067.       }
  1068.     }
  1069.  
  1070.     function manualEndGame() {
  1071.       state.gameState = 'ended';
  1072.  
  1073.       let winner = null;
  1074.       let winnerName = '';
  1075.       if (state.scoreA > state.scoreB) {
  1076.         winner = 'A';
  1077.         winnerName = state.playerA;
  1078.         state.gamesWonA += 1;
  1079.       } else if (state.scoreB > state.scoreA) {
  1080.         winner = 'B';
  1081.         winnerName = state.playerB;
  1082.         state.gamesWonB += 1;
  1083.       }
  1084.  
  1085.       addHistoryEntry('end_game', {
  1086.         game: state.currentGame,
  1087.         winner,
  1088.         winnerName,
  1089.         leftPlayerName: state.playerA,
  1090.         rightPlayerName: state.playerB,
  1091.         scoreA: state.scoreA,
  1092.         scoreB: state.scoreB,
  1093.         gamesWonA: state.gamesWonA,
  1094.         gamesWonB: state.gamesWonB
  1095.       });
  1096.  
  1097.       state.currentGame += 1;
  1098.       state.scoreA = 0;
  1099.       state.scoreB = 0;
  1100.  
  1101.       syncScoreUI();
  1102.       syncGameButtonUI();
  1103.       syncTimerLoop();
  1104.       saveState();
  1105.     }
  1106.  
  1107.     async function handleGameToggle() {
  1108.       if (isProcessingClick || isFlashing) return;
  1109.       lockClicks();
  1110.       if (state.gameState === 'playing') {
  1111.         manualEndGame();
  1112.         return;
  1113.       }
  1114.       await startGameFlow();
  1115.     }
  1116.  
  1117.     async function handleNew() {
  1118.       const ok = await showConfirmDialog('Start a new match? This will clear all game log and scores.');
  1119.       if (!ok) return;
  1120.  
  1121.       localStorage.removeItem(STORAGE_KEY);
  1122.       state = normalizeState();
  1123.       if (timerInterval) {
  1124.         clearInterval(timerInterval);
  1125.         timerInterval = null;
  1126.       }
  1127.       syncUI();
  1128.       syncTimerLoop();
  1129.       saveState();
  1130.     }
  1131.  
  1132.     async function handleUndo(silent = false) {
  1133.       if (state.gameLog.length === 0) return;
  1134.       if (!silent && isProcessingClick) return;
  1135.       if (!silent) lockClicks();
  1136.  
  1137.       let index = 0;
  1138.       let hiddenOnTop = false;
  1139.       if (state.gameLog[0] && state.gameLog[0].action === 'auto_end_game') {
  1140.        hiddenOnTop = true;
  1141.         index = 1;
  1142.       }
  1143.  
  1144.       const lastEntry = state.gameLog[index];
  1145.       if (!lastEntry) return;
  1146.  
  1147.       if (!silent) {
  1148.         const entrySeconds = parseTimeToSeconds(lastEntry.time);
  1149.         const currentSeconds = state.timerElapsed;
  1150.         const minutesAgo = Math.floor((currentSeconds - entrySeconds) / 60);
  1151.         if (minutesAgo >= 1) {
  1152.           const ok = await showConfirmDialog(`This action happened ${minutesAgo} minute${minutesAgo > 1 ? 's' : ''} ago. Are you sure you want to undo?`);
  1153.           if (!ok) return;
  1154.         }
  1155.       }
  1156.  
  1157.       if (lastEntry.action === 'end_game') {
  1158.         state.gameState = 'playing';
  1159.         state.currentGame = Math.max(1, state.currentGame - 1);
  1160.         if (lastEntry.winner === 'A') state.gamesWonA = Math.max(0, state.gamesWonA - 1);
  1161.         if (lastEntry.winner === 'B') state.gamesWonB = Math.max(0, state.gamesWonB - 1);
  1162.  
  1163.         state.scoreA = Number.isFinite(lastEntry.scoreA) ? lastEntry.scoreA : (lastEntry.leftScore || 0);
  1164.         state.scoreB = Number.isFinite(lastEntry.scoreB) ? lastEntry.scoreB : (lastEntry.rightScore || 0);
  1165.  
  1166.         state.gameLog.splice(index, 1);
  1167.         state.manualEndMode = hasAutoEndInCurrentGame();
  1168.       } else if (lastEntry.action === 'point') {
  1169.         state.scoreA = Number.isFinite(lastEntry.prevScoreA) ? lastEntry.prevScoreA : Math.max(0, state.scoreA - (lastEntry.player === 'A' ? 1 : 0));
  1170.         state.scoreB = Number.isFinite(lastEntry.prevScoreB) ? lastEntry.prevScoreB : Math.max(0, state.scoreB - (lastEntry.player === 'B' ? 1 : 0));
  1171.  
  1172.         if (hiddenOnTop) {
  1173.           state.gameLog.splice(0, 2);
  1174.           state.manualEndMode = false;
  1175.         } else {
  1176.           state.gameLog.splice(index, 1);
  1177.           state.manualEndMode = hasAutoEndInCurrentGame();
  1178.         }
  1179.       } else if (lastEntry.action === 'start_game') {
  1180.         if (state.gameLog.length === 1) {
  1181.           state.firstGameStartTime = null;
  1182.           state.timerElapsed = 0;
  1183.           state.gameState = 'not_started';
  1184.           state.currentGame = 1;
  1185.           state.gamesWonA = 0;
  1186.           state.gamesWonB = 0;
  1187.           state.scoreA = 0;
  1188.           state.scoreB = 0;
  1189.           state.gameLog = [];
  1190.           state.manualEndMode = false;
  1191.           state.customMatch = false;
  1192.         } else {
  1193.           state.gameLog.splice(index, 1);
  1194.           state.gameState = 'ended';
  1195.           state.scoreA = 0;
  1196.           state.scoreB = 0;
  1197.         }
  1198.       } else if (lastEntry.action === 'switch_sides') {
  1199.         doSwitchSides();
  1200.         state.gameLog.splice(index, 1);
  1201.       } else {
  1202.         state.gameLog.splice(index, 1);
  1203.       }
  1204.  
  1205.       syncScoreUI();
  1206.       syncGameButtonUI();
  1207.       renderHistory();
  1208.       syncTimerLoop();
  1209.       updateTimerDisplay();
  1210.       saveState();
  1211.     }
  1212.  
  1213.     function gameLogEntryToExportText(entry) {
  1214.       const leftName = entry.leftPlayerName || state.playerA;
  1215.       const rightName = entry.rightPlayerName || state.playerB;
  1216.       const scoreA = getHistoryScoreA(entry) ?? 0;
  1217.       const scoreB = getHistoryScoreB(entry) ?? 0;
  1218.  
  1219.       switch (entry.action) {
  1220.         case 'start_game':
  1221.           return `Game ${entry.game} Started (${leftName} vs ${rightName})`;
  1222.         case 'point':
  1223.           return `${scoreA} : ${scoreB} (+1 ${entry.playerName || (entry.player === 'A' ? state.playerA : state.playerB)})`;
  1224.         case 'end_game':
  1225.           return `Game ${entry.game} Ended (${entry.winnerName || ''}, ${scoreA} : ${scoreB})`;
  1226.         case 'switch_sides':
  1227.           return `Sides switched (${leftName} vs ${rightName})`;
  1228.         default:
  1229.           return '';
  1230.       }
  1231.     }
  1232.  
  1233.     function handleExport() {
  1234.       const visible = state.gameLog.filter((e) => e.action !== 'auto_end_game' && e.action !== 'free_scoring_started');
  1235.       if (visible.length === 0) {
  1236.         alert('No game log to export');
  1237.         return;
  1238.       }
  1239.  
  1240.       let filename = `${state.playerA} vs ${state.playerB}`;
  1241.       if (state.firstGameStartTime) {
  1242.         const d = new Date(state.firstGameStartTime);
  1243.         const date = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
  1244.         const time = `${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}${String(d.getSeconds()).padStart(2, '0')}`;
  1245.         filename += ` ${date}_${time}`;
  1246.       }
  1247.       filename += '.txt';
  1248.  
  1249.       const entries = [...visible].reverse();
  1250.       let content = '';
  1251.       entries.forEach((entry, idx) => {
  1252.         const sec = parseTimeToSeconds(entry.time);
  1253.         const stamp = formatExportTime(sec);
  1254.         const text = gameLogEntryToExportText(entry);
  1255.         if (!text) return;
  1256.         content += `${idx + 1}\n${stamp} --> ${stamp}\n${text}\n\n`;
  1257.       });
  1258.  
  1259.       const blob = new Blob([content], { type: 'text/plain' });
  1260.       const url = URL.createObjectURL(blob);
  1261.       const link = document.createElement('a');
  1262.       link.href = url;
  1263.       link.download = filename;
  1264.       document.body.appendChild(link);
  1265.       link.click();
  1266.       document.body.removeChild(link);
  1267.       URL.revokeObjectURL(url);
  1268.     }
  1269.  
  1270.     function showConfirmDialog(message) {
  1271.       return new Promise((resolve) => {
  1272.         const overlay = document.createElement('div');
  1273.         overlay.className = 'modal-overlay confirm-dialog';
  1274.  
  1275.         const modal = document.createElement('div');
  1276.         modal.className = 'modal-content';
  1277.  
  1278.         const messageEl = document.createElement('p');
  1279.         messageEl.textContent = message;
  1280.         modal.appendChild(messageEl);
  1281.  
  1282.         const buttons = document.createElement('div');
  1283.         buttons.className = 'modal-buttons';
  1284.  
  1285.         const noBtn = document.createElement('button');
  1286.         noBtn.className = 'btn-cancel';
  1287.         noBtn.textContent = 'No';
  1288.         noBtn.addEventListener('click', () => {
  1289.           document.body.removeChild(overlay);
  1290.           resolve(false);
  1291.         });
  1292.  
  1293.         const yesBtn = document.createElement('button');
  1294.         yesBtn.className = 'btn-save';
  1295.         yesBtn.textContent = 'Yes';
  1296.         yesBtn.addEventListener('click', () => {
  1297.           document.body.removeChild(overlay);
  1298.           resolve(true);
  1299.         });
  1300.  
  1301.         buttons.appendChild(noBtn);
  1302.         buttons.appendChild(yesBtn);
  1303.         modal.appendChild(buttons);
  1304.         overlay.appendChild(modal);
  1305.         document.body.appendChild(overlay);
  1306.       });
  1307.     }
  1308.  
  1309.     function setupNameEditing(element, player) {
  1310.       element.addEventListener('click', () => {
  1311.         const colorOptions = ['#000000', '#2196f3', '#e94560', '#4caf50', '#ff9800', '#ffffff'];
  1312.         const currentName = player === 'A' ? state.playerA : state.playerB;
  1313.         const currentColor = player === 'A' ? state.playerColorA : state.playerColorB;
  1314.  
  1315.         const overlay = document.createElement('div');
  1316.         overlay.className = 'modal-overlay';
  1317.  
  1318.         const modal = document.createElement('div');
  1319.         modal.className = 'modal-content';
  1320.  
  1321.         const title = document.createElement('h3');
  1322.         title.textContent = `Edit ${currentName}`;
  1323.         modal.appendChild(title);
  1324.  
  1325.         const nameInput = document.createElement('input');
  1326.         nameInput.type = 'text';
  1327.         nameInput.value = currentName;
  1328.         modal.appendChild(nameInput);
  1329.  
  1330.         const colorPicker = document.createElement('div');
  1331.         colorPicker.className = 'color-picker';
  1332.         let selectedColor = currentColor;
  1333.         const selectionBorder = getComputedStyle(document.documentElement).getPropertyValue('--selection-border').trim();
  1334.  
  1335.         colorOptions.forEach((color) => {
  1336.           const option = document.createElement('div');
  1337.           option.className = 'color-option';
  1338.           if (color === '#ffffff') option.classList.add('white-option');
  1339.           option.style.backgroundColor = color;
  1340.           if (color === selectionBorder) option.classList.add('two-tone');
  1341.  
  1342.           if (color.toLowerCase() === currentColor.toLowerCase()) {
  1343.             option.classList.add('selected');
  1344.           }
  1345.           option.addEventListener('click', () => {
  1346.             colorPicker.querySelectorAll('.color-option').forEach((node) => node.classList.remove('selected'));
  1347.             option.classList.add('selected');
  1348.             selectedColor = color;
  1349.           });
  1350.           colorPicker.appendChild(option);
  1351.         });
  1352.         modal.appendChild(colorPicker);
  1353.  
  1354.         const buttons = document.createElement('div');
  1355.         buttons.className = 'modal-buttons';
  1356.  
  1357.         const cancelBtn = document.createElement('button');
  1358.         cancelBtn.className = 'btn-cancel';
  1359.         cancelBtn.textContent = 'Cancel';
  1360.         cancelBtn.addEventListener('click', () => {
  1361.           document.body.removeChild(overlay);
  1362.         });
  1363.  
  1364.         const saveBtn = document.createElement('button');
  1365.         saveBtn.className = 'btn-save';
  1366.         saveBtn.textContent = 'Save';
  1367.         saveBtn.addEventListener('click', () => {
  1368.           const fallbackName = player === 'A' ? 'Player A' : 'Player B';
  1369.           const oldName = player === 'A' ? state.playerA : state.playerB;
  1370.           const newName = nameInput.value.trim() || fallbackName;
  1371.  
  1372.           if (player === 'A') {
  1373.             state.playerA = newName;
  1374.             state.playerColorA = selectedColor;
  1375.           } else {
  1376.             state.playerB = newName;
  1377.             state.playerColorB = selectedColor;
  1378.           }
  1379.  
  1380.           for (const entry of state.gameLog) {
  1381.             if (entry.action === 'point' && entry.player === player) {
  1382.              entry.playerName = newName;
  1383.             }
  1384.             if (entry.action === 'end_game' && entry.winner === player) {
  1385.              entry.winnerName = newName;
  1386.             }
  1387.             if (entry.leftPlayerName === oldName) entry.leftPlayerName = newName;
  1388.             if (entry.rightPlayerName === oldName) entry.rightPlayerName = newName;
  1389.           }
  1390.  
  1391.           syncPlayerUI();
  1392.           renderHistory();
  1393.           saveState();
  1394.           document.body.removeChild(overlay);
  1395.         });
  1396.  
  1397.         buttons.appendChild(cancelBtn);
  1398.         buttons.appendChild(saveBtn);
  1399.         modal.appendChild(buttons);
  1400.         overlay.appendChild(modal);
  1401.         document.body.appendChild(overlay);
  1402.  
  1403.         nameInput.focus();
  1404.         nameInput.select();
  1405.       });
  1406.     }
  1407.  
  1408.     function setupTouchHandlers(element, player) {
  1409.       let startX = null;
  1410.       let startY = null;
  1411.       let hasMoved = false;
  1412.       let direction = null;
  1413.       let trackingDeltaY = 0;
  1414.       let touchHandled = false;
  1415.       let oldScoreClone = null;
  1416.       let newScoreClone = null;
  1417.       const scoreElement = player === 'A' ? elements.scoreA : elements.scoreB;
  1418.       const scoreBtn = player === 'A' ? elements.scoreBtnA : elements.scoreBtnB;
  1419.  
  1420.       function clearPreview(showRealScore = true) {
  1421.         if (showRealScore) {
  1422.           scoreElement.style.opacity = '1';
  1423.         }
  1424.         if (oldScoreClone) oldScoreClone.remove();
  1425.         if (newScoreClone) newScoreClone.remove();
  1426.         oldScoreClone = null;
  1427.         newScoreClone = null;
  1428.       }
  1429.  
  1430.       function createPreviewNodes(directionNow, currentScore, scoreGap, textColor, canUndo, prevScore) {
  1431.         oldScoreClone = document.createElement('div');
  1432.         oldScoreClone.textContent = currentScore;
  1433.         oldScoreClone.style.cssText = `
  1434.           position: absolute;
  1435.           left: 0;
  1436.           top: 0;
  1437.           width: 100%;
  1438.           height: 100%;
  1439.           font-family: ui-monospace, 'SF Mono', 'Fira Code', 'Roboto Mono', 'Consolas', monospace;
  1440.           font-size: ${getComputedStyle(scoreElement).fontSize};
  1441.           font-weight: bold;
  1442.           color: ${textColor};
  1443.           text-shadow: 0 0.05em 0.1em rgba(0, 0, 0, 0.5);
  1444.           display: flex;
  1445.           justify-content: center;
  1446.           align-items: center;
  1447.           z-index: 10;
  1448.           pointer-events: none;
  1449.         `;
  1450.  
  1451.         newScoreClone = document.createElement('div');
  1452.         newScoreClone.textContent = directionNow === 'up' ? currentScore + 1 : (canUndo ? prevScore : '');
  1453.         newScoreClone.style.cssText = `
  1454.           position: absolute;
  1455.           left: 0;
  1456.           top: 0;
  1457.           width: 100%;
  1458.           height: 100%;
  1459.           font-family: ui-monospace, 'SF Mono', 'Fira Code', 'Roboto Mono', 'Consolas', monospace;
  1460.           font-size: ${getComputedStyle(scoreElement).fontSize};
  1461.           font-weight: bold;
  1462.           color: ${textColor};
  1463.           text-shadow: 0 0.05em 0.1em rgba(0, 0, 0, 0.5);
  1464.           display: flex;
  1465.           justify-content: center;
  1466.           align-items: center;
  1467.           z-index: 9;
  1468.           pointer-events: none;
  1469.           transform: translateY(${directionNow === 'up' ? scoreGap : -scoreGap}px);
  1470.         `;
  1471.  
  1472.         scoreBtn.appendChild(oldScoreClone);
  1473.         scoreBtn.appendChild(newScoreClone);
  1474.         scoreElement.style.opacity = '0';
  1475.       }
  1476.  
  1477.       element.addEventListener('touchstart', (e) => {
  1478.         startX = e.touches[0].clientX;
  1479.         startY = e.touches[0].clientY;
  1480.         hasMoved = false;
  1481.         direction = null;
  1482.         trackingDeltaY = 0;
  1483.         touchHandled = false;
  1484.         clearPreview(false);
  1485.       }, { passive: true });
  1486.  
  1487.       element.addEventListener('touchmove', (e) => {
  1488.         if (startX === null || startY === null) return;
  1489.         const dx = e.touches[0].clientX - startX;
  1490.         const dy = e.touches[0].clientY - startY;
  1491.  
  1492.         if (!hasMoved && Math.abs(dy) > Math.abs(dx) && Math.abs(dy) > 10) {
  1493.          hasMoved = true;
  1494.           direction = dy > 0 ? 'down' : 'up';
  1495.           trackingDeltaY = dy;
  1496.         }
  1497.  
  1498.         if (!hasMoved) return;
  1499.  
  1500.         if (Math.abs(dy) > Math.abs(dx)) {
  1501.           e.preventDefault();
  1502.         }
  1503.  
  1504.         const currentDirection = dy > 0 ? 'down' : 'up';
  1505.         const currentDelta = dy - trackingDeltaY;
  1506.         const fontSize = parseFloat(getComputedStyle(scoreElement).fontSize);
  1507.         const scoreGap = (scoreBtn.offsetHeight + fontSize) / 2;
  1508.         const currentScore = Number.parseInt(scoreElement.textContent, 10) || 0;
  1509.         const textColor = getScoreTextColor(player === 'A' ? state.playerColorA : state.playerColorB);
  1510.         const lastEntry = state.gameLog[0];
  1511.         const canUndo = state.gameState === 'playing' && lastEntry && lastEntry.action === 'point' && lastEntry.player === player;
  1512.         const prevScore = canUndo ? (player === 'A' ? lastEntry.prevScoreA : lastEntry.prevScoreB) : '';
  1513.  
  1514.         if (currentDirection !== direction || !oldScoreClone || !newScoreClone) {
  1515.           direction = currentDirection;
  1516.           trackingDeltaY = dy;
  1517.           clearPreview(false);
  1518.           createPreviewNodes(direction, currentScore, scoreGap, textColor, canUndo, prevScore);
  1519.         }
  1520.  
  1521.         if (!oldScoreClone || !newScoreClone) return;
  1522.  
  1523.         if (direction === 'up') {
  1524.           const clampedDelta = Math.max(-scoreGap, Math.min(0, currentDelta));
  1525.           oldScoreClone.style.transform = `translateY(${clampedDelta}px)`;
  1526.           newScoreClone.style.transform = `translateY(${scoreGap + clampedDelta}px)`;
  1527.         } else {
  1528.           const clampedDelta = Math.max(0, Math.min(scoreGap, currentDelta));
  1529.           oldScoreClone.style.transform = `translateY(${clampedDelta}px)`;
  1530.           newScoreClone.style.transform = `translateY(${clampedDelta - scoreGap}px)`;
  1531.         }
  1532.       }, { passive: false });
  1533.  
  1534.       element.addEventListener('touchend', async (e) => {
  1535.         if (startX === null || startY === null) return;
  1536.         const dx = e.changedTouches[0].clientX - startX;
  1537.         const dy = e.changedTouches[0].clientY - startY;
  1538.  
  1539.         const fontSize = parseFloat(getComputedStyle(scoreElement).fontSize);
  1540.         const scoreGap = (scoreBtn.offsetHeight + fontSize) / 2;
  1541.         const currentDelta = dy - trackingDeltaY;
  1542.         const lastEntry = state.gameLog[0];
  1543.         const canUndo = state.gameState === 'playing' && lastEntry && lastEntry.action === 'point' && lastEntry.player === player;
  1544.  
  1545.         startX = null;
  1546.         startY = null;
  1547.  
  1548.         if (hasMoved && direction === 'up' && currentDelta < -SWIPE_THRESHOLD) {
  1549.          touchHandled = true;
  1550.           clearPreview();
  1551.           await handleSwipe(player, dy, false);
  1552.         } else if (hasMoved && direction === 'down' && currentDelta > SWIPE_THRESHOLD && canUndo) {
  1553.          touchHandled = true;
  1554.           clearPreview();
  1555.           await handleUndo(true);
  1556.         } else if (hasMoved && oldScoreClone && newScoreClone) {
  1557.          oldScoreClone.style.transition = 'transform 0.15s ease-out';
  1558.           newScoreClone.style.transition = 'transform 0.15s ease-out';
  1559.           oldScoreClone.style.transform = 'translateY(0)';
  1560.           newScoreClone.style.transform = direction === 'up' ? `translateY(${scoreGap}px)` : `translateY(-${scoreGap}px)`;
  1561.           setTimeout(() => {
  1562.             clearPreview();
  1563.           }, 150);
  1564.         } else if (Math.abs(dy) > Math.abs(dx) && Math.abs(dy) > SWIPE_THRESHOLD) {
  1565.          touchHandled = true;
  1566.           await handleSwipe(player, dy, true);
  1567.         }
  1568.  
  1569.         hasMoved = false;
  1570.         direction = null;
  1571.         trackingDeltaY = 0;
  1572.       });
  1573.  
  1574.       element.addEventListener('click', (e) => {
  1575.         if (touchHandled) {
  1576.           touchHandled = false;
  1577.           return;
  1578.         }
  1579.         e.preventDefault();
  1580.         handleScoreClick(player);
  1581.       });
  1582.     }
  1583.  
  1584.     function updateScoreFontSize() {
  1585.       const main = document.querySelector('.main-area');
  1586.       if (!main || main.offsetHeight <= 0) return;
  1587.      const maxFromHeight = main.offsetHeight * 0.8;
  1588.      const maxFromWidth = main.offsetWidth * 0.4;
  1589.      const buttonSize = Math.min(maxFromHeight, maxFromWidth);
  1590.      const fontSize = buttonSize * 0.75;
  1591.      elements.scoreBtnA.style.flexBasis = `${buttonSize}px`;
  1592.      elements.scoreBtnA.style.width = `${buttonSize}px`;
  1593.      elements.scoreBtnA.style.height = `${buttonSize}px`;
  1594.      elements.scoreBtnA.style.marginRight = `${buttonSize * 0.15}px`;
  1595.      elements.scoreBtnB.style.flexBasis = `${buttonSize}px`;
  1596.      elements.scoreBtnB.style.width = `${buttonSize}px`;
  1597.      elements.scoreBtnB.style.height = `${buttonSize}px`;
  1598.      elements.scoreBtnB.style.marginLeft = `${buttonSize * 0.15}px`;
  1599.      elements.scoreA.style.fontSize = `${fontSize}px`;
  1600.      elements.scoreB.style.fontSize = `${fontSize}px`;
  1601.      const vsText = document.querySelector('.vs-text');
  1602.      if (vsText) {
  1603.        vsText.style.fontSize = `${fontSize * 0.6}px`;
  1604.      }
  1605.    }
  1606.  
  1607.    function init() {
  1608.      recomputeElapsed();
  1609.      syncUI();
  1610.      syncTimerLoop();
  1611.      updateScoreFontSize();
  1612.  
  1613.      setupNameEditing(elements.nameA, 'A');
  1614.      setupNameEditing(elements.nameB, 'B');
  1615.      setupTouchHandlers(elements.scoreBtnA, 'A');
  1616.      setupTouchHandlers(elements.scoreBtnB, 'B');
  1617.  
  1618.      elements.gameBtn.addEventListener('click', handleGameToggle);
  1619.      elements.newBtn.addEventListener('click', handleNew);
  1620.      elements.undoBtn.addEventListener('click', () => handleUndo(false));
  1621.       elements.exportBtn.addEventListener('click', handleExport);
  1622.  
  1623.       window.addEventListener('resize', updateScoreFontSize);
  1624.     }
  1625.  
  1626.     init();
  1627.   </script>
  1628. </body>
  1629. </html>
  1630.