功能:新增出席率統計,含每日人數分析分頁與名字別名合併
根本原因: 歷史戰績只有逐場比賽紀錄,看不出每個人長期的出席狀況,也不知道每天平均來幾個人;且名單上同一人有多種寫法(大小寫、錯字),直接統計會被拆成多個人而失真。 影響: 歷史戰績頁新增「出席率統計」按鈕,彈窗含兩個分頁——「出席排行」顯示每人出席場次、全期/加入後出席率長條與最近 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:
+245
@@ -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) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AttendanceStats,
|
||||
HistoryListItem,
|
||||
HistoryListPage,
|
||||
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) {
|
||||
const response = await apiFetch(`/api/history/${id}`, {
|
||||
method: 'DELETE',
|
||||
|
||||
+317
-3
@@ -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<HistoryListItem | null>(null)
|
||||
const [attendanceOpen, setAttendanceOpen] = useState(false)
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
@@ -99,6 +100,14 @@ export function HistoryPage() {
|
||||
<p className="panel-copy">
|
||||
這裡會直接從資料庫的 `history` 表讀取比賽紀錄,點開後可查看每一分的得分過程,也能刪除單筆資料。
|
||||
</p>
|
||||
|
||||
<button
|
||||
className="secondary-button attendance-open-button"
|
||||
type="button"
|
||||
onClick={() => setAttendanceOpen(true)}
|
||||
>
|
||||
出席率統計
|
||||
</button>
|
||||
</article>
|
||||
|
||||
<article className="panel full-span">
|
||||
@@ -213,10 +222,315 @@ export function HistoryPage() {
|
||||
{selectedItem ? (
|
||||
<HistoryReplayModal item={selectedItem} onClose={() => setSelectedItem(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 = {
|
||||
item: HistoryListItem
|
||||
onClose: () => void
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user