diff --git a/README.md b/README.md index 742e0fe..e6e1e25 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ - `比賽結算` 需要長按 `1 秒` 才會觸發。 - 比分 `0:0` 時不可結算。 - 全站文字預設不可選取,避免手機誤觸反白。 + - 只要已設定先攻並開始比賽,就不能切換到其他分頁,需先完成結算。 - 語音播報 - 可設定是否播報得分者。 - 可設定是否播報發球者。 @@ -38,6 +39,8 @@ - 帶入隊伍進入記分板後會自動建立房間。 - 記分板會顯示房號。 - 房間列表不顯示比分,只顯示房號、隊伍、目標分數與更新時間。 + - 房間列表可手動重新取得,按一次後有 `5 秒` 冷卻。 + - 手動重新取得時,會順便清理沒有主控在線的無主房間。 - 觀戰者只能看,不能操作。 - 觀戰同步使用 `SSE + 輪詢備援`。 - 房主重整、離開記分板或換隊伍時,未完成房間會自動清掉。 @@ -46,6 +49,7 @@ - PWA - 可加入手機主畫面,像 App 一樣開啟。 - 支援主畫面 icon 與版本更新提示。 + - 文件頁面改為網路優先,降低 iPad / iPhone PWA 卡舊版快取的機率。 ## 開發 diff --git a/public/sw.js b/public/sw.js index 7eec742..b48cfe3 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,7 +1,5 @@ -const CACHE_NAME = 'badminton-scoreboard-v1' +const CACHE_NAME = 'badminton-scoreboard-v2' const APP_SHELL = [ - '/', - '/index.html', '/manifest.webmanifest', '/favicon.png', '/icon.png', @@ -51,19 +49,45 @@ self.addEventListener('fetch', (event) => { return } + if (requestUrl.pathname.startsWith('/api/')) { + event.respondWith(fetch(event.request)) + return + } + + const isNavigationRequest = + event.request.mode === 'navigate' || event.request.destination === 'document' + + if (isNavigationRequest) { + event.respondWith( + fetch(event.request) + .then(async (networkResponse) => { + if (networkResponse.ok) { + const cache = await caches.open(CACHE_NAME) + cache.put('/index.html', networkResponse.clone()) + } + + return networkResponse + }) + .catch(async () => { + const fallback = await caches.match('/index.html') + if (fallback) { + return fallback + } + + throw new Error('Navigation request failed') + }), + ) + return + } + event.respondWith( caches.match(event.request).then(async (cachedResponse) => { - if (cachedResponse) { - return cachedResponse - } - try { const networkResponse = await fetch(event.request) if ( networkResponse.ok && - (event.request.destination === 'document' || - event.request.destination === 'script' || + (event.request.destination === 'script' || event.request.destination === 'style' || event.request.destination === 'image' || requestUrl.pathname.startsWith('/assets/')) @@ -74,11 +98,8 @@ self.addEventListener('fetch', (event) => { return networkResponse } catch (error) { - if (event.request.mode === 'navigate') { - const fallback = await caches.match('/index.html') - if (fallback) { - return fallback - } + if (cachedResponse) { + return cachedResponse } throw error diff --git a/server/data/live-rooms.json b/server/data/live-rooms.json index 08b73dd..fe51488 100644 --- a/server/data/live-rooms.json +++ b/server/data/live-rooms.json @@ -1,197 +1 @@ -[ - { - "createdAt": "2026-04-19T04:50:38.216Z", - "groupId": 1, - "hostToken": "mo5afjewicxz50b9", - "leftTeamName": "柏威 / 玟瑄", - "matchupLabel": "柏威 / 玟瑄 vs 小念 / 建喵", - "pointLog": [ - { - "round": 0, - "starter": 3, - "winCount": 0, - "winner": 1 - }, - { - "round": 1, - "starter": 3, - "winCount": 0, - "winner": 0 - }, - { - "round": 2, - "starter": 1, - "winCount": 1, - "winner": 0 - }, - { - "round": 3, - "starter": 1, - "winCount": 0, - "winner": 1 - }, - { - "round": 4, - "starter": 2, - "winCount": 1, - "winner": 1 - } - ], - "rightTeamName": "小念 / 建喵", - "roomId": "341793", - "scoreState": { - "scoreLeft": 2, - "scoreRight": 3, - "gamesLeft": 0, - "gamesRight": 0, - "currentGame": 1, - "targetScore": 21, - "serving": "right", - "leftRightCourtPlayer": "playerB", - "rightRightCourtPlayer": "playerA" - }, - "status": "finished", - "targetDate": "2026-04-13", - "updatedAt": "2026-04-19T04:50:56.351Z", - "winnerTeamName": "小念 / 建喵" - }, - { - "createdAt": "2026-04-19T04:51:17.794Z", - "groupId": 2, - "hostToken": "mo5agdyag7fyqyxv", - "leftTeamName": "景涵 / 小念", - "matchupLabel": "景涵 / 小念 vs 柏威 / 玟瑄", - "pointLog": [ - { - "round": 0, - "starter": 0, - "winCount": 0, - "winner": 0 - }, - { - "round": 1, - "starter": 0, - "winCount": 0, - "winner": 1 - } - ], - "rightTeamName": "柏威 / 玟瑄", - "roomId": "174740", - "scoreState": { - "scoreLeft": 1, - "scoreRight": 1, - "gamesLeft": 0, - "gamesRight": 0, - "currentGame": 1, - "targetScore": 21, - "serving": "right", - "leftRightCourtPlayer": "playerB", - "rightRightCourtPlayer": "playerA" - }, - "status": "finished", - "targetDate": "2026-04-13", - "updatedAt": "2026-04-19T04:51:25.160Z", - "winnerTeamName": "景涵 / 小念" - }, - { - "createdAt": "2026-04-19T04:51:25.190Z", - "groupId": 2, - "hostToken": "mo5agjnqeabkfpr2", - "leftTeamName": "景涵 / 小念", - "matchupLabel": "景涵 / 小念 vs 柏威 / 玟瑄", - "pointLog": [ - { - "round": 0, - "starter": 0, - "winCount": 0, - "winner": 0 - }, - { - "round": 1, - "starter": 0, - "winCount": 0, - "winner": 1 - }, - { - "round": 2, - "starter": 2, - "winCount": 1, - "winner": 1 - } - ], - "rightTeamName": "柏威 / 玟瑄", - "roomId": "239300", - "scoreState": { - "scoreLeft": 1, - "scoreRight": 2, - "gamesLeft": 0, - "gamesRight": 0, - "currentGame": 1, - "targetScore": 21, - "serving": "right", - "leftRightCourtPlayer": "playerB", - "rightRightCourtPlayer": "playerB" - }, - "status": "finished", - "targetDate": "2026-04-13", - "updatedAt": "2026-04-19T04:52:26.087Z", - "winnerTeamName": "柏威 / 玟瑄" - }, - { - "createdAt": "2026-04-19T04:58:15.291Z", - "groupId": 1, - "hostToken": "mo5apc3foksw0enn", - "leftTeamName": "景涵 / RuRu", - "matchupLabel": "景涵 / RuRu vs 小念 / 柏威", - "pointLog": [ - { - "round": 0, - "starter": 0, - "winCount": 0, - "winner": 0 - } - ], - "rightTeamName": "小念 / 柏威", - "roomId": "432277", - "scoreState": { - "scoreLeft": 1, - "scoreRight": 0, - "gamesLeft": 0, - "gamesRight": 0, - "currentGame": 1, - "targetScore": 21, - "serving": "left", - "leftRightCourtPlayer": "playerB", - "rightRightCourtPlayer": "playerA" - }, - "status": "finished", - "targetDate": "2026-04-13", - "updatedAt": "2026-04-19T04:58:25.870Z", - "winnerTeamName": "景涵 / RuRu" - }, - { - "createdAt": "2026-04-19T05:01:10.705Z", - "groupId": 1, - "hostToken": "mo5at3g18sybc1fw", - "leftTeamName": "柏威 / RuRu", - "matchupLabel": "柏威 / RuRu vs 建喵 / 小念", - "pointLog": [], - "rightTeamName": "建喵 / 小念", - "roomId": "498013", - "scoreState": { - "scoreLeft": 0, - "scoreRight": 0, - "gamesLeft": 0, - "gamesRight": 0, - "currentGame": 1, - "targetScore": 21, - "serving": null, - "leftRightCourtPlayer": "playerA", - "rightRightCourtPlayer": "playerA" - }, - "status": "live", - "targetDate": "2026-04-13", - "updatedAt": "2026-04-19T05:01:10.731Z", - "winnerTeamName": null - } -] \ No newline at end of file +[] diff --git a/server/server.mjs b/server/server.mjs index 78ea39d..fae5c34 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -11,6 +11,7 @@ const matchTableName = process.env.DB_TABLE ?? 'badminton' const historyTableName = process.env.DB_HISTORY_TABLE ?? 'history' const appVersion = process.env.APP_VERSION ?? `${Date.now()}` const appStartedAt = new Date().toISOString() +const LIVE_ROOM_STALE_MS = 30_000 const currentFilePath = fileURLToPath(import.meta.url) const currentDir = path.dirname(currentFilePath) @@ -77,6 +78,17 @@ app.get('/api/rooms', (_request, response) => { }) }) +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) @@ -106,6 +118,7 @@ app.post('/api/rooms', (request, response) => { clients: new Set(), createdAt: now, hostToken, + hostSeenAt: now, roomId, status: 'live', updatedAt: now, @@ -171,6 +184,35 @@ app.post('/api/rooms/:roomId/release', (request, response) => { }) }) +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) @@ -520,6 +562,8 @@ function loadPersistedRooms() { nextRooms.set(savedRoom.roomId, { ...savedRoom, + hostSeenAt: + typeof savedRoom.hostSeenAt === 'string' ? savedRoom.hostSeenAt : savedRoom.updatedAt, clients: new Set(), }) }) @@ -540,6 +584,7 @@ function persistRooms() { createdAt: room.createdAt, groupId: room.groupId, hostToken: room.hostToken, + hostSeenAt: room.hostSeenAt, leftTeamName: room.leftTeamName, matchupLabel: room.matchupLabel, pointLog: room.pointLog, @@ -618,6 +663,39 @@ function serializeRoom(room) { } } +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') diff --git a/src/App.tsx b/src/App.tsx index 6391b61..4dc6324 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import { loadMatchResults, releaseLiveRoom, saveMatchHistory, + sendLiveRoomHeartbeat, updateLiveRoom, } from './lib/api' import { @@ -90,6 +91,7 @@ const STREAK_TITLES: Record = { } const PWA_UPDATE_EVENT = 'badminton-scoreboard:pwa-update-ready' const APP_VERSION_POLL_MS = 30000 +const LIVE_ROOM_HEARTBEAT_MS = 10_000 function App() { const location = useLocation() @@ -129,6 +131,7 @@ function App() { const [victoryAnnouncement, setVictoryAnnouncement] = useState(null) const [pwaUpdateReady, setPwaUpdateReady] = useState(false) const [liveRoomSession, setLiveRoomSession] = useState(null) + const [navigationLockMessage, setNavigationLockMessage] = useState('') const currentAppVersionRef = useRef(null) const creatingRoomRef = useRef(false) const lastSyncedRoomSignatureRef = useRef('') @@ -139,6 +142,7 @@ function App() { const leftTeam = activeMatchup.leftTeam const rightTeam = activeMatchup.rightTeam const liveRoomId = liveRoomSession?.roomId ?? null + const isNavigationLocked = Boolean(leftTeam && rightTeam && scoreState.serving !== null) useEffect(() => { window.localStorage.setItem(STORAGE_KEYS.targetDate, targetDate) @@ -192,6 +196,18 @@ function App() { return () => window.clearTimeout(timer) }, [victoryAnnouncement]) + useEffect(() => { + if (!navigationLockMessage) { + return + } + + const timer = window.setTimeout(() => { + setNavigationLockMessage('') + }, 1400) + + return () => window.clearTimeout(timer) + }, [navigationLockMessage]) + useEffect(() => { const handlePwaUpdateReady = () => { setPwaUpdateReady(true) @@ -482,6 +498,43 @@ function App() { isScoreboardRoute, ]) + useEffect(() => { + if (!isNavigationLocked || isScoreboardRoute) { + return + } + + navigate('/scoreboard', { replace: true }) + setNavigationLockMessage('比賽進行中,請先完成結算。') + }, [isNavigationLocked, isScoreboardRoute, navigate]) + + useEffect(() => { + if (!isScoreboardRoute || !liveRoomSession || liveRoomSession.status !== 'live') { + return + } + + let active = true + + const syncHeartbeat = async () => { + try { + await sendLiveRoomHeartbeat(liveRoomSession.roomId, liveRoomSession.hostToken) + } catch (error) { + if (active) { + console.error('live room heartbeat error:', error) + } + } + } + + void syncHeartbeat() + const timer = window.setInterval(() => { + void syncHeartbeat() + }, LIVE_ROOM_HEARTBEAT_MS) + + return () => { + active = false + window.clearInterval(timer) + } + }, [isScoreboardRoute, liveRoomSession]) + useEffect(() => { if (!liveRoomSession || liveRoomSession.status !== 'live') { return @@ -822,6 +875,15 @@ function App() { } } + const handleNavAttempt = (targetPath: string) => (event: React.MouseEvent) => { + if (!isNavigationLocked || targetPath === '/scoreboard') { + return + } + + event.preventDefault() + setNavigationLockMessage('比賽進行中,請先完成結算。') + } + return (
@@ -837,16 +899,32 @@ function App() {
@@ -943,6 +1021,12 @@ function App() { ) : null} + + {navigationLockMessage ? ( +
+ {navigationLockMessage} +
+ ) : null} ) } diff --git a/src/lib/api.ts b/src/lib/api.ts index 81b7dc6..f376dba 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -24,7 +24,7 @@ export async function loadMatchResults(time: string) { } if (!response.ok || !payload.ok) { - throw new Error(payload.message ?? '無法讀取對戰資料。') + throw new Error(payload.message ?? '讀取指定日期分組失敗。') } return payload.data ?? null @@ -46,7 +46,7 @@ export async function saveMatchHistory(payload: HistoryUploadPayload) { } if (!response.ok || !result.ok || !result.data) { - throw new Error(result.message ?? '無法上傳戰績。') + throw new Error(result.message ?? '上傳戰績失敗。') } return result.data @@ -61,7 +61,7 @@ export async function loadHistoryList() { } if (!response.ok || !payload.ok) { - throw new Error(payload.message ?? '無法讀取歷史戰績。') + throw new Error(payload.message ?? '讀取歷史戰績失敗。') } return (payload.data ?? []).map(normalizeHistoryRecord) @@ -98,7 +98,7 @@ export async function createLiveRoom(payload: LiveRoomPayload) { } if (!response.ok || !result.ok || !result.data) { - throw new Error(result.message ?? '建立觀戰房間失敗。') + throw new Error(result.message ?? '建立房間失敗。') } return result.data @@ -146,6 +146,46 @@ export async function releaseLiveRoom(roomId: string, hostToken: string) { } } +export async function sendLiveRoomHeartbeat(roomId: string, hostToken: string) { + const response = await fetch(`/api/rooms/${roomId}/heartbeat`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ hostToken }), + keepalive: true, + }) + + const result = (await readJsonSafely(response)) as { + ok?: boolean + message?: string + } + + if (!response.ok || !result.ok) { + throw new Error(result.message ?? '更新房間心跳失敗。') + } +} + +export async function reconcileLiveRooms() { + const response = await fetch('/api/rooms/reconcile', { + method: 'POST', + }) + + const result = (await readJsonSafely(response)) as { + ok?: boolean + message?: string + data?: { + removedRoomIds?: string[] + } + } + + if (!response.ok || !result.ok) { + throw new Error(result.message ?? '清理無主房間失敗。') + } + + return result.data?.removedRoomIds ?? [] +} + export async function loadLiveRoomList() { const response = await fetch('/api/rooms') const result = (await readJsonSafely(response)) as { @@ -178,7 +218,7 @@ export async function loadLiveRoom(roomId: string) { } if (!response.ok || !result.ok || !result.data) { - throw new Error(result.message ?? '載入房間內容失敗。') + throw new Error(result.message ?? '載入觀戰房間失敗。') } return result.data @@ -271,7 +311,7 @@ function parseJson(value: string | null, fallback: T): T { } function getDayLabel(dayOfWeek: number) { - const labels = ['週日', '週一', '週二', '週三', '週四', '週五', '週六'] + const labels = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'] return labels[dayOfWeek] ?? '-' } diff --git a/src/main.tsx b/src/main.tsx index 9311842..004d7f0 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -43,7 +43,11 @@ if ('serviceWorker' in navigator) { window.location.reload() }) - void navigator.serviceWorker.register('/sw.js').then((registration) => { + void navigator.serviceWorker + .register('/sw.js', { + updateViaCache: 'none', + }) + .then((registration) => { if (registration.waiting) { notifyUpdateReady() } @@ -52,6 +56,7 @@ if ('serviceWorker' in navigator) { registration.addEventListener('updatefound', () => { trackWorker(registration.installing) }) + void registration.update() }) }) } diff --git a/src/pages/RoomListPage.tsx b/src/pages/RoomListPage.tsx index 1b2c86e..27ab50b 100644 --- a/src/pages/RoomListPage.tsx +++ b/src/pages/RoomListPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react' import { Link } from 'react-router-dom' -import { loadLiveRoomList, subscribeRoomList } from '../lib/api' +import { loadLiveRoomList, reconcileLiveRooms, subscribeRoomList } from '../lib/api' import type { LiveRoomSummary } from '../types' const REFRESH_COOLDOWN_SECONDS = 5 @@ -10,23 +10,20 @@ export function RoomListPage() { const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) const [refreshCooldown, setRefreshCooldown] = useState(0) + const [refreshMessage, setRefreshMessage] = useState('') const [rooms, setRooms] = useState([]) const loadingRef = useRef(false) useEffect(() => { let active = true - const loadRooms = async (options?: { manual?: boolean }) => { + const loadRooms = async () => { if (loadingRef.current) { return } loadingRef.current = true - if (options?.manual) { - setRefreshing(true) - } - try { const nextRooms = await loadLiveRoomList() @@ -47,9 +44,6 @@ export function RoomListPage() { if (active) { setLoading(false) - if (options?.manual) { - setRefreshing(false) - } } } } @@ -83,6 +77,18 @@ export function RoomListPage() { return () => window.clearTimeout(timer) }, [refreshCooldown]) + useEffect(() => { + if (!refreshMessage) { + return + } + + const timer = window.setTimeout(() => { + setRefreshMessage('') + }, 2000) + + return () => window.clearTimeout(timer) + }, [refreshMessage]) + const refreshRoomList = async () => { if (refreshCooldown > 0 || loadingRef.current) { return @@ -93,9 +99,15 @@ export function RoomListPage() { setRefreshCooldown(REFRESH_COOLDOWN_SECONDS) try { + const removedRoomIds = await reconcileLiveRooms() const nextRooms = await loadLiveRoomList() setRooms(nextRooms) setError('') + setRefreshMessage( + removedRoomIds.length > 0 + ? `已清掉 ${removedRoomIds.length} 個無主房間。` + : '已檢查房間列表,沒有需要清理的房間。', + ) } catch (loadError) { setError(loadError instanceof Error ? loadError.message : '載入房間列表失敗。') } finally { @@ -129,6 +141,7 @@ export function RoomListPage() { + {refreshMessage ?

{refreshMessage}

: null} {loading ?

正在載入房間列表...

: null} {!loading && error ?

{error}

: null} {!loading && !error && rooms.length === 0 ? ( diff --git a/src/pages/RoomSpectatorPage.tsx b/src/pages/RoomSpectatorPage.tsx index 29f8c95..45833c2 100644 --- a/src/pages/RoomSpectatorPage.tsx +++ b/src/pages/RoomSpectatorPage.tsx @@ -86,10 +86,13 @@ export function RoomSpectatorPage({ onConfirmFinished }: RoomSpectatorPageProps) return } - if (payload.status === 'released') { + if (payload.status === 'released' || payload.status === 'stale') { setRoomClosedDialog({ title: '房間已關閉', - message: '房主已重整頁面、離開記分板或重新選隊伍,本房間已結束觀戰。', + message: + payload.status === 'stale' + ? '這個房間已經沒有主控在線上,系統已自動清理並結束觀戰。' + : '房主已重整頁面、離開記分板或重新選隊伍,本房間已結束觀戰。', }) setLoading(false) }