Files
badminton-scoreboard/src/pages/ScoreboardPage.tsx

852 lines
24 KiB
TypeScript
Raw Normal View History

import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import {
getCourtAssignments,
getReceivingPlayer,
getServiceCourt,
getServingPlayer,
getTeamDisplayName,
} from '../lib/match'
import type {
CourtSide,
GroupTeam,
PlayerSlot,
RoundGroup,
ScoreSide,
ScoreState,
} from '../types'
type ScoreboardPageProps = {
currentSelectionOrder: string[]
finishDialogError: string
finishDialogOpen: boolean
finishDialogUploading: boolean
groupSource: 'idle' | 'db' | 'manual'
hasRecordedPoint: boolean
leftTeam: GroupTeam | null
rightTeam: GroupTeam | null
scoreState: ScoreState
selectedGroup: RoundGroup | null
streakAnnouncement: {
count: number
key: number
teamName: string
title: string
} | null
victoryAnnouncement: {
key: number
scoreLabel: string
teamName: string
title: string
} | null
targetDate: string
onApplyMatchup: (
leftTeam: GroupTeam,
rightTeam: GroupTeam,
targetScore: number,
) => void
onCloseFinishDialog: () => void
onConfirmUpload: () => void
onOpenFinishDialog: () => void
onRecordPoint: (side: ScoreSide) => void
onSetServing: (side: ScoreSide) => void
onSkipUpload: () => void
onSwapMatchup: () => void
onSwapTeamPlayers: (side: ScoreSide) => void
onUndoLastPoint: () => void
}
export function ScoreboardPage({
currentSelectionOrder,
finishDialogError,
finishDialogOpen,
finishDialogUploading,
groupSource,
hasRecordedPoint,
leftTeam,
rightTeam,
scoreState,
selectedGroup,
streakAnnouncement,
victoryAnnouncement,
targetDate,
onApplyMatchup,
onCloseFinishDialog,
onConfirmUpload,
onOpenFinishDialog,
onRecordPoint,
onSetServing,
onSkipUpload,
onSwapMatchup,
onSwapTeamPlayers,
onUndoLastPoint,
}: ScoreboardPageProps) {
const [pickerOpen, setPickerOpen] = useState(false)
const [draftPlayers, setDraftPlayers] = useState<string[]>([])
const [draftTargetScore, setDraftTargetScore] = useState(() => String(scoreState.targetScore))
const [clock, setClock] = useState(() => formatClock())
useEffect(() => {
const timer = window.setInterval(() => {
setClock(formatClock())
}, 1000)
return () => window.clearInterval(timer)
}, [])
const selectablePlayers = useMemo(() => {
if (!selectedGroup) {
return []
}
const seen = new Set<string>()
const players: string[] = []
selectedGroup.teams.forEach((team) => {
if (!team.isPlaceholderA && !seen.has(team.playerA)) {
seen.add(team.playerA)
players.push(team.playerA)
}
if (!team.isPlaceholderB && !seen.has(team.playerB)) {
seen.add(team.playerB)
players.push(team.playerB)
}
})
return players
}, [selectedGroup])
const presetTeams = useMemo(
() =>
selectedGroup?.teams.filter((team) => !team.isPlaceholderA && !team.isPlaceholderB) ?? [],
[selectedGroup],
)
const canArrangeMatch = !hasRecordedPoint
const canScore = scoreState.serving !== null
const servingScore =
scoreState.serving === 'left' ? scoreState.scoreLeft : scoreState.scoreRight
const servingCourt =
scoreState.serving === null ? null : getServiceCourt(servingScore)
const leftAssignments = useMemo(
() =>
leftTeam ? getCourtAssignments(leftTeam, scoreState.leftRightCourtPlayer) : [],
[leftTeam, scoreState.leftRightCourtPlayer],
)
const rightAssignments = useMemo(
() =>
rightTeam ? getCourtAssignments(rightTeam, scoreState.rightRightCourtPlayer) : [],
[rightTeam, scoreState.rightRightCourtPlayer],
)
const currentServer =
scoreState.serving === 'left'
? leftTeam
? getServingPlayer(leftTeam, scoreState.leftRightCourtPlayer, scoreState.scoreLeft)
: null
: scoreState.serving === 'right'
? rightTeam
? getServingPlayer(
rightTeam,
scoreState.rightRightCourtPlayer,
scoreState.scoreRight,
)
: null
: null
const currentReceiver =
scoreState.serving === 'left'
? rightTeam
? getReceivingPlayer(
rightTeam,
scoreState.rightRightCourtPlayer,
scoreState.scoreLeft,
)
: null
: scoreState.serving === 'right'
? leftTeam
? getReceivingPlayer(
leftTeam,
scoreState.leftRightCourtPlayer,
scoreState.scoreRight,
)
: null
: null
if (!selectedGroup) {
return (
<section className="page-grid">
<article className="panel panel-hero">
<p className="panel-kicker">Step 3</p>
<h2></h2>
<p className="panel-copy">
</p>
<Link className="primary-button inline-link" to="/teams">
</Link>
</article>
</section>
)
}
const matchupLabel =
leftTeam && rightTeam
? `${getTeamDisplayName(leftTeam)} vs ${getTeamDisplayName(rightTeam)}`
: '尚未設定對戰隊伍'
const openPicker = () => {
setDraftPlayers(currentSelectionOrder.filter((player) => selectablePlayers.includes(player)))
setDraftTargetScore(String(scoreState.targetScore))
setPickerOpen(true)
}
const toggleDraftPlayer = (playerName: string) => {
setDraftPlayers((current) => {
if (current.includes(playerName)) {
return current.filter((value) => value !== playerName)
}
if (current.length >= 4) {
return current
}
return [...current, playerName]
})
}
const togglePresetTeam = (team: GroupTeam) => {
setDraftPlayers((current) => {
const removed = removePresetTeamFromDraft(current, team)
if (removed.length !== current.length) {
return removed
}
if (current.length >= 4 || current.length % 2 !== 0) {
return current
}
if (current.includes(team.playerA) || current.includes(team.playerB)) {
return current
}
return [...current, team.playerA, team.playerB]
})
}
const confirmDraftTeams = () => {
if (draftPlayers.length !== 4) {
return
}
onApplyMatchup(
{
id: -1,
playerA: draftPlayers[0],
playerB: draftPlayers[1],
isPlaceholderA: false,
isPlaceholderB: false,
},
{
id: -2,
playerA: draftPlayers[3],
playerB: draftPlayers[2],
isPlaceholderA: false,
isPlaceholderB: false,
},
sanitizeTargetScore(draftTargetScore),
)
setPickerOpen(false)
}
const autoPickDraftPlayers = () => {
const shuffled = [...selectablePlayers]
for (let index = shuffled.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(Math.random() * (index + 1))
;[shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex], shuffled[index]]
}
setDraftPlayers(shuffled.slice(0, 4))
}
return (
<>
{streakAnnouncement ? (
<div className="streak-banner" key={streakAnnouncement.key}>
<span className="streak-banner-count">{streakAnnouncement.count} </span>
<strong>{streakAnnouncement.title}</strong>
<small>{streakAnnouncement.teamName}</small>
</div>
) : null}
{victoryAnnouncement ? (
<div className="victory-banner" key={victoryAnnouncement.key}>
<span className="victory-banner-kicker"></span>
<strong>{victoryAnnouncement.title}</strong>
<small>{victoryAnnouncement.teamName}</small>
<em>{victoryAnnouncement.scoreLabel}</em>
</div>
) : null}
<section className="scoreboard-screen">
<div className="scoreboard-court">
<ScoreboardTeamPanel
assignments={leftAssignments}
canArrangeMatch={canArrangeMatch}
canScore={canScore}
currentReceiver={scoreState.serving === 'right' ? currentReceiver?.name ?? null : null}
currentServer={scoreState.serving === 'left' ? currentServer?.name ?? null : null}
onRecordPoint={() => onRecordPoint('left')}
onSetServing={() => onSetServing('left')}
onSwapPlayers={() => onSwapTeamPlayers('left')}
onSwapTeams={onSwapMatchup}
score={scoreState.scoreLeft}
serviceCourt={scoreState.serving === 'left' ? servingCourt : null}
showServingPrompt={scoreState.serving === null}
team={leftTeam}
teamSlot="top"
/>
<div className="scoreboard-center-banner">
<p>{scoreState.serving === null ? '請先設定發球方' : '點擊分數即可記分'}</p>
<small>
{scoreState.serving === null
? `本場 ${scoreState.targetScore} 分獲勝`
: `發球:${currentServer?.name ?? '-'}${
currentReceiver ? ` / 接發:${currentReceiver.name}` : ''
} / ${scoreState.targetScore} `}
</small>
</div>
<ScoreboardTeamPanel
assignments={rightAssignments}
canArrangeMatch={canArrangeMatch}
canScore={canScore}
currentReceiver={scoreState.serving === 'left' ? currentReceiver?.name ?? null : null}
currentServer={scoreState.serving === 'right' ? currentServer?.name ?? null : null}
onRecordPoint={() => onRecordPoint('right')}
onSetServing={() => onSetServing('right')}
onSwapPlayers={() => onSwapTeamPlayers('right')}
onSwapTeams={onSwapMatchup}
score={scoreState.scoreRight}
serviceCourt={scoreState.serving === 'right' ? servingCourt : null}
showServingPrompt={scoreState.serving === null}
team={rightTeam}
teamSlot="bottom"
/>
</div>
<aside className="scoreboard-rail">
<div className="rail-icon-grid">
{hasRecordedPoint ? (
<button className="rail-square-button" type="button" onClick={onUndoLastPoint}>
</button>
) : (
<button className="rail-square-button" type="button" onClick={openPicker}>
</button>
)}
</div>
<div className="rail-clock">{clock}</div>
<button className="rail-pill rail-pill-danger" type="button" onClick={onOpenFinishDialog}>
</button>
</aside>
</section>
{pickerOpen ? (
<TeamPickerModal
draftPlayers={draftPlayers}
draftTargetScore={draftTargetScore}
group={selectedGroup}
presetTeams={presetTeams}
selectablePlayers={selectablePlayers}
selectionCount={draftPlayers.length}
sourceLabel={groupSource === 'db' ? 'DB' : groupSource === 'manual' ? '手動' : '-'}
targetDate={targetDate}
onAutoPick={autoPickDraftPlayers}
onClear={() => setDraftPlayers([])}
onClose={() => setPickerOpen(false)}
onConfirm={confirmDraftTeams}
onDraftTargetScoreChange={setDraftTargetScore}
onTogglePlayer={toggleDraftPlayer}
onTogglePresetTeam={togglePresetTeam}
/>
) : null}
{finishDialogOpen ? (
<FinishDialog
error={finishDialogError}
leftScore={scoreState.scoreLeft}
leftTeamName={leftTeam ? getTeamDisplayName(leftTeam) : '尚未設定'}
matchupLabel={matchupLabel}
rightScore={scoreState.scoreRight}
rightTeamName={rightTeam ? getTeamDisplayName(rightTeam) : '尚未設定'}
uploading={finishDialogUploading}
onClose={onCloseFinishDialog}
onConfirm={onConfirmUpload}
onSkip={onSkipUpload}
/>
) : null}
</>
)
}
type ScoreboardTeamPanelProps = {
assignments: Array<{ slot: PlayerSlot; name: string; court: CourtSide }>
canArrangeMatch: boolean
canScore: boolean
currentReceiver: string | null
currentServer: string | null
onRecordPoint: () => void
onSetServing: () => void
onSwapPlayers: () => void
onSwapTeams: () => void
score: number
serviceCourt: CourtSide | null
showServingPrompt: boolean
team: GroupTeam | null
teamSlot: 'top' | 'bottom'
}
function ScoreboardTeamPanel({
assignments,
canArrangeMatch,
canScore,
currentReceiver,
currentServer,
onRecordPoint,
onSetServing,
onSwapPlayers,
onSwapTeams,
score,
serviceCourt,
showServingPrompt,
team,
teamSlot,
}: ScoreboardTeamPanelProps) {
const orderedAssignments = [...assignments].sort((left, right) => {
if (left.court === right.court) {
return 0
}
return left.court === 'left' ? -1 : 1
})
const header = (
<div className="scoreboard-team-head">
<div className="team-head-main">
{orderedAssignments.map((assignment) => (
<div
className={
assignment.name === currentServer
? 'scoreboard-name-chip scoreboard-name-chip-serving'
: 'scoreboard-name-chip'
}
key={assignment.slot}
>
<span className="team-number">{getPlayerNumber(teamSlot, assignment.slot)}</span>
<strong>{assignment.name}</strong>
</div>
))}
</div>
<div className="team-head-buttons">
<button
aria-label="上下交換隊伍"
className="team-icon-button"
disabled={!canArrangeMatch}
type="button"
onClick={onSwapTeams}
>
</button>
<button
aria-label="左右交換球員"
className="team-icon-button"
disabled={!canArrangeMatch}
type="button"
onClick={onSwapPlayers}
>
</button>
</div>
</div>
)
const serveBar = (
<button
className={
currentServer && !canArrangeMatch
? 'serve-lane serve-lane-locked'
: showServingPrompt
? 'serve-lane serve-lane-prompt'
: 'serve-lane'
}
disabled={!canArrangeMatch || !team}
type="button"
onClick={onSetServing}
>
{showServingPrompt ? (
<span aria-hidden="true" className="serve-lane-arrow">
{teamSlot === 'top' ? '↓' : '↑'}
</span>
) : null}
<span className={currentServer ? 'serve-lane-box serve-lane-box-checked' : 'serve-lane-box'} />
<span></span>
{currentServer ? (
<small>
{serviceCourt === 'left' ? '左' : serviceCourt === 'right' ? '右' : '-'}
{currentReceiver ? ` / 接發:${currentReceiver}` : ''}
</small>
) : (
<small></small>
)}
</button>
)
const scoreBoard = (
<button
className={canScore ? 'score-panel-surface score-panel-surface-live' : 'score-panel-surface'}
disabled={!canScore || !team}
type="button"
onClick={onRecordPoint}
>
<span className="score-panel-value">{score}</span>
</button>
)
return (
<section className="scoreboard-team-section">
{teamSlot === 'top' ? (
<>
{header}
{serveBar}
{scoreBoard}
</>
) : (
<>
{scoreBoard}
{serveBar}
{header}
</>
)}
</section>
)
}
type TeamPickerModalProps = {
draftPlayers: string[]
draftTargetScore: string
group: RoundGroup
presetTeams: GroupTeam[]
selectablePlayers: string[]
selectionCount: number
sourceLabel: string
targetDate: string
onAutoPick: () => void
onClear: () => void
onClose: () => void
onConfirm: () => void
onDraftTargetScoreChange: (value: string) => void
onTogglePlayer: (playerName: string) => void
onTogglePresetTeam: (team: GroupTeam) => void
}
function TeamPickerModal({
draftPlayers,
draftTargetScore,
group,
presetTeams,
selectablePlayers,
selectionCount,
sourceLabel,
targetDate,
onAutoPick,
onClear,
onClose,
onConfirm,
onDraftTargetScoreChange,
onTogglePlayer,
onTogglePresetTeam,
}: TeamPickerModalProps) {
return (
<div className="team-picker-overlay" role="presentation" onClick={onClose}>
<div
aria-label="設定隊伍"
aria-modal="true"
className="team-picker-shell"
onClick={(event) => event.stopPropagation()}
role="dialog"
>
<button aria-label="關閉設定隊伍" className="team-picker-close" type="button" onClick={onClose}>
×
</button>
<div className="team-picker-ribbon">
<span>{selectionCount >= 4 ? '已完成選擇' : '請依序選 4 位球員'}</span>
</div>
<div className="team-picker-layout">
<section className="team-picker-panel team-picker-list-panel">
<div className="team-picker-title">
<span className="team-picker-count">{selectionCount}/4</span>
<div>
<strong></strong>
<p>
{group.id} / {sourceLabel} / {targetDate || '-'}
</p>
</div>
</div>
<div className="team-picker-config-row">
<label className="team-picker-config team-picker-config-compact">
<span></span>
<input
className="team-picker-score-input team-picker-score-input-compact"
inputMode="numeric"
maxLength={2}
type="text"
value={draftTargetScore}
onChange={(event) => onDraftTargetScoreChange(event.target.value)}
/>
</label>
</div>
<div className="team-picker-list">
{selectablePlayers.map((playerName) => {
const checked = draftPlayers.includes(playerName)
const selectedOrder = checked ? draftPlayers.indexOf(playerName) + 1 : null
return (
<button
className={
checked
? 'team-picker-option team-picker-option-active'
: 'team-picker-option'
}
key={`player-option-${playerName}`}
type="button"
onClick={() => onTogglePlayer(playerName)}
>
<span className="team-picker-checkbox">
{checked ? String(selectedOrder) : ''}
</span>
<div className="team-picker-option-text">
<strong>{playerName}</strong>
<small>
{selectedOrder ? `已選第 ${selectedOrder}` : '點擊加入目前配對'}
</small>
</div>
</button>
)
})}
</div>
<div className="team-picker-actions">
<button className="team-picker-ghost" type="button" onClick={onAutoPick}>
</button>
<button
className="team-picker-confirm"
disabled={draftPlayers.length !== 4}
type="button"
onClick={onConfirm}
>
</button>
</div>
</section>
<aside className="team-picker-panel team-picker-side-panel">
<div className="preset-team-block">
<div className="preset-team-head">
<strong></strong>
<small></small>
</div>
<div className="preset-team-list">
{presetTeams.map((team) => {
const selectedSlot = getPresetTeamSelectionSlot(draftPlayers, team)
return (
<button
className={
selectedSlot === null
? 'preset-team-card'
: 'preset-team-card preset-team-card-active'
}
key={`preset-team-${team.id}`}
type="button"
onClick={() => onTogglePresetTeam(team)}
>
<span className="preset-team-index">{team.id}</span>
<div>
<strong>{getTeamDisplayName(team)}</strong>
<small>
{selectedSlot === null
? '點擊帶入這組預設隊伍'
: `已帶入第 ${selectedSlot === 0 ? '1' : '2'}`}
</small>
</div>
</button>
)
})}
</div>
</div>
<button className="team-picker-clear" type="button" onClick={onClear}>
</button>
<p className="picker-side-hint">
12 34
</p>
</aside>
</div>
</div>
</div>
)
}
type FinishDialogProps = {
error: string
leftScore: number
leftTeamName: string
matchupLabel: string
rightScore: number
rightTeamName: string
uploading: boolean
onClose: () => void
onConfirm: () => void
onSkip: () => void
}
function FinishDialog({
error,
leftScore,
leftTeamName,
matchupLabel,
rightScore,
rightTeamName,
uploading,
onClose,
onConfirm,
onSkip,
}: FinishDialogProps) {
return (
<div className="finish-dialog-overlay" role="presentation">
<div aria-modal="true" className="finish-dialog" role="dialog">
<button
aria-label="關閉比賽結算"
className="finish-dialog-close"
disabled={uploading}
type="button"
onClick={onClose}
>
×
</button>
<p className="panel-kicker"></p>
<h3>{matchupLabel}</h3>
<div className="finish-score">
<div>
<strong>{leftScore}</strong>
<span>{leftTeamName}</span>
</div>
<div className="finish-score-divider">:</div>
<div>
<strong>{rightScore}</strong>
<span>{rightTeamName}</span>
</div>
</div>
<p className="finish-dialog-copy"></p>
{error ? <p className="finish-dialog-error">{error}</p> : null}
<div className="finish-dialog-actions">
<button
className="team-picker-ghost"
disabled={uploading}
type="button"
onClick={onSkip}
>
</button>
<button
className="team-picker-confirm"
disabled={uploading}
type="button"
onClick={onConfirm}
>
{uploading ? '上傳中...' : '確認上傳'}
</button>
</div>
</div>
</div>
)
}
function getPlayerNumber(teamSlot: 'top' | 'bottom', slot: PlayerSlot) {
if (teamSlot === 'top') {
return slot === 'playerA' ? 1 : 2
}
return slot === 'playerA' ? 4 : 3
}
function sanitizeTargetScore(value: string) {
const numeric = Number.parseInt(value.replace(/[^\d]/g, ''), 10)
if (Number.isNaN(numeric)) {
return 21
}
return Math.min(99, Math.max(1, numeric))
}
function removePresetTeamFromDraft(players: string[], team: GroupTeam) {
const firstPairSelected = players[0] === team.playerA && players[1] === team.playerB
const secondPairSelected = players[2] === team.playerA && players[3] === team.playerB
if (firstPairSelected) {
return players.slice(2)
}
if (secondPairSelected) {
return players.slice(0, 2)
}
return players
}
function getPresetTeamSelectionSlot(players: string[], team: GroupTeam) {
if (players[0] === team.playerA && players[1] === team.playerB) {
return 0
}
if (players[2] === team.playerA && players[3] === team.playerB) {
return 1
}
return null
}
function formatClock() {
return new Date().toLocaleTimeString('zh-TW', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
}