stats reorg, upcoming refinement

This commit is contained in:
yohlo
2025-09-14 23:10:05 -05:00
parent 8efc0a7a4b
commit 9a105b30c6
18 changed files with 703 additions and 373 deletions

View File

@@ -43,37 +43,29 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
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);
const matchConfidence = Math.min(stat.matches / 15, 1);
// Performance metrics
const avgCupsScore = Math.min(stat.avg_cups_per_match * 10, 100); // Cap at 10 avg cups
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; // Cap at 2.5 margin
: 0;
// Volume bonus for active players (small bonus for playing more)
const volumeBonus = Math.min(stat.matches * 0.5, 10); // Max 10 point bonus
const volumeBonus = Math.min(stat.matches * 0.5, 10);
// 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
winScore * 0.5 +
avgCupsScore * 0.25 +
marginScore * 0.15 +
volumeBonus * 0.1;
// Apply confidence factor (players with few matches get penalized)
const finalMMR = baseMMR * matchConfidence;
return Math.round(finalMMR * 10) / 10; // Round to 1 decimal
return Math.round(finalMMR * 10) / 10;
};
const handleSort = (key: SortKey) => {
@@ -101,7 +93,6 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
let aValue: number | string;
let bValue: number | string;
// Special handling for MMR
if (sortConfig.key === "mmr") {
aValue = calculateMMR(a);
bValue = calculateMMR(b);
@@ -315,7 +306,7 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
</Text>
<Text size="xs" mt="xs" c="dimmed">
* Confidence penalty applied for players
with &lt;20 matches
with &lt;15 matches
</Text>
<Text size="xs" mt="xs" c="dimmed">
** Not an official rating

View File

@@ -1,11 +1,12 @@
import { Box } from "@mantine/core";
import Header from "./header";
import { Player } from "@/features/players/types";
import { Player, PlayerStats } from "@/features/players/types";
import SwipeableTabs from "@/components/swipeable-tabs";
import { usePlayer, usePlayerMatches } from "../../queries";
import { usePlayer, usePlayerMatches, usePlayerStats } from "../../queries";
import TeamList from "@/features/teams/components/team-list";
import StatsOverview from "../stats-overview";
import StatsOverview from "@/shared/components/stats-overview";
import MatchList from "@/features/matches/components/match-list";
import { BaseStats } from "@/shared/types/stats";
interface ProfileProps {
id: string;
@@ -14,11 +15,24 @@ interface ProfileProps {
const Profile = ({ id }: ProfileProps) => {
const { data: player } = usePlayer(id);
const { data: matches } = usePlayerMatches(id);
const { data: stats, isLoading: statsLoading } = usePlayerStats(id);
// Aggregate player stats from multiple tournaments into a single BaseStats object
const aggregatedStats: BaseStats | null = stats && stats.length > 0 ? {
id: `player_${id}_aggregate`,
matches: stats.reduce((acc, stat) => acc + stat.matches, 0),
wins: stats.reduce((acc, stat) => acc + stat.wins, 0),
losses: stats.reduce((acc, stat) => acc + stat.losses, 0),
total_cups_made: stats.reduce((acc, stat) => acc + stat.total_cups_made, 0),
total_cups_against: stats.reduce((acc, stat) => acc + stat.total_cups_against, 0),
margin_of_victory: stats.filter(s => s.margin_of_victory > 0).reduce((acc, stat, _, arr) => acc + stat.margin_of_victory / arr.length, 0),
margin_of_loss: stats.filter(s => s.margin_of_loss > 0).reduce((acc, stat, _, arr) => acc + stat.margin_of_loss / arr.length, 0),
} : null;
const tabs = [
{
label: "Overview",
content: <StatsOverview playerId={id} />,
content: <StatsOverview statsData={aggregatedStats} isLoading={statsLoading} />,
},
{
label: "Matches",

View File

@@ -1,270 +0,0 @@
import { Box, Grid, Text, Group, Stack, ThemeIcon, Card, Avatar, Progress, Badge, Divider } from "@mantine/core";
import { CrownIcon, XIcon, FireIcon, ShieldIcon, ChartLineUpIcon, ShieldCheckIcon, BoxingGloveIcon, Icon, TrendUpIcon, ArrowUpIcon, ArrowDownIcon } from "@phosphor-icons/react";
import { usePlayerStats } from "../queries";
interface StatsOverviewProps {
playerId: string;
}
const StatCard = ({
label,
value,
suffix = "",
Icon,
variant = "default"
}: {
label: string;
value: number | null;
suffix?: string;
Icon?: Icon;
variant?: "default" | "compact";
}) => {
if (variant === "compact") {
return (
<Card p="xs" radius="md" withBorder>
<Group gap={2} justify="space-between" align="flex-start">
<Stack gap={2} flex={1}>
<Text size='xs' c="dimmed" fw={600} tt="uppercase" lts="0.3px">
{label}
</Text>
<Text size="md" fw={700} lh={1}>
{value !== null ? `${value}${suffix}` : "—"}
</Text>
</Stack>
{Icon && (
<ThemeIcon size="sm" variant="light" radius="md">
<Icon size={12} />
</ThemeIcon>
)}
</Group>
</Card>
);
}
return (
<Card p="sm" radius="md" withBorder>
<Stack gap="xs">
<Group justify="space-between" align="center">
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lts="0.3px">
{label}
</Text>
{Icon && (
<ThemeIcon size="sm" variant="light" radius="sm">
<Icon size={14} />
</ThemeIcon>
)}
</Group>
<Text size="xl" fw={700} lh={1}>
{value !== null ? `${value}${suffix}` : "—"}
</Text>
</Stack>
</Card>
);
};
const StatsOverview = ({ playerId }: StatsOverviewProps) => {
const { data: statsData } = usePlayerStats(playerId);
if (!statsData || statsData.length === 0) {
return (
<Box p="sm">
<div style={{ padding: '12px', border: '1px solid var(--mantine-color-gray-3)', borderRadius: '4px', textAlign: 'center' }}>
<Text size="sm" c="dimmed">
No stats available yet
</Text>
</div>
</Box>
);
}
const overallStats = statsData.reduce(
(acc, stat) => ({
matches: acc.matches + stat.matches,
wins: acc.wins + stat.wins,
losses: acc.losses + stat.losses,
total_cups_made: acc.total_cups_made + stat.total_cups_made,
total_cups_against: acc.total_cups_against + stat.total_cups_against,
}),
{ matches: 0, wins: 0, losses: 0, total_cups_made: 0, total_cups_against: 0 }
);
const winPercentage = overallStats.matches > 0
? ((overallStats.wins / overallStats.matches) * 100)
: 0;
const avgCupsPerMatch = overallStats.matches > 0
? (overallStats.total_cups_made / overallStats.matches)
: 0;
const avgCupsAgainstPerMatch = overallStats.matches > 0
? (overallStats.total_cups_against / overallStats.matches)
: 0;
const validMarginOfVictory = statsData.filter(stat => stat.margin_of_victory > 0);
const validMarginOfLoss = statsData.filter(stat => stat.margin_of_loss > 0);
const avgMarginOfVictory = validMarginOfVictory.length > 0
? (validMarginOfVictory.reduce((acc, stat) => acc + stat.margin_of_victory, 0) / validMarginOfVictory.length)
: 0;
const avgMarginOfLoss = validMarginOfLoss.length > 0
? (validMarginOfLoss.reduce((acc, stat) => acc + stat.margin_of_loss, 0) / validMarginOfLoss.length)
: 0;
const getWinRateColor = (rate: number) => {
if (rate >= 70) return "green";
if (rate >= 50) return "blue";
if (rate >= 30) return "orange";
return "red";
};
return (
<Box>
<Stack gap="lg">
<Divider />
<Stack gap="sm">
<Text size="md" fw={600} c="dark">Match Statistics</Text>
<Grid gutter="xs">
<Grid.Col span={4}>
<StatCard
label="Matches"
value={overallStats.matches}
Icon={BoxingGloveIcon}
variant="compact"
/>
</Grid.Col>
<Grid.Col span={4}>
<StatCard
label="Wins"
value={overallStats.wins}
Icon={CrownIcon}
variant="compact"
/>
</Grid.Col>
<Grid.Col span={4}>
<StatCard
label="Losses"
value={overallStats.losses}
Icon={XIcon}
variant="compact"
/>
</Grid.Col>
</Grid>
</Stack>
<Stack gap="sm">
<Text size="md" fw={600} c="dark">Metrics</Text>
<Grid gutter="xs">
<Grid.Col span={6}>
<StatCard
label="Cups Made"
value={overallStats.total_cups_made}
Icon={FireIcon}
/>
</Grid.Col>
<Grid.Col span={6}>
<StatCard
label="Cups Against"
value={overallStats.total_cups_against}
Icon={ShieldIcon}
/>
</Grid.Col>
<Grid.Col span={6}>
<StatCard
label="Avg Per Game"
value={parseFloat(avgCupsPerMatch.toFixed(1))}
Icon={ChartLineUpIcon}
/>
</Grid.Col>
<Grid.Col span={6}>
<StatCard
label="Avg Against"
value={parseFloat(avgCupsAgainstPerMatch.toFixed(1))}
Icon={ShieldCheckIcon}
/>
</Grid.Col>
<Grid.Col span={6}>
<StatCard
label="Win Margin"
value={avgMarginOfVictory > 0 ? parseFloat(avgMarginOfVictory.toFixed(1)) : null}
Icon={ArrowUpIcon}
/>
</Grid.Col>
<Grid.Col span={6}>
<StatCard
label="Loss Margin"
value={avgMarginOfLoss > 0 ? parseFloat(avgMarginOfLoss.toFixed(1)) : null}
Icon={ArrowDownIcon}
/>
</Grid.Col>
</Grid>
</Stack>
{/* Team Performance */}
{statsData.length > 1 && (
<>
<Divider />
<Stack gap="sm">
<Text size="md" fw={600} c="dark">Team Performance</Text>
<Stack gap="xs">
{statsData.map((stat) => {
const teamWinRate = (stat.wins / stat.matches) * 100;
return (
<Card key={stat.id} p="md" radius="md" withBorder>
<Group justify="space-between" align="center">
<Group gap="sm">
<Avatar
size="md"
color={getWinRateColor(teamWinRate)}
radius="lg"
>
{stat.player_name.split(' ').map(n => n[0]).join('')}
</Avatar>
<Stack gap={2}>
<Text size="sm" fw={600}>{stat.player_name}</Text>
<Group gap="xs">
<Text size="xs" c="dimmed">
{stat.matches} matches
</Text>
<Text size="xs" c="dimmed"></Text>
<Text size="xs" c="green.6">
{stat.wins}W
</Text>
<Text size="xs" c="red.6">
{stat.losses}L
</Text>
</Group>
</Stack>
</Group>
<Stack gap={4} align="flex-end">
<Badge
size="sm"
variant="light"
color={getWinRateColor(teamWinRate)}
>
{teamWinRate.toFixed(0)}%
</Badge>
<Progress
value={teamWinRate}
size="xs"
w={60}
color={getWinRateColor(teamWinRate)}
/>
</Stack>
</Group>
</Card>
);
})}
</Stack>
</Stack>
</>
)}
</Stack>
</Box>
);
};
export default StatsOverview;