add stuff for tournaments
This commit is contained in:
+319
-2
@@ -9,7 +9,7 @@ use rusqlite::{params, Connection, OptionalExtension};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::{
|
use std::{
|
||||||
collections::{BTreeMap, HashMap},
|
collections::{BTreeMap, HashMap, HashSet},
|
||||||
env, fs,
|
env, fs,
|
||||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||||
path::{Path as FsPath, PathBuf},
|
path::{Path as FsPath, PathBuf},
|
||||||
@@ -28,6 +28,7 @@ const LEADERBOARD_CACHE_TTL: Duration = Duration::from_secs(300);
|
|||||||
struct AppState {
|
struct AppState {
|
||||||
battles_db: PathBuf,
|
battles_db: PathBuf,
|
||||||
teams_db: PathBuf,
|
teams_db: PathBuf,
|
||||||
|
tournaments_db: PathBuf,
|
||||||
leaderboard_cache: Mutex<Option<CachedLeaderboard>>,
|
leaderboard_cache: Mutex<Option<CachedLeaderboard>>,
|
||||||
vehicle_names: HashMap<String, HashMap<String, String>>,
|
vehicle_names: HashMap<String, HashMap<String, String>>,
|
||||||
vehicle_icons: HashMap<String, String>,
|
vehicle_icons: HashMap<String, String>,
|
||||||
@@ -239,6 +240,72 @@ struct GameResponse {
|
|||||||
participants: Vec<GameParticipant>,
|
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)]
|
#[derive(Serialize)]
|
||||||
struct GameLogsResponse {
|
struct GameLogsResponse {
|
||||||
chat_log: Vec<String>,
|
chat_log: Vec<String>,
|
||||||
@@ -260,6 +327,8 @@ struct GameRow {
|
|||||||
winning_team: Option<String>,
|
winning_team: Option<String>,
|
||||||
losing_team: Option<String>,
|
losing_team: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
tournament_id: Option<i64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
tournament_name: Option<String>,
|
tournament_name: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
duration: Option<f64>,
|
duration: Option<f64>,
|
||||||
@@ -371,6 +440,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let state = Arc::new(AppState {
|
let state = Arc::new(AppState {
|
||||||
battles_db: resolve_db_path("TSS_BATTLES_DB", "tss_battles.db"),
|
battles_db: resolve_db_path("TSS_BATTLES_DB", "tss_battles.db"),
|
||||||
teams_db: resolve_db_path("TSS_TEAMS_DB", "tss_teams.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),
|
leaderboard_cache: Mutex::new(None),
|
||||||
vehicle_names: load_vehicle_names(&resolve_db_path(
|
vehicle_names: load_vehicle_names(&resolve_db_path(
|
||||||
"VEHICLE_TRANSLATIONS_JSON",
|
"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/teams", get(leaderboard))
|
||||||
.route("/api/tss/leaderboard/players", get(player_leaderboard))
|
.route("/api/tss/leaderboard/players", get(player_leaderboard))
|
||||||
.route("/api/tss/games/recent", get(recent_games))
|
.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}", get(game_detail))
|
||||||
.route("/api/tss/games/{session_id}/logs", get(game_logs))
|
.route("/api/tss/games/{session_id}/logs", get(game_logs))
|
||||||
.route("/api/tss/teams/resolve", get(resolve_team))
|
.route("/api/tss/teams/resolve", get(resolve_team))
|
||||||
@@ -597,6 +669,38 @@ async fn recent_games(
|
|||||||
Ok(Json(RecentGamesResponse { matches }))
|
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(
|
async fn game_detail(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Path(session_id): Path<String>,
|
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)?,
|
player_count: row.get(8)?,
|
||||||
winning_team: row.get(18)?,
|
winning_team: row.get(18)?,
|
||||||
losing_team: row.get(19)?,
|
losing_team: row.get(19)?,
|
||||||
|
tournament_id: None,
|
||||||
tournament_name: row.get(4)?,
|
tournament_name: row.get(4)?,
|
||||||
duration: row.get(5)?,
|
duration: row.get(5)?,
|
||||||
draw: draw_int != 0,
|
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)?,
|
player_count: row.get(7)?,
|
||||||
winning_team: row.get(8)?,
|
winning_team: row.get(8)?,
|
||||||
losing_team: row.get(9)?,
|
losing_team: row.get(9)?,
|
||||||
|
tournament_id: None,
|
||||||
tournament_name: row.get(4)?,
|
tournament_name: row.get(4)?,
|
||||||
duration: row.get(5)?,
|
duration: row.get(5)?,
|
||||||
draw: draw_int != 0,
|
draw: draw_int != 0,
|
||||||
@@ -1564,6 +1670,207 @@ fn recent_games_for(conn: &Connection, limit: i64) -> Result<Vec<GameRow>, ApiEr
|
|||||||
Ok(rows)
|
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> {
|
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
|
// 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
|
// 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'
|
AND pg.victor_bool = 'Loss'
|
||||||
GROUP BY pg.team_name COLLATE NOCASE
|
GROUP BY pg.team_name COLLATE NOCASE
|
||||||
ORDER BY COUNT(DISTINCT pg.UID) DESC, 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 (
|
FROM (
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) AS player_count,
|
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)?,
|
player_count: row.get(7)?,
|
||||||
winning_team: row.get(17)?,
|
winning_team: row.get(17)?,
|
||||||
losing_team: row.get(18)?,
|
losing_team: row.get(18)?,
|
||||||
|
tournament_id: row.get(19)?,
|
||||||
tournament_name: row.get(4)?,
|
tournament_name: row.get(4)?,
|
||||||
duration: row.get(5)?,
|
duration: row.get(5)?,
|
||||||
draw: draw_int != 0,
|
draw: draw_int != 0,
|
||||||
@@ -1853,6 +2162,14 @@ fn validate_uid(value: &str) -> Result<String, ApiError> {
|
|||||||
Ok(trimmed.to_string())
|
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> {
|
fn validate_session_id(value: &str) -> Result<&str, ApiError> {
|
||||||
if value.is_empty()
|
if value.is_empty()
|
||||||
|| value.len() > 96
|
|| value.len() > 96
|
||||||
|
|||||||
+509
-4
@@ -17,6 +17,8 @@ const apiEndpoints = {
|
|||||||
viewerDelete: '/api/viewers/delete',
|
viewerDelete: '/api/viewers/delete',
|
||||||
teams: '/api/tss/leaderboard/teams?limit=100',
|
teams: '/api/tss/leaderboard/teams?limit=100',
|
||||||
players: '/api/tss/leaderboard/players?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',
|
homeTeams: '/api/tss/leaderboard/teams?limit=4',
|
||||||
teamsHealth: '/api/tss/leaderboard/teams?limit=1',
|
teamsHealth: '/api/tss/leaderboard/teams?limit=1',
|
||||||
recentGames: '/api/tss/games/recent?limit=50',
|
recentGames: '/api/tss/games/recent?limit=50',
|
||||||
@@ -35,6 +37,7 @@ const navItems = [
|
|||||||
{ path: '/teams', label: 'Team Leaderboard' },
|
{ path: '/teams', label: 'Team Leaderboard' },
|
||||||
{ path: '/players', label: 'Player Leaderboard' },
|
{ path: '/players', label: 'Player Leaderboard' },
|
||||||
{ path: '/battle-logs', label: 'Battle Logs' },
|
{ path: '/battle-logs', label: 'Battle Logs' },
|
||||||
|
{ path: '/tournaments', label: 'Tournaments' },
|
||||||
{ path: '/viewers', label: 'Viewers' },
|
{ path: '/viewers', label: 'Viewers' },
|
||||||
{ path: '/docs', label: 'Setup' },
|
{ path: '/docs', label: 'Setup' },
|
||||||
]
|
]
|
||||||
@@ -180,6 +183,11 @@ function parseRoute(pathname = window.location.pathname) {
|
|||||||
if (pathname === '/viewers') return { page: 'viewers', teamName: '' }
|
if (pathname === '/viewers') return { page: 'viewers', teamName: '' }
|
||||||
if (pathname === '/privacy') return { page: 'privacy', teamName: '' }
|
if (pathname === '/privacy') return { page: 'privacy', teamName: '' }
|
||||||
if (pathname === '/docs') return { page: 'docs', 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/')) {
|
if (pathname.startsWith('/players/')) {
|
||||||
const uid = decodeURIComponent(pathname.slice('/players/'.length))
|
const uid = decodeURIComponent(pathname.slice('/players/'.length))
|
||||||
return { page: 'player', teamName: '', uid }
|
return { page: 'player', teamName: '', uid }
|
||||||
@@ -208,6 +216,10 @@ function playerPath(uid) {
|
|||||||
return `/players/${encodeURIComponent(uid)}`
|
return `/players/${encodeURIComponent(uid)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tournamentPath(tournamentId) {
|
||||||
|
return `/tournaments/${encodeURIComponent(tournamentId)}`
|
||||||
|
}
|
||||||
|
|
||||||
function formatNumber(value) {
|
function formatNumber(value) {
|
||||||
return numberFormat.format(Number(value || 0))
|
return numberFormat.format(Number(value || 0))
|
||||||
}
|
}
|
||||||
@@ -601,6 +613,8 @@ function routeLabel(route) {
|
|||||||
if (route.page === 'players') return 'Player Leaderboard'
|
if (route.page === 'players') return 'Player Leaderboard'
|
||||||
if (route.page === 'battle-logs') return 'Battle Logs'
|
if (route.page === 'battle-logs') return 'Battle Logs'
|
||||||
if (route.page === 'game') return route.gameId ? `Game ${route.gameId}` : 'Game'
|
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 === 'uptime') return 'Uptime'
|
||||||
if (route.page === 'viewers') return 'viewers'
|
if (route.page === 'viewers') return 'viewers'
|
||||||
if (route.page === 'privacy') return 'Privacy notice'
|
if (route.page === 'privacy') return 'Privacy notice'
|
||||||
@@ -619,6 +633,8 @@ function canonicalPathForRoute(route) {
|
|||||||
if (route.page === 'players') return '/players'
|
if (route.page === 'players') return '/players'
|
||||||
if (route.page === 'battle-logs') return '/battle-logs'
|
if (route.page === 'battle-logs') return '/battle-logs'
|
||||||
if (route.page === 'game' && route.gameId) return gamePath(route.gameId)
|
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 === 'uptime') return '/uptime'
|
||||||
if (route.page === 'viewers') return '/viewers'
|
if (route.page === 'viewers') return '/viewers'
|
||||||
if (route.page === 'privacy') return '/privacy'
|
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 = {
|
const byPage = {
|
||||||
teams: {
|
teams: {
|
||||||
title: "TSS Team Leaderboard | Toothless' TSS Bot",
|
title: "TSS Team Leaderboard | Toothless' TSS Bot",
|
||||||
@@ -688,6 +713,12 @@ function seoForRoute(route, profileDetail = null) {
|
|||||||
robots: 'index, follow',
|
robots: 'index, follow',
|
||||||
path: '/battle-logs',
|
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: {
|
uptime: {
|
||||||
title: "TSS Bot Uptime Status | Toothless' TSS Bot",
|
title: "TSS Bot Uptime Status | Toothless' TSS Bot",
|
||||||
description: 'Check Toothless TSS Bot uptime, API health, TSS data proxy status, and recent availability history.',
|
description: 'Check Toothless TSS Bot uptime, API health, TSS data proxy status, and recent availability history.',
|
||||||
@@ -1789,6 +1820,8 @@ function AppContent() {
|
|||||||
? '/players'
|
? '/players'
|
||||||
: route.page === 'battle-logs' || route.page === 'game'
|
: route.page === 'battle-logs' || route.page === 'game'
|
||||||
? '/battle-logs'
|
? '/battle-logs'
|
||||||
|
: route.page === 'tournaments-list' || route.page === 'tournament'
|
||||||
|
? '/tournaments'
|
||||||
: route.page === 'viewers'
|
: route.page === 'viewers'
|
||||||
? '/viewers'
|
? '/viewers'
|
||||||
: window.location.pathname
|
: window.location.pathname
|
||||||
@@ -1923,6 +1956,8 @@ function AppContent() {
|
|||||||
{route.page === 'docs' ? <DocsPage /> : null}
|
{route.page === 'docs' ? <DocsPage /> : null}
|
||||||
{route.page === 'player' ? <PlayerPage uid={route.uid} navigate={navigate} /> : null}
|
{route.page === 'player' ? <PlayerPage uid={route.uid} navigate={navigate} /> : null}
|
||||||
{route.page === 'game' ? <GamePage gameId={route.gameId} 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>
|
</section>
|
||||||
<Footer navigate={navigate} />
|
<Footer navigate={navigate} />
|
||||||
<ConsentBanner preferences={analyticsPreferences} onChoose={chooseAnalyticsConsent} />
|
<ConsentBanner preferences={analyticsPreferences} onChoose={chooseAnalyticsConsent} />
|
||||||
@@ -3278,9 +3313,19 @@ function GamePage({ gameId, navigate }) {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="rounded-lg border border-border bg-fury-white p-6 shadow-sm">
|
<div className="rounded-lg border border-border bg-fury-white p-6 shadow-sm">
|
||||||
<div className="flex flex-wrap items-baseline gap-x-3">
|
<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</p>
|
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">
|
||||||
<span className="break-all text-xs text-text-muted opacity-70">{game?.session_id || gameId}</span>
|
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>
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-3">
|
<div className="mt-1 flex flex-wrap items-center gap-3">
|
||||||
<h1 className="text-4xl font-bold">{game?.map_name || 'Battle log'}</h1>
|
<h1 className="text-4xl font-bold">{game?.map_name || 'Battle log'}</h1>
|
||||||
@@ -3290,7 +3335,19 @@ function GamePage({ gameId, navigate }) {
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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">
|
<p className="mt-2 text-sm text-text-soft">
|
||||||
{game ? formatDate(game.timestamp) : ''}
|
{game ? formatDate(game.timestamp) : ''}
|
||||||
{duration ? ` · ${duration}` : ''}
|
{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) {
|
function relativeSeconds(timestamp) {
|
||||||
if (!timestamp) return 'unknown'
|
if (!timestamp) return 'unknown'
|
||||||
const seconds = Math.max(0, Math.round((Date.now() - new Date(timestamp).getTime()) / 1000))
|
const seconds = Math.max(0, Math.round((Date.now() - new Date(timestamp).getTime()) / 1000))
|
||||||
|
|||||||
+10
@@ -2009,6 +2009,16 @@ function allowedApiTarget(req) {
|
|||||||
return url
|
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') {
|
if (pathname === '/api/tss/teams/resolve') {
|
||||||
const keys = [...params.keys()]
|
const keys = [...params.keys()]
|
||||||
const name = params.get('name') || ''
|
const name = params.get('name') || ''
|
||||||
|
|||||||
Reference in New Issue
Block a user