diff --git a/src/features/matches/components/match-card.tsx b/src/features/matches/components/match-card.tsx
index 37cfc16..452a471 100644
--- a/src/features/matches/components/match-card.tsx
+++ b/src/features/matches/components/match-card.tsx
@@ -1,17 +1,22 @@
-import { Text, Group, Stack, Paper, Indicator, Box, Tooltip } from "@mantine/core";
-import { CrownIcon } from "@phosphor-icons/react";
+import { Text, Group, Stack, Paper, Indicator, Box, Tooltip, ActionIcon } from "@mantine/core";
+import { CrownIcon, FootballHelmetIcon } from "@phosphor-icons/react";
import { useNavigate } from "@tanstack/react-router";
import { Match } from "../types";
import Avatar from "@/components/avatar";
import EmojiBar from "@/features/reactions/components/emoji-bar";
import { Suspense } from "react";
+import { useSheet } from "@/hooks/use-sheet";
+import Sheet from "@/components/sheet/sheet";
+import TeamHeadToHeadSheet from "./team-head-to-head-sheet";
interface MatchCardProps {
match: Match;
+ hideH2H?: boolean;
}
-const MatchCard = ({ match }: MatchCardProps) => {
+const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
const navigate = useNavigate();
+ const h2hSheet = useSheet();
const isHomeWin = match.home_cups > match.away_cups;
const isAwayWin = match.away_cups > match.home_cups;
const isStarted = match.status === "started";
@@ -30,15 +35,13 @@ const MatchCard = ({ match }: MatchCardProps) => {
}
};
+ const handleH2HClick = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ h2hSheet.open();
+ };
+
return (
-
+ <>
{
style={{ position: "relative", zIndex: 2 }}
>
-
-
- {match.tournament.name}
-
- -
-
- Round {match.round + 1}
- {match.is_losers_bracket && " (Losers)"}
-
+
+
+ {isStarted && (
+
+ )}
+
+ {match.tournament.name}
+
+ -
+
+ Round {match.round + 1}
+ {match.is_losers_bracket && " (Losers)"}
+
+
+ {match.home && match.away && !hideH2H && (
+
+
+
+
+
+
+
+
+ )}
@@ -205,7 +251,16 @@ const MatchCard = ({ match }: MatchCardProps) => {
-
+
+ {match.home && match.away && (
+
+
+
+ )}
+ >
);
};
diff --git a/src/features/matches/components/match-list.tsx b/src/features/matches/components/match-list.tsx
index d441557..e888fbb 100644
--- a/src/features/matches/components/match-list.tsx
+++ b/src/features/matches/components/match-list.tsx
@@ -4,9 +4,10 @@ import MatchCard from "./match-card";
interface MatchListProps {
matches: Match[];
+ hideH2H?: boolean;
}
-const MatchList = ({ matches }: MatchListProps) => {
+const MatchList = ({ matches, hideH2H = false }: MatchListProps) => {
const filteredMatches = matches?.filter(match =>
match.home && match.away && !match.bye && match.status != "tbd"
).sort((a, b) => a.start_time < b.start_time ? 1 : -1) || [];
@@ -21,7 +22,7 @@ const MatchList = ({ matches }: MatchListProps) => {
-
+
))}
diff --git a/src/features/matches/components/team-head-to-head-sheet.tsx b/src/features/matches/components/team-head-to-head-sheet.tsx
new file mode 100644
index 0000000..2e95da4
--- /dev/null
+++ b/src/features/matches/components/team-head-to-head-sheet.tsx
@@ -0,0 +1,195 @@
+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 { CrownIcon, TrophyIcon } from "@phosphor-icons/react";
+import MatchList from "./match-list";
+
+interface TeamHeadToHeadSheetProps {
+ team1: TeamInfo;
+ team2: TeamInfo;
+}
+
+const TeamHeadToHeadSheet = ({ team1, team2 }: TeamHeadToHeadSheetProps) => {
+ const { data: matches, isLoading } = useTeamHeadToHead(team1.id, team2.id);
+
+ const stats = useMemo(() => {
+ if (!matches || matches.length === 0) {
+ return {
+ team1Wins: 0,
+ team2Wins: 0,
+ team1CupsFor: 0,
+ team2CupsFor: 0,
+ team1CupsAgainst: 0,
+ team2CupsAgainst: 0,
+ team1AvgMargin: 0,
+ team2AvgMargin: 0,
+ };
+ }
+
+ let team1Wins = 0;
+ let team2Wins = 0;
+ let team1CupsFor = 0;
+ let team2CupsFor = 0;
+ let team1CupsAgainst = 0;
+ let team2CupsAgainst = 0;
+
+ matches.forEach((match) => {
+ const isTeam1Home = match.home?.id === team1.id;
+ const team1Cups = isTeam1Home ? match.home_cups : match.away_cups;
+ const team2Cups = isTeam1Home ? match.away_cups : match.home_cups;
+
+ if (team1Cups > team2Cups) {
+ team1Wins++;
+ } else if (team2Cups > team1Cups) {
+ team2Wins++;
+ }
+
+ team1CupsFor += team1Cups;
+ team2CupsFor += team2Cups;
+ team1CupsAgainst += team2Cups;
+ team2CupsAgainst += team1Cups;
+ });
+
+ const team1AvgMargin = team1Wins > 0
+ ? (team1CupsFor - team1CupsAgainst) / team1Wins
+ : 0;
+ const team2AvgMargin = team2Wins > 0
+ ? (team2CupsFor - team2CupsAgainst) / team2Wins
+ : 0;
+
+ return {
+ team1Wins,
+ team2Wins,
+ team1CupsFor,
+ team2CupsFor,
+ team1CupsAgainst,
+ team2CupsAgainst,
+ team1AvgMargin,
+ team2AvgMargin,
+ };
+ }, [matches, team1.id]);
+
+ if (isLoading) {
+ return (
+
+ Loading...
+
+ );
+ }
+
+ if (!matches || matches.length === 0) {
+ return (
+
+
+ These teams have not faced each other yet.
+
+
+ );
+ }
+
+ const totalGames = stats.team1Wins + stats.team2Wins;
+ const leader = stats.team1Wins > stats.team2Wins ? team1 : stats.team2Wins > stats.team1Wins ? team2 : null;
+
+ return (
+
+
+
+
+ {team1.name}
+ vs
+ {team2.name}
+
+
+
+
+ {stats.team1Wins}
+ {team1.name}
+
+ -
+
+ {stats.team2Wins}
+ {team2.name}
+
+
+
+ {leader && (
+
+
+
+ {leader.name} leads the series
+
+
+ )}
+
+ {!leader && totalGames > 0 && (
+
+ Series is tied
+
+ )}
+
+
+
+
+ Stats Comparison
+
+
+
+
+
+ {stats.team1CupsFor}
+ cups
+
+ Total Cups
+
+ cups
+ {stats.team2CupsFor}
+
+
+
+
+
+
+
+ {totalGames > 0 ? (stats.team1CupsFor / totalGames).toFixed(1) : '0.0'}
+
+ avg
+
+ Avg Cups/Game
+
+ avg
+
+ {totalGames > 0 ? (stats.team2CupsFor / totalGames).toFixed(1) : '0.0'}
+
+
+
+
+
+
+
+
+ {!isNaN(stats.team1AvgMargin) ? stats.team1AvgMargin.toFixed(1) : '0.0'}
+
+ margin
+
+ Avg Win Margin
+
+ margin
+
+ {!isNaN(stats.team2AvgMargin) ? stats.team2AvgMargin.toFixed(1) : '0.0'}
+
+
+
+
+
+
+
+
+ Match History ({totalGames} games)
+
+
+
+ );
+};
+
+export default TeamHeadToHeadSheet;
diff --git a/src/features/matches/queries.ts b/src/features/matches/queries.ts
new file mode 100644
index 0000000..cca3f33
--- /dev/null
+++ b/src/features/matches/queries.ts
@@ -0,0 +1,24 @@
+import { useServerQuery } from "@/lib/tanstack-query/hooks";
+import { getMatchesBetweenTeams, getMatchesBetweenPlayers } from "./server";
+
+export const matchKeys = {
+ headToHeadTeams: (team1Id: string, team2Id: string) => ['matches', 'headToHead', 'teams', team1Id, team2Id] as const,
+ headToHeadPlayers: (player1Id: string, player2Id: string) => ['matches', 'headToHead', 'players', player1Id, player2Id] as const,
+};
+
+export const matchQueries = {
+ headToHeadTeams: (team1Id: string, team2Id: string) => ({
+ queryKey: matchKeys.headToHeadTeams(team1Id, team2Id),
+ queryFn: () => getMatchesBetweenTeams({ data: { team1Id, team2Id } }),
+ }),
+ headToHeadPlayers: (player1Id: string, player2Id: string) => ({
+ queryKey: matchKeys.headToHeadPlayers(player1Id, player2Id),
+ queryFn: () => getMatchesBetweenPlayers({ data: { player1Id, player2Id } }),
+ }),
+};
+
+export const useTeamHeadToHead = (team1Id: string, team2Id: string) =>
+ useServerQuery(matchQueries.headToHeadTeams(team1Id, team2Id));
+
+export const usePlayerHeadToHead = (player1Id: string, player2Id: string) =>
+ useServerQuery(matchQueries.headToHeadPlayers(player1Id, player2Id));
diff --git a/src/features/matches/server.ts b/src/features/matches/server.ts
index d4b5887..a230d3f 100644
--- a/src/features/matches/server.ts
+++ b/src/features/matches/server.ts
@@ -347,3 +347,35 @@ export const getMatchReactions = createServerFn()
return reactions as Reaction[]
})
);
+
+const matchesBetweenPlayersSchema = z.object({
+ player1Id: z.string(),
+ player2Id: z.string(),
+});
+
+export const getMatchesBetweenPlayers = createServerFn()
+ .inputValidator(matchesBetweenPlayersSchema)
+ .middleware([superTokensFunctionMiddleware])
+ .handler(async ({ data: { player1Id, player2Id } }) =>
+ toServerResult(async () => {
+ logger.info("Getting matches between players", { player1Id, player2Id });
+ const matches = await pbAdmin.getMatchesBetweenPlayers(player1Id, player2Id);
+ return matches;
+ })
+ );
+
+const matchesBetweenTeamsSchema = z.object({
+ team1Id: z.string(),
+ team2Id: z.string(),
+});
+
+export const getMatchesBetweenTeams = createServerFn()
+ .inputValidator(matchesBetweenTeamsSchema)
+ .middleware([superTokensFunctionMiddleware])
+ .handler(async ({ data: { team1Id, team2Id } }) =>
+ toServerResult(async () => {
+ logger.info("Getting matches between teams", { team1Id, team2Id });
+ const matches = await pbAdmin.getMatchesBetweenTeams(team1Id, team2Id);
+ return matches;
+ })
+ );
diff --git a/src/lib/pocketbase/services/matches.ts b/src/lib/pocketbase/services/matches.ts
index d57721b..8ce3943 100644
--- a/src/lib/pocketbase/services/matches.ts
+++ b/src/lib/pocketbase/services/matches.ts
@@ -71,5 +71,58 @@ export function createMatchesService(pb: PocketBase) {
matches.map((match) => pb.collection("matches").delete(match.id))
);
},
+
+ async getMatchesBetweenPlayers(player1Id: string, player2Id: string): Promise {
+ logger.info("PocketBase | Getting matches between players", { player1Id, player2Id });
+
+ const player1Teams = await pb.collection("teams").getFullList({
+ filter: `players ~ "${player1Id}"`,
+ fields: "id",
+ });
+
+ const player2Teams = await pb.collection("teams").getFullList({
+ filter: `players ~ "${player2Id}"`,
+ fields: "id",
+ });
+
+ const player1TeamIds = player1Teams.map(t => t.id);
+ const player2TeamIds = player2Teams.map(t => t.id);
+
+ if (player1TeamIds.length === 0 || player2TeamIds.length === 0) {
+ return [];
+ }
+
+ const filterConditions: string[] = [];
+ player1TeamIds.forEach(team1Id => {
+ player2TeamIds.forEach(team2Id => {
+ filterConditions.push(`(home="${team1Id}" && away="${team2Id}")`);
+ filterConditions.push(`(home="${team2Id}" && away="${team1Id}")`);
+ });
+ });
+
+ const filter = filterConditions.join(" || ");
+
+ const results = await pb.collection("matches").getFullList({
+ filter,
+ expand: "tournament, home, away, home.players, away.players",
+ sort: "-created",
+ });
+
+ return results.map(match => transformMatch(match));
+ },
+
+ async getMatchesBetweenTeams(team1Id: string, team2Id: string): Promise {
+ logger.info("PocketBase | Getting matches between teams", { team1Id, team2Id });
+
+ const filter = `(home="${team1Id}" && away="${team2Id}") || (home="${team2Id}" && away="${team1Id}")`;
+
+ const results = await pb.collection("matches").getFullList({
+ filter,
+ expand: "tournament, home, away, home.players, away.players",
+ sort: "-created",
+ });
+
+ return results.map(match => transformMatch(match));
+ },
};
}