功能:新增出席率統計,含每日人數分析分頁與名字別名合併

根本原因:
歷史戰績只有逐場比賽紀錄,看不出每個人長期的出席狀況,也不知道每天平均來幾個人;且名單上同一人有多種寫法(大小寫、錯字),直接統計會被拆成多個人而失真。

影響:
歷史戰績頁新增「出席率統計」按鈕,彈窗含兩個分頁——「出席排行」顯示每人出席場次、全期/加入後出席率長條與最近 12 場到場數;「每日人數」顯示全期與最近平均、單場最多最少、年度平均人數、人數分佈與最近 12 場逐日人數。名字自動正規化:英文大小寫視為同一人,中文錯字用別名表合併(景函、卡森→景涵,振華→振驊,阿翔→昱翔),佔位字(輪空、那個、隨機)不列入統計,平均每場人數也一併排除佔位字。

修法:
後端新增 GET /api/attendance,從 badminton 表的每日分組名單統計(history 表只有記分場次會漏算出席),buildAttendanceStats 輸出球員列表、dailyCounts 與含年度平均的 yearTotals;前端 HistoryPage 新增 AttendanceModal 與分頁切換,App.css 補上排行長條與每日分析的樣式。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-10 15:21:37 +08:00
co-authored by Claude Opus 4.8
parent 68618a8e04
commit 710d04bffe
6 changed files with 813 additions and 3 deletions
+198
View File
@@ -14,6 +14,20 @@ const appVersion = process.env.APP_VERSION ?? `${Date.now()}`
const appStartedAt = new Date().toISOString()
const LIVE_ROOM_STALE_MS = 30_000
// 出席統計的名字正規化設定。
// 英文名只是大小寫不同(RURU / RuRu / Ruru)會自動視為同一人,
// 中文打錯字沒辦法自動判斷,統一在這張別名表對照。
const ATTENDANCE_ALIASES = {
阿翔: '昱翔',
卡森: '景涵',
景函: '景涵',
振華: '振驊',
}
// 分組名單裡的佔位字,不是真的有人到場,不列入統計。
const ATTENDANCE_IGNORED_NAMES = new Set(['輪空', '那個', '隨機'])
// 「最近 N 場」用來看誰還在打球。
const ATTENDANCE_RECENT_DAYS = 12
const currentFilePath = fileURLToPath(import.meta.url)
const currentDir = path.dirname(currentFilePath)
const projectRoot = path.resolve(currentDir, '..')
@@ -364,6 +378,36 @@ app.get('/api/match-results/:time', async (request, response) => {
}
})
app.get('/api/attendance', async (_request, response) => {
if (!pool) {
response.status(500).json({
ok: false,
message: `DB 尚未設定完成,缺少 ${missingEnv.join(', ')}`,
})
return
}
try {
await ensureMatchTable(pool, matchTableName)
// 出席統計要看每天的分組名單,不是 history 表;
// history 只有實際記分的場次,當天有來但沒被記到的人會被漏算。
const [rows] = await pool.execute(
`SELECT time, personnel FROM \`${matchTableName}\` ORDER BY time ASC`,
)
response.json({
ok: true,
data: buildAttendanceStats(rows),
})
} catch (error) {
console.error('attendance load error:', error)
response.status(500).json({
ok: false,
message: error instanceof Error ? error.message : '讀取出席統計失敗。',
})
}
})
app.post('/api/history', async (request, response) => {
if (!pool) {
response.status(500).json({
@@ -794,6 +838,160 @@ function broadcastRoomList() {
})
}
function getAttendanceName(rawName) {
const name = String(rawName ?? '').trim()
return ATTENDANCE_ALIASES[name] ?? name
}
// 大小寫不同視為同一人,所以統計用的 key 一律轉小寫。
function getAttendanceKey(rawName) {
return getAttendanceName(rawName).toLowerCase()
}
// 這裡保留名單上的原始寫法,合併留到統計時再做,才能回報是哪幾種寫法被併在一起。
function parseAttendanceNames(personnel) {
try {
const parsed = JSON.parse(personnel ?? '[]')
if (!Array.isArray(parsed)) {
return []
}
return parsed
.map((entry) => String((Array.isArray(entry) ? entry[1] : entry) ?? '').trim())
.filter(Boolean)
} catch {
return []
}
}
function buildAttendanceStats(rows) {
const days = rows
.map((row) => ({
time: Number(row.time),
names: parseAttendanceNames(row.personnel),
}))
.filter((day) => Number.isFinite(day.time) && day.names.length > 0)
const totalDays = days.length
if (totalDays === 0) {
return {
averagePerDay: 0,
dailyCounts: [],
firstDate: null,
lastDate: null,
players: [],
recentDays: 0,
totalDays: 0,
yearTotals: [],
}
}
const recentDays = Math.min(ATTENDANCE_RECENT_DAYS, totalDays)
const recentStartIndex = totalDays - recentDays
const yearTotals = new Map()
const players = new Map()
const dailyCounts = []
let attendanceSum = 0
days.forEach((day, dayIndex) => {
const year = String(day.time).slice(0, 4)
const yearTotal = yearTotals.get(year) ?? { days: 0, people: 0 }
yearTotal.days += 1
yearTotals.set(year, yearTotal)
// 同一天同一個人只算一次。
const seenKeys = new Set()
// 佔位字(輪空等)不是真的有人,不列入每天人數。
let dayCount = 0
day.names.forEach((rawName) => {
const key = getAttendanceKey(rawName)
const name = getAttendanceName(rawName)
if (seenKeys.has(key)) {
return
}
seenKeys.add(key)
if (ATTENDANCE_IGNORED_NAMES.has(name)) {
return
}
dayCount += 1
const player = players.get(key) ?? {
count: 0,
firstDate: day.time,
firstIndex: dayIndex,
forms: new Set(),
lastDate: day.time,
name,
recentCount: 0,
years: new Map(),
}
// 顯示名採用最近一次出現的寫法,貼近現在慣用的名字;別名表對照過的錯字則用正規名。
player.name = name
player.count += 1
player.lastDate = day.time
player.forms.add(rawName)
player.years.set(year, (player.years.get(year) ?? 0) + 1)
if (dayIndex >= recentStartIndex) {
player.recentCount += 1
}
players.set(key, player)
})
yearTotal.people += dayCount
dailyCounts.push({ date: day.time, count: dayCount })
attendanceSum += dayCount
})
const playerList = Array.from(players.values())
.filter((player) => !ATTENDANCE_IGNORED_NAMES.has(player.name))
.map((player) => {
// 加入後場次:從這個人第一次出席那天算到最新一場,新加入的人才不會被稀釋。
const sinceDays = totalDays - player.firstIndex
return {
count: player.count,
firstDate: player.firstDate,
lastDate: player.lastDate,
// 同一人有多種寫法時列出來,方便對照是不是打錯字。
mergedForms: player.forms.size > 1 ? Array.from(player.forms) : [],
name: player.name,
rate: player.count / totalDays,
recentCount: player.recentCount,
sinceDays,
sinceRate: player.count / sinceDays,
years: Array.from(player.years.entries()).map(([year, count]) => ({ year, count })),
}
})
.sort((left, right) => right.count - left.count || left.firstDate - right.firstDate)
return {
averagePerDay: attendanceSum / totalDays,
dailyCounts,
firstDate: days[0].time,
lastDate: days[totalDays - 1].time,
players: playerList,
recentDays,
totalDays,
yearTotals: Array.from(yearTotals.entries())
.map(([year, total]) => ({
year,
days: total.days,
averagePerDay: total.days > 0 ? total.people / total.days : 0,
}))
.sort((left, right) => left.year.localeCompare(right.year)),
}
}
async function ensureMatchTable(poolInstance, currentTableName) {
await poolInstance.execute(`
CREATE TABLE IF NOT EXISTS \`${currentTableName}\` (