功能:新增出席率統計,含每日人數分析分頁與名字別名合併

根本原因:
歷史戰績只有逐場比賽紀錄,看不出每個人長期的出席狀況,也不知道每天平均來幾個人;且名單上同一人有多種寫法(大小寫、錯字),直接統計會被拆成多個人而失真。

影響:
歷史戰績頁新增「出席率統計」按鈕,彈窗含兩個分頁——「出席排行」顯示每人出席場次、全期/加入後出席率長條與最近 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:
2026-08-10 15:21:37 +08:00
co-authored by Claude Opus 4.8
parent 68618a8e04
commit 710d04bffe
6 changed files with 813 additions and 3 deletions
+9
View File
@@ -34,6 +34,15 @@
- 可從資料庫讀取歷史列表。 - 可從資料庫讀取歷史列表。
- 點開單筆可查看得分過程。 - 點開單筆可查看得分過程。
- 每筆資料可刪除,刪除前會顯示確認提示。 - 每筆資料可刪除,刪除前會顯示確認提示。
- 出席率統計
- 歷史戰績頁的 `出席率統計` 按鈕會彈窗顯示每個人的出席狀況。
- 資料取自 `badminton` 表的每日分組名單,不是 `history` 表,所以有到場但沒被記分的場次也算出席。
- 同時顯示兩種出席率:
- `全期`:以資料庫全部場次為分母。
- `加入後`:從各自第一次出席那天算起,新加入的人不會被稀釋。
- 另外顯示最近 `12` 場的到場次數,可看出誰還在打球。
- 英文名大小寫不同(`RURU` / `RuRu` / `Ruru`)自動視為同一人;中文錯字用 `server/server.mjs``ATTENDANCE_ALIASES` 別名表對照,合併過的寫法會顯示在該列。
- `ATTENDANCE_IGNORED_NAMES` 列的佔位字(如 `輪空`)不列入統計。
- 房間觀戰 - 房間觀戰
- 記分板帶入隊伍後會自動建立房間。 - 記分板帶入隊伍後會自動建立房間。
- 房間列表可查看目前直播中的比賽。 - 房間列表可查看目前直播中的比賽。
+198
View File
@@ -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
View File
@@ -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) {
+20
View File
@@ -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
View File
@@ -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
+24
View File
@@ -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 = {