1 Commits

Author SHA1 Message Date
yohlo
e67f6b073c ready for regionals
All checks were successful
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 44s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m46s
2026-03-07 01:22:21 -06:00
23 changed files with 530 additions and 391 deletions

View File

@@ -122,12 +122,25 @@ export const Route = createRootRouteWithContext<{
context.queryClient, context.queryClient,
playerQueries.auth() playerQueries.auth()
); );
console.log('__root beforeLoad auth data:', auth);
return { auth }; return { auth };
} catch (error: any) { } catch (error: any) {
console.error('__root beforeLoad error:', error); if (typeof window !== 'undefined') {
const { doesSessionExist, attemptRefreshingSession } = await import('supertokens-web-js/recipe/session');
const sessionExists = await doesSessionExist();
if (sessionExists) {
try {
await attemptRefreshingSession();
const auth = await ensureServerQueryData(
context.queryClient,
playerQueries.auth()
);
return { auth };
} catch {
return {};
}
}
}
return {}; return {};
} }
}, },

View File

@@ -5,14 +5,10 @@ import { Flex, Loader } from "@mantine/core";
export const Route = createFileRoute("/_authed")({ export const Route = createFileRoute("/_authed")({
beforeLoad: ({ context }) => { beforeLoad: ({ context }) => {
console.log('_authed beforeLoad context:', context.auth);
if (!context.auth?.user) { if (!context.auth?.user) {
console.log('_authed: No user in context, redirecting to login');
throw redirect({ to: "/login" }); throw redirect({ to: "/login" });
} }
console.log('_authed: User found, allowing access');
return { return {
auth: { auth: {
...context.auth, ...context.auth,

View File

@@ -107,6 +107,7 @@ function RouteComponent() {
tournamentId={tournament.id} tournamentId={tournament.id}
hasKnockoutBracket={knockoutBracketPopulated} hasKnockoutBracket={knockoutBracketPopulated}
isRegional={tournament.regional} isRegional={tournament.regional}
groupConfig={tournament.group_config}
/> />
<Divider /> <Divider />
<div> <div>
@@ -122,6 +123,7 @@ function RouteComponent() {
tournamentId={tournament.id} tournamentId={tournament.id}
hasKnockoutBracket={knockoutBracketPopulated} hasKnockoutBracket={knockoutBracketPopulated}
isRegional={tournament.regional} isRegional={tournament.regional}
groupConfig={tournament.group_config}
/> />
) : ( ) : (
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} /> <BracketView bracket={bracket} showControls groupConfig={tournament.group_config} />

View File

@@ -40,6 +40,7 @@ function RouteComponent() {
groups={tournament.groups || []} groups={tournament.groups || []}
matches={tournament.matches || []} matches={tournament.matches || []}
isRegional={tournament.regional} isRegional={tournament.regional}
groupConfig={tournament.group_config}
/> />
</Container> </Container>
); );

View File

@@ -1,26 +1,14 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import FullScreenLoader from '@/components/full-screen-loader' import FullScreenLoader from '@/components/full-screen-loader'
import { refreshManager } from '@/lib/supertokens/refresh-manager' import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session'
import { resetRefreshFlag, getOrCreateRefreshPromise } from '@/lib/supertokens/client'
import { logger } from '@/lib/supertokens' import { logger } from '@/lib/supertokens'
export const Route = createFileRoute('/refresh-session')({ export const Route = createFileRoute('/refresh-session')({
component: RouteComponent, component: RouteComponent,
}) })
function clearSuperTokensCookies() {
const cookieNames = ['sAccessToken', 'sRefreshToken', 'sIdRefreshToken', 'sFrontToken'];
const cookieDomain = (window as any).__COOKIE_DOMAIN__ || undefined;
cookieNames.forEach(name => {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
if (cookieDomain) {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${cookieDomain}`;
}
});
}
function RouteComponent() { function RouteComponent() {
const hasAttemptedRef = useRef(false); const hasAttemptedRef = useRef(false);
@@ -32,17 +20,9 @@ function RouteComponent() {
try { try {
logger.info("Refresh session route: starting refresh"); logger.info("Refresh session route: starting refresh");
const cookies = document.cookie.split(';'); const refreshed = await getOrCreateRefreshPromise(async () => {
const accessTokenCookies = cookies.filter(c => c.trim().startsWith('sAccessToken=')); return await attemptRefreshingSession();
});
if (accessTokenCookies.length > 1) {
logger.warn(`Found ${accessTokenCookies.length} access tokens, clearing all before refresh`);
clearSuperTokensCookies();
await new Promise(resolve => setTimeout(resolve, 100));
}
const refreshed = await refreshManager.refresh();
if (refreshed) { if (refreshed) {
logger.info("Refresh session route: refresh successful"); logger.info("Refresh session route: refresh successful");

View File

@@ -1,11 +1,14 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { refreshManager } from '@/lib/supertokens/refresh-manager'; import { useNavigate } from '@tanstack/react-router';
import { doesSessionExist } from 'supertokens-web-js/recipe/session';
import { getOrCreateRefreshPromise } from '@/lib/supertokens/client';
import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session';
import { logger } from '@/lib/supertokens'; import { logger } from '@/lib/supertokens';
import { ensureSuperTokensFrontend } from '@/lib/supertokens/client';
export function SessionMonitor() { export function SessionMonitor() {
const navigate = useNavigate();
const lastRefreshTimeRef = useRef<number>(0); const lastRefreshTimeRef = useRef<number>(0);
const REFRESH_COOLDOWN = 5 * 1000; const REFRESH_COOLDOWN = 30 * 1000;
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
@@ -19,27 +22,23 @@ export function SessionMonitor() {
} }
const now = Date.now(); const now = Date.now();
const timeSinceLastRefresh = now - lastRefreshTimeRef.current; if (now - lastRefreshTimeRef.current < REFRESH_COOLDOWN) {
logger.info('Session monitor: skipping refresh (cooldown)');
if (timeSinceLastRefresh < REFRESH_COOLDOWN) {
logger.info(`Session monitor: skipping refresh (refreshed ${timeSinceLastRefresh}ms ago)`);
return; return;
} }
try { try {
ensureSuperTokensFrontend();
const { doesSessionExist } = await import('supertokens-web-js/recipe/session');
const sessionExists = await doesSessionExist(); const sessionExists = await doesSessionExist();
if (!sessionExists) { if (!sessionExists) {
logger.info('Session monitor: no session exists, skipping refresh'); logger.info('Session monitor: no session exists, skipping refresh');
return; return;
} }
logger.info('Session monitor: tab became visible, checking session freshness'); logger.info('Session monitor: tab became visible, refreshing session');
const refreshed = await refreshManager.refresh(); const refreshed = await getOrCreateRefreshPromise(async () => {
return await attemptRefreshingSession();
});
if (refreshed) { if (refreshed) {
lastRefreshTimeRef.current = Date.now(); lastRefreshTimeRef.current = Date.now();
@@ -47,17 +46,19 @@ export function SessionMonitor() {
} else { } else {
logger.warn('Session monitor: refresh returned false'); logger.warn('Session monitor: refresh returned false');
} }
} catch (error: any) { } catch (error) {
logger.error('Session monitor: error refreshing session', error?.message); logger.error('Session monitor: error refreshing session', error);
} }
}; };
handleVisibilityChange();
document.addEventListener('visibilitychange', handleVisibilityChange); document.addEventListener('visibilitychange', handleVisibilityChange);
return () => { return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange); document.removeEventListener('visibilitychange', handleVisibilityChange);
}; };
}, []); }, [navigate]);
return null; return null;
} }

View File

@@ -38,7 +38,7 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupC
</Text> </Text>
<Bracket rounds={bracket.winners} orders={orders} showControls={showControls} groupConfig={groupConfig} /> <Bracket rounds={bracket.winners} orders={orders} showControls={showControls} groupConfig={groupConfig} />
</div> </div>
{bracket.losers && ( {bracket.losers && bracket.losers.length > 0 && bracket.losers.some(round => round.length > 0) && (
<div> <div>
<Text fw={600} size="md" m={16}> <Text fw={600} size="md" m={16}>
Losers Bracket Losers Bracket

View File

@@ -40,6 +40,15 @@ export const MatchCard: React.FC<MatchCardProps> = ({
const numGroups = groupConfig.num_groups; const numGroups = groupConfig.num_groups;
const advancePerGroup = groupConfig.advance_per_group; const advancePerGroup = groupConfig.advance_per_group;
const totalQualifiedTeams = numGroups * advancePerGroup;
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(totalQualifiedTeams)));
const wildcardsNeeded = nextPowerOf2 - totalQualifiedTeams;
if (seed > totalQualifiedTeams && wildcardsNeeded > 0) {
const wildcardNumber = seed - totalQualifiedTeams;
return `Wildcard ${wildcardNumber}`;
}
const pairIndex = Math.floor((seed - 1) / 2); const pairIndex = Math.floor((seed - 1) / 2);
const isFirstInPair = (seed - 1) % 2 === 0; const isFirstInPair = (seed - 1) % 2 === 0;

View File

@@ -270,15 +270,120 @@ async function populateKnockoutBracketInternal(tournamentId: string, groupConfig
const orderedTeamIds: string[] = []; const orderedTeamIds: string[] = [];
const maxRank = groupConfig.advance_per_group; const maxRank = groupConfig.advance_per_group;
const numGroups = groupConfig.num_groups;
for (let rank = 1; rank <= maxRank; rank++) { const teamsByGroup: string[][] = [];
const teamsAtRank = qualifiedTeams for (let g = 0; g < numGroups; g++) {
.filter(t => t.rank === rank) teamsByGroup[g] = [];
.sort((a, b) => a.groupOrder - b.groupOrder);
orderedTeamIds.push(...teamsAtRank.map(t => t.teamId));
} }
logger.info('Ordered team IDs', { orderedTeamIds }); for (const qualified of qualifiedTeams) {
teamsByGroup[qualified.groupOrder][qualified.rank - 1] = qualified.teamId;
}
const totalQualifiedTeams = numGroups * maxRank;
for (let i = 0; i < totalQualifiedTeams / 2; i++) {
const group1 = i % numGroups;
const rankIndex1 = Math.floor(i / numGroups);
const group2 = (i + 1) % numGroups;
const rankIndex2 = maxRank - 1 - rankIndex1;
const team1 = teamsByGroup[group1]?.[rankIndex1];
const team2 = teamsByGroup[group2]?.[rankIndex2];
if (team1) orderedTeamIds.push(team1);
if (team2) orderedTeamIds.push(team2);
}
const knockoutTeamCount = orderedTeamIds.length;
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(knockoutTeamCount)));
const wildcardsNeeded = nextPowerOf2 - knockoutTeamCount;
if (wildcardsNeeded > 0) {
logger.info('Wildcards needed', { knockoutTeamCount, nextPowerOf2, wildcardsNeeded });
const allNonQualifiedTeams: Array<{ teamId: string; wins: number; losses: number; cups_for: number; cups_against: number; cup_differential: number }> = [];
const qualifiedTeamIds = new Set(qualifiedTeams.map(t => t.teamId));
for (const group of groups) {
const groupMatches = await pbAdmin.getMatchesByGroup(group.id);
const completedMatches = groupMatches.filter(m => m.status === "ended");
const standings = new Map<string, { teamId: string; wins: number; losses: number; cups_for: number; cups_against: number; cup_differential: number }>();
for (const team of group.teams || []) {
const teamId = typeof team === 'string' ? team : team.id;
if (qualifiedTeamIds.has(teamId)) continue;
standings.set(teamId, {
teamId,
wins: 0,
losses: 0,
cups_for: 0,
cups_against: 0,
cup_differential: 0,
});
}
for (const match of completedMatches) {
if (!match.home || !match.away) continue;
const homeStanding = standings.get(match.home.id);
const awayStanding = standings.get(match.away.id);
if (homeStanding) {
homeStanding.cups_for += match.home_cups;
homeStanding.cups_against += match.away_cups;
homeStanding.cup_differential = homeStanding.cups_for - homeStanding.cups_against;
if (match.home_cups > match.away_cups) {
homeStanding.wins++;
} else {
homeStanding.losses++;
}
}
if (awayStanding) {
awayStanding.cups_for += match.away_cups;
awayStanding.cups_against += match.home_cups;
awayStanding.cup_differential = awayStanding.cups_for - awayStanding.cups_against;
if (match.away_cups > match.home_cups) {
awayStanding.wins++;
} else {
awayStanding.losses++;
}
}
}
allNonQualifiedTeams.push(...Array.from(standings.values()));
}
allNonQualifiedTeams.sort((a, b) => {
if (b.wins !== a.wins) return b.wins - a.wins;
if (b.cup_differential !== a.cup_differential) return b.cup_differential - a.cup_differential;
if (b.cups_for !== a.cups_for) return b.cups_for - a.cups_for;
return a.teamId.localeCompare(b.teamId);
});
const wildcardTeams = allNonQualifiedTeams.slice(0, wildcardsNeeded);
const wildcardTeamIds = wildcardTeams.map(t => t.teamId);
orderedTeamIds.push(...wildcardTeamIds);
logger.info('Added wildcard teams', {
wildcardTeams: wildcardTeams.map(t => ({
teamId: t.teamId,
wins: t.wins,
cupDiff: t.cup_differential,
cupsFor: t.cups_for
}))
});
}
logger.info('Ordered team IDs (with wildcards)', { orderedTeamIds, totalTeams: orderedTeamIds.length });
const tournament = await pbAdmin.getTournament(tournamentId); const tournament = await pbAdmin.getTournament(tournamentId);
const knockoutMatches = (tournament.matches || []) const knockoutMatches = (tournament.matches || [])

View File

@@ -1,5 +1,8 @@
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks"; import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe, getPlayerStats, getAllPlayerStats, getPlayerMatches, getUnenrolledPlayers, getPlayersActivity } from "./server"; import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe, getPlayerStats, getAllPlayerStats, getPlayerMatches, getUnenrolledPlayers, getPlayersActivity } from "./server";
import { logger } from '@/lib/supertokens';
let queryRefreshRedirect: Promise<void> | null = null;
export const playerKeys = { export const playerKeys = {
auth: ['auth'], auth: ['auth'],
@@ -61,7 +64,37 @@ export const useMe = () => {
staleTime: 30 * 1000, staleTime: 30 * 1000,
refetchOnMount: false, refetchOnMount: false,
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
retry: 3, retry: (failureCount, error: any) => {
if (error?.response?.status === 401) {
const errorData = error?.response?.data;
if (errorData?.error === 'SESSION_REFRESH_REQUIRED') {
logger.warn("Query detected SESSION_REFRESH_REQUIRED");
if (!queryRefreshRedirect) {
const currentUrl = window.location.pathname + window.location.search;
logger.info("Query initiating refresh redirect to:", currentUrl);
queryRefreshRedirect = new Promise<void>((resolve) => {
setTimeout(() => {
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`;
resolve();
}, 100);
});
queryRefreshRedirect.finally(() => {
setTimeout(() => {
queryRefreshRedirect = null;
}, 1000);
});
} else {
logger.info("Query: refresh redirect already in progress");
}
return false;
}
}
return failureCount < 3;
},
}, },
}); });
}; };

