add stuff for tournaments

This commit is contained in:
FURRO404
2026-06-20 21:12:39 -07:00
parent eca52ca078
commit 1eb0f1ffc8
3 changed files with 838 additions and 6 deletions
+509 -4
View File
@@ -17,6 +17,8 @@ const apiEndpoints = {
viewerDelete: '/api/viewers/delete',
teams: '/api/tss/leaderboard/teams?limit=100',
players: '/api/tss/leaderboard/players?limit=100',
tournaments: '/api/tss/tournaments',
tournament: (id) => `/api/tss/tournaments/${encodeURIComponent(id)}`,
homeTeams: '/api/tss/leaderboard/teams?limit=4',
teamsHealth: '/api/tss/leaderboard/teams?limit=1',
recentGames: '/api/tss/games/recent?limit=50',
@@ -35,6 +37,7 @@ const navItems = [
{ path: '/teams', label: 'Team Leaderboard' },
{ path: '/players', label: 'Player Leaderboard' },
{ path: '/battle-logs', label: 'Battle Logs' },
{ path: '/tournaments', label: 'Tournaments' },
{ path: '/viewers', label: 'Viewers' },
{ path: '/docs', label: 'Setup' },
]
@@ -180,6 +183,11 @@ function parseRoute(pathname = window.location.pathname) {
if (pathname === '/viewers') return { page: 'viewers', teamName: '' }
if (pathname === '/privacy') return { page: 'privacy', teamName: '' }
if (pathname === '/docs') return { page: 'docs', teamName: '' }
if (pathname === '/tournaments') return { page: 'tournaments-list', teamName: '' }
if (pathname.startsWith('/tournaments/')) {
const tournamentId = decodeURIComponent(pathname.slice('/tournaments/'.length))
return { page: 'tournament', teamName: '', tournamentId }
}
if (pathname.startsWith('/players/')) {
const uid = decodeURIComponent(pathname.slice('/players/'.length))
return { page: 'player', teamName: '', uid }
@@ -208,6 +216,10 @@ function playerPath(uid) {
return `/players/${encodeURIComponent(uid)}`
}
function tournamentPath(tournamentId) {
return `/tournaments/${encodeURIComponent(tournamentId)}`
}
function formatNumber(value) {
return numberFormat.format(Number(value || 0))
}
@@ -601,6 +613,8 @@ function routeLabel(route) {
if (route.page === 'players') return 'Player Leaderboard'
if (route.page === 'battle-logs') return 'Battle Logs'
if (route.page === 'game') return route.gameId ? `Game ${route.gameId}` : 'Game'
if (route.page === 'tournaments-list') return 'Tournaments'
if (route.page === 'tournament') return route.tournamentId ? `Tournament ${route.tournamentId}` : 'Tournament'
if (route.page === 'uptime') return 'Uptime'
if (route.page === 'viewers') return 'viewers'
if (route.page === 'privacy') return 'Privacy notice'
@@ -619,6 +633,8 @@ function canonicalPathForRoute(route) {
if (route.page === 'players') return '/players'
if (route.page === 'battle-logs') return '/battle-logs'
if (route.page === 'game' && route.gameId) return gamePath(route.gameId)
if (route.page === 'tournaments-list') return '/tournaments'
if (route.page === 'tournament' && route.tournamentId) return tournamentPath(route.tournamentId)
if (route.page === 'uptime') return '/uptime'
if (route.page === 'viewers') return '/viewers'
if (route.page === 'privacy') return '/privacy'
@@ -669,6 +685,15 @@ function seoForRoute(route, profileDetail = null) {
}
}
if (route.page === 'tournament' && route.tournamentId) {
return {
title: `Tournament ${route.tournamentId} | Toothless' TSS Bot`,
description: `TSS tournament ${route.tournamentId} bracket, matches, standings, and games tracked by Toothless' TSS Bot.`,
robots: 'index, follow',
path: canonicalPathForRoute(route),
}
}
const byPage = {
teams: {
title: "TSS Team Leaderboard | Toothless' TSS Bot",
@@ -688,6 +713,12 @@ function seoForRoute(route, profileDetail = null) {
robots: 'index, follow',
path: '/battle-logs',
},
'tournaments-list': {
title: "TSS Tournaments | Toothless' TSS Bot",
description: 'Browse tracked TSS tournaments with authoritative brackets, standings, and linked replay availability.',
robots: 'index, follow',
path: '/tournaments',
},
uptime: {
title: "TSS Bot Uptime Status | Toothless' TSS Bot",
description: 'Check Toothless TSS Bot uptime, API health, TSS data proxy status, and recent availability history.',
@@ -1789,6 +1820,8 @@ function AppContent() {
? '/players'
: route.page === 'battle-logs' || route.page === 'game'
? '/battle-logs'
: route.page === 'tournaments-list' || route.page === 'tournament'
? '/tournaments'
: route.page === 'viewers'
? '/viewers'
: window.location.pathname
@@ -1923,6 +1956,8 @@ function AppContent() {
{route.page === 'docs' ? <DocsPage /> : null}
{route.page === 'player' ? <PlayerPage uid={route.uid} navigate={navigate} /> : null}
{route.page === 'game' ? <GamePage gameId={route.gameId} navigate={navigate} /> : null}
{route.page === 'tournaments-list' ? <TournamentsPage navigate={navigate} /> : null}
{route.page === 'tournament' ? <TournamentDetailPage tournamentId={route.tournamentId} navigate={navigate} /> : null}
</section>
<Footer navigate={navigate} />
<ConsentBanner preferences={analyticsPreferences} onChoose={chooseAnalyticsConsent} />
@@ -3278,9 +3313,19 @@ function GamePage({ gameId, navigate }) {
</button>
<div className="rounded-lg border border-border bg-fury-white p-6 shadow-sm">
<div className="flex flex-wrap items-baseline gap-x-3">
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">Game</p>
<span className="break-all text-xs text-text-muted opacity-70">{game?.session_id || gameId}</span>
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">
Game <span className="break-all text-text-muted opacity-70">{game?.session_id || gameId}</span>
</p>
{game?.tournament_id ? (
<button
className="text-sm font-semibold uppercase tracking-wide text-fury-violet transition hover:text-text"
onClick={() => navigate(tournamentPath(game.tournament_id))}
type="button"
>
Tournament {game.tournament_id}
</button>
) : null}
</div>
<div className="mt-1 flex flex-wrap items-center gap-3">
<h1 className="text-4xl font-bold">{game?.map_name || 'Battle log'}</h1>
@@ -3290,7 +3335,19 @@ function GamePage({ gameId, navigate }) {
</span>
) : null}
</div>
{subtitle ? <p className="mt-1 text-sm font-medium text-fury-violet">{subtitle}</p> : null}
{subtitle ? (
game?.tournament_id ? (
<button
className="mt-1 block text-left text-sm font-medium text-fury-violet underline-offset-2 transition hover:text-text hover:underline"
onClick={() => navigate(tournamentPath(game.tournament_id))}
type="button"
>
{subtitle}
</button>
) : (
<p className="mt-1 text-sm font-medium text-fury-violet">{subtitle}</p>
)
) : null}
<p className="mt-2 text-sm text-text-soft">
{game ? formatDate(game.timestamp) : ''}
{duration ? ` · ${duration}` : ''}
@@ -3621,6 +3678,454 @@ function BattleLogsPage({ live, matches, navigate }) {
)
}
function formatUnixDate(seconds) {
return seconds ? formatDate(seconds) : 'Unknown'
}
function tournamentDateRange(first, last) {
if (!first && !last) return 'No dates'
const a = formatUnixDate(first)
const b = formatUnixDate(last)
return a === b ? a : `${a} ${b}`
}
function tournamentFormatMeta(format, matches = []) {
const raw = String(format || '').toLowerCase()
const sides = matches.map((match) => String(match.side || match.type_bracket || '').toLowerCase())
const hasSide = (needle) => sides.some((side) => side.includes(needle))
const hasGroup = raw === 'group' || hasSide('group')
const hasSwiss = raw === 'swiss' || hasSide('swiss')
const hasLoser = raw === 'double-elim' || hasSide('loser') || hasSide('looser')
const hasElim = hasLoser || raw === 'single-elim' || hasSide('winner') || hasSide('final')
if ((hasGroup || hasSwiss) && hasElim) {
return { label: hasSwiss ? 'Swiss + playoffs' : 'Group stage + playoffs', mode: 'mixed' }
}
if (hasSwiss) return { label: 'Swiss', mode: 'standings' }
if (hasGroup) return { label: 'Group stage', mode: 'standings' }
if (hasLoser) return { label: 'Double elimination', mode: 'bracket' }
if (raw === 'single-elim' || hasElim) return { label: 'Single elimination', mode: 'bracket' }
return { label: 'Matches', mode: 'matches' }
}
function tournamentStatusLabel(status) {
const raw = String(status || '').trim()
if (!raw) return 'Unknown'
return raw.charAt(0).toUpperCase() + raw.slice(1)
}
function sideFromMatch(match) {
const side = String(match?.side || '').toLowerCase()
if (side) return side
const bracket = String(match?.type_bracket || '').toLowerCase()
if (bracket.includes('swiss')) return 'swiss'
if (bracket.includes('group')) return 'group'
if (bracket.includes('looser') || bracket.includes('loser')) return 'loser'
if (bracket.includes('final') || bracket.includes('semifinal')) return 'final'
if (bracket.includes('winner')) return 'winner'
return 'matches'
}
function sideLabel(side) {
const labels = {
winner: 'Winner bracket',
loser: 'Loser bracket',
final: 'Finals',
group: 'Group matches',
swiss: 'Swiss matches',
matches: 'Matches',
}
return labels[side] || tournamentStatusLabel(side)
}
function sidePriority(side) {
return { winner: 0, final: 1, loser: 2, group: 3, swiss: 4, matches: 5 }[side] ?? 6
}
function compareTournamentMatches(a, b) {
const roundA = a.round ?? Number.MAX_SAFE_INTEGER
const roundB = b.round ?? Number.MAX_SAFE_INTEGER
const posA = a.position ?? Number.MAX_SAFE_INTEGER
const posB = b.position ?? Number.MAX_SAFE_INTEGER
return roundA - roundB || posA - posB || String(a.match_id).localeCompare(String(b.match_id))
}
function TournamentsPage({ navigate }) {
const [state, setState] = useState({ status: 'loading', data: null, error: null })
useEffect(() => {
const controller = new AbortController()
setState({ status: 'loading', data: null, error: null })
fetchJson(apiEndpoints.tournaments, controller.signal)
.then((data) => setState({ status: 'ready', data, error: null }))
.catch((error) => {
if (!controller.signal.aborted) {
setState({ status: 'error', data: null, error: error.message })
}
})
return () => controller.abort()
}, [])
const tournaments = state.data?.tournaments || []
return (
<section className="space-y-6 pt-24 sm:pt-28">
<div>
<h1 className="text-3xl font-bold">Tournaments</h1>
<p className="mt-2 text-sm text-text-soft">
{state.status === 'loading'
? 'Loading tournaments'
: state.error || `${tournaments.length} tournaments returned`}
</p>
</div>
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm">
{tournaments.map((tournament) => (
<button
className="grid w-full gap-x-4 gap-y-1 border-b border-surface px-5 py-4 text-left transition hover:bg-surface md:grid-cols-[1fr_repeat(3,auto)] md:items-center"
key={tournament.tournament_id}
onClick={() => navigate(tournamentPath(tournament.tournament_id))}
type="button"
>
<div className="min-w-0">
<p className="truncate text-lg font-semibold">
{tournament.name || `Tournament ${tournament.tournament_id}`}
</p>
<p className="text-xs text-text-soft">
TID {tournament.tournament_id} · {tournamentDateRange(tournament.date_start, tournament.date_end)}
</p>
</div>
<span className="text-sm">{formatNumber(tournament.match_count)} matches</span>
<span className="text-sm">{formatNumber(tournament.team_count)} teams</span>
<span className="text-sm font-semibold text-fury-cyan">View</span>
</button>
))}
{!tournaments.length ? (
<p className="px-5 py-10 text-sm text-text-soft">
{state.status === 'loading' ? 'Loading tournaments' : 'No tournaments returned'}
</p>
) : null}
</div>
</section>
)
}
function groupMatchesBySide(matches) {
const bySide = new Map()
matches.forEach((match) => {
const side = sideFromMatch(match)
if (!bySide.has(side)) bySide.set(side, [])
bySide.get(side).push(match)
})
return [...bySide.entries()]
.map(([raw, sideMatches]) => ({
raw,
label: sideLabel(raw),
isGroup: raw === 'group' || raw === 'swiss',
matches: [...sideMatches].sort(compareTournamentMatches),
}))
.sort((a, b) => sidePriority(a.raw) - sidePriority(b.raw))
}
function roundsForSide(matches) {
const byRound = new Map()
matches.forEach((match) => {
const key = match.round ?? 'matches'
if (!byRound.has(key)) byRound.set(key, [])
byRound.get(key).push(match)
})
return [...byRound.entries()]
.sort(([a], [b]) => {
if (a === 'matches') return 1
if (b === 'matches') return -1
return Number(a) - Number(b)
})
.map(([round, roundMatches]) => ({
round,
matches: [...roundMatches].sort(compareTournamentMatches),
}))
}
function roundLabel(side, round, index, total) {
if (side === 'final' && total === 1) return 'Final'
if (round === 'matches') return 'Matches'
if (side === 'final') return index === total - 1 ? 'Final' : `Round ${Number(round) + 1}`
return `Round ${Number(round) + 1}`
}
function TournamentMatchCard({ match, navigate }) {
const winner = displayTeamName(match.winner_name).toLowerCase()
const teamA = displayTeamName(match.team_a_name)
const teamB = displayTeamName(match.team_b_name)
const aWon = winner && teamA && winner === teamA.toLowerCase()
const bWon = winner && teamB && winner === teamB.toLowerCase()
const battles = Array.isArray(match.battles) ? match.battles : []
const teamRow = (name, score, won) => (
<div className="flex items-center justify-between gap-2">
{name ? (
<button
className={`min-w-0 truncate text-left font-semibold transition hover:underline ${won ? 'text-win' : 'text-text'}`}
onClick={(event) => {
event.stopPropagation()
navigate(teamPath(name))
}}
type="button"
>
{name}
</button>
) : (
<span className="min-w-0 truncate font-semibold text-text-muted">TBD</span>
)}
<span className={`shrink-0 tabular-nums ${won ? 'text-win' : 'text-text-soft'}`}>{formatNumber(score)}</span>
</div>
)
return (
<div className="rounded-md border border-border bg-bg p-2.5 text-sm shadow-sm">
{teamRow(teamA, match.score_a, aWon)}
<div className="mt-1">{teamRow(teamB, match.score_b, bWon)}</div>
<div className="mt-2 flex items-center justify-between gap-2 text-[10px] font-semibold uppercase tracking-wide text-text-muted">
<span>{tournamentStatusLabel(match.status)}</span>
{match.position !== null && match.position !== undefined ? <span>Slot {Number(match.position) + 1}</span> : null}
</div>
{battles.length ? (
<div className="mt-2 flex flex-wrap gap-1">
{battles.map((battle, index) => (
battle.have_replay ? (
<button
className="rounded bg-surface px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-text-soft transition hover:text-text"
key={battle.session_hex}
onClick={() => navigate(gamePath(battle.session_hex))}
type="button"
>
G{index + 1}
</button>
) : (
<span
className="rounded bg-surface px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-text-muted opacity-70"
key={battle.session_hex}
>
G{index + 1}
</span>
)
))}
</div>
) : null}
</div>
)
}
function TournamentBracketSide({ side, navigate }) {
const rounds = roundsForSide(side.matches)
return (
<div>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-fury-cyan">{side.label}</h3>
<div className="overflow-x-auto pb-2">
<div className="flex gap-4">
{rounds.map((round, roundIndex) => (
<div className="flex min-w-[190px] flex-col gap-3" key={round.round}>
<p className="text-xs font-semibold uppercase tracking-wide text-text-muted">
{roundLabel(side.raw, round.round, roundIndex, rounds.length)}
</p>
<div className="flex flex-1 flex-col justify-around gap-3">
{round.matches.map((match) => (
<TournamentMatchCard
key={`${match.type_bracket}-${match.match_id}`}
match={match}
navigate={navigate}
/>
))}
</div>
</div>
))}
</div>
</div>
</div>
)
}
function TournamentStandings({ standings }) {
const rows = Array.isArray(standings) ? standings : []
if (!rows.length) return null
const grouped = new Map()
rows.forEach((row) => {
const group = row.group_index ?? 0
if (!grouped.has(group)) grouped.set(group, [])
grouped.get(group).push(row)
})
return (
<div className="space-y-4">
{[...grouped.entries()].map(([group, groupRows]) => (
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm" key={group}>
{grouped.size > 1 ? (
<p className="border-b border-border px-5 py-3 text-xs font-semibold uppercase tracking-wide text-fury-cyan">
Group {Number(group) + 1}
</p>
) : null}
<div className="grid grid-cols-[3rem_1fr_4rem_3rem_3rem_3rem_5rem] gap-2 border-b border-border px-5 py-3 text-xs font-semibold uppercase tracking-wide text-text-soft">
<p>#</p>
<p>Team</p>
<p className="text-center">Pts</p>
<p className="text-center">W</p>
<p className="text-center">D</p>
<p className="text-center">L</p>
<p className="text-center">Buch.</p>
</div>
{groupRows.map((row, index) => (
<div
className="grid grid-cols-[3rem_1fr_4rem_3rem_3rem_3rem_5rem] items-center gap-2 border-b border-surface px-5 py-2.5 text-sm"
key={`${group}-${row.team_name || index}`}
>
<span className="font-semibold text-fury-cyan">#{row.rank || index + 1}</span>
<span className="min-w-0 truncate font-semibold">{row.team_name || 'Unknown team'}</span>
<span className="text-center">{formatNumber(row.points)}</span>
<span className="text-center">{formatNumber(row.wins)}</span>
<span className="text-center">{formatNumber(row.draws)}</span>
<span className="text-center">{formatNumber(row.losses)}</span>
<span className="text-center">{formatNumber(row.buchholz)}</span>
</div>
))}
</div>
))}
</div>
)
}
function TournamentMatchList({ sides, navigate }) {
return (
<div className="space-y-6">
{sides.map((side) => (
<div key={side.raw || 'matches'}>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-fury-cyan">{side.label}</h3>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{side.matches.map((match) => (
<TournamentMatchCard
key={`${match.type_bracket}-${match.match_id}`}
match={match}
navigate={navigate}
/>
))}
</div>
</div>
))}
</div>
)
}
function TournamentDetailPage({ tournamentId, navigate }) {
const [state, setState] = useState({ status: 'loading', data: null, error: null })
useEffect(() => {
if (!tournamentId) return undefined
const controller = new AbortController()
setState({ status: 'loading', data: null, error: null })
fetchJson(apiEndpoints.tournament(tournamentId), controller.signal)
.then((data) => {
if (!controller.signal.aborted) setState({ status: 'ready', data, error: null })
})
.catch((error) => {
if (!controller.signal.aborted) {
setState({ status: 'error', data: null, error: error.message })
}
})
return () => controller.abort()
}, [tournamentId])
const data = state.data
const matches = useMemo(() => data?.matches || [], [data])
const format = useMemo(() => tournamentFormatMeta(data?.format, matches), [data?.format, matches])
const sides = useMemo(() => groupMatchesBySide(matches), [matches])
const elimSides = sides.filter((side) => !side.isGroup)
const groupSides = sides.filter((side) => side.isGroup)
const standings = data?.standings || []
return (
<section className="space-y-6 pt-24 sm:pt-28">
<button
className="text-sm font-semibold text-fury-cyan transition hover:text-text"
onClick={() => navigate('/tournaments')}
type="button"
>
Back to tournaments
</button>
<div className="rounded-lg border border-border bg-fury-white p-6 shadow-sm">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">
Tournament <span className="text-text-muted opacity-70">{tournamentId}</span>
</p>
<span className="rounded bg-surface px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-fury-violet">
{format.label}
</span>
</div>
<h1 className="mt-1 text-4xl font-bold">
{data?.name || `Tournament ${tournamentId}`}
</h1>
{data ? (
<p className="mt-2 text-sm text-text-soft">
{formatNumber(data.match_count)} matches · {formatNumber(data.team_count)} teams ·{' '}
{tournamentDateRange(data.date_start, data.date_end)}
{data.status ? ` · ${tournamentStatusLabel(data.status)}` : ''}
</p>
) : null}
{state.status === 'error' ? (
<p className="mt-4 text-sm text-danger">{state.error}</p>
) : null}
{state.status === 'loading' ? (
<p className="mt-4 text-sm text-text-soft">Loading tournament</p>
) : null}
</div>
{state.status === 'ready' && !matches.length ? (
<p className="rounded-lg border border-border bg-fury-white px-5 py-10 text-sm text-text-soft shadow-sm">
No games linked to this tournament yet.
</p>
) : null}
{format.mode === 'standings' && matches.length ? (
<>
<TournamentStandings standings={standings} />
<TournamentMatchList sides={sides} navigate={navigate} />
</>
) : null}
{format.mode === 'bracket' && matches.length ? (
<div className="space-y-6">
{elimSides.map((side) => (
<TournamentBracketSide key={side.raw || 'bracket'} side={side} navigate={navigate} />
))}
</div>
) : null}
{format.mode === 'mixed' && matches.length ? (
<div className="space-y-8">
{groupSides.length ? (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Group stage</h2>
<TournamentStandings standings={standings} />
<TournamentMatchList sides={groupSides} navigate={navigate} />
</div>
) : null}
{elimSides.length ? (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Playoffs</h2>
{elimSides.map((side) => (
<TournamentBracketSide key={side.raw || 'bracket'} side={side} navigate={navigate} />
))}
</div>
) : null}
</div>
) : null}
{format.mode === 'matches' && matches.length ? (
<TournamentMatchList sides={sides} navigate={navigate} />
) : null}
</section>
)
}
function relativeSeconds(timestamp) {
if (!timestamp) return 'unknown'
const seconds = Math.max(0, Math.round((Date.now() - new Date(timestamp).getTime()) / 1000))