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;
+  }
 }