match list

This commit is contained in:
yohlo
2025-09-14 21:59:15 -05:00
parent d11e50d4ef
commit 8efc0a7a4b
9 changed files with 411 additions and 111 deletions

View File

@@ -28,8 +28,8 @@ interface PlayerStatsTableProps {
playerStats: PlayerStats[];
}
type SortKey = keyof PlayerStats | 'mmr';
type SortDirection = 'asc' | 'desc';
type SortKey = keyof PlayerStats | "mmr";
type SortDirection = "asc" | "desc";
interface SortConfig {
key: SortKey;
@@ -39,8 +39,8 @@ interface SortConfig {
const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
const [search, setSearch] = useState("");
const [sortConfig, setSortConfig] = useState<SortConfig>({
key: 'mmr' as SortKey,
direction: 'desc'
key: "mmr" as SortKey,
direction: "desc",
});
// Calculate MMR (Match Making Rating) based on multiple factors
@@ -56,18 +56,19 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
// Performance metrics
const avgCupsScore = Math.min(stat.avg_cups_per_match * 10, 100); // Cap at 10 avg cups
const marginScore = stat.margin_of_victory ? Math.min(stat.margin_of_victory * 20, 50) : 0; // Cap at 2.5 margin
const marginScore = stat.margin_of_victory
? Math.min(stat.margin_of_victory * 20, 50)
: 0; // Cap at 2.5 margin
// Volume bonus for active players (small bonus for playing more)
const volumeBonus = Math.min(stat.matches * 0.5, 10); // Max 10 point bonus
// Weighted calculation
const baseMMR = (
winScore * 0.5 + // Win % is 50% of score
avgCupsScore * 0.25 + // Avg cups is 25% of score
marginScore * 0.15 + // Win margin is 15% of score
volumeBonus * 0.1 // Volume bonus is 10% of score
);
const baseMMR =
winScore * 0.5 + // Win % is 50% of score
avgCupsScore * 0.25 + // Avg cups is 25% of score
marginScore * 0.15 + // Win margin is 15% of score
volumeBonus * 0.1; // Volume bonus is 10% of score
// Apply confidence factor (players with few matches get penalized)
const finalMMR = baseMMR * matchConfidence;
@@ -76,19 +77,23 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
};
const handleSort = (key: SortKey) => {
setSortConfig(prev => ({
setSortConfig((prev) => ({
key,
direction: prev.key === key && prev.direction === 'desc' ? 'asc' : 'desc'
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} />;
return sortConfig.direction === "desc" ? (
<CaretDownIcon size={14} />
) : (
<CaretUpIcon size={14} />
);
};
const filteredAndSortedStats = useMemo(() => {
let filtered = playerStats.filter(stat =>
let filtered = playerStats.filter((stat) =>
stat.player_name.toLowerCase().includes(search.toLowerCase())
);
@@ -97,7 +102,7 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
let bValue: number | string;
// Special handling for MMR
if (sortConfig.key === 'mmr') {
if (sortConfig.key === "mmr") {
aValue = calculateMMR(a);
bValue = calculateMMR(b);
} else {
@@ -105,12 +110,14 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
bValue = b[sortConfig.key];
}
if (typeof aValue === 'number' && typeof bValue === 'number') {
return sortConfig.direction === 'desc' ? bValue - aValue : aValue - bValue;
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'
if (typeof aValue === "string" && typeof bValue === "string") {
return sortConfig.direction === "desc"
? bValue.localeCompare(aValue)
: aValue.localeCompare(bValue);
}
@@ -123,52 +130,66 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
const formatDecimal = (value: number) => value.toFixed(2);
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 },
{ 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 renderCellContent = (stat: PlayerStats, column: typeof columns[0], index: number) => {
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':
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" c={mmr >= 70 ? "green" : mmr >= 50 ? "blue" : mmr >= 30 ? "yellow" : "red"}>
<Text fw={700} size="md">
{mmr.toFixed(1)}
</Text>
</Box>
);
case 'win_percentage':
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 size='sm' c={stat.win_percentage >= 70 ? "green" : stat.win_percentage >= 50 ? "yellow" : "red"}>
{formatPercentage(stat.win_percentage)}
<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>
);
case 'wins':
return <Text c="green" fw={500}>{stat.wins}</Text>;
case 'losses':
return <Text c="red" 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>;
}
@@ -181,7 +202,9 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
<ThemeIcon size="xl" variant="light" radius="md">
<ChartBarIcon size={32} />
</ThemeIcon>
<Title order={3} c="dimmed">No Stats Available</Title>
<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>
@@ -193,7 +216,7 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
return (
<Container size="100%" px={0}>
<Stack gap="xs">
<Text ml='auto' size='xs' c="dimmed">
<Text ml="auto" size="xs" c="dimmed">
{filteredAndSortedStats.length} of {playerStats.length} players
</Text>
<TextInput
@@ -204,8 +227,7 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
size="md"
/>
<Paper withBorder radius="md" p={0} style={{ overflow: 'hidden' }}>
<Paper withBorder radius="md" p={0} style={{ overflow: "hidden" }}>
<ScrollArea>
<Table
highlightOnHover
@@ -213,15 +235,15 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
withTableBorder={false}
style={{
minWidth: 1000,
borderRadius: 'inherit',
borderRadius: "inherit",
}}
>
<Table.Thead
style={{
backgroundColor: 'var(--mantine-color-body)',
position: 'sticky',
backgroundColor: "var(--mantine-color-body)",
position: "sticky",
top: 0,
zIndex: 1
zIndex: 1,
}}
>
<Table.Tr>
@@ -229,31 +251,36 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
<Table.Th
key={column.key}
style={{
cursor: 'pointer',
userSelect: 'none',
cursor: "pointer",
userSelect: "none",
width: column.width,
minWidth: column.width,
padding: '12px 16px',
padding: "12px 16px",
fontWeight: 600,
backgroundColor: 'var(--mantine-color-body)',
borderBottom: '2px solid var(--mantine-color-default-border)',
backgroundColor: "var(--mantine-color-body)",
borderBottom:
"2px solid var(--mantine-color-default-border)",
...(index === 0 && {
position: 'sticky',
position: "sticky",
left: 0,
zIndex: 2,
borderTopLeftRadius: 'var(--mantine-radius-md)',
borderTopLeftRadius: "var(--mantine-radius-md)",
}),
...(index === columns.length - 1 && {
borderTopRightRadius: 'var(--mantine-radius-md)',
borderTopRightRadius: "var(--mantine-radius-md)",
}),
}}
onClick={() => handleSort(column.key)}
>
<Group gap="xs" wrap="nowrap" style={{ position: 'relative' }}>
<Group
gap="xs"
wrap="nowrap"
style={{ position: "relative" }}
>
<Text size="sm" fw={600}>
{column.label}
</Text>
{column.key === 'mmr' && (
{column.key === "mmr" && (
<div
onClick={(e) => {
e.stopPropagation();
@@ -265,22 +292,30 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
>
<Popover position="bottom" withArrow shadow="md">
<Popover.Target>
<ActionIcon
variant="subtle"
size="xs"
>
<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="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;20 matches
* Confidence penalty applied for players
with &lt;20 matches
</Text>
<Text size="xs" mt="xs" c="dimmed">
** Not an official rating
@@ -290,18 +325,25 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
</Popover>
</div>
)}
<Box style={{ minWidth: 16, display: 'flex', justifyContent: 'center' }}>
<Box
style={{
minWidth: 16,
display: "flex",
justifyContent: "center",
}}
>
{getSortIcon(column.key)}
</Box>
{index === 0 && (
<div
style={{
position: 'absolute',
position: "absolute",
top: 0,
right: 0,
bottom: 0,
width: '2px',
backgroundColor: 'var(--mantine-color-default-border)',
width: "2px",
backgroundColor:
"var(--mantine-color-default-border)",
zIndex: 1,
}}
/>
@@ -319,34 +361,36 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: index * 0.01 }}
style={{
borderBottom: '1px solid var(--mantine-color-default-border)',
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',
padding: "12px 16px",
verticalAlign: "middle",
...(columnIndex === 0 && {
position: 'sticky',
position: "sticky",
left: 0,
backgroundColor: 'var(--mantine-color-body)',
backgroundColor: "var(--mantine-color-body)",
zIndex: 1,
}),
}}
>
<div style={{ position: 'relative' }}>
<div style={{ position: "relative" }}>
{renderCellContent(stat, column, index)}
{columnIndex === 0 && (
<div
style={{
position: 'absolute',
position: "absolute",
top: 0,
right: 0,
bottom: 0,
width: '2px',
backgroundColor: 'var(--mantine-color-default-border)',
width: "2px",
backgroundColor:
"var(--mantine-color-default-border)",
zIndex: 1,
}}
/>
@@ -371,4 +415,4 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
);
};
export default PlayerStatsTable;
export default PlayerStatsTable;

View File

@@ -1,10 +1,11 @@
import { Box, Text } from "@mantine/core";
import { Box } from "@mantine/core";
import Header from "./header";
import { Player } from "@/features/players/types";
import SwipeableTabs from "@/components/swipeable-tabs";
import { usePlayer } from "../../queries";
import { usePlayer, usePlayerMatches } from "../../queries";
import TeamList from "@/features/teams/components/team-list";
import StatsOverview from "../stats-overview";
import MatchList from "@/features/matches/components/match-list";
interface ProfileProps {
id: string;
@@ -12,6 +13,8 @@ interface ProfileProps {
const Profile = ({ id }: ProfileProps) => {
const { data: player } = usePlayer(id);
const { data: matches } = usePlayerMatches(id);
const tabs = [
{
label: "Overview",
@@ -19,7 +22,7 @@ const Profile = ({ id }: ProfileProps) => {
},
{
label: "Matches",
content: <Text p="md">Matches feed will go here</Text>,
content: <Box p="md"><MatchList matches={matches || []} /></Box>,
},
{
label: "Teams",