diff --git a/src/app/routes/_authed/stats.tsx b/src/app/routes/_authed/stats.tsx index 83c1991..0c71942 100644 --- a/src/app/routes/_authed/stats.tsx +++ b/src/app/routes/_authed/stats.tsx @@ -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, @@ -17,12 +20,27 @@ export const Route = createFileRoute("/_authed/stats")({ header: { title: "Player Stats" }, - refresh: [playerQueries.allStats().queryKey], + refresh: [playerQueries.allStats().queryKey], }), }); function Stats() { - return }> - - ; + const tabs = [ + { + label: "Stats", + content: ( + }> + + + ), + }, + { + label: "Head to Head", + content: , + }, + ]; + + return + + ; } diff --git a/src/components/avatar.tsx b/src/components/avatar.tsx index cc87230..67dcafc 100644 --- a/src/components/avatar.tsx +++ b/src/components/avatar.tsx @@ -13,7 +13,7 @@ import { XIcon } from "@phosphor-icons/react"; interface AvatarProps extends Omit { - name: string; + name?: string; size?: number; radius?: string | number; withBorder?: boolean; diff --git a/src/components/swipeable-tabs.tsx b/src/components/swipeable-tabs.tsx index c2e3a3d..fa565e1 100644 --- a/src/components/swipeable-tabs.tsx +++ b/src/components/swipeable-tabs.tsx @@ -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)", }} diff --git a/src/features/matches/components/match-card.tsx b/src/features/matches/components/match-card.tsx index 452a471..e270149 100644 --- a/src/features/matches/components/match-card.tsx +++ b/src/features/matches/components/match-card.tsx @@ -257,7 +257,7 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => { title="Head to Head" {...h2hSheet.props} > - + )} diff --git a/src/features/matches/components/team-head-to-head-sheet.tsx b/src/features/matches/components/team-head-to-head-sheet.tsx index 2e95da4..93f8cb1 100644 --- a/src/features/matches/components/team-head-to-head-sheet.tsx +++ b/src/features/matches/components/team-head-to-head-sheet.tsx @@ -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) => { )} - {!leader && totalGames > 0 && ( + {!leader && totalMatches > 0 && ( Series is tied @@ -151,15 +164,15 @@ const TeamHeadToHeadSheet = ({ team1, team2 }: TeamHeadToHeadSheetProps) => { - {totalGames > 0 ? (stats.team1CupsFor / totalGames).toFixed(1) : '0.0'} + {totalMatches > 0 ? (stats.team1CupsFor / totalMatches).toFixed(1) : '0.0'} avg - Avg Cups/Game + Avg Cups/Match avg - {totalGames > 0 ? (stats.team2CupsFor / totalGames).toFixed(1) : '0.0'} + {totalMatches > 0 ? (stats.team2CupsFor / totalMatches).toFixed(1) : '0.0'} @@ -185,7 +198,7 @@ const TeamHeadToHeadSheet = ({ team1, team2 }: TeamHeadToHeadSheetProps) => { - Match History ({totalGames} games) + Match History ({totalMatches} match{totalMatches !== 1 ? 'es' : ''}) diff --git a/src/features/matches/queries.ts b/src/features/matches/queries.ts index cca3f33..531524d 100644 --- a/src/features/matches/queries.ts +++ b/src/features/matches/queries.ts @@ -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, + }); diff --git a/src/features/players/components/league-head-to-head.tsx b/src/features/players/components/league-head-to-head.tsx new file mode 100644 index 0000000..eb2e8bd --- /dev/null +++ b/src/features/players/components/league-head-to-head.tsx @@ -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(null); + const [player2Id, setPlayer2Id] = useState(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 ( + <> + + + + + + {player1Id ? ( + <> + + + + {player1Name} + + + { + e.stopPropagation(); + handleClearPlayer1(); + }} + style={{ position: "absolute", top: 4, right: 4 }} + > + + + + ) : ( + + + + Player 1 + + + )} + + +
+ + VS + +
+ + + {player2Id ? ( + <> + + + + {player2Name} + + + { + e.stopPropagation(); + handleClearPlayer2(); + }} + style={{ position: "absolute", top: 4, right: 4 }} + > + + + + ) : ( + + + + Player 2 + + + )} + +
+ + {activeStep > 0 ? ( + + {activeStep === 1 && "Step 1: Select first player"} + {activeStep === 2 && "Step 2: Select second player"} + + ) : ( + + { + setPlayer1Id(null); + setPlayer2Id(null); + }} + td="underline" + > + Clear both players + + + )} +
+
+ + setSearch(e.currentTarget.value)} + leftSection={} + size="md" + px="md" + /> + + + + {filteredPlayers.length === 0 && ( + + {search ? `No players found matching "${search}"` : "No players available"} + + )} + + {filteredPlayers.map((player, index) => ( + + handlePlayerClick(player.player_id)} + styles={{ + root: { + "&:hover": { + backgroundColor: "var(--mantine-color-default-hover)", + }, + }, + }} + > + + + + {player.player_name} + + + + + + + {index < filteredPlayers.length - 1 && } + + ))} + + +
+ + {player1Id && player2Id && ( + + + + )} + + ); +}; + +export default LeagueHeadToHead; diff --git a/src/features/players/components/player-head-to-head-sheet.tsx b/src/features/players/components/player-head-to-head-sheet.tsx new file mode 100644 index 0000000..7e0dbb0 --- /dev/null +++ b/src/features/players/components/player-head-to-head-sheet.tsx @@ -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 ( + + + Loading... + + + ); + } + + if (!matches || matches.length === 0) { + return ( + + + These players have not faced each other yet. + + + ); + } + + const totalGames = stats.player1Wins + stats.player2Wins; + const leader = + stats.player1Wins > stats.player2Wins + ? player1Name + : stats.player2Wins > stats.player1Wins + ? player2Name + : null; + + return ( + + + + + + {player1Name} + + + vs + + + {player2Name} + + + + + + + {stats.player1Wins} + + + {player1Name} + + + + - + + + + {stats.player2Wins} + + + {player2Name} + + + + + {leader && ( + + + + {leader} leads the series + + + )} + + {!leader && totalGames > 0 && ( + + Series is tied + + )} + + + + + + Stats Comparison + + + + + + + + {stats.player1CupsFor} + + + cups + + + + Total Cups + + + + cups + + + {stats.player2CupsFor} + + + + + + + + + {totalGames > 0 + ? (stats.player1CupsFor / totalGames).toFixed(1) + : "0.0"} + + + avg + + + + Avg Cups/Game + + + + avg + + + {totalGames > 0 + ? (stats.player2CupsFor / totalGames).toFixed(1) + : "0.0"} + + + + + + + + + {!isNaN(stats.player1AvgMargin) + ? stats.player1AvgMargin.toFixed(1) + : "0.0"} + + + margin + + + + Avg Win Margin + + + + margin + + + {!isNaN(stats.player2AvgMargin) + ? stats.player2AvgMargin.toFixed(1) + : "0.0"} + + + + + + + + + + Match History ({totalGames} games) + + + + + ); +}; + +export default PlayerHeadToHeadSheet; diff --git a/src/features/players/components/player-stats-table.tsx b/src/features/players/components/player-stats-table.tsx index 85fde11..e602c08 100644 --- a/src/features/players/components/player-stats-table.tsx +++ b/src/features/players/components/player-stats-table.tsx @@ -306,7 +306,7 @@ const PlayerStatsTable = () => { } return ( - + Showing {filteredAndSortedStats.length} of {playerStats.length} players diff --git a/src/features/players/components/profile/header.tsx b/src/features/players/components/profile/header.tsx index 4b5076f..ab36c20 100644 --- a/src/features/players/components/profile/header.tsx +++ b/src/features/players/components/profile/header.tsx @@ -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 ( <> - - - - {name} - - - + + + + + {name} + + + + + + + + + + - + - - + + + + {!owner && authUser && ( + + + + )} ) }; diff --git a/src/lib/tanstack-query/hooks/use-server-query.ts b/src/lib/tanstack-query/hooks/use-server-query.ts index bcf9a43..fe9deb8 100644 --- a/src/lib/tanstack-query/hooks/use-server-query.ts +++ b/src/lib/tanstack-query/hooks/use-server-query.ts @@ -8,6 +8,7 @@ export function useServerQuery( queryFn: () => Promise>; options?: Omit, 'queryFn' | 'queryKey'> showErrorToast?: boolean; + enabled?: boolean; } ) { const { queryKey, queryFn, showErrorToast = true, options: queryOptions } = options;