match list
This commit is contained in:
@@ -12,10 +12,16 @@ export const Route = createFileRoute("/_authed/profile/$playerId")({
|
|||||||
validateSearch: searchSchema,
|
validateSearch: searchSchema,
|
||||||
beforeLoad: async ({ params, context }) => {
|
beforeLoad: async ({ params, context }) => {
|
||||||
const { queryClient } = context;
|
const { queryClient } = context;
|
||||||
await prefetchServerQuery(
|
await Promise.all([
|
||||||
queryClient,
|
prefetchServerQuery(
|
||||||
playerQueries.details(params.playerId)
|
queryClient,
|
||||||
);
|
playerQueries.details(params.playerId)
|
||||||
|
),
|
||||||
|
prefetchServerQuery(
|
||||||
|
queryClient,
|
||||||
|
playerQueries.matches(params.playerId)
|
||||||
|
),
|
||||||
|
]);
|
||||||
},
|
},
|
||||||
loader: ({ params, context }) => ({
|
loader: ({ params, context }) => ({
|
||||||
header: {
|
header: {
|
||||||
|
|||||||
155
src/features/matches/components/match-card.tsx
Normal file
155
src/features/matches/components/match-card.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import {
|
||||||
|
Text,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
Paper,
|
||||||
|
ThemeIcon,
|
||||||
|
Indicator,
|
||||||
|
Box,
|
||||||
|
Badge,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { TrophyIcon, CrownIcon } from "@phosphor-icons/react";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import { Match } from "../types";
|
||||||
|
import Avatar from "@/components/avatar";
|
||||||
|
|
||||||
|
interface MatchCardProps {
|
||||||
|
match: Match;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MatchCard = ({ match }: MatchCardProps) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isHomeWin = match.home_cups > match.away_cups;
|
||||||
|
const isAwayWin = match.away_cups > match.home_cups;
|
||||||
|
const isStarted = match.status === "started";
|
||||||
|
|
||||||
|
const handleHomeTeamClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (match.home?.id) {
|
||||||
|
navigate({ to: `/teams/${match.home.id}` });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAwayTeamClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (match.away?.id) {
|
||||||
|
navigate({ to: `/teams/${match.away.id}` });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Indicator
|
||||||
|
disabled={!isStarted}
|
||||||
|
size={12}
|
||||||
|
color="red"
|
||||||
|
processing
|
||||||
|
position="top-end"
|
||||||
|
offset={2}
|
||||||
|
>
|
||||||
|
<Paper p="md" withBorder radius="md">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text size="xs" fw={600} lineClamp={1} c="dimmed">
|
||||||
|
{match.tournament.name}
|
||||||
|
</Text>
|
||||||
|
<Text c="dimmed">-</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Round {match.round}
|
||||||
|
{match.is_losers_bracket && " (Losers)"}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Group gap="sm" style={{ flex: 1 }}>
|
||||||
|
<Box
|
||||||
|
style={{ position: "relative", cursor: "pointer" }}
|
||||||
|
onClick={handleHomeTeamClick}
|
||||||
|
>
|
||||||
|
<Avatar size={40} name={match.home?.name!} radius="sm" />
|
||||||
|
{isHomeWin && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: -10,
|
||||||
|
left: -4,
|
||||||
|
transform: "rotate(-25deg)",
|
||||||
|
color: "gold",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CrownIcon size={16} weight="fill" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
fw={600}
|
||||||
|
lineClamp={1}
|
||||||
|
style={{ minWidth: 0, flex: 1 }}
|
||||||
|
>
|
||||||
|
{match.home?.name!}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap="sm" align="center">
|
||||||
|
<Text
|
||||||
|
size="md"
|
||||||
|
fw={700}
|
||||||
|
c={"dimmed"}
|
||||||
|
>
|
||||||
|
{match.home_cups}
|
||||||
|
</Text>
|
||||||
|
<Text size="md" c="dimmed" fw={500}>
|
||||||
|
-
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
size="md"
|
||||||
|
fw={700}
|
||||||
|
c={"dimmed"}
|
||||||
|
>
|
||||||
|
{match.away_cups}
|
||||||
|
</Text>
|
||||||
|
{match.ot_count > 0 && (
|
||||||
|
<Badge size="xs" color="orange" variant="light">
|
||||||
|
{match.ot_count}OT
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap="sm" style={{ flex: 1 }} justify="flex-end">
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
fw={600}
|
||||||
|
lineClamp={1}
|
||||||
|
ta="right"
|
||||||
|
style={{ minWidth: 0, flex: 1 }}
|
||||||
|
>
|
||||||
|
{match.away?.name}
|
||||||
|
</Text>
|
||||||
|
<Box
|
||||||
|
style={{ position: "relative", cursor: "pointer" }}
|
||||||
|
onClick={handleAwayTeamClick}
|
||||||
|
>
|
||||||
|
<Avatar size={40} name={match.away?.name!} radius="sm" />
|
||||||
|
{isAwayWin && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: -10,
|
||||||
|
right: -4,
|
||||||
|
transform: "rotate(25deg)",
|
||||||
|
color: "gold",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CrownIcon size={16} weight="fill" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Indicator>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MatchCard;
|
||||||
48
src/features/matches/components/match-list.tsx
Normal file
48
src/features/matches/components/match-list.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Stack, Text, ThemeIcon, Box } from "@mantine/core";
|
||||||
|
import { TrophyIcon } from "@phosphor-icons/react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { Match } from "../types";
|
||||||
|
import MatchCard from "./match-card";
|
||||||
|
|
||||||
|
interface MatchListProps {
|
||||||
|
matches: Match[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MatchList = ({ matches }: MatchListProps) => {
|
||||||
|
const filteredMatches = matches?.filter(match =>
|
||||||
|
match.home && match.away && !match.bye && match.status != "tbd"
|
||||||
|
) || [];
|
||||||
|
|
||||||
|
if (!filteredMatches.length) {
|
||||||
|
return (
|
||||||
|
<Box ta="center" py="xl">
|
||||||
|
<ThemeIcon size="xl" variant="light" radius="md" mb="md">
|
||||||
|
<TrophyIcon size={32} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text c="dimmed" size="lg">
|
||||||
|
No matches found
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<AnimatePresence>
|
||||||
|
{filteredMatches.map((match, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={`match-${match.id}-${index}`}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
transition={{ duration: 0.2, delay: index * 0.01 }}
|
||||||
|
>
|
||||||
|
<MatchCard match={match} />
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MatchList;
|
||||||
@@ -28,8 +28,8 @@ interface PlayerStatsTableProps {
|
|||||||
playerStats: PlayerStats[];
|
playerStats: PlayerStats[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortKey = keyof PlayerStats | 'mmr';
|
type SortKey = keyof PlayerStats | "mmr";
|
||||||
type SortDirection = 'asc' | 'desc';
|
type SortDirection = "asc" | "desc";
|
||||||
|
|
||||||
interface SortConfig {
|
interface SortConfig {
|
||||||
key: SortKey;
|
key: SortKey;
|
||||||
@@ -39,8 +39,8 @@ 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: 'mmr' as SortKey,
|
key: "mmr" as SortKey,
|
||||||
direction: 'desc'
|
direction: "desc",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate MMR (Match Making Rating) based on multiple factors
|
// Calculate MMR (Match Making Rating) based on multiple factors
|
||||||
@@ -56,18 +56,19 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
|
|||||||
|
|
||||||
// Performance metrics
|
// 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); // 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)
|
// 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); // Max 10 point bonus
|
||||||
|
|
||||||
// Weighted calculation
|
// Weighted calculation
|
||||||
const baseMMR = (
|
const baseMMR =
|
||||||
winScore * 0.5 + // Win % is 50% of score
|
winScore * 0.5 + // Win % is 50% of score
|
||||||
avgCupsScore * 0.25 + // Avg cups is 25% of score
|
avgCupsScore * 0.25 + // Avg cups is 25% of score
|
||||||
marginScore * 0.15 + // Win margin is 15% of score
|
marginScore * 0.15 + // Win margin is 15% of score
|
||||||
volumeBonus * 0.1 // Volume bonus is 10% of score
|
volumeBonus * 0.1; // Volume bonus is 10% of score
|
||||||
);
|
|
||||||
|
|
||||||
// Apply confidence factor (players with few matches get penalized)
|
// Apply confidence factor (players with few matches get penalized)
|
||||||
const finalMMR = baseMMR * matchConfidence;
|
const finalMMR = baseMMR * matchConfidence;
|
||||||
@@ -76,19 +77,23 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSort = (key: SortKey) => {
|
const handleSort = (key: SortKey) => {
|
||||||
setSortConfig(prev => ({
|
setSortConfig((prev) => ({
|
||||||
key,
|
key,
|
||||||
direction: prev.key === key && prev.direction === 'desc' ? 'asc' : 'desc'
|
direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc",
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSortIcon = (key: SortKey) => {
|
const getSortIcon = (key: SortKey) => {
|
||||||
if (sortConfig.key !== key) return null;
|
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(() => {
|
const filteredAndSortedStats = useMemo(() => {
|
||||||
let filtered = playerStats.filter(stat =>
|
let filtered = playerStats.filter((stat) =>
|
||||||
stat.player_name.toLowerCase().includes(search.toLowerCase())
|
stat.player_name.toLowerCase().includes(search.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -97,7 +102,7 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
|
|||||||
let bValue: number | string;
|
let bValue: number | string;
|
||||||
|
|
||||||
// Special handling for MMR
|
// Special handling for MMR
|
||||||
if (sortConfig.key === 'mmr') {
|
if (sortConfig.key === "mmr") {
|
||||||
aValue = calculateMMR(a);
|
aValue = calculateMMR(a);
|
||||||
bValue = calculateMMR(b);
|
bValue = calculateMMR(b);
|
||||||
} else {
|
} else {
|
||||||
@@ -105,12 +110,14 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
|
|||||||
bValue = b[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;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof aValue === 'string' && typeof bValue === 'string') {
|
if (typeof aValue === "string" && typeof bValue === "string") {
|
||||||
return sortConfig.direction === 'desc'
|
return sortConfig.direction === "desc"
|
||||||
? bValue.localeCompare(aValue)
|
? bValue.localeCompare(aValue)
|
||||||
: aValue.localeCompare(bValue);
|
: aValue.localeCompare(bValue);
|
||||||
}
|
}
|
||||||
@@ -123,52 +130,66 @@ 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: 175 },
|
{ key: "player_name" as SortKey, label: "Player", width: 175 },
|
||||||
{ key: 'mmr' as SortKey, label: 'MMR', width: 90 },
|
{ 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 },
|
||||||
{ key: 'losses' as SortKey, label: 'Losses', width: 80 },
|
{ key: "losses" as SortKey, label: "Losses", width: 80 },
|
||||||
{ key: 'total_cups_made' as SortKey, label: 'Cups Made', width: 110 },
|
{ key: "total_cups_made" as SortKey, label: "Cups Made", width: 110 },
|
||||||
{ key: 'total_cups_against' as SortKey, label: 'Cups Against', width: 120 },
|
{ key: "total_cups_against" as SortKey, label: "Cups Against", width: 120 },
|
||||||
{ key: 'avg_cups_per_match' as SortKey, label: 'Avg/Match', width: 100 },
|
{ 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_victory" as SortKey, label: "Win Margin", width: 110 },
|
||||||
{ key: 'margin_of_loss' as SortKey, label: 'Loss 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) {
|
switch (column.key) {
|
||||||
case 'player_name':
|
case "player_name":
|
||||||
return <Text size='sm' fw={600}>{stat.player_name}</Text>
|
return (
|
||||||
case 'mmr':
|
<Text size="sm" fw={600}>
|
||||||
|
{stat.player_name}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
case "mmr":
|
||||||
const mmr = calculateMMR(stat);
|
const mmr = calculateMMR(stat);
|
||||||
return (
|
return (
|
||||||
<Box>
|
<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)}
|
{mmr.toFixed(1)}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</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 (
|
return (
|
||||||
<Text size='sm' c={stat.win_percentage >= 70 ? "green" : stat.win_percentage >= 50 ? "yellow" : "red"}>
|
<Text>
|
||||||
{formatPercentage(stat.win_percentage)}
|
{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>
|
</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:
|
default:
|
||||||
return <Text>{(stat as any)[column.key]}</Text>;
|
return <Text>{(stat as any)[column.key]}</Text>;
|
||||||
}
|
}
|
||||||
@@ -181,7 +202,9 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
|
|||||||
<ThemeIcon size="xl" variant="light" radius="md">
|
<ThemeIcon size="xl" variant="light" radius="md">
|
||||||
<ChartBarIcon size={32} />
|
<ChartBarIcon size={32} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Title order={3} c="dimmed">No Stats Available</Title>
|
<Title order={3} c="dimmed">
|
||||||
|
No Stats Available
|
||||||
|
</Title>
|
||||||
<Text c="dimmed" ta="center">
|
<Text c="dimmed" ta="center">
|
||||||
Player statistics will appear here once matches have been played.
|
Player statistics will appear here once matches have been played.
|
||||||
</Text>
|
</Text>
|
||||||
@@ -193,7 +216,7 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
|
|||||||
return (
|
return (
|
||||||
<Container size="100%" px={0}>
|
<Container size="100%" px={0}>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text ml='auto' size='xs' c="dimmed">
|
<Text ml="auto" size="xs" c="dimmed">
|
||||||
{filteredAndSortedStats.length} of {playerStats.length} players
|
{filteredAndSortedStats.length} of {playerStats.length} players
|
||||||
</Text>
|
</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -204,8 +227,7 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
|
|||||||
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
|
||||||
highlightOnHover
|
highlightOnHover
|
||||||
@@ -213,15 +235,15 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
|
|||||||
withTableBorder={false}
|
withTableBorder={false}
|
||||||
style={{
|
style={{
|
||||||
minWidth: 1000,
|
minWidth: 1000,
|
||||||
borderRadius: 'inherit',
|
borderRadius: "inherit",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Table.Thead
|
<Table.Thead
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--mantine-color-body)',
|
backgroundColor: "var(--mantine-color-body)",
|
||||||
position: 'sticky',
|
position: "sticky",
|
||||||
top: 0,
|
top: 0,
|
||||||
zIndex: 1
|
zIndex: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
@@ -229,31 +251,36 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
|
|||||||
<Table.Th
|
<Table.Th
|
||||||
key={column.key}
|
key={column.key}
|
||||||
style={{
|
style={{
|
||||||
cursor: 'pointer',
|
cursor: "pointer",
|
||||||
userSelect: 'none',
|
userSelect: "none",
|
||||||
width: column.width,
|
width: column.width,
|
||||||
minWidth: column.width,
|
minWidth: column.width,
|
||||||
padding: '12px 16px',
|
padding: "12px 16px",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
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',
|
position: "sticky",
|
||||||
left: 0,
|
left: 0,
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
borderTopLeftRadius: 'var(--mantine-radius-md)',
|
borderTopLeftRadius: "var(--mantine-radius-md)",
|
||||||
}),
|
}),
|
||||||
...(index === columns.length - 1 && {
|
...(index === columns.length - 1 && {
|
||||||
borderTopRightRadius: 'var(--mantine-radius-md)',
|
borderTopRightRadius: "var(--mantine-radius-md)",
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
onClick={() => handleSort(column.key)}
|
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}>
|
<Text size="sm" fw={600}>
|
||||||
{column.label}
|
{column.label}
|
||||||
</Text>
|
</Text>
|
||||||
{column.key === 'mmr' && (
|
{column.key === "mmr" && (
|
||||||
<div
|
<div
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -265,22 +292,30 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
|
|||||||
>
|
>
|
||||||
<Popover position="bottom" withArrow shadow="md">
|
<Popover position="bottom" withArrow shadow="md">
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<ActionIcon
|
<ActionIcon variant="subtle" size="xs">
|
||||||
variant="subtle"
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
<InfoIcon size={12} />
|
<InfoIcon size={12} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
<Popover.Dropdown>
|
<Popover.Dropdown>
|
||||||
<Box maw={280}>
|
<Box maw={280}>
|
||||||
<Text size="sm" fw={500} mb="xs">MMR Calculation:</Text>
|
<Text size="sm" fw={500} mb="xs">
|
||||||
<Text size="xs" mb={2}>• Win Rate (50%)</Text>
|
MMR Calculation:
|
||||||
<Text size="xs" mb={2}>• Average Cups/Match (25%)</Text>
|
</Text>
|
||||||
<Text size="xs" mb={2}>• Average Win Margin (15%)</Text>
|
<Text size="xs" mb={2}>
|
||||||
<Text size="xs" mb={2}>• Match Volume Bonus (10%)</Text>
|
• 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">
|
<Text size="xs" mt="xs" c="dimmed">
|
||||||
* Confidence penalty applied for players with <20 matches
|
* Confidence penalty applied for players
|
||||||
|
with <20 matches
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" mt="xs" c="dimmed">
|
<Text size="xs" mt="xs" c="dimmed">
|
||||||
** Not an official rating
|
** Not an official rating
|
||||||
@@ -290,18 +325,25 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
|
|||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</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 && (
|
{index === 0 && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: "absolute",
|
||||||
top: 0,
|
top: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
width: '2px',
|
width: "2px",
|
||||||
backgroundColor: 'var(--mantine-color-default-border)',
|
backgroundColor:
|
||||||
|
"var(--mantine-color-default-border)",
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -319,34 +361,36 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.2, delay: index * 0.01 }}
|
transition={{ duration: 0.2, delay: index * 0.01 }}
|
||||||
style={{
|
style={{
|
||||||
borderBottom: '1px solid var(--mantine-color-default-border)',
|
borderBottom:
|
||||||
|
"1px solid var(--mantine-color-default-border)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{columns.map((column, columnIndex) => (
|
{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 && {
|
...(columnIndex === 0 && {
|
||||||
position: 'sticky',
|
position: "sticky",
|
||||||
left: 0,
|
left: 0,
|
||||||
backgroundColor: 'var(--mantine-color-body)',
|
backgroundColor: "var(--mantine-color-body)",
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: "relative" }}>
|
||||||
{renderCellContent(stat, column, index)}
|
{renderCellContent(stat, column, index)}
|
||||||
{columnIndex === 0 && (
|
{columnIndex === 0 && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: "absolute",
|
||||||
top: 0,
|
top: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
width: '2px',
|
width: "2px",
|
||||||
backgroundColor: 'var(--mantine-color-default-border)',
|
backgroundColor:
|
||||||
|
"var(--mantine-color-default-border)",
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Box, Text } from "@mantine/core";
|
import { Box } from "@mantine/core";
|
||||||
import Header from "./header";
|
import Header from "./header";
|
||||||
import { Player } from "@/features/players/types";
|
import { Player } from "@/features/players/types";
|
||||||
import SwipeableTabs from "@/components/swipeable-tabs";
|
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||||
import { usePlayer } from "../../queries";
|
import { usePlayer, usePlayerMatches } from "../../queries";
|
||||||
import TeamList from "@/features/teams/components/team-list";
|
import TeamList from "@/features/teams/components/team-list";
|
||||||
import StatsOverview from "../stats-overview";
|
import StatsOverview from "../stats-overview";
|
||||||
|
import MatchList from "@/features/matches/components/match-list";
|
||||||
|
|
||||||
interface ProfileProps {
|
interface ProfileProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -12,6 +13,8 @@ interface ProfileProps {
|
|||||||
|
|
||||||
const Profile = ({ id }: ProfileProps) => {
|
const Profile = ({ id }: ProfileProps) => {
|
||||||
const { data: player } = usePlayer(id);
|
const { data: player } = usePlayer(id);
|
||||||
|
const { data: matches } = usePlayerMatches(id);
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
label: "Overview",
|
label: "Overview",
|
||||||
@@ -19,7 +22,7 @@ const Profile = ({ id }: ProfileProps) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Matches",
|
label: "Matches",
|
||||||
content: <Text p="md">Matches feed will go here</Text>,
|
content: <Box p="md"><MatchList matches={matches || []} /></Box>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Teams",
|
label: "Teams",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
||||||
import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe, getPlayerStats, getAllPlayerStats } from "./server";
|
import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe, getPlayerStats, getAllPlayerStats, getPlayerMatches } from "./server";
|
||||||
|
|
||||||
export const playerKeys = {
|
export const playerKeys = {
|
||||||
auth: ['auth'],
|
auth: ['auth'],
|
||||||
@@ -8,6 +8,7 @@ export const playerKeys = {
|
|||||||
unassociated: ['players','unassociated'],
|
unassociated: ['players','unassociated'],
|
||||||
stats: (id: string) => ['players', 'stats', id],
|
stats: (id: string) => ['players', 'stats', id],
|
||||||
allStats: ['players', 'stats', 'all'],
|
allStats: ['players', 'stats', 'all'],
|
||||||
|
matches: (id: string) => ['players', 'matches', id],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const playerQueries = {
|
export const playerQueries = {
|
||||||
@@ -35,6 +36,10 @@ export const playerQueries = {
|
|||||||
queryKey: playerKeys.allStats,
|
queryKey: playerKeys.allStats,
|
||||||
queryFn: async () => await getAllPlayerStats()
|
queryFn: async () => await getAllPlayerStats()
|
||||||
}),
|
}),
|
||||||
|
matches: (id: string) => ({
|
||||||
|
queryKey: playerKeys.matches(id),
|
||||||
|
queryFn: async () => await getPlayerMatches({ data: id })
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useMe = () => {
|
export const useMe = () => {
|
||||||
@@ -74,3 +79,6 @@ export const usePlayerStats = (id: string) =>
|
|||||||
|
|
||||||
export const useAllPlayerStats = () =>
|
export const useAllPlayerStats = () =>
|
||||||
useServerSuspenseQuery(playerQueries.allStats());
|
useServerSuspenseQuery(playerQueries.allStats());
|
||||||
|
|
||||||
|
export const usePlayerMatches = (id: string) =>
|
||||||
|
useServerSuspenseQuery(playerQueries.matches(id));
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { setUserMetadata, superTokensFunctionMiddleware, getSessionContext } from "@/utils/supertokens";
|
import { setUserMetadata, superTokensFunctionMiddleware, getSessionContext } from "@/utils/supertokens";
|
||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { Player, playerInputSchema, playerUpdateSchema, PlayerStats } from "@/features/players/types";
|
import { Player, playerInputSchema, playerUpdateSchema, PlayerStats } from "@/features/players/types";
|
||||||
|
import { Match } from "@/features/matches/types";
|
||||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { logger } from ".";
|
import { logger } from ".";
|
||||||
@@ -132,3 +133,10 @@ export const getAllPlayerStats = createServerFn()
|
|||||||
.handler(async () =>
|
.handler(async () =>
|
||||||
toServerResult<PlayerStats[]>(async () => await pbAdmin.getAllPlayerStats())
|
toServerResult<PlayerStats[]>(async () => await pbAdmin.getAllPlayerStats())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getPlayerMatches = createServerFn()
|
||||||
|
.validator(z.string())
|
||||||
|
.middleware([superTokensFunctionMiddleware])
|
||||||
|
.handler(async ({ data }) =>
|
||||||
|
toServerResult<Match[]>(async () => await pbAdmin.getPlayerMatches(data))
|
||||||
|
);
|
||||||
@@ -3,7 +3,7 @@ import { useNavigate } from "@tanstack/react-router";
|
|||||||
import Avatar from "@/components/avatar";
|
import Avatar from "@/components/avatar";
|
||||||
import { TournamentInfo } from "../types";
|
import { TournamentInfo } from "../types";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { motion } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { TrophyIcon, CalendarIcon, MapPinIcon } from "@phosphor-icons/react";
|
import { TrophyIcon, CalendarIcon, MapPinIcon } from "@phosphor-icons/react";
|
||||||
|
|
||||||
interface TournamentListProps {
|
interface TournamentListProps {
|
||||||
@@ -51,15 +51,17 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) =
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
{tournaments.map((tournament, index) => {
|
<AnimatePresence>
|
||||||
|
{tournaments.map((tournament, index) => {
|
||||||
const startDate = tournament.start_time ? new Date(tournament.start_time) : null;
|
const startDate = tournament.start_time ? new Date(tournament.start_time) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={tournament.id}
|
key={`tournament-${tournament.id}-${index}`}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.3, delay: index * 0.05 }}
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
transition={{ duration: 0.2, delay: index * 0.01 }}
|
||||||
whileHover={{ y: -2 }}
|
whileHover={{ y: -2 }}
|
||||||
whileTap={{ scale: 0.98 }}
|
whileTap={{ scale: 0.98 }}
|
||||||
>
|
>
|
||||||
@@ -135,7 +137,8 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) =
|
|||||||
</Box>
|
</Box>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</AnimatePresence>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import type {
|
|||||||
PlayerUpdateInput,
|
PlayerUpdateInput,
|
||||||
PlayerStats,
|
PlayerStats,
|
||||||
} from "@/features/players/types";
|
} from "@/features/players/types";
|
||||||
import { transformPlayer, transformPlayerInfo } from "@/lib/pocketbase/util/transform-types";
|
import type { Match } from "@/features/matches/types";
|
||||||
|
import { transformPlayer, transformPlayerInfo, transformMatch } from "@/lib/pocketbase/util/transform-types";
|
||||||
import PocketBase from "pocketbase";
|
import PocketBase from "pocketbase";
|
||||||
import { DataFetchOptions } from "./base";
|
import { DataFetchOptions } from "./base";
|
||||||
|
|
||||||
@@ -77,5 +78,29 @@ export function createPlayersService(pb: PocketBase) {
|
|||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getPlayerMatches(playerId: string): Promise<Match[]> {
|
||||||
|
const player = await pb.collection("players").getOne(playerId, {
|
||||||
|
expand: "teams",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!player.expand?.teams || player.expand.teams.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamIds = Array.isArray(player.expand.teams)
|
||||||
|
? player.expand.teams.map((team: any) => team.id)
|
||||||
|
: [player.expand.teams.id];
|
||||||
|
|
||||||
|
const teamFilter = teamIds.map(teamId => `home = "${teamId}" || away = "${teamId}"`).join(" || ");
|
||||||
|
|
||||||
|
const result = await pb.collection("matches").getFullList({
|
||||||
|
filter: `(${teamFilter}) && (status = "ended" || status = "started")`,
|
||||||
|
sort: "-created",
|
||||||
|
expand: "tournament,home,away",
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.map(transformMatch);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user