diff --git a/.gitignore b/.gitignore index eadc0df..feaa671 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ vite-dev*.log server-local*.log .local-storage/ .claude/ +frontend/public/data/* +!frontend/public/data/ +!frontend/public/data/README.md diff --git a/frontend/public/data/README.md b/frontend/public/data/README.md new file mode 100644 index 0000000..ba13e33 --- /dev/null +++ b/frontend/public/data/README.md @@ -0,0 +1,33 @@ +# Static Public Data + +The frontend tries these JSON snapshots before falling back to the live API: + +- `/data/leaderboard-teams.json` +- `/data/leaderboard-players.json` +- `/data/home-teams.json` +- `/data/recent-games.json` +- `/data/teams/{key}.json` +- `/data/teams/{key}.games.json` +- `/data/players/{key}.json` +- `/data/games/{key}.json` +- `/data/games/{key}.logs.json` + +`{key}` is `encodeURIComponent(value).replace(/%/g, '~')`. + +Snapshot files should keep the same response shape as the matching `/api/tss/*` +endpoint. Missing files are fine: the app remembers the miss for the current +browser session and uses the existing API route instead. + +Do not generate every possible entity forever. Keep `/data` bounded: + +- Always generate the small global snapshots: leaderboards, home teams, recent + games. +- Generate team/player/game detail snapshots only for hot items, such as current + leaderboard entries, recent games, recently viewed pages, or pinned items. +- Prune detail snapshots by age and access. For example: keep recent games for + 7-30 days, keep leaderboard teams/players while they remain ranked, and delete + cold files that have not been refreshed recently. +- Write snapshots atomically (`file.tmp` then rename) so readers never see a + partial JSON file. +- Serve compressed responses from the web server/CDN when possible. Keep source + JSON minified unless humans need to inspect a fixture. diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1917111..64e4f53 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -27,6 +27,7 @@ const apiEndpoints = { detail: (name) => `/api/tss/teams/${encodeURIComponent(name)}`, games: (name) => `/api/tss/teams/${encodeURIComponent(name)}/games`, player: (uid) => `/api/tss/player/${encodeURIComponent(uid)}`, + searchPlayers: (name) => `/api/tss/players/search?q=${encodeURIComponent(name)}&limit=10`, } const navItems = [ @@ -46,9 +47,12 @@ const analyticsConsentVersion = 3 const themePreferenceKey = 'tssbot.theme' const themePreferenceCookie = 'tssbot_theme' const liveRefreshMs = 15000 -const siteVersion = 'v1' +const siteVersion = '1.0.1' const turnstileSiteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY || '' +const staticDataBase = (import.meta.env.VITE_STATIC_DATA_BASE || '/data').replace(/\/+$/, '') +const staticDataEnabled = String(import.meta.env.VITE_STATIC_DATA || 'true').toLowerCase() !== 'false' +const missingStaticDataPaths = new Set() const defaultAnalyticsPreferences = { chosen: false, @@ -61,6 +65,30 @@ const defaultAnalyticsPreferences = { version: analyticsConsentVersion, } +function staticDataPath(...segments) { + return [staticDataBase, ...segments].join('/') +} + +function staticDataKey(value) { + return encodeURIComponent(String(value || '').trim()).replace(/%/g, '~') +} + +function dataSource(apiPath, staticPath = null) { + return { apiPath, staticPath } +} + +const publicDataSources = { + teams: dataSource(apiEndpoints.teams, staticDataPath('leaderboard-teams.json')), + players: dataSource(apiEndpoints.players, staticDataPath('leaderboard-players.json')), + homeTeams: dataSource(apiEndpoints.homeTeams, staticDataPath('home-teams.json')), + recentGames: dataSource(apiEndpoints.recentGames, staticDataPath('recent-games.json')), + detail: (name) => dataSource(apiEndpoints.detail(name), staticDataPath('teams', `${staticDataKey(name)}.json`)), + games: (name) => dataSource(apiEndpoints.games(name), staticDataPath('teams', `${staticDataKey(name)}.games.json`)), + player: (uid) => dataSource(apiEndpoints.player(uid), staticDataPath('players', `${staticDataKey(uid)}.json`)), + game: (gameId) => dataSource(apiEndpoints.game(gameId), staticDataPath('games', `${staticDataKey(gameId)}.json`)), + gameLogs: (gameId) => dataSource(apiEndpoints.gameLogs(gameId), staticDataPath('games', `${staticDataKey(gameId)}.logs.json`)), +} + async function fetchJson(path, signal) { const response = await fetch(path, { signal, @@ -69,12 +97,29 @@ async function fetchJson(path, signal) { const body = await response.json().catch(() => null) if (!response.ok) { - throw new Error(body?.error || `Request failed with ${response.status}`) + const error = new Error(body?.error || `Request failed with ${response.status}`) + error.status = response.status + error.path = path + throw error } return body } +async function fetchPublicJson(source, signal) { + if (!source?.staticPath || !staticDataEnabled || missingStaticDataPaths.has(source.staticPath)) { + return fetchJson(source.apiPath, signal) + } + + try { + return await fetchJson(source.staticPath, signal) + } catch (error) { + if (signal?.aborted || error?.name === 'AbortError') throw error + if (error?.status === 404) missingStaticDataPaths.add(source.staticPath) + return fetchJson(source.apiPath, signal) + } +} + function parseRoute(pathname = window.location.pathname) { if (pathname === '/') return { page: 'home', teamName: '' } if (pathname === '/teams') return { page: 'teams', teamName: '' } @@ -170,9 +215,9 @@ function ParticipantNames({ participants, spread = false }) { if (spread && participants.length === 2) { const [first, second] = participants return ( -
+
{first.name} @@ -180,7 +225,7 @@ function ParticipantNames({ participants, spread = false }) { vs {second.name} @@ -206,6 +251,46 @@ function bestTeamName(team) { return team?.name || '' } +function searchKey(value) { + return String(value || '').trim().toLocaleLowerCase() +} + +function teamSearchResult(team) { + const name = bestTeamName(team) + return { + kind: 'team', + name, + value: name, + detail: 'Team', + aliases: [team?.name].filter(Boolean), + } +} + +function playerSearchResult(player) { + const uid = String(player?.uid || '').trim() + const nick = String(player?.nick || '').trim() + return { + kind: 'player', + name: nick || (uid ? `Player ${uid}` : ''), + value: nick || uid, + detail: uid ? `Player ${uid}` : 'Player', + uid, + aliases: [nick, uid].filter(Boolean), + } +} + +function dedupeSearchResults(results) { + const seen = new Set() + return results.filter((result) => { + const key = result.kind === 'player' && result.uid + ? `player:${result.uid}` + : `${result.kind}:${searchKey(result.name)}` + if (!result.name || seen.has(key)) return false + seen.add(key) + return true + }) +} + function teamDetailLooksReal(detail) { if (!detail || typeof detail !== 'object') return false @@ -664,7 +749,7 @@ function applySeo(route, profileDetail = null) { } async function fetchRecentTssGames(signal) { - return fetchJson(apiEndpoints.recentGames, signal) + return fetchPublicJson(publicDataSources.recentGames, signal) } function Stat({ label, value }) { @@ -1186,35 +1271,28 @@ function AppContent() { const controller = new AbortController() const timer = window.setTimeout(() => { setSearchHint({ status: 'loading', name: '' }) - fetchJson(apiEndpoints.searchTeams(query), controller.signal) - .then((data) => { - const results = (data.teams || data.results || []) - .map((team) => ({ - name: bestTeamName(team), - detail: '', - aliases: [team.name].filter(Boolean), - })) - .filter((team) => team.name) - .slice(0, 10) - setTeamSearchResults(results) - setSearchHint(results.length ? { status: 'ready', name: results[0].name } : { status: 'error', name: '' }) - }) - .catch(() => { - if (!controller.signal.aborted) { - fetchJson(apiEndpoints.resolve(query), controller.signal) - .then((data) => { - const name = data.name || '' - setTeamSearchResults(name ? [{ name, detail: '', aliases: [name] }] : []) - setSearchHint(name ? { status: 'ready', name } : { status: 'error', name: '' }) - }) - .catch(() => { - if (!controller.signal.aborted) { - setTeamSearchResults([]) - setSearchHint({ status: 'error', name: '' }) - } - }) - } - }) + Promise.allSettled([ + fetchJson(apiEndpoints.searchTeams(query), controller.signal), + fetchJson(apiEndpoints.searchPlayers(query), controller.signal), + ]).then(([teamResult, playerResult]) => { + if (controller.signal.aborted) return + + const teamResults = teamResult.status === 'fulfilled' + ? (teamResult.value.teams || teamResult.value.results || []).map(teamSearchResult) + : [] + const playerResults = playerResult.status === 'fulfilled' + ? (playerResult.value.players || []).map(playerSearchResult) + : [] + const results = dedupeSearchResults([...teamResults, ...playerResults]).slice(0, 10) + + setTeamSearchResults(results) + setSearchHint(results.length ? { status: 'ready', name: results[0].name } : { status: 'error', name: '' }) + }).catch(() => { + if (!controller.signal.aborted) { + setTeamSearchResults([]) + setSearchHint({ status: 'error', name: '' }) + } + }) }, 350) return () => { @@ -1230,7 +1308,7 @@ function AppContent() { const controller = new AbortController() setLeaderboard({ status: 'loading', data: null, error: null }) - fetchJson(apiEndpoints.teams, controller.signal) + fetchPublicJson(publicDataSources.teams, controller.signal) .then((data) => setLeaderboard({ status: 'ready', data, error: null })) .catch((error) => { if (!controller.signal.aborted) { @@ -1248,7 +1326,7 @@ function AppContent() { const controller = new AbortController() setPlayerLeaderboard({ status: 'loading', data: null, error: null }) - fetchJson(apiEndpoints.players, controller.signal) + fetchPublicJson(publicDataSources.players, controller.signal) .then((data) => setPlayerLeaderboard({ status: 'ready', data, error: null })) .catch((error) => { if (!controller.signal.aborted) { @@ -1266,7 +1344,7 @@ function AppContent() { const controller = new AbortController() setHomeTeams({ status: 'loading', data: null, error: null }) - fetchJson(apiEndpoints.homeTeams, controller.signal) + fetchPublicJson(publicDataSources.homeTeams, controller.signal) .then((data) => setHomeTeams({ status: 'ready', data, error: null })) .catch((error) => { if (!controller.signal.aborted) { @@ -1282,7 +1360,7 @@ function AppContent() { const controller = new AbortController() const timer = window.setInterval(() => { - fetchJson(apiEndpoints.teams, controller.signal) + fetchPublicJson(publicDataSources.teams, controller.signal) .then((data) => setLeaderboard({ status: 'ready', data, error: null })) .catch((error) => { if (!controller.signal.aborted) { @@ -1351,8 +1429,8 @@ function AppContent() { }) Promise.allSettled([ - fetchJson(apiEndpoints.detail(route.teamName), controller.signal), - fetchJson(apiEndpoints.games(route.teamName), controller.signal), + fetchPublicJson(publicDataSources.detail(route.teamName), controller.signal), + fetchPublicJson(publicDataSources.games(route.teamName), controller.signal), ]) .then(([detailResult, gamesResult]) => { if (controller.signal.aborted) return @@ -1523,8 +1601,8 @@ function AppContent() { const controller = new AbortController() const timer = window.setInterval(() => { Promise.allSettled([ - fetchJson(apiEndpoints.detail(route.teamName), controller.signal), - fetchJson(apiEndpoints.games(route.teamName), controller.signal), + fetchPublicJson(publicDataSources.detail(route.teamName), controller.signal), + fetchPublicJson(publicDataSources.games(route.teamName), controller.signal), ]).then(([detailResult, gamesResult]) => { if (controller.signal.aborted) return @@ -1550,30 +1628,26 @@ function AppContent() { const topTeamName = bestTeamName(teams[0]) const localTeamSuggestions = useMemo(() => { - const query = teamQuery.trim().toLowerCase() - const seen = new Set() + const query = searchKey(teamQuery) - return teams - .map((team) => { - const name = bestTeamName(team) - const aliases = [team.name].filter(Boolean) - return { name, detail: '', aliases } - }) + return dedupeSearchResults(teams.map(teamSearchResult)) .filter(({ name, aliases }) => { - if (!name || seen.has(name)) return false - seen.add(name) if (!query) return true - return aliases.some((alias) => String(alias).toLowerCase().includes(query)) + return aliases.some((alias) => searchKey(alias).includes(query)) || searchKey(name).includes(query) }) .slice(0, 10) }, [teamQuery, teams]) const teamSuggestions = teamSearchResults.length ? teamSearchResults : localTeamSuggestions + const selectedSearchResult = teamSuggestions.find((result) => { + const query = searchKey(teamQuery) + return searchKey(result.value) === query || result.aliases.some((alias) => searchKey(alias) === query) + }) const searchPlaceholder = searchHint.status === 'ready' ? `Found ${searchHint.name}` : searchHint.status === 'error' - ? 'Team not found' - : topTeamName || 'Search teams' + ? 'No team or player found' + : topTeamName ? `${topTeamName}, or search players` : 'Search teams or players' async function handleTeamSearch(event) { event.preventDefault() @@ -1581,17 +1655,34 @@ function AppContent() { if (!name) return try { + if (selectedSearchResult?.kind === 'player' && selectedSearchResult.uid) { + navigate(playerPath(selectedSearchResult.uid)) + setTeamQuery('') + return + } + const resolved = await fetchJson(apiEndpoints.resolve(name)) const resolvedName = resolved.name if (!resolvedName) throw new Error('Team not found') - const detail = await fetchJson(apiEndpoints.detail(resolvedName)) + const detail = await fetchPublicJson(publicDataSources.detail(resolvedName)) if (!teamDetailLooksReal(detail)) throw new Error('Team not found') navigate(teamPath(canonicalTeamName(detail, resolvedName))) setTeamQuery('') - } catch { - setSearchHint({ status: 'error', name: '' }) + } catch (teamError) { + try { + const players = await fetchJson(apiEndpoints.searchPlayers(name)) + const player = (players.players || []).find((candidate) => ( + searchKey(candidate.nick) === searchKey(name) || searchKey(candidate.uid) === searchKey(name) + )) || players.players?.[0] + + if (!player?.uid) throw teamError + navigate(playerPath(player.uid)) + setTeamQuery('') + } catch { + setSearchHint({ status: 'error', name: '' }) + } } } @@ -1665,7 +1756,7 @@ function AppContent() { const controller = new AbortController() const timer = window.setInterval(() => { - fetchJson(apiEndpoints.players, controller.signal) + fetchPublicJson(publicDataSources.players, controller.signal) .then((data) => setPlayerLeaderboard({ status: 'ready', data, error: null })) .catch((error) => { if (!controller.signal.aborted) { @@ -2130,7 +2221,7 @@ function PlayerPage({ uid, navigate }) { } let cancelled = false setState({ status: 'loading', data: null, error: '' }) - fetchJson(apiEndpoints.player(uid)) + fetchPublicJson(publicDataSources.player(uid)) .then((data) => { if (!cancelled) setState({ status: 'ready', data, error: '' }) }) @@ -2289,15 +2380,19 @@ function Landing({ onChange={(event) => setTeamQuery(event.target.value)} /> - {teamSuggestions.map((team) => ( -
@@ -3057,7 +3152,7 @@ function GamePage({ gameId, navigate }) { setGameState({ status: 'loading', data: null, error: null }) setLogs({ chat_log: [], battle_log: [], event_log: { kills: [], damage: [], chat: [] } }) - fetchJson(apiEndpoints.game(gameId), controller.signal) + fetchPublicJson(publicDataSources.game(gameId), controller.signal) .then((data) => { if (!controller.signal.aborted) { setGameState({ status: 'ready', data, error: null }) @@ -3069,7 +3164,7 @@ function GamePage({ gameId, navigate }) { } }) - fetchJson(apiEndpoints.gameLogs(gameId), controller.signal) + fetchPublicJson(publicDataSources.gameLogs(gameId), controller.signal) .then((data) => { if (!controller.signal.aborted) { setLogs({ @@ -3431,7 +3526,7 @@ function BattleLogsPage({ live, matches, navigate }) {
{matches.map((match) => (