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
+319 -2
View File
@@ -9,7 +9,7 @@ use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::{
collections::{BTreeMap, HashMap},
collections::{BTreeMap, HashMap, HashSet},
env, fs,
net::{IpAddr, Ipv4Addr, SocketAddr},
path::{Path as FsPath, PathBuf},
@@ -28,6 +28,7 @@ const LEADERBOARD_CACHE_TTL: Duration = Duration::from_secs(300);
struct AppState {
battles_db: PathBuf,
teams_db: PathBuf,
tournaments_db: PathBuf,
leaderboard_cache: Mutex<Option<CachedLeaderboard>>,
vehicle_names: HashMap<String, HashMap<String, String>>,
vehicle_icons: HashMap<String, String>,
@@ -239,6 +240,72 @@ struct GameResponse {
participants: Vec<GameParticipant>,
}
#[derive(Serialize)]
struct TournamentsResponse {
tournaments: Vec<TournamentSummary>,
}
#[derive(Serialize)]
struct TournamentSummary {
tournament_id: i64,
name: Option<String>,
format: Option<String>,
status: Option<String>,
match_count: i64,
team_count: i64,
date_start: Option<i64>,
date_end: Option<i64>,
}
#[derive(Serialize)]
struct TournamentDetailResponse {
tournament_id: i64,
name: Option<String>,
format: Option<String>,
status: Option<String>,
match_count: i64,
team_count: i64,
date_start: Option<i64>,
date_end: Option<i64>,
matches: Vec<TournamentMatchRow>,
standings: Vec<TournamentStandingRow>,
}
#[derive(Serialize)]
struct TournamentMatchRow {
match_id: String,
type_bracket: String,
side: Option<String>,
round: Option<i64>,
position: Option<i64>,
team_a_name: Option<String>,
team_b_name: Option<String>,
winner_name: Option<String>,
score_a: i64,
score_b: i64,
status: String,
battles: Vec<TournamentBattleRow>,
}
#[derive(Serialize)]
struct TournamentBattleRow {
session_hex: String,
position: Option<i64>,
have_replay: bool,
}
#[derive(Serialize)]
struct TournamentStandingRow {
group_index: i64,
team_name: Option<String>,
points: i64,
wins: i64,
draws: i64,
losses: i64,
buchholz: f64,
rank: Option<i64>,
}
#[derive(Serialize)]
struct GameLogsResponse {
chat_log: Vec<String>,
@@ -260,6 +327,8 @@ struct GameRow {
winning_team: Option<String>,
losing_team: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tournament_id: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
tournament_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
duration: Option<f64>,
@@ -371,6 +440,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let state = Arc::new(AppState {
battles_db: resolve_db_path("TSS_BATTLES_DB", "tss_battles.db"),
teams_db: resolve_db_path("TSS_TEAMS_DB", "tss_teams.db"),
tournaments_db: resolve_db_path("TSS_TOURNAMENTS_DB", "tss_tournaments.db"),
leaderboard_cache: Mutex::new(None),
vehicle_names: load_vehicle_names(&resolve_db_path(
"VEHICLE_TRANSLATIONS_JSON",
@@ -393,6 +463,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.route("/api/tss/leaderboard/teams", get(leaderboard))
.route("/api/tss/leaderboard/players", get(player_leaderboard))
.route("/api/tss/games/recent", get(recent_games))
.route("/api/tss/tournaments", get(tournaments))
.route("/api/tss/tournaments/{tournament_id}", get(tournament_detail))
.route("/api/tss/games/{session_id}", get(game_detail))
.route("/api/tss/games/{session_id}/logs", get(game_logs))
.route("/api/tss/teams/resolve", get(resolve_team))
@@ -597,6 +669,38 @@ async fn recent_games(
Ok(Json(RecentGamesResponse { matches }))
}
async fn tournaments(State(state): State<Arc<AppState>>) -> ApiResult<TournamentsResponse> {
let conn = open_db(&state.tournaments_db)?;
let tournaments = tournaments_list(&conn)?;
Ok(Json(TournamentsResponse { tournaments }))
}
async fn tournament_detail(
State(state): State<Arc<AppState>>,
Path(tournament_id): Path<String>,
) -> ApiResult<TournamentDetailResponse> {
let tid = validate_tournament_id(&tournament_id)?;
let conn = open_db(&state.tournaments_db)?;
let summary = tournament_summary_for(&conn, tid)?
.ok_or_else(|| ApiError::not_found("Tournament not found"))?;
let standings = tournament_standings_for(&conn, tid)?;
let mut matches = tournament_match_rows_for(&conn, tid)?;
attach_battles(&conn, &state.battles_db, tid, &mut matches)?;
Ok(Json(TournamentDetailResponse {
tournament_id: summary.tournament_id,
name: summary.name,
format: summary.format,
status: summary.status,
match_count: summary.match_count,
team_count: summary.team_count,
date_start: summary.date_start,
date_end: summary.date_end,
matches,
standings,
}))
}
async fn game_detail(
State(state): State<Arc<AppState>>,
Path(session_id): Path<String>,
@@ -1449,6 +1553,7 @@ fn games_for(conn: &Connection, team_name: &str) -> Result<Vec<GameRow>, ApiErro
player_count: row.get(8)?,
winning_team: row.get(18)?,
losing_team: row.get(19)?,
tournament_id: None,
tournament_name: row.get(4)?,
duration: row.get(5)?,
draw: draw_int != 0,
@@ -1541,6 +1646,7 @@ fn recent_games_for(conn: &Connection, limit: i64) -> Result<Vec<GameRow>, ApiEr
player_count: row.get(7)?,
winning_team: row.get(8)?,
losing_team: row.get(9)?,
tournament_id: None,
tournament_name: row.get(4)?,
duration: row.get(5)?,
draw: draw_int != 0,
@@ -1564,6 +1670,207 @@ fn recent_games_for(conn: &Connection, limit: i64) -> Result<Vec<GameRow>, ApiEr
Ok(rows)
}
fn tournaments_list(conn: &Connection) -> Result<Vec<TournamentSummary>, ApiError> {
let mut stmt = conn
.prepare(
"SELECT tournament_id, name, format, status, match_count, team_count,
date_start, date_end
FROM tournaments
ORDER BY COALESCE(date_end, scanned_unix, 0) DESC, tournament_id DESC
LIMIT 500",
)
.map_err(db_error)?;
let rows = stmt
.query_map([], read_tournament_summary)
.map_err(db_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_error)?;
Ok(rows)
}
fn tournament_summary_for(
conn: &Connection,
tid: i64,
) -> Result<Option<TournamentSummary>, ApiError> {
conn.query_row(
"SELECT tournament_id, name, format, status, match_count, team_count,
date_start, date_end
FROM tournaments WHERE tournament_id = ?1",
params![tid],
read_tournament_summary,
)
.optional()
.map_err(db_error)
}
fn read_tournament_summary(row: &rusqlite::Row<'_>) -> rusqlite::Result<TournamentSummary> {
Ok(TournamentSummary {
tournament_id: row.get(0)?,
name: row.get(1)?,
format: row.get(2)?,
status: row.get(3)?,
match_count: row.get(4)?,
team_count: row.get(5)?,
date_start: row.get(6)?,
date_end: row.get(7)?,
})
}
fn tournament_match_rows_for(
conn: &Connection,
tid: i64,
) -> Result<Vec<TournamentMatchRow>, ApiError> {
// Order: winner side, then final, then loser; group/swiss after. Within a
// side, by round then position (the schedule's round/matchNumber). NULLs last.
let mut stmt = conn
.prepare(
"SELECT match_id, type_bracket, side, round, position,
team_a_name, team_b_name, winner_name, score_a, score_b, status
FROM tournament_matches
WHERE tournament_id = ?1
ORDER BY
CASE side WHEN 'winner' THEN 0 WHEN 'final' THEN 1 WHEN 'loser' THEN 2
WHEN 'group' THEN 3 WHEN 'swiss' THEN 4 ELSE 5 END,
round IS NULL, round,
position IS NULL, position,
time_start, match_id",
)
.map_err(db_error)?;
let rows = stmt
.query_map(params![tid], |row| {
Ok(TournamentMatchRow {
match_id: row.get(0)?,
type_bracket: row.get(1)?,
side: row.get(2)?,
round: row.get(3)?,
position: row.get(4)?,
team_a_name: row.get(5)?,
team_b_name: row.get(6)?,
winner_name: row.get(7)?,
score_a: row.get(8)?,
score_b: row.get(9)?,
status: row.get(10)?,
battles: Vec::new(),
})
})
.map_err(db_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_error)?;
Ok(rows)
}
fn tournament_standings_for(
conn: &Connection,
tid: i64,
) -> Result<Vec<TournamentStandingRow>, ApiError> {
let mut stmt = conn
.prepare(
"SELECT group_index, team_name, points, wins, draws, losses, buchholz, rank
FROM tournament_standings
WHERE tournament_id = ?1
ORDER BY group_index, rank IS NULL, rank, points DESC",
)
.map_err(db_error)?;
let rows = stmt
.query_map(params![tid], |row| {
Ok(TournamentStandingRow {
group_index: row.get(0)?,
team_name: row.get(1)?,
points: row.get(2)?,
wins: row.get(3)?,
draws: row.get(4)?,
losses: row.get(5)?,
buchholz: row.get(6)?,
rank: row.get(7)?,
})
})
.map_err(db_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_error)?;
Ok(rows)
}
// Attach each match's battles and mark which ones we actually hold a replay for
// (session_hex present in match_summary over in the battles DB).
fn attach_battles(
conn: &Connection,
battles_db: &FsPath,
tid: i64,
matches: &mut [TournamentMatchRow],
) -> Result<(), ApiError> {
let mut index: HashMap<(String, String), usize> = HashMap::new();
for (i, m) in matches.iter().enumerate() {
index.insert((m.match_id.clone(), m.type_bracket.clone()), i);
}
let mut stmt = conn
.prepare(
"SELECT match_id, type_bracket, session_hex, position
FROM tournament_battles
WHERE tournament_id = ?1
ORDER BY match_id, type_bracket, position IS NULL, position",
)
.map_err(db_error)?;
let battles = stmt
.query_map(params![tid], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Option<i64>>(3)?,
))
})
.map_err(db_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_error)?;
let hexes: Vec<String> = battles.iter().map(|(_, _, h, _)| h.clone()).collect();
let held = held_session_ids(battles_db, &hexes)?;
for (match_id, type_bracket, session_hex, position) in battles {
if let Some(&idx) = index.get(&(match_id, type_bracket)) {
let have_replay = held.contains(&session_hex);
matches[idx].battles.push(TournamentBattleRow {
session_hex,
position,
have_replay,
});
}
}
Ok(())
}
fn held_session_ids(
battles_db: &FsPath,
hexes: &[String],
) -> Result<HashSet<String>, ApiError> {
let mut held = HashSet::new();
if hexes.is_empty() {
return Ok(held);
}
let conn = open_db(battles_db)?;
for chunk in hexes.chunks(400) {
let placeholders = std::iter::repeat("?")
.take(chunk.len())
.collect::<Vec<_>>()
.join(",");
let sql = format!(
"SELECT session_id FROM match_summary WHERE session_id IN ({placeholders})"
);
let mut stmt = conn.prepare(&sql).map_err(db_error)?;
let rows = stmt
.query_map(rusqlite::params_from_iter(chunk.iter()), |row| {
row.get::<_, String>(0)
})
.map_err(db_error)?;
for r in rows {
held.insert(r.map_err(db_error)?);
}
}
Ok(held)
}
fn game_for(conn: &Connection, session_id: &str) -> Result<Option<GameRow>, ApiError> {
// player_games_hist stores one row per used vehicle per player, with the
// per-player stats duplicated across those rows. Reduce to one row per UID
@@ -1604,7 +1911,8 @@ fn game_for(conn: &Connection, session_id: &str) -> Result<Option<GameRow>, ApiE
AND pg.victor_bool = 'Loss'
GROUP BY pg.team_name COLLATE NOCASE
ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE
LIMIT 1)
LIMIT 1),
m.tournament_id
FROM (
SELECT
COUNT(*) AS player_count,
@@ -1652,6 +1960,7 @@ fn game_for(conn: &Connection, session_id: &str) -> Result<Option<GameRow>, ApiE
player_count: row.get(7)?,
winning_team: row.get(17)?,
losing_team: row.get(18)?,
tournament_id: row.get(19)?,
tournament_name: row.get(4)?,
duration: row.get(5)?,
draw: draw_int != 0,
@@ -1853,6 +2162,14 @@ fn validate_uid(value: &str) -> Result<String, ApiError> {
Ok(trimmed.to_string())
}
fn validate_tournament_id(value: &str) -> Result<i64, ApiError> {
let trimmed = value.trim();
match trimmed.parse::<i64>() {
Ok(id) if id > 0 => Ok(id),
_ => Err(ApiError::bad_request("Invalid tournament ID")),
}
}
fn validate_session_id(value: &str) -> Result<&str, ApiError> {
if value.is_empty()
|| value.len() > 96
+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))
+10
View File
@@ -2009,6 +2009,16 @@ function allowedApiTarget(req) {
return url
}
if (pathname === '/api/tss/tournaments') {
if ([...params.keys()].length) return null
return url
}
if (/^\/api\/tss\/tournaments\/[0-9]{1,18}$/.test(pathname)) {
if ([...params.keys()].length) return null
return url
}
if (pathname === '/api/tss/teams/resolve') {
const keys = [...params.keys()]
const name = params.get('name') || ''