diff --git a/src/app/routes/_authed/profile.$playerId.tsx b/src/app/routes/_authed/profile.$playerId.tsx index e9f6cbf..b9b7319 100644 --- a/src/app/routes/_authed/profile.$playerId.tsx +++ b/src/app/routes/_authed/profile.$playerId.tsx @@ -12,10 +12,16 @@ export const Route = createFileRoute("/_authed/profile/$playerId")({ validateSearch: searchSchema, beforeLoad: async ({ params, context }) => { const { queryClient } = context; - await prefetchServerQuery( - queryClient, - playerQueries.details(params.playerId) - ); + await Promise.all([ + prefetchServerQuery( + queryClient, + playerQueries.details(params.playerId) + ), + prefetchServerQuery( + queryClient, + playerQueries.matches(params.playerId) + ), + ]); }, loader: ({ params, context }) => ({ header: { diff --git a/src/features/matches/components/match-card.tsx b/src/features/matches/components/match-card.tsx new file mode 100644 index 0000000..acc3f17 --- /dev/null +++ b/src/features/matches/components/match-card.tsx @@ -0,0 +1,155 @@ +import { + Text, + Group, + Stack, + Paper, + ThemeIcon, + Indicator, + Box, + Badge, +} from "@mantine/core"; +import { TrophyIcon, CrownIcon } from "@phosphor-icons/react"; +import { useNavigate } from "@tanstack/react-router"; +import { Match } from "../types"; +import Avatar from "@/components/avatar"; + +interface MatchCardProps { + match: Match; +} + +const MatchCard = ({ match }: MatchCardProps) => { + const navigate = useNavigate(); + const isHomeWin = match.home_cups > match.away_cups; + const isAwayWin = match.away_cups > match.home_cups; + const isStarted = match.status === "started"; + + const handleHomeTeamClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (match.home?.id) { + navigate({ to: `/teams/${match.home.id}` }); + } + }; + + const handleAwayTeamClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (match.away?.id) { + navigate({ to: `/teams/${match.away.id}` }); + } + }; + + return ( + + + + + + {match.tournament.name} + + - + + Round {match.round} + {match.is_losers_bracket && " (Losers)"} + + + + + + + + {isHomeWin && ( + + + + )} + + + {match.home?.name!} + + + + + + {match.home_cups} + + + - + + + {match.away_cups} + + {match.ot_count > 0 && ( + + {match.ot_count}OT + + )} + + + + + {match.away?.name} + + + + {isAwayWin && ( + + + + )} + + + + + + + ); +}; + +export default MatchCard; diff --git a/src/features/matches/components/match-list.tsx b/src/features/matches/components/match-list.tsx new file mode 100644 index 0000000..fdc00ec --- /dev/null +++ b/src/features/matches/components/match-list.tsx @@ -0,0 +1,48 @@ +import { Stack, Text, ThemeIcon, Box } from "@mantine/core"; +import { TrophyIcon } from "@phosphor-icons/react"; +import { motion, AnimatePresence } from "framer-motion"; +import { Match } from "../types"; +import MatchCard from "./match-card"; + +interface MatchListProps { + matches: Match[]; +} + +const MatchList = ({ matches }: MatchListProps) => { + const filteredMatches = matches?.filter(match => + match.home && match.away && !match.bye && match.status != "tbd" + ) || []; + + if (!filteredMatches.length) { + return ( + + + + + + No matches found + + + ); + } + + return ( + + + {filteredMatches.map((match, index) => ( + + + + ))} + + + ); +}; + +export default MatchList; \ No newline at end of file diff --git a/src/features/players/components/player-stats-table.tsx b/src/features/players/components/player-stats-table.tsx index 9ddcb5a..0272942 100644 --- a/src/features/players/components/player-stats-table.tsx +++ b/src/features/players/components/player-stats-table.tsx @@ -28,8 +28,8 @@ interface PlayerStatsTableProps { playerStats: PlayerStats[]; } -type SortKey = keyof PlayerStats | 'mmr'; -type SortDirection = 'asc' | 'desc'; +type SortKey = keyof PlayerStats | "mmr"; +type SortDirection = "asc" | "desc"; interface SortConfig { key: SortKey; @@ -39,8 +39,8 @@ interface SortConfig { const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => { const [search, setSearch] = useState(""); const [sortConfig, setSortConfig] = useState({ - key: 'mmr' as SortKey, - direction: 'desc' + key: "mmr" as SortKey, + direction: "desc", }); // Calculate MMR (Match Making Rating) based on multiple factors @@ -56,18 +56,19 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => { // Performance metrics const avgCupsScore = Math.min(stat.avg_cups_per_match * 10, 100); // Cap at 10 avg cups - const marginScore = stat.margin_of_victory ? Math.min(stat.margin_of_victory * 20, 50) : 0; // Cap at 2.5 margin + const marginScore = stat.margin_of_victory + ? Math.min(stat.margin_of_victory * 20, 50) + : 0; // Cap at 2.5 margin // Volume bonus for active players (small bonus for playing more) const volumeBonus = Math.min(stat.matches * 0.5, 10); // Max 10 point bonus // Weighted calculation - const baseMMR = ( - winScore * 0.5 + // Win % is 50% of score - avgCupsScore * 0.25 + // Avg cups is 25% of score - marginScore * 0.15 + // Win margin is 15% of score - volumeBonus * 0.1 // Volume bonus is 10% of score - ); + const baseMMR = + winScore * 0.5 + // Win % is 50% of score + avgCupsScore * 0.25 + // Avg cups is 25% of score + marginScore * 0.15 + // Win margin is 15% of score + volumeBonus * 0.1; // Volume bonus is 10% of score // Apply confidence factor (players with few matches get penalized) const finalMMR = baseMMR * matchConfidence; @@ -76,19 +77,23 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => { }; const handleSort = (key: SortKey) => { - setSortConfig(prev => ({ + setSortConfig((prev) => ({ key, - direction: prev.key === key && prev.direction === 'desc' ? 'asc' : 'desc' + direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc", })); }; const getSortIcon = (key: SortKey) => { if (sortConfig.key !== key) return null; - return sortConfig.direction === 'desc' ? : ; + return sortConfig.direction === "desc" ? ( + + ) : ( + + ); }; const filteredAndSortedStats = useMemo(() => { - let filtered = playerStats.filter(stat => + let filtered = playerStats.filter((stat) => stat.player_name.toLowerCase().includes(search.toLowerCase()) ); @@ -97,7 +102,7 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => { let bValue: number | string; // Special handling for MMR - if (sortConfig.key === 'mmr') { + if (sortConfig.key === "mmr") { aValue = calculateMMR(a); bValue = calculateMMR(b); } else { @@ -105,12 +110,14 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => { bValue = b[sortConfig.key]; } - if (typeof aValue === 'number' && typeof bValue === 'number') { - return sortConfig.direction === 'desc' ? bValue - aValue : aValue - bValue; + if (typeof aValue === "number" && typeof bValue === "number") { + return sortConfig.direction === "desc" + ? bValue - aValue + : aValue - bValue; } - if (typeof aValue === 'string' && typeof bValue === 'string') { - return sortConfig.direction === 'desc' + if (typeof aValue === "string" && typeof bValue === "string") { + return sortConfig.direction === "desc" ? bValue.localeCompare(aValue) : aValue.localeCompare(bValue); } @@ -123,52 +130,66 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => { const formatDecimal = (value: number) => value.toFixed(2); const columns = [ - { key: 'player_name' as SortKey, label: 'Player', width: 175 }, - { key: 'mmr' as SortKey, label: 'MMR', width: 90 }, - { key: 'win_percentage' as SortKey, label: 'Win %', width: 110 }, - { key: 'matches' as SortKey, label: 'Matches', width: 90 }, - { key: 'wins' as SortKey, label: 'Wins', width: 80 }, - { key: 'losses' as SortKey, label: 'Losses', width: 80 }, - { key: 'total_cups_made' as SortKey, label: 'Cups Made', width: 110 }, - { key: 'total_cups_against' as SortKey, label: 'Cups Against', width: 120 }, - { key: 'avg_cups_per_match' as SortKey, label: 'Avg/Match', width: 100 }, - { key: 'margin_of_victory' as SortKey, label: 'Win Margin', width: 110 }, - { key: 'margin_of_loss' as SortKey, label: 'Loss Margin', width: 110 }, + { key: "player_name" as SortKey, label: "Player", width: 175 }, + { key: "mmr" as SortKey, label: "MMR", width: 90 }, + { key: "win_percentage" as SortKey, label: "Win %", width: 110 }, + { key: "matches" as SortKey, label: "Matches", width: 90 }, + { key: "wins" as SortKey, label: "Wins", width: 80 }, + { key: "losses" as SortKey, label: "Losses", width: 80 }, + { key: "total_cups_made" as SortKey, label: "Cups Made", width: 110 }, + { key: "total_cups_against" as SortKey, label: "Cups Against", width: 120 }, + { key: "avg_cups_per_match" as SortKey, label: "Avg/Match", width: 100 }, + { key: "margin_of_victory" as SortKey, label: "Win Margin", width: 110 }, + { key: "margin_of_loss" as SortKey, label: "Loss Margin", width: 110 }, ]; - const renderCellContent = (stat: PlayerStats, column: typeof columns[0], index: number) => { + const renderCellContent = ( + stat: PlayerStats, + column: (typeof columns)[0], + index: number + ) => { switch (column.key) { - case 'player_name': - return {stat.player_name} - case 'mmr': + case "player_name": + return ( + + {stat.player_name} + + ); + case "mmr": const mmr = calculateMMR(stat); return ( - = 70 ? "green" : mmr >= 50 ? "blue" : mmr >= 30 ? "yellow" : "red"}> + {mmr.toFixed(1)} ); - case 'win_percentage': + case "win_percentage": + return {formatPercentage(stat.win_percentage)}; + case "wins": + return {stat.wins}; + case "losses": + return {stat.losses}; + case "total_cups_made": + return {stat.total_cups_made}; + case "matches": + return {stat.matches}; + case "avg_cups_per_match": + return {formatDecimal(stat.avg_cups_per_match)}; + case "margin_of_victory": return ( - = 70 ? "green" : stat.win_percentage >= 50 ? "yellow" : "red"}> - {formatPercentage(stat.win_percentage)} + + {stat.margin_of_victory + ? formatDecimal(stat.margin_of_victory) + : "N/A"} + + ); + case "margin_of_loss": + return ( + + {stat.margin_of_loss ? formatDecimal(stat.margin_of_loss) : "N/A"} ); - case 'wins': - return {stat.wins}; - case 'losses': - return {stat.losses}; - case 'total_cups_made': - return {stat.total_cups_made}; - case 'matches': - return {stat.matches}; - case 'avg_cups_per_match': - return {formatDecimal(stat.avg_cups_per_match)}; - case 'margin_of_victory': - return {stat.margin_of_victory ? formatDecimal(stat.margin_of_victory) : 'N/A'}; - case 'margin_of_loss': - return {stat.margin_of_loss ? formatDecimal(stat.margin_of_loss) : 'N/A'}; default: return {(stat as any)[column.key]}; } @@ -181,7 +202,9 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => { - No Stats Available + + No Stats Available + Player statistics will appear here once matches have been played. @@ -193,7 +216,7 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => { return ( - + {filteredAndSortedStats.length} of {playerStats.length} players { size="md" /> - - + { withTableBorder={false} style={{ minWidth: 1000, - borderRadius: 'inherit', + borderRadius: "inherit", }} > @@ -229,31 +251,36 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => { handleSort(column.key)} > - + {column.label} - {column.key === 'mmr' && ( + {column.key === "mmr" && (
{ e.stopPropagation(); @@ -265,22 +292,30 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => { > - + - MMR Calculation: - • Win Rate (50%) - • Average Cups/Match (25%) - • Average Win Margin (15%) - • Match Volume Bonus (10%) + + MMR Calculation: + + + • Win Rate (50%) + + + • Average Cups/Match (25%) + + + • Average Win Margin (15%) + + + • Match Volume Bonus (10%) + - * Confidence penalty applied for players with <20 matches + * Confidence penalty applied for players + with <20 matches ** Not an official rating @@ -290,18 +325,25 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
)} - + {getSortIcon(column.key)} {index === 0 && (
@@ -319,34 +361,36 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => { animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.2, delay: index * 0.01 }} style={{ - borderBottom: '1px solid var(--mantine-color-default-border)', + borderBottom: + "1px solid var(--mantine-color-default-border)", }} > {columns.map((column, columnIndex) => ( -
+
{renderCellContent(stat, column, index)} {columnIndex === 0 && (
@@ -371,4 +415,4 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => { ); }; -export default PlayerStatsTable; \ No newline at end of file +export default PlayerStatsTable; diff --git a/src/features/players/components/profile/index.tsx b/src/features/players/components/profile/index.tsx index b320b2e..d0af634 100644 --- a/src/features/players/components/profile/index.tsx +++ b/src/features/players/components/profile/index.tsx @@ -1,10 +1,11 @@ -import { Box, Text } from "@mantine/core"; +import { Box } from "@mantine/core"; import Header from "./header"; import { Player } from "@/features/players/types"; import SwipeableTabs from "@/components/swipeable-tabs"; -import { usePlayer } from "../../queries"; +import { usePlayer, usePlayerMatches } from "../../queries"; import TeamList from "@/features/teams/components/team-list"; import StatsOverview from "../stats-overview"; +import MatchList from "@/features/matches/components/match-list"; interface ProfileProps { id: string; @@ -12,6 +13,8 @@ interface ProfileProps { const Profile = ({ id }: ProfileProps) => { const { data: player } = usePlayer(id); + const { data: matches } = usePlayerMatches(id); + const tabs = [ { label: "Overview", @@ -19,7 +22,7 @@ const Profile = ({ id }: ProfileProps) => { }, { label: "Matches", - content: Matches feed will go here, + content: , }, { label: "Teams", diff --git a/src/features/players/queries.ts b/src/features/players/queries.ts index b5100ac..a17819c 100644 --- a/src/features/players/queries.ts +++ b/src/features/players/queries.ts @@ -1,5 +1,5 @@ import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks"; -import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe, getPlayerStats, getAllPlayerStats } from "./server"; +import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe, getPlayerStats, getAllPlayerStats, getPlayerMatches } from "./server"; export const playerKeys = { auth: ['auth'], @@ -8,6 +8,7 @@ export const playerKeys = { unassociated: ['players','unassociated'], stats: (id: string) => ['players', 'stats', id], allStats: ['players', 'stats', 'all'], + matches: (id: string) => ['players', 'matches', id], }; export const playerQueries = { @@ -35,6 +36,10 @@ export const playerQueries = { queryKey: playerKeys.allStats, queryFn: async () => await getAllPlayerStats() }), + matches: (id: string) => ({ + queryKey: playerKeys.matches(id), + queryFn: async () => await getPlayerMatches({ data: id }) + }), }; export const useMe = () => { @@ -73,4 +78,7 @@ export const usePlayerStats = (id: string) => useServerSuspenseQuery(playerQueries.stats(id)); export const useAllPlayerStats = () => - useServerSuspenseQuery(playerQueries.allStats()); \ No newline at end of file + useServerSuspenseQuery(playerQueries.allStats()); + +export const usePlayerMatches = (id: string) => + useServerSuspenseQuery(playerQueries.matches(id)); \ No newline at end of file diff --git a/src/features/players/server.ts b/src/features/players/server.ts index 0415379..785beca 100644 --- a/src/features/players/server.ts +++ b/src/features/players/server.ts @@ -1,6 +1,7 @@ import { setUserMetadata, superTokensFunctionMiddleware, getSessionContext } from "@/utils/supertokens"; import { createServerFn } from "@tanstack/react-start"; import { Player, playerInputSchema, playerUpdateSchema, PlayerStats } from "@/features/players/types"; +import { Match } from "@/features/matches/types"; import { pbAdmin } from "@/lib/pocketbase/client"; import { z } from "zod"; import { logger } from "."; @@ -131,4 +132,11 @@ export const getAllPlayerStats = createServerFn() .middleware([superTokensFunctionMiddleware]) .handler(async () => toServerResult(async () => await pbAdmin.getAllPlayerStats()) + ); + +export const getPlayerMatches = createServerFn() + .validator(z.string()) + .middleware([superTokensFunctionMiddleware]) + .handler(async ({ data }) => + toServerResult(async () => await pbAdmin.getPlayerMatches(data)) ); \ No newline at end of file diff --git a/src/features/tournaments/components/tournament-list.tsx b/src/features/tournaments/components/tournament-list.tsx index c32111d..5cbd99c 100644 --- a/src/features/tournaments/components/tournament-list.tsx +++ b/src/features/tournaments/components/tournament-list.tsx @@ -3,7 +3,7 @@ import { useNavigate } from "@tanstack/react-router"; import Avatar from "@/components/avatar"; import { TournamentInfo } from "../types"; import { useCallback } from "react"; -import { motion } from "framer-motion"; +import { motion, AnimatePresence } from "framer-motion"; import { TrophyIcon, CalendarIcon, MapPinIcon } from "@phosphor-icons/react"; interface TournamentListProps { @@ -51,15 +51,17 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) = return ( - {tournaments.map((tournament, index) => { + + {tournaments.map((tournament, index) => { const startDate = tournament.start_time ? new Date(tournament.start_time) : null; return ( @@ -135,7 +137,8 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) = ); - })} + })} + ); } diff --git a/src/lib/pocketbase/services/players.ts b/src/lib/pocketbase/services/players.ts index 74d2d2d..ca8680c 100644 --- a/src/lib/pocketbase/services/players.ts +++ b/src/lib/pocketbase/services/players.ts @@ -5,7 +5,8 @@ import type { PlayerUpdateInput, PlayerStats, } from "@/features/players/types"; -import { transformPlayer, transformPlayerInfo } from "@/lib/pocketbase/util/transform-types"; +import type { Match } from "@/features/matches/types"; +import { transformPlayer, transformPlayerInfo, transformMatch } from "@/lib/pocketbase/util/transform-types"; import PocketBase from "pocketbase"; import { DataFetchOptions } from "./base"; @@ -77,5 +78,29 @@ export function createPlayersService(pb: PocketBase) { }); return result; }, + + async getPlayerMatches(playerId: string): Promise { + const player = await pb.collection("players").getOne(playerId, { + expand: "teams", + }); + + if (!player.expand?.teams || player.expand.teams.length === 0) { + return []; + } + + const teamIds = Array.isArray(player.expand.teams) + ? player.expand.teams.map((team: any) => team.id) + : [player.expand.teams.id]; + + const teamFilter = teamIds.map(teamId => `home = "${teamId}" || away = "${teamId}"`).join(" || "); + + const result = await pb.collection("matches").getFullList({ + filter: `(${teamFilter}) && (status = "ended" || status = "started")`, + sort: "-created", + expand: "tournament,home,away", + }); + + return result.map(transformMatch); + }, }; }