diff --git a/README.md b/README.md index c8f82ff..c8beedb 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,9 @@ - 選隊伍頁面 - 可依指定日期從資料庫讀取分組資料。 - - 若當天沒有資料,可手動輸入 A、B 區名單建立配對。 + - match-hub V2 場地排程資料(`battlecombination` 以場地為 key、每場地 8 局 × 2 隊)會顯示成「2 號場地」「3 號場地」各 8 局賽程,挑一個場地帶進記分板;選隊面板的預設隊伍也會照局分組。 + - 舊的 V1 三輪資料(personnel 含 B 區)仍照「第 N 組」顯示,兩種格式自動判別。 + - 若當天沒有資料,可手動輸入「今日出席名單」排賽程:演算法與 match-hub V2 相同,依 `tg_members.skill` 實力(`GET /api/skills`,表名可用 `DB_MEMBERS_TABLE` 覆寫)做強弱互補與兩隊平衡,搭檔在全部組合輪過一遍前不重複、每人局數落差最多 1 局;8 人以上排 2、3 號兩個場地,未滿 8 人只排 2 號場地。 - 點進記分板時會直接帶入該組對戰。 - 記分板 - 兩隊隊員可自由交換上下、左右位置。 diff --git a/server/server.mjs b/server/server.mjs index e8e5666..27254cd 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -12,6 +12,8 @@ const matchTableName = process.env.DB_TABLE ?? 'badminton' const historyTableName = process.env.DB_HISTORY_TABLE ?? 'history' // 出席統計的來源:TG 報名 bot 寫入的 attendance 表(每天每人一列)。 const attendanceTableName = process.env.DB_ATTENDANCE_TABLE ?? 'attendance' +// 成員實力表(match-hub 維護的 tg_members.skill)。 +const membersTableName = process.env.DB_MEMBERS_TABLE ?? 'tg_members' const appVersion = process.env.APP_VERSION ?? `${Date.now()}` const appStartedAt = new Date().toISOString() const LIVE_ROOM_STALE_MS = 30_000 @@ -380,6 +382,43 @@ app.get('/api/match-results/:time', async (request, response) => { } }) +// 全體成員實力表(1~10,預設 1),給首頁手動排賽程時做實力配對用。 +app.get('/api/skills', async (_request, response) => { + if (!pool) { + response.status(500).json({ + ok: false, + message: `DB 尚未設定完成,缺少 ${missingEnv.join(', ')}`, + }) + return + } + + try { + const [rows] = await pool.execute( + `SELECT nickname, skill FROM \`${membersTableName}\` WHERE nickname IS NOT NULL`, + ) + const skills = {} + + rows.forEach((row) => { + const name = String(row.nickname ?? '').trim() + + if (name) { + skills[name] = clampSkill(row.skill) + } + }) + + response.json({ + ok: true, + data: { skills }, + }) + } catch (error) { + console.error('skills load error:', error) + response.status(500).json({ + ok: false, + message: error instanceof Error ? error.message : '實力資料讀取失敗。', + }) + } +}) + app.get('/api/attendance', async (_request, response) => { if (!pool) { response.status(500).json({ @@ -895,6 +934,17 @@ function broadcastRoomList() { }) } +// 實力限制在 1~10 的整數,缺值或壞值一律當 1。 +function clampSkill(value) { + const skill = Number(value) + + if (!Number.isFinite(skill)) { + return 1 + } + + return Math.min(10, Math.max(1, Math.round(skill))) +} + function getAttendanceName(rawName) { const name = String(rawName ?? '').trim() return ATTENDANCE_ALIASES[name] ?? name diff --git a/src/App.css b/src/App.css index 2d5e0bd..b455fd1 100644 --- a/src/App.css +++ b/src/App.css @@ -319,11 +319,7 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); } -/* 其他區名單跨滿整行,放在 A/B 區下方。 */ -.field-area-other { - grid-column: 1 / -1; -} - +/* 其他區名單和今日出席並排,各占一欄。 */ .field-area-other textarea { min-height: 72px; } @@ -432,6 +428,44 @@ color: var(--panel-soft); } +/* V2 場地排程:首頁每個場地卡片內的 8 局賽程列表。 */ +.court-game-list { + display: grid; + gap: 8px; + margin-top: 18px; +} + +.court-game-row { + display: grid; + grid-template-columns: 58px minmax(0, 1fr) auto minmax(0, 1fr); + gap: 10px; + align-items: center; + padding: 10px 14px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.88); + border: 1px solid rgba(10, 51, 45, 0.08); +} + +.court-game-index { + color: var(--panel-soft); + font-size: 0.82rem; + white-space: nowrap; +} + +.court-game-team { + font-weight: 700; + overflow-wrap: anywhere; +} + +.court-game-team:last-child { + text-align: right; +} + +.court-game-vs { + color: var(--panel-soft); + font-size: 0.78rem; +} + .team-name { margin-top: 10px; font-size: 1.08rem; @@ -1633,6 +1667,18 @@ overflow: auto; } +/* V2 場地排程:預設隊伍清單裡每局的小標題(兩隊一局)。 */ +.preset-game-label { + margin-top: 4px; + color: var(--panel-soft); + font-size: 0.74rem; + letter-spacing: 0.08em; +} + +.preset-game-label:first-child { + margin-top: 0; +} + .preset-team-card { display: grid; grid-template-columns: 44px minmax(0, 1fr); diff --git a/src/App.tsx b/src/App.tsx index 92ecbd5..fa56ae5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { createLiveRoom, loadHistoryPlayCounts, loadMatchResults, + loadMemberSkills, releaseLiveRoom, rememberCertNotAfter, saveMatchHistory, @@ -12,7 +13,7 @@ import { updateLiveRoom, } from './lib/api' import { - buildManualGroups, + buildCourtGroups, convertDateToKey, convertDbRecordToGroups, formatDateInputValue, @@ -51,10 +52,26 @@ const STORAGE_KEYS = { areaOther: 'badminton-scoreboard::area-other', history: 'badminton-scoreboard::history', playCounts: 'badminton-scoreboard::play-counts', + roster: 'badminton-scoreboard::roster-today', } as const -const defaultAreaA = ['柏威', '建喵', 'Yuki', '阿釧'] -const defaultAreaB = ['RURU', '玟瑄', '培根', 'Tim'] +// 首次啟用今日出席名單時,把舊版 A / B 區的儲存內容合併過來。 +function loadInitialRoster() { + const stored = loadStoredText(STORAGE_KEYS.roster, '') + + if (stored.trim()) { + return stored + } + + const legacy = [ + loadStoredText(STORAGE_KEYS.areaA, ''), + loadStoredText(STORAGE_KEYS.areaB, ''), + ] + .join('\n') + .trim() + + return legacy +} const initialScoreState: ScoreState = { scoreLeft: 0, @@ -119,12 +136,11 @@ function App() { const isScoreboardRoute = location.pathname === '/scoreboard' const [targetDate, setTargetDate] = useState(() => formatDateInputValue()) - const [areaAInput, setAreaAInput] = useState(() => - loadStoredText(STORAGE_KEYS.areaA, defaultAreaA.join('\n')), - ) - const [areaBInput, setAreaBInput] = useState(() => - loadStoredText(STORAGE_KEYS.areaB, defaultAreaB.join('\n')), - ) + // 今日出席名單:手動排賽程的對象,取代舊版 A / B 區。 + const [rosterInput, setRosterInput] = useState(() => loadInitialRoster()) + // 全體成員實力表(tg_members.skill),排賽程時做強弱互補與兩隊平衡。 + const [skillMap, setSkillMap] = useState>({}) + const [skillsReady, setSkillsReady] = useState(false) // 其他區:不參與分組配對,但會帶進記分板讓臨時上場的人可以被選到。 const [areaOtherInput, setAreaOtherInput] = useState(() => loadStoredText(STORAGE_KEYS.areaOther, ''), @@ -167,8 +183,7 @@ function App() { const creatingRoomRef = useRef(false) const lastSyncedRoomSignatureRef = useRef('') - const parsedAreaA = useMemo(() => parseRoster(areaAInput), [areaAInput]) - const parsedAreaB = useMemo(() => parseRoster(areaBInput), [areaBInput]) + const parsedRoster = useMemo(() => parseRoster(rosterInput), [rosterInput]) const parsedAreaOther = useMemo(() => parseRoster(areaOtherInput), [areaOtherInput]) const selectedGroup = groups.find((group) => group.id === selectedGroupId) ?? null const leftTeam = activeMatchup.leftTeam @@ -177,12 +192,21 @@ function App() { const isNavigationLocked = Boolean(leftTeam && rightTeam && scoreState.serving !== null) useEffect(() => { - window.localStorage.setItem(STORAGE_KEYS.areaA, areaAInput) - }, [areaAInput]) + window.localStorage.setItem(STORAGE_KEYS.roster, rosterInput) + }, [rosterInput]) + // 開站先載入實力表,載入完成前不能手動產生賽程(跟 match-hub 一致)。 useEffect(() => { - window.localStorage.setItem(STORAGE_KEYS.areaB, areaBInput) - }, [areaBInput]) + void (async () => { + try { + const skills = await loadMemberSkills() + setSkillMap(skills) + setSkillsReady(true) + } catch (error) { + console.error('skills load error:', error) + } + })() + }, []) useEffect(() => { window.localStorage.setItem(STORAGE_KEYS.areaOther, areaOtherInput) @@ -682,8 +706,7 @@ function App() { } const nextData = convertDbRecordToGroups(record) - setAreaAInput(nextData.areaA.join('\n')) - setAreaBInput(nextData.areaB.join('\n')) + setRosterInput([...nextData.areaA, ...nextData.areaB].join('\n')) setGroups(nextData.groups) setGroupSource('db') setLoadStatus('loaded') @@ -700,21 +723,31 @@ function App() { } const generateManualGroups = () => { - if (parsedAreaA.length === 0 || parsedAreaB.length === 0) { + if (parsedRoster.length < 4) { setGroups([]) setSelectedGroupId(null) setActiveMatchup({ leftTeam: null, rightTeam: null }) setGroupSource('idle') setLoadStatus('error') - setLoadMessage('A 區與 B 區至少都要有 1 位成員。') + setLoadMessage('今日出席至少需要 4 位成員才能排賽程。') return } - const nextGroups = buildManualGroups(parsedAreaA, parsedAreaB) + if (!skillsReady) { + setLoadStatus('error') + setLoadMessage('實力資料還沒載入完成,請稍候再產生賽程(或重新整理頁面)。') + return + } + + const nextGroups = buildCourtGroups(parsedRoster, skillMap) setGroups(nextGroups) setGroupSource('manual') setLoadStatus('loaded') - setLoadMessage('已產生手動配對結果,請選擇要使用的組別。') + setLoadMessage( + nextGroups.length === 2 + ? '已依實力產生 2、3 號場地各 8 局賽程,挑一個場地進記分板。' + : '人數不足 8 人,只排 2 號場地 8 局賽程。', + ) selectGroup(nextGroups[0]?.id ?? 1, nextGroups) } @@ -1091,17 +1124,15 @@ function App() { path="/" element={ void loadGroupsFromDb()} onTargetDateChange={setTargetDate} @@ -1113,17 +1144,15 @@ function App() { path="/teams" element={ void loadGroupsFromDb()} onTargetDateChange={setTargetDate} diff --git a/src/lib/api.ts b/src/lib/api.ts index f4691f3..c3e788f 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -136,6 +136,22 @@ export async function loadHistoryList(page = 1, pageSize = 20): Promise> { + const response = await apiFetch('/api/skills') + const payload = (await readJsonSafely(response)) as { + ok?: boolean + message?: string + data?: { skills?: Record } + } + + if (!response.ok || !payload.ok || !payload.data?.skills) { + throw new Error(payload.message ?? '實力資料讀取失敗。') + } + + return payload.data.skills +} + export async function loadAttendanceStats(): Promise { const response = await apiFetch('/api/attendance') const payload = (await readJsonSafely(response)) as { diff --git a/src/lib/match.ts b/src/lib/match.ts index d0f3318..5c367ea 100644 --- a/src/lib/match.ts +++ b/src/lib/match.ts @@ -7,7 +7,23 @@ import type { } from '../types' const PLACEHOLDER_NAME = '輪空' -const TOTAL_GROUPS = 3 +// match-hub V2 場地排程實際使用的場地編號(依 battlecombination key 排序後對應)。 +const COURT_NUMBERS = [2, 3] +// 以下常數與演算法移植自 match-hub 的 V2 場地排程。 +const GAMES_PER_COURT = 8 +const PLAYERS_PER_GAME = 4 +const PAIR_SCORE_BASE = 16 +const SCHEDULE_ATTEMPTS = 40 + +type Pair = [string, string] + +type CourtGame = { + court: number + teamA: Pair + teamB: Pair +} + +export type SkillMap = Record export function parseRoster(input: string) { const uniqueNames = new Set() @@ -21,57 +37,254 @@ export function parseRoster(input: string) { return Array.from(uniqueNames) } -export function buildManualGroups(areaA: string[], areaB: string[]) { - const shuffledA = shuffleList(areaA) - const shuffledB = shuffleList(areaB) +// 手動產生賽程(移植 match-hub V2):每局從全員挑 8 人分兩場地打雙打,其餘輪休, +// 搭檔在全部組合輪過一遍前不重複、強弱互補,每局兩隊實力總和盡量接近。 +// 回傳跟 DB V2 資料同樣的場地格式:每個場地一組,teams 依序是每局的兩隊。 +export function buildCourtGroups(players: string[], skillMap: SkillMap) { + const rounds = buildSchedule(players, skillMap) + const courtPairs = new Map() - // 每輪先做跨區平衡再配對:兩區差距 2 以上時抽「差距÷2」人跨區支援, - // 且每輪輪替不同人;只有總人數為奇數時才會補 1 個輪空。 - return Array.from({ length: TOTAL_GROUPS }, (_, roundIndex) => { - const balanced = balanceZones(shuffledA, shuffledB, roundIndex) - const targetCount = Math.max(balanced.areaA.length, balanced.areaB.length) - const paddedA = padTeams(balanced.areaA, targetCount) - const paddedB = padTeams(balanced.areaB, targetCount) + rounds.forEach((games) => { + games.forEach((game) => { + const pairs = courtPairs.get(game.court) ?? [] + pairs.push(game.teamA, game.teamB) + courtPairs.set(game.court, pairs) + }) + }) - return { - id: roundIndex + 1, - teams: paddedA.map((playerA, index) => - createTeam( - index + 1, - playerA, - paddedB[(index + roundIndex) % paddedB.length], - ), + return Array.from(courtPairs.entries()) + .sort((left, right) => left[0] - right[0]) + .map(([court, pairs], courtIndex) => ({ + id: courtIndex + 1, + label: `${court} 號場地`, + teams: pairs.map(([playerA, playerB], teamIndex) => + createTeam(teamIndex + 1, playerA, playerB), ), + })) +} + +// 重試多次取「搭檔重複最少、整份賽程兩隊實力差總和最小」的一份。 +function buildSchedule(players: string[], skillMap: SkillMap) { + let best: CourtGame[][] = [] + let bestScore = Number.POSITIVE_INFINITY + + for (let attempt = 0; attempt < SCHEDULE_ATTEMPTS; attempt += 1) { + const { rounds, repeatScore } = buildScheduleOnce(players, skillMap) + const imbalance = rounds.reduce( + (sum, games) => + sum + + games.reduce( + (gameSum, game) => + gameSum + + Math.abs(pairSkillSum(game.teamA, skillMap) - pairSkillSum(game.teamB, skillMap)), + 0, + ), + 0, + ) + // 不重複優先(權重放大),再比整份賽程的兩隊實力差總和 + const score = repeatScore * 100000 + imbalance + + if (score < bestScore) { + bestScore = score + best = rounds + } + + if (bestScore === 0) { + break + } + } + + return best +} + +function buildScheduleOnce(players: string[], skillMap: SkillMap) { + const courtCount = players.length >= COURT_NUMBERS.length * PLAYERS_PER_GAME ? 2 : 1 + const pairUse = new Map() + const opponentUse = new Map() + const playCount = new Map(players.map((name) => [name, 0])) + const rounds: CourtGame[][] = [] + + for (let roundIndex = 0; roundIndex < GAMES_PER_COURT; roundIndex += 1) { + const active = pickActivePlayers(players, playCount, courtCount * PLAYERS_PER_GAME) + const pairs = pickBestMatching(active, pairUse, skillMap) + const games = pickBestGames(pairs, opponentUse, skillMap) + + games.forEach((game) => { + bumpUse(pairUse, pairKey(game.teamA[0], game.teamA[1])) + bumpUse(pairUse, pairKey(game.teamB[0], game.teamB[1])) + game.teamA.forEach((left) => { + game.teamB.forEach((right) => bumpUse(opponentUse, pairKey(left, right))) + }) + ;[...game.teamA, ...game.teamB].forEach((name) => { + playCount.set(name, (playCount.get(name) ?? 0) + 1) + }) + }) + + rounds.push(games) + } + + const totalPairs = (players.length * (players.length - 1)) / 2 + const allowedUse = Math.max( + 1, + Math.ceil((GAMES_PER_COURT * courtCount * 2) / totalPairs), + ) + let repeatScore = 0 + pairUse.forEach((useCount) => { + if (useCount > allowedUse) { + repeatScore += useCount - allowedUse } }) + + return { rounds, repeatScore } } -function balanceZones(areaA: string[], areaB: string[], roundIndex: number) { - const moveCount = Math.floor(Math.abs(areaA.length - areaB.length) / 2) - - if (moveCount === 0) { - return { areaA, areaB } - } - - if (areaA.length > areaB.length) { - const { moved, remaining } = pickCrossMembers(areaA, moveCount, roundIndex) - return { areaA: remaining, areaB: [...areaB, ...moved] } - } - - const { moved, remaining } = pickCrossMembers(areaB, moveCount, roundIndex) - return { areaA: [...areaA, ...moved], areaB: remaining } +// 上場局數少的人優先,讓每人局數落差最多 1 局。 +function pickActivePlayers(players: string[], playCount: Map, needed: number) { + return shuffleList(players) + .sort((left, right) => (playCount.get(left) ?? 0) - (playCount.get(right) ?? 0)) + .slice(0, needed) } -// 依輪次輪替抽出要跨區支援的人,讓跨區的負擔平均分攤在不同人身上。 -function pickCrossMembers(list: string[], moveCount: number, roundIndex: number) { - const movedIndexes = new Set( - Array.from({ length: moveCount }, (_, offset) => (roundIndex * moveCount + offset) % list.length), - ) +function pickBestMatching(active: string[], pairUse: Map, skillMap: SkillMap) { + const matchings = enumerateMatchings(active) + let bestScore = Number.POSITIVE_INFINITY + let candidates: Pair[][] = [] - return { - moved: list.filter((_, index) => movedIndexes.has(index)), - remaining: list.filter((_, index) => !movedIndexes.has(index)), + matchings.forEach((matching) => { + const score = matching.reduce( + (sum, pair) => sum + PAIR_SCORE_BASE ** (pairUse.get(pairKey(pair[0], pair[1])) ?? 0), + 0, + ) + + if (score < bestScore) { + bestScore = score + candidates = [matching] + } else if (score === bestScore) { + candidates.push(matching) + } + }) + + // 在不重複的前提下,優先挑各組搭檔實力總和最接近的組合(強弱互補) + let bestSpread = Number.POSITIVE_INFINITY + let balanced: Pair[][] = [] + + candidates.forEach((matching) => { + const sums = matching.map((pair) => pairSkillSum(pair, skillMap)) + const spread = Math.max(...sums) - Math.min(...sums) + + if (spread < bestSpread) { + bestSpread = spread + balanced = [matching] + } else if (spread === bestSpread) { + balanced.push(matching) + } + }) + + return pickRandom(balanced) +} + +function enumerateMatchings(names: string[]): Pair[][] { + if (names.length < 2) { + return [[]] } + + const [first, ...rest] = names + const results: Pair[][] = [] + + rest.forEach((partner, index) => { + const remaining = rest.filter((_, restIndex) => restIndex !== index) + enumerateMatchings(remaining).forEach((subMatching) => { + results.push([[first, partner], ...subMatching]) + }) + }) + + return results +} + +function pickBestGames( + pairs: Pair[], + opponentUse: Map, + skillMap: SkillMap, +): CourtGame[] { + if (pairs.length === 2) { + return [{ court: COURT_NUMBERS[0], teamA: pairs[0], teamB: pairs[1] }] + } + + const groupings: [Pair, Pair][][] = [ + [ + [pairs[0], pairs[1]], + [pairs[2], pairs[3]], + ], + [ + [pairs[0], pairs[2]], + [pairs[1], pairs[3]], + ], + [ + [pairs[0], pairs[3]], + [pairs[1], pairs[2]], + ], + ] + + // 先求兩隊實力總和最接近,實力相同時再挑對手重複最少的組合 + let bestImbalance = Number.POSITIVE_INFINITY + let bestOpponentScore = Number.POSITIVE_INFINITY + let balanced: [Pair, Pair][][] = [] + + groupings.forEach((grouping) => { + const imbalance = grouping.reduce( + (sum, [teamA, teamB]) => + sum + Math.abs(pairSkillSum(teamA, skillMap) - pairSkillSum(teamB, skillMap)), + 0, + ) + const opponentScore = grouping.reduce((sum, [teamA, teamB]) => { + let gameScore = 0 + teamA.forEach((left) => { + teamB.forEach((right) => { + gameScore += PAIR_SCORE_BASE ** (opponentUse.get(pairKey(left, right)) ?? 0) + }) + }) + return sum + gameScore + }, 0) + + if ( + imbalance < bestImbalance || + (imbalance === bestImbalance && opponentScore < bestOpponentScore) + ) { + bestImbalance = imbalance + bestOpponentScore = opponentScore + balanced = [grouping] + } else if (imbalance === bestImbalance && opponentScore === bestOpponentScore) { + balanced.push(grouping) + } + }) + + const orderedGames = shuffleList(pickRandom(balanced)) + return orderedGames.map(([teamA, teamB], index) => ({ + court: COURT_NUMBERS[index], + teamA, + teamB, + })) +} + +function pairSkillSum(pair: Pair, skillMap: SkillMap) { + return skillOf(pair[0], skillMap) + skillOf(pair[1], skillMap) +} + +function skillOf(name: string, skillMap: SkillMap) { + const skill = skillMap[name] + return typeof skill === 'number' && Number.isFinite(skill) ? skill : 1 +} + +function pairKey(left: string, right: string) { + return [left, right].sort().join('|') +} + +function bumpUse(useMap: Map, key: string) { + useMap.set(key, (useMap.get(key) ?? 0) + 1) +} + +function pickRandom(list: T[]) { + return list[Math.floor(Math.random() * list.length)] } export function convertDbRecordToGroups(record: MatchResultsRecord) { @@ -83,16 +296,31 @@ export function convertDbRecordToGroups(record: MatchResultsRecord) { const areaA = personnel.filter(([group]) => group === 1).map(([, name]) => name) const areaB = personnel.filter(([group]) => group === 0).map(([, name]) => name) + const sortedKeys = Object.keys(battlecombination).sort( + (left, right) => Number(left) - Number(right), + ) - const groups = Object.keys(battlecombination) - .sort((left, right) => Number(left) - Number(right)) - .map((key, roundIndex) => ({ - id: roundIndex + 1, + // match-hub V2 場地排程:personnel 沒有 B 區,battlecombination 以場地為 key, + // 每個場地存 8 局 × 2 隊的搭檔。顯示成「N 號場地」讓使用者挑一個場地帶進記分板。 + if (areaB.length === 0) { + const groups = sortedKeys.map((key, courtIndex) => ({ + id: courtIndex + 1, + label: `${COURT_NUMBERS[courtIndex] ?? courtIndex + COURT_NUMBERS[0]} 號場地`, teams: battlecombination[key].map(([playerA, playerB], teamIndex) => createTeam(teamIndex + 1, playerA, playerB), ), })) + return { areaA, areaB, groups } + } + + const groups = sortedKeys.map((key, roundIndex) => ({ + id: roundIndex + 1, + teams: battlecombination[key].map(([playerA, playerB], teamIndex) => + createTeam(teamIndex + 1, playerA, playerB), + ), + })) + return { areaA, areaB, groups } } @@ -230,16 +458,6 @@ function createTeam(id: number, playerA: string, playerB: string) { } } -function padTeams(list: string[], targetCount: number) { - const next = [...list] - - while (next.length < targetCount) { - next.push(PLACEHOLDER_NAME) - } - - return next -} - function shuffleList(list: T[]) { const next = [...list] diff --git a/src/pages/ScoreboardPage.tsx b/src/pages/ScoreboardPage.tsx index 85c2111..b31403f 100644 --- a/src/pages/ScoreboardPage.tsx +++ b/src/pages/ScoreboardPage.tsx @@ -1,5 +1,5 @@ import type { Dispatch, SetStateAction } from 'react' -import { useEffect, useMemo, useRef, useState } from 'react' +import { Fragment, useEffect, useMemo, useRef, useState } from 'react' import { Link } from 'react-router-dom' import { getCourtAssignments, @@ -1020,7 +1020,7 @@ function TeamPickerModal({
依序選擇球員

