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)}
/>
@@ -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) => (