功能:出席率統計改以 attendance 表(TG 報名名單)為資料來源

摘要:
- 出席統計改讀 attendance 表(每天每人一列),依 poll_date 分組後餵給原本的統計函式
- 新增 DB_ATTENDANCE_TABLE 環境變數,預設 attendance;沒有此表時退回 badminton 分組名單
- 前端說明文字與 README 更新來源說明

根本原因:
- 原本用 badminton 表的每日分組名單,只有名單存進 DB 的日子才有資料,
  當天報名還沒存名單就統計不到,名字寫法也要靠別名表修補

影響:
- 統計天數 121 → 126 天,含當天報名尚未分組的場次
- 名字寫法在 attendance 表已統一,別名合併(卡森→景涵 等)仍生效

修法:
- server.mjs 新增 loadAttendanceDays(),buildAttendanceStats() 改吃 [{ time, names }]
- .env.example / docker-compose.yml / README 補上 DB_ATTENDANCE_TABLE

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 13:50:59 +08:00
co-authored by Claude Fable 5
parent 641e51c887
commit d53ebbd3a8
5 changed files with 53 additions and 13 deletions
+45 -9
View File
@@ -10,6 +10,8 @@ const app = express()
const port = Number(process.env.PORT ?? process.env.SERVER_PORT ?? 8788)
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'
const appVersion = process.env.APP_VERSION ?? `${Date.now()}`
const appStartedAt = new Date().toISOString()
const LIVE_ROOM_STALE_MS = 30_000
@@ -388,16 +390,14 @@ app.get('/api/attendance', async (_request, response) => {
}
try {
await ensureMatchTable(pool, matchTableName)
// 出席統計要看每天的分組名單,不是 history 表;
// history 只有實際記分的場次,當天有來但沒被記到的人會被漏算
const [rows] = await pool.execute(
`SELECT time, personnel FROM \`${matchTableName}\` ORDER BY time ASC`,
)
// 出席統計以 attendance 表(TG 報名名單)為主:比每天的分組名單完整,
// 當天名單還沒存進 badminton 表之前就有資料,名字寫法也已經統一。
// 沒有這張表的環境(例如舊的 DB)退回用 badminton 表的分組名單
const days = await loadAttendanceDays(pool)
response.json({
ok: true,
data: buildAttendanceStats(rows),
data: buildAttendanceStats(days),
})
} catch (error) {
console.error('attendance load error:', error)
@@ -865,14 +865,50 @@ function parseAttendanceNames(personnel) {
}
}
function buildAttendanceStats(rows) {
const days = rows
// 讀出每天的出席名單,回傳 [{ time, names }],時間由舊到新。
async function loadAttendanceDays(pool) {
try {
const [rows] = await pool.execute(
`SELECT poll_date, nickname FROM \`${attendanceTableName}\` ORDER BY poll_date ASC, joined_at ASC`,
)
const daysByTime = new Map()
rows.forEach((row) => {
const time = Number(row.poll_date)
const name = String(row.nickname ?? '').trim()
if (!Number.isFinite(time) || !name) {
return
}
const names = daysByTime.get(time) ?? []
names.push(name)
daysByTime.set(time, names)
})
return Array.from(daysByTime.entries()).map(([time, names]) => ({ time, names }))
} catch (error) {
if (error?.code !== 'ER_NO_SUCH_TABLE') {
throw error
}
console.warn(`attendance table \`${attendanceTableName}\` 不存在,改用 \`${matchTableName}\` 的分組名單統計。`)
}
await ensureMatchTable(pool, matchTableName)
const [rows] = await pool.execute(
`SELECT time, personnel FROM \`${matchTableName}\` ORDER BY time ASC`,
)
return rows
.map((row) => ({
time: Number(row.time),
names: parseAttendanceNames(row.personnel),
}))
.filter((day) => Number.isFinite(day.time) && day.names.length > 0)
}
function buildAttendanceStats(days) {
const totalDays = days.length
if (totalDays === 0) {