- 第 {group.id} 組 / {sourceLabel} / {targetDate || '-'} + {group.label ?? `第 ${group.id} 組`} / {sourceLabel} / {targetDate || '-'}

@@ -1111,30 +1111,35 @@ function TeamPickerModal({
- {presetTeams.map((team) => { + {presetTeams.map((team, teamIndex) => { const selectedSlot = getPresetTeamSelectionSlot(draftPlayers, team) + // V2 場地排程(有 label):兩隊一局,於每局第一隊前顯示局數標題。 + const gameLabel = + group.label && teamIndex % 2 === 0 ? `第 ${teamIndex / 2 + 1} 局` : null return ( - + + {gameLabel ?
{gameLabel}
: null} + +
) })}
@@ -1335,14 +1340,13 @@ function sanitizeTargetScore(value: string) { } 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 + const slot = getPresetTeamSelectionSlot(players, team) - if (firstPairSelected) { + if (slot === 0) { return players.slice(2) } - if (secondPairSelected) { + if (slot === 1) { return players.slice(0, 2) } @@ -1350,11 +1354,16 @@ function removePresetTeamFromDraft(players: string[], team: GroupTeam) { } function getPresetTeamSelectionSlot(players: string[], team: GroupTeam) { - if (players[0] === team.playerA && players[1] === team.playerB) { + // 兩人都在同一隊就算選到,不看先後順序(帶入現有對戰時右隊兩人是反序)。 + const matchesSlot = (first?: string, second?: string) => + (first === team.playerA && second === team.playerB) || + (first === team.playerB && second === team.playerA) + + if (matchesSlot(players[0], players[1])) { return 0 } - if (players[2] === team.playerA && players[3] === team.playerB) { + if (matchesSlot(players[2], players[3])) { return 1 } diff --git a/src/pages/TeamSelectionPage.tsx b/src/pages/TeamSelectionPage.tsx index 52dab95..12d3295 100644 --- a/src/pages/TeamSelectionPage.tsx +++ b/src/pages/TeamSelectionPage.tsx @@ -3,37 +3,33 @@ import { getTeamDisplayName } from '../lib/match' import type { LoadStatus, RoundGroup } from '../types' type TeamSelectionPageProps = { - areaAInput: string - areaBInput: string areaOtherInput: string groups: RoundGroup[] groupSource: 'idle' | 'db' | 'manual' loadMessage: string loadStatus: LoadStatus + rosterInput: string targetDate: string - onAreaAInputChange: (value: string) => void - onAreaBInputChange: (value: string) => void onAreaOtherInputChange: (value: string) => void onGenerateManualGroups: () => void onLoadGroupsFromDb: () => void + onRosterInputChange: (value: string) => void onTargetDateChange: (value: string) => void onUseGroup: (groupId: number) => void } export function TeamSelectionPage({ - areaAInput, - areaBInput, areaOtherInput, groups, groupSource, loadMessage, loadStatus, + rosterInput, targetDate, - onAreaAInputChange, - onAreaBInputChange, onAreaOtherInputChange, onGenerateManualGroups, onLoadGroupsFromDb, + onRosterInputChange, onTargetDateChange, onUseGroup, }: TeamSelectionPageProps) { @@ -78,25 +74,16 @@ export function TeamSelectionPage({