Files
badminton-scoreboard/src/App.tsx

1164 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
getServingPlayer,
getTeamDisplayName,
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']
const initialScoreState: ScoreState = {
scoreLeft: 0,
scoreRight: 0,
gamesLeft: 0,
gamesRight: 0,
currentGame: 1,
targetScore: 21,
serving: null,
leftRightCourtPlayer: 'playerA',
rightRightCourtPlayer: 'playerA',
}
type SettlementState = {
error: string
open: boolean
uploading: boolean
}
type StreakAnnouncement = {
count: number
key: number
teamName: string
title: string
}
type VictoryAnnouncement = {
key: number
scoreLabel: string
teamName: string
title: string
}
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
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<RoundGroup[]>([])
const [groupSource, setGroupSource] = useState<'idle' | 'db' | 'manual'>('idle')
const [loadStatus, setLoadStatus] = useState<LoadStatus>('idle')
const [loadMessage, setLoadMessage] = useState('')
const [selectedGroupId, setSelectedGroupId] = useState<number | null>(null)
const [activeMatchup, setActiveMatchup] = useState<ActiveMatchup>({
leftTeam: null,
rightTeam: null,
})
const [scoreState, setScoreState] = useState<ScoreState>(initialScoreState)
const [scoreHistory, setScoreHistory] = useState<ScoreSnapshot[]>([])
const [pointLog, setPointLog] = useState<PointHistoryEntry[]>([])
const [history, setHistory] = useState<MatchHistoryItem[]>(() =>
loadStoredHistory(STORAGE_KEYS.history),
)
const [settlement, setSettlement] = useState<SettlementState>({
error: '',
open: false,
uploading: false,
})
const [streakAnnouncement, setStreakAnnouncement] = useState<StreakAnnouncement | null>(null)
const [victoryAnnouncement, setVictoryAnnouncement] = useState<VictoryAnnouncement | 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])
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)
}, [victoryAnnouncement])
useEffect(() => {
if (!navigationLockMessage) {
return
}
const timer = window.setTimeout(() => {
setNavigationLockMessage('')
}, 1400)
return () => window.clearTimeout(timer)
}, [navigationLockMessage])
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)
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 =
scoreState.scoreLeft >= scoreState.targetScore
? getTeamDisplayName(leftTeam)
: scoreState.scoreRight >= scoreState.targetScore
? 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,
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
}
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'
: current.serving === 'right'
? 'left'
: null,
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,
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 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,
}
setScoreHistory((current) => [...current, { pointLog, scoreState }])
setPointLog(nextPointLog)
setScoreState(nextScoreState)
if (streakTitle) {
setStreakAnnouncement({
count: streakCount,
key: Date.now(),
teamName: side === 'left' ? getTeamDisplayName(leftTeam) : getTeamDisplayName(rightTeam),
title: streakTitle,
})
}
const reachedTarget =
nextScoreState.scoreLeft >= nextScoreState.targetScore ||
nextScoreState.scoreRight >= nextScoreState.targetScore
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,
})
}
}
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'}>
<header className={isScoreboardRoute ? 'topbar topbar-compact' : 'topbar'}>
<div className="branding">
<p className="eyebrow">Badminton Scoreboard</p>
<h1>{isScoreboardRoute ? '羽毛球記分板' : '羽毛球分組與記分板'}</h1>
{!isScoreboardRoute ? (
<p className="intro-copy">
DB
</p>
) : null}
</div>
<nav className="topnav" aria-label="主要導覽">
<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>
<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>
</nav>
</header>
<Routes>
<Route
path="/"
element={
<TeamSelectionPage
areaAInput={areaAInput}
areaBInput={areaBInput}
groups={groups}
groupSource={groupSource}
loadMessage={loadMessage}
loadStatus={loadStatus}
targetDate={targetDate}
onAreaAInputChange={setAreaAInput}
onAreaBInputChange={setAreaBInput}
onGenerateManualGroups={generateManualGroups}
onLoadGroupsFromDb={() => void loadGroupsFromDb()}
onTargetDateChange={setTargetDate}
onUseGroup={selectGroup}
/>
}
/>
<Route
path="/teams"
element={
<TeamSelectionPage
areaAInput={areaAInput}
areaBInput={areaBInput}
groups={groups}
groupSource={groupSource}
loadMessage={loadMessage}
loadStatus={loadStatus}
targetDate={targetDate}
onAreaAInputChange={setAreaAInput}
onAreaBInputChange={setAreaBInput}
onGenerateManualGroups={generateManualGroups}
onLoadGroupsFromDb={() => void loadGroupsFromDb()}
onTargetDateChange={setTargetDate}
onUseGroup={selectGroup}
/>
}
/>
<Route
path="/scoreboard"
element={
<ScoreboardPage
currentSelectionOrder={getSelectionOrder(leftTeam, rightTeam)}
finishDialogError={settlement.error}
finishDialogOpen={settlement.open}
finishDialogUploading={settlement.uploading}
groupSource={groupSource}
hasRecordedPoint={pointLog.length > 0}
leftTeam={leftTeam}
liveRoomId={liveRoomId}
rightTeam={rightTeam}
scoreState={scoreState}
selectedGroup={selectedGroup}
streakAnnouncement={streakAnnouncement}
victoryAnnouncement={victoryAnnouncement}
targetDate={targetDate}
onApplyMatchup={applyMatchup}
onCloseFinishDialog={closeSettlementDialog}
onConfirmUpload={uploadSettledMatch}
onOpenFinishDialog={openSettlementDialog}
onRecordPoint={recordPoint}
onSetServing={setServing}
onSkipUpload={skipUpload}
onSwapMatchup={swapMatchupSides}
onSwapTeamPlayers={swapTeamPlayers}
onUndoLastPoint={undoLastPoint}
/>
}
/>
<Route path="/history" element={<HistoryPage />} />
<Route path="/rooms" element={<RoomListPage />} />
<Route
path="/rooms/:roomId"
element={<RoomSpectatorPage onConfirmFinished={() => navigate('/rooms')} />}
/>
</Routes>
{pwaUpdateReady ? (
<div className="pwa-update-toast" role="status" aria-live="polite">
<div className="pwa-update-copy">
<strong></strong>
<span></span>
</div>
<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>
)
}
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,
rightTeam: GroupTeam,
) {
if (state.serving === 'left') {
const server = getServingPlayer(leftTeam, state.leftRightCourtPlayer, state.scoreLeft)
if (!server) {
return null
}
return server.slot === 'playerA' ? 0 : 1
}
if (state.serving === 'right') {
const server = getServingPlayer(rightTeam, state.rightRightCourtPlayer, state.scoreRight)
if (!server) {
return null
}
return server.slot === 'playerB' ? 2 : 3
}
return null
}
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