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

453 lines
14 KiB
TypeScript
Raw Normal View History

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[]
}
2026-04-15 10:11:53 +08:00
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('')
2026-04-15 10:11:53 +08:00
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 位成員。')
2026-04-15 10:11:53 +08:00
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('')
2026-04-15 10:11:53 +08:00
setActionState('idle')
setActionMessage('已產生配對,請按「上傳資料」。')
}
async function uploadResults() {
if (results.length === 0) {
2026-04-15 10:11:53 +08:00
setActionState('error')
setActionMessage('請先產生配對結果,再上傳資料。')
return
}
if (!targetDate) {
2026-04-15 10:11:53 +08:00
setActionState('error')
setActionMessage('請先選擇目標日期。')
return
}
2026-04-15 10:11:53 +08:00
setActionState('saving')
setActionMessage('上傳資料到資料庫中...')
try {
await saveMatchResults(convertDateToKey(targetDate), parsedAreaA, parsedAreaB, results)
2026-04-15 10:11:53 +08:00
setActionState('saved')
setActionMessage(`已同步到資料庫:${convertDateToKey(targetDate)}`)
} catch (saveError) {
2026-04-15 10:11:53 +08:00
setActionState('error')
setActionMessage(
saveError instanceof Error ? saveError.message : '資料庫儲存失敗,請稍後再試。',
)
}
}
2026-04-15 10:11:53 +08:00
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('')
2026-04-15 10:11:53 +08:00
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>
2026-04-15 10:11:53 +08:00
{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()}
2026-04-15 10:11:53 +08:00
disabled={results.length === 0 || actionState === 'saving' || actionState === 'loading'}
>
</button>
2026-04-15 10:11:53 +08:00
<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 ?? '資料庫儲存失敗。')
}
}
2026-04-15 10:11:53 +08:00
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