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 = { 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, }) const [streakAnnouncement, setStreakAnnouncement] = useState(null) const [victoryAnnouncement, setVictoryAnnouncement] = useState(null) const [pwaUpdateReady, setPwaUpdateReady] = useState(false) 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 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) => { if (!isNavigationLocked || targetPath === '/scoreboard') { return } event.preventDefault() setNavigationLockMessage('比賽進行中,請先完成結算。') } 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} targetDate={targetDate} onApplyMatchup={applyMatchup} onCloseFinishDialog={closeSettlementDialog} onConfirmUpload={uploadSettledMatch} onOpenFinishDialog={openSettlementDialog} onRecordPoint={recordPoint} onSetServing={setServing} onSkipUpload={skipUpload} onSwapMatchup={swapMatchupSides} onSwapTeamPlayers={swapTeamPlayers} onUndoLastPoint={undoLastPoint} /> } /> } /> } /> navigate('/rooms')} />} /> {pwaUpdateReady ? (
有新版本可更新 點重新整理後套用最新版本。
) : null} {navigationLockMessage ? (
{navigationLockMessage}
) : null}
) } 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