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 { GroupTeam, HistoryUploadPayload, LoadStatus, MatchHistoryItem, Matchup, 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 } 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 [matchup, setMatchup] = useState({ leftTeamId: null, rightTeamId: 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 parsedAreaA = useMemo(() => parseRoster(areaAInput), [areaAInput]) const parsedAreaB = useMemo(() => parseRoster(areaBInput), [areaBInput]) const selectedGroup = groups.find((group) => group.id === selectedGroupId) ?? null const leftTeam = selectedGroup?.teams.find((team) => team.id === matchup.leftTeamId) ?? null const rightTeam = selectedGroup?.teams.find((team) => team.id === matchup.rightTeamId) ?? 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]) const resetScoring = (nextState: ScoreState = initialScoreState) => { setScoreState(nextState) setScoreHistory([]) setPointLog([]) 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) setMatchup({ leftTeamId: firstTeam?.id ?? null, rightTeamId: secondTeam?.id ?? null, }) resetScoring() } const applyMatchup = (leftTeamId: number, rightTeamId: number) => { setMatchup({ leftTeamId, rightTeamId, }) resetScoring() } const loadGroupsFromDb = async () => { if (!targetDate) { setLoadStatus('error') setLoadMessage('請先選擇日期。') return } setLoadStatus('loading') setLoadMessage('正在讀取指定日期的分組資料...') try { const record = await loadMatchResults(convertDateToKey(targetDate)) if (!record) { setGroups([]) setSelectedGroupId(null) setMatchup({ leftTeamId: null, rightTeamId: 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) setMatchup({ leftTeamId: null, rightTeamId: null }) setGroupSource('idle') setLoadStatus('error') setLoadMessage(error instanceof Error ? error.message : '讀取資料失敗。') } } const generateManualGroups = () => { if (parsedAreaA.length === 0 || parsedAreaB.length === 0) { setGroups([]) setSelectedGroupId(null) setMatchup({ leftTeamId: null, rightTeamId: 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 } setMatchup((current) => ({ leftTeamId: current.rightTeamId, rightTeamId: current.leftTeamId, })) 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 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) } const undoLastPoint = () => { const previous = scoreHistory.at(-1) if (!previous) { return } setScoreHistory((current) => current.slice(0, -1)) setPointLog(previous.pointLog) setScoreState(previous.scoreState) } 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()} onSelectGroup={selectGroup} onTargetDateChange={setTargetDate} onUseGroup={selectGroup} /> } /> void loadGroupsFromDb()} onSelectGroup={selectGroup} onTargetDateChange={setTargetDate} onUseGroup={selectGroup} /> } /> 0} leftTeam={leftTeam} matchup={matchup} rightTeam={rightTeam} scoreState={scoreState} selectedGroup={selectedGroup} targetDate={targetDate} onApplyMatchup={applyMatchup} onCloseFinishDialog={closeSettlementDialog} onConfirmUpload={uploadSettledMatch} onOpenFinishDialog={openSettlementDialog} onRecordPoint={recordPoint} onSetServing={setServing} onSkipUpload={skipUpload} onSwapMatchup={swapMatchupSides} onSwapTeamPlayers={swapTeamPlayers} onUndoLastPoint={undoLastPoint} /> } /> } />
) } 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 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