功能:新增出席率統計,含每日人數分析分頁與名字別名合併
根本原因: 歷史戰績只有逐場比賽紀錄,看不出每個人長期的出席狀況,也不知道每天平均來幾個人;且名單上同一人有多種寫法(大小寫、錯字),直接統計會被拆成多個人而失真。 影響: 歷史戰績頁新增「出席率統計」按鈕,彈窗含兩個分頁——「出席排行」顯示每人出席場次、全期/加入後出席率長條與最近 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:
@@ -34,6 +34,15 @@
|
|||||||
- 可從資料庫讀取歷史列表。
|
- 可從資料庫讀取歷史列表。
|
||||||
- 點開單筆可查看得分過程。
|
- 點開單筆可查看得分過程。
|
||||||
- 每筆資料可刪除,刪除前會顯示確認提示。
|
- 每筆資料可刪除,刪除前會顯示確認提示。
|
||||||
|
- 出席率統計
|
||||||
|
- 歷史戰績頁的 `出席率統計` 按鈕會彈窗顯示每個人的出席狀況。
|
||||||
|
- 資料取自 `badminton` 表的每日分組名單,不是 `history` 表,所以有到場但沒被記分的場次也算出席。
|
||||||
|
- 同時顯示兩種出席率:
|
||||||
|
- `全期`:以資料庫全部場次為分母。
|
||||||
|
- `加入後`:從各自第一次出席那天算起,新加入的人不會被稀釋。
|
||||||
|
- 另外顯示最近 `12` 場的到場次數,可看出誰還在打球。
|
||||||
|
- 英文名大小寫不同(`RURU` / `RuRu` / `Ruru`)自動視為同一人;中文錯字用 `server/server.mjs` 的 `ATTENDANCE_ALIASES` 別名表對照,合併過的寫法會顯示在該列。
|
||||||
|
- `ATTENDANCE_IGNORED_NAMES` 列的佔位字(如 `輪空`)不列入統計。
|
||||||
- 房間觀戰
|
- 房間觀戰
|
||||||
- 記分板帶入隊伍後會自動建立房間。
|
- 記分板帶入隊伍後會自動建立房間。
|
||||||
- 房間列表可查看目前直播中的比賽。
|
- 房間列表可查看目前直播中的比賽。
|
||||||
|
|||||||
@@ -14,6 +14,20 @@ const appVersion = process.env.APP_VERSION ?? `${Date.now()}`
|
|||||||
const appStartedAt = new Date().toISOString()
|
const appStartedAt = new Date().toISOString()
|
||||||
const LIVE_ROOM_STALE_MS = 30_000
|
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 currentFilePath = fileURLToPath(import.meta.url)
|
||||||
const currentDir = path.dirname(currentFilePath)
|
const currentDir = path.dirname(currentFilePath)
|
||||||
const projectRoot = path.resolve(currentDir, '..')
|
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) => {
|
app.post('/api/history', async (request, response) => {
|
||||||
if (!pool) {
|
if (!pool) {
|
||||||
response.status(500).json({
|
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) {
|
async function ensureMatchTable(poolInstance, currentTableName) {
|
||||||
await poolInstance.execute(`
|
await poolInstance.execute(`
|
||||||
CREATE TABLE IF NOT EXISTS \`${currentTableName}\` (
|
CREATE TABLE IF NOT EXISTS \`${currentTableName}\` (
|
||||||
|
|||||||
+245
@@ -1876,6 +1876,230 @@
|
|||||||
background: rgba(255, 249, 238, 0.92);
|
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 {
|
.inline-link {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
@@ -1924,6 +2148,27 @@
|
|||||||
grid-template-columns: minmax(0, 1fr) 220px;
|
grid-template-columns: minmax(0, 1fr) 220px;
|
||||||
gap: 12px;
|
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) {
|
@media (max-width: 720px) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AttendanceStats,
|
||||||
HistoryListItem,
|
HistoryListItem,
|
||||||
HistoryListPage,
|
HistoryListPage,
|
||||||
HistoryRecord,
|
HistoryRecord,
|
||||||
@@ -134,6 +135,25 @@ export async function loadHistoryList(page = 1, pageSize = 20): Promise<HistoryL
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadAttendanceStats(): Promise<AttendanceStats> {
|
||||||
|
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) {
|
export async function deleteHistoryItem(id: number) {
|
||||||
const response = await apiFetch(`/api/history/${id}`, {
|
const response = await apiFetch(`/api/history/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
|||||||
+317
-3
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { deleteHistoryItem, loadHistoryList } from '../lib/api'
|
import { deleteHistoryItem, loadAttendanceStats, loadHistoryList } from '../lib/api'
|
||||||
import type { HistoryListItem } from '../types'
|
import type { AttendanceStats, HistoryListItem } from '../types'
|
||||||
|
|
||||||
const HISTORY_PAGE_SIZE = 20
|
const HISTORY_PAGE_SIZE = 20
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ export function HistoryPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [selectedItem, setSelectedItem] = useState<HistoryListItem | null>(null)
|
const [selectedItem, setSelectedItem] = useState<HistoryListItem | null>(null)
|
||||||
|
const [attendanceOpen, setAttendanceOpen] = useState(false)
|
||||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
@@ -99,6 +100,14 @@ export function HistoryPage() {
|
|||||||
<p className="panel-copy">
|
<p className="panel-copy">
|
||||||
這裡會直接從資料庫的 `history` 表讀取比賽紀錄,點開後可查看每一分的得分過程,也能刪除單筆資料。
|
這裡會直接從資料庫的 `history` 表讀取比賽紀錄,點開後可查看每一分的得分過程,也能刪除單筆資料。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="secondary-button attendance-open-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAttendanceOpen(true)}
|
||||||
|
>
|
||||||
|
出席率統計
|
||||||
|
</button>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article className="panel full-span">
|
<article className="panel full-span">
|
||||||
@@ -213,10 +222,315 @@ export function HistoryPage() {
|
|||||||
{selectedItem ? (
|
{selectedItem ? (
|
||||||
<HistoryReplayModal item={selectedItem} onClose={() => setSelectedItem(null)} />
|
<HistoryReplayModal item={selectedItem} onClose={() => setSelectedItem(null)} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{attendanceOpen ? <AttendanceModal onClose={() => setAttendanceOpen(false)} /> : null}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AttendanceModalProps = {
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttendanceModal({ onClose }: AttendanceModalProps) {
|
||||||
|
const [stats, setStats] = useState<AttendanceStats | null>(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<number, number>()
|
||||||
|
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 (
|
||||||
|
<div className="history-modal-overlay" role="presentation" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
aria-modal="true"
|
||||||
|
className="history-modal attendance-modal"
|
||||||
|
role="dialog"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label="關閉出席率統計"
|
||||||
|
className="history-modal-close"
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="panel-kicker">Attendance</p>
|
||||||
|
<h3>出席率統計</h3>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="history-replay-empty">正在讀取每天的分組名單...</p>
|
||||||
|
) : error ? (
|
||||||
|
<p className="history-replay-empty">{error}</p>
|
||||||
|
) : !stats || stats.totalDays === 0 ? (
|
||||||
|
<p className="history-replay-empty">資料庫還沒有可統計的分組名單。</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="history-modal-summary">
|
||||||
|
<span>
|
||||||
|
{formatDateKey(stats.firstDate)} ~ {formatDateKey(stats.lastDate)}
|
||||||
|
</span>
|
||||||
|
<span>共 {stats.totalDays} 場</span>
|
||||||
|
<span>平均每場 {stats.averagePerDay.toFixed(1)} 人</span>
|
||||||
|
<span>{stats.players.length} 人打過</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="attendance-tabs" role="tablist">
|
||||||
|
<button
|
||||||
|
aria-selected={view === 'players'}
|
||||||
|
className={
|
||||||
|
view === 'players' ? 'attendance-tab attendance-tab-active' : 'attendance-tab'
|
||||||
|
}
|
||||||
|
role="tab"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setView('players')}
|
||||||
|
>
|
||||||
|
出席排行
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-selected={view === 'daily'}
|
||||||
|
className={
|
||||||
|
view === 'daily' ? 'attendance-tab attendance-tab-active' : 'attendance-tab'
|
||||||
|
}
|
||||||
|
role="tab"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setView('daily')}
|
||||||
|
>
|
||||||
|
每日人數
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{view === 'daily' && dailyAnalysis ? (
|
||||||
|
<div className="attendance-daily">
|
||||||
|
<div className="attendance-daily-stats">
|
||||||
|
<div className="attendance-daily-stat">
|
||||||
|
<strong>{stats.averagePerDay.toFixed(1)}</strong>
|
||||||
|
<span>全期平均</span>
|
||||||
|
</div>
|
||||||
|
<div className="attendance-daily-stat">
|
||||||
|
<strong>{dailyAnalysis.recentAverage.toFixed(1)}</strong>
|
||||||
|
<span>最近 {stats.recentDays} 場平均</span>
|
||||||
|
</div>
|
||||||
|
<div className="attendance-daily-stat">
|
||||||
|
<strong>{dailyAnalysis.maxCount}</strong>
|
||||||
|
<span>單場最多</span>
|
||||||
|
</div>
|
||||||
|
<div className="attendance-daily-stat">
|
||||||
|
<strong>{dailyAnalysis.minCount}</strong>
|
||||||
|
<span>單場最少</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="attendance-daily-block">
|
||||||
|
<strong className="attendance-daily-title">年度平均人數</strong>
|
||||||
|
{stats.yearTotals.map((year) => (
|
||||||
|
<div className="attendance-bar-row" key={year.year}>
|
||||||
|
<span className="attendance-bar-label">{year.year}</span>
|
||||||
|
<div className="attendance-bar">
|
||||||
|
<div
|
||||||
|
className="attendance-bar-fill"
|
||||||
|
style={{
|
||||||
|
width: `${((year.averagePerDay / dailyAnalysis.maxYearAverage) * 100).toFixed(1)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="attendance-bar-value">
|
||||||
|
{year.averagePerDay.toFixed(1)} 人<small>/{year.days} 場</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="attendance-daily-block">
|
||||||
|
<strong className="attendance-daily-title">人數分佈(幾人來過幾場)</strong>
|
||||||
|
{dailyAnalysis.distribution.map((entry) => (
|
||||||
|
<div className="attendance-bar-row" key={entry.count}>
|
||||||
|
<span className="attendance-bar-label">{entry.count} 人</span>
|
||||||
|
<div className="attendance-bar">
|
||||||
|
<div
|
||||||
|
className="attendance-bar-fill attendance-bar-fill-since"
|
||||||
|
style={{
|
||||||
|
width: `${((entry.dayCount / dailyAnalysis.maxDistributionDays) * 100).toFixed(1)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="attendance-bar-value">{entry.dayCount} 場</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="attendance-daily-block">
|
||||||
|
<strong className="attendance-daily-title">最近 {stats.recentDays} 場(新到舊)</strong>
|
||||||
|
{dailyAnalysis.recent.map((day) => (
|
||||||
|
<div className="attendance-bar-row" key={day.date}>
|
||||||
|
<span className="attendance-bar-label attendance-bar-label-date">
|
||||||
|
{formatDateKey(day.date)}
|
||||||
|
</span>
|
||||||
|
<div className="attendance-bar">
|
||||||
|
<div
|
||||||
|
className="attendance-bar-fill"
|
||||||
|
style={{
|
||||||
|
width: `${((day.count / dailyAnalysis.maxCount) * 100).toFixed(1)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="attendance-bar-value">{day.count} 人</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{view === 'players' ? (
|
||||||
|
<div className="attendance-legend">
|
||||||
|
<span>全期:以全部 {stats.totalDays} 場為分母</span>
|
||||||
|
<span>加入後:從各自第一次出席那天算起</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{view === 'players' ? (
|
||||||
|
<div className="attendance-list">
|
||||||
|
{stats.players.map((player, index) => (
|
||||||
|
<div className="attendance-row" key={player.name}>
|
||||||
|
<div className="attendance-head">
|
||||||
|
<span className="attendance-rank">{index + 1}</span>
|
||||||
|
<strong className="attendance-name">{player.name}</strong>
|
||||||
|
<span className="attendance-count">{player.count} 場</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="attendance-bars">
|
||||||
|
<div className="attendance-bar-row">
|
||||||
|
<span className="attendance-bar-label">全期</span>
|
||||||
|
<div className="attendance-bar">
|
||||||
|
<div
|
||||||
|
className="attendance-bar-fill"
|
||||||
|
style={{ width: `${(player.rate * 100).toFixed(1)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="attendance-bar-value">{formatRate(player.rate)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="attendance-bar-row">
|
||||||
|
<span className="attendance-bar-label">加入後</span>
|
||||||
|
<div className="attendance-bar">
|
||||||
|
<div
|
||||||
|
className="attendance-bar-fill attendance-bar-fill-since"
|
||||||
|
style={{ width: `${(player.sinceRate * 100).toFixed(1)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="attendance-bar-value">
|
||||||
|
{formatRate(player.sinceRate)}
|
||||||
|
<small>/{player.sinceDays} 場</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="attendance-meta">
|
||||||
|
<span>
|
||||||
|
最近 {stats.recentDays} 場:{player.recentCount} 次
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{formatDateKey(player.firstDate)} ~ {formatDateKey(player.lastDate)}
|
||||||
|
</span>
|
||||||
|
{player.mergedForms.length > 0 ? (
|
||||||
|
<span>已合併:{player.mergedForms.join('、')}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<p className="attendance-note">
|
||||||
|
出席資料取自每天的分組名單(`badminton` 表),不是比賽紀錄,所以有到場但沒被記分的場次也算出席。
|
||||||
|
{mergedPlayers.length > 0
|
||||||
|
? ` 同一個人不同寫法會自動合併,例如 ${mergedPlayers[0].mergedForms.join('、')}。`
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = {
|
type HistoryReplayModalProps = {
|
||||||
item: HistoryListItem
|
item: HistoryListItem
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
|||||||
@@ -138,6 +138,30 @@ export type HistoryListPage = {
|
|||||||
totalPages: number
|
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 LiveRoomStatus = 'live' | 'finished'
|
||||||
|
|
||||||
export type LiveRoomSession = {
|
export type LiveRoomSession = {
|
||||||
|
|||||||
Reference in New Issue
Block a user