Files
flxn-app/src/features/players/components/player-stats-table.tsx
2025-09-22 16:45:41 -05:00

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 &lt;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;