various improvements

This commit is contained in:
yohlo
2025-09-17 09:02:20 -05:00
parent c170e1e1fe
commit 498010e3e2
25 changed files with 733 additions and 643 deletions

View File

@@ -1,6 +1,5 @@
import { useState, useMemo } from "react";
import {
Table,
Text,
TextInput,
Stack,
@@ -9,10 +8,11 @@ import {
ThemeIcon,
Container,
Title,
ScrollArea,
Paper,
Divider,
UnstyledButton,
Popover,
ActionIcon,
Skeleton,
} from "@mantine/core";
import {
MagnifyingGlassIcon,
@@ -22,7 +22,8 @@ import {
InfoIcon,
} from "@phosphor-icons/react";
import { PlayerStats } from "../types";
import { motion } from "framer-motion";
import Avatar from "@/components/avatar";
import { useNavigate } from "@tanstack/react-router";
interface PlayerStatsTableProps {
playerStats: PlayerStats[];
@@ -36,7 +37,138 @@ interface SortConfig {
direction: SortDirection;
}
interface PlayerListItemProps {
stat: PlayerStats;
index: number;
onPlayerClick: (playerId: string) => void;
}
const PlayerListItem = ({ stat, index, onPlayerClick }: PlayerListItemProps) => {
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 mmr = calculateMMR(stat);
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>
</>
);
};
const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [sortConfig, setSortConfig] = useState<SortConfig>({
key: "mmr" as SortKey,
@@ -47,14 +179,11 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
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 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 =
@@ -64,26 +193,9 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
volumeBonus * 0.1;
const finalMMR = baseMMR * matchConfidence;
return Math.round(finalMMR * 10) / 10;
};
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} />
);
};
const filteredAndSortedStats = useMemo(() => {
let filtered = playerStats.filter((stat) =>
stat.player_name.toLowerCase().includes(search.toLowerCase())
@@ -117,78 +229,29 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
});
}, [playerStats, search, sortConfig]);
const formatPercentage = (value: number) => `${value.toFixed(1)}%`;
const formatDecimal = (value: number) => value.toFixed(2);
const handlePlayerClick = (playerId: string) => {
navigate({ to: `/profile/${playerId}` });
};
const columns = [
{ key: "player_name" as SortKey, label: "Player", width: 175 },
{ key: "mmr" as SortKey, label: "MMR", width: 90 },
{ key: "win_percentage" as SortKey, label: "Win %", width: 110 },
{ key: "matches" as SortKey, label: "Matches", width: 90 },
{ key: "wins" as SortKey, label: "Wins", width: 80 },
{ key: "losses" as SortKey, label: "Losses", width: 80 },
{ key: "total_cups_made" as SortKey, label: "Cups Made", width: 110 },
{ key: "total_cups_against" as SortKey, label: "Cups Against", width: 120 },
{ key: "avg_cups_per_match" as SortKey, label: "Avg/Match", width: 100 },
{ key: "margin_of_victory" as SortKey, label: "Win Margin", width: 110 },
{ key: "margin_of_loss" as SortKey, label: "Loss Margin", width: 110 },
];
const handleSort = (key: SortKey) => {
setSortConfig((prev) => ({
key,
direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc",
}));
};
const renderCellContent = (
stat: PlayerStats,
column: (typeof columns)[0],
index: number
) => {
switch (column.key) {
case "player_name":
return (
<Text size="sm" fw={600}>
{stat.player_name}
</Text>
);
case "mmr":
const mmr = calculateMMR(stat);
return (
<Box>
<Text fw={700} size="md">
{mmr.toFixed(1)}
</Text>
</Box>
);
case "win_percentage":
return <Text size="sm">{formatPercentage(stat.win_percentage)}</Text>;
case "wins":
return <Text fw={500}>{stat.wins}</Text>;
case "losses":
return <Text fw={500}>{stat.losses}</Text>;
case "total_cups_made":
return <Text fw={500}>{stat.total_cups_made}</Text>;
case "matches":
return <Text fw={500}>{stat.matches}</Text>;
case "avg_cups_per_match":
return <Text>{formatDecimal(stat.avg_cups_per_match)}</Text>;
case "margin_of_victory":
return (
<Text>
{stat.margin_of_victory
? formatDecimal(stat.margin_of_victory)
: "N/A"}
</Text>
);
case "margin_of_loss":
return (
<Text>
{stat.margin_of_loss ? formatDecimal(stat.margin_of_loss) : "N/A"}
</Text>
);
default:
return <Text>{(stat as any)[column.key]}</Text>;
}
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 size="md">
<Container px={0} size="md">
<Stack align="center" gap="md" py="xl">
<ThemeIcon size="xl" variant="light" radius="md">
<ChartBarIcon size={32} />
@@ -196,9 +259,6 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
<Title order={3} c="dimmed">
No Stats Available
</Title>
<Text c="dimmed" ta="center">
Player statistics will appear here once matches have been played.
</Text>
</Stack>
</Container>
);
@@ -207,194 +267,97 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
return (
<Container size="100%" px={0}>
<Stack gap="xs">
<Text ml="auto" size="xs" c="dimmed">
{filteredAndSortedStats.length} of {playerStats.length} players
</Text>
<TextInput
placeholder="Search players"
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
leftSection={<MagnifyingGlassIcon size={16} />}
size="md"
px="md"
/>
<Paper withBorder radius="md" p={0} style={{ overflow: "hidden" }}>
<ScrollArea>
<Table
highlightOnHover
striped
withTableBorder={false}
style={{
minWidth: 1000,
borderRadius: "inherit",
}}
<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 }}
>
<Table.Thead
style={{
backgroundColor: "var(--mantine-color-body)",
position: "sticky",
top: 0,
zIndex: 1,
}}
>
<Table.Tr>
{columns.map((column, index) => (
<Table.Th
key={column.key}
style={{
cursor: "pointer",
userSelect: "none",
width: column.width,
minWidth: column.width,
padding: "12px 16px",
fontWeight: 600,
backgroundColor: "var(--mantine-color-body)",
borderBottom:
"2px solid var(--mantine-color-default-border)",
...(index === 0 && {
position: "sticky",
left: 0,
zIndex: 2,
borderTopLeftRadius: "var(--mantine-radius-md)",
}),
...(index === columns.length - 1 && {
borderTopRightRadius: "var(--mantine-radius-md)",
}),
}}
onClick={() => handleSort(column.key)}
>
<Group
gap="xs"
wrap="nowrap"
style={{ position: "relative" }}
>
<Text size="sm" fw={600}>
{column.label}
</Text>
{column.key === "mmr" && (
<div
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
onMouseDown={(e) => {
e.stopPropagation();
}}
>
<Popover position="bottom" withArrow shadow="md">
<Popover.Target>
<ActionIcon variant="subtle" size="xs">
<InfoIcon size={12} />
</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>
</div>
)}
<Box
style={{
minWidth: 16,
display: "flex",
justifyContent: "center",
}}
>
{getSortIcon(column.key)}
</Box>
{index === 0 && (
<div
style={{
position: "absolute",
top: 0,
right: 0,
bottom: 0,
width: "2px",
backgroundColor:
"var(--mantine-color-default-border)",
zIndex: 1,
}}
/>
)}
</Group>
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filteredAndSortedStats.map((stat, index) => (
<motion.tr
key={stat.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: index * 0.01 }}
style={{
borderBottom:
"1px solid var(--mantine-color-default-border)",
}}
>
{columns.map((column, columnIndex) => (
<Table.Td
key={`${stat.id}-${column.key}`}
style={{
padding: "12px 16px",
verticalAlign: "middle",
...(columnIndex === 0 && {
position: "sticky",
left: 0,
backgroundColor: "var(--mantine-color-body)",
zIndex: 1,
}),
}}
>
<div style={{ position: "relative" }}>
{renderCellContent(stat, column, index)}
{columnIndex === 0 && (
<div
style={{
position: "absolute",
top: 0,
right: 0,
bottom: 0,
width: "2px",
backgroundColor:
"var(--mantine-color-default-border)",
zIndex: 1,
}}
/>
)}
</div>
</Table.Td>
))}
</motion.tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
</Paper>
<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}
index={index}
onPlayerClick={handlePlayerClick}
/>
{index < filteredAndSortedStats.length - 1 && <Divider />}
</Box>
))}
</Stack>
{filteredAndSortedStats.length === 0 && search && (
<Text ta="center" c="dimmed" py="xl">