View File

@@ -26,34 +26,13 @@ export const fetchMe = createServerFn()
phone: context.phone phone: context.phone
}; };
} catch (error: any) { } catch (error: any) {
logger.info("FetchMe: Error caught", { // logger.info("FetchMe: Session error", error)
message: error?.message, if (error?.response?.status === 401) {
isResponse: error instanceof Response,
status: error instanceof Response ? error.status : error?.response?.status
});
if (error instanceof Response) {
const status = error.status;
if (status === 440) {
logger.info("FetchMe: Session refresh required (440)");
throw error;
}
}
if (error?.response?.status === 440 || error?.response?.status === 401) {
const errorData = error?.response?.data; const errorData = error?.response?.data;
if (errorData?.error === "SESSION_REFRESH_REQUIRED") { if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
logger.info("FetchMe: Session refresh required (legacy)");
throw error; throw error;
} }
} }
if (error?.message === "Unauthenticated") {
logger.info("FetchMe: No authenticated user (expected when not logged in)");
return { user: undefined, roles: [], metadata: {}, phone: undefined };
}
logger.warn("FetchMe: Unexpected error, returning default", error);
return { user: undefined, roles: [], metadata: {}, phone: undefined }; return { user: undefined, roles: [], metadata: {}, phone: undefined };
} }
}) })

