profile and stats improvements

This commit is contained in:
yohlo
2025-09-13 23:05:35 -05:00
parent 4bc25fb0bc
commit d11e50d4ef
5 changed files with 171 additions and 69 deletions

View File

@@ -14,7 +14,8 @@ export const Route = createFileRoute("/_authed/stats")({
fullWidth: true, fullWidth: true,
header: { header: {
title: "Player Stats" title: "Player Stats"
} },
refresh: [playerQueries.allStats().queryKey],
}), }),
}); });

View File

@@ -11,12 +11,15 @@ import {
Title, Title,
ScrollArea, ScrollArea,
Paper, Paper,
Popover,
ActionIcon,
} from "@mantine/core"; } from "@mantine/core";
import { import {
MagnifyingGlassIcon, MagnifyingGlassIcon,
CaretUpIcon, CaretUpIcon,
CaretDownIcon, CaretDownIcon,
ChartBarIcon, ChartBarIcon,
InfoIcon,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import { PlayerStats } from "../types"; import { PlayerStats } from "../types";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
@@ -25,7 +28,7 @@ interface PlayerStatsTableProps {
playerStats: PlayerStats[]; playerStats: PlayerStats[];
} }
type SortKey = keyof PlayerStats; type SortKey = keyof PlayerStats | 'mmr';
type SortDirection = 'asc' | 'desc'; type SortDirection = 'asc' | 'desc';
interface SortConfig { interface SortConfig {
@@ -36,10 +39,42 @@ interface SortConfig {
const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => { const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [sortConfig, setSortConfig] = useState<SortConfig>({ const [sortConfig, setSortConfig] = useState<SortConfig>({
key: 'win_percentage', key: 'mmr' as SortKey,
direction: 'desc' direction: 'desc'
}); });
// Calculate MMR (Match Making Rating) based on multiple factors
const calculateMMR = (stat: PlayerStats): number => {
if (stat.matches === 0) return 0;
// Base score from win percentage (0-100)
const winScore = stat.win_percentage;
// Match confidence factor (more matches = more reliable)
// Cap at 20 matches for full confidence
const matchConfidence = Math.min(stat.matches / 20, 1);
// 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
// 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
);
// Apply confidence factor (players with few matches get penalized)
const finalMMR = baseMMR * matchConfidence;
return Math.round(finalMMR * 10) / 10; // Round to 1 decimal
};
const handleSort = (key: SortKey) => { const handleSort = (key: SortKey) => {
setSortConfig(prev => ({ setSortConfig(prev => ({
key, key,
@@ -58,8 +93,17 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
); );
return filtered.sort((a, b) => { return filtered.sort((a, b) => {
const aValue = a[sortConfig.key]; let aValue: number | string;
const bValue = b[sortConfig.key]; let bValue: number | string;
// Special handling for MMR
if (sortConfig.key === 'mmr') {
aValue = calculateMMR(a);
bValue = calculateMMR(b);
} else {
aValue = a[sortConfig.key];
bValue = b[sortConfig.key];
}
if (typeof aValue === 'number' && typeof bValue === 'number') { if (typeof aValue === 'number' && typeof bValue === 'number') {
return sortConfig.direction === 'desc' ? bValue - aValue : aValue - bValue; return sortConfig.direction === 'desc' ? bValue - aValue : aValue - bValue;
@@ -79,7 +123,8 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
const formatDecimal = (value: number) => value.toFixed(2); const formatDecimal = (value: number) => value.toFixed(2);
const columns = [ const columns = [
{ key: 'player_name' as SortKey, label: 'Player', width: 200 }, { 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: 'win_percentage' as SortKey, label: 'Win %', width: 110 },
{ key: 'matches' as SortKey, label: 'Matches', width: 90 }, { key: 'matches' as SortKey, label: 'Matches', width: 90 },
{ key: 'wins' as SortKey, label: 'Wins', width: 80 }, { key: 'wins' as SortKey, label: 'Wins', width: 80 },
@@ -94,11 +139,15 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
const renderCellContent = (stat: PlayerStats, column: typeof columns[0], index: number) => { const renderCellContent = (stat: PlayerStats, column: typeof columns[0], index: number) => {
switch (column.key) { switch (column.key) {
case 'player_name': case 'player_name':
return <Text size='sm' fw={600}>{stat.player_name}</Text>
case 'mmr':
const mmr = calculateMMR(stat);
return ( return (
<Group gap="sm"> <Box>
<Text size="xs" c="dimmed" fw={500}>#{index + 1}</Text> <Text fw={700} size="md" c={mmr >= 70 ? "green" : mmr >= 50 ? "blue" : mmr >= 30 ? "yellow" : "red"}>
<Text fw={600}>{stat.player_name}</Text> {mmr.toFixed(1)}
</Group> </Text>
</Box>
); );
case 'win_percentage': case 'win_percentage':
return ( return (
@@ -143,24 +192,19 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
return ( return (
<Container size="100%" px={0}> <Container size="100%" px={0}>
<Stack gap="lg">
<Group justify="space-between" align="flex-end" wrap="wrap">
<Stack gap="xs"> <Stack gap="xs">
<Title order={2}>Player Statistics</Title> <Text ml='auto' size='xs' c="dimmed">
<Text c="dimmed">
{filteredAndSortedStats.length} of {playerStats.length} players {filteredAndSortedStats.length} of {playerStats.length} players
</Text> </Text>
</Stack>
</Group>
<TextInput <TextInput
placeholder="Search players..." placeholder="Search players"
value={search} value={search}
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => setSearch(e.currentTarget.value)}
leftSection={<MagnifyingGlassIcon size={16} />} leftSection={<MagnifyingGlassIcon size={16} />}
size="md" size="md"
/> />
<Paper withBorder radius="md" p={0} style={{ overflow: 'hidden' }}> <Paper withBorder radius="md" p={0} style={{ overflow: 'hidden' }}>
<ScrollArea> <ScrollArea>
<Table <Table
@@ -194,6 +238,9 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
backgroundColor: 'var(--mantine-color-body)', backgroundColor: 'var(--mantine-color-body)',
borderBottom: '2px solid var(--mantine-color-default-border)', borderBottom: '2px solid var(--mantine-color-default-border)',
...(index === 0 && { ...(index === 0 && {
position: 'sticky',
left: 0,
zIndex: 2,
borderTopLeftRadius: 'var(--mantine-radius-md)', borderTopLeftRadius: 'var(--mantine-radius-md)',
}), }),
...(index === columns.length - 1 && { ...(index === columns.length - 1 && {
@@ -202,13 +249,63 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
}} }}
onClick={() => handleSort(column.key)} onClick={() => handleSort(column.key)}
> >
<Group gap="xs" wrap="nowrap"> <Group gap="xs" wrap="nowrap" style={{ position: 'relative' }}>
<Text size="sm" fw={600}> <Text size="sm" fw={600}>
{column.label} {column.label}
</Text> </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;20 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' }}> <Box style={{ minWidth: 16, display: 'flex', justifyContent: 'center' }}>
{getSortIcon(column.key)} {getSortIcon(column.key)}
</Box> </Box>
{index === 0 && (
<div
style={{
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
width: '2px',
backgroundColor: 'var(--mantine-color-default-border)',
zIndex: 1,
}}
/>
)}
</Group> </Group>
</Table.Th> </Table.Th>
))} ))}
@@ -225,15 +322,36 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
borderBottom: '1px solid var(--mantine-color-default-border)', borderBottom: '1px solid var(--mantine-color-default-border)',
}} }}
> >
{columns.map((column) => ( {columns.map((column, columnIndex) => (
<Table.Td <Table.Td
key={`${stat.id}-${column.key}`} key={`${stat.id}-${column.key}`}
style={{ style={{
padding: '12px 16px', padding: '12px 16px',
verticalAlign: 'middle', verticalAlign: 'middle',
...(columnIndex === 0 && {
position: 'sticky',
left: 0,
backgroundColor: 'var(--mantine-color-body)',
zIndex: 1,
}),
}} }}
> >
<div style={{ position: 'relative' }}>
{renderCellContent(stat, column, index)} {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> </Table.Td>
))} ))}
</motion.tr> </motion.tr>

View File

@@ -20,12 +20,24 @@ const Header = ({ player }: HeaderProps) => {
const owner = useMemo(() => authUser?.id === player.id, [authUser?.id, player.id]); const owner = useMemo(() => authUser?.id === player.id, [authUser?.id, player.id]);
const name = useMemo(() => `${player.first_name} ${player.last_name}`, [player.first_name, player.last_name]); const name = useMemo(() => `${player.first_name} ${player.last_name}`, [player.first_name, player.last_name]);
const fontSize = useMemo(() => {
const baseSize = 24;
const maxLength = 20;
if (name.length <= maxLength) {
return `${baseSize}px`;
}
const scaleFactor = Math.max(0.6, maxLength / name.length);
return `${Math.floor(baseSize * scaleFactor)}px`;
}, [name]);
return ( return (
<> <>
<Flex px='xl' w='100%' align='self-end' gap='md'> <Flex px='lg' w='100%' align='self-end' gap='md'>
<Avatar name={name} size={125} /> <Avatar name={name} size={100} />
<Flex align='center' justify='center' gap={4} pb={20} w='100%'> <Flex align='center' justify='center' gap={4} pb={20} w='100%'>
<Title ta='center' order={2}>{name}</Title> <Title ta='center' style={{ fontSize, lineHeight: 1.2 }}>{name}</Title>
<ActionIcon display={owner ? 'block' : 'none'} radius='xl' variant='subtle' onClick={sheet.open}> <ActionIcon display={owner ? 'block' : 'none'} radius='xl' variant='subtle' onClick={sheet.open}>
<PencilIcon size={20} /> <PencilIcon size={20} />
</ActionIcon> </ActionIcon>

View File

@@ -30,7 +30,7 @@ const Profile = ({ id }: ProfileProps) => {
return ( return (
<> <>
<Header player={player} /> <Header player={player} />
<Box m="sm" mt="lg"> <Box m='md' mt="lg">
<SwipeableTabs tabs={tabs} /> <SwipeableTabs tabs={tabs} />
</Box> </Box>
</> </>

View File

@@ -17,44 +17,15 @@ const StatCard = ({
value: number | null; value: number | null;
suffix?: string; suffix?: string;
Icon?: Icon; Icon?: Icon;
variant?: "default" | "featured" | "compact"; variant?: "default" | "compact";
}) => { }) => {
if (variant === "featured") {
return (
<Card
p="lg"
radius="lg"
style={{
background: 'linear-gradient(135deg, var(--mantine-color-blue-6) 0%, var(--mantine-color-blue-7) 100%)',
color: 'white',
border: 'none'
}}
>
<Stack gap="xs" align="center" ta="center">
{Icon && (
<ThemeIcon size="lg" variant="white" color="blue" radius="lg">
<Icon size={24} />
</ThemeIcon>
)}
<div>
<Text size="xl" fw={700} lh={1}>
{value !== null ? `${value}${suffix}` : "—"}
</Text>
<Text size="sm" opacity={0.9} fw={500} tt="uppercase" lts="0.5px">
{label}
</Text>
</div>
</Stack>
</Card>
);
}
if (variant === "compact") { if (variant === "compact") {
return ( return (
<Card p="sm" radius="md" withBorder> <Card p="xs" radius="md" withBorder>
<Group justify="space-between" align="flex-start"> <Group gap={2} justify="space-between" align="flex-start">
<Stack gap={2} flex={1}> <Stack gap={2} flex={1}>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lts="0.3px"> <Text size='xs' c="dimmed" fw={600} tt="uppercase" lts="0.3px">
{label} {label}
</Text> </Text>
<Text size="md" fw={700} lh={1}> <Text size="md" fw={700} lh={1}>
@@ -62,8 +33,8 @@ const StatCard = ({
</Text> </Text>
</Stack> </Stack>
{Icon && ( {Icon && (
<ThemeIcon size="md" variant="light" radius="md"> <ThemeIcon size="sm" variant="light" radius="md">
<Icon size={16} /> <Icon size={12} />
</ThemeIcon> </ThemeIcon>
)} )}
</Group> </Group>
@@ -72,7 +43,7 @@ const StatCard = ({
} }
return ( return (
<Card p="md" radius="md" withBorder> <Card p="sm" radius="md" withBorder>
<Stack gap="xs"> <Stack gap="xs">
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lts="0.3px"> <Text size="xs" c="dimmed" fw={600} tt="uppercase" lts="0.3px">
@@ -149,14 +120,14 @@ const StatsOverview = ({ playerId }: StatsOverviewProps) => {
}; };
return ( return (
<Box p="md"> <Box>
<Stack gap="lg"> <Stack gap="lg">
<Divider /> <Divider />
<Stack gap="sm"> <Stack gap="sm">
<Text size="md" fw={600} c="dark">Match Statistics</Text> <Text size="md" fw={600} c="dark">Match Statistics</Text>
<Grid gutter="md"> <Grid gutter="xs">
<Grid.Col span={4}> <Grid.Col span={4}>
<StatCard <StatCard
label="Matches" label="Matches"
@@ -186,7 +157,7 @@ const StatsOverview = ({ playerId }: StatsOverviewProps) => {
<Stack gap="sm"> <Stack gap="sm">
<Text size="md" fw={600} c="dark">Metrics</Text> <Text size="md" fw={600} c="dark">Metrics</Text>
<Grid gutter="md"> <Grid gutter="xs">
<Grid.Col span={6}> <Grid.Col span={6}>
<StatCard <StatCard
label="Cups Made" label="Cups Made"
@@ -217,14 +188,14 @@ const StatsOverview = ({ playerId }: StatsOverviewProps) => {
</Grid.Col> </Grid.Col>
<Grid.Col span={6}> <Grid.Col span={6}>
<StatCard <StatCard
label="Avg Win Margin" label="Win Margin"
value={avgMarginOfVictory > 0 ? parseFloat(avgMarginOfVictory.toFixed(1)) : null} value={avgMarginOfVictory > 0 ? parseFloat(avgMarginOfVictory.toFixed(1)) : null}
Icon={ArrowUpIcon} Icon={ArrowUpIcon}
/> />
</Grid.Col> </Grid.Col>
<Grid.Col span={6}> <Grid.Col span={6}>
<StatCard <StatCard
label="Avg Loss Margin" label="Loss Margin"
value={avgMarginOfLoss > 0 ? parseFloat(avgMarginOfLoss.toFixed(1)) : null} value={avgMarginOfLoss > 0 ? parseFloat(avgMarginOfLoss.toFixed(1)) : null}
Icon={ArrowDownIcon} Icon={ArrowDownIcon}
/> />