Refine scoreboard flow and update ports
This commit is contained in:
653
src/App.tsx
653
src/App.tsx
@@ -1,65 +1,610 @@
|
||||
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<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 [matchup, setMatchup] = useState<Matchup>({
|
||||
leftTeamId: null,
|
||||
rightTeamId: 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 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 (
|
||||
<main className="app-shell">
|
||||
<section className="hero-panel">
|
||||
<div className="hero-copy">
|
||||
<span className="eyebrow">Vite + React + TypeScript</span>
|
||||
<h1>羽毛球記分板</h1>
|
||||
<p className="hero-text">
|
||||
專案已完成初始化,接下來可以往單打、雙打、發球權切換、局數統計與賽事模式繼續擴充。
|
||||
</p>
|
||||
<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>
|
||||
|
||||
<div className="status-strip" aria-label="專案狀態">
|
||||
<span>即時比分</span>
|
||||
<span>局數追蹤</span>
|
||||
<span>Docker Ready</span>
|
||||
</div>
|
||||
</section>
|
||||
<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>
|
||||
|
||||
<section className="scoreboard-card" aria-label="比賽記分板預覽">
|
||||
<div className="board-header">
|
||||
<div>
|
||||
<p className="label">友誼賽</p>
|
||||
<h2>中央球場</h2>
|
||||
</div>
|
||||
<div className="match-meta">
|
||||
<span>第 2 局</span>
|
||||
<span>21 分制</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="score-grid">
|
||||
<article className="team-card">
|
||||
<p className="team-tag">A 隊</p>
|
||||
<h3>林 / 陳</h3>
|
||||
<strong>18</strong>
|
||||
<p>上一局 21 : 16</p>
|
||||
</article>
|
||||
|
||||
<article className="team-card team-card-active">
|
||||
<p className="team-tag">B 隊</p>
|
||||
<h3>王 / 黃</h3>
|
||||
<strong>21</strong>
|
||||
<p>目前發球權</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div className="detail-grid">
|
||||
<div>
|
||||
<span className="label">本局節奏</span>
|
||||
<p>多拍相持偏多,比分進入收尾階段。</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="label">下一步</span>
|
||||
<p>把計分控制、局數規則與賽事資料模型串起來。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<TeamSelectionPage
|
||||
areaAInput={areaAInput}
|
||||
areaBInput={areaBInput}
|
||||
groups={groups}
|
||||
groupSource={groupSource}
|
||||
loadMessage={loadMessage}
|
||||
loadStatus={loadStatus}
|
||||
parsedAreaA={parsedAreaA}
|
||||
parsedAreaB={parsedAreaB}
|
||||
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}
|
||||
parsedAreaA={parsedAreaA}
|
||||
parsedAreaB={parsedAreaB}
|
||||
selectedGroupId={selectedGroupId}
|
||||
targetDate={targetDate}
|
||||
onAreaAInputChange={setAreaAInput}
|
||||
onAreaBInputChange={setAreaBInput}
|
||||
onGenerateManualGroups={generateManualGroups}
|
||||
onLoadGroupsFromDb={() => void loadGroupsFromDb()}
|
||||
onSelectGroup={selectGroup}
|
||||
onTargetDateChange={setTargetDate}
|
||||
onUseGroup={selectGroup}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/scoreboard"
|
||||
element={
|
||||
<ScoreboardPage
|
||||
finishDialogError={settlement.error}
|
||||
finishDialogOpen={settlement.open}
|
||||
finishDialogUploading={settlement.uploading}
|
||||
groupSource={groupSource}
|
||||
hasRecordedPoint={pointLog.length > 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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/history" element={<HistoryPage history={history} />} />
|
||||
</Routes>
|
||||
</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 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
|
||||
|
||||
Reference in New Issue
Block a user