match h2h

This commit is contained in:
yohlo
2025-10-11 13:40:12 -05:00
parent 14c2eb2c02
commit 43972b6a06
6 changed files with 383 additions and 23 deletions

View File

@@ -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 (
<Indicator
disabled={!isStarted}
size={12}
color="red"
processing
position="top-end"
offset={24}
>
<>
<Box style={{ position: "relative" }}>
<Paper
px="md"
@@ -48,15 +51,58 @@ const MatchCard = ({ match }: MatchCardProps) => {
style={{ position: "relative", zIndex: 2 }}
>
<Stack gap="sm">
<Group gap="xs">
<Text size="xs" fw={600} lineClamp={1} c="dimmed">
{match.tournament.name}
</Text>
<Text c="dimmed">-</Text>
<Text size="xs" c="dimmed">
Round {match.round + 1}
{match.is_losers_bracket && " (Losers)"}
</Text>
<Group gap="xs" justify="space-between">
<Group gap="xs" style={{ flex: 1 }}>
{isStarted && (
<Indicator
size={8}
color="red"
processing
position="middle-start"
offset={0}
/>
)}
<Text size="xs" fw={600} lineClamp={1} c="dimmed">
{match.tournament.name}
</Text>
<Text c="dimmed">-</Text>
<Text size="xs" c="dimmed">
Round {match.round + 1}
{match.is_losers_bracket && " (Losers)"}
</Text>
</Group>
{match.home && match.away && !hideH2H && (
<Tooltip label="Head to Head" withArrow position="left">
<ActionIcon
variant="subtle"
size="sm"
onClick={handleH2HClick}
aria-label="View head-to-head"
w={40}
>
<Group 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)',
}}
/>
</Group>
</ActionIcon>
</Tooltip>
)}
</Group>
<Group justify="space-between" align="center">
@@ -205,7 +251,16 @@ const MatchCard = ({ match }: MatchCardProps) => {
</Suspense>
</Paper>
</Box>
</Indicator>
{match.home && match.away && (
<Sheet
title="Head to Head"
{...h2hSheet.props}
>
<TeamHeadToHeadSheet team1={match.home} team2={match.away} />
</Sheet>
)}
</>
);
};

View File

@@ -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) => {
<div
key={`match-${match.id}-${index}`}
>
<MatchCard match={match} />
<MatchCard match={match} hideH2H={hideH2H} />
</div>
))}
</Stack>

View File

@@ -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 (
<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 teams have not faced each other yet.
</Text>
</Stack>
);
}
const totalGames = stats.team1Wins + stats.team2Wins;
const leader = stats.team1Wins > stats.team2Wins ? team1 : stats.team2Wins > stats.team1Wins ? team2 : null;
return (
<Stack gap="md">
<Paper p="md" withBorder radius="md">
<Stack gap="sm">
<Group justify="center" gap="xs">
<Text size="lg" fw={700}>{team1.name}</Text>
<Text size="sm" c="dimmed">vs</Text>
<Text size="lg" fw={700}>{team2.name}</Text>
</Group>
<Group justify="center" gap="lg">
<Stack gap={0} align="center">
<Text size="xl" fw={700}>{stats.team1Wins}</Text>
<Text size="xs" c="dimmed">{team1.name}</Text>
</Stack>
<Text size="md" c="dimmed">-</Text>
<Stack gap={0} align="center">
<Text size="xl" fw={700}>{stats.team2Wins}</Text>
<Text size="xs" c="dimmed">{team2.name}</Text>
</Stack>
</Group>
{leader && (
<Group justify="center" gap="xs">
<CrownIcon size={16} weight="fill" color="gold" />
<Text size="xs" c="dimmed">
{leader.name} 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.team1CupsFor}</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.team2CupsFor}</Text>
</Group>
</Group>
<Divider />
<Group justify="space-between" px="md" py="sm">
<Group gap="xs">
<Text size="sm" fw={600}>
{totalGames > 0 ? (stats.team1CupsFor / 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.team2CupsFor / 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.team1AvgMargin) ? stats.team1AvgMargin.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.team2AvgMargin) ? stats.team2AvgMargin.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 TeamHeadToHeadSheet;

View File

@@ -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));

View File

@@ -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;
})
);

View File

@@ -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<Match[]> {
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<Match[]> {
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));
},
};
}