功能:選隊面板新增「同步今日場次」,從 history 表撈今天資料覆蓋本機場次

摘要:
- 選隊伍面板新增「同步今日場次」按鈕,按下後把每個人的今日場次更新成 DB 的數字
- 後端新增 GET /api/history/play-counts?from=&to=,回傳區間內每人上場場數與比賽數
- 按鈕上方顯示同步結果或錯誤訊息;手機版同步按鈕獨占一列

根本原因:
- 今日場次只存在本機 localStorage,跨裝置記分時其他裝置上傳的場次本機看不到,
  自動選擇與「今日 N 場」提示就不準

影響:
- 任一裝置按同步後,今日場次會與 DB history 表一致(以 DB 為準,覆蓋本機)
- 輪空/那個等佔位字不計場次

修法:
- server.mjs 新增 play-counts 端點,依 time 區間查 history.players 累計
- api.ts 新增 loadHistoryPlayCounts;App.tsx 以本機時區當天 00:00 起算 24 小時區間並 setPlayCounts
- ScoreboardPage 加入同步狀態與按鈕,App.css 調整按鈕列為三欄、≤720px 改兩列

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 11:33:54 +08:00
co-authored by Claude Fable 5
parent d53ebbd3a8
commit 6ae1eda7e2
7 changed files with 186 additions and 1 deletions
+3
View File
@@ -34,6 +34,9 @@
- 可從資料庫讀取歷史列表。 - 可從資料庫讀取歷史列表。
- 點開單筆可查看得分過程。 - 點開單筆可查看得分過程。
- 每筆資料可刪除,刪除前會顯示確認提示。 - 每筆資料可刪除,刪除前會顯示確認提示。
- 今日場次
- 選隊伍面板每個人名字旁顯示 `今日 N 場`,每次結算幫上場的人各加一場,存在本機、跨日歸零。
- 面板的 `同步今日場次` 按鈕會從 `history` 表撈今天(本機時區)的比賽,把每個人的今日場次覆蓋成 DB 的數字,跨裝置記分時用來補齊其他裝置上傳的場次。
- 出席率統計 - 出席率統計
- 歷史戰績頁的 `出席率統計` 按鈕會彈窗顯示每個人的出席狀況。 - 歷史戰績頁的 `出席率統計` 按鈕會彈窗顯示每個人的出席狀況。
- 資料取自 `attendance` 表(TG 報名 bot 每天每人一列),不是 `history` 表,所以有報名到場但沒被記分的場次也算出席;當天名單還沒存進 `badminton` 表也已經算得到。 - 資料取自 `attendance` 表(TG 報名 bot 每天每人一列),不是 `history` 表,所以有報名到場但沒被記分的場次也算出席;當天名單還沒存進 `badminton` 表也已經算得到。
+57
View File
@@ -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) => { app.delete('/api/history/:id', async (request, response) => {
if (!pool) { if (!pool) {
response.status(500).json({ response.status(500).json({
+31 -1
View File
@@ -1540,10 +1540,27 @@
.team-picker-actions { .team-picker-actions {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; /* 同步今日場次 / 自動選擇 / 確認 三顆並排,確認稍寬一點當主要按鈕。 */
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.2fr);
gap: 10px; 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-ghost,
.team-picker-confirm, .team-picker-confirm,
.team-picker-clear { .team-picker-clear {
@@ -2670,6 +2687,19 @@
font-size: 0.86rem; 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 { .preset-team-list {
gap: 6px; gap: 6px;
max-height: min(42dvh, 300px); max-height: min(42dvh, 300px);
+16
View File
@@ -3,6 +3,7 @@ import { NavLink, Route, Routes, useLocation, useNavigate } from 'react-router-d
import './App.css' import './App.css'
import { import {
createLiveRoom, createLiveRoom,
loadHistoryPlayCounts,
loadMatchResults, loadMatchResults,
releaseLiveRoom, releaseLiveRoom,
rememberCertNotAfter, 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 = () => { const skipUpload = () => {
void finalizeLiveRoom().finally(() => { void finalizeLiveRoom().finally(() => {
recordPlayedMatch() recordPlayedMatch()
@@ -1140,6 +1155,7 @@ function App() {
voiceAnnouncement={voiceAnnouncement} voiceAnnouncement={voiceAnnouncement}
targetDate={targetDate} targetDate={targetDate}
onApplyMatchup={applyMatchup} onApplyMatchup={applyMatchup}
onSyncPlayCounts={syncPlayCountsFromHistory}
onCloseFinishDialog={closeSettlementDialog} onCloseFinishDialog={closeSettlementDialog}
onConfirmUpload={uploadSettledMatch} onConfirmUpload={uploadSettledMatch}
onOpenFinishDialog={openSettlementDialog} onOpenFinishDialog={openSettlementDialog}
+21
View File
@@ -2,6 +2,7 @@ import type {
AttendanceStats, AttendanceStats,
HistoryListItem, HistoryListItem,
HistoryListPage, HistoryListPage,
HistoryPlayCounts,
HistoryRecord, HistoryRecord,
HistoryUploadPayload, HistoryUploadPayload,
HistoryUploadResponse, HistoryUploadResponse,
@@ -154,6 +155,26 @@ export async function loadAttendanceStats(): Promise<AttendanceStats> {
return payload.data return payload.data
} }
// 讀取 history 表在 [from, to) 秒級時間區間內每個人打了幾場。
export async function loadHistoryPlayCounts(from: number, to: number): Promise<HistoryPlayCounts> {
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) { export async function deleteHistoryItem(id: number) {
const response = await apiFetch(`/api/history/${id}`, { const response = await apiFetch(`/api/history/${id}`, {
method: 'DELETE', method: 'DELETE',
+53
View File
@@ -12,6 +12,7 @@ import {
import type { import type {
CourtSide, CourtSide,
GroupTeam, GroupTeam,
HistoryPlayCounts,
PlayerSlot, PlayerSlot,
PointHistoryEntry, PointHistoryEntry,
RoundGroup, RoundGroup,
@@ -85,6 +86,7 @@ type ScoreboardPageProps = {
onOpenFinishDialog: () => void onOpenFinishDialog: () => void
onRecordPoint: (side: ScoreSide) => void onRecordPoint: (side: ScoreSide) => void
onSetServing: (side: ScoreSide) => void onSetServing: (side: ScoreSide) => void
onSyncPlayCounts: () => Promise<HistoryPlayCounts>
onSkipUpload: () => void onSkipUpload: () => void
onSwapMatchup: () => void onSwapMatchup: () => void
onSwapTeamPlayers: (side: ScoreSide) => void onSwapTeamPlayers: (side: ScoreSide) => void
@@ -117,6 +119,7 @@ export function ScoreboardPage({
onOpenFinishDialog, onOpenFinishDialog,
onRecordPoint, onRecordPoint,
onSetServing, onSetServing,
onSyncPlayCounts,
onSkipUpload, onSkipUpload,
onSwapMatchup, onSwapMatchup,
onSwapTeamPlayers, onSwapTeamPlayers,
@@ -124,6 +127,11 @@ export function ScoreboardPage({
}: ScoreboardPageProps) { }: ScoreboardPageProps) {
const FINISH_HOLD_DURATION = 1000 const FINISH_HOLD_DURATION = 1000
const [pickerOpen, setPickerOpen] = useState(false) const [pickerOpen, setPickerOpen] = useState(false)
// 從 DB 同步今日場次的進度與結果訊息,只在選隊面板顯示。
const [playCountsSync, setPlayCountsSync] = useState<{ loading: boolean; message: string }>({
loading: false,
message: '',
})
const [settingsOpen, setSettingsOpen] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false)
const [draftPlayers, setDraftPlayers] = useState<string[]>([]) const [draftPlayers, setDraftPlayers] = useState<string[]>([])
const [draftTargetScore, setDraftTargetScore] = useState(() => const [draftTargetScore, setDraftTargetScore] = useState(() =>
@@ -544,6 +552,32 @@ export function ScoreboardPage({
setDraftPlayers(shuffled.slice(0, 4)) 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 ( return (
<> <>
{streakAnnouncement ? ( {streakAnnouncement ? (
@@ -732,6 +766,7 @@ export function ScoreboardPage({
extraPlayerSet={extraPlayerSet} extraPlayerSet={extraPlayerSet}
group={selectedGroup} group={selectedGroup}
playCounts={playCounts} playCounts={playCounts}
playCountsSync={playCountsSync}
presetTeams={presetTeams} presetTeams={presetTeams}
selectablePlayers={selectablePlayers} selectablePlayers={selectablePlayers}
selectionCount={draftPlayers.length} selectionCount={draftPlayers.length}
@@ -742,6 +777,7 @@ export function ScoreboardPage({
onClose={() => setPickerOpen(false)} onClose={() => setPickerOpen(false)}
onConfirm={confirmDraftTeams} onConfirm={confirmDraftTeams}
onDraftTargetScoreChange={setDraftTargetScore} onDraftTargetScoreChange={setDraftTargetScore}
onSyncPlayCounts={syncPlayCounts}
onTogglePlayer={toggleDraftPlayer} onTogglePlayer={toggleDraftPlayer}
onTogglePresetTeam={togglePresetTeam} onTogglePresetTeam={togglePresetTeam}
/> />
@@ -921,6 +957,7 @@ type TeamPickerModalProps = {
draftPlayers: string[] draftPlayers: string[]
extraPlayerSet: Set<string> extraPlayerSet: Set<string>
playCounts: Record<string, number> playCounts: Record<string, number>
playCountsSync: { loading: boolean; message: string }
draftTargetScore: string draftTargetScore: string
group: RoundGroup group: RoundGroup
presetTeams: GroupTeam[] presetTeams: GroupTeam[]
@@ -933,6 +970,7 @@ type TeamPickerModalProps = {
onClose: () => void onClose: () => void
onConfirm: () => void onConfirm: () => void
onDraftTargetScoreChange: (value: string) => void onDraftTargetScoreChange: (value: string) => void
onSyncPlayCounts: () => void
onTogglePlayer: (playerName: string) => void onTogglePlayer: (playerName: string) => void
onTogglePresetTeam: (team: GroupTeam) => void onTogglePresetTeam: (team: GroupTeam) => void
} }
@@ -941,6 +979,7 @@ function TeamPickerModal({
draftPlayers, draftPlayers,
extraPlayerSet, extraPlayerSet,
playCounts, playCounts,
playCountsSync,
draftTargetScore, draftTargetScore,
group, group,
presetTeams, presetTeams,
@@ -953,6 +992,7 @@ function TeamPickerModal({
onClose, onClose,
onConfirm, onConfirm,
onDraftTargetScoreChange, onDraftTargetScoreChange,
onSyncPlayCounts,
onTogglePlayer, onTogglePlayer,
onTogglePresetTeam, onTogglePresetTeam,
}: TeamPickerModalProps) { }: TeamPickerModalProps) {
@@ -1035,7 +1075,20 @@ function TeamPickerModal({
})} })}
</div> </div>
{playCountsSync.message ? (
<p className="team-picker-sync-note">{playCountsSync.message}</p>
) : null}
<div className="team-picker-actions"> <div className="team-picker-actions">
<button
className="team-picker-ghost team-picker-sync-button"
disabled={playCountsSync.loading}
title="從 DB 歷史戰績撈今天的比賽,把每個人的今日場次更新成 DB 的數字(跨裝置記分用)"
type="button"
onClick={onSyncPlayCounts}
>
{playCountsSync.loading ? '同步中…' : '同步今日場次'}
</button>
<button className="team-picker-ghost" type="button" onClick={onAutoPick}> <button className="team-picker-ghost" type="button" onClick={onAutoPick}>
</button> </button>
+5
View File
@@ -131,6 +131,11 @@ export type HistoryListItem = {
winnerTeamName: string winnerTeamName: string
} }
export type HistoryPlayCounts = {
counts: Record<string, number>
matches: number
}
export type HistoryListPage = { export type HistoryListPage = {
items: HistoryListItem[] items: HistoryListItem[]
page: number page: number