From 36771627470e63e3419a0325d13fdf8b866c05be Mon Sep 17 00:00:00 2001 From: JianMiau Date: Mon, 18 May 2026 17:40:58 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E8=A8=98=E5=88=86=E6=9D=BF?= =?UTF-8?q?=E5=9C=A8=20iPad=20=E5=B0=BA=E5=AF=B8=E7=9A=84=E7=89=88?= =?UTF-8?q?=E9=9D=A2=E8=88=87=E4=BF=AE=E5=BE=A9=20dev=20server=20=E5=95=9F?= =?UTF-8?q?=E5=8B=95=E5=95=8F=E9=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + package.json | 2 +- src/App.css | 4706 +++++++++++++++++----------------- src/App.tsx | 1886 +++++++------- src/index.css | 168 +- src/pages/ScoreboardPage.tsx | 2132 +++++++-------- src/types.ts | 302 +-- 7 files changed, 4599 insertions(+), 4598 deletions(-) diff --git a/.gitignore b/.gitignore index b50664c..fc130f5 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ dist-ssr *.njsproj *.sln *.sw? +/*.stackdump diff --git a/package.json b/package.json index 8808afc..3fd464e 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "concurrently \"npm:dev:server\" \"npm:dev:client\"", "dev:client": "vite", - "dev:server": "node --watch server/server.mjs", + "dev:server": "node server/server.mjs", "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", diff --git a/src/App.css b/src/App.css index dda0cd5..aed7c45 100644 --- a/src/App.css +++ b/src/App.css @@ -1,2353 +1,2353 @@ -.app-shell { - width: min(1240px, calc(100% - 32px)); - margin: 0 auto; - padding: 28px 0 56px; -} - -.app-shell-scoreboard { - width: min(1180px, calc(100% - 20px)); - padding-top: 16px; -} - -.app-shell-scoreboard-fit { - min-height: 100dvh; - display: grid; - grid-template-rows: auto minmax(0, 1fr); - padding-bottom: max(12px, env(safe-area-inset-bottom)); -} - -.topbar { - display: flex; - justify-content: space-between; - gap: 24px; - align-items: end; - margin-bottom: 24px; -} - -.topbar-compact { - align-items: center; - margin-bottom: 14px; -} - -.branding { - max-width: 760px; -} - -.eyebrow, -.panel-kicker, -.team-index { - display: inline-flex; - letter-spacing: 0.14em; - text-transform: uppercase; - font-size: 0.76rem; -} - -.eyebrow { - margin-bottom: 12px; - padding: 8px 12px; - border-radius: 999px; - color: #effce4; - background: rgba(8, 47, 73, 0.84); -} - -.intro-copy { - max-width: 60ch; - margin-top: 10px; -} - -.topnav { - display: flex; - flex-wrap: wrap; - gap: 10px; -} - -.nav-pill, -.inline-link { - padding: 12px 16px; - border-radius: 999px; - color: var(--panel-strong); - text-decoration: none; - background: rgba(255, 255, 255, 0.58); - border: 1px solid rgba(10, 51, 45, 0.1); - transition: transform 0.18s ease; -} - -.nav-pill:hover, -.inline-link:hover { - transform: translateY(-1px); -} - -.nav-pill-active { - color: #f7fff7; - background: linear-gradient(135deg, rgba(8, 47, 73, 0.96), rgba(10, 96, 84, 0.92)); -} - -.pwa-update-toast { - position: fixed; - left: 50%; - bottom: 18px; - z-index: 1200; - display: flex; - align-items: center; - gap: 14px; - width: min(560px, calc(100vw - 24px)); - padding: 14px 16px; - border: 1px solid rgba(10, 51, 45, 0.16); - border-radius: 20px; - background: rgba(255, 249, 236, 0.96); - box-shadow: - 0 22px 46px rgba(10, 51, 45, 0.22), - 0 8px 18px rgba(10, 51, 45, 0.12); - transform: translateX(-50%); - backdrop-filter: blur(14px); -} - -.pwa-update-copy { - display: grid; - gap: 2px; - min-width: 0; -} - -.pwa-update-copy strong { - font-size: 1rem; - color: var(--panel-strong); -} - -.pwa-update-copy span { - font-size: 0.88rem; - color: var(--panel-soft); -} - -.pwa-update-button { - flex: 0 0 auto; - min-width: 96px; - padding: 10px 14px; - border: none; - border-radius: 999px; - background: linear-gradient(135deg, #0d5d53, #123f49); - color: #f8fff8; - font: inherit; - font-weight: 700; - cursor: pointer; -} - -.pwa-update-button:hover { - filter: brightness(1.06); -} - -.page-grid { - display: grid; - grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); - gap: 22px; -} - -.full-span { - grid-column: 1 / -1; -} - -.panel { - border: 1px solid var(--border); - border-radius: 28px; - background: rgba(255, 255, 255, 0.78); - box-shadow: var(--shadow); - padding: 28px; -} - -.panel-hero { - background: - radial-gradient(circle at top right, rgba(255, 196, 0, 0.24), transparent 30%), - linear-gradient(145deg, rgba(8, 47, 73, 0.95), rgba(10, 96, 84, 0.92)); - color: #f5fff8; -} - -.panel-hero h2, -.panel-hero p, -.panel-hero .panel-kicker { - color: inherit; -} - -.panel-copy { - margin-top: 12px; - max-width: 34rem; - color: rgba(245, 255, 248, 0.78); -} - -.panel-heading, -.group-head, -.history-head { - display: flex; - justify-content: space-between; - gap: 16px; - align-items: start; -} - -.group-head-compact { - align-items: center; -} - -.group-head-compact > div:first-child { - min-width: 0; -} - -.group-head-compact h3 { - margin: 4px 0 0; -} - -.summary-grid, -.double-grid, -.history-list, -.history-meta { - display: grid; - gap: 16px; -} - -.summary-grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); - margin-top: 24px; -} - -.mini-stat { - padding: 18px; - border-radius: 22px; - background: rgba(255, 255, 255, 0.12); - border: 1px solid rgba(255, 255, 255, 0.18); -} - -.mini-stat span { - display: block; - color: rgba(245, 255, 248, 0.76); -} - -.mini-stat strong { - display: block; - margin-top: 8px; - font-size: 1.6rem; -} - -.status-banner { - margin-top: 24px; - padding: 14px 16px; - border-radius: 18px; - font-weight: 700; -} - -.status-banner-loading { - background: rgba(255, 255, 255, 0.16); -} - -.status-banner-loaded { - color: #103b34; - background: rgba(201, 255, 215, 0.9); -} - -.status-banner-empty { - color: #5f3c0a; - background: rgba(255, 239, 190, 0.94); -} - -.status-banner-error { - color: #5b1020; - background: rgba(255, 214, 224, 0.94); -} - -.floating-status-bubble { - position: fixed; - left: 50%; - bottom: 22px; - z-index: 60; - transform: translateX(-50%); - min-width: min(78vw, 320px); - max-width: min(84vw, 420px); - padding: 14px 20px; - border-radius: 999px; - text-align: center; - font-weight: 700; - color: #f8fff9; - background: linear-gradient(135deg, rgba(8, 47, 73, 0.96), rgba(10, 96, 84, 0.92)); - box-shadow: 0 18px 40px rgba(8, 47, 73, 0.28); - pointer-events: none; - animation: status-bubble-in 0.18s ease-out; -} - -@keyframes status-bubble-in { - from { - opacity: 0; - transform: translateX(-50%) translateY(10px) scale(0.96); - } - - to { - opacity: 1; - transform: translateX(-50%) translateY(0) scale(1); - } -} - -.selection-shell, -.selection-form, -.group-board { - display: grid; - gap: 18px; -} - -.selection-toolbar { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 16px; - align-items: end; -} - -.selection-toolbar > .field { - width: min(100%, 220px); -} - -.double-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -.field { - display: grid; - gap: 8px; - min-width: 0; -} - -.field span { - font-weight: 700; -} - -.field input, -.field textarea, -.field select { - width: 100%; - padding: 14px 16px; - border: 1px solid rgba(10, 51, 45, 0.16); - border-radius: 16px; - background: rgba(255, 255, 255, 0.94); - color: var(--panel-strong); - font: inherit; -} - -.field textarea { - resize: vertical; - min-height: 200px; -} - -.field input[type='date'] { - min-width: 0; -} - -.button-stack { - display: grid; - gap: 12px; - align-content: end; -} - -.primary-button, -.secondary-button { - border: 0; - border-radius: 16px; - cursor: pointer; - font: inherit; - padding: 14px 18px; -} - -.primary-button { - color: #f8fff8; - background: linear-gradient(135deg, rgba(8, 47, 73, 0.96), rgba(10, 96, 84, 0.92)); -} - -.secondary-button { - color: var(--panel-strong); - background: rgba(11, 88, 73, 0.1); -} - -.selection-hint { - display: flex; - flex-wrap: wrap; - gap: 12px; - padding: 14px 16px; - border-radius: 18px; - background: rgba(10, 51, 45, 0.05); - color: var(--panel-soft); -} - -.group-card, -.history-card, -.empty-state { - padding: 22px; - border-radius: 22px; - background: rgba(246, 249, 244, 0.95); - border: 1px solid rgba(10, 51, 45, 0.08); -} - -.group-card-stage { - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(241, 246, 242, 0.94)); -} - -.group-actions { - display: flex; - flex-wrap: wrap; - gap: 10px; -} - -.team-stage-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 14px; - margin-top: 18px; -} - -.team-stage-card { - padding: 18px; - border-radius: 18px; - background: rgba(255, 255, 255, 0.88); - border: 1px solid rgba(10, 51, 45, 0.08); -} - -.team-index { - color: var(--panel-soft); -} - -.team-name { - margin-top: 10px; - font-size: 1.08rem; - color: var(--panel-strong); -} - -.winner-badge { - align-self: start; - padding: 10px 14px; - border-radius: 999px; - color: var(--panel-strong); - background: rgba(11, 88, 73, 0.1); -} - -.history-meta { - grid-template-columns: repeat(3, minmax(0, 1fr)); - margin-top: 16px; -} - -.history-meta span { - color: var(--panel-soft); -} - -.history-card-button { - width: 100%; - border: 0; - cursor: pointer; - text-align: left; - font: inherit; -} - -.history-card-button:hover { - box-shadow: 0 12px 28px rgba(8, 47, 73, 0.08); -} - -.history-card-shell { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 14px; - align-items: center; -} - -.history-card-content { - padding: 0; - background: transparent; -} - -.history-delete-button { - border: 0; - border-radius: 999px; - padding: 12px 16px; - cursor: pointer; - font: inherit; - color: #fff; - background: linear-gradient(180deg, #e57a63, #c44c3d); - box-shadow: - inset 0 0 0 1px rgba(161, 54, 37, 0.22), - 0 10px 18px rgba(8, 47, 73, 0.12); - transition: - transform 0.16s ease, - box-shadow 0.16s ease, - filter 0.16s ease, - opacity 0.16s ease; -} - -.history-delete-button:hover:not(:disabled) { - transform: translateY(-1px); - box-shadow: - inset 0 0 0 1px rgba(161, 54, 37, 0.28), - 0 14px 22px rgba(8, 47, 73, 0.16); -} - -.history-delete-button:active:not(:disabled) { - transform: translateY(0); - filter: brightness(0.98); -} - -.history-delete-button:disabled { - cursor: default; - opacity: 0.62; -} - -.scoreboard-screen { - display: grid; - grid-template-columns: minmax(0, 1fr) 160px; - gap: 14px; - align-items: start; - min-height: 0; - height: 100%; -} - -.streak-banner { - position: fixed; - left: 50%; - top: 18%; - z-index: 85; - display: grid; - justify-items: center; - gap: 6px; - min-width: min(88vw, 420px); - padding: 18px 28px; - border-radius: 28px; - color: #fff8e8; - background: - radial-gradient(circle at top, rgba(255, 219, 112, 0.38), transparent 46%), - linear-gradient(135deg, rgba(143, 25, 26, 0.96), rgba(248, 128, 45, 0.92)); - box-shadow: - 0 24px 48px rgba(8, 47, 73, 0.32), - inset 0 0 0 1px rgba(255, 238, 194, 0.24); - transform: translate(-50%, 0); - pointer-events: none; - animation: streak-banner-burst 1.8s ease forwards; -} - -.streak-banner-count { - font-size: 0.96rem; - letter-spacing: 0.18em; - text-transform: uppercase; - color: rgba(255, 244, 214, 0.86); -} - -.streak-banner strong { - font-size: clamp(2rem, 7vw, 3.6rem); - line-height: 1; - text-shadow: - 0 2px 0 rgba(101, 14, 10, 0.28), - 0 0 22px rgba(255, 226, 154, 0.24); -} - -.streak-banner small { - font-size: 1rem; - color: rgba(255, 248, 232, 0.92); -} - -.victory-banner { - position: fixed; - left: 50%; - top: 22%; - z-index: 86; - display: grid; - justify-items: center; - gap: 6px; - min-width: min(90vw, 460px); - padding: 22px 30px; - border-radius: 30px; - color: #4a2e1d; - background: - radial-gradient(circle at top, rgba(255, 255, 255, 0.34), transparent 40%), - linear-gradient(135deg, rgba(255, 243, 196, 0.98), rgba(255, 199, 92, 0.94)); - box-shadow: - 0 30px 56px rgba(8, 47, 73, 0.28), - 0 0 0 6px rgba(255, 243, 211, 0.16), - inset 0 0 0 1px rgba(196, 134, 32, 0.34); - transform: translate(-50%, 0); - pointer-events: none; - animation: victory-banner-burst 2.2s ease forwards; -} - -.victory-banner-kicker { - font-size: 0.9rem; - letter-spacing: 0.18em; - text-transform: uppercase; - color: rgba(101, 67, 22, 0.82); -} - -.victory-banner strong { - font-size: clamp(2.2rem, 8vw, 4rem); - line-height: 1; - text-shadow: - 0 2px 0 rgba(255, 255, 255, 0.4), - 0 0 22px rgba(255, 242, 176, 0.34); -} - -.victory-banner small, -.victory-banner em { - font-size: 1rem; - font-style: normal; - color: rgba(74, 46, 29, 0.9); -} - -@keyframes streak-banner-burst { - 0% { - opacity: 0; - transform: translate(-50%, 12px) scale(0.88); - } - - 15% { - opacity: 1; - transform: translate(-50%, 0) scale(1.04); - } - - 70% { - opacity: 1; - transform: translate(-50%, 0) scale(1); - } - - 100% { - opacity: 0; - transform: translate(-50%, -12px) scale(0.96); - } -} - -@keyframes victory-banner-burst { - 0% { - opacity: 0; - transform: translate(-50%, 18px) scale(0.84); - } - - 14% { - opacity: 1; - transform: translate(-50%, 0) scale(1.06); - } - - 68% { - opacity: 1; - transform: translate(-50%, 0) scale(1); - } - - 100% { - opacity: 0; - transform: translate(-50%, -18px) scale(0.96); - } -} - -.scoreboard-court { - display: grid; - gap: 14px; - min-height: 0; - padding: 12px; - border-radius: 22px; - background: - radial-gradient(circle at top right, rgba(255, 205, 96, 0.22), transparent 26%), - linear-gradient(145deg, rgba(8, 47, 73, 0.97), rgba(10, 96, 84, 0.93)); - box-shadow: 0 30px 60px rgba(8, 47, 73, 0.24); -} - -.scoreboard-team-section { - display: grid; - gap: 8px; -} - -.scoreboard-team-head { - display: grid; - grid-template-columns: minmax(0, 1fr) 62px; - gap: 8px; - align-items: center; -} - -.team-head-main { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; - min-height: 64px; - padding: 8px; - border-radius: 4px; - background: rgba(255, 248, 232, 0.92); - box-shadow: inset 0 0 0 1px rgba(195, 154, 88, 0.2); -} - -.scoreboard-name-chip { - display: flex; - align-items: center; - gap: 8px; - min-height: 46px; - padding: 4px 8px; - border-radius: 4px; - color: #16342f; - background: rgba(255, 255, 255, 0.7); -} - -.scoreboard-name-chip strong { - font-size: clamp(1rem, 2.4vw, 1.9rem); - line-height: 1; - font-weight: 500; -} - -.scoreboard-name-chip-serving { - background: linear-gradient(180deg, #ebf8a4, #d6e164); - box-shadow: inset 0 0 0 2px rgba(111, 128, 27, 0.24); -} - -.team-number { - display: inline-flex; - align-items: center; - justify-content: center; - width: 40px; - height: 40px; - flex: 0 0 40px; - font-family: var(--mono); - font-size: 1.15rem; - color: #fff; - background: linear-gradient(180deg, rgba(8, 47, 73, 0.96), rgba(10, 96, 84, 0.92)); - border-radius: 4px; -} - -.team-head-buttons { - display: grid; - gap: 10px; -} - -.team-icon-button { - min-height: 40px; - border: 0; - border-radius: 4px; - cursor: pointer; - font: inherit; - font-size: 1.2rem; - color: #5b2f13; - background: linear-gradient(180deg, #fff8e8, #f2d9a3); - box-shadow: - inset 0 0 0 1px rgba(199, 155, 83, 0.35), - 0 6px 14px rgba(8, 47, 73, 0.14); - transition: - transform 0.16s ease, - box-shadow 0.16s ease, - filter 0.16s ease; -} - -.team-icon-button:hover:not(:disabled) { - transform: translateY(-1px); - box-shadow: - inset 0 0 0 1px rgba(199, 155, 83, 0.45), - 0 10px 18px rgba(8, 47, 73, 0.18); -} - -.team-icon-button:active:not(:disabled) { - transform: translateY(0); - filter: brightness(0.98); -} - -.team-icon-button:disabled { - opacity: 0.45; - cursor: default; -} - -.serve-lane { - position: relative; - display: grid; - grid-template-columns: 32px auto 1fr; - align-items: center; - gap: 8px; - min-height: 46px; - width: min(100%, 440px); - border: 0; - border-radius: 4px; - padding: 8px 10px; - cursor: pointer; - text-align: left; - color: #f8f4ea; - background: rgba(255, 248, 232, 0.12); - box-shadow: inset 0 0 0 1px rgba(255, 236, 202, 0.12); - transition: - transform 0.16s ease, - box-shadow 0.16s ease, - background 0.16s ease; -} - -.serve-lane:disabled { - cursor: default; -} - -.serve-lane:hover:not(:disabled) { - transform: translateY(-1px); - background: rgba(255, 248, 232, 0.18); - box-shadow: - inset 0 0 0 1px rgba(255, 236, 202, 0.22), - 0 10px 18px rgba(8, 47, 73, 0.12); -} - -.serve-lane-prompt { - background: rgba(255, 248, 232, 0.12); - box-shadow: inset 0 0 0 1px rgba(255, 236, 202, 0.12); -} - -.serve-lane-arrow { - display: none; -} - -.serve-lane small { - justify-self: end; - color: rgba(248, 244, 234, 0.72); -} - -.serve-lane-prompt > span:nth-of-type(2) { - color: #f7ffbe; - font-weight: 800; - text-shadow: - 0 0 12px rgba(235, 248, 164, 0.5), - 0 0 22px rgba(235, 248, 164, 0.24); - animation: serve-label-pulse 0.9s ease-in-out infinite; -} - -.serve-lane-locked { - box-shadow: inset 0 0 0 1px rgba(214, 225, 100, 0.42); -} - -.serve-lane-box { - width: 24px; - height: 24px; - border-radius: 4px; - background: linear-gradient(180deg, #fff8e8, #f0dfbd); - box-shadow: inset 0 0 0 1px rgba(195, 154, 88, 0.22); -} - -.serve-lane-box-checked { - position: relative; - background: linear-gradient(180deg, #ebf8a4, #d6e164); - box-shadow: - inset 0 0 0 1px rgba(111, 128, 27, 0.26), - 0 0 0 1px rgba(214, 225, 100, 0.14); -} - -.serve-lane-box-checked::after { - content: '✓'; - position: absolute; - inset: 0; - display: grid; - place-items: center; - font-size: 1rem; - font-weight: 800; - color: #38501f; -} - -@keyframes serve-label-pulse { - 0%, - 100% { - transform: scale(1); - opacity: 0.82; - } - - 50% { - transform: scale(1.08); - opacity: 1; - } -} - -.score-panel-surface { - display: grid; - place-items: center; - min-height: 172px; - border: 0; - width: 100%; - padding: 0; - border-radius: 4px; - cursor: default; - transition: - transform 0.16s ease, - box-shadow 0.16s ease; - background: - linear-gradient(transparent 0 40%, rgba(11, 39, 34, 0.18) 40% 60%, transparent 60% 100%), - linear-gradient(90deg, transparent 0 40%, rgba(11, 39, 34, 0.18) 40% 60%, transparent 60% 100%), - linear-gradient(180deg, rgba(255, 248, 232, 0.2), rgba(255, 248, 232, 0.08)), - rgba(245, 237, 221, 0.96); -} - -.score-panel-surface-live { - cursor: pointer; -} - -.score-panel-surface-live:hover { - transform: translateY(-1px); - box-shadow: - inset 0 0 0 2px rgba(236, 193, 112, 0.44), - 0 10px 20px rgba(8, 47, 73, 0.12); -} - -.score-panel-value { - font-family: var(--mono); - font-size: clamp(4.4rem, 11vw, 7.2rem); - line-height: 1; - color: #0d544a; - text-shadow: 0 2px 0 rgba(255, 255, 255, 0.42); -} - -.scoreboard-center-banner { - display: grid; - justify-items: center; - gap: 6px; - padding: 2px 0; -} - -.scoreboard-center-banner p { - font-size: clamp(1.5rem, 3vw, 2.3rem); - color: #fff7e9; - text-align: center; -} - -.scoreboard-center-banner small { - font-size: 0.92rem; - color: rgba(255, 247, 233, 0.76); -} - -.scoreboard-rail { - display: grid; - gap: 10px; - align-content: start; -} - -.rail-icon-grid { - display: grid; - grid-template-columns: 1fr; - gap: 12px; -} - -.rail-square-button { - min-height: 52px; - border: 0; - border-radius: 10px; - cursor: pointer; - font: inherit; - color: #5b2f13; - background: linear-gradient(180deg, #fff8e8, #f2d9a3); - box-shadow: - inset 0 0 0 1px rgba(199, 155, 83, 0.35), - 0 8px 18px rgba(8, 47, 73, 0.14); - transition: - transform 0.16s ease, - box-shadow 0.16s ease, - filter 0.16s ease; -} - -.rail-square-button:hover { - transform: translateY(-1px); - box-shadow: - inset 0 0 0 1px rgba(199, 155, 83, 0.45), - 0 12px 20px rgba(8, 47, 73, 0.18); -} - -.rail-square-button:active { - transform: translateY(0); - filter: brightness(0.98); -} - -.rail-clock { - display: grid; - place-items: center; - min-height: 68px; - border-radius: 12px; - font-family: var(--mono); - font-size: 1.7rem; - color: #fff7e9; - background: - radial-gradient(circle at top right, rgba(255, 205, 96, 0.16), transparent 28%), - linear-gradient(145deg, rgba(8, 47, 73, 0.97), rgba(10, 96, 84, 0.93)); -} - -.rail-room-id { - padding: 10px 12px; - border-radius: 14px; - text-align: center; - color: #f7fff8; - background: rgba(8, 47, 73, 0.72); - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12); -} - -.rail-pill { - border: 0; - border-radius: 999px; - padding: 14px 14px; - cursor: pointer; - user-select: none; - -webkit-user-select: none; - -webkit-touch-callout: none; - touch-action: manipulation; - font: inherit; - font-size: 1rem; - color: #4a2e1d; - background: linear-gradient(180deg, #fff0c7, #f8c870); - box-shadow: - inset 0 0 0 1px rgba(199, 155, 83, 0.28), - 0 10px 18px rgba(8, 47, 73, 0.14); - transition: - transform 0.16s ease, - box-shadow 0.16s ease, - filter 0.16s ease; -} - -.rail-pill-hold-wrap { - display: grid; - gap: 8px; - user-select: none; - -webkit-user-select: none; - -webkit-touch-callout: none; -} - -.rail-pill-hold-wrap-active { - filter: drop-shadow(0 12px 20px rgba(217, 90, 68, 0.2)); -} - -.rail-pill:hover { - transform: translateY(-1px); - box-shadow: - inset 0 0 0 1px rgba(199, 155, 83, 0.34), - 0 14px 22px rgba(8, 47, 73, 0.18); -} - -.rail-pill:active { - transform: translateY(0); - filter: brightness(0.98); -} - -.rail-pill-danger { - color: #fff; - background: linear-gradient(180deg, #d95a44, #b53a28); -} - -.rail-pill-active-hold { - transform: translateY(1px) scale(0.99); - filter: brightness(1.08) saturate(1.08); - box-shadow: - inset 0 0 0 1px rgba(255, 227, 214, 0.5), - inset 0 12px 18px rgba(255, 255, 255, 0.1), - 0 0 0 4px rgba(255, 224, 194, 0.2), - 0 14px 26px rgba(181, 58, 40, 0.28); -} - -.rail-pill-muted { - color: #4d3a29; - background: linear-gradient(180deg, #f7f2e8, #e0d6c5); -} - -.rail-hold-progress { - position: relative; - width: 100%; - height: 8px; - overflow: hidden; - border-radius: 999px; - background: rgba(255, 240, 199, 0.32); - box-shadow: inset 0 0 0 1px rgba(255, 237, 208, 0.12); -} - -.rail-hold-progress-bar { - display: block; - width: 100%; - height: 100%; - transform-origin: left center; - background: linear-gradient(90deg, #ffe9a8, #fff7d0, #ffffff); - box-shadow: 0 0 12px rgba(255, 246, 203, 0.5); -} - -.voice-settings-overlay { - position: fixed; - inset: 0; - z-index: 75; - display: grid; - place-items: center; - padding: 18px; - background: rgba(0, 0, 0, 0.5); - backdrop-filter: blur(6px); -} - -.voice-settings-panel { - position: relative; - width: min(420px, 100%); - display: grid; - gap: 14px; - padding: 24px 20px 20px; - border-radius: 24px; - background: linear-gradient(180deg, #fff8e8, #ffe5ad); - box-shadow: - 0 0 0 4px rgba(255, 255, 255, 0.18), - inset 0 0 0 2px rgba(200, 140, 46, 0.45); -} - -.voice-settings-close { - position: absolute; - top: 10px; - right: 10px; - width: 44px; - height: 44px; - border: 0; - border-radius: 999px; - cursor: pointer; - font: inherit; - font-size: 1.6rem; - color: #b34e3a; - background: linear-gradient(180deg, #ffe5bf, #f0bd7c); - box-shadow: - inset 0 0 0 1px rgba(199, 125, 63, 0.34), - 0 10px 18px rgba(8, 47, 73, 0.16); -} - -.voice-setting-row, -.voice-setting-slider { - display: grid; - gap: 10px; - padding: 14px 16px; - border-radius: 16px; - background: rgba(255, 249, 238, 0.94); - box-shadow: inset 0 0 0 1px rgba(199, 155, 83, 0.12); -} - -.voice-setting-row { - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; -} - -.voice-setting-row input[type='checkbox'] { - width: 22px; - height: 22px; - accent-color: #0f6a5d; -} - -.voice-setting-slider strong { - justify-self: end; - color: #5b2f13; -} - -.voice-setting-slider input[type='range'] { - width: 100%; - accent-color: #0f6a5d; -} - -.team-picker-overlay { - position: fixed; - inset: 0; - z-index: 60; - display: grid; - place-items: center; - padding: 12px; - background: rgba(0, 0, 0, 0.52); - backdrop-filter: blur(8px); -} - -.team-picker-shell { - position: relative; - width: min(980px, 100%); - max-height: calc(100dvh - 24px); -} - -.team-picker-close { - position: absolute; - top: -12px; - right: 8px; - z-index: 2; - width: 68px; - height: 68px; - border: 0; - border-radius: 999px; - cursor: pointer; - font: inherit; - font-size: 2.4rem; - color: #b34e3a; - background: linear-gradient(180deg, #ffe5bf, #f0bd7c); - box-shadow: - inset 0 0 0 1px rgba(199, 125, 63, 0.34), - 0 14px 26px rgba(8, 47, 73, 0.2); - transition: - transform 0.16s ease, - box-shadow 0.16s ease, - filter 0.16s ease; -} - -.team-picker-close:hover { - transform: translateY(-1px) scale(1.01); - box-shadow: - inset 0 0 0 1px rgba(199, 125, 63, 0.42), - 0 18px 30px rgba(8, 47, 73, 0.24); -} - -.team-picker-close:active { - transform: translateY(0); - filter: brightness(0.98); -} - -.team-picker-ribbon { - position: absolute; - top: -24px; - left: 34px; - z-index: 2; - padding: 16px 24px; - border-radius: 24px; - color: #fff; - background: rgba(0, 0, 0, 0.78); -} - -.team-picker-layout { - display: grid; - grid-template-columns: minmax(0, 1fr) 260px; - gap: 16px; - padding: 22px 16px 16px; - border-radius: 28px; - background: rgba(20, 10, 6, 0.18); - max-height: calc(100dvh - 52px); -} - -.team-picker-panel { - display: grid; - gap: 12px; - padding: 14px; - border-radius: 22px; - background: linear-gradient(180deg, #fff8e8, #ffe5ad); - box-shadow: - 0 0 0 4px rgba(255, 255, 255, 0.18), - inset 0 0 0 2px rgba(200, 140, 46, 0.45); - min-height: 0; -} - -.team-picker-title { - display: flex; - align-items: center; - gap: 10px; -} - -.team-picker-count { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 72px; - min-height: 46px; - padding: 0 14px; - border-radius: 16px; - font-size: 1.25rem; - color: #4a2e1d; - background: linear-gradient(180deg, #f5d89f, #ecc170); -} - -.team-picker-title p { - margin-top: 2px; - font-size: 0.84rem; -} - -.team-picker-config-row { - display: flex; - justify-content: flex-start; -} - -.team-picker-config { - display: grid; - gap: 6px; - color: #4a2e1d; -} - -.team-picker-config span { - font-weight: 700; -} - -.team-picker-config-compact { - grid-template-columns: auto auto; - align-items: center; - gap: 8px; -} - -.team-picker-config-compact span { - font-size: 0.92rem; -} - -.team-picker-score-input { - width: 100%; - max-width: 140px; - padding: 12px 14px; - border: 1px solid rgba(124, 98, 61, 0.22); - border-radius: 14px; - background: rgba(255, 255, 255, 0.92); - color: #2e231b; - font: inherit; -} - -.team-picker-score-input-compact { - width: 72px; - max-width: 72px; - padding: 8px 10px; - text-align: center; -} - -.team-picker-list { - display: grid; - gap: 10px; - max-height: min(48dvh, 430px); - overflow: auto; - padding-right: 4px; -} - -.team-picker-option { - display: grid; - grid-template-columns: 34px minmax(0, 1fr); - gap: 10px; - align-items: center; - padding: 12px 12px; - border: 1px solid rgba(124, 98, 61, 0.18); - border-radius: 14px; - cursor: pointer; - text-align: left; - color: #2e231b; - background: rgba(255, 249, 238, 0.92); - transition: - transform 0.16s ease, - box-shadow 0.16s ease, - border-color 0.16s ease, - background 0.16s ease; -} - -.team-picker-option:hover { - transform: translateY(-1px); - border-color: rgba(199, 155, 83, 0.34); - box-shadow: 0 12px 22px rgba(8, 47, 73, 0.08); -} - -.team-picker-option-active { - background: rgba(255, 255, 255, 0.98); - box-shadow: 0 10px 20px rgba(147, 104, 35, 0.12); -} - -.team-picker-checkbox { - display: inline-flex; - align-items: center; - justify-content: center; - width: 30px; - height: 30px; - border-radius: 8px; - border: 1px solid rgba(100, 83, 61, 0.28); - background: rgba(255, 255, 255, 0.88); -} - -.team-picker-option-active .team-picker-checkbox { - color: #fff; - background: linear-gradient(180deg, #ffbf3b, #f0a21a); -} - -.team-picker-option strong, -.preset-team-card strong { - display: block; - font-size: 1.05rem; - line-height: 1.2; -} - -.team-picker-option small, -.preset-team-card small, -.picker-side-hint { - display: block; - margin-top: 4px; - color: #7b6148; - font-size: 0.82rem; -} - -.team-picker-actions { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px; -} - -.team-picker-ghost, -.team-picker-confirm, -.team-picker-clear { - border: 0; - border-radius: 999px; - padding: 12px 14px; - cursor: pointer; - font: inherit; - box-shadow: - inset 0 0 0 1px rgba(199, 155, 83, 0.24), - 0 10px 18px rgba(8, 47, 73, 0.12); - transition: - transform 0.16s ease, - box-shadow 0.16s ease, - filter 0.16s ease; -} - -.team-picker-ghost:hover, -.team-picker-confirm:hover:not(:disabled), -.team-picker-clear:hover { - transform: translateY(-1px); - box-shadow: - inset 0 0 0 1px rgba(199, 155, 83, 0.34), - 0 14px 22px rgba(8, 47, 73, 0.16); -} - -.team-picker-ghost:active, -.team-picker-confirm:active:not(:disabled), -.team-picker-clear:active { - transform: translateY(0); - filter: brightness(0.98); -} - -.team-picker-ghost { - color: #4d3a29; - background: linear-gradient(180deg, #f7f2e8, #e0d6c5); -} - -.team-picker-confirm { - color: #4a2e1d; - background: linear-gradient(180deg, #ebf8a4, #d6e164); -} - -.team-picker-confirm:disabled { - cursor: not-allowed; - opacity: 0.55; -} - -.preset-team-block { - display: grid; - gap: 10px; - min-height: 0; -} - -.preset-team-head { - display: grid; - gap: 2px; - color: #4a2e1d; -} - -.preset-team-head small { - color: #7b6148; - font-size: 0.8rem; -} - -.preset-team-list { - display: grid; - gap: 8px; - max-height: min(44dvh, 360px); - overflow: auto; -} - -.preset-team-card { - display: grid; - grid-template-columns: 44px minmax(0, 1fr); - gap: 10px; - align-items: center; - padding: 10px; - border: 1px solid rgba(124, 98, 61, 0.16); - border-radius: 14px; - cursor: pointer; - text-align: left; - background: rgba(255, 249, 238, 0.92); - transition: - transform 0.16s ease, - box-shadow 0.16s ease, - border-color 0.16s ease; -} - -.preset-team-card:hover { - transform: translateY(-1px); - border-color: rgba(199, 155, 83, 0.34); - box-shadow: 0 12px 22px rgba(8, 47, 73, 0.08); -} - -.preset-team-card-active { - background: rgba(255, 255, 255, 0.98); - box-shadow: 0 10px 20px rgba(147, 104, 35, 0.12); -} - -.preset-team-index { - display: inline-flex; - align-items: center; - justify-content: center; - width: 44px; - height: 44px; - border-radius: 999px; - font-size: 1.1rem; - color: #5b2f13; - background: linear-gradient(180deg, #ffc84d, #f2a316); -} - -.team-picker-clear { - color: #fff; - background: linear-gradient(180deg, #f7a17e, #ed774d); -} - -.finish-dialog-overlay { - position: fixed; - inset: 0; - z-index: 70; - display: grid; - place-items: center; - padding: 18px; - background: rgba(0, 0, 0, 0.56); - backdrop-filter: blur(6px); -} - -.finish-dialog { - position: relative; - width: min(460px, 100%); - display: grid; - gap: 16px; - padding: 24px 20px 20px; - border-radius: 24px; - background: linear-gradient(180deg, #fff8e8, #ffe5ad); - box-shadow: - 0 0 0 4px rgba(255, 255, 255, 0.18), - inset 0 0 0 2px rgba(200, 140, 46, 0.45); -} - -.finish-dialog-close { - position: absolute; - top: 10px; - right: 10px; - width: 48px; - height: 48px; - border: 0; - border-radius: 999px; - cursor: pointer; - font: inherit; - font-size: 1.8rem; - color: #b34e3a; - background: linear-gradient(180deg, #ffe5bf, #f0bd7c); - box-shadow: - inset 0 0 0 1px rgba(199, 125, 63, 0.34), - 0 10px 18px rgba(8, 47, 73, 0.16); - transition: - transform 0.16s ease, - box-shadow 0.16s ease, - filter 0.16s ease; -} - -.finish-dialog-close:hover:not(:disabled) { - transform: translateY(-1px); - box-shadow: - inset 0 0 0 1px rgba(199, 125, 63, 0.42), - 0 14px 22px rgba(8, 47, 73, 0.2); -} - -.finish-dialog-close:active:not(:disabled) { - transform: translateY(0); - filter: brightness(0.98); -} - -.finish-score { - display: grid; - grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); - gap: 10px; - align-items: center; - padding: 16px; - border-radius: 18px; - background: rgba(255, 249, 238, 0.94); -} - -.finish-score div { - display: grid; - gap: 6px; - justify-items: center; - text-align: center; -} - -.finish-score strong { - font-family: var(--mono); - font-size: 2.6rem; - line-height: 1; - color: #16342f; -} - -.finish-score span { - color: #5f4a35; -} - -.finish-score-divider { - font-family: var(--mono); - font-size: 2rem; - color: #70543c; -} - -.finish-dialog-copy { - color: #5f4a35; - text-align: center; -} - -.finish-dialog-error { - padding: 12px 14px; - border-radius: 14px; - color: #7a1d2a; - background: rgba(255, 224, 230, 0.95); -} - -.finish-dialog-actions { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; -} - -.history-modal-overlay { - position: fixed; - inset: 0; - z-index: 70; - display: grid; - place-items: center; - padding: 18px; - background: rgba(0, 0, 0, 0.56); - backdrop-filter: blur(6px); -} - -.history-modal { - position: relative; - width: min(680px, 100%); - display: grid; - gap: 10px; - padding: 18px 18px 16px; - border-radius: 24px; - background: linear-gradient(180deg, #fff8e8, #ffe5ad); - box-shadow: - 0 0 0 4px rgba(255, 255, 255, 0.18), - inset 0 0 0 2px rgba(200, 140, 46, 0.45); -} - -.history-modal-close { - position: absolute; - top: 10px; - right: 10px; - width: 44px; - height: 44px; - border: 0; - border-radius: 999px; - cursor: pointer; - font: inherit; - font-size: 1.6rem; - line-height: 1; - color: #b34e3a; - background: linear-gradient(180deg, #ffe5bf, #f0bd7c); - box-shadow: - inset 0 0 0 1px rgba(199, 125, 63, 0.34), - 0 10px 18px rgba(8, 47, 73, 0.16); - transition: - transform 0.16s ease, - box-shadow 0.16s ease, - filter 0.16s ease; -} - -.history-modal-close:hover { - transform: translateY(-1px); - box-shadow: - inset 0 0 0 1px rgba(199, 125, 63, 0.42), - 0 14px 22px rgba(8, 47, 73, 0.2); -} - -.history-modal-close:active { - transform: translateY(0); - filter: brightness(0.98); -} - -.history-modal-score { - display: grid; - grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); - gap: 8px; - align-items: center; - padding: 10px 12px; - border-radius: 14px; - background: rgba(255, 249, 238, 0.94); -} - -.history-modal-score div { - display: grid; - gap: 2px; - justify-items: center; - text-align: center; -} - -.history-modal-score strong { - font-family: var(--mono); - font-size: 1.9rem; - line-height: 1; - color: #16342f; -} - -.history-modal-score span { - font-size: 0.88rem; - color: #5f4a35; -} - -.history-modal-score-divider { - font-family: var(--mono); - font-size: 1.3rem; - color: #70543c; -} - -.history-modal-summary { - display: flex; - flex-wrap: wrap; - gap: 8px; - font-size: 0.86rem; - color: #5f4a35; -} - -.history-replay-list { - display: grid; - gap: 10px; - max-height: min(50vh, 480px); - overflow: auto; -} - -.history-replay-row { - display: grid; - grid-template-columns: 108px 92px 92px minmax(0, 1fr); - gap: 10px; - align-items: center; - padding: 12px 14px; - border-radius: 14px; - background: rgba(255, 249, 238, 0.92); -} - -.history-replay-empty { - padding: 12px 14px; - border-radius: 14px; - color: #5f4a35; - background: rgba(255, 249, 238, 0.92); -} - -.inline-link { - display: inline-flex; - width: fit-content; - margin-top: 20px; -} - -@media (max-width: 980px) { - .topbar, - .page-grid, - .summary-grid, - .double-grid, - .history-meta, - .selection-toolbar { - grid-template-columns: 1fr; - } - - .topbar { - display: grid; - align-items: start; - } - - .panel-heading, - .group-head, - .history-head { - flex-direction: column; - } - - .group-head-compact { - flex-direction: row; - align-items: center; - } - - .group-head-compact .group-actions { - flex-shrink: 0; - } - - .scoreboard-screen { - grid-template-columns: 1fr; - } - - .scoreboard-rail { - grid-template-columns: minmax(0, 1fr) 140px 160px; - align-items: stretch; - } - - .scoreboard-team-head { - grid-template-columns: minmax(0, 1fr) 62px; - } - - .team-head-main { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .team-picker-layout { - grid-template-columns: minmax(0, 1fr) 220px; - gap: 12px; - } -} - -@media (max-width: 720px) { - .pwa-update-toast { - gap: 10px; - bottom: 12px; - padding: 12px 12px 12px 14px; - border-radius: 16px; - } - - .pwa-update-copy strong { - font-size: 0.92rem; - } - - .pwa-update-copy span { - font-size: 0.78rem; - } - - .pwa-update-button { - min-width: 84px; - padding: 9px 12px; - font-size: 0.84rem; - } - - .app-shell { - width: min(100% - 14px, 1240px); - padding: 14px 0 24px; - } - - .app-shell-scoreboard { - width: min(100% - 10px, 1240px); - padding-top: 10px; - } - - .app-shell-scoreboard-fit { - min-height: 100dvh; - padding-top: max(8px, env(safe-area-inset-top)); - padding-bottom: max(8px, env(safe-area-inset-bottom)); - } - - .panel { - padding: 16px; - border-radius: 18px; - } - - .topbar { - gap: 10px; - margin-bottom: 10px; - } - - .branding h1 { - font-size: 1.35rem; - } - - .intro-copy { - display: none; - } - - .nav-pill, - .inline-link { - padding: 9px 12px; - } - - .selection-toolbar { - gap: 12px; - } - - .selection-toolbar > .field { - width: 100%; - } - - .button-stack { - grid-template-columns: 1fr; - } - - .field input[type='date'] { - max-width: 100%; - font-size: 16px; - overflow: hidden; - } - - .floating-status-bubble { - bottom: 12px; - min-width: min(88vw, 320px); - max-width: 92vw; - padding: 12px 16px; - font-size: 0.92rem; - } - - .group-head-compact { - gap: 10px; - } - - .group-head-compact h3 { - font-size: 1rem; - } - - .group-head-compact .inline-link { - padding: 8px 12px; - white-space: nowrap; - } - - .scoreboard-court { - gap: 10px; - padding: 8px; - border-radius: 16px; - } - - .app-shell-scoreboard-fit .topbar-compact { - gap: 6px; - margin-bottom: 6px; - } - - .app-shell-scoreboard-fit .eyebrow { - margin-bottom: 6px; - padding: 6px 10px; - font-size: 0.66rem; - } - - .app-shell-scoreboard-fit .branding h1 { - margin: 8px 0 6px; - font-size: 1.12rem; - } - - .app-shell-scoreboard-fit .topnav { - gap: 6px; - } - - .app-shell-scoreboard-fit .nav-pill { - padding: 7px 10px; - font-size: 0.82rem; - } - - .streak-banner { - top: 12%; - min-width: min(92vw, 360px); - padding: 14px 18px; - border-radius: 22px; - } - - .streak-banner strong { - font-size: clamp(1.6rem, 8vw, 2.4rem); - } - - .streak-banner small { - font-size: 0.88rem; - } - - .victory-banner { - top: 14%; - min-width: min(92vw, 360px); - padding: 16px 18px; - border-radius: 22px; - } - - .victory-banner strong { - font-size: clamp(1.8rem, 9vw, 2.8rem); - } - - .victory-banner small, - .victory-banner em { - font-size: 0.88rem; - } - - .voice-settings-panel { - padding: 20px 14px 14px; - border-radius: 18px; - } - - .scoreboard-team-head { - grid-template-columns: minmax(0, 1fr) 54px; - gap: 5px; - } - - .team-head-main { - gap: 5px; - min-height: 46px; - padding: 5px; - } - - .scoreboard-name-chip { - gap: 5px; - min-height: 34px; - padding: 3px 5px; - } - - .serve-lane-arrow { - left: 52px; - } - - .team-number { - width: 24px; - height: 24px; - flex-basis: 24px; - font-size: 0.82rem; - } - - .scoreboard-name-chip strong { - font-size: clamp(0.76rem, 3.6vw, 1rem); - } - - .team-icon-button { - min-height: 30px; - font-size: 0.94rem; - } - - .serve-lane { - grid-template-columns: 28px auto; - min-height: 34px; - padding: 4px 6px; - font-size: 0.86rem; - } - - .serve-lane small { - grid-column: 1 / -1; - justify-self: start; - font-size: 0.72rem; - } - - .score-panel-surface { - min-height: clamp(90px, 15.5dvh, 104px); - } - - .score-panel-value { - font-size: clamp(3rem, 15vw, 4.6rem); - } - - .scoreboard-center-banner { - gap: 2px; - } - - .scoreboard-center-banner p { - font-size: 1.02rem; - } - - .scoreboard-center-banner small { - font-size: 0.7rem; - text-align: center; - } - - .scoreboard-rail { - grid-template-columns: minmax(0, 1fr) 94px 106px; - gap: 6px; - } - - .rail-square-button { - min-height: 38px; - font-size: 0.84rem; - } - - .rail-clock { - min-height: 38px; - border-radius: 10px; - font-size: 0.96rem; - } - - .rail-room-id { - padding: 7px 8px; - font-size: 0.76rem; - } - - .rail-pill { - padding: 8px 7px; - font-size: 0.82rem; - } - - .rail-pill-hold-wrap { - gap: 4px; - } - - .rail-hold-progress { - height: 5px; - } - - .finish-dialog { - padding: 20px 14px 14px; - border-radius: 18px; - } - - .history-modal { - gap: 8px; - padding: 14px 12px; - border-radius: 18px; - } - - .finish-dialog-close { - width: 40px; - height: 40px; - font-size: 1.4rem; - } - - .finish-score { - padding: 12px 10px; - } - - .finish-score strong { - font-size: 2rem; - } - - .finish-dialog-actions { - grid-template-columns: 1fr; - } - - .history-modal-score { - grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); - gap: 6px; - padding: 8px 10px; - } - - .history-modal-score-divider { - display: block; - } - - .history-replay-row { - grid-template-columns: 1fr; - gap: 4px; - } - - .history-card-shell { - grid-template-columns: 1fr; - gap: 10px; - } - - .history-delete-button { - width: 100%; - } - - .room-card { - padding: 14px; - border-radius: 16px; - } - - .room-card-score strong, - .room-watch-team strong { - font-size: clamp(1.7rem, 12vw, 2.6rem); - } - - .team-picker-ribbon { - left: 18px; - right: 90px; - top: -18px; - padding: 10px 16px; - border-radius: 18px; - font-size: 0.88rem; - } - - .team-picker-shell { - max-height: calc(100dvh - 12px); - } - - .team-picker-layout { - grid-template-columns: minmax(0, 1fr) 156px; - gap: 8px; - padding: 18px 10px 10px; - border-radius: 20px; - max-height: calc(100dvh - 24px); - } - - .team-picker-panel { - gap: 8px; - padding: 10px; - border-radius: 16px; - } - - .team-picker-title { - gap: 8px; - } - - .team-picker-count { - min-width: 56px; - min-height: 38px; - padding: 0 10px; - font-size: 1rem; - } - - .team-picker-title strong { - font-size: 0.96rem; - } - - .team-picker-title p { - font-size: 0.72rem; - } - - .team-picker-config-compact span { - font-size: 0.8rem; - } - - .team-picker-score-input-compact { - width: 58px; - max-width: 58px; - padding: 6px 8px; - font-size: 0.88rem; - } - - .team-picker-list { - gap: 6px; - max-height: min(46dvh, 330px); - } - - .team-picker-option { - grid-template-columns: 24px minmax(0, 1fr); - gap: 8px; - padding: 9px 8px; - border-radius: 12px; - } - - .team-picker-checkbox { - width: 24px; - height: 24px; - font-size: 0.8rem; - } - - .team-picker-option strong, - .preset-team-card strong { - font-size: 0.9rem; - } - - .team-picker-option small, - .preset-team-card small, - .picker-side-hint, - .preset-team-head small { - font-size: 0.72rem; - } - - .team-picker-actions { - gap: 8px; - } - - .team-picker-ghost, - .team-picker-confirm, - .team-picker-clear { - padding: 10px 10px; - font-size: 0.86rem; - } - - .preset-team-list { - gap: 6px; - max-height: min(42dvh, 300px); - } - - .preset-team-card { - grid-template-columns: 34px minmax(0, 1fr); - gap: 8px; - padding: 8px; - border-radius: 12px; - } - - .preset-team-index { - width: 34px; - height: 34px; - font-size: 0.92rem; - } -} - -@media (max-width: 720px) and (max-height: 840px) { - .app-shell-scoreboard-fit .scoreboard-screen { - gap: 5px; - } - - .app-shell-scoreboard-fit .scoreboard-court { - gap: 8px; - padding: 6px; - } - - .app-shell-scoreboard-fit .scoreboard-team-section { - gap: 5px; - } - - .app-shell-scoreboard-fit .score-panel-surface { - min-height: clamp(82px, 14.2dvh, 96px); - } - - .app-shell-scoreboard-fit .score-panel-value { - font-size: clamp(2.7rem, 13.5vw, 4.1rem); - } - - .app-shell-scoreboard-fit .scoreboard-center-banner p { - font-size: 0.96rem; - } -} - -.room-list-grid { - display: grid; - gap: 16px; -} - -.room-list-toolbar { - display: flex; - justify-content: flex-end; - margin-bottom: 16px; -} - -.room-refresh-button:disabled { - cursor: default; - opacity: 0.72; -} - -.room-card { - display: grid; - gap: 14px; - padding: 18px; - border-radius: 20px; - color: inherit; - text-decoration: none; - background: rgba(255, 255, 255, 0.84); - box-shadow: 0 18px 28px rgba(8, 47, 73, 0.08); - transition: transform 0.16s ease, box-shadow 0.16s ease; -} - -.room-card:hover { - transform: translateY(-2px); - box-shadow: 0 22px 34px rgba(8, 47, 73, 0.12); -} - -.room-card-head, -.room-watch-meta { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - gap: 10px; -} - -.room-card-head span, -.room-watch-meta span, -.room-card-updated { - color: var(--panel-soft); - font-size: 0.9rem; -} - -.room-card-score, -.room-watch-scoreboard { - display: grid; - grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); - align-items: center; - gap: 12px; -} - -.room-card-matchup { - display: grid; - grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); - align-items: center; - gap: 12px; -} - -.room-card-score div, -.room-watch-team { - display: grid; - gap: 8px; - justify-items: center; - text-align: center; - padding: 16px 12px; - border-radius: 18px; - background: rgba(244, 236, 216, 0.8); -} - -.room-card-matchup strong { - display: block; - min-width: 0; - padding: 16px 12px; - border-radius: 18px; - text-align: center; - background: rgba(244, 236, 216, 0.8); - color: var(--panel-strong); -} - -.room-card-matchup span { - font-size: 1rem; - font-weight: 700; - color: var(--panel-soft); -} - -.room-card-score small, -.room-watch-team small { - color: var(--panel-soft); -} - -.room-card-score strong, -.room-watch-team strong { - font-size: clamp(2rem, 8vw, 3.4rem); - line-height: 1; -} - -.room-card-score span, -.room-watch-divider { - font-size: 2rem; - font-weight: 700; - color: var(--panel-strong); -} - -.room-watch-panel { - display: grid; - gap: 18px; -} +.app-shell { + width: min(1240px, calc(100% - 32px)); + margin: 0 auto; + padding: 28px 0 56px; +} + +.app-shell-scoreboard { + width: min(1180px, calc(100% - 20px)); + padding-top: 16px; +} + +.app-shell-scoreboard-fit { + min-height: 100dvh; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + padding-bottom: max(12px, env(safe-area-inset-bottom)); +} + +.topbar { + display: flex; + justify-content: space-between; + gap: 24px; + align-items: end; + margin-bottom: 24px; +} + +.topbar-compact { + align-items: center; + margin-bottom: 14px; +} + +.branding { + max-width: 760px; +} + +.eyebrow, +.panel-kicker, +.team-index { + display: inline-flex; + letter-spacing: 0.14em; + text-transform: uppercase; + font-size: 0.76rem; +} + +.eyebrow { + margin-bottom: 12px; + padding: 8px 12px; + border-radius: 999px; + color: #effce4; + background: rgba(8, 47, 73, 0.84); +} + +.intro-copy { + max-width: 60ch; + margin-top: 10px; +} + +.topnav { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.nav-pill, +.inline-link { + padding: 12px 16px; + border-radius: 999px; + color: var(--panel-strong); + text-decoration: none; + background: rgba(255, 255, 255, 0.58); + border: 1px solid rgba(10, 51, 45, 0.1); + transition: transform 0.18s ease; +} + +.nav-pill:hover, +.inline-link:hover { + transform: translateY(-1px); +} + +.nav-pill-active { + color: #f7fff7; + background: linear-gradient(135deg, rgba(8, 47, 73, 0.96), rgba(10, 96, 84, 0.92)); +} + +.pwa-update-toast { + position: fixed; + left: 50%; + bottom: 18px; + z-index: 1200; + display: flex; + align-items: center; + gap: 14px; + width: min(560px, calc(100vw - 24px)); + padding: 14px 16px; + border: 1px solid rgba(10, 51, 45, 0.16); + border-radius: 20px; + background: rgba(255, 249, 236, 0.96); + box-shadow: + 0 22px 46px rgba(10, 51, 45, 0.22), + 0 8px 18px rgba(10, 51, 45, 0.12); + transform: translateX(-50%); + backdrop-filter: blur(14px); +} + +.pwa-update-copy { + display: grid; + gap: 2px; + min-width: 0; +} + +.pwa-update-copy strong { + font-size: 1rem; + color: var(--panel-strong); +} + +.pwa-update-copy span { + font-size: 0.88rem; + color: var(--panel-soft); +} + +.pwa-update-button { + flex: 0 0 auto; + min-width: 96px; + padding: 10px 14px; + border: none; + border-radius: 999px; + background: linear-gradient(135deg, #0d5d53, #123f49); + color: #f8fff8; + font: inherit; + font-weight: 700; + cursor: pointer; +} + +.pwa-update-button:hover { + filter: brightness(1.06); +} + +.page-grid { + display: grid; + grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); + gap: 22px; +} + +.full-span { + grid-column: 1 / -1; +} + +.panel { + border: 1px solid var(--border); + border-radius: 28px; + background: rgba(255, 255, 255, 0.78); + box-shadow: var(--shadow); + padding: 28px; +} + +.panel-hero { + background: + radial-gradient(circle at top right, rgba(255, 196, 0, 0.24), transparent 30%), + linear-gradient(145deg, rgba(8, 47, 73, 0.95), rgba(10, 96, 84, 0.92)); + color: #f5fff8; +} + +.panel-hero h2, +.panel-hero p, +.panel-hero .panel-kicker { + color: inherit; +} + +.panel-copy { + margin-top: 12px; + max-width: 34rem; + color: rgba(245, 255, 248, 0.78); +} + +.panel-heading, +.group-head, +.history-head { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: start; +} + +.group-head-compact { + align-items: center; +} + +.group-head-compact > div:first-child { + min-width: 0; +} + +.group-head-compact h3 { + margin: 4px 0 0; +} + +.summary-grid, +.double-grid, +.history-list, +.history-meta { + display: grid; + gap: 16px; +} + +.summary-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-top: 24px; +} + +.mini-stat { + padding: 18px; + border-radius: 22px; + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.18); +} + +.mini-stat span { + display: block; + color: rgba(245, 255, 248, 0.76); +} + +.mini-stat strong { + display: block; + margin-top: 8px; + font-size: 1.6rem; +} + +.status-banner { + margin-top: 24px; + padding: 14px 16px; + border-radius: 18px; + font-weight: 700; +} + +.status-banner-loading { + background: rgba(255, 255, 255, 0.16); +} + +.status-banner-loaded { + color: #103b34; + background: rgba(201, 255, 215, 0.9); +} + +.status-banner-empty { + color: #5f3c0a; + background: rgba(255, 239, 190, 0.94); +} + +.status-banner-error { + color: #5b1020; + background: rgba(255, 214, 224, 0.94); +} + +.floating-status-bubble { + position: fixed; + left: 50%; + bottom: 22px; + z-index: 60; + transform: translateX(-50%); + min-width: min(78vw, 320px); + max-width: min(84vw, 420px); + padding: 14px 20px; + border-radius: 999px; + text-align: center; + font-weight: 700; + color: #f8fff9; + background: linear-gradient(135deg, rgba(8, 47, 73, 0.96), rgba(10, 96, 84, 0.92)); + box-shadow: 0 18px 40px rgba(8, 47, 73, 0.28); + pointer-events: none; + animation: status-bubble-in 0.18s ease-out; +} + +@keyframes status-bubble-in { + from { + opacity: 0; + transform: translateX(-50%) translateY(10px) scale(0.96); + } + + to { + opacity: 1; + transform: translateX(-50%) translateY(0) scale(1); + } +} + +.selection-shell, +.selection-form, +.group-board { + display: grid; + gap: 18px; +} + +.selection-toolbar { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 16px; + align-items: end; +} + +.selection-toolbar > .field { + width: min(100%, 220px); +} + +.double-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.field { + display: grid; + gap: 8px; + min-width: 0; +} + +.field span { + font-weight: 700; +} + +.field input, +.field textarea, +.field select { + width: 100%; + padding: 14px 16px; + border: 1px solid rgba(10, 51, 45, 0.16); + border-radius: 16px; + background: rgba(255, 255, 255, 0.94); + color: var(--panel-strong); + font: inherit; +} + +.field textarea { + resize: vertical; + min-height: 200px; +} + +.field input[type='date'] { + min-width: 0; +} + +.button-stack { + display: grid; + gap: 12px; + align-content: end; +} + +.primary-button, +.secondary-button { + border: 0; + border-radius: 16px; + cursor: pointer; + font: inherit; + padding: 14px 18px; +} + +.primary-button { + color: #f8fff8; + background: linear-gradient(135deg, rgba(8, 47, 73, 0.96), rgba(10, 96, 84, 0.92)); +} + +.secondary-button { + color: var(--panel-strong); + background: rgba(11, 88, 73, 0.1); +} + +.selection-hint { + display: flex; + flex-wrap: wrap; + gap: 12px; + padding: 14px 16px; + border-radius: 18px; + background: rgba(10, 51, 45, 0.05); + color: var(--panel-soft); +} + +.group-card, +.history-card, +.empty-state { + padding: 22px; + border-radius: 22px; + background: rgba(246, 249, 244, 0.95); + border: 1px solid rgba(10, 51, 45, 0.08); +} + +.group-card-stage { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(241, 246, 242, 0.94)); +} + +.group-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.team-stage-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 14px; + margin-top: 18px; +} + +.team-stage-card { + padding: 18px; + border-radius: 18px; + background: rgba(255, 255, 255, 0.88); + border: 1px solid rgba(10, 51, 45, 0.08); +} + +.team-index { + color: var(--panel-soft); +} + +.team-name { + margin-top: 10px; + font-size: 1.08rem; + color: var(--panel-strong); +} + +.winner-badge { + align-self: start; + padding: 10px 14px; + border-radius: 999px; + color: var(--panel-strong); + background: rgba(11, 88, 73, 0.1); +} + +.history-meta { + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-top: 16px; +} + +.history-meta span { + color: var(--panel-soft); +} + +.history-card-button { + width: 100%; + border: 0; + cursor: pointer; + text-align: left; + font: inherit; +} + +.history-card-button:hover { + box-shadow: 0 12px 28px rgba(8, 47, 73, 0.08); +} + +.history-card-shell { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 14px; + align-items: center; +} + +.history-card-content { + padding: 0; + background: transparent; +} + +.history-delete-button { + border: 0; + border-radius: 999px; + padding: 12px 16px; + cursor: pointer; + font: inherit; + color: #fff; + background: linear-gradient(180deg, #e57a63, #c44c3d); + box-shadow: + inset 0 0 0 1px rgba(161, 54, 37, 0.22), + 0 10px 18px rgba(8, 47, 73, 0.12); + transition: + transform 0.16s ease, + box-shadow 0.16s ease, + filter 0.16s ease, + opacity 0.16s ease; +} + +.history-delete-button:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: + inset 0 0 0 1px rgba(161, 54, 37, 0.28), + 0 14px 22px rgba(8, 47, 73, 0.16); +} + +.history-delete-button:active:not(:disabled) { + transform: translateY(0); + filter: brightness(0.98); +} + +.history-delete-button:disabled { + cursor: default; + opacity: 0.62; +} + +.scoreboard-screen { + display: grid; + grid-template-columns: minmax(0, 1fr) 160px; + gap: 14px; + align-items: start; + min-height: 0; + height: 100%; +} + +.streak-banner { + position: fixed; + left: 50%; + top: 18%; + z-index: 85; + display: grid; + justify-items: center; + gap: 6px; + min-width: min(88vw, 420px); + padding: 18px 28px; + border-radius: 28px; + color: #fff8e8; + background: + radial-gradient(circle at top, rgba(255, 219, 112, 0.38), transparent 46%), + linear-gradient(135deg, rgba(143, 25, 26, 0.96), rgba(248, 128, 45, 0.92)); + box-shadow: + 0 24px 48px rgba(8, 47, 73, 0.32), + inset 0 0 0 1px rgba(255, 238, 194, 0.24); + transform: translate(-50%, 0); + pointer-events: none; + animation: streak-banner-burst 1.8s ease forwards; +} + +.streak-banner-count { + font-size: 0.96rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: rgba(255, 244, 214, 0.86); +} + +.streak-banner strong { + font-size: clamp(2rem, 7vw, 3.6rem); + line-height: 1; + text-shadow: + 0 2px 0 rgba(101, 14, 10, 0.28), + 0 0 22px rgba(255, 226, 154, 0.24); +} + +.streak-banner small { + font-size: 1rem; + color: rgba(255, 248, 232, 0.92); +} + +.victory-banner { + position: fixed; + left: 50%; + top: 22%; + z-index: 86; + display: grid; + justify-items: center; + gap: 6px; + min-width: min(90vw, 460px); + padding: 22px 30px; + border-radius: 30px; + color: #4a2e1d; + background: + radial-gradient(circle at top, rgba(255, 255, 255, 0.34), transparent 40%), + linear-gradient(135deg, rgba(255, 243, 196, 0.98), rgba(255, 199, 92, 0.94)); + box-shadow: + 0 30px 56px rgba(8, 47, 73, 0.28), + 0 0 0 6px rgba(255, 243, 211, 0.16), + inset 0 0 0 1px rgba(196, 134, 32, 0.34); + transform: translate(-50%, 0); + pointer-events: none; + animation: victory-banner-burst 2.2s ease forwards; +} + +.victory-banner-kicker { + font-size: 0.9rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: rgba(101, 67, 22, 0.82); +} + +.victory-banner strong { + font-size: clamp(2.2rem, 8vw, 4rem); + line-height: 1; + text-shadow: + 0 2px 0 rgba(255, 255, 255, 0.4), + 0 0 22px rgba(255, 242, 176, 0.34); +} + +.victory-banner small, +.victory-banner em { + font-size: 1rem; + font-style: normal; + color: rgba(74, 46, 29, 0.9); +} + +@keyframes streak-banner-burst { + 0% { + opacity: 0; + transform: translate(-50%, 12px) scale(0.88); + } + + 15% { + opacity: 1; + transform: translate(-50%, 0) scale(1.04); + } + + 70% { + opacity: 1; + transform: translate(-50%, 0) scale(1); + } + + 100% { + opacity: 0; + transform: translate(-50%, -12px) scale(0.96); + } +} + +@keyframes victory-banner-burst { + 0% { + opacity: 0; + transform: translate(-50%, 18px) scale(0.84); + } + + 14% { + opacity: 1; + transform: translate(-50%, 0) scale(1.06); + } + + 68% { + opacity: 1; + transform: translate(-50%, 0) scale(1); + } + + 100% { + opacity: 0; + transform: translate(-50%, -18px) scale(0.96); + } +} + +.scoreboard-court { + display: grid; + gap: 14px; + min-height: 0; + padding: 12px; + border-radius: 22px; + background: + radial-gradient(circle at top right, rgba(255, 205, 96, 0.22), transparent 26%), + linear-gradient(145deg, rgba(8, 47, 73, 0.97), rgba(10, 96, 84, 0.93)); + box-shadow: 0 30px 60px rgba(8, 47, 73, 0.24); +} + +.scoreboard-team-section { + display: grid; + gap: 8px; +} + +.scoreboard-team-head { + display: grid; + grid-template-columns: minmax(0, 1fr) 62px; + gap: 8px; + align-items: center; +} + +.team-head-main { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + min-height: 64px; + padding: 8px; + border-radius: 4px; + background: rgba(255, 248, 232, 0.92); + box-shadow: inset 0 0 0 1px rgba(195, 154, 88, 0.2); +} + +.scoreboard-name-chip { + display: flex; + align-items: center; + gap: 8px; + min-height: 46px; + padding: 4px 8px; + border-radius: 4px; + color: #16342f; + background: rgba(255, 255, 255, 0.7); +} + +.scoreboard-name-chip strong { + font-size: clamp(1rem, 2.4vw, 1.9rem); + line-height: 1; + font-weight: 500; +} + +.scoreboard-name-chip-serving { + background: linear-gradient(180deg, #ebf8a4, #d6e164); + box-shadow: inset 0 0 0 2px rgba(111, 128, 27, 0.24); +} + +.team-number { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + flex: 0 0 40px; + font-family: var(--mono); + font-size: 1.15rem; + color: #fff; + background: linear-gradient(180deg, rgba(8, 47, 73, 0.96), rgba(10, 96, 84, 0.92)); + border-radius: 4px; +} + +.team-head-buttons { + display: grid; + gap: 10px; +} + +.team-icon-button { + min-height: 40px; + border: 0; + border-radius: 4px; + cursor: pointer; + font: inherit; + font-size: 1.2rem; + color: #5b2f13; + background: linear-gradient(180deg, #fff8e8, #f2d9a3); + box-shadow: + inset 0 0 0 1px rgba(199, 155, 83, 0.35), + 0 6px 14px rgba(8, 47, 73, 0.14); + transition: + transform 0.16s ease, + box-shadow 0.16s ease, + filter 0.16s ease; +} + +.team-icon-button:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: + inset 0 0 0 1px rgba(199, 155, 83, 0.45), + 0 10px 18px rgba(8, 47, 73, 0.18); +} + +.team-icon-button:active:not(:disabled) { + transform: translateY(0); + filter: brightness(0.98); +} + +.team-icon-button:disabled { + opacity: 0.45; + cursor: default; +} + +.serve-lane { + position: relative; + display: grid; + grid-template-columns: 32px auto 1fr; + align-items: center; + gap: 8px; + min-height: 46px; + width: min(100%, 440px); + border: 0; + border-radius: 4px; + padding: 8px 10px; + cursor: pointer; + text-align: left; + color: #f8f4ea; + background: rgba(255, 248, 232, 0.12); + box-shadow: inset 0 0 0 1px rgba(255, 236, 202, 0.12); + transition: + transform 0.16s ease, + box-shadow 0.16s ease, + background 0.16s ease; +} + +.serve-lane:disabled { + cursor: default; +} + +.serve-lane:hover:not(:disabled) { + transform: translateY(-1px); + background: rgba(255, 248, 232, 0.18); + box-shadow: + inset 0 0 0 1px rgba(255, 236, 202, 0.22), + 0 10px 18px rgba(8, 47, 73, 0.12); +} + +.serve-lane-prompt { + background: rgba(255, 248, 232, 0.12); + box-shadow: inset 0 0 0 1px rgba(255, 236, 202, 0.12); +} + +.serve-lane-arrow { + display: none; +} + +.serve-lane small { + justify-self: end; + color: rgba(248, 244, 234, 0.72); +} + +.serve-lane-prompt > span:nth-of-type(2) { + color: #f7ffbe; + font-weight: 800; + text-shadow: + 0 0 12px rgba(235, 248, 164, 0.5), + 0 0 22px rgba(235, 248, 164, 0.24); + animation: serve-label-pulse 0.9s ease-in-out infinite; +} + +.serve-lane-locked { + box-shadow: inset 0 0 0 1px rgba(214, 225, 100, 0.42); +} + +.serve-lane-box { + width: 24px; + height: 24px; + border-radius: 4px; + background: linear-gradient(180deg, #fff8e8, #f0dfbd); + box-shadow: inset 0 0 0 1px rgba(195, 154, 88, 0.22); +} + +.serve-lane-box-checked { + position: relative; + background: linear-gradient(180deg, #ebf8a4, #d6e164); + box-shadow: + inset 0 0 0 1px rgba(111, 128, 27, 0.26), + 0 0 0 1px rgba(214, 225, 100, 0.14); +} + +.serve-lane-box-checked::after { + content: '✓'; + position: absolute; + inset: 0; + display: grid; + place-items: center; + font-size: 1rem; + font-weight: 800; + color: #38501f; +} + +@keyframes serve-label-pulse { + 0%, + 100% { + transform: scale(1); + opacity: 0.82; + } + + 50% { + transform: scale(1.08); + opacity: 1; + } +} + +.score-panel-surface { + display: grid; + place-items: center; + min-height: 172px; + border: 0; + width: 100%; + padding: 0; + border-radius: 4px; + cursor: default; + transition: + transform 0.16s ease, + box-shadow 0.16s ease; + background: + linear-gradient(transparent 0 40%, rgba(11, 39, 34, 0.18) 40% 60%, transparent 60% 100%), + linear-gradient(90deg, transparent 0 40%, rgba(11, 39, 34, 0.18) 40% 60%, transparent 60% 100%), + linear-gradient(180deg, rgba(255, 248, 232, 0.2), rgba(255, 248, 232, 0.08)), + rgba(245, 237, 221, 0.96); +} + +.score-panel-surface-live { + cursor: pointer; +} + +.score-panel-surface-live:hover { + transform: translateY(-1px); + box-shadow: + inset 0 0 0 2px rgba(236, 193, 112, 0.44), + 0 10px 20px rgba(8, 47, 73, 0.12); +} + +.score-panel-value { + font-family: var(--mono); + font-size: clamp(4.4rem, 11vw, 7.2rem); + line-height: 1; + color: #0d544a; + text-shadow: 0 2px 0 rgba(255, 255, 255, 0.42); +} + +.scoreboard-center-banner { + display: grid; + justify-items: center; + gap: 6px; + padding: 2px 0; +} + +.scoreboard-center-banner p { + font-size: clamp(1.5rem, 3vw, 2.3rem); + color: #fff7e9; + text-align: center; +} + +.scoreboard-center-banner small { + font-size: 0.92rem; + color: rgba(255, 247, 233, 0.76); +} + +.scoreboard-rail { + display: grid; + gap: 10px; + align-content: start; +} + +.rail-icon-grid { + display: grid; + grid-template-columns: 1fr; + gap: 12px; +} + +.rail-square-button { + min-height: 52px; + border: 0; + border-radius: 10px; + cursor: pointer; + font: inherit; + color: #5b2f13; + background: linear-gradient(180deg, #fff8e8, #f2d9a3); + box-shadow: + inset 0 0 0 1px rgba(199, 155, 83, 0.35), + 0 8px 18px rgba(8, 47, 73, 0.14); + transition: + transform 0.16s ease, + box-shadow 0.16s ease, + filter 0.16s ease; +} + +.rail-square-button:hover { + transform: translateY(-1px); + box-shadow: + inset 0 0 0 1px rgba(199, 155, 83, 0.45), + 0 12px 20px rgba(8, 47, 73, 0.18); +} + +.rail-square-button:active { + transform: translateY(0); + filter: brightness(0.98); +} + +.rail-clock { + display: grid; + place-items: center; + min-height: 68px; + border-radius: 12px; + font-family: var(--mono); + font-size: 1.7rem; + color: #fff7e9; + background: + radial-gradient(circle at top right, rgba(255, 205, 96, 0.16), transparent 28%), + linear-gradient(145deg, rgba(8, 47, 73, 0.97), rgba(10, 96, 84, 0.93)); +} + +.rail-room-id { + padding: 10px 12px; + border-radius: 14px; + text-align: center; + color: #f7fff8; + background: rgba(8, 47, 73, 0.72); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12); +} + +.rail-pill { + border: 0; + border-radius: 999px; + padding: 14px 14px; + cursor: pointer; + user-select: none; + -webkit-user-select: none; + -webkit-touch-callout: none; + touch-action: manipulation; + font: inherit; + font-size: 1rem; + color: #4a2e1d; + background: linear-gradient(180deg, #fff0c7, #f8c870); + box-shadow: + inset 0 0 0 1px rgba(199, 155, 83, 0.28), + 0 10px 18px rgba(8, 47, 73, 0.14); + transition: + transform 0.16s ease, + box-shadow 0.16s ease, + filter 0.16s ease; +} + +.rail-pill-hold-wrap { + display: grid; + gap: 8px; + user-select: none; + -webkit-user-select: none; + -webkit-touch-callout: none; +} + +.rail-pill-hold-wrap-active { + filter: drop-shadow(0 12px 20px rgba(217, 90, 68, 0.2)); +} + +.rail-pill:hover { + transform: translateY(-1px); + box-shadow: + inset 0 0 0 1px rgba(199, 155, 83, 0.34), + 0 14px 22px rgba(8, 47, 73, 0.18); +} + +.rail-pill:active { + transform: translateY(0); + filter: brightness(0.98); +} + +.rail-pill-danger { + color: #fff; + background: linear-gradient(180deg, #d95a44, #b53a28); +} + +.rail-pill-active-hold { + transform: translateY(1px) scale(0.99); + filter: brightness(1.08) saturate(1.08); + box-shadow: + inset 0 0 0 1px rgba(255, 227, 214, 0.5), + inset 0 12px 18px rgba(255, 255, 255, 0.1), + 0 0 0 4px rgba(255, 224, 194, 0.2), + 0 14px 26px rgba(181, 58, 40, 0.28); +} + +.rail-pill-muted { + color: #4d3a29; + background: linear-gradient(180deg, #f7f2e8, #e0d6c5); +} + +.rail-hold-progress { + position: relative; + width: 100%; + height: 8px; + overflow: hidden; + border-radius: 999px; + background: rgba(255, 240, 199, 0.32); + box-shadow: inset 0 0 0 1px rgba(255, 237, 208, 0.12); +} + +.rail-hold-progress-bar { + display: block; + width: 100%; + height: 100%; + transform-origin: left center; + background: linear-gradient(90deg, #ffe9a8, #fff7d0, #ffffff); + box-shadow: 0 0 12px rgba(255, 246, 203, 0.5); +} + +.voice-settings-overlay { + position: fixed; + inset: 0; + z-index: 75; + display: grid; + place-items: center; + padding: 18px; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(6px); +} + +.voice-settings-panel { + position: relative; + width: min(420px, 100%); + display: grid; + gap: 14px; + padding: 24px 20px 20px; + border-radius: 24px; + background: linear-gradient(180deg, #fff8e8, #ffe5ad); + box-shadow: + 0 0 0 4px rgba(255, 255, 255, 0.18), + inset 0 0 0 2px rgba(200, 140, 46, 0.45); +} + +.voice-settings-close { + position: absolute; + top: 10px; + right: 10px; + width: 44px; + height: 44px; + border: 0; + border-radius: 999px; + cursor: pointer; + font: inherit; + font-size: 1.6rem; + color: #b34e3a; + background: linear-gradient(180deg, #ffe5bf, #f0bd7c); + box-shadow: + inset 0 0 0 1px rgba(199, 125, 63, 0.34), + 0 10px 18px rgba(8, 47, 73, 0.16); +} + +.voice-setting-row, +.voice-setting-slider { + display: grid; + gap: 10px; + padding: 14px 16px; + border-radius: 16px; + background: rgba(255, 249, 238, 0.94); + box-shadow: inset 0 0 0 1px rgba(199, 155, 83, 0.12); +} + +.voice-setting-row { + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; +} + +.voice-setting-row input[type='checkbox'] { + width: 22px; + height: 22px; + accent-color: #0f6a5d; +} + +.voice-setting-slider strong { + justify-self: end; + color: #5b2f13; +} + +.voice-setting-slider input[type='range'] { + width: 100%; + accent-color: #0f6a5d; +} + +.team-picker-overlay { + position: fixed; + inset: 0; + z-index: 60; + display: grid; + place-items: center; + padding: 12px; + background: rgba(0, 0, 0, 0.52); + backdrop-filter: blur(8px); +} + +.team-picker-shell { + position: relative; + width: min(980px, 100%); + max-height: calc(100dvh - 24px); +} + +.team-picker-close { + position: absolute; + top: -12px; + right: 8px; + z-index: 2; + width: 68px; + height: 68px; + border: 0; + border-radius: 999px; + cursor: pointer; + font: inherit; + font-size: 2.4rem; + color: #b34e3a; + background: linear-gradient(180deg, #ffe5bf, #f0bd7c); + box-shadow: + inset 0 0 0 1px rgba(199, 125, 63, 0.34), + 0 14px 26px rgba(8, 47, 73, 0.2); + transition: + transform 0.16s ease, + box-shadow 0.16s ease, + filter 0.16s ease; +} + +.team-picker-close:hover { + transform: translateY(-1px) scale(1.01); + box-shadow: + inset 0 0 0 1px rgba(199, 125, 63, 0.42), + 0 18px 30px rgba(8, 47, 73, 0.24); +} + +.team-picker-close:active { + transform: translateY(0); + filter: brightness(0.98); +} + +.team-picker-ribbon { + position: absolute; + top: -24px; + left: 34px; + z-index: 2; + padding: 16px 24px; + border-radius: 24px; + color: #fff; + background: rgba(0, 0, 0, 0.78); +} + +.team-picker-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 260px; + gap: 16px; + padding: 22px 16px 16px; + border-radius: 28px; + background: rgba(20, 10, 6, 0.18); + max-height: calc(100dvh - 52px); +} + +.team-picker-panel { + display: grid; + gap: 12px; + padding: 14px; + border-radius: 22px; + background: linear-gradient(180deg, #fff8e8, #ffe5ad); + box-shadow: + 0 0 0 4px rgba(255, 255, 255, 0.18), + inset 0 0 0 2px rgba(200, 140, 46, 0.45); + min-height: 0; +} + +.team-picker-title { + display: flex; + align-items: center; + gap: 10px; +} + +.team-picker-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 72px; + min-height: 46px; + padding: 0 14px; + border-radius: 16px; + font-size: 1.25rem; + color: #4a2e1d; + background: linear-gradient(180deg, #f5d89f, #ecc170); +} + +.team-picker-title p { + margin-top: 2px; + font-size: 0.84rem; +} + +.team-picker-config-row { + display: flex; + justify-content: flex-start; +} + +.team-picker-config { + display: grid; + gap: 6px; + color: #4a2e1d; +} + +.team-picker-config span { + font-weight: 700; +} + +.team-picker-config-compact { + grid-template-columns: auto auto; + align-items: center; + gap: 8px; +} + +.team-picker-config-compact span { + font-size: 0.92rem; +} + +.team-picker-score-input { + width: 100%; + max-width: 140px; + padding: 12px 14px; + border: 1px solid rgba(124, 98, 61, 0.22); + border-radius: 14px; + background: rgba(255, 255, 255, 0.92); + color: #2e231b; + font: inherit; +} + +.team-picker-score-input-compact { + width: 72px; + max-width: 72px; + padding: 8px 10px; + text-align: center; +} + +.team-picker-list { + display: grid; + gap: 10px; + max-height: min(48dvh, 430px); + overflow: auto; + padding-right: 4px; +} + +.team-picker-option { + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + gap: 10px; + align-items: center; + padding: 12px 12px; + border: 1px solid rgba(124, 98, 61, 0.18); + border-radius: 14px; + cursor: pointer; + text-align: left; + color: #2e231b; + background: rgba(255, 249, 238, 0.92); + transition: + transform 0.16s ease, + box-shadow 0.16s ease, + border-color 0.16s ease, + background 0.16s ease; +} + +.team-picker-option:hover { + transform: translateY(-1px); + border-color: rgba(199, 155, 83, 0.34); + box-shadow: 0 12px 22px rgba(8, 47, 73, 0.08); +} + +.team-picker-option-active { + background: rgba(255, 255, 255, 0.98); + box-shadow: 0 10px 20px rgba(147, 104, 35, 0.12); +} + +.team-picker-checkbox { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: 8px; + border: 1px solid rgba(100, 83, 61, 0.28); + background: rgba(255, 255, 255, 0.88); +} + +.team-picker-option-active .team-picker-checkbox { + color: #fff; + background: linear-gradient(180deg, #ffbf3b, #f0a21a); +} + +.team-picker-option strong, +.preset-team-card strong { + display: block; + font-size: 1.05rem; + line-height: 1.2; +} + +.team-picker-option small, +.preset-team-card small, +.picker-side-hint { + display: block; + margin-top: 4px; + color: #7b6148; + font-size: 0.82rem; +} + +.team-picker-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.team-picker-ghost, +.team-picker-confirm, +.team-picker-clear { + border: 0; + border-radius: 999px; + padding: 12px 14px; + cursor: pointer; + font: inherit; + box-shadow: + inset 0 0 0 1px rgba(199, 155, 83, 0.24), + 0 10px 18px rgba(8, 47, 73, 0.12); + transition: + transform 0.16s ease, + box-shadow 0.16s ease, + filter 0.16s ease; +} + +.team-picker-ghost:hover, +.team-picker-confirm:hover:not(:disabled), +.team-picker-clear:hover { + transform: translateY(-1px); + box-shadow: + inset 0 0 0 1px rgba(199, 155, 83, 0.34), + 0 14px 22px rgba(8, 47, 73, 0.16); +} + +.team-picker-ghost:active, +.team-picker-confirm:active:not(:disabled), +.team-picker-clear:active { + transform: translateY(0); + filter: brightness(0.98); +} + +.team-picker-ghost { + color: #4d3a29; + background: linear-gradient(180deg, #f7f2e8, #e0d6c5); +} + +.team-picker-confirm { + color: #4a2e1d; + background: linear-gradient(180deg, #ebf8a4, #d6e164); +} + +.team-picker-confirm:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.preset-team-block { + display: grid; + gap: 10px; + min-height: 0; +} + +.preset-team-head { + display: grid; + gap: 2px; + color: #4a2e1d; +} + +.preset-team-head small { + color: #7b6148; + font-size: 0.8rem; +} + +.preset-team-list { + display: grid; + gap: 8px; + max-height: min(44dvh, 360px); + overflow: auto; +} + +.preset-team-card { + display: grid; + grid-template-columns: 44px minmax(0, 1fr); + gap: 10px; + align-items: center; + padding: 10px; + border: 1px solid rgba(124, 98, 61, 0.16); + border-radius: 14px; + cursor: pointer; + text-align: left; + background: rgba(255, 249, 238, 0.92); + transition: + transform 0.16s ease, + box-shadow 0.16s ease, + border-color 0.16s ease; +} + +.preset-team-card:hover { + transform: translateY(-1px); + border-color: rgba(199, 155, 83, 0.34); + box-shadow: 0 12px 22px rgba(8, 47, 73, 0.08); +} + +.preset-team-card-active { + background: rgba(255, 255, 255, 0.98); + box-shadow: 0 10px 20px rgba(147, 104, 35, 0.12); +} + +.preset-team-index { + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border-radius: 999px; + font-size: 1.1rem; + color: #5b2f13; + background: linear-gradient(180deg, #ffc84d, #f2a316); +} + +.team-picker-clear { + color: #fff; + background: linear-gradient(180deg, #f7a17e, #ed774d); +} + +.finish-dialog-overlay { + position: fixed; + inset: 0; + z-index: 70; + display: grid; + place-items: center; + padding: 18px; + background: rgba(0, 0, 0, 0.56); + backdrop-filter: blur(6px); +} + +.finish-dialog { + position: relative; + width: min(460px, 100%); + display: grid; + gap: 16px; + padding: 24px 20px 20px; + border-radius: 24px; + background: linear-gradient(180deg, #fff8e8, #ffe5ad); + box-shadow: + 0 0 0 4px rgba(255, 255, 255, 0.18), + inset 0 0 0 2px rgba(200, 140, 46, 0.45); +} + +.finish-dialog-close { + position: absolute; + top: 10px; + right: 10px; + width: 48px; + height: 48px; + border: 0; + border-radius: 999px; + cursor: pointer; + font: inherit; + font-size: 1.8rem; + color: #b34e3a; + background: linear-gradient(180deg, #ffe5bf, #f0bd7c); + box-shadow: + inset 0 0 0 1px rgba(199, 125, 63, 0.34), + 0 10px 18px rgba(8, 47, 73, 0.16); + transition: + transform 0.16s ease, + box-shadow 0.16s ease, + filter 0.16s ease; +} + +.finish-dialog-close:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: + inset 0 0 0 1px rgba(199, 125, 63, 0.42), + 0 14px 22px rgba(8, 47, 73, 0.2); +} + +.finish-dialog-close:active:not(:disabled) { + transform: translateY(0); + filter: brightness(0.98); +} + +.finish-score { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + gap: 10px; + align-items: center; + padding: 16px; + border-radius: 18px; + background: rgba(255, 249, 238, 0.94); +} + +.finish-score div { + display: grid; + gap: 6px; + justify-items: center; + text-align: center; +} + +.finish-score strong { + font-family: var(--mono); + font-size: 2.6rem; + line-height: 1; + color: #16342f; +} + +.finish-score span { + color: #5f4a35; +} + +.finish-score-divider { + font-family: var(--mono); + font-size: 2rem; + color: #70543c; +} + +.finish-dialog-copy { + color: #5f4a35; + text-align: center; +} + +.finish-dialog-error { + padding: 12px 14px; + border-radius: 14px; + color: #7a1d2a; + background: rgba(255, 224, 230, 0.95); +} + +.finish-dialog-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.history-modal-overlay { + position: fixed; + inset: 0; + z-index: 70; + display: grid; + place-items: center; + padding: 18px; + background: rgba(0, 0, 0, 0.56); + backdrop-filter: blur(6px); +} + +.history-modal { + position: relative; + width: min(680px, 100%); + display: grid; + gap: 10px; + padding: 18px 18px 16px; + border-radius: 24px; + background: linear-gradient(180deg, #fff8e8, #ffe5ad); + box-shadow: + 0 0 0 4px rgba(255, 255, 255, 0.18), + inset 0 0 0 2px rgba(200, 140, 46, 0.45); +} + +.history-modal-close { + position: absolute; + top: 10px; + right: 10px; + width: 44px; + height: 44px; + border: 0; + border-radius: 999px; + cursor: pointer; + font: inherit; + font-size: 1.6rem; + line-height: 1; + color: #b34e3a; + background: linear-gradient(180deg, #ffe5bf, #f0bd7c); + box-shadow: + inset 0 0 0 1px rgba(199, 125, 63, 0.34), + 0 10px 18px rgba(8, 47, 73, 0.16); + transition: + transform 0.16s ease, + box-shadow 0.16s ease, + filter 0.16s ease; +} + +.history-modal-close:hover { + transform: translateY(-1px); + box-shadow: + inset 0 0 0 1px rgba(199, 125, 63, 0.42), + 0 14px 22px rgba(8, 47, 73, 0.2); +} + +.history-modal-close:active { + transform: translateY(0); + filter: brightness(0.98); +} + +.history-modal-score { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + gap: 8px; + align-items: center; + padding: 10px 12px; + border-radius: 14px; + background: rgba(255, 249, 238, 0.94); +} + +.history-modal-score div { + display: grid; + gap: 2px; + justify-items: center; + text-align: center; +} + +.history-modal-score strong { + font-family: var(--mono); + font-size: 1.9rem; + line-height: 1; + color: #16342f; +} + +.history-modal-score span { + font-size: 0.88rem; + color: #5f4a35; +} + +.history-modal-score-divider { + font-family: var(--mono); + font-size: 1.3rem; + color: #70543c; +} + +.history-modal-summary { + display: flex; + flex-wrap: wrap; + gap: 8px; + font-size: 0.86rem; + color: #5f4a35; +} + +.history-replay-list { + display: grid; + gap: 10px; + max-height: min(50vh, 480px); + overflow: auto; +} + +.history-replay-row { + display: grid; + grid-template-columns: 108px 92px 92px minmax(0, 1fr); + gap: 10px; + align-items: center; + padding: 12px 14px; + border-radius: 14px; + background: rgba(255, 249, 238, 0.92); +} + +.history-replay-empty { + padding: 12px 14px; + border-radius: 14px; + color: #5f4a35; + background: rgba(255, 249, 238, 0.92); +} + +.inline-link { + display: inline-flex; + width: fit-content; + margin-top: 20px; +} + +@media (max-width: 980px) { + .topbar, + .page-grid, + .summary-grid, + .double-grid, + .history-meta, + .selection-toolbar { + grid-template-columns: 1fr; + } + + .topbar { + display: grid; + align-items: start; + } + + .panel-heading, + .group-head, + .history-head { + flex-direction: column; + } + + .group-head-compact { + flex-direction: row; + align-items: center; + } + + .group-head-compact .group-actions { + flex-shrink: 0; + } + + .scoreboard-team-head { + grid-template-columns: minmax(0, 1fr) 62px; + } + + .team-head-main { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .team-picker-layout { + grid-template-columns: minmax(0, 1fr) 220px; + gap: 12px; + } +} + +@media (max-width: 720px) { + .pwa-update-toast { + gap: 10px; + bottom: 12px; + padding: 12px 12px 12px 14px; + border-radius: 16px; + } + + .pwa-update-copy strong { + font-size: 0.92rem; + } + + .pwa-update-copy span { + font-size: 0.78rem; + } + + .pwa-update-button { + min-width: 84px; + padding: 9px 12px; + font-size: 0.84rem; + } + + .app-shell { + width: min(100% - 14px, 1240px); + padding: 14px 0 24px; + } + + .app-shell-scoreboard { + width: min(100% - 10px, 1240px); + padding-top: 10px; + } + + .app-shell-scoreboard-fit { + min-height: 100dvh; + padding-top: max(8px, env(safe-area-inset-top)); + padding-bottom: max(8px, env(safe-area-inset-bottom)); + } + + .panel { + padding: 16px; + border-radius: 18px; + } + + .topbar { + gap: 10px; + margin-bottom: 10px; + } + + .branding h1 { + font-size: 1.35rem; + } + + .intro-copy { + display: none; + } + + .nav-pill, + .inline-link { + padding: 9px 12px; + } + + .selection-toolbar { + gap: 12px; + } + + .selection-toolbar > .field { + width: 100%; + } + + .button-stack { + grid-template-columns: 1fr; + } + + .field input[type='date'] { + max-width: 100%; + font-size: 16px; + overflow: hidden; + } + + .floating-status-bubble { + bottom: 12px; + min-width: min(88vw, 320px); + max-width: 92vw; + padding: 12px 16px; + font-size: 0.92rem; + } + + .group-head-compact { + gap: 10px; + } + + .group-head-compact h3 { + font-size: 1rem; + } + + .group-head-compact .inline-link { + padding: 8px 12px; + white-space: nowrap; + } + + .scoreboard-court { + gap: 10px; + padding: 8px; + border-radius: 16px; + } + + .app-shell-scoreboard-fit .topbar-compact { + gap: 6px; + margin-bottom: 6px; + } + + .app-shell-scoreboard-fit .eyebrow { + margin-bottom: 6px; + padding: 6px 10px; + font-size: 0.66rem; + } + + .app-shell-scoreboard-fit .branding h1 { + margin: 8px 0 6px; + font-size: 1.12rem; + } + + .app-shell-scoreboard-fit .topnav { + gap: 6px; + } + + .app-shell-scoreboard-fit .nav-pill { + padding: 7px 10px; + font-size: 0.82rem; + } + + .streak-banner { + top: 12%; + min-width: min(92vw, 360px); + padding: 14px 18px; + border-radius: 22px; + } + + .streak-banner strong { + font-size: clamp(1.6rem, 8vw, 2.4rem); + } + + .streak-banner small { + font-size: 0.88rem; + } + + .victory-banner { + top: 14%; + min-width: min(92vw, 360px); + padding: 16px 18px; + border-radius: 22px; + } + + .victory-banner strong { + font-size: clamp(1.8rem, 9vw, 2.8rem); + } + + .victory-banner small, + .victory-banner em { + font-size: 0.88rem; + } + + .voice-settings-panel { + padding: 20px 14px 14px; + border-radius: 18px; + } + + .scoreboard-team-head { + grid-template-columns: minmax(0, 1fr) 54px; + gap: 5px; + } + + .team-head-main { + gap: 5px; + min-height: 46px; + padding: 5px; + } + + .scoreboard-name-chip { + gap: 5px; + min-height: 34px; + padding: 3px 5px; + } + + .serve-lane-arrow { + left: 52px; + } + + .team-number { + width: 24px; + height: 24px; + flex-basis: 24px; + font-size: 0.82rem; + } + + .scoreboard-name-chip strong { + font-size: clamp(0.76rem, 3.6vw, 1rem); + } + + .team-icon-button { + min-height: 30px; + font-size: 0.94rem; + } + + .serve-lane { + grid-template-columns: 28px auto; + min-height: 34px; + padding: 4px 6px; + font-size: 0.86rem; + } + + .serve-lane small { + grid-column: 1 / -1; + justify-self: start; + font-size: 0.72rem; + } + + .score-panel-surface { + min-height: clamp(90px, 15.5dvh, 104px); + } + + .score-panel-value { + font-size: clamp(3rem, 15vw, 4.6rem); + } + + .scoreboard-center-banner { + gap: 2px; + } + + .scoreboard-center-banner p { + font-size: 1.02rem; + } + + .scoreboard-center-banner small { + font-size: 0.7rem; + text-align: center; + } + + .scoreboard-screen { + grid-template-columns: 1fr; + } + + .scoreboard-rail { + grid-template-columns: minmax(0, 1fr) 94px 106px; + gap: 6px; + align-items: stretch; + } + + .rail-pill-hold-wrap { + grid-column: 1 / -1; + } + + .rail-square-button { + min-height: 38px; + font-size: 0.84rem; + } + + .rail-clock { + min-height: 38px; + border-radius: 10px; + font-size: 0.96rem; + } + + .rail-room-id { + padding: 7px 8px; + font-size: 0.76rem; + } + + .rail-pill { + padding: 8px 7px; + font-size: 0.82rem; + } + + .rail-pill-hold-wrap { + gap: 4px; + } + + .rail-hold-progress { + height: 5px; + } + + .finish-dialog { + padding: 20px 14px 14px; + border-radius: 18px; + } + + .history-modal { + gap: 8px; + padding: 14px 12px; + border-radius: 18px; + } + + .finish-dialog-close { + width: 40px; + height: 40px; + font-size: 1.4rem; + } + + .finish-score { + padding: 12px 10px; + } + + .finish-score strong { + font-size: 2rem; + } + + .finish-dialog-actions { + grid-template-columns: 1fr; + } + + .history-modal-score { + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + gap: 6px; + padding: 8px 10px; + } + + .history-modal-score-divider { + display: block; + } + + .history-replay-row { + grid-template-columns: 1fr; + gap: 4px; + } + + .history-card-shell { + grid-template-columns: 1fr; + gap: 10px; + } + + .history-delete-button { + width: 100%; + } + + .room-card { + padding: 14px; + border-radius: 16px; + } + + .room-card-score strong, + .room-watch-team strong { + font-size: clamp(1.7rem, 12vw, 2.6rem); + } + + .team-picker-ribbon { + left: 18px; + right: 90px; + top: -18px; + padding: 10px 16px; + border-radius: 18px; + font-size: 0.88rem; + } + + .team-picker-shell { + max-height: calc(100dvh - 12px); + } + + .team-picker-layout { + grid-template-columns: minmax(0, 1fr) 156px; + gap: 8px; + padding: 18px 10px 10px; + border-radius: 20px; + max-height: calc(100dvh - 24px); + } + + .team-picker-panel { + gap: 8px; + padding: 10px; + border-radius: 16px; + } + + .team-picker-title { + gap: 8px; + } + + .team-picker-count { + min-width: 56px; + min-height: 38px; + padding: 0 10px; + font-size: 1rem; + } + + .team-picker-title strong { + font-size: 0.96rem; + } + + .team-picker-title p { + font-size: 0.72rem; + } + + .team-picker-config-compact span { + font-size: 0.8rem; + } + + .team-picker-score-input-compact { + width: 58px; + max-width: 58px; + padding: 6px 8px; + font-size: 0.88rem; + } + + .team-picker-list { + gap: 6px; + max-height: min(46dvh, 330px); + } + + .team-picker-option { + grid-template-columns: 24px minmax(0, 1fr); + gap: 8px; + padding: 9px 8px; + border-radius: 12px; + } + + .team-picker-checkbox { + width: 24px; + height: 24px; + font-size: 0.8rem; + } + + .team-picker-option strong, + .preset-team-card strong { + font-size: 0.9rem; + } + + .team-picker-option small, + .preset-team-card small, + .picker-side-hint, + .preset-team-head small { + font-size: 0.72rem; + } + + .team-picker-actions { + gap: 8px; + } + + .team-picker-ghost, + .team-picker-confirm, + .team-picker-clear { + padding: 10px 10px; + font-size: 0.86rem; + } + + .preset-team-list { + gap: 6px; + max-height: min(42dvh, 300px); + } + + .preset-team-card { + grid-template-columns: 34px minmax(0, 1fr); + gap: 8px; + padding: 8px; + border-radius: 12px; + } + + .preset-team-index { + width: 34px; + height: 34px; + font-size: 0.92rem; + } +} + +@media (max-width: 720px) and (max-height: 840px) { + .app-shell-scoreboard-fit .scoreboard-screen { + gap: 5px; + } + + .app-shell-scoreboard-fit .scoreboard-court { + gap: 8px; + padding: 6px; + } + + .app-shell-scoreboard-fit .scoreboard-team-section { + gap: 5px; + } + + .app-shell-scoreboard-fit .score-panel-surface { + min-height: clamp(82px, 14.2dvh, 96px); + } + + .app-shell-scoreboard-fit .score-panel-value { + font-size: clamp(2.7rem, 13.5vw, 4.1rem); + } + + .app-shell-scoreboard-fit .scoreboard-center-banner p { + font-size: 0.96rem; + } +} + +.room-list-grid { + display: grid; + gap: 16px; +} + +.room-list-toolbar { + display: flex; + justify-content: flex-end; + margin-bottom: 16px; +} + +.room-refresh-button:disabled { + cursor: default; + opacity: 0.72; +} + +.room-card { + display: grid; + gap: 14px; + padding: 18px; + border-radius: 20px; + color: inherit; + text-decoration: none; + background: rgba(255, 255, 255, 0.84); + box-shadow: 0 18px 28px rgba(8, 47, 73, 0.08); + transition: transform 0.16s ease, box-shadow 0.16s ease; +} + +.room-card:hover { + transform: translateY(-2px); + box-shadow: 0 22px 34px rgba(8, 47, 73, 0.12); +} + +.room-card-head, +.room-watch-meta { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 10px; +} + +.room-card-head span, +.room-watch-meta span, +.room-card-updated { + color: var(--panel-soft); + font-size: 0.9rem; +} + +.room-card-score, +.room-watch-scoreboard { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: center; + gap: 12px; +} + +.room-card-matchup { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: center; + gap: 12px; +} + +.room-card-score div, +.room-watch-team { + display: grid; + gap: 8px; + justify-items: center; + text-align: center; + padding: 16px 12px; + border-radius: 18px; + background: rgba(244, 236, 216, 0.8); +} + +.room-card-matchup strong { + display: block; + min-width: 0; + padding: 16px 12px; + border-radius: 18px; + text-align: center; + background: rgba(244, 236, 216, 0.8); + color: var(--panel-strong); +} + +.room-card-matchup span { + font-size: 1rem; + font-weight: 700; + color: var(--panel-soft); +} + +.room-card-score small, +.room-watch-team small { + color: var(--panel-soft); +} + +.room-card-score strong, +.room-watch-team strong { + font-size: clamp(2rem, 8vw, 3.4rem); + line-height: 1; +} + +.room-card-score span, +.room-watch-divider { + font-size: 2rem; + font-weight: 700; + color: var(--panel-strong); +} + +.room-watch-panel { + display: grid; + gap: 18px; +} diff --git a/src/App.tsx b/src/App.tsx index 2e8c04c..5f4adbb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ -import { useEffect, useMemo, useRef, useState } from 'react' -import { NavLink, Route, Routes, useLocation, useNavigate } from 'react-router-dom' -import './App.css' +import { useEffect, useMemo, useRef, useState } from 'react' +import { NavLink, Route, Routes, useLocation, useNavigate } from 'react-router-dom' +import './App.css' import { createLiveRoom, loadMatchResults, @@ -21,36 +21,36 @@ import { getWinnerName, parseRoster, swapCourtPositions, -} from './lib/match' -import { HistoryPage } from './pages/HistoryPage' -import { RoomListPage } from './pages/RoomListPage' -import { RoomSpectatorPage } from './pages/RoomSpectatorPage' -import { ScoreboardPage } from './pages/ScoreboardPage' -import { TeamSelectionPage } from './pages/TeamSelectionPage' -import type { - ActiveMatchup, - GroupTeam, - HistoryUploadPayload, - LiveRoomSession, - LoadStatus, - MatchHistoryItem, - PointHistoryEntry, - RoundGroup, - ScoreSide, - ScoreSnapshot, - ScoreState, -} from './types' - -const STORAGE_KEYS = { - areaA: 'badminton-scoreboard::area-a', - areaB: 'badminton-scoreboard::area-b', - history: 'badminton-scoreboard::history', - targetDate: 'badminton-scoreboard::target-date', -} as const - -const defaultAreaA = ['柏威', '建喵', 'Yuki', '阿釧'] -const defaultAreaB = ['RURU', '玟瑄', '培根', 'Tim'] - +} from './lib/match' +import { HistoryPage } from './pages/HistoryPage' +import { RoomListPage } from './pages/RoomListPage' +import { RoomSpectatorPage } from './pages/RoomSpectatorPage' +import { ScoreboardPage } from './pages/ScoreboardPage' +import { TeamSelectionPage } from './pages/TeamSelectionPage' +import type { + ActiveMatchup, + GroupTeam, + HistoryUploadPayload, + LiveRoomSession, + LoadStatus, + MatchHistoryItem, + PointHistoryEntry, + RoundGroup, + ScoreSide, + ScoreSnapshot, + ScoreState, +} from './types' + +const STORAGE_KEYS = { + areaA: 'badminton-scoreboard::area-a', + areaB: 'badminton-scoreboard::area-b', + history: 'badminton-scoreboard::history', + targetDate: 'badminton-scoreboard::target-date', +} as const + +const defaultAreaA = ['柏威', '建喵', 'Yuki', '阿釧'] +const defaultAreaB = ['RURU', '玟瑄', '培根', 'Tim'] + const initialScoreState: ScoreState = { scoreLeft: 0, scoreRight: 0, @@ -63,20 +63,20 @@ const initialScoreState: ScoreState = { leftRightCourtPlayer: 'playerA', rightRightCourtPlayer: 'playerA', } - -type SettlementState = { - error: string - open: boolean - uploading: boolean -} - -type StreakAnnouncement = { - count: number - key: number - teamName: string - title: string -} - + +type SettlementState = { + error: string + open: boolean + uploading: boolean +} + +type StreakAnnouncement = { + count: number + key: number + teamName: string + title: string +} + type VictoryAnnouncement = { key: number scoreLabel: string @@ -90,53 +90,53 @@ type VoiceAnnouncement = { serverChanged: boolean serverName: string } - + const STREAK_TITLES: Record = { - 3: '大殺特殺', - 4: '暴走', - 5: '無人能擋', - 6: '主宰比賽', - 7: '像神一般的', - 8: '成為傳說', + 3: '大殺特殺', + 4: '暴走', + 5: '無人能擋', + 6: '主宰比賽', + 7: '像神一般的', + 8: '成為傳說', } const PWA_UPDATE_EVENT = 'badminton-scoreboard:pwa-update-ready' const APP_VERSION_POLL_MS = 30000 const LIVE_ROOM_HEARTBEAT_MS = 10_000 - -function App() { - const location = useLocation() - const navigate = useNavigate() - const isScoreboardRoute = location.pathname === '/scoreboard' - - const [targetDate, setTargetDate] = useState(() => - loadStoredText(STORAGE_KEYS.targetDate, formatDateInputValue()), - ) - const [areaAInput, setAreaAInput] = useState(() => - loadStoredText(STORAGE_KEYS.areaA, defaultAreaA.join('\n')), - ) - const [areaBInput, setAreaBInput] = useState(() => - loadStoredText(STORAGE_KEYS.areaB, defaultAreaB.join('\n')), - ) - const [groups, setGroups] = useState([]) - const [groupSource, setGroupSource] = useState<'idle' | 'db' | 'manual'>('idle') - const [loadStatus, setLoadStatus] = useState('idle') - const [loadMessage, setLoadMessage] = useState('') - const [selectedGroupId, setSelectedGroupId] = useState(null) - const [activeMatchup, setActiveMatchup] = useState({ - leftTeam: null, - rightTeam: null, - }) - const [scoreState, setScoreState] = useState(initialScoreState) - const [scoreHistory, setScoreHistory] = useState([]) - const [pointLog, setPointLog] = useState([]) - const [history, setHistory] = useState(() => - loadStoredHistory(STORAGE_KEYS.history), - ) - const [settlement, setSettlement] = useState({ - error: '', - open: false, - uploading: false, - }) + +function App() { + const location = useLocation() + const navigate = useNavigate() + const isScoreboardRoute = location.pathname === '/scoreboard' + + const [targetDate, setTargetDate] = useState(() => + loadStoredText(STORAGE_KEYS.targetDate, formatDateInputValue()), + ) + const [areaAInput, setAreaAInput] = useState(() => + loadStoredText(STORAGE_KEYS.areaA, defaultAreaA.join('\n')), + ) + const [areaBInput, setAreaBInput] = useState(() => + loadStoredText(STORAGE_KEYS.areaB, defaultAreaB.join('\n')), + ) + const [groups, setGroups] = useState([]) + const [groupSource, setGroupSource] = useState<'idle' | 'db' | 'manual'>('idle') + const [loadStatus, setLoadStatus] = useState('idle') + const [loadMessage, setLoadMessage] = useState('') + const [selectedGroupId, setSelectedGroupId] = useState(null) + const [activeMatchup, setActiveMatchup] = useState({ + leftTeam: null, + rightTeam: null, + }) + const [scoreState, setScoreState] = useState(initialScoreState) + const [scoreHistory, setScoreHistory] = useState([]) + const [pointLog, setPointLog] = useState([]) + const [history, setHistory] = useState(() => + loadStoredHistory(STORAGE_KEYS.history), + ) + const [settlement, setSettlement] = useState({ + error: '', + open: false, + uploading: false, + }) const [streakAnnouncement, setStreakAnnouncement] = useState(null) const [victoryAnnouncement, setVictoryAnnouncement] = useState(null) const [voiceAnnouncement, setVoiceAnnouncement] = useState(null) @@ -144,67 +144,67 @@ function App() { const [liveRoomSession, setLiveRoomSession] = useState(null) const [navigationLockMessage, setNavigationLockMessage] = useState('') const currentAppVersionRef = useRef(null) - const creatingRoomRef = useRef(false) - const lastSyncedRoomSignatureRef = useRef('') - - const parsedAreaA = useMemo(() => parseRoster(areaAInput), [areaAInput]) - const parsedAreaB = useMemo(() => parseRoster(areaBInput), [areaBInput]) + const creatingRoomRef = useRef(false) + const lastSyncedRoomSignatureRef = useRef('') + + const parsedAreaA = useMemo(() => parseRoster(areaAInput), [areaAInput]) + const parsedAreaB = useMemo(() => parseRoster(areaBInput), [areaBInput]) const selectedGroup = groups.find((group) => group.id === selectedGroupId) ?? null const leftTeam = activeMatchup.leftTeam const rightTeam = activeMatchup.rightTeam const liveRoomId = liveRoomSession?.roomId ?? null const isNavigationLocked = Boolean(leftTeam && rightTeam && scoreState.serving !== null) - - useEffect(() => { - window.localStorage.setItem(STORAGE_KEYS.targetDate, targetDate) - }, [targetDate]) - - useEffect(() => { - window.localStorage.setItem(STORAGE_KEYS.areaA, areaAInput) - }, [areaAInput]) - - useEffect(() => { - window.localStorage.setItem(STORAGE_KEYS.areaB, areaBInput) - }, [areaBInput]) - - useEffect(() => { - window.localStorage.setItem(STORAGE_KEYS.history, JSON.stringify(history)) - }, [history]) - - useEffect(() => { - if (loadStatus !== 'loaded' || !loadMessage) { - return - } - - const timer = window.setTimeout(() => { - setLoadMessage('') - }, 1000) - - return () => window.clearTimeout(timer) - }, [loadMessage, loadStatus]) - - useEffect(() => { - if (!streakAnnouncement) { - return - } - - const timer = window.setTimeout(() => { - setStreakAnnouncement(null) - }, 1800) - - return () => window.clearTimeout(timer) - }, [streakAnnouncement]) - + + useEffect(() => { + window.localStorage.setItem(STORAGE_KEYS.targetDate, targetDate) + }, [targetDate]) + + useEffect(() => { + window.localStorage.setItem(STORAGE_KEYS.areaA, areaAInput) + }, [areaAInput]) + + useEffect(() => { + window.localStorage.setItem(STORAGE_KEYS.areaB, areaBInput) + }, [areaBInput]) + + useEffect(() => { + window.localStorage.setItem(STORAGE_KEYS.history, JSON.stringify(history)) + }, [history]) + + useEffect(() => { + if (loadStatus !== 'loaded' || !loadMessage) { + return + } + + const timer = window.setTimeout(() => { + setLoadMessage('') + }, 1000) + + return () => window.clearTimeout(timer) + }, [loadMessage, loadStatus]) + + useEffect(() => { + if (!streakAnnouncement) { + return + } + + const timer = window.setTimeout(() => { + setStreakAnnouncement(null) + }, 1800) + + return () => window.clearTimeout(timer) + }, [streakAnnouncement]) + useEffect(() => { if (!victoryAnnouncement) { return - } - - const timer = window.setTimeout(() => { - setVictoryAnnouncement(null) - }, 2200) - - return () => window.clearTimeout(timer) + } + + const timer = window.setTimeout(() => { + setVictoryAnnouncement(null) + }, 2200) + + return () => window.clearTimeout(timer) }, [victoryAnnouncement]) useEffect(() => { @@ -226,294 +226,294 @@ function App() { document.body.classList.remove('body-scoreboard') } }, [isScoreboardRoute]) - - useEffect(() => { - const handlePwaUpdateReady = () => { - setPwaUpdateReady(true) - } - - window.addEventListener(PWA_UPDATE_EVENT, handlePwaUpdateReady) - - return () => { - window.removeEventListener(PWA_UPDATE_EVENT, handlePwaUpdateReady) - } - }, []) - - useEffect(() => { - let active = true - - const checkAppVersion = async () => { - try { - const response = await fetch('/api/version', { - cache: 'no-store', - headers: { - 'cache-control': 'no-cache', - }, - }) - - if (!response.ok) { - return - } - - const payload = (await response.json()) as { - ok?: boolean - version?: string - } - const nextVersion = payload.version?.trim() - - if (!active || !nextVersion) { - return - } - - if (!currentAppVersionRef.current) { - currentAppVersionRef.current = nextVersion - return - } - - if (currentAppVersionRef.current !== nextVersion) { - currentAppVersionRef.current = nextVersion - setPwaUpdateReady(true) - } - } catch { - // Ignore transient version-check failures and retry on next poll. - } - } - - void checkAppVersion() - const timer = window.setInterval(() => { - void checkAppVersion() - }, APP_VERSION_POLL_MS) - - return () => { - active = false - window.clearInterval(timer) - } - }, []) - - const resetScoring = ( - nextState: ScoreState = initialScoreState, - options?: { - releaseLiveRoom?: boolean - }, - ) => { - const shouldReleaseLiveRoom = options?.releaseLiveRoom ?? true - - if (shouldReleaseLiveRoom && liveRoomSession?.status === 'live') { - void releaseLiveRoom(liveRoomSession.roomId, liveRoomSession.hostToken).catch(() => {}) - } - - setScoreState(nextState) - setScoreHistory([]) + + useEffect(() => { + const handlePwaUpdateReady = () => { + setPwaUpdateReady(true) + } + + window.addEventListener(PWA_UPDATE_EVENT, handlePwaUpdateReady) + + return () => { + window.removeEventListener(PWA_UPDATE_EVENT, handlePwaUpdateReady) + } + }, []) + + useEffect(() => { + let active = true + + const checkAppVersion = async () => { + try { + const response = await fetch('/api/version', { + cache: 'no-store', + headers: { + 'cache-control': 'no-cache', + }, + }) + + if (!response.ok) { + return + } + + const payload = (await response.json()) as { + ok?: boolean + version?: string + } + const nextVersion = payload.version?.trim() + + if (!active || !nextVersion) { + return + } + + if (!currentAppVersionRef.current) { + currentAppVersionRef.current = nextVersion + return + } + + if (currentAppVersionRef.current !== nextVersion) { + currentAppVersionRef.current = nextVersion + setPwaUpdateReady(true) + } + } catch { + // Ignore transient version-check failures and retry on next poll. + } + } + + void checkAppVersion() + const timer = window.setInterval(() => { + void checkAppVersion() + }, APP_VERSION_POLL_MS) + + return () => { + active = false + window.clearInterval(timer) + } + }, []) + + const resetScoring = ( + nextState: ScoreState = initialScoreState, + options?: { + releaseLiveRoom?: boolean + }, + ) => { + const shouldReleaseLiveRoom = options?.releaseLiveRoom ?? true + + if (shouldReleaseLiveRoom && liveRoomSession?.status === 'live') { + void releaseLiveRoom(liveRoomSession.roomId, liveRoomSession.hostToken).catch(() => {}) + } + + setScoreState(nextState) + setScoreHistory([]) setPointLog([]) setStreakAnnouncement(null) setVictoryAnnouncement(null) setVoiceAnnouncement(null) - setSettlement({ - error: '', - open: false, - uploading: false, - }) - creatingRoomRef.current = false - setLiveRoomSession(null) - lastSyncedRoomSignatureRef.current = '' - } - - const finalizeLiveRoom = async () => { - if (!liveRoomSession || !leftTeam || !rightTeam) { - return - } - - const winnerTeamName = getWinnerName( - getTeamDisplayName(leftTeam), - getTeamDisplayName(rightTeam), - scoreState, - ) - - const payload = buildLiveRoomPayload({ - groupId: selectedGroup?.id ?? null, - leftTeam, - pointLog, - rightTeam, - scoreState, - targetDate, - }) - - try { - await updateLiveRoom(liveRoomSession.roomId, { - ...payload, - hostToken: liveRoomSession.hostToken, - status: 'finished', - winnerTeamName, - }) - - setLiveRoomSession((current) => - current - ? { - ...current, - status: 'finished', - } - : current, - ) - } catch (error) { - console.error('finalize live room error:', error) - } - } - - const selectGroup = (groupId: number, nextGroups = groups) => { - const nextGroup = nextGroups.find((group) => group.id === groupId) - const firstTeam = nextGroup?.teams[0] ?? null - const secondTeam = nextGroup?.teams[1] ?? null - - setSelectedGroupId(nextGroup?.id ?? null) - setActiveMatchup({ - leftTeam: firstTeam, - rightTeam: secondTeam, - }) - resetScoring() - } - - const applyMatchup = ( - leftTeam: GroupTeam, - rightTeam: GroupTeam, - targetScore: number, - ) => { - setActiveMatchup({ - leftTeam, - rightTeam, - }) - resetScoring({ - ...initialScoreState, - targetScore, - }) - } - - const refreshForPwaUpdate = () => { - const registrationPromise = navigator.serviceWorker?.getRegistration - ? navigator.serviceWorker.getRegistration() - : Promise.resolve(undefined) - - void registrationPromise.then((registration) => { - if (registration?.waiting) { - registration.waiting.postMessage({ type: 'SKIP_WAITING' }) - return - } - - window.location.reload() - }) - } - - useEffect(() => { - if ( - !isScoreboardRoute || - !leftTeam || - !rightTeam || - liveRoomSession || - creatingRoomRef.current - ) { - return - } - - let cancelled = false - - const createRoom = async () => { - try { - creatingRoomRef.current = true - const session = await createLiveRoom( - buildLiveRoomPayload({ - groupId: selectedGroup?.id ?? null, - leftTeam, - pointLog, - rightTeam, - scoreState, - targetDate, - }), - ) - - if (!cancelled) { - setLiveRoomSession(session) - } - } catch (error) { - console.error('create live room error:', error) - } finally { - creatingRoomRef.current = false - } - } - - void createRoom() - - return () => { - cancelled = true - } - }, [ - leftTeam, - liveRoomSession, - pointLog, - rightTeam, - scoreState, - selectedGroup?.id, - targetDate, - isScoreboardRoute, - ]) - + setSettlement({ + error: '', + open: false, + uploading: false, + }) + creatingRoomRef.current = false + setLiveRoomSession(null) + lastSyncedRoomSignatureRef.current = '' + } + + const finalizeLiveRoom = async () => { + if (!liveRoomSession || !leftTeam || !rightTeam) { + return + } + + const winnerTeamName = getWinnerName( + getTeamDisplayName(leftTeam), + getTeamDisplayName(rightTeam), + scoreState, + ) + + const payload = buildLiveRoomPayload({ + groupId: selectedGroup?.id ?? null, + leftTeam, + pointLog, + rightTeam, + scoreState, + targetDate, + }) + + try { + await updateLiveRoom(liveRoomSession.roomId, { + ...payload, + hostToken: liveRoomSession.hostToken, + status: 'finished', + winnerTeamName, + }) + + setLiveRoomSession((current) => + current + ? { + ...current, + status: 'finished', + } + : current, + ) + } catch (error) { + console.error('finalize live room error:', error) + } + } + + const selectGroup = (groupId: number, nextGroups = groups) => { + const nextGroup = nextGroups.find((group) => group.id === groupId) + const firstTeam = nextGroup?.teams[0] ?? null + const secondTeam = nextGroup?.teams[1] ?? null + + setSelectedGroupId(nextGroup?.id ?? null) + setActiveMatchup({ + leftTeam: firstTeam, + rightTeam: secondTeam, + }) + resetScoring() + } + + const applyMatchup = ( + leftTeam: GroupTeam, + rightTeam: GroupTeam, + targetScore: number, + ) => { + setActiveMatchup({ + leftTeam, + rightTeam, + }) + resetScoring({ + ...initialScoreState, + targetScore, + }) + } + + const refreshForPwaUpdate = () => { + const registrationPromise = navigator.serviceWorker?.getRegistration + ? navigator.serviceWorker.getRegistration() + : Promise.resolve(undefined) + + void registrationPromise.then((registration) => { + if (registration?.waiting) { + registration.waiting.postMessage({ type: 'SKIP_WAITING' }) + return + } + + window.location.reload() + }) + } + + useEffect(() => { + if ( + !isScoreboardRoute || + !leftTeam || + !rightTeam || + liveRoomSession || + creatingRoomRef.current + ) { + return + } + + let cancelled = false + + const createRoom = async () => { + try { + creatingRoomRef.current = true + const session = await createLiveRoom( + buildLiveRoomPayload({ + groupId: selectedGroup?.id ?? null, + leftTeam, + pointLog, + rightTeam, + scoreState, + targetDate, + }), + ) + + if (!cancelled) { + setLiveRoomSession(session) + } + } catch (error) { + console.error('create live room error:', error) + } finally { + creatingRoomRef.current = false + } + } + + void createRoom() + + return () => { + cancelled = true + } + }, [ + leftTeam, + liveRoomSession, + pointLog, + rightTeam, + scoreState, + selectedGroup?.id, + targetDate, + isScoreboardRoute, + ]) + useEffect(() => { if (!isScoreboardRoute || !liveRoomSession || !leftTeam || !rightTeam) { return - } - + } + const winnerTeamName = hasWonGame(scoreState) && scoreState.scoreLeft > scoreState.scoreRight ? getTeamDisplayName(leftTeam) : hasWonGame(scoreState) && scoreState.scoreRight > scoreState.scoreLeft ? getTeamDisplayName(rightTeam) : null - const nextStatus = winnerTeamName ? 'finished' : 'live' - const payload = buildLiveRoomPayload({ - groupId: selectedGroup?.id ?? null, - leftTeam, - pointLog, - rightTeam, - scoreState, - targetDate, - }) - const signature = JSON.stringify({ - payload, - roomId: liveRoomSession.roomId, - status: nextStatus, - winnerTeamName, - }) - - if (signature === lastSyncedRoomSignatureRef.current) { - return - } - - lastSyncedRoomSignatureRef.current = signature - - void updateLiveRoom(liveRoomSession.roomId, { - ...payload, - hostToken: liveRoomSession.hostToken, - status: nextStatus, - winnerTeamName, - }) - .then((room) => { - setLiveRoomSession((current) => - current - ? { - ...current, - status: room.status, - } - : current, - ) - }) - .catch((error) => { - console.error('update live room error:', error) - }) - }, [ - leftTeam, - liveRoomSession, - pointLog, - rightTeam, - scoreState, - selectedGroup?.id, + const nextStatus = winnerTeamName ? 'finished' : 'live' + const payload = buildLiveRoomPayload({ + groupId: selectedGroup?.id ?? null, + leftTeam, + pointLog, + rightTeam, + scoreState, + targetDate, + }) + const signature = JSON.stringify({ + payload, + roomId: liveRoomSession.roomId, + status: nextStatus, + winnerTeamName, + }) + + if (signature === lastSyncedRoomSignatureRef.current) { + return + } + + lastSyncedRoomSignatureRef.current = signature + + void updateLiveRoom(liveRoomSession.roomId, { + ...payload, + hostToken: liveRoomSession.hostToken, + status: nextStatus, + winnerTeamName, + }) + .then((room) => { + setLiveRoomSession((current) => + current + ? { + ...current, + status: room.status, + } + : current, + ) + }) + .catch((error) => { + console.error('update live room error:', error) + }) + }, [ + leftTeam, + liveRoomSession, + pointLog, + rightTeam, + scoreState, + selectedGroup?.id, targetDate, isScoreboardRoute, ]) @@ -557,131 +557,131 @@ function App() { useEffect(() => { if (!liveRoomSession || liveRoomSession.status !== 'live') { - return - } - - const { hostToken, roomId } = liveRoomSession - let released = false - - const release = () => { - if (released) { - return - } - - released = true - void releaseLiveRoom(roomId, hostToken).catch(() => {}) - } - - const handleBeforeUnload = () => { - if (released) { - return - } - - released = true - if (navigator.sendBeacon) { - const payload = new Blob([JSON.stringify({ hostToken })], { - type: 'application/json', - }) - navigator.sendBeacon(`/api/rooms/${roomId}/release`, payload) - return - } - - void fetch(`/api/rooms/${roomId}/release`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ hostToken }), - keepalive: true, - }).catch(() => {}) - } - - window.addEventListener('beforeunload', handleBeforeUnload) - - return () => { - window.removeEventListener('beforeunload', handleBeforeUnload) - if (!isScoreboardRoute) { - release() - } - } - }, [isScoreboardRoute, liveRoomSession]) - - const loadGroupsFromDb = async () => { - if (!targetDate) { - setLoadStatus('error') - setLoadMessage('請先選擇日期。') - return - } - - setLoadStatus('loading') - setLoadMessage('正在讀取指定日期的分組資料...') - - try { - const record = await loadMatchResults(convertDateToKey(targetDate)) - - if (!record) { - setGroups([]) - setSelectedGroupId(null) - setActiveMatchup({ leftTeam: null, rightTeam: null }) - setGroupSource('idle') - setLoadStatus('empty') - setLoadMessage('指定日期沒有資料,請改用手動配對。') - return - } - - const nextData = convertDbRecordToGroups(record) - setAreaAInput(nextData.areaA.join('\n')) - setAreaBInput(nextData.areaB.join('\n')) - setGroups(nextData.groups) - setGroupSource('db') - setLoadStatus('loaded') - setLoadMessage(`已載入 ${convertDateToKey(targetDate)} 的分組資料。`) - selectGroup(nextData.groups[0]?.id ?? 1, nextData.groups) - } catch (error) { - setGroups([]) - setSelectedGroupId(null) - setActiveMatchup({ leftTeam: null, rightTeam: null }) - setGroupSource('idle') - setLoadStatus('error') - setLoadMessage(error instanceof Error ? error.message : '讀取資料失敗。') - } - } - - const generateManualGroups = () => { - if (parsedAreaA.length === 0 || parsedAreaB.length === 0) { - setGroups([]) - setSelectedGroupId(null) - setActiveMatchup({ leftTeam: null, rightTeam: null }) - setGroupSource('idle') - setLoadStatus('error') - setLoadMessage('A 區與 B 區至少都要有 1 位成員。') - return - } - - const nextGroups = buildManualGroups(parsedAreaA, parsedAreaB) - setGroups(nextGroups) - setGroupSource('manual') - setLoadStatus('loaded') - setLoadMessage('已產生手動配對結果,請選擇要使用的組別。') - selectGroup(nextGroups[0]?.id ?? 1, nextGroups) - } - - const swapMatchupSides = () => { - if (scoreHistory.length > 0) { - return - } - - setActiveMatchup((current) => ({ - leftTeam: current.rightTeam, - rightTeam: current.leftTeam, - })) - - setScoreState((current) => ({ - ...current, - scoreLeft: current.scoreRight, - scoreRight: current.scoreLeft, - gamesLeft: current.gamesRight, - gamesRight: current.gamesLeft, + return + } + + const { hostToken, roomId } = liveRoomSession + let released = false + + const release = () => { + if (released) { + return + } + + released = true + void releaseLiveRoom(roomId, hostToken).catch(() => {}) + } + + const handleBeforeUnload = () => { + if (released) { + return + } + + released = true + if (navigator.sendBeacon) { + const payload = new Blob([JSON.stringify({ hostToken })], { + type: 'application/json', + }) + navigator.sendBeacon(`/api/rooms/${roomId}/release`, payload) + return + } + + void fetch(`/api/rooms/${roomId}/release`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ hostToken }), + keepalive: true, + }).catch(() => {}) + } + + window.addEventListener('beforeunload', handleBeforeUnload) + + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload) + if (!isScoreboardRoute) { + release() + } + } + }, [isScoreboardRoute, liveRoomSession]) + + const loadGroupsFromDb = async () => { + if (!targetDate) { + setLoadStatus('error') + setLoadMessage('請先選擇日期。') + return + } + + setLoadStatus('loading') + setLoadMessage('正在讀取指定日期的分組資料...') + + try { + const record = await loadMatchResults(convertDateToKey(targetDate)) + + if (!record) { + setGroups([]) + setSelectedGroupId(null) + setActiveMatchup({ leftTeam: null, rightTeam: null }) + setGroupSource('idle') + setLoadStatus('empty') + setLoadMessage('指定日期沒有資料,請改用手動配對。') + return + } + + const nextData = convertDbRecordToGroups(record) + setAreaAInput(nextData.areaA.join('\n')) + setAreaBInput(nextData.areaB.join('\n')) + setGroups(nextData.groups) + setGroupSource('db') + setLoadStatus('loaded') + setLoadMessage(`已載入 ${convertDateToKey(targetDate)} 的分組資料。`) + selectGroup(nextData.groups[0]?.id ?? 1, nextData.groups) + } catch (error) { + setGroups([]) + setSelectedGroupId(null) + setActiveMatchup({ leftTeam: null, rightTeam: null }) + setGroupSource('idle') + setLoadStatus('error') + setLoadMessage(error instanceof Error ? error.message : '讀取資料失敗。') + } + } + + const generateManualGroups = () => { + if (parsedAreaA.length === 0 || parsedAreaB.length === 0) { + setGroups([]) + setSelectedGroupId(null) + setActiveMatchup({ leftTeam: null, rightTeam: null }) + setGroupSource('idle') + setLoadStatus('error') + setLoadMessage('A 區與 B 區至少都要有 1 位成員。') + return + } + + const nextGroups = buildManualGroups(parsedAreaA, parsedAreaB) + setGroups(nextGroups) + setGroupSource('manual') + setLoadStatus('loaded') + setLoadMessage('已產生手動配對結果,請選擇要使用的組別。') + selectGroup(nextGroups[0]?.id ?? 1, nextGroups) + } + + const swapMatchupSides = () => { + if (scoreHistory.length > 0) { + return + } + + setActiveMatchup((current) => ({ + leftTeam: current.rightTeam, + rightTeam: current.leftTeam, + })) + + setScoreState((current) => ({ + ...current, + scoreLeft: current.scoreRight, + scoreRight: current.scoreLeft, + gamesLeft: current.gamesRight, + gamesRight: current.gamesLeft, serving: current.serving === 'left' ? 'right' @@ -694,83 +694,83 @@ function App() { : current.initialServing === 'right' ? 'left' : null, - leftRightCourtPlayer: current.rightRightCourtPlayer, - rightRightCourtPlayer: current.leftRightCourtPlayer, - })) - } - - const swapTeamPlayers = (side: ScoreSide) => { + leftRightCourtPlayer: current.rightRightCourtPlayer, + rightRightCourtPlayer: current.leftRightCourtPlayer, + })) + } + + const swapTeamPlayers = (side: ScoreSide) => { + if (scoreHistory.length > 0) { + return + } + + setScoreState((current) => ({ + ...current, + leftRightCourtPlayer: + side === 'left' + ? swapCourtPositions(current.leftRightCourtPlayer) + : current.leftRightCourtPlayer, + rightRightCourtPlayer: + side === 'right' + ? swapCourtPositions(current.rightRightCourtPlayer) + : current.rightRightCourtPlayer, + })) + } + + const setServing = (side: ScoreSide) => { if (scoreHistory.length > 0) { return } setScoreState((current) => ({ ...current, - leftRightCourtPlayer: - side === 'left' - ? swapCourtPositions(current.leftRightCourtPlayer) - : current.leftRightCourtPlayer, - rightRightCourtPlayer: - side === 'right' - ? swapCourtPositions(current.rightRightCourtPlayer) - : current.rightRightCourtPlayer, + initialServing: current.initialServing === side ? null : side, + serving: current.initialServing === side ? null : side, })) } - - const setServing = (side: ScoreSide) => { - if (scoreHistory.length > 0 || scoreState.initialServing !== null) { - return - } - - setScoreState((current) => ({ - ...current, - initialServing: side, - serving: side, - })) - } - - const recordPoint = (side: ScoreSide) => { - if (!leftTeam || !rightTeam || scoreState.serving === null) { - return - } - - const starter = getServerHistoryIndex(scoreState, leftTeam, rightTeam) - - if (starter === null) { - return - } - - const winner: 0 | 1 = side === 'left' ? 0 : 1 - const previousPoint = pointLog.at(-1) - const winCount = previousPoint?.winner === winner ? previousPoint.winCount + 1 : 0 - const streakCount = winCount + 1 - const streakTitle = STREAK_TITLES[streakCount] - - const nextPointLog = [ - ...pointLog, - { - round: pointLog.length, - starter, - winCount, - winner, - }, - ] - + + const recordPoint = (side: ScoreSide) => { + if (!leftTeam || !rightTeam || scoreState.serving === null) { + return + } + + const starter = getServerHistoryIndex(scoreState, leftTeam, rightTeam) + + if (starter === null) { + return + } + + const winner: 0 | 1 = side === 'left' ? 0 : 1 + const previousPoint = pointLog.at(-1) + const winCount = previousPoint?.winner === winner ? previousPoint.winCount + 1 : 0 + const streakCount = winCount + 1 + const streakTitle = STREAK_TITLES[streakCount] + + const nextPointLog = [ + ...pointLog, + { + round: pointLog.length, + starter, + winCount, + winner, + }, + ] + const nextScoreState: ScoreState = { - ...scoreState, - scoreLeft: side === 'left' ? scoreState.scoreLeft + 1 : scoreState.scoreLeft, - scoreRight: side === 'right' ? scoreState.scoreRight + 1 : scoreState.scoreRight, - serving: side, - leftRightCourtPlayer: - side === 'left' && side === scoreState.serving - ? swapCourtPositions(scoreState.leftRightCourtPlayer) - : scoreState.leftRightCourtPlayer, - rightRightCourtPlayer: - side === 'right' && side === scoreState.serving - ? swapCourtPositions(scoreState.rightRightCourtPlayer) - : scoreState.rightRightCourtPlayer, - } - + ...scoreState, + scoreLeft: side === 'left' ? scoreState.scoreLeft + 1 : scoreState.scoreLeft, + scoreRight: side === 'right' ? scoreState.scoreRight + 1 : scoreState.scoreRight, + serving: side, + leftRightCourtPlayer: + side === 'left' && side === scoreState.serving + ? swapCourtPositions(scoreState.leftRightCourtPlayer) + : scoreState.leftRightCourtPlayer, + rightRightCourtPlayer: + side === 'right' && side === scoreState.serving + ? swapCourtPositions(scoreState.rightRightCourtPlayer) + : scoreState.rightRightCourtPlayer, + } + setScoreHistory((current) => [...current, { pointLog, scoreState }]) setPointLog(nextPointLog) setScoreState(nextScoreState) @@ -780,129 +780,129 @@ function App() { serverChanged: side === scoreState.serving, serverName: getNextServerName(nextScoreState, leftTeam, rightTeam, side), }) - - if (streakTitle) { - setStreakAnnouncement({ - count: streakCount, - key: Date.now(), - teamName: side === 'left' ? getTeamDisplayName(leftTeam) : getTeamDisplayName(rightTeam), - title: streakTitle, - }) - } - + + if (streakTitle) { + setStreakAnnouncement({ + count: streakCount, + key: Date.now(), + teamName: side === 'left' ? getTeamDisplayName(leftTeam) : getTeamDisplayName(rightTeam), + title: streakTitle, + }) + } + const reachedTarget = hasWonGame(nextScoreState) - - if (reachedTarget) { - setVictoryAnnouncement({ - key: Date.now() + 1, - scoreLabel: `${nextScoreState.scoreLeft} : ${nextScoreState.scoreRight}`, - teamName: side === 'left' ? getTeamDisplayName(leftTeam) : getTeamDisplayName(rightTeam), - title: '拿下勝利', - }) - } - } - - const undoLastPoint = () => { - const previous = scoreHistory.at(-1) - - if (!previous) { - return - } - - setScoreHistory((current) => current.slice(0, -1)) - setPointLog(previous.pointLog) - setScoreState(previous.scoreState) - setStreakAnnouncement(null) - setVictoryAnnouncement(null) - } - - const openSettlementDialog = () => { - if (!leftTeam || !rightTeam || pointLog.length === 0) { - return - } - - setSettlement({ - error: '', - open: true, - uploading: false, - }) - } - - const closeSettlementDialog = () => { - if (settlement.uploading) { - return - } - - setSettlement((current) => ({ - ...current, - error: '', - open: false, - })) - } - - const skipUpload = () => { - void finalizeLiveRoom().finally(() => { - setSettlement({ - error: '', - open: false, - uploading: false, - }) - resetScoring(initialScoreState, { releaseLiveRoom: false }) - }) - } - + + if (reachedTarget) { + setVictoryAnnouncement({ + key: Date.now() + 1, + scoreLabel: `${nextScoreState.scoreLeft} : ${nextScoreState.scoreRight}`, + teamName: side === 'left' ? getTeamDisplayName(leftTeam) : getTeamDisplayName(rightTeam), + title: '拿下勝利', + }) + } + } + + const undoLastPoint = () => { + const previous = scoreHistory.at(-1) + + if (!previous) { + return + } + + setScoreHistory((current) => current.slice(0, -1)) + setPointLog(previous.pointLog) + setScoreState(previous.scoreState) + setStreakAnnouncement(null) + setVictoryAnnouncement(null) + } + + const openSettlementDialog = () => { + if (!leftTeam || !rightTeam || pointLog.length === 0) { + return + } + + setSettlement({ + error: '', + open: true, + uploading: false, + }) + } + + const closeSettlementDialog = () => { + if (settlement.uploading) { + return + } + + setSettlement((current) => ({ + ...current, + error: '', + open: false, + })) + } + + const skipUpload = () => { + void finalizeLiveRoom().finally(() => { + setSettlement({ + error: '', + open: false, + uploading: false, + }) + resetScoring(initialScoreState, { releaseLiveRoom: false }) + }) + } + const uploadSettledMatch = async () => { - if (!leftTeam || !rightTeam || !selectedGroup || pointLog.length === 0) { - return - } - - setSettlement((current) => ({ - ...current, - error: '', - uploading: true, - })) - - try { - const payload = buildHistoryPayload({ - leftTeam, - pointLog, - rightTeam, - scoreState, - }) - - const result = await saveMatchHistory(payload) - - const historyItem: MatchHistoryItem = { - id: String(result.id), - playedAt: formatPlayedAt(payload.time), - matchDate: targetDate, - source: groupSource, - groupId: selectedGroup.id, - leftTeamName: getTeamDisplayName(leftTeam), - rightTeamName: getTeamDisplayName(rightTeam), - scoreLeft: scoreState.scoreLeft, - scoreRight: scoreState.scoreRight, - winner: getWinnerName( - getTeamDisplayName(leftTeam), - getTeamDisplayName(rightTeam), - scoreState, - ), - } - - setHistory((current) => [historyItem, ...current]) - await finalizeLiveRoom() - setSettlement({ - error: '', - open: false, - uploading: false, - }) - resetScoring(initialScoreState, { releaseLiveRoom: false }) - } catch (error) { - setSettlement({ - error: error instanceof Error ? error.message : '上傳戰績失敗。', - open: true, - uploading: false, - }) + if (!leftTeam || !rightTeam || !selectedGroup || pointLog.length === 0) { + return + } + + setSettlement((current) => ({ + ...current, + error: '', + uploading: true, + })) + + try { + const payload = buildHistoryPayload({ + leftTeam, + pointLog, + rightTeam, + scoreState, + }) + + const result = await saveMatchHistory(payload) + + const historyItem: MatchHistoryItem = { + id: String(result.id), + playedAt: formatPlayedAt(payload.time), + matchDate: targetDate, + source: groupSource, + groupId: selectedGroup.id, + leftTeamName: getTeamDisplayName(leftTeam), + rightTeamName: getTeamDisplayName(rightTeam), + scoreLeft: scoreState.scoreLeft, + scoreRight: scoreState.scoreRight, + winner: getWinnerName( + getTeamDisplayName(leftTeam), + getTeamDisplayName(rightTeam), + scoreState, + ), + } + + setHistory((current) => [historyItem, ...current]) + await finalizeLiveRoom() + setSettlement({ + error: '', + open: false, + uploading: false, + }) + resetScoring(initialScoreState, { releaseLiveRoom: false }) + } catch (error) { + setSettlement({ + error: error instanceof Error ? error.message : '上傳戰績失敗。', + open: true, + uploading: false, + }) } } @@ -917,140 +917,140 @@ function App() { return (
-
-
-

Badminton Scoreboard

-

{isScoreboardRoute ? '羽毛球記分板' : '羽毛球分組與記分板'}

- {!isScoreboardRoute ? ( -

- 先選日期或手動建立組別,再從該組挑選要對打的兩隊進入記分板。比賽結束後可直接上傳戰績到 - DB。 -

- ) : null} -
- - +
+ + + void loadGroupsFromDb()} + onTargetDateChange={setTargetDate} + onUseGroup={selectGroup} + /> + } + /> + void loadGroupsFromDb()} + onTargetDateChange={setTargetDate} + onUseGroup={selectGroup} + /> + } + /> + 0} + leftTeam={leftTeam} + liveRoomId={liveRoomId} + rightTeam={rightTeam} + scoreState={scoreState} + selectedGroup={selectedGroup} streakAnnouncement={streakAnnouncement} victoryAnnouncement={victoryAnnouncement} voiceAnnouncement={voiceAnnouncement} targetDate={targetDate} - onApplyMatchup={applyMatchup} - onCloseFinishDialog={closeSettlementDialog} - onConfirmUpload={uploadSettledMatch} - onOpenFinishDialog={openSettlementDialog} - onRecordPoint={recordPoint} - onSetServing={setServing} - onSkipUpload={skipUpload} - onSwapMatchup={swapMatchupSides} - onSwapTeamPlayers={swapTeamPlayers} - onUndoLastPoint={undoLastPoint} - /> - } - /> - } /> - } /> - navigate('/rooms')} />} - /> - - + onApplyMatchup={applyMatchup} + onCloseFinishDialog={closeSettlementDialog} + onConfirmUpload={uploadSettledMatch} + onOpenFinishDialog={openSettlementDialog} + onRecordPoint={recordPoint} + onSetServing={setServing} + onSkipUpload={skipUpload} + onSwapMatchup={swapMatchupSides} + onSwapTeamPlayers={swapTeamPlayers} + onUndoLastPoint={undoLastPoint} + /> + } + /> + } /> + } /> + navigate('/rooms')} />} + /> + + {pwaUpdateReady ? (
-
- 有新版本可更新 - 點重新整理後套用最新版本。 -
- +
+ 有新版本可更新 + 點重新整理後套用最新版本。 +
+
) : null} @@ -1062,45 +1062,45 @@ function App() {
) } - -function buildHistoryPayload({ - leftTeam, - pointLog, - rightTeam, - scoreState, -}: { - leftTeam: GroupTeam - pointLog: PointHistoryEntry[] - rightTeam: GroupTeam - scoreState: ScoreState -}): HistoryUploadPayload { - const players = [ - leftTeam.playerA, - leftTeam.playerB, - rightTeam.playerB, - rightTeam.playerA, - ] - - return { - dayOfWeek: new Date().getDay(), - players, - score: [scoreState.scoreLeft, scoreState.scoreRight], - scoreList: pointLog.map((point) => [ - point.round, - point.starter, - point.winCount, - point.winner, - ]), - team: [ - [leftTeam.playerA, leftTeam.playerB], - [rightTeam.playerB, rightTeam.playerA], - ], - time: Math.floor(Date.now() / 1000), - type: 0, - winScore: scoreState.targetScore, - } -} - + +function buildHistoryPayload({ + leftTeam, + pointLog, + rightTeam, + scoreState, +}: { + leftTeam: GroupTeam + pointLog: PointHistoryEntry[] + rightTeam: GroupTeam + scoreState: ScoreState +}): HistoryUploadPayload { + const players = [ + leftTeam.playerA, + leftTeam.playerB, + rightTeam.playerB, + rightTeam.playerA, + ] + + return { + dayOfWeek: new Date().getDay(), + players, + score: [scoreState.scoreLeft, scoreState.scoreRight], + scoreList: pointLog.map((point) => [ + point.round, + point.starter, + point.winCount, + point.winner, + ]), + team: [ + [leftTeam.playerA, leftTeam.playerB], + [rightTeam.playerB, rightTeam.playerA], + ], + time: Math.floor(Date.now() / 1000), + type: 0, + winScore: scoreState.targetScore, + } +} + function getServerHistoryIndex( state: ScoreState, leftTeam: GroupTeam, @@ -1160,68 +1160,68 @@ function hasWonGame(state: ScoreState) { return true } - -function formatPlayedAt(timestamp: number) { - return new Date(timestamp * 1000).toLocaleString('zh-TW', { hour12: false }) -} - -function getSelectionOrder(leftTeam: GroupTeam | null, rightTeam: GroupTeam | null) { - if (!leftTeam || !rightTeam) { - return [] - } - - return [ - leftTeam.playerA, - leftTeam.playerB, - rightTeam.playerB, - rightTeam.playerA, - ].filter((name) => name.trim().length > 0) -} - -function loadStoredText(storageKey: string, fallback: string) { - const value = window.localStorage.getItem(storageKey) - return value && value.trim() ? value : fallback -} - -function loadStoredHistory(storageKey: string) { - const value = window.localStorage.getItem(storageKey) - - if (!value) { - return [] - } - - try { - const parsed = JSON.parse(value) as MatchHistoryItem[] - return Array.isArray(parsed) ? parsed : [] - } catch { - return [] - } -} - -function buildLiveRoomPayload({ - groupId, - leftTeam, - pointLog, - rightTeam, - scoreState, - targetDate, -}: { - groupId: number | null - leftTeam: GroupTeam - pointLog: PointHistoryEntry[] - rightTeam: GroupTeam - scoreState: ScoreState - targetDate: string -}) { - return { - groupId, - leftTeamName: getTeamDisplayName(leftTeam), - matchupLabel: `${getTeamDisplayName(leftTeam)} vs ${getTeamDisplayName(rightTeam)}`, - pointLog, - rightTeamName: getTeamDisplayName(rightTeam), - scoreState, - targetDate, - } -} - -export default App + +function formatPlayedAt(timestamp: number) { + return new Date(timestamp * 1000).toLocaleString('zh-TW', { hour12: false }) +} + +function getSelectionOrder(leftTeam: GroupTeam | null, rightTeam: GroupTeam | null) { + if (!leftTeam || !rightTeam) { + return [] + } + + return [ + leftTeam.playerA, + leftTeam.playerB, + rightTeam.playerB, + rightTeam.playerA, + ].filter((name) => name.trim().length > 0) +} + +function loadStoredText(storageKey: string, fallback: string) { + const value = window.localStorage.getItem(storageKey) + return value && value.trim() ? value : fallback +} + +function loadStoredHistory(storageKey: string) { + const value = window.localStorage.getItem(storageKey) + + if (!value) { + return [] + } + + try { + const parsed = JSON.parse(value) as MatchHistoryItem[] + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} + +function buildLiveRoomPayload({ + groupId, + leftTeam, + pointLog, + rightTeam, + scoreState, + targetDate, +}: { + groupId: number | null + leftTeam: GroupTeam + pointLog: PointHistoryEntry[] + rightTeam: GroupTeam + scoreState: ScoreState + targetDate: string +}) { + return { + groupId, + leftTeamName: getTeamDisplayName(leftTeam), + matchupLabel: `${getTeamDisplayName(leftTeam)} vs ${getTeamDisplayName(rightTeam)}`, + pointLog, + rightTeamName: getTeamDisplayName(rightTeam), + scoreState, + targetDate, + } +} + +export default App diff --git a/src/index.css b/src/index.css index a6356dc..15a0ccc 100644 --- a/src/index.css +++ b/src/index.css @@ -1,38 +1,38 @@ -:root { - --page-bg: #eff5df; - --page-bg-2: #dbe8c6; - --panel-strong: #0a332d; - --panel-soft: #587169; - --border: rgba(7, 51, 44, 0.12); - --shadow: - 0 24px 60px rgba(19, 43, 34, 0.12), - 0 8px 20px rgba(19, 43, 34, 0.08); - --sans: 'Bahnschrift', 'Trebuchet MS', sans-serif; - --heading: 'Arial Black', 'Bahnschrift', sans-serif; - --mono: 'Consolas', 'Courier New', monospace; - font: 18px/1.5 var(--sans); - color: var(--panel-strong); - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -* { - box-sizing: border-box; - -webkit-user-select: none; - user-select: none; -} - +:root { + --page-bg: #eff5df; + --page-bg-2: #dbe8c6; + --panel-strong: #0a332d; + --panel-soft: #587169; + --border: rgba(7, 51, 44, 0.12); + --shadow: + 0 24px 60px rgba(19, 43, 34, 0.12), + 0 8px 20px rgba(19, 43, 34, 0.08); + --sans: 'Bahnschrift', 'Trebuchet MS', sans-serif; + --heading: 'Arial Black', 'Bahnschrift', sans-serif; + --mono: 'Consolas', 'Courier New', monospace; + font: 18px/1.5 var(--sans); + color: var(--panel-strong); + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +* { + box-sizing: border-box; + -webkit-user-select: none; + user-select: none; +} + html { min-height: 100%; } body { - margin: 0; - min-height: 100vh; - background: - radial-gradient(circle at top left, rgba(255, 214, 10, 0.35), transparent 28%), + margin: 0; + min-height: 100vh; + background: + radial-gradient(circle at top left, rgba(255, 214, 10, 0.35), transparent 28%), radial-gradient(circle at bottom right, rgba(11, 88, 73, 0.2), transparent 32%), linear-gradient(180deg, var(--page-bg), var(--page-bg-2)); } @@ -43,17 +43,17 @@ body.body-scoreboard { } body::before { - content: ''; - position: fixed; - inset: 0; - pointer-events: none; - background-image: - linear-gradient(rgba(255, 255, 255, 0.14) 1px, transparent 1px), - linear-gradient(90deg, rgba(255, 255, 255, 0.14) 1px, transparent 1px); - background-size: 48px 48px; - mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.45), transparent 78%); -} - + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + background-image: + linear-gradient(rgba(255, 255, 255, 0.14) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.14) 1px, transparent 1px); + background-size: 48px 48px; + mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.45), transparent 78%); +} + #root { min-height: 100vh; } @@ -62,46 +62,46 @@ body.body-scoreboard #root { min-height: 100dvh; overflow: hidden; } - -input, -textarea, -select, -button, -[contenteditable='true'] { - -webkit-user-select: auto; - user-select: auto; -} - -h1, -h2, -h3, -p { - margin: 0; -} - -h1, -h2, -h3 { - font-family: var(--heading); - letter-spacing: 0.02em; -} - -h1 { - margin: 18px 0 14px; - font-size: clamp(2.8rem, 8vw, 5rem); - line-height: 0.94; -} - -h2 { - font-size: clamp(1.6rem, 3vw, 2.2rem); -} - -p { - color: var(--panel-soft); -} - -@media (max-width: 720px) { - :root { - font-size: 16px; - } -} + +input, +textarea, +select, +button, +[contenteditable='true'] { + -webkit-user-select: auto; + user-select: auto; +} + +h1, +h2, +h3, +p { + margin: 0; +} + +h1, +h2, +h3 { + font-family: var(--heading); + letter-spacing: 0.02em; +} + +h1 { + margin: 18px 0 14px; + font-size: clamp(2.8rem, 8vw, 5rem); + line-height: 0.94; +} + +h2 { + font-size: clamp(1.6rem, 3vw, 2.2rem); +} + +p { + color: var(--panel-soft); +} + +@media (max-width: 720px) { + :root { + font-size: 16px; + } +} diff --git a/src/pages/ScoreboardPage.tsx b/src/pages/ScoreboardPage.tsx index 05975a4..a9ec6f7 100644 --- a/src/pages/ScoreboardPage.tsx +++ b/src/pages/ScoreboardPage.tsx @@ -1,57 +1,57 @@ -import type { Dispatch, SetStateAction } from 'react' -import { useEffect, useMemo, useRef, useState } from 'react' -import { Link } from 'react-router-dom' -import { +import type { Dispatch, SetStateAction } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { Link } from 'react-router-dom' +import { getCourtAssignments, getMirroredCourt, getReceivingPlayer, getServiceCourt, - getServingPlayer, - getTeamDisplayName, -} from '../lib/match' -import type { - CourtSide, - GroupTeam, - PlayerSlot, - RoundGroup, - ScoreSide, - ScoreState, -} from '../types' - -type VoiceSettings = { - announceScore: boolean - announceServer: boolean - rate: number -} - -const VOICE_SETTINGS_STORAGE_KEY = 'badminton-scoreboard::voice-settings' -const defaultVoiceSettings: VoiceSettings = { - announceScore: true, - announceServer: true, - rate: 1, -} -const SPEECH_NAME_MAP: Record = { - ruru: '嚕嚕', -} - -type ScoreboardPageProps = { - currentSelectionOrder: string[] - finishDialogError: string - finishDialogOpen: boolean - finishDialogUploading: boolean - groupSource: 'idle' | 'db' | 'manual' - hasRecordedPoint: boolean - leftTeam: GroupTeam | null - liveRoomId: string | null - rightTeam: GroupTeam | null - scoreState: ScoreState - selectedGroup: RoundGroup | null - streakAnnouncement: { - count: number - key: number - teamName: string - title: string - } | null + getServingPlayer, + getTeamDisplayName, +} from '../lib/match' +import type { + CourtSide, + GroupTeam, + PlayerSlot, + RoundGroup, + ScoreSide, + ScoreState, +} from '../types' + +type VoiceSettings = { + announceScore: boolean + announceServer: boolean + rate: number +} + +const VOICE_SETTINGS_STORAGE_KEY = 'badminton-scoreboard::voice-settings' +const defaultVoiceSettings: VoiceSettings = { + announceScore: true, + announceServer: true, + rate: 1, +} +const SPEECH_NAME_MAP: Record = { + ruru: '嚕嚕', +} + +type ScoreboardPageProps = { + currentSelectionOrder: string[] + finishDialogError: string + finishDialogOpen: boolean + finishDialogUploading: boolean + groupSource: 'idle' | 'db' | 'manual' + hasRecordedPoint: boolean + leftTeam: GroupTeam | null + liveRoomId: string | null + rightTeam: GroupTeam | null + scoreState: ScoreState + selectedGroup: RoundGroup | null + streakAnnouncement: { + count: number + key: number + teamName: string + title: string + } | null victoryAnnouncement: { key: number scoreLabel: string @@ -65,136 +65,136 @@ type ScoreboardPageProps = { serverName: string } | null targetDate: string - onApplyMatchup: ( - leftTeam: GroupTeam, - rightTeam: GroupTeam, - targetScore: number, - ) => void - onCloseFinishDialog: () => void - onConfirmUpload: () => void - onOpenFinishDialog: () => void - onRecordPoint: (side: ScoreSide) => void - onSetServing: (side: ScoreSide) => void - onSkipUpload: () => void - onSwapMatchup: () => void - onSwapTeamPlayers: (side: ScoreSide) => void - onUndoLastPoint: () => void -} - -export function ScoreboardPage({ - currentSelectionOrder, - finishDialogError, - finishDialogOpen, - finishDialogUploading, - groupSource, - hasRecordedPoint, - leftTeam, - liveRoomId, - rightTeam, - scoreState, + onApplyMatchup: ( + leftTeam: GroupTeam, + rightTeam: GroupTeam, + targetScore: number, + ) => void + onCloseFinishDialog: () => void + onConfirmUpload: () => void + onOpenFinishDialog: () => void + onRecordPoint: (side: ScoreSide) => void + onSetServing: (side: ScoreSide) => void + onSkipUpload: () => void + onSwapMatchup: () => void + onSwapTeamPlayers: (side: ScoreSide) => void + onUndoLastPoint: () => void +} + +export function ScoreboardPage({ + currentSelectionOrder, + finishDialogError, + finishDialogOpen, + finishDialogUploading, + groupSource, + hasRecordedPoint, + leftTeam, + liveRoomId, + rightTeam, + scoreState, selectedGroup, streakAnnouncement, victoryAnnouncement, voiceAnnouncement, targetDate, - onApplyMatchup, - onCloseFinishDialog, - onConfirmUpload, - onOpenFinishDialog, - onRecordPoint, - onSetServing, - onSkipUpload, - onSwapMatchup, - onSwapTeamPlayers, - onUndoLastPoint, -}: ScoreboardPageProps) { - const FINISH_HOLD_DURATION = 1000 - const [pickerOpen, setPickerOpen] = useState(false) - const [settingsOpen, setSettingsOpen] = useState(false) - const [draftPlayers, setDraftPlayers] = useState([]) - const [draftTargetScore, setDraftTargetScore] = useState(() => - String(scoreState.targetScore), - ) - const [clock, setClock] = useState(() => formatClock()) - const [finishHoldActive, setFinishHoldActive] = useState(false) - const [finishHoldProgress, setFinishHoldProgress] = useState(0) - const [voiceSettings, setVoiceSettings] = useState(() => - loadVoiceSettings(), - ) + onApplyMatchup, + onCloseFinishDialog, + onConfirmUpload, + onOpenFinishDialog, + onRecordPoint, + onSetServing, + onSkipUpload, + onSwapMatchup, + onSwapTeamPlayers, + onUndoLastPoint, +}: ScoreboardPageProps) { + const FINISH_HOLD_DURATION = 1000 + const [pickerOpen, setPickerOpen] = useState(false) + const [settingsOpen, setSettingsOpen] = useState(false) + const [draftPlayers, setDraftPlayers] = useState([]) + const [draftTargetScore, setDraftTargetScore] = useState(() => + String(scoreState.targetScore), + ) + const [clock, setClock] = useState(() => formatClock()) + const [finishHoldActive, setFinishHoldActive] = useState(false) + const [finishHoldProgress, setFinishHoldProgress] = useState(0) + const [voiceSettings, setVoiceSettings] = useState(() => + loadVoiceSettings(), + ) const finishHoldFrameRef = useRef(null) const finishHoldTimerRef = useRef(null) const finishHoldStartRef = useRef(0) const finishTriggeredRef = useRef(false) - - useEffect(() => { - const timer = window.setInterval(() => { - setClock(formatClock()) - }, 1000) - - return () => window.clearInterval(timer) - }, []) - - useEffect(() => { - window.localStorage.setItem( - VOICE_SETTINGS_STORAGE_KEY, - JSON.stringify(voiceSettings), - ) - }, [voiceSettings]) - - useEffect(() => { - return () => { - if (finishHoldFrameRef.current !== null) { - window.cancelAnimationFrame(finishHoldFrameRef.current) - } - - if (finishHoldTimerRef.current !== null) { - window.clearTimeout(finishHoldTimerRef.current) - } - - if ('speechSynthesis' in window) { - window.speechSynthesis.cancel() - } - } - }, []) - - const selectablePlayers = useMemo(() => { - if (!selectedGroup) { - return [] - } - - const seen = new Set() - const players: string[] = [] - - selectedGroup.teams.forEach((team) => { - if (!team.isPlaceholderA && !seen.has(team.playerA)) { - seen.add(team.playerA) - players.push(team.playerA) - } - - if (!team.isPlaceholderB && !seen.has(team.playerB)) { - seen.add(team.playerB) - players.push(team.playerB) - } - }) - - return players - }, [selectedGroup]) - - const presetTeams = useMemo( - () => - selectedGroup?.teams.filter((team) => !team.isPlaceholderA && !team.isPlaceholderB) ?? [], - [selectedGroup], - ) - - const canArrangeMatch = !hasRecordedPoint - const canScore = scoreState.serving !== null - const canFinishMatch = scoreState.scoreLeft > 0 || scoreState.scoreRight > 0 - - const servingScore = - scoreState.serving === 'left' ? scoreState.scoreLeft : scoreState.scoreRight - const servingCourt = - scoreState.serving === null ? null : getServiceCourt(servingScore) - + + useEffect(() => { + const timer = window.setInterval(() => { + setClock(formatClock()) + }, 1000) + + return () => window.clearInterval(timer) + }, []) + + useEffect(() => { + window.localStorage.setItem( + VOICE_SETTINGS_STORAGE_KEY, + JSON.stringify(voiceSettings), + ) + }, [voiceSettings]) + + useEffect(() => { + return () => { + if (finishHoldFrameRef.current !== null) { + window.cancelAnimationFrame(finishHoldFrameRef.current) + } + + if (finishHoldTimerRef.current !== null) { + window.clearTimeout(finishHoldTimerRef.current) + } + + if ('speechSynthesis' in window) { + window.speechSynthesis.cancel() + } + } + }, []) + + const selectablePlayers = useMemo(() => { + if (!selectedGroup) { + return [] + } + + const seen = new Set() + const players: string[] = [] + + selectedGroup.teams.forEach((team) => { + if (!team.isPlaceholderA && !seen.has(team.playerA)) { + seen.add(team.playerA) + players.push(team.playerA) + } + + if (!team.isPlaceholderB && !seen.has(team.playerB)) { + seen.add(team.playerB) + players.push(team.playerB) + } + }) + + return players + }, [selectedGroup]) + + const presetTeams = useMemo( + () => + selectedGroup?.teams.filter((team) => !team.isPlaceholderA && !team.isPlaceholderB) ?? [], + [selectedGroup], + ) + + const canArrangeMatch = !hasRecordedPoint + const canScore = scoreState.serving !== null + const canFinishMatch = scoreState.scoreLeft > 0 || scoreState.scoreRight > 0 + + const servingScore = + scoreState.serving === 'left' ? scoreState.scoreLeft : scoreState.scoreRight + const servingCourt = + scoreState.serving === null ? null : getServiceCourt(servingScore) + const leftAssignments = useMemo( () => leftTeam ? getCourtAssignments(leftTeam, scoreState.leftRightCourtPlayer, true) : [], @@ -205,41 +205,41 @@ export function ScoreboardPage({ rightTeam ? getCourtAssignments(rightTeam, scoreState.rightRightCourtPlayer, false) : [], [rightTeam, scoreState.rightRightCourtPlayer], ) - - const currentServer = - scoreState.serving === 'left' - ? leftTeam - ? getServingPlayer(leftTeam, scoreState.leftRightCourtPlayer, scoreState.scoreLeft) - : null - : scoreState.serving === 'right' - ? rightTeam - ? getServingPlayer( - rightTeam, - scoreState.rightRightCourtPlayer, - scoreState.scoreRight, - ) - : null - : null - - const currentReceiver = - scoreState.serving === 'left' - ? rightTeam - ? getReceivingPlayer( - rightTeam, - scoreState.rightRightCourtPlayer, - scoreState.scoreLeft, - ) - : null - : scoreState.serving === 'right' - ? leftTeam - ? getReceivingPlayer( - leftTeam, - scoreState.leftRightCourtPlayer, - scoreState.scoreRight, - ) - : null - : null - + + const currentServer = + scoreState.serving === 'left' + ? leftTeam + ? getServingPlayer(leftTeam, scoreState.leftRightCourtPlayer, scoreState.scoreLeft) + : null + : scoreState.serving === 'right' + ? rightTeam + ? getServingPlayer( + rightTeam, + scoreState.rightRightCourtPlayer, + scoreState.scoreRight, + ) + : null + : null + + const currentReceiver = + scoreState.serving === 'left' + ? rightTeam + ? getReceivingPlayer( + rightTeam, + scoreState.rightRightCourtPlayer, + scoreState.scoreLeft, + ) + : null + : scoreState.serving === 'right' + ? leftTeam + ? getReceivingPlayer( + leftTeam, + scoreState.leftRightCourtPlayer, + scoreState.scoreRight, + ) + : null + : null + useEffect(() => { if (!voiceAnnouncement) { return @@ -268,202 +268,202 @@ export function ScoreboardPage({ voiceSettings.announceServer, voiceSettings.rate, ]) - - if (!selectedGroup) { - return ( -
-
-

Step 3

-

請先回到選隊伍頁面

-

- 目前還沒有帶入要上場的組別,先回去選擇分組,再進入記分板。 -

- - 回到選隊伍 - -
-
- ) - } - - const matchupLabel = - leftTeam && rightTeam - ? `${getTeamDisplayName(leftTeam)} vs ${getTeamDisplayName(rightTeam)}` - : '尚未設定對戰隊伍' - - const openPicker = () => { - setDraftPlayers(currentSelectionOrder.filter((player) => selectablePlayers.includes(player))) - setDraftTargetScore(String(scoreState.targetScore)) - setPickerOpen(true) - } - - const stopFinishHold = () => { - if (finishHoldFrameRef.current !== null) { - window.cancelAnimationFrame(finishHoldFrameRef.current) - finishHoldFrameRef.current = null - } - - if (finishHoldTimerRef.current !== null) { - window.clearTimeout(finishHoldTimerRef.current) - finishHoldTimerRef.current = null - } - - finishHoldStartRef.current = 0 - finishTriggeredRef.current = false - setFinishHoldActive(false) - setFinishHoldProgress(0) - } - - const startFinishHold = () => { - if ( - !canFinishMatch || - finishDialogOpen || - finishDialogUploading || - finishHoldActive - ) { - return - } - - finishTriggeredRef.current = false - finishHoldStartRef.current = performance.now() - setFinishHoldActive(true) - setFinishHoldProgress(0) - - finishHoldTimerRef.current = window.setTimeout(() => { - finishTriggeredRef.current = true - setFinishHoldActive(false) - setFinishHoldProgress(0) - finishHoldTimerRef.current = null - - if (finishHoldFrameRef.current !== null) { - window.cancelAnimationFrame(finishHoldFrameRef.current) - finishHoldFrameRef.current = null - } - - onOpenFinishDialog() - }, FINISH_HOLD_DURATION) - - const tick = (now: number) => { - const elapsed = now - finishHoldStartRef.current - const progress = Math.min(elapsed / FINISH_HOLD_DURATION, 1) - setFinishHoldProgress(progress) - - if (!finishHoldStartRef.current || finishTriggeredRef.current) { - finishHoldFrameRef.current = null - return - } - - finishHoldFrameRef.current = window.requestAnimationFrame(tick) - } - - finishHoldFrameRef.current = window.requestAnimationFrame(tick) - } - - const cancelFinishHold = () => { - if (finishTriggeredRef.current) { - finishTriggeredRef.current = false - return - } - - stopFinishHold() - } - - const toggleDraftPlayer = (playerName: string) => { - setDraftPlayers((current) => { - if (current.includes(playerName)) { - return current.filter((value) => value !== playerName) - } - - if (current.length >= 4) { - return current - } - - return [...current, playerName] - }) - } - - const togglePresetTeam = (team: GroupTeam) => { - setDraftPlayers((current) => { - const removed = removePresetTeamFromDraft(current, team) - - if (removed.length !== current.length) { - return removed - } - - if (current.length >= 4 || current.length % 2 !== 0) { - return current - } - - if (current.includes(team.playerA) || current.includes(team.playerB)) { - return current - } - - return [...current, team.playerA, team.playerB] - }) - } - - const confirmDraftTeams = () => { - if (draftPlayers.length !== 4) { - return - } - - onApplyMatchup( - { - id: -1, - playerA: draftPlayers[0], - playerB: draftPlayers[1], - isPlaceholderA: false, - isPlaceholderB: false, - }, - { - id: -2, - playerA: draftPlayers[3], - playerB: draftPlayers[2], - isPlaceholderA: false, - isPlaceholderB: false, - }, - sanitizeTargetScore(draftTargetScore), - ) - setPickerOpen(false) - } - - const autoPickDraftPlayers = () => { - const shuffled = [...selectablePlayers] - - for (let index = shuffled.length - 1; index > 0; index -= 1) { - const swapIndex = Math.floor(Math.random() * (index + 1)) - ;[shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex], shuffled[index]] - } - - setDraftPlayers(shuffled.slice(0, 4)) - } - - return ( - <> - {streakAnnouncement ? ( -
- {streakAnnouncement.count} 連勝 - {streakAnnouncement.title} - {streakAnnouncement.teamName} -
- ) : null} - - {victoryAnnouncement ? ( -
- 目標分數達成 - {victoryAnnouncement.title} - {victoryAnnouncement.teamName} - {victoryAnnouncement.scoreLabel} -
- ) : null} - -
-
+ + if (!selectedGroup) { + return ( +
+
+

Step 3

+

請先回到選隊伍頁面

+

+ 目前還沒有帶入要上場的組別,先回去選擇分組,再進入記分板。 +

+ + 回到選隊伍 + +
+
+ ) + } + + const matchupLabel = + leftTeam && rightTeam + ? `${getTeamDisplayName(leftTeam)} vs ${getTeamDisplayName(rightTeam)}` + : '尚未設定對戰隊伍' + + const openPicker = () => { + setDraftPlayers(currentSelectionOrder.filter((player) => selectablePlayers.includes(player))) + setDraftTargetScore(String(scoreState.targetScore)) + setPickerOpen(true) + } + + const stopFinishHold = () => { + if (finishHoldFrameRef.current !== null) { + window.cancelAnimationFrame(finishHoldFrameRef.current) + finishHoldFrameRef.current = null + } + + if (finishHoldTimerRef.current !== null) { + window.clearTimeout(finishHoldTimerRef.current) + finishHoldTimerRef.current = null + } + + finishHoldStartRef.current = 0 + finishTriggeredRef.current = false + setFinishHoldActive(false) + setFinishHoldProgress(0) + } + + const startFinishHold = () => { + if ( + !canFinishMatch || + finishDialogOpen || + finishDialogUploading || + finishHoldActive + ) { + return + } + + finishTriggeredRef.current = false + finishHoldStartRef.current = performance.now() + setFinishHoldActive(true) + setFinishHoldProgress(0) + + finishHoldTimerRef.current = window.setTimeout(() => { + finishTriggeredRef.current = true + setFinishHoldActive(false) + setFinishHoldProgress(0) + finishHoldTimerRef.current = null + + if (finishHoldFrameRef.current !== null) { + window.cancelAnimationFrame(finishHoldFrameRef.current) + finishHoldFrameRef.current = null + } + + onOpenFinishDialog() + }, FINISH_HOLD_DURATION) + + const tick = (now: number) => { + const elapsed = now - finishHoldStartRef.current + const progress = Math.min(elapsed / FINISH_HOLD_DURATION, 1) + setFinishHoldProgress(progress) + + if (!finishHoldStartRef.current || finishTriggeredRef.current) { + finishHoldFrameRef.current = null + return + } + + finishHoldFrameRef.current = window.requestAnimationFrame(tick) + } + + finishHoldFrameRef.current = window.requestAnimationFrame(tick) + } + + const cancelFinishHold = () => { + if (finishTriggeredRef.current) { + finishTriggeredRef.current = false + return + } + + stopFinishHold() + } + + const toggleDraftPlayer = (playerName: string) => { + setDraftPlayers((current) => { + if (current.includes(playerName)) { + return current.filter((value) => value !== playerName) + } + + if (current.length >= 4) { + return current + } + + return [...current, playerName] + }) + } + + const togglePresetTeam = (team: GroupTeam) => { + setDraftPlayers((current) => { + const removed = removePresetTeamFromDraft(current, team) + + if (removed.length !== current.length) { + return removed + } + + if (current.length >= 4 || current.length % 2 !== 0) { + return current + } + + if (current.includes(team.playerA) || current.includes(team.playerB)) { + return current + } + + return [...current, team.playerA, team.playerB] + }) + } + + const confirmDraftTeams = () => { + if (draftPlayers.length !== 4) { + return + } + + onApplyMatchup( + { + id: -1, + playerA: draftPlayers[0], + playerB: draftPlayers[1], + isPlaceholderA: false, + isPlaceholderB: false, + }, + { + id: -2, + playerA: draftPlayers[3], + playerB: draftPlayers[2], + isPlaceholderA: false, + isPlaceholderB: false, + }, + sanitizeTargetScore(draftTargetScore), + ) + setPickerOpen(false) + } + + const autoPickDraftPlayers = () => { + const shuffled = [...selectablePlayers] + + for (let index = shuffled.length - 1; index > 0; index -= 1) { + const swapIndex = Math.floor(Math.random() * (index + 1)) + ;[shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex], shuffled[index]] + } + + setDraftPlayers(shuffled.slice(0, 4)) + } + + return ( + <> + {streakAnnouncement ? ( +
+ {streakAnnouncement.count} 連勝 + {streakAnnouncement.title} + {streakAnnouncement.teamName} +
+ ) : null} + + {victoryAnnouncement ? ( +
+ 目標分數達成 + {victoryAnnouncement.title} + {victoryAnnouncement.teamName} + {victoryAnnouncement.scoreLabel} +
+ ) : null} + +
+
- -
-

{scoreState.serving === null ? '請先設定發球方' : '比賽進行中'}

- - {scoreState.serving === null - ? `本場 ${scoreState.targetScore} 分獲勝` - : `發球:${currentServer?.name ?? '-'}${currentReceiver ? ` / 接發:${currentReceiver.name}` : '' - } / 目標 ${scoreState.targetScore} 分`} - -
- + /> + +
+

{scoreState.serving === null ? '請先設定發球方' : '比賽進行中'}

+ + {scoreState.serving === null + ? `本場 ${scoreState.targetScore} 分獲勝` + : `發球:${currentServer?.name ?? '-'}${currentReceiver ? ` / 接發:${currentReceiver.name}` : '' + } / 目標 ${scoreState.targetScore} 分`} + +
+ -
- - -
- - {pickerOpen ? ( - setDraftPlayers([])} - onClose={() => setPickerOpen(false)} - onConfirm={confirmDraftTeams} - onDraftTargetScoreChange={setDraftTargetScore} - onTogglePlayer={toggleDraftPlayer} - onTogglePresetTeam={togglePresetTeam} - /> - ) : null} - - {settingsOpen ? ( - setSettingsOpen(false)} - onUpdateSettings={setVoiceSettings} - /> - ) : null} - - {finishDialogOpen ? ( - - ) : null} - - ) -} - + /> +
+ + +
+ + {pickerOpen ? ( + setDraftPlayers([])} + onClose={() => setPickerOpen(false)} + onConfirm={confirmDraftTeams} + onDraftTargetScoreChange={setDraftTargetScore} + onTogglePlayer={toggleDraftPlayer} + onTogglePresetTeam={togglePresetTeam} + /> + ) : null} + + {settingsOpen ? ( + setSettingsOpen(false)} + onUpdateSettings={setVoiceSettings} + /> + ) : null} + + {finishDialogOpen ? ( + + ) : null} + + ) +} + type ScoreboardTeamPanelProps = { assignments: Array<{ slot: PlayerSlot; name: string; court: CourtSide }> canArrangeMatch: boolean @@ -641,17 +641,17 @@ type ScoreboardTeamPanelProps = { currentServer: string | null hasInitialServing: boolean onRecordPoint: () => void - onSetServing: () => void - onSwapPlayers: () => void - onSwapTeams: () => void - score: number - serviceCourt: CourtSide | null - showServingPrompt: boolean - team: GroupTeam | null - teamSlot: 'top' | 'bottom' -} - -function ScoreboardTeamPanel({ + onSetServing: () => void + onSwapPlayers: () => void + onSwapTeams: () => void + score: number + serviceCourt: CourtSide | null + showServingPrompt: boolean + team: GroupTeam | null + teamSlot: 'top' | 'bottom' +} + +function ScoreboardTeamPanel({ assignments, canArrangeMatch, canScore, @@ -660,64 +660,64 @@ function ScoreboardTeamPanel({ currentServer, hasInitialServing, onRecordPoint, - onSetServing, - onSwapPlayers, - onSwapTeams, - score, - serviceCourt, - showServingPrompt, - team, - teamSlot, -}: ScoreboardTeamPanelProps) { - const orderedAssignments = [...assignments].sort((left, right) => { - if (left.court === right.court) { - return 0 - } - - return left.court === 'left' ? -1 : 1 - }) - - const header = ( -
-
- {orderedAssignments.map((assignment) => ( -
+ onSetServing, + onSwapPlayers, + onSwapTeams, + score, + serviceCourt, + showServingPrompt, + team, + teamSlot, +}: ScoreboardTeamPanelProps) { + const orderedAssignments = [...assignments].sort((left, right) => { + if (left.court === right.court) { + return 0 + } + + return left.court === 'left' ? -1 : 1 + }) + + const header = ( +
+
+ {orderedAssignments.map((assignment) => ( +
{getPlayerNumber(teamSlot, assignment.court)} - {assignment.name} -
- ))} -
- -
- - -
-
- ) - + {assignment.name} +
+ ))} +
+ +
+ + +
+
+ ) + const serveBar = ( - ) - - const scoreBoard = ( - - ) - - return ( -
- {teamSlot === 'top' ? ( - <> - {header} - {serveBar} - {scoreBoard} - - ) : ( - <> - {scoreBoard} - {serveBar} - {header} - - )} -
- ) -} - -type TeamPickerModalProps = { - draftPlayers: string[] - draftTargetScore: string - group: RoundGroup - presetTeams: GroupTeam[] - selectablePlayers: string[] - selectionCount: number - sourceLabel: string - targetDate: string - onAutoPick: () => void - onClear: () => void - onClose: () => void - onConfirm: () => void - onDraftTargetScoreChange: (value: string) => void - onTogglePlayer: (playerName: string) => void - onTogglePresetTeam: (team: GroupTeam) => void -} - -function TeamPickerModal({ - draftPlayers, - draftTargetScore, - group, - presetTeams, - selectablePlayers, - selectionCount, - sourceLabel, - targetDate, - onAutoPick, - onClear, - onClose, - onConfirm, - onDraftTargetScoreChange, - onTogglePlayer, - onTogglePresetTeam, -}: TeamPickerModalProps) { - return ( -
-
event.stopPropagation()} - role="dialog" - > - - -
- {selectionCount >= 4 ? '已完成選擇' : '請選滿 4 位球員'} -
- -
-
-
- {selectionCount}/4 -
- 依序選擇球員 -

- 第 {group.id} 組 / {sourceLabel} / {targetDate || '-'} -

-
-
- -
- -
- -
- {selectablePlayers.map((playerName) => { - const checked = draftPlayers.includes(playerName) - const selectedOrder = checked ? draftPlayers.indexOf(playerName) + 1 : null - - return ( - - ) - })} -
- -
- - -
-
- - -
-
-
- ) -} - -type VoiceSettingsModalProps = { - settings: VoiceSettings - onClose: () => void - onUpdateSettings: Dispatch> -} - -function VoiceSettingsModal({ - settings, - onClose, - onUpdateSettings, -}: VoiceSettingsModalProps) { - return ( -
-
event.stopPropagation()} - > - - -

語音設定

-

播報內容

- - - - - - -
-
- ) -} - -type FinishDialogProps = { - error: string - leftScore: number - leftTeamName: string - matchupLabel: string - rightScore: number - rightTeamName: string - uploading: boolean - onClose: () => void - onConfirm: () => void - onSkip: () => void -} - -function FinishDialog({ - error, - leftScore, - leftTeamName, - matchupLabel, - rightScore, - rightTeamName, - uploading, - onClose, - onConfirm, - onSkip, -}: FinishDialogProps) { - return ( -
-
- - -

比賽結算

-

{matchupLabel}

- -
-
- {leftScore} - {leftTeamName} -
-
:
-
- {rightScore} - {rightTeamName} -
-
- -

要不要把這場比賽戰績上傳到資料庫?

- - {error ?

{error}

: null} - -
- - -
-
-
- ) -} - + + ) + + const scoreBoard = ( + + ) + + return ( +
+ {teamSlot === 'top' ? ( + <> + {header} + {serveBar} + {scoreBoard} + + ) : ( + <> + {scoreBoard} + {serveBar} + {header} + + )} +
+ ) +} + +type TeamPickerModalProps = { + draftPlayers: string[] + draftTargetScore: string + group: RoundGroup + presetTeams: GroupTeam[] + selectablePlayers: string[] + selectionCount: number + sourceLabel: string + targetDate: string + onAutoPick: () => void + onClear: () => void + onClose: () => void + onConfirm: () => void + onDraftTargetScoreChange: (value: string) => void + onTogglePlayer: (playerName: string) => void + onTogglePresetTeam: (team: GroupTeam) => void +} + +function TeamPickerModal({ + draftPlayers, + draftTargetScore, + group, + presetTeams, + selectablePlayers, + selectionCount, + sourceLabel, + targetDate, + onAutoPick, + onClear, + onClose, + onConfirm, + onDraftTargetScoreChange, + onTogglePlayer, + onTogglePresetTeam, +}: TeamPickerModalProps) { + return ( +
+
event.stopPropagation()} + role="dialog" + > + + +
+ {selectionCount >= 4 ? '已完成選擇' : '請選滿 4 位球員'} +
+ +
+
+
+ {selectionCount}/4 +
+ 依序選擇球員 +

+ 第 {group.id} 組 / {sourceLabel} / {targetDate || '-'} +

+
+
+ +
+ +
+ +
+ {selectablePlayers.map((playerName) => { + const checked = draftPlayers.includes(playerName) + const selectedOrder = checked ? draftPlayers.indexOf(playerName) + 1 : null + + return ( + + ) + })} +
+ +
+ + +
+
+ + +
+
+
+ ) +} + +type VoiceSettingsModalProps = { + settings: VoiceSettings + onClose: () => void + onUpdateSettings: Dispatch> +} + +function VoiceSettingsModal({ + settings, + onClose, + onUpdateSettings, +}: VoiceSettingsModalProps) { + return ( +
+
event.stopPropagation()} + > + + +

語音設定

+

播報內容

+ + + + + + +
+
+ ) +} + +type FinishDialogProps = { + error: string + leftScore: number + leftTeamName: string + matchupLabel: string + rightScore: number + rightTeamName: string + uploading: boolean + onClose: () => void + onConfirm: () => void + onSkip: () => void +} + +function FinishDialog({ + error, + leftScore, + leftTeamName, + matchupLabel, + rightScore, + rightTeamName, + uploading, + onClose, + onConfirm, + onSkip, +}: FinishDialogProps) { + return ( +
+
+ + +

比賽結算

+

{matchupLabel}

+ +
+
+ {leftScore} + {leftTeamName} +
+
:
+
+ {rightScore} + {rightTeamName} +
+
+ +

要不要把這場比賽戰績上傳到資料庫?

+ + {error ?

{error}

: null} + +
+ + +
+
+
+ ) +} + function getPlayerNumber(teamSlot: 'top' | 'bottom', court: CourtSide) { if (teamSlot === 'top') { return court === 'left' ? 1 : 2 @@ -1119,100 +1119,100 @@ function getPlayerNumber(teamSlot: 'top' | 'bottom', court: CourtSide) { return court === 'right' ? 3 : 4 } - -function sanitizeTargetScore(value: string) { - const numeric = Number.parseInt(value.replace(/[^\d]/g, ''), 10) - - if (Number.isNaN(numeric)) { - return 21 - } - - return Math.min(99, Math.max(1, numeric)) -} - -function removePresetTeamFromDraft(players: string[], team: GroupTeam) { - const firstPairSelected = players[0] === team.playerA && players[1] === team.playerB - const secondPairSelected = players[2] === team.playerA && players[3] === team.playerB - - if (firstPairSelected) { - return players.slice(2) - } - - if (secondPairSelected) { - return players.slice(0, 2) - } - - return players -} - -function getPresetTeamSelectionSlot(players: string[], team: GroupTeam) { - if (players[0] === team.playerA && players[1] === team.playerB) { - return 0 - } - - if (players[2] === team.playerA && players[3] === team.playerB) { - return 1 - } - - return null -} - -function formatClock() { - return new Date().toLocaleTimeString('zh-TW', { - hour: '2-digit', - minute: '2-digit', - hour12: false, - }) -} - -function loadVoiceSettings(): VoiceSettings { - try { - const raw = window.localStorage.getItem(VOICE_SETTINGS_STORAGE_KEY) - - if (!raw) { - return defaultVoiceSettings - } - - const parsed = JSON.parse(raw) as Partial - - return { - announceScore: parsed.announceScore ?? defaultVoiceSettings.announceScore, - announceServer: parsed.announceServer ?? defaultVoiceSettings.announceServer, - rate: - typeof parsed.rate === 'number' - ? Math.min(10, Math.max(0.7, parsed.rate)) - : defaultVoiceSettings.rate, - } - } catch { - return defaultVoiceSettings - } -} - + +function sanitizeTargetScore(value: string) { + const numeric = Number.parseInt(value.replace(/[^\d]/g, ''), 10) + + if (Number.isNaN(numeric)) { + return 21 + } + + return Math.min(99, Math.max(1, numeric)) +} + +function removePresetTeamFromDraft(players: string[], team: GroupTeam) { + const firstPairSelected = players[0] === team.playerA && players[1] === team.playerB + const secondPairSelected = players[2] === team.playerA && players[3] === team.playerB + + if (firstPairSelected) { + return players.slice(2) + } + + if (secondPairSelected) { + return players.slice(0, 2) + } + + return players +} + +function getPresetTeamSelectionSlot(players: string[], team: GroupTeam) { + if (players[0] === team.playerA && players[1] === team.playerB) { + return 0 + } + + if (players[2] === team.playerA && players[3] === team.playerB) { + return 1 + } + + return null +} + +function formatClock() { + return new Date().toLocaleTimeString('zh-TW', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }) +} + +function loadVoiceSettings(): VoiceSettings { + try { + const raw = window.localStorage.getItem(VOICE_SETTINGS_STORAGE_KEY) + + if (!raw) { + return defaultVoiceSettings + } + + const parsed = JSON.parse(raw) as Partial + + return { + announceScore: parsed.announceScore ?? defaultVoiceSettings.announceScore, + announceServer: parsed.announceServer ?? defaultVoiceSettings.announceServer, + rate: + typeof parsed.rate === 'number' + ? Math.min(10, Math.max(0.7, parsed.rate)) + : defaultVoiceSettings.rate, + } + } catch { + return defaultVoiceSettings + } +} + function getSpeechName(name: string) { return SPEECH_NAME_MAP[name.trim().toLowerCase()] ?? name } - -function speakAnnouncement(message: string, rate: number) { - if (!('speechSynthesis' in window)) { - return - } - - const synthesis = window.speechSynthesis - const utterance = new SpeechSynthesisUtterance(message) - const voices = synthesis.getVoices() - const zhVoice = - voices.find((voice) => voice.lang.toLowerCase().startsWith('zh-tw')) ?? - voices.find((voice) => voice.lang.toLowerCase().startsWith('zh')) - - utterance.lang = zhVoice?.lang ?? 'zh-TW' - utterance.rate = rate - utterance.pitch = 1 - utterance.volume = 1 - - if (zhVoice) { - utterance.voice = zhVoice - } - - synthesis.cancel() - synthesis.speak(utterance) -} + +function speakAnnouncement(message: string, rate: number) { + if (!('speechSynthesis' in window)) { + return + } + + const synthesis = window.speechSynthesis + const utterance = new SpeechSynthesisUtterance(message) + const voices = synthesis.getVoices() + const zhVoice = + voices.find((voice) => voice.lang.toLowerCase().startsWith('zh-tw')) ?? + voices.find((voice) => voice.lang.toLowerCase().startsWith('zh')) + + utterance.lang = zhVoice?.lang ?? 'zh-TW' + utterance.rate = rate + utterance.pitch = 1 + utterance.volume = 1 + + if (zhVoice) { + utterance.voice = zhVoice + } + + synthesis.cancel() + synthesis.speak(utterance) +} diff --git a/src/types.ts b/src/types.ts index d3659ea..623f85c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,40 +1,40 @@ -export type LoadStatus = 'idle' | 'loading' | 'loaded' | 'empty' | 'error' - -export type ScoreSide = 'left' | 'right' - -export type PlayerSlot = 'playerA' | 'playerB' - -export type CourtSide = 'left' | 'right' - -export type GroupTeam = { - id: number - playerA: string - playerB: string - isPlaceholderA: boolean - isPlaceholderB: boolean -} - -export type RoundGroup = { - id: number - teams: GroupTeam[] -} - -export type MatchResultsRecord = { - time: number - personnel: string - battlecombination: string | null -} - -export type Matchup = { - leftTeamId: number | null - rightTeamId: number | null -} - -export type ActiveMatchup = { - leftTeam: GroupTeam | null - rightTeam: GroupTeam | null -} - +export type LoadStatus = 'idle' | 'loading' | 'loaded' | 'empty' | 'error' + +export type ScoreSide = 'left' | 'right' + +export type PlayerSlot = 'playerA' | 'playerB' + +export type CourtSide = 'left' | 'right' + +export type GroupTeam = { + id: number + playerA: string + playerB: string + isPlaceholderA: boolean + isPlaceholderB: boolean +} + +export type RoundGroup = { + id: number + teams: GroupTeam[] +} + +export type MatchResultsRecord = { + time: number + personnel: string + battlecombination: string | null +} + +export type Matchup = { + leftTeamId: number | null + rightTeamId: number | null +} + +export type ActiveMatchup = { + leftTeam: GroupTeam | null + rightTeam: GroupTeam | null +} + export type ScoreState = { scoreLeft: number scoreRight: number @@ -47,117 +47,117 @@ export type ScoreState = { leftRightCourtPlayer: PlayerSlot rightRightCourtPlayer: PlayerSlot } - -export type PointHistoryEntry = { - round: number - starter: number - winCount: number - winner: 0 | 1 -} - -export type ScoreSnapshot = { - pointLog: PointHistoryEntry[] - scoreState: ScoreState -} - -export type MatchHistoryItem = { - id: string - playedAt: string - matchDate: string - source: 'db' | 'manual' | 'idle' - groupId: number - leftTeamName: string - rightTeamName: string - scoreLeft: number - scoreRight: number - winner: string -} - -export type HistoryUploadPayload = { - dayOfWeek: number - players: string[] - score: [number, number] - scoreList: Array<[number, number, number, 0 | 1]> - team: [string[], string[]] - time: number - type: 0 | 1 - winScore: number -} - -export type HistoryUploadResponse = { - id: number -} - -export type HistoryRecord = { - id: number - time: number - dayOfWeek: number - score: string - winScore: number - type: 0 | 1 - players: string - team: string - scoreList: string | null -} - -export type HistoryListItem = { - id: number - time: number - playedAt: string - dayOfWeek: number - dayLabel: string - score: [number, number] - winScore: number - type: 0 | 1 - typeLabel: string - players: string[] - team: [string[], string[]] - scoreList: Array<[number, number, number, 0 | 1]> - leftTeamName: string - rightTeamName: string - winnerTeamName: string -} - -export type LiveRoomStatus = 'live' | 'finished' - -export type LiveRoomSession = { - hostToken: string - roomId: string - status: LiveRoomStatus -} - -export type LiveRoomSummary = { - roomId: string - createdAt: string - leftTeamName: string - rightTeamName: string - scoreLeft: number - scoreRight: number - status: LiveRoomStatus - targetScore: number - updatedAt: string -} - -export type LiveRoomPayload = { - groupId: number | null - leftTeamName: string - matchupLabel: string - pointLog: PointHistoryEntry[] - rightTeamName: string - scoreState: ScoreState - targetDate: string -} - -export type LiveRoomDetail = LiveRoomPayload & { - createdAt: string - roomId: string - status: LiveRoomStatus - updatedAt: string - winnerTeamName: string | null -} - -export type LiveRoomUpdatePayload = LiveRoomPayload & { - hostToken: string - status: LiveRoomStatus - winnerTeamName: string | null -} + +export type PointHistoryEntry = { + round: number + starter: number + winCount: number + winner: 0 | 1 +} + +export type ScoreSnapshot = { + pointLog: PointHistoryEntry[] + scoreState: ScoreState +} + +export type MatchHistoryItem = { + id: string + playedAt: string + matchDate: string + source: 'db' | 'manual' | 'idle' + groupId: number + leftTeamName: string + rightTeamName: string + scoreLeft: number + scoreRight: number + winner: string +} + +export type HistoryUploadPayload = { + dayOfWeek: number + players: string[] + score: [number, number] + scoreList: Array<[number, number, number, 0 | 1]> + team: [string[], string[]] + time: number + type: 0 | 1 + winScore: number +} + +export type HistoryUploadResponse = { + id: number +} + +export type HistoryRecord = { + id: number + time: number + dayOfWeek: number + score: string + winScore: number + type: 0 | 1 + players: string + team: string + scoreList: string | null +} + +export type HistoryListItem = { + id: number + time: number + playedAt: string + dayOfWeek: number + dayLabel: string + score: [number, number] + winScore: number + type: 0 | 1 + typeLabel: string + players: string[] + team: [string[], string[]] + scoreList: Array<[number, number, number, 0 | 1]> + leftTeamName: string + rightTeamName: string + winnerTeamName: string +} + +export type LiveRoomStatus = 'live' | 'finished' + +export type LiveRoomSession = { + hostToken: string + roomId: string + status: LiveRoomStatus +} + +export type LiveRoomSummary = { + roomId: string + createdAt: string + leftTeamName: string + rightTeamName: string + scoreLeft: number + scoreRight: number + status: LiveRoomStatus + targetScore: number + updatedAt: string +} + +export type LiveRoomPayload = { + groupId: number | null + leftTeamName: string + matchupLabel: string + pointLog: PointHistoryEntry[] + rightTeamName: string + scoreState: ScoreState + targetDate: string +} + +export type LiveRoomDetail = LiveRoomPayload & { + createdAt: string + roomId: string + status: LiveRoomStatus + updatedAt: string + winnerTeamName: string | null +} + +export type LiveRoomUpdatePayload = LiveRoomPayload & { + hostToken: string + status: LiveRoomStatus + winnerTeamName: string | null +}