357 lines
11 KiB
TypeScript
357 lines
11 KiB
TypeScript
import { useState, useMemo, useCallback, memo } from "react";
|
|
import {
|
|
Text,
|
|
TextInput,
|
|
Stack,
|
|
Group,
|
|
Box,
|
|
ThemeIcon,
|
|
Container,
|
|
Title,
|
|
Divider,
|
|
UnstyledButton,
|
|
Popover,
|
|
ActionIcon,
|
|
} from "@mantine/core";
|
|
import {
|
|
MagnifyingGlassIcon,
|
|
CaretUpIcon,
|
|
CaretDownIcon,
|
|
ChartBarIcon,
|
|
InfoIcon,
|
|
} from "@phosphor-icons/react";
|
|
import { PlayerStats } from "../types";
|
|
import Avatar from "@/components/avatar";
|
|
import { useNavigate } from "@tanstack/react-router";
|
|
import { useAllPlayerStats } from "../queries";
|
|
|
|
type SortKey = keyof PlayerStats | "mmr";
|
|
type SortDirection = "asc" | "desc";
|
|
|
|
interface SortConfig {
|
|
key: SortKey;
|
|
direction: SortDirection;
|
|
}
|
|
|
|
interface PlayerListItemProps {
|
|
stat: PlayerStats;
|
|
onPlayerClick: (playerId: string) => void;
|
|
mmr: number;
|
|
}
|
|
|
|
const PlayerListItem = memo(({ stat, onPlayerClick, mmr }: PlayerListItemProps) => {
|
|
|
|
return (
|
|
<>
|
|
<UnstyledButton
|
|
w="100%"
|
|
p="md"
|
|
onClick={() => onPlayerClick(stat.id)}
|
|
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.player_name} size={40} />
|
|
<Stack gap={2}>
|
|
<Group gap='xs'>
|
|
<Text size="sm" fw={600}>
|
|
{stat.player_name}
|
|
</Text>
|
|
<Text size="xs" c="dimmed" ta="right">
|
|
{stat.matches} matches
|
|
</Text>
|
|
<Text size="xs" c="dimmed" ta="right">
|
|
{stat.tournaments} tournaments
|
|
</Text>
|
|
</Group>
|
|
<Group gap="md" ta="center">
|
|
<Stack gap={0}>
|
|
<Text size="xs" c="dimmed" fw={700}>
|
|
MMR
|
|
</Text>
|
|
<Text size="xs" c="dimmed">
|
|
{mmr.toFixed(1)}
|
|
</Text>
|
|
</Stack>
|
|
<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.win_percentage.toFixed(1)}%
|
|
</Text>
|
|
</Stack>
|
|
<Stack gap={0}>
|
|
<Text size="xs" c="dimmed" fw={700}>
|
|
AVG
|
|
</Text>
|
|
<Text size="xs" c="dimmed">
|
|
{stat.avg_cups_per_match.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>
|
|
</>
|
|
);
|
|
});
|
|
|
|
PlayerListItem.displayName = 'PlayerListItem';
|
|
|
|
const PlayerStatsTable = () => {
|
|
const { data: playerStats } = useAllPlayerStats();
|
|
const navigate = useNavigate();
|
|
const [search, setSearch] = useState("");
|
|
const [sortConfig, setSortConfig] = useState<SortConfig>({
|
|
key: "mmr" as SortKey,
|
|
direction: "desc",
|
|
});
|
|
|
|
const calculateMMR = (stat: PlayerStats): number => {
|
|
if (stat.matches === 0) return 0;
|
|
|
|
const winScore = stat.win_percentage;
|
|
const matchConfidence = Math.min(stat.matches / 15, 1);
|
|
const avgCupsScore = Math.min(stat.avg_cups_per_match * 10, 100);
|
|
const marginScore = stat.margin_of_victory
|
|
? Math.min(stat.margin_of_victory * 20, 50)
|
|
: 0;
|
|
const volumeBonus = Math.min(stat.matches * 0.5, 10);
|
|
|
|
const baseMMR =
|
|
winScore * 0.5 +
|
|
avgCupsScore * 0.25 +
|
|
marginScore * 0.15 +
|
|
volumeBonus * 0.1;
|
|
|
|
const finalMMR = baseMMR * matchConfidence;
|
|
return Math.round(finalMMR * 10) / 10;
|
|
};
|
|
|
|
const statsWithMMR = useMemo(() => {
|
|
return playerStats.map((stat) => ({
|
|
...stat,
|
|
mmr: calculateMMR(stat),
|
|
}));
|
|
}, [playerStats]);
|
|
|
|
const filteredAndSortedStats = useMemo(() => {
|
|
let filtered = statsWithMMR.filter((stat) =>
|
|
stat.player_name.toLowerCase().includes(search.toLowerCase())
|
|
);
|
|
|
|
return filtered.sort((a, b) => {
|
|
let aValue: number | string;
|
|
let bValue: number | string;
|
|
|
|
if (sortConfig.key === "mmr") {
|
|
aValue = a.mmr;
|
|
bValue = b.mmr;
|
|
} else {
|
|
aValue = a[sortConfig.key];
|
|
bValue = b[sortConfig.key];
|
|
}
|
|
|
|
if (typeof aValue === "number" && typeof bValue === "number") {
|
|
return sortConfig.direction === "desc"
|
|
? bValue - aValue
|
|
: aValue - bValue;
|
|
}
|
|
|
|
if (typeof aValue === "string" && typeof bValue === "string") {
|
|
return sortConfig.direction === "desc"
|
|
? bValue.localeCompare(aValue)
|
|
: aValue.localeCompare(bValue);
|
|
}
|
|
|
|
return 0;
|
|
});
|
|
}, [statsWithMMR, search, sortConfig]);
|
|
|
|
const handlePlayerClick = useCallback((playerId: string) => {
|
|
navigate({ to: `/profile/${playerId}` });
|
|
}, [navigate]);
|
|
|
|
const handleSort = (key: SortKey) => {
|
|
setSortConfig((prev) => ({
|
|
key,
|
|
direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc",
|
|
}));
|
|
};
|
|
|
|
const getSortIcon = (key: SortKey) => {
|
|
if (sortConfig.key !== key) return null;
|
|
return sortConfig.direction === "desc" ? (
|
|
<CaretDownIcon size={14} />
|
|
) : (
|
|
<CaretUpIcon size={14} />
|
|
);
|
|
};
|
|
|
|
if (playerStats.length === 0) {
|
|
return (
|
|
<Container px={0} size="md">
|
|
<Stack align="center" gap="md" py="xl">
|
|
<ThemeIcon size="xl" variant="light" radius="md">
|
|
<ChartBarIcon size={32} />
|
|
</ThemeIcon>
|
|
<Title order={3} c="dimmed">
|
|
No Stats Available
|
|
</Title>
|
|
</Stack>
|
|
</Container>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Container size="100%" px={0}>
|
|
<Stack gap="xs">
|
|
<TextInput
|
|
placeholder="Search players"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
|
leftSection={<MagnifyingGlassIcon size={16} />}
|
|
size="md"
|
|
px="md"
|
|
/>
|
|
|
|
<Group px="md" justify="space-between" align="center">
|
|
<Text size="10px" lh={0} c="dimmed">
|
|
{filteredAndSortedStats.length} of {playerStats.length} players
|
|
</Text>
|
|
<Group gap="xs">
|
|
<Text size="xs" c="dimmed">Sort:</Text>
|
|
<UnstyledButton
|
|
onClick={() => handleSort("mmr")}
|
|
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
|
>
|
|
<Text size="xs" fw={sortConfig.key === "mmr" ? 600 : 400} c={sortConfig.key === "mmr" ? "dark" : "dimmed"}>
|
|
MMR
|
|
</Text>
|
|
{getSortIcon("mmr")}
|
|
</UnstyledButton>
|
|
<Text size="xs" c="dimmed">•</Text>
|
|
<UnstyledButton
|
|
onClick={() => handleSort("wins")}
|
|
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
|
>
|
|
<Text size="xs" fw={sortConfig.key === "wins" ? 600 : 400} c={sortConfig.key === "wins" ? "dark" : "dimmed"}>
|
|
Wins
|
|
</Text>
|
|
{getSortIcon("wins")}
|
|
</UnstyledButton>
|
|
<Text size="xs" c="dimmed">•</Text>
|
|
<UnstyledButton
|
|
onClick={() => handleSort("matches")}
|
|
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
|
>
|
|
<Text size="xs" fw={sortConfig.key === "matches" ? 600 : 400} c={sortConfig.key === "matches" ? "dark" : "dimmed"}>
|
|
Matches
|
|
</Text>
|
|
{getSortIcon("matches")}
|
|
</UnstyledButton>
|
|
<Popover position="bottom-end" withArrow shadow="md">
|
|
<Popover.Target>
|
|
<ActionIcon variant="subtle" size="sm">
|
|
<InfoIcon size={14} />
|
|
</ActionIcon>
|
|
</Popover.Target>
|
|
<Popover.Dropdown>
|
|
<Box maw={280}>
|
|
<Text size="sm" fw={500} mb="xs">
|
|
MMR Calculation:
|
|
</Text>
|
|
<Text size="xs" mb={2}>
|
|
• Win Rate (50%)
|
|
</Text>
|
|
<Text size="xs" mb={2}>
|
|
• Average Cups/Match (25%)
|
|
</Text>
|
|
<Text size="xs" mb={2}>
|
|
• Average Win Margin (15%)
|
|
</Text>
|
|
<Text size="xs" mb={2}>
|
|
• Match Volume Bonus (10%)
|
|
</Text>
|
|
<Text size="xs" mt="xs" c="dimmed">
|
|
* Confidence penalty applied for players with <15 matches
|
|
</Text>
|
|
<Text size="xs" mt="xs" c="dimmed">
|
|
** Not an official rating
|
|
</Text>
|
|
</Box>
|
|
</Popover.Dropdown>
|
|
</Popover>
|
|
</Group>
|
|
</Group>
|
|
|
|
<Stack gap={0}>
|
|
{filteredAndSortedStats.map((stat, index) => (
|
|
<Box key={stat.id}>
|
|
<PlayerListItem
|
|
stat={stat}
|
|
onPlayerClick={handlePlayerClick}
|
|
mmr={stat.mmr}
|
|
/>
|
|
{index < filteredAndSortedStats.length - 1 && <Divider />}
|
|
</Box>
|
|
))}
|
|
</Stack>
|
|
|
|
{filteredAndSortedStats.length === 0 && search && (
|
|
<Text ta="center" c="dimmed" py="xl">
|
|
No players found matching "{search}"
|
|
</Text>
|
|
)}
|
|
</Stack>
|
|
</Container>
|
|
);
|
|
};
|
|
|
|
export default PlayerStatsTable;
|