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)); + }, }; }