diff --git a/README.md b/README.md
index ea263d4..077e20c 100644
--- a/README.md
+++ b/README.md
@@ -16,8 +16,10 @@
- 需先指定先攻,之後點擊分數即可直接加分。
- 第一分記錄後,右側 `設定隊伍` 會切成 `上一步`。
- 可交換上下隊伍位置,也可交換同隊左右球員位置。
- - `比賽結算` 為防誤觸設計,需長按 `1.5 秒` 才會觸發。
+ - `比賽結算` 為防誤觸設計,需長按 `1 秒` 才會觸發。
+ - 比分仍是 `0:0` 時,不會啟動比賽結算長按。
- 長按 `比賽結算` 時,按鈕下方會顯示進度條,按鈕本身也會有亮起與下壓的視覺回饋。
+ - 手機長按 `比賽結算` 不會再觸發文字選取。
- 連勝會出現特效提示:
- `3 連勝`:`大殺特殺`
- `4 連勝`:`暴走`
@@ -37,7 +39,9 @@
- PWA
- 可加入手機主畫面,像 App 一樣開啟。
- 支援 `manifest`、`service worker`、主畫面 icon。
+ - 網頁 favicon 與 PWA icon 已改用 `ICON.png` 產生的 PNG 圖示。
- 偵測到新版本時,畫面底部會顯示更新提示,可一鍵重新整理套用新版。
+ - 前端會定期輪詢 `/api/version`,只要重新部署並重建 app container,就能偵測到新版本。
## 執行環境
@@ -104,6 +108,8 @@ sudo docker compose up -d --build
https://你的網域或 NAS IP:3501
```
+每次執行 `sudo docker compose up -d --build` 重建 app container 時,後端都會帶新的容器啟動版號;前端輪詢發現版號不同後,就會跳出更新提示。
+
## SSL 憑證目錄
Docker Compose 會直接掛載 NAS 上的憑證目錄:
diff --git a/index.html b/index.html
index 328cda9..5dae752 100644
--- a/index.html
+++ b/index.html
@@ -2,7 +2,7 @@
-
+
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png
index 865ef0d..429c146 100644
Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ
diff --git a/public/icon.png b/public/icon.png
new file mode 100644
index 0000000..690cc2a
Binary files /dev/null and b/public/icon.png differ
diff --git a/public/pwa-192.png b/public/pwa-192.png
index c6f35d3..0d6a41d 100644
Binary files a/public/pwa-192.png and b/public/pwa-192.png differ
diff --git a/public/pwa-512.png b/public/pwa-512.png
index f5eec54..b47be7c 100644
Binary files a/public/pwa-512.png and b/public/pwa-512.png differ
diff --git a/public/sw.js b/public/sw.js
index 2703855..7eec742 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -3,7 +3,8 @@ const APP_SHELL = [
'/',
'/index.html',
'/manifest.webmanifest',
- '/favicon.svg',
+ '/favicon.png',
+ '/icon.png',
'/apple-touch-icon.png',
'/pwa-192.png',
'/pwa-512.png',
diff --git a/server/server.mjs b/server/server.mjs
index 5d220d9..6a0f161 100644
--- a/server/server.mjs
+++ b/server/server.mjs
@@ -9,6 +9,8 @@ const app = express()
const port = Number(process.env.PORT ?? process.env.SERVER_PORT ?? 8788)
const matchTableName = process.env.DB_TABLE ?? 'badminton'
const historyTableName = process.env.DB_HISTORY_TABLE ?? 'history'
+const appVersion = process.env.APP_VERSION ?? `${Date.now()}`
+const appStartedAt = new Date().toISOString()
const currentFilePath = fileURLToPath(import.meta.url)
const currentDir = path.dirname(currentFilePath)
@@ -37,6 +39,8 @@ app.use(express.json())
app.get('/api/health', (_request, response) => {
response.json({
+ appStartedAt,
+ appVersion,
ok: true,
dbReady: Boolean(pool),
distReady,
@@ -46,6 +50,21 @@ app.get('/api/health', (_request, response) => {
})
})
+app.get('/api/version', (_request, response) => {
+ response.set({
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
+ Expires: '0',
+ Pragma: 'no-cache',
+ 'Surrogate-Control': 'no-store',
+ })
+
+ response.json({
+ ok: true,
+ startedAt: appStartedAt,
+ version: appVersion,
+ })
+})
+
app.get('/api/match-results/:time', async (request, response) => {
if (!pool) {
response.status(500).json({
diff --git a/src/App.css b/src/App.css
index e80b8bc..c5ce49f 100644
--- a/src/App.css
+++ b/src/App.css
@@ -948,6 +948,10 @@
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;
@@ -964,6 +968,9 @@
.rail-pill-hold-wrap {
display: grid;
gap: 8px;
+ user-select: none;
+ -webkit-user-select: none;
+ -webkit-touch-callout: none;
}
.rail-pill-hold-wrap-active {
diff --git a/src/App.tsx b/src/App.tsx
index f2fa12d..5048c98 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from 'react'
+import { useEffect, useMemo, useRef, useState } from 'react'
import { NavLink, Route, Routes, useLocation } from 'react-router-dom'
import './App.css'
import { loadMatchResults, saveMatchHistory } from './lib/api'
@@ -80,6 +80,7 @@ const STREAK_TITLES: Record = {
8: '成為傳說',
}
const PWA_UPDATE_EVENT = 'badminton-scoreboard:pwa-update-ready'
+const APP_VERSION_POLL_MS = 30000
function App() {
const location = useLocation()
@@ -117,6 +118,7 @@ function App() {
const [streakAnnouncement, setStreakAnnouncement] = useState(null)
const [victoryAnnouncement, setVictoryAnnouncement] = useState(null)
const [pwaUpdateReady, setPwaUpdateReady] = useState(false)
+ const currentAppVersionRef = useRef(null)
const parsedAreaA = useMemo(() => parseRoster(areaAInput), [areaAInput])
const parsedAreaB = useMemo(() => parseRoster(areaBInput), [areaBInput])
@@ -188,6 +190,57 @@ function App() {
}
}, [])
+ 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) => {
setScoreState(nextState)
setScoreHistory([])
diff --git a/src/pages/ScoreboardPage.tsx b/src/pages/ScoreboardPage.tsx
index cb9c704..8124445 100644
--- a/src/pages/ScoreboardPage.tsx
+++ b/src/pages/ScoreboardPage.tsx
@@ -1,1197 +1,1226 @@
-import { useEffect, useMemo, useRef, useState } from 'react'
import type { Dispatch, SetStateAction } from 'react'
+import { useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import {
- getCourtAssignments,
- getReceivingPlayer,
- getServiceCourt,
- getServingPlayer,
- getTeamDisplayName,
+ getCourtAssignments,
+ getReceivingPlayer,
+ getServiceCourt,
+ getServingPlayer,
+ getTeamDisplayName,
} from '../lib/match'
import type {
- CourtSide,
- GroupTeam,
- PlayerSlot,
- RoundGroup,
- ScoreSide,
- ScoreState,
+ CourtSide,
+ GroupTeam,
+ PlayerSlot,
+ RoundGroup,
+ ScoreSide,
+ ScoreState,
} from '../types'
type VoiceSettings = {
- announceScore: boolean
- announceServer: boolean
- rate: number
+ announceScore: boolean
+ announceServer: boolean
+ rate: number
}
const VOICE_SETTINGS_STORAGE_KEY = 'badminton-scoreboard::voice-settings'
const defaultVoiceSettings: VoiceSettings = {
- announceScore: true,
- announceServer: true,
- rate: 1,
+ announceScore: true,
+ announceServer: true,
+ rate: 1,
}
const SPEECH_NAME_MAP: Record = {
- ruru: '嚕嚕',
+ ruru: '嚕嚕',
}
type ScoreboardPageProps = {
- currentSelectionOrder: string[]
- finishDialogError: string
- finishDialogOpen: boolean
- finishDialogUploading: boolean
- groupSource: 'idle' | 'db' | 'manual'
- hasRecordedPoint: boolean
- leftTeam: GroupTeam | null
- rightTeam: GroupTeam | null
- scoreState: ScoreState
- selectedGroup: RoundGroup | null
- streakAnnouncement: {
- count: number
- key: number
- teamName: string
- title: string
- } | null
- victoryAnnouncement: {
- key: number
- scoreLabel: string
- teamName: string
- title: 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
+ currentSelectionOrder: string[]
+ finishDialogError: string
+ finishDialogOpen: boolean
+ finishDialogUploading: boolean
+ groupSource: 'idle' | 'db' | 'manual'
+ hasRecordedPoint: boolean
+ leftTeam: GroupTeam | null
+ rightTeam: GroupTeam | null
+ scoreState: ScoreState
+ selectedGroup: RoundGroup | null
+ streakAnnouncement: {
+ count: number
+ key: number
+ teamName: string
+ title: string
+ } | null
+ victoryAnnouncement: {
+ key: number
+ scoreLabel: string
+ teamName: string
+ title: 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,
- rightTeam,
- scoreState,
- selectedGroup,
- streakAnnouncement,
- victoryAnnouncement,
- targetDate,
- onApplyMatchup,
- onCloseFinishDialog,
- onConfirmUpload,
- onOpenFinishDialog,
- onRecordPoint,
- onSetServing,
- onSkipUpload,
- onSwapMatchup,
- onSwapTeamPlayers,
- onUndoLastPoint,
+ currentSelectionOrder,
+ finishDialogError,
+ finishDialogOpen,
+ finishDialogUploading,
+ groupSource,
+ hasRecordedPoint,
+ leftTeam,
+ rightTeam,
+ scoreState,
+ selectedGroup,
+ streakAnnouncement,
+ victoryAnnouncement,
+ targetDate,
+ onApplyMatchup,
+ onCloseFinishDialog,
+ onConfirmUpload,
+ onOpenFinishDialog,
+ onRecordPoint,
+ onSetServing,
+ onSkipUpload,
+ onSwapMatchup,
+ onSwapTeamPlayers,
+ onUndoLastPoint,
}: ScoreboardPageProps) {
- const FINISH_HOLD_DURATION = 1500
- 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 finishHoldStartRef = useRef(0)
- const finishTriggeredRef = useRef(false)
- const lastAnnouncedPointRef = useRef(0)
- const previousScoresRef = useRef({ left: 0, right: 0 })
+ 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)
+ const lastAnnouncedPointRef = useRef(0)
+ const previousScoresRef = useRef({ left: 0, right: 0 })
- useEffect(() => {
- const timer = window.setInterval(() => {
- setClock(formatClock())
- }, 1000)
+ useEffect(() => {
+ const timer = window.setInterval(() => {
+ setClock(formatClock())
+ }, 1000)
- return () => window.clearInterval(timer)
- }, [])
+ return () => window.clearInterval(timer)
+ }, [])
- useEffect(() => {
- window.localStorage.setItem(
- VOICE_SETTINGS_STORAGE_KEY,
- JSON.stringify(voiceSettings),
- )
- }, [voiceSettings])
+ useEffect(() => {
+ window.localStorage.setItem(
+ VOICE_SETTINGS_STORAGE_KEY,
+ JSON.stringify(voiceSettings),
+ )
+ }, [voiceSettings])
- useEffect(() => {
- return () => {
- if (finishHoldFrameRef.current !== null) {
- window.cancelAnimationFrame(finishHoldFrameRef.current)
- }
+ useEffect(() => {
+ return () => {
+ if (finishHoldFrameRef.current !== null) {
+ window.cancelAnimationFrame(finishHoldFrameRef.current)
+ }
- if ('speechSynthesis' in window) {
- window.speechSynthesis.cancel()
- }
- }
- }, [])
+ if (finishHoldTimerRef.current !== null) {
+ window.clearTimeout(finishHoldTimerRef.current)
+ }
- const selectablePlayers = useMemo(() => {
- if (!selectedGroup) {
- return []
- }
+ if ('speechSynthesis' in window) {
+ window.speechSynthesis.cancel()
+ }
+ }
+ }, [])
- const seen = new Set()
- const players: string[] = []
+ const selectablePlayers = useMemo(() => {
+ if (!selectedGroup) {
+ return []
+ }
- selectedGroup.teams.forEach((team) => {
- if (!team.isPlaceholderA && !seen.has(team.playerA)) {
- seen.add(team.playerA)
- players.push(team.playerA)
- }
+ const seen = new Set()
+ const players: string[] = []
- if (!team.isPlaceholderB && !seen.has(team.playerB)) {
- seen.add(team.playerB)
- players.push(team.playerB)
- }
- })
+ selectedGroup.teams.forEach((team) => {
+ if (!team.isPlaceholderA && !seen.has(team.playerA)) {
+ seen.add(team.playerA)
+ players.push(team.playerA)
+ }
- return players
- }, [selectedGroup])
+ if (!team.isPlaceholderB && !seen.has(team.playerB)) {
+ seen.add(team.playerB)
+ players.push(team.playerB)
+ }
+ })
- const presetTeams = useMemo(
- () =>
- selectedGroup?.teams.filter((team) => !team.isPlaceholderA && !team.isPlaceholderB) ?? [],
- [selectedGroup],
- )
+ return players
+ }, [selectedGroup])
- const canArrangeMatch = !hasRecordedPoint
- const canScore = scoreState.serving !== null
+ const presetTeams = useMemo(
+ () =>
+ selectedGroup?.teams.filter((team) => !team.isPlaceholderA && !team.isPlaceholderB) ?? [],
+ [selectedGroup],
+ )
- const servingScore =
- scoreState.serving === 'left' ? scoreState.scoreLeft : scoreState.scoreRight
- const servingCourt =
- scoreState.serving === null ? null : getServiceCourt(servingScore)
+ const canArrangeMatch = !hasRecordedPoint
+ const canScore = scoreState.serving !== null
+ const canFinishMatch = scoreState.scoreLeft > 0 || scoreState.scoreRight > 0
- const leftAssignments = useMemo(
- () =>
- leftTeam ? getCourtAssignments(leftTeam, scoreState.leftRightCourtPlayer) : [],
- [leftTeam, scoreState.leftRightCourtPlayer],
- )
- const rightAssignments = useMemo(
- () =>
- rightTeam ? getCourtAssignments(rightTeam, scoreState.rightRightCourtPlayer) : [],
- [rightTeam, scoreState.rightRightCourtPlayer],
- )
+ const servingScore =
+ scoreState.serving === 'left' ? scoreState.scoreLeft : scoreState.scoreRight
+ const servingCourt =
+ scoreState.serving === null ? null : getServiceCourt(servingScore)
- 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 leftAssignments = useMemo(
+ () =>
+ leftTeam ? getCourtAssignments(leftTeam, scoreState.leftRightCourtPlayer) : [],
+ [leftTeam, scoreState.leftRightCourtPlayer],
+ )
+ const rightAssignments = useMemo(
+ () =>
+ rightTeam ? getCourtAssignments(rightTeam, scoreState.rightRightCourtPlayer) : [],
+ [rightTeam, scoreState.rightRightCourtPlayer],
+ )
- 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
- useEffect(() => {
- const totalPoints = scoreState.scoreLeft + scoreState.scoreRight
+ 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
- if (scoreState.serving === null || !currentServer?.name || totalPoints === 0) {
- lastAnnouncedPointRef.current = totalPoints
- previousScoresRef.current = {
- left: scoreState.scoreLeft,
- right: scoreState.scoreRight,
- }
- return
- }
+ useEffect(() => {
+ const totalPoints = scoreState.scoreLeft + scoreState.scoreRight
- if (lastAnnouncedPointRef.current === totalPoints) {
- return
- }
+ if (scoreState.serving === null || !currentServer?.name || totalPoints === 0) {
+ lastAnnouncedPointRef.current = totalPoints
+ previousScoresRef.current = {
+ left: scoreState.scoreLeft,
+ right: scoreState.scoreRight,
+ }
+ return
+ }
- lastAnnouncedPointRef.current = totalPoints
+ if (lastAnnouncedPointRef.current === totalPoints) {
+ return
+ }
- const scorerSide =
- scoreState.scoreLeft > previousScoresRef.current.left
- ? 'left'
- : scoreState.scoreRight > previousScoresRef.current.right
- ? 'right'
- : null
+ lastAnnouncedPointRef.current = totalPoints
- previousScoresRef.current = {
- left: scoreState.scoreLeft,
- right: scoreState.scoreRight,
- }
+ const scorerSide =
+ scoreState.scoreLeft > previousScoresRef.current.left
+ ? 'left'
+ : scoreState.scoreRight > previousScoresRef.current.right
+ ? 'right'
+ : null
- const parts: string[] = []
+ previousScoresRef.current = {
+ left: scoreState.scoreLeft,
+ right: scoreState.scoreRight,
+ }
- if (voiceSettings.announceScore && scorerSide) {
- parts.push(
- `${getAnnouncementName(scorerSide === 'left' ? leftTeam : rightTeam)}得分`,
- )
- }
+ const parts: string[] = []
- if (voiceSettings.announceServer) {
- parts.push(`${getSpeechName(currentServer.name)}發球`)
- }
+ if (voiceSettings.announceScore && scorerSide) {
+ parts.push(
+ `${getAnnouncementName(scorerSide === 'left' ? leftTeam : rightTeam)}得分`,
+ )
+ }
- if (parts.length > 0) {
- speakAnnouncement(parts.join(','), voiceSettings.rate)
- }
- }, [
- currentServer?.name,
- leftTeam,
- rightTeam,
- scoreState.scoreLeft,
- scoreState.scoreRight,
- scoreState.serving,
- voiceSettings.announceScore,
- voiceSettings.announceServer,
- voiceSettings.rate,
- ])
+ if (voiceSettings.announceServer) {
+ parts.push(`${getSpeechName(currentServer.name)}發球`)
+ }
- if (!selectedGroup) {
- return (
-
-
- Step 3
- 請先回到選隊伍頁面
-
- 目前還沒有帶入要上場的組別,先回去選擇分組,再進入記分板。
-
-
- 回到選隊伍
-
-
-
- )
- }
+ if (parts.length > 0) {
+ speakAnnouncement(parts.join(','), voiceSettings.rate)
+ }
+ }, [
+ currentServer?.name,
+ leftTeam,
+ rightTeam,
+ scoreState.scoreLeft,
+ scoreState.scoreRight,
+ scoreState.serving,
+ voiceSettings.announceScore,
+ voiceSettings.announceServer,
+ voiceSettings.rate,
+ ])
- const matchupLabel =
- leftTeam && rightTeam
- ? `${getTeamDisplayName(leftTeam)} vs ${getTeamDisplayName(rightTeam)}`
- : '尚未設定對戰隊伍'
+ if (!selectedGroup) {
+ return (
+
+
+ Step 3
+ 請先回到選隊伍頁面
+
+ 目前還沒有帶入要上場的組別,先回去選擇分組,再進入記分板。
+
+
+ 回到選隊伍
+
+
+
+ )
+ }
- const openPicker = () => {
- setDraftPlayers(currentSelectionOrder.filter((player) => selectablePlayers.includes(player)))
- setDraftTargetScore(String(scoreState.targetScore))
- setPickerOpen(true)
- }
+ const matchupLabel =
+ leftTeam && rightTeam
+ ? `${getTeamDisplayName(leftTeam)} vs ${getTeamDisplayName(rightTeam)}`
+ : '尚未設定對戰隊伍'
- const stopFinishHold = () => {
- if (finishHoldFrameRef.current !== null) {
- window.cancelAnimationFrame(finishHoldFrameRef.current)
- finishHoldFrameRef.current = null
- }
+ const openPicker = () => {
+ setDraftPlayers(currentSelectionOrder.filter((player) => selectablePlayers.includes(player)))
+ setDraftTargetScore(String(scoreState.targetScore))
+ setPickerOpen(true)
+ }
- finishHoldStartRef.current = 0
- finishTriggeredRef.current = false
- setFinishHoldActive(false)
- setFinishHoldProgress(0)
- }
+ const stopFinishHold = () => {
+ if (finishHoldFrameRef.current !== null) {
+ window.cancelAnimationFrame(finishHoldFrameRef.current)
+ finishHoldFrameRef.current = null
+ }
- const startFinishHold = () => {
- if (finishDialogOpen || finishDialogUploading || finishHoldActive) {
- return
- }
+ if (finishHoldTimerRef.current !== null) {
+ window.clearTimeout(finishHoldTimerRef.current)
+ finishHoldTimerRef.current = null
+ }
- finishTriggeredRef.current = false
- finishHoldStartRef.current = performance.now()
- setFinishHoldActive(true)
- setFinishHoldProgress(0)
+ finishHoldStartRef.current = 0
+ finishTriggeredRef.current = false
+ setFinishHoldActive(false)
+ setFinishHoldProgress(0)
+ }
- const tick = (now: number) => {
- const elapsed = now - finishHoldStartRef.current
- const progress = Math.min(elapsed / FINISH_HOLD_DURATION, 1)
- setFinishHoldProgress(progress)
+ const startFinishHold = () => {
+ if (
+ !canFinishMatch ||
+ finishDialogOpen ||
+ finishDialogUploading ||
+ finishHoldActive
+ ) {
+ return
+ }
- if (progress >= 1) {
- finishTriggeredRef.current = true
- setFinishHoldActive(false)
- setFinishHoldProgress(0)
- finishHoldFrameRef.current = null
- onOpenFinishDialog()
- return
- }
+ finishTriggeredRef.current = false
+ finishHoldStartRef.current = performance.now()
+ setFinishHoldActive(true)
+ setFinishHoldProgress(0)
- finishHoldFrameRef.current = window.requestAnimationFrame(tick)
- }
+ finishHoldTimerRef.current = window.setTimeout(() => {
+ finishTriggeredRef.current = true
+ setFinishHoldActive(false)
+ setFinishHoldProgress(0)
+ finishHoldTimerRef.current = null
- finishHoldFrameRef.current = window.requestAnimationFrame(tick)
- }
+ if (finishHoldFrameRef.current !== null) {
+ window.cancelAnimationFrame(finishHoldFrameRef.current)
+ finishHoldFrameRef.current = null
+ }
- const cancelFinishHold = () => {
- if (finishTriggeredRef.current) {
- finishTriggeredRef.current = false
- return
- }
+ onOpenFinishDialog()
+ }, FINISH_HOLD_DURATION)
- stopFinishHold()
- }
+ const tick = (now: number) => {
+ const elapsed = now - finishHoldStartRef.current
+ const progress = Math.min(elapsed / FINISH_HOLD_DURATION, 1)
+ setFinishHoldProgress(progress)
- const toggleDraftPlayer = (playerName: string) => {
- setDraftPlayers((current) => {
- if (current.includes(playerName)) {
- return current.filter((value) => value !== playerName)
- }
+ if (!finishHoldStartRef.current || finishTriggeredRef.current) {
+ finishHoldFrameRef.current = null
+ return
+ }
- if (current.length >= 4) {
- return current
- }
+ finishHoldFrameRef.current = window.requestAnimationFrame(tick)
+ }
- return [...current, playerName]
- })
- }
+ finishHoldFrameRef.current = window.requestAnimationFrame(tick)
+ }
- const togglePresetTeam = (team: GroupTeam) => {
- setDraftPlayers((current) => {
- const removed = removePresetTeamFromDraft(current, team)
+ const cancelFinishHold = () => {
+ if (finishTriggeredRef.current) {
+ finishTriggeredRef.current = false
+ return
+ }
- if (removed.length !== current.length) {
- return removed
- }
+ stopFinishHold()
+ }
- if (current.length >= 4 || current.length % 2 !== 0) {
- return current
- }
+ const toggleDraftPlayer = (playerName: string) => {
+ setDraftPlayers((current) => {
+ if (current.includes(playerName)) {
+ return current.filter((value) => value !== playerName)
+ }
- if (current.includes(team.playerA) || current.includes(team.playerB)) {
- return current
- }
+ if (current.length >= 4) {
+ return current
+ }
- return [...current, team.playerA, team.playerB]
- })
- }
+ return [...current, playerName]
+ })
+ }
- const confirmDraftTeams = () => {
- if (draftPlayers.length !== 4) {
- return
- }
+ const togglePresetTeam = (team: GroupTeam) => {
+ setDraftPlayers((current) => {
+ const removed = removePresetTeamFromDraft(current, team)
- 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)
- }
+ if (removed.length !== current.length) {
+ return removed
+ }
- const autoPickDraftPlayers = () => {
- const shuffled = [...selectablePlayers]
+ if (current.length >= 4 || current.length % 2 !== 0) {
+ return current
+ }
- 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]]
- }
+ if (current.includes(team.playerA) || current.includes(team.playerB)) {
+ return current
+ }
- setDraftPlayers(shuffled.slice(0, 4))
- }
+ return [...current, team.playerA, team.playerB]
+ })
+ }
- return (
- <>
- {streakAnnouncement ? (
-
- {streakAnnouncement.count} 連勝
- {streakAnnouncement.title}
- {streakAnnouncement.teamName}
-
- ) : null}
+ const confirmDraftTeams = () => {
+ if (draftPlayers.length !== 4) {
+ return
+ }
- {victoryAnnouncement ? (
-
- 目標分數達成
- {victoryAnnouncement.title}
- {victoryAnnouncement.teamName}
- {victoryAnnouncement.scoreLabel}
-
- ) : null}
+ 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)
+ }
-
-
-
onRecordPoint('left')}
- onSetServing={() => onSetServing('left')}
- onSwapPlayers={() => onSwapTeamPlayers('left')}
- onSwapTeams={onSwapMatchup}
- score={scoreState.scoreLeft}
- serviceCourt={scoreState.serving === 'left' ? servingCourt : null}
- showServingPrompt={scoreState.serving === null}
- team={leftTeam}
- teamSlot="top"
- />
+ const autoPickDraftPlayers = () => {
+ const shuffled = [...selectablePlayers]
-
-
{scoreState.serving === null ? '請先設定發球方' : '比賽進行中'}
-
- {scoreState.serving === null
- ? `本場 ${scoreState.targetScore} 分獲勝`
- : `發球:${currentServer?.name ?? '-'}${
- currentReceiver ? ` / 接發:${currentReceiver.name}` : ''
- } / 目標 ${scoreState.targetScore} 分`}
-
-
+ 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]]
+ }
- onRecordPoint('right')}
- onSetServing={() => onSetServing('right')}
- onSwapPlayers={() => onSwapTeamPlayers('right')}
- onSwapTeams={onSwapMatchup}
- score={scoreState.scoreRight}
- serviceCourt={scoreState.serving === 'right' ? servingCourt : null}
- showServingPrompt={scoreState.serving === null}
- team={rightTeam}
- teamSlot="bottom"
- />
-
+ setDraftPlayers(shuffled.slice(0, 4))
+ }
-
+
+ {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
- canScore: boolean
- currentReceiver: string | null
- currentServer: string | null
- onRecordPoint: () => void
- onSetServing: () => void
- onSwapPlayers: () => void
- onSwapTeams: () => void
- score: number
- serviceCourt: CourtSide | null
- showServingPrompt: boolean
- team: GroupTeam | null
- teamSlot: 'top' | 'bottom'
+ assignments: Array<{ slot: PlayerSlot; name: string; court: CourtSide }>
+ canArrangeMatch: boolean
+ canScore: boolean
+ currentReceiver: string | null
+ currentServer: string | null
+ onRecordPoint: () => void
+ onSetServing: () => void
+ onSwapPlayers: () => void
+ onSwapTeams: () => void
+ score: number
+ serviceCourt: CourtSide | null
+ showServingPrompt: boolean
+ team: GroupTeam | null
+ teamSlot: 'top' | 'bottom'
}
function ScoreboardTeamPanel({
- assignments,
- canArrangeMatch,
- canScore,
- currentReceiver,
- currentServer,
- onRecordPoint,
- onSetServing,
- onSwapPlayers,
- onSwapTeams,
- score,
- serviceCourt,
- showServingPrompt,
- team,
- teamSlot,
+ assignments,
+ canArrangeMatch,
+ canScore,
+ currentReceiver,
+ currentServer,
+ onRecordPoint,
+ onSetServing,
+ onSwapPlayers,
+ onSwapTeams,
+ score,
+ serviceCourt,
+ showServingPrompt,
+ team,
+ teamSlot,
}: ScoreboardTeamPanelProps) {
- const orderedAssignments = [...assignments].sort((left, right) => {
- if (left.court === right.court) {
- return 0
- }
+ const orderedAssignments = [...assignments].sort((left, right) => {
+ if (left.court === right.court) {
+ return 0
+ }
- return left.court === 'left' ? -1 : 1
- })
+ return left.court === 'left' ? -1 : 1
+ })
- const header = (
-
-
- {orderedAssignments.map((assignment) => (
-
- {getPlayerNumber(teamSlot, assignment.slot)}
- {assignment.name}
-
- ))}
-
+ const header = (
+
+
+ {orderedAssignments.map((assignment) => (
+
+ {getPlayerNumber(teamSlot, assignment.slot)}
+ {assignment.name}
+
+ ))}
+
-
-
-
-
-
- )
+
+
+
+
+
+ )
- const serveBar = (
-
- )
+ const serveBar = (
+
+ )
- const scoreBoard = (
-
- )
+ const scoreBoard = (
+
+ )
- return (
-
- {teamSlot === 'top' ? (
- <>
- {header}
- {serveBar}
- {scoreBoard}
- >
- ) : (
- <>
- {scoreBoard}
- {serveBar}
- {header}
- >
- )}
-
- )
+ 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
+ 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,
+ draftPlayers,
+ draftTargetScore,
+ group,
+ presetTeams,
+ selectablePlayers,
+ selectionCount,
+ sourceLabel,
+ targetDate,
+ onAutoPick,
+ onClear,
+ onClose,
+ onConfirm,
+ onDraftTargetScoreChange,
+ onTogglePlayer,
+ onTogglePresetTeam,
}: TeamPickerModalProps) {
- return (
-
-
event.stopPropagation()}
- role="dialog"
- >
-
+ return (
+
+
event.stopPropagation()}
+ role="dialog"
+ >
+
-
- {selectionCount >= 4 ? '已完成選擇' : '請選滿 4 位球員'}
-
+
+ {selectionCount >= 4 ? '已完成選擇' : '請選滿 4 位球員'}
+
-
-
-
-
{selectionCount}/4
-
-
依序選擇球員
-
- 第 {group.id} 組 / {sourceLabel} / {targetDate || '-'}
-
-
-
+
+
+
+
{selectionCount}/4
+
+
依序選擇球員
+
+ 第 {group.id} 組 / {sourceLabel} / {targetDate || '-'}
+
+
+
-
-
-
+
+
+
-
- {selectablePlayers.map((playerName) => {
- const checked = draftPlayers.includes(playerName)
- const selectedOrder = checked ? draftPlayers.indexOf(playerName) + 1 : null
+
+ {selectablePlayers.map((playerName) => {
+ const checked = draftPlayers.includes(playerName)
+ const selectedOrder = checked ? draftPlayers.indexOf(playerName) + 1 : null
- return (
-
- )
- })}
-
+ return (
+
+ )
+ })}
+
-
-
-
-
-
+
+
+
+
+
-
-
-
- 快速選預設隊伍
- 可直接點一整隊,或和左側逐一選人混用。
-
+
-
+
-
- 第 1、2 位會自動成為上方隊伍,第 3、4 位會成為下方隊伍。
-
-
-
-
-
- )
+
+ 第 1、2 位會自動成為上方隊伍,第 3、4 位會成為下方隊伍。
+
+
+
+
+
+ )
}
type VoiceSettingsModalProps = {
- settings: VoiceSettings
- onClose: () => void
- onUpdateSettings: Dispatch>
+ settings: VoiceSettings
+ onClose: () => void
+ onUpdateSettings: Dispatch>
}
function VoiceSettingsModal({
- settings,
- onClose,
- onUpdateSettings,
+ settings,
+ onClose,
+ onUpdateSettings,
}: VoiceSettingsModalProps) {
- return (
-
-
event.stopPropagation()}
- >
-
+ 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
+ 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,
+ error,
+ leftScore,
+ leftTeamName,
+ matchupLabel,
+ rightScore,
+ rightTeamName,
+ uploading,
+ onClose,
+ onConfirm,
+ onSkip,
}: FinishDialogProps) {
- return (
-
-
-
+ return (
+
+
+
-
比賽結算
-
{matchupLabel}
+
比賽結算
+
{matchupLabel}
-
-
- {leftScore}
- {leftTeamName}
-
-
:
-
- {rightScore}
- {rightTeamName}
-
-
+
+
+ {leftScore}
+ {leftTeamName}
+
+
:
+
+ {rightScore}
+ {rightTeamName}
+
+
-
要不要把這場比賽戰績上傳到資料庫?
+
要不要把這場比賽戰績上傳到資料庫?
- {error ?
{error}
: null}
+ {error ?
{error}
: null}
-
-
-
-
-
-
- )
+
+
+
+
+
+
+ )
}
function getPlayerNumber(teamSlot: 'top' | 'bottom', slot: PlayerSlot) {
- if (teamSlot === 'top') {
- return slot === 'playerA' ? 1 : 2
- }
+ if (teamSlot === 'top') {
+ return slot === 'playerA' ? 1 : 2
+ }
- return slot === 'playerA' ? 4 : 3
+ return slot === 'playerA' ? 4 : 3
}
function sanitizeTargetScore(value: string) {
- const numeric = Number.parseInt(value.replace(/[^\d]/g, ''), 10)
+ const numeric = Number.parseInt(value.replace(/[^\d]/g, ''), 10)
- if (Number.isNaN(numeric)) {
- return 21
- }
+ if (Number.isNaN(numeric)) {
+ return 21
+ }
- return Math.min(99, Math.max(1, numeric))
+ 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
+ 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 (firstPairSelected) {
+ return players.slice(2)
+ }
- if (secondPairSelected) {
- return players.slice(0, 2)
- }
+ if (secondPairSelected) {
+ return players.slice(0, 2)
+ }
- return players
+ return players
}
function getPresetTeamSelectionSlot(players: string[], team: GroupTeam) {
- if (players[0] === team.playerA && players[1] === team.playerB) {
- return 0
- }
+ if (players[0] === team.playerA && players[1] === team.playerB) {
+ return 0
+ }
- if (players[2] === team.playerA && players[3] === team.playerB) {
- return 1
- }
+ if (players[2] === team.playerA && players[3] === team.playerB) {
+ return 1
+ }
- return null
+ return null
}
function formatClock() {
- return new Date().toLocaleTimeString('zh-TW', {
- hour: '2-digit',
- minute: '2-digit',
- hour12: false,
- })
+ 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)
+ try {
+ const raw = window.localStorage.getItem(VOICE_SETTINGS_STORAGE_KEY)
- if (!raw) {
- return defaultVoiceSettings
- }
+ if (!raw) {
+ return defaultVoiceSettings
+ }
- const parsed = JSON.parse(raw) as Partial
+ 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
- }
+ 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 getAnnouncementName(team: GroupTeam | null) {
- return getSpeechName(team?.playerA ?? '本隊')
+ return getSpeechName(team?.playerA ?? '本隊')
}
function getSpeechName(name: string) {
- return SPEECH_NAME_MAP[name.trim().toLowerCase()] ?? name
+ return SPEECH_NAME_MAP[name.trim().toLowerCase()] ?? name
}
function speakAnnouncement(message: string, rate: number) {
- if (!('speechSynthesis' in window)) {
- return
- }
+ 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'))
+ 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
+ utterance.lang = zhVoice?.lang ?? 'zh-TW'
+ utterance.rate = rate
+ utterance.pitch = 1
+ utterance.volume = 1
- if (zhVoice) {
- utterance.voice = zhVoice
- }
+ if (zhVoice) {
+ utterance.voice = zhVoice
+ }
- synthesis.cancel()
- synthesis.speak(utterance)
+ synthesis.cancel()
+ synthesis.speak(utterance)
}