Files
badminton-scoreboard/src/App.tsx

1029 lines
27 KiB
TypeScript
Raw Normal View History

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,
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
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 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
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)
}
}, [])
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) => {
if (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 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 (!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 = () => {
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}
</div>
<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>
<NavLink className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')} 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}
</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