2026-04-15 22:56:50 +08:00
|
|
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
|
|
|
|
import { NavLink, Route, Routes, useLocation } from 'react-router-dom'
|
2026-04-15 16:55:54 +08:00
|
|
|
|
import './App.css'
|
2026-04-15 22:56:50 +08:00
|
|
|
|
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 {
|
2026-04-16 08:53:05 +08:00
|
|
|
|
ActiveMatchup,
|
2026-04-15 22:56:50 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-04-15 16:55:54 +08:00
|
|
|
|
|
|
|
|
|
|
function App() {
|
2026-04-15 22:56:50 +08:00
|
|
|
|
const location = useLocation()
|
|
|
|
|
|
const isScoreboardRoute = location.pathname === '/scoreboard'
|
2026-04-15 16:55:54 +08:00
|
|
|
|
|
2026-04-15 22:56:50 +08:00
|
|
|
|
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)
|
2026-04-16 08:53:05 +08:00
|
|
|
|
const [activeMatchup, setActiveMatchup] = useState<ActiveMatchup>({
|
|
|
|
|
|
leftTeam: null,
|
|
|
|
|
|
rightTeam: null,
|
2026-04-15 22:56:50 +08:00
|
|
|
|
})
|
|
|
|
|
|
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,
|
|
|
|
|
|
})
|
2026-04-15 16:55:54 +08:00
|
|
|
|
|
2026-04-15 22:56:50 +08:00
|
|
|
|
const parsedAreaA = useMemo(() => parseRoster(areaAInput), [areaAInput])
|
|
|
|
|
|
const parsedAreaB = useMemo(() => parseRoster(areaBInput), [areaBInput])
|
|
|
|
|
|
const selectedGroup = groups.find((group) => group.id === selectedGroupId) ?? null
|
2026-04-16 08:53:05 +08:00
|
|
|
|
const leftTeam = activeMatchup.leftTeam
|
|
|
|
|
|
const rightTeam = activeMatchup.rightTeam
|
2026-04-15 22:56:50 +08:00
|
|
|
|
|
|
|
|
|
|
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])
|
|
|
|
|
|
|
2026-04-16 10:55:41 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (loadStatus !== 'loaded' || !loadMessage) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const timer = window.setTimeout(() => {
|
|
|
|
|
|
setLoadMessage('')
|
|
|
|
|
|
}, 500)
|
|
|
|
|
|
|
|
|
|
|
|
return () => window.clearTimeout(timer)
|
|
|
|
|
|
}, [loadMessage, loadStatus])
|
|
|
|
|
|
|
2026-04-15 22:56:50 +08:00
|
|
|
|
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)
|
2026-04-16 08:53:05 +08:00
|
|
|
|
setActiveMatchup({
|
|
|
|
|
|
leftTeam: firstTeam,
|
|
|
|
|
|
rightTeam: secondTeam,
|
2026-04-15 22:56:50 +08:00
|
|
|
|
})
|
|
|
|
|
|
resetScoring()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 08:53:05 +08:00
|
|
|
|
const applyMatchup = (
|
|
|
|
|
|
leftTeam: GroupTeam,
|
|
|
|
|
|
rightTeam: GroupTeam,
|
|
|
|
|
|
targetScore: number,
|
|
|
|
|
|
) => {
|
|
|
|
|
|
setActiveMatchup({
|
|
|
|
|
|
leftTeam,
|
|
|
|
|
|
rightTeam,
|
|
|
|
|
|
})
|
|
|
|
|
|
resetScoring({
|
|
|
|
|
|
...initialScoreState,
|
|
|
|
|
|
targetScore,
|
2026-04-15 22:56:50 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const loadGroupsFromDb = async () => {
|
|
|
|
|
|
if (!targetDate) {
|
|
|
|
|
|
setLoadStatus('error')
|
|
|
|
|
|
setLoadMessage('請先選擇日期。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setLoadStatus('loading')
|
|
|
|
|
|
setLoadMessage('正在讀取指定日期的分組資料...')
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const record = await loadMatchResults(convertDateToKey(targetDate))
|
|
|
|
|
|
|
|
|
|
|
|
if (!record) {
|
|
|
|
|
|
setGroups([])
|
|
|
|
|
|
setSelectedGroupId(null)
|
2026-04-16 08:53:05 +08:00
|
|
|
|
setActiveMatchup({ leftTeam: null, rightTeam: null })
|
2026-04-15 22:56:50 +08:00
|
|
|
|
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)
|
2026-04-16 08:53:05 +08:00
|
|
|
|
setActiveMatchup({ leftTeam: null, rightTeam: null })
|
2026-04-15 22:56:50 +08:00
|
|
|
|
setGroupSource('idle')
|
|
|
|
|
|
setLoadStatus('error')
|
|
|
|
|
|
setLoadMessage(error instanceof Error ? error.message : '讀取資料失敗。')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const generateManualGroups = () => {
|
|
|
|
|
|
if (parsedAreaA.length === 0 || parsedAreaB.length === 0) {
|
|
|
|
|
|
setGroups([])
|
|
|
|
|
|
setSelectedGroupId(null)
|
2026-04-16 08:53:05 +08:00
|
|
|
|
setActiveMatchup({ leftTeam: null, rightTeam: null })
|
2026-04-15 22:56:50 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 08:53:05 +08:00
|
|
|
|
setActiveMatchup((current) => ({
|
|
|
|
|
|
leftTeam: current.rightTeam,
|
|
|
|
|
|
rightTeam: current.leftTeam,
|
2026-04-15 22:56:50 +08:00
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-04-15 16:55:54 +08:00
|
|
|
|
|
2026-04-15 22:56:50 +08:00
|
|
|
|
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 (
|
|
|
|
|
|
<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}
|
2026-04-15 16:55:54 +08:00
|
|
|
|
</div>
|
2026-04-15 22:56:50 +08:00
|
|
|
|
|
|
|
|
|
|
<nav className="topnav" aria-label="主要導覽">
|
|
|
|
|
|
<NavLink className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')} to="/teams">
|
|
|
|
|
|
選隊伍
|
|
|
|
|
|
</NavLink>
|
|
|
|
|
|
<NavLink className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')} to="/scoreboard">
|
|
|
|
|
|
記分板
|
|
|
|
|
|
</NavLink>
|
|
|
|
|
|
<NavLink className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')} to="/history">
|
|
|
|
|
|
歷史戰績
|
|
|
|
|
|
</NavLink>
|
|
|
|
|
|
</nav>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
<Routes>
|
|
|
|
|
|
<Route
|
|
|
|
|
|
path="/"
|
|
|
|
|
|
element={
|
|
|
|
|
|
<TeamSelectionPage
|
|
|
|
|
|
areaAInput={areaAInput}
|
|
|
|
|
|
areaBInput={areaBInput}
|
|
|
|
|
|
groups={groups}
|
|
|
|
|
|
groupSource={groupSource}
|
|
|
|
|
|
loadMessage={loadMessage}
|
|
|
|
|
|
loadStatus={loadStatus}
|
|
|
|
|
|
selectedGroupId={selectedGroupId}
|
|
|
|
|
|
targetDate={targetDate}
|
|
|
|
|
|
onAreaAInputChange={setAreaAInput}
|
|
|
|
|
|
onAreaBInputChange={setAreaBInput}
|
|
|
|
|
|
onGenerateManualGroups={generateManualGroups}
|
|
|
|
|
|
onLoadGroupsFromDb={() => void loadGroupsFromDb()}
|
|
|
|
|
|
onSelectGroup={selectGroup}
|
|
|
|
|
|
onTargetDateChange={setTargetDate}
|
|
|
|
|
|
onUseGroup={selectGroup}
|
|
|
|
|
|
/>
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Route
|
|
|
|
|
|
path="/teams"
|
|
|
|
|
|
element={
|
|
|
|
|
|
<TeamSelectionPage
|
|
|
|
|
|
areaAInput={areaAInput}
|
|
|
|
|
|
areaBInput={areaBInput}
|
|
|
|
|
|
groups={groups}
|
|
|
|
|
|
groupSource={groupSource}
|
|
|
|
|
|
loadMessage={loadMessage}
|
|
|
|
|
|
loadStatus={loadStatus}
|
|
|
|
|
|
selectedGroupId={selectedGroupId}
|
|
|
|
|
|
targetDate={targetDate}
|
|
|
|
|
|
onAreaAInputChange={setAreaAInput}
|
|
|
|
|
|
onAreaBInputChange={setAreaBInput}
|
|
|
|
|
|
onGenerateManualGroups={generateManualGroups}
|
|
|
|
|
|
onLoadGroupsFromDb={() => void loadGroupsFromDb()}
|
|
|
|
|
|
onSelectGroup={selectGroup}
|
|
|
|
|
|
onTargetDateChange={setTargetDate}
|
|
|
|
|
|
onUseGroup={selectGroup}
|
|
|
|
|
|
/>
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Route
|
|
|
|
|
|
path="/scoreboard"
|
|
|
|
|
|
element={
|
|
|
|
|
|
<ScoreboardPage
|
2026-04-16 08:53:05 +08:00
|
|
|
|
currentSelectionOrder={getSelectionOrder(leftTeam, rightTeam)}
|
2026-04-15 22:56:50 +08:00
|
|
|
|
finishDialogError={settlement.error}
|
|
|
|
|
|
finishDialogOpen={settlement.open}
|
|
|
|
|
|
finishDialogUploading={settlement.uploading}
|
|
|
|
|
|
groupSource={groupSource}
|
|
|
|
|
|
hasRecordedPoint={pointLog.length > 0}
|
|
|
|
|
|
leftTeam={leftTeam}
|
|
|
|
|
|
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}
|
|
|
|
|
|
/>
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
2026-04-15 23:04:16 +08:00
|
|
|
|
<Route path="/history" element={<HistoryPage />} />
|
2026-04-15 22:56:50 +08:00
|
|
|
|
</Routes>
|
|
|
|
|
|
</div>
|
2026-04-15 16:55:54 +08:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 22:56:50 +08:00
|
|
|
|
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 })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 08:53:05 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 22:56:50 +08:00
|
|
|
|
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 []
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 16:55:54 +08:00
|
|
|
|
export default App
|