diff --git a/src/app/routes/_authed/stats.tsx b/src/app/routes/_authed/stats.tsx index 95f2e8e..cf498ee 100644 --- a/src/app/routes/_authed/stats.tsx +++ b/src/app/routes/_authed/stats.tsx @@ -14,7 +14,8 @@ export const Route = createFileRoute("/_authed/stats")({ fullWidth: true, header: { title: "Player Stats" - } + }, + refresh: [playerQueries.allStats().queryKey], }), }); diff --git a/src/features/players/components/player-stats-table.tsx b/src/features/players/components/player-stats-table.tsx index dd3f67f..9ddcb5a 100644 --- a/src/features/players/components/player-stats-table.tsx +++ b/src/features/players/components/player-stats-table.tsx @@ -11,12 +11,15 @@ import { Title, ScrollArea, Paper, + Popover, + ActionIcon, } from "@mantine/core"; import { MagnifyingGlassIcon, CaretUpIcon, CaretDownIcon, ChartBarIcon, + InfoIcon, } from "@phosphor-icons/react"; import { PlayerStats } from "../types"; import { motion } from "framer-motion"; @@ -25,7 +28,7 @@ interface PlayerStatsTableProps { playerStats: PlayerStats[]; } -type SortKey = keyof PlayerStats; +type SortKey = keyof PlayerStats | 'mmr'; type SortDirection = 'asc' | 'desc'; interface SortConfig { @@ -36,10 +39,42 @@ interface SortConfig { const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => { const [search, setSearch] = useState(""); const [sortConfig, setSortConfig] = useState({ - key: 'win_percentage', + key: 'mmr' as SortKey, 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) => { setSortConfig(prev => ({ key, @@ -58,8 +93,17 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => { ); return filtered.sort((a, b) => { - const aValue = a[sortConfig.key]; - const bValue = b[sortConfig.key]; + let aValue: number | string; + 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') { return sortConfig.direction === 'desc' ? bValue - aValue : aValue - bValue; @@ -79,7 +123,8 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => { const formatDecimal = (value: number) => value.toFixed(2); 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: 'matches' as SortKey, label: 'Matches', width: 90 }, { 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) => { switch (column.key) { case 'player_name': + return {stat.player_name} + case 'mmr': + const mmr = calculateMMR(stat); return ( - - #{index + 1} - {stat.player_name} - + + = 70 ? "green" : mmr >= 50 ? "blue" : mmr >= 30 ? "yellow" : "red"}> + {mmr.toFixed(1)} + + ); case 'win_percentage': return ( @@ -143,24 +192,19 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => { return ( - - - - Player Statistics - - {filteredAndSortedStats.length} of {playerStats.length} players - - - - + + + {filteredAndSortedStats.length} of {playerStats.length} players + setSearch(e.currentTarget.value)} leftSection={} size="md" /> + { 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 && { @@ -202,13 +249,63 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => { }} onClick={() => handleSort(column.key)} > - + {column.label} + {column.key === 'mmr' && ( +
{ + e.stopPropagation(); + e.preventDefault(); + }} + onMouseDown={(e) => { + e.stopPropagation(); + }} + > + + + + + + + + + MMR Calculation: + • Win Rate (50%) + • Average Cups/Match (25%) + • Average Win Margin (15%) + • Match Volume Bonus (10%) + + * Confidence penalty applied for players with <20 matches + + + ** Not an official rating + + + + +
+ )} {getSortIcon(column.key)} + {index === 0 && ( +
+ )} ))} @@ -225,15 +322,36 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => { borderBottom: '1px solid var(--mantine-color-default-border)', }} > - {columns.map((column) => ( + {columns.map((column, columnIndex) => ( - {renderCellContent(stat, column, index)} +
+ {renderCellContent(stat, column, index)} + {columnIndex === 0 && ( +
+ )} +
))} diff --git a/src/features/players/components/profile/header.tsx b/src/features/players/components/profile/header.tsx index a05fe15..ad4d489 100644 --- a/src/features/players/components/profile/header.tsx +++ b/src/features/players/components/profile/header.tsx @@ -20,12 +20,24 @@ const Header = ({ player }: HeaderProps) => { 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 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 ( <> - - + + - {name} + {name} diff --git a/src/features/players/components/profile/index.tsx b/src/features/players/components/profile/index.tsx index 2cd9e5d..b320b2e 100644 --- a/src/features/players/components/profile/index.tsx +++ b/src/features/players/components/profile/index.tsx @@ -30,7 +30,7 @@ const Profile = ({ id }: ProfileProps) => { return ( <>
- + diff --git a/src/features/players/components/stats-overview.tsx b/src/features/players/components/stats-overview.tsx index a1ae4d4..f320c34 100644 --- a/src/features/players/components/stats-overview.tsx +++ b/src/features/players/components/stats-overview.tsx @@ -17,44 +17,15 @@ const StatCard = ({ value: number | null; suffix?: string; Icon?: Icon; - variant?: "default" | "featured" | "compact"; + variant?: "default" | "compact"; }) => { - if (variant === "featured") { - return ( - - - {Icon && ( - - - - )} -
- - {value !== null ? `${value}${suffix}` : "—"} - - - {label} - -
-
-
- ); - } if (variant === "compact") { return ( - - + + - + {label} @@ -62,8 +33,8 @@ const StatCard = ({ {Icon && ( - - + + )} @@ -72,7 +43,7 @@ const StatCard = ({ } return ( - + @@ -149,14 +120,14 @@ const StatsOverview = ({ playerId }: StatsOverviewProps) => { }; return ( - + Match Statistics - + { Metrics - + { 0 ? parseFloat(avgMarginOfVictory.toFixed(1)) : null} Icon={ArrowUpIcon} /> 0 ? parseFloat(avgMarginOfLoss.toFixed(1)) : null} Icon={ArrowDownIcon} />