Files
badminton-match-hub/src/App.tsx

453 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react'
import './App.css'
type Participant = {
id: string
name: string
isPlaceholder: boolean
}
type Team = {
id: number
playerA: Participant
playerB: Participant
}
type RoundResult = {
id: number
teams: Team[]
}
type ActionState = 'idle' | 'saving' | 'saved' | 'loading' | 'loaded' | 'error'
type LoadMatchResultsResponse = {
time: number
personnel: string
battlecombination: string | null
}
const STORAGE_KEYS = {
areaA: 'badminton-match-hub::area-a',
areaB: 'badminton-match-hub::area-b',
} as const
const PLACEHOLDER_NAME = '那個'
const TOTAL_ROUNDS = 3
const defaultAreaA = ['建喵', '柏威', '景涵', 'Gary', '昱翔']
const defaultAreaB = ['小念', '玟瑄', 'RuRu', '肉肉', '蓉蓉']
function App() {
const [areaAInput, setAreaAInput] = useState(() =>
loadRoster(STORAGE_KEYS.areaA, defaultAreaA),
)
const [areaBInput, setAreaBInput] = useState(() =>
loadRoster(STORAGE_KEYS.areaB, defaultAreaB),
)
const [targetDate, setTargetDate] = useState(() => formatDateInputValue())
const [results, setResults] = useState<RoundResult[]>([])
const [error, setError] = useState('')
const [actionState, setActionState] = useState<ActionState>('idle')
const [actionMessage, setActionMessage] = useState('')
useEffect(() => {
window.localStorage.setItem(STORAGE_KEYS.areaA, areaAInput)
}, [areaAInput])
useEffect(() => {
window.localStorage.setItem(STORAGE_KEYS.areaB, areaBInput)
}, [areaBInput])
const parsedAreaA = parseRoster(areaAInput)
const parsedAreaB = parseRoster(areaBInput)
const teamCount = Math.max(parsedAreaA.length, parsedAreaB.length)
const placeholderCountA = Math.max(0, teamCount - parsedAreaA.length)
const placeholderCountB = Math.max(0, teamCount - parsedAreaB.length)
function generateGroups() {
if (parsedAreaA.length === 0 || parsedAreaB.length === 0) {
setResults([])
setError('A 區與 B 區都至少要輸入 1 位成員。')
setActionState('idle')
setActionMessage('')
return
}
const prepared = prepareParticipants(parsedAreaA, parsedAreaB)
const nextResults = Array.from({ length: TOTAL_ROUNDS }, (_, index) => ({
id: index + 1,
teams: createRoundTeams(prepared.areaA, prepared.areaB, index),
}))
setResults(nextResults)
setError('')
setActionState('idle')
setActionMessage('已產生配對,請按「上傳資料」。')
}
async function uploadResults() {
if (results.length === 0) {
setActionState('error')
setActionMessage('請先產生配對結果,再上傳資料。')
return
}
if (!targetDate) {
setActionState('error')
setActionMessage('請先選擇目標日期。')
return
}
setActionState('saving')
setActionMessage('上傳資料到資料庫中...')
try {
await saveMatchResults(convertDateToKey(targetDate), parsedAreaA, parsedAreaB, results)
setActionState('saved')
setActionMessage(`已同步到資料庫:${convertDateToKey(targetDate)}`)
} catch (saveError) {
setActionState('error')
setActionMessage(
saveError instanceof Error ? saveError.message : '資料庫儲存失敗,請稍後再試。',
)
}
}
async function loadResults() {
if (!targetDate) {
setActionState('error')
setActionMessage('請先選擇目標日期。')
return
}
setActionState('loading')
setActionMessage('讀取指定日期資料中...')
try {
const payload = await loadMatchResults(convertDateToKey(targetDate))
const loaded = convertDbRecordToAppState(payload)
setAreaAInput(loaded.areaA.join('\n'))
setAreaBInput(loaded.areaB.join('\n'))
setResults(loaded.results)
setError('')
setActionState('loaded')
setActionMessage(`已讀取資料:${convertDateToKey(targetDate)}`)
} catch (loadError) {
setResults([])
setActionState('error')
setActionMessage(
loadError instanceof Error ? loadError.message : '讀取資料失敗,請稍後再試。',
)
}
}
function resetDemo() {
setAreaAInput(defaultAreaA.join('\n'))
setAreaBInput(defaultAreaB.join('\n'))
setResults([])
setError('')
setActionState('idle')
setActionMessage('')
}
return (
<main className="app-shell">
<section className="hero-card">
<div className="hero-copy">
<p className="eyebrow"></p>
<h1></h1>
<p className="hero-text">
A B 3
1 A 1 B
</p>
<div className="hero-actions">
<button className="primary-button" type="button" onClick={generateGroups}>
</button>
<button className="ghost-button" type="button" onClick={resetDemo}>
</button>
</div>
{actionMessage ? (
<p className={`save-status save-status-${actionState}`}>{actionMessage}</p>
) : null}
</div>
<div className="hero-stats">
<article className="stat-card">
<span>A </span>
<strong>{parsedAreaA.length}</strong>
</article>
<article className="stat-card">
<span>B </span>
<strong>{parsedAreaB.length}</strong>
</article>
<article className="tip-card">
<h2></h2>
<p>
{teamCount} A {placeholderCountA} B {placeholderCountB}
</p>
</article>
</div>
</section>
<section className="panel input-panel">
<div className="panel-heading">
<p className="eyebrow"></p>
<h2> A B </h2>
</div>
<div className="upload-row">
<label className="date-field">
<span></span>
<input
type="date"
value={targetDate}
onChange={(event) => setTargetDate(event.target.value)}
/>
</label>
<button
className="primary-button upload-button"
type="button"
onClick={() => void uploadResults()}
disabled={results.length === 0 || actionState === 'saving' || actionState === 'loading'}
>
</button>
<button
className="ghost-button upload-button"
type="button"
onClick={() => void loadResults()}
disabled={actionState === 'saving' || actionState === 'loading'}
>
</button>
</div>
<div className="input-grid">
<label className="roster-field">
<span>A </span>
<textarea
value={areaAInput}
onChange={(event) => setAreaAInput(event.target.value)}
placeholder={'每行一位\n例如建喵'}
rows={10}
/>
</label>
<label className="roster-field">
<span>B </span>
<textarea
value={areaBInput}
onChange={(event) => setAreaBInput(event.target.value)}
placeholder={'每行一位\n例如小念'}
rows={10}
/>
</label>
</div>
<p className="helper-text"></p>
{error ? <p className="error-banner">{error}</p> : null}
</section>
<section className="panel results-panel">
<div className="panel-heading">
<p className="eyebrow"></p>
<h2></h2>
</div>
{results.length > 0 ? (
<div className="round-list">
{results.map((round) => (
<article className="round-card" key={round.id}>
<h3 className="round-title"> {round.id} </h3>
<div className="team-list">
{round.teams.map((team) => (
<article className="team-card" key={`${round.id}-${team.id}`}>
<span className="team-index"> {team.id} </span>
<div className="team-line">
<span className={team.playerA.isPlaceholder ? 'name-placeholder' : 'name-a'}>
{team.playerA.name}
</span>
<span className="team-divider">×</span>
<span className={team.playerB.isPlaceholder ? 'name-placeholder' : 'name-b'}>
{team.playerB.name}
</span>
</div>
</article>
))}
</div>
</article>
))}
</div>
) : (
<div className="empty-state">
<p></p>
</div>
)}
</section>
</main>
)
}
function prepareParticipants(areaA: string[], areaB: string[]) {
const targetCount = Math.max(areaA.length, areaB.length)
const shuffledA = shuffleList(areaA)
const shuffledB = shuffleList(areaB)
return {
areaA: createParticipants(shuffledA, targetCount, 'A'),
areaB: createParticipants(shuffledB, targetCount, 'B'),
}
}
function createParticipants(players: string[], targetCount: number, zone: 'A' | 'B') {
const participants: Participant[] = players.map((name, index) => ({
id: `${zone}-${index}-${name}`,
name,
isPlaceholder: false,
}))
for (let index = players.length; index < targetCount; index += 1) {
participants.push({
id: `${zone}-placeholder-${index}`,
name: PLACEHOLDER_NAME,
isPlaceholder: true,
})
}
return participants
}
function createRoundTeams(areaA: Participant[], areaB: Participant[], roundIndex: number) {
return areaA.map((playerA, index) => ({
id: index + 1,
playerA,
playerB: areaB[(index + roundIndex) % areaB.length],
}))
}
async function saveMatchResults(
time: string,
areaA: string[],
areaB: string[],
rounds: RoundResult[],
) {
const response = await fetch('/api/match-results', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
time,
areaA,
areaB,
teams: rounds.map((round) => ({
round: round.id,
teams: round.teams.map((team) => ({
team: team.id,
a: team.playerA.name,
b: team.playerB.name,
})),
})),
}),
})
const payload = (await response.json()) as { ok?: boolean; message?: string }
if (!response.ok || !payload.ok) {
throw new Error(payload.message ?? '資料庫儲存失敗。')
}
}
async function loadMatchResults(time: string) {
const response = await fetch(`/api/match-results/${time}`)
const payload = (await response.json()) as {
ok?: boolean
message?: string
data?: LoadMatchResultsResponse
}
if (!response.ok || !payload.ok || !payload.data) {
throw new Error(payload.message ?? '指定日期沒有資料。')
}
return payload.data
}
function convertDbRecordToAppState(record: LoadMatchResultsResponse) {
const personnel = JSON.parse(record.personnel) as [number, string][]
const battlecombination = JSON.parse(record.battlecombination ?? '{}') as Record<
string,
[string, string][]
>
const areaA = personnel.filter(([group]) => group === 1).map(([, name]) => name)
const areaB = personnel.filter(([group]) => group === 0).map(([, name]) => name)
const results = Object.keys(battlecombination)
.sort((left, right) => Number(left) - Number(right))
.map((key, roundIndex) => ({
id: roundIndex + 1,
teams: battlecombination[key].map((team, teamIndex) => ({
id: teamIndex + 1,
playerA: createLoadedParticipant(team[0], 'A', roundIndex, teamIndex),
playerB: createLoadedParticipant(team[1], 'B', roundIndex, teamIndex),
})),
}))
return { areaA, areaB, results }
}
function createLoadedParticipant(name: string, zone: 'A' | 'B', roundIndex: number, teamIndex: number) {
return {
id: `${zone}-loaded-${roundIndex}-${teamIndex}-${name}`,
name,
isPlaceholder: name === PLACEHOLDER_NAME,
}
}
function formatDateInputValue() {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function convertDateToKey(dateValue: string) {
const [year = '', month = '', day = ''] = dateValue.split('-')
return `${year}${month}${day}`
}
function loadRoster(storageKey: string, fallbackList: string[]) {
const storedValue = window.localStorage.getItem(storageKey)
return storedValue && storedValue.trim() ? storedValue : fallbackList.join('\n')
}
function parseRoster(input: string) {
const uniqueNames = new Set<string>()
input
.split(/[\n,,、]+/g)
.map((name) => name.trim())
.filter(Boolean)
.forEach((name) => uniqueNames.add(name))
return Array.from(uniqueNames)
}
function shuffleList<T>(list: T[]) {
const next = [...list]
for (let index = next.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(Math.random() * (index + 1))
;[next[index], next[swapIndex]] = [next[swapIndex], next[index]]
}
return next
}
export default App