diff --git a/src/app/routeTree.gen.ts b/src/app/routeTree.gen.ts index b6947a1..7608220 100644 --- a/src/app/routeTree.gen.ts +++ b/src/app/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as LogoutRouteImport } from './routes/logout' import { Route as LoginRouteImport } from './routes/login' import { Route as AuthedRouteImport } from './routes/_authed' import { Route as AuthedIndexRouteImport } from './routes/_authed/index' +import { Route as AuthedStatsRouteImport } from './routes/_authed/stats' import { Route as AuthedSettingsRouteImport } from './routes/_authed/settings' import { Route as AuthedAdminRouteImport } from './routes/_authed/admin' import { Route as AuthedTournamentsIndexRouteImport } from './routes/_authed/tournaments/index' @@ -63,6 +64,11 @@ const AuthedIndexRoute = AuthedIndexRouteImport.update({ path: '/', getParentRoute: () => AuthedRoute, } as any) +const AuthedStatsRoute = AuthedStatsRouteImport.update({ + id: '/stats', + path: '/stats', + getParentRoute: () => AuthedRoute, +} as any) const AuthedSettingsRoute = AuthedSettingsRouteImport.update({ id: '/settings', path: '/settings', @@ -178,6 +184,7 @@ export interface FileRoutesByFullPath { '/refresh-session': typeof RefreshSessionRoute '/admin': typeof AuthedAdminRouteWithChildren '/settings': typeof AuthedSettingsRoute + '/stats': typeof AuthedStatsRoute '/': typeof AuthedIndexRoute '/admin/preview': typeof AuthedAdminPreviewRoute '/profile/$playerId': typeof AuthedProfilePlayerIdRoute @@ -194,6 +201,7 @@ export interface FileRoutesByTo { '/logout': typeof LogoutRoute '/refresh-session': typeof RefreshSessionRoute '/settings': typeof AuthedSettingsRoute + '/stats': typeof AuthedStatsRoute '/': typeof AuthedIndexRoute '/admin/preview': typeof AuthedAdminPreviewRoute '/profile/$playerId': typeof AuthedProfilePlayerIdRoute @@ -213,6 +221,7 @@ export interface FileRoutesById { '/refresh-session': typeof RefreshSessionRoute '/_authed/admin': typeof AuthedAdminRouteWithChildren '/_authed/settings': typeof AuthedSettingsRoute + '/_authed/stats': typeof AuthedStatsRoute '/_authed/': typeof AuthedIndexRoute '/_authed/admin/preview': typeof AuthedAdminPreviewRoute '/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute @@ -232,6 +241,7 @@ export interface FileRouteTypes { | '/refresh-session' | '/admin' | '/settings' + | '/stats' | '/' | '/admin/preview' | '/profile/$playerId' @@ -248,6 +258,7 @@ export interface FileRouteTypes { | '/logout' | '/refresh-session' | '/settings' + | '/stats' | '/' | '/admin/preview' | '/profile/$playerId' @@ -266,6 +277,7 @@ export interface FileRouteTypes { | '/refresh-session' | '/_authed/admin' | '/_authed/settings' + | '/_authed/stats' | '/_authed/' | '/_authed/admin/preview' | '/_authed/profile/$playerId' @@ -403,6 +415,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedIndexRouteImport parentRoute: typeof AuthedRoute } + '/_authed/stats': { + id: '/_authed/stats' + path: '/stats' + fullPath: '/stats' + preLoaderRoute: typeof AuthedStatsRouteImport + parentRoute: typeof AuthedRoute + } '/_authed/settings': { id: '/_authed/settings' path: '/settings' @@ -573,6 +592,7 @@ const AuthedAdminRouteWithChildren = AuthedAdminRoute._addFileChildren( interface AuthedRouteChildren { AuthedAdminRoute: typeof AuthedAdminRouteWithChildren AuthedSettingsRoute: typeof AuthedSettingsRoute + AuthedStatsRoute: typeof AuthedStatsRoute AuthedIndexRoute: typeof AuthedIndexRoute AuthedProfilePlayerIdRoute: typeof AuthedProfilePlayerIdRoute AuthedTeamsTeamIdRoute: typeof AuthedTeamsTeamIdRoute @@ -583,6 +603,7 @@ interface AuthedRouteChildren { const AuthedRouteChildren: AuthedRouteChildren = { AuthedAdminRoute: AuthedAdminRouteWithChildren, AuthedSettingsRoute: AuthedSettingsRoute, + AuthedStatsRoute: AuthedStatsRoute, AuthedIndexRoute: AuthedIndexRoute, AuthedProfilePlayerIdRoute: AuthedProfilePlayerIdRoute, AuthedTeamsTeamIdRoute: AuthedTeamsTeamIdRoute, diff --git a/src/app/routes/_authed/stats.tsx b/src/app/routes/_authed/stats.tsx new file mode 100644 index 0000000..95f2e8e --- /dev/null +++ b/src/app/routes/_authed/stats.tsx @@ -0,0 +1,25 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { playerQueries, useAllPlayerStats } from "@/features/players/queries"; +import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; +import PlayerStatsTable from "@/features/players/components/player-stats-table"; + +export const Route = createFileRoute("/_authed/stats")({ + component: Stats, + beforeLoad: async ({ context }) => { + const queryClient = context.queryClient; + await ensureServerQueryData(queryClient, playerQueries.allStats()); + }, + loader: () => ({ + withPadding: true, + fullWidth: true, + header: { + title: "Player Stats" + } + }), +}); + +function Stats() { + const { data: playerStats } = useAllPlayerStats(); + + return ; +} \ No newline at end of file diff --git a/src/features/core/hooks/use-links.ts b/src/features/core/hooks/use-links.ts index 9992fcd..4a2b357 100644 --- a/src/features/core/hooks/use-links.ts +++ b/src/features/core/hooks/use-links.ts @@ -10,8 +10,8 @@ export const useLinks = (userId: string | undefined, roles: string[]) => Icon: HouseIcon }, { - label: 'Leaderboard', - href: '/leaderboard', + label: 'Statistics', + href: '/stats', Icon: RankingIcon }, { diff --git a/src/features/players/components/player-stats-table.tsx b/src/features/players/components/player-stats-table.tsx new file mode 100644 index 0000000..dd3f67f --- /dev/null +++ b/src/features/players/components/player-stats-table.tsx @@ -0,0 +1,256 @@ +import { useState, useMemo } from "react"; +import { + Table, + Text, + TextInput, + Stack, + Group, + Box, + ThemeIcon, + Container, + Title, + ScrollArea, + Paper, +} from "@mantine/core"; +import { + MagnifyingGlassIcon, + CaretUpIcon, + CaretDownIcon, + ChartBarIcon, +} from "@phosphor-icons/react"; +import { PlayerStats } from "../types"; +import { motion } from "framer-motion"; + +interface PlayerStatsTableProps { + playerStats: PlayerStats[]; +} + +type SortKey = keyof PlayerStats; +type SortDirection = 'asc' | 'desc'; + +interface SortConfig { + key: SortKey; + direction: SortDirection; +} + +const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => { + const [search, setSearch] = useState(""); + const [sortConfig, setSortConfig] = useState({ + key: 'win_percentage', + direction: 'desc' + }); + + const handleSort = (key: SortKey) => { + setSortConfig(prev => ({ + key, + direction: prev.key === key && prev.direction === 'desc' ? 'asc' : 'desc' + })); + }; + + const getSortIcon = (key: SortKey) => { + if (sortConfig.key !== key) return null; + return sortConfig.direction === 'desc' ? : ; + }; + + const filteredAndSortedStats = useMemo(() => { + let filtered = playerStats.filter(stat => + stat.player_name.toLowerCase().includes(search.toLowerCase()) + ); + + return filtered.sort((a, b) => { + const aValue = a[sortConfig.key]; + const bValue = b[sortConfig.key]; + + 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' + ? bValue.localeCompare(aValue) + : aValue.localeCompare(bValue); + } + + return 0; + }); + }, [playerStats, search, sortConfig]); + + const formatPercentage = (value: number) => `${value.toFixed(1)}%`; + const formatDecimal = (value: number) => value.toFixed(2); + + const columns = [ + { key: 'player_name' as SortKey, label: 'Player', width: 200 }, + { 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) => { + switch (column.key) { + case 'player_name': + return ( + + #{index + 1} + {stat.player_name} + + ); + case 'win_percentage': + return ( + = 70 ? "green" : stat.win_percentage >= 50 ? "yellow" : "red"}> + {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 {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]}; + } + }; + + if (playerStats.length === 0) { + return ( + + + + + + No Stats Available + + Player statistics will appear here once matches have been played. + + + + ); + } + + return ( + + + + + Player Statistics + + {filteredAndSortedStats.length} of {playerStats.length} players + + + + + setSearch(e.currentTarget.value)} + leftSection={} + size="md" + /> + + + + + + + {columns.map((column, index) => ( + handleSort(column.key)} + > + + + {column.label} + + + {getSortIcon(column.key)} + + + + ))} + + + + {filteredAndSortedStats.map((stat, index) => ( + + {columns.map((column) => ( + + {renderCellContent(stat, column, index)} + + ))} + + ))} + +
+
+
+ + {filteredAndSortedStats.length === 0 && search && ( + + No players found matching "{search}" + + )} +
+
+ ); +}; + +export default PlayerStatsTable; \ No newline at end of file diff --git a/src/features/players/queries.ts b/src/features/players/queries.ts index 422a9e2..b5100ac 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 } from "./server"; +import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe, getPlayerStats, getAllPlayerStats } from "./server"; export const playerKeys = { auth: ['auth'], @@ -7,6 +7,7 @@ export const playerKeys = { details: (id: string) => ['players', 'details', id], unassociated: ['players','unassociated'], stats: (id: string) => ['players', 'stats', id], + allStats: ['players', 'stats', 'all'], }; export const playerQueries = { @@ -30,6 +31,10 @@ export const playerQueries = { queryKey: playerKeys.stats(id), queryFn: async () => await getPlayerStats({ data: id }) }), + allStats: () => ({ + queryKey: playerKeys.allStats, + queryFn: async () => await getAllPlayerStats() + }), }; export const useMe = () => { @@ -65,4 +70,7 @@ export const useUnassociatedPlayers = () => useServerSuspenseQuery(playerQueries.unassociated()); export const usePlayerStats = (id: string) => - useServerSuspenseQuery(playerQueries.stats(id)); \ No newline at end of file + useServerSuspenseQuery(playerQueries.stats(id)); + +export const useAllPlayerStats = () => + useServerSuspenseQuery(playerQueries.allStats()); \ No newline at end of file diff --git a/src/features/players/server.ts b/src/features/players/server.ts index cd29c87..0415379 100644 --- a/src/features/players/server.ts +++ b/src/features/players/server.ts @@ -123,6 +123,12 @@ export const getUnassociatedPlayers = createServerFn() export const getPlayerStats = createServerFn() .validator(z.string()) .middleware([superTokensFunctionMiddleware]) - .handler(async ({ data }) => + .handler(async ({ data }) => toServerResult(async () => await pbAdmin.getPlayerStats(data)) + ); + +export const getAllPlayerStats = createServerFn() + .middleware([superTokensFunctionMiddleware]) + .handler(async () => + toServerResult(async () => await pbAdmin.getAllPlayerStats()) ); \ No newline at end of file diff --git a/src/lib/pocketbase/services/players.ts b/src/lib/pocketbase/services/players.ts index 17d1e27..74d2d2d 100644 --- a/src/lib/pocketbase/services/players.ts +++ b/src/lib/pocketbase/services/players.ts @@ -70,5 +70,12 @@ export function createPlayersService(pb: PocketBase) { }); return result; }, + + async getAllPlayerStats(): Promise { + const result = await pb.collection("player_stats").getFullList({ + sort: "-win_percentage,-total_cups_made", + }); + return result; + }, }; }