skeletons, tournament stats, polish, bug fixes

This commit is contained in:
yohlo
2025-09-23 14:48:04 -05:00
parent 7ff26229d9
commit 7441d1ac58
36 changed files with 990 additions and 457 deletions

View File

@@ -0,0 +1,285 @@
import { useMemo, memo } from "react";
import {
Stack,
Text,
Group,
UnstyledButton,
Container,
Box,
Center,
ThemeIcon,
Divider,
} from "@mantine/core";
import { Tournament } from "@/features/tournaments/types";
import { CrownIcon, MedalIcon, TreeStructureIcon } from "@phosphor-icons/react";
import Avatar from "@/components/avatar";
import ListLink from "@/components/list-link";
interface TournamentStatsProps {
tournament: Tournament;
}
export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
const matches = tournament.matches || [];
const nonByeMatches = useMemo(() =>
matches.filter((match) => !(match.status === 'tbd' && match.bye === true)),
[matches]
);
const isComplete = useMemo(() =>
nonByeMatches.length > 0 && nonByeMatches.every((match) => match.status === 'ended'),
[nonByeMatches]
);
const sortedTeamStats = useMemo(() => {
return [...(tournament.team_stats || [])].sort((a, b) => {
if (b.wins !== a.wins) {
return b.wins - a.wins;
}
return b.total_cups_made - a.total_cups_made;
});
}, [tournament.team_stats]);
const renderPodium = () => {
if (!isComplete || !tournament.first_place) {
return (
<Box p="md">
<Center>
<Text c="dimmed" size="sm">
Podium will appear here when the tournament is over
</Text>
</Center>
</Box>
);
}
return (
<Stack gap="xs" px="md">
{tournament.first_place && (
<Group
gap="md"
p="md"
style={{
backgroundColor: 'var(--mantine-color-yellow-light)',
borderRadius: 'var(--mantine-radius-md)',
border: '3px solid var(--mantine-color-yellow-outline)',
boxShadow: 'var(--mantine-shadow-md)',
}}
>
<ThemeIcon size="xl" color="yellow" variant="light" radius="xl">
<CrownIcon size={24} />
</ThemeIcon>
<Stack gap={4} style={{ flex: 1 }}>
<Text size="md" fw={600}>
{tournament.first_place.name}
</Text>
<Group gap="xs">
{tournament.first_place.players?.map((player) => (
<Text key={player.id} size="sm" c="dimmed">
{player.first_name} {player.last_name}
</Text>
))}
</Group>
</Stack>
</Group>
)}
{tournament.second_place && (
<Group
gap="md"
p="xs"
style={{
backgroundColor: 'var(--mantine-color-default)',
borderRadius: 'var(--mantine-radius-md)',
border: '2px solid var(--mantine-color-default-border)',
boxShadow: 'var(--mantine-shadow-sm)',
}}
>
<ThemeIcon size="lg" color="gray" variant="light" radius="xl">
<MedalIcon size={20} />
</ThemeIcon>
<Stack gap={4} style={{ flex: 1 }}>
<Text size="sm" fw={600}>
{tournament.second_place.name}
</Text>
<Group gap="xs">
{tournament.second_place.players?.map((player) => (
<Text key={player.id} size="xs" c="dimmed">
{player.first_name} {player.last_name}
</Text>
))}
</Group>
</Stack>
</Group>
)}
{tournament.third_place && (
<Group
gap="md"
p="xs"
style={{
backgroundColor: 'var(--mantine-color-orange-light)',
borderRadius: 'var(--mantine-radius-md)',
border: '2px solid var(--mantine-color-orange-outline)',
boxShadow: 'var(--mantine-shadow-sm)',
}}
>
<ThemeIcon size="lg" color="orange" variant="light" radius="xl">
<MedalIcon size={18} />
</ThemeIcon>
<Stack gap={4} style={{ flex: 1 }}>
<Text size="sm" fw={600}>
{tournament.third_place.name}
</Text>
<Group gap="xs">
{tournament.third_place.players?.map((player) => (
<Text key={player.id} size="xs" c="dimmed">
{player.first_name} {player.last_name}
</Text>
))}
</Group>
</Stack>
</Group>
)}
</Stack>
);
};
const teamStatsWithCalculations = useMemo(() => {
return sortedTeamStats.map((stat, index) => ({
...stat,
index,
winPercentage: stat.matches > 0 ? (stat.wins / stat.matches) * 100 : 0,
avgCupsPerMatch: stat.matches > 0 ? stat.total_cups_made / stat.matches : 0,
}));
}, [sortedTeamStats]);
const renderTeamStatsTable = () => {
if (!teamStatsWithCalculations.length) {
return (
<Box p="md">
<Center>
<Text c="dimmed" size="sm">
No stats available yet
</Text>
</Center>
</Box>
);
}
return (
<Stack gap={0}>
<Text px="md" size="lg" fw={600}>Results</Text>
{teamStatsWithCalculations.map((stat) => {
return (
<Box key={stat.id}>
<UnstyledButton
w="100%"
p="md"
style={{
borderRadius: 0,
transition: "background-color 0.15s ease",
}}
styles={{
root: {
'&:hover': {
backgroundColor: 'var(--mantine-color-gray-0)',
},
},
}}
>
<Group justify="space-between" align="center" w="100%">
<Group gap="sm" align="center">
<Avatar name={stat.team_name} size={40} radius="sm" />
<Stack gap={2}>
<Group gap='xs'>
<Text size="xs" c="dimmed">
#{stat.index + 1}
</Text>
<Text size="sm" fw={600}>
{stat.team_name}
</Text>
{stat.index === 0 && isComplete && (
<ThemeIcon size="xs" color="yellow" variant="light" radius="xl">
<CrownIcon size={12} />
</ThemeIcon>
)}
</Group>
<Group gap="md" ta="center">
<Stack gap={0}>
<Text size="xs" c="dimmed" fw={700}>
W
</Text>
<Text size="xs" c="dimmed">
{stat.wins}
</Text>
</Stack>
<Stack gap={0}>
<Text size="xs" c="dimmed" fw={700}>
L
</Text>
<Text size="xs" c="dimmed">
{stat.losses}
</Text>
</Stack>
<Stack gap={0}>
<Text size="xs" c="dimmed" fw={700}>
W%
</Text>
<Text size="xs" c="dimmed">
{stat.winPercentage.toFixed(1)}%
</Text>
</Stack>
<Stack gap={0}>
<Text size="xs" c="dimmed" fw={700}>
AVG
</Text>
<Text size="xs" c="dimmed">
{stat.avgCupsPerMatch.toFixed(1)}
</Text>
</Stack>
<Stack gap={0}>
<Text size="xs" c="dimmed" fw={700}>
CF
</Text>
<Text size="xs" c="dimmed">
{stat.total_cups_made}
</Text>
</Stack>
<Stack gap={0}>
<Text size="xs" c="dimmed" fw={700}>
CA
</Text>
<Text size="xs" c="dimmed">
{stat.total_cups_against}
</Text>
</Stack>
</Group>
</Stack>
</Group>
</Group>
</UnstyledButton>
{stat.index < teamStatsWithCalculations.length - 1 && <Divider />}
</Box>
);
})}
</Stack>
);
};
return (
<Container size="100%" px={0}>
<Stack gap="md">
{renderPodium()}
<ListLink
label={`View Bracket`}
to={`/tournaments/${tournament.id}/bracket`}
Icon={TreeStructureIcon}
/>
{renderTeamStatsTable()}
</Stack>
</Container>
);
});
TournamentStats.displayName = 'TournamentStats';