)
}
@@ -5319,30 +5345,93 @@ function TournamentStandings({ standings }) {
)
}
-function TournamentMatchList({ sides, navigate }) {
+function TournamentListColumn({ column, navigate, highlight, onHover }) {
+ const [collapsed, setCollapsed] = useState(false)
return (
-
- {sides.map((side) => {
- const matches = side.columns.flatMap((column) => column.matches)
- return (
-
-
{side.label}
-
- {matches.map((match) => (
-
- ))}
-
-
- )
- })}
+
+
+ {collapsed ? null : (
+
+ {column.matches.map((match) => {
+ const traced = traceClass(teamsOf(match), highlight)
+ return (
+
+
+
+ )
+ })}
+
+ )}
)
}
+function TournamentListSide({ side, navigate, highlight, onHover }) {
+ const active = Boolean(highlight)
+ return (
+
+
+ {side.label}
+
+
+
+ {side.columns.map((column) => (
+
+ ))}
+
+
+
+ )
+}
+
+function TournamentMatchList({ sides, navigate, highlight, onHover }) {
+ return (
+
+ {sides.map((side) => (
+
+ ))}
+
+ )
+}
+
+// Swiss tournaments often have round: null on every match because the bot doesn't
+// populate that field. Matches ARE returned in round order (sorted by time_start /
+// match_id), so we can infer round boundaries: floor(uniqueTeams / 2) matches per
+// round. Only kicks in when a list side has exactly one column (all-null rounds).
+function inferSwissRounds(sides) {
+ return sides.map((side) => {
+ if (side.kind !== 'list' || side.columns.length !== 1) return side
+ const allMatches = side.columns[0].matches
+ if (!allMatches.length) return side
+ const teams = new Set()
+ allMatches.forEach((m) => {
+ if (m.team_a_name) teams.add(m.team_a_name)
+ if (m.team_b_name) teams.add(m.team_b_name)
+ })
+ const matchesPerRound = Math.floor(teams.size / 2)
+ if (matchesPerRound <= 0 || allMatches.length <= matchesPerRound) return side
+ const columns = []
+ for (let i = 0; i < allMatches.length; i += matchesPerRound) {
+ const roundNum = columns.length + 1
+ columns.push({
+ id: `${side.key}:inferred:${roundNum}`,
+ label: `Round ${roundNum}`,
+ matches: allMatches.slice(i, i + matchesPerRound),
+ })
+ }
+ return { ...side, columns }
+ })
+}
+
function TournamentDetailPage({ tournamentId, navigate }) {
const [state, setState] = useState({ status: 'loading', data: null, error: null })
@@ -5365,7 +5454,10 @@ function TournamentDetailPage({ tournamentId, navigate }) {
const data = state.data
const matches = useMemo(() => data?.matches || [], [data])
const format = useMemo(() => tournamentFormatMeta(data?.format, matches), [data?.format, matches])
- const { sides } = useMemo(() => buildBracket(matches), [matches])
+ const { sides } = useMemo(() => {
+ const result = buildBracket(matches)
+ return { ...result, sides: inferSwissRounds(result.sides) }
+ }, [matches])
const bracketSides = sides.filter((side) => side.kind === 'bracket')
const listSides = sides.filter((side) => side.kind === 'list')
const standings = data?.standings || []
@@ -5425,7 +5517,7 @@ function TournamentDetailPage({ tournamentId, navigate }) {
{bracketSides.length ?
Group stage
: null}
{hasStandings ? : null}
- {listSides.length ? : null}
+ {listSides.length ? : null}
) : null}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 5617017..c5e99ed 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -884,6 +884,101 @@ h3 {
margin-left: calc(50% - 42.5vw);
}
+.bracket-fs-container {
+ position: relative;
+}
+
+.bracket-fs-container:fullscreen {
+ display: flex;
+ flex-direction: column;
+ background-color: var(--color-bg);
+}
+
+.bracket-fs-container:fullscreen .bracket-viewport {
+ flex: 1;
+ max-height: none;
+}
+
+.bracket-fs-btn {
+ position: absolute;
+ top: 0.5rem;
+ right: 0.5rem;
+ z-index: 10;
+ padding: 0.25rem 0.5rem;
+ border: 1px solid var(--color-border);
+ border-radius: 0.375rem;
+ background: color-mix(in srgb, var(--color-surface) 85%, transparent);
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: var(--color-text-muted);
+ cursor: pointer;
+ transition: color 0.15s;
+ line-height: 1;
+}
+
+.bracket-fs-btn:hover {
+ color: var(--color-text);
+}
+
+/* List-style column inside the bracket viewport (Swiss / group rounds).
+ Label sits on the left as a vertical strip; cards fill the right. */
+.tournament-list-column {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ flex-direction: row;
+ align-items: flex-start;
+ gap: 1rem;
+ min-width: 440px;
+}
+
+.tournament-list-column.is-collapsed {
+ min-width: 0;
+}
+
+.tournament-list-round-btn {
+ writing-mode: vertical-lr;
+ align-self: stretch;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.5rem 0.375rem;
+ border-right: 1px solid var(--color-border);
+ font-size: 0.65rem;
+ font-weight: 700;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--color-text-muted);
+ cursor: pointer;
+ transition: color 0.15s, border-color 0.15s;
+ white-space: nowrap;
+}
+
+.tournament-list-round-btn:hover {
+ color: var(--color-text);
+ border-color: var(--color-text-muted);
+}
+
+/* Caret resets to horizontal so it renders upright inside the vertical label. */
+.tournament-list-round-caret {
+ writing-mode: horizontal-tb;
+ display: inline-block;
+ font-size: 1rem;
+ line-height: 1;
+ transition: transform 0.18s ease;
+ margin-top: 0.4rem;
+}
+
+.tournament-list-round-caret.is-collapsed {
+ transform: rotate(-90deg);
+}
+
+/* Divider between round columns. */
+.tournament-list-column:not(:last-child) {
+ border-right: 1px solid var(--color-border);
+ padding-right: 1rem;
+}
+
.bracket-caret {
display: inline-block;
font-size: 1.35rem;
diff --git a/vite.config.js b/vite.config.js
index a38239b..b84914c 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -28,6 +28,9 @@ function isAllowedApiUrl(req) {
if (req.method !== 'GET' && req.method !== 'HEAD') return false
+ if (url.pathname === '/api/tss/tournaments') return true
+ if (/^\/api\/tss\/tournaments\/[^/]+$/.test(url.pathname)) return true
+
if (url.pathname === '/api/tss/leaderboard/teams') {
const keys = [...params.keys()]
const limit = Number(params.get('limit') || 100)