diff --git a/README.md b/README.md index 972076f..04bcfc7 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,15 @@ - 可從資料庫讀取歷史列表。 - 點開單筆可查看得分過程。 - 每筆資料可刪除,刪除前會顯示確認提示。 +- 出席率統計 + - 歷史戰績頁的 `出席率統計` 按鈕會彈窗顯示每個人的出席狀況。 + - 資料取自 `badminton` 表的每日分組名單,不是 `history` 表,所以有到場但沒被記分的場次也算出席。 + - 同時顯示兩種出席率: + - `全期`:以資料庫全部場次為分母。 + - `加入後`:從各自第一次出席那天算起,新加入的人不會被稀釋。 + - 另外顯示最近 `12` 場的到場次數,可看出誰還在打球。 + - 英文名大小寫不同(`RURU` / `RuRu` / `Ruru`)自動視為同一人;中文錯字用 `server/server.mjs` 的 `ATTENDANCE_ALIASES` 別名表對照,合併過的寫法會顯示在該列。 + - `ATTENDANCE_IGNORED_NAMES` 列的佔位字(如 `輪空`)不列入統計。 - 房間觀戰 - 記分板帶入隊伍後會自動建立房間。 - 房間列表可查看目前直播中的比賽。 diff --git a/server/server.mjs b/server/server.mjs index 144febb..7e1bb21 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -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}\` ( diff --git a/src/App.css b/src/App.css index 11da2e2..9101bb0 100644 --- a/src/App.css +++ b/src/App.css @@ -1876,6 +1876,230 @@ background: rgba(255, 249, 238, 0.92); } +/* 深色 hero 上的按鈕,改用淺底才看得清楚。 */ +.attendance-open-button { + margin-top: 18px; + color: #0b3b32; + background: rgba(255, 249, 238, 0.94); + box-shadow: 0 10px 18px rgba(8, 47, 73, 0.18); + transition: + transform 0.16s ease, + box-shadow 0.16s ease; +} + +.attendance-open-button:hover { + transform: translateY(-1px); + box-shadow: 0 14px 22px rgba(8, 47, 73, 0.24); +} + +.attendance-open-button:active { + transform: translateY(0); +} + +.attendance-modal { + width: min(720px, 100%); +} + +.attendance-legend { + display: flex; + flex-wrap: wrap; + gap: 6px 14px; + font-size: 0.82rem; + color: #70543c; +} + +.attendance-list { + display: grid; + gap: 10px; + max-height: min(56vh, 520px); + overflow: auto; +} + +.attendance-row { + display: grid; + gap: 8px; + padding: 12px 14px; + border-radius: 14px; + background: rgba(255, 249, 238, 0.92); +} + +.attendance-head { + display: flex; + align-items: center; + gap: 10px; +} + +.attendance-rank { + display: grid; + place-items: center; + min-width: 26px; + height: 26px; + padding: 0 6px; + border-radius: 999px; + font-family: var(--mono); + font-size: 0.82rem; + color: #fff8e8; + background: linear-gradient(135deg, rgba(8, 47, 73, 0.94), rgba(10, 96, 84, 0.9)); +} + +.attendance-name { + flex: 1; + min-width: 0; + color: #16342f; +} + +.attendance-count { + font-family: var(--mono); + font-size: 0.92rem; + color: #5f4a35; +} + +.attendance-bars { + display: grid; + gap: 6px; +} + +.attendance-bar-row { + display: grid; + grid-template-columns: 52px minmax(0, 1fr) 116px; + gap: 10px; + align-items: center; +} + +.attendance-bar-label { + font-size: 0.8rem; + color: #70543c; +} + +.attendance-bar { + height: 10px; + border-radius: 999px; + overflow: hidden; + background: rgba(112, 84, 60, 0.16); +} + +.attendance-bar-fill { + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, rgba(10, 96, 84, 0.9), rgba(8, 47, 73, 0.92)); +} + +.attendance-bar-fill-since { + background: linear-gradient(90deg, rgba(248, 168, 45, 0.92), rgba(199, 108, 44, 0.9)); +} + +.attendance-bar-value { + font-family: var(--mono); + font-size: 0.86rem; + text-align: right; + color: #16342f; +} + +.attendance-bar-value small { + font-size: 0.76rem; + color: #70543c; +} + +.attendance-meta { + display: flex; + flex-wrap: wrap; + gap: 6px 12px; + font-size: 0.8rem; + color: #5f4a35; +} + +.attendance-note { + margin: 0; + font-size: 0.8rem; + line-height: 1.6; + color: #70543c; +} + +.attendance-tabs { + display: flex; + gap: 8px; +} + +.attendance-tab { + padding: 8px 16px; + border: 0; + border-radius: 999px; + cursor: pointer; + font: inherit; + font-size: 0.88rem; + color: #70543c; + background: rgba(112, 84, 60, 0.12); + transition: background 0.16s ease, color 0.16s ease; +} + +.attendance-tab:hover { + background: rgba(112, 84, 60, 0.2); +} + +.attendance-tab-active { + color: #f8fff8; + background: linear-gradient(135deg, rgba(8, 47, 73, 0.96), rgba(10, 96, 84, 0.92)); +} + +.attendance-daily { + display: grid; + gap: 14px; + max-height: min(56vh, 520px); + overflow: auto; + padding-right: 4px; +} + +.attendance-daily-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); + gap: 8px; +} + +.attendance-daily-stat { + display: grid; + gap: 2px; + justify-items: center; + padding: 10px 8px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.66); + box-shadow: inset 0 0 0 1px rgba(112, 84, 60, 0.14); +} + +.attendance-daily-stat strong { + font-family: var(--mono); + font-size: 1.3rem; + color: #16342f; +} + +.attendance-daily-stat span { + font-size: 0.76rem; + color: #70543c; +} + +.attendance-daily-block { + display: grid; + gap: 6px; + padding: 12px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.66); + box-shadow: inset 0 0 0 1px rgba(112, 84, 60, 0.14); +} + +.attendance-daily-title { + margin-bottom: 2px; + font-size: 0.86rem; + color: #4a2e1d; +} + +.attendance-bar-label-date { + font-family: var(--mono); + white-space: nowrap; +} + +.attendance-daily .attendance-bar-row { + grid-template-columns: 84px minmax(0, 1fr) 92px; +} + .inline-link { display: inline-flex; width: fit-content; @@ -1924,6 +2148,27 @@ grid-template-columns: minmax(0, 1fr) 220px; gap: 12px; } + + /* 手機寬度放不下三欄,改成上排文字、下排長條圖。 */ + .attendance-bar-row { + grid-template-columns: auto minmax(0, 1fr); + grid-template-areas: + 'label value' + 'bar bar'; + gap: 4px 8px; + } + + .attendance-bar-label { + grid-area: label; + } + + .attendance-bar { + grid-area: bar; + } + + .attendance-bar-value { + grid-area: value; + } } @media (max-width: 720px) { diff --git a/src/lib/api.ts b/src/lib/api.ts index c7e50ac..af945e3 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,4 +1,5 @@ import type { + AttendanceStats, HistoryListItem, HistoryListPage, HistoryRecord, @@ -134,6 +135,25 @@ export async function loadHistoryList(page = 1, pageSize = 20): Promise { + const response = await apiFetch('/api/attendance') + const payload = (await readJsonSafely(response)) as { + ok?: boolean + message?: string + data?: AttendanceStats + } + + if (response.status === 404) { + throw new Error('後端還沒更新到出席統計功能,請重新部署最新版。') + } + + if (!response.ok || !payload.ok || !payload.data) { + throw new Error(payload.message ?? '讀取出席統計失敗。') + } + + return payload.data +} + export async function deleteHistoryItem(id: number) { const response = await apiFetch(`/api/history/${id}`, { method: 'DELETE', diff --git a/src/pages/HistoryPage.tsx b/src/pages/HistoryPage.tsx index 7803e88..4e65a99 100644 --- a/src/pages/HistoryPage.tsx +++ b/src/pages/HistoryPage.tsx @@ -1,6 +1,6 @@ -import { useEffect, useState } from 'react' -import { deleteHistoryItem, loadHistoryList } from '../lib/api' -import type { HistoryListItem } from '../types' +import { useEffect, useMemo, useState } from 'react' +import { deleteHistoryItem, loadAttendanceStats, loadHistoryList } from '../lib/api' +import type { AttendanceStats, HistoryListItem } from '../types' const HISTORY_PAGE_SIZE = 20 @@ -9,6 +9,7 @@ export function HistoryPage() { const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [selectedItem, setSelectedItem] = useState(null) + const [attendanceOpen, setAttendanceOpen] = useState(false) const [deletingId, setDeletingId] = useState(null) const [page, setPage] = useState(1) const [total, setTotal] = useState(0) @@ -99,6 +100,14 @@ export function HistoryPage() {

這裡會直接從資料庫的 `history` 表讀取比賽紀錄,點開後可查看每一分的得分過程,也能刪除單筆資料。

+ +
@@ -213,10 +222,315 @@ export function HistoryPage() { {selectedItem ? ( setSelectedItem(null)} /> ) : null} + + {attendanceOpen ? setAttendanceOpen(false)} /> : null} ) } +type AttendanceModalProps = { + onClose: () => void +} + +function AttendanceModal({ onClose }: AttendanceModalProps) { + const [stats, setStats] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [view, setView] = useState<'players' | 'daily'>('players') + + useEffect(() => { + let active = true + + const run = async () => { + try { + const result = await loadAttendanceStats() + + if (active) { + setStats(result) + } + } catch (fetchError) { + if (active) { + setError(fetchError instanceof Error ? fetchError.message : '讀取出席統計失敗。') + } + } finally { + if (active) { + setLoading(false) + } + } + } + + void run() + + return () => { + active = false + } + }, []) + + const mergedPlayers = stats?.players.filter((player) => player.mergedForms.length > 0) ?? [] + + // 每日人數分析:全部由 dailyCounts 推導,讀不到資料時給空殼。 + const dailyAnalysis = useMemo(() => { + if (!stats || stats.dailyCounts.length === 0) { + return null + } + + const counts = stats.dailyCounts.map((day) => day.count) + const maxCount = Math.max(...counts) + const minCount = Math.min(...counts) + const recent = stats.dailyCounts.slice(-stats.recentDays) + const recentAverage = recent.reduce((sum, day) => sum + day.count, 0) / recent.length + + // 人數分佈:某個人數出現過幾天。 + const distributionMap = new Map() + counts.forEach((count) => { + distributionMap.set(count, (distributionMap.get(count) ?? 0) + 1) + }) + const distribution = Array.from(distributionMap.entries()) + .map(([count, dayCount]) => ({ count, dayCount })) + .sort((left, right) => left.count - right.count) + const maxDistributionDays = Math.max(...distribution.map((entry) => entry.dayCount)) + const maxYearAverage = Math.max(...stats.yearTotals.map((year) => year.averagePerDay)) + + return { + distribution, + maxCount, + maxDistributionDays, + maxYearAverage, + minCount, + recent: [...recent].reverse(), + recentAverage, + } + }, [stats]) + + return ( +
+
event.stopPropagation()} + > + + +

Attendance

+

出席率統計

+ + {loading ? ( +

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

+ ) : error ? ( +

{error}

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

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

+ ) : ( + <> +
+ + {formatDateKey(stats.firstDate)} ~ {formatDateKey(stats.lastDate)} + + 共 {stats.totalDays} 場 + 平均每場 {stats.averagePerDay.toFixed(1)} 人 + {stats.players.length} 人打過 +
+ +
+ + +
+ + {view === 'daily' && dailyAnalysis ? ( +
+
+
+ {stats.averagePerDay.toFixed(1)} + 全期平均 +
+
+ {dailyAnalysis.recentAverage.toFixed(1)} + 最近 {stats.recentDays} 場平均 +
+
+ {dailyAnalysis.maxCount} + 單場最多 +
+
+ {dailyAnalysis.minCount} + 單場最少 +
+
+ +
+ 年度平均人數 + {stats.yearTotals.map((year) => ( +
+ {year.year} +
+
+
+ + {year.averagePerDay.toFixed(1)} 人/{year.days} 場 + +
+ ))} +
+ +
+ 人數分佈(幾人來過幾場) + {dailyAnalysis.distribution.map((entry) => ( +
+ {entry.count} 人 +
+
+
+ {entry.dayCount} 場 +
+ ))} +
+ +
+ 最近 {stats.recentDays} 場(新到舊) + {dailyAnalysis.recent.map((day) => ( +
+ + {formatDateKey(day.date)} + +
+
+
+ {day.count} 人 +
+ ))} +
+
+ ) : null} + + {view === 'players' ? ( +
+ 全期:以全部 {stats.totalDays} 場為分母 + 加入後:從各自第一次出席那天算起 +
+ ) : null} + + {view === 'players' ? ( +
+ {stats.players.map((player, index) => ( +
+
+ {index + 1} + {player.name} + {player.count} 場 +
+ +
+
+ 全期 +
+
+
+ {formatRate(player.rate)} +
+ +
+ 加入後 +
+
+
+ + {formatRate(player.sinceRate)} + /{player.sinceDays} 場 + +
+
+ +
+ + 最近 {stats.recentDays} 場:{player.recentCount} 次 + + + {formatDateKey(player.firstDate)} ~ {formatDateKey(player.lastDate)} + + {player.mergedForms.length > 0 ? ( + 已合併:{player.mergedForms.join('、')} + ) : null} +
+
+ ))} +
+ ) : null} + +

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

+ + )} +
+
+ ) +} + +function formatRate(rate: number) { + return `${(rate * 100).toFixed(1)}%` +} + +function formatDateKey(value: number | null) { + if (!value) { + return '-' + } + + const text = String(value) + return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}` +} + type HistoryReplayModalProps = { item: HistoryListItem onClose: () => void diff --git a/src/types.ts b/src/types.ts index eab78d5..58b6958 100644 --- a/src/types.ts +++ b/src/types.ts @@ -138,6 +138,30 @@ export type HistoryListPage = { totalPages: number } +export type AttendancePlayer = { + count: number + firstDate: number + lastDate: number + mergedForms: string[] + name: string + rate: number + recentCount: number + sinceDays: number + sinceRate: number + years: Array<{ year: string; count: number }> +} + +export type AttendanceStats = { + averagePerDay: number + dailyCounts: Array<{ date: number; count: number }> + firstDate: number | null + lastDate: number | null + players: AttendancePlayer[] + recentDays: number + totalDays: number + yearTotals: Array<{ year: string; days: number; averagePerDay: number }> +} + export type LiveRoomStatus = 'live' | 'finished' export type LiveRoomSession = {