功能:首頁日期預設當天、記分板語音改為報比分與發球區

摘要:
- 首頁「指定日期」每次進入都預設為當天
- 記分板語音得分播報改為「比分(發球方先)+ 發球者左右發球區」,賽末點獲勝時整段改播「贏得比賽」

根本原因:
- 日期原本從 localStorage 讀上次選的值,重開會停在舊日期
- 語音原本只唸「誰得分、誰發球」,現場較不直覺,球敘想聽到即時比分與發球位置

影響:
- src/App.tsx:targetDate 改為直接用當天、移除日期的 localStorage 記憶;recordPoint 算出發球方比分與發球區,重整 voiceAnnouncement 欄位
- src/pages/ScoreboardPage.tsx:語音組字改為「X比Y」「OO左/右邊發球」,獲勝時改播「贏得比賽」;語音設定「播報誰得分」更名「播報比分」

修法:
- 發球方分數先報;上方(左側)隊伍發球區做左右鏡像以對齊畫面,下方隊伍不鏡像

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 15:33:22 +08:00
co-authored by Claude Opus 4.8
parent 3677162747
commit 091e654bdb
2 changed files with 473 additions and 451 deletions
+306 -296
View File
@@ -1,26 +1,26 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { NavLink, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
import './App.css'
import {
createLiveRoom,
loadMatchResults,
releaseLiveRoom,
saveMatchHistory,
sendLiveRoomHeartbeat,
updateLiveRoom,
} from './lib/api'
import {
buildManualGroups,
convertDateToKey,
convertDbRecordToGroups,
formatDateInputValue,
getMirroredCourt,
getServiceCourt,
getServingPlayer,
getTeamDisplayName,
getWinnerName,
parseRoster,
swapCourtPositions,
import {
createLiveRoom,
loadMatchResults,
releaseLiveRoom,
saveMatchHistory,
sendLiveRoomHeartbeat,
updateLiveRoom,
} from './lib/api'
import {
buildManualGroups,
convertDateToKey,
convertDbRecordToGroups,
formatDateInputValue,
getMirroredCourt,
getServiceCourt,
getServingPlayer,
getTeamDisplayName,
getWinnerName,
parseRoster,
swapCourtPositions,
} from './lib/match'
import { HistoryPage } from './pages/HistoryPage'
import { RoomListPage } from './pages/RoomListPage'
@@ -45,24 +45,23 @@ 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,
gamesLeft: 0,
gamesRight: 0,
currentGame: 1,
targetScore: 21,
initialServing: null,
serving: null,
leftRightCourtPlayer: 'playerA',
rightRightCourtPlayer: 'playerA',
}
const initialScoreState: ScoreState = {
scoreLeft: 0,
scoreRight: 0,
gamesLeft: 0,
gamesRight: 0,
currentGame: 1,
targetScore: 21,
initialServing: null,
serving: null,
leftRightCourtPlayer: 'playerA',
rightRightCourtPlayer: 'playerA',
}
type SettlementState = {
error: string
@@ -77,40 +76,40 @@ type StreakAnnouncement = {
title: string
}
type VictoryAnnouncement = {
key: number
scoreLabel: string
teamName: string
title: string
}
type VoiceAnnouncement = {
key: number
scorerName: string
serverChanged: boolean
serverName: string
}
type VictoryAnnouncement = {
key: number
scoreLabel: string
teamName: string
title: string
}
const STREAK_TITLES: Record<number, string> = {
type VoiceAnnouncement = {
key: number
servingScore: number
opponentScore: number
serverName: string
serverCourt: 'left' | 'right'
winnerTeamName: string | null
}
const STREAK_TITLES: Record<number, string> = {
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
}
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 [targetDate, setTargetDate] = useState(() => formatDateInputValue())
const [areaAInput, setAreaAInput] = useState(() =>
loadStoredText(STORAGE_KEYS.areaA, defaultAreaA.join('\n')),
)
@@ -137,27 +136,23 @@ function App() {
open: false,
uploading: false,
})
const [streakAnnouncement, setStreakAnnouncement] = useState<StreakAnnouncement | null>(null)
const [victoryAnnouncement, setVictoryAnnouncement] = useState<VictoryAnnouncement | null>(null)
const [voiceAnnouncement, setVoiceAnnouncement] = useState<VoiceAnnouncement | null>(null)
const [pwaUpdateReady, setPwaUpdateReady] = useState(false)
const [liveRoomSession, setLiveRoomSession] = useState<LiveRoomSession | null>(null)
const [navigationLockMessage, setNavigationLockMessage] = useState('')
const currentAppVersionRef = useRef<string | null>(null)
const [streakAnnouncement, setStreakAnnouncement] = useState<StreakAnnouncement | null>(null)
const [victoryAnnouncement, setVictoryAnnouncement] = useState<VictoryAnnouncement | null>(null)
const [voiceAnnouncement, setVoiceAnnouncement] = useState<VoiceAnnouncement | null>(null)
const [pwaUpdateReady, setPwaUpdateReady] = useState(false)
const [liveRoomSession, setLiveRoomSession] = useState<LiveRoomSession | null>(null)
const [navigationLockMessage, setNavigationLockMessage] = useState('')
const currentAppVersionRef = useRef<string | null>(null)
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])
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.areaA, areaAInput)
@@ -195,9 +190,9 @@ function App() {
return () => window.clearTimeout(timer)
}, [streakAnnouncement])
useEffect(() => {
if (!victoryAnnouncement) {
return
useEffect(() => {
if (!victoryAnnouncement) {
return
}
const timer = window.setTimeout(() => {
@@ -205,27 +200,27 @@ function App() {
}, 2200)
return () => window.clearTimeout(timer)
}, [victoryAnnouncement])
useEffect(() => {
if (!navigationLockMessage) {
return
}
const timer = window.setTimeout(() => {
setNavigationLockMessage('')
}, 1400)
return () => window.clearTimeout(timer)
}, [navigationLockMessage])
useEffect(() => {
document.body.classList.toggle('body-scoreboard', isScoreboardRoute)
return () => {
document.body.classList.remove('body-scoreboard')
}
}, [isScoreboardRoute])
}, [victoryAnnouncement])
useEffect(() => {
if (!navigationLockMessage) {
return
}
const timer = window.setTimeout(() => {
setNavigationLockMessage('')
}, 1400)
return () => window.clearTimeout(timer)
}, [navigationLockMessage])
useEffect(() => {
document.body.classList.toggle('body-scoreboard', isScoreboardRoute)
return () => {
document.body.classList.remove('body-scoreboard')
}
}, [isScoreboardRoute])
useEffect(() => {
const handlePwaUpdateReady = () => {
@@ -304,10 +299,10 @@ function App() {
setScoreState(nextState)
setScoreHistory([])
setPointLog([])
setStreakAnnouncement(null)
setVictoryAnnouncement(null)
setVoiceAnnouncement(null)
setPointLog([])
setStreakAnnouncement(null)
setVictoryAnnouncement(null)
setVoiceAnnouncement(null)
setSettlement({
error: '',
open: false,
@@ -455,17 +450,17 @@ function App() {
isScoreboardRoute,
])
useEffect(() => {
if (!isScoreboardRoute || !liveRoomSession || !leftTeam || !rightTeam) {
return
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 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,
@@ -514,49 +509,49 @@ function App() {
rightTeam,
scoreState,
selectedGroup?.id,
targetDate,
isScoreboardRoute,
])
useEffect(() => {
if (!isNavigationLocked || isScoreboardRoute) {
return
}
navigate('/scoreboard', { replace: true })
setNavigationLockMessage('比賽進行中,請先完成結算。')
}, [isNavigationLocked, isScoreboardRoute, navigate])
useEffect(() => {
if (!isScoreboardRoute || !liveRoomSession || liveRoomSession.status !== 'live') {
return
}
let active = true
const syncHeartbeat = async () => {
try {
await sendLiveRoomHeartbeat(liveRoomSession.roomId, liveRoomSession.hostToken)
} catch (error) {
if (active) {
console.error('live room heartbeat error:', error)
}
}
}
void syncHeartbeat()
const timer = window.setInterval(() => {
void syncHeartbeat()
}, LIVE_ROOM_HEARTBEAT_MS)
return () => {
active = false
window.clearInterval(timer)
}
}, [isScoreboardRoute, liveRoomSession])
useEffect(() => {
if (!liveRoomSession || liveRoomSession.status !== 'live') {
targetDate,
isScoreboardRoute,
])
useEffect(() => {
if (!isNavigationLocked || isScoreboardRoute) {
return
}
navigate('/scoreboard', { replace: true })
setNavigationLockMessage('比賽進行中,請先完成結算。')
}, [isNavigationLocked, isScoreboardRoute, navigate])
useEffect(() => {
if (!isScoreboardRoute || !liveRoomSession || liveRoomSession.status !== 'live') {
return
}
let active = true
const syncHeartbeat = async () => {
try {
await sendLiveRoomHeartbeat(liveRoomSession.roomId, liveRoomSession.hostToken)
} catch (error) {
if (active) {
console.error('live room heartbeat error:', error)
}
}
}
void syncHeartbeat()
const timer = window.setInterval(() => {
void syncHeartbeat()
}, LIVE_ROOM_HEARTBEAT_MS)
return () => {
active = false
window.clearInterval(timer)
}
}, [isScoreboardRoute, liveRoomSession])
useEffect(() => {
if (!liveRoomSession || liveRoomSession.status !== 'live') {
return
}
@@ -682,18 +677,18 @@ function App() {
scoreRight: current.scoreLeft,
gamesLeft: current.gamesRight,
gamesRight: current.gamesLeft,
serving:
current.serving === 'left'
? 'right'
: current.serving === 'right'
? 'left'
: null,
initialServing:
current.initialServing === 'left'
? 'right'
: current.initialServing === 'right'
? 'left'
: null,
serving:
current.serving === 'left'
? 'right'
: current.serving === 'right'
? 'left'
: null,
initialServing:
current.initialServing === 'left'
? 'right'
: current.initialServing === 'right'
? 'left'
: null,
leftRightCourtPlayer: current.rightRightCourtPlayer,
rightRightCourtPlayer: current.leftRightCourtPlayer,
}))
@@ -717,17 +712,17 @@ function App() {
}))
}
const setServing = (side: ScoreSide) => {
if (scoreHistory.length > 0) {
return
}
setScoreState((current) => ({
...current,
initialServing: current.initialServing === side ? null : side,
serving: current.initialServing === side ? null : side,
}))
}
const setServing = (side: ScoreSide) => {
if (scoreHistory.length > 0) {
return
}
setScoreState((current) => ({
...current,
initialServing: current.initialServing === side ? null : side,
serving: current.initialServing === side ? null : side,
}))
}
const recordPoint = (side: ScoreSide) => {
if (!leftTeam || !rightTeam || scoreState.serving === null) {
@@ -756,7 +751,7 @@ function App() {
},
]
const nextScoreState: ScoreState = {
const nextScoreState: ScoreState = {
...scoreState,
scoreLeft: side === 'left' ? scoreState.scoreLeft + 1 : scoreState.scoreLeft,
scoreRight: side === 'right' ? scoreState.scoreRight + 1 : scoreState.scoreRight,
@@ -771,15 +766,32 @@ function App() {
: scoreState.rightRightCourtPlayer,
}
setScoreHistory((current) => [...current, { pointLog, scoreState }])
setPointLog(nextPointLog)
setScoreState(nextScoreState)
setVoiceAnnouncement({
key: Date.now(),
scorerName: side === 'left' ? leftTeam.playerA : rightTeam.playerA,
serverChanged: side === scoreState.serving,
serverName: getNextServerName(nextScoreState, leftTeam, rightTeam, side),
})
const reachedTarget = hasWonGame(nextScoreState)
const winnerTeamName = reachedTarget
? side === 'left'
? getTeamDisplayName(leftTeam)
: getTeamDisplayName(rightTeam)
: null
// 得分方接著發球,報分以發球方分數為先;左側隊伍的發球區需鏡像對應畫面。
const servingScore = side === 'left' ? nextScoreState.scoreLeft : nextScoreState.scoreRight
const opponentScore = side === 'left' ? nextScoreState.scoreRight : nextScoreState.scoreLeft
const serverCourt =
side === 'left'
? getMirroredCourt(getServiceCourt(servingScore))
: getServiceCourt(servingScore)
setScoreHistory((current) => [...current, { pointLog, scoreState }])
setPointLog(nextPointLog)
setScoreState(nextScoreState)
setVoiceAnnouncement({
key: Date.now(),
servingScore,
opponentScore,
serverName: getNextServerName(nextScoreState, leftTeam, rightTeam, side),
serverCourt,
winnerTeamName,
})
if (streakTitle) {
setStreakAnnouncement({
@@ -790,8 +802,6 @@ function App() {
})
}
const reachedTarget = hasWonGame(nextScoreState)
if (reachedTarget) {
setVictoryAnnouncement({
key: Date.now() + 1,
@@ -851,7 +861,7 @@ function App() {
})
}
const uploadSettledMatch = async () => {
const uploadSettledMatch = async () => {
if (!leftTeam || !rightTeam || !selectedGroup || pointLog.length === 0) {
return
}
@@ -903,20 +913,20 @@ function App() {
open: true,
uploading: false,
})
}
}
const handleNavAttempt = (targetPath: string) => (event: React.MouseEvent<HTMLAnchorElement>) => {
if (!isNavigationLocked || targetPath === '/scoreboard') {
return
}
event.preventDefault()
setNavigationLockMessage('比賽進行中,請先完成結算。')
}
return (
<div className={isScoreboardRoute ? 'app-shell app-shell-scoreboard app-shell-scoreboard-fit' : 'app-shell'}>
}
}
const handleNavAttempt = (targetPath: string) => (event: React.MouseEvent<HTMLAnchorElement>) => {
if (!isNavigationLocked || targetPath === '/scoreboard') {
return
}
event.preventDefault()
setNavigationLockMessage('比賽進行中,請先完成結算。')
}
return (
<div className={isScoreboardRoute ? 'app-shell app-shell-scoreboard app-shell-scoreboard-fit' : 'app-shell'}>
<header className={isScoreboardRoute ? 'topbar topbar-compact' : 'topbar'}>
<div className="branding">
<p className="eyebrow">Badminton Scoreboard</p>
@@ -930,32 +940,32 @@ function App() {
</div>
<nav className="topnav" aria-label="主要導覽">
<NavLink
className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')}
onClick={handleNavAttempt('/teams')}
to="/teams"
>
<NavLink
className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')}
onClick={handleNavAttempt('/teams')}
to="/teams"
>
</NavLink>
<NavLink
className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')}
onClick={handleNavAttempt('/scoreboard')}
to="/scoreboard"
>
<NavLink
className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')}
onClick={handleNavAttempt('/scoreboard')}
to="/scoreboard"
>
</NavLink>
<NavLink
className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')}
onClick={handleNavAttempt('/history')}
to="/history"
>
<NavLink
className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')}
onClick={handleNavAttempt('/history')}
to="/history"
>
</NavLink>
<NavLink
className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')}
onClick={handleNavAttempt('/rooms')}
to="/rooms"
>
<NavLink
className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')}
onClick={handleNavAttempt('/rooms')}
to="/rooms"
>
</NavLink>
</nav>
@@ -1017,10 +1027,10 @@ function App() {
rightTeam={rightTeam}
scoreState={scoreState}
selectedGroup={selectedGroup}
streakAnnouncement={streakAnnouncement}
victoryAnnouncement={victoryAnnouncement}
voiceAnnouncement={voiceAnnouncement}
targetDate={targetDate}
streakAnnouncement={streakAnnouncement}
victoryAnnouncement={victoryAnnouncement}
voiceAnnouncement={voiceAnnouncement}
targetDate={targetDate}
onApplyMatchup={applyMatchup}
onCloseFinishDialog={closeSettlementDialog}
onConfirmUpload={uploadSettledMatch}
@@ -1042,8 +1052,8 @@ function App() {
/>
</Routes>
{pwaUpdateReady ? (
<div className="pwa-update-toast" role="status" aria-live="polite">
{pwaUpdateReady ? (
<div className="pwa-update-toast" role="status" aria-live="polite">
<div className="pwa-update-copy">
<strong></strong>
<span></span>
@@ -1051,17 +1061,17 @@ function App() {
<button className="pwa-update-button" onClick={refreshForPwaUpdate} type="button">
</button>
</div>
) : null}
{navigationLockMessage ? (
<div className="floating-status-bubble" role="status" aria-live="polite">
{navigationLockMessage}
</div>
) : null}
</div>
)
}
</div>
) : null}
{navigationLockMessage ? (
<div className="floating-status-bubble" role="status" aria-live="polite">
{navigationLockMessage}
</div>
) : null}
</div>
)
}
function buildHistoryPayload({
leftTeam,
@@ -1101,65 +1111,65 @@ function buildHistoryPayload({
}
}
function getServerHistoryIndex(
state: ScoreState,
leftTeam: GroupTeam,
rightTeam: GroupTeam,
) {
if (state.serving === 'left') {
const server = getServingPlayer(leftTeam, state.leftRightCourtPlayer, state.scoreLeft)
if (!server) {
return null
}
return getMirroredCourt(getServiceCourt(state.scoreLeft)) === 'left' ? 0 : 1
}
if (state.serving === 'right') {
const server = getServingPlayer(rightTeam, state.rightRightCourtPlayer, state.scoreRight)
if (!server) {
return null
}
return getServiceCourt(state.scoreRight) === 'right' ? 2 : 3
}
return null
}
function getNextServerName(
state: ScoreState,
leftTeam: GroupTeam,
rightTeam: GroupTeam,
side: ScoreSide,
) {
if (side === 'left') {
return getServingPlayer(leftTeam, state.leftRightCourtPlayer, state.scoreLeft)?.name ?? ''
}
return getServingPlayer(rightTeam, state.rightRightCourtPlayer, state.scoreRight)?.name ?? ''
}
function hasWonGame(state: ScoreState) {
const leadingScore = Math.max(state.scoreLeft, state.scoreRight)
const trailingScore = Math.min(state.scoreLeft, state.scoreRight)
if (leadingScore < state.targetScore) {
return false
}
if (leadingScore >= 30) {
return true
}
if (trailingScore >= state.targetScore - 1) {
return leadingScore - trailingScore >= 2
}
return true
}
function getServerHistoryIndex(
state: ScoreState,
leftTeam: GroupTeam,
rightTeam: GroupTeam,
) {
if (state.serving === 'left') {
const server = getServingPlayer(leftTeam, state.leftRightCourtPlayer, state.scoreLeft)
if (!server) {
return null
}
return getMirroredCourt(getServiceCourt(state.scoreLeft)) === 'left' ? 0 : 1
}
if (state.serving === 'right') {
const server = getServingPlayer(rightTeam, state.rightRightCourtPlayer, state.scoreRight)
if (!server) {
return null
}
return getServiceCourt(state.scoreRight) === 'right' ? 2 : 3
}
return null
}
function getNextServerName(
state: ScoreState,
leftTeam: GroupTeam,
rightTeam: GroupTeam,
side: ScoreSide,
) {
if (side === 'left') {
return getServingPlayer(leftTeam, state.leftRightCourtPlayer, state.scoreLeft)?.name ?? ''
}
return getServingPlayer(rightTeam, state.rightRightCourtPlayer, state.scoreRight)?.name ?? ''
}
function hasWonGame(state: ScoreState) {
const leadingScore = Math.max(state.scoreLeft, state.scoreRight)
const trailingScore = Math.min(state.scoreLeft, state.scoreRight)
if (leadingScore < state.targetScore) {
return false
}
if (leadingScore >= 30) {
return true
}
if (trailingScore >= state.targetScore - 1) {
return leadingScore - trailingScore >= 2
}
return true
}
function formatPlayedAt(timestamp: number) {
return new Date(timestamp * 1000).toLocaleString('zh-TW', { hour12: false })