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 { Suspense } from "react";
|
||||||
import PlayerStatsTableSkeleton from "@/features/players/components/player-stats-table-skeleton";
|
import PlayerStatsTableSkeleton from "@/features/players/components/player-stats-table-skeleton";
|
||||||
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
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")({
|
export const Route = createFileRoute("/_authed/stats")({
|
||||||
component: Stats,
|
component: Stats,
|
||||||
@@ -22,7 +25,22 @@ export const Route = createFileRoute("/_authed/stats")({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function Stats() {
|
function Stats() {
|
||||||
return <Suspense fallback={<PlayerStatsTableSkeleton />}>
|
const tabs = [
|
||||||
|
{
|
||||||
|
label: "Stats",
|
||||||
|
content: (
|
||||||
|
<Suspense fallback={<PlayerStatsTableSkeleton />}>
|
||||||
<PlayerStatsTable />
|
<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
|
interface AvatarProps
|
||||||
extends Omit<MantineAvatarProps, "radius" | "color" | "size"> {
|
extends Omit<MantineAvatarProps, "radius" | "color" | "size"> {
|
||||||
name: string;
|
name?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
radius?: string | number;
|
radius?: string | number;
|
||||||
withBorder?: boolean;
|
withBorder?: boolean;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface TabItem {
|
|||||||
interface SwipeableTabsProps {
|
interface SwipeableTabsProps {
|
||||||
tabs: TabItem[];
|
tabs: TabItem[];
|
||||||
defaultTab?: number;
|
defaultTab?: number;
|
||||||
|
mb?: string | number;
|
||||||
onTabChange?: (index: number, tab: TabItem) => void;
|
onTabChange?: (index: number, tab: TabItem) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ function SwipeableTabs({
|
|||||||
tabs,
|
tabs,
|
||||||
defaultTab = 0,
|
defaultTab = 0,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
|
mb,
|
||||||
}: SwipeableTabsProps) {
|
}: SwipeableTabsProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const search = router.state.location.search as any;
|
const search = router.state.location.search as any;
|
||||||
@@ -144,7 +146,7 @@ function SwipeableTabs({
|
|||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
paddingInline: "var(--mantine-spacing-md)",
|
paddingInline: "var(--mantine-spacing-md)",
|
||||||
marginBottom: "var(--mantine-spacing-md)",
|
marginBottom: mb !== undefined ? mb : "var(--mantine-spacing-md)",
|
||||||
zIndex: 100,
|
zIndex: 100,
|
||||||
backgroundColor: "var(--mantine-color-body)",
|
backgroundColor: "var(--mantine-color-body)",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
|
|||||||
title="Head to Head"
|
title="Head to Head"
|
||||||
{...h2hSheet.props}
|
{...h2hSheet.props}
|
||||||
>
|
>
|
||||||
<TeamHeadToHeadSheet team1={match.home} team2={match.away} />
|
<TeamHeadToHeadSheet team1={match.home} team2={match.away} isOpen={h2hSheet.props.opened} />
|
||||||
</Sheet>
|
</Sheet>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
import { Stack, Text, Group, Box, Divider, Paper } from "@mantine/core";
|
import { Stack, Text, Group, Box, Divider, Paper } from "@mantine/core";
|
||||||
import { TeamInfo } from "@/features/teams/types";
|
import { TeamInfo } from "@/features/teams/types";
|
||||||
import { useTeamHeadToHead } from "../queries";
|
import { useTeamHeadToHead } from "../queries";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useEffect, useState } from "react";
|
||||||
import { CrownIcon, TrophyIcon } from "@phosphor-icons/react";
|
import { CrownIcon, TrophyIcon } from "@phosphor-icons/react";
|
||||||
import MatchList from "./match-list";
|
import MatchList from "./match-list";
|
||||||
|
|
||||||
interface TeamHeadToHeadSheetProps {
|
interface TeamHeadToHeadSheetProps {
|
||||||
team1: TeamInfo;
|
team1: TeamInfo;
|
||||||
team2: TeamInfo;
|
team2: TeamInfo;
|
||||||
|
isOpen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TeamHeadToHeadSheet = ({ team1, team2 }: TeamHeadToHeadSheetProps) => {
|
const TeamHeadToHeadSheet = ({ team1, team2, isOpen = true }: TeamHeadToHeadSheetProps) => {
|
||||||
const { data: matches, isLoading } = useTeamHeadToHead(team1.id, team2.id);
|
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(() => {
|
const stats = useMemo(() => {
|
||||||
if (!matches || matches.length === 0) {
|
if (!matches || matches.length === 0) {
|
||||||
@@ -33,6 +42,8 @@ const TeamHeadToHeadSheet = ({ team1, team2 }: TeamHeadToHeadSheetProps) => {
|
|||||||
let team2CupsFor = 0;
|
let team2CupsFor = 0;
|
||||||
let team1CupsAgainst = 0;
|
let team1CupsAgainst = 0;
|
||||||
let team2CupsAgainst = 0;
|
let team2CupsAgainst = 0;
|
||||||
|
let team1TotalWinMargin = 0;
|
||||||
|
let team2TotalWinMargin = 0;
|
||||||
|
|
||||||
matches.forEach((match) => {
|
matches.forEach((match) => {
|
||||||
const isTeam1Home = match.home?.id === team1.id;
|
const isTeam1Home = match.home?.id === team1.id;
|
||||||
@@ -41,8 +52,10 @@ const TeamHeadToHeadSheet = ({ team1, team2 }: TeamHeadToHeadSheetProps) => {
|
|||||||
|
|
||||||
if (team1Cups > team2Cups) {
|
if (team1Cups > team2Cups) {
|
||||||
team1Wins++;
|
team1Wins++;
|
||||||
|
team1TotalWinMargin += (team1Cups - team2Cups);
|
||||||
} else if (team2Cups > team1Cups) {
|
} else if (team2Cups > team1Cups) {
|
||||||
team2Wins++;
|
team2Wins++;
|
||||||
|
team2TotalWinMargin += (team2Cups - team1Cups);
|
||||||
}
|
}
|
||||||
|
|
||||||
team1CupsFor += team1Cups;
|
team1CupsFor += team1Cups;
|
||||||
@@ -52,10 +65,10 @@ const TeamHeadToHeadSheet = ({ team1, team2 }: TeamHeadToHeadSheetProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const team1AvgMargin = team1Wins > 0
|
const team1AvgMargin = team1Wins > 0
|
||||||
? (team1CupsFor - team1CupsAgainst) / team1Wins
|
? team1TotalWinMargin / team1Wins
|
||||||
: 0;
|
: 0;
|
||||||
const team2AvgMargin = team2Wins > 0
|
const team2AvgMargin = team2Wins > 0
|
||||||
? (team2CupsFor - team2CupsAgainst) / team2Wins
|
? team2TotalWinMargin / team2Wins
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
return {
|
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;
|
const leader = stats.team1Wins > stats.team2Wins ? team1 : stats.team2Wins > stats.team1Wins ? team2 : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -122,7 +135,7 @@ const TeamHeadToHeadSheet = ({ team1, team2 }: TeamHeadToHeadSheetProps) => {
|
|||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!leader && totalGames > 0 && (
|
{!leader && totalMatches > 0 && (
|
||||||
<Text size="xs" c="dimmed" ta="center">
|
<Text size="xs" c="dimmed" ta="center">
|
||||||
Series is tied
|
Series is tied
|
||||||
</Text>
|
</Text>
|
||||||
@@ -151,15 +164,15 @@ const TeamHeadToHeadSheet = ({ team1, team2 }: TeamHeadToHeadSheetProps) => {
|
|||||||
<Group justify="space-between" px="md" py="sm">
|
<Group justify="space-between" px="md" py="sm">
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Text size="sm" fw={600}>
|
<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>
|
||||||
<Text size="xs" c="dimmed">avg</Text>
|
<Text size="xs" c="dimmed">avg</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Text size="xs" fw={500}>Avg Cups/Game</Text>
|
<Text size="xs" fw={500}>Avg Cups/Match</Text>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Text size="xs" c="dimmed">avg</Text>
|
<Text size="xs" c="dimmed">avg</Text>
|
||||||
<Text size="sm" fw={600}>
|
<Text size="sm" fw={600}>
|
||||||
{totalGames > 0 ? (stats.team2CupsFor / totalGames).toFixed(1) : '0.0'}
|
{totalMatches > 0 ? (stats.team2CupsFor / totalMatches).toFixed(1) : '0.0'}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -185,7 +198,7 @@ const TeamHeadToHeadSheet = ({ team1, team2 }: TeamHeadToHeadSheetProps) => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack gap="xs">
|
<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 />
|
<MatchList matches={matches} hideH2H />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -17,8 +17,14 @@ export const matchQueries = {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useTeamHeadToHead = (team1Id: string, team2Id: string) =>
|
export const useTeamHeadToHead = (team1Id: string, team2Id: string, enabled = true) =>
|
||||||
useServerQuery(matchQueries.headToHeadTeams(team1Id, team2Id));
|
useServerQuery({
|
||||||
|
...matchQueries.headToHeadTeams(team1Id, team2Id),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
|
||||||
export const usePlayerHeadToHead = (player1Id: string, player2Id: string) =>
|
export const usePlayerHeadToHead = (player1Id: string, player2Id: string, enabled = true) =>
|
||||||
useServerQuery(matchQueries.headToHeadPlayers(player1Id, player2Id));
|
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 (
|
return (
|
||||||
<Container size="100%" px={0}>
|
<Container size="100%" px={0} mt="md">
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text px="md" size="10px" lh={0} c="dimmed">
|
<Text px="md" size="10px" lh={0} c="dimmed">
|
||||||
Showing {filteredAndSortedStats.length} of {playerStats.length} players
|
Showing {filteredAndSortedStats.length} of {playerStats.length} players
|
||||||
|
|||||||
@@ -1,23 +1,29 @@
|
|||||||
import Sheet from "@/components/sheet/sheet";
|
import Sheet from "@/components/sheet/sheet";
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
import { Flex, Title, ActionIcon } from "@mantine/core";
|
import { Flex, Title, ActionIcon, Stack, Button, Box } from "@mantine/core";
|
||||||
import { PencilIcon } from "@phosphor-icons/react";
|
import { PencilIcon, FootballHelmetIcon } from "@phosphor-icons/react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import NameUpdateForm from "./name-form";
|
import NameUpdateForm from "./name-form";
|
||||||
import Avatar from "@/components/avatar";
|
import Avatar from "@/components/avatar";
|
||||||
import { useSheet } from "@/hooks/use-sheet";
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
import { Player } from "../../types";
|
import { Player } from "../../types";
|
||||||
|
import PlayerHeadToHeadSheet from "../player-head-to-head-sheet";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
player: Player;
|
player: Player;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Header = ({ player }: HeaderProps) => {
|
const Header = ({ player }: HeaderProps) => {
|
||||||
const sheet = useSheet();
|
const nameSheet = useSheet();
|
||||||
|
const h2hSheet = useSheet();
|
||||||
const { user: authUser } = useAuth();
|
const { user: authUser } = useAuth();
|
||||||
|
|
||||||
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 authUserName = useMemo(() => {
|
||||||
|
if (!authUser) return "";
|
||||||
|
return `${authUser.first_name} ${authUser.last_name}`;
|
||||||
|
}, [authUser]);
|
||||||
|
|
||||||
const fontSize = useMemo(() => {
|
const fontSize = useMemo(() => {
|
||||||
const baseSize = 28;
|
const baseSize = 28;
|
||||||
@@ -33,19 +39,62 @@ const Header = ({ player }: HeaderProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Stack gap="sm" align="center" pt="md">
|
||||||
<Flex h="15dvh" px='xl' w='100%' align='self-end' gap='md'>
|
<Flex h="15dvh" px='xl' w='100%' align='self-end' gap='md'>
|
||||||
<Avatar name={name} size={100} />
|
<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' style={{ fontSize, lineHeight: 1.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={nameSheet.open}>
|
||||||
<PencilIcon size={20} />
|
<PencilIcon size={20} />
|
||||||
</ActionIcon>
|
</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>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
<Sheet title='Update Name' {...sheet.props}>
|
<Sheet title='Update Name' {...nameSheet.props}>
|
||||||
<NameUpdateForm player={player} toggle={sheet.toggle} />
|
<NameUpdateForm player={player} toggle={nameSheet.toggle} />
|
||||||
</Sheet>
|
</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>>;
|
queryFn: () => Promise<ServerResult<TData>>;
|
||||||
options?: Omit<UseQueryOptions<TData, Error, TData>, 'queryFn' | 'queryKey'>
|
options?: Omit<UseQueryOptions<TData, Error, TData>, 'queryFn' | 'queryKey'>
|
||||||
showErrorToast?: boolean;
|
showErrorToast?: boolean;
|
||||||
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const { queryKey, queryFn, showErrorToast = true, options: queryOptions } = options;
|
const { queryKey, queryFn, showErrorToast = true, options: queryOptions } = options;
|
||||||
|
|||||||
Reference in New Issue
Block a user