diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 69442a2..eac9ad7 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -14,6 +14,12 @@ on: jobs: # This workflow contains a single job called "build" build-webapp: + # Add 'id-token' with the intended permissions for workload identity federation + permissions: + contents: 'read' + id-token: 'write' + checks: 'write' + # The type of runner that the job will run on runs-on: ubuntu-latest @@ -24,12 +30,38 @@ jobs: - name: Install packages run: npm ci + + # Environment variables + - name: create env files + run: | + touch .env; + echo "${{ secrets.APP_ENV_VARS }}" > .env; - name: Build run: npm run build - - name: Lint - run: npm run lint + ## TODO: DO NOT MERGE WITH THIS COMMENTED OUT + # - name: Lint + # run: npm run lint + + - name: Google Auth + id: auth + uses: 'google-github-actions/auth@v2' + with: + project_id: fim-queueing + workload_identity_provider: '${{ secrets.WIF_PROVIDER }}' + service_account: 'deployment-service@fim-queueing.iam.gserviceaccount.com' + + - run: | + echo "SERVICE_ACCOUNT_KEY=$(cat "${{ steps.auth.outputs.credentials_file_path }}" | tr -d '\n')" >> $GITHUB_ENV + + - uses: FirebaseExtended/action-hosting-deploy@v0 + with: + repoToken: "${{ secrets.GITHUB_TOKEN }}" + firebaseServiceAccount: "${{ env.SERVICE_ACCOUNT_KEY }}" + projectId: fim-queueing + channelId: ${{ github.ref }} + build-functions: runs-on: ubuntu-latest steps: @@ -43,4 +75,4 @@ jobs: run: npm run build --prefix=functions - name: Lint - run: npx eslint 'functions/src/**/*.{js,ts}' -c functions/.eslintrc.js + run: npx eslint 'functions/src/**/*.{js,ts}' -c functions/.eslintrc.js \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..cda7390 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/modules.xml +/contentModel.xml +/projectSettingsUpdater.xml +/.idea.fim-queueing.iml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/.idea.fim-queueing.dir/.idea/.gitignore b/.idea/.idea.fim-queueing.dir/.idea/.gitignore new file mode 100644 index 0000000..cda7390 --- /dev/null +++ b/.idea/.idea.fim-queueing.dir/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/modules.xml +/contentModel.xml +/projectSettingsUpdater.xml +/.idea.fim-queueing.iml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/.idea.fim-queueing.dir/.idea/encodings.xml b/.idea/.idea.fim-queueing.dir/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/.idea/.idea.fim-queueing.dir/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/.idea.fim-queueing.dir/.idea/indexLayout.xml b/.idea/.idea.fim-queueing.dir/.idea/indexLayout.xml new file mode 100644 index 0000000..7b08163 --- /dev/null +++ b/.idea/.idea.fim-queueing.dir/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/.idea.fim-queueing.dir/.idea/vcs.xml b/.idea/.idea.fim-queueing.dir/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/.idea.fim-queueing.dir/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/indexLayout.xml b/.idea/indexLayout.xml new file mode 100644 index 0000000..7b08163 --- /dev/null +++ b/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/emulator_data/auth_export/accounts.json b/emulator_data/auth_export/accounts.json new file mode 100644 index 0000000..135e08d --- /dev/null +++ b/emulator_data/auth_export/accounts.json @@ -0,0 +1 @@ +{"kind":"identitytoolkit#DownloadAccountResponse","users":[]} \ No newline at end of file diff --git a/emulator_data/auth_export/config.json b/emulator_data/auth_export/config.json new file mode 100644 index 0000000..8f77af9 --- /dev/null +++ b/emulator_data/auth_export/config.json @@ -0,0 +1 @@ +{"signIn":{"allowDuplicateEmails":false}} \ No newline at end of file diff --git a/emulator_data/firebase-export-metadata.json b/emulator_data/firebase-export-metadata.json new file mode 100644 index 0000000..af0aa64 --- /dev/null +++ b/emulator_data/firebase-export-metadata.json @@ -0,0 +1,11 @@ +{ + "version": "12.4.2", + "database": { + "version": "4.11.2", + "path": "database_export" + }, + "auth": { + "version": "12.4.2", + "path": "auth_export" + } +} \ No newline at end of file diff --git a/index.html b/index.html index c112b55..3895b0a 100644 --- a/index.html +++ b/index.html @@ -7,6 +7,8 @@ + +
diff --git a/infrastructure/ansible/roles/fim-queueing-sync/defaults/main.yml b/infrastructure/ansible/roles/fim-queueing-sync/defaults/main.yml index b991550..97c6e28 100644 --- a/infrastructure/ansible/roles/fim-queueing-sync/defaults/main.yml +++ b/infrastructure/ansible/roles/fim-queueing-sync/defaults/main.yml @@ -1,3 +1,3 @@ healthCheckEnabled: false healthCheckUrl: "" -intervalSec: 10 +intervalSec: 60 diff --git a/infrastructure/ansible/roles/fim-queueing-sync/templates/fim-updateCurrentMatch.service.j2 b/infrastructure/ansible/roles/fim-queueing-sync/templates/fim-updateCurrentMatch.service.j2 index 0172cac..5fdef0e 100644 --- a/infrastructure/ansible/roles/fim-queueing-sync/templates/fim-updateCurrentMatch.service.j2 +++ b/infrastructure/ansible/roles/fim-queueing-sync/templates/fim-updateCurrentMatch.service.j2 @@ -2,7 +2,7 @@ # If using this, it's recommended to disable the Cloud Scheduler that Firebase creates by default [Unit] -Description=Run the updateCurrentMatch Firebase Function every ten seconds +Description=Run the updateCurrentMatch Firebase Function After=network.target StartLimitIntervalSec=5 StartLimitBurst=2 diff --git a/package-lock.json b/package-lock.json index 619523e..57cb1eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,9 @@ "@microsoft/signalr": "^7.0.7", "@preact/preset-vite": "^2.5.0", "@react-spring/web": "^9.7.3", + "@supabase/supabase-js": "^2.48.1", + "@tanstack/react-query": "^5.66.0", + "@tanstack/react-query-devtools": "^5.66.0", "color": "^4.2.3", "firebase": "^10.0.0", "js-cookie": "^3.0.1", @@ -5037,6 +5040,101 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@supabase/auth-js": { + "version": "2.67.3", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.67.3.tgz", + "integrity": "sha512-NJDaW8yXs49xMvWVOkSIr8j46jf+tYHV0wHhrwOaLLMZSFO4g6kKAf+MfzQ2RaD06OCUkUHIzctLAxjTgEVpzw==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.4.tgz", + "integrity": "sha512-WL2p6r4AXNGwop7iwvul2BvOtuJ1YQy8EbOd0dhG1oN1q8el/BIRSFCFnWAMM/vJJlHWLi4ad22sKbKr9mvjoA==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/node-fetch": { + "version": "2.6.15", + "resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz", + "integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.18.1.tgz", + "integrity": "sha512-dWDnoC0MoDHKhaEOrsEKTadWQcBNknZVQcSgNE/Q2wXh05mhCL1ut/jthRUrSbYcqIw/CEjhaeIPp7dLarT0bg==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.11.2.tgz", + "integrity": "sha512-u/XeuL2Y0QEhXSoIPZZwR6wMXgB+RQbJzG9VErA3VghVt7uRfSVsjeqd7m5GhX3JR6dM/WRmLbVR8URpDWG4+w==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14", + "@types/phoenix": "^1.5.4", + "@types/ws": "^8.5.10", + "ws": "^8.18.0" + } + }, + "node_modules/@supabase/realtime-js/node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.7.1.tgz", + "integrity": "sha512-asYHcyDR1fKqrMpytAS1zjyEfvxuOIp1CIXX7ji4lHHcJKqyk+sLl/Vxgm4sN6u8zvuUtae9e4kDxQP2qrwWBA==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.48.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.48.1.tgz", + "integrity": "sha512-VMD+CYk/KxfwGbI4fqwSUVA7CLr1izXpqfFerhnYPSi6LEKD8GoR4kuO5Cc8a+N43LnfSQwLJu4kVm2e4etEmA==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.67.3", + "@supabase/functions-js": "2.4.4", + "@supabase/node-fetch": "2.6.15", + "@supabase/postgrest-js": "1.18.1", + "@supabase/realtime-js": "2.11.2", + "@supabase/storage-js": "2.7.1" + } + }, "node_modules/@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", @@ -5059,6 +5157,59 @@ "node": ">=6" } }, + "node_modules/@tanstack/query-core": { + "version": "5.66.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.66.0.tgz", + "integrity": "sha512-J+JeBtthiKxrpzUu7rfIPDzhscXF2p5zE/hVdrqkACBP8Yu0M96mwJ5m/8cPPYQE9aRNvXztXHlNwIh4FEeMZw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.65.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.65.0.tgz", + "integrity": "sha512-g5y7zc07U9D3esMdqUfTEVu9kMHoIaVBsD0+M3LPdAdD710RpTcLiNvJY1JkYXqkq9+NV+CQoemVNpQPBXVsJg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.66.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.66.0.tgz", + "integrity": "sha512-z3sYixFQJe8hndFnXgWu7C79ctL+pI0KAelYyW+khaNJ1m22lWrhJU2QrsTcRKMuVPtoZvfBYrTStIdKo+x0Xw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.66.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.66.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.66.0.tgz", + "integrity": "sha512-uB57wA2YZaQ2fPcFW0E9O1zAGDGSbRKRx84uMk/86VyU9jWVxvJ3Uzp+zNm+nZJYsuekCIo2opTdgNuvM3cKgA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.65.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.66.0", + "react": "^18 || ^19" + } + }, "node_modules/@trysound/sax": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", @@ -5231,6 +5382,12 @@ "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" }, + "node_modules/@types/phoenix": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", + "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.5", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", @@ -5395,9 +5552,10 @@ } }, "node_modules/@types/ws": { - "version": "8.5.4", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.4.tgz", - "integrity": "sha512-zdQDHKUgcX/zBc4GrwsE/7dVdAD8JR4EuiAXiiUhhfyIJXXb2+PrGshFyeXWQPMmmZ2XxgaqclgpIC7eTXc1mg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.14.tgz", + "integrity": "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw==", + "license": "MIT", "dependencies": { "@types/node": "*" } @@ -27600,6 +27758,78 @@ "@sinonjs/commons": "^3.0.0" } }, + "@supabase/auth-js": { + "version": "2.67.3", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.67.3.tgz", + "integrity": "sha512-NJDaW8yXs49xMvWVOkSIr8j46jf+tYHV0wHhrwOaLLMZSFO4g6kKAf+MfzQ2RaD06OCUkUHIzctLAxjTgEVpzw==", + "requires": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "@supabase/functions-js": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.4.tgz", + "integrity": "sha512-WL2p6r4AXNGwop7iwvul2BvOtuJ1YQy8EbOd0dhG1oN1q8el/BIRSFCFnWAMM/vJJlHWLi4ad22sKbKr9mvjoA==", + "requires": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "@supabase/node-fetch": { + "version": "2.6.15", + "resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz", + "integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==", + "requires": { + "whatwg-url": "^5.0.0" + } + }, + "@supabase/postgrest-js": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.18.1.tgz", + "integrity": "sha512-dWDnoC0MoDHKhaEOrsEKTadWQcBNknZVQcSgNE/Q2wXh05mhCL1ut/jthRUrSbYcqIw/CEjhaeIPp7dLarT0bg==", + "requires": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "@supabase/realtime-js": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.11.2.tgz", + "integrity": "sha512-u/XeuL2Y0QEhXSoIPZZwR6wMXgB+RQbJzG9VErA3VghVt7uRfSVsjeqd7m5GhX3JR6dM/WRmLbVR8URpDWG4+w==", + "requires": { + "@supabase/node-fetch": "^2.6.14", + "@types/phoenix": "^1.5.4", + "@types/ws": "^8.5.10", + "ws": "^8.18.0" + }, + "dependencies": { + "ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "requires": {} + } + } + }, + "@supabase/storage-js": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.7.1.tgz", + "integrity": "sha512-asYHcyDR1fKqrMpytAS1zjyEfvxuOIp1CIXX7ji4lHHcJKqyk+sLl/Vxgm4sN6u8zvuUtae9e4kDxQP2qrwWBA==", + "requires": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "@supabase/supabase-js": { + "version": "2.48.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.48.1.tgz", + "integrity": "sha512-VMD+CYk/KxfwGbI4fqwSUVA7CLr1izXpqfFerhnYPSi6LEKD8GoR4kuO5Cc8a+N43LnfSQwLJu4kVm2e4etEmA==", + "requires": { + "@supabase/auth-js": "2.67.3", + "@supabase/functions-js": "2.4.4", + "@supabase/node-fetch": "2.6.15", + "@supabase/postgrest-js": "1.18.1", + "@supabase/realtime-js": "2.11.2", + "@supabase/storage-js": "2.7.1" + } + }, "@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", @@ -27619,6 +27849,32 @@ "defer-to-connect": "^1.0.1" } }, + "@tanstack/query-core": { + "version": "5.66.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.66.0.tgz", + "integrity": "sha512-J+JeBtthiKxrpzUu7rfIPDzhscXF2p5zE/hVdrqkACBP8Yu0M96mwJ5m/8cPPYQE9aRNvXztXHlNwIh4FEeMZw==" + }, + "@tanstack/query-devtools": { + "version": "5.65.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.65.0.tgz", + "integrity": "sha512-g5y7zc07U9D3esMdqUfTEVu9kMHoIaVBsD0+M3LPdAdD710RpTcLiNvJY1JkYXqkq9+NV+CQoemVNpQPBXVsJg==" + }, + "@tanstack/react-query": { + "version": "5.66.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.66.0.tgz", + "integrity": "sha512-z3sYixFQJe8hndFnXgWu7C79ctL+pI0KAelYyW+khaNJ1m22lWrhJU2QrsTcRKMuVPtoZvfBYrTStIdKo+x0Xw==", + "requires": { + "@tanstack/query-core": "5.66.0" + } + }, + "@tanstack/react-query-devtools": { + "version": "5.66.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.66.0.tgz", + "integrity": "sha512-uB57wA2YZaQ2fPcFW0E9O1zAGDGSbRKRx84uMk/86VyU9jWVxvJ3Uzp+zNm+nZJYsuekCIo2opTdgNuvM3cKgA==", + "requires": { + "@tanstack/query-devtools": "5.65.0" + } + }, "@trysound/sax": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", @@ -27788,6 +28044,11 @@ "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" }, + "@types/phoenix": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", + "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==" + }, "@types/prop-types": { "version": "15.7.5", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", @@ -27951,9 +28212,9 @@ } }, "@types/ws": { - "version": "8.5.4", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.4.tgz", - "integrity": "sha512-zdQDHKUgcX/zBc4GrwsE/7dVdAD8JR4EuiAXiiUhhfyIJXXb2+PrGshFyeXWQPMmmZ2XxgaqclgpIC7eTXc1mg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.14.tgz", + "integrity": "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw==", "requires": { "@types/node": "*" } diff --git a/package.json b/package.json index 7f4952b..1c7eac5 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,9 @@ "@microsoft/signalr": "^7.0.7", "@preact/preset-vite": "^2.5.0", "@react-spring/web": "^9.7.3", + "@supabase/supabase-js": "^2.48.1", + "@tanstack/react-query": "^5.66.0", + "@tanstack/react-query-devtools": "^5.66.0", "color": "^4.2.3", "firebase": "^10.0.0", "js-cookie": "^3.0.1", diff --git a/src/assets/icons/icon-120x120.png b/public/icons/icon-120x120.png similarity index 100% rename from src/assets/icons/icon-120x120.png rename to public/icons/icon-120x120.png diff --git a/src/assets/icons/icon-128x128.png b/public/icons/icon-128x128.png similarity index 100% rename from src/assets/icons/icon-128x128.png rename to public/icons/icon-128x128.png diff --git a/src/assets/icons/icon-144x144.png b/public/icons/icon-144x144.png similarity index 100% rename from src/assets/icons/icon-144x144.png rename to public/icons/icon-144x144.png diff --git a/src/assets/icons/icon-152x152.png b/public/icons/icon-152x152.png similarity index 100% rename from src/assets/icons/icon-152x152.png rename to public/icons/icon-152x152.png diff --git a/src/assets/icons/icon-180x180.png b/public/icons/icon-180x180.png similarity index 100% rename from src/assets/icons/icon-180x180.png rename to public/icons/icon-180x180.png diff --git a/src/assets/icons/icon-192x192.png b/public/icons/icon-192x192.png similarity index 100% rename from src/assets/icons/icon-192x192.png rename to public/icons/icon-192x192.png diff --git a/src/assets/icons/icon-384x384.png b/public/icons/icon-384x384.png similarity index 100% rename from src/assets/icons/icon-384x384.png rename to public/icons/icon-384x384.png diff --git a/src/assets/icons/icon-512x512.png b/public/icons/icon-512x512.png similarity index 100% rename from src/assets/icons/icon-512x512.png rename to public/icons/icon-512x512.png diff --git a/src/assets/icons/icon-72x72.png b/public/icons/icon-72x72.png similarity index 100% rename from src/assets/icons/icon-72x72.png rename to public/icons/icon-72x72.png diff --git a/src/assets/icons/icon-96x96.png b/public/icons/icon-96x96.png similarity index 100% rename from src/assets/icons/icon-96x96.png rename to public/icons/icon-96x96.png diff --git a/src/manifest.json b/public/manifest.json similarity index 65% rename from src/manifest.json rename to public/manifest.json index e855d88..a3f32b0 100644 --- a/src/manifest.json +++ b/public/manifest.json @@ -8,52 +8,52 @@ "theme_color": "#eeeeee", "icons": [ { - "src": "assets/icons/icon-72x72.png", + "src": "icons/icon-72x72.png", "sizes": "72x72", "type": "image/png" }, { - "src": "assets/icons/icon-96x96.png", + "src": "icons/icon-96x96.png", "sizes": "96x96", "type": "image/png" }, { - "src": "assets/icons/icon-120x120.png", + "src": "icons/icon-120x120.png", "sizes": "120x120", "type": "image/png" }, { - "src": "assets/icons/icon-128x128.png", + "src": "icons/icon-128x128.png", "sizes": "128x128", "type": "image/png" }, { - "src": "assets/icons/icon-144x144.png", + "src": "icons/icon-144x144.png", "sizes": "144x144", "type": "image/png" }, { - "src": "assets/icons/icon-152x152.png", + "src": "icons/icon-152x152.png", "sizes": "152x152", "type": "image/png" }, { - "src": "assets/icons/icon-180x180.png", + "src": "icons/icon-180x180.png", "sizes": "180x180", "type": "image/png" }, { - "src": "assets/icons/icon-192x192.png", + "src": "icons/icon-192x192.png", "sizes": "192x192", "type": "image/png" }, { - "src": "assets/icons/icon-384x384.png", + "src": "icons/icon-384x384.png", "sizes": "384x384", "type": "image/png" }, { - "src": "assets/icons/icon-512x512.png", + "src": "icons/icon-512x512.png", "sizes": "512x512", "type": "image/png" } diff --git a/src/AppContext.ts b/src/AppContext.ts index bf3d493..12882cc 100644 --- a/src/AppContext.ts +++ b/src/AppContext.ts @@ -4,12 +4,10 @@ import { Event } from '@shared/DbTypes'; type Features = { showRankingsScreen?: boolean; showStaleDataBanner?: boolean; + useSupabaseData?: boolean; }; export type AppContextType = { - event?: Event; - season?: number; - token?: string; features?: Features }; diff --git a/src/components/App/index.tsx b/src/components/App/index.tsx index b99acc7..4ee2338 100644 --- a/src/components/App/index.tsx +++ b/src/components/App/index.tsx @@ -23,9 +23,15 @@ import StaleDataBanner from '../StaleDataBanner'; import useStateWithRef from '@/useStateWithRef'; import ErrorMessage from '../ErrorMessage'; import AuthenticatedRoute from '../AuthenticatedRoute'; +import { supabase } from "@/data/supabase"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { useRealtimeEvent } from "@/hooks/supabase/useRealtimeEvent"; // TODO: Figure out why the event details sometimes aren't getting sent over to SignalR +const queryClient = new QueryClient(); + const ErrorFallback = ({ error }: { error: Error }) => { // Reload after a while to try a recovery useEffect(() => { @@ -53,7 +59,6 @@ const ErrorFallback = ({ error }: { error: Error }) => { const encodedValue = encodeURIComponent((error as any)[key]); formBody.push(`${encodedKey}=${encodedValue}`); }); - console.log('asd', formBody); await fetch(import.meta.env.APP_REPORT_ERROR_URL!, { method: 'POST', @@ -94,6 +99,7 @@ const App = () => { connectionStatus?: 'online' | 'offline', lastConnectedDate?: Date }>(); const [isAuthenticated, setIsAuthenticated] = useState(false); + const event = useRealtimeEvent(); const [acFeatures, setFeatures] = useState(); const [acSeason, setSeason] = useState(); @@ -124,10 +130,11 @@ const App = () => { useEffect(() => { const newCtx = { features: acFeatures, - event: acEvent, + //event: acEvent, season: acSeason, token: acToken, }; + //console.log('ac', newCtx) setAppContext(newCtx); }, [acFeatures, acEvent, acSeason, acToken]); @@ -135,23 +142,25 @@ const App = () => { sendCurrentStatus(); }, [hub.current, appContext.token, appContext.event?.eventCode]); - const onLogin = (token?: string) => { + const onLogin = async (_: string, supaToken: string) => { if (appContext === undefined) throw new Error('appContext was undefined'); if (db === undefined) throw new Error('db was undefined'); setIsAuthenticated(true); - - onValue(ref(db, `/seasons/${appContext.season}/events/${token}`), (snap) => { - setEvent(snap.val() as Event); - setToken(token); - }); + await supabase.realtime.setAuth(supaToken); + (supabase as any).rest.headers.Authorization = `Bearer ${supaToken}`; + + // onValue(ref(db, `/seasons/${appContext.season}/events/${token}`), (snap) => { + // setEvent(snap.val() as Event); + // setToken(token); + // }); }; // Login if a token is available useEffect(() => { - if (appContext.token === undefined) return; - onLogin(appContext.token); - }, [appContext.token]); + if (appContext.token === undefined || appContext.supaToken === undefined || !appContext.season) return; + onLogin(appContext.token, appContext.supaToken); + }, [appContext.token, appContext.supaToken, appContext.season]); // Initialize app useEffect(() => { @@ -269,17 +278,18 @@ const App = () => { }); cn.on('OverrideEventKey', async (eventToken) => { try { - // TODO: Show error if unable to determine season - const event = await get(ref(getDatabase(), `/seasons/${appContext.season}/events/${eventToken}`)); - if (!event) { - throw new Error('Unable to get event'); - } - - const end = new Date(event.child('end').val().replace(' ', 'T')); - Cookies.set('queueing-event-key', eventToken, { - expires: end, - }); - onLogin(eventToken); + // TODO: Reimplement with supabase + // // TODO: Show error if unable to determine season + // const event = await get(ref(getDatabase(), `/seasons/${appContext.season}/events/${eventToken}`)); + // if (!event) { + // throw new Error('Unable to get event'); + // } + // + // const end = new Date(event.child('end').val().replace(' ', 'T')); + // Cookies.set('queueing-event-key', eventToken, { + // expires: end, + // }); + // onLogin(eventToken); } catch (err) { console.error(err); } @@ -323,10 +333,9 @@ const App = () => { // Change what's rendered based on global application state let appContent: JSX.Element; - if (!appContext || (!isAuthenticated && connection?.connectionStatus === undefined) - || (isAuthenticated && appContext.event === undefined)) { + if (!appContext || (!isAuthenticated && connection?.connectionStatus === undefined)) { appContent = (
Loading...
); - } else if ((isAuthenticated && appContext.event !== undefined) || skipEventKey) { + } else if ((isAuthenticated) || skipEventKey) { appContent = ( <> @@ -350,16 +359,19 @@ const App = () => {
{identifyTO !== null &&
{hub.current?.connectionId}
} - - {connection?.connectionStatus === 'offline' && ( -
- Check network connection. - {connection.lastConnectedDate - && ` Last connected ${connection.lastConnectedDate?.toLocaleString([], { timeStyle: 'short' })}`} -
- )} - {appContent} -
+ + + {connection?.connectionStatus === 'offline' && ( +
+ Check network connection. + {connection.lastConnectedDate + && ` Last connected ${connection.lastConnectedDate?.toLocaleString([], { timeStyle: 'short' })}`} +
+ )} + {appContent} +
+ {import.meta.env.DEV &&
} +
); diff --git a/src/components/Automated/index.tsx b/src/components/Automated/index.tsx index 7eab305..c976ef9 100644 --- a/src/components/Automated/index.tsx +++ b/src/components/Automated/index.tsx @@ -6,6 +6,8 @@ import MenuBar from '../MenuBar'; import AppErrorMessage, { ErrorMessageType } from '../ErrorMessage'; import Link from '../Link'; import Routes from '@/routes'; +import { useRealtimeEvent } from "@/hooks/supabase/useRealtimeEvent"; +import Disclaimer from "@/components/shared/Disclaimer"; type AutomatedProps = { matches: { @@ -15,7 +17,7 @@ type AutomatedProps = { }; const Automated = (props: AutomatedProps) => { - const { event, season } = useContext(AppContext); + const { isPending, data } = useRealtimeEvent(); const { matches: { playoff, qual } } = props; const ErrorMessage = ({ children, type }: { @@ -23,7 +25,7 @@ const Automated = (props: AutomatedProps) => { type?: ErrorMessageType, }) => ( <> - +
{ children }
@@ -46,9 +48,16 @@ const Automated = (props: AutomatedProps) => { ); } - switch (event?.state) { - case 'Pending': - case 'AwaitingQualSchedule': + + if (isPending) { + return ( + Loading... + ); + } + + switch (data?.status) { + case 'NotStarted': + case 'AwaitingQuals': case 'QualsInProgress': { const routeToUse = Routes.find((r) => r.url === qual && r.usedIn.includes('qual')); @@ -63,6 +72,7 @@ const Automated = (props: AutomatedProps) => { } case 'AwaitingAlliances': case 'PlayoffsInProgress': + case 'WinnerDetermined': { const routeToUse = Routes.find((r) => r.url === playoff && r.usedIn.includes('playoff')); if (!routeToUse) { @@ -74,7 +84,7 @@ const Automated = (props: AutomatedProps) => { } return (); } - case 'EventOver': + case 'Completed': return ( The event has ended. See you next time! diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index 5fe5c32..33d4abd 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -1,14 +1,14 @@ import { h } from 'preact'; import { get, getDatabase, ref } from 'firebase/database'; import Cookies from 'js-cookie'; -import { useContext, useState } from 'preact/hooks'; +import { useContext, useEffect, useState } from 'preact/hooks'; import styled from 'styled-components'; import AppContext from '../AppContext'; import Disclaimer from './shared/Disclaimer'; type LoginFormProps = { - onLogin: (token: string) => void; + onLogin: (token: string, supaToken: string) => void; }; const StyledLoginForm = styled.form` @@ -38,12 +38,18 @@ const LoginForm = ({ onLogin }: LoginFormProps) => { const appContext = useContext(AppContext); const [eventToken, setEventToken] = useState(''); const [badToken, setBadToken] = useState(false); - - const handleSuccessfulLogin = (expiration: Date): void => { + const [errorText, setErrorText] = useState(null); + + const handleSuccessfulLogin = (supaToken: string): void => { + const expFromToken = new Date(JSON.parse(atob(supaToken.split('.')[1]))['exp'] * 1_000); + console.log("about to set cookie exp", expFromToken, "token", supaToken, JSON.parse(atob(supaToken.split('.')[1]))); Cookies.set('queueing-event-key', eventToken, { - expires: expiration, + expires: expFromToken, + }); + Cookies.set('queueing-supa-token', supaToken, { + expires: expFromToken }); - onLogin(eventToken); + onLogin(eventToken, supaToken); }; const handleFailedLogin = (): void => { @@ -53,30 +59,44 @@ const LoginForm = ({ onLogin }: LoginFormProps) => { const handleSubmit = async (e: Event): Promise => { e.preventDefault(); if (!eventToken || eventToken === '') handleFailedLogin(); + setErrorText(null); try { - // TODO: Show error if unable to determine season - const event = await get(ref(getDatabase(), `/seasons/${appContext.season}/events/${eventToken}`)); - if (!event) { - handleFailedLogin(); - return; - } - - const now = new Date(); - const start = new Date(event.child('start').val().replace(' ', 'T')); - const end = new Date(event.child('end').val().replace(' ', 'T')); - - if (start > now || end < now) { + const loginResponse = await fetch(import.meta.env.APP_ADMIN_SERVER + '/av-token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + eventKey: eventToken + }) + }); + + const loginResponseJson = await loginResponse.json(); + + if (!loginResponse.ok) { handleFailedLogin(); - return; + setErrorText( + loginResponseJson["detail"] + ?? "An error occurred while logging in. Your key may be incorrect or inactive."); } - - handleSuccessfulLogin(end); + + const token = loginResponseJson["accessToken"]; + + handleSuccessfulLogin(token); } catch (err) { handleFailedLogin(); console.error(err); } }; + + useEffect(() => { + const token = Cookies.get('queueing-event-key'); + const supa = Cookies.get('queueing-supa-token'); + if (token && supa) { + onLogin(token, supa); + } + }, []); return (
diff --git a/src/components/MenuBar/index.tsx b/src/components/MenuBar/index.tsx index e654905..4edb032 100644 --- a/src/components/MenuBar/index.tsx +++ b/src/components/MenuBar/index.tsx @@ -1,7 +1,7 @@ import { h, Fragment } from 'preact'; import { route } from 'preact-router'; import Cookies from 'js-cookie'; -import { useEffect, useState } from 'preact/hooks'; +import { useContext, useEffect, useMemo, useState } from 'preact/hooks'; import { Event } from '@shared/DbTypes'; import styles from './styles.module.scss'; @@ -9,10 +9,11 @@ import Fullscreen from '@/assets/fullscreen.svg'; import FullscreenExit from '@/assets/fullscreen_exit.svg'; import Home from '@/assets/home.svg'; import Logout from '@/assets/logout.svg'; +import AppContext from "@/AppContext"; +import { useRealtimeEvent } from "@/hooks/supabase/useRealtimeEvent"; +import { useGetSeason } from "@/hooks/supabase/useGetSeason"; type MenuBarProps = { - event: Event | undefined, - season: number | undefined, alwaysShow?: boolean, options?: JSX.Element }; @@ -36,13 +37,12 @@ function getBrowserFullscreenStatus(): boolean { * Always displays on mobile devices, and slides down upon mouse movement for * desktop. */ -const MenuBar = (props: MenuBarProps) => { - const { - event, season, alwaysShow, options, - } = props; - +const MenuBar = ({ alwaysShow, options }: MenuBarProps) => { const [showMenu, setShowMenu] = useState(false); const [isFullscreen, setIsFullscreen] = useState(getBrowserFullscreenStatus()); + + const context = useContext(AppContext); + const supaEvent = useRealtimeEvent(); let mouseTimeout: number | undefined; const handleMouseMove = () => { @@ -55,6 +55,11 @@ const MenuBar = (props: MenuBarProps) => { }, 2000); } }; + + const eventName = useMemo(() => { + if (supaEvent.isPending) return ''; + return `${supaEvent.data?.name} (${supaEvent?.data?.seasons?.name})`; + }, [context.features?.useSupabaseData, supaEvent.data]); // Watch for fullscreenchange useEffect(() => { @@ -113,7 +118,7 @@ const MenuBar = (props: MenuBarProps) => { } } - if (event === undefined || season === undefined) return (<>); + //if (event === undefined || season === undefined) return (<>); return (
{ ].join(' ')} >
- {event.name} - {' '} - ( - {season} - ) + {eventName}
diff --git a/src/components/QualDisplay/MatchDisplay/index.tsx b/src/components/QualDisplay/MatchDisplay/index.tsx index b6e135f..0e31a8c 100644 --- a/src/components/QualDisplay/MatchDisplay/index.tsx +++ b/src/components/QualDisplay/MatchDisplay/index.tsx @@ -1,9 +1,10 @@ import { h, Fragment } from 'preact'; import { DriverStation, QualMatch } from '@shared/DbTypes'; import styles from './styles.module.scss'; +import { Match } from "@/hooks/supabase/useGetMatches"; type MatchDisplayProps = { - match: QualMatch | null; + match: Match | null; // Temp disable till I figure out what to do with avatars // teamAvatars: TeamAvatars | undefined; halfWidth?: boolean; @@ -44,12 +45,12 @@ function MatchDisplay({ halfWidth, match, className }: MatchDisplayProps): JSX.E */} {match && ( <> - {match.number} + {match.match_number} - {[1, 2, 3].map((n) => )} + {match.red_alliance_teams?.map((t, i) => )} - {[1, 2, 3].map((n) => )} + {match.blue_alliance_teams?.map((t, i) => )} )} diff --git a/src/components/QualDisplay/Queueing/index.tsx b/src/components/QualDisplay/Queueing/index.tsx index 7a38faf..d130ef6 100644 --- a/src/components/QualDisplay/Queueing/index.tsx +++ b/src/components/QualDisplay/Queueing/index.tsx @@ -1,156 +1,44 @@ import { h, Fragment } from 'preact'; -import { - DatabaseReference, getDatabase, child, ref, update, onValue, off, -} from 'firebase/database'; -import { - useContext, useEffect, useState, useRef, useReducer, -} from 'preact/hooks'; - -import { AppMode, QualMatch } from '@shared/DbTypes'; -import { TeamRanking } from '@/types'; -import AppContext from '@/AppContext'; +import { DatabaseReference, getDatabase, child, ref, update } from 'firebase/database'; +import { useEffect, useState, useRef, useMemo, } from 'preact/hooks'; import styles from './styles.module.scss'; import MatchDisplay from '../MatchDisplay'; import Ranking from '../../Tickers/Ranking'; import RankingList from '../../Tickers/RankingList'; import MenuBar from '../../MenuBar'; +import { useRealtimeMatches } from "@/hooks/supabase/useRealtimeMatches"; +import { useFirebaseEvent } from "@/hooks/firebase/useFirebaseEvent"; +import { useRealtimeEvent } from "@/hooks/supabase/useRealtimeEvent"; +import { useRealtimeRankings } from "@/hooks/supabase/useRealtimeRankings"; +import { Match } from "@/hooks/supabase/useGetMatches"; +import { useRealtimeScheduleDeviations } from "@/hooks/supabase/useRealtimeScheduleDeviations"; +import { ScheduleDeviation } from "@/hooks/supabase/useGetScheduleDeviations"; type LoadingState = 'loading' | 'ready' | 'error' | 'noAutomatic'; +type DisplayMatch = Match | ScheduleDeviation; -const Queueing = () => { - const { event, season, token } = useContext(AppContext); - if (event === undefined || season === undefined) throw new Error('App context has undefineds'); +const MATCHES_TO_SHOW = 5; +const Queueing = () => { + const { data: event } = useRealtimeEvent(); + const { data: matches } = useRealtimeMatches('Qualification'); + const { data: scheduleDeviations } = useRealtimeScheduleDeviations('Qualification'); + const { data: firebaseEvent } = useFirebaseEvent(); const [loadingState, setLoadingState] = useState('loading'); const dbEventRef = useRef(); - const [qualMatches, setQualMatches] = useState([]); const [displayMatches, setDisplayMatches] = useState<{ - currentMatch: QualMatch | null, - nextMatch: QualMatch | null, - queueingMatches: QualMatch[] + currentMatch: DisplayMatch | null, + nextMatch: DisplayMatch | null, + queueingMatches: DisplayMatch[] }>({ currentMatch: null, nextMatch: null, queueingMatches: [] }); - const [rankings, setRankings] = useState([]); + const rankings = useRealtimeRankings(); + const sortedRankings = useMemo(() => + rankings.data ? rankings.data.sort((a, b) => a.rank - b.rank) : [], [rankings.data]); useEffect(() => { - if (!token) return () => {}; - - dbEventRef.current = ref(getDatabase(), `/seasons/${season}/events/${token}`); - - const matchesRef = ref(getDatabase(), `/seasons/${season}/qual/${token}`); - onValue(matchesRef, (snap) => { - setQualMatches(snap.val() as QualMatch[]); - }); - - return () => { - off(matchesRef); - }; - }, [event.eventCode, season, token]); - - const getMatchByNumber = (matchNumber: number): QualMatch | null => qualMatches?.find( - (x) => x.number === matchNumber, - ) ?? null; - - const updateMatches = (): void => { - const matchNumber = event.currentMatchNumber; - - if (matchNumber === null || matchNumber === undefined) { - if (dbEventRef.current === undefined) return; // throw new Error('No event ref'); - update(dbEventRef.current, { - currentMatchNumber: 1, - }); - return; - } - - try { - setDisplayMatches({ - currentMatch: getMatchByNumber(matchNumber), - nextMatch: getMatchByNumber(matchNumber + 1), - // By default, we'll take the three matches after the one on deck - queueingMatches: [2, 3, 4].map((x) => getMatchByNumber(matchNumber + x)) - .filter((x) => x !== null) as QualMatch[], - }); - setLoadingState('ready'); - } catch (e) { - setLoadingState('error'); - console.error(e); - } - }; - - /** - * Swap the event's mode - * NOTE: This function must use refs instead of state. It can be called by an event listener - * which is only initialized as the component mounts. - * @param {"automatic" | "assisted" | null} mode The mode to switch to - */ - const swapMode = (mode: AppMode | null = null): void => { - if (dbEventRef.current === undefined) throw new Error('No event ref'); - let appMode = mode; - if (appMode === null) appMode = event.mode === 'assisted' ? 'automatic' : 'assisted'; - if (appMode === 'assisted') { - if (loadingState === 'noAutomatic' || event.currentMatchNumber === null) { - updateMatches(); - } - } - update(dbEventRef.current, { - mode: appMode, - }); - }; - - /** - * A reducer is being used because some actions can be performed in the context of an event - * listener, where access to the current state is not guaranteed. - */ - const [_, dispatchEventChange] = useReducer((_s, action) => { - if (!dbEventRef.current) throw new Error('DB ref not defined'); - const { type, number, mode } = action; - if (type === 'swap') { - swapMode(mode); - return; - } - - if (event.mode !== 'assisted') { - console.warn('Tried to set current match number while not in assisted mode!'); - return; - } - let newMatchNumber: number; - if (type === 'increment') { - newMatchNumber = event.currentMatchNumber! + 1; - } else if (type === 'decrement') { - if (event.mode !== 'assisted') return; - newMatchNumber = event.currentMatchNumber! - 1; - } else if (type === 'set') { - if (event.mode !== 'assisted') return; - if (number === undefined) throw new Error('`number` is required for `set` dispatches'); - newMatchNumber = number; - } else { - throw new Error('Unknown action type passed to match number reducer'); - } - update(dbEventRef.current, { - currentMatchNumber: newMatchNumber, - }); - }, undefined); - - const handleKeyPress = (e: KeyboardEvent): void => { - switch (e.code) { - case 'KeyA': - dispatchEventChange({ type: 'swap' }); - break; - case 'ArrowLeft': - dispatchEventChange({ type: 'decrement' }); - break; - case 'ArrowRight': - dispatchEventChange({ type: 'increment' }); - break; - default: - break; - } - }; + if (!event?.code || !event?.seasons?.name) return () => {}; + dbEventRef.current = ref(getDatabase(), `/seasons/${event.seasons.name}/events/${event.key}`); + }, [event?.code, event?.seasons?.name]); const setShowEventName = (value: boolean): void => { if (dbEventRef.current === undefined) throw new Error('No event ref'); @@ -168,119 +56,100 @@ const Queueing = () => { const menuOptions = () => ( <> - - - {event.mode === 'assisted' &&
Use the left/right arrow keys to change current match
} - ); useEffect(() => { - if (typeof document === undefined) return () => {}; - - document.addEventListener('keydown', handleKeyPress); - - return () => { - document.removeEventListener('keydown', handleKeyPress); - }; - }, []); - - useEffect(() => { - updateMatches(); - }, [event.currentMatchNumber, qualMatches]); + // TODO: Handle schedule deviations + const scheduleItems : (Match | ScheduleDeviation)[] | undefined = matches?.sort((a, b) => (a.match_number - b.match_number) || ((a.play_number ?? 0) - (b.play_number ?? 0))).filter(m => !m.actual_start_time && !m.is_discarded); + if (!scheduleItems || scheduleItems.length == 0) { + setLoadingState('ready'); + return; + } + + for (let i = 0; i < MATCHES_TO_SHOW; i++) { + const matchId = scheduleItems[i]?.id; + const deviationAfter = scheduleDeviations + ? scheduleDeviations.find(d => d.after_match_id.id == matchId) + : null; + + if (deviationAfter) { + if (deviationAfter.description) { + scheduleItems.splice(i + 1, 0, deviationAfter); + i++; + } + } + } - useEffect(() => { - const rankingsRef = ref(getDatabase(), `/seasons/${season}/rankings/${token}`); - if (event.options?.showRankings) { - onValue(rankingsRef, (snap) => { - setRankings((snap.val() as TeamRanking[])?.sort((a, b) => a.rank - b.rank) ?? []); + try { + setDisplayMatches({ + currentMatch: scheduleItems[0], + nextMatch: MATCHES_TO_SHOW > 1 ? scheduleItems[1] : null, + // By default, we'll take the three matches after the one on deck + queueingMatches: MATCHES_TO_SHOW > 2 + ? [...Array(MATCHES_TO_SHOW - 2)].map((_, i) => scheduleItems[i+2]) + .filter((x) => x) + : [], }); - } else { - off(rankingsRef); + setLoadingState('ready'); + } catch (e) { + setLoadingState('error'); + console.error(e); } - - return () => { off(rankingsRef); }; - }, [event.options?.showRankings]); + }, [matches, scheduleDeviations]); const { currentMatch, nextMatch, queueingMatches } = displayMatches; return ( <> - +
{loadingState === 'loading' &&
Loading matches...
} {loadingState === 'error' &&
Failed to fetch matches
} {loadingState === 'noAutomatic' &&
Unable to run in automatic mode. Press the 'a' key to switch modes.
} - {loadingState === 'ready' && !qualMatches?.length &&
Waiting for schedule to be posted...
} - {loadingState === 'ready' && qualMatches?.length !== 0 + {loadingState === 'ready' && !matches?.length &&
Waiting for schedule to be posted...
} + {loadingState === 'ready' && matches?.length !== 0 && currentMatch === null &&
Qualification matches have ended
} + {loadingState === 'ready' && matches?.length !== 0 && event && (
- {event.mode === 'assisted' && ( -
- - -
- )}
- {(event.options?.showEventName ?? false) && ( + {(firebaseEvent?.options?.showEventName ?? false) && (
{event.name}
)}
{currentMatch && (
- + {'match_number' in currentMatch && } + {'description' in currentMatch &&

{currentMatch.description}

} On Field
)} {nextMatch && (
- + {'match_number' in nextMatch && } + {'description' in nextMatch &&

{nextMatch.description}

} On Deck
)}
- {queueingMatches.map((x) => ( - - ))} + {queueingMatches.map((x) => (<> + {'match_number' in x && } + {'description' in x &&

{x.description}

} + ))}
- {(event.options?.showRankings ?? false ? ( + {((firebaseEvent?.options?.showRankings ?? false) && !rankings.isPending ? ( - {rankings.map((x) => ())} + {sortedRankings.map((x) => ())} ) : <>)}
diff --git a/src/components/QualDisplay/Queueing/styles.module.scss b/src/components/QualDisplay/Queueing/styles.module.scss index 0685095..8e64149 100644 --- a/src/components/QualDisplay/Queueing/styles.module.scss +++ b/src/components/QualDisplay/Queueing/styles.module.scss @@ -74,4 +74,10 @@ $border-color: #222; text-align: center; font-size: 1.5em; } + + .scheduleDeviation { + font-weight: 700; + text-align: center; + margin-block: 0.5em; + } } \ No newline at end of file diff --git a/src/components/RankingDisplay/TeamRankings/index.tsx b/src/components/RankingDisplay/TeamRankings/index.tsx index e524215..b6ef181 100644 --- a/src/components/RankingDisplay/TeamRankings/index.tsx +++ b/src/components/RankingDisplay/TeamRankings/index.tsx @@ -10,22 +10,19 @@ import MenuBar from '../../MenuBar'; import styles from './styles.module.scss'; import AppContext from '../../../AppContext'; import numberToOrdinal from '@/util/numberToOrdinal'; +import { useRealtimeRankings } from "@/hooks/supabase/useRealtimeRankings"; +import styled from "styled-components"; const numFmt = new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); -const TeamRankings = () => { - const { event, season, token } = useContext(AppContext); - const [rankings, setRankings] = useState([]); - useEffect(() => { - if (!token) throw new Error('Token was somehow empty.'); - - const rankingsRef = ref(getDatabase(), `/seasons/${season}/rankings/${token}`); - onValue(rankingsRef, (snap) => { - setRankings((snap.val() as TeamRanking[])?.sort((a, b) => a.rank - b.rank) ?? []); - }); +const Message = styled.div` + text-align: center; + width: 100%; + padding-block: 5vw; +`; - return () => { off(rankingsRef); }; - }, []); +const TeamRankings = () => { + const rankings = useRealtimeRankings(); const tableRef: RefObject = createRef(); useEffect(() => { @@ -33,14 +30,20 @@ const TeamRankings = () => { tableRef.current.style.animationDuration = `${(tableRef.current.clientHeight / 40)}s`; }, [tableRef]); + if (rankings.isError) return ( + Failed to load rankings: {rankings.error?.message} + ); + + if (rankings.isPending) return (Loading...); + return ( <> - +
- {/* TODO(@evanlihou): This should be toggleable from this page */} - {event?.options.showEventName === true && ( -
{event.name} ({season})
- )} + {/* TODO(@evanlihou): Reenable display of event name */} + {/*{event?.options.showEventName === true && (*/} + {/*
{event.name} ({season})
*/} + {/*)}*/} @@ -65,16 +68,16 @@ const TeamRankings = () => { - {rankings.map((ranking) => ( + {rankings.data?.map((ranking) => ( - - + + {/* Game specific: 2024 */} - - - - + + + + {/* End game specific */}
{numberToOrdinal(ranking.rank)}{ranking.teamNumber}{numFmt.format(ranking.rankingPoints)}{ranking.team_number}{numFmt.format(ranking.sort_orders[0])}{numFmt.format(ranking.sortOrder2)}{numFmt.format(ranking.sortOrder3)}{numFmt.format(ranking.sortOrder4)}{numFmt.format(ranking.sortOrder5)}{numFmt.format(ranking.sort_orders[1])}{numFmt.format(ranking.sort_orders[2])}{numFmt.format(ranking.sort_orders[3])}{numFmt.format(ranking.sort_orders[4])} {ranking.wins ?? 0} diff --git a/src/components/ScreenChooser/index.tsx b/src/components/ScreenChooser/index.tsx index 202db96..57933be 100644 --- a/src/components/ScreenChooser/index.tsx +++ b/src/components/ScreenChooser/index.tsx @@ -6,6 +6,7 @@ import AppContext from '@/AppContext'; import MenuBar from '../MenuBar'; import styles from './styles.module.scss'; import Routes from '@/routes'; +import { useRealtimeEvent } from "@/hooks/supabase/useRealtimeEvent"; let previousSetupState: { qual: string, playoff: string } | undefined; @@ -14,6 +15,7 @@ export default function ScreenChooser() { const { event, season } = ctx; const [qualScreen, setQualScreen] = useState('/qual/queueing'); const [playoffScreen, setPlayoffScreen] = useState('/playoff/queueing'); + const supaEvent = useRealtimeEvent(); function buildSetupQueryString() { const parts = []; diff --git a/src/data/supabase.ts b/src/data/supabase.ts new file mode 100644 index 0000000..7c6b7ab --- /dev/null +++ b/src/data/supabase.ts @@ -0,0 +1,4 @@ +import { createClient } from "@supabase/supabase-js"; + +export const supabase = + createClient(import.meta.env.APP_SUPA_URL, import.meta.env.APP_SUPA_APIKEY); \ No newline at end of file diff --git a/src/hooks/firebase/useFirebaseEvent.ts b/src/hooks/firebase/useFirebaseEvent.ts new file mode 100644 index 0000000..735bb92 --- /dev/null +++ b/src/hooks/firebase/useFirebaseEvent.ts @@ -0,0 +1,26 @@ +import { useRealtimeEvent } from "@/hooks/supabase/useRealtimeEvent"; +import { useEffect } from "preact/hooks"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { getDatabase, onValue, ref } from "firebase/database"; +import { Alliance } from "@/types"; +import { Event } from "@shared/DbTypes"; + +export const useFirebaseEvent = () => { + const { data } = useRealtimeEvent(); + const queryClient = useQueryClient(); + const firebaseQuery = useQuery({ + queryKey: ["firebase-event"], + staleTime: Infinity, + }); + + useEffect(() => { + if (!data?.seasons?.name || !data?.key) return () => {}; + const dbRef = ref(getDatabase(), `/seasons/${data?.seasons?.name}/events/${data?.key}`); + const unsubscribe = onValue(dbRef, (snap) => { + queryClient.setQueryData(["firebase-event"], () => snap.val()); + }); + return () => unsubscribe(); + }, [queryClient, data?.key, data?.seasons?.name]); + + return firebaseQuery; +} \ No newline at end of file diff --git a/src/hooks/supabase/useGetEvent.ts b/src/hooks/supabase/useGetEvent.ts new file mode 100644 index 0000000..b25d2c9 --- /dev/null +++ b/src/hooks/supabase/useGetEvent.ts @@ -0,0 +1,43 @@ +import { useQuery } from "@tanstack/react-query"; +import { supabase } from "@/data/supabase"; + +export type EventStatus = + 'NotStarted' | + 'AwaitingQuals' | + 'QualsInProgress' | + 'AwaitingAlliances' | + 'AwaitingPlayoffs' | + 'PlayoffsInProgress' | + 'WinnerDetermined' | + 'Completed'; + +export type Event = { + id: string, + key: string, + code: string, + name: string, + status: EventStatus, + seasons?: { + name: string + } +}; + +export const useGetEventQueryKey = () => ["events"]; + +export const useGetEvent = (enabled: boolean = true) => { + return useQuery({ + queryKey: useGetEventQueryKey(), + queryFn: async (): Promise => { + const result = await supabase + .from("events") + .select("id,key,code,name,status,seasons(name)") + .maybeSingle(); + + if (result.error != null) throw result.error; + + return result.data; + }, + staleTime: Infinity, + enabled: enabled, + }); +} \ No newline at end of file diff --git a/src/hooks/supabase/useGetMatches.ts b/src/hooks/supabase/useGetMatches.ts new file mode 100644 index 0000000..e49b507 --- /dev/null +++ b/src/hooks/supabase/useGetMatches.ts @@ -0,0 +1,35 @@ +import { useQuery } from "@tanstack/react-query"; +import { supabase } from "@/data/supabase"; + +export type Match = { + id: number, + match_number: number, + play_number: number | null, + red_alliance_teams: number[] | null, + blue_alliance_teams: number[] | null, + scheduled_start_time: Date | null, + actual_start_time: Date | null, + post_result_time: Date | null, + is_discarded: boolean | null, +}; + +export type TournamentLevel = 'Qualification' | 'Playoff'; + +export const useGetMatchesQueryKey = (level: TournamentLevel) => ["matches", level]; + +export const useGetMatches = (level: TournamentLevel) => { + return useQuery({ + queryKey: useGetMatchesQueryKey(level), + queryFn: async () => { + const result = await supabase + .from("matches") + .select("id,match_number,play_number,red_alliance_teams,blue_alliance_teams,scheduled_start_time,actual_start_time,post_result_time,is_discarded") + .eq('tournament_level', level) + .returns(); + + if (result.error != null) throw result.error; + + return result.data; + } + }); +} \ No newline at end of file diff --git a/src/hooks/supabase/useGetRankings.ts b/src/hooks/supabase/useGetRankings.ts new file mode 100644 index 0000000..8a9d736 --- /dev/null +++ b/src/hooks/supabase/useGetRankings.ts @@ -0,0 +1,35 @@ +import { useQuery } from "@tanstack/react-query"; +import { supabase } from "@/data/supabase"; + +export type Ranking = { + id: number, + team_number: number, + rank: number, + sort_orders: number[], + wins: number, + ties: number, + losses: number, + qual_average: number, + disqualifications: number, + matches_played: number +}; + +export const useGetRankingsQueryKey = () => ["rankings"]; + +export const useGetRankings = (enabled: boolean = true) => { + return useQuery({ + queryKey: useGetRankingsQueryKey(), + queryFn: async (): Promise => { + const result = await supabase + .from("event_rankings") + .select("*") + .returns(); + + if (result.error != null) throw result.error; + + return result.data; + }, + staleTime: Infinity, + enabled: enabled, + }); +} \ No newline at end of file diff --git a/src/hooks/supabase/useGetScheduleDeviations.ts b/src/hooks/supabase/useGetScheduleDeviations.ts new file mode 100644 index 0000000..89f5535 --- /dev/null +++ b/src/hooks/supabase/useGetScheduleDeviations.ts @@ -0,0 +1,33 @@ +import { useQuery } from "@tanstack/react-query"; +import { supabase } from "@/data/supabase"; +import { TournamentLevel } from "@/hooks/supabase/useGetMatches"; + +export type ScheduleDeviation = { + id: number, + description: string | null, + after_match_id: { + id: number + } + associated_match_id: { + id: number + } | null +}; + +export const useGetScheduleDeviationsQueryKey = (level: TournamentLevel) => ["scheduleDeviations", level]; + +export const useGetScheduleDeviations = (level: TournamentLevel) => { + return useQuery({ + queryKey: useGetScheduleDeviationsQueryKey(level), + queryFn: async () => { + const result = await supabase + .from("schedule_deviations") + .select("id,description,after_match_id(id),associated_match_id(id)") + .eq('after_match_id.tournament_level', level) + .returns(); + + if (result.error != null) throw result.error; + + return result.data; + } + }); +} \ No newline at end of file diff --git a/src/hooks/supabase/useGetSeason.ts b/src/hooks/supabase/useGetSeason.ts new file mode 100644 index 0000000..d6612cf --- /dev/null +++ b/src/hooks/supabase/useGetSeason.ts @@ -0,0 +1,24 @@ +import { useQuery } from "@tanstack/react-query"; +import { supabase } from "@/data/supabase"; + +export type Season = { + id: string, + name: string +}; + +export const useGetSeasonQueryKey = () => ["season"]; + +export const useGetSeason = (enabled: boolean = true) => { + return useQuery({ + queryKey: useGetSeasonQueryKey(), + queryFn: async (): Promise => { + const result = await supabase.from("seasons").select().maybeSingle(); + + if (result.error != null) throw result.error; + + return result.data; + }, + enabled: enabled, + staleTime: Infinity + }); +} \ No newline at end of file diff --git a/src/hooks/supabase/useRealtimeEvent.ts b/src/hooks/supabase/useRealtimeEvent.ts new file mode 100644 index 0000000..5db8e0e --- /dev/null +++ b/src/hooks/supabase/useRealtimeEvent.ts @@ -0,0 +1,60 @@ +import { useEffect } from "preact/hooks"; +import { supabase } from "@/data/supabase"; +import { useQueryClient } from "@tanstack/react-query"; +import { Event, useGetEvent, useGetEventQueryKey } from "@/hooks/supabase/useGetEvent"; + +/** + * Note! This will not update data in related tables. E.g., if the season name changes, this won't + * pick it up. + */ +export const useRealtimeEvent = () => { + const queryClient = useQueryClient(); + const query = useGetEvent(); + + // This table is a special case: DB rules prevent fim-queueing from seeing any events + // other than the one its token is associated with. Therefore, we can assume any changes + // we hear about pertain to the current event. + useEffect(() => { + const changes = supabase.channel('event-changes') + .on('postgres_changes', { + event: '*', + schema: 'public', + table: 'events' + }, (payload) => { + console.log('event rt', payload); + const changeType = payload.eventType; + if (changeType === 'INSERT') { + queryClient.setQueryData(useGetEventQueryKey(), (old: Event | undefined) => { + return { + ...old, + id: payload.new.id, + key: payload.new.key, + code: payload.new.code, + name: payload.new.name, + status: payload.new.status + } as Event; + }); + } else if (changeType === 'DELETE') { + queryClient.setQueryData(useGetEventQueryKey(), () => { + return null; + }); + } else if (changeType === 'UPDATE') { + queryClient.setQueryData(useGetEventQueryKey(), (old: Event | undefined) => { + return { + ...old, + id: payload.new.id, + key: payload.new.key, + code: payload.new.code, + name: payload.new.name, + status: payload.new.status + } as Event; + }); + } + }) + .subscribe(); + + return async () => { await changes.unsubscribe(); }; + }, []); + + return query; +} \ No newline at end of file diff --git a/src/hooks/supabase/useRealtimeMatches.ts b/src/hooks/supabase/useRealtimeMatches.ts new file mode 100644 index 0000000..17370a8 --- /dev/null +++ b/src/hooks/supabase/useRealtimeMatches.ts @@ -0,0 +1,39 @@ +import { useEffect } from "preact/hooks"; +import { Match, TournamentLevel, useGetMatches, useGetMatchesQueryKey } from "@/hooks/supabase/useGetMatches"; +import { supabase } from "@/data/supabase"; +import { useQueryClient } from "@tanstack/react-query"; + +export const useRealtimeMatches = (level: TournamentLevel) => { + const queryClient = useQueryClient(); + const query = useGetMatches(level); + + useEffect(() => { + const changes = supabase.channel('match-changes') + .on('postgres_changes', { + event: '*', + schema: 'public', + table: 'matches', + filter: 'tournament_level=eq.' + encodeURIComponent(level) + }, (payload) => { + const changeType = payload.eventType; + if (changeType === 'INSERT') { + queryClient.setQueryData(useGetMatchesQueryKey(level), (oldData: Match[]) => { + return [...oldData, payload.new]; + }); + } else if (changeType === 'DELETE') { + queryClient.setQueryData(useGetMatchesQueryKey(level), (oldData: Match[]) => { + return oldData.filter(m => m.id !== payload.old.id); + }); + } else if (changeType === 'UPDATE') { + queryClient.setQueryData(useGetMatchesQueryKey(level), (oldData: Match[]) => { + return oldData.map(m => m.id === payload.old.id ? payload.new : m); + }); + } + }) + .subscribe(); + + return async () => { await changes.unsubscribe(); }; + }, []); + + return query; +} \ No newline at end of file diff --git a/src/hooks/supabase/useRealtimeRankings.ts b/src/hooks/supabase/useRealtimeRankings.ts new file mode 100644 index 0000000..aef7614 --- /dev/null +++ b/src/hooks/supabase/useRealtimeRankings.ts @@ -0,0 +1,38 @@ +import { useEffect } from "preact/hooks"; +import { supabase } from "@/data/supabase"; +import { useQueryClient } from "@tanstack/react-query"; +import { Ranking, useGetRankings, useGetRankingsQueryKey } from "@/hooks/supabase/useGetRankings"; + +export const useRealtimeRankings = () => { + const queryClient = useQueryClient(); + const query = useGetRankings(); + + useEffect(() => { + const changes = supabase.channel('ranking-changes') + .on('postgres_changes', { + event: '*', + schema: 'public', + table: 'event_rankings' + }, (payload) => { + const changeType = payload.eventType; + if (changeType === 'INSERT') { + queryClient.setQueryData(useGetRankingsQueryKey(), (oldData: Ranking[]) => { + return [...oldData, payload.new]; + }); + } else if (changeType === 'DELETE') { + queryClient.setQueryData(useGetRankingsQueryKey(), (oldData: Ranking[]) => { + return oldData.filter(m => m.id !== payload.old.id); + }); + } else if (changeType === 'UPDATE') { + queryClient.setQueryData(useGetRankingsQueryKey(), (oldData: Ranking[]) => { + return oldData.map(m => m.id === payload.old.id ? payload.new : m); + }); + } + }) + .subscribe(); + + return async () => { await changes.unsubscribe(); }; + }, []); + + return query; +} \ No newline at end of file diff --git a/src/hooks/supabase/useRealtimeScheduleDeviations.ts b/src/hooks/supabase/useRealtimeScheduleDeviations.ts new file mode 100644 index 0000000..3e1ed8a --- /dev/null +++ b/src/hooks/supabase/useRealtimeScheduleDeviations.ts @@ -0,0 +1,44 @@ +import { useEffect } from "preact/hooks"; +import { Match, TournamentLevel, useGetMatches, useGetMatchesQueryKey } from "@/hooks/supabase/useGetMatches"; +import { supabase } from "@/data/supabase"; +import { useQueryClient } from "@tanstack/react-query"; +import { + ScheduleDeviation, + useGetScheduleDeviations, + useGetScheduleDeviationsQueryKey +} from "@/hooks/supabase/useGetScheduleDeviations"; + +export const useRealtimeScheduleDeviations = (level: TournamentLevel) => { + const queryClient = useQueryClient(); + const query = useGetScheduleDeviations(level); + + useEffect(() => { + const changes = supabase.channel('schedule-deviation-changes') + .on('postgres_changes', { + event: '*', + schema: 'public', + table: 'schedule_deviations', + filter: 'tournament_level=eq.' + encodeURIComponent(level) + }, (payload) => { + const changeType = payload.eventType; + if (changeType === 'INSERT') { + queryClient.setQueryData(useGetScheduleDeviationsQueryKey(level), (oldData: ScheduleDeviation[]) => { + return [...oldData, payload.new]; + }); + } else if (changeType === 'DELETE') { + queryClient.setQueryData(useGetScheduleDeviationsQueryKey(level), (oldData: ScheduleDeviation[]) => { + return oldData.filter(m => m.id !== payload.old.id); + }); + } else if (changeType === 'UPDATE') { + queryClient.setQueryData(useGetScheduleDeviationsQueryKey(level), (oldData: ScheduleDeviation[]) => { + return oldData.map(m => m.id === payload.old.id ? payload.new : m); + }); + } + }) + .subscribe(); + + return async () => { await changes.unsubscribe(); }; + }, []); + + return query; +} \ No newline at end of file