Files
badminton-scoreboard/server/server.mjs
T
JianMiauandClaude Fable 5 d53ebbd3a8 功能:出席率統計改以 attendance 表(TG 報名名單)為資料來源
摘要:
- 出席統計改讀 attendance 表(每天每人一列),依 poll_date 分組後餵給原本的統計函式
- 新增 DB_ATTENDANCE_TABLE 環境變數,預設 attendance;沒有此表時退回 badminton 分組名單
- 前端說明文字與 README 更新來源說明

根本原因:
- 原本用 badminton 表的每日分組名單,只有名單存進 DB 的日子才有資料,
  當天報名還沒存名單就統計不到,名字寫法也要靠別名表修補

影響:
- 統計天數 121 → 126 天,含當天報名尚未分組的場次
- 名字寫法在 attendance 表已統一,別名合併(卡森→景涵 等)仍生效

修法:
- server.mjs 新增 loadAttendanceDays(),buildAttendanceStats() 改吃 [{ time, names }]
- .env.example / docker-compose.yml / README 補上 DB_ATTENDANCE_TABLE

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 13:50:59 +08:00

1058 lines
26 KiB
JavaScript

import 'dotenv/config'
import express from 'express'
import mysql from 'mysql2/promise'
import { X509Certificate } from 'node:crypto'
import path from 'node:path'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
const app = express()
const port = Number(process.env.PORT ?? process.env.SERVER_PORT ?? 8788)
const matchTableName = process.env.DB_TABLE ?? 'badminton'
const historyTableName = process.env.DB_HISTORY_TABLE ?? 'history'
// 出席統計的來源:TG 報名 bot 寫入的 attendance 表(每天每人一列)。
const attendanceTableName = process.env.DB_ATTENDANCE_TABLE ?? 'attendance'
const appVersion = process.env.APP_VERSION ?? `${Date.now()}`
const appStartedAt = new Date().toISOString()
const LIVE_ROOM_STALE_MS = 30_000
// 出席統計的名字正規化設定。
// 英文名只是大小寫不同(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, '..')
const distDir = path.join(projectRoot, 'dist')
const roomDataDir = path.join(projectRoot, 'server', 'data')
const roomsFilePath = path.join(roomDataDir, 'live-rooms.json')
const distReady = existsSync(path.join(distDir, 'index.html'))
// SSL 憑證由 nginx 處理,這裡只讀取到期日回報給前端;
// 前端存下來後,遇到 Failed to fetch 就能判斷是不是憑證過期造成的。
const sslCertDir = process.env.SSL_CERT_DIR ?? ''
const sslCertFileName = process.env.SSL_CERT_FILE_NAME ?? 'cert.pem'
const sslCertPath = sslCertDir ? path.join(sslCertDir, sslCertFileName) : ''
const CERT_INFO_TTL_MS = 60_000
let certInfoCache = { checkedAt: 0, notAfter: null }
const requiredEnv = ['DB_HOST', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_DATABASE']
const missingEnv = requiredEnv.filter((key) => !process.env[key])
const pool =
missingEnv.length === 0
? mysql.createPool({
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT),
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
charset: 'utf8mb4',
waitForConnections: true,
connectionLimit: 10,
})
: null
const rooms = loadPersistedRooms()
const roomListClients = new Set()
app.use(express.json())
app.get('/api/health', (_request, response) => {
response.json({
appStartedAt,
appVersion,
certNotAfter: getCertNotAfter(),
ok: true,
dbReady: Boolean(pool),
distReady,
historyTableName,
matchTableName,
missingEnv,
})
})
app.get('/api/version', (_request, response) => {
response.set({
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
Expires: '0',
Pragma: 'no-cache',
'Surrogate-Control': 'no-store',
})
response.json({
certNotAfter: getCertNotAfter(),
ok: true,
startedAt: appStartedAt,
version: appVersion,
})
})
app.get('/api/rooms', (_request, response) => {
response.json({
ok: true,
data: getLiveRoomSummaries(),
})
})
app.post('/api/rooms/reconcile', (_request, response) => {
const removedRoomIds = pruneStaleRooms()
response.json({
ok: true,
data: {
removedRoomIds,
},
})
})
app.get('/api/rooms/stream', (request, response) => {
setupSse(response)
roomListClients.add(response)
sendSse(response, 'rooms', getLiveRoomSummaries())
request.on('close', () => {
roomListClients.delete(response)
})
})
app.post('/api/rooms', (request, response) => {
const payload = normalizeRoomPayload(request.body)
if (!payload.ok) {
response.status(400).json({
ok: false,
message: payload.message,
})
return
}
const roomId = createRoomId()
const hostToken = createHostToken()
const now = new Date().toISOString()
const room = {
...payload.value,
clients: new Set(),
createdAt: now,
hostToken,
hostSeenAt: now,
roomId,
status: 'live',
updatedAt: now,
winnerTeamName: null,
}
rooms.set(roomId, room)
persistRooms()
broadcastRoom(room)
broadcastRoomList()
response.json({
ok: true,
data: {
hostToken,
roomId,
status: room.status,
},
})
})
app.post('/api/rooms/:roomId/release', (request, response) => {
const room = rooms.get(request.params.roomId)
if (!room) {
response.json({
ok: true,
})
return
}
const { hostToken } = request.body ?? {}
if (typeof hostToken !== 'string' || hostToken !== room.hostToken) {
response.status(403).json({
ok: false,
message: '沒有權限清除此房間。',
})
return
}
if (room.status === 'finished') {
response.json({
ok: true,
})
return
}
room.clients.forEach((client) => {
sendSse(client, 'room-closed', {
roomId: room.roomId,
status: 'released',
})
client.end()
})
rooms.delete(room.roomId)
persistRooms()
broadcastRoomList()
response.json({
ok: true,
})
})
app.post('/api/rooms/:roomId/heartbeat', (request, response) => {
const room = rooms.get(request.params.roomId)
if (!room) {
response.status(404).json({
ok: false,
message: '找不到這個房間。',
})
return
}
const { hostToken } = request.body ?? {}
if (typeof hostToken !== 'string' || hostToken !== room.hostToken) {
response.status(403).json({
ok: false,
message: '沒有權限更新房間心跳。',
})
return
}
room.hostSeenAt = new Date().toISOString()
persistRooms()
response.json({
ok: true,
})
})
app.get('/api/rooms/:roomId', (request, response) => {
const room = rooms.get(request.params.roomId)
if (!room) {
response.status(404).json({
ok: false,
message: '找不到這個房間。',
})
return
}
response.json({
ok: true,
data: serializeRoom(room),
})
})
app.get('/api/rooms/:roomId/stream', (request, response) => {
const room = rooms.get(request.params.roomId)
if (!room) {
response.status(404).json({
ok: false,
message: '找不到這個房間。',
})
return
}
setupSse(response)
room.clients.add(response)
sendSse(response, 'room', serializeRoom(room))
request.on('close', () => {
room.clients.delete(response)
})
})
app.put('/api/rooms/:roomId', (request, response) => {
const room = rooms.get(request.params.roomId)
if (!room) {
response.status(404).json({
ok: false,
message: '找不到這個房間。',
})
return
}
const { hostToken, status, winnerTeamName } = request.body ?? {}
if (typeof hostToken !== 'string' || hostToken !== room.hostToken) {
response.status(403).json({
ok: false,
message: '沒有更新這個房間的權限。',
})
return
}
const payload = normalizeRoomPayload(request.body)
if (!payload.ok) {
response.status(400).json({
ok: false,
message: payload.message,
})
return
}
room.groupId = payload.value.groupId
room.leftTeamName = payload.value.leftTeamName
room.matchupLabel = payload.value.matchupLabel
room.pointLog = payload.value.pointLog
room.rightTeamName = payload.value.rightTeamName
room.scoreState = payload.value.scoreState
room.targetDate = payload.value.targetDate
room.status = status === 'finished' ? 'finished' : 'live'
room.updatedAt = new Date().toISOString()
room.winnerTeamName = room.status === 'finished' && typeof winnerTeamName === 'string'
? winnerTeamName
: null
persistRooms()
broadcastRoom(room)
broadcastRoomList()
response.json({
ok: true,
data: serializeRoom(room),
})
})
app.get('/api/match-results/:time', async (request, response) => {
if (!pool) {
response.status(500).json({
ok: false,
message: `DB 尚未設定完成,缺少 ${missingEnv.join(', ')}`,
})
return
}
const time = String(request.params.time ?? '')
if (!/^\d{8}$/.test(time)) {
response.status(400).json({
ok: false,
message: '日期格式必須是 YYYYMMDD。',
})
return
}
try {
await ensureMatchTable(pool, matchTableName)
const [rows] = await pool.execute(
`SELECT time, personnel, battlecombination FROM \`${matchTableName}\` WHERE time = ? LIMIT 1`,
[Number(time)],
)
const record = rows[0]
if (!record) {
response.status(404).json({
ok: false,
message: '指定日期沒有資料。',
})
return
}
response.json({
ok: true,
data: record,
})
} catch (error) {
console.error('match-results load error:', error)
response.status(500).json({
ok: false,
message: error instanceof Error ? error.message : '讀取對戰資料失敗。',
})
}
})
app.get('/api/attendance', async (_request, response) => {
if (!pool) {
response.status(500).json({
ok: false,
message: `DB 尚未設定完成,缺少 ${missingEnv.join(', ')}`,
})
return
}
try {
// 出席統計以 attendance 表(TG 報名名單)為主:比每天的分組名單完整,
// 當天名單還沒存進 badminton 表之前就有資料,名字寫法也已經統一。
// 沒有這張表的環境(例如舊的 DB)退回用 badminton 表的分組名單。
const days = await loadAttendanceDays(pool)
response.json({
ok: true,
data: buildAttendanceStats(days),
})
} 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({
ok: false,
message: `DB 尚未設定完成,缺少 ${missingEnv.join(', ')}`,
})
return
}
const {
dayOfWeek,
players,
score,
scoreList,
team,
time,
type,
winScore,
} = request.body ?? {}
if (
typeof time !== 'number' ||
typeof dayOfWeek !== 'number' ||
typeof winScore !== 'number' ||
typeof type !== 'number' ||
!Array.isArray(score) ||
score.length !== 2 ||
!Array.isArray(players) ||
!Array.isArray(team) ||
!Array.isArray(scoreList)
) {
response.status(400).json({
ok: false,
message: '戰績資料格式不正確。',
})
return
}
try {
await ensureHistoryTable(pool, historyTableName)
const [result] = await pool.execute(
`
INSERT INTO \`${historyTableName}\`
(time, dayOfWeek, score, winScore, type, players, team, scoreList)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
[
time,
dayOfWeek,
JSON.stringify(score),
winScore,
type,
JSON.stringify(players),
JSON.stringify(team),
JSON.stringify(scoreList),
],
)
response.json({
ok: true,
data: {
id: result.insertId,
},
message: '戰績已寫入 DB。',
})
} catch (error) {
console.error('history save error:', error)
response.status(500).json({
ok: false,
message: error instanceof Error ? error.message : '寫入戰績失敗。',
})
}
})
app.get('/api/history', async (request, response) => {
if (!pool) {
response.status(500).json({
ok: false,
message: `DB 尚未設定完成,缺少 ${missingEnv.join(', ')}`,
})
return
}
// 分頁參數已驗證為整數並限制範圍,直接內插避免 mysql2 execute 對 LIMIT 佔位符的相容問題。
const page = Math.max(1, Number.parseInt(request.query.page, 10) || 1)
const pageSize = Math.min(100, Math.max(1, Number.parseInt(request.query.pageSize, 10) || 20))
const offset = (page - 1) * pageSize
try {
await ensureHistoryTable(pool, historyTableName)
const [[countRow]] = await pool.execute(
`SELECT COUNT(*) AS total FROM \`${historyTableName}\``,
)
const total = Number(countRow?.total ?? 0)
const [rows] = await pool.execute(
`
SELECT id, time, dayOfWeek, score, winScore, type, players, team, scoreList
FROM \`${historyTableName}\`
ORDER BY id DESC
LIMIT ${pageSize} OFFSET ${offset}
`,
)
response.json({
ok: true,
data: rows,
page,
pageSize,
total,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
})
} catch (error) {
console.error('history load error:', error)
response.status(500).json({
ok: false,
message: error instanceof Error ? error.message : '讀取歷史戰績失敗。',
})
}
})
app.delete('/api/history/:id', async (request, response) => {
if (!pool) {
response.status(500).json({
ok: false,
message: `DB 設定不完整,缺少:${missingEnv.join(', ')}`,
})
return
}
const id = Number(request.params.id)
if (!Number.isInteger(id) || id <= 0) {
response.status(400).json({
ok: false,
message: '戰績編號格式不正確。',
})
return
}
try {
await ensureHistoryTable(pool, historyTableName)
const [result] = await pool.execute(
`DELETE FROM \`${historyTableName}\` WHERE id = ? LIMIT 1`,
[id],
)
if (result.affectedRows === 0) {
response.status(404).json({
ok: false,
message: '找不到要刪除的戰績。',
})
return
}
response.json({
ok: true,
message: '戰績已刪除。',
})
} catch (error) {
console.error('history delete error:', error)
response.status(500).json({
ok: false,
message: error instanceof Error ? error.message : '刪除戰績失敗。',
})
}
})
if (distReady) {
app.use(express.static(distDir))
app.get(/^(?!\/api).*/, (_request, response) => {
response.sendFile(path.join(distDir, 'index.html'))
})
}
app.listen(port, () => {
console.log(`Server ready on http://localhost:${port}`)
if (missingEnv.length > 0) {
console.log(`Missing env: ${missingEnv.join(', ')}`)
}
})
// 憑證檔可能隨時被換新,帶 60 秒快取避免每次請求都讀檔。
function getCertNotAfter() {
if (!sslCertPath) {
return null
}
const now = Date.now()
if (now - certInfoCache.checkedAt < CERT_INFO_TTL_MS) {
return certInfoCache.notAfter
}
let notAfter = null
try {
const certificate = new X509Certificate(readFileSync(sslCertPath))
const notAfterTime = Date.parse(certificate.validTo)
if (Number.isFinite(notAfterTime)) {
notAfter = new Date(notAfterTime).toISOString()
}
} catch (error) {
console.error('read ssl cert error:', error instanceof Error ? error.message : error)
}
certInfoCache = { checkedAt: now, notAfter }
return notAfter
}
function createHostToken() {
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
}
function createRoomId() {
let roomId = ''
do {
roomId = String(Math.floor(100000 + Math.random() * 900000))
} while (rooms.has(roomId))
return roomId
}
function loadPersistedRooms() {
const nextRooms = new Map()
try {
if (!existsSync(roomsFilePath)) {
return nextRooms
}
const raw = readFileSync(roomsFilePath, 'utf8')
if (!raw.trim()) {
return nextRooms
}
const savedRooms = JSON.parse(raw)
if (!Array.isArray(savedRooms)) {
return nextRooms
}
savedRooms.forEach((savedRoom) => {
if (!savedRoom || typeof savedRoom !== 'object' || typeof savedRoom.roomId !== 'string') {
return
}
nextRooms.set(savedRoom.roomId, {
...savedRoom,
hostSeenAt:
typeof savedRoom.hostSeenAt === 'string' ? savedRoom.hostSeenAt : savedRoom.updatedAt,
clients: new Set(),
})
})
} catch (error) {
console.error('load persisted rooms error:', error)
}
return nextRooms
}
function persistRooms() {
try {
mkdirSync(roomDataDir, { recursive: true })
writeFileSync(
roomsFilePath,
JSON.stringify(
Array.from(rooms.values()).map((room) => ({
createdAt: room.createdAt,
groupId: room.groupId,
hostToken: room.hostToken,
hostSeenAt: room.hostSeenAt,
leftTeamName: room.leftTeamName,
matchupLabel: room.matchupLabel,
pointLog: room.pointLog,
rightTeamName: room.rightTeamName,
roomId: room.roomId,
scoreState: room.scoreState,
status: room.status,
targetDate: room.targetDate,
updatedAt: room.updatedAt,
winnerTeamName: room.winnerTeamName,
})),
null,
2,
),
'utf8',
)
} catch (error) {
console.error('persist rooms error:', error)
}
}
function normalizeRoomPayload(value) {
const {
groupId,
leftTeamName,
matchupLabel,
pointLog,
rightTeamName,
scoreState,
targetDate,
} = value ?? {}
if (
typeof leftTeamName !== 'string' ||
typeof rightTeamName !== 'string' ||
typeof matchupLabel !== 'string' ||
typeof targetDate !== 'string' ||
!Array.isArray(pointLog) ||
!scoreState ||
typeof scoreState !== 'object'
) {
return {
ok: false,
message: '房間資料格式不正確。',
}
}
return {
ok: true,
value: {
groupId: typeof groupId === 'number' ? groupId : null,
leftTeamName,
matchupLabel,
pointLog,
rightTeamName,
scoreState,
targetDate,
},
}
}
function serializeRoom(room) {
return {
roomId: room.roomId,
groupId: room.groupId,
leftTeamName: room.leftTeamName,
matchupLabel: room.matchupLabel,
pointLog: room.pointLog,
rightTeamName: room.rightTeamName,
scoreState: room.scoreState,
status: room.status,
targetDate: room.targetDate,
createdAt: room.createdAt,
updatedAt: room.updatedAt,
winnerTeamName: room.winnerTeamName,
}
}
function pruneStaleRooms() {
const now = Date.now()
const removedRoomIds = []
rooms.forEach((room, roomId) => {
if (room.status !== 'live') {
return
}
const hostSeenAtTime = Date.parse(room.hostSeenAt ?? '')
if (!Number.isFinite(hostSeenAtTime) || now - hostSeenAtTime > LIVE_ROOM_STALE_MS) {
room.clients.forEach((client) => {
sendSse(client, 'room-closed', {
roomId: room.roomId,
status: 'stale',
})
client.end()
})
rooms.delete(roomId)
removedRoomIds.push(roomId)
}
})
if (removedRoomIds.length > 0) {
persistRooms()
broadcastRoomList()
}
return removedRoomIds
}
function getLiveRoomSummaries() {
return Array.from(rooms.values())
.filter((room) => room.status === 'live')
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
.map((room) => ({
roomId: room.roomId,
createdAt: room.createdAt,
leftTeamName: room.leftTeamName,
rightTeamName: room.rightTeamName,
scoreLeft: room.scoreState.scoreLeft,
scoreRight: room.scoreState.scoreRight,
status: room.status,
targetScore: room.scoreState.targetScore,
updatedAt: room.updatedAt,
}))
}
function setupSse(response) {
response.writeHead(200, {
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'Content-Type': 'text/event-stream',
})
}
function sendSse(response, eventName, payload) {
response.write(`event: ${eventName}\n`)
response.write(`data: ${JSON.stringify(payload)}\n\n`)
}
function broadcastRoom(room) {
const payload = serializeRoom(room)
room.clients.forEach((client) => {
sendSse(client, 'room', payload)
})
}
function broadcastRoomList() {
const payload = getLiveRoomSummaries()
roomListClients.forEach((client) => {
sendSse(client, 'rooms', payload)
})
}
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 []
}
}
// 讀出每天的出席名單,回傳 [{ time, names }],時間由舊到新。
async function loadAttendanceDays(pool) {
try {
const [rows] = await pool.execute(
`SELECT poll_date, nickname FROM \`${attendanceTableName}\` ORDER BY poll_date ASC, joined_at ASC`,
)
const daysByTime = new Map()
rows.forEach((row) => {
const time = Number(row.poll_date)
const name = String(row.nickname ?? '').trim()
if (!Number.isFinite(time) || !name) {
return
}
const names = daysByTime.get(time) ?? []
names.push(name)
daysByTime.set(time, names)
})
return Array.from(daysByTime.entries()).map(([time, names]) => ({ time, names }))
} catch (error) {
if (error?.code !== 'ER_NO_SUCH_TABLE') {
throw error
}
console.warn(`attendance table \`${attendanceTableName}\` 不存在,改用 \`${matchTableName}\` 的分組名單統計。`)
}
await ensureMatchTable(pool, matchTableName)
const [rows] = await pool.execute(
`SELECT time, personnel FROM \`${matchTableName}\` ORDER BY time ASC`,
)
return rows
.map((row) => ({
time: Number(row.time),
names: parseAttendanceNames(row.personnel),
}))
.filter((day) => Number.isFinite(day.time) && day.names.length > 0)
}
function buildAttendanceStats(days) {
const totalDays = days.length
if (totalDays === 0) {
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}\` (
time INT(11) NOT NULL,
personnel TEXT NOT NULL,
battlecombination TEXT DEFAULT NULL,
PRIMARY KEY (time)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
`)
}
async function ensureHistoryTable(poolInstance, currentTableName) {
await poolInstance.execute(`
CREATE TABLE IF NOT EXISTS \`${currentTableName}\` (
id INT(11) NOT NULL AUTO_INCREMENT,
time INT(11) NOT NULL COMMENT '記錄時間',
dayOfWeek INT(1) NOT NULL COMMENT '星期',
score VARCHAR(255) NOT NULL COMMENT '隊伍分數 [ [隊伍1分數], [隊伍2分數] ]',
winScore INT(2) NOT NULL COMMENT '幾分獲勝',
type INT(1) NOT NULL COMMENT '遊戲類型(0:雙打,1:單打)',
players VARCHAR(255) NOT NULL COMMENT '玩家',
team VARCHAR(255) NOT NULL COMMENT '玩家隊伍 [ [隊伍1成員], [隊伍2成員] ]',
scoreList TEXT DEFAULT NULL COMMENT '得分過程[round, starter, winCount, winner]',
PRIMARY KEY (id)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
`)
}