ai generated solutions to our ai generated problems
This commit is contained in:
+220
-34
@@ -9,7 +9,7 @@ use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
collections::{BTreeMap, HashMap},
|
||||
env, fs,
|
||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||
path::{Path as FsPath, PathBuf},
|
||||
@@ -97,6 +97,11 @@ struct LeaderboardResponse {
|
||||
teams: Vec<TeamLeaderboardRow>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RecentGamesResponse {
|
||||
matches: Vec<GameRow>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SearchResponse {
|
||||
teams: Vec<TeamSearchRow>,
|
||||
@@ -138,7 +143,7 @@ struct TeamDetail {
|
||||
players: Vec<PlayerSummary>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Default, Serialize)]
|
||||
struct TeamSummary {
|
||||
player_count: i64,
|
||||
total_battles: i64,
|
||||
@@ -192,6 +197,8 @@ struct GamesResponse {
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GameRow {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
team_name: Option<String>,
|
||||
session_id: String,
|
||||
timestamp: i64,
|
||||
endtime_unix: i64,
|
||||
@@ -286,6 +293,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let app = Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/api/tss/leaderboard/teams", get(leaderboard))
|
||||
.route("/api/tss/games/recent", get(recent_games))
|
||||
.route("/api/tss/teams/resolve", get(resolve_team))
|
||||
.route("/api/tss/teams/search", get(search_teams))
|
||||
.route("/api/tss/teams/{team}", get(team_detail))
|
||||
@@ -385,10 +393,13 @@ async fn leaderboard(
|
||||
.map_err(db_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(db_error)?;
|
||||
let mut summaries = team_summaries_for(&battles_conn)?;
|
||||
|
||||
let mut rows = Vec::with_capacity(teams.len());
|
||||
for team in teams {
|
||||
let summary = team_summary_for(&battles_conn, &team.name)?;
|
||||
let summary = summaries
|
||||
.remove(&team.name.to_ascii_lowercase())
|
||||
.unwrap_or_default();
|
||||
rows.push(TeamLeaderboardRow {
|
||||
team_id: team.team_id,
|
||||
name: team.name,
|
||||
@@ -404,6 +415,16 @@ async fn leaderboard(
|
||||
Ok(Json(LeaderboardResponse { teams: rows }))
|
||||
}
|
||||
|
||||
async fn recent_games(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(query): Query<LimitQuery>,
|
||||
) -> ApiResult<RecentGamesResponse> {
|
||||
let limit = i64::from(query.limit.unwrap_or(50).clamp(1, 100));
|
||||
let battles_conn = open_db(&state.battles_db)?;
|
||||
let matches = recent_games_for(&battles_conn, limit)?;
|
||||
Ok(Json(RecentGamesResponse { matches }))
|
||||
}
|
||||
|
||||
async fn resolve_team(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(query): Query<ResolveQuery>,
|
||||
@@ -634,6 +655,60 @@ fn team_summary_for(conn: &Connection, team_name: &str) -> Result<TeamSummary, A
|
||||
.map_err(db_error)
|
||||
}
|
||||
|
||||
fn team_summaries_for(conn: &Connection) -> Result<HashMap<String, TeamSummary>, ApiError> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT
|
||||
team_name,
|
||||
COUNT(DISTINCT session_id),
|
||||
SUM(CASE WHEN victor_bool = 'Win' THEN 1 ELSE 0 END),
|
||||
SUM(CASE WHEN victor_bool != 'Win' THEN 1 ELSE 0 END),
|
||||
COALESCE(SUM(ground_kills), 0),
|
||||
COALESCE(SUM(air_kills), 0),
|
||||
COALESCE(SUM(assists), 0),
|
||||
COALESCE(SUM(deaths), 0),
|
||||
COALESCE(SUM(score), 0),
|
||||
COUNT(DISTINCT UID)
|
||||
FROM player_games_hist
|
||||
WHERE team_name IS NOT NULL AND team_name != ''
|
||||
GROUP BY team_name COLLATE NOCASE",
|
||||
)
|
||||
.map_err(db_error)?;
|
||||
|
||||
let summaries = stmt
|
||||
.query_map([], |row| {
|
||||
let name: String = row.get(0)?;
|
||||
let battles: i64 = row.get(1)?;
|
||||
let wins: i64 = row.get(2)?;
|
||||
let losses: i64 = row.get(3)?;
|
||||
let ground: i64 = row.get(4)?;
|
||||
let air: i64 = row.get(5)?;
|
||||
let assists: i64 = row.get(6)?;
|
||||
let deaths: i64 = row.get(7)?;
|
||||
let score: i64 = row.get(8)?;
|
||||
let player_count: i64 = row.get(9)?;
|
||||
let total_kills = ground + air;
|
||||
Ok((
|
||||
name.to_ascii_lowercase(),
|
||||
TeamSummary {
|
||||
player_count,
|
||||
total_battles: battles,
|
||||
wins,
|
||||
losses,
|
||||
win_rate: percent(wins, battles),
|
||||
kdr: ratio(total_kills, deaths),
|
||||
total_kills,
|
||||
total_points: score + assists + total_kills,
|
||||
},
|
||||
))
|
||||
})
|
||||
.map_err(db_error)?
|
||||
.collect::<Result<HashMap<_, _>, _>>()
|
||||
.map_err(db_error)?;
|
||||
|
||||
Ok(summaries)
|
||||
}
|
||||
|
||||
fn player_summaries_for(
|
||||
teams_conn: &Connection,
|
||||
battles_conn: &Connection,
|
||||
@@ -652,19 +727,16 @@ fn player_summaries_for(
|
||||
.map_err(db_error)?;
|
||||
let members = stmt
|
||||
.query_map(params![team_id], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
))
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.map_err(db_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(db_error)?;
|
||||
|
||||
let mut out = Vec::with_capacity(members.len());
|
||||
let mut stats_stmt = battles_conn
|
||||
.prepare(
|
||||
"SELECT
|
||||
UID,
|
||||
COUNT(DISTINCT session_id),
|
||||
SUM(CASE WHEN victor_bool = 'Win' THEN 1 ELSE 0 END),
|
||||
SUM(CASE WHEN victor_bool != 'Win' THEN 1 ELSE 0 END),
|
||||
@@ -673,29 +745,32 @@ fn player_summaries_for(
|
||||
COALESCE(SUM(assists), 0),
|
||||
COALESCE(SUM(deaths), 0),
|
||||
(SELECT nick FROM player_games_hist
|
||||
WHERE UID = ?2 AND nick IS NOT NULL
|
||||
WHERE UID = p.UID AND nick IS NOT NULL
|
||||
ORDER BY endtime_unix DESC LIMIT 1)
|
||||
FROM player_games_hist
|
||||
WHERE team_name = ?1 COLLATE NOCASE AND UID = ?2",
|
||||
FROM player_games_hist p
|
||||
WHERE team_name = ?1 COLLATE NOCASE
|
||||
GROUP BY UID",
|
||||
)
|
||||
.map_err(db_error)?;
|
||||
|
||||
for (uid, role) in members {
|
||||
let summary = stats_stmt
|
||||
.query_row(params![team_name, uid], |row| {
|
||||
let battles: i64 = row.get(0)?;
|
||||
let wins: i64 = row.get(1)?;
|
||||
let losses: i64 = row.get(2)?;
|
||||
let ground: i64 = row.get(3)?;
|
||||
let air: i64 = row.get(4)?;
|
||||
let assists: i64 = row.get(5)?;
|
||||
let deaths: i64 = row.get(6)?;
|
||||
let nick: Option<String> = row.get(7)?;
|
||||
let mut summaries = stats_stmt
|
||||
.query_map(params![team_name], |row| {
|
||||
let uid: String = row.get(0)?;
|
||||
let battles: i64 = row.get(1)?;
|
||||
let wins: i64 = row.get(2)?;
|
||||
let losses: i64 = row.get(3)?;
|
||||
let ground: i64 = row.get(4)?;
|
||||
let air: i64 = row.get(5)?;
|
||||
let assists: i64 = row.get(6)?;
|
||||
let deaths: i64 = row.get(7)?;
|
||||
let nick: Option<String> = row.get(8)?;
|
||||
let total_kills = ground + air;
|
||||
Ok(PlayerSummary {
|
||||
uid: uid.clone(),
|
||||
Ok((
|
||||
uid.clone(),
|
||||
PlayerSummary {
|
||||
uid,
|
||||
nick,
|
||||
role: role.clone(),
|
||||
role: String::new(),
|
||||
total_battles: battles,
|
||||
wins,
|
||||
losses,
|
||||
@@ -706,9 +781,31 @@ fn player_summaries_for(
|
||||
assists,
|
||||
deaths,
|
||||
kdr: ratio(total_kills, deaths),
|
||||
},
|
||||
))
|
||||
})
|
||||
})
|
||||
.map_err(db_error)?
|
||||
.collect::<Result<HashMap<_, _>, _>>()
|
||||
.map_err(db_error)?;
|
||||
|
||||
let mut out = Vec::with_capacity(members.len());
|
||||
for (uid, role) in members {
|
||||
let mut summary = summaries.remove(&uid).unwrap_or(PlayerSummary {
|
||||
uid,
|
||||
nick: None,
|
||||
role: String::new(),
|
||||
total_battles: 0,
|
||||
wins: 0,
|
||||
losses: 0,
|
||||
win_rate: 0.0,
|
||||
total_kills: 0,
|
||||
ground_kills: 0,
|
||||
air_kills: 0,
|
||||
assists: 0,
|
||||
deaths: 0,
|
||||
kdr: 0.0,
|
||||
});
|
||||
summary.role = role;
|
||||
out.push(summary);
|
||||
}
|
||||
|
||||
@@ -812,13 +909,24 @@ fn player_career_for(conn: &Connection, uid: &str) -> Result<Option<PlayerCareer
|
||||
let captures: i64 = row.get(6)?;
|
||||
let deaths: i64 = row.get(7)?;
|
||||
let total_kills = ground + air;
|
||||
Ok((battles, wins, losses, ground, air, assists, captures, deaths, total_kills))
|
||||
Ok((
|
||||
battles,
|
||||
wins,
|
||||
losses,
|
||||
ground,
|
||||
air,
|
||||
assists,
|
||||
captures,
|
||||
deaths,
|
||||
total_kills,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(db_error)
|
||||
.map(|opt| {
|
||||
opt.and_then(|(battles, wins, losses, ground, air, assists, captures, deaths, total_kills)| {
|
||||
opt.and_then(
|
||||
|(battles, wins, losses, ground, air, assists, captures, deaths, total_kills)| {
|
||||
if battles == 0 {
|
||||
None
|
||||
} else {
|
||||
@@ -836,7 +944,8 @@ fn player_career_for(conn: &Connection, uid: &str) -> Result<Option<PlayerCareer
|
||||
kdr: ratio(total_kills, deaths),
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -941,6 +1050,7 @@ fn games_for(conn: &Connection, team_name: &str) -> Result<Vec<GameRow>, ApiErro
|
||||
let map_name: Option<String> = row.get(2)?;
|
||||
let mission_mode: Option<String> = row.get(3)?;
|
||||
Ok(GameRow {
|
||||
team_name: None,
|
||||
session_id,
|
||||
timestamp,
|
||||
endtime_unix: timestamp,
|
||||
@@ -969,6 +1079,83 @@ fn games_for(conn: &Connection, team_name: &str) -> Result<Vec<GameRow>, ApiErro
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn recent_games_for(conn: &Connection, limit: i64) -> Result<Vec<GameRow>, ApiError> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"WITH recent AS (
|
||||
SELECT team_name, session_id, MAX(endtime_unix) AS timestamp
|
||||
FROM player_games_hist
|
||||
WHERE team_name IS NOT NULL AND team_name != ''
|
||||
GROUP BY session_id
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?1
|
||||
)
|
||||
SELECT
|
||||
r.team_name,
|
||||
r.session_id,
|
||||
COALESCE(m.endtime_unix, r.timestamp, 0) AS timestamp,
|
||||
m.mission_name,
|
||||
m.mission_mode,
|
||||
CASE
|
||||
WHEN MAX(CASE WHEN p.victor_bool = 'Win' THEN 1 ELSE 0 END) = 1 THEN 'Win'
|
||||
ELSE 'Loss'
|
||||
END AS result,
|
||||
COUNT(DISTINCT p.UID),
|
||||
COALESCE(SUM(p.ground_kills), 0),
|
||||
COALESCE(SUM(p.air_kills), 0),
|
||||
COALESCE(SUM(p.assists), 0),
|
||||
COALESCE(SUM(p.captures), 0),
|
||||
COALESCE(SUM(p.deaths), 0),
|
||||
COALESCE(SUM(p.score), 0),
|
||||
COALESCE(SUM(p.missile_evades), 0),
|
||||
COALESCE(SUM(p.shell_interceptions), 0),
|
||||
COALESCE(SUM(p.team_kills_stat), 0),
|
||||
m.winning_slot,
|
||||
m.losing_slot
|
||||
FROM recent r
|
||||
JOIN player_games_hist p
|
||||
ON p.session_id = r.session_id AND p.team_name = r.team_name COLLATE NOCASE
|
||||
LEFT JOIN match_summary m ON m.session_id = r.session_id
|
||||
GROUP BY r.team_name COLLATE NOCASE, r.session_id
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?1",
|
||||
)
|
||||
.map_err(db_error)?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(params![limit], |row| {
|
||||
let timestamp: i64 = row.get(2)?;
|
||||
Ok(GameRow {
|
||||
team_name: row.get(0)?,
|
||||
session_id: row.get(1)?,
|
||||
timestamp,
|
||||
endtime_unix: timestamp,
|
||||
map_name: row.get(3)?,
|
||||
mission_mode: row.get(4)?,
|
||||
result: row.get(5)?,
|
||||
player_count: row.get(6)?,
|
||||
winning_team: row.get(16)?,
|
||||
losing_team: row.get(17)?,
|
||||
stats: GameStats {
|
||||
ground_kills: row.get(7)?,
|
||||
air_kills: row.get(8)?,
|
||||
assists: row.get(9)?,
|
||||
captures: row.get(10)?,
|
||||
deaths: row.get(11)?,
|
||||
score: row.get(12)?,
|
||||
missile_evades: row.get(13)?,
|
||||
shell_interceptions: row.get(14)?,
|
||||
team_kills_stat: row.get(15)?,
|
||||
},
|
||||
})
|
||||
})
|
||||
.map_err(db_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(db_error)?;
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn validate_team_name(name: &str) -> Result<&str, ApiError> {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.len() < 2 || trimmed.len() > MAX_TEAM_NAME_LENGTH {
|
||||
@@ -982,17 +1169,16 @@ fn validate_team_name(name: &str) -> Result<&str, ApiError> {
|
||||
fn validate_player_name(name: &str) -> Result<&str, ApiError> {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.len() < 2 || trimmed.len() > MAX_TEAM_NAME_LENGTH {
|
||||
return Err(ApiError::bad_request("Player name must be 2 to 80 characters"));
|
||||
return Err(ApiError::bad_request(
|
||||
"Player name must be 2 to 80 characters",
|
||||
));
|
||||
}
|
||||
Ok(trimmed)
|
||||
}
|
||||
|
||||
fn validate_uid(value: &str) -> Result<String, ApiError> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty()
|
||||
|| trimmed.len() > 32
|
||||
|| !trimmed.chars().all(|c| c.is_ascii_digit())
|
||||
{
|
||||
if trimmed.is_empty() || trimmed.len() > 32 || !trimmed.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Err(ApiError::bad_request("Invalid player UID"));
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
|
||||
+15
-126
@@ -19,17 +19,17 @@ const apiEndpoints = {
|
||||
songOfDay: '/api/song-of-day',
|
||||
teams: '/api/tss/leaderboard/teams?limit=100',
|
||||
teamsHealth: '/api/tss/leaderboard/teams?limit=1',
|
||||
recentGames: '/api/tss/games/recent?limit=50',
|
||||
resolve: (name) => `/api/tss/teams/resolve?name=${encodeURIComponent(name)}`,
|
||||
searchTeams: (name) => `/api/tss/teams/search?q=${encodeURIComponent(name)}&limit=10`,
|
||||
detail: (name) => `/api/tss/teams/${encodeURIComponent(name)}`,
|
||||
history: (name) => `/api/tss/teams/${encodeURIComponent(name)}/history`,
|
||||
games: (name) => `/api/tss/teams/${encodeURIComponent(name)}/games`,
|
||||
player: (uid) => `/api/tss/player/${encodeURIComponent(uid)}`,
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: 'Home' },
|
||||
{ path: '/teams', label: 'Team leaderboard' },
|
||||
{ path: '/teams', label: 'Team Leaderboard' },
|
||||
{ path: '/battle-logs', label: 'Battle Logs' },
|
||||
{ path: '/viewers', label: 'Viewers' },
|
||||
{ path: '/docs', label: 'Setup' },
|
||||
@@ -355,7 +355,7 @@ function deviceType() {
|
||||
|
||||
function routeLabel(route) {
|
||||
if (route.page === 'team' && route.teamName) return `Team: ${route.teamName}`
|
||||
if (route.page === 'teams') return 'Team leaderboard'
|
||||
if (route.page === 'teams') return 'Team Leaderboard'
|
||||
if (route.page === 'battle-logs') return 'Battle Logs'
|
||||
if (route.page === 'uptime') return 'Uptime'
|
||||
if (route.page === 'viewers') return 'viewers'
|
||||
@@ -539,41 +539,8 @@ function applySeo(route, profileDetail = null) {
|
||||
structuredData.textContent = structuredDataForSeo(seo, canonicalUrl)
|
||||
}
|
||||
|
||||
async function fetchRecentTssGames(teams, signal) {
|
||||
const teamNames = teams.map(bestTeamName).filter(Boolean).slice(0, 12)
|
||||
|
||||
if (!teamNames.length) {
|
||||
return { matches: [] }
|
||||
}
|
||||
|
||||
const responses = await Promise.allSettled(
|
||||
teamNames.map((name) => fetchJson(apiEndpoints.games(name), signal).then((data) => ({ name, data }))),
|
||||
)
|
||||
const bySession = new Map()
|
||||
|
||||
responses.forEach((result) => {
|
||||
if (result.status !== 'fulfilled') return
|
||||
|
||||
const { name, data } = result.value
|
||||
;(data.games || []).forEach((game) => {
|
||||
if (!game.session_id) return
|
||||
|
||||
const existing = bySession.get(game.session_id)
|
||||
const currentTimestamp = Number(game.timestamp || 0)
|
||||
if (existing && Number(existing.timestamp || 0) >= currentTimestamp) return
|
||||
|
||||
bySession.set(game.session_id, {
|
||||
...game,
|
||||
team_name: data.name || name,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
matches: Array.from(bySession.values())
|
||||
.sort((a, b) => Number(b.timestamp || 0) - Number(a.timestamp || 0))
|
||||
.slice(0, 50),
|
||||
}
|
||||
async function fetchRecentTssGames(signal) {
|
||||
return fetchJson(apiEndpoints.recentGames, signal)
|
||||
}
|
||||
|
||||
function Stat({ label, value }) {
|
||||
@@ -861,7 +828,6 @@ function AppContent() {
|
||||
const [profile, setProfile] = useState({
|
||||
teamName: '',
|
||||
detail: { status: 'idle', data: null, error: null },
|
||||
history: { status: 'idle', data: null, error: null },
|
||||
games: { status: 'idle', data: null, error: null },
|
||||
})
|
||||
const teams = useMemo(
|
||||
@@ -1138,7 +1104,6 @@ function AppContent() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!['home', 'battle-logs'].includes(route.page)) return
|
||||
if (!teams.length) return
|
||||
const currentLive = liveRef.current
|
||||
if (currentLive.status === 'ready' && Date.now() - currentLive.updatedAt < liveRefreshMs) return
|
||||
|
||||
@@ -1149,7 +1114,7 @@ function AppContent() {
|
||||
: { status: 'loading', data: null, error: null, updatedAt: current.updatedAt || 0 },
|
||||
)
|
||||
|
||||
fetchRecentTssGames(teams, controller.signal)
|
||||
fetchRecentTssGames(controller.signal)
|
||||
.then((data) => setLive({ status: 'ready', data, error: null, updatedAt: Date.now() }))
|
||||
.catch((error) => {
|
||||
if (!controller.signal.aborted) {
|
||||
@@ -1158,15 +1123,14 @@ function AppContent() {
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
}, [route.page, teams])
|
||||
}, [route.page])
|
||||
|
||||
useEffect(() => {
|
||||
if (!['home', 'battle-logs'].includes(route.page)) return
|
||||
if (!teams.length) return
|
||||
|
||||
const controller = new AbortController()
|
||||
const timer = window.setInterval(() => {
|
||||
fetchRecentTssGames(teams, controller.signal)
|
||||
fetchRecentTssGames(controller.signal)
|
||||
.then((data) => setLive({ status: 'ready', data, error: null, updatedAt: Date.now() }))
|
||||
.catch((error) => {
|
||||
if (!controller.signal.aborted) {
|
||||
@@ -1179,7 +1143,7 @@ function AppContent() {
|
||||
window.clearInterval(timer)
|
||||
controller.abort()
|
||||
}
|
||||
}, [route.page, teams])
|
||||
}, [route.page])
|
||||
|
||||
useEffect(() => {
|
||||
if (route.page !== 'home') return
|
||||
@@ -1219,7 +1183,6 @@ function AppContent() {
|
||||
setProfile({
|
||||
teamName: route.teamName,
|
||||
detail: { status: 'loading', data: null, error: null },
|
||||
history: { status: 'loading', data: null, error: null },
|
||||
games: { status: 'loading', data: null, error: null },
|
||||
})
|
||||
|
||||
@@ -1232,18 +1195,13 @@ function AppContent() {
|
||||
}
|
||||
|
||||
return Promise.allSettled([
|
||||
fetchJson(apiEndpoints.history(route.teamName), controller.signal),
|
||||
fetchJson(apiEndpoints.games(route.teamName), controller.signal),
|
||||
]).then(([historyResult, gamesResult]) => {
|
||||
]).then(([gamesResult]) => {
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
setProfile({
|
||||
teamName: route.teamName,
|
||||
detail: { status: 'ready', data: detail, error: null },
|
||||
history:
|
||||
historyResult.status === 'fulfilled'
|
||||
? { status: 'ready', data: historyResult.value, error: null }
|
||||
: { status: 'error', data: null, error: historyResult.reason.message },
|
||||
games:
|
||||
gamesResult.status === 'fulfilled'
|
||||
? { status: 'ready', data: gamesResult.value, error: null }
|
||||
@@ -1402,9 +1360,8 @@ function AppContent() {
|
||||
const timer = window.setInterval(() => {
|
||||
Promise.allSettled([
|
||||
fetchJson(apiEndpoints.detail(route.teamName), controller.signal),
|
||||
fetchJson(apiEndpoints.history(route.teamName), controller.signal),
|
||||
fetchJson(apiEndpoints.games(route.teamName), controller.signal),
|
||||
]).then(([detailResult, historyResult, gamesResult]) => {
|
||||
]).then(([detailResult, gamesResult]) => {
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
setProfile((current) => ({
|
||||
@@ -1413,10 +1370,6 @@ function AppContent() {
|
||||
detailResult.status === 'fulfilled'
|
||||
? { status: 'ready', data: detailResult.value, error: null }
|
||||
: current.detail,
|
||||
history:
|
||||
historyResult.status === 'fulfilled'
|
||||
? { status: 'ready', data: historyResult.value, error: null }
|
||||
: current.history,
|
||||
games:
|
||||
gamesResult.status === 'fulfilled'
|
||||
? { status: 'ready', data: gamesResult.value, error: null }
|
||||
@@ -2111,7 +2064,7 @@ function Landing({
|
||||
onClick={() => navigate('/teams')}
|
||||
type="button"
|
||||
>
|
||||
Team leaderboard
|
||||
Team Leaderboard
|
||||
</button>
|
||||
<button
|
||||
className="min-h-15 rounded-lg border-2 border-ring px-5 py-4 text-base font-semibold text-fury-cyan transition hover:bg-surface hover:text-text"
|
||||
@@ -2824,7 +2777,7 @@ function TeamsPage({ leaderboard, navigate, teams }) {
|
||||
return (
|
||||
<section className="space-y-6 pt-24 sm:pt-28">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Team leaderboard</h1>
|
||||
<h1 className="text-3xl font-bold">Team Leaderboard</h1>
|
||||
<p className="mt-2 text-sm text-text-soft">
|
||||
{leaderboard.status === 'loading'
|
||||
? 'Loading leaderboard'
|
||||
@@ -2871,9 +2824,6 @@ function TeamProfilePage({ navigate, profile, requestedTeam, teams }) {
|
||||
const summary = detail?.team_summary || detail?.squadron_summary
|
||||
const players = detail?.players || []
|
||||
const games = profile.games.data?.games || []
|
||||
const history = profile.history.data?.history || []
|
||||
const ratingHourly = profile.history.data?.rating_hourly || []
|
||||
const latestRating = ratingHourly.at(-1)?.rating || summary?.points?.total_points
|
||||
const leaderboardTeam = teams.find((team) => bestTeamName(team) === requestedTeam)
|
||||
const displayName = detail?.name || bestTeamName(leaderboardTeam) || requestedTeam
|
||||
|
||||
@@ -2888,7 +2838,7 @@ function TeamProfilePage({ navigate, profile, requestedTeam, teams }) {
|
||||
</button>
|
||||
|
||||
<div className="rounded-lg border border-border bg-fury-white p-6 shadow-sm">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">
|
||||
Team profile
|
||||
@@ -2898,32 +2848,17 @@ function TeamProfilePage({ navigate, profile, requestedTeam, teams }) {
|
||||
{profile.detail.error || ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-3">
|
||||
<span className="rounded-md bg-surface px-3 py-2 font-semibold">
|
||||
Rating {formatNumber(latestRating)}
|
||||
</span>
|
||||
<span className="rounded-md bg-surface px-3 py-2 font-semibold">
|
||||
Clan {detail?.clan_id || leaderboardTeam?.clan_id || 'n/a'}
|
||||
</span>
|
||||
<span className="rounded-md bg-surface px-3 py-2 font-semibold">
|
||||
{detail?.data_set || 'tss'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-5 sm:grid-cols-2 xl:grid-cols-5">
|
||||
<div className="mt-6 grid gap-5 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Stat label="Roster" value={formatNumber(summary?.player_count)} />
|
||||
<Stat label="Battles" value={formatNumber(summary?.total_battles)} />
|
||||
<Stat label="Wins" value={formatNumber(summary?.wins)} />
|
||||
<Stat label="Win rate" value={`${Number(summary?.win_rate || 0).toFixed(1)}%`} />
|
||||
<Stat label="KDR" value={Number(summary?.kdr || 0).toFixed(1)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
|
||||
<RosterTable players={players} status={profile.detail.status} />
|
||||
<RatingPanel history={history} ratingHourly={ratingHourly} status={profile.history.status} />
|
||||
</div>
|
||||
|
||||
<BattleResults games={games} status={profile.games.status} />
|
||||
</section>
|
||||
@@ -2970,51 +2905,6 @@ function RosterTable({ players, status }) {
|
||||
)
|
||||
}
|
||||
|
||||
function RatingPanel({ history, ratingHourly, status }) {
|
||||
const recentHistory = history.slice(-8)
|
||||
const firstRating = ratingHourly[0]?.rating || 0
|
||||
const latestRating = ratingHourly.at(-1)?.rating || 0
|
||||
const ratingChange = latestRating - firstRating
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-fury-white shadow-sm">
|
||||
<div className="border-b border-surface px-5 py-4">
|
||||
<h2 className="text-lg font-semibold">History</h2>
|
||||
<p className="mt-1 text-sm text-text-soft">
|
||||
{ratingHourly.length ? `${formatNumber(ratingHourly.length)} rating snapshots` : status}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-5 p-5">
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
<Stat label="Latest rating" value={formatNumber(latestRating)} />
|
||||
<Stat
|
||||
label="Rating change"
|
||||
value={`${ratingChange >= 0 ? '+' : ''}${formatNumber(ratingChange)}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{recentHistory.map((item) => (
|
||||
<div
|
||||
className="grid grid-cols-[1fr_auto_auto] gap-3 rounded-md bg-surface px-3 py-2 text-sm"
|
||||
key={item.period}
|
||||
>
|
||||
<span className="font-semibold">{item.period}</span>
|
||||
<span>{formatNumber(item.battles)} battles</span>
|
||||
<span>{Number(item.win_rate || 0).toFixed(1)}% WR</span>
|
||||
</div>
|
||||
))}
|
||||
{!recentHistory.length ? (
|
||||
<p className="text-sm text-text-soft">
|
||||
{status === 'loading' ? 'Loading history' : 'No history rows returned'}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BattleResults({ games, status }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-fury-white shadow-sm">
|
||||
@@ -3085,7 +2975,6 @@ function BattleLogsPage({ live, matches }) {
|
||||
<p className="truncate font-semibold text-fury-cyan">
|
||||
{match.team_name || 'TSS team'}
|
||||
</p>
|
||||
<p className="truncate text-xs text-text-soft">TSS battle record</p>
|
||||
</div>
|
||||
<p
|
||||
className={`w-fit rounded-md px-3 py-1 text-sm font-semibold ${String(match.result).toLowerCase() === 'win'
|
||||
|
||||
@@ -1768,6 +1768,15 @@ function allowedApiTarget(req) {
|
||||
return url
|
||||
}
|
||||
|
||||
if (pathname === '/api/tss/games/recent') {
|
||||
const keys = [...params.keys()]
|
||||
const limit = Number(params.get('limit') || 50)
|
||||
if (keys.some((key) => key !== 'limit') || !Number.isInteger(limit) || limit < 1 || limit > 100) {
|
||||
return null
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
if (pathname === '/api/tss/teams/resolve') {
|
||||
const keys = [...params.keys()]
|
||||
const name = params.get('name') || ''
|
||||
|
||||
@@ -277,6 +277,12 @@ function isAllowedApiUrl(req) {
|
||||
return keys.every((key) => key === 'limit') && Number.isInteger(limit) && limit >= 1 && limit <= 100
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/tss/games/recent') {
|
||||
const keys = [...params.keys()]
|
||||
const limit = Number(params.get('limit') || 50)
|
||||
return keys.every((key) => key === 'limit') && Number.isInteger(limit) && limit >= 1 && limit <= 100
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/tss/teams/resolve') {
|
||||
const keys = [...params.keys()]
|
||||
const name = params.get('name') || ''
|
||||
|
||||
Reference in New Issue
Block a user