ai generated solutions to our ai generated problems

This commit is contained in:
Heidi
2026-06-19 23:54:11 +01:00
parent bccd2bf405
commit 07b087e7df
4 changed files with 222 additions and 67 deletions
+3
View File
@@ -12,3 +12,6 @@ vite-dev*.log
server-local*.log server-local*.log
.local-storage/ .local-storage/
.claude/ .claude/
frontend/public/data/*
!frontend/public/data/
!frontend/public/data/README.md
+33
View File
@@ -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.
+153 -58
View File
@@ -27,6 +27,7 @@ const apiEndpoints = {
detail: (name) => `/api/tss/teams/${encodeURIComponent(name)}`, detail: (name) => `/api/tss/teams/${encodeURIComponent(name)}`,
games: (name) => `/api/tss/teams/${encodeURIComponent(name)}/games`, games: (name) => `/api/tss/teams/${encodeURIComponent(name)}/games`,
player: (uid) => `/api/tss/player/${encodeURIComponent(uid)}`, player: (uid) => `/api/tss/player/${encodeURIComponent(uid)}`,
searchPlayers: (name) => `/api/tss/players/search?q=${encodeURIComponent(name)}&limit=10`,
} }
const navItems = [ const navItems = [
@@ -46,9 +47,12 @@ const analyticsConsentVersion = 3
const themePreferenceKey = 'tssbot.theme' const themePreferenceKey = 'tssbot.theme'
const themePreferenceCookie = 'tssbot_theme' const themePreferenceCookie = 'tssbot_theme'
const liveRefreshMs = 15000 const liveRefreshMs = 15000
const siteVersion = 'v1' const siteVersion = '1.0.1'
const turnstileSiteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY || '' 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 = { const defaultAnalyticsPreferences = {
chosen: false, chosen: false,
@@ -61,6 +65,30 @@ const defaultAnalyticsPreferences = {
version: analyticsConsentVersion, 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) { async function fetchJson(path, signal) {
const response = await fetch(path, { const response = await fetch(path, {
signal, signal,
@@ -69,12 +97,29 @@ async function fetchJson(path, signal) {
const body = await response.json().catch(() => null) const body = await response.json().catch(() => null)
if (!response.ok) { 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 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) { function parseRoute(pathname = window.location.pathname) {
if (pathname === '/') return { page: 'home', teamName: '' } if (pathname === '/') return { page: 'home', teamName: '' }
if (pathname === '/teams') return { page: 'teams', teamName: '' } if (pathname === '/teams') return { page: 'teams', teamName: '' }
@@ -170,9 +215,9 @@ function ParticipantNames({ participants, spread = false }) {
if (spread && participants.length === 2) { if (spread && participants.length === 2) {
const [first, second] = participants const [first, second] = participants
return ( return (
<div className="flex min-w-0 items-center gap-x-3"> <div className="mx-auto grid w-full max-w-xl min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-x-4">
<span <span
className={`min-w-0 flex-1 truncate text-left text-sm font-semibold ${first.result === 'win' ? 'text-win' : 'text-loss'}`} className={`min-w-0 truncate text-right text-sm font-semibold ${first.result === 'win' ? 'text-win' : 'text-loss'}`}
> >
{first.name} {first.name}
</span> </span>
@@ -180,7 +225,7 @@ function ParticipantNames({ participants, spread = false }) {
vs vs
</span> </span>
<span <span
className={`min-w-0 flex-1 truncate text-right text-sm font-semibold ${second.result === 'win' ? 'text-win' : 'text-loss'}`} className={`min-w-0 truncate text-left text-sm font-semibold ${second.result === 'win' ? 'text-win' : 'text-loss'}`}
> >
{second.name} {second.name}
</span> </span>
@@ -206,6 +251,46 @@ function bestTeamName(team) {
return team?.name || '' 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) { function teamDetailLooksReal(detail) {
if (!detail || typeof detail !== 'object') return false if (!detail || typeof detail !== 'object') return false
@@ -664,7 +749,7 @@ function applySeo(route, profileDetail = null) {
} }
async function fetchRecentTssGames(signal) { async function fetchRecentTssGames(signal) {
return fetchJson(apiEndpoints.recentGames, signal) return fetchPublicJson(publicDataSources.recentGames, signal)
} }
function Stat({ label, value }) { function Stat({ label, value }) {
@@ -1186,35 +1271,28 @@ function AppContent() {
const controller = new AbortController() const controller = new AbortController()
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
setSearchHint({ status: 'loading', name: '' }) setSearchHint({ status: 'loading', name: '' })
fetchJson(apiEndpoints.searchTeams(query), controller.signal) Promise.allSettled([
.then((data) => { fetchJson(apiEndpoints.searchTeams(query), controller.signal),
const results = (data.teams || data.results || []) fetchJson(apiEndpoints.searchPlayers(query), controller.signal),
.map((team) => ({ ]).then(([teamResult, playerResult]) => {
name: bestTeamName(team), if (controller.signal.aborted) return
detail: '',
aliases: [team.name].filter(Boolean), const teamResults = teamResult.status === 'fulfilled'
})) ? (teamResult.value.teams || teamResult.value.results || []).map(teamSearchResult)
.filter((team) => team.name) : []
.slice(0, 10) const playerResults = playerResult.status === 'fulfilled'
? (playerResult.value.players || []).map(playerSearchResult)
: []
const results = dedupeSearchResults([...teamResults, ...playerResults]).slice(0, 10)
setTeamSearchResults(results) setTeamSearchResults(results)
setSearchHint(results.length ? { status: 'ready', name: results[0].name } : { status: 'error', name: '' }) setSearchHint(results.length ? { status: 'ready', name: results[0].name } : { status: 'error', name: '' })
}) }).catch(() => {
.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) { if (!controller.signal.aborted) {
setTeamSearchResults([]) setTeamSearchResults([])
setSearchHint({ status: 'error', name: '' }) setSearchHint({ status: 'error', name: '' })
} }
}) })
}
})
}, 350) }, 350)
return () => { return () => {
@@ -1230,7 +1308,7 @@ function AppContent() {
const controller = new AbortController() const controller = new AbortController()
setLeaderboard({ status: 'loading', data: null, error: null }) 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 })) .then((data) => setLeaderboard({ status: 'ready', data, error: null }))
.catch((error) => { .catch((error) => {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
@@ -1248,7 +1326,7 @@ function AppContent() {
const controller = new AbortController() const controller = new AbortController()
setPlayerLeaderboard({ status: 'loading', data: null, error: null }) 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 })) .then((data) => setPlayerLeaderboard({ status: 'ready', data, error: null }))
.catch((error) => { .catch((error) => {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
@@ -1266,7 +1344,7 @@ function AppContent() {
const controller = new AbortController() const controller = new AbortController()
setHomeTeams({ status: 'loading', data: null, error: null }) 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 })) .then((data) => setHomeTeams({ status: 'ready', data, error: null }))
.catch((error) => { .catch((error) => {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
@@ -1282,7 +1360,7 @@ function AppContent() {
const controller = new AbortController() const controller = new AbortController()
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
fetchJson(apiEndpoints.teams, controller.signal) fetchPublicJson(publicDataSources.teams, controller.signal)
.then((data) => setLeaderboard({ status: 'ready', data, error: null })) .then((data) => setLeaderboard({ status: 'ready', data, error: null }))
.catch((error) => { .catch((error) => {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
@@ -1351,8 +1429,8 @@ function AppContent() {
}) })
Promise.allSettled([ Promise.allSettled([
fetchJson(apiEndpoints.detail(route.teamName), controller.signal), fetchPublicJson(publicDataSources.detail(route.teamName), controller.signal),
fetchJson(apiEndpoints.games(route.teamName), controller.signal), fetchPublicJson(publicDataSources.games(route.teamName), controller.signal),
]) ])
.then(([detailResult, gamesResult]) => { .then(([detailResult, gamesResult]) => {
if (controller.signal.aborted) return if (controller.signal.aborted) return
@@ -1523,8 +1601,8 @@ function AppContent() {
const controller = new AbortController() const controller = new AbortController()
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
Promise.allSettled([ Promise.allSettled([
fetchJson(apiEndpoints.detail(route.teamName), controller.signal), fetchPublicJson(publicDataSources.detail(route.teamName), controller.signal),
fetchJson(apiEndpoints.games(route.teamName), controller.signal), fetchPublicJson(publicDataSources.games(route.teamName), controller.signal),
]).then(([detailResult, gamesResult]) => { ]).then(([detailResult, gamesResult]) => {
if (controller.signal.aborted) return if (controller.signal.aborted) return
@@ -1550,30 +1628,26 @@ function AppContent() {
const topTeamName = bestTeamName(teams[0]) const topTeamName = bestTeamName(teams[0])
const localTeamSuggestions = useMemo(() => { const localTeamSuggestions = useMemo(() => {
const query = teamQuery.trim().toLowerCase() const query = searchKey(teamQuery)
const seen = new Set()
return teams return dedupeSearchResults(teams.map(teamSearchResult))
.map((team) => {
const name = bestTeamName(team)
const aliases = [team.name].filter(Boolean)
return { name, detail: '', aliases }
})
.filter(({ name, aliases }) => { .filter(({ name, aliases }) => {
if (!name || seen.has(name)) return false
seen.add(name)
if (!query) return true 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) .slice(0, 10)
}, [teamQuery, teams]) }, [teamQuery, teams])
const teamSuggestions = teamSearchResults.length ? teamSearchResults : localTeamSuggestions 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 = const searchPlaceholder =
searchHint.status === 'ready' searchHint.status === 'ready'
? `Found ${searchHint.name}` ? `Found ${searchHint.name}`
: searchHint.status === 'error' : searchHint.status === 'error'
? 'Team not found' ? 'No team or player found'
: topTeamName || 'Search teams' : topTeamName ? `${topTeamName}, or search players` : 'Search teams or players'
async function handleTeamSearch(event) { async function handleTeamSearch(event) {
event.preventDefault() event.preventDefault()
@@ -1581,19 +1655,36 @@ function AppContent() {
if (!name) return if (!name) return
try { try {
if (selectedSearchResult?.kind === 'player' && selectedSearchResult.uid) {
navigate(playerPath(selectedSearchResult.uid))
setTeamQuery('')
return
}
const resolved = await fetchJson(apiEndpoints.resolve(name)) const resolved = await fetchJson(apiEndpoints.resolve(name))
const resolvedName = resolved.name const resolvedName = resolved.name
if (!resolvedName) throw new Error('Team not found') 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') if (!teamDetailLooksReal(detail)) throw new Error('Team not found')
navigate(teamPath(canonicalTeamName(detail, resolvedName))) navigate(teamPath(canonicalTeamName(detail, resolvedName)))
setTeamQuery('') setTeamQuery('')
} 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 { } catch {
setSearchHint({ status: 'error', name: '' }) setSearchHint({ status: 'error', name: '' })
} }
} }
}
function chooseAnalyticsConsent(preferences) { function chooseAnalyticsConsent(preferences) {
const previousVisitorId = storedVisitorId(analyticsVisitorKey) const previousVisitorId = storedVisitorId(analyticsVisitorKey)
@@ -1665,7 +1756,7 @@ function AppContent() {
const controller = new AbortController() const controller = new AbortController()
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
fetchJson(apiEndpoints.players, controller.signal) fetchPublicJson(publicDataSources.players, controller.signal)
.then((data) => setPlayerLeaderboard({ status: 'ready', data, error: null })) .then((data) => setPlayerLeaderboard({ status: 'ready', data, error: null }))
.catch((error) => { .catch((error) => {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
@@ -2130,7 +2221,7 @@ function PlayerPage({ uid, navigate }) {
} }
let cancelled = false let cancelled = false
setState({ status: 'loading', data: null, error: '' }) setState({ status: 'loading', data: null, error: '' })
fetchJson(apiEndpoints.player(uid)) fetchPublicJson(publicDataSources.player(uid))
.then((data) => { .then((data) => {
if (!cancelled) setState({ status: 'ready', data, error: '' }) if (!cancelled) setState({ status: 'ready', data, error: '' })
}) })
@@ -2289,15 +2380,19 @@ function Landing({
onChange={(event) => setTeamQuery(event.target.value)} onChange={(event) => setTeamQuery(event.target.value)}
/> />
<datalist id="team-search-suggestions"> <datalist id="team-search-suggestions">
{teamSuggestions.map((team) => ( {teamSuggestions.map((result) => (
<option key={team.name} label={team.detail} value={team.name} /> <option
key={`${result.kind}-${result.uid || result.name}`}
label={result.detail}
value={result.value}
/>
))} ))}
</datalist> </datalist>
<button <button
className="rounded-md bg-fury-cyan px-5 py-3 text-sm font-semibold text-text transition hover:bg-fury-aqua" className="rounded-md bg-fury-cyan px-5 py-3 text-sm font-semibold text-text transition hover:bg-fury-aqua"
type="submit" type="submit"
> >
Search teams Search
</button> </button>
</form> </form>
</div> </div>
@@ -3057,7 +3152,7 @@ function GamePage({ gameId, navigate }) {
setGameState({ status: 'loading', data: null, error: null }) setGameState({ status: 'loading', data: null, error: null })
setLogs({ chat_log: [], battle_log: [], event_log: { kills: [], damage: [], chat: [] } }) setLogs({ chat_log: [], battle_log: [], event_log: { kills: [], damage: [], chat: [] } })
fetchJson(apiEndpoints.game(gameId), controller.signal) fetchPublicJson(publicDataSources.game(gameId), controller.signal)
.then((data) => { .then((data) => {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
setGameState({ status: 'ready', data, error: null }) 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) => { .then((data) => {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
setLogs({ setLogs({
@@ -3431,7 +3526,7 @@ function BattleLogsPage({ live, matches, navigate }) {
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm"> <div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm">
{matches.map((match) => ( {matches.map((match) => (
<button <button
className="grid w-full gap-x-8 gap-y-2 border-b border-surface px-5 py-4 text-left transition hover:bg-surface md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.7fr)_auto] md:items-center" className="grid w-full gap-x-6 gap-y-2 border-b border-surface px-5 py-4 text-left transition hover:bg-surface md:grid-cols-[minmax(0,1fr)_minmax(18rem,0.9fr)_auto] md:items-center"
key={match.session_id} key={match.session_id}
onClick={() => navigate(gamePath(match.session_id))} onClick={() => navigate(gamePath(match.session_id))}
type="button" type="button"
+24
View File
@@ -20,6 +20,12 @@ function isAllowedApiUrl(req) {
return keys.every((key) => key === 'limit') && Number.isInteger(limit) && limit >= 1 && limit <= 100 return keys.every((key) => key === 'limit') && Number.isInteger(limit) && limit >= 1 && limit <= 100
} }
if (url.pathname === '/api/tss/leaderboard/players') {
const keys = [...params.keys()]
const limit = Number(params.get('limit') || 100)
return keys.every((key) => key === 'limit') && Number.isInteger(limit) && limit >= 1 && limit <= 100
}
if (url.pathname === '/api/tss/games/recent') { if (url.pathname === '/api/tss/games/recent') {
const keys = [...params.keys()] const keys = [...params.keys()]
const limit = Number(params.get('limit') || 50) const limit = Number(params.get('limit') || 50)
@@ -56,6 +62,24 @@ function isAllowedApiUrl(req) {
) )
} }
if (url.pathname === '/api/tss/players/search') {
const keys = [...params.keys()]
const query = params.get('q') || params.get('name') || ''
const limit = Number(params.get('limit') || 25)
return (
keys.every((key) => ['q', 'name', 'limit'].includes(key)) &&
query.length >= 2 &&
query.length <= MAX_TEAM_NAME_LENGTH &&
Number.isInteger(limit) &&
limit >= 1 &&
limit <= 25
)
}
if (/^\/api\/tss\/player\/[0-9]{1,32}$/.test(url.pathname)) {
return [...params.keys()].length === 0
}
if ([...params.keys()].length) return false if ([...params.keys()].length) return false
try { try {