diff --git a/README.md b/README.md index 106a6ba..c8f82ff 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,9 @@ - 可從資料庫讀取歷史列表。 - 點開單筆可查看得分過程。 - 每筆資料可刪除,刪除前會顯示確認提示。 +- 今日場次 + - 選隊伍面板每個人名字旁顯示 `今日 N 場`,每次結算幫上場的人各加一場,存在本機、跨日歸零。 + - 面板的 `同步今日場次` 按鈕會從 `history` 表撈今天(本機時區)的比賽,把每個人的今日場次覆蓋成 DB 的數字,跨裝置記分時用來補齊其他裝置上傳的場次。 - 出席率統計 - 歷史戰績頁的 `出席率統計` 按鈕會彈窗顯示每個人的出席狀況。 - 資料取自 `attendance` 表(TG 報名 bot 每天每人一列),不是 `history` 表,所以有報名到場但沒被記分的場次也算出席;當天名單還沒存進 `badminton` 表也已經算得到。 diff --git a/server/server.mjs b/server/server.mjs index 487b3bc..e8e5666 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -528,6 +528,63 @@ app.get('/api/history', async (request, response) => { } }) +// 指定時間區間內(通常是「今天」)每個人在 history 表打了幾場, +// 讓不同裝置記分後可以把本機的今日場次同步成 DB 的數字。 +app.get('/api/history/play-counts', async (request, response) => { + if (!pool) { + response.status(500).json({ + ok: false, + message: `DB 尚未設定完成,缺少 ${missingEnv.join(', ')}`, + }) + return + } + + const from = Number.parseInt(request.query.from, 10) + const to = Number.parseInt(request.query.to, 10) + + if (!Number.isFinite(from) || !Number.isFinite(to) || from >= to) { + response.status(400).json({ + ok: false, + message: '時間區間格式不正確。', + }) + return + } + + try { + await ensureHistoryTable(pool, historyTableName) + const [rows] = await pool.execute( + `SELECT players FROM \`${historyTableName}\` WHERE time >= ? AND time < ?`, + [from, to], + ) + const counts = {} + + rows.forEach((row) => { + parseAttendanceNames(row.players).forEach((name) => { + // 輪空等佔位字不是真的有人上場。 + if (ATTENDANCE_IGNORED_NAMES.has(name)) { + return + } + + counts[name] = (counts[name] ?? 0) + 1 + }) + }) + + response.json({ + ok: true, + data: { + counts, + matches: rows.length, + }, + }) + } catch (error) { + console.error('history play-counts error:', error) + response.status(500).json({ + ok: false, + message: error instanceof Error ? error.message : '讀取今日場次失敗。', + }) + } +}) + app.delete('/api/history/:id', async (request, response) => { if (!pool) { response.status(500).json({ diff --git a/src/App.css b/src/App.css index 97dca1c..2d5e0bd 100644 --- a/src/App.css +++ b/src/App.css @@ -1540,10 +1540,27 @@ .team-picker-actions { display: grid; - grid-template-columns: 1fr 1fr; + /* 同步今日場次 / 自動選擇 / 確認 三顆並排,確認稍寬一點當主要按鈕。 */ + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.2fr); gap: 10px; } +.team-picker-actions button { + white-space: nowrap; +} + +/* 同步今日場次的結果或錯誤訊息,放在按鈕列上方。 */ +.team-picker-sync-note { + margin: 0 0 8px; + padding: 6px 10px; + border-radius: 10px; + color: #5c4633; + background: rgba(255, 255, 255, 0.6); + box-shadow: inset 0 0 0 1px rgba(199, 155, 83, 0.22); + font-size: 0.78rem; + line-height: 1.4; +} + .team-picker-ghost, .team-picker-confirm, .team-picker-clear { @@ -2670,6 +2687,19 @@ font-size: 0.86rem; } + /* 手機版面板很窄,同步按鈕獨占一列,下面才是自動選擇 / 確認。 */ + .team-picker-actions { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + } + + .team-picker-sync-button { + grid-column: 1 / -1; + } + + .team-picker-sync-note { + font-size: 0.72rem; + } + .preset-team-list { gap: 6px; max-height: min(42dvh, 300px); diff --git a/src/App.tsx b/src/App.tsx index be7af27..92ecbd5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { NavLink, Route, Routes, useLocation, useNavigate } from 'react-router-d import './App.css' import { createLiveRoom, + loadHistoryPlayCounts, loadMatchResults, releaseLiveRoom, rememberCertNotAfter, @@ -939,6 +940,20 @@ function App() { }) } + // 從 DB 的 history 表撈今天(本機時區)的比賽,把每個人的今日場次覆蓋成 DB 的數字。 + // 跨裝置記分時,其他裝置上傳的場次本機沒有紀錄,靠這個補齊。 + const syncPlayCountsFromHistory = async () => { + const dayStart = new Date() + dayStart.setHours(0, 0, 0, 0) + const from = Math.floor(dayStart.getTime() / 1000) + const to = from + 24 * 60 * 60 + + const result = await loadHistoryPlayCounts(from, to) + setPlayCounts(result.counts) + + return result + } + const skipUpload = () => { void finalizeLiveRoom().finally(() => { recordPlayedMatch() @@ -1140,6 +1155,7 @@ function App() { voiceAnnouncement={voiceAnnouncement} targetDate={targetDate} onApplyMatchup={applyMatchup} + onSyncPlayCounts={syncPlayCountsFromHistory} onCloseFinishDialog={closeSettlementDialog} onConfirmUpload={uploadSettledMatch} onOpenFinishDialog={openSettlementDialog} diff --git a/src/lib/api.ts b/src/lib/api.ts index af945e3..f4691f3 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -2,6 +2,7 @@ import type { AttendanceStats, HistoryListItem, HistoryListPage, + HistoryPlayCounts, HistoryRecord, HistoryUploadPayload, HistoryUploadResponse, @@ -154,6 +155,26 @@ export async function loadAttendanceStats(): Promise { return payload.data } +// 讀取 history 表在 [from, to) 秒級時間區間內每個人打了幾場。 +export async function loadHistoryPlayCounts(from: number, to: number): Promise { + const response = await apiFetch(`/api/history/play-counts?from=${from}&to=${to}`) + const payload = (await readJsonSafely(response)) as { + ok?: boolean + message?: string + data?: HistoryPlayCounts + } + + if (response.status === 404) { + throw new Error('後端還沒更新到同步場次功能,請重新部署最新版。') + } + + if (!response.ok || !payload.ok || !payload.data) { + throw new Error(payload.message ?? '讀取今日場次失敗。') + } + + return payload.data +} + export async function deleteHistoryItem(id: number) { const response = await apiFetch(`/api/history/${id}`, { method: 'DELETE', diff --git a/src/pages/ScoreboardPage.tsx b/src/pages/ScoreboardPage.tsx index 533856f..85c2111 100644 --- a/src/pages/ScoreboardPage.tsx +++ b/src/pages/ScoreboardPage.tsx @@ -12,6 +12,7 @@ import { import type { CourtSide, GroupTeam, + HistoryPlayCounts, PlayerSlot, PointHistoryEntry, RoundGroup, @@ -85,6 +86,7 @@ type ScoreboardPageProps = { onOpenFinishDialog: () => void onRecordPoint: (side: ScoreSide) => void onSetServing: (side: ScoreSide) => void + onSyncPlayCounts: () => Promise onSkipUpload: () => void onSwapMatchup: () => void onSwapTeamPlayers: (side: ScoreSide) => void @@ -117,6 +119,7 @@ export function ScoreboardPage({ onOpenFinishDialog, onRecordPoint, onSetServing, + onSyncPlayCounts, onSkipUpload, onSwapMatchup, onSwapTeamPlayers, @@ -124,6 +127,11 @@ export function ScoreboardPage({ }: ScoreboardPageProps) { const FINISH_HOLD_DURATION = 1000 const [pickerOpen, setPickerOpen] = useState(false) + // 從 DB 同步今日場次的進度與結果訊息,只在選隊面板顯示。 + const [playCountsSync, setPlayCountsSync] = useState<{ loading: boolean; message: string }>({ + loading: false, + message: '', + }) const [settingsOpen, setSettingsOpen] = useState(false) const [draftPlayers, setDraftPlayers] = useState([]) const [draftTargetScore, setDraftTargetScore] = useState(() => @@ -544,6 +552,32 @@ export function ScoreboardPage({ setDraftPlayers(shuffled.slice(0, 4)) } + const syncPlayCounts = () => { + if (playCountsSync.loading) { + return + } + + setPlayCountsSync({ loading: true, message: '' }) + + onSyncPlayCounts() + .then((result) => { + const playerCount = Object.keys(result.counts).length + setPlayCountsSync({ + loading: false, + message: + result.matches === 0 + ? 'DB 今天還沒有比賽紀錄,今日場次已全部歸零。' + : `已同步 DB 今天 ${result.matches} 場比賽、${playerCount} 位球員的場次。`, + }) + }) + .catch((error: unknown) => { + setPlayCountsSync({ + loading: false, + message: error instanceof Error ? error.message : '同步今日場次失敗。', + }) + }) + } + return ( <> {streakAnnouncement ? ( @@ -732,6 +766,7 @@ export function ScoreboardPage({ extraPlayerSet={extraPlayerSet} group={selectedGroup} playCounts={playCounts} + playCountsSync={playCountsSync} presetTeams={presetTeams} selectablePlayers={selectablePlayers} selectionCount={draftPlayers.length} @@ -742,6 +777,7 @@ export function ScoreboardPage({ onClose={() => setPickerOpen(false)} onConfirm={confirmDraftTeams} onDraftTargetScoreChange={setDraftTargetScore} + onSyncPlayCounts={syncPlayCounts} onTogglePlayer={toggleDraftPlayer} onTogglePresetTeam={togglePresetTeam} /> @@ -921,6 +957,7 @@ type TeamPickerModalProps = { draftPlayers: string[] extraPlayerSet: Set playCounts: Record + playCountsSync: { loading: boolean; message: string } draftTargetScore: string group: RoundGroup presetTeams: GroupTeam[] @@ -933,6 +970,7 @@ type TeamPickerModalProps = { onClose: () => void onConfirm: () => void onDraftTargetScoreChange: (value: string) => void + onSyncPlayCounts: () => void onTogglePlayer: (playerName: string) => void onTogglePresetTeam: (team: GroupTeam) => void } @@ -941,6 +979,7 @@ function TeamPickerModal({ draftPlayers, extraPlayerSet, playCounts, + playCountsSync, draftTargetScore, group, presetTeams, @@ -953,6 +992,7 @@ function TeamPickerModal({ onClose, onConfirm, onDraftTargetScoreChange, + onSyncPlayCounts, onTogglePlayer, onTogglePresetTeam, }: TeamPickerModalProps) { @@ -1035,7 +1075,20 @@ function TeamPickerModal({ })} + {playCountsSync.message ? ( +

{playCountsSync.message}

+ ) : null} +
+ diff --git a/src/types.ts b/src/types.ts index 58b6958..8398c43 100644 --- a/src/types.ts +++ b/src/types.ts @@ -131,6 +131,11 @@ export type HistoryListItem = { winnerTeamName: string } +export type HistoryPlayCounts = { + counts: Record + matches: number +} + export type HistoryListPage = { items: HistoryListItem[] page: number