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