功能:出席率統計改以 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:
@@ -5,6 +5,7 @@ DB_PASSWORD=your-password
|
||||
DB_DATABASE=badminton
|
||||
DB_TABLE=badminton
|
||||
DB_HISTORY_TABLE=history
|
||||
DB_ATTENDANCE_TABLE=attendance
|
||||
SERVER_PORT=8788
|
||||
NGINX_SERVER_NAME=_
|
||||
SSL_CERT_FILE_NAME=RSA-cert.pem
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
- 每筆資料可刪除,刪除前會顯示確認提示。
|
||||
- 出席率統計
|
||||
- 歷史戰績頁的 `出席率統計` 按鈕會彈窗顯示每個人的出席狀況。
|
||||
- 資料取自 `badminton` 表的每日分組名單,不是 `history` 表,所以有到場但沒被記分的場次也算出席。
|
||||
- 資料取自 `attendance` 表(TG 報名 bot 每天每人一列),不是 `history` 表,所以有報名到場但沒被記分的場次也算出席;當天名單還沒存進 `badminton` 表也已經算得到。
|
||||
- 若 DB 沒有 `attendance` 表,會退回用 `badminton` 表的每日分組名單統計。表名可用 `DB_ATTENDANCE_TABLE` 覆寫。
|
||||
- 同時顯示兩種出席率:
|
||||
- `全期`:以資料庫全部場次為分母。
|
||||
- `加入後`:從各自第一次出席那天算起,新加入的人不會被稀釋。
|
||||
@@ -105,6 +106,7 @@ DB_PASSWORD=your_password
|
||||
DB_DATABASE=badminton
|
||||
DB_TABLE=badminton
|
||||
DB_HISTORY_TABLE=history
|
||||
DB_ATTENDANCE_TABLE=attendance
|
||||
PORT=8788
|
||||
```
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ services:
|
||||
DB_DATABASE: ${DB_DATABASE:-badminton}
|
||||
DB_TABLE: ${DB_TABLE:-badminton}
|
||||
DB_HISTORY_TABLE: ${DB_HISTORY_TABLE:-history}
|
||||
DB_ATTENDANCE_TABLE: ${DB_ATTENDANCE_TABLE:-attendance}
|
||||
SSL_CERT_DIR: /certs
|
||||
SSL_CERT_FILE_NAME: ${SSL_CERT_FILE_NAME:-cert.pem}
|
||||
volumes:
|
||||
|
||||
+45
-9
@@ -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) {
|
||||
|
||||
@@ -323,11 +323,11 @@ function AttendanceModal({ onClose }: AttendanceModalProps) {
|
||||
<h3>出席率統計</h3>
|
||||
|
||||
{loading ? (
|
||||
<p className="history-replay-empty">正在讀取每天的分組名單...</p>
|
||||
<p className="history-replay-empty">正在讀取每天的報名名單...</p>
|
||||
) : error ? (
|
||||
<p className="history-replay-empty">{error}</p>
|
||||
) : !stats || stats.totalDays === 0 ? (
|
||||
<p className="history-replay-empty">資料庫還沒有可統計的分組名單。</p>
|
||||
<p className="history-replay-empty">資料庫還沒有可統計的報名名單。</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="history-modal-summary">
|
||||
@@ -506,7 +506,7 @@ function AttendanceModal({ onClose }: AttendanceModalProps) {
|
||||
) : null}
|
||||
|
||||
<p className="attendance-note">
|
||||
出席資料取自每天的分組名單(`badminton` 表),不是比賽紀錄,所以有到場但沒被記分的場次也算出席。
|
||||
出席資料取自 TG 報名名單(`attendance` 表),不是比賽紀錄,所以有報名到場但沒被記分的場次也算出席。
|
||||
{mergedPlayers.length > 0
|
||||
? ` 同一個人不同寫法會自動合併,例如 ${mergedPlayers[0].mergedForms.join('、')}。`
|
||||
: ''}
|
||||
|
||||
Reference in New Issue
Block a user