1164 lines
30 KiB
TypeScript
1164 lines
30 KiB
TypeScript
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
|