diff --git a/Teams-2.xlsx b/Teams-2.xlsx new file mode 100644 index 0000000..9012367 Binary files /dev/null and b/Teams-2.xlsx differ diff --git a/bun.lock b/bun.lock index 75b36f5..3bd1fcb 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", @@ -44,6 +46,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", }, @@ -336,6 +339,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 +355,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 +369,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=="], @@ -512,6 +531,8 @@ "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + "adler-32": ["adler-32@1.3.1", "", {}, "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A=="], + "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "ansis": ["ansis@4.1.0", "", {}, "sha512-BGcItUBWSMRgOCe+SVZJ+S7yTRG0eGt9cXAHev72yuGcY23hnLA7Bky5L/xLyPINoSN95geovfBkqoTlNZYa7w=="], @@ -544,6 +565,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=="], @@ -562,6 +585,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001745", "", {}, "sha512-ywt6i8FzvdgrrrGbr1jZVObnVv6adj+0if2/omv9cmR2oiZs30zL4DIyaptKcbOrBdOIc74QTMoJvSE2QHh5UQ=="], + "cfb": ["cfb@1.2.2", "", { "dependencies": { "adler-32": "~1.3.0", "crc-32": "~1.2.0" } }, "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA=="], + "cheerio": ["cheerio@1.1.2", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.0.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.12.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg=="], "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], @@ -572,6 +597,8 @@ "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], + "codepage": ["codepage@1.15.0", "", {}, "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA=="], + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], @@ -584,6 +611,8 @@ "core-js-pure": ["core-js-pure@3.45.1", "", {}, "sha512-OHnWFKgTUshEU8MK+lOs1H8kC8GkTi9Z1tvNkxrCcw9wl3MJIO7q2ld77wjWn4/xuGrVu2X+nME1iIIPBSdyEQ=="], + "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="], + "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="], "cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="], @@ -712,6 +741,8 @@ "form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="], + "frac": ["frac@1.1.2", "", {}, "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA=="], + "fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="], "framer-motion": ["framer-motion@12.23.22", "", { "dependencies": { "motion-dom": "^12.23.21", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-ZgGvdxXCw55ZYvhoZChTlG6pUuehecgvEAJz0BHoC5pQKW1EC5xf1Mul1ej5+ai+pVY0pylyFfdl45qnM1/GsA=="], @@ -1098,6 +1129,8 @@ "srvx": ["srvx@0.8.7", "", { "dependencies": { "cookie-es": "^2.0.0" }, "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-g3+15LlwVOGL2QpoTPZlvRjg+9a5Tx/69CatXjFP6txvhIaW2FmGyzJfb8yft5wyfGddvJmP/Yx+e/uNDMRSLQ=="], + "ssf": ["ssf@0.11.2", "", { "dependencies": { "frac": "~1.1.2" } }, "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g=="], + "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], "sugarss": ["sugarss@5.0.1", "", { "peerDependencies": { "postcss": "^8.3.3" } }, "sha512-ctS5RYCBVvPoZAnzIaX5QSShK8ZiZxD5HUqSxlusvEMC+QZQIPCPOIJg6aceFX+K2rf4+SH89eu++h1Zmsr2nw=="], @@ -1166,6 +1199,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 +1225,14 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "wmf": ["wmf@1.0.2", "", {}, "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw=="], + + "word": ["word@0.3.0", "", {}, "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA=="], + + "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=="], + + "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 0334e71..9469827 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", @@ -51,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/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/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/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/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/app/routes/_authed/stats.tsx b/src/app/routes/_authed/stats.tsx index 83c1991..030bf57 100644 --- a/src/app/routes/_authed/stats.tsx +++ b/src/app/routes/_authed/stats.tsx @@ -1,15 +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, 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, @@ -17,12 +21,62 @@ export const Route = createFileRoute("/_authed/stats")({ header: { title: "Player Stats" }, - refresh: [playerQueries.allStats().queryKey], + refresh: [playerQueries.allStats().queryKey], }), }); function Stats() { - return }> - - ; + const [viewType, setViewType] = useState<'all' | 'mainline' | 'regional'>('all'); + const deferredViewType = useDeferredValue(viewType); + const isStale = viewType !== deferredViewType; + + return ( + + + Stats + Head to Head + + + + + + + + + + + + }> + + + + + + + + + }> + + + + + ); } 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/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/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/components/stats-overview.tsx b/src/components/stats-overview.tsx index 5ca7688..57f5bb3 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 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 }, ]; return ( @@ -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/components/swipeable-tabs.tsx b/src/components/swipeable-tabs.tsx index c2e3a3d..08428d9 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)", }} @@ -205,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/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/core/components/providers.tsx b/src/features/core/components/providers.tsx index 7eea703..b2e85c8 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/matches/components/match-card.tsx b/src/features/matches/components/match-card.tsx index 37cfc16..827942c 100644 --- a/src/features/matches/components/match-card.tsx +++ b/src/features/matches/components/match-card.tsx @@ -1,20 +1,26 @@ -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"; + const hasPrivate = match.home?.private || match.away?.private; const handleHomeTeamClick = (e: React.MouseEvent) => { e.stopPropagation(); @@ -30,15 +36,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} + + {!match.tournament.regional && ( + <> + - + + Round {match.round + 1} + {match.is_losers_bracket && " (Losers)"} + + + )} + + {match.home && match.away && !hideH2H && !hasPrivate && ( + + + + + + + + + )} @@ -205,7 +256,16 @@ const MatchCard = ({ match }: MatchCardProps) => { - + + {match.home && match.away && !hideH2H && h2hSheet.isOpen && ( + + + + )} + ); }; diff --git a/src/features/matches/components/match-list.tsx b/src/features/matches/components/match-list.tsx index d441557..22522af 100644 --- a/src/features/matches/components/match-list.tsx +++ b/src/features/matches/components/match-list.tsx @@ -1,12 +1,13 @@ -import { Stack } from "@mantine/core"; +import { Stack, Text } from "@mantine/core"; import { Match } from "../types"; 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) || []; @@ -15,13 +16,20 @@ const MatchList = ({ matches }: MatchListProps) => { return undefined; } + const isRegional = filteredMatches[0]?.tournament?.regional; + return ( + {isRegional && ( + + Matches for regionals are unordered + + )} {filteredMatches.map((match, index) => (
- +
))}
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..7eb73ec --- /dev/null +++ b/src/features/matches/components/team-head-to-head-sheet.tsx @@ -0,0 +1,217 @@ +import { Stack, Text, Group, Box, Divider, Paper } from "@mantine/core"; +import { TeamInfo } from "@/features/teams/types"; +import { useTeamHeadToHead } from "../queries"; +import { useMemo, useEffect, useState, Suspense } from "react"; +import { CrownIcon } from "@phosphor-icons/react"; +import MatchList from "./match-list"; +import TeamHeadToHeadSkeleton from "./team-head-to-head-skeleton"; + +interface TeamHeadToHeadSheetProps { + team1: TeamInfo; + team2: TeamInfo; + isOpen?: boolean; +} + +const TeamHeadToHeadContent = ({ 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) { + 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; + let team1TotalWinMargin = 0; + let team2TotalWinMargin = 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++; + team1TotalWinMargin += (team1Cups - team2Cups); + } else if (team2Cups > team1Cups) { + team2Wins++; + team2TotalWinMargin += (team2Cups - team1Cups); + } + + team1CupsFor += team1Cups; + team2CupsFor += team2Cups; + team1CupsAgainst += team2Cups; + team2CupsAgainst += team1Cups; + }); + + const team1AvgMargin = team1Wins > 0 + ? team1TotalWinMargin / team1Wins + : 0; + const team2AvgMargin = team2Wins > 0 + ? team2TotalWinMargin / 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 totalMatches = 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 && totalMatches > 0 && ( + + Series is tied + + )} + + + + + Stats Comparison + + + + + + {stats.team1CupsFor} + cups + + Total Cups + + cups + {stats.team2CupsFor} + + + + + + + + {totalMatches > 0 ? (stats.team1CupsFor / totalMatches).toFixed(1) : '0.0'} + + avg + + Avg Cups/Match + + avg + + {totalMatches > 0 ? (stats.team2CupsFor / totalMatches).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 ({totalMatches} match{totalMatches !== 1 ? 'es' : ''}) + + + + ); +}; + +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 new file mode 100644 index 0000000..81f5e0c --- /dev/null +++ b/src/features/matches/queries.ts @@ -0,0 +1,30 @@ +import { useServerSuspenseQuery } 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, enabled = true) => + useServerSuspenseQuery({ + ...matchQueries.headToHeadTeams(team1Id, team2Id), + enabled, + }); + +export const usePlayerHeadToHead = (player1Id: string, player2Id: string, enabled = true) => + useServerSuspenseQuery({ + ...matchQueries.headToHeadPlayers(player1Id, player2Id), + enabled, + }); 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/features/players/components/league-head-to-head.tsx b/src/features/players/components/league-head-to-head.tsx new file mode 100644 index 0000000..f8b6b49 --- /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 && "Select first player"} + {activeStep === 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..f5e0fea --- /dev/null +++ b/src/features/players/components/player-head-to-head-sheet.tsx @@ -0,0 +1,279 @@ +import { Stack, Text, Group, Box, Divider, Paper } from "@mantine/core"; +import { usePlayerHeadToHead } from "@/features/matches/queries"; +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; + player1Name: string; + player2Id: string; + player2Name: string; + isOpen?: boolean; +} + +const PlayerHeadToHeadContent = ({ + 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 totalMatches = 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 && totalMatches > 0 && ( + + Series is tied + + )} + + + + + + Stats Comparison + + + + + + + + {stats.player1CupsFor} + + + cups + + + + Total Cups + + + + cups + + + {stats.player2CupsFor} + + + + + + + + + {totalMatches > 0 + ? (stats.player1CupsFor / totalMatches).toFixed(1) + : "0.0"} + + + avg + + + + Avg Cups/Match + + + + avg + + + {totalMatches > 0 + ? (stats.player2CupsFor / totalMatches).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 ({totalMatches}) + + + + + ); +}; + +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/features/players/components/player-stats-table-skeleton.tsx b/src/features/players/components/player-stats-table-skeleton.tsx index 013c768..3d02616 100644 --- a/src/features/players/components/player-stats-table-skeleton.tsx +++ b/src/features/players/components/player-stats-table-skeleton.tsx @@ -5,68 +5,86 @@ import { Container, Divider, Skeleton, + ScrollArea, } from "@mantine/core"; const PlayerListItemSkeleton = () => { return ( - - - - - - - - - - - + + + + + + + + + + + + - + - + - + - + - + + + + + + + + + + + + + - + - - + + ); }; -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 f16d95b..f2b9fba 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, 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,62 @@ const PlayerListItem = memo(({ stat, onPlayerClick, mmr }: PlayerListItemProps) }, }} > - - - - - - - {stat.player_name} - - - {stat.matches} matches - - - {stat.tournaments} tournaments - + + + + + + {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)}% - - - - - AVG - - - {stat.avg_cups_per_match.toFixed(1)} - - - - - CF - - - {stat.total_cups_made} - - - - - CA - - - {stat.total_cups_against} - - - - - - + + ); }); -PlayerListItem.displayName = 'PlayerListItem'; +interface PlayerStatsTableProps { + viewType?: 'all' | 'mainline' | 'regional'; +} -const PlayerStatsTable = () => { - const { data: playerStats } = useAllPlayerStats(); +const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => { + const { data: playerStats } = useAllPlayerStats(viewType); const navigate = useNavigate(); const [search, setSearch] = useState(""); const [sortConfig, setSortConfig] = useState({ @@ -152,6 +155,64 @@ const PlayerStatsTable = () => { direction: "desc", }); + const viewportsRef = useRef>(new Set()); + const scrollHandlersRef = useRef void>>(new Map()); + 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; + + if (!scrollLeaderRef.current) { + scrollLeaderRef.current = target; + } + + if (scrollLeaderRef.current !== target) { + return; + } + + const scrollLeft = target.scrollLeft; + + viewportsRef.current.forEach((vp) => { + if (vp !== target && Math.abs(vp.scrollLeft - scrollLeft) > 0.5) { + vp.scrollLeft = scrollLeft; + } + }); + + 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); + }, []); + + const handleUnregisterViewport = useCallback((viewport: HTMLDivElement) => { + viewportsRef.current.delete(viewport); + + const handler = scrollHandlersRef.current.get(viewport); + if (handler) { + viewport.removeEventListener('scroll', handler); + viewport.removeEventListener('touchstart', handler); + viewport.removeEventListener('mousedown', handler); + scrollHandlersRef.current.delete(viewport); + } + }, []); + const calculateMMR = (stat: PlayerStats): number => { if (stat.matches === 0) return 0; @@ -235,22 +296,23 @@ const PlayerStatsTable = () => { if (playerStats.length === 0) { return ( - - - - - - - No Stats Available - - - + + + + + + No Stats Available + + ); } 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 }} @@ -303,6 +363,48 @@ const PlayerStatsTable = () => { + + Stat Abbreviations: + + + • M: Matches + + + • T: Tournaments + + + • MMR: Matchmaking Rating + + + • W: Wins + + + • L: Losses + + + • W%: Win Percentage + + + • AWM: Average Win Margin + + + • ALM: Average Loss Margin + + + • AC: Average Cups Per Match + + + • ACA: Average Cups Against + + + • CF: Cups For + + + • CA: Cups Against + + + + MMR Calculation: @@ -337,6 +439,8 @@ const PlayerStatsTable = () => { stat={stat} onPlayerClick={handlePlayerClick} mmr={stat.mmr} + onRegisterViewport={handleRegisterViewport} + onUnregisterViewport={handleUnregisterViewport} /> {index < filteredAndSortedStats.length - 1 && } 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/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/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/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..eb61288 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'], @@ -7,9 +7,10 @@ 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'], }; export const playerQueries = { @@ -33,18 +34,22 @@ 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), queryFn: async () => await getPlayerMatches({ data: id }) }), + activity: () => ({ + queryKey: playerKeys.activity, + queryFn: async () => await getPlayersActivity() + }), }; export const useMe = () => { @@ -79,14 +84,17 @@ 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)); 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..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() @@ -161,3 +165,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/teams/components/team-list.tsx b/src/features/teams/components/team-list.tsx index e119301..2c006e0 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} ))} @@ -46,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}` }); } }, @@ -91,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 && } = React.memo(({ ); }); -TeamSelectionView.displayName = 'TeamSelectionView'; - export default TeamSelectionView; \ No newline at end of file diff --git a/src/features/tournaments/types.ts b/src/features/tournaments/types.ts index e684645..05a235b 100644 --- a/src/features/tournaments/types.ts +++ b/src/features/tournaments/types.ts @@ -29,6 +29,7 @@ export interface TournamentInfo { first_place?: TeamInfo; second_place?: TeamInfo; third_place?: TeamInfo; + regional?: boolean; } export interface Tournament { @@ -50,6 +51,7 @@ export interface Tournament { second_place?: TeamInfo; third_place?: TeamInfo; team_stats?: TournamentTeamStats[]; + regional?: boolean; } export const tournamentInputSchema = z.object({ diff --git a/src/lib/pocketbase/services/badges.ts b/src/lib/pocketbase/services/badges.ts index 9597623..e4b5261 100644 --- a/src/lib/pocketbase/services/badges.ts +++ b/src/lib/pocketbase/services/badges.ts @@ -99,7 +99,7 @@ export function createBadgesService(pb: PocketBase) { async calculateMatchBadgeProgress(playerId: string, badge: Badge): Promise { 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); @@ -111,8 +111,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) { @@ -139,8 +139,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 => { @@ -167,7 +167,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', }); @@ -217,8 +217,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); @@ -249,8 +249,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) { @@ -301,6 +301,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', }); @@ -352,6 +353,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 d57721b..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; }, @@ -71,5 +71,75 @@ 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 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", + }); + + allMatches.push(...results); + } + + const uniqueMatches = Array.from( + new Map(allMatches.map(m => [m.id, m])).values() + ); + + 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 { + 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)); + }, }; } diff --git a/src/lib/pocketbase/services/players.ts b/src/lib/pocketbase/services/players.ts index 5fca6a1..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,15 +64,45 @@ export function createPlayersService(pb: PocketBase) { return result.map(transformPlayer); }, - async getPlayerStats(playerId: string): Promise { - const result = await pb.collection("player_stats").getFirstListItem( - `player_id = "${playerId}"` - ); - return result; + async getPlayerStats(playerId: string, viewType: 'all' | 'mainline' | 'regional' = 'all'): Promise { + try { + 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; + } 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 { - 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; @@ -148,5 +177,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/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 244fb72..2733d05 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 ) @@ -128,19 +130,13 @@ export function transformPlayer(record: any): Player { auth_id: record.auth_id, created: record.created, updated: record.updated, + last_activity: record.last_activity, teams, }; } 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, @@ -179,6 +175,7 @@ export function transformTeam(record: any): Team { updated: record.updated, players, tournaments, + private: record.private || false, }; } @@ -263,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/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; 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; 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 }; } }; 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; }); 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