Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22c282d8fa | ||
|
|
86cda294f9 | ||
|
|
9db3fa697d | ||
|
|
6565714ee4 | ||
|
|
1783f0e6bc | ||
|
|
0a7f4de321 | ||
|
|
e8b7647f3d | ||
|
|
0284b33e50 | ||
|
|
d3809b5805 | ||
|
|
a50f9b6644 | ||
|
|
f77b3a5c41 | ||
|
|
3b087850e7 | ||
|
|
19f61ac454 |
@@ -38,12 +38,14 @@
|
|||||||
"supertokens-web-js": "^0.15.0",
|
"supertokens-web-js": "^0.15.0",
|
||||||
"twilio": "^5.8.0",
|
"twilio": "^5.8.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"zod": "^4.0.15",
|
"zod": "^4.0.15",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.5.4",
|
"@types/node": "^22.5.4",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
@@ -614,6 +616,8 @@
|
|||||||
|
|
||||||
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
||||||
|
|
||||||
|
"@types/web-push": ["@types/web-push@3.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="],
|
||||||
|
|
||||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.3", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.2", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg=="],
|
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.3", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.2", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg=="],
|
||||||
|
|
||||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||||
@@ -632,6 +636,8 @@
|
|||||||
|
|
||||||
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
|
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
|
||||||
|
|
||||||
|
"asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="],
|
||||||
|
|
||||||
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
||||||
|
|
||||||
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
|
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
|
||||||
@@ -662,6 +668,8 @@
|
|||||||
|
|
||||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
|
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
|
||||||
|
|
||||||
|
"bn.js": ["bn.js@4.12.5", "", {}, "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ=="],
|
||||||
|
|
||||||
"brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="],
|
"brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="],
|
||||||
|
|
||||||
"browser-image-compression": ["browser-image-compression@2.0.2", "", { "dependencies": { "uzip": "0.20201231.0" } }, "sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw=="],
|
"browser-image-compression": ["browser-image-compression@2.0.2", "", { "dependencies": { "uzip": "0.20201231.0" } }, "sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw=="],
|
||||||
@@ -880,6 +888,8 @@
|
|||||||
|
|
||||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||||
|
|
||||||
|
"http_ece": ["http_ece@1.2.0", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="],
|
||||||
|
|
||||||
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
|
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
|
||||||
|
|
||||||
"idb": ["idb@7.1.1", "", {}, "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ=="],
|
"idb": ["idb@7.1.1", "", {}, "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ=="],
|
||||||
@@ -888,6 +898,8 @@
|
|||||||
|
|
||||||
"imask": ["imask@7.6.1", "", { "dependencies": { "@babel/runtime-corejs3": "^7.24.4" } }, "sha512-sJlIFM7eathUEMChTh9Mrfw/IgiWgJqBKq2VNbyXvBZ7ev/IlO6/KQTKlV/Fm+viQMLrFLG/zCuudrLIwgK2dg=="],
|
"imask": ["imask@7.6.1", "", { "dependencies": { "@babel/runtime-corejs3": "^7.24.4" } }, "sha512-sJlIFM7eathUEMChTh9Mrfw/IgiWgJqBKq2VNbyXvBZ7ev/IlO6/KQTKlV/Fm+viQMLrFLG/zCuudrLIwgK2dg=="],
|
||||||
|
|
||||||
|
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||||
|
|
||||||
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
|
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
|
||||||
|
|
||||||
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
|
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
|
||||||
@@ -1048,6 +1060,8 @@
|
|||||||
|
|
||||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||||
|
|
||||||
|
"minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
|
||||||
|
|
||||||
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||||
|
|
||||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||||
@@ -1238,6 +1252,8 @@
|
|||||||
|
|
||||||
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
|
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
|
||||||
|
|
||||||
|
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||||
|
|
||||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||||
|
|
||||||
"scmp": ["scmp@2.1.0", "", {}, "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q=="],
|
"scmp": ["scmp@2.1.0", "", {}, "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q=="],
|
||||||
@@ -1404,6 +1420,8 @@
|
|||||||
|
|
||||||
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
|
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
|
||||||
|
|
||||||
|
"web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="],
|
||||||
|
|
||||||
"webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="],
|
"webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="],
|
||||||
|
|
||||||
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
||||||
@@ -1706,6 +1724,8 @@
|
|||||||
|
|
||||||
"unplugin/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
|
"unplugin/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
|
||||||
|
|
||||||
|
"web-push/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||||
|
|
||||||
"@babel/helper-annotate-as-pure/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
"@babel/helper-annotate-as-pure/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||||
|
|
||||||
"@babel/helper-annotate-as-pure/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
"@babel/helper-annotate-as-pure/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||||
@@ -1924,6 +1944,8 @@
|
|||||||
|
|
||||||
"node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
"node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||||
|
|
||||||
|
"web-push/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||||
|
|
||||||
"@babel/helper-create-class-features-plugin/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
"@babel/helper-create-class-features-plugin/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||||
|
|
||||||
"@babel/helper-create-class-features-plugin/@babel/traverse/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
"@babel/helper-create-class-features-plugin/@babel/traverse/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# FLXN Replay — Design Spec (v1, locked)
|
||||||
|
|
||||||
|
**Status:** design locked, not yet built. The tournament recap feature: a **player-owned recap** and an **overall tournament recap**, viewable **retroactively** from any completed tournament. Content is shaped to render into a **shareable card layout** (the owner's friend is producing the SVG/PNG graphic template — we spec the payload, not the artwork).
|
||||||
|
|
||||||
|
**No new collection.** Everything below is derivable at read time from existing data — the same philosophy as the podium/placement derivation in `src/lib/pocketbase/util/transform-types.ts` (`transformTournament` computes `isComplete`, `first/second/third_place` purely from ended matches). Replay is a pair of server fns that compute a recap payload from data we already store.
|
||||||
|
|
||||||
|
## Availability
|
||||||
|
- A tournament has a Replay when it's **complete**: every non-bye match `status === "ended"` — reuse the exact `isComplete` derivation from `transformTournament` (`src/lib/pocketbase/util/transform-types.ts:218-219`).
|
||||||
|
- **Overall recap is public** (any authed user). **Player recap** exists for every enrolled player; the "View Replay" entry shows participants their own recap.
|
||||||
|
- Retroactive by construction: old completed tournaments get Replays for free since nothing is written at tournament end.
|
||||||
|
|
||||||
|
## Data sources (all existing)
|
||||||
|
- **Matches** — `status: "ended"`, `home_cups`/`away_cups`, `ot_count`, `start_time`/`end_time`, `home_seed`/`away_seed`, `round`, `lid`, `order`, `is_losers_bracket`, `reset`, `bye` (`src/features/matches/types.ts`, transformed at `src/lib/pocketbase/util/transform-types.ts:30`). `pbAdmin.getTournament(id)` already returns matches + teams expanded, plus `team_stats` from the `team_stats_per_tournament` view (`src/lib/pocketbase/services/tournaments.ts:21`).
|
||||||
|
- **Stats views** — `team_stats_per_tournament` gives per-team W/L, cups for/against, margins for this event (mapped onto `tournament.team_stats` in `transformTournament`). Global `player_stats` / `player_mainline_stats` / `player_regional_stats` views exist (`pb_migrations/1783926000_optimized_player_stats_views.js`) but are all-time; per-event player numbers come from the tournament's own matches (teams are 2-player, so a player's event record == their team's record).
|
||||||
|
- **Reactions** — `reactions` collection `{match, player, emoji}` (`src/lib/pocketbase/services/reactions.ts`). Currently only fetched per match; add one service method fetching all reactions for a tournament via filter `match.tournament = "<id>"`.
|
||||||
|
- **Predictions** — `computePredictionScore` (points, correct, `predictedChampionId`) in `src/features/predictions/utils.ts` and `getPredictionsLeaderboard` in `src/features/predictions/server.ts` give points / rank / called-the-champ directly.
|
||||||
|
- **Badges** — `badge_progress` (`earned` boolean, no per-tournament attribution) — see open questions.
|
||||||
|
|
||||||
|
## Player recap (content + derivation)
|
||||||
|
All from the completed tournament's ended matches involving the player's team (find the team by scanning `tournament.teams[].players` for the player id):
|
||||||
|
- **W–L + win %** — team's rows from `tournament.team_stats`, or count from matches.
|
||||||
|
- **Cups sunk / conceded** — `total_cups_made` / `total_cups_against` from `team_stats`.
|
||||||
|
- **Biggest blowout margin** — max `|home_cups - away_cups|` across their wins, with opponent.
|
||||||
|
- **Longest win streak** — sort their matches by `order` (fallback `end_time`), scan for the longest run of wins.
|
||||||
|
- **Nemesis** — the team that ended their tournament (opponent in their final loss; in double-elim that's their losers-bracket exit), or if undefeated/champion, worst H2H by aggregate cup differential.
|
||||||
|
- **Victim** — opponent with the biggest aggregate cup differential in the player's favor across H2H games.
|
||||||
|
- **OT games survived** — their ended matches with `ot_count >= 1`; show count and OT record.
|
||||||
|
- **Final placement + podium** — top 3 from the existing podium derivation; below that, label by exit round ("Eliminated in Match 12" / round name via `getMatchLabel`-style logic) rather than a fake numeric rank.
|
||||||
|
- **Partner chemistry** — teammate (the other id on the roster) + combined record; identical to team record, framed as "You and X went 4–1 together".
|
||||||
|
- **Prediction result** — points / rank / champion-call from the predictions leaderboard; omit the block if they didn't submit.
|
||||||
|
- **Most-reacted match** — their match with the most reaction rows; include the top emoji.
|
||||||
|
- **Badges earned this event** — see open questions; v1 approach below.
|
||||||
|
|
||||||
|
## Overall recap (content + derivation)
|
||||||
|
- **Champion + podium** — existing `first/second/third_place` derivation.
|
||||||
|
- **Biggest upset** — among ended matches where both `home_seed` and `away_seed` are set, the win with the largest (winner seed − loser seed) gap.
|
||||||
|
- **Highest-scoring match** — max `home_cups + away_cups`. **Closest** — min nonzero margin (tie-break: more total cups). **Longest** — max `ot_count` (tie-break: `end_time - start_time` when both set).
|
||||||
|
- **Most cups in one game (single team)** — max of `home_cups`/`away_cups` with team + match.
|
||||||
|
- **Totals** — Σ cups, count of ended matches, count with `ot_count >= 1` (+ total OT periods).
|
||||||
|
- **MVP** — best `team_stats` row by win %, then margin (cup differential); surfaced as the two players of that team (or the champion team — tunable).
|
||||||
|
- **Most-reacted moment** — match with most reactions overall + top emoji.
|
||||||
|
- **Prediction-pool winner** — top of `getPredictionsLeaderboard`; omit if nobody predicted.
|
||||||
|
- **Team count / attendance** — `tournament.teams.length` / unique players across rosters.
|
||||||
|
|
||||||
|
## Share-card layout
|
||||||
|
- The recap payload is a list of **discrete stat blocks** (`{ key, label, value, sublabel?, team?/player?, matchRef? }`) so the external SVG/PNG template can consume the same JSON the app renders — don't bake copy into components.
|
||||||
|
- In-app, render as a vertical stack of card sections (hero: placement/champion; then blocks), constrained to a portrait share-card aspect so what you see is what the graphic shows.
|
||||||
|
- v1 sharing: the friend's template consumes the payload out-of-band; an in-app "Share" (Web Share API / rendered PNG) is explicitly **not** in v1 scope — just keep the payload shape stable.
|
||||||
|
|
||||||
|
## Entry points
|
||||||
|
- `TournamentStats` (`src/features/tournaments/components/tournament-stats.tsx`) already renders the completed-tournament overview with `ListLink` rows ("View Bracket", "View Predictions" ~lines 178-196). Add **"View Replay"** there, gated on the same `isComplete` memo already computed in that file. It links to the overall recap; inside, a participant sees a "Your Replay" hero button to their own player recap.
|
||||||
|
|
||||||
|
## Reuses / dependencies
|
||||||
|
- `pbAdmin.getTournament` (one fetch covers matches, teams, team_stats), podium derivation, predictions scoring + leaderboard, reactions collection, `ListLink`, `SwipeableTabs`/sheet patterns, `useServerSuspenseQuery`.
|
||||||
|
- No dependency on any Wave 2/3 feature; ships standalone.
|
||||||
|
|
||||||
|
## Implementation notes for a fresh agent (build this cold)
|
||||||
|
Context: worktree `social` branch; changes are typically left uncommitted for review. Read `docs/flxn-replay.md` (this file) fully first, then:
|
||||||
|
- **No migration.** Replay writes nothing. Do not add a collection.
|
||||||
|
- **New feature dir:** `src/features/replay/{types.ts, server.ts, queries.ts, utils.ts, components/}`. Keep every derivation (streaks, nemesis/victim, upset, superlatives) as pure functions over `Match[]`/`TournamentTeamStats[]` in `utils.ts`, unit-testable like `src/features/predictions/utils.ts`.
|
||||||
|
- **Server fns:** two — `getTournamentRecap(tournamentId)` and `getPlayerRecap({tournamentId, playerId})` — following `createServerFn().validator(zod).middleware([...]).handler(...)` + `toServerResult`, with `.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])`; copy the shape of `getPredictionsLeaderboard` in `src/features/predictions/server.ts` (it already does getTournamentOrThrow + derive-from-matches). Resolve the caller via `pbAdmin.getPlayerByAuthId(context.userAuthId)`. Throw if the tournament isn't complete (reuse the `isComplete` logic; export it from a shared util rather than copy-pasting a third time — it already exists twice in `transform-types.ts`).
|
||||||
|
- **Reactions per tournament:** add `getReactionsForTournament(tournamentId)` to `src/lib/pocketbase/services/reactions.ts` — `getFullList({ filter: \`match.tournament="${id}"\` })` — and aggregate counts per match in `utils.ts`.
|
||||||
|
- **Prediction block:** call the existing prediction server logic (or its underlying service + `computePredictionScore`) inside the recap fn; don't re-implement scoring.
|
||||||
|
- **Badges block (v1):** fetch the player's `badge_progress` where `earned = true` and `updated` falls within `[tournament.start_time, tournament.end_time + 24h]` — attribution by timestamp. This is best-effort (see open questions); render nothing when empty.
|
||||||
|
- **Routes:** `src/app/routes/_authed/tournaments/$id.replay.tsx` (overall) and `src/app/routes/_authed/tournaments/$id.replay_.$playerId.tsx` (player), mirroring `$id.predictions.tsx` / `$id.predictions_.$playerId.tsx` exactly (same `createFileRoute` + loader header + Suspense pattern; note sub-routes use `$id`, only the profile route uses `$tournamentId`).
|
||||||
|
- **Client data:** `useServerSuspenseQuery` with a `replayKeys`/`replayQueries` module copying `src/features/predictions/queries.ts`. Data is immutable post-completion — no SSE wiring, no invalidation, long `staleTime` is fine.
|
||||||
|
- **Entry point:** in `tournament-stats.tsx`, add `{isComplete && <ListLink label="View Replay" to={\`/tournaments/${tournament.id}/replay\`} Icon={...} />}` next to the existing View Bracket/Predictions links. Skip it for `tournament.regional` (old regional data is flagged unreliable in that same file).
|
||||||
|
- **Design system:** Mantine 8 + app theme (`src/lib/mantine/mantine-provider.tsx`: `defaultRadius: "sm"`, primary = user accent, press feedback via the `flxn-press` activeClassName). The recap should feel like a highlight reel, not a table — but tasteful motion only (iOS-like easing, 120–350ms), accent color reserved for the hero stat per card, and match neighboring components rather than inventing a new look. Reuse `TeamAvatar`/`PlayerAvatar` in stat blocks.
|
||||||
|
- **Toasts:** none needed (read-only feature); errors surface via the route's error boundary.
|
||||||
|
|
||||||
|
## Tunables / open questions
|
||||||
|
- **Badge attribution** is the one shaky derivation: `badge_progress.earned` is a boolean with no event linkage, and retroactive/recomputed badges (`migrateBadgeProgress`) break timestamp inference. v1 = timestamp window (above); the clean fix is re-running badge criteria with/without this tournament's matches and diffing — decide if that's worth it before building.
|
||||||
|
- **Biggest upset without seeds:** group-stage matches (`round === -1`) and some formats lack `home_seed`/`away_seed`; v1 skips seedless matches. Fall back to group placement later?
|
||||||
|
- **Player recap visibility:** owner-only vs. public like `$id.predictions_.$playerId.tsx`? Default: viewable by any participant (it's all derivable from public match data anyway), but the entry point only advertises your own.
|
||||||
|
- **"OT games survived"** — count OT games played, or only OT wins? Default: played, with the win/loss split shown.
|
||||||
|
- **MVP definition** — best win % then margin (as specced) vs. simply the champions. Revisit after seeing real output.
|
||||||
|
- **Regional tournaments** — excluded in v1 (data quality); could be enabled per-tournament later.
|
||||||
|
- **Non-podium placement copy** — exit-round label vs. numeric rank; locked to label for v1.
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Free-Agent Matchmaking Board — Design Spec (v1, locked)
|
||||||
|
|
||||||
|
**Status:** design locked, not yet built. Extends the existing free-agent enrollment flow; the admin random-pairing flow stays as the fallback for anyone unmatched.
|
||||||
|
|
||||||
|
Players who sign up without a team ("free agents") can see each other — with stats and badges — and **claim a partner**. The claimed player must **confirm** (two-sided handshake, mirroring the score-report Confirm pattern). On mutual accept the app creates the team for them. The admin still arbitrates leftovers.
|
||||||
|
|
||||||
|
## What exists today (baseline)
|
||||||
|
- `free_agents` collection (`pbc_2929550049`): `{ player → players, tournament → tournaments, phone }`. Created in `pb_migrations/1758388728_created_free_agents.js`, tournament relation added in `1758402128_updated_free_agents.js`. No partner/status field, no claim concept.
|
||||||
|
- Service: `enrollFreeAgent` / `unenrollFreeAgent` / `getFreeAgents` in `src/lib/pocketbase/services/tournaments.ts` (~L189–216); `transformFreeAgent` in `src/lib/pocketbase/util/transform-types.ts` returns `{ id, phone, player }`.
|
||||||
|
- Server fns in `src/features/tournaments/server.ts`: `getFreeAgents` (L109, any authed user — note it currently returns phones for everyone), `enrollFreeAgent` (L116), `unenrollFreeAgent` (L131). All emit `{ type: "tournament", tournamentId }`.
|
||||||
|
- UI: `src/features/tournaments/components/upcoming-tournament/enroll-free-agent.tsx` (enroll sheet) and `enrolled-free-agent.tsx` (the pool view). **Regional agents can't see the pool at all** — the `isRegional` branch shows only your own card with "Partners will be randomly assigned". Non-regional agents see a plain name + phone list. No stats, no badges, no actions.
|
||||||
|
- Admin fallback: `src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsx` → `useGenerateRandomTeams` / `useConfirmTeamAssignments` → `generateRandomTeams` (L146) and `confirmTeamAssignments` (L326) in `src/features/tournaments/server.ts`. Confirm finds-or-creates a `private: true` team per pair, `enrollTeam`s it, then `unenrollFreeAgent`s both players.
|
||||||
|
- The two-sided confirm precedent: `reportMatchScore` / `confirmMatchScore` in `src/features/matches/server.ts` (~L828–928). Pending state lives **on the record itself** (`reported_by_team`, `reported_home_cups`, …), the other side confirms, and a cross-report with identical values auto-finalizes.
|
||||||
|
|
||||||
|
## The board
|
||||||
|
Replaces the plain list inside `enrolled-free-agent.tsx` for **both** regional and mainline tournaments (the regional visibility gate is relaxed — see below). Each agent renders as a card:
|
||||||
|
- `PlayerAvatar` + name.
|
||||||
|
- **Stats strip:** matches, win %, avg cups/match (from `PlayerStats` — `src/features/players/types.ts` L34).
|
||||||
|
- **Badges row:** the player's earned badges, compact (icon row, overflow "+N").
|
||||||
|
- **Action:** one of `Claim` / `Claimed — waiting` (yours, with Withdraw) / `Accept · Decline` (they claimed you) / a subtle "pending with someone else" state.
|
||||||
|
- Phone: keep today's behavior for mainline (copy-to-clipboard affordance); regionals stay phone-less (`enrollFreeAgent` is already called with `""` phone via the regional path, and admin-enroll at L737 passes `""`).
|
||||||
|
|
||||||
|
Fetch stats/badges in bulk — `getAllPlayerStats` (`src/features/players/server.ts` L174) and `getAllEarnedBadges` (`src/features/badges/server.ts`, keyed in `src/features/badges/queries.ts`) — filter client-side to the agents on the board. Do **not** issue per-agent `getPlayerStats`/`getPlayerBadges` calls (N+1).
|
||||||
|
|
||||||
|
## The handshake
|
||||||
|
1. **Claim:** agent A taps Claim on agent B. A's `free_agents` row gets `claimed = <B's row id>`. A can have **one outgoing claim** at a time (withdraw to switch).
|
||||||
|
2. **B sees it** (SSE-refreshed): "A wants to partner with you" with Accept / Decline. B can hold multiple *incoming* claims; accepting one implicitly declines the rest.
|
||||||
|
3. **Accept →** server verifies A's claim still points at B, then creates the team (reusing the `confirmTeamAssignments` find-or-create logic), enrolls it in the tournament, and `unenrollFreeAgent`s both — both rows vanish from the pool, exactly like the admin flow.
|
||||||
|
4. **Decline →** clears A's `claimed` field. A is free to claim someone else. (Optionally notify A — see tunables.)
|
||||||
|
5. **Symmetry shortcut:** if B already has an outgoing claim on A when A claims B (or vice versa), that *is* mutual intent — auto-accept immediately, mirroring how `reportMatchScore` auto-finalizes on an identical cross-report.
|
||||||
|
|
||||||
|
Team naming: default `"<A first> & <B first>"`, same shape the random generator produces; rename later via the existing team-edit flow.
|
||||||
|
|
||||||
|
## Data model (decided: Option A — field on `free_agents`)
|
||||||
|
Two options were weighed:
|
||||||
|
|
||||||
|
- **Option A (chosen): a self-relation on `free_agents`.** Add one nullable field `claimed` (relation → `free_agents`, maxSelect 1) — "this agent's outgoing claim". Everything derives from it: my outgoing claim = my row's `claimed`; my incoming claims = rows where `claimed = <my row id>`; decline = clear the field; match = both rows deleted. No status enum needed for v1 (one outgoing claim, terminal states delete rows). This mirrors the score-report precedent where pending state lives on the `matches` record, and unenroll/delete automatically cleans up outgoing claims (incoming pointers to a deleted row are cleared server-side on unenroll — one filtered update).
|
||||||
|
- **Option B (rejected for v1): a `partner_claims` collection** `{ tournament, from_agent, to_agent, status: pending|accepted|declined, created }`. Buys an audit trail, decline history (anti-re-spam), and native multi-claim — at the cost of a new collection, cross-record consistency, and cleanup hooks on unenroll. Revisit if tunables land on "multiple outgoing claims" or "declines block re-claiming".
|
||||||
|
|
||||||
|
`getFreeAgents` grows to expand `claimed` and return `{ id, phone, player, claimedAgentId }`; the board derives the four card states from that plus "who points at me".
|
||||||
|
|
||||||
|
## Server fns (new, in `src/features/tournaments/server.ts`)
|
||||||
|
All player-facing: `createServerFn().validator(zod).middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware]).handler(...)` + `toServerResult`, resolving the caller via `pbAdmin.getPlayerByAuthId(context.userAuthId)` — copy `enrollFreeAgent` (L116) as the shell and the guard style of `confirmMatchScore`.
|
||||||
|
|
||||||
|
- `proposePartner({ tournamentId, targetAgentId })` — caller must be an enrolled agent with no outgoing claim; target must be a different agent in the same tournament. Sets `claimed`. If target already claims caller → run the accept path (auto-match). Emits `tournament`; push to target.
|
||||||
|
- `withdrawPartnerClaim({ tournamentId })` — clears caller's `claimed`. Emits `tournament`.
|
||||||
|
- `respondToPartnerClaim({ tournamentId, fromAgentId, accept: boolean })` — verifies `fromAgent.claimed === callerAgent.id` (claim may have been withdrawn — fail with a friendly error). Decline: clear it. Accept: **reuse the team path** — extract the find-or-create-private-team + `enrollTeam` block from `confirmTeamAssignments` (L341–381) into a shared helper (e.g. `createOrReuseTeamForPair(player1Id, player2Id, teamName, tournamentId)`), call it, then `unenrollFreeAgent` both (which drops both rows and any pointers). Emit `team` + `tournament`; push to the claimer.
|
||||||
|
|
||||||
|
Race safety: re-read both rows inside the handler before mutating (same optimistic pattern as score confirm — no locks, just verify-then-act; PB writes are serialized enough at this app's scale, per the existing flows).
|
||||||
|
|
||||||
|
## Realtime + push
|
||||||
|
- **SSE:** reuse the existing `tournament` event (`src/lib/events/emitter.ts` L25). The handler in `src/hooks/use-server-events.ts` (L51) already invalidates the `['tournaments']` prefix, which covers `tournamentKeys.free_agents(id)` (`['tournaments','free_agents',id]`, `src/features/tournaments/queries.ts` L9) — **no new event type needed**; the board updates live as claims land.
|
||||||
|
- **Push:** `sendPushToPlayer` in `src/lib/push/index.ts` — its doc comment already reserves this exact seam ("a free-agent claim involving the player"). Payloads (`PushPayload` in `src/lib/push/types.ts`):
|
||||||
|
- On claim → target: `{ title: "You've been claimed!", body: "<Name> wants to be your partner for <tournament>", url: "/", tag: "partner-claim-<agentId>" }`.
|
||||||
|
- On accept → claimer: `{ title: "It's a match", body: "<Name> accepted — you're teammates", ... }`.
|
||||||
|
- Decline: **no push** (avoid rejection stings; the cleared state shows on the board).
|
||||||
|
|
||||||
|
## Regional visibility gate (relaxed)
|
||||||
|
Delete the regional "you're enrolled, wait for assignment" dead-end in `enrolled-free-agent.tsx` (L45–74): regionals get the same board (stats, badges, claim) — that self-card + "Partners will be randomly assigned when enrollment closes" collapses into the board's header copy ("Claim a partner, or the admin pairs whoever's left"). Keep the regional enroll sheet copy in `enroll-free-agent.tsx` but update it to mention claiming. Phone stays hidden for regionals (the claim button replaces its purpose).
|
||||||
|
|
||||||
|
## Admin fallback interplay
|
||||||
|
- Accepted handshakes remove both agents from `free_agents`, so `assign-partners.tsx` and `generateRandomTeams` see only the leftovers — **zero changes to the pairing math**. The even-count and ≥2 guards (L156–162) still apply to what remains.
|
||||||
|
- Pending (unaccepted) claims are invisible to the random pairer — it may split a pending pair. Acceptable for v1: random pairing runs at deadline, and `confirmTeamAssignments`'s `unenrollFreeAgent` sweep clears all claim state. Optional nicety (cheap, do it): `generateRandomTeams` seeds mutually-irrelevant pairs randomly but **keeps any pending claim pair together** when both are still in the pool — a one-pass pre-grouping before the shuffle.
|
||||||
|
- Admin's enrolled-players count and `assign-partners` header need no changes; they read the same `useFreeAgents` query.
|
||||||
|
|
||||||
|
## Design system
|
||||||
|
- Mantine 8 + the app theme (`src/lib/mantine/mantine-provider.tsx`): `defaultRadius: "sm"`, dynamic `primaryColor`. Use `Card withBorder radius="md" p="md"` like the existing regional self-card so the board matches its neighbors.
|
||||||
|
- Accent discipline: **one** accented element per card — the `Claim` button. Waiting/pending states are `variant="subtle"`/`c="dimmed"`; Accept is filled, Decline is `variant="subtle" color="red"` (matching the enroll sheet's Cancel).
|
||||||
|
- Press feedback via the `flxn-press` theme; motion only where it earns it — a single 120–350ms iOS-eased transition when a card flips state (claimed → matched), no confetti, no bouncing.
|
||||||
|
- Badges row reuses the visual language of `src/features/badges/components/badge-showcase.tsx` at small size — don't invent a new badge chip.
|
||||||
|
|
||||||
|
## Implementation notes for a fresh agent (build this cold)
|
||||||
|
Context: worktree `social` branch; changes are typically left uncommitted for review. Read `docs/free-agents.md` (this file) fully first, then:
|
||||||
|
- **Migration:** one new `pb_migrations/*_updated_free_agents.js` adding the `claimed` self-relation (collectionId `pbc_2929550049`, maxSelect 1, required false, cascadeDelete false). Mimic `1758402128_updated_free_agents.js` (plain `migrate((app)=>{...})` with up + down). Players collection id is `pbc_3072146508`, tournaments `pbc_340646327`, teams `pbc_1568971955`. Collections have null rules; all access is server-side via `pbAdmin`.
|
||||||
|
- **Service layer:** extend `createTournamentsService` in `src/lib/pocketbase/services/tournaments.ts` — `setFreeAgentClaim(agentId, targetAgentId|null)`, `getFreeAgentByPlayer(playerId, tournamentId)` (the lookup already inlined in `unenrollFreeAgent` L198), and make `unenrollFreeAgent` also clear inbound pointers (`getFullList({ filter: 'claimed = "<rowId>"' })` → clear each). Update `transformFreeAgent` in `src/lib/pocketbase/util/transform-types.ts` to surface `claimedAgentId`.
|
||||||
|
- **Server fns:** the three fns above in `src/features/tournaments/server.ts`, next to `enrollFreeAgent`. Extract `createOrReuseTeamForPair` from `confirmTeamAssignments` (L341–381) as a module-level helper and call it from both places — do not fork the team-creation logic. Emit `emitServerEvent({ type: "tournament", tournamentId })` (and `{ type: "team" }` on match) exactly like the existing fns; fire `sendPushToPlayer` (`src/lib/push/index.ts`) after the write, non-blocking (`.catch(log)`), never failing the request.
|
||||||
|
- **Client data:** hooks in `src/features/tournaments/hooks/` following `use-enroll-free-agent.ts` (uses `useServerMutation` from `src/lib/tanstack-query/hooks`, invalidates `tournamentKeys.free_agents(id)`). Board reads `useFreeAgents` + `getAllPlayerStats` / `getAllEarnedBadges` via new bulk queries keyed like `src/features/players/queries.ts` / `src/features/badges/queries.ts`. No new SSE event type: `use-server-events.ts`'s `tournament` handler already invalidates the `['tournaments']` prefix.
|
||||||
|
- **UI:** rework `enrolled-free-agent.tsx` into a `FreeAgentBoard` (new file under the same dir; keep `unenroll-free-agent.tsx` at the bottom). Card = `PlayerAvatar` + name + stats strip + badge row + action slot. Confirm-style incoming-claim UI: filled Accept, subtle red Decline, same layout rhythm as the score-confirm affordance in the match card. Sheets via `@/components/sheet/sheet` + `useSheet`, buttons via `@/components/button`.
|
||||||
|
- **Toasts:** `import { toast } from "@/lib/sonner"` — `toast.success` / `toast.error` only. "Claim sent", "You're teammates!", and a friendly error when a claim was withdrawn/taken ("They just partnered up — pick someone else").
|
||||||
|
- **Copy updates:** `enroll-free-agent.tsx` sheet text (both branches) now mentions the board + claiming; delete the regional-only self-card branch in the pool view.
|
||||||
|
- **Design system:** Mantine 8 + the app theme; `defaultRadius: "sm"`; press feedback via `flxn-press`; one accent per card; match neighboring components, don't invent a new look.
|
||||||
|
- **Verify:** enroll two players as free agents (one regional tournament, one mainline), claim, accept — confirm a `private: true` team appears on the tournament, both agents leave the pool, and `assign-partners` shows only leftovers. Then run the admin random flow on an even leftover pool to prove the fallback is untouched.
|
||||||
|
|
||||||
|
## Tunables / open questions
|
||||||
|
- **Multiple outgoing claims?** v1: one at a time (keeps Option A trivially consistent). If demand appears, move to Option B's collection.
|
||||||
|
- **Claim expiry?** v1: none — claims live until withdrawn, declined, or the pool is swept. Candidate: auto-expire via `updated` age (e.g. 24h) checked lazily in `getFreeAgents`.
|
||||||
|
- **Does admin auto-pair leftovers at a deadline?** Today it's manual (admin opens assign-partners and generates). Keep manual for v1; a scheduled auto-pair is a separate feature.
|
||||||
|
- **Decline memory:** should a declined claimer be blocked from re-claiming the same person? v1: no (rows carry no history). Option B enables it.
|
||||||
|
- **Keep pending pairs together in random pairing?** Recommended-cheap, but genuinely optional — cut it if it complicates the shuffle.
|
||||||
|
- **Phone visibility:** mainline keeps phones on the board for now; consider hiding until matched once claiming proves out.
|
||||||
|
- **Push copy/tag collapsing:** `tag: "partner-claim-<agentId>"` collapses repeat claims from the same person; revisit if it feels spammy.
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Push Notifications — Ideas Board (nothing decided)
|
||||||
|
|
||||||
|
**Status:** brainstorm, NOT a spec. The push *foundation* is built; **zero product triggers are wired**. This doc is a menu of candidate notification types to pick from later — none of them are commitments, and the list is meant to be argued with, pruned, and added to.
|
||||||
|
|
||||||
|
## Guiding principle (the one non-negotiable)
|
||||||
|
|
||||||
|
**Minimal by default. Strictly opt-in. Do not annoy.**
|
||||||
|
|
||||||
|
- Every notification type ships **OFF** unless the user opts in — and even opted-in volume stays low.
|
||||||
|
- Push is a scarce resource: each notification spends trust. One annoying ping and people nuke permission at the OS level and we never get it back.
|
||||||
|
- Only a **tiny high-value default-on set** for users who flip the master toggle. Current candidates: **"you're up next"** and **"you were claimed as a partner"** — both rare, both personally about *you*, both actionable right now.
|
||||||
|
- Prefer notifications that are: about the recipient personally > rare > time-sensitive > tappable to something useful. Anything failing two of those four probably shouldn't exist.
|
||||||
|
|
||||||
|
## What already exists (the foundation)
|
||||||
|
|
||||||
|
- **`src/lib/push/index.ts`** — `sendPushToPlayer(playerId, { title, body, url?, icon?, tag? })`. Sends to every device the player subscribed, auto-prunes dead endpoints (404/410). The file itself documents the trigger seams as **deliberately unwired**.
|
||||||
|
- **`push_subscriptions` collection** — `pb_migrations/1784200000_created_push_subscriptions.js`, service helpers in `src/lib/pocketbase/services/push.ts`.
|
||||||
|
- **Opt-in settings toggle** — `src/features/settings/components/notifications-section.tsx` (per-device enable + "send test notification", via `src/hooks/use-web-push.ts`). Today it's one master switch; per-type preferences are a follow-up idea (see bottom).
|
||||||
|
- **SSE domain events** — `src/lib/events/emitter.ts` already types `match | reaction | tournament | team | player | badge` events, emitted from real product code paths (`src/features/matches/server.ts`, `src/features/tournaments/server.ts`, `src/features/badges/server.ts`, `src/features/players/server.ts`, `src/features/teams/server.ts`). **Most push triggers can hang off the exact same emit sites** — the moment we `emitServerEvent(...)`, we already know something notification-worthy happened.
|
||||||
|
|
||||||
|
Again: **no trigger below is wired.** The point of this board is to choose which few deserve to be.
|
||||||
|
|
||||||
|
## The menu
|
||||||
|
|
||||||
|
Columns: what fires it, who gets it, why it drives engagement, roughly how often it would fire, an annoyance-risk rating, and a suggested default (all of these are suggestions, not decisions). "Default ON" here means *on once the user enables notifications at all* — nothing fires for users who never flip the master switch.
|
||||||
|
|
||||||
|
### BEFORE the tournament
|
||||||
|
|
||||||
|
| Candidate | Trigger | Audience | Why it hooks | Frequency | Annoyance | Default |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| **New tournament posted / enrollment open** | Tournament created or opened for enrollment (`src/features/tournaments/server.ts` create emit, ~line 27) | All players | The starting gun — nobody wants to hear about a tournament secondhand | Rare (per tournament) | Low | Off (opt-in "announcements") |
|
||||||
|
| **Enrollment closing soon** | Timed job N hours before enrollment lock | Enrolled-nowhere players | FOMO nudge for fence-sitters | Once per tournament | **Med** — it's marketing, not information | Off |
|
||||||
|
| **You were claimed as a partner** | Free-agent partner assignment (`assignPartners` path in `src/features/tournaments/server.ts`, emits `team` + `tournament`, ~lines 385–397) | The claimed free agent(s) | Deeply personal — "someone picked YOU." Answers the question free agents are actively anxious about | Once per tournament per free agent | Low | **ON (candidate default)** |
|
||||||
|
| **Predictions are open** | Bracket generated / predictions window opens (`submitPrediction` lives in `src/features/predictions/server.ts`) | Enrolled players | Predictions only work if people fill them out before lock | Once per tournament | Low-med | Off |
|
||||||
|
| **Predictions closing soon** | Timed job before first-match lock (predictions lock on first match start) | Enrolled players who haven't submitted | Targeted (only non-submitters), genuinely useful deadline | ≤1 per tournament | Low-med | Off |
|
||||||
|
| **Side bets are open on a match** | Match hits `ready` (book opens per `docs/side-bets.md`) | Players with tokens, not in that match | Side-bets spec explicitly wants this nudge for marquee matches | Could be *every match* — needs throttling (e.g. finals only) | **High** if per-match; low if finals-only | Off |
|
||||||
|
| **Tournament starts in 1h** | Timed job vs. tournament start time | Enrolled players | Logistics — get people in the building | Once per tournament | Low | Off (but a strong opt-in) |
|
||||||
|
|
||||||
|
### DURING the tournament
|
||||||
|
|
||||||
|
| Candidate | Trigger | Audience | Why it hooks | Frequency | Annoyance | Default |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| **You're up next / your match started** | Admin starts the match — `startMatch` in `src/features/matches/server.ts` (~line 142, emits `match`) | The 2–4 players in the match | The single most valuable push in the app: personal, urgent, actionable ("get to the table") | 3–6 per player per tournament | Low — this is the notification people *install the PWA for* | **ON (candidate default)** |
|
||||||
|
| **Score reported on your match — confirm needed** | `reportMatchScore` (~line 828) when the *other* team reports | The team that must confirm | Unblocks the bracket; the confirmer often doesn't know they're the bottleneck | ≤1 per match played | Low | Off, but arguably deserves default-on — it's a to-do, not a broadcast |
|
||||||
|
| **Your match result is final** | `finalizeMatch` (~line 753, single finalization path for admin `endMatch` + player `confirmMatchScore`) | Players in the match | Closure + "what's next" tap-through | 1 per match played | Low-med (you usually *know* — you were there) | Off |
|
||||||
|
| **You advanced / you were eliminated** | Bracket propagation after finalize (same `finalizeMatch` seam) | The advancing/eliminated team | "You're in the semis" is a brag-worthy moment; elimination push is a kindness debate | ≤1 per round | Med — elimination pushes can feel like salt | Off; if built, maybe advance-only |
|
||||||
|
| **Someone reacted to your match** | `toggleMatchReaction` (~line 964, emits `reaction`) | Players in the match | Social warm-fuzzies | Potentially **very** chatty | **High** — classic notification spam shape; needs heavy batching ("5 reactions on your match") if ever built | Off, probably forever |
|
||||||
|
| **Big upset just happened** | `finalizeMatch` + an upset heuristic (seed gap / prediction consensus) | Everyone at the tournament | Shared "did you SEE that" moment; drives people to the bracket | 0–2 per tournament if threshold is strict | Med — must be genuinely rare to land | Off |
|
||||||
|
| **Side-bet settled (you won/lost)** | Settlement inside `finalizeMatch` per `docs/side-bets.md` | Bettors on that match | Win reveals are dopamine; losses close the loop | Per bet placed (self-inflicted volume) | Low-med — user opted in by betting | Off (or implicit-on for people who bet?) |
|
||||||
|
|
||||||
|
### AFTER the tournament
|
||||||
|
|
||||||
|
| Candidate | Trigger | Audience | Why it hooks | Frequency | Annoyance | Default |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| **Final results / you placed** | Tournament completed (tournament status emit in `src/features/tournaments/server.ts`) | All enrolled; podium gets a personal variant ("You took 2nd") | The recap moment; personal placement > generic results | Once per tournament | Low | Off (single post-tournament push is defensible as default though) |
|
||||||
|
| **You earned a badge** | `src/features/badges/server.ts` award (~line 44, emits `badge` with `playerId`) | The recipient | Personal, rare, purely positive — near-ideal push shape | Rare | Low | Off, but a strong candidate to promote later |
|
||||||
|
| **Prediction results — how you scored** | Final match finalizes → predictions settle | Players who submitted a prediction | Only reaches opt-ins-by-behavior; leaderboard tap-through | Once per tournament | Low | Off |
|
||||||
|
| **Side-bets final standings** | Tournament completes → side-bets leaderboard freezes | Players who placed ≥1 bet | Same self-selected audience as above | Once per tournament | Low | Off |
|
||||||
|
| **Your FLXN Replay is ready** | *(Future feature — no code yet.)* Post-tournament recap generation | All enrolled | Personalized recap = the highest-retention artifact we could push | Once per tournament | Low | Off until the feature exists; then a strong default candidate |
|
||||||
|
|
||||||
|
### AMBIENT / cross-tournament
|
||||||
|
|
||||||
|
| Candidate | Trigger | Audience | Why it hooks | Frequency | Annoyance | Default |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| **A rival is in your next match** | *(Future feature — rivalries don't exist in code yet; head-to-head data does: `getMatchesBetweenPlayers` / `getMatchesBetweenTeams` in `src/features/matches/server.ts`)* | Both sides of the rivalry | "Revenge match" framing is the best narrative hook in the app | Rare | Low-med | Off until rivalries exist |
|
||||||
|
| **New tournament posted** | Tournament create emit (also listed under BEFORE — could be the same "announcements" type) | Everyone | Re-engages lapsed players between tournaments | Rare | Low | Off (opt-in "announcements") |
|
||||||
|
| **Free agents need partners** | N unclaimed free agents as enrollment deadline nears | Enrolled players without full teams | Matchmaking pressure in both directions | ≤1 per tournament | Med | Off |
|
||||||
|
| **You've been made admin / roster changes touch you** | `player` emit sites in `src/features/players/server.ts` | Affected player | Administrative courtesy | Very rare | Low | Off — probably not worth building |
|
||||||
|
|
||||||
|
## Suggested tiny default-on set (opinion, not decision)
|
||||||
|
|
||||||
|
If a user enables notifications at all, they get exactly two things until they say otherwise:
|
||||||
|
|
||||||
|
1. **You're up next** — `startMatch` seam.
|
||||||
|
2. **You were claimed as a partner** — free-agent assignment seam.
|
||||||
|
|
||||||
|
Everything else is opt-in per type. Both defaults are rare, personal, and actionable; neither can fire more than a handful of times per tournament. This matches the seams already name-dropped in the `sendPushToPlayer` doc comment.
|
||||||
|
|
||||||
|
## How to wire one (when the picks are made)
|
||||||
|
|
||||||
|
The pattern is deliberately boring:
|
||||||
|
|
||||||
|
1. Find the emit site — e.g. `startMatch` in `src/features/matches/server.ts` already calls `emitServerEvent({ type: "match", ... })` when the admin starts a match.
|
||||||
|
2. Next to that emit, resolve the affected player IDs and call `sendPushToPlayer(playerId, { title, body, url })` from `src/lib/push/index.ts`, with `url` deep-linking to the match/tournament route. Fire-and-forget (don't block the server fn on push transport), use `tag` to collapse repeats of the same kind.
|
||||||
|
3. That's it — subscription storage, multi-device fan-out, and dead-endpoint pruning are already handled by the foundation.
|
||||||
|
|
||||||
|
## Follow-up idea: per-type preferences
|
||||||
|
|
||||||
|
Today `src/features/settings/components/notifications-section.tsx` is a single per-device switch. Once types are chosen, extend that section with **category toggles** (e.g. "My matches", "Announcements", "Social", "Results & recaps") persisted per *player* (not per device), and have each trigger check the player's category preference before calling `sendPushToPlayer`. Categories, not individual types — a settings screen with 15 switches is its own kind of annoying.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Side Bets — Design Spec (v1, locked)
|
||||||
|
|
||||||
|
**Status:** design locked, not yet built. Sequenced for Wave 3 (after player score reporting + the home "Upcoming" surface; ideally after web push for bet-open nudges).
|
||||||
|
|
||||||
|
No real money, ever. This is an in-tournament engagement layer.
|
||||||
|
|
||||||
|
## Currency
|
||||||
|
- Each player gets **10 🪙 tokens fresh at the start of every tournament**. No carryover, **no seasons** (this app has no season concept).
|
||||||
|
- **Whole numbers only.**
|
||||||
|
- **No hard bust.** If you hit 0 tokens with matches remaining, you get a **1-token "comeback" bet each remaining match** so you're never fully benched. Not abusable — comeback stakes are smaller than the 10 you started with, so busting on purpose is strictly worse.
|
||||||
|
|
||||||
|
## When you can bet
|
||||||
|
- A match's book **opens when the match is *set*** (both teams populated → status `ready`).
|
||||||
|
- The book **closes the instant the admin *starts* the match** (status `started`) — same moment walkouts/announcements play. No bets after start.
|
||||||
|
- This dovetails with score reporting, which only begins once a match is `started`.
|
||||||
|
|
||||||
|
## Bet type 1 — Winner (social pool / parimutuel)
|
||||||
|
The core, social bet. You bet against your friends, not the house.
|
||||||
|
|
||||||
|
- Pick the winning team. Stake `1..(your tokens)`.
|
||||||
|
- All stakes on the match form **one pot**. On finalize, everyone who picked the actual winner **splits the whole pot proportional to their stake**; wrong picks forfeit their tokens to the pot.
|
||||||
|
- **Integer payout rule:** floor each winner's proportional share, then hand the leftover tokens out one-each to the largest fractional remainders (tie-break: larger stake, then earlier bet). Guarantees whole tokens and exact pot conservation.
|
||||||
|
- **House floor:** a correct bet always returns **at least ~1.5× the stake** even if nobody took the other side, so thin/late pools still feel worth it. (Tunable.)
|
||||||
|
- **Upset dynamics are automatic:** betting the less-popular team pays more because fewer winners split a bigger pot — no odds engine, no stats needed.
|
||||||
|
- **Edge cases:**
|
||||||
|
- Everyone picked the same team → no losers → **all stakes refunded** (no action).
|
||||||
|
- Nobody picked the winner → **everyone refunded**.
|
||||||
|
- Solo bettor → the **house floor** applies (this is exactly what it's for).
|
||||||
|
- **Live projected payout:** before close, show "if you're right you'd win ~X", ticking up via SSE as friends pile in. Final pot locks at match start.
|
||||||
|
|
||||||
|
## Bet type 2 — Overtime long shots (house-backed, fixed payout)
|
||||||
|
NOT a pool — you bet against a fixed multiplier, so including these does **not** thin the Winner pool. Cumulative "reaches at least N overtimes" tiers, keyed off the match `ot_count` field (the app tracks multiple OTs):
|
||||||
|
|
||||||
|
| Bet | Wins if | Payout |
|
||||||
|
|---|---|---|
|
||||||
|
| Reaches OT | `ot_count ≥ 1` | **2×** |
|
||||||
|
| Reaches Double OT | `ot_count ≥ 2` | **4×** |
|
||||||
|
| Reaches Triple OT | `ot_count ≥ 3` | **8×** |
|
||||||
|
|
||||||
|
Each is an independent yes/no long shot; **each additional OT doubles the payout** (memorable rule, jackpot feel for triple OT). Multipliers are tunable once real OT frequency is observed.
|
||||||
|
|
||||||
|
## Leaderboard & payoff
|
||||||
|
- **Per-tournament Side Bets leaderboard**, ranked by token count; tie-break by number of correct bets (so a hoarder who never bet ranks below active players at the same count).
|
||||||
|
- A few **badges** (global, like existing badges): e.g. "Called the Upset", "House Money", "Stone Cold Lock", "Degenerate" (most all-ins).
|
||||||
|
|
||||||
|
## Data model (proposed — finalize at build)
|
||||||
|
- New `bets` collection: `{ tournament, match, player, market: "winner"|"ot1"|"ot2"|"ot3", pick (team id for winner), stake, status: pending|won|lost|refunded, payout }`.
|
||||||
|
- **Settlement hooks into match finalization** (`finalizeMatch` / the `match` SSE): compute Winner pool splits + house-backed OT results, write payouts, update balances.
|
||||||
|
- Per-(player, tournament) balance: start 10 − stakes + payouts (materialized or derived).
|
||||||
|
|
||||||
|
## UI/UX
|
||||||
|
- Bettable matches surface on the home **Upcoming** carousel (Wave 2) + the match card/dock.
|
||||||
|
- **Bet slip sheet:** choose market, stake slider, live projected payout.
|
||||||
|
- Win/loss **reveal animation** on settle; the reaction rail already exists next to matches.
|
||||||
|
- **Side Bets leaderboard screen** — mirror the existing predictions leaderboard component.
|
||||||
|
|
||||||
|
## Reuses / dependencies
|
||||||
|
- SSE bus, the predictions leaderboard pattern, the badges system.
|
||||||
|
- Depends on: score reporting (done), home Upcoming carousel (Wave 2), web push (Wave 2) for "betting's open on the Final" nudges.
|
||||||
|
|
||||||
|
## Side Bets on the home screen (reserved slot)
|
||||||
|
The Wave 2 home redesign **leaves a labeled slot for this** — build it here later:
|
||||||
|
- A compact **"Your Side Bets" card** near the top of the **Upcoming** section, showing your 🪙 token balance, number of open (unsettled) bets, and a link to the Side Bets leaderboard. Hidden if the tournament isn't bettable (no set-but-unstarted matches) or the user has no involvement.
|
||||||
|
- On each card in the **Upcoming** carousel (matches that are `ready`, not yet `started`), a **"Bet" affordance** that opens the bet-slip sheet. This is the primary entry point — you bet on matches that are about to start.
|
||||||
|
- Once a match is `started`, its bets are locked; the card flips to showing the pool result live as it settles.
|
||||||
|
- The redesign should reserve this vertical space and (in code) leave a clear `{/* SIDE BETS SLOT — see docs/side-bets.md */}` marker where the card mounts, so wiring it in later is a drop-in.
|
||||||
|
|
||||||
|
## Implementation notes for a fresh agent (build this cold)
|
||||||
|
Context: worktree `social` branch; changes are typically left uncommitted for review. Read `docs/side-bets.md` (this file) fully first, then:
|
||||||
|
- **Server fns:** follow the `createServerFn(...).validator(zod).middleware([...]).handler(...)` + `toServerResult` pattern. Player-facing (non-admin) fns use `.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])` and resolve the caller via `getPlayerByAuthId(context.userAuthId)` — copy `toggleMatchReaction` in `src/features/matches/server.ts` as the template. All DB access is server-side via `pbAdmin`; collections have null rules.
|
||||||
|
- **Settlement:** hook into `finalizeMatch` in `src/features/matches/server.ts` (the single match-finalization path — admin `endMatch` and player `confirmMatchScore` both call it). When a match finalizes, settle its Winner pool (integer largest-remainder split + house floor) and the OT long shots. Emit an SSE event so balances/leaderboard update live.
|
||||||
|
- **Bet open/close** is derived from match `status`: bettable while `ready`, locked at `started` (identical trigger to score reporting).
|
||||||
|
- **Migration:** mimic an existing `pb_migrations/*_created_*.js` (e.g. `1784007613_created_predictions.js`) for the new `bets` collection — plain `migrate((app)=>{...})` with an up + down. Teams collection id `pbc_1568971955`, players `pbc_3072146508`, matches `pbc_2541054544`, tournaments `pbc_340646327`.
|
||||||
|
- **Client data:** use the `useServerQuery` / `useServerMutation` hooks (`src/lib/tanstack-query/hooks/*`) and the query-key convention seen in `src/features/predictions/queries.ts`. Add the new event type to `src/lib/events/emitter.ts` + handle it in `src/hooks/use-server-events.ts` (invalidate side-bets keys, mirroring how `match`/`reaction` are handled).
|
||||||
|
- **Leaderboard UI:** mirror `src/features/predictions/components/prediction-leaderboard.tsx` and its route `src/app/routes/_authed/tournaments/$id.predictions.tsx` for a `$id.side-bets.tsx` route.
|
||||||
|
- **Toasts:** `import { toast } from "@/lib/sonner"` — `toast.success` / `toast.error` only.
|
||||||
|
- **New feature dir:** `src/features/side-bets/{types.ts, server.ts, queries.ts, utils.ts, components/}` — keep pure logic (pool split, house floor, OT payout) in `utils.ts` with unit-testable functions, like `src/features/predictions/utils.ts`.
|
||||||
|
- **Design system:** Mantine 8 + the app theme; press feedback via the `flxn-press` theme; tasteful motion only (iOS-like easing, 120–350ms) — match neighboring components, don't invent a new look.
|
||||||
|
|
||||||
|
## Tunables to revisit after first live use
|
||||||
|
- House floor multiplier (default **1.5×**).
|
||||||
|
- OT multipliers (**2× / 4× / 8×**).
|
||||||
|
- Comeback token amount (**1**).
|
||||||
|
- Whether an all-agreed pool refunds or voids.
|
||||||
@@ -6,7 +6,7 @@ metadata:
|
|||||||
app: flxn
|
app: flxn
|
||||||
component: app
|
component: app
|
||||||
spec:
|
spec:
|
||||||
replicas: 1
|
replicas: 1 # Must stay at 1 for SSE
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app: flxn
|
app: flxn
|
||||||
|
|||||||
@@ -43,12 +43,14 @@
|
|||||||
"supertokens-web-js": "^0.15.0",
|
"supertokens-web-js": "^0.15.0",
|
||||||
"twilio": "^5.8.0",
|
"twilio": "^5.8.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"zod": "^4.0.15"
|
"zod": "^4.0.15"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.5.4",
|
"@types/node": "^22.5.4",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
// Rewrites the player stats views from a cartesian LIKE-join over
|
||||||
|
// players x teams x matches (which forced a full re-scan per row and took
|
||||||
|
// ~1.5-2s per request) to equi-joins driven by matches, with team rosters
|
||||||
|
// expanded once via json_each. Results are byte-identical; each view now
|
||||||
|
// runs in well under 100ms. The unary "+" in the regional filter prevents
|
||||||
|
// the query planner from picking a pathological join order.
|
||||||
|
migrate((app) => {
|
||||||
|
{
|
||||||
|
const collection = app.findCollectionByNameOrId("player_stats");
|
||||||
|
unmarshal({
|
||||||
|
"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(mt.match_id) as matches,\n COUNT(DISTINCT mt.tournament) as tournaments,\n SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) as wins,\n SUM(CASE WHEN mt.cups_for < mt.cups_against THEN 1 ELSE 0 END) as losses,\n SUM(mt.cups_for) as total_cups_made,\n SUM(mt.cups_against) as total_cups_against,\n ROUND((CAST(SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) AS REAL) / COUNT(mt.match_id)) * 100, 2) as win_percentage,\n ROUND(CAST(SUM(mt.cups_for) AS REAL) / COUNT(mt.match_id), 2) as avg_cups_per_match,\n ROUND(AVG(CASE WHEN mt.cups_for > mt.cups_against THEN mt.cups_for - mt.cups_against ELSE NULL END), 2) as margin_of_victory,\n ROUND(AVG(CASE WHEN mt.cups_for < mt.cups_against THEN mt.cups_against - mt.cups_for ELSE NULL END), 2) as margin_of_loss\n FROM (\n SELECT m.id as match_id, m.tournament as tournament, m.home as team_id, m.home_cups as cups_for, m.away_cups as cups_against\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended'\n UNION ALL\n SELECT m.id, m.tournament, m.away, m.away_cups, m.home_cups\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended'\n ) mt\n JOIN (\n SELECT teams.id AS team_id, je.value AS player_id\n FROM teams, json_each(ifnull(nullif(teams.players, ''), '[]')) je\n GROUP BY teams.id, je.value\n ) tp ON tp.team_id = mt.team_id\n JOIN players p ON p.id = tp.player_id\n GROUP BY p.id"
|
||||||
|
}, collection);
|
||||||
|
app.save(collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const collection = app.findCollectionByNameOrId("player_mainline_stats");
|
||||||
|
unmarshal({
|
||||||
|
"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(mt.match_id) as matches,\n COUNT(DISTINCT mt.tournament) as tournaments,\n SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) as wins,\n SUM(CASE WHEN mt.cups_for < mt.cups_against THEN 1 ELSE 0 END) as losses,\n SUM(mt.cups_for) as total_cups_made,\n SUM(mt.cups_against) as total_cups_against,\n ROUND((CAST(SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) AS REAL) / COUNT(mt.match_id)) * 100, 2) as win_percentage,\n ROUND(CAST(SUM(mt.cups_for) AS REAL) / COUNT(mt.match_id), 2) as avg_cups_per_match,\n ROUND(AVG(CASE WHEN mt.cups_for > mt.cups_against THEN mt.cups_for - mt.cups_against ELSE NULL END), 2) as margin_of_victory,\n ROUND(AVG(CASE WHEN mt.cups_for < mt.cups_against THEN mt.cups_against - mt.cups_for ELSE NULL END), 2) as margin_of_loss\n FROM (\n SELECT m.id as match_id, m.tournament as tournament, m.home as team_id, m.home_cups as cups_for, m.away_cups as cups_against\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended' AND (tour.regional = false OR tour.regional IS NULL)\n UNION ALL\n SELECT m.id, m.tournament, m.away, m.away_cups, m.home_cups\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended' AND (tour.regional = false OR tour.regional IS NULL)\n ) mt\n JOIN (\n SELECT teams.id AS team_id, je.value AS player_id\n FROM teams, json_each(ifnull(nullif(teams.players, ''), '[]')) je\n GROUP BY teams.id, je.value\n ) tp ON tp.team_id = mt.team_id\n JOIN players p ON p.id = tp.player_id\n GROUP BY p.id"
|
||||||
|
}, collection);
|
||||||
|
app.save(collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const collection = app.findCollectionByNameOrId("player_regional_stats");
|
||||||
|
unmarshal({
|
||||||
|
"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(mt.match_id) as matches,\n COUNT(DISTINCT mt.tournament) as tournaments,\n SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) as wins,\n SUM(CASE WHEN mt.cups_for < mt.cups_against THEN 1 ELSE 0 END) as losses,\n SUM(mt.cups_for) as total_cups_made,\n SUM(mt.cups_against) as total_cups_against,\n ROUND((CAST(SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) AS REAL) / COUNT(mt.match_id)) * 100, 2) as win_percentage,\n ROUND(CAST(SUM(mt.cups_for) AS REAL) / COUNT(mt.match_id), 2) as avg_cups_per_match,\n ROUND(AVG(CASE WHEN mt.cups_for > mt.cups_against THEN mt.cups_for - mt.cups_against ELSE NULL END), 2) as margin_of_victory,\n ROUND(AVG(CASE WHEN mt.cups_for < mt.cups_against THEN mt.cups_against - mt.cups_for ELSE NULL END), 2) as margin_of_loss\n FROM (\n SELECT m.id as match_id, m.tournament as tournament, m.home as team_id, m.home_cups as cups_for, m.away_cups as cups_against\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended' AND +tour.regional = true\n UNION ALL\n SELECT m.id, m.tournament, m.away, m.away_cups, m.home_cups\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended' AND +tour.regional = true\n ) mt\n JOIN (\n SELECT teams.id AS team_id, je.value AS player_id\n FROM teams, json_each(ifnull(nullif(teams.players, ''), '[]')) je\n GROUP BY teams.id, je.value\n ) tp ON tp.team_id = mt.team_id\n JOIN players p ON p.id = tp.player_id\n GROUP BY p.id"
|
||||||
|
}, collection);
|
||||||
|
app.save(collection);
|
||||||
|
}
|
||||||
|
}, (app) => {
|
||||||
|
{
|
||||||
|
const collection = app.findCollectionByNameOrId("player_stats");
|
||||||
|
unmarshal({
|
||||||
|
"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'\n GROUP BY p.id"
|
||||||
|
}, collection);
|
||||||
|
app.save(collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const collection = app.findCollectionByNameOrId("player_mainline_stats");
|
||||||
|
unmarshal({
|
||||||
|
"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"
|
||||||
|
}, collection);
|
||||||
|
app.save(collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const collection = app.findCollectionByNameOrId("player_regional_stats");
|
||||||
|
unmarshal({
|
||||||
|
"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"
|
||||||
|
}, collection);
|
||||||
|
app.save(collection);
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = new Collection({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": true,
|
||||||
|
"collectionId": "pbc_340646327",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation3177167065",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "tournament",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": true,
|
||||||
|
"collectionId": "pbc_3072146508",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation2551806565",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "player",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json2153001328",
|
||||||
|
"maxSize": 0,
|
||||||
|
"name": "picks",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate3332085495",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "pbc_1784007613",
|
||||||
|
"indexes": [
|
||||||
|
"CREATE UNIQUE INDEX `idx_predictions_tournament_player` ON `predictions` (`tournament`, `player`)"
|
||||||
|
],
|
||||||
|
"listRule": null,
|
||||||
|
"name": "predictions",
|
||||||
|
"system": false,
|
||||||
|
"type": "base",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1784007613");
|
||||||
|
|
||||||
|
return app.delete(collection);
|
||||||
|
})
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_2541054544")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(24, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number9101010101",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "reported_home_cups",
|
||||||
|
"onlyInt": true,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
}))
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(25, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number9202020202",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "reported_away_cups",
|
||||||
|
"onlyInt": true,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
}))
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(26, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number9303030303",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "reported_ot_count",
|
||||||
|
"onlyInt": true,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
}))
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(27, new Field({
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_1568971955",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation9404040404",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "reported_by_team",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
}))
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(28, new Field({
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_3072146508",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation9505050505",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "reported_by_player",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_2541054544")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("number9101010101")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("number9202020202")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("number9303030303")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("relation9404040404")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("relation9505050505")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = new Collection({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": true,
|
||||||
|
"collectionId": "pbc_3072146508",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation_player_push",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "player",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text_endpoint_push",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "endpoint",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text_p256dh_push",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "p256dh",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text_auth_push",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "auth",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text_user_agent_push",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "user_agent",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate3332085495",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "pbc_push_subscriptions",
|
||||||
|
"indexes": [
|
||||||
|
"CREATE UNIQUE INDEX `idx_push_subscriptions_endpoint` ON `push_subscriptions` (`endpoint`)"
|
||||||
|
],
|
||||||
|
"listRule": null,
|
||||||
|
"name": "push_subscriptions",
|
||||||
|
"system": false,
|
||||||
|
"type": "base",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_push_subscriptions");
|
||||||
|
|
||||||
|
return app.delete(collection);
|
||||||
|
})
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/* eslint-disable no-undef */
|
||||||
|
// Hand-written Web Push handlers, imported into the Workbox-generated SW via
|
||||||
|
// `importScripts` (see scripts/generate-sw.mjs). Workbox never overwrites this
|
||||||
|
// file — it's copied verbatim from /public and pulled in at SW startup.
|
||||||
|
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
let data = {};
|
||||||
|
try {
|
||||||
|
data = event.data ? event.data.json() : {};
|
||||||
|
} catch (e) {
|
||||||
|
data = { title: 'Notification', body: event.data ? event.data.text() : '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = data.title || 'Flexxon';
|
||||||
|
const options = {
|
||||||
|
body: data.body || '',
|
||||||
|
icon: data.icon || '/icon-192x192.png',
|
||||||
|
badge: '/icon-192x192.png',
|
||||||
|
tag: data.tag || undefined,
|
||||||
|
data: { url: data.url || '/' },
|
||||||
|
};
|
||||||
|
|
||||||
|
event.waitUntil(self.registration.showNotification(title, options));
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
|
||||||
|
const targetUrl = (event.notification.data && event.notification.data.url) || '/';
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients
|
||||||
|
.matchAll({ type: 'window', includeUncontrolled: true })
|
||||||
|
.then((clientList) => {
|
||||||
|
// Focus an existing tab if one is already open, else open a new one.
|
||||||
|
for (const client of clientList) {
|
||||||
|
try {
|
||||||
|
const clientUrl = new URL(client.url);
|
||||||
|
const target = new URL(targetUrl, self.location.origin);
|
||||||
|
if (clientUrl.pathname === target.pathname && 'focus' in client) {
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore malformed URLs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const client of clientList) {
|
||||||
|
if ('focus' in client) {
|
||||||
|
client.navigate(targetUrl);
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (self.clients.openWindow) {
|
||||||
|
return self.clients.openWindow(targetUrl);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -14,7 +14,13 @@ const { count, size, warnings } = await generateSW({
|
|||||||
'icon-512x512.png',
|
'icon-512x512.png',
|
||||||
'site.webmanifest',
|
'site.webmanifest',
|
||||||
'styles.css',
|
'styles.css',
|
||||||
|
'push-handlers.js',
|
||||||
],
|
],
|
||||||
|
// Hand-written Web Push handlers (public/push-handlers.js) are pulled into
|
||||||
|
// the generated SW at startup. importScripts runs them in the SW global
|
||||||
|
// scope, so their `push` / `notificationclick` listeners compose with
|
||||||
|
// Workbox's precache + runtime caching setup below.
|
||||||
|
importScripts: ['/push-handlers.js'],
|
||||||
navigateFallback: null,
|
navigateFallback: null,
|
||||||
skipWaiting: true,
|
skipWaiting: true,
|
||||||
clientsClaim: true,
|
clientsClaim: true,
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Grant or revoke the "Admin" role for a user by phone number (US +1 assumed).
|
||||||
|
// Usage: bun run scripts/make-admin.ts <phone> [--revoke]
|
||||||
|
import "dotenv/config";
|
||||||
|
import SuperTokens from "supertokens-node";
|
||||||
|
import Session from "supertokens-node/recipe/session";
|
||||||
|
import Passwordless from "supertokens-node/recipe/passwordless";
|
||||||
|
import UserRoles from "supertokens-node/recipe/userroles";
|
||||||
|
|
||||||
|
SuperTokens.init({
|
||||||
|
framework: "custom",
|
||||||
|
supertokens: {
|
||||||
|
connectionURI: process.env.SUPERTOKENS_URI || "http://localhost:3567",
|
||||||
|
apiKey: process.env.SUPERTOKENS_API_KEY || undefined,
|
||||||
|
},
|
||||||
|
appInfo: {
|
||||||
|
appName: "FLXN",
|
||||||
|
apiDomain: "http://localhost:3000",
|
||||||
|
websiteDomain: "http://localhost:3000",
|
||||||
|
apiBasePath: "/api/auth",
|
||||||
|
websiteBasePath: "/auth",
|
||||||
|
},
|
||||||
|
recipeList: [
|
||||||
|
Passwordless.init({ contactMethod: "PHONE", flowType: "USER_INPUT_CODE" }),
|
||||||
|
Session.init(),
|
||||||
|
UserRoles.init(),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const raw = process.argv[2];
|
||||||
|
const revoke = process.argv.includes("--revoke");
|
||||||
|
if (!raw) {
|
||||||
|
console.error("Usage: bun run scripts/make-admin.ts <phone> [--revoke]");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const digits = raw.replace(/[^\d]/g, "");
|
||||||
|
const candidates = Array.from(
|
||||||
|
new Set([
|
||||||
|
raw.startsWith("+") ? raw : null,
|
||||||
|
digits.length === 10 ? `+1${digits}` : null,
|
||||||
|
`+${digits}`,
|
||||||
|
digits,
|
||||||
|
].filter(Boolean) as string[])
|
||||||
|
);
|
||||||
|
|
||||||
|
let user: { id: string } | undefined;
|
||||||
|
let matched = "";
|
||||||
|
for (const phoneNumber of candidates) {
|
||||||
|
const users = await SuperTokens.listUsersByAccountInfo("public", { phoneNumber });
|
||||||
|
if (users.length) {
|
||||||
|
user = users[0];
|
||||||
|
matched = phoneNumber;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.error(`No SuperTokens user found for phone (tried: ${candidates.join(", ")}).`);
|
||||||
|
console.error("The user must have logged in at least once so their account exists.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
await UserRoles.createNewRoleOrAddPermissions("Admin", []);
|
||||||
|
|
||||||
|
if (revoke) {
|
||||||
|
const res = await UserRoles.removeUserRole("public", user.id, "Admin");
|
||||||
|
console.log(`Removed Admin from ${matched} (user ${user.id}):`, res.status);
|
||||||
|
} else {
|
||||||
|
const res = await UserRoles.addRoleToUser("public", user.id, "Admin");
|
||||||
|
console.log(
|
||||||
|
`Granted Admin to ${matched} (user ${user.id}):`,
|
||||||
|
res.status === "OK"
|
||||||
|
? res.didUserAlreadyHaveRole
|
||||||
|
? "already had it"
|
||||||
|
: "added"
|
||||||
|
: res.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Note: sign out and back in (or refresh the session) for the role to take effect.");
|
||||||
|
process.exit(0);
|
||||||
@@ -29,6 +29,9 @@ import { Route as ApiSpotifyResumeRouteImport } from './routes/api/spotify/resum
|
|||||||
import { Route as ApiSpotifyPlaybackRouteImport } from './routes/api/spotify/playback'
|
import { Route as ApiSpotifyPlaybackRouteImport } from './routes/api/spotify/playback'
|
||||||
import { Route as ApiSpotifyCaptureRouteImport } from './routes/api/spotify/capture'
|
import { Route as ApiSpotifyCaptureRouteImport } from './routes/api/spotify/capture'
|
||||||
import { Route as ApiSpotifyCallbackRouteImport } from './routes/api/spotify/callback'
|
import { Route as ApiSpotifyCallbackRouteImport } from './routes/api/spotify/callback'
|
||||||
|
import { Route as ApiPushUnsubscribeRouteImport } from './routes/api/push/unsubscribe'
|
||||||
|
import { Route as ApiPushTestRouteImport } from './routes/api/push/test'
|
||||||
|
import { Route as ApiPushSubscribeRouteImport } from './routes/api/push/subscribe'
|
||||||
import { Route as ApiEventsSplatRouteImport } from './routes/api/events.$'
|
import { Route as ApiEventsSplatRouteImport } from './routes/api/events.$'
|
||||||
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$'
|
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$'
|
||||||
import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_authed/tournaments/$tournamentId'
|
import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_authed/tournaments/$tournamentId'
|
||||||
@@ -38,10 +41,13 @@ import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/p
|
|||||||
import { Route as AuthedAdminBadgesRouteImport } from './routes/_authed/admin/badges'
|
import { Route as AuthedAdminBadgesRouteImport } from './routes/_authed/admin/badges'
|
||||||
import { Route as AuthedAdminActivitiesRouteImport } from './routes/_authed/admin/activities'
|
import { Route as AuthedAdminActivitiesRouteImport } from './routes/_authed/admin/activities'
|
||||||
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
|
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
|
||||||
|
import { Route as AuthedTournamentsIdPredictionsRouteImport } from './routes/_authed/tournaments/$id.predictions'
|
||||||
import { Route as AuthedTournamentsIdGroupsRouteImport } from './routes/_authed/tournaments/$id.groups'
|
import { Route as AuthedTournamentsIdGroupsRouteImport } from './routes/_authed/tournaments/$id.groups'
|
||||||
import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
|
import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
|
||||||
import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index'
|
import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index'
|
||||||
import { Route as ApiFilesCollectionRecordIdFileRouteImport } from './routes/api/files/$collection/$recordId/$file'
|
import { Route as ApiFilesCollectionRecordIdFileRouteImport } from './routes/api/files/$collection/$recordId/$file'
|
||||||
|
import { Route as AuthedTournamentsIdPredictionsMakeRouteImport } from './routes/_authed/tournaments/$id.predictions_.make'
|
||||||
|
import { Route as AuthedTournamentsIdPredictionsPlayerIdRouteImport } from './routes/_authed/tournaments/$id.predictions_.$playerId'
|
||||||
import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
|
import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
|
||||||
import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams'
|
import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams'
|
||||||
import { Route as AuthedAdminTournamentsIdAssignPartnersRouteImport } from './routes/_authed/admin/tournaments/$id/assign-partners'
|
import { Route as AuthedAdminTournamentsIdAssignPartnersRouteImport } from './routes/_authed/admin/tournaments/$id/assign-partners'
|
||||||
@@ -146,6 +152,21 @@ const ApiSpotifyCallbackRoute = ApiSpotifyCallbackRouteImport.update({
|
|||||||
path: '/api/spotify/callback',
|
path: '/api/spotify/callback',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiPushUnsubscribeRoute = ApiPushUnsubscribeRouteImport.update({
|
||||||
|
id: '/api/push/unsubscribe',
|
||||||
|
path: '/api/push/unsubscribe',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ApiPushTestRoute = ApiPushTestRouteImport.update({
|
||||||
|
id: '/api/push/test',
|
||||||
|
path: '/api/push/test',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ApiPushSubscribeRoute = ApiPushSubscribeRouteImport.update({
|
||||||
|
id: '/api/push/subscribe',
|
||||||
|
path: '/api/push/subscribe',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const ApiEventsSplatRoute = ApiEventsSplatRouteImport.update({
|
const ApiEventsSplatRoute = ApiEventsSplatRouteImport.update({
|
||||||
id: '/api/events/$',
|
id: '/api/events/$',
|
||||||
path: '/api/events/$',
|
path: '/api/events/$',
|
||||||
@@ -193,6 +214,12 @@ const AuthedAdminTournamentsIndexRoute =
|
|||||||
path: '/tournaments/',
|
path: '/tournaments/',
|
||||||
getParentRoute: () => AuthedAdminRoute,
|
getParentRoute: () => AuthedAdminRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthedTournamentsIdPredictionsRoute =
|
||||||
|
AuthedTournamentsIdPredictionsRouteImport.update({
|
||||||
|
id: '/tournaments/$id/predictions',
|
||||||
|
path: '/tournaments/$id/predictions',
|
||||||
|
getParentRoute: () => AuthedRoute,
|
||||||
|
} as any)
|
||||||
const AuthedTournamentsIdGroupsRoute =
|
const AuthedTournamentsIdGroupsRoute =
|
||||||
AuthedTournamentsIdGroupsRouteImport.update({
|
AuthedTournamentsIdGroupsRouteImport.update({
|
||||||
id: '/tournaments/$id/groups',
|
id: '/tournaments/$id/groups',
|
||||||
@@ -217,6 +244,18 @@ const ApiFilesCollectionRecordIdFileRoute =
|
|||||||
path: '/api/files/$collection/$recordId/$file',
|
path: '/api/files/$collection/$recordId/$file',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthedTournamentsIdPredictionsMakeRoute =
|
||||||
|
AuthedTournamentsIdPredictionsMakeRouteImport.update({
|
||||||
|
id: '/tournaments/$id/predictions_/make',
|
||||||
|
path: '/tournaments/$id/predictions/make',
|
||||||
|
getParentRoute: () => AuthedRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthedTournamentsIdPredictionsPlayerIdRoute =
|
||||||
|
AuthedTournamentsIdPredictionsPlayerIdRouteImport.update({
|
||||||
|
id: '/tournaments/$id/predictions_/$playerId',
|
||||||
|
path: '/tournaments/$id/predictions/$playerId',
|
||||||
|
getParentRoute: () => AuthedRoute,
|
||||||
|
} as any)
|
||||||
const AuthedAdminTournamentsRunIdRoute =
|
const AuthedAdminTournamentsRunIdRoute =
|
||||||
AuthedAdminTournamentsRunIdRouteImport.update({
|
AuthedAdminTournamentsRunIdRouteImport.update({
|
||||||
id: '/tournaments/run/$id',
|
id: '/tournaments/run/$id',
|
||||||
@@ -254,6 +293,9 @@ export interface FileRoutesByFullPath {
|
|||||||
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/api/events/$': typeof ApiEventsSplatRoute
|
'/api/events/$': typeof ApiEventsSplatRoute
|
||||||
|
'/api/push/subscribe': typeof ApiPushSubscribeRoute
|
||||||
|
'/api/push/test': typeof ApiPushTestRoute
|
||||||
|
'/api/push/unsubscribe': typeof ApiPushUnsubscribeRoute
|
||||||
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
|
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
|
||||||
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
|
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
|
||||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
|
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
|
||||||
@@ -266,10 +308,13 @@ export interface FileRoutesByFullPath {
|
|||||||
'/tournaments/': typeof AuthedTournamentsIndexRoute
|
'/tournaments/': typeof AuthedTournamentsIndexRoute
|
||||||
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||||
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
|
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
|
||||||
|
'/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
|
||||||
'/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
|
'/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
|
||||||
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
||||||
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
||||||
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
||||||
|
'/tournaments/$id/predictions/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
|
||||||
|
'/tournaments/$id/predictions/make': typeof AuthedTournamentsIdPredictionsMakeRoute
|
||||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
||||||
'/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
|
'/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
|
||||||
}
|
}
|
||||||
@@ -290,6 +335,9 @@ export interface FileRoutesByTo {
|
|||||||
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/api/events/$': typeof ApiEventsSplatRoute
|
'/api/events/$': typeof ApiEventsSplatRoute
|
||||||
|
'/api/push/subscribe': typeof ApiPushSubscribeRoute
|
||||||
|
'/api/push/test': typeof ApiPushTestRoute
|
||||||
|
'/api/push/unsubscribe': typeof ApiPushUnsubscribeRoute
|
||||||
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
|
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
|
||||||
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
|
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
|
||||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
|
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
|
||||||
@@ -302,10 +350,13 @@ export interface FileRoutesByTo {
|
|||||||
'/tournaments': typeof AuthedTournamentsIndexRoute
|
'/tournaments': typeof AuthedTournamentsIndexRoute
|
||||||
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||||
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
|
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
|
||||||
|
'/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
|
||||||
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
|
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
|
||||||
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
||||||
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
||||||
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
||||||
|
'/tournaments/$id/predictions/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
|
||||||
|
'/tournaments/$id/predictions/make': typeof AuthedTournamentsIdPredictionsMakeRoute
|
||||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
||||||
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
|
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
|
||||||
}
|
}
|
||||||
@@ -329,6 +380,9 @@ export interface FileRoutesById {
|
|||||||
'/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
'/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/api/events/$': typeof ApiEventsSplatRoute
|
'/api/events/$': typeof ApiEventsSplatRoute
|
||||||
|
'/api/push/subscribe': typeof ApiPushSubscribeRoute
|
||||||
|
'/api/push/test': typeof ApiPushTestRoute
|
||||||
|
'/api/push/unsubscribe': typeof ApiPushUnsubscribeRoute
|
||||||
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
|
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
|
||||||
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
|
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
|
||||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
|
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
|
||||||
@@ -341,10 +395,13 @@ export interface FileRoutesById {
|
|||||||
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
|
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
|
||||||
'/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
'/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||||
'/_authed/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
|
'/_authed/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
|
||||||
|
'/_authed/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
|
||||||
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
|
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
|
||||||
'/_authed/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
'/_authed/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
||||||
'/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
'/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
||||||
'/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
'/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
||||||
|
'/_authed/tournaments/$id/predictions_/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
|
||||||
|
'/_authed/tournaments/$id/predictions_/make': typeof AuthedTournamentsIdPredictionsMakeRoute
|
||||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
||||||
'/_authed/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
|
'/_authed/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
|
||||||
}
|
}
|
||||||
@@ -368,6 +425,9 @@ export interface FileRouteTypes {
|
|||||||
| '/tournaments/$tournamentId'
|
| '/tournaments/$tournamentId'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/events/$'
|
| '/api/events/$'
|
||||||
|
| '/api/push/subscribe'
|
||||||
|
| '/api/push/test'
|
||||||
|
| '/api/push/unsubscribe'
|
||||||
| '/api/spotify/callback'
|
| '/api/spotify/callback'
|
||||||
| '/api/spotify/capture'
|
| '/api/spotify/capture'
|
||||||
| '/api/spotify/playback'
|
| '/api/spotify/playback'
|
||||||
@@ -380,10 +440,13 @@ export interface FileRouteTypes {
|
|||||||
| '/tournaments/'
|
| '/tournaments/'
|
||||||
| '/tournaments/$id/bracket'
|
| '/tournaments/$id/bracket'
|
||||||
| '/tournaments/$id/groups'
|
| '/tournaments/$id/groups'
|
||||||
|
| '/tournaments/$id/predictions'
|
||||||
| '/admin/tournaments/'
|
| '/admin/tournaments/'
|
||||||
| '/admin/tournaments/$id/assign-partners'
|
| '/admin/tournaments/$id/assign-partners'
|
||||||
| '/admin/tournaments/$id/teams'
|
| '/admin/tournaments/$id/teams'
|
||||||
| '/admin/tournaments/run/$id'
|
| '/admin/tournaments/run/$id'
|
||||||
|
| '/tournaments/$id/predictions/$playerId'
|
||||||
|
| '/tournaments/$id/predictions/make'
|
||||||
| '/api/files/$collection/$recordId/$file'
|
| '/api/files/$collection/$recordId/$file'
|
||||||
| '/admin/tournaments/$id/'
|
| '/admin/tournaments/$id/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
@@ -404,6 +467,9 @@ export interface FileRouteTypes {
|
|||||||
| '/tournaments/$tournamentId'
|
| '/tournaments/$tournamentId'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/events/$'
|
| '/api/events/$'
|
||||||
|
| '/api/push/subscribe'
|
||||||
|
| '/api/push/test'
|
||||||
|
| '/api/push/unsubscribe'
|
||||||
| '/api/spotify/callback'
|
| '/api/spotify/callback'
|
||||||
| '/api/spotify/capture'
|
| '/api/spotify/capture'
|
||||||
| '/api/spotify/playback'
|
| '/api/spotify/playback'
|
||||||
@@ -416,10 +482,13 @@ export interface FileRouteTypes {
|
|||||||
| '/tournaments'
|
| '/tournaments'
|
||||||
| '/tournaments/$id/bracket'
|
| '/tournaments/$id/bracket'
|
||||||
| '/tournaments/$id/groups'
|
| '/tournaments/$id/groups'
|
||||||
|
| '/tournaments/$id/predictions'
|
||||||
| '/admin/tournaments'
|
| '/admin/tournaments'
|
||||||
| '/admin/tournaments/$id/assign-partners'
|
| '/admin/tournaments/$id/assign-partners'
|
||||||
| '/admin/tournaments/$id/teams'
|
| '/admin/tournaments/$id/teams'
|
||||||
| '/admin/tournaments/run/$id'
|
| '/admin/tournaments/run/$id'
|
||||||
|
| '/tournaments/$id/predictions/$playerId'
|
||||||
|
| '/tournaments/$id/predictions/make'
|
||||||
| '/api/files/$collection/$recordId/$file'
|
| '/api/files/$collection/$recordId/$file'
|
||||||
| '/admin/tournaments/$id'
|
| '/admin/tournaments/$id'
|
||||||
id:
|
id:
|
||||||
@@ -442,6 +511,9 @@ export interface FileRouteTypes {
|
|||||||
| '/_authed/tournaments/$tournamentId'
|
| '/_authed/tournaments/$tournamentId'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/events/$'
|
| '/api/events/$'
|
||||||
|
| '/api/push/subscribe'
|
||||||
|
| '/api/push/test'
|
||||||
|
| '/api/push/unsubscribe'
|
||||||
| '/api/spotify/callback'
|
| '/api/spotify/callback'
|
||||||
| '/api/spotify/capture'
|
| '/api/spotify/capture'
|
||||||
| '/api/spotify/playback'
|
| '/api/spotify/playback'
|
||||||
@@ -454,10 +526,13 @@ export interface FileRouteTypes {
|
|||||||
| '/_authed/tournaments/'
|
| '/_authed/tournaments/'
|
||||||
| '/_authed/tournaments/$id/bracket'
|
| '/_authed/tournaments/$id/bracket'
|
||||||
| '/_authed/tournaments/$id/groups'
|
| '/_authed/tournaments/$id/groups'
|
||||||
|
| '/_authed/tournaments/$id/predictions'
|
||||||
| '/_authed/admin/tournaments/'
|
| '/_authed/admin/tournaments/'
|
||||||
| '/_authed/admin/tournaments/$id/assign-partners'
|
| '/_authed/admin/tournaments/$id/assign-partners'
|
||||||
| '/_authed/admin/tournaments/$id/teams'
|
| '/_authed/admin/tournaments/$id/teams'
|
||||||
| '/_authed/admin/tournaments/run/$id'
|
| '/_authed/admin/tournaments/run/$id'
|
||||||
|
| '/_authed/tournaments/$id/predictions_/$playerId'
|
||||||
|
| '/_authed/tournaments/$id/predictions_/make'
|
||||||
| '/api/files/$collection/$recordId/$file'
|
| '/api/files/$collection/$recordId/$file'
|
||||||
| '/_authed/admin/tournaments/$id/'
|
| '/_authed/admin/tournaments/$id/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
@@ -470,6 +545,9 @@ export interface RootRouteChildren {
|
|||||||
ApiHealthRoute: typeof ApiHealthRoute
|
ApiHealthRoute: typeof ApiHealthRoute
|
||||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||||
ApiEventsSplatRoute: typeof ApiEventsSplatRoute
|
ApiEventsSplatRoute: typeof ApiEventsSplatRoute
|
||||||
|
ApiPushSubscribeRoute: typeof ApiPushSubscribeRoute
|
||||||
|
ApiPushTestRoute: typeof ApiPushTestRoute
|
||||||
|
ApiPushUnsubscribeRoute: typeof ApiPushUnsubscribeRoute
|
||||||
ApiSpotifyCallbackRoute: typeof ApiSpotifyCallbackRoute
|
ApiSpotifyCallbackRoute: typeof ApiSpotifyCallbackRoute
|
||||||
ApiSpotifyCaptureRoute: typeof ApiSpotifyCaptureRoute
|
ApiSpotifyCaptureRoute: typeof ApiSpotifyCaptureRoute
|
||||||
ApiSpotifyPlaybackRoute: typeof ApiSpotifyPlaybackRoute
|
ApiSpotifyPlaybackRoute: typeof ApiSpotifyPlaybackRoute
|
||||||
@@ -623,6 +701,27 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ApiSpotifyCallbackRouteImport
|
preLoaderRoute: typeof ApiSpotifyCallbackRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/api/push/unsubscribe': {
|
||||||
|
id: '/api/push/unsubscribe'
|
||||||
|
path: '/api/push/unsubscribe'
|
||||||
|
fullPath: '/api/push/unsubscribe'
|
||||||
|
preLoaderRoute: typeof ApiPushUnsubscribeRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/api/push/test': {
|
||||||
|
id: '/api/push/test'
|
||||||
|
path: '/api/push/test'
|
||||||
|
fullPath: '/api/push/test'
|
||||||
|
preLoaderRoute: typeof ApiPushTestRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/api/push/subscribe': {
|
||||||
|
id: '/api/push/subscribe'
|
||||||
|
path: '/api/push/subscribe'
|
||||||
|
fullPath: '/api/push/subscribe'
|
||||||
|
preLoaderRoute: typeof ApiPushSubscribeRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/api/events/$': {
|
'/api/events/$': {
|
||||||
id: '/api/events/$'
|
id: '/api/events/$'
|
||||||
path: '/api/events/$'
|
path: '/api/events/$'
|
||||||
@@ -686,6 +785,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport
|
preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport
|
||||||
parentRoute: typeof AuthedAdminRoute
|
parentRoute: typeof AuthedAdminRoute
|
||||||
}
|
}
|
||||||
|
'/_authed/tournaments/$id/predictions': {
|
||||||
|
id: '/_authed/tournaments/$id/predictions'
|
||||||
|
path: '/tournaments/$id/predictions'
|
||||||
|
fullPath: '/tournaments/$id/predictions'
|
||||||
|
preLoaderRoute: typeof AuthedTournamentsIdPredictionsRouteImport
|
||||||
|
parentRoute: typeof AuthedRoute
|
||||||
|
}
|
||||||
'/_authed/tournaments/$id/groups': {
|
'/_authed/tournaments/$id/groups': {
|
||||||
id: '/_authed/tournaments/$id/groups'
|
id: '/_authed/tournaments/$id/groups'
|
||||||
path: '/tournaments/$id/groups'
|
path: '/tournaments/$id/groups'
|
||||||
@@ -714,6 +820,20 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ApiFilesCollectionRecordIdFileRouteImport
|
preLoaderRoute: typeof ApiFilesCollectionRecordIdFileRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/_authed/tournaments/$id/predictions_/make': {
|
||||||
|
id: '/_authed/tournaments/$id/predictions_/make'
|
||||||
|
path: '/tournaments/$id/predictions/make'
|
||||||
|
fullPath: '/tournaments/$id/predictions/make'
|
||||||
|
preLoaderRoute: typeof AuthedTournamentsIdPredictionsMakeRouteImport
|
||||||
|
parentRoute: typeof AuthedRoute
|
||||||
|
}
|
||||||
|
'/_authed/tournaments/$id/predictions_/$playerId': {
|
||||||
|
id: '/_authed/tournaments/$id/predictions_/$playerId'
|
||||||
|
path: '/tournaments/$id/predictions/$playerId'
|
||||||
|
fullPath: '/tournaments/$id/predictions/$playerId'
|
||||||
|
preLoaderRoute: typeof AuthedTournamentsIdPredictionsPlayerIdRouteImport
|
||||||
|
parentRoute: typeof AuthedRoute
|
||||||
|
}
|
||||||
'/_authed/admin/tournaments/run/$id': {
|
'/_authed/admin/tournaments/run/$id': {
|
||||||
id: '/_authed/admin/tournaments/run/$id'
|
id: '/_authed/admin/tournaments/run/$id'
|
||||||
path: '/tournaments/run/$id'
|
path: '/tournaments/run/$id'
|
||||||
@@ -779,6 +899,9 @@ interface AuthedRouteChildren {
|
|||||||
AuthedTournamentsIndexRoute: typeof AuthedTournamentsIndexRoute
|
AuthedTournamentsIndexRoute: typeof AuthedTournamentsIndexRoute
|
||||||
AuthedTournamentsIdBracketRoute: typeof AuthedTournamentsIdBracketRoute
|
AuthedTournamentsIdBracketRoute: typeof AuthedTournamentsIdBracketRoute
|
||||||
AuthedTournamentsIdGroupsRoute: typeof AuthedTournamentsIdGroupsRoute
|
AuthedTournamentsIdGroupsRoute: typeof AuthedTournamentsIdGroupsRoute
|
||||||
|
AuthedTournamentsIdPredictionsRoute: typeof AuthedTournamentsIdPredictionsRoute
|
||||||
|
AuthedTournamentsIdPredictionsPlayerIdRoute: typeof AuthedTournamentsIdPredictionsPlayerIdRoute
|
||||||
|
AuthedTournamentsIdPredictionsMakeRoute: typeof AuthedTournamentsIdPredictionsMakeRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthedRouteChildren: AuthedRouteChildren = {
|
const AuthedRouteChildren: AuthedRouteChildren = {
|
||||||
@@ -793,6 +916,11 @@ const AuthedRouteChildren: AuthedRouteChildren = {
|
|||||||
AuthedTournamentsIndexRoute: AuthedTournamentsIndexRoute,
|
AuthedTournamentsIndexRoute: AuthedTournamentsIndexRoute,
|
||||||
AuthedTournamentsIdBracketRoute: AuthedTournamentsIdBracketRoute,
|
AuthedTournamentsIdBracketRoute: AuthedTournamentsIdBracketRoute,
|
||||||
AuthedTournamentsIdGroupsRoute: AuthedTournamentsIdGroupsRoute,
|
AuthedTournamentsIdGroupsRoute: AuthedTournamentsIdGroupsRoute,
|
||||||
|
AuthedTournamentsIdPredictionsRoute: AuthedTournamentsIdPredictionsRoute,
|
||||||
|
AuthedTournamentsIdPredictionsPlayerIdRoute:
|
||||||
|
AuthedTournamentsIdPredictionsPlayerIdRoute,
|
||||||
|
AuthedTournamentsIdPredictionsMakeRoute:
|
||||||
|
AuthedTournamentsIdPredictionsMakeRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthedRouteWithChildren =
|
const AuthedRouteWithChildren =
|
||||||
@@ -806,6 +934,9 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
ApiHealthRoute: ApiHealthRoute,
|
ApiHealthRoute: ApiHealthRoute,
|
||||||
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||||
ApiEventsSplatRoute: ApiEventsSplatRoute,
|
ApiEventsSplatRoute: ApiEventsSplatRoute,
|
||||||
|
ApiPushSubscribeRoute: ApiPushSubscribeRoute,
|
||||||
|
ApiPushTestRoute: ApiPushTestRoute,
|
||||||
|
ApiPushUnsubscribeRoute: ApiPushUnsubscribeRoute,
|
||||||
ApiSpotifyCallbackRoute: ApiSpotifyCallbackRoute,
|
ApiSpotifyCallbackRoute: ApiSpotifyCallbackRoute,
|
||||||
ApiSpotifyCaptureRoute: ApiSpotifyCaptureRoute,
|
ApiSpotifyCaptureRoute: ApiSpotifyCaptureRoute,
|
||||||
ApiSpotifyPlaybackRoute: ApiSpotifyPlaybackRoute,
|
ApiSpotifyPlaybackRoute: ApiSpotifyPlaybackRoute,
|
||||||
|
|||||||
@@ -44,10 +44,10 @@ export const Route = createRootRouteWithContext<{
|
|||||||
content:
|
content:
|
||||||
"width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, interactive-widget=resizes-content, viewport-fit=cover",
|
"width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, interactive-widget=resizes-content, viewport-fit=cover",
|
||||||
},
|
},
|
||||||
{ name: 'description', content: 'Amicus meus madidus' },
|
{ name: 'description', content: 'FLXN — beer pong tournaments, brackets, and stats.' },
|
||||||
{ name: 'keywords', content: 'FLXN, beer pong, tournament, sports, statistics, pong' },
|
{ name: 'keywords', content: 'FLXN, beer pong, tournament, sports, statistics, pong' },
|
||||||
{ property: 'og:title', content: 'FLXN' },
|
{ property: 'og:title', content: 'FLXN' },
|
||||||
{ property: 'og:description', content: 'Amicus meus madidus' },
|
{ property: 'og:description', content: 'FLXN — beer pong tournaments, brackets, and stats.' },
|
||||||
{ property: 'og:url', content: 'https://flexxon.app' },
|
{ property: 'og:url', content: 'https://flexxon.app' },
|
||||||
{ property: 'og:type', content: 'website' },
|
{ property: 'og:type', content: 'website' },
|
||||||
{ property: 'og:site_name', content: 'FLXN' },
|
{ property: 'og:site_name', content: 'FLXN' },
|
||||||
@@ -58,7 +58,7 @@ export const Route = createRootRouteWithContext<{
|
|||||||
{ property: 'og:locale', content: 'en_US' },
|
{ property: 'og:locale', content: 'en_US' },
|
||||||
{ name: 'twitter:card', content: 'summary' },
|
{ name: 'twitter:card', content: 'summary' },
|
||||||
{ name: 'twitter:title', content: 'FLXN' },
|
{ name: 'twitter:title', content: 'FLXN' },
|
||||||
{ name: 'twitter:description', content: 'Amicus meus madidus' },
|
{ name: 'twitter:description', content: 'FLXN — beer pong tournaments, brackets, and stats.' },
|
||||||
{ name: 'twitter:image', content: 'https://flexxon.app/favicon.png' },
|
{ name: 'twitter:image', content: 'https://flexxon.app/favicon.png' },
|
||||||
{ name: 'mobile-web-app-capable', content: 'yes' },
|
{ name: 'mobile-web-app-capable', content: 'yes' },
|
||||||
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
|
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
|
||||||
|
|||||||
@@ -97,6 +97,16 @@ function RouteComponent() {
|
|||||||
) || false;
|
) || false;
|
||||||
}, [tournament.matches]);
|
}, [tournament.matches]);
|
||||||
|
|
||||||
|
const nextUpMatchId = useMemo(() => {
|
||||||
|
const ready = (tournament.matches ?? [])
|
||||||
|
.filter(
|
||||||
|
(match) =>
|
||||||
|
match.status === "ready" && match.home && match.away && !match.bye
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.order - b.order);
|
||||||
|
return ready[0]?.id;
|
||||||
|
}, [tournament.matches]);
|
||||||
|
|
||||||
const bracket: BracketData = useMemo(() => {
|
const bracket: BracketData = useMemo(() => {
|
||||||
if (!tournament.matches || tournament.matches.length === 0) {
|
if (!tournament.matches || tournament.matches.length === 0) {
|
||||||
return { winners: [], losers: [] };
|
return { winners: [], losers: [] };
|
||||||
@@ -146,11 +156,12 @@ function RouteComponent() {
|
|||||||
hasKnockoutBracket={knockoutBracketPopulated}
|
hasKnockoutBracket={knockoutBracketPopulated}
|
||||||
isRegional={tournament.regional}
|
isRegional={tournament.regional}
|
||||||
groupConfig={tournament.group_config}
|
groupConfig={tournament.group_config}
|
||||||
|
nextUpMatchId={nextUpMatchId}
|
||||||
/>
|
/>
|
||||||
<Divider />
|
<Divider />
|
||||||
<div>
|
<div>
|
||||||
<Title order={3} ta="center" mb="md">Knockout Bracket</Title>
|
<Title order={3} ta="center" mb="md">Knockout Bracket</Title>
|
||||||
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} />
|
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} nextUpMatchId={nextUpMatchId} />
|
||||||
</div>
|
</div>
|
||||||
</Stack>
|
</Stack>
|
||||||
) : hasGroupStage ? (
|
) : hasGroupStage ? (
|
||||||
@@ -162,9 +173,10 @@ function RouteComponent() {
|
|||||||
hasKnockoutBracket={knockoutBracketPopulated}
|
hasKnockoutBracket={knockoutBracketPopulated}
|
||||||
isRegional={tournament.regional}
|
isRegional={tournament.regional}
|
||||||
groupConfig={tournament.group_config}
|
groupConfig={tournament.group_config}
|
||||||
|
nextUpMatchId={nextUpMatchId}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} />
|
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} nextUpMatchId={nextUpMatchId} />
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
tournament.regional === true ? (
|
tournament.regional === true ? (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createFileRoute } from "@tanstack/react-router";
|
|||||||
import { Box, Title, Stack } from "@mantine/core";
|
import { Box, Title, Stack } from "@mantine/core";
|
||||||
import { ColorSchemePicker } from "@/features/settings/components/color-scheme-picker";
|
import { ColorSchemePicker } from "@/features/settings/components/color-scheme-picker";
|
||||||
import AccentColorPicker from "@/features/settings/components/accent-color-picker";
|
import AccentColorPicker from "@/features/settings/components/accent-color-picker";
|
||||||
|
import { NotificationsSection } from "@/features/settings/components/notifications-section";
|
||||||
import { SignOutIcon } from "@phosphor-icons/react";
|
import { SignOutIcon } from "@phosphor-icons/react";
|
||||||
import ListLink from "@/components/list-link";
|
import ListLink from "@/components/list-link";
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ function RouteComponent() {
|
|||||||
<ColorSchemePicker />
|
<ColorSchemePicker />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
<NotificationsSection />
|
||||||
<ListLink label="Sign Out" to="/logout" Icon={SignOutIcon} />
|
<ListLink label="Sign Out" to="/logout" Icon={SignOutIcon} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { playerQueries } from "@/features/players/queries";
|
import { playerQueries } from "@/features/players/queries";
|
||||||
import PlayerStatsTable from "@/features/players/components/player-stats-table";
|
import PlayerStatsTable from "@/features/players/components/player-stats-table";
|
||||||
import { Suspense, useState, useDeferredValue } from "react";
|
import { Suspense, useState, useDeferredValue, useEffect } from "react";
|
||||||
import PlayerStatsTableSkeleton from "@/features/players/components/player-stats-table-skeleton";
|
import PlayerStatsTableSkeleton from "@/features/players/components/player-stats-table-skeleton";
|
||||||
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
||||||
import LeagueHeadToHead from "@/features/players/components/league-head-to-head";
|
import LeagueHeadToHead from "@/features/players/components/league-head-to-head";
|
||||||
import { Box, Loader, Tabs, Button, Group, Container, Stack } from "@mantine/core";
|
import { Box, Loader, Tabs, Button, Group, Container, Stack } from "@mantine/core";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/stats")({
|
export const Route = createFileRoute("/_authed/stats")({
|
||||||
component: Stats,
|
component: Stats,
|
||||||
beforeLoad: ({ context }) => {
|
beforeLoad: ({ context }) => {
|
||||||
const queryClient = context.queryClient;
|
const queryClient = context.queryClient;
|
||||||
prefetchServerQuery(queryClient, playerQueries.allStats('all'));
|
prefetchServerQuery(queryClient, playerQueries.allStats('all'));
|
||||||
prefetchServerQuery(queryClient, playerQueries.allStats('mainline'));
|
|
||||||
prefetchServerQuery(queryClient, playerQueries.allStats('regional'));
|
|
||||||
},
|
},
|
||||||
loader: () => ({
|
loader: () => ({
|
||||||
withPadding: false,
|
withPadding: false,
|
||||||
@@ -29,6 +28,15 @@ function Stats() {
|
|||||||
const [viewType, setViewType] = useState<'all' | 'mainline' | 'regional'>('all');
|
const [viewType, setViewType] = useState<'all' | 'mainline' | 'regional'>('all');
|
||||||
const deferredViewType = useDeferredValue(viewType);
|
const deferredViewType = useDeferredValue(viewType);
|
||||||
const isStale = viewType !== deferredViewType;
|
const isStale = viewType !== deferredViewType;
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
prefetchServerQuery(queryClient, playerQueries.allStats('mainline'));
|
||||||
|
prefetchServerQuery(queryClient, playerQueries.allStats('regional'));
|
||||||
|
}, 1500);
|
||||||
|
return () => window.clearTimeout(timeout);
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs defaultValue="stats">
|
<Tabs defaultValue="stats">
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import {
|
|||||||
useTournament,
|
useTournament,
|
||||||
} from "@/features/tournaments/queries";
|
} from "@/features/tournaments/queries";
|
||||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||||
import { Box, Container, Flex, Skeleton, Stack } from "@mantine/core";
|
import { Container } from "@mantine/core";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { BracketData } from "@/features/bracket/types";
|
import { BracketData } from "@/features/bracket/types";
|
||||||
import { Match } from "@/features/matches/types";
|
import { groupMatchesIntoBracket } from "@/features/bracket/utils/group";
|
||||||
import BracketView from "@/features/bracket/components/bracket-view";
|
import BracketView from "@/features/bracket/components/bracket-view";
|
||||||
|
import { BracketPending } from "@/features/bracket/components/bracket-pending";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({
|
export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({
|
||||||
beforeLoad: async ({ context, params }) => {
|
beforeLoad: async ({ context, params }) => {
|
||||||
@@ -34,84 +35,14 @@ export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({
|
|||||||
pendingComponent: BracketPending,
|
pendingComponent: BracketPending,
|
||||||
});
|
});
|
||||||
|
|
||||||
function BracketPending() {
|
|
||||||
const columns = [4, 2, 1];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container size="md" px={0}>
|
|
||||||
<Box
|
|
||||||
p={0}
|
|
||||||
style={{
|
|
||||||
overflow: "hidden",
|
|
||||||
backgroundImage: `radial-gradient(circle, var(--mantine-color-default-border) 1px, transparent 1px)`,
|
|
||||||
backgroundSize: "16px 16px",
|
|
||||||
backgroundPosition: "0 0, 8px 8px",
|
|
||||||
minHeight: "70dvh",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Skeleton height={18} width={140} radius="sm" m={16} />
|
|
||||||
<Flex gap="xl" px={16} align="stretch">
|
|
||||||
{columns.map((count, columnIndex) => (
|
|
||||||
<Stack
|
|
||||||
key={`bracket-pending-round-${columnIndex}`}
|
|
||||||
gap="xl"
|
|
||||||
justify="space-around"
|
|
||||||
style={{ opacity: 1 - columnIndex * 0.25 }}
|
|
||||||
>
|
|
||||||
{Array.from({ length: count }).map((_, matchIndex) => (
|
|
||||||
<Skeleton
|
|
||||||
key={`bracket-pending-match-${columnIndex}-${matchIndex}`}
|
|
||||||
height={84}
|
|
||||||
width={220}
|
|
||||||
radius="md"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
))}
|
|
||||||
</Flex>
|
|
||||||
</Box>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { id } = Route.useParams();
|
const { id } = Route.useParams();
|
||||||
const { data: tournament } = useTournament(id);
|
const { data: tournament } = useTournament(id);
|
||||||
|
|
||||||
const bracket: BracketData = useMemo(() => {
|
const bracket: BracketData = useMemo(
|
||||||
if (!tournament.matches || tournament.matches.length === 0) {
|
() => groupMatchesIntoBracket(tournament.matches),
|
||||||
return { winners: [], losers: [] };
|
[tournament.matches]
|
||||||
}
|
);
|
||||||
|
|
||||||
const winnersMap = new Map<number, Match[]>();
|
|
||||||
const losersMap = new Map<number, Match[]>();
|
|
||||||
|
|
||||||
tournament.matches
|
|
||||||
.filter((match) => match.round !== -1)
|
|
||||||
.sort((a, b) => a.lid - b.lid)
|
|
||||||
.forEach((match) => {
|
|
||||||
if (!match.is_losers_bracket) {
|
|
||||||
if (!winnersMap.has(match.round)) {
|
|
||||||
winnersMap.set(match.round, []);
|
|
||||||
}
|
|
||||||
winnersMap.get(match.round)!.push(match);
|
|
||||||
} else {
|
|
||||||
if (!losersMap.has(match.round)) {
|
|
||||||
losersMap.set(match.round, []);
|
|
||||||
}
|
|
||||||
losersMap.get(match.round)!.push(match);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const winners = Array.from(winnersMap.entries())
|
|
||||||
.sort(([a], [b]) => a - b)
|
|
||||||
.map(([, matches]) => matches);
|
|
||||||
|
|
||||||
const losers = Array.from(losersMap.entries())
|
|
||||||
.sort(([a], [b]) => a - b)
|
|
||||||
.map(([, matches]) => matches);
|
|
||||||
return { winners, losers };
|
|
||||||
}, [tournament.matches]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="md" px={0}>
|
<Container size="md" px={0}>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
|
import { tournamentQueries, useTournament } from "@/features/tournaments/queries";
|
||||||
|
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||||
|
import { Container } from "@mantine/core";
|
||||||
|
import { PredictionLeaderboard } from "@/features/predictions/components/prediction-leaderboard";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authed/tournaments/$id/predictions")({
|
||||||
|
beforeLoad: async ({ context, params }) => {
|
||||||
|
const { queryClient } = context;
|
||||||
|
const tournament = await ensureServerQueryData(
|
||||||
|
queryClient,
|
||||||
|
tournamentQueries.details(params.id)
|
||||||
|
);
|
||||||
|
if (!tournament) throw redirect({ to: "/tournaments" });
|
||||||
|
return {
|
||||||
|
tournament,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
loader: () => ({
|
||||||
|
header: {
|
||||||
|
withBackButton: true,
|
||||||
|
title: "Predictions",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
component: RouteComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const { id } = Route.useParams();
|
||||||
|
const { data: tournament } = useTournament(id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="md" px={0}>
|
||||||
|
<PredictionLeaderboard tournament={tournament} />
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { Box, Container, Group, Paper, Stack, Text } from "@mantine/core";
|
||||||
|
import { tournamentQueries, useTournament } from "@/features/tournaments/queries";
|
||||||
|
import { predictionQueries, usePlayerPrediction } from "@/features/predictions/queries";
|
||||||
|
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||||
|
import { BracketPending } from "@/features/bracket/components/bracket-pending";
|
||||||
|
import { PredictionBracket } from "@/features/predictions/components/prediction-bracket";
|
||||||
|
import { computePredictionScore } from "@/features/predictions/utils";
|
||||||
|
import PlayerAvatar from "@/components/player-avatar";
|
||||||
|
|
||||||
|
export const Route = createFileRoute(
|
||||||
|
"/_authed/tournaments/$id/predictions_/$playerId"
|
||||||
|
)({
|
||||||
|
beforeLoad: async ({ context, params }) => {
|
||||||
|
const { queryClient } = context;
|
||||||
|
const tournament = await ensureServerQueryData(
|
||||||
|
queryClient,
|
||||||
|
tournamentQueries.details(params.id)
|
||||||
|
);
|
||||||
|
if (!tournament) throw redirect({ to: "/tournaments" });
|
||||||
|
|
||||||
|
const prediction = await ensureServerQueryData(
|
||||||
|
queryClient,
|
||||||
|
predictionQueries.player(params.id, params.playerId)
|
||||||
|
);
|
||||||
|
if (!prediction) {
|
||||||
|
throw redirect({
|
||||||
|
to: "/tournaments/$id/predictions",
|
||||||
|
params: { id: params.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tournament,
|
||||||
|
prediction,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
loader: ({ context }) => ({
|
||||||
|
fullWidth: true,
|
||||||
|
withPadding: false,
|
||||||
|
header: {
|
||||||
|
withBackButton: true,
|
||||||
|
title: `${context.prediction.player.first_name}'s Bracket`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
component: RouteComponent,
|
||||||
|
pendingComponent: BracketPending,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const { id, playerId } = Route.useParams();
|
||||||
|
const { data: tournament } = useTournament(id);
|
||||||
|
const { data: prediction } = usePlayerPrediction(id, playerId);
|
||||||
|
|
||||||
|
const matches = tournament.matches || [];
|
||||||
|
const picks = prediction?.picks ?? {};
|
||||||
|
|
||||||
|
const score = useMemo(
|
||||||
|
() => computePredictionScore(matches, picks),
|
||||||
|
[matches, picks]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="md" px={0}>
|
||||||
|
<Box pos="relative">
|
||||||
|
<PredictionBracket
|
||||||
|
matches={matches}
|
||||||
|
picks={picks}
|
||||||
|
mode="view"
|
||||||
|
perMatch={score.perMatch}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
pos="absolute"
|
||||||
|
left={0}
|
||||||
|
right={0}
|
||||||
|
bottom={0}
|
||||||
|
p="md"
|
||||||
|
style={{ zIndex: 2, pointerEvents: "none" }}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
withBorder
|
||||||
|
shadow="md"
|
||||||
|
radius="lg"
|
||||||
|
p="sm"
|
||||||
|
style={{ pointerEvents: "auto" }}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
|
<Group gap="sm" align="center" wrap="nowrap">
|
||||||
|
<PlayerAvatar
|
||||||
|
name={`${prediction?.player.first_name} ${prediction?.player.last_name}`}
|
||||||
|
size={32}
|
||||||
|
disableFullscreen
|
||||||
|
/>
|
||||||
|
<Text size="sm" fw={600} lineClamp={1}>
|
||||||
|
{prediction?.player.first_name} {prediction?.player.last_name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap="md" wrap="nowrap">
|
||||||
|
<Stack gap={0} ta="center">
|
||||||
|
<Text size="xs" c="dimmed" fw={700}>
|
||||||
|
PTS
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={700}>
|
||||||
|
{score.points}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={0} ta="center">
|
||||||
|
<Text size="xs" c="dimmed" fw={700}>
|
||||||
|
PICKS
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{score.correct}/{score.total}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
|
import { tournamentQueries, useTournament } from "@/features/tournaments/queries";
|
||||||
|
import { predictionQueries, useMyPrediction } from "@/features/predictions/queries";
|
||||||
|
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||||
|
import { Container } from "@mantine/core";
|
||||||
|
import { BracketPending } from "@/features/bracket/components/bracket-pending";
|
||||||
|
import { PredictionEditor } from "@/features/predictions/components/prediction-editor";
|
||||||
|
|
||||||
|
export const Route = createFileRoute(
|
||||||
|
"/_authed/tournaments/$id/predictions_/make"
|
||||||
|
)({
|
||||||
|
beforeLoad: async ({ context, params }) => {
|
||||||
|
const { queryClient } = context;
|
||||||
|
const tournament = await ensureServerQueryData(
|
||||||
|
queryClient,
|
||||||
|
tournamentQueries.details(params.id)
|
||||||
|
);
|
||||||
|
if (!tournament) throw redirect({ to: "/tournaments" });
|
||||||
|
|
||||||
|
const myPrediction = await ensureServerQueryData(
|
||||||
|
queryClient,
|
||||||
|
predictionQueries.mine(params.id)
|
||||||
|
);
|
||||||
|
if (!myPrediction.eligible || myPrediction.locked) {
|
||||||
|
throw redirect({
|
||||||
|
to: "/tournaments/$id/predictions",
|
||||||
|
params: { id: params.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tournament,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
loader: ({ context }) => ({
|
||||||
|
fullWidth: true,
|
||||||
|
withPadding: false,
|
||||||
|
header: {
|
||||||
|
withBackButton: true,
|
||||||
|
title: `${context.tournament.name}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
component: RouteComponent,
|
||||||
|
pendingComponent: BracketPending,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const { id } = Route.useParams();
|
||||||
|
const { data: tournament } = useTournament(id);
|
||||||
|
const { data: myPrediction } = useMyPrediction(id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="md" px={0}>
|
||||||
|
<PredictionEditor
|
||||||
|
tournament={tournament}
|
||||||
|
initialPicks={myPrediction.prediction?.picks ?? {}}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { serverEvents, type ServerEvent } from "@/lib/events/emitter";
|
import { serverEvents, EVENT_TYPES, type ServerEvent } from "@/lib/events/emitter";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||||
|
|
||||||
let activeConnections = 0;
|
let activeConnections = 0;
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
export const Route = createFileRoute("/api/events/$")({
|
export const Route = createFileRoute("/api/events/$")({
|
||||||
server: {
|
server: {
|
||||||
@@ -13,63 +14,47 @@ export const Route = createFileRoute("/api/events/$")({
|
|||||||
activeConnections++;
|
activeConnections++;
|
||||||
const connectionId = `conn_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
const connectionId = `conn_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
||||||
logger.info(`ServerEvents | New connection ${connectionId}. Active: ${activeConnections}`);
|
logger.info(`ServerEvents | New connection ${connectionId}. Active: ${activeConnections}`);
|
||||||
|
|
||||||
|
let cleanedUp = false;
|
||||||
|
let cleanup = () => {};
|
||||||
|
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
const connectMessage = `data: ${JSON.stringify({ type: "connected" })}\n\n`;
|
const send = (payload: unknown) => {
|
||||||
controller.enqueue(new TextEncoder().encode(connectMessage));
|
|
||||||
|
|
||||||
const handleEvent = (event: ServerEvent) => {
|
|
||||||
logger.info("ServerEvents | Event received", event);
|
|
||||||
const message = `data: ${JSON.stringify(event)}\n\n`;
|
|
||||||
try {
|
try {
|
||||||
if (!controller.desiredSize || controller.desiredSize <= 0) {
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`));
|
||||||
logger.warn("ServerEvents | Stream closed, skipping event");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
controller.enqueue(new TextEncoder().encode(message));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("ServerEvents | Error sending SSE message", error);
|
logger.error("ServerEvents | Error sending SSE message", error);
|
||||||
|
cleanup();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
serverEvents.on("test", handleEvent);
|
const handleEvent = (event: ServerEvent) => send(event);
|
||||||
serverEvents.on("match", handleEvent);
|
for (const type of EVENT_TYPES) {
|
||||||
serverEvents.on("reaction", handleEvent);
|
serverEvents.on(type, handleEvent);
|
||||||
|
}
|
||||||
|
|
||||||
const pingInterval = setInterval(() => {
|
const pingInterval = setInterval(() => {
|
||||||
try {
|
send({ type: "ping", timestamp: Date.now() });
|
||||||
if (!controller.desiredSize || controller.desiredSize <= 0) {
|
|
||||||
clearInterval(pingInterval);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const pingMessage = `data: ${JSON.stringify({ type: "ping", timestamp: Date.now() })}\n\n`;
|
|
||||||
controller.enqueue(new TextEncoder().encode(pingMessage));
|
|
||||||
} catch (e) {
|
|
||||||
logger.error("ServerEvents | Ping interval error", e);
|
|
||||||
clearInterval(pingInterval);
|
|
||||||
}
|
|
||||||
}, 15000);
|
}, 15000);
|
||||||
|
|
||||||
setTimeout(() => {
|
cleanup = () => {
|
||||||
try {
|
if (cleanedUp) return;
|
||||||
const heartbeatMessage = `data: ${JSON.stringify({ type: "heartbeat", timestamp: Date.now() })}\n\n`;
|
cleanedUp = true;
|
||||||
controller.enqueue(new TextEncoder().encode(heartbeatMessage));
|
|
||||||
} catch (e) {
|
|
||||||
logger.error("ServerEvents | Heartbeat error", e);
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
activeConnections--;
|
activeConnections--;
|
||||||
serverEvents.off("test", handleEvent);
|
for (const type of EVENT_TYPES) {
|
||||||
serverEvents.off("match", handleEvent);
|
serverEvents.off(type, handleEvent);
|
||||||
serverEvents.off("reaction", handleEvent);
|
}
|
||||||
clearInterval(pingInterval);
|
clearInterval(pingInterval);
|
||||||
logger.info(`ServerEvents | Connection ${connectionId} cleanup completed. Active: ${activeConnections}`);
|
logger.info(`ServerEvents | Connection ${connectionId} cleanup completed. Active: ${activeConnections}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
request.signal?.addEventListener("abort", cleanup);
|
request.signal?.addEventListener("abort", cleanup);
|
||||||
return cleanup;
|
|
||||||
|
send({ type: "connected" });
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
cleanup();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,13 +62,7 @@ export const Route = createFileRoute("/api/events/$")({
|
|||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "text/event-stream",
|
"Content-Type": "text/event-stream",
|
||||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||||
"Connection": "keep-alive",
|
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Access-Control-Allow-Headers": "Cache-Control",
|
|
||||||
"X-Accel-Buffering": "no",
|
"X-Accel-Buffering": "no",
|
||||||
"X-Proxy-Buffering": "no",
|
|
||||||
"Proxy-Buffering": "off",
|
|
||||||
"Transfer-Encoding": "chunked",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||||
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
|
const json = (body: unknown, status = 200) =>
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/api/push/subscribe")({
|
||||||
|
server: {
|
||||||
|
middleware: [superTokensRequestMiddleware],
|
||||||
|
handlers: {
|
||||||
|
POST: async ({ request, context }) => {
|
||||||
|
const player = (context as any).player;
|
||||||
|
if (!player) return json({ error: "No player for session" }, 401);
|
||||||
|
|
||||||
|
let payload: any;
|
||||||
|
try {
|
||||||
|
payload = await request.json();
|
||||||
|
} catch {
|
||||||
|
return json({ error: "Invalid JSON" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sub = payload?.subscription ?? payload;
|
||||||
|
const endpoint: string | undefined = sub?.endpoint;
|
||||||
|
const p256dh: string | undefined = sub?.keys?.p256dh;
|
||||||
|
const auth: string | undefined = sub?.keys?.auth;
|
||||||
|
|
||||||
|
if (!endpoint || !p256dh || !auth) {
|
||||||
|
return json({ error: "Invalid subscription" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pbAdmin.upsertPushSubscription({
|
||||||
|
player: player.id,
|
||||||
|
endpoint,
|
||||||
|
p256dh,
|
||||||
|
auth,
|
||||||
|
user_agent: request.headers.get("user-agent") ?? undefined,
|
||||||
|
});
|
||||||
|
return json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Push | subscribe failed", error);
|
||||||
|
return json({ error: "Failed to store subscription" }, 500);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||||
|
import { sendPushToPlayer } from "@/lib/push";
|
||||||
|
import { isPushConfigured } from "@/lib/config";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
|
const json = (body: unknown, status = 200) =>
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/api/push/test")({
|
||||||
|
server: {
|
||||||
|
middleware: [superTokensRequestMiddleware],
|
||||||
|
handlers: {
|
||||||
|
POST: async ({ context }) => {
|
||||||
|
const player = (context as any).player;
|
||||||
|
if (!player) return json({ error: "No player for session" }, 401);
|
||||||
|
|
||||||
|
if (!isPushConfigured()) {
|
||||||
|
return json({ error: "Push is not configured on the server" }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await sendPushToPlayer(player.id, {
|
||||||
|
title: "Flexxon",
|
||||||
|
body: "Test notification — push is working on this device.",
|
||||||
|
url: "/settings",
|
||||||
|
tag: "flexxon-test",
|
||||||
|
});
|
||||||
|
return json({ ok: true, ...result });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Push | test send failed", error);
|
||||||
|
return json({ error: "Failed to send test notification" }, 500);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||||
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
|
const json = (body: unknown, status = 200) =>
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/api/push/unsubscribe")({
|
||||||
|
server: {
|
||||||
|
middleware: [superTokensRequestMiddleware],
|
||||||
|
handlers: {
|
||||||
|
POST: async ({ request, context }) => {
|
||||||
|
const player = (context as any).player;
|
||||||
|
if (!player) return json({ error: "No player for session" }, 401);
|
||||||
|
|
||||||
|
let payload: any;
|
||||||
|
try {
|
||||||
|
payload = await request.json();
|
||||||
|
} catch {
|
||||||
|
return json({ error: "Invalid JSON" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint: string | undefined =
|
||||||
|
payload?.endpoint ?? payload?.subscription?.endpoint;
|
||||||
|
if (!endpoint) return json({ error: "Missing endpoint" }, 400);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pbAdmin.deletePushSubscriptionByEndpoint(endpoint);
|
||||||
|
return json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Push | unsubscribe failed", error);
|
||||||
|
return json({ error: "Failed to remove subscription" }, 500);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,36 +1,29 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { Paper, Box } from "@mantine/core";
|
import { Box, Avatar as MantineAvatar } from "@mantine/core";
|
||||||
import {
|
|
||||||
Avatar as MantineAvatar,
|
|
||||||
AvatarProps as MantineAvatarProps,
|
|
||||||
} from "@mantine/core";
|
|
||||||
|
|
||||||
interface GlitchAvatarProps
|
interface GlitchAvatarProps {
|
||||||
extends Omit<MantineAvatarProps, "radius" | "color" | "size"> {
|
|
||||||
name: string;
|
name: string;
|
||||||
src?: string;
|
src?: string;
|
||||||
glitchSrc?: string;
|
glitchSrc?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
radius?: string | number;
|
radius?: string | number;
|
||||||
withBorder?: boolean;
|
withBorder?: boolean;
|
||||||
contain?: boolean;
|
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
px?: string | number;
|
|
||||||
frame?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FRAME_PADDING = 8;
|
||||||
|
|
||||||
|
const toCssRadius = (radius: string | number) =>
|
||||||
|
typeof radius === "number" ? `${radius}px` : `var(--mantine-radius-${radius})`;
|
||||||
|
|
||||||
const GlitchAvatar = ({
|
const GlitchAvatar = ({
|
||||||
name,
|
name,
|
||||||
src,
|
src,
|
||||||
glitchSrc,
|
glitchSrc,
|
||||||
size = 35,
|
size = 35,
|
||||||
radius = "100%",
|
radius = "md",
|
||||||
withBorder = true,
|
withBorder = true,
|
||||||
contain = false,
|
|
||||||
children,
|
children,
|
||||||
px,
|
|
||||||
frame = false,
|
|
||||||
...props
|
|
||||||
}: GlitchAvatarProps) => {
|
}: GlitchAvatarProps) => {
|
||||||
const [showGlitch, setShowGlitch] = useState(false);
|
const [showGlitch, setShowGlitch] = useState(false);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
@@ -90,39 +83,61 @@ const GlitchAvatar = ({
|
|||||||
});
|
});
|
||||||
}, [showGlitch, isPlaying]);
|
}, [showGlitch, isPlaying]);
|
||||||
|
|
||||||
|
const innerRadius = toCssRadius(radius);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
padding: "8px",
|
|
||||||
borderRadius:
|
|
||||||
typeof radius === "number"
|
|
||||||
? `${radius + 8}px`
|
|
||||||
: "calc(var(--mantine-radius-md) + 8px)",
|
|
||||||
position: "relative",
|
position: "relative",
|
||||||
...(frame && {
|
width: "fit-content",
|
||||||
boxShadow:
|
padding: FRAME_PADDING,
|
||||||
"0 0 0 1px color-mix(in srgb, var(--mantine-primary-color-filled) 35%, transparent), 0 8px 32px -8px color-mix(in srgb, var(--mantine-primary-color-filled) 30%, transparent)",
|
border: withBorder
|
||||||
}),
|
? "1px solid var(--mantine-color-default-border)"
|
||||||
|
: "1px solid transparent",
|
||||||
|
borderRadius: `calc(${innerRadius} + ${FRAME_PADDING}px)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
{src ? (
|
||||||
|
<Box style={{ position: "relative" }}>
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={name}
|
||||||
style={{
|
style={{
|
||||||
|
display: "block",
|
||||||
|
maxWidth: size,
|
||||||
|
maxHeight: size,
|
||||||
|
width: "auto",
|
||||||
|
height: "auto",
|
||||||
|
borderRadius: innerRadius,
|
||||||
opacity: showGlitch ? 0 : 1,
|
opacity: showGlitch ? 0 : 1,
|
||||||
transition: showGlitch
|
transition: showGlitch
|
||||||
? "opacity 0.05s ease-in"
|
? "opacity 0.05s ease-in"
|
||||||
: "opacity 0.25s ease-out",
|
: "opacity 0.25s ease-out",
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<Paper
|
{glitchSrc && (
|
||||||
py={size / 12.5}
|
<video
|
||||||
px={size / 20}
|
ref={videoRef}
|
||||||
bg="var(--mantine-color-default-border)"
|
src={glitchSrc}
|
||||||
radius={radius}
|
|
||||||
withBorder={false}
|
|
||||||
style={{
|
style={{
|
||||||
cursor: "default",
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
objectFit: "contain",
|
||||||
|
borderRadius: innerRadius,
|
||||||
|
opacity: showGlitch ? 1 : 0,
|
||||||
|
visibility: showGlitch ? "visible" : "hidden",
|
||||||
|
transition: showGlitch ? "opacity 0.05s ease-in" : "none",
|
||||||
|
pointerEvents: "none",
|
||||||
}}
|
}}
|
||||||
>
|
muted
|
||||||
|
playsInline
|
||||||
|
preload="auto"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
<MantineAvatar
|
<MantineAvatar
|
||||||
alt={name}
|
alt={name}
|
||||||
key={name}
|
key={name}
|
||||||
@@ -131,57 +146,9 @@ const GlitchAvatar = ({
|
|||||||
size={size}
|
size={size}
|
||||||
radius={radius}
|
radius={radius}
|
||||||
w={size}
|
w={size}
|
||||||
styles={{
|
|
||||||
image: {
|
|
||||||
objectFit: contain ? "contain" : "cover",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
src={src}
|
|
||||||
{...props}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</MantineAvatar>
|
</MantineAvatar>
|
||||||
</Paper>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{glitchSrc && (
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "8px",
|
|
||||||
left: "8px",
|
|
||||||
opacity: showGlitch ? 1 : 0,
|
|
||||||
visibility: showGlitch ? "visible" : "hidden",
|
|
||||||
transition: showGlitch ? "opacity 0.05s ease-in" : "none",
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Paper
|
|
||||||
py={size / 12.5}
|
|
||||||
px={size / 20}
|
|
||||||
bg="var(--mantine-color-default-border)"
|
|
||||||
radius={radius}
|
|
||||||
withBorder={false}
|
|
||||||
style={{
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
src={glitchSrc}
|
|
||||||
style={{
|
|
||||||
width: `${size}px`,
|
|
||||||
height: `${size}px`,
|
|
||||||
objectFit: contain ? "contain" : "cover",
|
|
||||||
borderRadius: typeof radius === "number" ? `${radius}px` : radius,
|
|
||||||
display: "block",
|
|
||||||
}}
|
|
||||||
muted
|
|
||||||
playsInline
|
|
||||||
preload="auto"
|
|
||||||
/>
|
|
||||||
</Paper>
|
|
||||||
</Box>
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||||
|
import { Box } from "@mantine/core";
|
||||||
|
|
||||||
|
interface InfiniteScrollProps<T> {
|
||||||
|
items: T[];
|
||||||
|
renderItem: (item: T, index: number) => ReactNode;
|
||||||
|
batchSize?: number;
|
||||||
|
initialCount?: number;
|
||||||
|
rootMargin?: string;
|
||||||
|
loader?: ReactNode;
|
||||||
|
hasMore?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
onLoadMore?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InfiniteScroll = <T,>({
|
||||||
|
items,
|
||||||
|
renderItem,
|
||||||
|
batchSize = 25,
|
||||||
|
initialCount = batchSize,
|
||||||
|
rootMargin = "600px 0px",
|
||||||
|
loader,
|
||||||
|
hasMore = false,
|
||||||
|
loading = false,
|
||||||
|
onLoadMore,
|
||||||
|
}: InfiniteScrollProps<T>) => {
|
||||||
|
const [visibleCount, setVisibleCount] = useState(initialCount);
|
||||||
|
const [prevItems, setPrevItems] = useState(items);
|
||||||
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
if (prevItems !== items) {
|
||||||
|
setPrevItems(items);
|
||||||
|
setVisibleCount(initialCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasHiddenItems = visibleCount < items.length;
|
||||||
|
const showLoader = hasHiddenItems || hasMore || loading;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sentinel = sentinelRef.current;
|
||||||
|
if (!sentinel) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (!entries.some((entry) => entry.isIntersecting)) return;
|
||||||
|
|
||||||
|
if (visibleCount < items.length) {
|
||||||
|
setVisibleCount((count) => Math.min(count + batchSize, items.length));
|
||||||
|
} else if (hasMore && !loading) {
|
||||||
|
onLoadMore?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin }
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(sentinel);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [items, visibleCount, batchSize, hasMore, loading, onLoadMore, rootMargin]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{items.slice(0, visibleCount).map((item, index) => renderItem(item, index))}
|
||||||
|
{showLoader && (
|
||||||
|
<>
|
||||||
|
<Box ref={sentinelRef} />
|
||||||
|
{loader}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InfiniteScroll;
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { forwardRef } from "react";
|
||||||
|
import type { Icon, IconProps } from "@phosphor-icons/react";
|
||||||
|
|
||||||
|
export const WizardOrbIcon = forwardRef<SVGSVGElement, IconProps>(
|
||||||
|
(
|
||||||
|
{ size = 24, color = "currentColor", weight = "regular", mirrored, alt, style, ...rest },
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const strokeWidth = weight === "bold" ? 20 : 16;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
ref={ref}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 256 256"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
fill="none"
|
||||||
|
style={{
|
||||||
|
transform: mirrored ? "scale(-1, 1)" : undefined,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
{...(alt ? { role: "img", "aria-label": alt } : { "aria-hidden": true })}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="128"
|
||||||
|
cy="112"
|
||||||
|
r="66"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
fill={weight === "duotone" ? color : "none"}
|
||||||
|
fillOpacity={weight === "duotone" ? 0.18 : undefined}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M94 96 a 44 44 0 0 1 30 -26"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={strokeWidth * 0.7}
|
||||||
|
strokeLinecap="round"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M96 194 h64 l16 26 h-96 z"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M208 26 l8 16 16 8 -16 8 -8 16 -8 -16 -16 -8 16 -8 z"
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
) as Icon;
|
||||||
|
|
||||||
|
export default WizardOrbIcon;
|
||||||
@@ -3,6 +3,7 @@ import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } fro
|
|||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { emitServerEvent } from "@/lib/events/emitter";
|
||||||
|
|
||||||
export const getPlayerBadges = createServerFn()
|
export const getPlayerBadges = createServerFn()
|
||||||
.validator(z.string())
|
.validator(z.string())
|
||||||
@@ -14,7 +15,11 @@ export const getPlayerBadges = createServerFn()
|
|||||||
export const migrateBadgeProgress = createServerFn()
|
export const migrateBadgeProgress = createServerFn()
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
.handler(async () =>
|
.handler(async () =>
|
||||||
toServerResult(() => pbAdmin.migrateBadgeProgress())
|
toServerResult(async () => {
|
||||||
|
const result = await pbAdmin.migrateBadgeProgress();
|
||||||
|
emitServerEvent({ type: "badge" });
|
||||||
|
return result;
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getAllBadges = createServerFn()
|
export const getAllBadges = createServerFn()
|
||||||
@@ -34,5 +39,9 @@ export const awardManualBadge = createServerFn()
|
|||||||
}))
|
}))
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult(() => pbAdmin.awardManualBadge(data.playerId, data.badgeId))
|
toServerResult(async () => {
|
||||||
|
const result = await pbAdmin.awardManualBadge(data.playerId, data.badgeId);
|
||||||
|
emitServerEvent({ type: "badge", playerId: data.playerId });
|
||||||
|
return result;
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Box, Container, Flex, Skeleton, Stack } from "@mantine/core";
|
||||||
|
|
||||||
|
export function BracketPending() {
|
||||||
|
const columns = [4, 2, 1];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="md" px={0}>
|
||||||
|
<Box
|
||||||
|
p={0}
|
||||||
|
style={{
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundImage: `radial-gradient(circle, var(--mantine-color-default-border) 1px, transparent 1px)`,
|
||||||
|
backgroundSize: "16px 16px",
|
||||||
|
backgroundPosition: "0 0, 8px 8px",
|
||||||
|
minHeight: "70dvh",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Skeleton height={18} width={140} radius="sm" m={16} />
|
||||||
|
<Flex gap="xl" px={16} align="stretch">
|
||||||
|
{columns.map((count, columnIndex) => (
|
||||||
|
<Stack
|
||||||
|
key={`bracket-pending-round-${columnIndex}`}
|
||||||
|
gap="xl"
|
||||||
|
justify="space-around"
|
||||||
|
style={{ opacity: 1 - columnIndex * 0.25 }}
|
||||||
|
>
|
||||||
|
{Array.from({ length: count }).map((_, matchIndex) => (
|
||||||
|
<Skeleton
|
||||||
|
key={`bracket-pending-match-${columnIndex}-${matchIndex}`}
|
||||||
|
height={84}
|
||||||
|
width={220}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useMemo } from "react";
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Text, ScrollArea } from "@mantine/core";
|
import { Text, ScrollArea, Box } from "@mantine/core";
|
||||||
import { BracketData } from "../types";
|
import { BracketData } from "../types";
|
||||||
import { Bracket } from "./bracket";
|
import { Bracket } from "./bracket";
|
||||||
|
import MatchDock from "./match-dock";
|
||||||
import useAppShellHeight from "@/hooks/use-appshell-height";
|
import useAppShellHeight from "@/hooks/use-appshell-height";
|
||||||
import { Match } from "@/features/matches/types";
|
import { Match } from "@/features/matches/types";
|
||||||
import styles from "./styles.module.css";
|
import styles from "./styles.module.css";
|
||||||
@@ -13,10 +14,15 @@ interface BracketViewProps {
|
|||||||
num_groups: number;
|
num_groups: number;
|
||||||
advance_per_group: number;
|
advance_per_group: number;
|
||||||
};
|
};
|
||||||
|
renderMatch?: (match: Match) => React.ReactNode;
|
||||||
|
bottomOffset?: number;
|
||||||
|
nextUpMatchId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupConfig }) => {
|
const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupConfig, renderMatch, bottomOffset, nextUpMatchId }) => {
|
||||||
const height = useAppShellHeight();
|
const height = useAppShellHeight();
|
||||||
|
const viewportRef = useRef<HTMLDivElement>(null);
|
||||||
|
const hasAutoScrolled = useRef(false);
|
||||||
const orders = useMemo(() => {
|
const orders = useMemo(() => {
|
||||||
const map: Record<number, number> = {};
|
const map: Record<number, number> = {};
|
||||||
bracket.winners.flat().forEach(match => map[match.lid] = match.order);
|
bracket.winners.flat().forEach(match => map[match.lid] = match.order);
|
||||||
@@ -24,7 +30,61 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupC
|
|||||||
return map;
|
return map;
|
||||||
}, [bracket.winners, bracket.losers]);
|
}, [bracket.winners, bracket.losers]);
|
||||||
|
|
||||||
return <ScrollArea
|
const [selectedLid, setSelectedLid] = useState<number | null>(null);
|
||||||
|
const handleMatchTap = useCallback((match: Match) => {
|
||||||
|
setSelectedLid((prev) => (prev === match.lid ? null : match.lid));
|
||||||
|
}, []);
|
||||||
|
const closeDock = useCallback(() => setSelectedLid(null), []);
|
||||||
|
|
||||||
|
const selectedMatch = useMemo(() => {
|
||||||
|
if (selectedLid == null) return null;
|
||||||
|
return (
|
||||||
|
[...bracket.winners.flat(), ...bracket.losers.flat()].find(
|
||||||
|
(match) => match.lid === selectedLid
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
|
}, [bracket, selectedLid]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasAutoScrolled.current) return;
|
||||||
|
|
||||||
|
const matches = [...bracket.winners.flat(), ...bracket.losers.flat()].filter(
|
||||||
|
(match) => !match.bye
|
||||||
|
);
|
||||||
|
const target = nextUpMatchId !== undefined
|
||||||
|
? matches.find((match) => match.id === nextUpMatchId)
|
||||||
|
: matches.find((match) => match.status === "started") ??
|
||||||
|
matches.find((match) => match.status === "ready");
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
const viewport = viewportRef.current;
|
||||||
|
const element = viewport?.querySelector(`[data-match-lid="${target.lid}"]`);
|
||||||
|
if (!viewport || !element) return;
|
||||||
|
|
||||||
|
hasAutoScrolled.current = true;
|
||||||
|
|
||||||
|
const viewportRect = viewport.getBoundingClientRect();
|
||||||
|
const elementRect = element.getBoundingClientRect();
|
||||||
|
const left =
|
||||||
|
elementRect.left - viewportRect.left + viewport.scrollLeft - 40;
|
||||||
|
const top =
|
||||||
|
elementRect.top -
|
||||||
|
viewportRect.top +
|
||||||
|
viewport.scrollTop -
|
||||||
|
(viewport.clientHeight - elementRect.height) / 2;
|
||||||
|
|
||||||
|
viewport.scrollTo({
|
||||||
|
left: Math.max(0, left),
|
||||||
|
top: Math.max(0, top),
|
||||||
|
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||||
|
? "auto"
|
||||||
|
: "smooth",
|
||||||
|
});
|
||||||
|
}, [bracket, nextUpMatchId]);
|
||||||
|
|
||||||
|
return <Box pos="relative">
|
||||||
|
<ScrollArea
|
||||||
|
viewportRef={viewportRef}
|
||||||
h={`calc(${height})`}
|
h={`calc(${height})`}
|
||||||
className={styles["bracket-container"]}
|
className={styles["bracket-container"]}
|
||||||
style={{
|
style={{
|
||||||
@@ -37,17 +97,20 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupC
|
|||||||
<Text fw={600} size="md" m={16}>
|
<Text fw={600} size="md" m={16}>
|
||||||
Winners Bracket
|
Winners Bracket
|
||||||
</Text>
|
</Text>
|
||||||
<Bracket rounds={bracket.winners} orders={orders} showControls={showControls} groupConfig={groupConfig} />
|
<Bracket rounds={bracket.winners} orders={orders} showControls={showControls} groupConfig={groupConfig} renderMatch={renderMatch} onMatchTap={handleMatchTap} selectedMatchLid={selectedLid} nextUpMatchId={nextUpMatchId} />
|
||||||
</div>
|
</div>
|
||||||
{bracket.losers && bracket.losers.length > 0 && bracket.losers.some(round => round.length > 0) && (
|
{bracket.losers && bracket.losers.length > 0 && bracket.losers.some(round => round.length > 0) && (
|
||||||
<div>
|
<div>
|
||||||
<Text fw={600} size="md" m={16}>
|
<Text fw={600} size="md" m={16}>
|
||||||
Losers Bracket
|
Losers Bracket
|
||||||
</Text>
|
</Text>
|
||||||
<Bracket rounds={bracket.losers} orders={orders} showControls={showControls} groupConfig={groupConfig} />
|
<Bracket rounds={bracket.losers} orders={orders} showControls={showControls} groupConfig={groupConfig} renderMatch={renderMatch} onMatchTap={handleMatchTap} selectedMatchLid={selectedLid} nextUpMatchId={nextUpMatchId} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{bottomOffset ? <div style={{ height: bottomOffset }} /> : null}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
<MatchDock match={selectedMatch} onClose={closeDock} />
|
||||||
|
</Box>
|
||||||
};
|
};
|
||||||
|
|
||||||
export default BracketView;
|
export default BracketView;
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ interface BracketProps {
|
|||||||
num_groups: number;
|
num_groups: number;
|
||||||
advance_per_group: number;
|
advance_per_group: number;
|
||||||
};
|
};
|
||||||
|
renderMatch?: (match: Match) => React.ReactNode;
|
||||||
|
onMatchTap?: (match: Match) => void;
|
||||||
|
selectedMatchLid?: number | null;
|
||||||
|
nextUpMatchId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Bracket: React.FC<BracketProps> = ({
|
export const Bracket: React.FC<BracketProps> = ({
|
||||||
@@ -18,6 +22,10 @@ export const Bracket: React.FC<BracketProps> = ({
|
|||||||
orders,
|
orders,
|
||||||
showControls,
|
showControls,
|
||||||
groupConfig,
|
groupConfig,
|
||||||
|
renderMatch,
|
||||||
|
onMatchTap,
|
||||||
|
selectedMatchLid,
|
||||||
|
nextUpMatchId,
|
||||||
}) => {
|
}) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const svgRef = useRef<SVGSVGElement>(null);
|
const svgRef = useRef<SVGSVGElement>(null);
|
||||||
@@ -133,12 +141,19 @@ export const Bracket: React.FC<BracketProps> = ({
|
|||||||
<div key={match.lid}></div>
|
<div key={match.lid}></div>
|
||||||
) : (
|
) : (
|
||||||
<div key={match.lid}>
|
<div key={match.lid}>
|
||||||
|
{renderMatch ? (
|
||||||
|
renderMatch(match)
|
||||||
|
) : (
|
||||||
<MatchCard
|
<MatchCard
|
||||||
match={match}
|
match={match}
|
||||||
orders={orders}
|
orders={orders}
|
||||||
showControls={showControls}
|
showControls={showControls}
|
||||||
groupConfig={groupConfig}
|
groupConfig={groupConfig}
|
||||||
|
onTap={onMatchTap}
|
||||||
|
selected={selectedMatchLid === match.lid}
|
||||||
|
nextUpMatchId={nextUpMatchId}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ActionIcon, Card, Flex, Text, Indicator } from "@mantine/core";
|
import { ActionIcon, Card, Flex, Text, Indicator, Stack } from "@mantine/core";
|
||||||
import { PlayIcon, PencilIcon, SpeakerHighIcon } from "@phosphor-icons/react";
|
import { PlayIcon, PencilIcon, SpeakerHighIcon } from "@phosphor-icons/react";
|
||||||
import React, { useCallback, useMemo } from "react";
|
import React, { useCallback, useMemo } from "react";
|
||||||
import { MatchSlot } from "./match-slot";
|
import { MatchSlot } from "./match-slot";
|
||||||
@@ -6,12 +6,16 @@ import { Match } from "@/features/matches/types";
|
|||||||
import { Team } from "@/features/teams/types";
|
import { Team } from "@/features/teams/types";
|
||||||
import { useSheet } from "@/hooks/use-sheet";
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
import { MatchForm } from "./match-form";
|
import { MatchForm } from "./match-form";
|
||||||
|
import { MatchReport } from "./match-report";
|
||||||
|
import toast from "@/lib/sonner";
|
||||||
import Sheet from "@/components/sheet/sheet";
|
import Sheet from "@/components/sheet/sheet";
|
||||||
import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
||||||
import { endMatch, startMatch } from "@/features/matches/server";
|
import { endMatch, startMatch } from "@/features/matches/server";
|
||||||
import { tournamentKeys } from "@/features/tournaments/queries";
|
import { tournamentKeys } from "@/features/tournaments/queries";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useSpotifyPlayback } from "@/lib/spotify/hooks";
|
import { useSpotifyPlayback } from "@/lib/spotify/hooks";
|
||||||
|
import { getGroupLabel } from "../utils/group-label";
|
||||||
|
import styles from "./styles.module.css";
|
||||||
|
|
||||||
interface MatchCardProps {
|
interface MatchCardProps {
|
||||||
match: Match;
|
match: Match;
|
||||||
@@ -21,6 +25,9 @@ interface MatchCardProps {
|
|||||||
num_groups: number;
|
num_groups: number;
|
||||||
advance_per_group: number;
|
advance_per_group: number;
|
||||||
};
|
};
|
||||||
|
onTap?: (match: Match) => void;
|
||||||
|
selected?: boolean;
|
||||||
|
nextUpMatchId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MatchCard: React.FC<MatchCardProps> = ({
|
export const MatchCard: React.FC<MatchCardProps> = ({
|
||||||
@@ -28,50 +35,17 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
orders,
|
orders,
|
||||||
showControls,
|
showControls,
|
||||||
groupConfig,
|
groupConfig,
|
||||||
|
onTap,
|
||||||
|
selected,
|
||||||
|
nextUpMatchId,
|
||||||
}) => {
|
}) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const editSheet = useSheet();
|
const editSheet = useSheet();
|
||||||
const { playTrack, pause } = useSpotifyPlayback();
|
const { playTrack, pause } = useSpotifyPlayback();
|
||||||
|
|
||||||
const getGroupLabel = useCallback((seed: number | undefined) => {
|
const canTap = !!(onTap && match.home && match.away);
|
||||||
if (!seed || !groupConfig) return undefined;
|
|
||||||
|
|
||||||
const groupNames = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
|
const isNextUp = nextUpMatchId !== undefined && match.id === nextUpMatchId;
|
||||||
const numGroups = groupConfig.num_groups;
|
|
||||||
const advancePerGroup = groupConfig.advance_per_group;
|
|
||||||
|
|
||||||
const totalQualifiedTeams = numGroups * advancePerGroup;
|
|
||||||
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(totalQualifiedTeams)));
|
|
||||||
const wildcardsNeeded = nextPowerOf2 - totalQualifiedTeams;
|
|
||||||
|
|
||||||
if (seed > totalQualifiedTeams && wildcardsNeeded > 0) {
|
|
||||||
const wildcardNumber = seed - totalQualifiedTeams;
|
|
||||||
return `Wildcard ${wildcardNumber}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pairIndex = Math.floor((seed - 1) / 2);
|
|
||||||
const isFirstInPair = (seed - 1) % 2 === 0;
|
|
||||||
|
|
||||||
if (isFirstInPair) {
|
|
||||||
const groupIndex = pairIndex % numGroups;
|
|
||||||
const rankIndex = Math.floor(pairIndex / numGroups);
|
|
||||||
|
|
||||||
const rank = rankIndex + 1;
|
|
||||||
const groupName = groupNames[groupIndex] || `${groupIndex + 1}`;
|
|
||||||
const rankSuffix = rank === 1 ? '1st' : rank === 2 ? '2nd' : rank === 3 ? '3rd' : `${rank}th`;
|
|
||||||
|
|
||||||
return `${groupName} ${rankSuffix}`;
|
|
||||||
} else {
|
|
||||||
const groupIndex = (pairIndex + 1) % numGroups;
|
|
||||||
const rankIndex = advancePerGroup - 1 - Math.floor(pairIndex / numGroups);
|
|
||||||
|
|
||||||
const rank = rankIndex + 1;
|
|
||||||
const groupName = groupNames[groupIndex] || `${groupIndex + 1}`;
|
|
||||||
const rankSuffix = rank === 1 ? '1st' : rank === 2 ? '2nd' : rank === 3 ? '3rd' : `${rank}th`;
|
|
||||||
|
|
||||||
return `${groupName} ${rankSuffix}`;
|
|
||||||
}
|
|
||||||
}, [groupConfig]);
|
|
||||||
|
|
||||||
const homeSlot = useMemo(
|
const homeSlot = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -85,9 +59,9 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
match.home_cups !== undefined &&
|
match.home_cups !== undefined &&
|
||||||
match.away_cups !== undefined &&
|
match.away_cups !== undefined &&
|
||||||
match.home_cups > match.away_cups,
|
match.home_cups > match.away_cups,
|
||||||
groupLabel: !match.home && match.home_seed ? getGroupLabel(match.home_seed) : undefined,
|
groupLabel: !match.home && match.home_seed ? getGroupLabel(match.home_seed, groupConfig) : undefined,
|
||||||
}),
|
}),
|
||||||
[match, getGroupLabel]
|
[match, orders, groupConfig]
|
||||||
);
|
);
|
||||||
const awaySlot = useMemo(
|
const awaySlot = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -101,9 +75,9 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
match.away_cups !== undefined &&
|
match.away_cups !== undefined &&
|
||||||
match.home_cups !== undefined &&
|
match.home_cups !== undefined &&
|
||||||
match.away_cups > match.home_cups,
|
match.away_cups > match.home_cups,
|
||||||
groupLabel: !match.away && match.away_seed ? getGroupLabel(match.away_seed) : undefined,
|
groupLabel: !match.away && match.away_seed ? getGroupLabel(match.away_seed, groupConfig) : undefined,
|
||||||
}),
|
}),
|
||||||
[match, getGroupLabel]
|
[match, orders, groupConfig]
|
||||||
);
|
);
|
||||||
|
|
||||||
const showToolbar = useMemo(
|
const showToolbar = useMemo(
|
||||||
@@ -112,7 +86,8 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const showEditButton = useMemo(
|
const showEditButton = useMemo(
|
||||||
() => showControls && match.status === "started",
|
() =>
|
||||||
|
showControls && (match.status === "started" || match.status === "ended"),
|
||||||
[showControls, match.status]
|
[showControls, match.status]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -130,11 +105,21 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
|
|
||||||
const end = useServerMutation({
|
const end = useServerMutation({
|
||||||
mutationFn: endMatch,
|
mutationFn: endMatch,
|
||||||
onSuccess: () => {
|
onSuccess: (data) => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: tournamentKeys.details(match.tournament.id),
|
queryKey: tournamentKeys.details(match.tournament.id),
|
||||||
});
|
});
|
||||||
editSheet.close();
|
editSheet.close();
|
||||||
|
if (data?.downstreamReset) {
|
||||||
|
toast.error(
|
||||||
|
"Downstream matches were reset because the correction changed who advances."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data?.groupEditAfterKnockout) {
|
||||||
|
toast.error(
|
||||||
|
"Group result changed after the bracket was seeded — reseed the knockout stage if needed."
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -227,10 +212,8 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
data: match.id,
|
data: match.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Skip announcements for regional tournaments
|
|
||||||
const isRegional = match.tournament?.regional === true;
|
const isRegional = match.tournament?.regional === true;
|
||||||
|
|
||||||
// Play walkout sequence after starting the match (only for non-regional tournaments)
|
|
||||||
if (!isRegional && hasWalkoutData && match.home?.name && match.away?.name) {
|
if (!isRegional && hasWalkoutData && match.home?.name && match.away?.name) {
|
||||||
try {
|
try {
|
||||||
const homeTeam = match.home as Team;
|
const homeTeam = match.home as Team;
|
||||||
@@ -249,6 +232,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
}, [match, start, hasWalkoutData, playTeamWalkout, speak]);
|
}, [match, start, hasWalkoutData, playTeamWalkout, speak]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Stack gap={6} align="flex-end">
|
||||||
<Flex direction="row" align="center" justify="end" gap={8}>
|
<Flex direction="row" align="center" justify="end" gap={8}>
|
||||||
<Text
|
<Text
|
||||||
c="dimmed"
|
c="dimmed"
|
||||||
@@ -261,7 +245,17 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
>
|
>
|
||||||
{match.order}
|
{match.order}
|
||||||
</Text>
|
</Text>
|
||||||
<Flex align="stretch">
|
<Flex
|
||||||
|
align="stretch"
|
||||||
|
style={{
|
||||||
|
borderRadius: "var(--mantine-radius-default)",
|
||||||
|
boxShadow: isNextUp
|
||||||
|
? "0 0 0 1px var(--mantine-primary-color-filled), 0 0 12px var(--mantine-primary-color-light-hover)"
|
||||||
|
: undefined,
|
||||||
|
transition:
|
||||||
|
"box-shadow 200ms cubic-bezier(0.32, 0.72, 0, 1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Indicator
|
<Indicator
|
||||||
inline
|
inline
|
||||||
processing={match.status === "started"}
|
processing={match.status === "started"}
|
||||||
@@ -273,11 +267,34 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
w={showToolbar || showEditButton ? 200 : 220}
|
w={showToolbar || showEditButton ? 200 : 220}
|
||||||
withBorder
|
withBorder
|
||||||
pos="relative"
|
pos="relative"
|
||||||
|
className={canTap ? styles["tappable-card"] : undefined}
|
||||||
|
onClick={canTap ? () => onTap!(match) : undefined}
|
||||||
|
role={canTap ? "button" : undefined}
|
||||||
|
tabIndex={canTap ? 0 : undefined}
|
||||||
|
onKeyDown={
|
||||||
|
canTap
|
||||||
|
? (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
onTap!(match);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
aria-label={
|
||||||
|
canTap
|
||||||
|
? `Match ${match.order}: ${match.home!.name} vs ${match.away!.name}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
style={{
|
style={{
|
||||||
overflow: "visible",
|
overflow: "visible",
|
||||||
backgroundColor: 'var(--mantine-color-body)',
|
backgroundColor: 'var(--mantine-color-body)',
|
||||||
borderColor: 'var(--mantine-color-default-border)',
|
borderColor: selected
|
||||||
|
? 'var(--mantine-primary-color-filled)'
|
||||||
|
: 'var(--mantine-color-default-border)',
|
||||||
boxShadow: 'var(--mantine-shadow-sm)',
|
boxShadow: 'var(--mantine-shadow-sm)',
|
||||||
|
transition:
|
||||||
|
'border-color 200ms cubic-bezier(0.32, 0.72, 0, 1)',
|
||||||
}}
|
}}
|
||||||
data-match-lid={match.lid}
|
data-match-lid={match.lid}
|
||||||
>
|
>
|
||||||
@@ -310,7 +327,10 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
onClick={handleSpeakerClick}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleSpeakerClick();
|
||||||
|
}}
|
||||||
aria-label="Announce matchup"
|
aria-label="Announce matchup"
|
||||||
>
|
>
|
||||||
<SpeakerHighIcon size={12} />
|
<SpeakerHighIcon size={12} />
|
||||||
@@ -344,7 +364,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
{showEditButton && (
|
{showEditButton && (
|
||||||
<Flex direction="column" justify="center" align="center">
|
<Flex direction="column" justify="center" align="center">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
color="blue"
|
color={match.status === "ended" ? "gray" : "blue"}
|
||||||
onClick={editSheet.open}
|
onClick={editSheet.open}
|
||||||
loading={end.isPending}
|
loading={end.isPending}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -372,5 +392,8 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
/>
|
/>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
|
{!showControls && <MatchReport match={match} compact />}
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Box,
|
||||||
|
Center,
|
||||||
|
CloseButton,
|
||||||
|
Group,
|
||||||
|
Indicator,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||||
|
import { FootballHelmetIcon } from "@phosphor-icons/react";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import { Match } from "@/features/matches/types";
|
||||||
|
import TeamAvatar from "@/components/team-avatar";
|
||||||
|
import AnimatedScore from "@/features/matches/components/animated-score";
|
||||||
|
import EmojiBar from "@/features/reactions/components/emoji-bar";
|
||||||
|
import TeamHeadToHeadSheet from "@/features/matches/components/team-head-to-head-sheet";
|
||||||
|
import { MatchReport } from "./match-report";
|
||||||
|
import Sheet from "@/components/sheet/sheet";
|
||||||
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
|
|
||||||
|
const EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
|
||||||
|
|
||||||
|
interface MatchDockProps {
|
||||||
|
match: Match | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TeamRow = ({
|
||||||
|
team,
|
||||||
|
cups,
|
||||||
|
isWinner,
|
||||||
|
isRegional,
|
||||||
|
ended,
|
||||||
|
}: {
|
||||||
|
team: NonNullable<Match["home"]>;
|
||||||
|
cups: number;
|
||||||
|
isWinner: boolean;
|
||||||
|
isRegional: boolean;
|
||||||
|
ended: boolean;
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group justify="space-between" wrap="nowrap" gap="sm">
|
||||||
|
<Group
|
||||||
|
gap="sm"
|
||||||
|
wrap="nowrap"
|
||||||
|
style={{ flex: 1, minWidth: 0, cursor: "pointer" }}
|
||||||
|
onClick={() => navigate({ to: `/teams/${team.id}` })}
|
||||||
|
>
|
||||||
|
<TeamAvatar
|
||||||
|
team={team}
|
||||||
|
size={32}
|
||||||
|
radius="sm"
|
||||||
|
winner={ended && isWinner}
|
||||||
|
isRegional={isRegional}
|
||||||
|
/>
|
||||||
|
<Text size="sm" fw={ended && isWinner ? 700 : 500} lineClamp={1}>
|
||||||
|
{team.name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
{ended && (
|
||||||
|
<AnimatedScore
|
||||||
|
value={cups}
|
||||||
|
size="lg"
|
||||||
|
fw={700}
|
||||||
|
c={isWinner ? "green" : "dimmed"}
|
||||||
|
style={{ minWidth: 28, textAlign: "center" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MatchDock = ({ match, onClose }: MatchDockProps) => {
|
||||||
|
const reduceMotion = useReducedMotion();
|
||||||
|
const h2hSheet = useSheet();
|
||||||
|
const hasPrivate = match?.home?.private || match?.away?.private;
|
||||||
|
const ended = match?.status === "ended";
|
||||||
|
const started = match?.status === "started";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AnimatePresence>
|
||||||
|
{match && match.home && match.away && (
|
||||||
|
<motion.div
|
||||||
|
key="match-dock"
|
||||||
|
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 16 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 16 }}
|
||||||
|
transition={{ duration: 0.2, ease: EASE }}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
zIndex: 5,
|
||||||
|
padding: "0 12px 12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paper withBorder shadow="md" radius="lg" px="md" py="sm">
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
{started && (
|
||||||
|
<Indicator
|
||||||
|
size={8}
|
||||||
|
color="red"
|
||||||
|
processing
|
||||||
|
position="middle-start"
|
||||||
|
offset={0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Text size="xs" fw={600} c="dimmed" lineClamp={1}>
|
||||||
|
Match {match.order} · Round {match.round + 1}
|
||||||
|
{match.is_losers_bracket && " (Losers)"}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<CloseButton
|
||||||
|
size="sm"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close match actions"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<TeamRow
|
||||||
|
team={match.home}
|
||||||
|
cups={match.home_cups}
|
||||||
|
isWinner={match.home_cups > match.away_cups}
|
||||||
|
isRegional={match.tournament.regional === true}
|
||||||
|
ended={ended}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: "var(--mantine-color-default-border)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TeamRow
|
||||||
|
team={match.away}
|
||||||
|
cups={match.away_cups}
|
||||||
|
isWinner={match.away_cups > match.home_cups}
|
||||||
|
isRegional={match.tournament.regional === true}
|
||||||
|
ended={ended}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MatchReport match={match} />
|
||||||
|
|
||||||
|
<Group justify="space-between" wrap="nowrap" gap="sm">
|
||||||
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Center py={4}>
|
||||||
|
<Loader size="xs" />
|
||||||
|
</Center>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<EmojiBar matchId={match.id} />
|
||||||
|
</Suspense>
|
||||||
|
</Box>
|
||||||
|
{!hasPrivate && (
|
||||||
|
<Tooltip label="Head to Head" withArrow position="top">
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
onClick={h2hSheet.open}
|
||||||
|
aria-label="View head-to-head"
|
||||||
|
w={40}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<Group
|
||||||
|
style={{ position: "relative", width: 27.5, height: 16 }}
|
||||||
|
>
|
||||||
|
<FootballHelmetIcon
|
||||||
|
size={14}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
transform: "rotate(25deg)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FootballHelmetIcon
|
||||||
|
size={14}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
transform: "scaleX(-1) rotate(25deg)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{match?.home && match?.away && h2hSheet.isOpen && (
|
||||||
|
<Sheet title="Head to Head" {...h2hSheet.props}>
|
||||||
|
<TeamHeadToHeadSheet
|
||||||
|
team1={match.home}
|
||||||
|
team2={match.away}
|
||||||
|
isOpen={h2hSheet.props.opened}
|
||||||
|
/>
|
||||||
|
</Sheet>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MatchDock;
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { Alert, Button, Flex, Group, Paper, Stack, Text } from "@mantine/core";
|
||||||
|
import { CheckIcon, LightningIcon } from "@phosphor-icons/react";
|
||||||
|
import { Match } from "@/features/matches/types";
|
||||||
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
import {
|
||||||
|
useReportMatchScore,
|
||||||
|
useConfirmMatchScore,
|
||||||
|
useClearMatchReport,
|
||||||
|
} from "@/features/matches/queries";
|
||||||
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
|
import Sheet from "@/components/sheet/sheet";
|
||||||
|
import { MatchForm } from "./match-form";
|
||||||
|
import toast from "@/lib/sonner";
|
||||||
|
|
||||||
|
const teamId = (t: Match["home"]): string | undefined =>
|
||||||
|
!t ? undefined : typeof t === "string" ? t : t.id;
|
||||||
|
|
||||||
|
const teamPlayerIds = (t: Match["home"]): string[] =>
|
||||||
|
t && typeof t !== "string" ? (t.players ?? []).map((p) => p.id) : [];
|
||||||
|
|
||||||
|
const teamName = (t: Match["home"]): string | undefined =>
|
||||||
|
t && typeof t !== "string" ? t.name : undefined;
|
||||||
|
|
||||||
|
interface MatchReportProps {
|
||||||
|
match: Match;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MatchReport: React.FC<MatchReportProps> = ({ match, compact }) => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const formSheet = useSheet();
|
||||||
|
const [acknowledgedReport, setAcknowledgedReport] = useState<string | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const homeId = teamId(match.home);
|
||||||
|
const onHome = !!user?.id && teamPlayerIds(match.home).includes(user.id);
|
||||||
|
const onAway = !!user?.id && teamPlayerIds(match.away).includes(user.id);
|
||||||
|
const isParticipant = onHome || onAway;
|
||||||
|
|
||||||
|
const hasPending =
|
||||||
|
match.reported_by_team != null &&
|
||||||
|
match.reported_home_cups != null &&
|
||||||
|
match.reported_away_cups != null;
|
||||||
|
|
||||||
|
const reportedByHome = hasPending && match.reported_by_team === homeId;
|
||||||
|
const reportingTeam = hasPending
|
||||||
|
? reportedByHome
|
||||||
|
? match.home
|
||||||
|
: match.away
|
||||||
|
: undefined;
|
||||||
|
const opposingTeam = reportedByHome ? match.away : match.home;
|
||||||
|
const reportedByMine =
|
||||||
|
hasPending && ((reportedByHome && onHome) || (!reportedByHome && onAway));
|
||||||
|
|
||||||
|
const pendingSignature = hasPending
|
||||||
|
? [
|
||||||
|
match.reported_by_team,
|
||||||
|
match.reported_home_cups,
|
||||||
|
match.reported_away_cups,
|
||||||
|
match.reported_ot_count ?? 0,
|
||||||
|
].join(":")
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const sheetTakenOver =
|
||||||
|
formSheet.isOpen &&
|
||||||
|
isParticipant &&
|
||||||
|
hasPending &&
|
||||||
|
!reportedByMine &&
|
||||||
|
pendingSignature !== acknowledgedReport;
|
||||||
|
|
||||||
|
const report = useReportMatchScore(user?.id, {
|
||||||
|
onSuccess: (data: any) => {
|
||||||
|
if (data?.finalized) {
|
||||||
|
toast.success("Score confirmed 🍻");
|
||||||
|
if (data.downstreamReset) {
|
||||||
|
toast.error(
|
||||||
|
"Downstream matches were reset because the correction changed who advances."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data.groupEditAfterKnockout) {
|
||||||
|
toast.error(
|
||||||
|
"Group result changed after the bracket was seeded — reseed the knockout stage if needed."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirm = useConfirmMatchScore({
|
||||||
|
onSuccess: (data: any) => {
|
||||||
|
if (data?.downstreamReset) {
|
||||||
|
toast.error(
|
||||||
|
"Downstream matches were reset because the correction changed who advances."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data?.groupEditAfterKnockout) {
|
||||||
|
toast.error(
|
||||||
|
"Group result changed after the bracket was seeded — reseed the knockout stage if needed."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const clear = useClearMatchReport();
|
||||||
|
|
||||||
|
const submitReport = useCallback(
|
||||||
|
(data: { home_cups: number; away_cups: number; ot_count: number }) => {
|
||||||
|
report.mutate({ data: { ...data, matchId: match.id } });
|
||||||
|
formSheet.close();
|
||||||
|
toast.success("Score submitted — waiting for the other team");
|
||||||
|
},
|
||||||
|
[report, match.id, formSheet.close]
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitConfirm = useCallback(() => {
|
||||||
|
confirm.mutate({ data: { matchId: match.id } });
|
||||||
|
formSheet.close();
|
||||||
|
toast.success("Score confirmed 🍻");
|
||||||
|
}, [confirm, match.id, formSheet.close]);
|
||||||
|
|
||||||
|
const submitClear = useCallback(() => {
|
||||||
|
clear.mutate({ data: { matchId: match.id } });
|
||||||
|
toast.success("Report cleared");
|
||||||
|
}, [clear, match.id]);
|
||||||
|
|
||||||
|
const openForm = useCallback(() => {
|
||||||
|
setAcknowledgedReport(pendingSignature);
|
||||||
|
formSheet.open();
|
||||||
|
}, [pendingSignature, formSheet.open]);
|
||||||
|
|
||||||
|
const dismissTakeover = useCallback(() => {
|
||||||
|
setAcknowledgedReport(pendingSignature);
|
||||||
|
}, [pendingSignature]);
|
||||||
|
|
||||||
|
if (match.status !== "started") return null;
|
||||||
|
|
||||||
|
const scoreText = hasPending
|
||||||
|
? `${match.reported_home_cups}–${match.reported_away_cups}`
|
||||||
|
: "";
|
||||||
|
const size = compact ? "compact-xs" : "sm";
|
||||||
|
|
||||||
|
const formSheetEl = (
|
||||||
|
<Sheet
|
||||||
|
title={sheetTakenOver ? "Confirm Score" : "Report Score"}
|
||||||
|
{...formSheet.props}
|
||||||
|
>
|
||||||
|
{sheetTakenOver ? (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert
|
||||||
|
variant="light"
|
||||||
|
color="yellow"
|
||||||
|
icon={<LightningIcon size={18} weight="fill" />}
|
||||||
|
title={`${teamName(reportingTeam) ?? "The other team"} just reported a score`}
|
||||||
|
>
|
||||||
|
Does this look right?
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Paper withBorder p="md">
|
||||||
|
<Group justify="center" gap="md" wrap="nowrap">
|
||||||
|
<Stack gap={2} align="center" style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text size="sm" fw={600} ta="center" lineClamp={1}>
|
||||||
|
{teamName(match.home) ?? "Home"}
|
||||||
|
</Text>
|
||||||
|
<Text fz={34} fw={700} lh={1.2}>
|
||||||
|
{match.reported_home_cups}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Text c="dimmed" fw={600}>
|
||||||
|
–
|
||||||
|
</Text>
|
||||||
|
<Stack gap={2} align="center" style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text size="sm" fw={600} ta="center" lineClamp={1}>
|
||||||
|
{teamName(match.away) ?? "Away"}
|
||||||
|
</Text>
|
||||||
|
<Text fz={34} fw={700} lh={1.2}>
|
||||||
|
{match.reported_away_cups}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
leftSection={<CheckIcon size={14} weight="bold" />}
|
||||||
|
onClick={submitConfirm}
|
||||||
|
loading={confirm.isPending}
|
||||||
|
>
|
||||||
|
Confirm {scoreText}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="default"
|
||||||
|
onClick={dismissTakeover}
|
||||||
|
disabled={confirm.isPending}
|
||||||
|
>
|
||||||
|
Not right — enter our score
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<MatchForm
|
||||||
|
match={match}
|
||||||
|
onSubmit={submitReport}
|
||||||
|
onCancel={formSheet.close}
|
||||||
|
loading={report.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isParticipant && !hasPending) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size={size}
|
||||||
|
fullWidth={!compact}
|
||||||
|
onClick={openForm}
|
||||||
|
loading={report.isPending}
|
||||||
|
>
|
||||||
|
Report Score
|
||||||
|
</Button>
|
||||||
|
{formSheetEl}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPending) {
|
||||||
|
if (reportedByMine) {
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
direction={compact ? "column" : "row"}
|
||||||
|
align={compact ? "stretch" : "center"}
|
||||||
|
justify="space-between"
|
||||||
|
gap={compact ? 4 : "xs"}
|
||||||
|
>
|
||||||
|
<Text size="xs" c="dimmed" lineClamp={2} style={{ minWidth: 0 }}>
|
||||||
|
Waiting for {teamName(opposingTeam) ?? "the other team"} to confirm{" "}
|
||||||
|
·{" "}
|
||||||
|
<Text span fw={600} c="bright">
|
||||||
|
{scoreText}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
size={size}
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
onClick={submitClear}
|
||||||
|
loading={clear.isPending}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isParticipant) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Text size="xs" c="dimmed" lineClamp={2}>
|
||||||
|
{teamName(reportingTeam) ?? "Opponent"} reported{" "}
|
||||||
|
<Text span fw={600} c="bright">
|
||||||
|
{scoreText}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<Button
|
||||||
|
size={size}
|
||||||
|
leftSection={<CheckIcon size={12} weight="bold" />}
|
||||||
|
onClick={submitConfirm}
|
||||||
|
loading={confirm.isPending}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size={size}
|
||||||
|
variant="default"
|
||||||
|
onClick={openForm}
|
||||||
|
disabled={confirm.isPending}
|
||||||
|
>
|
||||||
|
Not right
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
{formSheetEl}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text size="xs" c="dimmed" lineClamp={2}>
|
||||||
|
Pending: {scoreText} · reported by {teamName(reportingTeam) ?? "a team"}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -6,6 +6,8 @@ import { TeamInfo } from "@/features/teams/types";
|
|||||||
import AnimatedScore from "@/features/matches/components/animated-score";
|
import AnimatedScore from "@/features/matches/components/animated-score";
|
||||||
import classes from "./match-slot.module.css";
|
import classes from "./match-slot.module.css";
|
||||||
|
|
||||||
|
export type MatchSlotState = "winner" | "correct" | "incorrect";
|
||||||
|
|
||||||
interface MatchSlotProps {
|
interface MatchSlotProps {
|
||||||
from?: number;
|
from?: number;
|
||||||
from_loser?: boolean;
|
from_loser?: boolean;
|
||||||
@@ -14,6 +16,7 @@ interface MatchSlotProps {
|
|||||||
cups?: number;
|
cups?: number;
|
||||||
isWinner?: boolean;
|
isWinner?: boolean;
|
||||||
groupLabel?: string;
|
groupLabel?: string;
|
||||||
|
state?: MatchSlotState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MatchSlot: React.FC<MatchSlotProps> = ({
|
export const MatchSlot: React.FC<MatchSlotProps> = ({
|
||||||
@@ -23,7 +26,8 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
|
|||||||
seed,
|
seed,
|
||||||
cups,
|
cups,
|
||||||
isWinner,
|
isWinner,
|
||||||
groupLabel
|
groupLabel,
|
||||||
|
state,
|
||||||
}) => {
|
}) => {
|
||||||
const teamId = team?.id;
|
const teamId = team?.id;
|
||||||
const previousTeamIdRef = useRef<string | undefined>(teamId);
|
const previousTeamIdRef = useRef<string | undefined>(teamId);
|
||||||
@@ -37,11 +41,19 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
|
|||||||
}
|
}
|
||||||
}, [teamId]);
|
}, [teamId]);
|
||||||
|
|
||||||
|
const slotState: MatchSlotState | undefined =
|
||||||
|
state ?? (isWinner ? "winner" : undefined);
|
||||||
|
const highlighted = slotState === "winner" || slotState === "correct";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex
|
<Flex
|
||||||
align="stretch"
|
align="stretch"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isWinner ? 'var(--mantine-color-green-light)' : 'transparent',
|
backgroundColor: highlighted
|
||||||
|
? 'var(--mantine-color-green-light)'
|
||||||
|
: slotState === "incorrect"
|
||||||
|
? 'var(--mantine-color-red-light)'
|
||||||
|
: 'transparent',
|
||||||
borderRadius: 'var(--mantine-radius-sm)',
|
borderRadius: 'var(--mantine-radius-sm)',
|
||||||
transition: 'background-color 200ms ease',
|
transition: 'background-color 200ms ease',
|
||||||
}}
|
}}
|
||||||
@@ -60,11 +72,12 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
|
|||||||
<Text
|
<Text
|
||||||
size={team.name.length > 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'}
|
size={team.name.length > 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'}
|
||||||
truncate
|
truncate
|
||||||
|
c={slotState === "incorrect" ? "dimmed" : undefined}
|
||||||
style={{ minWidth: 0, flex: 1, lineHeight: "12px" }}
|
style={{ minWidth: 0, flex: 1, lineHeight: "12px" }}
|
||||||
>
|
>
|
||||||
{team.name}
|
{team.name}
|
||||||
</Text>
|
</Text>
|
||||||
{isWinner && (
|
{highlighted && (
|
||||||
<CrownIcon
|
<CrownIcon
|
||||||
size={14}
|
size={14}
|
||||||
weight="fill"
|
weight="fill"
|
||||||
|
|||||||
@@ -7,3 +7,19 @@
|
|||||||
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.05);
|
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tappable-card {
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.tappable-card {
|
||||||
|
transition: transform 120ms cubic-bezier(0.32, 0.72, 0, 1),
|
||||||
|
border-color 150ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tappable-card:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
export interface GroupConfig {
|
||||||
|
num_groups: number;
|
||||||
|
advance_per_group: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGroupLabel(
|
||||||
|
seed: number | undefined,
|
||||||
|
groupConfig: GroupConfig | undefined
|
||||||
|
): string | undefined {
|
||||||
|
if (!seed || !groupConfig) return undefined;
|
||||||
|
|
||||||
|
const groupNames = ["A", "B", "C", "D", "E", "F", "G", "H"];
|
||||||
|
const numGroups = groupConfig.num_groups;
|
||||||
|
const advancePerGroup = groupConfig.advance_per_group;
|
||||||
|
|
||||||
|
const totalQualifiedTeams = numGroups * advancePerGroup;
|
||||||
|
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(totalQualifiedTeams)));
|
||||||
|
const wildcardsNeeded = nextPowerOf2 - totalQualifiedTeams;
|
||||||
|
|
||||||
|
if (seed > totalQualifiedTeams && wildcardsNeeded > 0) {
|
||||||
|
const wildcardNumber = seed - totalQualifiedTeams;
|
||||||
|
return `Wildcard ${wildcardNumber}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pairIndex = Math.floor((seed - 1) / 2);
|
||||||
|
const isFirstInPair = (seed - 1) % 2 === 0;
|
||||||
|
|
||||||
|
if (isFirstInPair) {
|
||||||
|
const groupIndex = pairIndex % numGroups;
|
||||||
|
const rankIndex = Math.floor(pairIndex / numGroups);
|
||||||
|
|
||||||
|
const rank = rankIndex + 1;
|
||||||
|
const groupName = groupNames[groupIndex] || `${groupIndex + 1}`;
|
||||||
|
const rankSuffix =
|
||||||
|
rank === 1 ? "1st" : rank === 2 ? "2nd" : rank === 3 ? "3rd" : `${rank}th`;
|
||||||
|
|
||||||
|
return `${groupName} ${rankSuffix}`;
|
||||||
|
} else {
|
||||||
|
const groupIndex = (pairIndex + 1) % numGroups;
|
||||||
|
const rankIndex = advancePerGroup - 1 - Math.floor(pairIndex / numGroups);
|
||||||
|
|
||||||
|
const rank = rankIndex + 1;
|
||||||
|
const groupName = groupNames[groupIndex] || `${groupIndex + 1}`;
|
||||||
|
const rankSuffix =
|
||||||
|
rank === 1 ? "1st" : rank === 2 ? "2nd" : rank === 3 ? "3rd" : `${rank}th`;
|
||||||
|
|
||||||
|
return `${groupName} ${rankSuffix}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Match } from "@/features/matches/types";
|
||||||
|
import { BracketData } from "../types";
|
||||||
|
|
||||||
|
export const groupMatchesIntoBracket = (matches?: Match[]): BracketData => {
|
||||||
|
if (!matches || matches.length === 0) {
|
||||||
|
return { winners: [], losers: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const winnersMap = new Map<number, Match[]>();
|
||||||
|
const losersMap = new Map<number, Match[]>();
|
||||||
|
|
||||||
|
matches
|
||||||
|
.filter((match) => match.round !== -1)
|
||||||
|
.sort((a, b) => a.lid - b.lid)
|
||||||
|
.forEach((match) => {
|
||||||
|
if (!match.is_losers_bracket) {
|
||||||
|
if (!winnersMap.has(match.round)) {
|
||||||
|
winnersMap.set(match.round, []);
|
||||||
|
}
|
||||||
|
winnersMap.get(match.round)!.push(match);
|
||||||
|
} else {
|
||||||
|
if (!losersMap.has(match.round)) {
|
||||||
|
losersMap.set(match.round, []);
|
||||||
|
}
|
||||||
|
losersMap.get(match.round)!.push(match);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const winners = Array.from(winnersMap.entries())
|
||||||
|
.sort(([a], [b]) => a - b)
|
||||||
|
.map(([, matches]) => matches);
|
||||||
|
|
||||||
|
const losers = Array.from(losersMap.entries())
|
||||||
|
.sort(([a], [b]) => a - b)
|
||||||
|
.map(([, matches]) => matches);
|
||||||
|
return { winners, losers };
|
||||||
|
};
|
||||||
@@ -7,11 +7,9 @@ const Header = ({ collapsed, title, withBackButton }: HeaderConfig) => {
|
|||||||
<AppShell.Header
|
<AppShell.Header
|
||||||
id='app-header'
|
id='app-header'
|
||||||
display={collapsed ? 'none' : 'flex'}
|
display={collapsed ? 'none' : 'flex'}
|
||||||
withBorder={false}
|
|
||||||
style={{
|
style={{
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
transition: 'border-color 200ms ease-out',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{ withBackButton && <BackButton /> }
|
{ withBackButton && <BackButton /> }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import GlitchAvatar from '@/components/glitch-avatar';
|
import GlitchAvatar from '@/components/glitch-avatar';
|
||||||
import useVisualViewportSize from '@/features/core/hooks/use-visual-viewport-size';
|
import useVisualViewportSize from '@/features/core/hooks/use-visual-viewport-size';
|
||||||
import { useCurrentTournament } from '@/features/tournaments/queries';
|
import { useCurrentTournament } from '@/features/tournaments/queries';
|
||||||
import { AppShell, Flex, Paper, em, Title, Text, Stack } from '@mantine/core';
|
import { AppShell, Flex, Paper, em, Title, Stack } from '@mantine/core';
|
||||||
import { useMediaQuery, useViewportSize } from '@mantine/hooks';
|
import { useMediaQuery, useViewportSize } from '@mantine/hooks';
|
||||||
import { TrophyIcon } from '@phosphor-icons/react';
|
import { TrophyIcon } from '@phosphor-icons/react';
|
||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
@@ -27,8 +27,6 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
style={{ transition: 'padding-top 0.1s ease' }}
|
style={{ transition: 'padding-top 0.1s ease' }}
|
||||||
>
|
>
|
||||||
<Paper
|
<Paper
|
||||||
shadow='md'
|
|
||||||
withBorder
|
|
||||||
p='md'
|
p='md'
|
||||||
w='100%'
|
w='100%'
|
||||||
maw='375px'
|
maw='375px'
|
||||||
@@ -37,8 +35,6 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
<Stack align='center' gap='xs' mb='md'>
|
<Stack align='center' gap='xs' mb='md'>
|
||||||
<GlitchAvatar
|
<GlitchAvatar
|
||||||
name={tournament.name}
|
name={tournament.name}
|
||||||
contain
|
|
||||||
frame
|
|
||||||
src={
|
src={
|
||||||
tournament.logo
|
tournament.logo
|
||||||
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||||
@@ -51,17 +47,11 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
}
|
}
|
||||||
radius="md"
|
radius="md"
|
||||||
size={250}
|
size={250}
|
||||||
px="xs"
|
|
||||||
withBorder={false}
|
withBorder={false}
|
||||||
>
|
>
|
||||||
<TrophyIcon size={32} />
|
<TrophyIcon size={32} />
|
||||||
</GlitchAvatar>
|
</GlitchAvatar>
|
||||||
<Stack align='center' gap={2}>
|
|
||||||
<Title order={1} ta='center'>Welcome to FLXN</Title>
|
<Title order={1} ta='center'>Welcome to FLXN</Title>
|
||||||
<Text size='sm' c='dimmed' fs='italic' ta='center'>
|
|
||||||
Amicus meus madidus
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
{children}
|
{children}
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { getMatchesBetweenTeams, getMatchesBetweenPlayers } from "./server";
|
import { useServerMutation, useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
||||||
|
import { Match } from "@/features/matches/types";
|
||||||
|
import {
|
||||||
|
getMatchesBetweenTeams,
|
||||||
|
getMatchesBetweenPlayers,
|
||||||
|
reportMatchScore,
|
||||||
|
confirmMatchScore,
|
||||||
|
clearMatchReport,
|
||||||
|
} from "./server";
|
||||||
|
|
||||||
export const matchKeys = {
|
export const matchKeys = {
|
||||||
headToHeadTeams: (team1Id: string, team2Id: string) => ['matches', 'headToHead', 'teams', team1Id, team2Id] as const,
|
headToHeadTeams: (team1Id: string, team2Id: string) => ['matches', 'headToHead', 'teams', team1Id, team2Id] as const,
|
||||||
@@ -28,3 +36,130 @@ export const usePlayerHeadToHead = (player1Id: string, player2Id: string, enable
|
|||||||
...matchQueries.headToHeadPlayers(player1Id, player2Id),
|
...matchQueries.headToHeadPlayers(player1Id, player2Id),
|
||||||
enabled,
|
enabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tournamentsRoot = { queryKey: ["tournaments"] as const };
|
||||||
|
|
||||||
|
const reportTeamId = (t: Match["home"]): string | undefined =>
|
||||||
|
!t ? undefined : typeof t === "string" ? t : t.id;
|
||||||
|
|
||||||
|
const reportTeamPlayerIds = (t: Match["home"]): string[] =>
|
||||||
|
t && typeof t !== "string" ? (t.players ?? []).map((p) => p.id) : [];
|
||||||
|
|
||||||
|
function patchTournamentsData(
|
||||||
|
data: unknown,
|
||||||
|
matchId: string,
|
||||||
|
patch: (match: Match) => Partial<Match>
|
||||||
|
): unknown {
|
||||||
|
if (!data) return data;
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return data.map((entry) => patchTournamentsData(entry, matchId, patch));
|
||||||
|
}
|
||||||
|
const tournament = data as { matches?: Match[] };
|
||||||
|
if (!Array.isArray(tournament.matches)) return data;
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
const matches = tournament.matches.map((m) => {
|
||||||
|
if (m.id !== matchId) return m;
|
||||||
|
changed = true;
|
||||||
|
return { ...m, ...patch(m) };
|
||||||
|
});
|
||||||
|
return changed ? { ...tournament, matches } : data;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MatchVariables = { data: { matchId: string } };
|
||||||
|
type TournamentsSnapshot = [readonly unknown[], unknown][];
|
||||||
|
|
||||||
|
function useOptimisticMatchMutation<TData, TVariables extends MatchVariables>(
|
||||||
|
options: Parameters<typeof useServerMutation<TData, TVariables>>[0] & {
|
||||||
|
buildPatch: (match: Match, variables: TVariables) => Partial<Match>;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { buildPatch, ...mutationOptions } = options;
|
||||||
|
|
||||||
|
return useServerMutation<TData, TVariables>({
|
||||||
|
...mutationOptions,
|
||||||
|
onMutate: async (variables) => {
|
||||||
|
const { matchId } = variables.data;
|
||||||
|
await queryClient.cancelQueries(tournamentsRoot);
|
||||||
|
|
||||||
|
const previous = queryClient.getQueriesData(tournamentsRoot);
|
||||||
|
queryClient.setQueriesData(tournamentsRoot, (data: unknown) =>
|
||||||
|
patchTournamentsData(data, matchId, (match) => buildPatch(match, variables))
|
||||||
|
);
|
||||||
|
|
||||||
|
return { previous };
|
||||||
|
},
|
||||||
|
onError: (error, variables, onMutateResult, context) => {
|
||||||
|
if (context && typeof context === "object" && "previous" in context) {
|
||||||
|
const snapshot = (context as { previous: TournamentsSnapshot }).previous;
|
||||||
|
for (const [key, data] of snapshot) {
|
||||||
|
queryClient.setQueryData(key, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mutationOptions.onError?.(error, variables, onMutateResult, context);
|
||||||
|
},
|
||||||
|
onSettled: (data, error, variables, onMutateResult, context) => {
|
||||||
|
queryClient.invalidateQueries(tournamentsRoot);
|
||||||
|
mutationOptions.onSettled?.(data, error, variables, onMutateResult, context);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type MatchMutationOptions = {
|
||||||
|
onSuccess?: Parameters<typeof useServerMutation>[0]["onSuccess"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useReportMatchScore = (userId?: string, options?: MatchMutationOptions) =>
|
||||||
|
useOptimisticMatchMutation({
|
||||||
|
mutationFn: reportMatchScore,
|
||||||
|
showSuccessToast: false,
|
||||||
|
...options,
|
||||||
|
buildPatch: (match, variables) => {
|
||||||
|
const onHome = !!userId && reportTeamPlayerIds(match.home).includes(userId);
|
||||||
|
const onAway = !!userId && reportTeamPlayerIds(match.away).includes(userId);
|
||||||
|
const callerTeamId = onHome
|
||||||
|
? reportTeamId(match.home)
|
||||||
|
: onAway
|
||||||
|
? reportTeamId(match.away)
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
reported_home_cups: variables.data.home_cups,
|
||||||
|
reported_away_cups: variables.data.away_cups,
|
||||||
|
reported_ot_count: variables.data.ot_count,
|
||||||
|
reported_by_team: callerTeamId,
|
||||||
|
reported_by_player: userId,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useConfirmMatchScore = (options?: MatchMutationOptions) =>
|
||||||
|
useOptimisticMatchMutation({
|
||||||
|
mutationFn: confirmMatchScore,
|
||||||
|
showSuccessToast: false,
|
||||||
|
...options,
|
||||||
|
buildPatch: (match) => ({
|
||||||
|
status: "ended",
|
||||||
|
home_cups: match.reported_home_cups ?? match.home_cups,
|
||||||
|
away_cups: match.reported_away_cups ?? match.away_cups,
|
||||||
|
ot_count: match.reported_ot_count ?? match.ot_count,
|
||||||
|
reported_home_cups: undefined,
|
||||||
|
reported_away_cups: undefined,
|
||||||
|
reported_ot_count: undefined,
|
||||||
|
reported_by_team: undefined,
|
||||||
|
reported_by_player: undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useClearMatchReport = (options?: MatchMutationOptions) =>
|
||||||
|
useOptimisticMatchMutation({
|
||||||
|
mutationFn: clearMatchReport,
|
||||||
|
...options,
|
||||||
|
buildPatch: () => ({
|
||||||
|
reported_home_cups: undefined,
|
||||||
|
reported_away_cups: undefined,
|
||||||
|
reported_ot_count: undefined,
|
||||||
|
reported_by_team: undefined,
|
||||||
|
reported_by_player: undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|||||||
+478
-62
@@ -6,7 +6,7 @@ import { z } from "zod";
|
|||||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||||
import brackets from "@/features/bracket/utils";
|
import brackets from "@/features/bracket/utils";
|
||||||
import { Match, MatchInput } from "@/features/matches/types";
|
import { Match, MatchInput } from "@/features/matches/types";
|
||||||
import { serverEvents } from "@/lib/events/emitter";
|
import { emitServerEvent } from "@/lib/events/emitter";
|
||||||
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||||
import { PlayerInfo } from "../players/types";
|
import { PlayerInfo } from "../players/types";
|
||||||
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||||
@@ -129,6 +129,8 @@ export const generateTournamentBracket = createServerFn()
|
|||||||
matchCount: createdMatches.length,
|
matchCount: createdMatches.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
emitServerEvent({ type: "tournament", tournamentId });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tournament,
|
tournament,
|
||||||
matchCount: createdMatches.length,
|
matchCount: createdMatches.length,
|
||||||
@@ -154,7 +156,7 @@ export const startMatch = createServerFn()
|
|||||||
status: "started",
|
status: "started",
|
||||||
});
|
});
|
||||||
|
|
||||||
serverEvents.emit("match", {
|
emitServerEvent({
|
||||||
type: "match",
|
type: "match",
|
||||||
matchId: match.id,
|
matchId: match.id,
|
||||||
tournamentId: match.tournament.id
|
tournamentId: match.tournament.id
|
||||||
@@ -180,7 +182,9 @@ export const populateKnockoutBracket = createServerFn()
|
|||||||
throw new Error("Tournament must have group_config");
|
throw new Error("Tournament must have group_config");
|
||||||
}
|
}
|
||||||
|
|
||||||
return await populateKnockoutBracketInternal(tournamentId, tournament.group_config);
|
const result = await populateKnockoutBracketInternal(tournamentId, tournament.group_config);
|
||||||
|
emitServerEvent({ type: "tournament", tournamentId });
|
||||||
|
return result;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -208,7 +212,6 @@ async function populateKnockoutBracketInternal(tournamentId: string, groupConfig
|
|||||||
const standings = new Map<string, { teamId: string; wins: number; losses: number; cups_for: number; cups_against: number; cup_differential: number }>();
|
const standings = new Map<string, { teamId: string; wins: number; losses: number; cups_for: number; cups_against: number; cup_differential: number }>();
|
||||||
|
|
||||||
for (const team of group.teams || []) {
|
for (const team of group.teams || []) {
|
||||||
// group.teams can be either team objects or just team ID strings
|
|
||||||
const teamId = typeof team === 'string' ? team : team.id;
|
const teamId = typeof team === 'string' ? team : team.id;
|
||||||
standings.set(teamId, {
|
standings.set(teamId, {
|
||||||
teamId,
|
teamId,
|
||||||
@@ -452,6 +455,344 @@ async function populateKnockoutBracketInternal(tournamentId: string, groupConfig
|
|||||||
logger.info('Knockout bracket populated successfully', { tournamentId });
|
logger.info('Knockout bracket populated successfully', { tournamentId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const teamId = (t: Match["home"] | string | undefined): string | undefined =>
|
||||||
|
!t ? undefined : typeof t === "string" ? t : t.id;
|
||||||
|
|
||||||
|
const winnerId = (m: Match): string | undefined =>
|
||||||
|
m.home_cups > m.away_cups ? teamId(m.home) : teamId(m.away);
|
||||||
|
const loserId = (m: Match): string | undefined =>
|
||||||
|
m.home_cups > m.away_cups ? teamId(m.away) : teamId(m.home);
|
||||||
|
|
||||||
|
function assertValidScore(home_cups: number, away_cups: number, ot_count: number) {
|
||||||
|
if (home_cups === away_cups) throw new Error("A match cannot end in a tie");
|
||||||
|
if (ot_count > 0) return;
|
||||||
|
if (home_cups !== 10 && away_cups !== 10)
|
||||||
|
throw new Error("At least one team must have 10 cups");
|
||||||
|
if (home_cups === 10 && away_cups === 10)
|
||||||
|
throw new Error("Both teams cannot have 10 cups");
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLEARED_RESULT = {
|
||||||
|
home_cups: 0,
|
||||||
|
away_cups: 0,
|
||||||
|
ot_count: 0,
|
||||||
|
start_time: "",
|
||||||
|
end_time: "",
|
||||||
|
reported_home_cups: null,
|
||||||
|
reported_away_cups: null,
|
||||||
|
reported_ot_count: null,
|
||||||
|
reported_by_team: null,
|
||||||
|
reported_by_player: null,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function generateRecordId(): string {
|
||||||
|
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
let id = "";
|
||||||
|
for (let i = 0; i < 15; i++)
|
||||||
|
id += alphabet[Math.floor(Math.random() * alphabet.length)];
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BracketOp =
|
||||||
|
| { kind: "updateMatch"; id: string; data: Record<string, unknown> }
|
||||||
|
| { kind: "createMatch"; data: Record<string, unknown> }
|
||||||
|
| { kind: "deleteMatch"; id: string }
|
||||||
|
| { kind: "updateTournamentMatches"; tournamentId: string; matchIds: string[] };
|
||||||
|
|
||||||
|
function isBatchApiUnavailable(error: unknown): boolean {
|
||||||
|
if (!error || typeof error !== "object") return false;
|
||||||
|
const e = error as { status?: unknown; url?: unknown };
|
||||||
|
const url = typeof e.url === "string" ? e.url : "";
|
||||||
|
if (!url.includes("/api/batch")) return false;
|
||||||
|
return e.status === 403 || e.status === 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitBracketWrites(ops: BracketOp[]): Promise<void> {
|
||||||
|
const batch = pbAdmin.createBatch();
|
||||||
|
for (const op of ops) {
|
||||||
|
switch (op.kind) {
|
||||||
|
case "updateMatch":
|
||||||
|
batch.collection("matches").update(op.id, op.data);
|
||||||
|
break;
|
||||||
|
case "createMatch":
|
||||||
|
batch.collection("matches").create(op.data);
|
||||||
|
break;
|
||||||
|
case "deleteMatch":
|
||||||
|
batch.collection("matches").delete(op.id);
|
||||||
|
break;
|
||||||
|
case "updateTournamentMatches":
|
||||||
|
batch
|
||||||
|
.collection("tournaments")
|
||||||
|
.update(op.tournamentId, { matches: op.matchIds });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await batch.send();
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
if (!isBatchApiUnavailable(error)) throw error;
|
||||||
|
logger.warn(
|
||||||
|
"PocketBase batch API is disabled; finalizing match with sequential writes (reduced atomicity). Enable Settings > Application > batch API to restore transactional bracket updates.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const op of ops) {
|
||||||
|
switch (op.kind) {
|
||||||
|
case "updateMatch":
|
||||||
|
await pbAdmin.updateMatch(op.id, op.data as Partial<MatchInput>);
|
||||||
|
break;
|
||||||
|
case "createMatch":
|
||||||
|
await pbAdmin.createMatch(op.data as unknown as MatchInput);
|
||||||
|
break;
|
||||||
|
case "deleteMatch":
|
||||||
|
await pbAdmin.deleteMatch(op.id);
|
||||||
|
break;
|
||||||
|
case "updateTournamentMatches":
|
||||||
|
await pbAdmin.updateTournamentMatches(op.tournamentId, op.matchIds);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rederiveBracket(
|
||||||
|
tournamentId: string,
|
||||||
|
sourceId: string,
|
||||||
|
score: { home_cups: number; away_cups: number; ot_count: number }
|
||||||
|
): Promise<{ source: Match; downstreamReset: boolean }> {
|
||||||
|
const all = await pbAdmin.getMatchesByTournament(tournamentId);
|
||||||
|
const knockout = all.filter((m) => m.lid >= 0);
|
||||||
|
const byLid = new Map<number, Match>();
|
||||||
|
for (const m of knockout) byLid.set(m.lid, m);
|
||||||
|
|
||||||
|
const original = all.find((m) => m.id === sourceId);
|
||||||
|
if (!original) throw new Error("Match not found");
|
||||||
|
|
||||||
|
const ops: BracketOp[] = [];
|
||||||
|
let warn = false;
|
||||||
|
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
ops.push({
|
||||||
|
kind: "updateMatch",
|
||||||
|
id: sourceId,
|
||||||
|
data: {
|
||||||
|
end_time: nowIso,
|
||||||
|
status: "ended",
|
||||||
|
home_cups: score.home_cups,
|
||||||
|
away_cups: score.away_cups,
|
||||||
|
ot_count: score.ot_count,
|
||||||
|
reported_home_cups: null,
|
||||||
|
reported_away_cups: null,
|
||||||
|
reported_ot_count: null,
|
||||||
|
reported_by_team: null,
|
||||||
|
reported_by_player: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const source: Match = {
|
||||||
|
...original,
|
||||||
|
status: "ended",
|
||||||
|
home_cups: score.home_cups,
|
||||||
|
away_cups: score.away_cups,
|
||||||
|
ot_count: score.ot_count,
|
||||||
|
end_time: nowIso,
|
||||||
|
reported_home_cups: undefined,
|
||||||
|
reported_away_cups: undefined,
|
||||||
|
reported_ot_count: undefined,
|
||||||
|
reported_by_team: undefined,
|
||||||
|
reported_by_player: undefined,
|
||||||
|
};
|
||||||
|
byLid.set(source.lid, source);
|
||||||
|
|
||||||
|
const resolveFeeder = (lid: number, fromLoser: boolean): string | undefined => {
|
||||||
|
const f = byLid.get(lid);
|
||||||
|
if (!f || f.bye || f.status !== "ended") return undefined;
|
||||||
|
return fromLoser ? loserId(f) : winnerId(f);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasLosers = knockout.some((m) => m.is_losers_bracket);
|
||||||
|
const resetMatch = knockout.find((m) => m.reset);
|
||||||
|
const grandFinal = hasLosers
|
||||||
|
? knockout
|
||||||
|
.filter((m) => !m.reset && !m.bye)
|
||||||
|
.reduce<Match | undefined>(
|
||||||
|
(hi, cur) => (!hi || cur.lid > hi.lid ? cur : hi),
|
||||||
|
undefined
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const ordered = knockout
|
||||||
|
.filter((m) => !m.bye && !m.reset)
|
||||||
|
.sort((a, b) => a.lid - b.lid);
|
||||||
|
|
||||||
|
for (const m of ordered) {
|
||||||
|
if (m.id === sourceId) continue;
|
||||||
|
|
||||||
|
const homeFed = m.home_from_lid >= 0;
|
||||||
|
const awayFed = m.away_from_lid >= 0;
|
||||||
|
if (!homeFed && !awayFed) continue;
|
||||||
|
|
||||||
|
const curHome = teamId(m.home);
|
||||||
|
const curAway = teamId(m.away);
|
||||||
|
const expHome = homeFed ? resolveFeeder(m.home_from_lid, m.home_from_loser) : curHome;
|
||||||
|
const expAway = awayFed ? resolveFeeder(m.away_from_lid, m.away_from_loser) : curAway;
|
||||||
|
const expStatus = expHome && expAway ? "ready" : "tbd";
|
||||||
|
|
||||||
|
const played = m.status === "started" || m.status === "ended";
|
||||||
|
const changed =
|
||||||
|
(homeFed && expHome !== curHome) || (awayFed && expAway !== curAway);
|
||||||
|
|
||||||
|
if (played && changed) {
|
||||||
|
ops.push({
|
||||||
|
kind: "updateMatch",
|
||||||
|
id: m.id,
|
||||||
|
data: {
|
||||||
|
home: expHome ?? null,
|
||||||
|
away: expAway ?? null,
|
||||||
|
status: expStatus,
|
||||||
|
...CLEARED_RESULT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
byLid.set(m.lid, { ...m, status: expStatus });
|
||||||
|
warn = true;
|
||||||
|
} else if (!played && (changed || m.status !== expStatus)) {
|
||||||
|
ops.push({
|
||||||
|
kind: "updateMatch",
|
||||||
|
id: m.id,
|
||||||
|
data: {
|
||||||
|
home: expHome ?? null,
|
||||||
|
away: expAway ?? null,
|
||||||
|
status: expStatus,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
byLid.set(m.lid, { ...m, status: expStatus });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grandFinal) {
|
||||||
|
const gf = byLid.get(grandFinal.lid)!;
|
||||||
|
const gfEnded = gf.status === "ended";
|
||||||
|
const resetNeeded = gfEnded && winnerId(gf) === teamId(gf.away);
|
||||||
|
|
||||||
|
if (resetNeeded) {
|
||||||
|
const expHome = winnerId(gf);
|
||||||
|
const expAway = loserId(gf);
|
||||||
|
|
||||||
|
if (resetMatch && resetMatch.id !== sourceId) {
|
||||||
|
const played =
|
||||||
|
resetMatch.status === "started" || resetMatch.status === "ended";
|
||||||
|
const changed =
|
||||||
|
teamId(resetMatch.home) !== expHome || teamId(resetMatch.away) !== expAway;
|
||||||
|
if (played && changed) {
|
||||||
|
ops.push({
|
||||||
|
kind: "updateMatch",
|
||||||
|
id: resetMatch.id,
|
||||||
|
data: {
|
||||||
|
home: expHome ?? null,
|
||||||
|
away: expAway ?? null,
|
||||||
|
status: "ready",
|
||||||
|
...CLEARED_RESULT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
warn = true;
|
||||||
|
} else if (!played && (changed || resetMatch.status !== "ready")) {
|
||||||
|
ops.push({
|
||||||
|
kind: "updateMatch",
|
||||||
|
id: resetMatch.id,
|
||||||
|
data: {
|
||||||
|
home: expHome ?? null,
|
||||||
|
away: expAway ?? null,
|
||||||
|
status: "ready",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (!resetMatch) {
|
||||||
|
const newId = generateRecordId();
|
||||||
|
ops.push({
|
||||||
|
kind: "createMatch",
|
||||||
|
data: {
|
||||||
|
id: newId,
|
||||||
|
lid: gf.lid + 1,
|
||||||
|
order: gf.order + 1,
|
||||||
|
round: gf.round + 1,
|
||||||
|
reset: true,
|
||||||
|
bye: false,
|
||||||
|
home_cups: 0,
|
||||||
|
away_cups: 0,
|
||||||
|
ot_count: 0,
|
||||||
|
home_from_lid: gf.lid,
|
||||||
|
away_from_lid: gf.lid,
|
||||||
|
home_from_loser: false,
|
||||||
|
away_from_loser: true,
|
||||||
|
is_losers_bracket: false,
|
||||||
|
status: "ready",
|
||||||
|
tournament: tournamentId,
|
||||||
|
home: expHome ?? undefined,
|
||||||
|
away: expAway ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ops.push({
|
||||||
|
kind: "updateTournamentMatches",
|
||||||
|
tournamentId,
|
||||||
|
matchIds: [...all.map((m) => m.id), newId],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (resetMatch && resetMatch.id !== sourceId) {
|
||||||
|
if (resetMatch.status === "started" || resetMatch.status === "ended") {
|
||||||
|
warn = true;
|
||||||
|
}
|
||||||
|
ops.push({ kind: "deleteMatch", id: resetMatch.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await commitBracketWrites(ops);
|
||||||
|
|
||||||
|
return { source, downstreamReset: warn };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finalizeMatch(
|
||||||
|
matchId: string,
|
||||||
|
{ home_cups, away_cups, ot_count }: { home_cups: number; away_cups: number; ot_count: number }
|
||||||
|
): Promise<{ match: Match; downstreamReset: boolean; groupEditAfterKnockout: boolean }> {
|
||||||
|
assertValidScore(home_cups, away_cups, ot_count);
|
||||||
|
|
||||||
|
const existing = await pbAdmin.getMatch(matchId);
|
||||||
|
if (!existing) throw new Error("Match not found");
|
||||||
|
const tournamentId = existing.tournament.id;
|
||||||
|
|
||||||
|
if (existing.lid === -1) {
|
||||||
|
const source = await pbAdmin.updateMatch(matchId, {
|
||||||
|
end_time: new Date().toISOString(),
|
||||||
|
status: "ended",
|
||||||
|
home_cups,
|
||||||
|
away_cups,
|
||||||
|
ot_count,
|
||||||
|
reported_home_cups: null,
|
||||||
|
reported_away_cups: null,
|
||||||
|
reported_ot_count: null,
|
||||||
|
reported_by_team: null,
|
||||||
|
reported_by_player: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const all = await pbAdmin.getMatchesByTournament(tournamentId);
|
||||||
|
const groupEditAfterKnockout = all.some(
|
||||||
|
(m) => m.round >= 0 && (teamId(m.home) || teamId(m.away))
|
||||||
|
);
|
||||||
|
|
||||||
|
emitServerEvent({ type: "match", matchId: source.id, tournamentId });
|
||||||
|
return { match: source, downstreamReset: false, groupEditAfterKnockout };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { source, downstreamReset } = await rederiveBracket(tournamentId, matchId, {
|
||||||
|
home_cups,
|
||||||
|
away_cups,
|
||||||
|
ot_count,
|
||||||
|
});
|
||||||
|
|
||||||
|
emitServerEvent({ type: "match", matchId: source.id, tournamentId });
|
||||||
|
return { match: source, downstreamReset, groupEditAfterKnockout: false };
|
||||||
|
}
|
||||||
|
|
||||||
const endMatchSchema = z.object({
|
const endMatchSchema = z.object({
|
||||||
matchId: z.string(),
|
matchId: z.string(),
|
||||||
home_cups: z.number(),
|
home_cups: z.number(),
|
||||||
@@ -465,78 +806,153 @@ export const endMatch = createServerFn()
|
|||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
logger.info("Ending match", matchId);
|
logger.info("Ending match", matchId);
|
||||||
|
|
||||||
let match = await pbAdmin.getMatch(matchId);
|
const match = await pbAdmin.getMatch(matchId);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new Error("Match not found");
|
throw new Error("Match not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
match = await pbAdmin.updateMatch(matchId, {
|
return finalizeMatch(matchId, { home_cups, away_cups, ot_count });
|
||||||
end_time: new Date().toISOString(),
|
})
|
||||||
status: "ended",
|
);
|
||||||
home_cups,
|
|
||||||
away_cups,
|
|
||||||
ot_count,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (match.lid === -1) {
|
const teamPlayerIds = (team: Match["home"]): string[] =>
|
||||||
serverEvents.emit("match", {
|
team && typeof team !== "string" ? (team.players ?? []).map((p) => p.id) : [];
|
||||||
type: "match",
|
|
||||||
matchId: match.id,
|
const reportScoreSchema = z.object({
|
||||||
tournamentId: match.tournament.id
|
matchId: z.string(),
|
||||||
});
|
home_cups: z.number(),
|
||||||
return match;
|
away_cups: z.number(),
|
||||||
|
ot_count: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const reportMatchScore = createServerFn()
|
||||||
|
.validator(reportScoreSchema)
|
||||||
|
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
|
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count }, context }) =>
|
||||||
|
toServerResult(async () => {
|
||||||
|
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
||||||
|
if (!player?.id) throw new Error("Player not found");
|
||||||
|
|
||||||
|
const match = await pbAdmin.getMatch(matchId);
|
||||||
|
if (!match) throw new Error("Match not found");
|
||||||
|
if (match.status !== "started")
|
||||||
|
throw new Error("Scores can only be reported once the match has started");
|
||||||
|
|
||||||
|
const onHome = teamPlayerIds(match.home).includes(player.id);
|
||||||
|
const onAway = teamPlayerIds(match.away).includes(player.id);
|
||||||
|
if (!onHome && !onAway)
|
||||||
|
throw new Error("You are not a player in this match");
|
||||||
|
if (onHome && onAway)
|
||||||
|
throw new Error(
|
||||||
|
"You are on both teams in this match and cannot report or confirm its score"
|
||||||
|
);
|
||||||
|
|
||||||
|
assertValidScore(home_cups, away_cups, ot_count);
|
||||||
|
|
||||||
|
const callerTeamId = onHome ? teamId(match.home) : teamId(match.away);
|
||||||
|
|
||||||
|
const hasPending =
|
||||||
|
match.reported_by_team != null &&
|
||||||
|
match.reported_home_cups != null &&
|
||||||
|
match.reported_away_cups != null;
|
||||||
|
const fromOppositeTeam =
|
||||||
|
hasPending && match.reported_by_team !== callerTeamId;
|
||||||
|
const identical =
|
||||||
|
hasPending &&
|
||||||
|
match.reported_home_cups === home_cups &&
|
||||||
|
match.reported_away_cups === away_cups &&
|
||||||
|
(match.reported_ot_count ?? 0) === ot_count;
|
||||||
|
|
||||||
|
if (fromOppositeTeam && identical) {
|
||||||
|
const result = await finalizeMatch(matchId, { home_cups, away_cups, ot_count });
|
||||||
|
return {
|
||||||
|
success: true as const,
|
||||||
|
finalized: true as const,
|
||||||
|
downstreamReset: result.downstreamReset,
|
||||||
|
groupEditAfterKnockout: result.groupEditAfterKnockout,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const matchWinner = home_cups > away_cups ? match.home : match.away;
|
await pbAdmin.updateMatch(matchId, {
|
||||||
const matchLoser = home_cups < away_cups ? match.home : match.away;
|
reported_home_cups: home_cups,
|
||||||
if (!matchWinner || !matchLoser) throw new Error("Something went wrong");
|
reported_away_cups: away_cups,
|
||||||
|
reported_ot_count: ot_count,
|
||||||
const { winner, loser } = await pbAdmin.getChildMatches(matchId);
|
reported_by_team: callerTeamId,
|
||||||
|
reported_by_player: player.id,
|
||||||
if (winner && winner.reset) {
|
|
||||||
const awayTeamWon = match.away === matchWinner;
|
|
||||||
|
|
||||||
if (!awayTeamWon) {
|
|
||||||
logger.info("Deleting reset match", {
|
|
||||||
resetMatchId: winner.id,
|
|
||||||
currentMatchId: match.id,
|
|
||||||
reason: "not necessary",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await pbAdmin.deleteMatch(winner.id);
|
emitServerEvent({ type: "match", matchId, tournamentId: match.tournament.id });
|
||||||
return match;
|
return { success: true as const, finalized: false as const };
|
||||||
}
|
})
|
||||||
}
|
);
|
||||||
|
|
||||||
if (winner) {
|
const matchIdSchema = z.object({ matchId: z.string() });
|
||||||
await pbAdmin.updateMatch(winner.id, {
|
|
||||||
[winner.home_from_lid === match.lid ? "home" : "away"]: matchWinner.id,
|
export const confirmMatchScore = createServerFn()
|
||||||
status:
|
.validator(matchIdSchema)
|
||||||
(winner.home_from_lid === match.lid && winner.away) ||
|
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
(winner.away_from_lid === match.lid && winner.home)
|
.handler(async ({ data: { matchId }, context }) =>
|
||||||
? "ready"
|
toServerResult(async () => {
|
||||||
: "tbd",
|
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
||||||
|
if (!player?.id) throw new Error("Player not found");
|
||||||
|
|
||||||
|
const match = await pbAdmin.getMatch(matchId);
|
||||||
|
if (!match) throw new Error("Match not found");
|
||||||
|
if (match.status !== "started")
|
||||||
|
throw new Error("Match is not in progress");
|
||||||
|
if (!match.reported_by_team)
|
||||||
|
throw new Error("No score has been reported yet");
|
||||||
|
if (match.reported_home_cups == null || match.reported_away_cups == null)
|
||||||
|
throw new Error("The reported score is incomplete");
|
||||||
|
|
||||||
|
const onHome = teamPlayerIds(match.home).includes(player.id);
|
||||||
|
const onAway = teamPlayerIds(match.away).includes(player.id);
|
||||||
|
if (onHome && onAway)
|
||||||
|
throw new Error(
|
||||||
|
"You are on both teams in this match and cannot report or confirm its score"
|
||||||
|
);
|
||||||
|
|
||||||
|
const homeId = teamId(match.home);
|
||||||
|
const reportedByHome = match.reported_by_team === homeId;
|
||||||
|
const opposingTeam = reportedByHome ? match.away : match.home;
|
||||||
|
|
||||||
|
if (!teamPlayerIds(opposingTeam).includes(player.id))
|
||||||
|
throw new Error("Only a player on the other team can confirm this score");
|
||||||
|
|
||||||
|
return finalizeMatch(matchId, {
|
||||||
|
home_cups: match.reported_home_cups,
|
||||||
|
away_cups: match.reported_away_cups,
|
||||||
|
ot_count: match.reported_ot_count ?? 0,
|
||||||
});
|
});
|
||||||
}
|
})
|
||||||
|
);
|
||||||
|
|
||||||
if (loser) {
|
export const clearMatchReport = createServerFn()
|
||||||
await pbAdmin.updateMatch(loser.id, {
|
.validator(matchIdSchema)
|
||||||
[loser.home_from_lid === match.lid ? "home" : "away"]: matchLoser.id,
|
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
status:
|
.handler(async ({ data: { matchId }, context }) =>
|
||||||
(loser.home_from_lid === match.lid && loser.away) ||
|
toServerResult(async () => {
|
||||||
(loser.away_from_lid === match.lid && loser.home)
|
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
||||||
? "ready"
|
if (!player?.id) throw new Error("Player not found");
|
||||||
: "tbd",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
serverEvents.emit("match", {
|
const match = await pbAdmin.getMatch(matchId);
|
||||||
type: "match",
|
if (!match) throw new Error("Match not found");
|
||||||
matchId: match.id,
|
|
||||||
tournamentId: match.tournament.id
|
const onHome = teamPlayerIds(match.home).includes(player.id);
|
||||||
|
const onAway = teamPlayerIds(match.away).includes(player.id);
|
||||||
|
if (!onHome && !onAway)
|
||||||
|
throw new Error("You are not a player in this match");
|
||||||
|
|
||||||
|
await pbAdmin.updateMatch(matchId, {
|
||||||
|
reported_home_cups: null,
|
||||||
|
reported_away_cups: null,
|
||||||
|
reported_ot_count: null,
|
||||||
|
reported_by_team: null,
|
||||||
|
reported_by_player: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return match;
|
emitServerEvent({ type: "match", matchId, tournamentId: match.tournament.id });
|
||||||
|
return { success: true };
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -590,7 +1006,7 @@ export const toggleMatchReaction = createServerFn()
|
|||||||
|
|
||||||
const reactions = Object.values(reactionsByEmoji);
|
const reactions = Object.values(reactionsByEmoji);
|
||||||
|
|
||||||
serverEvents.emit("reaction", {
|
emitServerEvent({
|
||||||
type: "reaction",
|
type: "reaction",
|
||||||
matchId,
|
matchId,
|
||||||
reactions,
|
reactions,
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ export interface Match {
|
|||||||
away_seed?: number;
|
away_seed?: number;
|
||||||
match_type?: MatchType;
|
match_type?: MatchType;
|
||||||
group?: string;
|
group?: string;
|
||||||
|
reported_home_cups?: number;
|
||||||
|
reported_away_cups?: number;
|
||||||
|
reported_ot_count?: number;
|
||||||
|
reported_by_team?: string;
|
||||||
|
reported_by_player?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const matchInputSchema = z.object({
|
export const matchInputSchema = z.object({
|
||||||
@@ -52,12 +57,17 @@ export const matchInputSchema = z.object({
|
|||||||
is_losers_bracket: z.boolean().optional().default(false),
|
is_losers_bracket: z.boolean().optional().default(false),
|
||||||
status: z.enum(["tbd", "ready", "started", "ended"]).optional().default("tbd"),
|
status: z.enum(["tbd", "ready", "started", "ended"]).optional().default("tbd"),
|
||||||
tournament: z.string().min(1),
|
tournament: z.string().min(1),
|
||||||
home: z.string().min(1).optional(),
|
home: z.string().min(1).nullable().optional(),
|
||||||
away: z.string().min(1).optional(),
|
away: z.string().min(1).nullable().optional(),
|
||||||
home_seed: z.number().int().min(1).optional(),
|
home_seed: z.number().int().min(1).optional(),
|
||||||
away_seed: z.number().int().min(1).optional(),
|
away_seed: z.number().int().min(1).optional(),
|
||||||
match_type: z.enum(["group_stage", "knockout", "winners", "losers", "bracket"]).optional(),
|
match_type: z.enum(["group_stage", "knockout", "winners", "losers", "bracket"]).optional(),
|
||||||
group: z.string().optional(),
|
group: z.string().optional(),
|
||||||
|
reported_home_cups: z.number().int().min(0).nullable().optional(),
|
||||||
|
reported_away_cups: z.number().int().min(0).nullable().optional(),
|
||||||
|
reported_ot_count: z.number().int().min(0).nullable().optional(),
|
||||||
|
reported_by_team: z.string().nullable().optional(),
|
||||||
|
reported_by_player: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type MatchInput = z.infer<typeof matchInputSchema>;
|
export type MatchInput = z.infer<typeof matchInputSchema>;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
ScrollArea,
|
ScrollArea,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
|
||||||
const PlayerListItemSkeleton = () => {
|
export const PlayerListItemSkeleton = () => {
|
||||||
return (
|
return (
|
||||||
<Box p="md">
|
<Box p="md">
|
||||||
<Group gap="sm" align="center" w="100%" wrap="nowrap" style={{ overflow: 'hidden' }}>
|
<Group gap="sm" align="center" w="100%" wrap="nowrap" style={{ overflow: 'hidden' }}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo, useCallback, memo, useRef, useEffect } from "react";
|
import { useState, useMemo, useCallback, memo, useRef, useEffect, useDeferredValue } from "react";
|
||||||
import {
|
import {
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
@@ -23,8 +23,10 @@ import {
|
|||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import { PlayerStats } from "../types";
|
import { PlayerStats } from "../types";
|
||||||
import PlayerAvatar from "@/components/player-avatar";
|
import PlayerAvatar from "@/components/player-avatar";
|
||||||
|
import InfiniteScroll from "@/components/infinite-scroll";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { useAllPlayerStats } from "../queries";
|
import { useAllPlayerStats } from "../queries";
|
||||||
|
import { PlayerListItemSkeleton } from "./player-stats-table-skeleton";
|
||||||
|
|
||||||
type SortKey = keyof PlayerStats | "mmr";
|
type SortKey = keyof PlayerStats | "mmr";
|
||||||
type SortDirection = "asc" | "desc";
|
type SortDirection = "asc" | "desc";
|
||||||
@@ -146,10 +148,32 @@ interface PlayerStatsTableProps {
|
|||||||
viewType?: 'all' | 'mainline' | 'regional';
|
viewType?: 'all' | 'mainline' | 'regional';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const calculateMMR = (stat: PlayerStats): number => {
|
||||||
|
if (stat.matches === 0) return 0;
|
||||||
|
|
||||||
|
const winScore = stat.win_percentage;
|
||||||
|
const matchConfidence = Math.min(stat.matches / 15, 1);
|
||||||
|
const avgCupsScore = Math.min(stat.avg_cups_per_match * 10, 100);
|
||||||
|
const marginScore = stat.margin_of_victory
|
||||||
|
? Math.min(stat.margin_of_victory * 20, 50)
|
||||||
|
: 0;
|
||||||
|
const volumeBonus = Math.min(stat.matches * 0.5, 10);
|
||||||
|
|
||||||
|
const baseMMR =
|
||||||
|
winScore * 0.5 +
|
||||||
|
avgCupsScore * 0.25 +
|
||||||
|
marginScore * 0.15 +
|
||||||
|
volumeBonus * 0.1;
|
||||||
|
|
||||||
|
const finalMMR = baseMMR * matchConfidence;
|
||||||
|
return Math.round(finalMMR * 10) / 10;
|
||||||
|
};
|
||||||
|
|
||||||
const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
|
const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
|
||||||
const { data: playerStats } = useAllPlayerStats(viewType);
|
const { data: playerStats } = useAllPlayerStats(viewType);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const deferredSearch = useDeferredValue(search);
|
||||||
const [sortConfig, setSortConfig] = useState<SortConfig>({
|
const [sortConfig, setSortConfig] = useState<SortConfig>({
|
||||||
key: "mmr" as SortKey,
|
key: "mmr" as SortKey,
|
||||||
direction: "desc",
|
direction: "desc",
|
||||||
@@ -159,10 +183,15 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
|
|||||||
const scrollHandlersRef = useRef<Map<HTMLDivElement, (e: Event) => void>>(new Map());
|
const scrollHandlersRef = useRef<Map<HTMLDivElement, (e: Event) => void>>(new Map());
|
||||||
const scrollLeaderRef = useRef<HTMLDivElement | null>(null);
|
const scrollLeaderRef = useRef<HTMLDivElement | null>(null);
|
||||||
const scrollTimeoutRef = useRef<number | null>(null);
|
const scrollTimeoutRef = useRef<number | null>(null);
|
||||||
|
const lastScrollLeftRef = useRef(0);
|
||||||
|
|
||||||
const handleRegisterViewport = useCallback((viewport: HTMLDivElement) => {
|
const handleRegisterViewport = useCallback((viewport: HTMLDivElement) => {
|
||||||
viewportsRef.current.add(viewport);
|
viewportsRef.current.add(viewport);
|
||||||
|
|
||||||
|
if (lastScrollLeftRef.current > 0) {
|
||||||
|
viewport.scrollLeft = lastScrollLeftRef.current;
|
||||||
|
}
|
||||||
|
|
||||||
const handleScrollStart = () => {
|
const handleScrollStart = () => {
|
||||||
scrollLeaderRef.current = viewport;
|
scrollLeaderRef.current = viewport;
|
||||||
};
|
};
|
||||||
@@ -179,6 +208,7 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const scrollLeft = target.scrollLeft;
|
const scrollLeft = target.scrollLeft;
|
||||||
|
lastScrollLeftRef.current = scrollLeft;
|
||||||
|
|
||||||
viewportsRef.current.forEach((vp) => {
|
viewportsRef.current.forEach((vp) => {
|
||||||
if (vp !== target && Math.abs(vp.scrollLeft - scrollLeft) > 0.5) {
|
if (vp !== target && Math.abs(vp.scrollLeft - scrollLeft) > 0.5) {
|
||||||
@@ -213,27 +243,6 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const calculateMMR = (stat: PlayerStats): number => {
|
|
||||||
if (stat.matches === 0) return 0;
|
|
||||||
|
|
||||||
const winScore = stat.win_percentage;
|
|
||||||
const matchConfidence = Math.min(stat.matches / 15, 1);
|
|
||||||
const avgCupsScore = Math.min(stat.avg_cups_per_match * 10, 100);
|
|
||||||
const marginScore = stat.margin_of_victory
|
|
||||||
? Math.min(stat.margin_of_victory * 20, 50)
|
|
||||||
: 0;
|
|
||||||
const volumeBonus = Math.min(stat.matches * 0.5, 10);
|
|
||||||
|
|
||||||
const baseMMR =
|
|
||||||
winScore * 0.5 +
|
|
||||||
avgCupsScore * 0.25 +
|
|
||||||
marginScore * 0.15 +
|
|
||||||
volumeBonus * 0.1;
|
|
||||||
|
|
||||||
const finalMMR = baseMMR * matchConfidence;
|
|
||||||
return Math.round(finalMMR * 10) / 10;
|
|
||||||
};
|
|
||||||
|
|
||||||
const statsWithMMR = useMemo(() => {
|
const statsWithMMR = useMemo(() => {
|
||||||
return playerStats.map((stat) => ({
|
return playerStats.map((stat) => ({
|
||||||
...stat,
|
...stat,
|
||||||
@@ -243,7 +252,7 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
|
|||||||
|
|
||||||
const filteredAndSortedStats = useMemo(() => {
|
const filteredAndSortedStats = useMemo(() => {
|
||||||
let filtered = statsWithMMR.filter((stat) =>
|
let filtered = statsWithMMR.filter((stat) =>
|
||||||
stat.player_name.toLowerCase().includes(search.toLowerCase())
|
stat.player_name.toLowerCase().includes(deferredSearch.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
return filtered.sort((a, b) => {
|
return filtered.sort((a, b) => {
|
||||||
@@ -272,7 +281,7 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
|
|||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
}, [statsWithMMR, search, sortConfig]);
|
}, [statsWithMMR, deferredSearch, sortConfig]);
|
||||||
|
|
||||||
const handlePlayerClick = useCallback((playerId: string) => {
|
const handlePlayerClick = useCallback((playerId: string) => {
|
||||||
navigate({ to: `/profile/${playerId}` });
|
navigate({ to: `/profile/${playerId}` });
|
||||||
@@ -433,8 +442,12 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
{filteredAndSortedStats.map((stat, index) => (
|
<InfiniteScroll
|
||||||
|
items={filteredAndSortedStats}
|
||||||
|
batchSize={25}
|
||||||
|
renderItem={(stat, index) => (
|
||||||
<Box key={stat.id}>
|
<Box key={stat.id}>
|
||||||
|
{index > 0 && <Divider />}
|
||||||
<PlayerListItem
|
<PlayerListItem
|
||||||
stat={stat}
|
stat={stat}
|
||||||
onPlayerClick={handlePlayerClick}
|
onPlayerClick={handlePlayerClick}
|
||||||
@@ -442,9 +455,17 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
|
|||||||
onRegisterViewport={handleRegisterViewport}
|
onRegisterViewport={handleRegisterViewport}
|
||||||
onUnregisterViewport={handleUnregisterViewport}
|
onUnregisterViewport={handleUnregisterViewport}
|
||||||
/>
|
/>
|
||||||
{index < filteredAndSortedStats.length - 1 && <Divider />}
|
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
)}
|
||||||
|
loader={
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<PlayerListItemSkeleton />
|
||||||
|
<Divider />
|
||||||
|
<PlayerListItemSkeleton />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{filteredAndSortedStats.length === 0 && search && (
|
{filteredAndSortedStats.length === 0 && search && (
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ export const updatePlayer = createServerFn()
|
|||||||
|
|
||||||
await setUserMetadata({ data: { first_name: data.first_name, last_name: data.last_name } });
|
await setUserMetadata({ data: { first_name: data.first_name, last_name: data.last_name } });
|
||||||
|
|
||||||
|
const { emitServerEvent } = await import("@/lib/events/emitter");
|
||||||
|
emitServerEvent({ type: "player", playerId: existing.id! });
|
||||||
|
|
||||||
return updatedPlayer;
|
return updatedPlayer;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -95,6 +98,12 @@ export const createPlayer = createServerFn()
|
|||||||
|
|
||||||
await setUserMetadata({ data: { first_name: data.first_name, last_name: data.last_name, player_id: newPlayer?.id?.toString() } });
|
await setUserMetadata({ data: { first_name: data.first_name, last_name: data.last_name, player_id: newPlayer?.id?.toString() } });
|
||||||
logger.info('Created player', newPlayer);
|
logger.info('Created player', newPlayer);
|
||||||
|
|
||||||
|
if (newPlayer?.id) {
|
||||||
|
const { emitServerEvent } = await import("@/lib/events/emitter");
|
||||||
|
emitServerEvent({ type: "player", playerId: newPlayer.id });
|
||||||
|
}
|
||||||
|
|
||||||
return newPlayer;
|
return newPlayer;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -123,6 +132,10 @@ export const associatePlayer = createServerFn()
|
|||||||
|
|
||||||
const player = await pbAdmin.getPlayer(data);
|
const player = await pbAdmin.getPlayer(data);
|
||||||
logger.info('Associated player', player);
|
logger.info('Associated player', player);
|
||||||
|
|
||||||
|
const { emitServerEvent } = await import("@/lib/events/emitter");
|
||||||
|
emitServerEvent({ type: "player", playerId: data });
|
||||||
|
|
||||||
return player;
|
return player;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Divider, Group, Paper, Stack, Text } from "@mantine/core";
|
||||||
|
import { TeamInfo } from "@/features/teams/types";
|
||||||
|
import TeamAvatar from "@/components/team-avatar";
|
||||||
|
import PlayerAvatar from "@/components/player-avatar";
|
||||||
|
import TeamHeadToHeadSheet from "@/features/matches/components/team-head-to-head-sheet";
|
||||||
|
|
||||||
|
interface MatchupSheetProps {
|
||||||
|
home?: TeamInfo;
|
||||||
|
away?: TeamInfo;
|
||||||
|
isOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TeamRow = ({ team }: { team: TeamInfo }) => (
|
||||||
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
|
<Group gap="sm" align="center" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<TeamAvatar team={team} size={40} radius="sm" />
|
||||||
|
<Text size="sm" fw={600} lineClamp={2}>
|
||||||
|
{team.name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Stack gap={4} align="flex-end">
|
||||||
|
{team.players?.map((player) => {
|
||||||
|
const name = `${player.first_name} ${player.last_name}`;
|
||||||
|
return (
|
||||||
|
<Group key={player.id} gap={6} wrap="nowrap">
|
||||||
|
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
<PlayerAvatar name={name} size={20} disableFullscreen />
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const MatchupSheet: React.FC<MatchupSheetProps> = ({
|
||||||
|
home,
|
||||||
|
away,
|
||||||
|
isOpen,
|
||||||
|
}) => {
|
||||||
|
if (!home && !away) {
|
||||||
|
return (
|
||||||
|
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||||
|
Pick the earlier matches first — these teams aren't decided yet.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Paper p="md" withBorder radius="md">
|
||||||
|
<Stack gap="sm">
|
||||||
|
{home ? (
|
||||||
|
<TeamRow team={home} />
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Home team TBD — pick the earlier matches first
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Divider label="vs" labelPosition="center" />
|
||||||
|
{away ? (
|
||||||
|
<TeamRow team={away} />
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Away team TBD — pick the earlier matches first
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{home && away && (
|
||||||
|
<TeamHeadToHeadSheet team1={home} team2={away} isOpen={isOpen} />
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import React, { useMemo } from "react";
|
||||||
|
import BracketView from "@/features/bracket/components/bracket-view";
|
||||||
|
import { groupMatchesIntoBracket } from "@/features/bracket/utils/group";
|
||||||
|
import { Match } from "@/features/matches/types";
|
||||||
|
import { PicksMap } from "../types";
|
||||||
|
import { PickResult, resolvePredictedBracket } from "../utils";
|
||||||
|
import { PredictionMatchCard } from "./prediction-match-card";
|
||||||
|
|
||||||
|
interface PredictionBracketProps {
|
||||||
|
matches: Match[];
|
||||||
|
picks: PicksMap;
|
||||||
|
mode: "edit" | "view";
|
||||||
|
perMatch?: Map<number, PickResult>;
|
||||||
|
activeLid?: number;
|
||||||
|
onActivate?: (lid: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PredictionBracket: React.FC<PredictionBracketProps> = ({
|
||||||
|
matches,
|
||||||
|
picks,
|
||||||
|
mode,
|
||||||
|
perMatch,
|
||||||
|
activeLid,
|
||||||
|
onActivate,
|
||||||
|
}) => {
|
||||||
|
const bracket = useMemo(() => groupMatchesIntoBracket(matches), [matches]);
|
||||||
|
|
||||||
|
const resolved = useMemo(
|
||||||
|
() => resolvePredictedBracket(matches, picks),
|
||||||
|
[matches, picks]
|
||||||
|
);
|
||||||
|
|
||||||
|
const orders = useMemo(() => {
|
||||||
|
const map: Record<number, number> = {};
|
||||||
|
bracket.winners.flat().forEach((match) => (map[match.lid] = match.order));
|
||||||
|
bracket.losers.flat().forEach((match) => (map[match.lid] = match.order));
|
||||||
|
return map;
|
||||||
|
}, [bracket]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BracketView
|
||||||
|
bracket={bracket}
|
||||||
|
bottomOffset={110}
|
||||||
|
renderMatch={(match) => (
|
||||||
|
<PredictionMatchCard
|
||||||
|
match={match}
|
||||||
|
resolved={resolved.get(match.lid)}
|
||||||
|
orders={orders}
|
||||||
|
mode={mode}
|
||||||
|
result={perMatch?.get(match.lid)}
|
||||||
|
active={activeLid === match.lid}
|
||||||
|
onActivate={
|
||||||
|
onActivate &&
|
||||||
|
(!match.reset || resolved.get(match.lid)?.resetNecessary)
|
||||||
|
? () => onActivate(match.lid)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { CaretLeftIcon, CaretRightIcon, InfoIcon } from "@phosphor-icons/react";
|
||||||
|
import WizardOrbIcon from "@/components/wizard-orb-icon";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Tournament } from "@/features/tournaments/types";
|
||||||
|
import Sheet from "@/components/sheet/sheet";
|
||||||
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
|
import { PicksMap } from "../types";
|
||||||
|
import { useSubmitPrediction } from "../queries";
|
||||||
|
import {
|
||||||
|
getMatchLabel,
|
||||||
|
getPickableMatches,
|
||||||
|
resolvePredictedBracket,
|
||||||
|
setPick,
|
||||||
|
} from "../utils";
|
||||||
|
import { PredictionBracket } from "./prediction-bracket";
|
||||||
|
import { MatchupSheet } from "./matchup-sheet";
|
||||||
|
import { WinnerSelector } from "./winner-selector";
|
||||||
|
|
||||||
|
interface PredictionEditorProps {
|
||||||
|
tournament: Tournament;
|
||||||
|
initialPicks: PicksMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PredictionEditor: React.FC<PredictionEditorProps> = ({
|
||||||
|
tournament,
|
||||||
|
initialPicks,
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const matchupSheet = useSheet();
|
||||||
|
const matches = tournament.matches || [];
|
||||||
|
const [picks, setPicks] = useState<PicksMap>(initialPicks);
|
||||||
|
|
||||||
|
const pickable = useMemo(
|
||||||
|
() => getPickableMatches(matches, picks),
|
||||||
|
[matches, picks]
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolved = useMemo(
|
||||||
|
() => resolvePredictedBracket(matches, picks),
|
||||||
|
[matches, picks]
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstUnpickedLid = useMemo(
|
||||||
|
() =>
|
||||||
|
pickable.find((match) => !resolved.get(match.lid)?.pickedWinnerId)?.lid,
|
||||||
|
[pickable, resolved]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [activeLid, setActiveLid] = useState<number | undefined>(undefined);
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeLid === undefined && pickable.length > 0) {
|
||||||
|
setActiveLid(firstUnpickedLid ?? pickable[0].lid);
|
||||||
|
}
|
||||||
|
}, [activeLid, firstUnpickedLid, pickable]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeLid === undefined) return;
|
||||||
|
const card = containerRef.current?.querySelector(
|
||||||
|
`[data-match-lid="${activeLid}"]`
|
||||||
|
) as HTMLElement | null;
|
||||||
|
const viewport = card?.closest(
|
||||||
|
".mantine-ScrollArea-viewport"
|
||||||
|
) as HTMLElement | null;
|
||||||
|
if (!card || !viewport) return;
|
||||||
|
|
||||||
|
const cardRect = card.getBoundingClientRect();
|
||||||
|
const viewportRect = viewport.getBoundingClientRect();
|
||||||
|
viewport.scrollTo({
|
||||||
|
left: Math.max(
|
||||||
|
0,
|
||||||
|
viewport.scrollLeft +
|
||||||
|
(cardRect.left - viewportRect.left) -
|
||||||
|
(viewportRect.width - cardRect.width) / 2
|
||||||
|
),
|
||||||
|
top: Math.max(
|
||||||
|
0,
|
||||||
|
viewport.scrollTop +
|
||||||
|
(cardRect.top - viewportRect.top) -
|
||||||
|
(viewportRect.height - cardRect.height) / 2
|
||||||
|
),
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
}, [activeLid]);
|
||||||
|
|
||||||
|
const pickedCount = pickable.filter(
|
||||||
|
(match) => resolved.get(match.lid)?.pickedWinnerId
|
||||||
|
).length;
|
||||||
|
const complete = pickedCount === pickable.length;
|
||||||
|
|
||||||
|
const submit = useSubmitPrediction(tournament.id);
|
||||||
|
|
||||||
|
const handlePick = (lid: number, teamId: string) => {
|
||||||
|
const next = setPick(matches, picks, lid, teamId);
|
||||||
|
setPicks(next);
|
||||||
|
|
||||||
|
const nextResolved = resolvePredictedBracket(matches, next);
|
||||||
|
const nextPickable = getPickableMatches(matches, next);
|
||||||
|
const nextUnpicked = nextPickable.find(
|
||||||
|
(match) => !nextResolved.get(match.lid)?.pickedWinnerId
|
||||||
|
);
|
||||||
|
setActiveLid(nextUnpicked?.lid ?? lid);
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeIndex = pickable.findIndex((match) => match.lid === activeLid);
|
||||||
|
const stepTo = (offset: number) => {
|
||||||
|
const next = pickable[activeIndex + offset];
|
||||||
|
if (next) setActiveLid(next.lid);
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeResolved =
|
||||||
|
activeLid !== undefined ? resolved.get(activeLid) : undefined;
|
||||||
|
const activeMatch = pickable[activeIndex];
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await submit.mutateAsync({
|
||||||
|
data: { tournamentId: tournament.id, picks },
|
||||||
|
});
|
||||||
|
navigate({ to: "/" });
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box pos="relative" ref={containerRef}>
|
||||||
|
<PredictionBracket
|
||||||
|
matches={matches}
|
||||||
|
picks={picks}
|
||||||
|
mode="edit"
|
||||||
|
activeLid={activeLid}
|
||||||
|
onActivate={setActiveLid}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
pos="absolute"
|
||||||
|
left={0}
|
||||||
|
right={0}
|
||||||
|
bottom={0}
|
||||||
|
p="md"
|
||||||
|
style={{ zIndex: 2, pointerEvents: "none" }}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
withBorder
|
||||||
|
shadow="md"
|
||||||
|
radius="lg"
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
pointerEvents: "auto",
|
||||||
|
borderColor: "var(--mantine-primary-color-filled)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap="xs">
|
||||||
|
{activeMatch && (
|
||||||
|
<>
|
||||||
|
<Group gap={6} align="center" wrap="nowrap">
|
||||||
|
<WizardOrbIcon
|
||||||
|
size={16}
|
||||||
|
weight="duotone"
|
||||||
|
color="var(--mantine-primary-color-filled)"
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
fw={700}
|
||||||
|
tt="uppercase"
|
||||||
|
c="var(--mantine-primary-color-filled)"
|
||||||
|
style={{ letterSpacing: 0.5 }}
|
||||||
|
>
|
||||||
|
{getMatchLabel(matches, activeMatch)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<WinnerSelector
|
||||||
|
home={activeResolved?.home}
|
||||||
|
away={activeResolved?.away}
|
||||||
|
pickedId={activeResolved?.pickedWinnerId}
|
||||||
|
onSelect={(teamId) => handlePick(activeMatch.lid, teamId)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
|
<Group gap={4} align="center" wrap="nowrap">
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
radius="md"
|
||||||
|
onClick={() => stepTo(-1)}
|
||||||
|
disabled={activeIndex <= 0}
|
||||||
|
aria-label="Previous match"
|
||||||
|
>
|
||||||
|
<CaretLeftIcon size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
radius="md"
|
||||||
|
onClick={() => stepTo(1)}
|
||||||
|
disabled={activeIndex < 0 || activeIndex >= pickable.length - 1}
|
||||||
|
aria-label="Next match"
|
||||||
|
>
|
||||||
|
<CaretRightIcon size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
radius="md"
|
||||||
|
onClick={matchupSheet.open}
|
||||||
|
disabled={!activeResolved?.home && !activeResolved?.away}
|
||||||
|
aria-label="Matchup details"
|
||||||
|
>
|
||||||
|
<InfoIcon size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
<Group gap="sm" align="center" wrap="nowrap">
|
||||||
|
<Text size="sm" fw={600} c={complete ? undefined : "dimmed"}>
|
||||||
|
{pickedCount}/{pickable.length}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
disabled={!complete}
|
||||||
|
loading={submit.isPending}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Sheet
|
||||||
|
title={activeMatch ? getMatchLabel(matches, activeMatch) : "Matchup"}
|
||||||
|
{...matchupSheet.props}
|
||||||
|
>
|
||||||
|
<MatchupSheet
|
||||||
|
home={activeResolved?.home}
|
||||||
|
away={activeResolved?.away}
|
||||||
|
isOpen={matchupSheet.isOpen}
|
||||||
|
/>
|
||||||
|
</Sheet>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import React, { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Popover,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
UnstyledButton,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { CrownIcon, InfoIcon } from "@phosphor-icons/react";
|
||||||
|
import WizardOrbIcon from "@/components/wizard-orb-icon";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import { Tournament } from "@/features/tournaments/types";
|
||||||
|
import PlayerAvatar from "@/components/player-avatar";
|
||||||
|
import { useServerQuery } from "@/lib/tanstack-query/hooks";
|
||||||
|
import { predictionQueries, usePredictionsLeaderboard } from "../queries";
|
||||||
|
import { isPredictionLocked, isTournamentPredictable } from "../utils";
|
||||||
|
|
||||||
|
interface PredictionLeaderboardProps {
|
||||||
|
tournament: Tournament;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PredictionLeaderboard: React.FC<PredictionLeaderboardProps> = ({
|
||||||
|
tournament,
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: leaderboard } = usePredictionsLeaderboard(tournament.id);
|
||||||
|
|
||||||
|
const matches = tournament.matches || [];
|
||||||
|
const isComplete = useMemo(() => {
|
||||||
|
const nonByeMatches = matches.filter(
|
||||||
|
(match) => !(match.status === "tbd" && match.bye === true)
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
nonByeMatches.length > 0 &&
|
||||||
|
nonByeMatches.every((match) => match.status === "ended")
|
||||||
|
);
|
||||||
|
}, [matches]);
|
||||||
|
|
||||||
|
const predictionsOpen =
|
||||||
|
isTournamentPredictable(tournament) && !isPredictionLocked(matches);
|
||||||
|
|
||||||
|
const { data: myPrediction } = useServerQuery({
|
||||||
|
...predictionQueries.mine(tournament.id),
|
||||||
|
options: { enabled: !leaderboard.locked && predictionsOpen },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!leaderboard.locked) {
|
||||||
|
const cta = predictionsOpen ? (
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
navigate({
|
||||||
|
to: "/tournaments/$id/predictions/make",
|
||||||
|
params: { id: tournament.id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{myPrediction?.prediction
|
||||||
|
? "Edit Your Prediction"
|
||||||
|
: "Make Your Prediction"}
|
||||||
|
</Button>
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Stack align="center" gap="md" py="xl">
|
||||||
|
<WizardOrbIcon
|
||||||
|
size={56}
|
||||||
|
weight="duotone"
|
||||||
|
color="var(--mantine-primary-color-filled)"
|
||||||
|
/>
|
||||||
|
<Stack align="center" gap={4}>
|
||||||
|
<Title order={3} c="dimmed" ta="center">
|
||||||
|
Predictions are open
|
||||||
|
</Title>
|
||||||
|
<Text size="sm" c="dimmed" ta="center" maw={280}>
|
||||||
|
Other players' predictions are hidden until the tournament
|
||||||
|
starts.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
{cta}
|
||||||
|
</Stack>
|
||||||
|
{leaderboard.submitters.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text px="md" size="sm" fw={600} pb="xs">
|
||||||
|
{leaderboard.count} bracket{leaderboard.count === 1 ? "" : "s"} in
|
||||||
|
</Text>
|
||||||
|
{leaderboard.submitters.map((player, index) => {
|
||||||
|
const name = `${player.first_name} ${player.last_name}`;
|
||||||
|
return (
|
||||||
|
<Box key={player.id}>
|
||||||
|
<Group gap="sm" align="center" p="md" wrap="nowrap">
|
||||||
|
<PlayerAvatar name={name} size={32} disableFullscreen />
|
||||||
|
<Text size="sm" fw={600} lineClamp={1}>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
{index < leaderboard.submitters.length - 1 && <Divider />}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leaderboard.entries.length === 0) {
|
||||||
|
return (
|
||||||
|
<Stack align="center" gap="md" py="xl">
|
||||||
|
<WizardOrbIcon
|
||||||
|
size={56}
|
||||||
|
weight="duotone"
|
||||||
|
color="var(--mantine-primary-color-filled)"
|
||||||
|
/>
|
||||||
|
<Stack align="center" gap={4}>
|
||||||
|
<Title order={3} c="dimmed" ta="center">
|
||||||
|
No predictions
|
||||||
|
</Title>
|
||||||
|
<Text size="sm" c="dimmed" ta="center" maw={280}>
|
||||||
|
Nobody made a prediction for this tournament.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Group px="md" justify="space-between" align="center" wrap="nowrap">
|
||||||
|
<Text size="lg" fw={600}>
|
||||||
|
Predictions
|
||||||
|
</Text>
|
||||||
|
<Popover position="bottom-end" withArrow shadow="md">
|
||||||
|
<Popover.Target>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
aria-label="How prediction scoring works"
|
||||||
|
>
|
||||||
|
<InfoIcon size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown>
|
||||||
|
<Box maw={280}>
|
||||||
|
<Text size="sm" fw={500} mb="xs">
|
||||||
|
Prediction Scoring:
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" mb={2}>
|
||||||
|
• Each correct pick earns points, doubling every round
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" mb={2}>
|
||||||
|
• <strong>Winners bracket:</strong> 10, 20, 40, 80…
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" mb={2}>
|
||||||
|
• <strong>Losers bracket:</strong> 5, 10, 20, 40…
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" mb={2}>
|
||||||
|
• <strong>Bracket reset:</strong> only picked if your bracket
|
||||||
|
triggers it — worth double the Final
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Divider my="sm" />
|
||||||
|
|
||||||
|
<Text size="sm" fw={500} mb="xs">
|
||||||
|
Tiebreakers:
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" mb={2}>
|
||||||
|
1. Correct champion pick
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" mb={2}>
|
||||||
|
2. Earlier submission
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" mt="xs" c="dimmed">
|
||||||
|
* PICKS shows correct picks / total picks made
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
</Group>
|
||||||
|
<Text px="md" c="dimmed" size="xs" fw={500}>
|
||||||
|
Correct picks are worth more each round
|
||||||
|
</Text>
|
||||||
|
{leaderboard.entries.map((entry, index) => {
|
||||||
|
const name = `${entry.player.first_name} ${entry.player.last_name}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={entry.player.id}>
|
||||||
|
<UnstyledButton
|
||||||
|
w="100%"
|
||||||
|
p="md"
|
||||||
|
style={{ borderRadius: 0 }}
|
||||||
|
onClick={() =>
|
||||||
|
navigate({
|
||||||
|
to: "/tournaments/$id/predictions/$playerId",
|
||||||
|
params: { id: tournament.id, playerId: entry.player.id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="center" w="100%" wrap="nowrap">
|
||||||
|
<Group gap="sm" align="center" wrap="nowrap">
|
||||||
|
<PlayerAvatar name={name} size={40} disableFullscreen />
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
#{index + 1}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={600} lineClamp={1}>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
{index === 0 && isComplete && (
|
||||||
|
<ThemeIcon size="xs" color="yellow" variant="light" radius="xl">
|
||||||
|
<CrownIcon size={12} />
|
||||||
|
</ThemeIcon>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
{entry.championPick && (
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
<CrownIcon
|
||||||
|
size={12}
|
||||||
|
weight="fill"
|
||||||
|
color={
|
||||||
|
entry.championCorrect && isComplete
|
||||||
|
? "gold"
|
||||||
|
: "var(--mantine-color-dimmed)"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||||
|
{entry.championPick.name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
<Group gap="md" wrap="nowrap">
|
||||||
|
<Stack gap={0} ta="center">
|
||||||
|
<Text size="xs" c="dimmed" fw={700}>
|
||||||
|
PTS
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={700}>
|
||||||
|
{entry.points}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={0} ta="center">
|
||||||
|
<Text size="xs" c="dimmed" fw={700}>
|
||||||
|
PICKS
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{entry.correct}/{entry.total}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
{index < leaderboard.entries.length - 1 && <Divider />}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { Card, Flex, Text } from "@mantine/core";
|
||||||
|
import React from "react";
|
||||||
|
import { MatchSlot, MatchSlotState } from "@/features/bracket/components/match-slot";
|
||||||
|
import { Match } from "@/features/matches/types";
|
||||||
|
import { PickResult, ResolvedMatch } from "../utils";
|
||||||
|
|
||||||
|
interface PredictionMatchCardProps {
|
||||||
|
match: Match;
|
||||||
|
resolved?: ResolvedMatch;
|
||||||
|
orders: Record<number, number>;
|
||||||
|
mode: "edit" | "view";
|
||||||
|
result?: PickResult;
|
||||||
|
active?: boolean;
|
||||||
|
onActivate?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickedState = (mode: "edit" | "view", result?: PickResult): MatchSlotState => {
|
||||||
|
if (mode === "edit") return "winner";
|
||||||
|
if (result === "correct") return "correct";
|
||||||
|
if (result === "incorrect") return "incorrect";
|
||||||
|
return "winner";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PredictionMatchCard: React.FC<PredictionMatchCardProps> = ({
|
||||||
|
match,
|
||||||
|
resolved,
|
||||||
|
orders,
|
||||||
|
mode,
|
||||||
|
result,
|
||||||
|
active,
|
||||||
|
onActivate,
|
||||||
|
}) => {
|
||||||
|
const resetLive = !match.reset || !!resolved?.resetNecessary;
|
||||||
|
|
||||||
|
const slotProps = (side: "home" | "away") => {
|
||||||
|
const team = side === "home" ? resolved?.home : resolved?.away;
|
||||||
|
const teamId = side === "home" ? resolved?.homeId : resolved?.awayId;
|
||||||
|
const isPicked =
|
||||||
|
!!teamId && resetLive && resolved?.pickedWinnerId === teamId;
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: orders[side === "home" ? match.home_from_lid : match.away_from_lid],
|
||||||
|
from_loser:
|
||||||
|
side === "home" ? match.home_from_loser : match.away_from_loser,
|
||||||
|
team,
|
||||||
|
seed: side === "home" ? match.home_seed : match.away_seed,
|
||||||
|
state: isPicked ? pickedState(mode, result) : undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
direction="row"
|
||||||
|
align="center"
|
||||||
|
justify="end"
|
||||||
|
gap={8}
|
||||||
|
opacity={resetLive ? 1 : 0.55}
|
||||||
|
style={{ transition: "opacity 200ms ease" }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
c="dimmed"
|
||||||
|
fw="bolder"
|
||||||
|
px={6}
|
||||||
|
py={2}
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--mantine-color-body)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{match.order}
|
||||||
|
</Text>
|
||||||
|
<Card
|
||||||
|
w={220}
|
||||||
|
withBorder
|
||||||
|
pos="relative"
|
||||||
|
onClick={onActivate}
|
||||||
|
style={{
|
||||||
|
cursor: onActivate ? "pointer" : undefined,
|
||||||
|
overflow: "visible",
|
||||||
|
backgroundColor: 'var(--mantine-color-body)',
|
||||||
|
borderColor: active
|
||||||
|
? 'var(--mantine-primary-color-filled)'
|
||||||
|
: 'var(--mantine-color-default-border)',
|
||||||
|
boxShadow: active
|
||||||
|
? '0 0 0 1px var(--mantine-primary-color-filled), 0 0 12px var(--mantine-primary-color-light-hover), var(--mantine-shadow-sm)'
|
||||||
|
: 'var(--mantine-shadow-sm)',
|
||||||
|
transition: 'border-color 200ms ease, box-shadow 200ms ease',
|
||||||
|
}}
|
||||||
|
data-match-lid={match.lid}
|
||||||
|
>
|
||||||
|
<Card.Section withBorder p={0}>
|
||||||
|
<MatchSlot {...slotProps("home")} />
|
||||||
|
</Card.Section>
|
||||||
|
|
||||||
|
<Card.Section p={0} mb={-16}>
|
||||||
|
<MatchSlot {...slotProps("away")} />
|
||||||
|
</Card.Section>
|
||||||
|
|
||||||
|
{match.reset && (
|
||||||
|
<Text
|
||||||
|
pos="absolute"
|
||||||
|
top={-20}
|
||||||
|
left={8}
|
||||||
|
size="xs"
|
||||||
|
c="dimmed"
|
||||||
|
fw="bold"
|
||||||
|
>
|
||||||
|
* If necessary
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Group, Text, UnstyledButton } from "@mantine/core";
|
||||||
|
import { CrownIcon } from "@phosphor-icons/react";
|
||||||
|
import { TeamInfo } from "@/features/teams/types";
|
||||||
|
import TeamAvatar from "@/components/team-avatar";
|
||||||
|
|
||||||
|
interface WinnerSelectorProps {
|
||||||
|
home?: TeamInfo;
|
||||||
|
away?: TeamInfo;
|
||||||
|
pickedId?: string;
|
||||||
|
onSelect: (teamId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TeamChip = ({
|
||||||
|
team,
|
||||||
|
picked,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
team?: TeamInfo;
|
||||||
|
picked: boolean;
|
||||||
|
onSelect: (teamId: string) => void;
|
||||||
|
}) => {
|
||||||
|
if (!team) {
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
gap={8}
|
||||||
|
wrap="nowrap"
|
||||||
|
justify="center"
|
||||||
|
p="6px 10px"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
border: "1px dashed var(--mantine-color-default-border)",
|
||||||
|
borderRadius: "var(--mantine-radius-md)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
TBD
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={() => onSelect(team.id)}
|
||||||
|
style={{ flex: 1, minWidth: 0 }}
|
||||||
|
aria-pressed={picked}
|
||||||
|
>
|
||||||
|
<Group
|
||||||
|
gap={8}
|
||||||
|
wrap="nowrap"
|
||||||
|
p="6px 10px"
|
||||||
|
style={{
|
||||||
|
border: `1px solid ${
|
||||||
|
picked
|
||||||
|
? "var(--mantine-color-green-light-color)"
|
||||||
|
: "var(--mantine-color-default-border)"
|
||||||
|
}`,
|
||||||
|
borderRadius: "var(--mantine-radius-md)",
|
||||||
|
backgroundColor: picked
|
||||||
|
? "var(--mantine-color-green-light)"
|
||||||
|
: "transparent",
|
||||||
|
transition: "background-color 200ms ease, border-color 200ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TeamAvatar team={team} size={26} radius="sm" disableFullscreen />
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
fw={600}
|
||||||
|
truncate
|
||||||
|
style={{ flex: 1, minWidth: 0 }}
|
||||||
|
>
|
||||||
|
{team.name}
|
||||||
|
</Text>
|
||||||
|
{picked && (
|
||||||
|
<CrownIcon
|
||||||
|
size={14}
|
||||||
|
weight="fill"
|
||||||
|
style={{
|
||||||
|
color: "gold",
|
||||||
|
filter: "drop-shadow(0 1px 1px rgba(0,0,0,0.3))",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WinnerSelector: React.FC<WinnerSelectorProps> = ({
|
||||||
|
home,
|
||||||
|
away,
|
||||||
|
pickedId,
|
||||||
|
onSelect,
|
||||||
|
}) => (
|
||||||
|
<Group gap="xs" wrap="nowrap" align="center">
|
||||||
|
<TeamChip
|
||||||
|
team={home}
|
||||||
|
picked={!!home && pickedId === home.id}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
<Text size="xs" c="dimmed" fw={700}>
|
||||||
|
vs
|
||||||
|
</Text>
|
||||||
|
<TeamChip
|
||||||
|
team={away}
|
||||||
|
picked={!!away && pickedId === away.id}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
useServerMutation,
|
||||||
|
useServerSuspenseQuery,
|
||||||
|
} from "@/lib/tanstack-query/hooks";
|
||||||
|
import {
|
||||||
|
getMyPrediction,
|
||||||
|
getPlayerPrediction,
|
||||||
|
getPredictionsLeaderboard,
|
||||||
|
submitPrediction,
|
||||||
|
} from "./server";
|
||||||
|
|
||||||
|
export const predictionKeys = {
|
||||||
|
tournament: (tournamentId: string) => ['predictions', tournamentId] as const,
|
||||||
|
mine: (tournamentId: string) => ['predictions', tournamentId, 'mine'] as const,
|
||||||
|
leaderboard: (tournamentId: string) => ['predictions', tournamentId, 'leaderboard'] as const,
|
||||||
|
player: (tournamentId: string, playerId: string) => ['predictions', tournamentId, 'player', playerId] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const predictionQueries = {
|
||||||
|
mine: (tournamentId: string) => ({
|
||||||
|
queryKey: predictionKeys.mine(tournamentId),
|
||||||
|
queryFn: () => getMyPrediction({ data: tournamentId }),
|
||||||
|
}),
|
||||||
|
leaderboard: (tournamentId: string) => ({
|
||||||
|
queryKey: predictionKeys.leaderboard(tournamentId),
|
||||||
|
queryFn: () => getPredictionsLeaderboard({ data: tournamentId }),
|
||||||
|
}),
|
||||||
|
player: (tournamentId: string, playerId: string) => ({
|
||||||
|
queryKey: predictionKeys.player(tournamentId, playerId),
|
||||||
|
queryFn: () => getPlayerPrediction({ data: { tournamentId, playerId } }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useMyPrediction = (tournamentId: string) =>
|
||||||
|
useServerSuspenseQuery(predictionQueries.mine(tournamentId));
|
||||||
|
|
||||||
|
export const usePredictionsLeaderboard = (tournamentId: string) =>
|
||||||
|
useServerSuspenseQuery(predictionQueries.leaderboard(tournamentId));
|
||||||
|
|
||||||
|
export const usePlayerPrediction = (tournamentId: string, playerId: string) =>
|
||||||
|
useServerSuspenseQuery(predictionQueries.player(tournamentId, playerId));
|
||||||
|
|
||||||
|
export const useSubmitPrediction = (tournamentId: string) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useServerMutation({
|
||||||
|
mutationFn: submitPrediction,
|
||||||
|
successMessage: "Prediction saved!",
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: predictionKeys.tournament(tournamentId),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||||
|
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||||
|
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||||
|
import { Tournament } from "@/features/tournaments/types";
|
||||||
|
import {
|
||||||
|
MyPrediction,
|
||||||
|
Prediction,
|
||||||
|
PredictionLeaderboardEntry,
|
||||||
|
PredictionsLeaderboard,
|
||||||
|
} from "./types";
|
||||||
|
import {
|
||||||
|
computePredictionScore,
|
||||||
|
getPickableMatches,
|
||||||
|
isPredictionComplete,
|
||||||
|
isPredictionLocked,
|
||||||
|
isTournamentPredictable,
|
||||||
|
} from "./utils";
|
||||||
|
|
||||||
|
const getTournamentOrThrow = async (tournamentId: string): Promise<Tournament> => {
|
||||||
|
const tournament = await pbAdmin.getTournament(tournamentId);
|
||||||
|
if (!tournament) {
|
||||||
|
throw new Error("Tournament not found");
|
||||||
|
}
|
||||||
|
return tournament;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMyPrediction = createServerFn()
|
||||||
|
.validator(z.string())
|
||||||
|
.middleware([superTokensFunctionMiddleware])
|
||||||
|
.handler(async ({ data: tournamentId, context }) =>
|
||||||
|
toServerResult(async (): Promise<MyPrediction> => {
|
||||||
|
const tournament = await getTournamentOrThrow(tournamentId);
|
||||||
|
const matches = tournament.matches || [];
|
||||||
|
|
||||||
|
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
||||||
|
const prediction = player
|
||||||
|
? await pbAdmin.getPrediction(tournamentId, player.id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
prediction,
|
||||||
|
locked: isPredictionLocked(matches),
|
||||||
|
eligible: isTournamentPredictable(tournament),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitPredictionSchema = z.object({
|
||||||
|
tournamentId: z.string(),
|
||||||
|
picks: z.record(z.string(), z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const submitPrediction = createServerFn()
|
||||||
|
.validator(submitPredictionSchema)
|
||||||
|
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
|
.handler(async ({ data: { tournamentId, picks }, context }) =>
|
||||||
|
toServerResult(async (): Promise<Prediction> => {
|
||||||
|
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
||||||
|
if (!player) {
|
||||||
|
throw new Error("Player not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tournament = await getTournamentOrThrow(tournamentId);
|
||||||
|
const matches = tournament.matches || [];
|
||||||
|
|
||||||
|
if (!isTournamentPredictable(tournament)) {
|
||||||
|
throw new Error("Predictions are not available for this tournament");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPredictionLocked(matches)) {
|
||||||
|
throw new Error("Predictions are locked — the tournament has started");
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickableLids = new Set(
|
||||||
|
getPickableMatches(matches, picks).map((match) => String(match.lid))
|
||||||
|
);
|
||||||
|
const pickKeys = Object.keys(picks);
|
||||||
|
if (
|
||||||
|
pickKeys.length !== pickableLids.size ||
|
||||||
|
pickKeys.some((lid) => !pickableLids.has(lid))
|
||||||
|
) {
|
||||||
|
throw new Error("Prediction must include a pick for every match");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPredictionComplete(matches, picks)) {
|
||||||
|
throw new Error("Prediction contains invalid picks");
|
||||||
|
}
|
||||||
|
|
||||||
|
const prediction = await pbAdmin.upsertPrediction(
|
||||||
|
tournamentId,
|
||||||
|
player.id,
|
||||||
|
picks
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("Prediction submitted", {
|
||||||
|
tournamentId,
|
||||||
|
playerId: player.id,
|
||||||
|
pickCount: pickKeys.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return prediction;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getPredictionsLeaderboard = createServerFn()
|
||||||
|
.validator(z.string())
|
||||||
|
.middleware([superTokensFunctionMiddleware])
|
||||||
|
.handler(async ({ data: tournamentId }) =>
|
||||||
|
toServerResult(async (): Promise<PredictionsLeaderboard> => {
|
||||||
|
const tournament = await getTournamentOrThrow(tournamentId);
|
||||||
|
const matches = tournament.matches || [];
|
||||||
|
const locked = isPredictionLocked(matches);
|
||||||
|
|
||||||
|
const predictions = await pbAdmin.getPredictionsForTournament(tournamentId);
|
||||||
|
const submitters = predictions.map((prediction) => prediction.player);
|
||||||
|
|
||||||
|
if (!locked) {
|
||||||
|
return { locked, count: predictions.length, entries: [], submitters };
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries: PredictionLeaderboardEntry[] = predictions.map(
|
||||||
|
(prediction) => {
|
||||||
|
const score = computePredictionScore(matches, prediction.picks);
|
||||||
|
const championPick = tournament.teams?.find(
|
||||||
|
(team) => team.id === score.predictedChampionId
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
player: prediction.player,
|
||||||
|
points: score.points,
|
||||||
|
correct: score.correct,
|
||||||
|
total: score.total,
|
||||||
|
championPick,
|
||||||
|
championCorrect:
|
||||||
|
!!score.predictedChampionId &&
|
||||||
|
score.predictedChampionId === tournament.first_place?.id,
|
||||||
|
updated: prediction.updated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
entries.sort(
|
||||||
|
(a, b) =>
|
||||||
|
b.points - a.points ||
|
||||||
|
Number(b.championCorrect) - Number(a.championCorrect) ||
|
||||||
|
a.updated.localeCompare(b.updated) ||
|
||||||
|
(a.player.first_name ?? "").localeCompare(b.player.first_name ?? "")
|
||||||
|
);
|
||||||
|
|
||||||
|
return { locked, count: entries.length, entries, submitters };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const playerPredictionSchema = z.object({
|
||||||
|
tournamentId: z.string(),
|
||||||
|
playerId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getPlayerPrediction = createServerFn()
|
||||||
|
.validator(playerPredictionSchema)
|
||||||
|
.middleware([superTokensFunctionMiddleware])
|
||||||
|
.handler(async ({ data: { tournamentId, playerId }, context }) =>
|
||||||
|
toServerResult(async (): Promise<Prediction | null> => {
|
||||||
|
const tournament = await getTournamentOrThrow(tournamentId);
|
||||||
|
|
||||||
|
if (!isPredictionLocked(tournament.matches || [])) {
|
||||||
|
const me = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
||||||
|
if (me?.id !== playerId) {
|
||||||
|
throw new Error("Predictions are private until the tournament starts");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pbAdmin.getPrediction(tournamentId, playerId);
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { PlayerInfo } from "@/features/players/types";
|
||||||
|
import { TeamInfo } from "@/features/teams/types";
|
||||||
|
|
||||||
|
export type PicksMap = Record<string, string>;
|
||||||
|
|
||||||
|
export interface Prediction {
|
||||||
|
id: string;
|
||||||
|
tournament: string;
|
||||||
|
player: PlayerInfo;
|
||||||
|
picks: PicksMap;
|
||||||
|
created: string;
|
||||||
|
updated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PredictionLeaderboardEntry {
|
||||||
|
player: PlayerInfo;
|
||||||
|
points: number;
|
||||||
|
correct: number;
|
||||||
|
total: number;
|
||||||
|
championPick?: TeamInfo;
|
||||||
|
championCorrect: boolean;
|
||||||
|
updated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PredictionsLeaderboard {
|
||||||
|
locked: boolean;
|
||||||
|
count: number;
|
||||||
|
entries: PredictionLeaderboardEntry[];
|
||||||
|
submitters: PlayerInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MyPrediction {
|
||||||
|
prediction: Prediction | null;
|
||||||
|
locked: boolean;
|
||||||
|
eligible: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { Match } from "@/features/matches/types";
|
||||||
|
import { Team, TeamInfo } from "@/features/teams/types";
|
||||||
|
import { Tournament } from "@/features/tournaments/types";
|
||||||
|
import { PicksMap } from "./types";
|
||||||
|
|
||||||
|
const teamId = (team?: TeamInfo | Team | string): string | undefined =>
|
||||||
|
typeof team === "string" ? team : team?.id;
|
||||||
|
|
||||||
|
const asTeamInfo = (team?: TeamInfo | Team | string): TeamInfo | undefined =>
|
||||||
|
typeof team === "string" ? undefined : team;
|
||||||
|
|
||||||
|
export interface ResolvedMatch {
|
||||||
|
lid: number;
|
||||||
|
home?: TeamInfo;
|
||||||
|
homeId?: string;
|
||||||
|
away?: TeamInfo;
|
||||||
|
awayId?: string;
|
||||||
|
pickedWinner?: TeamInfo;
|
||||||
|
pickedWinnerId?: string;
|
||||||
|
pickedLoser?: TeamInfo;
|
||||||
|
pickedLoserId?: string;
|
||||||
|
resetNecessary?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBracketMatch = (match: Match) => match.round !== -1 && !match.bye;
|
||||||
|
|
||||||
|
export const getPickableMatches = (
|
||||||
|
matches: Match[],
|
||||||
|
picks: PicksMap
|
||||||
|
): Match[] => {
|
||||||
|
const resolved = resolvePredictedBracket(matches, picks);
|
||||||
|
return matches
|
||||||
|
.filter(
|
||||||
|
(match) =>
|
||||||
|
isBracketMatch(match) &&
|
||||||
|
(!match.reset || resolved.get(match.lid)?.resetNecessary)
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.lid - b.lid);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isPredictionLocked = (matches: Match[]): boolean =>
|
||||||
|
matches.some(
|
||||||
|
(match) => match.status === "started" || match.status === "ended"
|
||||||
|
);
|
||||||
|
|
||||||
|
export const isTournamentPredictable = (tournament: Tournament): boolean => {
|
||||||
|
const matches = tournament.matches || [];
|
||||||
|
return (
|
||||||
|
!tournament.regional &&
|
||||||
|
matches.length > 0 &&
|
||||||
|
!matches.some((match) => match.round === -1)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveInternal = (
|
||||||
|
matches: Match[],
|
||||||
|
picks: PicksMap,
|
||||||
|
prune: boolean
|
||||||
|
): { resolved: Map<number, ResolvedMatch>; picks: PicksMap } => {
|
||||||
|
const resolved = new Map<number, ResolvedMatch>();
|
||||||
|
const nextPicks: PicksMap = { ...picks };
|
||||||
|
|
||||||
|
const bracketMatches = matches
|
||||||
|
.filter(isBracketMatch)
|
||||||
|
.sort((a, b) => a.lid - b.lid);
|
||||||
|
|
||||||
|
for (const match of bracketMatches) {
|
||||||
|
const entry: ResolvedMatch = { lid: match.lid };
|
||||||
|
|
||||||
|
if (match.home_from_lid === -1) {
|
||||||
|
entry.home = asTeamInfo(match.home);
|
||||||
|
entry.homeId = teamId(match.home);
|
||||||
|
} else {
|
||||||
|
const source = resolved.get(match.home_from_lid);
|
||||||
|
entry.home = match.home_from_loser
|
||||||
|
? source?.pickedLoser
|
||||||
|
: source?.pickedWinner;
|
||||||
|
entry.homeId = match.home_from_loser
|
||||||
|
? source?.pickedLoserId
|
||||||
|
: source?.pickedWinnerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.away_from_lid === -1) {
|
||||||
|
entry.away = asTeamInfo(match.away);
|
||||||
|
entry.awayId = teamId(match.away);
|
||||||
|
} else {
|
||||||
|
const source = resolved.get(match.away_from_lid);
|
||||||
|
entry.away = match.away_from_loser
|
||||||
|
? source?.pickedLoser
|
||||||
|
: source?.pickedWinner;
|
||||||
|
entry.awayId = match.away_from_loser
|
||||||
|
? source?.pickedLoserId
|
||||||
|
: source?.pickedWinnerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pickEligible = true;
|
||||||
|
if (match.reset) {
|
||||||
|
const grandFinal = resolved.get(match.home_from_lid);
|
||||||
|
entry.resetNecessary =
|
||||||
|
!!grandFinal?.pickedWinnerId &&
|
||||||
|
grandFinal.pickedWinnerId === grandFinal.awayId;
|
||||||
|
pickEligible = entry.resetNecessary;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickId = nextPicks[String(match.lid)];
|
||||||
|
if (pickEligible && pickId && pickId === entry.homeId) {
|
||||||
|
entry.pickedWinner = entry.home;
|
||||||
|
entry.pickedWinnerId = entry.homeId;
|
||||||
|
entry.pickedLoser = entry.away;
|
||||||
|
entry.pickedLoserId = entry.awayId;
|
||||||
|
} else if (pickEligible && pickId && pickId === entry.awayId) {
|
||||||
|
entry.pickedWinner = entry.away;
|
||||||
|
entry.pickedWinnerId = entry.awayId;
|
||||||
|
entry.pickedLoser = entry.home;
|
||||||
|
entry.pickedLoserId = entry.homeId;
|
||||||
|
} else if (pickId && prune) {
|
||||||
|
delete nextPicks[String(match.lid)];
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved.set(match.lid, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { resolved, picks: nextPicks };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolvePredictedBracket = (
|
||||||
|
matches: Match[],
|
||||||
|
picks: PicksMap
|
||||||
|
): Map<number, ResolvedMatch> => resolveInternal(matches, picks, false).resolved;
|
||||||
|
|
||||||
|
export const setPick = (
|
||||||
|
matches: Match[],
|
||||||
|
picks: PicksMap,
|
||||||
|
lid: number,
|
||||||
|
pickedTeamId: string
|
||||||
|
): PicksMap =>
|
||||||
|
resolveInternal(
|
||||||
|
matches,
|
||||||
|
{ ...picks, [String(lid)]: pickedTeamId },
|
||||||
|
true
|
||||||
|
).picks;
|
||||||
|
|
||||||
|
export const isPredictionComplete = (
|
||||||
|
matches: Match[],
|
||||||
|
picks: PicksMap
|
||||||
|
): boolean => {
|
||||||
|
const resolved = resolvePredictedBracket(matches, picks);
|
||||||
|
return getPickableMatches(matches, picks).every(
|
||||||
|
(match) => resolved.get(match.lid)?.pickedWinnerId
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMatchLabel = (matches: Match[], match: Match): string => {
|
||||||
|
if (match.reset) return "Bracket Reset";
|
||||||
|
|
||||||
|
const winners = matches.filter(
|
||||||
|
(m) => isBracketMatch(m) && !m.reset && !m.is_losers_bracket
|
||||||
|
);
|
||||||
|
const grandFinal = winners.reduce(
|
||||||
|
(highest: Match | undefined, current) =>
|
||||||
|
!highest || current.lid > highest.lid ? current : highest,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
if (!grandFinal) return `Match ${match.order}`;
|
||||||
|
|
||||||
|
const hasLosersBracket = matches.some(
|
||||||
|
(m) => isBracketMatch(m) && m.is_losers_bracket
|
||||||
|
);
|
||||||
|
|
||||||
|
if (match.lid === grandFinal.lid) return "Final";
|
||||||
|
if (
|
||||||
|
hasLosersBracket &&
|
||||||
|
!match.is_losers_bracket &&
|
||||||
|
grandFinal.home_from_lid === match.lid
|
||||||
|
) {
|
||||||
|
return "Winners Bracket Final";
|
||||||
|
}
|
||||||
|
if (match.is_losers_bracket && grandFinal.away_from_lid === match.lid) {
|
||||||
|
return "Losers Bracket Final";
|
||||||
|
}
|
||||||
|
return `Match ${match.order}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PickResult = "correct" | "incorrect" | "pending";
|
||||||
|
|
||||||
|
export interface PredictionScore {
|
||||||
|
points: number;
|
||||||
|
correct: number;
|
||||||
|
total: number;
|
||||||
|
perMatch: Map<number, PickResult>;
|
||||||
|
predictedChampionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getMatchPoints = (match: Match): number =>
|
||||||
|
(match.is_losers_bracket ? 5 : 10) * 2 ** match.round;
|
||||||
|
|
||||||
|
export const computePredictionScore = (
|
||||||
|
matches: Match[],
|
||||||
|
picks: PicksMap
|
||||||
|
): PredictionScore => {
|
||||||
|
const pickable = getPickableMatches(matches, picks);
|
||||||
|
const perMatch = new Map<number, PickResult>();
|
||||||
|
|
||||||
|
let points = 0;
|
||||||
|
let correct = 0;
|
||||||
|
|
||||||
|
for (const match of pickable) {
|
||||||
|
const pickId = picks[String(match.lid)];
|
||||||
|
|
||||||
|
if (match.status !== "ended") {
|
||||||
|
perMatch.set(match.lid, "pending");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actualWinnerId =
|
||||||
|
match.home_cups > match.away_cups ? teamId(match.home) : teamId(match.away);
|
||||||
|
|
||||||
|
if (pickId && actualWinnerId && pickId === actualWinnerId) {
|
||||||
|
perMatch.set(match.lid, "correct");
|
||||||
|
points += getMatchPoints(match);
|
||||||
|
correct += 1;
|
||||||
|
} else {
|
||||||
|
perMatch.set(match.lid, "incorrect");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const grandFinal = pickable
|
||||||
|
.filter((match) => !match.is_losers_bracket)
|
||||||
|
.at(-1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
points,
|
||||||
|
correct,
|
||||||
|
total: pickable.length,
|
||||||
|
perMatch,
|
||||||
|
predictedChampionId: grandFinal
|
||||||
|
? picks[String(grandFinal.lid)]
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -37,7 +37,7 @@ const EmojiPicker = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
position="bottom"
|
position="top-end"
|
||||||
withArrow
|
withArrow
|
||||||
shadow="sm"
|
shadow="sm"
|
||||||
opened={opened}
|
opened={opened}
|
||||||
@@ -45,6 +45,7 @@ const EmojiPicker = ({
|
|||||||
trapFocus
|
trapFocus
|
||||||
closeOnEscape
|
closeOnEscape
|
||||||
closeOnClickOutside
|
closeOnClickOutside
|
||||||
|
withinPortal
|
||||||
>
|
>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { Box, Title, Stack, Switch, Button, Text, Group } from "@mantine/core";
|
||||||
|
import { useWebPush } from "@/hooks/use-web-push";
|
||||||
|
import toast from "@/lib/sonner";
|
||||||
|
|
||||||
|
export function NotificationsSection() {
|
||||||
|
const { supported, configured, permission, subscribed, busy, enable, disable, sendTest } =
|
||||||
|
useWebPush();
|
||||||
|
|
||||||
|
if (!supported || !configured) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
px="md"
|
||||||
|
py="sm"
|
||||||
|
style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}
|
||||||
|
>
|
||||||
|
<Title order={3}>Notifications</Title>
|
||||||
|
<Text size="sm" c="dimmed" mt="xs">
|
||||||
|
{supported
|
||||||
|
? "Notifications aren't available right now."
|
||||||
|
: "This device doesn't support push notifications. On iOS, install the app to your Home Screen first."}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggle = async (checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
const ok = await enable();
|
||||||
|
if (ok) {
|
||||||
|
toast.success("Notifications enabled on this device");
|
||||||
|
} else if (permission === "denied") {
|
||||||
|
toast.error(
|
||||||
|
"Notifications are blocked. Enable them in your browser settings."
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error("Couldn't enable notifications");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const ok = await disable();
|
||||||
|
if (ok) toast.success("Notifications disabled on this device");
|
||||||
|
else toast.error("Couldn't disable notifications");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTest = async () => {
|
||||||
|
const ok = await sendTest();
|
||||||
|
if (ok) toast.success("Test notification sent");
|
||||||
|
else toast.error("Couldn't send test notification");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
px="md"
|
||||||
|
py="sm"
|
||||||
|
style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}
|
||||||
|
>
|
||||||
|
<Title order={3}>Notifications</Title>
|
||||||
|
<Stack mt="xs" gap="sm">
|
||||||
|
<Switch
|
||||||
|
checked={subscribed}
|
||||||
|
onChange={(e) => handleToggle(e.currentTarget.checked)}
|
||||||
|
disabled={busy}
|
||||||
|
label="Enable notifications on this device"
|
||||||
|
/>
|
||||||
|
{subscribed && (
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
size="xs"
|
||||||
|
onClick={handleTest}
|
||||||
|
loading={busy}
|
||||||
|
>
|
||||||
|
Send test notification
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NotificationsSection;
|
||||||
@@ -7,6 +7,7 @@ import { teamInputSchema, teamUpdateSchema } from "./types";
|
|||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { Match } from "../matches/types";
|
import { Match } from "../matches/types";
|
||||||
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||||
|
import { emitServerEvent } from "@/lib/events/emitter";
|
||||||
|
|
||||||
|
|
||||||
export const listTeamInfos = createServerFn()
|
export const listTeamInfos = createServerFn()
|
||||||
@@ -42,7 +43,9 @@ export const createTeam = createServerFn()
|
|||||||
//}
|
//}
|
||||||
|
|
||||||
logger.info("Creating team", { name: data.name, userId, isAdmin });
|
logger.info("Creating team", { name: data.name, userId, isAdmin });
|
||||||
return pbAdmin.createTeam(data);
|
const team = await pbAdmin.createTeam(data);
|
||||||
|
emitServerEvent({ type: "team", teamId: team?.id });
|
||||||
|
return team;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -68,7 +71,9 @@ export const updateTeam = createServerFn()
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
logger.info("Updating team", { teamId: id, userId, isAdmin });
|
logger.info("Updating team", { teamId: id, userId, isAdmin });
|
||||||
return pbAdmin.updateTeam(id, updates);
|
const updated = await pbAdmin.updateTeam(id, updates);
|
||||||
|
emitServerEvent({ type: "team", teamId: id });
|
||||||
|
return updated;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ import AnimatedScore from "@/features/matches/components/animated-score";
|
|||||||
interface GroupMatchCardProps {
|
interface GroupMatchCardProps {
|
||||||
match: Match;
|
match: Match;
|
||||||
showControls?: boolean;
|
showControls?: boolean;
|
||||||
|
nextUpMatchId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GroupMatchCard: React.FC<GroupMatchCardProps> = ({ match, showControls }) => {
|
const GroupMatchCard: React.FC<GroupMatchCardProps> = ({ match, showControls, nextUpMatchId }) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const editSheet = useSheet();
|
const editSheet = useSheet();
|
||||||
|
|
||||||
|
const isNextUp = nextUpMatchId !== undefined && match.id === nextUpMatchId;
|
||||||
const isReady = match.status === "ready";
|
const isReady = match.status === "ready";
|
||||||
const isStarted = match.status === "started";
|
const isStarted = match.status === "started";
|
||||||
const isEnded = match.status === "ended";
|
const isEnded = match.status === "ended";
|
||||||
@@ -73,7 +75,17 @@ const GroupMatchCard: React.FC<GroupMatchCardProps> = ({ match, showControls })
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Flex direction="row" align="stretch">
|
<Flex
|
||||||
|
direction="row"
|
||||||
|
align="stretch"
|
||||||
|
style={{
|
||||||
|
borderRadius: "var(--mantine-radius-md)",
|
||||||
|
boxShadow: isNextUp
|
||||||
|
? "0 0 0 1px var(--mantine-primary-color-filled), 0 0 12px var(--mantine-primary-color-light-hover)"
|
||||||
|
: undefined,
|
||||||
|
transition: "box-shadow 200ms cubic-bezier(0.32, 0.72, 0, 1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Indicator
|
<Indicator
|
||||||
inline
|
inline
|
||||||
processing={isStarted}
|
processing={isStarted}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutatio
|
|||||||
import { populateKnockoutBracket } from "@/features/matches/server";
|
import { populateKnockoutBracket } from "@/features/matches/server";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { tournamentKeys } from "../queries";
|
import { tournamentKeys } from "../queries";
|
||||||
|
import { computeGroupStageOrder } from "../utils/match-queue";
|
||||||
|
|
||||||
interface GroupStageViewProps {
|
interface GroupStageViewProps {
|
||||||
groups: Group[];
|
groups: Group[];
|
||||||
@@ -18,6 +19,7 @@ interface GroupStageViewProps {
|
|||||||
hasKnockoutBracket?: boolean;
|
hasKnockoutBracket?: boolean;
|
||||||
isRegional?: boolean;
|
isRegional?: boolean;
|
||||||
groupConfig?: GroupConfig;
|
groupConfig?: GroupConfig;
|
||||||
|
nextUpMatchId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TeamStanding {
|
interface TeamStanding {
|
||||||
@@ -39,6 +41,7 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
|||||||
hasKnockoutBracket,
|
hasKnockoutBracket,
|
||||||
isRegional,
|
isRegional,
|
||||||
groupConfig,
|
groupConfig,
|
||||||
|
nextUpMatchId,
|
||||||
}) => {
|
}) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [expandedTeams, setExpandedTeams] = useState<Record<string, boolean>>({});
|
const [expandedTeams, setExpandedTeams] = useState<Record<string, boolean>>({});
|
||||||
@@ -64,35 +67,22 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
|||||||
populateKnockoutMutation.mutate({ data: { tournamentId } });
|
populateKnockoutMutation.mutate({ data: { tournamentId } });
|
||||||
};
|
};
|
||||||
|
|
||||||
const orderMatchesWithSpacing = (matches: Match[]): Match[] => {
|
const globalOrderRank = useMemo(() => {
|
||||||
if (matches.length <= 1) return matches;
|
const groupMatches = matches.filter((match) => match.round === -1 && match.group);
|
||||||
|
const order = computeGroupStageOrder(
|
||||||
|
groupMatches.map((m) => ({
|
||||||
|
groupId: m.group ?? "",
|
||||||
|
teamA: m.home?.id ?? "",
|
||||||
|
teamB: m.away?.id ?? "",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
const ordered: Match[] = [];
|
const rank = new Map<string, number>();
|
||||||
const remaining = [...matches];
|
order.forEach((matchIdx, position) => {
|
||||||
|
rank.set(groupMatches[matchIdx].id, position);
|
||||||
ordered.push(remaining.shift()!);
|
|
||||||
|
|
||||||
while (remaining.length > 0) {
|
|
||||||
const lastMatch = ordered[ordered.length - 1];
|
|
||||||
const lastTeams = new Set([lastMatch.home?.id, lastMatch.away?.id].filter(Boolean));
|
|
||||||
|
|
||||||
let bestMatchIndex = remaining.findIndex((match) => {
|
|
||||||
const currentTeams = new Set([match.home?.id, match.away?.id].filter(Boolean));
|
|
||||||
for (const teamId of currentTeams) {
|
|
||||||
if (lastTeams.has(teamId)) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
});
|
||||||
|
return rank;
|
||||||
if (bestMatchIndex === -1) {
|
}, [matches]);
|
||||||
bestMatchIndex = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
ordered.push(remaining.splice(bestMatchIndex, 1)[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ordered;
|
|
||||||
};
|
|
||||||
|
|
||||||
const matchesByGroup = useMemo(() => {
|
const matchesByGroup = useMemo(() => {
|
||||||
const map = new Map<string, Match[]>();
|
const map = new Map<string, Match[]>();
|
||||||
@@ -107,11 +97,14 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
map.forEach((groupMatches, groupId) => {
|
map.forEach((groupMatches, groupId) => {
|
||||||
map.set(groupId, orderMatchesWithSpacing(groupMatches));
|
const sorted = [...groupMatches].sort(
|
||||||
|
(a, b) => (globalOrderRank.get(a.id) ?? 0) - (globalOrderRank.get(b.id) ?? 0),
|
||||||
|
);
|
||||||
|
map.set(groupId, sorted);
|
||||||
});
|
});
|
||||||
|
|
||||||
return map;
|
return map;
|
||||||
}, [matches]);
|
}, [matches, globalOrderRank]);
|
||||||
|
|
||||||
const sortedGroups = useMemo(() => {
|
const sortedGroups = useMemo(() => {
|
||||||
return [...groups].sort((a, b) => a.order - b.order);
|
return [...groups].sort((a, b) => a.order - b.order);
|
||||||
@@ -461,6 +454,7 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
|||||||
key={match.id}
|
key={match.id}
|
||||||
match={match}
|
match={match}
|
||||||
showControls={showControls}
|
showControls={showControls}
|
||||||
|
nextUpMatchId={nextUpMatchId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
.indicators.indicators {
|
||||||
|
position: static;
|
||||||
|
transform: none;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: var(--mantine-spacing-xs);
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator.indicator {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: var(--mantine-color-default-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator.indicator[data-active] {
|
||||||
|
width: 16px;
|
||||||
|
background-color: var(--mantine-primary-color-filled);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.indicator.indicator {
|
||||||
|
transition: background-color 150ms ease-out, width 150ms ease-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Group, Stack, ThemeIcon, Text, Flex } from "@mantine/core";
|
import { Group, Stack, ThemeIcon, Text } from "@mantine/core";
|
||||||
import { Tournament } from "../../types";
|
import { Tournament } from "../../types";
|
||||||
import { CalendarIcon, MapPinIcon, TrophyIcon } from "@phosphor-icons/react";
|
import { CalendarIcon, MapPinIcon, TrophyIcon } from "@phosphor-icons/react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
@@ -11,10 +11,9 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack px="sm" align="center" gap={0}>
|
<Group px="md" gap="md" wrap="nowrap" align="center">
|
||||||
<GlitchAvatar
|
<GlitchAvatar
|
||||||
name={tournament.name}
|
name={tournament.name}
|
||||||
contain
|
|
||||||
src={
|
src={
|
||||||
tournament.logo
|
tournament.logo
|
||||||
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||||
@@ -26,29 +25,27 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
radius="md"
|
radius="md"
|
||||||
size={250}
|
size={64}
|
||||||
px="xs"
|
|
||||||
withBorder={false}
|
|
||||||
>
|
>
|
||||||
<TrophyIcon size={32} />
|
<TrophyIcon size={24} />
|
||||||
</GlitchAvatar>
|
</GlitchAvatar>
|
||||||
<Flex gap="xs" direction="row" wrap="wrap" justify="space-around">
|
<Stack gap={6} style={{ minWidth: 0 }}>
|
||||||
{tournament.location && (
|
{tournament.location && (
|
||||||
<Group gap="xs">
|
<Group gap="xs" wrap="nowrap">
|
||||||
<ThemeIcon size="sm" variant="light" radius="sm">
|
<ThemeIcon size="sm" variant="light" radius="sm">
|
||||||
<MapPinIcon size={14} />
|
<MapPinIcon size={14} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||||
{tournament.location}
|
{tournament.location}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Group gap="xs">
|
<Group gap="xs" wrap="nowrap">
|
||||||
<ThemeIcon size="sm" variant="light" radius="sm">
|
<ThemeIcon size="sm" variant="light" radius="sm">
|
||||||
<CalendarIcon size={14} />
|
<CalendarIcon size={14} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||||
{tournamentStart.toLocaleDateString(undefined, {
|
{tournamentStart.toLocaleDateString(undefined, {
|
||||||
weekday: "short",
|
weekday: "short",
|
||||||
month: "short",
|
month: "short",
|
||||||
@@ -61,8 +58,8 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
|||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Flex>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</Group>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,71 +1,137 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Tournament } from "../../types";
|
import { Tournament } from "../../types";
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
import { Box, Divider, Stack, Text, Card, Center } from "@mantine/core";
|
import { Box, Card, Center, Divider, Stack, Text } from "@mantine/core";
|
||||||
import { Carousel } from "@mantine/carousel";
|
import { ClockIcon } from "@phosphor-icons/react";
|
||||||
import ListLink from "@/components/list-link";
|
import { isPredictionLocked, isTournamentPredictable } from "@/features/predictions/utils";
|
||||||
import { TreeStructureIcon, UsersIcon, ClockIcon, ListDashes } from "@phosphor-icons/react";
|
import { predictionQueries } from "@/features/predictions/queries";
|
||||||
import TeamListButton from "../upcoming-tournament/team-list-button";
|
import { useServerQuery } from "@/lib/tanstack-query/hooks";
|
||||||
import RulesListButton from "../upcoming-tournament/rules-list-button";
|
import { Match } from "@/features/matches/types";
|
||||||
import MatchCard from "@/features/matches/components/match-card";
|
|
||||||
import Header from "./header";
|
import Header from "./header";
|
||||||
import { Podium } from "../podium";
|
import { Podium } from "../podium";
|
||||||
|
import SectionHeading from "./sections/section-heading";
|
||||||
|
import MatchCarousel from "./sections/match-carousel";
|
||||||
|
import ScoreReportCta from "./sections/score-report-cta";
|
||||||
|
import RecentResults from "./sections/recent-results";
|
||||||
|
import NavGrid from "./sections/nav-grid";
|
||||||
|
|
||||||
|
const teamPlayerIds = (t: Match["home"]): string[] =>
|
||||||
|
t && typeof t !== "string" ? (t.players ?? []).map((p) => p.id) : [];
|
||||||
|
|
||||||
const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
||||||
tournament,
|
tournament,
|
||||||
}) => {
|
}) => {
|
||||||
const { roles } = useAuth();
|
const { user, roles } = useAuth();
|
||||||
|
|
||||||
const isAdmin = useMemo(() => roles.includes("Admin"), [roles]);
|
const isAdmin = useMemo(() => roles.includes("Admin"), [roles]);
|
||||||
|
|
||||||
const startedMatches = useMemo(() =>
|
const matches = useMemo(() => tournament.matches || [], [tournament.matches]);
|
||||||
tournament.matches?.filter(match => match.status === "started") || [],
|
|
||||||
[tournament.matches]
|
const liveMatches = useMemo(
|
||||||
|
() => matches.filter((m) => m.status === "started"),
|
||||||
|
[matches]
|
||||||
|
);
|
||||||
|
|
||||||
|
const upcomingMatches = useMemo(
|
||||||
|
() => matches.filter((m) => m.status === "ready" && m.home && m.away),
|
||||||
|
[matches]
|
||||||
|
);
|
||||||
|
|
||||||
|
const endedMatches = useMemo(
|
||||||
|
() =>
|
||||||
|
matches
|
||||||
|
.filter((m) => m.status === "ended")
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.end_time).getTime() - new Date(a.end_time).getTime()
|
||||||
|
),
|
||||||
|
[matches]
|
||||||
|
);
|
||||||
|
|
||||||
|
const myLiveMatches = useMemo(
|
||||||
|
() =>
|
||||||
|
liveMatches.filter(
|
||||||
|
(m) =>
|
||||||
|
!!user?.id &&
|
||||||
|
(teamPlayerIds(m.home).includes(user.id) ||
|
||||||
|
teamPlayerIds(m.away).includes(user.id))
|
||||||
|
),
|
||||||
|
[liveMatches, user?.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
const isTournamentOver = useMemo(() => {
|
const isTournamentOver = useMemo(() => {
|
||||||
const matches = tournament.matches || [];
|
|
||||||
if (matches.length === 0) return false;
|
if (matches.length === 0) return false;
|
||||||
|
|
||||||
const nonByeMatches = matches.filter((match) => !(match.status === 'tbd' && match.bye === true));
|
const nonByeMatches = matches.filter(
|
||||||
|
(match) => !(match.status === "tbd" && match.bye === true)
|
||||||
|
);
|
||||||
if (nonByeMatches.length === 0) return false;
|
if (nonByeMatches.length === 0) return false;
|
||||||
|
|
||||||
const finalsMatch = nonByeMatches.reduce((highest, current) =>
|
const finalsMatch = nonByeMatches.reduce((highest, current) =>
|
||||||
(!highest || current.lid > highest.lid) ? current : highest
|
!highest || current.lid > highest.lid ? current : highest
|
||||||
);
|
);
|
||||||
|
|
||||||
return finalsMatch?.status === 'ended';
|
return finalsMatch?.status === "ended";
|
||||||
}, [tournament.matches]);
|
}, [matches]);
|
||||||
|
|
||||||
const hasGroupStage = useMemo(() => {
|
const hasGroupStage = useMemo(
|
||||||
return tournament.matches?.some((match) => match.round === -1) || false;
|
() => matches.some((match) => match.round === -1),
|
||||||
}, [tournament.matches]);
|
[matches]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isPredictable = useMemo(
|
||||||
|
() => isTournamentPredictable(tournament),
|
||||||
|
[tournament]
|
||||||
|
);
|
||||||
|
|
||||||
|
const predictionsLocked = useMemo(
|
||||||
|
() => isPredictionLocked(matches),
|
||||||
|
[matches]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: myPrediction } = useServerQuery({
|
||||||
|
...predictionQueries.mine(tournament.id),
|
||||||
|
options: { enabled: isPredictable && !predictionsLocked },
|
||||||
|
});
|
||||||
|
const hasSubmitted = !!myPrediction?.prediction;
|
||||||
|
|
||||||
|
const hasNoMatchContent =
|
||||||
|
!isTournamentOver &&
|
||||||
|
liveMatches.length === 0 &&
|
||||||
|
upcomingMatches.length === 0 &&
|
||||||
|
endedMatches.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Header tournament={tournament} />
|
<Header tournament={tournament} />
|
||||||
|
|
||||||
{startedMatches.length > 0 ? (
|
<ScoreReportCta matches={myLiveMatches} />
|
||||||
|
|
||||||
|
{isTournamentOver && (
|
||||||
<Box>
|
<Box>
|
||||||
<Carousel
|
<SectionHeading label="Final Results" />
|
||||||
slideSize="95%"
|
|
||||||
slideGap="xs"
|
|
||||||
withControls={false}
|
|
||||||
>
|
|
||||||
{startedMatches.map((match, index) => (
|
|
||||||
<Carousel.Slide key={match.id}>
|
|
||||||
<Box pl={index === 0 ? "md" : undefined } pr={index === startedMatches.length - 1 ? "md" : undefined}>
|
|
||||||
<MatchCard match={match} />
|
|
||||||
</Box>
|
|
||||||
</Carousel.Slide>
|
|
||||||
))}
|
|
||||||
</Carousel>
|
|
||||||
</Box>
|
|
||||||
) : isTournamentOver ? (
|
|
||||||
<Box px="lg" w="100%">
|
|
||||||
<Podium tournament={tournament} />
|
<Podium tournament={tournament} />
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
)}
|
||||||
|
|
||||||
|
{liveMatches.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<SectionHeading label="Live Matches" live count={liveMatches.length} />
|
||||||
|
<MatchCarousel matches={liveMatches} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{upcomingMatches.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<SectionHeading label="Upcoming" count={upcomingMatches.length} />
|
||||||
|
{/* SIDE BETS SLOT — see docs/side-bets.md */}
|
||||||
|
<MatchCarousel matches={upcomingMatches} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<RecentResults matches={endedMatches} tournamentId={tournament.id} />
|
||||||
|
|
||||||
|
{hasNoMatchContent && (
|
||||||
<Card withBorder radius="lg" p="xl" mx="md">
|
<Card withBorder radius="lg" p="xl" mx="md">
|
||||||
<Center>
|
<Center>
|
||||||
<Stack align="center" gap="md">
|
<Stack align="center" gap="md">
|
||||||
@@ -79,28 +145,15 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Divider />
|
<Divider mb="sm" />
|
||||||
{isAdmin && (
|
<NavGrid
|
||||||
<ListLink
|
tournament={tournament}
|
||||||
label={`Manage ${tournament.name}`}
|
isAdmin={isAdmin}
|
||||||
to={`/admin/tournaments/${tournament.id}`}
|
hasGroupStage={hasGroupStage}
|
||||||
Icon={UsersIcon}
|
isPredictable={isPredictable}
|
||||||
|
predictionsLocked={predictionsLocked}
|
||||||
|
hasSubmitted={hasSubmitted}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
{hasGroupStage && (
|
|
||||||
<ListLink
|
|
||||||
label={`View Groups`}
|
|
||||||
to={`/tournaments/${tournament.id}/groups`}
|
|
||||||
Icon={ListDashes}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<ListLink
|
|
||||||
label={`View Bracket`}
|
|
||||||
to={`/tournaments/${tournament.id}/bracket`}
|
|
||||||
Icon={TreeStructureIcon}
|
|
||||||
/>
|
|
||||||
<TeamListButton teams={tournament.teams || []} isRegional={tournament.regional} />
|
|
||||||
<RulesListButton tournamentId={tournament.id} />
|
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Box } from "@mantine/core";
|
||||||
|
import { Carousel } from "@mantine/carousel";
|
||||||
|
import MatchCard from "@/features/matches/components/match-card";
|
||||||
|
import { Match } from "@/features/matches/types";
|
||||||
|
import carouselClasses from "../carousel.module.css";
|
||||||
|
|
||||||
|
interface MatchCarouselProps {
|
||||||
|
matches: Match[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MatchCarousel = ({ matches }: MatchCarouselProps) => {
|
||||||
|
if (matches.length === 1) {
|
||||||
|
return (
|
||||||
|
<Box px="md">
|
||||||
|
<MatchCard match={matches[0]} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Carousel
|
||||||
|
slideSize="90%"
|
||||||
|
slideGap="xs"
|
||||||
|
withControls={false}
|
||||||
|
withIndicators={matches.length > 1}
|
||||||
|
classNames={{
|
||||||
|
indicators: carouselClasses.indicators,
|
||||||
|
indicator: carouselClasses.indicator,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{matches.map((match, index) => (
|
||||||
|
<Carousel.Slide key={match.id}>
|
||||||
|
<Box
|
||||||
|
pl={index === 0 ? "md" : undefined}
|
||||||
|
pr={index === matches.length - 1 ? "md" : undefined}
|
||||||
|
>
|
||||||
|
<MatchCard match={match} />
|
||||||
|
</Box>
|
||||||
|
</Carousel.Slide>
|
||||||
|
))}
|
||||||
|
</Carousel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MatchCarousel;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
.tile {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px 8px;
|
||||||
|
border-radius: var(--mantine-radius-md);
|
||||||
|
border: 1px solid var(--mantine-color-default-border);
|
||||||
|
background-color: var(--mantine-color-body);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: var(--mantine-primary-color-filled);
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.tile:hover:not(:disabled) {
|
||||||
|
background-color: var(--mantine-color-default-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile:active:not(:disabled) {
|
||||||
|
background-color: var(--mantine-color-default-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.tile {
|
||||||
|
transition: background-color 120ms ease-out, transform 120ms cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile:active:not(:disabled) {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Flex,
|
||||||
|
Loader,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
UnstyledButton,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { Icon } from "@phosphor-icons/react";
|
||||||
|
import {
|
||||||
|
ListDashes,
|
||||||
|
ListIcon,
|
||||||
|
TreeStructureIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import Sheet from "@/components/sheet/sheet";
|
||||||
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
|
import TeamList from "@/features/teams/components/team-list";
|
||||||
|
import WizardOrbIcon from "@/components/wizard-orb-icon";
|
||||||
|
import { Tournament } from "../../../types";
|
||||||
|
import classes from "./nav-grid.module.css";
|
||||||
|
|
||||||
|
const RulesContent = lazy(
|
||||||
|
() => import("../../upcoming-tournament/rules-content")
|
||||||
|
);
|
||||||
|
|
||||||
|
interface NavTileProps {
|
||||||
|
label: string;
|
||||||
|
Icon: Icon;
|
||||||
|
onClick?: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single square-ish nav tile with icon-over-label and press feedback. */
|
||||||
|
export const NavTile = ({ label, Icon, onClick, disabled }: NavTileProps) => (
|
||||||
|
<UnstyledButton className={classes.tile} onClick={onClick} disabled={disabled}>
|
||||||
|
<span className={classes.icon}>
|
||||||
|
<Icon size={22} weight="bold" />
|
||||||
|
</span>
|
||||||
|
<Text size="xs" fw={600} lineClamp={2} lh={1.2}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface NavGridProps {
|
||||||
|
tournament: Tournament;
|
||||||
|
isAdmin: boolean;
|
||||||
|
hasGroupStage: boolean;
|
||||||
|
isPredictable: boolean;
|
||||||
|
predictionsLocked: boolean;
|
||||||
|
hasSubmitted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consolidates the old stack of full-width ListLinks into a compact icon grid
|
||||||
|
* so it no longer dominates the page. Every original destination stays
|
||||||
|
* reachable with identical conditional visibility. Teams and Rules open sheets;
|
||||||
|
* everything else navigates.
|
||||||
|
*/
|
||||||
|
const NavGrid = ({
|
||||||
|
tournament,
|
||||||
|
isAdmin,
|
||||||
|
hasGroupStage,
|
||||||
|
isPredictable,
|
||||||
|
predictionsLocked,
|
||||||
|
hasSubmitted,
|
||||||
|
}: NavGridProps) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const teamsSheet = useSheet();
|
||||||
|
const rulesSheet = useSheet();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box px="md">
|
||||||
|
<SimpleGrid cols={3} spacing="sm" verticalSpacing="sm">
|
||||||
|
{isAdmin && (
|
||||||
|
<NavTile
|
||||||
|
label="Manage"
|
||||||
|
Icon={UsersIcon}
|
||||||
|
onClick={() =>
|
||||||
|
navigate({ to: `/admin/tournaments/${tournament.id}` })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hasGroupStage && (
|
||||||
|
<NavTile
|
||||||
|
label="Groups"
|
||||||
|
Icon={ListDashes}
|
||||||
|
onClick={() =>
|
||||||
|
navigate({ to: `/tournaments/${tournament.id}/groups` })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<NavTile
|
||||||
|
label="Bracket"
|
||||||
|
Icon={TreeStructureIcon}
|
||||||
|
onClick={() =>
|
||||||
|
navigate({ to: `/tournaments/${tournament.id}/bracket` })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{isPredictable && !predictionsLocked && (
|
||||||
|
<NavTile
|
||||||
|
label={hasSubmitted ? "Edit Prediction" : "Make Prediction"}
|
||||||
|
Icon={WizardOrbIcon}
|
||||||
|
onClick={() =>
|
||||||
|
navigate({ to: `/tournaments/${tournament.id}/predictions/make` })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isPredictable && (predictionsLocked || hasSubmitted) && (
|
||||||
|
<NavTile
|
||||||
|
label="Predictions"
|
||||||
|
Icon={WizardOrbIcon}
|
||||||
|
onClick={() =>
|
||||||
|
navigate({ to: `/tournaments/${tournament.id}/predictions` })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<NavTile
|
||||||
|
label={`Teams${
|
||||||
|
tournament.teams ? ` (${tournament.teams.length})` : ""
|
||||||
|
}`}
|
||||||
|
Icon={UsersIcon}
|
||||||
|
onClick={teamsSheet.open}
|
||||||
|
/>
|
||||||
|
<NavTile label="Rules" Icon={ListIcon} onClick={rulesSheet.open} />
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Sheet title="Enrolled Teams" {...teamsSheet.props}>
|
||||||
|
<TeamList
|
||||||
|
teams={tournament.teams || []}
|
||||||
|
isRegional={tournament.regional}
|
||||||
|
/>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
<Sheet title="Tournament Rules" {...rulesSheet.props}>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Flex
|
||||||
|
justify="center"
|
||||||
|
align="center"
|
||||||
|
w="100%"
|
||||||
|
style={{ minHeight: "25vh" }}
|
||||||
|
>
|
||||||
|
<Loader size="lg" />
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<RulesContent content={tournament.rules || ""} />
|
||||||
|
</Suspense>
|
||||||
|
<Button variant="subtle" c="red" onClick={rulesSheet.close}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Sheet>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NavGrid;
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Anchor, Box, Group, Stack } from "@mantine/core";
|
||||||
|
import { CaretRightIcon } from "@phosphor-icons/react";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import MatchCard from "@/features/matches/components/match-card";
|
||||||
|
import { Match } from "@/features/matches/types";
|
||||||
|
import SectionHeading from "./section-heading";
|
||||||
|
|
||||||
|
interface RecentResultsProps {
|
||||||
|
/** Ended matches, already sorted most-recent-first. */
|
||||||
|
matches: Match[];
|
||||||
|
tournamentId: string;
|
||||||
|
/** How many to show inline before offering "View all results". */
|
||||||
|
max?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recently finished matches, newest on top, so people can look back and react
|
||||||
|
* (each MatchCard carries its own reaction bar). Caps the inline list and links
|
||||||
|
* to the full bracket when there are more results than fit.
|
||||||
|
*/
|
||||||
|
const RecentResults = ({ matches, tournamentId, max = 5 }: RecentResultsProps) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
if (matches.length === 0) return null;
|
||||||
|
|
||||||
|
const shown = matches.slice(0, max);
|
||||||
|
const hasMore = matches.length > max;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<SectionHeading
|
||||||
|
label="Recently finished"
|
||||||
|
action={
|
||||||
|
hasMore ? (
|
||||||
|
<Anchor
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
size="xs"
|
||||||
|
fw={600}
|
||||||
|
onClick={() =>
|
||||||
|
navigate({ to: `/tournaments/${tournamentId}/bracket` })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Group gap={2} align="center" wrap="nowrap">
|
||||||
|
View all results
|
||||||
|
<CaretRightIcon size={12} weight="bold" />
|
||||||
|
</Group>
|
||||||
|
</Anchor>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Stack gap="sm" px="md">
|
||||||
|
{shown.map((match) => (
|
||||||
|
<MatchCard key={match.id} match={match} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RecentResults;
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { Box, Group, Paper, Stack, Text } from "@mantine/core";
|
||||||
|
import { Match } from "@/features/matches/types";
|
||||||
|
import { MatchReport } from "@/features/bracket/components/match-report";
|
||||||
|
import TeamAvatar from "@/components/team-avatar";
|
||||||
|
import SectionHeading from "./section-heading";
|
||||||
|
|
||||||
|
interface ScoreReportCtaProps {
|
||||||
|
/** Started matches the current user is playing in. */
|
||||||
|
matches: Match[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamName = (t: Match["home"]): string =>
|
||||||
|
t && typeof t !== "string" ? t.name : "TBD";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-of-home call-to-action for a participant whose match is live. Composes
|
||||||
|
* the existing <MatchReport> so reporting/confirming a score is one tap from
|
||||||
|
* the home screen instead of buried in the bracket. Renders nothing when the
|
||||||
|
* user isn't playing in a started match.
|
||||||
|
*/
|
||||||
|
const ScoreReportCta = ({ matches }: ScoreReportCtaProps) => {
|
||||||
|
if (matches.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<SectionHeading
|
||||||
|
label="Your match is live"
|
||||||
|
live
|
||||||
|
count={matches.length}
|
||||||
|
/>
|
||||||
|
<Stack gap="sm" px="md">
|
||||||
|
{matches.map((match) => (
|
||||||
|
<Paper key={match.id} withBorder p="md">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<Group gap={8} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{match.home && typeof match.home !== "string" && (
|
||||||
|
<TeamAvatar
|
||||||
|
team={match.home}
|
||||||
|
size={28}
|
||||||
|
radius="sm"
|
||||||
|
isRegional={match.tournament.regional === true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Text size="sm" fw={600} lineClamp={1} style={{ minWidth: 0 }}>
|
||||||
|
{teamName(match.home)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed" fw={600} style={{ flexShrink: 0 }}>
|
||||||
|
vs
|
||||||
|
</Text>
|
||||||
|
<Group
|
||||||
|
gap={8}
|
||||||
|
wrap="nowrap"
|
||||||
|
justify="flex-end"
|
||||||
|
style={{ flex: 1, minWidth: 0 }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
fw={600}
|
||||||
|
lineClamp={1}
|
||||||
|
ta="right"
|
||||||
|
style={{ minWidth: 0 }}
|
||||||
|
>
|
||||||
|
{teamName(match.away)}
|
||||||
|
</Text>
|
||||||
|
{match.away && typeof match.away !== "string" && (
|
||||||
|
<TeamAvatar
|
||||||
|
team={match.away}
|
||||||
|
size={28}
|
||||||
|
radius="sm"
|
||||||
|
isRegional={match.tournament.regional === true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
<MatchReport match={match} />
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ScoreReportCta;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Group, Indicator, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
interface SectionHeadingProps {
|
||||||
|
label: string;
|
||||||
|
/** Show a pulsing red "live" dot before the label. */
|
||||||
|
live?: boolean;
|
||||||
|
/** Appended as "· N" after the label when greater than 1. */
|
||||||
|
count?: number;
|
||||||
|
/** Optional right-aligned action (e.g. a "View all" link). */
|
||||||
|
action?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact, uppercase section label used across the started-tournament home
|
||||||
|
* surfaces. Mirrors the original "Live Matches" heading treatment.
|
||||||
|
*/
|
||||||
|
const SectionHeading = ({ label, live, count, action }: SectionHeadingProps) => {
|
||||||
|
return (
|
||||||
|
<Group gap={10} px="md" mb={6} align="center" wrap="nowrap" justify="space-between">
|
||||||
|
<Group gap={10} align="center" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
{live && (
|
||||||
|
<Indicator
|
||||||
|
size={8}
|
||||||
|
color="red"
|
||||||
|
processing
|
||||||
|
position="middle-start"
|
||||||
|
offset={0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Text size="xs" fw={700} c="dimmed" tt="uppercase" lts="0.05em" lineClamp={1}>
|
||||||
|
{label}
|
||||||
|
{count && count > 1 ? ` · ${count}` : ""}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
{action}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SectionHeading;
|
||||||
@@ -1,51 +1,27 @@
|
|||||||
import { Box, Card, Center, Divider, Group, Skeleton, Stack } from "@mantine/core";
|
import { Box, Card, Divider, Group, SimpleGrid, Skeleton, Stack } from "@mantine/core";
|
||||||
|
|
||||||
const StartedTournamentSkeleton = () => {
|
const MatchCardSkeleton = ({ minWidth }: { minWidth: string }) => (
|
||||||
return (
|
|
||||||
<Stack gap="lg">
|
|
||||||
{/* Header skeleton */}
|
|
||||||
<Stack px="md">
|
|
||||||
<Group justify="space-between" align="flex-start">
|
|
||||||
<Box style={{ flex: 1 }}>
|
|
||||||
<Skeleton height={32} width="60%" mb="xs" />
|
|
||||||
<Skeleton height={16} width="40%" />
|
|
||||||
</Box>
|
|
||||||
<Skeleton height={60} width={60} radius="md" />
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* Match carousel skeleton */}
|
|
||||||
<Box>
|
|
||||||
<Group gap="xs" px="xl">
|
|
||||||
{Array.from({ length: 2 }).map((_, index) => (
|
|
||||||
<Card
|
<Card
|
||||||
key={index}
|
|
||||||
withBorder
|
withBorder
|
||||||
radius="lg"
|
radius="md"
|
||||||
p="lg"
|
p="md"
|
||||||
style={{ minWidth: "95%", flex: "0 0 auto" }}
|
style={{ minWidth, flex: "0 0 auto" }}
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{/* Match header */}
|
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Skeleton height={14} width="30%" />
|
<Skeleton height={14} width="30%" />
|
||||||
<Skeleton height={20} width={60} radius="xl" />
|
<Skeleton height={20} width={40} radius="xl" />
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Teams */}
|
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Group>
|
<Group>
|
||||||
<Skeleton height={32} width={32} radius="sm" />
|
<Skeleton height={40} width={40} radius="sm" />
|
||||||
<Skeleton height={16} width="40%" />
|
<Skeleton height={16} width="40%" />
|
||||||
<Box ml="auto">
|
<Box ml="auto">
|
||||||
<Skeleton height={24} width={30} />
|
<Skeleton height={24} width={30} />
|
||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
<Center>
|
|
||||||
<Skeleton height={14} width={20} />
|
|
||||||
</Center>
|
|
||||||
<Group>
|
<Group>
|
||||||
<Skeleton height={32} width={32} radius="sm" />
|
<Skeleton height={40} width={40} radius="sm" />
|
||||||
<Skeleton height={16} width="40%" />
|
<Skeleton height={16} width="40%" />
|
||||||
<Box ml="auto">
|
<Box ml="auto">
|
||||||
<Skeleton height={24} width={30} />
|
<Skeleton height={24} width={30} />
|
||||||
@@ -54,20 +30,47 @@ const StartedTournamentSkeleton = () => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
);
|
||||||
|
|
||||||
|
const StartedTournamentSkeleton = () => {
|
||||||
|
return (
|
||||||
|
<Stack gap="lg">
|
||||||
|
{/* Compact header skeleton */}
|
||||||
|
<Group px="md" gap="md" wrap="nowrap" align="center">
|
||||||
|
<Skeleton height={64} width={64} radius="md" />
|
||||||
|
<Stack gap={8} style={{ flex: 1 }}>
|
||||||
|
<Skeleton height={14} width="55%" />
|
||||||
|
<Skeleton height={14} width="40%" />
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Live carousel skeleton */}
|
||||||
|
<Box>
|
||||||
|
<Skeleton height={12} width={90} ml="md" mb={10} />
|
||||||
|
<Group gap="xs" pl="md" wrap="nowrap" style={{ overflow: "hidden" }}>
|
||||||
|
<MatchCardSkeleton minWidth="88%" />
|
||||||
|
<MatchCardSkeleton minWidth="88%" />
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Actions section skeleton */}
|
{/* Recently finished skeleton */}
|
||||||
<Box>
|
<Box>
|
||||||
<Divider />
|
<Skeleton height={12} width={120} ml="md" mb={10} />
|
||||||
<Stack gap={0}>
|
<Stack gap="sm" px="md">
|
||||||
<Skeleton height={48} width="100%" />
|
<MatchCardSkeleton minWidth="100%" />
|
||||||
<Skeleton height={48} width="100%" />
|
<MatchCardSkeleton minWidth="100%" />
|
||||||
<Skeleton height={48} width="100%" />
|
|
||||||
<Skeleton height={48} width="100%" />
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Nav grid skeleton */}
|
||||||
|
<Box px="md">
|
||||||
|
<Divider mb="sm" />
|
||||||
|
<SimpleGrid cols={3} spacing="sm" verticalSpacing="sm">
|
||||||
|
{Array.from({ length: 6 }).map((_, index) => (
|
||||||
|
<Skeleton key={index} height={72} radius="md" />
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { Tournament } from "@/features/tournaments/types";
|
import { Tournament } from "@/features/tournaments/types";
|
||||||
import { CrownIcon, TreeStructureIcon, InfoIcon, ListDashes } from "@phosphor-icons/react";
|
import { CrownIcon, TreeStructureIcon, InfoIcon, ListDashes } from "@phosphor-icons/react";
|
||||||
|
import WizardOrbIcon from "@/components/wizard-orb-icon";
|
||||||
import TeamAvatar from "@/components/team-avatar";
|
import TeamAvatar from "@/components/team-avatar";
|
||||||
import ListLink from "@/components/list-link";
|
import ListLink from "@/components/list-link";
|
||||||
|
import { isTournamentPredictable } from "@/features/predictions/utils";
|
||||||
import { Podium } from "./podium";
|
import { Podium } from "./podium";
|
||||||
|
|
||||||
interface TournamentStatsProps {
|
interface TournamentStatsProps {
|
||||||
@@ -173,6 +175,7 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{!tournament.regional && <Podium tournament={tournament} />}
|
{!tournament.regional && <Podium tournament={tournament} />}
|
||||||
|
<Stack gap={0}>
|
||||||
{hasGroupStage && (
|
{hasGroupStage && (
|
||||||
<ListLink
|
<ListLink
|
||||||
label={`View Groups`}
|
label={`View Groups`}
|
||||||
@@ -185,6 +188,14 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
|
|||||||
to={`/tournaments/${tournament.id}/bracket`}
|
to={`/tournaments/${tournament.id}/bracket`}
|
||||||
Icon={TreeStructureIcon}
|
Icon={TreeStructureIcon}
|
||||||
/>
|
/>
|
||||||
|
{isTournamentPredictable(tournament) && (
|
||||||
|
<ListLink
|
||||||
|
label={`View Predictions`}
|
||||||
|
to={`/tournaments/${tournament.id}/predictions`}
|
||||||
|
Icon={WizardOrbIcon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
{renderTeamStatsTable()}
|
{renderTeamStatsTable()}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
+2
-6
@@ -1,4 +1,3 @@
|
|||||||
import ListButton from "@/components/list-button";
|
|
||||||
import Sheet from "@/components/sheet/sheet";
|
import Sheet from "@/components/sheet/sheet";
|
||||||
import { useSheet } from "@/hooks/use-sheet";
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
import { UserListIcon } from "@phosphor-icons/react";
|
import { UserListIcon } from "@phosphor-icons/react";
|
||||||
@@ -7,6 +6,7 @@ import { useFreeAgents } from "../../queries";
|
|||||||
import { Text } from "@mantine/core";
|
import { Text } from "@mantine/core";
|
||||||
import PlayerList from "@/features/players/components/player-list";
|
import PlayerList from "@/features/players/components/player-list";
|
||||||
import { Player } from "@/features/players/types";
|
import { Player } from "@/features/players/types";
|
||||||
|
import { NavTile } from "../started-tournament/sections/nav-grid";
|
||||||
|
|
||||||
interface EnrolledPlayersListButtonProps {
|
interface EnrolledPlayersListButtonProps {
|
||||||
tournamentId: string;
|
tournamentId: string;
|
||||||
@@ -24,11 +24,7 @@ const EnrolledPlayersListButton: React.FC<EnrolledPlayersListButtonProps> = ({ t
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ListButton
|
<NavTile label={`Players (${count})`} Icon={UserListIcon} onClick={open} />
|
||||||
label={`View Enrolled Players (${count})`}
|
|
||||||
Icon={UserListIcon}
|
|
||||||
onClick={open}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Sheet title="Enrolled Players" opened={isOpen} onChange={toggle}>
|
<Sheet title="Enrolled Players" opened={isOpen} onChange={toggle}>
|
||||||
{count === 0 ? (
|
{count === 0 ? (
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import { Group, Stack, ThemeIcon, Text, Flex } from "@mantine/core";
|
|
||||||
import { Tournament } from "../../types";
|
|
||||||
import GlitchAvatar from "@/components/glitch-avatar";
|
|
||||||
import {
|
|
||||||
CalendarIcon,
|
|
||||||
MapPinIcon,
|
|
||||||
TrophyIcon,
|
|
||||||
UsersIcon,
|
|
||||||
} from "@phosphor-icons/react";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
const Header = ({ tournament }: { tournament: Tournament }) => {
|
|
||||||
const tournamentStart = useMemo(
|
|
||||||
() => new Date(tournament.start_time),
|
|
||||||
[tournament.start_time]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack align="center" gap={16}>
|
|
||||||
<GlitchAvatar
|
|
||||||
name={tournament.name}
|
|
||||||
contain
|
|
||||||
src={
|
|
||||||
tournament.logo
|
|
||||||
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
glitchSrc={
|
|
||||||
tournament.glitch_logo
|
|
||||||
? `/api/files/tournaments/${tournament.id}/${tournament.glitch_logo}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
radius="md"
|
|
||||||
size={300}
|
|
||||||
px="xs"
|
|
||||||
withBorder={false}
|
|
||||||
>
|
|
||||||
<TrophyIcon size={32} />
|
|
||||||
</GlitchAvatar>
|
|
||||||
<Flex gap="xs" direction="column" justify="space-around" align="center">
|
|
||||||
{tournament.location && (
|
|
||||||
<Group gap="xs">
|
|
||||||
<ThemeIcon size="sm" variant="light" radius="sm">
|
|
||||||
<MapPinIcon size={14} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{tournament.location}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Group gap="xs">
|
|
||||||
<ThemeIcon size="sm" variant="light" radius="sm">
|
|
||||||
<CalendarIcon size={14} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{tournamentStart.toLocaleDateString(undefined, {
|
|
||||||
weekday: "short",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
})}{" "}
|
|
||||||
at{" "}
|
|
||||||
{tournamentStart.toLocaleTimeString([], {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Flex>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Header;
|
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
import { Suspense, useMemo } from "react";
|
import { Suspense, useMemo } from "react";
|
||||||
import { Tournament } from "../../types";
|
import { Tournament } from "../../types";
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
import { Box, Card, Divider, Group, Stack, Text, Title } from "@mantine/core";
|
import { Box, Divider, Paper, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||||
import Countdown from "@/components/countdown";
|
import Countdown from "@/components/countdown";
|
||||||
import ListLink from "@/components/list-link";
|
|
||||||
import { TreeStructureIcon, UsersIcon } from "@phosphor-icons/react";
|
import { TreeStructureIcon, UsersIcon } from "@phosphor-icons/react";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import EnrollTeam from "./enroll-team";
|
import EnrollTeam from "./enroll-team";
|
||||||
import EnrollFreeAgent from "./enroll-free-agent";
|
import EnrollFreeAgent from "./enroll-free-agent";
|
||||||
import TeamListButton from "./team-list-button";
|
import TeamListButton from "./team-list-button";
|
||||||
import EnrolledPlayersListButton from "./enrolled-players-list-button";
|
import EnrolledPlayersListButton from "./enrolled-players-list-button";
|
||||||
import Header from "./header";
|
import Header from "../started-tournament/header";
|
||||||
|
import SectionHeading from "../started-tournament/sections/section-heading";
|
||||||
|
import { NavTile } from "../started-tournament/sections/nav-grid";
|
||||||
import TeamCardSkeleton from "@/features/teams/components/team-card-skeleton";
|
import TeamCardSkeleton from "@/features/teams/components/team-card-skeleton";
|
||||||
import TeamCard from "@/features/teams/components/team-card";
|
import TeamCard from "@/features/teams/components/team-card";
|
||||||
import RegionalTeamCard from "@/features/teams/components/regional-team-card";
|
import RegionalTeamCard from "@/features/teams/components/regional-team-card";
|
||||||
@@ -24,6 +26,7 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
|
|||||||
tournament,
|
tournament,
|
||||||
}) => {
|
}) => {
|
||||||
const { user, roles } = useAuth();
|
const { user, roles } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const isAdmin = useMemo(() => roles.includes("Admin"), [roles]);
|
const isAdmin = useMemo(() => roles.includes("Admin"), [roles]);
|
||||||
const userTeam = useMemo(
|
const userTeam = useMemo(
|
||||||
@@ -52,36 +55,24 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
|
|||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Header tournament={tournament} />
|
<Header tournament={tournament} />
|
||||||
|
|
||||||
<Stack px="xs">
|
{tournament.desc && (
|
||||||
{tournament.desc && <Text px="md" ta="center" size="sm" style={{ whiteSpace: 'pre-wrap' }}>{tournament.desc}</Text>}
|
<Text px="md" size="sm" c="dimmed" style={{ whiteSpace: "pre-wrap" }}>
|
||||||
|
{tournament.desc}
|
||||||
<Card
|
</Text>
|
||||||
withBorder
|
|
||||||
radius="lg"
|
|
||||||
p="lg"
|
|
||||||
style={{
|
|
||||||
borderTop: "3px solid var(--mantine-primary-color-filled)",
|
|
||||||
backgroundImage:
|
|
||||||
"linear-gradient(to bottom, var(--mantine-primary-color-light), transparent 110px)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Group gap="xs" align="center">
|
|
||||||
<UsersIcon size={16} />
|
|
||||||
<Title mt={4} order={5}>
|
|
||||||
Enrollment
|
|
||||||
</Title>
|
|
||||||
{isEnrollmentOpen && (
|
|
||||||
<Box ml="auto">
|
|
||||||
<Countdown
|
|
||||||
date={enrollmentDeadline}
|
|
||||||
label="Time left"
|
|
||||||
color="yellow"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)}
|
)}
|
||||||
</Group>
|
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<SectionHeading
|
||||||
|
label="Enrollment"
|
||||||
|
action={
|
||||||
|
isEnrollmentOpen ? (
|
||||||
|
<Countdown date={enrollmentDeadline} />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Box px="md">
|
||||||
|
<Paper withBorder p="md">
|
||||||
|
<Stack gap="sm">
|
||||||
{!isUserEnrolled && !isEnrollmentOpen && (
|
{!isUserEnrolled && !isEnrollmentOpen && (
|
||||||
<Text fw={600} c="dimmed" size="sm">
|
<Text fw={600} c="dimmed" size="sm">
|
||||||
Enrollment has been closed for this tournament.
|
Enrollment has been closed for this tournament.
|
||||||
@@ -130,34 +121,31 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{
|
{isFreeAgent && isEnrollmentOpen && (
|
||||||
isFreeAgent && isEnrollmentOpen && (
|
|
||||||
<EnrolledFreeAgent
|
<EnrolledFreeAgent
|
||||||
tournamentId={tournament.id}
|
tournamentId={tournament.id}
|
||||||
isRegional={tournament.regional}
|
isRegional={tournament.regional}
|
||||||
/>
|
/>
|
||||||
)
|
)}
|
||||||
}
|
|
||||||
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Divider />
|
<Divider mb="sm" />
|
||||||
|
<Box px="md">
|
||||||
|
<SimpleGrid cols={3} spacing="sm" verticalSpacing="sm">
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<ListLink
|
<NavTile
|
||||||
label={`Manage ${tournament.name}`}
|
label="Manage"
|
||||||
to={`/admin/tournaments/${tournament.id}`}
|
|
||||||
Icon={UsersIcon}
|
Icon={UsersIcon}
|
||||||
|
onClick={() =>
|
||||||
|
navigate({ to: `/admin/tournaments/${tournament.id}` })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ListLink
|
<NavTile label="Bracket" Icon={TreeStructureIcon} disabled />
|
||||||
label={`View Bracket`}
|
|
||||||
to={`/tournaments/${tournament.id}/bracket`}
|
|
||||||
Icon={TreeStructureIcon}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
{tournament.regional === true ? (
|
{tournament.regional === true ? (
|
||||||
(tournament.teams && tournament.teams.length > 0) ? (
|
(tournament.teams && tournament.teams.length > 0) ? (
|
||||||
<TeamListButton teams={tournament.teams} isRegional={true} />
|
<TeamListButton teams={tournament.teams} isRegional={true} />
|
||||||
@@ -168,6 +156,8 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
|
|||||||
<TeamListButton teams={tournament.teams || []} isRegional={false} />
|
<TeamListButton teams={tournament.teams || []} isRegional={false} />
|
||||||
)}
|
)}
|
||||||
<RulesListButton tournamentId={tournament.id} />
|
<RulesListButton tournamentId={tournament.id} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import ListButton from "@/components/list-button"
|
|
||||||
import Sheet from "@/components/sheet/sheet"
|
import Sheet from "@/components/sheet/sheet"
|
||||||
import { useSheet } from "@/hooks/use-sheet"
|
import { useSheet } from "@/hooks/use-sheet"
|
||||||
import { ListIcon } from "@phosphor-icons/react"
|
import { ListIcon } from "@phosphor-icons/react"
|
||||||
import { useTournament } from "../../queries"
|
import { useTournament } from "../../queries"
|
||||||
import { Button, Flex, Loader, Stack } from "@mantine/core"
|
import { Button, Flex, Loader, Stack } from "@mantine/core"
|
||||||
import { lazy, Suspense } from "react"
|
import { lazy, Suspense } from "react"
|
||||||
|
import { NavTile } from "../started-tournament/sections/nav-grid"
|
||||||
|
|
||||||
const RulesContent = lazy(() => import("./rules-content"));
|
const RulesContent = lazy(() => import("./rules-content"));
|
||||||
|
|
||||||
@@ -18,11 +18,7 @@ const RulesListButton: React.FC<RulesListButtonProps> = ({ tournamentId }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ListButton
|
<NavTile label="Rules" Icon={ListIcon} onClick={open} />
|
||||||
label={`View Rules`}
|
|
||||||
Icon={ListIcon}
|
|
||||||
onClick={open}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Sheet title="Tournament Rules" opened={isOpen} onChange={toggle}>
|
<Sheet title="Tournament Rules" opened={isOpen} onChange={toggle}>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
|
|||||||
@@ -1,35 +1,36 @@
|
|||||||
import { Box, Card, Divider, Flex, Group, Skeleton, Stack } from "@mantine/core";
|
import { Box, Divider, Group, Paper, SimpleGrid, Skeleton, Stack } from "@mantine/core";
|
||||||
|
|
||||||
const UpcomingTournamentSkeleton = () => {
|
const UpcomingTournamentSkeleton = () => {
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Flex px="md" justify="center" w="100%">
|
<Group px="md" gap="md" wrap="nowrap" align="center">
|
||||||
<Skeleton height={200} width={240} radius="md" />
|
<Skeleton height={64} width={64} radius="md" />
|
||||||
</Flex>
|
<Stack gap={8} style={{ flex: 1 }}>
|
||||||
<Stack align="center" gap={2}>
|
<Skeleton height={14} width="55%" />
|
||||||
<Skeleton height={16} w="30%" mb="md" />
|
<Skeleton height={14} width="40%" />
|
||||||
<Skeleton height={16} w="30%" />
|
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
|
||||||
<Stack px="md">
|
<Box>
|
||||||
<Card withBorder radius="lg" p="lg">
|
<Skeleton height={12} width={90} ml="md" mb={10} />
|
||||||
<Skeleton height={14} width="80%" mb={16} />
|
<Box px="md">
|
||||||
<Group mb="sm" gap="xs" align="center">
|
<Paper withBorder p="md">
|
||||||
<Skeleton height={32} width={16} />
|
<Stack gap="sm">
|
||||||
<Skeleton height={32} width="20%" />
|
<Skeleton height={36} radius="sm" />
|
||||||
<Box ml="auto">
|
<Skeleton height={36} radius="sm" />
|
||||||
<Skeleton height={32} width={80} radius="sm" />
|
|
||||||
</Box>
|
|
||||||
</Group>
|
|
||||||
<Group mb="sm" gap="xs" align="center">
|
|
||||||
<Skeleton height={32} width={16} />
|
|
||||||
<Skeleton height={32} width="20%" />
|
|
||||||
<Box ml="auto">
|
|
||||||
<Skeleton height={32} width={80} radius="sm" />
|
|
||||||
</Box>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box px="md">
|
||||||
|
<Divider mb="sm" />
|
||||||
|
<SimpleGrid cols={3} spacing="sm" verticalSpacing="sm">
|
||||||
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
<Skeleton key={index} height={72} radius="md" />
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import ListButton from "@/components/list-button"
|
|
||||||
import Sheet from "@/components/sheet/sheet"
|
import Sheet from "@/components/sheet/sheet"
|
||||||
import TeamList from "@/features/teams/components/team-list"
|
import TeamList from "@/features/teams/components/team-list"
|
||||||
import { TeamInfo } from "@/features/teams/types"
|
import { TeamInfo } from "@/features/teams/types"
|
||||||
import { useSheet } from "@/hooks/use-sheet"
|
import { useSheet } from "@/hooks/use-sheet"
|
||||||
import { UsersIcon } from "@phosphor-icons/react"
|
import { UsersIcon } from "@phosphor-icons/react"
|
||||||
import { useMemo } from "react"
|
import { useMemo } from "react"
|
||||||
|
import { NavTile } from "../started-tournament/sections/nav-grid"
|
||||||
|
|
||||||
interface TeamListButtonProps {
|
interface TeamListButtonProps {
|
||||||
teams: TeamInfo[]
|
teams: TeamInfo[]
|
||||||
@@ -16,11 +16,7 @@ const TeamListButton: React.FC<TeamListButtonProps> = ({ teams, isRegional }) =>
|
|||||||
const { open, isOpen, toggle } = useSheet();
|
const { open, isOpen, toggle } = useSheet();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ListButton
|
<NavTile label={`Teams (${count})`} Icon={UsersIcon} onClick={open} />
|
||||||
label={`View Teams (${count})`}
|
|
||||||
Icon={UsersIcon}
|
|
||||||
onClick={open}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Sheet title="Enrolled Teams" opened={isOpen} onChange={toggle}>
|
<Sheet title="Enrolled Teams" opened={isOpen} onChange={toggle}>
|
||||||
<TeamList teams={teams} isRegional={isRegional} />
|
<TeamList teams={teams} isRegional={isRegional} />
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import { logger } from ".";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||||
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||||
|
import { emitServerEvent } from "@/lib/events/emitter";
|
||||||
import brackets from "@/features/bracket/utils";
|
import brackets from "@/features/bracket/utils";
|
||||||
import { MatchInput } from "@/features/matches/types";
|
import { MatchInput } from "@/features/matches/types";
|
||||||
import { generateSingleEliminationBracket } from "./utils/bracket-generator";
|
import { generateSingleEliminationBracket } from "./utils/bracket-generator";
|
||||||
|
import { orderGroupStageMatches } from "./utils/match-queue";
|
||||||
|
|
||||||
export const listTournaments = createServerFn()
|
export const listTournaments = createServerFn()
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
@@ -20,7 +22,11 @@ export const createTournament = createServerFn()
|
|||||||
.validator(tournamentInputSchema)
|
.validator(tournamentInputSchema)
|
||||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult(() => pbAdmin.createTournament(data))
|
toServerResult(async () => {
|
||||||
|
const tournament = await pbAdmin.createTournament(data);
|
||||||
|
emitServerEvent({ type: "tournament", tournamentId: tournament.id });
|
||||||
|
return tournament;
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
export const updateTournament = createServerFn()
|
export const updateTournament = createServerFn()
|
||||||
@@ -30,7 +36,11 @@ export const updateTournament = createServerFn()
|
|||||||
}))
|
}))
|
||||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult(() => pbAdmin.updateTournament(data.id, data.updates))
|
toServerResult(async () => {
|
||||||
|
const tournament = await pbAdmin.updateTournament(data.id, data.updates);
|
||||||
|
emitServerEvent({ type: "tournament", tournamentId: data.id });
|
||||||
|
return tournament;
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getTournament = createServerFn()
|
export const getTournament = createServerFn()
|
||||||
@@ -60,12 +70,6 @@ export const enrollTeam = createServerFn()
|
|||||||
const team = await pbAdmin.getTeam(teamId);
|
const team = await pbAdmin.getTeam(teamId);
|
||||||
if (!team) { throw new Error('Team not found'); }
|
if (!team) { throw new Error('Team not found'); }
|
||||||
|
|
||||||
//const isPlayerOnTeam = team.players?.some(player => player.id === userId);
|
|
||||||
|
|
||||||
//if (!isPlayerOnTeam && !isAdmin) {
|
|
||||||
// throw new Error('You do not have permission to enroll this team');
|
|
||||||
//}
|
|
||||||
|
|
||||||
const freeAgents = await pbAdmin.getFreeAgents(tournamentId);
|
const freeAgents = await pbAdmin.getFreeAgents(tournamentId);
|
||||||
for (const player of team.players || []) {
|
for (const player of team.players || []) {
|
||||||
const isFreeAgent = freeAgents.some(fa => fa.player?.id === player.id);
|
const isFreeAgent = freeAgents.some(fa => fa.player?.id === player.id);
|
||||||
@@ -76,6 +80,7 @@ export const enrollTeam = createServerFn()
|
|||||||
|
|
||||||
logger.info('Enrolling team in tournament', { tournamentId, teamId, userId });
|
logger.info('Enrolling team in tournament', { tournamentId, teamId, userId });
|
||||||
const tournament = await pbAdmin.enrollTeam(tournamentId, teamId);
|
const tournament = await pbAdmin.enrollTeam(tournamentId, teamId);
|
||||||
|
emitServerEvent({ type: "tournament", tournamentId });
|
||||||
return tournament;
|
return tournament;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -87,7 +92,11 @@ export const unenrollTeam = createServerFn()
|
|||||||
}))
|
}))
|
||||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
.handler(async ({ data: { tournamentId, teamId }, context }) =>
|
.handler(async ({ data: { tournamentId, teamId }, context }) =>
|
||||||
toServerResult(() => pbAdmin.unenrollTeam(tournamentId, teamId))
|
toServerResult(async () => {
|
||||||
|
const result = await pbAdmin.unenrollTeam(tournamentId, teamId);
|
||||||
|
emitServerEvent({ type: "tournament", tournamentId });
|
||||||
|
return result;
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getUnenrolledTeams = createServerFn()
|
export const getUnenrolledTeams = createServerFn()
|
||||||
@@ -115,6 +124,7 @@ export const enrollFreeAgent = createServerFn()
|
|||||||
|
|
||||||
await pbAdmin.enrollFreeAgent(player.id, data.phone, data.tournamentId);
|
await pbAdmin.enrollFreeAgent(player.id, data.phone, data.tournamentId);
|
||||||
logger.info('Player enrolled as free agent', { playerId: player.id, phone: data.phone });
|
logger.info('Player enrolled as free agent', { playerId: player.id, phone: data.phone });
|
||||||
|
emitServerEvent({ type: "tournament", tournamentId: data.tournamentId });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -129,6 +139,7 @@ export const unenrollFreeAgent = createServerFn()
|
|||||||
|
|
||||||
await pbAdmin.unenrollFreeAgent(player.id, data.tournamentId);
|
await pbAdmin.unenrollFreeAgent(player.id, data.tournamentId);
|
||||||
logger.info('Player unenrolled as free agent', { playerId: player.id });
|
logger.info('Player unenrolled as free agent', { playerId: player.id });
|
||||||
|
emitServerEvent({ type: "tournament", tournamentId: data.tournamentId });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -382,6 +393,9 @@ export const confirmTeamAssignments = createServerFn()
|
|||||||
newCount: createdTeams.length - reusedCount
|
newCount: createdTeams.length - reusedCount
|
||||||
});
|
});
|
||||||
|
|
||||||
|
emitServerEvent({ type: "team" });
|
||||||
|
emitServerEvent({ type: "tournament", tournamentId: data.tournamentId });
|
||||||
|
|
||||||
return { teams: createdTeams };
|
return { teams: createdTeams };
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -702,6 +716,8 @@ export const generateKnockoutBracket = createServerFn()
|
|||||||
qualifiedTeamCount: qualifiedTeams.length
|
qualifiedTeamCount: qualifiedTeams.length
|
||||||
});
|
});
|
||||||
|
|
||||||
|
emitServerEvent({ type: "tournament", tournamentId: data.tournamentId });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tournament,
|
tournament,
|
||||||
matchCount: createdMatches.length,
|
matchCount: createdMatches.length,
|
||||||
@@ -720,6 +736,7 @@ export const adminEnrollPlayer = createServerFn()
|
|||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
await pbAdmin.enrollFreeAgent(data.playerId, "", data.tournamentId);
|
await pbAdmin.enrollFreeAgent(data.playerId, "", data.tournamentId);
|
||||||
logger.info('Admin enrolled player', { playerId: data.playerId, tournamentId: data.tournamentId });
|
logger.info('Admin enrolled player', { playerId: data.playerId, tournamentId: data.tournamentId });
|
||||||
|
emitServerEvent({ type: "tournament", tournamentId: data.tournamentId });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -733,6 +750,7 @@ export const adminUnenrollPlayer = createServerFn()
|
|||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
await pbAdmin.unenrollFreeAgent(data.playerId, data.tournamentId);
|
await pbAdmin.unenrollFreeAgent(data.playerId, data.tournamentId);
|
||||||
logger.info('Admin unenrolled player', { playerId: data.playerId, tournamentId: data.tournamentId });
|
logger.info('Admin unenrolled player', { playerId: data.playerId, tournamentId: data.tournamentId });
|
||||||
|
emitServerEvent({ type: "tournament", tournamentId: data.tournamentId });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -793,7 +811,8 @@ export const generateGroupStage = createServerFn()
|
|||||||
groupStageMatches.push({
|
groupStageMatches.push({
|
||||||
lid: -1,
|
lid: -1,
|
||||||
round: -1,
|
round: -1,
|
||||||
order: groupStageMatches.length + 1,
|
order: 0,
|
||||||
|
|
||||||
reset: false,
|
reset: false,
|
||||||
bye: false,
|
bye: false,
|
||||||
home: teamIds[i],
|
home: teamIds[i],
|
||||||
@@ -815,6 +834,14 @@ export const generateGroupStage = createServerFn()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const orderedGroupMatches = orderGroupStageMatches(
|
||||||
|
groupStageMatches,
|
||||||
|
(m) => ({ groupId: m.group, teamA: m.home, teamB: m.away })
|
||||||
|
);
|
||||||
|
orderedGroupMatches.forEach((match, index) => {
|
||||||
|
match.order = index + 1;
|
||||||
|
});
|
||||||
|
|
||||||
const knockoutTeamCount = data.groupConfig.num_groups * data.groupConfig.advance_per_group;
|
const knockoutTeamCount = data.groupConfig.num_groups * data.groupConfig.advance_per_group;
|
||||||
|
|
||||||
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(knockoutTeamCount)));
|
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(knockoutTeamCount)));
|
||||||
@@ -881,6 +908,8 @@ export const generateGroupStage = createServerFn()
|
|||||||
totalMatchCount: createdMatches.length
|
totalMatchCount: createdMatches.length
|
||||||
});
|
});
|
||||||
|
|
||||||
|
emitServerEvent({ type: "tournament", tournamentId: data.tournamentId });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tournament,
|
tournament,
|
||||||
groups: createdGroups,
|
groups: createdGroups,
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
export interface OrderableMatch {
|
||||||
|
groupId: string;
|
||||||
|
teamA: string;
|
||||||
|
teamB: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BYE = "__BYE__";
|
||||||
|
|
||||||
|
function pairKey(a: string, b: string): string {
|
||||||
|
return a < b ? `${a}|${b}` : `${b}|${a}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function circleMethodRoundIndex(teams: string[]): Map<string, number> {
|
||||||
|
const sorted = [...teams].sort();
|
||||||
|
const arr = [...sorted];
|
||||||
|
if (arr.length % 2 === 1) arr.push(BYE);
|
||||||
|
|
||||||
|
const n = arr.length;
|
||||||
|
const result = new Map<string, number>();
|
||||||
|
if (n < 2) return result;
|
||||||
|
|
||||||
|
const rounds = n - 1;
|
||||||
|
const half = n / 2;
|
||||||
|
let list = [...arr];
|
||||||
|
|
||||||
|
for (let r = 0; r < rounds; r++) {
|
||||||
|
for (let i = 0; i < half; i++) {
|
||||||
|
const a = list[i];
|
||||||
|
const b = list[n - 1 - i];
|
||||||
|
if (a !== BYE && b !== BYE) {
|
||||||
|
result.set(pairKey(a, b), r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
list = [list[0], list[n - 1], ...list.slice(1, n - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnnotatedMatch {
|
||||||
|
idx: number;
|
||||||
|
groupId: string;
|
||||||
|
teamA: string;
|
||||||
|
teamB: string;
|
||||||
|
roundIndex: number;
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBetter(a: number[], aKey: string, b: number[], bKey: string): boolean {
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
if (a[i] !== b[i]) return a[i] > b[i];
|
||||||
|
}
|
||||||
|
return aKey < bKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeGroupStageOrder(matches: OrderableMatch[]): number[] {
|
||||||
|
const n = matches.length;
|
||||||
|
if (n <= 1) return matches.map((_, i) => i);
|
||||||
|
|
||||||
|
const groupTeams = new Map<string, Set<string>>();
|
||||||
|
for (const m of matches) {
|
||||||
|
let set = groupTeams.get(m.groupId);
|
||||||
|
if (!set) {
|
||||||
|
set = new Set<string>();
|
||||||
|
groupTeams.set(m.groupId, set);
|
||||||
|
}
|
||||||
|
set.add(m.teamA);
|
||||||
|
set.add(m.teamB);
|
||||||
|
}
|
||||||
|
|
||||||
|
const roundIndexByGroup = new Map<string, Map<string, number>>();
|
||||||
|
for (const [groupId, teams] of groupTeams) {
|
||||||
|
roundIndexByGroup.set(groupId, circleMethodRoundIndex([...teams]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: AnnotatedMatch[] = matches.map((m, idx) => {
|
||||||
|
const round = roundIndexByGroup.get(m.groupId)?.get(pairKey(m.teamA, m.teamB));
|
||||||
|
return {
|
||||||
|
idx,
|
||||||
|
groupId: m.groupId,
|
||||||
|
teamA: m.teamA,
|
||||||
|
teamB: m.teamB,
|
||||||
|
roundIndex: round ?? Number.MAX_SAFE_INTEGER,
|
||||||
|
key: `${m.groupId}|${pairKey(m.teamA, m.teamB)}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const remaining = new Set<number>(items.map((it) => it.idx));
|
||||||
|
const itemByIdx = new Map<number, AnnotatedMatch>(items.map((it) => [it.idx, it]));
|
||||||
|
|
||||||
|
const order: number[] = [];
|
||||||
|
const lastPlayed = new Map<string, number>();
|
||||||
|
const NEVER = -1 - n;
|
||||||
|
let prevTeams = new Set<string>();
|
||||||
|
let prevGroup: string | null = null;
|
||||||
|
|
||||||
|
for (let pos = 0; pos < n; pos++) {
|
||||||
|
let best: AnnotatedMatch | null = null;
|
||||||
|
let bestScore: number[] | null = null;
|
||||||
|
|
||||||
|
for (const idx of remaining) {
|
||||||
|
const it = itemByIdx.get(idx)!;
|
||||||
|
|
||||||
|
const adjacent = prevTeams.has(it.teamA) || prevTeams.has(it.teamB) ? 1 : 0;
|
||||||
|
const gapA = pos - (lastPlayed.get(it.teamA) ?? NEVER);
|
||||||
|
const gapB = pos - (lastPlayed.get(it.teamB) ?? NEVER);
|
||||||
|
const gap = Math.min(gapA, gapB);
|
||||||
|
const sameGroup = prevGroup !== null && it.groupId === prevGroup ? 1 : 0;
|
||||||
|
const score = [-adjacent, gap, -sameGroup, -it.roundIndex];
|
||||||
|
|
||||||
|
if (best === null || isBetter(score, it.key, bestScore!, best.key)) {
|
||||||
|
best = it;
|
||||||
|
bestScore = score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
order.push(best!.idx);
|
||||||
|
remaining.delete(best!.idx);
|
||||||
|
lastPlayed.set(best!.teamA, pos);
|
||||||
|
lastPlayed.set(best!.teamB, pos);
|
||||||
|
prevTeams = new Set([best!.teamA, best!.teamB]);
|
||||||
|
prevGroup = best!.groupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function orderGroupStageMatches<T>(
|
||||||
|
items: T[],
|
||||||
|
accessor: (item: T) => OrderableMatch,
|
||||||
|
): T[] {
|
||||||
|
const orderable = items.map(accessor);
|
||||||
|
const order = computeGroupStageOrder(orderable);
|
||||||
|
return order.map((idx) => items[idx]);
|
||||||
|
}
|
||||||
+115
-54
@@ -1,9 +1,10 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect } from "react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Logger } from "@/lib/logger";
|
import { Logger } from "@/lib/logger";
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
import { tournamentQueries } from "@/features/tournaments/queries";
|
import { tournamentKeys } from "@/features/tournaments/queries";
|
||||||
import { reactionKeys, reactionQueries } from "@/features/reactions/queries";
|
import { reactionKeys } from "@/features/reactions/queries";
|
||||||
|
import { predictionKeys } from "@/features/predictions/queries";
|
||||||
|
|
||||||
const logger = new Logger('ServerEvents');
|
const logger = new Logger('ServerEvents');
|
||||||
|
|
||||||
@@ -12,11 +13,14 @@ type SSEEvent = {
|
|||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EventHandler = (event: SSEEvent, queryClient: ReturnType<typeof useQueryClient>, currentSessionId?: string) => void;
|
type EventHandler = (event: SSEEvent, queryClient: ReturnType<typeof useQueryClient>) => void;
|
||||||
|
|
||||||
const INVALIDATE_DEBOUNCE_MS = 1500;
|
const INVALIDATE_DEBOUNCE_MS = 1000;
|
||||||
|
const INVALIDATE_JITTER_MS = 1500;
|
||||||
const invalidateTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
const invalidateTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
const WATCHDOG_MS = 45_000;
|
||||||
|
|
||||||
function debouncedInvalidate(
|
function debouncedInvalidate(
|
||||||
queryClient: ReturnType<typeof useQueryClient>,
|
queryClient: ReturnType<typeof useQueryClient>,
|
||||||
filters: { queryKey: readonly unknown[] }
|
filters: { queryKey: readonly unknown[] }
|
||||||
@@ -25,10 +29,11 @@ function debouncedInvalidate(
|
|||||||
const existing = invalidateTimers.get(key);
|
const existing = invalidateTimers.get(key);
|
||||||
if (existing) clearTimeout(existing);
|
if (existing) clearTimeout(existing);
|
||||||
|
|
||||||
|
const delay = INVALIDATE_DEBOUNCE_MS + Math.random() * INVALIDATE_JITTER_MS;
|
||||||
invalidateTimers.set(key, setTimeout(() => {
|
invalidateTimers.set(key, setTimeout(() => {
|
||||||
invalidateTimers.delete(key);
|
invalidateTimers.delete(key);
|
||||||
queryClient.invalidateQueries(filters);
|
queryClient.invalidateQueries(filters);
|
||||||
}, INVALIDATE_DEBOUNCE_MS));
|
}, delay));
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearPendingInvalidations() {
|
function clearPendingInvalidations() {
|
||||||
@@ -39,53 +44,108 @@ function clearPendingInvalidations() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const eventHandlers: Record<string, EventHandler> = {
|
const eventHandlers: Record<string, EventHandler> = {
|
||||||
"connected": () => {
|
|
||||||
logger.info("New Connection");
|
|
||||||
},
|
|
||||||
"ping": () => {},
|
"ping": () => {},
|
||||||
"heartbeat": () => {},
|
"test": (event) => {
|
||||||
|
logger.info("Test event", event);
|
||||||
|
},
|
||||||
|
"tournament": (event, queryClient) => {
|
||||||
|
debouncedInvalidate(queryClient, { queryKey: ['tournaments'] });
|
||||||
|
debouncedInvalidate(queryClient, { queryKey: ['players', 'unenrolled'] });
|
||||||
|
},
|
||||||
"match": (event, queryClient) => {
|
"match": (event, queryClient) => {
|
||||||
debouncedInvalidate(queryClient, tournamentQueries.details(event.tournamentId))
|
debouncedInvalidate(queryClient, { queryKey: tournamentKeys.details(event.tournamentId) });
|
||||||
debouncedInvalidate(queryClient, tournamentQueries.current())
|
debouncedInvalidate(queryClient, { queryKey: tournamentKeys.current });
|
||||||
|
debouncedInvalidate(queryClient, { queryKey: predictionKeys.tournament(event.tournamentId) });
|
||||||
|
debouncedInvalidate(queryClient, { queryKey: ['players', 'stats'] });
|
||||||
|
debouncedInvalidate(queryClient, { queryKey: ['players', 'matches'] });
|
||||||
|
debouncedInvalidate(queryClient, { queryKey: ['players', 'activity'] });
|
||||||
|
debouncedInvalidate(queryClient, { queryKey: ['teams', 'stats'] });
|
||||||
|
debouncedInvalidate(queryClient, { queryKey: ['teams', 'matches'] });
|
||||||
|
debouncedInvalidate(queryClient, { queryKey: ['matches'] });
|
||||||
},
|
},
|
||||||
"reaction": (event, queryClient) => {
|
"reaction": (event, queryClient) => {
|
||||||
queryClient.invalidateQueries(reactionQueries.match(event.matchId));
|
|
||||||
queryClient.setQueryData(reactionKeys.match(event.matchId), () => event.reactions);
|
queryClient.setQueryData(reactionKeys.match(event.matchId), () => event.reactions);
|
||||||
}
|
},
|
||||||
|
"team": (event, queryClient) => {
|
||||||
|
debouncedInvalidate(queryClient, { queryKey: ['teams'] });
|
||||||
|
debouncedInvalidate(queryClient, { queryKey: ['tournaments'] });
|
||||||
|
},
|
||||||
|
"player": (event, queryClient) => {
|
||||||
|
debouncedInvalidate(queryClient, { queryKey: ['players'] });
|
||||||
|
},
|
||||||
|
"badge": (event, queryClient) => {
|
||||||
|
debouncedInvalidate(queryClient, { queryKey: ['badges'] });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useServerEvents() {
|
export function useServerEvents() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const retryCountRef = useRef(0);
|
|
||||||
const shouldConnectRef = useRef(true);
|
|
||||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
if (!user?.id) return;
|
if (!user?.id) return;
|
||||||
|
|
||||||
shouldConnectRef.current = true;
|
let disposed = false;
|
||||||
retryCountRef.current = 0;
|
let eventSource: EventSource | null = null;
|
||||||
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let watchdogTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let retryCount = 0;
|
||||||
|
let hasConnectedOnce = false;
|
||||||
|
|
||||||
const connectEventSource = () => {
|
const disconnect = () => {
|
||||||
if (!shouldConnectRef.current) return;
|
if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null; }
|
||||||
|
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
||||||
|
eventSource?.close();
|
||||||
|
eventSource = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const reconnect = (delay: number) => {
|
||||||
|
if (disposed) return;
|
||||||
|
disconnect();
|
||||||
|
reconnectTimer = setTimeout(connect, delay);
|
||||||
|
};
|
||||||
|
|
||||||
const eventSource = new EventSource(`/api/events/$`);
|
const armWatchdog = () => {
|
||||||
|
if (watchdogTimer) clearTimeout(watchdogTimer);
|
||||||
|
watchdogTimer = setTimeout(() => {
|
||||||
|
logger.warn(`SSE watchdog: no messages in ${WATCHDOG_MS}ms, reconnecting`);
|
||||||
|
retryCount = 0;
|
||||||
|
reconnect(0);
|
||||||
|
}, WATCHDOG_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
if (disposed || eventSource) return;
|
||||||
|
|
||||||
|
eventSource = new EventSource(`/api/events/$`);
|
||||||
|
armWatchdog();
|
||||||
|
|
||||||
eventSource.onopen = () => {
|
eventSource.onopen = () => {
|
||||||
retryCountRef.current = 0;
|
retryCount = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
eventSource.onmessage = (event) => {
|
eventSource.onmessage = (event) => {
|
||||||
|
armWatchdog();
|
||||||
try {
|
try {
|
||||||
const data: SSEEvent = JSON.parse(event.data);
|
const data: SSEEvent = JSON.parse(event.data);
|
||||||
|
if (data.type !== "ping") {
|
||||||
logger.info("Event received", data);
|
logger.info("Event received", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === "connected") {
|
||||||
|
if (hasConnectedOnce) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!disposed) queryClient.invalidateQueries();
|
||||||
|
}, Math.random() * 2000);
|
||||||
|
}
|
||||||
|
hasConnectedOnce = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const handler = eventHandlers[data.type];
|
const handler = eventHandlers[data.type];
|
||||||
if (handler) {
|
if (handler) {
|
||||||
handler(data, queryClient, user?.id);
|
handler(data, queryClient);
|
||||||
} else {
|
} else {
|
||||||
logger.warn(`Unhandled SSE event type: ${data.type}`);
|
logger.warn(`Unhandled SSE event type: ${data.type}`);
|
||||||
}
|
}
|
||||||
@@ -94,50 +154,51 @@ export function useServerEvents() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
eventSource.onerror = (error) => {
|
eventSource.onerror = async (error) => {
|
||||||
|
if (disposed) return;
|
||||||
logger.error("SSE connection error", error);
|
logger.error("SSE connection error", error);
|
||||||
eventSource.close();
|
disconnect();
|
||||||
|
|
||||||
if (shouldConnectRef.current && retryCountRef.current < 10) {
|
retryCount += 1;
|
||||||
retryCountRef.current += 1;
|
const delay = Math.min(1000 * Math.pow(1.5, retryCount - 1), 15000);
|
||||||
const delay = Math.min(
|
logger.info(`SSE reconnection attempt ${retryCount} in ${Math.round(delay)}ms`);
|
||||||
1000 * Math.pow(1.5, retryCountRef.current - 1),
|
|
||||||
15000
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.info(
|
try {
|
||||||
`SSE reconnection attempt ${retryCountRef.current}/10 in ${delay}ms`
|
const { attemptRefreshingSession } = await import('supertokens-web-js/recipe/session');
|
||||||
);
|
await attemptRefreshingSession();
|
||||||
|
} catch {
|
||||||
timeoutRef.current = setTimeout(() => {
|
|
||||||
if (shouldConnectRef.current) {
|
|
||||||
connectEventSource();
|
|
||||||
}
|
|
||||||
}, delay);
|
|
||||||
} else if (retryCountRef.current >= 10) {
|
|
||||||
logger.error("SSE max reconnection attempts reached");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reconnect(delay);
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
return eventSource;
|
const wake = () => {
|
||||||
|
if (disposed) return;
|
||||||
|
if (!eventSource || eventSource.readyState === EventSource.CLOSED) {
|
||||||
|
retryCount = 0;
|
||||||
|
reconnect(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleVisibility = () => {
|
||||||
|
if (document.visibilityState === 'visible') wake();
|
||||||
};
|
};
|
||||||
|
|
||||||
const eventSource = connectEventSource();
|
window.addEventListener('online', wake);
|
||||||
|
document.addEventListener('visibilitychange', handleVisibility);
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
logger.info("Closing SSE connection");
|
logger.info("Closing SSE connection");
|
||||||
shouldConnectRef.current = false;
|
disposed = true;
|
||||||
|
|
||||||
clearPendingInvalidations();
|
clearPendingInvalidations();
|
||||||
|
|
||||||
if (timeoutRef.current) {
|
window.removeEventListener('online', wake);
|
||||||
clearTimeout(timeoutRef.current);
|
document.removeEventListener('visibilitychange', handleVisibility);
|
||||||
timeoutRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eventSource) {
|
disconnect();
|
||||||
eventSource.close();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [user?.id]);
|
}, [user?.id, queryClient]);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const VAPID_PUBLIC_KEY = import.meta.env.VITE_VAPID_PUBLIC_KEY as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
function urlBase64ToBuffer(base64String: string): ArrayBuffer {
|
||||||
|
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||||
|
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const raw = atob(base64);
|
||||||
|
const buffer = new ArrayBuffer(raw.length);
|
||||||
|
const output = new Uint8Array(buffer);
|
||||||
|
for (let i = 0; i < raw.length; i++) output[i] = raw.charCodeAt(i);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PushPermission = "default" | "granted" | "denied";
|
||||||
|
|
||||||
|
export interface UseWebPush {
|
||||||
|
supported: boolean;
|
||||||
|
configured: boolean;
|
||||||
|
permission: PushPermission;
|
||||||
|
subscribed: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
enable: () => Promise<boolean>;
|
||||||
|
disable: () => Promise<boolean>;
|
||||||
|
sendTest: () => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRegistration(): Promise<ServiceWorkerRegistration | null> {
|
||||||
|
if (!("serviceWorker" in navigator)) return null;
|
||||||
|
return (
|
||||||
|
(await navigator.serviceWorker.getRegistration()) ??
|
||||||
|
(await navigator.serviceWorker.ready.catch(() => null))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWebPush(): UseWebPush {
|
||||||
|
const [supported, setSupported] = useState(false);
|
||||||
|
const [permission, setPermission] = useState<PushPermission>("default");
|
||||||
|
const [subscribed, setSubscribed] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const configured = Boolean(VAPID_PUBLIC_KEY);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const isSupported =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
"Notification" in window &&
|
||||||
|
"PushManager" in window &&
|
||||||
|
"serviceWorker" in navigator;
|
||||||
|
setSupported(isSupported);
|
||||||
|
if (!isSupported) return;
|
||||||
|
|
||||||
|
setPermission(Notification.permission as PushPermission);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const reg = await getRegistration();
|
||||||
|
const sub = await reg?.pushManager.getSubscription();
|
||||||
|
setSubscribed(Boolean(sub));
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const enable = useCallback(async (): Promise<boolean> => {
|
||||||
|
if (!supported || !configured || !VAPID_PUBLIC_KEY) return false;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const perm = await Notification.requestPermission();
|
||||||
|
setPermission(perm as PushPermission);
|
||||||
|
if (perm !== "granted") return false;
|
||||||
|
|
||||||
|
const reg = await getRegistration();
|
||||||
|
if (!reg) return false;
|
||||||
|
|
||||||
|
let sub = await reg.pushManager.getSubscription();
|
||||||
|
if (!sub) {
|
||||||
|
sub = await reg.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToBuffer(VAPID_PUBLIC_KEY),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch("/api/push/subscribe", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ subscription: sub.toJSON() }),
|
||||||
|
});
|
||||||
|
if (!res.ok) return false;
|
||||||
|
|
||||||
|
setSubscribed(true);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [supported, configured]);
|
||||||
|
|
||||||
|
const disable = useCallback(async (): Promise<boolean> => {
|
||||||
|
if (!supported) return false;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const reg = await getRegistration();
|
||||||
|
const sub = await reg?.pushManager.getSubscription();
|
||||||
|
if (sub) {
|
||||||
|
await fetch("/api/push/unsubscribe", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ endpoint: sub.endpoint }),
|
||||||
|
}).catch(() => {});
|
||||||
|
await sub.unsubscribe().catch(() => {});
|
||||||
|
}
|
||||||
|
setSubscribed(false);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [supported]);
|
||||||
|
|
||||||
|
const sendTest = useCallback(async (): Promise<boolean> => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/push/test", { method: "POST" });
|
||||||
|
return res.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
supported,
|
||||||
|
configured,
|
||||||
|
permission,
|
||||||
|
subscribed,
|
||||||
|
busy,
|
||||||
|
enable,
|
||||||
|
disable,
|
||||||
|
sendTest,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -17,3 +17,12 @@ export const serverConfig = {
|
|||||||
spotifyClientId: process.env.VITE_SPOTIFY_CLIENT_ID || '',
|
spotifyClientId: process.env.VITE_SPOTIFY_CLIENT_ID || '',
|
||||||
spotifyClientSecret: process.env.SPOTIFY_CLIENT_SECRET || '',
|
spotifyClientSecret: process.env.SPOTIFY_CLIENT_SECRET || '',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const vapidConfig = {
|
||||||
|
publicKey: process.env.VAPID_PUBLIC_KEY || '',
|
||||||
|
privateKey: process.env.VAPID_PRIVATE_KEY || '',
|
||||||
|
subject: process.env.VAPID_SUBJECT || 'mailto:admin@flexxon.app',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isPushConfigured = () =>
|
||||||
|
Boolean(vapidConfig.publicKey && vapidConfig.privateKey)
|
||||||
|
|||||||
+66
-23
@@ -1,38 +1,81 @@
|
|||||||
import { EventEmitter } from "events";
|
import { EventEmitter } from "events";
|
||||||
|
|
||||||
export const serverEvents = new EventEmitter();
|
|
||||||
|
|
||||||
serverEvents.setMaxListeners(50);
|
|
||||||
|
|
||||||
// Debug logging for listener count
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
setInterval(() => {
|
|
||||||
const listenerCounts = {
|
|
||||||
test: serverEvents.listenerCount('test'),
|
|
||||||
match: serverEvents.listenerCount('match'),
|
|
||||||
reaction: serverEvents.listenerCount('reaction'),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (listenerCounts.test > 0 || listenerCounts.match > 0 || listenerCounts.reaction > 0) {
|
|
||||||
console.log('ServerEvents listener count:', listenerCounts);
|
|
||||||
}
|
|
||||||
}, 30000); // Log every 30 seconds in development
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TestEvent = {
|
export type TestEvent = {
|
||||||
type: "test";
|
type: "test";
|
||||||
playerId: string;
|
userId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MatchEvent = {
|
export type MatchEvent = {
|
||||||
type: "match";
|
type: "match";
|
||||||
matchId: string;
|
matchId: string;
|
||||||
tournamentId: string;
|
tournamentId: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type ReactionEvent = {
|
export type ReactionEvent = {
|
||||||
type: "reaction";
|
type: "reaction";
|
||||||
matchId: string;
|
matchId: string;
|
||||||
}
|
reactions: Array<{
|
||||||
|
emoji: string;
|
||||||
|
count: number;
|
||||||
|
players: Array<{ id?: string; first_name?: string; last_name?: string }>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
export type ServerEvent = TestEvent | MatchEvent | ReactionEvent;
|
export type TournamentEvent = {
|
||||||
|
type: "tournament";
|
||||||
|
tournamentId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TeamEvent = {
|
||||||
|
type: "team";
|
||||||
|
teamId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlayerEvent = {
|
||||||
|
type: "player";
|
||||||
|
playerId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BadgeEvent = {
|
||||||
|
type: "badge";
|
||||||
|
playerId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServerEvent =
|
||||||
|
| TestEvent
|
||||||
|
| MatchEvent
|
||||||
|
| ReactionEvent
|
||||||
|
| TournamentEvent
|
||||||
|
| TeamEvent
|
||||||
|
| PlayerEvent
|
||||||
|
| BadgeEvent;
|
||||||
|
|
||||||
|
export const EVENT_TYPES = [
|
||||||
|
"test",
|
||||||
|
"match",
|
||||||
|
"reaction",
|
||||||
|
"tournament",
|
||||||
|
"team",
|
||||||
|
"player",
|
||||||
|
"badge",
|
||||||
|
] as const satisfies readonly ServerEvent["type"][];
|
||||||
|
|
||||||
|
export const serverEvents = new EventEmitter();
|
||||||
|
|
||||||
|
serverEvents.setMaxListeners(200);
|
||||||
|
|
||||||
|
export const emitServerEvent = (event: ServerEvent) => {
|
||||||
|
serverEvents.emit(event.type, event);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
setInterval(() => {
|
||||||
|
const listenerCounts = Object.fromEntries(
|
||||||
|
EVENT_TYPES.map((type) => [type, serverEvents.listenerCount(type)])
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Object.values(listenerCounts).some((count) => count > 0)) {
|
||||||
|
console.log('ServerEvents listener count:', listenerCounts);
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { createReactionsService } from "./services/reactions";
|
|||||||
import { createActivitiesService } from "./services/activities";
|
import { createActivitiesService } from "./services/activities";
|
||||||
import { createBadgesService } from "./services/badges";
|
import { createBadgesService } from "./services/badges";
|
||||||
import { createGroupsService } from "./services/groups";
|
import { createGroupsService } from "./services/groups";
|
||||||
|
import { createPredictionsService } from "./services/predictions";
|
||||||
|
import { createPushService } from "./services/push";
|
||||||
|
|
||||||
class PocketBaseAdminClient {
|
class PocketBaseAdminClient {
|
||||||
private pb: PocketBase;
|
private pb: PocketBase;
|
||||||
@@ -48,6 +50,8 @@ class PocketBaseAdminClient {
|
|||||||
Object.assign(this, createActivitiesService(this.pb));
|
Object.assign(this, createActivitiesService(this.pb));
|
||||||
Object.assign(this, createBadgesService(this.pb));
|
Object.assign(this, createBadgesService(this.pb));
|
||||||
Object.assign(this, createGroupsService(this.pb));
|
Object.assign(this, createGroupsService(this.pb));
|
||||||
|
Object.assign(this, createPredictionsService(this.pb));
|
||||||
|
Object.assign(this, createPushService(this.pb));
|
||||||
|
|
||||||
this.authPromise = this.authenticate();
|
this.authPromise = this.authenticate();
|
||||||
this.authPromise.then(() => {
|
this.authPromise.then(() => {
|
||||||
@@ -126,7 +130,9 @@ interface AdminClient
|
|||||||
ReturnType<typeof createReactionsService>,
|
ReturnType<typeof createReactionsService>,
|
||||||
ReturnType<typeof createActivitiesService>,
|
ReturnType<typeof createActivitiesService>,
|
||||||
ReturnType<typeof createBadgesService>,
|
ReturnType<typeof createBadgesService>,
|
||||||
ReturnType<typeof createGroupsService> {
|
ReturnType<typeof createGroupsService>,
|
||||||
|
ReturnType<typeof createPredictionsService>,
|
||||||
|
ReturnType<typeof createPushService> {
|
||||||
authPromise: Promise<void>;
|
authPromise: Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { transformMatch } from "../util/transform-types";
|
|||||||
|
|
||||||
export function createMatchesService(pb: PocketBase) {
|
export function createMatchesService(pb: PocketBase) {
|
||||||
return {
|
return {
|
||||||
|
createBatch() {
|
||||||
|
return pb.createBatch();
|
||||||
|
},
|
||||||
|
|
||||||
async getMatch(id: string): Promise<Match | null> {
|
async getMatch(id: string): Promise<Match | null> {
|
||||||
const result = await pb.collection("matches").getOne(id, {
|
const result = await pb.collection("matches").getOne(id, {
|
||||||
expand: "tournament, home, away, home.players, away.players",
|
expand: "tournament, home, away, home.players, away.players",
|
||||||
@@ -12,7 +16,6 @@ export function createMatchesService(pb: PocketBase) {
|
|||||||
return transformMatch(result);
|
return transformMatch(result);
|
||||||
},
|
},
|
||||||
|
|
||||||
// match Ids where the current lid is home_from_lid or away_from_lid
|
|
||||||
async getChildMatches(matchId: string): Promise<{ winner: Match | undefined, loser: Match | undefined }> {
|
async getChildMatches(matchId: string): Promise<{ winner: Match | undefined, loser: Match | undefined }> {
|
||||||
const match = await this.getMatch(matchId);
|
const match = await this.getMatch(matchId);
|
||||||
if (!match) throw new Error("Match not found")
|
if (!match) throw new Error("Match not found")
|
||||||
@@ -32,7 +35,6 @@ export function createMatchesService(pb: PocketBase) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async createMatch(data: MatchInput): Promise<Match> {
|
async createMatch(data: MatchInput): Promise<Match> {
|
||||||
// logger.info("PocketBase | Creating match", data);
|
|
||||||
const result = await pb.collection("matches").create<Match>(data);
|
const result = await pb.collection("matches").create<Match>(data);
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
@@ -142,6 +144,15 @@ export function createMatchesService(pb: PocketBase) {
|
|||||||
return results.map(match => transformMatch(match));
|
return results.map(match => transformMatch(match));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getMatchesByTournament(tournamentId: string): Promise<Match[]> {
|
||||||
|
const results = await pb.collection("matches").getFullList({
|
||||||
|
filter: `tournament = "${tournamentId}"`,
|
||||||
|
expand: "tournament, home, away, home.players, away.players",
|
||||||
|
sort: "lid",
|
||||||
|
});
|
||||||
|
return results.map(match => transformMatch(match));
|
||||||
|
},
|
||||||
|
|
||||||
async getMatchesByGroup(groupId: string): Promise<Match[]> {
|
async getMatchesByGroup(groupId: string): Promise<Match[]> {
|
||||||
logger.info("PocketBase | Getting matches for group", { groupId });
|
logger.info("PocketBase | Getting matches for group", { groupId });
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import PocketBase from "pocketbase";
|
||||||
|
import { PicksMap, Prediction } from "@/features/predictions/types";
|
||||||
|
import { transformPrediction } from "../util/transform-types";
|
||||||
|
|
||||||
|
export function createPredictionsService(pb: PocketBase) {
|
||||||
|
return {
|
||||||
|
async getPrediction(
|
||||||
|
tournamentId: string,
|
||||||
|
playerId: string
|
||||||
|
): Promise<Prediction | null> {
|
||||||
|
try {
|
||||||
|
const record = await pb
|
||||||
|
.collection("predictions")
|
||||||
|
.getFirstListItem(
|
||||||
|
`tournament="${tournamentId}" && player="${playerId}"`,
|
||||||
|
{ expand: "player" }
|
||||||
|
);
|
||||||
|
return transformPrediction(record);
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPredictionsForTournament(
|
||||||
|
tournamentId: string
|
||||||
|
): Promise<Prediction[]> {
|
||||||
|
const records = await pb.collection("predictions").getFullList({
|
||||||
|
filter: `tournament="${tournamentId}"`,
|
||||||
|
expand: "player",
|
||||||
|
sort: "updated",
|
||||||
|
});
|
||||||
|
return records.map(transformPrediction);
|
||||||
|
},
|
||||||
|
|
||||||
|
async upsertPrediction(
|
||||||
|
tournamentId: string,
|
||||||
|
playerId: string,
|
||||||
|
picks: PicksMap
|
||||||
|
): Promise<Prediction> {
|
||||||
|
const existing = await this.getPrediction(tournamentId, playerId);
|
||||||
|
|
||||||
|
const record = existing
|
||||||
|
? await pb
|
||||||
|
.collection("predictions")
|
||||||
|
.update(existing.id, { picks }, { expand: "player" })
|
||||||
|
: await pb.collection("predictions").create(
|
||||||
|
{
|
||||||
|
tournament: tournamentId,
|
||||||
|
player: playerId,
|
||||||
|
picks,
|
||||||
|
},
|
||||||
|
{ expand: "player" }
|
||||||
|
);
|
||||||
|
|
||||||
|
return transformPrediction(record);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deletePredictionsForTournament(tournamentId: string): Promise<void> {
|
||||||
|
const records = await pb.collection("predictions").getFullList({
|
||||||
|
filter: `tournament="${tournamentId}"`,
|
||||||
|
fields: "id",
|
||||||
|
});
|
||||||
|
for (const record of records) {
|
||||||
|
await pb.collection("predictions").delete(record.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import PocketBase from "pocketbase";
|
||||||
|
|
||||||
|
export interface PushSubscriptionRecord {
|
||||||
|
id: string;
|
||||||
|
player: string;
|
||||||
|
endpoint: string;
|
||||||
|
p256dh: string;
|
||||||
|
auth: string;
|
||||||
|
user_agent?: string;
|
||||||
|
created: string;
|
||||||
|
updated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PushSubscriptionInput {
|
||||||
|
player: string;
|
||||||
|
endpoint: string;
|
||||||
|
p256dh: string;
|
||||||
|
auth: string;
|
||||||
|
user_agent?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createPushService = (pb: PocketBase) => ({
|
||||||
|
async listPushSubscriptionsByPlayer(
|
||||||
|
playerId: string
|
||||||
|
): Promise<PushSubscriptionRecord[]> {
|
||||||
|
return await pb
|
||||||
|
.collection("push_subscriptions")
|
||||||
|
.getFullList<PushSubscriptionRecord>({
|
||||||
|
filter: `player = "${playerId}"`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPushSubscriptionByEndpoint(
|
||||||
|
endpoint: string
|
||||||
|
): Promise<PushSubscriptionRecord | null> {
|
||||||
|
try {
|
||||||
|
return await pb
|
||||||
|
.collection("push_subscriptions")
|
||||||
|
.getFirstListItem<PushSubscriptionRecord>(
|
||||||
|
`endpoint = "${endpoint}"`
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async upsertPushSubscription(
|
||||||
|
data: PushSubscriptionInput
|
||||||
|
): Promise<PushSubscriptionRecord> {
|
||||||
|
const existing = await this.getPushSubscriptionByEndpoint(data.endpoint);
|
||||||
|
if (existing) {
|
||||||
|
return await pb
|
||||||
|
.collection("push_subscriptions")
|
||||||
|
.update<PushSubscriptionRecord>(existing.id, data);
|
||||||
|
}
|
||||||
|
return await pb
|
||||||
|
.collection("push_subscriptions")
|
||||||
|
.create<PushSubscriptionRecord>(data);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deletePushSubscriptionByEndpoint(endpoint: string): Promise<void> {
|
||||||
|
const existing = await this.getPushSubscriptionByEndpoint(endpoint);
|
||||||
|
if (existing) {
|
||||||
|
await pb.collection("push_subscriptions").delete(existing.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async deletePushSubscriptionById(id: string): Promise<void> {
|
||||||
|
await pb.collection("push_subscriptions").delete(id);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -3,11 +3,9 @@ import { Player, PlayerInfo } from "@/features/players/types";
|
|||||||
import { Team, TeamInfo } from "@/features/teams/types";
|
import { Team, TeamInfo } from "@/features/teams/types";
|
||||||
import { Tournament, TournamentInfo } from "@/features/tournaments/types";
|
import { Tournament, TournamentInfo } from "@/features/tournaments/types";
|
||||||
import { Badge, BadgeInfo, BadgeProgress, EarnedBadge } from "@/features/badges/types";
|
import { Badge, BadgeInfo, BadgeProgress, EarnedBadge } from "@/features/badges/types";
|
||||||
|
import { Prediction } from "@/features/predictions/types";
|
||||||
import { Activity } from "../services/activities";
|
import { Activity } from "../services/activities";
|
||||||
|
|
||||||
// pocketbase does this weird thing with relations where it puts them under a seperate "expand" field
|
|
||||||
// this file transforms raw pocketbase results to our types
|
|
||||||
|
|
||||||
export function transformPlayerInfo(record: any): PlayerInfo {
|
export function transformPlayerInfo(record: any): PlayerInfo {
|
||||||
return {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
@@ -57,6 +55,11 @@ export const transformMatch = (record: any, isAdmin: boolean = false): Match =>
|
|||||||
away_seed: record.away_seed,
|
away_seed: record.away_seed,
|
||||||
match_type: record.match_type,
|
match_type: record.match_type,
|
||||||
group: record.group,
|
group: record.group,
|
||||||
|
reported_home_cups: record.reported_home_cups ?? undefined,
|
||||||
|
reported_away_cups: record.reported_away_cups ?? undefined,
|
||||||
|
reported_ot_count: record.reported_ot_count ?? undefined,
|
||||||
|
reported_by_team: record.reported_by_team || undefined,
|
||||||
|
reported_by_player: record.reported_by_player || undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,6 +298,17 @@ export function transformReaction(record: any) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function transformPrediction(record: any): Prediction {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
tournament: record.tournament,
|
||||||
|
player: transformPlayerInfo(record.expand?.player ?? { id: record.player }),
|
||||||
|
picks: record.picks || {},
|
||||||
|
created: record.created,
|
||||||
|
updated: record.updated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function transformBadgeInfo(record: any): BadgeInfo {
|
export function transformBadgeInfo(record: any): BadgeInfo {
|
||||||
return {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import webpush from "web-push";
|
||||||
|
import { vapidConfig, isPushConfigured } from "@/lib/config";
|
||||||
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import type { PushSubscriptionRecord } from "@/lib/pocketbase/services/push";
|
||||||
|
|
||||||
|
export interface PushPayload {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
/** Path (or absolute URL) to open/focus when the notification is clicked. */
|
||||||
|
url?: string;
|
||||||
|
/** Optional icon override; defaults to the app icon in the SW handler. */
|
||||||
|
icon?: string;
|
||||||
|
/** Optional tag to collapse/replace notifications of the same kind. */
|
||||||
|
tag?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let vapidInitialized = false;
|
||||||
|
|
||||||
|
function ensureVapid(): boolean {
|
||||||
|
if (!isPushConfigured()) {
|
||||||
|
logger.warn(
|
||||||
|
"Push | VAPID keys not configured (VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY); skipping send"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!vapidInitialized) {
|
||||||
|
webpush.setVapidDetails(
|
||||||
|
vapidConfig.subject,
|
||||||
|
vapidConfig.publicKey,
|
||||||
|
vapidConfig.privateKey
|
||||||
|
);
|
||||||
|
vapidInitialized = true;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Low-level: send a single push to one subscription. Throws on transport
|
||||||
|
* errors. Callers that persist subscriptions should handle 404/410 (dead
|
||||||
|
* endpoint) themselves — see sendPushToPlayer.
|
||||||
|
*/
|
||||||
|
export async function sendPush(
|
||||||
|
subscription: {
|
||||||
|
endpoint: string;
|
||||||
|
keys: { p256dh: string; auth: string };
|
||||||
|
},
|
||||||
|
payload: PushPayload
|
||||||
|
): Promise<void> {
|
||||||
|
if (!ensureVapid()) return;
|
||||||
|
await webpush.sendNotification(subscription, JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a push to every device a player has subscribed. Dead subscriptions
|
||||||
|
* (HTTP 404/410 from the push service) are pruned from storage automatically.
|
||||||
|
*
|
||||||
|
* This is the seam that real product triggers will call LATER, e.g.:
|
||||||
|
* - a match the player is in starts (src/lib/pocketbase/services/matches.ts)
|
||||||
|
* - a free-agent claim involving the player (src/lib/pocketbase/services/free-agents ...)
|
||||||
|
* - a badge is awarded to the player (src/lib/pocketbase/services/badges.ts)
|
||||||
|
* NONE of these are wired yet — this foundation pass only exposes the seam
|
||||||
|
* plus a manual "test notification" path.
|
||||||
|
*/
|
||||||
|
export async function sendPushToPlayer(
|
||||||
|
playerId: string,
|
||||||
|
payload: PushPayload
|
||||||
|
): Promise<{ sent: number; failed: number; pruned: number }> {
|
||||||
|
if (!ensureVapid()) return { sent: 0, failed: 0, pruned: 0 };
|
||||||
|
|
||||||
|
const subscriptions: PushSubscriptionRecord[] =
|
||||||
|
await pbAdmin.listPushSubscriptionsByPlayer(playerId);
|
||||||
|
|
||||||
|
let sent = 0;
|
||||||
|
let failed = 0;
|
||||||
|
let pruned = 0;
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
subscriptions.map(async (sub) => {
|
||||||
|
try {
|
||||||
|
await sendPush(
|
||||||
|
{
|
||||||
|
endpoint: sub.endpoint,
|
||||||
|
keys: { p256dh: sub.p256dh, auth: sub.auth },
|
||||||
|
},
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
sent++;
|
||||||
|
} catch (error: any) {
|
||||||
|
const status = error?.statusCode;
|
||||||
|
if (status === 404 || status === 410) {
|
||||||
|
// Subscription no longer valid — prune it.
|
||||||
|
try {
|
||||||
|
await pbAdmin.deletePushSubscriptionById(sub.id);
|
||||||
|
pruned++;
|
||||||
|
} catch (delError) {
|
||||||
|
logger.error("Push | Failed to prune dead subscription", delError);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
failed++;
|
||||||
|
logger.error(
|
||||||
|
`Push | Failed to send to endpoint (status ${status ?? "?"})`,
|
||||||
|
error?.body ?? error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return { sent, failed, pruned };
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { superTokensFunctionMiddleware } from "./supertokens";
|
import { superTokensFunctionMiddleware } from "./supertokens";
|
||||||
import { serverEvents } from "@/lib/events/emitter";
|
import { emitServerEvent } from "@/lib/events/emitter";
|
||||||
|
|
||||||
export const testEvent = createServerFn()
|
export const testEvent = createServerFn()
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ context }) => {
|
.handler(async ({ context }) => {
|
||||||
serverEvents.emit("test", {
|
emitServerEvent({
|
||||||
type: "test",
|
type: "test",
|
||||||
userId: context.userAuthId,
|
userId: context.userAuthId,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user