From 470b4ef99cb2385ebef002658c98aea173942bbc Mon Sep 17 00:00:00 2001 From: yohlo Date: Thu, 16 Oct 2025 09:12:11 -0500 Subject: [PATCH] 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 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.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