diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 9613bbe..3de55e2 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -5536,6 +5536,69 @@ function inferSwissRounds(sides) {
})
}
+function TeamTotals({ matches, navigate, highlight, onHover }) {
+ const teams = useMemo(() => {
+ const map = {}
+ for (const match of matches) {
+ const teamA = displayTeamName(match.team_a_name)
+ const teamB = displayTeamName(match.team_b_name)
+ const winner = displayTeamName(match.winner_name)
+ for (const name of [teamA, teamB]) {
+ if (!name) continue
+ if (!map[name]) map[name] = { wins: 0, losses: 0 }
+ if (winner) {
+ if (winner === name) map[name].wins++
+ else map[name].losses++
+ }
+ }
+ }
+ return Object.entries(map)
+ .map(([name, s]) => ({ name, wins: s.wins, losses: s.losses, total: s.wins + s.losses }))
+ .sort((a, b) => b.wins - a.wins || a.losses - b.losses || a.name.localeCompare(b.name))
+ }, [matches])
+
+ if (!teams.length) return null
+
+ const active = Boolean(highlight)
+
+ return (
+
+
+ TEAM TOTALS
+
+
+ {teams.map((team) => {
+ const traced = traceClass([team.name.toLowerCase()], highlight)
+ const wr = team.total > 0 ? Math.round(team.wins / team.total * 100) : 0
+ return (
+
onHover?.({ [team.name.toLowerCase()]: 'win' })}
+ onMouseLeave={() => onHover?.(null)}
+ >
+
+ {team.wins}
+ {team.losses}
+ {wr}%
+
+ )
+ })}
+
+ )
+}
+
function TournamentDetailPage({ tournamentId, navigate }) {
const [state, setState] = useState({ status: 'loading', data: null, error: null })
@@ -5564,6 +5627,9 @@ function TournamentDetailPage({ tournamentId, navigate }) {
}, [matches])
const bracketSides = sides.filter((side) => side.kind === 'bracket')
const listSides = sides.filter((side) => side.kind === 'list')
+ const swissMatches = useMemo(() => {
+ return listSides.filter((s) => s.key === 'swiss').flatMap((s) => s.columns.flatMap((c) => c.matches))
+ }, [listSides])
const standings = data?.standings || []
const hasStandings = standings.length > 0
// Lit-up teams shared across both brackets, so a run lights in the winner
@@ -5622,6 +5688,9 @@ function TournamentDetailPage({ tournamentId, navigate }) {
{bracketSides.length ? Group stage
: null}
{hasStandings ? : null}
{listSides.length ? : null}
+ {swissMatches.length ? (
+
+ ) : null}
) : null}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index c5e99ed..7214934 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -1180,11 +1180,32 @@ h3 {
@media (prefers-reduced-motion: reduce) {
.bracket-line-flow,
.tournament-match-node.is-traced > *,
- .tournament-match-node.is-traced-loss > * {
+ .tournament-match-node.is-traced-loss > *,
+ .team-total-row.is-traced,
+ .team-total-row.is-traced-loss {
animation: none;
}
}
+/* Team totals row trace */
+.team-total-row {
+ transition: opacity 0.18s ease;
+}
+
+.has-trace .team-total-row:not(.is-traced):not(.is-traced-loss) {
+ opacity: 0.32;
+}
+
+.team-total-row.is-traced {
+ box-shadow: inset 0 0 0 1.5px var(--color-win), 0 0 18px -2px color-mix(in srgb, var(--color-win) 70%, transparent);
+ animation: bracketBreathe 1.6s ease-in-out infinite;
+}
+
+.team-total-row.is-traced-loss {
+ box-shadow: inset 0 0 0 1.5px var(--color-loss), 0 0 18px -2px color-mix(in srgb, var(--color-loss) 70%, transparent);
+ animation: bracketBreatheLoss 1.6s ease-in-out infinite;
+}
+
@keyframes scrollPulse {
0% {
transform: translateY(-100%);