From 15bbca8b90942e138c8d59211f39300461814524 Mon Sep 17 00:00:00 2001 From: yohlo Date: Wed, 8 Oct 2025 09:03:29 -0500 Subject: [PATCH 01/18] fix stats breaking new player profiles --- src/components/stats-overview.tsx | 8 ++++---- src/lib/pocketbase/services/players.ts | 26 ++++++++++++++++++++++---- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/components/stats-overview.tsx b/src/components/stats-overview.tsx index 5ca7688..60a3bbd 100644 --- a/src/components/stats-overview.tsx +++ b/src/components/stats-overview.tsx @@ -101,10 +101,10 @@ const StatsOverview = ({ statsData, isLoading = false }: StatsOverviewProps) => { label: "Losses", value: overallStats.losses, Icon: XIcon }, { label: "Cups Made", value: overallStats.total_cups_made, Icon: FireIcon }, { label: "Cups Against", value: overallStats.total_cups_against, Icon: ShieldIcon }, - { label: "Avg Cups Per Game", value: avgCupsPerMatch > 0 ? avgCupsPerMatch : null, Icon: ChartLineUpIcon }, - { label: "Avg Cups Against", value: avgCupsAgainstPerMatch > 0 ? avgCupsAgainstPerMatch : null, Icon: ShieldCheckIcon }, - { label: "Avg Win Margin", value: avgMarginOfVictory > 0 ? avgMarginOfVictory : null, Icon: ArrowUpIcon }, - { label: "Avg Loss Margin", value: avgMarginOfLoss > 0 ? avgMarginOfLoss : null, Icon: ArrowDownIcon }, + { label: "Avg Cups Per Game", value: avgCupsPerMatch >= 0 ? avgCupsPerMatch : null, Icon: ChartLineUpIcon }, + { label: "Avg Cups Against", value: avgCupsAgainstPerMatch >= 0 ? avgCupsAgainstPerMatch : null, Icon: ShieldCheckIcon }, + { label: "Avg Win Margin", value: avgMarginOfVictory >= 0 ? avgMarginOfVictory : null, Icon: ArrowUpIcon }, + { label: "Avg Loss Margin", value: avgMarginOfLoss >= 0 ? avgMarginOfLoss : null, Icon: ArrowDownIcon }, ]; return ( diff --git a/src/lib/pocketbase/services/players.ts b/src/lib/pocketbase/services/players.ts index 5fca6a1..cdd2783 100644 --- a/src/lib/pocketbase/services/players.ts +++ b/src/lib/pocketbase/services/players.ts @@ -66,10 +66,28 @@ export function createPlayersService(pb: PocketBase) { }, async getPlayerStats(playerId: string): Promise { - const result = await pb.collection("player_stats").getFirstListItem( - `player_id = "${playerId}"` - ); - return result; + try { + const result = await pb.collection("player_stats").getFirstListItem( + `player_id = "${playerId}"` + ); + return result; + } catch (error) { + return { + id: "", + player_id: playerId, + player_name: "", + matches: 0, + tournaments: 0, + wins: 0, + losses: 0, + total_cups_made: 0, + total_cups_against: 0, + win_percentage: 0, + avg_cups_per_match: 0, + margin_of_victory: 0, + margin_of_loss: 0, + }; + } }, async getAllPlayerStats(): Promise { From 97427718e895eec0aadecfcd386bc624e720bfc4 Mon Sep 17 00:00:00 2001 From: yohlo Date: Wed, 8 Oct 2025 09:20:26 -0500 Subject: [PATCH 02/18] activity for server result errors --- src/app/router.tsx | 2 +- .../tanstack-query/utils/to-server-result.ts | 43 ++++++++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/app/router.tsx b/src/app/router.tsx index c70f40b..49d04f5 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -13,7 +13,7 @@ export function getRouter() { gcTime: 5 * 60 * 1000, // 5 minutes refetchOnWindowFocus: false, refetchOnReconnect: "always", - retry: 3, + retry: 1, }, }, }); diff --git a/src/lib/tanstack-query/utils/to-server-result.ts b/src/lib/tanstack-query/utils/to-server-result.ts index d6d6f4c..d910b37 100644 --- a/src/lib/tanstack-query/utils/to-server-result.ts +++ b/src/lib/tanstack-query/utils/to-server-result.ts @@ -1,5 +1,7 @@ import { logger } from "../../logger"; import { ErrorType, ServerError, ServerResult } from "../types"; +import { pbAdmin } from "../../pocketbase/client"; +import { getRequest } from "@tanstack/react-start/server"; export const createServerError = ( type: ErrorType, @@ -15,14 +17,53 @@ export const createServerError = ( context, }); -export const toServerResult = async (serverFn: () => Promise): Promise> => { +export const toServerResult = async ( + serverFn: () => Promise +): Promise> => { + const startTime = Date.now(); + try { const data = await serverFn(); return { success: true, data }; } catch (error) { + const duration = Date.now() - startTime; logger.error('Server Fn Error', error); const mappedError = mapKnownError(error); + + let fnName = 'unknown'; + try { + const request = getRequest(); + const url = new URL(request.url); + + const functionId = url.searchParams.get('_serverFnId') || url.pathname; + + if (functionId.includes('--')) { + const match = functionId.match(/--([^_]+)_/); + fnName = match?.[1] || functionId.split('--')[1]?.split('_')[0] || 'unknown'; + } else { + fnName = serverFn.name || 'unknown'; + } + } catch { + fnName = serverFn.name || 'unknown'; + } + + try { + await pbAdmin.authPromise; + await pbAdmin.createActivity({ + name: fnName, + duration, + success: false, + error: mappedError.message, + arguments: { + errorType: mappedError.code, + statusCode: mappedError.statusCode, + userMessage: mappedError.userMessage, + }, + }); + } catch (activityError) { + } + return { success: false, error: mappedError }; } }; From f96f92c7c905903d2f426817638eca1b1b48b08b Mon Sep 17 00:00:00 2001 From: yohlo Date: Fri, 10 Oct 2025 16:03:51 -0500 Subject: [PATCH 03/18] last activity for players --- pb_migrations/1760127117_updated_players.js | 26 ++++ src/app/routes/_authed/admin/activities.tsx | 25 +++- src/components/button.tsx | 1 - .../components/activities-table.tsx | 4 - .../players/components/player-stats-table.tsx | 2 - .../components/players-activity-table.tsx | 118 ++++++++++++++++++ src/features/players/index.ts | 4 +- src/features/players/queries.ts | 12 +- src/features/players/server.ts | 6 + src/features/players/types.ts | 5 +- .../enroll-team/team-selection-view.tsx | 2 - src/lib/pocketbase/services/players.ts | 8 ++ src/lib/pocketbase/util/transform-types.ts | 1 + src/utils/supertokens.ts | 12 ++ 14 files changed, 211 insertions(+), 15 deletions(-) create mode 100644 pb_migrations/1760127117_updated_players.js create mode 100644 src/features/players/components/players-activity-table.tsx diff --git a/pb_migrations/1760127117_updated_players.js b/pb_migrations/1760127117_updated_players.js new file mode 100644 index 0000000..f748717 --- /dev/null +++ b/pb_migrations/1760127117_updated_players.js @@ -0,0 +1,26 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_3072146508") + + // add field + collection.fields.addAt(5, new Field({ + "hidden": false, + "id": "date3558165700", + "max": "", + "min": "", + "name": "last_activity", + "presentable": false, + "required": false, + "system": false, + "type": "date" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_3072146508") + + // remove field + collection.fields.removeById("date3558165700") + + return app.save(collection) +}) diff --git a/src/app/routes/_authed/admin/activities.tsx b/src/app/routes/_authed/admin/activities.tsx index e9141d1..e1ee2ba 100644 --- a/src/app/routes/_authed/admin/activities.tsx +++ b/src/app/routes/_authed/admin/activities.tsx @@ -1,12 +1,16 @@ import { createFileRoute } from "@tanstack/react-router"; import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch"; import { ActivitiesTable, activityQueries } from "@/features/activities"; +import { PlayersActivityTable, playerQueries } from "@/features/players"; +import { Tabs } from "@mantine/core"; +import { useState } from "react"; export const Route = createFileRoute("/_authed/admin/activities")({ component: Stats, beforeLoad: ({ context }) => { const queryClient = context.queryClient; prefetchServerQuery(queryClient, activityQueries.search()); + prefetchServerQuery(queryClient, playerQueries.activity()); }, loader: () => ({ withPadding: false, @@ -15,10 +19,27 @@ export const Route = createFileRoute("/_authed/admin/activities")({ title: "Activities", withBackButton: true, }, - refresh: [activityQueries.search().queryKey], + refresh: [activityQueries.search().queryKey, playerQueries.activity().queryKey], }), }); function Stats() { - return ; + const [activeTab, setActiveTab] = useState("server-functions"); + + return ( + + + Server Functions + Player Activity + + + + + + + + + + + ); } diff --git a/src/components/button.tsx b/src/components/button.tsx index 68b6dab..9224a52 100644 --- a/src/components/button.tsx +++ b/src/components/button.tsx @@ -10,5 +10,4 @@ const Button = forwardRef((props, ref) => { return ; }); -Button.displayName = "Button"; export default Button; diff --git a/src/features/activities/components/activities-table.tsx b/src/features/activities/components/activities-table.tsx index 2b07a88..dbbb381 100644 --- a/src/features/activities/components/activities-table.tsx +++ b/src/features/activities/components/activities-table.tsx @@ -92,8 +92,6 @@ const ActivityListItem = memo(({ activity, onClick }: ActivityListItemProps) => ); }); -ActivityListItem.displayName = "ActivityListItem"; - interface ActivityDetailsSheetProps { activity: Activity | null; isOpen: boolean; @@ -205,8 +203,6 @@ const ActivityDetailsSheet = memo(({ activity, isOpen, onClose }: ActivityDetail ); }); -ActivityDetailsSheet.displayName = "ActivityDetailsSheet"; - const ActivitiesResults = ({ searchParams, page, setPage, onActivityClick }: any) => { const { data: result } = useActivities(searchParams); return ( diff --git a/src/features/players/components/player-stats-table.tsx b/src/features/players/components/player-stats-table.tsx index f16d95b..8195f36 100644 --- a/src/features/players/components/player-stats-table.tsx +++ b/src/features/players/components/player-stats-table.tsx @@ -141,8 +141,6 @@ const PlayerListItem = memo(({ stat, onPlayerClick, mmr }: PlayerListItemProps) ); }); -PlayerListItem.displayName = 'PlayerListItem'; - const PlayerStatsTable = () => { const { data: playerStats } = useAllPlayerStats(); const navigate = useNavigate(); diff --git a/src/features/players/components/players-activity-table.tsx b/src/features/players/components/players-activity-table.tsx new file mode 100644 index 0000000..ba64f8f --- /dev/null +++ b/src/features/players/components/players-activity-table.tsx @@ -0,0 +1,118 @@ +import { memo } from "react"; +import { + Text, + Stack, + Group, + Box, + Container, + Divider, + UnstyledButton, +} from "@mantine/core"; +import { Player } from "../types"; +import { usePlayersActivity } from "../queries"; + +interface PlayerActivityItemProps { + player: Player; +} + +const PlayerActivityItem = memo(({ player }: PlayerActivityItemProps) => { + const playerName = player.first_name && player.last_name + ? `${player.first_name} ${player.last_name}` + : player.first_name || player.last_name || "Unknown Player"; + + const formatDate = (dateStr?: string) => { + if (!dateStr) return "Never"; + const date = new Date(dateStr); + return date.toLocaleString(); + }; + + const getTimeSince = (dateStr?: string) => { + if (!dateStr) return "Never active"; + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffMins < 1) return "Just now"; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 30) return `${diffDays}d ago`; + return formatDate(dateStr); + }; + + const isActive = player.last_activity && + (new Date().getTime() - new Date(player.last_activity).getTime()) < 5 * 60 * 1000; + + return ( + + + + + + {playerName} + + {isActive && ( + + )} + + + + {getTimeSince(player.last_activity)} + + {player.last_activity && ( + + {formatDate(player.last_activity)} + + )} + + + + + ); +}); + +export const PlayersActivityTable = () => { + const { data: players } = usePlayersActivity(); + + return ( + + + + + {players.length} players + + + + + {players.map((player: Player, index: number) => ( + + + {index < players.length - 1 && } + + ))} + + + {players.length === 0 && ( + + No player activity found + + )} + + + ); +}; diff --git a/src/features/players/index.ts b/src/features/players/index.ts index d847586..b551ff7 100644 --- a/src/features/players/index.ts +++ b/src/features/players/index.ts @@ -1,3 +1,5 @@ import { Logger } from "@/lib/logger"; -export const logger = new Logger('Players'); \ No newline at end of file +export const logger = new Logger('Players'); +export * from "./queries"; +export { PlayersActivityTable } from "./components/players-activity-table"; \ No newline at end of file diff --git a/src/features/players/queries.ts b/src/features/players/queries.ts index ee4170f..3a871b4 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, getPlayerMatches, getUnenrolledPlayers } from "./server"; +import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe, getPlayerStats, getAllPlayerStats, getPlayerMatches, getUnenrolledPlayers, getPlayersActivity } from "./server"; export const playerKeys = { auth: ['auth'], @@ -10,6 +10,7 @@ export const playerKeys = { stats: (id: string) => ['players', 'stats', id], allStats: ['players', 'stats', 'all'], matches: (id: string) => ['players', 'matches', id], + activity: ['players', 'activity'], }; export const playerQueries = { @@ -45,6 +46,10 @@ export const playerQueries = { queryKey: playerKeys.matches(id), queryFn: async () => await getPlayerMatches({ data: id }) }), + activity: () => ({ + queryKey: playerKeys.activity, + queryFn: async () => await getPlayersActivity() + }), }; export const useMe = () => { @@ -89,4 +94,7 @@ export const usePlayerMatches = (id: string) => useServerSuspenseQuery(playerQueries.matches(id)); export const useUnenrolledPlayers = (tournamentId: string) => - useServerSuspenseQuery(playerQueries.unenrolled(tournamentId)); \ No newline at end of file + useServerSuspenseQuery(playerQueries.unenrolled(tournamentId)); + +export const usePlayersActivity = () => + useServerSuspenseQuery(playerQueries.activity()); \ No newline at end of file diff --git a/src/features/players/server.ts b/src/features/players/server.ts index d316b0d..399dba3 100644 --- a/src/features/players/server.ts +++ b/src/features/players/server.ts @@ -161,3 +161,9 @@ export const getUnenrolledPlayers = createServerFn() .handler(async ({ data: tournamentId }) => toServerResult(async () => await pbAdmin.getUnenrolledPlayers(tournamentId)) ); + +export const getPlayersActivity = createServerFn() + .middleware([superTokensFunctionMiddleware]) + .handler(async () => + toServerResult(async () => await pbAdmin.getPlayersActivity()) + ); diff --git a/src/features/players/types.ts b/src/features/players/types.ts index f850715..bf7bba0 100644 --- a/src/features/players/types.ts +++ b/src/features/players/types.ts @@ -14,6 +14,7 @@ export interface Player { last_name?: string; created?: string; updated?: string; + last_activity?: string; teams?: TeamInfo[]; } @@ -23,7 +24,9 @@ export const playerInputSchema = z.object({ last_name: z.string().min(2).max(20).regex(/^[a-zA-Z0-9\s]+$/, "Last name must be 2-20 characters long and contain only letters and spaces"), }); -export const playerUpdateSchema = playerInputSchema.partial(); +export const playerUpdateSchema = playerInputSchema.extend({ + last_activity: z.string().optional(), +}).partial(); export type PlayerInput = z.infer; export type PlayerUpdateInput = z.infer; diff --git a/src/features/tournaments/components/upcoming-tournament/enroll-team/team-selection-view.tsx b/src/features/tournaments/components/upcoming-tournament/enroll-team/team-selection-view.tsx index 95a3bb7..d4e3956 100644 --- a/src/features/tournaments/components/upcoming-tournament/enroll-team/team-selection-view.tsx +++ b/src/features/tournaments/components/upcoming-tournament/enroll-team/team-selection-view.tsx @@ -79,6 +79,4 @@ const TeamSelectionView: React.FC = React.memo(({ ); }); -TeamSelectionView.displayName = 'TeamSelectionView'; - export default TeamSelectionView; \ No newline at end of file diff --git a/src/lib/pocketbase/services/players.ts b/src/lib/pocketbase/services/players.ts index cdd2783..89a720d 100644 --- a/src/lib/pocketbase/services/players.ts +++ b/src/lib/pocketbase/services/players.ts @@ -166,5 +166,13 @@ export function createPlayersService(pb: PocketBase) { return allPlayers.map(transformPlayer); } }, + + async getPlayersActivity(): Promise { + const result = await pb.collection("players").getFullList({ + sort: "-last_activity", + fields: "id,first_name,last_name,auth_id,last_activity", + }); + return result.map(transformPlayer); + }, }; } diff --git a/src/lib/pocketbase/util/transform-types.ts b/src/lib/pocketbase/util/transform-types.ts index b80a4e0..03c583d 100644 --- a/src/lib/pocketbase/util/transform-types.ts +++ b/src/lib/pocketbase/util/transform-types.ts @@ -128,6 +128,7 @@ export function transformPlayer(record: any): Player { auth_id: record.auth_id, created: record.created, updated: record.updated, + last_activity: record.last_activity, teams, }; } diff --git a/src/utils/supertokens.ts b/src/utils/supertokens.ts index 8d7efc1..faeba78 100644 --- a/src/utils/supertokens.ts +++ b/src/utils/supertokens.ts @@ -11,6 +11,7 @@ import { getSessionForStart } from "@/lib/supertokens/recipes/start-session"; import { Logger } from "@/lib/logger"; import z from "zod"; import { serverFnLoggingMiddleware } from "./activities"; +import { pbAdmin } from "@/lib/pocketbase/client"; const logger = new Logger("Middleware"); const verifySuperTokensSession = async ( @@ -75,6 +76,17 @@ export const getSessionContext = createServerOnlyFn(async (request: Request, opt phone: session.context.phone }; + try { + const player = await pbAdmin.getPlayerByAuthId(session.context.userAuthId); + if (player) { + await pbAdmin.updatePlayer(player.id, { + last_activity: new Date().toISOString(), + }); + } + } catch (error) { + logger.error("Failed to update player last_activity", error); + } + return context; }); From 4b534c86cd0e3586f79b851fc576279a13d4fdb3 Mon Sep 17 00:00:00 2001 From: yohlo Date: Fri, 10 Oct 2025 23:44:27 -0500 Subject: [PATCH 04/18] fixes, improvmeents --- bun.lock | 24 ++++++++ package.json | 1 + src/features/core/components/providers.tsx | 19 +++++++ src/features/core/components/pullable.tsx | 5 +- .../players/components/player-stats-table.tsx | 56 +++++++++++++++++-- 5 files changed, 99 insertions(+), 6 deletions(-) diff --git a/bun.lock b/bun.lock index 75b36f5..1d51468 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "@mantine/tiptap": "^8.2.4", "@phosphor-icons/react": "^2.1.10", "@svgmoji/noto": "^3.2.0", + "@tanstack/react-devtools": "^0.7.6", "@tanstack/react-query": "^5.66.0", "@tanstack/react-query-devtools": "^5.66.0", "@tanstack/react-router": "^1.130.12", @@ -26,6 +27,7 @@ "@tiptap/starter-kit": "^3.4.3", "@types/bun": "^1.2.22", "@types/ioredis": "^4.28.10", + "browser-image-compression": "^2.0.2", "dotenv": "^17.2.2", "embla-carousel-react": "^8.6.0", "framer-motion": "^12.23.12", @@ -336,6 +338,14 @@ "@simplewebauthn/browser": ["@simplewebauthn/browser@13.2.0", "", {}, "sha512-N3fuA1AAnTo5gCStYoIoiasPccC+xPLx2YU88Dv0GeAmPQTWHETlZQq5xZ0DgUq1H9loXMWQH5qqUjcI7BHJ1A=="], + "@solid-primitives/event-listener": ["@solid-primitives/event-listener@2.4.3", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-h4VqkYFv6Gf+L7SQj+Y6puigL/5DIi7x5q07VZET7AWcS+9/G3WfIE9WheniHWJs51OEkRB43w6lDys5YeFceg=="], + + "@solid-primitives/keyboard": ["@solid-primitives/keyboard@1.3.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-9dQHTTgLBqyAI7aavtO+HnpTVJgWQA1ghBSrmLtMu1SMxLPDuLfuNr+Tk5udb4AL4Ojg7h9JrKOGEEDqsJXWJA=="], + + "@solid-primitives/rootless": ["@solid-primitives/rootless@1.5.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-9HULb0QAzL2r47CCad0M+NKFtQ+LrGGNHZfteX/ThdGvKIg2o2GYhBooZubTCd/RTu2l2+Nw4s+dEfiDGvdrrQ=="], + + "@solid-primitives/utils": ["@solid-primitives/utils@6.3.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ=="], + "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], @@ -344,6 +354,12 @@ "@svgmoji/noto": ["@svgmoji/noto@3.2.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@svgmoji/core": "^3.2.0" } }, "sha512-JgtNciB06hMDI1Pb1N2IgLh44XRMZUUNwBANzjY5jXTPqOCu1A1VA35ENvUsRhEUZOm8I+hbdAEHkwMVqxLeIQ=="], + "@tanstack/devtools": ["@tanstack/devtools@0.6.20", "", { "dependencies": { "@solid-primitives/keyboard": "^1.3.3", "@tanstack/devtools-event-bus": "0.3.2", "@tanstack/devtools-ui": "0.4.2", "clsx": "^2.1.1", "goober": "^2.1.16", "solid-js": "^1.9.9" } }, "sha512-7Sw6bWvwKsHDNLg+8v7xOXhE5tzwx6/KgLWSSP55pJ86wpSXYdIm89vvXm4ED1lgKfEU5l3f4Y6QVagU4rgRiQ=="], + + "@tanstack/devtools-event-bus": ["@tanstack/devtools-event-bus@0.3.2", "", { "dependencies": { "ws": "^8.18.3" } }, "sha512-yJT2As/drc+Epu0nsqCsJaKaLcaNGufiNxSlp/+/oeTD0jsBxF9/PJBfh66XVpYXkKr97b8689mSu7QMef0Rrw=="], + + "@tanstack/devtools-ui": ["@tanstack/devtools-ui@0.4.2", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "solid-js": "^1.9.9" } }, "sha512-xvALRLeD+TYjaLx9f9OrRBBZITAYPIk7RH8LRiESUQHw7lZO/sBU1ggrcSePh7TwKWXl9zLmtUi+7xVIS+j/dQ=="], + "@tanstack/directive-functions-plugin": ["@tanstack/directive-functions-plugin@1.132.0", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/router-utils": "1.132.0", "babel-dead-code-elimination": "^1.0.10", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "vite": ">=6.0.0 || >=7.0.0" } }, "sha512-5+K3msIpSYkiDE0PTIAT2HzZRps/M2uQsDEA5HApXxOhIAWykQ/yyO1umgkMwYpgJqnT96AVHb0E559Dfvhj0A=="], "@tanstack/history": ["@tanstack/history@1.132.0", "", {}, "sha512-GG2R9I6QSlbNR9fEuX2sQCigY6K28w51h2634TWmkaHXlzQw+rWuIWr4nAGM9doA+kWRi1LFSFMvAiG3cOqjXQ=="], @@ -352,6 +368,8 @@ "@tanstack/query-devtools": ["@tanstack/query-devtools@5.90.1", "", {}, "sha512-GtINOPjPUH0OegJExZ70UahT9ykmAhmtNVcmtdnOZbxLwT7R5OmRztR5Ahe3/Cu7LArEmR6/588tAycuaWb1xQ=="], + "@tanstack/react-devtools": ["@tanstack/react-devtools@0.7.6", "", { "dependencies": { "@tanstack/devtools": "0.6.20" }, "peerDependencies": { "@types/react": ">=16.8", "@types/react-dom": ">=16.8", "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-fP0jY7yed0HVIEhs+rjn8wZqABD/6TUiq6SV8jlyYP8NBK2Jfq3ce+IRw5w+N7KBzEokveLQFktxoLNpt3ZOkA=="], + "@tanstack/react-query": ["@tanstack/react-query@5.90.2", "", { "dependencies": { "@tanstack/query-core": "5.90.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-CLABiR+h5PYfOWr/z+vWFt5VsOA2ekQeRQBFSKlcoW6Ndx/f8rfyVmq4LbgOM4GG2qtxAxjLYLOpCNTYm4uKzw=="], "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.90.2", "", { "dependencies": { "@tanstack/query-devtools": "5.90.1" }, "peerDependencies": { "@tanstack/react-query": "^5.90.2", "react": "^18 || ^19" } }, "sha512-vAXJzZuBXtCQtrY3F/yUNJCV4obT/A/n81kb3+YqLbro5Z2+phdAbceO+deU3ywPw8B42oyJlp4FhO0SoivDFQ=="], @@ -544,6 +562,8 @@ "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "browser-image-compression": ["browser-image-compression@2.0.2", "", { "dependencies": { "uzip": "0.20201231.0" } }, "sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw=="], + "browser-tabs-lock": ["browser-tabs-lock@1.3.0", "", { "dependencies": { "lodash": ">=4.17.21" } }, "sha512-g6nHaobTiT0eMZ7jh16YpD2kcjAp+PInbiVq3M1x6KKaEIVhT4v9oURNIpZLOZ3LQbQ3XYfNhMAb/9hzNLIWrw=="], "browserslist": ["browserslist@4.26.2", "", { "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", "electron-to-chromium": "^1.5.218", "node-releases": "^2.0.21", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A=="], @@ -1166,6 +1186,8 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "uzip": ["uzip@0.20201231.0", "", {}, "sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng=="], + "vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="], "victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], @@ -1190,6 +1212,8 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "xmlbuilder": ["xmlbuilder@13.0.2", "", {}, "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ=="], "xmlbuilder2": ["xmlbuilder2@3.1.1", "", { "dependencies": { "@oozcitak/dom": "1.15.10", "@oozcitak/infra": "1.0.8", "@oozcitak/util": "8.3.8", "js-yaml": "3.14.1" } }, "sha512-WCSfbfZnQDdLQLiMdGUQpMxxckeQ4oZNMNhLVkcekTu7xhD4tuUDyAPoY8CwXvBYE6LwBHd6QW2WZXlOWr1vCw=="], diff --git a/package.json b/package.json index 0334e71..8452087 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@mantine/tiptap": "^8.2.4", "@phosphor-icons/react": "^2.1.10", "@svgmoji/noto": "^3.2.0", + "@tanstack/react-devtools": "^0.7.6", "@tanstack/react-query": "^5.66.0", "@tanstack/react-query-devtools": "^5.66.0", "@tanstack/react-router": "^1.130.12", diff --git a/src/features/core/components/providers.tsx b/src/features/core/components/providers.tsx index 7eea703..fe79eff 100644 --- a/src/features/core/components/providers.tsx +++ b/src/features/core/components/providers.tsx @@ -1,6 +1,9 @@ import { AuthProvider } from "@/contexts/auth-context" import { SpotifyProvider } from "@/contexts/spotify-context" import MantineProvider from "@/lib/mantine/mantine-provider" +import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools' +import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools' +import { TanStackDevtools } from '@tanstack/react-devtools' import { Toaster } from "sonner" const Providers = ({ children }: { children: React.ReactNode }) => { @@ -8,6 +11,22 @@ const Providers = ({ children }: { children: React.ReactNode }) => { + , + }, + { + name: 'TanStack Router', + render: , + } + ]} + /> {children} diff --git a/src/features/core/components/pullable.tsx b/src/features/core/components/pullable.tsx index 7fcbd7c..043f9cc 100644 --- a/src/features/core/components/pullable.tsx +++ b/src/features/core/components/pullable.tsx @@ -32,7 +32,10 @@ const Pullable: React.FC = ({ children, scrollPosition, onScrollP if (refresh.length > 0) { // TODO: Remove this after testing - or does the delay help ux? await new Promise(resolve => setTimeout(resolve, 1000)); - await queryClient.refetchQueries({ queryKey: refresh, exact: true}); + refresh.forEach(async (queryKey) => { + const keyArray = Array.isArray(queryKey) ? queryKey : [queryKey]; + await queryClient.refetchQueries({ queryKey: keyArray, exact: true }); + }); } setIsRefreshing(false); }, [refresh]); diff --git a/src/features/players/components/player-stats-table.tsx b/src/features/players/components/player-stats-table.tsx index 8195f36..d1b48d6 100644 --- a/src/features/players/components/player-stats-table.tsx +++ b/src/features/players/components/player-stats-table.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useCallback, memo } from "react"; +import { useState, useMemo, useCallback, memo, use } from "react"; import { Text, TextInput, @@ -68,13 +68,15 @@ const PlayerListItem = memo(({ stat, onPlayerClick, mmr }: PlayerListItemProps) {stat.player_name} - {stat.matches} matches + {stat.matches} + M - {stat.tournaments} tournaments + {stat.tournaments} + T - + MMR @@ -109,7 +111,15 @@ const PlayerListItem = memo(({ stat, onPlayerClick, mmr }: PlayerListItemProps) - AVG + AWM + + + {stat.margin_of_victory?.toFixed(1) || 0} + + + + + AC {stat.avg_cups_per_match.toFixed(1)} @@ -301,6 +311,42 @@ const PlayerStatsTable = () => { + + Stat Abbreviations: + + + • M: Matches + + + • T: Tournaments + + + • MMR: Matchmaking Rating + + + • W: Wins + + + • L: Losses + + + • W%: Win Percentage + + + • AWM: Average Win Margin + + + • AC: Average Cups Per Match + + + • CF: Cups For + + + • CA: Cups Against + + + + MMR Calculation: From 6a7d119d3e6fb8742eaeb678ce22fabcb6a7dcea Mon Sep 17 00:00:00 2001 From: yohlo Date: Sat, 11 Oct 2025 00:29:29 -0500 Subject: [PATCH 05/18] more stats --- .../player-stats-table-skeleton.tsx | 60 +++-- .../players/components/player-stats-table.tsx | 206 ++++++++++-------- 2 files changed, 151 insertions(+), 115 deletions(-) diff --git a/src/features/players/components/player-stats-table-skeleton.tsx b/src/features/players/components/player-stats-table-skeleton.tsx index 013c768..b341608 100644 --- a/src/features/players/components/player-stats-table-skeleton.tsx +++ b/src/features/players/components/player-stats-table-skeleton.tsx @@ -5,52 +5,66 @@ import { Container, Divider, Skeleton, + ScrollArea, } from "@mantine/core"; const PlayerListItemSkeleton = () => { return ( - - - - - - - - - - - + + + + + + + + + + + + - + - + - + - + - + + + + + + + + + + + + + - + - - + + ); @@ -60,13 +74,13 @@ const PlayerStatsTableSkeleton = () => { return ( - + + - - - + + diff --git a/src/features/players/components/player-stats-table.tsx b/src/features/players/components/player-stats-table.tsx index d1b48d6..8f47063 100644 --- a/src/features/players/components/player-stats-table.tsx +++ b/src/features/players/components/player-stats-table.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useCallback, memo, use } from "react"; +import { useState, useMemo, useCallback, memo, useRef, useEffect } from "react"; import { Text, TextInput, @@ -12,6 +12,7 @@ import { UnstyledButton, Popover, ActionIcon, + ScrollArea, } from "@mantine/core"; import { MagnifyingGlassIcon, @@ -37,9 +38,41 @@ interface PlayerListItemProps { stat: PlayerStats; onPlayerClick: (playerId: string) => void; mmr: number; + onRegisterViewport: (viewport: HTMLDivElement) => void; + onUnregisterViewport: (viewport: HTMLDivElement) => void; } -const PlayerListItem = memo(({ stat, onPlayerClick, mmr }: PlayerListItemProps) => { +interface StatCellProps { + label: string; + value: string | number; +} + +const StatCell = memo(({ label, value }: StatCellProps) => ( + + + {label} + + + {value} + + +)); + +const PlayerListItem = memo(({ stat, onPlayerClick, mmr, onRegisterViewport, onUnregisterViewport }: PlayerListItemProps) => { + const viewportRef = useRef(null); + + const avg_cups_against = useMemo(() => stat.total_cups_against / stat.matches || 0, [stat.total_cups_against, stat.matches]); + + useEffect(() => { + if (viewportRef.current) { + onRegisterViewport(viewportRef.current); + return () => { + if (viewportRef.current) { + onUnregisterViewport(viewportRef.current); + } + }; + } + }, [onRegisterViewport, onUnregisterViewport]); return ( <> @@ -59,92 +92,41 @@ const PlayerListItem = memo(({ stat, onPlayerClick, mmr }: PlayerListItemProps) }, }} > - - - - - - - {stat.player_name} - - - {stat.matches} - M - - - {stat.tournaments} - T - + + + + + + {stat.player_name} + + + {stat.matches} + M + + + {stat.tournaments} + T + + + + + + + + + + + + + + + - - - - MMR - - - {mmr.toFixed(1)} - - - - - W - - - {stat.wins} - - - - - L - - - {stat.losses} - - - - - W% - - - {stat.win_percentage.toFixed(1)}% - - - - - AWM - - - {stat.margin_of_victory?.toFixed(1) || 0} - - - - - AC - - - {stat.avg_cups_per_match.toFixed(1)} - - - - - CF - - - {stat.total_cups_made} - - - - - CA - - - {stat.total_cups_against} - - - - - - + + @@ -160,6 +142,37 @@ const PlayerStatsTable = () => { direction: "desc", }); + const viewportsRef = useRef>(new Set()); + const isScrollingRef = useRef(false); + + const handleRegisterViewport = useCallback((viewport: HTMLDivElement) => { + viewportsRef.current.add(viewport); + + const handleScroll = (e: Event) => { + if (isScrollingRef.current) return; + + isScrollingRef.current = true; + const scrollLeft = (e.target as HTMLDivElement).scrollLeft; + + viewportsRef.current.forEach((vp) => { + if (vp !== e.target) { + vp.scrollLeft = scrollLeft; + } + }); + + requestAnimationFrame(() => { + isScrollingRef.current = false; + }); + }; + + viewport.addEventListener('scroll', handleScroll); + viewport.dataset.scrollHandler = 'attached'; + }, []); + + const handleUnregisterViewport = useCallback((viewport: HTMLDivElement) => { + viewportsRef.current.delete(viewport); + }, []); + const calculateMMR = (stat: PlayerStats): number => { if (stat.matches === 0) return 0; @@ -259,6 +272,9 @@ const PlayerStatsTable = () => { return ( + + Showing {filteredAndSortedStats.length} of {playerStats.length} players + { /> - - {filteredAndSortedStats.length} of {playerStats.length} players - - - Sort: + +
+ Sort: handleSort("mmr")} style={{ display: "flex", alignItems: "center", gap: 4 }} @@ -335,9 +349,15 @@ const PlayerStatsTable = () => { AWM: Average Win Margin + + • ALM: Average Loss Margin + AC: Average Cups Per Match + + • ACA: Average Cups Against + CF: Cups For @@ -381,6 +401,8 @@ const PlayerStatsTable = () => { stat={stat} onPlayerClick={handlePlayerClick} mmr={stat.mmr} + onRegisterViewport={handleRegisterViewport} + onUnregisterViewport={handleUnregisterViewport} /> {index < filteredAndSortedStats.length - 1 && }
From 127709bb6cab023dbc5f879d612560d400528944 Mon Sep 17 00:00:00 2001 From: yohlo Date: Sat, 11 Oct 2025 00:33:27 -0500 Subject: [PATCH 06/18] more imporvmentes --- src/features/teams/components/team-list.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/features/teams/components/team-list.tsx b/src/features/teams/components/team-list.tsx index e119301..0812a84 100644 --- a/src/features/teams/components/team-list.tsx +++ b/src/features/teams/components/team-list.tsx @@ -22,12 +22,21 @@ const TeamListItem = React.memo(({ team }: TeamListItemProps) => { [team.players] ); + const teamNameSize = useMemo(() => { + const nameLength = team.name.length; + if (nameLength > 20) return 'xs'; + if (nameLength > 15) return 'sm'; + return 'md'; + }, [team.name]); + return ( - - {`${team.name}`} - - {playerNames.map((name) => ( - + + + {`${team.name}`} + + + {playerNames.map((name, idx) => ( + {name} ))} From 26c6343a8908b99d20b11f56b2cf8d9849357149 Mon Sep 17 00:00:00 2001 From: yohlo Date: Sat, 11 Oct 2025 00:34:58 -0500 Subject: [PATCH 07/18] no devtools --- src/features/core/components/providers.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/features/core/components/providers.tsx b/src/features/core/components/providers.tsx index fe79eff..b2e85c8 100644 --- a/src/features/core/components/providers.tsx +++ b/src/features/core/components/providers.tsx @@ -1,9 +1,9 @@ import { AuthProvider } from "@/contexts/auth-context" import { SpotifyProvider } from "@/contexts/spotify-context" import MantineProvider from "@/lib/mantine/mantine-provider" -import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools' -import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools' -import { TanStackDevtools } from '@tanstack/react-devtools' +//import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools' +//import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools' +//import { TanStackDevtools } from '@tanstack/react-devtools' import { Toaster } from "sonner" const Providers = ({ children }: { children: React.ReactNode }) => { @@ -11,7 +11,7 @@ const Providers = ({ children }: { children: React.ReactNode }) => { - { render: , } ]} - /> + />*/} {children} From f74d2daf9cda8474f94611ebda1215d1700a1e56 Mon Sep 17 00:00:00 2001 From: yohlo Date: Sat, 11 Oct 2025 00:40:18 -0500 Subject: [PATCH 08/18] try something different for stats scroll --- .../players/components/player-stats-table.tsx | 39 +++++++++++++------ 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/src/features/players/components/player-stats-table.tsx b/src/features/players/components/player-stats-table.tsx index 8f47063..cebcd6c 100644 --- a/src/features/players/components/player-stats-table.tsx +++ b/src/features/players/components/player-stats-table.tsx @@ -112,6 +112,12 @@ const PlayerListItem = memo(({ stat, onPlayerClick, mmr, onRegisterViewport, onU @@ -120,7 +126,7 @@ const PlayerListItem = memo(({ stat, onPlayerClick, mmr, onRegisterViewport, onU - + @@ -143,34 +149,45 @@ const PlayerStatsTable = () => { }); const viewportsRef = useRef>(new Set()); - const isScrollingRef = useRef(false); + const scrollHandlersRef = useRef void>>(new Map()); + const isSyncingRef = useRef(false); const handleRegisterViewport = useCallback((viewport: HTMLDivElement) => { viewportsRef.current.add(viewport); const handleScroll = (e: Event) => { - if (isScrollingRef.current) return; + const target = e.target as HTMLDivElement; - isScrollingRef.current = true; - const scrollLeft = (e.target as HTMLDivElement).scrollLeft; + // Prevent infinite loops + if (isSyncingRef.current) { + return; + } + isSyncingRef.current = true; + const scrollLeft = target.scrollLeft; + + // Synchronize all other viewports immediately viewportsRef.current.forEach((vp) => { - if (vp !== e.target) { + if (vp !== target) { vp.scrollLeft = scrollLeft; } }); - requestAnimationFrame(() => { - isScrollingRef.current = false; - }); + isSyncingRef.current = false; }; - viewport.addEventListener('scroll', handleScroll); - viewport.dataset.scrollHandler = 'attached'; + viewport.addEventListener('scroll', handleScroll, { passive: true }); + scrollHandlersRef.current.set(viewport, handleScroll); }, []); const handleUnregisterViewport = useCallback((viewport: HTMLDivElement) => { viewportsRef.current.delete(viewport); + + const handler = scrollHandlersRef.current.get(viewport); + if (handler) { + viewport.removeEventListener('scroll', handler); + scrollHandlersRef.current.delete(viewport); + } }, []); const calculateMMR = (stat: PlayerStats): number => { From 46943b6971c1c92a3061aa8603d85c9884c24c25 Mon Sep 17 00:00:00 2001 From: yohlo Date: Sat, 11 Oct 2025 00:43:28 -0500 Subject: [PATCH 09/18] try something different for stats scroll --- .../players/components/player-stats-table.tsx | 34 +++++++++++++++---- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/src/features/players/components/player-stats-table.tsx b/src/features/players/components/player-stats-table.tsx index cebcd6c..6d07136 100644 --- a/src/features/players/components/player-stats-table.tsx +++ b/src/features/players/components/player-stats-table.tsx @@ -116,6 +116,9 @@ const PlayerListItem = memo(({ stat, onPlayerClick, mmr, onRegisterViewport, onU viewport: { WebkitOverflowScrolling: 'touch', scrollBehavior: 'auto', + willChange: 'scroll-position', + transform: 'translateZ(0)', + backfaceVisibility: 'hidden', }, }} > @@ -150,33 +153,48 @@ const PlayerStatsTable = () => { const viewportsRef = useRef>(new Set()); const scrollHandlersRef = useRef void>>(new Map()); - const isSyncingRef = useRef(false); + const scrollLeaderRef = useRef(null); + const scrollTimeoutRef = useRef(null); const handleRegisterViewport = useCallback((viewport: HTMLDivElement) => { viewportsRef.current.add(viewport); + const handleScrollStart = () => { + scrollLeaderRef.current = viewport; + }; + const handleScroll = (e: Event) => { const target = e.target as HTMLDivElement; - // Prevent infinite loops - if (isSyncingRef.current) { + if (scrollLeaderRef.current !== target) { return; } - isSyncingRef.current = true; const scrollLeft = target.scrollLeft; - // Synchronize all other viewports immediately viewportsRef.current.forEach((vp) => { if (vp !== target) { - vp.scrollLeft = scrollLeft; + if (Math.abs(vp.scrollLeft - scrollLeft) > 0.5) { + const wasLeader = scrollLeaderRef.current; + scrollLeaderRef.current = null; + vp.scrollLeft = scrollLeft; + scrollLeaderRef.current = wasLeader; + } } }); - isSyncingRef.current = false; + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current); + } + scrollTimeoutRef.current = window.setTimeout(() => { + scrollLeaderRef.current = null; + }, 150); }; + viewport.addEventListener('touchstart', handleScrollStart, { passive: true }); + viewport.addEventListener('mousedown', handleScrollStart, { passive: true }); viewport.addEventListener('scroll', handleScroll, { passive: true }); + scrollHandlersRef.current.set(viewport, handleScroll); }, []); @@ -186,6 +204,8 @@ const PlayerStatsTable = () => { const handler = scrollHandlersRef.current.get(viewport); if (handler) { viewport.removeEventListener('scroll', handler); + viewport.removeEventListener('touchstart', handler); + viewport.removeEventListener('mousedown', handler); scrollHandlersRef.current.delete(viewport); } }, []); From 14c2eb2c027a8dc505d91e58901591671a95910c Mon Sep 17 00:00:00 2001 From: yohlo Date: Sat, 11 Oct 2025 00:45:05 -0500 Subject: [PATCH 10/18] try something different for stats scroll --- .../players/components/player-stats-table.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/features/players/components/player-stats-table.tsx b/src/features/players/components/player-stats-table.tsx index 6d07136..85fde11 100644 --- a/src/features/players/components/player-stats-table.tsx +++ b/src/features/players/components/player-stats-table.tsx @@ -166,6 +166,10 @@ const PlayerStatsTable = () => { const handleScroll = (e: Event) => { const target = e.target as HTMLDivElement; + if (!scrollLeaderRef.current) { + scrollLeaderRef.current = target; + } + if (scrollLeaderRef.current !== target) { return; } @@ -173,13 +177,8 @@ const PlayerStatsTable = () => { const scrollLeft = target.scrollLeft; viewportsRef.current.forEach((vp) => { - if (vp !== target) { - if (Math.abs(vp.scrollLeft - scrollLeft) > 0.5) { - const wasLeader = scrollLeaderRef.current; - scrollLeaderRef.current = null; - vp.scrollLeft = scrollLeft; - scrollLeaderRef.current = wasLeader; - } + if (vp !== target && Math.abs(vp.scrollLeft - scrollLeft) > 0.5) { + vp.scrollLeft = scrollLeft; } }); From 43972b6a06d1460798970e20cd6c7513a6a0f104 Mon Sep 17 00:00:00 2001 From: yohlo Date: Sat, 11 Oct 2025 13:40:12 -0500 Subject: [PATCH 11/18] match h2h --- .../matches/components/match-card.tsx | 97 +++++++-- .../matches/components/match-list.tsx | 5 +- .../components/team-head-to-head-sheet.tsx | 195 ++++++++++++++++++ src/features/matches/queries.ts | 24 +++ src/features/matches/server.ts | 32 +++ src/lib/pocketbase/services/matches.ts | 53 +++++ 6 files changed, 383 insertions(+), 23 deletions(-) create mode 100644 src/features/matches/components/team-head-to-head-sheet.tsx create mode 100644 src/features/matches/queries.ts 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)); + }, }; } From d3379e54a4c5aa11eb7a9ef41a0461f84c725d13 Mon Sep 17 00:00:00 2001 From: yohlo Date: Sat, 11 Oct 2025 14:47:03 -0500 Subject: [PATCH 12/18] player h2h --- src/app/routes/_authed/stats.tsx | 26 +- src/components/avatar.tsx | 2 +- src/components/swipeable-tabs.tsx | 4 +- .../matches/components/match-card.tsx | 2 +- .../components/team-head-to-head-sheet.tsx | 35 ++- src/features/matches/queries.ts | 14 +- .../components/league-head-to-head.tsx | 276 ++++++++++++++++++ .../components/player-head-to-head-sheet.tsx | 270 +++++++++++++++++ .../players/components/player-stats-table.tsx | 2 +- .../players/components/profile/header.tsx | 75 ++++- .../tanstack-query/hooks/use-server-query.ts | 1 + 11 files changed, 671 insertions(+), 36 deletions(-) create mode 100644 src/features/players/components/league-head-to-head.tsx create mode 100644 src/features/players/components/player-head-to-head-sheet.tsx diff --git a/src/app/routes/_authed/stats.tsx b/src/app/routes/_authed/stats.tsx index 83c1991..0c71942 100644 --- a/src/app/routes/_authed/stats.tsx +++ b/src/app/routes/_authed/stats.tsx @@ -4,6 +4,9 @@ import PlayerStatsTable from "@/features/players/components/player-stats-table"; import { Suspense } from "react"; import PlayerStatsTableSkeleton from "@/features/players/components/player-stats-table-skeleton"; import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch"; +import SwipeableTabs from "@/components/swipeable-tabs"; +import LeagueHeadToHead from "@/features/players/components/league-head-to-head"; +import { Box } from "@mantine/core"; export const Route = createFileRoute("/_authed/stats")({ component: Stats, @@ -17,12 +20,27 @@ export const Route = createFileRoute("/_authed/stats")({ header: { title: "Player Stats" }, - refresh: [playerQueries.allStats().queryKey], + refresh: [playerQueries.allStats().queryKey], }), }); function Stats() { - return }> - - ; + const tabs = [ + { + label: "Stats", + content: ( + }> + + + ), + }, + { + label: "Head to Head", + content: , + }, + ]; + + return + + ; } diff --git a/src/components/avatar.tsx b/src/components/avatar.tsx index cc87230..67dcafc 100644 --- a/src/components/avatar.tsx +++ b/src/components/avatar.tsx @@ -13,7 +13,7 @@ import { XIcon } from "@phosphor-icons/react"; interface AvatarProps extends Omit { - name: string; + name?: string; size?: number; radius?: string | number; withBorder?: boolean; diff --git a/src/components/swipeable-tabs.tsx b/src/components/swipeable-tabs.tsx index c2e3a3d..fa565e1 100644 --- a/src/components/swipeable-tabs.tsx +++ b/src/components/swipeable-tabs.tsx @@ -18,6 +18,7 @@ interface TabItem { interface SwipeableTabsProps { tabs: TabItem[]; defaultTab?: number; + mb?: string | number; onTabChange?: (index: number, tab: TabItem) => void; } @@ -25,6 +26,7 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange, + mb, }: SwipeableTabsProps) { const router = useRouter(); const search = router.state.location.search as any; @@ -144,7 +146,7 @@ function SwipeableTabs({ style={{ display: "flex", paddingInline: "var(--mantine-spacing-md)", - marginBottom: "var(--mantine-spacing-md)", + marginBottom: mb !== undefined ? mb : "var(--mantine-spacing-md)", zIndex: 100, backgroundColor: "var(--mantine-color-body)", }} diff --git a/src/features/matches/components/match-card.tsx b/src/features/matches/components/match-card.tsx index 452a471..e270149 100644 --- a/src/features/matches/components/match-card.tsx +++ b/src/features/matches/components/match-card.tsx @@ -257,7 +257,7 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => { title="Head to Head" {...h2hSheet.props} > - + )} diff --git a/src/features/matches/components/team-head-to-head-sheet.tsx b/src/features/matches/components/team-head-to-head-sheet.tsx index 2e95da4..93f8cb1 100644 --- a/src/features/matches/components/team-head-to-head-sheet.tsx +++ b/src/features/matches/components/team-head-to-head-sheet.tsx @@ -1,17 +1,26 @@ 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 { useMemo, useEffect, useState } from "react"; import { CrownIcon, TrophyIcon } from "@phosphor-icons/react"; import MatchList from "./match-list"; interface TeamHeadToHeadSheetProps { team1: TeamInfo; team2: TeamInfo; + isOpen?: boolean; } -const TeamHeadToHeadSheet = ({ team1, team2 }: TeamHeadToHeadSheetProps) => { - const { data: matches, isLoading } = useTeamHeadToHead(team1.id, team2.id); +const TeamHeadToHeadSheet = ({ team1, team2, isOpen = true }: TeamHeadToHeadSheetProps) => { + const [shouldFetch, setShouldFetch] = useState(false); + + useEffect(() => { + if (isOpen && !shouldFetch) { + setShouldFetch(true); + } + }, [isOpen, shouldFetch]); + + const { data: matches, isLoading } = useTeamHeadToHead(team1.id, team2.id, shouldFetch); const stats = useMemo(() => { if (!matches || matches.length === 0) { @@ -33,6 +42,8 @@ const TeamHeadToHeadSheet = ({ team1, team2 }: TeamHeadToHeadSheetProps) => { let team2CupsFor = 0; let team1CupsAgainst = 0; let team2CupsAgainst = 0; + let team1TotalWinMargin = 0; + let team2TotalWinMargin = 0; matches.forEach((match) => { const isTeam1Home = match.home?.id === team1.id; @@ -41,8 +52,10 @@ const TeamHeadToHeadSheet = ({ team1, team2 }: TeamHeadToHeadSheetProps) => { if (team1Cups > team2Cups) { team1Wins++; + team1TotalWinMargin += (team1Cups - team2Cups); } else if (team2Cups > team1Cups) { team2Wins++; + team2TotalWinMargin += (team2Cups - team1Cups); } team1CupsFor += team1Cups; @@ -52,10 +65,10 @@ const TeamHeadToHeadSheet = ({ team1, team2 }: TeamHeadToHeadSheetProps) => { }); const team1AvgMargin = team1Wins > 0 - ? (team1CupsFor - team1CupsAgainst) / team1Wins + ? team1TotalWinMargin / team1Wins : 0; const team2AvgMargin = team2Wins > 0 - ? (team2CupsFor - team2CupsAgainst) / team2Wins + ? team2TotalWinMargin / team2Wins : 0; return { @@ -88,7 +101,7 @@ const TeamHeadToHeadSheet = ({ team1, team2 }: TeamHeadToHeadSheetProps) => { ); } - const totalGames = stats.team1Wins + stats.team2Wins; + const totalMatches = stats.team1Wins + stats.team2Wins; const leader = stats.team1Wins > stats.team2Wins ? team1 : stats.team2Wins > stats.team1Wins ? team2 : null; return ( @@ -122,7 +135,7 @@ const TeamHeadToHeadSheet = ({ team1, team2 }: TeamHeadToHeadSheetProps) => {
)} - {!leader && totalGames > 0 && ( + {!leader && totalMatches > 0 && ( Series is tied @@ -151,15 +164,15 @@ const TeamHeadToHeadSheet = ({ team1, team2 }: TeamHeadToHeadSheetProps) => { - {totalGames > 0 ? (stats.team1CupsFor / totalGames).toFixed(1) : '0.0'} + {totalMatches > 0 ? (stats.team1CupsFor / totalMatches).toFixed(1) : '0.0'} avg - Avg Cups/Game + Avg Cups/Match avg - {totalGames > 0 ? (stats.team2CupsFor / totalGames).toFixed(1) : '0.0'} + {totalMatches > 0 ? (stats.team2CupsFor / totalMatches).toFixed(1) : '0.0'} @@ -185,7 +198,7 @@ const TeamHeadToHeadSheet = ({ team1, team2 }: TeamHeadToHeadSheetProps) => {
- Match History ({totalGames} games) + Match History ({totalMatches} match{totalMatches !== 1 ? 'es' : ''})
diff --git a/src/features/matches/queries.ts b/src/features/matches/queries.ts index cca3f33..531524d 100644 --- a/src/features/matches/queries.ts +++ b/src/features/matches/queries.ts @@ -17,8 +17,14 @@ export const matchQueries = { }), }; -export const useTeamHeadToHead = (team1Id: string, team2Id: string) => - useServerQuery(matchQueries.headToHeadTeams(team1Id, team2Id)); +export const useTeamHeadToHead = (team1Id: string, team2Id: string, enabled = true) => + useServerQuery({ + ...matchQueries.headToHeadTeams(team1Id, team2Id), + enabled, + }); -export const usePlayerHeadToHead = (player1Id: string, player2Id: string) => - useServerQuery(matchQueries.headToHeadPlayers(player1Id, player2Id)); +export const usePlayerHeadToHead = (player1Id: string, player2Id: string, enabled = true) => + useServerQuery({ + ...matchQueries.headToHeadPlayers(player1Id, player2Id), + enabled, + }); diff --git a/src/features/players/components/league-head-to-head.tsx b/src/features/players/components/league-head-to-head.tsx new file mode 100644 index 0000000..eb2e8bd --- /dev/null +++ b/src/features/players/components/league-head-to-head.tsx @@ -0,0 +1,276 @@ +import { Stack, Text, TextInput, Box, Paper, Group, Divider, Center, ActionIcon, Badge } from "@mantine/core"; +import { useState, useMemo } from "react"; +import { MagnifyingGlassIcon, XIcon, ArrowRightIcon } from "@phosphor-icons/react"; +import { useAllPlayerStats } from "../queries"; +import { useSheet } from "@/hooks/use-sheet"; +import Sheet from "@/components/sheet/sheet"; +import PlayerHeadToHeadSheet from "./player-head-to-head-sheet"; +import Avatar from "@/components/avatar"; + +const LeagueHeadToHead = () => { + const [player1Id, setPlayer1Id] = useState(null); + const [player2Id, setPlayer2Id] = useState(null); + const [search, setSearch] = useState(""); + const { data: allPlayerStats } = useAllPlayerStats(); + const h2hSheet = useSheet(); + + const player1Name = useMemo(() => { + if (!player1Id || !allPlayerStats) return ""; + return allPlayerStats.find((p) => p.player_id === player1Id)?.player_name || ""; + }, [player1Id, allPlayerStats]); + + const player2Name = useMemo(() => { + if (!player2Id || !allPlayerStats) return ""; + return allPlayerStats.find((p) => p.player_id === player2Id)?.player_name || ""; + }, [player2Id, allPlayerStats]); + + const filteredPlayers = useMemo(() => { + if (!allPlayerStats) return []; + + return allPlayerStats + .filter((stat) => { + if (player1Id && stat.player_id === player1Id) return false; + if (player2Id && stat.player_id === player2Id) return false; + return true; + }) + .filter((stat) => + stat.player_name.toLowerCase().includes(search.toLowerCase()) + ) + .sort((a, b) => b.matches - a.matches); + }, [allPlayerStats, player1Id, player2Id, search]); + + const handlePlayerClick = (playerId: string) => { + if (!player1Id) { + setPlayer1Id(playerId); + } else if (!player2Id) { + setPlayer2Id(playerId); + h2hSheet.open(); + } + }; + + const handleClearPlayer1 = () => { + setPlayer1Id(null); + if (player2Id) { + setPlayer1Id(player2Id); + setPlayer2Id(null); + } + }; + + const handleClearPlayer2 = () => { + setPlayer2Id(null); + }; + + const activeStep = !player1Id ? 1 : !player2Id ? 2 : 0; + + return ( + <> + + + + + + {player1Id ? ( + <> + + + + {player1Name} + + + { + e.stopPropagation(); + handleClearPlayer1(); + }} + style={{ position: "absolute", top: 4, right: 4 }} + > + + + + ) : ( + + + + Player 1 + + + )} + + +
+ + VS + +
+ + + {player2Id ? ( + <> + + + + {player2Name} + + + { + e.stopPropagation(); + handleClearPlayer2(); + }} + style={{ position: "absolute", top: 4, right: 4 }} + > + + + + ) : ( + + + + Player 2 + + + )} + +
+ + {activeStep > 0 ? ( + + {activeStep === 1 && "Step 1: Select first player"} + {activeStep === 2 && "Step 2: Select second player"} + + ) : ( + + { + setPlayer1Id(null); + setPlayer2Id(null); + }} + td="underline" + > + Clear both players + + + )} +
+
+ + setSearch(e.currentTarget.value)} + leftSection={} + size="md" + px="md" + /> + + + + {filteredPlayers.length === 0 && ( + + {search ? `No players found matching "${search}"` : "No players available"} + + )} + + {filteredPlayers.map((player, index) => ( + + handlePlayerClick(player.player_id)} + styles={{ + root: { + "&:hover": { + backgroundColor: "var(--mantine-color-default-hover)", + }, + }, + }} + > + + + + {player.player_name} + + + + + + + {index < filteredPlayers.length - 1 && } + + ))} + + +
+ + {player1Id && player2Id && ( + + + + )} + + ); +}; + +export default LeagueHeadToHead; diff --git a/src/features/players/components/player-head-to-head-sheet.tsx b/src/features/players/components/player-head-to-head-sheet.tsx new file mode 100644 index 0000000..7e0dbb0 --- /dev/null +++ b/src/features/players/components/player-head-to-head-sheet.tsx @@ -0,0 +1,270 @@ +import { Stack, Text, Group, Box, Divider, Paper } from "@mantine/core"; +import { usePlayerHeadToHead } from "@/features/matches/queries"; +import { useMemo, useEffect, useState } from "react"; +import { CrownIcon } from "@phosphor-icons/react"; +import MatchList from "@/features/matches/components/match-list"; + +interface PlayerHeadToHeadSheetProps { + player1Id: string; + player1Name: string; + player2Id: string; + player2Name: string; + isOpen?: boolean; +} + +const PlayerHeadToHeadSheet = ({ + player1Id, + player1Name, + player2Id, + player2Name, + isOpen = true, +}: PlayerHeadToHeadSheetProps) => { + const [shouldFetch, setShouldFetch] = useState(false); + + useEffect(() => { + if (isOpen && !shouldFetch) { + setShouldFetch(true); + } + }, [isOpen, shouldFetch]); + + const { data: matches, isLoading } = usePlayerHeadToHead(player1Id, player2Id, shouldFetch); + + const stats = useMemo(() => { + if (!matches || matches.length === 0) { + return { + player1Wins: 0, + player2Wins: 0, + player1CupsFor: 0, + player2CupsFor: 0, + player1CupsAgainst: 0, + player2CupsAgainst: 0, + player1AvgMargin: 0, + player2AvgMargin: 0, + }; + } + + let player1Wins = 0; + let player2Wins = 0; + let player1CupsFor = 0; + let player2CupsFor = 0; + let player1CupsAgainst = 0; + let player2CupsAgainst = 0; + let player1TotalWinMargin = 0; + let player2TotalWinMargin = 0; + + matches.forEach((match) => { + const isPlayer1Home = match.home?.players?.some((p) => p.id === player1Id); + const player1Cups = isPlayer1Home ? match.home_cups : match.away_cups; + const player2Cups = isPlayer1Home ? match.away_cups : match.home_cups; + + if (player1Cups > player2Cups) { + player1Wins++; + player1TotalWinMargin += (player1Cups - player2Cups); + } else if (player2Cups > player1Cups) { + player2Wins++; + player2TotalWinMargin += (player2Cups - player1Cups); + } + + player1CupsFor += player1Cups; + player2CupsFor += player2Cups; + player1CupsAgainst += player2Cups; + player2CupsAgainst += player1Cups; + }); + + const player1AvgMargin = + player1Wins > 0 ? player1TotalWinMargin / player1Wins : 0; + const player2AvgMargin = + player2Wins > 0 ? player2TotalWinMargin / player2Wins : 0; + + return { + player1Wins, + player2Wins, + player1CupsFor, + player2CupsFor, + player1CupsAgainst, + player2CupsAgainst, + player1AvgMargin, + player2AvgMargin, + }; + }, [matches, player1Id]); + + if (isLoading) { + return ( + + + Loading... + + + ); + } + + if (!matches || matches.length === 0) { + return ( + + + These players have not faced each other yet. + + + ); + } + + const totalGames = stats.player1Wins + stats.player2Wins; + const leader = + stats.player1Wins > stats.player2Wins + ? player1Name + : stats.player2Wins > stats.player1Wins + ? player2Name + : null; + + return ( + + + + + + {player1Name} + + + vs + + + {player2Name} + + + + + + + {stats.player1Wins} + + + {player1Name} + + + + - + + + + {stats.player2Wins} + + + {player2Name} + + + + + {leader && ( + + + + {leader} leads the series + + + )} + + {!leader && totalGames > 0 && ( + + Series is tied + + )} + + + + + + Stats Comparison + + + + + + + + {stats.player1CupsFor} + + + cups + + + + Total Cups + + + + cups + + + {stats.player2CupsFor} + + + + + + + + + {totalGames > 0 + ? (stats.player1CupsFor / totalGames).toFixed(1) + : "0.0"} + + + avg + + + + Avg Cups/Game + + + + avg + + + {totalGames > 0 + ? (stats.player2CupsFor / totalGames).toFixed(1) + : "0.0"} + + + + + + + + + {!isNaN(stats.player1AvgMargin) + ? stats.player1AvgMargin.toFixed(1) + : "0.0"} + + + margin + + + + Avg Win Margin + + + + margin + + + {!isNaN(stats.player2AvgMargin) + ? stats.player2AvgMargin.toFixed(1) + : "0.0"} + + + + + + + + + + Match History ({totalGames} games) + + + + + ); +}; + +export default PlayerHeadToHeadSheet; diff --git a/src/features/players/components/player-stats-table.tsx b/src/features/players/components/player-stats-table.tsx index 85fde11..e602c08 100644 --- a/src/features/players/components/player-stats-table.tsx +++ b/src/features/players/components/player-stats-table.tsx @@ -306,7 +306,7 @@ const PlayerStatsTable = () => { } return ( - + Showing {filteredAndSortedStats.length} of {playerStats.length} players diff --git a/src/features/players/components/profile/header.tsx b/src/features/players/components/profile/header.tsx index 4b5076f..ab36c20 100644 --- a/src/features/players/components/profile/header.tsx +++ b/src/features/players/components/profile/header.tsx @@ -1,23 +1,29 @@ import Sheet from "@/components/sheet/sheet"; import { useAuth } from "@/contexts/auth-context"; -import { Flex, Title, ActionIcon } from "@mantine/core"; -import { PencilIcon } from "@phosphor-icons/react"; +import { Flex, Title, ActionIcon, Stack, Button, Box } from "@mantine/core"; +import { PencilIcon, FootballHelmetIcon } from "@phosphor-icons/react"; import { useMemo } from "react"; import NameUpdateForm from "./name-form"; import Avatar from "@/components/avatar"; import { useSheet } from "@/hooks/use-sheet"; import { Player } from "../../types"; +import PlayerHeadToHeadSheet from "../player-head-to-head-sheet"; interface HeaderProps { player: Player; } const Header = ({ player }: HeaderProps) => { - const sheet = useSheet(); + const nameSheet = useSheet(); + const h2hSheet = useSheet(); const { user: authUser } = useAuth(); const owner = useMemo(() => authUser?.id === player.id, [authUser?.id, player.id]); const name = useMemo(() => `${player.first_name} ${player.last_name}`, [player.first_name, player.last_name]); + const authUserName = useMemo(() => { + if (!authUser) return ""; + return `${authUser.first_name} ${authUser.last_name}`; + }, [authUser]); const fontSize = useMemo(() => { const baseSize = 28; @@ -33,19 +39,62 @@ const Header = ({ player }: HeaderProps) => { return ( <> - - - - {name} - - - + + + + + {name} + + + + + + + + + + - + - - + + + + {!owner && authUser && ( + + + + )} ) }; diff --git a/src/lib/tanstack-query/hooks/use-server-query.ts b/src/lib/tanstack-query/hooks/use-server-query.ts index bcf9a43..fe9deb8 100644 --- a/src/lib/tanstack-query/hooks/use-server-query.ts +++ b/src/lib/tanstack-query/hooks/use-server-query.ts @@ -8,6 +8,7 @@ export function useServerQuery( queryFn: () => Promise>; options?: Omit, 'queryFn' | 'queryKey'> showErrorToast?: boolean; + enabled?: boolean; } ) { const { queryKey, queryFn, showErrorToast = true, options: queryOptions } = options; From 2ed5ab602664631068743c4b1255bf47edf673d4 Mon Sep 17 00:00:00 2001 From: yohlo Date: Sat, 11 Oct 2025 15:23:33 -0500 Subject: [PATCH 13/18] fix stats table --- src/components/swipeable-tabs.tsx | 4 +++- src/features/players/components/player-stats-table.tsx | 9 ++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/components/swipeable-tabs.tsx b/src/components/swipeable-tabs.tsx index fa565e1..08428d9 100644 --- a/src/components/swipeable-tabs.tsx +++ b/src/components/swipeable-tabs.tsx @@ -207,11 +207,13 @@ function SwipeableTabs({ height: carouselHeight === "auto" ? "auto" : `${carouselHeight}px`, transition: "height 300ms ease", touchAction: "pan-y", + width: "100%", + maxWidth: "100vw", }} > {tabs.map((tab, index) => ( - + {tab.content} diff --git a/src/features/players/components/player-stats-table.tsx b/src/features/players/components/player-stats-table.tsx index e602c08..0f8d861 100644 --- a/src/features/players/components/player-stats-table.tsx +++ b/src/features/players/components/player-stats-table.tsx @@ -6,7 +6,6 @@ import { Group, Box, ThemeIcon, - Container, Title, Divider, UnstyledButton, @@ -292,7 +291,7 @@ const PlayerStatsTable = () => { if (playerStats.length === 0) { return ( - + @@ -301,12 +300,12 @@ const PlayerStatsTable = () => { No Stats Available - +
); } return ( - + Showing {filteredAndSortedStats.length} of {playerStats.length} players @@ -451,7 +450,7 @@ const PlayerStatsTable = () => { )} - + ); }; From b59c7cd7b6898ab99556fbda3e089eeaa8b03753 Mon Sep 17 00:00:00 2001 From: yohlo Date: Sat, 11 Oct 2025 15:34:12 -0500 Subject: [PATCH 14/18] fix tabs on stats table --- src/app/routes/_authed/stats.tsx | 31 +++++++++---------- src/components/stats-overview.tsx | 4 +-- .../components/player-head-to-head-sheet.tsx | 16 +++++----- .../players/components/player-stats-table.tsx | 9 +++--- 4 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/app/routes/_authed/stats.tsx b/src/app/routes/_authed/stats.tsx index 0c71942..03f1387 100644 --- a/src/app/routes/_authed/stats.tsx +++ b/src/app/routes/_authed/stats.tsx @@ -4,9 +4,8 @@ import PlayerStatsTable from "@/features/players/components/player-stats-table"; import { Suspense } from "react"; import PlayerStatsTableSkeleton from "@/features/players/components/player-stats-table-skeleton"; import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch"; -import SwipeableTabs from "@/components/swipeable-tabs"; import LeagueHeadToHead from "@/features/players/components/league-head-to-head"; -import { Box } from "@mantine/core"; +import { Tabs } from "@mantine/core"; export const Route = createFileRoute("/_authed/stats")({ component: Stats, @@ -25,22 +24,22 @@ export const Route = createFileRoute("/_authed/stats")({ }); function Stats() { - const tabs = [ - { - label: "Stats", - content: ( + return ( + + + Stats + Head to Head + + + }> - ), - }, - { - label: "Head to Head", - content: , - }, - ]; + - return - - ; + + + + + ); } diff --git a/src/components/stats-overview.tsx b/src/components/stats-overview.tsx index 60a3bbd..57f5bb3 100644 --- a/src/components/stats-overview.tsx +++ b/src/components/stats-overview.tsx @@ -101,7 +101,7 @@ const StatsOverview = ({ statsData, isLoading = false }: StatsOverviewProps) => { label: "Losses", value: overallStats.losses, Icon: XIcon }, { label: "Cups Made", value: overallStats.total_cups_made, Icon: FireIcon }, { label: "Cups Against", value: overallStats.total_cups_against, Icon: ShieldIcon }, - { label: "Avg Cups Per Game", value: avgCupsPerMatch >= 0 ? avgCupsPerMatch : null, Icon: ChartLineUpIcon }, + { label: "Avg Cups Per Match", value: avgCupsPerMatch >= 0 ? avgCupsPerMatch : null, Icon: ChartLineUpIcon }, { label: "Avg Cups Against", value: avgCupsAgainstPerMatch >= 0 ? avgCupsAgainstPerMatch : null, Icon: ShieldCheckIcon }, { label: "Avg Win Margin", value: avgMarginOfVictory >= 0 ? avgMarginOfVictory : null, Icon: ArrowUpIcon }, { label: "Avg Loss Margin", value: avgMarginOfLoss >= 0 ? avgMarginOfLoss : null, Icon: ArrowDownIcon }, @@ -133,7 +133,7 @@ export const StatsSkeleton = () => { { label: "Losses", Icon: XIcon }, { label: "Cups Made", Icon: FireIcon }, { label: "Cups Against", Icon: ShieldIcon }, - { label: "Avg Cups Per Game", Icon: ChartLineUpIcon }, + { label: "Avg Cups Per Match", Icon: ChartLineUpIcon }, { label: "Avg Cups Against", Icon: ShieldCheckIcon }, { label: "Avg Win Margin", Icon: ArrowUpIcon }, { label: "Avg Loss Margin", Icon: ArrowDownIcon }, diff --git a/src/features/players/components/player-head-to-head-sheet.tsx b/src/features/players/components/player-head-to-head-sheet.tsx index 7e0dbb0..5ddbe1f 100644 --- a/src/features/players/components/player-head-to-head-sheet.tsx +++ b/src/features/players/components/player-head-to-head-sheet.tsx @@ -108,7 +108,7 @@ const PlayerHeadToHeadSheet = ({ ); } - const totalGames = stats.player1Wins + stats.player2Wins; + const totalMatches = stats.player1Wins + stats.player2Wins; const leader = stats.player1Wins > stats.player2Wins ? player1Name @@ -163,7 +163,7 @@ const PlayerHeadToHeadSheet = ({
)} - {!leader && totalGames > 0 && ( + {!leader && totalMatches > 0 && ( Series is tied @@ -204,8 +204,8 @@ const PlayerHeadToHeadSheet = ({ - {totalGames > 0 - ? (stats.player1CupsFor / totalGames).toFixed(1) + {totalMatches > 0 + ? (stats.player1CupsFor / totalMatches).toFixed(1) : "0.0"} @@ -213,15 +213,15 @@ const PlayerHeadToHeadSheet = ({ - Avg Cups/Game + Avg Cups/Match avg - {totalGames > 0 - ? (stats.player2CupsFor / totalGames).toFixed(1) + {totalMatches > 0 + ? (stats.player2CupsFor / totalMatches).toFixed(1) : "0.0"} @@ -259,7 +259,7 @@ const PlayerHeadToHeadSheet = ({ - Match History ({totalGames} games) + Match History ({totalMatches}) diff --git a/src/features/players/components/player-stats-table.tsx b/src/features/players/components/player-stats-table.tsx index 0f8d861..e602c08 100644 --- a/src/features/players/components/player-stats-table.tsx +++ b/src/features/players/components/player-stats-table.tsx @@ -6,6 +6,7 @@ import { Group, Box, ThemeIcon, + Container, Title, Divider, UnstyledButton, @@ -291,7 +292,7 @@ const PlayerStatsTable = () => { if (playerStats.length === 0) { return ( - + @@ -300,12 +301,12 @@ const PlayerStatsTable = () => { No Stats Available - + ); } return ( - + Showing {filteredAndSortedStats.length} of {playerStats.length} players @@ -450,7 +451,7 @@ const PlayerStatsTable = () => { )} - + ); }; From 939d1cee9010aa4018355c1105a585faf86685aa Mon Sep 17 00:00:00 2001 From: yohlo Date: Sat, 11 Oct 2025 15:37:37 -0500 Subject: [PATCH 15/18] remove steps --- src/features/players/components/league-head-to-head.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/players/components/league-head-to-head.tsx b/src/features/players/components/league-head-to-head.tsx index eb2e8bd..f8b6b49 100644 --- a/src/features/players/components/league-head-to-head.tsx +++ b/src/features/players/components/league-head-to-head.tsx @@ -183,8 +183,8 @@ const LeagueHeadToHead = () => { fullWidth styles={{ label: { textTransform: "none" } }} > - {activeStep === 1 && "Step 1: Select first player"} - {activeStep === 2 && "Step 2: Select second player"} + {activeStep === 1 && "Select first player"} + {activeStep === 2 && "Select second player"} ) : ( From 168ef1b05dbdd4d217877ff0d49feb201b5ec571 Mon Sep 17 00:00:00 2001 From: yohlo Date: Mon, 13 Oct 2025 13:00:29 -0500 Subject: [PATCH 16/18] restore suspense boundary in stats --- src/app/routes/_authed/stats.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/app/routes/_authed/stats.tsx b/src/app/routes/_authed/stats.tsx index 03f1387..65f385b 100644 --- a/src/app/routes/_authed/stats.tsx +++ b/src/app/routes/_authed/stats.tsx @@ -5,7 +5,7 @@ import { Suspense } from "react"; import PlayerStatsTableSkeleton from "@/features/players/components/player-stats-table-skeleton"; import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch"; import LeagueHeadToHead from "@/features/players/components/league-head-to-head"; -import { Tabs } from "@mantine/core"; +import { Box, Loader, Tabs } from "@mantine/core"; export const Route = createFileRoute("/_authed/stats")({ component: Stats, @@ -38,7 +38,9 @@ function Stats() { - + }> + + ); From 612f1f28bfcd0635bbe189b94dbbad7e695911ae Mon Sep 17 00:00:00 2001 From: yohlo Date: Mon, 13 Oct 2025 14:18:54 -0500 Subject: [PATCH 17/18] skeleton for h2h --- src/components/sheet/drawer.tsx | 66 +++++++++-------- src/components/sheet/sheet.tsx | 28 +++++--- .../matches/components/match-card.tsx | 2 +- .../components/team-head-to-head-sheet.tsx | 15 +++- .../components/team-head-to-head-skeleton.tsx | 72 +++++++++++++++++++ src/features/matches/queries.ts | 6 +- .../components/player-head-to-head-sheet.tsx | 13 +++- .../player-head-to-head-skeleton.tsx | 72 +++++++++++++++++++ .../hooks/user-server-suspense-query.ts | 1 + 9 files changed, 225 insertions(+), 50 deletions(-) create mode 100644 src/features/matches/components/team-head-to-head-skeleton.tsx create mode 100644 src/features/players/components/player-head-to-head-skeleton.tsx diff --git a/src/components/sheet/drawer.tsx b/src/components/sheet/drawer.tsx index 9fa4d22..dabbbc8 100644 --- a/src/components/sheet/drawer.tsx +++ b/src/components/sheet/drawer.tsx @@ -1,5 +1,10 @@ -import { Box, Container, Flex, Loader, Title, useComputedColorScheme } from "@mantine/core"; -import { PropsWithChildren, Suspense, useEffect, useRef } from "react"; +import { + Box, + Container, + Title, + useComputedColorScheme, +} from "@mantine/core"; +import { PropsWithChildren, useEffect, useRef } from "react"; import { Drawer as VaulDrawer } from "vaul"; import styles from "./styles.module.css"; @@ -17,6 +22,11 @@ const Drawer: React.FC = ({ }) => { const colorScheme = useComputedColorScheme("light"); const contentRef = useRef(null); + const openedRef = useRef(opened); + + useEffect(() => { + openedRef.current = opened; + }, [opened]); useEffect(() => { const appElement = document.querySelector(".app") as HTMLElement; @@ -57,7 +67,7 @@ const Drawer: React.FC = ({ appElement.classList.remove("drawer-scaling"); themeColorMeta.content = currentColors.normal; }; - }, [opened, colorScheme]); + }, [opened]); useEffect(() => { if (!opened || !contentRef.current) return; @@ -69,46 +79,44 @@ const Drawer: React.FC = ({ if (visualViewport) { const availableHeight = visualViewport.height; - const maxDrawerHeight = Math.min(availableHeight * 0.75, window.innerHeight * 0.75); + const maxDrawerHeight = Math.min( + availableHeight * 0.75, + window.innerHeight * 0.75 + ); drawerContent.style.maxHeight = `${maxDrawerHeight}px`; } else { - drawerContent.style.maxHeight = '75vh'; + drawerContent.style.maxHeight = "75vh"; } } }; - const resizeObserver = new ResizeObserver(() => { - if (contentRef.current) { - const drawerContent = contentRef.current.closest('[data-vaul-drawer-wrapper]'); - if (drawerContent) { - (drawerContent as HTMLElement).style.height = 'auto'; - (drawerContent as HTMLElement).offsetHeight; - } - } - }); - updateDrawerHeight(); if (window.visualViewport) { - window.visualViewport.addEventListener('resize', updateDrawerHeight); + window.visualViewport.addEventListener("resize", updateDrawerHeight); } - resizeObserver.observe(contentRef.current); - return () => { - resizeObserver.disconnect(); if (window.visualViewport) { - window.visualViewport.removeEventListener('resize', updateDrawerHeight); + window.visualViewport.removeEventListener("resize", updateDrawerHeight); } }; - }, [opened, children]); + }, [opened]); return ( - + - + = ({ style={{ borderRadius: "9999px" }} /> - {title} - - - - }> - {children} - + + {title} + + {children} diff --git a/src/components/sheet/sheet.tsx b/src/components/sheet/sheet.tsx index 9d2e178..522900c 100644 --- a/src/components/sheet/sheet.tsx +++ b/src/components/sheet/sheet.tsx @@ -1,8 +1,8 @@ -import { PropsWithChildren, useCallback } from "react"; +import { PropsWithChildren, Suspense, useCallback } from "react"; import { useIsMobile } from "@/hooks/use-is-mobile"; import Drawer from "./drawer"; import Modal from "./modal"; -import { ScrollArea } from "@mantine/core"; +import { ScrollArea, Flex, Loader } from "@mantine/core"; interface SheetProps extends PropsWithChildren { title?: string; @@ -16,6 +16,8 @@ const Sheet: React.FC = ({ title, children, opened, onChange }) => { const SheetComponent = isMobile ? Drawer : Modal; + if (!opened) return null; + return ( = ({ title, children, opened, onChange }) => { onChange={onChange} onClose={handleClose} > - - {children} - + + + + }> + + {children} + + ); }; diff --git a/src/features/matches/components/match-card.tsx b/src/features/matches/components/match-card.tsx index e270149..eb41d1a 100644 --- a/src/features/matches/components/match-card.tsx +++ b/src/features/matches/components/match-card.tsx @@ -252,7 +252,7 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => { - {match.home && match.away && ( + {match.home && match.away && !hideH2H && h2hSheet.isOpen && ( { +const TeamHeadToHeadContent = ({ team1, team2, isOpen = true }: TeamHeadToHeadSheetProps) => { const [shouldFetch, setShouldFetch] = useState(false); useEffect(() => { @@ -205,4 +206,12 @@ const TeamHeadToHeadSheet = ({ team1, team2, isOpen = true }: TeamHeadToHeadShee ); }; +const TeamHeadToHeadSheet = (props: TeamHeadToHeadSheetProps) => { + return ( + }> + + + ); +}; + export default TeamHeadToHeadSheet; diff --git a/src/features/matches/components/team-head-to-head-skeleton.tsx b/src/features/matches/components/team-head-to-head-skeleton.tsx new file mode 100644 index 0000000..52b5223 --- /dev/null +++ b/src/features/matches/components/team-head-to-head-skeleton.tsx @@ -0,0 +1,72 @@ +import { Stack, Skeleton, Group, Paper, Divider } from "@mantine/core"; + +const TeamHeadToHeadSkeleton = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default TeamHeadToHeadSkeleton; diff --git a/src/features/matches/queries.ts b/src/features/matches/queries.ts index 531524d..81f5e0c 100644 --- a/src/features/matches/queries.ts +++ b/src/features/matches/queries.ts @@ -1,4 +1,4 @@ -import { useServerQuery } from "@/lib/tanstack-query/hooks"; +import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks"; import { getMatchesBetweenTeams, getMatchesBetweenPlayers } from "./server"; export const matchKeys = { @@ -18,13 +18,13 @@ export const matchQueries = { }; export const useTeamHeadToHead = (team1Id: string, team2Id: string, enabled = true) => - useServerQuery({ + useServerSuspenseQuery({ ...matchQueries.headToHeadTeams(team1Id, team2Id), enabled, }); export const usePlayerHeadToHead = (player1Id: string, player2Id: string, enabled = true) => - useServerQuery({ + useServerSuspenseQuery({ ...matchQueries.headToHeadPlayers(player1Id, player2Id), enabled, }); diff --git a/src/features/players/components/player-head-to-head-sheet.tsx b/src/features/players/components/player-head-to-head-sheet.tsx index 5ddbe1f..f5e0fea 100644 --- a/src/features/players/components/player-head-to-head-sheet.tsx +++ b/src/features/players/components/player-head-to-head-sheet.tsx @@ -1,8 +1,9 @@ import { Stack, Text, Group, Box, Divider, Paper } from "@mantine/core"; import { usePlayerHeadToHead } from "@/features/matches/queries"; -import { useMemo, useEffect, useState } from "react"; +import { useMemo, useEffect, useState, Suspense } from "react"; import { CrownIcon } from "@phosphor-icons/react"; import MatchList from "@/features/matches/components/match-list"; +import PlayerHeadToHeadSkeleton from "./player-head-to-head-skeleton"; interface PlayerHeadToHeadSheetProps { player1Id: string; @@ -12,7 +13,7 @@ interface PlayerHeadToHeadSheetProps { isOpen?: boolean; } -const PlayerHeadToHeadSheet = ({ +const PlayerHeadToHeadContent = ({ player1Id, player1Name, player2Id, @@ -267,4 +268,12 @@ const PlayerHeadToHeadSheet = ({ ); }; +const PlayerHeadToHeadSheet = (props: PlayerHeadToHeadSheetProps) => { + return ( + }> + + + ); +}; + export default PlayerHeadToHeadSheet; diff --git a/src/features/players/components/player-head-to-head-skeleton.tsx b/src/features/players/components/player-head-to-head-skeleton.tsx new file mode 100644 index 0000000..cc5cd16 --- /dev/null +++ b/src/features/players/components/player-head-to-head-skeleton.tsx @@ -0,0 +1,72 @@ +import { Stack, Skeleton, Group, Paper, Divider } from "@mantine/core"; + +const PlayerHeadToHeadSkeleton = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default PlayerHeadToHeadSkeleton; diff --git a/src/lib/tanstack-query/hooks/user-server-suspense-query.ts b/src/lib/tanstack-query/hooks/user-server-suspense-query.ts index 828ce91..df77117 100644 --- a/src/lib/tanstack-query/hooks/user-server-suspense-query.ts +++ b/src/lib/tanstack-query/hooks/user-server-suspense-query.ts @@ -8,6 +8,7 @@ export function useServerSuspenseQuery( queryFn: () => Promise>; options?: Omit, 'queryFn' | 'queryKey'> showErrorToast?: boolean; + enabled?: boolean; } ) { const { queryKey, queryFn, showErrorToast = true, options: queryOptions } = options; From 470b4ef99cb2385ebef002658c98aea173942bbc Mon Sep 17 00:00:00 2001 From: yohlo Date: Thu, 16 Oct 2025 09:12:11 -0500 Subject: [PATCH 18/18] regionals --- Teams-2.xlsx | Bin 0 -> 13520 bytes bun.lock | 19 ++ package.json | 1 + pb_migrations/1760556705_updated_teams.js | 24 ++ .../1760556851_updated_tournaments.js | 24 ++ .../1760556905_updated_tournaments.js | 31 ++ ...760559911_created_player_regional_stats.js | 165 +++++++++++ ...760559954_created_player_mainline_stats.js | 165 +++++++++++ .../1760585178_updated_tournaments.js | 48 ++++ src/app/routes/_authed/stats.tsx | 47 ++- .../matches/components/match-card.tsx | 17 +- .../matches/components/match-list.tsx | 9 +- .../player-stats-table-skeleton.tsx | 6 +- .../players/components/player-stats-table.tsx | 28 +- .../players/components/profile/index.tsx | 41 ++- src/features/players/queries.ts | 24 +- src/features/players/server.ts | 12 +- src/features/teams/components/team-list.tsx | 6 +- src/features/teams/types.ts | 2 + .../components/tournament-card.tsx | 2 +- .../components/tournament-stats.tsx | 10 +- src/features/tournaments/types.ts | 2 + src/lib/pocketbase/services/badges.ts | 22 +- src/lib/pocketbase/services/matches.ts | 45 ++- src/lib/pocketbase/services/players.ts | 21 +- src/lib/pocketbase/services/tournaments.ts | 4 +- src/lib/pocketbase/util/transform-types.ts | 15 +- test.js | 269 ++++++++++++++++++ 28 files changed, 962 insertions(+), 97 deletions(-) create mode 100644 Teams-2.xlsx create mode 100644 pb_migrations/1760556705_updated_teams.js create mode 100644 pb_migrations/1760556851_updated_tournaments.js create mode 100644 pb_migrations/1760556905_updated_tournaments.js create mode 100644 pb_migrations/1760559911_created_player_regional_stats.js create mode 100644 pb_migrations/1760559954_created_player_mainline_stats.js create mode 100644 pb_migrations/1760585178_updated_tournaments.js create mode 100644 test.js diff --git a/Teams-2.xlsx b/Teams-2.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..9012367332a95dd6ce18e6c0e1bfe5a2cfc57095 GIT binary patch literal 13520 zcmeHubzIb2wC^xO4BZGwHzJK7NDQ5l(%qnRmoT&_NK4BgC8d;fcS}hl-5}BpZ@}~3 z@tk|^x%b`A`}1u+FvD-n`tG&Ad#}CM-g}L*JP45h00x5rs+mfw2elQyVt*TR8;@)Lg>OZ~gG4l%wsfx)062i82j-Tmmu9<0n6-yu0( zYi|S!+g_2g-F`clic<*_d>`7)QamIt!ty5}4=0SRX0JT&Bv25y(Rwkag1-DA<(Thk%p4+p5wT7B`zg z$)Kf!>!;&E6_3zPMAOL5A5#YrnVch?Sj-pZp?p#iQocm!@C)F64GiUd_n>Nvc~VB~ zN)Yw&HR;i8muf@LH^Z)3f=&c-rPhbl;$B^$B@6})vF1|lKm04LK;-_tT~&`ymu5>I zbn|{;VzDVP_+HdkMpJ31ERO<4F|iIrLI40zKmdU9|1FhV|DVeLS5$KUe=7f{RJz+c zy>PZNH+OMnzkB)R;3?BiQ!+Rq=|*$@i^3uNKsJTvfwHzgRCT?p7^tS7;W)|eQ`w<;I4Nm%M)Yo4ft`D&>J}O&Qgs-@oi!tyqoP{CMp&tM}}rJ)_o1 z?b}Mt&b9X3+vJNHE&E8r2Pd>oC^}WDzmtcE(CUq?wSBa+b4yvQnG%d^&$uGVVmscd zkZ-J>p5<%&5dRhD(~RwAexBXbo2TjxQkz^8;jh=8Ix1Ls#VA+vGiEyJ`}tMf3 zaOsYZ%DxdNi5skKtWcV5 z@Lfq94py8NYl00+(d3s$Cw_$g?w9MQSKwRg%1nzkCCS}K%?!}PmpGbyP4&+5$3UnUDSv1@yt9hNmM@*{N zQC@AYygcwEB9#Kx5#*x!z-fnhN)ns6s_AunHSc8Bz263XTM>&=s8+aW%ca>vr4w#{ zCEy&e4zHjW2{(Z`K|B3iAxk)T)RI^w0bSIk8b<>D!nI=3AuDAXeTfmX%l-!DrD_@e zevBlO(XGQQ$BowJxdf<>SQjGkam~HOL#P>FDnUq;%iKOjwLeTvB_H|aTEqY*$!rJV zo7g;?*goql0nC~a*l681_XG~bN@y4E7)ww$p$FH;XtYP`%7J!)d)>rZ_S7JJ0aGVd z8Jb7KS;*?ol^wloSV=N1rCU5c>p~KvtVQ?pqQxFrJqyff)ehm{=*2-khjLU4%ji5B zEAaGXfLB)WOuy4fjVA?PwJgUtC^I%2K8!f3Exic?B6M z0DK6bDIPUfupp|G&D3~43X^87!6b?ZLY)!|=Sq+5~kc z^)r-dTl2oLC|kd;CCZKL1+}?>A<-%3AhA{V%ie;}+KqH7hYzhO2kntmRxk51&aMvH zUb(8&*N72kvIKE>MbwxmSnSNl+@v0+i{J1&J@;kacm|gAKU>1=5 zT%8c^|6Lk+yDxEO!G1se3BQEo4gaFZEIZgM=WL zxt2IB3QpWIe+WXq2V9|=9zF=>aA=ZFoIy>Of9oZW{!B$M?kaea@4f<^pJ zjBFZDo_MT4`7~k(g0T@9j(m%tY~qYylEP+|EP3*1#w>%fq-A0phmk4~qne~}(x`l_ z08gICQh7XQ4K|ot#nik#Q9B2zyTV{b!@ z$t|YlHF4$|O^bpvVT{dIwyn_ASi0l)9%B8aprKM4nGSUW!OwA@j+Vpd9a%?FjA6n9 zk8RBrJ_*OzUUfUmmlrW&6J!X3K}UPn18$Oui3J&Ey6m=FLlxH;(_F|pP>j@j+zieJ z+p)IwC#G(xY@cN_f6=)iS}qQ`|%BF0W|IE;up zmOC7s%pQBTqri1^rzqq*)?JB=54~*L6T^eWoXn!$Vyt&T?E>o&O%DwMI5}caWAAr& zXNr39z@%GAo~U*}&|kecGD+Y}`er9XfjOLsAVk_#CXx6C406UbHe#buuwZ-Lou!h@ z1CymK$!cf}?-q>z_27dnyf^Q9zNIM%=b~svKH%{%4KjcaKsFI+EC0DvQ|e~W8re{B(diy#c0%x!*;BiL^LkN7H&*1~~< zJdf`Y%HbV57W|55M=HoJo5DyY2it~%Zf|HhCE~+;%Szu@pH{<1*xY6JgCFy=WGo|b zl=f_NW8n?VI@W`T@O2`}%IJ(Uz)$P*6?sg1_#9Jx%t%U9S`uUC&~d-XAk(_Pw^8)E zhmj)~BI@z%9+`~IYZI21T!f>9ZXH(Y@*2c3*7w&X`g2OJu@v<&Cl8l`2N8CwwHP=t z;^-W%r|Mf{EQuua!#=-0!oTI9=;PMYGI$Fgz~lS>GPeJXgS#l|S8N}z8<|N(9OBn} zjk0t_fDNlCQFO~m+MapJ;9X@Ar6?;&+C*`E^z|^-Ka*vFW zpfpc%cEVnor=vfXCO(Ar+T7ZekM&_fm=r8F7$1BT$(WcX645_U%f;6G7tTW-XuMkd5AX zbIwlJiEcMnQ$ekW1`G2sLvqgpLgCJz!uFQj2}b-SBAx3uGLDFg9w7k}vu`fGaFo#b zQg*!WDw521f;4pJ6QtQ`$V)Pv+T-tTJLg`YD#tgCVFu2HzaO3vRYNu%hJrs=S?7ZX%|lGlx{KXUA1?6hZ->FG&6dK< zc!A;XT;&UOxkj_Zr}PN8Key4Za({k-h{aC*;DZU)qz6^7AXe=7i0a61IZRS?L-b$W^lqU3 zlbcqc<62KefM?}|nxYgaCm5*1BENsPAwXx-8@TM_yEMN_WprbtlzKt8EC`a4(iTcK zXvr3`J!uk?$aE|Xp?gRG`^3AF#1Ov0fRTy*keWe;*u({eI)K!wqp65CHfG?Q#FX?U zJ6S%Bo4%@ZC=H%0(FJVx+?4d!09CJ2Ovh zcfNk<(7^kqr6Cs++c*7rTLy?#I}|Zh+lCV4Ac1#RccW>M-Ue;bX-%%8z1aA< z;8@Flh*>11td-0-cwuh84Y$fJYvpW_!*Y_mSKwZ60U zK7vPkz2?OBoj!uT*PZq$YtBoQ9p55Se3Gu7VrSkMU*3$zTHA#uxvsoBto=wiw!b$# zlJhC0mxN!R=A?30|GJ^Jakhct{WyEUoV(`5c>iAS&6e>|-IcHV%Q^=oztgqzgM`*; zO1mHS_H3Q?H%lU0-%eg0?-o?{o<{B*f33W^JVr!WvCTcf_r2`v9KSNIJ?q?DlTO?! z$o-bPvUOlw^74B3eBrEQX>1u)H{Imkv!aVazCzbrX))CE*eIi@Tc^stP`?LB2#Ck6G z?xh`SW2eCZo-ER6dM}DS%l)*ecX;n!Jx@%ify>aFxiu;`p_XPdyF>^tJ!Ot;bV+nW z0!maKVblgmb7t5#m7(0l`N5||YY`I*pv}>@xb|*Q8+j_iKEV2IhG6HBcLS^45oALV zWW!IHAEwWg@5hID>6XVX)|6z^D!MHX+P%g-U6u~N%5*wK^g7$l0@$aXA_)=mciiVx z#+EXJ4iSLPx+7Rp^;}|s_5O)+;`Py{LS1I0B;j4aOlIW6*p%Ugbp|9KP)U4<4{fJU zRML}N;%X%~i3f>2p_yp}XP7}oq!$rnml0%FqB;H}uUt5iXeeMQ5&+}?R&xNYIFz|# zGnPCF6xW)&xM0PY0rgLmHwL&kak46Knx$_;71MR%mKfFOkbPb$$P9`-%TWLbJPHaO z>AW|(Au;$eF9&1SX`cv=yMp`7cR z>fwtrx)DD3zy{!@c^EqZ>u@REUGq-juj!7hhgb}cjK^hI4{|C zNZ61^nYAmqgcV>3UzJ({d9j;W+d-pw=^sck8GKRr7zBWKabN~dQxd&=w4j@*x z9J3~b6ss2?^CATF(-vpLpF)bc3^8EbO{Veyhf=j=6nHJoEzgCJ5LrOLp1!l_1bQhJ zwucH8Rk~1LJjw|{fUbo+WEB*ZbT-v<$?-wazmsVE(V#NC9_$kWoI(sR)yRD_NLuy0 z9Kc~;ty0zqpK3x7Q5OxYfs7bOB7(DE4Jhr|_h&^wJeP$U9e{0^skBINlr8M229xf~ zARrF*;_CqsQ>2aLZ>guFJL{M>TDEwJ}*%nUe>2D&Db|{iz;Imv2 z2A2e|(j&6q1A7z@V^B@X(Wobe-y{Bb$sp$739g94NmT$-adTJ1CJ;+$>d>58cM>81 zujt@H47Wdo_!Y6JCN7H|t;?I>X8PYHK|5vv#Az`c^Z8uJdNhbAFK+%&dEuaF03%K- z4Vah9Lt1B)r+^CiGQ5N!BSt0BQ{(~yVk>zWM&C?_v*88=N&w}+iVTRk52;~_v{7o{ zW_8>E`JnlIoNXrR$4NMl90_iiBBKqX1--&2yU;rWz<-$Ld{2*nF?@(Q2;jvJxV7vL z0h3w)D6Q7N?O|y5x9s~>h+wwJrnkU=ScK+oYY=EN&Lb5F{`4HRnGy06u0nuQhXFP~ zK>WPT>A|^WyzdH~$EP|KMD#`j8{ArauoO>!D?C*-0l_R~9ET0DyoC0pQVud31r7*0UM!*^k;=iX!+~1|9U$I}!yjeli%NaFz6H2z zDB>{z&Yg;aL%mY~0i;+e zFuW-rFu+V|A|FRk5nMJI#}LF6pg0S-X+W_!1O#&dYUlvsC=(-fn*ih$Dh^HIf*Rx% z7nOuL5`xR>H9{IgMEhR_{GrB>v;lh+xYNFYlH*fJc!)!9p{RS9F+iIx!ms3FObX>x z!{JzUTr%*-aChc5l)lBY5QdYI2%v7k6G1{844XyjIrK+xMRO>L3-Z`kJKHxoKBlRY zYN~4_HW$IU*?31wF>pHc-K5Yff|Eh`RdJir8Nj7ODfiUBb>p4Gvr{UKJy8W8oC7c! z^RID~gbm4k;sWFT%-Obxh1_7749Ezi1E7J^PG2=bl7PU?QRQ4SW;b>1*ZRO9EQ*EO zkp%C5;fxQ8AZ137m?jGNRxtMS55ac`Y~;Spwwx}QQN$HGNDo<`htmdJ52s{N0((5h zgrot%uzQR_YzSf~xQRe9#vcd|m2(iluB(?!P=ME@5*&<%2i{Z?5rgXQbxyv^gh8LM zaB2|257fr+JD=wLS#%B4mQa3NKGf`n5u3jL;G_H{zOnf*|2NQEa83aoAo-*j~Zd z%DGP<^(`nbt*_v-pYS?0L``_zTo{sMVE>Ujm^qFhsNn%phLX=8cwEI!aymUCI3L2+o#tqy>Td>0xzvKDNhm!5boAiR!Gd05=3bOuW}1DKyfZfAN~ z(RF#|@O4JG(FlqAqU;9CljZ<>o6RkQcTw2+i>$dOKfo zp@eFne=>6nBI{$rMb##bC-0dJMwkkfDVqBw4!Ll&U*Wp-Q8at=Ehw%|{QxZ^aFyLTinHhAS zBikr>n6zb5Lq9pdx>P_86Zv^g3|{%XO*cCWMj2Ms2>s_r;B=0Ic=cz`Io@+WiiaVs z6~wuCRM4*}ygi`iXvj}&;n6nFMs5{Q)6HW>Ixb`7JTQW!r9C1jW%6r*+iH^6ygopn z4Y##OF@>k?c{uInjHFg2&^WnpR5eiLJ#)oqSDe2QC zV`erZyEQ@LhFgO$gYe&#AQQp)`6p3&<^BwrgISkr!s8~MXM5fgYCX3J+$0dzh;P|}#xS*(O9vM#iQ8KF)q!QJ_bl&Gky`dBs zi7fw+i=LtcX$jqB;2y7$4`J`)_crmp5V$dQ{@FxHs*w``2j*?Y*k#CilV%ZoQ^Uf2 zeVPU+r1B4(;$iJBo$U&ir#Pg=Yx0Q6*1S>+KIT>a@%NH?Zq<+k0#yc~N7)Z;;|hMY zj*vJBEebAKDc`~&<@n01LnvcIr58$KYm_IMgO?Tk;t`v2ZYzZaIfr>&?+V4&2Dzj} zZbP_OQYwdru43NDe}GUId+H2NQ<_2T6Dj{e{1CE+h1mmf6Z8SiQ6EuI2-cU)s0m0F z7WYgOT7*Kdj7{?yaK=c(wivX!CtrYPz1bs?T&S4UEEl1WeJ4z77Po1dH6*n!cdd*5 z_J*hI^;-;ktzbT7%T-UCrb2rwb=qF>#Iqg`kUTe6JZ%ZmS})T$wOf;eeYN27q^30( zZlAqTm{d-{t$kr4PdWSET52HlsMCC{*wo-F<%+S;=!(8TLPO$%thK-?e&0|Slf%E2 zvCYzo`Ea6ZG`5W>m3^=B0rF_|y80&)&kFVUx5lL(Vvu_UxXkIFmJ}~d5)C4YtDNG7 zvtshvFf%Xb7Nu-UZ@hQw-Q7y~s#zPkm0P33)4$19K-V|ayIJUUZcVhCvY%uP>J~6@nb-L?fWo~QEe)sq8YPer>+HQsl z=PKYDv{>IfUe8>sQktSpsiVGGvB_Psm)$%8O#We+R>s`|o0g#p6Pt`{J;8?Ba!b{cBvohG|tS3J!g7 z`5US!y${Hc6`acPMp8{si}xD6aPH#$MK2#xXdpzAQ*YXt==ys>3Gzo$tZMtuLgPnI zJOby)pI28;>gfAcwtIu>w)EM3Dg#e+>O}CiY%C|q>*f|MTRklKYIh0)$6-M^+rH2W z+qpTr7cQVMr}Gtak2hi+19_K8sZ-J<*Rcon!1cxaoo=Yl1DPODaP2B{4!w{8uAm)u zTO#?S-C?}x>F7L&*0f7)8lwHfC2JVuZ1xfc6) zb354<=X~{LTF-LSycnzL7NLoXS|^a8S~(O|(P=A-tjyliDBeRaB6KzAXIVq`7Og^-7IIupcSFXOih*ns{tqU`BDp{kbrlq(`O+7s2HD_O z05!aKuKAZr--Nugb7Lkkr%6!lH`0oR$#FZ}wqA2o7zcks>OF>oxNn6_mU)VQI~_V)AH>JyVuA00(xbApMAiVJq7<* zkG^SopmIIKhEJR$#yg3jW@qz*_f7p)zVK^WcEKxXhGf9jTN8s-LcPfQ{8D5()*=sh z-nGtd2kSn~y2q`mT;y9m`SSiPCg;=^4uH`K)=SzcCX$HK+*d-)0+vUykN%W$a{b zrsm>wn{IWR&*G{vZ8!ZG?rB#jqPr3F?G=knZVMedpGFw8%B`V0@|^EF@o2-!W#!&o z9`*7dmG))|?*HtIa$Xf1m~(d}EH8{ucEas|-yszDjVgBe;QIQV9{zZM5l&y7DJN?X zB$V>=YCp1?lh+|<_+d-SRO^wIS!uuvu6vtJ!#msYpSo+Z2c%bFNp3kt6|RruK3ECl z%XV>eJ)_;BQBZZap=~c&NV!M%Hr9ck&xF6Sx8F;+wD)Y?^C;kw9OdNM0YI?nX-IW9 z!2M}F6(yO{zTFead;&cLca}w#xz;kp@l{At>{nD{tx7VsMpYKzoUdQc;#Ie_{WE$x zvUm-_km3fOmeYywmB%5)!ebwR2aZp^SZ`*g2lKvLK%UAGU=h_Crhh>>yAzv*fGc99 zsf9-NCMG=@H1mP7%HFb`oRlQeNlmoiMCS9Uvd&c!qsThUDDhZ9Wa`-Rqb zv%T&0%#-rg=Ua@E%+mRgj7UPuwaKQvP@uEA2B!HFDm6)@xLz~)Sk^4Gm!C*YOW1lB zeH5E6Gg&J9(Q39CP@lDnNaWzq%apm1gQ(*kM98hTuC4Bu zz|aeM67#4Z_lLidqx-I?IrcFIMu2D3+tVUv!wRX$1U5X$#yNOW1N%C2@aYc_NqZ%A zWuh)L2&0NkG~7^KhUBRcDSa;e&*hq#=9$Ka2=xL|(fiI8xNH?!6|3FVAW2d*^dCd2XgVWF~9Sidi7?zX$}my=LEvMGwE0Yz7*>dfCRUP$&(Rk zdn(A#<0ZsZ1!@gNH zmG*mbscSh4XyIKOOl0-U!sY{!ax?B?9s%HvmGXmzMvK00X@fKUuzSc#I>pJ}1Un(s z-_gET&1Vtn4y>`{RP*pq>tVlZ+j}Z>`^nbg0 zY&>m9W%ISZFe}D4ywEB-sfg{1dsax%o>qb-p=|F7a(scj9NxU9RWWb!BO5Yi7B1ei zd)nw0*at}rlF`+S`(1=4eI<+V^c0}{c21AA_P`SRN6c{=lJ7d*>dNgw4B79HZNd|j zNBmm$n;&xcX(o2lvQg{ijXGDzClg49938bJ;~{bFjGIOlBhNhx2y?qB)^Hj*vws(f z!sgiAML23Bv#xFQBJh|i*dt;l zVZ~EWjgQL0IpNSl^TlyLs;Lt9X7Hz3sujnpL3(m74JJ&}x6Jm`5@&uH=Lq!B4lO8Z z@*a%4tf(`5LroE?7jpct=FJK0f^sjx9``1Tl=3MI#FDN|$u#kE)37RI-08LHmW_sN z&Ge?+1lK@Kq@iray_MX?o-u}g1zF|ziQve5nR61<8$?P+3 z!CS6?6h%wD@{!<|P-3=7yK-V2rU~1x=hT_s0o7)B4$F$^ot|>xn@lB&ZPQ zShSa~yH6HgmNNJsDC8=TVscgo(AF-SUzl#6S@rQZ)wywY*R#{ek$)BCl<om*_u43sF^>JG<9$zuYpibcz65eM{@U$SWZktzHRKIdO^Y)OCh&#wf8 z-+SpUQm>B{ecsA}BdIj_m_+1^E5EjsHj+o_TWAgPIG?pvDMblFK( zI+WUIB`Q$reWg@s^Wp?fXA0YzJpFZgdJ@tf*;Bp9o=Iyg6dfeW`evm?L^u^SfQtKq z*NoHOTnm0q=0#Gl>gUbJS#Tot3{s(<8-k^Hv1U2ujtCpAEOme}G9y_Nmi=&w-}Ja7`!j{Z+@qjHZGZKS8bfJ6F!K&!8%WH-zb5Q#Nil zHoD^Wb}r_2E{4xM9n78eZ{4{+PX2a`guurLv8{X-U9hTL>cexbir^fSM7gC2iFZL# zp0f?5sRX2YGEEt?>kEe5bL=m&x@~l~Y$D!2c;UpmDE=-ZT0`9;x-9bSVE(N+K@-#9 zn9+cnj16yna-TaE^PA=nCykK#AHh`GCYWtS;`s3`=cHn(=fMtC3B?dj3M~8Jat0V` z;NY^+NH;A}@i4WwRU==XGxETOoY<=d35AZ*Tw4O&lzzQboJ!u!u0A9CAn#FY%=q}s zEmO#|JnFg*ktLR3OKKaHXL#}P1p*-sP86alVZ_h-`Nr5ss>~bwvDuY7vrG!wD`jY0 zX&dG?C@+X_@G1Ec4n*lap6xbww&b$0ix3@dh z01^QH{GP$@Y4f+=Gx%%y&%Sl=-$%Kv_a9L1avlErDDU8l!atzg?f?Id@_WM6Ut6>Q zce_6pj=RjD-_xW1j`DlT5$#_{p~d(+%0H%B{T=Z41d+d*#Rw0Y{=uw&r0Dkyi@%!H z178;Zit?LTf2NlF-K^hFVg72?SDe2C-jVi?od14|@mI4hss4`gn^|{Qcaz-T&H8=2 zb~{P@SrBOM0RMg#@^^sW!`0he-Jj(-{T;x6*yH^j;rC$VHmv=#6fpfl_&-7J-&KEK zCvT&bKMOX?|Dof5$1Z=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "xlsx": ["xlsx@0.18.5", "", { "dependencies": { "adler-32": "~1.3.0", "cfb": "~1.2.1", "codepage": "~1.15.0", "crc-32": "~1.2.1", "ssf": "~0.11.2", "wmf": "~1.0.1", "word": "~0.3.0" }, "bin": { "xlsx": "bin/xlsx.njs" } }, "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ=="], + "xmlbuilder": ["xmlbuilder@13.0.2", "", {}, "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ=="], "xmlbuilder2": ["xmlbuilder2@3.1.1", "", { "dependencies": { "@oozcitak/dom": "1.15.10", "@oozcitak/infra": "1.0.8", "@oozcitak/util": "8.3.8", "js-yaml": "3.14.1" } }, "sha512-WCSfbfZnQDdLQLiMdGUQpMxxckeQ4oZNMNhLVkcekTu7xhD4tuUDyAPoY8CwXvBYE6LwBHd6QW2WZXlOWr1vCw=="], diff --git a/package.json b/package.json index 8452087..9469827 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "supertokens-web-js": "^0.15.0", "twilio": "^5.8.0", "vaul": "^1.1.2", + "xlsx": "^0.18.5", "zod": "^4.0.15", "zustand": "^5.0.7" }, diff --git a/pb_migrations/1760556705_updated_teams.js b/pb_migrations/1760556705_updated_teams.js new file mode 100644 index 0000000..fb8db15 --- /dev/null +++ b/pb_migrations/1760556705_updated_teams.js @@ -0,0 +1,24 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1568971955") + + // add field + collection.fields.addAt(14, new Field({ + "hidden": false, + "id": "bool3523658193", + "name": "private", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1568971955") + + // remove field + collection.fields.removeById("bool3523658193") + + return app.save(collection) +}) diff --git a/pb_migrations/1760556851_updated_tournaments.js b/pb_migrations/1760556851_updated_tournaments.js new file mode 100644 index 0000000..70b1438 --- /dev/null +++ b/pb_migrations/1760556851_updated_tournaments.js @@ -0,0 +1,24 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_340646327") + + // add field + collection.fields.addAt(12, new Field({ + "hidden": false, + "id": "bool3403970290", + "name": "regional", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_340646327") + + // remove field + collection.fields.removeById("bool3403970290") + + return app.save(collection) +}) diff --git a/pb_migrations/1760556905_updated_tournaments.js b/pb_migrations/1760556905_updated_tournaments.js new file mode 100644 index 0000000..a3456c7 --- /dev/null +++ b/pb_migrations/1760556905_updated_tournaments.js @@ -0,0 +1,31 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_340646327") + + // add field + collection.fields.addAt(13, new Field({ + "hidden": false, + "id": "select3736761055", + "maxSelect": 1, + "name": "format", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "single_elim", + "double_elim", + "groups", + "swiss" + ] + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_340646327") + + // remove field + collection.fields.removeById("select3736761055") + + return app.save(collection) +}) diff --git a/pb_migrations/1760559911_created_player_regional_stats.js b/pb_migrations/1760559911_created_player_regional_stats.js new file mode 100644 index 0000000..ad1d465 --- /dev/null +++ b/pb_migrations/1760559911_created_player_regional_stats.js @@ -0,0 +1,165 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": null, + "deleteRule": null, + "fields": [ + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3208210256", + "max": 0, + "min": 0, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_3072146508", + "hidden": false, + "id": "relation2582050271", + "maxSelect": 1, + "minSelect": 0, + "name": "player_id", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "json4231605813", + "maxSize": 1, + "name": "player_name", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "number103159226", + "max": null, + "min": null, + "name": "matches", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number3837590211", + "max": null, + "min": null, + "name": "tournaments", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "json2732118329", + "maxSize": 1, + "name": "wins", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "json724428801", + "maxSize": 1, + "name": "losses", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "json3154249934", + "maxSize": 1, + "name": "total_cups_made", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "json3227208027", + "maxSize": 1, + "name": "total_cups_against", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "json2379943496", + "maxSize": 1, + "name": "win_percentage", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "json3165107022", + "maxSize": 1, + "name": "avg_cups_per_match", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "json3041953980", + "maxSize": 1, + "name": "margin_of_victory", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "json1531431708", + "maxSize": 1, + "name": "margin_of_loss", + "presentable": false, + "required": false, + "system": false, + "type": "json" + } + ], + "id": "pbc_4086490894", + "indexes": [], + "listRule": null, + "name": "player_regional_stats", + "system": false, + "type": "view", + "updateRule": null, + "viewQuery": "SELECT\n p.id as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_name,\n COUNT(m.id) as matches,\n COUNT(DISTINCT m.tournament) as tournaments,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) as wins,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups < m.away_cups) OR\n (m.away = t.id AND m.away_cups < m.home_cups)\n THEN 1 ELSE 0\n END) as losses,\n SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) as total_cups_made,\n SUM(CASE\n WHEN m.home = t.id THEN m.away_cups\n WHEN m.away = t.id THEN m.home_cups\n ELSE 0\n END) as total_cups_against,\n -- Win percentage\n ROUND((CAST(SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) AS REAL) / COUNT(m.id)) * 100, 2) as win_percentage,\n -- Average cups per match\n ROUND(CAST(SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) AS REAL) / COUNT(m.id), 2) as avg_cups_per_match,\n -- Margin of Victory\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups > m.away_cups\n THEN m.home_cups - m.away_cups\n WHEN m.away = t.id AND m.away_cups > m.home_cups\n THEN m.away_cups - m.home_cups\n ELSE NULL\n END), 2) as margin_of_victory,\n -- Margin of Loss\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups < m.away_cups\n THEN m.away_cups - m.home_cups\n WHEN m.away = t.id AND m.away_cups < m.home_cups\n THEN m.home_cups - m.away_cups\n ELSE NULL\n END), 2) as margin_of_loss\n FROM players p, teams t, matches m, tournaments tour\n WHERE\n t.players LIKE '%\"' || p.id || '\"%' AND\n (m.home = t.id OR m.away = t.id) AND\n m.tournament = tour.id AND\n m.status = 'ended' AND\n tour.regional = true\n GROUP BY p.id", + "viewRule": null + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_4086490894"); + + return app.delete(collection); +}) diff --git a/pb_migrations/1760559954_created_player_mainline_stats.js b/pb_migrations/1760559954_created_player_mainline_stats.js new file mode 100644 index 0000000..5c67082 --- /dev/null +++ b/pb_migrations/1760559954_created_player_mainline_stats.js @@ -0,0 +1,165 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": null, + "deleteRule": null, + "fields": [ + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3208210256", + "max": 0, + "min": 0, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_3072146508", + "hidden": false, + "id": "relation2582050271", + "maxSelect": 1, + "minSelect": 0, + "name": "player_id", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "json4231605813", + "maxSize": 1, + "name": "player_name", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "number103159226", + "max": null, + "min": null, + "name": "matches", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number3837590211", + "max": null, + "min": null, + "name": "tournaments", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "json2732118329", + "maxSize": 1, + "name": "wins", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "json724428801", + "maxSize": 1, + "name": "losses", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "json3154249934", + "maxSize": 1, + "name": "total_cups_made", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "json3227208027", + "maxSize": 1, + "name": "total_cups_against", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "json2379943496", + "maxSize": 1, + "name": "win_percentage", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "json3165107022", + "maxSize": 1, + "name": "avg_cups_per_match", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "json3041953980", + "maxSize": 1, + "name": "margin_of_victory", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "json1531431708", + "maxSize": 1, + "name": "margin_of_loss", + "presentable": false, + "required": false, + "system": false, + "type": "json" + } + ], + "id": "pbc_15286826", + "indexes": [], + "listRule": null, + "name": "player_mainline_stats", + "system": false, + "type": "view", + "updateRule": null, + "viewQuery": "SELECT\n p.id as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_name,\n COUNT(m.id) as matches,\n COUNT(DISTINCT m.tournament) as tournaments,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) as wins,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups < m.away_cups) OR\n (m.away = t.id AND m.away_cups < m.home_cups)\n THEN 1 ELSE 0\n END) as losses,\n SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) as total_cups_made,\n SUM(CASE\n WHEN m.home = t.id THEN m.away_cups\n WHEN m.away = t.id THEN m.home_cups\n ELSE 0\n END) as total_cups_against,\n -- Win percentage\n ROUND((CAST(SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) AS REAL) / COUNT(m.id)) * 100, 2) as win_percentage,\n -- Average cups per match\n ROUND(CAST(SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) AS REAL) / COUNT(m.id), 2) as avg_cups_per_match,\n -- Margin of Victory\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups > m.away_cups\n THEN m.home_cups - m.away_cups\n WHEN m.away = t.id AND m.away_cups > m.home_cups\n THEN m.away_cups - m.home_cups\n ELSE NULL\n END), 2) as margin_of_victory,\n -- Margin of Loss\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups < m.away_cups\n THEN m.away_cups - m.home_cups\n WHEN m.away = t.id AND m.away_cups < m.home_cups\n THEN m.home_cups - m.away_cups\n ELSE NULL\n END), 2) as margin_of_loss\n FROM players p, teams t, matches m, tournaments tour\n WHERE\n t.players LIKE '%\"' || p.id || '\"%' AND\n (m.home = t.id OR m.away = t.id) AND\n m.tournament = tour.id AND\n m.status = 'ended' AND\n (tour.regional = false OR tour.regional IS NULL)\n GROUP BY p.id", + "viewRule": null + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_15286826"); + + return app.delete(collection); +}) diff --git a/pb_migrations/1760585178_updated_tournaments.js b/pb_migrations/1760585178_updated_tournaments.js new file mode 100644 index 0000000..df372ba --- /dev/null +++ b/pb_migrations/1760585178_updated_tournaments.js @@ -0,0 +1,48 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_340646327") + + // update field + collection.fields.addAt(13, new Field({ + "hidden": false, + "id": "select3736761055", + "maxSelect": 1, + "name": "format", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "single_elim", + "double_elim", + "groups", + "swiss", + "swiss_bracket", + "round_robin" + ] + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_340646327") + + // update field + collection.fields.addAt(13, new Field({ + "hidden": false, + "id": "select3736761055", + "maxSelect": 1, + "name": "format", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "single_elim", + "double_elim", + "groups", + "swiss" + ] + })) + + return app.save(collection) +}) diff --git a/src/app/routes/_authed/stats.tsx b/src/app/routes/_authed/stats.tsx index 65f385b..030bf57 100644 --- a/src/app/routes/_authed/stats.tsx +++ b/src/app/routes/_authed/stats.tsx @@ -1,17 +1,19 @@ import { createFileRoute } from "@tanstack/react-router"; import { playerQueries } from "@/features/players/queries"; import PlayerStatsTable from "@/features/players/components/player-stats-table"; -import { Suspense } from "react"; +import { Suspense, useState, useDeferredValue } from "react"; import PlayerStatsTableSkeleton from "@/features/players/components/player-stats-table-skeleton"; import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch"; import LeagueHeadToHead from "@/features/players/components/league-head-to-head"; -import { Box, Loader, Tabs } from "@mantine/core"; +import { Box, Loader, Tabs, Button, Group, Container, Stack } from "@mantine/core"; export const Route = createFileRoute("/_authed/stats")({ component: Stats, beforeLoad: ({ context }) => { const queryClient = context.queryClient; - prefetchServerQuery(queryClient, playerQueries.allStats()); + prefetchServerQuery(queryClient, playerQueries.allStats('all')); + prefetchServerQuery(queryClient, playerQueries.allStats('mainline')); + prefetchServerQuery(queryClient, playerQueries.allStats('regional')); }, loader: () => ({ withPadding: false, @@ -24,6 +26,10 @@ export const Route = createFileRoute("/_authed/stats")({ }); function Stats() { + const [viewType, setViewType] = useState<'all' | 'mainline' | 'regional'>('all'); + const deferredViewType = useDeferredValue(viewType); + const isStale = viewType !== deferredViewType; + return ( @@ -32,9 +38,38 @@ function Stats() { - }> - - + + + + + + + + + }> + + + + + diff --git a/src/features/matches/components/match-card.tsx b/src/features/matches/components/match-card.tsx index eb41d1a..827942c 100644 --- a/src/features/matches/components/match-card.tsx +++ b/src/features/matches/components/match-card.tsx @@ -20,6 +20,7 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => { const isHomeWin = match.home_cups > match.away_cups; const isAwayWin = match.away_cups > match.home_cups; const isStarted = match.status === "started"; + const hasPrivate = match.home?.private || match.away?.private; const handleHomeTeamClick = (e: React.MouseEvent) => { e.stopPropagation(); @@ -65,13 +66,17 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => { {match.tournament.name} - - - - Round {match.round + 1} - {match.is_losers_bracket && " (Losers)"} - + {!match.tournament.regional && ( + <> + - + + Round {match.round + 1} + {match.is_losers_bracket && " (Losers)"} + + + )} - {match.home && match.away && !hideH2H && ( + {match.home && match.away && !hideH2H && !hasPrivate && ( { return undefined; } + const isRegional = filteredMatches[0]?.tournament?.regional; + return ( + {isRegional && ( + + Matches for regionals are unordered + + )} {filteredMatches.map((match, index) => (
{ ); }; -const PlayerStatsTableSkeleton = () => { +interface PlayerStatsTableSkeletonProps { + hideFilters?: boolean; +} + +const PlayerStatsTableSkeleton = ({ hideFilters = false }: PlayerStatsTableSkeletonProps) => { return ( diff --git a/src/features/players/components/player-stats-table.tsx b/src/features/players/components/player-stats-table.tsx index e602c08..f2b9fba 100644 --- a/src/features/players/components/player-stats-table.tsx +++ b/src/features/players/components/player-stats-table.tsx @@ -142,8 +142,12 @@ const PlayerListItem = memo(({ stat, onPlayerClick, mmr, onRegisterViewport, onU ); }); -const PlayerStatsTable = () => { - const { data: playerStats } = useAllPlayerStats(); +interface PlayerStatsTableProps { + viewType?: 'all' | 'mainline' | 'regional'; +} + +const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => { + const { data: playerStats } = useAllPlayerStats(viewType); const navigate = useNavigate(); const [search, setSearch] = useState(""); const [sortConfig, setSortConfig] = useState({ @@ -292,21 +296,19 @@ const PlayerStatsTable = () => { if (playerStats.length === 0) { return ( - - - - - - - No Stats Available - - - + + + + + + No Stats Available + + ); } return ( - + Showing {filteredAndSortedStats.length} of {playerStats.length} players diff --git a/src/features/players/components/profile/index.tsx b/src/features/players/components/profile/index.tsx index 33c397d..ff524bb 100644 --- a/src/features/players/components/profile/index.tsx +++ b/src/features/players/components/profile/index.tsx @@ -1,10 +1,10 @@ -import { Box, Stack, Text, Divider } from "@mantine/core"; -import { Suspense } from "react"; +import { Box, Stack, Text, Divider, Group, Button } from "@mantine/core"; +import { Suspense, useState, useDeferredValue } from "react"; import Header from "./header"; import SwipeableTabs from "@/components/swipeable-tabs"; import { usePlayer, usePlayerMatches, usePlayerStats } from "../../queries"; import TeamList from "@/features/teams/components/team-list"; -import StatsOverview from "@/components/stats-overview"; +import StatsOverview, { StatsSkeleton } from "@/components/stats-overview"; import MatchList from "@/features/matches/components/match-list"; import BadgeShowcase from "@/features/badges/components/badge-showcase"; import BadgeShowcaseSkeleton from "@/features/badges/components/badge-showcase-skeleton"; @@ -13,10 +13,38 @@ interface ProfileProps { id: string; } +const StatsWithFilter = ({ id }: { id: string }) => { + const [viewType, setViewType] = useState<'all' | 'mainline' | 'regional'>('all'); + const deferredViewType = useDeferredValue(viewType); + const isStale = viewType !== deferredViewType; + + return ( + + + Statistics + + + + + + + + }> + + + + + ); +}; + +const StatsContent = ({ id, viewType }: { id: string; viewType: 'all' | 'mainline' | 'regional' }) => { + const { data: stats, isLoading: statsLoading } = usePlayerStats(id, viewType); + return ; +}; + const Profile = ({ id }: ProfileProps) => { const { data: player } = usePlayer(id); const { data: matches } = usePlayerMatches(id); - const { data: stats, isLoading: statsLoading } = usePlayerStats(id); const tabs = [ { @@ -29,10 +57,7 @@ const Profile = ({ id }: ProfileProps) => { - - Statistics - - + , }, { diff --git a/src/features/players/queries.ts b/src/features/players/queries.ts index 3a871b4..eb61288 100644 --- a/src/features/players/queries.ts +++ b/src/features/players/queries.ts @@ -7,8 +7,8 @@ export const playerKeys = { details: (id: string) => ['players', 'details', id], unassociated: ['players','unassociated'], unenrolled: (tournamentId: string) => ['players', 'unenrolled', tournamentId], - stats: (id: string) => ['players', 'stats', id], - allStats: ['players', 'stats', 'all'], + stats: (id: string, viewType?: 'all' | 'mainline' | 'regional') => ['players', 'stats', id, viewType ?? 'all'], + allStats: (viewType?: 'all' | 'mainline' | 'regional') => ['players', 'stats', 'all', viewType ?? 'all'], matches: (id: string) => ['players', 'matches', id], activity: ['players', 'activity'], }; @@ -34,13 +34,13 @@ export const playerQueries = { queryKey: playerKeys.unenrolled(tournamentId), queryFn: async () => await getUnenrolledPlayers({ data: tournamentId }) }), - stats: (id: string) => ({ - queryKey: playerKeys.stats(id), - queryFn: async () => await getPlayerStats({ data: id }) + stats: (id: string, viewType?: 'all' | 'mainline' | 'regional') => ({ + queryKey: playerKeys.stats(id, viewType), + queryFn: async () => await getPlayerStats({ data: { playerId: id, viewType } }) }), - allStats: () => ({ - queryKey: playerKeys.allStats, - queryFn: async () => await getAllPlayerStats() + allStats: (viewType?: 'all' | 'mainline' | 'regional') => ({ + queryKey: playerKeys.allStats(viewType), + queryFn: async () => await getAllPlayerStats({ data: viewType }) }), matches: (id: string) => ({ queryKey: playerKeys.matches(id), @@ -84,11 +84,11 @@ export const usePlayers = () => export const useUnassociatedPlayers = () => useServerSuspenseQuery(playerQueries.unassociated()); -export const usePlayerStats = (id: string) => - useServerSuspenseQuery(playerQueries.stats(id)); +export const usePlayerStats = (id: string, viewType?: 'all' | 'mainline' | 'regional') => + useServerSuspenseQuery(playerQueries.stats(id, viewType)); -export const useAllPlayerStats = () => - useServerSuspenseQuery(playerQueries.allStats()); +export const useAllPlayerStats = (viewType?: 'all' | 'mainline' | 'regional') => + useServerSuspenseQuery(playerQueries.allStats(viewType)); export const usePlayerMatches = (id: string) => useServerSuspenseQuery(playerQueries.matches(id)); diff --git a/src/features/players/server.ts b/src/features/players/server.ts index 399dba3..56874e1 100644 --- a/src/features/players/server.ts +++ b/src/features/players/server.ts @@ -136,16 +136,20 @@ export const getUnassociatedPlayers = createServerFn() ); export const getPlayerStats = createServerFn() - .inputValidator(z.string()) + .inputValidator(z.object({ + playerId: z.string(), + viewType: z.enum(['all', 'mainline', 'regional']).optional() + })) .middleware([superTokensFunctionMiddleware]) .handler(async ({ data }) => - toServerResult(async () => await pbAdmin.getPlayerStats(data)) + toServerResult(async () => await pbAdmin.getPlayerStats(data.playerId, data.viewType)) ); export const getAllPlayerStats = createServerFn() + .inputValidator(z.enum(['all', 'mainline', 'regional']).optional()) .middleware([superTokensFunctionMiddleware]) - .handler(async () => - toServerResult(async () => await pbAdmin.getAllPlayerStats()) + .handler(async ({ data }) => + toServerResult(async () => await pbAdmin.getAllPlayerStats(data)) ); export const getPlayerMatches = createServerFn() diff --git a/src/features/teams/components/team-list.tsx b/src/features/teams/components/team-list.tsx index 0812a84..2c006e0 100644 --- a/src/features/teams/components/team-list.tsx +++ b/src/features/teams/components/team-list.tsx @@ -55,10 +55,10 @@ const TeamList = ({ teams, loading = false, onTeamClick }: TeamListProps) => { const navigate = useNavigate(); const handleClick = useCallback( - (teamId: string) => { + (teamId: string, priv: boolean) => { if (onTeamClick) { onTeamClick(teamId); - } else { + } else if (!priv) { navigate({ to: `/teams/${teamId}` }); } }, @@ -100,7 +100,7 @@ const TeamList = ({ teams, loading = false, onTeamClick }: TeamListProps) => { /> } style={{ cursor: "pointer" }} - onClick={() => handleClick(team.id)} + onClick={() => handleClick(team.id, team.private)} styles={{ itemWrapper: { width: "100%" }, itemLabel: { width: "100%" }, diff --git a/src/features/teams/types.ts b/src/features/teams/types.ts index c034a53..4b7cafa 100644 --- a/src/features/teams/types.ts +++ b/src/features/teams/types.ts @@ -19,6 +19,7 @@ export interface Team { updated: string; players: PlayerInfo[]; tournaments: TournamentInfo[]; + private: boolean; } export interface TeamInfo { @@ -28,6 +29,7 @@ export interface TeamInfo { accent_color: string; logo?: string; players: PlayerInfo[]; + private: boolean; } export const teamInputSchema = z diff --git a/src/features/tournaments/components/tournament-card.tsx b/src/features/tournaments/components/tournament-card.tsx index 2aebe0d..88c864d 100644 --- a/src/features/tournaments/components/tournament-card.tsx +++ b/src/features/tournaments/components/tournament-card.tsx @@ -58,7 +58,7 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => { {tournament.name} - {(tournament.first_place || tournament.second_place || tournament.third_place) && ( + {((tournament.first_place || tournament.second_place || tournament.third_place) && !tournament.regional) && ( {tournament.first_place && ( { return ( - + {tournament.regional && ( + }> + Regional tournaments are a work in progress. Some features might not work as expected. + + )} + {!tournament.regional && } { const criteria = badge.criteria; - const stats = await pb.collection("player_stats").getFirstListItem( + const stats = await pb.collection("player_mainline_stats").getFirstListItem( `player_id = "${playerId}"` ).catch(() => null); @@ -103,8 +103,8 @@ export function createBadgesService(pb: PocketBase) { if (criteria.overtime_matches !== undefined || criteria.overtime_wins !== undefined) { const matches = await pb.collection("matches").getFullList({ - filter: `(home.players.id ?~ "${playerId}" || away.players.id ?~ "${playerId}") && status = "ended" && ot_count > 0`, - expand: 'home,away,home.players,away.players', + filter: `(home.players.id ?~ "${playerId}" || away.players.id ?~ "${playerId}") && status = "ended" && ot_count > 0 && (tournament.regional = false || tournament.regional = null)`, + expand: 'tournament,home,away,home.players,away.players', }); if (criteria.overtime_matches !== undefined) { @@ -131,8 +131,8 @@ export function createBadgesService(pb: PocketBase) { if (criteria.margin_of_victory !== undefined) { const matches = await pb.collection("matches").getFullList({ - filter: `(home.players.id ?~ "${playerId}" || away.players.id ?~ "${playerId}") && status = "ended"`, - expand: 'home,away,home.players,away.players', + filter: `(home.players.id ?~ "${playerId}" || away.players.id ?~ "${playerId}") && status = "ended" && (tournament.regional = false || tournament.regional = null)`, + expand: 'tournament,home,away,home.players,away.players', }); const bigWins = matches.filter(m => { @@ -159,7 +159,7 @@ export function createBadgesService(pb: PocketBase) { const criteria = badge.criteria; const matches = await pb.collection("matches").getFullList({ - filter: `(home.players.id ?~ "${playerId}" || away.players.id ?~ "${playerId}") && status = "ended"`, + filter: `(home.players.id ?~ "${playerId}" || away.players.id ?~ "${playerId}") && status = "ended" && (tournament.regional = false || tournament.regional = null)`, expand: 'tournament,home,away,home.players,away.players', }); @@ -209,8 +209,8 @@ export function createBadgesService(pb: PocketBase) { for (const tournamentId of tournamentIds) { const tournamentMatches = await pb.collection("matches").getFullList({ - filter: `tournament = "${tournamentId}" && status = "ended"`, - expand: 'home,away,home.players,away.players', + filter: `tournament = "${tournamentId}" && status = "ended" && (tournament.regional = false || tournament.regional = null)`, + expand: 'tournament,home,away,home.players,away.players', }); const winnersMatches = tournamentMatches.filter(m => !m.is_losers_bracket); @@ -241,8 +241,8 @@ export function createBadgesService(pb: PocketBase) { for (const tournamentId of tournamentIds) { const tournamentMatches = await pb.collection("matches").getFullList({ - filter: `tournament = "${tournamentId}" && status = "ended"`, - expand: 'home,away,home.players,away.players', + filter: `tournament = "${tournamentId}" && status = "ended" && (tournament.regional = false || tournament.regional = null)`, + expand: 'tournament,home,away,home.players,away.players', }); if (criteria.placement === 2) { @@ -293,6 +293,7 @@ export function createBadgesService(pb: PocketBase) { if (criteria.tournament_record !== undefined) { const tournaments = await pb.collection("tournaments").getFullList({ + filter: 'regional = false || regional = null', sort: 'start_time', }); @@ -344,6 +345,7 @@ export function createBadgesService(pb: PocketBase) { if (criteria.consecutive_wins !== undefined) { const tournaments = await pb.collection("tournaments").getFullList({ + filter: 'regional = false || regional = null', sort: 'start_time', }); diff --git a/src/lib/pocketbase/services/matches.ts b/src/lib/pocketbase/services/matches.ts index 8ce3943..9bcb325 100644 --- a/src/lib/pocketbase/services/matches.ts +++ b/src/lib/pocketbase/services/matches.ts @@ -32,7 +32,7 @@ export function createMatchesService(pb: PocketBase) { }, async createMatch(data: MatchInput): Promise { - logger.info("PocketBase | Creating match", data); + // logger.info("PocketBase | Creating match", data); const result = await pb.collection("matches").create(data); return result; }, @@ -92,23 +92,40 @@ export function createMatchesService(pb: PocketBase) { return []; } - const filterConditions: string[] = []; - player1TeamIds.forEach(team1Id => { - player2TeamIds.forEach(team2Id => { - filterConditions.push(`(home="${team1Id}" && away="${team2Id}")`); - filterConditions.push(`(home="${team2Id}" && away="${team1Id}")`); + const allTeamIds = [...new Set([...player1TeamIds, ...player2TeamIds])]; + const batchSize = 10; + const allMatches: any[] = []; + + for (let i = 0; i < allTeamIds.length; i += batchSize) { + const batch = allTeamIds.slice(i, i + batchSize); + const teamFilters = batch.map(id => `home="${id}" || away="${id}"`).join(' || '); + + const results = await pb.collection("matches").getFullList({ + filter: teamFilters, + expand: "tournament, home, away, home.players, away.players", + sort: "-created", }); - }); - const filter = filterConditions.join(" || "); + allMatches.push(...results); + } - const results = await pb.collection("matches").getFullList({ - filter, - expand: "tournament, home, away, home.players, away.players", - sort: "-created", - }); + const uniqueMatches = Array.from( + new Map(allMatches.map(m => [m.id, m])).values() + ); - return results.map(match => transformMatch(match)); + return uniqueMatches + .filter(match => { + const homeTeamId = typeof match.home === 'string' ? match.home : match.home?.id; + const awayTeamId = typeof match.away === 'string' ? match.away : match.away?.id; + + const player1InHome = player1TeamIds.includes(homeTeamId); + const player1InAway = player1TeamIds.includes(awayTeamId); + const player2InHome = player2TeamIds.includes(homeTeamId); + const player2InAway = player2TeamIds.includes(awayTeamId); + + return (player1InHome && player2InAway) || (player1InAway && player2InHome); + }) + .map(match => transformMatch(match)); }, async getMatchesBetweenTeams(team1Id: string, team2Id: string): Promise { diff --git a/src/lib/pocketbase/services/players.ts b/src/lib/pocketbase/services/players.ts index 89a720d..2d4c638 100644 --- a/src/lib/pocketbase/services/players.ts +++ b/src/lib/pocketbase/services/players.ts @@ -8,7 +8,6 @@ import type { 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"; export function createPlayersService(pb: PocketBase) { return { @@ -65,9 +64,15 @@ export function createPlayersService(pb: PocketBase) { return result.map(transformPlayer); }, - async getPlayerStats(playerId: string): Promise { + async getPlayerStats(playerId: string, viewType: 'all' | 'mainline' | 'regional' = 'all'): Promise { try { - const result = await pb.collection("player_stats").getFirstListItem( + const collectionMap = { + all: 'player_stats', + mainline: 'player_mainline_stats', + regional: 'player_regional_stats', + }; + + const result = await pb.collection(collectionMap[viewType]).getFirstListItem( `player_id = "${playerId}"` ); return result; @@ -90,8 +95,14 @@ export function createPlayersService(pb: PocketBase) { } }, - async getAllPlayerStats(): Promise { - const result = await pb.collection("player_stats").getFullList({ + async getAllPlayerStats(viewType: 'all' | 'mainline' | 'regional' = 'all'): Promise { + const collectionMap = { + all: 'player_stats', + mainline: 'player_mainline_stats', + regional: 'player_regional_stats', + }; + + const result = await pb.collection(collectionMap[viewType]).getFullList({ sort: "-win_percentage,-total_cups_made", }); return result; diff --git a/src/lib/pocketbase/services/tournaments.ts b/src/lib/pocketbase/services/tournaments.ts index fe483dd..592a0e3 100644 --- a/src/lib/pocketbase/services/tournaments.ts +++ b/src/lib/pocketbase/services/tournaments.ts @@ -34,7 +34,7 @@ export function createTournamentsService(pb: PocketBase) { .getFirstListItem('', { expand: "teams, teams.players, matches, matches.tournament, matches.home, matches.away, matches.home.players, matches.away.players", - sort: "-created", + sort: "-start_time", } ); @@ -52,7 +52,7 @@ export function createTournamentsService(pb: PocketBase) { .collection("tournaments") .getFullList({ expand: "teams,teams.players,matches", - sort: "-created", + sort: "-start_time", }); const tournamentsWithStats = await Promise.all(result.map(async (tournament) => { diff --git a/src/lib/pocketbase/util/transform-types.ts b/src/lib/pocketbase/util/transform-types.ts index 03c583d..900de8f 100644 --- a/src/lib/pocketbase/util/transform-types.ts +++ b/src/lib/pocketbase/util/transform-types.ts @@ -1,4 +1,3 @@ -import { Reaction } from "@/features/matches/server"; import { Match } from "@/features/matches/types"; import { Player, PlayerInfo } from "@/features/players/types"; import { Team, TeamInfo } from "@/features/teams/types"; @@ -25,7 +24,8 @@ export function transformTeamInfo(record: any): TeamInfo { primary_color: record.primary_color, accent_color: record.accent_color, players, - logo: record.logo + logo: record.logo, + private: record.private || false, }; } @@ -107,6 +107,7 @@ export const transformTournamentInfo = (record: any): TournamentInfo => { end_time: record.end_time, logo: record.logo, glitch_logo: record.glitch_logo, + regional: record.regional || false, first_place, second_place, third_place, @@ -116,6 +117,7 @@ export const transformTournamentInfo = (record: any): TournamentInfo => { export function transformPlayer(record: any): Player { const teams = record.expand?.teams + ?.filter((team: any) => !team.private) ?.sort((a: any, b: any) => new Date(a.created) < new Date(b.created) ? -1 : 0 ) @@ -135,13 +137,6 @@ export function transformPlayer(record: any): Player { export function transformFreeAgent(record: any) { const player = record.expand?.player ? transformPlayerInfo(record.expand.player) : undefined; - const tournaments = - record.expand?.tournaments - ?.sort((a: any, b: any) => - new Date(a.created!) < new Date(b.created!) ? -1 : 0 - ) - ?.map(transformTournamentInfo) ?? []; - return { id: record.id as string, phone: record.phone as string, @@ -180,6 +175,7 @@ export function transformTeam(record: any): Team { updated: record.updated, players, tournaments, + private: record.private || false, }; } @@ -264,6 +260,7 @@ export function transformTournament(record: any, isAdmin: boolean = false): Tour end_time: record.end_time, created: record.created, updated: record.updated, + regional: record.regional || false, teams, matches, first_place, diff --git a/test.js b/test.js new file mode 100644 index 0000000..739a63c --- /dev/null +++ b/test.js @@ -0,0 +1,269 @@ +import PocketBase from "pocketbase"; +import * as xlsx from "xlsx"; +import { nanoid } from "nanoid"; + +import { createTeamsService } from "./src/lib/pocketbase/services/teams.ts"; +import { createPlayersService } from "./src/lib/pocketbase/services/players.ts"; +import { createMatchesService } from "./src/lib/pocketbase/services/matches.ts"; +import { createTournamentsService } from "./src/lib/pocketbase/services/tournaments.ts"; + +const POCKETBASE_URL = "http://127.0.0.1:8090"; +const EXCEL_FILE_PATH = "./Teams-2.xlsx"; + +const ADMIN_EMAIL = "kyle.yohler@gmail.com"; +const ADMIN_PASSWORD = "xj44aqz9CWrNNM0o"; + +// --- Helpers --- +async function createPlayerIfMissing(playersService, nameColumn, idColumn) { + const playerId = idColumn?.trim(); + if (playerId) return playerId; + + let firstName, lastName; + if (!nameColumn || !nameColumn.trim()) { + firstName = `Player_${nanoid(4)}`; + lastName = "(Regional)"; + } else { + const parts = nameColumn.trim().split(" "); + firstName = parts[0]; + lastName = parts[1] || "(Regional)"; + } + + const newPlayer = await playersService.createPlayer({ first_name: firstName, last_name: lastName }); + return newPlayer.id; +} + +async function handleTeamsSheet(rows, teamsService, playersService, pb, tournamentIdMap = {}) { + console.log(`📥 Importing ${rows.length} teams...`); + const teamIdMap = {}; // spreadsheet ID -> PocketBase ID + + for (const [i, row] of rows.entries()) { + try { + const spreadsheetTeamId = row["ID"]?.toString().trim(); + if (!spreadsheetTeamId) { + console.warn(`⚠️ [${i + 1}] Team row missing spreadsheet ID, skipping.`); + continue; + } + + const p1Id = await createPlayerIfMissing(playersService, row["P1 Name"], row["P1 ID"]); + const p2Id = await createPlayerIfMissing(playersService, row["P2 Name"], row["P2 ID"]); + + let name = row["Name"]?.trim(); + if (!name) { + const p1First = row["P1 Name"]?.split(" ")[0] || "Player1"; + const p2First = row["P2 Name"]?.split(" ")[0] || "Player2"; + name = `${p1First} and ${p2First}`; + console.warn(`⚠️ [${i + 1}] No team name found. Using generated name: ${name}`); + } + + const existing = await pb.collection("teams").getFullList({ + filter: `name = "${name}"`, + fields: "id", + }); + + if (existing.length > 0) { + console.log(`ℹ️ [${i + 1}] Team "${name}" already exists, skipping.`); + teamIdMap[spreadsheetTeamId] = existing[0].id; + continue; + } + + // If there's a tournament for this team, get its PB ID + const tournamentSpreadsheetId = row["Tournament ID"]?.toString().trim(); + const tournamentId = tournamentSpreadsheetId ? tournamentIdMap[tournamentSpreadsheetId] : undefined; + + const teamInput = { + name, + primary_color: row.primary_color || "", + accent_color: row.accent_color || "", + logo: row.logo || "", + players: [p1Id, p2Id], + tournament: tournamentId, // single tournament relation, + private: true + }; + + const team = await teamsService.createTeam(teamInput); + teamIdMap[spreadsheetTeamId] = team.id; + + console.log(`✅ [${i + 1}] Created team: ${team.name} with players: ${[p1Id, p2Id].join(", ")}`); + + // Add the team to the tournament's "teams" relation + if (tournamentId) { + await pb.collection("tournaments").update(tournamentId, { + "teams+": [team.id], + }); + console.log(`✅ Added team "${team.name}" to tournament ${tournamentId}`); + } + } catch (err) { + console.error(`❌ [${i + 1}] Failed to create team: ${err.message}`); + } + } + + return teamIdMap; +} + + +async function handleTournamentSheet(rows, tournamentsService, teamIdMap, pb) { + console.log(`📥 Importing ${rows.length} tournaments...`); + const tournamentIdMap = {}; + const validFormats = ["double_elim", "single_elim", "groups", "swiss", "swiss_bracket"]; + + for (const [i, row] of rows.entries()) { + try { + const spreadsheetId = row["ID"]?.toString().trim(); + if (!spreadsheetId) { + console.warn(`⚠️ [${i + 1}] Tournament missing spreadsheet ID, skipping.`); + continue; + } + + if (!row["Name"]) { + console.warn(`⚠️ [${i + 1}] Tournament name missing, skipping.`); + continue; + } + + const format = validFormats.includes(row["Format"]) ? row["Format"] : "double_elim"; + + // Convert start_time to ISO datetime string + let startTime = null; + if (row["Start Time"]) { + try { + startTime = new Date(row["Start Time"]).toISOString(); + } catch (e) { + console.warn(`⚠️ [${i + 1}] Invalid start time format, using null`); + } + } + + const tournamentInput = { + name: row["Name"], + start_time: startTime, + format, + regional: true, + teams: Object.values(teamIdMap), // Add all created teams + }; + + const tournament = await tournamentsService.createTournament(tournamentInput); + tournamentIdMap[spreadsheetId] = tournament.id; + + console.log(`✅ [${i + 1}] Created tournament: ${tournament.name} with ${Object.values(teamIdMap).length} teams`); + } catch (err) { + console.error(`❌ [${i + 1}] Failed to create tournament: ${err.message}`); + } + } + + return tournamentIdMap; +} + + +async function handleMatchesSheet(rows, matchesService, teamIdMap, tournamentIdMap, pb) { + console.log(`📥 Importing ${rows.length} matches...`); + + const tournamentMatchesMap = {}; + + for (const [i, row] of rows.entries()) { + try { + const homeId = teamIdMap[row["Home ID"]]; + const awayId = teamIdMap[row["Away ID"]]; + const tournamentId = tournamentIdMap[row["Tournament ID"]]; + + if (!homeId || !awayId || !tournamentId) { + console.warn(`⚠️ [${i + 1}] Could not find mapping for Home, Away, or Tournament, skipping.`); + continue; + } + + // --- Ensure the teams are linked to the tournament --- + for (const teamId of [homeId, awayId]) { + const team = await pb.collection("teams").getOne(teamId, { fields: "tournaments" }); + const tournaments = team.tournaments || []; + if (!tournaments.includes(tournamentId)) { + // Add tournament to team + await pb.collection("teams").update(teamId, { "tournaments+": [tournamentId] }); + // Add team to tournament + await pb.collection("tournaments").update(tournamentId, { "teams+": [teamId] }); + console.log(`✅ Linked team ${team.name} to tournament ${tournamentId}`); + } + } + + // --- Create match --- + const data = { + tournament: tournamentId, + home: homeId, + away: awayId, + home_cups: Number(row["Home cups"] || 0), + away_cups: Number(row["Away cups"] || 0), + status: "ended", + lid: i+1 + }; + + const match = await matchesService.createMatch(data); + console.log(`✅ [${i + 1}] Created match ID: ${match.id}`); + + if (!tournamentMatchesMap[tournamentId]) tournamentMatchesMap[tournamentId] = []; + tournamentMatchesMap[tournamentId].push(match.id); + } catch (err) { + console.error(`❌ [${i + 1}] Failed to create match: ${err.message}`); + } + } + + // Update each tournament with the created match IDs + for (const [tournamentId, matchIds] of Object.entries(tournamentMatchesMap)) { + try { + await pb.collection("tournaments").update(tournamentId, { "matches+": matchIds }); + console.log(`✅ Updated tournament ${tournamentId} with ${matchIds.length} matches`); + } catch (err) { + console.error(`❌ Failed to update tournament ${tournamentId} with matches: ${err.message}`); + } + } +} + + +// --- Main Import --- +export async function importExcel() { + const pb = new PocketBase(POCKETBASE_URL); + await pb.admins.authWithPassword(ADMIN_EMAIL, ADMIN_PASSWORD); + + const teamsService = createTeamsService(pb); + const playersService = createPlayersService(pb); + const tournamentsService = createTournamentsService(pb); + const matchesService = createMatchesService(pb); + + const workbook = xlsx.readFile(EXCEL_FILE_PATH); + + let teamIdMap = {}; + let tournamentIdMap = {}; + + // Process sheets in correct order: Tournaments -> Teams -> Matches + const sheetOrder = ["tournament", "tournaments", "teams", "matches"]; + const processedSheets = new Set(); + + for (const sheetNamePattern of sheetOrder) { + for (const sheetName of workbook.SheetNames) { + if (processedSheets.has(sheetName)) continue; + if (sheetName.toLowerCase() !== sheetNamePattern) continue; + + const worksheet = workbook.Sheets[sheetName]; + const rows = xlsx.utils.sheet_to_json(worksheet); + + console.log(`\n📘 Processing sheet: ${sheetName}`); + + switch (sheetName.toLowerCase()) { + case "teams": + teamIdMap = await handleTeamsSheet(rows, teamsService, playersService, pb, tournamentIdMap); + break; + case "tournament": + case "tournaments": + tournamentIdMap = await handleTournamentSheet(rows, tournamentsService, teamIdMap, pb); + break; + case "matches": + await handleMatchesSheet(rows, matchesService, teamIdMap, tournamentIdMap, pb); + break; + default: + console.log(`⚠️ No handler found for sheet '${sheetName}', skipping.`); + } + + processedSheets.add(sheetName); + } + } + + console.log("\n🎉 All sheets imported successfully!"); +} + +// --- Run --- +importExcel().catch(console.error); \ No newline at end of file