Scouting: Add ability to scout offline
Signed-off-by: Filip Kujawa <filip.j.kujawa@gmail.com>
Change-Id: I0e0c0be033824c05b6c239d38eb79d95745fceec
diff --git a/scouting/scouting_test.cy.js b/scouting/scouting_test.cy.js
index 31ca935..d15cdfc 100644
--- a/scouting/scouting_test.cy.js
+++ b/scouting/scouting_test.cy.js
@@ -117,15 +117,27 @@
clickButton('Submit');
headerShouldBe(teamNumber + ' Success ');
}
+function visit(path) {
+ cy.visit(path, {
+ onBeforeLoad(win) {
+ // The service worker seems to interfere with Cypress somehow. There
+ // doesn't seem to be a proper fix for this issue. Work around it with
+ // this hack that disables the service worker.
+ // https://github.com/cypress-io/cypress/issues/16192#issuecomment-870421667
+ // https://github.com/cypress-io/cypress/issues/702#issuecomment-587127275
+ delete win.navigator.__proto__.serviceWorker;
+ },
+ });
+}
before(() => {
- cy.visit('/');
+ visit('/');
disableAlerts();
cy.title().should('eq', 'FRC971 Scouting Application');
});
beforeEach(() => {
- cy.visit('/');
+ visit('/');
disableAlerts();
});
diff --git a/scouting/www/BUILD b/scouting/www/BUILD
index b46f265..a6ca0a1 100644
--- a/scouting/www/BUILD
+++ b/scouting/www/BUILD
@@ -1,10 +1,19 @@
load("@aspect_bazel_lib//lib:copy_file.bzl", "copy_file")
+load("@aspect_rules_js//js:defs.bzl", "js_binary", "js_run_binary")
+load("@npm//:@angular/service-worker/package_json.bzl", angular_service_worker = "bin")
load("@npm//:defs.bzl", "npm_link_all_packages")
load("//tools/build_rules:js.bzl", "ng_application")
-load(":defs.bzl", "assemble_static_files")
+load(":defs.bzl", "assemble_service_worker_files", "assemble_static_files")
npm_link_all_packages(name = "node_modules")
+assemble_service_worker_files(
+ name = "service_worker_files",
+ outs = [
+ "ngsw-worker.js",
+ ],
+)
+
OPENCV_VERSION = "4.9.0"
copy_file(
@@ -16,6 +25,7 @@
ng_application(
name = "app",
assets = [
+ "manifest.json",
":opencv.js",
],
extra_srcs = [
@@ -23,9 +33,11 @@
],
html_assets = [
"favicon.ico",
+ "assets/971_144.png",
],
deps = [
"//:node_modules/@angular/animations",
+ "//:node_modules/@angular/service-worker",
"//scouting/www/driver_ranking",
"//scouting/www/entry",
"//scouting/www/match_list",
@@ -42,6 +54,8 @@
app_files = ":app",
pictures = [
"//third_party/y2024/field:pictures",
+ ":ngsw-worker.js",
+ ":ngsw.json",
],
replace_prefixes = {
"prod": "",
@@ -60,3 +74,35 @@
out = "app/common.css",
visibility = ["//scouting/www:__subpackages__"],
)
+
+angular_service_worker.ngsw_config_binary(
+ name = "ngsw_config_binary",
+)
+
+js_binary(
+ name = "ngsw_config_wrapper",
+ data = [
+ ":ngsw_config_binary",
+ ],
+ entry_point = "ngsw_config_wrapper.js",
+)
+
+js_run_binary(
+ name = "ngsw_config",
+ srcs = [
+ "manifest.json",
+ "ngsw-config.json",
+ ":app",
+ ":ngsw_config_binary",
+ ],
+ outs = [
+ "ngsw.json",
+ ],
+ args = [
+ "$(rootpath :ngsw_config_binary)",
+ "$(rootpath :ngsw.json)",
+ "$(rootpath :prod)",
+ "$(rootpath ngsw-config.json)",
+ ],
+ tool = ":ngsw_config_wrapper",
+)
diff --git a/scouting/www/app/app.module.ts b/scouting/www/app/app.module.ts
index ccc4840..decd1f3 100644
--- a/scouting/www/app/app.module.ts
+++ b/scouting/www/app/app.module.ts
@@ -1,6 +1,7 @@
-import {NgModule} from '@angular/core';
+import {NgModule, isDevMode} from '@angular/core';
import {BrowserModule} from '@angular/platform-browser';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
+import {ServiceWorkerModule} from '@angular/service-worker';
import {App} from './app';
import {EntryModule} from '../entry';
@@ -17,6 +18,12 @@
imports: [
BrowserModule,
BrowserAnimationsModule,
+ ServiceWorkerModule.register('./ngsw-worker.js', {
+ enabled: !isDevMode(),
+ // Register the ServiceWorker as soon as the application is stable
+ // or after 30 seconds (whichever comes first).
+ registrationStrategy: 'registerWhenStable:30000',
+ }),
EntryModule,
NotesModule,
MatchListModule,
diff --git a/scouting/www/app/app.ts b/scouting/www/app/app.ts
index 895336b..ab15ae5 100644
--- a/scouting/www/app/app.ts
+++ b/scouting/www/app/app.ts
@@ -1,4 +1,4 @@
-import {Component, ElementRef, ViewChild} from '@angular/core';
+import {Component, ElementRef, ViewChild, isDevMode} from '@angular/core';
type Tab =
| 'MatchList'
@@ -40,6 +40,8 @@
@ViewChild('block_alerts') block_alerts: ElementRef;
constructor() {
+ console.log(`Using development mode: ${isDevMode()}`);
+
window.addEventListener('beforeunload', (e) => {
if (!unguardedTabs.includes(this.tab)) {
if (!this.block_alerts.nativeElement.checked) {
diff --git a/scouting/www/assets/971_144.png b/scouting/www/assets/971_144.png
new file mode 100644
index 0000000..881edfa
--- /dev/null
+++ b/scouting/www/assets/971_144.png
Binary files differ
diff --git a/scouting/www/defs.bzl b/scouting/www/defs.bzl
index 828f30a..e7fb44a 100644
--- a/scouting/www/defs.bzl
+++ b/scouting/www/defs.bzl
@@ -27,6 +27,7 @@
),
"pictures": attr.label_list(
mandatory = True,
+ allow_files = True,
),
"replace_prefixes": attr.string_dict(
mandatory = True,
@@ -34,3 +35,45 @@
},
toolchains = ["@aspect_bazel_lib//lib:copy_to_directory_toolchain_type"],
)
+
+def _assemble_service_worker_files_impl(ctx):
+ args = ctx.actions.args()
+ args.add_all(ctx.attr._package.files, before_each = "--input_dir", expand_directories = False)
+ args.add_all(ctx.outputs.outs, before_each = "--output")
+ args.add_all(ctx.attr.outs_as_strings, before_each = "--relative_output")
+ ctx.actions.run(
+ inputs = ctx.attr._package.files,
+ outputs = ctx.outputs.outs,
+ executable = ctx.executable._tool,
+ arguments = [args],
+ mnemonic = "AssembleAngularServiceWorker",
+ )
+
+_assemble_service_worker_files = rule(
+ implementation = _assemble_service_worker_files_impl,
+ attrs = {
+ "outs": attr.output_list(
+ allow_empty = False,
+ mandatory = True,
+ ),
+ "outs_as_strings": attr.string_list(
+ allow_empty = False,
+ mandatory = True,
+ ),
+ "_package": attr.label(
+ default = "//:node_modules/@angular/service-worker",
+ ),
+ "_tool": attr.label(
+ default = "//tools/build_rules/js:assemble_service_worker_files",
+ cfg = "exec",
+ executable = True,
+ ),
+ },
+)
+
+def assemble_service_worker_files(outs, **kwargs):
+ _assemble_service_worker_files(
+ outs = outs,
+ outs_as_strings = outs,
+ **kwargs
+ )
diff --git a/scouting/www/index.html b/scouting/www/index.html
index e4e996a..821acf2 100644
--- a/scouting/www/index.html
+++ b/scouting/www/index.html
@@ -4,6 +4,8 @@
<meta charset="utf-8" />
<title>FRC971 Scouting Application</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
+ <meta name="theme-color" content="#000000" />
+ <link rel="manifest" href="/manifest.json" />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css"
@@ -25,5 +27,8 @@
</head>
<body>
<my-app></my-app>
+ <noscript>
+ Please enable JavaScript to continue using this application.
+ </noscript>
</body>
</html>
diff --git a/scouting/www/manifest.json b/scouting/www/manifest.json
new file mode 100644
index 0000000..9366399
--- /dev/null
+++ b/scouting/www/manifest.json
@@ -0,0 +1,17 @@
+{
+ "name": "FRC971 Scouting App",
+ "short_name": "scouting",
+ "theme_color": "#1976d2",
+ "background_color": "#fafafa",
+ "display": "standalone",
+ "scope": "./",
+ "start_url": "./",
+ "icons": [
+ {
+ "src": "assets/971_144.png",
+ "sizes": "144x144",
+ "type": "image/png",
+ "purpose": "maskable any"
+ }
+ ]
+}
diff --git a/scouting/www/ngsw-config.json b/scouting/www/ngsw-config.json
new file mode 100644
index 0000000..78d921d
--- /dev/null
+++ b/scouting/www/ngsw-config.json
@@ -0,0 +1,32 @@
+{
+ "$schema": "./node_modules/@angular/service-worker/config/schema.json",
+ "index": "/index.html",
+ "assetGroups": [
+ {
+ "name": "app",
+ "installMode": "prefetch",
+ "resources": {
+ "files": [
+ "/favicon.ico",
+ "/index.html",
+ "/manifest.json",
+ "/*.css",
+ "/bundle-*/*.js",
+ "/*.js"
+ ]
+ }
+ },
+ {
+ "name": "assets",
+ "installMode": "lazy",
+ "updateMode": "prefetch",
+ "resources": {
+ "files": [
+ "/assets/**",
+ "/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
+ ]
+ }
+ }
+ ]
+ }
+
diff --git a/scouting/www/ngsw_config_wrapper.js b/scouting/www/ngsw_config_wrapper.js
new file mode 100644
index 0000000..0bc7f19
--- /dev/null
+++ b/scouting/www/ngsw_config_wrapper.js
@@ -0,0 +1,37 @@
+const fs = require('fs');
+const path = require('path');
+const {spawnSync} = require('child_process');
+
+const output_dir = path.join(
+ process.env.BAZEL_BINDIR,
+ process.env.BAZEL_PACKAGE
+);
+console.log(output_dir);
+console.log(process.argv[2]);
+console.log(process.cwd());
+const ngsw_config = process.argv[2];
+console.log(`Trying to run ${ngsw_config} ${process.argv.slice(4).join(' ')}`);
+const result = spawnSync(ngsw_config, process.argv.slice(4), {
+ stdout: 'inherit',
+ stderr: 'inherit',
+});
+
+if (result.status || result.error || result.signal) {
+ console.log("Failed to run 'ngsw_config'");
+ console.log(`status: ${result.status}`);
+ console.log(`error: ${result.error}`);
+ console.log(`signal: ${result.signal}`);
+ console.log(`stdout: ${result.stdout}`);
+ console.log(`stderr: ${result.stderr}`);
+ process.exit(1);
+}
+
+const currentDirectory = process.cwd();
+
+// Read the contents of the current directory
+console.log(`Contents of the current directory: ${currentDirectory}`);
+fs.readdirSync(currentDirectory).forEach((file) => {
+ console.log(file);
+});
+
+fs.copyFileSync(path.join(process.argv[4], 'ngsw.json'), process.argv[3]);
diff --git a/scouting/www/rpc/BUILD b/scouting/www/rpc/BUILD
index d1367ea..592735c 100644
--- a/scouting/www/rpc/BUILD
+++ b/scouting/www/rpc/BUILD
@@ -10,6 +10,7 @@
],
generate_public_api = False,
deps = [
+ "//:node_modules/dexie",
"//scouting/webserver/requests/messages:error_response_ts_fbs",
"//scouting/webserver/requests/messages:request_2024_data_scouting_response_ts_fbs",
"//scouting/webserver/requests/messages:request_2024_data_scouting_ts_fbs",
diff --git a/scouting/www/rpc/db.ts b/scouting/www/rpc/db.ts
new file mode 100644
index 0000000..789ac0e
--- /dev/null
+++ b/scouting/www/rpc/db.ts
@@ -0,0 +1,18 @@
+import Dexie, {Table} from 'dexie';
+
+export interface MatchListData {
+ id?: number;
+ data: Uint8Array;
+}
+
+export class AppDB extends Dexie {
+ matchListData!: Table<MatchListData, number>;
+
+ constructor() {
+ super('ngdexieliveQuery');
+ this.version(1).stores({
+ matchListData: 'id,data',
+ });
+ }
+}
+export const db = new AppDB();
diff --git a/scouting/www/rpc/match_list_requestor.ts b/scouting/www/rpc/match_list_requestor.ts
index fa2dcbd..0a812ce 100644
--- a/scouting/www/rpc/match_list_requestor.ts
+++ b/scouting/www/rpc/match_list_requestor.ts
@@ -6,78 +6,82 @@
Match,
RequestAllMatchesResponse,
} from '../../webserver/requests/messages/request_all_matches_response_generated';
-
+import {db, MatchListData} from './db';
const MATCH_TYPE_ORDERING = ['qm', 'ef', 'qf', 'sf', 'f'];
-
@Injectable({providedIn: 'root'})
export class MatchListRequestor {
async fetchMatchList(): Promise<Match[]> {
const builder = new Builder();
RequestAllMatches.startRequestAllMatches(builder);
builder.finish(RequestAllMatches.endRequestAllMatches(builder));
-
const buffer = builder.asUint8Array();
const res = await fetch('/requests/request/all_matches', {
method: 'POST',
body: buffer,
});
-
if (res.ok) {
const resBuffer = await res.arrayBuffer();
- const fbBuffer = new ByteBuffer(new Uint8Array(resBuffer));
- const parsedResponse =
- RequestAllMatchesResponse.getRootAsRequestAllMatchesResponse(fbBuffer);
-
- // Convert the flatbuffer list into an array. That's more useful.
- const matchList = [];
- for (let i = 0; i < parsedResponse.matchListLength(); i++) {
- matchList.push(parsedResponse.matchList(i));
- }
-
- // Sort the list so it is in chronological order.
- matchList.sort((a, b) => {
- // First sort by match type. E.g. finals are last.
- const aMatchTypeIndex = MATCH_TYPE_ORDERING.indexOf(a.compLevel());
- const bMatchTypeIndex = MATCH_TYPE_ORDERING.indexOf(b.compLevel());
- if (aMatchTypeIndex < bMatchTypeIndex) {
- return -1;
- }
- if (aMatchTypeIndex > bMatchTypeIndex) {
- return 1;
- }
- // Then sort by match number. E.g. in semi finals, all match 1 rounds
- // are done first. Then come match 2 rounds. And then, if necessary,
- // the match 3 rounds.
- const aMatchNumber = a.matchNumber();
- const bMatchNumber = b.matchNumber();
- if (aMatchNumber < bMatchNumber) {
- return -1;
- }
- if (aMatchNumber > bMatchNumber) {
- return 1;
- }
- // Lastly, sort by set number. I.e. Semi Final 1 Match 1 happens first.
- // Then comes Semi Final 2 Match 1. Then comes Semi Final 1 Match 2. Then
- // Semi Final 2 Match 2.
- const aSetNumber = a.setNumber();
- const bSetNumber = b.setNumber();
- if (aSetNumber < bSetNumber) {
- return -1;
- }
- if (aSetNumber > bSetNumber) {
- return 1;
- }
- return 0;
- });
-
- return matchList;
+ const u8Buffer = new Uint8Array(resBuffer);
+ // Cache the response.
+ await db.matchListData.put({id: 1, data: u8Buffer});
+ return this.parseMatchList(u8Buffer);
} else {
+ const cachedResult = await db.matchListData.where({id: 1}).toArray();
+ if (cachedResult && cachedResult.length == 1) {
+ const u8Buffer = cachedResult[0].data;
+ return this.parseMatchList(u8Buffer);
+ }
const resBuffer = await res.arrayBuffer();
const fbBuffer = new ByteBuffer(new Uint8Array(resBuffer));
const parsedResponse = ErrorResponse.getRootAsErrorResponse(fbBuffer);
-
const errorMessage = parsedResponse.errorMessage();
throw `Received ${res.status} ${res.statusText}: "${errorMessage}"`;
}
}
+ parseMatchList(u8Buffer: Uint8Array): Match[] {
+ const fbBuffer = new ByteBuffer(u8Buffer);
+ const parsedResponse =
+ RequestAllMatchesResponse.getRootAsRequestAllMatchesResponse(fbBuffer);
+ // Convert the flatbuffer list into an array. That's more useful.
+ const matchList = [];
+ for (let i = 0; i < parsedResponse.matchListLength(); i++) {
+ matchList.push(parsedResponse.matchList(i));
+ }
+ // Sort the list so it is in chronological order.
+ matchList.sort((a, b) => {
+ // First sort by match type. E.g. finals are last.
+ const aMatchTypeIndex = MATCH_TYPE_ORDERING.indexOf(a.compLevel());
+ const bMatchTypeIndex = MATCH_TYPE_ORDERING.indexOf(b.compLevel());
+ if (aMatchTypeIndex < bMatchTypeIndex) {
+ return -1;
+ }
+ if (aMatchTypeIndex > bMatchTypeIndex) {
+ return 1;
+ }
+ // Then sort by match number. E.g. in semi finals, all match 1 rounds
+ // are done first. Then come match 2 rounds. And then, if necessary,
+ // the match 3 rounds.
+ const aMatchNumber = a.matchNumber();
+ const bMatchNumber = b.matchNumber();
+ if (aMatchNumber < bMatchNumber) {
+ return -1;
+ }
+ if (aMatchNumber > bMatchNumber) {
+ return 1;
+ }
+ // Lastly, sort by set number. I.e. Semi Final 1 Match 1 happens first.
+ // Then comes Semi Final 2 Match 1. Then comes Semi Final 1 Match 2. Then
+ // Semi Final 2 Match 2.
+ const aSetNumber = a.setNumber();
+ const bSetNumber = b.setNumber();
+ if (aSetNumber < bSetNumber) {
+ return -1;
+ }
+ if (aSetNumber > bSetNumber) {
+ return 1;
+ }
+ return 0;
+ });
+ return matchList;
+ }
}