From d53ebbd3a8ffecf37af1783b6cadf7c5eb8e6c86 Mon Sep 17 00:00:00 2001 From: JianMiau Date: Mon, 17 Aug 2026 13:50:59 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E5=87=BA=E5=B8=AD?= =?UTF-8?q?=E7=8E=87=E7=B5=B1=E8=A8=88=E6=94=B9=E4=BB=A5=20attendance=20?= =?UTF-8?q?=E8=A1=A8=EF=BC=88TG=20=E5=A0=B1=E5=90=8D=E5=90=8D=E5=96=AE?= =?UTF-8?q?=EF=BC=89=E7=82=BA=E8=B3=87=E6=96=99=E4=BE=86=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 摘要: - 出席統計改讀 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 --- .env.example | 1 + README.md | 4 ++- docker-compose.yml | 1 + server/server.mjs | 54 ++++++++++++++++++++++++++++++++------- src/pages/HistoryPage.tsx | 6 ++--- 5 files changed, 53 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index b770ffe..2aead6e 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 04bcfc7..106a6ba 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/docker-compose.yml b/docker-compose.yml index 8ed17a0..46fde82 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/server/server.mjs b/server/server.mjs index 7e1bb21..487b3bc 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -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) { diff --git a/src/pages/HistoryPage.tsx b/src/pages/HistoryPage.tsx index 4e65a99..2d6749c 100644 --- a/src/pages/HistoryPage.tsx +++ b/src/pages/HistoryPage.tsx @@ -323,11 +323,11 @@ function AttendanceModal({ onClose }: AttendanceModalProps) {

出席率統計

{loading ? ( -

正在讀取每天的分組名單...

+

正在讀取每天的報名名單...

) : error ? (

{error}

) : !stats || stats.totalDays === 0 ? ( -

資料庫還沒有可統計的分組名單。

+

資料庫還沒有可統計的報名名單。

) : ( <>
@@ -506,7 +506,7 @@ function AttendanceModal({ onClose }: AttendanceModalProps) { ) : null}

- 出席資料取自每天的分組名單(`badminton` 表),不是比賽紀錄,所以有到場但沒被記分的場次也算出席。 + 出席資料取自 TG 報名名單(`attendance` 表),不是比賽紀錄,所以有報名到場但沒被記分的場次也算出席。 {mergedPlayers.length > 0 ? ` 同一個人不同寫法會自動合併,例如 ${mergedPlayers[0].mergedForms.join('、')}。` : ''}