import { useEffect, useMemo, useState } from 'react' import { NavLink, Route, Routes, useLocation } from 'react-router-dom' import './App.css' import { loadMatchResults, saveMatchHistory } from './lib/api' import { buildManualGroups, convertDateToKey, convertDbRecordToGroups, formatDateInputValue, getServingPlayer, getTeamDisplayName, getWinnerName, parseRoster, swapCourtPositions, } from './lib/match' import { HistoryPage } from './pages/HistoryPage' import { ScoreboardPage } from './pages/ScoreboardPage' import { TeamSelectionPage } from './pages/TeamSelectionPage' import type { ActiveMatchup, GroupTeam, HistoryUploadPayload, 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' function App() { const location = useLocation() 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 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 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(() => { const handlePwaUpdateReady = () => { setPwaUpdateReady(true) } window.addEventListener(PWA_UPDATE_EVENT, handlePwaUpdateReady) return () => { window.removeEventListener(PWA_UPDATE_EVENT, handlePwaUpdateReady) } }, []) const resetScoring = (nextState: ScoreState = initialScoreState) => { setScoreState(nextState) setScoreHistory([]) setPointLog([]) setStreakAnnouncement(null) setVictoryAnnouncement(null) setSettlement({ error: '', open: false, uploading: false, }) } 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() }) } 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 = () => { setSettlement({ error: '', open: false, uploading: false, }) resetScoring() } 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]) setSettlement({ error: '', open: false, uploading: false, }) resetScoring() } catch (error) { setSettlement({ error: error instanceof Error ? error.message : '上傳戰績失敗。', open: true, uploading: false, }) } } return (

Badminton Scoreboard

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

{!isScoreboardRoute ? (

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

) : null}
void loadGroupsFromDb()} onTargetDateChange={setTargetDate} onUseGroup={selectGroup} /> } /> void loadGroupsFromDb()} onTargetDateChange={setTargetDate} onUseGroup={selectGroup} /> } /> 0} leftTeam={leftTeam} 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} /> } /> } /> {pwaUpdateReady ? (
有新版本可更新 點重新整理後套用最新版本。
) : 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 [] } } export default App