View File

@@ -1,8 +1,8 @@
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import { Stack, Text, Card, Group as MantineGroup, Box, SimpleGrid, Tabs, Collapse, ActionIcon, Button, Alert } from "@mantine/core"; import { Stack, Text, Card, Group as MantineGroup, Box, SimpleGrid, Tabs, Collapse, ActionIcon, Button, Alert, Badge } from "@mantine/core";
import { CaretCircleDownIcon, CaretCircleUpIcon } from "@phosphor-icons/react"; import { CaretCircleDownIcon, CaretCircleUpIcon } from "@phosphor-icons/react";
import { Match } from "@/features/matches/types"; import { Match } from "@/features/matches/types";
import { Group } from "../types"; import { Group, GroupConfig } from "../types";
import GroupMatchCard from "./group-match-card"; import GroupMatchCard from "./group-match-card";
import TeamAvatar from "@/components/team-avatar"; import TeamAvatar from "@/components/team-avatar";
import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation"; import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation";
@@ -17,6 +17,7 @@ interface GroupStageViewProps {
tournamentId?: string; tournamentId?: string;
hasKnockoutBracket?: boolean; hasKnockoutBracket?: boolean;
isRegional?: boolean; isRegional?: boolean;
groupConfig?: GroupConfig;
} }
interface TeamStanding { interface TeamStanding {
@@ -37,6 +38,7 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
tournamentId, tournamentId,
hasKnockoutBracket, hasKnockoutBracket,
isRegional, isRegional,
groupConfig,
}) => { }) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [expandedTeams, setExpandedTeams] = useState<Record<string, boolean>>({}); const [expandedTeams, setExpandedTeams] = useState<Record<string, boolean>>({});
@@ -239,6 +241,57 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
}); });
}; };
const allGroupStandings = useMemo(() => {
return sortedGroups.map((group) => ({
groupId: group.id,
groupOrder: group.order,
standings: getTeamStandings(group.id, group.teams || []),
}));
}, [sortedGroups, matchesByGroup]);
const advancingTeams = useMemo(() => {
if (!groupConfig) return { qualifiedTeams: new Set<string>(), wildcardTeams: new Set<string>() };
const advancePerGroup = groupConfig.advance_per_group;
const qualifiedTeams = new Set<string>();
const wildcardTeams = new Set<string>();
allGroupStandings.forEach(({ standings }) => {
standings.slice(0, advancePerGroup).forEach((standing) => {
qualifiedTeams.add(standing.teamId);
});
});
const totalQualified = qualifiedTeams.size;
const knockoutTeamCount = totalQualified;
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(knockoutTeamCount)));
const wildcardsNeeded = nextPowerOf2 - knockoutTeamCount;
if (wildcardsNeeded > 0) {
const allNonQualifiedTeams: TeamStanding[] = [];
allGroupStandings.forEach(({ standings }) => {
standings.slice(advancePerGroup).forEach((standing) => {
allNonQualifiedTeams.push(standing);
});
});
allNonQualifiedTeams.sort((a, b) => {
if (b.wins !== a.wins) return b.wins - a.wins;
if (b.cupDifference !== a.cupDifference) return b.cupDifference - a.cupDifference;
if (b.cupsFor !== a.cupsFor) return b.cupsFor - a.cupsFor;
return a.teamId.localeCompare(b.teamId);
});
allNonQualifiedTeams.slice(0, wildcardsNeeded).forEach((standing) => {
wildcardTeams.add(standing.teamId);
});
}
return { qualifiedTeams, wildcardTeams };
}, [allGroupStandings, groupConfig]);
if (sortedGroups.length === 0) { if (sortedGroups.length === 0) {
return ( return (
<Box p="md"> <Box p="md">
@@ -321,44 +374,64 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
<Collapse in={expandedTeams[group.id]}> <Collapse in={expandedTeams[group.id]}>
<Stack gap={0}> <Stack gap={0}>
{standings.length > 0 ? ( {standings.length > 0 ? (
standings.map((standing, index) => ( standings.map((standing, index) => {
<MantineGroup const isQualified = advancingTeams.qualifiedTeams.has(standing.teamId);
key={standing.teamId} const isWildcard = advancingTeams.wildcardTeams.has(standing.teamId);
gap="sm" const isAdvancing = isQualified || isWildcard;
align="center"
wrap="nowrap" return (
px="md" <MantineGroup
py="xs" key={standing.teamId}
style={{ gap="sm"
borderTop: index > 0 ? '1px solid var(--mantine-color-default-border)' : 'none', align="center"
}} wrap="nowrap"
> px="md"
<Text size="sm" fw={700} c="dimmed" w={24} ta="center"> py="xs"
{index + 1} style={{
</Text> borderTop: index > 0 ? '1px solid var(--mantine-color-default-border)' : 'none',
<TeamAvatar team={standing.team} size={28} radius="sm" isRegional={isRegional} /> backgroundColor: isAdvancing ? 'var(--mantine-primary-color-light)' : undefined,
<Text size="sm" fw={500} style={{ flex: 1 }} lineClamp={1}> borderLeft: isAdvancing ? '3px solid var(--mantine-primary-color-filled)' : '3px solid transparent',
{standing.teamName} }}
</Text> >
<MantineGroup gap="xs" wrap="nowrap"> <Text size="sm" fw={700} c="dimmed" w={24} ta="center">
<Text size="xs" c="dimmed" fw={500} miw={35} ta="center"> {index + 1}
{standing.wins}-{standing.losses}
</Text>
<Text
size="xs"
c={standing.cupDifference > 0 ? "green" : standing.cupDifference < 0 ? "red" : "dimmed"}
fw={600}
miw={30}
ta="center"
>
{standing.cupDifference > 0 ? '+' : ''}{standing.cupDifference}
</Text>
<Text size="xs" c="dimmed" fw={400} miw={40} ta="center">
{standing.cupsFor}/{standing.cupsAgainst}
</Text> </Text>
<TeamAvatar team={standing.team} size={28} radius="sm" isRegional={isRegional} />
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} lineClamp={1}>
{standing.teamName}
</Text>
</Box>
<MantineGroup gap="xs" wrap="nowrap">
{isWildcard && (
<Badge size="xs" color="yellow" variant="light">
WC
</Badge>
)}
{isQualified && (
<Badge size="xs" variant="light">
Q
</Badge>
)}
<Text size="xs" c="dimmed" fw={500} miw={35} ta="center">
{standing.wins}-{standing.losses}
</Text>
<Text
size="xs"
c={standing.cupDifference > 0 ? "green" : standing.cupDifference < 0 ? "red" : "dimmed"}
fw={600}
miw={30}
ta="center"
>
{standing.cupDifference > 0 ? '+' : ''}{standing.cupDifference}
</Text>
<Text size="xs" c="dimmed" fw={400} miw={40} ta="center">
{standing.cupsFor}/{standing.cupsAgainst}
</Text>
</MantineGroup>
</MantineGroup> </MantineGroup>
</MantineGroup> );
)) })
) : ( ) : (
<Text size="sm" c="dimmed" ta="center" py="md"> <Text size="sm" c="dimmed" ta="center" py="md">
No teams assigned No teams assigned

View File

@@ -80,12 +80,16 @@ const SetupGroupStage: React.FC<SetupGroupStageProps> = ({
return null; return null;
} }
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(knockoutTeamCount)));
const bracketSize = nextPowerOf2;
const wildcardsNeeded = bracketSize - knockoutTeamCount;
let bracketTemplate: any; let bracketTemplate: any;
if (Object.keys(brackets).includes(knockoutTeamCount.toString())) { if (Object.keys(brackets).includes(bracketSize.toString())) {
bracketTemplate = brackets[knockoutTeamCount as keyof typeof brackets]; bracketTemplate = brackets[bracketSize as keyof typeof brackets];
} else { } else {
try { try {
bracketTemplate = generateSingleEliminationBracket(knockoutTeamCount); bracketTemplate = generateSingleEliminationBracket(bracketSize);
} catch (error) { } catch (error) {
return null; return null;
} }
@@ -113,6 +117,10 @@ const SetupGroupStage: React.FC<SetupGroupStageProps> = ({
seedLabels[seedIndex++] = `${groupName2} ${rankSuffix2}`; seedLabels[seedIndex++] = `${groupName2} ${rankSuffix2}`;
} }
for (let i = 0; i < wildcardsNeeded; i++) {
seedLabels[seedIndex++] = `Wildcard ${i + 1}`;
}
const ordersMap: Record<number, number> = {}; const ordersMap: Record<number, number> = {};
bracketTemplate.winners.forEach((round: any[]) => { bracketTemplate.winners.forEach((round: any[]) => {
round.forEach((match: any) => { round.forEach((match: any) => {
@@ -265,10 +273,11 @@ const SetupGroupStage: React.FC<SetupGroupStageProps> = ({
<Box> <Box>
<Divider mb="lg" /> <Divider mb="lg" />
<Title order={3} ta="center" mb="md"> <Title order={3} ta="center" mb="md">
Knockout Bracket Preview ({knockoutTeamCount} Teams) Knockout Bracket Preview ({selectedConfig?.knockout_size} Teams)
</Title> </Title>
<Text size="sm" c="dimmed" ta="center" mb="lg"> <Text size="sm" c="dimmed" ta="center" mb="lg">
Top {selectedConfig?.advance_per_group} team{selectedConfig?.advance_per_group !== 1 ? 's' : ''} from each group will advance Top {selectedConfig?.advance_per_group} team{selectedConfig?.advance_per_group !== 1 ? 's' : ''} from each group will advance
{selectedConfig?.wildcards_needed ? ` + ${selectedConfig.wildcards_needed} wildcard${selectedConfig.wildcards_needed > 1 ? 's' : ''}` : ''}
</Text> </Text>
<Box <Box
style={{ style={{

View File

@@ -6,7 +6,6 @@ import { logger } from ".";
import { z } from "zod"; import { z } from "zod";
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result"; import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
import { serverFnLoggingMiddleware } from "@/utils/activities"; import { serverFnLoggingMiddleware } from "@/utils/activities";
import { fa } from "zod/v4/locales";
import brackets from "@/features/bracket/utils"; import brackets from "@/features/bracket/utils";
import { MatchInput } from "@/features/matches/types"; import { MatchInput } from "@/features/matches/types";
import { generateSingleEliminationBracket } from "./utils/bracket-generator"; import { generateSingleEliminationBracket } from "./utils/bracket-generator";
@@ -540,10 +539,18 @@ export const generateKnockoutBracket = createServerFn()
} }
const qualifiedTeams: { teamId: string; groupOrder: number; rank: number }[] = []; const qualifiedTeams: { teamId: string; groupOrder: number; rank: number }[] = [];
const allStandings: { standing: GroupStanding; groupOrder: number }[] = [];
for (const group of groups) { for (const group of groups) {
const standings = await calculateGroupStandings(group.id); const standings = await calculateGroupStandings(group.id);
for (let i = 0; i < standings.length; i++) {
allStandings.push({
standing: standings[i],
groupOrder: group.order,
});
}
const topTeams = standings.slice(0, tournament.group_config.advance_per_group); const topTeams = standings.slice(0, tournament.group_config.advance_per_group);
for (const standing of topTeams) { for (const standing of topTeams) {
qualifiedTeams.push({ qualifiedTeams.push({
@@ -582,7 +589,42 @@ export const generateKnockoutBracket = createServerFn()
if (team2) orderedTeamIds.push(team2); if (team2) orderedTeamIds.push(team2);
} }
const teamCount = orderedTeamIds.length; let teamCount = orderedTeamIds.length;
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(teamCount)));
const wildcardsNeeded = nextPowerOf2 - teamCount;
if (wildcardsNeeded > 0) {
const qualifiedTeamIds = new Set(qualifiedTeams.map(t => t.teamId));
const wildcardCandidates = allStandings
.filter(s => !qualifiedTeamIds.has(s.standing.team.id))
.map(s => s.standing);
wildcardCandidates.sort((a, b) => {
if (b.wins !== a.wins) return b.wins - a.wins;
if (b.cup_differential !== a.cup_differential) return b.cup_differential - a.cup_differential;
if (b.cups_for !== a.cups_for) return b.cups_for - a.cups_for;
return a.team.id.localeCompare(b.team.id);
});
const wildcardTeams = wildcardCandidates.slice(0, wildcardsNeeded);
const wildcardTeamIds = wildcardTeams.map(t => t.team.id);
orderedTeamIds.push(...wildcardTeamIds);
teamCount = orderedTeamIds.length;
logger.info('Added wildcard teams to knockout bracket', {
tournamentId: data.tournamentId,
wildcardsNeeded,
wildcardTeams: wildcardTeams.map(t => ({
id: t.team.id,
name: t.team.name,
wins: t.wins,
cupDiff: t.cup_differential,
cupsFor: t.cups_for
}))
});
}
let bracketTemplate: any; let bracketTemplate: any;
if (Object.keys(brackets).includes(teamCount.toString())) { if (Object.keys(brackets).includes(teamCount.toString())) {
@@ -774,14 +816,25 @@ export const generateGroupStage = createServerFn()
} }
const knockoutTeamCount = data.groupConfig.num_groups * data.groupConfig.advance_per_group; const knockoutTeamCount = data.groupConfig.num_groups * data.groupConfig.advance_per_group;
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(knockoutTeamCount)));
const bracketSize = nextPowerOf2;
let bracketTemplate: any; let bracketTemplate: any;
if (Object.keys(brackets).includes(knockoutTeamCount.toString())) { if (Object.keys(brackets).includes(bracketSize.toString())) {
bracketTemplate = brackets[knockoutTeamCount as keyof typeof brackets]; bracketTemplate = brackets[bracketSize as keyof typeof brackets];
} else { } else {
bracketTemplate = generateSingleEliminationBracket(knockoutTeamCount); bracketTemplate = generateSingleEliminationBracket(bracketSize);
} }
logger.info('Creating knockout bracket template', {
tournamentId: data.tournamentId,
knockoutTeamCount,
bracketSize,
wildcardsNeeded: bracketSize - knockoutTeamCount
});
const knockoutMatches: any[] = []; const knockoutMatches: any[] = [];
bracketTemplate.winners.forEach((round: any[]) => { bracketTemplate.winners.forEach((round: any[]) => {

View File

@@ -26,57 +26,63 @@ export function calculateGroupConfigurations(teamCount: number): GroupConfigOpti
const configs: GroupConfigOption[] = []; const configs: GroupConfigOption[] = [];
for (let teamsPerGroup = 3; teamsPerGroup <= Math.min(6, teamCount); teamsPerGroup++) { for (let teamsPerGroup = 3; teamsPerGroup <= Math.min(6, teamCount); teamsPerGroup++) {
const numGroups = Math.floor(teamCount / teamsPerGroup); const numGroupsFloor = Math.floor(teamCount / teamsPerGroup);
const remainder = teamCount % teamsPerGroup; const numGroupsCeil = Math.ceil(teamCount / teamsPerGroup);
if (numGroups < 2) continue; const groupOptions = new Set([numGroupsFloor, numGroupsCeil]);
if (remainder > numGroups) continue; for (const numGroups of groupOptions) {
if (numGroups < 2) continue;
const groupsWithExtra = remainder; const baseTeamsPerGroup = Math.floor(teamCount / numGroups);
const groupsWithExtra = teamCount % numGroups;
const groupsAtBaseSize = numGroups - groupsWithExtra; const minGroupSize = baseTeamsPerGroup;
const minGroupSize = groupsAtBaseSize > 0 ? teamsPerGroup : teamsPerGroup + 1; const maxGroupSize = baseTeamsPerGroup + (groupsWithExtra > 0 ? 1 : 0);
const matchesGuaranteed = minGroupSize - 1;
for (let advancePerGroup = 1; advancePerGroup <= Math.min(3, teamsPerGroup - 1); advancePerGroup++) { if (minGroupSize < 3 || maxGroupSize > 6) continue;
const teamsAdvancing = numGroups * advancePerGroup;
if (teamsAdvancing < 4 || teamsAdvancing > 32) continue; const matchesGuaranteed = minGroupSize - 1;
const knockoutSize = getNextPowerOfTwo(teamsAdvancing); for (let advancePerGroup = 1; advancePerGroup <= Math.min(3, minGroupSize - 1); advancePerGroup++) {
const wildcardsNeeded = knockoutSize - teamsAdvancing; const teamsAdvancing = numGroups * advancePerGroup;
if (wildcardsNeeded > teamsAdvancing / 2) continue; if (teamsAdvancing < 4 || teamsAdvancing > 32) continue;
let totalGroupMatches = 0; const knockoutSize = getNextPowerOfTwo(teamsAdvancing);
for (let i = 0; i < numGroups; i++) { const wildcardsNeeded = knockoutSize - teamsAdvancing;
const groupSize = teamsPerGroup + (i < groupsWithExtra ? 1 : 0);
totalGroupMatches += (groupSize * (groupSize - 1)) / 2; if (wildcardsNeeded > teamsAdvancing / 2) continue;
let totalGroupMatches = 0;
for (let i = 0; i < numGroups; i++) {
const groupSize = baseTeamsPerGroup + (i < groupsWithExtra ? 1 : 0);
totalGroupMatches += (groupSize * (groupSize - 1)) / 2;
}
const description = generateDescription({
num_groups: numGroups,
teams_per_group: baseTeamsPerGroup,
groups_with_extra: groupsWithExtra,
advance_per_group: advancePerGroup,
matches_guaranteed: matchesGuaranteed,
knockout_size: knockoutSize,
wildcards_needed: wildcardsNeeded,
});
configs.push({
num_groups: numGroups,
teams_per_group: baseTeamsPerGroup,
advance_per_group: advancePerGroup,
matches_guaranteed: matchesGuaranteed,
seeding_method: "random",
groups_with_extra: groupsWithExtra,
knockout_size: knockoutSize,
wildcards_needed: wildcardsNeeded,
total_group_matches: totalGroupMatches,
description,
});
} }
const description = generateDescription({
num_groups: numGroups,
teams_per_group: teamsPerGroup,
groups_with_extra: groupsWithExtra,
advance_per_group: advancePerGroup,
matches_guaranteed: matchesGuaranteed,
knockout_size: knockoutSize,
wildcards_needed: wildcardsNeeded,
});
configs.push({
num_groups: numGroups,
teams_per_group: teamsPerGroup,
advance_per_group: advancePerGroup,
matches_guaranteed: matchesGuaranteed,
seeding_method: "random",
groups_with_extra: groupsWithExtra,
knockout_size: knockoutSize,
wildcards_needed: wildcardsNeeded,
total_group_matches: totalGroupMatches,
description,
});
} }
} }

View File

@@ -4,15 +4,34 @@ import Passwordless from "supertokens-web-js/recipe/passwordless";
import { appInfo } from "./config"; import { appInfo } from "./config";
import { logger } from "./"; import { logger } from "./";
let refreshPromise: Promise<boolean> | null = null;
export const resetRefreshFlag = () => { export const resetRefreshFlag = () => {
logger.warn("resetRefreshFlag is deprecated. Use refreshManager.reset() instead."); refreshPromise = null;
}; };
export const getOrCreateRefreshPromise = (refreshFn: () => Promise<boolean>): Promise<boolean> => { export const getOrCreateRefreshPromise = (refreshFn: () => Promise<boolean>): Promise<boolean> => {
logger.warn("getOrCreateRefreshPromise is deprecated. Use refreshManager.refresh() instead."); if (refreshPromise) {
return refreshFn(); logger.info("Reusing existing refresh promise");
return refreshPromise;
}
logger.info("Creating new refresh promise");
refreshPromise = refreshFn()
.then((result) => {
logger.info("Refresh completed successfully:", result);
setTimeout(() => {
refreshPromise = null;
}, 500);
return result;
})
.catch((error) => {
logger.error("Refresh failed:", error);
refreshPromise = null;
throw error;
});
return refreshPromise;
}; };
export const frontendConfig = () => { export const frontendConfig = () => {

View File

@@ -1,77 +0,0 @@
import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session';
import { logger } from './index';
class SessionRefreshManager {
private refreshPromise: Promise<boolean> | null = null;
private redirectPromise: Promise<void> | null = null;
private lastRefreshTime: number = 0;
private readonly MIN_REFRESH_INTERVAL = 1000;
async refresh(): Promise<boolean> {
if (this.refreshPromise) {
logger.info('RefreshManager: Reusing existing refresh promise');
return this.refreshPromise;
}
const timeSinceLastRefresh = Date.now() - this.lastRefreshTime;
if (timeSinceLastRefresh < this.MIN_REFRESH_INTERVAL) {
logger.info(`RefreshManager: Skipping refresh (last refresh ${timeSinceLastRefresh}ms ago)`);
return true;
}
logger.info('RefreshManager: Starting new session refresh');
this.refreshPromise = attemptRefreshingSession()
.then((result) => {
logger.info('RefreshManager: Refresh completed successfully:', result);
this.lastRefreshTime = Date.now();
return result;
})
.catch((error) => {
logger.error('RefreshManager: Refresh failed:', error);
throw error;
})
.finally(() => {
setTimeout(() => {
this.refreshPromise = null;
}, 500);
});
return this.refreshPromise;
}
async redirectToRefresh(currentPath: string): Promise<void> {
if (this.redirectPromise) {
logger.info('RefreshManager: Redirect already in progress, waiting...');
return this.redirectPromise;
}
logger.info('RefreshManager: Initiating refresh redirect to:', currentPath);
this.redirectPromise = new Promise<void>((resolve) => {
setTimeout(() => {
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentPath)}`;
resolve();
}, 100);
});
this.redirectPromise.finally(() => {
setTimeout(() => {
this.redirectPromise = null;
}, 1000);
});
return this.redirectPromise;
}
reset(): void {
logger.info('RefreshManager: Resetting state');
this.refreshPromise = null;
this.redirectPromise = null;
this.lastRefreshTime = 0;
}
getTimeSinceLastRefresh(): number {
return Date.now() - this.lastRefreshTime;
}
}
export const refreshManager = new SessionRefreshManager();

View File

@@ -28,26 +28,6 @@ export const backendConfig = (): TypeInput => {
olderCookieDomain: undefined, olderCookieDomain: undefined,
antiCsrf: process.env.NODE_ENV === "production" ? "VIA_TOKEN" : "NONE", antiCsrf: process.env.NODE_ENV === "production" ? "VIA_TOKEN" : "NONE",
sessionExpiredStatusCode: 440,
invalidClaimStatusCode: 403,
override: {
functions: (originalImplementation) => ({
...originalImplementation,
refreshSession: async (input) => {
logger.info('Backend: Refresh session attempt');
try {
const result = await originalImplementation.refreshSession(input);
logger.info('Backend: Refresh session successful');
return result;
} catch (error) {
logger.error('Backend: Refresh session failed:', error);
throw error;
}
},
}),
},
// Debug only // Debug only
exposeAccessTokenToFrontendInCookieBasedAuth: process.env.NODE_ENV !== "production", exposeAccessTokenToFrontendInCookieBasedAuth: process.env.NODE_ENV !== "production",
}), }),

View File

@@ -1,7 +1,10 @@
import { useMutation, UseMutationOptions } from "@tanstack/react-query"; import { useMutation, UseMutationOptions } from "@tanstack/react-query";
import { ServerResult } from "../types"; import { ServerResult } from "../types";
import toast from '@/lib/sonner' import toast from '@/lib/sonner'
import { handleQueryError } from '../utils/global-error-handler'; import { logger } from '@/lib/supertokens'
let sessionRefreshRedirect: Promise<void> | null = null;
export function useServerMutation<TData, TVariables = unknown>( export function useServerMutation<TData, TVariables = unknown>(
options: Omit<UseMutationOptions<TData, Error, TVariables>, 'mutationFn'> & { options: Omit<UseMutationOptions<TData, Error, TVariables>, 'mutationFn'> & {
@@ -36,7 +39,41 @@ export function useServerMutation<TData, TVariables = unknown>(
return result.data; return result.data;
} catch (error: any) { } catch (error: any) {
await handleQueryError(error); if (error?.response?.status === 401) {
try {
const errorData = typeof error.response.data === 'string'
? JSON.parse(error.response.data)
: error.response.data;
if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
logger.warn("Mutation detected SESSION_REFRESH_REQUIRED");
if (!sessionRefreshRedirect) {
const currentUrl = window.location.pathname + window.location.search;
logger.info("Mutation initiating refresh redirect to:", currentUrl);
sessionRefreshRedirect = new Promise<void>((resolve) => {
setTimeout(() => {
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`;
resolve();
}, 100);
});
sessionRefreshRedirect.finally(() => {
setTimeout(() => {
sessionRefreshRedirect = null;
}, 1000);
});
} else {
logger.info("Mutation: refresh redirect already in progress, waiting...");
await sessionRefreshRedirect;
}
throw new Error("SESSION_REFRESH_REQUIRED");
}
} catch (parseError) {}
}
throw error; throw error;
} }
}, },

View File

@@ -1,7 +1,6 @@
import { QueryKey, useQuery, UseQueryOptions } from "@tanstack/react-query"; import { QueryKey, useQuery, UseQueryOptions } from "@tanstack/react-query";
import { ServerResult } from "../types"; import { ServerResult } from "../types";
import toast from '@/lib/sonner' import toast from '@/lib/sonner'
import { handleQueryError } from '../utils/global-error-handler';
export function useServerQuery<TData>( export function useServerQuery<TData>(
options: { options: {
@@ -18,21 +17,16 @@ export function useServerQuery<TData>(
...queryOptions, ...queryOptions,
queryKey, queryKey,
queryFn: async () => { queryFn: async () => {
try { const result = await queryFn();
const result = await queryFn();
if (!result.success) { if (!result.success) {
if (showErrorToast) { if (showErrorToast) {
toast.error(result.error.userMessage); toast.error(result.error.userMessage);
}
throw new Error(result.error.userMessage);
} }
throw new Error(result.error.userMessage);
return result.data;
} catch (error: any) {
await handleQueryError(error);
throw error;
} }
return result.data;
} }
}); });
} }

View File

@@ -1,7 +1,6 @@
import { QueryKey, UseQueryOptions, useSuspenseQuery } from "@tanstack/react-query"; import { QueryKey, UseQueryOptions, useSuspenseQuery } from "@tanstack/react-query";
import { ServerResult } from "../types"; import { ServerResult } from "../types";
import toast from '@/lib/sonner' import toast from '@/lib/sonner'
import { handleQueryError } from '../utils/global-error-handler';
export function useServerSuspenseQuery<TData>( export function useServerSuspenseQuery<TData>(
options: { options: {
@@ -17,21 +16,16 @@ export function useServerSuspenseQuery<TData>(
...queryOptions, ...queryOptions,
queryKey, queryKey,
queryFn: async () => { queryFn: async () => {
try { const result = await queryFn();
const result = await queryFn();
if (!result.success) { if (!result.success) {
if (showErrorToast) { if (showErrorToast) {
toast.error(result.error.userMessage); toast.error(result.error.userMessage);
}
throw new Error(result.error.userMessage);
} }
throw new Error(result.error.userMessage);
return result.data;
} catch (error: any) {
await handleQueryError(error);
throw error;
} }
return result.data;
} }
}); });

View File

@@ -1,60 +0,0 @@
import { refreshManager } from '@/lib/supertokens/refresh-manager';
import { logger } from '@/lib/supertokens';
export async function handleQueryError(error: any): Promise<void> {
if (typeof window === 'undefined') {
throw error;
}
if (!error || typeof error !== 'object') {
throw error;
}
if (error instanceof Response) {
const status = error.status;
if (status === 440) {
try {
const errorData = await error.json();
if (errorData?.error === 'SESSION_REFRESH_REQUIRED' && errorData?.shouldRetry === true) {
logger.warn('Query detected SESSION_REFRESH_REQUIRED (Response), initiating redirect');
const currentUrl = window.location.pathname + window.location.search;
await refreshManager.redirectToRefresh(currentUrl);
throw new Error('Redirecting to refresh session');
}
} catch (parseError) {
}
}
throw error;
}
const status = error?.response?.status;
if (status === 440) {
try {
let errorData = error?.response?.data;
if (typeof errorData === 'string') {
try {
errorData = JSON.parse(errorData);
} catch {
}
}
if (errorData?.error === 'SESSION_REFRESH_REQUIRED' && errorData?.shouldRetry === true) {
logger.warn('Query detected SESSION_REFRESH_REQUIRED (legacy format), initiating redirect');
const currentUrl = window.location.pathname + window.location.search;
await refreshManager.redirectToRefresh(currentUrl);
throw new Error('Redirecting to refresh session');
}
} catch (parseError) {
}
}
throw error;
}

View File

@@ -110,15 +110,11 @@ export const superTokensFunctionMiddleware = createMiddleware({
throw new Response( throw new Response(
JSON.stringify({ JSON.stringify({
error: "SESSION_REFRESH_REQUIRED", error: "SESSION_REFRESH_REQUIRED",
message: "Session needs to be refreshed", message: "Session needs to be refreshed"
shouldRetry: true
}), }),
{ {
status: 440, status: 401,
headers: { headers: { "Content-Type": "application/json" }
"Content-Type": "application/json",
"X-Session-Expired": "true"
}
} }
); );
} }
@@ -145,15 +141,11 @@ export const superTokensAdminFunctionMiddleware = createMiddleware({
throw new Response( throw new Response(
JSON.stringify({ JSON.stringify({
error: "SESSION_REFRESH_REQUIRED", error: "SESSION_REFRESH_REQUIRED",
message: "Session needs to be refreshed", message: "Session needs to be refreshed"
shouldRetry: true
}), }),
{ {
status: 440, status: 401,
headers: { headers: { "Content-Type": "application/json" }
"Content-Type": "application/json",
"X-Session-Expired": "true"
}
} }
); );
} }