import { useEffect, useMemo, useRef, useState } from 'react' import type { Dispatch, SetStateAction } 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 VoiceSettings = { announceScore: boolean announceServer: boolean rate: number } const VOICE_SETTINGS_STORAGE_KEY = 'badminton-scoreboard::voice-settings' const defaultVoiceSettings: VoiceSettings = { announceScore: true, announceServer: true, rate: 1, } 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 [settingsOpen, setSettingsOpen] = useState(false) const [draftPlayers, setDraftPlayers] = useState([]) const [draftTargetScore, setDraftTargetScore] = useState(() => String(scoreState.targetScore), ) const [clock, setClock] = useState(() => formatClock()) const [voiceSettings, setVoiceSettings] = useState(() => loadVoiceSettings(), ) const lastAnnouncedPointRef = useRef(0) const previousScoresRef = useRef({ left: 0, right: 0 }) useEffect(() => { const timer = window.setInterval(() => { setClock(formatClock()) }, 1000) return () => window.clearInterval(timer) }, []) useEffect(() => { window.localStorage.setItem( VOICE_SETTINGS_STORAGE_KEY, JSON.stringify(voiceSettings), ) }, [voiceSettings]) useEffect(() => { return () => { if ('speechSynthesis' in window) { window.speechSynthesis.cancel() } } }, []) const selectablePlayers = useMemo(() => { if (!selectedGroup) { return [] } const seen = new Set() 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 useEffect(() => { const totalPoints = scoreState.scoreLeft + scoreState.scoreRight if (scoreState.serving === null || !currentServer?.name || totalPoints === 0) { lastAnnouncedPointRef.current = totalPoints previousScoresRef.current = { left: scoreState.scoreLeft, right: scoreState.scoreRight, } return } if (lastAnnouncedPointRef.current === totalPoints) { return } lastAnnouncedPointRef.current = totalPoints const scorerSide = scoreState.scoreLeft > previousScoresRef.current.left ? 'left' : scoreState.scoreRight > previousScoresRef.current.right ? 'right' : null previousScoresRef.current = { left: scoreState.scoreLeft, right: scoreState.scoreRight, } const parts: string[] = [] if (voiceSettings.announceScore && scorerSide) { parts.push( `${getAnnouncementName(scorerSide === 'left' ? leftTeam : rightTeam)}得分`, ) } if (voiceSettings.announceServer) { parts.push(`${currentServer.name}發球`) } if (parts.length > 0) { speakAnnouncement(parts.join(','), voiceSettings.rate) } }, [ currentServer?.name, leftTeam, rightTeam, scoreState.scoreLeft, scoreState.scoreRight, scoreState.serving, voiceSettings.announceScore, voiceSettings.announceServer, voiceSettings.rate, ]) if (!selectedGroup) { return (

Step 3

請先回到選隊伍頁面

目前還沒有帶入要上場的組別,先回去選擇分組,再進入記分板。

回到選隊伍
) } 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 ? (
{streakAnnouncement.count} 連勝 {streakAnnouncement.title} {streakAnnouncement.teamName}
) : null} {victoryAnnouncement ? (
目標分數達成 {victoryAnnouncement.title} {victoryAnnouncement.teamName} {victoryAnnouncement.scoreLabel}
) : null}
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" />

{scoreState.serving === null ? '請先設定發球方' : '比賽進行中'}

{scoreState.serving === null ? `本場 ${scoreState.targetScore} 分獲勝` : `發球:${currentServer?.name ?? '-'}${ currentReceiver ? ` / 接發:${currentReceiver.name}` : '' } / 目標 ${scoreState.targetScore} 分`}
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" />
{pickerOpen ? ( setDraftPlayers([])} onClose={() => setPickerOpen(false)} onConfirm={confirmDraftTeams} onDraftTargetScoreChange={setDraftTargetScore} onTogglePlayer={toggleDraftPlayer} onTogglePresetTeam={togglePresetTeam} /> ) : null} {settingsOpen ? ( setSettingsOpen(false)} onUpdateSettings={setVoiceSettings} /> ) : null} {finishDialogOpen ? ( ) : 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 = (
{orderedAssignments.map((assignment) => (
{getPlayerNumber(teamSlot, assignment.slot)} {assignment.name}
))}
) const serveBar = ( ) const scoreBoard = ( ) return (
{teamSlot === 'top' ? ( <> {header} {serveBar} {scoreBoard} ) : ( <> {scoreBoard} {serveBar} {header} )}
) } 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 (
event.stopPropagation()} role="dialog" >
{selectionCount >= 4 ? '已完成選擇' : '請選滿 4 位球員'}
{selectionCount}/4
依序選擇球員

第 {group.id} 組 / {sourceLabel} / {targetDate || '-'}

{selectablePlayers.map((playerName) => { const checked = draftPlayers.includes(playerName) const selectedOrder = checked ? draftPlayers.indexOf(playerName) + 1 : null return ( ) })}
) } type VoiceSettingsModalProps = { settings: VoiceSettings onClose: () => void onUpdateSettings: Dispatch> } function VoiceSettingsModal({ settings, onClose, onUpdateSettings, }: VoiceSettingsModalProps) { return (
event.stopPropagation()} >

語音設定

播報內容

) } 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 (

比賽結算

{matchupLabel}

{leftScore} {leftTeamName}
:
{rightScore} {rightTeamName}

要不要把這場比賽戰績上傳到資料庫?

{error ?

{error}

: null}
) } 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, }) } function loadVoiceSettings(): VoiceSettings { try { const raw = window.localStorage.getItem(VOICE_SETTINGS_STORAGE_KEY) if (!raw) { return defaultVoiceSettings } const parsed = JSON.parse(raw) as Partial return { announceScore: parsed.announceScore ?? defaultVoiceSettings.announceScore, announceServer: parsed.announceServer ?? defaultVoiceSettings.announceServer, rate: typeof parsed.rate === 'number' ? Math.min(10, Math.max(0.7, parsed.rate)) : defaultVoiceSettings.rate, } } catch { return defaultVoiceSettings } } function getAnnouncementName(team: GroupTeam | null) { return team?.playerA ?? '本隊' } function speakAnnouncement(message: string, rate: number) { if (!('speechSynthesis' in window)) { return } const synthesis = window.speechSynthesis const utterance = new SpeechSynthesisUtterance(message) const voices = synthesis.getVoices() const zhVoice = voices.find((voice) => voice.lang.toLowerCase().startsWith('zh-tw')) ?? voices.find((voice) => voice.lang.toLowerCase().startsWith('zh')) utterance.lang = zhVoice?.lang ?? 'zh-TW' utterance.rate = rate utterance.pitch = 1 utterance.volume = 1 if (zhVoice) { utterance.voice = zhVoice } synthesis.cancel() synthesis.speak(utterance) }