4 Commits
Author SHA1 Message Date
yohlo 22c282d8fa more for the overhaul
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m49s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 30s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 9m40s
2026-07-22 16:41:44 -07:00
yohlo 86cda294f9 social overhaul 2026-07-22 15:47:34 -07:00
yohlo 9db3fa697d Merge branch 'loader' into development
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m55s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m24s
2026-07-14 15:00:50 -07:00
yohlo f77b3a5c41 infinite scroll 2026-07-13 23:57:31 -07:00
59 changed files with 3582 additions and 715 deletions
+22
View File
@@ -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=="],
+78
View File
@@ -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):
- **WL + 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 41 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, 120350ms), 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.
+91
View File
@@ -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` (~L189216); `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` (~L828928). 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` (L341381) 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` (L4574): 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 (L156162) 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 120350ms 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` (L341381) 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.
+89
View File
@@ -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 385397) | 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 24 players in the match | The single most valuable push in the app: personal, urgent, actionable ("get to the table") | 36 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 | 02 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.
+84
View File
@@ -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, 120350ms) — 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.
+2
View File
@@ -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,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);
})
+58
View File
@@ -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);
}
})
);
});
+6
View File
@@ -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,
+81
View File
@@ -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);
+63
View File
@@ -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'
@@ -149,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/$',
@@ -275,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
@@ -314,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
@@ -356,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
@@ -398,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'
@@ -437,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'
@@ -478,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'
@@ -509,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
@@ -662,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/$'
@@ -874,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,
+3 -3
View File
@@ -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
View File
@@ -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} />
</> </>
); );
+11 -3
View File
@@ -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">
+52
View File
@@ -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);
}
},
},
},
});
+40
View File
@@ -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);
}
},
},
},
});
+41
View File
@@ -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);
}
},
},
},
});
+73
View File
@@ -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;
@@ -16,9 +16,10 @@ interface BracketViewProps {
}; };
renderMatch?: (match: Match) => React.ReactNode; renderMatch?: (match: Match) => React.ReactNode;
bottomOffset?: number; bottomOffset?: number;
nextUpMatchId?: string;
} }
const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupConfig, renderMatch, bottomOffset }) => { const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupConfig, renderMatch, bottomOffset, nextUpMatchId }) => {
const height = useAppShellHeight(); const height = useAppShellHeight();
const viewportRef = useRef<HTMLDivElement>(null); const viewportRef = useRef<HTMLDivElement>(null);
const hasAutoScrolled = useRef(false); const hasAutoScrolled = useRef(false);
@@ -50,9 +51,10 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupC
const matches = [...bracket.winners.flat(), ...bracket.losers.flat()].filter( const matches = [...bracket.winners.flat(), ...bracket.losers.flat()].filter(
(match) => !match.bye (match) => !match.bye
); );
const target = const target = nextUpMatchId !== undefined
matches.find((match) => match.status === "started") ?? ? matches.find((match) => match.id === nextUpMatchId)
matches.find((match) => match.status === "ready"); : matches.find((match) => match.status === "started") ??
matches.find((match) => match.status === "ready");
if (!target) return; if (!target) return;
const viewport = viewportRef.current; const viewport = viewportRef.current;
@@ -78,7 +80,7 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupC
? "auto" ? "auto"
: "smooth", : "smooth",
}); });
}, [bracket]); }, [bracket, nextUpMatchId]);
return <Box pos="relative"> return <Box pos="relative">
<ScrollArea <ScrollArea
@@ -95,14 +97,14 @@ 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} renderMatch={renderMatch} onMatchTap={handleMatchTap} selectedMatchLid={selectedLid} /> <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} renderMatch={renderMatch} onMatchTap={handleMatchTap} selectedMatchLid={selectedLid} /> <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} {bottomOffset ? <div style={{ height: bottomOffset }} /> : null}
@@ -14,6 +14,7 @@ interface BracketProps {
renderMatch?: (match: Match) => React.ReactNode; renderMatch?: (match: Match) => React.ReactNode;
onMatchTap?: (match: Match) => void; onMatchTap?: (match: Match) => void;
selectedMatchLid?: number | null; selectedMatchLid?: number | null;
nextUpMatchId?: string;
} }
export const Bracket: React.FC<BracketProps> = ({ export const Bracket: React.FC<BracketProps> = ({
@@ -24,6 +25,7 @@ export const Bracket: React.FC<BracketProps> = ({
renderMatch, renderMatch,
onMatchTap, onMatchTap,
selectedMatchLid, selectedMatchLid,
nextUpMatchId,
}) => { }) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null); const svgRef = useRef<SVGSVGElement>(null);
@@ -149,6 +151,7 @@ export const Bracket: React.FC<BracketProps> = ({
groupConfig={groupConfig} groupConfig={groupConfig}
onTap={onMatchTap} onTap={onMatchTap}
selected={selectedMatchLid === match.lid} selected={selectedMatchLid === match.lid}
nextUpMatchId={nextUpMatchId}
/> />
)} )}
</div> </div>
+169 -138
View File
@@ -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,6 +6,8 @@ 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";
@@ -25,6 +27,7 @@ interface MatchCardProps {
}; };
onTap?: (match: Match) => void; onTap?: (match: Match) => void;
selected?: boolean; selected?: boolean;
nextUpMatchId?: string;
} }
export const MatchCard: React.FC<MatchCardProps> = ({ export const MatchCard: React.FC<MatchCardProps> = ({
@@ -34,6 +37,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
groupConfig, groupConfig,
onTap, onTap,
selected, selected,
nextUpMatchId,
}) => { }) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const editSheet = useSheet(); const editSheet = useSheet();
@@ -41,6 +45,8 @@ export const MatchCard: React.FC<MatchCardProps> = ({
const canTap = !!(onTap && match.home && match.away); const canTap = !!(onTap && match.home && match.away);
const isNextUp = nextUpMatchId !== undefined && match.id === nextUpMatchId;
const homeSlot = useMemo( const homeSlot = useMemo(
() => ({ () => ({
from: orders[match.home_from_lid], from: orders[match.home_from_lid],
@@ -80,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]
); );
@@ -98,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."
);
}
}, },
}); });
@@ -195,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;
@@ -217,152 +232,168 @@ export const MatchCard: React.FC<MatchCardProps> = ({
}, [match, start, hasWalkoutData, playTeamWalkout, speak]); }, [match, start, hasWalkoutData, playTeamWalkout, speak]);
return ( return (
<Flex direction="row" align="center" justify="end" gap={8}> <Stack gap={6} align="flex-end">
<Text <Flex direction="row" align="center" justify="end" gap={8}>
c="dimmed" <Text
fw="bolder" c="dimmed"
px={6} fw="bolder"
py={2} px={6}
style={{ py={2}
backgroundColor: 'var(--mantine-color-body)' style={{
}} backgroundColor: 'var(--mantine-color-body)'
> }}
{match.order}
</Text>
<Flex align="stretch">
<Indicator
inline
processing={match.status === "started"}
color="red"
size={12}
disabled={match.status !== "started" || showEditButton}
> >
<Card {match.order}
w={showToolbar || showEditButton ? 200 : 220} </Text>
withBorder <Flex
pos="relative" align="stretch"
className={canTap ? styles["tappable-card"] : undefined} style={{
onClick={canTap ? () => onTap!(match) : undefined} borderRadius: "var(--mantine-radius-default)",
role={canTap ? "button" : undefined} boxShadow: isNextUp
tabIndex={canTap ? 0 : undefined} ? "0 0 0 1px var(--mantine-primary-color-filled), 0 0 12px var(--mantine-primary-color-light-hover)"
onKeyDown={ : undefined,
canTap transition:
? (e: React.KeyboardEvent) => { "box-shadow 200ms cubic-bezier(0.32, 0.72, 0, 1)",
if (e.key === "Enter" || e.key === " ") { }}
e.preventDefault(); >
onTap!(match); <Indicator
} inline
} processing={match.status === "started"}
: undefined color="red"
} size={12}
aria-label={ disabled={match.status !== "started" || showEditButton}
canTap
? `Match ${match.order}: ${match.home!.name} vs ${match.away!.name}`
: undefined
}
style={{
overflow: "visible",
backgroundColor: 'var(--mantine-color-body)',
borderColor: selected
? 'var(--mantine-primary-color-filled)'
: 'var(--mantine-color-default-border)',
boxShadow: 'var(--mantine-shadow-sm)',
}}
data-match-lid={match.lid}
> >
<Card.Section withBorder p={0}> <Card
<MatchSlot {...homeSlot} /> w={showToolbar || showEditButton ? 200 : 220}
</Card.Section> withBorder
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={{
overflow: "visible",
backgroundColor: 'var(--mantine-color-body)',
borderColor: selected
? 'var(--mantine-primary-color-filled)'
: 'var(--mantine-color-default-border)',
boxShadow: 'var(--mantine-shadow-sm)',
transition:
'border-color 200ms cubic-bezier(0.32, 0.72, 0, 1)',
}}
data-match-lid={match.lid}
>
<Card.Section withBorder p={0}>
<MatchSlot {...homeSlot} />
</Card.Section>
<Card.Section p={0} mb={-16}> <Card.Section p={0} mb={-16}>
<MatchSlot {...awaySlot} /> <MatchSlot {...awaySlot} />
</Card.Section> </Card.Section>
{match.reset && ( {match.reset && (
<Text <Text
pos="absolute" pos="absolute"
top={-20} top={-20}
left={8} left={8}
size="xs" size="xs"
c="dimmed" c="dimmed"
fw="bold" fw="bold"
> >
* If necessary * If necessary
</Text> </Text>
)} )}
{showControls && match.status !== "tbd" && ( {showControls && match.status !== "tbd" && (
<ActionIcon
pos="absolute"
bottom={-2}
left={-26}
size="sm"
variant="subtle"
color="gray"
onClick={(e) => {
e.stopPropagation();
handleSpeakerClick();
}}
aria-label="Announce matchup"
>
<SpeakerHighIcon size={12} />
</ActionIcon>
)}
</Card>
</Indicator>
{showToolbar && (
<Flex direction="column" justify="center" align="center">
<ActionIcon <ActionIcon
pos="absolute" color="green"
bottom={-2} onClick={handleStart}
left={-26} loading={start.isPending}
size="sm" size="sm"
variant="subtle" h="100%"
color="gray" radius="sm"
onClick={(e) => { ml={-4}
e.stopPropagation(); aria-label="Start match"
handleSpeakerClick(); style={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
}} }}
aria-label="Announce matchup"
> >
<SpeakerHighIcon size={12} /> <PlayIcon size={14} />
</ActionIcon> </ActionIcon>
)} </Flex>
</Card> )}
</Indicator>
{showToolbar && (
<Flex direction="column" justify="center" align="center">
<ActionIcon
color="green"
onClick={handleStart}
loading={start.isPending}
size="sm"
h="100%"
radius="sm"
ml={-4}
aria-label="Start match"
style={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
}}
>
<PlayIcon size={14} />
</ActionIcon>
</Flex>
)}
{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"
h="100%" h="100%"
radius="sm" radius="sm"
ml={-4} ml={-4}
aria-label="Edit match score" aria-label="Edit match score"
style={{ style={{
borderTopLeftRadius: 0, borderTopLeftRadius: 0,
borderBottomLeftRadius: 0, borderBottomLeftRadius: 0,
}} }}
> >
<PencilIcon size={14} /> <PencilIcon size={14} />
</ActionIcon> </ActionIcon>
</Flex> </Flex>
)} )}
</Flex>
<Sheet title="Edit Match" {...editSheet.props}>
<MatchForm
match={match}
onSubmit={handleFormSubmit}
onCancel={editSheet.close}
loading={end.isPending}
/>
</Sheet>
</Flex> </Flex>
<Sheet title="Edit Match" {...editSheet.props}> {!showControls && <MatchReport match={match} compact />}
<MatchForm </Stack>
match={match}
onSubmit={handleFormSubmit}
onCancel={editSheet.close}
loading={end.isPending}
/>
</Sheet>
</Flex>
); );
}; };
@@ -20,6 +20,7 @@ import TeamAvatar from "@/components/team-avatar";
import AnimatedScore from "@/features/matches/components/animated-score"; import AnimatedScore from "@/features/matches/components/animated-score";
import EmojiBar from "@/features/reactions/components/emoji-bar"; import EmojiBar from "@/features/reactions/components/emoji-bar";
import TeamHeadToHeadSheet from "@/features/matches/components/team-head-to-head-sheet"; import TeamHeadToHeadSheet from "@/features/matches/components/team-head-to-head-sheet";
import { MatchReport } from "./match-report";
import Sheet from "@/components/sheet/sheet"; import Sheet from "@/components/sheet/sheet";
import { useSheet } from "@/hooks/use-sheet"; import { useSheet } from "@/hooks/use-sheet";
@@ -149,6 +150,8 @@ const MatchDock = ({ match, onClose }: MatchDockProps) => {
ended={ended} ended={ended}
/> />
<MatchReport match={match} />
<Group justify="space-between" wrap="nowrap" gap="sm"> <Group justify="space-between" wrap="nowrap" gap="sm">
<Box style={{ flex: 1, minWidth: 0 }}> <Box style={{ flex: 1, minWidth: 0 }}>
<Suspense <Suspense
@@ -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;
};
+2 -8
View File
@@ -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,7 +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'
p='md' p='md'
w='100%' w='100%'
maw='375px' maw='375px'
@@ -52,12 +51,7 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
> >
<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>
+137 -2
View File
@@ -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,
}),
});
+469 -62
View File
@@ -212,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,
@@ -456,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(),
@@ -469,83 +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, const teamPlayerIds = (team: Match["home"]): string[] =>
ot_count, team && typeof team !== "string" ? (team.players ?? []).map((p) => p.id) : [];
const reportScoreSchema = z.object({
matchId: z.string(),
home_cups: z.number(),
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,
};
}
await pbAdmin.updateMatch(matchId, {
reported_home_cups: home_cups,
reported_away_cups: away_cups,
reported_ot_count: ot_count,
reported_by_team: callerTeamId,
reported_by_player: player.id,
}); });
if (match.lid === -1) { emitServerEvent({ type: "match", matchId, tournamentId: match.tournament.id });
emitServerEvent({ return { success: true as const, finalized: false as const };
type: "match", })
matchId: match.id, );
tournamentId: match.tournament.id
});
return match;
}
const matchWinner = home_cups > away_cups ? match.home : match.away; const matchIdSchema = z.object({ matchId: z.string() });
const matchLoser = home_cups < away_cups ? match.home : match.away;
if (!matchWinner || !matchLoser) throw new Error("Something went wrong");
const { winner, loser } = await pbAdmin.getChildMatches(matchId); export const confirmMatchScore = createServerFn()
.validator(matchIdSchema)
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data: { matchId }, context }) =>
toServerResult(async () => {
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
if (!player?.id) throw new Error("Player not found");
if (winner && winner.reset) { const match = await pbAdmin.getMatch(matchId);
const awayTeamWon = match.away === matchWinner; 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");
if (!awayTeamWon) { const onHome = teamPlayerIds(match.home).includes(player.id);
logger.info("Deleting reset match", { const onAway = teamPlayerIds(match.away).includes(player.id);
resetMatchId: winner.id, if (onHome && onAway)
currentMatchId: match.id, throw new Error(
reason: "not necessary", "You are on both teams in this match and cannot report or confirm its score"
}); );
await pbAdmin.deleteMatch(winner.id); const homeId = teamId(match.home);
emitServerEvent({ const reportedByHome = match.reported_by_team === homeId;
type: "match", const opposingTeam = reportedByHome ? match.away : match.home;
matchId: match.id,
tournamentId: match.tournament.id
});
return match;
}
}
if (winner) { if (!teamPlayerIds(opposingTeam).includes(player.id))
await pbAdmin.updateMatch(winner.id, { throw new Error("Only a player on the other team can confirm this score");
[winner.home_from_lid === match.lid ? "home" : "away"]: matchWinner.id,
status:
(winner.home_from_lid === match.lid && winner.away) ||
(winner.away_from_lid === match.lid && winner.home)
? "ready"
: "tbd",
});
}
if (loser) { return finalizeMatch(matchId, {
await pbAdmin.updateMatch(loser.id, { home_cups: match.reported_home_cups,
[loser.home_from_lid === match.lid ? "home" : "away"]: matchLoser.id, away_cups: match.reported_away_cups,
status: ot_count: match.reported_ot_count ?? 0,
(loser.home_from_lid === match.lid && loser.away) || });
(loser.away_from_lid === match.lid && loser.home) })
? "ready" );
: "tbd",
});
}
emitServerEvent({ export const clearMatchReport = createServerFn()
type: "match", .validator(matchIdSchema)
matchId: match.id, .middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
tournamentId: match.tournament.id .handler(async ({ data: { matchId }, 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");
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 };
}) })
); );
+12 -2
View File
@@ -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,18 +442,30 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
</Group> </Group>
<Stack gap={0}> <Stack gap={0}>
{filteredAndSortedStats.map((stat, index) => ( <InfiniteScroll
<Box key={stat.id}> items={filteredAndSortedStats}
<PlayerListItem batchSize={25}
stat={stat} renderItem={(stat, index) => (
onPlayerClick={handlePlayerClick} <Box key={stat.id}>
mmr={stat.mmr} {index > 0 && <Divider />}
onRegisterViewport={handleRegisterViewport} <PlayerListItem
onUnregisterViewport={handleUnregisterViewport} stat={stat}
/> onPlayerClick={handlePlayerClick}
{index < filteredAndSortedStats.length - 1 && <Divider />} mmr={stat.mmr}
</Box> onRegisterViewport={handleRegisterViewport}
))} onUnregisterViewport={handleUnregisterViewport}
/>
</Box>
)}
loader={
<>
<Divider />
<PlayerListItemSkeleton />
<Divider />
<PlayerListItemSkeleton />
</>
}
/>
</Stack> </Stack>
{filteredAndSortedStats.length === 0 && search && ( {filteredAndSortedStats.length === 0 && search && (
@@ -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;
@@ -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()!); });
return rank;
while (remaining.length > 0) { }, [matches]);
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;
});
if (bestMatchIndex === -1) {
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>
@@ -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,7 +11,7 @@ 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}
src={ src={
@@ -25,27 +25,27 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
: undefined : undefined
} }
radius="md" radius="md"
size={250} size={64}
> >
<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",
@@ -58,8 +58,8 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
})} })}
</Text> </Text>
</Group> </Group>
</Flex> </Stack>
</Stack> </Group>
); );
}; };
@@ -1,50 +1,83 @@
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, Group, Indicator } 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 carouselClasses from "./carousel.module.css";
import ListLink from "@/components/list-link";
import { TreeStructureIcon, UsersIcon, ClockIcon, ListDashes } from "@phosphor-icons/react";
import WizardOrbIcon from "@/components/wizard-orb-icon";
import { isPredictionLocked, isTournamentPredictable } from "@/features/predictions/utils"; import { isPredictionLocked, isTournamentPredictable } from "@/features/predictions/utils";
import { predictionQueries } from "@/features/predictions/queries"; import { predictionQueries } from "@/features/predictions/queries";
import { useServerQuery } from "@/lib/tanstack-query/hooks"; import { useServerQuery } from "@/lib/tanstack-query/hooks";
import TeamListButton from "../upcoming-tournament/team-list-button"; import { Match } from "@/features/matches/types";
import RulesListButton from "../upcoming-tournament/rules-list-button";
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( const isPredictable = useMemo(
() => isTournamentPredictable(tournament), () => isTournamentPredictable(tournament),
@@ -52,8 +85,8 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
); );
const predictionsLocked = useMemo( const predictionsLocked = useMemo(
() => isPredictionLocked(tournament.matches || []), () => isPredictionLocked(matches),
[tournament.matches] [matches]
); );
const { data: myPrediction } = useServerQuery({ const { data: myPrediction } = useServerQuery({
@@ -62,48 +95,43 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
}); });
const hasSubmitted = !!myPrediction?.prediction; 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>
<Group gap={10} px="md" mb={6} align="center" wrap="nowrap"> <SectionHeading label="Final Results" />
<Indicator <Podium tournament={tournament} />
size={8}
color="red"
processing
position="middle-start"
offset={0}
/>
<Text size="xs" fw={700} c="dimmed" tt="uppercase" lts="0.05em">
Live Matches{startedMatches.length > 1 && ` · ${startedMatches.length}`}
</Text>
</Group>
<Carousel
slideSize="90%"
slideGap="xs"
withControls={false}
withIndicators={startedMatches.length > 1}
classNames={{
indicators: carouselClasses.indicators,
indicator: carouselClasses.indicator,
}}
>
{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> </Box>
) : isTournamentOver ? ( )}
<Box px="lg" w="100%">
<Podium tournament={tournament} /> {liveMatches.length > 0 && (
</Box> <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">
@@ -117,45 +145,18 @@ 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}
/> />
{isPredictable && !predictionsLocked && (
<ListLink
label={hasSubmitted ? `Edit Your Prediction` : `Make Your Prediction`}
to={`/tournaments/${tournament.id}/predictions/make`}
Icon={WizardOrbIcon}
/>
)}
{isPredictable && (predictionsLocked || hasSubmitted) && (
<ListLink
label={`View Predictions`}
to={`/tournaments/${tournament.id}/predictions`}
Icon={WizardOrbIcon}
/>
)}
<TeamListButton teams={tournament.teams || []} isRegional={tournament.regional} />
<RulesListButton tournamentId={tournament.id} />
</Box> </Box>
</Stack> </Stack>
); );
}; };
export default StartedTournament; export default StartedTournament;
@@ -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,70 +1,78 @@
import { Box, Card, Center, Divider, Group, Skeleton, Stack } from "@mantine/core"; import { Box, Card, Divider, Group, SimpleGrid, Skeleton, Stack } from "@mantine/core";
const MatchCardSkeleton = ({ minWidth }: { minWidth: string }) => (
<Card
withBorder
radius="md"
p="md"
style={{ minWidth, flex: "0 0 auto" }}
>
<Stack gap="md">
<Group justify="space-between">
<Skeleton height={14} width="30%" />
<Skeleton height={20} width={40} radius="xl" />
</Group>
<Stack gap="sm">
<Group>
<Skeleton height={40} width={40} radius="sm" />
<Skeleton height={16} width="40%" />
<Box ml="auto">
<Skeleton height={24} width={30} />
</Box>
</Group>
<Group>
<Skeleton height={40} width={40} radius="sm" />
<Skeleton height={16} width="40%" />
<Box ml="auto">
<Skeleton height={24} width={30} />
</Box>
</Group>
</Stack>
</Stack>
</Card>
);
const StartedTournamentSkeleton = () => { const StartedTournamentSkeleton = () => {
return ( return (
<Stack gap="lg"> <Stack gap="lg">
{/* Header skeleton */} {/* Compact header skeleton */}
<Stack px="md" align="center" gap="xs"> <Group px="md" gap="md" wrap="nowrap" align="center">
<Skeleton height={268} width={268} radius="lg" /> <Skeleton height={64} width={64} radius="md" />
<Skeleton height={16} width="55%" /> <Stack gap={8} style={{ flex: 1 }}>
</Stack> <Skeleton height={14} width="55%" />
<Skeleton height={14} width="40%" />
</Stack>
</Group>
{/* Match carousel skeleton */} {/* Live carousel skeleton */}
<Box> <Box>
<Group gap="xs" px="xl"> <Skeleton height={12} width={90} ml="md" mb={10} />
{Array.from({ length: 2 }).map((_, index) => ( <Group gap="xs" pl="md" wrap="nowrap" style={{ overflow: "hidden" }}>
<Card <MatchCardSkeleton minWidth="88%" />
key={index} <MatchCardSkeleton minWidth="88%" />
withBorder
radius="lg"
p="lg"
style={{ minWidth: "95%", flex: "0 0 auto" }}
>
<Stack gap="md">
{/* Match header */}
<Group justify="space-between">
<Skeleton height={14} width="30%" />
<Skeleton height={20} width={60} radius="xl" />
</Group>
{/* Teams */}
<Stack gap="sm">
<Group>
<Skeleton height={32} width={32} radius="sm" />
<Skeleton height={16} width="40%" />
<Box ml="auto">
<Skeleton height={24} width={30} />
</Box>
</Group>
<Center>
<Skeleton height={14} width={20} />
</Center>
<Group>
<Skeleton height={32} width={32} radius="sm" />
<Skeleton height={16} width="40%" />
<Box ml="auto">
<Skeleton height={24} width={30} />
</Box>
</Group>
</Stack>
</Stack>
</Card>
))}
</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>
); );
}; };
export default StartedTournamentSkeleton; export default StartedTournamentSkeleton;
@@ -175,25 +175,27 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
</Alert> </Alert>
)} )}
{!tournament.regional && <Podium tournament={tournament} />} {!tournament.regional && <Podium tournament={tournament} />}
{hasGroupStage && ( <Stack gap={0}>
{hasGroupStage && (
<ListLink
label={`View Groups`}
to={`/tournaments/${tournament.id}/groups`}
Icon={ListDashes}
/>
)}
<ListLink <ListLink
label={`View Groups`} label={`View Bracket`}
to={`/tournaments/${tournament.id}/groups`} to={`/tournaments/${tournament.id}/bracket`}
Icon={ListDashes} Icon={TreeStructureIcon}
/> />
)} {isTournamentPredictable(tournament) && (
<ListLink <ListLink
label={`View Bracket`} label={`View Predictions`}
to={`/tournaments/${tournament.id}/bracket`} to={`/tournaments/${tournament.id}/predictions`}
Icon={TreeStructureIcon} Icon={WizardOrbIcon}
/> />
{isTournamentPredictable(tournament) && ( )}
<ListLink </Stack>
label={`View Predictions`}
to={`/tournaments/${tournament.id}/predictions`}
Icon={WizardOrbIcon}
/>
)}
{renderTeamStatsTable()} {renderTeamStatsTable()}
</Stack> </Stack>
</Container> </Container>
@@ -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,71 +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}
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}
>
<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,121 +55,109 @@ 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}
</Text>
)}
<Card <Box>
withBorder <SectionHeading
p="lg" label="Enrollment"
style={{ action={
borderRadius: isEnrollmentOpen ? (
"2px 2px var(--mantine-radius-lg) var(--mantine-radius-lg)", <Countdown date={enrollmentDeadline} />
borderTop: "3px solid var(--mantine-primary-color-filled)", ) : undefined
}} }
> />
<Stack gap="xs"> <Box px="md">
<Group gap="xs" align="center"> <Paper withBorder p="md">
<UsersIcon size={16} /> <Stack gap="sm">
<Title mt={4} order={5}> {!isUserEnrolled && !isEnrollmentOpen && (
Enrollment <Text fw={600} c="dimmed" size="sm">
</Title> Enrollment has been closed for this tournament.
{isEnrollmentOpen && ( </Text>
<Box ml="auto">
<Countdown
date={enrollmentDeadline}
label="Time left"
color="yellow"
/>
</Box>
)} )}
</Group>
{!isUserEnrolled && !isEnrollmentOpen && ( {!isUserEnrolled && isEnrollmentOpen && !isFreeAgent && (
<Text fw={600} c="dimmed" size="sm"> <>
Enrollment has been closed for this tournament. {!tournament.regional && (
</Text> <>
)} <EnrollTeam
{!isUserEnrolled && isEnrollmentOpen && !isFreeAgent && (
<>
{!tournament.regional && (
<>
<EnrollTeam
tournamentId={tournament.id}
onSubmit={handleSubmit}
/>
<Divider my={0} label="or" />
</>
)}
<EnrollFreeAgent
tournamentId={tournament.id}
isRegional={tournament.regional}
/>
</>
)}
{isUserEnrolled && (
<>
<Suspense fallback={<TeamCardSkeleton />}>
{tournament.regional === true ? (
<RegionalTeamCard teamId={userTeam.id} />
) : (
<TeamCard teamId={userTeam.id} />
)}
</Suspense>
{tournament.regional !== true && (
<>
<UpdateTeam tournamentId={tournament.id} teamId={userTeam.id} />
{isEnrollmentOpen && (
<UnenrollTeam
tournamentId={tournament.id} tournamentId={tournament.id}
teamId={userTeam.id}
onSubmit={handleSubmit} onSubmit={handleSubmit}
/> />
)} <Divider my={0} label="or" />
</> </>
)} )}
</> <EnrollFreeAgent
)} tournamentId={tournament.id}
isRegional={tournament.regional}
/>
</>
)}
{ {isUserEnrolled && (
isFreeAgent && isEnrollmentOpen && ( <>
<Suspense fallback={<TeamCardSkeleton />}>
{tournament.regional === true ? (
<RegionalTeamCard teamId={userTeam.id} />
) : (
<TeamCard teamId={userTeam.id} />
)}
</Suspense>
{tournament.regional !== true && (
<>
<UpdateTeam tournamentId={tournament.id} teamId={userTeam.id} />
{isEnrollmentOpen && (
<UnenrollTeam
tournamentId={tournament.id}
teamId={userTeam.id}
onSubmit={handleSubmit}
/>
)}
</>
)}
</>
)}
{isFreeAgent && isEnrollmentOpen && (
<EnrolledFreeAgent <EnrolledFreeAgent
tournamentId={tournament.id} tournamentId={tournament.id}
isRegional={tournament.regional} isRegional={tournament.regional}
/> />
) )}
} </Stack>
</Paper>
</Stack> </Box>
</Card> </Box>
</Stack>
<Box> <Box>
<Divider /> <Divider mb="sm" />
{isAdmin && ( <Box px="md">
<ListLink <SimpleGrid cols={3} spacing="sm" verticalSpacing="sm">
label={`Manage ${tournament.name}`} {isAdmin && (
to={`/admin/tournaments/${tournament.id}`} <NavTile
Icon={UsersIcon} label="Manage"
/> Icon={UsersIcon}
)} onClick={() =>
<ListLink navigate({ to: `/admin/tournaments/${tournament.id}` })
label={`View Bracket`} }
to={`/tournaments/${tournament.id}/bracket`} />
Icon={TreeStructureIcon} )}
disabled <NavTile label="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} /> ) : (
) : ( <EnrolledPlayersListButton tournamentId={tournament.id} />
<EnrolledPlayersListButton tournamentId={tournament.id} /> )
) ) : (
) : ( <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,44 +1,38 @@
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={318} width={318} radius="lg" /> <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 <Skeleton height={12} width={90} ml="md" mb={10} />
withBorder <Box px="md">
p="lg" <Paper withBorder p="md">
style={{ <Stack gap="sm">
borderRadius: <Skeleton height={36} radius="sm" />
"2px 2px var(--mantine-radius-lg) var(--mantine-radius-lg)", <Skeleton height={36} radius="sm" />
}} </Stack>
> </Paper>
<Skeleton height={14} width="80%" mb={16} /> </Box>
<Group mb="sm" gap="xs" align="center"> </Box>
<Skeleton height={32} width={16} />
<Skeleton height={32} width="20%" /> <Box px="md">
<Box ml="auto"> <Divider mb="sm" />
<Skeleton height={32} width={80} radius="sm" /> <SimpleGrid cols={3} spacing="sm" verticalSpacing="sm">
</Box> {Array.from({ length: 3 }).map((_, index) => (
</Group> <Skeleton key={index} height={72} radius="md" />
<Group mb="sm" gap="xs" align="center"> ))}
<Skeleton height={32} width={16} /> </SimpleGrid>
<Skeleton height={32} width="20%" /> </Box>
<Box ml="auto">
<Skeleton height={32} width={80} radius="sm" />
</Box>
</Group>
</Card>
</Stack>
</Stack> </Stack>
); );
}; };
export default UpcomingTournamentSkeleton; export default UpcomingTournamentSkeleton;
@@ -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} />
@@ -29,4 +25,4 @@ const TeamListButton: React.FC<TeamListButtonProps> = ({ teams, isRegional }) =>
) )
} }
export default TeamListButton; export default TeamListButton;
+11 -7
View File
@@ -10,6 +10,7 @@ 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])
@@ -69,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);
@@ -816,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],
@@ -838,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)));
@@ -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]);
}
+144
View File
@@ -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,
};
}
+9
View File
@@ -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)
+4 -1
View File
@@ -8,6 +8,7 @@ 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 { createPredictionsService } from "./services/predictions";
import { createPushService } from "./services/push";
class PocketBaseAdminClient { class PocketBaseAdminClient {
private pb: PocketBase; private pb: PocketBase;
@@ -50,6 +51,7 @@ class PocketBaseAdminClient {
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, createPredictionsService(this.pb));
Object.assign(this, createPushService(this.pb));
this.authPromise = this.authenticate(); this.authPromise = this.authenticate();
this.authPromise.then(() => { this.authPromise.then(() => {
@@ -129,7 +131,8 @@ interface AdminClient
ReturnType<typeof createActivitiesService>, ReturnType<typeof createActivitiesService>,
ReturnType<typeof createBadgesService>, ReturnType<typeof createBadgesService>,
ReturnType<typeof createGroupsService>, ReturnType<typeof createGroupsService>,
ReturnType<typeof createPredictionsService> { ReturnType<typeof createPredictionsService>,
ReturnType<typeof createPushService> {
authPromise: Promise<void>; authPromise: Promise<void>;
} }
+13 -2
View File
@@ -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 });
+71
View File
@@ -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);
},
});
+5 -3
View File
@@ -6,9 +6,6 @@ import { Badge, BadgeInfo, BadgeProgress, EarnedBadge } from "@/features/badges/
import { Prediction } from "@/features/predictions/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,
@@ -58,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,
}; };
} }
+111
View File
@@ -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 };
}