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;