功能:新增出席率統計,含每日人數分析分頁與名字別名合併
根本原因: 歷史戰績只有逐場比賽紀錄,看不出每個人長期的出席狀況,也不知道每天平均來幾個人;且名單上同一人有多種寫法(大小寫、錯字),直接統計會被拆成多個人而失真。 影響: 歷史戰績頁新增「出席率統計」按鈕,彈窗含兩個分頁——「出席排行」顯示每人出席場次、全期/加入後出席率長條與最近 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:
@@ -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}\` (
|
||||
|
||||
Reference in New Issue
Block a user