From 413e238194a940da792c0440e0ef45a3edfff719 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Sep 2024 17:58:16 +0000 Subject: [PATCH 01/93] [npm]: Bump react and @types/react Bumps [react](https://github.com/facebook/react/tree/HEAD/packages/react) and [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react). These dependencies needed to be updated together. Updates `react` from 18.2.0 to 18.3.1 - [Release notes](https://github.com/facebook/react/releases) - [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md) - [Commits](https://github.com/facebook/react/commits/v18.3.1/packages/react) Updates `@types/react` from 18.2.54 to 18.3.8 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react) --- updated-dependencies: - dependency-name: react dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: "@types/react" dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package-lock.json | 40 ++++++++++++++-------------------------- package.json | 4 ++-- 2 files changed, 16 insertions(+), 28 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3b8430fc..855a7d58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "next-pwa": "^5.6.0", "next-seo": "^6.5.0", "nodemailer": "^6.9.14", - "react": "18.2.0", + "react": "18.3.1", "react-beautiful-dnd": "^13.1.1", "react-bootstrap": "^2.10.0", "react-chartjs-2": "^5.2.0", @@ -54,7 +54,7 @@ "devDependencies": { "@types/formidable": "^3.4.5", "@types/node": "^20.11.16", - "@types/react": "^18.2.54", + "@types/react": "^18.3.8", "autoprefixer": "^10.4.20", "cross-env": "^7.0.3", "daisyui": "^4.7.3", @@ -3264,12 +3264,11 @@ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" }, "node_modules/@types/react": { - "version": "18.2.54", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.54.tgz", - "integrity": "sha512-039k+vrVJymDoe2y+HLk3O3oI3sa+C8KNjuDKofqrIJK26ramnqLNj9VJTaxAzFGMvpW/79HrrAJapHzpQ9fGQ==", + "version": "18.3.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.8.tgz", + "integrity": "sha512-syBUrW3/XpnW4WJ41Pft+I+aPoDVbrBVQGEnbD7NijDGlVC+8gV/XKRY+7vMDlfPpbwYt0l1vd/Sj8bJGMbs9Q==", "dependencies": { "@types/prop-types": "*", - "@types/scheduler": "*", "csstype": "^3.0.2" } }, @@ -3313,11 +3312,6 @@ "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==" }, - "node_modules/@types/scheduler": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", - "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==" - }, "node_modules/@types/send": { "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", @@ -9179,9 +9173,9 @@ } }, "node_modules/react": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", - "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "dependencies": { "loose-envify": "^1.1.0" }, @@ -13958,12 +13952,11 @@ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" }, "@types/react": { - "version": "18.2.54", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.54.tgz", - "integrity": "sha512-039k+vrVJymDoe2y+HLk3O3oI3sa+C8KNjuDKofqrIJK26ramnqLNj9VJTaxAzFGMvpW/79HrrAJapHzpQ9fGQ==", + "version": "18.3.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.8.tgz", + "integrity": "sha512-syBUrW3/XpnW4WJ41Pft+I+aPoDVbrBVQGEnbD7NijDGlVC+8gV/XKRY+7vMDlfPpbwYt0l1vd/Sj8bJGMbs9Q==", "requires": { "@types/prop-types": "*", - "@types/scheduler": "*", "csstype": "^3.0.2" } }, @@ -14007,11 +14000,6 @@ "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==" }, - "@types/scheduler": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", - "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==" - }, "@types/send": { "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", @@ -18238,9 +18226,9 @@ } }, "react": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", - "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "requires": { "loose-envify": "^1.1.0" } diff --git a/package.json b/package.json index c10ae830..73fe7f51 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "next-pwa": "^5.6.0", "next-seo": "^6.5.0", "nodemailer": "^6.9.14", - "react": "18.2.0", + "react": "18.3.1", "react-beautiful-dnd": "^13.1.1", "react-bootstrap": "^2.10.0", "react-chartjs-2": "^5.2.0", @@ -57,7 +57,7 @@ "devDependencies": { "@types/formidable": "^3.4.5", "@types/node": "^20.11.16", - "@types/react": "^18.2.54", + "@types/react": "^18.3.8", "autoprefixer": "^10.4.20", "cross-env": "^7.0.3", "daisyui": "^4.7.3", From 0bf077d99e5bd36a7a7f110a07b941a95470c642 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Oct 2024 17:30:52 +0000 Subject: [PATCH 02/93] [npm]: Bump typescript from 5.0.4 to 5.6.2 Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.0.4 to 5.6.2. - [Release notes](https://github.com/microsoft/TypeScript/releases) - [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml) - [Commits](https://github.com/microsoft/TypeScript/compare/v5.0.4...v5.6.2) --- updated-dependencies: - dependency-name: typescript dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package-lock.json | 10 +++++----- package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6889733a..772abdde 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,7 +50,7 @@ "string-similarity-js": "^2.1.4", "ts-node": "^10.9.2", "tsx": "^4.19.1", - "typescript": "5.0.4" + "typescript": "5.6.2" }, "devDependencies": { "@jest/globals": "^29.7.0", @@ -13132,15 +13132,15 @@ } }, "node_modules/typescript": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", - "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz", + "integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=12.20" + "node": ">=14.17" } }, "node_modules/unbox-primitive": { diff --git a/package.json b/package.json index 4ee32052..faf250dd 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "string-similarity-js": "^2.1.4", "ts-node": "^10.9.2", "tsx": "^4.19.1", - "typescript": "5.0.4" + "typescript": "5.6.2" }, "devDependencies": { "@jest/globals": "^29.7.0", From 185ce1de6ff2445c99d593063a10deacf912f70b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 20 Oct 2024 23:47:27 +0000 Subject: [PATCH 03/93] [npm]: Bump cloc from 2.0.0-cloc to 2.11.0 Bumps [cloc](https://github.com/kentcdodds/cloc) from 2.0.0-cloc to 2.11.0. - [Release notes](https://github.com/kentcdodds/cloc/releases) - [Commits](https://github.com/kentcdodds/cloc/commits/v2.11.0) --- updated-dependencies: - dependency-name: cloc dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3eaac99b..8b242441 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "bootstrap": "^5.3.3", "browser-image-compression": "^2.0.2", "bson": "^6.8.0", - "cloc": "^2.0.0-cloc", + "cloc": "^2.11.0", "dotenv": "^16.4.5", "eslint": "8.39.0", "eslint-config-next": "13.3.1", @@ -5376,9 +5376,9 @@ } }, "node_modules/cloc": { - "version": "2.0.0-cloc", - "resolved": "https://registry.npmjs.org/cloc/-/cloc-2.0.0-cloc.tgz", - "integrity": "sha512-hy6aS/5QbKlxyasequhfAOHOBzV1QpBeA01P8hnjnlV++OaRXazX3YLv5Mpqw0+Vh4FV4kiFhKYSV2x6CAKlpQ==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/cloc/-/cloc-2.11.0.tgz", + "integrity": "sha512-+mxuCHo7ESOQadlsyMjmPZ4hGBtvQzmNGHfLdBNvXKbnRhtmOTslU4XF2cyFSaOCHaaF26ba2CGjU6lpeIFB0w==", "bin": { "cloc": "lib/cloc" } diff --git a/package.json b/package.json index fdb87736..4bf588d1 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "bootstrap": "^5.3.3", "browser-image-compression": "^2.0.2", "bson": "^6.8.0", - "cloc": "^2.0.0-cloc", + "cloc": "^2.11.0", "dependencies": "^0.0.1", "dotenv": "^16.4.5", "eslint": "8.39.0", From 12d1019d7a4d939f9a769f12647309a2141d5c62 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 20 Oct 2024 23:47:27 +0000 Subject: [PATCH 04/93] [npm]: Bump jose from 5.9.3 to 5.9.6 Bumps [jose](https://github.com/panva/jose) from 5.9.3 to 5.9.6. - [Release notes](https://github.com/panva/jose/releases) - [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md) - [Commits](https://github.com/panva/jose/compare/v5.9.3...v5.9.6) --- updated-dependencies: - dependency-name: jose dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3eaac99b..5bd83bb2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "eslint": "8.39.0", "eslint-config-next": "13.3.1", "formidable": "^3.5.1", - "jose": "^5.9.3", + "jose": "^5.9.6", "levenary": "^1.1.1", "minimongo": "^6.19.0", "mongodb": "^5.9.2", @@ -9490,9 +9490,9 @@ } }, "node_modules/jose": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.3.tgz", - "integrity": "sha512-egLIoYSpcd+QUF+UHgobt5YzI2Pkw/H39ou9suW687MY6PmCwPmkNV/4TNjn1p2tX5xO3j0d0sq5hiYE24bSlg==", + "version": "5.9.6", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz", + "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==", "funding": { "url": "https://github.com/sponsors/panva" } diff --git a/package.json b/package.json index fdb87736..54a3d0fd 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "eslint": "8.39.0", "eslint-config-next": "13.3.1", "formidable": "^3.5.1", - "jose": "^5.9.3", + "jose": "^5.9.6", "levenary": "^1.1.1", "minimongo": "^6.19.0", "mongodb": "^5.9.2", From f8775a6b4530207c3eae48df259d499a03f09cb2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Oct 2024 17:24:04 +0000 Subject: [PATCH 05/93] [npm]: Bump socket.io from 4.8.0 to 4.8.1 Bumps [socket.io](https://github.com/socketio/socket.io) from 4.8.0 to 4.8.1. - [Release notes](https://github.com/socketio/socket.io/releases) - [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md) - [Commits](https://github.com/socketio/socket.io/compare/socket.io@4.8.0...socket.io@4.8.1) --- updated-dependencies: - dependency-name: socket.io dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 054683d0..ca0af6b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,7 +46,7 @@ "react-qr-code": "^2.0.15", "resend": "^4.0.0", "slack": "^11.0.2", - "socket.io": "^4.8.0", + "socket.io": "^4.8.1", "socket.io-client": "^4.7.2", "string-similarity-js": "^2.1.4", "ts-node": "^10.9.2", @@ -12353,9 +12353,9 @@ } }, "node_modules/socket.io": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.0.tgz", - "integrity": "sha512-8U6BEgGjQOfGz3HHTYaC/L1GaxDCJ/KM0XTkJly0EhZ5U/du9uNEZy4ZgYzEzIqlx2CMm25CrCqr1ck899eLNA==", + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", + "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==", "dependencies": { "accepts": "~1.3.4", "base64id": "~2.0.0", diff --git a/package.json b/package.json index 0d87a484..06fdede6 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "react-qr-code": "^2.0.15", "resend": "^4.0.0", "slack": "^11.0.2", - "socket.io": "^4.8.0", + "socket.io": "^4.8.1", "socket.io-client": "^4.7.2", "string-similarity-js": "^2.1.4", "ts-node": "^10.9.2", From 70073c52fa52b9f9221d8cb3ce683a1773ef87ff Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 Nov 2024 01:55:16 +0000 Subject: [PATCH 06/93] [npm]: Bump the npm_and_yarn group with 3 updates Bumps the npm_and_yarn group with 3 updates: [cookie](https://github.com/jshttp/cookie), [next-auth](https://github.com/nextauthjs/next-auth) and [engine.io](https://github.com/socketio/socket.io). Updates `cookie` from 0.4.2 to 0.7.2 - [Release notes](https://github.com/jshttp/cookie/releases) - [Commits](https://github.com/jshttp/cookie/compare/v0.4.2...v0.7.2) Updates `next-auth` from 4.24.7 to 4.24.10 - [Release notes](https://github.com/nextauthjs/next-auth/releases) - [Commits](https://github.com/nextauthjs/next-auth/compare/next-auth@4.24.7...next-auth@4.24.10) Updates `engine.io` from 6.6.1 to 6.6.2 - [Release notes](https://github.com/socketio/socket.io/releases) - [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md) - [Commits](https://github.com/socketio/socket.io/compare/engine.io@6.6.1...engine.io@6.6.2) --- updated-dependencies: - dependency-name: cookie dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: next-auth dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: engine.io dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] --- package-lock.json | 38 +++++++++++++++++--------------------- package.json | 2 +- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/package-lock.json b/package-lock.json index d24d7a03..1db0a47d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ "minimongo": "^6.19.0", "mongodb": "^5.0.0", "next": "^14.2.13", - "next-auth": "^4.22.1", + "next-auth": "^4.24.10", "next-pwa": "^5.6.0", "next-seo": "^6.6.0", "nodemailer": "^6.9.14", @@ -5264,9 +5264,9 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" }, "node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "engines": { "node": ">= 0.6" } @@ -5972,16 +5972,16 @@ } }, "node_modules/engine.io": { - "version": "6.6.1", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.1.tgz", - "integrity": "sha512-NEpDCw9hrvBW+hVEOK4T7v0jFJ++KgtPl4jKFwsZVfG1XhS0dCrSb3VMb9gPAd7VAdW52VT1EnaNiU2vM8C0og==", + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.2.tgz", + "integrity": "sha512-gmNvsYi9C8iErnZdVcJnvCpSKbWTt1E8+JZo8b+daLninywUWi5NQ5STSHZ9rFjFO7imNcvb8Pc5pe/wMR5xEw==", "dependencies": { "@types/cookie": "^0.4.1", "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "accepts": "~1.3.4", "base64id": "2.0.0", - "cookie": "~0.4.1", + "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", @@ -6011,14 +6011,6 @@ "node": ">=10.0.0" } }, - "node_modules/engine.io/node_modules/cookie": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", - "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/enhanced-resolve": { "version": "5.17.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", @@ -10072,13 +10064,13 @@ } }, "node_modules/next-auth": { - "version": "4.24.7", - "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.7.tgz", - "integrity": "sha512-iChjE8ov/1K/z98gdKbn2Jw+2vLgJtVV39X+rCP5SGnVQuco7QOr19FRNGMIrD8d3LYhHWV9j9sKLzq1aDWWQQ==", + "version": "4.24.10", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.10.tgz", + "integrity": "sha512-8NGqiRO1GXBcVfV8tbbGcUgQkAGsX4GRzzXXea4lDikAsJtD5KiEY34bfhUOjHLvr6rT6afpcxw2H8EZqOV6aQ==", "dependencies": { "@babel/runtime": "^7.20.13", "@panva/hkdf": "^1.0.2", - "cookie": "^0.5.0", + "cookie": "^0.7.0", "jose": "^4.15.5", "oauth": "^0.9.15", "openid-client": "^5.4.0", @@ -10087,12 +10079,16 @@ "uuid": "^8.3.2" }, "peerDependencies": { - "next": "^12.2.5 || ^13 || ^14", + "@auth/core": "0.34.2", + "next": "^12.2.5 || ^13 || ^14 || ^15", "nodemailer": "^6.6.5", "react": "^17.0.2 || ^18", "react-dom": "^17.0.2 || ^18" }, "peerDependenciesMeta": { + "@auth/core": { + "optional": true + }, "nodemailer": { "optional": true } diff --git a/package.json b/package.json index 01909ff1..c581fdf8 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "minimongo": "^6.19.0", "mongodb": "^5.0.0", "next": "^14.2.13", - "next-auth": "^4.22.1", + "next-auth": "^4.24.10", "next-pwa": "^5.6.0", "next-seo": "^6.6.0", "nodemailer": "^6.9.14", From cc20e0a39e8ddbd36acf1e0656814a7a6ea4623f Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Sun, 17 Nov 2024 19:14:32 -0500 Subject: [PATCH 07/93] Removed PWA --- next.config.js | 7 +------ pages/_document.tsx | 1 - 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/next.config.js b/next.config.js index 088f0e41..edfa13ed 100644 --- a/next.config.js +++ b/next.config.js @@ -1,8 +1,3 @@ -const withPwa = require("next-pwa")({ - dest: "public", - cacheOnFrontEndNav: false, -}); - /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: false, @@ -23,4 +18,4 @@ const nextConfig = { } }; -module.exports = withPwa(nextConfig); +module.exports = nextConfig; diff --git a/pages/_document.tsx b/pages/_document.tsx index b16cb5db..400a581d 100644 --- a/pages/_document.tsx +++ b/pages/_document.tsx @@ -5,7 +5,6 @@ export default function Document() { return ( - From 249a6423be679d8ac5eb18a6c23368aaae05d6cf Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Mon, 18 Nov 2024 21:24:02 -0500 Subject: [PATCH 08/93] Removed PWA-specific files and un-PWAified comp page --- components/PwaConfig.tsx | 50 ------ components/competition/CompHeaderCard.tsx | 12 +- components/competition/CompetitionIndex.tsx | 65 +------ components/competition/DownloadModal.tsx | 165 ------------------ components/competition/MatchScheduleCard.tsx | 12 +- lib/client/offlineUtils.ts | 154 ---------------- lib/client/useIsOnline.ts | 34 ---- lib/client/useOfflineCompFromRouter.ts | 66 ------- pages/_offline.tsx | 17 -- pages/offline/[compId]/index.tsx | 17 -- pages/offline/[compId]/pit/[pitReportId].tsx | 18 -- .../[compId]/quant/[quantReportId].tsx | 24 --- pages/offline/[compId]/stats.tsx | 19 -- .../offline/[compId]/subjective/[matchId].tsx | 20 --- pages/offline/index.tsx | 65 ------- 15 files changed, 6 insertions(+), 732 deletions(-) delete mode 100644 components/PwaConfig.tsx delete mode 100644 components/competition/DownloadModal.tsx delete mode 100644 lib/client/offlineUtils.ts delete mode 100644 lib/client/useIsOnline.ts delete mode 100644 lib/client/useOfflineCompFromRouter.ts delete mode 100644 pages/_offline.tsx delete mode 100644 pages/offline/[compId]/index.tsx delete mode 100644 pages/offline/[compId]/pit/[pitReportId].tsx delete mode 100644 pages/offline/[compId]/quant/[quantReportId].tsx delete mode 100644 pages/offline/[compId]/stats.tsx delete mode 100644 pages/offline/[compId]/subjective/[matchId].tsx delete mode 100644 pages/offline/index.tsx diff --git a/components/PwaConfig.tsx b/components/PwaConfig.tsx deleted file mode 100644 index bd685139..00000000 --- a/components/PwaConfig.tsx +++ /dev/null @@ -1,50 +0,0 @@ -export default function PwaConfig() { - return ( - <> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {/* apple splash screen images */} - {/* - - - - - - */} - - ); -} \ No newline at end of file diff --git a/components/competition/CompHeaderCard.tsx b/components/competition/CompHeaderCard.tsx index 2a677f74..02b44797 100644 --- a/components/competition/CompHeaderCard.tsx +++ b/components/competition/CompHeaderCard.tsx @@ -3,9 +3,7 @@ import { Competition } from "@/lib/Types"; import { BiExport } from "react-icons/bi"; import { MdAutoGraph, MdQueryStats, MdCoPresent } from "react-icons/md"; -export default function CompHeaderCard(props: { comp: Competition | undefined, openDownloadModal: () => void, isOnline: boolean }) { - const { comp, openDownloadModal, isOnline } = props; - +export default function CompHeaderCard({ comp }: { comp: Competition | undefined }) { return (
@@ -13,12 +11,6 @@ export default function CompHeaderCard(props: { comp: Competition | undefined, o

{comp?.name}

-
@@ -31,7 +23,7 @@ export default function CompHeaderCard(props: { comp: Competition | undefined, o
Stats diff --git a/components/competition/CompetitionIndex.tsx b/components/competition/CompetitionIndex.tsx index aac7eba4..869f8e7b 100644 --- a/components/competition/CompetitionIndex.tsx +++ b/components/competition/CompetitionIndex.tsx @@ -35,13 +35,10 @@ import useInterval from "@/lib/client/useInterval"; import { NotLinkedToTba, download, getIdsInProgressFromTimestamps } from "@/lib/client/ClientUtils"; import { games } from "@/lib/games"; import { defaultGameId } from "@/lib/client/GameId"; -import { saveCompToLocalStorage, updateCompInLocalStorage } from "@/lib/client/offlineUtils"; import { toDict } from "@/lib/client/ClientUtils"; import { BiExport } from "react-icons/bi"; -import DownloadModal from "./DownloadModal"; import EditMatchModal from "./EditMatchModal"; import { BSON } from "bson"; -import useIsOnline from "@/lib/client/useIsOnline"; import CompHeaderCard from "./CompHeaderCard"; import InsightsAndSettingsCard from "./InsightsAndSettingsCard"; import MatchScheduleCard from "./MatchScheduleCard"; @@ -131,10 +128,6 @@ export default function CompetitionIndex(props: { const [newCompName, setNewCompName] = useState(comp?.name); const [newCompTbaId, setNewCompTbaId] = useState(comp?.tbaId); - const [downloadModalOpen, setDownloadModalOpen] = useState(false); - - const isOnline = useIsOnline(); - useEffect(() => { if (!fallbackData) { console.log("No fallback data provided"); @@ -422,19 +415,6 @@ export default function CompetitionIndex(props: { ); } catch (e) { console.error(e); - - // Save match to local storage - const savedComp = getSavedCompetition(); - if (!savedComp) return; - - const match = new Match(Number(matchNumber), "", "", Date.now(), MatchType.Qualifying, - blueAlliance as number[], redAlliance as number[]); - match._id = new BSON.ObjectId().toHexString(); - - savedComp.matches[match._id] = match; - savedComp?.comp.matches.push(match._id); - - saveCompToLocalStorage(savedComp); } location.reload(); @@ -505,7 +485,6 @@ export default function CompetitionIndex(props: { (document.getElementById("edit-match-modal") as HTMLDialogElement | undefined)?.showModal(); setMatchBeingEdited(match._id); - closeDownloadModal(); } useInterval(() => loadMatches(true), 5000); @@ -564,41 +543,11 @@ export default function CompetitionIndex(props: { return savedComp; } - function setSavedCompetition(comp: SavedCompetition) { - saveCompToLocalStorage(comp); - - location.reload(); - } - - useEffect(() => { - const comp = getSavedCompetition(); - if (comp) { - console.log("Saving competition to local storage... Comp:", comp); - saveCompToLocalStorage(comp); - } - }, [comp, matches, reports, pitreports, subjectiveReports, usersById]); - - // Offline mode - useEffect(() => { - const savedComp = getSavedCompetition(); - - if (savedComp) - saveCompToLocalStorage(savedComp); - }, [comp, matches, reports, pitreports, subjectiveReports, usersById]); - - function openDownloadModal() { - setDownloadModalOpen(true); - } - - function closeDownloadModal() { - setDownloadModalOpen(false); - } - return ( <>
- + } - { (team && comp && downloadModalOpen) && - setDownloadModalOpen(false)} - team={team} - comp={comp} - getSavedComp={getSavedCompetition} - setSavedComp={setSavedCompetition} - /> - }
); diff --git a/components/competition/DownloadModal.tsx b/components/competition/DownloadModal.tsx deleted file mode 100644 index 7d4886d7..00000000 --- a/components/competition/DownloadModal.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import ClientApi from "@/lib/api/ClientApi"; -import { download } from "@/lib/client/ClientUtils"; -import { mergeSavedComps, updateCompInLocalStorage } from "@/lib/client/offlineUtils"; -import { useCurrentSession } from "@/lib/client/useCurrentSession"; -import useIsOnline from "@/lib/client/useIsOnline"; -import { SavedCompetition, League, Team, Competition, Report, Pitreport, SubjectiveReport } from "@/lib/Types"; -import { useState, ChangeEvent } from "react"; -import Loading from "../Loading"; -import { IDetectedBarcode, Scanner } from "@yudiel/react-qr-scanner"; - -const api = new ClientApi(); - -export default function DownloadModal(props: { - open: boolean, - close: () => void, - team: Team, - comp: Competition, - getSavedComp: () => SavedCompetition | undefined - setSavedComp: (comp: SavedCompetition) => void - }) { - const { team, comp } = props; - - const { session, status } = useCurrentSession(); - const isManager = session?.user?._id - ? team?.owners.includes(session.user?._id) - : false; - - const [uploadedComp, setUploadedComp] = useState(undefined); - const [uploadingToCloud, setUploadingToCloud] = useState(false); - - const isOnline = useIsOnline(); - - function downloadJson() { - const savedComp = props.getSavedComp(); - download(`${team?.league ?? League.FRC}${team?.number}-${comp?.name}.json`, JSON.stringify(savedComp), "application/json"); - } - - function uploadCompFromFile(e: ChangeEvent) { - console.log("Uploading comp..."); - const file = e.target.files?.[0]; - - if (!file) return; - - const reader = new FileReader(); - reader.onload = (e) => { - const data = JSON.parse(e.target?.result as string) as SavedCompetition; - setUploadedComp(data); - console.log("Uploaded comp", data); - } - - reader.readAsText(file); - } - - function importComp() { - const current = props.getSavedComp(); - - if (!current || !uploadedComp) return; - - mergeSavedComps(current, uploadedComp); - props.setSavedComp(current); - } - - function uploadCompToCloud() { - const savedComp = props.getSavedComp(); - if (!savedComp) return; - - setUploadingToCloud(true); - try { - api.uploadSavedComp(savedComp); - } - catch (e) { - console.error(e); - } - setUploadingToCloud(false); - } - - /** - * Untested because my laptop doesn't have a camera. - * - Renato - */ - function readQrCode(code: IDetectedBarcode[]) { - console.log("Found QR code..."); - if (!confirm("Scan QR code?")) - return; - - console.log("Reading QR code..."); - type QrData = { - quantReport?: Report, - pitReport?: Pitreport, - subjectiveReport?: SubjectiveReport - } - - const data = code[0].rawValue; - const qrData = JSON.parse(data) as QrData; - console.log("Read QR code"); - - if (!qrData.quantReport && !qrData.pitReport && !qrData.subjectiveReport) { - console.error("Invalid QR code data", qrData); - return; - } - - if (!props.comp._id) - return; - - updateCompInLocalStorage(props.comp._id, (comp) => { - if (qrData.quantReport) { - if (!qrData.quantReport._id) { - console.error("Quant report has no _id", qrData.quantReport); - return; - } - - comp.quantReports[qrData.quantReport._id] = qrData.quantReport; - } - - if (qrData.pitReport) { - if (!qrData.pitReport._id) { - console.error("Pit report has no _id", qrData.pitReport); - return; - } - - comp.pitReports[qrData.pitReport._id] = qrData.pitReport; - } - - if (qrData.subjectiveReport) { - if (!qrData.subjectiveReport._id) { - console.error("Subjective report has no _id", qrData.subjectiveReport); - return; - } - - comp.subjectiveReports[qrData.subjectiveReport._id] = qrData.subjectiveReport; - comp.matches[qrData.subjectiveReport.match].subjectiveReports.push(qrData.subjectiveReport._id); - } - }) - } - - return ( - -
- -

Share Competition

- -
-

Import Competition Reports

- -
- -
- { isManager && - <> - -
- - } -

Read QR Data

- -
-
- ) -} \ No newline at end of file diff --git a/components/competition/MatchScheduleCard.tsx b/components/competition/MatchScheduleCard.tsx index ec62e392..f8fde192 100644 --- a/components/competition/MatchScheduleCard.tsx +++ b/components/competition/MatchScheduleCard.tsx @@ -17,7 +17,6 @@ export default function MatchScheduleCard(props: { loadingUsers: boolean; noMatches: boolean; isManager: boolean | undefined; - isOnline: boolean; matchesAssigned: boolean | undefined; assigningMatches: boolean; assignScouters: () => void; @@ -41,7 +40,6 @@ export default function MatchScheduleCard(props: { loadingUsers, noMatches, isManager, - isOnline, matchesAssigned, assigningMatches, assignScouters, @@ -159,10 +157,7 @@ export default function MatchScheduleCard(props: { return ( + href={`/${team?.slug}/${seasonSlug}/${comp?.slug}/${match._id}/subjective`}> Add Subjective Report ({`${match.subjectiveReports ? match.subjectiveReports.length : 0} submitted, ${match.subjectiveReportsCheckInTimestamps ? getIdsInProgressFromTimestamps(match.subjectiveReportsCheckInTimestamps).length diff --git a/lib/client/offlineUtils.ts b/lib/client/offlineUtils.ts deleted file mode 100644 index 7b5da741..00000000 --- a/lib/client/offlineUtils.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { Competition, Match, Pitreport, Report, SavedCompetition } from "../Types"; -import { removeDuplicates, rotateArray } from "./ClientUtils"; - -export function saveCompToLocalStorage(comp: SavedCompetition) { - localStorage.setItem(`comp-${comp.comp._id}`, JSON.stringify(comp)); -} - -export function getCompFromLocalStorage(id: string): SavedCompetition | undefined { - const stored = localStorage.getItem(`comp-${id}`); - return stored ? JSON.parse(stored) : undefined; -} - -export function getAllCompsFromLocalStorage(): SavedCompetition[] { - return Object.keys(localStorage) - .filter(k => k.startsWith("comp-")) - .map(k => getCompFromLocalStorage(k.replace("comp-", ""))) - .filter(c => c !== undefined) as SavedCompetition[]; -} - -export function updateCompInLocalStorage(compId: string, update: (comp: SavedCompetition) => void) { - const comp = getCompFromLocalStorage(compId); - if (comp) { - update(comp); - saveCompToLocalStorage(comp); - } -} - -function mergeMatches(original: { [_id: string]: Match }, incoming: { [_id: string]: Match }) { - const merged: { [_id: string]: Match } = { ...original }; - - for (const id in incoming) { - if (!merged[id]) { - merged[id] = incoming[id]; - } - else { - const originalMatch = merged[id]; - const incomingMatch = incoming[id]; - - const newMatch = { ...originalMatch }; - - newMatch.subjectiveReports = removeDuplicates([...originalMatch.subjectiveReports, ...incomingMatch.subjectiveReports]); - - newMatch.assignedSubjectiveScouterHasSubmitted = - originalMatch.assignedSubjectiveScouterHasSubmitted || incomingMatch.assignedSubjectiveScouterHasSubmitted; - - merged[id] = newMatch; - } - } - - return merged; -} - -function mergeQuantReports(incoming: { [_id: string]: Report }, original: { [_id: string]: Report }) { - const merged: { [_id: string]: Report } = { ...original }; - - for (const id in incoming) { - if (merged[id] === undefined) { - merged[id] = incoming[id]; - } - else { - const originalReport = merged[id]; - const incomingReport = incoming[id]; - - let newReport = { ...originalReport }; - - if (originalReport.submitted && !incomingReport.submitted) { - newReport = { ...incomingReport }; - } - - newReport.submitted = originalReport.submitted || incomingReport.submitted; - newReport.data.comments = `${originalReport.data.comments}
${incomingReport.data.comments}`; - - merged[id] = newReport; - } - } - - return merged; -} - -function mergePitReports(incoming: { [_id: string]: Pitreport }, original: { [_id: string]: Pitreport }) { - const merged = { ...original }; - - for (const id in incoming) { - if (merged[id] === undefined) { - merged[id] = incoming[id]; - } - else { - const originalReport = merged[id]; - const incomingReport = incoming[id]; - - let newReport = { ...originalReport }; - - if (originalReport.submitted && !incomingReport.submitted) { - newReport = { ...incomingReport }; - } - - newReport.submitted = originalReport.submitted || incomingReport.submitted; - - merged[id] = newReport; - } - } - - return merged; -} - -export function mergeSavedComps(original: SavedCompetition, incoming: SavedCompetition) { - original.matches = mergeMatches(original.matches, incoming.matches); - original.comp.matches = removeDuplicates([...original.comp.matches, ...incoming.comp.matches]); - - original.quantReports = mergeQuantReports(incoming.quantReports, original.quantReports); - - original.pitReports = mergePitReports(incoming.pitReports, original.pitReports); - original.comp.pitReports = removeDuplicates([...original.comp.pitReports, ...incoming.comp.pitReports]); - - original.users = { ...original.users, ...incoming.users }; - original.subjectiveReports = { ...original.subjectiveReports, ...incoming.subjectiveReports }; - - if (incoming.picklists) { - if (!original.picklists) - original.picklists = incoming.picklists; - else - for (const key in incoming.picklists.picklists) { - if (!original.picklists.picklists[key]) { - original.picklists.picklists[key] = incoming.picklists.picklists[key]; - } - } - } -} - -/** - * Assigns scouters in place. Works the same as @see assignScoutersToMatches but can be done offline. - */ -export function assignScoutersOffline(save: SavedCompetition) { - const { team, matches, quantReports } = save; - - let scouters = team.scouters; - let subjectiveScouters = team.subjectiveScouters; - - for (const match of Object.values(matches)) { - const subjectiveScouter = subjectiveScouters.length > 0 ? subjectiveScouters[0] : undefined; - if (subjectiveScouter) { - match.subjectiveScouter = subjectiveScouter; - rotateArray(subjectiveScouters); - } - - const reports = match.reports.map(r => quantReports[r]); - const scoutersForMatch = scouters.filter(id => !subjectiveScouters.includes(id)).slice(0, reports.length); - for (let i = 0; i < reports.length; i++) { - reports[i].user = scoutersForMatch[i]; - } - - rotateArray(scouters); - } -} \ No newline at end of file diff --git a/lib/client/useIsOnline.ts b/lib/client/useIsOnline.ts deleted file mode 100644 index 5423a7a2..00000000 --- a/lib/client/useIsOnline.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { useEffect, useState } from "react"; -import useInterval from "./useInterval"; -import { forceOfflineMode } from "./ClientUtils"; -import ClientApi from "@/lib/api/ClientApi"; - -const api = new ClientApi(); - -export default function useIsOnline() { - const [isOnline, setIsOnline] = useState(true); - - async function updateOnlineStatus() { - // Don't check if we just checked, even if it was in another useIsOnline hook - const lastIsOnlineCheck = localStorage.getItem("lastIsOnlineCheckTime"); - if (lastIsOnlineCheck && Date.now() - parseInt(lastIsOnlineCheck) < 5000) { - return localStorage.getItem("lastIsOnlineCheckResult") == "true"; - } - - let online = false; - await api.ping() - .then(() => online = true) - .catch(() => {}); - - setIsOnline(online); - localStorage.setItem("lastIsOnlineCheckTime", Date.now().toString()); - localStorage.setItem("lastIsOnlineCheckResult", Date.now().toString()); - } - - useEffect(() => { - updateOnlineStatus(); - }, []); - useInterval(updateOnlineStatus, 5000); - - return isOnline && !forceOfflineMode(); -} \ No newline at end of file diff --git a/lib/client/useOfflineCompFromRouter.ts b/lib/client/useOfflineCompFromRouter.ts deleted file mode 100644 index 26cbfb7f..00000000 --- a/lib/client/useOfflineCompFromRouter.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { useRouter } from "next/router"; -import { useState, useEffect } from "react"; -import { SavedCompetition, Report, Match, Pitreport } from "../Types"; -import { getCompFromLocalStorage } from "./offlineUtils"; -import useDynamicState from "./useDynamicState"; - -export enum OfflineLoadStatus { - Loaded = "Loaded", - WaitingForUseEffect = "Waiting for useEffect", - WaitingForQuery = "Waiting for query", - NoCompInQuery = "No comp in query", -} - -export default function useOfflineCompFromRouter() { - const router = useRouter(); - - const [savedComp, setSavedComp] = useState(undefined); - const [quantReport, setQuantReport] = useState(undefined); - const [match, setMatch] = useState(undefined); - const [pitReport, setPitReport] = useState(undefined); - - const [status, setStatus, getStatus] = useDynamicState(OfflineLoadStatus.WaitingForUseEffect); - - function loadQuery() { - getStatus((status) => { - if (status === OfflineLoadStatus.Loaded) - return; - - const { compId, quantReportId, matchId, pitReportId } = router.query; - console.log("Loading offline comp from router", router.query); - - const comp = getCompFromLocalStorage(compId as string); - if (!comp) { - setStatus(OfflineLoadStatus.NoCompInQuery); - setTimeout(loadQuery, 1000); - return; - } - - setStatus(OfflineLoadStatus.Loaded); - setSavedComp(comp) - - if (quantReportId) - setQuantReport(comp.quantReports[quantReportId as string]); - - if (matchId) - setMatch(comp.matches[matchId as string]); - - if (pitReportId) - setPitReport(comp.pitReports[pitReportId as string]); - }); - } - - useEffect(() => { - if (!router.isReady) { - console.log("Trying to load offline comp from router, but router is not ready"); - - setStatus(OfflineLoadStatus.WaitingForUseEffect); - setTimeout(loadQuery, 1000); - return; - } - - loadQuery(); - }, [router.isReady]); - - return { savedComp, quantReport, match, pitReport, status }; -} \ No newline at end of file diff --git a/pages/_offline.tsx b/pages/_offline.tsx deleted file mode 100644 index dd14dc41..00000000 --- a/pages/_offline.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import Container from "@/components/Container" -import { useEffect } from "react"; - -export default function Fallback() { - useEffect(() => { - setTimeout(location.reload, 2500); - }, []); - - return ( - -
-

Offline

-

Sorry, you are offline and the page you're on can't be used offline.

-
-
- ); -} \ No newline at end of file diff --git a/pages/offline/[compId]/index.tsx b/pages/offline/[compId]/index.tsx deleted file mode 100644 index 8222622b..00000000 --- a/pages/offline/[compId]/index.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import CompetitionIndex from "@/components/competition/CompetitionIndex"; -import Container from "@/components/Container"; -import useOfflineCompFromRouter from "@/lib/client/useOfflineCompFromRouter"; - -export default function OfflineCompetitionPage() { - const { savedComp, status } = useOfflineCompFromRouter(); - - return ( - - { savedComp - ? - :
Loading... Status: {status}
- } -
- ) -} \ No newline at end of file diff --git a/pages/offline/[compId]/pit/[pitReportId].tsx b/pages/offline/[compId]/pit/[pitReportId].tsx deleted file mode 100644 index 894197d1..00000000 --- a/pages/offline/[compId]/pit/[pitReportId].tsx +++ /dev/null @@ -1,18 +0,0 @@ -import Container from "@/components/Container"; -import PitReportForm from "@/components/PitReport"; -import useOfflineCompFromRouter from "@/lib/client/useOfflineCompFromRouter"; -import { games } from "@/lib/games"; - -export default function OfflinePitReport() { - const { savedComp, pitReport } = useOfflineCompFromRouter(); - - return ( - - { savedComp && pitReport - ? - :
Loading...
- } -
- ); -} \ No newline at end of file diff --git a/pages/offline/[compId]/quant/[quantReportId].tsx b/pages/offline/[compId]/quant/[quantReportId].tsx deleted file mode 100644 index 1e3395e7..00000000 --- a/pages/offline/[compId]/quant/[quantReportId].tsx +++ /dev/null @@ -1,24 +0,0 @@ -import Container from "@/components/Container"; -import Form from "@/components/forms/Form"; -import useOfflineCompFromRouter from "@/lib/client/useOfflineCompFromRouter"; - -export default function OfflineQuantReport() { - const { savedComp, quantReport } = useOfflineCompFromRouter(); - if (!savedComp) { - return ( - -

Comp Not Found

-
- ); - } - - return ( - - { quantReport - ?
- :

Welp.

- } - - ); -} \ No newline at end of file diff --git a/pages/offline/[compId]/stats.tsx b/pages/offline/[compId]/stats.tsx deleted file mode 100644 index 8a7e7972..00000000 --- a/pages/offline/[compId]/stats.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import StatsPage from "@/components/stats/StatsPage"; -import useOfflineCompFromRouter from "@/lib/client/useOfflineCompFromRouter"; - -export default function OfflineStats() { - const { savedComp, status } = useOfflineCompFromRouter(); - - if (!savedComp) - return
Loading... Status: {status}
- - return ( - r.submitted)} - pitReports={Object.values(savedComp.pitReports)} - subjectiveReports={Object.values(savedComp.subjectiveReports)} - picklists={savedComp.picklists ?? { _id: "", picklists: {} }} - /> - ); -} \ No newline at end of file diff --git a/pages/offline/[compId]/subjective/[matchId].tsx b/pages/offline/[compId]/subjective/[matchId].tsx deleted file mode 100644 index 8d40005f..00000000 --- a/pages/offline/[compId]/subjective/[matchId].tsx +++ /dev/null @@ -1,20 +0,0 @@ -import Container from "@/components/Container"; -import SubjectiveReportForm from "@/components/SubjectiveReportForm"; -import useOfflineCompFromRouter from "@/lib/client/useOfflineCompFromRouter"; - -export default function OfflineSubjectiveReport() { - const { savedComp, match } = useOfflineCompFromRouter(); - - if (!match) - return ( - -
Loading...
-
- ); - - return ( - - - - ); -} \ No newline at end of file diff --git a/pages/offline/index.tsx b/pages/offline/index.tsx deleted file mode 100644 index e465992f..00000000 --- a/pages/offline/index.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import CompetitionIndex from "@/components/competition/CompetitionIndex"; -import Container from "@/components/Container"; -import { getAllCompsFromLocalStorage, saveCompToLocalStorage } from "@/lib/client/offlineUtils"; -import { SavedCompetition } from "@/lib/Types"; -import Link from "next/link"; -import { ChangeEvent, useEffect, useState } from "react"; - -export default function SelectCompetitionPage() { - const [allSavedComps, setAllSavedComps] = useState([]); - - useEffect(() => { - setAllSavedComps(getAllCompsFromLocalStorage().sort((a, b) => b.lastAccessTime - a.lastAccessTime)); - }, []); - - function fileSelected(e: ChangeEvent) { - const file = e.target.files?.[0]; - - if (!file) return; - - const reader = new FileReader(); - reader.onload = (e) => { - const data = JSON.parse(e.target?.result as string) as SavedCompetition; - saveCompToLocalStorage(data); - - window.location.href = `/offline/${data.comp._id}`; - } - - reader.readAsText(file); - } - - function formatCompName(comp: SavedCompetition) { - return `${comp.team?.league ?? "FRC"} ${comp.team?.number} - ${comp.comp.name}`; - } - - return ( - -
-
-
-

- Select a saved competition -

-
- { - allSavedComps.map(comp => ( - - {formatCompName(comp)} - - )) - } -
-
-
-
-
-

Upload a competition

-
- -
-
-
-
-
- ) -} \ No newline at end of file From ae044b761679f93864d0de855a7b2148ddaa9a68 Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Mon, 18 Nov 2024 21:27:28 -0500 Subject: [PATCH 09/93] Remove CompetitionIndex --- components/competition/CompetitionIndex.tsx | 593 ----------------- .../[seasonSlug]/[competitonSlug]/index.tsx | 595 +++++++++++++++++- 2 files changed, 589 insertions(+), 599 deletions(-) delete mode 100644 components/competition/CompetitionIndex.tsx diff --git a/components/competition/CompetitionIndex.tsx b/components/competition/CompetitionIndex.tsx deleted file mode 100644 index 869f8e7b..00000000 --- a/components/competition/CompetitionIndex.tsx +++ /dev/null @@ -1,593 +0,0 @@ -import { ChangeEvent, useEffect, useLayoutEffect, useRef, useState } from "react"; - -import ClientApi from "@/lib/api/ClientApi"; -import { - AllianceColor, - Match, - MatchType, - Pitreport, - Report, - SavedCompetition, - SubjectiveReport, - User, - Team, - Competition, - League, - DbPicklist -} from "@/lib/Types"; - -import Link from "next/link"; -import { useCurrentSession } from "@/lib/client/useCurrentSession"; - -import { - MdAutoGraph, - MdCoPresent, - MdErrorOutline, - MdQueryStats, -} from "react-icons/md"; -import { BsClipboard2Check, BsGearFill, BsQrCode, BsQrCodeScan } from "react-icons/bs"; -import { FaBinoculars, FaDatabase, FaSync, FaUserCheck } from "react-icons/fa"; -import { FaCheck, FaRobot, FaUserGroup } from "react-icons/fa6"; -import { Round } from "@/lib/client/StatsMath"; -import Avatar from "@/components/Avatar"; -import Loading from "@/components/Loading"; -import useInterval from "@/lib/client/useInterval"; -import { NotLinkedToTba, download, getIdsInProgressFromTimestamps } from "@/lib/client/ClientUtils"; -import { games } from "@/lib/games"; -import { defaultGameId } from "@/lib/client/GameId"; -import { toDict } from "@/lib/client/ClientUtils"; -import { BiExport } from "react-icons/bi"; -import EditMatchModal from "./EditMatchModal"; -import { BSON } from "bson"; -import CompHeaderCard from "./CompHeaderCard"; -import InsightsAndSettingsCard from "./InsightsAndSettingsCard"; -import MatchScheduleCard from "./MatchScheduleCard"; -import PitScoutingCard from "./PitScoutingCard"; - -const api = new ClientApi(); - -export default function CompetitionIndex(props: { - team: Team | undefined, - competition: Competition | undefined, - seasonSlug: string | undefined, - fallbackData?: SavedCompetition - overrideIsManager?: boolean -}) { - const team = props.team; - const seasonSlug = props.seasonSlug; - const comp = props.competition; - const fallbackData = props.fallbackData - ? { - users: props.fallbackData.users, - matches: Object.values(props.fallbackData.matches), - quantReports: Object.values(props.fallbackData.quantReports), - subjectiveReports: Object.values(props.fallbackData.subjectiveReports), - pitReports: Object.values(props.fallbackData.pitReports), - } - : undefined; - - const { session, status } = useCurrentSession(); - const isManager = session?.user?._id - ? team?.owners.includes(session.user?._id) || props.overrideIsManager - : false || props.overrideIsManager; - - const [showSettings, setShowSettings] = useState(false); - const [matchNumber, setMatchNumber] = useState(undefined); - const [blueAlliance, setBlueAlliance] = useState([]); - const [redAlliance, setRedAlliance] = useState([]); - - const [matches, setMatches] = useState([]); - - const [showSubmittedMatches, setShowSubmittedMatches] = useState(false); - - const [reports, setReports] = useState([]); - const [matchesAssigned, setMatchesAssigned] = useState(undefined); - const [assigningMatches, setAssigningMatches] = useState(false); - const [noMatches, setNoMatches] = useState(false); - - const [subjectiveReports, setSubjectiveReports] = useState([]); - - const [reportsById, setReportsById] = useState<{ [key: string]: Report }>({}); - const [usersById, setUsersById] = useState<{ [key: string]: User }>({}); - const [subjectiveReportsById, setSubjectiveReportsById] = useState<{ [key: string]: SubjectiveReport }>({}); - - //loading states - const [loadingMatches, setLoadingMatches] = useState(false); - const [loadingReports, setLoadingReports] = useState(false); - const [loadingScoutStats, setLoadingScoutStats] = useState(false); - const [loadingUsers, setLoadingUsers] = useState(false); - - const [submissionRate, setSubmissionRate] = useState(0); - const [submittedReports, setSubmittedReports] = useState( - undefined - ); - - const [pitreports, setPitreports] = useState([]); - const [loadingPitreports, setLoadingPitreports] = useState(true); - const [submittedPitreports, setSubmittedPitreports] = useState< - number | undefined - >(undefined); - const [ - attemptedRegeneratingPitReports, - setAttemptedRegeneratingPitReports, - ] = useState(false); - - const [picklists, setPicklists] = useState(); - - const [updatingComp, setUpdatingComp] = useState(""); - - const [ranking, setRanking] = useState<{ - place: number | string; - max: number | string; - } | null>(null); - - const [matchBeingEdited, setMatchBeingEdited] = useState(); - - const [teamToAdd, setTeamToAdd] = useState(0); - - const [newCompName, setNewCompName] = useState(comp?.name); - const [newCompTbaId, setNewCompTbaId] = useState(comp?.tbaId); - - useEffect(() => { - if (!fallbackData) { - console.log("No fallback data provided"); - - return; - } - - console.log("Initially loading fallback data:", fallbackData); - - if (!matches || matches.length === 0) - setMatches(fallbackData.matches); - if (!reports || reports.length === 0) { - setReports(fallbackData.quantReports); - setReportsById(toDict(fallbackData.quantReports)); - } - if (!pitreports || pitreports.length === 0) { - setPitreports(fallbackData.pitReports); - setLoadingPitreports(false); - } - if (!subjectiveReports || subjectiveReports.length === 0) - setSubjectiveReports(fallbackData.subjectiveReports); - console.log(usersById); - if (!usersById || Object.keys(usersById).length === 0) { - console.log("Setting users by id", fallbackData.users); - setUsersById(fallbackData.users); - setLoadingUsers(false); - } - if (!picklists) - setPicklists(props.fallbackData?.picklists); - }, [props.fallbackData?.comp._id]); - - const regeneratePitReports = async () => { - console.log("Regenerating pit reports..."); - api - .regeneratePitReports(comp?._id!) - .then(({ pitReports }: { pitReports: string[] }) => { - setAttemptedRegeneratingPitReports(true); - setLoadingPitreports(true); - - // Fetch pit reports - const pitReportPromises = pitReports.map( - api.findPitreportById - ); - - Promise.all(pitReportPromises).then((reports) => { - console.log("Got all pit reports"); - setPitreports(reports.filter((r) => r !== undefined) as Pitreport[]); - setLoadingPitreports(false); - }); - }); - }; - - useEffect(() => { - if (!reports) - return; - - let matchesAssigned = true; - - for (const report of reports) { - if (!report.user) { - matchesAssigned = false; - break; - } - } - - setMatchesAssigned(matchesAssigned); - }, [reports]); - - const loadMatches = async (silent: boolean = false) => { - if (!silent) - setLoadingMatches(true); - - window.location.hash = ""; - let matches: Match[] = await api.allCompetitionMatches(comp?._id!) ?? fallbackData?.matches; - if (matches.length === 0) - matches = fallbackData?.matches ?? []; - - if (!matches || matches.length === 0) { - setNoMatches(true); - - if (!silent) - setLoadingMatches(false); - - return; - } - - matches?.sort((a, b) => a.number - b.number); - - setMatches(matches); - - api.getSubjectiveReportsFromMatches(comp?._id ?? "", matches).then((reports) => { - setSubjectiveReports(reports); - - const newReportIds: { [key: string]: SubjectiveReport } = {}; - reports.forEach((report) => { - if (!report._id) { - return; - } - newReportIds[report._id] = report; - }); - setSubjectiveReportsById(newReportIds); - }); - - if (!silent) - setLoadingMatches(false); - }; - - const loadReports = async (silent: boolean = false) => { - const scoutingStats = (reps: Report[]) => { - if (!silent) - setLoadingScoutStats(true); - let submittedCount = 0; - reps.forEach((report) => { - if (report.submitted) { - submittedCount++; - } - }); - - setSubmittedReports(submittedCount); - if (!silent) - setLoadingScoutStats(false); - }; - - if (!silent) - setLoadingReports(true); - - let newReports: Report[] = await api.competitionReports( - comp?._id!, - false, - false - ); - - if (!newReports || newReports.length === 0) - newReports = fallbackData?.quantReports ?? []; - - setReports(newReports); - - const newReportsById: { [key: string]: Report } = {}; - newReports?.forEach((report) => { - if (!report._id) { - return; - } - newReportsById[report._id] = report; - }); - setReportsById(newReportsById); - - if (!silent) - setLoadingReports(false); - - scoutingStats(newReports); - }; - - useEffect(() => { - setInterval(() => loadReports(true), 5000); - }, []); - - useEffect(() => { - const loadUsers = async (silent: boolean = false) => { - console.log("Loading users..."); - - if (Object.keys(usersById).length === 0 && !silent) - setLoadingUsers(true); - - if (!team || (!team.scouters && !team.subjectiveScouters)) { - return; - } - - const newUsersById: { [key: string]: User } = {}; - const promises: Promise[] = []; - for (const userId of team.users) { - promises.push(api.findUserById(userId) - .then((user) => { - if (user) { - newUsersById[userId] = user; - } - }) - .catch((e) => { - if (fallbackData?.users[userId]) - newUsersById[userId] = fallbackData.users[userId]; - }) - ); - } - - await Promise.all(promises); - - setUsersById(newUsersById); - setLoadingUsers(false); - }; - - const loadPitreports = async (silent: boolean = false) => { - console.log("Loading pit reports... Current:", pitreports); - if (pitreports.length === 0 && !silent) - setLoadingPitreports(true); - - if (!comp?.pitReports) { - setLoadingPitreports(false); - return; - } - const newPitReports: Pitreport[] = []; - let submitted = 0; - const promises: Promise[] = []; - for (const pitreportId of comp?.pitReports) { - promises.push(api.findPitreportById(pitreportId) - .catch((e) => fallbackData?.pitReports.find((r) => r._id === pitreportId)) - .then((pitreport) => { - if (!pitreport) { - return; - } - - if (pitreport.submitted) { - submitted++; - } - newPitReports.push(pitreport); - })); - } - - await Promise.all(promises); - - console.log("Loaded pit reports:", newPitReports); - setSubmittedPitreports(submitted); - setPitreports(newPitReports); - setLoadingPitreports(false); - }; - - if (!assigningMatches) { - loadUsers(true); - loadMatches(matches !== undefined); - loadReports(reports !== undefined); - loadPitreports(true); - } - - // Load picklists - if (comp?._id) - api.getPicklistFromComp(comp?._id).then(setPicklists).catch(console.error); - - // Resync pit reports if none are present - if (!attemptedRegeneratingPitReports && comp?.pitReports.length === 0) { - regeneratePitReports(); - } - }, [assigningMatches]); - - const assignScouters = async () => { - setAssigningMatches(true); - const res = await api.assignScouters(comp?._id!, true); - - if (props.fallbackData && (res as any) === props.fallbackData) { - location.reload(); - return; - } - - if ((res.result as string).toLowerCase() !== "success") { - alert(res.result); - } - - setAssigningMatches(false); - }; - - const reloadCompetition = async () => { - const num = Math.floor(Math.random() * 1000000); - if(prompt(`Are you sure you want to reload the competition? This will overwrite ALL your data. We CANNOT recover your data. If you are sure, type '${num}'`) !== String(num)) { - alert("Cancelled"); - return; - } - - alert("Reloading competition..."); - - setUpdatingComp("Checking for Updates..."); - const res = await api.reloadCompetition(comp?._id!); - if (res.result === "success") { - window.location.reload(); - } else { - setUpdatingComp("None found"); - } - }; - - const createMatch = async () => { - try { - await api.createMatch( - comp?._id!, - Number(matchNumber), - 0, - MatchType.Qualifying, - blueAlliance as number[], - redAlliance as number[] - ); - } catch (e) { - console.error(e); - } - - location.reload(); - }; - - // useEffect(() => { - // if ( - // qualificationMatches.length > 0 && - // Object.keys(reportsById).length > 0 && - // !showSubmittedMatches - // ) { - // const b = qualificationMatches.filter((match) => { - // let s = true; - - // for (const id of match.reports) { - // const r = reportsById[id]; - // if (!r?.submitted) { - // s = false; - // break; - // } - // } - // return !s; - // }); - // if (b.length > 0) { - // setQualificationMatches(b); - // } - // } - // }, [reportsById, matches, showSubmittedMatches]); - - function toggleShowSubmittedMatches() { - setShowSubmittedMatches(!showSubmittedMatches); - - loadMatches(matches !== undefined); - } - - const [exportPending, setExportPending] = useState(false); - - const exportAsCsv = async () => { - setExportPending(true); - - const res = await api.exportCompAsCsv(comp?._id!).catch((e) => { - console.error(e); - return { csv: undefined }; - }); - - if (!res) { - console.error("failed to export"); - } - - if (res.csv) { - download(`${comp?.name ?? "Competition"}.csv`, res.csv, "text/csv"); - } else { - console.error("No CSV data returned from server"); - } - - setExportPending(false); - }; - - useEffect(() => { - if (ranking || !comp?.tbaId || !team?.number) return; - - api.teamCompRanking(comp?.tbaId, team?.number).then((res) => { - setRanking(res); - }); - }); - - function openEditMatchModal(match: Match) { - (document.getElementById("edit-match-modal") as HTMLDialogElement | undefined)?.showModal(); - - setMatchBeingEdited(match._id); - } - - useInterval(() => loadMatches(true), 5000); - - function togglePublicData(e: ChangeEvent) { - if (!comp?._id) return; - api.setCompPublicData(comp?._id, e.target.checked); - } - - function remindUserOnSlack(slackId: string) { - if (slackId && session?.user?.slackId && team?._id && isManager && confirm("Remind scouter on Slack?")) - api.remindSlack(team._id.toString(), slackId); - } - - function addTeam() { - console.log("Adding pit report for team", teamToAdd); - if (!teamToAdd || teamToAdd < 1 || !comp?._id) return; - - api.createPitReportForTeam(teamToAdd, comp?._id) - // We can't just pass location.reload, it will throw "illegal invocation." I don't know why. -Renato - .finally(() => location.reload()); - } - - async function saveCompChanges() { - // Check if tbaId is valid - if (!comp?.tbaId || !comp?.name || !comp?._id) return; - - let tbaId = newCompTbaId; - const autoFillData = await api.competitionAutofill(tbaId ?? ""); - if (!autoFillData?.name && !confirm(`Invalid TBA ID: ${tbaId}. Save changes anyway?`)) - return; - - await api.updateCompNameAndTbaId(comp?._id, newCompName ?? "Unnamed", tbaId ?? NotLinkedToTba); - location.reload(); - } - - const allianceIndices: number[] = []; - for (let i = 0; i < games[comp?.gameId ?? defaultGameId].allianceSize; i++) { - allianceIndices.push(i); - } - - function getSavedCompetition() { - if (!comp || !team) return; - - // Save comp to local storage - const savedComp = new SavedCompetition(comp, games[comp?.gameId ?? defaultGameId], team, usersById, seasonSlug); - savedComp.lastAccessTime = Date.now(); - - savedComp.matches = toDict(matches); - savedComp.quantReports = toDict(reports); - savedComp.pitReports = toDict(pitreports); - savedComp.subjectiveReports = toDict(subjectiveReports); - - savedComp.picklists = picklists; - - return savedComp; - } - - return ( - <> -
-
- - -
- -
- - -
- - { isManager && - setMatchBeingEdited(undefined)} - match={matches.find(m => m._id === matchBeingEdited!)} - reportsById={reportsById} - usersById={usersById} - comp={comp} - loadReports={loadReports} - loadMatches={loadMatches} - /> - } -
- - ); -} \ No newline at end of file diff --git a/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/index.tsx b/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/index.tsx index d2b7154e..e1062fb8 100644 --- a/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/index.tsx +++ b/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/index.tsx @@ -1,13 +1,596 @@ -import UrlResolver, { ResolvedUrlData } from "@/lib/UrlResolver"; +import { ChangeEvent, useEffect, useLayoutEffect, useRef, useState } from "react"; + +import ClientApi from "@/lib/api/ClientApi"; +import { + AllianceColor, + Match, + MatchType, + Pitreport, + Report, + SavedCompetition, + SubjectiveReport, + User, + Team, + Competition, + League, + DbPicklist +} from "@/lib/Types"; + +import Link from "next/link"; +import { useCurrentSession } from "@/lib/client/useCurrentSession"; + +import { + MdAutoGraph, + MdCoPresent, + MdErrorOutline, + MdQueryStats, +} from "react-icons/md"; +import { BsClipboard2Check, BsGearFill, BsQrCode, BsQrCodeScan } from "react-icons/bs"; +import { FaBinoculars, FaDatabase, FaSync, FaUserCheck } from "react-icons/fa"; +import { FaCheck, FaRobot, FaUserGroup } from "react-icons/fa6"; +import { Round } from "@/lib/client/StatsMath"; +import Avatar from "@/components/Avatar"; +import Loading from "@/components/Loading"; +import useInterval from "@/lib/client/useInterval"; +import { NotLinkedToTba, download, getIdsInProgressFromTimestamps, makeObjSerializeable } from "@/lib/client/ClientUtils"; +import { games } from "@/lib/games"; +import { defaultGameId } from "@/lib/client/GameId"; +import { toDict } from "@/lib/client/ClientUtils"; +import { BiExport } from "react-icons/bi"; +import { BSON } from "bson"; import { GetServerSideProps } from "next"; -import CompetitionIndex from "@/components/competition/CompetitionIndex"; +import UrlResolver from "@/lib/UrlResolver"; import Container from "@/components/Container"; -import { makeObjSerializeable } from "@/lib/client/ClientUtils"; +import CompHeaderCard from "@/components/competition/CompHeaderCard"; +import EditMatchModal from "@/components/competition/EditMatchModal"; +import InsightsAndSettingsCard from "@/components/competition/InsightsAndSettingsCard"; +import MatchScheduleCard from "@/components/competition/MatchScheduleCard"; +import PitScoutingCard from "@/components/competition/PitScoutingCard"; + +const api = new ClientApi(); + +export default function CompetitionIndex(props: { + team: Team | undefined, + competition: Competition | undefined, + seasonSlug: string | undefined, + fallbackData?: SavedCompetition + overrideIsManager?: boolean +}) { + const team = props.team; + const seasonSlug = props.seasonSlug; + const comp = props.competition; + const fallbackData = props.fallbackData + ? { + users: props.fallbackData.users, + matches: Object.values(props.fallbackData.matches), + quantReports: Object.values(props.fallbackData.quantReports), + subjectiveReports: Object.values(props.fallbackData.subjectiveReports), + pitReports: Object.values(props.fallbackData.pitReports), + } + : undefined; + + const { session, status } = useCurrentSession(); + const isManager = session?.user?._id + ? team?.owners.includes(session.user?._id) || props.overrideIsManager + : false || props.overrideIsManager; + + const [showSettings, setShowSettings] = useState(false); + const [matchNumber, setMatchNumber] = useState(undefined); + const [blueAlliance, setBlueAlliance] = useState([]); + const [redAlliance, setRedAlliance] = useState([]); + + const [matches, setMatches] = useState([]); + + const [showSubmittedMatches, setShowSubmittedMatches] = useState(false); + + const [reports, setReports] = useState([]); + const [matchesAssigned, setMatchesAssigned] = useState(undefined); + const [assigningMatches, setAssigningMatches] = useState(false); + const [noMatches, setNoMatches] = useState(false); + + const [subjectiveReports, setSubjectiveReports] = useState([]); + + const [reportsById, setReportsById] = useState<{ [key: string]: Report }>({}); + const [usersById, setUsersById] = useState<{ [key: string]: User }>({}); + const [subjectiveReportsById, setSubjectiveReportsById] = useState<{ [key: string]: SubjectiveReport }>({}); + + //loading states + const [loadingMatches, setLoadingMatches] = useState(false); + const [loadingReports, setLoadingReports] = useState(false); + const [loadingScoutStats, setLoadingScoutStats] = useState(false); + const [loadingUsers, setLoadingUsers] = useState(false); + + const [submissionRate, setSubmissionRate] = useState(0); + const [submittedReports, setSubmittedReports] = useState( + undefined + ); + + const [pitreports, setPitreports] = useState([]); + const [loadingPitreports, setLoadingPitreports] = useState(true); + const [submittedPitreports, setSubmittedPitreports] = useState< + number | undefined + >(undefined); + const [ + attemptedRegeneratingPitReports, + setAttemptedRegeneratingPitReports, + ] = useState(false); + + const [picklists, setPicklists] = useState(); + + const [updatingComp, setUpdatingComp] = useState(""); + + const [ranking, setRanking] = useState<{ + place: number | string; + max: number | string; + } | null>(null); + + const [matchBeingEdited, setMatchBeingEdited] = useState(); + + const [teamToAdd, setTeamToAdd] = useState(0); + + const [newCompName, setNewCompName] = useState(comp?.name); + const [newCompTbaId, setNewCompTbaId] = useState(comp?.tbaId); + + useEffect(() => { + if (!fallbackData) { + console.log("No fallback data provided"); + + return; + } + + console.log("Initially loading fallback data:", fallbackData); + + if (!matches || matches.length === 0) + setMatches(fallbackData.matches); + if (!reports || reports.length === 0) { + setReports(fallbackData.quantReports); + setReportsById(toDict(fallbackData.quantReports)); + } + if (!pitreports || pitreports.length === 0) { + setPitreports(fallbackData.pitReports); + setLoadingPitreports(false); + } + if (!subjectiveReports || subjectiveReports.length === 0) + setSubjectiveReports(fallbackData.subjectiveReports); + console.log(usersById); + if (!usersById || Object.keys(usersById).length === 0) { + console.log("Setting users by id", fallbackData.users); + setUsersById(fallbackData.users); + setLoadingUsers(false); + } + if (!picklists) + setPicklists(props.fallbackData?.picklists); + }, [props.fallbackData?.comp._id]); + + const regeneratePitReports = async () => { + console.log("Regenerating pit reports..."); + api + .regeneratePitReports(comp?._id!) + .then(({ pitReports }: { pitReports: string[] }) => { + setAttemptedRegeneratingPitReports(true); + setLoadingPitreports(true); + + // Fetch pit reports + const pitReportPromises = pitReports.map( + api.findPitreportById + ); + + Promise.all(pitReportPromises).then((reports) => { + console.log("Got all pit reports"); + setPitreports(reports.filter((r) => r !== undefined) as Pitreport[]); + setLoadingPitreports(false); + }); + }); + }; + + useEffect(() => { + if (!reports) + return; + + let matchesAssigned = true; + + for (const report of reports) { + if (!report.user) { + matchesAssigned = false; + break; + } + } + + setMatchesAssigned(matchesAssigned); + }, [reports]); + + const loadMatches = async (silent: boolean = false) => { + if (!silent) + setLoadingMatches(true); + + window.location.hash = ""; + let matches: Match[] = await api.allCompetitionMatches(comp?._id!) ?? fallbackData?.matches; + if (matches.length === 0) + matches = fallbackData?.matches ?? []; + + if (!matches || matches.length === 0) { + setNoMatches(true); + + if (!silent) + setLoadingMatches(false); + + return; + } + + matches?.sort((a, b) => a.number - b.number); + + setMatches(matches); + + api.getSubjectiveReportsFromMatches(comp?._id ?? "", matches).then((reports) => { + setSubjectiveReports(reports); + + const newReportIds: { [key: string]: SubjectiveReport } = {}; + reports.forEach((report) => { + if (!report._id) { + return; + } + newReportIds[report._id] = report; + }); + setSubjectiveReportsById(newReportIds); + }); + + if (!silent) + setLoadingMatches(false); + }; + + const loadReports = async (silent: boolean = false) => { + const scoutingStats = (reps: Report[]) => { + if (!silent) + setLoadingScoutStats(true); + let submittedCount = 0; + reps.forEach((report) => { + if (report.submitted) { + submittedCount++; + } + }); + + setSubmittedReports(submittedCount); + if (!silent) + setLoadingScoutStats(false); + }; + + if (!silent) + setLoadingReports(true); + + let newReports: Report[] = await api.competitionReports( + comp?._id!, + false, + false + ); + + if (!newReports || newReports.length === 0) + newReports = fallbackData?.quantReports ?? []; + + setReports(newReports); + + const newReportsById: { [key: string]: Report } = {}; + newReports?.forEach((report) => { + if (!report._id) { + return; + } + newReportsById[report._id] = report; + }); + setReportsById(newReportsById); + + if (!silent) + setLoadingReports(false); + + scoutingStats(newReports); + }; + + useEffect(() => { + setInterval(() => loadReports(true), 5000); + }, []); + + useEffect(() => { + const loadUsers = async (silent: boolean = false) => { + console.log("Loading users..."); + + if (Object.keys(usersById).length === 0 && !silent) + setLoadingUsers(true); + + if (!team || (!team.scouters && !team.subjectiveScouters)) { + return; + } + + const newUsersById: { [key: string]: User } = {}; + const promises: Promise[] = []; + for (const userId of team.users) { + promises.push(api.findUserById(userId) + .then((user) => { + if (user) { + newUsersById[userId] = user; + } + }) + .catch((e) => { + if (fallbackData?.users[userId]) + newUsersById[userId] = fallbackData.users[userId]; + }) + ); + } + + await Promise.all(promises); + + setUsersById(newUsersById); + setLoadingUsers(false); + }; + + const loadPitreports = async (silent: boolean = false) => { + console.log("Loading pit reports... Current:", pitreports); + if (pitreports.length === 0 && !silent) + setLoadingPitreports(true); + + if (!comp?.pitReports) { + setLoadingPitreports(false); + return; + } + const newPitReports: Pitreport[] = []; + let submitted = 0; + const promises: Promise[] = []; + for (const pitreportId of comp?.pitReports) { + promises.push(api.findPitreportById(pitreportId) + .catch((e) => fallbackData?.pitReports.find((r) => r._id === pitreportId)) + .then((pitreport) => { + if (!pitreport) { + return; + } + + if (pitreport.submitted) { + submitted++; + } + newPitReports.push(pitreport); + })); + } + + await Promise.all(promises); + + console.log("Loaded pit reports:", newPitReports); + setSubmittedPitreports(submitted); + setPitreports(newPitReports); + setLoadingPitreports(false); + }; + + if (!assigningMatches) { + loadUsers(true); + loadMatches(matches !== undefined); + loadReports(reports !== undefined); + loadPitreports(true); + } + + // Load picklists + if (comp?._id) + api.getPicklistFromComp(comp?._id).then(setPicklists).catch(console.error); + + // Resync pit reports if none are present + if (!attemptedRegeneratingPitReports && comp?.pitReports.length === 0) { + regeneratePitReports(); + } + }, [assigningMatches]); + + const assignScouters = async () => { + setAssigningMatches(true); + const res = await api.assignScouters(comp?._id!, true); + + if (props.fallbackData && (res as any) === props.fallbackData) { + location.reload(); + return; + } + + if ((res.result as string).toLowerCase() !== "success") { + alert(res.result); + } + + setAssigningMatches(false); + }; + + const reloadCompetition = async () => { + const num = Math.floor(Math.random() * 1000000); + if(prompt(`Are you sure you want to reload the competition? This will overwrite ALL your data. We CANNOT recover your data. If you are sure, type '${num}'`) !== String(num)) { + alert("Cancelled"); + return; + } + + alert("Reloading competition..."); + + setUpdatingComp("Checking for Updates..."); + const res = await api.reloadCompetition(comp?._id!); + if (res.result === "success") { + window.location.reload(); + } else { + setUpdatingComp("None found"); + } + }; + + const createMatch = async () => { + try { + await api.createMatch( + comp?._id!, + Number(matchNumber), + 0, + MatchType.Qualifying, + blueAlliance as number[], + redAlliance as number[] + ); + } catch (e) { + console.error(e); + } + + location.reload(); + }; + + // useEffect(() => { + // if ( + // qualificationMatches.length > 0 && + // Object.keys(reportsById).length > 0 && + // !showSubmittedMatches + // ) { + // const b = qualificationMatches.filter((match) => { + // let s = true; + + // for (const id of match.reports) { + // const r = reportsById[id]; + // if (!r?.submitted) { + // s = false; + // break; + // } + // } + // return !s; + // }); + // if (b.length > 0) { + // setQualificationMatches(b); + // } + // } + // }, [reportsById, matches, showSubmittedMatches]); + + function toggleShowSubmittedMatches() { + setShowSubmittedMatches(!showSubmittedMatches); + + loadMatches(matches !== undefined); + } + + const [exportPending, setExportPending] = useState(false); + + const exportAsCsv = async () => { + setExportPending(true); + + const res = await api.exportCompAsCsv(comp?._id!).catch((e) => { + console.error(e); + return { csv: undefined }; + }); + + if (!res) { + console.error("failed to export"); + } + + if (res.csv) { + download(`${comp?.name ?? "Competition"}.csv`, res.csv, "text/csv"); + } else { + console.error("No CSV data returned from server"); + } + + setExportPending(false); + }; + + useEffect(() => { + if (ranking || !comp?.tbaId || !team?.number) return; + + api.teamCompRanking(comp?.tbaId, team?.number).then((res) => { + setRanking(res); + }); + }); + + function openEditMatchModal(match: Match) { + (document.getElementById("edit-match-modal") as HTMLDialogElement | undefined)?.showModal(); + + setMatchBeingEdited(match._id); + } + + useInterval(() => loadMatches(true), 5000); + + function togglePublicData(e: ChangeEvent) { + if (!comp?._id) return; + api.setCompPublicData(comp?._id, e.target.checked); + } + + function remindUserOnSlack(slackId: string) { + if (slackId && session?.user?.slackId && team?._id && isManager && confirm("Remind scouter on Slack?")) + api.remindSlack(team._id.toString(), slackId); + } + + function addTeam() { + console.log("Adding pit report for team", teamToAdd); + if (!teamToAdd || teamToAdd < 1 || !comp?._id) return; + + api.createPitReportForTeam(teamToAdd, comp?._id) + // We can't just pass location.reload, it will throw "illegal invocation." I don't know why. -Renato + .finally(() => location.reload()); + } + + async function saveCompChanges() { + // Check if tbaId is valid + if (!comp?.tbaId || !comp?.name || !comp?._id) return; + + let tbaId = newCompTbaId; + const autoFillData = await api.competitionAutofill(tbaId ?? ""); + if (!autoFillData?.name && !confirm(`Invalid TBA ID: ${tbaId}. Save changes anyway?`)) + return; + + await api.updateCompNameAndTbaId(comp?._id, newCompName ?? "Unnamed", tbaId ?? NotLinkedToTba); + location.reload(); + } + + const allianceIndices: number[] = []; + for (let i = 0; i < games[comp?.gameId ?? defaultGameId].allianceSize; i++) { + allianceIndices.push(i); + } + + function getSavedCompetition() { + if (!comp || !team) return; + + // Save comp to local storage + const savedComp = new SavedCompetition(comp, games[comp?.gameId ?? defaultGameId], team, usersById, seasonSlug); + savedComp.lastAccessTime = Date.now(); + + savedComp.matches = toDict(matches); + savedComp.quantReports = toDict(reports); + savedComp.pitReports = toDict(pitreports); + savedComp.subjectiveReports = toDict(subjectiveReports); + + savedComp.picklists = picklists; + + return savedComp; + } -export default function CompetitionPage(props: ResolvedUrlData) { return ( - - + +
+
+ + +
+ +
+ + +
+ + { isManager && + setMatchBeingEdited(undefined)} + match={matches.find(m => m._id === matchBeingEdited!)} + reportsById={reportsById} + usersById={usersById} + comp={comp} + loadReports={loadReports} + loadMatches={loadMatches} + /> + } +
); } From 1a7cc55a2a6f7782ca4a346a42721aafd522df53 Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Mon, 18 Nov 2024 21:28:58 -0500 Subject: [PATCH 10/93] Un-PWAified Container --- components/Container.tsx | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/components/Container.tsx b/components/Container.tsx index 2021113e..47342eb3 100644 --- a/components/Container.tsx +++ b/components/Container.tsx @@ -14,7 +14,6 @@ import { RiWifiOffLine } from "react-icons/ri"; import Avatar from "./Avatar"; import Banner, { DiscordBanner } from "./Banner"; import { stat } from "fs"; -import useIsOnline from "@/lib/client/useIsOnline"; import { forceOfflineMode } from "@/lib/client/ClientUtils"; import Head from "next/head"; @@ -34,7 +33,6 @@ type ContainerProps = { export default function Container(props: ContainerProps) { const { session, status } = useCurrentSession(); const user = session?.user; - const isOnline = useIsOnline(); const authenticated = !forceOfflineMode() && status === "authenticated"; const [loadingTeams, setLoadingTeams] = useState(false); @@ -208,7 +206,7 @@ export default function Container(props: ContainerProps) { borderThickness={2} /> - ) : isOnline ? ( + ) : ( - ) : ( - -
- -
- Go to offline scouting - )} {/* From 6427c2170673d501fb7fd4418b1723767ee3ffe1 Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Wed, 20 Nov 2024 21:24:55 -0500 Subject: [PATCH 11/93] UnPWAified the stats page --- components/stats/StatsPage.tsx | 150 ---------------- .../[seasonSlug]/[competitonSlug]/stats.tsx | 164 ++++++++++++++++-- 2 files changed, 147 insertions(+), 167 deletions(-) delete mode 100644 components/stats/StatsPage.tsx diff --git a/components/stats/StatsPage.tsx b/components/stats/StatsPage.tsx deleted file mode 100644 index 62c8a821..00000000 --- a/components/stats/StatsPage.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import ClientApi from "@/lib/api/ClientApi"; -import { NotLinkedToTba } from "@/lib/client/ClientUtils"; -import { defaultGameId } from "@/lib/client/GameId"; -import { Competition, Pitreport, SubjectiveReport, Report, DbPicklist } from "@/lib/Types"; -import { useState, useEffect } from "react"; -import Container from "@/components/Container"; -import PicklistScreen from "./Picklist"; -import TeamPage from "./TeamPage"; -import PredictionScreen from "./PredictionScreen"; -import { games } from "@/lib/games"; - -const api = new ClientApi(); - -export type StatsPageProps = { - reports: Report[]; - pitReports: Pitreport[]; - subjectiveReports: SubjectiveReport[]; - competition: Competition; - picklists: DbPicklist; -}; - -export default function Stats(props: StatsPageProps) { - const [update, setUpdate] = useState(Date.now()); - const [updating, setUpdating] = useState(false); - const [reports, setReports] = useState(props.reports); - const [pitReports, setPitReports] = useState(props.pitReports); - const [subjectiveReports, setSubjectiveReports] = useState(props.subjectiveReports); - const [page, setPage] = useState(0); - const [usePublicData, setUsePublicData] = useState(true); - - useEffect(() => { - const i = setInterval(() => { - resync(); - }, 15000); - return () => { - clearInterval(i); - }; - }); - - const resync = async () => { - console.log("Resyncing..."); - setUpdating(true); - - const promises = [ - api - .competitionReports(props.competition._id!, true, usePublicData) - .then((data) => setReports(data)), - pitReports.length === 0 && props.competition._id && - api.getPitReports(props.competition._id).then((data) => { - setPitReports(data); - }), - api.getSubjectiveReportsForComp(props.competition._id!).then(setSubjectiveReports), - ].flat(); - - await Promise.all(promises); - - setUpdate(Date.now()); - setUpdating(false); - }; - - useEffect(() => { - resync(); - }, [usePublicData]); - - const teams: Set = new Set(); - reports.forEach((r) => teams.add(r.robotNumber)); - pitReports.forEach((r) => teams.add(r.teamNumber)); - subjectiveReports.forEach((r) => Object.keys(r.robotComments).forEach((c) => teams.add(+c))); //+str converts to number - - console.log("Reports", reports); - console.log("PitReports", pitReports); - console.log("SubjectiveReports", subjectiveReports); - console.log("Teams", teams); - - return ( - -
- {props.competition?.tbaId !== NotLinkedToTba && - - } - {/*

- Use public data? -

- setUsePublicData(e.target.checked)} /> */} -
- - - {page === 0 && ( - - )} - {page === 1 - && - } - { - page === 2 && - } -
- ); -} \ No newline at end of file diff --git a/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/stats.tsx b/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/stats.tsx index 07d86c61..0d72667d 100644 --- a/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/stats.tsx +++ b/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/stats.tsx @@ -1,25 +1,155 @@ -import Container from "@/components/Container"; -import { GetServerSideProps } from "next"; -import UrlResolver, { SerializeDatabaseObject, SerializeDatabaseObjects } from "@/lib/UrlResolver"; - -import { getDatabase } from "@/lib/MongoDB"; -import CollectionId from "@/lib/client/CollectionId"; -import { useEffect, useRef, useState } from "react"; -import { Competition, DbPicklist, Pitreport, Report, SubjectiveReport } from "@/lib/Types"; -import TeamPage from "@/components/stats/TeamPage"; -import PicklistScreen from "@/components/stats/Picklist"; -import { FaSync } from "react-icons/fa"; -import { TimeString } from "@/lib/client/FormatTime"; - import ClientApi from "@/lib/api/ClientApi"; -import { team } from "slack"; import { NotLinkedToTba } from "@/lib/client/ClientUtils"; import { defaultGameId } from "@/lib/client/GameId"; -import StatsPage, { StatsPageProps } from "@/components/stats/StatsPage"; +import { Competition, Pitreport, SubjectiveReport, Report, DbPicklist } from "@/lib/Types"; +import { useState, useEffect } from "react"; +import Container from "@/components/Container"; +import { games } from "@/lib/games"; +import CollectionId from "@/lib/client/CollectionId"; +import { getDatabase } from "@/lib/MongoDB"; +import UrlResolver, { SerializeDatabaseObjects, SerializeDatabaseObject } from "@/lib/UrlResolver"; import { ObjectId } from "bson"; +import { GetServerSideProps } from "next"; +import PicklistScreen from "@/components/stats/Picklist"; +import PredictionScreen from "@/components/stats/PredictionScreen"; +import TeamPage from "@/components/stats/TeamPage"; + +const api = new ClientApi(); + +export default function Stats(props: { + reports: Report[]; + pitReports: Pitreport[]; + subjectiveReports: SubjectiveReport[]; + competition: Competition; + picklists: DbPicklist; +}) { + const [update, setUpdate] = useState(Date.now()); + const [updating, setUpdating] = useState(false); + const [reports, setReports] = useState(props.reports); + const [pitReports, setPitReports] = useState(props.pitReports); + const [subjectiveReports, setSubjectiveReports] = useState(props.subjectiveReports); + const [page, setPage] = useState(0); + const [usePublicData, setUsePublicData] = useState(true); + + useEffect(() => { + const i = setInterval(() => { + resync(); + }, 15000); + return () => { + clearInterval(i); + }; + }); + + const resync = async () => { + console.log("Resyncing..."); + setUpdating(true); + + const promises = [ + api + .competitionReports(props.competition._id!, true, usePublicData) + .then((data) => setReports(data)), + pitReports.length === 0 && props.competition._id && + api.getPitReports(props.competition._id).then((data) => { + setPitReports(data); + }), + api.getSubjectiveReportsForComp(props.competition._id!).then(setSubjectiveReports), + ].flat(); + + await Promise.all(promises); + + setUpdate(Date.now()); + setUpdating(false); + }; + + useEffect(() => { + resync(); + }, [usePublicData]); + + const teams: Set = new Set(); + reports.forEach((r) => teams.add(r.robotNumber)); + pitReports.forEach((r) => teams.add(r.teamNumber)); + subjectiveReports.forEach((r) => Object.keys(r.robotComments).forEach((c) => teams.add(+c))); //+str converts to number + + console.log("Reports", reports); + console.log("PitReports", pitReports); + console.log("SubjectiveReports", subjectiveReports); + console.log("Teams", teams); + + return ( + +
+ {props.competition?.tbaId !== NotLinkedToTba && + + } + {/*

+ Use public data? +

+ setUsePublicData(e.target.checked)} /> */} +
+ -export default function Stats(props: StatsPageProps) { - return ; + {page === 0 && ( + + )} + {page === 1 + && + } + { + page === 2 && + } +
+ ); } export const getServerSideProps: GetServerSideProps = async (context) => { From cea3246f8e299e3c53fb6376360168dc1a9f25b2 Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Wed, 20 Nov 2024 21:41:56 -0500 Subject: [PATCH 12/93] De-PWAified Form --- components/forms/Form.tsx | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/components/forms/Form.tsx b/components/forms/Form.tsx index 57c42715..a2afbfff 100644 --- a/components/forms/Form.tsx +++ b/components/forms/Form.tsx @@ -14,7 +14,6 @@ import { CommentBox } from "./Comment"; import { IncrementButton } from "./Buttons"; import Slider from "./Sliders"; import { BlockElement, FormLayout, FormElement } from "@/lib/Layout"; -import { updateCompInLocalStorage } from "@/lib/client/offlineUtils"; import Loading from "../Loading"; import QRCode from "react-qr-code"; import Card from "../Card"; @@ -51,21 +50,6 @@ export default function Form(props: FormProps) { .then(() => { console.log("Submitted form successfully!"); }) - .catch((e) => { - console.error(e); - - if (!props.compId) - return; - - updateCompInLocalStorage(props.compId, (comp) => { - const report = comp.quantReports[props.report._id ?? ""] - - report.data = formData; - report.submitted = true; - - return comp; - }); - }) .finally(() => { if (location.href.includes("offline")) location.href = `/offline/${props.compId}`; From 2ed2de659c464f1a3c64398a29a2970d810d63e2 Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Wed, 20 Nov 2024 21:49:53 -0500 Subject: [PATCH 13/93] Remove unneeded import --- components/forms/Form.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/components/forms/Form.tsx b/components/forms/Form.tsx index a2afbfff..0adec956 100644 --- a/components/forms/Form.tsx +++ b/components/forms/Form.tsx @@ -15,7 +15,6 @@ import { IncrementButton } from "./Buttons"; import Slider from "./Sliders"; import { BlockElement, FormLayout, FormElement } from "@/lib/Layout"; import Loading from "../Loading"; -import QRCode from "react-qr-code"; import Card from "../Card"; import { Analytics } from "@/lib/client/Analytics"; import QrCode from "../QrCode"; From f22b7a80228c51df0c5be1782bae1fe65bbdb107 Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Wed, 20 Nov 2024 21:53:19 -0500 Subject: [PATCH 14/93] Test signed commit --- components/forms/Form.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/components/forms/Form.tsx b/components/forms/Form.tsx index 0adec956..67ccf638 100644 --- a/components/forms/Form.tsx +++ b/components/forms/Form.tsx @@ -2,10 +2,8 @@ import { AllianceColor, Report, QuantData, FieldPos } from "@/lib/Types"; import { useCallback, useState } from "react"; import FormPage from "./FormPages"; import { useCurrentSession } from "@/lib/client/useCurrentSession"; - import { FaArrowLeft, FaArrowRight } from "react-icons/fa"; import { TfiReload } from "react-icons/tfi"; - import ClientApi from "@/lib/api/ClientApi"; import Checkbox from "./Checkboxes"; import { camelCaseToTitleCase } from "@/lib/client/ClientUtils"; From b6ab47c025aea8d9106661b2dfe498d07ba6dac2 Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Wed, 20 Nov 2024 21:56:33 -0500 Subject: [PATCH 15/93] Test signed commit 2 From 7cbcaf200178388d28667d4735633d815836333e Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Wed, 20 Nov 2024 21:56:41 -0500 Subject: [PATCH 16/93] Test signed commit 3 From edd8d0746a41c317b5e18891fb4b478ee37a31f9 Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Thu, 21 Nov 2024 15:45:47 -0500 Subject: [PATCH 17/93] UnPWAify Pit Reports --- components/PitReport.tsx | 21 -- .../[competitonSlug]/pit/[...pitreportId].tsx | 222 ++++++++++++++++-- 2 files changed, 203 insertions(+), 40 deletions(-) diff --git a/components/PitReport.tsx b/components/PitReport.tsx index 1ff26a9d..7e8b1b79 100644 --- a/components/PitReport.tsx +++ b/components/PitReport.tsx @@ -8,7 +8,6 @@ import Flex from "./Flex"; import Checkbox from "./forms/Checkboxes"; import ImageUpload from "./forms/ImageUpload"; import Card from "./Card"; -import { getCompFromLocalStorage, updateCompInLocalStorage } from "@/lib/client/offlineUtils"; import QRCode from "react-qr-code"; import { Analytics } from "@/lib/client/Analytics"; import QrCode from "./QrCode"; @@ -46,26 +45,6 @@ export default function PitReportForm(props: { pitReport: Pitreport, layout: For submitted: true, submitter: session?.user?._id }) - .catch((e) => { - console.error("Error submitting pitreport", e); - - if (!props.compId || !pitreport._id) return; - - updateCompInLocalStorage(props.compId, (comp) => { - if (!pitreport._id) { - console.error("Pitreport has no _id"); - return; - } - - console.log("Updating pitreport in local storage"); - - comp.pitReports[pitreport._id] = { - ...pitreport, - submitted: true, - submitter: session?.user?._id - }; - }); - }) .then(() => { Analytics.pitReportSubmitted(pitreport.teamNumber, props.usersteamNumber ?? -1, props.compName ?? "Unknown", props.username ?? "Unknown"); }) diff --git a/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/pit/[...pitreportId].tsx b/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/pit/[...pitreportId].tsx index 23b26f3d..a4090483 100644 --- a/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/pit/[...pitreportId].tsx +++ b/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/pit/[...pitreportId].tsx @@ -1,30 +1,214 @@ -import { getDatabase } from "@/lib/MongoDB"; -import { Game, PitReportData, Pitreport } from "@/lib/Types"; -import { ObjectId } from "bson"; -import { GetServerSideProps } from "next"; -import UrlResolver, { SerializeDatabaseObject } from "@/lib/UrlResolver"; - -import Container from "@/components/Container"; +import ClientApi from "@/lib/api/ClientApi"; import { useCurrentSession } from "@/lib/client/useCurrentSession"; -import { games } from "@/lib/games"; -import { GameId } from "@/lib/client/GameId"; -import { makeObjSerializeable } from "@/lib/client/ClientUtils"; -import PitReportForm from "@/components/PitReport"; -import { BlockElement, FormLayout, FormElement } from "@/lib/Layout"; +import { FormLayout, FormElement, BlockElement } from "@/lib/Layout"; +import { AllianceColor, FieldPos, Game, Pitreport, PitReportData } from "@/lib/Types"; +import { useState, useCallback, Fragment } from "react"; +import { FaRobot } from "react-icons/fa"; import { Analytics } from "@/lib/client/Analytics"; -import ClientApi from "@/lib/api/ClientApi"; +import { camelCaseToTitleCase, makeObjSerializeable } from "@/lib/client/ClientUtils"; +import { GameId } from "@/lib/client/GameId"; import CollectionId from "@/lib/client/CollectionId"; +import { games } from "@/lib/games"; +import { getDatabase } from "@/lib/MongoDB"; +import UrlResolver, { SerializeDatabaseObject } from "@/lib/UrlResolver"; +import { ObjectId } from "bson"; +import { GetServerSideProps } from "next"; +import Flex from "@/components/Flex"; +import Checkbox from "@/components/forms/Checkboxes"; +import FieldPositionSelector from "@/components/forms/FieldPositionSelector"; +import ImageUpload from "@/components/forms/ImageUpload"; +import QrCode from "@/components/QrCode"; +import Card from "@/components/Card"; const api = new ClientApi(); -export default function PitreportForm(props: { pitReport: Pitreport, layout: FormLayout, teamNumber: number, compName: string, game: Game }) { - const { session, status } = useCurrentSession(); - const hide = status === "authenticated"; +export default function PitReportForm(props: { pitReport: Pitreport, layout: FormLayout, compId?: string, + usersteamNumber?: number, compName?: string, username?: string, game: Game }) { + const { session } = useCurrentSession(); + + const [pitreport, setPitreport] = useState(props.pitReport); + + const setCallback = useCallback( + (key: any, value: boolean | string | number | object) => { + setPitreport((old) => { + let copy = structuredClone(old); + //@ts-expect-error + copy.data[key] = value; + return copy; + }); + }, + [] + ); + + async function submit() { + // Remove _id from object + const { _id, ...report } = pitreport; + + console.log("Submitting pitreport", report); + api.updatePitreport(props.pitReport?._id!, { + ...report, + submitted: true, + submitter: session?.user?._id + }) + .then(() => { + Analytics.pitReportSubmitted(pitreport.teamNumber, props.usersteamNumber ?? -1, props.compName ?? "Unknown", props.username ?? "Unknown"); + }) + .finally(() => { + location.href = location.href.substring(0, location.href.lastIndexOf("/pit")); + }); + } + + function getComponent(element: FormElement, isLastInHeader: boolean, index: number) { + const key = element.key as string; + + if (element.type === "image") + return + + if (element.type === "boolean") + return + + if (element.type === "number") + // lets us the key attribute on a <> element + return ( +

{element.label}

+ setCallback(key, e.target.value)} + type="number" + className="input input-bordered" + placeholder={element.label} + /> +
); + + if (element.type === "string") + return ( + -
- ); + return ( +
+

Comments:

+ +
+ ); } diff --git a/components/forms/FieldPositionSelector.tsx b/components/forms/FieldPositionSelector.tsx index d043ee8f..8aac43f3 100644 --- a/components/forms/FieldPositionSelector.tsx +++ b/components/forms/FieldPositionSelector.tsx @@ -5,89 +5,90 @@ import { useCallback, useEffect, useState } from "react"; import useDynamicState from "@/lib/client/useDynamicState"; const Sketch = dynamic(() => import("react-p5").then((mod) => mod.default), { - ssr: false, + ssr: false, }); let bg: p5Types.Image; let dropped = false; -export default function FieldPositionSelector(props: { - alliance: AllianceColor, - fieldImagePrefix: string, - initialPos: FieldPos, - callback: (key: string, value: FieldPos) => void +export default function FieldPositionSelector(props: { + alliance: AllianceColor; + fieldImagePrefix: string; + initialPos: FieldPos; + callback: (key: string, value: FieldPos) => void; }) { - const [mx, setMx, getMx] = useDynamicState(props.initialPos?.x ?? 0); - const [my, setMy, getMy] = useDynamicState(props.initialPos?.y ?? 0); - const [a, setA, getA] = useDynamicState(props.initialPos?.angle ?? 0); - const [ax, setAx] = useState(0); - const [ay, setAy] = useState(0); + const [mx, setMx, getMx] = useDynamicState(props.initialPos?.x ?? 0); + const [my, setMy, getMy] = useDynamicState(props.initialPos?.y ?? 0); + const [a, setA, getA] = useDynamicState(props.initialPos?.angle ?? 0); + const [ax, setAx] = useState(0); + const [ay, setAy] = useState(0); - useEffect(() => { - props.callback("AutoStart", { x: mx ?? 0, y: my ?? 0, angle: a ?? 0 }); - }, [mx, my, a, ax, ay]); + useEffect(() => { + props.callback("AutoStart", { x: mx ?? 0, y: my ?? 0, angle: a ?? 0 }); + }, [mx, my, a, ax, ay]); - const setup = (p5: p5Types, canvasParentRef: Element) => { - bg = p5.loadImage( - props.alliance === AllianceColor.Blue - ? `/fields/${props.fieldImagePrefix}Blue.png` - : `/fields/${props.fieldImagePrefix}Red.png`, - ); + const setup = (p5: p5Types, canvasParentRef: Element) => { + bg = p5.loadImage( + props.alliance === AllianceColor.Blue + ? `/fields/${props.fieldImagePrefix}Blue.png` + : `/fields/${props.fieldImagePrefix}Red.png`, + ); - const ctx = p5.createCanvas(350, 300).parent(canvasParentRef); - ctx.mousePressed(() => { - - if (!dropped) { - dropped = true; - setMx(p5.mouseX); - setMy(p5.mouseY); - } else { - dropped = false; - setAx(p5.mouseX); - setAy(p5.mouseY); + const ctx = p5.createCanvas(350, 300).parent(canvasParentRef); + ctx.mousePressed(() => { + if (!dropped) { + dropped = true; + setMx(p5.mouseX); + setMy(p5.mouseY); + } else { + dropped = false; + setAx(p5.mouseX); + setAy(p5.mouseY); - getMx((mx) => { - getMy((my) => { - setA(p5.atan2(p5.mouseY - (my ?? 0), p5.mouseX - (mx ?? 0))); - }); - }); - } - }); + getMx((mx) => { + getMy((my) => { + setA(p5.atan2(p5.mouseY - (my ?? 0), p5.mouseX - (mx ?? 0))); + }); + }); + } + }); - p5.rectMode(p5.CENTER); - }; + p5.rectMode(p5.CENTER); + }; - const draw = (p5: p5Types) => { - p5.background(bg); - p5.stroke("black"); - p5.strokeWeight(1); - p5.fill("lightgrey"); + const draw = (p5: p5Types) => { + p5.background(bg); + p5.stroke("black"); + p5.strokeWeight(1); + p5.fill("lightgrey"); - if (a == 0 && mx == 0 && my == 0) - return; + if (a == 0 && mx == 0 && my == 0) return; - p5.translate(mx ?? 0, my ?? 0); - setA(a); - p5.rotate(a ?? 0); - p5.rect(0, 0, 35, 35, 10); - p5.fill("black"); - p5.rect(12.5, 15, 10, 5, 3); - p5.rect(12.5, -15, 10, 5, 3); - p5.rect(-12.5, 15, 10, 5, 3); - p5.rect(-12.5, -15, 10, 5, 3); + p5.translate(mx ?? 0, my ?? 0); + setA(a); + p5.rotate(a ?? 0); + p5.rect(0, 0, 35, 35, 10); + p5.fill("black"); + p5.rect(12.5, 15, 10, 5, 3); + p5.rect(12.5, -15, 10, 5, 3); + p5.rect(-12.5, 15, 10, 5, 3); + p5.rect(-12.5, -15, 10, 5, 3); - p5.stroke("red"); - p5.strokeWeight(3); - p5.line(0, 0, 35, 0); - }; + p5.stroke("red"); + p5.strokeWeight(3); + p5.line(0, 0, 35, 0); + }; - return ( -
-
- -
- -

Tap to set the position

-
- ); + return ( +
+
+ +
+ +

Tap to set the position

+
+ ); } diff --git a/components/forms/Form.tsx b/components/forms/Form.tsx index dd7bdfc5..612a634e 100644 --- a/components/forms/Form.tsx +++ b/components/forms/Form.tsx @@ -18,256 +18,308 @@ import { Analytics } from "@/lib/client/Analytics"; const api = new ClientApi(); export type FormProps = { - report: Report; - layout: FormLayout; - fieldImagePrefix: string; - teamNumber: number; - compName: string; - compId: string; + report: Report; + layout: FormLayout; + fieldImagePrefix: string; + teamNumber: number; + compName: string; + compId: string; }; export default function Form(props: FormProps) { - const { session, status } = useCurrentSession(); - - const [page, setPage] = useState(0); - const [formData, setFormData] = useState(props.report?.data); - const [syncing, setSyncing] = useState(false); - const [submitting, setSubmitting] = useState(false); - - const alliance = props.report?.color; - - async function submitForm() { - console.log("Submitting form..."); - - setSubmitting(true); - - api.submitForm(props.report?._id!, formData) - .then(() => { - console.log("Submitted form successfully!"); - }) - .finally(() => { - if (location.href.includes("offline")) - location.href = `/offline/${props.compId}`; - else - location.href = location.href.substring(0, location.href.lastIndexOf("/")); - }); - - Analytics.quantReportSubmitted(props.report.robotNumber, props.teamNumber, props.compName, session.user?.name ?? "Unknown User"); - } - - const sync = async () => { - setSyncing(true); - await api.updateReport({ data: formData }, props.report?._id!); - setSyncing(false); - }; - - const setCallback = useCallback( - (key: any, value: boolean | string | number | object) => { - setFormData((old) => { - let copy = structuredClone(old); - copy[key] = value; - sync(); - return copy; - }); - }, - [] - ); - - useCallback(() => { - // Set all Nan values to 0 - for (const key in formData) { - if (typeof formData[key] === "number" && isNaN(formData[key])) { - setCallback(key, 0); - } - } - - setFormData(formData); - }, [props.report?.data]); - - function elementToNode(element: FormElement) { - const key = element.key as string; - - if (element.type === "boolean") { - return ( - - ); - } - - if (element.type === "startingPos") { - return ( - - ); - } - - if (element.type === "number") { - return ( - { - setCallback(key, parseInt(e.target.value)); - }} - key={key} - /> - ); - } - - if (element.type === "string") { - return ( - - ); - } - - // Enum - if (typeof element.type === "object") { - return ( - - ); - } - } - - function blockToNode(block: BlockElement) { - const colCount = block.length; - const rowCount = block[0].length; - - const elements = []; - - // console.log(`Block: ${rowCount}x${colCount}: ${block.flat().map(e => e.key).join(", ")}`); - for (let r = 0; r < rowCount; r++) { - for (let c = 0; c < colCount; c++) { - let topRounding = "", bottomRounding = ""; - - if (r === 0) { - if (c === 0 || c === colCount - 1) - topRounding = "t"; - - if (colCount > 1) { - if (c === 0) topRounding += "l"; - if (c === colCount - 1) topRounding += "r"; - } - } - - if (r === rowCount - 1) { - if (c === 0 || c === colCount - 1) - bottomRounding = "b"; - - if (colCount > 1) { - if (c === 0) bottomRounding += "l"; - if (c === colCount - 1) bottomRounding += "r"; - } - } - - // console.log(`(${r}, ${c}) - ${topRounding}, ${bottomRounding}`); - - if (!BlockElement.isBlock(block[c][r])) { - const element = block[c][r] as FormElement; - - elements.push(); - } - } - } - - return ( -
e.keys).join(",")} className="w-full flex flex-col items-center"> -
- {elements} -
-
- ); - } - - // Use an array to preserve the order of pages - const layout: { page: string, elements: (FormElement | BlockElement)[] }[] = []; - Object.entries(props.layout).map(([header, elements]) => { - layout.push({ page: header, elements }); - }); - - const pages = layout.map((page, index) => { - const inputs = page.elements.map((element) => { - return BlockElement.isBlock(element) ? blockToNode(element) : elementToNode(element as FormElement); - }); - - return ( - - {inputs} - - ); - }); - - pages.push( - - - - ); - - return ( -
- {pages[page]} -
-
-
-

- #{[props.report.robotNumber]} -

-
- - - {syncing ? ( -

- {" "} - {" "} - Syncing Changes -

- ) : ( - <> - )} - - -
-
-
-
-
- ); + const { session, status } = useCurrentSession(); + + const [page, setPage] = useState(0); + const [formData, setFormData] = useState(props.report?.data); + const [syncing, setSyncing] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const alliance = props.report?.color; + + async function submitForm() { + console.log("Submitting form..."); + + setSubmitting(true); + + api + .submitForm(props.report?._id!, formData) + .then(() => { + console.log("Submitted form successfully!"); + }) + .finally(() => { + if (location.href.includes("offline")) + location.href = `/offline/${props.compId}`; + else + location.href = location.href.substring( + 0, + location.href.lastIndexOf("/"), + ); + }); + + Analytics.quantReportSubmitted( + props.report.robotNumber, + props.teamNumber, + props.compName, + session.user?.name ?? "Unknown User", + ); + } + + const sync = async () => { + setSyncing(true); + await api.updateReport({ data: formData }, props.report?._id!); + setSyncing(false); + }; + + const setCallback = useCallback( + (key: any, value: boolean | string | number | object) => { + setFormData((old) => { + let copy = structuredClone(old); + copy[key] = value; + sync(); + return copy; + }); + }, + [], + ); + + useCallback(() => { + // Set all Nan values to 0 + for (const key in formData) { + if (typeof formData[key] === "number" && isNaN(formData[key])) { + setCallback(key, 0); + } + } + + setFormData(formData); + }, [props.report?.data]); + + function elementToNode(element: FormElement) { + const key = element.key as string; + + if (element.type === "boolean") { + return ( + + ); + } + + if (element.type === "startingPos") { + return ( + + ); + } + + if (element.type === "number") { + return ( + { + setCallback(key, parseInt(e.target.value)); + }} + key={key} + /> + ); + } + + if (element.type === "string") { + return ( + + ); + } + + // Enum + if (typeof element.type === "object") { + return ( + + ); + } + } + + function blockToNode(block: BlockElement) { + const colCount = block.length; + const rowCount = block[0].length; + + const elements = []; + + // console.log(`Block: ${rowCount}x${colCount}: ${block.flat().map(e => e.key).join(", ")}`); + for (let r = 0; r < rowCount; r++) { + for (let c = 0; c < colCount; c++) { + let topRounding = "", + bottomRounding = ""; + + if (r === 0) { + if (c === 0 || c === colCount - 1) topRounding = "t"; + + if (colCount > 1) { + if (c === 0) topRounding += "l"; + if (c === colCount - 1) topRounding += "r"; + } + } + + if (r === rowCount - 1) { + if (c === 0 || c === colCount - 1) bottomRounding = "b"; + + if (colCount > 1) { + if (c === 0) bottomRounding += "l"; + if (c === colCount - 1) bottomRounding += "r"; + } + } + + // console.log(`(${r}, ${c}) - ${topRounding}, ${bottomRounding}`); + + if (!BlockElement.isBlock(block[c][r])) { + const element = block[c][r] as FormElement; + + elements.push( + , + ); + } + } + } + + return ( +
e.keys).join(",")} + className="w-full flex flex-col items-center" + > +
+ {elements} +
+
+ ); + } + + // Use an array to preserve the order of pages + const layout: { + page: string; + elements: (FormElement | BlockElement)[]; + }[] = []; + Object.entries(props.layout).map(([header, elements]) => { + layout.push({ page: header, elements }); + }); + + const pages = layout.map((page, index) => { + const inputs = page.elements.map((element) => { + return BlockElement.isBlock(element) + ? blockToNode(element) + : elementToNode(element as FormElement); + }); + + return ( + + {inputs} + + ); + }); + + pages.push( + + + , + ); + + return ( +
+ {pages[page]} +
+
+
+

+ #{[props.report.robotNumber]} +

+
+ + + {syncing ? ( +

+ {" "} + {" "} + Syncing Changes +

+ ) : ( + <> + )} + + +
+
+
+
+
+ ); } diff --git a/components/forms/FormPages.tsx b/components/forms/FormPages.tsx index a73e96cc..63b835ab 100644 --- a/components/forms/FormPages.tsx +++ b/components/forms/FormPages.tsx @@ -2,34 +2,34 @@ import { ReactNode } from "react"; import { AllianceColor, QuantData } from "@/lib/Types"; export type PageProps = { - alliance: AllianceColor; - data: QuantData; - callback: (key: string, value: string | number | boolean) => void; - fieldImagePrefix?: string; + alliance: AllianceColor; + data: QuantData; + callback: (key: string, value: string | number | boolean) => void; + fieldImagePrefix?: string; }; export type EndPageProps = { - alliance: AllianceColor; - data: QuantData; - submit: () => void; - callback: (key: string, value: string | number | boolean) => void; + alliance: AllianceColor; + data: QuantData; + submit: () => void; + callback: (key: string, value: string | number | boolean) => void; }; export default function FormPage(props: { - children: ReactNode; - title: string; + children: ReactNode; + title: string; }) { - return ( -
-
-
-

{props.title}

-
-
- {props.children} -
-
-
-
- ); -} \ No newline at end of file + return ( +
+
+
+

{props.title}

+
+
+ {props.children} +
+
+
+
+ ); +} diff --git a/components/forms/ImageUpload.tsx b/components/forms/ImageUpload.tsx index ac3fb921..87c71e21 100644 --- a/components/forms/ImageUpload.tsx +++ b/components/forms/ImageUpload.tsx @@ -4,92 +4,95 @@ import React, { useState } from "react"; import { FaFileUpload, FaImage } from "react-icons/fa"; const DefaultCompressionOptions = { - maxSizeMB: 0.5, - maxWidthOrHeight: 1920, - useWebWorker: true, + maxSizeMB: 0.5, + maxWidthOrHeight: 1920, + useWebWorker: true, }; export default function ImageUpload(props: { - report: Pitreport; - callback: (key: string, value: string | number | boolean) => void; + report: Pitreport; + callback: (key: string, value: string | number | boolean) => void; }) { - const [imageUrl, setImageUrl] = useState( - props.report.data?.image !== "/robot.jpg" ? props.report.data?.image : "" - ); - const [uploadProgress, setUploadProgress] = useState(-1); + const [imageUrl, setImageUrl] = useState( + props.report.data?.image !== "/robot.jpg" ? props.report.data?.image : "", + ); + const [uploadProgress, setUploadProgress] = useState(-1); - const onUpload = async (event: any) => { - setUploadProgress(50); - const data = event.target.files[0]; - console.log(`originalFile size ${data.size / 1024 / 1024} MB`); + const onUpload = async (event: any) => { + setUploadProgress(50); + const data = event.target.files[0]; + console.log(`originalFile size ${data.size / 1024 / 1024} MB`); - const compressionOptions = { - ...DefaultCompressionOptions, - fileType: (data.name as string).split(".").at(-1), - }; + const compressionOptions = { + ...DefaultCompressionOptions, + fileType: (data.name as string).split(".").at(-1), + }; - const compressedFile = await imageCompression(data, compressionOptions); - console.log(`compressedFile size ${compressedFile.size / 1024 / 1024} MB`); + const compressedFile = await imageCompression(data, compressionOptions); + console.log(`compressedFile size ${compressedFile.size / 1024 / 1024} MB`); - const formData = new FormData(); - formData.append("files", compressedFile); + const formData = new FormData(); + formData.append("files", compressedFile); - const res = await fetch("/api/img/upload", { - method: "POST", - headers: { - "gearbox-auth": "gearboxiscool", - }, - body: formData, - }); + const res = await fetch("/api/img/upload", { + method: "POST", + headers: { + "gearbox-auth": "gearboxiscool", + }, + body: formData, + }); - if (res.status === 200) { - const data = await res.json(); - const url = `/api/img/get?filename=${data.filename}`; + if (res.status === 200) { + const data = await res.json(); + const url = `/api/img/get?filename=${data.filename}`; - setTimeout(() => { - setImageUrl(url); - props.callback("image", url); - setUploadProgress(100); - }, 1000); - } else { - setUploadProgress(-1); - } - }; + setTimeout(() => { + setImageUrl(url); + props.callback("image", url); + setUploadProgress(100); + }, 1000); + } else { + setUploadProgress(-1); + } + }; - return ( -
- {!imageUrl ? ( -
- - Upload an Image first -
- ) : ( - - )} - {uploadProgress > 0 ? ( - - ) : ( - <> - )} - -
- ); + return ( +
+ {!imageUrl ? ( +
+ + Upload an Image first +
+ ) : ( + + )} + {uploadProgress > 0 ? ( + + ) : ( + <> + )} + +
+ ); } diff --git a/components/forms/Sliders.tsx b/components/forms/Sliders.tsx index d63af886..59619448 100644 --- a/components/forms/Sliders.tsx +++ b/components/forms/Sliders.tsx @@ -1,42 +1,36 @@ import { QuantData } from "@/lib/Types"; export type SliderProps = { - data: QuantData; - callback: (key: string, value: string | number | boolean) => void; - possibleValues: { [key: string]: any }; - title: string; - value: any; - dataKey: keyof QuantData; + data: QuantData; + callback: (key: string, value: string | number | boolean) => void; + possibleValues: { [key: string]: any }; + title: string; + value: any; + dataKey: keyof QuantData; }; export default function Slider(props: SliderProps) { - const keys = Object.keys(props.possibleValues); - const num = keys.indexOf(props.value); + const keys = Object.keys(props.possibleValues); + const num = keys.indexOf(props.value); - return ( -
-

{props.title}

- { - props.callback(props.dataKey as string, keys[e.target.valueAsNumber]); - }} - type="range" - min={0} - max={keys.length - 1} - value={num} - className="range range-primary" - step="1" - /> -
- { - keys.map((key, i) => { - return ( - - {key} - - ); - }) - } -
-
- ); + return ( +
+

{props.title}

+ { + props.callback(props.dataKey as string, keys[e.target.valueAsNumber]); + }} + type="range" + min={0} + max={keys.length - 1} + value={num} + className="range range-primary" + step="1" + /> +
+ {keys.map((key, i) => { + return {key}; + })} +
+
+ ); } diff --git a/components/stats/Graph.tsx b/components/stats/Graph.tsx index 50d93378..a13fad8e 100644 --- a/components/stats/Graph.tsx +++ b/components/stats/Graph.tsx @@ -1,72 +1,75 @@ import { - Chart as ChartJS, - CategoryScale, - LinearScale, - BarElement, - Title, - Tooltip, - Legend, - ChartData, + Chart as ChartJS, + CategoryScale, + LinearScale, + BarElement, + Title, + Tooltip, + Legend, + ChartData, } from "chart.js"; import { Bar } from "react-chartjs-2"; ChartJS.register( - CategoryScale, - LinearScale, - BarElement, - Title, - Tooltip, - Legend + CategoryScale, + LinearScale, + BarElement, + Title, + Tooltip, + Legend, ); const options = { - responsive: true, - layout: { - padding: 14, - }, - scales: { - y: { - grid: { - color: "#404040", - }, - }, - x: { - grid: { - color: "#404040", - }, - }, - }, - plugins: { - colors: {}, - legend: { - position: "top" as const, - }, - title: { - display: false, - text: "Graph", - }, - }, + responsive: true, + layout: { + padding: 14, + }, + scales: { + y: { + grid: { + color: "#404040", + }, + }, + x: { + grid: { + color: "#404040", + }, + }, + }, + plugins: { + colors: {}, + legend: { + position: "top" as const, + }, + title: { + display: false, + text: "Graph", + }, + }, }; export default function BarGraph(props: { - label: string; - data: number[]; - xlabels: string[]; + label: string; + data: number[]; + xlabels: string[]; }) { - const data = { - labels: props.xlabels, - datasets: [ - { - label: props.label, - data: props.data, - backgroundColor: "rgba(255, 99, 132, 0.5)", - }, - ], - }; + const data = { + labels: props.xlabels, + datasets: [ + { + label: props.label, + data: props.data, + backgroundColor: "rgba(255, 99, 132, 0.5)", + }, + ], + }; - return ( -
- -
- ); + return ( +
+ +
+ ); } diff --git a/components/stats/Heatmap.tsx b/components/stats/Heatmap.tsx index f2e71a05..6fb59085 100644 --- a/components/stats/Heatmap.tsx +++ b/components/stats/Heatmap.tsx @@ -4,87 +4,95 @@ import { useEffect } from "react"; import { FieldPos, Report } from "@/lib/Types"; const Sketch = dynamic(() => import("react-p5").then((mod) => mod.default), { - ssr: false, + ssr: false, }); interface Position { - x: number; - y: number; + x: number; + y: number; } export interface Dot { - x?: number; - y?: number - color: { - r: number; - g: number; - b: number; - a: number; - }; - size: number; - label: string; + x?: number; + y?: number; + color: { + r: number; + g: number; + b: number; + a: number; + }; + size: number; + label: string; } var bg: p5Types.Image; const resolution = 25; var positions: Position[] = []; -export default function Heatmap(props: { selectedReports: Report[], fieldImagePrefix: string, dots: Dot[] }) { - const setup = (p5: p5Types, canvasParentRef: Element) => { - bg = p5.loadImage(`/fields/${props.fieldImagePrefix}Blue.PNG`); - p5.createCanvas(350, 300).parent(canvasParentRef); - p5.rectMode(p5.CENTER); - p5.stroke(1); - }; +export default function Heatmap(props: { + selectedReports: Report[]; + fieldImagePrefix: string; + dots: Dot[]; +}) { + const setup = (p5: p5Types, canvasParentRef: Element) => { + bg = p5.loadImage(`/fields/${props.fieldImagePrefix}Blue.PNG`); + p5.createCanvas(350, 300).parent(canvasParentRef); + p5.rectMode(p5.CENTER); + p5.stroke(1); + }; - const inSquare = (x: number, y: number, w: number) => { - let counter = 0; - positions.forEach((pos) => { - if (Math.abs(pos.x - x) <= w) { - if (Math.abs(pos.y - y) <= w) { - counter++; - } - } - }); - return counter; - }; + const inSquare = (x: number, y: number, w: number) => { + let counter = 0; + positions.forEach((pos) => { + if (Math.abs(pos.x - x) <= w) { + if (Math.abs(pos.y - y) <= w) { + counter++; + } + } + }); + return counter; + }; - useEffect(() => { - if (props.selectedReports) { - positions = []; - props.selectedReports.forEach((report) => { - if (report.data.AutoStart) - positions.push({ - x: report.data.AutoStart.x, - y: report.data.AutoStart.y, - }); - } - ); - } - }); + useEffect(() => { + if (props.selectedReports) { + positions = []; + props.selectedReports.forEach((report) => { + if (report.data.AutoStart) + positions.push({ + x: report.data.AutoStart.x, + y: report.data.AutoStart.y, + }); + }); + } + }); - const draw = (p5: p5Types) => { - p5.background(bg); + const draw = (p5: p5Types) => { + p5.background(bg); - for (var x = 0; x < p5.width + resolution; x += resolution) { - for (var y = 0; y < p5.height + resolution; y += resolution) { - var v = inSquare(x, y, resolution) / (positions.length / 2); - p5.fill( - p5.lerpColor(p5.color(124, 252, 0, 150), p5.color(255, 0, 0, 200), v), - ); - p5.rect(x, y, resolution, resolution); - } - } - - props.dots.forEach((dot) => { - if (dot && dot.x && dot.y) { - p5.fill(dot.color.r, dot.color.g, dot.color.b, dot.color.a); - p5.ellipse(dot.x, dot.y, dot.size, dot.size); - } - }); + for (var x = 0; x < p5.width + resolution; x += resolution) { + for (var y = 0; y < p5.height + resolution; y += resolution) { + var v = inSquare(x, y, resolution) / (positions.length / 2); + p5.fill( + p5.lerpColor(p5.color(124, 252, 0, 150), p5.color(255, 0, 0, 200), v), + ); + p5.rect(x, y, resolution, resolution); + } + } - p5.fill(0, 0, 0, 0); // Reset fill so it doesn't affect the background - }; + props.dots.forEach((dot) => { + if (dot && dot.x && dot.y) { + p5.fill(dot.color.r, dot.color.g, dot.color.b, dot.color.a); + p5.ellipse(dot.x, dot.y, dot.size, dot.size); + } + }); - return ; + p5.fill(0, 0, 0, 0); // Reset fill so it doesn't affect the background + }; + + return ( + + ); } diff --git a/components/stats/Picklist.tsx b/components/stats/Picklist.tsx index 165bc45a..c9290801 100644 --- a/components/stats/Picklist.tsx +++ b/components/stats/Picklist.tsx @@ -5,283 +5,343 @@ import { ChangeEvent, useEffect, useState } from "react"; import { FaArrowDown, FaArrowUp, FaPlus } from "react-icons/fa"; import ClientApi from "@/lib/api/ClientApi"; -type CardData = { - number: number; - picklistIndex?: number; +type CardData = { + number: number; + picklistIndex?: number; }; type Picklist = { - index: number; - name: string; - teams: CardData[]; - update: (picklist: Picklist) => void; -} + index: number; + name: string; + teams: CardData[]; + update: (picklist: Picklist) => void; +}; const Includes = (bucket: any[], item: CardData) => { - let result = false; - bucket.forEach((i: { number: number }) => { - if (i.number === item.number) { - result = true; - } - }); - - return result; + let result = false; + bucket.forEach((i: { number: number }) => { + if (i.number === item.number) { + result = true; + } + }); + + return result; }; function removeTeamFromPicklist(team: CardData, picklists: Picklist[]) { - if (team.picklistIndex === undefined) return; + if (team.picklistIndex === undefined) return; - const picklist = picklists[team.picklistIndex]; - if (!picklist) return; - - picklist.teams = picklist.teams.filter((t) => t.number !== team.number); - picklist.update(picklist); + const picklist = picklists[team.picklistIndex]; + if (!picklist) return; + + picklist.teams = picklist.teams.filter((t) => t.number !== team.number); + picklist.update(picklist); } -function TeamCard(props: { cardData: CardData, draggable: boolean, picklist?: Picklist, rank?: number, lastRank?: number, - width?: string, height?: string}) { - const { number: teamNumber, picklistIndex: picklist } = props.cardData; - - const [{ isDragging }, dragRef] = useDrag({ - type: "team", - item: props.cardData, - collect: (monitor) => ({ - isDragging: monitor.isDragging(), - }), - }); - - function changeTeamRank(change: number) { - const picklist = props.picklist; - if (picklist === undefined || props.rank === undefined || props.lastRank === undefined) return; - - const newRank = props.rank + change; - if (newRank < 0 || newRank > props.lastRank) return; - - const otherTeam = picklist.teams[newRank]; - picklist.teams[newRank] = picklist.teams[props.rank]; - picklist.teams[props.rank] = otherTeam; - - picklist.update(picklist); - } - - return ( -
void} - > -

- {props.rank !== undefined ? `${props.rank + 1}. ` : ""}Team #{teamNumber} -

- { - props.rank !== undefined && props.lastRank && props.picklist ? ( -
- { props.rank > 0 && } - { props.rank < props.lastRank && } -
) - : "" - } -
- ); +function TeamCard(props: { + cardData: CardData; + draggable: boolean; + picklist?: Picklist; + rank?: number; + lastRank?: number; + width?: string; + height?: string; +}) { + const { number: teamNumber, picklistIndex: picklist } = props.cardData; + + const [{ isDragging }, dragRef] = useDrag({ + type: "team", + item: props.cardData, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }); + + function changeTeamRank(change: number) { + const picklist = props.picklist; + if ( + picklist === undefined || + props.rank === undefined || + props.lastRank === undefined + ) + return; + + const newRank = props.rank + change; + if (newRank < 0 || newRank > props.lastRank) return; + + const otherTeam = picklist.teams[newRank]; + picklist.teams[newRank] = picklist.teams[props.rank]; + picklist.teams[props.rank] = otherTeam; + + picklist.update(picklist); + } + + return ( +
void} + > +

+ {props.rank !== undefined ? `${props.rank + 1}. ` : ""} + Team{" "} + #{teamNumber} +

+ {props.rank !== undefined && props.lastRank && props.picklist ? ( +
+ {props.rank > 0 && ( + + )} + {props.rank < props.lastRank && ( + + )} +
+ ) : ( + "" + )} +
+ ); } -function PicklistCard(props: { picklist: Picklist, picklists: Picklist[] }) { - const picklist = props.picklist; - - const [{ isOver }, dropRef] = useDrop({ - accept: "team", - drop: (item: CardData) => { - if (item.picklistIndex === picklist.index) return; - - removeTeamFromPicklist(item, props.picklists); - - if (!Includes(picklist.teams, item)) { - item.picklistIndex = picklist.index; - picklist.teams.push(item); - picklist.update(picklist); - } - }, - collect: (monitor) => ({ - isOver: monitor.isOver(), - }), - }); - - function changeName(e: ChangeEvent) { - picklist.name = e.target.value; - picklist.update(picklist); - } - - return ( -
void} - > - -

{picklist.name}

- {picklist.teams.map((team, index) => ( - - ))} - {isOver &&

Drop Here!

} -
- ); +function PicklistCard(props: { picklist: Picklist; picklists: Picklist[] }) { + const picklist = props.picklist; + + const [{ isOver }, dropRef] = useDrop({ + accept: "team", + drop: (item: CardData) => { + if (item.picklistIndex === picklist.index) return; + + removeTeamFromPicklist(item, props.picklists); + + if (!Includes(picklist.teams, item)) { + item.picklistIndex = picklist.index; + picklist.teams.push(item); + picklist.update(picklist); + } + }, + collect: (monitor) => ({ + isOver: monitor.isOver(), + }), + }); + + function changeName(e: ChangeEvent) { + picklist.name = e.target.value; + picklist.update(picklist); + } + + return ( +
void} + > + +

+ {picklist.name} +

+ {picklist.teams.map((team, index) => ( + + ))} + {isOver &&

Drop Here!

} +
+ ); } -export function TeamList(props: { teams: CardData[], picklists: Picklist[], expectedTeamCount: number }) { - const [{ isOver}, dropRef] = useDrop({ - accept: "team", - drop: (item: CardData) => { - removeTeamFromPicklist(item, props.picklists); - }, - collect: (monitor) => ({ - isOver: monitor.isOver(), - }), - }); - - return ( -
void} className="w-full h-fit flex flex-row bg-base-300 space-x-2 p-2 overflow-x-scroll"> - { - props.teams.sort((a, b) => a.number - b.number).map((team) => ( - - )) - } - { props.teams.length !== props.expectedTeamCount && -
- } -
); +export function TeamList(props: { + teams: CardData[]; + picklists: Picklist[]; + expectedTeamCount: number; +}) { + const [{ isOver }, dropRef] = useDrop({ + accept: "team", + drop: (item: CardData) => { + removeTeamFromPicklist(item, props.picklists); + }, + collect: (monitor) => ({ + isOver: monitor.isOver(), + }), + }); + + return ( +
void} + className="w-full h-fit flex flex-row bg-base-300 space-x-2 p-2 overflow-x-scroll" + > + {props.teams + .sort((a, b) => a.number - b.number) + .map((team) => ( + + ))} + {props.teams.length !== props.expectedTeamCount && ( +
+ )} +
+ ); } const api = new ClientApi(); -export default function PicklistScreen(props: { teams: number[], reports: Report[], expectedTeamCount: number, picklist: DbPicklist, compId: string }) { - const [picklists, setPicklists] = useState([]); - - enum LoadState { - NotLoaded, - Loading, - Loaded - } - - const [loadingPicklists, setLoadingPicklists] = useState(LoadState.NotLoaded); - - const teams = props.teams.map((team) => ({ number: team })); - - function savePicklists(picklists: Picklist[]) { - const picklistDict = picklists.reduce((acc, picklist) => { - acc.picklists[picklist.name] = picklist.teams.map((team) => team.number); - return acc; - }, { - _id: props.picklist._id, - picklists: {} - }); - - api.updatePicklist(picklistDict); - } - - function updatePicklist(picklist: Picklist) { - setPicklists((old) => { - const newPicklists = old.map((p) => { - if (p.index === picklist.index) { - return picklist; - } else { - return p; - } - }); - - savePicklists(newPicklists); - return newPicklists; - }); - } - - function loadDbPicklist(picklistDict: DbPicklist) { - setPicklists(Object.entries(picklistDict.picklists).map((picklist, index) => { - const newPicklist: Picklist = { - index, - name: picklist[0], - teams: picklist[1].map((team: number) => ({ number: team })), - update: updatePicklist - }; - - for (const team of newPicklist.teams) { - team.picklistIndex = newPicklist.index; - } - - return newPicklist; - })); - } - - useEffect(() => { - if (loadingPicklists !== LoadState.NotLoaded) return; - - console.log(props); - setLoadingPicklists(LoadState.Loading); - api.getPicklist(props.picklist?._id).then((picklist) => { - if (picklist) { - loadDbPicklist(picklist); - } - }); - loadDbPicklist(props.picklist); - - setLoadingPicklists(LoadState.Loaded); - }); - - const addPicklist = () => { - const newPicklist: Picklist = { - index: picklists.length, - name: `Picklist ${picklists.length + 1}`, - teams: [], - update: updatePicklist - }; - - const newPicklists = [...picklists, newPicklist]; - savePicklists(newPicklists); - setPicklists(newPicklists); - }; - - return ( -
- - -
- { - loadingPicklists === LoadState.Loading - ?
-
-
- : picklists.length === 0 - ? ( -
-

- Create A Picklist -

-
- ) - : picklists.map((picklist) => ( - - ) - ) - } -
- - { loadingPicklists !== LoadState.Loading && - - } -
- ); -} \ No newline at end of file +export default function PicklistScreen(props: { + teams: number[]; + reports: Report[]; + expectedTeamCount: number; + picklist: DbPicklist; + compId: string; +}) { + const [picklists, setPicklists] = useState([]); + + enum LoadState { + NotLoaded, + Loading, + Loaded, + } + + const [loadingPicklists, setLoadingPicklists] = useState(LoadState.NotLoaded); + + const teams = props.teams.map((team) => ({ number: team })); + + function savePicklists(picklists: Picklist[]) { + const picklistDict = picklists.reduce( + (acc, picklist) => { + acc.picklists[picklist.name] = picklist.teams.map( + (team) => team.number, + ); + return acc; + }, + { + _id: props.picklist._id, + picklists: {}, + }, + ); + + api.updatePicklist(picklistDict); + } + + function updatePicklist(picklist: Picklist) { + setPicklists((old) => { + const newPicklists = old.map((p) => { + if (p.index === picklist.index) { + return picklist; + } else { + return p; + } + }); + + savePicklists(newPicklists); + return newPicklists; + }); + } + + function loadDbPicklist(picklistDict: DbPicklist) { + setPicklists( + Object.entries(picklistDict.picklists).map((picklist, index) => { + const newPicklist: Picklist = { + index, + name: picklist[0], + teams: picklist[1].map((team: number) => ({ number: team })), + update: updatePicklist, + }; + + for (const team of newPicklist.teams) { + team.picklistIndex = newPicklist.index; + } + + return newPicklist; + }), + ); + } + + useEffect(() => { + if (loadingPicklists !== LoadState.NotLoaded) return; + + console.log(props); + setLoadingPicklists(LoadState.Loading); + api.getPicklist(props.picklist?._id).then((picklist) => { + if (picklist) { + loadDbPicklist(picklist); + } + }); + loadDbPicklist(props.picklist); + + setLoadingPicklists(LoadState.Loaded); + }); + + const addPicklist = () => { + const newPicklist: Picklist = { + index: picklists.length, + name: `Picklist ${picklists.length + 1}`, + teams: [], + update: updatePicklist, + }; + + const newPicklists = [...picklists, newPicklist]; + savePicklists(newPicklists); + setPicklists(newPicklists); + }; + + return ( +
+ + +
+ {loadingPicklists === LoadState.Loading ? ( +
+
+
+ ) : picklists.length === 0 ? ( +
+

+ Create A Picklist +

+
+ ) : ( + picklists.map((picklist) => ( + + )) + )} +
+ + {loadingPicklists !== LoadState.Loading && ( + + )} +
+ ); +} diff --git a/components/stats/PredictionScreen.tsx b/components/stats/PredictionScreen.tsx index 42309467..ae5999eb 100644 --- a/components/stats/PredictionScreen.tsx +++ b/components/stats/PredictionScreen.tsx @@ -2,119 +2,188 @@ import { Game, Report } from "@/lib/Types"; import { useEffect, useState } from "react"; import { Bar } from "react-chartjs-2"; -function AllianceBuilder(props: { - teams: number[], - alliance: (number | undefined)[], - update: (index: number, team: number) => void, - name: string, - color: string - }) { - return ( -
-

{props.name} Alliance

-
- {props.alliance.map((team, index) => - - )} -
-
- ); +function AllianceBuilder(props: { + teams: number[]; + alliance: (number | undefined)[]; + update: (index: number, team: number) => void; + name: string; + color: string; +}) { + return ( +
+

+ {props.name} Alliance +

+
+ {props.alliance.map((team, index) => ( + + ))} +
+
+ ); } -export default function PredictionScreen(props: { reports: Report[], teams: number[], game: Game }) { - const [reportsByTeam, setReportsByTeam] = useState>({}); +export default function PredictionScreen(props: { + reports: Report[]; + teams: number[]; + game: Game; +}) { + const [reportsByTeam, setReportsByTeam] = useState>( + {}, + ); - // Array(length) constructor doesn't actually fill the array with undefined, so we have to do it manually - const [blueAlliance, setBlueAlliance] = useState<(number | undefined)[]>(Array(props.game.allianceSize).fill(undefined)); - const [redAlliance, setRedAlliance] = useState<(number | undefined)[]>(Array(props.game.allianceSize).fill(undefined)); + // Array(length) constructor doesn't actually fill the array with undefined, so we have to do it manually + const [blueAlliance, setBlueAlliance] = useState<(number | undefined)[]>( + Array(props.game.allianceSize).fill(undefined), + ); + const [redAlliance, setRedAlliance] = useState<(number | undefined)[]>( + Array(props.game.allianceSize).fill(undefined), + ); - useEffect(() => { - const reportsByTeam = props.reports.reduce((acc, report) => { - if (!acc[report.robotNumber]) { - acc[report.robotNumber] = []; - } + useEffect(() => { + const reportsByTeam = props.reports.reduce( + (acc, report) => { + if (!acc[report.robotNumber]) { + acc[report.robotNumber] = []; + } - acc[report.robotNumber].push(report); - return acc; - }, {} as Record); + acc[report.robotNumber].push(report); + return acc; + }, + {} as Record, + ); - setReportsByTeam(reportsByTeam); - }, [props.reports]); + setReportsByTeam(reportsByTeam); + }, [props.reports]); - function updateAlliance(setAlliance: (alliance: (number | undefined)[]) => void, alliance: (number | undefined)[], - index: number, team: number) { - alliance[index] = team; - setAlliance([...alliance]); // We have to create a new array for the update to work - } + function updateAlliance( + setAlliance: (alliance: (number | undefined)[]) => void, + alliance: (number | undefined)[], + index: number, + team: number, + ) { + alliance[index] = team; + setAlliance([...alliance]); // We have to create a new array for the update to work + } - const blueAllianceFilled = blueAlliance.filter((team) => team !== undefined); - const redAllianceFilled = redAlliance.filter((team) => team !== undefined); + const blueAllianceFilled = blueAlliance.filter((team) => team !== undefined); + const redAllianceFilled = redAlliance.filter((team) => team !== undefined); - const avgPointsBlueAllianceIndividual = blueAllianceFilled.map((team) => props.game.getAvgPoints(reportsByTeam[team!])); - const avgPointsRedAllianceIndividual = redAllianceFilled.map((team) => props.game.getAvgPoints(reportsByTeam[team!])); + const avgPointsBlueAllianceIndividual = blueAllianceFilled.map((team) => + props.game.getAvgPoints(reportsByTeam[team!]), + ); + const avgPointsRedAllianceIndividual = redAllianceFilled.map((team) => + props.game.getAvgPoints(reportsByTeam[team!]), + ); - const totalPointsBlueAlliance = avgPointsBlueAllianceIndividual.reduce((acc, points) => acc + points, 0); - const totalPointsRedAlliance = avgPointsRedAllianceIndividual.reduce((acc, points) => acc + points, 0); + const totalPointsBlueAlliance = avgPointsBlueAllianceIndividual.reduce( + (acc, points) => acc + points, + 0, + ); + const totalPointsRedAlliance = avgPointsRedAllianceIndividual.reduce( + (acc, points) => acc + points, + 0, + ); - const datasets = []; - for (let i = 0; i < Math.max(avgPointsBlueAllianceIndividual.length, avgPointsRedAllianceIndividual.length); i++) { - const blue = avgPointsBlueAllianceIndividual[i] || undefined; - const red = avgPointsRedAllianceIndividual[i] || undefined; + const datasets = []; + for ( + let i = 0; + i < + Math.max( + avgPointsBlueAllianceIndividual.length, + avgPointsRedAllianceIndividual.length, + ); + i++ + ) { + const blue = avgPointsBlueAllianceIndividual[i] || undefined; + const red = avgPointsRedAllianceIndividual[i] || undefined; - datasets.push({ - data: [blue, red], - label: `Team ${blueAllianceFilled[i] || "Empty"} vs Team ${redAllianceFilled[i] || "Empty"}`, - backgroundColor: [`rgba(0, ${i * 255 / 2}, 235, 1)`, `rgba(235, ${i * 255 / 2}, 0, 1)`], - }); - } + datasets.push({ + data: [blue, red], + label: `Team ${blueAllianceFilled[i] || "Empty"} vs Team ${redAllianceFilled[i] || "Empty"}`, + backgroundColor: [ + `rgba(0, ${(i * 255) / 2}, 235, 1)`, + `rgba(235, ${(i * 255) / 2}, 0, 1)`, + ], + }); + } - const pointDiff = totalPointsBlueAlliance - totalPointsRedAlliance; - let winner = "Tie"; - let color = ""; - if (pointDiff > 0) { - winner = "Blue Alliance"; - color = "blue-500"; - } else if (pointDiff < 0) { - winner = "Red Alliance"; - color = "red-500"; - } + const pointDiff = totalPointsBlueAlliance - totalPointsRedAlliance; + let winner = "Tie"; + let color = ""; + if (pointDiff > 0) { + winner = "Blue Alliance"; + color = "blue-500"; + } else if (pointDiff < 0) { + winner = "Red Alliance"; + color = "red-500"; + } - return ( -
-
- updateAlliance(setBlueAlliance, blueAlliance, index, team)} name="Blue" color="blue-500" /> - updateAlliance(setRedAlliance, redAlliance, index, team)} name="Red" color="red-500" /> -
-

- {pointDiff != 0 ? `${winner} wins by ${Math.abs(pointDiff)} points` : winner} ({totalPointsBlueAlliance} - {totalPointsRedAlliance}) -

- {/* If we don't have bar graph in a div, it will vertically expand into infinity. Don't question it. - Renato */ } -
- -
-
- ); -} \ No newline at end of file + return ( +
+
+ + updateAlliance(setBlueAlliance, blueAlliance, index, team) + } + name="Blue" + color="blue-500" + /> + + updateAlliance(setRedAlliance, redAlliance, index, team) + } + name="Red" + color="red-500" + /> +
+

+ {pointDiff != 0 + ? `${winner} wins by ${Math.abs(pointDiff)} points` + : winner}{" "} + ({totalPointsBlueAlliance} - {totalPointsRedAlliance}) +

+ {/* If we don't have bar graph in a div, it will vertically expand into infinity. Don't question it. - Renato */} +
+ +
+
+ ); +} diff --git a/components/stats/SmallGraph.tsx b/components/stats/SmallGraph.tsx index e77a100f..da4bb0f6 100644 --- a/components/stats/SmallGraph.tsx +++ b/components/stats/SmallGraph.tsx @@ -3,139 +3,167 @@ import { Report } from "@/lib/Types"; import ClientApi from "@/lib/api/ClientApi"; import { - Chart as ChartJS, - CategoryScale, - LinearScale, - BarElement, - Title, - Tooltip, - Legend, + Chart as ChartJS, + CategoryScale, + LinearScale, + BarElement, + Title, + Tooltip, + Legend, } from "chart.js"; import { useEffect, useState } from "react"; import { Bar } from "react-chartjs-2"; ChartJS.register( - CategoryScale, - LinearScale, - BarElement, - Title, - Tooltip, - Legend, + CategoryScale, + LinearScale, + BarElement, + Title, + Tooltip, + Legend, ); const options = { - responsive: true, + responsive: true, - layout: { - padding: 18, - }, - scales: { - y: { - grid: { - color: "#404040", - }, - }, - x: { - grid: { - color: "#404040", - }, - }, - }, - plugins: { - colors: {}, - legend: { - position: "top" as const, - }, - title: { - display: false, - text: "Graph", - }, - }, + layout: { + padding: 18, + }, + scales: { + y: { + grid: { + color: "#404040", + }, + }, + x: { + grid: { + color: "#404040", + }, + }, + }, + plugins: { + colors: {}, + legend: { + position: "top" as const, + }, + title: { + display: false, + text: "Graph", + }, + }, }; const api = new ClientApi(); -export default function SmallGraph(props: { selectedReports: Report[], team: number }) { - const [key, setKey] = useState("Defense"); - const keys = Array.from(new Set(props.selectedReports?.map(r => Object.keys(r.data)).flat() ?? [])); +export default function SmallGraph(props: { + selectedReports: Report[]; + team: number; +}) { + const [key, setKey] = useState("Defense"); + const keys = Array.from( + new Set( + props.selectedReports?.map((r) => Object.keys(r.data)).flat() ?? [], + ), + ); - interface Datapoint { - x: number; - y: number; - } + interface Datapoint { + x: number; + y: number; + } - const [datapoints, setDataPoints] = useState(null); - const [currentTeam, setCurrentTeam] = useState(0); + const [datapoints, setDataPoints] = useState(null); + const [currentTeam, setCurrentTeam] = useState(0); - function dataToNumber(key: string, data: any): number { - if (key === "Defense") { - let n = 0; - switch (data) { - case Defense.None: - return 0; - case Defense.Partial: - return 0.5; - case Defense.Full: - return 1; - } - } - return data; - } - - const data = { - labels: datapoints?.map((point) => point.x) ?? [], - datasets: [ - { - label: key, - data: props.selectedReports?.map((report) => dataToNumber(key, report.data[key])), - backgroundColor: "rgba(255, 99, 132, 0.5)", - }, - ], - }; + function dataToNumber(key: string, data: any): number { + if (key === "Defense") { + let n = 0; + switch (data) { + case Defense.None: + return 0; + case Defense.Partial: + return 0.5; + case Defense.Full: + return 1; + } + } + return data; + } - useEffect(() => { - if (!props.selectedReports || (datapoints && currentTeam === props.team && false)) return; + const data = { + labels: datapoints?.map((point) => point.x) ?? [], + datasets: [ + { + label: key, + data: props.selectedReports?.map((report) => + dataToNumber(key, report.data[key]), + ), + backgroundColor: "rgba(255, 99, 132, 0.5)", + }, + ], + }; - setDataPoints([]); - setCurrentTeam(props.team); - for (const report of props.selectedReports) { - api.findMatchById(report.match).then((match) => { - if (!match) return; + useEffect(() => { + if ( + !props.selectedReports || + (datapoints && currentTeam === props.team && false) + ) + return; - setDataPoints((prev) => [...prev ?? [], { - x: match.number, - y: dataToNumber(key, report.data[key]), - }].sort((a, b) => a.x - b.x)); - }); - } - }, [key]); + setDataPoints([]); + setCurrentTeam(props.team); + for (const report of props.selectedReports) { + api.findMatchById(report.match).then((match) => { + if (!match) return; - if (!props.selectedReports) { - return <>; - } + setDataPoints((prev) => + [ + ...(prev ?? []), + { + x: match.number, + y: dataToNumber(key, report.data[key]), + }, + ].sort((a, b) => a.x - b.x), + ); + }); + } + }, [key]); - return ( -
-

Graph

- - point.x) ?? [] - }} /> -
- ); + if (!props.selectedReports) { + return <>; + } + + return ( +
+

Graph

+ + point.x) ?? [], + }} + /> +
+ ); } diff --git a/components/stats/Summary.tsx b/components/stats/Summary.tsx index a7929a49..6d3a8eff 100644 --- a/components/stats/Summary.tsx +++ b/components/stats/Summary.tsx @@ -2,51 +2,68 @@ import { Report } from "@/lib/Types"; import { NumericalAverage, MostCommonValue } from "@/lib/client/StatsMath"; import { Dot } from "./Heatmap"; -export default function Summary(props: { selectedReports: Report[], dots: Dot[] }) { - if (!props.selectedReports) { - return ( - -
- { - props.dots.map((dot, index) => ( -
{dot.label}
- )) - } -
-
); - } +export default function Summary(props: { + selectedReports: Report[]; + dots: Dot[]; +}) { + if (!props.selectedReports) { + return ( + +
+ {props.dots.map((dot, index) => ( +
+ {dot.label} +
+ ))} +
+
+ ); + } - const avgX = NumericalAverage((r) => r.AutoStart?.x ?? 0, props.selectedReports); - const avgA = NumericalAverage((r) => r.AutoStart?.y ?? 0, props.selectedReports); - const matches = props.selectedReports.length; - const startingSide = avgX < 350 / 2 ? "left" : "right"; - const startingAngle = avgA < 180 ? "low" : "high"; + const avgX = NumericalAverage( + (r) => r.AutoStart?.x ?? 0, + props.selectedReports, + ); + const avgA = NumericalAverage( + (r) => r.AutoStart?.y ?? 0, + props.selectedReports, + ); + const matches = props.selectedReports.length; + const startingSide = avgX < 350 / 2 ? "left" : "right"; + const startingAngle = avgA < 180 ? "low" : "high"; - return ( - -

Insights

- Analysis suggests that this robot favors starting{" "} - {startingSide} on the field, at{" "} - {startingAngle} angles of attack. - {matches < 5 ? ( - - {" "} -
-
However, this robot has only competed in{" "} - {matches} matches. -
- ) : ( - <> - This robot has competed in {matches} matches and is very well characterized. - - )} -
- { - props.dots.map((dot, index) => ( -
{dot.label}
- )) - } -
-
- ); + return ( + +

Insights

+ Analysis suggests that this robot favors starting{" "} + {startingSide} on the field, at{" "} + {startingAngle} angles of attack. + {matches < 5 ? ( + + {" "} +
+
However, this robot has only competed in{" "} + {matches} matches. +
+ ) : ( + <> + This robot has competed in {matches} matches and is very well + characterized. + + )} +
+ {props.dots.map((dot, index) => ( +
+ {dot.label} +
+ ))} +
+
+ ); } diff --git a/components/stats/TeamPage.tsx b/components/stats/TeamPage.tsx index 3e83c3cb..247f08c8 100644 --- a/components/stats/TeamPage.tsx +++ b/components/stats/TeamPage.tsx @@ -1,11 +1,15 @@ -import { Game, PitReportData, Pitreport, QuantData, Report, SubjectiveReport } from "@/lib/Types"; +import { + Game, + PitReportData, + Pitreport, + QuantData, + Report, + SubjectiveReport, +} from "@/lib/Types"; import { useEffect, useState } from "react"; import { Badge } from "@/lib/Layout"; -import { - StandardDeviation, - Round, -} from "@/lib/client/StatsMath"; +import { StandardDeviation, Round } from "@/lib/client/StatsMath"; import Heatmap from "@/components/stats/Heatmap"; import TeamStats from "@/components/stats/TeamStats"; @@ -17,217 +21,263 @@ import { GameId } from "@/lib/client/GameId"; import { FrcDrivetrain } from "@/lib/Enums"; function TeamCard(props: { - number: number; - rank: number; - reports: Report[]; - pitReport: Pitreport | undefined; - onClick: () => void; - selected: boolean; - compAvgPoints: number; - compPointsStDev: number; - getBadges: (pitData: Pitreport | undefined, quantitativeData: Report[], card: boolean) => Badge[]; - getAvgPoints: (reports: Report[]) => number; + number: number; + rank: number; + reports: Report[]; + pitReport: Pitreport | undefined; + onClick: () => void; + selected: boolean; + compAvgPoints: number; + compPointsStDev: number; + getBadges: ( + pitData: Pitreport | undefined, + quantitativeData: Report[], + card: boolean, + ) => Badge[]; + getAvgPoints: (reports: Report[]) => number; }) { - const pitReport = props.pitReport; - const data = pitReport?.data as PitReportData; - - const avgPoints = props.getAvgPoints(props.reports); - - const badges = props.getBadges(pitReport, props.reports, true); - - const pointsDiffFromAvg = Round(avgPoints - props.compAvgPoints); - const pointsDiffFromAvgFormatted = pointsDiffFromAvg > 0 ? `+${pointsDiffFromAvg}` : pointsDiffFromAvg; - - let textColor = "info"; - if (pointsDiffFromAvg > props.compPointsStDev) { - textColor = "primary"; - } - else if (pointsDiffFromAvg > props.compPointsStDev / 2) { - textColor = "accent"; - } else if (pointsDiffFromAvg < -props.compPointsStDev) { - textColor = "warning"; - } - - let badgeColor = "neutral"; - if (props.rank === 1) { - badgeColor = "primary"; - } - else if (props.rank === 2) { - badgeColor = "secondary"; - } - else if (props.rank === 3) { - badgeColor = "accent"; - } - - let rankSuffix = "th"; - if (props.rank === 1) { - rankSuffix = "st"; - } - else if (props.rank === 2) { - rankSuffix = "nd"; - } - else if (props.rank === 3) { - rankSuffix = "rd"; - } - - let drivetrainColor = "outline"; - if (pitReport?.submitted) { - drivetrainColor = data?.drivetrain === FrcDrivetrain.Swerve ? "accent" : "warning"; - } - - return ( -
-
-

- #{props.number} -
{props.rank}{rankSuffix}
-

-

Avg Points: {avgPoints}{" "} - ({pointsDiffFromAvgFormatted}) -

-
- {badges.map((badge, index) => ( -
{badge.text}
- ))} -
-
-
- ); + const pitReport = props.pitReport; + const data = pitReport?.data as PitReportData; + + const avgPoints = props.getAvgPoints(props.reports); + + const badges = props.getBadges(pitReport, props.reports, true); + + const pointsDiffFromAvg = Round(avgPoints - props.compAvgPoints); + const pointsDiffFromAvgFormatted = + pointsDiffFromAvg > 0 ? `+${pointsDiffFromAvg}` : pointsDiffFromAvg; + + let textColor = "info"; + if (pointsDiffFromAvg > props.compPointsStDev) { + textColor = "primary"; + } else if (pointsDiffFromAvg > props.compPointsStDev / 2) { + textColor = "accent"; + } else if (pointsDiffFromAvg < -props.compPointsStDev) { + textColor = "warning"; + } + + let badgeColor = "neutral"; + if (props.rank === 1) { + badgeColor = "primary"; + } else if (props.rank === 2) { + badgeColor = "secondary"; + } else if (props.rank === 3) { + badgeColor = "accent"; + } + + let rankSuffix = "th"; + if (props.rank === 1) { + rankSuffix = "st"; + } else if (props.rank === 2) { + rankSuffix = "nd"; + } else if (props.rank === 3) { + rankSuffix = "rd"; + } + + let drivetrainColor = "outline"; + if (pitReport?.submitted) { + drivetrainColor = + data?.drivetrain === FrcDrivetrain.Swerve ? "accent" : "warning"; + } + + return ( +
+
+

+ + #{props.number} + +
+ {props.rank} + {rankSuffix} +
+

+

+ Avg Points: {avgPoints}{" "} + + ({pointsDiffFromAvgFormatted}) + +

+
+ {badges.map((badge, index) => ( +
+ {badge.text} +
+ ))} +
+
+
+ ); } -export default function TeamPage(props: { reports: Report[], pitReports: Pitreport[], subjectiveReports: SubjectiveReport[], gameId: GameId }) { - const reports = props.reports; - const [pitReports, setPitReports] = useState<{ [key: number]: Pitreport }>({}); - const [teamReports, setTeamReports] = useState<{ [key: number]: Report[] }>( - {} - ); - const [teamSubjectiveReports, setTeamSubjectiveReports] = useState<{ [key: number]: SubjectiveReport[] }>({}); - - const teamNumbers = Array.from(new Set([...Object.keys(teamReports), ...Object.keys(pitReports), ...Object.keys(teamSubjectiveReports)])); - - const [selectedTeam, setSelectedTeam] = useState(); - const selectedReports = teamReports[selectedTeam ? selectedTeam : 0]; - - const game = games[props.gameId]; - - const associateTeams = () => { - const newTeamReports: typeof teamReports = {}; - reports.forEach((report) => { - if (!(report.robotNumber in newTeamReports)) { - newTeamReports[report.robotNumber] = [report]; - } else { - newTeamReports[report.robotNumber].push(report); - } - }); - setTeamReports(newTeamReports); - - const newPitReports: typeof pitReports = {}; - props.pitReports.forEach((pitReport) => { - newPitReports[pitReport.teamNumber] = pitReport; - }); - setPitReports(newPitReports); - - const subjectiveReports: typeof teamSubjectiveReports = {}; - props.subjectiveReports.forEach((subjectiveReport) => { - for (const teamNumber of Object.keys(subjectiveReport.robotComments)) { - if (!Object.keys(subjectiveReports).includes(teamNumber)) { - subjectiveReports[Number(teamNumber)] = [subjectiveReport]; - } else { - subjectiveReports[Number(teamNumber)].push(subjectiveReport); - } - } - }); - setTeamSubjectiveReports(subjectiveReports); - }; - - useEffect(() => { - const subjectiveReports: typeof teamSubjectiveReports = {}; - props.subjectiveReports.forEach((subjectiveReport) => { - for (const teamNumber of Object.keys(subjectiveReport.robotComments)) { - if (!Object.keys(subjectiveReports).includes(teamNumber)) { - subjectiveReports[Number(teamNumber)] = [subjectiveReport]; - } else { - subjectiveReports[Number(teamNumber)].push(subjectiveReport); - } - } - }); - setTeamSubjectiveReports(subjectiveReports); - }, [props.subjectiveReports]); - - const pointTotals = reports.map((report) => game.getAvgPoints([report])); - const avgPoints = game.getAvgPoints(reports); - const stDev = StandardDeviation(pointTotals); - - useEffect(() => { - console.log("Associating teams..."); - associateTeams(); - }, [reports, props.pitReports, props.subjectiveReports]); - - // Associate pit reports - props.pitReports.forEach((pitReport) => { - pitReports[pitReport.teamNumber] = pitReport; - }); - - const teamRanking = Object.keys(teamReports).sort((a, b) => { - const a1 = game.getAvgPoints(teamReports[Number(a)]); - const b1 = game.getAvgPoints(teamReports[Number(b)]); - return b1 - a1; - }); - - // Find teams not in team ranking - const missingTeams = teamNumbers.filter((team) => !teamRanking.includes(team)); - - return ( -
-
- {teamRanking.concat(missingTeams).map((number, index) => ( - setSelectedTeam(Number(number))} - compAvgPoints={avgPoints} - compPointsStDev={stDev} - getBadges={game.getBadges} - getAvgPoints={game.getAvgPoints} - /> - ))} -
- - - -
-
-
- -
- -
- -
-
- - -
-
- ); +export default function TeamPage(props: { + reports: Report[]; + pitReports: Pitreport[]; + subjectiveReports: SubjectiveReport[]; + gameId: GameId; +}) { + const reports = props.reports; + const [pitReports, setPitReports] = useState<{ [key: number]: Pitreport }>( + {}, + ); + const [teamReports, setTeamReports] = useState<{ [key: number]: Report[] }>( + {}, + ); + const [teamSubjectiveReports, setTeamSubjectiveReports] = useState<{ + [key: number]: SubjectiveReport[]; + }>({}); + + const teamNumbers = Array.from( + new Set([ + ...Object.keys(teamReports), + ...Object.keys(pitReports), + ...Object.keys(teamSubjectiveReports), + ]), + ); + + const [selectedTeam, setSelectedTeam] = useState(); + const selectedReports = teamReports[selectedTeam ? selectedTeam : 0]; + + const game = games[props.gameId]; + + const associateTeams = () => { + const newTeamReports: typeof teamReports = {}; + reports.forEach((report) => { + if (!(report.robotNumber in newTeamReports)) { + newTeamReports[report.robotNumber] = [report]; + } else { + newTeamReports[report.robotNumber].push(report); + } + }); + setTeamReports(newTeamReports); + + const newPitReports: typeof pitReports = {}; + props.pitReports.forEach((pitReport) => { + newPitReports[pitReport.teamNumber] = pitReport; + }); + setPitReports(newPitReports); + + const subjectiveReports: typeof teamSubjectiveReports = {}; + props.subjectiveReports.forEach((subjectiveReport) => { + for (const teamNumber of Object.keys(subjectiveReport.robotComments)) { + if (!Object.keys(subjectiveReports).includes(teamNumber)) { + subjectiveReports[Number(teamNumber)] = [subjectiveReport]; + } else { + subjectiveReports[Number(teamNumber)].push(subjectiveReport); + } + } + }); + setTeamSubjectiveReports(subjectiveReports); + }; + + useEffect(() => { + const subjectiveReports: typeof teamSubjectiveReports = {}; + props.subjectiveReports.forEach((subjectiveReport) => { + for (const teamNumber of Object.keys(subjectiveReport.robotComments)) { + if (!Object.keys(subjectiveReports).includes(teamNumber)) { + subjectiveReports[Number(teamNumber)] = [subjectiveReport]; + } else { + subjectiveReports[Number(teamNumber)].push(subjectiveReport); + } + } + }); + setTeamSubjectiveReports(subjectiveReports); + }, [props.subjectiveReports]); + + const pointTotals = reports.map((report) => game.getAvgPoints([report])); + const avgPoints = game.getAvgPoints(reports); + const stDev = StandardDeviation(pointTotals); + + useEffect(() => { + console.log("Associating teams..."); + associateTeams(); + }, [reports, props.pitReports, props.subjectiveReports]); + + // Associate pit reports + props.pitReports.forEach((pitReport) => { + pitReports[pitReport.teamNumber] = pitReport; + }); + + const teamRanking = Object.keys(teamReports).sort((a, b) => { + const a1 = game.getAvgPoints(teamReports[Number(a)]); + const b1 = game.getAvgPoints(teamReports[Number(b)]); + return b1 - a1; + }); + + // Find teams not in team ranking + const missingTeams = teamNumbers.filter( + (team) => !teamRanking.includes(team), + ); + + return ( +
+
+ {teamRanking.concat(missingTeams).map((number, index) => ( + setSelectedTeam(Number(number))} + compAvgPoints={avgPoints} + compPointsStDev={stDev} + getBadges={game.getBadges} + getAvgPoints={game.getAvgPoints} + /> + ))} +
+ + + +
+
+
+ +
+ +
+ +
+
+ + +
+
+ ); } diff --git a/components/stats/TeamStats.tsx b/components/stats/TeamStats.tsx index 0a7eef8d..75ebf0a7 100644 --- a/components/stats/TeamStats.tsx +++ b/components/stats/TeamStats.tsx @@ -1,8 +1,12 @@ +import { NumericalAverage, ComparativePercent } from "@/lib/client/StatsMath"; import { - NumericalAverage, - ComparativePercent, -} from "@/lib/client/StatsMath"; -import { PitReportData, Pitreport, QuantData, Report, SubjectiveReport, SubjectiveReportSubmissionType } from "@/lib/Types"; + PitReportData, + Pitreport, + QuantData, + Report, + SubjectiveReport, + SubjectiveReportSubmissionType, +} from "@/lib/Types"; import { PiCrosshair, PiGitFork } from "react-icons/pi"; import { FaCode, FaWifi } from "react-icons/fa6"; import { FaComment } from "react-icons/fa"; @@ -14,185 +18,306 @@ import { StatsLayout, Stat, StatPair, Badge } from "@/lib/Layout"; const api = new ClientApi(); export default function TeamStats(props: { - selectedTeam: number | undefined; - selectedReports: Report[]; - pitReport: Pitreport | null; - subjectiveReports: SubjectiveReport[]; - getBadges: (pitData: Pitreport | undefined, quantitativeData: Report[], card: boolean) => Badge[]; - layout: StatsLayout; + selectedTeam: number | undefined; + selectedReports: Report[]; + pitReport: Pitreport | null; + subjectiveReports: SubjectiveReport[]; + getBadges: ( + pitData: Pitreport | undefined, + quantitativeData: Report[], + card: boolean, + ) => Badge[]; + layout: StatsLayout; }) { - const [comments, setComments] = useState<{matchNum: number, content: { order: number, jsx: ReactNode}[] }[] | null>(null); - - useEffect(() => { - if(!props.selectedTeam) return; - setComments(null); - - const newComments: typeof comments = []; - - function addComment(match: number, order: number, jsx: ReactNode) { - if (!newComments!.some((comment) => comment.matchNum === match)) newComments!.push({ matchNum: match, content: [{ - order, - jsx - }] }); - else newComments!.find((comment) => comment.matchNum === match)!.content.push({ order, jsx }); - } - - if (pitReport) - addComment(0, 0, pitReport.data?.comments.length ?? 0 > 0 ? `Pit Report: ${pitReport.data?.comments}` : "No pit report comments."); - - if (!props.subjectiveReports) addComment(0, 0.1, ); - else if (props.subjectiveReports.length === 0) addComment(0, 0.1, "No subjective reports."); - else { - for (const report of props.subjectiveReports) { - const submissionType = - (report.submitted === SubjectiveReportSubmissionType.ByAssignedScouter - ? "assigned" - : report.submitted === SubjectiveReportSubmissionType.BySubjectiveScouter - ? "subjective" - : "non-subjective") + " scouter"; - - if (report.robotComments[props.selectedTeam ?? 0]) { - addComment( - report.matchNumber ?? 0, 2, - Subjective: {report.robotComments[props.selectedTeam ?? 0]} - ); - } - - if (report.wholeMatchComment) { - addComment( - report.matchNumber ?? 0, 1, - Whole Match: {report.wholeMatchComment} - ); - } - } - } - - const commentList = props.selectedReports?.filter((report) => report.data?.comments.length > 0) ?? []; - if (commentList.length === 0) return setComments(newComments); - - const promises = commentList.map((report) => api.findMatchById(report.match).then((match) => match && addComment( - match.number, 0, `Quantitative: ${report.data?.comments}` - ))); - - Promise.all(promises).then(() => setComments(newComments)); - }, [props.selectedTeam, props.selectedReports, props.subjectiveReports, props.pitReport]); - - if (!props.selectedTeam) { - return ( -
-

- Select A Team -

-
- ); - } - - const pitReport = props.pitReport; - const badges = props.getBadges(pitReport ?? undefined, props.selectedReports, false); - - function getSections(header: string, stats: (Stat | StatPair)[]) { - const statElements = stats.map((stat, index) => { - if ((stat as Stat).key) { - // Single stat - const singleStat = stat as Stat; - - return

- {singleStat.label}: {NumericalAverage(singleStat.key as string, props.selectedReports)} -

- } - - // Stat pair - const pair = stat as StatPair; - if (pair.stats.length !== 2) { - console.error("Invalid stat pair. Wrong # of stats provided.", pair); - return <>; - } - - const first = pair.stats[0].get?.(pitReport ?? undefined, props.selectedReports) - ?? NumericalAverage(pair.stats[0].key as string, props.selectedReports); - const second = pair.stats[1].get?.(pitReport ?? undefined, props.selectedReports) - ?? NumericalAverage(pair.stats[1].key as string, props.selectedReports); - - return
-
-

- {pair.stats[0].label}: {first} -

-

- {pair.stats[1].label}: {second} -

-
- -

- {pair.label}: {ComparativePercent(pair.stats[0].key as string, pair.stats[1].key as string, props.selectedReports)} -

-
- }); - - const iconDict: { [header: string]: ReactNode } = { - "Positioning": , - "Auto": , - "Teleop": , - "Comments": - }; - const icon = header in iconDict ? iconDict[header] : <>; - - return ( -
-

- {icon} {header} -

-
- {statElements} -
-
- ); - } - - const sections = Object.entries(props.layout.sections).map(([header, stats]) => getSections(header, stats)); - - return ( -
-

- Team #{props.selectedTeam} -

- -
- { badges.map((badge, index) => ( -
{badge.text}
- )) - } -
- -
- - {sections} - -
-

- Comments -

- -
-
    - { comments - ? comments.map((match) => match.matchNum > 0 - ? (
  • - Match {match.matchNum} -
      - {match.content.sort((a, b) => a.order - b.order).map((content, index) => ( -
    • {content.jsx}
    • - ))} -
    -
  • ) - : match.content.sort((a, b) => a.order - b.order).map((content, index) => ( -
  • {content.jsx}
  • - )) - ) - : - } -
-
-
- ); + const [comments, setComments] = useState< + { matchNum: number; content: { order: number; jsx: ReactNode }[] }[] | null + >(null); + + useEffect(() => { + if (!props.selectedTeam) return; + setComments(null); + + const newComments: typeof comments = []; + + function addComment(match: number, order: number, jsx: ReactNode) { + if (!newComments!.some((comment) => comment.matchNum === match)) + newComments!.push({ + matchNum: match, + content: [ + { + order, + jsx, + }, + ], + }); + else + newComments! + .find((comment) => comment.matchNum === match)! + .content.push({ order, jsx }); + } + + if (pitReport) + addComment( + 0, + 0, + (pitReport.data?.comments.length ?? 0 > 0) + ? `Pit Report: ${pitReport.data?.comments}` + : "No pit report comments.", + ); + + if (!props.subjectiveReports) addComment(0, 0.1, ); + else if (props.subjectiveReports.length === 0) + addComment(0, 0.1, "No subjective reports."); + else { + for (const report of props.subjectiveReports) { + const submissionType = + (report.submitted === SubjectiveReportSubmissionType.ByAssignedScouter + ? "assigned" + : report.submitted === + SubjectiveReportSubmissionType.BySubjectiveScouter + ? "subjective" + : "non-subjective") + " scouter"; + + if (report.robotComments[props.selectedTeam ?? 0]) { + addComment( + report.matchNumber ?? 0, + 2, + + Subjective: {report.robotComments[props.selectedTeam ?? 0]} + , + ); + } + + if (report.wholeMatchComment) { + addComment( + report.matchNumber ?? 0, + 1, + + Whole Match: {report.wholeMatchComment} + , + ); + } + } + } + + const commentList = + props.selectedReports?.filter( + (report) => report.data?.comments.length > 0, + ) ?? []; + if (commentList.length === 0) return setComments(newComments); + + const promises = commentList.map((report) => + api + .findMatchById(report.match) + .then( + (match) => + match && + addComment( + match.number, + 0, + `Quantitative: ${report.data?.comments}`, + ), + ), + ); + + Promise.all(promises).then(() => setComments(newComments)); + }, [ + props.selectedTeam, + props.selectedReports, + props.subjectiveReports, + props.pitReport, + ]); + + if (!props.selectedTeam) { + return ( +
+

+ Select A Team +

+
+ ); + } + + const pitReport = props.pitReport; + const badges = props.getBadges( + pitReport ?? undefined, + props.selectedReports, + false, + ); + + function getSections( + header: string, + stats: ( + | Stat + | StatPair + )[], + ) { + const statElements = stats.map((stat, index) => { + if ((stat as Stat).key) { + // Single stat + const singleStat = stat as Stat; + + return ( +

+ {singleStat.label}:{" "} + {NumericalAverage(singleStat.key as string, props.selectedReports)} +

+ ); + } + + // Stat pair + const pair = stat as StatPair; + if (pair.stats.length !== 2) { + console.error("Invalid stat pair. Wrong # of stats provided.", pair); + return <>; + } + + const first = + pair.stats[0].get?.(pitReport ?? undefined, props.selectedReports) ?? + NumericalAverage(pair.stats[0].key as string, props.selectedReports); + const second = + pair.stats[1].get?.(pitReport ?? undefined, props.selectedReports) ?? + NumericalAverage(pair.stats[1].key as string, props.selectedReports); + + return ( +
+
+

+ {pair.stats[0].label}: {first} +

+

+ {pair.stats[1].label}: {second} +

+
+ +

+ {pair.label}:{" "} + {ComparativePercent( + pair.stats[0].key as string, + pair.stats[1].key as string, + props.selectedReports, + )} +

+
+ ); + }); + + const iconDict: { [header: string]: ReactNode } = { + Positioning: ( + + ), + Auto: ( + + ), + Teleop: ( + + ), + Comments: ( + + ), + }; + const icon = header in iconDict ? iconDict[header] : <>; + + return ( +
+

+ {icon} {header} +

+
{statElements}
+
+ ); + } + + const sections = Object.entries(props.layout.sections).map( + ([header, stats]) => getSections(header, stats), + ); + + return ( +
+

+ Team #{props.selectedTeam} +

+ +
+ {badges.map((badge, index) => ( +
+ {badge.text} +
+ ))} +
+ +
+ + {sections} + +
+

+ {" "} + Comments +

+ +
+
    + {comments ? ( + comments.map((match) => + match.matchNum > 0 ? ( +
  • + Match {match.matchNum} +
      + {match.content + .sort((a, b) => a.order - b.order) + .map((content, index) => ( +
    • {content.jsx}
    • + ))} +
    +
  • + ) : ( + match.content + .sort((a, b) => a.order - b.order) + .map((content, index) =>
  • {content.jsx}
  • ) + ), + ) + ) : ( + + )} +
+
+
+ ); } diff --git a/enviroment.d.ts b/enviroment.d.ts index 6c89123e..fcee40ef 100644 --- a/enviroment.d.ts +++ b/enviroment.d.ts @@ -1,56 +1,56 @@ declare global { - namespace NodeJS { - interface ProcessEnv { - // Environment variables are always strings + namespace NodeJS { + interface ProcessEnv { + // Environment variables are always strings - NEXTAUTH_URL: string; - NEXTAUTH_SECRET: string; - NEXT_PUBLIC_API_URL: string; + NEXTAUTH_URL: string; + NEXTAUTH_SECRET: string; + NEXT_PUBLIC_API_URL: string; - GOOGLE_ID: string; - GOOGLE_SECRET: string; + GOOGLE_ID: string; + GOOGLE_SECRET: string; - GITHUB_ID: string; - GITHUB_SECRET: string; + GITHUB_ID: string; + GITHUB_SECRET: string; - MONGODB_URI: string; - DB: string; + MONGODB_URI: string; + DB: string; - TBA_URL: string; - TBA_KEY: string; + TBA_URL: string; + TBA_KEY: string; - TOA_URL: string; - TOA_KEY: string; - TOA_APP_ID: string; + TOA_URL: string; + TOA_KEY: string; + TOA_APP_ID: string; - API_URL: string; - API_KEY: string; + API_URL: string; + API_KEY: string; - DEFAULT_IMAGE: string; - IMAGE_UPLOAD_DIR: string; - FILL_TEAMS: string; + DEFAULT_IMAGE: string; + IMAGE_UPLOAD_DIR: string; + FILL_TEAMS: string; - NEXT_PUBLIC_SLACK_CLIENT_ID: string; - SLACK_CLIENT_SECRET: string; + NEXT_PUBLIC_SLACK_CLIENT_ID: string; + SLACK_CLIENT_SECRET: string; - NEXT_PUBLIC_GOOGLE_ANALYTICS_ID: string; + NEXT_PUBLIC_GOOGLE_ANALYTICS_ID: string; - SMTP_HOST: string; - SMTP_PORT: string; - SMTP_USER: string; - SMTP_PASSWORD: string; - SMTP_FROM: string; + SMTP_HOST: string; + SMTP_PORT: string; + SMTP_USER: string; + SMTP_PASSWORD: string; + SMTP_FROM: string; - RESEND_AUDIENCE_ID: string; + RESEND_AUDIENCE_ID: string; - NEXT_PUBLIC_FORCE_OFFLINE_MODE: string; + NEXT_PUBLIC_FORCE_OFFLINE_MODE: string; - NEXT_PUBLIC_BUILD_TIME: string; + NEXT_PUBLIC_BUILD_TIME: string; - DEVELOPER_EMAILS: string; + DEVELOPER_EMAILS: string; - NODE_ENV: "development" | "production"; - } - } + NODE_ENV: "development" | "production"; + } + } } export {}; diff --git a/global.d.ts b/global.d.ts index eaf97791..06f05ebf 100644 --- a/global.d.ts +++ b/global.d.ts @@ -1,6 +1,6 @@ declare global { - var clientPromise; - var interface; - var compIdPairs; + var clientPromise; + var interface; + var compIdPairs; } export {}; diff --git a/index.ts b/index.ts index 99bc68f7..e5ec1d5b 100644 --- a/index.ts +++ b/index.ts @@ -15,68 +15,82 @@ const handle = app.getRequestHandler(); console.log("Constants set"); const httpsOptions = { - key: readFileSync("./certs/key.pem"), - cert: readFileSync("./certs/cert.pem"), + key: readFileSync("./certs/key.pem"), + cert: readFileSync("./certs/cert.pem"), }; console.log("HTTPS options set"); console.log("App preparing..."); app.prepare().then(() => { - console.log("App prepared. Creating server..."); + console.log("App prepared. Creating server..."); - try { - const server = createServer(httpsOptions, async (req: IncomingMessage, res: ServerResponse) => { - if (!req.url) - return; + try { + const server = createServer( + httpsOptions, + async (req: IncomingMessage, res: ServerResponse) => { + if (!req.url) return; - const parsedUrl = parse(req.url, true); - const { pathname } = parsedUrl; + const parsedUrl = parse(req.url, true); + const { pathname } = parsedUrl; - if (pathname && (pathname === '/sw.js' || /^\/(workbox|worker|fallback)-\w+\.js$/.test(pathname))) { - console.log("Service worker request received: " + parsedUrl.pathname); - const filePath = join(__dirname, 'public', pathname); - const file = fs.readFileSync(filePath, 'utf8'); + if ( + pathname && + (pathname === "/sw.js" || + /^\/(workbox|worker|fallback)-\w+\.js$/.test(pathname)) + ) { + console.log("Service worker request received: " + parsedUrl.pathname); + const filePath = join(__dirname, "public", pathname); + const file = fs.readFileSync(filePath, "utf8"); - res.writeHead(200, { 'Content-Type': 'application/javascript' }); - res.write(file, (err) => console.log(err ? "Service worker write error: " + err : "Service worker written")); - } else if (pathname && pathname.startsWith("/slack")) { - console.log("Slack event received: " + parsedUrl.pathname); - - // Pipe request to slack app - const newReq = request( - Object.assign( - {}, - parse("http://localhost:" + process.env.SLACK_PORT + req.url), - { - method: req.method, - path: req.url, - } - ), - (newRes) => { - res.writeHead(newRes.statusCode || 200, newRes.headers); - newRes.pipe(res); - } - ); + res.writeHead(200, { "Content-Type": "application/javascript" }); + res.write(file, (err) => + console.log( + err + ? "Service worker write error: " + err + : "Service worker written", + ), + ); + } else if (pathname && pathname.startsWith("/slack")) { + console.log("Slack event received: " + parsedUrl.pathname); - req.pipe(newReq); - } else { - handle(req, res, parsedUrl); - } - }).listen(port, () => { - console.log( - process.env.NODE_ENV + - " HTTPS Server Running At: https://localhost:" + - port, - ); - }).on("error", (err: Error) => { - console.log(err); - throw err; - }); + // Pipe request to slack app + const newReq = request( + Object.assign( + {}, + parse("http://localhost:" + process.env.SLACK_PORT + req.url), + { + method: req.method, + path: req.url, + }, + ), + (newRes) => { + res.writeHead(newRes.statusCode || 200, newRes.headers); + newRes.pipe(res); + }, + ); - console.log("Server created. Listening: " + server.listening); - } catch (err) { - console.log(err); - throw err; - } -}); \ No newline at end of file + req.pipe(newReq); + } else { + handle(req, res, parsedUrl); + } + }, + ) + .listen(port, () => { + console.log( + process.env.NODE_ENV + + " HTTPS Server Running At: https://localhost:" + + port, + ); + }) + .on("error", (err: Error) => { + console.log(err); + throw err; + }); + + console.log("Server created. Listening: " + server.listening); + } catch (err) { + console.log(err); + throw err; + } +}); diff --git a/jest.config.ts b/jest.config.ts index 7be5578c..e16b603b 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -3,206 +3,206 @@ * https://jestjs.io/docs/configuration */ -import type { Config } from 'jest'; +import type { Config } from "jest"; const config: Config = { - preset: 'ts-jest', + preset: "ts-jest", - // Map @import paths to the correct directories - moduleNameMapper: { - "@/lib/(.*)$": ["/lib/$1"], - "@/pages/(.*)$": ["/pages/$1"], - "@/components/(.*)$": ["/components/$1"], - }, + // Map @import paths to the correct directories + moduleNameMapper: { + "@/lib/(.*)$": ["/lib/$1"], + "@/pages/(.*)$": ["/pages/$1"], + "@/components/(.*)$": ["/components/$1"], + }, - // All imported modules in your tests should be mocked automatically - // automock: false, + // All imported modules in your tests should be mocked automatically + // automock: false, - // Stop running tests after `n` failures - // bail: 0, + // Stop running tests after `n` failures + // bail: 0, - // The directory where Jest should store its cached dependency information - // cacheDirectory: "C:\\Users\\rddel\\AppData\\Local\\Temp\\jest", + // The directory where Jest should store its cached dependency information + // cacheDirectory: "C:\\Users\\rddel\\AppData\\Local\\Temp\\jest", - // Automatically clear mock calls, instances, contexts and results before every test - clearMocks: true, + // Automatically clear mock calls, instances, contexts and results before every test + clearMocks: true, - // Indicates whether the coverage information should be collected while executing the test - collectCoverage: true, + // Indicates whether the coverage information should be collected while executing the test + collectCoverage: true, - // An array of glob patterns indicating a set of files for which coverage information should be collected - // collectCoverageFrom: undefined, + // An array of glob patterns indicating a set of files for which coverage information should be collected + // collectCoverageFrom: undefined, - // The directory where Jest should output its coverage files - coverageDirectory: "coverage", + // The directory where Jest should output its coverage files + coverageDirectory: "coverage", - // An array of regexp pattern strings used to skip coverage collection - // coveragePathIgnorePatterns: [ - // "\\\\node_modules\\\\" - // ], + // An array of regexp pattern strings used to skip coverage collection + // coveragePathIgnorePatterns: [ + // "\\\\node_modules\\\\" + // ], - // Indicates which provider should be used to instrument code for coverage - coverageProvider: "v8", + // Indicates which provider should be used to instrument code for coverage + coverageProvider: "v8", - // A list of reporter names that Jest uses when writing coverage reports - // coverageReporters: [ - // "json", - // "text", - // "lcov", - // "clover" - // ], + // A list of reporter names that Jest uses when writing coverage reports + // coverageReporters: [ + // "json", + // "text", + // "lcov", + // "clover" + // ], - // An object that configures minimum threshold enforcement for coverage results - // coverageThreshold: undefined, + // An object that configures minimum threshold enforcement for coverage results + // coverageThreshold: undefined, - // A path to a custom dependency extractor - // dependencyExtractor: undefined, + // A path to a custom dependency extractor + // dependencyExtractor: undefined, - // Make calling deprecated APIs throw helpful error messages - // errorOnDeprecated: false, + // Make calling deprecated APIs throw helpful error messages + // errorOnDeprecated: false, - // The default configuration for fake timers - // fakeTimers: { - // "enableGlobally": false - // }, + // The default configuration for fake timers + // fakeTimers: { + // "enableGlobally": false + // }, - // Force coverage collection from ignored files using an array of glob patterns - // forceCoverageMatch: [], + // Force coverage collection from ignored files using an array of glob patterns + // forceCoverageMatch: [], - // A path to a module which exports an async function that is triggered once before all test suites - // globalSetup: undefined, + // A path to a module which exports an async function that is triggered once before all test suites + // globalSetup: undefined, - // A path to a module which exports an async function that is triggered once after all test suites - // globalTeardown: undefined, + // A path to a module which exports an async function that is triggered once after all test suites + // globalTeardown: undefined, - // A set of global variables that need to be available in all test environments - // globals: {}, + // A set of global variables that need to be available in all test environments + // globals: {}, - // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. - // maxWorkers: "50%", + // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. + // maxWorkers: "50%", - // An array of directory names to be searched recursively up from the requiring module's location - // moduleDirectories: [ - // "node_modules" - // ], + // An array of directory names to be searched recursively up from the requiring module's location + // moduleDirectories: [ + // "node_modules" + // ], - // An array of file extensions your modules use - // moduleFileExtensions: [ - // "js", - // "mjs", - // "cjs", - // "jsx", - // "ts", - // "tsx", - // "json", - // "node" - // ], + // An array of file extensions your modules use + // moduleFileExtensions: [ + // "js", + // "mjs", + // "cjs", + // "jsx", + // "ts", + // "tsx", + // "json", + // "node" + // ], - // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module - // moduleNameMapper: {}, + // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module + // moduleNameMapper: {}, - // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader - // modulePathIgnorePatterns: [], + // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader + // modulePathIgnorePatterns: [], - // Activates notifications for test results - // notify: false, + // Activates notifications for test results + // notify: false, - // An enum that specifies notification mode. Requires { notify: true } - // notifyMode: "failure-change", + // An enum that specifies notification mode. Requires { notify: true } + // notifyMode: "failure-change", - // A preset that is used as a base for Jest's configuration - // preset: undefined, + // A preset that is used as a base for Jest's configuration + // preset: undefined, - // Run tests from one or more projects - // projects: undefined, + // Run tests from one or more projects + // projects: undefined, - // Use this configuration option to add custom reporters to Jest - // reporters: undefined, + // Use this configuration option to add custom reporters to Jest + // reporters: undefined, - // Automatically reset mock state before every test - // resetMocks: false, + // Automatically reset mock state before every test + // resetMocks: false, - // Reset the module registry before running each individual test - // resetModules: false, + // Reset the module registry before running each individual test + // resetModules: false, - // A path to a custom resolver - // resolver: undefined, + // A path to a custom resolver + // resolver: undefined, - // Automatically restore mock state and implementation before every test - // restoreMocks: false, + // Automatically restore mock state and implementation before every test + // restoreMocks: false, - // The root directory that Jest should scan for tests and modules within - // rootDir: undefined, + // The root directory that Jest should scan for tests and modules within + // rootDir: undefined, - // A list of paths to directories that Jest should use to search for files in - // roots: [ - // "" - // ], + // A list of paths to directories that Jest should use to search for files in + // roots: [ + // "" + // ], - // Allows you to use a custom runner instead of Jest's default test runner - // runner: "jest-runner", + // Allows you to use a custom runner instead of Jest's default test runner + // runner: "jest-runner", - // The paths to modules that run some code to configure or set up the testing environment before each test - setupFiles: ["/lib/testutils/setup.ts"], + // The paths to modules that run some code to configure or set up the testing environment before each test + setupFiles: ["/lib/testutils/setup.ts"], - // A list of paths to modules that run some code to configure or set up the testing framework before each test - // setupFilesAfterEnv: [], + // A list of paths to modules that run some code to configure or set up the testing framework before each test + // setupFilesAfterEnv: [], - // The number of seconds after which a test is considered as slow and reported as such in the results. - // slowTestThreshold: 5, + // The number of seconds after which a test is considered as slow and reported as such in the results. + // slowTestThreshold: 5, - // A list of paths to snapshot serializer modules Jest should use for snapshot testing - // snapshotSerializers: [], + // A list of paths to snapshot serializer modules Jest should use for snapshot testing + // snapshotSerializers: [], - // The test environment that will be used for testing - // testEnvironment: "jest-environment-node", + // The test environment that will be used for testing + // testEnvironment: "jest-environment-node", - // Options that will be passed to the testEnvironment - // testEnvironmentOptions: {}, + // Options that will be passed to the testEnvironment + // testEnvironmentOptions: {}, - // Adds a location field to test results - // testLocationInResults: false, + // Adds a location field to test results + // testLocationInResults: false, - // The glob patterns Jest uses to detect test files - // testMatch: [ - // "**/__tests__/**/*.[jt]s?(x)", - // "**/?(*.)+(spec|test).[tj]s?(x)" - // ], + // The glob patterns Jest uses to detect test files + // testMatch: [ + // "**/__tests__/**/*.[jt]s?(x)", + // "**/?(*.)+(spec|test).[tj]s?(x)" + // ], - // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped - // testPathIgnorePatterns: [ - // "\\\\node_modules\\\\" - // ], + // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped + // testPathIgnorePatterns: [ + // "\\\\node_modules\\\\" + // ], - // The regexp pattern or array of patterns that Jest uses to detect test files - // testRegex: [], + // The regexp pattern or array of patterns that Jest uses to detect test files + // testRegex: [], - // This option allows the use of a custom results processor - // testResultsProcessor: undefined, + // This option allows the use of a custom results processor + // testResultsProcessor: undefined, - // This option allows use of a custom test runner - // testRunner: "jest-circus/runner", + // This option allows use of a custom test runner + // testRunner: "jest-circus/runner", - // A map from regular expressions to paths to transformers - // transform: undefined, + // A map from regular expressions to paths to transformers + // transform: undefined, - // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation - // transformIgnorePatterns: [ - // "\\\\node_modules\\\\", - // "\\.pnp\\.[^\\\\]+$" - // ], + // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation + // transformIgnorePatterns: [ + // "\\\\node_modules\\\\", + // "\\.pnp\\.[^\\\\]+$" + // ], - // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them - // unmockedModulePathPatterns: undefined, + // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them + // unmockedModulePathPatterns: undefined, - // Indicates whether each individual test should be reported during the run - // verbose: undefined, + // Indicates whether each individual test should be reported during the run + // verbose: undefined, - // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode - // watchPathIgnorePatterns: [], + // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode + // watchPathIgnorePatterns: [], - // Whether to use watchman for file crawling - // watchman: true, + // Whether to use watchman for file crawling + // watchman: true, }; export default config; diff --git a/lib/Auth.ts b/lib/Auth.ts index bbf404fd..d80805df 100644 --- a/lib/Auth.ts +++ b/lib/Auth.ts @@ -7,7 +7,7 @@ import { getDatabase, clientPromise } from "./MongoDB"; import { ObjectId } from "bson"; import { User } from "./Types"; import { GenerateSlug } from "./Utils"; -import { Analytics } from '@/lib/client/Analytics'; +import { Analytics } from "@/lib/client/Analytics"; import Email from "next-auth/providers/email"; import ResendUtils from "./ResendUtils"; import CollectionId from "./client/CollectionId"; @@ -18,26 +18,30 @@ var db = getDatabase(); const adapter = MongoDBAdapter(clientPromise, { databaseName: process.env.DB }); export const AuthenticationOptions: AuthOptions = { - secret: process.env.NEXTAUTH_SECRET, - providers: [ - Google({ - clientId: process.env.GOOGLE_ID, - clientSecret: process.env.GOOGLE_SECRET, - profile: async (profile) => { - const user = new User( - profile.name, - profile.email, - profile.picture, - false, - await GenerateSlug(await getDatabase(), CollectionId.Users, profile.name), - [], - [] - ); - user.id = profile.sub; - return user; - }, - }), - /* + secret: process.env.NEXTAUTH_SECRET, + providers: [ + Google({ + clientId: process.env.GOOGLE_ID, + clientSecret: process.env.GOOGLE_SECRET, + profile: async (profile) => { + const user = new User( + profile.name, + profile.email, + profile.picture, + false, + await GenerateSlug( + await getDatabase(), + CollectionId.Users, + profile.name, + ), + [], + [], + ); + user.id = profile.sub; + return user; + }, + }), + /* GitHubProvider({ clientId: process.env.GITHUB_ID as string, clientSecret: process.env.GITHUB_SECRET as string, @@ -48,98 +52,112 @@ export const AuthenticationOptions: AuthOptions = { }, }), */ - SlackProvider({ - clientId: process.env.NEXT_PUBLIC_SLACK_CLIENT_ID as string, - clientSecret: process.env.SLACK_CLIENT_SECRET as string, - profile: async (profile) => { - const user = new User( - profile.name, - profile.email, - profile.picture, - false, - await GenerateSlug(await getDatabase(), CollectionId.Users, profile.name), - [], - [], - profile.sub, - 10, - 1 - ); - user.id = profile.sub; - return user; - } - }), - Email({ - server: { - host: process.env.SMTP_HOST, - port: process.env.SMTP_PORT, - auth: { - user: process.env.SMTP_USER, - pass: process.env.SMTP_PASSWORD, - }, - }, - from: process.env.SMTP_FROM - }) - ], - callbacks: { - async session({ session, user }) { - session.user = await (await db).findObjectById( - CollectionId.Users, - new ObjectId(user.id) - ); + SlackProvider({ + clientId: process.env.NEXT_PUBLIC_SLACK_CLIENT_ID as string, + clientSecret: process.env.SLACK_CLIENT_SECRET as string, + profile: async (profile) => { + const user = new User( + profile.name, + profile.email, + profile.picture, + false, + await GenerateSlug( + await getDatabase(), + CollectionId.Users, + profile.name, + ), + [], + [], + profile.sub, + 10, + 1, + ); + user.id = profile.sub; + return user; + }, + }), + Email({ + server: { + host: process.env.SMTP_HOST, + port: process.env.SMTP_PORT, + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASSWORD, + }, + }, + from: process.env.SMTP_FROM, + }), + ], + callbacks: { + async session({ session, user }) { + session.user = await ( + await db + ).findObjectById(CollectionId.Users, new ObjectId(user.id)); + + return session; + }, - return session; - }, + async redirect({ baseUrl }) { + return baseUrl + "/onboarding"; + }, - async redirect({ baseUrl }) { - return baseUrl + "/onboarding"; - }, + async signIn({ user }) { + Analytics.signIn(user.name ?? "Unknown User"); + new ResendUtils().createContact(user); - async signIn({ user }) { - Analytics.signIn(user.name ?? "Unknown User"); - new ResendUtils().createContact(user); + let typedUser = user as Partial; + if (!typedUser.slug) { + console.log("User is incomplete, filling in missing fields"); - let typedUser = user as Partial; - if (!typedUser.slug) { - console.log("User is incomplete, filling in missing fields"); + const name = + typedUser.name ?? typedUser.email?.split("@")[0] ?? "Unknown User"; - const name = typedUser.name ?? typedUser.email?.split("@")[0] ?? "Unknown User"; - - // User is incomplete, fill in the missing fields - typedUser = { - _id: typedUser._id ?? new ObjectId(typedUser.id), - name, - image: typedUser.image ?? "https://4026.org/user.jpg", - slug: await GenerateSlug(await getDatabase(), CollectionId.Users, name), - teams: typedUser.teams ?? [], - owner: typedUser.owner ?? [], - slackId: typedUser.slackId ?? "", - onboardingComplete: typedUser.onboardingComplete ?? false, - admin: typedUser.admin ?? false, - xp: typedUser.xp ?? 0, - level: typedUser.level ?? 0, - ...typedUser, - } as User; + // User is incomplete, fill in the missing fields + typedUser = { + _id: typedUser._id ?? new ObjectId(typedUser.id), + name, + image: typedUser.image ?? "https://4026.org/user.jpg", + slug: await GenerateSlug( + await getDatabase(), + CollectionId.Users, + name, + ), + teams: typedUser.teams ?? [], + owner: typedUser.owner ?? [], + slackId: typedUser.slackId ?? "", + onboardingComplete: typedUser.onboardingComplete ?? false, + admin: typedUser.admin ?? false, + xp: typedUser.xp ?? 0, + level: typedUser.level ?? 0, + ...typedUser, + } as User; - await (await db).updateObjectById(CollectionId.Users, new ObjectId(typedUser._id?.toString()), typedUser); - } + await ( + await db + ).updateObjectById( + CollectionId.Users, + new ObjectId(typedUser._id?.toString()), + typedUser, + ); + } - return true; - } - }, - debug: false, - adapter: { - ...adapter, - createUser: async (user: Omit) => { - const createdUser = await adapter.createUser!(user); + return true; + }, + }, + debug: false, + adapter: { + ...adapter, + createUser: async (user: Omit) => { + const createdUser = await adapter.createUser!(user); - Analytics.newSignUp(user.name ?? "Unknown User"); + Analytics.newSignUp(user.name ?? "Unknown User"); - return createdUser; - } - }, - pages: { - //signIn: "/signin", - }, + return createdUser; + }, + }, + pages: { + //signIn: "/signin", + }, }; export default NextAuth(AuthenticationOptions); diff --git a/lib/CompetitionHandling.ts b/lib/CompetitionHandling.ts index 10b817c3..5009c3f1 100644 --- a/lib/CompetitionHandling.ts +++ b/lib/CompetitionHandling.ts @@ -1,142 +1,171 @@ import { - Competition, - Match, - Team, - Report, - AllianceColor, - League, + Competition, + Match, + Team, + Report, + AllianceColor, + League, } from "./Types"; import { ObjectId } from "bson"; import { rotateArray } from "./client/ClientUtils"; import { games } from "./games"; import { GameId } from "./client/GameId"; import CollectionId from "./client/CollectionId"; -import DbInterface from './client/dbinterfaces/DbInterface'; +import DbInterface from "./client/dbinterfaces/DbInterface"; type ScheduleMatch = { - subjectiveScouter?: string; - assignedScouters: string[]; -} + subjectiveScouter?: string; + assignedScouters: string[]; +}; /** * @tested_by tests/lib/CompetitionHandling.test.ts */ -export function generateSchedule(scouters: string[], subjectiveScouters: string[], matchCount: number, robotsPerMatch: number) { - const schedule = []; - for (let i = 0; i < matchCount; i++) { - const subjectiveScouter = subjectiveScouters.length > 0 ? subjectiveScouters[0] : undefined; - const assignedScouters = (subjectiveScouter ? scouters.filter((s) => s !== subjectiveScouter) : scouters).slice(0, robotsPerMatch); - - const match = { - subjectiveScouter, - assignedScouters - }; - schedule.push(match); - rotateArray(scouters); - rotateArray(subjectiveScouters); - } - - return schedule; +export function generateSchedule( + scouters: string[], + subjectiveScouters: string[], + matchCount: number, + robotsPerMatch: number, +) { + const schedule = []; + for (let i = 0; i < matchCount; i++) { + const subjectiveScouter = + subjectiveScouters.length > 0 ? subjectiveScouters[0] : undefined; + const assignedScouters = ( + subjectiveScouter + ? scouters.filter((s) => s !== subjectiveScouter) + : scouters + ).slice(0, robotsPerMatch); + + const match = { + subjectiveScouter, + assignedScouters, + }; + schedule.push(match); + rotateArray(scouters); + rotateArray(subjectiveScouters); + } + + return schedule; } export async function AssignScoutersToCompetitionMatches( - db: DbInterface, - teamId: string, - competitionId: string, + db: DbInterface, + teamId: string, + competitionId: string, ) { - const comp = await db.findObjectById( - CollectionId.Competitions, - new ObjectId(competitionId), - ); - - const team = await db.findObject( - CollectionId.Teams, - new ObjectId(teamId), - ); - - if (!comp || !team) { - throw new Error("Competition or team not found"); - } - - team.scouters = team.scouters.filter((s) => team.users.includes(s)); - team.subjectiveScouters = team.subjectiveScouters.filter((s) => team.users.includes(s)); - - const schedule = generateSchedule(team.scouters, team.subjectiveScouters, comp.matches.length, games[comp.gameId].league == League.FRC ? 6 : 4); - - const promises: Promise[] = []; - for (let i = 0; i < comp.matches.length; i++) { - // Filter out the subjective scouter that will be assigned to this match - promises.push(generateReportsForMatch(db, comp.matches[i], comp.gameId, schedule[i])); - } - - await Promise.all(promises); - return "Success"; + const comp = await db.findObjectById( + CollectionId.Competitions, + new ObjectId(competitionId), + ); + + const team = await db.findObject( + CollectionId.Teams, + new ObjectId(teamId), + ); + + if (!comp || !team) { + throw new Error("Competition or team not found"); + } + + team.scouters = team.scouters.filter((s) => team.users.includes(s)); + team.subjectiveScouters = team.subjectiveScouters.filter((s) => + team.users.includes(s), + ); + + const schedule = generateSchedule( + team.scouters, + team.subjectiveScouters, + comp.matches.length, + games[comp.gameId].league == League.FRC ? 6 : 4, + ); + + const promises: Promise[] = []; + for (let i = 0; i < comp.matches.length; i++) { + // Filter out the subjective scouter that will be assigned to this match + promises.push( + generateReportsForMatch(db, comp.matches[i], comp.gameId, schedule[i]), + ); + } + + await Promise.all(promises); + return "Success"; } -export async function generateReportsForMatch(db: DbInterface, match: string | Match, gameId: GameId, schedule?: ScheduleMatch) { - if (typeof match === "string") { - const foundMatch = await db.findObjectById( - CollectionId.Matches, - new ObjectId(match), - ); - - if (!foundMatch) { - throw new Error("Match not found"); - } - - match = foundMatch; - } - - match.subjectiveScouter = schedule?.subjectiveScouter; - - const existingReportPromises = match.reports.map((r) => - db.findObjectById(CollectionId.Reports, new ObjectId(r))); - const existingReports = await Promise.all(existingReportPromises); - - const bots = match.blueAlliance.concat(match.redAlliance); - const reports = []; - for (let i = 0; i < bots.length; i++) { - const teamNumber = bots[i]; - const scouter = i < (schedule?.assignedScouters.length ?? 0) ? schedule?.assignedScouters[i] : undefined; - const color = match.blueAlliance.includes(teamNumber) - ? AllianceColor.Blue - : AllianceColor.Red; - - const oldReport = existingReports.find((r) => r?.robotNumber === teamNumber); - - if (!oldReport) { - // Create a new report - - const newReport = new Report( - scouter, - games[gameId].createQuantitativeFormData(), - teamNumber, - color, - String(match._id), - 0 - ); - - reports.push( - String((await db.addObject(CollectionId.Reports, newReport))._id), - ); - } - else { - // Update existing report - oldReport.user = scouter; - - await db.updateObjectById( - CollectionId.Reports, - new ObjectId(oldReport._id), - oldReport, - ); - reports.push(oldReport._id); - } - } - - match.reports = reports.filter((r) => r !== undefined) as string[]; - await db.updateObjectById( - CollectionId.Matches, - new ObjectId(match._id), - match, - ); -} \ No newline at end of file +export async function generateReportsForMatch( + db: DbInterface, + match: string | Match, + gameId: GameId, + schedule?: ScheduleMatch, +) { + if (typeof match === "string") { + const foundMatch = await db.findObjectById( + CollectionId.Matches, + new ObjectId(match), + ); + + if (!foundMatch) { + throw new Error("Match not found"); + } + + match = foundMatch; + } + + match.subjectiveScouter = schedule?.subjectiveScouter; + + const existingReportPromises = match.reports.map((r) => + db.findObjectById(CollectionId.Reports, new ObjectId(r)), + ); + const existingReports = await Promise.all(existingReportPromises); + + const bots = match.blueAlliance.concat(match.redAlliance); + const reports = []; + for (let i = 0; i < bots.length; i++) { + const teamNumber = bots[i]; + const scouter = + i < (schedule?.assignedScouters.length ?? 0) + ? schedule?.assignedScouters[i] + : undefined; + const color = match.blueAlliance.includes(teamNumber) + ? AllianceColor.Blue + : AllianceColor.Red; + + const oldReport = existingReports.find( + (r) => r?.robotNumber === teamNumber, + ); + + if (!oldReport) { + // Create a new report + + const newReport = new Report( + scouter, + games[gameId].createQuantitativeFormData(), + teamNumber, + color, + String(match._id), + 0, + ); + + reports.push( + String((await db.addObject(CollectionId.Reports, newReport))._id), + ); + } else { + // Update existing report + oldReport.user = scouter; + + await db.updateObjectById( + CollectionId.Reports, + new ObjectId(oldReport._id), + oldReport, + ); + reports.push(oldReport._id); + } + } + + match.reports = reports.filter((r) => r !== undefined) as string[]; + await db.updateObjectById( + CollectionId.Matches, + new ObjectId(match._id), + match, + ); +} diff --git a/lib/Enums.ts b/lib/Enums.ts index c322ff13..8dc4ba49 100644 --- a/lib/Enums.ts +++ b/lib/Enums.ts @@ -1,83 +1,83 @@ export enum Defense { - None = "None", - Partial = "Partial", - Full = "Full", + None = "None", + Partial = "Partial", + Full = "Full", } export enum FTCEndgame { - None = "None", - Parked = "Parked", - TouchingTheLowerBar = "Touching the Lower Bar", - LowClimb = "Low Climb", - HighClimb = "High Climb", + None = "None", + Parked = "Parked", + TouchingTheLowerBar = "Touching the Lower Bar", + LowClimb = "Low Climb", + HighClimb = "High Climb", } export enum IntakeTypes { - None = "None", - Human = "Human", - Ground = "Ground", - Both = "Both", + None = "None", + Human = "Human", + Ground = "Ground", + Both = "Both", } export enum FrcDrivetrain { - Tank = "Tank", - Swerve = "Swerve", - Mecanum = "Mecanum", + Tank = "Tank", + Swerve = "Swerve", + Mecanum = "Mecanum", } export enum FtcDrivetrain { - Tank = FrcDrivetrain.Tank, // Reference the other enum to allow interoperability - Mecanum = FrcDrivetrain.Mecanum, + Tank = FrcDrivetrain.Tank, // Reference the other enum to allow interoperability + Mecanum = FrcDrivetrain.Mecanum, } export enum Motors { - CIMs = "CIM", - Krakens = "Krakens", - Falcons = "Falcons", - Talons = "Talons", - Neos = "Neos", + CIMs = "CIM", + Krakens = "Krakens", + Falcons = "Falcons", + Talons = "Talons", + Neos = "Neos", } export enum SwerveLevel { - None = "None", - L1 = "L1", - L2 = "L2", - L3 = "L3", + None = "None", + L1 = "L1", + L2 = "L2", + L3 = "L3", } export namespace CenterStageEnums { - export enum CenterStageParkingLocation { - Corner = "Corner", - Center = "Center", - FarSide = "Far Side", - NotApplicable = "N/A", - } + export enum CenterStageParkingLocation { + Corner = "Corner", + Center = "Center", + FarSide = "Far Side", + NotApplicable = "N/A", + } - export enum AutoAdjustable { - NoNeed = "No Need", - Yes = "Yes", - No = "No", - } + export enum AutoAdjustable { + NoNeed = "No Need", + Yes = "Yes", + No = "No", + } - export enum AutoSidePreference { - Backstage = "Backstage", - Audience = "Audience", - NoPreference = "No Preference", - } + export enum AutoSidePreference { + Backstage = "Backstage", + Audience = "Audience", + NoPreference = "No Preference", + } } export namespace IntoTheDeepEnums { - export enum StartedWith { - Nothing = "Nothing", - Sample = "Sample", - Specimen = "Specimen", - } + export enum StartedWith { + Nothing = "Nothing", + Sample = "Sample", + Specimen = "Specimen", + } - export enum EndgameLevelClimbed { - None = "None", - Parked = "Parked", - TouchedLowRung = "Touched Low Rung", - LowLevelClimb = "Low Level Climb", - HighLevelClimb = "High Level Climb", - } -} \ No newline at end of file + export enum EndgameLevelClimbed { + None = "None", + Parked = "Parked", + TouchedLowRung = "Touched Low Rung", + LowLevelClimb = "Low Level Climb", + HighLevelClimb = "High Level Climb", + } +} diff --git a/lib/Layout.ts b/lib/Layout.ts index a61452fa..5da62fa8 100644 --- a/lib/Layout.ts +++ b/lib/Layout.ts @@ -1,139 +1,221 @@ import { Dot } from "@/components/stats/Heatmap"; import { camelCaseToTitleCase } from "./client/ClientUtils"; -import { IntakeTypes, Defense, FrcDrivetrain, Motors, SwerveLevel, CenterStageEnums, IntoTheDeepEnums, FtcDrivetrain } from "./Enums"; +import { + IntakeTypes, + Defense, + FrcDrivetrain, + Motors, + SwerveLevel, + CenterStageEnums, + IntoTheDeepEnums, + FtcDrivetrain, +} from "./Enums"; import { PitReportData, QuantData, Pitreport, Report, League } from "./Types"; export type StringKeyedObject = { [key: string]: any }; -export type ElementType = "string" | "number" | "boolean" | "image" | "startingPos" | Object; - -export type FormElementProps = keyof TData | { - key: keyof TData; - label?: string; - type?: ElementType; -} +export type ElementType = + | "string" + | "number" + | "boolean" + | "image" + | "startingPos" + | Object; + +export type FormElementProps = + | keyof TData + | { + key: keyof TData; + label?: string; + type?: ElementType; + }; export class FormElement { - key: keyof TData; - label: string; - type?: ElementType - - constructor(league: League, key: keyof TData, dataExample: TData, label?: string, type?: ElementType) { - this.key = key; - this.label = label ?? camelCaseToTitleCase(key as string); - this.type = type ?? keyToType(league, key as string, dataExample); - } - - /** - * @tested_by tests/lib/Layout.test.ts - */ - static fromProps(league: League, props: FormElementProps, dataExample: TData): FormElement { - if (typeof props === "string") - return new FormElement(league, props, dataExample); - if (typeof props !== "object") - throw new Error("Invalid FormElementProps: " + props.toString()); - return new FormElement(league, props.key, dataExample, props.label, props.type); - } + key: keyof TData; + label: string; + type?: ElementType; + + constructor( + league: League, + key: keyof TData, + dataExample: TData, + label?: string, + type?: ElementType, + ) { + this.key = key; + this.label = label ?? camelCaseToTitleCase(key as string); + this.type = type ?? keyToType(league, key as string, dataExample); + } + + /** + * @tested_by tests/lib/Layout.test.ts + */ + static fromProps( + league: League, + props: FormElementProps, + dataExample: TData, + ): FormElement { + if (typeof props === "string") + return new FormElement(league, props, dataExample); + if (typeof props !== "object") + throw new Error("Invalid FormElementProps: " + props.toString()); + return new FormElement( + league, + props.key, + dataExample, + props.label, + props.type, + ); + } } -export type BlockElementProps = FormElementProps[][]; - -export class BlockElement extends Array>> { - constructor(league: League, elements: BlockElementProps, dataExample: TData) { - const genElements = elements.map(c => c.map(e => FormElement.fromProps(league, e, dataExample))); - super(...genElements); - } - - static isBlock(element: FormElement | BlockElement): element is BlockElement { - return Array.isArray(element); - } +export type BlockElementProps = + FormElementProps[][]; + +export class BlockElement extends Array< + Array> +> { + constructor( + league: League, + elements: BlockElementProps, + dataExample: TData, + ) { + const genElements = elements.map((c) => + c.map((e) => FormElement.fromProps(league, e, dataExample)), + ); + super(...genElements); + } + + static isBlock( + element: FormElement | BlockElement, + ): element is BlockElement { + return Array.isArray(element); + } } -export type FormLayoutProps = - { [header: string]: (FormElementProps | BlockElementProps)[] }; +export type FormLayoutProps = { + [header: string]: (FormElementProps | BlockElementProps)[]; +}; export class FormLayout { - [header: string]: (FormElement | BlockElement)[]; - - static fromProps(league: League, props: FormLayoutProps, dataExample: TData): FormLayout { - const layout = new FormLayout(); - for (const header in props) { - layout[header] = props[header].map(e => { - if (!Array.isArray(e)) - return FormElement.fromProps(league, e, dataExample); - return new BlockElement(league, e, dataExample); - }); - } - - return layout; - } + [header: string]: (FormElement | BlockElement)[]; + + static fromProps( + league: League, + props: FormLayoutProps, + dataExample: TData, + ): FormLayout { + const layout = new FormLayout(); + for (const header in props) { + layout[header] = props[header].map((e) => { + if (!Array.isArray(e)) + return FormElement.fromProps(league, e, dataExample); + return new BlockElement(league, e, dataExample); + }); + } + + return layout; + } } export type Badge = { - text: string; - color: "primary" | "secondary" | "accent" | "success" | "warning" | "info"; -} - -export type Stat = { - label: string; - key?: keyof TPitData | keyof TQuantData | undefined; - get?: (pitData: Pitreport | undefined, quantitativeReports: Report[] | undefined) => number; -} - -export type StatPair = { - stats: [Stat, Stat]; - label: string; -} - -export type StatsLayout = { - sections: { [header: string]: (Stat | StatPair)[] }; - getGraphDots: (quantitativeReports: Report[], pitReport?: Pitreport) => Dot[]; -} - -export type PitStatsLayout = { - [key: string]: Stat | Stat[]; - - overallSlideStats: Stat[]; - individualSlideStats: Stat[]; - robotCapabilities: Stat[]; - graphStat: Stat; -} + text: string; + color: "primary" | "secondary" | "accent" | "success" | "warning" | "info"; +}; + +export type Stat< + TPitData extends PitReportData, + TQuantData extends QuantData, +> = { + label: string; + key?: keyof TPitData | keyof TQuantData | undefined; + get?: ( + pitData: Pitreport | undefined, + quantitativeReports: Report[] | undefined, + ) => number; +}; + +export type StatPair< + TPitData extends PitReportData, + TQuantData extends QuantData, +> = { + stats: [Stat, Stat]; + label: string; +}; + +export type StatsLayout< + TPitData extends PitReportData, + TQuantData extends QuantData, +> = { + sections: { + [header: string]: ( + | Stat + | StatPair + )[]; + }; + getGraphDots: ( + quantitativeReports: Report[], + pitReport?: Pitreport, + ) => Dot[]; +}; + +export type PitStatsLayout< + TPitData extends PitReportData, + TQuantData extends QuantData, +> = { + [key: string]: Stat | Stat[]; + + overallSlideStats: Stat[]; + individualSlideStats: Stat[]; + robotCapabilities: Stat[]; + graphStat: Stat; +}; /** * @tested_by tests/lib/Layout.test.ts */ -export function keyToType(league: League, key: string, exampleData: StringKeyedObject): ElementType { - if (key === "image") - return "image"; - - const type = typeof exampleData[key]; - - if (type === "object" && "x" in exampleData[key] && "y" in exampleData[key] && "angle" in exampleData[key]) - return "startingPos"; - - if (type !== "string") - return type as ElementType; - - const enums = [ - IntakeTypes, Defense, league === League.FRC ? FrcDrivetrain : FtcDrivetrain, Motors, SwerveLevel, - CenterStageEnums.CenterStageParkingLocation, CenterStageEnums.AutoAdjustable, CenterStageEnums.AutoSidePreference, - IntoTheDeepEnums.StartedWith, IntoTheDeepEnums.EndgameLevelClimbed - ]; - - if (key === "Defense") - return Defense; - if (key === "swerveLevel") - return SwerveLevel; - - if (key === "StartedWith") - return IntoTheDeepEnums.StartedWith; - if (key === "EndgameLevelClimbed") - return IntoTheDeepEnums.EndgameLevelClimbed; - - for (const e of enums) { - if (Object.values(e).includes(exampleData[key])) - return e; - } - - return "string"; -} \ No newline at end of file +export function keyToType( + league: League, + key: string, + exampleData: StringKeyedObject, +): ElementType { + if (key === "image") return "image"; + + const type = typeof exampleData[key]; + + if ( + type === "object" && + "x" in exampleData[key] && + "y" in exampleData[key] && + "angle" in exampleData[key] + ) + return "startingPos"; + + if (type !== "string") return type as ElementType; + + const enums = [ + IntakeTypes, + Defense, + league === League.FRC ? FrcDrivetrain : FtcDrivetrain, + Motors, + SwerveLevel, + CenterStageEnums.CenterStageParkingLocation, + CenterStageEnums.AutoAdjustable, + CenterStageEnums.AutoSidePreference, + IntoTheDeepEnums.StartedWith, + IntoTheDeepEnums.EndgameLevelClimbed, + ]; + + if (key === "Defense") return Defense; + if (key === "swerveLevel") return SwerveLevel; + + if (key === "StartedWith") return IntoTheDeepEnums.StartedWith; + if (key === "EndgameLevelClimbed") + return IntoTheDeepEnums.EndgameLevelClimbed; + + for (const e of enums) { + if (Object.values(e).includes(exampleData[key])) return e; + } + + return "string"; +} diff --git a/lib/MongoDB.ts b/lib/MongoDB.ts index 8ea281dc..054b1021 100644 --- a/lib/MongoDB.ts +++ b/lib/MongoDB.ts @@ -1,18 +1,16 @@ -import { - Db, - MongoClient, - MongoClientOptions, -} from "mongodb"; +import { Db, MongoClient, MongoClientOptions } from "mongodb"; import { ObjectId, Document } from "bson"; import CollectionId, { CollectionIdToType } from "./client/CollectionId"; -import DbInterface, { WithStringOrObjectIdId } from "./client/dbinterfaces/DbInterface"; +import DbInterface, { + WithStringOrObjectIdId, +} from "./client/dbinterfaces/DbInterface"; if (!process.env.MONGODB_URI) { - // Necessary to allow connections from files running outside of Next - require("dotenv").config(); + // Necessary to allow connections from files running outside of Next + require("dotenv").config(); - if (!process.env.MONGODB_URI) - console.error('Invalid/Missing environment variable: "MONGODB_URI"'); + if (!process.env.MONGODB_URI) + console.error('Invalid/Missing environment variable: "MONGODB_URI"'); } const uri = process.env.MONGODB_URI ?? "mongodb://localhost:27017"; @@ -22,105 +20,117 @@ let client; let clientPromise: Promise; if (!global.clientPromise) { - client = new MongoClient(uri, options); - global.clientPromise = client.connect(); + client = new MongoClient(uri, options); + global.clientPromise = client.connect(); } clientPromise = global.clientPromise; export { clientPromise }; export async function getDatabase(): Promise { - if (!global.interface) { - await clientPromise; - const dbInterface = new MongoDBInterface(clientPromise); - await dbInterface.init(); - global.interface = dbInterface; + if (!global.interface) { + await clientPromise; + const dbInterface = new MongoDBInterface(clientPromise); + await dbInterface.init(); + global.interface = dbInterface; - return dbInterface; - } + return dbInterface; + } - return global.interface; + return global.interface; } export class MongoDBInterface implements DbInterface { - promise: Promise | undefined; - client: MongoClient | undefined; - db: Db | undefined; - - constructor(promise: Promise) { - this.promise = promise; - } - - async init() { - this.client = await this.promise; - this.db = this.client?.db(process.env.DB); - //@ts-ignore - - const CollectionId = await this.db?.listCollections().toArray() as CollectionId; - if (CollectionId?.length === 0) { - try { - Object.values(CollectionId).forEach( - async (collectionName) => - await this.db?.createCollection(collectionName) - ); - } catch (e) { - console.log("Failed to create CollectionId... (probably exist already)"); - } - } - } - - async addObject>(collection: TId, object: WithStringOrObjectIdId): Promise { - if (object._id && typeof object._id === "string") - object._id = new ObjectId(object._id); - - const ack = await this?.db?.collection(collection).insertOne(object as Document & { _id?: ObjectId }); - object._id = ack?.insertedId; - return object as TObj; - } - - async deleteObjectById(collection: CollectionId, id: ObjectId) { - var query = { _id: id }; - await this?.db?.collection(collection).deleteOne(query); - } - - updateObjectById>(collection: TId, id: ObjectId, newValues: Partial): Promise { - var query = { _id: id }; - var updated = { $set: newValues }; - this?.db - ?.collection(collection) - .updateOne(query, updated); - - return Promise.resolve(); - } - - async findObjectById( - collection: CollectionId, - id: ObjectId - ): Promise { - return (await this?.db?.collection(collection).findOne({ _id: id })) as Type; - } - - async findObject( - collection: CollectionId, - query: object - ): Promise { - return (await this?.db?.collection(collection).findOne(query)) as Type; - } - - async findObjects( - collection: CollectionId, - query: object - ): Promise { - return (await this?.db - ?.collection(collection) - .find(query) - .toArray()) as Type[]; - } - - async countObjects( - collection: CollectionId, - query: object - ): Promise { - return await this?.db?.collection(collection).countDocuments(query); - } + promise: Promise | undefined; + client: MongoClient | undefined; + db: Db | undefined; + + constructor(promise: Promise) { + this.promise = promise; + } + + async init() { + this.client = await this.promise; + this.db = this.client?.db(process.env.DB); + //@ts-ignore + + const CollectionId = (await this.db + ?.listCollections() + .toArray()) as CollectionId; + if (CollectionId?.length === 0) { + try { + Object.values(CollectionId).forEach( + async (collectionName) => + await this.db?.createCollection(collectionName), + ); + } catch (e) { + console.log( + "Failed to create CollectionId... (probably exist already)", + ); + } + } + } + + async addObject< + TId extends CollectionId, + TObj extends CollectionIdToType, + >(collection: TId, object: WithStringOrObjectIdId): Promise { + if (object._id && typeof object._id === "string") + object._id = new ObjectId(object._id); + + const ack = await this?.db + ?.collection(collection) + .insertOne(object as Document & { _id?: ObjectId }); + object._id = ack?.insertedId; + return object as TObj; + } + + async deleteObjectById(collection: CollectionId, id: ObjectId) { + var query = { _id: id }; + await this?.db?.collection(collection).deleteOne(query); + } + + updateObjectById< + TId extends CollectionId, + TObj extends CollectionIdToType, + >(collection: TId, id: ObjectId, newValues: Partial): Promise { + var query = { _id: id }; + var updated = { $set: newValues }; + this?.db?.collection(collection).updateOne(query, updated); + + return Promise.resolve(); + } + + async findObjectById( + collection: CollectionId, + id: ObjectId, + ): Promise { + return (await this?.db + ?.collection(collection) + .findOne({ _id: id })) as Type; + } + + async findObject( + collection: CollectionId, + query: object, + ): Promise { + return (await this?.db?.collection(collection).findOne(query)) as Type; + } + + async findObjects( + collection: CollectionId, + query: object, + ): Promise { + return (await this?.db + ?.collection(collection) + .find(query) + .toArray()) as Type[]; + } + + async countObjects( + collection: CollectionId, + query: object, + ): Promise { + return await this?.db?.collection(collection).countDocuments(query); + } } diff --git a/lib/ResendUtils.ts b/lib/ResendUtils.ts index 420b97ed..bac16f7b 100644 --- a/lib/ResendUtils.ts +++ b/lib/ResendUtils.ts @@ -1,64 +1,68 @@ import { User as NextAuthUser } from "next-auth"; -import { Resend } from 'resend'; -import { getDatabase } from './MongoDB'; +import { Resend } from "resend"; +import { getDatabase } from "./MongoDB"; import { User } from "./Types"; import CollectionId from "./client/CollectionId"; const resend = new Resend(process.env.SMTP_PASSWORD); export interface ResendInterface { - createContact: (rawUser: NextAuthUser) => Promise; - emailDevelopers: (subject: string, message: string) => void; + createContact: (rawUser: NextAuthUser) => Promise; + emailDevelopers: (subject: string, message: string) => void; } export class ResendUtils implements ResendInterface { - async createContact(rawUser: NextAuthUser) { - const user = rawUser as User; + async createContact(rawUser: NextAuthUser) { + const user = rawUser as User; - if (user.resendContactId) - return; + if (user.resendContactId) return; - if (!user.email || !user.name) { - console.error("User is missing email or name", user); - return; - } + if (!user.email || !user.name) { + console.error("User is missing email or name", user); + return; + } - console.log("Creating contact for", user.email); + console.log("Creating contact for", user.email); - const nameParts = user.name?.split(" "); + const nameParts = user.name?.split(" "); - const res = await resend.contacts.create({ - email: user.email, - firstName: nameParts[0], - lastName: nameParts.length > 1 ? nameParts[1] : "", - unsubscribed: false, - audienceId: process.env.RESEND_AUDIENCE_ID, - }); + const res = await resend.contacts.create({ + email: user.email, + firstName: nameParts[0], + lastName: nameParts.length > 1 ? nameParts[1] : "", + unsubscribed: false, + audienceId: process.env.RESEND_AUDIENCE_ID, + }); - if (!res.data?.id) { - console.error("Failed to create contact for", user.email); - console.error(res); - return; - } + if (!res.data?.id) { + console.error("Failed to create contact for", user.email); + console.error(res); + return; + } - const db = await getDatabase(); - // Going around our own interface is a red flag, but it's 11 PM and I'm tired -Renato - db.db?.collection(CollectionId.Users).updateOne({ email: user.email}, { $set: { resendContactId: res.data.id } }); - } + const db = await getDatabase(); + // Going around our own interface is a red flag, but it's 11 PM and I'm tired -Renato + db.db + ?.collection(CollectionId.Users) + .updateOne( + { email: user.email }, + { $set: { resendContactId: res.data.id } }, + ); + } - async emailDevelopers(subject: string, message: string) { - if (!process.env.DEVELOPER_EMAILS) { - console.error("No developer emails found"); - return; - } + async emailDevelopers(subject: string, message: string) { + if (!process.env.DEVELOPER_EMAILS) { + console.error("No developer emails found"); + return; + } - resend.emails.send({ - from: "Gearbox Server ", - to: JSON.parse(process.env.DEVELOPER_EMAILS), // Environment variables are always strings, so we need to parse it - subject, - text: message, - }) - } + resend.emails.send({ + from: "Gearbox Server ", + to: JSON.parse(process.env.DEVELOPER_EMAILS), // Environment variables are always strings, so we need to parse it + subject, + text: message, + }); + } } -export default ResendUtils; \ No newline at end of file +export default ResendUtils; diff --git a/lib/SlackClient.ts b/lib/SlackClient.ts index 973d58f4..5befdf63 100644 --- a/lib/SlackClient.ts +++ b/lib/SlackClient.ts @@ -1,12 +1,12 @@ export interface SlackInterface { - sendMsg(webhookUrl: string, msg: string): Promise; + sendMsg(webhookUrl: string, msg: string): Promise; } export default class SlackClient implements SlackInterface { - sendMsg(webhookUrl: string, msg: string) { - return fetch(webhookUrl, { - method: "POST", - body: JSON.stringify({ text: msg }), - }) - } -} \ No newline at end of file + sendMsg(webhookUrl: string, msg: string) { + return fetch(webhookUrl, { + method: "POST", + body: JSON.stringify({ text: msg }), + }); + } +} diff --git a/lib/Socket.ts b/lib/Socket.ts index 7663958b..849c09ad 100644 --- a/lib/Socket.ts +++ b/lib/Socket.ts @@ -7,48 +7,48 @@ import ClientApi from "@/lib/api/ClientApi"; const api = new ClientApi(); export interface SocketServer extends HTTPServer { - io?: Server | undefined; + io?: Server | undefined; } export interface SocketIO extends Socket { - server: SocketServer; + server: SocketServer; } export interface NextResponseWithSocketIO extends NextResponse { - socket: SocketIO; + socket: SocketIO; } const SocketHandler = (req: NextRequest, res: NextResponseWithSocketIO) => { - if (!res.socket.server.io) { - console.log("Socket is initializing"); - const io = new Server(res.socket.server, { - path: "/api/socketio", - addTrailingSlash: false, - }); - res.socket.server.io = io; - - io.on("connect", (socket) => { - socket.on("form-update", (data) => { - socket.broadcast.emit("form-update", data); - }); - - socket.on("form-submit", (_id) => { - socket.broadcast.emit("form-submit", _id); - }); - - socket.on("update-checkin", (reportId) => { - console.log("Checkin"); - socket.broadcast.emit("update-checkin", reportId); - }); - - socket.on("update-checkout", (reportId) => { - socket.broadcast.emit("update-checkout", reportId); - console.log("Checkout"); - }); - }); - } - //@ts-ignore - res.end(); + if (!res.socket.server.io) { + console.log("Socket is initializing"); + const io = new Server(res.socket.server, { + path: "/api/socketio", + addTrailingSlash: false, + }); + res.socket.server.io = io; + + io.on("connect", (socket) => { + socket.on("form-update", (data) => { + socket.broadcast.emit("form-update", data); + }); + + socket.on("form-submit", (_id) => { + socket.broadcast.emit("form-submit", _id); + }); + + socket.on("update-checkin", (reportId) => { + console.log("Checkin"); + socket.broadcast.emit("update-checkin", reportId); + }); + + socket.on("update-checkout", (reportId) => { + socket.broadcast.emit("update-checkout", reportId); + console.log("Checkout"); + }); + }); + } + //@ts-ignore + res.end(); }; export default SocketHandler; diff --git a/lib/Statbotics.ts b/lib/Statbotics.ts index 1e57605d..9263965d 100644 --- a/lib/Statbotics.ts +++ b/lib/Statbotics.ts @@ -1,64 +1,67 @@ - - export namespace Statbotics { - const BaseURL = "https://api.statbotics.io/v3" - export interface TeamEvent { - team: number, - year: number, - event: string, - time: number, - offseason : boolean, - team_name : string , - event_name : string , - country : string, - state : string , - district : string , - //type : district , - week : number, - //status : Completed, - epa : { - total_points : { - mean : number, - }, - breakdown : { - total_points : { - mean : number, - }, - auto_points : { - mean : number, - }, - teleop_points : { - mean : number, - }, - endgame_points : { - mean : number, - }, - total_notes : { - mean : number, - }, - } - }, - record : { - qual : { - wins : number, - losses : number, - ties : number, - winrate : number, - rank : number - } - }, - district_points : number, - } + const BaseURL = "https://api.statbotics.io/v3"; + export interface TeamEvent { + team: number; + year: number; + event: string; + time: number; + offseason: boolean; + team_name: string; + event_name: string; + country: string; + state: string; + district: string; + //type : district , + week: number; + //status : Completed, + epa: { + total_points: { + mean: number; + }; + breakdown: { + total_points: { + mean: number; + }; + auto_points: { + mean: number; + }; + teleop_points: { + mean: number; + }; + endgame_points: { + mean: number; + }; + total_notes: { + mean: number; + }; + }; + }; + record: { + qual: { + wins: number; + losses: number; + ties: number; + winrate: number; + rank: number; + }; + }; + district_points: number; + } - async function statboticsRequest(subUrl: string): Promise { - var res = await fetch(BaseURL + subUrl, { - method: "GET", - }); - return res.json(); - } + async function statboticsRequest(subUrl: string): Promise { + var res = await fetch(BaseURL + subUrl, { + method: "GET", + }); + return res.json(); + } - export async function getTeamEvent(eventKey: string, teamNumber: number|string) { - const teamEvent = await statboticsRequest(`/team_event/${teamNumber}/${eventKey}`) - return teamEvent; - } + export async function getTeamEvent( + eventKey: string, + teamNumber: number | string, + ) { + const teamEvent = await statboticsRequest( + `/team_event/${teamNumber}/${eventKey}`, + ); + return teamEvent; + } } diff --git a/lib/TheBlueAlliance.ts b/lib/TheBlueAlliance.ts index 80fd1464..fdcffd08 100644 --- a/lib/TheBlueAlliance.ts +++ b/lib/TheBlueAlliance.ts @@ -1,336 +1,342 @@ import stringSimilarity from "string-similarity-js"; import { getDatabase, MongoDBInterface } from "./MongoDB"; import { - Competition, - Match, - Team, - CompetitonNameIdPair, - MatchType, - Alliance, - Pitreport, + Competition, + Match, + Team, + CompetitonNameIdPair, + MatchType, + Alliance, + Pitreport, } from "./Types"; import { NotLinkedToTba } from "./client/ClientUtils"; import { GameId, defaultGameId } from "./client/GameId"; import { games } from "./games"; export namespace TheBlueAlliance { - export interface SimpleTeam { - key: string; - team_number: number; - nickname: string; - name: string; - city: string; - state_prov: string; - country: string; - } - - export interface SimpleDistrict { - abbreviation: string; - display_name: string; - key: string; - year: number; - } - - export interface SimpleCompetition { - key: string; - name: string; - event_code: string; - event_type: number; - district: SimpleDistrict; - city: string; - state_prov: string; - country: string; - start_date: string; //"2023-04-26" - end_date: string; - year: number; - } - - export enum CompetitionLevel { - QM = "qm", - EF = "ef", - QF = "qf", - SF = "sf", - F = "f", - } - - export interface Alliances { - red: TbaAlliance; - blue: TbaAlliance; - } - - export interface TbaAlliance { - score: number; - team_keys: string[]; - surrogate_team_keys: string[]; - dq_team_keys: string[]; - } - - export enum WinningAlliance { - Red = "red", - Blue = "blue", - } - - export interface SimpleRank { - matches_played: number; - qual_average: number; - record: { - losses: number; - wins: number; - ties: number; - }; - rank: number; - dq: number; - team_key: string; - } - - export interface OprRanking { - oprs: { - [key: string]: number; - }; - } - - export interface SimpleMatch { - key: string; - comp_level: CompetitionLevel; // qm - set_number: number; - match_number: number; - alliances: Alliances; - winning_alliance: WinningAlliance; - event_key: string; - time: number; - predicted_time: number; - actual_time: number; - } - - export interface District { - key: string; - display_name: string; - year: number; - abbreviation: string; - } - - export enum Prefixes { - FTC = "ftc", - FRC = "frc", - } - - export type TeamArray = { [team_number: string]: number }[]; - - export function ConvertDate(tbaDate: string): number { - return Date.parse(tbaDate); - } - - export function competitionLevelToMatchType( - matchType: CompetitionLevel - ): MatchType { - if (matchType === CompetitionLevel.QM) { - return MatchType.Qualifying; - } - if (matchType === CompetitionLevel.EF) { - return MatchType.Qualifying; - } - if (matchType === CompetitionLevel.QF) { - return MatchType.Quarterfinals; - } - if (matchType === CompetitionLevel.SF) { - return MatchType.Semifinals; - } else { - return MatchType.Finals; - } - } - - export function tbaIdsToTeamNumbers(tbaIds: string[]): Alliance { - //@ts-ignore - return tbaIds.map((id) => parseInt(id.match(/\d+/g)[0])); - } - - class Request { - baseUrl: string; - apiKey: string; - - constructor(baseUrl = process.env.TBA_URL, apiKey = process.env.TBA_KEY) { - this.baseUrl = baseUrl; - this.apiKey = apiKey; - } - - async request(suburl: string): Promise { - var res = await fetch(this.baseUrl + suburl, { - method: "GET", - headers: { - "X-TBA-Auth-Key": this.apiKey, - }, - }); - - return res.json(); - } - - async getTeam(tbaId: string): Promise { - return this.request(`/team/${tbaId}/simple`); - } - - async getCompetition(tbaId: string): Promise { - return this.request(`/event/${tbaId}/simple`); - } - - async getCompetitionMatches(tbaId: string): Promise { - return this.request(`/event/${tbaId}/matches`); - } - - async getCompetitonRanking( - tbaId: string - ): Promise<{ rankings: SimpleRank[] }> { - return this.request(`/event/${tbaId}/rankings`); - } - - async getCompetitonOPRS(tbaId: string): Promise { - return this.request(`/event/${tbaId}/oprs`); - } - - async getMatch(tbaId: string): Promise { - return this.request(`/match/${tbaId}/simple`); - } - - async getDistricts(year: number): Promise { - return this.request(`/districts/${year}`); - } - - async getDistrictEvents(tbaId: string): Promise { - return this.request(`/district/${tbaId}/events/simple`); - } - - async getEvents(year: number): Promise { - return this.request(`/events/${year}/simple`); - } - - async getCompetitionTeams(tbaId: string): Promise { - return this.request(`/event/${tbaId}/teams`); - } - } - - export class Interface { - req: Request; - db: Promise; - - competitionPairings: CompetitonNameIdPair[] = []; - - constructor() { - this.req = new Request(); - this.db = getDatabase(); - - this.loadCompetitionPairings(); - } - - async getTeamAutofillData(teamNumber: number): Promise { - let team = await this.req.getTeam(Prefixes.FRC + teamNumber.toString()); - if (!team) { - team = await this.req.getTeam(Prefixes.FTC + teamNumber.toString()); - } - - return new Team(team.nickname, undefined, team.key, team.team_number); - } - - async getCompetitionAutofillData(tbaId: string): Promise { - var competitonData = await this.req.getCompetition(tbaId); - - let competition = new Competition( - competitonData.name, - undefined, - competitonData.key, - ConvertDate(competitonData.start_date), - ConvertDate(competitonData.end_date) - ); - - // maybe give automatic matches later, once scouting is more stabilized - return competition; - } - - async getMatchAutofillData(tbaId: string): Promise { - let data = await this.req.getMatch(tbaId); - return new Match( - data.match_number, - undefined, - data.key, - data.time, - competitionLevelToMatchType(data.comp_level), - tbaIdsToTeamNumbers(data.alliances.blue.team_keys), - tbaIdsToTeamNumbers(data.alliances.red.team_keys) - ); - } - - async getCompetitionMatches(tbaId: string): Promise { - if (tbaId === NotLinkedToTba) - return []; - - let matches = (await this.req.getCompetitionMatches(tbaId)).map( - (data) => - new Match( - data.match_number, - undefined, - data.key, - data.time, - competitionLevelToMatchType(data.comp_level), - tbaIdsToTeamNumbers(data.alliances.blue.team_keys), - tbaIdsToTeamNumbers(data.alliances.red.team_keys) - ) - ) ?? []; - return matches; - } - - async getCompetitionPitreports(tbaId: string, gameId: GameId): Promise { - if (tbaId === NotLinkedToTba) - return []; - - const competitionTeams = await this.req.getCompetitionTeams(tbaId); - return competitionTeams.map( - ({ team_number }) => new Pitreport(team_number, games[gameId ?? defaultGameId].createPitReportData()) - ); - } - - async getMatchAndReportsAutofillData() { - throw new Error(); - } - - async searchCompetitionByName(name: string, trim: number = 5) { - var results: { value: number; pair: CompetitonNameIdPair }[] = []; - this.competitionPairings.forEach((pair) => { - results.push({ value: stringSimilarity(name, pair.name), pair }); - }); - - results.sort((a, b) => { - if (a.value < b.value) { - return 1; - } else if (a.value > b.value) { - return -1; - } - - return 0; - }); - - results = results.slice(0, trim); - return results; - } - - async loadCompetitionPairings() { - if (global?.compIdPairs) { - this.competitionPairings = global.compIdPairs; - } else { - console.log("Loading Pairings For Competition Searches..."); - const year = new Date().getFullYear(); - const pairings = await this.allCompetitionsToPairings(year); - console.log(`Loaded ${pairings.length} Pairings!`); - this.competitionPairings = pairings; - global.compIdPairs = pairings; - } - } - - async allCompetitionsToPairings(year: number) { - var allCompetitions = await this.req.getEvents(year); - var pairings: CompetitonNameIdPair[] = []; - allCompetitions.forEach((comp) => { - pairings.push({ name: comp.name, tbaId: comp.key }); - }); - - return pairings; - } - } + export interface SimpleTeam { + key: string; + team_number: number; + nickname: string; + name: string; + city: string; + state_prov: string; + country: string; + } + + export interface SimpleDistrict { + abbreviation: string; + display_name: string; + key: string; + year: number; + } + + export interface SimpleCompetition { + key: string; + name: string; + event_code: string; + event_type: number; + district: SimpleDistrict; + city: string; + state_prov: string; + country: string; + start_date: string; //"2023-04-26" + end_date: string; + year: number; + } + + export enum CompetitionLevel { + QM = "qm", + EF = "ef", + QF = "qf", + SF = "sf", + F = "f", + } + + export interface Alliances { + red: TbaAlliance; + blue: TbaAlliance; + } + + export interface TbaAlliance { + score: number; + team_keys: string[]; + surrogate_team_keys: string[]; + dq_team_keys: string[]; + } + + export enum WinningAlliance { + Red = "red", + Blue = "blue", + } + + export interface SimpleRank { + matches_played: number; + qual_average: number; + record: { + losses: number; + wins: number; + ties: number; + }; + rank: number; + dq: number; + team_key: string; + } + + export interface OprRanking { + oprs: { + [key: string]: number; + }; + } + + export interface SimpleMatch { + key: string; + comp_level: CompetitionLevel; // qm + set_number: number; + match_number: number; + alliances: Alliances; + winning_alliance: WinningAlliance; + event_key: string; + time: number; + predicted_time: number; + actual_time: number; + } + + export interface District { + key: string; + display_name: string; + year: number; + abbreviation: string; + } + + export enum Prefixes { + FTC = "ftc", + FRC = "frc", + } + + export type TeamArray = { [team_number: string]: number }[]; + + export function ConvertDate(tbaDate: string): number { + return Date.parse(tbaDate); + } + + export function competitionLevelToMatchType( + matchType: CompetitionLevel, + ): MatchType { + if (matchType === CompetitionLevel.QM) { + return MatchType.Qualifying; + } + if (matchType === CompetitionLevel.EF) { + return MatchType.Qualifying; + } + if (matchType === CompetitionLevel.QF) { + return MatchType.Quarterfinals; + } + if (matchType === CompetitionLevel.SF) { + return MatchType.Semifinals; + } else { + return MatchType.Finals; + } + } + + export function tbaIdsToTeamNumbers(tbaIds: string[]): Alliance { + //@ts-ignore + return tbaIds.map((id) => parseInt(id.match(/\d+/g)[0])); + } + + class Request { + baseUrl: string; + apiKey: string; + + constructor(baseUrl = process.env.TBA_URL, apiKey = process.env.TBA_KEY) { + this.baseUrl = baseUrl; + this.apiKey = apiKey; + } + + async request(suburl: string): Promise { + var res = await fetch(this.baseUrl + suburl, { + method: "GET", + headers: { + "X-TBA-Auth-Key": this.apiKey, + }, + }); + + return res.json(); + } + + async getTeam(tbaId: string): Promise { + return this.request(`/team/${tbaId}/simple`); + } + + async getCompetition(tbaId: string): Promise { + return this.request(`/event/${tbaId}/simple`); + } + + async getCompetitionMatches(tbaId: string): Promise { + return this.request(`/event/${tbaId}/matches`); + } + + async getCompetitonRanking( + tbaId: string, + ): Promise<{ rankings: SimpleRank[] }> { + return this.request(`/event/${tbaId}/rankings`); + } + + async getCompetitonOPRS(tbaId: string): Promise { + return this.request(`/event/${tbaId}/oprs`); + } + + async getMatch(tbaId: string): Promise { + return this.request(`/match/${tbaId}/simple`); + } + + async getDistricts(year: number): Promise { + return this.request(`/districts/${year}`); + } + + async getDistrictEvents(tbaId: string): Promise { + return this.request(`/district/${tbaId}/events/simple`); + } + + async getEvents(year: number): Promise { + return this.request(`/events/${year}/simple`); + } + + async getCompetitionTeams(tbaId: string): Promise { + return this.request(`/event/${tbaId}/teams`); + } + } + + export class Interface { + req: Request; + db: Promise; + + competitionPairings: CompetitonNameIdPair[] = []; + + constructor() { + this.req = new Request(); + this.db = getDatabase(); + + this.loadCompetitionPairings(); + } + + async getTeamAutofillData(teamNumber: number): Promise { + let team = await this.req.getTeam(Prefixes.FRC + teamNumber.toString()); + if (!team) { + team = await this.req.getTeam(Prefixes.FTC + teamNumber.toString()); + } + + return new Team(team.nickname, undefined, team.key, team.team_number); + } + + async getCompetitionAutofillData(tbaId: string): Promise { + var competitonData = await this.req.getCompetition(tbaId); + + let competition = new Competition( + competitonData.name, + undefined, + competitonData.key, + ConvertDate(competitonData.start_date), + ConvertDate(competitonData.end_date), + ); + + // maybe give automatic matches later, once scouting is more stabilized + return competition; + } + + async getMatchAutofillData(tbaId: string): Promise { + let data = await this.req.getMatch(tbaId); + return new Match( + data.match_number, + undefined, + data.key, + data.time, + competitionLevelToMatchType(data.comp_level), + tbaIdsToTeamNumbers(data.alliances.blue.team_keys), + tbaIdsToTeamNumbers(data.alliances.red.team_keys), + ); + } + + async getCompetitionMatches(tbaId: string): Promise { + if (tbaId === NotLinkedToTba) return []; + + let matches = + (await this.req.getCompetitionMatches(tbaId)).map( + (data) => + new Match( + data.match_number, + undefined, + data.key, + data.time, + competitionLevelToMatchType(data.comp_level), + tbaIdsToTeamNumbers(data.alliances.blue.team_keys), + tbaIdsToTeamNumbers(data.alliances.red.team_keys), + ), + ) ?? []; + return matches; + } + + async getCompetitionPitreports( + tbaId: string, + gameId: GameId, + ): Promise { + if (tbaId === NotLinkedToTba) return []; + + const competitionTeams = await this.req.getCompetitionTeams(tbaId); + return competitionTeams.map( + ({ team_number }) => + new Pitreport( + team_number, + games[gameId ?? defaultGameId].createPitReportData(), + ), + ); + } + + async getMatchAndReportsAutofillData() { + throw new Error(); + } + + async searchCompetitionByName(name: string, trim: number = 5) { + var results: { value: number; pair: CompetitonNameIdPair }[] = []; + this.competitionPairings.forEach((pair) => { + results.push({ value: stringSimilarity(name, pair.name), pair }); + }); + + results.sort((a, b) => { + if (a.value < b.value) { + return 1; + } else if (a.value > b.value) { + return -1; + } + + return 0; + }); + + results = results.slice(0, trim); + return results; + } + + async loadCompetitionPairings() { + if (global?.compIdPairs) { + this.competitionPairings = global.compIdPairs; + } else { + console.log("Loading Pairings For Competition Searches..."); + const year = new Date().getFullYear(); + const pairings = await this.allCompetitionsToPairings(year); + console.log(`Loaded ${pairings.length} Pairings!`); + this.competitionPairings = pairings; + global.compIdPairs = pairings; + } + } + + async allCompetitionsToPairings(year: number) { + var allCompetitions = await this.req.getEvents(year); + var pairings: CompetitonNameIdPair[] = []; + allCompetitions.forEach((comp) => { + pairings.push({ name: comp.name, tbaId: comp.key }); + }); + + return pairings; + } + } } diff --git a/lib/TheOrangeAlliance.ts b/lib/TheOrangeAlliance.ts index ad954236..fd35d487 100644 --- a/lib/TheOrangeAlliance.ts +++ b/lib/TheOrangeAlliance.ts @@ -1,46 +1,50 @@ import { League, Team } from "./Types"; async function request(suburl: string): Promise { - var res = await fetch(process.env.TOA_URL + suburl, { - method: "GET", - headers: { - "X-Application-Origin": process.env.TOA_APP_ID, - "X-TOA-Key": process.env.TOA_KEY, - }, - }); + var res = await fetch(process.env.TOA_URL + suburl, { + method: "GET", + headers: { + "X-Application-Origin": process.env.TOA_APP_ID, + "X-TOA-Key": process.env.TOA_KEY, + }, + }); - return res.json(); + return res.json(); } export namespace TheOrangeAlliance { - export type SimpleTeam = { - /** Appears to be the same as team_number */ - team_key: string; - region_key: string; - league_key: string; - team_number: number; - team_name_short: string; - team_name_long: string; - robot_name: string; - last_active: string; - city: string; - state_prov: string; - zip_code: number; - country: string; - rookie_year: number; - website: string; - } + export type SimpleTeam = { + /** Appears to be the same as team_number */ + team_key: string; + region_key: string; + league_key: string; + team_number: number; + team_name_short: string; + team_name_long: string; + robot_name: string; + last_active: string; + city: string; + state_prov: string; + zip_code: number; + country: string; + rookie_year: number; + website: string; + }; - export async function getTeam(teamNumber: number): Promise { - const teams = await request(`/team/${teamNumber}`) as SimpleTeam[]; + export async function getTeam(teamNumber: number): Promise { + const teams = (await request(`/team/${teamNumber}`)) as SimpleTeam[]; - if (teams.length === 0) - return undefined; + if (teams.length === 0) return undefined; - const team = teams[0]; - if (!team?.team_number || !team?.team_name_short) - return undefined; - - return new Team(team.team_name_short, team.team_name_long, team.robot_name, team.team_number, League.FTC); - } -} \ No newline at end of file + const team = teams[0]; + if (!team?.team_number || !team?.team_name_short) return undefined; + + return new Team( + team.team_name_short, + team.team_name_long, + team.robot_name, + team.team_number, + League.FTC, + ); + } +} diff --git a/lib/Types.ts b/lib/Types.ts index 8157654f..e4d6f620 100644 --- a/lib/Types.ts +++ b/lib/Types.ts @@ -1,13 +1,19 @@ // A collection of all the standard types Gearbox uses import { - Account as NextAuthAccount, - Session as NextAuthSession, - User as NextAuthUser, + Account as NextAuthAccount, + Session as NextAuthSession, + User as NextAuthUser, } from "next-auth"; import { TheBlueAlliance } from "./TheBlueAlliance"; import { GameId, defaultGameId } from "./client/GameId"; import { Defense, FrcDrivetrain, Motors, SwerveLevel } from "./Enums"; -import { FormLayoutProps, FormLayout, Badge, PitStatsLayout, StatsLayout } from './Layout'; +import { + FormLayoutProps, + FormLayout, + Badge, + PitStatsLayout, + StatsLayout, +} from "./Layout"; import { ObjectId } from "bson"; /** @@ -16,522 +22,568 @@ import { ObjectId } from "bson"; * Derived orginally from NextAuth's Account struct */ export interface Account extends NextAuthAccount { - /** - * ID for the Mongo database - */ - _id: string; + /** + * ID for the Mongo database + */ + _id: string; } export interface Session extends NextAuthSession { - _id: string; + _id: string; } export class User implements NextAuthUser { - id: string = ""; - _id: string | undefined; - name: string | undefined; - email: string | undefined; - image: string; - admin: boolean; - slug: string | undefined; - teams: string[]; - owner: string[]; - slackId: string = ""; - xp: number = 10; - level: number = 1; - onboardingComplete: boolean = false; - resendContactId: string | undefined = undefined; - - constructor( - name: string | undefined, - email: string | undefined, - image: string = process.env.DEFAULT_IMAGE, - admin: boolean = false, - slug: string | undefined, - teams: string[] = [], - owner: string[] = [], - slackId: string = "", - xp: number = 10, - level: number = 1 - ) { - this.name = name; - this.email = email; - this.image = image; - this.admin = admin; - this.slug = slug; - this.teams = teams; - this.owner = owner; - this.slackId = slackId; - this.xp = xp; - } + id: string = ""; + _id: string | undefined; + name: string | undefined; + email: string | undefined; + image: string; + admin: boolean; + slug: string | undefined; + teams: string[]; + owner: string[]; + slackId: string = ""; + xp: number = 10; + level: number = 1; + onboardingComplete: boolean = false; + resendContactId: string | undefined = undefined; + + constructor( + name: string | undefined, + email: string | undefined, + image: string = process.env.DEFAULT_IMAGE, + admin: boolean = false, + slug: string | undefined, + teams: string[] = [], + owner: string[] = [], + slackId: string = "", + xp: number = 10, + level: number = 1, + ) { + this.name = name; + this.email = email; + this.image = image; + this.admin = admin; + this.slug = slug; + this.teams = teams; + this.owner = owner; + this.slackId = slackId; + this.xp = xp; + } } export class FieldPos { - x: number = 0; - y: number = 0; - angle: number = 0; + x: number = 0; + y: number = 0; + angle: number = 0; - static Zero = new FieldPos(); + static Zero = new FieldPos(); } export class Team { - _id: ObjectId; - name: string; - slug: string | undefined; - tbaId: string | undefined; - number: number; - league: League = League.FRC; - - owners: string[]; - users: string[]; - scouters: string[]; - subjectiveScouters: string[]; - requests: string[]; - - seasons: string[]; - - /** - * ID of the WebhookHolder object - * @see WebhookHolder - */ - slackWebhook: string | undefined; - - constructor( - name: string, - slug: string | undefined, - tbaId: string | undefined, - number: number, - league: League = League.FRC, - owners: string[] = [], - users: string[] = [], - scouters: string[] = [], - subjectiveScouters: string[] = [], - requests: string[] = [], - seasons: string[] = [], - ) { - this._id = new ObjectId(); - this.name = name; - this.slug = slug; - this.tbaId = tbaId; - this.number = number; - this.league = league; - this.owners = owners; - this.users = users; - this.scouters = scouters; - this.subjectiveScouters = subjectiveScouters; - this.seasons = seasons; - this.requests = requests; - } + _id: ObjectId; + name: string; + slug: string | undefined; + tbaId: string | undefined; + number: number; + league: League = League.FRC; + + owners: string[]; + users: string[]; + scouters: string[]; + subjectiveScouters: string[]; + requests: string[]; + + seasons: string[]; + + /** + * ID of the WebhookHolder object + * @see WebhookHolder + */ + slackWebhook: string | undefined; + + constructor( + name: string, + slug: string | undefined, + tbaId: string | undefined, + number: number, + league: League = League.FRC, + owners: string[] = [], + users: string[] = [], + scouters: string[] = [], + subjectiveScouters: string[] = [], + requests: string[] = [], + seasons: string[] = [], + ) { + this._id = new ObjectId(); + this.name = name; + this.slug = slug; + this.tbaId = tbaId; + this.number = number; + this.league = league; + this.owners = owners; + this.users = users; + this.scouters = scouters; + this.subjectiveScouters = subjectiveScouters; + this.seasons = seasons; + this.requests = requests; + } } export abstract class QuantData { - [key: string]: any; + [key: string]: any; - Presented: boolean = true; + Presented: boolean = true; - AutoStart: FieldPos = FieldPos.Zero; + AutoStart: FieldPos = FieldPos.Zero; - Defense: Defense = Defense.None; + Defense: Defense = Defense.None; - drivetrain: FrcDrivetrain = FrcDrivetrain.Tank; + drivetrain: FrcDrivetrain = FrcDrivetrain.Tank; - comments: string = ""; + comments: string = ""; } export enum League { - FTC = "FTC", FRC = "FRC" + FTC = "FTC", + FRC = "FRC", } -export class Game { - name: string; - - year: number; - league: League; - - allianceSize: number - - quantDataType: new() => TQuantData; - pitDataType: new() => TPitData; - - pitReportLayout: FormLayout; - quantitativeReportLayout: FormLayout; - statsLayout: StatsLayout; - pitStatsLayout: PitStatsLayout; - - fieldImagePrefix: string; - coverImage: string - - getBadges: (pitData: Pitreport | undefined, quantitativeReports: Report[] | undefined, card: boolean) => Badge[]; - getAvgPoints: (quantitativeReports: Report[] | undefined) => number; - - /** - * @param name - * @param year - * @param league - * @param quantDataType - * @param pitDataType - * @param pitReportLayout will auto-populate fields from PitReportData (everything not unique to the game) - */ - constructor( - name: string, - year: number, - league: League, - quantDataType: new() => TQuantData, - pitDataType: new() => TPitData, - pitReportLayout: FormLayoutProps, - quantitativeReportLayout: FormLayoutProps, - statsLayout: StatsLayout, - pitStatsLayout: PitStatsLayout, - fieldImagePrefix: string, - coverImage: string, - getBadges: (pitData: Pitreport | undefined, quantitativeReports: Report[] | undefined, card: boolean) => Badge[], - getAvgPoints: (quantitativeReports: Report[] | undefined) => number - ) { - this.name = name; - this.year = year; - this.league = league; - this.allianceSize = league === League.FRC ? 3 : 2; - - this.quantDataType = quantDataType; - this.pitDataType = pitDataType; - - this.pitReportLayout = Game.mergePitLayoutWithBaseLayout(pitReportLayout, new pitDataType(), league); - this.quantitativeReportLayout = Game.mergeQuantitativeLayoutWithBaseLayout(league, quantitativeReportLayout, new quantDataType()); - this.statsLayout = Game.mergeStatsLayoutWithBaseLayout(statsLayout); - this.pitStatsLayout = Game.mergePitStatsLayoutWithBaseLayout(pitStatsLayout); - - this.fieldImagePrefix = fieldImagePrefix; - this.coverImage = coverImage; - - this.getBadges = getBadges; - this.getAvgPoints = getAvgPoints; - } - - private static mergePitLayoutWithBaseLayout(layout: FormLayoutProps, exampleData: TData, league: League) { - const finalLayout: typeof layout = { - "Image": [{ key: "image", type: "image" }], - "Drivetrain": ["drivetrain"] - } - - if (league === League.FRC) - finalLayout["Drivetrain"].push("motorType", "swerveLevel"); - - for (const [header, keys] of Object.entries(layout)) { - finalLayout[header] = keys; - } - - finalLayout["Comments"] = ["comments"]; - - return FormLayout.fromProps(league, finalLayout, exampleData); - } - - private static mergeQuantitativeLayoutWithBaseLayout(league: League, layout: FormLayoutProps, exampleData: TData) { - const finalLayout: typeof layout = { - "Pre-Match": [{ key: "Presented", label: "Robot Present" }, { key: "AutoStartX", type: "startingPos" }], - }; - - // Copy over the rest of the layout - for (const [header, keys] of Object.entries(layout)) { - if (header === "Pre-Match") finalLayout["Pre-Match"].push(...keys); - else finalLayout[header] = keys; - } - - const keys = Object.keys(layout); - finalLayout[keys[keys.length - 1]]?.push("comments"); - - return FormLayout.fromProps(league, finalLayout, exampleData); - } - - private static mergeStatsLayoutWithBaseLayout - (layout: StatsLayout): StatsLayout { - const finalSections: typeof layout.sections = { - "Positioning": [{label: "Avg Start X", key: "AutoStartX"}, {label: "Avg Start Y", key: "AutoStartY"}, - {label: "Avg Start Angle (Deg)", key: "AutoStartAngle"}], - } - - for (const [header, stats] of Object.entries(layout.sections)) { - finalSections[header] = stats; - } - - return { sections: finalSections, getGraphDots: layout.getGraphDots }; - } - - private static mergePitStatsLayoutWithBaseLayout - (layout: PitStatsLayout) { - const finalLayout: typeof layout = { - overallSlideStats: [], - individualSlideStats: [], - robotCapabilities: [ - { key: "drivetrain", label: "Drivetrain" } - ], - graphStat: { - key: "AutoStartX", - label: "Avg Start X", - } - }; - - for (const [key, value] of Object.entries(layout)) { - if (!Array.isArray(value)) { - finalLayout[key] = value; - } else { - (finalLayout[key] as []).push(...value as []); - } - } - - return finalLayout; - } - - public createQuantitativeFormData(): TQuantData { - return new this.quantDataType(); - } - - public createPitReportData(): TPitData { - return new this.pitDataType(); - } +export class Game< + TQuantData extends QuantData = QuantData, + TPitData extends PitReportData = PitReportData, +> { + name: string; + + year: number; + league: League; + + allianceSize: number; + + quantDataType: new () => TQuantData; + pitDataType: new () => TPitData; + + pitReportLayout: FormLayout; + quantitativeReportLayout: FormLayout; + statsLayout: StatsLayout; + pitStatsLayout: PitStatsLayout; + + fieldImagePrefix: string; + coverImage: string; + + getBadges: ( + pitData: Pitreport | undefined, + quantitativeReports: Report[] | undefined, + card: boolean, + ) => Badge[]; + getAvgPoints: ( + quantitativeReports: Report[] | undefined, + ) => number; + + /** + * @param name + * @param year + * @param league + * @param quantDataType + * @param pitDataType + * @param pitReportLayout will auto-populate fields from PitReportData (everything not unique to the game) + */ + constructor( + name: string, + year: number, + league: League, + quantDataType: new () => TQuantData, + pitDataType: new () => TPitData, + pitReportLayout: FormLayoutProps, + quantitativeReportLayout: FormLayoutProps, + statsLayout: StatsLayout, + pitStatsLayout: PitStatsLayout, + fieldImagePrefix: string, + coverImage: string, + getBadges: ( + pitData: Pitreport | undefined, + quantitativeReports: Report[] | undefined, + card: boolean, + ) => Badge[], + getAvgPoints: ( + quantitativeReports: Report[] | undefined, + ) => number, + ) { + this.name = name; + this.year = year; + this.league = league; + this.allianceSize = league === League.FRC ? 3 : 2; + + this.quantDataType = quantDataType; + this.pitDataType = pitDataType; + + this.pitReportLayout = Game.mergePitLayoutWithBaseLayout( + pitReportLayout, + new pitDataType(), + league, + ); + this.quantitativeReportLayout = Game.mergeQuantitativeLayoutWithBaseLayout( + league, + quantitativeReportLayout, + new quantDataType(), + ); + this.statsLayout = Game.mergeStatsLayoutWithBaseLayout(statsLayout); + this.pitStatsLayout = + Game.mergePitStatsLayoutWithBaseLayout(pitStatsLayout); + + this.fieldImagePrefix = fieldImagePrefix; + this.coverImage = coverImage; + + this.getBadges = getBadges; + this.getAvgPoints = getAvgPoints; + } + + private static mergePitLayoutWithBaseLayout( + layout: FormLayoutProps, + exampleData: TData, + league: League, + ) { + const finalLayout: typeof layout = { + Image: [{ key: "image", type: "image" }], + Drivetrain: ["drivetrain"], + }; + + if (league === League.FRC) + finalLayout["Drivetrain"].push("motorType", "swerveLevel"); + + for (const [header, keys] of Object.entries(layout)) { + finalLayout[header] = keys; + } + + finalLayout["Comments"] = ["comments"]; + + return FormLayout.fromProps(league, finalLayout, exampleData); + } + + private static mergeQuantitativeLayoutWithBaseLayout( + league: League, + layout: FormLayoutProps, + exampleData: TData, + ) { + const finalLayout: typeof layout = { + "Pre-Match": [ + { key: "Presented", label: "Robot Present" }, + { key: "AutoStartX", type: "startingPos" }, + ], + }; + + // Copy over the rest of the layout + for (const [header, keys] of Object.entries(layout)) { + if (header === "Pre-Match") finalLayout["Pre-Match"].push(...keys); + else finalLayout[header] = keys; + } + + const keys = Object.keys(layout); + finalLayout[keys[keys.length - 1]]?.push("comments"); + + return FormLayout.fromProps(league, finalLayout, exampleData); + } + + private static mergeStatsLayoutWithBaseLayout< + TPitData extends PitReportData, + TQuantData extends QuantData, + >( + layout: StatsLayout, + ): StatsLayout { + const finalSections: typeof layout.sections = { + Positioning: [ + { label: "Avg Start X", key: "AutoStartX" }, + { label: "Avg Start Y", key: "AutoStartY" }, + { label: "Avg Start Angle (Deg)", key: "AutoStartAngle" }, + ], + }; + + for (const [header, stats] of Object.entries(layout.sections)) { + finalSections[header] = stats; + } + + return { sections: finalSections, getGraphDots: layout.getGraphDots }; + } + + private static mergePitStatsLayoutWithBaseLayout< + TPitData extends PitReportData, + TQuantData extends QuantData, + >(layout: PitStatsLayout) { + const finalLayout: typeof layout = { + overallSlideStats: [], + individualSlideStats: [], + robotCapabilities: [{ key: "drivetrain", label: "Drivetrain" }], + graphStat: { + key: "AutoStartX", + label: "Avg Start X", + }, + }; + + for (const [key, value] of Object.entries(layout)) { + if (!Array.isArray(value)) { + finalLayout[key] = value; + } else { + (finalLayout[key] as []).push(...(value as [])); + } + } + + return finalLayout; + } + + public createQuantitativeFormData(): TQuantData { + return new this.quantDataType(); + } + + public createPitReportData(): TPitData { + return new this.pitDataType(); + } } export class Season { - _id: string | undefined; - name: string; - slug: string | undefined; - - gameId: GameId; - - year: number; - - competitions: string[]; - - constructor( - name: string, - slug: string | undefined, - year: number, - gameId: GameId = GameId.Crescendo, - competitions: string[] = [] - ) { - this.name = name; - this.slug = slug; - this.year = year; - this.competitions = competitions; - this.gameId = gameId; - } + _id: string | undefined; + name: string; + slug: string | undefined; + + gameId: GameId; + + year: number; + + competitions: string[]; + + constructor( + name: string, + slug: string | undefined, + year: number, + gameId: GameId = GameId.Crescendo, + competitions: string[] = [], + ) { + this.name = name; + this.slug = slug; + this.year = year; + this.competitions = competitions; + this.gameId = gameId; + } } export abstract class PitReportData { - [key: string]: any; + [key: string]: any; - image: string = "/robot.jpg"; - drivetrain: FrcDrivetrain = FrcDrivetrain.Tank; - motorType: Motors = Motors.Talons; - swerveLevel: SwerveLevel = SwerveLevel.None; - comments: string = ""; + image: string = "/robot.jpg"; + drivetrain: FrcDrivetrain = FrcDrivetrain.Tank; + motorType: Motors = Motors.Talons; + swerveLevel: SwerveLevel = SwerveLevel.None; + comments: string = ""; } export class Pitreport { - _id: string | undefined; + _id: string | undefined; - teamNumber: number; + teamNumber: number; - submitted: boolean = false; - submitter: string | undefined; + submitted: boolean = false; + submitter: string | undefined; - data: TFormData | undefined; + data: TFormData | undefined; - constructor(teamNumber: number, data: TFormData) { - this.teamNumber = teamNumber; - this.data = data; - } + constructor(teamNumber: number, data: TFormData) { + this.teamNumber = teamNumber; + this.data = data; + } } export class Competition { - _id: string | undefined; - name: string; - slug: string | undefined; - tbaId: string | undefined; - - gameId: GameId = GameId.Crescendo; - - publicData: boolean; - - start: number; - end: number; - - pitReports: string[]; - matches: string[]; - - picklist: string; - - constructor( - name: string, - slug: string | undefined, - tbaId: string | undefined, - start: number, - end: number, - pitReports: string[] = [], - matches: string[] = [], - picklist: string = "", - publicData = false, - gameId: GameId | undefined = undefined - ) { - this.name = name; - this.slug = slug; - this.tbaId = tbaId; - this.start = start; - this.end = end; - this.pitReports = pitReports; - this.matches = matches; - this.picklist = picklist; - this.publicData = publicData; - this.gameId = gameId ?? defaultGameId; - } + _id: string | undefined; + name: string; + slug: string | undefined; + tbaId: string | undefined; + + gameId: GameId = GameId.Crescendo; + + publicData: boolean; + + start: number; + end: number; + + pitReports: string[]; + matches: string[]; + + picklist: string; + + constructor( + name: string, + slug: string | undefined, + tbaId: string | undefined, + start: number, + end: number, + pitReports: string[] = [], + matches: string[] = [], + picklist: string = "", + publicData = false, + gameId: GameId | undefined = undefined, + ) { + this.name = name; + this.slug = slug; + this.tbaId = tbaId; + this.start = start; + this.end = end; + this.pitReports = pitReports; + this.matches = matches; + this.picklist = picklist; + this.publicData = publicData; + this.gameId = gameId ?? defaultGameId; + } } export enum AllianceColor { - Red = "Red", - Blue = "Blue", + Red = "Red", + Blue = "Blue", } export type Alliance = number[]; export enum MatchType { - Qualifying = "Qualifying", - Quarterfinals = "Quarterfinals", - Semifinals = "Semifinals", - Finals = "Finals", + Qualifying = "Qualifying", + Quarterfinals = "Quarterfinals", + Semifinals = "Semifinals", + Finals = "Finals", } // add more fields export class Match { - _id: string | undefined; - slug: string | undefined; - tbaId: string | undefined; - - type: MatchType; - number: number; - - blueAlliance: Alliance; - redAlliance: Alliance; - - time: number; // time the match begins - reports: string[]; - - subjectiveScouter: string | undefined; - subjectiveReports: string[] = []; - subjectiveReportsCheckInTimestamps: { [userId: string]: string } = {}; - assignedSubjectiveScouterHasSubmitted: boolean = false; - - constructor( - number: number, - slug: string | undefined, - tbaId: string | undefined, - time: number, - type: MatchType, - blueAlliance: Alliance, - redAlliance: Alliance, - reports: string[] = [], - ) { - this.number = number; - this.tbaId = tbaId; - this.time = time; - this.type = type; - - this.blueAlliance = blueAlliance; - this.redAlliance = redAlliance; - this.reports = reports; - } + _id: string | undefined; + slug: string | undefined; + tbaId: string | undefined; + + type: MatchType; + number: number; + + blueAlliance: Alliance; + redAlliance: Alliance; + + time: number; // time the match begins + reports: string[]; + + subjectiveScouter: string | undefined; + subjectiveReports: string[] = []; + subjectiveReportsCheckInTimestamps: { [userId: string]: string } = {}; + assignedSubjectiveScouterHasSubmitted: boolean = false; + + constructor( + number: number, + slug: string | undefined, + tbaId: string | undefined, + time: number, + type: MatchType, + blueAlliance: Alliance, + redAlliance: Alliance, + reports: string[] = [], + ) { + this.number = number; + this.tbaId = tbaId; + this.time = time; + this.type = type; + + this.blueAlliance = blueAlliance; + this.redAlliance = redAlliance; + this.reports = reports; + } } -export class Report{ - _id: string | undefined; - - timestamp: number | undefined; // time it was initially submitted - user: string | undefined; // id of user assigned to report - submitter: string | undefined; // id of user who submitted the report - - color: AllianceColor; - robotNumber: number; // number of robot to be reported - match: string; // id of match - - submitted: boolean = false; - data: TFormData; - - checkInTimestamp: string | undefined; - - constructor( - user: string | undefined, - data: TFormData, - robotNumber: number, - color: AllianceColor, - match: string, - timestamp: number = 0, - checkInTimestamp: string | undefined = undefined - ) { - this.timestamp = timestamp; - this.user = user; - this.data = data; - this.robotNumber = robotNumber; - this.match = match; - this.color = color; - this.checkInTimestamp = checkInTimestamp; - } +export class Report { + _id: string | undefined; + + timestamp: number | undefined; // time it was initially submitted + user: string | undefined; // id of user assigned to report + submitter: string | undefined; // id of user who submitted the report + + color: AllianceColor; + robotNumber: number; // number of robot to be reported + match: string; // id of match + + submitted: boolean = false; + data: TFormData; + + checkInTimestamp: string | undefined; + + constructor( + user: string | undefined, + data: TFormData, + robotNumber: number, + color: AllianceColor, + match: string, + timestamp: number = 0, + checkInTimestamp: string | undefined = undefined, + ) { + this.timestamp = timestamp; + this.user = user; + this.data = data; + this.robotNumber = robotNumber; + this.match = match; + this.color = color; + this.checkInTimestamp = checkInTimestamp; + } } export enum SubjectiveReportSubmissionType { - ByAssignedScouter = "ByAssignedScouter", - BySubjectiveScouter = "BySubjectiveScouter", - ByNonSubjectiveScouter = "ByNonSubjectiveScouter", - NotSubmitted = "NotSubmitted", + ByAssignedScouter = "ByAssignedScouter", + BySubjectiveScouter = "BySubjectiveScouter", + ByNonSubjectiveScouter = "ByNonSubjectiveScouter", + NotSubmitted = "NotSubmitted", } export class SubjectiveReport { - _id: string | undefined; - submitter: string | undefined; - submitted: SubjectiveReportSubmissionType = SubjectiveReportSubmissionType.NotSubmitted; + _id: string | undefined; + submitter: string | undefined; + submitted: SubjectiveReportSubmissionType = + SubjectiveReportSubmissionType.NotSubmitted; - match: string; // id of match - matchNumber: number; + match: string; // id of match + matchNumber: number; - wholeMatchComment: string = ""; - robotComments: { [key: number]: string } = {}; + wholeMatchComment: string = ""; + robotComments: { [key: number]: string } = {}; - constructor(match: string, matchNumber: number) { - this.match = match; - this.matchNumber = matchNumber; - } + constructor(match: string, matchNumber: number) { + this.match = match; + this.matchNumber = matchNumber; + } } export interface CompetitonNameIdPair { - name: string; - tbaId: string; + name: string; + tbaId: string; } export interface EventData { - comp: Competition; - firstRanking: TheBlueAlliance.SimpleRank[]; - oprRanking: TheBlueAlliance.OprRanking; + comp: Competition; + firstRanking: TheBlueAlliance.SimpleRank[]; + oprRanking: TheBlueAlliance.OprRanking; } export type DbPicklist = { - _id: string; - picklists: { - [name: string]: number[]; - }; -} + _id: string; + picklists: { + [name: string]: number[]; + }; +}; /** * Taken from https://stackoverflow.com/a/62502740/22099600 */ -export type OmitCallSignature = - { [K in keyof T]: T[K] } & - (T extends new (...args: infer R) => infer S ? new (...args: R) => S : unknown) +export type OmitCallSignature = { [K in keyof T]: T[K] } & (T extends new ( + ...args: infer R +) => infer S + ? new (...args: R) => S + : unknown); /** * DO NOT GIVE TO CLIENTS! */ export class WebhookHolder { - _id: ObjectId; - url: string; - - constructor(url: string) { - this._id = new ObjectId(); - this.url = url; - } -} \ No newline at end of file + _id: ObjectId; + url: string; + + constructor(url: string) { + this._id = new ObjectId(); + this.url = url; + } +} diff --git a/lib/UrlResolver.ts b/lib/UrlResolver.ts index 0eb7d6ee..ab254422 100644 --- a/lib/UrlResolver.ts +++ b/lib/UrlResolver.ts @@ -13,10 +13,10 @@ const gdb = getDatabase(); * Structure to hold the final, resolved URL Data */ export interface ResolvedUrlData { - team: Team | undefined; - season: Season | undefined; - competition: Competition | undefined; - report: Report | undefined; + team: Team | undefined; + season: Season | undefined; + competition: Competition | undefined; + report: Report | undefined; } /** @@ -30,24 +30,24 @@ export interface ResolvedUrlData { * @returns - The same object, but with `_id` set as a string */ export function SerializeDatabaseObject(object: any): any { - if (!object) { - return null; - } - if (object?._id) { - object._id = object?._id.toString(); - } - if (object?.ownerTeam) { - object.ownerTeam = object.ownerTeam.toString(); - } - if (object?.ownerComp) { - object.ownerComp = object.ownerComp.toString(); - } + if (!object) { + return null; + } + if (object?._id) { + object._id = object?._id.toString(); + } + if (object?.ownerTeam) { + object.ownerTeam = object.ownerTeam.toString(); + } + if (object?.ownerComp) { + object.ownerComp = object.ownerComp.toString(); + } - return object; + return object; } export function SerializeDatabaseObjects(objectArray: any[]): any[] { - return objectArray.map((obj) => SerializeDatabaseObject(obj)); + return objectArray.map((obj) => SerializeDatabaseObject(obj)); } /** @@ -62,62 +62,71 @@ export function SerializeDatabaseObjects(objectArray: any[]): any[] { */ export default async function UrlResolver( - context: GetServerSidePropsContext, - depthToCheckValidity: number + context: GetServerSidePropsContext, + depthToCheckValidity: number, ): Promise { - const db = await gdb; + const db = await gdb; - // split the url into the specific parts - const splittedUrl = context.resolvedUrl.split("/"); - splittedUrl.shift(); + // split the url into the specific parts + const splittedUrl = context.resolvedUrl.split("/"); + splittedUrl.shift(); - // each split cooresponds to a different slug for a specific object - const teamSlug = splittedUrl[0]; + // each split cooresponds to a different slug for a specific object + const teamSlug = splittedUrl[0]; - const seasonSlug = splittedUrl[1]; - const competitionSlug = splittedUrl[2]; - // very hacky- fix this - const reportId = splittedUrl[3]?.length > 5 ? splittedUrl[3] : undefined; + const seasonSlug = splittedUrl[1]; + const competitionSlug = splittedUrl[2]; + // very hacky- fix this + const reportId = splittedUrl[3]?.length > 5 ? splittedUrl[3] : undefined; - try { - const promises = [ - db.findObject(CollectionId.Teams, { slug: teamSlug }), - seasonSlug - ? db.findObject(CollectionId.Seasons, { slug: seasonSlug }) - : null, - competitionSlug - ? db.findObject(CollectionId.Competitions, { - slug: competitionSlug, - }) - : null, - reportId - ? db.findObject(CollectionId.Reports, { - _id: new ObjectId(reportId), - }) - : null, - ]; + try { + const promises = [ + db.findObject(CollectionId.Teams, { slug: teamSlug }), + seasonSlug + ? db.findObject(CollectionId.Seasons, { slug: seasonSlug }) + : null, + competitionSlug + ? db.findObject(CollectionId.Competitions, { + slug: competitionSlug, + }) + : null, + reportId + ? db.findObject(CollectionId.Reports, { + _id: new ObjectId(reportId), + }) + : null, + ]; - await Promise.all(promises); + await Promise.all(promises); - for (const promise of promises.slice(0, depthToCheckValidity)) { - // If the value is just null, we didn't fetch the object in the first place - if (promise instanceof Promise && (await promise === null || await promise === undefined)) { - return createRedirect("/error", { message: `Page Not Found: ${context.resolvedUrl}`, code: 404 }); - } - } + for (const promise of promises.slice(0, depthToCheckValidity)) { + // If the value is just null, we didn't fetch the object in the first place + if ( + promise instanceof Promise && + ((await promise) === null || (await promise) === undefined) + ) { + return createRedirect("/error", { + message: `Page Not Found: ${context.resolvedUrl}`, + code: 404, + }); + } + } - // find these slugs, and convert them to a JSON safe condition - // if they dont exist, simply return nothing - const data: ResolvedUrlData = { - team: SerializeDatabaseObject(await promises[0]), - season: SerializeDatabaseObject(await promises[1]), - competition: SerializeDatabaseObject(await promises[2]), - report: SerializeDatabaseObject(await promises[3]), - }; - return data; - } catch (error) { - console.error(error); - - return createRedirect("/error", { message: `Internal Server Error: ${error}`, code: 500 }); - } + // find these slugs, and convert them to a JSON safe condition + // if they dont exist, simply return nothing + const data: ResolvedUrlData = { + team: SerializeDatabaseObject(await promises[0]), + season: SerializeDatabaseObject(await promises[1]), + competition: SerializeDatabaseObject(await promises[2]), + report: SerializeDatabaseObject(await promises[3]), + }; + return data; + } catch (error) { + console.error(error); + + return createRedirect("/error", { + message: `Internal Server Error: ${error}`, + code: 500, + }); + } } diff --git a/lib/Utils.ts b/lib/Utils.ts index b4404aac..cd87a2df 100644 --- a/lib/Utils.ts +++ b/lib/Utils.ts @@ -22,39 +22,51 @@ import { User } from "./Types"; * @returns - A Unique SLUG */ export async function GenerateSlug( - db: DbInterface, - collection: CollectionId, - name: string, - index: number = 0, + db: DbInterface, + collection: CollectionId, + name: string, + index: number = 0, ): Promise { - let finalName; - if (index === 0) { - finalName = removeWhitespaceAndMakeLowerCase(name); - } else { - finalName = name + index.toString(); - } + let finalName; + if (index === 0) { + finalName = removeWhitespaceAndMakeLowerCase(name); + } else { + finalName = name + index.toString(); + } - const result = await db.findObject(collection, { slug: finalName }); - if (result) { - return GenerateSlug(db, collection, index === 0 ? finalName : name, index + 1); - } + const result = await db.findObject(collection, { slug: finalName }); + if (result) { + return GenerateSlug( + db, + collection, + index === 0 ? finalName : name, + index + 1, + ); + } - return finalName; + return finalName; } -export function createRedirect(destination: string, query: Record = {}): { redirect: Redirect } { - return { - redirect: { - destination: `${destination}?${Object.keys(query).map((key) => `${key}=${query[key]}`).join("&")}`, - permanent: false, - } - }; +export function createRedirect( + destination: string, + query: Record = {}, +): { redirect: Redirect } { + return { + redirect: { + destination: `${destination}?${Object.keys(query) + .map((key) => `${key}=${query[key]}`) + .join("&")}`, + permanent: false, + }, + }; } export function isDeveloper(email: string | undefined) { - return (JSON.parse(process.env.DEVELOPER_EMAILS) as string[]).includes(email ?? ""); + return (JSON.parse(process.env.DEVELOPER_EMAILS) as string[]).includes( + email ?? "", + ); } export function mentionUserInSlack(user: User) { - return user?.slackId ? `<@${user!.slackId}>` : user!.name; -} \ No newline at end of file + return user?.slackId ? `<@${user!.slackId}>` : user!.name; +} diff --git a/lib/Xp.ts b/lib/Xp.ts index 440d4749..5f6b8139 100644 --- a/lib/Xp.ts +++ b/lib/Xp.ts @@ -1,30 +1,30 @@ const XP_PER_LEVEL = 20; export function xpToLevel(xp: number) { - return Math.floor(xp / XP_PER_LEVEL); + return Math.floor(xp / XP_PER_LEVEL); } export function levelToXp(level: number) { - return level * XP_PER_LEVEL; + return level * XP_PER_LEVEL; } export function xpRequiredForNextLevel(level: number) { - return levelToXp(level + 1); + return levelToXp(level + 1); } export function levelToClassName(level: number | undefined) { - if (!level) { - return "border-neutral"; - } - let className = "border-"; + if (!level) { + return "border-neutral"; + } + let className = "border-"; - if (level >= 20) className += "primary"; - else if (level >= 15) className += "secondary"; - else if (level >= 10) className += "accent"; - else if (level >= 5) className += "error"; - else if (level >= 3) className += "warning"; - else if (level >= 1) className += "success"; - else className += "info"; + if (level >= 20) className += "primary"; + else if (level >= 15) className += "secondary"; + else if (level >= 10) className += "accent"; + else if (level >= 5) className += "error"; + else if (level >= 3) className += "warning"; + else if (level >= 1) className += "success"; + else className += "info"; - return className; + return className; } diff --git a/lib/api/AccessLevels.ts b/lib/api/AccessLevels.ts index 0ec9b23f..376978e0 100644 --- a/lib/api/AccessLevels.ts +++ b/lib/api/AccessLevels.ts @@ -1,261 +1,428 @@ import { NextApiRequest } from "next"; -import { Competition, DbPicklist, Match, Pitreport, Report, Season, SubjectiveReport, Team, User } from "../Types"; +import { + Competition, + DbPicklist, + Match, + Pitreport, + Report, + Season, + SubjectiveReport, + Team, + User, +} from "../Types"; import ApiLib from "./ApiLib"; import ApiDependencies from "./ApiDependencies"; import CollectionId from "../client/CollectionId"; import { ObjectId } from "bson"; -import { getCompFromMatch, getCompFromPitReport, getTeamFromComp, getTeamFromPicklist, getTeamFromReport, getTeamFromSeason, getTeamFromSubjectiveReport } from "./ApiUtils"; +import { + getCompFromMatch, + getCompFromPitReport, + getTeamFromComp, + getTeamFromPicklist, + getTeamFromReport, + getTeamFromSeason, + getTeamFromSubjectiveReport, +} from "./ApiUtils"; import DbInterface from "../client/dbinterfaces/DbInterface"; import { isDeveloper } from "../Utils"; -type UserAndDb = { userPromise: Promise, db: Promise }; - -namespace AccessLevels { - export function AlwaysAuthorized() { - return Promise.resolve({ authorized: true, authData: undefined }); - } - - export async function IfSignedIn(req: NextApiRequest, res: ApiLib.ApiResponse, { userPromise }: UserAndDb) { - return { authorized: (await userPromise) != undefined, authData: undefined }; - } - - export async function IfDeveloper(req: NextApiRequest, res: ApiLib.ApiResponse, { userPromise }: UserAndDb) { - const user = await userPromise; - return { authorized: isDeveloper(user?.email), authData: undefined }; - } - - export async function IfOnTeam(req: NextApiRequest, res: ApiLib.ApiResponse, { userPromise, db }: UserAndDb, teamId: string) { - const user = await userPromise; - if (!user) { - return { authorized: false, authData: undefined }; - } - - const team = await (await db).findObjectById(CollectionId.Teams, new ObjectId(teamId)); - if (!team) { - return { authorized: false, authData: undefined }; - } - - return { authorized: team.users.includes(user._id?.toString()!), authData: team }; - } - - export async function IfTeamOwner(req: NextApiRequest, res: ApiLib.ApiResponse, { userPromise, db }: UserAndDb, teamId: string) { - const user = await userPromise; - if (!user) { - return { authorized: false, authData: undefined }; - } - - const team = await (await db).findObjectById(CollectionId.Teams, new ObjectId(teamId)); - if (!team) { - return { authorized: false, authData: undefined }; - } - - return { authorized: team.owners.includes(user._id?.toString()!), authData: team }; - } - - export async function IfCompOwner(req: NextApiRequest, res: ApiLib.ApiResponse, { userPromise, db }: UserAndDb, compId: string) { - const user = await userPromise; - if (!user) { - return { authorized: false, authData: undefined }; - } - - const comp = await (await db).findObjectById(CollectionId.Competitions, new ObjectId(compId)); - if (!comp) { - return { authorized: false, authData: undefined }; - } - - const team = await getTeamFromComp(await db, comp); - if (!team) { - return { authorized: false, authData: undefined }; - } - - return { authorized: team.owners.includes(user._id?.toString()!), authData: { team, comp } }; - } - - export async function IfSeasonOwner(req: NextApiRequest, res: ApiLib.ApiResponse, { userPromise, db }: UserAndDb, seasonId: string) { - const user = await userPromise; - if (!user) { - return { authorized: false, authData: undefined }; - } - - const season = await (await db).findObjectById(CollectionId.Seasons, new ObjectId(seasonId)); - if (!season) { - return { authorized: false, authData: undefined }; - } - - const team = await getTeamFromSeason(await db, season); - if (!team) { - return { authorized: false, authData: undefined }; - } - - return { authorized: team.owners.includes(user._id?.toString()!), authData: { team, season } }; - } - - export async function IfMatchOwner(req: NextApiRequest, res: ApiLib.ApiResponse, { userPromise, db }: UserAndDb, matchId: string) { - const user = await userPromise; - if (!user) { - return { authorized: false, authData: undefined }; - } - - const match = await (await db).findObjectById(CollectionId.Matches, new ObjectId(matchId)); - if (!match) { - return { authorized: false, authData: undefined }; - } - - const comp = await getCompFromMatch(await db, match); - if (!comp) { - return { authorized: false, authData: undefined }; - } - - const team = await getTeamFromComp(await db, comp); - if (!team) { - return { authorized: false, authData: undefined }; - } - - return { authorized: team.owners.includes(user._id?.toString()!), authData: { team, comp, match } }; - } - - export async function IfReportOwner(req: NextApiRequest, res: ApiLib.ApiResponse, { userPromise, db }: UserAndDb, reportId: string) { - const user = await userPromise; - if (!user) { - return { authorized: false, authData: undefined }; - } - - const report = await (await db).findObjectById(CollectionId.Reports, new ObjectId(reportId)); - if (!report) { - return { authorized: false, authData: undefined }; - } - - const team = await getTeamFromReport(await db, report); - if (!team) { - return { authorized: false, authData: undefined }; - } - - return { authorized: team.owners.includes(user._id?.toString()!), authData: { team, report } }; - } - - export async function IfOnTeamThatOwnsComp(req: NextApiRequest, res: ApiLib.ApiResponse, { userPromise, db }: UserAndDb, compId: string) { - const user = await userPromise; - if (!user) { - return { authorized: false, authData: undefined }; - } - - const comp = await (await db).findObjectById(CollectionId.Competitions, new ObjectId(compId)); - if (!comp) { - return { authorized: false, authData: undefined }; - } - - const team = await getTeamFromComp(await db, comp); - if (!team) { - return { authorized: false, authData: undefined }; - } - - return { authorized: team.users.includes(user._id?.toString()!), authData: { team, comp } }; - } - - export async function IfOnTeamThatOwnsMatch(req: NextApiRequest, res: ApiLib.ApiResponse, { userPromise, db }: UserAndDb, matchId: string) { - const user = await userPromise; - if (!user) { - return { authorized: false, authData: undefined }; - } - - const match = await (await db).findObjectById(CollectionId.Matches, new ObjectId(matchId)); - if (!match) { - return { authorized: false, authData: undefined }; - } - - const comp = await getCompFromMatch(await db, match); - if (!comp) { - return { authorized: false, authData: undefined }; - } - - const team = await getTeamFromComp(await db, comp); - if (!team) { - return { authorized: false, authData: undefined }; - } - - return { authorized: team.users.includes(user._id?.toString()!), authData: { team, comp, match } }; - } - - export async function IfOnTeamThatOwnsPitReport(req: NextApiRequest, res: ApiLib.ApiResponse, { userPromise, db }: UserAndDb, pitReportId: string) { - const user = await userPromise; - if (!user) { - return { authorized: false, authData: undefined }; - } - - const pitReport = await (await db).findObjectById(CollectionId.PitReports, new ObjectId(pitReportId)); - if (!pitReport) { - return { authorized: false, authData: undefined }; - } - - const comp = await getCompFromPitReport(await db, pitReport); - if (!comp) { - return { authorized: false, authData: undefined }; - } - - const team = await getTeamFromComp(await db, comp); - if (!team) { - return { authorized: false, authData: undefined }; - } - - return { authorized: team?.users.includes(user._id?.toString()!), authData: { team, comp, pitReport } }; - } - - export async function IfOnTeamThatOwnsReport(req: NextApiRequest, res: ApiLib.ApiResponse, { userPromise, db }: UserAndDb, reportId: string) { - const user = await userPromise; - if (!user) { - return { authorized: false, authData: undefined }; - } - - const report = await (await db).findObjectById(CollectionId.Reports, new ObjectId(reportId)); - if (!report) { - return { authorized: false, authData: undefined }; - } - - const team = await getTeamFromReport(await db, report); - if (!team) { - return { authorized: false, authData: undefined }; - } - - return { authorized: team.users.includes(user._id?.toString()!), authData: { team, report } }; - } - - export async function IfOnTeamThatOwnsSubjectiveReport(req: NextApiRequest, res: ApiLib.ApiResponse, { userPromise, db }: UserAndDb, reportId: string) { - const user = await userPromise; - if (!user) { - return { authorized: false, authData: undefined }; - } - - const report = await (await db).findObjectById(CollectionId.SubjectiveReports, new ObjectId(reportId)); - if (!report) { - return { authorized: false, authData: undefined }; - } - - const team = await getTeamFromSubjectiveReport(await db, report); - if (!team) { - return { authorized: false, authData: undefined }; - } - - return { authorized: team.users.includes(user._id?.toString()!), authData: { team, report } }; - } - - export async function IfOnTeamThatOwnsPicklist(req: NextApiRequest, res: ApiLib.ApiResponse, { userPromise, db }: UserAndDb, picklistId: string) { - const user = await userPromise; - if (!user) { - return { authorized: false, authData: undefined }; - } - - const picklist = await (await db).findObjectById(CollectionId.Picklists, new ObjectId(picklistId)); - if (!picklist) { - return { authorized: false, authData: undefined }; - } - - const team = await getTeamFromPicklist(await db, picklist); - if (!team) { - return { authorized: false, authData: undefined }; - } - - return { authorized: team.users.includes(user._id?.toString()!), authData: { team, picklist } }; - } +type UserAndDb = { + userPromise: Promise; + db: Promise; +}; + +namespace AccessLevels { + export function AlwaysAuthorized() { + return Promise.resolve({ authorized: true, authData: undefined }); + } + + export async function IfSignedIn( + req: NextApiRequest, + res: ApiLib.ApiResponse, + { userPromise }: UserAndDb, + ) { + return { + authorized: (await userPromise) != undefined, + authData: undefined, + }; + } + + export async function IfDeveloper( + req: NextApiRequest, + res: ApiLib.ApiResponse, + { userPromise }: UserAndDb, + ) { + const user = await userPromise; + return { authorized: isDeveloper(user?.email), authData: undefined }; + } + + export async function IfOnTeam( + req: NextApiRequest, + res: ApiLib.ApiResponse, + { userPromise, db }: UserAndDb, + teamId: string, + ) { + const user = await userPromise; + if (!user) { + return { authorized: false, authData: undefined }; + } + + const team = await ( + await db + ).findObjectById(CollectionId.Teams, new ObjectId(teamId)); + if (!team) { + return { authorized: false, authData: undefined }; + } + + return { + authorized: team.users.includes(user._id?.toString()!), + authData: team, + }; + } + + export async function IfTeamOwner( + req: NextApiRequest, + res: ApiLib.ApiResponse, + { userPromise, db }: UserAndDb, + teamId: string, + ) { + const user = await userPromise; + if (!user) { + return { authorized: false, authData: undefined }; + } + + const team = await ( + await db + ).findObjectById(CollectionId.Teams, new ObjectId(teamId)); + if (!team) { + return { authorized: false, authData: undefined }; + } + + return { + authorized: team.owners.includes(user._id?.toString()!), + authData: team, + }; + } + + export async function IfCompOwner( + req: NextApiRequest, + res: ApiLib.ApiResponse, + { userPromise, db }: UserAndDb, + compId: string, + ) { + const user = await userPromise; + if (!user) { + return { authorized: false, authData: undefined }; + } + + const comp = await ( + await db + ).findObjectById( + CollectionId.Competitions, + new ObjectId(compId), + ); + if (!comp) { + return { authorized: false, authData: undefined }; + } + + const team = await getTeamFromComp(await db, comp); + if (!team) { + return { authorized: false, authData: undefined }; + } + + return { + authorized: team.owners.includes(user._id?.toString()!), + authData: { team, comp }, + }; + } + + export async function IfSeasonOwner( + req: NextApiRequest, + res: ApiLib.ApiResponse, + { userPromise, db }: UserAndDb, + seasonId: string, + ) { + const user = await userPromise; + if (!user) { + return { authorized: false, authData: undefined }; + } + + const season = await ( + await db + ).findObjectById(CollectionId.Seasons, new ObjectId(seasonId)); + if (!season) { + return { authorized: false, authData: undefined }; + } + + const team = await getTeamFromSeason(await db, season); + if (!team) { + return { authorized: false, authData: undefined }; + } + + return { + authorized: team.owners.includes(user._id?.toString()!), + authData: { team, season }, + }; + } + + export async function IfMatchOwner( + req: NextApiRequest, + res: ApiLib.ApiResponse, + { userPromise, db }: UserAndDb, + matchId: string, + ) { + const user = await userPromise; + if (!user) { + return { authorized: false, authData: undefined }; + } + + const match = await ( + await db + ).findObjectById(CollectionId.Matches, new ObjectId(matchId)); + if (!match) { + return { authorized: false, authData: undefined }; + } + + const comp = await getCompFromMatch(await db, match); + if (!comp) { + return { authorized: false, authData: undefined }; + } + + const team = await getTeamFromComp(await db, comp); + if (!team) { + return { authorized: false, authData: undefined }; + } + + return { + authorized: team.owners.includes(user._id?.toString()!), + authData: { team, comp, match }, + }; + } + + export async function IfReportOwner( + req: NextApiRequest, + res: ApiLib.ApiResponse, + { userPromise, db }: UserAndDb, + reportId: string, + ) { + const user = await userPromise; + if (!user) { + return { authorized: false, authData: undefined }; + } + + const report = await ( + await db + ).findObjectById(CollectionId.Reports, new ObjectId(reportId)); + if (!report) { + return { authorized: false, authData: undefined }; + } + + const team = await getTeamFromReport(await db, report); + if (!team) { + return { authorized: false, authData: undefined }; + } + + return { + authorized: team.owners.includes(user._id?.toString()!), + authData: { team, report }, + }; + } + + export async function IfOnTeamThatOwnsComp( + req: NextApiRequest, + res: ApiLib.ApiResponse, + { userPromise, db }: UserAndDb, + compId: string, + ) { + const user = await userPromise; + if (!user) { + return { authorized: false, authData: undefined }; + } + + const comp = await ( + await db + ).findObjectById( + CollectionId.Competitions, + new ObjectId(compId), + ); + if (!comp) { + return { authorized: false, authData: undefined }; + } + + const team = await getTeamFromComp(await db, comp); + if (!team) { + return { authorized: false, authData: undefined }; + } + + return { + authorized: team.users.includes(user._id?.toString()!), + authData: { team, comp }, + }; + } + + export async function IfOnTeamThatOwnsMatch( + req: NextApiRequest, + res: ApiLib.ApiResponse, + { userPromise, db }: UserAndDb, + matchId: string, + ) { + const user = await userPromise; + if (!user) { + return { authorized: false, authData: undefined }; + } + + const match = await ( + await db + ).findObjectById(CollectionId.Matches, new ObjectId(matchId)); + if (!match) { + return { authorized: false, authData: undefined }; + } + + const comp = await getCompFromMatch(await db, match); + if (!comp) { + return { authorized: false, authData: undefined }; + } + + const team = await getTeamFromComp(await db, comp); + if (!team) { + return { authorized: false, authData: undefined }; + } + + return { + authorized: team.users.includes(user._id?.toString()!), + authData: { team, comp, match }, + }; + } + + export async function IfOnTeamThatOwnsPitReport( + req: NextApiRequest, + res: ApiLib.ApiResponse, + { userPromise, db }: UserAndDb, + pitReportId: string, + ) { + const user = await userPromise; + if (!user) { + return { authorized: false, authData: undefined }; + } + + const pitReport = await ( + await db + ).findObjectById( + CollectionId.PitReports, + new ObjectId(pitReportId), + ); + if (!pitReport) { + return { authorized: false, authData: undefined }; + } + + const comp = await getCompFromPitReport(await db, pitReport); + if (!comp) { + return { authorized: false, authData: undefined }; + } + + const team = await getTeamFromComp(await db, comp); + if (!team) { + return { authorized: false, authData: undefined }; + } + + return { + authorized: team?.users.includes(user._id?.toString()!), + authData: { team, comp, pitReport }, + }; + } + + export async function IfOnTeamThatOwnsReport( + req: NextApiRequest, + res: ApiLib.ApiResponse, + { userPromise, db }: UserAndDb, + reportId: string, + ) { + const user = await userPromise; + if (!user) { + return { authorized: false, authData: undefined }; + } + + const report = await ( + await db + ).findObjectById(CollectionId.Reports, new ObjectId(reportId)); + if (!report) { + return { authorized: false, authData: undefined }; + } + + const team = await getTeamFromReport(await db, report); + if (!team) { + return { authorized: false, authData: undefined }; + } + + return { + authorized: team.users.includes(user._id?.toString()!), + authData: { team, report }, + }; + } + + export async function IfOnTeamThatOwnsSubjectiveReport( + req: NextApiRequest, + res: ApiLib.ApiResponse, + { userPromise, db }: UserAndDb, + reportId: string, + ) { + const user = await userPromise; + if (!user) { + return { authorized: false, authData: undefined }; + } + + const report = await ( + await db + ).findObjectById( + CollectionId.SubjectiveReports, + new ObjectId(reportId), + ); + if (!report) { + return { authorized: false, authData: undefined }; + } + + const team = await getTeamFromSubjectiveReport(await db, report); + if (!team) { + return { authorized: false, authData: undefined }; + } + + return { + authorized: team.users.includes(user._id?.toString()!), + authData: { team, report }, + }; + } + + export async function IfOnTeamThatOwnsPicklist( + req: NextApiRequest, + res: ApiLib.ApiResponse, + { userPromise, db }: UserAndDb, + picklistId: string, + ) { + const user = await userPromise; + if (!user) { + return { authorized: false, authData: undefined }; + } + + const picklist = await ( + await db + ).findObjectById( + CollectionId.Picklists, + new ObjectId(picklistId), + ); + if (!picklist) { + return { authorized: false, authData: undefined }; + } + + const team = await getTeamFromPicklist(await db, picklist); + if (!team) { + return { authorized: false, authData: undefined }; + } + + return { + authorized: team.users.includes(user._id?.toString()!), + authData: { team, picklist }, + }; + } } -export default AccessLevels; \ No newline at end of file +export default AccessLevels; diff --git a/lib/api/ApiDependencies.ts b/lib/api/ApiDependencies.ts index 8be52c36..19df3402 100644 --- a/lib/api/ApiDependencies.ts +++ b/lib/api/ApiDependencies.ts @@ -5,11 +5,11 @@ import { ResendInterface } from "../ResendUtils"; import SlackClient, { SlackInterface } from "../SlackClient"; type ApiDependencies = { - db: Promise; - tba: TheBlueAlliance.Interface; - userPromise: Promise; - slackClient: SlackInterface; - resend: ResendInterface; -} + db: Promise; + tba: TheBlueAlliance.Interface; + userPromise: Promise; + slackClient: SlackInterface; + resend: ResendInterface; +}; -export default ApiDependencies; \ No newline at end of file +export default ApiDependencies; diff --git a/lib/api/ApiLib.ts b/lib/api/ApiLib.ts index 3a425590..79fe7875 100644 --- a/lib/api/ApiLib.ts +++ b/lib/api/ApiLib.ts @@ -6,221 +6,275 @@ import toast from "react-hot-toast"; * @tested_by tests/lib/api/ApiLib.test.ts */ namespace ApiLib { - export namespace Errors { - export type ErrorType = { error: string }; - - export class Error { - errorCode: number; - description: string; - route: string | undefined; - - constructor( - res: ApiResponse, - errorCode: number = 500, - description: string = "The server encountered an error while processing the request" - ) { - this.errorCode = errorCode; - this.description = description; - - res.error(errorCode, description); - } - - toString() { - return `${this.errorCode}: ${this.description}`; - } - - } - - export class NotFoundError extends Error { - constructor(res: ApiResponse, routeName: string) { - super(res, 404, `This API Route (/${routeName}) does not exist`); - } - } - - export class InvalidRequestError extends Error { - constructor(res: ApiResponse) { - super(res, 400, "Invalid Request"); - } - } - - export class UnauthorizedError extends Error { - constructor(res: ApiResponse) { - super(res, 403, "You are not authorized to execute this route"); - } - } - - export class InternalServerError extends Error { - constructor(res: ApiResponse) { - super(res, 500, "The server encountered an error while processing the request"); - } - } - } - - export class ApiResponse { - constructor(public innerRes: NextApiResponse) {} - - send(data: TSend | Errors.ErrorType) { - this.innerRes.send(data); - return this; - } - - status(code: number) { - this.innerRes.status(code); - return this; - } - - error(code: number, message: string) { - this.innerRes.status(code).send({ error: message }); - return this; - } - } - - export type Route, TReturn, TDependencies, TDataFetchedDuringAuth> = { - subUrl: string; - - (...args: TArgs): Promise; - - isAuthorized: (req: NextApiRequest, res: ApiResponse, deps: TDependencies, args: TArgs) => Promise<{ authorized: boolean, authData: TDataFetchedDuringAuth | undefined }>; - handler: (req: NextApiRequest, res: ApiResponse, deps: TDependencies, authData: TDataFetchedDuringAuth, args: TArgs) => Promise | any; - } - - export enum RequestMethod { - POST = "POST", - GET = "GET", - } - - export async function request( - subUrl: string, - body: any, - method: RequestMethod = RequestMethod.POST - ) { - const rawResponse = await fetch(process.env.NEXT_PUBLIC_API_URL + subUrl, { - method: method, - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }); - - // Null or undefined are sent as an empty string that we can't parse as JSON - const text = await rawResponse.text(); - const res = text.length ? JSON.parse(text) : undefined; - - if (res?.error) { - if (res.error === "Unauthorized") { - toast.error(`Unauthorized API request: ${subUrl}. If this is an error, please contact the developers.`); - } - throw new Error(`${subUrl}: ${res.error}`); - } - - return res; - } - - /** - * There's no easy one-liner to create a function with properties while maintaining typing, so I made this shortcut - */ - export function createRoute, TReturn, TDependencies, TFetchedDuringAuth>( - server: Omit>, "subUrl">, - clientHandler?: (...args: any) => Promise - ): Route { - return Object.assign(clientHandler ?? { subUrl: "newRoute" }, server) as any; - } - - export type Segment = { - [route: string]: Segment | Route; - } - - export abstract class ApiTemplate { - [route: string]: any; - - private initSegment(segment: Segment, subUrl: string) { - for (const [key, value] of Object.entries(segment)) { - if (typeof value === "function") { - value.subUrl = subUrl + "/" + key; - } else if ((value as unknown as Route).subUrl === "newRoute") { - const route = value as unknown as Route; - route.subUrl = subUrl + "/" + key; - - segment[key] = createRoute(route, (...args: any[]) => request(route.subUrl, args)); - } else if (typeof value === "object") { - this.initSegment(value, subUrl + "/" + key); - } - } - } - - protected init() { - this.initSegment(this as Segment, ""); - } - - /** - * You need to pass false in subclasses and then call this.init() - * @param init Whether to call init() on construction. Pass false if calling super() - */ - constructor(init = true) { - if (init) { - this.init(); - } - } - } - - export enum ErrorLogMode { - Throw, - Log, - None - } - - export abstract class ServerApi { - constructor(private api: ApiTemplate, private urlPrefix: string, private errorLogMode: ErrorLogMode = ErrorLogMode.Log) {} - - async handle(req: NextApiRequest, rawRes: NextApiResponse) { - const res = new ApiResponse(rawRes); - - if (!req.url) { - throw new Errors.InvalidRequestError(res); - } - - const path = req.url - .slice(this.urlPrefix.length) - .split("/"); - - try { - const route = path.reduce((segment, route) => segment[route], this.api) as unknown as Route | undefined; - - if (!route?.handler) - throw new Errors.NotFoundError(res, path.join("/")); - - const deps = this.getDependencies(req, res); - const body = req.body; - - const { authorized, authData } = await route.isAuthorized(req, res, deps, body); - - if (!authorized) - throw new Errors.UnauthorizedError(res); - - await route.handler(req, res, deps, authData, body); - } catch (e) { - (e as Errors.Error).route = path.join("/"); - - if (this.errorLogMode === ErrorLogMode.None) - return; - - if (this.errorLogMode === ErrorLogMode.Throw) - throw e; - - console.error(e); - - // If it's an error we've already handled, don't do anything - if (e instanceof Errors.Error) { - return; - } - - new Errors.InternalServerError(new ApiResponse(rawRes)); - } - } - - abstract getDependencies(req: NextApiRequest, res: ApiResponse): TDependencies; - } + export namespace Errors { + export type ErrorType = { error: string }; + + export class Error { + errorCode: number; + description: string; + route: string | undefined; + + constructor( + res: ApiResponse, + errorCode: number = 500, + description: string = "The server encountered an error while processing the request", + ) { + this.errorCode = errorCode; + this.description = description; + + res.error(errorCode, description); + } + + toString() { + return `${this.errorCode}: ${this.description}`; + } + } + + export class NotFoundError extends Error { + constructor(res: ApiResponse, routeName: string) { + super(res, 404, `This API Route (/${routeName}) does not exist`); + } + } + + export class InvalidRequestError extends Error { + constructor(res: ApiResponse) { + super(res, 400, "Invalid Request"); + } + } + + export class UnauthorizedError extends Error { + constructor(res: ApiResponse) { + super(res, 403, "You are not authorized to execute this route"); + } + } + + export class InternalServerError extends Error { + constructor(res: ApiResponse) { + super( + res, + 500, + "The server encountered an error while processing the request", + ); + } + } + } + + export class ApiResponse { + constructor(public innerRes: NextApiResponse) {} + + send(data: TSend | Errors.ErrorType) { + this.innerRes.send(data); + return this; + } + + status(code: number) { + this.innerRes.status(code); + return this; + } + + error(code: number, message: string) { + this.innerRes.status(code).send({ error: message }); + return this; + } + } + + export type Route< + TArgs extends Array, + TReturn, + TDependencies, + TDataFetchedDuringAuth, + > = { + subUrl: string; + + (...args: TArgs): Promise; + + isAuthorized: ( + req: NextApiRequest, + res: ApiResponse, + deps: TDependencies, + args: TArgs, + ) => Promise<{ + authorized: boolean; + authData: TDataFetchedDuringAuth | undefined; + }>; + handler: ( + req: NextApiRequest, + res: ApiResponse, + deps: TDependencies, + authData: TDataFetchedDuringAuth, + args: TArgs, + ) => Promise | any; + }; + + export enum RequestMethod { + POST = "POST", + GET = "GET", + } + + export async function request( + subUrl: string, + body: any, + method: RequestMethod = RequestMethod.POST, + ) { + const rawResponse = await fetch(process.env.NEXT_PUBLIC_API_URL + subUrl, { + method: method, + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + // Null or undefined are sent as an empty string that we can't parse as JSON + const text = await rawResponse.text(); + const res = text.length ? JSON.parse(text) : undefined; + + if (res?.error) { + if (res.error === "Unauthorized") { + toast.error( + `Unauthorized API request: ${subUrl}. If this is an error, please contact the developers.`, + ); + } + throw new Error(`${subUrl}: ${res.error}`); + } + + return res; + } + + /** + * There's no easy one-liner to create a function with properties while maintaining typing, so I made this shortcut + */ + export function createRoute< + TArgs extends Array, + TReturn, + TDependencies, + TFetchedDuringAuth, + >( + server: Omit< + OmitCallSignature< + Route + >, + "subUrl" + >, + clientHandler?: (...args: any) => Promise, + ): Route { + return Object.assign( + clientHandler ?? { subUrl: "newRoute" }, + server, + ) as any; + } + + export type Segment = { + [route: string]: + | Segment + | Route; + }; + + export abstract class ApiTemplate { + [route: string]: any; + + private initSegment(segment: Segment, subUrl: string) { + for (const [key, value] of Object.entries(segment)) { + if (typeof value === "function") { + value.subUrl = subUrl + "/" + key; + } else if ( + (value as unknown as Route).subUrl === + "newRoute" + ) { + const route = value as unknown as Route; + route.subUrl = subUrl + "/" + key; + + segment[key] = createRoute(route, (...args: any[]) => + request(route.subUrl, args), + ); + } else if (typeof value === "object") { + this.initSegment(value, subUrl + "/" + key); + } + } + } + + protected init() { + this.initSegment(this as Segment, ""); + } + + /** + * You need to pass false in subclasses and then call this.init() + * @param init Whether to call init() on construction. Pass false if calling super() + */ + constructor(init = true) { + if (init) { + this.init(); + } + } + } + + export enum ErrorLogMode { + Throw, + Log, + None, + } + + export abstract class ServerApi { + constructor( + private api: ApiTemplate, + private urlPrefix: string, + private errorLogMode: ErrorLogMode = ErrorLogMode.Log, + ) {} + + async handle(req: NextApiRequest, rawRes: NextApiResponse) { + const res = new ApiResponse(rawRes); + + if (!req.url) { + throw new Errors.InvalidRequestError(res); + } + + const path = req.url.slice(this.urlPrefix.length).split("/"); + + try { + const route = path.reduce( + (segment, route) => segment[route], + this.api, + ) as unknown as Route | undefined; + + if (!route?.handler) + throw new Errors.NotFoundError(res, path.join("/")); + + const deps = this.getDependencies(req, res); + const body = req.body; + + const { authorized, authData } = await route.isAuthorized( + req, + res, + deps, + body, + ); + + if (!authorized) throw new Errors.UnauthorizedError(res); + + await route.handler(req, res, deps, authData, body); + } catch (e) { + (e as Errors.Error).route = path.join("/"); + + if (this.errorLogMode === ErrorLogMode.None) return; + + if (this.errorLogMode === ErrorLogMode.Throw) throw e; + + console.error(e); + + // If it's an error we've already handled, don't do anything + if (e instanceof Errors.Error) { + return; + } + + new Errors.InternalServerError(new ApiResponse(rawRes)); + } + } + + abstract getDependencies( + req: NextApiRequest, + res: ApiResponse, + ): TDependencies; + } } export default ApiLib; @@ -231,8 +285,8 @@ export default ApiLib; * - (args): client method * - Needs to be populated with full path * - (req, res, deps, args): server method - * + * * ApiSegment has a fields that are ApiRoutes - * + * * ServerApiManager has a root ApiSegment - */ \ No newline at end of file + */ diff --git a/lib/api/ApiUtils.ts b/lib/api/ApiUtils.ts index 27adfd70..8507c08e 100644 --- a/lib/api/ApiUtils.ts +++ b/lib/api/ApiUtils.ts @@ -3,121 +3,161 @@ import CollectionId from "../client/CollectionId"; import DbInterface from "../client/dbinterfaces/DbInterface"; import { GameId } from "../client/GameId"; import { TheBlueAlliance } from "../TheBlueAlliance"; -import { User, Team, Report, Competition, DbPicklist, Match, Pitreport, Season, SubjectiveReport } from "../Types"; +import { + User, + Team, + Report, + Competition, + DbPicklist, + Match, + Pitreport, + Season, + SubjectiveReport, +} from "../Types"; import { xpToLevel } from "../Xp"; export function onTeam(team?: Team | null, user?: User) { - return team && user && user._id && team.users.find((owner) => owner === user._id?.toString()) !== undefined; + return ( + team && + user && + user._id && + team.users.find((owner) => owner === user._id?.toString()) !== undefined + ); } export function ownsTeam(team?: Team | null, user?: User) { - return team && user && user._id && team.owners.find((owner) => owner === user._id?.toString()) !== undefined; + return ( + team && + user && + user._id && + team.owners.find((owner) => owner === user._id?.toString()) !== undefined + ); } export function getCompFromReport(db: DbInterface, report: Report) { - return db.findObject(CollectionId.Competitions, { - matches: report.match?.toString() - }); + return db.findObject(CollectionId.Competitions, { + matches: report.match?.toString(), + }); } export function getCompFromMatch(db: DbInterface, match: Match) { - return db.findObject(CollectionId.Competitions, { - matches: match._id?.toString() - }); + return db.findObject(CollectionId.Competitions, { + matches: match._id?.toString(), + }); } export function getCompFromPitReport(db: DbInterface, report: Pitreport) { - return db.findObject(CollectionId.Competitions, { - pitReports: report._id?.toString() - }); + return db.findObject(CollectionId.Competitions, { + pitReports: report._id?.toString(), + }); } -export function getCompFromSubjectiveReport(db: DbInterface, report: SubjectiveReport) { - return db.findObject(CollectionId.Matches, { - subjectiveReports: report._id?.toString() - }).then(match => { - if (!match) - return undefined; +export function getCompFromSubjectiveReport( + db: DbInterface, + report: SubjectiveReport, +) { + return db + .findObject(CollectionId.Matches, { + subjectiveReports: report._id?.toString(), + }) + .then((match) => { + if (!match) return undefined; - return getCompFromMatch(db, match); - }); + return getCompFromMatch(db, match); + }); } export function getCompFromPicklist(db: DbInterface, picklist: DbPicklist) { - return db.findObject(CollectionId.Competitions, { - picklist: picklist._id?.toString() - }); + return db.findObject(CollectionId.Competitions, { + picklist: picklist._id?.toString(), + }); } export function getSeasonFromComp(db: DbInterface, comp: Competition) { - return db.findObject(CollectionId.Seasons, { - competitions: comp?._id?.toString() // Specifying one value is effectively includes for arrays - }); + return db.findObject(CollectionId.Seasons, { + competitions: comp?._id?.toString(), // Specifying one value is effectively includes for arrays + }); } export function getTeamFromSeason(db: DbInterface, season: Season) { - return db.findObject(CollectionId.Teams, { - seasons: season._id?.toString() - }); + return db.findObject(CollectionId.Teams, { + seasons: season._id?.toString(), + }); } export async function getTeamFromComp(db: DbInterface, comp: Competition) { - const season = await getSeasonFromComp(db, comp); + const season = await getSeasonFromComp(db, comp); - if (!season) - return undefined; + if (!season) return undefined; - return getTeamFromSeason(db, season); + return getTeamFromSeason(db, season); } -export async function getTeamFromDocument(db: DbInterface, getComp: (db: DbInterface, doc: any) => Promise, doc: any) { - const comp = await getComp(db, doc); +export async function getTeamFromDocument( + db: DbInterface, + getComp: (db: DbInterface, doc: any) => Promise, + doc: any, +) { + const comp = await getComp(db, doc); - if (!comp) - return undefined; + if (!comp) return undefined; - return getTeamFromComp(db, comp); + return getTeamFromComp(db, comp); } export async function getTeamFromReport(db: DbInterface, report: Report) { - return getTeamFromDocument(db, getCompFromReport, report); + return getTeamFromDocument(db, getCompFromReport, report); } export async function getTeamFromMatch(db: DbInterface, match: Match) { - return getTeamFromDocument(db, getCompFromMatch, match); + return getTeamFromDocument(db, getCompFromMatch, match); } export async function getTeamFromPitReport(db: DbInterface, report: Pitreport) { - return getTeamFromDocument(db, getCompFromPitReport, report); + return getTeamFromDocument(db, getCompFromPitReport, report); } -export async function getTeamFromPicklist(db: DbInterface, picklist: DbPicklist) { - return getTeamFromDocument(db, getCompFromPicklist, picklist); +export async function getTeamFromPicklist( + db: DbInterface, + picklist: DbPicklist, +) { + return getTeamFromDocument(db, getCompFromPicklist, picklist); } -export async function getTeamFromSubjectiveReport(db: DbInterface, report: SubjectiveReport) { - return getTeamFromDocument(db, getCompFromSubjectiveReport, report); +export async function getTeamFromSubjectiveReport( + db: DbInterface, + report: SubjectiveReport, +) { + return getTeamFromDocument(db, getCompFromSubjectiveReport, report); } -export async function generatePitReports(tba: TheBlueAlliance.Interface, db: DbInterface, tbaId: string, gameId: GameId): Promise { - var pitreports = await tba.getCompetitionPitreports(tbaId, gameId); - pitreports.map(async (report) => (await db.addObject(CollectionId.PitReports, report))._id) +export async function generatePitReports( + tba: TheBlueAlliance.Interface, + db: DbInterface, + tbaId: string, + gameId: GameId, +): Promise { + var pitreports = await tba.getCompetitionPitreports(tbaId, gameId); + pitreports.map( + async (report) => (await db.addObject(CollectionId.PitReports, report))._id, + ); - return pitreports.map((pit) => String(pit._id)); + return pitreports.map((pit) => String(pit._id)); } export async function addXp(db: DbInterface, userId: string, xp: number) { - const user = await db.findObjectById(CollectionId.Users, new ObjectId(userId)); + const user = await db.findObjectById( + CollectionId.Users, + new ObjectId(userId), + ); - if (!user) - return; + if (!user) return; - const newXp = user.xp + xp - const newLevel = xpToLevel(newXp); + const newXp = user.xp + xp; + const newLevel = xpToLevel(newXp); - await db.updateObjectById( - CollectionId.Users, - new ObjectId(userId), - { xp: newXp, level: newLevel } - ); -} \ No newline at end of file + await db.updateObjectById(CollectionId.Users, new ObjectId(userId), { + xp: newXp, + level: newLevel, + }); +} diff --git a/lib/api/ClientApi.ts b/lib/api/ClientApi.ts index 71d76de6..1074b610 100644 --- a/lib/api/ClientApi.ts +++ b/lib/api/ClientApi.ts @@ -2,1244 +2,2069 @@ import { ObjectId } from "bson"; import CollectionId from "../client/CollectionId"; import AccessLevels from "./AccessLevels"; import ApiDependencies from "./ApiDependencies"; -import ApiLib from './ApiLib'; -import { Alliance, Competition, CompetitonNameIdPair, DbPicklist, League, Match, MatchType, Pitreport, QuantData, Season, SubjectiveReport, SubjectiveReportSubmissionType, Team, User, Report, WebhookHolder } from "@/lib/Types"; +import ApiLib from "./ApiLib"; +import { + Alliance, + Competition, + CompetitonNameIdPair, + DbPicklist, + League, + Match, + MatchType, + Pitreport, + QuantData, + Season, + SubjectiveReport, + SubjectiveReportSubmissionType, + Team, + User, + Report, + WebhookHolder, +} from "@/lib/Types"; import { NotLinkedToTba, removeDuplicates } from "../client/ClientUtils"; -import { addXp, generatePitReports, getTeamFromMatch, getTeamFromReport, onTeam, ownsTeam } from "./ApiUtils"; +import { + addXp, + generatePitReports, + getTeamFromMatch, + getTeamFromReport, + onTeam, + ownsTeam, +} from "./ApiUtils"; import { TheOrangeAlliance } from "../TheOrangeAlliance"; import { GenerateSlug, isDeveloper, mentionUserInSlack } from "../Utils"; import { fillTeamWithFakeUsers } from "../dev/FakeData"; import { GameId } from "../client/GameId"; -import { AssignScoutersToCompetitionMatches, generateReportsForMatch } from "../CompetitionHandling"; +import { + AssignScoutersToCompetitionMatches, + generateReportsForMatch, +} from "../CompetitionHandling"; import { games } from "../games"; import { Statbotics } from "../Statbotics"; import { TheBlueAlliance } from "../TheBlueAlliance"; -import { request } from 'http'; +import { request } from "http"; import { SlackNotLinkedError } from "./Errors"; /** * @tested_by tests/lib/api/ClientApi.test.ts */ export default class ClientApi extends ApiLib.ApiTemplate { - constructor() { - super(false); - this.init(); - } - - hello = ApiLib.createRoute<[], { message: string, db: string, data: any }, ApiDependencies, void>({ - isAuthorized: AccessLevels.AlwaysAuthorized, - handler: async (req, res, { db }, authData, args) => { - res.status(200).send({ - message: "howdy there partner", - db: await db ? "connected" : "disconnected", - data: args, - }); - } - }); - - requestToJoinTeam = ApiLib.createRoute<[string], { result: string }, ApiDependencies, void>({ - isAuthorized: AccessLevels.IfSignedIn, - handler: async (req, res, { db, userPromise }, authData, [teamId]) => { - let team = await (await db).findObjectById( - CollectionId.Teams, - new ObjectId(teamId) - ); - - if (!team) { - return res.error(404, "Team not found"); - } - - if (team.users.indexOf((await userPromise)?._id?.toString() ?? "") > -1) { - return res.status(200).send({ result: "Already on team" }); - } - - team.requests = removeDuplicates([...team.requests, (await userPromise)?._id?.toString()]); - - await (await db).updateObjectById( - CollectionId.Teams, - new ObjectId(teamId), - team - ) - - return res.status(200).send({ result: "Success" }); - } - }); - - handleTeamJoinRequest = ApiLib.createRoute<[boolean, string, string], Team, ApiDependencies, void>({ - isAuthorized: AccessLevels.IfSignedIn, - handler: async (req, res, { db: dbPromise, userPromise }, authData, [accept, teamId, userId]) => { - const db = await dbPromise; - - const teamPromise = db.findObjectById( - CollectionId.Teams, - new ObjectId(teamId.toString()) - ); - - const joineePromise = db.findObjectById( - CollectionId.Users, - new ObjectId(userId.toString()) - ); - - const userOnTeam = await userPromise; - const team = await teamPromise; - - if (!team) { - return res.error(404, "Team not found"); - } - - if (!ownsTeam(team, userOnTeam)) { - return res.error(403, "You do not own this team"); - } - - const joinee = await joineePromise; - - if (!joinee) { - return res.error(404, "User not found"); - } - - team.requests.splice(team.requests.indexOf(userId), 1); - - if (accept) { - team.users = removeDuplicates(...team.users, userId); - team.scouters = removeDuplicates(...team.scouters, userId); - - joinee.teams = removeDuplicates(...joinee.teams, teamId); - } - - await Promise.all([ - db.updateObjectById( - CollectionId.Users, - new ObjectId(userId), - joinee - ), - db.updateObjectById( - CollectionId.Teams, - new ObjectId(teamId), - team - ) - ]); - - return res.status(200).send(team); - } - }); - - getTeamAutofillData = ApiLib.createRoute<[number, League], Team | undefined, ApiDependencies, void>({ - isAuthorized: AccessLevels.AlwaysAuthorized, - handler: async (req, res, { tba }, authData, [number, league]) => { - if (number <= 0) { - return res.status(200).send(undefined); - } - - res.status(200).send(league === League.FTC - ? await TheOrangeAlliance.getTeam(number) - : await tba.getTeamAutofillData(number) - ); - } - }); - - competitionAutofill = ApiLib.createRoute<[string], Competition | undefined, ApiDependencies, void>({ - isAuthorized: AccessLevels.AlwaysAuthorized, - handler: async (req, res, { tba }, authData, [tbaId]) => { - res.status(200).send(await tba.getCompetitionAutofillData(tbaId)); - } - }); - - competitionMatches = ApiLib.createRoute<[string], Match | undefined, ApiDependencies, void>({ - isAuthorized: AccessLevels.AlwaysAuthorized, - handler: async (req, res, { tba }, authData, [tbaId]) => { - res.status(200).send(await tba.getMatchAutofillData(tbaId)); - } - }); - - createTeam = ApiLib.createRoute<[string, string, number, League], Team | undefined, ApiDependencies, void>({ - isAuthorized: AccessLevels.IfSignedIn, - handler: async (req, res, { db: dbPromise, resend, userPromise }, authData, [name, tbaId, number, league]) => { - const user = (await userPromise)!; - const db = await dbPromise; - - // Find if team already exists - const existingTeam = await db.findObject(CollectionId.Teams, { - number, - ...(league === League.FRC - ? { $or: [ - { league: League.FRC }, - { league: undefined } - ] } - : { league: league } - ) - }); - - if (existingTeam) { - return res.error(400, "Team already exists"); - } - - const newTeamObj = new Team( - name, - await GenerateSlug(db, CollectionId.Teams, name), - tbaId, - number, - league, - [user._id!.toString()], - [user._id!.toString()], - [user._id!.toString()] - ); - const team = await db.addObject(CollectionId.Teams, newTeamObj); - - user.teams = removeDuplicates(...user.teams, team._id!.toString()); - user.owner = removeDuplicates(...user.owner, team._id!.toString()); - - await db.updateObjectById( - CollectionId.Users, - new ObjectId(user._id?.toString()), - user - ); - - resend.emailDevelopers(`New team created: ${team.name}`, - `A new team has been created by ${user.name}: ${team.league} ${team.number}, ${team.name}.`); - - if (process.env.FILL_TEAMS === "true") { - fillTeamWithFakeUsers(20, team._id.toString(), db); - } - - return res.status(200).send(team); - } - }); - - createSeason = ApiLib.createRoute<[string, number, string, GameId], Season, ApiDependencies, Team>({ - isAuthorized: (req, res, deps, [name, year, teamId]) => AccessLevels.IfTeamOwner(req, res, deps, teamId), - handler: async (req, res, { db: dbPromise, userPromise }, team, [name, year, teamId, gameId]) => { - const db = await dbPromise; - - if (!ownsTeam(team, (await userPromise))) { - return res.status(403).send({ error: "Unauthorized" }); - } - - const season = await db.addObject( - CollectionId.Seasons, - new Season( - name, - await GenerateSlug(db, CollectionId.Seasons, name), - year, - gameId - ) - ); - team!.seasons = [...team!.seasons, String(season._id)]; - - await db.updateObjectById( - CollectionId.Teams, - new ObjectId(teamId), - team! - ); - - return res.status(200).send(season); - } - }); - - reloadCompetition = ApiLib.createRoute<[string], { result: string }, ApiDependencies, { comp: Competition, team: Team }>({ - isAuthorized: (req, res, deps, [compId]) => AccessLevels.IfCompOwner(req, res, deps, compId), - handler: async (req, res, { db: dbPromise, tba }, { comp, team }, [compId]) => { - - const matches = await tba.getCompetitionMatches(comp.tbaId!); - if (!matches || matches.length <= 0) { - res.status(200).send({ result: "none" }); - return; - } - - const db = await dbPromise; - - matches.map( - async (match) => - (await db.addObject(CollectionId.Matches, match))._id - ); - - if (!comp.tbaId || comp.tbaId === NotLinkedToTba) { - return res.status(200).send({ result: "not linked to TBA" }); - } - - const pitReports = await generatePitReports(tba, db, comp.tbaId, comp.gameId); - - await db.updateObjectById( - CollectionId.Competitions, - new ObjectId(compId), - { - matches: matches.map((match) => String(match._id)), - pitReports: pitReports, - } - ); - res.status(200).send({ result: "success" }); - } - }); - - createCompetition = ApiLib.createRoute<[string, number, number, string, string, boolean], Competition, ApiDependencies, { team: Team, season: Season }>({ - isAuthorized: (req, res, deps, [tbaId, start, end, name, seasonId]) => AccessLevels.IfSeasonOwner(req, res, deps, seasonId), - handler: async (req, res, { db: dbPromise, tba }, { team, season }, [tbaId, start, end, name, seasonId, publicData]) => { - const db = await dbPromise; - - const matches = await tba.getCompetitionMatches(tbaId); - matches.map( - async (match) => - (await db.addObject(CollectionId.Matches, match))._id, - ); - - const pitReports = await generatePitReports(tba, db, tbaId, season.gameId); - - const picklist = await db.addObject(CollectionId.Picklists, { - picklists: {}, - }); - - const comp = await db.addObject( - CollectionId.Competitions, - new Competition( - name, - await GenerateSlug(db, CollectionId.Competitions, name), - tbaId, - start, - end, - pitReports, - matches.map((match) => String(match._id)), - picklist._id.toString(), - publicData, - season?.gameId - ) - ); - - season.competitions = [...season.competitions, String(comp._id)]; - - await db.updateObjectById( - CollectionId.Seasons, - new ObjectId(season._id), - season - ); - - // Create reports - const reportCreationPromises = matches.map((match) => - generateReportsForMatch(db, match, comp.gameId) - ); - await Promise.all(reportCreationPromises); - - return res.status(200).send(comp); - } - }); - - createMatch = ApiLib.createRoute<[string, number, number, MatchType, Alliance, Alliance], Match, ApiDependencies, { team: Team, comp: Competition }>({ - isAuthorized: (req, res, deps, [compId]) => AccessLevels.IfCompOwner(req, res, deps, compId), - handler: async (req, res, { db: dbPromise, userPromise }, { team, comp }, [compId, number, time, type, redAlliance, blueAlliance]) => { - const db = await dbPromise; - - const match = await db.addObject( - CollectionId.Matches, - new Match( - number, - await GenerateSlug(db, CollectionId.Matches, number.toString()), - undefined, - time, - type, - blueAlliance, - redAlliance - ) - ); - comp.matches.push(match._id ? String(match._id) : ""); - - const reportPromise = generateReportsForMatch(db, match, comp.gameId); - - await Promise.all([db.updateObjectById( - CollectionId.Competitions, - new ObjectId(comp._id), - comp - ), reportPromise]); - - return res.status(200).send(match); - } - }); - - searchCompetitionByName = ApiLib.createRoute<[string], { value: number; pair: CompetitonNameIdPair }[], ApiDependencies, void>({ - isAuthorized: AccessLevels.AlwaysAuthorized, - handler: async (req, res, { tba }, authData, [name]) => { - res.status(200).send(await tba.searchCompetitionByName(name)); - } - }); - - assignScouters = ApiLib.createRoute<[string, boolean], { result: string }, ApiDependencies, { team: Team, comp: Competition }>({ - isAuthorized: (req, res, deps, [compId]) => AccessLevels.IfCompOwner(req, res, deps, compId), - handler: async (req, res, { db: dbPromise, userPromise }, { team, comp }, [compId, shuffle]) => { - const db = await dbPromise; - - if (!team?._id) - return res.status(400).send({ error: "Team not found" }); - - const result = await AssignScoutersToCompetitionMatches( - db, - team?._id?.toString(), - compId, - ); - - console.log(result); - return res.status(200).send({ result: result }); - } - }); - - submitForm = ApiLib.createRoute<[string, QuantData], { result: string }, ApiDependencies, { team: Team, report: Report }>({ - isAuthorized: (req, res, deps, [reportId]) => AccessLevels.IfOnTeamThatOwnsReport(req, res, deps, reportId), - handler: async (req, res, { db: dbPromise, userPromise }, { team, report }, [reportId, formData]) => { - const db = await dbPromise; - - const user = await userPromise; - if (!onTeam(team, user) || !user?._id) - return res.status(403).send({ error: "Unauthorized" }); - - report.data = formData; - report.submitted = true; - report.submitter = user._id.toString(); - - await db.updateObjectById( - CollectionId.Reports, - new ObjectId(reportId), - report - ); - - addXp(db, user._id.toString(), 10); - - await db.updateObjectById( - CollectionId.Users, - new ObjectId(user._id.toString()), - user - ); - - return res.status(200).send({ result: "success" }); - } - }); - - competitionReports = ApiLib.createRoute<[string, boolean, boolean], Report[], ApiDependencies, { team: Team, comp: Competition }>({ - isAuthorized: (req, res, deps, [compId]) => AccessLevels.IfOnTeamThatOwnsComp(req, res, deps, compId), - handler: async (req, res, { db: dbPromise, userPromise }, { team, comp }, [compId, submitted, usePublicData]) => { - const db = await dbPromise; - - const usedComps = usePublicData && comp.tbaId !== NotLinkedToTba - ? await db.findObjects(CollectionId.Competitions, { publicData: true, tbaId: comp.tbaId, gameId: comp.gameId }) - : [comp]; - - if (usePublicData && !comp.publicData) - usedComps.push(comp); - - const reports = (await db.findObjects(CollectionId.Reports, { - match: { $in: usedComps.flatMap((m) => m.matches) }, - submitted: submitted ? true : { $exists: true }, - })) - // Filter out comments from other competitions - .map((report) => comp.matches.includes(report.match) ? report : { ...report, data: { ...report.data, comments: "" } } ); - return res.status(200).send(reports); - } - }); - - allCompetitionMatches = ApiLib.createRoute<[string], Match[], ApiDependencies, { team: Team, comp: Competition }>({ - isAuthorized: (req, res, deps, [compId]) => AccessLevels.IfOnTeamThatOwnsComp(req, res, deps, compId), - handler: async (req, res, { db: dbPromise, userPromise }, { team, comp }, [compId]) => { - const db = await dbPromise; - - const matches = await db.findObjects(CollectionId.Matches, { - _id: { $in: comp.matches.map((matchId) => new ObjectId(matchId)) }, - }); - return res.status(200).send(matches); - } - }); - - matchReports = ApiLib.createRoute<[string], Report[], ApiDependencies, { team: Team, match: Match }>({ - isAuthorized: (req, res, deps, [matchId]) => AccessLevels.IfOnTeamThatOwnsMatch(req, res, deps, matchId), - handler: async (req, res, { db: dbPromise, userPromise }, { team, match }, [matchId]) => { - const db = await dbPromise; - - const reports = await db.findObjects(CollectionId.Reports, { - _id: { $in: match.reports.map((reportId) => new ObjectId(reportId)) }, - }); - return res.status(200).send(reports); - } - }); - - changePFP = ApiLib.createRoute<[string], { result: string }, ApiDependencies, void>({ - isAuthorized: AccessLevels.IfSignedIn, - handler: async (req, res, { db: dbPromise, userPromise }, authData, [newImage]) => { - const db = await dbPromise; - const user = await userPromise; - - if (!user?._id) - return res.status(403).send({ error: "Unauthorized" }); - - await db.updateObjectById( - CollectionId.Users, - new ObjectId(user._id), - { image: newImage } - ); - - return res.status(200).send({ result: "success" }); - } - }); - - checkInForReport = ApiLib.createRoute<[string], { result: string }, ApiDependencies, { team: Team, report: Report }>({ - isAuthorized: (req, res, deps, [reportId]) => AccessLevels.IfOnTeamThatOwnsReport(req, res, deps, reportId), - handler: async (req, res, { db: dbPromise, userPromise }, { team, report }, [reportId]) => { - const db = await dbPromise; - - await db.updateObjectById( - CollectionId.Reports, - new ObjectId(reportId), - { checkInTimestamp: new Date().toISOString() } - ); - - return res.status(200).send({ result: "success" }); - } - }); - - checkInForSubjectiveReport = ApiLib.createRoute<[string], { result: string }, ApiDependencies, { match: Match }>({ - isAuthorized: (req, res, deps, [matchId]) => AccessLevels.IfOnTeamThatOwnsMatch(req, res, deps, matchId), - handler: async (req, res, { db: dbPromise, userPromise }, { match }, [matchId]) => { - const db = await dbPromise; - - const user = await userPromise; - - const update: { [key: string]: any } = {}; - update[`subjectiveReportsCheckInTimestamps.${user?._id?.toString()}`] = new Date().toISOString(); - await db.updateObjectById(CollectionId.Matches, new ObjectId(matchId), update); - - return res.status(200).send({ result: "success" }); - } - }); - - remindSlack = ApiLib.createRoute<[string, string], { result: string }, ApiDependencies, Team>({ - isAuthorized: (req, res, deps, [teamId]) => AccessLevels.IfTeamOwner(req, res, deps, teamId), - handler: async (req, res, { db: dbPromise, slackClient, userPromise }, team, [teamId, targetUserId]) => { - const db = await dbPromise; - const targetUserPromise = db.findObjectById(CollectionId.Users, new ObjectId(targetUserId)); - - if (!team.slackWebhook) - throw new SlackNotLinkedError(res); - - const webhookHolder = await db.findObjectById(CollectionId.Webhooks, new ObjectId(team.slackWebhook)); - if (!webhookHolder) - throw new SlackNotLinkedError(res); - - const user = (await userPromise)!; - const targetUser = await targetUserPromise; - - if (!targetUser || team.users.indexOf(targetUser._id?.toString() ?? "") === -1) { - return res.status(400).send({ error: "User not found" }); - } - - await slackClient.sendMsg(webhookHolder.url, - `${mentionUserInSlack(targetUser)}, please report to our section and prepare for scouting. Sent by ${mentionUserInSlack(user)}.`); - - return res.status(200).send({ result: "success" }); - } - }); - - setSlackWebhook = ApiLib.createRoute<[string, string], { result: string }, ApiDependencies, Team>({ - isAuthorized: (req, res, deps, [teamId]) => AccessLevels.IfTeamOwner(req, res, deps, teamId), - handler: async (req, res, { db, userPromise, slackClient }, team, [teamId, webhookUrl]) => { - const user = (await userPromise)!; - - // Check that the webhook works - slackClient.sendMsg(webhookUrl, `Gearbox integration for ${team.name} was added by ${mentionUserInSlack(user)}.`) - .catch(() => { - return res.status(400).send({ error: "Invalid webhook" }); - }); - - if (team.slackWebhook) { - (await db).updateObjectById(CollectionId.Webhooks, new ObjectId(team.slackWebhook), { url: webhookUrl }); - } else { - const webhook = await (await db).addObject(CollectionId.Webhooks, { url: webhookUrl }); - team.slackWebhook = webhook._id.toString(); - (await db).updateObjectById(CollectionId.Teams, new ObjectId(teamId), team); - } - - return res.status(200).send({ result: "success" }); - } - }); - - setSlackId = ApiLib.createRoute<[string], { result: string }, ApiDependencies, void>({ - isAuthorized: AccessLevels.IfSignedIn, - handler: async (req, res, { db: dbPromise, userPromise }, authData, [slackId]) => { - const db = await dbPromise; - const user = await userPromise; - - if (!user?._id) - return res.status(403).send({ error: "Unauthorized" }); - - await db.updateObjectById( - CollectionId.Users, - new ObjectId(user._id), - { slackId: slackId } - ); - - return res.status(200).send({ result: "success" }); - } - }); - - initialEventData = ApiLib.createRoute<[string], { firstRanking: TheBlueAlliance.SimpleRank[], comp: Competition, oprRanking: TheBlueAlliance.OprRanking }, ApiDependencies, void>({ - isAuthorized: AccessLevels.AlwaysAuthorized, - handler: async (req, res, { tba }, authData, [eventKey]) => { - const compRankingsPromise = tba.req.getCompetitonRanking(eventKey); - const eventInformationPromise = tba.getCompetitionAutofillData( - eventKey - ); - const tbaOPRPromise = tba.req.getCompetitonOPRS(eventKey); - - return res.status(200).send({ - firstRanking: (await compRankingsPromise).rankings, - comp: await eventInformationPromise, - oprRanking: await tbaOPRPromise, - }); - } - }); - - compRankings = ApiLib.createRoute<[string], TheBlueAlliance.SimpleRank[], ApiDependencies, void>({ - isAuthorized: AccessLevels.AlwaysAuthorized, - handler: async (req, res, { tba }, authData, [tbaId]) => { - const compRankings = await tba.req.getCompetitonRanking(tbaId); - return res.status(200).send(compRankings.rankings); - } - }); - - statboticsTeamEvent = ApiLib.createRoute<[string, string], Statbotics.TeamEvent, ApiDependencies, void>({ - isAuthorized: AccessLevels.AlwaysAuthorized, - handler: async (req, res, { tba }, authData, [eventKey, team]) => { - const teamEvent = await Statbotics.getTeamEvent(eventKey, team); - return res.status(200).send(teamEvent); - } - }); - - getMainPageCounterData = ApiLib.createRoute<[], { teams: number | undefined, users: number | undefined, datapoints: number | undefined, competitions: number | undefined }, ApiDependencies, void>({ - isAuthorized: AccessLevels.AlwaysAuthorized, - handler: async (req, res, { db: dbPromise }, authData, args) => { - const db = await dbPromise; - - const teamsPromise = db.countObjects(CollectionId.Teams, {}); - const usersPromise = db.countObjects(CollectionId.Users, {}); - const reportsPromise = db.countObjects(CollectionId.Reports, {}); - const pitReportsPromise = db.countObjects(CollectionId.PitReports, {}); - const subjectiveReportsPromise = db.countObjects(CollectionId.SubjectiveReports, {}); - const competitionsPromise = db.countObjects(CollectionId.Competitions, {}); - - const dataPointsPerReport = Reflect.ownKeys(QuantData).length; - const dataPointsPerPitReports = Reflect.ownKeys(Pitreport).length; - const dataPointsPerSubjectiveReport = Reflect.ownKeys(SubjectiveReport).length + 5; - - await Promise.all([teamsPromise, usersPromise, reportsPromise, pitReportsPromise, subjectiveReportsPromise, competitionsPromise]); - - return res.status(200).send({ - teams: await teamsPromise, - users: await usersPromise, - datapoints: ((await reportsPromise) ?? 0) * dataPointsPerReport - + ((await pitReportsPromise) ?? 0) * dataPointsPerPitReports - + ((await subjectiveReportsPromise) ?? 0) * dataPointsPerSubjectiveReport, - competitions: await competitionsPromise, - }); - } - }); - - exportCompAsCsv = ApiLib.createRoute<[string], { csv: string }, ApiDependencies, { team: Team, comp: Competition }>({ - isAuthorized: (req, res, deps, [compId]) => AccessLevels.IfCompOwner(req, res, deps, compId), - handler: async (req, res, { db: dbPromise, userPromise }, { team, comp }, [compId]) => { - const db = await dbPromise; - - const matches = await db.findObjects(CollectionId.Matches, { - _id: { $in: comp.matches.map((matchId) => new ObjectId(matchId)) }, - }); - const allReports = await db.findObjects(CollectionId.Reports, { - match: { $in: matches.map((match) => match?._id?.toString()) }, - }); - const reports = allReports.filter((report) => report.submitted); - - if (reports.length == 0) { - return res - .status(200) - .send({ error: "No reports found for competition" }); - } - - interface Row extends QuantData { - timestamp: number | undefined; - team: number; - } - - const rows: Row[] = []; - for (const report of reports) { - const row = { - ...report.data, - timestamp: report.timestamp, - team: report.robotNumber, - }; - rows.push(row); - } - - const headers = Object.keys(rows[0]); - - let csv = headers.join(",") + "\n"; - - for (const row of rows) { - const data = headers.map((header) => row[header]); - - csv += data.join(",") + "\n"; - } - - res.status(200).send({ csv }); - } - }); - - teamCompRanking = ApiLib.createRoute<[string, number], { place: number | string, max: number | string }, ApiDependencies, void>({ - isAuthorized: AccessLevels.AlwaysAuthorized, - handler: async (req, res, { tba }, authData, [tbaId, team]) => { - const tbaResult = await tba.req.getCompetitonRanking(tbaId); - if (!tbaResult || !tbaResult.rankings) { - return res.status(200).send({ place: "?", max: "?" }); - } - - const { rankings } = tbaResult; - - const rank = rankings?.find((ranking) => ranking.team_key === `frc${team}`)?.rank; - - if (!rank) { - return res.status(200).send({ - place: "?", - max: rankings?.length, - }); - } - - return res.status(200).send({ - place: rankings?.find((ranking) => ranking.team_key === `frc${team}`)?.rank ?? "?", - max: rankings?.length, - }); - } - }); - - getPitReports = ApiLib.createRoute<[string], Pitreport[], ApiDependencies, { team: Team, comp: Competition }>({ - isAuthorized: (req, res, deps, [compId]) => AccessLevels.IfOnTeamThatOwnsComp(req, res, deps, compId), - handler: async (req, res, { db: dbPromise, userPromise }, { team, comp }, [compId]) => { - const db = await dbPromise; - - const pitReports = await db.findObjects(CollectionId.PitReports, { - _id: { $in: comp.pitReports.map((id) => new ObjectId(id)) }, - }); - - return res.status(200).send(pitReports); - } - }); - - changeScouterForReport = ApiLib.createRoute<[string, string], { result: string }, ApiDependencies, any>({ - isAuthorized: (req, res, deps, [reportId]) => AccessLevels.IfReportOwner(req, res, deps, reportId), - handler: async (req, res, { db: dbPromise, userPromise }, authData, [reportId, scouterId]) => { - const db = await dbPromise; - - await db.updateObjectById( - CollectionId.Reports, - new ObjectId(reportId), - { user: scouterId } - ); - - return res.status(200).send({ result: "success" }); - } - }); - - getCompReports = ApiLib.createRoute<[string], Report[], ApiDependencies, { team: Team, comp: Competition }>({ - isAuthorized: (req, res, deps, [compId]) => AccessLevels.IfOnTeamThatOwnsComp(req, res, deps, compId), - handler: async (req, res, { db: dbPromise, userPromise }, { team, comp }, [compId]) => { - const db = await dbPromise; - - const reports = await db.findObjects(CollectionId.Reports, { - match: { $in: comp.matches }, - }); - - return res.status(200).send(reports); - } - }); - - findScouterManagementData = ApiLib.createRoute<[string], { scouters: User[], matches: Match[], quantitativeReports: Report[], pitReports: Pitreport[], subjectiveReports: SubjectiveReport[] }, ApiDependencies, { team: Team, comp: Competition }>({ - isAuthorized: (req, res, deps, [compId]) => AccessLevels.IfCompOwner(req, res, deps, compId), - handler: async (req, res, { db: dbPromise, userPromise }, { team, comp }, [compId]) => { - const db = await dbPromise; - - const promises: Promise[] = []; - - const scouters: User[] = []; - const matches: Match[] = []; - const quantitativeReports: Report[] = []; - const pitReports: Pitreport[] = []; - const subjectiveReports: SubjectiveReport[] = []; - - for (const scouterId of team?.scouters) { - promises.push(db.findObjectById(CollectionId.Users, new ObjectId(scouterId)).then((scouter) => scouter && scouters.push(scouter))); - } - - for (const matchId of comp.matches) { - promises.push(db.findObjectById(CollectionId.Matches, new ObjectId(matchId)).then((match) => match && matches.push(match))); - } - - promises.push(db.findObjects(CollectionId.Reports, { - match: { $in: comp.matches }, - }).then((r) => quantitativeReports.push(...r))); - - promises.push(db.findObjects(CollectionId.PitReports, { - _id: { $in: comp.pitReports.map((id) => new ObjectId(id)) }, - submitted: true - }).then((r) => pitReports.push(...r))); - - promises.push(db.findObjects(CollectionId.SubjectiveReports, { - match: { $in: comp.matches } - }).then((r) => subjectiveReports.push(...r))); - - await Promise.all(promises); - - return res.status(200).send({ scouters, matches, quantitativeReports, pitReports, subjectiveReports }); - } - }); - - getPicklistFromComp = ApiLib.createRoute<[string], DbPicklist | undefined, ApiDependencies, { comp: Competition }>({ - isAuthorized: (req, res, deps, [compId]) => AccessLevels.IfOnTeamThatOwnsComp(req, res, deps, compId), - handler: async (req, res, { db: dbPromise }, { comp }, [compId]) => { - const db = await dbPromise; - - const picklist = await db.findObjectById(CollectionId.Picklists, new ObjectId(comp.picklist)); - - return res.status(200).send(picklist); - } - }); - - getPicklist = ApiLib.createRoute<[string], DbPicklist | undefined, ApiDependencies, { picklist: DbPicklist }>({ - isAuthorized: (req, res, deps, [picklistId]) => AccessLevels.IfOnTeamThatOwnsPicklist(req, res, deps, picklistId), - handler: async (req, res, deps, { picklist }, [picklistId]) => { - return res.status(200).send(picklist); - } - }); - - updatePicklist = ApiLib.createRoute<[DbPicklist], { result: string }, ApiDependencies, { picklist: DbPicklist }>({ - isAuthorized: (req, res, deps, [picklist]) => AccessLevels.IfOnTeamThatOwnsPicklist(req, res, deps, picklist._id), - handler: async (req, res, { db: dbPromise, userPromise }, { picklist: oldPicklist }, [newPicklist]) => { - const db = await dbPromise; - - const { _id, ...picklistData } = newPicklist; - await db.updateObjectById(CollectionId.Picklists, new ObjectId(oldPicklist._id), picklistData); - return res.status(200).send({ result: "success" }); - } - }); - - setCompPublicData = ApiLib.createRoute<[string, boolean], { result: string }, ApiDependencies, { team: Team, comp: Competition }>({ - isAuthorized: (req, res, deps, [compId]) => AccessLevels.IfCompOwner(req, res, deps, compId), - handler: async (req, res, { db: dbPromise, userPromise }, { team, comp }, [compId, publicData]) => { - const db = await dbPromise; - - await db.updateObjectById(CollectionId.Competitions, new ObjectId(compId), { publicData: publicData }); - return res.status(200).send({ result: "success" }); - } - }); - - setOnboardingCompleted = ApiLib.createRoute<[string], { result: string }, ApiDependencies, void>({ - isAuthorized: AccessLevels.IfSignedIn, - handler: async (req, res, { db: dbPromise, userPromise }, authData, [userId]) => { - const db = await dbPromise; - const user = await userPromise; - if (!user?._id) - return res.status(403).send({ error: "Unauthorized" }); - - await db.updateObjectById(CollectionId.Users, new ObjectId(user._id), { onboardingComplete: true }); - return res.status(200).send({ result: "success" }); - } - }); - - submitSubjectiveReport = ApiLib.createRoute<[SubjectiveReport, string], { result: string }, ApiDependencies, { team: Team, match: Match }>({ - isAuthorized: (req, res, deps, [report, matchId]) => AccessLevels.IfOnTeamThatOwnsMatch(req, res, deps, matchId), - handler: async (req, res, { db: dbPromise, userPromise }, { team, match }, [report]) => { - const db = await dbPromise; - const rawReport = report as SubjectiveReport; - - const user = await userPromise; - - if (!onTeam(team, user)) - return res.status(403).send({ error: "Unauthorized" }); - - const newReport: SubjectiveReport = { - ...report, - _id: new ObjectId().toString(), - submitter: user!._id!.toString(), - submitted: match.subjectiveScouter === user!._id!.toString() - ? SubjectiveReportSubmissionType.ByAssignedScouter - : team!.subjectiveScouters.find(id => id === user!._id!.toString()) - ? SubjectiveReportSubmissionType.BySubjectiveScouter - : SubjectiveReportSubmissionType.ByNonSubjectiveScouter, - }; - - const update: Partial = { - subjectiveReports: [...match.subjectiveReports ?? [], newReport._id!.toString()], - }; - - if (match.subjectiveScouter === user!._id!.toString()) - update.assignedSubjectiveScouterHasSubmitted = true; - - const insertReportPromise = db.addObject(CollectionId.SubjectiveReports, newReport); - const updateMatchPromise = db.updateObjectById(CollectionId.Matches, new ObjectId(match._id), update); - - addXp(db, user!._id!, match.subjectiveScouter === user!._id!.toString() ? 10 : 5); - - await Promise.all([insertReportPromise, updateMatchPromise]); - return res.status(200).send({ result: "success" }); - } - }); - - getSubjectiveReportsForComp = ApiLib.createRoute<[string], SubjectiveReport[], ApiDependencies, { team: Team, comp: Competition }>({ - isAuthorized: (req, res, deps, [compId]) => AccessLevels.IfOnTeamThatOwnsComp(req, res, deps, compId), - handler: async (req, res, { db: dbPromise, userPromise }, { team, comp }, [compId]) => { - const db = await dbPromise; - - const reports = await db.findObjects(CollectionId.SubjectiveReports, { - match: { $in: comp.matches }, - }); - - return res.status(200).send(reports); - } - }); - - updateSubjectiveReport = ApiLib.createRoute<[SubjectiveReport], { result: string }, ApiDependencies, { report: SubjectiveReport }>({ - isAuthorized: (req, res, deps, [report]) => AccessLevels.IfOnTeamThatOwnsSubjectiveReport(req, res, deps, report._id?.toString() ?? ""), - handler: async (req, res, { db: dbPromise, userPromise }, { report: oldRepor }, [newReport]) => { - const db = await dbPromise; - - await db.updateObjectById(CollectionId.SubjectiveReports, new ObjectId(oldRepor._id), newReport); - return res.status(200).send({ result: "success" }); - } - }); - - setSubjectiveScouterForMatch = ApiLib.createRoute<[string, string], { result: string }, ApiDependencies, { team: Team, match: Match }>({ - isAuthorized: (req, res, deps, [matchId]) => AccessLevels.IfMatchOwner(req, res, deps, matchId), - handler: async (req, res, { db: dbPromise }, { team, match }, [matchId, scouterId]) => { - const db = await dbPromise; - - const scouter = team?.users.find(id => id === scouterId); - - if (!scouter) - return res.status(400).send({ error: "Scouter not on team" }); - - await db.updateObjectById(CollectionId.Matches, new ObjectId(matchId), { - subjectiveScouter: scouter - }); - return res.status(200).send({ result: "success" }); - } - }); - - regeneratePitReports = ApiLib.createRoute<[string], { result: string, pitReports: string[] }, ApiDependencies, { team: Team, comp: Competition }>({ - isAuthorized: (req, res, deps, [compId]) => AccessLevels.IfCompOwner(req, res, deps, compId), - handler: async (req, res, { db: dbPromise, tba, userPromise }, { team, comp }, [compId]) => { - const db = await dbPromise; - - if (!comp.tbaId) - return res.status(200).send({ error: "not linked to TBA" }); - - const pitReports = await generatePitReports(tba, db, comp.tbaId, comp.gameId); - - await db.updateObjectById( - CollectionId.Competitions, - new ObjectId(compId), - { pitReports: pitReports } - ); - - return res.status(200).send({ result: "success", pitReports }); - } - }); - - createPitReportForTeam = ApiLib.createRoute<[number, string], { result: string }, ApiDependencies, { team: Team, comp: Competition }>({ - isAuthorized: (req, res, deps, [teamNumber, compId]) => AccessLevels.IfCompOwner(req, res, deps, compId), - handler: async (req, res, { db: dbPromise, userPromise }, { team, comp }, [teamNumber, compId]) => { - const db = await dbPromise; - - const pitReport = new Pitreport(teamNumber, games[comp.gameId].createPitReportData()); - const pitReportId = (await db.addObject(CollectionId.PitReports, pitReport))._id?.toString(); - - if (!pitReportId) - return res.status(500).send({ error: "Failed to create pit report" }); - - comp.pitReports.push(pitReportId); - - await db.updateObjectById(CollectionId.Competitions, new ObjectId(compId), { - pitReports: comp.pitReports, - }); - - return res.status(200).send({ result: "success" }); - } - }); - - updateCompNameAndTbaId = ApiLib.createRoute<[string, string, string], { result: string }, ApiDependencies, { team: Team, comp: Competition }>({ - isAuthorized: (req, res, deps, [compId]) => AccessLevels.IfCompOwner(req, res, deps, compId), - handler: async (req, res, { db: dbPromise, userPromise }, { team, comp }, [compId, name, tbaId]) => { - const db = await dbPromise; - - await db.updateObjectById(CollectionId.Competitions, new ObjectId(compId), { - name, - tbaId, - }); - - return res.status(200).send({ result: "success" }); - } - }); - - getFtcTeamAutofillData = ApiLib.createRoute<[number], Team | undefined, ApiDependencies, void>({ - isAuthorized: AccessLevels.AlwaysAuthorized, - handler: async (req, res, { tba }, authData, [teamNumber]) => { - const team = await TheOrangeAlliance.getTeam(teamNumber); - return res.status(200).send(team); - } - }); - - ping = ApiLib.createRoute<[], { result: string }, ApiDependencies, void>({ - isAuthorized: AccessLevels.AlwaysAuthorized, - handler: async (req, res, authData, args) => { - return res.status(200).send({ result: "success" }); - } - }); - - getSubjectiveReportsFromMatches = ApiLib.createRoute<[string, Match[]], SubjectiveReport[], ApiDependencies, { team: Team, comp: Competition }>({ - isAuthorized: (req, res, deps, [compId]) => AccessLevels.IfOnTeamThatOwnsComp(req, res, deps, compId), - handler: async (req, res, { db: dbPromise, userPromise }, { team, comp }, [compId, matches]) => { - const db = await dbPromise; - - for (const match of matches) { - if (!comp.matches.find(id => id === match._id?.toString())) - return res.status(400).send({ error: "Match not in competition" }); - } - - const matchIds = matches.map((match) => match._id?.toString()); - const reports = await db.findObjects(CollectionId.SubjectiveReports, { - match: { $in: matchIds }, - }); - - return res.status(200).send(reports); - } - }); - - removeUserFromTeam = ApiLib.createRoute<[string, string], { result: string, team: Team }, ApiDependencies, Team>({ - isAuthorized: (req, res, deps, [teamId]) => AccessLevels.IfTeamOwner(req, res, deps, teamId), - handler: async (req, res, { db: dbPromise, userPromise }, team, [teamId, userId]) => { - const db = await dbPromise; - - const removedUserPromise = db.findObjectById(CollectionId.Users, new ObjectId(userId)); - - const newTeam: Team = { - ...team, - users: team.users.filter((id) => id !== userId), - owners: team.owners.filter((id) => id !== userId), - scouters: team.scouters.filter((id) => id !== userId), - subjectiveScouters: team.subjectiveScouters.filter((id) => id !== userId), - } - - const teamPromise = db.updateObjectById(CollectionId.Teams, new ObjectId(teamId), newTeam); - - const removedUser = await removedUserPromise; - if (!removedUser) - return res.status(404).send({ error: "User not found" }); - - const newUserData: User = { - ...removedUser, - teams: removedUser.teams.filter((id) => id !== teamId), - owner: removedUser.owner.filter((id) => id !== teamId), - } - - await db.updateObjectById(CollectionId.Users, new ObjectId(userId), newUserData); - await teamPromise; - - return res.status(200).send({ result: "success", team: newTeam }); - } - }); - - findUserById = ApiLib.createRoute<[string], User | undefined, ApiDependencies, void>({ - isAuthorized: AccessLevels.AlwaysAuthorized, - handler: async (req, res, { db: dbPromise }, authData, [id]) => { - const db = await dbPromise; - const user = await db.findObjectById(CollectionId.Users, new ObjectId(id)); - return res.status(200).send(user); - } - }); - - findTeamById = ApiLib.createRoute<[string], Team | undefined, ApiDependencies, void>({ - isAuthorized: AccessLevels.AlwaysAuthorized, - handler: async (req, res, { db: dbPromise }, authData, [id]) => { - const db = await dbPromise; - const team = await db.findObjectById(CollectionId.Teams, new ObjectId(id)); - return res.status(200).send(team); - } - }); - - findTeamByNumberAndLeague = ApiLib.createRoute<[number, League], Team | undefined, ApiDependencies, void>({ - isAuthorized: AccessLevels.AlwaysAuthorized, - handler: async (req, res, { db: dbPromise }, authData, [number, league]) => { - const db = await dbPromise; - - const query = league === League.FRC - ? { - number: number, - $or: [ - { league: league }, - { tbaId: { $exists: true } } - ] - } - : { number: number, league: league }; - - const team = await db.findObject(CollectionId.Teams, query); - - return res.status(200).send(team); - } - }); - - findSeasonById = ApiLib.createRoute<[string], Season | undefined, ApiDependencies, void>({ - isAuthorized: AccessLevels.AlwaysAuthorized, - handler: async (req, res, { db: dbPromise }, authData, [id]) => { - const db = await dbPromise; - const season = await db.findObjectById(CollectionId.Seasons, new ObjectId(id)); - return res.status(200).send(season); - } - }); - - findCompetitionById = ApiLib.createRoute<[string], Competition | undefined, ApiDependencies, void>({ - isAuthorized: AccessLevels.AlwaysAuthorized, - handler: async (req, res, { db: dbPromise }, authData, [id]) => { - const db = await dbPromise; - const competition = await db.findObjectById(CollectionId.Competitions, new ObjectId(id)); - return res.status(200).send(competition); - } - }); - - findMatchById = ApiLib.createRoute<[string], Match | undefined, ApiDependencies, void>({ - isAuthorized: AccessLevels.AlwaysAuthorized, - handler: async (req, res, { db: dbPromise }, authData, [id]) => { - const db = await dbPromise; - const match = await db.findObjectById(CollectionId.Matches, new ObjectId(id)); - return res.status(200).send(match); - } - }); - - findReportById = ApiLib.createRoute<[string], Report | undefined, ApiDependencies, void>({ - isAuthorized: AccessLevels.AlwaysAuthorized, - handler: async (req, res, { db: dbPromise }, authData, [id]) => { - const db = await dbPromise; - const report = await db.findObjectById(CollectionId.Reports, new ObjectId(id)); - return res.status(200).send(report); - } - }); - - findPitreportById = ApiLib.createRoute<[string], Pitreport | undefined, ApiDependencies, void>({ - isAuthorized: AccessLevels.AlwaysAuthorized, - handler: async (req, res, { db: dbPromise }, authData, [id]) => { - const db = await dbPromise; - const pitreport = await db.findObjectById(CollectionId.PitReports, new ObjectId(id)); - return res.status(200).send(pitreport); - } - }); - - updateUser = ApiLib.createRoute<[object], { result: string }, ApiDependencies, void>({ - isAuthorized: AccessLevels.IfSignedIn, - handler: async (req, res, { db: dbPromise, userPromise }, authData, [newValues]) => { - const db = await dbPromise; - const user = await userPromise; - - await db.updateObjectById(CollectionId.Users, new ObjectId(user?._id?.toString()), newValues); - return res.status(200).send({ result: "success" }); - } - }); - - updateTeam = ApiLib.createRoute<[object, string], { result: string }, ApiDependencies, Team>({ - isAuthorized: (req, res, deps, [newValues, teamId]) => AccessLevels.IfOnTeam(req, res, deps, teamId), - handler: async (req, res, { db: dbPromise, userPromise }, team, [newValues, teamId]) => { - const db = await dbPromise; - - await db.updateObjectById(CollectionId.Teams, new ObjectId(teamId), newValues); - return res.status(200).send({ result: "success" }); - } - }); - - updateSeason = ApiLib.createRoute<[object, string], { result: string }, ApiDependencies, { team: Team, season: Season }>({ - isAuthorized: (req, res, deps, [newValues, seasonId]) => AccessLevels.IfSeasonOwner(req, res, deps, seasonId), - handler: async (req, res, { db: dbPromise, userPromise }, { team, season }, [newValues, seasonId]) => { - const db = await dbPromise; - - await db.updateObjectById(CollectionId.Seasons, new ObjectId(seasonId), newValues); - return res.status(200).send({ result: "success" }); - } - }); - - updateReport = ApiLib.createRoute<[Partial, string], { result: string }, ApiDependencies, { team: Team, report: Report }>({ - isAuthorized: (req, res, deps, [newValues, reportId]) => AccessLevels.IfOnTeamThatOwnsReport(req, res, deps, reportId), - handler: async (req, res, { db: dbPromise, userPromise }, authData, [newValues, reportId]) => { - const db = await dbPromise; - - await db.updateObjectById(CollectionId.Reports, new ObjectId(reportId), newValues); - return res.status(200).send({ result: "success" }); - } - }); - - updatePitreport = ApiLib.createRoute<[string, object], { result: string }, ApiDependencies, { team: Team, comp: Competition }>({ - isAuthorized: (req, res, deps, [pitreportId]) => AccessLevels.IfOnTeamThatOwnsPitReport(req, res, deps, pitreportId), - handler: async (req, res, { db: dbPromise, userPromise }, { team, comp }, [pitreportId, newValues]) => { - const db = await dbPromise; - - await db.updateObjectById(CollectionId.PitReports, new ObjectId(pitreportId), newValues); - return res.status(200).send({ result: "success" }); - } - }); - - speedTest = ApiLib.createRoute< - [], - { - requestTime: number, - authTime: number, - insertTime: number, - findTime: number, - updateTime: number, - deleteTime: number, - responseTimestamp: number - }, - ApiDependencies, void - >( - { - isAuthorized: AccessLevels.AlwaysAuthorized, - handler: async (req, res, { userPromise, db: dbPromise }, authData, args) => { - const [requestTimestamp] = (args as unknown as [number]); - - const authStart = Date.now(); - const user = await userPromise; - if (!user || !isDeveloper(user.email)) - return res.status(403).send({ error: "Unauthorized" }); - - const resObj = { - requestTime: Math.max(Date.now() - requestTimestamp, 0), - authTime: Date.now() - authStart, - insertTime: 0, - findTime: 0, - updateTime: 0, - deleteTime: 0, - responseTimestamp: Date.now(), - } - - const db = await dbPromise; - - const testObject = { - _id: new ObjectId(), - } - const insertStart = Date.now(); - await db.addObject(CollectionId.Misc, testObject); - resObj.insertTime = Date.now() - insertStart; - - const findStart = Date.now(); - await db.findObjectById(CollectionId.Misc, testObject._id); - resObj.findTime = Date.now() - findStart; - - const updateStart = Date.now(); - await db.updateObjectById(CollectionId.Misc, testObject._id, {name: "test"}); - resObj.updateTime = Date.now() - updateStart; - - const deleteStart = Date.now(); - await db.deleteObjectById(CollectionId.Misc, testObject._id); - resObj.deleteTime = Date.now() - deleteStart; - - resObj.responseTimestamp = Date.now(); - return res.status(200).send(resObj); - } - }, - () => ApiLib.request("/speedTest", [Date.now()]).then((times) => ({ - ...times, - responseTime: Date.now() - times.responseTimestamp, - })) - ); + constructor() { + super(false); + this.init(); + } + + hello = ApiLib.createRoute< + [], + { message: string; db: string; data: any }, + ApiDependencies, + void + >({ + isAuthorized: AccessLevels.AlwaysAuthorized, + handler: async (req, res, { db }, authData, args) => { + res.status(200).send({ + message: "howdy there partner", + db: (await db) ? "connected" : "disconnected", + data: args, + }); + }, + }); + + requestToJoinTeam = ApiLib.createRoute< + [string], + { result: string }, + ApiDependencies, + void + >({ + isAuthorized: AccessLevels.IfSignedIn, + handler: async (req, res, { db, userPromise }, authData, [teamId]) => { + let team = await ( + await db + ).findObjectById(CollectionId.Teams, new ObjectId(teamId)); + + if (!team) { + return res.error(404, "Team not found"); + } + + if (team.users.indexOf((await userPromise)?._id?.toString() ?? "") > -1) { + return res.status(200).send({ result: "Already on team" }); + } + + team.requests = removeDuplicates([ + ...team.requests, + (await userPromise)?._id?.toString(), + ]); + + await ( + await db + ).updateObjectById(CollectionId.Teams, new ObjectId(teamId), team); + + return res.status(200).send({ result: "Success" }); + }, + }); + + handleTeamJoinRequest = ApiLib.createRoute< + [boolean, string, string], + Team, + ApiDependencies, + void + >({ + isAuthorized: AccessLevels.IfSignedIn, + handler: async ( + req, + res, + { db: dbPromise, userPromise }, + authData, + [accept, teamId, userId], + ) => { + const db = await dbPromise; + + const teamPromise = db.findObjectById( + CollectionId.Teams, + new ObjectId(teamId.toString()), + ); + + const joineePromise = db.findObjectById( + CollectionId.Users, + new ObjectId(userId.toString()), + ); + + const userOnTeam = await userPromise; + const team = await teamPromise; + + if (!team) { + return res.error(404, "Team not found"); + } + + if (!ownsTeam(team, userOnTeam)) { + return res.error(403, "You do not own this team"); + } + + const joinee = await joineePromise; + + if (!joinee) { + return res.error(404, "User not found"); + } + + team.requests.splice(team.requests.indexOf(userId), 1); + + if (accept) { + team.users = removeDuplicates(...team.users, userId); + team.scouters = removeDuplicates(...team.scouters, userId); + + joinee.teams = removeDuplicates(...joinee.teams, teamId); + } + + await Promise.all([ + db.updateObjectById(CollectionId.Users, new ObjectId(userId), joinee), + db.updateObjectById(CollectionId.Teams, new ObjectId(teamId), team), + ]); + + return res.status(200).send(team); + }, + }); + + getTeamAutofillData = ApiLib.createRoute< + [number, League], + Team | undefined, + ApiDependencies, + void + >({ + isAuthorized: AccessLevels.AlwaysAuthorized, + handler: async (req, res, { tba }, authData, [number, league]) => { + if (number <= 0) { + return res.status(200).send(undefined); + } + + res + .status(200) + .send( + league === League.FTC + ? await TheOrangeAlliance.getTeam(number) + : await tba.getTeamAutofillData(number), + ); + }, + }); + + competitionAutofill = ApiLib.createRoute< + [string], + Competition | undefined, + ApiDependencies, + void + >({ + isAuthorized: AccessLevels.AlwaysAuthorized, + handler: async (req, res, { tba }, authData, [tbaId]) => { + res.status(200).send(await tba.getCompetitionAutofillData(tbaId)); + }, + }); + + competitionMatches = ApiLib.createRoute< + [string], + Match | undefined, + ApiDependencies, + void + >({ + isAuthorized: AccessLevels.AlwaysAuthorized, + handler: async (req, res, { tba }, authData, [tbaId]) => { + res.status(200).send(await tba.getMatchAutofillData(tbaId)); + }, + }); + + createTeam = ApiLib.createRoute< + [string, string, number, League], + Team | undefined, + ApiDependencies, + void + >({ + isAuthorized: AccessLevels.IfSignedIn, + handler: async ( + req, + res, + { db: dbPromise, resend, userPromise }, + authData, + [name, tbaId, number, league], + ) => { + const user = (await userPromise)!; + const db = await dbPromise; + + // Find if team already exists + const existingTeam = await db.findObject(CollectionId.Teams, { + number, + ...(league === League.FRC + ? { $or: [{ league: League.FRC }, { league: undefined }] } + : { league: league }), + }); + + if (existingTeam) { + return res.error(400, "Team already exists"); + } + + const newTeamObj = new Team( + name, + await GenerateSlug(db, CollectionId.Teams, name), + tbaId, + number, + league, + [user._id!.toString()], + [user._id!.toString()], + [user._id!.toString()], + ); + const team = await db.addObject(CollectionId.Teams, newTeamObj); + + user.teams = removeDuplicates(...user.teams, team._id!.toString()); + user.owner = removeDuplicates(...user.owner, team._id!.toString()); + + await db.updateObjectById( + CollectionId.Users, + new ObjectId(user._id?.toString()), + user, + ); + + resend.emailDevelopers( + `New team created: ${team.name}`, + `A new team has been created by ${user.name}: ${team.league} ${team.number}, ${team.name}.`, + ); + + if (process.env.FILL_TEAMS === "true") { + fillTeamWithFakeUsers(20, team._id.toString(), db); + } + + return res.status(200).send(team); + }, + }); + + createSeason = ApiLib.createRoute< + [string, number, string, GameId], + Season, + ApiDependencies, + Team + >({ + isAuthorized: (req, res, deps, [name, year, teamId]) => + AccessLevels.IfTeamOwner(req, res, deps, teamId), + handler: async ( + req, + res, + { db: dbPromise, userPromise }, + team, + [name, year, teamId, gameId], + ) => { + const db = await dbPromise; + + if (!ownsTeam(team, await userPromise)) { + return res.status(403).send({ error: "Unauthorized" }); + } + + const season = await db.addObject( + CollectionId.Seasons, + new Season( + name, + await GenerateSlug(db, CollectionId.Seasons, name), + year, + gameId, + ), + ); + team!.seasons = [...team!.seasons, String(season._id)]; + + await db.updateObjectById( + CollectionId.Teams, + new ObjectId(teamId), + team!, + ); + + return res.status(200).send(season); + }, + }); + + reloadCompetition = ApiLib.createRoute< + [string], + { result: string }, + ApiDependencies, + { comp: Competition; team: Team } + >({ + isAuthorized: (req, res, deps, [compId]) => + AccessLevels.IfCompOwner(req, res, deps, compId), + handler: async ( + req, + res, + { db: dbPromise, tba }, + { comp, team }, + [compId], + ) => { + const matches = await tba.getCompetitionMatches(comp.tbaId!); + if (!matches || matches.length <= 0) { + res.status(200).send({ result: "none" }); + return; + } + + const db = await dbPromise; + + matches.map( + async (match) => (await db.addObject(CollectionId.Matches, match))._id, + ); + + if (!comp.tbaId || comp.tbaId === NotLinkedToTba) { + return res.status(200).send({ result: "not linked to TBA" }); + } + + const pitReports = await generatePitReports( + tba, + db, + comp.tbaId, + comp.gameId, + ); + + await db.updateObjectById( + CollectionId.Competitions, + new ObjectId(compId), + { + matches: matches.map((match) => String(match._id)), + pitReports: pitReports, + }, + ); + res.status(200).send({ result: "success" }); + }, + }); + + createCompetition = ApiLib.createRoute< + [string, number, number, string, string, boolean], + Competition, + ApiDependencies, + { team: Team; season: Season } + >({ + isAuthorized: (req, res, deps, [tbaId, start, end, name, seasonId]) => + AccessLevels.IfSeasonOwner(req, res, deps, seasonId), + handler: async ( + req, + res, + { db: dbPromise, tba }, + { team, season }, + [tbaId, start, end, name, seasonId, publicData], + ) => { + const db = await dbPromise; + + const matches = await tba.getCompetitionMatches(tbaId); + matches.map( + async (match) => (await db.addObject(CollectionId.Matches, match))._id, + ); + + const pitReports = await generatePitReports( + tba, + db, + tbaId, + season.gameId, + ); + + const picklist = await db.addObject(CollectionId.Picklists, { + picklists: {}, + }); + + const comp = await db.addObject( + CollectionId.Competitions, + new Competition( + name, + await GenerateSlug(db, CollectionId.Competitions, name), + tbaId, + start, + end, + pitReports, + matches.map((match) => String(match._id)), + picklist._id.toString(), + publicData, + season?.gameId, + ), + ); + + season.competitions = [...season.competitions, String(comp._id)]; + + await db.updateObjectById( + CollectionId.Seasons, + new ObjectId(season._id), + season, + ); + + // Create reports + const reportCreationPromises = matches.map((match) => + generateReportsForMatch(db, match, comp.gameId), + ); + await Promise.all(reportCreationPromises); + + return res.status(200).send(comp); + }, + }); + + createMatch = ApiLib.createRoute< + [string, number, number, MatchType, Alliance, Alliance], + Match, + ApiDependencies, + { team: Team; comp: Competition } + >({ + isAuthorized: (req, res, deps, [compId]) => + AccessLevels.IfCompOwner(req, res, deps, compId), + handler: async ( + req, + res, + { db: dbPromise, userPromise }, + { team, comp }, + [compId, number, time, type, redAlliance, blueAlliance], + ) => { + const db = await dbPromise; + + const match = await db.addObject( + CollectionId.Matches, + new Match( + number, + await GenerateSlug(db, CollectionId.Matches, number.toString()), + undefined, + time, + type, + blueAlliance, + redAlliance, + ), + ); + comp.matches.push(match._id ? String(match._id) : ""); + + const reportPromise = generateReportsForMatch(db, match, comp.gameId); + + await Promise.all([ + db.updateObjectById( + CollectionId.Competitions, + new ObjectId(comp._id), + comp, + ), + reportPromise, + ]); + + return res.status(200).send(match); + }, + }); + + searchCompetitionByName = ApiLib.createRoute< + [string], + { value: number; pair: CompetitonNameIdPair }[], + ApiDependencies, + void + >({ + isAuthorized: AccessLevels.AlwaysAuthorized, + handler: async (req, res, { tba }, authData, [name]) => { + res.status(200).send(await tba.searchCompetitionByName(name)); + }, + }); + + assignScouters = ApiLib.createRoute< + [string, boolean], + { result: string }, + ApiDependencies, + { team: Team; comp: Competition } + >({ + isAuthorized: (req, res, deps, [compId]) => + AccessLevels.IfCompOwner(req, res, deps, compId), + handler: async ( + req, + res, + { db: dbPromise, userPromise }, + { team, comp }, + [compId, shuffle], + ) => { + const db = await dbPromise; + + if (!team?._id) return res.status(400).send({ error: "Team not found" }); + + const result = await AssignScoutersToCompetitionMatches( + db, + team?._id?.toString(), + compId, + ); + + console.log(result); + return res.status(200).send({ result: result }); + }, + }); + + submitForm = ApiLib.createRoute< + [string, QuantData], + { result: string }, + ApiDependencies, + { team: Team; report: Report } + >({ + isAuthorized: (req, res, deps, [reportId]) => + AccessLevels.IfOnTeamThatOwnsReport(req, res, deps, reportId), + handler: async ( + req, + res, + { db: dbPromise, userPromise }, + { team, report }, + [reportId, formData], + ) => { + const db = await dbPromise; + + const user = await userPromise; + if (!onTeam(team, user) || !user?._id) + return res.status(403).send({ error: "Unauthorized" }); + + report.data = formData; + report.submitted = true; + report.submitter = user._id.toString(); + + await db.updateObjectById( + CollectionId.Reports, + new ObjectId(reportId), + report, + ); + + addXp(db, user._id.toString(), 10); + + await db.updateObjectById( + CollectionId.Users, + new ObjectId(user._id.toString()), + user, + ); + + return res.status(200).send({ result: "success" }); + }, + }); + + competitionReports = ApiLib.createRoute< + [string, boolean, boolean], + Report[], + ApiDependencies, + { team: Team; comp: Competition } + >({ + isAuthorized: (req, res, deps, [compId]) => + AccessLevels.IfOnTeamThatOwnsComp(req, res, deps, compId), + handler: async ( + req, + res, + { db: dbPromise, userPromise }, + { team, comp }, + [compId, submitted, usePublicData], + ) => { + const db = await dbPromise; + + const usedComps = + usePublicData && comp.tbaId !== NotLinkedToTba + ? await db.findObjects(CollectionId.Competitions, { + publicData: true, + tbaId: comp.tbaId, + gameId: comp.gameId, + }) + : [comp]; + + if (usePublicData && !comp.publicData) usedComps.push(comp); + + const reports = ( + await db.findObjects(CollectionId.Reports, { + match: { $in: usedComps.flatMap((m) => m.matches) }, + submitted: submitted ? true : { $exists: true }, + }) + ) + // Filter out comments from other competitions + .map((report) => + comp.matches.includes(report.match) + ? report + : { ...report, data: { ...report.data, comments: "" } }, + ); + return res.status(200).send(reports); + }, + }); + + allCompetitionMatches = ApiLib.createRoute< + [string], + Match[], + ApiDependencies, + { team: Team; comp: Competition } + >({ + isAuthorized: (req, res, deps, [compId]) => + AccessLevels.IfOnTeamThatOwnsComp(req, res, deps, compId), + handler: async ( + req, + res, + { db: dbPromise, userPromise }, + { team, comp }, + [compId], + ) => { + const db = await dbPromise; + + const matches = await db.findObjects(CollectionId.Matches, { + _id: { $in: comp.matches.map((matchId) => new ObjectId(matchId)) }, + }); + return res.status(200).send(matches); + }, + }); + + matchReports = ApiLib.createRoute< + [string], + Report[], + ApiDependencies, + { team: Team; match: Match } + >({ + isAuthorized: (req, res, deps, [matchId]) => + AccessLevels.IfOnTeamThatOwnsMatch(req, res, deps, matchId), + handler: async ( + req, + res, + { db: dbPromise, userPromise }, + { team, match }, + [matchId], + ) => { + const db = await dbPromise; + + const reports = await db.findObjects(CollectionId.Reports, { + _id: { $in: match.reports.map((reportId) => new ObjectId(reportId)) }, + }); + return res.status(200).send(reports); + }, + }); + + changePFP = ApiLib.createRoute< + [string], + { result: string }, + ApiDependencies, + void + >({ + isAuthorized: AccessLevels.IfSignedIn, + handler: async ( + req, + res, + { db: dbPromise, userPromise }, + authData, + [newImage], + ) => { + const db = await dbPromise; + const user = await userPromise; + + if (!user?._id) return res.status(403).send({ error: "Unauthorized" }); + + await db.updateObjectById(CollectionId.Users, new ObjectId(user._id), { + image: newImage, + }); + + return res.status(200).send({ result: "success" }); + }, + }); + + checkInForReport = ApiLib.createRoute< + [string], + { result: string }, + ApiDependencies, + { team: Team; report: Report } + >({ + isAuthorized: (req, res, deps, [reportId]) => + AccessLevels.IfOnTeamThatOwnsReport(req, res, deps, reportId), + handler: async ( + req, + res, + { db: dbPromise, userPromise }, + { team, report }, + [reportId], + ) => { + const db = await dbPromise; + + await db.updateObjectById(CollectionId.Reports, new ObjectId(reportId), { + checkInTimestamp: new Date().toISOString(), + }); + + return res.status(200).send({ result: "success" }); + }, + }); + + checkInForSubjectiveReport = ApiLib.createRoute< + [string], + { result: string }, + ApiDependencies, + { match: Match } + >({ + isAuthorized: (req, res, deps, [matchId]) => + AccessLevels.IfOnTeamThatOwnsMatch(req, res, deps, matchId), + handler: async ( + req, + res, + { db: dbPromise, userPromise }, + { match }, + [matchId], + ) => { + const db = await dbPromise; + + const user = await userPromise; + + const update: { [key: string]: any } = {}; + update[`subjectiveReportsCheckInTimestamps.${user?._id?.toString()}`] = + new Date().toISOString(); + await db.updateObjectById( + CollectionId.Matches, + new ObjectId(matchId), + update, + ); + + return res.status(200).send({ result: "success" }); + }, + }); + + remindSlack = ApiLib.createRoute< + [string, string], + { result: string }, + ApiDependencies, + Team + >({ + isAuthorized: (req, res, deps, [teamId]) => + AccessLevels.IfTeamOwner(req, res, deps, teamId), + handler: async ( + req, + res, + { db: dbPromise, slackClient, userPromise }, + team, + [teamId, targetUserId], + ) => { + const db = await dbPromise; + const targetUserPromise = db.findObjectById( + CollectionId.Users, + new ObjectId(targetUserId), + ); + + if (!team.slackWebhook) throw new SlackNotLinkedError(res); + + const webhookHolder = await db.findObjectById( + CollectionId.Webhooks, + new ObjectId(team.slackWebhook), + ); + if (!webhookHolder) throw new SlackNotLinkedError(res); + + const user = (await userPromise)!; + const targetUser = await targetUserPromise; + + if ( + !targetUser || + team.users.indexOf(targetUser._id?.toString() ?? "") === -1 + ) { + return res.status(400).send({ error: "User not found" }); + } + + await slackClient.sendMsg( + webhookHolder.url, + `${mentionUserInSlack(targetUser)}, please report to our section and prepare for scouting. Sent by ${mentionUserInSlack(user)}.`, + ); + + return res.status(200).send({ result: "success" }); + }, + }); + + setSlackWebhook = ApiLib.createRoute< + [string, string], + { result: string }, + ApiDependencies, + Team + >({ + isAuthorized: (req, res, deps, [teamId]) => + AccessLevels.IfTeamOwner(req, res, deps, teamId), + handler: async ( + req, + res, + { db, userPromise, slackClient }, + team, + [teamId, webhookUrl], + ) => { + const user = (await userPromise)!; + + // Check that the webhook works + slackClient + .sendMsg( + webhookUrl, + `Gearbox integration for ${team.name} was added by ${mentionUserInSlack(user)}.`, + ) + .catch(() => { + return res.status(400).send({ error: "Invalid webhook" }); + }); + + if (team.slackWebhook) { + (await db).updateObjectById( + CollectionId.Webhooks, + new ObjectId(team.slackWebhook), + { url: webhookUrl }, + ); + } else { + const webhook = await ( + await db + ).addObject(CollectionId.Webhooks, { url: webhookUrl }); + team.slackWebhook = webhook._id.toString(); + (await db).updateObjectById( + CollectionId.Teams, + new ObjectId(teamId), + team, + ); + } + + return res.status(200).send({ result: "success" }); + }, + }); + + setSlackId = ApiLib.createRoute< + [string], + { result: string }, + ApiDependencies, + void + >({ + isAuthorized: AccessLevels.IfSignedIn, + handler: async ( + req, + res, + { db: dbPromise, userPromise }, + authData, + [slackId], + ) => { + const db = await dbPromise; + const user = await userPromise; + + if (!user?._id) return res.status(403).send({ error: "Unauthorized" }); + + await db.updateObjectById(CollectionId.Users, new ObjectId(user._id), { + slackId: slackId, + }); + + return res.status(200).send({ result: "success" }); + }, + }); + + initialEventData = ApiLib.createRoute< + [string], + { + firstRanking: TheBlueAlliance.SimpleRank[]; + comp: Competition; + oprRanking: TheBlueAlliance.OprRanking; + }, + ApiDependencies, + void + >({ + isAuthorized: AccessLevels.AlwaysAuthorized, + handler: async (req, res, { tba }, authData, [eventKey]) => { + const compRankingsPromise = tba.req.getCompetitonRanking(eventKey); + const eventInformationPromise = tba.getCompetitionAutofillData(eventKey); + const tbaOPRPromise = tba.req.getCompetitonOPRS(eventKey); + + return res.status(200).send({ + firstRanking: (await compRankingsPromise).rankings, + comp: await eventInformationPromise, + oprRanking: await tbaOPRPromise, + }); + }, + }); + + compRankings = ApiLib.createRoute< + [string], + TheBlueAlliance.SimpleRank[], + ApiDependencies, + void + >({ + isAuthorized: AccessLevels.AlwaysAuthorized, + handler: async (req, res, { tba }, authData, [tbaId]) => { + const compRankings = await tba.req.getCompetitonRanking(tbaId); + return res.status(200).send(compRankings.rankings); + }, + }); + + statboticsTeamEvent = ApiLib.createRoute< + [string, string], + Statbotics.TeamEvent, + ApiDependencies, + void + >({ + isAuthorized: AccessLevels.AlwaysAuthorized, + handler: async (req, res, { tba }, authData, [eventKey, team]) => { + const teamEvent = await Statbotics.getTeamEvent(eventKey, team); + return res.status(200).send(teamEvent); + }, + }); + + getMainPageCounterData = ApiLib.createRoute< + [], + { + teams: number | undefined; + users: number | undefined; + datapoints: number | undefined; + competitions: number | undefined; + }, + ApiDependencies, + void + >({ + isAuthorized: AccessLevels.AlwaysAuthorized, + handler: async (req, res, { db: dbPromise }, authData, args) => { + const db = await dbPromise; + + const teamsPromise = db.countObjects(CollectionId.Teams, {}); + const usersPromise = db.countObjects(CollectionId.Users, {}); + const reportsPromise = db.countObjects(CollectionId.Reports, {}); + const pitReportsPromise = db.countObjects(CollectionId.PitReports, {}); + const subjectiveReportsPromise = db.countObjects( + CollectionId.SubjectiveReports, + {}, + ); + const competitionsPromise = db.countObjects( + CollectionId.Competitions, + {}, + ); + + const dataPointsPerReport = Reflect.ownKeys(QuantData).length; + const dataPointsPerPitReports = Reflect.ownKeys(Pitreport).length; + const dataPointsPerSubjectiveReport = + Reflect.ownKeys(SubjectiveReport).length + 5; + + await Promise.all([ + teamsPromise, + usersPromise, + reportsPromise, + pitReportsPromise, + subjectiveReportsPromise, + competitionsPromise, + ]); + + return res.status(200).send({ + teams: await teamsPromise, + users: await usersPromise, + datapoints: + ((await reportsPromise) ?? 0) * dataPointsPerReport + + ((await pitReportsPromise) ?? 0) * dataPointsPerPitReports + + ((await subjectiveReportsPromise) ?? 0) * + dataPointsPerSubjectiveReport, + competitions: await competitionsPromise, + }); + }, + }); + + exportCompAsCsv = ApiLib.createRoute< + [string], + { csv: string }, + ApiDependencies, + { team: Team; comp: Competition } + >({ + isAuthorized: (req, res, deps, [compId]) => + AccessLevels.IfCompOwner(req, res, deps, compId), + handler: async ( + req, + res, + { db: dbPromise, userPromise }, + { team, comp }, + [compId], + ) => { + const db = await dbPromise; + + const matches = await db.findObjects(CollectionId.Matches, { + _id: { $in: comp.matches.map((matchId) => new ObjectId(matchId)) }, + }); + const allReports = await db.findObjects(CollectionId.Reports, { + match: { $in: matches.map((match) => match?._id?.toString()) }, + }); + const reports = allReports.filter((report) => report.submitted); + + if (reports.length == 0) { + return res + .status(200) + .send({ error: "No reports found for competition" }); + } + + interface Row extends QuantData { + timestamp: number | undefined; + team: number; + } + + const rows: Row[] = []; + for (const report of reports) { + const row = { + ...report.data, + timestamp: report.timestamp, + team: report.robotNumber, + }; + rows.push(row); + } + + const headers = Object.keys(rows[0]); + + let csv = headers.join(",") + "\n"; + + for (const row of rows) { + const data = headers.map((header) => row[header]); + + csv += data.join(",") + "\n"; + } + + res.status(200).send({ csv }); + }, + }); + + teamCompRanking = ApiLib.createRoute< + [string, number], + { place: number | string; max: number | string }, + ApiDependencies, + void + >({ + isAuthorized: AccessLevels.AlwaysAuthorized, + handler: async (req, res, { tba }, authData, [tbaId, team]) => { + const tbaResult = await tba.req.getCompetitonRanking(tbaId); + if (!tbaResult || !tbaResult.rankings) { + return res.status(200).send({ place: "?", max: "?" }); + } + + const { rankings } = tbaResult; + + const rank = rankings?.find( + (ranking) => ranking.team_key === `frc${team}`, + )?.rank; + + if (!rank) { + return res.status(200).send({ + place: "?", + max: rankings?.length, + }); + } + + return res.status(200).send({ + place: + rankings?.find((ranking) => ranking.team_key === `frc${team}`) + ?.rank ?? "?", + max: rankings?.length, + }); + }, + }); + + getPitReports = ApiLib.createRoute< + [string], + Pitreport[], + ApiDependencies, + { team: Team; comp: Competition } + >({ + isAuthorized: (req, res, deps, [compId]) => + AccessLevels.IfOnTeamThatOwnsComp(req, res, deps, compId), + handler: async ( + req, + res, + { db: dbPromise, userPromise }, + { team, comp }, + [compId], + ) => { + const db = await dbPromise; + + const pitReports = await db.findObjects( + CollectionId.PitReports, + { + _id: { $in: comp.pitReports.map((id) => new ObjectId(id)) }, + }, + ); + + return res.status(200).send(pitReports); + }, + }); + + changeScouterForReport = ApiLib.createRoute< + [string, string], + { result: string }, + ApiDependencies, + any + >({ + isAuthorized: (req, res, deps, [reportId]) => + AccessLevels.IfReportOwner(req, res, deps, reportId), + handler: async ( + req, + res, + { db: dbPromise, userPromise }, + authData, + [reportId, scouterId], + ) => { + const db = await dbPromise; + + await db.updateObjectById(CollectionId.Reports, new ObjectId(reportId), { + user: scouterId, + }); + + return res.status(200).send({ result: "success" }); + }, + }); + + getCompReports = ApiLib.createRoute< + [string], + Report[], + ApiDependencies, + { team: Team; comp: Competition } + >({ + isAuthorized: (req, res, deps, [compId]) => + AccessLevels.IfOnTeamThatOwnsComp(req, res, deps, compId), + handler: async ( + req, + res, + { db: dbPromise, userPromise }, + { team, comp }, + [compId], + ) => { + const db = await dbPromise; + + const reports = await db.findObjects(CollectionId.Reports, { + match: { $in: comp.matches }, + }); + + return res.status(200).send(reports); + }, + }); + + findScouterManagementData = ApiLib.createRoute< + [string], + { + scouters: User[]; + matches: Match[]; + quantitativeReports: Report[]; + pitReports: Pitreport[]; + subjectiveReports: SubjectiveReport[]; + }, + ApiDependencies, + { team: Team; comp: Competition } + >({ + isAuthorized: (req, res, deps, [compId]) => + AccessLevels.IfCompOwner(req, res, deps, compId), + handler: async ( + req, + res, + { db: dbPromise, userPromise }, + { team, comp }, + [compId], + ) => { + const db = await dbPromise; + + const promises: Promise[] = []; + + const scouters: User[] = []; + const matches: Match[] = []; + const quantitativeReports: Report[] = []; + const pitReports: Pitreport[] = []; + const subjectiveReports: SubjectiveReport[] = []; + + for (const scouterId of team?.scouters) { + promises.push( + db + .findObjectById(CollectionId.Users, new ObjectId(scouterId)) + .then((scouter) => scouter && scouters.push(scouter)), + ); + } + + for (const matchId of comp.matches) { + promises.push( + db + .findObjectById(CollectionId.Matches, new ObjectId(matchId)) + .then((match) => match && matches.push(match)), + ); + } + + promises.push( + db + .findObjects(CollectionId.Reports, { + match: { $in: comp.matches }, + }) + .then((r) => quantitativeReports.push(...r)), + ); + + promises.push( + db + .findObjects(CollectionId.PitReports, { + _id: { $in: comp.pitReports.map((id) => new ObjectId(id)) }, + submitted: true, + }) + .then((r) => pitReports.push(...r)), + ); + + promises.push( + db + .findObjects(CollectionId.SubjectiveReports, { + match: { $in: comp.matches }, + }) + .then((r) => subjectiveReports.push(...r)), + ); + + await Promise.all(promises); + + return res + .status(200) + .send({ + scouters, + matches, + quantitativeReports, + pitReports, + subjectiveReports, + }); + }, + }); + + getPicklistFromComp = ApiLib.createRoute< + [string], + DbPicklist | undefined, + ApiDependencies, + { comp: Competition } + >({ + isAuthorized: (req, res, deps, [compId]) => + AccessLevels.IfOnTeamThatOwnsComp(req, res, deps, compId), + handler: async (req, res, { db: dbPromise }, { comp }, [compId]) => { + const db = await dbPromise; + + const picklist = await db.findObjectById( + CollectionId.Picklists, + new ObjectId(comp.picklist), + ); + + return res.status(200).send(picklist); + }, + }); + + getPicklist = ApiLib.createRoute< + [string], + DbPicklist | undefined, + ApiDependencies, + { picklist: DbPicklist } + >({ + isAuthorized: (req, res, deps, [picklistId]) => + AccessLevels.IfOnTeamThatOwnsPicklist(req, res, deps, picklistId), + handler: async (req, res, deps, { picklist }, [picklistId]) => { + return res.status(200).send(picklist); + }, + }); + + updatePicklist = ApiLib.createRoute< + [DbPicklist], + { result: string }, + ApiDependencies, + { picklist: DbPicklist } + >({ + isAuthorized: (req, res, deps, [picklist]) => + AccessLevels.IfOnTeamThatOwnsPicklist(req, res, deps, picklist._id), + handler: async ( + req, + res, + { db: dbPromise, userPromise }, + { picklist: oldPicklist }, + [newPicklist], + ) => { + const db = await dbPromise; + + const { _id, ...picklistData } = newPicklist; + await db.updateObjectById( + CollectionId.Picklists, + new ObjectId(oldPicklist._id), + picklistData, + ); + return res.status(200).send({ result: "success" }); + }, + }); + + setCompPublicData = ApiLib.createRoute< + [string, boolean], + { result: string }, + ApiDependencies, + { team: Team; comp: Competition } + >({ + isAuthorized: (req, res, deps, [compId]) => + AccessLevels.IfCompOwner(req, res, deps, compId), + handler: async ( + req, + res, + { db: dbPromise, userPromise }, + { team, comp }, + [compId, publicData], + ) => { + const db = await dbPromise; + + await db.updateObjectById( + CollectionId.Competitions, + new ObjectId(compId), + { publicData: publicData }, + ); + return res.status(200).send({ result: "success" }); + }, + }); + + setOnboardingCompleted = ApiLib.createRoute< + [string], + { result: string }, + ApiDependencies, + void + >({ + isAuthorized: AccessLevels.IfSignedIn, + handler: async ( + req, + res, + { db: dbPromise, userPromise }, + authData, + [userId], + ) => { + const db = await dbPromise; + const user = await userPromise; + if (!user?._id) return res.status(403).send({ error: "Unauthorized" }); + + await db.updateObjectById(CollectionId.Users, new ObjectId(user._id), { + onboardingComplete: true, + }); + return res.status(200).send({ result: "success" }); + }, + }); + + submitSubjectiveReport = ApiLib.createRoute< + [SubjectiveReport, string], + { result: string }, + ApiDependencies, + { team: Team; match: Match } + >({ + isAuthorized: (req, res, deps, [report, matchId]) => + AccessLevels.IfOnTeamThatOwnsMatch(req, res, deps, matchId), + handler: async ( + req, + res, + { db: dbPromise, userPromise }, + { team, match }, + [report], + ) => { + const db = await dbPromise; + const rawReport = report as SubjectiveReport; + + const user = await userPromise; + + if (!onTeam(team, user)) + return res.status(403).send({ error: "Unauthorized" }); + + const newReport: SubjectiveReport = { + ...report, + _id: new ObjectId().toString(), + submitter: user!._id!.toString(), + submitted: + match.subjectiveScouter === user!._id!.toString() + ? SubjectiveReportSubmissionType.ByAssignedScouter + : team!.subjectiveScouters.find( + (id) => id === user!._id!.toString(), + ) + ? SubjectiveReportSubmissionType.BySubjectiveScouter + : SubjectiveReportSubmissionType.ByNonSubjectiveScouter, + }; + + const update: Partial = { + subjectiveReports: [ + ...(match.subjectiveReports ?? []), + newReport._id!.toString(), + ], + }; + + if (match.subjectiveScouter === user!._id!.toString()) + update.assignedSubjectiveScouterHasSubmitted = true; + + const insertReportPromise = db.addObject( + CollectionId.SubjectiveReports, + newReport, + ); + const updateMatchPromise = db.updateObjectById( + CollectionId.Matches, + new ObjectId(match._id), + update, + ); + + addXp( + db, + user!._id!, + match.subjectiveScouter === user!._id!.toString() ? 10 : 5, + ); + + await Promise.all([insertReportPromise, updateMatchPromise]); + return res.status(200).send({ result: "success" }); + }, + }); + + getSubjectiveReportsForComp = ApiLib.createRoute< + [string], + SubjectiveReport[], + ApiDependencies, + { team: Team; comp: Competition } + >({ + isAuthorized: (req, res, deps, [compId]) => + AccessLevels.IfOnTeamThatOwnsComp(req, res, deps, compId), + handler: async ( + req, + res, + { db: dbPromise, userPromise }, + { team, comp }, + [compId], + ) => { + const db = await dbPromise; + + const reports = await db.findObjects( + CollectionId.SubjectiveReports, + { + match: { $in: comp.matches }, + }, + ); + + return res.status(200).send(reports); + }, + }); + + updateSubjectiveReport = ApiLib.createRoute< + [SubjectiveReport], + { result: string }, + ApiDependencies, + { report: SubjectiveReport } + >({ + isAuthorized: (req, res, deps, [report]) => + AccessLevels.IfOnTeamThatOwnsSubjectiveReport( + req, + res, + deps, + report._id?.toString() ?? "", + ), + handler: async ( + req, + res, + { db: dbPromise, userPromise }, + { report: oldRepor }, + [newReport], + ) => { + const db = await dbPromise; + + await db.updateObjectById( + CollectionId.SubjectiveReports, + new ObjectId(oldRepor._id), + newReport, + ); + return res.status(200).send({ result: "success" }); + }, + }); + + setSubjectiveScouterForMatch = ApiLib.createRoute< + [string, string], + { result: string }, + ApiDependencies, + { team: Team; match: Match } + >({ + isAuthorized: (req, res, deps, [matchId]) => + AccessLevels.IfMatchOwner(req, res, deps, matchId), + handler: async ( + req, + res, + { db: dbPromise }, + { team, match }, + [matchId, scouterId], + ) => { + const db = await dbPromise; + + const scouter = team?.users.find((id) => id === scouterId); + + if (!scouter) + return res.status(400).send({ error: "Scouter not on team" }); + + await db.updateObjectById(CollectionId.Matches, new ObjectId(matchId), { + subjectiveScouter: scouter, + }); + return res.status(200).send({ result: "success" }); + }, + }); + + regeneratePitReports = ApiLib.createRoute< + [string], + { result: string; pitReports: string[] }, + ApiDependencies, + { team: Team; comp: Competition } + >({ + isAuthorized: (req, res, deps, [compId]) => + AccessLevels.IfCompOwner(req, res, deps, compId), + handler: async ( + req, + res, + { db: dbPromise, tba, userPromise }, + { team, comp }, + [compId], + ) => { + const db = await dbPromise; + + if (!comp.tbaId) + return res.status(200).send({ error: "not linked to TBA" }); + + const pitReports = await generatePitReports( + tba, + db, + comp.tbaId, + comp.gameId, + ); + + await db.updateObjectById( + CollectionId.Competitions, + new ObjectId(compId), + { pitReports: pitReports }, + ); + + return res.status(200).send({ result: "success", pitReports }); + }, + }); + + createPitReportForTeam = ApiLib.createRoute< + [number, string], + { result: string }, + ApiDependencies, + { team: Team; comp: Competition } + >({ + isAuthorized: (req, res, deps, [teamNumber, compId]) => + AccessLevels.IfCompOwner(req, res, deps, compId), + handler: async ( + req, + res, + { db: dbPromise, userPromise }, + { team, comp }, + [teamNumber, compId], + ) => { + const db = await dbPromise; + + const pitReport = new Pitreport( + teamNumber, + games[comp.gameId].createPitReportData(), + ); + const pitReportId = ( + await db.addObject(CollectionId.PitReports, pitReport) + )._id?.toString(); + + if (!pitReportId) + return res.status(500).send({ error: "Failed to create pit report" }); + + comp.pitReports.push(pitReportId); + + await db.updateObjectById( + CollectionId.Competitions, + new ObjectId(compId), + { + pitReports: comp.pitReports, + }, + ); + + return res.status(200).send({ result: "success" }); + }, + }); + + updateCompNameAndTbaId = ApiLib.createRoute< + [string, string, string], + { result: string }, + ApiDependencies, + { team: Team; comp: Competition } + >({ + isAuthorized: (req, res, deps, [compId]) => + AccessLevels.IfCompOwner(req, res, deps, compId), + handler: async ( + req, + res, + { db: dbPromise, userPromise }, + { team, comp }, + [compId, name, tbaId], + ) => { + const db = await dbPromise; + + await db.updateObjectById( + CollectionId.Competitions, + new ObjectId(compId), + { + name, + tbaId, + }, + ); + + return res.status(200).send({ result: "success" }); + }, + }); + + getFtcTeamAutofillData = ApiLib.createRoute< + [number], + Team | undefined, + ApiDependencies, + void + >({ + isAuthorized: AccessLevels.AlwaysAuthorized, + handler: async (req, res, { tba }, authData, [teamNumber]) => { + const team = await TheOrangeAlliance.getTeam(teamNumber); + return res.status(200).send(team); + }, + }); + + ping = ApiLib.createRoute<[], { result: string }, ApiDependencies, void>({ + isAuthorized: AccessLevels.AlwaysAuthorized, + handler: async (req, res, authData, args) => { + return res.status(200).send({ result: "success" }); + }, + }); + + getSubjectiveReportsFromMatches = ApiLib.createRoute< + [string, Match[]], + SubjectiveReport[], + ApiDependencies, + { team: Team; comp: Competition } + >({ + isAuthorized: (req, res, deps, [compId]) => + AccessLevels.IfOnTeamThatOwnsComp(req, res, deps, compId), + handler: async ( + req, + res, + { db: dbPromise, userPromise }, + { team, comp }, + [compId, matches], + ) => { + const db = await dbPromise; + + for (const match of matches) { + if (!comp.matches.find((id) => id === match._id?.toString())) + return res.status(400).send({ error: "Match not in competition" }); + } + + const matchIds = matches.map((match) => match._id?.toString()); + const reports = await db.findObjects( + CollectionId.SubjectiveReports, + { + match: { $in: matchIds }, + }, + ); + + return res.status(200).send(reports); + }, + }); + + removeUserFromTeam = ApiLib.createRoute< + [string, string], + { result: string; team: Team }, + ApiDependencies, + Team + >({ + isAuthorized: (req, res, deps, [teamId]) => + AccessLevels.IfTeamOwner(req, res, deps, teamId), + handler: async ( + req, + res, + { db: dbPromise, userPromise }, + team, + [teamId, userId], + ) => { + const db = await dbPromise; + + const removedUserPromise = db.findObjectById( + CollectionId.Users, + new ObjectId(userId), + ); + + const newTeam: Team = { + ...team, + users: team.users.filter((id) => id !== userId), + owners: team.owners.filter((id) => id !== userId), + scouters: team.scouters.filter((id) => id !== userId), + subjectiveScouters: team.subjectiveScouters.filter( + (id) => id !== userId, + ), + }; + + const teamPromise = db.updateObjectById( + CollectionId.Teams, + new ObjectId(teamId), + newTeam, + ); + + const removedUser = await removedUserPromise; + if (!removedUser) + return res.status(404).send({ error: "User not found" }); + + const newUserData: User = { + ...removedUser, + teams: removedUser.teams.filter((id) => id !== teamId), + owner: removedUser.owner.filter((id) => id !== teamId), + }; + + await db.updateObjectById( + CollectionId.Users, + new ObjectId(userId), + newUserData, + ); + await teamPromise; + + return res.status(200).send({ result: "success", team: newTeam }); + }, + }); + + findUserById = ApiLib.createRoute< + [string], + User | undefined, + ApiDependencies, + void + >({ + isAuthorized: AccessLevels.AlwaysAuthorized, + handler: async (req, res, { db: dbPromise }, authData, [id]) => { + const db = await dbPromise; + const user = await db.findObjectById( + CollectionId.Users, + new ObjectId(id), + ); + return res.status(200).send(user); + }, + }); + + findTeamById = ApiLib.createRoute< + [string], + Team | undefined, + ApiDependencies, + void + >({ + isAuthorized: AccessLevels.AlwaysAuthorized, + handler: async (req, res, { db: dbPromise }, authData, [id]) => { + const db = await dbPromise; + const team = await db.findObjectById( + CollectionId.Teams, + new ObjectId(id), + ); + return res.status(200).send(team); + }, + }); + + findTeamByNumberAndLeague = ApiLib.createRoute< + [number, League], + Team | undefined, + ApiDependencies, + void + >({ + isAuthorized: AccessLevels.AlwaysAuthorized, + handler: async ( + req, + res, + { db: dbPromise }, + authData, + [number, league], + ) => { + const db = await dbPromise; + + const query = + league === League.FRC + ? { + number: number, + $or: [{ league: league }, { tbaId: { $exists: true } }], + } + : { number: number, league: league }; + + const team = await db.findObject(CollectionId.Teams, query); + + return res.status(200).send(team); + }, + }); + + findSeasonById = ApiLib.createRoute< + [string], + Season | undefined, + ApiDependencies, + void + >({ + isAuthorized: AccessLevels.AlwaysAuthorized, + handler: async (req, res, { db: dbPromise }, authData, [id]) => { + const db = await dbPromise; + const season = await db.findObjectById( + CollectionId.Seasons, + new ObjectId(id), + ); + return res.status(200).send(season); + }, + }); + + findCompetitionById = ApiLib.createRoute< + [string], + Competition | undefined, + ApiDependencies, + void + >({ + isAuthorized: AccessLevels.AlwaysAuthorized, + handler: async (req, res, { db: dbPromise }, authData, [id]) => { + const db = await dbPromise; + const competition = await db.findObjectById( + CollectionId.Competitions, + new ObjectId(id), + ); + return res.status(200).send(competition); + }, + }); + + findMatchById = ApiLib.createRoute< + [string], + Match | undefined, + ApiDependencies, + void + >({ + isAuthorized: AccessLevels.AlwaysAuthorized, + handler: async (req, res, { db: dbPromise }, authData, [id]) => { + const db = await dbPromise; + const match = await db.findObjectById( + CollectionId.Matches, + new ObjectId(id), + ); + return res.status(200).send(match); + }, + }); + + findReportById = ApiLib.createRoute< + [string], + Report | undefined, + ApiDependencies, + void + >({ + isAuthorized: AccessLevels.AlwaysAuthorized, + handler: async (req, res, { db: dbPromise }, authData, [id]) => { + const db = await dbPromise; + const report = await db.findObjectById( + CollectionId.Reports, + new ObjectId(id), + ); + return res.status(200).send(report); + }, + }); + + findPitreportById = ApiLib.createRoute< + [string], + Pitreport | undefined, + ApiDependencies, + void + >({ + isAuthorized: AccessLevels.AlwaysAuthorized, + handler: async (req, res, { db: dbPromise }, authData, [id]) => { + const db = await dbPromise; + const pitreport = await db.findObjectById( + CollectionId.PitReports, + new ObjectId(id), + ); + return res.status(200).send(pitreport); + }, + }); + + updateUser = ApiLib.createRoute< + [object], + { result: string }, + ApiDependencies, + void + >({ + isAuthorized: AccessLevels.IfSignedIn, + handler: async ( + req, + res, + { db: dbPromise, userPromise }, + authData, + [newValues], + ) => { + const db = await dbPromise; + const user = await userPromise; + + await db.updateObjectById( + CollectionId.Users, + new ObjectId(user?._id?.toString()), + newValues, + ); + return res.status(200).send({ result: "success" }); + }, + }); + + updateTeam = ApiLib.createRoute< + [object, string], + { result: string }, + ApiDependencies, + Team + >({ + isAuthorized: (req, res, deps, [newValues, teamId]) => + AccessLevels.IfOnTeam(req, res, deps, teamId), + handler: async ( + req, + res, + { db: dbPromise, userPromise }, + team, + [newValues, teamId], + ) => { + const db = await dbPromise; + + await db.updateObjectById( + CollectionId.Teams, + new ObjectId(teamId), + newValues, + ); + return res.status(200).send({ result: "success" }); + }, + }); + + updateSeason = ApiLib.createRoute< + [object, string], + { result: string }, + ApiDependencies, + { team: Team; season: Season } + >({ + isAuthorized: (req, res, deps, [newValues, seasonId]) => + AccessLevels.IfSeasonOwner(req, res, deps, seasonId), + handler: async ( + req, + res, + { db: dbPromise, userPromise }, + { team, season }, + [newValues, seasonId], + ) => { + const db = await dbPromise; + + await db.updateObjectById( + CollectionId.Seasons, + new ObjectId(seasonId), + newValues, + ); + return res.status(200).send({ result: "success" }); + }, + }); + + updateReport = ApiLib.createRoute< + [Partial, string], + { result: string }, + ApiDependencies, + { team: Team; report: Report } + >({ + isAuthorized: (req, res, deps, [newValues, reportId]) => + AccessLevels.IfOnTeamThatOwnsReport(req, res, deps, reportId), + handler: async ( + req, + res, + { db: dbPromise, userPromise }, + authData, + [newValues, reportId], + ) => { + const db = await dbPromise; + + await db.updateObjectById( + CollectionId.Reports, + new ObjectId(reportId), + newValues, + ); + return res.status(200).send({ result: "success" }); + }, + }); + + updatePitreport = ApiLib.createRoute< + [string, object], + { result: string }, + ApiDependencies, + { team: Team; comp: Competition } + >({ + isAuthorized: (req, res, deps, [pitreportId]) => + AccessLevels.IfOnTeamThatOwnsPitReport(req, res, deps, pitreportId), + handler: async ( + req, + res, + { db: dbPromise, userPromise }, + { team, comp }, + [pitreportId, newValues], + ) => { + const db = await dbPromise; + + await db.updateObjectById( + CollectionId.PitReports, + new ObjectId(pitreportId), + newValues, + ); + return res.status(200).send({ result: "success" }); + }, + }); + + speedTest = ApiLib.createRoute< + [], + { + requestTime: number; + authTime: number; + insertTime: number; + findTime: number; + updateTime: number; + deleteTime: number; + responseTimestamp: number; + }, + ApiDependencies, + void + >( + { + isAuthorized: AccessLevels.AlwaysAuthorized, + handler: async ( + req, + res, + { userPromise, db: dbPromise }, + authData, + args, + ) => { + const [requestTimestamp] = args as unknown as [number]; + + const authStart = Date.now(); + const user = await userPromise; + if (!user || !isDeveloper(user.email)) + return res.status(403).send({ error: "Unauthorized" }); + + const resObj = { + requestTime: Math.max(Date.now() - requestTimestamp, 0), + authTime: Date.now() - authStart, + insertTime: 0, + findTime: 0, + updateTime: 0, + deleteTime: 0, + responseTimestamp: Date.now(), + }; + + const db = await dbPromise; + + const testObject = { + _id: new ObjectId(), + }; + const insertStart = Date.now(); + await db.addObject(CollectionId.Misc, testObject); + resObj.insertTime = Date.now() - insertStart; + + const findStart = Date.now(); + await db.findObjectById(CollectionId.Misc, testObject._id); + resObj.findTime = Date.now() - findStart; + + const updateStart = Date.now(); + await db.updateObjectById(CollectionId.Misc, testObject._id, { + name: "test", + }); + resObj.updateTime = Date.now() - updateStart; + + const deleteStart = Date.now(); + await db.deleteObjectById(CollectionId.Misc, testObject._id); + resObj.deleteTime = Date.now() - deleteStart; + + resObj.responseTimestamp = Date.now(); + return res.status(200).send(resObj); + }, + }, + () => + ApiLib.request("/speedTest", [Date.now()]).then((times) => ({ + ...times, + responseTime: Date.now() - times.responseTimestamp, + })), + ); } diff --git a/lib/api/Errors.ts b/lib/api/Errors.ts index 1e5d4fed..ce4ebd3d 100644 --- a/lib/api/Errors.ts +++ b/lib/api/Errors.ts @@ -1,7 +1,7 @@ import ApiLib from "./ApiLib"; export class SlackNotLinkedError extends ApiLib.Errors.Error { - constructor(res: ApiLib.ApiResponse) { - super(res, 400, "Team has not provided a Slack webhook"); - } -} \ No newline at end of file + constructor(res: ApiLib.ApiResponse) { + super(res, 400, "Team has not provided a Slack webhook"); + } +} diff --git a/lib/api/ServerApi.ts b/lib/api/ServerApi.ts index 5ba87357..eb07c4aa 100644 --- a/lib/api/ServerApi.ts +++ b/lib/api/ServerApi.ts @@ -12,18 +12,24 @@ import ResendUtils from "../ResendUtils"; import SlackClient from "../SlackClient"; export default class ServerApi extends ApiLib.ServerApi { - constructor(clientApi?: ApiLib.ApiTemplate) { - super(clientApi ?? new ClientApi(), "/api/"); - } + constructor(clientApi?: ApiLib.ApiTemplate) { + super(clientApi ?? new ClientApi(), "/api/"); + } - getDependencies(req: NextApiRequest, res: ApiLib.ApiResponse): ApiDependencies - { - return { - db: getDatabase(), - tba: new TheBlueAlliance.Interface(), - slackClient: new SlackClient(), - userPromise: getServerSession(req, res.innerRes, AuthenticationOptions).then((s) => s?.user as User | undefined), - resend: new ResendUtils() - }; - } -} \ No newline at end of file + getDependencies( + req: NextApiRequest, + res: ApiLib.ApiResponse, + ): ApiDependencies { + return { + db: getDatabase(), + tba: new TheBlueAlliance.Interface(), + slackClient: new SlackClient(), + userPromise: getServerSession( + req, + res.innerRes, + AuthenticationOptions, + ).then((s) => s?.user as User | undefined), + resend: new ResendUtils(), + }; + } +} diff --git a/lib/client/Analytics.ts b/lib/client/Analytics.ts index be2c534a..44dfb1b1 100644 --- a/lib/client/Analytics.ts +++ b/lib/client/Analytics.ts @@ -1,159 +1,199 @@ import ReactGA from "react-ga4"; -import { League, User, Team } from '../Types'; +import { League, User, Team } from "../Types"; import { UaEventOptions } from "react-ga4/types/ga4"; import { GameId } from "./GameId"; import { PHASE_PRODUCTION_BUILD } from "next/dist/shared/lib/constants"; enum EventCategory { - User = "User", - Team = "Team", - Season = "Season", - Comp = "Competition", + User = "User", + Team = "Team", + Season = "Season", + Comp = "Competition", } /** * Event parameters must be added as custom dimensions in Google Analytics. Go to Admin > Custom definitions. */ type EventParams = UaEventOptions & { - teamNumber?: number; - username?: string; - league?: League; - gameId?: GameId; - compName?: string; - usePublicData?: boolean; - teamScouted?: number; - targetName?: string; -} - -if (process.env.NEXT_PHASE !== PHASE_PRODUCTION_BUILD && process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID !== undefined && process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID !== "") { - console.log("Initializing Google Analytics..."); - ReactGA.initialize(process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID); - console.log("Google Analytics initialized"); + teamNumber?: number; + username?: string; + league?: League; + gameId?: GameId; + compName?: string; + usePublicData?: boolean; + teamScouted?: number; + targetName?: string; +}; + +if ( + process.env.NEXT_PHASE !== PHASE_PRODUCTION_BUILD && + process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID !== undefined && + process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID !== "" +) { + console.log("Initializing Google Analytics..."); + ReactGA.initialize(process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID); + console.log("Google Analytics initialized"); } function triggerEvent(params: EventParams) { - if (!ReactGA._hasLoadedGA) - console.error("Google Analytics not loaded"); + if (!ReactGA._hasLoadedGA) console.error("Google Analytics not loaded"); - if (!ReactGA.isInitialized) - console.error("Google Analytics not initialized"); + if (!ReactGA.isInitialized) console.error("Google Analytics not initialized"); - console.log("Event triggered:", params); - const { action, ...options } = params; - ReactGA.event(action, options); + console.log("Event triggered:", params); + const { action, ...options } = params; + ReactGA.event(action, options); } export namespace Analytics { - export function onboardingCompleted(name: string, teamNumber: number, league: League) { - triggerEvent({ - category: EventCategory.User, - action: "onboarding_complete", - username: name, - teamNumber, - league - }); - } - - export function newSignUp(name: string) { - triggerEvent({ - category: EventCategory.User, - action: "new_sign_up", - username: name - }); - } - - export function signIn(name: string) { - triggerEvent({ - category: EventCategory.User, - action: "sign_in", - username: name - }); - } - - export function teamCreated(number: number, league: League, username: string) { - triggerEvent({ - category: EventCategory.Team, - action: "create_team", - teamNumber: number, - username, - league - }); - } - - export function requestedToJoinTeam(teamNumber: number, username: string) { - triggerEvent({ - category: EventCategory.Team, - action: "request_to_join_team", - teamNumber, - username - }); - } - - export function teamJoinRequestHandled(teamNumber: number, league: League, requesterName: string, doneByName: string, - accepted: boolean) { - triggerEvent({ - category: EventCategory.Team, - action: `team_join_request_${accepted ? "accepted" : "declined"}`, - label: accepted ? "accepted" : "declined", - teamNumber, - username: doneByName, - targetName: requesterName, - league - }); - } - - export function seasonCreated(gameId: GameId, teamNumber: number, username: string) { - triggerEvent({ - category: EventCategory.Season, - action: "create_season", - gameId, - teamNumber, - username - }); - } - - export function compCreated(compName: string, gameId: GameId, usePublicData: boolean, teamNumber: number, username: string) { - triggerEvent({ - category: EventCategory.Season, - action: "create_competition", - compName, - gameId, - usePublicData, - teamNumber, - username - }); - } - - export function quantReportSubmitted(teamScouted: number, teamNumber: number, compName: string, username: string) { - triggerEvent({ - category: EventCategory.Comp, - action: "submit_quantitative_report", - teamNumber, - username, - compName, - teamScouted - }); - } - - export function pitReportSubmitted(teamScouted: number, teamNumber: number, compName: string, username: string) { - triggerEvent({ - category: EventCategory.Comp, - action: "submit_pit_report", - teamNumber, - username, - compName, - teamScouted - }); - } - - export function subjectiveReportSubmitted(teamsWithComments: string[], teamNumber: number, compName: string, username: string) { - triggerEvent({ - category: EventCategory.Comp, - action: "submit_subjective_report", - label: teamsWithComments.join(", "), - teamNumber, - username, - compName - }); - } -} \ No newline at end of file + export function onboardingCompleted( + name: string, + teamNumber: number, + league: League, + ) { + triggerEvent({ + category: EventCategory.User, + action: "onboarding_complete", + username: name, + teamNumber, + league, + }); + } + + export function newSignUp(name: string) { + triggerEvent({ + category: EventCategory.User, + action: "new_sign_up", + username: name, + }); + } + + export function signIn(name: string) { + triggerEvent({ + category: EventCategory.User, + action: "sign_in", + username: name, + }); + } + + export function teamCreated( + number: number, + league: League, + username: string, + ) { + triggerEvent({ + category: EventCategory.Team, + action: "create_team", + teamNumber: number, + username, + league, + }); + } + + export function requestedToJoinTeam(teamNumber: number, username: string) { + triggerEvent({ + category: EventCategory.Team, + action: "request_to_join_team", + teamNumber, + username, + }); + } + + export function teamJoinRequestHandled( + teamNumber: number, + league: League, + requesterName: string, + doneByName: string, + accepted: boolean, + ) { + triggerEvent({ + category: EventCategory.Team, + action: `team_join_request_${accepted ? "accepted" : "declined"}`, + label: accepted ? "accepted" : "declined", + teamNumber, + username: doneByName, + targetName: requesterName, + league, + }); + } + + export function seasonCreated( + gameId: GameId, + teamNumber: number, + username: string, + ) { + triggerEvent({ + category: EventCategory.Season, + action: "create_season", + gameId, + teamNumber, + username, + }); + } + + export function compCreated( + compName: string, + gameId: GameId, + usePublicData: boolean, + teamNumber: number, + username: string, + ) { + triggerEvent({ + category: EventCategory.Season, + action: "create_competition", + compName, + gameId, + usePublicData, + teamNumber, + username, + }); + } + + export function quantReportSubmitted( + teamScouted: number, + teamNumber: number, + compName: string, + username: string, + ) { + triggerEvent({ + category: EventCategory.Comp, + action: "submit_quantitative_report", + teamNumber, + username, + compName, + teamScouted, + }); + } + + export function pitReportSubmitted( + teamScouted: number, + teamNumber: number, + compName: string, + username: string, + ) { + triggerEvent({ + category: EventCategory.Comp, + action: "submit_pit_report", + teamNumber, + username, + compName, + teamScouted, + }); + } + + export function subjectiveReportSubmitted( + teamsWithComments: string[], + teamNumber: number, + compName: string, + username: string, + ) { + triggerEvent({ + category: EventCategory.Comp, + action: "submit_subjective_report", + label: teamsWithComments.join(", "), + teamNumber, + username, + compName, + }); + } +} diff --git a/lib/client/ClientSocket.ts b/lib/client/ClientSocket.ts index abb00478..1a900f16 100644 --- a/lib/client/ClientSocket.ts +++ b/lib/client/ClientSocket.ts @@ -3,6 +3,6 @@ import io, { Socket } from "socket.io-client"; import { DefaultEventsMap } from "socket.io/dist/typed-events"; export async function ClientSocket() { - await fetch("/api/socket"); - return io({ path: "/api/socketio" }); + await fetch("/api/socket"); + return io({ path: "/api/socketio" }); } diff --git a/lib/client/ClientUtils.ts b/lib/client/ClientUtils.ts index 5f8f8966..6779e044 100644 --- a/lib/client/ClientUtils.ts +++ b/lib/client/ClientUtils.ts @@ -1,9 +1,11 @@ -export function getIdsInProgressFromTimestamps(timestamps: { [id: string]: string }) { - const now = Date.now(); - return Object.keys(timestamps).filter((id) => { - const timestamp = timestamps[id]; - return ((now - new Date(timestamp).getTime()) / 1000) < 10; - }); +export function getIdsInProgressFromTimestamps(timestamps: { + [id: string]: string; +}) { + const now = Date.now(); + return Object.keys(timestamps).filter((id) => { + const timestamp = timestamps[id]; + return (now - new Date(timestamp).getTime()) / 1000 < 10; + }); } export const NotLinkedToTba = "not-linked"; @@ -12,11 +14,11 @@ export const NotLinkedToTba = "not-linked"; * @tested_by tests/lib/client/ClientUtils.test.ts */ export function camelCaseToTitleCase(str: string) { - if (typeof str !== "string") return ""; + if (typeof str !== "string") return ""; - return str - .replace(/([A-Z])/g, " $1") - .replace(/^./, (str) => str.toUpperCase()); + return str + .replace(/([A-Z])/g, " $1") + .replace(/^./, (str) => str.toUpperCase()); } /** @@ -24,7 +26,7 @@ export function camelCaseToTitleCase(str: string) { * @returns true if the .env is set to force offline mode */ export function forceOfflineMode() { - return process.env.NEXT_PUBLIC_FORCE_OFFLINE_MODE === "true"; + return process.env.NEXT_PUBLIC_FORCE_OFFLINE_MODE === "true"; } /** @@ -32,26 +34,32 @@ export function forceOfflineMode() { * @returns a dictionary of the array with the _id as the key * @tested_by tests/lib/client/ClientUtils.test.ts */ -export function toDict(arr: TElement[]) { - const dict: { [_id: string]: TElement } = {}; - - arr.forEach((item) => { - if (item._id) { - dict[item._id] = item; - } - }); - - return dict; +export function toDict( + arr: TElement[], +) { + const dict: { [_id: string]: TElement } = {}; + + arr.forEach((item) => { + if (item._id) { + dict[item._id] = item; + } + }); + + return dict; } -export function download(filename: string, content: string, type: string = "text/plain") { - const blob = new Blob([content], { type }); - const url = window.URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - a.click(); - window.URL.revokeObjectURL(url); +export function download( + filename: string, + content: string, + type: string = "text/plain", +) { + const blob = new Blob([content], { type }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + window.URL.revokeObjectURL(url); } /** @@ -61,9 +69,9 @@ export function download(filename: string, content: string, type: string = "text * @tested_by tests/lib/client/ClientUtils.test.ts */ export function removeDuplicates(...arr: any[]) { - arr = arr.map((a) => Array.isArray(a) ? removeDuplicates(...a) : a).flat(); + arr = arr.map((a) => (Array.isArray(a) ? removeDuplicates(...a) : a)).flat(); - return Array.from(new Set(arr)); + return Array.from(new Set(arr)); } /** @@ -72,7 +80,7 @@ export function removeDuplicates(...arr: any[]) { * @returns - A random value from the supplied array */ export function randomArrayValue(array: any[]): any { - return array[Math.floor(Math.random() * array.length)]; + return array[Math.floor(Math.random() * array.length)]; } /** @@ -81,13 +89,13 @@ export function randomArrayValue(array: any[]): any { * @returns - The shuffled array */ export function shuffleArray(array: any[]) { - for (var i = array.length - 1; i > 0; i--) { - var j = Math.floor(Math.random() * (i + 1)); - var temp = array[i]; - array[i] = array[j]; - array[j] = temp; - } - return array; + for (var i = array.length - 1; i > 0; i--) { + var j = Math.floor(Math.random() * (i + 1)); + var temp = array[i]; + array[i] = array[j]; + array[j] = temp; + } + return array; } /** @@ -97,16 +105,16 @@ export function shuffleArray(array: any[]) { * @tested_by tests/lib/client/ClientUtils.test.ts */ export function rotateArray(array: any[]) { - return array.push(array.shift()); + return array.push(array.shift()); } /** * Gets around "... cannot be serialized as JSON" error by converting the object to a string and back to an object - * + * * @returns a clone of the object that is serializable */ export function makeObjSerializeable(obj: Object) { - return JSON.parse(JSON.stringify(obj)); + return JSON.parse(JSON.stringify(obj)); } /** @@ -116,15 +124,21 @@ export function makeObjSerializeable(obj: Object) { * @tested_by tests/lib/client/ClientUtils.test.ts */ export function removeWhitespaceAndMakeLowerCase(str: string): string { - return str.replace(/\s/g, "").toLowerCase(); + return str.replace(/\s/g, "").toLowerCase(); } /** -* @tested_by tests/lib/clien/tClientUtils.test.ts -*/ -export function promisify(func: (...args: any[]) => TReturn): (...args: any[]) => Promise { - return (...args: any[]) => - new Promise((resolve, reject) => { - func(...args, (val: TReturn) => resolve(val), (err: any) => reject(err)); - }); -} \ No newline at end of file + * @tested_by tests/lib/clien/tClientUtils.test.ts + */ +export function promisify( + func: (...args: any[]) => TReturn, +): (...args: any[]) => Promise { + return (...args: any[]) => + new Promise((resolve, reject) => { + func( + ...args, + (val: TReturn) => resolve(val), + (err: any) => reject(err), + ); + }); +} diff --git a/lib/client/CollectionId.ts b/lib/client/CollectionId.ts index 74a9b76e..c1051ad9 100644 --- a/lib/client/CollectionId.ts +++ b/lib/client/CollectionId.ts @@ -1,38 +1,64 @@ -import { Season, Competition, Match, SubjectiveReport, Team, Report, User, Account, Session, Pitreport, DbPicklist, WebhookHolder, } from "../Types"; +import { + Season, + Competition, + Match, + SubjectiveReport, + Team, + Report, + User, + Account, + Session, + Pitreport, + DbPicklist, + WebhookHolder, +} from "../Types"; enum CollectionId { - Seasons = "Seasons", - Competitions = "Competitions", - Matches = "Matches", - Reports = "Reports", - Teams = "Teams", - Users = "users", - Accounts = "accounts", - Sessions = "sessions", - Forms = "Forms", - PitReports = "Pitreports", - Picklists = "Picklists", - SubjectiveReports = "SubjectiveReports", - SlackInstallations = "SlackInstallations", - Webhooks = "Webhooks", - Misc = "Misc", + Seasons = "Seasons", + Competitions = "Competitions", + Matches = "Matches", + Reports = "Reports", + Teams = "Teams", + Users = "users", + Accounts = "accounts", + Sessions = "sessions", + Forms = "Forms", + PitReports = "Pitreports", + Picklists = "Picklists", + SubjectiveReports = "SubjectiveReports", + SlackInstallations = "SlackInstallations", + Webhooks = "Webhooks", + Misc = "Misc", } // We can't do export default enum CollectionId export default CollectionId; export type CollectionIdToType = - Id extends CollectionId.Seasons ? Season : - Id extends CollectionId.Competitions ? Competition : - Id extends CollectionId.Matches ? Match : - Id extends CollectionId.Reports ? Report : - Id extends CollectionId.Teams ? Team : - Id extends CollectionId.Users ? User : - Id extends CollectionId.Accounts ? Account : - Id extends CollectionId.Sessions ? Session : - Id extends CollectionId.PitReports ? Pitreport : - Id extends CollectionId.Picklists ? DbPicklist : - Id extends CollectionId.SubjectiveReports ? SubjectiveReport : - Id extends CollectionId.Webhooks ? WebhookHolder : - Id extends CollectionId.Misc ? any : - any; \ No newline at end of file + Id extends CollectionId.Seasons + ? Season + : Id extends CollectionId.Competitions + ? Competition + : Id extends CollectionId.Matches + ? Match + : Id extends CollectionId.Reports + ? Report + : Id extends CollectionId.Teams + ? Team + : Id extends CollectionId.Users + ? User + : Id extends CollectionId.Accounts + ? Account + : Id extends CollectionId.Sessions + ? Session + : Id extends CollectionId.PitReports + ? Pitreport + : Id extends CollectionId.Picklists + ? DbPicklist + : Id extends CollectionId.SubjectiveReports + ? SubjectiveReport + : Id extends CollectionId.Webhooks + ? WebhookHolder + : Id extends CollectionId.Misc + ? any + : any; diff --git a/lib/client/FormatTime.ts b/lib/client/FormatTime.ts index 669b0180..1482cf58 100644 --- a/lib/client/FormatTime.ts +++ b/lib/client/FormatTime.ts @@ -1,29 +1,29 @@ export const MonthNames = [ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", ]; export function MonthString(timestamp: number): string { - // returns Month Day, Year - const d = new Date(timestamp); - const day = d.getDay() > 0 ? ` ${d.getDay()}` : ""; - return `${MonthNames[d.getMonth()]}${day}, ${d.getFullYear()}`; + // returns Month Day, Year + const d = new Date(timestamp); + const day = d.getDay() > 0 ? ` ${d.getDay()}` : ""; + return `${MonthNames[d.getMonth()]}${day}, ${d.getFullYear()}`; } export function TimeString(timestamp: number): string { - return new Date(timestamp).toLocaleTimeString(); + return new Date(timestamp).toLocaleTimeString(); } export function DateString(timestamp: number): string { - return new Date(timestamp).toLocaleDateString("en-US") + return new Date(timestamp).toLocaleDateString("en-US"); } diff --git a/lib/client/GameId.ts b/lib/client/GameId.ts index 9d4c319c..4105da0a 100644 --- a/lib/client/GameId.ts +++ b/lib/client/GameId.ts @@ -1,7 +1,7 @@ export enum GameId { - Crescendo = "Crescendo", - CenterStage = "CenterStage", - IntoTheDeep = "IntoTheDeep", + Crescendo = "Crescendo", + CenterStage = "CenterStage", + IntoTheDeep = "IntoTheDeep", } -export const defaultGameId = GameId.Crescendo; \ No newline at end of file +export const defaultGameId = GameId.Crescendo; diff --git a/lib/client/InputVerification.ts b/lib/client/InputVerification.ts index d25a1e3a..0fff1475 100644 --- a/lib/client/InputVerification.ts +++ b/lib/client/InputVerification.ts @@ -6,27 +6,27 @@ export const MinimumNameLength = 3; * @tested_by tests/lib/client/InputValidation.test.ts */ export function validName(name: string, allowSpaces: boolean = false): boolean { - if (!name.match(allowSpaces ? ValidRegexWithSpaces : ValidRegex)) { - return false; - } + if (!name.match(allowSpaces ? ValidRegexWithSpaces : ValidRegex)) { + return false; + } - if (name.length < MinimumNameLength) { - return false; - } + if (name.length < MinimumNameLength) { + return false; + } - return true; + return true; } /** * @tested_by tests/lib/client/InputValidation.test.ts */ export function validEmail(email: string): boolean { - if ( - !email.match( - /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ - ) - ) { - return false; - } - return true; + if ( + !email.match( + /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, + ) + ) { + return false; + } + return true; } diff --git a/lib/client/StatsMath.ts b/lib/client/StatsMath.ts index 5271f848..40c86ad3 100644 --- a/lib/client/StatsMath.ts +++ b/lib/client/StatsMath.ts @@ -8,90 +8,113 @@ export const TrapPoints = 5; type Selector = ((r: T) => number) | (keyof T & string); -function getSelection(selector: Selector, report: Report) { - return typeof selector === "string" ? report.data[selector] : selector(report.data); +function getSelection( + selector: Selector, + report: Report, +) { + return typeof selector === "string" + ? report.data[selector] + : selector(report.data); } /** * Rounds to two decimal places - * + * * @tested_by tests/lib/client/StatsMath.test.ts */ export function Round(n: number): number { - return Math.round(n * 100) / 100; + return Math.round(n * 100) / 100; } /** * @tested_by tests/lib/client/StatsMath.test.ts */ export function StandardDeviation(numbers: number[]) { - const mean = numbers.reduce((a, b) => a + b, 0) / numbers.length; - const variance = numbers.reduce((a, b) => a + (b - mean) ** 2, 0) / numbers.length; - return Math.sqrt(variance); + const mean = numbers.reduce((a, b) => a + b, 0) / numbers.length; + const variance = + numbers.reduce((a, b) => a + (b - mean) ** 2, 0) / numbers.length; + return Math.sqrt(variance); } /** * @tested_by tests/lib/client/StatsMath.test.ts */ -export function NumericalTotal(selector: Selector, reports: Report[]) { - let sum = 0; - reports?.forEach((report) => (sum += getSelection(selector, report))); - return Round(sum); +export function NumericalTotal( + selector: Selector, + reports: Report[], +) { + let sum = 0; + reports?.forEach((report) => (sum += getSelection(selector, report))); + return Round(sum); } - /** * @tested_by tests/lib/client/StatsMath.test.ts */ -export function MostCommonValue(selector: Selector, reports: Report[]) { - // Get a list of all values of the specified field - let values: string[] = []; - reports?.forEach((report) => { - const val = getSelection(selector, report); - values.push((val as any).toString?.() ?? JSON.stringify(val)); - }); - - // Count the occurrences of each value - const occurences: { [key: string]: number } = {}; - values.forEach((num) => (occurences[num] ? (occurences[num] += 1) : (occurences[num] = 1))); - - // Return the most common value - const sortedValues = Object.keys(occurences).sort((a, b) => occurences[b] - occurences[a]); - const mode = sortedValues[0]; - - return mode === "undefined" ? "Unknown" : mode; +export function MostCommonValue( + selector: Selector, + reports: Report[], +) { + // Get a list of all values of the specified field + let values: string[] = []; + reports?.forEach((report) => { + const val = getSelection(selector, report); + values.push((val as any).toString?.() ?? JSON.stringify(val)); + }); + + // Count the occurrences of each value + const occurences: { [key: string]: number } = {}; + values.forEach((num) => + occurences[num] ? (occurences[num] += 1) : (occurences[num] = 1), + ); + + // Return the most common value + const sortedValues = Object.keys(occurences).sort( + (a, b) => occurences[b] - occurences[a], + ); + const mode = sortedValues[0]; + + return mode === "undefined" ? "Unknown" : mode; } /** * @tested_by tests/lib/client/StatsMath.test.ts */ -export function BooleanAverage(selector: Selector, reports: Report[]) { - const trues = reports?.filter((report) => getSelection(selector, report) === true).length; +export function BooleanAverage( + selector: Selector, + reports: Report[], +) { + const trues = reports?.filter( + (report) => getSelection(selector, report) === true, + ).length; - return trues / Math.max(reports?.length, 1) > 0.5; + return trues / Math.max(reports?.length, 1) > 0.5; } /** * @tested_by tests/lib/client/StatsMath.test.ts */ -export function NumericalAverage(selector: Selector, reports: Report[]) { - return Round(NumericalTotal(selector, reports) / reports?.length); +export function NumericalAverage( + selector: Selector, + reports: Report[], +) { + return Round(NumericalTotal(selector, reports) / reports?.length); } /** * @tested_by tests/lib/client/StatsMath.test.ts */ export function ComparativePercent( - selector1: Selector, - selector2: Selector, - reports: Report[], + selector1: Selector, + selector2: Selector, + reports: Report[], ) { - const a = NumericalTotal(selector1, reports); - const b = NumericalTotal(selector2, reports); + const a = NumericalTotal(selector1, reports); + const b = NumericalTotal(selector2, reports); - if (a === 0 && b === 0) { - return "0%"; - } + if (a === 0 && b === 0) { + return "0%"; + } - return Round(a / (b + a) * 100) + "%"; + return Round((a / (b + a)) * 100) + "%"; } diff --git a/lib/client/dbinterfaces/DbInterface.ts b/lib/client/dbinterfaces/DbInterface.ts index 2e4586fc..b0d6147c 100644 --- a/lib/client/dbinterfaces/DbInterface.ts +++ b/lib/client/dbinterfaces/DbInterface.ts @@ -1,18 +1,42 @@ import { ObjectId, Document } from "bson"; import CollectionId, { CollectionIdToType } from "../CollectionId"; -export type WithStringOrObjectIdId = Omit & { _id?: ObjectId | string }; +export type WithStringOrObjectIdId = Omit & { + _id?: ObjectId | string; +}; export default interface DbInterface { - init(): Promise; - addObject>(collection: TId, object: WithStringOrObjectIdId): Promise; - deleteObjectById(collection: CollectionId, id: ObjectId): Promise; - updateObjectById>(collection: TId, id: ObjectId, newValues: Partial): Promise; - findObjectById(collection: CollectionId, id: ObjectId): Promise; - findObject(collection: CollectionId, query: object): Promise; - /** - * Type should not be an array! This function returns an array of Type (Type[]). - */ - findObjects(collection: CollectionId, query: object): Promise; - countObjects(collection: CollectionId, query: object): Promise; -} \ No newline at end of file + init(): Promise; + addObject>( + collection: TId, + object: WithStringOrObjectIdId, + ): Promise; + deleteObjectById(collection: CollectionId, id: ObjectId): Promise; + updateObjectById< + TId extends CollectionId, + TObj extends CollectionIdToType, + >( + collection: TId, + id: ObjectId, + newValues: Partial, + ): Promise; + findObjectById( + collection: CollectionId, + id: ObjectId, + ): Promise; + findObject( + collection: CollectionId, + query: object, + ): Promise; + /** + * Type should not be an array! This function returns an array of Type (Type[]). + */ + findObjects( + collection: CollectionId, + query: object, + ): Promise; + countObjects( + collection: CollectionId, + query: object, + ): Promise; +} diff --git a/lib/client/dbinterfaces/InMemoryDbInterface.ts b/lib/client/dbinterfaces/InMemoryDbInterface.ts index 1b302098..b5e55c71 100644 --- a/lib/client/dbinterfaces/InMemoryDbInterface.ts +++ b/lib/client/dbinterfaces/InMemoryDbInterface.ts @@ -1,157 +1,204 @@ import { Document, EJSON, ObjectId } from "bson"; import CollectionId, { CollectionIdToType } from "@/lib/client/CollectionId"; -import DbInterface, { WithStringOrObjectIdId } from "@/lib/client/dbinterfaces/DbInterface"; +import DbInterface, { + WithStringOrObjectIdId, +} from "@/lib/client/dbinterfaces/DbInterface"; import { MemoryDb } from "minimongo"; /** * Remove undefined values or EJSON will convert them to null */ -function removeUndefinedValues(obj: { [key: string]: any }): { [key: string]: any } { - const newObj = { ...obj }; - - for (const key in newObj) { - if (newObj[key] === undefined) { - delete newObj[key]; - } else if (Array.isArray(newObj[key])) { - newObj[key] = newObj[key].map((item: any) => { - if (typeof item === "object") { - return removeUndefinedValues(item); - } - return item; - }); - } else if (newObj[key] !== undefined && !(newObj[key] instanceof ObjectId) && newObj[key] !== null && typeof newObj[key] === "object") { - newObj[key] = removeUndefinedValues(newObj[key]); - } - } - - return newObj; +function removeUndefinedValues(obj: { [key: string]: any }): { + [key: string]: any; +} { + const newObj = { ...obj }; + + for (const key in newObj) { + if (newObj[key] === undefined) { + delete newObj[key]; + } else if (Array.isArray(newObj[key])) { + newObj[key] = newObj[key].map((item: any) => { + if (typeof item === "object") { + return removeUndefinedValues(item); + } + return item; + }); + } else if ( + newObj[key] !== undefined && + !(newObj[key] instanceof ObjectId) && + newObj[key] !== null && + typeof newObj[key] === "object" + ) { + newObj[key] = removeUndefinedValues(newObj[key]); + } + } + + return newObj; } -function replaceOidOperator(obj: { [key: string]: any }, idsToString: boolean): { [key: string]: any } { - const newObj = { ...obj }; - - for (const key in newObj) { - if (idsToString && key === "_id") { - newObj["_id"] = newObj._id.$oid; - } else if (!idsToString && key === "_id") { - newObj._id = new ObjectId(newObj._id.toString()); - } else if (Array.isArray(newObj[key])) { - newObj[key] = newObj[key].map((item: any) => { - if (typeof item === "object") { - return replaceOidOperator(item, idsToString); - } - return item; - }); - } else if (newObj[key] !== undefined && !(newObj[key] instanceof ObjectId) && newObj[key] !== null && typeof newObj[key] === "object") { - newObj[key] = replaceOidOperator(newObj[key], idsToString); - } - } - - return newObj; +function replaceOidOperator( + obj: { [key: string]: any }, + idsToString: boolean, +): { [key: string]: any } { + const newObj = { ...obj }; + + for (const key in newObj) { + if (idsToString && key === "_id") { + newObj["_id"] = newObj._id.$oid; + } else if (!idsToString && key === "_id") { + newObj._id = new ObjectId(newObj._id.toString()); + } else if (Array.isArray(newObj[key])) { + newObj[key] = newObj[key].map((item: any) => { + if (typeof item === "object") { + return replaceOidOperator(item, idsToString); + } + return item; + }); + } else if ( + newObj[key] !== undefined && + !(newObj[key] instanceof ObjectId) && + newObj[key] !== null && + typeof newObj[key] === "object" + ) { + newObj[key] = replaceOidOperator(newObj[key], idsToString); + } + } + + return newObj; } /** - * @param removeUndefined pass false if you're serializing a query where undefined values are important + * @param removeUndefined pass false if you're serializing a query where undefined values are important * (this is most of the time that you're serializing a query) */ function serialize(obj: any, removeUndefined: boolean = true): any { - return replaceOidOperator(EJSON.serialize(removeUndefined ? removeUndefinedValues(obj) : obj), true); + return replaceOidOperator( + EJSON.serialize(removeUndefined ? removeUndefinedValues(obj) : obj), + true, + ); } function deserialize(obj: any): any { - return replaceOidOperator(EJSON.deserialize(obj), false); + return replaceOidOperator(EJSON.deserialize(obj), false); } /** -* @tested_by tests/lib/client/dbinterfaces/InMemoryDbInterface.test.ts -*/ + * @tested_by tests/lib/client/dbinterfaces/InMemoryDbInterface.test.ts + */ export default class InMemoryDbInterface implements DbInterface { - backingDb: MemoryDb; - - constructor() { - this.backingDb = new MemoryDb(); - } - - init(): Promise - { - const promise = new Promise((resolve) => { - let collectionsCreated = 0; - - function onCollectionCreated() { - collectionsCreated++; - if (collectionsCreated === Object.keys(CollectionId).length) - { - resolve(undefined); - } - } - - // Have to use Object.values here or else we'll get the keys as strings - // Be sure to use of, not in! - for (const collectionId of Object.values(CollectionId)) - { - this.backingDb.addCollection(collectionId, onCollectionCreated, onCollectionCreated); - } - }); - - return promise as Promise; - } - - addObject>(collection: TId, object: WithStringOrObjectIdId): Promise - { - if (!object._id) - object._id = new ObjectId(); - - return this.backingDb.collections[collection].upsert(serialize(object)).then(deserialize); - } - - deleteObjectById(collection: CollectionId, id: ObjectId): Promise - { - return this.backingDb.collections[collection].remove(serialize({ _id: id })); - } - - updateObjectById>(collection: TId, id: ObjectId, newValues: Partial): Promise - { - return this.backingDb.collections[collection].findOne(serialize({ _id: id })).then((existingDoc) => { - if (!existingDoc) - { - throw new Error(`Document with id ${id} not found in collection ${collection}`); - } - - const returnValue = this.backingDb.collections[collection].upsert(serialize({ ...existingDoc, ...newValues, _id: id })); - return deserialize(returnValue); - }) - } - - findObjectById(collection: CollectionId, id: ObjectId): Promise - { - return this.findObject(collection, { _id: id }); - } - - findObject(collection: CollectionId, query: object): Promise - { - return this.backingDb.collections[collection].findOne(serialize(query, false)).then(deserialize).then((obj: Type) => { - if (Object.keys(obj).length === 0) - { - return undefined; - } - - return obj; - }); - } - - findObjects(collection: CollectionId, query: object): Promise - { - return this.backingDb.collections[collection].find(serialize(query, false)).fetch().then((res: { [index: string]: object }) => { - return Object.values(res).map(deserialize); - }); - } - - countObjects(collection: CollectionId, query: object): Promise - { - return (this.backingDb.collections[collection].find(serialize(query, false)).fetch() as Promise) - .then((objects) => { - return Object.keys(objects as any).length; - }); - } - -} \ No newline at end of file + backingDb: MemoryDb; + + constructor() { + this.backingDb = new MemoryDb(); + } + + init(): Promise { + const promise = new Promise((resolve) => { + let collectionsCreated = 0; + + function onCollectionCreated() { + collectionsCreated++; + if (collectionsCreated === Object.keys(CollectionId).length) { + resolve(undefined); + } + } + + // Have to use Object.values here or else we'll get the keys as strings + // Be sure to use of, not in! + for (const collectionId of Object.values(CollectionId)) { + this.backingDb.addCollection( + collectionId, + onCollectionCreated, + onCollectionCreated, + ); + } + }); + + return promise as Promise; + } + + addObject>( + collection: TId, + object: WithStringOrObjectIdId, + ): Promise { + if (!object._id) object._id = new ObjectId(); + + return this.backingDb.collections[collection] + .upsert(serialize(object)) + .then(deserialize); + } + + deleteObjectById(collection: CollectionId, id: ObjectId): Promise { + return this.backingDb.collections[collection].remove( + serialize({ _id: id }), + ); + } + + updateObjectById< + TId extends CollectionId, + TObj extends CollectionIdToType, + >(collection: TId, id: ObjectId, newValues: Partial): Promise { + return this.backingDb.collections[collection] + .findOne(serialize({ _id: id })) + .then((existingDoc) => { + if (!existingDoc) { + throw new Error( + `Document with id ${id} not found in collection ${collection}`, + ); + } + + const returnValue = this.backingDb.collections[collection].upsert( + serialize({ ...existingDoc, ...newValues, _id: id }), + ); + return deserialize(returnValue); + }); + } + + findObjectById( + collection: CollectionId, + id: ObjectId, + ): Promise { + return this.findObject(collection, { _id: id }); + } + + findObject( + collection: CollectionId, + query: object, + ): Promise { + return this.backingDb.collections[collection] + .findOne(serialize(query, false)) + .then(deserialize) + .then((obj: Type) => { + if (Object.keys(obj).length === 0) { + return undefined; + } + + return obj; + }); + } + + findObjects( + collection: CollectionId, + query: object, + ): Promise { + return this.backingDb.collections[collection] + .find(serialize(query, false)) + .fetch() + .then((res: { [index: string]: object }) => { + return Object.values(res).map(deserialize); + }); + } + + countObjects( + collection: CollectionId, + query: object, + ): Promise { + return ( + this.backingDb.collections[collection] + .find(serialize(query, false)) + .fetch() as Promise + ).then((objects) => { + return Object.keys(objects as any).length; + }); + } +} diff --git a/lib/client/useCheckMobile.ts b/lib/client/useCheckMobile.ts index 896ce816..192bd8c1 100644 --- a/lib/client/useCheckMobile.ts +++ b/lib/client/useCheckMobile.ts @@ -1,21 +1,21 @@ import React, { useEffect, useState } from "react"; const useCheckMobile = () => { - const [width, setWidth] = useState(0); + const [width, setWidth] = useState(0); - const handleWindowSizeChange = () => { - setWidth(window.innerWidth); - }; + const handleWindowSizeChange = () => { + setWidth(window.innerWidth); + }; - useEffect(() => { - setWidth(window.innerWidth); - window.addEventListener("resize", handleWindowSizeChange); - return () => { - window.removeEventListener("resize", handleWindowSizeChange); - }; - }, []); + useEffect(() => { + setWidth(window.innerWidth); + window.addEventListener("resize", handleWindowSizeChange); + return () => { + window.removeEventListener("resize", handleWindowSizeChange); + }; + }, []); - return width <= 768; + return width <= 768; }; export default useCheckMobile; diff --git a/lib/client/useCurrentSession.ts b/lib/client/useCurrentSession.ts index 27ee80d4..a1e418f1 100644 --- a/lib/client/useCurrentSession.ts +++ b/lib/client/useCurrentSession.ts @@ -4,11 +4,11 @@ import { ISODateString } from "next-auth"; // abstraction for next-auth useSession, just makes typescript stuff tidy export interface AdvancedSession { - user: User | null; - expires: ISODateString; + user: User | null; + expires: ISODateString; } export function useCurrentSession() { - const { data: session, status } = useSession(); - return { session: session as AdvancedSession, status }; + const { data: session, status } = useSession(); + return { session: session as AdvancedSession, status }; } diff --git a/lib/client/useDynamicState.ts b/lib/client/useDynamicState.ts index 8d3d66d3..e6a021d9 100644 --- a/lib/client/useDynamicState.ts +++ b/lib/client/useDynamicState.ts @@ -2,25 +2,30 @@ import { Dispatch, SetStateAction, useState } from "react"; /** * An alternative to useState that allows for the latest state to be easily retrieved. - * + * * @param initialState * @returns [ state, setState, getState ]. The first two elements are the same as useState, and the * third element is a function that takes a function as a parameter. The parameter function takes the latest state as * a parameter. * @todo Rework this! The forced undefined return type and the forced callback makes me want to barf. */ -export default function(initialState?: T): - [T | undefined, Dispatch>, (func: (state: T | undefined) => void) => void] { - const [state, setState] = useState(initialState); +export default function ( + initialState?: T, +): [ + T | undefined, + Dispatch>, + (func: (state: T | undefined) => void) => void, +] { + const [state, setState] = useState(initialState); - return [ - state, - setState, - (func: (state: T | undefined) => void) => { - setState((prevState) => { - func(prevState); - return prevState; - }); - } - ] -} \ No newline at end of file + return [ + state, + setState, + (func: (state: T | undefined) => void) => { + setState((prevState) => { + func(prevState); + return prevState; + }); + }, + ]; +} diff --git a/lib/client/useInterval.ts b/lib/client/useInterval.ts index 7ddcf051..e04c348a 100644 --- a/lib/client/useInterval.ts +++ b/lib/client/useInterval.ts @@ -3,13 +3,17 @@ import { useEffect, useState } from "react"; /** * Can be janky. You've been warned. */ -export default function useInterval(func: () => any, interval: number, deps: any[] = []) { - const [id, setId] = useState(undefined); +export default function useInterval( + func: () => any, + interval: number, + deps: any[] = [], +) { + const [id, setId] = useState(undefined); - useEffect(() => { - setId(setInterval(func, interval)); - return () => clearInterval(id); - }, [func.name, interval, ...deps]); + useEffect(() => { + setId(setInterval(func, interval)); + return () => clearInterval(id); + }, [func.name, interval, ...deps]); - return id; -} \ No newline at end of file + return id; +} diff --git a/lib/client/useIsVisible.ts b/lib/client/useIsVisible.ts index 68bb1038..ac5a220b 100644 --- a/lib/client/useIsVisible.ts +++ b/lib/client/useIsVisible.ts @@ -1,18 +1,18 @@ import { Ref, useEffect, useState } from "react"; export default function useIsVisible(ref: any) { - const [isIntersecting, setIntersecting] = useState(false); + const [isIntersecting, setIntersecting] = useState(false); - useEffect(() => { - const observer = new IntersectionObserver(([entry]) => { - setIntersecting(entry.isIntersecting); - }); + useEffect(() => { + const observer = new IntersectionObserver(([entry]) => { + setIntersecting(entry.isIntersecting); + }); - observer.observe(ref.current); - return () => { - observer.disconnect(); - }; - }, [ref]); + observer.observe(ref.current); + return () => { + observer.disconnect(); + }; + }, [ref]); - return isIntersecting; + return isIntersecting; } diff --git a/lib/client/useLocalStorage.ts b/lib/client/useLocalStorage.ts index de71260d..1e5f2727 100644 --- a/lib/client/useLocalStorage.ts +++ b/lib/client/useLocalStorage.ts @@ -1,18 +1,18 @@ import { useState, useEffect } from "react"; export default function useLocalStorage(key: string) { - const [data, setData] = useState(); + const [data, setData] = useState(); - const setCallback = (dataToSet: Type) => { - setData(data); - localStorage.setItem(key, JSON.stringify(dataToSet)); - }; + const setCallback = (dataToSet: Type) => { + setData(data); + localStorage.setItem(key, JSON.stringify(dataToSet)); + }; - useEffect(() => { - if (localStorage && key.length > 0) { - JSON.stringify(localStorage.getItem(key)); - } - }, []); + useEffect(() => { + if (localStorage && key.length > 0) { + JSON.stringify(localStorage.getItem(key)); + } + }, []); - return [data as Type, setCallback] as const; + return [data as Type, setCallback] as const; } diff --git a/lib/dev/FakeData.ts b/lib/dev/FakeData.ts index 8a999d6c..73d47902 100644 --- a/lib/dev/FakeData.ts +++ b/lib/dev/FakeData.ts @@ -12,68 +12,71 @@ const firstNameFemaleURL = "https://www.randomlists.com/data/names-female.json"; var cachedFirstNames: string[] = []; var cachedLastNames: string[] = []; async function fetchNames(): Promise<[string[], string[]]> { - if (cachedFirstNames.length === 0) { - cachedFirstNames = cachedFirstNames.concat( - (await (await fetch(firstNameFemaleURL)).json()).data, - ); - cachedFirstNames = cachedFirstNames.concat( - (await (await fetch(firstNameMaleURL)).json()).data, - ); - } - if (cachedLastNames.length === 0) { - cachedLastNames = ( - await ( - await fetch("https://www.randomlists.com/data/names-surnames.json") - ).json() - ).data; - } + if (cachedFirstNames.length === 0) { + cachedFirstNames = cachedFirstNames.concat( + (await (await fetch(firstNameFemaleURL)).json()).data, + ); + cachedFirstNames = cachedFirstNames.concat( + (await (await fetch(firstNameMaleURL)).json()).data, + ); + } + if (cachedLastNames.length === 0) { + cachedLastNames = ( + await ( + await fetch("https://www.randomlists.com/data/names-surnames.json") + ).json() + ).data; + } - return [cachedFirstNames, cachedLastNames]; + return [cachedFirstNames, cachedLastNames]; } async function randomName(): Promise { - const [first, last] = await fetchNames(); - return randomArrayValue(first) + " " + randomArrayValue(last); + const [first, last] = await fetchNames(); + return randomArrayValue(first) + " " + randomArrayValue(last); } -export async function fakeUser(teamId: string | undefined, db: DbInterface): Promise { - const name = await randomName(); - const user = new User( - name, - "totallyrealemail@gmail.com", - "https://media.npr.org/assets/img/2015/06/15/gettyimages-1445210_custom-9cff1c641fe4451adaf1bcd3750bf4a11fb5d4e9.jpg", - false, - await GenerateSlug(db, CollectionId.Users, name), - teamId ? [teamId] : [], - [], - "", - 10, - ); - return await db.addObject(CollectionId.Users, user); +export async function fakeUser( + teamId: string | undefined, + db: DbInterface, +): Promise { + const name = await randomName(); + const user = new User( + name, + "totallyrealemail@gmail.com", + "https://media.npr.org/assets/img/2015/06/15/gettyimages-1445210_custom-9cff1c641fe4451adaf1bcd3750bf4a11fb5d4e9.jpg", + false, + await GenerateSlug(db, CollectionId.Users, name), + teamId ? [teamId] : [], + [], + "", + 10, + ); + return await db.addObject(CollectionId.Users, user); } export async function fillTeamWithFakeUsers( - n: number, - teamId: string | undefined, - db: DbInterface + n: number, + teamId: string | undefined, + db: DbInterface, ): Promise { - const users: any[] = []; - for (let i = 0; i < n; i++) { - users.push((await fakeUser(teamId, db))._id?.toString()); - } + const users: any[] = []; + for (let i = 0; i < n; i++) { + users.push((await fakeUser(teamId, db))._id?.toString()); + } - const team = await db.findObjectById( - CollectionId.Teams, - new ObjectId(teamId?.toString()), - ); + const team = await db.findObjectById( + CollectionId.Teams, + new ObjectId(teamId?.toString()), + ); - if (!team) { - throw new Error("Team not found"); - } + if (!team) { + throw new Error("Team not found"); + } - team.users = team.users.concat(users); - team.scouters = team.scouters.concat(users); + team.users = team.users.concat(users); + team.scouters = team.scouters.concat(users); - await db.updateObjectById(CollectionId.Teams, new ObjectId(team._id), team); - return team; + await db.updateObjectById(CollectionId.Teams, new ObjectId(team._id), team); + return team; } diff --git a/lib/games.ts b/lib/games.ts index 8f593c09..8ddb292a 100644 --- a/lib/games.ts +++ b/lib/games.ts @@ -1,868 +1,1167 @@ -import { CenterStageEnums, Defense, FrcDrivetrain, IntakeTypes, IntoTheDeepEnums } from './Enums'; +import { + CenterStageEnums, + Defense, + FrcDrivetrain, + IntakeTypes, + IntoTheDeepEnums, +} from "./Enums"; import { Badge, FormLayoutProps, PitStatsLayout, StatsLayout } from "./Layout"; -import { Report, Game, League, PitReportData, QuantData, Pitreport, FieldPos } from "./Types"; +import { + Report, + Game, + League, + PitReportData, + QuantData, + Pitreport, + FieldPos, +} from "./Types"; import { GameId } from "./client/GameId"; -import { AmpAutoPoints, AmpTeleopPoints, BooleanAverage, MostCommonValue, NumericalTotal, Round, SpeakerAutoPoints, SpeakerTeleopPoints, TrapPoints } from "./client/StatsMath"; - -function getBaseBadges(pitReport: Pitreport | undefined, quantitativeReports: Report[] | undefined) { - const badges: Badge[] = []; - const pitData = pitReport?.data; - - const defense = MostCommonValue("Defense", quantitativeReports ?? []); - const drivetrain = pitData?.drivetrain; - const motorType = pitData?.motorType; - const swerveLevel = pitData?.swerveLevel; - - if (defense && defense !== Defense.None) { - badges.push({ text: defense + " Defense", color: defense === Defense.Full ? "primary" : "info" }); - } - - if (pitReport?.submitted && drivetrain) { - const drivetrainBadge: Badge = { text: drivetrain, color: "info" }; - - if (motorType) { - drivetrainBadge.text += " (" + motorType; - } - - if (drivetrain === FrcDrivetrain.Swerve) { - drivetrainBadge.color = "primary"; - drivetrainBadge.text += ", " + swerveLevel; - } - else if (drivetrain === FrcDrivetrain.Mecanum) { - drivetrainBadge.color = "warning"; - } - - if (drivetrainBadge.text.includes("(")) { - drivetrainBadge.text += ")"; - } - - badges.push(drivetrainBadge); - } - - return badges; +import { + AmpAutoPoints, + AmpTeleopPoints, + BooleanAverage, + MostCommonValue, + NumericalTotal, + Round, + SpeakerAutoPoints, + SpeakerTeleopPoints, + TrapPoints, +} from "./client/StatsMath"; + +function getBaseBadges( + pitReport: Pitreport | undefined, + quantitativeReports: Report[] | undefined, +) { + const badges: Badge[] = []; + const pitData = pitReport?.data; + + const defense = MostCommonValue("Defense", quantitativeReports ?? []); + const drivetrain = pitData?.drivetrain; + const motorType = pitData?.motorType; + const swerveLevel = pitData?.swerveLevel; + + if (defense && defense !== Defense.None) { + badges.push({ + text: defense + " Defense", + color: defense === Defense.Full ? "primary" : "info", + }); + } + + if (pitReport?.submitted && drivetrain) { + const drivetrainBadge: Badge = { text: drivetrain, color: "info" }; + + if (motorType) { + drivetrainBadge.text += " (" + motorType; + } + + if (drivetrain === FrcDrivetrain.Swerve) { + drivetrainBadge.color = "primary"; + drivetrainBadge.text += ", " + swerveLevel; + } else if (drivetrain === FrcDrivetrain.Mecanum) { + drivetrainBadge.color = "warning"; + } + + if (drivetrainBadge.text.includes("(")) { + drivetrainBadge.text += ")"; + } + + badges.push(drivetrainBadge); + } + + return badges; } // Data keys use upper camel case so they can be used as labels in the forms export namespace Crescendo { - export class QuantitativeData extends QuantData { - AutoScoredAmp: number = 0; // # of times scored in the amp - AutoMissedAmp: number = 0; - AutoScoredSpeaker: number = 0; - AutoMissedSpeaker: number = 0; - MovedOut: boolean = false; - - TeleopScoredAmp: number = 0; - TeleopMissedAmp: number = 0; - TeleopScoredSpeaker: number = 0; - TeleopMissedSpeaker: number = 0; - TeleopScoredTrap: number = 0; - TeleopMissedTrap: number = 0; - TeleopPassed: number = 0; - - Coopertition: boolean = false; // true if used any point in match - ClimbedStage: boolean = false; - ParkedStage: boolean = false; - UnderStage: boolean = false; - - intakeType: IntakeTypes = IntakeTypes.Human; - } - - export class PitData extends PitReportData { - intakeType: IntakeTypes = IntakeTypes.None; - canClimb: boolean = false; - fixedShooter: boolean = false; - canScoreAmp: boolean = false; - canScoreSpeaker: boolean = false; - canScoreFromDistance: boolean = false; - underBumperIntake: boolean = false; - autoNotes: number = 0; - } - - const pitReportLayout: FormLayoutProps = { - "Intake": ["intakeType"], - "Shooter": ["canScoreAmp", "canScoreSpeaker", "fixedShooter", "canScoreFromDistance"], - "Climber": ["canClimb"], - "Auto": [{key: "autoNotes", type: "number"}] - } - - const quantitativeReportLayout: FormLayoutProps = { - "Auto": [ - "MovedOut", - [ - ["AutoScoredAmp", "AutoMissedAmp"], - ["AutoScoredSpeaker", "AutoMissedSpeaker"] - ] - ], - "Teleop": [ - [ - ["TeleopScoredAmp", "TeleopMissedAmp"], - ["TeleopScoredSpeaker", "TeleopMissedSpeaker"], - ["TeleopScoredTrap", "TeleopMissedTrap"] - ], - [[{ key: "TeleopPassed", label: "Notes Passed" }]], - "Defense" - ], - "Summary": [ - { key: "Coopertition", label: "Coopertition Activated" }, "ClimbedStage", "ParkedStage", - { key: "UnderStage", label: "Went Under Stage" } - ] - } - - const statsLayout: StatsLayout = { - sections: { - "Auto": [ - { - stats: [ - { label: "Avg Scored Amp Shots", key: "AutoScoredAmp" }, - { label: "Avg Missed Amp Shots", key: "AutoMissedAmp" } - ], - label: "Overall Amp Accuracy" }, - { - stats: [ - { label: "Avg Scored Speaker Shots", key: "AutoScoredSpeaker" }, - { label: "Avg Missed Speaker Shots" , key: "AutoMissedSpeaker" } - ], - label: "Overall Speaker Accuracy" } - ], - "Teleop": [ - { - stats: [ - { label: "Avg Scored Amp Shots", key: "TeleopScoredAmp" }, - { label: "Avg Missed Amp Shots", key: "TeleopMissedAmp" } - ], - label: "Overall Amp Accuracy" - }, - { - stats: [ - { label: "Avg Scored Speaker Shots", key: "TeleopScoredSpeaker" }, - { label: "Avg Missed Speaker Shots", key: "TeleopMissedSpeaker" } - ], - label: "Overall Speaker Accuracy" - }, - { - stats: [ - { label: "Avg Scored Trap Shots", key: "TeleopScoredTrap" }, - { label: "Avg Missed Trap Shots", key: "TeleopMissedTrap" } - ], - label: "Overall Trap Accuracy" - }, - { - key: "TeleopPassed", - label: "Notes Passed" - } - ] - }, - getGraphDots: (quantReports: Report[], pitReport?: Pitreport) => { - return []; - } - } - - const pitStatsLayout: PitStatsLayout = { - overallSlideStats: [{ - label: "Avg Notes Scored", - get: (pitReport: Pitreport | undefined, quantitativeReports: Report[] | undefined) => { - if (!quantitativeReports) return 0; - - return quantitativeReports.reduce( - (acc, report) => acc - + report.data.AutoScoredSpeaker - + report.data.TeleopMissedSpeaker - + report.data.AutoScoredAmp - + report.data.TeleopScoredAmp - + report.data.TeleopScoredTrap, 0) - / quantitativeReports.length; - } - }, - { - label: "Teleop Speaker Accuracy", - get: (pitReport: Pitreport | undefined, quantitativeReports: Report[] | undefined) => { - if (!quantitativeReports) return 0; - - const scores = quantitativeReports.map(report => report.data.TeleopScoredSpeaker); - const misses = quantitativeReports.map(report => report.data.TeleopMissedSpeaker); - - const scoreCount = scores.reduce((acc, score) => acc + score, 0); - const missCount = misses.reduce((acc, miss) => acc + miss, 0); - - return scoreCount / (scoreCount + missCount); - } - }, - { - label: "Teleop Amp Accuracy", - get: (pitReport: Pitreport | undefined, quantitativeReports: Report[] | undefined) => { - if (!quantitativeReports) return 0; - - const scores = quantitativeReports.map(report => report.data.TeleopScoredAmp); - const misses = quantitativeReports.map(report => report.data.TeleopMissedAmp); - - const scoreCount = scores.reduce((acc, score) => acc + score, 0); - const missCount = misses.reduce((acc, miss) => acc + miss, 0); - - return scoreCount / (scoreCount + missCount); - } - }, - { - label: "Avg Notes in Trap", - key: "TeleopScoredTrap" - } - ], - individualSlideStats: [ - { - label: "Avg Teleop Points", - get: (pitReport: Pitreport | undefined, quantitativeReports: Report[] | undefined) => { - if (!quantitativeReports) return 0; - - const speakerAuto = NumericalTotal("AutoScoredSpeaker", quantitativeReports) * SpeakerAutoPoints; - const speakerTeleop = NumericalTotal("TeleopScoredAmp", quantitativeReports) * SpeakerTeleopPoints; - const ampAuto = NumericalTotal("AutoScoredAmp", quantitativeReports) * AmpAutoPoints; - const ampTeleop = NumericalTotal("TeleopScoredAmp", quantitativeReports) * AmpTeleopPoints; - const trap = NumericalTotal("TeleopScoredTrap", quantitativeReports) * TrapPoints; - - return Round(speakerAuto + speakerTeleop + ampAuto + ampTeleop + trap) / quantitativeReports.length; - } - }, - { - label: "Avg Auto Points", - get: (pitReport: Pitreport | undefined, quantitativeReports: Report[] | undefined) => { - if (!quantitativeReports) return 0; - - const speakerAuto = NumericalTotal("AutoScoredSpeaker", quantitativeReports) * SpeakerAutoPoints; - const ampAuto = NumericalTotal("AutoScoredAmp", quantitativeReports) * AmpAutoPoints; - - return Round(speakerAuto + ampAuto) / quantitativeReports.length; - } - }, - { - label: "Avg Speaker Points", - get: (pitReport: Pitreport | undefined, quantitativeReports: Report[] | undefined) => { - if (!quantitativeReports) return 0; - - const speakerAuto = NumericalTotal("AutoScoredSpeaker", quantitativeReports) * SpeakerAutoPoints; - const speakerTeleop = NumericalTotal("TeleopScoredAmp", quantitativeReports) * SpeakerTeleopPoints; - - return Round(speakerAuto + speakerTeleop) / quantitativeReports.length; - } - }, - { - label: "Avg Amp Points", - get: (pitReport: Pitreport | undefined, quantitativeReports: Report[] | undefined) => { - if (!quantitativeReports) return 0; - - const ampAuto = NumericalTotal("AutoScoredAmp", quantitativeReports) * AmpAutoPoints; - const ampTeleop = NumericalTotal("TeleopScoredAmp", quantitativeReports) * AmpTeleopPoints; - - return Round(ampAuto + ampTeleop) / quantitativeReports.length; - } - } - ], - robotCapabilities: [ - { - label: "Intake Type", - key: "intakeType" - } - ], - graphStat: { - label: "Avg Notes Scored", - get: (pitReport: Pitreport | undefined, quantitativeReports: Report[] | undefined) => { - if (!quantitativeReports) return 0; - - return quantitativeReports.reduce( - (acc, report) => acc - + report.data.AutoScoredSpeaker - + report.data.TeleopMissedSpeaker - + report.data.AutoScoredAmp - + report.data.TeleopScoredAmp - + report.data.TeleopScoredTrap, 0) - / quantitativeReports.length; - } - } - } - - function getAvgPoints(reports: Report[] | undefined) { - if (!reports) return 0; - - const speakerAuto = NumericalTotal("AutoScoredSpeaker", reports) * SpeakerAutoPoints; - const speakerTeleop = NumericalTotal("TeleopScoredAmp", reports) * SpeakerTeleopPoints; - const ampAuto = NumericalTotal("AutoScoredAmp", reports) * AmpAutoPoints; - const ampTeleop = NumericalTotal("TeleopScoredAmp", reports) * AmpTeleopPoints; - const trap = NumericalTotal("TeleopScoredTrap", reports) * TrapPoints; - - return Round(speakerAuto + speakerTeleop + ampAuto + ampTeleop + trap) / reports.length; - } - - function getBadges(pitReport: Pitreport | undefined, quantitativeReports: Report[] | undefined, card: boolean) { - const pitData = pitReport?.data; - const badges = getBaseBadges(pitReport, quantitativeReports); - - const intake = pitData?.intakeType; - const cooperates = BooleanAverage("Coopertition", quantitativeReports ?? []); - const climbs = BooleanAverage("ClimbedStage", quantitativeReports ?? []); - const parks = BooleanAverage("ParkedStage", quantitativeReports ?? []); - const understage = BooleanAverage("UnderStage", quantitativeReports ?? []); - - if (pitReport?.submitted && intake) { - const intakeBadge: Badge = { text: intake, color: "primary" }; - if (intake === IntakeTypes.Human) { - intakeBadge.color = "warning"; - } - else if (intake === IntakeTypes.Both) { - intakeBadge.color = "secondary"; - } - else if (intake === IntakeTypes.None) { - intakeBadge.color = "warning"; - intakeBadge.text = "No Intake"; - } - - badges.push(intakeBadge); - } - - if (cooperates) - badges.push({ text: "Cooperates", color: "success" }); - - if (climbs) - badges.push({ text: "Climbs", color: "accent" }); - - if (parks) - badges.push({ text: "Parks", color: "primary" }); - - if (understage) - badges.push({ text: "Can Go Under Stage", color: "success" }); - - return badges; - } - - export const game = new Game("Crescendo", 2024, League.FRC, QuantitativeData, PitData, pitReportLayout, quantitativeReportLayout, - statsLayout, pitStatsLayout, - "Crescendo", "https://www.firstinspires.org/sites/default/files/uploads/resource_library/frc/crescendo/crescendo.png", - getBadges, getAvgPoints); + export class QuantitativeData extends QuantData { + AutoScoredAmp: number = 0; // # of times scored in the amp + AutoMissedAmp: number = 0; + AutoScoredSpeaker: number = 0; + AutoMissedSpeaker: number = 0; + MovedOut: boolean = false; + + TeleopScoredAmp: number = 0; + TeleopMissedAmp: number = 0; + TeleopScoredSpeaker: number = 0; + TeleopMissedSpeaker: number = 0; + TeleopScoredTrap: number = 0; + TeleopMissedTrap: number = 0; + TeleopPassed: number = 0; + + Coopertition: boolean = false; // true if used any point in match + ClimbedStage: boolean = false; + ParkedStage: boolean = false; + UnderStage: boolean = false; + + intakeType: IntakeTypes = IntakeTypes.Human; + } + + export class PitData extends PitReportData { + intakeType: IntakeTypes = IntakeTypes.None; + canClimb: boolean = false; + fixedShooter: boolean = false; + canScoreAmp: boolean = false; + canScoreSpeaker: boolean = false; + canScoreFromDistance: boolean = false; + underBumperIntake: boolean = false; + autoNotes: number = 0; + } + + const pitReportLayout: FormLayoutProps = { + Intake: ["intakeType"], + Shooter: [ + "canScoreAmp", + "canScoreSpeaker", + "fixedShooter", + "canScoreFromDistance", + ], + Climber: ["canClimb"], + Auto: [{ key: "autoNotes", type: "number" }], + }; + + const quantitativeReportLayout: FormLayoutProps = { + Auto: [ + "MovedOut", + [ + ["AutoScoredAmp", "AutoMissedAmp"], + ["AutoScoredSpeaker", "AutoMissedSpeaker"], + ], + ], + Teleop: [ + [ + ["TeleopScoredAmp", "TeleopMissedAmp"], + ["TeleopScoredSpeaker", "TeleopMissedSpeaker"], + ["TeleopScoredTrap", "TeleopMissedTrap"], + ], + [[{ key: "TeleopPassed", label: "Notes Passed" }]], + "Defense", + ], + Summary: [ + { key: "Coopertition", label: "Coopertition Activated" }, + "ClimbedStage", + "ParkedStage", + { key: "UnderStage", label: "Went Under Stage" }, + ], + }; + + const statsLayout: StatsLayout = { + sections: { + Auto: [ + { + stats: [ + { label: "Avg Scored Amp Shots", key: "AutoScoredAmp" }, + { label: "Avg Missed Amp Shots", key: "AutoMissedAmp" }, + ], + label: "Overall Amp Accuracy", + }, + { + stats: [ + { label: "Avg Scored Speaker Shots", key: "AutoScoredSpeaker" }, + { label: "Avg Missed Speaker Shots", key: "AutoMissedSpeaker" }, + ], + label: "Overall Speaker Accuracy", + }, + ], + Teleop: [ + { + stats: [ + { label: "Avg Scored Amp Shots", key: "TeleopScoredAmp" }, + { label: "Avg Missed Amp Shots", key: "TeleopMissedAmp" }, + ], + label: "Overall Amp Accuracy", + }, + { + stats: [ + { label: "Avg Scored Speaker Shots", key: "TeleopScoredSpeaker" }, + { label: "Avg Missed Speaker Shots", key: "TeleopMissedSpeaker" }, + ], + label: "Overall Speaker Accuracy", + }, + { + stats: [ + { label: "Avg Scored Trap Shots", key: "TeleopScoredTrap" }, + { label: "Avg Missed Trap Shots", key: "TeleopMissedTrap" }, + ], + label: "Overall Trap Accuracy", + }, + { + key: "TeleopPassed", + label: "Notes Passed", + }, + ], + }, + getGraphDots: ( + quantReports: Report[], + pitReport?: Pitreport, + ) => { + return []; + }, + }; + + const pitStatsLayout: PitStatsLayout = { + overallSlideStats: [ + { + label: "Avg Notes Scored", + get: ( + pitReport: Pitreport | undefined, + quantitativeReports: Report[] | undefined, + ) => { + if (!quantitativeReports) return 0; + + return ( + quantitativeReports.reduce( + (acc, report) => + acc + + report.data.AutoScoredSpeaker + + report.data.TeleopMissedSpeaker + + report.data.AutoScoredAmp + + report.data.TeleopScoredAmp + + report.data.TeleopScoredTrap, + 0, + ) / quantitativeReports.length + ); + }, + }, + { + label: "Teleop Speaker Accuracy", + get: ( + pitReport: Pitreport | undefined, + quantitativeReports: Report[] | undefined, + ) => { + if (!quantitativeReports) return 0; + + const scores = quantitativeReports.map( + (report) => report.data.TeleopScoredSpeaker, + ); + const misses = quantitativeReports.map( + (report) => report.data.TeleopMissedSpeaker, + ); + + const scoreCount = scores.reduce((acc, score) => acc + score, 0); + const missCount = misses.reduce((acc, miss) => acc + miss, 0); + + return scoreCount / (scoreCount + missCount); + }, + }, + { + label: "Teleop Amp Accuracy", + get: ( + pitReport: Pitreport | undefined, + quantitativeReports: Report[] | undefined, + ) => { + if (!quantitativeReports) return 0; + + const scores = quantitativeReports.map( + (report) => report.data.TeleopScoredAmp, + ); + const misses = quantitativeReports.map( + (report) => report.data.TeleopMissedAmp, + ); + + const scoreCount = scores.reduce((acc, score) => acc + score, 0); + const missCount = misses.reduce((acc, miss) => acc + miss, 0); + + return scoreCount / (scoreCount + missCount); + }, + }, + { + label: "Avg Notes in Trap", + key: "TeleopScoredTrap", + }, + ], + individualSlideStats: [ + { + label: "Avg Teleop Points", + get: ( + pitReport: Pitreport | undefined, + quantitativeReports: Report[] | undefined, + ) => { + if (!quantitativeReports) return 0; + + const speakerAuto = + NumericalTotal("AutoScoredSpeaker", quantitativeReports) * + SpeakerAutoPoints; + const speakerTeleop = + NumericalTotal("TeleopScoredAmp", quantitativeReports) * + SpeakerTeleopPoints; + const ampAuto = + NumericalTotal("AutoScoredAmp", quantitativeReports) * + AmpAutoPoints; + const ampTeleop = + NumericalTotal("TeleopScoredAmp", quantitativeReports) * + AmpTeleopPoints; + const trap = + NumericalTotal("TeleopScoredTrap", quantitativeReports) * + TrapPoints; + + return ( + Round(speakerAuto + speakerTeleop + ampAuto + ampTeleop + trap) / + quantitativeReports.length + ); + }, + }, + { + label: "Avg Auto Points", + get: ( + pitReport: Pitreport | undefined, + quantitativeReports: Report[] | undefined, + ) => { + if (!quantitativeReports) return 0; + + const speakerAuto = + NumericalTotal("AutoScoredSpeaker", quantitativeReports) * + SpeakerAutoPoints; + const ampAuto = + NumericalTotal("AutoScoredAmp", quantitativeReports) * + AmpAutoPoints; + + return Round(speakerAuto + ampAuto) / quantitativeReports.length; + }, + }, + { + label: "Avg Speaker Points", + get: ( + pitReport: Pitreport | undefined, + quantitativeReports: Report[] | undefined, + ) => { + if (!quantitativeReports) return 0; + + const speakerAuto = + NumericalTotal("AutoScoredSpeaker", quantitativeReports) * + SpeakerAutoPoints; + const speakerTeleop = + NumericalTotal("TeleopScoredAmp", quantitativeReports) * + SpeakerTeleopPoints; + + return ( + Round(speakerAuto + speakerTeleop) / quantitativeReports.length + ); + }, + }, + { + label: "Avg Amp Points", + get: ( + pitReport: Pitreport | undefined, + quantitativeReports: Report[] | undefined, + ) => { + if (!quantitativeReports) return 0; + + const ampAuto = + NumericalTotal("AutoScoredAmp", quantitativeReports) * + AmpAutoPoints; + const ampTeleop = + NumericalTotal("TeleopScoredAmp", quantitativeReports) * + AmpTeleopPoints; + + return Round(ampAuto + ampTeleop) / quantitativeReports.length; + }, + }, + ], + robotCapabilities: [ + { + label: "Intake Type", + key: "intakeType", + }, + ], + graphStat: { + label: "Avg Notes Scored", + get: ( + pitReport: Pitreport | undefined, + quantitativeReports: Report[] | undefined, + ) => { + if (!quantitativeReports) return 0; + + return ( + quantitativeReports.reduce( + (acc, report) => + acc + + report.data.AutoScoredSpeaker + + report.data.TeleopMissedSpeaker + + report.data.AutoScoredAmp + + report.data.TeleopScoredAmp + + report.data.TeleopScoredTrap, + 0, + ) / quantitativeReports.length + ); + }, + }, + }; + + function getAvgPoints(reports: Report[] | undefined) { + if (!reports) return 0; + + const speakerAuto = + NumericalTotal("AutoScoredSpeaker", reports) * SpeakerAutoPoints; + const speakerTeleop = + NumericalTotal("TeleopScoredAmp", reports) * SpeakerTeleopPoints; + const ampAuto = NumericalTotal("AutoScoredAmp", reports) * AmpAutoPoints; + const ampTeleop = + NumericalTotal("TeleopScoredAmp", reports) * AmpTeleopPoints; + const trap = NumericalTotal("TeleopScoredTrap", reports) * TrapPoints; + + return ( + Round(speakerAuto + speakerTeleop + ampAuto + ampTeleop + trap) / + reports.length + ); + } + + function getBadges( + pitReport: Pitreport | undefined, + quantitativeReports: Report[] | undefined, + card: boolean, + ) { + const pitData = pitReport?.data; + const badges = getBaseBadges(pitReport, quantitativeReports); + + const intake = pitData?.intakeType; + const cooperates = BooleanAverage( + "Coopertition", + quantitativeReports ?? [], + ); + const climbs = BooleanAverage("ClimbedStage", quantitativeReports ?? []); + const parks = BooleanAverage("ParkedStage", quantitativeReports ?? []); + const understage = BooleanAverage("UnderStage", quantitativeReports ?? []); + + if (pitReport?.submitted && intake) { + const intakeBadge: Badge = { text: intake, color: "primary" }; + if (intake === IntakeTypes.Human) { + intakeBadge.color = "warning"; + } else if (intake === IntakeTypes.Both) { + intakeBadge.color = "secondary"; + } else if (intake === IntakeTypes.None) { + intakeBadge.color = "warning"; + intakeBadge.text = "No Intake"; + } + + badges.push(intakeBadge); + } + + if (cooperates) badges.push({ text: "Cooperates", color: "success" }); + + if (climbs) badges.push({ text: "Climbs", color: "accent" }); + + if (parks) badges.push({ text: "Parks", color: "primary" }); + + if (understage) + badges.push({ text: "Can Go Under Stage", color: "success" }); + + return badges; + } + + export const game = new Game( + "Crescendo", + 2024, + League.FRC, + QuantitativeData, + PitData, + pitReportLayout, + quantitativeReportLayout, + statsLayout, + pitStatsLayout, + "Crescendo", + "https://www.firstinspires.org/sites/default/files/uploads/resource_library/frc/crescendo/crescendo.png", + getBadges, + getAvgPoints, + ); } export namespace CenterStage { - export class QuantitativeData extends QuantData { - AutoScoredBackstage: number = 0; - AutoScoredBackdrop: number = 0; - AutoPlacedPixelOnSpikeMark: boolean = false; - AutoParked: boolean = false; - - TeleopScoredBackstage: number = 0; - Mosaics: number = 0; - SetLinesReached: number = 0; - - LandingZoneReached: number = 0; - EndgameParked: boolean = false; - EndgameClimbed: boolean = false; - } - - export class PitData extends PitReportData { - AutoBackstageSideExists: boolean = false; - AutoBackstageParkingLocation: CenterStageEnums.CenterStageParkingLocation = CenterStageEnums.CenterStageParkingLocation.NotApplicable; - AutoBackstageCanPlacePurplePixel: boolean = false; - AutoBackstageCanPlaceYellowPixelOnBackboard: boolean = false; - AutoBackstageCanPark: boolean = false; - AutoBackstageWhitePixels: number = 0; - AutoBackstageAdjustableToFitOurs: CenterStageEnums.AutoAdjustable = CenterStageEnums.AutoAdjustable.NoNeed; - - AutoAudienceSideExists: boolean = false; - AutoAudienceParkingLocation: CenterStageEnums.CenterStageParkingLocation = CenterStageEnums.CenterStageParkingLocation.NotApplicable; - AutoAudienceCanPlacePurplePixel: boolean = false; - AutoAudienceCanPlaceYellowPixelOnBackboard: boolean = false; - AutoAudienceCanPark: boolean = false; - AutoAudienceWhitePixels: number = 0; - AutoAudienceAdjustableToFitOurs: CenterStageEnums.AutoAdjustable = CenterStageEnums.AutoAdjustable.NoNeed; - - AutoSidePreference: CenterStageEnums.AutoSidePreference = CenterStageEnums.AutoSidePreference.NoPreference; - - CanPlaceOnBackboard: boolean = false; - CanPickUpFromStack: boolean = false; - PixelsMovedAtOnce: number = 0; - - EndgameCanLaunchDrone: boolean = false; - EndgameCanHang: boolean = false; - EndgameCanPark: boolean = false; - } - - const quantitativeReportLayout: FormLayoutProps = { - "Auto": [ - [["AutoScoredBackstage"], ["AutoScoredBackdrop"]], - "AutoPlacedPixelOnSpikeMark", - "AutoParked" - ], - "Teleop": [ - [ - ["TeleopScoredBackstage"], - ["Mosaics"], - ["SetLinesReached"] - ] - ], - "Endgame": [ - [["LandingZoneReached"]], - "EndgameParked", - "EndgameClimbed" - ] - } - - const pitReportLayout: FormLayoutProps = { - "Backstage Auto": [ - { key: "AutoBackstageSideExists", label: "Has Auto?" }, - { key: "AutoBackstageParkingLocation", label: "Parking Location" }, - { key: "AutoBackstageCanPlacePurplePixel", label: "Can Place Purple Pixel?" }, - { key: "AutoBackstageCanPlaceYellowPixelOnBackboard", label: "Can Place Yellow Pixel on Backboard?" }, - { key: "AutoBackstageCanPark", label: "Can Park?" }, - { key: "AutoBackstageWhitePixels", label: "White Pixels Place" }, - { key: "AutoBackstageAdjustableToFitOurs", label: "Adjustable to Fit Our Auto?" } - ], - "Audience Auto": [ - { key: "AutoAudienceSideExists", label: "Has Auto?" }, - { key: "AutoAudienceParkingLocation", label: "Parking Location" }, - { key: "AutoAudienceCanPlacePurplePixel", label: "Can Place Purple Pixel?" }, - { key: "AutoAudienceCanPlaceYellowPixelOnBackboard", label: "Can Place Yellow Pixel on Backboard?" }, - { key: "AutoAudienceCanPark", label: "Can Park?" }, - { key: "AutoAudienceWhitePixels", label: "White Pixels Place" }, - { key: "AutoAudienceAdjustableToFitOurs", label: "Adjustable to Fit Our Auto?" } - ], - "Auto": ["AutoSidePreference"], - "Teleop": [ - "CanPlaceOnBackboard", - "CanPickUpFromStack", - "PixelsMovedAtOnce" - ], - "Endgame": [ - { key: "EndgameCanLaunchDrone", label: "Can Launch Drone?" }, - { key: "EndgameCanHang", label: "Can Hang?" }, - { key: "EndgameCanPark", label: "Can Park?" } - ] - } - - const statsLayout: StatsLayout = { - sections: { - "Auto": [ - { - stats: [ - { label: "Avg Scored Backstage", key: "AutoScoredBackstage" }, - { label: "Avg Scored Backdrop", key: "AutoScoredBackdrop" } - ], - label: "Overall Auto Accuracy" - } - ], - "Teleop": [ - { - label: "Avg Scored Backstage", key: "TeleopScoredBackstage" - }, - { - label: "Avg Mosaics", key: "Mosaics" - }, - { - label: "Avg Set Lines Reached", key: "SetLinesReached" - } - ], - "Endgame": [ - { - label: "Avg Landing Zone Reached", key: "LandingZoneReached" - } - ] - }, - getGraphDots: (quantReports: Report[], pitReport?: Pitreport) => { - return []; - } - } - - const pitStatsLayout: PitStatsLayout = { - overallSlideStats: [ - { - label: "Avg Props", - get: (pitReport: Pitreport | undefined, quantitativeReports: Report[] | undefined) => { - if (!quantitativeReports) return 0; - - return quantitativeReports.reduce( - (acc, report) => acc + report.data.HasTeamProp + report.data.HasDrone, 0) - / quantitativeReports.length; - } - } - ], - individualSlideStats: [ - { - label: "Avg Auto Points", - get: (pitReport: Pitreport | undefined, quantitativeReports: Report[] | undefined) => { - if (!quantitativeReports) return 0; - - const autoBackstage = NumericalTotal("AutoScoredBackstage", quantitativeReports); - const autoBackdrop = NumericalTotal("AutoScoredBackdrop", quantitativeReports); - - return Round(autoBackstage + autoBackdrop) / quantitativeReports.length; - } - }, - { - label: "Avg Teleop Points", - get: (pitReport: Pitreport | undefined, quantitativeReports: Report[] | undefined) => { - if (!quantitativeReports) return 0; - - const teleopBackstage = NumericalTotal("TeleopScoredBackstage", quantitativeReports); - const mosaics = NumericalTotal("Mosaics", quantitativeReports); - const setLines = NumericalTotal("SetLinesReached", quantitativeReports); - - return Round(teleopBackstage + mosaics + setLines) / quantitativeReports.length; - } - }, - { - label: "Avg Endgame Points", - get: (pitReport: Pitreport | undefined, quantitativeReports: Report[] | undefined) => { - if (!quantitativeReports) return 0; - - const landingZone = NumericalTotal("LandingZoneReached", quantitativeReports); - - return Round(landingZone) / quantitativeReports.length; - } - } - ], - robotCapabilities: [ - { - label: "Has Team Prop", - key: "HasTeamProp" - }, - { - label: "Has Drone", - key: "HasDrone" - } - ], - graphStat: { - label: "Avg Props", - get: (pitReport: Pitreport | undefined, quantitativeReports: Report[] | undefined) => { - if (!quantitativeReports) return 0; - - return quantitativeReports.reduce( - (acc, report) => acc + report.data.HasTeamProp + report.data.HasDrone, 0) - / quantitativeReports.length; - } - } - } - - function getBadges(pitReport: Pitreport | undefined, quantitativeReports: Report[] | undefined, card: boolean) { - const badges: Badge[] = getBaseBadges(pitReport, quantitativeReports); - - if (pitReport?.data?.HasDrone) - badges.push({ text: "Has Drone", color: "primary" }); - if (pitReport?.data?.HasTeamProp) - badges.push({ text: "Has Team Prop", color: "info" }); - if (pitReport?.data?.AutoBackstageSideExists) - badges.push({ text: "Has Auto Backstage", color: "success" }); - if (pitReport?.data?.AutoAudienceSideExists) - badges.push({ text: "Has Auto Audience", color: "success" }); - if (pitReport?.data?.AutoBackstageCanPlacePurplePixel) - badges.push({ text: card ? "Purple Pixel Backstage Auto" : "Can Place Purple Pixel In Backstage Auto", color: "accent" }); - if (pitReport?.data?.AutoBackstageCanPlaceYellowPixelOnBackboard) - badges.push({ text: card ? "Yellow Pixel Backstage Auto" : "Can Place Yellow Pixel On Backboard In Backstage Auto", color: "accent" }); - if (pitReport?.data?.AutoAudienceCanPlacePurplePixel) - badges.push({ text: card ? "Purple Pixel Audience Auto" : "Can Place Purple Pixel In Audience Auto", color: "secondary" }); - if (pitReport?.data?.AutoAudienceCanPlaceYellowPixelOnBackboard) - badges.push({ text: card ? "Yellow Pixel Audience Auto" : "Can Place Yellow Pixel On Backboard In Audience Auto", color: "secondary" }); - if (pitReport?.data?.AutoBackstageCanPark) - badges.push({ text: "Can Park In Backstage Auto", color: "accent" }); - if (pitReport?.data?.AutoAudienceCanPark) - badges.push({ text: "Can Park In Audience Auto", color: "secondary" }); - if (pitReport?.data?.CanPlaceOnBackboard) - badges.push({ text: "Can Place On Backboard", color: "primary" }); - if (pitReport?.data?.CanPickUpFromStack) - badges.push({ text: "Can Pick Up From Stack", color: "info" }); - if (pitReport?.data?.EndgameCanLaunchDrone) - badges.push({ text: "Can Launch Drone", color: "success" }); - if (pitReport?.data?.EndgameCanHang) - badges.push({ text: "Can Hang", color: "accent" }); - if (pitReport?.data?.EndgameCanPark) - badges.push({ text: "Can Park", color: "primary" }); - - return badges; - } - - /** NOT ACCURATE, just for demo */ - function getAvgPoints(reports: Report[] | undefined) { - console.log("Getting avg points"); - - if (!reports) return 0; - - const autoBackstage = NumericalTotal("AutoScoredBackstage", reports); - const autoBackdrop = NumericalTotal("AutoScoredBackdrop", reports); - const teleopBackstage = NumericalTotal("TeleopScoredBackstage", reports); - const mosaics = NumericalTotal("Mosaics", reports); - const setLines = NumericalTotal("SetLinesReached", reports); - const landingZone = NumericalTotal("LandingZoneReached", reports); - - return Round(autoBackstage + autoBackdrop + teleopBackstage + mosaics + setLines + landingZone) / Math.max(reports.length, 1); - } - - export const game = new Game("Center Stage", 2024, League.FTC, QuantitativeData, PitData, pitReportLayout, quantitativeReportLayout, statsLayout, - pitStatsLayout, "CenterStage", "https://www.firstinspires.org/sites/default/files/uploads/resource_library/ftc/centerstage/centerstage.png", - getBadges, getAvgPoints); + export class QuantitativeData extends QuantData { + AutoScoredBackstage: number = 0; + AutoScoredBackdrop: number = 0; + AutoPlacedPixelOnSpikeMark: boolean = false; + AutoParked: boolean = false; + + TeleopScoredBackstage: number = 0; + Mosaics: number = 0; + SetLinesReached: number = 0; + + LandingZoneReached: number = 0; + EndgameParked: boolean = false; + EndgameClimbed: boolean = false; + } + + export class PitData extends PitReportData { + AutoBackstageSideExists: boolean = false; + AutoBackstageParkingLocation: CenterStageEnums.CenterStageParkingLocation = + CenterStageEnums.CenterStageParkingLocation.NotApplicable; + AutoBackstageCanPlacePurplePixel: boolean = false; + AutoBackstageCanPlaceYellowPixelOnBackboard: boolean = false; + AutoBackstageCanPark: boolean = false; + AutoBackstageWhitePixels: number = 0; + AutoBackstageAdjustableToFitOurs: CenterStageEnums.AutoAdjustable = + CenterStageEnums.AutoAdjustable.NoNeed; + + AutoAudienceSideExists: boolean = false; + AutoAudienceParkingLocation: CenterStageEnums.CenterStageParkingLocation = + CenterStageEnums.CenterStageParkingLocation.NotApplicable; + AutoAudienceCanPlacePurplePixel: boolean = false; + AutoAudienceCanPlaceYellowPixelOnBackboard: boolean = false; + AutoAudienceCanPark: boolean = false; + AutoAudienceWhitePixels: number = 0; + AutoAudienceAdjustableToFitOurs: CenterStageEnums.AutoAdjustable = + CenterStageEnums.AutoAdjustable.NoNeed; + + AutoSidePreference: CenterStageEnums.AutoSidePreference = + CenterStageEnums.AutoSidePreference.NoPreference; + + CanPlaceOnBackboard: boolean = false; + CanPickUpFromStack: boolean = false; + PixelsMovedAtOnce: number = 0; + + EndgameCanLaunchDrone: boolean = false; + EndgameCanHang: boolean = false; + EndgameCanPark: boolean = false; + } + + const quantitativeReportLayout: FormLayoutProps = { + Auto: [ + [["AutoScoredBackstage"], ["AutoScoredBackdrop"]], + "AutoPlacedPixelOnSpikeMark", + "AutoParked", + ], + Teleop: [[["TeleopScoredBackstage"], ["Mosaics"], ["SetLinesReached"]]], + Endgame: [[["LandingZoneReached"]], "EndgameParked", "EndgameClimbed"], + }; + + const pitReportLayout: FormLayoutProps = { + "Backstage Auto": [ + { key: "AutoBackstageSideExists", label: "Has Auto?" }, + { key: "AutoBackstageParkingLocation", label: "Parking Location" }, + { + key: "AutoBackstageCanPlacePurplePixel", + label: "Can Place Purple Pixel?", + }, + { + key: "AutoBackstageCanPlaceYellowPixelOnBackboard", + label: "Can Place Yellow Pixel on Backboard?", + }, + { key: "AutoBackstageCanPark", label: "Can Park?" }, + { key: "AutoBackstageWhitePixels", label: "White Pixels Place" }, + { + key: "AutoBackstageAdjustableToFitOurs", + label: "Adjustable to Fit Our Auto?", + }, + ], + "Audience Auto": [ + { key: "AutoAudienceSideExists", label: "Has Auto?" }, + { key: "AutoAudienceParkingLocation", label: "Parking Location" }, + { + key: "AutoAudienceCanPlacePurplePixel", + label: "Can Place Purple Pixel?", + }, + { + key: "AutoAudienceCanPlaceYellowPixelOnBackboard", + label: "Can Place Yellow Pixel on Backboard?", + }, + { key: "AutoAudienceCanPark", label: "Can Park?" }, + { key: "AutoAudienceWhitePixels", label: "White Pixels Place" }, + { + key: "AutoAudienceAdjustableToFitOurs", + label: "Adjustable to Fit Our Auto?", + }, + ], + Auto: ["AutoSidePreference"], + Teleop: ["CanPlaceOnBackboard", "CanPickUpFromStack", "PixelsMovedAtOnce"], + Endgame: [ + { key: "EndgameCanLaunchDrone", label: "Can Launch Drone?" }, + { key: "EndgameCanHang", label: "Can Hang?" }, + { key: "EndgameCanPark", label: "Can Park?" }, + ], + }; + + const statsLayout: StatsLayout = { + sections: { + Auto: [ + { + stats: [ + { label: "Avg Scored Backstage", key: "AutoScoredBackstage" }, + { label: "Avg Scored Backdrop", key: "AutoScoredBackdrop" }, + ], + label: "Overall Auto Accuracy", + }, + ], + Teleop: [ + { + label: "Avg Scored Backstage", + key: "TeleopScoredBackstage", + }, + { + label: "Avg Mosaics", + key: "Mosaics", + }, + { + label: "Avg Set Lines Reached", + key: "SetLinesReached", + }, + ], + Endgame: [ + { + label: "Avg Landing Zone Reached", + key: "LandingZoneReached", + }, + ], + }, + getGraphDots: ( + quantReports: Report[], + pitReport?: Pitreport, + ) => { + return []; + }, + }; + + const pitStatsLayout: PitStatsLayout = { + overallSlideStats: [ + { + label: "Avg Props", + get: ( + pitReport: Pitreport | undefined, + quantitativeReports: Report[] | undefined, + ) => { + if (!quantitativeReports) return 0; + + return ( + quantitativeReports.reduce( + (acc, report) => + acc + report.data.HasTeamProp + report.data.HasDrone, + 0, + ) / quantitativeReports.length + ); + }, + }, + ], + individualSlideStats: [ + { + label: "Avg Auto Points", + get: ( + pitReport: Pitreport | undefined, + quantitativeReports: Report[] | undefined, + ) => { + if (!quantitativeReports) return 0; + + const autoBackstage = NumericalTotal( + "AutoScoredBackstage", + quantitativeReports, + ); + const autoBackdrop = NumericalTotal( + "AutoScoredBackdrop", + quantitativeReports, + ); + + return ( + Round(autoBackstage + autoBackdrop) / quantitativeReports.length + ); + }, + }, + { + label: "Avg Teleop Points", + get: ( + pitReport: Pitreport | undefined, + quantitativeReports: Report[] | undefined, + ) => { + if (!quantitativeReports) return 0; + + const teleopBackstage = NumericalTotal( + "TeleopScoredBackstage", + quantitativeReports, + ); + const mosaics = NumericalTotal("Mosaics", quantitativeReports); + const setLines = NumericalTotal( + "SetLinesReached", + quantitativeReports, + ); + + return ( + Round(teleopBackstage + mosaics + setLines) / + quantitativeReports.length + ); + }, + }, + { + label: "Avg Endgame Points", + get: ( + pitReport: Pitreport | undefined, + quantitativeReports: Report[] | undefined, + ) => { + if (!quantitativeReports) return 0; + + const landingZone = NumericalTotal( + "LandingZoneReached", + quantitativeReports, + ); + + return Round(landingZone) / quantitativeReports.length; + }, + }, + ], + robotCapabilities: [ + { + label: "Has Team Prop", + key: "HasTeamProp", + }, + { + label: "Has Drone", + key: "HasDrone", + }, + ], + graphStat: { + label: "Avg Props", + get: ( + pitReport: Pitreport | undefined, + quantitativeReports: Report[] | undefined, + ) => { + if (!quantitativeReports) return 0; + + return ( + quantitativeReports.reduce( + (acc, report) => + acc + report.data.HasTeamProp + report.data.HasDrone, + 0, + ) / quantitativeReports.length + ); + }, + }, + }; + + function getBadges( + pitReport: Pitreport | undefined, + quantitativeReports: Report[] | undefined, + card: boolean, + ) { + const badges: Badge[] = getBaseBadges(pitReport, quantitativeReports); + + if (pitReport?.data?.HasDrone) + badges.push({ text: "Has Drone", color: "primary" }); + if (pitReport?.data?.HasTeamProp) + badges.push({ text: "Has Team Prop", color: "info" }); + if (pitReport?.data?.AutoBackstageSideExists) + badges.push({ text: "Has Auto Backstage", color: "success" }); + if (pitReport?.data?.AutoAudienceSideExists) + badges.push({ text: "Has Auto Audience", color: "success" }); + if (pitReport?.data?.AutoBackstageCanPlacePurplePixel) + badges.push({ + text: card + ? "Purple Pixel Backstage Auto" + : "Can Place Purple Pixel In Backstage Auto", + color: "accent", + }); + if (pitReport?.data?.AutoBackstageCanPlaceYellowPixelOnBackboard) + badges.push({ + text: card + ? "Yellow Pixel Backstage Auto" + : "Can Place Yellow Pixel On Backboard In Backstage Auto", + color: "accent", + }); + if (pitReport?.data?.AutoAudienceCanPlacePurplePixel) + badges.push({ + text: card + ? "Purple Pixel Audience Auto" + : "Can Place Purple Pixel In Audience Auto", + color: "secondary", + }); + if (pitReport?.data?.AutoAudienceCanPlaceYellowPixelOnBackboard) + badges.push({ + text: card + ? "Yellow Pixel Audience Auto" + : "Can Place Yellow Pixel On Backboard In Audience Auto", + color: "secondary", + }); + if (pitReport?.data?.AutoBackstageCanPark) + badges.push({ text: "Can Park In Backstage Auto", color: "accent" }); + if (pitReport?.data?.AutoAudienceCanPark) + badges.push({ text: "Can Park In Audience Auto", color: "secondary" }); + if (pitReport?.data?.CanPlaceOnBackboard) + badges.push({ text: "Can Place On Backboard", color: "primary" }); + if (pitReport?.data?.CanPickUpFromStack) + badges.push({ text: "Can Pick Up From Stack", color: "info" }); + if (pitReport?.data?.EndgameCanLaunchDrone) + badges.push({ text: "Can Launch Drone", color: "success" }); + if (pitReport?.data?.EndgameCanHang) + badges.push({ text: "Can Hang", color: "accent" }); + if (pitReport?.data?.EndgameCanPark) + badges.push({ text: "Can Park", color: "primary" }); + + return badges; + } + + /** NOT ACCURATE, just for demo */ + function getAvgPoints(reports: Report[] | undefined) { + console.log("Getting avg points"); + + if (!reports) return 0; + + const autoBackstage = NumericalTotal("AutoScoredBackstage", reports); + const autoBackdrop = NumericalTotal("AutoScoredBackdrop", reports); + const teleopBackstage = NumericalTotal("TeleopScoredBackstage", reports); + const mosaics = NumericalTotal("Mosaics", reports); + const setLines = NumericalTotal("SetLinesReached", reports); + const landingZone = NumericalTotal("LandingZoneReached", reports); + + return ( + Round( + autoBackstage + + autoBackdrop + + teleopBackstage + + mosaics + + setLines + + landingZone, + ) / Math.max(reports.length, 1) + ); + } + + export const game = new Game( + "Center Stage", + 2024, + League.FTC, + QuantitativeData, + PitData, + pitReportLayout, + quantitativeReportLayout, + statsLayout, + pitStatsLayout, + "CenterStage", + "https://www.firstinspires.org/sites/default/files/uploads/resource_library/ftc/centerstage/centerstage.png", + getBadges, + getAvgPoints, + ); } namespace IntoTheDeep { - export class QuantitativeData extends QuantData { - StartedWith: IntoTheDeepEnums.StartedWith = IntoTheDeepEnums.StartedWith.Nothing; - - AutoScoredNetZone: number = 0; - AutoScoredLowNet: number = 0; - AutoScoredHighNet: number = 0; - AutoScoredLowRung: number = 0; - AutoScoredHighRung: number = 0; - - TeleopScoredNetZone: number = 0; - TeleopScoredLowNet: number = 0; - TeleopScoredHighNet: number = 0; - TeleopScoredLowRung: number = 0; - TeleopScoredHighRung: number = 0; - - EndgameLevelClimbed: IntoTheDeepEnums.EndgameLevelClimbed = IntoTheDeepEnums.EndgameLevelClimbed.None; - } - - export class PitData extends PitReportData { - CanPlaceInLowerBasket: boolean = false; - CanPlaceInUpperBasket: boolean = false; - CanPlaceOnLowerRung: boolean = false; - CanPlaceOnUpperRung: boolean = false; - HighestHangLevel: IntoTheDeepEnums.EndgameLevelClimbed = IntoTheDeepEnums.EndgameLevelClimbed.None; - SamplesScoredInAuto: number = 0; - SpecimensScoredInAuto: number = 0; - AutonomousStrategy: string = ""; - AutoStartPreferred: FieldPos = FieldPos.Zero; - AutoEndPreferred: FieldPos = FieldPos.Zero; - GameStrategy: string = ""; - } - - const pitReportLayout: FormLayoutProps = { - "Capabilities": [ - { key: "CanPlaceInLowerBasket", label: "Can Place in Lower Basket?" }, - { key: "CanPlaceInUpperBasket", label: "Can Place in Upper Basket?" }, - { key: "CanPlaceOnLowerRung", label: "Can Place on Lower Rung?" }, - { key: "CanPlaceOnUpperRung", label: "Can Place on Upper Rung?" }, - { key: "HighestHangLevel", label: "Highest Hang Level" } - ], - "Auto": [ - { key: "SamplesScoredInAuto", label: "Samples Scored in Auto" }, - { key: "SpecimensScoredInAuto", label: "Specimens Scored in Auto" }, - { key: "AutonomousStrategy", label: "Autonomous Strategy" }, - { key: "AutoStartPreferred", label: "Preferred Auto Start Position" }, - { key: "AutoEndPreferred", label: "Preferred Auto End Position" } - ], - "General": [ - { key: "GameStrategy", label: "Game Strategy" } - ] - } - - const quantitativeReportLayout: FormLayoutProps = { - "Pre-Match": [ - "StartedWith" - ], - "Auto": [ - [ - ["AutoScoredNetZone"], ["AutoScoredLowNet"], ["AutoScoredHighNet"] - ], - [ - ["AutoScoredLowRung"], ["AutoScoredHighRung"] - ] - ], - "Teleop & Endgame": [ - [ - ["TeleopScoredNetZone"], ["TeleopScoredLowNet"], ["TeleopScoredHighNet"] - ], - [ - ["TeleopScoredLowRung"], ["TeleopScoredHighRung"] - ], - "EndgameLevelClimbed" - ], - } - - const statsLayout: StatsLayout = { - sections: { - "Auto": [ - { - key: "AutoScoredNetZone", - label: "Avg Scored Net Zone" - }, - { - stats: [ - { label: "Avg Scored Low Net", key: "AutoScoredLowNet" }, - { label: "Avg Scored High Net", key: "AutoScoredHighNet" } - ], - label: "Overall Auto % in Low Net" - }, - { - stats: [ - { label: "Avg Scored Low Rung", key: "AutoScoredLowRung" }, - { label: "Avg Scored High Rung", key: "AutoScoredHighRung" } - ], - label: "Overall Auto % on Low Rung" - } - ], - "Teleop": [ - { - key: "TeleopScoredNetZone", - label: "Avg Scored Net Zone" - }, - { - stats: [ - { label: "Avg Scored Low Net", key: "TeleopScoredLowNet" }, - { label: "Avg Scored High Net", key: "TeleopScoredHighNet" } - ], - label: "Overall Teleop % in Low Net" - }, - { - stats: [ - { label: "Avg Scored Low Rung", key: "TeleopScoredLowRung" }, - { label: "Avg Scored High Rung", key: "TeleopScoredHighRung" } - ], - label: "Overall Teleop % on Low Rung" - } - ], - "Endgame": [ - { - label: "Avg Level Climbed", key: "EndgameLevelClimbed" - } - ] - }, - getGraphDots: (quantReports: Report[], pitReport?: Pitreport) => { - return [ - { - ...pitReport?.data?.AutoStartPreferred, - color: { r: 255, g: 0, b: 0, a: 255 }, - size: 10, - label: "Red dot is preferred auto start" - }, - { - ...pitReport?.data?.AutoEndPreferred, - color: { r: 0, g: 0, b: 255, a: 255 }, - size: 10, - label: "Blue dot is preferred auto end" - } - ]; - } - } - - const pitStatsLayout: PitStatsLayout = { - overallSlideStats: [ - { - label: "Avg Samples Scored in Auto", - key: "SamplesScoredInAuto" - }, - { - label: "Avg Specimens Scored in Auto", - key: "SpecimensScoredInAuto" - } - ], - individualSlideStats: [ - { - label: "Avg Auto Points", - get: (pitReport: Pitreport | undefined, quantitativeReports: Report[] | undefined) => { - if (!quantitativeReports) return 0; - - const netZone = NumericalTotal("AutoScoredNetZone", quantitativeReports); - const lowNet = NumericalTotal("AutoScoredLowNet", quantitativeReports); - const highNet = NumericalTotal("AutoScoredHighNet", quantitativeReports); - const lowRung = NumericalTotal("AutoScoredLowRung", quantitativeReports); - const highRung = NumericalTotal("AutoScoredHighRung", quantitativeReports); - - return Round(netZone + lowNet + highNet + lowRung + highRung) / quantitativeReports.length; - } - }, - { - label: "Avg Teleop Points", - get: (pitReport: Pitreport | undefined, quantitativeReports: Report[] | undefined) => { - if (!quantitativeReports) return 0; - - const netZone = NumericalTotal("TeleopScoredNetZone", quantitativeReports); - const lowNet = NumericalTotal("TeleopScoredLowNet", quantitativeReports); - const highNet = NumericalTotal("TeleopScoredHighNet", quantitativeReports); - const lowRung = NumericalTotal("TeleopScoredLowRung", quantitativeReports); - const highRung = NumericalTotal("TeleopScoredHighRung", quantitativeReports); - - return Round(netZone + lowNet + highNet + lowRung + highRung) / quantitativeReports.length; - } - }, - { - label: "Avg Endgame Points", - get: (pitReport: Pitreport | undefined, quantitativeReports: Report[] | undefined) => { - if (!quantitativeReports) return 0; - - const climbed = NumericalTotal("EndgameLevelClimbed", quantitativeReports); - - return Round(climbed) / quantitativeReports.length; - } - } - ], - robotCapabilities: [ - { - label: "Can Place in Lower Basket", - key: "CanPlaceInLowerBasket" - }, - { - label: "Can Place in Upper Basket", - key: "CanPlaceInUpperBasket" - }, - { - label: "Can Place on Lower Rung", - key: "CanPlaceOnLowerRung" - }, - { - label: "Can Place on Upper Rung", - key: "CanPlaceOnUpperRung" - } - ], - graphStat: { - label: "Avg Samples Scored in Auto", - key: "SamplesScoredInAuto" - } - } - - function getBadges(pitReport: Pitreport | undefined, quantitativeReports: Report[] | undefined, card: boolean) { - const badges: Badge[] = getBaseBadges(pitReport, quantitativeReports); - - if (pitReport?.data?.CanPlaceInLowerBasket) - badges.push({ text: "Can Place in Lower Basket", color: "primary" }); - if (pitReport?.data?.CanPlaceInUpperBasket) - badges.push({ text: "Can Place in Upper Basket", color: "info" }); - if (pitReport?.data?.CanPlaceOnLowerRung) - badges.push({ text: "Can Place on Lower Rung", color: "success" }); - if (pitReport?.data?.CanPlaceOnUpperRung) - badges.push({ text: "Can Place on Upper Rung", color: "warning" }); - - return badges; - } - - function getAvgPoints(reports: Report[] | undefined) { - if (!reports) return 0; - - let totalPoints = 0; - for (const report of reports.map(r => r.data)) { - switch (report.EndgameLevelClimbed) { - case IntoTheDeepEnums.EndgameLevelClimbed.Parked: - case IntoTheDeepEnums.EndgameLevelClimbed.TouchedLowRung: - totalPoints += 3; - break; - case IntoTheDeepEnums.EndgameLevelClimbed.LowLevelClimb: - totalPoints += 15; - break; - case IntoTheDeepEnums.EndgameLevelClimbed.HighLevelClimb: - totalPoints += 30; - break; - } - - totalPoints += (report.AutoScoredNetZone + report.TeleopScoredNetZone) * 2; - totalPoints += (report.AutoScoredLowNet + report.TeleopScoredLowNet) * 4; - totalPoints += (report.AutoScoredHighNet + report.TeleopScoredHighNet) * 8; - totalPoints += (report.AutoScoredLowRung + report.TeleopScoredLowRung) * 6; - totalPoints += (report.AutoScoredHighRung + report.TeleopScoredHighRung) * 10; - } - - // Avoid divide by 0! - return totalPoints / Math.max(reports.length, 1); - } - - export const game = new Game("Into the Deep", 2025, League.FTC, QuantitativeData, PitData, pitReportLayout, quantitativeReportLayout, statsLayout, - pitStatsLayout, "IntoTheDeep", "https://info.firstinspires.org/hubfs/Dive/into-the-deep.svg", getBadges, getAvgPoints - ); + export class QuantitativeData extends QuantData { + StartedWith: IntoTheDeepEnums.StartedWith = + IntoTheDeepEnums.StartedWith.Nothing; + + AutoScoredNetZone: number = 0; + AutoScoredLowNet: number = 0; + AutoScoredHighNet: number = 0; + AutoScoredLowRung: number = 0; + AutoScoredHighRung: number = 0; + + TeleopScoredNetZone: number = 0; + TeleopScoredLowNet: number = 0; + TeleopScoredHighNet: number = 0; + TeleopScoredLowRung: number = 0; + TeleopScoredHighRung: number = 0; + + EndgameLevelClimbed: IntoTheDeepEnums.EndgameLevelClimbed = + IntoTheDeepEnums.EndgameLevelClimbed.None; + } + + export class PitData extends PitReportData { + CanPlaceInLowerBasket: boolean = false; + CanPlaceInUpperBasket: boolean = false; + CanPlaceOnLowerRung: boolean = false; + CanPlaceOnUpperRung: boolean = false; + HighestHangLevel: IntoTheDeepEnums.EndgameLevelClimbed = + IntoTheDeepEnums.EndgameLevelClimbed.None; + SamplesScoredInAuto: number = 0; + SpecimensScoredInAuto: number = 0; + AutonomousStrategy: string = ""; + AutoStartPreferred: FieldPos = FieldPos.Zero; + AutoEndPreferred: FieldPos = FieldPos.Zero; + GameStrategy: string = ""; + } + + const pitReportLayout: FormLayoutProps = { + Capabilities: [ + { key: "CanPlaceInLowerBasket", label: "Can Place in Lower Basket?" }, + { key: "CanPlaceInUpperBasket", label: "Can Place in Upper Basket?" }, + { key: "CanPlaceOnLowerRung", label: "Can Place on Lower Rung?" }, + { key: "CanPlaceOnUpperRung", label: "Can Place on Upper Rung?" }, + { key: "HighestHangLevel", label: "Highest Hang Level" }, + ], + Auto: [ + { key: "SamplesScoredInAuto", label: "Samples Scored in Auto" }, + { key: "SpecimensScoredInAuto", label: "Specimens Scored in Auto" }, + { key: "AutonomousStrategy", label: "Autonomous Strategy" }, + { key: "AutoStartPreferred", label: "Preferred Auto Start Position" }, + { key: "AutoEndPreferred", label: "Preferred Auto End Position" }, + ], + General: [{ key: "GameStrategy", label: "Game Strategy" }], + }; + + const quantitativeReportLayout: FormLayoutProps = { + "Pre-Match": ["StartedWith"], + Auto: [ + [["AutoScoredNetZone"], ["AutoScoredLowNet"], ["AutoScoredHighNet"]], + [["AutoScoredLowRung"], ["AutoScoredHighRung"]], + ], + "Teleop & Endgame": [ + [ + ["TeleopScoredNetZone"], + ["TeleopScoredLowNet"], + ["TeleopScoredHighNet"], + ], + [["TeleopScoredLowRung"], ["TeleopScoredHighRung"]], + "EndgameLevelClimbed", + ], + }; + + const statsLayout: StatsLayout = { + sections: { + Auto: [ + { + key: "AutoScoredNetZone", + label: "Avg Scored Net Zone", + }, + { + stats: [ + { label: "Avg Scored Low Net", key: "AutoScoredLowNet" }, + { label: "Avg Scored High Net", key: "AutoScoredHighNet" }, + ], + label: "Overall Auto % in Low Net", + }, + { + stats: [ + { label: "Avg Scored Low Rung", key: "AutoScoredLowRung" }, + { label: "Avg Scored High Rung", key: "AutoScoredHighRung" }, + ], + label: "Overall Auto % on Low Rung", + }, + ], + Teleop: [ + { + key: "TeleopScoredNetZone", + label: "Avg Scored Net Zone", + }, + { + stats: [ + { label: "Avg Scored Low Net", key: "TeleopScoredLowNet" }, + { label: "Avg Scored High Net", key: "TeleopScoredHighNet" }, + ], + label: "Overall Teleop % in Low Net", + }, + { + stats: [ + { label: "Avg Scored Low Rung", key: "TeleopScoredLowRung" }, + { label: "Avg Scored High Rung", key: "TeleopScoredHighRung" }, + ], + label: "Overall Teleop % on Low Rung", + }, + ], + Endgame: [ + { + label: "Avg Level Climbed", + key: "EndgameLevelClimbed", + }, + ], + }, + getGraphDots: ( + quantReports: Report[], + pitReport?: Pitreport, + ) => { + return [ + { + ...pitReport?.data?.AutoStartPreferred, + color: { r: 255, g: 0, b: 0, a: 255 }, + size: 10, + label: "Red dot is preferred auto start", + }, + { + ...pitReport?.data?.AutoEndPreferred, + color: { r: 0, g: 0, b: 255, a: 255 }, + size: 10, + label: "Blue dot is preferred auto end", + }, + ]; + }, + }; + + const pitStatsLayout: PitStatsLayout = { + overallSlideStats: [ + { + label: "Avg Samples Scored in Auto", + key: "SamplesScoredInAuto", + }, + { + label: "Avg Specimens Scored in Auto", + key: "SpecimensScoredInAuto", + }, + ], + individualSlideStats: [ + { + label: "Avg Auto Points", + get: ( + pitReport: Pitreport | undefined, + quantitativeReports: Report[] | undefined, + ) => { + if (!quantitativeReports) return 0; + + const netZone = NumericalTotal( + "AutoScoredNetZone", + quantitativeReports, + ); + const lowNet = NumericalTotal( + "AutoScoredLowNet", + quantitativeReports, + ); + const highNet = NumericalTotal( + "AutoScoredHighNet", + quantitativeReports, + ); + const lowRung = NumericalTotal( + "AutoScoredLowRung", + quantitativeReports, + ); + const highRung = NumericalTotal( + "AutoScoredHighRung", + quantitativeReports, + ); + + return ( + Round(netZone + lowNet + highNet + lowRung + highRung) / + quantitativeReports.length + ); + }, + }, + { + label: "Avg Teleop Points", + get: ( + pitReport: Pitreport | undefined, + quantitativeReports: Report[] | undefined, + ) => { + if (!quantitativeReports) return 0; + + const netZone = NumericalTotal( + "TeleopScoredNetZone", + quantitativeReports, + ); + const lowNet = NumericalTotal( + "TeleopScoredLowNet", + quantitativeReports, + ); + const highNet = NumericalTotal( + "TeleopScoredHighNet", + quantitativeReports, + ); + const lowRung = NumericalTotal( + "TeleopScoredLowRung", + quantitativeReports, + ); + const highRung = NumericalTotal( + "TeleopScoredHighRung", + quantitativeReports, + ); + + return ( + Round(netZone + lowNet + highNet + lowRung + highRung) / + quantitativeReports.length + ); + }, + }, + { + label: "Avg Endgame Points", + get: ( + pitReport: Pitreport | undefined, + quantitativeReports: Report[] | undefined, + ) => { + if (!quantitativeReports) return 0; + + const climbed = NumericalTotal( + "EndgameLevelClimbed", + quantitativeReports, + ); + + return Round(climbed) / quantitativeReports.length; + }, + }, + ], + robotCapabilities: [ + { + label: "Can Place in Lower Basket", + key: "CanPlaceInLowerBasket", + }, + { + label: "Can Place in Upper Basket", + key: "CanPlaceInUpperBasket", + }, + { + label: "Can Place on Lower Rung", + key: "CanPlaceOnLowerRung", + }, + { + label: "Can Place on Upper Rung", + key: "CanPlaceOnUpperRung", + }, + ], + graphStat: { + label: "Avg Samples Scored in Auto", + key: "SamplesScoredInAuto", + }, + }; + + function getBadges( + pitReport: Pitreport | undefined, + quantitativeReports: Report[] | undefined, + card: boolean, + ) { + const badges: Badge[] = getBaseBadges(pitReport, quantitativeReports); + + if (pitReport?.data?.CanPlaceInLowerBasket) + badges.push({ text: "Can Place in Lower Basket", color: "primary" }); + if (pitReport?.data?.CanPlaceInUpperBasket) + badges.push({ text: "Can Place in Upper Basket", color: "info" }); + if (pitReport?.data?.CanPlaceOnLowerRung) + badges.push({ text: "Can Place on Lower Rung", color: "success" }); + if (pitReport?.data?.CanPlaceOnUpperRung) + badges.push({ text: "Can Place on Upper Rung", color: "warning" }); + + return badges; + } + + function getAvgPoints(reports: Report[] | undefined) { + if (!reports) return 0; + + let totalPoints = 0; + for (const report of reports.map((r) => r.data)) { + switch (report.EndgameLevelClimbed) { + case IntoTheDeepEnums.EndgameLevelClimbed.Parked: + case IntoTheDeepEnums.EndgameLevelClimbed.TouchedLowRung: + totalPoints += 3; + break; + case IntoTheDeepEnums.EndgameLevelClimbed.LowLevelClimb: + totalPoints += 15; + break; + case IntoTheDeepEnums.EndgameLevelClimbed.HighLevelClimb: + totalPoints += 30; + break; + } + + totalPoints += + (report.AutoScoredNetZone + report.TeleopScoredNetZone) * 2; + totalPoints += (report.AutoScoredLowNet + report.TeleopScoredLowNet) * 4; + totalPoints += + (report.AutoScoredHighNet + report.TeleopScoredHighNet) * 8; + totalPoints += + (report.AutoScoredLowRung + report.TeleopScoredLowRung) * 6; + totalPoints += + (report.AutoScoredHighRung + report.TeleopScoredHighRung) * 10; + } + + // Avoid divide by 0! + return totalPoints / Math.max(reports.length, 1); + } + + export const game = new Game( + "Into the Deep", + 2025, + League.FTC, + QuantitativeData, + PitData, + pitReportLayout, + quantitativeReportLayout, + statsLayout, + pitStatsLayout, + "IntoTheDeep", + "https://info.firstinspires.org/hubfs/Dive/into-the-deep.svg", + getBadges, + getAvgPoints, + ); } export const games: { [id in GameId]: Game } = Object.freeze({ - [GameId.IntoTheDeep]: IntoTheDeep.game, - [GameId.Crescendo]: Crescendo.game, - [GameId.CenterStage]: CenterStage.game -}); \ No newline at end of file + [GameId.IntoTheDeep]: IntoTheDeep.game, + [GameId.Crescendo]: Crescendo.game, + [GameId.CenterStage]: CenterStage.game, +}); diff --git a/lib/testutils/TestUtils.ts b/lib/testutils/TestUtils.ts index c2d8807a..38dfc841 100644 --- a/lib/testutils/TestUtils.ts +++ b/lib/testutils/TestUtils.ts @@ -10,82 +10,91 @@ import { ResendInterface } from "../ResendUtils"; import { SlackInterface } from "../SlackClient"; export class TestRes extends ApiLib.ApiResponse { - status = jest.fn((code) => this); - send = jest.fn((obj) => this); - error = jest.fn((code, message) => { - this.status(code); - this.send({ error: message }); - return this; - }); + status = jest.fn((code) => this); + send = jest.fn((obj) => this); + error = jest.fn((code, message) => { + this.status(code); + this.send({ error: message }); + return this; + }); - constructor(res?: NextApiResponse) { - super(res ?? {} as NextApiResponse); - } + constructor(res?: NextApiResponse) { + super(res ?? ({} as NextApiResponse)); + } } export class TestResend implements ResendInterface { - createContact = jest.fn(); - emailDevelopers = jest.fn(); + createContact = jest.fn(); + emailDevelopers = jest.fn(); } export class TestSlackClient implements SlackInterface { - sendMsg = jest.fn(() => Promise.resolve()); + sendMsg = jest.fn(() => Promise.resolve()); } function getTestUser() { - return { - _id: new ObjectId(), - email: "", - name: "", - image: "", - teams: [], - owner: [] - } as any as User; + return { + _id: new ObjectId(), + email: "", + name: "", + image: "", + teams: [], + owner: [], + } as any as User; } export async function getTestApiUtils() { - const db = new InMemoryDbInterface(); - db.init(); + const db = new InMemoryDbInterface(); + db.init(); - const user = getTestUser(); - await db.addObject(CollectionId.Users, user); + const user = getTestUser(); + await db.addObject(CollectionId.Users, user); - return { - res: new TestRes(), - db, - resend: new TestResend(), - user - } + return { + res: new TestRes(), + db, + resend: new TestResend(), + user, + }; } -export async function getTestApiParams, TAuthData = undefined>( - res: TestRes, - deps: Partial | Partial<{ db: DbInterface, user: Partial, resend: ResendInterface }>, - args: TArgs, - authData: TAuthData = undefined as any +export async function getTestApiParams< + TArgs extends Array, + TAuthData = undefined, +>( + res: TestRes, + deps: + | Partial + | Partial<{ + db: DbInterface; + user: Partial; + resend: ResendInterface; + }>, + args: TArgs, + authData: TAuthData = undefined as any, ): Promise<[any, TestRes, ApiDependencies, TAuthData, any]> { - let user = (deps as any).user ?? (deps as any).userPromise; - const db = await deps.db ?? new InMemoryDbInterface(); + let user = (deps as any).user ?? (deps as any).userPromise; + const db = (await deps.db) ?? new InMemoryDbInterface(); - if (!user) { - user = getTestUser(); - (deps as any).user = user; + if (!user) { + user = getTestUser(); + (deps as any).user = user; - await db.addObject(CollectionId.Users, user); - } + await db.addObject(CollectionId.Users, user); + } - return [ - {} as any, - res, - { - db: Promise.resolve(db), - slackClient: new TestSlackClient(), - userPromise: Promise.resolve(user), - tba: undefined, - resend: deps.resend ?? new TestResend(), - ...deps - } as ApiDependencies, - authData, - args - ] -} \ No newline at end of file + return [ + {} as any, + res, + { + db: Promise.resolve(db), + slackClient: new TestSlackClient(), + userPromise: Promise.resolve(user), + tba: undefined, + resend: deps.resend ?? new TestResend(), + ...deps, + } as ApiDependencies, + authData, + args, + ]; +} diff --git a/lib/testutils/setup.ts b/lib/testutils/setup.ts index 872b8b4d..967c95da 100644 --- a/lib/testutils/setup.ts +++ b/lib/testutils/setup.ts @@ -1,3 +1,3 @@ import * as dotenv from "dotenv"; -dotenv.config({ path: ".env.test" }); \ No newline at end of file +dotenv.config({ path: ".env.test" }); diff --git a/next.config.js b/next.config.js index d472677f..b4d99700 100644 --- a/next.config.js +++ b/next.config.js @@ -1,24 +1,24 @@ /** @type {import('next').NextConfig} */ const nextConfig = { - reactStrictMode: false, - swcMinify: false, - images: { - domains: [ - "lh3.googleusercontent.com", - "www.mouser.de", - "www.firstinspires.org", - "files.slack.com", - ], - }, - publicRuntimeConfig: { - buildTime: Date.now(), - }, - env: { - NEXT_PUBLIC_BUILD_TIME: Date.now().toString(), - }, - eslint: { - dirs: ["pages", "components", "lib", "tests"], - } + reactStrictMode: false, + swcMinify: false, + images: { + domains: [ + "lh3.googleusercontent.com", + "www.mouser.de", + "www.firstinspires.org", + "files.slack.com", + ], + }, + publicRuntimeConfig: { + buildTime: Date.now(), + }, + env: { + NEXT_PUBLIC_BUILD_TIME: Date.now().toString(), + }, + eslint: { + dirs: ["pages", "components", "lib", "tests"], + }, }; module.exports = nextConfig; diff --git a/package.json b/package.json index b16740e4..57b139b0 100644 --- a/package.json +++ b/package.json @@ -1,82 +1,82 @@ { - "name": "sj3", - "version": "0.1.0", - "private": true, - "scripts": { - "dev": "cross-env NODE_ENV=development npx tsx index.ts", - "build": "next build", - "start": "cross-env NODE_ENV=production npx tsx index.ts", - "restart": "next build && cross-env NODE_ENV=production npx tsx index.ts", - "docker-build": "docker build -t gearbox .", - "docker-start": "docker run -i -t -p 443:443 gearbox", - "docker-prune": "docker container prune", - "lint": "eslint . --max-warnings=0", - "prettier-fix": "prettier --write ./**/*.{js,tsx,jsx,json,ts}", - "prettier-check": "prettier --check ./**/*.{js,tsx,jsx,json,ts}", - "test": "jest" - }, - "dependencies": { - "@next-auth/mongodb-adapter": "^1.1.3", - "@slack/bolt": "^4.1.0", - "@slack/web-api": "^7.0.2", - "@types/http-proxy": "^1.17.15", - "@types/react-dom": "18.3.1", - "@types/socket.io-client": "^3.0.0", - "@yudiel/react-qr-scanner": "^2.0.8", - "bootstrap": "^5.3.3", - "browser-image-compression": "^2.0.2", - "bson": "^5.0.0", - "cloc": "^2.11.0", - "dependencies": "^0.0.1", - "dotenv": "^16.4.5", - "eslint": "9.16.0", - "eslint-config-next": "15.0.3", - "formidable": "^3.5.2", - "jose": "^5.9.6", - "levenary": "^1.1.1", - "minimongo": "^6.19.0", - "mongodb": "^5.0.0", - "next": "^15.0.3", - "next-auth": "^4.24.10", - "next-pwa": "^5.6.0", - "next-seo": "^6.6.0", - "nodemailer": "^6.9.16", - "react": "18.3.1", - "react-beautiful-dnd": "^13.1.1", - "react-bootstrap": "^2.10.5", - "react-chartjs-2": "^5.2.0", - "react-dnd": "^16.0.1", - "react-dnd-html5-backend": "^16.0.1", - "react-dom": "18.3.1", - "react-ga4": "^2.1.0", - "react-hot-toast": "^2.4.1", - "react-icons": "^5.3.0", - "react-p5": "^1.4.1", - "react-qr-code": "^2.0.15", - "resend": "^4.0.0", - "slack": "^11.0.2", - "socket.io": "^4.8.1", - "socket.io-client": "^4.7.2", - "string-similarity-js": "^2.1.4", - "ts-node": "^10.9.2", - "tsx": "^4.19.2", - "typescript": "5.6.2" - }, - "devDependencies": { - "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "^9.16.0", - "@jest/globals": "^29.7.0", - "@types/formidable": "^3.4.5", - "@types/jest": "^29.5.13", - "@types/node": "^20.11.16", - "@types/react": "^18.3.8", - "autoprefixer": "^10.4.20", - "cross-env": "^7.0.3", - "daisyui": "^4.12.14", - "jest": "^29.7.0", - "postcss": "^8.4.47", - "prettier": "3.3.3", - "tailwindcss": "^3.3.2", - "ts-jest": "^29.2.5" - } + "name": "sj3", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "cross-env NODE_ENV=development npx tsx index.ts", + "build": "next build", + "start": "cross-env NODE_ENV=production npx tsx index.ts", + "restart": "next build && cross-env NODE_ENV=production npx tsx index.ts", + "docker-build": "docker build -t gearbox .", + "docker-start": "docker run -i -t -p 443:443 gearbox", + "docker-prune": "docker container prune", + "lint": "eslint . --max-warnings=0", + "prettier-fix": "prettier --write ./**/*.{js,tsx,jsx,json,ts}", + "prettier-check": "prettier --check ./**/*.{js,tsx,jsx,json,ts}", + "test": "jest" + }, + "dependencies": { + "@next-auth/mongodb-adapter": "^1.1.3", + "@slack/bolt": "^4.1.0", + "@slack/web-api": "^7.0.2", + "@types/http-proxy": "^1.17.15", + "@types/react-dom": "18.3.1", + "@types/socket.io-client": "^3.0.0", + "@yudiel/react-qr-scanner": "^2.0.8", + "bootstrap": "^5.3.3", + "browser-image-compression": "^2.0.2", + "bson": "^5.0.0", + "cloc": "^2.11.0", + "dependencies": "^0.0.1", + "dotenv": "^16.4.5", + "eslint": "9.16.0", + "eslint-config-next": "15.0.3", + "formidable": "^3.5.2", + "jose": "^5.9.6", + "levenary": "^1.1.1", + "minimongo": "^6.19.0", + "mongodb": "^5.0.0", + "next": "^15.0.3", + "next-auth": "^4.24.10", + "next-pwa": "^5.6.0", + "next-seo": "^6.6.0", + "nodemailer": "^6.9.16", + "react": "18.3.1", + "react-beautiful-dnd": "^13.1.1", + "react-bootstrap": "^2.10.5", + "react-chartjs-2": "^5.2.0", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", + "react-dom": "18.3.1", + "react-ga4": "^2.1.0", + "react-hot-toast": "^2.4.1", + "react-icons": "^5.3.0", + "react-p5": "^1.4.1", + "react-qr-code": "^2.0.15", + "resend": "^4.0.0", + "slack": "^11.0.2", + "socket.io": "^4.8.1", + "socket.io-client": "^4.7.2", + "string-similarity-js": "^2.1.4", + "ts-node": "^10.9.2", + "tsx": "^4.19.2", + "typescript": "5.6.2" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "^9.16.0", + "@jest/globals": "^29.7.0", + "@types/formidable": "^3.4.5", + "@types/jest": "^29.5.13", + "@types/node": "^20.11.16", + "@types/react": "^18.3.8", + "autoprefixer": "^10.4.20", + "cross-env": "^7.0.3", + "daisyui": "^4.12.14", + "jest": "^29.7.0", + "postcss": "^8.4.47", + "prettier": "3.3.3", + "tailwindcss": "^3.3.2", + "ts-jest": "^29.2.5" + } } diff --git a/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/[reportId]/index.tsx b/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/[reportId]/index.tsx index d2c879e0..12eeb60c 100644 --- a/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/[reportId]/index.tsx +++ b/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/[reportId]/index.tsx @@ -14,40 +14,42 @@ import ClientApi from "@/lib/api/ClientApi"; const api = new ClientApi(); export default function Homepage(props: FormProps) { - const { session, status } = useCurrentSession(); - const hide = status === "authenticated"; + const { session, status } = useCurrentSession(); + const hide = status === "authenticated"; - useEffect(() => { - if (props.report) - setInterval(() => api.checkInForReport(props.report._id!), 5000); - }, []); + useEffect(() => { + if (props.report) + setInterval(() => api.checkInForReport(props.report._id!), 5000); + }, []); - return ( - - {props.report ? ( - - ) : ( -

Welp.

- )} -
- ); + return ( + + {props.report ? :

Welp.

} +
+ ); } export const getServerSideProps: GetServerSideProps = async (context) => { - const resolved = await UrlResolver(context, 4); - if ("redirect" in resolved) { - return resolved; - } + const resolved = await UrlResolver(context, 4); + if ("redirect" in resolved) { + return resolved; + } - const season = resolved.season; + const season = resolved.season; - return { - props: { - report: resolved.report, - layout: makeObjSerializeable(games[season?.gameId ?? defaultGameId].quantitativeReportLayout), - fieldImagePrefix: games[season?.gameId ?? defaultGameId].fieldImagePrefix, - teamNumber: resolved.team?.number, - compName: resolved.competition?.name, - } - } as { props: FormProps }; + return { + props: { + report: resolved.report, + layout: makeObjSerializeable( + games[season?.gameId ?? defaultGameId].quantitativeReportLayout, + ), + fieldImagePrefix: games[season?.gameId ?? defaultGameId].fieldImagePrefix, + teamNumber: resolved.team?.number, + compName: resolved.competition?.name, + }, + } as { props: FormProps }; }; diff --git a/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/[reportId]/subjective.tsx b/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/[reportId]/subjective.tsx index 26ce665f..d85abe60 100644 --- a/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/[reportId]/subjective.tsx +++ b/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/[reportId]/subjective.tsx @@ -1,7 +1,11 @@ import ClientApi from "@/lib/api/ClientApi"; import { useCurrentSession } from "@/lib/client/useCurrentSession"; import useInterval from "@/lib/client/useInterval"; -import { Match, SubjectiveReport, SubjectiveReportSubmissionType } from "@/lib/Types"; +import { + Match, + SubjectiveReport, + SubjectiveReportSubmissionType, +} from "@/lib/Types"; import { useRouter } from "next/router"; import React, { createRef, FormEvent, useEffect, useState } from "react"; import { Analytics } from "@/lib/client/Analytics"; @@ -13,141 +17,198 @@ import Container from "@/components/Container"; const api = new ClientApi(); -export default function SubjectiveReportForm(props: { compId?: string, teamNumber?: number, compName?: string }) { - const router = useRouter(); - const { teamSlug, seasonSlug, competitonSlug, reportId: matchId } = router.query; - - const [match, setMatch] = useState(); - - const session = useCurrentSession(); - - const submitButtonRef = createRef(); - - const [submitting, setSubmitting] = useState(false); - - const [report, setReport] = useState(undefined); - - useEffect(() => { - if (matchId) { - api.findMatchById(matchId as string) - .then((match) => setMatch(match)); - } - }, [matchId]); - - function getReportFromForm(): SubjectiveReport { - return { - _id: undefined, - match: match?._id as string, - matchNumber: match?.number ?? 0, - wholeMatchComment: (document.getElementById("comment-WholeMatch") as HTMLTextAreaElement)?.value ?? "", - robotComments: Object.fromEntries( - match?.blueAlliance.concat(match.redAlliance).map((team: number, index: number) => - [match?.blueAlliance.concat(match.redAlliance)[index], (document.getElementById(`comment-${team}`) as HTMLTextAreaElement).value ?? ""]) ?? [] - ), - submitter: undefined, - submitted: SubjectiveReportSubmissionType.NotSubmitted - }; - } - - function updateReport() { - setReport(getReportFromForm()); - } - - async function submit(e: FormEvent) { - e.preventDefault(); - - setSubmitting(true); - - let valid = [...(e.target as any)].filter((element: any) => element.value).length > 0; - if (!valid) { - if (submitButtonRef.current) { - const btn = submitButtonRef.current; - - btn.classList.add("btn-error"); - btn.innerText = "Please fill out at least one field"; - setTimeout(() => { - btn.classList.remove("btn-error"); - btn.innerText = "Submit"; - }, 1500); - } - - setSubmitting(false); - return; - } - - api.submitSubjectiveReport(getReportFromForm(), matchId as string) - .then(() => { - const teamsWithComments = [...(e.target as any)].map((element: any, index: number) => - ["Whole Match"].concat(match?.blueAlliance.concat(match.redAlliance).map((n) => n.toString()) ?? [])[index] - ); - - Analytics.subjectiveReportSubmitted(teamsWithComments, props.teamNumber ?? -1, props.compName ?? "Unknown Competition", - session.session.user?.name ?? "Unknown User"); - }) - .finally(() => { - if (location.href.includes("offline")) - location.href = `/offline/${props.compId}`; - else - location.href = `/${teamSlug}/${seasonSlug}/${competitonSlug}`; - }); - } - - // We have to use router as a dependency because it is only populated after the first render (during hydration) - useInterval(() => api.checkInForSubjectiveReport(matchId as string).catch(console.error), 5000, [router, match?._id]); - - return ( - -
- { !match - ? -
- : <> -
-
-
- Match {match.number} -
- - { - ["Whole Match", ...match.blueAlliance, ...match.redAlliance].map((team, index) => ( -
-
- - {team} - -
-