player h2h
This commit is contained in:
@@ -4,6 +4,9 @@ import PlayerStatsTable from "@/features/players/components/player-stats-table";
|
||||
import { Suspense } from "react";
|
||||
import PlayerStatsTableSkeleton from "@/features/players/components/player-stats-table-skeleton";
|
||||
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
||||
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||
import LeagueHeadToHead from "@/features/players/components/league-head-to-head";
|
||||
import { Box } from "@mantine/core";
|
||||
|
||||
export const Route = createFileRoute("/_authed/stats")({
|
||||
component: Stats,
|
||||
@@ -22,7 +25,22 @@ export const Route = createFileRoute("/_authed/stats")({
|
||||
});
|
||||
|
||||
function Stats() {
|
||||
return <Suspense fallback={<PlayerStatsTableSkeleton />}>
|
||||
const tabs = [
|
||||
{
|
||||
label: "Stats",
|
||||
content: (
|
||||
<Suspense fallback={<PlayerStatsTableSkeleton />}>
|
||||
<PlayerStatsTable />
|
||||
</Suspense>;
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Head to Head",
|
||||
content: <LeagueHeadToHead />,
|
||||
},
|
||||
];
|
||||
|
||||
return <Box mt={-20}>
|
||||
<SwipeableTabs mb={0} tabs={tabs} />
|
||||
</Box>;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { XIcon } from "@phosphor-icons/react";
|
||||
|
||||
interface AvatarProps
|
||||
extends Omit<MantineAvatarProps, "radius" | "color" | "size"> {
|
||||
name: string;
|
||||
name?: string;
|
||||
size?: number;
|
||||
radius?: string | number;
|
||||
withBorder?: boolean;
|
||||
|
||||
@@ -18,6 +18,7 @@ interface TabItem {
|
||||
interface SwipeableTabsProps {
|
||||
tabs: TabItem[];
|
||||
defaultTab?: number;
|
||||
mb?: string | number;
|
||||
onTabChange?: (index: number, tab: TabItem) => void;
|
||||
}
|
||||
|
||||
@@ -25,6 +26,7 @@ function SwipeableTabs({
|
||||
tabs,
|
||||
defaultTab = 0,
|
||||
onTabChange,
|
||||
mb,
|
||||
}: SwipeableTabsProps) {
|
||||
const router = useRouter();
|
||||
const search = router.state.location.search as any;
|
||||
@@ -144,7 +146,7 @@ function SwipeableTabs({
|
||||
style={{
|
||||
display: "flex",
|
||||
paddingInline: "var(--mantine-spacing-md)",
|
||||
marginBottom: "var(--mantine-spacing-md)",
|
||||
marginBottom: mb !== undefined ? mb : "var(--mantine-spacing-md)",
|
||||
zIndex: 100,
|
||||
backgroundColor: "var(--mantine-color-body)",
|
||||
}}
|
||||
|
||||
@@ -257,7 +257,7 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
|
||||
title="Head to Head"
|
||||
{...h2hSheet.props}
|
||||
>
|
||||
<TeamHeadToHeadSheet team1={match.home} team2={match.away} />
|
||||
<TeamHeadToHeadSheet team1={match.home} team2={match.away} isOpen={h2hSheet.props.opened} />
|
||||
</Sheet>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
import { Stack, Text, Group, Box, Divider, Paper } from "@mantine/core";
|
||||
import { TeamInfo } from "@/features/teams/types";
|
||||
import { useTeamHeadToHead } from "../queries";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useEffect, useState } from "react";
|
||||
import { CrownIcon, TrophyIcon } from "@phosphor-icons/react";
|
||||
import MatchList from "./match-list";
|
||||
|
||||
interface TeamHeadToHeadSheetProps {
|
||||
team1: TeamInfo;
|
||||
team2: TeamInfo;
|
||||
isOpen?: boolean;
|
||||
}
|
||||
|
||||
const TeamHeadToHeadSheet = ({ team1, team2 }: TeamHeadToHeadSheetProps) => {
|
||||
const { data: matches, isLoading } = useTeamHeadToHead(team1.id, team2.id);
|
||||
const TeamHeadToHeadSheet = ({ team1, team2, isOpen = true }: TeamHeadToHeadSheetProps) => {
|
||||
const [shouldFetch, setShouldFetch] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && !shouldFetch) {
|
||||
setShouldFetch(true);
|
||||
}
|
||||
}, [isOpen, shouldFetch]);
|
||||
|
||||
const { data: matches, isLoading } = useTeamHeadToHead(team1.id, team2.id, shouldFetch);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (!matches || matches.length === 0) {
|
||||
@@ -33,6 +42,8 @@ const TeamHeadToHeadSheet = ({ team1, team2 }: TeamHeadToHeadSheetProps) => {
|
||||
let team2CupsFor = 0;
|
||||
let team1CupsAgainst = 0;
|
||||
let team2CupsAgainst = 0;
|
||||
let team1TotalWinMargin = 0;
|
||||
let team2TotalWinMargin = 0;
|
||||
|
||||
matches.forEach((match) => {
|
||||
const isTeam1Home = match.home?.id === team1.id;
|
||||
@@ -41,8 +52,10 @@ const TeamHeadToHeadSheet = ({ team1, team2 }: TeamHeadToHeadSheetProps) => {
|
||||
|
||||
if (team1Cups > team2Cups) {
|
||||
team1Wins++;
|
||||
team1TotalWinMargin += (team1Cups - team2Cups);
|
||||
} else if (team2Cups > team1Cups) {
|
||||
team2Wins++;
|
||||
team2TotalWinMargin += (team2Cups - team1Cups);
|
||||
}
|
||||
|
||||
team1CupsFor += team1Cups;
|
||||
@@ -52,10 +65,10 @@ const TeamHeadToHeadSheet = ({ team1, team2 }: TeamHeadToHeadSheetProps) => {
|
||||
});
|
||||
|
||||
const team1AvgMargin = team1Wins > 0
|
||||
? (team1CupsFor - team1CupsAgainst) / team1Wins
|
||||
? team1TotalWinMargin / team1Wins
|
||||
: 0;
|
||||
const team2AvgMargin = team2Wins > 0
|
||||
? (team2CupsFor - team2CupsAgainst) / team2Wins
|
||||
? team2TotalWinMargin / team2Wins
|
||||
: 0;
|
||||
|
||||
return {
|
||||
@@ -88,7 +101,7 @@ const TeamHeadToHeadSheet = ({ team1, team2 }: TeamHeadToHeadSheetProps) => {
|
||||
);
|
||||
}
|
||||
|
||||
const totalGames = stats.team1Wins + stats.team2Wins;
|
||||
const totalMatches = stats.team1Wins + stats.team2Wins;
|
||||
const leader = stats.team1Wins > stats.team2Wins ? team1 : stats.team2Wins > stats.team1Wins ? team2 : null;
|
||||
|
||||
return (
|
||||
@@ -122,7 +135,7 @@ const TeamHeadToHeadSheet = ({ team1, team2 }: TeamHeadToHeadSheetProps) => {
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{!leader && totalGames > 0 && (
|
||||
{!leader && totalMatches > 0 && (
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
Series is tied
|
||||
</Text>
|
||||
@@ -151,15 +164,15 @@ const TeamHeadToHeadSheet = ({ team1, team2 }: TeamHeadToHeadSheetProps) => {
|
||||
<Group justify="space-between" px="md" py="sm">
|
||||
<Group gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
{totalGames > 0 ? (stats.team1CupsFor / totalGames).toFixed(1) : '0.0'}
|
||||
{totalMatches > 0 ? (stats.team1CupsFor / totalMatches).toFixed(1) : '0.0'}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">avg</Text>
|
||||
</Group>
|
||||
<Text size="xs" fw={500}>Avg Cups/Game</Text>
|
||||
<Text size="xs" fw={500}>Avg Cups/Match</Text>
|
||||
<Group gap="xs">
|
||||
<Text size="xs" c="dimmed">avg</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{totalGames > 0 ? (stats.team2CupsFor / totalGames).toFixed(1) : '0.0'}
|
||||
{totalMatches > 0 ? (stats.team2CupsFor / totalMatches).toFixed(1) : '0.0'}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
@@ -185,7 +198,7 @@ const TeamHeadToHeadSheet = ({ team1, team2 }: TeamHeadToHeadSheetProps) => {
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600} px="md">Match History ({totalGames} games)</Text>
|
||||
<Text size="sm" fw={600} px="md">Match History ({totalMatches} match{totalMatches !== 1 ? 'es' : ''})</Text>
|
||||
<MatchList matches={matches} hideH2H />
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
@@ -17,8 +17,14 @@ export const matchQueries = {
|
||||
}),
|
||||
};
|
||||
|
||||
export const useTeamHeadToHead = (team1Id: string, team2Id: string) =>
|
||||
useServerQuery(matchQueries.headToHeadTeams(team1Id, team2Id));
|
||||
export const useTeamHeadToHead = (team1Id: string, team2Id: string, enabled = true) =>
|
||||
useServerQuery({
|
||||
...matchQueries.headToHeadTeams(team1Id, team2Id),
|
||||
enabled,
|
||||
});
|
||||
|
||||
export const usePlayerHeadToHead = (player1Id: string, player2Id: string) =>
|
||||
useServerQuery(matchQueries.headToHeadPlayers(player1Id, player2Id));
|
||||
export const usePlayerHeadToHead = (player1Id: string, player2Id: string, enabled = true) =>
|
||||
useServerQuery({
|
||||
...matchQueries.headToHeadPlayers(player1Id, player2Id),
|
||||
enabled,
|
||||
});
|
||||
|
||||
276
src/features/players/components/league-head-to-head.tsx
Normal file
276
src/features/players/components/league-head-to-head.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { Stack, Text, TextInput, Box, Paper, Group, Divider, Center, ActionIcon, Badge } from "@mantine/core";
|
||||
import { useState, useMemo } from "react";
|
||||
import { MagnifyingGlassIcon, XIcon, ArrowRightIcon } from "@phosphor-icons/react";
|
||||
import { useAllPlayerStats } from "../queries";
|
||||
import { useSheet } from "@/hooks/use-sheet";
|
||||
import Sheet from "@/components/sheet/sheet";
|
||||
import PlayerHeadToHeadSheet from "./player-head-to-head-sheet";
|
||||
import Avatar from "@/components/avatar";
|
||||
|
||||
const LeagueHeadToHead = () => {
|
||||
const [player1Id, setPlayer1Id] = useState<string | null>(null);
|
||||
const [player2Id, setPlayer2Id] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const { data: allPlayerStats } = useAllPlayerStats();
|
||||
const h2hSheet = useSheet();
|
||||
|
||||
const player1Name = useMemo(() => {
|
||||
if (!player1Id || !allPlayerStats) return "";
|
||||
return allPlayerStats.find((p) => p.player_id === player1Id)?.player_name || "";
|
||||
}, [player1Id, allPlayerStats]);
|
||||
|
||||
const player2Name = useMemo(() => {
|
||||
if (!player2Id || !allPlayerStats) return "";
|
||||
return allPlayerStats.find((p) => p.player_id === player2Id)?.player_name || "";
|
||||
}, [player2Id, allPlayerStats]);
|
||||
|
||||
const filteredPlayers = useMemo(() => {
|
||||
if (!allPlayerStats) return [];
|
||||
|
||||
return allPlayerStats
|
||||
.filter((stat) => {
|
||||
if (player1Id && stat.player_id === player1Id) return false;
|
||||
if (player2Id && stat.player_id === player2Id) return false;
|
||||
return true;
|
||||
})
|
||||
.filter((stat) =>
|
||||
stat.player_name.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
.sort((a, b) => b.matches - a.matches);
|
||||
}, [allPlayerStats, player1Id, player2Id, search]);
|
||||
|
||||
const handlePlayerClick = (playerId: string) => {
|
||||
if (!player1Id) {
|
||||
setPlayer1Id(playerId);
|
||||
} else if (!player2Id) {
|
||||
setPlayer2Id(playerId);
|
||||
h2hSheet.open();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearPlayer1 = () => {
|
||||
setPlayer1Id(null);
|
||||
if (player2Id) {
|
||||
setPlayer1Id(player2Id);
|
||||
setPlayer2Id(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearPlayer2 = () => {
|
||||
setPlayer2Id(null);
|
||||
};
|
||||
|
||||
const activeStep = !player1Id ? 1 : !player2Id ? 2 : 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack gap="md">
|
||||
<Paper px="md" pt="md" pb="sm" withBorder shadow="sm">
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Paper
|
||||
p="sm"
|
||||
withBorder
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 70,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
borderWidth: 2,
|
||||
borderColor: activeStep === 1 ? "var(--mantine-primary-color-filled)" : undefined,
|
||||
backgroundColor: player1Id && activeStep !== 1 ? "var(--mantine-color-default-hover)" : undefined,
|
||||
cursor: player1Id && activeStep === 0 ? "pointer" : undefined,
|
||||
transition: "all 150ms ease",
|
||||
}}
|
||||
onClick={player1Id && activeStep === 0 ? handleClearPlayer1 : undefined}
|
||||
>
|
||||
{player1Id ? (
|
||||
<>
|
||||
<Stack gap={4} align="center" style={{ flex: 1 }}>
|
||||
<Avatar name={player1Name} size={36} />
|
||||
<Text size="xs" fw={600} ta="center" lineClamp={1}>
|
||||
{player1Name}
|
||||
</Text>
|
||||
</Stack>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="gray"
|
||||
size="xs"
|
||||
radius="xl"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClearPlayer1();
|
||||
}}
|
||||
style={{ position: "absolute", top: 4, right: 4 }}
|
||||
>
|
||||
<XIcon size={10} />
|
||||
</ActionIcon>
|
||||
</>
|
||||
) : (
|
||||
<Stack gap={4} align="center">
|
||||
<Avatar size={36} />
|
||||
<Text size="xs" c="dimmed" fw={500}>
|
||||
Player 1
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Center>
|
||||
<Text size="xl" fw={700} c="dimmed">
|
||||
VS
|
||||
</Text>
|
||||
</Center>
|
||||
|
||||
<Paper
|
||||
p="sm"
|
||||
withBorder
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 70,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
borderWidth: 2,
|
||||
borderColor: activeStep === 2 ? "var(--mantine-primary-color-filled)" : undefined,
|
||||
backgroundColor: player2Id && activeStep !== 2 ? "var(--mantine-color-default-hover)" : undefined,
|
||||
cursor: player2Id && activeStep === 0 ? "pointer" : undefined,
|
||||
transition: "all 150ms ease",
|
||||
}}
|
||||
onClick={player2Id && activeStep === 0 ? handleClearPlayer2 : undefined}
|
||||
>
|
||||
{player2Id ? (
|
||||
<>
|
||||
<Stack gap={4} align="center" style={{ flex: 1 }}>
|
||||
<Avatar name={player2Name} size={36} />
|
||||
<Text size="xs" fw={600} ta="center" lineClamp={1}>
|
||||
{player2Name}
|
||||
</Text>
|
||||
</Stack>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="gray"
|
||||
size="xs"
|
||||
radius="xl"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClearPlayer2();
|
||||
}}
|
||||
style={{ position: "absolute", top: 4, right: 4 }}
|
||||
>
|
||||
<XIcon size={10} />
|
||||
</ActionIcon>
|
||||
</>
|
||||
) : (
|
||||
<Stack gap={4} align="center">
|
||||
<Avatar size={36} />
|
||||
<Text size="xs" c="dimmed" fw={500}>
|
||||
Player 2
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
</Group>
|
||||
|
||||
{activeStep > 0 ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
size="sm"
|
||||
radius="sm"
|
||||
fullWidth
|
||||
styles={{ label: { textTransform: "none" } }}
|
||||
>
|
||||
{activeStep === 1 && "Step 1: Select first player"}
|
||||
{activeStep === 2 && "Step 2: Select second player"}
|
||||
</Badge>
|
||||
) : (
|
||||
<Group justify="center">
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => {
|
||||
setPlayer1Id(null);
|
||||
setPlayer2Id(null);
|
||||
}}
|
||||
td="underline"
|
||||
>
|
||||
Clear both players
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<TextInput
|
||||
placeholder="Search players"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
leftSection={<MagnifyingGlassIcon size={16} />}
|
||||
size="md"
|
||||
px="md"
|
||||
/>
|
||||
|
||||
<Box px="md" pb="md">
|
||||
<Paper withBorder>
|
||||
{filteredPlayers.length === 0 && (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
{search ? `No players found matching "${search}"` : "No players available"}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{filteredPlayers.map((player, index) => (
|
||||
<Box key={player.player_id}>
|
||||
<Group
|
||||
p="md"
|
||||
gap="sm"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
transition: "background-color 150ms ease",
|
||||
}}
|
||||
onClick={() => handlePlayerClick(player.player_id)}
|
||||
styles={{
|
||||
root: {
|
||||
"&:hover": {
|
||||
backgroundColor: "var(--mantine-color-default-hover)",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Avatar name={player.player_name} size={44} />
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{player.player_name}
|
||||
</Text>
|
||||
</Box>
|
||||
<ActionIcon variant="subtle" color="gray" size="lg" radius="xl">
|
||||
<ArrowRightIcon size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
{index < filteredPlayers.length - 1 && <Divider />}
|
||||
</Box>
|
||||
))}
|
||||
</Paper>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{player1Id && player2Id && (
|
||||
<Sheet title="Head to Head" {...h2hSheet.props}>
|
||||
<PlayerHeadToHeadSheet
|
||||
player1Id={player1Id}
|
||||
player1Name={player1Name}
|
||||
player2Id={player2Id}
|
||||
player2Name={player2Name}
|
||||
isOpen={h2hSheet.props.opened}
|
||||
/>
|
||||
</Sheet>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LeagueHeadToHead;
|
||||
270
src/features/players/components/player-head-to-head-sheet.tsx
Normal file
270
src/features/players/components/player-head-to-head-sheet.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import { Stack, Text, Group, Box, Divider, Paper } from "@mantine/core";
|
||||
import { usePlayerHeadToHead } from "@/features/matches/queries";
|
||||
import { useMemo, useEffect, useState } from "react";
|
||||
import { CrownIcon } from "@phosphor-icons/react";
|
||||
import MatchList from "@/features/matches/components/match-list";
|
||||
|
||||
interface PlayerHeadToHeadSheetProps {
|
||||
player1Id: string;
|
||||
player1Name: string;
|
||||
player2Id: string;
|
||||
player2Name: string;
|
||||
isOpen?: boolean;
|
||||
}
|
||||
|
||||
const PlayerHeadToHeadSheet = ({
|
||||
player1Id,
|
||||
player1Name,
|
||||
player2Id,
|
||||
player2Name,
|
||||
isOpen = true,
|
||||
}: PlayerHeadToHeadSheetProps) => {
|
||||
const [shouldFetch, setShouldFetch] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && !shouldFetch) {
|
||||
setShouldFetch(true);
|
||||
}
|
||||
}, [isOpen, shouldFetch]);
|
||||
|
||||
const { data: matches, isLoading } = usePlayerHeadToHead(player1Id, player2Id, shouldFetch);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (!matches || matches.length === 0) {
|
||||
return {
|
||||
player1Wins: 0,
|
||||
player2Wins: 0,
|
||||
player1CupsFor: 0,
|
||||
player2CupsFor: 0,
|
||||
player1CupsAgainst: 0,
|
||||
player2CupsAgainst: 0,
|
||||
player1AvgMargin: 0,
|
||||
player2AvgMargin: 0,
|
||||
};
|
||||
}
|
||||
|
||||
let player1Wins = 0;
|
||||
let player2Wins = 0;
|
||||
let player1CupsFor = 0;
|
||||
let player2CupsFor = 0;
|
||||
let player1CupsAgainst = 0;
|
||||
let player2CupsAgainst = 0;
|
||||
let player1TotalWinMargin = 0;
|
||||
let player2TotalWinMargin = 0;
|
||||
|
||||
matches.forEach((match) => {
|
||||
const isPlayer1Home = match.home?.players?.some((p) => p.id === player1Id);
|
||||
const player1Cups = isPlayer1Home ? match.home_cups : match.away_cups;
|
||||
const player2Cups = isPlayer1Home ? match.away_cups : match.home_cups;
|
||||
|
||||
if (player1Cups > player2Cups) {
|
||||
player1Wins++;
|
||||
player1TotalWinMargin += (player1Cups - player2Cups);
|
||||
} else if (player2Cups > player1Cups) {
|
||||
player2Wins++;
|
||||
player2TotalWinMargin += (player2Cups - player1Cups);
|
||||
}
|
||||
|
||||
player1CupsFor += player1Cups;
|
||||
player2CupsFor += player2Cups;
|
||||
player1CupsAgainst += player2Cups;
|
||||
player2CupsAgainst += player1Cups;
|
||||
});
|
||||
|
||||
const player1AvgMargin =
|
||||
player1Wins > 0 ? player1TotalWinMargin / player1Wins : 0;
|
||||
const player2AvgMargin =
|
||||
player2Wins > 0 ? player2TotalWinMargin / player2Wins : 0;
|
||||
|
||||
return {
|
||||
player1Wins,
|
||||
player2Wins,
|
||||
player1CupsFor,
|
||||
player2CupsFor,
|
||||
player1CupsAgainst,
|
||||
player2CupsAgainst,
|
||||
player1AvgMargin,
|
||||
player2AvgMargin,
|
||||
};
|
||||
}, [matches, player1Id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Stack p="md" gap="md">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Loading...
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!matches || matches.length === 0) {
|
||||
return (
|
||||
<Stack p="md" gap="md">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
These players have not faced each other yet.
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const totalGames = stats.player1Wins + stats.player2Wins;
|
||||
const leader =
|
||||
stats.player1Wins > stats.player2Wins
|
||||
? player1Name
|
||||
: stats.player2Wins > stats.player1Wins
|
||||
? player2Name
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Paper p="md" withBorder radius="md">
|
||||
<Stack gap="sm">
|
||||
<Group justify="center" gap="xs">
|
||||
<Text size="lg" fw={700}>
|
||||
{player1Name}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
vs
|
||||
</Text>
|
||||
<Text size="lg" fw={700}>
|
||||
{player2Name}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Group justify="center" gap="lg">
|
||||
<Stack gap={0} align="center">
|
||||
<Text size="xl" fw={700}>
|
||||
{stats.player1Wins}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{player1Name}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Text size="md" c="dimmed">
|
||||
-
|
||||
</Text>
|
||||
<Stack gap={0} align="center">
|
||||
<Text size="xl" fw={700}>
|
||||
{stats.player2Wins}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{player2Name}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{leader && (
|
||||
<Group justify="center" gap="xs">
|
||||
<CrownIcon size={16} weight="fill" color="gold" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{leader} leads the series
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{!leader && totalGames > 0 && (
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
Series is tied
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw={600} px="md" mb="xs">
|
||||
Stats Comparison
|
||||
</Text>
|
||||
|
||||
<Paper withBorder>
|
||||
<Stack gap={0}>
|
||||
<Group justify="space-between" px="md" py="sm">
|
||||
<Group gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
{stats.player1CupsFor}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
cups
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" fw={500}>
|
||||
Total Cups
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
cups
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{stats.player2CupsFor}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
<Divider />
|
||||
|
||||
<Group justify="space-between" px="md" py="sm">
|
||||
<Group gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
{totalGames > 0
|
||||
? (stats.player1CupsFor / totalGames).toFixed(1)
|
||||
: "0.0"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
avg
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" fw={500}>
|
||||
Avg Cups/Game
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
avg
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{totalGames > 0
|
||||
? (stats.player2CupsFor / totalGames).toFixed(1)
|
||||
: "0.0"}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
<Divider />
|
||||
|
||||
<Group justify="space-between" px="md" py="sm">
|
||||
<Group gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
{!isNaN(stats.player1AvgMargin)
|
||||
? stats.player1AvgMargin.toFixed(1)
|
||||
: "0.0"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
margin
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" fw={500}>
|
||||
Avg Win Margin
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
margin
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{!isNaN(stats.player2AvgMargin)
|
||||
? stats.player2AvgMargin.toFixed(1)
|
||||
: "0.0"}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600} px="md">
|
||||
Match History ({totalGames} games)
|
||||
</Text>
|
||||
<MatchList matches={matches} hideH2H />
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlayerHeadToHeadSheet;
|
||||
@@ -306,7 +306,7 @@ const PlayerStatsTable = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="100%" px={0}>
|
||||
<Container size="100%" px={0} mt="md">
|
||||
<Stack gap="xs">
|
||||
<Text px="md" size="10px" lh={0} c="dimmed">
|
||||
Showing {filteredAndSortedStats.length} of {playerStats.length} players
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
import Sheet from "@/components/sheet/sheet";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { Flex, Title, ActionIcon } from "@mantine/core";
|
||||
import { PencilIcon } from "@phosphor-icons/react";
|
||||
import { Flex, Title, ActionIcon, Stack, Button, Box } from "@mantine/core";
|
||||
import { PencilIcon, FootballHelmetIcon } from "@phosphor-icons/react";
|
||||
import { useMemo } from "react";
|
||||
import NameUpdateForm from "./name-form";
|
||||
import Avatar from "@/components/avatar";
|
||||
import { useSheet } from "@/hooks/use-sheet";
|
||||
import { Player } from "../../types";
|
||||
import PlayerHeadToHeadSheet from "../player-head-to-head-sheet";
|
||||
|
||||
interface HeaderProps {
|
||||
player: Player;
|
||||
}
|
||||
|
||||
const Header = ({ player }: HeaderProps) => {
|
||||
const sheet = useSheet();
|
||||
const nameSheet = useSheet();
|
||||
const h2hSheet = useSheet();
|
||||
const { user: authUser } = useAuth();
|
||||
|
||||
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 authUserName = useMemo(() => {
|
||||
if (!authUser) return "";
|
||||
return `${authUser.first_name} ${authUser.last_name}`;
|
||||
}, [authUser]);
|
||||
|
||||
const fontSize = useMemo(() => {
|
||||
const baseSize = 28;
|
||||
@@ -33,19 +39,62 @@ const Header = ({ player }: HeaderProps) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack gap="sm" align="center" pt="md">
|
||||
<Flex h="15dvh" px='xl' w='100%' align='self-end' gap='md'>
|
||||
<Avatar name={name} size={100} />
|
||||
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
|
||||
<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={nameSheet.open}>
|
||||
<PencilIcon size={20} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
radius="xl"
|
||||
onClick={h2hSheet.open}
|
||||
w={40}
|
||||
display={!owner ? 'block' : 'none'}
|
||||
>
|
||||
<Box style={{ position: 'relative', width: 27.5, height: 16 }}>
|
||||
<FootballHelmetIcon
|
||||
size={14}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
transform: 'rotate(25deg)'
|
||||
}}
|
||||
/>
|
||||
<FootballHelmetIcon
|
||||
size={14}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
transform: 'scaleX(-1) rotate(25deg)'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</ActionIcon>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Stack>
|
||||
|
||||
<Sheet title='Update Name' {...sheet.props}>
|
||||
<NameUpdateForm player={player} toggle={sheet.toggle} />
|
||||
<Sheet title='Update Name' {...nameSheet.props}>
|
||||
<NameUpdateForm player={player} toggle={nameSheet.toggle} />
|
||||
</Sheet>
|
||||
|
||||
{!owner && authUser && (
|
||||
<Sheet title="Head to Head" {...h2hSheet.props}>
|
||||
<PlayerHeadToHeadSheet
|
||||
player1Id={authUser.id}
|
||||
player1Name={authUserName}
|
||||
player2Id={player.id}
|
||||
player2Name={name}
|
||||
isOpen={h2hSheet.props.opened}
|
||||
/>
|
||||
</Sheet>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ export function useServerQuery<TData>(
|
||||
queryFn: () => Promise<ServerResult<TData>>;
|
||||
options?: Omit<UseQueryOptions<TData, Error, TData>, 'queryFn' | 'queryKey'>
|
||||
showErrorToast?: boolean;
|
||||
enabled?: boolean;
|
||||
}
|
||||
) {
|
||||
const { queryKey, queryFn, showErrorToast = true, options: queryOptions } = options;
|
||||
|
||||
Reference in New Issue
Block a user